From 394b0646a3957a237e2bccc2bf29940a6e5408fa Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 8 Feb 2022 16:37:01 +1100 Subject: [PATCH 001/157] Updated the code to support additional session id prefixes --- Session.xcodeproj/project.pbxproj | 12 ++++++++---- .../Control Messages/ClosedGroupControlMessage.swift | 2 +- SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift | 2 +- .../MessageReceiver+Decryption.swift | 4 ++-- .../MessageReceiver+Handling.swift | 2 +- .../MessageSender+ClosedGroups.swift | 2 +- .../MessageSender+Encryption.swift | 2 +- SessionSnodeKit/SnodeAPI.swift | 8 ++++---- SessionSnodeKit/SnodeMessage.swift | 2 +- .../Crypto/ECKeyPair+Hexadecimal.swift | 8 ++++---- SessionUtilitiesKit/General/Data+Trimming.swift | 8 ++++---- SessionUtilitiesKit/General/IdPrefix.swift | 8 ++++++++ SessionUtilitiesKit/General/String+Trimming.swift | 8 ++++---- .../Profile Pictures/Identicon+ObjC.swift | 2 +- 14 files changed, 41 insertions(+), 29 deletions(-) create mode 100644 SessionUtilitiesKit/General/IdPrefix.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 91d6253c3..21cfe7172 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -777,15 +777,16 @@ FCB11D8C1A129A76002F93FB /* CoreMedia.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FCB11D8B1A129A76002F93FB /* CoreMedia.framework */; }; FD5D200F27AA2B6000FEA984 /* MessageRequestResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */; }; FD5D201127AA331F00FEA984 /* ConfigurationMessage+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D201027AA331F00FEA984 /* ConfigurationMessage+Convenience.swift */; }; + FD5D201E27B0D87C00FEA984 /* IdPrefix.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D201D27B0D87C00FEA984 /* IdPrefix.swift */; }; FD659AC027A7649600F12C02 /* MessageRequestsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD659ABF27A7649600F12C02 /* MessageRequestsViewController.swift */; }; - FD88BAD927A7439C00BBC442 /* MessageRequestsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */; }; - FD88BADB27A750F200BBC442 /* MessageRequestsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */; }; FD705A8C278CDB5600F16121 /* SAEScreenLockViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A8B278CDB5600F16121 /* SAEScreenLockViewController.swift */; }; FD705A8E278CE29800F16121 /* String+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A8D278CE29800F16121 /* String+Localization.swift */; }; FD705A90278CEBBC00F16121 /* ShareAppExtensionContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A8F278CEBBC00F16121 /* ShareAppExtensionContext.swift */; }; FD705A92278D051200F16121 /* ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A91278D051200F16121 /* ReusableView.swift */; }; FD705A94278D052B00F16121 /* UITableView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A93278D052B00F16121 /* UITableView+ReusableView.swift */; }; FD705A98278E9F4D00F16121 /* UIColor+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A97278E9F4D00F16121 /* UIColor+Extensions.swift */; }; + FD88BAD927A7439C00BBC442 /* MessageRequestsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */; }; + FD88BADB27A750F200BBC442 /* MessageRequestsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1811,15 +1812,16 @@ FCB11D8B1A129A76002F93FB /* CoreMedia.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreMedia.framework; path = System/Library/Frameworks/CoreMedia.framework; sourceTree = SDKROOT; }; FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestResponse.swift; sourceTree = ""; }; FD5D201027AA331F00FEA984 /* ConfigurationMessage+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConfigurationMessage+Convenience.swift"; sourceTree = ""; }; + FD5D201D27B0D87C00FEA984 /* IdPrefix.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdPrefix.swift; sourceTree = ""; }; FD659ABF27A7649600F12C02 /* MessageRequestsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsViewController.swift; sourceTree = ""; }; - FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsCell.swift; sourceTree = ""; }; - FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsMigration.swift; sourceTree = ""; }; FD705A8B278CDB5600F16121 /* SAEScreenLockViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SAEScreenLockViewController.swift; sourceTree = ""; }; FD705A8D278CE29800F16121 /* String+Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Localization.swift"; sourceTree = ""; }; FD705A8F278CEBBC00F16121 /* ShareAppExtensionContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAppExtensionContext.swift; sourceTree = ""; }; FD705A91278D051200F16121 /* ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReusableView.swift; sourceTree = ""; }; FD705A93278D052B00F16121 /* UITableView+ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITableView+ReusableView.swift"; sourceTree = ""; }; FD705A97278E9F4D00F16121 /* UIColor+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Extensions.swift"; sourceTree = ""; }; + FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsCell.swift; sourceTree = ""; }; + FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsMigration.swift; sourceTree = ""; }; FD9039443F7CB729CF71350E /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig"; sourceTree = ""; }; FEDBAE1B98C49BBE8C87F575 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; sourceTree = ""; }; FF9BA33D021B115B1F5B4E46 /* Pods-SessionMessagingKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionMessagingKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionMessagingKit/Pods-SessionMessagingKit.app store release.xcconfig"; sourceTree = ""; }; @@ -2362,6 +2364,7 @@ C38EF23D255B6D66007E1867 /* UIView+OWS.h */, C38EF23E255B6D66007E1867 /* UIView+OWS.m */, C38EF2EF255B6DBB007E1867 /* Weak.swift */, + FD5D201D27B0D87C00FEA984 /* IdPrefix.swift */, ); path = General; sourceTree = ""; @@ -4669,6 +4672,7 @@ B8F5F58325EC94A6003BF8D4 /* Collection+Subscripting.swift in Sources */, C33FDEF8255A656D00E217F9 /* Promise+Delaying.swift in Sources */, B8BC00C0257D90E30032E807 /* General.swift in Sources */, + FD5D201E27B0D87C00FEA984 /* IdPrefix.swift in Sources */, C32C5A24256DB7DB003C73A2 /* SNUserDefaults.swift in Sources */, C3D9E41F25676C870040E4F3 /* OWSPrimaryStorageProtocol.swift in Sources */, C3BBE0A72554D4DE0050F1E3 /* Promise+Retrying.swift in Sources */, diff --git a/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift b/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift index ce25b18f0..91731306c 100644 --- a/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift @@ -177,7 +177,7 @@ public final class ClosedGroupControlMessage : ControlMessage { let encryptionKeyPairAsProto = closedGroupControlMessageProto.encryptionKeyPair else { return nil } let expirationTimer = closedGroupControlMessageProto.expirationTimer do { - let encryptionKeyPair = try ECKeyPair(publicKeyData: encryptionKeyPairAsProto.publicKey.removing05PrefixIfNeeded(), privateKeyData: encryptionKeyPairAsProto.privateKey) + let encryptionKeyPair = try ECKeyPair(publicKeyData: encryptionKeyPairAsProto.publicKey.removingIdPrefixIfNeeded(), privateKeyData: encryptionKeyPairAsProto.privateKey) kind = .new(publicKey: publicKey, name: name, encryptionKeyPair: encryptionKeyPair, members: closedGroupControlMessageProto.members, admins: closedGroupControlMessageProto.admins, expirationTimer: expirationTimer) } catch { diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift b/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift index f47a30463..0e7edc2bd 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift @@ -325,7 +325,7 @@ public final class OpenGroupAPIV2 : NSObject { return nil } // Validate the message signature - let publicKey = Data(hex: sender.removing05PrefixIfNeeded()) + let publicKey = Data(hex: sender.removingIdPrefixIfNeeded()) let isValid = (try? Ed25519.verifySignature(signature, publicKey: publicKey, data: data)) ?? false guard isValid else { SNLog("Ignoring message with invalid signature.") diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift index 20846e9d5..f810e4ff9 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift @@ -6,7 +6,7 @@ extension MessageReceiver { internal static func decryptWithSessionProtocol(ciphertext: Data, using x25519KeyPair: ECKeyPair) throws -> (plaintext: Data, senderX25519PublicKey: String) { let recipientX25519PrivateKey = x25519KeyPair.privateKey - let recipientX25519PublicKey = Data(hex: x25519KeyPair.hexEncodedPublicKey.removing05PrefixIfNeeded()) + let recipientX25519PublicKey = Data(hex: x25519KeyPair.hexEncodedPublicKey.removingIdPrefixIfNeeded()) let sodium = Sodium() let signatureSize = sodium.sign.Bytes let ed25519PublicKeySize = sodium.sign.PublicKeyBytes @@ -25,6 +25,6 @@ extension MessageReceiver { // 4. ) Get the sender's X25519 public key guard let senderX25519PublicKey = sodium.sign.toX25519(ed25519PublicKey: senderED25519PublicKey) else { throw Error.decryptionFailed } - return (Data(plaintext), "05" + senderX25519PublicKey.toHexString()) + return (Data(plaintext), IdPrefix.standard.rawValue + senderX25519PublicKey.toHexString()) } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index eeaa8fbe8..67e309d1f 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -550,7 +550,7 @@ extension MessageReceiver { } let keyPair: ECKeyPair do { - keyPair = try ECKeyPair(publicKeyData: proto.publicKey.removing05PrefixIfNeeded(), privateKeyData: proto.privateKey) + keyPair = try ECKeyPair(publicKeyData: proto.publicKey.removingIdPrefixIfNeeded(), privateKeyData: proto.privateKey) } catch { return SNLog("Couldn't parse closed group encryption key pair.") } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift index 8b75e2f44..93500c512 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift @@ -8,7 +8,7 @@ extension MessageSender { var members = members let userPublicKey = getUserHexEncodedPublicKey() // Generate the group's public key - let groupPublicKey = Curve25519.generateKeyPair().hexEncodedPublicKey // Includes the "05" prefix + let groupPublicKey = Curve25519.generateKeyPair().hexEncodedPublicKey // Includes the 'IdPrefix.standard' prefix // Generate the key pair that'll be used for encryption and decryption let encryptionKeyPair = Curve25519.generateKeyPair() // Ensure the current user is included in the member list diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift index 6fee9e1c9..da74d17f5 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift @@ -5,7 +5,7 @@ extension MessageSender { internal static func encryptWithSessionProtocol(_ plaintext: Data, for recipientHexEncodedX25519PublicKey: String) throws -> Data { guard let userED25519KeyPair = SNMessagingKitConfiguration.shared.storage.getUserED25519KeyPair() else { throw Error.noUserED25519KeyPair } - let recipientX25519PublicKey = Data(hex: recipientHexEncodedX25519PublicKey.removing05PrefixIfNeeded()) + let recipientX25519PublicKey = Data(hex: recipientHexEncodedX25519PublicKey.removingIdPrefixIfNeeded()) let sodium = Sodium() let verificationData = plaintext + Data(userED25519KeyPair.publicKey) + recipientX25519PublicKey diff --git a/SessionSnodeKit/SnodeAPI.swift b/SessionSnodeKit/SnodeAPI.swift index 7602242bf..c96331eef 100644 --- a/SessionSnodeKit/SnodeAPI.swift +++ b/SessionSnodeKit/SnodeAPI.swift @@ -377,7 +377,7 @@ public final class SnodeAPI : NSObject { return Promise> { $0.fulfill(cachedSwarm) } } else { SNLog("Getting swarm for: \((publicKey == SNSnodeKitConfiguration.shared.storage.getUserPublicKey()) ? "self" : publicKey).") - let parameters: [String:Any] = [ "pubKey" : Features.useTestnet ? publicKey.removing05PrefixIfNeeded() : publicKey ] + let parameters: [String:Any] = [ "pubKey" : Features.useTestnet ? publicKey.removingIdPrefixIfNeeded() : publicKey ] return getRandomSnode().then2 { snode in attempt(maxRetryCount: 4, recoveringOn: Threading.workQueue) { invoke(.getSwarm, on: snode, associatedWith: publicKey, parameters: parameters) @@ -430,7 +430,7 @@ public final class SnodeAPI : NSObject { // let signature = sodium.sign.signature(message: Bytes(verificationData), secretKey: userED25519KeyPair.secretKey)! // Make the request let parameters: JSON = [ - "pubKey" : Features.useTestnet ? publicKey.removing05PrefixIfNeeded() : publicKey, + "pubKey" : Features.useTestnet ? publicKey.removingIdPrefixIfNeeded() : publicKey, "lastHash" : lastHash, // "timestamp" : timestamp, // "pubkey_ed25519" : ed25519PublicKey, @@ -441,7 +441,7 @@ public final class SnodeAPI : NSObject { public static func sendMessage(_ message: SnodeMessage) -> Promise> { let (promise, seal) = Promise>.pending() - let publicKey = Features.useTestnet ? message.recipient.removing05PrefixIfNeeded() : message.recipient + let publicKey = Features.useTestnet ? message.recipient.removingIdPrefixIfNeeded() : message.recipient Threading.workQueue.async { getTargetSnodes(for: publicKey).map2 { targetSnodes in let parameters = message.toJSON() @@ -464,7 +464,7 @@ public final class SnodeAPI : NSObject { let storage = SNSnodeKitConfiguration.shared.storage guard let userX25519PublicKey = storage.getUserPublicKey(), let userED25519KeyPair = storage.getUserED25519KeyPair() else { return Promise(error: Error.noKeyPair) } - let publicKey = Features.useTestnet ? publicKey.removing05PrefixIfNeeded() : publicKey + let publicKey = Features.useTestnet ? publicKey.removingIdPrefixIfNeeded() : publicKey return attempt(maxRetryCount: maxRetryCount, recoveringOn: Threading.workQueue) { getSwarm(for: publicKey).then2 { swarm -> Promise<[String:Bool]> in let snode = swarm.randomElement()! diff --git a/SessionSnodeKit/SnodeMessage.swift b/SessionSnodeKit/SnodeMessage.swift index f767a7d35..2151ec320 100644 --- a/SessionSnodeKit/SnodeMessage.swift +++ b/SessionSnodeKit/SnodeMessage.swift @@ -44,7 +44,7 @@ public final class SnodeMessage : NSObject, NSCoding { // NSObject/NSCoding conf // MARK: JSON Conversion public func toJSON() -> JSON { return [ - "pubKey" : Features.useTestnet ? recipient.removing05PrefixIfNeeded() : recipient, + "pubKey" : Features.useTestnet ? recipient.removingIdPrefixIfNeeded() : recipient, "data" : data.description, "ttl" : String(ttl), "timestamp" : String(timestamp), diff --git a/SessionUtilitiesKit/Crypto/ECKeyPair+Hexadecimal.swift b/SessionUtilitiesKit/Crypto/ECKeyPair+Hexadecimal.swift index 585d1965f..72f15c5de 100644 --- a/SessionUtilitiesKit/Crypto/ECKeyPair+Hexadecimal.swift +++ b/SessionUtilitiesKit/Crypto/ECKeyPair+Hexadecimal.swift @@ -7,15 +7,15 @@ public extension ECKeyPair { } @objc var hexEncodedPublicKey: String { - // Prefixing with "05" is necessary for what seems to be a sort of Signal public key versioning system - return "05" + publicKey.map { String(format: "%02hhx", $0) }.joined() + // Prefixing with 'IdPrefix.standard' is necessary for what seems to be a sort of Signal public key versioning system + return IdPrefix.standard.rawValue + publicKey.map { String(format: "%02hhx", $0) }.joined() } @objc static func isValidHexEncodedPublicKey(candidate: String) -> Bool { // Check that it's a valid hexadecimal encoding guard Hex.isValid(candidate) else { return false } - // Check that it has length 66 and a leading "05" - guard candidate.count == 66 && candidate.hasPrefix("05") else { return false } + // Check that it has length 66 and a valid prefix + guard candidate.count == 66 && IdPrefix.allCases.first(where: { candidate.hasPrefix($0.rawValue) }) != nil else { return false } // It appears to be a valid public key return true } diff --git a/SessionUtilitiesKit/General/Data+Trimming.swift b/SessionUtilitiesKit/General/Data+Trimming.swift index 7dfdd3667..e16ebb094 100644 --- a/SessionUtilitiesKit/General/Data+Trimming.swift +++ b/SessionUtilitiesKit/General/Data+Trimming.swift @@ -1,18 +1,18 @@ public extension Data { - func removing05PrefixIfNeeded() -> Data { + func removingIdPrefixIfNeeded() -> Data { var result = self - if result.count == 33 && result.toHexString().hasPrefix("05") { result.removeFirst() } + if result.count == 33 && IdPrefix(with: result.toHexString()) != nil { result.removeFirst() } return result } } @objc public extension NSData { - @objc func removing05PrefixIfNeeded() -> NSData { + @objc func removingIdPrefixIfNeeded() -> NSData { var result = self as Data - if result.count == 33 && result.toHexString().hasPrefix("05") { result.removeFirst() } + if result.count == 33 && IdPrefix(with: result.toHexString()) != nil { result.removeFirst() } return result as NSData } } diff --git a/SessionUtilitiesKit/General/IdPrefix.swift b/SessionUtilitiesKit/General/IdPrefix.swift new file mode 100644 index 000000000..640fe85c5 --- /dev/null +++ b/SessionUtilitiesKit/General/IdPrefix.swift @@ -0,0 +1,8 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum IdPrefix: String, CaseIterable { + case standard = "05" // Used for identified users, open groups, etc. + case blinded = "15" // Used for participants in open groups +} diff --git a/SessionUtilitiesKit/General/String+Trimming.swift b/SessionUtilitiesKit/General/String+Trimming.swift index 997ab8e04..c7e247c97 100644 --- a/SessionUtilitiesKit/General/String+Trimming.swift +++ b/SessionUtilitiesKit/General/String+Trimming.swift @@ -1,18 +1,18 @@ public extension String { - func removing05PrefixIfNeeded() -> String { + func removingIdPrefixIfNeeded() -> String { var result = self - if result.count == 66 && result.hasPrefix("05") { result.removeFirst(2) } + if result.count == 66 && IdPrefix(with: result) != nil { result.removeFirst(2) } return result } } @objc public extension NSString { - @objc func removing05PrefixIfNeeded() -> NSString { + @objc func removingIdPrefixIfNeeded() -> NSString { var result = self as String - if result.count == 66 && result.hasPrefix("05") { result.removeFirst(2) } + if result.count == 66 && IdPrefix(with: result) != nil { result.removeFirst(2) } return result as NSString } } diff --git a/SignalUtilitiesKit/Profile Pictures/Identicon+ObjC.swift b/SignalUtilitiesKit/Profile Pictures/Identicon+ObjC.swift index a2d87e318..b50ce81a2 100644 --- a/SignalUtilitiesKit/Profile Pictures/Identicon+ObjC.swift +++ b/SignalUtilitiesKit/Profile Pictures/Identicon+ObjC.swift @@ -5,7 +5,7 @@ public final class Identicon : NSObject { @objc public static func generatePlaceholderIcon(seed: String, text: String, size: CGFloat) -> UIImage { let icon = PlaceholderIcon(seed: seed) var content = text - if content.count > 2 && content.hasPrefix("05") { + if content.count > 2 && IdPrefix(with: content) != nil { content.removeFirst(2) } let layer = icon.generateLayer(with: size, text: content.substring(to: 1)) From 49dd52a1fbfafb38995be5e3f1d2afccfc34cd11 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 8 Feb 2022 17:01:37 +1100 Subject: [PATCH 002/157] Added code to support generating a derived key for id blinding --- .../Utilities/Sodium+Conversion.swift | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/SessionMessagingKit/Utilities/Sodium+Conversion.swift b/SessionMessagingKit/Utilities/Sodium+Conversion.swift index c522bdf92..9ad03dc67 100644 --- a/SessionMessagingKit/Utilities/Sodium+Conversion.swift +++ b/SessionMessagingKit/Utilities/Sodium+Conversion.swift @@ -39,3 +39,29 @@ extension Sign { return x25519SecretKey } } + +extension Sodium { + public typealias SOGSDerivedKey = Data + + private static let publicKeyBytes: Int = Int(crypto_scalarmult_bytes()) + private static let sharedSecretBytes: Int = Int(crypto_scalarmult_bytes()) + + public func derivedKey(serverPublicKeyBytes: [UInt8], userKeyBytes: [UInt8]) -> SOGSDerivedKey? { + guard serverPublicKeyBytes.count == Sodium.publicKeyBytes && userKeyBytes.count == Sodium.publicKeyBytes else { return nil } + + let sharedSecretPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.sharedSecretBytes) + let result = userKeyBytes.withUnsafeBytes { (userPublicKeyPtr: UnsafeRawBufferPointer) in + return serverPublicKeyBytes.withUnsafeBytes { (serverPublicKeyPtr: UnsafeRawBufferPointer) -> Int32 in + guard let serverKeyBaseAddress: UnsafePointer = serverPublicKeyPtr.baseAddress?.assumingMemoryBound(to: UInt8.self), let userKeyBaseAddress: UnsafePointer = userPublicKeyPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return -1 + } + + return crypto_scalarmult(sharedSecretPtr, serverKeyBaseAddress, userKeyBaseAddress) + } + } + + guard result == 0 else { return nil } + + return Data(bytes: sharedSecretPtr, count: Sodium.sharedSecretBytes) + } +} From 2284375fc0e16bf39009cf442a3ed563a9a474dc Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 10 Feb 2022 11:17:41 +1100 Subject: [PATCH 003/157] Started work on updated SOGS support Split the OpenGroupAPIV2 into separate files Started working on the new auth and blinded-id approaches (new auth working with un-blinded id suggesting blinded-id code is incorrect) Updated the SOGS request/response types to use Codable Updated the SOGS Request type to use enums instead of strings for keys (to reduce likelihood of typos breaking things) Updated SessionMessagingKit to use Codable and JSONEncoder/JSONDecoder instead of the legacy JSONSerialization Cleaned up some naming conventions in the SessionMessagingKit (calling a URLRequest body 'parameters' is very confusing...) Removed the custom TSRequest class (just using standard URLRequest everywhere instead) Added a number of extension functions to enable some more functional-coding styles Added extensions to Sodium methods to allow scalar multiplication and the ability to hash providing a salt and a personalisation value (both needed for new SOGS auth) Fixed an issue where the legacy auth for SOGS could crash due to threading issues (multiple threads accessing the same variable) Fixed an issue where if you were in two rooms in a single SOGS and deleted one of them, the other room would stop getting updates as the server public key was getting removed --- Session.xcodeproj/project.pbxproj | 212 +++- Session/Open Groups/JoinOpenGroupVC.swift | 2 +- .../Open Groups/OpenGroupSuggestionGrid.swift | 6 +- .../Common Networking/Header.swift | 23 + .../Models/FileDownloadResponse.swift | 35 + .../Models/FileUploadBody.swift | 7 + .../Models/FileUploadResponse.swift | 11 + .../Common Networking/QueryParam.swift | 8 + .../File Server/FileServerAPIV2.swift | 103 +- .../File Server/Models/VersionResponse.swift | 13 + .../Jobs/NotifyPNServerJob.swift | 27 +- .../Models/AuthTokenResponse.swift | 46 + .../Open Groups/Models/CompactPollBody.swift | 27 + .../Models/CompactPollResponse.swift | 25 + .../Models/DeletedMessagesResponse.swift | 13 + .../Open Groups/Models/Deletion.swift | 23 + .../Open Groups/Models/GetInfoResponse.swift | 9 + .../Models/MemberCountResponse.swift | 13 + .../Models/ModeratorsResponse.swift | 9 + .../Models/OpenGroupMessageV2.swift | 72 ++ .../{ => Models}/OpenGroupV2.swift | 2 + .../Open Groups/Models/PublicKeyBody.swift | 13 + .../Open Groups/Models/RoomInfo.swift | 17 + .../Open Groups/Models/RoomsResponse.swift | 9 + .../Open Groups/OpenGroupAPIV2.swift | 1009 +++++++++++------ .../Open Groups/OpenGroupManagerV2.swift | 8 +- .../Open Groups/OpenGroupMessageV2.swift | 38 - .../Open Groups/Types/Endpoint.swift | 83 ++ .../Open Groups/Types/Error.swift | 25 + .../Types/NonceGenerator16Byte.swift | 9 + .../Open Groups/Types/Personalization.swift | 15 + .../Open Groups/Types/Request.swift | 57 + .../Sending & Receiving/MessageSender.swift | 18 +- .../Models/RegisterResponse.swift | 11 + .../Models/UnregisterResponse.swift | 11 + .../Notifications/PushNotificationAPI.swift | 87 +- .../Pollers/OpenGroupPollerV2.swift | 44 +- SessionMessagingKit/Utilities/Atomic.swift | 27 + .../Utilities/ECKeyPair+Conversion.swift | 34 + ...onversion.swift => Sodium+Utilities.swift} | 46 +- SessionSnodeKit/OnionRequestAPI.swift | 79 +- .../Utilities/String+Trimming.swift | 12 +- .../Crypto/ECKeyPair+Hexadecimal.swift | 6 + .../General/Array+Description.swift | 7 - .../General/Array+Utilities.swift | 23 + .../General/Data+Trimming.swift | 18 - .../General/Data+Utilities.swift | 48 + .../General/Dictionary+Description.swift | 13 - .../General/Dictionary+Utilities.swift | 42 + SessionUtilitiesKit/General/IdPrefix.swift | 12 + .../General/String+Encoding.swift | 18 + .../Meta/SessionUtilitiesKit.h | 1 - SessionUtilitiesKit/Networking/TSRequest.h | 29 - SessionUtilitiesKit/Networking/TSRequest.m | 64 -- .../MessageSender+Convenience.swift | 29 +- 55 files changed, 1927 insertions(+), 721 deletions(-) create mode 100644 SessionMessagingKit/Common Networking/Header.swift create mode 100644 SessionMessagingKit/Common Networking/Models/FileDownloadResponse.swift create mode 100644 SessionMessagingKit/Common Networking/Models/FileUploadBody.swift create mode 100644 SessionMessagingKit/Common Networking/Models/FileUploadResponse.swift create mode 100644 SessionMessagingKit/Common Networking/QueryParam.swift create mode 100644 SessionMessagingKit/File Server/Models/VersionResponse.swift create mode 100644 SessionMessagingKit/Open Groups/Models/AuthTokenResponse.swift create mode 100644 SessionMessagingKit/Open Groups/Models/CompactPollBody.swift create mode 100644 SessionMessagingKit/Open Groups/Models/CompactPollResponse.swift create mode 100644 SessionMessagingKit/Open Groups/Models/DeletedMessagesResponse.swift create mode 100644 SessionMessagingKit/Open Groups/Models/Deletion.swift create mode 100644 SessionMessagingKit/Open Groups/Models/GetInfoResponse.swift create mode 100644 SessionMessagingKit/Open Groups/Models/MemberCountResponse.swift create mode 100644 SessionMessagingKit/Open Groups/Models/ModeratorsResponse.swift create mode 100644 SessionMessagingKit/Open Groups/Models/OpenGroupMessageV2.swift rename SessionMessagingKit/Open Groups/{ => Models}/OpenGroupV2.swift (97%) create mode 100644 SessionMessagingKit/Open Groups/Models/PublicKeyBody.swift create mode 100644 SessionMessagingKit/Open Groups/Models/RoomInfo.swift create mode 100644 SessionMessagingKit/Open Groups/Models/RoomsResponse.swift delete mode 100644 SessionMessagingKit/Open Groups/OpenGroupMessageV2.swift create mode 100644 SessionMessagingKit/Open Groups/Types/Endpoint.swift create mode 100644 SessionMessagingKit/Open Groups/Types/Error.swift create mode 100644 SessionMessagingKit/Open Groups/Types/NonceGenerator16Byte.swift create mode 100644 SessionMessagingKit/Open Groups/Types/Personalization.swift create mode 100644 SessionMessagingKit/Open Groups/Types/Request.swift create mode 100644 SessionMessagingKit/Sending & Receiving/Notifications/Models/RegisterResponse.swift create mode 100644 SessionMessagingKit/Sending & Receiving/Notifications/Models/UnregisterResponse.swift create mode 100644 SessionMessagingKit/Utilities/Atomic.swift create mode 100644 SessionMessagingKit/Utilities/ECKeyPair+Conversion.swift rename SessionMessagingKit/Utilities/{Sodium+Conversion.swift => Sodium+Utilities.swift} (55%) delete mode 100644 SessionUtilitiesKit/General/Array+Description.swift create mode 100644 SessionUtilitiesKit/General/Array+Utilities.swift delete mode 100644 SessionUtilitiesKit/General/Data+Trimming.swift create mode 100644 SessionUtilitiesKit/General/Data+Utilities.swift delete mode 100644 SessionUtilitiesKit/General/Dictionary+Description.swift create mode 100644 SessionUtilitiesKit/General/Dictionary+Utilities.swift create mode 100644 SessionUtilitiesKit/General/String+Encoding.swift delete mode 100644 SessionUtilitiesKit/Networking/TSRequest.h delete mode 100644 SessionUtilitiesKit/Networking/TSRequest.m diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 21cfe7172..b20cbbf47 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -196,7 +196,7 @@ B8569AC325CB5D2900DBA3DB /* ConversationVC+Interaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8569AC225CB5D2900DBA3DB /* ConversationVC+Interaction.swift */; }; B8569AD325CBA13D00DBA3DB /* MediaTextOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8569AD225CBA13D00DBA3DB /* MediaTextOverlayView.swift */; }; B8569AE325CBB19A00DBA3DB /* DocumentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8569AE225CBB19A00DBA3DB /* DocumentView.swift */; }; - B866CE112581C1A900535CC4 /* Sodium+Conversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E7134E251C867C009649BB /* Sodium+Conversion.swift */; }; + B866CE112581C1A900535CC4 /* Sodium+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E7134E251C867C009649BB /* Sodium+Utilities.swift */; }; B86BD08423399ACF000F5AE3 /* Modal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B86BD08323399ACF000F5AE3 /* Modal.swift */; }; B86BD08623399CEF000F5AE3 /* SeedModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B86BD08523399CEF000F5AE3 /* SeedModal.swift */; }; B875885A264503A6000E60D0 /* JoinOpenGroupModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8758859264503A6000E60D0 /* JoinOpenGroupModal.swift */; }; @@ -238,7 +238,7 @@ B893063F2383961A005EAA8E /* ScanQRCodeWrapperVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B893063E2383961A005EAA8E /* ScanQRCodeWrapperVC.swift */; }; B894D0752339EDCF00B4D94D /* NukeDataModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B894D0742339EDCF00B4D94D /* NukeDataModal.swift */; }; B897621C25D201F7004F83B2 /* ScrollToBottomButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B897621B25D201F7004F83B2 /* ScrollToBottomButton.swift */; }; - B8AE75A425A6C6A6001A84D2 /* Data+Trimming.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8AE75A325A6C6A6001A84D2 /* Data+Trimming.swift */; }; + B8AE75A425A6C6A6001A84D2 /* Data+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8AE75A325A6C6A6001A84D2 /* Data+Utilities.swift */; }; B8AE760B25ABFB5A001A84D2 /* GeneralUtilities.m in Sources */ = {isa = PBXBuildFile; fileRef = B8AE760A25ABFB5A001A84D2 /* GeneralUtilities.m */; }; B8AE761425ABFBB9001A84D2 /* GeneralUtilities.h in Headers */ = {isa = PBXBuildFile; fileRef = B8AE760925ABFB00001A84D2 /* GeneralUtilities.h */; settings = {ATTRIBUTES = (Public, ); }; }; B8AF4BB426A5204600583500 /* SendSeedModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8AF4BB326A5204600583500 /* SendSeedModal.swift */; }; @@ -300,7 +300,6 @@ C31D1DE32521718E005D4DA8 /* UserSelectionVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31D1DE22521718E005D4DA8 /* UserSelectionVC.swift */; }; C31D1DE9252172D4005D4DA8 /* ContactUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31D1DE8252172D4005D4DA8 /* ContactUtilities.swift */; }; C31FFE57254A5FFE00F19441 /* KeyPairUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31FFE56254A5FFE00F19441 /* KeyPairUtilities.swift */; }; - C3227FF6260AAD66006EA627 /* OpenGroupMessageV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3227FF5260AAD66006EA627 /* OpenGroupMessageV2.swift */; }; C32824D325C9F9790062D0A7 /* SessionSnodeKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A59F255385C100C340D1 /* SessionSnodeKit.framework */; }; C328250F25CA06020062D0A7 /* VoiceMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C328250E25CA06020062D0A7 /* VoiceMessageView.swift */; }; C328251F25CA3A900062D0A7 /* QuoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C328251E25CA3A900062D0A7 /* QuoteView.swift */; }; @@ -510,8 +509,6 @@ C352A3772557864000338F3E /* NSTimer+Proxying.h in Headers */ = {isa = PBXBuildFile; fileRef = C352A3762557859C00338F3E /* NSTimer+Proxying.h */; settings = {ATTRIBUTES = (Public, ); }; }; C352A3892557876500338F3E /* JobQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A3882557876500338F3E /* JobQueue.swift */; }; C352A3932557883D00338F3E /* JobDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A3922557883D00338F3E /* JobDelegate.swift */; }; - C352A3A62557B60D00338F3E /* TSRequest.m in Sources */ = {isa = PBXBuildFile; fileRef = C352A3A52557B60D00338F3E /* TSRequest.m */; }; - C352A3B72557B6ED00338F3E /* TSRequest.h in Headers */ = {isa = PBXBuildFile; fileRef = C352A3A42557B5F000338F3E /* TSRequest.h */; settings = {ATTRIBUTES = (Public, ); }; }; C3548F0624456447009433A8 /* PNModeVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3548F0524456447009433A8 /* PNModeVC.swift */; }; C3548F0824456AB6009433A8 /* UIView+Wrapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3548F0724456AB6009433A8 /* UIView+Wrapping.swift */; }; C354E75A23FE2A7600CE22E3 /* BaseVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C354E75923FE2A7600CE22E3 /* BaseVC.swift */; }; @@ -676,7 +673,7 @@ C3A7219A2558C1660043A11F /* AnyPromise+Conversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A721992558C1660043A11F /* AnyPromise+Conversion.swift */; }; C3A7225E2558C38D0043A11F /* Promise+Retaining.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A7225D2558C38D0043A11F /* Promise+Retaining.swift */; }; C3A76A8D25DB83F90074CB90 /* PermissionMissingModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A76A8C25DB83F90074CB90 /* PermissionMissingModal.swift */; }; - C3AABDDF2553ECF00042FF4C /* Array+Description.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D12553860800C340D1 /* Array+Description.swift */; }; + C3AABDDF2553ECF00042FF4C /* Array+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D12553860800C340D1 /* Array+Utilities.swift */; }; C3AAFFF225AE99710089E6DD /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3AAFFF125AE99710089E6DD /* AppDelegate.swift */; }; C3ADC66126426688005F1414 /* ShareVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3ADC66026426688005F1414 /* ShareVC.swift */; }; C3BBE0762554CDA60050F1E3 /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3BBE0752554CDA60050F1E3 /* Configuration.swift */; }; @@ -684,7 +681,7 @@ C3BBE0A72554D4DE0050F1E3 /* Promise+Retrying.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D62553860B00C340D1 /* Promise+Retrying.swift */; }; C3BBE0A82554D4DE0050F1E3 /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D92553860B00C340D1 /* JSON.swift */; }; C3BBE0A92554D4DE0050F1E3 /* HTTP.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5BC255385EE00C340D1 /* HTTP.swift */; }; - C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Description.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D52553860A00C340D1 /* Dictionary+Description.swift */; }; + C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D52553860A00C340D1 /* Dictionary+Utilities.swift */; }; C3BBE0B52554F0E10050F1E3 /* ProofOfWork.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3BBE0B42554F0E10050F1E3 /* ProofOfWork.swift */; }; C3BBE0C72554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3BBE0C62554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift */; }; C3C2A5A3255385C100C340D1 /* SessionSnodeKit.h in Headers */ = {isa = PBXBuildFile; fileRef = C3C2A5A1255385C100C340D1 /* SessionSnodeKit.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -754,7 +751,6 @@ C3D9E52725677DF20040E4F3 /* OWSThumbnailService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAF1255A580500E217F9 /* OWSThumbnailService.swift */; }; C3DA9C0725AE7396008F7C7E /* ConfigurationMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DA9C0625AE7396008F7C7E /* ConfigurationMessage.swift */; }; C3DAB3242480CB2B00725F25 /* SRCopyableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DAB3232480CB2A00725F25 /* SRCopyableLabel.swift */; }; - C3DB6695260AC923001EFC55 /* OpenGroupV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DB6694260AC923001EFC55 /* OpenGroupV2.swift */; }; C3DB66AC260ACA42001EFC55 /* OpenGroupManagerV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DB66AB260ACA42001EFC55 /* OpenGroupManagerV2.swift */; }; C3DB66C3260ACCE6001EFC55 /* OpenGroupPollerV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DB66C2260ACCE6001EFC55 /* OpenGroupPollerV2.swift */; }; C3DB66CC260AF1F3001EFC55 /* OpenGroupAPIV2+ObjC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DB66CB260AF1F3001EFC55 /* OpenGroupAPIV2+ObjC.swift */; }; @@ -778,6 +774,8 @@ FD5D200F27AA2B6000FEA984 /* MessageRequestResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */; }; FD5D201127AA331F00FEA984 /* ConfigurationMessage+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D201027AA331F00FEA984 /* ConfigurationMessage+Convenience.swift */; }; FD5D201E27B0D87C00FEA984 /* IdPrefix.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D201D27B0D87C00FEA984 /* IdPrefix.swift */; }; + FD5D202027B0E67900FEA984 /* String+Encoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D201F27B0E67800FEA984 /* String+Encoding.swift */; }; + FD5D202227B1D74F00FEA984 /* ECKeyPair+Conversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D202127B1D74F00FEA984 /* ECKeyPair+Conversion.swift */; }; FD659AC027A7649600F12C02 /* MessageRequestsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD659ABF27A7649600F12C02 /* MessageRequestsViewController.swift */; }; FD705A8C278CDB5600F16121 /* SAEScreenLockViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A8B278CDB5600F16121 /* SAEScreenLockViewController.swift */; }; FD705A8E278CE29800F16121 /* String+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A8D278CE29800F16121 /* String+Localization.swift */; }; @@ -787,6 +785,33 @@ FD705A98278E9F4D00F16121 /* UIColor+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A97278E9F4D00F16121 /* UIColor+Extensions.swift */; }; FD88BAD927A7439C00BBC442 /* MessageRequestsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */; }; FD88BADB27A750F200BBC442 /* MessageRequestsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */; }; + FDC4380927B31D4E00C60D73 /* Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4380827B31D4E00C60D73 /* Error.swift */; }; + FDC4380B27B31D7E00C60D73 /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4380A27B31D7E00C60D73 /* Request.swift */; }; + FDC4381527B329CE00C60D73 /* NonceGenerator16Byte.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381427B329CE00C60D73 /* NonceGenerator16Byte.swift */; }; + FDC4381727B32EC700C60D73 /* Personalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381627B32EC700C60D73 /* Personalization.swift */; }; + FDC4381A27B34EBA00C60D73 /* CompactPollBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381927B34EBA00C60D73 /* CompactPollBody.swift */; }; + FDC4381C27B354AC00C60D73 /* PublicKeyBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381B27B354AC00C60D73 /* PublicKeyBody.swift */; }; + FDC4382027B36ADC00C60D73 /* Endpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381F27B36ADC00C60D73 /* Endpoint.swift */; }; + FDC4382627B37F6900C60D73 /* DeletedMessagesResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382527B37F6900C60D73 /* DeletedMessagesResponse.swift */; }; + FDC4382827B37FD300C60D73 /* ModeratorsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382727B37FD300C60D73 /* ModeratorsResponse.swift */; }; + FDC4382A27B3802D00C60D73 /* RoomsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382927B3802D00C60D73 /* RoomsResponse.swift */; }; + FDC4382C27B380E300C60D73 /* MemberCountResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382B27B380E300C60D73 /* MemberCountResponse.swift */; }; + FDC4382F27B383AF00C60D73 /* UnregisterResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382E27B383AF00C60D73 /* UnregisterResponse.swift */; }; + FDC4383127B3841C00C60D73 /* RegisterResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4383027B3841C00C60D73 /* RegisterResponse.swift */; }; + FDC4383827B3863200C60D73 /* VersionResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4383727B3863200C60D73 /* VersionResponse.swift */; }; + FDC4383A27B4696200C60D73 /* AuthTokenResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4383927B4696200C60D73 /* AuthTokenResponse.swift */; }; + FDC4383E27B4708600C60D73 /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4383D27B4708600C60D73 /* Atomic.swift */; }; + FDC4384027B4746D00C60D73 /* GetInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4383F27B4746D00C60D73 /* GetInfoResponse.swift */; }; + FDC4384727B47F4D00C60D73 /* OpenGroupMessageV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384327B47F4D00C60D73 /* OpenGroupMessageV2.swift */; }; + FDC4384827B47F4D00C60D73 /* RoomInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384427B47F4D00C60D73 /* RoomInfo.swift */; }; + FDC4384927B47F4D00C60D73 /* CompactPollResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384527B47F4D00C60D73 /* CompactPollResponse.swift */; }; + FDC4384A27B47F4D00C60D73 /* Deletion.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384627B47F4D00C60D73 /* Deletion.swift */; }; + FDC4384C27B47F7700C60D73 /* OpenGroupV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384B27B47F7700C60D73 /* OpenGroupV2.swift */; }; + FDC4384F27B4804F00C60D73 /* Header.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384E27B4804F00C60D73 /* Header.swift */; }; + FDC4385127B4807400C60D73 /* QueryParam.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4385027B4807400C60D73 /* QueryParam.swift */; }; + FDC4385727B484B700C60D73 /* FileUploadResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4385627B484B700C60D73 /* FileUploadResponse.swift */; }; + FDC4385927B484E800C60D73 /* FileUploadBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4385827B484E800C60D73 /* FileUploadBody.swift */; }; + FDC4385B27B485DE00C60D73 /* FileDownloadResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4385A27B485DE00C60D73 /* FileDownloadResponse.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1236,7 +1261,7 @@ B893063E2383961A005EAA8E /* ScanQRCodeWrapperVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanQRCodeWrapperVC.swift; sourceTree = ""; }; B894D0742339EDCF00B4D94D /* NukeDataModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NukeDataModal.swift; sourceTree = ""; }; B897621B25D201F7004F83B2 /* ScrollToBottomButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollToBottomButton.swift; sourceTree = ""; }; - B8AE75A325A6C6A6001A84D2 /* Data+Trimming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Trimming.swift"; sourceTree = ""; }; + B8AE75A325A6C6A6001A84D2 /* Data+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Utilities.swift"; sourceTree = ""; }; B8AE760925ABFB00001A84D2 /* GeneralUtilities.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneralUtilities.h; sourceTree = ""; }; B8AE760A25ABFB5A001A84D2 /* GeneralUtilities.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = GeneralUtilities.m; sourceTree = ""; }; B8AF4BB326A5204600583500 /* SendSeedModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendSeedModal.swift; sourceTree = ""; }; @@ -1314,7 +1339,6 @@ C31D1DE22521718E005D4DA8 /* UserSelectionVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSelectionVC.swift; sourceTree = ""; }; C31D1DE8252172D4005D4DA8 /* ContactUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactUtilities.swift; sourceTree = ""; }; C31FFE56254A5FFE00F19441 /* KeyPairUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyPairUtilities.swift; sourceTree = ""; }; - C3227FF5260AAD66006EA627 /* OpenGroupMessageV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupMessageV2.swift; sourceTree = ""; }; C328250E25CA06020062D0A7 /* VoiceMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageView.swift; sourceTree = ""; }; C328251E25CA3A900062D0A7 /* QuoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuoteView.swift; sourceTree = ""; }; C328252F25CA55360062D0A7 /* ContextMenuWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuWindow.swift; sourceTree = ""; }; @@ -1525,8 +1549,6 @@ C352A3762557859C00338F3E /* NSTimer+Proxying.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSTimer+Proxying.h"; sourceTree = ""; }; C352A3882557876500338F3E /* JobQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobQueue.swift; sourceTree = ""; }; C352A3922557883D00338F3E /* JobDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobDelegate.swift; sourceTree = ""; }; - C352A3A42557B5F000338F3E /* TSRequest.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = TSRequest.h; sourceTree = ""; }; - C352A3A52557B60D00338F3E /* TSRequest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = TSRequest.m; sourceTree = ""; }; C353F8F8244809150011121A /* PNOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PNOptionView.swift; sourceTree = ""; }; C3548F0524456447009433A8 /* PNModeVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PNModeVC.swift; sourceTree = ""; }; C3548F0724456AB6009433A8 /* UIView+Wrapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Wrapping.swift"; sourceTree = ""; }; @@ -1731,11 +1753,11 @@ C3C2A5CE2553860700C340D1 /* Logging.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Logging.swift; sourceTree = ""; }; C3C2A5CF2553860700C340D1 /* Promise+Hashing.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Promise+Hashing.swift"; sourceTree = ""; }; C3C2A5D02553860800C340D1 /* Promise+Threading.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Promise+Threading.swift"; sourceTree = ""; }; - C3C2A5D12553860800C340D1 /* Array+Description.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Array+Description.swift"; sourceTree = ""; }; + C3C2A5D12553860800C340D1 /* Array+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Array+Utilities.swift"; sourceTree = ""; }; C3C2A5D22553860900C340D1 /* String+Trimming.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Trimming.swift"; sourceTree = ""; }; C3C2A5D32553860900C340D1 /* Promise+Delaying.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Promise+Delaying.swift"; sourceTree = ""; }; C3C2A5D42553860A00C340D1 /* Threading.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Threading.swift; sourceTree = ""; }; - C3C2A5D52553860A00C340D1 /* Dictionary+Description.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Dictionary+Description.swift"; sourceTree = ""; }; + C3C2A5D52553860A00C340D1 /* Dictionary+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Dictionary+Utilities.swift"; sourceTree = ""; }; C3C2A5D62553860B00C340D1 /* Promise+Retrying.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Promise+Retrying.swift"; sourceTree = ""; }; C3C2A5D72553860B00C340D1 /* AESGCM.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AESGCM.swift; sourceTree = ""; }; C3C2A5D82553860B00C340D1 /* Data+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Data+Utilities.swift"; sourceTree = ""; }; @@ -1767,13 +1789,12 @@ C3D9E43025676D3D0040E4F3 /* Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = ""; }; C3DA9C0625AE7396008F7C7E /* ConfigurationMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationMessage.swift; sourceTree = ""; }; C3DAB3232480CB2A00725F25 /* SRCopyableLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SRCopyableLabel.swift; sourceTree = ""; }; - C3DB6694260AC923001EFC55 /* OpenGroupV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupV2.swift; sourceTree = ""; }; C3DB66AB260ACA42001EFC55 /* OpenGroupManagerV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupManagerV2.swift; sourceTree = ""; }; C3DB66C2260ACCE6001EFC55 /* OpenGroupPollerV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupPollerV2.swift; sourceTree = ""; }; C3DB66CB260AF1F3001EFC55 /* OpenGroupAPIV2+ObjC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OpenGroupAPIV2+ObjC.swift"; sourceTree = ""; }; C3DFFAC523E96F0D0058DAF8 /* Sheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sheet.swift; sourceTree = ""; }; C3E5C2F9251DBABB0040DFFC /* EditClosedGroupVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditClosedGroupVC.swift; sourceTree = ""; }; - C3E7134E251C867C009649BB /* Sodium+Conversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sodium+Conversion.swift"; sourceTree = ""; }; + C3E7134E251C867C009649BB /* Sodium+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sodium+Utilities.swift"; sourceTree = ""; }; C3ECBF7A257056B700EA7FCE /* Threading.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Threading.swift; sourceTree = ""; }; C3F0A52F255C80BC007BE2A3 /* NoopNotificationsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoopNotificationsManager.swift; sourceTree = ""; }; C3F0A5B2255C915C007BE2A3 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; @@ -1813,6 +1834,8 @@ FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestResponse.swift; sourceTree = ""; }; FD5D201027AA331F00FEA984 /* ConfigurationMessage+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConfigurationMessage+Convenience.swift"; sourceTree = ""; }; FD5D201D27B0D87C00FEA984 /* IdPrefix.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdPrefix.swift; sourceTree = ""; }; + FD5D201F27B0E67800FEA984 /* String+Encoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Encoding.swift"; sourceTree = ""; }; + FD5D202127B1D74F00FEA984 /* ECKeyPair+Conversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ECKeyPair+Conversion.swift"; sourceTree = ""; }; FD659ABF27A7649600F12C02 /* MessageRequestsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsViewController.swift; sourceTree = ""; }; FD705A8B278CDB5600F16121 /* SAEScreenLockViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SAEScreenLockViewController.swift; sourceTree = ""; }; FD705A8D278CE29800F16121 /* String+Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Localization.swift"; sourceTree = ""; }; @@ -1823,6 +1846,33 @@ FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsCell.swift; sourceTree = ""; }; FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsMigration.swift; sourceTree = ""; }; FD9039443F7CB729CF71350E /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig"; sourceTree = ""; }; + FDC4380827B31D4E00C60D73 /* Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Error.swift; sourceTree = ""; }; + FDC4380A27B31D7E00C60D73 /* Request.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Request.swift; sourceTree = ""; }; + FDC4381427B329CE00C60D73 /* NonceGenerator16Byte.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonceGenerator16Byte.swift; sourceTree = ""; }; + FDC4381627B32EC700C60D73 /* Personalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Personalization.swift; sourceTree = ""; }; + FDC4381927B34EBA00C60D73 /* CompactPollBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactPollBody.swift; sourceTree = ""; }; + FDC4381B27B354AC00C60D73 /* PublicKeyBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicKeyBody.swift; sourceTree = ""; }; + FDC4381F27B36ADC00C60D73 /* Endpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Endpoint.swift; sourceTree = ""; }; + FDC4382527B37F6900C60D73 /* DeletedMessagesResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedMessagesResponse.swift; sourceTree = ""; }; + FDC4382727B37FD300C60D73 /* ModeratorsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModeratorsResponse.swift; sourceTree = ""; }; + FDC4382927B3802D00C60D73 /* RoomsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomsResponse.swift; sourceTree = ""; }; + FDC4382B27B380E300C60D73 /* MemberCountResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberCountResponse.swift; sourceTree = ""; }; + FDC4382E27B383AF00C60D73 /* UnregisterResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnregisterResponse.swift; sourceTree = ""; }; + FDC4383027B3841C00C60D73 /* RegisterResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterResponse.swift; sourceTree = ""; }; + FDC4383727B3863200C60D73 /* VersionResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionResponse.swift; sourceTree = ""; }; + FDC4383927B4696200C60D73 /* AuthTokenResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthTokenResponse.swift; sourceTree = ""; }; + FDC4383D27B4708600C60D73 /* Atomic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = ""; }; + FDC4383F27B4746D00C60D73 /* GetInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetInfoResponse.swift; sourceTree = ""; }; + FDC4384327B47F4D00C60D73 /* OpenGroupMessageV2.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OpenGroupMessageV2.swift; sourceTree = ""; }; + FDC4384427B47F4D00C60D73 /* RoomInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RoomInfo.swift; sourceTree = ""; }; + FDC4384527B47F4D00C60D73 /* CompactPollResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CompactPollResponse.swift; sourceTree = ""; }; + FDC4384627B47F4D00C60D73 /* Deletion.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Deletion.swift; sourceTree = ""; }; + FDC4384B27B47F7700C60D73 /* OpenGroupV2.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OpenGroupV2.swift; sourceTree = ""; }; + FDC4384E27B4804F00C60D73 /* Header.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Header.swift; sourceTree = ""; }; + FDC4385027B4807400C60D73 /* QueryParam.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryParam.swift; sourceTree = ""; }; + FDC4385627B484B700C60D73 /* FileUploadResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUploadResponse.swift; sourceTree = ""; }; + FDC4385827B484E800C60D73 /* FileUploadBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUploadBody.swift; sourceTree = ""; }; + FDC4385A27B485DE00C60D73 /* FileDownloadResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDownloadResponse.swift; sourceTree = ""; }; FEDBAE1B98C49BBE8C87F575 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; sourceTree = ""; }; FF9BA33D021B115B1F5B4E46 /* Pods-SessionMessagingKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionMessagingKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionMessagingKit/Pods-SessionMessagingKit.app store release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -2303,8 +2353,6 @@ B8FF8EA525C11FEF004D1F22 /* IPv4.swift */, C3C2A5D92553860B00C340D1 /* JSON.swift */, C33FDAF2255A580500E217F9 /* ProxiedContentDownloader.swift */, - C352A3A42557B5F000338F3E /* TSRequest.h */, - C352A3A52557B60D00338F3E /* TSRequest.m */, ); path = Networking; sourceTree = ""; @@ -2330,11 +2378,11 @@ children = ( C33FDB8A255A581200E217F9 /* AppContext.h */, C33FDB85255A581100E217F9 /* AppContext.m */, - C3C2A5D12553860800C340D1 /* Array+Description.swift */, + C3C2A5D12553860800C340D1 /* Array+Utilities.swift */, C33FDAA8255A57FF00E217F9 /* BuildConfiguration.swift */, B8F5F58225EC94A6003BF8D4 /* Collection+Subscripting.swift */, - B8AE75A325A6C6A6001A84D2 /* Data+Trimming.swift */, - C3C2A5D52553860A00C340D1 /* Dictionary+Description.swift */, + B8AE75A325A6C6A6001A84D2 /* Data+Utilities.swift */, + C3C2A5D52553860A00C340D1 /* Dictionary+Utilities.swift */, B87EF18026377A1D00124B3C /* Features.swift */, B8BC00BF257D90E30032E807 /* General.swift */, C3C2A5CE2553860700C340D1 /* Logging.swift */, @@ -2357,6 +2405,7 @@ C33FDB3F255A580C00E217F9 /* String+SSK.swift */, C3C2AC2D2553CBEB00C340D1 /* String+Trimming.swift */, FD705A8D278CE29800F16121 /* String+Localization.swift */, + FD5D201F27B0E67800FEA984 /* String+Encoding.swift */, C38EF237255B6D65007E1867 /* UIDevice+featureSupport.swift */, C35D0DB425AE5F1200B6BF49 /* UIEdgeInsets.swift */, FD705A91278D051200F16121 /* ReusableView.swift */, @@ -3043,6 +3092,7 @@ C379DC6825672B5E0002D4EB /* Notifications */ = { isa = PBXGroup; children = ( + FDC4382D27B383A600C60D73 /* Models */, C33FDB7A255A581000E217F9 /* NotificationsProtocol.h */, C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */, ); @@ -3182,11 +3232,11 @@ C3A721332558BDDF0043A11F /* Open Groups */ = { isa = PBXGroup; children = ( - C3DB6694260AC923001EFC55 /* OpenGroupV2.swift */, + FDC4381827B34EAD00C60D73 /* Models */, + FDC4380727B31D3A00C60D73 /* Types */, B88FA7B726045D100049422F /* OpenGroupAPIV2.swift */, C3DB66CB260AF1F3001EFC55 /* OpenGroupAPIV2+ObjC.swift */, C3DB66AB260ACA42001EFC55 /* OpenGroupManagerV2.swift */, - C3227FF5260AAD66006EA627 /* OpenGroupMessageV2.swift */, ); path = "Open Groups"; sourceTree = ""; @@ -3194,6 +3244,7 @@ C3A7215C2558C0AC0043A11F /* File Server */ = { isa = PBXGroup; children = ( + FDC4383227B385B200C60D73 /* Models */, B87EF17026367CF800124B3C /* FileServerAPIV2.swift */, ); path = "File Server"; @@ -3204,6 +3255,7 @@ children = ( C33FDB01255A580700E217F9 /* AppReadiness.h */, C33FDB75255A581000E217F9 /* AppReadiness.m */, + FDC4383D27B4708600C60D73 /* Atomic.swift */, C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */, C37F53E8255BA9BB002AEA92 /* Environment.h */, C37F5402255BA9ED002AEA92 /* Environment.m */, @@ -3241,7 +3293,8 @@ C33FDB91255A581200E217F9 /* ProtoUtils.h */, C33FDA6C255A57FA00E217F9 /* ProtoUtils.m */, C38EEF09255B49A8007E1867 /* SNProtoEnvelope+Conversion.swift */, - C3E7134E251C867C009649BB /* Sodium+Conversion.swift */, + C3E7134E251C867C009649BB /* Sodium+Utilities.swift */, + FD5D202127B1D74F00FEA984 /* ECKeyPair+Conversion.swift */, C33FDB31255A580A00E217F9 /* SSKEnvironment.h */, C33FDAF4255A580600E217F9 /* SSKEnvironment.m */, C33FDB32255A580A00E217F9 /* SSKIncrementingIdFinder.swift */, @@ -3329,6 +3382,7 @@ C32C5BB9256DC7C4003C73A2 /* To Do */, C3BBE0752554CDA60050F1E3 /* Configuration.swift */, C3BBE07F2554CDD70050F1E3 /* Storage.swift */, + FDC4384D27B47FD600C60D73 /* Common Networking */, B8B3201F258B1A540020074B /* Contacts */, C32C5BCB256DC818003C73A2 /* Database */, C300A5BB2554AFFB00555489 /* Messages */, @@ -3636,6 +3690,75 @@ path = Views; sourceTree = ""; }; + FDC4380727B31D3A00C60D73 /* Types */ = { + isa = PBXGroup; + children = ( + FDC4380A27B31D7E00C60D73 /* Request.swift */, + FDC4381F27B36ADC00C60D73 /* Endpoint.swift */, + FDC4380827B31D4E00C60D73 /* Error.swift */, + FDC4381627B32EC700C60D73 /* Personalization.swift */, + FDC4381427B329CE00C60D73 /* NonceGenerator16Byte.swift */, + ); + path = Types; + sourceTree = ""; + }; + FDC4381827B34EAD00C60D73 /* Models */ = { + isa = PBXGroup; + children = ( + FDC4384B27B47F7700C60D73 /* OpenGroupV2.swift */, + FDC4381B27B354AC00C60D73 /* PublicKeyBody.swift */, + FDC4381927B34EBA00C60D73 /* CompactPollBody.swift */, + FDC4384527B47F4D00C60D73 /* CompactPollResponse.swift */, + FDC4383F27B4746D00C60D73 /* GetInfoResponse.swift */, + FDC4382927B3802D00C60D73 /* RoomsResponse.swift */, + FDC4384427B47F4D00C60D73 /* RoomInfo.swift */, + FDC4382B27B380E300C60D73 /* MemberCountResponse.swift */, + FDC4384327B47F4D00C60D73 /* OpenGroupMessageV2.swift */, + FDC4382527B37F6900C60D73 /* DeletedMessagesResponse.swift */, + FDC4384627B47F4D00C60D73 /* Deletion.swift */, + FDC4382727B37FD300C60D73 /* ModeratorsResponse.swift */, + FDC4383927B4696200C60D73 /* AuthTokenResponse.swift */, + ); + path = Models; + sourceTree = ""; + }; + FDC4382D27B383A600C60D73 /* Models */ = { + isa = PBXGroup; + children = ( + FDC4382E27B383AF00C60D73 /* UnregisterResponse.swift */, + FDC4383027B3841C00C60D73 /* RegisterResponse.swift */, + ); + path = Models; + sourceTree = ""; + }; + FDC4383227B385B200C60D73 /* Models */ = { + isa = PBXGroup; + children = ( + FDC4383727B3863200C60D73 /* VersionResponse.swift */, + ); + path = Models; + sourceTree = ""; + }; + FDC4384D27B47FD600C60D73 /* Common Networking */ = { + isa = PBXGroup; + children = ( + FDC4385527B484AE00C60D73 /* Models */, + FDC4384E27B4804F00C60D73 /* Header.swift */, + FDC4385027B4807400C60D73 /* QueryParam.swift */, + ); + path = "Common Networking"; + sourceTree = ""; + }; + FDC4385527B484AE00C60D73 /* Models */ = { + isa = PBXGroup; + children = ( + FDC4385827B484E800C60D73 /* FileUploadBody.swift */, + FDC4385627B484B700C60D73 /* FileUploadResponse.swift */, + FDC4385A27B485DE00C60D73 /* FileDownloadResponse.swift */, + ); + path = Models; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -3723,7 +3846,6 @@ C3D9E3FA25676BCE0040E4F3 /* TSYapDatabaseObject.h in Headers */, C3D9E3A4256763DE0040E4F3 /* AppContext.h in Headers */, C3D9E38A256760390040E4F3 /* OWSFileSystem.h in Headers */, - C352A3B72557B6ED00338F3E /* TSRequest.h in Headers */, C32C5A36256DB856003C73A2 /* LKGroupUtilities.h in Headers */, C3D9E379256760340040E4F3 /* MIMETypeUtil.h in Headers */, C3D9E50E25677A510040E4F3 /* DataSource.h in Headers */, @@ -4635,7 +4757,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - C3AABDDF2553ECF00042FF4C /* Array+Description.swift in Sources */, + C3AABDDF2553ECF00042FF4C /* Array+Utilities.swift in Sources */, C32C5A47256DB8F0003C73A2 /* ECKeyPair+Hexadecimal.swift in Sources */, C3D9E41525676C320040E4F3 /* Storage.swift in Sources */, C32C5D83256DD5B6003C73A2 /* SSKKeychainStorage.swift in Sources */, @@ -4654,17 +4776,17 @@ C3A7211A2558BCA10043A11F /* DiffieHellman.swift in Sources */, C32C5FA1256DFED5003C73A2 /* NSArray+Functional.m in Sources */, C3A7225E2558C38D0043A11F /* Promise+Retaining.swift in Sources */, + FD5D202027B0E67900FEA984 /* String+Encoding.swift in Sources */, C3D9E4D12567777D0040E4F3 /* OWSMediaUtils.swift in Sources */, - C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Description.swift in Sources */, + C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Utilities.swift in Sources */, C3D9E4DA256778410040E4F3 /* UIImage+OWS.m in Sources */, C32C600F256E07F5003C73A2 /* NSUserDefaults+OWS.m in Sources */, C3D9E35E25675F640040E4F3 /* OWSFileSystem.m in Sources */, C3C2AC2E2553CBEB00C340D1 /* String+Trimming.swift in Sources */, C32C5B48256DC211003C73A2 /* NSNotificationCenter+OWS.m in Sources */, C3D9E3C925676AF30040E4F3 /* TSYapDatabaseObject.m in Sources */, - C352A3A62557B60D00338F3E /* TSRequest.m in Sources */, FD705A92278D051200F16121 /* ReusableView.swift in Sources */, - B8AE75A425A6C6A6001A84D2 /* Data+Trimming.swift in Sources */, + B8AE75A425A6C6A6001A84D2 /* Data+Utilities.swift in Sources */, B8856DE6256F15F2001CE70E /* String+SSK.swift in Sources */, C3471ED42555386B00297E91 /* AESGCM.swift in Sources */, C32C5DDB256DD9FF003C73A2 /* ContentProxy.swift in Sources */, @@ -4699,6 +4821,7 @@ buildActionMask = 2147483647; files = ( B8856D08256F10F1001CE70E /* DeviceSleepManager.swift in Sources */, + FDC4382A27B3802D00C60D73 /* RoomsResponse.swift in Sources */, C3471F4C25553AB000297E91 /* MessageReceiver+Decryption.swift in Sources */, C300A5D32554B05A00555489 /* TypingIndicator.swift in Sources */, C3A3A156256E1B91004D228D /* ProtoUtils.m in Sources */, @@ -4711,6 +4834,7 @@ FD5D201127AA331F00FEA984 /* ConfigurationMessage+Convenience.swift in Sources */, C32C5A13256DB7A5003C73A2 /* PushNotificationAPI.swift in Sources */, C32A026325A801AA000ED5D4 /* NSData+messagePadding.m in Sources */, + FDC4384927B47F4D00C60D73 /* CompactPollResponse.swift in Sources */, C352A3932557883D00338F3E /* JobDelegate.swift in Sources */, C32C5B84256DC54F003C73A2 /* SSKEnvironment.m in Sources */, C3A3A108256E1A5C004D228D /* OWSIncomingMessageFinder.m in Sources */, @@ -4718,17 +4842,20 @@ C32C5BDD256DC88D003C73A2 /* OWSReadReceiptManager.m in Sources */, C3D9E3BF25676AD70040E4F3 /* TSAttachmentStream.m in Sources */, C3C2A7562553A3AB00C340D1 /* VisibleMessage+Quote.swift in Sources */, - C3227FF6260AAD66006EA627 /* OpenGroupMessageV2.swift in Sources */, B8B32021258B1A650020074B /* Contact.swift in Sources */, + FDC4380B27B31D7E00C60D73 /* Request.swift in Sources */, + FDC4384027B4746D00C60D73 /* GetInfoResponse.swift in Sources */, C32C5C89256DD0D2003C73A2 /* Storage+Jobs.swift in Sources */, C300A5FC2554B0A000555489 /* MessageReceiver.swift in Sources */, 7B1581E2271E743B00848B49 /* OWSSounds.swift in Sources */, + FDC4383E27B4708600C60D73 /* Atomic.swift in Sources */, C32C5A76256DBBCF003C73A2 /* SignalAttachment.swift in Sources */, C32C5CA4256DD1DC003C73A2 /* TSAccountManager.m in Sources */, C352A3892557876500338F3E /* JobQueue.swift in Sources */, C3BBE0B52554F0E10050F1E3 /* ProofOfWork.swift in Sources */, C32C59C1256DB41F003C73A2 /* TSGroupThread.m in Sources */, C3A3A08F256E1728004D228D /* FullTextSearchFinder.swift in Sources */, + FDC4381A27B34EBA00C60D73 /* CompactPollBody.swift in Sources */, B8856D1A256F114D001CE70E /* ProximityMonitoringManager.swift in Sources */, C32C5B9F256DC739003C73A2 /* OWSBlockingManager.m in Sources */, C3D9E52725677DF20040E4F3 /* OWSThumbnailService.swift in Sources */, @@ -4751,41 +4878,56 @@ B8F5F60325EDE16F003BF8D4 /* DataExtractionNotification.swift in Sources */, C32C5D24256DD4C0003C73A2 /* MentionsManager.swift in Sources */, C3A71D1E25589AC30043A11F /* WebSocketProto.swift in Sources */, + FDC4384A27B47F4D00C60D73 /* Deletion.swift in Sources */, C3C2A7852553AAF300C340D1 /* SessionProtos.pb.swift in Sources */, B8566C63256F55930045A0B9 /* OWSLinkPreview+Conversion.swift in Sources */, + FDC4382827B37FD300C60D73 /* ModeratorsResponse.swift in Sources */, C32C5B3F256DC1DF003C73A2 /* TSQuotedMessage+Conversion.swift in Sources */, B8EB20EE2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift in Sources */, + FD5D202227B1D74F00FEA984 /* ECKeyPair+Conversion.swift in Sources */, + FDC4381C27B354AC00C60D73 /* PublicKeyBody.swift in Sources */, + FDC4382F27B383AF00C60D73 /* UnregisterResponse.swift in Sources */, C3C2A7712553A41E00C340D1 /* ControlMessage.swift in Sources */, C32C5D19256DD493003C73A2 /* OWSLinkPreview.swift in Sources */, C32C5CF0256DD3E4003C73A2 /* Storage+Shared.swift in Sources */, C300A5BD2554B00D00555489 /* ReadReceipt.swift in Sources */, C32C5AB5256DBE8F003C73A2 /* TSOutgoingMessage+Conversion.swift in Sources */, + FDC4385927B484E800C60D73 /* FileUploadBody.swift in Sources */, C32C5E5B256DDF45003C73A2 /* OWSStorage.m in Sources */, C32C5E15256DDC78003C73A2 /* SSKPreferences.swift in Sources */, C32C5D9C256DD6DC003C73A2 /* OWSOutgoingReceiptManager.m in Sources */, B8AE760B25ABFB5A001A84D2 /* GeneralUtilities.m in Sources */, C32C5C4F256DCC36003C73A2 /* Storage+OpenGroups.swift in Sources */, + FDC4384727B47F4D00C60D73 /* OpenGroupMessageV2.swift in Sources */, + FDC4384F27B4804F00C60D73 /* Header.swift in Sources */, C3DA9C0725AE7396008F7C7E /* ConfigurationMessage.swift in Sources */, B8856CEE256F1054001CE70E /* OWSAudioPlayer.m in Sources */, FD5D200F27AA2B6000FEA984 /* MessageRequestResponse.swift in Sources */, + FDC4381727B32EC700C60D73 /* Personalization.swift in Sources */, C32C5EDC256DF501003C73A2 /* YapDatabaseConnection+OWS.m in Sources */, + FDC4381527B329CE00C60D73 /* NonceGenerator16Byte.swift in Sources */, C3BBE0762554CDA60050F1E3 /* Configuration.swift in Sources */, C35D76DB26606304009AA5FB /* ThreadUpdateBatcher.swift in Sources */, + FDC4382C27B380E300C60D73 /* MemberCountResponse.swift in Sources */, B8B32033258B235D0020074B /* Storage+Contacts.swift in Sources */, B8856D69256F141F001CE70E /* OWSWindowManager.m in Sources */, C3D9E3BE25676AD70040E4F3 /* TSAttachmentPointer.m in Sources */, C3ECBF7B257056B700EA7FCE /* Threading.swift in Sources */, C32C5AAB256DBE8F003C73A2 /* TSIncomingMessage+Conversion.swift in Sources */, - B866CE112581C1A900535CC4 /* Sodium+Conversion.swift in Sources */, + B866CE112581C1A900535CC4 /* Sodium+Utilities.swift in Sources */, C32C5A88256DBCF9003C73A2 /* MessageReceiver+Handling.swift in Sources */, C32C5C1B256DC9E0003C73A2 /* General.swift in Sources */, C32C5A02256DB658003C73A2 /* MessageSender+Convenience.swift in Sources */, B8566C6C256F60F50045A0B9 /* OWSUserProfile.m in Sources */, B8D0A25925E367AC00C1835E /* Notification+MessageReceiver.swift in Sources */, C32C599E256DB02B003C73A2 /* TypingIndicators.swift in Sources */, + FDC4380927B31D4E00C60D73 /* Error.swift in Sources */, + FDC4382027B36ADC00C60D73 /* Endpoint.swift in Sources */, C3DB66CC260AF1F3001EFC55 /* OpenGroupAPIV2+ObjC.swift in Sources */, C32C5BEF256DC8EE003C73A2 /* OWSDisappearingMessagesJob.m in Sources */, C34A977425A3E34A00852C71 /* ClosedGroupControlMessage.swift in Sources */, + FDC4384C27B47F7700C60D73 /* OpenGroupV2.swift in Sources */, + FDC4382627B37F6900C60D73 /* DeletedMessagesResponse.swift in Sources */, B88FA7B826045D100049422F /* OpenGroupAPIV2.swift in Sources */, C32C5E97256DE0CB003C73A2 /* OWSPrimaryStorage.m in Sources */, C32C5EB9256DE130003C73A2 /* OWSQuotedReplyModel+Conversion.swift in Sources */, @@ -4793,17 +4935,18 @@ B8856E94256F1C37001CE70E /* OWSSounds.m in Sources */, C32C5BCC256DC830003C73A2 /* Storage+ClosedGroups.swift in Sources */, C3A3A0EC256E1949004D228D /* OWSRecipientIdentity.m in Sources */, + FDC4383A27B4696200C60D73 /* AuthTokenResponse.swift in Sources */, B8F5F56525EC8453003BF8D4 /* Notification+Contacts.swift in Sources */, C32C5AB2256DBE8F003C73A2 /* TSMessage.m in Sources */, C3A3A0FE256E1A3C004D228D /* TSDatabaseSecondaryIndexes.m in Sources */, C38D5E8D2575011E00B6A65C /* MessageSender+ClosedGroups.swift in Sources */, C32C5B1C256DC19D003C73A2 /* TSQuotedMessage.m in Sources */, - C3DB6695260AC923001EFC55 /* OpenGroupV2.swift in Sources */, C352A349255781F400338F3E /* AttachmentDownloadJob.swift in Sources */, C352A30925574D8500338F3E /* Message+Destination.swift in Sources */, C300A5E72554B07300555489 /* ExpirationTimerUpdate.swift in Sources */, B88A1AC725C90A4700E6D421 /* TypingIndicatorInteraction.swift in Sources */, C3D9E3C025676AD70040E4F3 /* TSAttachment.m in Sources */, + FDC4384827B47F4D00C60D73 /* RoomInfo.swift in Sources */, C32C598A256D0664003C73A2 /* SNProtoEnvelope+Conversion.swift in Sources */, C352A2FF25574B6300338F3E /* MessageSendJob.swift in Sources */, B8856D11256F112A001CE70E /* OWSAudioSession.swift in Sources */, @@ -4824,16 +4967,21 @@ C32C5D23256DD4C0003C73A2 /* Mention.swift in Sources */, C32C5FD6256E0346003C73A2 /* Notification+Thread.swift in Sources */, C3BBE0C72554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift in Sources */, + FDC4383827B3863200C60D73 /* VersionResponse.swift in Sources */, C32C5C88256DD0D2003C73A2 /* Storage+Messaging.swift in Sources */, C32C59C7256DB41F003C73A2 /* TSThread.m in Sources */, C300A5B22554AF9800555489 /* VisibleMessage+Profile.swift in Sources */, C32C5A75256DBBCF003C73A2 /* TSAttachmentPointer+Conversion.swift in Sources */, C32C5AF8256DC051003C73A2 /* OWSDisappearingMessagesConfiguration.m in Sources */, C32C5EBA256DE130003C73A2 /* OWSQuotedReplyModel.m in Sources */, + FDC4385127B4807400C60D73 /* QueryParam.swift in Sources */, C32C5B62256DC333003C73A2 /* OWSDisappearingConfigurationUpdateInfoMessage.m in Sources */, C352A2F525574B4700338F3E /* Job.swift in Sources */, + FDC4385727B484B700C60D73 /* FileUploadResponse.swift in Sources */, + FDC4385B27B485DE00C60D73 /* FileDownloadResponse.swift in Sources */, C32C5C01256DC9A0003C73A2 /* OWSIdentityManager.m in Sources */, C32C59C4256DB41F003C73A2 /* TSContactThread.m in Sources */, + FDC4383127B3841C00C60D73 /* RegisterResponse.swift in Sources */, C32C5AB0256DBE8F003C73A2 /* TSOutgoingMessage.m in Sources */, B82A0C3826B9098200C1BCE3 /* MessageInvalidator.swift in Sources */, ); diff --git a/Session/Open Groups/JoinOpenGroupVC.swift b/Session/Open Groups/JoinOpenGroupVC.swift index 866513807..2f62101f7 100644 --- a/Session/Open Groups/JoinOpenGroupVC.swift +++ b/Session/Open Groups/JoinOpenGroupVC.swift @@ -238,7 +238,7 @@ private final class EnterURLVC : UIViewController, UIGestureRecognizerDelegate, return !suggestionGrid.frame.contains(location) } - func join(_ room: OpenGroupAPIV2.Info) { + func join(_ room: OpenGroupAPIV2.RoomInfo) { joinOpenGroupVC.joinV2OpenGroup(room: room.id, server: OpenGroupAPIV2.defaultServer, publicKey: OpenGroupAPIV2.defaultServerPublicKey) } diff --git a/Session/Open Groups/OpenGroupSuggestionGrid.swift b/Session/Open Groups/OpenGroupSuggestionGrid.swift index b62af71fc..5d0e5826a 100644 --- a/Session/Open Groups/OpenGroupSuggestionGrid.swift +++ b/Session/Open Groups/OpenGroupSuggestionGrid.swift @@ -3,7 +3,7 @@ import NVActivityIndicatorView final class OpenGroupSuggestionGrid : UIView, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { private let maxWidth: CGFloat - private var rooms: [OpenGroupAPIV2.Info] = [] { didSet { update() } } + private var rooms: [OpenGroupAPIV2.RoomInfo] = [] { didSet { update() } } private var heightConstraint: NSLayoutConstraint! var delegate: OpenGroupSuggestionGridDelegate? @@ -104,7 +104,7 @@ final class OpenGroupSuggestionGrid : UIView, UICollectionViewDataSource, UIColl extension OpenGroupSuggestionGrid { fileprivate final class Cell : UICollectionViewCell { - var room: OpenGroupAPIV2.Info? { didSet { update() } } + var room: OpenGroupAPIV2.RoomInfo? { didSet { update() } } static let identifier = "OpenGroupSuggestionGridCell" @@ -183,5 +183,5 @@ extension OpenGroupSuggestionGrid { // MARK: Delegate protocol OpenGroupSuggestionGridDelegate { - func join(_ room: OpenGroupAPIV2.Info) + func join(_ room: OpenGroupAPIV2.RoomInfo) } diff --git a/SessionMessagingKit/Common Networking/Header.swift b/SessionMessagingKit/Common Networking/Header.swift new file mode 100644 index 000000000..9081fbf05 --- /dev/null +++ b/SessionMessagingKit/Common Networking/Header.swift @@ -0,0 +1,23 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +enum Header: String { + case authorization = "Authorization" + case contentType = "Content-Type" + + case room = "Room" // TODO: Confirm this is needed + + case sogsPubKey = "X-SOGS-Pubkey" + case sogsNonce = "X-SOGS-Nonce" + case sogsTimestamp = "X-SOGS-Timestamp" + case sogsHash = "X-SOGS-Hash" +} + +// MARK: - Convenience + +extension Dictionary where Key == Header, Value == String { + func toHTTPHeaders() -> [String: String] { + return self.reduce(into: [:]) { result, next in result[next.key.rawValue] = next.value } + } +} diff --git a/SessionMessagingKit/Common Networking/Models/FileDownloadResponse.swift b/SessionMessagingKit/Common Networking/Models/FileDownloadResponse.swift new file mode 100644 index 000000000..b18fda763 --- /dev/null +++ b/SessionMessagingKit/Common Networking/Models/FileDownloadResponse.swift @@ -0,0 +1,35 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +struct FileDownloadResponse: Codable { + enum CodingKeys: String, CodingKey { + case base64EncodedData = "result" + } + + let data: Data + + public func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(data.base64EncodedString(), forKey: .base64EncodedData) + } +} + +// MARK: - Decoder + +extension FileDownloadResponse { + init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + let base64EncodedData: String = try container.decode(String.self, forKey: .base64EncodedData) + + guard let data = Data(base64Encoded: base64EncodedData) else { + throw FileServerAPIV2.Error.parsingFailed + } + + self = FileDownloadResponse( + data: data + ) + } +} diff --git a/SessionMessagingKit/Common Networking/Models/FileUploadBody.swift b/SessionMessagingKit/Common Networking/Models/FileUploadBody.swift new file mode 100644 index 000000000..f62154b70 --- /dev/null +++ b/SessionMessagingKit/Common Networking/Models/FileUploadBody.swift @@ -0,0 +1,7 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +struct FileUploadBody: Codable { + let file: String +} diff --git a/SessionMessagingKit/Common Networking/Models/FileUploadResponse.swift b/SessionMessagingKit/Common Networking/Models/FileUploadResponse.swift new file mode 100644 index 000000000..ba59e65d0 --- /dev/null +++ b/SessionMessagingKit/Common Networking/Models/FileUploadResponse.swift @@ -0,0 +1,11 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +struct FileUploadResponse: Codable { + enum CodingKeys: String, CodingKey { + case fileId = "result" + } + + public let fileId: UInt64 +} diff --git a/SessionMessagingKit/Common Networking/QueryParam.swift b/SessionMessagingKit/Common Networking/QueryParam.swift new file mode 100644 index 000000000..5c71ae852 --- /dev/null +++ b/SessionMessagingKit/Common Networking/QueryParam.swift @@ -0,0 +1,8 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +enum QueryParam: String { + case publicKey = "public_key" + case fromServerId = "from_server_id" +} diff --git a/SessionMessagingKit/File Server/FileServerAPIV2.swift b/SessionMessagingKit/File Server/FileServerAPIV2.swift index a5b880b36..ce5404159 100644 --- a/SessionMessagingKit/File Server/FileServerAPIV2.swift +++ b/SessionMessagingKit/File Server/FileServerAPIV2.swift @@ -22,16 +22,16 @@ public final class FileServerAPIV2 : NSObject { private override init() { } // MARK: Error - public enum Error : LocalizedError { + public enum Error: LocalizedError { case parsingFailed case invalidURL case maxFileSizeExceeded public var errorDescription: String? { switch self { - case .parsingFailed: return "Invalid response." - case .invalidURL: return "Invalid URL." - case .maxFileSizeExceeded: return "Maximum file size exceeded." + case .parsingFailed: return "Invalid response." + case .invalidURL: return "Invalid URL." + case .maxFileSizeExceeded: return "Maximum file size exceeded." } } } @@ -40,49 +40,61 @@ public final class FileServerAPIV2 : NSObject { private struct Request { let verb: HTTP.Verb let endpoint: String - let queryParameters: [String:String] - let parameters: JSON - let headers: [String:String] + let queryParameters: [QueryParam: String] + let body: Data? + let headers: [Header: String] /// Always `true` under normal circumstances. You might want to disable /// this when running over Lokinet. let useOnionRouting: Bool - init(verb: HTTP.Verb, endpoint: String, queryParameters: [String:String] = [:], parameters: JSON = [:], - headers: [String:String] = [:], useOnionRouting: Bool = true) { + init(verb: HTTP.Verb, endpoint: String, queryParameters: [QueryParam: String] = [:], body: Data? = nil, + headers: [Header: String] = [:], useOnionRouting: Bool = true) { self.verb = verb self.endpoint = endpoint self.queryParameters = queryParameters - self.parameters = parameters + self.body = body self.headers = headers self.useOnionRouting = useOnionRouting } } - // MARK: Convenience - private static func send(_ request: Request, useOldServer: Bool) -> Promise { + // MARK: - Convenience + + private static func send(_ request: Request, useOldServer: Bool) -> Promise { let server = useOldServer ? oldServer : server let serverPublicKey = useOldServer ? oldServerPublicKey : serverPublicKey - let tsRequest: TSRequest + var urlRequest: URLRequest + // TODO: Combine this 'Request' with the the pattern in OpenGroupServerV2? switch request.verb { - case .get: - var rawURL = "\(server)/\(request.endpoint)" - if !request.queryParameters.isEmpty { - let queryString = request.queryParameters.map { key, value in "\(key)=\(value)" }.joined(separator: "&") - rawURL += "?\(queryString)" - } - guard let url = URL(string: rawURL) else { return Promise(error: Error.invalidURL) } - tsRequest = TSRequest(url: url) - case .post, .put, .delete: - let rawURL = "\(server)/\(request.endpoint)" - guard let url = URL(string: rawURL) else { return Promise(error: Error.invalidURL) } - tsRequest = TSRequest(url: url, method: request.verb.rawValue, parameters: request.parameters) + case .get: + var rawURL = "\(server)/\(request.endpoint)" + + if !request.queryParameters.isEmpty { + let queryString = request.queryParameters.map { key, value in "\(key)=\(value)" }.joined(separator: "&") + rawURL += "?\(queryString)" + } + + guard let url = URL(string: rawURL) else { return Promise(error: Error.invalidURL) } + + urlRequest = URLRequest(url: url) + + case .post, .put, .delete: + let rawURL = "\(server)/\(request.endpoint)" + + guard let url = URL(string: rawURL) else { return Promise(error: Error.invalidURL) } + + urlRequest = URLRequest(url: url) + urlRequest.httpMethod = request.verb.rawValue + urlRequest.httpBody = request.body } - tsRequest.allHTTPHeaderFields = request.headers - if request.useOnionRouting { - return OnionRequestAPI.sendOnionRequest(tsRequest, to: server, using: serverPublicKey) - } else { + + urlRequest.allHTTPHeaderFields = request.headers.toHTTPHeaders() + + guard request.useOnionRouting else { preconditionFailure("It's currently not allowed to send non onion routed requests.") } + + return OnionRequestAPI.sendOnionRequest(urlRequest, to: server, using: serverPublicKey) } // MARK: File Storage @@ -92,12 +104,17 @@ public final class FileServerAPIV2 : NSObject { } public static func upload(_ file: Data) -> Promise { - let base64EncodedFile = file.base64EncodedString() - let parameters = [ "file" : base64EncodedFile ] - let request = Request(verb: .post, endpoint: "files", parameters: parameters) - return send(request, useOldServer: false).map(on: DispatchQueue.global(qos: .userInitiated)) { json in - guard let fileID = json["result"] as? UInt64 else { throw Error.parsingFailed } - return fileID + let requestBody: FileUploadBody = FileUploadBody(file: file.base64EncodedString()) + + guard let body: Data = try? JSONEncoder().encode(requestBody) else { + return Promise(error: HTTP.Error.invalidJSON) + } + + let request = Request(verb: .post, endpoint: "files", body: body) + return send(request, useOldServer: false).map(on: DispatchQueue.global(qos: .userInitiated)) { data in + let response: FileUploadResponse = try data.decoded(as: FileUploadResponse.self, customError: Error.parsingFailed) + + return response.fileId } } @@ -109,17 +126,21 @@ public final class FileServerAPIV2 : NSObject { public static func download(_ file: UInt64, useOldServer: Bool) -> Promise { let request = Request(verb: .get, endpoint: "files/\(file)") - return send(request, useOldServer: useOldServer).map(on: DispatchQueue.global(qos: .userInitiated)) { json in - guard let base64EncodedFile = json["result"] as? String, let file = Data(base64Encoded: base64EncodedFile) else { throw Error.parsingFailed } - return file + + return send(request, useOldServer: useOldServer).map(on: DispatchQueue.global(qos: .userInitiated)) { data in + let response: FileDownloadResponse = try data.decoded(as: FileDownloadResponse.self, customError: Error.parsingFailed) + + return response.data } } public static func getVersion(_ platform: String) -> Promise { let request = Request(verb: .get, endpoint: "session_version?platform=\(platform)") - return send(request, useOldServer: false).map(on: DispatchQueue.global(qos: .userInitiated)) { json in - guard let version = json["result"] as? String else { throw Error.parsingFailed } - return version + + return send(request, useOldServer: false).map(on: DispatchQueue.global(qos: .userInitiated)) { data in + let response: VersionResponse = try data.decoded(as: VersionResponse.self, customError: Error.parsingFailed) + + return response.version } } } diff --git a/SessionMessagingKit/File Server/Models/VersionResponse.swift b/SessionMessagingKit/File Server/Models/VersionResponse.swift new file mode 100644 index 000000000..f72b4393a --- /dev/null +++ b/SessionMessagingKit/File Server/Models/VersionResponse.swift @@ -0,0 +1,13 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension FileServerAPIV2 { + struct VersionResponse: Codable { + enum CodingKeys: String, CodingKey { + case version = "version" + } + + public let version: String + } +} diff --git a/SessionMessagingKit/Jobs/NotifyPNServerJob.swift b/SessionMessagingKit/Jobs/NotifyPNServerJob.swift index 82c57b3f3..227c66202 100644 --- a/SessionMessagingKit/Jobs/NotifyPNServerJob.swift +++ b/SessionMessagingKit/Jobs/NotifyPNServerJob.swift @@ -3,6 +3,16 @@ import SessionSnodeKit import SessionUtilitiesKit public final class NotifyPNServerJob : NSObject, Job, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility + struct RequestBody: Codable { + enum CodingKeys: String, CodingKey { + case data + case sendTo = "send_to" + } + + let data: String + let sendTo: String + } + public let message: SnodeMessage public var delegate: JobDelegate? public var id: String? @@ -32,7 +42,8 @@ public final class NotifyPNServerJob : NSObject, Job, NSCoding { // NSObject/NSC coder.encode(failureCount, forKey: "failureCount") } - // MARK: Running + // MARK: - Running + public func execute() { let _: Promise = execute() } @@ -42,10 +53,18 @@ public final class NotifyPNServerJob : NSObject, Job, NSCoding { // NSObject/NSC JobQueue.currentlyExecutingJobs.insert(id) } let server = PushNotificationAPI.server - let parameters = [ "data" : message.data.description, "send_to" : message.recipient ] let url = URL(string: "\(server)/notify")! - let request = TSRequest(url: url, method: "POST", parameters: parameters) - request.allHTTPHeaderFields = [ "Content-Type" : "application/json" ] + let requestBody: RequestBody = RequestBody(data: message.data.description, sendTo: message.recipient) + + guard let body: Data = try? JSONEncoder().encode(requestBody) else { + return Promise(error: HTTP.Error.invalidJSON) + } + + var request: URLRequest = URLRequest(url: url) + request.httpMethod = "POST" + request.allHTTPHeaderFields = [ Header.contentType.rawValue: "application/json" ] + request.httpBody = body + let promise = attempt(maxRetryCount: 4, recoveringOn: DispatchQueue.global()) { OnionRequestAPI.sendOnionRequest(request, to: server, target: "/loki/v2/lsrpc", using: PushNotificationAPI.serverPublicKey).map { _ in } } diff --git a/SessionMessagingKit/Open Groups/Models/AuthTokenResponse.swift b/SessionMessagingKit/Open Groups/Models/AuthTokenResponse.swift new file mode 100644 index 000000000..d4340a053 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/AuthTokenResponse.swift @@ -0,0 +1,46 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPIV2 { + struct AuthTokenResponse: Codable { + struct Challenge: Codable { + enum CodingKeys: String, CodingKey { + case ciphertext = "ciphertext" + case ephemeralPublicKey = "ephemeral_public_key" + } + + let ciphertext: Data + let ephemeralPublicKey: Data + } + + let challenge: Challenge + } +} + +// MARK: - Codable + +extension OpenGroupAPIV2.AuthTokenResponse.Challenge { + init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + let base64EncodedCiphertext: String = try container.decode(String.self, forKey: .ciphertext) + let base64EncodedEphemeralPublicKey: String = try container.decode(String.self, forKey: .ephemeralPublicKey) + + guard let ciphertext = Data(base64Encoded: base64EncodedCiphertext), let ephemeralPublicKey = Data(base64Encoded: base64EncodedEphemeralPublicKey) else { + throw OpenGroupAPIV2.Error.parsingFailed + } + + self = OpenGroupAPIV2.AuthTokenResponse.Challenge( + ciphertext: ciphertext, + ephemeralPublicKey: ephemeralPublicKey + ) + } + + public func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(ciphertext.base64EncodedString(), forKey: .ciphertext) + try container.encode(ephemeralPublicKey.base64EncodedString(), forKey: .ephemeralPublicKey) + } +} diff --git a/SessionMessagingKit/Open Groups/Models/CompactPollBody.swift b/SessionMessagingKit/Open Groups/Models/CompactPollBody.swift new file mode 100644 index 000000000..0e26fd773 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/CompactPollBody.swift @@ -0,0 +1,27 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPIV2 { + struct CompactPollBody: Codable { + struct Room: Codable { + enum CodingKeys: String, CodingKey { + case id = "room_id" + case fromMessageServerId = "from_message_server_id" + case fromDeletionServerId = "from_deletion_server_id" + + // TODO: Remove this legacy value + case legacyAuthToken = "auth_token" + } + + let id: String + let fromMessageServerId: Int64? + let fromDeletionServerId: Int64? + + // TODO: This is a legacy value + let legacyAuthToken: String? + } + + let requests: [Room] + } +} diff --git a/SessionMessagingKit/Open Groups/Models/CompactPollResponse.swift b/SessionMessagingKit/Open Groups/Models/CompactPollResponse.swift new file mode 100644 index 000000000..626108d84 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/CompactPollResponse.swift @@ -0,0 +1,25 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPIV2 { + public struct CompactPollResponse: Codable { + public struct Result: Codable { + enum CodingKeys: String, CodingKey { + case room = "room_id" + case statusCode = "status_code" + case messages + case deletions + case moderators + } + + public let room: String + public let statusCode: UInt + public let messages: [OpenGroupMessageV2]? + public let deletions: [Deletion]? + public let moderators: [String]? + } + + public let results: [Result] + } +} diff --git a/SessionMessagingKit/Open Groups/Models/DeletedMessagesResponse.swift b/SessionMessagingKit/Open Groups/Models/DeletedMessagesResponse.swift new file mode 100644 index 000000000..5b8e34921 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/DeletedMessagesResponse.swift @@ -0,0 +1,13 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPIV2 { + struct DeletedMessagesResponse: Codable { + enum CodingKeys: String, CodingKey { + case deletions = "ids" + } + + let deletions: [Deletion] + } +} diff --git a/SessionMessagingKit/Open Groups/Models/Deletion.swift b/SessionMessagingKit/Open Groups/Models/Deletion.swift new file mode 100644 index 000000000..d7a3e9bd9 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/Deletion.swift @@ -0,0 +1,23 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPIV2 { + public struct Deletion: Codable { + enum CodingKeys: String, CodingKey { + case id + case deletedMessageID = "deleted_message_id" + } + + let id: Int64 + let deletedMessageID: Int64 + + public static func from(_ json: JSON) -> Deletion? { + guard let id = json["id"] as? Int64, let deletedMessageID = json["deleted_message_id"] as? Int64 else { + return nil + } + + return Deletion(id: id, deletedMessageID: deletedMessageID) + } + } +} diff --git a/SessionMessagingKit/Open Groups/Models/GetInfoResponse.swift b/SessionMessagingKit/Open Groups/Models/GetInfoResponse.swift new file mode 100644 index 000000000..6d637cddc --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/GetInfoResponse.swift @@ -0,0 +1,9 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPIV2 { + struct GetInfoResponse: Codable { + let room: RoomInfo + } +} diff --git a/SessionMessagingKit/Open Groups/Models/MemberCountResponse.swift b/SessionMessagingKit/Open Groups/Models/MemberCountResponse.swift new file mode 100644 index 000000000..2bc0ec604 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/MemberCountResponse.swift @@ -0,0 +1,13 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPIV2 { + struct MemberCountResponse: Codable { + enum CodingKeys: String, CodingKey { + case memberCount = "member_count" + } + + let memberCount: UInt64 + } +} diff --git a/SessionMessagingKit/Open Groups/Models/ModeratorsResponse.swift b/SessionMessagingKit/Open Groups/Models/ModeratorsResponse.swift new file mode 100644 index 000000000..82c00e656 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/ModeratorsResponse.swift @@ -0,0 +1,9 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPIV2 { + struct ModeratorsResponse: Codable { + let moderators: [String] + } +} diff --git a/SessionMessagingKit/Open Groups/Models/OpenGroupMessageV2.swift b/SessionMessagingKit/Open Groups/Models/OpenGroupMessageV2.swift new file mode 100644 index 000000000..76a0b11b6 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/OpenGroupMessageV2.swift @@ -0,0 +1,72 @@ +import Foundation +import SessionUtilitiesKit + +public struct OpenGroupMessageV2: Codable { + enum CodingKeys: String, CodingKey { + case serverID = "server_id" + case sender = "public_key" + case sentTimestamp = "timestamp" + case base64EncodedData = "data" + case base64EncodedSignature = "signature" + } + + public let serverID: Int64? + public let sender: String? + public let sentTimestamp: UInt64 + /// The serialized protobuf in base64 encoding. + public let base64EncodedData: String + /// When sending a message, the sender signs the serialized protobuf with their private key so that + /// a receiving user can verify that the message wasn't tampered with. + public let base64EncodedSignature: String? + + public func sign(with publicKey: String) -> OpenGroupMessageV2? { + // TODO: Swap to use blinded key + guard let userKeyPair = SNMessagingKitConfiguration.shared.storage.getUserKeyPair() else { return nil } + guard let data = Data(base64Encoded: base64EncodedData) else { return nil } + guard let signature = try? Ed25519.sign(data, with: userKeyPair) else { + SNLog("Failed to sign open group message.") + return nil + } + + return OpenGroupMessageV2( + serverID: serverID, + sender: sender, + sentTimestamp: sentTimestamp, + base64EncodedData: base64EncodedData, + base64EncodedSignature: signature.base64EncodedString() + ) + } +} + +// MARK: - Decoder + +extension OpenGroupMessageV2 { + public init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + let sender: String = try container.decode(String.self, forKey: .sender) + let base64EncodedData: String = try container.decode(String.self, forKey: .base64EncodedData) + let base64EncodedSignature: String = try container.decode(String.self, forKey: .base64EncodedSignature) + + // Validate the message signature + guard let data = Data(base64Encoded: base64EncodedData), let signature = Data(base64Encoded: base64EncodedSignature) else { + throw OpenGroupAPIV2.Error.parsingFailed + } + + let publicKey = Data(hex: sender.removingIdPrefixIfNeeded()) + let isValid = (try? Ed25519.verifySignature(signature, publicKey: publicKey, data: data)) ?? false + + guard isValid else { + SNLog("Ignoring message with invalid signature.") + throw OpenGroupAPIV2.Error.parsingFailed + } + + self = OpenGroupMessageV2( + serverID: try? container.decode(Int64.self, forKey: .serverID), + sender: sender, + sentTimestamp: try container.decode(UInt64.self, forKey: .sentTimestamp), + base64EncodedData: base64EncodedData, + base64EncodedSignature: base64EncodedSignature + ) + } +} diff --git a/SessionMessagingKit/Open Groups/OpenGroupV2.swift b/SessionMessagingKit/Open Groups/Models/OpenGroupV2.swift similarity index 97% rename from SessionMessagingKit/Open Groups/OpenGroupV2.swift rename to SessionMessagingKit/Open Groups/Models/OpenGroupV2.swift index 504920b76..a95caee8d 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupV2.swift +++ b/SessionMessagingKit/Open Groups/Models/OpenGroupV2.swift @@ -1,3 +1,5 @@ +import Sodium +import SessionUtilitiesKit @objc(SNOpenGroupV2) public final class OpenGroupV2 : NSObject, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility diff --git a/SessionMessagingKit/Open Groups/Models/PublicKeyBody.swift b/SessionMessagingKit/Open Groups/Models/PublicKeyBody.swift new file mode 100644 index 000000000..d7a5c6e24 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/PublicKeyBody.swift @@ -0,0 +1,13 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPIV2 { + struct PublicKeyBody: Codable { + enum CodingKeys: String, CodingKey { + case publicKey = "public_key" + } + + let publicKey: String + } +} diff --git a/SessionMessagingKit/Open Groups/Models/RoomInfo.swift b/SessionMessagingKit/Open Groups/Models/RoomInfo.swift new file mode 100644 index 000000000..bef83f77f --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/RoomInfo.swift @@ -0,0 +1,17 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPIV2 { + public struct RoomInfo: Codable { + enum CodingKeys: String, CodingKey { + case id + case name + case imageID = "image_id" + } + + public let id: String + public let name: String + public let imageID: String? + } +} diff --git a/SessionMessagingKit/Open Groups/Models/RoomsResponse.swift b/SessionMessagingKit/Open Groups/Models/RoomsResponse.swift new file mode 100644 index 000000000..e5ac33d23 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/RoomsResponse.swift @@ -0,0 +1,9 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPIV2 { + struct RoomsResponse: Codable { + let rooms: [RoomInfo] + } +} diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift b/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift index 0e7edc2bd..e447a6832 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift @@ -1,301 +1,462 @@ import PromiseKit import SessionSnodeKit +import Sodium +import Curve25519Kit @objc(SNOpenGroupAPIV2) -public final class OpenGroupAPIV2 : NSObject { - private static var authTokenPromises: [String:Promise] = [:] - private static var hasPerformedInitialPoll: [String:Bool] = [:] - private static var hasUpdatedLastOpenDate = false - public static let workQueue = DispatchQueue(label: "OpenGroupAPIV2.workQueue", qos: .userInitiated) // It's important that this is a serial queue - public static var moderators: [String:[String:Set]] = [:] // Server URL to room ID to set of moderator IDs - public static var defaultRoomsPromise: Promise<[Info]>? - public static var groupImagePromises: [String:Promise] = [:] - - private static let timeSinceLastOpen: TimeInterval = { - guard let lastOpen = UserDefaults.standard[.lastOpen] else { return .greatestFiniteMagnitude } - let now = Date() - return now.timeIntervalSince(lastOpen) - }() - - // MARK: Settings +public final class OpenGroupAPIV2: NSObject { + + // MARK: - Settings + public static let defaultServer = "http://116.203.70.33" public static let defaultServerPublicKey = "a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238" - // MARK: Error - public enum Error : LocalizedError { - case generic - case parsingFailed - case decryptionFailed - case signingFailed - case invalidURL - case noPublicKey - - public var errorDescription: String? { - switch self { - case .generic: return "An error occurred." - case .parsingFailed: return "Invalid response." - case .decryptionFailed: return "Couldn't decrypt response." - case .signingFailed: return "Couldn't sign message." - case .invalidURL: return "Invalid URL." - case .noPublicKey: return "Couldn't find server public key." - } - } - } - - // MARK: Request - private struct Request { - let verb: HTTP.Verb - let room: String? - let server: String - let endpoint: String - let queryParameters: [String:String] - let parameters: JSON - let headers: [String:String] - let isAuthRequired: Bool - /// Always `true` under normal circumstances. You might want to disable - /// this when running over Lokinet. - let useOnionRouting: Bool - - init(verb: HTTP.Verb, room: String?, server: String, endpoint: String, queryParameters: [String:String] = [:], - parameters: JSON = [:], headers: [String:String] = [:], isAuthRequired: Bool = true, useOnionRouting: Bool = true) { - self.verb = verb - self.room = room - self.server = server - self.endpoint = endpoint - self.queryParameters = queryParameters - self.parameters = parameters - self.headers = headers - self.isAuthRequired = isAuthRequired - self.useOnionRouting = useOnionRouting - } - } + // MARK: - Cache - // MARK: Info - public struct Info { - public let id: String - public let name: String - public let imageID: String? - - public init(id: String, name: String, imageID: String?) { - self.id = id - self.name = name - self.imageID = imageID - } - } - - // MARK: Compact Poll Response Body - public struct CompactPollResponseBody { - let room: String - let messages: [OpenGroupMessageV2] - let deletions: [Deletion] - let moderators: [String] - } - - public struct Deletion { - let id: Int64 - let deletedMessageID: Int64 - - public static func from(_ json: JSON) -> Deletion? { - guard let id = json["id"] as? Int64, let deletedMessageID = json["deleted_message_id"] as? Int64 else { return nil } - return Deletion(id: id, deletedMessageID: deletedMessageID) - } - } + private static var authTokenPromises: Atomic<[String: Promise]> = Atomic([:]) + private static var hasPerformedInitialPoll: [String: Bool] = [:] + private static var hasUpdatedLastOpenDate = false + public static let workQueue = DispatchQueue(label: "OpenGroupAPIV2.workQueue", qos: .userInitiated) // It's important that this is a serial queue + public static var moderators: [String: [String: Set]] = [:] // Server URL to room ID to set of moderator IDs + public static var defaultRoomsPromise: Promise<[RoomInfo]>? + public static var groupImagePromises: [String: Promise] = [:] - // MARK: Convenience - private static func send(_ request: Request) -> Promise { - let tsRequest: TSRequest - switch request.verb { - case .get: - var rawURL = "\(request.server)/\(request.endpoint)" - if !request.queryParameters.isEmpty { - let queryString = request.queryParameters.map { key, value in "\(key)=\(value)" }.joined(separator: "&") - rawURL += "?\(queryString)" - } - guard let url = URL(string: rawURL) else { return Promise(error: Error.invalidURL) } - tsRequest = TSRequest(url: url) - case .post, .put, .delete: - let rawURL = "\(request.server)/\(request.endpoint)" - guard let url = URL(string: rawURL) else { return Promise(error: Error.invalidURL) } - tsRequest = TSRequest(url: url, method: request.verb.rawValue, parameters: request.parameters) - } - tsRequest.allHTTPHeaderFields = request.headers - tsRequest.setValue(request.room, forHTTPHeaderField: "Room") + private static let timeSinceLastOpen: TimeInterval = { + guard let lastOpen = UserDefaults.standard[.lastOpen] else { return .greatestFiniteMagnitude } + + return Date().timeIntervalSince(lastOpen) + }() + + // MARK: - Convenience + + private static func send(_ request: Request) -> Promise { + guard let url: URL = request.url else { return Promise(error: Error.invalidURL) } + + var urlRequest: URLRequest = URLRequest(url: url) + urlRequest.httpMethod = request.verb.rawValue + urlRequest.allHTTPHeaderFields = request.headers + .setting(.room, request.room) // TODO: Is this needed anymore? Add at the request level? + .toHTTPHeaders() + urlRequest.httpBody = request.body + if request.useOnionRouting { - guard let publicKey = SNMessagingKitConfiguration.shared.storage.getOpenGroupPublicKey(for: request.server) else { return Promise(error: Error.noPublicKey) } - if request.isAuthRequired, let room = request.room { // Because auth happens on a per-room basis, we need both to make an authenticated request - return getAuthToken(for: room, on: request.server).then(on: OpenGroupAPIV2.workQueue) { authToken -> Promise in - tsRequest.setValue(authToken, forHTTPHeaderField: "Authorization") - let promise = OnionRequestAPI.sendOnionRequest(tsRequest, to: request.server, using: publicKey) - promise.catch(on: OpenGroupAPIV2.workQueue) { error in - // A 401 means that we didn't provide a (valid) auth token for a route that required one. We use this as an - // indication that the token we're using has expired. Note that a 403 has a different meaning; it means that - // we provided a valid token but it doesn't have a high enough permission level for the route in question. - if case OnionRequestAPI.Error.httpRequestFailedAtDestination(let statusCode, _, _) = error, statusCode == 401 { - let storage = SNMessagingKitConfiguration.shared.storage - storage.writeSync { transaction in - storage.removeAuthToken(for: room, on: request.server, using: transaction) - } - } - } - return promise - } - } else { - return OnionRequestAPI.sendOnionRequest(tsRequest, to: request.server, using: publicKey) + guard let publicKey = SNMessagingKitConfiguration.shared.storage.getOpenGroupPublicKey(for: request.server) else { + return Promise(error: Error.noPublicKey) } - } else { - preconditionFailure("It's currently not allowed to send non onion routed requests.") + + if request.isAuthRequired { + // Determine if we should be using legacy auth for this endpoint + // TODO: Might need to store this at an OpenGroup level (so all requests can use the appropriate method) + if request.endpoint.useLegacyAuth { + // Because legacy auth happens on a per-room basis, we need to have a room to + // make an authenticated request + guard let room = request.room else { + return OnionRequestAPI.sendOnionRequest(urlRequest, to: request.server, using: publicKey) + } + + return getAuthToken(for: room, on: request.server) + .then(on: OpenGroupAPIV2.workQueue) { authToken -> Promise in + urlRequest.setValue(authToken, forHTTPHeaderField: Header.authorization.rawValue) + + let promise = OnionRequestAPI.sendOnionRequest(urlRequest, to: request.server, using: publicKey) + promise.catch(on: OpenGroupAPIV2.workQueue) { error in + // A 401 means that we didn't provide a (valid) auth token for a route + // that required one. We use this as an indication that the token we're + // using has expired. Note that a 403 has a different meaning; it means + // that we provided a valid token but it doesn't have a high enough + // permission level for the route in question. + if case OnionRequestAPI.Error.httpRequestFailedAtDestination(let statusCode, _, _) = error, statusCode == 401 { + let storage = SNMessagingKitConfiguration.shared.storage + + storage.writeSync { transaction in + storage.removeAuthToken(for: room, on: request.server, using: transaction) + } + } + } + + return promise + } + } + + // Attempt to sign the request with the new auth + guard let signedRequest: URLRequest = sign(urlRequest, with: publicKey) else { + return Promise(error: Error.signingFailed) + } + + // TODO: 'removeAuthToken' as a migration??? (would previously do this when getting a `401`) + return OnionRequestAPI.sendOnionRequest(signedRequest, to: request.server, using: publicKey) + } + + return OnionRequestAPI.sendOnionRequest(urlRequest, to: request.server, using: publicKey) } + + preconditionFailure("It's currently not allowed to send non onion routed requests.") } - public static func compactPoll(_ server: String) -> Promise<[CompactPollResponseBody]> { - let storage = SNMessagingKitConfiguration.shared.storage - let rooms = storage.getAllV2OpenGroups().values.filter { $0.server == server }.map { $0.room } - var body: [JSON] = [] - var authTokenPromises: [String:Promise] = [:] + public static func compactPoll(_ server: String) -> Promise { + let storage: SessionMessagingKitStorageProtocol = SNMessagingKitConfiguration.shared.storage + let rooms: [String] = storage.getAllV2OpenGroups().values + .filter { $0.server == server } + .map { $0.room } let useMessageLimit = (hasPerformedInitialPoll[server] != true && timeSinceLastOpen > OpenGroupPollerV2.maxInactivityPeriod) + hasPerformedInitialPoll[server] = true + if !hasUpdatedLastOpenDate { UserDefaults.standard[.lastOpen] = Date() hasUpdatedLastOpenDate = true } - for room in rooms { - authTokenPromises[room] = getAuthToken(for: room, on: server) - var json: JSON = [ "room_id" : room ] - if let lastMessageServerID = storage.getLastMessageServerID(for: room, on: server) { - json["from_message_server_id"] = useMessageLimit ? nil : lastMessageServerID - } - if let lastDeletionServerID = storage.getLastDeletionServerID(for: room, on: server) { - json["from_deletion_server_id"] = useMessageLimit ? nil : lastDeletionServerID - } - body.append(json) - } - return when(fulfilled: [Promise](authTokenPromises.values)).then(on: OpenGroupAPIV2.workQueue) { _ -> Promise<[CompactPollResponseBody]> in - let bodyWithAuthTokens = body.compactMap { json -> JSON? in - guard let roomID = json["room_id"] as? String, let authToken = authTokenPromises[roomID]?.value else { return nil } - var json = json - json["auth_token"] = authToken - return json - } - let request = Request(verb: .post, room: nil, server: server, endpoint: "compact_poll", parameters: [ "requests" : bodyWithAuthTokens ], isAuthRequired: false) - return send(request).then(on: OpenGroupAPIV2.workQueue) { json -> Promise<[CompactPollResponseBody]> in - guard let results = json["results"] as? [JSON] else { throw Error.parsingFailed } - let promises = results.compactMap { json -> Promise? in - guard let room = json["room_id"] as? String, let status = json["status_code"] as? UInt else { return nil } - // A 401 means that we didn't provide a (valid) auth token for a route that required one. We use this as an - // indication that the token we're using has expired. Note that a 403 has a different meaning; it means that - // we provided a valid token but it doesn't have a high enough permission level for the route in question. - guard status != 401 else { - storage.writeSync { transaction in - storage.removeAuthToken(for: room, on: server, using: transaction) - } - return nil - } - let rawDeletions = json["deletions"] as? [JSON] ?? [] - let moderators = json["moderators"] as? [String] ?? [] - return try? parseMessages(from: json, for: room, on: server).then(on: OpenGroupAPIV2.workQueue) { messages in - parseDeletions(from: rawDeletions, for: room, on: server).map(on: OpenGroupAPIV2.workQueue) { deletions in - return CompactPollResponseBody(room: room, messages: messages, deletions: deletions, moderators: moderators) - } - } + + let requestBody: CompactPollBody = CompactPollBody( + requests: rooms + .map { roomId -> CompactPollBody.Room in + CompactPollBody.Room( + id: roomId, + fromMessageServerId: (useMessageLimit ? nil : + storage.getLastMessageServerID(for: roomId, on: server) + ), + fromDeletionServerId: (useMessageLimit ? nil : + storage.getLastDeletionServerID(for: roomId, on: server) + ), + legacyAuthToken: nil + ) } - return when(fulfilled: promises) - } + ) + + guard let body: Data = try? JSONEncoder().encode(requestBody) else { + return Promise(error: HTTP.Error.invalidJSON) + } + + let request = Request( + verb: .post, + room: nil, + server: server, + endpoint: .legacyCompactPoll(legacyAuth: false), + body: body, + isAuthRequired: true + ) + + return send(request) + .then(on: OpenGroupAPIV2.workQueue) { data -> Promise in + let response: CompactPollResponse = try data.decoded(as: CompactPollResponse.self, customError: Error.parsingFailed) + + return when( + fulfilled: response.results + .map { (result: CompactPollResponse.Result) in + process(messages: result.messages, for: result.room, on: server) + .then(on: OpenGroupAPIV2.workQueue) { _ in + process(deletions: result.deletions, for: result.room, on: server) + } + } + ).then(on: OpenGroupAPIV2.workQueue) { _ in Promise.value(response) } } } - // MARK: Authorization + public static func legacyCompactPoll(_ server: String) -> Promise { + let storage: SessionMessagingKitStorageProtocol = SNMessagingKitConfiguration.shared.storage + let rooms: [String] = storage.getAllV2OpenGroups().values + .filter { $0.server == server } + .map { $0.room } + var getAuthTokenPromises: [String: Promise] = [:] + let useMessageLimit = (hasPerformedInitialPoll[server] != true && timeSinceLastOpen > OpenGroupPollerV2.maxInactivityPeriod) + + hasPerformedInitialPoll[server] = true + + if !hasUpdatedLastOpenDate { + UserDefaults.standard[.lastOpen] = Date() + hasUpdatedLastOpenDate = true + } + + for room in rooms { + getAuthTokenPromises[room] = getAuthToken(for: room, on: server) + } + + let requestBody: CompactPollBody = CompactPollBody( + requests: rooms + .map { roomId -> CompactPollBody.Room in + CompactPollBody.Room( + id: roomId, + fromMessageServerId: (useMessageLimit ? nil : + storage.getLastMessageServerID(for: roomId, on: server) + ), + fromDeletionServerId: (useMessageLimit ? nil : + storage.getLastDeletionServerID(for: roomId, on: server) + ), + legacyAuthToken: nil + ) + } + ) + + return when(fulfilled: [Promise](getAuthTokenPromises.values)) + .then(on: OpenGroupAPIV2.workQueue) { _ -> Promise in + let requestBodyWithAuthTokens: CompactPollBody = CompactPollBody( + requests: requestBody.requests.compactMap { oldRoom -> CompactPollBody.Room? in + guard let authToken: String = getAuthTokenPromises[oldRoom.id]?.value else { return nil } + + return CompactPollBody.Room( + id: oldRoom.id, + fromMessageServerId: oldRoom.fromMessageServerId, + fromDeletionServerId: oldRoom.fromDeletionServerId, + legacyAuthToken: authToken + ) + } + ) + + guard let body: Data = try? JSONEncoder().encode(requestBodyWithAuthTokens) else { + return Promise(error: HTTP.Error.invalidJSON) + } + + let request = Request( + verb: .post, + room: nil, + server: server, + endpoint: .legacyCompactPoll(legacyAuth: true), + body: body, + isAuthRequired: false + ) + + return send(request) + .then(on: OpenGroupAPIV2.workQueue) { data -> Promise in + let response: CompactPollResponse = try data.decoded(as: CompactPollResponse.self, customError: Error.parsingFailed) + + return when( + fulfilled: response.results + .compactMap { (result: CompactPollResponse.Result) -> Promise<[Deletion]>? in + // A 401 means that we didn't provide a (valid) auth token for a route that + // required one. We use this as an indication that the token we're using has + // expired. Note that a 403 has a different meaning; it means that we provided + // a valid token but it doesn't have a high enough permission level for the + // route in question. + guard result.statusCode != 401 else { + storage.writeSync { transaction in + storage.removeAuthToken(for: result.room, on: server, using: transaction) + } + + return nil + } + + return process(messages: result.messages, for: result.room, on: server) + .then(on: OpenGroupAPIV2.workQueue) { _ -> Promise<[Deletion]> in + process(deletions: result.deletions, for: result.room, on: server) + } + } + ).then(on: OpenGroupAPIV2.workQueue) { _ in Promise.value(response) } + } + } + } + + // MARK: - Authentication + + // TODO: Turn 'Sodium' and 'NonceGenerator16Byte' into protocols for unit testing + static func sign( + _ request: URLRequest, + with publicKey: String, + sodium: Sodium = Sodium(), + nonceGenerator: NonceGenerator16Byte = NonceGenerator16Byte() + ) -> URLRequest? { + guard let path: String = request.url?.path else { return nil } + + var updatedRequest: URLRequest = request + let method: String = (request.httpMethod ?? "GET") + let timestamp: Int = Int(floor(Date().timeIntervalSince1970)) + let nonce: Data = Data(nonceGenerator.nonce()) + + guard let publicKeyData: Data = publicKey.dataFromHex() else { return nil } + guard let userKeyPair: ECKeyPair = SNMessagingKitConfiguration.shared.storage.getUserKeyPair() else { + return nil + } +// guard let blindedKeyPair: ECKeyPair = try? userKeyPair.convert(to: .blinded, with: publicKey) else { +// return nil +// } + // TODO: Change this back once you figure out why it's busted + let blindedKeyPair: ECKeyPair = userKeyPair + + // Generate the sharedSecret by "aB || A || B" where + // a, A are the users private and public keys respectively, + // B is the SOGS public key + let maybeSharedSecret: Data? = sodium.sharedSecret(blindedKeyPair.privateKey.bytes, publicKeyData.bytes)? + .appending(blindedKeyPair.publicKey) + .appending(publicKeyData.bytes) + + // Generate the hash to be sent along with the request + // intermediateHash = Blake2B(sharedSecret, size=42, salt=noncebytes, person='sogs.shared_keys') + // secretHash = Blake2B( + // Method || Path || Timestamp || Body, + // size=42, + // key=r, + // salt=noncebytes, + // person='sogs.auth_header' + // ) + let secretHashMessage: Bytes = method.bytes + .appending(path.bytes) + .appending("\(timestamp)".bytes) + .appending(request.httpBody?.bytes ?? []) // TODO: Might need to do the 'httpBodyStream' as well??? + + guard let sharedSecret: Data = maybeSharedSecret else { return nil } + guard let intermediateHash: Bytes = sodium.genericHash.hashSaltPersonal(message: sharedSecret.bytes, outputLength: 42, key: nil, salt: nonce.bytes, personal: Personalization.sharedKeys.bytes) else { + return nil + } + guard let secretHash: Bytes = sodium.genericHash.hashSaltPersonal(message: secretHashMessage, outputLength: 42, key: intermediateHash, salt: nonce.bytes, personal: Personalization.authHeader.bytes) else { + return nil + } + + updatedRequest.allHTTPHeaderFields = (request.allHTTPHeaderFields ?? [:]) + .updated(with: [ + Header.sogsPubKey.rawValue: blindedKeyPair.hexEncodedPublicKey, + Header.sogsTimestamp.rawValue: "\(timestamp)", + Header.sogsNonce.rawValue: nonce.base64EncodedString(), + Header.sogsHash.rawValue: secretHash.toBase64() + ]) + + return updatedRequest + } + private static func getAuthToken(for room: String, on server: String) -> Promise { + // TODO: Do we need to check the `/capabilities` of the SOGS to determine if it has new auth and if not then fall back to the old auth approach?????? let storage = SNMessagingKitConfiguration.shared.storage - if let authToken = storage.getAuthToken(for: room, on: server) { + + if let authToken: String = storage.getAuthToken(for: room, on: server) { return Promise.value(authToken) - } else { - if let authTokenPromise = authTokenPromises["\(server).\(room)"] { - return authTokenPromise - } else { - let promise = requestNewAuthToken(for: room, on: server) - .then(on: OpenGroupAPIV2.workQueue) { claimAuthToken($0, for: room, on: server) } - .then(on: OpenGroupAPIV2.workQueue) { authToken -> Promise in - let (promise, seal) = Promise.pending() - storage.write(with: { transaction in - storage.setAuthToken(for: room, on: server, to: authToken, using: transaction) - }, completion: { - seal.fulfill(authToken) - }) - return promise - } - promise.done(on: OpenGroupAPIV2.workQueue) { _ in - authTokenPromises["\(server).\(room)"] = nil - }.catch(on: OpenGroupAPIV2.workQueue) { _ in - authTokenPromises["\(server).\(room)"] = nil - } - authTokenPromises["\(server).\(room)"] = promise + } + + if let authTokenPromise: Promise = authTokenPromises.wrappedValue["\(server).\(room)"] { + return authTokenPromise + } + + let promise: Promise = requestNewAuthToken(for: room, on: server) + .then(on: OpenGroupAPIV2.workQueue) { claimAuthToken($0, for: room, on: server) } + .then(on: OpenGroupAPIV2.workQueue) { authToken -> Promise in + let (promise, seal) = Promise.pending() + storage.write(with: { transaction in + storage.setAuthToken(for: room, on: server, to: authToken, using: transaction) + }, completion: { + seal.fulfill(authToken) + }) return promise } - } + + promise + .done(on: OpenGroupAPIV2.workQueue) { _ in + authTokenPromises.wrappedValue["\(server).\(room)"] = nil + } + .catch(on: OpenGroupAPIV2.workQueue) { _ in + authTokenPromises.wrappedValue["\(server).\(room)"] = nil + } + + authTokenPromises.wrappedValue["\(server).\(room)"] = promise + return promise } public static func requestNewAuthToken(for room: String, on server: String) -> Promise { SNLog("Requesting auth token for server: \(server).") - guard let userKeyPair = SNMessagingKitConfiguration.shared.storage.getUserKeyPair() else { return Promise(error: Error.generic) } - let queryParameters = [ "public_key" : getUserHexEncodedPublicKey() ] - let request = Request(verb: .get, room: room, server: server, endpoint: "auth_token_challenge", queryParameters: queryParameters, isAuthRequired: false) - return send(request).map(on: OpenGroupAPIV2.workQueue) { json in - guard let challenge = json["challenge"] as? JSON, let base64EncodedCiphertext = challenge["ciphertext"] as? String, - let base64EncodedEphemeralPublicKey = challenge["ephemeral_public_key"] as? String, let ciphertext = Data(base64Encoded: base64EncodedCiphertext), - let ephemeralPublicKey = Data(base64Encoded: base64EncodedEphemeralPublicKey) else { - throw Error.parsingFailed + guard let userKeyPair: ECKeyPair = SNMessagingKitConfiguration.shared.storage.getUserKeyPair() else { + return Promise(error: Error.generic) + } + + let request: Request = Request( + verb: .get, + room: room, + server: server, + endpoint: .legacyAuthTokenChallenge(legacyAuth: true), + queryParameters: [ + .publicKey: getUserHexEncodedPublicKey() + ], + isAuthRequired: false + ) + + return send(request).map(on: OpenGroupAPIV2.workQueue) { data in + let response = try data.decoded(as: AuthTokenResponse.self, customError: Error.parsingFailed) + let symmetricKey = try AESGCM.generateSymmetricKey(x25519PublicKey: response.challenge.ephemeralPublicKey, x25519PrivateKey: userKeyPair.privateKey) + + guard let tokenAsData = try? AESGCM.decrypt(response.challenge.ciphertext, with: symmetricKey) else { + throw Error.decryptionFailed } - let symmetricKey = try AESGCM.generateSymmetricKey(x25519PublicKey: ephemeralPublicKey, x25519PrivateKey: userKeyPair.privateKey) - guard let tokenAsData = try? AESGCM.decrypt(ciphertext, with: symmetricKey) else { throw Error.decryptionFailed } + return tokenAsData.toHexString() } } - + public static func claimAuthToken(_ authToken: String, for room: String, on server: String) -> Promise { - let parameters = [ "public_key" : getUserHexEncodedPublicKey() ] - let headers = [ "Authorization" : authToken ] // Set explicitly here because is isn't in the database yet at this point - let request = Request(verb: .post, room: room, server: server, endpoint: "claim_auth_token", - parameters: parameters, headers: headers, isAuthRequired: false) + let requestBody: PublicKeyBody = PublicKeyBody(publicKey: getUserHexEncodedPublicKey()) + + guard let body: Data = try? JSONEncoder().encode(requestBody) else { + return Promise(error: HTTP.Error.invalidJSON) + } + + let request: Request = Request( + verb: .post, + room: room, + server: server, + endpoint: .legacyAuthTokenClaim(legacyAuth: true), + body: body, + headers: [ + // Set explicitly here because is isn't in the database yet at this point + .authorization: authToken + ], + isAuthRequired: false + ) + return send(request).map(on: OpenGroupAPIV2.workQueue) { _ in authToken } } - + /// Should be called when leaving a group. public static func deleteAuthToken(for room: String, on server: String) -> Promise { - let request = Request(verb: .delete, room: room, server: server, endpoint: "auth_token") + let request: Request = Request( + verb: .delete, + room: room, + server: server, + endpoint: .legacyAuthToken(legacyAuth: true) + ) + return send(request).map(on: OpenGroupAPIV2.workQueue) { _ in let storage = SNMessagingKitConfiguration.shared.storage + storage.write { transaction in storage.removeAuthToken(for: room, on: server, using: transaction) } } } - // MARK: File Storage + // MARK: - File Storage + public static func upload(_ file: Data, to room: String, on server: String) -> Promise { - let base64EncodedFile = file.base64EncodedString() - let parameters = [ "file" : base64EncodedFile ] - let request = Request(verb: .post, room: room, server: server, endpoint: "files", parameters: parameters) - return send(request).map(on: OpenGroupAPIV2.workQueue) { json in - guard let fileID = json["result"] as? UInt64 else { throw Error.parsingFailed } - return fileID + let requestBody: FileUploadBody = FileUploadBody(file: file.base64EncodedString()) + + guard let body: Data = try? JSONEncoder().encode(requestBody) else { + return Promise(error: HTTP.Error.invalidJSON) + } + + let request = Request(verb: .post, room: room, server: server, endpoint: .files, body: body) + + return send(request).map(on: OpenGroupAPIV2.workQueue) { data in + let response: FileUploadResponse = try data.decoded(as: FileUploadResponse.self, customError: Error.parsingFailed) + + return response.fileId } } public static func download(_ file: UInt64, from room: String, on server: String) -> Promise { - let request = Request(verb: .get, room: room, server: server, endpoint: "files/\(file)") - return send(request).map(on: OpenGroupAPIV2.workQueue) { json in - guard let base64EncodedFile = json["result"] as? String, let file = Data(base64Encoded: base64EncodedFile) else { throw Error.parsingFailed } - return file + let request = Request(verb: .get, room: room, server: server, endpoint: .file(file)) + + return send(request).map(on: OpenGroupAPIV2.workQueue) { data in + let response: FileDownloadResponse = try data.decoded(as: FileDownloadResponse.self, customError: Error.parsingFailed) + + return response.data } } - // MARK: Message Sending & Receiving - public static func send(_ message: OpenGroupMessageV2, to room: String, on server: String) -> Promise { - guard let signedMessage = message.sign() else { return Promise(error: Error.signingFailed) } - guard let json = signedMessage.toJSON() else { return Promise(error: Error.parsingFailed) } - let request = Request(verb: .post, room: room, server: server, endpoint: "messages", parameters: json) - return send(request).map(on: OpenGroupAPIV2.workQueue) { json in - guard let rawMessage = json["message"] as? JSON, let message = OpenGroupMessageV2.fromJSON(rawMessage) else { throw Error.parsingFailed } + // MARK: - Message Sending & Receiving + + public static func send(_ message: OpenGroupMessageV2, to room: String, on server: String, with publicKey: String) -> Promise { + // TODO: Test if we need a legacy version + guard let signedMessage = message.sign(with: publicKey) else { return Promise(error: Error.signingFailed) } + guard let body: Data = try? JSONEncoder().encode(signedMessage) else { + return Promise(error: Error.parsingFailed) + } + let request = Request(verb: .post, room: room, server: server, endpoint: .messages, body: body) + + return send(request).map(on: OpenGroupAPIV2.workQueue) { data in + let message: OpenGroupMessageV2 = try data.decoded(as: OpenGroupMessageV2.self, customError: Error.parsingFailed) Storage.shared.write { transaction in Storage.shared.addReceivedMessageTimestamp(message.sentTimestamp, using: transaction) } @@ -305,115 +466,177 @@ public final class OpenGroupAPIV2 : NSObject { public static func getMessages(for room: String, on server: String) -> Promise<[OpenGroupMessageV2]> { let storage = SNMessagingKitConfiguration.shared.storage - var queryParameters: [String:String] = [:] - if let lastMessageServerID = storage.getLastMessageServerID(for: room, on: server) { - queryParameters["from_server_id"] = String(lastMessageServerID) - } - let request = Request(verb: .get, room: room, server: server, endpoint: "messages", queryParameters: queryParameters) - return send(request).then(on: OpenGroupAPIV2.workQueue) { json -> Promise<[OpenGroupMessageV2]> in - try parseMessages(from: json, for: room, on: server) + let request: Request = Request( + verb: .get, + room: room, + server: server, + endpoint: .messages, + queryParameters: [ + .fromServerId: storage.getLastMessageServerID(for: room, on: server).map { String($0) } + ].compactMapValues { $0 } + ) + + return send(request).then(on: OpenGroupAPIV2.workQueue) { data -> Promise<[OpenGroupMessageV2]> in + let messages: [OpenGroupMessageV2] = try data.decoded(as: [OpenGroupMessageV2].self, customError: Error.parsingFailed) + + return process(messages: messages, for: room, on: server) } } - private static func parseMessages(from json: JSON, for room: String, on server: String) throws -> Promise<[OpenGroupMessageV2]> { + private static func process(messages: [OpenGroupMessageV2]?, for room: String, on server: String) -> Promise<[OpenGroupMessageV2]> { + guard let messages: [OpenGroupMessageV2] = messages, !messages.isEmpty else { return Promise.value([]) } + let storage = SNMessagingKitConfiguration.shared.storage - guard let rawMessages = json["messages"] as? [JSON] else { throw Error.parsingFailed } - let messages: [OpenGroupMessageV2] = rawMessages.compactMap { json in - guard let message = OpenGroupMessageV2.fromJSON(json), message.serverID != nil, let sender = message.sender, let data = Data(base64Encoded: message.base64EncodedData), - let base64EncodedSignature = message.base64EncodedSignature, let signature = Data(base64Encoded: base64EncodedSignature) else { - SNLog("Couldn't parse open group message from JSON: \(json).") - return nil - } - // Validate the message signature - let publicKey = Data(hex: sender.removingIdPrefixIfNeeded()) - let isValid = (try? Ed25519.verifySignature(signature, publicKey: publicKey, data: data)) ?? false - guard isValid else { - SNLog("Ignoring message with invalid signature.") - return nil - } - return message - } - let serverID = messages.map { $0.serverID! }.max() ?? 0 // Safe because messages with a nil serverID are filtered out - let lastMessageServerID = storage.getLastMessageServerID(for: room, on: server) ?? 0 + let serverID: Int64 = (messages.compactMap { $0.serverID }.max() ?? 0) + let lastMessageServerID: Int64 = (storage.getLastMessageServerID(for: room, on: server) ?? 0) + if serverID > lastMessageServerID { let (promise, seal) = Promise<[OpenGroupMessageV2]>.pending() - storage.write(with: { transaction in - storage.setLastMessageServerID(for: room, on: server, to: serverID, using: transaction) - }, completion: { - seal.fulfill(messages) - }) + + storage.write( + with: { transaction in + storage.setLastMessageServerID(for: room, on: server, to: serverID, using: transaction) + }, + completion: { + seal.fulfill(messages) + } + ) + return promise - } else { - return Promise.value(messages) } + + return Promise.value(messages) } - // MARK: Message Deletion + // MARK: - Message Deletion + public static func deleteMessage(with serverID: Int64, from room: String, on server: String) -> Promise { - let request = Request(verb: .delete, room: room, server: server, endpoint: "messages/\(serverID)") + let request: Request = Request( + verb: .delete, + room: room, + server: server, + endpoint: .messagesForServer(serverID) + ) + // TODO: Legacy version? return send(request).map(on: OpenGroupAPIV2.workQueue) { _ in } } public static func getDeletedMessages(for room: String, on server: String) -> Promise<[Deletion]> { let storage = SNMessagingKitConfiguration.shared.storage - var queryParameters: [String:String] = [:] - if let lastDeletionServerID = storage.getLastDeletionServerID(for: room, on: server) { - queryParameters["from_server_id"] = String(lastDeletionServerID) - } - let request = Request(verb: .get, room: room, server: server, endpoint: "deleted_messages", queryParameters: queryParameters) - return send(request).then(on: OpenGroupAPIV2.workQueue) { json -> Promise<[Deletion]> in - guard let rawDeletions = json["ids"] as? [JSON] else { throw Error.parsingFailed } - return parseDeletions(from: rawDeletions, for: room, on: server) + + let request: Request = Request( + verb: .get, + room: room, + server: server, + endpoint: .deletedMessages, + queryParameters: [ + .fromServerId: storage.getLastDeletionServerID(for: room, on: server).map { String($0) } + ].compactMapValues { $0 } + ) + // TODO: Legacy version? + return send(request).then(on: OpenGroupAPIV2.workQueue) { data -> Promise<[Deletion]> in + let response: DeletedMessagesResponse = try data.decoded(as: DeletedMessagesResponse.self, customError: Error.parsingFailed) + + return process(deletions: response.deletions, for: room, on: server) } } - private static func parseDeletions(from rawDeletions: [JSON], for room: String, on server: String) -> Promise<[Deletion]> { + private static func process(deletions: [Deletion]?, for room: String, on server: String) -> Promise<[Deletion]> { + guard let deletions: [Deletion] = deletions else { return Promise.value([]) } + let storage = SNMessagingKitConfiguration.shared.storage - let deletions = rawDeletions.compactMap { Deletion.from($0) } - let serverID = deletions.map { $0.id }.max() ?? 0 - let lastDeletionServerID = storage.getLastDeletionServerID(for: room, on: server) ?? 0 + let serverID: Int64 = (deletions.compactMap { $0.id }.max() ?? 0) + let lastDeletionServerID: Int64 = (storage.getLastDeletionServerID(for: room, on: server) ?? 0) + if serverID > lastDeletionServerID { let (promise, seal) = Promise<[Deletion]>.pending() - storage.write(with: { transaction in - storage.setLastDeletionServerID(for: room, on: server, to: serverID, using: transaction) - }, completion: { - seal.fulfill(deletions) - }) + + storage.write( + with: { transaction in + storage.setLastDeletionServerID(for: room, on: server, to: serverID, using: transaction) + }, + completion: { + seal.fulfill(deletions) + } + ) + return promise - } else { - return Promise.value(deletions) } + + return Promise.value(deletions) } - // MARK: Moderation + // MARK: - Moderation + public static func getModerators(for room: String, on server: String) -> Promise<[String]> { - let request = Request(verb: .get, room: room, server: server, endpoint: "moderators") - return send(request).map(on: OpenGroupAPIV2.workQueue) { json in - guard let moderators = json["moderators"] as? [String] else { throw Error.parsingFailed } - if var x = self.moderators[server] { - x[room] = Set(moderators) - self.moderators[server] = x - } else { - self.moderators[server] = [room:Set(moderators)] + let request: Request = Request( + verb: .get, + room: room, + server: server, + endpoint: .moderators + ) + // TODO: Legacy version? + return send(request) + .map(on: OpenGroupAPIV2.workQueue) { data in + let response: ModeratorsResponse = try data.decoded(as: ModeratorsResponse.self, customError: Error.parsingFailed) + + if var x = self.moderators[server] { + x[room] = Set(response.moderators) + self.moderators[server] = x + } + else { + self.moderators[server] = [room: Set(response.moderators)] + } + + return response.moderators } - return moderators - } } public static func ban(_ publicKey: String, from room: String, on server: String) -> Promise { - let parameters = [ "public_key" : publicKey ] - let request = Request(verb: .post, room: room, server: server, endpoint: "block_list", parameters: parameters) + let requestBody: PublicKeyBody = PublicKeyBody(publicKey: getUserHexEncodedPublicKey()) + + guard let body: Data = try? JSONEncoder().encode(requestBody) else { + return Promise(error: HTTP.Error.invalidJSON) + } + // TODO: Legacy version? + let request: Request = Request( + verb: .post, + room: room, + server: server, + endpoint: .blockList, + body: body + ) + return send(request).map(on: OpenGroupAPIV2.workQueue) { _ in } } public static func banAndDeleteAllMessages(_ publicKey: String, from room: String, on server: String) -> Promise { - let parameters = [ "public_key" : publicKey ] - let request = Request(verb: .post, room: room, server: server, endpoint: "ban_and_delete_all", parameters: parameters) + let requestBody: PublicKeyBody = PublicKeyBody(publicKey: getUserHexEncodedPublicKey()) + + guard let body: Data = try? JSONEncoder().encode(requestBody) else { + return Promise(error: HTTP.Error.invalidJSON) + } + + // TODO: Legacy version? + let request: Request = Request( + verb: .post, + room: room, + server: server, + endpoint: .banAndDeleteAll, + body: body + ) + return send(request).map(on: OpenGroupAPIV2.workQueue) { _ in } } public static func unban(_ publicKey: String, from room: String, on server: String) -> Promise { - let request = Request(verb: .delete, room: room, server: server, endpoint: "block_list/\(publicKey)") + let request: Request = Request( + verb: .delete, + room: room, + server: server, + endpoint: .blockListIndividual(publicKey) + ) + // TODO: Legacy version? return send(request).map(on: OpenGroupAPIV2.workQueue) { _ in } } @@ -421,60 +644,81 @@ public final class OpenGroupAPIV2 : NSObject { return moderators[server]?[room]?.contains(publicKey) ?? false } - // MARK: General + // MARK: - General + public static func getDefaultRoomsIfNeeded() { - Storage.shared.write(with: { transaction in - Storage.shared.setOpenGroupPublicKey(for: defaultServer, to: defaultServerPublicKey, using: transaction) - }, completion: { - let promise = attempt(maxRetryCount: 8, recoveringOn: DispatchQueue.main) { - OpenGroupAPIV2.getAllRooms(from: defaultServer) - } - let _ = promise.done(on: OpenGroupAPIV2.workQueue) { items in - items.forEach { getGroupImage(for: $0.id, on: defaultServer).retainUntilComplete() } - } - promise.catch(on: OpenGroupAPIV2.workQueue) { _ in - OpenGroupAPIV2.defaultRoomsPromise = nil - } - defaultRoomsPromise = promise - }) - } - - public static func getInfo(for room: String, on server: String) -> Promise { - let request = Request(verb: .get, room: room, server: server, endpoint: "rooms/\(room)", isAuthRequired: false) - let promise: Promise = send(request).map(on: OpenGroupAPIV2.workQueue) { json in - guard let rawRoom = json["room"] as? JSON, let id = rawRoom["id"] as? String, let name = rawRoom["name"] as? String else { throw Error.parsingFailed } - let imageID = rawRoom["image_id"] as? String - return Info(id: id, name: name, imageID: imageID) - } - return promise - } - - public static func getAllRooms(from server: String) -> Promise<[Info]> { - let request = Request(verb: .get, room: nil, server: server, endpoint: "rooms", isAuthRequired: false) - return send(request).map(on: OpenGroupAPIV2.workQueue) { json in - guard let rawRooms = json["rooms"] as? [JSON] else { throw Error.parsingFailed } - let rooms: [Info] = rawRooms.compactMap { json in - guard let id = json["id"] as? String, let name = json["name"] as? String else { - SNLog("Couldn't parse room from JSON: \(json).") - return nil + Storage.shared.write( + with: { transaction in + Storage.shared.setOpenGroupPublicKey(for: defaultServer, to: defaultServerPublicKey, using: transaction) + }, + completion: { + let promise = attempt(maxRetryCount: 8, recoveringOn: DispatchQueue.main) { + OpenGroupAPIV2.getAllRooms(from: defaultServer) } - let imageID = json["image_id"] as? String - return Info(id: id, name: name, imageID: imageID) + _ = promise.done(on: OpenGroupAPIV2.workQueue) { items in + items.forEach { getGroupImage(for: $0.id, on: defaultServer).retainUntilComplete() } + } + promise.catch(on: OpenGroupAPIV2.workQueue) { _ in + OpenGroupAPIV2.defaultRoomsPromise = nil + } + defaultRoomsPromise = promise + } + ) + } + + public static func getInfo(for room: String, on server: String) -> Promise { + let request: Request = Request( + verb: .get, + room: room, + server: server, + endpoint: .roomInfo(room), + isAuthRequired: false + ) + + return send(request) + .map(on: OpenGroupAPIV2.workQueue) { data in + let response: GetInfoResponse = try data.decoded(as: GetInfoResponse.self, customError: Error.parsingFailed) + + return response.room + } + } + + public static func getAllRooms(from server: String) -> Promise<[RoomInfo]> { + let request: Request = Request( + verb: .get, + room: nil, + server: server, + endpoint: .rooms, + isAuthRequired: false + ) + + return send(request) + .map(on: OpenGroupAPIV2.workQueue) { data in + let response: RoomsResponse = try data.decoded(as: RoomsResponse.self, customError: Error.parsingFailed) + + return response.rooms } - return rooms - } } public static func getMemberCount(for room: String, on server: String) -> Promise { - let request = Request(verb: .get, room: room, server: server, endpoint: "member_count") - return send(request).map(on: OpenGroupAPIV2.workQueue) { json in - guard let memberCount = json["member_count"] as? UInt64 else { throw Error.parsingFailed } - let storage = SNMessagingKitConfiguration.shared.storage - storage.write { transaction in - storage.setUserCount(to: memberCount, forV2OpenGroupWithID: "\(server).\(room)", using: transaction) + let request: Request = Request( + verb: .get, + room: room, + server: server, + endpoint: .legacyMemberCount(legacyAuth: true) + ) + // TODO: Non-legacy version? + return send(request) + .map(on: OpenGroupAPIV2.workQueue) { data in + let response: MemberCountResponse = try data.decoded(as: MemberCountResponse.self, customError: Error.parsingFailed) + + let storage = SNMessagingKitConfiguration.shared.storage + storage.write { transaction in + storage.setUserCount(to: response.memberCount, forV2OpenGroupWithID: "\(server).\(room)", using: transaction) + } + + return response.memberCount } - return memberCount - } } public static func getGroupImage(for room: String, on server: String) -> Promise { @@ -487,28 +731,41 @@ public final class OpenGroupAPIV2 : NSObject { // we only need to maintain one date in user defaults. On top of all of this we also // don't double up on fetch requests by storing the existing request as a promise if // there is one. - let lastOpenGroupImageUpdate = UserDefaults.standard[.lastOpenGroupImageUpdate] - let now = Date() - let timeSinceLastUpdate = given(lastOpenGroupImageUpdate) { now.timeIntervalSince($0) } ?? .greatestFiniteMagnitude - let updateInterval: TimeInterval = 7 * 24 * 60 * 60 + let lastOpenGroupImageUpdate: Date? = UserDefaults.standard[.lastOpenGroupImageUpdate] + let now: Date = Date() + let timeSinceLastUpdate: TimeInterval = (given(lastOpenGroupImageUpdate) { now.timeIntervalSince($0) } ?? .greatestFiniteMagnitude) + let updateInterval: TimeInterval = (7 * 24 * 60 * 60) + if let data = Storage.shared.getOpenGroupImage(for: room, on: server), server == defaultServer, timeSinceLastUpdate < updateInterval { return Promise.value(data) - } else if let promise = groupImagePromises["\(server).\(room)"] { - return promise - } else { - let request = Request(verb: .get, room: room, server: server, endpoint: "rooms/\(room)/image", isAuthRequired: false) - let promise: Promise = send(request).map(on: OpenGroupAPIV2.workQueue) { json in - guard let base64EncodedFile = json["result"] as? String, let file = Data(base64Encoded: base64EncodedFile) else { throw Error.parsingFailed } - if server == defaultServer { - Storage.shared.write { transaction in - Storage.shared.setOpenGroupImage(to: file, for: room, on: server, using: transaction) - } - UserDefaults.standard[.lastOpenGroupImageUpdate] = now - } - return file - } - groupImagePromises["\(server).\(room)"] = promise + } + + if let promise = groupImagePromises["\(server).\(room)"] { return promise } + + let request: Request = Request( + verb: .get, + room: room, + server: server, + endpoint: .roomImage(room), + isAuthRequired: false + ) + // TODO: Legacy version (doesn't work on new SOGS) + let promise: Promise = send(request).map(on: OpenGroupAPIV2.workQueue) { data in + let response: FileDownloadResponse = try data.decoded(as: FileDownloadResponse.self, customError: Error.parsingFailed) + + if server == defaultServer { + Storage.shared.write { transaction in + Storage.shared.setOpenGroupImage(to: response.data, for: room, on: server, using: transaction) + } + UserDefaults.standard[.lastOpenGroupImageUpdate] = now + } + + return response.data + } + groupImagePromises["\(server).\(room)"] = promise + + return promise } } diff --git a/SessionMessagingKit/Open Groups/OpenGroupManagerV2.swift b/SessionMessagingKit/Open Groups/OpenGroupManagerV2.swift index 9e7f7fe73..804a1b730 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManagerV2.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManagerV2.swift @@ -82,6 +82,7 @@ public final class OpenGroupManagerV2 : NSObject { public func delete(_ openGroup: OpenGroupV2, associatedWith thread: TSThread, using transaction: YapDatabaseReadWriteTransaction) { let storage = SNMessagingKitConfiguration.shared.storage + // Stop the poller if needed let openGroups = storage.getAllV2OpenGroups().values.filter { $0.server == openGroup.server } if openGroups.count == 1 && openGroups.last == openGroup { @@ -89,6 +90,7 @@ public final class OpenGroupManagerV2 : NSObject { poller?.stop() pollers[openGroup.server] = nil } + // Remove all data var messageIDs: Set = [] var messageTimestamps: Set = [] @@ -101,10 +103,14 @@ public final class OpenGroupManagerV2 : NSObject { Storage.shared.removeLastMessageServerID(for: openGroup.room, on: openGroup.server, using: transaction) Storage.shared.removeLastDeletionServerID(for: openGroup.room, on: openGroup.server, using: transaction) let _ = OpenGroupAPIV2.deleteAuthToken(for: openGroup.room, on: openGroup.server) - Storage.shared.removeOpenGroupPublicKey(for: openGroup.server, using: transaction) thread.removeAllThreadInteractions(with: transaction) thread.remove(with: transaction) Storage.shared.removeV2OpenGroup(for: thread.uniqueId!, using: transaction) + + // Only remove the open group public key if the user isn't in any other rooms + if openGroups.count <= 1 { + Storage.shared.removeOpenGroupPublicKey(for: openGroup.server, using: transaction) + } } // MARK: Convenience diff --git a/SessionMessagingKit/Open Groups/OpenGroupMessageV2.swift b/SessionMessagingKit/Open Groups/OpenGroupMessageV2.swift deleted file mode 100644 index bc82ad7ce..000000000 --- a/SessionMessagingKit/Open Groups/OpenGroupMessageV2.swift +++ /dev/null @@ -1,38 +0,0 @@ - -public struct OpenGroupMessageV2 { - public let serverID: Int64? - public let sender: String? - public let sentTimestamp: UInt64 - /// The serialized protobuf in base64 encoding. - public let base64EncodedData: String - /// When sending a message, the sender signs the serialized protobuf with their private key so that - /// a receiving user can verify that the message wasn't tampered with. - public let base64EncodedSignature: String? - - public func sign() -> OpenGroupMessageV2? { - let userKeyPair = SNMessagingKitConfiguration.shared.storage.getUserKeyPair()! - let data = Data(base64Encoded: base64EncodedData)! - guard let signature = try? Ed25519.sign(data, with: userKeyPair) else { - SNLog("Failed to sign open group message.") - return nil - } - return OpenGroupMessageV2(serverID: serverID, sender: sender, sentTimestamp: sentTimestamp, - base64EncodedData: base64EncodedData, base64EncodedSignature: signature.base64EncodedString()) - } - - public func toJSON() -> JSON? { - var result: JSON = [ "data" : base64EncodedData, "timestamp" : sentTimestamp ] - if let serverID = serverID { result["server_id"] = serverID } - if let sender = sender { result["public_key"] = sender } - if let base64EncodedSignature = base64EncodedSignature { result["signature"] = base64EncodedSignature } - return result - } - - public static func fromJSON(_ json: JSON) -> OpenGroupMessageV2? { - guard let base64EncodedData = json["data"] as? String, let sentTimestamp = json["timestamp"] as? UInt64 else { return nil } - let serverID = json["server_id"] as? Int64 - let sender = json["public_key"] as? String - let base64EncodedSignature = json["signature"] as? String - return OpenGroupMessageV2(serverID: serverID, sender: sender, sentTimestamp: sentTimestamp, base64EncodedData: base64EncodedData, base64EncodedSignature: base64EncodedSignature) - } -} diff --git a/SessionMessagingKit/Open Groups/Types/Endpoint.swift b/SessionMessagingKit/Open Groups/Types/Endpoint.swift new file mode 100644 index 000000000..ac6b4526b --- /dev/null +++ b/SessionMessagingKit/Open Groups/Types/Endpoint.swift @@ -0,0 +1,83 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +enum Endpoint { + case files + case file(UInt64) + + case messages + case messagesForServer(Int64) + case deletedMessages + + case moderators + + case blockList + case blockListIndividual(String) + case banAndDeleteAll + + case rooms + case roomInfo(String) + case roomImage(String) + + // Legacy endpoints (to be deprecated and removed) + case legacyCompactPoll(legacyAuth: Bool) + case legacyAuthToken(legacyAuth: Bool) + case legacyAuthTokenChallenge(legacyAuth: Bool) + case legacyAuthTokenClaim(legacyAuth: Bool) + case legacyMemberCount(legacyAuth: Bool) + + var path: String { + switch self { + case .files: return "files" + case .file(let fileId): return "files/\(fileId)" + + case .messages: return "messages" + case .messagesForServer(let serverId): return "messages/\(serverId)" + case .deletedMessages: return "deleted_messages" + + case .moderators: return "moderators" + + case .blockList: return "block_list" + case .blockListIndividual(let publicKey): return "block_list/\(publicKey)" + case .banAndDeleteAll: return "ban_and_delete_all" + + case .rooms: return "rooms" + case .roomInfo(let roomName): return "rooms/\(roomName)" + case .roomImage(let roomName): return "rooms/\(roomName)/image" + + // Legacy endpoints (to be deprecated and removed) + // TODO: Look for a nicer way to prepend 'legacy'? (OnionRequestAPI messes with this but the new auth needs it to be correct...) + case .legacyCompactPoll(let useLegacyAuth): + return "\(useLegacyAuth ? "" : "legacy/")compact_poll" + + case .legacyAuthToken(let useLegacyAuth): + return "\(useLegacyAuth ? "" : "legacy/")auth_token" + + case .legacyAuthTokenChallenge(let useLegacyAuth): + return "\(useLegacyAuth ? "" : "legacy/")auth_token_challenge" + + case .legacyAuthTokenClaim(let useLegacyAuth): + return "\(useLegacyAuth ? "" : "legacy/")claim_auth_token" + + case .legacyMemberCount(let useLegacyAuth): + return "\(useLegacyAuth ? "" : "legacy/")member_count" + } + } + + var useLegacyAuth: Bool { + switch self { + // File upload/download should use legacy auth + case .files, .file: return true + + case .legacyCompactPoll(let useLegacyAuth), + .legacyAuthToken(let useLegacyAuth), + .legacyAuthTokenChallenge(let useLegacyAuth), + .legacyAuthTokenClaim(let useLegacyAuth), + .legacyMemberCount(let useLegacyAuth): + return useLegacyAuth + + default: return false + } + } +} diff --git a/SessionMessagingKit/Open Groups/Types/Error.swift b/SessionMessagingKit/Open Groups/Types/Error.swift new file mode 100644 index 000000000..52610469f --- /dev/null +++ b/SessionMessagingKit/Open Groups/Types/Error.swift @@ -0,0 +1,25 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPIV2 { + public enum Error: LocalizedError { + case generic + case parsingFailed + case decryptionFailed + case signingFailed + case invalidURL + case noPublicKey + + public var errorDescription: String? { + switch self { + case .generic: return "An error occurred." + case .parsingFailed: return "Invalid response." + case .decryptionFailed: return "Couldn't decrypt response." + case .signingFailed: return "Couldn't sign message." + case .invalidURL: return "Invalid URL." + case .noPublicKey: return "Couldn't find server public key." + } + } + } +} diff --git a/SessionMessagingKit/Open Groups/Types/NonceGenerator16Byte.swift b/SessionMessagingKit/Open Groups/Types/NonceGenerator16Byte.swift new file mode 100644 index 000000000..e62a1c974 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Types/NonceGenerator16Byte.swift @@ -0,0 +1,9 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Sodium + +extension OpenGroupAPIV2 { + class NonceGenerator16Byte: NonceGenerator { + var NonceBytes: Int { 16 } + } +} diff --git a/SessionMessagingKit/Open Groups/Types/Personalization.swift b/SessionMessagingKit/Open Groups/Types/Personalization.swift new file mode 100644 index 000000000..44235af0d --- /dev/null +++ b/SessionMessagingKit/Open Groups/Types/Personalization.swift @@ -0,0 +1,15 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium + +extension OpenGroupAPIV2 { + public enum Personalization: String { + case sharedKeys = "sogs.shared_keys" + case authHeader = "sogs.auth_header" + + var bytes: Bytes { + return self.rawValue.bytes + } + } +} diff --git a/SessionMessagingKit/Open Groups/Types/Request.swift b/SessionMessagingKit/Open Groups/Types/Request.swift new file mode 100644 index 000000000..cb66217c3 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Types/Request.swift @@ -0,0 +1,57 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPIV2 { + struct Request { + let verb: HTTP.Verb + let room: String? + let server: String + let endpoint: Endpoint + let queryParameters: [QueryParam: String] + let body: Data? + let headers: [Header: String] + let isAuthRequired: Bool + /// Always `true` under normal circumstances. You might want to disable + /// this when running over Lokinet. + let useOnionRouting: Bool + + init( + verb: HTTP.Verb, + room: String?, + server: String, + endpoint: Endpoint, + queryParameters: [QueryParam: String] = [:], + body: Data? = nil, + headers: [Header: String] = [:], + isAuthRequired: Bool = true, + useOnionRouting: Bool = true + ) { + self.verb = verb + self.room = room + self.server = server + self.endpoint = endpoint + self.queryParameters = queryParameters + self.body = body + self.headers = headers + self.isAuthRequired = isAuthRequired + self.useOnionRouting = useOnionRouting + } + + var url: URL? { + guard verb == .get else { return URL(string: "\(server)/\(endpoint.path)") } + + return URL( + string: [ + "\(server)/\(endpoint.path)", + queryParameters + .map { key, value in "\(key.rawValue)=\(value)" } + .joined(separator: "&") + ] + .compactMap { $0 } + .filter { !$0.isEmpty } + .joined(separator: "?") + ) + } + } +} diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index fcb4f1a54..8542ea923 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -257,7 +257,8 @@ public final class MessageSender : NSObject { return promise } - // MARK: Open Groups + // MARK: - Open Groups + internal static func sendToOpenGroupDestination(_ destination: Message.Destination, message: Message, using transaction: Any) -> Promise { let (promise, seal) = Promise.pending() let storage = SNMessagingKitConfiguration.shared.storage @@ -266,7 +267,15 @@ public final class MessageSender : NSObject { if message.sentTimestamp == nil { // Visible messages will already have their sent timestamp set message.sentTimestamp = NSDate.millisecondTimestamp() } - message.sender = storage.getUserPublicKey() + + guard let threadId: String = message.threadID, let openGroupV2 = Storage.shared.getV2OpenGroup(for: threadId) else { + preconditionFailure() + } + + if let userDerivedKey: ECKeyPair = try? OWSIdentityManager.shared().identityKeyPair()?.convert(to: .blinded, with: openGroupV2.publicKey) { + message.sender = userDerivedKey.hexEncodedPublicKey + } + switch destination { case .contact(_): preconditionFailure() case .closedGroup(_): preconditionFailure() @@ -308,9 +317,12 @@ public final class MessageSender : NSObject { } // Send the result guard case .openGroupV2(let room, let server) = destination else { preconditionFailure() } + // TODO: Determine if the 'getV2OpenGroup' call will cause issues + guard let threadId: String = message.threadID, let openGroupV2 = Storage.shared.getV2OpenGroup(for: threadId) else { preconditionFailure() } let openGroupMessage = OpenGroupMessageV2(serverID: nil, sender: nil, sentTimestamp: message.sentTimestamp!, base64EncodedData: plaintext.base64EncodedString(), base64EncodedSignature: nil) - OpenGroupAPIV2.send(openGroupMessage, to: room, on: server).done(on: DispatchQueue.global(qos: .userInitiated)) { openGroupMessage in + + OpenGroupAPIV2.send(openGroupMessage, to: room, on: server, with: openGroupV2.publicKey).done(on: DispatchQueue.global(qos: .userInitiated)) { openGroupMessage in message.openGroupServerMessageID = given(openGroupMessage.serverID) { UInt64($0) } storage.write(with: { transaction in MessageSender.handleSuccessfulMessageSend(message, to: destination, serverTimestamp: openGroupMessage.sentTimestamp, using: transaction) diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/RegisterResponse.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/RegisterResponse.swift new file mode 100644 index 000000000..c1add2d2d --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/RegisterResponse.swift @@ -0,0 +1,11 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension PushNotificationAPI { + struct RegisterResponse: Codable { + let body: String + let code: Int + let message: String? + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/UnregisterResponse.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/UnregisterResponse.swift new file mode 100644 index 000000000..d14776e76 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/UnregisterResponse.swift @@ -0,0 +1,11 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension PushNotificationAPI { + struct UnregisterResponse: Codable { + let body: String + let code: Int + let message: String? + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift index 8fccb96ec..a9f5b8d02 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift @@ -3,10 +3,20 @@ import PromiseKit @objc(LKPushNotificationAPI) public final class PushNotificationAPI : NSObject { + struct RequestBody: Codable { + let token: String + let pubKey: String? + } + + struct ClosedGroupRequestBody: Codable { + let token: String + let pubKey: String + } - // MARK: Settings + // MARK: - Settings public static let server = "https://live.apns.getsession.org" public static let serverPublicKey = "642a6585919742e5a2d4dc51244964fbcd8bcab2b75612407de58b810740d049" + private static let maxRetryCount: UInt = 4 private static let tokenExpirationInterval: TimeInterval = 12 * 60 * 60 @@ -15,29 +25,38 @@ public final class PushNotificationAPI : NSObject { public var endpoint: String { switch self { - case .subscribe: return "subscribe_closed_group" - case .unsubscribe: return "unsubscribe_closed_group" + case .subscribe: return "subscribe_closed_group" + case .unsubscribe: return "unsubscribe_closed_group" } } } - // MARK: Initialization + // MARK: - Initialization + private override init() { } - // MARK: Registration + // MARK: - Registration + public static func unregister(_ token: Data) -> Promise { - let hexEncodedToken = token.toHexString() - let parameters = [ "token" : hexEncodedToken ] + let requestBody: RequestBody = RequestBody(token: token.toHexString(), pubKey: nil) + + guard let body: Data = try? JSONEncoder().encode(requestBody) else { + return Promise(error: HTTP.Error.invalidJSON) + } + let url = URL(string: "\(server)/unregister")! - let request = TSRequest(url: url, method: "POST", parameters: parameters) - request.allHTTPHeaderFields = [ "Content-Type" : "application/json" ] + var request: URLRequest = URLRequest(url: url) + request.httpMethod = "POST" + request.allHTTPHeaderFields = [ Header.contentType.rawValue: "application/json" ] + request.httpBody = body + let promise: Promise = attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) { OnionRequestAPI.sendOnionRequest(request, to: server, target: "/loki/v2/lsrpc", using: serverPublicKey).map2 { response in - guard let json = response["body"] as? JSON else { + guard let response: UnregisterResponse = try? response.decoded(as: UnregisterResponse.self) else { return SNLog("Couldn't unregister from push notifications.") } - guard json["code"] as? Int != 0 else { - return SNLog("Couldn't unregister from push notifications due to error: \(json["message"] as? String ?? "nil").") + guard response.code != 0 else { + return SNLog("Couldn't unregister from push notifications due to error: \(response.message ?? "nil").") } } } @@ -57,7 +76,13 @@ public final class PushNotificationAPI : NSObject { } public static func register(with token: Data, publicKey: String, isForcedUpdate: Bool) -> Promise { - let hexEncodedToken = token.toHexString() + let hexEncodedToken: String = token.toHexString() + let requestBody: RequestBody = RequestBody(token: hexEncodedToken, pubKey: publicKey) + + guard let body: Data = try? JSONEncoder().encode(requestBody) else { + return Promise(error: HTTP.Error.invalidJSON) + } + let userDefaults = UserDefaults.standard let oldToken = userDefaults[.deviceToken] let lastUploadTime = userDefaults[.lastDeviceTokenUpload] @@ -66,18 +91,22 @@ public final class PushNotificationAPI : NSObject { SNLog("Device token hasn't changed or expired; no need to re-upload.") return Promise { $0.fulfill(()) } } - let parameters = [ "token" : hexEncodedToken, "pubKey" : publicKey ] + let url = URL(string: "\(server)/register")! - let request = TSRequest(url: url, method: "POST", parameters: parameters) - request.allHTTPHeaderFields = [ "Content-Type" : "application/json" ] + var request: URLRequest = URLRequest(url: url) + request.httpMethod = "POST" + request.allHTTPHeaderFields = [ Header.contentType.rawValue: "application/json" ] + request.httpBody = body + let promise: Promise = attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) { OnionRequestAPI.sendOnionRequest(request, to: server, target: "/loki/v2/lsrpc", using: serverPublicKey).map2 { response in - guard let json = response["body"] as? JSON else { + guard let response: RegisterResponse = try? response.decoded(as: RegisterResponse.self) else { return SNLog("Couldn't register device token.") } - guard json["code"] as? Int != 0 else { - return SNLog("Couldn't register device token due to error: \(json["message"] as? String ?? "nil").") + guard response.code != 0 else { + return SNLog("Couldn't register device token due to error: \(response.message ?? "nil").") } + userDefaults[.deviceToken] = hexEncodedToken userDefaults[.lastDeviceTokenUpload] = now userDefaults[.isUsingFullAPNs] = true @@ -101,18 +130,26 @@ public final class PushNotificationAPI : NSObject { @discardableResult public static func performOperation(_ operation: ClosedGroupOperation, for closedGroupPublicKey: String, publicKey: String) -> Promise { let isUsingFullAPNs = UserDefaults.standard[.isUsingFullAPNs] + let requestBody: ClosedGroupRequestBody = ClosedGroupRequestBody(token: closedGroupPublicKey, pubKey: publicKey) + guard isUsingFullAPNs else { return Promise { $0.fulfill(()) } } - let parameters = [ "closedGroupPublicKey" : closedGroupPublicKey, "pubKey" : publicKey ] + guard let body: Data = try? JSONEncoder().encode(requestBody) else { + return Promise(error: HTTP.Error.invalidJSON) + } + let url = URL(string: "\(server)/\(operation.endpoint)")! - let request = TSRequest(url: url, method: "POST", parameters: parameters) - request.allHTTPHeaderFields = [ "Content-Type" : "application/json" ] + var request: URLRequest = URLRequest(url: url) + request.httpMethod = "POST" + request.allHTTPHeaderFields = [ Header.contentType.rawValue: "application/json" ] + request.httpBody = body + let promise: Promise = attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) { OnionRequestAPI.sendOnionRequest(request, to: server, target: "/loki/v2/lsrpc", using: serverPublicKey).map2 { response in - guard let json = response["body"] as? JSON else { + guard let response: RegisterResponse = try? response.decoded(as: RegisterResponse.self) else { return SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey).") } - guard json["code"] as? Int != 0 else { - return SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey) due to error: \(json["message"] as? String ?? "nil").") + guard response.code != 0 else { + return SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey) due to error: \(response.message ?? "nil").") } } } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift index d5eee9ad1..549a8dda0 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift @@ -46,25 +46,32 @@ public final class OpenGroupPollerV2 : NSObject { self.isPolling = true let (promise, seal) = Promise.pending() promise.retainUntilComplete() - OpenGroupAPIV2.compactPoll(server).done(on: OpenGroupAPIV2.workQueue) { [weak self] bodies in - guard let self = self else { return } - self.isPolling = false - bodies.forEach { self.handleCompactPollBody($0, isBackgroundPoll: isBackgroundPoll) } - seal.fulfill(()) - }.catch(on: OpenGroupAPIV2.workQueue) { error in - SNLog("Open group polling failed due to error: \(error).") - self.isPolling = false - seal.fulfill(()) // The promise is just used to keep track of when we're done - } + + // TODO: Update to use the non-legacy version +// OpenGroupAPIV2.compactPoll(server) + OpenGroupAPIV2.legacyCompactPoll(server) + .done(on: OpenGroupAPIV2.workQueue) { [weak self] response in + guard let self = self else { return } + self.isPolling = false + response.results.forEach { self.handleCompactPollBody($0, isBackgroundPoll: isBackgroundPoll) } + seal.fulfill(()) + } + .catch(on: OpenGroupAPIV2.workQueue) { error in + SNLog("Open group polling failed due to error: \(error).") + self.isPolling = false + seal.fulfill(()) // The promise is just used to keep track of when we're done + } + return promise } - private func handleCompactPollBody(_ body: OpenGroupAPIV2.CompactPollResponseBody, isBackgroundPoll: Bool) { + private func handleCompactPollBody(_ body: OpenGroupAPIV2.CompactPollResponse.Result, isBackgroundPoll: Bool) { let storage = SNMessagingKitConfiguration.shared.storage // - Messages // Sorting the messages by server ID before importing them fixes an issue where messages that quote older messages can't find those older messages let openGroupID = "\(server).\(body.room)" - let messages = body.messages.sorted { $0.serverID! < $1.serverID! } // Safe because messages with a nil serverID are filtered out + let messages = (body.messages ?? []).sorted { ($0.serverID ?? 0) < ($1.serverID ?? 0) } + storage.write { transaction in messages.forEach { message in guard let data = Data(base64Encoded: message.base64EncodedData) else { @@ -82,24 +89,29 @@ public final class OpenGroupPollerV2 : NSObject { } } } + // - Moderators if var x = OpenGroupAPIV2.moderators[server] { - x[body.room] = Set(body.moderators) + x[body.room] = Set(body.moderators ?? []) OpenGroupAPIV2.moderators[server] = x - } else { - OpenGroupAPIV2.moderators[server] = [ body.room : Set(body.moderators) ] } + else { + OpenGroupAPIV2.moderators[server] = [ body.room : Set(body.moderators ?? []) ] + } + // - Deletions - let deletedMessageServerIDs = Set(body.deletions.map { UInt64($0.deletedMessageID) }) + let deletedMessageServerIDs = Set((body.deletions ?? []).map { UInt64($0.deletedMessageID) }) storage.write { transaction in let transaction = transaction as! YapDatabaseReadWriteTransaction guard let threadID = storage.v2GetThreadID(for: openGroupID), let thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction) else { return } var messagesToRemove: [TSMessage] = [] + thread.enumerateInteractions(with: transaction) { interaction, stop in guard let message = interaction as? TSMessage, deletedMessageServerIDs.contains(message.openGroupServerMessageID) else { return } messagesToRemove.append(message) } + messagesToRemove.forEach { $0.remove(with: transaction) } } } diff --git a/SessionMessagingKit/Utilities/Atomic.swift b/SessionMessagingKit/Utilities/Atomic.swift new file mode 100644 index 000000000..8ba7ca568 --- /dev/null +++ b/SessionMessagingKit/Utilities/Atomic.swift @@ -0,0 +1,27 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +/// The `Atomic` wrapper is a generic wrapper providing a thread-safe way to get and set a value +@propertyWrapper +struct Atomic { + private let lock = DispatchSemaphore(value: 1) + private var value: Value + + init(_ initialValue: Value) { + self.value = initialValue + } + + var wrappedValue: Value { + get { + lock.wait() + defer { lock.signal() } + return value + } + set { + lock.wait() + value = newValue + lock.signal() + } + } +} diff --git a/SessionMessagingKit/Utilities/ECKeyPair+Conversion.swift b/SessionMessagingKit/Utilities/ECKeyPair+Conversion.swift new file mode 100644 index 000000000..f420b5164 --- /dev/null +++ b/SessionMessagingKit/Utilities/ECKeyPair+Conversion.swift @@ -0,0 +1,34 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Curve25519Kit +import SessionUtilitiesKit +import Sodium + +public extension ECKeyPair { + func convert(to targetPrefix: IdPrefix, with otherKey: String, using sodium: Sodium = Sodium()) throws -> ECKeyPair? { + guard let publicKeyPrefix: IdPrefix = IdPrefix(with: hexEncodedPublicKey) else { return nil } + + switch (publicKeyPrefix, targetPrefix) { + case (.standard, .blinded): // Only support standard -> blinded conversions + // TODO: Figure out why this is broken... +// guard let otherPubKeyData: Data = otherKey.data(using: .utf8) else { return nil } + guard let otherPubKeyData: Data = otherKey.dataFromHex() else { return nil } + guard let otherPubKeyHashBytes: Bytes = sodium.genericHash.hash(message: [UInt8](otherPubKeyData)) else { + return nil + } + guard let blindedPublicKey: Sodium.SharedSecret = sodium.sharedSecret(otherPubKeyHashBytes, [UInt8](publicKey)) else { + return nil + } + guard let blindedPrivateKey: Sodium.SharedSecret = sodium.sharedSecret(otherPubKeyHashBytes, [UInt8](privateKey)) else { + return nil + } + + return try BlindedECKeyPair(publicKeyData: blindedPublicKey, privateKeyData: blindedPrivateKey) + + case (.standard, .standard): return self + case (.blinded, .blinded): return self + default: return nil + } + } +} diff --git a/SessionMessagingKit/Utilities/Sodium+Conversion.swift b/SessionMessagingKit/Utilities/Sodium+Utilities.swift similarity index 55% rename from SessionMessagingKit/Utilities/Sodium+Conversion.swift rename to SessionMessagingKit/Utilities/Sodium+Utilities.swift index 9ad03dc67..d71891fd3 100644 --- a/SessionMessagingKit/Utilities/Sodium+Conversion.swift +++ b/SessionMessagingKit/Utilities/Sodium+Utilities.swift @@ -41,22 +41,27 @@ extension Sign { } extension Sodium { - public typealias SOGSDerivedKey = Data + public typealias SharedSecret = Data private static let publicKeyBytes: Int = Int(crypto_scalarmult_bytes()) private static let sharedSecretBytes: Int = Int(crypto_scalarmult_bytes()) - public func derivedKey(serverPublicKeyBytes: [UInt8], userKeyBytes: [UInt8]) -> SOGSDerivedKey? { - guard serverPublicKeyBytes.count == Sodium.publicKeyBytes && userKeyBytes.count == Sodium.publicKeyBytes else { return nil } + public func sharedSecret(_ firstKeyBytes: [UInt8], _ secondKeyBytes: [UInt8]) -> SharedSecret? { + guard firstKeyBytes.count == Sodium.publicKeyBytes && secondKeyBytes.count == Sodium.publicKeyBytes else { + return nil + } let sharedSecretPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.sharedSecretBytes) - let result = userKeyBytes.withUnsafeBytes { (userPublicKeyPtr: UnsafeRawBufferPointer) in - return serverPublicKeyBytes.withUnsafeBytes { (serverPublicKeyPtr: UnsafeRawBufferPointer) -> Int32 in - guard let serverKeyBaseAddress: UnsafePointer = serverPublicKeyPtr.baseAddress?.assumingMemoryBound(to: UInt8.self), let userKeyBaseAddress: UnsafePointer = userPublicKeyPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + let result = secondKeyBytes.withUnsafeBytes { (secondKeyPtr: UnsafeRawBufferPointer) -> Int32 in + return firstKeyBytes.withUnsafeBytes { (firstKeyPtr: UnsafeRawBufferPointer) -> Int32 in + guard let firstKeyBaseAddress: UnsafePointer = firstKeyPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return -1 + } + guard let secondKeyBaseAddress: UnsafePointer = secondKeyPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return -1 } - return crypto_scalarmult(sharedSecretPtr, serverKeyBaseAddress, userKeyBaseAddress) + return crypto_scalarmult(sharedSecretPtr, firstKeyBaseAddress, secondKeyBaseAddress) } } @@ -65,3 +70,30 @@ extension Sodium { return Data(bytes: sharedSecretPtr, count: Sodium.sharedSecretBytes) } } + +extension GenericHash { + public func hashSaltPersonal( + message: Bytes, + outputLength: Int, + key: Bytes? = nil, + salt: Bytes, + personal: Bytes + ) -> Bytes? { + var output: [UInt8] = [UInt8](repeating: 0, count: outputLength) + + let result = crypto_generichash_blake2b_salt_personal( + &output, + outputLength, + message, + UInt64(message.count), + key, + (key?.count ?? 0), + salt, + personal + ) + + guard result == 0 else { return nil } + + return output + } +} diff --git a/SessionSnodeKit/OnionRequestAPI.swift b/SessionSnodeKit/OnionRequestAPI.swift index 61e617c72..e6f89cb85 100644 --- a/SessionSnodeKit/OnionRequestAPI.swift +++ b/SessionSnodeKit/OnionRequestAPI.swift @@ -1,3 +1,4 @@ +import Foundation import CryptoSwift import PromiseKit import SessionUtilitiesKit @@ -301,54 +302,54 @@ public enum OnionRequestAPI { // MARK: Public API /// Sends an onion request to `snode`. Builds new paths as needed. - public static func sendOnionRequest(to snode: Snode, invoking method: Snode.Method, with parameters: JSON, associatedWith publicKey: String? = nil) -> Promise { + public static func sendOnionRequest(to snode: Snode, invoking method: Snode.Method, with parameters: JSON, associatedWith publicKey: String? = nil) -> Promise { let payload: JSON = [ "method" : method.rawValue, "params" : parameters ] - return sendOnionRequest(with: payload, to: Destination.snode(snode)).recover2 { error -> Promise in + return sendOnionRequest(with: payload, to: Destination.snode(snode)).recover2 { error -> Promise in guard case OnionRequestAPI.Error.httpRequestFailedAtDestination(let statusCode, let json, _) = error else { throw error } throw SnodeAPI.handleError(withStatusCode: statusCode, json: json, forSnode: snode, associatedWith: publicKey) ?? error } } /// Sends an onion request to `server`. Builds new paths as needed. - public static func sendOnionRequest(_ request: NSURLRequest, to server: String, target: String = "/loki/v3/lsrpc", using x25519PublicKey: String) -> Promise { - var rawHeaders = request.allHTTPHeaderFields ?? [:] - rawHeaders.removeValue(forKey: "User-Agent") - var headers: JSON = rawHeaders.mapValues { value in - switch value.lowercased() { - case "true": return true - case "false": return false - default: return value - } - } + public static func sendOnionRequest(_ request: URLRequest, to server: String, target: String = "/loki/v3/lsrpc", using x25519PublicKey: String) -> Promise { guard let url = request.url, let host = request.url?.host else { return Promise(error: Error.invalidURL) } - var endpoint = url.path.removingPrefix("/") - if let query = url.query { endpoint += "?\(query)" } - let scheme = url.scheme - let port = given(url.port) { UInt16($0) } - let parametersAsString: String - if let tsRequest = request as? TSRequest { - headers["Content-Type"] = "application/json" - let tsRequestParameters = tsRequest.parameters - if !tsRequestParameters.isEmpty { - guard let parameters = try? JSONSerialization.data(withJSONObject: tsRequestParameters, options: [ .fragmentsAllowed ]) else { - return Promise(error: HTTP.Error.invalidJSON) + + var headers: JSON = (request.allHTTPHeaderFields ?? [:]) + .mapValues { value -> Any in + switch value.lowercased() { + case "true": return true + case "false": return false + default: return value } - parametersAsString = String(bytes: parameters, encoding: .utf8) ?? "null" - } else { - parametersAsString = "null" - } - } else { - headers["Content-Type"] = request.allHTTPHeaderFields!["Content-Type"] - if let parametersAsInputStream = request.httpBodyStream, let parameters = try? Data(from: parametersAsInputStream) { - parametersAsString = "{ \"fileUpload\" : \"\(String(data: parameters.base64EncodedData(), encoding: .utf8) ?? "null")\" }" - } else { - parametersAsString = "null" } + .removingValue(forKey: "User-Agent") + + // Note: We need to remove the leading forward slash unless we are explicitly hitting a legacy + // endpoint (in which case we need it to ensure the request signing works correctly + // TODO: Confirm the 'removingPrefix' isn't going to break the request signing on non-legacy endpoints + let endpoint: String = url.path + .removingPrefix("/", if: !url.path.starts(with: "/legacy")) + .appending(url.query.map { value in "?\(value)" }) + let scheme: String? = url.scheme + let port: UInt16? = url.port.map { UInt16($0) } + let bodyAsString: String + + if let body: Data = request.httpBody { + headers["Content-Type"] = "application/json" // Assume data is JSON + bodyAsString = (String(data: body, encoding: .utf8) ?? "null") } + else if let inputStream: InputStream = request.httpBodyStream, let body: Data = try? Data(from: inputStream) { + headers["Content-Type"] = request.allHTTPHeaderFields!["Content-Type"] + bodyAsString = "{ \"fileUpload\" : \"\(String(data: body.base64EncodedData(), encoding: .utf8) ?? "null")\" }" + } + else { + bodyAsString = "null" + } + let payload: JSON = [ - "body" : parametersAsString, + "body" : bodyAsString, "endpoint" : endpoint, - "method" : request.httpMethod!, + "method" : (request.httpMethod ?? "GET"), // Default (if nil) is 'GET' "headers" : headers ] let destination = Destination.server(host: host, target: target, x25519PublicKey: x25519PublicKey, scheme: scheme, port: port) @@ -359,8 +360,8 @@ public enum OnionRequestAPI { return promise } - public static func sendOnionRequest(with payload: JSON, to destination: Destination) -> Promise { - let (promise, seal) = Promise.pending() + public static func sendOnionRequest(with payload: JSON, to destination: Destination) -> Promise { + let (promise, seal) = Promise.pending() var guardSnode: Snode? Threading.workQueue.async { // Avoid race conditions on `guardSnodes` and `paths` buildOnion(around: payload, targetedAt: destination).done2 { intermediate in @@ -401,12 +402,12 @@ public enum OnionRequestAPI { guard 200...299 ~= statusCode else { return seal.reject(Error.httpRequestFailedAtDestination(statusCode: UInt(statusCode), json: body, destination: destination)) } - seal.fulfill(body) + seal.fulfill(data) } else { guard 200...299 ~= statusCode else { return seal.reject(Error.httpRequestFailedAtDestination(statusCode: UInt(statusCode), json: json, destination: destination)) } - seal.fulfill(json) + seal.fulfill(data) } } catch { seal.reject(error) diff --git a/SessionSnodeKit/Utilities/String+Trimming.swift b/SessionSnodeKit/Utilities/String+Trimming.swift index 6d412b450..5e1f743e6 100644 --- a/SessionSnodeKit/Utilities/String+Trimming.swift +++ b/SessionSnodeKit/Utilities/String+Trimming.swift @@ -2,8 +2,18 @@ import Foundation internal extension String { - func removingPrefix(_ prefix: String) -> String { + func removingPrefix(_ prefix: String, if condition: Bool = true) -> String { + guard condition else { return self } guard let range = self.range(of: prefix), range.lowerBound == startIndex else { return self } + return String(self[range.upperBound.. String { + guard let value: String = other else { return self } + + return self.appending(value) + } +} diff --git a/SessionUtilitiesKit/Crypto/ECKeyPair+Hexadecimal.swift b/SessionUtilitiesKit/Crypto/ECKeyPair+Hexadecimal.swift index 72f15c5de..c1ac78934 100644 --- a/SessionUtilitiesKit/Crypto/ECKeyPair+Hexadecimal.swift +++ b/SessionUtilitiesKit/Crypto/ECKeyPair+Hexadecimal.swift @@ -20,3 +20,9 @@ public extension ECKeyPair { return true } } + +public extension BlindedECKeyPair { + @objc override var hexEncodedPublicKey: String { + return IdPrefix.blinded.rawValue + publicKey.map { String(format: "%02hhx", $0) }.joined() + } +} diff --git a/SessionUtilitiesKit/General/Array+Description.swift b/SessionUtilitiesKit/General/Array+Description.swift deleted file mode 100644 index 6ac99240a..000000000 --- a/SessionUtilitiesKit/General/Array+Description.swift +++ /dev/null @@ -1,7 +0,0 @@ - -public extension Array where Element : CustomStringConvertible { - - var prettifiedDescription: String { - return "[ " + map { $0.description }.joined(separator: ", ") + " ]" - } -} diff --git a/SessionUtilitiesKit/General/Array+Utilities.swift b/SessionUtilitiesKit/General/Array+Utilities.swift new file mode 100644 index 000000000..3a22fc210 --- /dev/null +++ b/SessionUtilitiesKit/General/Array+Utilities.swift @@ -0,0 +1,23 @@ + +public extension Array where Element : CustomStringConvertible { + + var prettifiedDescription: String { + return "[ " + map { $0.description }.joined(separator: ", ") + " ]" + } +} + +public extension Array { + func appending(_ other: Element) -> [Element] { + var updatedArray: [Element] = self + updatedArray.append(other) + + return updatedArray + } + + func appending(_ other: [Element]) -> [Element] { + var updatedArray: [Element] = self + updatedArray.append(contentsOf: other) + + return updatedArray + } +} diff --git a/SessionUtilitiesKit/General/Data+Trimming.swift b/SessionUtilitiesKit/General/Data+Trimming.swift deleted file mode 100644 index e16ebb094..000000000 --- a/SessionUtilitiesKit/General/Data+Trimming.swift +++ /dev/null @@ -1,18 +0,0 @@ - -public extension Data { - - func removingIdPrefixIfNeeded() -> Data { - var result = self - if result.count == 33 && IdPrefix(with: result.toHexString()) != nil { result.removeFirst() } - return result - } -} - -@objc public extension NSData { - - @objc func removingIdPrefixIfNeeded() -> NSData { - var result = self as Data - if result.count == 33 && IdPrefix(with: result.toHexString()) != nil { result.removeFirst() } - return result as NSData - } -} diff --git a/SessionUtilitiesKit/General/Data+Utilities.swift b/SessionUtilitiesKit/General/Data+Utilities.swift new file mode 100644 index 000000000..e0fddf1a3 --- /dev/null +++ b/SessionUtilitiesKit/General/Data+Utilities.swift @@ -0,0 +1,48 @@ +import Foundation + +public extension Data { + + func removingIdPrefixIfNeeded() -> Data { + var result = self + if result.count == 33 && IdPrefix(with: result.toHexString()) != nil { result.removeFirst() } + return result + } + + func appending(_ other: Data) -> Data { + var mutableData: Data = Data() + mutableData.append(self) + mutableData.append(other) + + return mutableData + } + + func appending(_ other: [UInt8]) -> Data { + var mutableData: Data = Data() + mutableData.append(self) + mutableData.append(contentsOf: other) + + return mutableData + } +} + +@objc public extension NSData { + + @objc func removingIdPrefixIfNeeded() -> NSData { + var result = self as Data + if result.count == 33 && IdPrefix(with: result.toHexString()) != nil { result.removeFirst() } + return result as NSData + } +} + +// MARK: - Decoding + +public extension Data { + func decoded(as type: T.Type, customError: Error? = nil) throws -> T { + do { + return try JSONDecoder().decode(type, from: self) + } + catch let error { + throw (customError ?? error) + } + } +} diff --git a/SessionUtilitiesKit/General/Dictionary+Description.swift b/SessionUtilitiesKit/General/Dictionary+Description.swift deleted file mode 100644 index f402736ac..000000000 --- a/SessionUtilitiesKit/General/Dictionary+Description.swift +++ /dev/null @@ -1,13 +0,0 @@ - -public extension Dictionary { - - var prettifiedDescription: String { - return "[ " + map { key, value in - let keyDescription = String(describing: key) - let valueDescription = String(describing: value) - let maxLength = 20 - let truncatedValueDescription = valueDescription.count > maxLength ? valueDescription.prefix(maxLength) + "..." : valueDescription - return keyDescription + " : " + truncatedValueDescription - }.joined(separator: ", ") + " ]" - } -} diff --git a/SessionUtilitiesKit/General/Dictionary+Utilities.swift b/SessionUtilitiesKit/General/Dictionary+Utilities.swift new file mode 100644 index 000000000..d01b3176d --- /dev/null +++ b/SessionUtilitiesKit/General/Dictionary+Utilities.swift @@ -0,0 +1,42 @@ + +public extension Dictionary { + + var prettifiedDescription: String { + return "[ " + map { key, value in + let keyDescription = String(describing: key) + let valueDescription = String(describing: value) + let maxLength = 20 + let truncatedValueDescription = valueDescription.count > maxLength ? valueDescription.prefix(maxLength) + "..." : valueDescription + return keyDescription + " : " + truncatedValueDescription + }.joined(separator: ", ") + " ]" + } +} + +// MARK: - Functional Convenience + +public extension Dictionary { + func setting(_ key: Key, _ value: Value?) -> [Key: Value] { + var updatedDictionary: [Key: Value] = self + updatedDictionary[key] = value + + return updatedDictionary + } + + func updated(with other: [Key: Value]) -> [Key: Value] { + var updatedDictionary: [Key: Value] = self + + other.forEach { key, value in + updatedDictionary[key] = value + } + + return updatedDictionary + } + + func removingValue(forKey key: Key) -> [Key: Value] { + var updatedDictionary: [Key: Value] = self + updatedDictionary.removeValue(forKey: key) + + return updatedDictionary + } +} + diff --git a/SessionUtilitiesKit/General/IdPrefix.swift b/SessionUtilitiesKit/General/IdPrefix.swift index 640fe85c5..a49a0f9ba 100644 --- a/SessionUtilitiesKit/General/IdPrefix.swift +++ b/SessionUtilitiesKit/General/IdPrefix.swift @@ -1,8 +1,20 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Curve25519Kit + +/// The `BlindedECKeyPair` is essentially the same as the `ECKeyPair` except it allows us to more easily distinguish between the two, +/// additionally when generating the `hexEncodedPublicKey` value it will apply the correct prefix +public class BlindedECKeyPair: ECKeyPair {} public enum IdPrefix: String, CaseIterable { case standard = "05" // Used for identified users, open groups, etc. case blinded = "15" // Used for participants in open groups + + public init?(with sessionId: String) { + guard ECKeyPair.isValidHexEncodedPublicKey(candidate: sessionId) else { return nil } + guard let targetPrefix: IdPrefix = IdPrefix(rawValue: String(sessionId.prefix(2))) else { return nil } + + self = targetPrefix + } } diff --git a/SessionUtilitiesKit/General/String+Encoding.swift b/SessionUtilitiesKit/General/String+Encoding.swift new file mode 100644 index 000000000..270f43ac2 --- /dev/null +++ b/SessionUtilitiesKit/General/String+Encoding.swift @@ -0,0 +1,18 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension String { + public func dataFromHex() -> Data? { + guard (self.count % 2) == 0 else { return nil } + + let chars = self.map { $0 } + let bytes: [UInt8] = stride(from: 0, to: chars.count, by: 2) + .map { index -> String in String(chars[index]) + String(chars[index + 1]) } + .compactMap { (str: String) -> UInt8? in UInt8(str, radix: 16) } + + guard (self.count / bytes.count) == 2 else { return nil } + + return Data(bytes) + } +} diff --git a/SessionUtilitiesKit/Meta/SessionUtilitiesKit.h b/SessionUtilitiesKit/Meta/SessionUtilitiesKit.h index c4aca0f08..13c7076ba 100644 --- a/SessionUtilitiesKit/Meta/SessionUtilitiesKit.h +++ b/SessionUtilitiesKit/Meta/SessionUtilitiesKit.h @@ -16,7 +16,6 @@ FOUNDATION_EXPORT const unsigned char SessionUtilitiesKitVersionString[]; #import #import #import -#import #import #import #import diff --git a/SessionUtilitiesKit/Networking/TSRequest.h b/SessionUtilitiesKit/Networking/TSRequest.h deleted file mode 100644 index 5c4f75d01..000000000 --- a/SessionUtilitiesKit/Networking/TSRequest.h +++ /dev/null @@ -1,29 +0,0 @@ -#import - -NS_ASSUME_NONNULL_BEGIN - -#define textSecureHTTPTimeOut 10 - -@interface TSRequest : NSMutableURLRequest - -@property (nonatomic, readonly) NSDictionary *parameters; - -- (instancetype)init NS_UNAVAILABLE; - -- (instancetype)initWithURL:(NSURL *)URL; - -- (instancetype)initWithURL:(NSURL *)URL - cachePolicy:(NSURLRequestCachePolicy)cachePolicy - timeoutInterval:(NSTimeInterval)timeoutInterval NS_UNAVAILABLE; - -- (instancetype)initWithURL:(NSURL *)URL - method:(NSString *)method - parameters:(nullable NSDictionary *)parameters; - -+ (instancetype)requestWithUrl:(NSURL *)url - method:(NSString *)method - parameters:(nullable NSDictionary *)parameters; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionUtilitiesKit/Networking/TSRequest.m b/SessionUtilitiesKit/Networking/TSRequest.m deleted file mode 100644 index 4d0951939..000000000 --- a/SessionUtilitiesKit/Networking/TSRequest.m +++ /dev/null @@ -1,64 +0,0 @@ -#import "TSRequest.h" - -NS_ASSUME_NONNULL_BEGIN - -@implementation TSRequest - -- (id)initWithURL:(NSURL *)URL { - self = [super initWithURL:URL - cachePolicy:NSURLRequestReloadIgnoringLocalAndRemoteCacheData - timeoutInterval:textSecureHTTPTimeOut]; - - if (!self) { - return nil; - } - - _parameters = @{}; - - return self; -} - -- (instancetype)init -{ - return nil; -} - -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wobjc-designated-initializers" - -- (instancetype)initWithURL:(NSURL *)URL - cachePolicy:(NSURLRequestCachePolicy)cachePolicy - timeoutInterval:(NSTimeInterval)timeoutInterval -{ - return nil; -} - -- (instancetype)initWithURL:(NSURL *)URL - method:(NSString *)method - parameters:(nullable NSDictionary *)parameters -{ - self = [super initWithURL:URL - cachePolicy:NSURLRequestReloadIgnoringLocalAndRemoteCacheData - timeoutInterval:textSecureHTTPTimeOut]; - - if (!self) { - return nil; - } - - _parameters = parameters ?: @{}; - - [self setHTTPMethod:method]; - - return self; -} - -+ (instancetype)requestWithUrl:(NSURL *)url - method:(NSString *)method - parameters:(nullable NSDictionary *)parameters -{ - return [[TSRequest alloc] initWithURL:url method:method parameters:parameters]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift b/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift index 3297ce14e..57ad21e43 100644 --- a/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift +++ b/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift @@ -45,7 +45,20 @@ extension MessageSender { let storage = SNMessagingKitConfiguration.shared.storage if let v2OpenGroup = storage.getV2OpenGroup(for: thread.uniqueId!) { let (promise, seal) = Promise.pending() - AttachmentUploadJob.upload(stream, using: { data in return OpenGroupAPIV2.upload(data, to: v2OpenGroup.room, on: v2OpenGroup.server) }, encrypt: false, onSuccess: { seal.fulfill(()) }, onFailure: { seal.reject($0) }) + AttachmentUploadJob.upload( + stream, + using: { data in + OpenGroupAPIV2.upload( + data, + to: v2OpenGroup.room, + on: v2OpenGroup.server + ) + }, + encrypt: false, + onSuccess: { seal.fulfill(()) }, + onFailure: { seal.reject($0) } + ) + return promise } else { let (promise, seal) = Promise.pending() @@ -78,7 +91,19 @@ extension MessageSender { let storage = SNMessagingKitConfiguration.shared.storage if let v2OpenGroup = storage.getV2OpenGroup(for: thread.uniqueId!) { let (promise, seal) = Promise.pending() - AttachmentUploadJob.upload(stream, using: { data in return OpenGroupAPIV2.upload(data, to: v2OpenGroup.room, on: v2OpenGroup.server) }, encrypt: false, onSuccess: { seal.fulfill(()) }, onFailure: { seal.reject($0) }) + AttachmentUploadJob.upload( + stream, + using: { data in + OpenGroupAPIV2.upload( + data, + to: v2OpenGroup.room, + on: v2OpenGroup.server + ) + }, + encrypt: false, + onSuccess: { seal.fulfill(()) }, + onFailure: { seal.reject($0) } + ) return promise } else { let (promise, seal) = Promise.pending() From 4f3900771efc6c34233889e5d022b354c7a10089 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 11 Feb 2022 16:48:16 +1100 Subject: [PATCH 004/157] More work on getting SOGS V4 integrated Updated the MessageSendJob to support V4 messages (V2 messages will be upgraded to V4 if they get re-encoded) Renamed the Message+Destination from 'openGroup' & 'openGroupV2' to 'legacyOpenGroup' and 'openGroup' Started plugging in more of the V4 APIs Renamed a number of the V2 APIs to start with 'legacy' --- Session.xcodeproj/project.pbxproj | 135 +- Session/Conversations/ConversationVC.swift | 3 +- .../Input View/MentionSelectionView.swift | 3 +- Session/Home/HomeVC.swift | 2 +- Session/Meta/AppDelegate.m | 2 +- Session/Open Groups/JoinOpenGroupVC.swift | 2 +- .../Open Groups/OpenGroupSuggestionGrid.swift | 13 +- .../Models/FileDownloadResponse.swift | 25 +- .../Models/FileUploadResponse.swift | 10 +- .../Models/LegacyFileDownloadResponse.swift | 35 + .../Models/LegacyFileUploadResponse.swift | 11 + .../Common Networking/QueryParam.swift | 4 + .../File Server/FileServerAPIV2.swift | 4 +- SessionMessagingKit/Jobs/MessageSendJob.swift | 149 +- .../Messages/Message+Destination.swift | 27 +- .../Open Groups/Models/BatchRequestInfo.swift | 90 ++ .../Open Groups/Models/Capabilities.swift | 40 + .../Open Groups/Models/FileResponse.swift | 19 + ...Body.swift => LegacyCompactPollBody.swift} | 2 +- ....swift => LegacyCompactPollResponse.swift} | 2 +- ...onse.swift => LegacyGetInfoResponse.swift} | 4 +- .../{RoomInfo.swift => LegacyRoomInfo.swift} | 2 +- ...sponse.swift => LegacyRoomsResponse.swift} | 4 +- .../Open Groups/Models/OGMessage.swift | 73 + .../Models/OpenGroupMessageV2.swift | 1 - .../Open Groups/Models/PinnedMessage.swift | 17 + .../Open Groups/Models/Room.swift | 107 ++ .../Open Groups/Models/RoomPollInfo.swift | 73 + .../Models/SendMessageRequest.swift | 67 + .../Open Groups/OpenGroupAPIV2+ObjC.swift | 6 +- .../Open Groups/OpenGroupAPIV2.swift | 1332 +++++++++++------ .../Open Groups/OpenGroupManagerV2.swift | 138 +- .../Open Groups/Types/Endpoint.swift | 166 +- .../Open Groups/Types/Request.swift | 39 +- .../Sending & Receiving/MessageSender.swift | 157 +- .../Utilities/Promise+Utilities.swift | 12 + .../Utilities/String+Utlities.swift | 11 + SessionSnodeKit/OnionRequestAPI.swift | 128 +- SessionUtilitiesKit/Networking/HTTP.swift | 2 +- 39 files changed, 2183 insertions(+), 734 deletions(-) create mode 100644 SessionMessagingKit/Common Networking/Models/LegacyFileDownloadResponse.swift create mode 100644 SessionMessagingKit/Common Networking/Models/LegacyFileUploadResponse.swift create mode 100644 SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift create mode 100644 SessionMessagingKit/Open Groups/Models/Capabilities.swift create mode 100644 SessionMessagingKit/Open Groups/Models/FileResponse.swift rename SessionMessagingKit/Open Groups/Models/{CompactPollBody.swift => LegacyCompactPollBody.swift} (94%) rename SessionMessagingKit/Open Groups/Models/{CompactPollResponse.swift => LegacyCompactPollResponse.swift} (92%) rename SessionMessagingKit/Open Groups/Models/{RoomsResponse.swift => LegacyGetInfoResponse.swift} (60%) rename SessionMessagingKit/Open Groups/Models/{RoomInfo.swift => LegacyRoomInfo.swift} (89%) rename SessionMessagingKit/Open Groups/Models/{GetInfoResponse.swift => LegacyRoomsResponse.swift} (60%) create mode 100644 SessionMessagingKit/Open Groups/Models/OGMessage.swift create mode 100644 SessionMessagingKit/Open Groups/Models/PinnedMessage.swift create mode 100644 SessionMessagingKit/Open Groups/Models/Room.swift create mode 100644 SessionMessagingKit/Open Groups/Models/RoomPollInfo.swift create mode 100644 SessionMessagingKit/Open Groups/Models/SendMessageRequest.swift create mode 100644 SessionMessagingKit/Utilities/Promise+Utilities.swift create mode 100644 SessionMessagingKit/Utilities/String+Utlities.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index b20cbbf47..c5888779b 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -789,29 +789,43 @@ FDC4380B27B31D7E00C60D73 /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4380A27B31D7E00C60D73 /* Request.swift */; }; FDC4381527B329CE00C60D73 /* NonceGenerator16Byte.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381427B329CE00C60D73 /* NonceGenerator16Byte.swift */; }; FDC4381727B32EC700C60D73 /* Personalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381627B32EC700C60D73 /* Personalization.swift */; }; - FDC4381A27B34EBA00C60D73 /* CompactPollBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381927B34EBA00C60D73 /* CompactPollBody.swift */; }; + FDC4381A27B34EBA00C60D73 /* LegacyCompactPollBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381927B34EBA00C60D73 /* LegacyCompactPollBody.swift */; }; FDC4381C27B354AC00C60D73 /* PublicKeyBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381B27B354AC00C60D73 /* PublicKeyBody.swift */; }; FDC4382027B36ADC00C60D73 /* Endpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381F27B36ADC00C60D73 /* Endpoint.swift */; }; FDC4382627B37F6900C60D73 /* DeletedMessagesResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382527B37F6900C60D73 /* DeletedMessagesResponse.swift */; }; FDC4382827B37FD300C60D73 /* ModeratorsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382727B37FD300C60D73 /* ModeratorsResponse.swift */; }; - FDC4382A27B3802D00C60D73 /* RoomsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382927B3802D00C60D73 /* RoomsResponse.swift */; }; + FDC4382A27B3802D00C60D73 /* LegacyRoomsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382927B3802D00C60D73 /* LegacyRoomsResponse.swift */; }; FDC4382C27B380E300C60D73 /* MemberCountResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382B27B380E300C60D73 /* MemberCountResponse.swift */; }; FDC4382F27B383AF00C60D73 /* UnregisterResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382E27B383AF00C60D73 /* UnregisterResponse.swift */; }; FDC4383127B3841C00C60D73 /* RegisterResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4383027B3841C00C60D73 /* RegisterResponse.swift */; }; FDC4383827B3863200C60D73 /* VersionResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4383727B3863200C60D73 /* VersionResponse.swift */; }; FDC4383A27B4696200C60D73 /* AuthTokenResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4383927B4696200C60D73 /* AuthTokenResponse.swift */; }; FDC4383E27B4708600C60D73 /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4383D27B4708600C60D73 /* Atomic.swift */; }; - FDC4384027B4746D00C60D73 /* GetInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4383F27B4746D00C60D73 /* GetInfoResponse.swift */; }; + FDC4384027B4746D00C60D73 /* LegacyGetInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4383F27B4746D00C60D73 /* LegacyGetInfoResponse.swift */; }; FDC4384727B47F4D00C60D73 /* OpenGroupMessageV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384327B47F4D00C60D73 /* OpenGroupMessageV2.swift */; }; - FDC4384827B47F4D00C60D73 /* RoomInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384427B47F4D00C60D73 /* RoomInfo.swift */; }; - FDC4384927B47F4D00C60D73 /* CompactPollResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384527B47F4D00C60D73 /* CompactPollResponse.swift */; }; + FDC4384827B47F4D00C60D73 /* LegacyRoomInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384427B47F4D00C60D73 /* LegacyRoomInfo.swift */; }; + FDC4384927B47F4D00C60D73 /* LegacyCompactPollResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384527B47F4D00C60D73 /* LegacyCompactPollResponse.swift */; }; FDC4384A27B47F4D00C60D73 /* Deletion.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384627B47F4D00C60D73 /* Deletion.swift */; }; FDC4384C27B47F7700C60D73 /* OpenGroupV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384B27B47F7700C60D73 /* OpenGroupV2.swift */; }; FDC4384F27B4804F00C60D73 /* Header.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384E27B4804F00C60D73 /* Header.swift */; }; FDC4385127B4807400C60D73 /* QueryParam.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4385027B4807400C60D73 /* QueryParam.swift */; }; - FDC4385727B484B700C60D73 /* FileUploadResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4385627B484B700C60D73 /* FileUploadResponse.swift */; }; + FDC4385727B484B700C60D73 /* LegacyFileUploadResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4385627B484B700C60D73 /* LegacyFileUploadResponse.swift */; }; FDC4385927B484E800C60D73 /* FileUploadBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4385827B484E800C60D73 /* FileUploadBody.swift */; }; - FDC4385B27B485DE00C60D73 /* FileDownloadResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4385A27B485DE00C60D73 /* FileDownloadResponse.swift */; }; + FDC4385B27B485DE00C60D73 /* LegacyFileDownloadResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4385A27B485DE00C60D73 /* LegacyFileDownloadResponse.swift */; }; + FDC4385D27B4C18900C60D73 /* Room.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4385C27B4C18900C60D73 /* Room.swift */; }; + FDC4385F27B4C4A200C60D73 /* PinnedMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4385E27B4C4A200C60D73 /* PinnedMessage.swift */; }; + FDC4386127B4CDDF00C60D73 /* FileResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4386027B4CDDF00C60D73 /* FileResponse.swift */; }; + FDC4386327B4D94E00C60D73 /* OGMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4386227B4D94E00C60D73 /* OGMessage.swift */; }; + FDC4386527B4DE7600C60D73 /* RoomPollInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4386427B4DE7600C60D73 /* RoomPollInfo.swift */; }; + FDC4386727B4E10E00C60D73 /* Capabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4386627B4E10E00C60D73 /* Capabilities.swift */; }; + FDC4386927B4E6B800C60D73 /* String+Utlities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4386827B4E6B700C60D73 /* String+Utlities.swift */; }; + FDC4386B27B4E88F00C60D73 /* BatchRequestInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4386A27B4E88F00C60D73 /* BatchRequestInfo.swift */; }; + FDC4386C27B4E90300C60D73 /* SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */; platformFilter = ios; }; + FDC4386D27B4E90300C60D73 /* SessionUtilitiesKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + FDC4387227B5BB3B00C60D73 /* FileUploadResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4387127B5BB3B00C60D73 /* FileUploadResponse.swift */; }; + FDC4387427B5BB9B00C60D73 /* Promise+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4387327B5BB9B00C60D73 /* Promise+Utilities.swift */; }; + FDC4387627B5BEF300C60D73 /* FileDownloadResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4387527B5BEF300C60D73 /* FileDownloadResponse.swift */; }; + FDC4387827B5C35400C60D73 /* SendMessageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4387727B5C35400C60D73 /* SendMessageRequest.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -927,6 +941,13 @@ remoteGlobalIDString = C33FD9AA255A548A00E217F9; remoteInfo = SignalUtilitiesKit; }; + FDC4386E27B4E90300C60D73 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D221A080169C9E5E00537ABF /* Project object */; + proxyType = 1; + remoteGlobalIDString = C3C2A678255388CC00C340D1; + remoteInfo = SessionUtilitiesKit; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -957,6 +978,17 @@ name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; + FDC4387027B4E90300C60D73 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + FDC4386D27B4E90300C60D73 /* SessionUtilitiesKit.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -1850,29 +1882,41 @@ FDC4380A27B31D7E00C60D73 /* Request.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Request.swift; sourceTree = ""; }; FDC4381427B329CE00C60D73 /* NonceGenerator16Byte.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonceGenerator16Byte.swift; sourceTree = ""; }; FDC4381627B32EC700C60D73 /* Personalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Personalization.swift; sourceTree = ""; }; - FDC4381927B34EBA00C60D73 /* CompactPollBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactPollBody.swift; sourceTree = ""; }; + FDC4381927B34EBA00C60D73 /* LegacyCompactPollBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyCompactPollBody.swift; sourceTree = ""; }; FDC4381B27B354AC00C60D73 /* PublicKeyBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicKeyBody.swift; sourceTree = ""; }; FDC4381F27B36ADC00C60D73 /* Endpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Endpoint.swift; sourceTree = ""; }; FDC4382527B37F6900C60D73 /* DeletedMessagesResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedMessagesResponse.swift; sourceTree = ""; }; FDC4382727B37FD300C60D73 /* ModeratorsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModeratorsResponse.swift; sourceTree = ""; }; - FDC4382927B3802D00C60D73 /* RoomsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomsResponse.swift; sourceTree = ""; }; + FDC4382927B3802D00C60D73 /* LegacyRoomsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyRoomsResponse.swift; sourceTree = ""; }; FDC4382B27B380E300C60D73 /* MemberCountResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberCountResponse.swift; sourceTree = ""; }; FDC4382E27B383AF00C60D73 /* UnregisterResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnregisterResponse.swift; sourceTree = ""; }; FDC4383027B3841C00C60D73 /* RegisterResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterResponse.swift; sourceTree = ""; }; FDC4383727B3863200C60D73 /* VersionResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionResponse.swift; sourceTree = ""; }; FDC4383927B4696200C60D73 /* AuthTokenResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthTokenResponse.swift; sourceTree = ""; }; FDC4383D27B4708600C60D73 /* Atomic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = ""; }; - FDC4383F27B4746D00C60D73 /* GetInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetInfoResponse.swift; sourceTree = ""; }; + FDC4383F27B4746D00C60D73 /* LegacyGetInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyGetInfoResponse.swift; sourceTree = ""; }; FDC4384327B47F4D00C60D73 /* OpenGroupMessageV2.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OpenGroupMessageV2.swift; sourceTree = ""; }; - FDC4384427B47F4D00C60D73 /* RoomInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RoomInfo.swift; sourceTree = ""; }; - FDC4384527B47F4D00C60D73 /* CompactPollResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CompactPollResponse.swift; sourceTree = ""; }; + FDC4384427B47F4D00C60D73 /* LegacyRoomInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LegacyRoomInfo.swift; sourceTree = ""; }; + FDC4384527B47F4D00C60D73 /* LegacyCompactPollResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LegacyCompactPollResponse.swift; sourceTree = ""; }; FDC4384627B47F4D00C60D73 /* Deletion.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Deletion.swift; sourceTree = ""; }; FDC4384B27B47F7700C60D73 /* OpenGroupV2.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OpenGroupV2.swift; sourceTree = ""; }; FDC4384E27B4804F00C60D73 /* Header.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Header.swift; sourceTree = ""; }; FDC4385027B4807400C60D73 /* QueryParam.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryParam.swift; sourceTree = ""; }; - FDC4385627B484B700C60D73 /* FileUploadResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUploadResponse.swift; sourceTree = ""; }; + FDC4385627B484B700C60D73 /* LegacyFileUploadResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyFileUploadResponse.swift; sourceTree = ""; }; FDC4385827B484E800C60D73 /* FileUploadBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUploadBody.swift; sourceTree = ""; }; - FDC4385A27B485DE00C60D73 /* FileDownloadResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDownloadResponse.swift; sourceTree = ""; }; + FDC4385A27B485DE00C60D73 /* LegacyFileDownloadResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyFileDownloadResponse.swift; sourceTree = ""; }; + FDC4385C27B4C18900C60D73 /* Room.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Room.swift; sourceTree = ""; }; + FDC4385E27B4C4A200C60D73 /* PinnedMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedMessage.swift; sourceTree = ""; }; + FDC4386027B4CDDF00C60D73 /* FileResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileResponse.swift; sourceTree = ""; }; + FDC4386227B4D94E00C60D73 /* OGMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OGMessage.swift; sourceTree = ""; }; + FDC4386427B4DE7600C60D73 /* RoomPollInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPollInfo.swift; sourceTree = ""; }; + FDC4386627B4E10E00C60D73 /* Capabilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Capabilities.swift; sourceTree = ""; }; + FDC4386827B4E6B700C60D73 /* String+Utlities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Utlities.swift"; sourceTree = ""; }; + FDC4386A27B4E88F00C60D73 /* BatchRequestInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchRequestInfo.swift; sourceTree = ""; }; + FDC4387127B5BB3B00C60D73 /* FileUploadResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUploadResponse.swift; sourceTree = ""; }; + FDC4387327B5BB9B00C60D73 /* Promise+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Promise+Utilities.swift"; sourceTree = ""; }; + FDC4387527B5BEF300C60D73 /* FileDownloadResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDownloadResponse.swift; sourceTree = ""; }; + FDC4387727B5C35400C60D73 /* SendMessageRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageRequest.swift; sourceTree = ""; }; FEDBAE1B98C49BBE8C87F575 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; sourceTree = ""; }; FF9BA33D021B115B1F5B4E46 /* Pods-SessionMessagingKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionMessagingKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionMessagingKit/Pods-SessionMessagingKit.app store release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -1944,6 +1988,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + FDC4386C27B4E90300C60D73 /* SessionUtilitiesKit.framework in Frameworks */, C3C2A70B25539E1E00C340D1 /* SessionSnodeKit.framework in Frameworks */, 9B0A583E9B89FEF0916B793A /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionMessagingKit.framework in Frameworks */, ); @@ -3294,7 +3339,9 @@ C33FDA6C255A57FA00E217F9 /* ProtoUtils.m */, C38EEF09255B49A8007E1867 /* SNProtoEnvelope+Conversion.swift */, C3E7134E251C867C009649BB /* Sodium+Utilities.swift */, + FDC4386827B4E6B700C60D73 /* String+Utlities.swift */, FD5D202127B1D74F00FEA984 /* ECKeyPair+Conversion.swift */, + FDC4387327B5BB9B00C60D73 /* Promise+Utilities.swift */, C33FDB31255A580A00E217F9 /* SSKEnvironment.h */, C33FDAF4255A580600E217F9 /* SSKEnvironment.m */, C33FDB32255A580A00E217F9 /* SSKIncrementingIdFinder.swift */, @@ -3707,16 +3754,24 @@ children = ( FDC4384B27B47F7700C60D73 /* OpenGroupV2.swift */, FDC4381B27B354AC00C60D73 /* PublicKeyBody.swift */, - FDC4381927B34EBA00C60D73 /* CompactPollBody.swift */, - FDC4384527B47F4D00C60D73 /* CompactPollResponse.swift */, - FDC4383F27B4746D00C60D73 /* GetInfoResponse.swift */, - FDC4382927B3802D00C60D73 /* RoomsResponse.swift */, - FDC4384427B47F4D00C60D73 /* RoomInfo.swift */, + FDC4386A27B4E88F00C60D73 /* BatchRequestInfo.swift */, + FDC4385C27B4C18900C60D73 /* Room.swift */, + FDC4386427B4DE7600C60D73 /* RoomPollInfo.swift */, + FDC4385E27B4C4A200C60D73 /* PinnedMessage.swift */, + FDC4386027B4CDDF00C60D73 /* FileResponse.swift */, + FDC4387727B5C35400C60D73 /* SendMessageRequest.swift */, + FDC4386227B4D94E00C60D73 /* OGMessage.swift */, FDC4382B27B380E300C60D73 /* MemberCountResponse.swift */, - FDC4384327B47F4D00C60D73 /* OpenGroupMessageV2.swift */, FDC4382527B37F6900C60D73 /* DeletedMessagesResponse.swift */, FDC4384627B47F4D00C60D73 /* Deletion.swift */, + FDC4386627B4E10E00C60D73 /* Capabilities.swift */, FDC4382727B37FD300C60D73 /* ModeratorsResponse.swift */, + FDC4381927B34EBA00C60D73 /* LegacyCompactPollBody.swift */, + FDC4384527B47F4D00C60D73 /* LegacyCompactPollResponse.swift */, + FDC4383F27B4746D00C60D73 /* LegacyGetInfoResponse.swift */, + FDC4382927B3802D00C60D73 /* LegacyRoomsResponse.swift */, + FDC4384427B47F4D00C60D73 /* LegacyRoomInfo.swift */, + FDC4384327B47F4D00C60D73 /* OpenGroupMessageV2.swift */, FDC4383927B4696200C60D73 /* AuthTokenResponse.swift */, ); path = Models; @@ -3753,8 +3808,10 @@ isa = PBXGroup; children = ( FDC4385827B484E800C60D73 /* FileUploadBody.swift */, - FDC4385627B484B700C60D73 /* FileUploadResponse.swift */, - FDC4385A27B485DE00C60D73 /* FileDownloadResponse.swift */, + FDC4387127B5BB3B00C60D73 /* FileUploadResponse.swift */, + FDC4387527B5BEF300C60D73 /* FileDownloadResponse.swift */, + FDC4385627B484B700C60D73 /* LegacyFileUploadResponse.swift */, + FDC4385A27B485DE00C60D73 /* LegacyFileDownloadResponse.swift */, ); path = Models; sourceTree = ""; @@ -4053,10 +4110,12 @@ C3C2A6EC25539DE700C340D1 /* Sources */, C3C2A6ED25539DE700C340D1 /* Frameworks */, C3C2A6EE25539DE700C340D1 /* Resources */, + FDC4387027B4E90300C60D73 /* Embed Frameworks */, ); buildRules = ( ); dependencies = ( + FDC4386F27B4E90300C60D73 /* PBXTargetDependency */, ); name = SessionMessagingKit; productName = SessionMessagingKit; @@ -4821,7 +4880,7 @@ buildActionMask = 2147483647; files = ( B8856D08256F10F1001CE70E /* DeviceSleepManager.swift in Sources */, - FDC4382A27B3802D00C60D73 /* RoomsResponse.swift in Sources */, + FDC4382A27B3802D00C60D73 /* LegacyRoomsResponse.swift in Sources */, C3471F4C25553AB000297E91 /* MessageReceiver+Decryption.swift in Sources */, C300A5D32554B05A00555489 /* TypingIndicator.swift in Sources */, C3A3A156256E1B91004D228D /* ProtoUtils.m in Sources */, @@ -4832,11 +4891,14 @@ C3C2A74D2553A39700C340D1 /* VisibleMessage.swift in Sources */, C32C5AAD256DBE8F003C73A2 /* TSInfoMessage.m in Sources */, FD5D201127AA331F00FEA984 /* ConfigurationMessage+Convenience.swift in Sources */, + FDC4386927B4E6B800C60D73 /* String+Utlities.swift in Sources */, C32C5A13256DB7A5003C73A2 /* PushNotificationAPI.swift in Sources */, + FDC4385F27B4C4A200C60D73 /* PinnedMessage.swift in Sources */, C32A026325A801AA000ED5D4 /* NSData+messagePadding.m in Sources */, - FDC4384927B47F4D00C60D73 /* CompactPollResponse.swift in Sources */, + FDC4384927B47F4D00C60D73 /* LegacyCompactPollResponse.swift in Sources */, C352A3932557883D00338F3E /* JobDelegate.swift in Sources */, C32C5B84256DC54F003C73A2 /* SSKEnvironment.m in Sources */, + FDC4386527B4DE7600C60D73 /* RoomPollInfo.swift in Sources */, C3A3A108256E1A5C004D228D /* OWSIncomingMessageFinder.m in Sources */, C352A31325574F5200338F3E /* MessageReceiveJob.swift in Sources */, C32C5BDD256DC88D003C73A2 /* OWSReadReceiptManager.m in Sources */, @@ -4844,7 +4906,7 @@ C3C2A7562553A3AB00C340D1 /* VisibleMessage+Quote.swift in Sources */, B8B32021258B1A650020074B /* Contact.swift in Sources */, FDC4380B27B31D7E00C60D73 /* Request.swift in Sources */, - FDC4384027B4746D00C60D73 /* GetInfoResponse.swift in Sources */, + FDC4384027B4746D00C60D73 /* LegacyGetInfoResponse.swift in Sources */, C32C5C89256DD0D2003C73A2 /* Storage+Jobs.swift in Sources */, C300A5FC2554B0A000555489 /* MessageReceiver.swift in Sources */, 7B1581E2271E743B00848B49 /* OWSSounds.swift in Sources */, @@ -4853,9 +4915,10 @@ C32C5CA4256DD1DC003C73A2 /* TSAccountManager.m in Sources */, C352A3892557876500338F3E /* JobQueue.swift in Sources */, C3BBE0B52554F0E10050F1E3 /* ProofOfWork.swift in Sources */, + FDC4386727B4E10E00C60D73 /* Capabilities.swift in Sources */, C32C59C1256DB41F003C73A2 /* TSGroupThread.m in Sources */, C3A3A08F256E1728004D228D /* FullTextSearchFinder.swift in Sources */, - FDC4381A27B34EBA00C60D73 /* CompactPollBody.swift in Sources */, + FDC4381A27B34EBA00C60D73 /* LegacyCompactPollBody.swift in Sources */, B8856D1A256F114D001CE70E /* ProximityMonitoringManager.swift in Sources */, C32C5B9F256DC739003C73A2 /* OWSBlockingManager.m in Sources */, C3D9E52725677DF20040E4F3 /* OWSThumbnailService.swift in Sources */, @@ -4866,6 +4929,7 @@ C379DCF4256735770002D4EB /* VisibleMessage+Attachment.swift in Sources */, B8856D34256F1192001CE70E /* Environment.m in Sources */, B8B320B7258C30D70020074B /* HTMLMetadata.swift in Sources */, + FDC4387427B5BB9B00C60D73 /* Promise+Utilities.swift in Sources */, C32C5AB1256DBE8F003C73A2 /* TSIncomingMessage.m in Sources */, C3A3A107256E1A5C004D228D /* OWSDisappearingMessagesFinder.m in Sources */, C32C59C3256DB41F003C73A2 /* TSGroupModel.m in Sources */, @@ -4873,6 +4937,7 @@ C3C2A7842553AAF300C340D1 /* SNProto.swift in Sources */, C32C5DC0256DD743003C73A2 /* Poller.swift in Sources */, C3C2A7682553A3D900C340D1 /* VisibleMessage+Contact.swift in Sources */, + FDC4386127B4CDDF00C60D73 /* FileResponse.swift in Sources */, C3A3A171256E1D25004D228D /* SSKReachabilityManager.swift in Sources */, C3A71D0B2558989C0043A11F /* MessageWrapper.swift in Sources */, B8F5F60325EDE16F003BF8D4 /* DataExtractionNotification.swift in Sources */, @@ -4887,6 +4952,7 @@ FD5D202227B1D74F00FEA984 /* ECKeyPair+Conversion.swift in Sources */, FDC4381C27B354AC00C60D73 /* PublicKeyBody.swift in Sources */, FDC4382F27B383AF00C60D73 /* UnregisterResponse.swift in Sources */, + FDC4386327B4D94E00C60D73 /* OGMessage.swift in Sources */, C3C2A7712553A41E00C340D1 /* ControlMessage.swift in Sources */, C32C5D19256DD493003C73A2 /* OWSLinkPreview.swift in Sources */, C32C5CF0256DD3E4003C73A2 /* Storage+Shared.swift in Sources */, @@ -4904,9 +4970,11 @@ B8856CEE256F1054001CE70E /* OWSAudioPlayer.m in Sources */, FD5D200F27AA2B6000FEA984 /* MessageRequestResponse.swift in Sources */, FDC4381727B32EC700C60D73 /* Personalization.swift in Sources */, + FDC4387827B5C35400C60D73 /* SendMessageRequest.swift in Sources */, C32C5EDC256DF501003C73A2 /* YapDatabaseConnection+OWS.m in Sources */, FDC4381527B329CE00C60D73 /* NonceGenerator16Byte.swift in Sources */, C3BBE0762554CDA60050F1E3 /* Configuration.swift in Sources */, + FDC4387627B5BEF300C60D73 /* FileDownloadResponse.swift in Sources */, C35D76DB26606304009AA5FB /* ThreadUpdateBatcher.swift in Sources */, FDC4382C27B380E300C60D73 /* MemberCountResponse.swift in Sources */, B8B32033258B235D0020074B /* Storage+Contacts.swift in Sources */, @@ -4920,6 +4988,7 @@ C32C5A02256DB658003C73A2 /* MessageSender+Convenience.swift in Sources */, B8566C6C256F60F50045A0B9 /* OWSUserProfile.m in Sources */, B8D0A25925E367AC00C1835E /* Notification+MessageReceiver.swift in Sources */, + FDC4387227B5BB3B00C60D73 /* FileUploadResponse.swift in Sources */, C32C599E256DB02B003C73A2 /* TypingIndicators.swift in Sources */, FDC4380927B31D4E00C60D73 /* Error.swift in Sources */, FDC4382027B36ADC00C60D73 /* Endpoint.swift in Sources */, @@ -4946,7 +5015,7 @@ C300A5E72554B07300555489 /* ExpirationTimerUpdate.swift in Sources */, B88A1AC725C90A4700E6D421 /* TypingIndicatorInteraction.swift in Sources */, C3D9E3C025676AD70040E4F3 /* TSAttachment.m in Sources */, - FDC4384827B47F4D00C60D73 /* RoomInfo.swift in Sources */, + FDC4384827B47F4D00C60D73 /* LegacyRoomInfo.swift in Sources */, C32C598A256D0664003C73A2 /* SNProtoEnvelope+Conversion.swift in Sources */, C352A2FF25574B6300338F3E /* MessageSendJob.swift in Sources */, B8856D11256F112A001CE70E /* OWSAudioSession.swift in Sources */, @@ -4974,16 +5043,18 @@ C32C5A75256DBBCF003C73A2 /* TSAttachmentPointer+Conversion.swift in Sources */, C32C5AF8256DC051003C73A2 /* OWSDisappearingMessagesConfiguration.m in Sources */, C32C5EBA256DE130003C73A2 /* OWSQuotedReplyModel.m in Sources */, + FDC4386B27B4E88F00C60D73 /* BatchRequestInfo.swift in Sources */, FDC4385127B4807400C60D73 /* QueryParam.swift in Sources */, C32C5B62256DC333003C73A2 /* OWSDisappearingConfigurationUpdateInfoMessage.m in Sources */, C352A2F525574B4700338F3E /* Job.swift in Sources */, - FDC4385727B484B700C60D73 /* FileUploadResponse.swift in Sources */, - FDC4385B27B485DE00C60D73 /* FileDownloadResponse.swift in Sources */, + FDC4385727B484B700C60D73 /* LegacyFileUploadResponse.swift in Sources */, + FDC4385B27B485DE00C60D73 /* LegacyFileDownloadResponse.swift in Sources */, C32C5C01256DC9A0003C73A2 /* OWSIdentityManager.m in Sources */, C32C59C4256DB41F003C73A2 /* TSContactThread.m in Sources */, FDC4383127B3841C00C60D73 /* RegisterResponse.swift in Sources */, C32C5AB0256DBE8F003C73A2 /* TSOutgoingMessage.m in Sources */, B82A0C3826B9098200C1BCE3 /* MessageInvalidator.swift in Sources */, + FDC4385D27B4C18900C60D73 /* Room.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5244,6 +5315,12 @@ target = C33FD9AA255A548A00E217F9 /* SignalUtilitiesKit */; targetProxy = C3D90A7025773A44002C9DF5 /* PBXContainerItemProxy */; }; + FDC4386F27B4E90300C60D73 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + platformFilter = ios; + target = C3C2A678255388CC00C340D1 /* SessionUtilitiesKit */; + targetProxy = FDC4386E27B4E90300C60D73 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 1ad23de66..aeca31155 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -371,8 +371,9 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat snInputView.text = draft } // Update member count if this is a V2 open group + // TODO: Non-legacy version (I assue this comes through room updates... 'activeUsers'? if let v2OpenGroup = Storage.shared.getV2OpenGroup(for: thread.uniqueId!) { - OpenGroupAPIV2.getMemberCount(for: v2OpenGroup.room, on: v2OpenGroup.server).retainUntilComplete() + OpenGroupAPIV2.legacyGetMemberCount(for: v2OpenGroup.room, on: v2OpenGroup.server).retainUntilComplete() } } diff --git a/Session/Conversations/Input View/MentionSelectionView.swift b/Session/Conversations/Input View/MentionSelectionView.swift index 327f50ec7..1c33b00f6 100644 --- a/Session/Conversations/Input View/MentionSelectionView.swift +++ b/Session/Conversations/Input View/MentionSelectionView.swift @@ -174,7 +174,6 @@ private extension MentionSelectionView { // MARK: - Delegate -protocol MentionSelectionViewDelegate : class { - +protocol MentionSelectionViewDelegate: AnyObject { func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView) } diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index b2a0e279f..72bc407a6 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -160,7 +160,7 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv let _ = IP2Country.shared.populateCacheIfNeeded() } // Get default open group rooms if needed - OpenGroupAPIV2.getDefaultRoomsIfNeeded() + OpenGroupAPIV2.legacyGetDefaultRoomsIfNeeded() } override func viewDidAppear(_ animated: Bool) { diff --git a/Session/Meta/AppDelegate.m b/Session/Meta/AppDelegate.m index 000af7460..e0ca1d383 100644 --- a/Session/Meta/AppDelegate.m +++ b/Session/Meta/AppDelegate.m @@ -383,7 +383,7 @@ static NSTimeInterval launchStartedAt; } if (CurrentAppContext().isMainApp) { - [SNOpenGroupAPIV2 getDefaultRoomsIfNeeded]; + [SNOpenGroupAPIV2 legacyGetDefaultRoomsIfNeeded]; } [[SNSnodeAPI getSnodePool] retainUntilComplete]; diff --git a/Session/Open Groups/JoinOpenGroupVC.swift b/Session/Open Groups/JoinOpenGroupVC.swift index 2f62101f7..7eb4bb567 100644 --- a/Session/Open Groups/JoinOpenGroupVC.swift +++ b/Session/Open Groups/JoinOpenGroupVC.swift @@ -238,7 +238,7 @@ private final class EnterURLVC : UIViewController, UIGestureRecognizerDelegate, return !suggestionGrid.frame.contains(location) } - func join(_ room: OpenGroupAPIV2.RoomInfo) { + func join(_ room: OpenGroupAPIV2.LegacyRoomInfo) { joinOpenGroupVC.joinV2OpenGroup(room: room.id, server: OpenGroupAPIV2.defaultServer, publicKey: OpenGroupAPIV2.defaultServerPublicKey) } diff --git a/Session/Open Groups/OpenGroupSuggestionGrid.swift b/Session/Open Groups/OpenGroupSuggestionGrid.swift index 5d0e5826a..2fb2d86df 100644 --- a/Session/Open Groups/OpenGroupSuggestionGrid.swift +++ b/Session/Open Groups/OpenGroupSuggestionGrid.swift @@ -3,7 +3,7 @@ import NVActivityIndicatorView final class OpenGroupSuggestionGrid : UIView, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { private let maxWidth: CGFloat - private var rooms: [OpenGroupAPIV2.RoomInfo] = [] { didSet { update() } } + private var rooms: [OpenGroupAPIV2.LegacyRoomInfo] = [] { didSet { update() } } private var heightConstraint: NSLayoutConstraint! var delegate: OpenGroupSuggestionGridDelegate? @@ -60,9 +60,10 @@ final class OpenGroupSuggestionGrid : UIView, UICollectionViewDataSource, UIColl heightConstraint = set(.height, to: OpenGroupSuggestionGrid.cellHeight) widthAnchor.constraint(greaterThanOrEqualToConstant: OpenGroupSuggestionGrid.cellHeight).isActive = true if OpenGroupAPIV2.defaultRoomsPromise == nil { - OpenGroupAPIV2.getDefaultRoomsIfNeeded() + OpenGroupAPIV2.legacyGetDefaultRoomsIfNeeded() } - let _ = OpenGroupAPIV2.defaultRoomsPromise?.done { [weak self] rooms in + let _ = OpenGroupAPIV2.legacyDefaultRoomsPromise?.done { [weak self] rooms in + // TODO: Update this for the new rooms API self?.rooms = rooms } } @@ -104,7 +105,7 @@ final class OpenGroupSuggestionGrid : UIView, UICollectionViewDataSource, UIColl extension OpenGroupSuggestionGrid { fileprivate final class Cell : UICollectionViewCell { - var room: OpenGroupAPIV2.RoomInfo? { didSet { update() } } + var room: OpenGroupAPIV2.LegacyRoomInfo? { didSet { update() } } static let identifier = "OpenGroupSuggestionGridCell" @@ -172,7 +173,7 @@ extension OpenGroupSuggestionGrid { private func update() { guard let room = room else { return } - let promise = OpenGroupAPIV2.getGroupImage(for: room.id, on: OpenGroupAPIV2.defaultServer) + let promise = OpenGroupAPIV2.legacyGetGroupImage(for: room.id, on: OpenGroupAPIV2.defaultServer) imageView.image = given(promise.value) { UIImage(data: $0)! } imageView.isHidden = (imageView.image == nil) label.text = room.name @@ -183,5 +184,5 @@ extension OpenGroupSuggestionGrid { // MARK: Delegate protocol OpenGroupSuggestionGridDelegate { - func join(_ room: OpenGroupAPIV2.RoomInfo) + func join(_ room: OpenGroupAPIV2.LegacyRoomInfo) } diff --git a/SessionMessagingKit/Common Networking/Models/FileDownloadResponse.swift b/SessionMessagingKit/Common Networking/Models/FileDownloadResponse.swift index b18fda763..45f7c1989 100644 --- a/SessionMessagingKit/Common Networking/Models/FileDownloadResponse.swift +++ b/SessionMessagingKit/Common Networking/Models/FileDownloadResponse.swift @@ -2,16 +2,29 @@ import Foundation -struct FileDownloadResponse: Codable { +// TODO: Update this (looks like it's getting changed to just be the data, the properties are send through as headers) +public struct FileDownloadResponse: Codable { enum CodingKeys: String, CodingKey { - case base64EncodedData = "result" + case fileName = "filename" + case size + case uploaded + case expires + case base64EncodedData = "result" // TODO: Confirm the name of this value } - let data: Data + public let fileName: String + public let size: Int64 + public let uploaded: TimeInterval + public let expires: TimeInterval? + public let data: Data public func encode(to encoder: Encoder) throws { var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + try container.encode(fileName, forKey: .fileName) + try container.encode(size, forKey: .size) + try container.encode(uploaded, forKey: .uploaded) + try container.encodeIfPresent(expires, forKey: .expires) try container.encode(data.base64EncodedString(), forKey: .base64EncodedData) } } @@ -19,7 +32,7 @@ struct FileDownloadResponse: Codable { // MARK: - Decoder extension FileDownloadResponse { - init(from decoder: Decoder) throws { + public init(from decoder: Decoder) throws { let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) let base64EncodedData: String = try container.decode(String.self, forKey: .base64EncodedData) @@ -29,6 +42,10 @@ extension FileDownloadResponse { } self = FileDownloadResponse( + fileName: try container.decode(String.self, forKey: .fileName), + size: try container.decode(Int64.self, forKey: .size), + uploaded: try container.decode(TimeInterval.self, forKey: .uploaded), + expires: try? container.decode(TimeInterval.self, forKey: .expires), data: data ) } diff --git a/SessionMessagingKit/Common Networking/Models/FileUploadResponse.swift b/SessionMessagingKit/Common Networking/Models/FileUploadResponse.swift index ba59e65d0..b787b0ceb 100644 --- a/SessionMessagingKit/Common Networking/Models/FileUploadResponse.swift +++ b/SessionMessagingKit/Common Networking/Models/FileUploadResponse.swift @@ -1,11 +1,5 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -import Foundation - -struct FileUploadResponse: Codable { - enum CodingKeys: String, CodingKey { - case fileId = "result" - } - - public let fileId: UInt64 +public struct FileUploadResponse: Codable { + public let id: UInt64 } diff --git a/SessionMessagingKit/Common Networking/Models/LegacyFileDownloadResponse.swift b/SessionMessagingKit/Common Networking/Models/LegacyFileDownloadResponse.swift new file mode 100644 index 000000000..d05a3e251 --- /dev/null +++ b/SessionMessagingKit/Common Networking/Models/LegacyFileDownloadResponse.swift @@ -0,0 +1,35 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +struct LegacyFileDownloadResponse: Codable { + enum CodingKeys: String, CodingKey { + case base64EncodedData = "result" + } + + let data: Data + + public func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(data.base64EncodedString(), forKey: .base64EncodedData) + } +} + +// MARK: - Decoder + +extension LegacyFileDownloadResponse { + init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + let base64EncodedData: String = try container.decode(String.self, forKey: .base64EncodedData) + + guard let data = Data(base64Encoded: base64EncodedData) else { + throw FileServerAPIV2.Error.parsingFailed + } + + self = LegacyFileDownloadResponse( + data: data + ) + } +} diff --git a/SessionMessagingKit/Common Networking/Models/LegacyFileUploadResponse.swift b/SessionMessagingKit/Common Networking/Models/LegacyFileUploadResponse.swift new file mode 100644 index 000000000..fd22f5799 --- /dev/null +++ b/SessionMessagingKit/Common Networking/Models/LegacyFileUploadResponse.swift @@ -0,0 +1,11 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +struct LegacyFileUploadResponse: Codable { + enum CodingKeys: String, CodingKey { + case fileId = "result" + } + + public let fileId: UInt64 +} diff --git a/SessionMessagingKit/Common Networking/QueryParam.swift b/SessionMessagingKit/Common Networking/QueryParam.swift index 5c71ae852..611b30eb8 100644 --- a/SessionMessagingKit/Common Networking/QueryParam.swift +++ b/SessionMessagingKit/Common Networking/QueryParam.swift @@ -5,4 +5,8 @@ import Foundation enum QueryParam: String { case publicKey = "public_key" case fromServerId = "from_server_id" + + case required = "required" + case fileName = "X-Filename" + case limit // For messages - number between 1 and 256 (default is 100) } diff --git a/SessionMessagingKit/File Server/FileServerAPIV2.swift b/SessionMessagingKit/File Server/FileServerAPIV2.swift index ce5404159..6e885823f 100644 --- a/SessionMessagingKit/File Server/FileServerAPIV2.swift +++ b/SessionMessagingKit/File Server/FileServerAPIV2.swift @@ -112,7 +112,7 @@ public final class FileServerAPIV2 : NSObject { let request = Request(verb: .post, endpoint: "files", body: body) return send(request, useOldServer: false).map(on: DispatchQueue.global(qos: .userInitiated)) { data in - let response: FileUploadResponse = try data.decoded(as: FileUploadResponse.self, customError: Error.parsingFailed) + let response: LegacyFileUploadResponse = try data.decoded(as: LegacyFileUploadResponse.self, customError: Error.parsingFailed) return response.fileId } @@ -128,7 +128,7 @@ public final class FileServerAPIV2 : NSObject { let request = Request(verb: .get, endpoint: "files/\(file)") return send(request, useOldServer: useOldServer).map(on: DispatchQueue.global(qos: .userInitiated)) { data in - let response: FileDownloadResponse = try data.decoded(as: FileDownloadResponse.self, customError: Error.parsingFailed) + let response: LegacyFileDownloadResponse = try data.decoded(as: LegacyFileDownloadResponse.self, customError: Error.parsingFailed) return response.data } diff --git a/SessionMessagingKit/Jobs/MessageSendJob.swift b/SessionMessagingKit/Jobs/MessageSendJob.swift index e1cbad17d..79ad93b93 100644 --- a/SessionMessagingKit/Jobs/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/MessageSendJob.swift @@ -9,49 +9,111 @@ public final class MessageSendJob : NSObject, Job, NSCoding { // NSObject/NSCodi public var id: String? public var failureCount: UInt = 0 - // MARK: Settings + // MARK: - Settings + public class var collection: String { return "MessageSendJobCollection" } public static let maxFailureCount: UInt = 10 - // MARK: Initialization - @objc public convenience init(message: Message, publicKey: String) { self.init(message: message, destination: .contact(publicKey: publicKey)) } - @objc public convenience init(message: Message, groupPublicKey: String) { self.init(message: message, destination: .closedGroup(groupPublicKey: groupPublicKey)) } + // MARK: - Initialization + + @objc public convenience init(message: Message, publicKey: String) { + self.init(message: message, destination: .contact(publicKey: publicKey)) + } + + @objc public convenience init(message: Message, groupPublicKey: String) { + self.init(message: message, destination: .closedGroup(groupPublicKey: groupPublicKey)) + } public init(message: Message, destination: Message.Destination) { self.message = message self.destination = destination } - // MARK: Coding + // MARK: - Coding + public init?(coder: NSCoder) { - guard let message = coder.decodeObject(forKey: "message") as! Message?, - var rawDestination = coder.decodeObject(forKey: "destination") as! String?, - let id = coder.decodeObject(forKey: "id") as! String? else { return nil } - self.message = message - if rawDestination.removePrefix("contact(") { - guard rawDestination.removeSuffix(")") else { return nil } - let publicKey = rawDestination - destination = .contact(publicKey: publicKey) - } else if rawDestination.removePrefix("closedGroup(") { - guard rawDestination.removeSuffix(")") else { return nil } - let groupPublicKey = rawDestination - destination = .closedGroup(groupPublicKey: groupPublicKey) - } else if rawDestination.removePrefix("openGroup(") { - guard rawDestination.removeSuffix(")") else { return nil } - let components = rawDestination.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } - guard components.count == 2, let channel = UInt64(components[0]) else { return nil } - let server = components[1] - destination = .openGroup(channel: channel, server: server) - } else if rawDestination.removePrefix("openGroupV2(") { - guard rawDestination.removeSuffix(")") else { return nil } - let components = rawDestination.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } - guard components.count == 2 else { return nil } - let room = components[0] - let server = components[1] - destination = .openGroupV2(room: room, server: server) - } else { + guard let message = coder.decodeObject(forKey: "message") as! Message?, var rawDestination = coder.decodeObject(forKey: "destination") as! String?, let id = coder.decodeObject(forKey: "id") as! String? else { return nil } + + self.message = message + + if rawDestination.removePrefix("contact(") { + guard rawDestination.removeSuffix(")") else { return nil } + + let publicKey = rawDestination + destination = .contact(publicKey: publicKey) + } + else if rawDestination.removePrefix("closedGroup(") { + guard rawDestination.removeSuffix(")") else { return nil } + + let groupPublicKey = rawDestination + destination = .closedGroup(groupPublicKey: groupPublicKey) + } + else if rawDestination.removePrefix("openGroup(") { + guard rawDestination.removeSuffix(")") else { return nil } + + let components = rawDestination + .split(separator: ",") + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + + guard components.count == 2, let channel = UInt64(components[0]) else { return nil } + + let server = components[1] + destination = .legacyOpenGroup(channel: channel, server: server) + } + else if rawDestination.removePrefix("openGroupV2(") { + guard rawDestination.removeSuffix(")") else { return nil } + + let components = rawDestination + .split(separator: ",") + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + + guard components.count == 2 else { return nil } + + let roomToken: String = components[0] + let server: String = components[1] + + destination = .openGroup( + roomToken: roomToken, + server: server + ) + } + else if rawDestination.removePrefix("openGroupV4(") { + guard rawDestination.removeSuffix(")") else { return nil } + + let components = rawDestination + .split(separator: ",") + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + + guard components.count == 5 else { return nil } + + let roomToken: String = components[0] + let server: String = components[1] + let whisperTo: String? = (!components[2].isEmpty ? + components[2] : + nil + ) + let whisperMods: Bool = (components[3] == "true") + let fileIdStrings: [String] = components[4] + .replacingOccurrences(of: "[", with: "") + .replacingOccurrences(of: "]", with: "") + .split(separator: "|") + .map { String($0) } + let fileIds: [Int64]? = (fileIdStrings.isEmpty ? nil : fileIdStrings.compactMap { Int64($0) }) + + destination = .openGroup( + roomToken: roomToken, + server: server, + whisperTo: whisperTo, + whisperMods: whisperMods, + fileIds: fileIds + ) + } + else { + return nil + } + self.id = id self.failureCount = coder.decodeObject(forKey: "failureCount") as! UInt? ?? 0 } @@ -59,11 +121,28 @@ public final class MessageSendJob : NSObject, Job, NSCoding { // NSObject/NSCodi public func encode(with coder: NSCoder) { coder.encode(message, forKey: "message") switch destination { - case .contact(let publicKey): coder.encode("contact(\(publicKey))", forKey: "destination") - case .closedGroup(let groupPublicKey): coder.encode("closedGroup(\(groupPublicKey))", forKey: "destination") - case .openGroup(let channel, let server): coder.encode("openGroup(\(channel), \(server))", forKey: "destination") - case .openGroupV2(let room, let server): coder.encode("openGroupV2(\(room), \(server))", forKey: "destination") + case .contact(let publicKey): + coder.encode("contact(\(publicKey))", forKey: "destination") + + case .closedGroup(let groupPublicKey): + coder.encode("closedGroup(\(groupPublicKey))", forKey: "destination") + + case .legacyOpenGroup(let channel, let server): + coder.encode("openGroup(\(channel), \(server))", forKey: "destination") + + case .openGroup(let room, let server, let whisperTo, let whisperMods, let fileIds): + let whisperToString: String = (whisperTo ?? "") + let whisperModsString: String = (whisperMods ? "true" : "false") + let fileIdString: String = (fileIds ?? []) + .map { String($0) } + .joined(separator: "|") + + coder.encode( + "openGroupV4(\(room), \(server), \(whisperToString), \(whisperModsString), [\(fileIdString)])", + forKey: "destination" + ) } + coder.encode(id, forKey: "id") coder.encode(failureCount, forKey: "failureCount") } diff --git a/SessionMessagingKit/Messages/Message+Destination.swift b/SessionMessagingKit/Messages/Message+Destination.swift index 8b0252fa6..019ad1dd1 100644 --- a/SessionMessagingKit/Messages/Message+Destination.swift +++ b/SessionMessagingKit/Messages/Message+Destination.swift @@ -4,22 +4,33 @@ public extension Message { enum Destination { case contact(publicKey: String) case closedGroup(groupPublicKey: String) - case openGroup(channel: UInt64, server: String) - case openGroupV2(room: String, server: String) + case legacyOpenGroup(channel: UInt64, server: String) + case openGroup( + roomToken: String, + server: String, + whisperTo: String? = nil, + whisperMods: Bool = false, + fileIds: [Int64]? = nil // TODO: Handle 'fileIds' + ) static func from(_ thread: TSThread) -> Message.Destination { if let thread = thread as? TSContactThread { return .contact(publicKey: thread.contactSessionID()) - } else if let thread = thread as? TSGroupThread, thread.isClosedGroup { + } + + if let thread = thread as? TSGroupThread, thread.isClosedGroup { let groupID = thread.groupModel.groupId let groupPublicKey = LKGroupUtilities.getDecodedGroupID(groupID) return .closedGroup(groupPublicKey: groupPublicKey) - } else if let thread = thread as? TSGroupThread, thread.isOpenGroup { - let openGroupV2 = Storage.shared.getV2OpenGroup(for: thread.uniqueId!)! - return .openGroupV2(room: openGroupV2.room, server: openGroupV2.server) - } else { - preconditionFailure("TODO: Handle legacy closed groups.") } + + if let thread = thread as? TSGroupThread, thread.isOpenGroup { + let openGroup: OpenGroupV2 = Storage.shared.getV2OpenGroup(for: thread.uniqueId!)! + + return .openGroup(roomToken: openGroup.room, server: openGroup.server) + } + + preconditionFailure("TODO: Handle legacy closed groups.") } } } diff --git a/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift b/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift new file mode 100644 index 000000000..ddbeeb7ec --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift @@ -0,0 +1,90 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import PromiseKit +import SessionUtilitiesKit + +extension OpenGroupAPIV2 { + // MARK: - BatchSubRequest + + struct BatchSubRequest: Codable { + let method: HTTP.Verb + let path: String + let headers: [String: String]? + let json: String? + let b64: String? + + init(request: Request) { + self.method = request.method + self.path = request.urlPathAndParamsString + self.headers = (request.headers.isEmpty ? nil : request.headers.toHTTPHeaders()) + + // TODO: Differentiate between JSON and b64 body + if let body: Data = request.body, let bodyString: String = String(data: body, encoding: .utf8) { + self.json = bodyString + } + else { + self.json = nil + } + + self.b64 = nil + } + } + + // MARK: - BatchSubResponse + + struct BatchSubResponse: Codable { + let code: Int32 + let headers: [String: String] + let body: T + } + + // MARK: - BatchRequestInfo + + struct BatchRequestInfo { + let request: Request + let responseType: Codable.Type + + init(request: Request, responseType: T.Type) { + self.request = request + self.responseType = BatchSubResponse.self + } + } + + // MARK: - BatchRequest + + typealias BatchRequest = [BatchSubRequest] + typealias BatchResponseTypes = [Codable.Type] + typealias BatchResponse = [Codable] +} + +// MARK: - Convenience + +public extension Decodable { + static func decoded(from data: Data) throws -> Self { + return try JSONDecoder().decode(Self.self, from: data) + } +} + +extension Promise where T == Data { + func decoded(as types: OpenGroupAPIV2.BatchResponseTypes, on queue: DispatchQueue? = nil, error: Error? = nil) -> Promise { + self.map(on: queue) { data -> OpenGroupAPIV2.BatchResponse in + // Need to split the data into an array of data so each item can be Decoded correctly + guard let jsonObject: Any = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) else { + throw OpenGroupAPIV2.Error.parsingFailed + } + guard let anyArray: [Any] = jsonObject as? [Any] else { throw OpenGroupAPIV2.Error.parsingFailed } + + let dataArray: [Data] = anyArray.compactMap { try? JSONSerialization.data(withJSONObject: $0) } + guard dataArray.count == types.count else { throw OpenGroupAPIV2.Error.parsingFailed } + + do { + return try zip(dataArray, types) + .map { data, type in try type.decoded(from: data) } + } + catch let thrownError { + throw (error ?? thrownError) + } + } + } +} diff --git a/SessionMessagingKit/Open Groups/Models/Capabilities.swift b/SessionMessagingKit/Open Groups/Models/Capabilities.swift new file mode 100644 index 000000000..ab1cd3dab --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/Capabilities.swift @@ -0,0 +1,40 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPIV2 { + public struct Capabilities: Codable { + enum Capability: CaseIterable, Codable { + static var allCases: [Capability] { + [.pysogs] + } + + case pysogs + + /// Fallback case if the capability isn't supported by this version of the app + case unsupported(String) + + // MARK: - Convenience + + var rawValue: String { + switch self { + case .unsupported(let originalValue): return originalValue + default: return "\(self)" + } + } + + // MARK: - Codable + + init(from decoder: Decoder) throws { + let container: SingleValueDecodingContainer = try decoder.singleValueContainer() + let valueString: String = try container.decode(String.self) + let maybeValue: Capability? = Capability.allCases.first { $0.rawValue == valueString } + + self = (maybeValue ?? .unsupported(valueString)) + } + } + + let capabilities: [Capability] + let missing: [Capability]? + } +} diff --git a/SessionMessagingKit/Open Groups/Models/FileResponse.swift b/SessionMessagingKit/Open Groups/Models/FileResponse.swift new file mode 100644 index 000000000..6ec0f9888 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/FileResponse.swift @@ -0,0 +1,19 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPIV2 { + public struct FileResponse: Codable { + enum CodingKeys: String, CodingKey { + case fileName = "filename" + case size + case uploaded + case expires + } + + let fileName: String? + let size: Int64 + let uploaded: TimeInterval + let expires: TimeInterval? + } +} diff --git a/SessionMessagingKit/Open Groups/Models/CompactPollBody.swift b/SessionMessagingKit/Open Groups/Models/LegacyCompactPollBody.swift similarity index 94% rename from SessionMessagingKit/Open Groups/Models/CompactPollBody.swift rename to SessionMessagingKit/Open Groups/Models/LegacyCompactPollBody.swift index 0e26fd773..9180bcc8c 100644 --- a/SessionMessagingKit/Open Groups/Models/CompactPollBody.swift +++ b/SessionMessagingKit/Open Groups/Models/LegacyCompactPollBody.swift @@ -3,7 +3,7 @@ import Foundation extension OpenGroupAPIV2 { - struct CompactPollBody: Codable { + struct LegacyCompactPollBody: Codable { struct Room: Codable { enum CodingKeys: String, CodingKey { case id = "room_id" diff --git a/SessionMessagingKit/Open Groups/Models/CompactPollResponse.swift b/SessionMessagingKit/Open Groups/Models/LegacyCompactPollResponse.swift similarity index 92% rename from SessionMessagingKit/Open Groups/Models/CompactPollResponse.swift rename to SessionMessagingKit/Open Groups/Models/LegacyCompactPollResponse.swift index 626108d84..8988a72b5 100644 --- a/SessionMessagingKit/Open Groups/Models/CompactPollResponse.swift +++ b/SessionMessagingKit/Open Groups/Models/LegacyCompactPollResponse.swift @@ -3,7 +3,7 @@ import Foundation extension OpenGroupAPIV2 { - public struct CompactPollResponse: Codable { + public struct LegacyCompactPollResponse: Codable { public struct Result: Codable { enum CodingKeys: String, CodingKey { case room = "room_id" diff --git a/SessionMessagingKit/Open Groups/Models/RoomsResponse.swift b/SessionMessagingKit/Open Groups/Models/LegacyGetInfoResponse.swift similarity index 60% rename from SessionMessagingKit/Open Groups/Models/RoomsResponse.swift rename to SessionMessagingKit/Open Groups/Models/LegacyGetInfoResponse.swift index e5ac33d23..fe00c940e 100644 --- a/SessionMessagingKit/Open Groups/Models/RoomsResponse.swift +++ b/SessionMessagingKit/Open Groups/Models/LegacyGetInfoResponse.swift @@ -3,7 +3,7 @@ import Foundation extension OpenGroupAPIV2 { - struct RoomsResponse: Codable { - let rooms: [RoomInfo] + struct LegacyGetInfoResponse: Codable { + let room: LegacyRoomInfo } } diff --git a/SessionMessagingKit/Open Groups/Models/RoomInfo.swift b/SessionMessagingKit/Open Groups/Models/LegacyRoomInfo.swift similarity index 89% rename from SessionMessagingKit/Open Groups/Models/RoomInfo.swift rename to SessionMessagingKit/Open Groups/Models/LegacyRoomInfo.swift index bef83f77f..1afce0b96 100644 --- a/SessionMessagingKit/Open Groups/Models/RoomInfo.swift +++ b/SessionMessagingKit/Open Groups/Models/LegacyRoomInfo.swift @@ -3,7 +3,7 @@ import Foundation extension OpenGroupAPIV2 { - public struct RoomInfo: Codable { + public struct LegacyRoomInfo: Codable { enum CodingKeys: String, CodingKey { case id case name diff --git a/SessionMessagingKit/Open Groups/Models/GetInfoResponse.swift b/SessionMessagingKit/Open Groups/Models/LegacyRoomsResponse.swift similarity index 60% rename from SessionMessagingKit/Open Groups/Models/GetInfoResponse.swift rename to SessionMessagingKit/Open Groups/Models/LegacyRoomsResponse.swift index 6d637cddc..7251a9ce4 100644 --- a/SessionMessagingKit/Open Groups/Models/GetInfoResponse.swift +++ b/SessionMessagingKit/Open Groups/Models/LegacyRoomsResponse.swift @@ -3,7 +3,7 @@ import Foundation extension OpenGroupAPIV2 { - struct GetInfoResponse: Codable { - let room: RoomInfo + struct LegacyRoomsResponse: Codable { + let rooms: [LegacyRoomInfo] } } diff --git a/SessionMessagingKit/Open Groups/Models/OGMessage.swift b/SessionMessagingKit/Open Groups/Models/OGMessage.swift new file mode 100644 index 000000000..d748f3306 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/OGMessage.swift @@ -0,0 +1,73 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPIV2 { + public struct Message: Codable { + enum CodingKeys: String, CodingKey { + case id + case sender = "session_id" + case posted + case edited + case seqNo = "seqno" + case whisper + case whisperMods = "whisper_mods" + case whisperTo = "whisper_to" + + case base64EncodedData = "data" + case base64EncodedSignature = "signature" + } + + public let id: Int64 + public let sender: String? + public let posted: TimeInterval + public let edited: TimeInterval? + public let seqNo: Int64 + public let whisper: Bool + public let whisperMods: Bool + public let whisperTo: String? + + public let base64EncodedData: String? + public let base64EncodedSignature: String? + } +} + +// MARK: - Decoder + +extension OpenGroupAPIV2.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) + + // 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 { + guard let sender: String = maybeSender, let data = Data(base64Encoded: base64EncodedData), let signature = Data(base64Encoded: base64EncodedSignature) else { + throw OpenGroupAPIV2.Error.parsingFailed + } + + let publicKey: Data = Data(hex: sender.removingIdPrefixIfNeeded()) + let isValid: Bool = ((try? Ed25519.verifySignature(signature, publicKey: publicKey, data: data)) ?? false) + + guard isValid else { + SNLog("Ignoring message with invalid signature.") + throw OpenGroupAPIV2.Error.parsingFailed + } + } + + self = OpenGroupAPIV2.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), + 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), + base64EncodedData: maybeBase64EncodedData, + base64EncodedSignature: maybeBase64EncodedSignature + ) + } +} diff --git a/SessionMessagingKit/Open Groups/Models/OpenGroupMessageV2.swift b/SessionMessagingKit/Open Groups/Models/OpenGroupMessageV2.swift index 76a0b11b6..4f7bf2163 100644 --- a/SessionMessagingKit/Open Groups/Models/OpenGroupMessageV2.swift +++ b/SessionMessagingKit/Open Groups/Models/OpenGroupMessageV2.swift @@ -20,7 +20,6 @@ public struct OpenGroupMessageV2: Codable { public let base64EncodedSignature: String? public func sign(with publicKey: String) -> OpenGroupMessageV2? { - // TODO: Swap to use blinded key guard let userKeyPair = SNMessagingKitConfiguration.shared.storage.getUserKeyPair() else { return nil } guard let data = Data(base64Encoded: base64EncodedData) else { return nil } guard let signature = try? Ed25519.sign(data, with: userKeyPair) else { diff --git a/SessionMessagingKit/Open Groups/Models/PinnedMessage.swift b/SessionMessagingKit/Open Groups/Models/PinnedMessage.swift new file mode 100644 index 000000000..610daa5db --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/PinnedMessage.swift @@ -0,0 +1,17 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPIV2 { + public struct PinnedMessage: Codable { + enum CodingKeys: String, CodingKey { + case id + case pinnedAt = "pinned_at" + case pinnedBy = "pinned_by" + } + + let id: Int64 + let pinnedAt: TimeInterval + let pinnedBy: String + } +} diff --git a/SessionMessagingKit/Open Groups/Models/Room.swift b/SessionMessagingKit/Open Groups/Models/Room.swift new file mode 100644 index 000000000..4e116cb29 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/Room.swift @@ -0,0 +1,107 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPIV2 { + public struct Room: Codable { + enum CodingKeys: String, CodingKey { + case token + case created + case name + case description + case imageId = "image_id" + + case infoUpdates = "info_updates" + case messageSequence = "message_sequence" + case activeUsers = "active_users" + case activeUsersCutoff = "active_users_cutoff" + case pinnedMessages = "pinned_messages" + + case admin + case globalAdmin = "global_admin" + case admins + case hiddenAdmins = "hidden_admins" + + case moderator + case globalModerator = "global_moderator" + case moderators + case hiddenModerators = "hidden_moderators" + + case read + case defaultRead = "default_read" + case write + case defaultWrite = "default_write" + case upload + case defaultUpload = "default_upload" + } + + public let token: String + public let created: TimeInterval + public let name: String + public let description: String? + public let imageId: Int64? + + public let infoUpdates: Int64 + public let messageSequence: Int64 + public let activeUsers: Int64 + public let activeUsersCutoff: Int64 + public let pinnedMessages: [PinnedMessage]? + + public let admin: Bool + public let globalAdmin: Bool + public let admins: [String] + public let hiddenAdmins: [String]? + + public let moderator: Bool + public let globalModerator: Bool + public let moderators: [String] + public let hiddenModerators: [String]? + + public let read: Bool + public let defaultRead: Bool + public let write: Bool + public let defaultWrite: Bool + public let upload: Bool + public let defaultUpload: Bool + } +} + +// MARK: - Decoding + +extension OpenGroupAPIV2.Room { + public init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + self = OpenGroupAPIV2.Room( + token: try container.decode(String.self, forKey: .token), + created: try container.decode(TimeInterval.self, forKey: .created), + name: try container.decode(String.self, forKey: .name), + description: try? container.decode(String.self, forKey: .description), + imageId: try? container.decode(Int64.self, forKey: .imageId), + + infoUpdates: try container.decode(Int64.self, forKey: .infoUpdates), + messageSequence: try container.decode(Int64.self, forKey: .messageSequence), + activeUsers: try container.decode(Int64.self, forKey: .activeUsers), + activeUsersCutoff: try container.decode(Int64.self, forKey: .activeUsersCutoff), + pinnedMessages: try? container.decode([OpenGroupAPIV2.PinnedMessage].self, forKey: .pinnedMessages), + + admin: ((try? container.decode(Bool.self, forKey: .admin)) ?? false), + globalAdmin: ((try? container.decode(Bool.self, forKey: .globalAdmin)) ?? false), + admins: try container.decode([String].self, forKey: .admins), + hiddenAdmins: try? container.decode([String].self, forKey: .hiddenAdmins), + + moderator: ((try? container.decode(Bool.self, forKey: .moderator)) ?? false), + globalModerator: ((try? container.decode(Bool.self, forKey: .globalModerator)) ?? false), + moderators: try container.decode([String].self, forKey: .moderators), + hiddenModerators: try? container.decode([String].self, forKey: .hiddenModerators), + + read: try container.decode(Bool.self, forKey: .read), + defaultRead: ((try? container.decode(Bool.self, forKey: .defaultRead)) ?? false), + write: try container.decode(Bool.self, forKey: .write), + defaultWrite: ((try? container.decode(Bool.self, forKey: .defaultWrite)) ?? false), + upload: try container.decode(Bool.self, forKey: .upload), + defaultUpload: ((try? container.decode(Bool.self, forKey: .defaultUpload)) ?? false) + ) + } +} + diff --git a/SessionMessagingKit/Open Groups/Models/RoomPollInfo.swift b/SessionMessagingKit/Open Groups/Models/RoomPollInfo.swift new file mode 100644 index 000000000..d48bc24a9 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/RoomPollInfo.swift @@ -0,0 +1,73 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPIV2 { + /// This only contains ephemeral data + public struct RoomPollInfo: Codable { + enum CodingKeys: String, CodingKey { + case token + case created + case name + case description + case imageId = "image_id" + + case infoUpdates = "info_updates" + case messageSequence = "message_sequence" + case activeUsers = "active_users" + case activeUsersCutoff = "active_users_cutoff" + case pinnedMessages = "pinned_messages" + + case admin + case globalAdmin = "global_admin" + case admins + case hiddenAdmins = "hidden_admins" + + case moderator + case globalModerator = "global_moderator" + case moderators + case hiddenModerators = "hidden_moderators" + + case read + case defaultRead = "default_read" + case write + case defaultWrite = "default_write" + case upload + case defaultUpload = "default_upload" + + case details + } + + public let token: String? + public let created: TimeInterval? + public let name: String? + public let description: String? + public let imageId: Int64? + + public let infoUpdates: Int64? + public let messageSequence: Int64? + public let activeUsers: Int64? + public let activeUsersCutoff: Int64? + public let pinnedMessages: [PinnedMessage]? + + public let admin: Bool? + public let globalAdmin: Bool? + public let admins: [String]? + public let hiddenAdmins: [String]? + + public let moderator: Bool? + public let globalModerator: Bool? + public let moderators: [String]? + public let hiddenModerators: [String]? + + public let read: Bool? + public let defaultRead: Bool? + public let write: Bool? + public let defaultWrite: Bool? + public let upload: Bool? + public let defaultUpload: Bool? + + /// Only populated and different if the `info_updates` counter differs from the provided `info_updated` value + public let details: Room? + } +} diff --git a/SessionMessagingKit/Open Groups/Models/SendMessageRequest.swift b/SessionMessagingKit/Open Groups/Models/SendMessageRequest.swift new file mode 100644 index 000000000..848e677bf --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/SendMessageRequest.swift @@ -0,0 +1,67 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPIV2 { + public struct SendMessageRequest: Codable { + enum CodingKeys: String, CodingKey { + case data + case signature + case whisperTo = "whisper_to" + case whisperMods = "whisper_mods" + case fileIds = "files" + } + + let data: Data + let signature: Data + let whisperTo: String? + let whisperMods: Bool + let fileIds: [Int64]? + + // MARK: - Initialization + + init( + data: Data, + signature: Data, + whisperTo: String? = nil, + whisperMods: Bool = false, + fileIds: [Int64]? = nil + ) { + self.data = data + self.signature = signature + self.whisperTo = whisperTo + self.whisperMods = whisperMods + self.fileIds = fileIds + } + + // MARK: - Encodable + + public func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(data.base64EncodedString(), forKey: .data) + try container.encode(signature.base64EncodedString(), forKey: .signature) + try container.encodeIfPresent(whisperTo, forKey: .whisperTo) + try container.encode(whisperMods, forKey: .whisperMods) + try container.encodeIfPresent(fileIds, forKey: .fileIds) + } + + // MARK: - Signing + + public static func sign(message: Data, for idType: IdPrefix, with publicKey: String) -> (data: Data, signature: Data)? { + guard let userKeyPair: ECKeyPair = SNMessagingKitConfiguration.shared.storage.getUserKeyPair() else { + return nil + } + guard let targetKeyPair: ECKeyPair = try? userKeyPair.convert(to: idType, with: publicKey) else { + return nil + } + + guard let signature = try? Ed25519.sign(message, with: targetKeyPair) else { + SNLog("Failed to sign open group message.") + return nil + } + + return (message, signature) + } + } +} diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPIV2+ObjC.swift b/SessionMessagingKit/Open Groups/OpenGroupAPIV2+ObjC.swift index dd9a57b18..6eb84e145 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPIV2+ObjC.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPIV2+ObjC.swift @@ -12,8 +12,8 @@ extension OpenGroupAPIV2 { return isUserModerator(publicKey, for: room, on: server) } - @objc(getDefaultRoomsIfNeeded) - public static func objc_getDefaultRoomsIfNeeded() { - return getDefaultRoomsIfNeeded() + @objc(legacyGetDefaultRoomsIfNeeded) + public static func objc_legacyGetDefaultRoomsIfNeeded() { + return legacyGetDefaultRoomsIfNeeded() } } diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift b/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift index e447a6832..6741050ca 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift @@ -18,7 +18,7 @@ public final class OpenGroupAPIV2: NSObject { private static var hasUpdatedLastOpenDate = false public static let workQueue = DispatchQueue(label: "OpenGroupAPIV2.workQueue", qos: .userInitiated) // It's important that this is a serial queue public static var moderators: [String: [String: Set]] = [:] // Server URL to room ID to set of moderator IDs - public static var defaultRoomsPromise: Promise<[RoomInfo]>? + public static var defaultRoomsPromise: Promise<[Room]>? public static var groupImagePromises: [String: Promise] = [:] private static let timeSinceLastOpen: TimeInterval = { @@ -27,73 +27,94 @@ public final class OpenGroupAPIV2: NSObject { return Date().timeIntervalSince(lastOpen) }() - // MARK: - Convenience + // MARK: - Batching & Polling - private static func send(_ request: Request) -> Promise { - guard let url: URL = request.url else { return Promise(error: Error.invalidURL) } + public static func poll(_ server: String) -> Promise { + // TODO: Remove comments + // Capabilities + // Fetch each room + // Poll Info + // /room//pollInfo/ instead? + // Fetch messages for each room + // /room/{roomToken}/messages/since/{messageSequence}: + // Fetch deletions for each room (included in messages) - var urlRequest: URLRequest = URLRequest(url: url) - urlRequest.httpMethod = request.verb.rawValue - urlRequest.allHTTPHeaderFields = request.headers - .setting(.room, request.room) // TODO: Is this needed anymore? Add at the request level? - .toHTTPHeaders() - urlRequest.httpBody = request.body + // old compact_poll data +// public let room: String +// public let statusCode: UInt +// public let messages: [OpenGroupMessageV2]? +// public let deletions: [Deletion]? +// public let moderators: [String]? - if request.useOnionRouting { - guard let publicKey = SNMessagingKitConfiguration.shared.storage.getOpenGroupPublicKey(for: request.server) else { - return Promise(error: Error.noPublicKey) - } - - if request.isAuthRequired { - // Determine if we should be using legacy auth for this endpoint - // TODO: Might need to store this at an OpenGroup level (so all requests can use the appropriate method) - if request.endpoint.useLegacyAuth { - // Because legacy auth happens on a per-room basis, we need to have a room to - // make an authenticated request - guard let room = request.room else { - return OnionRequestAPI.sendOnionRequest(urlRequest, to: request.server, using: publicKey) - } + let storage: SessionMessagingKitStorageProtocol = SNMessagingKitConfiguration.shared.storage + let requestResponseType: [BatchRequestInfo] = [ + BatchRequestInfo( + request: Request( + server: server, + endpoint: .capabilities, + queryParameters: [:] // TODO: Add any requirements '.required' + ), + responseType: Capabilities.self + ) + ] + .appending( + storage.getAllV2OpenGroups().values + .filter { $0.server == server } + .flatMap { openGroup -> [BatchRequestInfo] in + let lastSeqNo: Int64? = storage.getLastMessageServerID(for: openGroup.room, on: server) + let targetSeqNo: Int64 = (lastSeqNo ?? 0) - return getAuthToken(for: room, on: request.server) - .then(on: OpenGroupAPIV2.workQueue) { authToken -> Promise in - urlRequest.setValue(authToken, forHTTPHeaderField: Header.authorization.rawValue) - - let promise = OnionRequestAPI.sendOnionRequest(urlRequest, to: request.server, using: publicKey) - promise.catch(on: OpenGroupAPIV2.workQueue) { error in - // A 401 means that we didn't provide a (valid) auth token for a route - // that required one. We use this as an indication that the token we're - // using has expired. Note that a 403 has a different meaning; it means - // that we provided a valid token but it doesn't have a high enough - // permission level for the route in question. - if case OnionRequestAPI.Error.httpRequestFailedAtDestination(let statusCode, _, _) = error, statusCode == 401 { - let storage = SNMessagingKitConfiguration.shared.storage - - storage.writeSync { transaction in - storage.removeAuthToken(for: room, on: request.server, using: transaction) - } - } - } - - return promise - } + return [ + BatchRequestInfo( + request: Request( + server: server, + // TODO: Source the '0' from the open group (will need to add a new field and default to 0) + endpoint: .roomPollInfo(openGroup.room, 0) + ), + responseType: RoomPollInfo.self + ), + BatchRequestInfo( + request: Request( + server: server, + endpoint: (lastSeqNo == nil ? + .roomMessagesRecent(openGroup.room) : + .roomMessagesSince(openGroup.room, seqNo: targetSeqNo) + ) + ), + responseType: [Message].self + ) + ] } - - // Attempt to sign the request with the new auth - guard let signedRequest: URLRequest = sign(urlRequest, with: publicKey) else { - return Promise(error: Error.signingFailed) - } - - // TODO: 'removeAuthToken' as a migration??? (would previously do this when getting a `401`) - return OnionRequestAPI.sendOnionRequest(signedRequest, to: request.server, using: publicKey) - } - - return OnionRequestAPI.sendOnionRequest(urlRequest, to: request.server, using: publicKey) - } + ) - preconditionFailure("It's currently not allowed to send non onion routed requests.") + // TODO: Handle response (maybe in the poller or the OpenGroupManagerV2?) + return batch(server, requests: requestResponseType) + .map { _ in () } } - public static func compactPoll(_ server: String) -> Promise { + private static func batch(_ server: String, requests: [BatchRequestInfo]) -> Promise { + let requestBody: BatchRequest = requests.map { BatchSubRequest(request: $0.request) } + let responseTypes = requests.map { $0.responseType } + + guard let body: Data = try? JSONEncoder().encode(requestBody) else { + return Promise(error: HTTP.Error.invalidJSON) + } + + let request: Request = Request( + method: .post, + server: server, + endpoint: .batch, + body: body + ) + + return send(request) + .decoded(as: responseTypes, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) + .map { result in + return "" + } + } + + public static func compactPoll(_ server: String) -> Promise { let storage: SessionMessagingKitStorageProtocol = SNMessagingKitConfiguration.shared.storage let rooms: [String] = storage.getAllV2OpenGroups().values .filter { $0.server == server } @@ -107,10 +128,10 @@ public final class OpenGroupAPIV2: NSObject { hasUpdatedLastOpenDate = true } - let requestBody: CompactPollBody = CompactPollBody( + let requestBody: LegacyCompactPollBody = LegacyCompactPollBody( requests: rooms - .map { roomId -> CompactPollBody.Room in - CompactPollBody.Room( + .map { roomId -> LegacyCompactPollBody.Room in + LegacyCompactPollBody.Room( id: roomId, fromMessageServerId: (useMessageLimit ? nil : storage.getLastMessageServerID(for: roomId, on: server) @@ -128,22 +149,20 @@ public final class OpenGroupAPIV2: NSObject { } let request = Request( - verb: .post, - room: nil, + method: .post, server: server, endpoint: .legacyCompactPoll(legacyAuth: false), - body: body, - isAuthRequired: true + body: body ) return send(request) - .then(on: OpenGroupAPIV2.workQueue) { data -> Promise in - let response: CompactPollResponse = try data.decoded(as: CompactPollResponse.self, customError: Error.parsingFailed) + .then(on: OpenGroupAPIV2.workQueue) { data -> Promise in + let response: LegacyCompactPollResponse = try data.decoded(as: LegacyCompactPollResponse.self, customError: Error.parsingFailed) return when( fulfilled: response.results - .map { (result: CompactPollResponse.Result) in - process(messages: result.messages, for: result.room, on: server) + .map { (result: LegacyCompactPollResponse.Result) in + legacyProcess(messages: result.messages, for: result.room, on: server) .then(on: OpenGroupAPIV2.workQueue) { _ in process(deletions: result.deletions, for: result.room, on: server) } @@ -152,111 +171,20 @@ public final class OpenGroupAPIV2: NSObject { } } - public static func legacyCompactPoll(_ server: String) -> Promise { - let storage: SessionMessagingKitStorageProtocol = SNMessagingKitConfiguration.shared.storage - let rooms: [String] = storage.getAllV2OpenGroups().values - .filter { $0.server == server } - .map { $0.room } - var getAuthTokenPromises: [String: Promise] = [:] - let useMessageLimit = (hasPerformedInitialPoll[server] != true && timeSinceLastOpen > OpenGroupPollerV2.maxInactivityPeriod) - - hasPerformedInitialPoll[server] = true - - if !hasUpdatedLastOpenDate { - UserDefaults.standard[.lastOpen] = Date() - hasUpdatedLastOpenDate = true - } - - for room in rooms { - getAuthTokenPromises[room] = getAuthToken(for: room, on: server) - } - - let requestBody: CompactPollBody = CompactPollBody( - requests: rooms - .map { roomId -> CompactPollBody.Room in - CompactPollBody.Room( - id: roomId, - fromMessageServerId: (useMessageLimit ? nil : - storage.getLastMessageServerID(for: roomId, on: server) - ), - fromDeletionServerId: (useMessageLimit ? nil : - storage.getLastDeletionServerID(for: roomId, on: server) - ), - legacyAuthToken: nil - ) - } - ) - - return when(fulfilled: [Promise](getAuthTokenPromises.values)) - .then(on: OpenGroupAPIV2.workQueue) { _ -> Promise in - let requestBodyWithAuthTokens: CompactPollBody = CompactPollBody( - requests: requestBody.requests.compactMap { oldRoom -> CompactPollBody.Room? in - guard let authToken: String = getAuthTokenPromises[oldRoom.id]?.value else { return nil } - - return CompactPollBody.Room( - id: oldRoom.id, - fromMessageServerId: oldRoom.fromMessageServerId, - fromDeletionServerId: oldRoom.fromDeletionServerId, - legacyAuthToken: authToken - ) - } - ) - - guard let body: Data = try? JSONEncoder().encode(requestBodyWithAuthTokens) else { - return Promise(error: HTTP.Error.invalidJSON) - } - - let request = Request( - verb: .post, - room: nil, - server: server, - endpoint: .legacyCompactPoll(legacyAuth: true), - body: body, - isAuthRequired: false - ) - - return send(request) - .then(on: OpenGroupAPIV2.workQueue) { data -> Promise in - let response: CompactPollResponse = try data.decoded(as: CompactPollResponse.self, customError: Error.parsingFailed) - - return when( - fulfilled: response.results - .compactMap { (result: CompactPollResponse.Result) -> Promise<[Deletion]>? in - // A 401 means that we didn't provide a (valid) auth token for a route that - // required one. We use this as an indication that the token we're using has - // expired. Note that a 403 has a different meaning; it means that we provided - // a valid token but it doesn't have a high enough permission level for the - // route in question. - guard result.statusCode != 401 else { - storage.writeSync { transaction in - storage.removeAuthToken(for: result.room, on: server, using: transaction) - } - - return nil - } - - return process(messages: result.messages, for: result.room, on: server) - .then(on: OpenGroupAPIV2.workQueue) { _ -> Promise<[Deletion]> in - process(deletions: result.deletions, for: result.room, on: server) - } - } - ).then(on: OpenGroupAPIV2.workQueue) { _ in Promise.value(response) } - } - } - } - // MARK: - Authentication - // TODO: Turn 'Sodium' and 'NonceGenerator16Byte' into protocols for unit testing + // TODO: Turn 'Sodium' and 'NonceGenerator16Byte' into protocols for unit testing. static func sign( _ request: URLRequest, with publicKey: String, sodium: Sodium = Sodium(), nonceGenerator: NonceGenerator16Byte = NonceGenerator16Byte() ) -> URLRequest? { - guard let path: String = request.url?.path else { return nil } + guard let url: URL = request.url else { return nil } var updatedRequest: URLRequest = request + let path: String = url.path + .appending(url.query.map { value in "?\(value)" }) let method: String = (request.httpMethod ?? "GET") let timestamp: Int = Int(floor(Date().timeIntervalSince1970)) let nonce: Data = Data(nonceGenerator.nonce()) @@ -311,191 +239,248 @@ public final class OpenGroupAPIV2: NSObject { return updatedRequest } - private static func getAuthToken(for room: String, on server: String) -> Promise { - // TODO: Do we need to check the `/capabilities` of the SOGS to determine if it has new auth and if not then fall back to the old auth approach?????? - let storage = SNMessagingKitConfiguration.shared.storage - - if let authToken: String = storage.getAuthToken(for: room, on: server) { - return Promise.value(authToken) - } - - if let authTokenPromise: Promise = authTokenPromises.wrappedValue["\(server).\(room)"] { - return authTokenPromise - } - - let promise: Promise = requestNewAuthToken(for: room, on: server) - .then(on: OpenGroupAPIV2.workQueue) { claimAuthToken($0, for: room, on: server) } - .then(on: OpenGroupAPIV2.workQueue) { authToken -> Promise in - let (promise, seal) = Promise.pending() - storage.write(with: { transaction in - storage.setAuthToken(for: room, on: server, to: authToken, using: transaction) - }, completion: { - seal.fulfill(authToken) - }) - return promise - } - - promise - .done(on: OpenGroupAPIV2.workQueue) { _ in - authTokenPromises.wrappedValue["\(server).\(room)"] = nil - } - .catch(on: OpenGroupAPIV2.workQueue) { _ in - authTokenPromises.wrappedValue["\(server).\(room)"] = nil - } - - authTokenPromises.wrappedValue["\(server).\(room)"] = promise - return promise - } - - public static func requestNewAuthToken(for room: String, on server: String) -> Promise { - SNLog("Requesting auth token for server: \(server).") - guard let userKeyPair: ECKeyPair = SNMessagingKitConfiguration.shared.storage.getUserKeyPair() else { - return Promise(error: Error.generic) - } - + // MARK: - Capabilities + + public static func capabilities(on server: String) -> Promise { let request: Request = Request( - verb: .get, - room: room, server: server, - endpoint: .legacyAuthTokenChallenge(legacyAuth: true), - queryParameters: [ - .publicKey: getUserHexEncodedPublicKey() - ], - isAuthRequired: false + endpoint: .capabilities, + queryParameters: [:] // TODO: Add any requirements '.required' ) - return send(request).map(on: OpenGroupAPIV2.workQueue) { data in - let response = try data.decoded(as: AuthTokenResponse.self, customError: Error.parsingFailed) - let symmetricKey = try AESGCM.generateSymmetricKey(x25519PublicKey: response.challenge.ephemeralPublicKey, x25519PrivateKey: userKeyPair.privateKey) - - guard let tokenAsData = try? AESGCM.decrypt(response.challenge.ciphertext, with: symmetricKey) else { - throw Error.decryptionFailed - } - - return tokenAsData.toHexString() - } - } - - public static func claimAuthToken(_ authToken: String, for room: String, on server: String) -> Promise { - let requestBody: PublicKeyBody = PublicKeyBody(publicKey: getUserHexEncodedPublicKey()) - - guard let body: Data = try? JSONEncoder().encode(requestBody) else { - return Promise(error: HTTP.Error.invalidJSON) - } - - let request: Request = Request( - verb: .post, - room: room, - server: server, - endpoint: .legacyAuthTokenClaim(legacyAuth: true), - body: body, - headers: [ - // Set explicitly here because is isn't in the database yet at this point - .authorization: authToken - ], - isAuthRequired: false - ) - - return send(request).map(on: OpenGroupAPIV2.workQueue) { _ in authToken } - } - - /// Should be called when leaving a group. - public static func deleteAuthToken(for room: String, on server: String) -> Promise { - let request: Request = Request( - verb: .delete, - room: room, - server: server, - endpoint: .legacyAuthToken(legacyAuth: true) - ) - - return send(request).map(on: OpenGroupAPIV2.workQueue) { _ in - let storage = SNMessagingKitConfiguration.shared.storage - - storage.write { transaction in - storage.removeAuthToken(for: room, on: server, using: transaction) - } - } + // TODO: Handle a `412` response (ie. a required capability isn't supported) + return send(request) + .decoded(as: Capabilities.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) } - // MARK: - File Storage + // MARK: - Room - public static func upload(_ file: Data, to room: String, on server: String) -> Promise { - let requestBody: FileUploadBody = FileUploadBody(file: file.base64EncodedString()) + public static func rooms(for server: String) -> Promise<[Room]> { + let request: Request = Request( + server: server, + endpoint: .rooms + ) + + return send(request) + .decoded(as: [Room].self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) + } + + public static func room(for roomToken: String, on server: String) -> Promise { + let request: Request = Request( + server: server, + endpoint: .room(roomToken) + ) + + return send(request) + .decoded(as: Room.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) + } + + public static func roomPollInfo(lastUpdated: Int64, for roomToken: String, on server: String) -> Promise { + let request: Request = Request( + server: server, + endpoint: .roomPollInfo(roomToken, lastUpdated) + ) + + return send(request) + .decoded(as: RoomPollInfo.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) + } + + // MARK: - Messages + + public static func send( + _ plaintext: Data, + to roomToken: String, + on server: String, + whisperTo: String?, + whisperMods: Bool, + with serverPublicKey: String + ) -> Promise { + // TODO: Change this to use '.blinded' once it's working + guard let signedRequest: (data: Data, signature: Data) = SendMessageRequest.sign(message: plaintext, for: .standard, with: serverPublicKey) else { + return Promise(error: Error.signingFailed) + } + + let requestBody: SendMessageRequest = SendMessageRequest( + data: signedRequest.data, + signature: signedRequest.signature, + whisperTo: whisperTo, + whisperMods: whisperMods, + fileIds: nil // TODO: Add support for 'fileIds' + ) guard let body: Data = try? JSONEncoder().encode(requestBody) else { - return Promise(error: HTTP.Error.invalidJSON) - } - - let request = Request(verb: .post, room: room, server: server, endpoint: .files, body: body) - - return send(request).map(on: OpenGroupAPIV2.workQueue) { data in - let response: FileUploadResponse = try data.decoded(as: FileUploadResponse.self, customError: Error.parsingFailed) - - return response.fileId - } - } - - public static func download(_ file: UInt64, from room: String, on server: String) -> Promise { - let request = Request(verb: .get, room: room, server: server, endpoint: .file(file)) - - return send(request).map(on: OpenGroupAPIV2.workQueue) { data in - let response: FileDownloadResponse = try data.decoded(as: FileDownloadResponse.self, customError: Error.parsingFailed) - - return response.data - } - } - - // MARK: - Message Sending & Receiving - - public static func send(_ message: OpenGroupMessageV2, to room: String, on server: String, with publicKey: String) -> Promise { - // TODO: Test if we need a legacy version - guard let signedMessage = message.sign(with: publicKey) else { return Promise(error: Error.signingFailed) } - guard let body: Data = try? JSONEncoder().encode(signedMessage) else { return Promise(error: Error.parsingFailed) } - let request = Request(verb: .post, room: room, server: server, endpoint: .messages, body: body) - return send(request).map(on: OpenGroupAPIV2.workQueue) { data in - let message: OpenGroupMessageV2 = try data.decoded(as: OpenGroupMessageV2.self, customError: Error.parsingFailed) - Storage.shared.write { transaction in - Storage.shared.addReceivedMessageTimestamp(message.sentTimestamp, using: transaction) - } - return message - } - } - - public static func getMessages(for room: String, on server: String) -> Promise<[OpenGroupMessageV2]> { - let storage = SNMessagingKitConfiguration.shared.storage - let request: Request = Request( - verb: .get, - room: room, + let request = Request( + method: .post, server: server, - endpoint: .messages, - queryParameters: [ - .fromServerId: storage.getLastMessageServerID(for: room, on: server).map { String($0) } - ].compactMapValues { $0 } + endpoint: .roomMessage(roomToken), + body: body ) - return send(request).then(on: OpenGroupAPIV2.workQueue) { data -> Promise<[OpenGroupMessageV2]> in - let messages: [OpenGroupMessageV2] = try data.decoded(as: [OpenGroupMessageV2].self, customError: Error.parsingFailed) - - return process(messages: messages, for: room, on: server) - } + return send(request) + .decoded(as: Message.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) } - private static func process(messages: [OpenGroupMessageV2]?, for room: String, on server: String) -> Promise<[OpenGroupMessageV2]> { - guard let messages: [OpenGroupMessageV2] = messages, !messages.isEmpty else { return Promise.value([]) } + + public static func recentMessages(in roomToken: String, on server: String) -> Promise<[Message]> { + // TODO: Recent vs. Since? + let request: Request = Request( + server: server, + endpoint: .roomMessagesRecent(roomToken) + // TODO: Limit? +// queryParameters: [ +// .fromServerId: storage.getLastMessageServerID(for: room, on: server).map { String($0) } +// ].compactMapValues { $0 } + ) + + return send(request) + .decoded(as: [Message].self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) + .then(on: OpenGroupAPIV2.workQueue) { messages -> Promise<[Message]> in + process(messages: messages, for: roomToken, on: server) + } + } + + public static func messagesBefore(messageId: Int64, in roomToken: String, on server: String) -> Promise<[Message]> { + // TODO: Recent vs. Since? + let request: Request = Request( + server: server, + endpoint: .roomMessagesBefore(roomToken, id: messageId) + // TODO: Limit? +// queryParameters: [ +// .fromServerId: storage.getLastMessageServerID(for: room, on: server).map { String($0) } +// ].compactMapValues { $0 } + ) + + return send(request) + .decoded(as: [Message].self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) + .then(on: OpenGroupAPIV2.workQueue) { messages -> Promise<[Message]> in + process(messages: messages, for: roomToken, on: server) + } + } + + public static func messagesSince(seqNo: Int64, in roomToken: String, on server: String) -> Promise<[Message]> { + // TODO: Recent vs. Since? + let request: Request = Request( + server: server, + endpoint: .roomMessagesSince(roomToken, seqNo: seqNo) + // TODO: Limit? +// queryParameters: [ +// .fromServerId: storage.getLastMessageServerID(for: room, on: server).map { String($0) } +// ].compactMapValues { $0 } + ) + + return send(request) + .decoded(as: [Message].self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) + .then(on: OpenGroupAPIV2.workQueue) { messages -> Promise<[Message]> in + process(messages: messages, for: roomToken, on: server) + } + } + + // MARK: - Files + + // TODO: Shift this logic to the `OpenGroupManager` (makes more sense since it's not API logic) + public static func roomImage(_ fileId: Int64, for roomToken: String, on server: String) -> Promise { + // Normally the image for a given group is stored with the group thread, so it's only + // fetched once. However, on the join open group screen we show images for groups the + // user * hasn't * joined yet. We don't want to re-fetch these images every time the + // user opens the app because that could slow the app down or be data-intensive. So + // instead we assume that these images don't change that often and just fetch them once + // a week. We also assume that they're all fetched at the same time as well, so that + // we only need to maintain one date in user defaults. On top of all of this we also + // don't double up on fetch requests by storing the existing request as a promise if + // there is one. + let lastOpenGroupImageUpdate: Date? = UserDefaults.standard[.lastOpenGroupImageUpdate] + let now: Date = Date() + let timeSinceLastUpdate: TimeInterval = (given(lastOpenGroupImageUpdate) { now.timeIntervalSince($0) } ?? .greatestFiniteMagnitude) + let updateInterval: TimeInterval = (7 * 24 * 60 * 60) + + if let data = Storage.shared.getOpenGroupImage(for: roomToken, on: server), server == defaultServer, timeSinceLastUpdate < updateInterval { + return Promise.value(data) + } + + if let promise = groupImagePromises["\(server).\(roomToken)"] { + return promise + } + + let promise: Promise = downloadFile(fileId, from: roomToken, on: server) + _ = promise.done(on: OpenGroupAPIV2.workQueue) { imageData in + if server == defaultServer { + Storage.shared.write { transaction in + Storage.shared.setOpenGroupImage(to: imageData, for: roomToken, on: server, using: transaction) + } + UserDefaults.standard[.lastOpenGroupImageUpdate] = now + } + } + groupImagePromises["\(server).\(roomToken)"] = promise + + return promise + } + + public static func uploadFile(_ bytes: [UInt8], fileName: String? = nil, to roomToken: String, on server: String) -> Promise { + let request: Request = Request( + method: .post, + server: server, + endpoint: .roomFile(roomToken), + queryParameters: [ .fileName: fileName ].compactMapValues { $0 }, + body: Data(bytes) + ) + + return send(request) + .decoded(as: FileUploadResponse.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) + } + + /// Warning: This approach is less efficient as it expects the data to be base64Encoded (with is 33% larger than binary), please use the binary approach + /// whenever possible + public static func uploadFile(_ base64EncodedString: String, fileName: String? = nil, to roomToken: String, on server: String) -> Promise { + let request: Request = Request( + method: .post, + server: server, + endpoint: .roomFileJson(roomToken), + queryParameters: [ .fileName: fileName ].compactMapValues { $0 }, + body: Data(base64Encoded: base64EncodedString) + ) + + return send(request) + .decoded(as: FileUploadResponse.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) + } + + public static func downloadFile(_ fileId: Int64, from roomToken: String, on server: String) -> Promise { + let request: Request = Request( + server: server, + endpoint: .roomFileIndividual(roomToken, fileId) + ) + + return send(request) + } + + public static func downloadFileJson(_ fileId: Int64, from roomToken: String, on server: String) -> Promise { + let request: Request = Request( + server: server, + endpoint: .roomFileIndividualJson(roomToken, fileId) + ) + + return send(request) + .decoded(as: FileDownloadResponse.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) + } + + // MARK: - Processing + // TODO: Move these methods to the OpenGroupManager? (seems odd for them to be in the API) + + private static func process(messages: [Message]?, for room: String, on server: String) -> Promise<[Message]> { + guard let messages: [Message] = messages, !messages.isEmpty else { return Promise.value([]) } let storage = SNMessagingKitConfiguration.shared.storage - let serverID: Int64 = (messages.compactMap { $0.serverID }.max() ?? 0) - let lastMessageServerID: Int64 = (storage.getLastMessageServerID(for: room, on: server) ?? 0) + let seqNo: Int64 = (messages.compactMap { $0.seqNo }.max() ?? 0) + let lastMessageSeqNo: Int64 = (storage.getLastMessageServerID(for: room, on: server) ?? 0) - if serverID > lastMessageServerID { - let (promise, seal) = Promise<[OpenGroupMessageV2]>.pending() + if seqNo > lastMessageSeqNo { + let (promise, seal) = Promise<[Message]>.pending() storage.write( with: { transaction in - storage.setLastMessageServerID(for: room, on: server, to: serverID, using: transaction) + storage.setLastMessageServerID(for: room, on: server, to: seqNo, using: transaction) }, completion: { seal.fulfill(messages) @@ -508,39 +493,6 @@ public final class OpenGroupAPIV2: NSObject { return Promise.value(messages) } - // MARK: - Message Deletion - - public static func deleteMessage(with serverID: Int64, from room: String, on server: String) -> Promise { - let request: Request = Request( - verb: .delete, - room: room, - server: server, - endpoint: .messagesForServer(serverID) - ) - // TODO: Legacy version? - return send(request).map(on: OpenGroupAPIV2.workQueue) { _ in } - } - - public static func getDeletedMessages(for room: String, on server: String) -> Promise<[Deletion]> { - let storage = SNMessagingKitConfiguration.shared.storage - - let request: Request = Request( - verb: .get, - room: room, - server: server, - endpoint: .deletedMessages, - queryParameters: [ - .fromServerId: storage.getLastDeletionServerID(for: room, on: server).map { String($0) } - ].compactMapValues { $0 } - ) - // TODO: Legacy version? - return send(request).then(on: OpenGroupAPIV2.workQueue) { data -> Promise<[Deletion]> in - let response: DeletedMessagesResponse = try data.decoded(as: DeletedMessagesResponse.self, customError: Error.parsingFailed) - - return process(deletions: response.deletions, for: room, on: server) - } - } - private static func process(deletions: [Deletion]?, for room: String, on server: String) -> Promise<[Deletion]> { guard let deletions: [Deletion] = deletions else { return Promise.value([]) } @@ -565,80 +517,6 @@ public final class OpenGroupAPIV2: NSObject { return Promise.value(deletions) } - - // MARK: - Moderation - - public static func getModerators(for room: String, on server: String) -> Promise<[String]> { - let request: Request = Request( - verb: .get, - room: room, - server: server, - endpoint: .moderators - ) - // TODO: Legacy version? - return send(request) - .map(on: OpenGroupAPIV2.workQueue) { data in - let response: ModeratorsResponse = try data.decoded(as: ModeratorsResponse.self, customError: Error.parsingFailed) - - if var x = self.moderators[server] { - x[room] = Set(response.moderators) - self.moderators[server] = x - } - else { - self.moderators[server] = [room: Set(response.moderators)] - } - - return response.moderators - } - } - - public static func ban(_ publicKey: String, from room: String, on server: String) -> Promise { - let requestBody: PublicKeyBody = PublicKeyBody(publicKey: getUserHexEncodedPublicKey()) - - guard let body: Data = try? JSONEncoder().encode(requestBody) else { - return Promise(error: HTTP.Error.invalidJSON) - } - // TODO: Legacy version? - let request: Request = Request( - verb: .post, - room: room, - server: server, - endpoint: .blockList, - body: body - ) - - return send(request).map(on: OpenGroupAPIV2.workQueue) { _ in } - } - - public static func banAndDeleteAllMessages(_ publicKey: String, from room: String, on server: String) -> Promise { - let requestBody: PublicKeyBody = PublicKeyBody(publicKey: getUserHexEncodedPublicKey()) - - guard let body: Data = try? JSONEncoder().encode(requestBody) else { - return Promise(error: HTTP.Error.invalidJSON) - } - - // TODO: Legacy version? - let request: Request = Request( - verb: .post, - room: room, - server: server, - endpoint: .banAndDeleteAll, - body: body - ) - - return send(request).map(on: OpenGroupAPIV2.workQueue) { _ in } - } - - public static func unban(_ publicKey: String, from room: String, on server: String) -> Promise { - let request: Request = Request( - verb: .delete, - room: room, - server: server, - endpoint: .blockListIndividual(publicKey) - ) - // TODO: Legacy version? - return send(request).map(on: OpenGroupAPIV2.workQueue) { _ in } - } public static func isUserModerator(_ publicKey: String, for room: String, on server: String) -> Bool { return moderators[server]?[room]?.contains(publicKey) ?? false @@ -653,10 +531,19 @@ public final class OpenGroupAPIV2: NSObject { }, completion: { let promise = attempt(maxRetryCount: 8, recoveringOn: DispatchQueue.main) { - OpenGroupAPIV2.getAllRooms(from: defaultServer) + OpenGroupAPIV2.rooms(for: defaultServer) } _ = promise.done(on: OpenGroupAPIV2.workQueue) { items in - items.forEach { getGroupImage(for: $0.id, on: defaultServer).retainUntilComplete() } + items + .compactMap { room -> (Int64, String)? in + guard let imageId: Int64 = room.imageId else { return nil} + + return (imageId, room.token) + } + .forEach { imageId, roomToken in + roomImage(imageId, for: roomToken, on: defaultServer) + .retainUntilComplete() + } } promise.catch(on: OpenGroupAPIV2.workQueue) { _ in OpenGroupAPIV2.defaultRoomsPromise = nil @@ -666,62 +553,331 @@ public final class OpenGroupAPIV2: NSObject { ) } - public static func getInfo(for room: String, on server: String) -> Promise { + // MARK: - Convenience + + private static func send(_ request: Request) -> Promise { + guard let url: URL = request.url else { return Promise(error: Error.invalidURL) } + + var urlRequest: URLRequest = URLRequest(url: url) + urlRequest.httpMethod = request.method.rawValue + urlRequest.allHTTPHeaderFields = request.headers + .setting(.room, request.room) // TODO: Is this needed anymore? Add at the request level?. + .toHTTPHeaders() + urlRequest.httpBody = request.body + + if request.useOnionRouting { + guard let publicKey = SNMessagingKitConfiguration.shared.storage.getOpenGroupPublicKey(for: request.server) else { + return Promise(error: Error.noPublicKey) + } + + if request.isAuthRequired { + // Determine if we should be using legacy auth for this endpoint + // TODO: Might need to store this at an OpenGroup level (so all requests can use the appropriate method). + if request.endpoint.useLegacyAuth { + // Because legacy auth happens on a per-room basis, we need to have a room to + // make an authenticated request + guard let room = request.room else { + return OnionRequestAPI.sendOnionRequest(urlRequest, to: request.server, using: publicKey) + } + + return legacyGetAuthToken(for: room, on: request.server) + .then(on: OpenGroupAPIV2.workQueue) { authToken -> Promise in + urlRequest.setValue(authToken, forHTTPHeaderField: Header.authorization.rawValue) + + let promise = OnionRequestAPI.sendOnionRequest(urlRequest, to: request.server, using: publicKey) + promise.catch(on: OpenGroupAPIV2.workQueue) { error in + // A 401 means that we didn't provide a (valid) auth token for a route + // that required one. We use this as an indication that the token we're + // using has expired. Note that a 403 has a different meaning; it means + // that we provided a valid token but it doesn't have a high enough + // permission level for the route in question. + if case OnionRequestAPI.Error.httpRequestFailedAtDestination(let statusCode, _, _) = error, statusCode == 401 { + let storage = SNMessagingKitConfiguration.shared.storage + + storage.writeSync { transaction in + storage.removeAuthToken(for: room, on: request.server, using: transaction) + } + } + } + + return promise + } + } + + // Attempt to sign the request with the new auth + guard let signedRequest: URLRequest = sign(urlRequest, with: publicKey) else { + return Promise(error: Error.signingFailed) + } + + // TODO: 'removeAuthToken' as a migration??? (would previously do this when getting a `401`). + return OnionRequestAPI.sendOnionRequest(signedRequest, to: request.server, using: publicKey) + } + + return OnionRequestAPI.sendOnionRequest(urlRequest, to: request.server, using: publicKey) + } + + preconditionFailure("It's currently not allowed to send non onion routed requests.") + } + + // MARK: - + // MARK: - + // MARK: - Legacy Requests (To be removed) + // TODO: Remove the legacy requests (should be unused once we release - just here for testing) + + public static var legacyDefaultRoomsPromise: Promise<[LegacyRoomInfo]>? + + // MARK: -- Legacy Auth + + private static func legacyGetAuthToken(for room: String, on server: String) -> Promise { + let storage = SNMessagingKitConfiguration.shared.storage + + if let authToken: String = storage.getAuthToken(for: room, on: server) { + return Promise.value(authToken) + } + + if let authTokenPromise: Promise = authTokenPromises.wrappedValue["\(server).\(room)"] { + return authTokenPromise + } + + let promise: Promise = legacyRequestNewAuthToken(for: room, on: server) + .then(on: OpenGroupAPIV2.workQueue) { legacyClaimAuthToken($0, for: room, on: server) } + .then(on: OpenGroupAPIV2.workQueue) { authToken -> Promise in + let (promise, seal) = Promise.pending() + storage.write(with: { transaction in + storage.setAuthToken(for: room, on: server, to: authToken, using: transaction) + }, completion: { + seal.fulfill(authToken) + }) + return promise + } + + promise + .done(on: OpenGroupAPIV2.workQueue) { _ in + authTokenPromises.wrappedValue["\(server).\(room)"] = nil + } + .catch(on: OpenGroupAPIV2.workQueue) { _ in + authTokenPromises.wrappedValue["\(server).\(room)"] = nil + } + + authTokenPromises.wrappedValue["\(server).\(room)"] = promise + return promise + } + + public static func legacyRequestNewAuthToken(for room: String, on server: String) -> Promise { + SNLog("Requesting auth token for server: \(server).") + guard let userKeyPair: ECKeyPair = SNMessagingKitConfiguration.shared.storage.getUserKeyPair() else { + return Promise(error: Error.generic) + } + let request: Request = Request( - verb: .get, - room: room, server: server, - endpoint: .roomInfo(room), + room: room, + endpoint: .legacyAuthTokenChallenge(legacyAuth: true), + queryParameters: [ + .publicKey: getUserHexEncodedPublicKey() + ], isAuthRequired: false ) - return send(request) - .map(on: OpenGroupAPIV2.workQueue) { data in - let response: GetInfoResponse = try data.decoded(as: GetInfoResponse.self, customError: Error.parsingFailed) + return send(request).map(on: OpenGroupAPIV2.workQueue) { data in + let response = try data.decoded(as: AuthTokenResponse.self, customError: Error.parsingFailed) + let symmetricKey = try AESGCM.generateSymmetricKey(x25519PublicKey: response.challenge.ephemeralPublicKey, x25519PrivateKey: userKeyPair.privateKey) + + guard let tokenAsData = try? AESGCM.decrypt(response.challenge.ciphertext, with: symmetricKey) else { + throw Error.decryptionFailed + } + + return tokenAsData.toHexString() + } + } + + public static func legacyClaimAuthToken(_ authToken: String, for room: String, on server: String) -> Promise { + let requestBody: PublicKeyBody = PublicKeyBody(publicKey: getUserHexEncodedPublicKey()) + + guard let body: Data = try? JSONEncoder().encode(requestBody) else { + return Promise(error: HTTP.Error.invalidJSON) + } + + let request: Request = Request( + method: .post, + server: server, + room: room, + endpoint: .legacyAuthTokenClaim(legacyAuth: true), + body: body, + headers: [ + // Set explicitly here because is isn't in the database yet at this point + .authorization: authToken + ], + isAuthRequired: false + ) + + return send(request).map(on: OpenGroupAPIV2.workQueue) { _ in authToken } + } + + /// Should be called when leaving a group. + public static func legacyDeleteAuthToken(for room: String, on server: String) -> Promise { + let request: Request = Request( + method: .delete, + server: server, + room: room, + endpoint: .legacyAuthToken(legacyAuth: true) + ) + + return send(request).map(on: OpenGroupAPIV2.workQueue) { _ in + let storage = SNMessagingKitConfiguration.shared.storage + + storage.write { transaction in + storage.removeAuthToken(for: room, on: server, using: transaction) + } + } + } + + // MARK: -- Legacy Requests + + public static func legacyCompactPoll(_ server: String) -> Promise { + let storage: SessionMessagingKitStorageProtocol = SNMessagingKitConfiguration.shared.storage + let rooms: [String] = storage.getAllV2OpenGroups().values + .filter { $0.server == server } + .map { $0.room } + var getAuthTokenPromises: [String: Promise] = [:] + let useMessageLimit = (hasPerformedInitialPoll[server] != true && timeSinceLastOpen > OpenGroupPollerV2.maxInactivityPeriod) + + hasPerformedInitialPoll[server] = true + + if !hasUpdatedLastOpenDate { + UserDefaults.standard[.lastOpen] = Date() + hasUpdatedLastOpenDate = true + } + + for room in rooms { + getAuthTokenPromises[room] = legacyGetAuthToken(for: room, on: server) + } + + let requestBody: LegacyCompactPollBody = LegacyCompactPollBody( + requests: rooms + .map { roomId -> LegacyCompactPollBody.Room in + LegacyCompactPollBody.Room( + id: roomId, + fromMessageServerId: (useMessageLimit ? nil : + storage.getLastMessageServerID(for: roomId, on: server) + ), + fromDeletionServerId: (useMessageLimit ? nil : + storage.getLastDeletionServerID(for: roomId, on: server) + ), + legacyAuthToken: nil + ) + } + ) + + return when(fulfilled: [Promise](getAuthTokenPromises.values)) + .then(on: OpenGroupAPIV2.workQueue) { _ -> Promise in + let requestBodyWithAuthTokens: LegacyCompactPollBody = LegacyCompactPollBody( + requests: requestBody.requests.compactMap { oldRoom -> LegacyCompactPollBody.Room? in + guard let authToken: String = getAuthTokenPromises[oldRoom.id]?.value else { return nil } + + return LegacyCompactPollBody.Room( + id: oldRoom.id, + fromMessageServerId: oldRoom.fromMessageServerId, + fromDeletionServerId: oldRoom.fromDeletionServerId, + legacyAuthToken: authToken + ) + } + ) - return response.room + guard let body: Data = try? JSONEncoder().encode(requestBodyWithAuthTokens) else { + return Promise(error: HTTP.Error.invalidJSON) + } + + let request = Request( + method: .post, + server: server, + endpoint: .legacyCompactPoll(legacyAuth: true), + body: body, + isAuthRequired: false + ) + + return send(request) + .then(on: OpenGroupAPIV2.workQueue) { data -> Promise in + let response: LegacyCompactPollResponse = try data.decoded(as: LegacyCompactPollResponse.self, customError: Error.parsingFailed) + + return when( + fulfilled: response.results + .compactMap { (result: LegacyCompactPollResponse.Result) -> Promise<[Deletion]>? in + // A 401 means that we didn't provide a (valid) auth token for a route that + // required one. We use this as an indication that the token we're using has + // expired. Note that a 403 has a different meaning; it means that we provided + // a valid token but it doesn't have a high enough permission level for the + // route in question. + guard result.statusCode != 401 else { + storage.writeSync { transaction in + storage.removeAuthToken(for: result.room, on: server, using: transaction) + } + + return nil + } + + return legacyProcess(messages: result.messages, for: result.room, on: server) + .then(on: OpenGroupAPIV2.workQueue) { _ -> Promise<[Deletion]> in + legacyProcess(deletions: result.deletions, for: result.room, on: server) + } + } + ).then(on: OpenGroupAPIV2.workQueue) { _ in Promise.value(response) } + } } } - public static func getAllRooms(from server: String) -> Promise<[RoomInfo]> { + public static func legacyGetDefaultRoomsIfNeeded() { + Storage.shared.write( + with: { transaction in + Storage.shared.setOpenGroupPublicKey(for: defaultServer, to: defaultServerPublicKey, using: transaction) + }, + completion: { + let promise = attempt(maxRetryCount: 8, recoveringOn: DispatchQueue.main) { + OpenGroupAPIV2.legacyGetAllRooms(from: defaultServer) + } + _ = promise.done(on: OpenGroupAPIV2.workQueue) { items in + items.forEach { legacyGetGroupImage(for: $0.id, on: defaultServer).retainUntilComplete() } + } + promise.catch(on: OpenGroupAPIV2.workQueue) { _ in + OpenGroupAPIV2.defaultRoomsPromise = nil + } + legacyDefaultRoomsPromise = promise + } + ) + } + + public static func legacyGetAllRooms(from server: String) -> Promise<[LegacyRoomInfo]> { let request: Request = Request( - verb: .get, - room: nil, server: server, - endpoint: .rooms, + endpoint: .legacyRooms, isAuthRequired: false ) return send(request) .map(on: OpenGroupAPIV2.workQueue) { data in - let response: RoomsResponse = try data.decoded(as: RoomsResponse.self, customError: Error.parsingFailed) + let response: LegacyRoomsResponse = try data.decoded(as: LegacyRoomsResponse.self, customError: Error.parsingFailed) return response.rooms } } - public static func getMemberCount(for room: String, on server: String) -> Promise { + public static func legacyGetRoomInfo(for room: String, on server: String) -> Promise { let request: Request = Request( - verb: .get, - room: room, server: server, - endpoint: .legacyMemberCount(legacyAuth: true) + room: room, + endpoint: .legacyRoomInfo(room), + isAuthRequired: false ) - // TODO: Non-legacy version? + return send(request) .map(on: OpenGroupAPIV2.workQueue) { data in - let response: MemberCountResponse = try data.decoded(as: MemberCountResponse.self, customError: Error.parsingFailed) + let response: LegacyGetInfoResponse = try data.decoded(as: LegacyGetInfoResponse.self, customError: Error.parsingFailed) - let storage = SNMessagingKitConfiguration.shared.storage - storage.write { transaction in - storage.setUserCount(to: response.memberCount, forV2OpenGroupWithID: "\(server).\(room)", using: transaction) - } - - return response.memberCount + return response.room } } - public static func getGroupImage(for room: String, on server: String) -> Promise { + public static func legacyGetGroupImage(for room: String, on server: String) -> Promise { // Normally the image for a given group is stored with the group thread, so it's only // fetched once. However, on the join open group screen we show images for groups the // user * hasn't * joined yet. We don't want to re-fetch these images every time the @@ -745,15 +901,14 @@ public final class OpenGroupAPIV2: NSObject { } let request: Request = Request( - verb: .get, - room: room, server: server, - endpoint: .roomImage(room), + room: room, + endpoint: .legacyRoomImage(room), isAuthRequired: false ) - // TODO: Legacy version (doesn't work on new SOGS) + let promise: Promise = send(request).map(on: OpenGroupAPIV2.workQueue) { data in - let response: FileDownloadResponse = try data.decoded(as: FileDownloadResponse.self, customError: Error.parsingFailed) + let response: LegacyFileDownloadResponse = try data.decoded(as: LegacyFileDownloadResponse.self, customError: Error.parsingFailed) if server == defaultServer { Storage.shared.write { transaction in @@ -768,4 +923,245 @@ public final class OpenGroupAPIV2: NSObject { return promise } + + public static func legacyGetMemberCount(for room: String, on server: String) -> Promise { + let request: Request = Request( + server: server, + room: room, + endpoint: .legacyMemberCount(legacyAuth: true) + ) + + return send(request) + .map(on: OpenGroupAPIV2.workQueue) { data in + let response: MemberCountResponse = try data.decoded(as: MemberCountResponse.self, customError: Error.parsingFailed) + + let storage = SNMessagingKitConfiguration.shared.storage + storage.write { transaction in + storage.setUserCount(to: response.memberCount, forV2OpenGroupWithID: "\(server).\(room)", using: transaction) + } + + return response.memberCount + } + } + + // MARK: - Legacy File Storage + + public static func upload(_ file: Data, to room: String, on server: String) -> Promise { + let requestBody: FileUploadBody = FileUploadBody(file: file.base64EncodedString()) + + guard let body: Data = try? JSONEncoder().encode(requestBody) else { + return Promise(error: HTTP.Error.invalidJSON) + } + + let request = Request(method: .post, server: server, room: room, endpoint: .legacyFiles, body: body) + + return send(request).map(on: OpenGroupAPIV2.workQueue) { data in + let response: LegacyFileUploadResponse = try data.decoded(as: LegacyFileUploadResponse.self, customError: Error.parsingFailed) + + return response.fileId + } + } + + public static func download(_ file: UInt64, from room: String, on server: String) -> Promise { + let request = Request(server: server, room: room, endpoint: .legacyFile(file)) + + return send(request).map(on: OpenGroupAPIV2.workQueue) { data in + let response: LegacyFileDownloadResponse = try data.decoded(as: LegacyFileDownloadResponse.self, customError: Error.parsingFailed) + + return response.data + } + } + + // MARK: - Legacy Message Sending & Receiving + + public static func legacySend(_ message: OpenGroupMessageV2, to room: String, on server: String, with publicKey: String) -> Promise { + guard let signedMessage = message.sign(with: publicKey) else { return Promise(error: Error.signingFailed) } + guard let body: Data = try? JSONEncoder().encode(signedMessage) else { + return Promise(error: Error.parsingFailed) + } + let request = Request(method: .post, server: server, room: room, endpoint: .legacyMessages, body: body) + + return send(request).map(on: OpenGroupAPIV2.workQueue) { data in + let message: OpenGroupMessageV2 = try data.decoded(as: OpenGroupMessageV2.self, customError: Error.parsingFailed) + Storage.shared.write { transaction in + Storage.shared.addReceivedMessageTimestamp(message.sentTimestamp, using: transaction) + } + return message + } + } + + public static func legacyGetMessages(for room: String, on server: String) -> Promise<[OpenGroupMessageV2]> { + let storage = SNMessagingKitConfiguration.shared.storage + let request: Request = Request( + server: server, + room: room, + endpoint: .legacyMessages, + queryParameters: [ + .fromServerId: storage.getLastMessageServerID(for: room, on: server).map { String($0) } + ].compactMapValues { $0 } + ) + + return send(request).then(on: OpenGroupAPIV2.workQueue) { data -> Promise<[OpenGroupMessageV2]> in + let messages: [OpenGroupMessageV2] = try data.decoded(as: [OpenGroupMessageV2].self, customError: Error.parsingFailed) + + return legacyProcess(messages: messages, for: room, on: server) + } + } + + // MARK: - Legacy Message Deletion + + public static func deleteMessage(with serverID: Int64, from room: String, on server: String) -> Promise { + let request: Request = Request( + method: .delete, + server: server, + room: room, + endpoint: .legacyMessagesForServer(serverID) + ) + + return send(request).map(on: OpenGroupAPIV2.workQueue) { _ in } + } + + public static func getDeletedMessages(for room: String, on server: String) -> Promise<[Deletion]> { + let storage = SNMessagingKitConfiguration.shared.storage + + let request: Request = Request( + server: server, + room: room, + endpoint: .legacyDeletedMessages, + queryParameters: [ + .fromServerId: storage.getLastDeletionServerID(for: room, on: server).map { String($0) } + ].compactMapValues { $0 } + ) + + return send(request).then(on: OpenGroupAPIV2.workQueue) { data -> Promise<[Deletion]> in + let response: DeletedMessagesResponse = try data.decoded(as: DeletedMessagesResponse.self, customError: Error.parsingFailed) + + return process(deletions: response.deletions, for: room, on: server) + } + } + + // MARK: - Legacy Moderation + + public static func getModerators(for room: String, on server: String) -> Promise<[String]> { + let request: Request = Request( + server: server, + room: room, + endpoint: .legacyModerators + ) + + return send(request) + .map(on: OpenGroupAPIV2.workQueue) { data in + let response: ModeratorsResponse = try data.decoded(as: ModeratorsResponse.self, customError: Error.parsingFailed) + + if var x = self.moderators[server] { + x[room] = Set(response.moderators) + self.moderators[server] = x + } + else { + self.moderators[server] = [room: Set(response.moderators)] + } + + return response.moderators + } + } + + public static func ban(_ publicKey: String, from room: String, on server: String) -> Promise { + let requestBody: PublicKeyBody = PublicKeyBody(publicKey: getUserHexEncodedPublicKey()) + + guard let body: Data = try? JSONEncoder().encode(requestBody) else { + return Promise(error: HTTP.Error.invalidJSON) + } + + let request: Request = Request( + method: .post, + server: server, + room: room, + endpoint: .legacyBlockList, + body: body + ) + + return send(request).map(on: OpenGroupAPIV2.workQueue) { _ in } + } + + public static func banAndDeleteAllMessages(_ publicKey: String, from room: String, on server: String) -> Promise { + let requestBody: PublicKeyBody = PublicKeyBody(publicKey: getUserHexEncodedPublicKey()) + + guard let body: Data = try? JSONEncoder().encode(requestBody) else { + return Promise(error: HTTP.Error.invalidJSON) + } + + let request: Request = Request( + method: .post, + server: server, + room: room, + endpoint: .legacyBanAndDeleteAll, + body: body + ) + + return send(request).map(on: OpenGroupAPIV2.workQueue) { _ in } + } + + public static func unban(_ publicKey: String, from room: String, on server: String) -> Promise { + let request: Request = Request( + method: .delete, + server: server, + room: room, + endpoint: .legacyBlockListIndividual(publicKey) + ) + + return send(request).map(on: OpenGroupAPIV2.workQueue) { _ in } + } + + // MARK: - Processing + // TODO: Move these methods to the OpenGroupManager? (seems odd for them to be in the API) + + private static func legacyProcess(messages: [OpenGroupMessageV2]?, for room: String, on server: String) -> Promise<[OpenGroupMessageV2]> { + guard let messages: [OpenGroupMessageV2] = messages, !messages.isEmpty else { return Promise.value([]) } + + let storage = SNMessagingKitConfiguration.shared.storage + let serverID: Int64 = (messages.compactMap { $0.serverID }.max() ?? 0) + let lastMessageServerID: Int64 = (storage.getLastMessageServerID(for: room, on: server) ?? 0) + + if serverID > lastMessageServerID { + let (promise, seal) = Promise<[OpenGroupMessageV2]>.pending() + + storage.write( + with: { transaction in + storage.setLastMessageServerID(for: room, on: server, to: serverID, using: transaction) + }, + completion: { + seal.fulfill(messages) + } + ) + + return promise + } + + return Promise.value(messages) + } + + private static func legacyProcess(deletions: [Deletion]?, for room: String, on server: String) -> Promise<[Deletion]> { + guard let deletions: [Deletion] = deletions else { return Promise.value([]) } + + let storage = SNMessagingKitConfiguration.shared.storage + let serverID: Int64 = (deletions.compactMap { $0.id }.max() ?? 0) + let lastDeletionServerID: Int64 = (storage.getLastDeletionServerID(for: room, on: server) ?? 0) + + if serverID > lastDeletionServerID { + let (promise, seal) = Promise<[Deletion]>.pending() + + storage.write( + with: { transaction in + storage.setLastDeletionServerID(for: room, on: server, to: serverID, using: transaction) + }, + completion: { + seal.fulfill(deletions) + } + ) + + return promise + } + + return Promise.value(deletions) + } } diff --git a/SessionMessagingKit/Open Groups/OpenGroupManagerV2.swift b/SessionMessagingKit/Open Groups/OpenGroupManagerV2.swift index 804a1b730..6ce29d141 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManagerV2.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManagerV2.swift @@ -41,42 +41,116 @@ public final class OpenGroupManagerV2 : NSObject { let transaction = transaction as! YapDatabaseReadWriteTransaction transaction.addCompletionQueue(DispatchQueue.global(qos: .userInitiated)) { // Get the group info - OpenGroupAPIV2.getInfo(for: room, on: server).done(on: DispatchQueue.global(qos: .userInitiated)) { info in - // Create the open group model and the thread - let openGroup = OpenGroupV2(server: server, room: room, name: info.name, publicKey: publicKey, imageID: info.imageID) - let groupID = LKGroupUtilities.getEncodedOpenGroupIDAsData(openGroup.id) - let model = TSGroupModel(title: openGroup.name, memberIds: [ getUserHexEncodedPublicKey() ], image: nil, groupId: groupID, groupType: .openGroup, adminIds: []) - // Store everything - storage.write(with: { transaction in - let transaction = transaction as! YapDatabaseReadWriteTransaction - let thread = TSGroupThread.getOrCreateThread(with: model, transaction: transaction) - thread.shouldBeVisible = true - thread.save(with: transaction) - storage.setV2OpenGroup(openGroup, for: thread.uniqueId!, using: transaction) - }, completion: { - // Start the poller if needed - if OpenGroupManagerV2.shared.pollers[server] == nil { - let poller = OpenGroupPollerV2(for: server) - poller.startIfNeeded() - OpenGroupManagerV2.shared.pollers[server] = poller - } - // Fetch the group image - OpenGroupAPIV2.getGroupImage(for: room, on: server).done(on: DispatchQueue.global(qos: .userInitiated)) { data in - storage.write { transaction in - // Update the thread + // TODO: Remove this legacy method +// OpenGroupAPIV2.legacyGetRoomInfo(for: room, on: server).done(on: DispatchQueue.global(qos: .userInitiated)) { info in +// // Create the open group model and the thread +// let openGroup = OpenGroupV2(server: server, room: room, name: info.name, publicKey: publicKey, imageID: info.imageID) +// let groupID = LKGroupUtilities.getEncodedOpenGroupIDAsData(openGroup.id) +// let model = TSGroupModel(title: openGroup.name, memberIds: [ getUserHexEncodedPublicKey() ], image: nil, groupId: groupID, groupType: .openGroup, adminIds: []) +// // Store everything +// storage.write(with: { transaction in +// let transaction = transaction as! YapDatabaseReadWriteTransaction +// let thread = TSGroupThread.getOrCreateThread(with: model, transaction: transaction) +// thread.shouldBeVisible = true +// thread.save(with: transaction) +// storage.setV2OpenGroup(openGroup, for: thread.uniqueId!, using: transaction) +// }, completion: { +// // Start the poller if needed +// if OpenGroupManagerV2.shared.pollers[server] == nil { +// let poller = OpenGroupPollerV2(for: server) +// poller.startIfNeeded() +// OpenGroupManagerV2.shared.pollers[server] = poller +// } +// // Fetch the group image +// OpenGroupAPIV2.legacyGetGroupImage(for: room, on: server).done(on: DispatchQueue.global(qos: .userInitiated)) { data in +// storage.write { transaction in +// // Update the thread +// let transaction = transaction as! YapDatabaseReadWriteTransaction +// let thread = TSGroupThread.getOrCreateThread(with: model, transaction: transaction) +// thread.groupModel.groupImage = UIImage(data: data) +// thread.save(with: transaction) +// } +// }.retainUntilComplete() +// // Finish +// seal.fulfill(()) +// }) +// }.catch(on: DispatchQueue.global(qos: .userInitiated)) { error in +// seal.reject(error) +// } + + OpenGroupAPIV2.room(for: room, on: server) + .done(on: DispatchQueue.global(qos: .userInitiated)) { room in + // Create the open group model and the thread + let openGroup: OpenGroupV2 = OpenGroupV2( + server: server, + room: room.token, + name: room.name, + publicKey: publicKey, + imageID: room.imageId.map { "\($0)" } // TODO: Update this? + ) + + let groupID: Data = LKGroupUtilities.getEncodedOpenGroupIDAsData(openGroup.id) + let model: TSGroupModel = TSGroupModel( + title: openGroup.name, + memberIds: [ getUserHexEncodedPublicKey() ], + image: nil, + groupId: groupID, + groupType: .openGroup, + adminIds: [] // TODO: This is part of the 'room' object + ) + + // Store everything + storage.write( + with: { transaction in let transaction = transaction as! YapDatabaseReadWriteTransaction let thread = TSGroupThread.getOrCreateThread(with: model, transaction: transaction) - thread.groupModel.groupImage = UIImage(data: data) + thread.shouldBeVisible = true thread.save(with: transaction) + storage.setV2OpenGroup(openGroup, for: thread.uniqueId!, using: transaction) + }, + completion: { + // Start the poller if needed + if OpenGroupManagerV2.shared.pollers[server] == nil { + let poller = OpenGroupPollerV2(for: server) + poller.startIfNeeded() + OpenGroupManagerV2.shared.pollers[server] = poller + } + + // Fetch the group image (if there is one) + // TODO: Need to test this + // TODO: Clean this up (can we avoid the if/else with fancy promise wrangling?) + if let imageId: Int64 = room.imageId { + OpenGroupAPIV2.roomImage(imageId, for: room.token, on: server) + .done(on: DispatchQueue.global(qos: .userInitiated)) { data in + storage.write { transaction in + // Update the thread + let transaction = transaction as! YapDatabaseReadWriteTransaction + let thread = TSGroupThread.getOrCreateThread(with: model, transaction: transaction) + thread.groupModel.groupImage = UIImage(data: data) + thread.save(with: transaction) + } + } + .retainUntilComplete() + } + else { + storage.write { transaction in + // Update the thread + let transaction = transaction as! YapDatabaseReadWriteTransaction + let thread = TSGroupThread.getOrCreateThread(with: model, transaction: transaction) + thread.save(with: transaction) + } + } + + // Finish + seal.fulfill(()) } - }.retainUntilComplete() - // Finish - seal.fulfill(()) - }) - }.catch(on: DispatchQueue.global(qos: .userInitiated)) { error in - seal.reject(error) - } + ) + } + .catch(on: DispatchQueue.global(qos: .userInitiated)) { error in + seal.reject(error) + } } + return promise } @@ -102,7 +176,7 @@ public final class OpenGroupManagerV2 : NSObject { Storage.shared.removeReceivedMessageTimestamps(messageTimestamps, using: transaction) Storage.shared.removeLastMessageServerID(for: openGroup.room, on: openGroup.server, using: transaction) Storage.shared.removeLastDeletionServerID(for: openGroup.room, on: openGroup.server, using: transaction) - let _ = OpenGroupAPIV2.deleteAuthToken(for: openGroup.room, on: openGroup.server) + let _ = OpenGroupAPIV2.legacyDeleteAuthToken(for: openGroup.room, on: openGroup.server) thread.removeAllThreadInteractions(with: transaction) thread.remove(with: transaction) Storage.shared.removeV2OpenGroup(for: thread.uniqueId!, using: transaction) diff --git a/SessionMessagingKit/Open Groups/Types/Endpoint.swift b/SessionMessagingKit/Open Groups/Types/Endpoint.swift index ac6b4526b..aa4a55ca1 100644 --- a/SessionMessagingKit/Open Groups/Types/Endpoint.swift +++ b/SessionMessagingKit/Open Groups/Types/Endpoint.swift @@ -3,51 +3,154 @@ import Foundation enum Endpoint { - case files - case file(UInt64) + // Utility - case messages - case messagesForServer(Int64) - case deletedMessages + case onion + case batch + case sequence + case capabilities - case moderators - - case blockList - case blockListIndividual(String) - case banAndDeleteAll + // Rooms case rooms - case roomInfo(String) - case roomImage(String) + case room(String) + case roomPollInfo(String, Int64) + + // Messages + + case roomMessage(String) + case roomMessageIndividual(String, String) + case roomMessagesRecent(String) + case roomMessagesBefore(String, id: Int64) + case roomMessagesSince(String, seqNo: Int64) + + // Pinning + + case roomPinMessage(String, id: Int64) + case roomUnpinMessage(String, id: Int64) + case roomUnpinAll(String) + + // Files + + case roomFile(String) + case roomFileJson(String) + case roomFileIndividual(String, Int64) + case roomFileIndividualJson(String, Int64) + + // Users + + case userBan(String) + case userUnban(String) + case userPermission(String) + case userModerator(String) + case userDeleteMessages(String) // Legacy endpoints (to be deprecated and removed) + + case legacyFiles + case legacyFile(UInt64) + + case legacyMessages + case legacyMessagesForServer(Int64) + case legacyDeletedMessages + + case legacyModerators + + case legacyBlockList + case legacyBlockListIndividual(String) + case legacyBanAndDeleteAll + case legacyCompactPoll(legacyAuth: Bool) case legacyAuthToken(legacyAuth: Bool) case legacyAuthTokenChallenge(legacyAuth: Bool) case legacyAuthTokenClaim(legacyAuth: Bool) + + case legacyRooms + case legacyRoomInfo(String) + case legacyRoomImage(String) case legacyMemberCount(legacyAuth: Bool) var path: String { switch self { - case .files: return "files" - case .file(let fileId): return "files/\(fileId)" + // Utility - case .messages: return "messages" - case .messagesForServer(let serverId): return "messages/\(serverId)" - case .deletedMessages: return "deleted_messages" + case .onion: return "oxen/v4/lsrpc" + case .batch: return "batch" + case .sequence: return "sequence" + case .capabilities: return "capabilities" - case .moderators: return "moderators" - - case .blockList: return "block_list" - case .blockListIndividual(let publicKey): return "block_list/\(publicKey)" - case .banAndDeleteAll: return "ban_and_delete_all" + // Rooms case .rooms: return "rooms" - case .roomInfo(let roomName): return "rooms/\(roomName)" - case .roomImage(let roomName): return "rooms/\(roomName)/image" + case .room(let roomToken): return "room/\(roomToken)" + case .roomPollInfo(let roomToken, let infoUpdated): return "room/\(roomToken)/pollInfo/\(infoUpdated)" + + // Messages + + case .roomMessage(let roomToken): + return "room/\(roomToken)/message" + + case .roomMessageIndividual(let roomToken, let messageId): + return "room/\(roomToken)/message/\(messageId)" + + case .roomMessagesRecent(let roomToken): + return "room/\(roomToken)/messages/recent" + + case .roomMessagesBefore(let roomToken, let messageId): + return "room/\(roomToken)/messages/before/\(messageId)" + + case .roomMessagesSince(let roomToken, let seqNo): + return "room/\(roomToken)/messages/since/\(seqNo)" + + // Pinning + + case .roomPinMessage(let roomToken, let messageId): + return "room/\(roomToken)/pin/\(messageId)" + + case .roomUnpinMessage(let roomToken, let messageId): + return "room/\(roomToken)/unpin/\(messageId)" + + case .roomUnpinAll(let roomToken): + return "room/\(roomToken)/unpin/all" + + // Files + + case .roomFile(let roomToken): return "room/\(roomToken)/file" + case .roomFileJson(let roomToken): return "room/\(roomToken)/fileJSON" + case .roomFileIndividual(let roomToken, let fileId): + // Note: The 'fileName' value is ignored by the server and is only used to distinguish + // this from the 'Json' variant + let fileName: String = "" + return "room/\(roomToken)/file/\(fileId)/\(fileName)" + + case .roomFileIndividualJson(let roomToken, let fileId): + return "room/\(roomToken)/file/\(fileId)" + + // Users + + case .userBan(let sessionId): return "user/\(sessionId)/ban" + case .userUnban(let sessionId): return "user/\(sessionId)/unban" + case .userPermission(let sessionId): return "user/\(sessionId)/permission" + case .userModerator(let sessionId): return "user/\(sessionId)/moderator" + case .userDeleteMessages(let sessionId): return "user/\(sessionId)/deleteMessages" // Legacy endpoints (to be deprecated and removed) - // TODO: Look for a nicer way to prepend 'legacy'? (OnionRequestAPI messes with this but the new auth needs it to be correct...) + // TODO: Look for a nicer way to prepend 'legacy'? (OnionRequestAPI messes with this but the new auth needs it to be correct... ) + + + case .legacyFiles: return "legacy/files" + case .legacyFile(let fileId): return "legacy/files/\(fileId)" + + case .legacyMessages: return "legacy/messages" + case .legacyMessagesForServer(let serverId): return "legacy/messages/\(serverId)" + case .legacyDeletedMessages: return "legacy/deleted_messages" + + case .legacyModerators: return "legacy/moderators" + + case .legacyBlockList: return "legacy/block_list" + case .legacyBlockListIndividual(let publicKey): return "legacy/block_list/\(publicKey)" + case .legacyBanAndDeleteAll: return "legacy/ban_and_delete_all" + case .legacyCompactPoll(let useLegacyAuth): return "\(useLegacyAuth ? "" : "legacy/")compact_poll" @@ -60,6 +163,10 @@ enum Endpoint { case .legacyAuthTokenClaim(let useLegacyAuth): return "\(useLegacyAuth ? "" : "legacy/")claim_auth_token" + case .legacyRooms: return "legacy/rooms" + case .legacyRoomInfo(let roomName): return "legacy/rooms/\(roomName)" + case .legacyRoomImage(let roomName): return "legacy/rooms/\(roomName)/image" + case .legacyMemberCount(let useLegacyAuth): return "\(useLegacyAuth ? "" : "legacy/")member_count" } @@ -68,7 +175,11 @@ enum Endpoint { var useLegacyAuth: Bool { switch self { // File upload/download should use legacy auth - case .files, .file: return true + case .legacyFiles, .legacyFile, .legacyMessages, + .legacyMessagesForServer, .legacyDeletedMessages, + .legacyModerators, .legacyBlockList, + .legacyBlockListIndividual, .legacyBanAndDeleteAll: + return true case .legacyCompactPoll(let useLegacyAuth), .legacyAuthToken(let useLegacyAuth), @@ -76,6 +187,9 @@ enum Endpoint { .legacyAuthTokenClaim(let useLegacyAuth), .legacyMemberCount(let useLegacyAuth): return useLegacyAuth + + case .legacyRooms, .legacyRoomInfo, .legacyRoomImage: + return true default: return false } diff --git a/SessionMessagingKit/Open Groups/Types/Request.swift b/SessionMessagingKit/Open Groups/Types/Request.swift index cb66217c3..2e34adebc 100644 --- a/SessionMessagingKit/Open Groups/Types/Request.swift +++ b/SessionMessagingKit/Open Groups/Types/Request.swift @@ -1,12 +1,13 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import SessionUtilitiesKit extension OpenGroupAPIV2 { struct Request { - let verb: HTTP.Verb - let room: String? + let method: HTTP.Verb let server: String + let room: String? // TODO: Remove this? let endpoint: Endpoint let queryParameters: [QueryParam: String] let body: Data? @@ -17,9 +18,9 @@ extension OpenGroupAPIV2 { let useOnionRouting: Bool init( - verb: HTTP.Verb, - room: String?, + method: HTTP.Verb = .get, server: String, + room: String? = nil, endpoint: Endpoint, queryParameters: [QueryParam: String] = [:], body: Data? = nil, @@ -27,9 +28,9 @@ extension OpenGroupAPIV2 { isAuthRequired: Bool = true, useOnionRouting: Bool = true ) { - self.verb = verb - self.room = room + self.method = method self.server = server + self.room = room self.endpoint = endpoint self.queryParameters = queryParameters self.body = body @@ -39,19 +40,21 @@ extension OpenGroupAPIV2 { } var url: URL? { - guard verb == .get else { return URL(string: "\(server)/\(endpoint.path)") } + return URL(string: "\(server)\(urlPathAndParamsString)") + } + + var urlPathAndParamsString: String { + guard method == .get else { return "/\(endpoint.path)" } - return URL( - string: [ - "\(server)/\(endpoint.path)", - queryParameters - .map { key, value in "\(key.rawValue)=\(value)" } - .joined(separator: "&") - ] - .compactMap { $0 } - .filter { !$0.isEmpty } - .joined(separator: "?") - ) + return [ + "/\(endpoint.path)", + queryParameters + .map { key, value in "\(key.rawValue)=\(value)" } + .joined(separator: "&") + ] + .compactMap { $0 } + .filter { !$0.isEmpty } + .joined(separator: "?") } } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 8542ea923..7289420aa 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -97,8 +97,11 @@ public final class MessageSender : NSObject { // MARK: Convenience public static func send(_ message: Message, to destination: Message.Destination, using transaction: Any) -> Promise { switch destination { - case .contact(_), .closedGroup(_): return sendToSnodeDestination(destination, message: message, using: transaction) - case .openGroup(_, _), .openGroupV2(_, _): return sendToOpenGroupDestination(destination, message: message, using: transaction) + case .contact, .closedGroup: + return sendToSnodeDestination(destination, message: message, using: transaction) + + case .legacyOpenGroup, .openGroup: + return sendToOpenGroupDestination(destination, message: message, using: transaction) } } @@ -117,11 +120,13 @@ public final class MessageSender : NSObject { message.sentTimestamp = NSDate.millisecondTimestamp() } message.sender = userPublicKey + switch destination { - case .contact(let publicKey): message.recipient = publicKey - case .closedGroup(let groupPublicKey): message.recipient = groupPublicKey - case .openGroup(_, _), .openGroupV2(_, _): preconditionFailure() + case .contact(let publicKey): message.recipient = publicKey + case .closedGroup(let groupPublicKey): message.recipient = groupPublicKey + case .legacyOpenGroup, .openGroup: preconditionFailure() } + let isSelfSend = (message.recipient == userPublicKey) // Set the failure handler (need it here already for precondition failure handling) func handleFailure(with error: Swift.Error, using transaction: YapDatabaseReadWriteTransaction) { @@ -167,29 +172,41 @@ public final class MessageSender : NSObject { let ciphertext: Data do { switch destination { - case .contact(let publicKey): ciphertext = try encryptWithSessionProtocol(plaintext, for: publicKey) - case .closedGroup(let groupPublicKey): - guard let encryptionKeyPair = Storage.shared.getLatestClosedGroupEncryptionKeyPair(for: groupPublicKey) else { throw Error.noKeyPair } - ciphertext = try encryptWithSessionProtocol(plaintext, for: encryptionKeyPair.hexEncodedPublicKey) - case .openGroup(_, _), .openGroupV2(_, _): preconditionFailure() + case .contact(let publicKey): + ciphertext = try encryptWithSessionProtocol(plaintext, for: publicKey) + + case .closedGroup(let groupPublicKey): + guard let encryptionKeyPair = Storage.shared.getLatestClosedGroupEncryptionKeyPair(for: groupPublicKey) else { + throw Error.noKeyPair + } + + ciphertext = try encryptWithSessionProtocol(plaintext, for: encryptionKeyPair.hexEncodedPublicKey) + + case .legacyOpenGroup, .openGroup: preconditionFailure() } - } catch { + } + catch { SNLog("Couldn't encrypt message for destination: \(destination) due to error: \(error).") handleFailure(with: error, using: transaction) return promise } + // Wrap the result let kind: SNProtoEnvelope.SNProtoEnvelopeType let senderPublicKey: String + switch destination { - case .contact(_): - kind = .sessionMessage - senderPublicKey = "" - case .closedGroup(let groupPublicKey): - kind = .closedGroupMessage - senderPublicKey = groupPublicKey - case .openGroup(_, _), .openGroupV2(_, _): preconditionFailure() + case .contact: + kind = .sessionMessage + senderPublicKey = "" + + case .closedGroup(let groupPublicKey): + kind = .closedGroupMessage + senderPublicKey = groupPublicKey + + case .legacyOpenGroup, .openGroup: preconditionFailure() } + let wrappedMessage: Data do { wrappedMessage = try MessageWrapper.wrap(type: kind, timestamp: message.sentTimestamp!, @@ -277,16 +294,27 @@ public final class MessageSender : NSObject { } switch destination { - case .contact(_): preconditionFailure() - case .closedGroup(_): preconditionFailure() - case .openGroup(let channel, let server): message.recipient = "\(server).\(channel)" - case .openGroupV2(let room, let server): message.recipient = "\(server).\(room)" + case .contact(_): preconditionFailure() + case .closedGroup(_): preconditionFailure() + case .legacyOpenGroup(let channel, let server): message.recipient = "\(server).\(channel)" + + case .openGroup(let room, let server, let whisperTo, let whisperMods, _): + message.recipient = [ + server, + room, + whisperTo, + (whisperTo == nil && whisperMods ? "mods" : nil) + ] + .compactMap { $0 } + .joined(separator: ".") } + // Set the failure handler (need it here already for precondition failure handling) func handleFailure(with error: Swift.Error, using transaction: YapDatabaseReadWriteTransaction) { MessageSender.handleFailedMessageSend(message, with: error, using: transaction) seal.reject(error) } + // Validate the message guard let message = message as? VisibleMessage else { #if DEBUG @@ -296,45 +324,84 @@ public final class MessageSender : NSObject { return promise #endif } - guard message.isValid else { handleFailure(with: Error.invalidMessage, using: transaction); return promise } + guard message.isValid else { + handleFailure(with: Error.invalidMessage, using: transaction) + return promise + } + // Attach the user's profile - guard let name = storage.getUser()?.name else { handleFailure(with: Error.noUsername, using: transaction); return promise } + guard let name = storage.getUser()?.name else { + handleFailure(with: Error.noUsername, using: transaction) + return promise + } + if let profileKey = storage.getUser()?.profileEncryptionKey?.keyData, let profilePictureURL = storage.getUser()?.profilePictureURL { message.profile = VisibleMessage.Profile(displayName: name, profileKey: profileKey, profilePictureURL: profilePictureURL) - } else { + } + else { message.profile = VisibleMessage.Profile(displayName: name) } + // Convert it to protobuf - guard let proto = message.toProto(using: transaction) else { handleFailure(with: Error.protoConversionFailed, using: transaction); return promise } + guard let proto = message.toProto(using: transaction) else { + handleFailure(with: Error.protoConversionFailed, using: transaction) + return promise + } + // Serialize the protobuf let plaintext: Data + do { plaintext = (try proto.serializedData() as NSData).paddedMessageBody() - } catch { + } + catch { SNLog("Couldn't serialize proto due to error: \(error).") handleFailure(with: error, using: transaction) return promise } - // Send the result - guard case .openGroupV2(let room, let server) = destination else { preconditionFailure() } - // TODO: Determine if the 'getV2OpenGroup' call will cause issues - guard let threadId: String = message.threadID, let openGroupV2 = Storage.shared.getV2OpenGroup(for: threadId) else { preconditionFailure() } - let openGroupMessage = OpenGroupMessageV2(serverID: nil, sender: nil, sentTimestamp: message.sentTimestamp!, - base64EncodedData: plaintext.base64EncodedString(), base64EncodedSignature: nil) - OpenGroupAPIV2.send(openGroupMessage, to: room, on: server, with: openGroupV2.publicKey).done(on: DispatchQueue.global(qos: .userInitiated)) { openGroupMessage in - message.openGroupServerMessageID = given(openGroupMessage.serverID) { UInt64($0) } - storage.write(with: { transaction in - MessageSender.handleSuccessfulMessageSend(message, to: destination, serverTimestamp: openGroupMessage.sentTimestamp, using: transaction) - seal.fulfill(()) - }, completion: { }) - }.catch(on: DispatchQueue.global(qos: .userInitiated)) { error in - storage.write(with: { transaction in - handleFailure(with: error, using: transaction as! YapDatabaseReadWriteTransaction) - }, completion: { }) + // Send the result + + guard case .openGroup(let room, let server, let whisperTo, let whisperMods, _) = destination else { + preconditionFailure() } - // Return - return promise + + // TODO: Determine if the 'getV2OpenGroup' call will cause issues. + guard let threadId: String = message.threadID, let openGroupV2 = Storage.shared.getV2OpenGroup(for: threadId) else { + preconditionFailure() + } + +// let openGroupMessage = OpenGroupMessageV2(serverID: nil, sender: nil, sentTimestamp: message.sentTimestamp!, +// base64EncodedData: plaintext.base64EncodedString(), base64EncodedSignature: nil) + + //OpenGroupAPIV2.send(openGroupMessage, to: room, on: server, with: openGroupV2.publicKey) + return promise // TODO: Remove!!! +// OpenGroupAPIV2 +// .send( +// plaintext, +// to: room, +// on: server, +// whisperTo: whisperTo, +// whisperMods: whisperMods, +// with: openGroupV2.publicKey +// ) +// .done(on: DispatchQueue.global(qos: .userInitiated)) { response in +// print("RAWR") +//// message.openGroupServerMessageID = given(response.serverID) { UInt64($0) } +//// storage.write(with: { transaction in +//// Storage.shared.addReceivedMessageTimestamp(message.sentTimestamp, using: transaction) +//// +//// MessageSender.handleSuccessfulMessageSend(message, to: destination, serverTimestamp: response.sentTimestamp, using: transaction) +//// seal.fulfill(()) +//// }, completion: { }) +// } +// .catch(on: DispatchQueue.global(qos: .userInitiated)) { error in +// storage.write(with: { transaction in +// handleFailure(with: error, using: transaction as! YapDatabaseReadWriteTransaction) +// }, completion: { }) +// } +// // Return +// return promise } // MARK: Success & Failure Handling diff --git a/SessionMessagingKit/Utilities/Promise+Utilities.swift b/SessionMessagingKit/Utilities/Promise+Utilities.swift new file mode 100644 index 000000000..c75808c7a --- /dev/null +++ b/SessionMessagingKit/Utilities/Promise+Utilities.swift @@ -0,0 +1,12 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import PromiseKit + +extension Promise where T == Data { + func decoded(as type: R.Type, on queue: DispatchQueue? = nil, error: Error? = nil) -> Promise { + self.map(on: queue) { data -> R in + try data.decoded(as: type, customError: error) + } + } +} diff --git a/SessionMessagingKit/Utilities/String+Utlities.swift b/SessionMessagingKit/Utilities/String+Utlities.swift new file mode 100644 index 000000000..f97695f5e --- /dev/null +++ b/SessionMessagingKit/Utilities/String+Utlities.swift @@ -0,0 +1,11 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +internal extension String { + func appending(_ other: String?) -> String { + guard let value: String = other else { return self } + + return self.appending(value) + } +} diff --git a/SessionSnodeKit/OnionRequestAPI.swift b/SessionSnodeKit/OnionRequestAPI.swift index e6f89cb85..57192182a 100644 --- a/SessionSnodeKit/OnionRequestAPI.swift +++ b/SessionSnodeKit/OnionRequestAPI.swift @@ -328,7 +328,7 @@ public enum OnionRequestAPI { // endpoint (in which case we need it to ensure the request signing works correctly // TODO: Confirm the 'removingPrefix' isn't going to break the request signing on non-legacy endpoints let endpoint: String = url.path - .removingPrefix("/", if: !url.path.starts(with: "/legacy")) +// .removingPrefix("/", if: !url.path.starts(with: "/legacy")) .appending(url.query.map { value in "?\(value)" }) let scheme: String? = url.scheme let port: UInt16? = url.port.map { UInt16($0) } @@ -382,90 +382,148 @@ public enum OnionRequestAPI { return seal.reject(error) } let destinationSymmetricKey = intermediate.destinationSymmetricKey - HTTP.execute(.post, url, body: body).done2 { json in - guard let base64EncodedIVAndCiphertext = json["result"] as? String, - let ivAndCiphertext = Data(base64Encoded: base64EncodedIVAndCiphertext), ivAndCiphertext.count >= AESGCM.ivSize else { return seal.reject(HTTP.Error.invalidJSON) } - do { - let data = try AESGCM.decrypt(ivAndCiphertext, with: destinationSymmetricKey) - guard let json = try JSONSerialization.jsonObject(with: data, options: [ .fragmentsAllowed ]) as? JSON, - let statusCode = json["status_code"] as? Int ?? json["status"] as? Int else { return seal.reject(HTTP.Error.invalidJSON) } - if statusCode == 406 { // Clock out of sync - SNLog("The user's clock is out of sync with the service node network.") - seal.reject(SnodeAPI.Error.clockOutOfSync) - } else if let bodyAsString = json["body"] as? String { - guard let bodyAsData = bodyAsString.data(using: .utf8), - let body = try JSONSerialization.jsonObject(with: bodyAsData, options: [ .fragmentsAllowed ]) as? JSON else { return seal.reject(HTTP.Error.invalidJSON) } - if let timestamp = body["t"] as? Int64 { - let offset = timestamp - Int64(NSDate.millisecondTimestamp()) - SnodeAPI.clockOffset = offset + + HTTP.execute(.post, url, body: body) + .done2 { json in + guard let base64EncodedIVAndCiphertext = json["result"] as? String, let ivAndCiphertext = Data(base64Encoded: base64EncodedIVAndCiphertext), ivAndCiphertext.count >= AESGCM.ivSize else { + return seal.reject(HTTP.Error.invalidJSON) + } + + do { + let data = try AESGCM.decrypt(ivAndCiphertext, with: destinationSymmetricKey) + + // The JSON data can be either an array or an object so can't cast to 'JSON' here + // TODO: Would be nice to ditch this 'JSONSerialization' behaviour if we can + guard let jsonObject = try? JSONSerialization.jsonObject(with: data, options: [ .fragmentsAllowed ]) else { + return seal.reject(HTTP.Error.invalidJSON) } - guard 200...299 ~= statusCode else { - return seal.reject(Error.httpRequestFailedAtDestination(statusCode: UInt(statusCode), json: body, destination: destination)) + + // TODO: How do we now handle this case when the `status_code` is out of sync now that the value isn't provided? + // TODO: Upgrade to V4? + var customStatusCode: Int = 200 + + if let json: JSON = jsonObject as? JSON, let bodyStatusCode: Int = (((json["status_code"] as? Int) ?? json["status"] as? Int) ?? json["code"] as? Int) { + guard bodyStatusCode != 406 else { + SNLog("The user's clock is out of sync with the service node network.") + return seal.reject(SnodeAPI.Error.clockOutOfSync) + } + + customStatusCode = bodyStatusCode } - seal.fulfill(data) - } else { - guard 200...299 ~= statusCode else { - return seal.reject(Error.httpRequestFailedAtDestination(statusCode: UInt(statusCode), json: json, destination: destination)) + + if let json: JSON = jsonObject as? JSON, let bodyAsString: String = json["body"] as? String { + guard let bodyAsData = bodyAsString.data(using: .utf8), let body = try JSONSerialization.jsonObject(with: bodyAsData, options: [ .fragmentsAllowed ]) as? JSON else { + return seal.reject(HTTP.Error.invalidJSON) + } + + if let timestamp = body["t"] as? Int64 { + let offset = timestamp - Int64(NSDate.millisecondTimestamp()) + SnodeAPI.clockOffset = offset + } + + guard 200...299 ~= customStatusCode else { + return seal.reject( + Error.httpRequestFailedAtDestination( + statusCode: UInt(customStatusCode), + json: body, + destination: destination + ) + ) + } + + return seal.fulfill(data) } + + guard 200...299 ~= customStatusCode else { + return seal.reject( + Error.httpRequestFailedAtDestination( + statusCode: UInt(customStatusCode), + json: json, + destination: destination + ) + ) + } + seal.fulfill(data) } - } catch { + catch { + seal.reject(error) + } + } + .catch2 { error in seal.reject(error) } - }.catch2 { error in - seal.reject(error) - } }.catch2 { error in seal.reject(error) } } + promise.catch2 { error in // Must be invoked on Threading.workQueue - guard case HTTP.Error.httpRequestFailed(let statusCode, let json) = error, let guardSnode = guardSnode else { return } + guard case HTTP.Error.httpRequestFailed(let statusCode, let json) = error, let guardSnode = guardSnode else { + return + } + let path = paths.first { $0.contains(guardSnode) } + func handleUnspecificError() { guard let path = path else { return } + var pathFailureCount = OnionRequestAPI.pathFailureCount[path] ?? 0 pathFailureCount += 1 + if pathFailureCount >= pathFailureThreshold { dropGuardSnode(guardSnode) path.forEach { snode in SnodeAPI.handleError(withStatusCode: statusCode, json: json, forSnode: snode) // Intentionally don't throw } + drop(path) - } else { + } + else { OnionRequestAPI.pathFailureCount[path] = pathFailureCount } } + let prefix = "Next node not found: " + if let message = json?["result"] as? String, message.hasPrefix(prefix) { let ed25519PublicKey = message[message.index(message.startIndex, offsetBy: prefix.count)..= snodeFailureThreshold { SnodeAPI.handleError(withStatusCode: statusCode, json: json, forSnode: snode) // Intentionally don't throw do { try drop(snode) - } catch { + } + catch { handleUnspecificError() } - } else { + } + else { OnionRequestAPI.snodeFailureCount[snode] = snodeFailureCount } } else { // Do nothing } - } else if let message = json?["result"] as? String, message == "Loki Server error" { + } + else if let message = json?["result"] as? String, message == "Loki Server error" { // Do nothing - } else if case .server(let host, _, _, _, _) = destination, host == "116.203.70.33" && statusCode == 0 { + } + else if case .server(let host, _, _, _, _) = destination, host == "116.203.70.33" && statusCode == 0 { // FIXME: Temporary thing to kick out nodes that can't talk to the V2 OGS yet handleUnspecificError() - } else if statusCode == 0 { // Timeout + } + else if statusCode == 0 { // Timeout // Do nothing - } else { + } + else { handleUnspecificError() } } + return promise } } diff --git a/SessionUtilitiesKit/Networking/HTTP.swift b/SessionUtilitiesKit/Networking/HTTP.swift index 5ce5e12ac..20ba08101 100644 --- a/SessionUtilitiesKit/Networking/HTTP.swift +++ b/SessionUtilitiesKit/Networking/HTTP.swift @@ -68,7 +68,7 @@ public enum HTTP { } // MARK: Verb - public enum Verb : String { + public enum Verb: String, Codable { case get = "GET" case put = "PUT" case post = "POST" From c90f346d6a9f9e7d20d2eab520367bd237e34fc0 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 14 Feb 2022 14:07:45 +1100 Subject: [PATCH 005/157] Further SOGS V4 integration work Added in the v4 onion requests logic Added in the new pin/unpin APIs Split up additional legacy methods to try and simplify the refactoring Added a number of TODOs around usage of legacy request methods --- Session.xcodeproj/project.pbxproj | 4 + .../File Server/FileServerAPIV2.swift | 4 +- .../Jobs/AttachmentDownloadJob.swift | 3 +- .../Jobs/AttachmentUploadJob.swift | 11 +- .../Open Groups/Models/BatchRequestInfo.swift | 6 +- .../Open Groups/OpenGroupAPIV2+ObjC.swift | 3 +- .../Open Groups/OpenGroupAPIV2.swift | 247 ++++++---- .../Open Groups/OpenGroupManagerV2.swift | 2 +- .../Sending & Receiving/MessageSender.swift | 55 +-- .../Notifications/PushNotificationAPI.swift | 59 ++- .../Pollers/OpenGroupPollerV2.swift | 17 +- .../Utilities/Promise+Utilities.swift | 13 + SessionSnodeKit/LegacyOnionRequestAPI.swift | 455 ++++++++++++++++++ .../OnionRequestAPI+Encryption.swift | 55 ++- SessionSnodeKit/OnionRequestAPI.swift | 210 ++++---- SessionSnodeKit/SnodeAPI.swift | 3 +- SessionUtilitiesKit/Networking/HTTP.swift | 44 ++ .../MessageSender+Convenience.swift | 6 +- 18 files changed, 944 insertions(+), 253 deletions(-) create mode 100644 SessionSnodeKit/LegacyOnionRequestAPI.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index c5888779b..c48c97224 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -826,6 +826,7 @@ FDC4387427B5BB9B00C60D73 /* Promise+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4387327B5BB9B00C60D73 /* Promise+Utilities.swift */; }; FDC4387627B5BEF300C60D73 /* FileDownloadResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4387527B5BEF300C60D73 /* FileDownloadResponse.swift */; }; FDC4387827B5C35400C60D73 /* SendMessageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4387727B5C35400C60D73 /* SendMessageRequest.swift */; }; + FDC4387C27B9C6C900C60D73 /* LegacyOnionRequestAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4387B27B9C6C900C60D73 /* LegacyOnionRequestAPI.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1917,6 +1918,7 @@ FDC4387327B5BB9B00C60D73 /* Promise+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Promise+Utilities.swift"; sourceTree = ""; }; FDC4387527B5BEF300C60D73 /* FileDownloadResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDownloadResponse.swift; sourceTree = ""; }; FDC4387727B5C35400C60D73 /* SendMessageRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageRequest.swift; sourceTree = ""; }; + FDC4387B27B9C6C900C60D73 /* LegacyOnionRequestAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyOnionRequestAPI.swift; sourceTree = ""; }; FEDBAE1B98C49BBE8C87F575 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; sourceTree = ""; }; FF9BA33D021B115B1F5B4E46 /* Pods-SessionMessagingKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionMessagingKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionMessagingKit/Pods-SessionMessagingKit.app store release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -3363,6 +3365,7 @@ C3C2A5B9255385ED00C340D1 /* Configuration.swift */, C3C2A5BD255385EE00C340D1 /* Notification+OnionRequestAPI.swift */, C3C2A5BA255385ED00C340D1 /* OnionRequestAPI.swift */, + FDC4387B27B9C6C900C60D73 /* LegacyOnionRequestAPI.swift */, C3C2A5BB255385ED00C340D1 /* OnionRequestAPI+Encryption.swift */, C3C2A5B7255385EC00C340D1 /* Snode.swift */, C3C2A5BE255385EE00C340D1 /* SnodeAPI.swift */, @@ -4795,6 +4798,7 @@ buildActionMask = 2147483647; files = ( C3C2A5E02553860B00C340D1 /* Threading.swift in Sources */, + FDC4387C27B9C6C900C60D73 /* LegacyOnionRequestAPI.swift in Sources */, C3C2A5BF255385EE00C340D1 /* SnodeMessage.swift in Sources */, C3C2A5C0255385EE00C340D1 /* Snode.swift in Sources */, C3C2A5C7255385EE00C340D1 /* SnodeAPI.swift in Sources */, diff --git a/SessionMessagingKit/File Server/FileServerAPIV2.swift b/SessionMessagingKit/File Server/FileServerAPIV2.swift index 6e885823f..7829d9b74 100644 --- a/SessionMessagingKit/File Server/FileServerAPIV2.swift +++ b/SessionMessagingKit/File Server/FileServerAPIV2.swift @@ -94,7 +94,9 @@ public final class FileServerAPIV2 : NSObject { preconditionFailure("It's currently not allowed to send non onion routed requests.") } - return OnionRequestAPI.sendOnionRequest(urlRequest, to: server, using: serverPublicKey) + // TODO: Upgrade this to use the V4 onion requests once supported + return LegacyOnionRequestAPI.sendOnionRequest(urlRequest, to: server, using: serverPublicKey) + .map2 { json in try JSONSerialization.data(withJSONObject: json, options: []) } } // MARK: File Storage diff --git a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift index 164f422a9..c3d5d4c32 100644 --- a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift @@ -101,7 +101,8 @@ public final class AttachmentDownloadJob : NSObject, Job, NSCoding { // NSObject guard let fileAsString = pointer.downloadURL.split(separator: "/").last, let file = UInt64(fileAsString) else { return handleFailure(Error.invalidURL) } - OpenGroupAPIV2.download(file, from: v2OpenGroup.room, on: v2OpenGroup.server).done(on: DispatchQueue.global(qos: .userInitiated)) { data in + // TODO: Upgrade this to use the non-legacy version + OpenGroupAPIV2.legacyDownload(file, from: v2OpenGroup.room, on: v2OpenGroup.server).done(on: DispatchQueue.global(qos: .userInitiated)) { data in self.handleDownloadedAttachment(data: data, temporaryFilePath: temporaryFilePath, pointer: pointer, failureHandler: handleFailure) }.catch(on: DispatchQueue.global()) { error in handleFailure(error) diff --git a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift index 401824b69..e48255979 100644 --- a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift @@ -69,7 +69,16 @@ public final class AttachmentUploadJob : NSObject, Job, NSCoding { // NSObject/N guard !stream.isUploaded else { return handleSuccess() } // Should never occur let storage = SNMessagingKitConfiguration.shared.storage if let v2OpenGroup = storage.getV2OpenGroup(for: threadID) { - AttachmentUploadJob.upload(stream, using: { data in return OpenGroupAPIV2.upload(data, to: v2OpenGroup.room, on: v2OpenGroup.server) }, encrypt: false, onSuccess: handleSuccess, onFailure: handleFailure) + AttachmentUploadJob.upload( + stream, + using: { data in + // TODO: Upgrade this to use the non-legacy version + return OpenGroupAPIV2.legacyUpload(data, to: v2OpenGroup.room, on: v2OpenGroup.server) + }, + encrypt: false, + onSuccess: handleSuccess, + onFailure: handleFailure + ) } else { AttachmentUploadJob.upload(stream, using: FileServerAPIV2.upload, encrypt: true, onSuccess: handleSuccess, onFailure: handleFailure) } diff --git a/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift b/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift index ddbeeb7ec..05101987c 100644 --- a/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift +++ b/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift @@ -3,6 +3,7 @@ import Foundation import PromiseKit import SessionUtilitiesKit +import SessionSnodeKit extension OpenGroupAPIV2 { // MARK: - BatchSubRequest @@ -66,10 +67,11 @@ public extension Decodable { } } -extension Promise where T == Data { +extension Promise where T == (OnionRequestAPI.ResponseInfo, Data?) { func decoded(as types: OpenGroupAPIV2.BatchResponseTypes, on queue: DispatchQueue? = nil, error: Error? = nil) -> Promise { - self.map(on: queue) { data -> OpenGroupAPIV2.BatchResponse in + self.map(on: queue) { responseInfo, maybeData -> OpenGroupAPIV2.BatchResponse in // Need to split the data into an array of data so each item can be Decoded correctly + guard let data: Data = maybeData else { throw OpenGroupAPIV2.Error.parsingFailed } guard let jsonObject: Any = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) else { throw OpenGroupAPIV2.Error.parsingFailed } diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPIV2+ObjC.swift b/SessionMessagingKit/Open Groups/OpenGroupAPIV2+ObjC.swift index 6eb84e145..8c7b7420e 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPIV2+ObjC.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPIV2+ObjC.swift @@ -4,7 +4,8 @@ extension OpenGroupAPIV2 { @objc(deleteMessageWithServerID:fromRoom:onServer:) public static func objc_deleteMessage(with serverID: Int64, from room: String, on server: String) -> AnyPromise { - return AnyPromise.from(deleteMessage(with: serverID, from: room, on: server)) + // TODO: Upgrade this to use the non-legacy version + return AnyPromise.from(legacyDeleteMessage(with: serverID, from: room, on: server)) } @objc(isUserModerator:forRoom:onServer:) diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift b/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift index 6741050ca..a4cf3231b 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift @@ -156,7 +156,9 @@ public final class OpenGroupAPIV2: NSObject { ) return send(request) - .then(on: OpenGroupAPIV2.workQueue) { data -> Promise in + .then(on: OpenGroupAPIV2.workQueue) { _, maybeData -> Promise in + guard let data: Data = maybeData else { throw Error.parsingFailed } + let response: LegacyCompactPollResponse = try data.decoded(as: LegacyCompactPollResponse.self, customError: Error.parsingFailed) return when( @@ -241,7 +243,7 @@ public final class OpenGroupAPIV2: NSObject { // MARK: - Capabilities - public static func capabilities(on server: String) -> Promise { + public static func capabilities(on server: String) -> Promise<(OnionRequestAPI.ResponseInfo, Capabilities)> { let request: Request = Request( server: server, endpoint: .capabilities, @@ -255,7 +257,7 @@ public final class OpenGroupAPIV2: NSObject { // MARK: - Room - public static func rooms(for server: String) -> Promise<[Room]> { + public static func rooms(for server: String) -> Promise<(OnionRequestAPI.ResponseInfo, [Room])> { let request: Request = Request( server: server, endpoint: .rooms @@ -265,7 +267,7 @@ public final class OpenGroupAPIV2: NSObject { .decoded(as: [Room].self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) } - public static func room(for roomToken: String, on server: String) -> Promise { + public static func room(for roomToken: String, on server: String) -> Promise<(OnionRequestAPI.ResponseInfo, Room)> { let request: Request = Request( server: server, endpoint: .room(roomToken) @@ -275,7 +277,7 @@ public final class OpenGroupAPIV2: NSObject { .decoded(as: Room.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) } - public static func roomPollInfo(lastUpdated: Int64, for roomToken: String, on server: String) -> Promise { + public static func roomPollInfo(lastUpdated: Int64, for roomToken: String, on server: String) -> Promise<(OnionRequestAPI.ResponseInfo, RoomPollInfo)> { let request: Request = Request( server: server, endpoint: .roomPollInfo(roomToken, lastUpdated) @@ -294,7 +296,7 @@ public final class OpenGroupAPIV2: NSObject { whisperTo: String?, whisperMods: Bool, with serverPublicKey: String - ) -> Promise { + ) -> Promise<(OnionRequestAPI.ResponseInfo, Message)> { // TODO: Change this to use '.blinded' once it's working guard let signedRequest: (data: Data, signature: Data) = SendMessageRequest.sign(message: plaintext, for: .standard, with: serverPublicKey) else { return Promise(error: Error.signingFailed) @@ -322,9 +324,8 @@ public final class OpenGroupAPIV2: NSObject { return send(request) .decoded(as: Message.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) } - - - public static func recentMessages(in roomToken: String, on server: String) -> Promise<[Message]> { + + public static func recentMessages(in roomToken: String, on server: String) -> Promise<(OnionRequestAPI.ResponseInfo, [Message])> { // TODO: Recent vs. Since? let request: Request = Request( server: server, @@ -337,12 +338,13 @@ public final class OpenGroupAPIV2: NSObject { return send(request) .decoded(as: [Message].self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) - .then(on: OpenGroupAPIV2.workQueue) { messages -> Promise<[Message]> in + .then(on: OpenGroupAPIV2.workQueue) { responseInfo, messages -> Promise<(OnionRequestAPI.ResponseInfo, [Message])> in process(messages: messages, for: roomToken, on: server) + .map { processedMessages in (responseInfo, processedMessages) } } } - public static func messagesBefore(messageId: Int64, in roomToken: String, on server: String) -> Promise<[Message]> { + public static func messagesBefore(messageId: Int64, in roomToken: String, on server: String) -> Promise<(OnionRequestAPI.ResponseInfo, [Message])> { // TODO: Recent vs. Since? let request: Request = Request( server: server, @@ -355,12 +357,13 @@ public final class OpenGroupAPIV2: NSObject { return send(request) .decoded(as: [Message].self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) - .then(on: OpenGroupAPIV2.workQueue) { messages -> Promise<[Message]> in + .then(on: OpenGroupAPIV2.workQueue) { responseInfo, messages -> Promise<(OnionRequestAPI.ResponseInfo, [Message])> in process(messages: messages, for: roomToken, on: server) + .map { processedMessages in (responseInfo, processedMessages) } } } - public static func messagesSince(seqNo: Int64, in roomToken: String, on server: String) -> Promise<[Message]> { + public static func messagesSince(seqNo: Int64, in roomToken: String, on server: String) -> Promise<(OnionRequestAPI.ResponseInfo, [Message])> { // TODO: Recent vs. Since? let request: Request = Request( server: server, @@ -373,11 +376,47 @@ public final class OpenGroupAPIV2: NSObject { return send(request) .decoded(as: [Message].self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) - .then(on: OpenGroupAPIV2.workQueue) { messages -> Promise<[Message]> in + .then(on: OpenGroupAPIV2.workQueue) { responseInfo, messages -> Promise<(OnionRequestAPI.ResponseInfo, [Message])> in process(messages: messages, for: roomToken, on: server) + .map { processedMessages in (responseInfo, processedMessages) } } } + // MARK: - Pinning + + public static func pinMessage(id: Int64, in roomToken: String, on server: String) -> Promise { + let request: Request = Request( + method: .post, + server: server, + endpoint: .roomPinMessage(roomToken, id: id) + ) + + return send(request) + .map { responseInfo, _ in responseInfo } + } + + public static func unpinMessage(id: Int64, in roomToken: String, on server: String) -> Promise { + let request: Request = Request( + method: .post, + server: server, + endpoint: .roomUnpinMessage(roomToken, id: id) + ) + + return send(request) + .map { responseInfo, _ in responseInfo } + } + + public static func unpinAll(in roomToken: String, on server: String) -> Promise { + let request: Request = Request( + method: .post, + server: server, + endpoint: .roomUnpinAll(roomToken) + ) + + return send(request) + .map { responseInfo, _ in responseInfo } + } + // MARK: - Files // TODO: Shift this logic to the `OpenGroupManager` (makes more sense since it's not API logic) @@ -405,6 +444,7 @@ public final class OpenGroupAPIV2: NSObject { } let promise: Promise = downloadFile(fileId, from: roomToken, on: server) + .map { _, data in data } _ = promise.done(on: OpenGroupAPIV2.workQueue) { imageData in if server == defaultServer { Storage.shared.write { transaction in @@ -418,7 +458,7 @@ public final class OpenGroupAPIV2: NSObject { return promise } - public static func uploadFile(_ bytes: [UInt8], fileName: String? = nil, to roomToken: String, on server: String) -> Promise { + public static func uploadFile(_ bytes: [UInt8], fileName: String? = nil, to roomToken: String, on server: String) -> Promise<(OnionRequestAPI.ResponseInfo, FileUploadResponse)> { let request: Request = Request( method: .post, server: server, @@ -433,7 +473,7 @@ public final class OpenGroupAPIV2: NSObject { /// Warning: This approach is less efficient as it expects the data to be base64Encoded (with is 33% larger than binary), please use the binary approach /// whenever possible - public static func uploadFile(_ base64EncodedString: String, fileName: String? = nil, to roomToken: String, on server: String) -> Promise { + public static func uploadFile(_ base64EncodedString: String, fileName: String? = nil, to roomToken: String, on server: String) -> Promise<(OnionRequestAPI.ResponseInfo, FileUploadResponse)> { let request: Request = Request( method: .post, server: server, @@ -446,21 +486,26 @@ public final class OpenGroupAPIV2: NSObject { .decoded(as: FileUploadResponse.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) } - public static func downloadFile(_ fileId: Int64, from roomToken: String, on server: String) -> Promise { + public static func downloadFile(_ fileId: Int64, from roomToken: String, on server: String) -> Promise<(OnionRequestAPI.ResponseInfo, Data)> { let request: Request = Request( server: server, endpoint: .roomFileIndividual(roomToken, fileId) ) return send(request) + .map { responseInfo, maybeData in + guard let data: Data = maybeData else { throw Error.parsingFailed } + + return (responseInfo, data) + } } - public static func downloadFileJson(_ fileId: Int64, from roomToken: String, on server: String) -> Promise { + public static func downloadFileJson(_ fileId: Int64, from roomToken: String, on server: String) -> Promise<(OnionRequestAPI.ResponseInfo, FileDownloadResponse)> { let request: Request = Request( server: server, endpoint: .roomFileIndividualJson(roomToken, fileId) ) - + // TODO: This endpoint is getting rewritten to return just data (properties would come through as headers) return send(request) .decoded(as: FileDownloadResponse.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) } @@ -532,6 +577,7 @@ public final class OpenGroupAPIV2: NSObject { completion: { let promise = attempt(maxRetryCount: 8, recoveringOn: DispatchQueue.main) { OpenGroupAPIV2.rooms(for: defaultServer) + .map { _, data in data } } _ = promise.done(on: OpenGroupAPIV2.workQueue) { items in items @@ -555,7 +601,7 @@ public final class OpenGroupAPIV2: NSObject { // MARK: - Convenience - private static func send(_ request: Request) -> Promise { + private static func send(_ request: Request, through api: OnionRequestAPIType.Type = OnionRequestAPI.self) -> Promise<(OnionRequestAPI.ResponseInfo, Data?)> { guard let url: URL = request.url else { return Promise(error: Error.invalidURL) } var urlRequest: URLRequest = URLRequest(url: url) @@ -571,39 +617,6 @@ public final class OpenGroupAPIV2: NSObject { } if request.isAuthRequired { - // Determine if we should be using legacy auth for this endpoint - // TODO: Might need to store this at an OpenGroup level (so all requests can use the appropriate method). - if request.endpoint.useLegacyAuth { - // Because legacy auth happens on a per-room basis, we need to have a room to - // make an authenticated request - guard let room = request.room else { - return OnionRequestAPI.sendOnionRequest(urlRequest, to: request.server, using: publicKey) - } - - return legacyGetAuthToken(for: room, on: request.server) - .then(on: OpenGroupAPIV2.workQueue) { authToken -> Promise in - urlRequest.setValue(authToken, forHTTPHeaderField: Header.authorization.rawValue) - - let promise = OnionRequestAPI.sendOnionRequest(urlRequest, to: request.server, using: publicKey) - promise.catch(on: OpenGroupAPIV2.workQueue) { error in - // A 401 means that we didn't provide a (valid) auth token for a route - // that required one. We use this as an indication that the token we're - // using has expired. Note that a 403 has a different meaning; it means - // that we provided a valid token but it doesn't have a high enough - // permission level for the route in question. - if case OnionRequestAPI.Error.httpRequestFailedAtDestination(let statusCode, _, _) = error, statusCode == 401 { - let storage = SNMessagingKitConfiguration.shared.storage - - storage.writeSync { transaction in - storage.removeAuthToken(for: room, on: request.server, using: transaction) - } - } - } - - return promise - } - } - // Attempt to sign the request with the new auth guard let signedRequest: URLRequest = sign(urlRequest, with: publicKey) else { return Promise(error: Error.signingFailed) @@ -679,7 +692,8 @@ public final class OpenGroupAPIV2: NSObject { isAuthRequired: false ) - return send(request).map(on: OpenGroupAPIV2.workQueue) { data in + return legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _, maybeData in + guard let data: Data = maybeData else { throw Error.parsingFailed } let response = try data.decoded(as: AuthTokenResponse.self, customError: Error.parsingFailed) let symmetricKey = try AESGCM.generateSymmetricKey(x25519PublicKey: response.challenge.ephemeralPublicKey, x25519PrivateKey: userKeyPair.privateKey) @@ -711,7 +725,7 @@ public final class OpenGroupAPIV2: NSObject { isAuthRequired: false ) - return send(request).map(on: OpenGroupAPIV2.workQueue) { _ in authToken } + return legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _ in authToken } } /// Should be called when leaving a group. @@ -723,7 +737,7 @@ public final class OpenGroupAPIV2: NSObject { endpoint: .legacyAuthToken(legacyAuth: true) ) - return send(request).map(on: OpenGroupAPIV2.workQueue) { _ in + return legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _ in let storage = SNMessagingKitConfiguration.shared.storage storage.write { transaction in @@ -796,8 +810,9 @@ public final class OpenGroupAPIV2: NSObject { isAuthRequired: false ) - return send(request) - .then(on: OpenGroupAPIV2.workQueue) { data -> Promise in + return legacySend(request) + .then(on: OpenGroupAPIV2.workQueue) { _, maybeData -> Promise in + guard let data: Data = maybeData else { throw Error.parsingFailed } let response: LegacyCompactPollResponse = try data.decoded(as: LegacyCompactPollResponse.self, customError: Error.parsingFailed) return when( @@ -853,8 +868,9 @@ public final class OpenGroupAPIV2: NSObject { isAuthRequired: false ) - return send(request) - .map(on: OpenGroupAPIV2.workQueue) { data in + return legacySend(request) + .map(on: OpenGroupAPIV2.workQueue) { _, maybeData in + guard let data: Data = maybeData else { throw Error.parsingFailed } let response: LegacyRoomsResponse = try data.decoded(as: LegacyRoomsResponse.self, customError: Error.parsingFailed) return response.rooms @@ -869,8 +885,9 @@ public final class OpenGroupAPIV2: NSObject { isAuthRequired: false ) - return send(request) - .map(on: OpenGroupAPIV2.workQueue) { data in + return legacySend(request) + .map(on: OpenGroupAPIV2.workQueue) { _, maybeData in + guard let data: Data = maybeData else { throw Error.parsingFailed } let response: LegacyGetInfoResponse = try data.decoded(as: LegacyGetInfoResponse.self, customError: Error.parsingFailed) return response.room @@ -907,7 +924,8 @@ public final class OpenGroupAPIV2: NSObject { isAuthRequired: false ) - let promise: Promise = send(request).map(on: OpenGroupAPIV2.workQueue) { data in + let promise: Promise = legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _, maybeData in + guard let data: Data = maybeData else { throw Error.parsingFailed } let response: LegacyFileDownloadResponse = try data.decoded(as: LegacyFileDownloadResponse.self, customError: Error.parsingFailed) if server == defaultServer { @@ -931,8 +949,9 @@ public final class OpenGroupAPIV2: NSObject { endpoint: .legacyMemberCount(legacyAuth: true) ) - return send(request) - .map(on: OpenGroupAPIV2.workQueue) { data in + return legacySend(request) + .map(on: OpenGroupAPIV2.workQueue) { _, maybeData in + guard let data: Data = maybeData else { throw Error.parsingFailed } let response: MemberCountResponse = try data.decoded(as: MemberCountResponse.self, customError: Error.parsingFailed) let storage = SNMessagingKitConfiguration.shared.storage @@ -946,7 +965,7 @@ public final class OpenGroupAPIV2: NSObject { // MARK: - Legacy File Storage - public static func upload(_ file: Data, to room: String, on server: String) -> Promise { + public static func legacyUpload(_ file: Data, to room: String, on server: String) -> Promise { let requestBody: FileUploadBody = FileUploadBody(file: file.base64EncodedString()) guard let body: Data = try? JSONEncoder().encode(requestBody) else { @@ -955,17 +974,19 @@ public final class OpenGroupAPIV2: NSObject { let request = Request(method: .post, server: server, room: room, endpoint: .legacyFiles, body: body) - return send(request).map(on: OpenGroupAPIV2.workQueue) { data in + return legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _, maybeData in + guard let data: Data = maybeData else { throw Error.parsingFailed } let response: LegacyFileUploadResponse = try data.decoded(as: LegacyFileUploadResponse.self, customError: Error.parsingFailed) return response.fileId } } - public static func download(_ file: UInt64, from room: String, on server: String) -> Promise { + public static func legacyDownload(_ file: UInt64, from room: String, on server: String) -> Promise { let request = Request(server: server, room: room, endpoint: .legacyFile(file)) - return send(request).map(on: OpenGroupAPIV2.workQueue) { data in + return legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _, maybeData in + guard let data: Data = maybeData else { throw Error.parsingFailed } let response: LegacyFileDownloadResponse = try data.decoded(as: LegacyFileDownloadResponse.self, customError: Error.parsingFailed) return response.data @@ -981,7 +1002,8 @@ public final class OpenGroupAPIV2: NSObject { } let request = Request(method: .post, server: server, room: room, endpoint: .legacyMessages, body: body) - return send(request).map(on: OpenGroupAPIV2.workQueue) { data in + return legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _, maybeData in + guard let data: Data = maybeData else { throw Error.parsingFailed } let message: OpenGroupMessageV2 = try data.decoded(as: OpenGroupMessageV2.self, customError: Error.parsingFailed) Storage.shared.write { transaction in Storage.shared.addReceivedMessageTimestamp(message.sentTimestamp, using: transaction) @@ -1001,7 +1023,8 @@ public final class OpenGroupAPIV2: NSObject { ].compactMapValues { $0 } ) - return send(request).then(on: OpenGroupAPIV2.workQueue) { data -> Promise<[OpenGroupMessageV2]> in + return legacySend(request).then(on: OpenGroupAPIV2.workQueue) { _, maybeData -> Promise<[OpenGroupMessageV2]> in + guard let data: Data = maybeData else { throw Error.parsingFailed } let messages: [OpenGroupMessageV2] = try data.decoded(as: [OpenGroupMessageV2].self, customError: Error.parsingFailed) return legacyProcess(messages: messages, for: room, on: server) @@ -1010,7 +1033,7 @@ public final class OpenGroupAPIV2: NSObject { // MARK: - Legacy Message Deletion - public static func deleteMessage(with serverID: Int64, from room: String, on server: String) -> Promise { + public static func legacyDeleteMessage(with serverID: Int64, from room: String, on server: String) -> Promise { let request: Request = Request( method: .delete, server: server, @@ -1018,10 +1041,10 @@ public final class OpenGroupAPIV2: NSObject { endpoint: .legacyMessagesForServer(serverID) ) - return send(request).map(on: OpenGroupAPIV2.workQueue) { _ in } + return legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _ in } } - public static func getDeletedMessages(for room: String, on server: String) -> Promise<[Deletion]> { + public static func legacyGetDeletedMessages(for room: String, on server: String) -> Promise<[Deletion]> { let storage = SNMessagingKitConfiguration.shared.storage let request: Request = Request( @@ -1033,7 +1056,8 @@ public final class OpenGroupAPIV2: NSObject { ].compactMapValues { $0 } ) - return send(request).then(on: OpenGroupAPIV2.workQueue) { data -> Promise<[Deletion]> in + return legacySend(request).then(on: OpenGroupAPIV2.workQueue) { _, maybeData -> Promise<[Deletion]> in + guard let data: Data = maybeData else { throw Error.parsingFailed } let response: DeletedMessagesResponse = try data.decoded(as: DeletedMessagesResponse.self, customError: Error.parsingFailed) return process(deletions: response.deletions, for: room, on: server) @@ -1042,15 +1066,16 @@ public final class OpenGroupAPIV2: NSObject { // MARK: - Legacy Moderation - public static func getModerators(for room: String, on server: String) -> Promise<[String]> { + public static func legacyGetModerators(for room: String, on server: String) -> Promise<[String]> { let request: Request = Request( server: server, room: room, endpoint: .legacyModerators ) - return send(request) - .map(on: OpenGroupAPIV2.workQueue) { data in + return legacySend(request) + .map(on: OpenGroupAPIV2.workQueue) { _, maybeData in + guard let data: Data = maybeData else { throw Error.parsingFailed } let response: ModeratorsResponse = try data.decoded(as: ModeratorsResponse.self, customError: Error.parsingFailed) if var x = self.moderators[server] { @@ -1065,7 +1090,7 @@ public final class OpenGroupAPIV2: NSObject { } } - public static func ban(_ publicKey: String, from room: String, on server: String) -> Promise { + public static func legacyBan(_ publicKey: String, from room: String, on server: String) -> Promise { let requestBody: PublicKeyBody = PublicKeyBody(publicKey: getUserHexEncodedPublicKey()) guard let body: Data = try? JSONEncoder().encode(requestBody) else { @@ -1080,10 +1105,10 @@ public final class OpenGroupAPIV2: NSObject { body: body ) - return send(request).map(on: OpenGroupAPIV2.workQueue) { _ in } + return legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _ in } } - public static func banAndDeleteAllMessages(_ publicKey: String, from room: String, on server: String) -> Promise { + public static func legacyBanAndDeleteAllMessages(_ publicKey: String, from room: String, on server: String) -> Promise { let requestBody: PublicKeyBody = PublicKeyBody(publicKey: getUserHexEncodedPublicKey()) guard let body: Data = try? JSONEncoder().encode(requestBody) else { @@ -1098,10 +1123,10 @@ public final class OpenGroupAPIV2: NSObject { body: body ) - return send(request).map(on: OpenGroupAPIV2.workQueue) { _ in } + return legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _ in } } - public static func unban(_ publicKey: String, from room: String, on server: String) -> Promise { + public static func legacyUnban(_ publicKey: String, from room: String, on server: String) -> Promise { let request: Request = Request( method: .delete, server: server, @@ -1109,7 +1134,7 @@ public final class OpenGroupAPIV2: NSObject { endpoint: .legacyBlockListIndividual(publicKey) ) - return send(request).map(on: OpenGroupAPIV2.workQueue) { _ in } + return legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _ in } } // MARK: - Processing @@ -1164,4 +1189,58 @@ public final class OpenGroupAPIV2: NSObject { return Promise.value(deletions) } + + // MARK: - Legacy Convenience + + private static func legacySend(_ request: Request, through api: OnionRequestAPIType.Type = LegacyOnionRequestAPI.self) -> Promise<(OnionRequestAPI.ResponseInfo, Data?)> { + guard let url: URL = request.url else { return Promise(error: Error.invalidURL) } + + var urlRequest: URLRequest = URLRequest(url: url) + urlRequest.httpMethod = request.method.rawValue + urlRequest.allHTTPHeaderFields = request.headers + .setting(.room, request.room) // TODO: Is this needed anymore? Add at the request level?. + .toHTTPHeaders() + urlRequest.httpBody = request.body + + if request.useOnionRouting { + guard let publicKey = SNMessagingKitConfiguration.shared.storage.getOpenGroupPublicKey(for: request.server) else { + return Promise(error: Error.noPublicKey) + } + + if request.isAuthRequired { + // Because legacy auth happens on a per-room basis, we need to have a room to + // make an authenticated request + guard let room = request.room else { + return api.sendOnionRequest(urlRequest, to: request.server, target: "/loki/v3/lsrpc", using: publicKey) + } + + return legacyGetAuthToken(for: room, on: request.server) + .then(on: OpenGroupAPIV2.workQueue) { authToken -> Promise<(OnionRequestAPI.ResponseInfo, Data?)> in + urlRequest.setValue(authToken, forHTTPHeaderField: Header.authorization.rawValue) + + let promise = api.sendOnionRequest(urlRequest, to: request.server, target: "/loki/v3/lsrpc", using: publicKey) + promise.catch(on: OpenGroupAPIV2.workQueue) { error in + // A 401 means that we didn't provide a (valid) auth token for a route + // that required one. We use this as an indication that the token we're + // using has expired. Note that a 403 has a different meaning; it means + // that we provided a valid token but it doesn't have a high enough + // permission level for the route in question. + if case OnionRequestAPI.Error.httpRequestFailedAtDestination(let statusCode, _, _) = error, statusCode == 401 { + let storage = SNMessagingKitConfiguration.shared.storage + + storage.writeSync { transaction in + storage.removeAuthToken(for: room, on: request.server, using: transaction) + } + } + } + + return promise + } + } + + return api.sendOnionRequest(urlRequest, to: request.server, target: "/loki/v3/lsrpc", using: publicKey) + } + + preconditionFailure("It's currently not allowed to send non onion routed requests.") + } } diff --git a/SessionMessagingKit/Open Groups/OpenGroupManagerV2.swift b/SessionMessagingKit/Open Groups/OpenGroupManagerV2.swift index 6ce29d141..c29191ae1 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManagerV2.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManagerV2.swift @@ -79,7 +79,7 @@ public final class OpenGroupManagerV2 : NSObject { // } OpenGroupAPIV2.room(for: room, on: server) - .done(on: DispatchQueue.global(qos: .userInitiated)) { room in + .done(on: DispatchQueue.global(qos: .userInitiated)) { _, room in // Create the open group model and the thread let openGroup: OpenGroupV2 = OpenGroupV2( server: server, diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 7289420aa..bd2d2fcc3 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -216,6 +216,7 @@ public final class MessageSender : NSObject { handleFailure(with: error, using: transaction) return promise } + // Send the result let base64EncodedData = wrappedMessage.base64EncodedString() let timestamp = UInt64(Int64(message.sentTimestamp!) + SnodeAPI.clockOffset) @@ -280,6 +281,7 @@ public final class MessageSender : NSObject { let (promise, seal) = Promise.pending() let storage = SNMessagingKitConfiguration.shared.storage let transaction = transaction as! YapDatabaseReadWriteTransaction + // Set the timestamp, sender and recipient if message.sentTimestamp == nil { // Visible messages will already have their sent timestamp set message.sentTimestamp = NSDate.millisecondTimestamp() @@ -371,37 +373,30 @@ public final class MessageSender : NSObject { preconditionFailure() } -// let openGroupMessage = OpenGroupMessageV2(serverID: nil, sender: nil, sentTimestamp: message.sentTimestamp!, -// base64EncodedData: plaintext.base64EncodedString(), base64EncodedSignature: nil) + OpenGroupAPIV2 + .send( + plaintext, + to: room, + on: server, + whisperTo: whisperTo, + whisperMods: whisperMods, + with: openGroupV2.publicKey + ) + .done(on: DispatchQueue.global(qos: .userInitiated)) { responseInfo, data in + message.openGroupServerMessageID = given(data.seqNo) { UInt64($0) } - //OpenGroupAPIV2.send(openGroupMessage, to: room, on: server, with: openGroupV2.publicKey) - return promise // TODO: Remove!!! -// OpenGroupAPIV2 -// .send( -// plaintext, -// to: room, -// on: server, -// whisperTo: whisperTo, -// whisperMods: whisperMods, -// with: openGroupV2.publicKey -// ) -// .done(on: DispatchQueue.global(qos: .userInitiated)) { response in -// print("RAWR") -//// message.openGroupServerMessageID = given(response.serverID) { UInt64($0) } -//// storage.write(with: { transaction in -//// Storage.shared.addReceivedMessageTimestamp(message.sentTimestamp, using: transaction) -//// -//// MessageSender.handleSuccessfulMessageSend(message, to: destination, serverTimestamp: response.sentTimestamp, using: transaction) -//// seal.fulfill(()) -//// }, completion: { }) -// } -// .catch(on: DispatchQueue.global(qos: .userInitiated)) { error in -// storage.write(with: { transaction in -// handleFailure(with: error, using: transaction as! YapDatabaseReadWriteTransaction) -// }, completion: { }) -// } -// // Return -// return promise + Storage.shared.write { transaction in + MessageSender.handleSuccessfulMessageSend(message, to: destination, serverTimestamp: UInt64(floor(data.posted)), using: transaction) + seal.fulfill(()) + } + } + .catch(on: DispatchQueue.global(qos: .userInitiated)) { error in + storage.write(with: { transaction in + handleFailure(with: error, using: transaction as! YapDatabaseReadWriteTransaction) + }, completion: { }) + } + + return promise } // MARK: Success & Failure Handling diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift index a9f5b8d02..d434bbd56 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift @@ -51,14 +51,17 @@ public final class PushNotificationAPI : NSObject { request.httpBody = body let promise: Promise = attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) { - OnionRequestAPI.sendOnionRequest(request, to: server, target: "/loki/v2/lsrpc", using: serverPublicKey).map2 { response in - guard let response: UnregisterResponse = try? response.decoded(as: UnregisterResponse.self) else { - return SNLog("Couldn't unregister from push notifications.") + // TODO: Update this to use the V4 union requests once supported + LegacyOnionRequestAPI.sendOnionRequest(request, to: server, target: "/loki/v2/lsrpc", using: serverPublicKey) + .map2 { json in try JSONSerialization.data(withJSONObject: json, options: []) } + .map2 { response in + guard let response: UnregisterResponse = try? response.decoded(as: UnregisterResponse.self) else { + return SNLog("Couldn't unregister from push notifications.") + } + guard response.code != 0 else { + return SNLog("Couldn't unregister from push notifications due to error: \(response.message ?? "nil").") + } } - guard response.code != 0 else { - return SNLog("Couldn't unregister from push notifications due to error: \(response.message ?? "nil").") - } - } } promise.catch2 { error in SNLog("Couldn't unregister from push notifications.") @@ -99,18 +102,21 @@ public final class PushNotificationAPI : NSObject { request.httpBody = body let promise: Promise = attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) { - OnionRequestAPI.sendOnionRequest(request, to: server, target: "/loki/v2/lsrpc", using: serverPublicKey).map2 { response in - guard let response: RegisterResponse = try? response.decoded(as: RegisterResponse.self) else { - return SNLog("Couldn't register device token.") + // TODO: Update this to use the V4 union requests once supported + LegacyOnionRequestAPI.sendOnionRequest(request, to: server, target: "/loki/v2/lsrpc", using: serverPublicKey) + .map2 { json in try JSONSerialization.data(withJSONObject: json, options: []) } + .map2 { response in + guard let response: RegisterResponse = try? response.decoded(as: RegisterResponse.self) else { + return SNLog("Couldn't register device token.") + } + guard response.code != 0 else { + return SNLog("Couldn't register device token due to error: \(response.message ?? "nil").") + } + + userDefaults[.deviceToken] = hexEncodedToken + userDefaults[.lastDeviceTokenUpload] = now + userDefaults[.isUsingFullAPNs] = true } - guard response.code != 0 else { - return SNLog("Couldn't register device token due to error: \(response.message ?? "nil").") - } - - userDefaults[.deviceToken] = hexEncodedToken - userDefaults[.lastDeviceTokenUpload] = now - userDefaults[.isUsingFullAPNs] = true - } } promise.catch2 { error in SNLog("Couldn't register device token.") @@ -144,14 +150,17 @@ public final class PushNotificationAPI : NSObject { request.httpBody = body let promise: Promise = attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) { - OnionRequestAPI.sendOnionRequest(request, to: server, target: "/loki/v2/lsrpc", using: serverPublicKey).map2 { response in - guard let response: RegisterResponse = try? response.decoded(as: RegisterResponse.self) else { - return SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey).") + // TODO: Update this to use the V4 union requests once supported + LegacyOnionRequestAPI.sendOnionRequest(request, to: server, target: "/loki/v2/lsrpc", using: serverPublicKey) + .map2 { json in try JSONSerialization.data(withJSONObject: json, options: []) } + .map2 { response in + guard let response: RegisterResponse = try? response.decoded(as: RegisterResponse.self) else { + return SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey).") + } + guard response.code != 0 else { + return SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey) due to error: \(response.message ?? "nil").") + } } - guard response.code != 0 else { - return SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey) due to error: \(response.message ?? "nil").") - } - } } promise.catch2 { error in SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey).") diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift index 549a8dda0..b5a53d4fe 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift @@ -47,25 +47,22 @@ public final class OpenGroupPollerV2 : NSObject { let (promise, seal) = Promise.pending() promise.retainUntilComplete() - // TODO: Update to use the non-legacy version -// OpenGroupAPIV2.compactPoll(server) - OpenGroupAPIV2.legacyCompactPoll(server) - .done(on: OpenGroupAPIV2.workQueue) { [weak self] response in - guard let self = self else { return } - self.isPolling = false - response.results.forEach { self.handleCompactPollBody($0, isBackgroundPoll: isBackgroundPoll) } + OpenGroupAPIV2.poll(server) + .done(on: OpenGroupAPIV2.workQueue) { [weak self] _ in + self?.isPolling = false + // TODO: Handle response seal.fulfill(()) } - .catch(on: OpenGroupAPIV2.workQueue) { error in + .catch(on: OpenGroupAPIV2.workQueue) { [weak self] error in SNLog("Open group polling failed due to error: \(error).") - self.isPolling = false + self?.isPolling = false seal.fulfill(()) // The promise is just used to keep track of when we're done } return promise } - private func handleCompactPollBody(_ body: OpenGroupAPIV2.CompactPollResponse.Result, isBackgroundPoll: Bool) { + private func handleCompactPollBody(_ body: OpenGroupAPIV2.LegacyCompactPollResponse.Result, isBackgroundPoll: Bool) { let storage = SNMessagingKitConfiguration.shared.storage // - Messages // Sorting the messages by server ID before importing them fixes an issue where messages that quote older messages can't find those older messages diff --git a/SessionMessagingKit/Utilities/Promise+Utilities.swift b/SessionMessagingKit/Utilities/Promise+Utilities.swift index c75808c7a..e39b21082 100644 --- a/SessionMessagingKit/Utilities/Promise+Utilities.swift +++ b/SessionMessagingKit/Utilities/Promise+Utilities.swift @@ -2,6 +2,7 @@ import Foundation import PromiseKit +import SessionSnodeKit extension Promise where T == Data { func decoded(as type: R.Type, on queue: DispatchQueue? = nil, error: Error? = nil) -> Promise { @@ -10,3 +11,15 @@ extension Promise where T == Data { } } } + +extension Promise where T == (OnionRequestAPI.ResponseInfo, Data?) { + func decoded(as type: R.Type, on queue: DispatchQueue? = nil, error: Error? = nil) -> Promise<(OnionRequestAPI.ResponseInfo, R)> { + self.map(on: queue) { responseInfo, maybeData -> (OnionRequestAPI.ResponseInfo, R) in + guard let data: Data = maybeData else { + throw OpenGroupAPIV2.Error.parsingFailed + } + + return (responseInfo, try data.decoded(as: type, customError: error)) + } + } +} diff --git a/SessionSnodeKit/LegacyOnionRequestAPI.swift b/SessionSnodeKit/LegacyOnionRequestAPI.swift new file mode 100644 index 000000000..9f897e575 --- /dev/null +++ b/SessionSnodeKit/LegacyOnionRequestAPI.swift @@ -0,0 +1,455 @@ +import CryptoSwift +import PromiseKit +import SessionUtilitiesKit + +/// See the "Onion Requests" section of [The Session Whitepaper](https://arxiv.org/pdf/2002.04609.pdf) for more information. +public enum LegacyOnionRequestAPI: OnionRequestAPIType { + private static var buildPathsPromise: Promise<[Path]>? = nil + /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. + private static var pathFailureCount: [Path:UInt] = [:] + /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. + private static var snodeFailureCount: [Snode:UInt] = [:] + /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. + public static var guardSnodes: Set = [] + public static var paths: [Path] = [] // Not a set to ensure we consistently show the same path to the user + // MARK: Settings + public static let maxRequestSize = 10_000_000 // 10 MB + /// The number of snodes (including the guard snode) in a path. + private static let pathSize: UInt = 3 + /// The number of times a path can fail before it's replaced. + private static let pathFailureThreshold: UInt = 3 + /// The number of times a snode can fail before it's replaced. + private static let snodeFailureThreshold: UInt = 3 + /// The number of paths to maintain. + public static let targetPathCount: UInt = 2 + + /// The number of guard snodes required to maintain `targetPathCount` paths. + private static var targetGuardSnodeCount: UInt { return targetPathCount } // One per path + + // MARK: Error + public enum Error : LocalizedError { + case httpRequestFailedAtDestination(statusCode: UInt, json: JSON, destination: OnionRequestAPI.Destination) + case insufficientSnodes + case invalidURL + case missingSnodeVersion + case snodePublicKeySetMissing + case unsupportedSnodeVersion(String) + + public var errorDescription: String? { + switch self { + case .httpRequestFailedAtDestination(let statusCode, _, let destination): + if statusCode == 429 { + return "Rate limited." + } else { + return "HTTP request failed at destination (\(destination)) with status code: \(statusCode)." + } + case .insufficientSnodes: return "Couldn't find enough Service Nodes to build a path." + case .invalidURL: return "Invalid URL" + case .missingSnodeVersion: return "Missing Service Node version." + case .snodePublicKeySetMissing: return "Missing Service Node public key set." + case .unsupportedSnodeVersion(let version): return "Unsupported Service Node version: \(version)." + } + } + } + + // MARK: Path + public typealias Path = [Snode] + + // MARK: Onion Building Result + private typealias OnionBuildingResult = (guardSnode: Snode, finalEncryptionResult: AESGCM.EncryptionResult, destinationSymmetricKey: Data) + + // MARK: Private API + /// Tests the given snode. The returned promise errors out if the snode is faulty; the promise is fulfilled otherwise. + private static func testSnode(_ snode: Snode) -> Promise { + let (promise, seal) = Promise.pending() + DispatchQueue.global(qos: .userInitiated).async { + let url = "\(snode.address):\(snode.port)/get_stats/v1" + let timeout: TimeInterval = 3 // Use a shorter timeout for testing + HTTP.execute(.get, url, timeout: timeout).done2 { json in + guard let version = json["version"] as? String else { return seal.reject(Error.missingSnodeVersion) } + if version >= "2.0.7" { + seal.fulfill(()) + } else { + SNLog("Unsupported snode version: \(version).") + seal.reject(Error.unsupportedSnodeVersion(version)) + } + }.catch2 { error in + seal.reject(error) + } + } + return promise + } + + /// Finds `targetGuardSnodeCount` guard snodes to use for path building. The returned promise errors out with `Error.insufficientSnodes` + /// if not enough (reliable) snodes are available. + private static func getGuardSnodes(reusing reusableGuardSnodes: [Snode]) -> Promise> { + if guardSnodes.count >= targetGuardSnodeCount { + return Promise> { $0.fulfill(guardSnodes) } + } else { + SNLog("Populating guard snode cache.") + var unusedSnodes = SnodeAPI.snodePool.subtracting(reusableGuardSnodes) // Sync on LokiAPI.workQueue + let reusableGuardSnodeCount = UInt(reusableGuardSnodes.count) + guard unusedSnodes.count >= (targetGuardSnodeCount - reusableGuardSnodeCount) else { return Promise(error: Error.insufficientSnodes) } + func getGuardSnode() -> Promise { + // randomElement() uses the system's default random generator, which is cryptographically secure + guard let candidate = unusedSnodes.randomElement() else { return Promise { $0.reject(Error.insufficientSnodes) } } + unusedSnodes.remove(candidate) // All used snodes should be unique + SNLog("Testing guard snode: \(candidate).") + // Loop until a reliable guard snode is found + return testSnode(candidate).map2 { candidate }.recover(on: DispatchQueue.main) { _ in + withDelay(0.1, completionQueue: Threading.workQueue) { getGuardSnode() } + } + } + let promises = (0..<(targetGuardSnodeCount - reusableGuardSnodeCount)).map { _ in getGuardSnode() } + return when(fulfilled: promises).map2 { guardSnodes in + let guardSnodesAsSet = Set(guardSnodes + reusableGuardSnodes) + OnionRequestAPI.guardSnodes = guardSnodesAsSet + return guardSnodesAsSet + } + } + } + + /// Builds and returns `targetPathCount` paths. The returned promise errors out with `Error.insufficientSnodes` + /// if not enough (reliable) snodes are available. + @discardableResult + private static func buildPaths(reusing reusablePaths: [Path]) -> Promise<[Path]> { + if let existingBuildPathsPromise = buildPathsPromise { return existingBuildPathsPromise } + SNLog("Building onion request paths.") + DispatchQueue.main.async { + NotificationCenter.default.post(name: .buildingPaths, object: nil) + } + let reusableGuardSnodes = reusablePaths.map { $0[0] } + let promise: Promise<[Path]> = getGuardSnodes(reusing: reusableGuardSnodes).map2 { guardSnodes -> [Path] in + var unusedSnodes = SnodeAPI.snodePool.subtracting(guardSnodes).subtracting(reusablePaths.flatMap { $0 }) + let reusableGuardSnodeCount = UInt(reusableGuardSnodes.count) + let pathSnodeCount = (targetGuardSnodeCount - reusableGuardSnodeCount) * pathSize - (targetGuardSnodeCount - reusableGuardSnodeCount) + guard unusedSnodes.count >= pathSnodeCount else { throw Error.insufficientSnodes } + // Don't test path snodes as this would reveal the user's IP to them + return guardSnodes.subtracting(reusableGuardSnodes).map { guardSnode in + let result = [ guardSnode ] + (0..<(pathSize - 1)).map { _ in + // randomElement() uses the system's default random generator, which is cryptographically secure + let pathSnode = unusedSnodes.randomElement()! // Safe because of the pathSnodeCount check above + unusedSnodes.remove(pathSnode) // All used snodes should be unique + return pathSnode + } + SNLog("Built new onion request path: \(result.prettifiedDescription).") + return result + } + }.map2 { paths in + OnionRequestAPI.paths = paths + reusablePaths + SNSnodeKitConfiguration.shared.storage.writeSync { transaction in + SNLog("Persisting onion request paths to database.") + SNSnodeKitConfiguration.shared.storage.setOnionRequestPaths(to: paths, using: transaction) + } + DispatchQueue.main.async { + NotificationCenter.default.post(name: .pathsBuilt, object: nil) + } + return paths + } + promise.done2 { _ in buildPathsPromise = nil } + promise.catch2 { _ in buildPathsPromise = nil } + buildPathsPromise = promise + return promise + } + + /// Returns a `Path` to be used for building an onion request. Builds new paths as needed. + private static func getPath(excluding snode: Snode?) -> Promise { + guard pathSize >= 1 else { preconditionFailure("Can't build path of size zero.") } + var paths = OnionRequestAPI.paths + if paths.isEmpty { + paths = SNSnodeKitConfiguration.shared.storage.getOnionRequestPaths() + OnionRequestAPI.paths = paths + if !paths.isEmpty { + guardSnodes.formUnion([ paths[0][0] ]) + if paths.count >= 2 { + guardSnodes.formUnion([ paths[1][0] ]) + } + } + } + // randomElement() uses the system's default random generator, which is cryptographically secure + if paths.count >= targetPathCount { + if let snode = snode { + return Promise { $0.fulfill(paths.filter { !$0.contains(snode) }.randomElement()!) } + } else { + return Promise { $0.fulfill(paths.randomElement()!) } + } + } else if !paths.isEmpty { + if let snode = snode { + if let path = paths.first(where: { !$0.contains(snode) }) { + buildPaths(reusing: paths) // Re-build paths in the background + return Promise { $0.fulfill(path) } + } else { + return buildPaths(reusing: paths).map2 { paths in + return paths.filter { !$0.contains(snode) }.randomElement()! + } + } + } else { + buildPaths(reusing: paths) // Re-build paths in the background + return Promise { $0.fulfill(paths.randomElement()!) } + } + } else { + return buildPaths(reusing: []).map2 { paths in + if let snode = snode { + return paths.filter { !$0.contains(snode) }.randomElement()! + } else { + return paths.randomElement()! + } + } + } + } + + private static func dropGuardSnode(_ snode: Snode) { + #if DEBUG + dispatchPrecondition(condition: .onQueue(Threading.workQueue)) + #endif + guardSnodes = guardSnodes.filter { $0 != snode } + } + + private static func drop(_ snode: Snode) throws { + #if DEBUG + dispatchPrecondition(condition: .onQueue(Threading.workQueue)) + #endif + // We repair the path here because we can do it sync. In the case where we drop a whole + // path we leave the re-building up to getPath(excluding:) because re-building the path + // in that case is async. + LegacyOnionRequestAPI.snodeFailureCount[snode] = 0 + var oldPaths = paths + guard let pathIndex = oldPaths.firstIndex(where: { $0.contains(snode) }) else { return } + var path = oldPaths[pathIndex] + guard let snodeIndex = path.firstIndex(of: snode) else { return } + path.remove(at: snodeIndex) + let unusedSnodes = SnodeAPI.snodePool.subtracting(oldPaths.flatMap { $0 }) + guard !unusedSnodes.isEmpty else { throw Error.insufficientSnodes } + // randomElement() uses the system's default random generator, which is cryptographically secure + path.append(unusedSnodes.randomElement()!) + // Don't test the new snode as this would reveal the user's IP + oldPaths.remove(at: pathIndex) + let newPaths = oldPaths + [ path ] + paths = newPaths + SNSnodeKitConfiguration.shared.storage.writeSync { transaction in + SNLog("Persisting onion request paths to database.") + SNSnodeKitConfiguration.shared.storage.setOnionRequestPaths(to: newPaths, using: transaction) + } + } + + private static func drop(_ path: Path) { + #if DEBUG + dispatchPrecondition(condition: .onQueue(Threading.workQueue)) + #endif + LegacyOnionRequestAPI.pathFailureCount[path] = 0 + var paths = LegacyOnionRequestAPI.paths + guard let pathIndex = paths.firstIndex(of: path) else { return } + paths.remove(at: pathIndex) + OnionRequestAPI.paths = paths + SNSnodeKitConfiguration.shared.storage.writeSync { transaction in + if !paths.isEmpty { + SNLog("Persisting onion request paths to database.") + SNSnodeKitConfiguration.shared.storage.setOnionRequestPaths(to: paths, using: transaction) + } else { + SNLog("Clearing onion request paths.") + SNSnodeKitConfiguration.shared.storage.setOnionRequestPaths(to: [], using: transaction) + } + } + } + + /// Builds an onion around `payload` and returns the result. + private static func buildOnion(around payload: JSON, targetedAt destination: OnionRequestAPI.Destination) -> Promise { + var guardSnode: Snode! + var targetSnodeSymmetricKey: Data! // Needed by invoke(_:on:with:) to decrypt the response sent back by the destination + var encryptionResult: AESGCM.EncryptionResult! + var snodeToExclude: Snode? + if case .snode(let snode) = destination { snodeToExclude = snode } + return getPath(excluding: snodeToExclude).then2 { path -> Promise in + guardSnode = path.first! + // Encrypt in reverse order, i.e. the destination first + return OnionRequestAPI.encrypt(payload, for: destination).then2 { r -> Promise in + targetSnodeSymmetricKey = r.symmetricKey + // Recursively encrypt the layers of the onion (again in reverse order) + encryptionResult = r + var path = path + var rhs = destination + func addLayer() -> Promise { + if path.isEmpty { + return Promise { $0.fulfill(encryptionResult) } + } else { + let lhs = OnionRequestAPI.Destination.snode(path.removeLast()) + return OnionRequestAPI.encryptHop(from: lhs, to: rhs, using: encryptionResult).then2 { r -> Promise in + encryptionResult = r + rhs = lhs + return addLayer() + } + } + } + return addLayer() + } + }.map2 { _ in (guardSnode, encryptionResult, targetSnodeSymmetricKey) } + } + + // MARK: Public API + /// Sends an onion request to `snode`. Builds new paths as needed. + public static func sendOnionRequest(to snode: Snode, invoking method: Snode.Method, with parameters: JSON, associatedWith publicKey: String? = nil) -> Promise { + let payload: JSON = [ "method" : method.rawValue, "params" : parameters ] + return sendOnionRequest(with: payload, to: OnionRequestAPI.Destination.snode(snode)).recover2 { error -> Promise in + guard case OnionRequestAPI.Error.httpRequestFailedAtDestination(let statusCode, let json, _) = error else { throw error } + throw SnodeAPI.handleError(withStatusCode: statusCode, json: json, forSnode: snode, associatedWith: publicKey) ?? error + } + } + + /// Sends an onion request to `server`. Builds new paths as needed. + public static func sendOnionRequest(_ request: URLRequest, to server: String, target: String = "/loki/v3/lsrpc", using x25519PublicKey: String) -> Promise<(OnionRequestAPI.ResponseInfo, Data?)> { + var rawHeaders = request.allHTTPHeaderFields ?? [:] + rawHeaders.removeValue(forKey: "User-Agent") + var headers: JSON = rawHeaders.mapValues { value in + switch value.lowercased() { + case "true": return true + case "false": return false + default: return value + } + } + guard let url = request.url, let host = request.url?.host else { return Promise(error: Error.invalidURL) } + var endpoint = url.path.removingPrefix("/") + if let query = url.query { endpoint += "?\(query)" } + let scheme = url.scheme + let port = given(url.port) { UInt16($0) } + let bodyAsString: String + + if let body: Data = request.httpBody { + headers["Content-Type"] = "application/json" // Assume data is JSON + bodyAsString = (String(data: body, encoding: .utf8) ?? "null") + } + else if let inputStream: InputStream = request.httpBodyStream, let body: Data = try? Data(from: inputStream) { + headers["Content-Type"] = request.allHTTPHeaderFields!["Content-Type"] + bodyAsString = "{ \"fileUpload\" : \"\(String(data: body.base64EncodedData(), encoding: .utf8) ?? "null")\" }" + } + else { + bodyAsString = "null" + } + + let payload: JSON = [ + "body" : bodyAsString, + "endpoint" : endpoint, + "method" : request.httpMethod!, + "headers" : headers + ] + let destination = OnionRequestAPI.Destination.server(host: host, target: target, x25519PublicKey: x25519PublicKey, scheme: scheme, port: port) + let promise = sendOnionRequest(with: payload, to: destination) + .map { (json: JSON) -> (OnionRequestAPI.ResponseInfo, Data?) in + guard let data = try? JSONSerialization.data(withJSONObject: json, options: []) else { throw HTTP.Error.invalidJSON } + + return (OnionRequestAPI.ResponseInfo(code: 200, headers: [:]), data) + } + promise.catch2 { error in + SNLog("Couldn't reach server: \(url) due to error: \(error).") + } + return promise + } + + public static func sendOnionRequest(with payload: JSON, to destination: OnionRequestAPI.Destination) -> Promise { + let (promise, seal) = Promise.pending() + var guardSnode: Snode? + Threading.workQueue.async { // Avoid race conditions on `guardSnodes` and `paths` + buildOnion(around: payload, targetedAt: destination).done2 { intermediate in + guardSnode = intermediate.guardSnode + let url = "\(guardSnode!.address):\(guardSnode!.port)/onion_req/v2" + let finalEncryptionResult = intermediate.finalEncryptionResult + let onion = finalEncryptionResult.ciphertext + if case OnionRequestAPI.Destination.server = destination, Double(onion.count) > 0.75 * Double(maxRequestSize) { + SNLog("Approaching request size limit: ~\(onion.count) bytes.") + } + let parameters: JSON = [ + "ephemeral_key" : finalEncryptionResult.ephemeralPublicKey.toHexString() + ] + let body: Data + do { + body = try OnionRequestAPI.encode(ciphertext: onion, json: parameters) + } catch { + return seal.reject(error) + } + let destinationSymmetricKey = intermediate.destinationSymmetricKey + HTTP.execute(.post, url, body: body).done2 { json in + guard let base64EncodedIVAndCiphertext = json["result"] as? String, + let ivAndCiphertext = Data(base64Encoded: base64EncodedIVAndCiphertext), ivAndCiphertext.count >= AESGCM.ivSize else { return seal.reject(HTTP.Error.invalidJSON) } + do { + let data = try AESGCM.decrypt(ivAndCiphertext, with: destinationSymmetricKey) + guard let json = try JSONSerialization.jsonObject(with: data, options: [ .fragmentsAllowed ]) as? JSON, + let statusCode = json["status_code"] as? Int ?? json["status"] as? Int else { return seal.reject(HTTP.Error.invalidJSON) } + if statusCode == 406 { // Clock out of sync + SNLog("The user's clock is out of sync with the service node network.") + seal.reject(SnodeAPI.Error.clockOutOfSync) + } else if let bodyAsString = json["body"] as? String { + guard let bodyAsData = bodyAsString.data(using: .utf8), + let body = try JSONSerialization.jsonObject(with: bodyAsData, options: [ .fragmentsAllowed ]) as? JSON else { return seal.reject(HTTP.Error.invalidJSON) } + if let timestamp = body["t"] as? Int64 { + let offset = timestamp - Int64(NSDate.millisecondTimestamp()) + SnodeAPI.clockOffset = offset + } + guard 200...299 ~= statusCode else { + return seal.reject(Error.httpRequestFailedAtDestination(statusCode: UInt(statusCode), json: body, destination: destination)) + } + seal.fulfill(body) + } else { + guard 200...299 ~= statusCode else { + return seal.reject(Error.httpRequestFailedAtDestination(statusCode: UInt(statusCode), json: json, destination: destination)) + } + seal.fulfill(json) + } + } catch { + seal.reject(error) + } + }.catch2 { error in + seal.reject(error) + } + }.catch2 { error in + seal.reject(error) + } + } + promise.catch2 { error in // Must be invoked on Threading.workQueue + guard case HTTP.Error.httpRequestFailed(let statusCode, let json) = error, let guardSnode = guardSnode else { return } + let path = paths.first { $0.contains(guardSnode) } + func handleUnspecificError() { + guard let path = path else { return } + var pathFailureCount = LegacyOnionRequestAPI.pathFailureCount[path] ?? 0 + pathFailureCount += 1 + if pathFailureCount >= pathFailureThreshold { + dropGuardSnode(guardSnode) + path.forEach { snode in + SnodeAPI.handleError(withStatusCode: statusCode, json: json, forSnode: snode) // Intentionally don't throw + } + drop(path) + } else { + LegacyOnionRequestAPI.pathFailureCount[path] = pathFailureCount + } + } + let prefix = "Next node not found: " + if let message = json?["result"] as? String, message.hasPrefix(prefix) { + let ed25519PublicKey = message[message.index(message.startIndex, offsetBy: prefix.count)..= snodeFailureThreshold { + SnodeAPI.handleError(withStatusCode: statusCode, json: json, forSnode: snode) // Intentionally don't throw + do { + try drop(snode) + } catch { + handleUnspecificError() + } + } else { + LegacyOnionRequestAPI.snodeFailureCount[snode] = snodeFailureCount + } + } else { + // Do nothing + } + } else if let message = json?["result"] as? String, message == "Loki Server error" { + // Do nothing + } else if case .server(let host, _, _, _, _) = destination, host == "116.203.70.33" && statusCode == 0 { + // FIXME: Temporary thing to kick out nodes that can't talk to the V2 OGS yet + handleUnspecificError() + } else if statusCode == 0 { // Timeout + // Do nothing + } else { + handleUnspecificError() + } + } + return promise + } +} diff --git a/SessionSnodeKit/OnionRequestAPI+Encryption.swift b/SessionSnodeKit/OnionRequestAPI+Encryption.swift index 27a7ab31c..deec2a30c 100644 --- a/SessionSnodeKit/OnionRequestAPI+Encryption.swift +++ b/SessionSnodeKit/OnionRequestAPI+Encryption.swift @@ -14,30 +14,63 @@ internal extension OnionRequestAPI { } /// Encrypts `payload` for `destination` and returns the result. Use this to build the core of an onion request. + static func encrypt(_ payload: String, for destination: Destination) -> Promise { + let (promise, seal) = Promise.pending() + DispatchQueue.global(qos: .userInitiated).async { + do { + guard let data = payload.data(using: .utf8) else { + throw Error.invalidRequestInfo + } + + let result = try encrypt(data, for: destination) + seal.fulfill(result) + } + catch (let error) { + seal.reject(error) + } + } + + return promise + } + static func encrypt(_ payload: JSON, for destination: Destination) -> Promise { let (promise, seal) = Promise.pending() DispatchQueue.global(qos: .userInitiated).async { do { guard JSONSerialization.isValidJSONObject(payload) else { return seal.reject(HTTP.Error.invalidJSON) } + // Wrapping isn't needed for file server or open group onion requests switch destination { - case .snode(let snode): - let snodeX25519PublicKey = snode.publicKeySet.x25519Key - let payloadAsData = try JSONSerialization.data(withJSONObject: payload, options: [ .fragmentsAllowed ]) - let plaintext = try encode(ciphertext: payloadAsData, json: [ "headers" : "" ]) - let result = try AESGCM.encrypt(plaintext, for: snodeX25519PublicKey) - seal.fulfill(result) - case .server(_, _, let serverX25519PublicKey, _, _): - let plaintext = try JSONSerialization.data(withJSONObject: payload, options: [ .fragmentsAllowed ]) - let result = try AESGCM.encrypt(plaintext, for: serverX25519PublicKey) - seal.fulfill(result) + case .snode: + let payloadAsData = try JSONSerialization.data(withJSONObject: payload, options: [ .fragmentsAllowed ]) + let data = try encode(ciphertext: payloadAsData, json: [ "headers" : "" ]) + let result = try encrypt(data, for: destination) + seal.fulfill(result) + + case .server: + let data = try JSONSerialization.data(withJSONObject: payload, options: [ .fragmentsAllowed ]) + let result = try encrypt(data, for: destination) + seal.fulfill(result) } - } catch (let error) { + } + catch (let error) { seal.reject(error) } } + return promise } + + private static func encrypt(_ payload: Data, for destination: Destination) throws -> AESGCM.EncryptionResult { + switch destination { + case .snode(let snode): + let snodeX25519PublicKey = snode.publicKeySet.x25519Key + return try AESGCM.encrypt(payload, for: snodeX25519PublicKey) + + case .server(_, _, let serverX25519PublicKey, _, _): + return try AESGCM.encrypt(payload, for: serverX25519PublicKey) + } + } /// Encrypts the previous encryption result (i.e. that of the hop after this one) for this hop. Use this to build the layers of an onion request. static func encryptHop(from lhs: Destination, to rhs: Destination, using previousEncryptionResult: AESGCM.EncryptionResult) -> Promise { diff --git a/SessionSnodeKit/OnionRequestAPI.swift b/SessionSnodeKit/OnionRequestAPI.swift index 57192182a..84765f969 100644 --- a/SessionSnodeKit/OnionRequestAPI.swift +++ b/SessionSnodeKit/OnionRequestAPI.swift @@ -3,8 +3,18 @@ import CryptoSwift import PromiseKit import SessionUtilitiesKit +public protocol OnionRequestAPIType { + static func sendOnionRequest(_ request: URLRequest, to server: String, target: String, using x25519PublicKey: String) -> Promise<(OnionRequestAPI.ResponseInfo, Data?)> +} + +public extension OnionRequestAPIType { + static func sendOnionRequest(_ request: URLRequest, to server: String, using x25519PublicKey: String) -> Promise<(OnionRequestAPI.ResponseInfo, Data?)> { + sendOnionRequest(request, to: server, target: "/oxen/v4/lsrpc", using: x25519PublicKey) + } +} + /// See the "Onion Requests" section of [The Session Whitepaper](https://arxiv.org/pdf/2002.04609.pdf) for more information. -public enum OnionRequestAPI { +public enum OnionRequestAPI: OnionRequestAPIType { private static var buildPathsPromise: Promise<[Path]>? = nil /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. private static var pathFailureCount: [Path:UInt] = [:] @@ -49,6 +59,7 @@ public enum OnionRequestAPI { case missingSnodeVersion case snodePublicKeySetMissing case unsupportedSnodeVersion(String) + case invalidRequestInfo public var errorDescription: String? { switch self { @@ -63,9 +74,22 @@ public enum OnionRequestAPI { case .missingSnodeVersion: return "Missing Service Node version." case .snodePublicKeySetMissing: return "Missing Service Node public key set." case .unsupportedSnodeVersion(let version): return "Unsupported Service Node version: \(version)." + case .invalidRequestInfo: return "Invalid Request Info" } } } + + // MARK: RequestInfo + private struct RequestInfo: Codable { + let method: String + let endpoint: String + let headers: [String: String] + } + + public struct ResponseInfo: Codable { + let code: Int + let headers: [String: String] + } // MARK: Path public typealias Path = [Snode] @@ -268,7 +292,7 @@ public enum OnionRequestAPI { } /// Builds an onion around `payload` and returns the result. - private static func buildOnion(around payload: JSON, targetedAt destination: Destination) -> Promise { + private static func buildOnion(around payload: String, targetedAt destination: Destination) -> Promise { var guardSnode: Snode! var targetSnodeSymmetricKey: Data! // Needed by invoke(_:on:with:) to decrypt the response sent back by the destination var encryptionResult: AESGCM.EncryptionResult! @@ -301,57 +325,67 @@ public enum OnionRequestAPI { } // MARK: Public API - /// Sends an onion request to `snode`. Builds new paths as needed. - public static func sendOnionRequest(to snode: Snode, invoking method: Snode.Method, with parameters: JSON, associatedWith publicKey: String? = nil) -> Promise { - let payload: JSON = [ "method" : method.rawValue, "params" : parameters ] - return sendOnionRequest(with: payload, to: Destination.snode(snode)).recover2 { error -> Promise in - guard case OnionRequestAPI.Error.httpRequestFailedAtDestination(let statusCode, let json, _) = error else { throw error } - throw SnodeAPI.handleError(withStatusCode: statusCode, json: json, forSnode: snode, associatedWith: publicKey) ?? error - } - } +// /// Sends an onion request to `snode`. Builds new paths as needed. +// public static func sendOnionRequest(to snode: Snode, invoking method: Snode.Method, with parameters: JSON, associatedWith publicKey: String? = nil) -> Promise { +// let payload: JSON = [ "method" : method.rawValue, "params" : parameters ] +// return sendOnionRequest(with: payload, to: Destination.snode(snode)).recover2 { error -> Promise in +// guard case OnionRequestAPI.Error.httpRequestFailedAtDestination(let statusCode, let json, _) = error else { throw error } +// throw SnodeAPI.handleError(withStatusCode: statusCode, json: json, forSnode: snode, associatedWith: publicKey) ?? error +// } +// } /// Sends an onion request to `server`. Builds new paths as needed. - public static func sendOnionRequest(_ request: URLRequest, to server: String, target: String = "/loki/v3/lsrpc", using x25519PublicKey: String) -> Promise { + public static func sendOnionRequest(_ request: URLRequest, to server: String, target: String = "/oxen/v4/lsrpc", using x25519PublicKey: String) -> Promise<(ResponseInfo, Data?)> { + guard server == "https://chat.lokinet.dev" else { // TODO: Remove this + return LegacyOnionRequestAPI.sendOnionRequest(request, to: server, target: "/loki/v3/lsrpc", using: x25519PublicKey) + } guard let url = request.url, let host = request.url?.host else { return Promise(error: Error.invalidURL) } - var headers: JSON = (request.allHTTPHeaderFields ?? [:]) - .mapValues { value -> Any in - switch value.lowercased() { - case "true": return true - case "false": return false - default: return value - } - } - .removingValue(forKey: "User-Agent") - // Note: We need to remove the leading forward slash unless we are explicitly hitting a legacy // endpoint (in which case we need it to ensure the request signing works correctly - // TODO: Confirm the 'removingPrefix' isn't going to break the request signing on non-legacy endpoints + // TODO: Confirm the 'removingPrefix' isn't going to break the request signing on non-legacy endpoints. let endpoint: String = url.path -// .removingPrefix("/", if: !url.path.starts(with: "/legacy")) .appending(url.query.map { value in "?\(value)" }) let scheme: String? = url.scheme let port: UInt16? = url.port.map { UInt16($0) } - let bodyAsString: String + + let requestInfo: RequestInfo = RequestInfo( + method: (request.httpMethod ?? "GET"), // Default (if nil) is 'GET' + endpoint: endpoint, + headers: (request.allHTTPHeaderFields ?? [:]) + .setting( + "Content-Type", + // TODO: Determine what 'Content-Type' 'httpBodyStream' should have??? + (request.httpBody == nil && request.httpBodyStream == nil ? nil : + ((request.allHTTPHeaderFields ?? [:])["Content-Type"] ?? "application/json") // Default to JSON if not defined + ) + ) + .removingValue(forKey: "User-Agent") + ) + + guard let requestInfoData: Data = try? JSONEncoder().encode(requestInfo), let requestInfoString: String = String(data: requestInfoData, encoding: .ascii) else { + return Promise(error: Error.invalidRequestInfo) + } + + let payload: String if let body: Data = request.httpBody { - headers["Content-Type"] = "application/json" // Assume data is JSON - bodyAsString = (String(data: body, encoding: .utf8) ?? "null") + guard let bodyString: String = String(data: body, encoding: .ascii) else { + return Promise(error: Error.invalidRequestInfo) + } + + payload = "l\(requestInfoString.count):\(requestInfoString)\(bodyString.count):\(bodyString)e" } - else if let inputStream: InputStream = request.httpBodyStream, let body: Data = try? Data(from: inputStream) { - headers["Content-Type"] = request.allHTTPHeaderFields!["Content-Type"] - bodyAsString = "{ \"fileUpload\" : \"\(String(data: body.base64EncodedData(), encoding: .utf8) ?? "null")\" }" + else if let inputStream: InputStream = request.httpBodyStream, let body: Data = try? Data(from: inputStream), let bodyString: String = String(data: body, encoding: .ascii) { + // TODO: Handle this properly +// headers["Content-Type"] = request.allHTTPHeaderFields!["Content-Type"] +// bodyAsString = "{ \"fileUpload\" : \"\(String(data: body.base64EncodedData(), encoding: .utf8) ?? "null")\" }" + payload = "l\(requestInfoString.count):\(requestInfoString)\(bodyString.count):\(bodyString)e" } else { - bodyAsString = "null" + payload = "l\(requestInfoString.count):\(requestInfoString)e" } - let payload: JSON = [ - "body" : bodyAsString, - "endpoint" : endpoint, - "method" : (request.httpMethod ?? "GET"), // Default (if nil) is 'GET' - "headers" : headers - ] let destination = Destination.server(host: host, target: target, x25519PublicKey: x25519PublicKey, scheme: scheme, port: port) let promise = sendOnionRequest(with: payload, to: destination) promise.catch2 { error in @@ -360,8 +394,8 @@ public enum OnionRequestAPI { return promise } - public static func sendOnionRequest(with payload: JSON, to destination: Destination) -> Promise { - let (promise, seal) = Promise.pending() + public static func sendOnionRequest(with payload: String, to destination: Destination) -> Promise<(ResponseInfo, Data?)> { + let (promise, seal) = Promise<(ResponseInfo, Data?)>.pending() var guardSnode: Snode? Threading.workQueue.async { // Avoid race conditions on `guardSnodes` and `paths` buildOnion(around: payload, targetedAt: destination).done2 { intermediate in @@ -383,68 +417,78 @@ public enum OnionRequestAPI { } let destinationSymmetricKey = intermediate.destinationSymmetricKey - HTTP.execute(.post, url, body: body) - .done2 { json in - guard let base64EncodedIVAndCiphertext = json["result"] as? String, let ivAndCiphertext = Data(base64Encoded: base64EncodedIVAndCiphertext), ivAndCiphertext.count >= AESGCM.ivSize else { - return seal.reject(HTTP.Error.invalidJSON) - } + HTTP.updatedExecute(.post, url, body: body) + .done2 { responseData in + guard responseData.count >= AESGCM.ivSize else { return seal.reject(HTTP.Error.invalidResponse) } do { - let data = try AESGCM.decrypt(ivAndCiphertext, with: destinationSymmetricKey) + let data: Data = try AESGCM.decrypt(responseData, with: destinationSymmetricKey) - // The JSON data can be either an array or an object so can't cast to 'JSON' here - // TODO: Would be nice to ditch this 'JSONSerialization' behaviour if we can - guard let jsonObject = try? JSONSerialization.jsonObject(with: data, options: [ .fragmentsAllowed ]) else { - return seal.reject(HTTP.Error.invalidJSON) + // The data will be in the form of `l123:jsone` or `l123:json456:bodye` so we need to break the data into + // parts to properly process it + guard let responseString: String = String(data: data, encoding: .ascii), responseString.starts(with: "l") else { + return seal.reject(HTTP.Error.invalidResponse) } - // TODO: How do we now handle this case when the `status_code` is out of sync now that the value isn't provided? - // TODO: Upgrade to V4? - var customStatusCode: Int = 200 + let stringParts: [String.SubSequence] = responseString.split(separator: ":") - if let json: JSON = jsonObject as? JSON, let bodyStatusCode: Int = (((json["status_code"] as? Int) ?? json["status"] as? Int) ?? json["code"] as? Int) { - guard bodyStatusCode != 406 else { - SNLog("The user's clock is out of sync with the service node network.") - return seal.reject(SnodeAPI.Error.clockOutOfSync) - } - - customStatusCode = bodyStatusCode + guard stringParts.count > 1, let infoLength: Int = Int(stringParts[0].suffix(from: stringParts[0].index(stringParts[0].startIndex, offsetBy: 1))) else { + return seal.reject(HTTP.Error.invalidResponse) } - if let json: JSON = jsonObject as? JSON, let bodyAsString: String = json["body"] as? String { - guard let bodyAsData = bodyAsString.data(using: .utf8), let body = try JSONSerialization.jsonObject(with: bodyAsData, options: [ .fragmentsAllowed ]) as? JSON else { - return seal.reject(HTTP.Error.invalidJSON) - } - - if let timestamp = body["t"] as? Int64 { - let offset = timestamp - Int64(NSDate.millisecondTimestamp()) - SnodeAPI.clockOffset = offset - } - - guard 200...299 ~= customStatusCode else { - return seal.reject( - Error.httpRequestFailedAtDestination( - statusCode: UInt(customStatusCode), - json: body, - destination: destination - ) - ) - } - - return seal.fulfill(data) + let infoStringStartIndex: String.Index = responseString.index(responseString.startIndex, offsetBy: "l\(infoLength):".count) + let infoStringEndIndex: String.Index = responseString.index(infoStringStartIndex, offsetBy: infoLength) + let infoString: String = String(responseString[infoStringStartIndex.. "l\(infoLength)\(infoString)e".count else { + return seal.fulfill((responseInfo, nil)) + } + + // TODO: Is this going to be done anymore...??? +// if let timestamp = body["t"] as? Int64 { +// let offset = timestamp - Int64(NSDate.millisecondTimestamp()) +// SnodeAPI.clockOffset = offset +// } + + // Extract the response data as well + let dataString: String = String(responseString.suffix(from: infoStringEndIndex)) + let dataStringParts: [String.SubSequence] = dataString.split(separator: ":") + + guard dataStringParts.count > 1, let finalDataLength: Int = Int(dataStringParts[0]) else { + return seal.reject(HTTP.Error.invalidResponse) + } + + let finalDataStringStartIndex: String.Index = responseString.index(infoStringEndIndex, offsetBy: "\(finalDataLength):".count) + let finalDataStringEndIndex: String.Index = responseString.index(finalDataStringStartIndex, offsetBy: finalDataLength) + let finalDataString: String = String(responseString[finalDataStringStartIndex.. RawResponsePromise { if Features.useOnionRequests { - return OnionRequestAPI.sendOnionRequest(to: snode, invoking: method, with: parameters, associatedWith: publicKey).map2 { $0 as Any } + // TODO: Ensure this should use the Legact request? + return LegacyOnionRequestAPI.sendOnionRequest(to: snode, invoking: method, with: parameters, associatedWith: publicKey).map2 { $0 as Any } } else { let url = "\(snode.address):\(snode.port)/storage_rpc/v1" return HTTP.execute(.post, url, parameters: parameters).map2 { $0 as Any }.recover2 { error -> Promise in diff --git a/SessionUtilitiesKit/Networking/HTTP.swift b/SessionUtilitiesKit/Networking/HTTP.swift index 20ba08101..6d8b7d45b 100644 --- a/SessionUtilitiesKit/Networking/HTTP.swift +++ b/SessionUtilitiesKit/Networking/HTTP.swift @@ -80,12 +80,14 @@ public enum HTTP { case generic case httpRequestFailed(statusCode: UInt, json: JSON?) case invalidJSON + case invalidResponse public var errorDescription: String? { switch self { case .generic: return "An error occurred." case .httpRequestFailed(let statusCode, _): return "HTTP request failed with status code: \(statusCode)." case .invalidJSON: return "Invalid JSON." + case .invalidResponse: return "Invalid Response" } } } @@ -156,4 +158,46 @@ public enum HTTP { task.resume() return promise } + + // TODO: Consilidate the above and this method + public static func updatedExecute(_ verb: Verb, _ url: String, body: Data?, timeout: TimeInterval = HTTP.timeout, useSeedNodeURLSession: Bool = false) -> Promise { + var request = URLRequest(url: URL(string: url)!) + request.httpMethod = verb.rawValue + request.httpBody = body + request.timeoutInterval = timeout + request.allHTTPHeaderFields?.removeValue(forKey: "User-Agent") + request.setValue("WhatsApp", forHTTPHeaderField: "User-Agent") // Set a fake value + request.setValue("en-us", forHTTPHeaderField: "Accept-Language") // Set a fake value + let (promise, seal) = Promise.pending() + let urlSession = useSeedNodeURLSession ? seedNodeURLSession : snodeURLSession + let task = urlSession.dataTask(with: request) { data, response, error in + guard let data = data, let response = response as? HTTPURLResponse else { + if let error = error { + SNLog("\(verb.rawValue) request to \(url) failed due to error: \(error).") + } else { + SNLog("\(verb.rawValue) request to \(url) failed.") + } + // Override the actual error so that we can correctly catch failed requests in sendOnionRequest(invoking:on:with:) + return seal.reject(Error.httpRequestFailed(statusCode: 0, json: nil)) + } + if let error = error { + SNLog("\(verb.rawValue) request to \(url) failed due to error: \(error).") + // Override the actual error so that we can correctly catch failed requests in sendOnionRequest(invoking:on:with:) + return seal.reject(Error.httpRequestFailed(statusCode: 0, json: nil)) + } + let statusCode = UInt(response.statusCode) + + guard 200...299 ~= statusCode else { +// let jsonDescription = json?.prettifiedDescription ?? "no debugging info provided" +// SNLog("\(verb.rawValue) request to \(url) failed with status code: \(statusCode) (\(jsonDescription)).") +// return seal.reject(Error.httpRequestFailed(statusCode: statusCode, json: json)) + // TODO: Provide error from backend here + return seal.reject(Error.httpRequestFailed(statusCode: statusCode, json: [:])) + } + + seal.fulfill(data) + } + task.resume() + return promise + } } diff --git a/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift b/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift index 57ad21e43..d491b716f 100644 --- a/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift +++ b/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift @@ -48,7 +48,8 @@ extension MessageSender { AttachmentUploadJob.upload( stream, using: { data in - OpenGroupAPIV2.upload( + // TODO: Update to non-legacy version + OpenGroupAPIV2.legacyUpload( data, to: v2OpenGroup.room, on: v2OpenGroup.server @@ -94,7 +95,8 @@ extension MessageSender { AttachmentUploadJob.upload( stream, using: { data in - OpenGroupAPIV2.upload( + // TODO: Update to non-legacy version + OpenGroupAPIV2.legacyUpload( data, to: v2OpenGroup.room, on: v2OpenGroup.server From eb927c36a947fc84f3e6a1637b2b68a7aedd0db0 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 15 Feb 2022 13:55:59 +1100 Subject: [PATCH 006/157] Started cleaning up some of the SOGS and Onion Requests structure Cleaned up the OnionRequestAPI so we don't need the LegacyOnionRequestAPI Added requests for the user endpoints Added deprecated flags to the legacy endpoints and functions Added some logic to start handling the new poll (batch) response Started adding unit tests for the OpenGroupAPI functions --- Podfile | 6 + Podfile.lock | 6 +- Session.xcodeproj/project.pbxproj | 337 ++++++++++++- .../xcshareddata/xcschemes/Session.xcscheme | 178 +------ .../xcschemes/SessionMessagingKit.xcscheme | 78 +++ ...ssionNotificationServiceExtension.xcscheme | 11 + .../xcschemes/SessionShareExtension.xcscheme | 11 + .../ConversationVC+Interaction.swift | 4 +- .../File Server/FileServerAPIV2.swift | 4 +- .../Jobs/NotifyPNServerJob.swift | 3 +- .../Open Groups/Models/BatchRequestInfo.swift | 7 +- .../Open Groups/Models/Capabilities.swift | 12 +- .../Open Groups/Models/UserBanRequest.swift | 11 + .../Models/UserDeleteMessagesRequest.swift | 10 + .../Models/UserDeleteMessagesResponse.swift | 15 + .../Models/UserModeratorRequest.swift | 69 +++ .../Models/UserPermissionsRequest.swift | 13 + .../Open Groups/Models/UserUnbanRequest.swift | 10 + .../Open Groups/OpenGroupAPIV2.swift | 392 ++++++++++----- .../Open Groups/Types/Endpoint.swift | 36 +- .../Types/NonceGenerator16Byte.swift | 14 +- .../Notifications/PushNotificationAPI.swift | 6 +- .../Pollers/OpenGroupPollerV2.swift | 93 +++- .../Utilities/Promise+Utilities.swift | 6 +- .../Open Groups/OpenGroupAPIV2Tests.swift | 220 +++++++++ .../_TestUtilities/Mockable.swift | 9 + .../_TestUtilities/TestStorage.swift | 114 +++++ SessionSnodeKit/LegacyOnionRequestAPI.swift | 455 ----------------- SessionSnodeKit/Models/Destination.swift | 17 + SessionSnodeKit/Models/Error.swift | 34 ++ SessionSnodeKit/Models/RequestInfo.swift | 11 + SessionSnodeKit/Models/ResponseInfo.swift | 20 + SessionSnodeKit/Models/Version.swift | 11 + SessionSnodeKit/OnionRequestAPI.swift | 466 +++++++++++------- SessionSnodeKit/SnodeAPI.swift | 4 +- 35 files changed, 1718 insertions(+), 975 deletions(-) create mode 100644 Session.xcodeproj/xcshareddata/xcschemes/SessionMessagingKit.xcscheme create mode 100644 SessionMessagingKit/Open Groups/Models/UserBanRequest.swift create mode 100644 SessionMessagingKit/Open Groups/Models/UserDeleteMessagesRequest.swift create mode 100644 SessionMessagingKit/Open Groups/Models/UserDeleteMessagesResponse.swift create mode 100644 SessionMessagingKit/Open Groups/Models/UserModeratorRequest.swift create mode 100644 SessionMessagingKit/Open Groups/Models/UserPermissionsRequest.swift create mode 100644 SessionMessagingKit/Open Groups/Models/UserUnbanRequest.swift create mode 100644 SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift create mode 100644 SessionMessagingKitTests/_TestUtilities/Mockable.swift create mode 100644 SessionMessagingKitTests/_TestUtilities/TestStorage.swift delete mode 100644 SessionSnodeKit/LegacyOnionRequestAPI.swift create mode 100644 SessionSnodeKit/Models/Destination.swift create mode 100644 SessionSnodeKit/Models/Error.swift create mode 100644 SessionSnodeKit/Models/RequestInfo.swift create mode 100644 SessionSnodeKit/Models/ResponseInfo.swift create mode 100644 SessionSnodeKit/Models/Version.swift diff --git a/Podfile b/Podfile index e2429bb66..e7bacf287 100644 --- a/Podfile +++ b/Podfile @@ -51,6 +51,12 @@ abstract_target 'GlobalDependencies' do pod 'Reachability' pod 'SAMKeychain' pod 'SwiftProtobuf', '~> 1.5.0' + + target 'SessionMessagingKitTests' do + inherit! :complete + + pod 'Nimble' + end end target 'SessionUtilitiesKit' do diff --git a/Podfile.lock b/Podfile.lock index fb59d482d..edb3b4fd7 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -24,6 +24,7 @@ PODS: - Mantle (2.1.0): - Mantle/extobjc (= 2.1.0) - Mantle/extobjc (2.1.0) + - Nimble (9.2.1) - NVActivityIndicatorView (5.1.1): - NVActivityIndicatorView/Base (= 5.1.1) - NVActivityIndicatorView/Base (5.1.1) @@ -124,6 +125,7 @@ DEPENDENCIES: - CryptoSwift - Curve25519Kit (from `https://github.com/signalapp/Curve25519Kit.git`) - Mantle (from `https://github.com/signalapp/Mantle`, branch `signal-master`) + - Nimble - NVActivityIndicatorView - PromiseKit - PureLayout (~> 3.1.8) @@ -141,6 +143,7 @@ SPEC REPOS: - AFNetworking - CocoaLumberjack - CryptoSwift + - Nimble - NVActivityIndicatorView - OpenSSL-Universal - PromiseKit @@ -190,6 +193,7 @@ SPEC CHECKSUMS: CryptoSwift: a532e74ed010f8c95f611d00b8bbae42e9fe7c17 Curve25519Kit: e63f9859ede02438ae3defc5e1a87e09d1ec7ee6 Mantle: 2fa750afa478cd625a94230fbf1c13462f29395b + Nimble: e7e615c0335ee4bf5b0d786685451e62746117d5 NVActivityIndicatorView: 1f6c5687f1171810aa27a3296814dc2d7dec3667 OpenSSL-Universal: e7311447fd2419f57420c79524b641537387eff2 PromiseKit: 3b2b6995e51a954c46dbc550ce3da44fbfb563c5 @@ -204,6 +208,6 @@ SPEC CHECKSUMS: YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331 ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: 19ce2820c263e8f3c114817f7ca2da73a9382b6a +PODFILE CHECKSUM: a4acbe047a767c48a709e93318532fbf345330dd COCOAPODS: 1.11.2 diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index c48c97224..b51ac4466 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -767,6 +767,7 @@ D24B5BD5169F568C00681372 /* AudioToolbox.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D24B5BD4169F568C00681372 /* AudioToolbox.framework */; }; D2AEACDC16C426DA00C364C0 /* CFNetwork.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2AEACDB16C426DA00C364C0 /* CFNetwork.framework */; }; D48CEFD2222D323FEFEFC6CC /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SignalUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8962372EEC51D3F56FE3A68A /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SignalUtilitiesKit.framework */; }; + E197F4A653289312F13926E6 /* Pods_SessionMessagingKitTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CF061FF98D2BFA3ECCF8C6F6 /* Pods_SessionMessagingKitTests.framework */; }; EF764C351DB67CC5000D9A87 /* UIViewController+Permissions.m in Sources */ = {isa = PBXBuildFile; fileRef = EF764C341DB67CC5000D9A87 /* UIViewController+Permissions.m */; }; F5765D284BC6ECAC0C1D33F0 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionNotificationServiceExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E4A93ECA93B3DE800CC7D7F6 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionNotificationServiceExtension.framework */; }; FC3BD9881A30A790005B96BB /* Social.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FC3BD9871A30A790005B96BB /* Social.framework */; }; @@ -826,7 +827,21 @@ FDC4387427B5BB9B00C60D73 /* Promise+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4387327B5BB9B00C60D73 /* Promise+Utilities.swift */; }; FDC4387627B5BEF300C60D73 /* FileDownloadResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4387527B5BEF300C60D73 /* FileDownloadResponse.swift */; }; FDC4387827B5C35400C60D73 /* SendMessageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4387727B5C35400C60D73 /* SendMessageRequest.swift */; }; - FDC4387C27B9C6C900C60D73 /* LegacyOnionRequestAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4387B27B9C6C900C60D73 /* LegacyOnionRequestAPI.swift */; }; + FDC4389227B9FFC700C60D73 /* SessionMessagingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A6F025539DE700C340D1 /* SessionMessagingKit.framework */; platformFilter = ios; }; + FDC4389A27BA002500C60D73 /* OpenGroupAPIV2Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4389927BA002500C60D73 /* OpenGroupAPIV2Tests.swift */; }; + FDC4389D27BA01F000C60D73 /* TestStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4389C27BA01F000C60D73 /* TestStorage.swift */; }; + FDC438A427BB107F00C60D73 /* UserBanRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438A327BB107F00C60D73 /* UserBanRequest.swift */; }; + FDC438A627BB113A00C60D73 /* UserUnbanRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438A527BB113A00C60D73 /* UserUnbanRequest.swift */; }; + FDC438A827BB11CD00C60D73 /* UserPermissionsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438A727BB11CD00C60D73 /* UserPermissionsRequest.swift */; }; + FDC438AA27BB12BB00C60D73 /* UserModeratorRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438A927BB12BB00C60D73 /* UserModeratorRequest.swift */; }; + FDC438AC27BB145200C60D73 /* UserDeleteMessagesRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438AB27BB145200C60D73 /* UserDeleteMessagesRequest.swift */; }; + FDC438AE27BB148700C60D73 /* UserDeleteMessagesResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438AD27BB148700C60D73 /* UserDeleteMessagesResponse.swift */; }; + FDC438B127BB159600C60D73 /* RequestInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438B027BB159600C60D73 /* RequestInfo.swift */; }; + FDC438B327BB15B400C60D73 /* ResponseInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438B227BB15B400C60D73 /* ResponseInfo.swift */; }; + FDC438B527BB15D400C60D73 /* Destination.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438B427BB15D400C60D73 /* Destination.swift */; }; + FDC438B727BB160000C60D73 /* Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438B627BB160000C60D73 /* Error.swift */; }; + FDC438B927BB161E00C60D73 /* Version.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438B827BB161E00C60D73 /* Version.swift */; }; + FDC438BD27BB2AB400C60D73 /* Mockable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438BC27BB2AB400C60D73 /* Mockable.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -949,6 +964,20 @@ remoteGlobalIDString = C3C2A678255388CC00C340D1; remoteInfo = SessionUtilitiesKit; }; + FDC4389327B9FFC700C60D73 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D221A080169C9E5E00537ABF /* Project object */; + proxyType = 1; + remoteGlobalIDString = C3C2A6EF25539DE700C340D1; + remoteInfo = SessionMessagingKit; + }; + FDC438BA27BB276F00C60D73 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D221A080169C9E5E00537ABF /* Project object */; + proxyType = 1; + remoteGlobalIDString = D221A088169C9E5E00537ABF; + remoteInfo = Session; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -994,6 +1023,7 @@ /* Begin PBXFileReference section */ 038A3BABD5BA0CE41D8C17F5 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionShareExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionShareExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 0840117FDFD286D1CC14A2E1 /* Pods-SessionTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SessionTests/Pods-SessionTests.debug.xcconfig"; sourceTree = ""; }; 0D3D13FEE4FF6A2E2ED85322 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit.app store release.xcconfig"; sourceTree = ""; }; 174BD0AE74771D02DAC2B7A9 /* Pods-SessionProtocolKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionProtocolKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionProtocolKit/Pods-SessionProtocolKit.app store release.xcconfig"; sourceTree = ""; }; 18D19142FD6E60FD0A5D89F7 /* Pods-LokiPushNotificationService.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LokiPushNotificationService.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-LokiPushNotificationService/Pods-LokiPushNotificationService.app store release.xcconfig"; sourceTree = ""; }; @@ -1201,6 +1231,7 @@ 9B3329176C10E9640865E65B /* Pods-GlobalDependencies-Session.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-Session.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-Session/Pods-GlobalDependencies-Session.debug.xcconfig"; sourceTree = ""; }; 9B533A9FA46206D3D99C9ADA /* Pods-SignalMessaging.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SignalMessaging.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SignalMessaging/Pods-SignalMessaging.debug.xcconfig"; sourceTree = ""; }; 9C0469AC557930C01552CC83 /* Pods-SignalUtilitiesKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SignalUtilitiesKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SignalUtilitiesKit/Pods-SignalUtilitiesKit.app store release.xcconfig"; sourceTree = ""; }; + A0FB43B511403A5FAFAC88B8 /* Pods-SessionMessagingKitTests.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionMessagingKitTests.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionMessagingKitTests/Pods-SessionMessagingKitTests.app store release.xcconfig"; sourceTree = ""; }; A11CD70C17FA230600A2D1B1 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; }; A163E8AA16F3F6A90094D68B /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; A1C32D4D17A0652C000A904E /* AddressBook.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AddressBook.framework; path = System/Library/Frameworks/AddressBook.framework; sourceTree = SDKROOT; }; @@ -1835,8 +1866,10 @@ C3F0A5FD255C988A007BE2A3 /* Storage+Shared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+Shared.swift"; sourceTree = ""; }; C3F0A607255C98A6007BE2A3 /* Storage+SnodeAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+SnodeAPI.swift"; sourceTree = ""; }; C5060C3B36A848B71CCE4685 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C8153B96A292A25045BE2C54 /* Pods-SessionTests.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionTests.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionTests/Pods-SessionTests.app store release.xcconfig"; sourceTree = ""; }; C88965DE4F4EC4FC919BEC4E /* Pods-SessionUIKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionUIKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SessionUIKit/Pods-SessionUIKit.debug.xcconfig"; sourceTree = ""; }; C98441E849C3CA7FE8220D33 /* Pods-SessionNotificationServiceExtension.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionNotificationServiceExtension.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionNotificationServiceExtension/Pods-SessionNotificationServiceExtension.app store release.xcconfig"; sourceTree = ""; }; + CF061FF98D2BFA3ECCF8C6F6 /* Pods_SessionMessagingKitTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SessionMessagingKitTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D2179CFB16BB0B3A0006F3AB /* CoreTelephony.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreTelephony.framework; path = System/Library/Frameworks/CoreTelephony.framework; sourceTree = SDKROOT; }; D2179CFD16BB0B480006F3AB /* SystemConfiguration.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SystemConfiguration.framework; path = System/Library/Frameworks/SystemConfiguration.framework; sourceTree = SDKROOT; }; D221A089169C9E5E00537ABF /* Session.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Session.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1849,6 +1882,7 @@ D221A0E7169DFFC500537ABF /* AVFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVFoundation.framework; path = ../../../../../../System/Library/Frameworks/AVFoundation.framework; sourceTree = ""; }; D24B5BD4169F568C00681372 /* AudioToolbox.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AudioToolbox.framework; path = ../../../../../../System/Library/Frameworks/AudioToolbox.framework; sourceTree = ""; }; D2AEACDB16C426DA00C364C0 /* CFNetwork.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CFNetwork.framework; path = System/Library/Frameworks/CFNetwork.framework; sourceTree = SDKROOT; }; + D2C155B76C8483CB9A6EA9B4 /* Pods_SessionTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SessionTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DE2DD605305BC6EFAD731723 /* Pods-Signal.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Signal.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Signal/Pods-Signal.debug.xcconfig"; sourceTree = ""; }; DF728B4B438716EAF95CEC18 /* Pods-Signal.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Signal.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-Signal/Pods-Signal.app store release.xcconfig"; sourceTree = ""; }; E19F30497676B0FA3553CCE6 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionSnodeKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionSnodeKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1860,6 +1894,7 @@ EF764C331DB67CC5000D9A87 /* UIViewController+Permissions.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "UIViewController+Permissions.h"; sourceTree = ""; }; EF764C341DB67CC5000D9A87 /* UIViewController+Permissions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "UIViewController+Permissions.m"; sourceTree = ""; }; F121FB43E2A1C1CF7F2AFC23 /* Pods-SessionPushNotificationExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionPushNotificationExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SessionPushNotificationExtension/Pods-SessionPushNotificationExtension.debug.xcconfig"; sourceTree = ""; }; + F4DC483F404B65C59D9C2CF8 /* Pods-SessionMessagingKitTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionMessagingKitTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SessionMessagingKitTests/Pods-SessionMessagingKitTests.debug.xcconfig"; sourceTree = ""; }; F62ECF7B8AF4F8089AA705B3 /* Pods-LokiPushNotificationService.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LokiPushNotificationService.debug.xcconfig"; path = "Pods/Target Support Files/Pods-LokiPushNotificationService/Pods-LokiPushNotificationService.debug.xcconfig"; sourceTree = ""; }; F9BBF530D71905BA9007675F /* Pods-SessionShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionShareExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SessionShareExtension/Pods-SessionShareExtension.debug.xcconfig"; sourceTree = ""; }; FC3BD9871A30A790005B96BB /* Social.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Social.framework; path = System/Library/Frameworks/Social.framework; sourceTree = SDKROOT; }; @@ -1918,7 +1953,21 @@ FDC4387327B5BB9B00C60D73 /* Promise+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Promise+Utilities.swift"; sourceTree = ""; }; FDC4387527B5BEF300C60D73 /* FileDownloadResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDownloadResponse.swift; sourceTree = ""; }; FDC4387727B5C35400C60D73 /* SendMessageRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageRequest.swift; sourceTree = ""; }; - FDC4387B27B9C6C900C60D73 /* LegacyOnionRequestAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyOnionRequestAPI.swift; sourceTree = ""; }; + FDC4388E27B9FFC700C60D73 /* SessionMessagingKitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SessionMessagingKitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + FDC4389927BA002500C60D73 /* OpenGroupAPIV2Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupAPIV2Tests.swift; sourceTree = ""; }; + FDC4389C27BA01F000C60D73 /* TestStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestStorage.swift; sourceTree = ""; }; + FDC438A327BB107F00C60D73 /* UserBanRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserBanRequest.swift; sourceTree = ""; }; + FDC438A527BB113A00C60D73 /* UserUnbanRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserUnbanRequest.swift; sourceTree = ""; }; + FDC438A727BB11CD00C60D73 /* UserPermissionsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPermissionsRequest.swift; sourceTree = ""; }; + FDC438A927BB12BB00C60D73 /* UserModeratorRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserModeratorRequest.swift; sourceTree = ""; }; + FDC438AB27BB145200C60D73 /* UserDeleteMessagesRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDeleteMessagesRequest.swift; sourceTree = ""; }; + FDC438AD27BB148700C60D73 /* UserDeleteMessagesResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDeleteMessagesResponse.swift; sourceTree = ""; }; + FDC438B027BB159600C60D73 /* RequestInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestInfo.swift; sourceTree = ""; }; + FDC438B227BB15B400C60D73 /* ResponseInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResponseInfo.swift; sourceTree = ""; }; + FDC438B427BB15D400C60D73 /* Destination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Destination.swift; sourceTree = ""; }; + FDC438B627BB160000C60D73 /* Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Error.swift; sourceTree = ""; }; + FDC438B827BB161E00C60D73 /* Version.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Version.swift; sourceTree = ""; }; + FDC438BC27BB2AB400C60D73 /* Mockable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mockable.swift; sourceTree = ""; }; FEDBAE1B98C49BBE8C87F575 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; sourceTree = ""; }; FF9BA33D021B115B1F5B4E46 /* Pods-SessionMessagingKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionMessagingKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionMessagingKit/Pods-SessionMessagingKit.app store release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -2035,6 +2084,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FDC4388B27B9FFC700C60D73 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + FDC4389227B9FFC700C60D73 /* SessionMessagingKit.framework in Frameworks */, + E197F4A653289312F13926E6 /* Pods_SessionMessagingKitTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -2241,6 +2299,10 @@ 37A3185C08AE9AE72A9E0922 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.app store release.xcconfig */, 5B7FDA4BA2DDFF4612600FB8 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.debug.xcconfig */, 7ABE4694B110C1BBCB0E46A2 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.app store release.xcconfig */, + F4DC483F404B65C59D9C2CF8 /* Pods-SessionMessagingKitTests.debug.xcconfig */, + A0FB43B511403A5FAFAC88B8 /* Pods-SessionMessagingKitTests.app store release.xcconfig */, + 0840117FDFD286D1CC14A2E1 /* Pods-SessionTests.debug.xcconfig */, + C8153B96A292A25045BE2C54 /* Pods-SessionTests.app store release.xcconfig */, ); name = Pods; sourceTree = ""; @@ -3362,10 +3424,10 @@ isa = PBXGroup; children = ( C3C2A5B0255385C700C340D1 /* Meta */, + FDC438AF27BB158500C60D73 /* Models */, C3C2A5B9255385ED00C340D1 /* Configuration.swift */, C3C2A5BD255385EE00C340D1 /* Notification+OnionRequestAPI.swift */, C3C2A5BA255385ED00C340D1 /* OnionRequestAPI.swift */, - FDC4387B27B9C6C900C60D73 /* LegacyOnionRequestAPI.swift */, C3C2A5BB255385ED00C340D1 /* OnionRequestAPI+Encryption.swift */, C3C2A5B7255385EC00C340D1 /* Snode.swift */, C3C2A5BE255385EE00C340D1 /* SnodeAPI.swift */, @@ -3628,6 +3690,7 @@ C3C2A6F125539DE700C340D1 /* SessionMessagingKit */, C3C2A5A0255385C100C340D1 /* SessionSnodeKit */, C3C2A67A255388CC00C340D1 /* SessionUtilitiesKit */, + FDC4388F27B9FFC700C60D73 /* SessionMessagingKitTests */, D221A08C169C9E5E00537ABF /* Frameworks */, D221A08A169C9E5E00537ABF /* Products */, 9404664EC513585B05DF1350 /* Pods */, @@ -3645,6 +3708,7 @@ C3C2A6F025539DE700C340D1 /* SessionMessagingKit.framework */, C331FF1B2558F9D300070591 /* SessionUIKit.framework */, C33FD9AB255A548A00E217F9 /* SignalUtilitiesKit.framework */, + FDC4388E27B9FFC700C60D73 /* SessionMessagingKitTests.xctest */, ); name = Products; sourceTree = ""; @@ -3697,6 +3761,8 @@ 038A3BABD5BA0CE41D8C17F5 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionShareExtension.framework */, C5060C3B36A848B71CCE4685 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit.framework */, 8962372EEC51D3F56FE3A68A /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SignalUtilitiesKit.framework */, + CF061FF98D2BFA3ECCF8C6F6 /* Pods_SessionMessagingKitTests.framework */, + D2C155B76C8483CB9A6EA9B4 /* Pods_SessionTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -3764,6 +3830,12 @@ FDC4386027B4CDDF00C60D73 /* FileResponse.swift */, FDC4387727B5C35400C60D73 /* SendMessageRequest.swift */, FDC4386227B4D94E00C60D73 /* OGMessage.swift */, + FDC438A327BB107F00C60D73 /* UserBanRequest.swift */, + FDC438A527BB113A00C60D73 /* UserUnbanRequest.swift */, + FDC438A727BB11CD00C60D73 /* UserPermissionsRequest.swift */, + FDC438A927BB12BB00C60D73 /* UserModeratorRequest.swift */, + FDC438AB27BB145200C60D73 /* UserDeleteMessagesRequest.swift */, + FDC438AD27BB148700C60D73 /* UserDeleteMessagesResponse.swift */, FDC4382B27B380E300C60D73 /* MemberCountResponse.swift */, FDC4382527B37F6900C60D73 /* DeletedMessagesResponse.swift */, FDC4384627B47F4D00C60D73 /* Deletion.swift */, @@ -3819,6 +3891,44 @@ path = Models; sourceTree = ""; }; + FDC4388F27B9FFC700C60D73 /* SessionMessagingKitTests */ = { + isa = PBXGroup; + children = ( + FDC4389B27BA01E300C60D73 /* _TestUtilities */, + FDC4389827BA001800C60D73 /* Open Groups */, + ); + path = SessionMessagingKitTests; + sourceTree = ""; + }; + FDC4389827BA001800C60D73 /* Open Groups */ = { + isa = PBXGroup; + children = ( + FDC4389927BA002500C60D73 /* OpenGroupAPIV2Tests.swift */, + ); + path = "Open Groups"; + sourceTree = ""; + }; + FDC4389B27BA01E300C60D73 /* _TestUtilities */ = { + isa = PBXGroup; + children = ( + FDC438BC27BB2AB400C60D73 /* Mockable.swift */, + FDC4389C27BA01F000C60D73 /* TestStorage.swift */, + ); + path = _TestUtilities; + sourceTree = ""; + }; + FDC438AF27BB158500C60D73 /* Models */ = { + isa = PBXGroup; + children = ( + FDC438B827BB161E00C60D73 /* Version.swift */, + FDC438B627BB160000C60D73 /* Error.swift */, + FDC438B427BB15D400C60D73 /* Destination.swift */, + FDC438B027BB159600C60D73 /* RequestInfo.swift */, + FDC438B227BB15B400C60D73 /* ResponseInfo.swift */, + ); + path = Models; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -4153,6 +4263,27 @@ productReference = D221A089169C9E5E00537ABF /* Session.app */; productType = "com.apple.product-type.application"; }; + FDC4388D27B9FFC700C60D73 /* SessionMessagingKitTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = FDC4389527B9FFC700C60D73 /* Build configuration list for PBXNativeTarget "SessionMessagingKitTests" */; + buildPhases = ( + A067C0B8A52FC6C6FDA49939 /* [CP] Check Pods Manifest.lock */, + FDC4388A27B9FFC700C60D73 /* Sources */, + FDC4388B27B9FFC700C60D73 /* Frameworks */, + FDC4388C27B9FFC700C60D73 /* Resources */, + 7D43E8AB603234C5ADEF2812 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + FDC4389427B9FFC700C60D73 /* PBXTargetDependency */, + FDC438BB27BB276F00C60D73 /* PBXTargetDependency */, + ); + name = SessionMessagingKitTests; + productName = SessionMessagingKitTests; + productReference = FDC4388E27B9FFC700C60D73 /* SessionMessagingKitTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -4160,7 +4291,7 @@ isa = PBXProject; attributes = { DefaultBuildSystemTypeForWorkspace = Original; - LastSwiftUpdateCheck = 1130; + LastSwiftUpdateCheck = 1320; LastTestingUpgradeCheck = 0600; LastUpgradeCheck = 1020; ORGANIZATIONNAME = "Rangeproof Pty Ltd"; @@ -4250,6 +4381,9 @@ }; }; }; + FDC4388D27B9FFC700C60D73 = { + CreatedOnToolsVersion = 13.2.1; + }; }; }; buildConfigurationList = D221A083169C9E5E00537ABF /* Build configuration list for PBXProject "Session" */; @@ -4294,6 +4428,7 @@ C3C2A6EF25539DE700C340D1 /* SessionMessagingKit */, C3C2A59E255385C100C340D1 /* SessionSnodeKit */, C3C2A678255388CC00C340D1 /* SessionUtilitiesKit */, + FDC4388D27B9FFC700C60D73 /* SessionMessagingKitTests */, ); }; /* End PBXProject section */ @@ -4418,6 +4553,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FDC4388C27B9FFC700C60D73 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -4518,6 +4660,23 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + 7D43E8AB603234C5ADEF2812 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-SessionMessagingKitTests/Pods-SessionMessagingKitTests-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-SessionMessagingKitTests/Pods-SessionMessagingKitTests-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SessionMessagingKitTests/Pods-SessionMessagingKitTests-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; 7E2D14F857C70F98DED3B8E9 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -4562,6 +4721,28 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + A067C0B8A52FC6C6FDA49939 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-SessionMessagingKitTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; B19B891E99B1507CAC8AAD19 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -4798,18 +4979,22 @@ buildActionMask = 2147483647; files = ( C3C2A5E02553860B00C340D1 /* Threading.swift in Sources */, - FDC4387C27B9C6C900C60D73 /* LegacyOnionRequestAPI.swift in Sources */, C3C2A5BF255385EE00C340D1 /* SnodeMessage.swift in Sources */, + FDC438B127BB159600C60D73 /* RequestInfo.swift in Sources */, + FDC438B927BB161E00C60D73 /* Version.swift in Sources */, C3C2A5C0255385EE00C340D1 /* Snode.swift in Sources */, C3C2A5C7255385EE00C340D1 /* SnodeAPI.swift in Sources */, C32C5CBF256DD282003C73A2 /* Storage+SnodeAPI.swift in Sources */, C3C2A5C6255385EE00C340D1 /* Notification+OnionRequestAPI.swift in Sources */, + FDC438B727BB160000C60D73 /* Error.swift in Sources */, + FDC438B527BB15D400C60D73 /* Destination.swift in Sources */, C32C5CBE256DD282003C73A2 /* Storage+OnionRequests.swift in Sources */, C3C2A5DC2553860B00C340D1 /* Promise+Threading.swift in Sources */, C3C2A5C4255385EE00C340D1 /* OnionRequestAPI+Encryption.swift in Sources */, C3C2A5DE2553860B00C340D1 /* String+Trimming.swift in Sources */, C3C2A5DB2553860B00C340D1 /* Promise+Hashing.swift in Sources */, C3C2A5E42553860B00C340D1 /* Data+Utilities.swift in Sources */, + FDC438B327BB15B400C60D73 /* ResponseInfo.swift in Sources */, C3C2A5C2255385EE00C340D1 /* Configuration.swift in Sources */, C3C2A5C3255385EE00C340D1 /* OnionRequestAPI.swift in Sources */, C3C2A5C1255385EE00C340D1 /* Storage.swift in Sources */, @@ -4913,7 +5098,9 @@ FDC4384027B4746D00C60D73 /* LegacyGetInfoResponse.swift in Sources */, C32C5C89256DD0D2003C73A2 /* Storage+Jobs.swift in Sources */, C300A5FC2554B0A000555489 /* MessageReceiver.swift in Sources */, + FDC438AC27BB145200C60D73 /* UserDeleteMessagesRequest.swift in Sources */, 7B1581E2271E743B00848B49 /* OWSSounds.swift in Sources */, + FDC438A627BB113A00C60D73 /* UserUnbanRequest.swift in Sources */, FDC4383E27B4708600C60D73 /* Atomic.swift in Sources */, C32C5A76256DBBCF003C73A2 /* SignalAttachment.swift in Sources */, C32C5CA4256DD1DC003C73A2 /* TSAccountManager.m in Sources */, @@ -4930,6 +5117,7 @@ C3BBE0802554CDD70050F1E3 /* Storage.swift in Sources */, C3DB66AC260ACA42001EFC55 /* OpenGroupManagerV2.swift in Sources */, B8F5F61B25EDE4BF003BF8D4 /* DataExtractionNotificationInfoMessage.swift in Sources */, + FDC438A427BB107F00C60D73 /* UserBanRequest.swift in Sources */, C379DCF4256735770002D4EB /* VisibleMessage+Attachment.swift in Sources */, B8856D34256F1192001CE70E /* Environment.m in Sources */, B8B320B7258C30D70020074B /* HTMLMetadata.swift in Sources */, @@ -5024,6 +5212,7 @@ C352A2FF25574B6300338F3E /* MessageSendJob.swift in Sources */, B8856D11256F112A001CE70E /* OWSAudioSession.swift in Sources */, C3DB66C3260ACCE6001EFC55 /* OpenGroupPollerV2.swift in Sources */, + FDC438AE27BB148700C60D73 /* UserDeleteMessagesResponse.swift in Sources */, C3C2A75F2553A3C500C340D1 /* VisibleMessage+LinkPreview.swift in Sources */, C32C5FBB256E0206003C73A2 /* OWSBackgroundTask.m in Sources */, B8856CA8256F0F42001CE70E /* OWSBackupFragment.m in Sources */, @@ -5039,6 +5228,7 @@ C32C5AAE256DBE8F003C73A2 /* TSInteraction.m in Sources */, C32C5D23256DD4C0003C73A2 /* Mention.swift in Sources */, C32C5FD6256E0346003C73A2 /* Notification+Thread.swift in Sources */, + FDC438A827BB11CD00C60D73 /* UserPermissionsRequest.swift in Sources */, C3BBE0C72554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift in Sources */, FDC4383827B3863200C60D73 /* VersionResponse.swift in Sources */, C32C5C88256DD0D2003C73A2 /* Storage+Messaging.swift in Sources */, @@ -5054,6 +5244,7 @@ FDC4385727B484B700C60D73 /* LegacyFileUploadResponse.swift in Sources */, FDC4385B27B485DE00C60D73 /* LegacyFileDownloadResponse.swift in Sources */, C32C5C01256DC9A0003C73A2 /* OWSIdentityManager.m in Sources */, + FDC438AA27BB12BB00C60D73 /* UserModeratorRequest.swift in Sources */, C32C59C4256DB41F003C73A2 /* TSContactThread.m in Sources */, FDC4383127B3841C00C60D73 /* RegisterResponse.swift in Sources */, C32C5AB0256DBE8F003C73A2 /* TSOutgoingMessage.m in Sources */, @@ -5236,6 +5427,16 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FDC4388A27B9FFC700C60D73 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FDC4389A27BA002500C60D73 /* OpenGroupAPIV2Tests.swift in Sources */, + FDC438BD27BB2AB400C60D73 /* Mockable.swift in Sources */, + FDC4389D27BA01F000C60D73 /* TestStorage.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -5325,6 +5526,17 @@ target = C3C2A678255388CC00C340D1 /* SessionUtilitiesKit */; targetProxy = FDC4386E27B4E90300C60D73 /* PBXContainerItemProxy */; }; + FDC4389427B9FFC700C60D73 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + platformFilter = ios; + target = C3C2A6EF25539DE700C340D1 /* SessionMessagingKit */; + targetProxy = FDC4389327B9FFC700C60D73 /* PBXContainerItemProxy */; + }; + FDC438BB27BB276F00C60D73 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D221A088169C9E5E00537ABF /* Session */; + targetProxy = FDC438BA27BB276F00C60D73 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -6664,6 +6876,112 @@ }; name = "App Store Release"; }; + FDC4389627B9FFC700C60D73 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F4DC483F404B65C59D9C2CF8 /* Pods-SessionMessagingKitTests.debug.xcconfig */; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.2; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = io.oxen.SessionMessagingKitTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + FDC4389727B9FFC700C60D73 /* App Store Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A0FB43B511403A5FAFAC88B8 /* Pods-SessionMessagingKitTests.app store release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.2; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = io.oxen.SessionMessagingKitTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = "App Store Release"; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -6748,6 +7066,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = "App Store Release"; }; + FDC4389527B9FFC700C60D73 /* Build configuration list for PBXNativeTarget "SessionMessagingKitTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FDC4389627B9FFC700C60D73 /* Debug */, + FDC4389727B9FFC700C60D73 /* App Store Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = "App Store Release"; + }; /* End XCConfigurationList section */ }; rootObject = D221A080169C9E5E00537ABF /* Project object */; diff --git a/Session.xcodeproj/xcshareddata/xcschemes/Session.xcscheme b/Session.xcodeproj/xcshareddata/xcschemes/Session.xcscheme index 3426f0ce1..2383ad12e 100644 --- a/Session.xcodeproj/xcshareddata/xcschemes/Session.xcscheme +++ b/Session.xcodeproj/xcshareddata/xcschemes/Session.xcscheme @@ -20,20 +20,6 @@ ReferencedContainer = "container:Session.xcodeproj"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Session.xcodeproj/xcshareddata/xcschemes/SessionNotificationServiceExtension.xcscheme b/Session.xcodeproj/xcshareddata/xcschemes/SessionNotificationServiceExtension.xcscheme index 6f27e32c8..d32a32659 100644 --- a/Session.xcodeproj/xcshareddata/xcschemes/SessionNotificationServiceExtension.xcscheme +++ b/Session.xcodeproj/xcshareddata/xcschemes/SessionNotificationServiceExtension.xcscheme @@ -43,6 +43,16 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> + + + + diff --git a/Session.xcodeproj/xcshareddata/xcschemes/SessionShareExtension.xcscheme b/Session.xcodeproj/xcshareddata/xcschemes/SessionShareExtension.xcscheme index 7ab99da50..da97b2ea0 100644 --- a/Session.xcodeproj/xcshareddata/xcschemes/SessionShareExtension.xcscheme +++ b/Session.xcodeproj/xcshareddata/xcschemes/SessionShareExtension.xcscheme @@ -52,6 +52,16 @@ + + + + diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index a1be037f0..13b0ec5ab 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -700,7 +700,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in let publicKey = message.authorId guard let openGroupV2 = Storage.shared.getV2OpenGroup(for: threadID) else { return } - OpenGroupAPIV2.ban(publicKey, from: openGroupV2.room, on: openGroupV2.server).retainUntilComplete() + OpenGroupAPIV2.legacyBan(publicKey, from: openGroupV2.room, on: openGroupV2.server).retainUntilComplete() })) alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: nil)) present(alert, animated: true, completion: nil) @@ -714,7 +714,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in let publicKey = message.authorId guard let openGroupV2 = Storage.shared.getV2OpenGroup(for: threadID) else { return } - OpenGroupAPIV2.banAndDeleteAllMessages(publicKey, from: openGroupV2.room, on: openGroupV2.server).retainUntilComplete() + OpenGroupAPIV2.legacyBanAndDeleteAllMessages(publicKey, from: openGroupV2.room, on: openGroupV2.server).retainUntilComplete() })) alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: nil)) present(alert, animated: true, completion: nil) diff --git a/SessionMessagingKit/File Server/FileServerAPIV2.swift b/SessionMessagingKit/File Server/FileServerAPIV2.swift index 7829d9b74..4e18259a7 100644 --- a/SessionMessagingKit/File Server/FileServerAPIV2.swift +++ b/SessionMessagingKit/File Server/FileServerAPIV2.swift @@ -94,8 +94,8 @@ public final class FileServerAPIV2 : NSObject { preconditionFailure("It's currently not allowed to send non onion routed requests.") } - // TODO: Upgrade this to use the V4 onion requests once supported - return LegacyOnionRequestAPI.sendOnionRequest(urlRequest, to: server, using: serverPublicKey) + // TODO: Upgrade this to use the V4 onion requests once supported. + return OnionRequestAPI.sendOnionRequest(urlRequest, to: server, using: .v3, with: serverPublicKey) .map2 { json in try JSONSerialization.data(withJSONObject: json, options: []) } } diff --git a/SessionMessagingKit/Jobs/NotifyPNServerJob.swift b/SessionMessagingKit/Jobs/NotifyPNServerJob.swift index 227c66202..f13fb8d5f 100644 --- a/SessionMessagingKit/Jobs/NotifyPNServerJob.swift +++ b/SessionMessagingKit/Jobs/NotifyPNServerJob.swift @@ -66,7 +66,8 @@ public final class NotifyPNServerJob : NSObject, Job, NSCoding { // NSObject/NSC request.httpBody = body let promise = attempt(maxRetryCount: 4, recoveringOn: DispatchQueue.global()) { - OnionRequestAPI.sendOnionRequest(request, to: server, target: "/loki/v2/lsrpc", using: PushNotificationAPI.serverPublicKey).map { _ in } + OnionRequestAPI.sendOnionRequest(request, to: server, using: .v2, with: PushNotificationAPI.serverPublicKey) + .map { _ in } } let _ = promise.done(on: DispatchQueue.global()) { // Intentionally capture self self.handleSuccess() diff --git a/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift b/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift index 05101987c..e75ceb191 100644 --- a/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift +++ b/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift @@ -20,7 +20,7 @@ extension OpenGroupAPIV2 { self.path = request.urlPathAndParamsString self.headers = (request.headers.isEmpty ? nil : request.headers.toHTTPHeaders()) - // TODO: Differentiate between JSON and b64 body + // TODO: Differentiate between JSON and b64 body. if let body: Data = request.body, let bodyString: String = String(data: body, encoding: .utf8) { self.json = bodyString } @@ -56,7 +56,7 @@ extension OpenGroupAPIV2 { typealias BatchRequest = [BatchSubRequest] typealias BatchResponseTypes = [Codable.Type] - typealias BatchResponse = [Codable] + typealias BatchResponse = [(OnionRequestResponseInfoType, Codable)] } // MARK: - Convenience @@ -67,7 +67,7 @@ public extension Decodable { } } -extension Promise where T == (OnionRequestAPI.ResponseInfo, Data?) { +extension Promise where T == (OnionRequestResponseInfoType, Data?) { func decoded(as types: OpenGroupAPIV2.BatchResponseTypes, on queue: DispatchQueue? = nil, error: Error? = nil) -> Promise { self.map(on: queue) { responseInfo, maybeData -> OpenGroupAPIV2.BatchResponse in // Need to split the data into an array of data so each item can be Decoded correctly @@ -83,6 +83,7 @@ extension Promise where T == (OnionRequestAPI.ResponseInfo, Data?) { do { return try zip(dataArray, types) .map { data, type in try type.decoded(from: data) } + .map { data in (responseInfo, data) } } catch let thrownError { throw (error ?? thrownError) diff --git a/SessionMessagingKit/Open Groups/Models/Capabilities.swift b/SessionMessagingKit/Open Groups/Models/Capabilities.swift index ab1cd3dab..b87873016 100644 --- a/SessionMessagingKit/Open Groups/Models/Capabilities.swift +++ b/SessionMessagingKit/Open Groups/Models/Capabilities.swift @@ -4,8 +4,8 @@ import Foundation extension OpenGroupAPIV2 { public struct Capabilities: Codable { - enum Capability: CaseIterable, Codable { - static var allCases: [Capability] { + public enum Capability: CaseIterable, Codable { + public static var allCases: [Capability] { [.pysogs] } @@ -16,7 +16,7 @@ extension OpenGroupAPIV2 { // MARK: - Convenience - var rawValue: String { + public var rawValue: String { switch self { case .unsupported(let originalValue): return originalValue default: return "\(self)" @@ -25,7 +25,7 @@ extension OpenGroupAPIV2 { // MARK: - Codable - init(from decoder: Decoder) throws { + public init(from decoder: Decoder) throws { let container: SingleValueDecodingContainer = try decoder.singleValueContainer() let valueString: String = try container.decode(String.self) let maybeValue: Capability? = Capability.allCases.first { $0.rawValue == valueString } @@ -34,7 +34,7 @@ extension OpenGroupAPIV2 { } } - let capabilities: [Capability] - let missing: [Capability]? + public let capabilities: [Capability] + public let missing: [Capability]? } } diff --git a/SessionMessagingKit/Open Groups/Models/UserBanRequest.swift b/SessionMessagingKit/Open Groups/Models/UserBanRequest.swift new file mode 100644 index 000000000..48144df8d --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/UserBanRequest.swift @@ -0,0 +1,11 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPIV2 { + struct UserBanRequest: Codable { + let rooms: [String]? + let global: Bool? + let timeout: TimeInterval? + } +} diff --git a/SessionMessagingKit/Open Groups/Models/UserDeleteMessagesRequest.swift b/SessionMessagingKit/Open Groups/Models/UserDeleteMessagesRequest.swift new file mode 100644 index 000000000..c4ac800c8 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/UserDeleteMessagesRequest.swift @@ -0,0 +1,10 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPIV2 { + struct UserDeleteMessagesRequest: Codable { + let rooms: [String]? + let global: Bool? + } +} diff --git a/SessionMessagingKit/Open Groups/Models/UserDeleteMessagesResponse.swift b/SessionMessagingKit/Open Groups/Models/UserDeleteMessagesResponse.swift new file mode 100644 index 000000000..68406a13a --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/UserDeleteMessagesResponse.swift @@ -0,0 +1,15 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPIV2 { + public struct UserDeleteMessagesResponse: Codable { + enum CodingKeys: String, CodingKey { + case id + case messagesDeleted = "messages_deleted" + } + + let id: String + let messagesDeleted: Int64 + } +} diff --git a/SessionMessagingKit/Open Groups/Models/UserModeratorRequest.swift b/SessionMessagingKit/Open Groups/Models/UserModeratorRequest.swift new file mode 100644 index 000000000..468e9e950 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/UserModeratorRequest.swift @@ -0,0 +1,69 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPIV2 { + struct UserModeratorRequest: Codable { + /// List of room tokens to which the moderator status should be applied. The invoking user must be an admin of all of the given rooms. + /// + /// This may be set to the single-element list ['*'] to add or remove the moderator from all rooms in which the current user has admin + /// permissions (the call will succeed if the calling user is an admin in at least one channel). + /// + /// Exclusive of `global`. (If you want to apply both at once use two calls, e.g. bundled in a batch request). + let rooms: [String]? + + /// If true then appoint this user as a global moderator or admin of the server. The user will receive moderator/admin ability in all rooms + /// on the server (both current and future). + /// + /// The caller must be a global admin to add/remove a global moderator or admin. + let global: Bool? + + /// If `true` then this user will be granted moderator permission to either the listed room(s) or the server globally. + /// + /// If `false` then this user will have their moderator *and admin* permissions removed from the given rooms (or server). Note + /// that removing a global moderator only removes the global permission but does not remove individual room moderator permissions + /// that may also be present. + /// + /// See the `admin` parameter description for information on how `admin` and `moderator` parameters interact. + let moderator: Bool + + /// If `true` then this user will be granted moderator and admin permissions to the given rooms or server. Admin permissions are + /// required to appoint new moderators or administrators and to alter room info such as the image, adding/removing pinned messages, + /// and changing the name/description of the room. + /// + /// If false then this user will have their admin permission removed, but will keep moderator permissions. To remove both moderator and + /// admin permissions specify `moderator: false` (which implies clearing admin permissions as well). + /// + /// Note that removing a global admin only removes the global permission but does not remove individual room admin permissions that + /// may also be present. + /// + /// The `admin`/`moderator` paramters interact as follows: + /// - `admin=true`, `moderator` omitted: this adds admin permissions, which automatically also implies moderator permissions. + /// - `admin=true, moderator=true`: exactly the same as above. + /// - `admin=false, moderator=true`: removes any existing admin permissions from the rooms (or globally), if present, and adds + /// moderator permissions to the rooms/globally (if not already present). + /// - `admin=false`, `moderator` omitted: this removes admin permissions but leaves moderator permissions, if present. (This + /// effectively "downgrades" an admin to a moderator). Unlike the above this does *not* add moderator permissions to matching rooms + /// if not already present. + /// - `moderator=true`, `admin` omitted: adds moderator permissions to the given rooms (or globally), if not already present. If + /// the user already has admin permissions this does nothing (that is, admin permission is *not* removed, unlike the above). + /// - `moderator=false`, `admin` omitted: this removes moderator *and* admin permissions from all given rooms (or globally). + /// - `moderator=false, admin=false`: exactly the same as above. + /// - `moderator=false, admin=true`: this combination is *not* *permitted* (because admin permissions imply moderator + /// permissions) and will result in Bad Request error if given. + let admin: Bool + + /// Whether this user should be a "visible" moderator or admin in the specified rooms (or globally). Visible moderators are identified to all + /// room users (e.g. via a special status badge in Session clients). + /// + /// Invisible moderators/admins have the same permission as as visible ones, but their moderator/admin status is only visible to other + /// moderators, not to ordinary room participants. + /// + /// The default if this field is omitted is true for room-specific moderators/admins and false for server-level global moderators/admins. + /// + /// If an admin or moderator has both global and room-specific moderation permissions then the visibility of the admin/mod for that + /// room's moderator/admin list will use the room-specific visibility value, regardless of the global setting. (This differs from + /// moderator/admin permissions themselves, which are additive). + let visible: Bool + } +} diff --git a/SessionMessagingKit/Open Groups/Models/UserPermissionsRequest.swift b/SessionMessagingKit/Open Groups/Models/UserPermissionsRequest.swift new file mode 100644 index 000000000..66bbe19a7 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/UserPermissionsRequest.swift @@ -0,0 +1,13 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPIV2 { + struct UserPermissionsRequest: Codable { + let rooms: [String] + let timeout: TimeInterval + let read: Bool + let write: Bool + let upload: Bool + } +} diff --git a/SessionMessagingKit/Open Groups/Models/UserUnbanRequest.swift b/SessionMessagingKit/Open Groups/Models/UserUnbanRequest.swift new file mode 100644 index 000000000..5e77485ee --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/UserUnbanRequest.swift @@ -0,0 +1,10 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPIV2 { + struct UserUnbanRequest: Codable { + let rooms: [String]? + let global: Bool? + } +} diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift b/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift index a4cf3231b..8d9cd0faf 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift @@ -29,7 +29,14 @@ public final class OpenGroupAPIV2: NSObject { // MARK: - Batching & Polling - public static func poll(_ server: String) -> Promise { + /// This is a convenience method which calls `/batch` with a pre-defined set of requests used to update an Open Group + public static func poll( + _ server: String, + through api: OnionRequestAPIType.Type = OnionRequestAPI.self, + using storage: SessionMessagingKitStorageProtocol = SNMessagingKitConfiguration.shared.storage, + nonceGenerator: NonceGenerator16ByteType = NonceGenerator16Byte(), + date: Date = Date() + ) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable)]> { // TODO: Remove comments // Capabilities // Fetch each room @@ -37,7 +44,7 @@ public final class OpenGroupAPIV2: NSObject { // /room//pollInfo/ instead? // Fetch messages for each room // /room/{roomToken}/messages/since/{messageSequence}: - // Fetch deletions for each room (included in messages) + // Fetch deletions for each room (included in messages) // old compact_poll data // public let room: String @@ -46,7 +53,6 @@ public final class OpenGroupAPIV2: NSObject { // public let deletions: [Deletion]? // public let moderators: [String]? - let storage: SessionMessagingKitStorageProtocol = SNMessagingKitConfiguration.shared.storage let requestResponseType: [BatchRequestInfo] = [ BatchRequestInfo( request: Request( @@ -59,7 +65,7 @@ public final class OpenGroupAPIV2: NSObject { ] .appending( storage.getAllV2OpenGroups().values - .filter { $0.server == server } + .filter { $0.server == server.lowercased() } // Note: The `OpenGroupV2` converts the server value to lowercase during init .flatMap { openGroup -> [BatchRequestInfo] in let lastSeqNo: Int64? = storage.getLastMessageServerID(for: openGroup.room, on: server) let targetSeqNo: Int64 = (lastSeqNo ?? 0) @@ -88,11 +94,22 @@ public final class OpenGroupAPIV2: NSObject { ) // TODO: Handle response (maybe in the poller or the OpenGroupManagerV2?) - return batch(server, requests: requestResponseType) - .map { _ in () } + return batch(server, requests: requestResponseType, through: api, using: storage, nonceGenerator: nonceGenerator, date: date) } - private static func batch(_ server: String, requests: [BatchRequestInfo]) -> Promise { + /// This is used, for example, to poll multiple rooms on the same server for updates in a single query rather than needing to make multiple requests for each room. + /// + /// No guarantee is made as to the order in which sub-requests are processed; use the `/sequence` instead if you need that. + /// + /// For contained subrequests that specify a body (i.e. POST or PUT requests) exactly one of `json`, `b64`, or `bytes` must be provided with the request body. + private static func batch( + _ server: String, + requests: [BatchRequestInfo], + through api: OnionRequestAPIType.Type = OnionRequestAPI.self, + using storage: SessionMessagingKitStorageProtocol = SNMessagingKitConfiguration.shared.storage, + nonceGenerator: NonceGenerator16ByteType = NonceGenerator16Byte(), + date: Date = Date() + ) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable)]> { let requestBody: BatchRequest = requests.map { BatchSubRequest(request: $0.request) } let responseTypes = requests.map { $0.responseType } @@ -107,14 +124,19 @@ public final class OpenGroupAPIV2: NSObject { body: body ) - return send(request) + return send(request, through: api, using: storage, nonceGenerator: nonceGenerator, date: date) .decoded(as: responseTypes, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) .map { result in - return "" + result.enumerated() + .reduce(into: [:]) { prev, next in + prev[requests[next.offset].request.endpoint] = next.element + } } } - public static func compactPoll(_ server: String) -> Promise { + // TODO: `/sequence` request + + public static func compactPoll(_ server: String, api: OnionRequestAPIType.Type = OnionRequestAPI.self) -> Promise { let storage: SessionMessagingKitStorageProtocol = SNMessagingKitConfiguration.shared.storage let rooms: [String] = storage.getAllV2OpenGroups().values .filter { $0.server == server } @@ -155,7 +177,7 @@ public final class OpenGroupAPIV2: NSObject { body: body ) - return send(request) + return send(request, through: api) .then(on: OpenGroupAPIV2.workQueue) { _, maybeData -> Promise in guard let data: Data = maybeData else { throw Error.parsingFailed } @@ -173,101 +195,39 @@ public final class OpenGroupAPIV2: NSObject { } } - // MARK: - Authentication - - // TODO: Turn 'Sodium' and 'NonceGenerator16Byte' into protocols for unit testing. - static func sign( - _ request: URLRequest, - with publicKey: String, - sodium: Sodium = Sodium(), - nonceGenerator: NonceGenerator16Byte = NonceGenerator16Byte() - ) -> URLRequest? { - guard let url: URL = request.url else { return nil } - - var updatedRequest: URLRequest = request - let path: String = url.path - .appending(url.query.map { value in "?\(value)" }) - let method: String = (request.httpMethod ?? "GET") - let timestamp: Int = Int(floor(Date().timeIntervalSince1970)) - let nonce: Data = Data(nonceGenerator.nonce()) - - guard let publicKeyData: Data = publicKey.dataFromHex() else { return nil } - guard let userKeyPair: ECKeyPair = SNMessagingKitConfiguration.shared.storage.getUserKeyPair() else { - return nil - } -// guard let blindedKeyPair: ECKeyPair = try? userKeyPair.convert(to: .blinded, with: publicKey) else { -// return nil -// } - // TODO: Change this back once you figure out why it's busted - let blindedKeyPair: ECKeyPair = userKeyPair - - // Generate the sharedSecret by "aB || A || B" where - // a, A are the users private and public keys respectively, - // B is the SOGS public key - let maybeSharedSecret: Data? = sodium.sharedSecret(blindedKeyPair.privateKey.bytes, publicKeyData.bytes)? - .appending(blindedKeyPair.publicKey) - .appending(publicKeyData.bytes) - - // Generate the hash to be sent along with the request - // intermediateHash = Blake2B(sharedSecret, size=42, salt=noncebytes, person='sogs.shared_keys') - // secretHash = Blake2B( - // Method || Path || Timestamp || Body, - // size=42, - // key=r, - // salt=noncebytes, - // person='sogs.auth_header' - // ) - let secretHashMessage: Bytes = method.bytes - .appending(path.bytes) - .appending("\(timestamp)".bytes) - .appending(request.httpBody?.bytes ?? []) // TODO: Might need to do the 'httpBodyStream' as well??? - - guard let sharedSecret: Data = maybeSharedSecret else { return nil } - guard let intermediateHash: Bytes = sodium.genericHash.hashSaltPersonal(message: sharedSecret.bytes, outputLength: 42, key: nil, salt: nonce.bytes, personal: Personalization.sharedKeys.bytes) else { - return nil - } - guard let secretHash: Bytes = sodium.genericHash.hashSaltPersonal(message: secretHashMessage, outputLength: 42, key: intermediateHash, salt: nonce.bytes, personal: Personalization.authHeader.bytes) else { - return nil - } - - updatedRequest.allHTTPHeaderFields = (request.allHTTPHeaderFields ?? [:]) - .updated(with: [ - Header.sogsPubKey.rawValue: blindedKeyPair.hexEncodedPublicKey, - Header.sogsTimestamp.rawValue: "\(timestamp)", - Header.sogsNonce.rawValue: nonce.base64EncodedString(), - Header.sogsHash.rawValue: secretHash.toBase64() - ]) - - return updatedRequest - } - // MARK: - Capabilities - public static func capabilities(on server: String) -> Promise<(OnionRequestAPI.ResponseInfo, Capabilities)> { + public static func capabilities(on server: String) -> Promise<(OnionRequestResponseInfoType, Capabilities)> { let request: Request = Request( server: server, endpoint: .capabilities, - queryParameters: [:] // TODO: Add any requirements '.required' + queryParameters: [:] // TODO: Add any requirements '.required'. ) - // TODO: Handle a `412` response (ie. a required capability isn't supported) + // TODO: Handle a `412` response (ie. a required capability isn't supported). return send(request) .decoded(as: Capabilities.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) } // MARK: - Room - public static func rooms(for server: String) -> Promise<(OnionRequestAPI.ResponseInfo, [Room])> { + public static func rooms( + for server: String, + through api: OnionRequestAPIType.Type = OnionRequestAPI.self, + using storage: SessionMessagingKitStorageProtocol = SNMessagingKitConfiguration.shared.storage, + nonceGenerator: NonceGenerator16ByteType = NonceGenerator16Byte(), + date: Date = Date() + ) -> Promise<(OnionRequestResponseInfoType, [Room])> { let request: Request = Request( server: server, endpoint: .rooms ) - return send(request) + return send(request, through: api, using: storage, nonceGenerator: nonceGenerator, date: date) .decoded(as: [Room].self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) } - public static func room(for roomToken: String, on server: String) -> Promise<(OnionRequestAPI.ResponseInfo, Room)> { + public static func room(for roomToken: String, on server: String) -> Promise<(OnionRequestResponseInfoType, Room)> { let request: Request = Request( server: server, endpoint: .room(roomToken) @@ -277,7 +237,7 @@ public final class OpenGroupAPIV2: NSObject { .decoded(as: Room.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) } - public static func roomPollInfo(lastUpdated: Int64, for roomToken: String, on server: String) -> Promise<(OnionRequestAPI.ResponseInfo, RoomPollInfo)> { + public static func roomPollInfo(lastUpdated: Int64, for roomToken: String, on server: String) -> Promise<(OnionRequestResponseInfoType, RoomPollInfo)> { let request: Request = Request( server: server, endpoint: .roomPollInfo(roomToken, lastUpdated) @@ -296,8 +256,8 @@ public final class OpenGroupAPIV2: NSObject { whisperTo: String?, whisperMods: Bool, with serverPublicKey: String - ) -> Promise<(OnionRequestAPI.ResponseInfo, Message)> { - // TODO: Change this to use '.blinded' once it's working + ) -> Promise<(OnionRequestResponseInfoType, Message)> { + // TODO: Change this to use '.blinded' once it's working. guard let signedRequest: (data: Data, signature: Data) = SendMessageRequest.sign(message: plaintext, for: .standard, with: serverPublicKey) else { return Promise(error: Error.signingFailed) } @@ -325,7 +285,7 @@ public final class OpenGroupAPIV2: NSObject { .decoded(as: Message.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) } - public static func recentMessages(in roomToken: String, on server: String) -> Promise<(OnionRequestAPI.ResponseInfo, [Message])> { + public static func recentMessages(in roomToken: String, on server: String) -> Promise<(OnionRequestResponseInfoType, [Message])> { // TODO: Recent vs. Since? let request: Request = Request( server: server, @@ -338,13 +298,13 @@ public final class OpenGroupAPIV2: NSObject { return send(request) .decoded(as: [Message].self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) - .then(on: OpenGroupAPIV2.workQueue) { responseInfo, messages -> Promise<(OnionRequestAPI.ResponseInfo, [Message])> in + .then(on: OpenGroupAPIV2.workQueue) { responseInfo, messages -> Promise<(OnionRequestResponseInfoType, [Message])> in process(messages: messages, for: roomToken, on: server) .map { processedMessages in (responseInfo, processedMessages) } } } - public static func messagesBefore(messageId: Int64, in roomToken: String, on server: String) -> Promise<(OnionRequestAPI.ResponseInfo, [Message])> { + public static func messagesBefore(messageId: Int64, in roomToken: String, on server: String) -> Promise<(OnionRequestResponseInfoType, [Message])> { // TODO: Recent vs. Since? let request: Request = Request( server: server, @@ -357,13 +317,13 @@ public final class OpenGroupAPIV2: NSObject { return send(request) .decoded(as: [Message].self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) - .then(on: OpenGroupAPIV2.workQueue) { responseInfo, messages -> Promise<(OnionRequestAPI.ResponseInfo, [Message])> in + .then(on: OpenGroupAPIV2.workQueue) { responseInfo, messages -> Promise<(OnionRequestResponseInfoType, [Message])> in process(messages: messages, for: roomToken, on: server) .map { processedMessages in (responseInfo, processedMessages) } } } - public static func messagesSince(seqNo: Int64, in roomToken: String, on server: String) -> Promise<(OnionRequestAPI.ResponseInfo, [Message])> { + public static func messagesSince(seqNo: Int64, in roomToken: String, on server: String) -> Promise<(OnionRequestResponseInfoType, [Message])> { // TODO: Recent vs. Since? let request: Request = Request( server: server, @@ -376,7 +336,7 @@ public final class OpenGroupAPIV2: NSObject { return send(request) .decoded(as: [Message].self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) - .then(on: OpenGroupAPIV2.workQueue) { responseInfo, messages -> Promise<(OnionRequestAPI.ResponseInfo, [Message])> in + .then(on: OpenGroupAPIV2.workQueue) { responseInfo, messages -> Promise<(OnionRequestResponseInfoType, [Message])> in process(messages: messages, for: roomToken, on: server) .map { processedMessages in (responseInfo, processedMessages) } } @@ -384,7 +344,7 @@ public final class OpenGroupAPIV2: NSObject { // MARK: - Pinning - public static func pinMessage(id: Int64, in roomToken: String, on server: String) -> Promise { + public static func pinMessage(id: Int64, in roomToken: String, on server: String) -> Promise { let request: Request = Request( method: .post, server: server, @@ -395,7 +355,7 @@ public final class OpenGroupAPIV2: NSObject { .map { responseInfo, _ in responseInfo } } - public static func unpinMessage(id: Int64, in roomToken: String, on server: String) -> Promise { + public static func unpinMessage(id: Int64, in roomToken: String, on server: String) -> Promise { let request: Request = Request( method: .post, server: server, @@ -406,7 +366,7 @@ public final class OpenGroupAPIV2: NSObject { .map { responseInfo, _ in responseInfo } } - public static func unpinAll(in roomToken: String, on server: String) -> Promise { + public static func unpinAll(in roomToken: String, on server: String) -> Promise { let request: Request = Request( method: .post, server: server, @@ -458,7 +418,7 @@ public final class OpenGroupAPIV2: NSObject { return promise } - public static func uploadFile(_ bytes: [UInt8], fileName: String? = nil, to roomToken: String, on server: String) -> Promise<(OnionRequestAPI.ResponseInfo, FileUploadResponse)> { + public static func uploadFile(_ bytes: [UInt8], fileName: String? = nil, to roomToken: String, on server: String) -> Promise<(OnionRequestResponseInfoType, FileUploadResponse)> { let request: Request = Request( method: .post, server: server, @@ -473,7 +433,7 @@ public final class OpenGroupAPIV2: NSObject { /// Warning: This approach is less efficient as it expects the data to be base64Encoded (with is 33% larger than binary), please use the binary approach /// whenever possible - public static func uploadFile(_ base64EncodedString: String, fileName: String? = nil, to roomToken: String, on server: String) -> Promise<(OnionRequestAPI.ResponseInfo, FileUploadResponse)> { + public static func uploadFile(_ base64EncodedString: String, fileName: String? = nil, to roomToken: String, on server: String) -> Promise<(OnionRequestResponseInfoType, FileUploadResponse)> { let request: Request = Request( method: .post, server: server, @@ -486,7 +446,7 @@ public final class OpenGroupAPIV2: NSObject { .decoded(as: FileUploadResponse.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) } - public static func downloadFile(_ fileId: Int64, from roomToken: String, on server: String) -> Promise<(OnionRequestAPI.ResponseInfo, Data)> { + public static func downloadFile(_ fileId: Int64, from roomToken: String, on server: String) -> Promise<(OnionRequestResponseInfoType, Data)> { let request: Request = Request( server: server, endpoint: .roomFileIndividual(roomToken, fileId) @@ -500,7 +460,7 @@ public final class OpenGroupAPIV2: NSObject { } } - public static func downloadFileJson(_ fileId: Int64, from roomToken: String, on server: String) -> Promise<(OnionRequestAPI.ResponseInfo, FileDownloadResponse)> { + public static func downloadFileJson(_ fileId: Int64, from roomToken: String, on server: String) -> Promise<(OnionRequestResponseInfoType, FileDownloadResponse)> { let request: Request = Request( server: server, endpoint: .roomFileIndividualJson(roomToken, fileId) @@ -510,6 +470,116 @@ public final class OpenGroupAPIV2: NSObject { .decoded(as: FileDownloadResponse.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) } + // MARK: - Users + + public static func userBan(_ sessionId: String, for timeout: TimeInterval? = nil, from roomTokens: [String]? = nil, on server: String) -> Promise<(OnionRequestResponseInfoType, Data?)> { + let requestBody: UserBanRequest = UserBanRequest( + rooms: roomTokens, + global: (roomTokens == nil ? true : nil), + timeout: timeout + ) + + guard let body: Data = try? JSONEncoder().encode(requestBody) else { + return Promise(error: Error.parsingFailed) + } + + let request: Request = Request( + method: .post, + server: server, + endpoint: .userBan(sessionId), + body: body + ) + + return send(request) + } + + public static func userUnban(_ sessionId: String, from roomTokens: [String]? = nil, on server: String) -> Promise<(OnionRequestResponseInfoType, Data?)> { + let requestBody: UserUnbanRequest = UserUnbanRequest( + rooms: roomTokens, + global: (roomTokens == nil ? true : nil) + ) + + guard let body: Data = try? JSONEncoder().encode(requestBody) else { + return Promise(error: Error.parsingFailed) + } + + let request: Request = Request( + method: .post, + server: server, + endpoint: .userUnban(sessionId), + body: body + ) + + return send(request) + } + + public static func userPermissionUpdate(_ sessionId: String, read: Bool, write: Bool, upload: Bool, for roomTokens: [String], timeout: TimeInterval, on server: String) -> Promise<(OnionRequestResponseInfoType, Data?)> { + let requestBody: UserPermissionsRequest = UserPermissionsRequest( + rooms: roomTokens, + timeout: timeout, + read: read, + write: write, + upload: upload + ) + + guard let body: Data = try? JSONEncoder().encode(requestBody) else { + return Promise(error: Error.parsingFailed) + } + + let request: Request = Request( + method: .post, + server: server, + endpoint: .userPermission(sessionId), + body: body + ) + + return send(request) + } + + public static func userModeratorUpdate(_ sessionId: String, moderator: Bool, admin: Bool, visible: Bool, for roomTokens: [String]? = nil, on server: String) -> Promise<(OnionRequestResponseInfoType, Data?)> { + let requestBody: UserModeratorRequest = UserModeratorRequest( + rooms: roomTokens, + global: (roomTokens == nil ? true : nil), + moderator: moderator, + admin: admin, + visible: visible + ) + + guard let body: Data = try? JSONEncoder().encode(requestBody) else { + return Promise(error: Error.parsingFailed) + } + + let request: Request = Request( + method: .post, + server: server, + endpoint: .userModerator(sessionId), + body: body + ) + + return send(request) + } + + public static func userDeleteMessages(_ sessionId: String, for roomTokens: [String]? = nil, on server: String) -> Promise<(OnionRequestResponseInfoType, UserDeleteMessagesResponse)> { + let requestBody: UserDeleteMessagesRequest = UserDeleteMessagesRequest( + rooms: roomTokens, + global: (roomTokens == nil ? true : nil) + ) + + guard let body: Data = try? JSONEncoder().encode(requestBody) else { + return Promise(error: Error.parsingFailed) + } + + let request: Request = Request( + method: .post, + server: server, + endpoint: .userDeleteMessages(sessionId), + body: body + ) + + return send(request) + .decoded(as: UserDeleteMessagesResponse.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) + } + // MARK: - Processing // TODO: Move these methods to the OpenGroupManager? (seems odd for them to be in the API) @@ -599,9 +669,85 @@ public final class OpenGroupAPIV2: NSObject { ) } + // MARK: - Authentication + + // TODO: Turn 'Sodium' into a protocol for unit testing + static func sign( + _ request: URLRequest, + with publicKey: String, + using storage: SessionMessagingKitStorageProtocol = SNMessagingKitConfiguration.shared.storage, + sodium: Sodium = Sodium(), + nonceGenerator: NonceGenerator16ByteType = NonceGenerator16Byte(), + date: Date = Date() + ) -> URLRequest? { + guard let url: URL = request.url else { return nil } + + var updatedRequest: URLRequest = request + let path: String = url.path + .appending(url.query.map { value in "?\(value)" }) + let method: String = (request.httpMethod ?? "GET") + let timestamp: Int = Int(floor(date.timeIntervalSince1970)) + let nonce: Data = Data(nonceGenerator.nonce()) + + guard let publicKeyData: Data = publicKey.dataFromHex() else { return nil } + guard let userKeyPair: ECKeyPair = storage.getUserKeyPair() else { + return nil + } +// guard let blindedKeyPair: ECKeyPair = try? userKeyPair.convert(to: .blinded, with: publicKey) else { +// return nil +// } + // TODO: Change this back once you figure out why it's busted + let blindedKeyPair: ECKeyPair = userKeyPair + + /// Generate the sharedSecret by "aB || A || B" where + /// a, A are the users private and public keys respectively, + /// B is the SOGS public key + let maybeSharedSecret: Data? = sodium.sharedSecret(blindedKeyPair.privateKey.bytes, publicKeyData.bytes)? + .appending(blindedKeyPair.publicKey) + .appending(publicKeyData.bytes) + + /// Generate the hash to be sent along with the request + /// intermediateHash = Blake2B(sharedSecret, size=42, salt=noncebytes, person='sogs.shared_keys') + /// secretHash = Blake2B( + /// Method || Path || Timestamp || Body, + /// size=42, + /// key=r, + /// salt=noncebytes, + /// person='sogs.auth_header' + /// ) + let secretHashMessage: Bytes = method.bytes + .appending(path.bytes) + .appending("\(timestamp)".bytes) + .appending(request.httpBody?.bytes ?? []) // TODO: Might need to do the 'httpBodyStream' as well??? + + guard let sharedSecret: Data = maybeSharedSecret else { return nil } + guard let intermediateHash: Bytes = sodium.genericHash.hashSaltPersonal(message: sharedSecret.bytes, outputLength: 42, key: nil, salt: nonce.bytes, personal: Personalization.sharedKeys.bytes) else { + return nil + } + guard let secretHash: Bytes = sodium.genericHash.hashSaltPersonal(message: secretHashMessage, outputLength: 42, key: intermediateHash, salt: nonce.bytes, personal: Personalization.authHeader.bytes) else { + return nil + } + + updatedRequest.allHTTPHeaderFields = (request.allHTTPHeaderFields ?? [:]) + .updated(with: [ + Header.sogsPubKey.rawValue: blindedKeyPair.hexEncodedPublicKey, + Header.sogsTimestamp.rawValue: "\(timestamp)", + Header.sogsNonce.rawValue: nonce.base64EncodedString(), + Header.sogsHash.rawValue: secretHash.toBase64() + ]) + + return updatedRequest + } + // MARK: - Convenience - private static func send(_ request: Request, through api: OnionRequestAPIType.Type = OnionRequestAPI.self) -> Promise<(OnionRequestAPI.ResponseInfo, Data?)> { + private static func send( + _ request: Request, + through api: OnionRequestAPIType.Type = OnionRequestAPI.self, + using storage: SessionMessagingKitStorageProtocol = SNMessagingKitConfiguration.shared.storage, + nonceGenerator: NonceGenerator16ByteType = NonceGenerator16Byte(), + date: Date = Date() + ) -> Promise<(OnionRequestResponseInfoType, Data?)> { guard let url: URL = request.url else { return Promise(error: Error.invalidURL) } var urlRequest: URLRequest = URLRequest(url: url) @@ -612,21 +758,21 @@ public final class OpenGroupAPIV2: NSObject { urlRequest.httpBody = request.body if request.useOnionRouting { - guard let publicKey = SNMessagingKitConfiguration.shared.storage.getOpenGroupPublicKey(for: request.server) else { + guard let publicKey = storage.getOpenGroupPublicKey(for: request.server) else { return Promise(error: Error.noPublicKey) } if request.isAuthRequired { // Attempt to sign the request with the new auth - guard let signedRequest: URLRequest = sign(urlRequest, with: publicKey) else { + guard let signedRequest: URLRequest = sign(urlRequest, with: publicKey, using: storage, nonceGenerator: nonceGenerator, date: date) else { return Promise(error: Error.signingFailed) } // TODO: 'removeAuthToken' as a migration??? (would previously do this when getting a `401`). - return OnionRequestAPI.sendOnionRequest(signedRequest, to: request.server, using: publicKey) + return api.sendOnionRequest(signedRequest, to: request.server, with: publicKey) } - return OnionRequestAPI.sendOnionRequest(urlRequest, to: request.server, using: publicKey) + return api.sendOnionRequest(urlRequest, to: request.server, with: publicKey) } preconditionFailure("It's currently not allowed to send non onion routed requests.") @@ -641,6 +787,7 @@ public final class OpenGroupAPIV2: NSObject { // MARK: -- Legacy Auth + @available(*, deprecated, message: "Use request signing instead") private static func legacyGetAuthToken(for room: String, on server: String) -> Promise { let storage = SNMessagingKitConfiguration.shared.storage @@ -676,6 +823,7 @@ public final class OpenGroupAPIV2: NSObject { return promise } + @available(*, deprecated, message: "Use request signing instead") public static func legacyRequestNewAuthToken(for room: String, on server: String) -> Promise { SNLog("Requesting auth token for server: \(server).") guard let userKeyPair: ECKeyPair = SNMessagingKitConfiguration.shared.storage.getUserKeyPair() else { @@ -705,6 +853,7 @@ public final class OpenGroupAPIV2: NSObject { } } + @available(*, deprecated, message: "Use request signing instead") public static func legacyClaimAuthToken(_ authToken: String, for room: String, on server: String) -> Promise { let requestBody: PublicKeyBody = PublicKeyBody(publicKey: getUserHexEncodedPublicKey()) @@ -729,6 +878,7 @@ public final class OpenGroupAPIV2: NSObject { } /// Should be called when leaving a group. + @available(*, deprecated, message: "Use request signing instead") public static func legacyDeleteAuthToken(for room: String, on server: String) -> Promise { let request: Request = Request( method: .delete, @@ -748,6 +898,7 @@ public final class OpenGroupAPIV2: NSObject { // MARK: -- Legacy Requests + @available(*, deprecated, message: "Use poll or batch instead") public static func legacyCompactPoll(_ server: String) -> Promise { let storage: SessionMessagingKitStorageProtocol = SNMessagingKitConfiguration.shared.storage let rooms: [String] = storage.getAllV2OpenGroups().values @@ -841,6 +992,7 @@ public final class OpenGroupAPIV2: NSObject { } } + @available(*, deprecated, message: "Use getDefaultRoomsIfNeeded instead") public static func legacyGetDefaultRoomsIfNeeded() { Storage.shared.write( with: { transaction in @@ -861,6 +1013,7 @@ public final class OpenGroupAPIV2: NSObject { ) } + @available(*, deprecated, message: "Use rooms(for:) instead") public static func legacyGetAllRooms(from server: String) -> Promise<[LegacyRoomInfo]> { let request: Request = Request( server: server, @@ -877,6 +1030,7 @@ public final class OpenGroupAPIV2: NSObject { } } + @available(*, deprecated, message: "Use room(for:on:) instead") public static func legacyGetRoomInfo(for room: String, on server: String) -> Promise { let request: Request = Request( server: server, @@ -894,6 +1048,7 @@ public final class OpenGroupAPIV2: NSObject { } } + @available(*, deprecated, message: "Use roomImage(_:for:on:) instead") public static func legacyGetGroupImage(for room: String, on server: String) -> Promise { // Normally the image for a given group is stored with the group thread, so it's only // fetched once. However, on the join open group screen we show images for groups the @@ -942,6 +1097,7 @@ public final class OpenGroupAPIV2: NSObject { return promise } + @available(*, deprecated, message: "Use room(for:on:) instead") public static func legacyGetMemberCount(for room: String, on server: String) -> Promise { let request: Request = Request( server: server, @@ -965,6 +1121,7 @@ public final class OpenGroupAPIV2: NSObject { // MARK: - Legacy File Storage + @available(*, deprecated, message: "Use uploadFile(_:fileName:to:on:) instead") public static func legacyUpload(_ file: Data, to room: String, on server: String) -> Promise { let requestBody: FileUploadBody = FileUploadBody(file: file.base64EncodedString()) @@ -982,6 +1139,7 @@ public final class OpenGroupAPIV2: NSObject { } } + @available(*, deprecated, message: "Use downloadFile(_:from:on:) instead") public static func legacyDownload(_ file: UInt64, from room: String, on server: String) -> Promise { let request = Request(server: server, room: room, endpoint: .legacyFile(file)) @@ -995,6 +1153,7 @@ public final class OpenGroupAPIV2: NSObject { // MARK: - Legacy Message Sending & Receiving + @available(*, deprecated, message: "Use send(_:to:on:whisperTo:whisperMods:with:) instead") public static func legacySend(_ message: OpenGroupMessageV2, to room: String, on server: String, with publicKey: String) -> Promise { guard let signedMessage = message.sign(with: publicKey) else { return Promise(error: Error.signingFailed) } guard let body: Data = try? JSONEncoder().encode(signedMessage) else { @@ -1012,6 +1171,7 @@ public final class OpenGroupAPIV2: NSObject { } } + @available(*, deprecated, message: "Use recentMessages(in:on:) or messagesSince(seqNo:in:on:) instead") public static func legacyGetMessages(for room: String, on server: String) -> Promise<[OpenGroupMessageV2]> { let storage = SNMessagingKitConfiguration.shared.storage let request: Request = Request( @@ -1033,6 +1193,8 @@ public final class OpenGroupAPIV2: NSObject { // MARK: - Legacy Message Deletion + // TODO: No delete method???? + @available(*, deprecated, message: "Use v4 endpoint instead") public static func legacyDeleteMessage(with serverID: Int64, from room: String, on server: String) -> Promise { let request: Request = Request( method: .delete, @@ -1044,6 +1206,7 @@ public final class OpenGroupAPIV2: NSObject { return legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _ in } } + @available(*, deprecated, message: "Use v4 endpoint instead") public static func legacyGetDeletedMessages(for room: String, on server: String) -> Promise<[Deletion]> { let storage = SNMessagingKitConfiguration.shared.storage @@ -1066,6 +1229,7 @@ public final class OpenGroupAPIV2: NSObject { // MARK: - Legacy Moderation + @available(*, deprecated, message: "Use v4 endpoint instead") public static func legacyGetModerators(for room: String, on server: String) -> Promise<[String]> { let request: Request = Request( server: server, @@ -1090,6 +1254,7 @@ public final class OpenGroupAPIV2: NSObject { } } + @available(*, deprecated, message: "Use v4 endpoint instead") public static func legacyBan(_ publicKey: String, from room: String, on server: String) -> Promise { let requestBody: PublicKeyBody = PublicKeyBody(publicKey: getUserHexEncodedPublicKey()) @@ -1108,6 +1273,7 @@ public final class OpenGroupAPIV2: NSObject { return legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _ in } } + @available(*, deprecated, message: "Use v4 endpoint instead") public static func legacyBanAndDeleteAllMessages(_ publicKey: String, from room: String, on server: String) -> Promise { let requestBody: PublicKeyBody = PublicKeyBody(publicKey: getUserHexEncodedPublicKey()) @@ -1126,6 +1292,7 @@ public final class OpenGroupAPIV2: NSObject { return legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _ in } } + @available(*, deprecated, message: "Use v4 endpoint instead") public static func legacyUnban(_ publicKey: String, from room: String, on server: String) -> Promise { let request: Request = Request( method: .delete, @@ -1140,6 +1307,7 @@ public final class OpenGroupAPIV2: NSObject { // MARK: - Processing // TODO: Move these methods to the OpenGroupManager? (seems odd for them to be in the API) + @available(*, deprecated, message: "Use v4 endpoint instead") private static func legacyProcess(messages: [OpenGroupMessageV2]?, for room: String, on server: String) -> Promise<[OpenGroupMessageV2]> { guard let messages: [OpenGroupMessageV2] = messages, !messages.isEmpty else { return Promise.value([]) } @@ -1165,6 +1333,7 @@ public final class OpenGroupAPIV2: NSObject { return Promise.value(messages) } + @available(*, deprecated, message: "Use v4 endpoint instead") private static func legacyProcess(deletions: [Deletion]?, for room: String, on server: String) -> Promise<[Deletion]> { guard let deletions: [Deletion] = deletions else { return Promise.value([]) } @@ -1192,7 +1361,8 @@ public final class OpenGroupAPIV2: NSObject { // MARK: - Legacy Convenience - private static func legacySend(_ request: Request, through api: OnionRequestAPIType.Type = LegacyOnionRequestAPI.self) -> Promise<(OnionRequestAPI.ResponseInfo, Data?)> { + @available(*, deprecated, message: "Use v4 endpoint instead") + private static func legacySend(_ request: Request, through api: OnionRequestAPIType.Type = OnionRequestAPI.self) -> Promise<(OnionRequestResponseInfoType, Data?)> { guard let url: URL = request.url else { return Promise(error: Error.invalidURL) } var urlRequest: URLRequest = URLRequest(url: url) @@ -1211,14 +1381,14 @@ public final class OpenGroupAPIV2: NSObject { // Because legacy auth happens on a per-room basis, we need to have a room to // make an authenticated request guard let room = request.room else { - return api.sendOnionRequest(urlRequest, to: request.server, target: "/loki/v3/lsrpc", using: publicKey) + return api.sendOnionRequest(urlRequest, to: request.server, using: .v3, with: publicKey) } return legacyGetAuthToken(for: room, on: request.server) - .then(on: OpenGroupAPIV2.workQueue) { authToken -> Promise<(OnionRequestAPI.ResponseInfo, Data?)> in + .then(on: OpenGroupAPIV2.workQueue) { authToken -> Promise<(OnionRequestResponseInfoType, Data?)> in urlRequest.setValue(authToken, forHTTPHeaderField: Header.authorization.rawValue) - let promise = api.sendOnionRequest(urlRequest, to: request.server, target: "/loki/v3/lsrpc", using: publicKey) + let promise = api.sendOnionRequest(urlRequest, to: request.server, using: .v3, with: publicKey) promise.catch(on: OpenGroupAPIV2.workQueue) { error in // A 401 means that we didn't provide a (valid) auth token for a route // that required one. We use this as an indication that the token we're @@ -1238,7 +1408,7 @@ public final class OpenGroupAPIV2: NSObject { } } - return api.sendOnionRequest(urlRequest, to: request.server, target: "/loki/v3/lsrpc", using: publicKey) + return api.sendOnionRequest(urlRequest, to: request.server, using: .v3, with: publicKey) } preconditionFailure("It's currently not allowed to send non onion routed requests.") diff --git a/SessionMessagingKit/Open Groups/Types/Endpoint.swift b/SessionMessagingKit/Open Groups/Types/Endpoint.swift index aa4a55ca1..7d8514571 100644 --- a/SessionMessagingKit/Open Groups/Types/Endpoint.swift +++ b/SessionMessagingKit/Open Groups/Types/Endpoint.swift @@ -2,7 +2,7 @@ import Foundation -enum Endpoint { +public enum Endpoint: Hashable { // Utility case onion @@ -47,28 +47,28 @@ enum Endpoint { // Legacy endpoints (to be deprecated and removed) - case legacyFiles - case legacyFile(UInt64) + @available(*, deprecated, message: "Use v4 endpoint") case legacyFiles + @available(*, deprecated, message: "Use v4 endpoint") case legacyFile(UInt64) - case legacyMessages - case legacyMessagesForServer(Int64) - case legacyDeletedMessages + @available(*, deprecated, message: "Use v4 endpoint") case legacyMessages + @available(*, deprecated, message: "Use v4 endpoint") case legacyMessagesForServer(Int64) + @available(*, deprecated, message: "Use v4 endpoint") case legacyDeletedMessages - case legacyModerators + @available(*, deprecated, message: "Use v4 endpoint") case legacyModerators - case legacyBlockList - case legacyBlockListIndividual(String) - case legacyBanAndDeleteAll + @available(*, deprecated, message: "Use v4 endpoint") case legacyBlockList + @available(*, deprecated, message: "Use v4 endpoint") case legacyBlockListIndividual(String) + @available(*, deprecated, message: "Use v4 endpoint") case legacyBanAndDeleteAll - case legacyCompactPoll(legacyAuth: Bool) - case legacyAuthToken(legacyAuth: Bool) - case legacyAuthTokenChallenge(legacyAuth: Bool) - case legacyAuthTokenClaim(legacyAuth: Bool) + @available(*, deprecated, message: "Use v4 endpoint") case legacyCompactPoll(legacyAuth: Bool) + @available(*, deprecated, message: "Use request signing") case legacyAuthToken(legacyAuth: Bool) + @available(*, deprecated, message: "Use request signing") case legacyAuthTokenChallenge(legacyAuth: Bool) + @available(*, deprecated, message: "Use request signing") case legacyAuthTokenClaim(legacyAuth: Bool) - case legacyRooms - case legacyRoomInfo(String) - case legacyRoomImage(String) - case legacyMemberCount(legacyAuth: Bool) + @available(*, deprecated, message: "Use v4 endpoint") case legacyRooms + @available(*, deprecated, message: "Use v4 endpoint") case legacyRoomInfo(String) + @available(*, deprecated, message: "Use v4 endpoint") case legacyRoomImage(String) + @available(*, deprecated, message: "Use v4 endpoint") case legacyMemberCount(legacyAuth: Bool) var path: String { switch self { diff --git a/SessionMessagingKit/Open Groups/Types/NonceGenerator16Byte.swift b/SessionMessagingKit/Open Groups/Types/NonceGenerator16Byte.swift index e62a1c974..b4c5cb2e1 100644 --- a/SessionMessagingKit/Open Groups/Types/NonceGenerator16Byte.swift +++ b/SessionMessagingKit/Open Groups/Types/NonceGenerator16Byte.swift @@ -2,8 +2,18 @@ import Sodium +public protocol NonceGenerator16ByteType { + func nonce() -> Array +} + +extension NonceGenerator16ByteType { + +} + extension OpenGroupAPIV2 { - class NonceGenerator16Byte: NonceGenerator { - var NonceBytes: Int { 16 } + public class NonceGenerator16Byte: NonceGenerator, NonceGenerator16ByteType { + public var NonceBytes: Int { 16 } + + public init() {} } } diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift index d434bbd56..2e6a8f3a8 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift @@ -52,7 +52,7 @@ public final class PushNotificationAPI : NSObject { let promise: Promise = attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) { // TODO: Update this to use the V4 union requests once supported - LegacyOnionRequestAPI.sendOnionRequest(request, to: server, target: "/loki/v2/lsrpc", using: serverPublicKey) + OnionRequestAPI.sendOnionRequest(request, to: server, using: .v2, with: serverPublicKey) .map2 { json in try JSONSerialization.data(withJSONObject: json, options: []) } .map2 { response in guard let response: UnregisterResponse = try? response.decoded(as: UnregisterResponse.self) else { @@ -103,7 +103,7 @@ public final class PushNotificationAPI : NSObject { let promise: Promise = attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) { // TODO: Update this to use the V4 union requests once supported - LegacyOnionRequestAPI.sendOnionRequest(request, to: server, target: "/loki/v2/lsrpc", using: serverPublicKey) + OnionRequestAPI.sendOnionRequest(request, to: server, using: .v2, with: serverPublicKey) .map2 { json in try JSONSerialization.data(withJSONObject: json, options: []) } .map2 { response in guard let response: RegisterResponse = try? response.decoded(as: RegisterResponse.self) else { @@ -151,7 +151,7 @@ public final class PushNotificationAPI : NSObject { let promise: Promise = attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) { // TODO: Update this to use the V4 union requests once supported - LegacyOnionRequestAPI.sendOnionRequest(request, to: server, target: "/loki/v2/lsrpc", using: serverPublicKey) + OnionRequestAPI.sendOnionRequest(request, to: server, using: .v2, with: serverPublicKey) .map2 { json in try JSONSerialization.data(withJSONObject: json, options: []) } .map2 { response in guard let response: RegisterResponse = try? response.decoded(as: RegisterResponse.self) else { diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift index b5a53d4fe..23e0331a3 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift @@ -1,4 +1,5 @@ import PromiseKit +import SessionSnodeKit @objc(SNOpenGroupPollerV2) public final class OpenGroupPollerV2 : NSObject { @@ -48,9 +49,9 @@ public final class OpenGroupPollerV2 : NSObject { promise.retainUntilComplete() OpenGroupAPIV2.poll(server) - .done(on: OpenGroupAPIV2.workQueue) { [weak self] _ in + .done(on: OpenGroupAPIV2.workQueue) { [weak self] response in self?.isPolling = false - // TODO: Handle response + self?.handlePollResponse(response, isBackgroundPoll: isBackgroundPoll) seal.fulfill(()) } .catch(on: OpenGroupAPIV2.workQueue) { [weak self] error in @@ -61,6 +62,94 @@ public final class OpenGroupPollerV2 : NSObject { return promise } + + private func handlePollResponse(_ response: [Endpoint: (info: OnionRequestResponseInfoType, data: Codable)], isBackgroundPoll: Bool) { + let storage = SNMessagingKitConfiguration.shared.storage + + response.forEach { endpoint, response in + switch endpoint { + case .roomMessagesRecent(let roomToken), .roomMessagesBefore(let roomToken, _), .roomMessagesSince(let roomToken, _): + guard let responseData: [OpenGroupAPIV2.Message] = response.data as? [OpenGroupAPIV2.Message] else { + //SNLog("Open group polling failed due to error: \(error).") + return // TODO: Throw error? + } + + handleMessages(responseData, roomToken: roomToken, isBackgroundPoll: isBackgroundPoll, using: storage) + + case .roomPollInfo(let roomToken, _): + guard let responseData: OpenGroupAPIV2.RoomPollInfo = response.data as? OpenGroupAPIV2.RoomPollInfo else { + //SNLog("Open group polling failed due to error: \(error).") + return // TODO: Throw error? + } + + handlePollInfo(responseData, roomToken: roomToken, isBackgroundPoll: isBackgroundPoll, using: storage) + + default: break // No custom handling needed + } + } + } + + // MARK: - Custom response handling + // TODO: Shift this logic to the OpenGroupManagerV2? (seems like the place it should belong?) + + private func handleMessages(_ messages: [OpenGroupAPIV2.Message], roomToken: String, isBackgroundPoll: Bool, using storage: SessionMessagingKitStorageProtocol) { + // Sorting the messages by server ID before importing them fixes an issue where messages that quote older messages can't find those older messages + let openGroupID = "\(server).\(roomToken)" + let sortedMessages: [OpenGroupAPIV2.Message] = messages + .sorted { lhs, rhs in lhs.seqNo < rhs.seqNo } + + storage.write { transaction in + var messageServerIDsToRemove: [UInt64] = [] + + sortedMessages.forEach { message in + guard let base64EncodedString: String = message.base64EncodedData, let data = Data(base64Encoded: base64EncodedString), let sender: String = message.sender else { + // A message with no data has been deleted so add it to the list to remove + messageServerIDsToRemove.append(UInt64(message.seqNo)) + return + } + + let envelope = SNProtoEnvelope.builder(type: .sessionMessage, timestamp: UInt64(floor(message.posted))) + envelope.setContent(data) + envelope.setSource(sender) + + do { + let data = try envelope.buildSerializedData() + let (message, proto) = try MessageReceiver.parse(data, openGroupMessageServerID: UInt64(message.seqNo), isRetry: false, using: transaction) + try MessageReceiver.handle(message, associatedWithProto: proto, openGroupID: openGroupID, isBackgroundPoll: isBackgroundPoll, using: transaction) + } + catch { + SNLog("Couldn't receive open group message due to error: \(error).") + } + } + + // Handle any deletions that are needed + guard !messageServerIDsToRemove.isEmpty else { return } + guard let transaction: YapDatabaseReadWriteTransaction = transaction as? YapDatabaseReadWriteTransaction else { return } + guard let threadID = storage.v2GetThreadID(for: openGroupID), let thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction) else { + return + } + + var messagesToRemove: [TSMessage] = [] + + thread.enumerateInteractions(with: transaction) { interaction, stop in + guard let message: TSMessage = interaction as? TSMessage, messageServerIDsToRemove.contains(message.openGroupServerMessageID) else { return } + messagesToRemove.append(message) + } + + messagesToRemove.forEach { $0.remove(with: transaction) } + } + } + + private func handlePollInfo(_ pollInfo: OpenGroupAPIV2.RoomPollInfo, roomToken: String, isBackgroundPoll: Bool, using storage: SessionMessagingKitStorageProtocol) { + // TODO: Handle other properties??? + + // - Moderators + OpenGroupAPIV2.moderators[server] = (OpenGroupAPIV2.moderators[server] ?? [:]) + .setting(roomToken, Set(pollInfo.moderators ?? [])) + + } + + // MARK: - Legacy Handling private func handleCompactPollBody(_ body: OpenGroupAPIV2.LegacyCompactPollResponse.Result, isBackgroundPoll: Bool) { let storage = SNMessagingKitConfiguration.shared.storage diff --git a/SessionMessagingKit/Utilities/Promise+Utilities.swift b/SessionMessagingKit/Utilities/Promise+Utilities.swift index e39b21082..84e71cd1c 100644 --- a/SessionMessagingKit/Utilities/Promise+Utilities.swift +++ b/SessionMessagingKit/Utilities/Promise+Utilities.swift @@ -12,9 +12,9 @@ extension Promise where T == Data { } } -extension Promise where T == (OnionRequestAPI.ResponseInfo, Data?) { - func decoded(as type: R.Type, on queue: DispatchQueue? = nil, error: Error? = nil) -> Promise<(OnionRequestAPI.ResponseInfo, R)> { - self.map(on: queue) { responseInfo, maybeData -> (OnionRequestAPI.ResponseInfo, R) in +extension Promise where T == (OnionRequestResponseInfoType, Data?) { + func decoded(as type: R.Type, on queue: DispatchQueue? = nil, error: Error? = nil) -> Promise<(OnionRequestResponseInfoType, R)> { + self.map(on: queue) { responseInfo, maybeData -> (OnionRequestResponseInfoType, R) in guard let data: Data = maybeData else { throw OpenGroupAPIV2.Error.parsingFailed } diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift b/SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift new file mode 100644 index 000000000..8923287b6 --- /dev/null +++ b/SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift @@ -0,0 +1,220 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import XCTest +import Nimble +import PromiseKit +import SessionSnodeKit + +@testable import SessionMessagingKit + +class OpenGroupAPIV2Tests: XCTestCase { + class TestResponseInfo: OnionRequestResponseInfoType { + let requestData: TestApi.RequestData + let code: Int + let headers: [String: String] + + init(requestData: TestApi.RequestData, code: Int, headers: [String: String]) { + self.requestData = requestData + self.code = code + self.headers = headers + } + } + + struct TestNonceGenerator: NonceGenerator16ByteType { + func nonce() -> Array { return Data(base64Encoded: "pK6YRtQApl4NhECGizF0Cg==")!.bytes } + } + + class TestApi: OnionRequestAPIType { + struct RequestData: Codable { + let urlString: String? + let httpMethod: String + let headers: [String: String] + let snodeMethod: String? + let body: Data? + + let server: String + let version: OnionRequestAPI.Version + let publicKey: String? + } + + class var mockResponse: Data? { return nil } + + static func sendOnionRequest(_ request: URLRequest, to server: String, using version: OnionRequestAPI.Version, with x25519PublicKey: String) -> Promise<(OnionRequestResponseInfoType, Data?)> { + let responseInfo: TestResponseInfo = TestResponseInfo( + requestData: RequestData( + urlString: request.url?.absoluteString, + httpMethod: (request.httpMethod ?? "GET"), + headers: (request.allHTTPHeaderFields ?? [:]), + snodeMethod: nil, + body: request.httpBody, + + server: server, + version: version, + publicKey: x25519PublicKey + ), + code: 200, + headers: [:] + ) + + return Promise.value((responseInfo, mockResponse)) + } + + static func sendOnionRequest(to snode: Snode, invoking method: Snode.Method, with parameters: JSON, using version: OnionRequestAPI.Version, associatedWith publicKey: String?) -> Promise { + // TODO: Test the 'responseInfo' somehow? + return Promise.value(mockResponse!) + } + } + + var testStorage: TestStorage! + + // MARK: - Configuration + + override func setUpWithError() throws { + testStorage = TestStorage() + + testStorage.mockData[.allV2OpenGroups] = [ + "0": OpenGroupV2(server: "testServer", room: "test1", name: "Test", publicKey: "7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d", imageID: nil) + ] + testStorage.mockData[.openGroupPublicKeys] = ["testServer": "7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d"] + + // Test Private key, not actually used (from here https://www.notion.so/oxen/SOGS-Authentication-dc64cc846cb24b2abbf7dd4bfd74abbb) + testStorage.mockData[.userKeyPair] = try! ECKeyPair( + publicKeyData: Data.data(fromHex: "7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")!, + privateKeyData: Data.data(fromHex: "881132ee03dbd2da065aa4c94f96081f62142dc8011d1b7a00de83e4aab38ce4")! + ) + } + + override func tearDownWithError() throws { + testStorage = nil + } + + // MARK: - Batching & Polling + + func testPollGeneratesTheCorrectRequest() throws { + // Define a custom TestApi class so we can override the response + class TestApi1: TestApi { + override class var mockResponse: Data? { + let responses: [Data] = [ + try! JSONEncoder().encode( + OpenGroupAPIV2.BatchSubResponse( + code: 200, + headers: [:], + body: OpenGroupAPIV2.Capabilities(capabilities: [], missing: nil) + ) + ), + try! JSONEncoder().encode( + OpenGroupAPIV2.BatchSubResponse( + code: 200, + headers: [:], + body: OpenGroupAPIV2.RoomPollInfo( + token: nil, + created: nil, + name: nil, + description: nil, + imageId: nil, + + infoUpdates: nil, + messageSequence: nil, + activeUsers: nil, + activeUsersCutoff: nil, + pinnedMessages: nil, + + admin: nil, + globalAdmin: nil, + admins: nil, + hiddenAdmins: nil, + + moderator: nil, + globalModerator: nil, + moderators: nil, + hiddenModerators: nil, + read: nil, + defaultRead: nil, + write: nil, + defaultWrite: nil, + upload: nil, + defaultUpload: nil, + details: nil + ) + ) + ), + try! JSONEncoder().encode( + OpenGroupAPIV2.BatchSubResponse( + code: 200, + headers: [:], + body: [OpenGroupAPIV2.Message]() + ) + ) + ] + + return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) + } + } + + var pollResponse: [Endpoint: (OnionRequestResponseInfoType, Codable)]? = nil + + OpenGroupAPIV2.poll("testServer", through: TestApi1.self, using: testStorage, nonceGenerator: TestNonceGenerator(), date: Date(timeIntervalSince1970: 1234567890)) + .map { result -> [Endpoint: (OnionRequestResponseInfoType, Codable)] in + pollResponse = result + return result + } + .retainUntilComplete() + + expect(pollResponse) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(10000) + ) + + // Validate the response data + expect(pollResponse?.values).to(haveCount(3)) + expect(pollResponse?.keys).to(contain(.capabilities)) + expect(pollResponse?.keys).to(contain(.roomPollInfo("test1", 0))) + expect(pollResponse?.keys).to(contain(.roomMessagesRecent("test1"))) + expect(pollResponse?[.capabilities]?.0).to(beAKindOf(TestResponseInfo.self)) + + // Validate request data + let requestData: TestApi.RequestData? = (pollResponse?[.capabilities]?.0 as? TestResponseInfo)?.requestData + expect(requestData?.urlString).to(equal("testServer/batch")) + expect(requestData?.httpMethod).to(equal("POST")) + expect(requestData?.server).to(equal("testServer")) + expect(requestData?.publicKey).to(equal("7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")) + } + + // MARK: - Authentication + + func testItSignsTheRequestCorrectly() throws { + class TestApi1: TestApi { + override class var mockResponse: Data? { + return try! JSONEncoder().encode([OpenGroupAPIV2.Room]()) + } + } + + var response: (OnionRequestResponseInfoType, [OpenGroupAPIV2.Room])? = nil + + OpenGroupAPIV2.rooms(for: "testServer", through: TestApi1.self, using: testStorage, nonceGenerator: TestNonceGenerator(), date: Date(timeIntervalSince1970: 1234567890)) + .map { result -> (OnionRequestResponseInfoType, [OpenGroupAPIV2.Room]) in + response = result + return result + } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(10000) + ) + + // Validate signature headers + let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData + expect(requestData?.urlString).to(equal("testServer/rooms")) + expect(requestData?.httpMethod).to(equal("GET")) + expect(requestData?.server).to(equal("testServer")) + expect(requestData?.publicKey).to(equal("7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")) + expect(requestData?.headers).to(haveCount(4)) + expect(requestData?.headers[Header.sogsPubKey.rawValue]).to(equal("057aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")) + expect(requestData?.headers[Header.sogsNonce.rawValue]).to(equal("pK6YRtQApl4NhECGizF0Cg==")) + expect(requestData?.headers[Header.sogsHash.rawValue]).to(equal("fxqLy5ZDWCsLQpwLw0Dax+4xe7cG2vPRk1NlHORIm0DPd3o9UA24KLZY")) + expect(requestData?.headers[Header.sogsTimestamp.rawValue]).to(equal("1234567890")) + } +} diff --git a/SessionMessagingKitTests/_TestUtilities/Mockable.swift b/SessionMessagingKitTests/_TestUtilities/Mockable.swift new file mode 100644 index 000000000..b903f0fa3 --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/Mockable.swift @@ -0,0 +1,9 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +protocol Mockable { + associatedtype Key: Hashable + + var mockData: [Key: Any] { get } +} diff --git a/SessionMessagingKitTests/_TestUtilities/TestStorage.swift b/SessionMessagingKitTests/_TestUtilities/TestStorage.swift new file mode 100644 index 000000000..82f21d4a5 --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/TestStorage.swift @@ -0,0 +1,114 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import PromiseKit +import Sodium + +@testable import SessionMessagingKit + +class TestStorage: SessionMessagingKitStorageProtocol, Mockable { + // MARK: - Mockable + + enum DataKey: Hashable { + case allV2OpenGroups + case openGroupPublicKeys + case userKeyPair + } + + typealias Key = DataKey + + var mockData: [DataKey: Any] = [:] + + // MARK: - Shared + + @discardableResult func write(with block: @escaping (Any) -> Void) -> Promise { + block(()) // TODO: Pass Transaction type to prevent force-cast crashes throughout codebase + return Promise.value(()) + } + + @discardableResult func write(with block: @escaping (Any) -> Void, completion: @escaping () -> Void) -> Promise { + block(()) // TODO: Pass Transaction type to prevent force-cast crashes throughout codebase + return Promise.value(()) + } + + func writeSync(with block: @escaping (Any) -> Void) { + block(()) // TODO: Pass Transaction type to prevent force-cast crashes throughout codebase + } + + // MARK: - General + + func getUserPublicKey() -> String? { return nil } + func getUserKeyPair() -> ECKeyPair? { return (mockData[.userKeyPair] as? ECKeyPair) } + func getUserED25519KeyPair() -> Box.KeyPair? { return nil } + func getUser() -> Contact? { return nil } + func getAllContacts() -> Set { return Set() } + + // MARK: - Closed Groups + + func getUserClosedGroupPublicKeys() -> Set { return Set() } + func getZombieMembers(for groupPublicKey: String) -> Set { return Set() } + func setZombieMembers(for groupPublicKey: String, to zombies: Set, using transaction: Any) {} + func isClosedGroup(_ publicKey: String) -> Bool { return false } + + // MARK: - Jobs + + func persist(_ job: Job, using transaction: Any) {} + func markJobAsSucceeded(_ job: Job, using transaction: Any) {} + func markJobAsFailed(_ job: Job, using transaction: Any) {} + func getAllPendingJobs(of type: Job.Type) -> [Job] { return [] } + func getAttachmentUploadJob(for attachmentID: String) -> AttachmentUploadJob? { return nil } + func getMessageSendJob(for messageSendJobID: String) -> MessageSendJob? { return nil } + func resumeMessageSendJobIfNeeded(_ messageSendJobID: String) {} + func isJobCanceled(_ job: Job) -> Bool { return true } + + // MARK: - Authorization + + func getAuthToken(for room: String, on server: String) -> String? { return nil } + func setAuthToken(for room: String, on server: String, to newValue: String, using transaction: Any) {} + func removeAuthToken(for room: String, on server: String, using transaction: Any) {} + + // MARK: - Open Groups + + func getAllV2OpenGroups() -> [String: OpenGroupV2] { return (mockData[.allV2OpenGroups] as! [String: OpenGroupV2]) } + func getV2OpenGroup(for threadID: String) -> OpenGroupV2? { return nil } + func v2GetThreadID(for v2OpenGroupID: String) -> String? { return nil } + func updateMessageIDCollectionByPruningMessagesWithIDs(_ messageIDs: Set, using transaction: Any) {} + + // MARK: - Open Group Public Keys + + func getOpenGroupPublicKey(for server: String) -> String? { + guard let publicKeyMap: [String: String] = mockData[.openGroupPublicKeys] as? [String: String] else { + return (mockData[.openGroupPublicKeys] as? String) + } + + return publicKeyMap[server] + } + + func setOpenGroupPublicKey(for server: String, to newValue: String, using transaction: Any) {} + + // MARK: - Last Message Server ID + + func getLastMessageServerID(for room: String, on server: String) -> Int64? { return nil } + func setLastMessageServerID(for room: String, on server: String, to newValue: Int64, using transaction: Any) {} + func removeLastMessageServerID(for room: String, on server: String, using transaction: Any) {} + + // MARK: - Last Deletion Server ID + + func getLastDeletionServerID(for room: String, on server: String) -> Int64? { return nil } + func setLastDeletionServerID(for room: String, on server: String, to newValue: Int64, using transaction: Any) {} + func removeLastDeletionServerID(for room: String, on server: String, using transaction: Any) {} + + // MARK: - Open Group Metadata + + func setUserCount(to newValue: UInt64, forV2OpenGroupWithID openGroupID: String, using transaction: Any) {} + + // MARK: - Message Handling + + func getReceivedMessageTimestamps(using transaction: Any) -> [UInt64] { return [] } + func addReceivedMessageTimestamp(_ timestamp: UInt64, using transaction: Any) {} + func getOrCreateThread(for publicKey: String, groupPublicKey: String?, openGroupID: String?, using transaction: Any) -> String? { return nil } + func persist(_ message: VisibleMessage, quotedMessage: TSQuotedMessage?, linkPreview: OWSLinkPreview?, groupPublicKey: String?, openGroupID: String?, using transaction: Any) -> String? { return nil } + func persist(_ attachments: [VisibleMessage.Attachment], using transaction: Any) -> [String] { return [] } + func setAttachmentState(to state: TSAttachmentPointerState, for pointer: TSAttachmentPointer, associatedWith tsIncomingMessageID: String, using transaction: Any) {} + func persist(_ stream: TSAttachmentStream, associatedWith tsIncomingMessageID: String, using transaction: Any) {} +} diff --git a/SessionSnodeKit/LegacyOnionRequestAPI.swift b/SessionSnodeKit/LegacyOnionRequestAPI.swift deleted file mode 100644 index 9f897e575..000000000 --- a/SessionSnodeKit/LegacyOnionRequestAPI.swift +++ /dev/null @@ -1,455 +0,0 @@ -import CryptoSwift -import PromiseKit -import SessionUtilitiesKit - -/// See the "Onion Requests" section of [The Session Whitepaper](https://arxiv.org/pdf/2002.04609.pdf) for more information. -public enum LegacyOnionRequestAPI: OnionRequestAPIType { - private static var buildPathsPromise: Promise<[Path]>? = nil - /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. - private static var pathFailureCount: [Path:UInt] = [:] - /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. - private static var snodeFailureCount: [Snode:UInt] = [:] - /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. - public static var guardSnodes: Set = [] - public static var paths: [Path] = [] // Not a set to ensure we consistently show the same path to the user - // MARK: Settings - public static let maxRequestSize = 10_000_000 // 10 MB - /// The number of snodes (including the guard snode) in a path. - private static let pathSize: UInt = 3 - /// The number of times a path can fail before it's replaced. - private static let pathFailureThreshold: UInt = 3 - /// The number of times a snode can fail before it's replaced. - private static let snodeFailureThreshold: UInt = 3 - /// The number of paths to maintain. - public static let targetPathCount: UInt = 2 - - /// The number of guard snodes required to maintain `targetPathCount` paths. - private static var targetGuardSnodeCount: UInt { return targetPathCount } // One per path - - // MARK: Error - public enum Error : LocalizedError { - case httpRequestFailedAtDestination(statusCode: UInt, json: JSON, destination: OnionRequestAPI.Destination) - case insufficientSnodes - case invalidURL - case missingSnodeVersion - case snodePublicKeySetMissing - case unsupportedSnodeVersion(String) - - public var errorDescription: String? { - switch self { - case .httpRequestFailedAtDestination(let statusCode, _, let destination): - if statusCode == 429 { - return "Rate limited." - } else { - return "HTTP request failed at destination (\(destination)) with status code: \(statusCode)." - } - case .insufficientSnodes: return "Couldn't find enough Service Nodes to build a path." - case .invalidURL: return "Invalid URL" - case .missingSnodeVersion: return "Missing Service Node version." - case .snodePublicKeySetMissing: return "Missing Service Node public key set." - case .unsupportedSnodeVersion(let version): return "Unsupported Service Node version: \(version)." - } - } - } - - // MARK: Path - public typealias Path = [Snode] - - // MARK: Onion Building Result - private typealias OnionBuildingResult = (guardSnode: Snode, finalEncryptionResult: AESGCM.EncryptionResult, destinationSymmetricKey: Data) - - // MARK: Private API - /// Tests the given snode. The returned promise errors out if the snode is faulty; the promise is fulfilled otherwise. - private static func testSnode(_ snode: Snode) -> Promise { - let (promise, seal) = Promise.pending() - DispatchQueue.global(qos: .userInitiated).async { - let url = "\(snode.address):\(snode.port)/get_stats/v1" - let timeout: TimeInterval = 3 // Use a shorter timeout for testing - HTTP.execute(.get, url, timeout: timeout).done2 { json in - guard let version = json["version"] as? String else { return seal.reject(Error.missingSnodeVersion) } - if version >= "2.0.7" { - seal.fulfill(()) - } else { - SNLog("Unsupported snode version: \(version).") - seal.reject(Error.unsupportedSnodeVersion(version)) - } - }.catch2 { error in - seal.reject(error) - } - } - return promise - } - - /// Finds `targetGuardSnodeCount` guard snodes to use for path building. The returned promise errors out with `Error.insufficientSnodes` - /// if not enough (reliable) snodes are available. - private static func getGuardSnodes(reusing reusableGuardSnodes: [Snode]) -> Promise> { - if guardSnodes.count >= targetGuardSnodeCount { - return Promise> { $0.fulfill(guardSnodes) } - } else { - SNLog("Populating guard snode cache.") - var unusedSnodes = SnodeAPI.snodePool.subtracting(reusableGuardSnodes) // Sync on LokiAPI.workQueue - let reusableGuardSnodeCount = UInt(reusableGuardSnodes.count) - guard unusedSnodes.count >= (targetGuardSnodeCount - reusableGuardSnodeCount) else { return Promise(error: Error.insufficientSnodes) } - func getGuardSnode() -> Promise { - // randomElement() uses the system's default random generator, which is cryptographically secure - guard let candidate = unusedSnodes.randomElement() else { return Promise { $0.reject(Error.insufficientSnodes) } } - unusedSnodes.remove(candidate) // All used snodes should be unique - SNLog("Testing guard snode: \(candidate).") - // Loop until a reliable guard snode is found - return testSnode(candidate).map2 { candidate }.recover(on: DispatchQueue.main) { _ in - withDelay(0.1, completionQueue: Threading.workQueue) { getGuardSnode() } - } - } - let promises = (0..<(targetGuardSnodeCount - reusableGuardSnodeCount)).map { _ in getGuardSnode() } - return when(fulfilled: promises).map2 { guardSnodes in - let guardSnodesAsSet = Set(guardSnodes + reusableGuardSnodes) - OnionRequestAPI.guardSnodes = guardSnodesAsSet - return guardSnodesAsSet - } - } - } - - /// Builds and returns `targetPathCount` paths. The returned promise errors out with `Error.insufficientSnodes` - /// if not enough (reliable) snodes are available. - @discardableResult - private static func buildPaths(reusing reusablePaths: [Path]) -> Promise<[Path]> { - if let existingBuildPathsPromise = buildPathsPromise { return existingBuildPathsPromise } - SNLog("Building onion request paths.") - DispatchQueue.main.async { - NotificationCenter.default.post(name: .buildingPaths, object: nil) - } - let reusableGuardSnodes = reusablePaths.map { $0[0] } - let promise: Promise<[Path]> = getGuardSnodes(reusing: reusableGuardSnodes).map2 { guardSnodes -> [Path] in - var unusedSnodes = SnodeAPI.snodePool.subtracting(guardSnodes).subtracting(reusablePaths.flatMap { $0 }) - let reusableGuardSnodeCount = UInt(reusableGuardSnodes.count) - let pathSnodeCount = (targetGuardSnodeCount - reusableGuardSnodeCount) * pathSize - (targetGuardSnodeCount - reusableGuardSnodeCount) - guard unusedSnodes.count >= pathSnodeCount else { throw Error.insufficientSnodes } - // Don't test path snodes as this would reveal the user's IP to them - return guardSnodes.subtracting(reusableGuardSnodes).map { guardSnode in - let result = [ guardSnode ] + (0..<(pathSize - 1)).map { _ in - // randomElement() uses the system's default random generator, which is cryptographically secure - let pathSnode = unusedSnodes.randomElement()! // Safe because of the pathSnodeCount check above - unusedSnodes.remove(pathSnode) // All used snodes should be unique - return pathSnode - } - SNLog("Built new onion request path: \(result.prettifiedDescription).") - return result - } - }.map2 { paths in - OnionRequestAPI.paths = paths + reusablePaths - SNSnodeKitConfiguration.shared.storage.writeSync { transaction in - SNLog("Persisting onion request paths to database.") - SNSnodeKitConfiguration.shared.storage.setOnionRequestPaths(to: paths, using: transaction) - } - DispatchQueue.main.async { - NotificationCenter.default.post(name: .pathsBuilt, object: nil) - } - return paths - } - promise.done2 { _ in buildPathsPromise = nil } - promise.catch2 { _ in buildPathsPromise = nil } - buildPathsPromise = promise - return promise - } - - /// Returns a `Path` to be used for building an onion request. Builds new paths as needed. - private static func getPath(excluding snode: Snode?) -> Promise { - guard pathSize >= 1 else { preconditionFailure("Can't build path of size zero.") } - var paths = OnionRequestAPI.paths - if paths.isEmpty { - paths = SNSnodeKitConfiguration.shared.storage.getOnionRequestPaths() - OnionRequestAPI.paths = paths - if !paths.isEmpty { - guardSnodes.formUnion([ paths[0][0] ]) - if paths.count >= 2 { - guardSnodes.formUnion([ paths[1][0] ]) - } - } - } - // randomElement() uses the system's default random generator, which is cryptographically secure - if paths.count >= targetPathCount { - if let snode = snode { - return Promise { $0.fulfill(paths.filter { !$0.contains(snode) }.randomElement()!) } - } else { - return Promise { $0.fulfill(paths.randomElement()!) } - } - } else if !paths.isEmpty { - if let snode = snode { - if let path = paths.first(where: { !$0.contains(snode) }) { - buildPaths(reusing: paths) // Re-build paths in the background - return Promise { $0.fulfill(path) } - } else { - return buildPaths(reusing: paths).map2 { paths in - return paths.filter { !$0.contains(snode) }.randomElement()! - } - } - } else { - buildPaths(reusing: paths) // Re-build paths in the background - return Promise { $0.fulfill(paths.randomElement()!) } - } - } else { - return buildPaths(reusing: []).map2 { paths in - if let snode = snode { - return paths.filter { !$0.contains(snode) }.randomElement()! - } else { - return paths.randomElement()! - } - } - } - } - - private static func dropGuardSnode(_ snode: Snode) { - #if DEBUG - dispatchPrecondition(condition: .onQueue(Threading.workQueue)) - #endif - guardSnodes = guardSnodes.filter { $0 != snode } - } - - private static func drop(_ snode: Snode) throws { - #if DEBUG - dispatchPrecondition(condition: .onQueue(Threading.workQueue)) - #endif - // We repair the path here because we can do it sync. In the case where we drop a whole - // path we leave the re-building up to getPath(excluding:) because re-building the path - // in that case is async. - LegacyOnionRequestAPI.snodeFailureCount[snode] = 0 - var oldPaths = paths - guard let pathIndex = oldPaths.firstIndex(where: { $0.contains(snode) }) else { return } - var path = oldPaths[pathIndex] - guard let snodeIndex = path.firstIndex(of: snode) else { return } - path.remove(at: snodeIndex) - let unusedSnodes = SnodeAPI.snodePool.subtracting(oldPaths.flatMap { $0 }) - guard !unusedSnodes.isEmpty else { throw Error.insufficientSnodes } - // randomElement() uses the system's default random generator, which is cryptographically secure - path.append(unusedSnodes.randomElement()!) - // Don't test the new snode as this would reveal the user's IP - oldPaths.remove(at: pathIndex) - let newPaths = oldPaths + [ path ] - paths = newPaths - SNSnodeKitConfiguration.shared.storage.writeSync { transaction in - SNLog("Persisting onion request paths to database.") - SNSnodeKitConfiguration.shared.storage.setOnionRequestPaths(to: newPaths, using: transaction) - } - } - - private static func drop(_ path: Path) { - #if DEBUG - dispatchPrecondition(condition: .onQueue(Threading.workQueue)) - #endif - LegacyOnionRequestAPI.pathFailureCount[path] = 0 - var paths = LegacyOnionRequestAPI.paths - guard let pathIndex = paths.firstIndex(of: path) else { return } - paths.remove(at: pathIndex) - OnionRequestAPI.paths = paths - SNSnodeKitConfiguration.shared.storage.writeSync { transaction in - if !paths.isEmpty { - SNLog("Persisting onion request paths to database.") - SNSnodeKitConfiguration.shared.storage.setOnionRequestPaths(to: paths, using: transaction) - } else { - SNLog("Clearing onion request paths.") - SNSnodeKitConfiguration.shared.storage.setOnionRequestPaths(to: [], using: transaction) - } - } - } - - /// Builds an onion around `payload` and returns the result. - private static func buildOnion(around payload: JSON, targetedAt destination: OnionRequestAPI.Destination) -> Promise { - var guardSnode: Snode! - var targetSnodeSymmetricKey: Data! // Needed by invoke(_:on:with:) to decrypt the response sent back by the destination - var encryptionResult: AESGCM.EncryptionResult! - var snodeToExclude: Snode? - if case .snode(let snode) = destination { snodeToExclude = snode } - return getPath(excluding: snodeToExclude).then2 { path -> Promise in - guardSnode = path.first! - // Encrypt in reverse order, i.e. the destination first - return OnionRequestAPI.encrypt(payload, for: destination).then2 { r -> Promise in - targetSnodeSymmetricKey = r.symmetricKey - // Recursively encrypt the layers of the onion (again in reverse order) - encryptionResult = r - var path = path - var rhs = destination - func addLayer() -> Promise { - if path.isEmpty { - return Promise { $0.fulfill(encryptionResult) } - } else { - let lhs = OnionRequestAPI.Destination.snode(path.removeLast()) - return OnionRequestAPI.encryptHop(from: lhs, to: rhs, using: encryptionResult).then2 { r -> Promise in - encryptionResult = r - rhs = lhs - return addLayer() - } - } - } - return addLayer() - } - }.map2 { _ in (guardSnode, encryptionResult, targetSnodeSymmetricKey) } - } - - // MARK: Public API - /// Sends an onion request to `snode`. Builds new paths as needed. - public static func sendOnionRequest(to snode: Snode, invoking method: Snode.Method, with parameters: JSON, associatedWith publicKey: String? = nil) -> Promise { - let payload: JSON = [ "method" : method.rawValue, "params" : parameters ] - return sendOnionRequest(with: payload, to: OnionRequestAPI.Destination.snode(snode)).recover2 { error -> Promise in - guard case OnionRequestAPI.Error.httpRequestFailedAtDestination(let statusCode, let json, _) = error else { throw error } - throw SnodeAPI.handleError(withStatusCode: statusCode, json: json, forSnode: snode, associatedWith: publicKey) ?? error - } - } - - /// Sends an onion request to `server`. Builds new paths as needed. - public static func sendOnionRequest(_ request: URLRequest, to server: String, target: String = "/loki/v3/lsrpc", using x25519PublicKey: String) -> Promise<(OnionRequestAPI.ResponseInfo, Data?)> { - var rawHeaders = request.allHTTPHeaderFields ?? [:] - rawHeaders.removeValue(forKey: "User-Agent") - var headers: JSON = rawHeaders.mapValues { value in - switch value.lowercased() { - case "true": return true - case "false": return false - default: return value - } - } - guard let url = request.url, let host = request.url?.host else { return Promise(error: Error.invalidURL) } - var endpoint = url.path.removingPrefix("/") - if let query = url.query { endpoint += "?\(query)" } - let scheme = url.scheme - let port = given(url.port) { UInt16($0) } - let bodyAsString: String - - if let body: Data = request.httpBody { - headers["Content-Type"] = "application/json" // Assume data is JSON - bodyAsString = (String(data: body, encoding: .utf8) ?? "null") - } - else if let inputStream: InputStream = request.httpBodyStream, let body: Data = try? Data(from: inputStream) { - headers["Content-Type"] = request.allHTTPHeaderFields!["Content-Type"] - bodyAsString = "{ \"fileUpload\" : \"\(String(data: body.base64EncodedData(), encoding: .utf8) ?? "null")\" }" - } - else { - bodyAsString = "null" - } - - let payload: JSON = [ - "body" : bodyAsString, - "endpoint" : endpoint, - "method" : request.httpMethod!, - "headers" : headers - ] - let destination = OnionRequestAPI.Destination.server(host: host, target: target, x25519PublicKey: x25519PublicKey, scheme: scheme, port: port) - let promise = sendOnionRequest(with: payload, to: destination) - .map { (json: JSON) -> (OnionRequestAPI.ResponseInfo, Data?) in - guard let data = try? JSONSerialization.data(withJSONObject: json, options: []) else { throw HTTP.Error.invalidJSON } - - return (OnionRequestAPI.ResponseInfo(code: 200, headers: [:]), data) - } - promise.catch2 { error in - SNLog("Couldn't reach server: \(url) due to error: \(error).") - } - return promise - } - - public static func sendOnionRequest(with payload: JSON, to destination: OnionRequestAPI.Destination) -> Promise { - let (promise, seal) = Promise.pending() - var guardSnode: Snode? - Threading.workQueue.async { // Avoid race conditions on `guardSnodes` and `paths` - buildOnion(around: payload, targetedAt: destination).done2 { intermediate in - guardSnode = intermediate.guardSnode - let url = "\(guardSnode!.address):\(guardSnode!.port)/onion_req/v2" - let finalEncryptionResult = intermediate.finalEncryptionResult - let onion = finalEncryptionResult.ciphertext - if case OnionRequestAPI.Destination.server = destination, Double(onion.count) > 0.75 * Double(maxRequestSize) { - SNLog("Approaching request size limit: ~\(onion.count) bytes.") - } - let parameters: JSON = [ - "ephemeral_key" : finalEncryptionResult.ephemeralPublicKey.toHexString() - ] - let body: Data - do { - body = try OnionRequestAPI.encode(ciphertext: onion, json: parameters) - } catch { - return seal.reject(error) - } - let destinationSymmetricKey = intermediate.destinationSymmetricKey - HTTP.execute(.post, url, body: body).done2 { json in - guard let base64EncodedIVAndCiphertext = json["result"] as? String, - let ivAndCiphertext = Data(base64Encoded: base64EncodedIVAndCiphertext), ivAndCiphertext.count >= AESGCM.ivSize else { return seal.reject(HTTP.Error.invalidJSON) } - do { - let data = try AESGCM.decrypt(ivAndCiphertext, with: destinationSymmetricKey) - guard let json = try JSONSerialization.jsonObject(with: data, options: [ .fragmentsAllowed ]) as? JSON, - let statusCode = json["status_code"] as? Int ?? json["status"] as? Int else { return seal.reject(HTTP.Error.invalidJSON) } - if statusCode == 406 { // Clock out of sync - SNLog("The user's clock is out of sync with the service node network.") - seal.reject(SnodeAPI.Error.clockOutOfSync) - } else if let bodyAsString = json["body"] as? String { - guard let bodyAsData = bodyAsString.data(using: .utf8), - let body = try JSONSerialization.jsonObject(with: bodyAsData, options: [ .fragmentsAllowed ]) as? JSON else { return seal.reject(HTTP.Error.invalidJSON) } - if let timestamp = body["t"] as? Int64 { - let offset = timestamp - Int64(NSDate.millisecondTimestamp()) - SnodeAPI.clockOffset = offset - } - guard 200...299 ~= statusCode else { - return seal.reject(Error.httpRequestFailedAtDestination(statusCode: UInt(statusCode), json: body, destination: destination)) - } - seal.fulfill(body) - } else { - guard 200...299 ~= statusCode else { - return seal.reject(Error.httpRequestFailedAtDestination(statusCode: UInt(statusCode), json: json, destination: destination)) - } - seal.fulfill(json) - } - } catch { - seal.reject(error) - } - }.catch2 { error in - seal.reject(error) - } - }.catch2 { error in - seal.reject(error) - } - } - promise.catch2 { error in // Must be invoked on Threading.workQueue - guard case HTTP.Error.httpRequestFailed(let statusCode, let json) = error, let guardSnode = guardSnode else { return } - let path = paths.first { $0.contains(guardSnode) } - func handleUnspecificError() { - guard let path = path else { return } - var pathFailureCount = LegacyOnionRequestAPI.pathFailureCount[path] ?? 0 - pathFailureCount += 1 - if pathFailureCount >= pathFailureThreshold { - dropGuardSnode(guardSnode) - path.forEach { snode in - SnodeAPI.handleError(withStatusCode: statusCode, json: json, forSnode: snode) // Intentionally don't throw - } - drop(path) - } else { - LegacyOnionRequestAPI.pathFailureCount[path] = pathFailureCount - } - } - let prefix = "Next node not found: " - if let message = json?["result"] as? String, message.hasPrefix(prefix) { - let ed25519PublicKey = message[message.index(message.startIndex, offsetBy: prefix.count)..= snodeFailureThreshold { - SnodeAPI.handleError(withStatusCode: statusCode, json: json, forSnode: snode) // Intentionally don't throw - do { - try drop(snode) - } catch { - handleUnspecificError() - } - } else { - LegacyOnionRequestAPI.snodeFailureCount[snode] = snodeFailureCount - } - } else { - // Do nothing - } - } else if let message = json?["result"] as? String, message == "Loki Server error" { - // Do nothing - } else if case .server(let host, _, _, _, _) = destination, host == "116.203.70.33" && statusCode == 0 { - // FIXME: Temporary thing to kick out nodes that can't talk to the V2 OGS yet - handleUnspecificError() - } else if statusCode == 0 { // Timeout - // Do nothing - } else { - handleUnspecificError() - } - } - return promise - } -} diff --git a/SessionSnodeKit/Models/Destination.swift b/SessionSnodeKit/Models/Destination.swift new file mode 100644 index 000000000..f879c1034 --- /dev/null +++ b/SessionSnodeKit/Models/Destination.swift @@ -0,0 +1,17 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OnionRequestAPI { + public enum Destination: CustomStringConvertible { + case snode(Snode) + case server(host: String, target: String, x25519PublicKey: String, scheme: String?, port: UInt16?) + + public var description: String { + switch self { + case .snode(let snode): return "Service node \(snode.ip):\(snode.port)" + case .server(let host, _, _, _, _): return host + } + } + } +} diff --git a/SessionSnodeKit/Models/Error.swift b/SessionSnodeKit/Models/Error.swift new file mode 100644 index 000000000..d12635df8 --- /dev/null +++ b/SessionSnodeKit/Models/Error.swift @@ -0,0 +1,34 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +extension OnionRequestAPI { + public enum Error: LocalizedError { + case httpRequestFailedAtDestination(statusCode: UInt, json: JSON, destination: Destination) + case insufficientSnodes + case invalidURL + case missingSnodeVersion + case snodePublicKeySetMissing + case unsupportedSnodeVersion(String) + case invalidRequestInfo + + public var errorDescription: String? { + switch self { + case .httpRequestFailedAtDestination(let statusCode, _, let destination): + if statusCode == 429 { + return "Rate limited." + } + + return "HTTP request failed at destination (\(destination)) with status code: \(statusCode)." + + case .insufficientSnodes: return "Couldn't find enough Service Nodes to build a path." + case .invalidURL: return "Invalid URL" + case .missingSnodeVersion: return "Missing Service Node version." + case .snodePublicKeySetMissing: return "Missing Service Node public key set." + case .unsupportedSnodeVersion(let version): return "Unsupported Service Node version: \(version)." + case .invalidRequestInfo: return "Invalid Request Info" + } + } + } +} diff --git a/SessionSnodeKit/Models/RequestInfo.swift b/SessionSnodeKit/Models/RequestInfo.swift new file mode 100644 index 000000000..8072364df --- /dev/null +++ b/SessionSnodeKit/Models/RequestInfo.swift @@ -0,0 +1,11 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OnionRequestAPI { + struct RequestInfo: Codable { + let method: String + let endpoint: String + let headers: [String: String] + } +} diff --git a/SessionSnodeKit/Models/ResponseInfo.swift b/SessionSnodeKit/Models/ResponseInfo.swift new file mode 100644 index 000000000..80e9b5f87 --- /dev/null +++ b/SessionSnodeKit/Models/ResponseInfo.swift @@ -0,0 +1,20 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public protocol OnionRequestResponseInfoType: Codable { + var code: Int { get } + var headers: [String: String] { get } +} + +extension OnionRequestAPI { + public struct ResponseInfo: OnionRequestResponseInfoType { + public let code: Int + public let headers: [String: String] + + public init(code: Int, headers: [String: String]) { + self.code = code + self.headers = headers + } + } +} diff --git a/SessionSnodeKit/Models/Version.swift b/SessionSnodeKit/Models/Version.swift new file mode 100644 index 000000000..d45ca87ef --- /dev/null +++ b/SessionSnodeKit/Models/Version.swift @@ -0,0 +1,11 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OnionRequestAPI { + public enum Version: String, Codable { + case v2 = "/loki/v2/lsrpc" + case v3 = "/loki/v3/lsrpc" + case v4 = "/oxen/v4/lsrpc" + } +} diff --git a/SessionSnodeKit/OnionRequestAPI.swift b/SessionSnodeKit/OnionRequestAPI.swift index 84765f969..016a373ca 100644 --- a/SessionSnodeKit/OnionRequestAPI.swift +++ b/SessionSnodeKit/OnionRequestAPI.swift @@ -4,12 +4,17 @@ import PromiseKit import SessionUtilitiesKit public protocol OnionRequestAPIType { - static func sendOnionRequest(_ request: URLRequest, to server: String, target: String, using x25519PublicKey: String) -> Promise<(OnionRequestAPI.ResponseInfo, Data?)> + static func sendOnionRequest(to snode: Snode, invoking method: Snode.Method, with parameters: JSON, using version: OnionRequestAPI.Version, associatedWith publicKey: String?) -> Promise + static func sendOnionRequest(_ request: URLRequest, to server: String, using version: OnionRequestAPI.Version, with x25519PublicKey: String) -> Promise<(OnionRequestResponseInfoType, Data?)> } public extension OnionRequestAPIType { - static func sendOnionRequest(_ request: URLRequest, to server: String, using x25519PublicKey: String) -> Promise<(OnionRequestAPI.ResponseInfo, Data?)> { - sendOnionRequest(request, to: server, target: "/oxen/v4/lsrpc", using: x25519PublicKey) + static func sendOnionRequest(to snode: Snode, invoking method: Snode.Method, with parameters: JSON, using version: OnionRequestAPI.Version = .v3) -> Promise { + return sendOnionRequest(to: snode, invoking: method, with: parameters, using: version, associatedWith: nil) + } + + static func sendOnionRequest(_ request: URLRequest, to server: String, with x25519PublicKey: String) -> Promise<(OnionRequestResponseInfoType, Data?)> { + sendOnionRequest(request, to: server, using: .v4, with: x25519PublicKey) } } @@ -38,58 +43,6 @@ public enum OnionRequestAPI: OnionRequestAPIType { /// The number of guard snodes required to maintain `targetPathCount` paths. private static var targetGuardSnodeCount: UInt { return targetPathCount } // One per path - // MARK: Destination - public enum Destination : CustomStringConvertible { - case snode(Snode) - case server(host: String, target: String, x25519PublicKey: String, scheme: String?, port: UInt16?) - - public var description: String { - switch self { - case .snode(let snode): return "Service node \(snode.ip):\(snode.port)" - case .server(let host, _, _, _, _): return host - } - } - } - - // MARK: Error - public enum Error : LocalizedError { - case httpRequestFailedAtDestination(statusCode: UInt, json: JSON, destination: Destination) - case insufficientSnodes - case invalidURL - case missingSnodeVersion - case snodePublicKeySetMissing - case unsupportedSnodeVersion(String) - case invalidRequestInfo - - public var errorDescription: String? { - switch self { - case .httpRequestFailedAtDestination(let statusCode, _, let destination): - if statusCode == 429 { - return "Rate limited." - } else { - return "HTTP request failed at destination (\(destination)) with status code: \(statusCode)." - } - case .insufficientSnodes: return "Couldn't find enough Service Nodes to build a path." - case .invalidURL: return "Invalid URL" - case .missingSnodeVersion: return "Missing Service Node version." - case .snodePublicKeySetMissing: return "Missing Service Node public key set." - case .unsupportedSnodeVersion(let version): return "Unsupported Service Node version: \(version)." - case .invalidRequestInfo: return "Invalid Request Info" - } - } - } - - // MARK: RequestInfo - private struct RequestInfo: Codable { - let method: String - let endpoint: String - let headers: [String: String] - } - - public struct ResponseInfo: Codable { - let code: Int - let headers: [String: String] - } // MARK: Path public typealias Path = [Snode] @@ -324,78 +277,55 @@ public enum OnionRequestAPI: OnionRequestAPIType { }.map2 { _ in (guardSnode, encryptionResult, targetSnodeSymmetricKey) } } - // MARK: Public API -// /// Sends an onion request to `snode`. Builds new paths as needed. -// public static func sendOnionRequest(to snode: Snode, invoking method: Snode.Method, with parameters: JSON, associatedWith publicKey: String? = nil) -> Promise { -// let payload: JSON = [ "method" : method.rawValue, "params" : parameters ] -// return sendOnionRequest(with: payload, to: Destination.snode(snode)).recover2 { error -> Promise in -// guard case OnionRequestAPI.Error.httpRequestFailedAtDestination(let statusCode, let json, _) = error else { throw error } -// throw SnodeAPI.handleError(withStatusCode: statusCode, json: json, forSnode: snode, associatedWith: publicKey) ?? error -// } -// } + // MARK: - Public API + + /// Sends an onion request to `snode`. Builds new paths as needed. + public static func sendOnionRequest(to snode: Snode, invoking method: Snode.Method, with parameters: JSON, using version: Version = .v3, associatedWith publicKey: String? = nil) -> Promise { + let payloadJson: JSON = [ "method": method.rawValue, "params": parameters ] + + guard let jsonData: Data = try? JSONSerialization.data(withJSONObject: payloadJson, options: []), let payload: String = String(data: jsonData, encoding: .utf8) else { + return Promise(error: HTTP.Error.invalidJSON) + } + + return sendOnionRequest(with: payload, to: Destination.snode(snode), version: version) + .map { _, maybeData in + guard let data: Data = maybeData else { throw HTTP.Error.invalidResponse } + + return data + } + .recover2 { error -> Promise in + guard case OnionRequestAPI.Error.httpRequestFailedAtDestination(let statusCode, let json, _) = error else { + throw error + } + + throw SnodeAPI.handleError(withStatusCode: statusCode, json: json, forSnode: snode, associatedWith: publicKey) ?? error + } + } /// Sends an onion request to `server`. Builds new paths as needed. - public static func sendOnionRequest(_ request: URLRequest, to server: String, target: String = "/oxen/v4/lsrpc", using x25519PublicKey: String) -> Promise<(ResponseInfo, Data?)> { - guard server == "https://chat.lokinet.dev" else { // TODO: Remove this - return LegacyOnionRequestAPI.sendOnionRequest(request, to: server, target: "/loki/v3/lsrpc", using: x25519PublicKey) + public static func sendOnionRequest(_ request: URLRequest, to server: String, using version: Version = .v4, with x25519PublicKey: String) -> Promise<(OnionRequestResponseInfoType, Data?)> { + guard version != .v4 || server == "https://chat.lokinet.dev" else { // TODO: Remove this + return sendOnionRequest(request, to: server, using: .v3, with: x25519PublicKey) } guard let url = request.url, let host = request.url?.host else { return Promise(error: Error.invalidURL) } - // Note: We need to remove the leading forward slash unless we are explicitly hitting a legacy - // endpoint (in which case we need it to ensure the request signing works correctly - // TODO: Confirm the 'removingPrefix' isn't going to break the request signing on non-legacy endpoints. - let endpoint: String = url.path - .appending(url.query.map { value in "?\(value)" }) let scheme: String? = url.scheme let port: UInt16? = url.port.map { UInt16($0) } - let requestInfo: RequestInfo = RequestInfo( - method: (request.httpMethod ?? "GET"), // Default (if nil) is 'GET' - endpoint: endpoint, - headers: (request.allHTTPHeaderFields ?? [:]) - .setting( - "Content-Type", - // TODO: Determine what 'Content-Type' 'httpBodyStream' should have??? - (request.httpBody == nil && request.httpBodyStream == nil ? nil : - ((request.allHTTPHeaderFields ?? [:])["Content-Type"] ?? "application/json") // Default to JSON if not defined - ) - ) - .removingValue(forKey: "User-Agent") - ) - - guard let requestInfoData: Data = try? JSONEncoder().encode(requestInfo), let requestInfoString: String = String(data: requestInfoData, encoding: .ascii) else { + guard let payload: String = generatePayload(for: request, with: version) else { return Promise(error: Error.invalidRequestInfo) } - let payload: String - - if let body: Data = request.httpBody { - guard let bodyString: String = String(data: body, encoding: .ascii) else { - return Promise(error: Error.invalidRequestInfo) - } - - payload = "l\(requestInfoString.count):\(requestInfoString)\(bodyString.count):\(bodyString)e" - } - else if let inputStream: InputStream = request.httpBodyStream, let body: Data = try? Data(from: inputStream), let bodyString: String = String(data: body, encoding: .ascii) { - // TODO: Handle this properly -// headers["Content-Type"] = request.allHTTPHeaderFields!["Content-Type"] -// bodyAsString = "{ \"fileUpload\" : \"\(String(data: body.base64EncodedData(), encoding: .utf8) ?? "null")\" }" - payload = "l\(requestInfoString.count):\(requestInfoString)\(bodyString.count):\(bodyString)e" - } - else { - payload = "l\(requestInfoString.count):\(requestInfoString)e" - } - - let destination = Destination.server(host: host, target: target, x25519PublicKey: x25519PublicKey, scheme: scheme, port: port) - let promise = sendOnionRequest(with: payload, to: destination) + let destination = Destination.server(host: host, target: version.rawValue, x25519PublicKey: x25519PublicKey, scheme: scheme, port: port) + let promise = sendOnionRequest(with: payload, to: destination, version: version) promise.catch2 { error in SNLog("Couldn't reach server: \(url) due to error: \(error).") } return promise } - public static func sendOnionRequest(with payload: String, to destination: Destination) -> Promise<(ResponseInfo, Data?)> { - let (promise, seal) = Promise<(ResponseInfo, Data?)>.pending() + public static func sendOnionRequest(with payload: String, to destination: Destination, version: Version) -> Promise<(OnionRequestResponseInfoType, Data?)> { + let (promise, seal) = Promise<(OnionRequestResponseInfoType, Data?)>.pending() var guardSnode: Snode? Threading.workQueue.async { // Avoid race conditions on `guardSnodes` and `paths` buildOnion(around: payload, targetedAt: destination).done2 { intermediate in @@ -419,80 +349,13 @@ public enum OnionRequestAPI: OnionRequestAPIType { HTTP.updatedExecute(.post, url, body: body) .done2 { responseData in - guard responseData.count >= AESGCM.ivSize else { return seal.reject(HTTP.Error.invalidResponse) } - - do { - let data: Data = try AESGCM.decrypt(responseData, with: destinationSymmetricKey) - - // The data will be in the form of `l123:jsone` or `l123:json456:bodye` so we need to break the data into - // parts to properly process it - guard let responseString: String = String(data: data, encoding: .ascii), responseString.starts(with: "l") else { - return seal.reject(HTTP.Error.invalidResponse) - } - - let stringParts: [String.SubSequence] = responseString.split(separator: ":") - - guard stringParts.count > 1, let infoLength: Int = Int(stringParts[0].suffix(from: stringParts[0].index(stringParts[0].startIndex, offsetBy: 1))) else { - return seal.reject(HTTP.Error.invalidResponse) - } - - let infoStringStartIndex: String.Index = responseString.index(responseString.startIndex, offsetBy: "l\(infoLength):".count) - let infoStringEndIndex: String.Index = responseString.index(infoStringStartIndex, offsetBy: infoLength) - let infoString: String = String(responseString[infoStringStartIndex.. "l\(infoLength)\(infoString)e".count else { - return seal.fulfill((responseInfo, nil)) - } - - // TODO: Is this going to be done anymore...??? -// if let timestamp = body["t"] as? Int64 { -// let offset = timestamp - Int64(NSDate.millisecondTimestamp()) -// SnodeAPI.clockOffset = offset -// } - - // Extract the response data as well - let dataString: String = String(responseString.suffix(from: infoStringEndIndex)) - let dataStringParts: [String.SubSequence] = dataString.split(separator: ":") - - guard dataStringParts.count > 1, let finalDataLength: Int = Int(dataStringParts[0]) else { - return seal.reject(HTTP.Error.invalidResponse) - } - - let finalDataStringStartIndex: String.Index = responseString.index(infoStringEndIndex, offsetBy: "\(finalDataLength):".count) - let finalDataStringEndIndex: String.Index = responseString.index(finalDataStringStartIndex, offsetBy: finalDataLength) - let finalDataString: String = String(responseString[finalDataStringStartIndex.. String? { + guard let url = request.url else { return nil } + + switch version { + // V2 and V3 Onion Requests have the same structure + case .v2, .v3: + var rawHeaders = request.allHTTPHeaderFields ?? [:] + rawHeaders.removeValue(forKey: "User-Agent") + var headers: JSON = rawHeaders.mapValues { value in + switch value.lowercased() { + case "true": return true + case "false": return false + default: return value + } + } + + var endpoint = url.path.removingPrefix("/") + if let query = url.query { endpoint += "?\(query)" } + let bodyAsString: String + + if let body: Data = request.httpBody { + headers["Content-Type"] = "application/json" // Assume data is JSON + bodyAsString = (String(data: body, encoding: .utf8) ?? "null") + } + else if let inputStream: InputStream = request.httpBodyStream, let body: Data = try? Data(from: inputStream) { + headers["Content-Type"] = request.allHTTPHeaderFields!["Content-Type"] + bodyAsString = "{ \"fileUpload\" : \"\(String(data: body.base64EncodedData(), encoding: .utf8) ?? "null")\" }" + } + else { + bodyAsString = "null" + } + + let payload: JSON = [ + "body" : bodyAsString, + "endpoint" : endpoint, + "method" : request.httpMethod!, + "headers" : headers + ] + + guard let jsonData: Data = try? JSONSerialization.data(withJSONObject: payload, options: []) else { return nil } + + return String(data: jsonData, encoding: .utf8) + + // V4 Onion Requests have a very different structure + case .v4: + // Note: We need to remove the leading forward slash unless we are explicitly hitting a legacy + // endpoint (in which case we need it to ensure the request signing works correctly + // TODO: Confirm the 'removingPrefix' isn't going to break the request signing on non-legacy endpoints + let endpoint: String = url.path + .appending(url.query.map { value in "?\(value)" }) + + let requestInfo: RequestInfo = RequestInfo( + method: (request.httpMethod ?? "GET"), // Default (if nil) is 'GET' + endpoint: endpoint, + headers: (request.allHTTPHeaderFields ?? [:]) + .setting( + "Content-Type", + // TODO: Determine what 'Content-Type' 'httpBodyStream' should have???. + (request.httpBody == nil && request.httpBodyStream == nil ? nil : + ((request.allHTTPHeaderFields ?? [:])["Content-Type"] ?? "application/json") // Default to JSON if not defined + ) + ) + .removingValue(forKey: "User-Agent") + ) + + guard let requestInfoData: Data = try? JSONEncoder().encode(requestInfo), let requestInfoString: String = String(data: requestInfoData, encoding: .ascii) else { + return nil + } + + if let body: Data = request.httpBody { + guard let bodyString: String = String(data: body, encoding: .ascii) else { + return nil + } + + return "l\(requestInfoString.count):\(requestInfoString)\(bodyString.count):\(bodyString)e" + } + else if let inputStream: InputStream = request.httpBodyStream, let body: Data = try? Data(from: inputStream), let bodyString: String = String(data: body, encoding: .ascii) { + // TODO: Handle this properly + // headers["Content-Type"] = request.allHTTPHeaderFields!["Content-Type"] + // bodyAsString = "{ \"fileUpload\" : \"\(String(data: body.base64EncodedData(), encoding: .utf8) ?? "null")\" }" + return "l\(requestInfoString.count):\(requestInfoString)\(bodyString.count):\(bodyString)e" + } + else { + return "l\(requestInfoString.count):\(requestInfoString)e" + } + } + } + + private static func handleResponse( + responseData: Data, + destinationSymmetricKey: Data, + version: Version, + destination: Destination, + seal: Resolver<(OnionRequestResponseInfoType, Data?)> + ) { + switch version { + // V2 and V3 Onion Requests have the same structure for responses + case .v2, .v3: + let json: JSON + + if let processedJson = try? JSONSerialization.jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? JSON { + json = processedJson + } + else if let result: String = String(data: responseData, encoding: .utf8) { + json = [ "result": result ] + } + else { + return seal.reject(HTTP.Error.invalidJSON) + } + + guard let base64EncodedIVAndCiphertext = json["result"] as? String, let ivAndCiphertext = Data(base64Encoded: base64EncodedIVAndCiphertext), ivAndCiphertext.count >= AESGCM.ivSize else { + return seal.reject(HTTP.Error.invalidJSON) + } + + do { + let data = try AESGCM.decrypt(ivAndCiphertext, with: destinationSymmetricKey) + + guard let json = try JSONSerialization.jsonObject(with: data, options: [ .fragmentsAllowed ]) as? JSON, let statusCode = json["status_code"] as? Int ?? json["status"] as? Int else { + return seal.reject(HTTP.Error.invalidJSON) + } + + if statusCode == 406 { // Clock out of sync + SNLog("The user's clock is out of sync with the service node network.") + return seal.reject(SnodeAPI.Error.clockOutOfSync) + } + + if let bodyAsString = json["body"] as? String { + guard let bodyAsData = bodyAsString.data(using: .utf8), let body = try JSONSerialization.jsonObject(with: bodyAsData, options: [ .fragmentsAllowed ]) as? JSON else { + return seal.reject(HTTP.Error.invalidJSON) + } + + if let timestamp = body["t"] as? Int64 { + let offset = timestamp - Int64(NSDate.millisecondTimestamp()) + SnodeAPI.clockOffset = offset + } + + guard 200...299 ~= statusCode else { + return seal.reject(Error.httpRequestFailedAtDestination(statusCode: UInt(statusCode), json: body, destination: destination)) + } + + return seal.fulfill((OnionRequestAPI.ResponseInfo(code: statusCode, headers: [:]), bodyAsData)) + } + + guard 200...299 ~= statusCode else { + return seal.reject(Error.httpRequestFailedAtDestination(statusCode: UInt(statusCode), json: json, destination: destination)) + } + + return seal.fulfill((OnionRequestAPI.ResponseInfo(code: statusCode, headers: [:]), data)) + + } + catch { + return seal.reject(error) + } + + // V4 Onion Requests have a very different structure for responses + case .v4: + guard responseData.count >= AESGCM.ivSize else { return seal.reject(HTTP.Error.invalidResponse) } + + do { + let data: Data = try AESGCM.decrypt(responseData, with: destinationSymmetricKey) + + // The data will be in the form of `l123:jsone` or `l123:json456:bodye` so we need to break the data into + // parts to properly process it + guard let responseString: String = String(data: data, encoding: .ascii), responseString.starts(with: "l") else { + return seal.reject(HTTP.Error.invalidResponse) + } + + let stringParts: [String.SubSequence] = responseString.split(separator: ":") + + guard stringParts.count > 1, let infoLength: Int = Int(stringParts[0].suffix(from: stringParts[0].index(stringParts[0].startIndex, offsetBy: 1))) else { + return seal.reject(HTTP.Error.invalidResponse) + } + + let infoStringStartIndex: String.Index = responseString.index(responseString.startIndex, offsetBy: "l\(infoLength):".count) + let infoStringEndIndex: String.Index = responseString.index(infoStringStartIndex, offsetBy: infoLength) + let infoString: String = String(responseString[infoStringStartIndex.. "l\(infoLength)\(infoString)e".count else { + return seal.fulfill((responseInfo, nil)) + } + + // TODO: Is this going to be done anymore...??? +// if let timestamp = body["t"] as? Int64 { +// let offset = timestamp - Int64(NSDate.millisecondTimestamp()) +// SnodeAPI.clockOffset = offset +// } + + // Extract the response data as well + let dataString: String = String(responseString.suffix(from: infoStringEndIndex)) + let dataStringParts: [String.SubSequence] = dataString.split(separator: ":") + + guard dataStringParts.count > 1, let finalDataLength: Int = Int(dataStringParts[0]) else { + return seal.reject(HTTP.Error.invalidResponse) + } + + let finalDataStringStartIndex: String.Index = responseString.index(infoStringEndIndex, offsetBy: "\(finalDataLength):".count) + let finalDataStringEndIndex: String.Index = responseString.index(finalDataStringStartIndex, offsetBy: finalDataLength) + let finalDataString: String = String(responseString[finalDataStringStartIndex.. RawResponsePromise { if Features.useOnionRequests { - // TODO: Ensure this should use the Legact request? - return LegacyOnionRequestAPI.sendOnionRequest(to: snode, invoking: method, with: parameters, associatedWith: publicKey).map2 { $0 as Any } + // TODO: Ensure this should use the v3 request? + return OnionRequestAPI.sendOnionRequest(to: snode, invoking: method, with: parameters, using: .v3, associatedWith: publicKey).map2 { $0 as Any } } else { let url = "\(snode.address):\(snode.port)/storage_rpc/v1" return HTTP.execute(.post, url, parameters: parameters).map2 { $0 as Any }.recover2 { error -> Promise in From 4963a84ddd092646f44ab5680933a24ef23ec5dd Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 15 Feb 2022 16:00:51 +1100 Subject: [PATCH 007/157] Added more unit tests for the OpenGroupAPI (fixed a couple bugs as well) --- Session.xcodeproj/project.pbxproj | 32 +- .../xcschemes/SessionMessagingKit.xcscheme | 12 +- .../Common Networking/Header.swift | 1 + .../Common Networking/QueryParam.swift | 1 - .../Database/Storage+OpenGroups.swift | 7 +- .../Open Groups/Models/BatchRequestInfo.swift | 6 +- .../Open Groups/OpenGroupAPIV2.swift | 175 +++---- .../Open Groups/Types/Dependencies.swift | 52 ++ .../Open Groups/Types/Request.swift | 8 +- .../Open Groups/Types/SodiumProtocols.swift | 27 + SessionMessagingKit/Storage.swift | 2 +- .../Open Groups/OpenGroupAPIV2Tests.swift | 461 +++++++++++++++--- .../_TestUtilities/TestStorage.swift | 8 + .../General/String+Encoding.swift | 2 +- 14 files changed, 616 insertions(+), 178 deletions(-) create mode 100644 SessionMessagingKit/Open Groups/Types/Dependencies.swift create mode 100644 SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index b51ac4466..f65d6f9b8 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -151,6 +151,7 @@ A1C32D5017A06538000A904E /* AddressBookUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A1C32D4F17A06537000A904E /* AddressBookUI.framework */; }; A1C32D5117A06544000A904E /* AddressBook.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A1C32D4D17A0652C000A904E /* AddressBook.framework */; }; A5509ECA1A69AB8B00ABA4BC /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = A5509EC91A69AB8B00ABA4BC /* Main.storyboard */; }; + B5FE70D512E75D659386BAD4 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionMessagingKit_SessionMessagingKitTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F1E0F51F17E4443731B94D32 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionMessagingKit_SessionMessagingKitTests.framework */; }; B66DBF4A19D5BBC8006EA940 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B66DBF4919D5BBC8006EA940 /* Images.xcassets */; }; B67EBF5D19194AC60084CCFD /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = B67EBF5C19194AC60084CCFD /* Settings.bundle */; }; B6B226971BE4B7D200860F4D /* ContactsUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B6B226961BE4B7D200860F4D /* ContactsUI.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; @@ -767,7 +768,6 @@ D24B5BD5169F568C00681372 /* AudioToolbox.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D24B5BD4169F568C00681372 /* AudioToolbox.framework */; }; D2AEACDC16C426DA00C364C0 /* CFNetwork.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2AEACDB16C426DA00C364C0 /* CFNetwork.framework */; }; D48CEFD2222D323FEFEFC6CC /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SignalUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8962372EEC51D3F56FE3A68A /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SignalUtilitiesKit.framework */; }; - E197F4A653289312F13926E6 /* Pods_SessionMessagingKitTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CF061FF98D2BFA3ECCF8C6F6 /* Pods_SessionMessagingKitTests.framework */; }; EF764C351DB67CC5000D9A87 /* UIViewController+Permissions.m in Sources */ = {isa = PBXBuildFile; fileRef = EF764C341DB67CC5000D9A87 /* UIViewController+Permissions.m */; }; F5765D284BC6ECAC0C1D33F0 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionNotificationServiceExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E4A93ECA93B3DE800CC7D7F6 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionNotificationServiceExtension.framework */; }; FC3BD9881A30A790005B96BB /* Social.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FC3BD9871A30A790005B96BB /* Social.framework */; }; @@ -842,6 +842,8 @@ FDC438B727BB160000C60D73 /* Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438B627BB160000C60D73 /* Error.swift */; }; FDC438B927BB161E00C60D73 /* Version.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438B827BB161E00C60D73 /* Version.swift */; }; FDC438BD27BB2AB400C60D73 /* Mockable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438BC27BB2AB400C60D73 /* Mockable.swift */; }; + FDC438C127BB4E6800C60D73 /* Dependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438C027BB4E6800C60D73 /* Dependencies.swift */; }; + FDC438C327BB512200C60D73 /* SodiumProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438C227BB512200C60D73 /* SodiumProtocols.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1022,6 +1024,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0208C84C4D15048D699BEC10 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests.app store release.xcconfig"; sourceTree = ""; }; 038A3BABD5BA0CE41D8C17F5 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionShareExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionShareExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0840117FDFD286D1CC14A2E1 /* Pods-SessionTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SessionTests/Pods-SessionTests.debug.xcconfig"; sourceTree = ""; }; 0D3D13FEE4FF6A2E2ED85322 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit.app store release.xcconfig"; sourceTree = ""; }; @@ -1227,6 +1230,7 @@ 8981C8F64D94D3C52EB67A2C /* Pods-SignalTests.test.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SignalTests.test.xcconfig"; path = "Pods/Target Support Files/Pods-SignalTests/Pods-SignalTests.test.xcconfig"; sourceTree = ""; }; 8EEE74B0753448C085B48721 /* Pods-SignalMessaging.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SignalMessaging.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SignalMessaging/Pods-SignalMessaging.app store release.xcconfig"; sourceTree = ""; }; 948239851C08032C842937CC /* Pods-SignalMessaging.test.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SignalMessaging.test.xcconfig"; path = "Pods/Target Support Files/Pods-SignalMessaging/Pods-SignalMessaging.test.xcconfig"; sourceTree = ""; }; + 949F269926ABA08C125DCA9D /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests.debug.xcconfig"; sourceTree = ""; }; 9AE1058A3BB2148A279432B2 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit.debug.xcconfig"; sourceTree = ""; }; 9B3329176C10E9640865E65B /* Pods-GlobalDependencies-Session.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-Session.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-Session/Pods-GlobalDependencies-Session.debug.xcconfig"; sourceTree = ""; }; 9B533A9FA46206D3D99C9ADA /* Pods-SignalMessaging.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SignalMessaging.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SignalMessaging/Pods-SignalMessaging.debug.xcconfig"; sourceTree = ""; }; @@ -1869,7 +1873,6 @@ C8153B96A292A25045BE2C54 /* Pods-SessionTests.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionTests.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionTests/Pods-SessionTests.app store release.xcconfig"; sourceTree = ""; }; C88965DE4F4EC4FC919BEC4E /* Pods-SessionUIKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionUIKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SessionUIKit/Pods-SessionUIKit.debug.xcconfig"; sourceTree = ""; }; C98441E849C3CA7FE8220D33 /* Pods-SessionNotificationServiceExtension.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionNotificationServiceExtension.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionNotificationServiceExtension/Pods-SessionNotificationServiceExtension.app store release.xcconfig"; sourceTree = ""; }; - CF061FF98D2BFA3ECCF8C6F6 /* Pods_SessionMessagingKitTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SessionMessagingKitTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D2179CFB16BB0B3A0006F3AB /* CoreTelephony.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreTelephony.framework; path = System/Library/Frameworks/CoreTelephony.framework; sourceTree = SDKROOT; }; D2179CFD16BB0B480006F3AB /* SystemConfiguration.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SystemConfiguration.framework; path = System/Library/Frameworks/SystemConfiguration.framework; sourceTree = SDKROOT; }; D221A089169C9E5E00537ABF /* Session.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Session.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1894,6 +1897,7 @@ EF764C331DB67CC5000D9A87 /* UIViewController+Permissions.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "UIViewController+Permissions.h"; sourceTree = ""; }; EF764C341DB67CC5000D9A87 /* UIViewController+Permissions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "UIViewController+Permissions.m"; sourceTree = ""; }; F121FB43E2A1C1CF7F2AFC23 /* Pods-SessionPushNotificationExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionPushNotificationExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SessionPushNotificationExtension/Pods-SessionPushNotificationExtension.debug.xcconfig"; sourceTree = ""; }; + F1E0F51F17E4443731B94D32 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionMessagingKit_SessionMessagingKitTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionMessagingKit_SessionMessagingKitTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; F4DC483F404B65C59D9C2CF8 /* Pods-SessionMessagingKitTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionMessagingKitTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SessionMessagingKitTests/Pods-SessionMessagingKitTests.debug.xcconfig"; sourceTree = ""; }; F62ECF7B8AF4F8089AA705B3 /* Pods-LokiPushNotificationService.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LokiPushNotificationService.debug.xcconfig"; path = "Pods/Target Support Files/Pods-LokiPushNotificationService/Pods-LokiPushNotificationService.debug.xcconfig"; sourceTree = ""; }; F9BBF530D71905BA9007675F /* Pods-SessionShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionShareExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SessionShareExtension/Pods-SessionShareExtension.debug.xcconfig"; sourceTree = ""; }; @@ -1968,6 +1972,8 @@ FDC438B627BB160000C60D73 /* Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Error.swift; sourceTree = ""; }; FDC438B827BB161E00C60D73 /* Version.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Version.swift; sourceTree = ""; }; FDC438BC27BB2AB400C60D73 /* Mockable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mockable.swift; sourceTree = ""; }; + FDC438C027BB4E6800C60D73 /* Dependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dependencies.swift; sourceTree = ""; }; + FDC438C227BB512200C60D73 /* SodiumProtocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SodiumProtocols.swift; sourceTree = ""; }; FEDBAE1B98C49BBE8C87F575 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; sourceTree = ""; }; FF9BA33D021B115B1F5B4E46 /* Pods-SessionMessagingKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionMessagingKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionMessagingKit/Pods-SessionMessagingKit.app store release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -2089,7 +2095,7 @@ buildActionMask = 2147483647; files = ( FDC4389227B9FFC700C60D73 /* SessionMessagingKit.framework in Frameworks */, - E197F4A653289312F13926E6 /* Pods_SessionMessagingKitTests.framework in Frameworks */, + B5FE70D512E75D659386BAD4 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionMessagingKit_SessionMessagingKitTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2303,6 +2309,8 @@ A0FB43B511403A5FAFAC88B8 /* Pods-SessionMessagingKitTests.app store release.xcconfig */, 0840117FDFD286D1CC14A2E1 /* Pods-SessionTests.debug.xcconfig */, C8153B96A292A25045BE2C54 /* Pods-SessionTests.app store release.xcconfig */, + 949F269926ABA08C125DCA9D /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests.debug.xcconfig */, + 0208C84C4D15048D699BEC10 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests.app store release.xcconfig */, ); name = Pods; sourceTree = ""; @@ -3761,8 +3769,8 @@ 038A3BABD5BA0CE41D8C17F5 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionShareExtension.framework */, C5060C3B36A848B71CCE4685 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit.framework */, 8962372EEC51D3F56FE3A68A /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SignalUtilitiesKit.framework */, - CF061FF98D2BFA3ECCF8C6F6 /* Pods_SessionMessagingKitTests.framework */, D2C155B76C8483CB9A6EA9B4 /* Pods_SessionTests.framework */, + F1E0F51F17E4443731B94D32 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionMessagingKit_SessionMessagingKitTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -3814,6 +3822,8 @@ FDC4380827B31D4E00C60D73 /* Error.swift */, FDC4381627B32EC700C60D73 /* Personalization.swift */, FDC4381427B329CE00C60D73 /* NonceGenerator16Byte.swift */, + FDC438C027BB4E6800C60D73 /* Dependencies.swift */, + FDC438C227BB512200C60D73 /* SodiumProtocols.swift */, ); path = Types; sourceTree = ""; @@ -4666,15 +4676,15 @@ files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-SessionMessagingKitTests/Pods-SessionMessagingKitTests-frameworks-${CONFIGURATION}-input-files.xcfilelist", + "${PODS_ROOT}/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-SessionMessagingKitTests/Pods-SessionMessagingKitTests-frameworks-${CONFIGURATION}-output-files.xcfilelist", + "${PODS_ROOT}/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SessionMessagingKitTests/Pods-SessionMessagingKitTests-frameworks.sh\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; 7E2D14F857C70F98DED3B8E9 /* [CP] Check Pods Manifest.lock */ = { @@ -4736,7 +4746,7 @@ outputFileListPaths = ( ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-SessionMessagingKitTests-checkManifestLockResult.txt", + "$(DERIVED_FILE_DIR)/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; @@ -5210,6 +5220,7 @@ FDC4384827B47F4D00C60D73 /* LegacyRoomInfo.swift in Sources */, C32C598A256D0664003C73A2 /* SNProtoEnvelope+Conversion.swift in Sources */, C352A2FF25574B6300338F3E /* MessageSendJob.swift in Sources */, + FDC438C327BB512200C60D73 /* SodiumProtocols.swift in Sources */, B8856D11256F112A001CE70E /* OWSAudioSession.swift in Sources */, C3DB66C3260ACCE6001EFC55 /* OpenGroupPollerV2.swift in Sources */, FDC438AE27BB148700C60D73 /* UserDeleteMessagesResponse.swift in Sources */, @@ -5230,6 +5241,7 @@ C32C5FD6256E0346003C73A2 /* Notification+Thread.swift in Sources */, FDC438A827BB11CD00C60D73 /* UserPermissionsRequest.swift in Sources */, C3BBE0C72554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift in Sources */, + FDC438C127BB4E6800C60D73 /* Dependencies.swift in Sources */, FDC4383827B3863200C60D73 /* VersionResponse.swift in Sources */, C32C5C88256DD0D2003C73A2 /* Storage+Messaging.swift in Sources */, C32C59C7256DB41F003C73A2 /* TSThread.m in Sources */, @@ -6878,7 +6890,7 @@ }; FDC4389627B9FFC700C60D73 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = F4DC483F404B65C59D9C2CF8 /* Pods-SessionMessagingKitTests.debug.xcconfig */; + baseConfigurationReference = 949F269926ABA08C125DCA9D /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests.debug.xcconfig */; buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -6919,7 +6931,7 @@ }; FDC4389727B9FFC700C60D73 /* App Store Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = A0FB43B511403A5FAFAC88B8 /* Pods-SessionMessagingKitTests.app store release.xcconfig */; + baseConfigurationReference = 0208C84C4D15048D699BEC10 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests.app store release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; diff --git a/Session.xcodeproj/xcshareddata/xcschemes/SessionMessagingKit.xcscheme b/Session.xcodeproj/xcshareddata/xcschemes/SessionMessagingKit.xcscheme index d10ea9ea8..cbee62b91 100644 --- a/Session.xcodeproj/xcshareddata/xcschemes/SessionMessagingKit.xcscheme +++ b/Session.xcodeproj/xcshareddata/xcschemes/SessionMessagingKit.xcscheme @@ -27,7 +27,17 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES" - codeCoverageEnabled = "YES"> + codeCoverageEnabled = "YES" + onlyGenerateCoverageForSpecifiedTargets = "YES"> + + + + diff --git a/SessionMessagingKit/Common Networking/Header.swift b/SessionMessagingKit/Common Networking/Header.swift index 9081fbf05..56b37c988 100644 --- a/SessionMessagingKit/Common Networking/Header.swift +++ b/SessionMessagingKit/Common Networking/Header.swift @@ -7,6 +7,7 @@ enum Header: String { case contentType = "Content-Type" case room = "Room" // TODO: Confirm this is needed + case fileName = "X-Filename" case sogsPubKey = "X-SOGS-Pubkey" case sogsNonce = "X-SOGS-Nonce" diff --git a/SessionMessagingKit/Common Networking/QueryParam.swift b/SessionMessagingKit/Common Networking/QueryParam.swift index 611b30eb8..81e9d849e 100644 --- a/SessionMessagingKit/Common Networking/QueryParam.swift +++ b/SessionMessagingKit/Common Networking/QueryParam.swift @@ -7,6 +7,5 @@ enum QueryParam: String { case fromServerId = "from_server_id" case required = "required" - case fileName = "X-Filename" case limit // For messages - number between 1 and 256 (default is 100) } diff --git a/SessionMessagingKit/Database/Storage+OpenGroups.swift b/SessionMessagingKit/Database/Storage+OpenGroups.swift index 5ea663550..a769b2fd7 100644 --- a/SessionMessagingKit/Database/Storage+OpenGroups.swift +++ b/SessionMessagingKit/Database/Storage+OpenGroups.swift @@ -1,5 +1,10 @@ -extension Storage { +public protocol SessionMessagingKitOpenGroupStorageProtocol { + func getOpenGroupImage(for room: String, on server: String) -> Data? + func setOpenGroupImage(to data: Data, for room: String, on server: String, using transaction: Any) +} + +extension Storage: SessionMessagingKitOpenGroupStorageProtocol { // MARK: - Open Groups diff --git a/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift b/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift index e75ceb191..b5ac2812a 100644 --- a/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift +++ b/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift @@ -68,7 +68,7 @@ public extension Decodable { } extension Promise where T == (OnionRequestResponseInfoType, Data?) { - func decoded(as types: OpenGroupAPIV2.BatchResponseTypes, on queue: DispatchQueue? = nil, error: Error? = nil) -> Promise { + func decoded(as types: OpenGroupAPIV2.BatchResponseTypes, on queue: DispatchQueue? = nil, error: Error) -> Promise { self.map(on: queue) { responseInfo, maybeData -> OpenGroupAPIV2.BatchResponse in // Need to split the data into an array of data so each item can be Decoded correctly guard let data: Data = maybeData else { throw OpenGroupAPIV2.Error.parsingFailed } @@ -85,8 +85,8 @@ extension Promise where T == (OnionRequestResponseInfoType, Data?) { .map { data, type in try type.decoded(from: data) } .map { data in (responseInfo, data) } } - catch let thrownError { - throw (error ?? thrownError) + catch _ { + throw error } } } diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift b/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift index 8d9cd0faf..c490dedcc 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift @@ -197,7 +197,7 @@ public final class OpenGroupAPIV2: NSObject { // MARK: - Capabilities - public static func capabilities(on server: String) -> Promise<(OnionRequestResponseInfoType, Capabilities)> { + public static func capabilities(on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Capabilities)> { let request: Request = Request( server: server, endpoint: .capabilities, @@ -205,45 +205,39 @@ public final class OpenGroupAPIV2: NSObject { ) // TODO: Handle a `412` response (ie. a required capability isn't supported). - return send(request) + return send(request, using: dependencies) .decoded(as: Capabilities.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) } // MARK: - Room - public static func rooms( - for server: String, - through api: OnionRequestAPIType.Type = OnionRequestAPI.self, - using storage: SessionMessagingKitStorageProtocol = SNMessagingKitConfiguration.shared.storage, - nonceGenerator: NonceGenerator16ByteType = NonceGenerator16Byte(), - date: Date = Date() - ) -> Promise<(OnionRequestResponseInfoType, [Room])> { + public static func rooms(for server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [Room])> { let request: Request = Request( server: server, endpoint: .rooms ) - return send(request, through: api, using: storage, nonceGenerator: nonceGenerator, date: date) + return send(request, using: dependencies) .decoded(as: [Room].self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) } - public static func room(for roomToken: String, on server: String) -> Promise<(OnionRequestResponseInfoType, Room)> { + public static func room(for roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Room)> { let request: Request = Request( server: server, endpoint: .room(roomToken) ) - return send(request) + return send(request, using: dependencies) .decoded(as: Room.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) } - public static func roomPollInfo(lastUpdated: Int64, for roomToken: String, on server: String) -> Promise<(OnionRequestResponseInfoType, RoomPollInfo)> { + public static func roomPollInfo(lastUpdated: Int64, for roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, RoomPollInfo)> { let request: Request = Request( server: server, endpoint: .roomPollInfo(roomToken, lastUpdated) ) - return send(request) + return send(request, using: dependencies) .decoded(as: RoomPollInfo.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) } @@ -255,7 +249,8 @@ public final class OpenGroupAPIV2: NSObject { on server: String, whisperTo: String?, whisperMods: Bool, - with serverPublicKey: String + with serverPublicKey: String, + using dependencies: Dependencies = Dependencies() ) -> Promise<(OnionRequestResponseInfoType, Message)> { // TODO: Change this to use '.blinded' once it's working. guard let signedRequest: (data: Data, signature: Data) = SendMessageRequest.sign(message: plaintext, for: .standard, with: serverPublicKey) else { @@ -281,12 +276,11 @@ public final class OpenGroupAPIV2: NSObject { body: body ) - return send(request) + return send(request, using: dependencies) .decoded(as: Message.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) } - public static func recentMessages(in roomToken: String, on server: String) -> Promise<(OnionRequestResponseInfoType, [Message])> { - // TODO: Recent vs. Since? + public static func recentMessages(in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [Message])> { let request: Request = Request( server: server, endpoint: .roomMessagesRecent(roomToken) @@ -296,15 +290,15 @@ public final class OpenGroupAPIV2: NSObject { // ].compactMapValues { $0 } ) - return send(request) + return send(request, using: dependencies) .decoded(as: [Message].self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) .then(on: OpenGroupAPIV2.workQueue) { responseInfo, messages -> Promise<(OnionRequestResponseInfoType, [Message])> in - process(messages: messages, for: roomToken, on: server) + process(messages: messages, for: roomToken, on: server, using: dependencies) .map { processedMessages in (responseInfo, processedMessages) } } } - public static func messagesBefore(messageId: Int64, in roomToken: String, on server: String) -> Promise<(OnionRequestResponseInfoType, [Message])> { + public static func messagesBefore(messageId: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [Message])> { // TODO: Recent vs. Since? let request: Request = Request( server: server, @@ -315,10 +309,10 @@ public final class OpenGroupAPIV2: NSObject { // ].compactMapValues { $0 } ) - return send(request) + return send(request, using: dependencies) .decoded(as: [Message].self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) .then(on: OpenGroupAPIV2.workQueue) { responseInfo, messages -> Promise<(OnionRequestResponseInfoType, [Message])> in - process(messages: messages, for: roomToken, on: server) + process(messages: messages, for: roomToken, on: server, using: dependencies) .map { processedMessages in (responseInfo, processedMessages) } } } @@ -334,53 +328,53 @@ public final class OpenGroupAPIV2: NSObject { // ].compactMapValues { $0 } ) - return send(request) + return send(request, using: dependencies) .decoded(as: [Message].self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) .then(on: OpenGroupAPIV2.workQueue) { responseInfo, messages -> Promise<(OnionRequestResponseInfoType, [Message])> in - process(messages: messages, for: roomToken, on: server) + process(messages: messages, for: roomToken, on: server, using: dependencies) .map { processedMessages in (responseInfo, processedMessages) } } } // MARK: - Pinning - public static func pinMessage(id: Int64, in roomToken: String, on server: String) -> Promise { + public static func pinMessage(id: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise { let request: Request = Request( method: .post, server: server, endpoint: .roomPinMessage(roomToken, id: id) ) - return send(request) + return send(request, using: dependencies) .map { responseInfo, _ in responseInfo } } - public static func unpinMessage(id: Int64, in roomToken: String, on server: String) -> Promise { + public static func unpinMessage(id: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise { let request: Request = Request( method: .post, server: server, endpoint: .roomUnpinMessage(roomToken, id: id) ) - return send(request) + return send(request, using: dependencies) .map { responseInfo, _ in responseInfo } } - public static func unpinAll(in roomToken: String, on server: String) -> Promise { + public static func unpinAll(in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise { let request: Request = Request( method: .post, server: server, endpoint: .roomUnpinAll(roomToken) ) - return send(request) + return send(request, using: dependencies) .map { responseInfo, _ in responseInfo } } // MARK: - Files // TODO: Shift this logic to the `OpenGroupManager` (makes more sense since it's not API logic) - public static func roomImage(_ fileId: Int64, for roomToken: String, on server: String) -> Promise { + public static func roomImage(_ fileId: Int64, for roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise { // Normally the image for a given group is stored with the group thread, so it's only // fetched once. However, on the join open group screen we show images for groups the // user * hasn't * joined yet. We don't want to re-fetch these images every time the @@ -391,11 +385,11 @@ public final class OpenGroupAPIV2: NSObject { // don't double up on fetch requests by storing the existing request as a promise if // there is one. let lastOpenGroupImageUpdate: Date? = UserDefaults.standard[.lastOpenGroupImageUpdate] - let now: Date = Date() + let now: Date = dependencies.date let timeSinceLastUpdate: TimeInterval = (given(lastOpenGroupImageUpdate) { now.timeIntervalSince($0) } ?? .greatestFiniteMagnitude) let updateInterval: TimeInterval = (7 * 24 * 60 * 60) - if let data = Storage.shared.getOpenGroupImage(for: roomToken, on: server), server == defaultServer, timeSinceLastUpdate < updateInterval { + if let data = dependencies.storage.getOpenGroupImage(for: roomToken, on: server), server == defaultServer, timeSinceLastUpdate < updateInterval { return Promise.value(data) } @@ -403,12 +397,12 @@ public final class OpenGroupAPIV2: NSObject { return promise } - let promise: Promise = downloadFile(fileId, from: roomToken, on: server) + let promise: Promise = downloadFile(fileId, from: roomToken, on: server, using: dependencies) .map { _, data in data } _ = promise.done(on: OpenGroupAPIV2.workQueue) { imageData in if server == defaultServer { - Storage.shared.write { transaction in - Storage.shared.setOpenGroupImage(to: imageData, for: roomToken, on: server, using: transaction) + dependencies.storage.write { transaction in + dependencies.storage.setOpenGroupImage(to: imageData, for: roomToken, on: server, using: transaction) } UserDefaults.standard[.lastOpenGroupImageUpdate] = now } @@ -418,41 +412,41 @@ public final class OpenGroupAPIV2: NSObject { return promise } - public static func uploadFile(_ bytes: [UInt8], fileName: String? = nil, to roomToken: String, on server: String) -> Promise<(OnionRequestResponseInfoType, FileUploadResponse)> { + public static func uploadFile(_ bytes: [UInt8], fileName: String? = nil, to roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, FileUploadResponse)> { let request: Request = Request( method: .post, server: server, endpoint: .roomFile(roomToken), - queryParameters: [ .fileName: fileName ].compactMapValues { $0 }, + headers: [ .fileName: fileName ].compactMapValues { $0 }, body: Data(bytes) ) - return send(request) + return send(request, using: dependencies) .decoded(as: FileUploadResponse.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) } /// Warning: This approach is less efficient as it expects the data to be base64Encoded (with is 33% larger than binary), please use the binary approach /// whenever possible - public static func uploadFile(_ base64EncodedString: String, fileName: String? = nil, to roomToken: String, on server: String) -> Promise<(OnionRequestResponseInfoType, FileUploadResponse)> { + public static func uploadFile(_ base64EncodedString: String, fileName: String? = nil, to roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, FileUploadResponse)> { let request: Request = Request( method: .post, server: server, endpoint: .roomFileJson(roomToken), - queryParameters: [ .fileName: fileName ].compactMapValues { $0 }, + headers: [ .fileName: fileName ].compactMapValues { $0 }, body: Data(base64Encoded: base64EncodedString) ) - return send(request) + return send(request, using: dependencies) .decoded(as: FileUploadResponse.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) } - public static func downloadFile(_ fileId: Int64, from roomToken: String, on server: String) -> Promise<(OnionRequestResponseInfoType, Data)> { + public static func downloadFile(_ fileId: Int64, from roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Data)> { let request: Request = Request( server: server, endpoint: .roomFileIndividual(roomToken, fileId) ) - return send(request) + return send(request, using: dependencies) .map { responseInfo, maybeData in guard let data: Data = maybeData else { throw Error.parsingFailed } @@ -460,19 +454,19 @@ public final class OpenGroupAPIV2: NSObject { } } - public static func downloadFileJson(_ fileId: Int64, from roomToken: String, on server: String) -> Promise<(OnionRequestResponseInfoType, FileDownloadResponse)> { + public static func downloadFileJson(_ fileId: Int64, from roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, FileDownloadResponse)> { let request: Request = Request( server: server, endpoint: .roomFileIndividualJson(roomToken, fileId) ) // TODO: This endpoint is getting rewritten to return just data (properties would come through as headers) - return send(request) + return send(request, using: dependencies) .decoded(as: FileDownloadResponse.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) } // MARK: - Users - public static func userBan(_ sessionId: String, for timeout: TimeInterval? = nil, from roomTokens: [String]? = nil, on server: String) -> Promise<(OnionRequestResponseInfoType, Data?)> { + public static func userBan(_ sessionId: String, for timeout: TimeInterval? = nil, from roomTokens: [String]? = nil, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Data?)> { let requestBody: UserBanRequest = UserBanRequest( rooms: roomTokens, global: (roomTokens == nil ? true : nil), @@ -490,10 +484,10 @@ public final class OpenGroupAPIV2: NSObject { body: body ) - return send(request) + return send(request, using: dependencies) } - public static func userUnban(_ sessionId: String, from roomTokens: [String]? = nil, on server: String) -> Promise<(OnionRequestResponseInfoType, Data?)> { + public static func userUnban(_ sessionId: String, from roomTokens: [String]? = nil, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Data?)> { let requestBody: UserUnbanRequest = UserUnbanRequest( rooms: roomTokens, global: (roomTokens == nil ? true : nil) @@ -510,10 +504,10 @@ public final class OpenGroupAPIV2: NSObject { body: body ) - return send(request) + return send(request, using: dependencies) } - public static func userPermissionUpdate(_ sessionId: String, read: Bool, write: Bool, upload: Bool, for roomTokens: [String], timeout: TimeInterval, on server: String) -> Promise<(OnionRequestResponseInfoType, Data?)> { + public static func userPermissionUpdate(_ sessionId: String, read: Bool, write: Bool, upload: Bool, for roomTokens: [String], timeout: TimeInterval, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Data?)> { let requestBody: UserPermissionsRequest = UserPermissionsRequest( rooms: roomTokens, timeout: timeout, @@ -533,10 +527,10 @@ public final class OpenGroupAPIV2: NSObject { body: body ) - return send(request) + return send(request, using: dependencies) } - public static func userModeratorUpdate(_ sessionId: String, moderator: Bool, admin: Bool, visible: Bool, for roomTokens: [String]? = nil, on server: String) -> Promise<(OnionRequestResponseInfoType, Data?)> { + public static func userModeratorUpdate(_ sessionId: String, moderator: Bool, admin: Bool, visible: Bool, for roomTokens: [String]? = nil, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Data?)> { let requestBody: UserModeratorRequest = UserModeratorRequest( rooms: roomTokens, global: (roomTokens == nil ? true : nil), @@ -556,10 +550,10 @@ public final class OpenGroupAPIV2: NSObject { body: body ) - return send(request) + return send(request, using: dependencies) } - public static func userDeleteMessages(_ sessionId: String, for roomTokens: [String]? = nil, on server: String) -> Promise<(OnionRequestResponseInfoType, UserDeleteMessagesResponse)> { + public static func userDeleteMessages(_ sessionId: String, for roomTokens: [String]? = nil, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, UserDeleteMessagesResponse)> { let requestBody: UserDeleteMessagesRequest = UserDeleteMessagesRequest( rooms: roomTokens, global: (roomTokens == nil ? true : nil) @@ -576,26 +570,25 @@ public final class OpenGroupAPIV2: NSObject { body: body ) - return send(request) + return send(request, using: dependencies) .decoded(as: UserDeleteMessagesResponse.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) } // MARK: - Processing // TODO: Move these methods to the OpenGroupManager? (seems odd for them to be in the API) - private static func process(messages: [Message]?, for room: String, on server: String) -> Promise<[Message]> { + private static func process(messages: [Message]?, for room: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<[Message]> { guard let messages: [Message] = messages, !messages.isEmpty else { return Promise.value([]) } - let storage = SNMessagingKitConfiguration.shared.storage let seqNo: Int64 = (messages.compactMap { $0.seqNo }.max() ?? 0) - let lastMessageSeqNo: Int64 = (storage.getLastMessageServerID(for: room, on: server) ?? 0) + let lastMessageSeqNo: Int64 = (dependencies.storage.getLastMessageServerID(for: room, on: server) ?? 0) if seqNo > lastMessageSeqNo { let (promise, seal) = Promise<[Message]>.pending() - storage.write( + dependencies.storage.write( with: { transaction in - storage.setLastMessageServerID(for: room, on: server, to: seqNo, using: transaction) + dependencies.storage.setLastMessageServerID(for: room, on: server, to: seqNo, using: transaction) }, completion: { seal.fulfill(messages) @@ -608,19 +601,18 @@ public final class OpenGroupAPIV2: NSObject { return Promise.value(messages) } - private static func process(deletions: [Deletion]?, for room: String, on server: String) -> Promise<[Deletion]> { + private static func process(deletions: [Deletion]?, for room: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<[Deletion]> { guard let deletions: [Deletion] = deletions else { return Promise.value([]) } - let storage = SNMessagingKitConfiguration.shared.storage let serverID: Int64 = (deletions.compactMap { $0.id }.max() ?? 0) - let lastDeletionServerID: Int64 = (storage.getLastDeletionServerID(for: room, on: server) ?? 0) + let lastDeletionServerID: Int64 = (dependencies.storage.getLastDeletionServerID(for: room, on: server) ?? 0) if serverID > lastDeletionServerID { let (promise, seal) = Promise<[Deletion]>.pending() - storage.write( + dependencies.storage.write( with: { transaction in - storage.setLastDeletionServerID(for: room, on: server, to: serverID, using: transaction) + dependencies.storage.setLastDeletionServerID(for: room, on: server, to: serverID, using: transaction) }, completion: { seal.fulfill(deletions) @@ -639,14 +631,15 @@ public final class OpenGroupAPIV2: NSObject { // MARK: - General - public static func getDefaultRoomsIfNeeded() { + // TODO: Shift this to the OpenGroupManagerV2? (seems more at place there than in the API) + public static func getDefaultRoomsIfNeeded(using dependencies: Dependencies = Dependencies()) { Storage.shared.write( with: { transaction in - Storage.shared.setOpenGroupPublicKey(for: defaultServer, to: defaultServerPublicKey, using: transaction) + dependencies.storage.setOpenGroupPublicKey(for: defaultServer, to: defaultServerPublicKey, using: transaction) }, completion: { let promise = attempt(maxRetryCount: 8, recoveringOn: DispatchQueue.main) { - OpenGroupAPIV2.rooms(for: defaultServer) + OpenGroupAPIV2.rooms(for: defaultServer, using: dependencies) .map { _, data in data } } _ = promise.done(on: OpenGroupAPIV2.workQueue) { items in @@ -657,7 +650,7 @@ public final class OpenGroupAPIV2: NSObject { return (imageId, room.token) } .forEach { imageId, roomToken in - roomImage(imageId, for: roomToken, on: defaultServer) + roomImage(imageId, for: roomToken, on: defaultServer, using: dependencies) .retainUntilComplete() } } @@ -671,26 +664,18 @@ public final class OpenGroupAPIV2: NSObject { // MARK: - Authentication - // TODO: Turn 'Sodium' into a protocol for unit testing - static func sign( - _ request: URLRequest, - with publicKey: String, - using storage: SessionMessagingKitStorageProtocol = SNMessagingKitConfiguration.shared.storage, - sodium: Sodium = Sodium(), - nonceGenerator: NonceGenerator16ByteType = NonceGenerator16Byte(), - date: Date = Date() - ) -> URLRequest? { + private static func sign(_ request: URLRequest, with publicKey: String, using dependencies: Dependencies = Dependencies()) -> URLRequest? { guard let url: URL = request.url else { return nil } var updatedRequest: URLRequest = request let path: String = url.path .appending(url.query.map { value in "?\(value)" }) let method: String = (request.httpMethod ?? "GET") - let timestamp: Int = Int(floor(date.timeIntervalSince1970)) - let nonce: Data = Data(nonceGenerator.nonce()) + let timestamp: Int = Int(floor(dependencies.date.timeIntervalSince1970)) + let nonce: Data = Data(dependencies.nonceGenerator.nonce()) guard let publicKeyData: Data = publicKey.dataFromHex() else { return nil } - guard let userKeyPair: ECKeyPair = storage.getUserKeyPair() else { + guard let userKeyPair: ECKeyPair = dependencies.storage.getUserKeyPair() else { return nil } // guard let blindedKeyPair: ECKeyPair = try? userKeyPair.convert(to: .blinded, with: publicKey) else { @@ -702,7 +687,7 @@ public final class OpenGroupAPIV2: NSObject { /// Generate the sharedSecret by "aB || A || B" where /// a, A are the users private and public keys respectively, /// B is the SOGS public key - let maybeSharedSecret: Data? = sodium.sharedSecret(blindedKeyPair.privateKey.bytes, publicKeyData.bytes)? + let maybeSharedSecret: Data? = dependencies.sodium.sharedSecret(blindedKeyPair.privateKey.bytes, publicKeyData.bytes)? .appending(blindedKeyPair.publicKey) .appending(publicKeyData.bytes) @@ -721,10 +706,10 @@ public final class OpenGroupAPIV2: NSObject { .appending(request.httpBody?.bytes ?? []) // TODO: Might need to do the 'httpBodyStream' as well??? guard let sharedSecret: Data = maybeSharedSecret else { return nil } - guard let intermediateHash: Bytes = sodium.genericHash.hashSaltPersonal(message: sharedSecret.bytes, outputLength: 42, key: nil, salt: nonce.bytes, personal: Personalization.sharedKeys.bytes) else { + guard let intermediateHash: Bytes = dependencies.genericHash.hashSaltPersonal(message: sharedSecret.bytes, outputLength: 42, key: nil, salt: nonce.bytes, personal: Personalization.sharedKeys.bytes) else { return nil } - guard let secretHash: Bytes = sodium.genericHash.hashSaltPersonal(message: secretHashMessage, outputLength: 42, key: intermediateHash, salt: nonce.bytes, personal: Personalization.authHeader.bytes) else { + guard let secretHash: Bytes = dependencies.genericHash.hashSaltPersonal(message: secretHashMessage, outputLength: 42, key: intermediateHash, salt: nonce.bytes, personal: Personalization.authHeader.bytes) else { return nil } @@ -741,13 +726,7 @@ public final class OpenGroupAPIV2: NSObject { // MARK: - Convenience - private static func send( - _ request: Request, - through api: OnionRequestAPIType.Type = OnionRequestAPI.self, - using storage: SessionMessagingKitStorageProtocol = SNMessagingKitConfiguration.shared.storage, - nonceGenerator: NonceGenerator16ByteType = NonceGenerator16Byte(), - date: Date = Date() - ) -> Promise<(OnionRequestResponseInfoType, Data?)> { + private static func send(_ request: Request, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Data?)> { guard let url: URL = request.url else { return Promise(error: Error.invalidURL) } var urlRequest: URLRequest = URLRequest(url: url) @@ -758,21 +737,21 @@ public final class OpenGroupAPIV2: NSObject { urlRequest.httpBody = request.body if request.useOnionRouting { - guard let publicKey = storage.getOpenGroupPublicKey(for: request.server) else { + guard let publicKey = dependencies.storage.getOpenGroupPublicKey(for: request.server) else { return Promise(error: Error.noPublicKey) } if request.isAuthRequired { // Attempt to sign the request with the new auth - guard let signedRequest: URLRequest = sign(urlRequest, with: publicKey, using: storage, nonceGenerator: nonceGenerator, date: date) else { + guard let signedRequest: URLRequest = sign(urlRequest, with: publicKey, using: dependencies) else { return Promise(error: Error.signingFailed) } // TODO: 'removeAuthToken' as a migration??? (would previously do this when getting a `401`). - return api.sendOnionRequest(signedRequest, to: request.server, with: publicKey) + return dependencies.api.sendOnionRequest(signedRequest, to: request.server, with: publicKey) } - return api.sendOnionRequest(urlRequest, to: request.server, with: publicKey) + return dependencies.api.sendOnionRequest(urlRequest, to: request.server, with: publicKey) } preconditionFailure("It's currently not allowed to send non onion routed requests.") @@ -866,11 +845,11 @@ public final class OpenGroupAPIV2: NSObject { server: server, room: room, endpoint: .legacyAuthTokenClaim(legacyAuth: true), - body: body, headers: [ // Set explicitly here because is isn't in the database yet at this point .authorization: authToken ], + body: body, isAuthRequired: false ) diff --git a/SessionMessagingKit/Open Groups/Types/Dependencies.swift b/SessionMessagingKit/Open Groups/Types/Dependencies.swift new file mode 100644 index 000000000..58d4e4fc0 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Types/Dependencies.swift @@ -0,0 +1,52 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium +import SessionSnodeKit + +extension OpenGroupAPIV2 { + public struct Dependencies { + let api: OnionRequestAPIType.Type + let storage: SessionMessagingKitStorageProtocol + let sodium: SodiumType + let genericHash: GenericHashType + let nonceGenerator: NonceGenerator16ByteType + let date: Date + + public init( + api: OnionRequestAPIType.Type = OnionRequestAPI.self, + storage: SessionMessagingKitStorageProtocol = SNMessagingKitConfiguration.shared.storage, + sodium: SodiumType = Sodium(), + genericHash: GenericHashType? = nil, + nonceGenerator: NonceGenerator16ByteType = NonceGenerator16Byte(), + date: Date = Date() + ) { + self.api = api + self.storage = storage + self.sodium = sodium + self.genericHash = (genericHash ?? sodium.getGenericHash()) + self.nonceGenerator = nonceGenerator + self.date = date + } + + // MARK: - Convenience + + public func with( + api: OnionRequestAPIType.Type? = nil, + storage: SessionMessagingKitStorageProtocol? = nil, + sodium: SodiumType? = nil, + genericHash: GenericHashType? = nil, + nonceGenerator: NonceGenerator16ByteType? = nil, + date: Date? = nil + ) -> Dependencies { + return Dependencies( + api: (api ?? self.api), + storage: (storage ?? self.storage), + sodium: (sodium ?? self.sodium), + genericHash: (genericHash ?? self.genericHash), + nonceGenerator: (nonceGenerator ?? self.nonceGenerator), + date: (date ?? self.date) + ) + } + } +} diff --git a/SessionMessagingKit/Open Groups/Types/Request.swift b/SessionMessagingKit/Open Groups/Types/Request.swift index 2e34adebc..955a5e07c 100644 --- a/SessionMessagingKit/Open Groups/Types/Request.swift +++ b/SessionMessagingKit/Open Groups/Types/Request.swift @@ -10,8 +10,8 @@ extension OpenGroupAPIV2 { let room: String? // TODO: Remove this? let endpoint: Endpoint let queryParameters: [QueryParam: String] - let body: Data? let headers: [Header: String] + let body: Data? let isAuthRequired: Bool /// Always `true` under normal circumstances. You might want to disable /// this when running over Lokinet. @@ -23,8 +23,8 @@ extension OpenGroupAPIV2 { room: String? = nil, endpoint: Endpoint, queryParameters: [QueryParam: String] = [:], - body: Data? = nil, headers: [Header: String] = [:], + body: Data? = nil, isAuthRequired: Bool = true, useOnionRouting: Bool = true ) { @@ -33,8 +33,8 @@ extension OpenGroupAPIV2 { self.room = room self.endpoint = endpoint self.queryParameters = queryParameters - self.body = body self.headers = headers + self.body = body self.isAuthRequired = isAuthRequired self.useOnionRouting = useOnionRouting } @@ -44,8 +44,6 @@ extension OpenGroupAPIV2 { } var urlPathAndParamsString: String { - guard method == .get else { return "/\(endpoint.path)" } - return [ "/\(endpoint.path)", queryParameters diff --git a/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift b/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift new file mode 100644 index 000000000..ae6f42847 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift @@ -0,0 +1,27 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium + +public protocol SodiumType { + func getGenericHash() -> GenericHashType + + func sharedSecret(_ firstKeyBytes: [UInt8], _ secondKeyBytes: [UInt8]) -> Sodium.SharedSecret? +} + +public protocol GenericHashType { + func hashSaltPersonal(message: Bytes, outputLength: Int, key: Bytes?, salt: Bytes, personal: Bytes) -> Bytes? +} + +extension GenericHashType { + func hashSaltPersonal(message: Bytes, outputLength: Int, salt: Bytes, personal: Bytes) -> Bytes? { + return hashSaltPersonal(message: message, outputLength: outputLength, key: nil, salt: salt, personal: personal) + } +} + +extension Sodium: SodiumType { + public func getGenericHash() -> GenericHashType { return genericHash } +} + +extension GenericHash: GenericHashType {} + diff --git a/SessionMessagingKit/Storage.swift b/SessionMessagingKit/Storage.swift index 4444307d3..fab20bd78 100644 --- a/SessionMessagingKit/Storage.swift +++ b/SessionMessagingKit/Storage.swift @@ -1,7 +1,7 @@ import PromiseKit import Sodium -public protocol SessionMessagingKitStorageProtocol { +public protocol SessionMessagingKitStorageProtocol: SessionMessagingKitOpenGroupStorageProtocol { // MARK: - Shared diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift b/SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift index 8923287b6..7d01bb41a 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift @@ -3,6 +3,7 @@ import XCTest import Nimble import PromiseKit +import Sodium import SessionSnodeKit @testable import SessionMessagingKit @@ -66,14 +67,21 @@ class OpenGroupAPIV2Tests: XCTestCase { } var testStorage: TestStorage! + var dependencies: OpenGroupAPIV2.Dependencies! // MARK: - Configuration override func setUpWithError() throws { testStorage = TestStorage() + dependencies = OpenGroupAPIV2.Dependencies( + api: TestApi.self, + storage: testStorage, + nonceGenerator: TestNonceGenerator(), + date: Date(timeIntervalSince1970: 1234567890) + ) testStorage.mockData[.allV2OpenGroups] = [ - "0": OpenGroupV2(server: "testServer", room: "test1", name: "Test", publicKey: "7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d", imageID: nil) + "0": OpenGroupV2(server: "testServer", room: "testRoom", name: "Test", publicKey: "7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d", imageID: nil) ] testStorage.mockData[.openGroupPublicKeys] = ["testServer": "7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d"] @@ -85,14 +93,14 @@ class OpenGroupAPIV2Tests: XCTestCase { } override func tearDownWithError() throws { + dependencies = nil testStorage = nil } // MARK: - Batching & Polling func testPollGeneratesTheCorrectRequest() throws { - // Define a custom TestApi class so we can override the response - class TestApi1: TestApi { + class LocalTestApi: TestApi { override class var mockResponse: Data? { let responses: [Data] = [ try! JSONEncoder().encode( @@ -106,36 +114,7 @@ class OpenGroupAPIV2Tests: XCTestCase { OpenGroupAPIV2.BatchSubResponse( code: 200, headers: [:], - body: OpenGroupAPIV2.RoomPollInfo( - token: nil, - created: nil, - name: nil, - description: nil, - imageId: nil, - - infoUpdates: nil, - messageSequence: nil, - activeUsers: nil, - activeUsersCutoff: nil, - pinnedMessages: nil, - - admin: nil, - globalAdmin: nil, - admins: nil, - hiddenAdmins: nil, - - moderator: nil, - globalModerator: nil, - moderators: nil, - hiddenModerators: nil, - read: nil, - defaultRead: nil, - write: nil, - defaultWrite: nil, - upload: nil, - defaultUpload: nil, - details: nil - ) + body: try! JSONDecoder().decode(OpenGroupAPIV2.RoomPollInfo.self, from: "{}".data(using: .utf8)!) ) ), try! JSONEncoder().encode( @@ -150,60 +129,304 @@ class OpenGroupAPIV2Tests: XCTestCase { return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) } } + dependencies = dependencies.with(api: LocalTestApi.self) - var pollResponse: [Endpoint: (OnionRequestResponseInfoType, Codable)]? = nil + var response: [Endpoint: (OnionRequestResponseInfoType, Codable)]? = nil + var error: Error? = nil - OpenGroupAPIV2.poll("testServer", through: TestApi1.self, using: testStorage, nonceGenerator: TestNonceGenerator(), date: Date(timeIntervalSince1970: 1234567890)) - .map { result -> [Endpoint: (OnionRequestResponseInfoType, Codable)] in - pollResponse = result - return result - } + OpenGroupAPIV2.poll("testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } .retainUntilComplete() - expect(pollResponse) + expect(response) .toEventuallyNot( beNil(), - timeout: .milliseconds(10000) + timeout: .milliseconds(100) ) + expect(error?.localizedDescription).to(beNil()) // Validate the response data - expect(pollResponse?.values).to(haveCount(3)) - expect(pollResponse?.keys).to(contain(.capabilities)) - expect(pollResponse?.keys).to(contain(.roomPollInfo("test1", 0))) - expect(pollResponse?.keys).to(contain(.roomMessagesRecent("test1"))) - expect(pollResponse?[.capabilities]?.0).to(beAKindOf(TestResponseInfo.self)) + expect(response?.values).to(haveCount(3)) + expect(response?.keys).to(contain(.capabilities)) + expect(response?.keys).to(contain(.roomPollInfo("testRoom", 0))) + expect(response?.keys).to(contain(.roomMessagesRecent("testRoom"))) + expect(response?[.capabilities]?.0).to(beAKindOf(TestResponseInfo.self)) // Validate request data - let requestData: TestApi.RequestData? = (pollResponse?[.capabilities]?.0 as? TestResponseInfo)?.requestData + let requestData: TestApi.RequestData? = (response?[.capabilities]?.0 as? TestResponseInfo)?.requestData expect(requestData?.urlString).to(equal("testServer/batch")) expect(requestData?.httpMethod).to(equal("POST")) expect(requestData?.server).to(equal("testServer")) expect(requestData?.publicKey).to(equal("7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")) } - // MARK: - Authentication + func testPollReturnsAnErrorWhenGivenNoData() throws { + var response: [Endpoint: (OnionRequestResponseInfoType, Codable)]? = nil + var error: Error? = nil + + OpenGroupAPIV2.poll("testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPIV2.Error.parsingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } - func testItSignsTheRequestCorrectly() throws { - class TestApi1: TestApi { + func testPollReturnsAnErrorWhenGivenInvalidData() throws { + class LocalTestApi: TestApi { + override class var mockResponse: Data? { return Data() } + } + dependencies = dependencies.with(api: LocalTestApi.self) + + var response: [Endpoint: (OnionRequestResponseInfoType, Codable)]? = nil + var error: Error? = nil + + OpenGroupAPIV2.poll("testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPIV2.Error.parsingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + + func testPollReturnsAnErrorWhenGivenAnEmptyResponse() throws { + class LocalTestApi: TestApi { + override class var mockResponse: Data? { return "[]".data(using: .utf8) } + } + dependencies = dependencies.with(api: LocalTestApi.self) + + var response: [Endpoint: (OnionRequestResponseInfoType, Codable)]? = nil + var error: Error? = nil + + OpenGroupAPIV2.poll("testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPIV2.Error.parsingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + + func testPollReturnsAnErrorWhenGivenAnObjectResponse() throws { + class LocalTestApi: TestApi { + override class var mockResponse: Data? { return "{}".data(using: .utf8) } + } + dependencies = dependencies.with(api: LocalTestApi.self) + + var response: [Endpoint: (OnionRequestResponseInfoType, Codable)]? = nil + var error: Error? = nil + + OpenGroupAPIV2.poll("testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPIV2.Error.parsingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + + func testPollReturnsAnErrorWhenGivenAnDifferentNumberOfResponses() throws { + class LocalTestApi: TestApi { override class var mockResponse: Data? { - return try! JSONEncoder().encode([OpenGroupAPIV2.Room]()) + let responses: [Data] = [ + try! JSONEncoder().encode( + OpenGroupAPIV2.BatchSubResponse( + code: 200, + headers: [:], + body: OpenGroupAPIV2.Capabilities(capabilities: [], missing: nil) + ) + ), + try! JSONEncoder().encode( + OpenGroupAPIV2.BatchSubResponse( + code: 200, + headers: [:], + body: try! JSONDecoder().decode(OpenGroupAPIV2.RoomPollInfo.self, from: "{}".data(using: .utf8)!) + ) + ) + ] + + return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) } } + dependencies = dependencies.with(api: LocalTestApi.self) - var response: (OnionRequestResponseInfoType, [OpenGroupAPIV2.Room])? = nil + var response: [Endpoint: (OnionRequestResponseInfoType, Codable)]? = nil + var error: Error? = nil - OpenGroupAPIV2.rooms(for: "testServer", through: TestApi1.self, using: testStorage, nonceGenerator: TestNonceGenerator(), date: Date(timeIntervalSince1970: 1234567890)) - .map { result -> (OnionRequestResponseInfoType, [OpenGroupAPIV2.Room]) in - response = result - return result + OpenGroupAPIV2.poll("testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPIV2.Error.parsingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + + func testPollReturnsAnErrorWhenGivenAnUnexpectedResponse() throws { + class LocalTestApi: TestApi { + override class var mockResponse: Data? { + let responses: [Data] = [ + try! JSONEncoder().encode( + OpenGroupAPIV2.BatchSubResponse( + code: 200, + headers: [:], + body: OpenGroupAPIV2.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: "") + ) + ), + try! JSONEncoder().encode( + OpenGroupAPIV2.BatchSubResponse( + code: 200, + headers: [:], + body: OpenGroupAPIV2.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: "") + ) + ), + try! JSONEncoder().encode( + OpenGroupAPIV2.BatchSubResponse( + code: 200, + headers: [:], + body: OpenGroupAPIV2.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: "") + ) + ) + ] + + return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) } + } + dependencies = dependencies.with(api: LocalTestApi.self) + + var response: [Endpoint: (OnionRequestResponseInfoType, Codable)]? = nil + var error: Error? = nil + + OpenGroupAPIV2.poll("testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPIV2.Error.parsingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + + // MARK: - Files + + func testItDoesNotAddAFileNameHeaderWhenNotProvided() throws { + class LocalTestApi: TestApi { + override class var mockResponse: Data? { + return try! JSONEncoder().encode(FileUploadResponse(id: 1)) + } + } + dependencies = dependencies.with(api: LocalTestApi.self) + + var response: (OnionRequestResponseInfoType, FileUploadResponse)? = nil + var error: Error? = nil + + OpenGroupAPIV2.uploadFile([], to: "testRoom", on: "testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } .retainUntilComplete() expect(response) .toEventuallyNot( beNil(), - timeout: .milliseconds(10000) + timeout: .milliseconds(100) ) + expect(error?.localizedDescription).to(beNil()) + + // Validate signature headers + let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData + expect(requestData?.urlString).to(equal("testServer/room/testRoom/file")) + expect(requestData?.httpMethod).to(equal("POST")) + expect(requestData?.headers).to(haveCount(4)) + expect(requestData?.headers.keys).toNot(contain(Header.fileName.rawValue)) + } + + func testItAddsAFileNameHeaderWhenProvided() throws { + class LocalTestApi: TestApi { + override class var mockResponse: Data? { + return try! JSONEncoder().encode(FileUploadResponse(id: 1)) + } + } + dependencies = dependencies.with(api: LocalTestApi.self) + + var response: (OnionRequestResponseInfoType, FileUploadResponse)? = nil + var error: Error? = nil + + OpenGroupAPIV2.uploadFile([], fileName: "TestFileName", to: "testRoom", on: "testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate signature headers + let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData + expect(requestData?.urlString).to(equal("testServer/room/testRoom/file")) + expect(requestData?.httpMethod).to(equal("POST")) + expect(requestData?.headers).to(haveCount(5)) + expect(requestData?.headers[Header.fileName.rawValue]).to(equal("TestFileName")) + } + + // MARK: - Authentication + + func testItSignsTheRequestCorrectly() throws { + class LocalTestApi: TestApi { + override class var mockResponse: Data? { + return try! JSONEncoder().encode([OpenGroupAPIV2.Room]()) + } + } + dependencies = dependencies.with(api: LocalTestApi.self) + + var response: (OnionRequestResponseInfoType, [OpenGroupAPIV2.Room])? = nil + var error: Error? = nil + + OpenGroupAPIV2.rooms(for: "testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) // Validate signature headers let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData @@ -217,4 +440,128 @@ class OpenGroupAPIV2Tests: XCTestCase { expect(requestData?.headers[Header.sogsHash.rawValue]).to(equal("fxqLy5ZDWCsLQpwLw0Dax+4xe7cG2vPRk1NlHORIm0DPd3o9UA24KLZY")) expect(requestData?.headers[Header.sogsTimestamp.rawValue]).to(equal("1234567890")) } + + func testItFailsToSignIfTheServerPublicKeyIsInvalid() throws { + testStorage.mockData[.openGroupPublicKeys] = ["testServer": ""] + + var response: Any? = nil + var error: Error? = nil + + OpenGroupAPIV2.rooms(for: "testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPIV2.Error.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + + func testItFailsToSignIfThereIsNoUserKeyPair() throws { + testStorage.mockData[.userKeyPair] = nil + + var response: Any? = nil + var error: Error? = nil + + OpenGroupAPIV2.rooms(for: "testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPIV2.Error.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + + func testItFailsToSignIfTheSharedSecretDoesNotGetGenerated() throws { + class InvalidSodium: SodiumType { + func getGenericHash() -> GenericHashType { return Sodium().genericHash } + func sharedSecret(_ firstKeyBytes: [UInt8], _ secondKeyBytes: [UInt8]) -> Sodium.SharedSecret? { return nil } + } + + dependencies = dependencies.with(sodium: InvalidSodium()) + + var response: Any? = nil + var error: Error? = nil + + OpenGroupAPIV2.rooms(for: "testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPIV2.Error.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + + func testItFailsToSignIfTheIntermediateHashDoesNotGetGenerated() throws { + class InvalidGenericHash: GenericHashType { + func hashSaltPersonal(message: Bytes, outputLength: Int, key: Bytes?, salt: Bytes, personal: Bytes) -> Bytes? { + return nil + } + } + + dependencies = dependencies.with(genericHash: InvalidGenericHash()) + + var response: Any? = nil + var error: Error? = nil + + OpenGroupAPIV2.rooms(for: "testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPIV2.Error.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + + func testItFailsToSignIfTheSecretHashDoesNotGetGenerated() throws { + class InvalidSecondGenericHash: GenericHashType { + static var didSucceedOnce: Bool = false + + func hashSaltPersonal(message: Bytes, outputLength: Int, key: Bytes?, salt: Bytes, personal: Bytes) -> Bytes? { + if !InvalidSecondGenericHash.didSucceedOnce { + InvalidSecondGenericHash.didSucceedOnce = true + return Data().bytes + } + + return nil + } + } + + dependencies = dependencies.with(genericHash: InvalidSecondGenericHash()) + + var response: Any? = nil + var error: Error? = nil + + OpenGroupAPIV2.rooms(for: "testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPIV2.Error.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } } diff --git a/SessionMessagingKitTests/_TestUtilities/TestStorage.swift b/SessionMessagingKitTests/_TestUtilities/TestStorage.swift index 82f21d4a5..cc5ad174b 100644 --- a/SessionMessagingKitTests/_TestUtilities/TestStorage.swift +++ b/SessionMessagingKitTests/_TestUtilities/TestStorage.swift @@ -13,6 +13,7 @@ class TestStorage: SessionMessagingKitStorageProtocol, Mockable { case allV2OpenGroups case openGroupPublicKeys case userKeyPair + case openGroupImage } typealias Key = DataKey @@ -112,3 +113,10 @@ class TestStorage: SessionMessagingKitStorageProtocol, Mockable { func setAttachmentState(to state: TSAttachmentPointerState, for pointer: TSAttachmentPointer, associatedWith tsIncomingMessageID: String, using transaction: Any) {} func persist(_ stream: TSAttachmentStream, associatedWith tsIncomingMessageID: String, using transaction: Any) {} } + +// MARK: - SessionMessagingKitOpenGroupStorageProtocol + +extension TestStorage: SessionMessagingKitOpenGroupStorageProtocol { + func getOpenGroupImage(for room: String, on server: String) -> Data? { return (mockData[.openGroupImage] as? Data) } + func setOpenGroupImage(to data: Data, for room: String, on server: String, using transaction: Any) {} +} diff --git a/SessionUtilitiesKit/General/String+Encoding.swift b/SessionUtilitiesKit/General/String+Encoding.swift index 270f43ac2..bb208adec 100644 --- a/SessionUtilitiesKit/General/String+Encoding.swift +++ b/SessionUtilitiesKit/General/String+Encoding.swift @@ -4,7 +4,7 @@ import Foundation extension String { public func dataFromHex() -> Data? { - guard (self.count % 2) == 0 else { return nil } + guard self.count > 0 && (self.count % 2) == 0 else { return nil } let chars = self.map { $0 } let bytes: [UInt8] = stride(from: 0, to: chars.count, by: 2) From 63e6cdd9ecb1812b0e08d15f6038715f3b2ee37b Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 15 Feb 2022 16:30:11 +1100 Subject: [PATCH 008/157] Renamed OpenGroupAPIV2 to OpenGroupAPI Added the inbox endpoints --- Session.xcodeproj/project.pbxproj | 26 +- .../ConversationVC+Interaction.swift | 4 +- Session/Conversations/ConversationVC.swift | 2 +- Session/Conversations/ConversationViewItem.m | 12 +- .../Input View/MentionSelectionView.swift | 2 +- .../Message Cells/VisibleMessageCell.swift | 2 +- Session/Home/HomeVC.swift | 2 +- Session/Meta/AppDelegate.m | 2 +- Session/Open Groups/JoinOpenGroupVC.swift | 4 +- .../Open Groups/OpenGroupSuggestionGrid.swift | 14 +- .../Jobs/AttachmentDownloadJob.swift | 2 +- .../Jobs/AttachmentUploadJob.swift | 2 +- .../Models/AuthTokenResponse.swift | 8 +- .../Open Groups/Models/BatchRequestInfo.swift | 14 +- .../Open Groups/Models/Capabilities.swift | 2 +- .../Models/DeletedMessagesResponse.swift | 2 +- .../Open Groups/Models/Deletion.swift | 2 +- .../Open Groups/Models/DirectMessage.swift | 19 + .../Open Groups/Models/FileResponse.swift | 2 +- .../Models/LegacyCompactPollBody.swift | 2 +- .../Models/LegacyCompactPollResponse.swift | 2 +- .../Models/LegacyGetInfoResponse.swift | 2 +- .../Open Groups/Models/LegacyRoomInfo.swift | 2 +- .../Models/LegacyRoomsResponse.swift | 2 +- .../Models/MemberCountResponse.swift | 2 +- .../Models/ModeratorsResponse.swift | 2 +- .../Open Groups/Models/OGMessage.swift | 10 +- .../Models/OpenGroupMessageV2.swift | 4 +- .../Open Groups/Models/PinnedMessage.swift | 2 +- .../Open Groups/Models/PublicKeyBody.swift | 2 +- .../Open Groups/Models/Room.swift | 8 +- .../Open Groups/Models/RoomPollInfo.swift | 2 +- .../Models/SendDirectMessageRequest.swift | 19 + .../Models/SendMessageRequest.swift | 2 +- .../Open Groups/Models/UserBanRequest.swift | 2 +- .../Models/UserDeleteMessagesRequest.swift | 2 +- .../Models/UserDeleteMessagesResponse.swift | 2 +- .../Models/UserModeratorRequest.swift | 2 +- .../Models/UserPermissionsRequest.swift | 2 +- .../Open Groups/Models/UserUnbanRequest.swift | 2 +- ...IV2+ObjC.swift => OpenGroupAPI+ObjC.swift} | 4 +- ...penGroupAPIV2.swift => OpenGroupAPI.swift} | 246 +++++++----- .../Open Groups/OpenGroupManagerV2.swift | 10 +- .../Open Groups/Types/Dependencies.swift | 2 +- .../Open Groups/Types/Endpoint.swift | 378 +++++++++--------- .../Open Groups/Types/Error.swift | 2 +- .../Types/NonceGenerator16Byte.swift | 2 +- .../Open Groups/Types/Personalization.swift | 2 +- .../Open Groups/Types/Request.swift | 2 +- .../Sending & Receiving/MessageSender.swift | 2 +- .../Pollers/OpenGroupPollerV2.swift | 28 +- .../Utilities/Promise+Utilities.swift | 2 +- .../MessageSender+Convenience.swift | 4 +- 53 files changed, 490 insertions(+), 392 deletions(-) create mode 100644 SessionMessagingKit/Open Groups/Models/DirectMessage.swift create mode 100644 SessionMessagingKit/Open Groups/Models/SendDirectMessageRequest.swift rename SessionMessagingKit/Open Groups/{OpenGroupAPIV2+ObjC.swift => OpenGroupAPI+ObjC.swift} (88%) rename SessionMessagingKit/Open Groups/{OpenGroupAPIV2.swift => OpenGroupAPI.swift} (86%) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index f65d6f9b8..f1ff64623 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -233,7 +233,7 @@ B886B4A72398B23E00211ABE /* QRCodeVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B886B4A62398B23E00211ABE /* QRCodeVC.swift */; }; B886B4A92398BA1500211ABE /* QRCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = B886B4A82398BA1500211ABE /* QRCode.swift */; }; B88A1AC725C90A4700E6D421 /* TypingIndicatorInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34B6A904218B4C90007C4606 /* TypingIndicatorInteraction.swift */; }; - B88FA7B826045D100049422F /* OpenGroupAPIV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88FA7B726045D100049422F /* OpenGroupAPIV2.swift */; }; + B88FA7B826045D100049422F /* OpenGroupAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88FA7B726045D100049422F /* OpenGroupAPI.swift */; }; B88FA7F2260C3EB10049422F /* OpenGroupSuggestionGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88FA7F1260C3EB10049422F /* OpenGroupSuggestionGrid.swift */; }; B88FA7FB26114EA70049422F /* Hex.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88FA7FA26114EA70049422F /* Hex.swift */; }; B893063F2383961A005EAA8E /* ScanQRCodeWrapperVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B893063E2383961A005EAA8E /* ScanQRCodeWrapperVC.swift */; }; @@ -754,7 +754,7 @@ C3DAB3242480CB2B00725F25 /* SRCopyableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DAB3232480CB2A00725F25 /* SRCopyableLabel.swift */; }; C3DB66AC260ACA42001EFC55 /* OpenGroupManagerV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DB66AB260ACA42001EFC55 /* OpenGroupManagerV2.swift */; }; C3DB66C3260ACCE6001EFC55 /* OpenGroupPollerV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DB66C2260ACCE6001EFC55 /* OpenGroupPollerV2.swift */; }; - C3DB66CC260AF1F3001EFC55 /* OpenGroupAPIV2+ObjC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DB66CB260AF1F3001EFC55 /* OpenGroupAPIV2+ObjC.swift */; }; + C3DB66CC260AF1F3001EFC55 /* OpenGroupAPI+ObjC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DB66CB260AF1F3001EFC55 /* OpenGroupAPI+ObjC.swift */; }; C3DFFAC623E96F0D0058DAF8 /* Sheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DFFAC523E96F0D0058DAF8 /* Sheet.swift */; }; C3E5C2FA251DBABB0040DFFC /* EditClosedGroupVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E5C2F9251DBABB0040DFFC /* EditClosedGroupVC.swift */; }; C3ECBF7B257056B700EA7FCE /* Threading.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3ECBF7A257056B700EA7FCE /* Threading.swift */; }; @@ -844,6 +844,8 @@ FDC438BD27BB2AB400C60D73 /* Mockable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438BC27BB2AB400C60D73 /* Mockable.swift */; }; FDC438C127BB4E6800C60D73 /* Dependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438C027BB4E6800C60D73 /* Dependencies.swift */; }; FDC438C327BB512200C60D73 /* SodiumProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438C227BB512200C60D73 /* SodiumProtocols.swift */; }; + FDC438C727BB6DF000C60D73 /* DirectMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438C627BB6DF000C60D73 /* DirectMessage.swift */; }; + FDC438C927BB706500C60D73 /* SendDirectMessageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438C827BB706500C60D73 /* SendDirectMessageRequest.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1323,7 +1325,7 @@ B885D5F52334A32100EE0D8E /* UIView+Constraints.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Constraints.swift"; sourceTree = ""; }; B886B4A62398B23E00211ABE /* QRCodeVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeVC.swift; sourceTree = ""; }; B886B4A82398BA1500211ABE /* QRCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCode.swift; sourceTree = ""; }; - B88FA7B726045D100049422F /* OpenGroupAPIV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupAPIV2.swift; sourceTree = ""; }; + B88FA7B726045D100049422F /* OpenGroupAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupAPI.swift; sourceTree = ""; }; B88FA7F1260C3EB10049422F /* OpenGroupSuggestionGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupSuggestionGrid.swift; sourceTree = ""; }; B88FA7FA26114EA70049422F /* Hex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Hex.swift; sourceTree = ""; }; B893063E2383961A005EAA8E /* ScanQRCodeWrapperVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanQRCodeWrapperVC.swift; sourceTree = ""; }; @@ -1859,7 +1861,7 @@ C3DAB3232480CB2A00725F25 /* SRCopyableLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SRCopyableLabel.swift; sourceTree = ""; }; C3DB66AB260ACA42001EFC55 /* OpenGroupManagerV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupManagerV2.swift; sourceTree = ""; }; C3DB66C2260ACCE6001EFC55 /* OpenGroupPollerV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupPollerV2.swift; sourceTree = ""; }; - C3DB66CB260AF1F3001EFC55 /* OpenGroupAPIV2+ObjC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OpenGroupAPIV2+ObjC.swift"; sourceTree = ""; }; + C3DB66CB260AF1F3001EFC55 /* OpenGroupAPI+ObjC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OpenGroupAPI+ObjC.swift"; sourceTree = ""; }; C3DFFAC523E96F0D0058DAF8 /* Sheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sheet.swift; sourceTree = ""; }; C3E5C2F9251DBABB0040DFFC /* EditClosedGroupVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditClosedGroupVC.swift; sourceTree = ""; }; C3E7134E251C867C009649BB /* Sodium+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sodium+Utilities.swift"; sourceTree = ""; }; @@ -1974,6 +1976,8 @@ FDC438BC27BB2AB400C60D73 /* Mockable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mockable.swift; sourceTree = ""; }; FDC438C027BB4E6800C60D73 /* Dependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dependencies.swift; sourceTree = ""; }; FDC438C227BB512200C60D73 /* SodiumProtocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SodiumProtocols.swift; sourceTree = ""; }; + FDC438C627BB6DF000C60D73 /* DirectMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectMessage.swift; sourceTree = ""; }; + FDC438C827BB706500C60D73 /* SendDirectMessageRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendDirectMessageRequest.swift; sourceTree = ""; }; FEDBAE1B98C49BBE8C87F575 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; sourceTree = ""; }; FF9BA33D021B115B1F5B4E46 /* Pods-SessionMessagingKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionMessagingKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionMessagingKit/Pods-SessionMessagingKit.app store release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -3351,8 +3355,8 @@ children = ( FDC4381827B34EAD00C60D73 /* Models */, FDC4380727B31D3A00C60D73 /* Types */, - B88FA7B726045D100049422F /* OpenGroupAPIV2.swift */, - C3DB66CB260AF1F3001EFC55 /* OpenGroupAPIV2+ObjC.swift */, + B88FA7B726045D100049422F /* OpenGroupAPI.swift */, + C3DB66CB260AF1F3001EFC55 /* OpenGroupAPI+ObjC.swift */, C3DB66AB260ACA42001EFC55 /* OpenGroupManagerV2.swift */, ); path = "Open Groups"; @@ -3834,12 +3838,15 @@ FDC4384B27B47F7700C60D73 /* OpenGroupV2.swift */, FDC4381B27B354AC00C60D73 /* PublicKeyBody.swift */, FDC4386A27B4E88F00C60D73 /* BatchRequestInfo.swift */, + FDC4386627B4E10E00C60D73 /* Capabilities.swift */, FDC4385C27B4C18900C60D73 /* Room.swift */, FDC4386427B4DE7600C60D73 /* RoomPollInfo.swift */, FDC4385E27B4C4A200C60D73 /* PinnedMessage.swift */, FDC4386027B4CDDF00C60D73 /* FileResponse.swift */, FDC4387727B5C35400C60D73 /* SendMessageRequest.swift */, FDC4386227B4D94E00C60D73 /* OGMessage.swift */, + FDC438C627BB6DF000C60D73 /* DirectMessage.swift */, + FDC438C827BB706500C60D73 /* SendDirectMessageRequest.swift */, FDC438A327BB107F00C60D73 /* UserBanRequest.swift */, FDC438A527BB113A00C60D73 /* UserUnbanRequest.swift */, FDC438A727BB11CD00C60D73 /* UserPermissionsRequest.swift */, @@ -3849,7 +3856,6 @@ FDC4382B27B380E300C60D73 /* MemberCountResponse.swift */, FDC4382527B37F6900C60D73 /* DeletedMessagesResponse.swift */, FDC4384627B47F4D00C60D73 /* Deletion.swift */, - FDC4386627B4E10E00C60D73 /* Capabilities.swift */, FDC4382727B37FD300C60D73 /* ModeratorsResponse.swift */, FDC4381927B34EBA00C60D73 /* LegacyCompactPollBody.swift */, FDC4384527B47F4D00C60D73 /* LegacyCompactPollResponse.swift */, @@ -5163,6 +5169,7 @@ FDC4385927B484E800C60D73 /* FileUploadBody.swift in Sources */, C32C5E5B256DDF45003C73A2 /* OWSStorage.m in Sources */, C32C5E15256DDC78003C73A2 /* SSKPreferences.swift in Sources */, + FDC438C727BB6DF000C60D73 /* DirectMessage.swift in Sources */, C32C5D9C256DD6DC003C73A2 /* OWSOutgoingReceiptManager.m in Sources */, B8AE760B25ABFB5A001A84D2 /* GeneralUtilities.m in Sources */, C32C5C4F256DCC36003C73A2 /* Storage+OpenGroups.swift in Sources */, @@ -5194,13 +5201,14 @@ C32C599E256DB02B003C73A2 /* TypingIndicators.swift in Sources */, FDC4380927B31D4E00C60D73 /* Error.swift in Sources */, FDC4382027B36ADC00C60D73 /* Endpoint.swift in Sources */, - C3DB66CC260AF1F3001EFC55 /* OpenGroupAPIV2+ObjC.swift in Sources */, + C3DB66CC260AF1F3001EFC55 /* OpenGroupAPI+ObjC.swift in Sources */, C32C5BEF256DC8EE003C73A2 /* OWSDisappearingMessagesJob.m in Sources */, C34A977425A3E34A00852C71 /* ClosedGroupControlMessage.swift in Sources */, FDC4384C27B47F7700C60D73 /* OpenGroupV2.swift in Sources */, FDC4382627B37F6900C60D73 /* DeletedMessagesResponse.swift in Sources */, - B88FA7B826045D100049422F /* OpenGroupAPIV2.swift in Sources */, + B88FA7B826045D100049422F /* OpenGroupAPI.swift in Sources */, C32C5E97256DE0CB003C73A2 /* OWSPrimaryStorage.m in Sources */, + FDC438C927BB706500C60D73 /* SendDirectMessageRequest.swift in Sources */, C32C5EB9256DE130003C73A2 /* OWSQuotedReplyModel+Conversion.swift in Sources */, C3A71D1F25589AC30043A11F /* WebSocketResources.pb.swift in Sources */, B8856E94256F1C37001CE70E /* OWSSounds.m in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 13b0ec5ab..b2d02e13d 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -700,7 +700,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in let publicKey = message.authorId guard let openGroupV2 = Storage.shared.getV2OpenGroup(for: threadID) else { return } - OpenGroupAPIV2.legacyBan(publicKey, from: openGroupV2.room, on: openGroupV2.server).retainUntilComplete() + OpenGroupAPI.legacyBan(publicKey, from: openGroupV2.room, on: openGroupV2.server).retainUntilComplete() })) alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: nil)) present(alert, animated: true, completion: nil) @@ -714,7 +714,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in let publicKey = message.authorId guard let openGroupV2 = Storage.shared.getV2OpenGroup(for: threadID) else { return } - OpenGroupAPIV2.legacyBanAndDeleteAllMessages(publicKey, from: openGroupV2.room, on: openGroupV2.server).retainUntilComplete() + OpenGroupAPI.legacyBanAndDeleteAllMessages(publicKey, from: openGroupV2.room, on: openGroupV2.server).retainUntilComplete() })) alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: nil)) present(alert, animated: true, completion: nil) diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index aeca31155..86de50e68 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -373,7 +373,7 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat // Update member count if this is a V2 open group // TODO: Non-legacy version (I assue this comes through room updates... 'activeUsers'? if let v2OpenGroup = Storage.shared.getV2OpenGroup(for: thread.uniqueId!) { - OpenGroupAPIV2.legacyGetMemberCount(for: v2OpenGroup.room, on: v2OpenGroup.server).retainUntilComplete() + OpenGroupAPI.legacyGetMemberCount(for: v2OpenGroup.room, on: v2OpenGroup.server).retainUntilComplete() } } diff --git a/Session/Conversations/ConversationViewItem.m b/Session/Conversations/ConversationViewItem.m index 4ebdd168a..8afcf5442 100644 --- a/Session/Conversations/ConversationViewItem.m +++ b/Session/Conversations/ConversationViewItem.m @@ -1006,11 +1006,11 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) // If it's an incoming message the user must have moderator status if (self.interaction.interactionType == OWSInteractionType_IncomingMessage) { NSString *userPublicKey = [LKStorage.shared getUserPublicKey]; - if (![SNOpenGroupAPIV2 isUserModerator:userPublicKey forRoom:openGroupV2.room onServer:openGroupV2.server]) { return; } + if (![SNOpenGroupAPI isUserModerator:userPublicKey forRoom:openGroupV2.room onServer:openGroupV2.server]) { return; } } // Delete the message - [[SNOpenGroupAPIV2 deleteMessageWithServerID:message.openGroupServerMessageID fromRoom:openGroupV2.room onServer:openGroupV2.server].catch(^(NSError *error) { + [[SNOpenGroupAPI deleteMessageWithServerID:message.openGroupServerMessageID fromRoom:openGroupV2.room onServer:openGroupV2.server].catch(^(NSError *error) { // Roll back [self.interaction save]; }) retainUntilComplete]; @@ -1060,14 +1060,14 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) if (self.interaction.interactionType == OWSInteractionType_IncomingMessage) { NSString *userPublicKey = [LKStorage.shared getUserPublicKey]; if (openGroupV2 != nil) { - if (![SNOpenGroupAPIV2 isUserModerator:userPublicKey forRoom:openGroupV2.room onServer:openGroupV2.server]) { return; } + if (![SNOpenGroupAPI isUserModerator:userPublicKey forRoom:openGroupV2.room onServer:openGroupV2.server]) { return; } } } // Delete the message BOOL wasSentByUser = (interationType == OWSInteractionType_OutgoingMessage); if (openGroupV2 != nil) { - [[SNOpenGroupAPIV2 deleteMessageWithServerID:message.openGroupServerMessageID fromRoom:openGroupV2.room onServer:openGroupV2.server].catch(^(NSError *error) { + [[SNOpenGroupAPI deleteMessageWithServerID:message.openGroupServerMessageID fromRoom:openGroupV2.room onServer:openGroupV2.server].catch(^(NSError *error) { // Roll back [self.interaction save]; }) retainUntilComplete]; @@ -1133,7 +1133,7 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) if (interationType == OWSInteractionType_IncomingMessage) { // Only allow deletion on incoming messages if the user has moderation permission if (openGroupV2 != nil) { - return [SNOpenGroupAPIV2 isUserModerator:[SNGeneralUtilities getUserPublicKey] forRoom:openGroupV2.room onServer:openGroupV2.server]; + return [SNOpenGroupAPI isUserModerator:[SNGeneralUtilities getUserPublicKey] forRoom:openGroupV2.room onServer:openGroupV2.server]; } } else { return YES; @@ -1155,7 +1155,7 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) // Check that we're a moderator if (openGroupV2 != nil) { - return [SNOpenGroupAPIV2 isUserModerator:[SNGeneralUtilities getUserPublicKey] forRoom:openGroupV2.room onServer:openGroupV2.server]; + return [SNOpenGroupAPI isUserModerator:[SNGeneralUtilities getUserPublicKey] forRoom:openGroupV2.room onServer:openGroupV2.server]; } } diff --git a/Session/Conversations/Input View/MentionSelectionView.swift b/Session/Conversations/Input View/MentionSelectionView.swift index 1c33b00f6..580710569 100644 --- a/Session/Conversations/Input View/MentionSelectionView.swift +++ b/Session/Conversations/Input View/MentionSelectionView.swift @@ -163,7 +163,7 @@ private extension MentionSelectionView { profilePictureView.publicKey = mentionCandidate.publicKey profilePictureView.update() if let server = openGroupServer, let room = openGroupRoom { - let isUserModerator = OpenGroupAPIV2.isUserModerator(mentionCandidate.publicKey, for: room, on: server) + let isUserModerator = OpenGroupAPI.isUserModerator(mentionCandidate.publicKey, for: room, on: server) moderatorIconImageView.isHidden = !isUserModerator } else { moderatorIconImageView.isHidden = true diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 1106b2d10..65584efbd 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -219,7 +219,7 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { } if let senderSessionID = senderSessionID, message.isOpenGroupMessage { if let openGroupV2 = Storage.shared.getV2OpenGroup(for: message.uniqueThreadId) { - let isUserModerator = OpenGroupAPIV2.isUserModerator(senderSessionID, for: openGroupV2.room, on: openGroupV2.server) + let isUserModerator = OpenGroupAPI.isUserModerator(senderSessionID, for: openGroupV2.room, on: openGroupV2.server) moderatorIconImageView.isHidden = !isUserModerator || profilePictureView.isHidden } else { moderatorIconImageView.isHidden = true diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 72bc407a6..d0a15cae3 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -160,7 +160,7 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv let _ = IP2Country.shared.populateCacheIfNeeded() } // Get default open group rooms if needed - OpenGroupAPIV2.legacyGetDefaultRoomsIfNeeded() + OpenGroupAPI.legacyGetDefaultRoomsIfNeeded() } override func viewDidAppear(_ animated: Bool) { diff --git a/Session/Meta/AppDelegate.m b/Session/Meta/AppDelegate.m index e0ca1d383..0bb1a4964 100644 --- a/Session/Meta/AppDelegate.m +++ b/Session/Meta/AppDelegate.m @@ -383,7 +383,7 @@ static NSTimeInterval launchStartedAt; } if (CurrentAppContext().isMainApp) { - [SNOpenGroupAPIV2 legacyGetDefaultRoomsIfNeeded]; + [SNOpenGroupAPI legacyGetDefaultRoomsIfNeeded]; } [[SNSnodeAPI getSnodePool] retainUntilComplete]; diff --git a/Session/Open Groups/JoinOpenGroupVC.swift b/Session/Open Groups/JoinOpenGroupVC.swift index 7eb4bb567..fcd9b0a71 100644 --- a/Session/Open Groups/JoinOpenGroupVC.swift +++ b/Session/Open Groups/JoinOpenGroupVC.swift @@ -238,8 +238,8 @@ private final class EnterURLVC : UIViewController, UIGestureRecognizerDelegate, return !suggestionGrid.frame.contains(location) } - func join(_ room: OpenGroupAPIV2.LegacyRoomInfo) { - joinOpenGroupVC.joinV2OpenGroup(room: room.id, server: OpenGroupAPIV2.defaultServer, publicKey: OpenGroupAPIV2.defaultServerPublicKey) + func join(_ room: OpenGroupAPI.LegacyRoomInfo) { + joinOpenGroupVC.joinV2OpenGroup(room: room.id, server: OpenGroupAPI.defaultServer, publicKey: OpenGroupAPI.defaultServerPublicKey) } @objc private func joinOpenGroup() { diff --git a/Session/Open Groups/OpenGroupSuggestionGrid.swift b/Session/Open Groups/OpenGroupSuggestionGrid.swift index 2fb2d86df..3ac1f5e69 100644 --- a/Session/Open Groups/OpenGroupSuggestionGrid.swift +++ b/Session/Open Groups/OpenGroupSuggestionGrid.swift @@ -3,7 +3,7 @@ import NVActivityIndicatorView final class OpenGroupSuggestionGrid : UIView, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { private let maxWidth: CGFloat - private var rooms: [OpenGroupAPIV2.LegacyRoomInfo] = [] { didSet { update() } } + private var rooms: [OpenGroupAPI.LegacyRoomInfo] = [] { didSet { update() } } private var heightConstraint: NSLayoutConstraint! var delegate: OpenGroupSuggestionGridDelegate? @@ -59,10 +59,10 @@ final class OpenGroupSuggestionGrid : UIView, UICollectionViewDataSource, UIColl spinner.startAnimating() heightConstraint = set(.height, to: OpenGroupSuggestionGrid.cellHeight) widthAnchor.constraint(greaterThanOrEqualToConstant: OpenGroupSuggestionGrid.cellHeight).isActive = true - if OpenGroupAPIV2.defaultRoomsPromise == nil { - OpenGroupAPIV2.legacyGetDefaultRoomsIfNeeded() + if OpenGroupAPI.defaultRoomsPromise == nil { + OpenGroupAPI.legacyGetDefaultRoomsIfNeeded() } - let _ = OpenGroupAPIV2.legacyDefaultRoomsPromise?.done { [weak self] rooms in + let _ = OpenGroupAPI.legacyDefaultRoomsPromise?.done { [weak self] rooms in // TODO: Update this for the new rooms API self?.rooms = rooms } @@ -105,7 +105,7 @@ final class OpenGroupSuggestionGrid : UIView, UICollectionViewDataSource, UIColl extension OpenGroupSuggestionGrid { fileprivate final class Cell : UICollectionViewCell { - var room: OpenGroupAPIV2.LegacyRoomInfo? { didSet { update() } } + var room: OpenGroupAPI.LegacyRoomInfo? { didSet { update() } } static let identifier = "OpenGroupSuggestionGridCell" @@ -173,7 +173,7 @@ extension OpenGroupSuggestionGrid { private func update() { guard let room = room else { return } - let promise = OpenGroupAPIV2.legacyGetGroupImage(for: room.id, on: OpenGroupAPIV2.defaultServer) + let promise = OpenGroupAPI.legacyGetGroupImage(for: room.id, on: OpenGroupAPI.defaultServer) imageView.image = given(promise.value) { UIImage(data: $0)! } imageView.isHidden = (imageView.image == nil) label.text = room.name @@ -184,5 +184,5 @@ extension OpenGroupSuggestionGrid { // MARK: Delegate protocol OpenGroupSuggestionGridDelegate { - func join(_ room: OpenGroupAPIV2.LegacyRoomInfo) + func join(_ room: OpenGroupAPI.LegacyRoomInfo) } diff --git a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift index c3d5d4c32..30842c939 100644 --- a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift @@ -102,7 +102,7 @@ public final class AttachmentDownloadJob : NSObject, Job, NSCoding { // NSObject return handleFailure(Error.invalidURL) } // TODO: Upgrade this to use the non-legacy version - OpenGroupAPIV2.legacyDownload(file, from: v2OpenGroup.room, on: v2OpenGroup.server).done(on: DispatchQueue.global(qos: .userInitiated)) { data in + OpenGroupAPI.legacyDownload(file, from: v2OpenGroup.room, on: v2OpenGroup.server).done(on: DispatchQueue.global(qos: .userInitiated)) { data in self.handleDownloadedAttachment(data: data, temporaryFilePath: temporaryFilePath, pointer: pointer, failureHandler: handleFailure) }.catch(on: DispatchQueue.global()) { error in handleFailure(error) diff --git a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift index e48255979..0630cef7c 100644 --- a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift @@ -73,7 +73,7 @@ public final class AttachmentUploadJob : NSObject, Job, NSCoding { // NSObject/N stream, using: { data in // TODO: Upgrade this to use the non-legacy version - return OpenGroupAPIV2.legacyUpload(data, to: v2OpenGroup.room, on: v2OpenGroup.server) + return OpenGroupAPI.legacyUpload(data, to: v2OpenGroup.room, on: v2OpenGroup.server) }, encrypt: false, onSuccess: handleSuccess, diff --git a/SessionMessagingKit/Open Groups/Models/AuthTokenResponse.swift b/SessionMessagingKit/Open Groups/Models/AuthTokenResponse.swift index d4340a053..0823b3016 100644 --- a/SessionMessagingKit/Open Groups/Models/AuthTokenResponse.swift +++ b/SessionMessagingKit/Open Groups/Models/AuthTokenResponse.swift @@ -2,7 +2,7 @@ import Foundation -extension OpenGroupAPIV2 { +extension OpenGroupAPI { struct AuthTokenResponse: Codable { struct Challenge: Codable { enum CodingKeys: String, CodingKey { @@ -20,7 +20,7 @@ extension OpenGroupAPIV2 { // MARK: - Codable -extension OpenGroupAPIV2.AuthTokenResponse.Challenge { +extension OpenGroupAPI.AuthTokenResponse.Challenge { init(from decoder: Decoder) throws { let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) @@ -28,10 +28,10 @@ extension OpenGroupAPIV2.AuthTokenResponse.Challenge { let base64EncodedEphemeralPublicKey: String = try container.decode(String.self, forKey: .ephemeralPublicKey) guard let ciphertext = Data(base64Encoded: base64EncodedCiphertext), let ephemeralPublicKey = Data(base64Encoded: base64EncodedEphemeralPublicKey) else { - throw OpenGroupAPIV2.Error.parsingFailed + throw OpenGroupAPI.Error.parsingFailed } - self = OpenGroupAPIV2.AuthTokenResponse.Challenge( + self = OpenGroupAPI.AuthTokenResponse.Challenge( ciphertext: ciphertext, ephemeralPublicKey: ephemeralPublicKey ) diff --git a/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift b/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift index b5ac2812a..28525723d 100644 --- a/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift +++ b/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift @@ -5,7 +5,7 @@ import PromiseKit import SessionUtilitiesKit import SessionSnodeKit -extension OpenGroupAPIV2 { +extension OpenGroupAPI { // MARK: - BatchSubRequest struct BatchSubRequest: Codable { @@ -68,17 +68,17 @@ public extension Decodable { } extension Promise where T == (OnionRequestResponseInfoType, Data?) { - func decoded(as types: OpenGroupAPIV2.BatchResponseTypes, on queue: DispatchQueue? = nil, error: Error) -> Promise { - self.map(on: queue) { responseInfo, maybeData -> OpenGroupAPIV2.BatchResponse in + func decoded(as types: OpenGroupAPI.BatchResponseTypes, on queue: DispatchQueue? = nil, error: Error) -> Promise { + self.map(on: queue) { responseInfo, maybeData -> OpenGroupAPI.BatchResponse in // Need to split the data into an array of data so each item can be Decoded correctly - guard let data: Data = maybeData else { throw OpenGroupAPIV2.Error.parsingFailed } + guard let data: Data = maybeData else { throw OpenGroupAPI.Error.parsingFailed } guard let jsonObject: Any = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) else { - throw OpenGroupAPIV2.Error.parsingFailed + throw OpenGroupAPI.Error.parsingFailed } - guard let anyArray: [Any] = jsonObject as? [Any] else { throw OpenGroupAPIV2.Error.parsingFailed } + guard let anyArray: [Any] = jsonObject as? [Any] else { throw OpenGroupAPI.Error.parsingFailed } let dataArray: [Data] = anyArray.compactMap { try? JSONSerialization.data(withJSONObject: $0) } - guard dataArray.count == types.count else { throw OpenGroupAPIV2.Error.parsingFailed } + guard dataArray.count == types.count else { throw OpenGroupAPI.Error.parsingFailed } do { return try zip(dataArray, types) diff --git a/SessionMessagingKit/Open Groups/Models/Capabilities.swift b/SessionMessagingKit/Open Groups/Models/Capabilities.swift index b87873016..1e132ed11 100644 --- a/SessionMessagingKit/Open Groups/Models/Capabilities.swift +++ b/SessionMessagingKit/Open Groups/Models/Capabilities.swift @@ -2,7 +2,7 @@ import Foundation -extension OpenGroupAPIV2 { +extension OpenGroupAPI { public struct Capabilities: Codable { public enum Capability: CaseIterable, Codable { public static var allCases: [Capability] { diff --git a/SessionMessagingKit/Open Groups/Models/DeletedMessagesResponse.swift b/SessionMessagingKit/Open Groups/Models/DeletedMessagesResponse.swift index 5b8e34921..a701594b4 100644 --- a/SessionMessagingKit/Open Groups/Models/DeletedMessagesResponse.swift +++ b/SessionMessagingKit/Open Groups/Models/DeletedMessagesResponse.swift @@ -2,7 +2,7 @@ import Foundation -extension OpenGroupAPIV2 { +extension OpenGroupAPI { struct DeletedMessagesResponse: Codable { enum CodingKeys: String, CodingKey { case deletions = "ids" diff --git a/SessionMessagingKit/Open Groups/Models/Deletion.swift b/SessionMessagingKit/Open Groups/Models/Deletion.swift index d7a3e9bd9..407fcec41 100644 --- a/SessionMessagingKit/Open Groups/Models/Deletion.swift +++ b/SessionMessagingKit/Open Groups/Models/Deletion.swift @@ -2,7 +2,7 @@ import Foundation -extension OpenGroupAPIV2 { +extension OpenGroupAPI { public struct Deletion: Codable { enum CodingKeys: String, CodingKey { case id diff --git a/SessionMessagingKit/Open Groups/Models/DirectMessage.swift b/SessionMessagingKit/Open Groups/Models/DirectMessage.swift new file mode 100644 index 000000000..47976bb19 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/DirectMessage.swift @@ -0,0 +1,19 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPI { + public struct DirectMessage: Codable { + enum CodingKeys: String, CodingKey { + case id + case sender + case expires = "expires_at" + case base64EncodedData = "data" + } + + public let id: Int64 + public let sender: String + public let expires: TimeInterval + public let base64EncodedData: String + } +} diff --git a/SessionMessagingKit/Open Groups/Models/FileResponse.swift b/SessionMessagingKit/Open Groups/Models/FileResponse.swift index 6ec0f9888..d2c723e00 100644 --- a/SessionMessagingKit/Open Groups/Models/FileResponse.swift +++ b/SessionMessagingKit/Open Groups/Models/FileResponse.swift @@ -2,7 +2,7 @@ import Foundation -extension OpenGroupAPIV2 { +extension OpenGroupAPI { public struct FileResponse: Codable { enum CodingKeys: String, CodingKey { case fileName = "filename" diff --git a/SessionMessagingKit/Open Groups/Models/LegacyCompactPollBody.swift b/SessionMessagingKit/Open Groups/Models/LegacyCompactPollBody.swift index 9180bcc8c..70036c1e2 100644 --- a/SessionMessagingKit/Open Groups/Models/LegacyCompactPollBody.swift +++ b/SessionMessagingKit/Open Groups/Models/LegacyCompactPollBody.swift @@ -2,7 +2,7 @@ import Foundation -extension OpenGroupAPIV2 { +extension OpenGroupAPI { struct LegacyCompactPollBody: Codable { struct Room: Codable { enum CodingKeys: String, CodingKey { diff --git a/SessionMessagingKit/Open Groups/Models/LegacyCompactPollResponse.swift b/SessionMessagingKit/Open Groups/Models/LegacyCompactPollResponse.swift index 8988a72b5..963ef1449 100644 --- a/SessionMessagingKit/Open Groups/Models/LegacyCompactPollResponse.swift +++ b/SessionMessagingKit/Open Groups/Models/LegacyCompactPollResponse.swift @@ -2,7 +2,7 @@ import Foundation -extension OpenGroupAPIV2 { +extension OpenGroupAPI { public struct LegacyCompactPollResponse: Codable { public struct Result: Codable { enum CodingKeys: String, CodingKey { diff --git a/SessionMessagingKit/Open Groups/Models/LegacyGetInfoResponse.swift b/SessionMessagingKit/Open Groups/Models/LegacyGetInfoResponse.swift index fe00c940e..b3e4317f3 100644 --- a/SessionMessagingKit/Open Groups/Models/LegacyGetInfoResponse.swift +++ b/SessionMessagingKit/Open Groups/Models/LegacyGetInfoResponse.swift @@ -2,7 +2,7 @@ import Foundation -extension OpenGroupAPIV2 { +extension OpenGroupAPI { struct LegacyGetInfoResponse: Codable { let room: LegacyRoomInfo } diff --git a/SessionMessagingKit/Open Groups/Models/LegacyRoomInfo.swift b/SessionMessagingKit/Open Groups/Models/LegacyRoomInfo.swift index 1afce0b96..bccd9ea93 100644 --- a/SessionMessagingKit/Open Groups/Models/LegacyRoomInfo.swift +++ b/SessionMessagingKit/Open Groups/Models/LegacyRoomInfo.swift @@ -2,7 +2,7 @@ import Foundation -extension OpenGroupAPIV2 { +extension OpenGroupAPI { public struct LegacyRoomInfo: Codable { enum CodingKeys: String, CodingKey { case id diff --git a/SessionMessagingKit/Open Groups/Models/LegacyRoomsResponse.swift b/SessionMessagingKit/Open Groups/Models/LegacyRoomsResponse.swift index 7251a9ce4..162e629fc 100644 --- a/SessionMessagingKit/Open Groups/Models/LegacyRoomsResponse.swift +++ b/SessionMessagingKit/Open Groups/Models/LegacyRoomsResponse.swift @@ -2,7 +2,7 @@ import Foundation -extension OpenGroupAPIV2 { +extension OpenGroupAPI { struct LegacyRoomsResponse: Codable { let rooms: [LegacyRoomInfo] } diff --git a/SessionMessagingKit/Open Groups/Models/MemberCountResponse.swift b/SessionMessagingKit/Open Groups/Models/MemberCountResponse.swift index 2bc0ec604..8ca0d8e43 100644 --- a/SessionMessagingKit/Open Groups/Models/MemberCountResponse.swift +++ b/SessionMessagingKit/Open Groups/Models/MemberCountResponse.swift @@ -2,7 +2,7 @@ import Foundation -extension OpenGroupAPIV2 { +extension OpenGroupAPI { struct MemberCountResponse: Codable { enum CodingKeys: String, CodingKey { case memberCount = "member_count" diff --git a/SessionMessagingKit/Open Groups/Models/ModeratorsResponse.swift b/SessionMessagingKit/Open Groups/Models/ModeratorsResponse.swift index 82c00e656..40b8fb08a 100644 --- a/SessionMessagingKit/Open Groups/Models/ModeratorsResponse.swift +++ b/SessionMessagingKit/Open Groups/Models/ModeratorsResponse.swift @@ -2,7 +2,7 @@ import Foundation -extension OpenGroupAPIV2 { +extension OpenGroupAPI { struct ModeratorsResponse: Codable { let moderators: [String] } diff --git a/SessionMessagingKit/Open Groups/Models/OGMessage.swift b/SessionMessagingKit/Open Groups/Models/OGMessage.swift index d748f3306..4101dca7c 100644 --- a/SessionMessagingKit/Open Groups/Models/OGMessage.swift +++ b/SessionMessagingKit/Open Groups/Models/OGMessage.swift @@ -2,7 +2,7 @@ import Foundation -extension OpenGroupAPIV2 { +extension OpenGroupAPI { public struct Message: Codable { enum CodingKeys: String, CodingKey { case id @@ -34,7 +34,7 @@ extension OpenGroupAPIV2 { // MARK: - Decoder -extension OpenGroupAPIV2.Message { +extension OpenGroupAPI.Message { public init(from decoder: Decoder) throws { let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) @@ -45,7 +45,7 @@ extension OpenGroupAPIV2.Message { // 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 { guard let sender: String = maybeSender, let data = Data(base64Encoded: base64EncodedData), let signature = Data(base64Encoded: base64EncodedSignature) else { - throw OpenGroupAPIV2.Error.parsingFailed + throw OpenGroupAPI.Error.parsingFailed } let publicKey: Data = Data(hex: sender.removingIdPrefixIfNeeded()) @@ -53,11 +53,11 @@ extension OpenGroupAPIV2.Message { guard isValid else { SNLog("Ignoring message with invalid signature.") - throw OpenGroupAPIV2.Error.parsingFailed + throw OpenGroupAPI.Error.parsingFailed } } - self = OpenGroupAPIV2.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), diff --git a/SessionMessagingKit/Open Groups/Models/OpenGroupMessageV2.swift b/SessionMessagingKit/Open Groups/Models/OpenGroupMessageV2.swift index 4f7bf2163..c7a89ea83 100644 --- a/SessionMessagingKit/Open Groups/Models/OpenGroupMessageV2.swift +++ b/SessionMessagingKit/Open Groups/Models/OpenGroupMessageV2.swift @@ -49,7 +49,7 @@ extension OpenGroupMessageV2 { // Validate the message signature guard let data = Data(base64Encoded: base64EncodedData), let signature = Data(base64Encoded: base64EncodedSignature) else { - throw OpenGroupAPIV2.Error.parsingFailed + throw OpenGroupAPI.Error.parsingFailed } let publicKey = Data(hex: sender.removingIdPrefixIfNeeded()) @@ -57,7 +57,7 @@ extension OpenGroupMessageV2 { guard isValid else { SNLog("Ignoring message with invalid signature.") - throw OpenGroupAPIV2.Error.parsingFailed + throw OpenGroupAPI.Error.parsingFailed } self = OpenGroupMessageV2( diff --git a/SessionMessagingKit/Open Groups/Models/PinnedMessage.swift b/SessionMessagingKit/Open Groups/Models/PinnedMessage.swift index 610daa5db..bd8e9625f 100644 --- a/SessionMessagingKit/Open Groups/Models/PinnedMessage.swift +++ b/SessionMessagingKit/Open Groups/Models/PinnedMessage.swift @@ -2,7 +2,7 @@ import Foundation -extension OpenGroupAPIV2 { +extension OpenGroupAPI { public struct PinnedMessage: Codable { enum CodingKeys: String, CodingKey { case id diff --git a/SessionMessagingKit/Open Groups/Models/PublicKeyBody.swift b/SessionMessagingKit/Open Groups/Models/PublicKeyBody.swift index d7a5c6e24..b10e6bdbc 100644 --- a/SessionMessagingKit/Open Groups/Models/PublicKeyBody.swift +++ b/SessionMessagingKit/Open Groups/Models/PublicKeyBody.swift @@ -2,7 +2,7 @@ import Foundation -extension OpenGroupAPIV2 { +extension OpenGroupAPI { struct PublicKeyBody: Codable { enum CodingKeys: String, CodingKey { case publicKey = "public_key" diff --git a/SessionMessagingKit/Open Groups/Models/Room.swift b/SessionMessagingKit/Open Groups/Models/Room.swift index 4e116cb29..77e4785d8 100644 --- a/SessionMessagingKit/Open Groups/Models/Room.swift +++ b/SessionMessagingKit/Open Groups/Models/Room.swift @@ -2,7 +2,7 @@ import Foundation -extension OpenGroupAPIV2 { +extension OpenGroupAPI { public struct Room: Codable { enum CodingKeys: String, CodingKey { case token @@ -68,11 +68,11 @@ extension OpenGroupAPIV2 { // MARK: - Decoding -extension OpenGroupAPIV2.Room { +extension OpenGroupAPI.Room { public init(from decoder: Decoder) throws { let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) - self = OpenGroupAPIV2.Room( + self = OpenGroupAPI.Room( token: try container.decode(String.self, forKey: .token), created: try container.decode(TimeInterval.self, forKey: .created), name: try container.decode(String.self, forKey: .name), @@ -83,7 +83,7 @@ extension OpenGroupAPIV2.Room { messageSequence: try container.decode(Int64.self, forKey: .messageSequence), activeUsers: try container.decode(Int64.self, forKey: .activeUsers), activeUsersCutoff: try container.decode(Int64.self, forKey: .activeUsersCutoff), - pinnedMessages: try? container.decode([OpenGroupAPIV2.PinnedMessage].self, forKey: .pinnedMessages), + pinnedMessages: try? container.decode([OpenGroupAPI.PinnedMessage].self, forKey: .pinnedMessages), admin: ((try? container.decode(Bool.self, forKey: .admin)) ?? false), globalAdmin: ((try? container.decode(Bool.self, forKey: .globalAdmin)) ?? false), diff --git a/SessionMessagingKit/Open Groups/Models/RoomPollInfo.swift b/SessionMessagingKit/Open Groups/Models/RoomPollInfo.swift index d48bc24a9..01803253c 100644 --- a/SessionMessagingKit/Open Groups/Models/RoomPollInfo.swift +++ b/SessionMessagingKit/Open Groups/Models/RoomPollInfo.swift @@ -2,7 +2,7 @@ import Foundation -extension OpenGroupAPIV2 { +extension OpenGroupAPI { /// This only contains ephemeral data public struct RoomPollInfo: Codable { enum CodingKeys: String, CodingKey { diff --git a/SessionMessagingKit/Open Groups/Models/SendDirectMessageRequest.swift b/SessionMessagingKit/Open Groups/Models/SendDirectMessageRequest.swift new file mode 100644 index 000000000..2fe3e44c1 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/SendDirectMessageRequest.swift @@ -0,0 +1,19 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPI { + public struct SendDirectMessageRequest: Codable { + let data: Data + let signature: Data + + // MARK: - Encodable + + public func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(data.base64EncodedString(), forKey: .data) + try container.encode(signature.base64EncodedString(), forKey: .signature) + } + } +} diff --git a/SessionMessagingKit/Open Groups/Models/SendMessageRequest.swift b/SessionMessagingKit/Open Groups/Models/SendMessageRequest.swift index 848e677bf..068575d81 100644 --- a/SessionMessagingKit/Open Groups/Models/SendMessageRequest.swift +++ b/SessionMessagingKit/Open Groups/Models/SendMessageRequest.swift @@ -2,7 +2,7 @@ import Foundation -extension OpenGroupAPIV2 { +extension OpenGroupAPI { public struct SendMessageRequest: Codable { enum CodingKeys: String, CodingKey { case data diff --git a/SessionMessagingKit/Open Groups/Models/UserBanRequest.swift b/SessionMessagingKit/Open Groups/Models/UserBanRequest.swift index 48144df8d..2e21bca42 100644 --- a/SessionMessagingKit/Open Groups/Models/UserBanRequest.swift +++ b/SessionMessagingKit/Open Groups/Models/UserBanRequest.swift @@ -2,7 +2,7 @@ import Foundation -extension OpenGroupAPIV2 { +extension OpenGroupAPI { struct UserBanRequest: Codable { let rooms: [String]? let global: Bool? diff --git a/SessionMessagingKit/Open Groups/Models/UserDeleteMessagesRequest.swift b/SessionMessagingKit/Open Groups/Models/UserDeleteMessagesRequest.swift index c4ac800c8..0719528cc 100644 --- a/SessionMessagingKit/Open Groups/Models/UserDeleteMessagesRequest.swift +++ b/SessionMessagingKit/Open Groups/Models/UserDeleteMessagesRequest.swift @@ -2,7 +2,7 @@ import Foundation -extension OpenGroupAPIV2 { +extension OpenGroupAPI { struct UserDeleteMessagesRequest: Codable { let rooms: [String]? let global: Bool? diff --git a/SessionMessagingKit/Open Groups/Models/UserDeleteMessagesResponse.swift b/SessionMessagingKit/Open Groups/Models/UserDeleteMessagesResponse.swift index 68406a13a..9cbde6f7f 100644 --- a/SessionMessagingKit/Open Groups/Models/UserDeleteMessagesResponse.swift +++ b/SessionMessagingKit/Open Groups/Models/UserDeleteMessagesResponse.swift @@ -2,7 +2,7 @@ import Foundation -extension OpenGroupAPIV2 { +extension OpenGroupAPI { public struct UserDeleteMessagesResponse: Codable { enum CodingKeys: String, CodingKey { case id diff --git a/SessionMessagingKit/Open Groups/Models/UserModeratorRequest.swift b/SessionMessagingKit/Open Groups/Models/UserModeratorRequest.swift index 468e9e950..3e815ea86 100644 --- a/SessionMessagingKit/Open Groups/Models/UserModeratorRequest.swift +++ b/SessionMessagingKit/Open Groups/Models/UserModeratorRequest.swift @@ -2,7 +2,7 @@ import Foundation -extension OpenGroupAPIV2 { +extension OpenGroupAPI { struct UserModeratorRequest: Codable { /// List of room tokens to which the moderator status should be applied. The invoking user must be an admin of all of the given rooms. /// diff --git a/SessionMessagingKit/Open Groups/Models/UserPermissionsRequest.swift b/SessionMessagingKit/Open Groups/Models/UserPermissionsRequest.swift index 66bbe19a7..d67fa69b0 100644 --- a/SessionMessagingKit/Open Groups/Models/UserPermissionsRequest.swift +++ b/SessionMessagingKit/Open Groups/Models/UserPermissionsRequest.swift @@ -2,7 +2,7 @@ import Foundation -extension OpenGroupAPIV2 { +extension OpenGroupAPI { struct UserPermissionsRequest: Codable { let rooms: [String] let timeout: TimeInterval diff --git a/SessionMessagingKit/Open Groups/Models/UserUnbanRequest.swift b/SessionMessagingKit/Open Groups/Models/UserUnbanRequest.swift index 5e77485ee..2d28b21cb 100644 --- a/SessionMessagingKit/Open Groups/Models/UserUnbanRequest.swift +++ b/SessionMessagingKit/Open Groups/Models/UserUnbanRequest.swift @@ -2,7 +2,7 @@ import Foundation -extension OpenGroupAPIV2 { +extension OpenGroupAPI { struct UserUnbanRequest: Codable { let rooms: [String]? let global: Bool? diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPIV2+ObjC.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI+ObjC.swift similarity index 88% rename from SessionMessagingKit/Open Groups/OpenGroupAPIV2+ObjC.swift rename to SessionMessagingKit/Open Groups/OpenGroupAPI+ObjC.swift index 8c7b7420e..89ba6e8c1 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPIV2+ObjC.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI+ObjC.swift @@ -1,10 +1,10 @@ import PromiseKit -extension OpenGroupAPIV2 { +extension OpenGroupAPI { @objc(deleteMessageWithServerID:fromRoom:onServer:) public static func objc_deleteMessage(with serverID: Int64, from room: String, on server: String) -> AnyPromise { - // TODO: Upgrade this to use the non-legacy version + // TODO: Upgrade this to use the non-legacy version. return AnyPromise.from(legacyDeleteMessage(with: serverID, from: room, on: server)) } diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift similarity index 86% rename from SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift rename to SessionMessagingKit/Open Groups/OpenGroupAPI.swift index c490dedcc..0f909e71a 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -3,8 +3,8 @@ import SessionSnodeKit import Sodium import Curve25519Kit -@objc(SNOpenGroupAPIV2) -public final class OpenGroupAPIV2: NSObject { +@objc(SNOpenGroupAPI) +public final class OpenGroupAPI: NSObject { // MARK: - Settings @@ -16,7 +16,7 @@ public final class OpenGroupAPIV2: NSObject { private static var authTokenPromises: Atomic<[String: Promise]> = Atomic([:]) private static var hasPerformedInitialPoll: [String: Bool] = [:] private static var hasUpdatedLastOpenDate = false - public static let workQueue = DispatchQueue(label: "OpenGroupAPIV2.workQueue", qos: .userInitiated) // It's important that this is a serial queue + public static let workQueue = DispatchQueue(label: "OpenGroupAPI.workQueue", qos: .userInitiated) // It's important that this is a serial queue public static var moderators: [String: [String: Set]] = [:] // Server URL to room ID to set of moderator IDs public static var defaultRoomsPromise: Promise<[Room]>? public static var groupImagePromises: [String: Promise] = [:] @@ -30,13 +30,7 @@ public final class OpenGroupAPIV2: NSObject { // MARK: - Batching & Polling /// This is a convenience method which calls `/batch` with a pre-defined set of requests used to update an Open Group - public static func poll( - _ server: String, - through api: OnionRequestAPIType.Type = OnionRequestAPI.self, - using storage: SessionMessagingKitStorageProtocol = SNMessagingKitConfiguration.shared.storage, - nonceGenerator: NonceGenerator16ByteType = NonceGenerator16Byte(), - date: Date = Date() - ) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable)]> { + public static func poll(_ server: String, using dependencies: Dependencies = Dependencies()) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable)]> { // TODO: Remove comments // Capabilities // Fetch each room @@ -64,10 +58,10 @@ public final class OpenGroupAPIV2: NSObject { ) ] .appending( - storage.getAllV2OpenGroups().values + dependencies.storage.getAllV2OpenGroups().values .filter { $0.server == server.lowercased() } // Note: The `OpenGroupV2` converts the server value to lowercase during init .flatMap { openGroup -> [BatchRequestInfo] in - let lastSeqNo: Int64? = storage.getLastMessageServerID(for: openGroup.room, on: server) + let lastSeqNo: Int64? = dependencies.storage.getLastMessageServerID(for: openGroup.room, on: server) let targetSeqNo: Int64 = (lastSeqNo ?? 0) return [ @@ -93,8 +87,8 @@ public final class OpenGroupAPIV2: NSObject { } ) - // TODO: Handle response (maybe in the poller or the OpenGroupManagerV2?) - return batch(server, requests: requestResponseType, through: api, using: storage, nonceGenerator: nonceGenerator, date: date) + // TODO: Handle response (maybe in the poller or the OpenGroupManagerV2?). + return batch(server, requests: requestResponseType, using: dependencies) } /// This is used, for example, to poll multiple rooms on the same server for updates in a single query rather than needing to make multiple requests for each room. @@ -102,14 +96,7 @@ public final class OpenGroupAPIV2: NSObject { /// No guarantee is made as to the order in which sub-requests are processed; use the `/sequence` instead if you need that. /// /// For contained subrequests that specify a body (i.e. POST or PUT requests) exactly one of `json`, `b64`, or `bytes` must be provided with the request body. - private static func batch( - _ server: String, - requests: [BatchRequestInfo], - through api: OnionRequestAPIType.Type = OnionRequestAPI.self, - using storage: SessionMessagingKitStorageProtocol = SNMessagingKitConfiguration.shared.storage, - nonceGenerator: NonceGenerator16ByteType = NonceGenerator16Byte(), - date: Date = Date() - ) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable)]> { + private static func batch(_ server: String, requests: [BatchRequestInfo], using dependencies: Dependencies = Dependencies()) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable)]> { let requestBody: BatchRequest = requests.map { BatchSubRequest(request: $0.request) } let responseTypes = requests.map { $0.responseType } @@ -124,8 +111,8 @@ public final class OpenGroupAPIV2: NSObject { body: body ) - return send(request, through: api, using: storage, nonceGenerator: nonceGenerator, date: date) - .decoded(as: responseTypes, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) + return send(request, using: dependencies) + .decoded(as: responseTypes, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) .map { result in result.enumerated() .reduce(into: [:]) { prev, next in @@ -134,11 +121,10 @@ public final class OpenGroupAPIV2: NSObject { } } - // TODO: `/sequence` request + // TODO: `/sequence` request. - public static func compactPoll(_ server: String, api: OnionRequestAPIType.Type = OnionRequestAPI.self) -> Promise { - let storage: SessionMessagingKitStorageProtocol = SNMessagingKitConfiguration.shared.storage - let rooms: [String] = storage.getAllV2OpenGroups().values + public static func compactPoll(_ server: String, using dependencies: Dependencies = Dependencies()) -> Promise { + let rooms: [String] = dependencies.storage.getAllV2OpenGroups().values .filter { $0.server == server } .map { $0.room } let useMessageLimit = (hasPerformedInitialPoll[server] != true && timeSinceLastOpen > OpenGroupPollerV2.maxInactivityPeriod) @@ -146,7 +132,7 @@ public final class OpenGroupAPIV2: NSObject { hasPerformedInitialPoll[server] = true if !hasUpdatedLastOpenDate { - UserDefaults.standard[.lastOpen] = Date() + UserDefaults.standard[.lastOpen] = dependencies.date hasUpdatedLastOpenDate = true } @@ -156,10 +142,10 @@ public final class OpenGroupAPIV2: NSObject { LegacyCompactPollBody.Room( id: roomId, fromMessageServerId: (useMessageLimit ? nil : - storage.getLastMessageServerID(for: roomId, on: server) + dependencies.storage.getLastMessageServerID(for: roomId, on: server) ), fromDeletionServerId: (useMessageLimit ? nil : - storage.getLastDeletionServerID(for: roomId, on: server) + dependencies.storage.getLastDeletionServerID(for: roomId, on: server) ), legacyAuthToken: nil ) @@ -177,8 +163,8 @@ public final class OpenGroupAPIV2: NSObject { body: body ) - return send(request, through: api) - .then(on: OpenGroupAPIV2.workQueue) { _, maybeData -> Promise in + return send(request, using: dependencies) + .then(on: OpenGroupAPI.workQueue) { _, maybeData -> Promise in guard let data: Data = maybeData else { throw Error.parsingFailed } let response: LegacyCompactPollResponse = try data.decoded(as: LegacyCompactPollResponse.self, customError: Error.parsingFailed) @@ -187,11 +173,11 @@ public final class OpenGroupAPIV2: NSObject { fulfilled: response.results .map { (result: LegacyCompactPollResponse.Result) in legacyProcess(messages: result.messages, for: result.room, on: server) - .then(on: OpenGroupAPIV2.workQueue) { _ in + .then(on: OpenGroupAPI.workQueue) { _ in process(deletions: result.deletions, for: result.room, on: server) } } - ).then(on: OpenGroupAPIV2.workQueue) { _ in Promise.value(response) } + ).then(on: OpenGroupAPI.workQueue) { _ in Promise.value(response) } } } @@ -204,9 +190,9 @@ public final class OpenGroupAPIV2: NSObject { queryParameters: [:] // TODO: Add any requirements '.required'. ) - // TODO: Handle a `412` response (ie. a required capability isn't supported). + // TODO: Handle a `412` response (ie. a required capability isn't supported) return send(request, using: dependencies) - .decoded(as: Capabilities.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) + .decoded(as: Capabilities.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) } // MARK: - Room @@ -218,7 +204,7 @@ public final class OpenGroupAPIV2: NSObject { ) return send(request, using: dependencies) - .decoded(as: [Room].self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) + .decoded(as: [Room].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) } public static func room(for roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Room)> { @@ -228,7 +214,7 @@ public final class OpenGroupAPIV2: NSObject { ) return send(request, using: dependencies) - .decoded(as: Room.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) + .decoded(as: Room.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) } public static func roomPollInfo(lastUpdated: Int64, for roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, RoomPollInfo)> { @@ -238,7 +224,7 @@ public final class OpenGroupAPIV2: NSObject { ) return send(request, using: dependencies) - .decoded(as: RoomPollInfo.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) + .decoded(as: RoomPollInfo.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) } // MARK: - Messages @@ -262,7 +248,7 @@ public final class OpenGroupAPIV2: NSObject { signature: signedRequest.signature, whisperTo: whisperTo, whisperMods: whisperMods, - fileIds: nil // TODO: Add support for 'fileIds' + fileIds: nil // TODO: Add support for 'fileIds'. ) guard let body: Data = try? JSONEncoder().encode(requestBody) else { @@ -277,60 +263,61 @@ public final class OpenGroupAPIV2: NSObject { ) return send(request, using: dependencies) - .decoded(as: Message.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) + .decoded(as: Message.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) } public static func recentMessages(in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [Message])> { + // TODO: Recent vs. Since?. let request: Request = Request( server: server, endpoint: .roomMessagesRecent(roomToken) - // TODO: Limit? + // TODO: Limit?. // queryParameters: [ // .fromServerId: storage.getLastMessageServerID(for: room, on: server).map { String($0) } // ].compactMapValues { $0 } ) return send(request, using: dependencies) - .decoded(as: [Message].self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) - .then(on: OpenGroupAPIV2.workQueue) { responseInfo, messages -> Promise<(OnionRequestResponseInfoType, [Message])> in + .decoded(as: [Message].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) + .then(on: OpenGroupAPI.workQueue) { responseInfo, messages -> Promise<(OnionRequestResponseInfoType, [Message])> in process(messages: messages, for: roomToken, on: server, using: dependencies) .map { processedMessages in (responseInfo, processedMessages) } } } public static func messagesBefore(messageId: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [Message])> { - // TODO: Recent vs. Since? + // TODO: Recent vs. Since?. let request: Request = Request( server: server, endpoint: .roomMessagesBefore(roomToken, id: messageId) - // TODO: Limit? + // TODO: Limit?. // queryParameters: [ // .fromServerId: storage.getLastMessageServerID(for: room, on: server).map { String($0) } // ].compactMapValues { $0 } ) return send(request, using: dependencies) - .decoded(as: [Message].self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) - .then(on: OpenGroupAPIV2.workQueue) { responseInfo, messages -> Promise<(OnionRequestResponseInfoType, [Message])> in + .decoded(as: [Message].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) + .then(on: OpenGroupAPI.workQueue) { responseInfo, messages -> Promise<(OnionRequestResponseInfoType, [Message])> in process(messages: messages, for: roomToken, on: server, using: dependencies) .map { processedMessages in (responseInfo, processedMessages) } } } - public static func messagesSince(seqNo: Int64, in roomToken: String, on server: String) -> Promise<(OnionRequestResponseInfoType, [Message])> { - // TODO: Recent vs. Since? + public static func messagesSince(seqNo: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [Message])> { + // TODO: Recent vs. Since?. let request: Request = Request( server: server, endpoint: .roomMessagesSince(roomToken, seqNo: seqNo) - // TODO: Limit? + // TODO: Limit?. // queryParameters: [ // .fromServerId: storage.getLastMessageServerID(for: room, on: server).map { String($0) } // ].compactMapValues { $0 } ) return send(request, using: dependencies) - .decoded(as: [Message].self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) - .then(on: OpenGroupAPIV2.workQueue) { responseInfo, messages -> Promise<(OnionRequestResponseInfoType, [Message])> in + .decoded(as: [Message].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) + .then(on: OpenGroupAPI.workQueue) { responseInfo, messages -> Promise<(OnionRequestResponseInfoType, [Message])> in process(messages: messages, for: roomToken, on: server, using: dependencies) .map { processedMessages in (responseInfo, processedMessages) } } @@ -373,7 +360,7 @@ public final class OpenGroupAPIV2: NSObject { // MARK: - Files - // TODO: Shift this logic to the `OpenGroupManager` (makes more sense since it's not API logic) + // TODO: Shift this logic to the `OpenGroupManager` (makes more sense since it's not API logic). public static func roomImage(_ fileId: Int64, for roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise { // Normally the image for a given group is stored with the group thread, so it's only // fetched once. However, on the join open group screen we show images for groups the @@ -399,7 +386,7 @@ public final class OpenGroupAPIV2: NSObject { let promise: Promise = downloadFile(fileId, from: roomToken, on: server, using: dependencies) .map { _, data in data } - _ = promise.done(on: OpenGroupAPIV2.workQueue) { imageData in + _ = promise.done(on: OpenGroupAPI.workQueue) { imageData in if server == defaultServer { dependencies.storage.write { transaction in dependencies.storage.setOpenGroupImage(to: imageData, for: roomToken, on: server, using: transaction) @@ -422,7 +409,7 @@ public final class OpenGroupAPIV2: NSObject { ) return send(request, using: dependencies) - .decoded(as: FileUploadResponse.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) + .decoded(as: FileUploadResponse.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) } /// Warning: This approach is less efficient as it expects the data to be base64Encoded (with is 33% larger than binary), please use the binary approach @@ -437,7 +424,7 @@ public final class OpenGroupAPIV2: NSObject { ) return send(request, using: dependencies) - .decoded(as: FileUploadResponse.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) + .decoded(as: FileUploadResponse.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) } public static func downloadFile(_ fileId: Int64, from roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Data)> { @@ -459,9 +446,57 @@ public final class OpenGroupAPIV2: NSObject { server: server, endpoint: .roomFileIndividualJson(roomToken, fileId) ) - // TODO: This endpoint is getting rewritten to return just data (properties would come through as headers) + // TODO: This endpoint is getting rewritten to return just data (properties would come through as headers). return send(request, using: dependencies) - .decoded(as: FileDownloadResponse.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) + .decoded(as: FileDownloadResponse.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) + } + + // MARK: - Inbox (Message Requests) + + public static func messageRequests(on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage])> { + let request: Request = Request( + server: server, + endpoint: .inbox + ) + + return send(request, using: dependencies) + .decoded(as: [DirectMessage].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) + } + + public static func messageRequestsSince(id: Int64, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage])> { + let request: Request = Request( + server: server, + endpoint: .inboxSince(id: id) + ) + + return send(request, using: dependencies) + .decoded(as: [DirectMessage].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) + } + + public static func sendMessageRequest(_ plaintext: Data, to sessionId: String, on server: String, with serverPublicKey: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage])> { + // TODO: Change this to use '.blinded' once it's working + guard let signedRequest: (data: Data, signature: Data) = SendMessageRequest.sign(message: plaintext, for: .standard, with: serverPublicKey) else { + return Promise(error: Error.signingFailed) + } + + let requestBody: SendDirectMessageRequest = SendDirectMessageRequest( + data: signedRequest.data, + signature: signedRequest.signature + ) + + guard let body: Data = try? JSONEncoder().encode(requestBody) else { + return Promise(error: Error.parsingFailed) + } + + let request: Request = Request( + method: .post, + server: server, + endpoint: .inboxFor(sessionId: sessionId), + body: body + ) + + return send(request, using: dependencies) + .decoded(as: [DirectMessage].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) } // MARK: - Users @@ -571,11 +606,11 @@ public final class OpenGroupAPIV2: NSObject { ) return send(request, using: dependencies) - .decoded(as: UserDeleteMessagesResponse.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) + .decoded(as: UserDeleteMessagesResponse.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) } // MARK: - Processing - // TODO: Move these methods to the OpenGroupManager? (seems odd for them to be in the API) + // TODO: Move these methods to the OpenGroupManager? (seems odd for them to be in the API). private static func process(messages: [Message]?, for room: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<[Message]> { guard let messages: [Message] = messages, !messages.isEmpty else { return Promise.value([]) } @@ -631,7 +666,7 @@ public final class OpenGroupAPIV2: NSObject { // MARK: - General - // TODO: Shift this to the OpenGroupManagerV2? (seems more at place there than in the API) + // TODO: Shift this to the OpenGroupManagerV2? (seems more at place there than in the API). public static func getDefaultRoomsIfNeeded(using dependencies: Dependencies = Dependencies()) { Storage.shared.write( with: { transaction in @@ -639,10 +674,10 @@ public final class OpenGroupAPIV2: NSObject { }, completion: { let promise = attempt(maxRetryCount: 8, recoveringOn: DispatchQueue.main) { - OpenGroupAPIV2.rooms(for: defaultServer, using: dependencies) + OpenGroupAPI.rooms(for: defaultServer, using: dependencies) .map { _, data in data } } - _ = promise.done(on: OpenGroupAPIV2.workQueue) { items in + _ = promise.done(on: OpenGroupAPI.workQueue) { items in items .compactMap { room -> (Int64, String)? in guard let imageId: Int64 = room.imageId else { return nil} @@ -654,8 +689,8 @@ public final class OpenGroupAPIV2: NSObject { .retainUntilComplete() } } - promise.catch(on: OpenGroupAPIV2.workQueue) { _ in - OpenGroupAPIV2.defaultRoomsPromise = nil + promise.catch(on: OpenGroupAPI.workQueue) { _ in + OpenGroupAPI.defaultRoomsPromise = nil } defaultRoomsPromise = promise } @@ -681,7 +716,7 @@ public final class OpenGroupAPIV2: NSObject { // guard let blindedKeyPair: ECKeyPair = try? userKeyPair.convert(to: .blinded, with: publicKey) else { // return nil // } - // TODO: Change this back once you figure out why it's busted + // TODO: Change this back once you figure out why it's busted. let blindedKeyPair: ECKeyPair = userKeyPair /// Generate the sharedSecret by "aB || A || B" where @@ -703,8 +738,10 @@ public final class OpenGroupAPIV2: NSObject { let secretHashMessage: Bytes = method.bytes .appending(path.bytes) .appending("\(timestamp)".bytes) - .appending(request.httpBody?.bytes ?? []) // TODO: Might need to do the 'httpBodyStream' as well??? - + .appending(request.httpBody?.bytes ?? []) // TODO: Might need to do the 'httpBodyStream' as well???. + print("RAWR 1 \(blindedKeyPair.hexEncodedPublicKey)") + print("RAWR 2 \(maybeSharedSecret?.hexadecimalString)") + print("RAWR '\(String(describing: String(data: Data(secretHashMessage), encoding: .utf8)))'") guard let sharedSecret: Data = maybeSharedSecret else { return nil } guard let intermediateHash: Bytes = dependencies.genericHash.hashSaltPersonal(message: sharedSecret.bytes, outputLength: 42, key: nil, salt: nonce.bytes, personal: Personalization.sharedKeys.bytes) else { return nil @@ -712,7 +749,8 @@ public final class OpenGroupAPIV2: NSObject { guard let secretHash: Bytes = dependencies.genericHash.hashSaltPersonal(message: secretHashMessage, outputLength: 42, key: intermediateHash, salt: nonce.bytes, personal: Personalization.authHeader.bytes) else { return nil } - + print("RAWR3 '\(intermediateHash.toHexString())'") // This is the one we can compare + print("RAWR4 '\(secretHash.toHexString())'") updatedRequest.allHTTPHeaderFields = (request.allHTTPHeaderFields ?? [:]) .updated(with: [ Header.sogsPubKey.rawValue: blindedKeyPair.hexEncodedPublicKey, @@ -747,7 +785,7 @@ public final class OpenGroupAPIV2: NSObject { return Promise(error: Error.signingFailed) } - // TODO: 'removeAuthToken' as a migration??? (would previously do this when getting a `401`). + // TODO: 'removeAuthToken' as a migration??? (would previously do this when getting a `401`) return dependencies.api.sendOnionRequest(signedRequest, to: request.server, with: publicKey) } @@ -779,8 +817,8 @@ public final class OpenGroupAPIV2: NSObject { } let promise: Promise = legacyRequestNewAuthToken(for: room, on: server) - .then(on: OpenGroupAPIV2.workQueue) { legacyClaimAuthToken($0, for: room, on: server) } - .then(on: OpenGroupAPIV2.workQueue) { authToken -> Promise in + .then(on: OpenGroupAPI.workQueue) { legacyClaimAuthToken($0, for: room, on: server) } + .then(on: OpenGroupAPI.workQueue) { authToken -> Promise in let (promise, seal) = Promise.pending() storage.write(with: { transaction in storage.setAuthToken(for: room, on: server, to: authToken, using: transaction) @@ -791,10 +829,10 @@ public final class OpenGroupAPIV2: NSObject { } promise - .done(on: OpenGroupAPIV2.workQueue) { _ in + .done(on: OpenGroupAPI.workQueue) { _ in authTokenPromises.wrappedValue["\(server).\(room)"] = nil } - .catch(on: OpenGroupAPIV2.workQueue) { _ in + .catch(on: OpenGroupAPI.workQueue) { _ in authTokenPromises.wrappedValue["\(server).\(room)"] = nil } @@ -819,7 +857,7 @@ public final class OpenGroupAPIV2: NSObject { isAuthRequired: false ) - return legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _, maybeData in + return legacySend(request).map(on: OpenGroupAPI.workQueue) { _, maybeData in guard let data: Data = maybeData else { throw Error.parsingFailed } let response = try data.decoded(as: AuthTokenResponse.self, customError: Error.parsingFailed) let symmetricKey = try AESGCM.generateSymmetricKey(x25519PublicKey: response.challenge.ephemeralPublicKey, x25519PrivateKey: userKeyPair.privateKey) @@ -853,7 +891,7 @@ public final class OpenGroupAPIV2: NSObject { isAuthRequired: false ) - return legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _ in authToken } + return legacySend(request).map(on: OpenGroupAPI.workQueue) { _ in authToken } } /// Should be called when leaving a group. @@ -866,7 +904,7 @@ public final class OpenGroupAPIV2: NSObject { endpoint: .legacyAuthToken(legacyAuth: true) ) - return legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _ in + return legacySend(request).map(on: OpenGroupAPI.workQueue) { _ in let storage = SNMessagingKitConfiguration.shared.storage storage.write { transaction in @@ -914,7 +952,7 @@ public final class OpenGroupAPIV2: NSObject { ) return when(fulfilled: [Promise](getAuthTokenPromises.values)) - .then(on: OpenGroupAPIV2.workQueue) { _ -> Promise in + .then(on: OpenGroupAPI.workQueue) { _ -> Promise in let requestBodyWithAuthTokens: LegacyCompactPollBody = LegacyCompactPollBody( requests: requestBody.requests.compactMap { oldRoom -> LegacyCompactPollBody.Room? in guard let authToken: String = getAuthTokenPromises[oldRoom.id]?.value else { return nil } @@ -941,7 +979,7 @@ public final class OpenGroupAPIV2: NSObject { ) return legacySend(request) - .then(on: OpenGroupAPIV2.workQueue) { _, maybeData -> Promise in + .then(on: OpenGroupAPI.workQueue) { _, maybeData -> Promise in guard let data: Data = maybeData else { throw Error.parsingFailed } let response: LegacyCompactPollResponse = try data.decoded(as: LegacyCompactPollResponse.self, customError: Error.parsingFailed) @@ -962,11 +1000,11 @@ public final class OpenGroupAPIV2: NSObject { } return legacyProcess(messages: result.messages, for: result.room, on: server) - .then(on: OpenGroupAPIV2.workQueue) { _ -> Promise<[Deletion]> in + .then(on: OpenGroupAPI.workQueue) { _ -> Promise<[Deletion]> in legacyProcess(deletions: result.deletions, for: result.room, on: server) } } - ).then(on: OpenGroupAPIV2.workQueue) { _ in Promise.value(response) } + ).then(on: OpenGroupAPI.workQueue) { _ in Promise.value(response) } } } } @@ -979,13 +1017,13 @@ public final class OpenGroupAPIV2: NSObject { }, completion: { let promise = attempt(maxRetryCount: 8, recoveringOn: DispatchQueue.main) { - OpenGroupAPIV2.legacyGetAllRooms(from: defaultServer) + OpenGroupAPI.legacyGetAllRooms(from: defaultServer) } - _ = promise.done(on: OpenGroupAPIV2.workQueue) { items in + _ = promise.done(on: OpenGroupAPI.workQueue) { items in items.forEach { legacyGetGroupImage(for: $0.id, on: defaultServer).retainUntilComplete() } } - promise.catch(on: OpenGroupAPIV2.workQueue) { _ in - OpenGroupAPIV2.defaultRoomsPromise = nil + promise.catch(on: OpenGroupAPI.workQueue) { _ in + OpenGroupAPI.defaultRoomsPromise = nil } legacyDefaultRoomsPromise = promise } @@ -1001,7 +1039,7 @@ public final class OpenGroupAPIV2: NSObject { ) return legacySend(request) - .map(on: OpenGroupAPIV2.workQueue) { _, maybeData in + .map(on: OpenGroupAPI.workQueue) { _, maybeData in guard let data: Data = maybeData else { throw Error.parsingFailed } let response: LegacyRoomsResponse = try data.decoded(as: LegacyRoomsResponse.self, customError: Error.parsingFailed) @@ -1019,7 +1057,7 @@ public final class OpenGroupAPIV2: NSObject { ) return legacySend(request) - .map(on: OpenGroupAPIV2.workQueue) { _, maybeData in + .map(on: OpenGroupAPI.workQueue) { _, maybeData in guard let data: Data = maybeData else { throw Error.parsingFailed } let response: LegacyGetInfoResponse = try data.decoded(as: LegacyGetInfoResponse.self, customError: Error.parsingFailed) @@ -1058,7 +1096,7 @@ public final class OpenGroupAPIV2: NSObject { isAuthRequired: false ) - let promise: Promise = legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _, maybeData in + let promise: Promise = legacySend(request).map(on: OpenGroupAPI.workQueue) { _, maybeData in guard let data: Data = maybeData else { throw Error.parsingFailed } let response: LegacyFileDownloadResponse = try data.decoded(as: LegacyFileDownloadResponse.self, customError: Error.parsingFailed) @@ -1085,7 +1123,7 @@ public final class OpenGroupAPIV2: NSObject { ) return legacySend(request) - .map(on: OpenGroupAPIV2.workQueue) { _, maybeData in + .map(on: OpenGroupAPI.workQueue) { _, maybeData in guard let data: Data = maybeData else { throw Error.parsingFailed } let response: MemberCountResponse = try data.decoded(as: MemberCountResponse.self, customError: Error.parsingFailed) @@ -1110,7 +1148,7 @@ public final class OpenGroupAPIV2: NSObject { let request = Request(method: .post, server: server, room: room, endpoint: .legacyFiles, body: body) - return legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _, maybeData in + return legacySend(request).map(on: OpenGroupAPI.workQueue) { _, maybeData in guard let data: Data = maybeData else { throw Error.parsingFailed } let response: LegacyFileUploadResponse = try data.decoded(as: LegacyFileUploadResponse.self, customError: Error.parsingFailed) @@ -1122,7 +1160,7 @@ public final class OpenGroupAPIV2: NSObject { public static func legacyDownload(_ file: UInt64, from room: String, on server: String) -> Promise { let request = Request(server: server, room: room, endpoint: .legacyFile(file)) - return legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _, maybeData in + return legacySend(request).map(on: OpenGroupAPI.workQueue) { _, maybeData in guard let data: Data = maybeData else { throw Error.parsingFailed } let response: LegacyFileDownloadResponse = try data.decoded(as: LegacyFileDownloadResponse.self, customError: Error.parsingFailed) @@ -1140,7 +1178,7 @@ public final class OpenGroupAPIV2: NSObject { } let request = Request(method: .post, server: server, room: room, endpoint: .legacyMessages, body: body) - return legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _, maybeData in + return legacySend(request).map(on: OpenGroupAPI.workQueue) { _, maybeData in guard let data: Data = maybeData else { throw Error.parsingFailed } let message: OpenGroupMessageV2 = try data.decoded(as: OpenGroupMessageV2.self, customError: Error.parsingFailed) Storage.shared.write { transaction in @@ -1162,7 +1200,7 @@ public final class OpenGroupAPIV2: NSObject { ].compactMapValues { $0 } ) - return legacySend(request).then(on: OpenGroupAPIV2.workQueue) { _, maybeData -> Promise<[OpenGroupMessageV2]> in + return legacySend(request).then(on: OpenGroupAPI.workQueue) { _, maybeData -> Promise<[OpenGroupMessageV2]> in guard let data: Data = maybeData else { throw Error.parsingFailed } let messages: [OpenGroupMessageV2] = try data.decoded(as: [OpenGroupMessageV2].self, customError: Error.parsingFailed) @@ -1172,7 +1210,7 @@ public final class OpenGroupAPIV2: NSObject { // MARK: - Legacy Message Deletion - // TODO: No delete method???? + // TODO: No delete method????. @available(*, deprecated, message: "Use v4 endpoint instead") public static func legacyDeleteMessage(with serverID: Int64, from room: String, on server: String) -> Promise { let request: Request = Request( @@ -1182,7 +1220,7 @@ public final class OpenGroupAPIV2: NSObject { endpoint: .legacyMessagesForServer(serverID) ) - return legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _ in } + return legacySend(request).map(on: OpenGroupAPI.workQueue) { _ in } } @available(*, deprecated, message: "Use v4 endpoint instead") @@ -1198,7 +1236,7 @@ public final class OpenGroupAPIV2: NSObject { ].compactMapValues { $0 } ) - return legacySend(request).then(on: OpenGroupAPIV2.workQueue) { _, maybeData -> Promise<[Deletion]> in + return legacySend(request).then(on: OpenGroupAPI.workQueue) { _, maybeData -> Promise<[Deletion]> in guard let data: Data = maybeData else { throw Error.parsingFailed } let response: DeletedMessagesResponse = try data.decoded(as: DeletedMessagesResponse.self, customError: Error.parsingFailed) @@ -1217,7 +1255,7 @@ public final class OpenGroupAPIV2: NSObject { ) return legacySend(request) - .map(on: OpenGroupAPIV2.workQueue) { _, maybeData in + .map(on: OpenGroupAPI.workQueue) { _, maybeData in guard let data: Data = maybeData else { throw Error.parsingFailed } let response: ModeratorsResponse = try data.decoded(as: ModeratorsResponse.self, customError: Error.parsingFailed) @@ -1249,7 +1287,7 @@ public final class OpenGroupAPIV2: NSObject { body: body ) - return legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _ in } + return legacySend(request).map(on: OpenGroupAPI.workQueue) { _ in } } @available(*, deprecated, message: "Use v4 endpoint instead") @@ -1268,7 +1306,7 @@ public final class OpenGroupAPIV2: NSObject { body: body ) - return legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _ in } + return legacySend(request).map(on: OpenGroupAPI.workQueue) { _ in } } @available(*, deprecated, message: "Use v4 endpoint instead") @@ -1280,7 +1318,7 @@ public final class OpenGroupAPIV2: NSObject { endpoint: .legacyBlockListIndividual(publicKey) ) - return legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _ in } + return legacySend(request).map(on: OpenGroupAPI.workQueue) { _ in } } // MARK: - Processing @@ -1364,11 +1402,11 @@ public final class OpenGroupAPIV2: NSObject { } return legacyGetAuthToken(for: room, on: request.server) - .then(on: OpenGroupAPIV2.workQueue) { authToken -> Promise<(OnionRequestResponseInfoType, Data?)> in + .then(on: OpenGroupAPI.workQueue) { authToken -> Promise<(OnionRequestResponseInfoType, Data?)> in urlRequest.setValue(authToken, forHTTPHeaderField: Header.authorization.rawValue) let promise = api.sendOnionRequest(urlRequest, to: request.server, using: .v3, with: publicKey) - promise.catch(on: OpenGroupAPIV2.workQueue) { error in + promise.catch(on: OpenGroupAPI.workQueue) { error in // A 401 means that we didn't provide a (valid) auth token for a route // that required one. We use this as an indication that the token we're // using has expired. Note that a 403 has a different meaning; it means diff --git a/SessionMessagingKit/Open Groups/OpenGroupManagerV2.swift b/SessionMessagingKit/Open Groups/OpenGroupManagerV2.swift index c29191ae1..3f6e42bbd 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManagerV2.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManagerV2.swift @@ -42,7 +42,7 @@ public final class OpenGroupManagerV2 : NSObject { transaction.addCompletionQueue(DispatchQueue.global(qos: .userInitiated)) { // Get the group info // TODO: Remove this legacy method -// OpenGroupAPIV2.legacyGetRoomInfo(for: room, on: server).done(on: DispatchQueue.global(qos: .userInitiated)) { info in +// OpenGroupAPI.legacyGetRoomInfo(for: room, on: server).done(on: DispatchQueue.global(qos: .userInitiated)) { info in // // Create the open group model and the thread // let openGroup = OpenGroupV2(server: server, room: room, name: info.name, publicKey: publicKey, imageID: info.imageID) // let groupID = LKGroupUtilities.getEncodedOpenGroupIDAsData(openGroup.id) @@ -62,7 +62,7 @@ public final class OpenGroupManagerV2 : NSObject { // OpenGroupManagerV2.shared.pollers[server] = poller // } // // Fetch the group image -// OpenGroupAPIV2.legacyGetGroupImage(for: room, on: server).done(on: DispatchQueue.global(qos: .userInitiated)) { data in +// OpenGroupAPI.legacyGetGroupImage(for: room, on: server).done(on: DispatchQueue.global(qos: .userInitiated)) { data in // storage.write { transaction in // // Update the thread // let transaction = transaction as! YapDatabaseReadWriteTransaction @@ -78,7 +78,7 @@ public final class OpenGroupManagerV2 : NSObject { // seal.reject(error) // } - OpenGroupAPIV2.room(for: room, on: server) + OpenGroupAPI.room(for: room, on: server) .done(on: DispatchQueue.global(qos: .userInitiated)) { _, room in // Create the open group model and the thread let openGroup: OpenGroupV2 = OpenGroupV2( @@ -120,7 +120,7 @@ public final class OpenGroupManagerV2 : NSObject { // TODO: Need to test this // TODO: Clean this up (can we avoid the if/else with fancy promise wrangling?) if let imageId: Int64 = room.imageId { - OpenGroupAPIV2.roomImage(imageId, for: room.token, on: server) + OpenGroupAPI.roomImage(imageId, for: room.token, on: server) .done(on: DispatchQueue.global(qos: .userInitiated)) { data in storage.write { transaction in // Update the thread @@ -176,7 +176,7 @@ public final class OpenGroupManagerV2 : NSObject { Storage.shared.removeReceivedMessageTimestamps(messageTimestamps, using: transaction) Storage.shared.removeLastMessageServerID(for: openGroup.room, on: openGroup.server, using: transaction) Storage.shared.removeLastDeletionServerID(for: openGroup.room, on: openGroup.server, using: transaction) - let _ = OpenGroupAPIV2.legacyDeleteAuthToken(for: openGroup.room, on: openGroup.server) + let _ = OpenGroupAPI.legacyDeleteAuthToken(for: openGroup.room, on: openGroup.server) thread.removeAllThreadInteractions(with: transaction) thread.remove(with: transaction) Storage.shared.removeV2OpenGroup(for: thread.uniqueId!, using: transaction) diff --git a/SessionMessagingKit/Open Groups/Types/Dependencies.swift b/SessionMessagingKit/Open Groups/Types/Dependencies.swift index 58d4e4fc0..ba2a9ceff 100644 --- a/SessionMessagingKit/Open Groups/Types/Dependencies.swift +++ b/SessionMessagingKit/Open Groups/Types/Dependencies.swift @@ -4,7 +4,7 @@ import Foundation import Sodium import SessionSnodeKit -extension OpenGroupAPIV2 { +extension OpenGroupAPI { public struct Dependencies { let api: OnionRequestAPIType.Type let storage: SessionMessagingKitStorageProtocol diff --git a/SessionMessagingKit/Open Groups/Types/Endpoint.swift b/SessionMessagingKit/Open Groups/Types/Endpoint.swift index 7d8514571..5cdc19785 100644 --- a/SessionMessagingKit/Open Groups/Types/Endpoint.swift +++ b/SessionMessagingKit/Open Groups/Types/Endpoint.swift @@ -2,196 +2,210 @@ import Foundation -public enum Endpoint: Hashable { - // Utility +extension OpenGroupAPI { + public enum Endpoint: Hashable { + // Utility + + case onion + case batch + case sequence + case capabilities + + // Rooms + + case rooms + case room(String) + case roomPollInfo(String, Int64) + + // Messages + + case roomMessage(String) + case roomMessageIndividual(String, String) + case roomMessagesRecent(String) + case roomMessagesBefore(String, id: Int64) + case roomMessagesSince(String, seqNo: Int64) + + // Pinning + + case roomPinMessage(String, id: Int64) + case roomUnpinMessage(String, id: Int64) + case roomUnpinAll(String) + + // Files + + case roomFile(String) + case roomFileJson(String) + case roomFileIndividual(String, Int64) + case roomFileIndividualJson(String, Int64) + + // Inbox (Message Requests) + + case inbox + case inboxSince(id: Int64) + case inboxFor(sessionId: String) + + // Users + + case userBan(String) + case userUnban(String) + case userPermission(String) + case userModerator(String) + case userDeleteMessages(String) + + // Legacy endpoints (to be deprecated and removed) + + @available(*, deprecated, message: "Use v4 endpoint") case legacyFiles + @available(*, deprecated, message: "Use v4 endpoint") case legacyFile(UInt64) + + @available(*, deprecated, message: "Use v4 endpoint") case legacyMessages + @available(*, deprecated, message: "Use v4 endpoint") case legacyMessagesForServer(Int64) + @available(*, deprecated, message: "Use v4 endpoint") case legacyDeletedMessages + + @available(*, deprecated, message: "Use v4 endpoint") case legacyModerators + + @available(*, deprecated, message: "Use v4 endpoint") case legacyBlockList + @available(*, deprecated, message: "Use v4 endpoint") case legacyBlockListIndividual(String) + @available(*, deprecated, message: "Use v4 endpoint") case legacyBanAndDeleteAll + + @available(*, deprecated, message: "Use v4 endpoint") case legacyCompactPoll(legacyAuth: Bool) + @available(*, deprecated, message: "Use request signing") case legacyAuthToken(legacyAuth: Bool) + @available(*, deprecated, message: "Use request signing") case legacyAuthTokenChallenge(legacyAuth: Bool) + @available(*, deprecated, message: "Use request signing") case legacyAuthTokenClaim(legacyAuth: Bool) + + @available(*, deprecated, message: "Use v4 endpoint") case legacyRooms + @available(*, deprecated, message: "Use v4 endpoint") case legacyRoomInfo(String) + @available(*, deprecated, message: "Use v4 endpoint") case legacyRoomImage(String) + @available(*, deprecated, message: "Use v4 endpoint") case legacyMemberCount(legacyAuth: Bool) + + var path: String { + switch self { + // Utility + + case .onion: return "oxen/v4/lsrpc" + case .batch: return "batch" + case .sequence: return "sequence" + case .capabilities: return "capabilities" + + // Rooms + + case .rooms: return "rooms" + case .room(let roomToken): return "room/\(roomToken)" + case .roomPollInfo(let roomToken, let infoUpdated): return "room/\(roomToken)/pollInfo/\(infoUpdated)" + + // Messages + + case .roomMessage(let roomToken): + return "room/\(roomToken)/message" + + case .roomMessageIndividual(let roomToken, let messageId): + return "room/\(roomToken)/message/\(messageId)" + + case .roomMessagesRecent(let roomToken): + return "room/\(roomToken)/messages/recent" + + case .roomMessagesBefore(let roomToken, let messageId): + return "room/\(roomToken)/messages/before/\(messageId)" + + case .roomMessagesSince(let roomToken, let seqNo): + return "room/\(roomToken)/messages/since/\(seqNo)" + + // Pinning + + case .roomPinMessage(let roomToken, let messageId): + return "room/\(roomToken)/pin/\(messageId)" + + case .roomUnpinMessage(let roomToken, let messageId): + return "room/\(roomToken)/unpin/\(messageId)" + + case .roomUnpinAll(let roomToken): + return "room/\(roomToken)/unpin/all" + + // Files + + case .roomFile(let roomToken): return "room/\(roomToken)/file" + case .roomFileJson(let roomToken): return "room/\(roomToken)/fileJSON" + case .roomFileIndividual(let roomToken, let fileId): + // Note: The 'fileName' value is ignored by the server and is only used to distinguish + // this from the 'Json' variant + let fileName: String = "" + return "room/\(roomToken)/file/\(fileId)/\(fileName)" + + case .roomFileIndividualJson(let roomToken, let fileId): + return "room/\(roomToken)/file/\(fileId)" + + // Inbox (Message Requests) - case onion - case batch - case sequence - case capabilities - - // Rooms - - case rooms - case room(String) - case roomPollInfo(String, Int64) - - // Messages - - case roomMessage(String) - case roomMessageIndividual(String, String) - case roomMessagesRecent(String) - case roomMessagesBefore(String, id: Int64) - case roomMessagesSince(String, seqNo: Int64) - - // Pinning - - case roomPinMessage(String, id: Int64) - case roomUnpinMessage(String, id: Int64) - case roomUnpinAll(String) - - // Files - - case roomFile(String) - case roomFileJson(String) - case roomFileIndividual(String, Int64) - case roomFileIndividualJson(String, Int64) - - // Users - - case userBan(String) - case userUnban(String) - case userPermission(String) - case userModerator(String) - case userDeleteMessages(String) - - // Legacy endpoints (to be deprecated and removed) - - @available(*, deprecated, message: "Use v4 endpoint") case legacyFiles - @available(*, deprecated, message: "Use v4 endpoint") case legacyFile(UInt64) - - @available(*, deprecated, message: "Use v4 endpoint") case legacyMessages - @available(*, deprecated, message: "Use v4 endpoint") case legacyMessagesForServer(Int64) - @available(*, deprecated, message: "Use v4 endpoint") case legacyDeletedMessages - - @available(*, deprecated, message: "Use v4 endpoint") case legacyModerators - - @available(*, deprecated, message: "Use v4 endpoint") case legacyBlockList - @available(*, deprecated, message: "Use v4 endpoint") case legacyBlockListIndividual(String) - @available(*, deprecated, message: "Use v4 endpoint") case legacyBanAndDeleteAll - - @available(*, deprecated, message: "Use v4 endpoint") case legacyCompactPoll(legacyAuth: Bool) - @available(*, deprecated, message: "Use request signing") case legacyAuthToken(legacyAuth: Bool) - @available(*, deprecated, message: "Use request signing") case legacyAuthTokenChallenge(legacyAuth: Bool) - @available(*, deprecated, message: "Use request signing") case legacyAuthTokenClaim(legacyAuth: Bool) - - @available(*, deprecated, message: "Use v4 endpoint") case legacyRooms - @available(*, deprecated, message: "Use v4 endpoint") case legacyRoomInfo(String) - @available(*, deprecated, message: "Use v4 endpoint") case legacyRoomImage(String) - @available(*, deprecated, message: "Use v4 endpoint") case legacyMemberCount(legacyAuth: Bool) - - var path: String { - switch self { - // Utility - - case .onion: return "oxen/v4/lsrpc" - case .batch: return "batch" - case .sequence: return "sequence" - case .capabilities: return "capabilities" + case .inbox: return "inbox" + case .inboxSince(let id): return "inbox/\(id)" + case .inboxFor(let sessionId): return "inbox/\(sessionId)" - // Rooms + // Users - case .rooms: return "rooms" - case .room(let roomToken): return "room/\(roomToken)" - case .roomPollInfo(let roomToken, let infoUpdated): return "room/\(roomToken)/pollInfo/\(infoUpdated)" + case .userBan(let sessionId): return "user/\(sessionId)/ban" + case .userUnban(let sessionId): return "user/\(sessionId)/unban" + case .userPermission(let sessionId): return "user/\(sessionId)/permission" + case .userModerator(let sessionId): return "user/\(sessionId)/moderator" + case .userDeleteMessages(let sessionId): return "user/\(sessionId)/deleteMessages" - // Messages - - case .roomMessage(let roomToken): - return "room/\(roomToken)/message" + // Legacy endpoints (to be deprecated and removed) + // TODO: Look for a nicer way to prepend 'legacy'? (OnionRequestAPI messes with this but the new auth needs it to be correct...) + + + case .legacyFiles: return "legacy/files" + case .legacyFile(let fileId): return "legacy/files/\(fileId)" - case .roomMessageIndividual(let roomToken, let messageId): - return "room/\(roomToken)/message/\(messageId)" - - case .roomMessagesRecent(let roomToken): - return "room/\(roomToken)/messages/recent" + case .legacyMessages: return "legacy/messages" + case .legacyMessagesForServer(let serverId): return "legacy/messages/\(serverId)" + case .legacyDeletedMessages: return "legacy/deleted_messages" + + case .legacyModerators: return "legacy/moderators" + + case .legacyBlockList: return "legacy/block_list" + case .legacyBlockListIndividual(let publicKey): return "legacy/block_list/\(publicKey)" + case .legacyBanAndDeleteAll: return "legacy/ban_and_delete_all" - case .roomMessagesBefore(let roomToken, let messageId): - return "room/\(roomToken)/messages/before/\(messageId)" - - case .roomMessagesSince(let roomToken, let seqNo): - return "room/\(roomToken)/messages/since/\(seqNo)" - - // Pinning - - case .roomPinMessage(let roomToken, let messageId): - return "room/\(roomToken)/pin/\(messageId)" - - case .roomUnpinMessage(let roomToken, let messageId): - return "room/\(roomToken)/unpin/\(messageId)" - - case .roomUnpinAll(let roomToken): - return "room/\(roomToken)/unpin/all" - - // Files - - case .roomFile(let roomToken): return "room/\(roomToken)/file" - case .roomFileJson(let roomToken): return "room/\(roomToken)/fileJSON" - case .roomFileIndividual(let roomToken, let fileId): - // Note: The 'fileName' value is ignored by the server and is only used to distinguish - // this from the 'Json' variant - let fileName: String = "" - return "room/\(roomToken)/file/\(fileId)/\(fileName)" - - case .roomFileIndividualJson(let roomToken, let fileId): - return "room/\(roomToken)/file/\(fileId)" - - // Users - - case .userBan(let sessionId): return "user/\(sessionId)/ban" - case .userUnban(let sessionId): return "user/\(sessionId)/unban" - case .userPermission(let sessionId): return "user/\(sessionId)/permission" - case .userModerator(let sessionId): return "user/\(sessionId)/moderator" - case .userDeleteMessages(let sessionId): return "user/\(sessionId)/deleteMessages" - - // Legacy endpoints (to be deprecated and removed) - // TODO: Look for a nicer way to prepend 'legacy'? (OnionRequestAPI messes with this but the new auth needs it to be correct... ) - - - case .legacyFiles: return "legacy/files" - case .legacyFile(let fileId): return "legacy/files/\(fileId)" - - case .legacyMessages: return "legacy/messages" - case .legacyMessagesForServer(let serverId): return "legacy/messages/\(serverId)" - case .legacyDeletedMessages: return "legacy/deleted_messages" - - case .legacyModerators: return "legacy/moderators" - - case .legacyBlockList: return "legacy/block_list" - case .legacyBlockListIndividual(let publicKey): return "legacy/block_list/\(publicKey)" - case .legacyBanAndDeleteAll: return "legacy/ban_and_delete_all" - - case .legacyCompactPoll(let useLegacyAuth): - return "\(useLegacyAuth ? "" : "legacy/")compact_poll" - - case .legacyAuthToken(let useLegacyAuth): - return "\(useLegacyAuth ? "" : "legacy/")auth_token" - - case .legacyAuthTokenChallenge(let useLegacyAuth): - return "\(useLegacyAuth ? "" : "legacy/")auth_token_challenge" - - case .legacyAuthTokenClaim(let useLegacyAuth): - return "\(useLegacyAuth ? "" : "legacy/")claim_auth_token" - - case .legacyRooms: return "legacy/rooms" - case .legacyRoomInfo(let roomName): return "legacy/rooms/\(roomName)" - case .legacyRoomImage(let roomName): return "legacy/rooms/\(roomName)/image" - - case .legacyMemberCount(let useLegacyAuth): - return "\(useLegacyAuth ? "" : "legacy/")member_count" + case .legacyCompactPoll(let useLegacyAuth): + return "\(useLegacyAuth ? "" : "legacy/")compact_poll" + + case .legacyAuthToken(let useLegacyAuth): + return "\(useLegacyAuth ? "" : "legacy/")auth_token" + + case .legacyAuthTokenChallenge(let useLegacyAuth): + return "\(useLegacyAuth ? "" : "legacy/")auth_token_challenge" + + case .legacyAuthTokenClaim(let useLegacyAuth): + return "\(useLegacyAuth ? "" : "legacy/")claim_auth_token" + + case .legacyRooms: return "legacy/rooms" + case .legacyRoomInfo(let roomName): return "legacy/rooms/\(roomName)" + case .legacyRoomImage(let roomName): return "legacy/rooms/\(roomName)/image" + + case .legacyMemberCount(let useLegacyAuth): + return "\(useLegacyAuth ? "" : "legacy/")member_count" + } } - } - - var useLegacyAuth: Bool { - switch self { - // File upload/download should use legacy auth - case .legacyFiles, .legacyFile, .legacyMessages, - .legacyMessagesForServer, .legacyDeletedMessages, - .legacyModerators, .legacyBlockList, - .legacyBlockListIndividual, .legacyBanAndDeleteAll: - return true + + var useLegacyAuth: Bool { + switch self { + // File upload/download should use legacy auth + case .legacyFiles, .legacyFile, .legacyMessages, + .legacyMessagesForServer, .legacyDeletedMessages, + .legacyModerators, .legacyBlockList, + .legacyBlockListIndividual, .legacyBanAndDeleteAll: + return true + + case .legacyCompactPoll(let useLegacyAuth), + .legacyAuthToken(let useLegacyAuth), + .legacyAuthTokenChallenge(let useLegacyAuth), + .legacyAuthTokenClaim(let useLegacyAuth), + .legacyMemberCount(let useLegacyAuth): + return useLegacyAuth + + case .legacyRooms, .legacyRoomInfo, .legacyRoomImage: + return true - case .legacyCompactPoll(let useLegacyAuth), - .legacyAuthToken(let useLegacyAuth), - .legacyAuthTokenChallenge(let useLegacyAuth), - .legacyAuthTokenClaim(let useLegacyAuth), - .legacyMemberCount(let useLegacyAuth): - return useLegacyAuth - - case .legacyRooms, .legacyRoomInfo, .legacyRoomImage: - return true - - default: return false + default: return false + } } } } diff --git a/SessionMessagingKit/Open Groups/Types/Error.swift b/SessionMessagingKit/Open Groups/Types/Error.swift index 52610469f..87eb8b255 100644 --- a/SessionMessagingKit/Open Groups/Types/Error.swift +++ b/SessionMessagingKit/Open Groups/Types/Error.swift @@ -2,7 +2,7 @@ import Foundation -extension OpenGroupAPIV2 { +extension OpenGroupAPI { public enum Error: LocalizedError { case generic case parsingFailed diff --git a/SessionMessagingKit/Open Groups/Types/NonceGenerator16Byte.swift b/SessionMessagingKit/Open Groups/Types/NonceGenerator16Byte.swift index b4c5cb2e1..e8e1626ad 100644 --- a/SessionMessagingKit/Open Groups/Types/NonceGenerator16Byte.swift +++ b/SessionMessagingKit/Open Groups/Types/NonceGenerator16Byte.swift @@ -10,7 +10,7 @@ extension NonceGenerator16ByteType { } -extension OpenGroupAPIV2 { +extension OpenGroupAPI { public class NonceGenerator16Byte: NonceGenerator, NonceGenerator16ByteType { public var NonceBytes: Int { 16 } diff --git a/SessionMessagingKit/Open Groups/Types/Personalization.swift b/SessionMessagingKit/Open Groups/Types/Personalization.swift index 44235af0d..e1285c212 100644 --- a/SessionMessagingKit/Open Groups/Types/Personalization.swift +++ b/SessionMessagingKit/Open Groups/Types/Personalization.swift @@ -3,7 +3,7 @@ import Foundation import Sodium -extension OpenGroupAPIV2 { +extension OpenGroupAPI { public enum Personalization: String { case sharedKeys = "sogs.shared_keys" case authHeader = "sogs.auth_header" diff --git a/SessionMessagingKit/Open Groups/Types/Request.swift b/SessionMessagingKit/Open Groups/Types/Request.swift index 955a5e07c..d6ad29671 100644 --- a/SessionMessagingKit/Open Groups/Types/Request.swift +++ b/SessionMessagingKit/Open Groups/Types/Request.swift @@ -3,7 +3,7 @@ import Foundation import SessionUtilitiesKit -extension OpenGroupAPIV2 { +extension OpenGroupAPI { struct Request { let method: HTTP.Verb let server: String diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index bd2d2fcc3..b6a3cdacd 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -373,7 +373,7 @@ public final class MessageSender : NSObject { preconditionFailure() } - OpenGroupAPIV2 + OpenGroupAPI .send( plaintext, to: room, diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift index 23e0331a3..3497c884a 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift @@ -48,13 +48,13 @@ public final class OpenGroupPollerV2 : NSObject { let (promise, seal) = Promise.pending() promise.retainUntilComplete() - OpenGroupAPIV2.poll(server) - .done(on: OpenGroupAPIV2.workQueue) { [weak self] response in + OpenGroupAPI.poll(server) + .done(on: OpenGroupAPI.workQueue) { [weak self] response in self?.isPolling = false self?.handlePollResponse(response, isBackgroundPoll: isBackgroundPoll) seal.fulfill(()) } - .catch(on: OpenGroupAPIV2.workQueue) { [weak self] error in + .catch(on: OpenGroupAPI.workQueue) { [weak self] error in SNLog("Open group polling failed due to error: \(error).") self?.isPolling = false seal.fulfill(()) // The promise is just used to keep track of when we're done @@ -63,13 +63,13 @@ public final class OpenGroupPollerV2 : NSObject { return promise } - private func handlePollResponse(_ response: [Endpoint: (info: OnionRequestResponseInfoType, data: Codable)], isBackgroundPoll: Bool) { + private func handlePollResponse(_ response: [OpenGroupAPI.Endpoint: (info: OnionRequestResponseInfoType, data: Codable)], isBackgroundPoll: Bool) { let storage = SNMessagingKitConfiguration.shared.storage response.forEach { endpoint, response in switch endpoint { case .roomMessagesRecent(let roomToken), .roomMessagesBefore(let roomToken, _), .roomMessagesSince(let roomToken, _): - guard let responseData: [OpenGroupAPIV2.Message] = response.data as? [OpenGroupAPIV2.Message] else { + guard let responseData: [OpenGroupAPI.Message] = response.data as? [OpenGroupAPI.Message] else { //SNLog("Open group polling failed due to error: \(error).") return // TODO: Throw error? } @@ -77,7 +77,7 @@ public final class OpenGroupPollerV2 : NSObject { handleMessages(responseData, roomToken: roomToken, isBackgroundPoll: isBackgroundPoll, using: storage) case .roomPollInfo(let roomToken, _): - guard let responseData: OpenGroupAPIV2.RoomPollInfo = response.data as? OpenGroupAPIV2.RoomPollInfo else { + guard let responseData: OpenGroupAPI.RoomPollInfo = response.data as? OpenGroupAPI.RoomPollInfo else { //SNLog("Open group polling failed due to error: \(error).") return // TODO: Throw error? } @@ -92,10 +92,10 @@ public final class OpenGroupPollerV2 : NSObject { // MARK: - Custom response handling // TODO: Shift this logic to the OpenGroupManagerV2? (seems like the place it should belong?) - private func handleMessages(_ messages: [OpenGroupAPIV2.Message], roomToken: String, isBackgroundPoll: Bool, using storage: SessionMessagingKitStorageProtocol) { + private func handleMessages(_ messages: [OpenGroupAPI.Message], roomToken: String, isBackgroundPoll: Bool, using storage: SessionMessagingKitStorageProtocol) { // Sorting the messages by server ID before importing them fixes an issue where messages that quote older messages can't find those older messages let openGroupID = "\(server).\(roomToken)" - let sortedMessages: [OpenGroupAPIV2.Message] = messages + let sortedMessages: [OpenGroupAPI.Message] = messages .sorted { lhs, rhs in lhs.seqNo < rhs.seqNo } storage.write { transaction in @@ -140,18 +140,18 @@ public final class OpenGroupPollerV2 : NSObject { } } - private func handlePollInfo(_ pollInfo: OpenGroupAPIV2.RoomPollInfo, roomToken: String, isBackgroundPoll: Bool, using storage: SessionMessagingKitStorageProtocol) { + private func handlePollInfo(_ pollInfo: OpenGroupAPI.RoomPollInfo, roomToken: String, isBackgroundPoll: Bool, using storage: SessionMessagingKitStorageProtocol) { // TODO: Handle other properties??? // - Moderators - OpenGroupAPIV2.moderators[server] = (OpenGroupAPIV2.moderators[server] ?? [:]) + OpenGroupAPI.moderators[server] = (OpenGroupAPI.moderators[server] ?? [:]) .setting(roomToken, Set(pollInfo.moderators ?? [])) } // MARK: - Legacy Handling - private func handleCompactPollBody(_ body: OpenGroupAPIV2.LegacyCompactPollResponse.Result, isBackgroundPoll: Bool) { + private func handleCompactPollBody(_ body: OpenGroupAPI.LegacyCompactPollResponse.Result, isBackgroundPoll: Bool) { let storage = SNMessagingKitConfiguration.shared.storage // - Messages // Sorting the messages by server ID before importing them fixes an issue where messages that quote older messages can't find those older messages @@ -177,12 +177,12 @@ public final class OpenGroupPollerV2 : NSObject { } // - Moderators - if var x = OpenGroupAPIV2.moderators[server] { + if var x = OpenGroupAPI.moderators[server] { x[body.room] = Set(body.moderators ?? []) - OpenGroupAPIV2.moderators[server] = x + OpenGroupAPI.moderators[server] = x } else { - OpenGroupAPIV2.moderators[server] = [ body.room : Set(body.moderators ?? []) ] + OpenGroupAPI.moderators[server] = [ body.room : Set(body.moderators ?? []) ] } // - Deletions diff --git a/SessionMessagingKit/Utilities/Promise+Utilities.swift b/SessionMessagingKit/Utilities/Promise+Utilities.swift index 84e71cd1c..b59ebdbc2 100644 --- a/SessionMessagingKit/Utilities/Promise+Utilities.swift +++ b/SessionMessagingKit/Utilities/Promise+Utilities.swift @@ -16,7 +16,7 @@ extension Promise where T == (OnionRequestResponseInfoType, Data?) { func decoded(as type: R.Type, on queue: DispatchQueue? = nil, error: Error? = nil) -> Promise<(OnionRequestResponseInfoType, R)> { self.map(on: queue) { responseInfo, maybeData -> (OnionRequestResponseInfoType, R) in guard let data: Data = maybeData else { - throw OpenGroupAPIV2.Error.parsingFailed + throw OpenGroupAPI.Error.parsingFailed } return (responseInfo, try data.decoded(as: type, customError: error)) diff --git a/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift b/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift index d491b716f..15bade4d6 100644 --- a/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift +++ b/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift @@ -49,7 +49,7 @@ extension MessageSender { stream, using: { data in // TODO: Update to non-legacy version - OpenGroupAPIV2.legacyUpload( + OpenGroupAPI.legacyUpload( data, to: v2OpenGroup.room, on: v2OpenGroup.server @@ -96,7 +96,7 @@ extension MessageSender { stream, using: { data in // TODO: Update to non-legacy version - OpenGroupAPIV2.legacyUpload( + OpenGroupAPI.legacyUpload( data, to: v2OpenGroup.room, on: v2OpenGroup.server From cd3dffcff99f20182994fbf7dfecfdc62dc751ab Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 15 Feb 2022 16:31:25 +1100 Subject: [PATCH 009/157] Missed the renaming in the unit tests --- .../Open Groups/OpenGroupAPIV2Tests.swift | 108 +++++++++--------- 1 file changed, 54 insertions(+), 54 deletions(-) diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift b/SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift index 7d01bb41a..5275bc9c6 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift @@ -8,7 +8,7 @@ import SessionSnodeKit @testable import SessionMessagingKit -class OpenGroupAPIV2Tests: XCTestCase { +class OpenGroupAPITests: XCTestCase { class TestResponseInfo: OnionRequestResponseInfoType { let requestData: TestApi.RequestData let code: Int @@ -67,13 +67,13 @@ class OpenGroupAPIV2Tests: XCTestCase { } var testStorage: TestStorage! - var dependencies: OpenGroupAPIV2.Dependencies! + var dependencies: OpenGroupAPI.Dependencies! // MARK: - Configuration override func setUpWithError() throws { testStorage = TestStorage() - dependencies = OpenGroupAPIV2.Dependencies( + dependencies = OpenGroupAPI.Dependencies( api: TestApi.self, storage: testStorage, nonceGenerator: TestNonceGenerator(), @@ -104,24 +104,24 @@ class OpenGroupAPIV2Tests: XCTestCase { override class var mockResponse: Data? { let responses: [Data] = [ try! JSONEncoder().encode( - OpenGroupAPIV2.BatchSubResponse( + OpenGroupAPI.BatchSubResponse( code: 200, headers: [:], - body: OpenGroupAPIV2.Capabilities(capabilities: [], missing: nil) + body: OpenGroupAPI.Capabilities(capabilities: [], missing: nil) ) ), try! JSONEncoder().encode( - OpenGroupAPIV2.BatchSubResponse( + OpenGroupAPI.BatchSubResponse( code: 200, headers: [:], - body: try! JSONDecoder().decode(OpenGroupAPIV2.RoomPollInfo.self, from: "{}".data(using: .utf8)!) + body: try! JSONDecoder().decode(OpenGroupAPI.RoomPollInfo.self, from: "{}".data(using: .utf8)!) ) ), try! JSONEncoder().encode( - OpenGroupAPIV2.BatchSubResponse( + OpenGroupAPI.BatchSubResponse( code: 200, headers: [:], - body: [OpenGroupAPIV2.Message]() + body: [OpenGroupAPI.Message]() ) ) ] @@ -131,10 +131,10 @@ class OpenGroupAPIV2Tests: XCTestCase { } dependencies = dependencies.with(api: LocalTestApi.self) - var response: [Endpoint: (OnionRequestResponseInfoType, Codable)]? = nil + var response: [OpenGroupAPI.Endpoint: (OnionRequestResponseInfoType, Codable)]? = nil var error: Error? = nil - OpenGroupAPIV2.poll("testServer", using: dependencies) + OpenGroupAPI.poll("testServer", using: dependencies) .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -162,17 +162,17 @@ class OpenGroupAPIV2Tests: XCTestCase { } func testPollReturnsAnErrorWhenGivenNoData() throws { - var response: [Endpoint: (OnionRequestResponseInfoType, Codable)]? = nil + var response: [OpenGroupAPI.Endpoint: (OnionRequestResponseInfoType, Codable)]? = nil var error: Error? = nil - OpenGroupAPIV2.poll("testServer", using: dependencies) + OpenGroupAPI.poll("testServer", using: dependencies) .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPIV2.Error.parsingFailed.localizedDescription), + equal(OpenGroupAPI.Error.parsingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -185,17 +185,17 @@ class OpenGroupAPIV2Tests: XCTestCase { } dependencies = dependencies.with(api: LocalTestApi.self) - var response: [Endpoint: (OnionRequestResponseInfoType, Codable)]? = nil + var response: [OpenGroupAPI.Endpoint: (OnionRequestResponseInfoType, Codable)]? = nil var error: Error? = nil - OpenGroupAPIV2.poll("testServer", using: dependencies) + OpenGroupAPI.poll("testServer", using: dependencies) .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPIV2.Error.parsingFailed.localizedDescription), + equal(OpenGroupAPI.Error.parsingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -208,17 +208,17 @@ class OpenGroupAPIV2Tests: XCTestCase { } dependencies = dependencies.with(api: LocalTestApi.self) - var response: [Endpoint: (OnionRequestResponseInfoType, Codable)]? = nil + var response: [OpenGroupAPI.Endpoint: (OnionRequestResponseInfoType, Codable)]? = nil var error: Error? = nil - OpenGroupAPIV2.poll("testServer", using: dependencies) + OpenGroupAPI.poll("testServer", using: dependencies) .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPIV2.Error.parsingFailed.localizedDescription), + equal(OpenGroupAPI.Error.parsingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -231,17 +231,17 @@ class OpenGroupAPIV2Tests: XCTestCase { } dependencies = dependencies.with(api: LocalTestApi.self) - var response: [Endpoint: (OnionRequestResponseInfoType, Codable)]? = nil + var response: [OpenGroupAPI.Endpoint: (OnionRequestResponseInfoType, Codable)]? = nil var error: Error? = nil - OpenGroupAPIV2.poll("testServer", using: dependencies) + OpenGroupAPI.poll("testServer", using: dependencies) .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPIV2.Error.parsingFailed.localizedDescription), + equal(OpenGroupAPI.Error.parsingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -253,17 +253,17 @@ class OpenGroupAPIV2Tests: XCTestCase { override class var mockResponse: Data? { let responses: [Data] = [ try! JSONEncoder().encode( - OpenGroupAPIV2.BatchSubResponse( + OpenGroupAPI.BatchSubResponse( code: 200, headers: [:], - body: OpenGroupAPIV2.Capabilities(capabilities: [], missing: nil) + body: OpenGroupAPI.Capabilities(capabilities: [], missing: nil) ) ), try! JSONEncoder().encode( - OpenGroupAPIV2.BatchSubResponse( + OpenGroupAPI.BatchSubResponse( code: 200, headers: [:], - body: try! JSONDecoder().decode(OpenGroupAPIV2.RoomPollInfo.self, from: "{}".data(using: .utf8)!) + body: try! JSONDecoder().decode(OpenGroupAPI.RoomPollInfo.self, from: "{}".data(using: .utf8)!) ) ) ] @@ -273,17 +273,17 @@ class OpenGroupAPIV2Tests: XCTestCase { } dependencies = dependencies.with(api: LocalTestApi.self) - var response: [Endpoint: (OnionRequestResponseInfoType, Codable)]? = nil + var response: [OpenGroupAPI.Endpoint: (OnionRequestResponseInfoType, Codable)]? = nil var error: Error? = nil - OpenGroupAPIV2.poll("testServer", using: dependencies) + OpenGroupAPI.poll("testServer", using: dependencies) .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPIV2.Error.parsingFailed.localizedDescription), + equal(OpenGroupAPI.Error.parsingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -295,24 +295,24 @@ class OpenGroupAPIV2Tests: XCTestCase { override class var mockResponse: Data? { let responses: [Data] = [ try! JSONEncoder().encode( - OpenGroupAPIV2.BatchSubResponse( + OpenGroupAPI.BatchSubResponse( code: 200, headers: [:], - body: OpenGroupAPIV2.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: "") + body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: "") ) ), try! JSONEncoder().encode( - OpenGroupAPIV2.BatchSubResponse( + OpenGroupAPI.BatchSubResponse( code: 200, headers: [:], - body: OpenGroupAPIV2.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: "") + body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: "") ) ), try! JSONEncoder().encode( - OpenGroupAPIV2.BatchSubResponse( + OpenGroupAPI.BatchSubResponse( code: 200, headers: [:], - body: OpenGroupAPIV2.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: "") + body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: "") ) ) ] @@ -322,17 +322,17 @@ class OpenGroupAPIV2Tests: XCTestCase { } dependencies = dependencies.with(api: LocalTestApi.self) - var response: [Endpoint: (OnionRequestResponseInfoType, Codable)]? = nil + var response: [OpenGroupAPI.Endpoint: (OnionRequestResponseInfoType, Codable)]? = nil var error: Error? = nil - OpenGroupAPIV2.poll("testServer", using: dependencies) + OpenGroupAPI.poll("testServer", using: dependencies) .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPIV2.Error.parsingFailed.localizedDescription), + equal(OpenGroupAPI.Error.parsingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -352,7 +352,7 @@ class OpenGroupAPIV2Tests: XCTestCase { var response: (OnionRequestResponseInfoType, FileUploadResponse)? = nil var error: Error? = nil - OpenGroupAPIV2.uploadFile([], to: "testRoom", on: "testServer", using: dependencies) + OpenGroupAPI.uploadFile([], to: "testRoom", on: "testServer", using: dependencies) .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -383,7 +383,7 @@ class OpenGroupAPIV2Tests: XCTestCase { var response: (OnionRequestResponseInfoType, FileUploadResponse)? = nil var error: Error? = nil - OpenGroupAPIV2.uploadFile([], fileName: "TestFileName", to: "testRoom", on: "testServer", using: dependencies) + OpenGroupAPI.uploadFile([], fileName: "TestFileName", to: "testRoom", on: "testServer", using: dependencies) .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -408,15 +408,15 @@ class OpenGroupAPIV2Tests: XCTestCase { func testItSignsTheRequestCorrectly() throws { class LocalTestApi: TestApi { override class var mockResponse: Data? { - return try! JSONEncoder().encode([OpenGroupAPIV2.Room]()) + return try! JSONEncoder().encode([OpenGroupAPI.Room]()) } } dependencies = dependencies.with(api: LocalTestApi.self) - var response: (OnionRequestResponseInfoType, [OpenGroupAPIV2.Room])? = nil + var response: (OnionRequestResponseInfoType, [OpenGroupAPI.Room])? = nil var error: Error? = nil - OpenGroupAPIV2.rooms(for: "testServer", using: dependencies) + OpenGroupAPI.rooms(for: "testServer", using: dependencies) .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -447,14 +447,14 @@ class OpenGroupAPIV2Tests: XCTestCase { var response: Any? = nil var error: Error? = nil - OpenGroupAPIV2.rooms(for: "testServer", using: dependencies) + OpenGroupAPI.rooms(for: "testServer", using: dependencies) .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPIV2.Error.signingFailed.localizedDescription), + equal(OpenGroupAPI.Error.signingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -467,14 +467,14 @@ class OpenGroupAPIV2Tests: XCTestCase { var response: Any? = nil var error: Error? = nil - OpenGroupAPIV2.rooms(for: "testServer", using: dependencies) + OpenGroupAPI.rooms(for: "testServer", using: dependencies) .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPIV2.Error.signingFailed.localizedDescription), + equal(OpenGroupAPI.Error.signingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -492,14 +492,14 @@ class OpenGroupAPIV2Tests: XCTestCase { var response: Any? = nil var error: Error? = nil - OpenGroupAPIV2.rooms(for: "testServer", using: dependencies) + OpenGroupAPI.rooms(for: "testServer", using: dependencies) .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPIV2.Error.signingFailed.localizedDescription), + equal(OpenGroupAPI.Error.signingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -518,14 +518,14 @@ class OpenGroupAPIV2Tests: XCTestCase { var response: Any? = nil var error: Error? = nil - OpenGroupAPIV2.rooms(for: "testServer", using: dependencies) + OpenGroupAPI.rooms(for: "testServer", using: dependencies) .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPIV2.Error.signingFailed.localizedDescription), + equal(OpenGroupAPI.Error.signingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -551,14 +551,14 @@ class OpenGroupAPIV2Tests: XCTestCase { var response: Any? = nil var error: Error? = nil - OpenGroupAPIV2.rooms(for: "testServer", using: dependencies) + OpenGroupAPI.rooms(for: "testServer", using: dependencies) .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPIV2.Error.signingFailed.localizedDescription), + equal(OpenGroupAPI.Error.signingFailed.localizedDescription), timeout: .milliseconds(100) ) From 8cc9caa0fda30ec788366db2673f99f3df7cb324 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 15 Feb 2022 16:44:10 +1100 Subject: [PATCH 010/157] Renamed the OpenGroupPollerV2 and OpenGroupManagerV2 --- Session.xcodeproj/project.pbxproj | 16 +- .../Views & Modals/JoinOpenGroupModal.swift | 23 +- Session/Home/HomeVC.swift | 2 +- Session/Meta/AppDelegate.m | 4 +- Session/Open Groups/JoinOpenGroupVC.swift | 4 +- Session/Utilities/BackgroundPoller.swift | 74 ++--- .../Open Groups/OpenGroupAPI.swift | 8 +- ...ManagerV2.swift => OpenGroupManager.swift} | 53 ++-- .../MessageReceiver+Handling.swift | 4 +- .../Pollers/OpenGroupPoller.swift | 252 ++++++++++++++++++ .../Pollers/OpenGroupPollerV2.swift | 204 -------------- 11 files changed, 356 insertions(+), 288 deletions(-) rename SessionMessagingKit/Open Groups/{OpenGroupManagerV2.swift => OpenGroupManager.swift} (89%) create mode 100644 SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift delete mode 100644 SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index f1ff64623..b671fabb6 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -752,8 +752,8 @@ C3D9E52725677DF20040E4F3 /* OWSThumbnailService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAF1255A580500E217F9 /* OWSThumbnailService.swift */; }; C3DA9C0725AE7396008F7C7E /* ConfigurationMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DA9C0625AE7396008F7C7E /* ConfigurationMessage.swift */; }; C3DAB3242480CB2B00725F25 /* SRCopyableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DAB3232480CB2A00725F25 /* SRCopyableLabel.swift */; }; - C3DB66AC260ACA42001EFC55 /* OpenGroupManagerV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DB66AB260ACA42001EFC55 /* OpenGroupManagerV2.swift */; }; - C3DB66C3260ACCE6001EFC55 /* OpenGroupPollerV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DB66C2260ACCE6001EFC55 /* OpenGroupPollerV2.swift */; }; + C3DB66AC260ACA42001EFC55 /* OpenGroupManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DB66AB260ACA42001EFC55 /* OpenGroupManager.swift */; }; + C3DB66C3260ACCE6001EFC55 /* OpenGroupPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DB66C2260ACCE6001EFC55 /* OpenGroupPoller.swift */; }; C3DB66CC260AF1F3001EFC55 /* OpenGroupAPI+ObjC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DB66CB260AF1F3001EFC55 /* OpenGroupAPI+ObjC.swift */; }; C3DFFAC623E96F0D0058DAF8 /* Sheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DFFAC523E96F0D0058DAF8 /* Sheet.swift */; }; C3E5C2FA251DBABB0040DFFC /* EditClosedGroupVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E5C2F9251DBABB0040DFFC /* EditClosedGroupVC.swift */; }; @@ -1859,8 +1859,8 @@ C3D9E43025676D3D0040E4F3 /* Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = ""; }; C3DA9C0625AE7396008F7C7E /* ConfigurationMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationMessage.swift; sourceTree = ""; }; C3DAB3232480CB2A00725F25 /* SRCopyableLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SRCopyableLabel.swift; sourceTree = ""; }; - C3DB66AB260ACA42001EFC55 /* OpenGroupManagerV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupManagerV2.swift; sourceTree = ""; }; - C3DB66C2260ACCE6001EFC55 /* OpenGroupPollerV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupPollerV2.swift; sourceTree = ""; }; + C3DB66AB260ACA42001EFC55 /* OpenGroupManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupManager.swift; sourceTree = ""; }; + C3DB66C2260ACCE6001EFC55 /* OpenGroupPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupPoller.swift; sourceTree = ""; }; C3DB66CB260AF1F3001EFC55 /* OpenGroupAPI+ObjC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OpenGroupAPI+ObjC.swift"; sourceTree = ""; }; C3DFFAC523E96F0D0058DAF8 /* Sheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sheet.swift; sourceTree = ""; }; C3E5C2F9251DBABB0040DFFC /* EditClosedGroupVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditClosedGroupVC.swift; sourceTree = ""; }; @@ -2748,7 +2748,7 @@ isa = PBXGroup; children = ( C33FDB34255A580B00E217F9 /* ClosedGroupPoller.swift */, - C3DB66C2260ACCE6001EFC55 /* OpenGroupPollerV2.swift */, + C3DB66C2260ACCE6001EFC55 /* OpenGroupPoller.swift */, C33FDB3A255A580B00E217F9 /* Poller.swift */, ); path = Pollers; @@ -3357,7 +3357,7 @@ FDC4380727B31D3A00C60D73 /* Types */, B88FA7B726045D100049422F /* OpenGroupAPI.swift */, C3DB66CB260AF1F3001EFC55 /* OpenGroupAPI+ObjC.swift */, - C3DB66AB260ACA42001EFC55 /* OpenGroupManagerV2.swift */, + C3DB66AB260ACA42001EFC55 /* OpenGroupManager.swift */, ); path = "Open Groups"; sourceTree = ""; @@ -5131,7 +5131,7 @@ C3D9E52725677DF20040E4F3 /* OWSThumbnailService.swift in Sources */, C32C5E75256DE020003C73A2 /* YapDatabaseTransaction+OWS.m in Sources */, C3BBE0802554CDD70050F1E3 /* Storage.swift in Sources */, - C3DB66AC260ACA42001EFC55 /* OpenGroupManagerV2.swift in Sources */, + C3DB66AC260ACA42001EFC55 /* OpenGroupManager.swift in Sources */, B8F5F61B25EDE4BF003BF8D4 /* DataExtractionNotificationInfoMessage.swift in Sources */, FDC438A427BB107F00C60D73 /* UserBanRequest.swift in Sources */, C379DCF4256735770002D4EB /* VisibleMessage+Attachment.swift in Sources */, @@ -5230,7 +5230,7 @@ C352A2FF25574B6300338F3E /* MessageSendJob.swift in Sources */, FDC438C327BB512200C60D73 /* SodiumProtocols.swift in Sources */, B8856D11256F112A001CE70E /* OWSAudioSession.swift in Sources */, - C3DB66C3260ACCE6001EFC55 /* OpenGroupPollerV2.swift in Sources */, + C3DB66C3260ACCE6001EFC55 /* OpenGroupPoller.swift in Sources */, FDC438AE27BB148700C60D73 /* UserDeleteMessagesResponse.swift in Sources */, C3C2A75F2553A3C500C340D1 /* VisibleMessage+LinkPreview.swift in Sources */, C32C5FBB256E0206003C73A2 /* OWSBackgroundTask.m in Sources */, diff --git a/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift b/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift index d6a47a02b..6b51dc2e6 100644 --- a/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift +++ b/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift @@ -63,23 +63,24 @@ final class JoinOpenGroupModal : Modal { // MARK: Interaction @objc private func joinOpenGroup() { - guard let (room, server, publicKey) = OpenGroupManagerV2.parseV2OpenGroup(from: url) else { + guard let (room, server, publicKey) = OpenGroupManager.parseV2OpenGroup(from: url) else { let alert = UIAlertController(title: "Couldn't Join", message: nil, preferredStyle: .alert) alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil)) return presentingViewController!.present(alert, animated: true, completion: nil) } presentingViewController!.dismiss(animated: true, completion: nil) Storage.shared.write { [presentingViewController = self.presentingViewController!] transaction in - OpenGroupManagerV2.shared.add(room: room, server: server, publicKey: publicKey, using: transaction) - .done(on: DispatchQueue.main) { _ in - let appDelegate = UIApplication.shared.delegate as! AppDelegate - appDelegate.forceSyncConfigurationNowIfNeeded().retainUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...) - } - .catch(on: DispatchQueue.main) { error in - let alert = UIAlertController(title: "Couldn't Join", message: error.localizedDescription, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil)) - presentingViewController.present(alert, animated: true, completion: nil) - } + OpenGroupManager.shared + .add(room: room, server: server, publicKey: publicKey, using: transaction) + .done(on: DispatchQueue.main) { _ in + let appDelegate = UIApplication.shared.delegate as! AppDelegate + appDelegate.forceSyncConfigurationNowIfNeeded().retainUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...) + } + .catch(on: DispatchQueue.main) { error in + let alert = UIAlertController(title: "Couldn't Join", message: error.localizedDescription, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil)) + presentingViewController.present(alert, animated: true, completion: nil) + } } } } diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index d0a15cae3..15db7dccc 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -525,7 +525,7 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv Storage.write { transaction in Storage.shared.cancelPendingMessageSendJobs(for: thread.uniqueId!, using: transaction) if let openGroupV2 = openGroupV2 { - OpenGroupManagerV2.shared.delete(openGroupV2, associatedWith: thread, using: transaction) + OpenGroupManager.shared.delete(openGroupV2, associatedWith: thread, using: transaction) } else if let thread = thread as? TSGroupThread, thread.isClosedGroup == true { let groupID = thread.groupModel.groupId let groupPublicKey = LKGroupUtilities.getDecodedGroupID(groupID) diff --git a/Session/Meta/AppDelegate.m b/Session/Meta/AppDelegate.m index 0bb1a4964..124557489 100644 --- a/Session/Meta/AppDelegate.m +++ b/Session/Meta/AppDelegate.m @@ -697,11 +697,11 @@ static NSTimeInterval launchStartedAt; - (void)startOpenGroupPollersIfNeeded { - [SNOpenGroupManagerV2.shared startPolling]; + [SNOpenGroupManager.shared startPolling]; } - (void)stopOpenGroupPollers { - [SNOpenGroupManagerV2.shared stopPolling]; + [SNOpenGroupManager.shared stopPolling]; } # pragma mark - App Mode diff --git a/Session/Open Groups/JoinOpenGroupVC.swift b/Session/Open Groups/JoinOpenGroupVC.swift index fcd9b0a71..cf4597575 100644 --- a/Session/Open Groups/JoinOpenGroupVC.swift +++ b/Session/Open Groups/JoinOpenGroupVC.swift @@ -127,7 +127,7 @@ final class JoinOpenGroupVC : BaseVC, UIPageViewControllerDataSource, UIPageView fileprivate func joinOpenGroup(with string: String) { // A V2 open group URL will look like: + + + + // The host doesn't parse if no explicit scheme is provided - if let (room, server, publicKey) = OpenGroupManagerV2.parseV2OpenGroup(from: string) { + if let (room, server, publicKey) = OpenGroupManager.parseV2OpenGroup(from: string) { joinV2OpenGroup(room: room, server: server, publicKey: publicKey) } else { let title = NSLocalizedString("invalid_url", comment: "") @@ -141,7 +141,7 @@ final class JoinOpenGroupVC : BaseVC, UIPageViewControllerDataSource, UIPageView isJoining = true ModalActivityIndicatorViewController.present(fromViewController: navigationController!, canCancel: false) { [weak self] _ in Storage.shared.write { transaction in - OpenGroupManagerV2.shared.add(room: room, server: server, publicKey: publicKey, using: transaction) + OpenGroupManager.shared.add(room: room, server: server, publicKey: publicKey, using: transaction) .done(on: DispatchQueue.main) { [weak self] _ in self?.presentingViewController?.dismiss(animated: true, completion: nil) let appDelegate = UIApplication.shared.delegate as! AppDelegate diff --git a/Session/Utilities/BackgroundPoller.swift b/Session/Utilities/BackgroundPoller.swift index 0d67c90fe..7fb5524c6 100644 --- a/Session/Utilities/BackgroundPoller.swift +++ b/Session/Utilities/BackgroundPoller.swift @@ -2,7 +2,7 @@ import PromiseKit import SessionSnodeKit @objc(LKBackgroundPoller) -public final class BackgroundPoller : NSObject { +public final class BackgroundPoller: NSObject { private static var closedGroupPoller: ClosedGroupPoller! private static var promises: [Promise] = [] @@ -11,20 +11,26 @@ public final class BackgroundPoller : NSObject { @objc(pollWithCompletionHandler:) public static func poll(completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { promises = [] - promises.append(pollForMessages()) - promises.append(contentsOf: pollForClosedGroupMessages()) - let v2OpenGroupServers = Set(Storage.shared.getAllV2OpenGroups().values.map { $0.server }) - v2OpenGroupServers.forEach { server in - let poller = OpenGroupPollerV2(for: server) - poller.stop() - promises.append(poller.poll(isBackgroundPoll: true)) - } - when(resolved: promises).done { _ in - completionHandler(.newData) - }.catch { error in - SNLog("Background poll failed due to error: \(error)") - completionHandler(.failed) - } + .appending(pollForMessages()) + .appending(pollForClosedGroupMessages()) + .appending( + Set(Storage.shared.getAllV2OpenGroups().values.map { $0.server }) + .map { server in + let poller = OpenGroupAPI.Poller(for: server) + poller.stop() + + return poller.poll(isBackgroundPoll: true) + } + ) + + when(resolved: promises) + .done { _ in + completionHandler(.newData) + } + .catch { error in + SNLog("Background poll failed due to error: \(error)") + completionHandler(.failed) + } } private static func pollForMessages() -> Promise { @@ -38,22 +44,30 @@ public final class BackgroundPoller : NSObject { } private static func getMessages(for publicKey: String) -> Promise { - return SnodeAPI.getSwarm(for: publicKey).then(on: DispatchQueue.main) { swarm -> Promise in - guard let snode = swarm.randomElement() else { throw SnodeAPI.Error.generic } - return attempt(maxRetryCount: 4, recoveringOn: DispatchQueue.main) { - return SnodeAPI.getRawMessages(from: snode, associatedWith: publicKey).then(on: DispatchQueue.main) { rawResponse -> Promise in - let messages = SnodeAPI.parseRawMessagesResponse(rawResponse, from: snode, associatedWith: publicKey) - let promises = messages.compactMap { json -> Promise? in - // Use a best attempt approach here; we don't want to fail the entire process if one of the - // messages failed to parse. - guard let envelope = SNProtoEnvelope.from(json), - let data = try? envelope.serializedData() else { return nil } - let job = MessageReceiveJob(data: data, serverHash: json["hash"] as? String, isBackgroundPoll: true) - return job.execute() - } - return when(fulfilled: promises) // The promise returned by MessageReceiveJob never rejects + return SnodeAPI.getSwarm(for: publicKey) + .then(on: DispatchQueue.main) { swarm -> Promise in + guard let snode = swarm.randomElement() else { throw SnodeAPI.Error.generic } + + return attempt(maxRetryCount: 4, recoveringOn: DispatchQueue.main) { + return SnodeAPI.getRawMessages(from: snode, associatedWith: publicKey) + .then(on: DispatchQueue.main) { rawResponse -> Promise in + let messages = SnodeAPI.parseRawMessagesResponse(rawResponse, from: snode, associatedWith: publicKey) + let promises = messages + .compactMap { json -> Promise? in + // Use a best attempt approach here; we don't want to fail + // the entire process if one of the messages failed to parse. + guard let envelope = SNProtoEnvelope.from(json), let data = try? envelope.serializedData() else { + return nil + } + + let job = MessageReceiveJob(data: data, serverHash: json["hash"] as? String, isBackgroundPoll: true) + + return job.execute() + } + + return when(fulfilled: promises) // The promise returned by MessageReceiveJob never rejects + } } } - } } } diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index 0f909e71a..ecf87d375 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -87,7 +87,7 @@ public final class OpenGroupAPI: NSObject { } ) - // TODO: Handle response (maybe in the poller or the OpenGroupManagerV2?). + // TODO: Handle response (maybe in the poller or the OpenGroupManager?) return batch(server, requests: requestResponseType, using: dependencies) } @@ -127,7 +127,7 @@ public final class OpenGroupAPI: NSObject { let rooms: [String] = dependencies.storage.getAllV2OpenGroups().values .filter { $0.server == server } .map { $0.room } - let useMessageLimit = (hasPerformedInitialPoll[server] != true && timeSinceLastOpen > OpenGroupPollerV2.maxInactivityPeriod) + let useMessageLimit = (hasPerformedInitialPoll[server] != true && timeSinceLastOpen > OpenGroupAPI.Poller.maxInactivityPeriod) hasPerformedInitialPoll[server] = true @@ -666,7 +666,7 @@ public final class OpenGroupAPI: NSObject { // MARK: - General - // TODO: Shift this to the OpenGroupManagerV2? (seems more at place there than in the API). + // TODO: Shift this to the OpenGroupManager? (seems more at place there than in the API) public static func getDefaultRoomsIfNeeded(using dependencies: Dependencies = Dependencies()) { Storage.shared.write( with: { transaction in @@ -922,7 +922,7 @@ public final class OpenGroupAPI: NSObject { .filter { $0.server == server } .map { $0.room } var getAuthTokenPromises: [String: Promise] = [:] - let useMessageLimit = (hasPerformedInitialPoll[server] != true && timeSinceLastOpen > OpenGroupPollerV2.maxInactivityPeriod) + let useMessageLimit = (hasPerformedInitialPoll[server] != true && timeSinceLastOpen > OpenGroupAPI.Poller.maxInactivityPeriod) hasPerformedInitialPoll[server] = true diff --git a/SessionMessagingKit/Open Groups/OpenGroupManagerV2.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift similarity index 89% rename from SessionMessagingKit/Open Groups/OpenGroupManagerV2.swift rename to SessionMessagingKit/Open Groups/OpenGroupManager.swift index 3f6e42bbd..fee3e6b14 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManagerV2.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -1,26 +1,26 @@ import PromiseKit -@objc(SNOpenGroupManagerV2) -public final class OpenGroupManagerV2 : NSObject { - private var pollers: [String:OpenGroupPollerV2] = [:] // One for each server +@objc(SNOpenGroupManager) +public final class OpenGroupManager: NSObject { + @objc public static let shared = OpenGroupManager() + + private var pollers: [String: OpenGroupAPI.Poller] = [:] // One for each server private var isPolling = false - // MARK: Initialization - @objc public static let shared = OpenGroupManagerV2() - - private override init() { } - - // MARK: Polling + // MARK: - Polling @objc public func startPolling() { guard !isPolling else { return } + isPolling = true - let servers = Set(Storage.shared.getAllV2OpenGroups().values.map { $0.server }) - servers.forEach { server in - if let poller = pollers[server] { poller.stop() } // Should never occur - let poller = OpenGroupPollerV2(for: server) - poller.startIfNeeded() - pollers[server] = poller - } + pollers = Set(Storage.shared.getAllV2OpenGroups().values.map { $0.server }) + .reduce(into: [:]) { prev, server in + pollers[server]?.stop() // Should never occur + + let poller = OpenGroupAPI.Poller(for: server) + poller.startIfNeeded() + + prev[server] = poller + } } @objc public func stopPolling() { @@ -28,17 +28,22 @@ public final class OpenGroupManagerV2 : NSObject { pollers.removeAll() } - // MARK: Adding & Removing + // MARK: - Adding & Removing + public func add(room: String, server: String, publicKey: String, using transaction: Any) -> Promise { let storage = Storage.shared + // Clear any existing data if needed storage.removeLastMessageServerID(for: room, on: server, using: transaction) storage.removeLastDeletionServerID(for: room, on: server, using: transaction) storage.removeAuthToken(for: room, on: server, using: transaction) + // Store the public key storage.setOpenGroupPublicKey(for: server, to: publicKey, using: transaction) + let (promise, seal) = Promise.pending() let transaction = transaction as! YapDatabaseReadWriteTransaction + transaction.addCompletionQueue(DispatchQueue.global(qos: .userInitiated)) { // Get the group info // TODO: Remove this legacy method @@ -56,10 +61,10 @@ public final class OpenGroupManagerV2 : NSObject { // storage.setV2OpenGroup(openGroup, for: thread.uniqueId!, using: transaction) // }, completion: { // // Start the poller if needed -// if OpenGroupManagerV2.shared.pollers[server] == nil { +// if OpenGroupManager.shared.pollers[server] == nil { // let poller = OpenGroupPollerV2(for: server) // poller.startIfNeeded() -// OpenGroupManagerV2.shared.pollers[server] = poller +// OpenGroupManager.shared.pollers[server] = poller // } // // Fetch the group image // OpenGroupAPI.legacyGetGroupImage(for: room, on: server).done(on: DispatchQueue.global(qos: .userInitiated)) { data in @@ -110,15 +115,15 @@ public final class OpenGroupManagerV2 : NSObject { }, completion: { // Start the poller if needed - if OpenGroupManagerV2.shared.pollers[server] == nil { - let poller = OpenGroupPollerV2(for: server) + if OpenGroupManager.shared.pollers[server] == nil { + let poller = OpenGroupAPI.Poller(for: server) poller.startIfNeeded() - OpenGroupManagerV2.shared.pollers[server] = poller + OpenGroupManager.shared.pollers[server] = poller } // Fetch the group image (if there is one) - // TODO: Need to test this - // TODO: Clean this up (can we avoid the if/else with fancy promise wrangling?) + // TODO: Need to test this. + // TODO: Clean this up (can we avoid the if/else with fancy promise wrangling?). if let imageId: Int64 = room.imageId { OpenGroupAPI.roomImage(imageId, for: room.token, on: server) .done(on: DispatchQueue.global(qos: .userInitiated)) { data in diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index 67e309d1f..eedaa0e84 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -233,8 +233,8 @@ extension MessageReceiver { } // Open groups for openGroupURL in message.openGroups { - if let (room, server, publicKey) = OpenGroupManagerV2.parseV2OpenGroup(from: openGroupURL) { - OpenGroupManagerV2.shared.add(room: room, server: server, publicKey: publicKey, using: transaction).retainUntilComplete() + if let (room, server, publicKey) = OpenGroupManager.parseV2OpenGroup(from: openGroupURL) { + OpenGroupManager.shared.add(room: room, server: server, publicKey: publicKey, using: transaction).retainUntilComplete() } } } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift new file mode 100644 index 000000000..38097ce7c --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift @@ -0,0 +1,252 @@ +import PromiseKit +import SessionSnodeKit + +extension OpenGroupAPI { + public final class Poller { + private let server: String + private var timer: Timer? = nil + private var hasStarted = false + private var isPolling = false + + // MARK: - Settings + + internal static let maxInactivityPeriod: Double = (14 * 24 * 60 * 60) + private static let pollInterval: TimeInterval = 4 + + // MARK: - Lifecycle + + public init(for server: String) { + self.server = server + } + + @objc public func startIfNeeded() { + guard !hasStarted else { return } + + DispatchQueue.main.async { [weak self] in // Timers don't do well on background queues + self?.hasStarted = true + self?.timer = Timer.scheduledTimer(withTimeInterval: Poller.pollInterval, repeats: true) { _ in + self?.poll().retainUntilComplete() + } + self?.poll().retainUntilComplete() + } + } + + @objc public func stop() { + timer?.invalidate() + hasStarted = false + } + + // MARK: - Polling + + @discardableResult + public func poll() -> Promise { + return poll(isBackgroundPoll: false) + } + + @discardableResult + public func poll(isBackgroundPoll: Bool) -> Promise { + guard !self.isPolling else { return Promise.value(()) } + + self.isPolling = true + let (promise, seal) = Promise.pending() + promise.retainUntilComplete() + + OpenGroupAPI.poll(server) + .done(on: OpenGroupAPI.workQueue) { [weak self] response in + self?.isPolling = false + self?.handlePollResponse(response, isBackgroundPoll: isBackgroundPoll) + seal.fulfill(()) + } + .catch(on: OpenGroupAPI.workQueue) { [weak self] error in + SNLog("Open group polling failed due to error: \(error).") + self?.isPolling = false + seal.fulfill(()) // The promise is just used to keep track of when we're done + } + // OpenGroupAPI.compactPoll(server) + // OpenGroupAPI.legacyCompactPoll(server) + // .done(on: OpenGroupAPI.workQueue) { [weak self] response in + // guard let self = self else { return } + // self.isPolling = false + // response.results.forEach { self.handleCompactPollBody($0, isBackgroundPoll: isBackgroundPoll) } + // seal.fulfill(()) + // } + // .catch(on: OpenGroupAPI.workQueue) { error in + // SNLog("Open group polling failed due to error: \(error).") + // self.isPolling = false + // seal.fulfill(()) // The promise is just used to keep track of when we're done + // } + + return promise + } + + private func handlePollResponse(_ response: [OpenGroupAPI.Endpoint: (info: OnionRequestResponseInfoType, data: Codable)], isBackgroundPoll: Bool) { + let storage = SNMessagingKitConfiguration.shared.storage + + response.forEach { endpoint, response in + switch endpoint { + case .roomMessagesRecent(let roomToken), .roomMessagesBefore(let roomToken, _), .roomMessagesSince(let roomToken, _): + guard let responseData: [OpenGroupAPI.Message] = response.data as? [OpenGroupAPI.Message] else { + //SNLog("Open group polling failed due to error: \(error).") + return // TODO: Throw error? + } + + handleMessages(responseData, roomToken: roomToken, isBackgroundPoll: isBackgroundPoll, using: storage) + + case .roomPollInfo(let roomToken, _): + guard let responseData: OpenGroupAPI.RoomPollInfo = response.data as? OpenGroupAPI.RoomPollInfo else { + //SNLog("Open group polling failed due to error: \(error).") + return // TODO: Throw error? + } + + handlePollInfo(responseData, roomToken: roomToken, isBackgroundPoll: isBackgroundPoll, using: storage) + + default: break // No custom handling needed + } + } + } + + // MARK: - Custom response handling + // TODO: Shift this logic to the OpenGroupManager? (seems like the place it should belong?) + + private func handleMessages(_ messages: [OpenGroupAPI.Message], roomToken: String, isBackgroundPoll: Bool, using storage: SessionMessagingKitStorageProtocol) { + // Sorting the messages by server ID before importing them fixes an issue where messages that quote older messages can't find those older messages + let openGroupID = "\(server).\(roomToken)" + let sortedMessages: [OpenGroupAPI.Message] = messages + .sorted { lhs, rhs in lhs.seqNo < rhs.seqNo } + + storage.write { transaction in + var messageServerIDsToRemove: [UInt64] = [] + + sortedMessages.forEach { message in + guard let base64EncodedString: String = message.base64EncodedData, let data = Data(base64Encoded: base64EncodedString), let sender: String = message.sender else { + // A message with no data has been deleted so add it to the list to remove + messageServerIDsToRemove.append(UInt64(message.seqNo)) + return + } + + let envelope = SNProtoEnvelope.builder(type: .sessionMessage, timestamp: UInt64(floor(message.posted))) + envelope.setContent(data) + envelope.setSource(sender) + + do { + let data = try envelope.buildSerializedData() + let (message, proto) = try MessageReceiver.parse(data, openGroupMessageServerID: UInt64(message.seqNo), isRetry: false, using: transaction) + try MessageReceiver.handle(message, associatedWithProto: proto, openGroupID: openGroupID, isBackgroundPoll: isBackgroundPoll, using: transaction) + } + catch { + SNLog("Couldn't receive open group message due to error: \(error).") + } + } + + // Handle any deletions that are needed + guard !messageServerIDsToRemove.isEmpty else { return } + guard let transaction: YapDatabaseReadWriteTransaction = transaction as? YapDatabaseReadWriteTransaction else { return } + guard let threadID = storage.v2GetThreadID(for: openGroupID), let thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction) else { + return + } + + var messagesToRemove: [TSMessage] = [] + + thread.enumerateInteractions(with: transaction) { interaction, stop in + guard let message: TSMessage = interaction as? TSMessage, messageServerIDsToRemove.contains(message.openGroupServerMessageID) else { return } + messagesToRemove.append(message) + } + + messagesToRemove.forEach { $0.remove(with: transaction) } + } + } + + private func handlePollInfo(_ pollInfo: OpenGroupAPI.RoomPollInfo, roomToken: String, isBackgroundPoll: Bool, using storage: SessionMessagingKitStorageProtocol) { + // TODO: Handle other properties???. + + // public let token: String? + // public let created: TimeInterval? + // public let name: String? + // public let description: String? + // public let imageId: Int64? + // + // public let infoUpdates: Int64? + // public let messageSequence: Int64? + // public let activeUsers: Int64? + // public let activeUsersCutoff: Int64? + // public let pinnedMessages: [PinnedMessage]? + // + // public let admin: Bool? + // public let globalAdmin: Bool? + // public let admins: [String]? + // public let hiddenAdmins: [String]? + // + // public let moderator: Bool? + // public let globalModerator: Bool? + // public let moderators: [String]? + // public let hiddenModerators: [String]? + + // - Moderators + OpenGroupAPI.moderators[server] = (OpenGroupAPI.moderators[server] ?? [:]) + .setting(roomToken, Set(pollInfo.moderators ?? [])) + + // public let read: Bool? + // public let defaultRead: Bool? + // public let write: Bool? + // public let defaultWrite: Bool? + // public let upload: Bool? + // public let defaultUpload: Bool? + // + // /// Only populated and different if the `info_updates` counter differs from the provided `info_updated` value + // public let details: Room? + } + + // MARK: - Legacy Handling + + private func handleCompactPollBody(_ body: OpenGroupAPI.LegacyCompactPollResponse.Result, isBackgroundPoll: Bool) { + let storage = SNMessagingKitConfiguration.shared.storage + // - Messages + // Sorting the messages by server ID before importing them fixes an issue where messages that quote older messages can't find those older messages + let openGroupID = "\(server).\(body.room)" + let messages = (body.messages ?? []).sorted { ($0.serverID ?? 0) < ($1.serverID ?? 0) } + + storage.write { transaction in + messages.forEach { message in + guard let data = Data(base64Encoded: message.base64EncodedData) else { + return SNLog("Ignoring open group message with invalid encoding.") + } + let envelope = SNProtoEnvelope.builder(type: .sessionMessage, timestamp: message.sentTimestamp) + envelope.setContent(data) + envelope.setSource(message.sender!) // Safe because messages with a nil sender are filtered out + do { + let data = try envelope.buildSerializedData() + let (message, proto) = try MessageReceiver.parse(data, openGroupMessageServerID: UInt64(message.serverID!), isRetry: false, using: transaction) + try MessageReceiver.handle(message, associatedWithProto: proto, openGroupID: openGroupID, isBackgroundPoll: isBackgroundPoll, using: transaction) + } catch { + SNLog("Couldn't receive open group message due to error: \(error).") + } + } + } + + // - Moderators + if var x = OpenGroupAPI.moderators[server] { + x[body.room] = Set(body.moderators ?? []) + OpenGroupAPI.moderators[server] = x + } + else { + OpenGroupAPI.moderators[server] = [ body.room : Set(body.moderators ?? []) ] + } + + // - Deletions + let deletedMessageServerIDs = Set((body.deletions ?? []).map { UInt64($0.deletedMessageID) }) + storage.write { transaction in + let transaction = transaction as! YapDatabaseReadWriteTransaction + guard let threadID = storage.v2GetThreadID(for: openGroupID), + let thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction) else { return } + var messagesToRemove: [TSMessage] = [] + + thread.enumerateInteractions(with: transaction) { interaction, stop in + guard let message = interaction as? TSMessage, deletedMessageServerIDs.contains(message.openGroupServerMessageID) else { return } + messagesToRemove.append(message) + } + + messagesToRemove.forEach { $0.remove(with: transaction) } + } + } + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift deleted file mode 100644 index 3497c884a..000000000 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift +++ /dev/null @@ -1,204 +0,0 @@ -import PromiseKit -import SessionSnodeKit - -@objc(SNOpenGroupPollerV2) -public final class OpenGroupPollerV2 : NSObject { - private let server: String - private var timer: Timer? = nil - private var hasStarted = false - private var isPolling = false - - // MARK: Settings - private let pollInterval: TimeInterval = 4 - static let maxInactivityPeriod: Double = 14 * 24 * 60 * 60 - - // MARK: Lifecycle - public init(for server: String) { - self.server = server - super.init() - } - - @objc public func startIfNeeded() { - guard !hasStarted else { return } - DispatchQueue.main.async { [weak self] in // Timers don't do well on background queues - guard let strongSelf = self else { return } - strongSelf.hasStarted = true - strongSelf.timer = Timer.scheduledTimer(withTimeInterval: strongSelf.pollInterval, repeats: true) { _ in - self?.poll().retainUntilComplete() - } - strongSelf.poll().retainUntilComplete() - } - } - - @objc public func stop() { - timer?.invalidate() - hasStarted = false - } - - // MARK: Polling - @discardableResult - public func poll() -> Promise { - return poll(isBackgroundPoll: false) - } - - @discardableResult - public func poll(isBackgroundPoll: Bool) -> Promise { - guard !self.isPolling else { return Promise.value(()) } - self.isPolling = true - let (promise, seal) = Promise.pending() - promise.retainUntilComplete() - - OpenGroupAPI.poll(server) - .done(on: OpenGroupAPI.workQueue) { [weak self] response in - self?.isPolling = false - self?.handlePollResponse(response, isBackgroundPoll: isBackgroundPoll) - seal.fulfill(()) - } - .catch(on: OpenGroupAPI.workQueue) { [weak self] error in - SNLog("Open group polling failed due to error: \(error).") - self?.isPolling = false - seal.fulfill(()) // The promise is just used to keep track of when we're done - } - - return promise - } - - private func handlePollResponse(_ response: [OpenGroupAPI.Endpoint: (info: OnionRequestResponseInfoType, data: Codable)], isBackgroundPoll: Bool) { - let storage = SNMessagingKitConfiguration.shared.storage - - response.forEach { endpoint, response in - switch endpoint { - case .roomMessagesRecent(let roomToken), .roomMessagesBefore(let roomToken, _), .roomMessagesSince(let roomToken, _): - guard let responseData: [OpenGroupAPI.Message] = response.data as? [OpenGroupAPI.Message] else { - //SNLog("Open group polling failed due to error: \(error).") - return // TODO: Throw error? - } - - handleMessages(responseData, roomToken: roomToken, isBackgroundPoll: isBackgroundPoll, using: storage) - - case .roomPollInfo(let roomToken, _): - guard let responseData: OpenGroupAPI.RoomPollInfo = response.data as? OpenGroupAPI.RoomPollInfo else { - //SNLog("Open group polling failed due to error: \(error).") - return // TODO: Throw error? - } - - handlePollInfo(responseData, roomToken: roomToken, isBackgroundPoll: isBackgroundPoll, using: storage) - - default: break // No custom handling needed - } - } - } - - // MARK: - Custom response handling - // TODO: Shift this logic to the OpenGroupManagerV2? (seems like the place it should belong?) - - private func handleMessages(_ messages: [OpenGroupAPI.Message], roomToken: String, isBackgroundPoll: Bool, using storage: SessionMessagingKitStorageProtocol) { - // Sorting the messages by server ID before importing them fixes an issue where messages that quote older messages can't find those older messages - let openGroupID = "\(server).\(roomToken)" - let sortedMessages: [OpenGroupAPI.Message] = messages - .sorted { lhs, rhs in lhs.seqNo < rhs.seqNo } - - storage.write { transaction in - var messageServerIDsToRemove: [UInt64] = [] - - sortedMessages.forEach { message in - guard let base64EncodedString: String = message.base64EncodedData, let data = Data(base64Encoded: base64EncodedString), let sender: String = message.sender else { - // A message with no data has been deleted so add it to the list to remove - messageServerIDsToRemove.append(UInt64(message.seqNo)) - return - } - - let envelope = SNProtoEnvelope.builder(type: .sessionMessage, timestamp: UInt64(floor(message.posted))) - envelope.setContent(data) - envelope.setSource(sender) - - do { - let data = try envelope.buildSerializedData() - let (message, proto) = try MessageReceiver.parse(data, openGroupMessageServerID: UInt64(message.seqNo), isRetry: false, using: transaction) - try MessageReceiver.handle(message, associatedWithProto: proto, openGroupID: openGroupID, isBackgroundPoll: isBackgroundPoll, using: transaction) - } - catch { - SNLog("Couldn't receive open group message due to error: \(error).") - } - } - - // Handle any deletions that are needed - guard !messageServerIDsToRemove.isEmpty else { return } - guard let transaction: YapDatabaseReadWriteTransaction = transaction as? YapDatabaseReadWriteTransaction else { return } - guard let threadID = storage.v2GetThreadID(for: openGroupID), let thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction) else { - return - } - - var messagesToRemove: [TSMessage] = [] - - thread.enumerateInteractions(with: transaction) { interaction, stop in - guard let message: TSMessage = interaction as? TSMessage, messageServerIDsToRemove.contains(message.openGroupServerMessageID) else { return } - messagesToRemove.append(message) - } - - messagesToRemove.forEach { $0.remove(with: transaction) } - } - } - - private func handlePollInfo(_ pollInfo: OpenGroupAPI.RoomPollInfo, roomToken: String, isBackgroundPoll: Bool, using storage: SessionMessagingKitStorageProtocol) { - // TODO: Handle other properties??? - - // - Moderators - OpenGroupAPI.moderators[server] = (OpenGroupAPI.moderators[server] ?? [:]) - .setting(roomToken, Set(pollInfo.moderators ?? [])) - - } - - // MARK: - Legacy Handling - - private func handleCompactPollBody(_ body: OpenGroupAPI.LegacyCompactPollResponse.Result, isBackgroundPoll: Bool) { - let storage = SNMessagingKitConfiguration.shared.storage - // - Messages - // Sorting the messages by server ID before importing them fixes an issue where messages that quote older messages can't find those older messages - let openGroupID = "\(server).\(body.room)" - let messages = (body.messages ?? []).sorted { ($0.serverID ?? 0) < ($1.serverID ?? 0) } - - storage.write { transaction in - messages.forEach { message in - guard let data = Data(base64Encoded: message.base64EncodedData) else { - return SNLog("Ignoring open group message with invalid encoding.") - } - let envelope = SNProtoEnvelope.builder(type: .sessionMessage, timestamp: message.sentTimestamp) - envelope.setContent(data) - envelope.setSource(message.sender!) // Safe because messages with a nil sender are filtered out - do { - let data = try envelope.buildSerializedData() - let (message, proto) = try MessageReceiver.parse(data, openGroupMessageServerID: UInt64(message.serverID!), isRetry: false, using: transaction) - try MessageReceiver.handle(message, associatedWithProto: proto, openGroupID: openGroupID, isBackgroundPoll: isBackgroundPoll, using: transaction) - } catch { - SNLog("Couldn't receive open group message due to error: \(error).") - } - } - } - - // - Moderators - if var x = OpenGroupAPI.moderators[server] { - x[body.room] = Set(body.moderators ?? []) - OpenGroupAPI.moderators[server] = x - } - else { - OpenGroupAPI.moderators[server] = [ body.room : Set(body.moderators ?? []) ] - } - - // - Deletions - let deletedMessageServerIDs = Set((body.deletions ?? []).map { UInt64($0.deletedMessageID) }) - storage.write { transaction in - let transaction = transaction as! YapDatabaseReadWriteTransaction - guard let threadID = storage.v2GetThreadID(for: openGroupID), - let thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction) else { return } - var messagesToRemove: [TSMessage] = [] - - thread.enumerateInteractions(with: transaction) { interaction, stop in - guard let message = interaction as? TSMessage, deletedMessageServerIDs.contains(message.openGroupServerMessageID) else { return } - messagesToRemove.append(message) - } - - messagesToRemove.forEach { $0.remove(with: transaction) } - } - } -} From b655882cbde77401eea04ce09322ac8e4c61c618 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 16 Feb 2022 10:31:08 +1100 Subject: [PATCH 011/157] Started resolving TODOs Added some new properties to the OpenGroupV2 Moved a number of methods and variables from OpenGroupAPI to OpenGroupManager (anything doing actual logic) Moved the message signing into the OpenGroupAPI (since that's the only place it happens) Renamed remaining old model classes to start with 'Legacy' to make clean up easier Updated the OpenGroupAPI poll method to use the same logic as it previously did to determine if it should retrieve recent messages or messages since the last one --- Session.xcodeproj/project.pbxproj | 64 +-- .../xcshareddata/xcschemes/Session.xcscheme | 62 ++- Session/Conversations/ConversationViewItem.m | 8 +- .../Input View/MentionSelectionView.swift | 2 +- .../Message Cells/VisibleMessageCell.swift | 2 +- .../Views & Modals/JoinOpenGroupModal.swift | 3 +- Session/Open Groups/JoinOpenGroupVC.swift | 49 +- .../Open Groups/OpenGroupSuggestionGrid.swift | 57 ++- .../Database/Storage+OpenGroups.swift | 6 + ...se.swift => LegacyAuthTokenResponse.swift} | 6 +- .../Models/LegacyCompactPollResponse.swift | 4 +- ...ft => LegacyDeletedMessagesResponse.swift} | 4 +- .../{Deletion.swift => LegacyDeletion.swift} | 6 +- ....swift => LegacyMemberCountResponse.swift} | 2 +- ...e.swift => LegacyModeratorsResponse.swift} | 2 +- ...2.swift => LegacyOpenGroupMessageV2.swift} | 10 +- ...eyBody.swift => LegacyPublicKeyBody.swift} | 2 +- .../Open Groups/Models/RoomPollInfo.swift | 34 ++ .../Models/SendMessageRequest.swift | 18 - .../Models/UpdateMessageRequest.swift | 19 + .../Open Groups/OpenGroupAPI+ObjC.swift | 5 - .../Open Groups/OpenGroupAPI.swift | 438 +++++++----------- .../Open Groups/OpenGroupManager.swift | 401 +++++++++++----- .../Open Groups/Types/Endpoint.swift | 2 +- .../MessageReceiver+Handling.swift | 2 +- .../Pollers/OpenGroupPoller.swift | 116 +---- SessionMessagingKit/Storage.swift | 4 - .../Open Groups/OpenGroupAPIV2Tests.swift | 14 +- .../_TestUtilities/TestStorage.swift | 24 +- .../General/Set+Utilities.swift | 12 + 30 files changed, 771 insertions(+), 607 deletions(-) rename SessionMessagingKit/Open Groups/Models/{AuthTokenResponse.swift => LegacyAuthTokenResponse.swift} (90%) rename SessionMessagingKit/Open Groups/Models/{DeletedMessagesResponse.swift => LegacyDeletedMessagesResponse.swift} (69%) rename SessionMessagingKit/Open Groups/Models/{Deletion.swift => LegacyDeletion.swift} (72%) rename SessionMessagingKit/Open Groups/Models/{MemberCountResponse.swift => LegacyMemberCountResponse.swift} (84%) rename SessionMessagingKit/Open Groups/Models/{ModeratorsResponse.swift => LegacyModeratorsResponse.swift} (75%) rename SessionMessagingKit/Open Groups/Models/{OpenGroupMessageV2.swift => LegacyOpenGroupMessageV2.swift} (91%) rename SessionMessagingKit/Open Groups/Models/{PublicKeyBody.swift => LegacyPublicKeyBody.swift} (85%) create mode 100644 SessionMessagingKit/Open Groups/Models/UpdateMessageRequest.swift create mode 100644 SessionUtilitiesKit/General/Set+Utilities.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index b671fabb6..390444359 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -791,22 +791,22 @@ FDC4381527B329CE00C60D73 /* NonceGenerator16Byte.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381427B329CE00C60D73 /* NonceGenerator16Byte.swift */; }; FDC4381727B32EC700C60D73 /* Personalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381627B32EC700C60D73 /* Personalization.swift */; }; FDC4381A27B34EBA00C60D73 /* LegacyCompactPollBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381927B34EBA00C60D73 /* LegacyCompactPollBody.swift */; }; - FDC4381C27B354AC00C60D73 /* PublicKeyBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381B27B354AC00C60D73 /* PublicKeyBody.swift */; }; + FDC4381C27B354AC00C60D73 /* LegacyPublicKeyBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381B27B354AC00C60D73 /* LegacyPublicKeyBody.swift */; }; FDC4382027B36ADC00C60D73 /* Endpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381F27B36ADC00C60D73 /* Endpoint.swift */; }; - FDC4382627B37F6900C60D73 /* DeletedMessagesResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382527B37F6900C60D73 /* DeletedMessagesResponse.swift */; }; - FDC4382827B37FD300C60D73 /* ModeratorsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382727B37FD300C60D73 /* ModeratorsResponse.swift */; }; + FDC4382627B37F6900C60D73 /* LegacyDeletedMessagesResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382527B37F6900C60D73 /* LegacyDeletedMessagesResponse.swift */; }; + FDC4382827B37FD300C60D73 /* LegacyModeratorsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382727B37FD300C60D73 /* LegacyModeratorsResponse.swift */; }; FDC4382A27B3802D00C60D73 /* LegacyRoomsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382927B3802D00C60D73 /* LegacyRoomsResponse.swift */; }; - FDC4382C27B380E300C60D73 /* MemberCountResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382B27B380E300C60D73 /* MemberCountResponse.swift */; }; + FDC4382C27B380E300C60D73 /* LegacyMemberCountResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382B27B380E300C60D73 /* LegacyMemberCountResponse.swift */; }; FDC4382F27B383AF00C60D73 /* UnregisterResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382E27B383AF00C60D73 /* UnregisterResponse.swift */; }; FDC4383127B3841C00C60D73 /* RegisterResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4383027B3841C00C60D73 /* RegisterResponse.swift */; }; FDC4383827B3863200C60D73 /* VersionResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4383727B3863200C60D73 /* VersionResponse.swift */; }; - FDC4383A27B4696200C60D73 /* AuthTokenResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4383927B4696200C60D73 /* AuthTokenResponse.swift */; }; + FDC4383A27B4696200C60D73 /* LegacyAuthTokenResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4383927B4696200C60D73 /* LegacyAuthTokenResponse.swift */; }; FDC4383E27B4708600C60D73 /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4383D27B4708600C60D73 /* Atomic.swift */; }; FDC4384027B4746D00C60D73 /* LegacyGetInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4383F27B4746D00C60D73 /* LegacyGetInfoResponse.swift */; }; - FDC4384727B47F4D00C60D73 /* OpenGroupMessageV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384327B47F4D00C60D73 /* OpenGroupMessageV2.swift */; }; + FDC4384727B47F4D00C60D73 /* LegacyOpenGroupMessageV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384327B47F4D00C60D73 /* LegacyOpenGroupMessageV2.swift */; }; FDC4384827B47F4D00C60D73 /* LegacyRoomInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384427B47F4D00C60D73 /* LegacyRoomInfo.swift */; }; FDC4384927B47F4D00C60D73 /* LegacyCompactPollResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384527B47F4D00C60D73 /* LegacyCompactPollResponse.swift */; }; - FDC4384A27B47F4D00C60D73 /* Deletion.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384627B47F4D00C60D73 /* Deletion.swift */; }; + FDC4384A27B47F4D00C60D73 /* LegacyDeletion.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384627B47F4D00C60D73 /* LegacyDeletion.swift */; }; FDC4384C27B47F7700C60D73 /* OpenGroupV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384B27B47F7700C60D73 /* OpenGroupV2.swift */; }; FDC4384F27B4804F00C60D73 /* Header.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384E27B4804F00C60D73 /* Header.swift */; }; FDC4385127B4807400C60D73 /* QueryParam.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4385027B4807400C60D73 /* QueryParam.swift */; }; @@ -846,6 +846,8 @@ FDC438C327BB512200C60D73 /* SodiumProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438C227BB512200C60D73 /* SodiumProtocols.swift */; }; FDC438C727BB6DF000C60D73 /* DirectMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438C627BB6DF000C60D73 /* DirectMessage.swift */; }; 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 */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1925,22 +1927,22 @@ FDC4381427B329CE00C60D73 /* NonceGenerator16Byte.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonceGenerator16Byte.swift; sourceTree = ""; }; FDC4381627B32EC700C60D73 /* Personalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Personalization.swift; sourceTree = ""; }; FDC4381927B34EBA00C60D73 /* LegacyCompactPollBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyCompactPollBody.swift; sourceTree = ""; }; - FDC4381B27B354AC00C60D73 /* PublicKeyBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicKeyBody.swift; sourceTree = ""; }; + FDC4381B27B354AC00C60D73 /* LegacyPublicKeyBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyPublicKeyBody.swift; sourceTree = ""; }; FDC4381F27B36ADC00C60D73 /* Endpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Endpoint.swift; sourceTree = ""; }; - FDC4382527B37F6900C60D73 /* DeletedMessagesResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedMessagesResponse.swift; sourceTree = ""; }; - FDC4382727B37FD300C60D73 /* ModeratorsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModeratorsResponse.swift; sourceTree = ""; }; + FDC4382527B37F6900C60D73 /* LegacyDeletedMessagesResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyDeletedMessagesResponse.swift; sourceTree = ""; }; + FDC4382727B37FD300C60D73 /* LegacyModeratorsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyModeratorsResponse.swift; sourceTree = ""; }; FDC4382927B3802D00C60D73 /* LegacyRoomsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyRoomsResponse.swift; sourceTree = ""; }; - FDC4382B27B380E300C60D73 /* MemberCountResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberCountResponse.swift; sourceTree = ""; }; + FDC4382B27B380E300C60D73 /* LegacyMemberCountResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyMemberCountResponse.swift; sourceTree = ""; }; FDC4382E27B383AF00C60D73 /* UnregisterResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnregisterResponse.swift; sourceTree = ""; }; FDC4383027B3841C00C60D73 /* RegisterResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterResponse.swift; sourceTree = ""; }; FDC4383727B3863200C60D73 /* VersionResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionResponse.swift; sourceTree = ""; }; - FDC4383927B4696200C60D73 /* AuthTokenResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthTokenResponse.swift; sourceTree = ""; }; + FDC4383927B4696200C60D73 /* LegacyAuthTokenResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyAuthTokenResponse.swift; sourceTree = ""; }; FDC4383D27B4708600C60D73 /* Atomic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = ""; }; FDC4383F27B4746D00C60D73 /* LegacyGetInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyGetInfoResponse.swift; sourceTree = ""; }; - FDC4384327B47F4D00C60D73 /* OpenGroupMessageV2.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OpenGroupMessageV2.swift; sourceTree = ""; }; + FDC4384327B47F4D00C60D73 /* LegacyOpenGroupMessageV2.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LegacyOpenGroupMessageV2.swift; sourceTree = ""; }; FDC4384427B47F4D00C60D73 /* LegacyRoomInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LegacyRoomInfo.swift; sourceTree = ""; }; FDC4384527B47F4D00C60D73 /* LegacyCompactPollResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LegacyCompactPollResponse.swift; sourceTree = ""; }; - FDC4384627B47F4D00C60D73 /* Deletion.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Deletion.swift; sourceTree = ""; }; + FDC4384627B47F4D00C60D73 /* LegacyDeletion.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LegacyDeletion.swift; sourceTree = ""; }; FDC4384B27B47F7700C60D73 /* OpenGroupV2.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OpenGroupV2.swift; sourceTree = ""; }; FDC4384E27B4804F00C60D73 /* Header.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Header.swift; sourceTree = ""; }; FDC4385027B4807400C60D73 /* QueryParam.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryParam.swift; sourceTree = ""; }; @@ -1978,6 +1980,8 @@ FDC438C227BB512200C60D73 /* SodiumProtocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SodiumProtocols.swift; sourceTree = ""; }; FDC438C627BB6DF000C60D73 /* DirectMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectMessage.swift; sourceTree = ""; }; 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 = ""; }; FEDBAE1B98C49BBE8C87F575 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; sourceTree = ""; }; FF9BA33D021B115B1F5B4E46 /* Pods-SessionMessagingKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionMessagingKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionMessagingKit/Pods-SessionMessagingKit.app store release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -2500,6 +2504,7 @@ C33FDB8A255A581200E217F9 /* AppContext.h */, C33FDB85255A581100E217F9 /* AppContext.m */, C3C2A5D12553860800C340D1 /* Array+Utilities.swift */, + FDC438CC27BC641200C60D73 /* Set+Utilities.swift */, C33FDAA8255A57FF00E217F9 /* BuildConfiguration.swift */, B8F5F58225EC94A6003BF8D4 /* Collection+Subscripting.swift */, B8AE75A325A6C6A6001A84D2 /* Data+Utilities.swift */, @@ -3836,7 +3841,6 @@ isa = PBXGroup; children = ( FDC4384B27B47F7700C60D73 /* OpenGroupV2.swift */, - FDC4381B27B354AC00C60D73 /* PublicKeyBody.swift */, FDC4386A27B4E88F00C60D73 /* BatchRequestInfo.swift */, FDC4386627B4E10E00C60D73 /* Capabilities.swift */, FDC4385C27B4C18900C60D73 /* Room.swift */, @@ -3844,6 +3848,7 @@ FDC4385E27B4C4A200C60D73 /* PinnedMessage.swift */, FDC4386027B4CDDF00C60D73 /* FileResponse.swift */, FDC4387727B5C35400C60D73 /* SendMessageRequest.swift */, + FDC438CA27BB7DB100C60D73 /* UpdateMessageRequest.swift */, FDC4386227B4D94E00C60D73 /* OGMessage.swift */, FDC438C627BB6DF000C60D73 /* DirectMessage.swift */, FDC438C827BB706500C60D73 /* SendDirectMessageRequest.swift */, @@ -3853,17 +3858,18 @@ FDC438A927BB12BB00C60D73 /* UserModeratorRequest.swift */, FDC438AB27BB145200C60D73 /* UserDeleteMessagesRequest.swift */, FDC438AD27BB148700C60D73 /* UserDeleteMessagesResponse.swift */, - FDC4382B27B380E300C60D73 /* MemberCountResponse.swift */, - FDC4382527B37F6900C60D73 /* DeletedMessagesResponse.swift */, - FDC4384627B47F4D00C60D73 /* Deletion.swift */, - FDC4382727B37FD300C60D73 /* ModeratorsResponse.swift */, + FDC4381B27B354AC00C60D73 /* LegacyPublicKeyBody.swift */, + FDC4383927B4696200C60D73 /* LegacyAuthTokenResponse.swift */, FDC4381927B34EBA00C60D73 /* LegacyCompactPollBody.swift */, FDC4384527B47F4D00C60D73 /* LegacyCompactPollResponse.swift */, FDC4383F27B4746D00C60D73 /* LegacyGetInfoResponse.swift */, FDC4382927B3802D00C60D73 /* LegacyRoomsResponse.swift */, FDC4384427B47F4D00C60D73 /* LegacyRoomInfo.swift */, - FDC4384327B47F4D00C60D73 /* OpenGroupMessageV2.swift */, - FDC4383927B4696200C60D73 /* AuthTokenResponse.swift */, + FDC4382B27B380E300C60D73 /* LegacyMemberCountResponse.swift */, + FDC4382727B37FD300C60D73 /* LegacyModeratorsResponse.swift */, + FDC4382527B37F6900C60D73 /* LegacyDeletedMessagesResponse.swift */, + FDC4384627B47F4D00C60D73 /* LegacyDeletion.swift */, + FDC4384327B47F4D00C60D73 /* LegacyOpenGroupMessageV2.swift */, ); path = Models; sourceTree = ""; @@ -5048,6 +5054,7 @@ C3D9E35E25675F640040E4F3 /* OWSFileSystem.m in Sources */, C3C2AC2E2553CBEB00C340D1 /* String+Trimming.swift in Sources */, C32C5B48256DC211003C73A2 /* NSNotificationCenter+OWS.m in Sources */, + FDC438CD27BC641200C60D73 /* Set+Utilities.swift in Sources */, C3D9E3C925676AF30040E4F3 /* TSYapDatabaseObject.m in Sources */, FD705A92278D051200F16121 /* ReusableView.swift in Sources */, B8AE75A425A6C6A6001A84D2 /* Data+Utilities.swift in Sources */, @@ -5151,14 +5158,14 @@ B8F5F60325EDE16F003BF8D4 /* DataExtractionNotification.swift in Sources */, C32C5D24256DD4C0003C73A2 /* MentionsManager.swift in Sources */, C3A71D1E25589AC30043A11F /* WebSocketProto.swift in Sources */, - FDC4384A27B47F4D00C60D73 /* Deletion.swift in Sources */, + FDC4384A27B47F4D00C60D73 /* LegacyDeletion.swift in Sources */, C3C2A7852553AAF300C340D1 /* SessionProtos.pb.swift in Sources */, B8566C63256F55930045A0B9 /* OWSLinkPreview+Conversion.swift in Sources */, - FDC4382827B37FD300C60D73 /* ModeratorsResponse.swift in Sources */, + FDC4382827B37FD300C60D73 /* LegacyModeratorsResponse.swift in Sources */, C32C5B3F256DC1DF003C73A2 /* TSQuotedMessage+Conversion.swift in Sources */, B8EB20EE2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift in Sources */, FD5D202227B1D74F00FEA984 /* ECKeyPair+Conversion.swift in Sources */, - FDC4381C27B354AC00C60D73 /* PublicKeyBody.swift in Sources */, + FDC4381C27B354AC00C60D73 /* LegacyPublicKeyBody.swift in Sources */, FDC4382F27B383AF00C60D73 /* UnregisterResponse.swift in Sources */, FDC4386327B4D94E00C60D73 /* OGMessage.swift in Sources */, C3C2A7712553A41E00C340D1 /* ControlMessage.swift in Sources */, @@ -5173,7 +5180,7 @@ C32C5D9C256DD6DC003C73A2 /* OWSOutgoingReceiptManager.m in Sources */, B8AE760B25ABFB5A001A84D2 /* GeneralUtilities.m in Sources */, C32C5C4F256DCC36003C73A2 /* Storage+OpenGroups.swift in Sources */, - FDC4384727B47F4D00C60D73 /* OpenGroupMessageV2.swift in Sources */, + FDC4384727B47F4D00C60D73 /* LegacyOpenGroupMessageV2.swift in Sources */, FDC4384F27B4804F00C60D73 /* Header.swift in Sources */, C3DA9C0725AE7396008F7C7E /* ConfigurationMessage.swift in Sources */, B8856CEE256F1054001CE70E /* OWSAudioPlayer.m in Sources */, @@ -5185,7 +5192,7 @@ C3BBE0762554CDA60050F1E3 /* Configuration.swift in Sources */, FDC4387627B5BEF300C60D73 /* FileDownloadResponse.swift in Sources */, C35D76DB26606304009AA5FB /* ThreadUpdateBatcher.swift in Sources */, - FDC4382C27B380E300C60D73 /* MemberCountResponse.swift in Sources */, + FDC4382C27B380E300C60D73 /* LegacyMemberCountResponse.swift in Sources */, B8B32033258B235D0020074B /* Storage+Contacts.swift in Sources */, B8856D69256F141F001CE70E /* OWSWindowManager.m in Sources */, C3D9E3BE25676AD70040E4F3 /* TSAttachmentPointer.m in Sources */, @@ -5205,7 +5212,7 @@ C32C5BEF256DC8EE003C73A2 /* OWSDisappearingMessagesJob.m in Sources */, C34A977425A3E34A00852C71 /* ClosedGroupControlMessage.swift in Sources */, FDC4384C27B47F7700C60D73 /* OpenGroupV2.swift in Sources */, - FDC4382627B37F6900C60D73 /* DeletedMessagesResponse.swift in Sources */, + FDC4382627B37F6900C60D73 /* LegacyDeletedMessagesResponse.swift in Sources */, B88FA7B826045D100049422F /* OpenGroupAPI.swift in Sources */, C32C5E97256DE0CB003C73A2 /* OWSPrimaryStorage.m in Sources */, FDC438C927BB706500C60D73 /* SendDirectMessageRequest.swift in Sources */, @@ -5214,7 +5221,7 @@ B8856E94256F1C37001CE70E /* OWSSounds.m in Sources */, C32C5BCC256DC830003C73A2 /* Storage+ClosedGroups.swift in Sources */, C3A3A0EC256E1949004D228D /* OWSRecipientIdentity.m in Sources */, - FDC4383A27B4696200C60D73 /* AuthTokenResponse.swift in Sources */, + FDC4383A27B4696200C60D73 /* LegacyAuthTokenResponse.swift in Sources */, B8F5F56525EC8453003BF8D4 /* Notification+Contacts.swift in Sources */, C32C5AB2256DBE8F003C73A2 /* TSMessage.m in Sources */, C3A3A0FE256E1A3C004D228D /* TSDatabaseSecondaryIndexes.m in Sources */, @@ -5227,6 +5234,7 @@ C3D9E3C025676AD70040E4F3 /* TSAttachment.m in Sources */, FDC4384827B47F4D00C60D73 /* LegacyRoomInfo.swift in Sources */, C32C598A256D0664003C73A2 /* SNProtoEnvelope+Conversion.swift in Sources */, + FDC438CB27BB7DB100C60D73 /* UpdateMessageRequest.swift in Sources */, C352A2FF25574B6300338F3E /* MessageSendJob.swift in Sources */, FDC438C327BB512200C60D73 /* SodiumProtocols.swift in Sources */, B8856D11256F112A001CE70E /* OWSAudioSession.swift in Sources */, diff --git a/Session.xcodeproj/xcshareddata/xcschemes/Session.xcscheme b/Session.xcodeproj/xcshareddata/xcschemes/Session.xcscheme index 2383ad12e..3f9cd0749 100644 --- a/Session.xcodeproj/xcshareddata/xcschemes/Session.xcscheme +++ b/Session.xcodeproj/xcshareddata/xcschemes/Session.xcscheme @@ -26,7 +26,9 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "NO"> + shouldUseLaunchSchemeArgsEnv = "NO" + codeCoverageEnabled = "YES" + onlyGenerateCoverageForSpecifiedTargets = "YES"> + + + + + + + + + + + + + + + + + + diff --git a/Session/Conversations/ConversationViewItem.m b/Session/Conversations/ConversationViewItem.m index 8afcf5442..51b199501 100644 --- a/Session/Conversations/ConversationViewItem.m +++ b/Session/Conversations/ConversationViewItem.m @@ -1006,7 +1006,7 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) // If it's an incoming message the user must have moderator status if (self.interaction.interactionType == OWSInteractionType_IncomingMessage) { NSString *userPublicKey = [LKStorage.shared getUserPublicKey]; - if (![SNOpenGroupAPI isUserModerator:userPublicKey forRoom:openGroupV2.room onServer:openGroupV2.server]) { return; } + if (![SNOpenGroupManager isUserModerator:userPublicKey forRoom:openGroupV2.room onServer:openGroupV2.server]) { return; } } // Delete the message @@ -1060,7 +1060,7 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) if (self.interaction.interactionType == OWSInteractionType_IncomingMessage) { NSString *userPublicKey = [LKStorage.shared getUserPublicKey]; if (openGroupV2 != nil) { - if (![SNOpenGroupAPI isUserModerator:userPublicKey forRoom:openGroupV2.room onServer:openGroupV2.server]) { return; } + if (![SNOpenGroupManager isUserModerator:userPublicKey forRoom:openGroupV2.room onServer:openGroupV2.server]) { return; } } } @@ -1133,7 +1133,7 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) if (interationType == OWSInteractionType_IncomingMessage) { // Only allow deletion on incoming messages if the user has moderation permission if (openGroupV2 != nil) { - return [SNOpenGroupAPI isUserModerator:[SNGeneralUtilities getUserPublicKey] forRoom:openGroupV2.room onServer:openGroupV2.server]; + return [SNOpenGroupManager isUserModerator:[SNGeneralUtilities getUserPublicKey] forRoom:openGroupV2.room onServer:openGroupV2.server]; } } else { return YES; @@ -1155,7 +1155,7 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) // Check that we're a moderator if (openGroupV2 != nil) { - return [SNOpenGroupAPI isUserModerator:[SNGeneralUtilities getUserPublicKey] forRoom:openGroupV2.room onServer:openGroupV2.server]; + return [SNOpenGroupManager isUserModerator:[SNGeneralUtilities getUserPublicKey] forRoom:openGroupV2.room onServer:openGroupV2.server]; } } diff --git a/Session/Conversations/Input View/MentionSelectionView.swift b/Session/Conversations/Input View/MentionSelectionView.swift index 580710569..a5cc636c4 100644 --- a/Session/Conversations/Input View/MentionSelectionView.swift +++ b/Session/Conversations/Input View/MentionSelectionView.swift @@ -163,7 +163,7 @@ private extension MentionSelectionView { profilePictureView.publicKey = mentionCandidate.publicKey profilePictureView.update() if let server = openGroupServer, let room = openGroupRoom { - let isUserModerator = OpenGroupAPI.isUserModerator(mentionCandidate.publicKey, for: room, on: server) + let isUserModerator = OpenGroupManager.isUserModerator(mentionCandidate.publicKey, for: room, on: server) moderatorIconImageView.isHidden = !isUserModerator } else { moderatorIconImageView.isHidden = true diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 65584efbd..96703a52b 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -219,7 +219,7 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { } if let senderSessionID = senderSessionID, message.isOpenGroupMessage { if let openGroupV2 = Storage.shared.getV2OpenGroup(for: message.uniqueThreadId) { - let isUserModerator = OpenGroupAPI.isUserModerator(senderSessionID, for: openGroupV2.room, on: openGroupV2.server) + let isUserModerator = OpenGroupManager.isUserModerator(senderSessionID, for: openGroupV2.room, on: openGroupV2.server) moderatorIconImageView.isHidden = !isUserModerator || profilePictureView.isHidden } else { moderatorIconImageView.isHidden = true diff --git a/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift b/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift index 6b51dc2e6..fc32e51f9 100644 --- a/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift +++ b/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift @@ -69,9 +69,10 @@ final class JoinOpenGroupModal : Modal { return presentingViewController!.present(alert, animated: true, completion: nil) } presentingViewController!.dismiss(animated: true, completion: nil) + Storage.shared.write { [presentingViewController = self.presentingViewController!] transaction in OpenGroupManager.shared - .add(room: room, server: server, publicKey: publicKey, using: transaction) + .add(roomToken: room, server: server, publicKey: publicKey, using: transaction) .done(on: DispatchQueue.main) { _ in let appDelegate = UIApplication.shared.delegate as! AppDelegate appDelegate.forceSyncConfigurationNowIfNeeded().retainUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...) diff --git a/Session/Open Groups/JoinOpenGroupVC.swift b/Session/Open Groups/JoinOpenGroupVC.swift index cf4597575..8973ce1aa 100644 --- a/Session/Open Groups/JoinOpenGroupVC.swift +++ b/Session/Open Groups/JoinOpenGroupVC.swift @@ -128,7 +128,7 @@ final class JoinOpenGroupVC : BaseVC, UIPageViewControllerDataSource, UIPageView // A V2 open group URL will look like: + + + + // The host doesn't parse if no explicit scheme is provided if let (room, server, publicKey) = OpenGroupManager.parseV2OpenGroup(from: string) { - joinV2OpenGroup(room: room, server: server, publicKey: publicKey) + joinV2OpenGroup(roomToken: room, server: server, publicKey: publicKey) } else { let title = NSLocalizedString("invalid_url", comment: "") let message = "Please check the URL you entered and try again." @@ -136,24 +136,25 @@ final class JoinOpenGroupVC : BaseVC, UIPageViewControllerDataSource, UIPageView } } - fileprivate func joinV2OpenGroup(room: String, server: String, publicKey: String) { + fileprivate func joinV2OpenGroup(roomToken: String, server: String, publicKey: String) { guard !isJoining else { return } isJoining = true ModalActivityIndicatorViewController.present(fromViewController: navigationController!, canCancel: false) { [weak self] _ in Storage.shared.write { transaction in - OpenGroupManager.shared.add(room: room, server: server, publicKey: publicKey, using: transaction) - .done(on: DispatchQueue.main) { [weak self] _ in - self?.presentingViewController?.dismiss(animated: true, completion: nil) - let appDelegate = UIApplication.shared.delegate as! AppDelegate - appDelegate.forceSyncConfigurationNowIfNeeded().retainUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...) - } - .catch(on: DispatchQueue.main) { [weak self] error in - self?.dismiss(animated: true, completion: nil) // Dismiss the loader - let title = "Couldn't Join" - let message = error.localizedDescription - self?.isJoining = false - self?.showError(title: title, message: message) - } + OpenGroupManager.shared + .add(roomToken: roomToken, server: server, publicKey: publicKey, using: transaction) + .done(on: DispatchQueue.main) { [weak self] _ in + self?.presentingViewController?.dismiss(animated: true, completion: nil) + let appDelegate = UIApplication.shared.delegate as! AppDelegate + appDelegate.forceSyncConfigurationNowIfNeeded().retainUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...) + } + .catch(on: DispatchQueue.main) { [weak self] error in + self?.dismiss(animated: true, completion: nil) // Dismiss the loader + let title = "Couldn't Join" + let message = error.localizedDescription + self?.isJoining = false + self?.showError(title: title, message: message) + } } } } @@ -166,10 +167,11 @@ final class JoinOpenGroupVC : BaseVC, UIPageViewControllerDataSource, UIPageView } } -private final class EnterURLVC : UIViewController, UIGestureRecognizerDelegate, OpenGroupSuggestionGridDelegate { +private final class EnterURLVC: UIViewController, UIGestureRecognizerDelegate, OpenGroupSuggestionGridDelegate { weak var joinOpenGroupVC: JoinOpenGroupVC! - // MARK: Components + // MARK: - Components + private lazy var urlTextView: TextView = { let result = TextView(placeholder: NSLocalizedString("vc_enter_chat_url_text_field_hint", comment: "")) result.keyboardType = .URL @@ -185,7 +187,8 @@ private final class EnterURLVC : UIViewController, UIGestureRecognizerDelegate, return result }() - // MARK: Lifecycle + // MARK: - Lifecycle + override func viewDidLoad() { // Remove background color view.backgroundColor = .clear @@ -223,7 +226,8 @@ private final class EnterURLVC : UIViewController, UIGestureRecognizerDelegate, view.addGestureRecognizer(tapGestureRecognizer) } - // MARK: General + // MARK: - General + func constrainHeight(to height: CGFloat) { view.set(.height, to: height) } @@ -232,14 +236,15 @@ private final class EnterURLVC : UIViewController, UIGestureRecognizerDelegate, urlTextView.resignFirstResponder() } - // MARK: Interaction + // MARK: - Interaction + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { let location = gestureRecognizer.location(in: view) return !suggestionGrid.frame.contains(location) } - func join(_ room: OpenGroupAPI.LegacyRoomInfo) { - joinOpenGroupVC.joinV2OpenGroup(room: room.id, server: OpenGroupAPI.defaultServer, publicKey: OpenGroupAPI.defaultServerPublicKey) + func join(_ room: OpenGroupAPI.Room) { + joinOpenGroupVC.joinV2OpenGroup(roomToken: room.token, server: OpenGroupAPI.defaultServer, publicKey: OpenGroupAPI.defaultServerPublicKey) } @objc private func joinOpenGroup() { diff --git a/Session/Open Groups/OpenGroupSuggestionGrid.swift b/Session/Open Groups/OpenGroupSuggestionGrid.swift index 3ac1f5e69..58e7633d7 100644 --- a/Session/Open Groups/OpenGroupSuggestionGrid.swift +++ b/Session/Open Groups/OpenGroupSuggestionGrid.swift @@ -3,11 +3,12 @@ import NVActivityIndicatorView final class OpenGroupSuggestionGrid : UIView, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { private let maxWidth: CGFloat - private var rooms: [OpenGroupAPI.LegacyRoomInfo] = [] { didSet { update() } } + private var rooms: [OpenGroupAPI.Room] = [] { didSet { update() } } private var heightConstraint: NSLayoutConstraint! var delegate: OpenGroupSuggestionGridDelegate? - // MARK: UI Components + // MARK: - UI + private lazy var layout: UICollectionViewFlowLayout = { let result = UICollectionViewFlowLayout() result.minimumLineSpacing = 0 @@ -32,11 +33,13 @@ final class OpenGroupSuggestionGrid : UIView, UICollectionViewDataSource, UIColl return result }() - // MARK: Settings + // MARK: - Settings + private static let cellHeight: CGFloat = 40 private static let separatorWidth = 1 / UIScreen.main.scale - // MARK: Initialization + // MARK: - Initialization + init(maxWidth: CGFloat) { self.maxWidth = maxWidth super.init(frame: CGRect.zero) @@ -59,16 +62,15 @@ final class OpenGroupSuggestionGrid : UIView, UICollectionViewDataSource, UIColl spinner.startAnimating() heightConstraint = set(.height, to: OpenGroupSuggestionGrid.cellHeight) widthAnchor.constraint(greaterThanOrEqualToConstant: OpenGroupSuggestionGrid.cellHeight).isActive = true - if OpenGroupAPI.defaultRoomsPromise == nil { - OpenGroupAPI.legacyGetDefaultRoomsIfNeeded() - } - let _ = OpenGroupAPI.legacyDefaultRoomsPromise?.done { [weak self] rooms in - // TODO: Update this for the new rooms API + + OpenGroupManager.getDefaultRoomsIfNeeded() + _ = OpenGroupManager.defaultRoomsPromise?.done { [weak self] rooms in self?.rooms = rooms } } - // MARK: Updating + // MARK: - Updating + private func update() { spinner.stopAnimating() spinner.isHidden = true @@ -78,12 +80,14 @@ final class OpenGroupSuggestionGrid : UIView, UICollectionViewDataSource, UIColl collectionView.reloadData() } - // MARK: Layout + // MARK: - Layout + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { return CGSize(width: maxWidth / 2, height: OpenGroupSuggestionGrid.cellHeight) } - // MARK: Data Source + // MARK: - Data Source + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return min(rooms.count, 8) // Cap to a maximum of 8 (4 rows of 2) } @@ -94,18 +98,20 @@ final class OpenGroupSuggestionGrid : UIView, UICollectionViewDataSource, UIColl return cell } - // MARK: Interaction + // MARK: - Interaction + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { let room = rooms[indexPath.item] delegate?.join(room) } } -// MARK: Cell +// MARK: - Cell + extension OpenGroupSuggestionGrid { fileprivate final class Cell : UICollectionViewCell { - var room: OpenGroupAPI.LegacyRoomInfo? { didSet { update() } } + var room: OpenGroupAPI.Room? { didSet { update() } } static let identifier = "OpenGroupSuggestionGridCell" @@ -172,17 +178,24 @@ extension OpenGroupSuggestionGrid { } private func update() { - guard let room = room else { return } - let promise = OpenGroupAPI.legacyGetGroupImage(for: room.id, on: OpenGroupAPI.defaultServer) - imageView.image = given(promise.value) { UIImage(data: $0)! } - imageView.isHidden = (imageView.image == nil) + guard let room: OpenGroupAPI.Room = room else { return } + label.text = room.name + + if let imageId: Int64 = room.imageId { + let promise = OpenGroupManager.roomImage(imageId, for: room.token, on: OpenGroupAPI.defaultServer) + imageView.image = given(promise.value) { UIImage(data: $0)! } + imageView.isHidden = (imageView.image == nil) + } + else { + imageView.isHidden = true + } } } } -// MARK: Delegate +// MARK: - Delegate + protocol OpenGroupSuggestionGridDelegate { - - func join(_ room: OpenGroupAPI.LegacyRoomInfo) + func join(_ room: OpenGroupAPI.Room) } diff --git a/SessionMessagingKit/Database/Storage+OpenGroups.swift b/SessionMessagingKit/Database/Storage+OpenGroups.swift index a769b2fd7..9ef34143b 100644 --- a/SessionMessagingKit/Database/Storage+OpenGroups.swift +++ b/SessionMessagingKit/Database/Storage+OpenGroups.swift @@ -2,6 +2,12 @@ public protocol SessionMessagingKitOpenGroupStorageProtocol { func getOpenGroupImage(for room: String, on server: String) -> Data? func setOpenGroupImage(to data: Data, for room: String, on server: String, using transaction: Any) + + func getV2OpenGroup(for threadID: String) -> OpenGroupV2? + func setV2OpenGroup(_ openGroup: OpenGroupV2, for threadID: String, using transaction: Any) + + func getUserCount(forV2OpenGroupWithID openGroupID: String) -> UInt64? + func setUserCount(to newValue: UInt64, forV2OpenGroupWithID openGroupID: String, using transaction: Any) } extension Storage: SessionMessagingKitOpenGroupStorageProtocol { diff --git a/SessionMessagingKit/Open Groups/Models/AuthTokenResponse.swift b/SessionMessagingKit/Open Groups/Models/LegacyAuthTokenResponse.swift similarity index 90% rename from SessionMessagingKit/Open Groups/Models/AuthTokenResponse.swift rename to SessionMessagingKit/Open Groups/Models/LegacyAuthTokenResponse.swift index 0823b3016..1f02b8ac8 100644 --- a/SessionMessagingKit/Open Groups/Models/AuthTokenResponse.swift +++ b/SessionMessagingKit/Open Groups/Models/LegacyAuthTokenResponse.swift @@ -3,7 +3,7 @@ import Foundation extension OpenGroupAPI { - struct AuthTokenResponse: Codable { + struct LegacyAuthTokenResponse: Codable { struct Challenge: Codable { enum CodingKeys: String, CodingKey { case ciphertext = "ciphertext" @@ -20,7 +20,7 @@ extension OpenGroupAPI { // MARK: - Codable -extension OpenGroupAPI.AuthTokenResponse.Challenge { +extension OpenGroupAPI.LegacyAuthTokenResponse.Challenge { init(from decoder: Decoder) throws { let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) @@ -31,7 +31,7 @@ extension OpenGroupAPI.AuthTokenResponse.Challenge { throw OpenGroupAPI.Error.parsingFailed } - self = OpenGroupAPI.AuthTokenResponse.Challenge( + self = OpenGroupAPI.LegacyAuthTokenResponse.Challenge( ciphertext: ciphertext, ephemeralPublicKey: ephemeralPublicKey ) diff --git a/SessionMessagingKit/Open Groups/Models/LegacyCompactPollResponse.swift b/SessionMessagingKit/Open Groups/Models/LegacyCompactPollResponse.swift index 963ef1449..f699b3206 100644 --- a/SessionMessagingKit/Open Groups/Models/LegacyCompactPollResponse.swift +++ b/SessionMessagingKit/Open Groups/Models/LegacyCompactPollResponse.swift @@ -15,8 +15,8 @@ extension OpenGroupAPI { public let room: String public let statusCode: UInt - public let messages: [OpenGroupMessageV2]? - public let deletions: [Deletion]? + public let messages: [LegacyOpenGroupMessageV2]? + public let deletions: [LegacyDeletion]? public let moderators: [String]? } diff --git a/SessionMessagingKit/Open Groups/Models/DeletedMessagesResponse.swift b/SessionMessagingKit/Open Groups/Models/LegacyDeletedMessagesResponse.swift similarity index 69% rename from SessionMessagingKit/Open Groups/Models/DeletedMessagesResponse.swift rename to SessionMessagingKit/Open Groups/Models/LegacyDeletedMessagesResponse.swift index a701594b4..f063cc08c 100644 --- a/SessionMessagingKit/Open Groups/Models/DeletedMessagesResponse.swift +++ b/SessionMessagingKit/Open Groups/Models/LegacyDeletedMessagesResponse.swift @@ -3,11 +3,11 @@ import Foundation extension OpenGroupAPI { - struct DeletedMessagesResponse: Codable { + struct LegacyDeletedMessagesResponse: Codable { enum CodingKeys: String, CodingKey { case deletions = "ids" } - let deletions: [Deletion] + let deletions: [LegacyDeletion] } } diff --git a/SessionMessagingKit/Open Groups/Models/Deletion.swift b/SessionMessagingKit/Open Groups/Models/LegacyDeletion.swift similarity index 72% rename from SessionMessagingKit/Open Groups/Models/Deletion.swift rename to SessionMessagingKit/Open Groups/Models/LegacyDeletion.swift index 407fcec41..03fc1ae0a 100644 --- a/SessionMessagingKit/Open Groups/Models/Deletion.swift +++ b/SessionMessagingKit/Open Groups/Models/LegacyDeletion.swift @@ -3,7 +3,7 @@ import Foundation extension OpenGroupAPI { - public struct Deletion: Codable { + public struct LegacyDeletion: Codable { enum CodingKeys: String, CodingKey { case id case deletedMessageID = "deleted_message_id" @@ -12,12 +12,12 @@ extension OpenGroupAPI { let id: Int64 let deletedMessageID: Int64 - public static func from(_ json: JSON) -> Deletion? { + public static func from(_ json: JSON) -> LegacyDeletion? { guard let id = json["id"] as? Int64, let deletedMessageID = json["deleted_message_id"] as? Int64 else { return nil } - return Deletion(id: id, deletedMessageID: deletedMessageID) + return LegacyDeletion(id: id, deletedMessageID: deletedMessageID) } } } diff --git a/SessionMessagingKit/Open Groups/Models/MemberCountResponse.swift b/SessionMessagingKit/Open Groups/Models/LegacyMemberCountResponse.swift similarity index 84% rename from SessionMessagingKit/Open Groups/Models/MemberCountResponse.swift rename to SessionMessagingKit/Open Groups/Models/LegacyMemberCountResponse.swift index 8ca0d8e43..1f7c13cfb 100644 --- a/SessionMessagingKit/Open Groups/Models/MemberCountResponse.swift +++ b/SessionMessagingKit/Open Groups/Models/LegacyMemberCountResponse.swift @@ -3,7 +3,7 @@ import Foundation extension OpenGroupAPI { - struct MemberCountResponse: Codable { + struct LegacyMemberCountResponse: Codable { enum CodingKeys: String, CodingKey { case memberCount = "member_count" } diff --git a/SessionMessagingKit/Open Groups/Models/ModeratorsResponse.swift b/SessionMessagingKit/Open Groups/Models/LegacyModeratorsResponse.swift similarity index 75% rename from SessionMessagingKit/Open Groups/Models/ModeratorsResponse.swift rename to SessionMessagingKit/Open Groups/Models/LegacyModeratorsResponse.swift index 40b8fb08a..4846a5faf 100644 --- a/SessionMessagingKit/Open Groups/Models/ModeratorsResponse.swift +++ b/SessionMessagingKit/Open Groups/Models/LegacyModeratorsResponse.swift @@ -3,7 +3,7 @@ import Foundation extension OpenGroupAPI { - struct ModeratorsResponse: Codable { + struct LegacyModeratorsResponse: Codable { let moderators: [String] } } diff --git a/SessionMessagingKit/Open Groups/Models/OpenGroupMessageV2.swift b/SessionMessagingKit/Open Groups/Models/LegacyOpenGroupMessageV2.swift similarity index 91% rename from SessionMessagingKit/Open Groups/Models/OpenGroupMessageV2.swift rename to SessionMessagingKit/Open Groups/Models/LegacyOpenGroupMessageV2.swift index c7a89ea83..3c87036e0 100644 --- a/SessionMessagingKit/Open Groups/Models/OpenGroupMessageV2.swift +++ b/SessionMessagingKit/Open Groups/Models/LegacyOpenGroupMessageV2.swift @@ -1,7 +1,7 @@ import Foundation import SessionUtilitiesKit -public struct OpenGroupMessageV2: Codable { +public struct LegacyOpenGroupMessageV2: Codable { enum CodingKeys: String, CodingKey { case serverID = "server_id" case sender = "public_key" @@ -19,7 +19,7 @@ public struct OpenGroupMessageV2: Codable { /// a receiving user can verify that the message wasn't tampered with. public let base64EncodedSignature: String? - public func sign(with publicKey: String) -> OpenGroupMessageV2? { + public func sign(with publicKey: String) -> LegacyOpenGroupMessageV2? { guard let userKeyPair = SNMessagingKitConfiguration.shared.storage.getUserKeyPair() else { return nil } guard let data = Data(base64Encoded: base64EncodedData) else { return nil } guard let signature = try? Ed25519.sign(data, with: userKeyPair) else { @@ -27,7 +27,7 @@ public struct OpenGroupMessageV2: Codable { return nil } - return OpenGroupMessageV2( + return LegacyOpenGroupMessageV2( serverID: serverID, sender: sender, sentTimestamp: sentTimestamp, @@ -39,7 +39,7 @@ public struct OpenGroupMessageV2: Codable { // MARK: - Decoder -extension OpenGroupMessageV2 { +extension LegacyOpenGroupMessageV2 { public init(from decoder: Decoder) throws { let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) @@ -60,7 +60,7 @@ extension OpenGroupMessageV2 { throw OpenGroupAPI.Error.parsingFailed } - self = OpenGroupMessageV2( + self = LegacyOpenGroupMessageV2( serverID: try? container.decode(Int64.self, forKey: .serverID), sender: sender, sentTimestamp: try container.decode(UInt64.self, forKey: .sentTimestamp), diff --git a/SessionMessagingKit/Open Groups/Models/PublicKeyBody.swift b/SessionMessagingKit/Open Groups/Models/LegacyPublicKeyBody.swift similarity index 85% rename from SessionMessagingKit/Open Groups/Models/PublicKeyBody.swift rename to SessionMessagingKit/Open Groups/Models/LegacyPublicKeyBody.swift index b10e6bdbc..572bdbdbf 100644 --- a/SessionMessagingKit/Open Groups/Models/PublicKeyBody.swift +++ b/SessionMessagingKit/Open Groups/Models/LegacyPublicKeyBody.swift @@ -3,7 +3,7 @@ import Foundation extension OpenGroupAPI { - struct PublicKeyBody: Codable { + struct LegacyPublicKeyBody: Codable { enum CodingKeys: String, CodingKey { case publicKey = "public_key" } diff --git a/SessionMessagingKit/Open Groups/Models/RoomPollInfo.swift b/SessionMessagingKit/Open Groups/Models/RoomPollInfo.swift index 01803253c..85f0841d8 100644 --- a/SessionMessagingKit/Open Groups/Models/RoomPollInfo.swift +++ b/SessionMessagingKit/Open Groups/Models/RoomPollInfo.swift @@ -71,3 +71,37 @@ extension OpenGroupAPI { public let details: Room? } } + +// MARK: - Convenience + +extension OpenGroupAPI.RoomPollInfo { + init(room: OpenGroupAPI.Room) { + self.init( + token: room.token, + created: room.created, + name: room.name, + description: room.description, + imageId: room.imageId, + infoUpdates: room.infoUpdates, + messageSequence: room.messageSequence, + activeUsers: room.activeUsers, + activeUsersCutoff: room.activeUsersCutoff, + pinnedMessages: room.pinnedMessages, + admin: room.admin, + globalAdmin: room.globalAdmin, + admins: room.admins, + hiddenAdmins: room.hiddenAdmins, + moderator: room.moderator, + globalModerator: room.globalModerator, + moderators: room.moderators, + hiddenModerators: room.hiddenModerators, + read: room.read, + defaultRead: room.defaultRead, + write: room.write, + defaultWrite: room.defaultWrite, + upload: room.upload, + defaultUpload: room.defaultUpload, + details: nil + ) + } +} diff --git a/SessionMessagingKit/Open Groups/Models/SendMessageRequest.swift b/SessionMessagingKit/Open Groups/Models/SendMessageRequest.swift index 068575d81..92e8db8ae 100644 --- a/SessionMessagingKit/Open Groups/Models/SendMessageRequest.swift +++ b/SessionMessagingKit/Open Groups/Models/SendMessageRequest.swift @@ -45,23 +45,5 @@ extension OpenGroupAPI { try container.encode(whisperMods, forKey: .whisperMods) try container.encodeIfPresent(fileIds, forKey: .fileIds) } - - // MARK: - Signing - - public static func sign(message: Data, for idType: IdPrefix, with publicKey: String) -> (data: Data, signature: Data)? { - guard let userKeyPair: ECKeyPair = SNMessagingKitConfiguration.shared.storage.getUserKeyPair() else { - return nil - } - guard let targetKeyPair: ECKeyPair = try? userKeyPair.convert(to: idType, with: publicKey) else { - return nil - } - - guard let signature = try? Ed25519.sign(message, with: targetKeyPair) else { - SNLog("Failed to sign open group message.") - return nil - } - - return (message, signature) - } } } diff --git a/SessionMessagingKit/Open Groups/Models/UpdateMessageRequest.swift b/SessionMessagingKit/Open Groups/Models/UpdateMessageRequest.swift new file mode 100644 index 000000000..aadff442b --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/UpdateMessageRequest.swift @@ -0,0 +1,19 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPI { + public struct UpdateMessageRequest: Codable { + let data: Data + let signature: Data + + // MARK: - Encodable + + public func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(data.base64EncodedString(), forKey: .data) + try container.encode(signature.base64EncodedString(), forKey: .signature) + } + } +} diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI+ObjC.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI+ObjC.swift index 89ba6e8c1..13ce1c4e1 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI+ObjC.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI+ObjC.swift @@ -7,11 +7,6 @@ extension OpenGroupAPI { // TODO: Upgrade this to use the non-legacy version. return AnyPromise.from(legacyDeleteMessage(with: serverID, from: room, on: server)) } - - @objc(isUserModerator:forRoom:onServer:) - public static func objc_isUserModerator(_ publicKey: String, for room: String, on server: String) -> Bool { - return isUserModerator(publicKey, for: room, on: server) - } @objc(legacyGetDefaultRoomsIfNeeded) public static func objc_legacyGetDefaultRoomsIfNeeded() { diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index ecf87d375..a077e01fd 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -11,42 +11,46 @@ public final class OpenGroupAPI: NSObject { public static let defaultServer = "http://116.203.70.33" public static let defaultServerPublicKey = "a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238" - // MARK: - Cache - - private static var authTokenPromises: Atomic<[String: Promise]> = Atomic([:]) - private static var hasPerformedInitialPoll: [String: Bool] = [:] - private static var hasUpdatedLastOpenDate = false public static let workQueue = DispatchQueue(label: "OpenGroupAPI.workQueue", qos: .userInitiated) // It's important that this is a serial queue - public static var moderators: [String: [String: Set]] = [:] // Server URL to room ID to set of moderator IDs - public static var defaultRoomsPromise: Promise<[Room]>? - public static var groupImagePromises: [String: Promise] = [:] + + // MARK: - Polling State + + private static var hasPerformedInitialPoll: [String: Bool] = [:] + private static var timeSinceLastPoll: [String: TimeInterval] = [:] + private static var lastPollTime: TimeInterval = .greatestFiniteMagnitude private static let timeSinceLastOpen: TimeInterval = { guard let lastOpen = UserDefaults.standard[.lastOpen] else { return .greatestFiniteMagnitude } return Date().timeIntervalSince(lastOpen) }() + + + // TODO: Remove these + private static var legacyAuthTokenPromises: Atomic<[String: Promise]> = Atomic([:]) + private static var legacyHasUpdatedLastOpenDate = false + private static var legacyGroupImagePromises: [String: Promise] = [:] + // MARK: - Batching & Polling - /// This is a convenience method which calls `/batch` with a pre-defined set of requests used to update an Open Group + /// This is a convenience method which calls `/batch` with a pre-defined set of requests used to update an Open + /// Group, currently this will retrieve: + /// - Capabilities for the server + /// - For each room: + /// - Poll Info + /// - Messages (includes additions and deletions) public static func poll(_ server: String, using dependencies: Dependencies = Dependencies()) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable)]> { - // TODO: Remove comments - // Capabilities - // Fetch each room - // Poll Info - // /room//pollInfo/ instead? - // Fetch messages for each room - // /room/{roomToken}/messages/since/{messageSequence}: - // Fetch deletions for each room (included in messages) + // Store a local copy of the cached state for this server + let hadPerformedInitialPoll: Bool = (hasPerformedInitialPoll[server] == true) + let originalTimeSinceLastPoll: TimeInterval = (timeSinceLastPoll[server] ?? min(lastPollTime, timeSinceLastOpen)) - // old compact_poll data -// public let room: String -// public let statusCode: UInt -// public let messages: [OpenGroupMessageV2]? -// public let deletions: [Deletion]? -// public let moderators: [String]? + // Update the cached state for this server + hasPerformedInitialPoll[server] = true + lastPollTime = min(lastPollTime, timeSinceLastOpen) + UserDefaults.standard[.lastOpen] = Date() + // Generate the requests let requestResponseType: [BatchRequestInfo] = [ BatchRequestInfo( request: Request( @@ -59,27 +63,37 @@ public final class OpenGroupAPI: NSObject { ] .appending( dependencies.storage.getAllV2OpenGroups().values - .filter { $0.server == server.lowercased() } // Note: The `OpenGroupV2` converts the server value to lowercase during init + .filter { $0.server == server.lowercased() } // Note: The `OpenGroupV2` type converts to lowercase in init .flatMap { openGroup -> [BatchRequestInfo] in let lastSeqNo: Int64? = dependencies.storage.getLastMessageServerID(for: openGroup.room, on: server) let targetSeqNo: Int64 = (lastSeqNo ?? 0) + let shouldRetrieveRecentMessages: Bool = ( + lastSeqNo == nil || ( + // If it's the first poll for this launch and it's been longer than + // 'maxInactivityPeriod' then just retrieve recent messages instead + // of trying to get all messages since the last one retrieved + !hadPerformedInitialPoll && + originalTimeSinceLastPoll > OpenGroupAPI.Poller.maxInactivityPeriod + ) + ) return [ BatchRequestInfo( request: Request( server: server, - // TODO: Source the '0' from the open group (will need to add a new field and default to 0) - endpoint: .roomPollInfo(openGroup.room, 0) + endpoint: .roomPollInfo(openGroup.room, openGroup.infoUpdates) ), responseType: RoomPollInfo.self ), BatchRequestInfo( request: Request( server: server, - endpoint: (lastSeqNo == nil ? + endpoint: (shouldRetrieveRecentMessages ? .roomMessagesRecent(openGroup.room) : .roomMessagesSince(openGroup.room, seqNo: targetSeqNo) ) + // TODO: Limit? +// queryParameters: [ .limit: 256 ] ), responseType: [Message].self ) @@ -87,7 +101,6 @@ public final class OpenGroupAPI: NSObject { } ) - // TODO: Handle response (maybe in the poller or the OpenGroupManager?) return batch(server, requests: requestResponseType, using: dependencies) } @@ -121,64 +134,35 @@ public final class OpenGroupAPI: NSObject { } } - // TODO: `/sequence` request. - - public static func compactPoll(_ server: String, using dependencies: Dependencies = Dependencies()) -> Promise { - let rooms: [String] = dependencies.storage.getAllV2OpenGroups().values - .filter { $0.server == server } - .map { $0.room } - let useMessageLimit = (hasPerformedInitialPoll[server] != true && timeSinceLastOpen > OpenGroupAPI.Poller.maxInactivityPeriod) - - hasPerformedInitialPoll[server] = true - - if !hasUpdatedLastOpenDate { - UserDefaults.standard[.lastOpen] = dependencies.date - hasUpdatedLastOpenDate = true - } - - let requestBody: LegacyCompactPollBody = LegacyCompactPollBody( - requests: rooms - .map { roomId -> LegacyCompactPollBody.Room in - LegacyCompactPollBody.Room( - id: roomId, - fromMessageServerId: (useMessageLimit ? nil : - dependencies.storage.getLastMessageServerID(for: roomId, on: server) - ), - fromDeletionServerId: (useMessageLimit ? nil : - dependencies.storage.getLastDeletionServerID(for: roomId, on: server) - ), - legacyAuthToken: nil - ) - } - ) + /// The requests are guaranteed to be performed sequentially in the order given in the request and will abort if any request does not return a status-`2xx` response. + /// + /// For example, this can be used to ban and delete all of a user's messages by sequencing the ban followed by the `delete_all`: if the ban fails (e.g. because + /// permission is denied) then the `delete_all` will not occur. The batch body and response are identical to the `/batch` endpoint; requests that are not + /// carried out because of an earlier failure will have a response code of `412` (Precondition Failed)." + private static func sequence(_ server: String, requests: [BatchRequestInfo], using dependencies: Dependencies = Dependencies()) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable)]> { + let requestBody: BatchRequest = requests.map { BatchSubRequest(request: $0.request) } + let responseTypes = requests.map { $0.responseType } guard let body: Data = try? JSONEncoder().encode(requestBody) else { return Promise(error: HTTP.Error.invalidJSON) } - - let request = Request( + + let request: Request = Request( method: .post, server: server, - endpoint: .legacyCompactPoll(legacyAuth: false), + endpoint: .sequence, body: body ) + // TODO: Handle a `412` response (ie. a required capability isn't supported) return send(request, using: dependencies) - .then(on: OpenGroupAPI.workQueue) { _, maybeData -> Promise in - guard let data: Data = maybeData else { throw Error.parsingFailed } - - let response: LegacyCompactPollResponse = try data.decoded(as: LegacyCompactPollResponse.self, customError: Error.parsingFailed) - - return when( - fulfilled: response.results - .map { (result: LegacyCompactPollResponse.Result) in - legacyProcess(messages: result.messages, for: result.room, on: server) - .then(on: OpenGroupAPI.workQueue) { _ in - process(deletions: result.deletions, for: result.room, on: server) - } - } - ).then(on: OpenGroupAPI.workQueue) { _ in Promise.value(response) } - } + .decoded(as: responseTypes, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) + .map { result in + result.enumerated() + .reduce(into: [:]) { prev, next in + prev[requests[next.offset].request.endpoint] = next.element + } + } } // MARK: - Capabilities @@ -239,13 +223,13 @@ public final class OpenGroupAPI: NSObject { using dependencies: Dependencies = Dependencies() ) -> Promise<(OnionRequestResponseInfoType, Message)> { // TODO: Change this to use '.blinded' once it's working. - guard let signedRequest: (data: Data, signature: Data) = SendMessageRequest.sign(message: plaintext, for: .standard, with: serverPublicKey) else { + guard let signedMessage: (data: Data, signature: Data) = sign(message: plaintext, for: .standard, with: serverPublicKey) else { return Promise(error: Error.signingFailed) } let requestBody: SendMessageRequest = SendMessageRequest( - data: signedRequest.data, - signature: signedRequest.signature, + data: signedMessage.data, + signature: signedMessage.signature, whisperTo: whisperTo, whisperMods: whisperMods, fileIds: nil // TODO: Add support for 'fileIds'. @@ -265,62 +249,97 @@ public final class OpenGroupAPI: NSObject { return send(request, using: dependencies) .decoded(as: Message.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) } + + public static func message(_ id: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Message)> { + let request: Request = Request( + server: server, + endpoint: .roomMessageIndividual(roomToken, id: id) + ) + return send(request, using: dependencies) + .decoded(as: Message.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) + } + + public static func messageUpdate( + _ id: Int64, + plaintext: Data, + in roomToken: String, + on server: String, + with serverPublicKey: String, + using dependencies: Dependencies = Dependencies() + ) -> Promise<(OnionRequestResponseInfoType, Data?)> { + // TODO: Change this to use '.blinded' once it's working. + guard let signedMessage: (data: Data, signature: Data) = sign(message: plaintext, for: .standard, with: serverPublicKey) else { + return Promise(error: Error.signingFailed) + } + + let requestBody: UpdateMessageRequest = UpdateMessageRequest( + data: signedMessage.data, + signature: signedMessage.signature + ) + + guard let body: Data = try? JSONEncoder().encode(requestBody) else { + return Promise(error: Error.parsingFailed) + } + + let request: Request = Request( + method: .put, + server: server, + endpoint: .roomMessageIndividual(roomToken, id: id), + body: body + ) + + // TODO: Handle custom response info? + return send(request, using: dependencies) + } + + /// This is the direct request to retrieve recent messages from an Open Group so should be retrieved automatically from the `poll()` + /// method, if the logic should change then remove the `@available` line and make sure to route the response of this method to + /// the `OpenGroupManager` `handleMessages` method (otherwise the logic may not work correctly) + @available(*, unavailable, message: "Avoid using this directly, use the pre-build `poll()` method instead") public static func recentMessages(in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [Message])> { - // TODO: Recent vs. Since?. let request: Request = Request( server: server, endpoint: .roomMessagesRecent(roomToken) // TODO: Limit?. -// queryParameters: [ -// .fromServerId: storage.getLastMessageServerID(for: room, on: server).map { String($0) } -// ].compactMapValues { $0 } +// queryParameters: [ .limit: 50 ] ) return send(request, using: dependencies) .decoded(as: [Message].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) - .then(on: OpenGroupAPI.workQueue) { responseInfo, messages -> Promise<(OnionRequestResponseInfoType, [Message])> in - process(messages: messages, for: roomToken, on: server, using: dependencies) - .map { processedMessages in (responseInfo, processedMessages) } - } } + /// This is the direct request to retrieve recent messages from an Open Group so should be retrieved automatically from the `poll()` + /// method, if the logic should change then remove the `@available` line and make sure to route the response of this method to + /// the `OpenGroupManager` `handleMessages` method (otherwise the logic may not work correctly) + @available(*, unavailable, message: "Avoid using this directly, use the pre-build `poll()` method instead") public static func messagesBefore(messageId: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [Message])> { - // TODO: Recent vs. Since?. + // TODO: Do we need to be able to load old messages? let request: Request = Request( server: server, endpoint: .roomMessagesBefore(roomToken, id: messageId) // TODO: Limit?. -// queryParameters: [ -// .fromServerId: storage.getLastMessageServerID(for: room, on: server).map { String($0) } -// ].compactMapValues { $0 } +// queryParameters: [ .limit: 50 ] ) return send(request, using: dependencies) .decoded(as: [Message].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) - .then(on: OpenGroupAPI.workQueue) { responseInfo, messages -> Promise<(OnionRequestResponseInfoType, [Message])> in - process(messages: messages, for: roomToken, on: server, using: dependencies) - .map { processedMessages in (responseInfo, processedMessages) } - } } + /// This is the direct request to retrieve recent messages from an Open Group so should be retrieved automatically from the `poll()` + /// method, if the logic should change then remove the `@available` line and make sure to route the response of this method to + /// the `OpenGroupManager` `handleMessages` method (otherwise the logic may not work correctly) + @available(*, unavailable, message: "Avoid using this directly, use the pre-build `poll()` method instead") public static func messagesSince(seqNo: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [Message])> { - // TODO: Recent vs. Since?. let request: Request = Request( server: server, endpoint: .roomMessagesSince(roomToken, seqNo: seqNo) // TODO: Limit?. -// queryParameters: [ -// .fromServerId: storage.getLastMessageServerID(for: room, on: server).map { String($0) } -// ].compactMapValues { $0 } +// queryParameters: [ .limit: 50 ] ) return send(request, using: dependencies) .decoded(as: [Message].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) - .then(on: OpenGroupAPI.workQueue) { responseInfo, messages -> Promise<(OnionRequestResponseInfoType, [Message])> in - process(messages: messages, for: roomToken, on: server, using: dependencies) - .map { processedMessages in (responseInfo, processedMessages) } - } } // MARK: - Pinning @@ -360,45 +379,6 @@ public final class OpenGroupAPI: NSObject { // MARK: - Files - // TODO: Shift this logic to the `OpenGroupManager` (makes more sense since it's not API logic). - public static func roomImage(_ fileId: Int64, for roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise { - // Normally the image for a given group is stored with the group thread, so it's only - // fetched once. However, on the join open group screen we show images for groups the - // user * hasn't * joined yet. We don't want to re-fetch these images every time the - // user opens the app because that could slow the app down or be data-intensive. So - // instead we assume that these images don't change that often and just fetch them once - // a week. We also assume that they're all fetched at the same time as well, so that - // we only need to maintain one date in user defaults. On top of all of this we also - // don't double up on fetch requests by storing the existing request as a promise if - // there is one. - let lastOpenGroupImageUpdate: Date? = UserDefaults.standard[.lastOpenGroupImageUpdate] - let now: Date = dependencies.date - let timeSinceLastUpdate: TimeInterval = (given(lastOpenGroupImageUpdate) { now.timeIntervalSince($0) } ?? .greatestFiniteMagnitude) - let updateInterval: TimeInterval = (7 * 24 * 60 * 60) - - if let data = dependencies.storage.getOpenGroupImage(for: roomToken, on: server), server == defaultServer, timeSinceLastUpdate < updateInterval { - return Promise.value(data) - } - - if let promise = groupImagePromises["\(server).\(roomToken)"] { - return promise - } - - let promise: Promise = downloadFile(fileId, from: roomToken, on: server, using: dependencies) - .map { _, data in data } - _ = promise.done(on: OpenGroupAPI.workQueue) { imageData in - if server == defaultServer { - dependencies.storage.write { transaction in - dependencies.storage.setOpenGroupImage(to: imageData, for: roomToken, on: server, using: transaction) - } - UserDefaults.standard[.lastOpenGroupImageUpdate] = now - } - } - groupImagePromises["\(server).\(roomToken)"] = promise - - return promise - } - public static func uploadFile(_ bytes: [UInt8], fileName: String? = nil, to roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, FileUploadResponse)> { let request: Request = Request( method: .post, @@ -475,13 +455,13 @@ public final class OpenGroupAPI: NSObject { public static func sendMessageRequest(_ plaintext: Data, to sessionId: String, on server: String, with serverPublicKey: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage])> { // TODO: Change this to use '.blinded' once it's working - guard let signedRequest: (data: Data, signature: Data) = SendMessageRequest.sign(message: plaintext, for: .standard, with: serverPublicKey) else { + guard let signedMessage: (data: Data, signature: Data) = sign(message: plaintext, for: .standard, with: serverPublicKey) else { return Promise(error: Error.signingFailed) } let requestBody: SendDirectMessageRequest = SendDirectMessageRequest( - data: signedRequest.data, - signature: signedRequest.signature + data: signedMessage.data, + signature: signedMessage.signature ) guard let body: Data = try? JSONEncoder().encode(requestBody) else { @@ -609,96 +589,24 @@ public final class OpenGroupAPI: NSObject { .decoded(as: UserDeleteMessagesResponse.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) } - // MARK: - Processing - // TODO: Move these methods to the OpenGroupManager? (seems odd for them to be in the API). - - private static func process(messages: [Message]?, for room: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<[Message]> { - guard let messages: [Message] = messages, !messages.isEmpty else { return Promise.value([]) } - - let seqNo: Int64 = (messages.compactMap { $0.seqNo }.max() ?? 0) - let lastMessageSeqNo: Int64 = (dependencies.storage.getLastMessageServerID(for: room, on: server) ?? 0) - - if seqNo > lastMessageSeqNo { - let (promise, seal) = Promise<[Message]>.pending() - - dependencies.storage.write( - with: { transaction in - dependencies.storage.setLastMessageServerID(for: room, on: server, to: seqNo, using: transaction) - }, - completion: { - seal.fulfill(messages) - } - ) - - return promise - } - - return Promise.value(messages) - } - - private static func process(deletions: [Deletion]?, for room: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<[Deletion]> { - guard let deletions: [Deletion] = deletions else { return Promise.value([]) } - - let serverID: Int64 = (deletions.compactMap { $0.id }.max() ?? 0) - let lastDeletionServerID: Int64 = (dependencies.storage.getLastDeletionServerID(for: room, on: server) ?? 0) - - if serverID > lastDeletionServerID { - let (promise, seal) = Promise<[Deletion]>.pending() - - dependencies.storage.write( - with: { transaction in - dependencies.storage.setLastDeletionServerID(for: room, on: server, to: serverID, using: transaction) - }, - completion: { - seal.fulfill(deletions) - } - ) - - return promise - } - - return Promise.value(deletions) - } - - public static func isUserModerator(_ publicKey: String, for room: String, on server: String) -> Bool { - return moderators[server]?[room]?.contains(publicKey) ?? false - } - - // MARK: - General - - // TODO: Shift this to the OpenGroupManager? (seems more at place there than in the API) - public static func getDefaultRoomsIfNeeded(using dependencies: Dependencies = Dependencies()) { - Storage.shared.write( - with: { transaction in - dependencies.storage.setOpenGroupPublicKey(for: defaultServer, to: defaultServerPublicKey, using: transaction) - }, - completion: { - let promise = attempt(maxRetryCount: 8, recoveringOn: DispatchQueue.main) { - OpenGroupAPI.rooms(for: defaultServer, using: dependencies) - .map { _, data in data } - } - _ = promise.done(on: OpenGroupAPI.workQueue) { items in - items - .compactMap { room -> (Int64, String)? in - guard let imageId: Int64 = room.imageId else { return nil} - - return (imageId, room.token) - } - .forEach { imageId, roomToken in - roomImage(imageId, for: roomToken, on: defaultServer, using: dependencies) - .retainUntilComplete() - } - } - promise.catch(on: OpenGroupAPI.workQueue) { _ in - OpenGroupAPI.defaultRoomsPromise = nil - } - defaultRoomsPromise = promise - } - ) - } - // MARK: - Authentication + public static func sign(message: Data, for idType: IdPrefix, with publicKey: String, using dependencies: Dependencies = Dependencies()) -> (data: Data, signature: Data)? { + guard let userKeyPair: ECKeyPair = dependencies.storage.getUserKeyPair() else { + return nil + } + guard let targetKeyPair: ECKeyPair = try? userKeyPair.convert(to: idType, with: publicKey) else { + return nil + } + + guard let signature = try? Ed25519.sign(message, with: targetKeyPair) else { + SNLog("Failed to sign open group message.") + return nil + } + + return (message, signature) + } + private static func sign(_ request: URLRequest, with publicKey: String, using dependencies: Dependencies = Dependencies()) -> URLRequest? { guard let url: URL = request.url else { return nil } @@ -812,7 +720,7 @@ public final class OpenGroupAPI: NSObject { return Promise.value(authToken) } - if let authTokenPromise: Promise = authTokenPromises.wrappedValue["\(server).\(room)"] { + if let authTokenPromise: Promise = legacyAuthTokenPromises.wrappedValue["\(server).\(room)"] { return authTokenPromise } @@ -830,13 +738,13 @@ public final class OpenGroupAPI: NSObject { promise .done(on: OpenGroupAPI.workQueue) { _ in - authTokenPromises.wrappedValue["\(server).\(room)"] = nil + legacyAuthTokenPromises.wrappedValue["\(server).\(room)"] = nil } .catch(on: OpenGroupAPI.workQueue) { _ in - authTokenPromises.wrappedValue["\(server).\(room)"] = nil + legacyAuthTokenPromises.wrappedValue["\(server).\(room)"] = nil } - authTokenPromises.wrappedValue["\(server).\(room)"] = promise + legacyAuthTokenPromises.wrappedValue["\(server).\(room)"] = promise return promise } @@ -859,7 +767,7 @@ public final class OpenGroupAPI: NSObject { return legacySend(request).map(on: OpenGroupAPI.workQueue) { _, maybeData in guard let data: Data = maybeData else { throw Error.parsingFailed } - let response = try data.decoded(as: AuthTokenResponse.self, customError: Error.parsingFailed) + let response = try data.decoded(as: LegacyAuthTokenResponse.self, customError: Error.parsingFailed) let symmetricKey = try AESGCM.generateSymmetricKey(x25519PublicKey: response.challenge.ephemeralPublicKey, x25519PrivateKey: userKeyPair.privateKey) guard let tokenAsData = try? AESGCM.decrypt(response.challenge.ciphertext, with: symmetricKey) else { @@ -872,7 +780,7 @@ public final class OpenGroupAPI: NSObject { @available(*, deprecated, message: "Use request signing instead") public static func legacyClaimAuthToken(_ authToken: String, for room: String, on server: String) -> Promise { - let requestBody: PublicKeyBody = PublicKeyBody(publicKey: getUserHexEncodedPublicKey()) + let requestBody: LegacyPublicKeyBody = LegacyPublicKeyBody(publicKey: getUserHexEncodedPublicKey()) guard let body: Data = try? JSONEncoder().encode(requestBody) else { return Promise(error: HTTP.Error.invalidJSON) @@ -926,9 +834,9 @@ public final class OpenGroupAPI: NSObject { hasPerformedInitialPoll[server] = true - if !hasUpdatedLastOpenDate { + if !legacyHasUpdatedLastOpenDate { UserDefaults.standard[.lastOpen] = Date() - hasUpdatedLastOpenDate = true + legacyHasUpdatedLastOpenDate = true } for room in rooms { @@ -985,7 +893,7 @@ public final class OpenGroupAPI: NSObject { return when( fulfilled: response.results - .compactMap { (result: LegacyCompactPollResponse.Result) -> Promise<[Deletion]>? in + .compactMap { (result: LegacyCompactPollResponse.Result) -> Promise<[LegacyDeletion]>? in // A 401 means that we didn't provide a (valid) auth token for a route that // required one. We use this as an indication that the token we're using has // expired. Note that a 403 has a different meaning; it means that we provided @@ -1000,7 +908,7 @@ public final class OpenGroupAPI: NSObject { } return legacyProcess(messages: result.messages, for: result.room, on: server) - .then(on: OpenGroupAPI.workQueue) { _ -> Promise<[Deletion]> in + .then(on: OpenGroupAPI.workQueue) { _ -> Promise<[LegacyDeletion]> in legacyProcess(deletions: result.deletions, for: result.room, on: server) } } @@ -1023,7 +931,7 @@ public final class OpenGroupAPI: NSObject { items.forEach { legacyGetGroupImage(for: $0.id, on: defaultServer).retainUntilComplete() } } promise.catch(on: OpenGroupAPI.workQueue) { _ in - OpenGroupAPI.defaultRoomsPromise = nil + OpenGroupAPI.legacyDefaultRoomsPromise = nil } legacyDefaultRoomsPromise = promise } @@ -1085,7 +993,7 @@ public final class OpenGroupAPI: NSObject { return Promise.value(data) } - if let promise = groupImagePromises["\(server).\(room)"] { + if let promise = legacyGroupImagePromises["\(server).\(room)"] { return promise } @@ -1109,7 +1017,7 @@ public final class OpenGroupAPI: NSObject { return response.data } - groupImagePromises["\(server).\(room)"] = promise + legacyGroupImagePromises["\(server).\(room)"] = promise return promise } @@ -1125,7 +1033,7 @@ public final class OpenGroupAPI: NSObject { return legacySend(request) .map(on: OpenGroupAPI.workQueue) { _, maybeData in guard let data: Data = maybeData else { throw Error.parsingFailed } - let response: MemberCountResponse = try data.decoded(as: MemberCountResponse.self, customError: Error.parsingFailed) + let response: LegacyMemberCountResponse = try data.decoded(as: LegacyMemberCountResponse.self, customError: Error.parsingFailed) let storage = SNMessagingKitConfiguration.shared.storage storage.write { transaction in @@ -1171,7 +1079,7 @@ public final class OpenGroupAPI: NSObject { // MARK: - Legacy Message Sending & Receiving @available(*, deprecated, message: "Use send(_:to:on:whisperTo:whisperMods:with:) instead") - public static func legacySend(_ message: OpenGroupMessageV2, to room: String, on server: String, with publicKey: String) -> Promise { + public static func legacySend(_ message: LegacyOpenGroupMessageV2, to room: String, on server: String, with publicKey: String) -> Promise { guard let signedMessage = message.sign(with: publicKey) else { return Promise(error: Error.signingFailed) } guard let body: Data = try? JSONEncoder().encode(signedMessage) else { return Promise(error: Error.parsingFailed) @@ -1180,7 +1088,7 @@ public final class OpenGroupAPI: NSObject { return legacySend(request).map(on: OpenGroupAPI.workQueue) { _, maybeData in guard let data: Data = maybeData else { throw Error.parsingFailed } - let message: OpenGroupMessageV2 = try data.decoded(as: OpenGroupMessageV2.self, customError: Error.parsingFailed) + let message: LegacyOpenGroupMessageV2 = try data.decoded(as: LegacyOpenGroupMessageV2.self, customError: Error.parsingFailed) Storage.shared.write { transaction in Storage.shared.addReceivedMessageTimestamp(message.sentTimestamp, using: transaction) } @@ -1189,7 +1097,7 @@ public final class OpenGroupAPI: NSObject { } @available(*, deprecated, message: "Use recentMessages(in:on:) or messagesSince(seqNo:in:on:) instead") - public static func legacyGetMessages(for room: String, on server: String) -> Promise<[OpenGroupMessageV2]> { + public static func legacyGetMessages(for room: String, on server: String) -> Promise<[LegacyOpenGroupMessageV2]> { let storage = SNMessagingKitConfiguration.shared.storage let request: Request = Request( server: server, @@ -1200,9 +1108,9 @@ public final class OpenGroupAPI: NSObject { ].compactMapValues { $0 } ) - return legacySend(request).then(on: OpenGroupAPI.workQueue) { _, maybeData -> Promise<[OpenGroupMessageV2]> in + return legacySend(request).then(on: OpenGroupAPI.workQueue) { _, maybeData -> Promise<[LegacyOpenGroupMessageV2]> in guard let data: Data = maybeData else { throw Error.parsingFailed } - let messages: [OpenGroupMessageV2] = try data.decoded(as: [OpenGroupMessageV2].self, customError: Error.parsingFailed) + let messages: [LegacyOpenGroupMessageV2] = try data.decoded(as: [LegacyOpenGroupMessageV2].self, customError: Error.parsingFailed) return legacyProcess(messages: messages, for: room, on: server) } @@ -1224,7 +1132,7 @@ public final class OpenGroupAPI: NSObject { } @available(*, deprecated, message: "Use v4 endpoint instead") - public static func legacyGetDeletedMessages(for room: String, on server: String) -> Promise<[Deletion]> { + public static func legacyGetDeletedMessages(for room: String, on server: String) -> Promise<[LegacyDeletion]> { let storage = SNMessagingKitConfiguration.shared.storage let request: Request = Request( @@ -1236,11 +1144,11 @@ public final class OpenGroupAPI: NSObject { ].compactMapValues { $0 } ) - return legacySend(request).then(on: OpenGroupAPI.workQueue) { _, maybeData -> Promise<[Deletion]> in + return legacySend(request).then(on: OpenGroupAPI.workQueue) { _, maybeData -> Promise<[LegacyDeletion]> in guard let data: Data = maybeData else { throw Error.parsingFailed } - let response: DeletedMessagesResponse = try data.decoded(as: DeletedMessagesResponse.self, customError: Error.parsingFailed) + let response: LegacyDeletedMessagesResponse = try data.decoded(as: LegacyDeletedMessagesResponse.self, customError: Error.parsingFailed) - return process(deletions: response.deletions, for: room, on: server) + return legacyProcess(deletions: response.deletions, for: room, on: server) } } @@ -1257,7 +1165,7 @@ public final class OpenGroupAPI: NSObject { return legacySend(request) .map(on: OpenGroupAPI.workQueue) { _, maybeData in guard let data: Data = maybeData else { throw Error.parsingFailed } - let response: ModeratorsResponse = try data.decoded(as: ModeratorsResponse.self, customError: Error.parsingFailed) + let response: LegacyModeratorsResponse = try data.decoded(as: LegacyModeratorsResponse.self, customError: Error.parsingFailed) if var x = self.moderators[server] { x[room] = Set(response.moderators) @@ -1273,7 +1181,7 @@ public final class OpenGroupAPI: NSObject { @available(*, deprecated, message: "Use v4 endpoint instead") public static func legacyBan(_ publicKey: String, from room: String, on server: String) -> Promise { - let requestBody: PublicKeyBody = PublicKeyBody(publicKey: getUserHexEncodedPublicKey()) + let requestBody: LegacyPublicKeyBody = LegacyPublicKeyBody(publicKey: getUserHexEncodedPublicKey()) guard let body: Data = try? JSONEncoder().encode(requestBody) else { return Promise(error: HTTP.Error.invalidJSON) @@ -1292,7 +1200,7 @@ public final class OpenGroupAPI: NSObject { @available(*, deprecated, message: "Use v4 endpoint instead") public static func legacyBanAndDeleteAllMessages(_ publicKey: String, from room: String, on server: String) -> Promise { - let requestBody: PublicKeyBody = PublicKeyBody(publicKey: getUserHexEncodedPublicKey()) + let requestBody: LegacyPublicKeyBody = LegacyPublicKeyBody(publicKey: getUserHexEncodedPublicKey()) guard let body: Data = try? JSONEncoder().encode(requestBody) else { return Promise(error: HTTP.Error.invalidJSON) @@ -1325,15 +1233,15 @@ public final class OpenGroupAPI: NSObject { // TODO: Move these methods to the OpenGroupManager? (seems odd for them to be in the API) @available(*, deprecated, message: "Use v4 endpoint instead") - private static func legacyProcess(messages: [OpenGroupMessageV2]?, for room: String, on server: String) -> Promise<[OpenGroupMessageV2]> { - guard let messages: [OpenGroupMessageV2] = messages, !messages.isEmpty else { return Promise.value([]) } + private static func legacyProcess(messages: [LegacyOpenGroupMessageV2]?, for room: String, on server: String) -> Promise<[LegacyOpenGroupMessageV2]> { + guard let messages: [LegacyOpenGroupMessageV2] = messages, !messages.isEmpty else { return Promise.value([]) } let storage = SNMessagingKitConfiguration.shared.storage let serverID: Int64 = (messages.compactMap { $0.serverID }.max() ?? 0) let lastMessageServerID: Int64 = (storage.getLastMessageServerID(for: room, on: server) ?? 0) if serverID > lastMessageServerID { - let (promise, seal) = Promise<[OpenGroupMessageV2]>.pending() + let (promise, seal) = Promise<[LegacyOpenGroupMessageV2]>.pending() storage.write( with: { transaction in @@ -1351,15 +1259,15 @@ public final class OpenGroupAPI: NSObject { } @available(*, deprecated, message: "Use v4 endpoint instead") - private static func legacyProcess(deletions: [Deletion]?, for room: String, on server: String) -> Promise<[Deletion]> { - guard let deletions: [Deletion] = deletions else { return Promise.value([]) } + private static func legacyProcess(deletions: [LegacyDeletion]?, for room: String, on server: String) -> Promise<[LegacyDeletion]> { + guard let deletions: [LegacyDeletion] = deletions else { return Promise.value([]) } let storage = SNMessagingKitConfiguration.shared.storage let serverID: Int64 = (deletions.compactMap { $0.id }.max() ?? 0) let lastDeletionServerID: Int64 = (storage.getLastDeletionServerID(for: room, on: server) ?? 0) if serverID > lastDeletionServerID { - let (promise, seal) = Promise<[Deletion]>.pending() + let (promise, seal) = Promise<[LegacyDeletion]>.pending() storage.write( with: { transaction in diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index fee3e6b14..c6ac5b239 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -6,8 +6,15 @@ public final class OpenGroupManager: NSObject { private var pollers: [String: OpenGroupAPI.Poller] = [:] // One for each server private var isPolling = false + + // MARK: - Cache + + public static var defaultRoomsPromise: Promise<[OpenGroupAPI.Room]>? + private static var groupImagePromises: [String: Promise] = [:] + private static var moderators: [String: [String: Set]] = [:] // Server URL to room ID to set of moderator IDs // MARK: - Polling + @objc public func startPolling() { guard !isPolling else { return } @@ -30,13 +37,13 @@ public final class OpenGroupManager: NSObject { // MARK: - Adding & Removing - public func add(room: String, server: String, publicKey: String, using transaction: Any) -> Promise { + public func add(roomToken: String, server: String, publicKey: String, using transaction: Any) -> Promise { let storage = Storage.shared // Clear any existing data if needed - storage.removeLastMessageServerID(for: room, on: server, using: transaction) - storage.removeLastDeletionServerID(for: room, on: server, using: transaction) - storage.removeAuthToken(for: room, on: server, using: transaction) + storage.removeLastMessageServerID(for: roomToken, on: server, using: transaction) + storage.removeLastDeletionServerID(for: roomToken, on: server, using: transaction) + storage.removeAuthToken(for: roomToken, on: server, using: transaction) // Store the public key storage.setOpenGroupPublicKey(for: server, to: publicKey, using: transaction) @@ -45,111 +52,17 @@ public final class OpenGroupManager: NSObject { let transaction = transaction as! YapDatabaseReadWriteTransaction transaction.addCompletionQueue(DispatchQueue.global(qos: .userInitiated)) { - // Get the group info - // TODO: Remove this legacy method -// OpenGroupAPI.legacyGetRoomInfo(for: room, on: server).done(on: DispatchQueue.global(qos: .userInitiated)) { info in -// // Create the open group model and the thread -// let openGroup = OpenGroupV2(server: server, room: room, name: info.name, publicKey: publicKey, imageID: info.imageID) -// let groupID = LKGroupUtilities.getEncodedOpenGroupIDAsData(openGroup.id) -// let model = TSGroupModel(title: openGroup.name, memberIds: [ getUserHexEncodedPublicKey() ], image: nil, groupId: groupID, groupType: .openGroup, adminIds: []) -// // Store everything -// storage.write(with: { transaction in -// let transaction = transaction as! YapDatabaseReadWriteTransaction -// let thread = TSGroupThread.getOrCreateThread(with: model, transaction: transaction) -// thread.shouldBeVisible = true -// thread.save(with: transaction) -// storage.setV2OpenGroup(openGroup, for: thread.uniqueId!, using: transaction) -// }, completion: { -// // Start the poller if needed -// if OpenGroupManager.shared.pollers[server] == nil { -// let poller = OpenGroupPollerV2(for: server) -// poller.startIfNeeded() -// OpenGroupManager.shared.pollers[server] = poller -// } -// // Fetch the group image -// OpenGroupAPI.legacyGetGroupImage(for: room, on: server).done(on: DispatchQueue.global(qos: .userInitiated)) { data in -// storage.write { transaction in -// // Update the thread -// let transaction = transaction as! YapDatabaseReadWriteTransaction -// let thread = TSGroupThread.getOrCreateThread(with: model, transaction: transaction) -// thread.groupModel.groupImage = UIImage(data: data) -// thread.save(with: transaction) -// } -// }.retainUntilComplete() -// // Finish -// seal.fulfill(()) -// }) -// }.catch(on: DispatchQueue.global(qos: .userInitiated)) { error in -// seal.reject(error) -// } - - OpenGroupAPI.room(for: room, on: server) + OpenGroupAPI.room(for: roomToken, on: server) .done(on: DispatchQueue.global(qos: .userInitiated)) { _, room in - // Create the open group model and the thread - let openGroup: OpenGroupV2 = OpenGroupV2( - server: server, - room: room.token, - name: room.name, + OpenGroupManager.handleRoom( + room, publicKey: publicKey, - imageID: room.imageId.map { "\($0)" } // TODO: Update this? - ) - - let groupID: Data = LKGroupUtilities.getEncodedOpenGroupIDAsData(openGroup.id) - let model: TSGroupModel = TSGroupModel( - title: openGroup.name, - memberIds: [ getUserHexEncodedPublicKey() ], - image: nil, - groupId: groupID, - groupType: .openGroup, - adminIds: [] // TODO: This is part of the 'room' object - ) - - // Store everything - storage.write( - with: { transaction in - let transaction = transaction as! YapDatabaseReadWriteTransaction - let thread = TSGroupThread.getOrCreateThread(with: model, transaction: transaction) - thread.shouldBeVisible = true - thread.save(with: transaction) - storage.setV2OpenGroup(openGroup, for: thread.uniqueId!, using: transaction) - }, - completion: { - // Start the poller if needed - if OpenGroupManager.shared.pollers[server] == nil { - let poller = OpenGroupAPI.Poller(for: server) - poller.startIfNeeded() - OpenGroupManager.shared.pollers[server] = poller - } - - // Fetch the group image (if there is one) - // TODO: Need to test this. - // TODO: Clean this up (can we avoid the if/else with fancy promise wrangling?). - if let imageId: Int64 = room.imageId { - OpenGroupAPI.roomImage(imageId, for: room.token, on: server) - .done(on: DispatchQueue.global(qos: .userInitiated)) { data in - storage.write { transaction in - // Update the thread - let transaction = transaction as! YapDatabaseReadWriteTransaction - let thread = TSGroupThread.getOrCreateThread(with: model, transaction: transaction) - thread.groupModel.groupImage = UIImage(data: data) - thread.save(with: transaction) - } - } - .retainUntilComplete() - } - else { - storage.write { transaction in - // Update the thread - let transaction = transaction as! YapDatabaseReadWriteTransaction - let thread = TSGroupThread.getOrCreateThread(with: model, transaction: transaction) - thread.save(with: transaction) - } - } - - // Finish - seal.fulfill(()) - } - ) + for: roomToken, + on: server, + isBackgroundPoll: false + ) { + seal.fulfill(()) + } } .catch(on: DispatchQueue.global(qos: .userInitiated)) { error in seal.reject(error) @@ -192,7 +105,279 @@ public final class OpenGroupManager: NSObject { } } - // MARK: Convenience + // MARK: - Response Processing + + internal static func handleMessages( + _ messages: [OpenGroupAPI.Message], + for roomToken: String, + on server: String, + isBackgroundPoll: Bool, + using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies() + ) { + // Sorting the messages by server ID before importing them fixes an issue where messages that quote older messages can't find those older messages + let openGroupID = "\(server).\(roomToken)" + let sortedMessages: [OpenGroupAPI.Message] = messages + .sorted { lhs, rhs in lhs.seqNo < rhs.seqNo } + let seqNo: Int64 = (sortedMessages.last?.seqNo ?? 0) + let lastMessageSeqNo: Int64 = (dependencies.storage.getLastMessageServerID(for: roomToken, on: server) ?? 0) + + dependencies.storage.write { transaction in + var messageServerIDsToRemove: [UInt64] = [] + + // Update the 'lastMessageServerId' value if we've gotten a newer message + if seqNo > lastMessageSeqNo { + dependencies.storage.setLastMessageServerID(for: roomToken, on: server, to: seqNo, using: transaction) + } + + // Process the messages + sortedMessages.forEach { message in + guard let base64EncodedString: String = message.base64EncodedData, let data = Data(base64Encoded: base64EncodedString), let sender: String = message.sender else { + // A message with no data has been deleted so add it to the list to remove + messageServerIDsToRemove.append(UInt64(message.seqNo)) + return + } + + let envelope = SNProtoEnvelope.builder(type: .sessionMessage, timestamp: UInt64(floor(message.posted))) + envelope.setContent(data) + envelope.setSource(sender) + + do { + let data = try envelope.buildSerializedData() + let (message, proto) = try MessageReceiver.parse(data, openGroupMessageServerID: UInt64(message.seqNo), isRetry: false, using: transaction) + try MessageReceiver.handle(message, associatedWithProto: proto, openGroupID: openGroupID, isBackgroundPoll: isBackgroundPoll, using: transaction) + } + catch { + SNLog("Couldn't receive open group message due to error: \(error).") + } + } + + // Handle any deletions that are needed + guard !messageServerIDsToRemove.isEmpty else { return } + guard let transaction: YapDatabaseReadWriteTransaction = transaction as? YapDatabaseReadWriteTransaction else { return } + guard let threadID = dependencies.storage.v2GetThreadID(for: openGroupID), let thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction) else { + return + } + + var messagesToRemove: [TSMessage] = [] + + thread.enumerateInteractions(with: transaction) { interaction, stop in + guard let message: TSMessage = interaction as? TSMessage, messageServerIDsToRemove.contains(message.openGroupServerMessageID) else { return } + messagesToRemove.append(message) + } + + messagesToRemove.forEach { $0.remove(with: transaction) } + } + } + + internal static func handleRoom( + _ room: OpenGroupAPI.Room, + publicKey: String, + for roomToken: String, + on server: String, + isBackgroundPoll: Bool, + using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies(), + completion: (() -> ())? = nil + ) { + OpenGroupManager.handlePollInfo( + OpenGroupAPI.RoomPollInfo(room: room), + publicKey: publicKey, + for: roomToken, + on: server, + isBackgroundPoll: isBackgroundPoll, + using: dependencies, + completion: completion + ) + } + + internal static func handlePollInfo( + _ pollInfo: OpenGroupAPI.RoomPollInfo, + publicKey maybePublicKey: String?, + for roomToken: String, + on server: String, + isBackgroundPoll: Bool, + using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies(), + completion: (() -> ())? = nil + ) { + // Create the open group model and get or create the thread + let groupId: Data = LKGroupUtilities.getEncodedOpenGroupIDAsData("\(server).\(roomToken)") + let userPublicKey: String = getUserHexEncodedPublicKey() + let initialModel: TSGroupModel = TSGroupModel( + title: pollInfo.name, + memberIds: [ userPublicKey ], + image: nil, + groupId: groupId, + groupType: .openGroup, + adminIds: (pollInfo.admins ?? []) + ) + + // Store/Update everything + dependencies.storage.write( + with: { transaction in + let transaction = transaction as! YapDatabaseReadWriteTransaction + let thread = TSGroupThread.getOrCreateThread(with: initialModel, transaction: transaction) + let existingOpenGroup: OpenGroupV2? = thread.uniqueId.flatMap { uniqueId -> OpenGroupV2? in + dependencies.storage.getV2OpenGroup(for: uniqueId) + } + + guard let threadUniqueId: String = thread.uniqueId else { return } + guard let publicKey: String = (maybePublicKey ?? existingOpenGroup?.publicKey) else { return } + + let updatedModel: TSGroupModel = TSGroupModel( + title: (pollInfo.name ?? thread.groupModel.groupName), + memberIds: Array(Set(thread.groupModel.groupMemberIds).inserting(userPublicKey)), + image: thread.groupModel.groupImage, + groupId: groupId, + groupType: .openGroup, + adminIds: (pollInfo.admins ?? thread.groupModel.groupAdminIds) + ) + let updatedOpenGroup: OpenGroupV2 = OpenGroupV2( + server: server, + room: (pollInfo.token ?? roomToken), + publicKey: publicKey, + name: (pollInfo.name ?? thread.name()), + groupDescription: (pollInfo.description ?? existingOpenGroup?.description), + imageID: (pollInfo.imageId.map { "\($0)" } ?? existingOpenGroup?.imageID), + infoUpdates: ((pollInfo.infoUpdates ?? existingOpenGroup?.infoUpdates) ?? 0) + ) + let existingUserCount: UInt64? = dependencies.storage.getUserCount(forV2OpenGroupWithID: updatedOpenGroup.id) + + // - Thread changes + thread.shouldBeVisible = true + thread.groupModel = updatedModel + thread.save(with: transaction) + + // - Open Group changes + dependencies.storage.setV2OpenGroup(updatedOpenGroup, for: threadUniqueId, using: transaction) + + // - User Count + dependencies.storage.setUserCount( + to: ((pollInfo.activeUsers.map { UInt64($0) } ?? existingUserCount) ?? 0), + forV2OpenGroupWithID: updatedOpenGroup.id, + using: transaction + ) + }, + completion: { + // Start the poller if needed + if OpenGroupManager.shared.pollers[server] == nil { + OpenGroupManager.shared.pollers[server] = OpenGroupAPI.Poller(for: server) + OpenGroupManager.shared.pollers[server]?.startIfNeeded() + } + + // - Moderators + if let moderators: [String] = pollInfo.moderators { + OpenGroupManager.moderators[server] = (OpenGroupManager.moderators[server] ?? [:]) + .setting(roomToken, Set(moderators)) + } + + // - Room image (if there is one) + if let imageId: Int64 = pollInfo.imageId { + OpenGroupManager.roomImage(imageId, for: roomToken, on: server) + .done(on: DispatchQueue.global(qos: .userInitiated)) { data in + dependencies.storage.write { transaction in + // Update the thread + let transaction = transaction as! YapDatabaseReadWriteTransaction + let thread = TSGroupThread.getOrCreateThread(with: initialModel, transaction: transaction) + thread.groupModel.groupImage = UIImage(data: data) + thread.save(with: transaction) + } + } + .retainUntilComplete() + } + + // Finish + completion?() + } + ) + } + + // MARK: - Convenience + + @objc(isUserModerator:forRoom:onServer:) + public static func isUserModerator(_ publicKey: String, for room: String, on server: String) -> Bool { + return (OpenGroupManager.moderators[server]?[room]?.contains(publicKey) ?? false) + } + + public static func getDefaultRoomsIfNeeded(using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) { + // Note: If we already have a 'defaultRoomsPromise' then there is no need to get it again + guard OpenGroupManager.defaultRoomsPromise == nil else { return } + + dependencies.storage.write( + with: { transaction in + dependencies.storage.setOpenGroupPublicKey( + for: OpenGroupAPI.defaultServer, + to: OpenGroupAPI.defaultServerPublicKey, + using: transaction + ) + }, + completion: { + OpenGroupManager.defaultRoomsPromise = attempt(maxRetryCount: 8, recoveringOn: DispatchQueue.main) { + OpenGroupAPI.rooms(for: OpenGroupAPI.defaultServer, using: dependencies) + .map { _, data in data } + } + OpenGroupManager.defaultRoomsPromise? + .done(on: OpenGroupAPI.workQueue) { items in + items + .compactMap { room -> (Int64, String)? in + guard let imageId: Int64 = room.imageId else { return nil} + + return (imageId, room.token) + } + .forEach { imageId, roomToken in + roomImage(imageId, for: roomToken, on: OpenGroupAPI.defaultServer, using: dependencies) + .retainUntilComplete() + } + } + .catch(on: OpenGroupAPI.workQueue) { _ in + OpenGroupManager.defaultRoomsPromise = nil + } + } + ) + } + + public static func roomImage( + _ fileId: Int64, + for roomToken: String, + on server: String, + using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies() + ) -> Promise { + // Normally the image for a given group is stored with the group thread, so it's only + // fetched once. However, on the join open group screen we show images for groups the + // user * hasn't * joined yet. We don't want to re-fetch these images every time the + // user opens the app because that could slow the app down or be data-intensive. So + // instead we assume that these images don't change that often and just fetch them once + // a week. We also assume that they're all fetched at the same time as well, so that + // we only need to maintain one date in user defaults. On top of all of this we also + // don't double up on fetch requests by storing the existing request as a promise if + // there is one. + let lastOpenGroupImageUpdate: Date? = UserDefaults.standard[.lastOpenGroupImageUpdate] + let now: Date = dependencies.date + let timeSinceLastUpdate: TimeInterval = (lastOpenGroupImageUpdate.map { now.timeIntervalSince($0) } ?? .greatestFiniteMagnitude) + let updateInterval: TimeInterval = (7 * 24 * 60 * 60) + + if let data = dependencies.storage.getOpenGroupImage(for: roomToken, on: server), server == OpenGroupAPI.defaultServer, timeSinceLastUpdate < updateInterval { + return Promise.value(data) + } + + if let promise = OpenGroupManager.groupImagePromises["\(server).\(roomToken)"] { + return promise + } + + let promise: Promise = OpenGroupAPI + .downloadFile(fileId, from: roomToken, on: server, using: dependencies) + .map { _, data in data } + _ = promise.done(on: OpenGroupAPI.workQueue) { imageData in + if server == OpenGroupAPI.defaultServer { + dependencies.storage.write { transaction in + dependencies.storage.setOpenGroupImage(to: imageData, for: roomToken, on: server, using: transaction) + } + UserDefaults.standard[.lastOpenGroupImageUpdate] = now + } + } + OpenGroupManager.groupImagePromises["\(server).\(roomToken)"] = promise + + return promise + } + public static func parseV2OpenGroup(from string: String) -> (room: String, server: String, publicKey: String)? { guard let url = URL(string: string), let host = url.host ?? given(string.split(separator: "/").first, { String($0) }), let query = url.query else { return nil } // Inputs that should work: diff --git a/SessionMessagingKit/Open Groups/Types/Endpoint.swift b/SessionMessagingKit/Open Groups/Types/Endpoint.swift index 5cdc19785..d7e70b4d1 100644 --- a/SessionMessagingKit/Open Groups/Types/Endpoint.swift +++ b/SessionMessagingKit/Open Groups/Types/Endpoint.swift @@ -20,7 +20,7 @@ extension OpenGroupAPI { // Messages case roomMessage(String) - case roomMessageIndividual(String, String) + case roomMessageIndividual(String, id: Int64) case roomMessagesRecent(String) case roomMessagesBefore(String, id: Int64) case roomMessagesSince(String, seqNo: Int64) diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index eedaa0e84..0adea4a7b 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -234,7 +234,7 @@ extension MessageReceiver { // Open groups for openGroupURL in message.openGroups { if let (room, server, publicKey) = OpenGroupManager.parseV2OpenGroup(from: openGroupURL) { - OpenGroupManager.shared.add(room: room, server: server, publicKey: publicKey, using: transaction).retainUntilComplete() + OpenGroupManager.shared.add(roomToken: room, server: server, publicKey: publicKey, using: transaction).retainUntilComplete() } } } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift index 38097ce7c..384589e39 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift @@ -80,122 +80,40 @@ extension OpenGroupAPI { } private func handlePollResponse(_ response: [OpenGroupAPI.Endpoint: (info: OnionRequestResponseInfoType, data: Codable)], isBackgroundPoll: Bool) { - let storage = SNMessagingKitConfiguration.shared.storage - response.forEach { endpoint, response in switch endpoint { case .roomMessagesRecent(let roomToken), .roomMessagesBefore(let roomToken, _), .roomMessagesSince(let roomToken, _): guard let responseData: [OpenGroupAPI.Message] = response.data as? [OpenGroupAPI.Message] else { - //SNLog("Open group polling failed due to error: \(error).") - return // TODO: Throw error? + SNLog("Open group polling failed due to invalid data.") + return } - handleMessages(responseData, roomToken: roomToken, isBackgroundPoll: isBackgroundPoll, using: storage) + OpenGroupManager.handleMessages( + responseData, + for: roomToken, + on: server, + isBackgroundPoll: isBackgroundPoll + ) case .roomPollInfo(let roomToken, _): guard let responseData: OpenGroupAPI.RoomPollInfo = response.data as? OpenGroupAPI.RoomPollInfo else { - //SNLog("Open group polling failed due to error: \(error).") - return // TODO: Throw error? + SNLog("Open group polling failed due to invalid data.") + return } - handlePollInfo(responseData, roomToken: roomToken, isBackgroundPoll: isBackgroundPoll, using: storage) + OpenGroupManager.handlePollInfo( + responseData, + publicKey: nil, + for: roomToken, + on: server, + isBackgroundPoll: isBackgroundPoll + ) default: break // No custom handling needed } } } - // MARK: - Custom response handling - // TODO: Shift this logic to the OpenGroupManager? (seems like the place it should belong?) - - private func handleMessages(_ messages: [OpenGroupAPI.Message], roomToken: String, isBackgroundPoll: Bool, using storage: SessionMessagingKitStorageProtocol) { - // Sorting the messages by server ID before importing them fixes an issue where messages that quote older messages can't find those older messages - let openGroupID = "\(server).\(roomToken)" - let sortedMessages: [OpenGroupAPI.Message] = messages - .sorted { lhs, rhs in lhs.seqNo < rhs.seqNo } - - storage.write { transaction in - var messageServerIDsToRemove: [UInt64] = [] - - sortedMessages.forEach { message in - guard let base64EncodedString: String = message.base64EncodedData, let data = Data(base64Encoded: base64EncodedString), let sender: String = message.sender else { - // A message with no data has been deleted so add it to the list to remove - messageServerIDsToRemove.append(UInt64(message.seqNo)) - return - } - - let envelope = SNProtoEnvelope.builder(type: .sessionMessage, timestamp: UInt64(floor(message.posted))) - envelope.setContent(data) - envelope.setSource(sender) - - do { - let data = try envelope.buildSerializedData() - let (message, proto) = try MessageReceiver.parse(data, openGroupMessageServerID: UInt64(message.seqNo), isRetry: false, using: transaction) - try MessageReceiver.handle(message, associatedWithProto: proto, openGroupID: openGroupID, isBackgroundPoll: isBackgroundPoll, using: transaction) - } - catch { - SNLog("Couldn't receive open group message due to error: \(error).") - } - } - - // Handle any deletions that are needed - guard !messageServerIDsToRemove.isEmpty else { return } - guard let transaction: YapDatabaseReadWriteTransaction = transaction as? YapDatabaseReadWriteTransaction else { return } - guard let threadID = storage.v2GetThreadID(for: openGroupID), let thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction) else { - return - } - - var messagesToRemove: [TSMessage] = [] - - thread.enumerateInteractions(with: transaction) { interaction, stop in - guard let message: TSMessage = interaction as? TSMessage, messageServerIDsToRemove.contains(message.openGroupServerMessageID) else { return } - messagesToRemove.append(message) - } - - messagesToRemove.forEach { $0.remove(with: transaction) } - } - } - - private func handlePollInfo(_ pollInfo: OpenGroupAPI.RoomPollInfo, roomToken: String, isBackgroundPoll: Bool, using storage: SessionMessagingKitStorageProtocol) { - // TODO: Handle other properties???. - - // public let token: String? - // public let created: TimeInterval? - // public let name: String? - // public let description: String? - // public let imageId: Int64? - // - // public let infoUpdates: Int64? - // public let messageSequence: Int64? - // public let activeUsers: Int64? - // public let activeUsersCutoff: Int64? - // public let pinnedMessages: [PinnedMessage]? - // - // public let admin: Bool? - // public let globalAdmin: Bool? - // public let admins: [String]? - // public let hiddenAdmins: [String]? - // - // public let moderator: Bool? - // public let globalModerator: Bool? - // public let moderators: [String]? - // public let hiddenModerators: [String]? - - // - Moderators - OpenGroupAPI.moderators[server] = (OpenGroupAPI.moderators[server] ?? [:]) - .setting(roomToken, Set(pollInfo.moderators ?? [])) - - // public let read: Bool? - // public let defaultRead: Bool? - // public let write: Bool? - // public let defaultWrite: Bool? - // public let upload: Bool? - // public let defaultUpload: Bool? - // - // /// Only populated and different if the `info_updates` counter differs from the provided `info_updated` value - // public let details: Room? - } - // MARK: - Legacy Handling private func handleCompactPollBody(_ body: OpenGroupAPI.LegacyCompactPollResponse.Result, isBackgroundPoll: Bool) { diff --git a/SessionMessagingKit/Storage.swift b/SessionMessagingKit/Storage.swift index fab20bd78..8df85e35f 100644 --- a/SessionMessagingKit/Storage.swift +++ b/SessionMessagingKit/Storage.swift @@ -67,10 +67,6 @@ public protocol SessionMessagingKitStorageProtocol: SessionMessagingKitOpenGroup func setLastDeletionServerID(for room: String, on server: String, to newValue: Int64, using transaction: Any) func removeLastDeletionServerID(for room: String, on server: String, using transaction: Any) - // MARK: - Open Group Metadata - - func setUserCount(to newValue: UInt64, forV2OpenGroupWithID openGroupID: String, using transaction: Any) - // MARK: - Message Handling func getReceivedMessageTimestamps(using transaction: Any) -> [UInt64] diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift b/SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift index 5275bc9c6..4914f102c 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift @@ -81,9 +81,19 @@ class OpenGroupAPITests: XCTestCase { ) testStorage.mockData[.allV2OpenGroups] = [ - "0": OpenGroupV2(server: "testServer", room: "testRoom", name: "Test", publicKey: "7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d", imageID: nil) + "0": OpenGroupV2( + server: "testServer", + room: "testRoom", + publicKey: "7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d", + name: "Test", + groupDescription: nil, + imageID: nil, + infoUpdates: 0 + ) + ] + testStorage.mockData[.openGroupPublicKeys] = [ + "testServer": "7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d" ] - testStorage.mockData[.openGroupPublicKeys] = ["testServer": "7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d"] // Test Private key, not actually used (from here https://www.notion.so/oxen/SOGS-Authentication-dc64cc846cb24b2abbf7dd4bfd74abbb) testStorage.mockData[.userKeyPair] = try! ECKeyPair( diff --git a/SessionMessagingKitTests/_TestUtilities/TestStorage.swift b/SessionMessagingKitTests/_TestUtilities/TestStorage.swift index cc5ad174b..fa697392c 100644 --- a/SessionMessagingKitTests/_TestUtilities/TestStorage.swift +++ b/SessionMessagingKitTests/_TestUtilities/TestStorage.swift @@ -13,7 +13,9 @@ class TestStorage: SessionMessagingKitStorageProtocol, Mockable { case allV2OpenGroups case openGroupPublicKeys case userKeyPair + case openGroup case openGroupImage + case openGroupUserCount } typealias Key = DataKey @@ -71,7 +73,7 @@ class TestStorage: SessionMessagingKitStorageProtocol, Mockable { // MARK: - Open Groups func getAllV2OpenGroups() -> [String: OpenGroupV2] { return (mockData[.allV2OpenGroups] as! [String: OpenGroupV2]) } - func getV2OpenGroup(for threadID: String) -> OpenGroupV2? { return nil } + func getV2OpenGroup(for threadID: String) -> OpenGroupV2? { return (mockData[.openGroup] as? OpenGroupV2) } func v2GetThreadID(for v2OpenGroupID: String) -> String? { return nil } func updateMessageIDCollectionByPruningMessagesWithIDs(_ messageIDs: Set, using transaction: Any) {} @@ -99,10 +101,6 @@ class TestStorage: SessionMessagingKitStorageProtocol, Mockable { func setLastDeletionServerID(for room: String, on server: String, to newValue: Int64, using transaction: Any) {} func removeLastDeletionServerID(for room: String, on server: String, using transaction: Any) {} - // MARK: - Open Group Metadata - - func setUserCount(to newValue: UInt64, forV2OpenGroupWithID openGroupID: String, using transaction: Any) {} - // MARK: - Message Handling func getReceivedMessageTimestamps(using transaction: Any) -> [UInt64] { return [] } @@ -118,5 +116,19 @@ class TestStorage: SessionMessagingKitStorageProtocol, Mockable { extension TestStorage: SessionMessagingKitOpenGroupStorageProtocol { func getOpenGroupImage(for room: String, on server: String) -> Data? { return (mockData[.openGroupImage] as? Data) } - func setOpenGroupImage(to data: Data, for room: String, on server: String, using transaction: Any) {} + func setOpenGroupImage(to data: Data, for room: String, on server: String, using transaction: Any) { + mockData[.openGroupImage] = data + } + + func setV2OpenGroup(_ openGroup: OpenGroupV2, for threadID: String, using transaction: Any) { + mockData[.openGroup] = openGroup + } + + func getUserCount(forV2OpenGroupWithID openGroupID: String) -> UInt64? { + return (mockData[.openGroupUserCount] as? UInt64) + } + + func setUserCount(to newValue: UInt64, forV2OpenGroupWithID openGroupID: String, using transaction: Any) { + mockData[.openGroupUserCount] = newValue + } } diff --git a/SessionUtilitiesKit/General/Set+Utilities.swift b/SessionUtilitiesKit/General/Set+Utilities.swift new file mode 100644 index 000000000..9c4b8eaca --- /dev/null +++ b/SessionUtilitiesKit/General/Set+Utilities.swift @@ -0,0 +1,12 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension Set { + func inserting(_ other: Element) -> Set { + var updatedSet: Set = self + updatedSet.insert(other) + + return updatedSet + } +} From ef09d4d5aaf9e90b155189894ab42fbd6661d281 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 17 Feb 2022 18:33:23 +1100 Subject: [PATCH 012/157] Additional encryption work on id blinding Got the updated blinding logic working (at least when authenticating a request - still need to deal with message signing and verification) Storing the server capabilities in the database now so we can correctly blind requests based on them Renamed the remaining 'v2' functions and classes to just be 'OpenGroup' since there isn't a 'V2' anymore Cleaned up a few TODOs and functions --- Podfile | 3 +- Podfile.lock | 13 +- Session.xcodeproj/project.pbxproj | 12 +- .../ConversationVC+Interaction.swift | 8 +- Session/Conversations/ConversationVC.swift | 5 - Session/Conversations/ConversationViewItem.m | 36 ++-- .../Conversations/Input View/InputView.swift | 6 +- .../Message Cells/VisibleMessageCell.swift | 4 +- .../OWSConversationSettingsViewController.m | 2 +- .../ConversationTitleView.swift | 4 +- Session/Home/HomeVC.swift | 6 +- Session/Utilities/BackgroundPoller.swift | 2 +- Session/Utilities/MentionUtilities.swift | 4 +- .../Common Networking/Header.swift | 2 +- .../Database/Storage+Messaging.swift | 2 +- .../Database/Storage+OpenGroups.swift | 53 +++-- .../File Server/FileServerAPIV2.swift | 6 +- .../Jobs/AttachmentDownloadJob.swift | 4 +- .../Jobs/AttachmentUploadJob.swift | 4 +- .../ConfigurationMessage+Convenience.swift | 4 +- .../Messages/Message+Destination.swift | 2 +- .../Open Groups/Models/Capabilities.swift | 22 +- .../{OpenGroupV2.swift => OpenGroup.swift} | 38 +++- .../Open Groups/Models/Server.swift | 46 ++++ .../Open Groups/OpenGroupAPI.swift | 199 ++++++++++++------ .../Open Groups/OpenGroupManager.swift | 43 ++-- .../Open Groups/Types/Dependencies.swift | 5 + .../Open Groups/Types/SodiumProtocols.swift | 28 ++- .../Mentions/MentionsManager.swift | 4 +- .../Sending & Receiving/MessageSender.swift | 14 +- .../Notifications/PushNotificationAPI.swift | 15 +- .../Pollers/OpenGroupPoller.swift | 24 ++- SessionMessagingKit/Storage.swift | 19 +- .../Utilities/Sodium+Utilities.swift | 161 +++++++++++++- .../Open Groups/OpenGroupAPIV2Tests.swift | 15 +- .../_TestUtilities/TestStorage.swift | 47 ++--- .../Utilities/Data+Utilities.swift | 2 +- .../Crypto/ECKeyPair+Hexadecimal.swift | 6 - SessionUtilitiesKit/General/IdPrefix.swift | 14 +- .../MessageSender+Convenience.swift | 14 +- 40 files changed, 631 insertions(+), 267 deletions(-) rename SessionMessagingKit/Open Groups/Models/{OpenGroupV2.swift => OpenGroup.swift} (54%) create mode 100644 SessionMessagingKit/Open Groups/Models/Server.swift diff --git a/Podfile b/Podfile index e7bacf287..44b0bb1b8 100644 --- a/Podfile +++ b/Podfile @@ -8,7 +8,8 @@ inhibit_all_warnings! abstract_target 'GlobalDependencies' do pod 'PromiseKit' pod 'CryptoSwift' - pod 'Sodium', '~> 0.9.1' + # FIXME: If https://github.com/jedisct1/swift-sodium/pull/249 gets resolved then revert this back to the standard pod + pod 'Sodium', :git => 'https://github.com/mpretty-cyro/swift-sodium.git', branch: 'full-clibsodium-build' pod 'YapDatabase/SQLCipher', :git => 'https://github.com/oxen-io/session-ios-yap-database.git', branch: 'signal-release' target 'Session' do diff --git a/Podfile.lock b/Podfile.lock index edb3b4fd7..27b73ca97 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -132,7 +132,7 @@ DEPENDENCIES: - Reachability - SAMKeychain - SignalCoreKit (from `https://github.com/oxen-io/session-ios-core-kit`, branch `session-version`) - - Sodium (~> 0.9.1) + - Sodium (from `https://github.com/mpretty-cyro/swift-sodium.git`, branch `full-clibsodium-build`) - SwiftProtobuf (~> 1.5.0) - YapDatabase/SQLCipher (from `https://github.com/oxen-io/session-ios-yap-database.git`, branch `signal-release`) - YYImage (from `https://github.com/signalapp/YYImage`) @@ -150,7 +150,6 @@ SPEC REPOS: - PureLayout - Reachability - SAMKeychain - - Sodium - SQLCipher - SwiftProtobuf - ZXingObjC @@ -164,6 +163,9 @@ EXTERNAL SOURCES: SignalCoreKit: :branch: session-version :git: https://github.com/oxen-io/session-ios-core-kit + Sodium: + :branch: full-clibsodium-build + :git: https://github.com/mpretty-cyro/swift-sodium.git YapDatabase: :branch: signal-release :git: https://github.com/oxen-io/session-ios-yap-database.git @@ -180,6 +182,9 @@ CHECKOUT OPTIONS: SignalCoreKit: :commit: 4590c2737a2b5dc0ef4ace9f9019b581caccc1de :git: https://github.com/oxen-io/session-ios-core-kit + Sodium: + :commit: eeb18f6fa8c28dc254e64cb340ee2c8f37f2ebe8 + :git: https://github.com/mpretty-cyro/swift-sodium.git YapDatabase: :commit: d84069e25e12a16ab4422e5258127a04b70489ad :git: https://github.com/oxen-io/session-ios-yap-database.git @@ -201,13 +206,13 @@ SPEC CHECKSUMS: Reachability: 33e18b67625424e47b6cde6d202dce689ad7af96 SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c SignalCoreKit: 1fbd8732163ef76de16cd1107d1fa3684b607e5d - Sodium: 23d11554ecd556196d313cf6130d406dfe7ac6da + Sodium: a7d42cb46e789d2630fa552d35870b416ed055ae SQLCipher: 98dc22f27c0b1790d39e710d440f22a466ebdb59 SwiftProtobuf: 241400280f912735c1e1b9fe675fdd2c6c4d42e2 YapDatabase: b418a4baa6906e8028748938f9159807fd039af4 YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331 ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: a4acbe047a767c48a709e93318532fbf345330dd +PODFILE CHECKSUM: 011a84301a09b80dfc7343c105fc810eacb31074 COCOAPODS: 1.11.2 diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 390444359..ad42a143f 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -807,7 +807,7 @@ FDC4384827B47F4D00C60D73 /* LegacyRoomInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384427B47F4D00C60D73 /* LegacyRoomInfo.swift */; }; FDC4384927B47F4D00C60D73 /* LegacyCompactPollResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384527B47F4D00C60D73 /* LegacyCompactPollResponse.swift */; }; FDC4384A27B47F4D00C60D73 /* LegacyDeletion.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384627B47F4D00C60D73 /* LegacyDeletion.swift */; }; - FDC4384C27B47F7700C60D73 /* OpenGroupV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384B27B47F7700C60D73 /* OpenGroupV2.swift */; }; + FDC4384C27B47F7700C60D73 /* OpenGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384B27B47F7700C60D73 /* OpenGroup.swift */; }; FDC4384F27B4804F00C60D73 /* Header.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384E27B4804F00C60D73 /* Header.swift */; }; FDC4385127B4807400C60D73 /* QueryParam.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4385027B4807400C60D73 /* QueryParam.swift */; }; FDC4385727B484B700C60D73 /* LegacyFileUploadResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4385627B484B700C60D73 /* LegacyFileUploadResponse.swift */; }; @@ -848,6 +848,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 */; }; + FDC438CF27BCA45400C60D73 /* Server.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438CE27BCA45400C60D73 /* Server.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1943,7 +1944,7 @@ FDC4384427B47F4D00C60D73 /* LegacyRoomInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LegacyRoomInfo.swift; sourceTree = ""; }; FDC4384527B47F4D00C60D73 /* LegacyCompactPollResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LegacyCompactPollResponse.swift; sourceTree = ""; }; FDC4384627B47F4D00C60D73 /* LegacyDeletion.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LegacyDeletion.swift; sourceTree = ""; }; - FDC4384B27B47F7700C60D73 /* OpenGroupV2.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OpenGroupV2.swift; sourceTree = ""; }; + FDC4384B27B47F7700C60D73 /* OpenGroup.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OpenGroup.swift; sourceTree = ""; }; FDC4384E27B4804F00C60D73 /* Header.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Header.swift; sourceTree = ""; }; FDC4385027B4807400C60D73 /* QueryParam.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryParam.swift; sourceTree = ""; }; FDC4385627B484B700C60D73 /* LegacyFileUploadResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyFileUploadResponse.swift; sourceTree = ""; }; @@ -1982,6 +1983,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 = ""; }; + FDC438CE27BCA45400C60D73 /* Server.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.swift; sourceTree = ""; }; FEDBAE1B98C49BBE8C87F575 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; sourceTree = ""; }; FF9BA33D021B115B1F5B4E46 /* Pods-SessionMessagingKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionMessagingKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionMessagingKit/Pods-SessionMessagingKit.app store release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -3840,7 +3842,8 @@ FDC4381827B34EAD00C60D73 /* Models */ = { isa = PBXGroup; children = ( - FDC4384B27B47F7700C60D73 /* OpenGroupV2.swift */, + FDC438CE27BCA45400C60D73 /* Server.swift */, + FDC4384B27B47F7700C60D73 /* OpenGroup.swift */, FDC4386A27B4E88F00C60D73 /* BatchRequestInfo.swift */, FDC4386627B4E10E00C60D73 /* Capabilities.swift */, FDC4385C27B4C18900C60D73 /* Room.swift */, @@ -5129,6 +5132,7 @@ C32C5CA4256DD1DC003C73A2 /* TSAccountManager.m in Sources */, C352A3892557876500338F3E /* JobQueue.swift in Sources */, C3BBE0B52554F0E10050F1E3 /* ProofOfWork.swift in Sources */, + FDC438CF27BCA45400C60D73 /* Server.swift in Sources */, FDC4386727B4E10E00C60D73 /* Capabilities.swift in Sources */, C32C59C1256DB41F003C73A2 /* TSGroupThread.m in Sources */, C3A3A08F256E1728004D228D /* FullTextSearchFinder.swift in Sources */, @@ -5211,7 +5215,7 @@ C3DB66CC260AF1F3001EFC55 /* OpenGroupAPI+ObjC.swift in Sources */, C32C5BEF256DC8EE003C73A2 /* OWSDisappearingMessagesJob.m in Sources */, C34A977425A3E34A00852C71 /* ClosedGroupControlMessage.swift in Sources */, - FDC4384C27B47F7700C60D73 /* OpenGroupV2.swift in Sources */, + FDC4384C27B47F7700C60D73 /* OpenGroup.swift in Sources */, FDC4382627B37F6900C60D73 /* LegacyDeletedMessagesResponse.swift in Sources */, B88FA7B826045D100049422F /* OpenGroupAPI.swift in Sources */, C32C5E97256DE0CB003C73A2 /* OWSPrimaryStorage.m in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index b2d02e13d..faf1e07a3 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -699,8 +699,8 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc let threadID = thread.uniqueId! alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in let publicKey = message.authorId - guard let openGroupV2 = Storage.shared.getV2OpenGroup(for: threadID) else { return } - OpenGroupAPI.legacyBan(publicKey, from: openGroupV2.room, on: openGroupV2.server).retainUntilComplete() + guard let openGroup = Storage.shared.getOpenGroup(for: threadID) else { return } + OpenGroupAPI.legacyBan(publicKey, from: openGroup.room, on: openGroup.server).retainUntilComplete() })) alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: nil)) present(alert, animated: true, completion: nil) @@ -713,8 +713,8 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc let threadID = thread.uniqueId! alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in let publicKey = message.authorId - guard let openGroupV2 = Storage.shared.getV2OpenGroup(for: threadID) else { return } - OpenGroupAPI.legacyBanAndDeleteAllMessages(publicKey, from: openGroupV2.room, on: openGroupV2.server).retainUntilComplete() + guard let openGroup = Storage.shared.getOpenGroup(for: threadID) else { return } + OpenGroupAPI.legacyBanAndDeleteAllMessages(publicKey, from: openGroup.room, on: openGroup.server).retainUntilComplete() })) alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: nil)) present(alert, animated: true, completion: nil) diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 86de50e68..4896aa983 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -370,11 +370,6 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat if !draft.isEmpty { snInputView.text = draft } - // Update member count if this is a V2 open group - // TODO: Non-legacy version (I assue this comes through room updates... 'activeUsers'? - if let v2OpenGroup = Storage.shared.getV2OpenGroup(for: thread.uniqueId!) { - OpenGroupAPI.legacyGetMemberCount(for: v2OpenGroup.room, on: v2OpenGroup.server).retainUntilComplete() - } } override func viewDidLayoutSubviews() { diff --git a/Session/Conversations/ConversationViewItem.m b/Session/Conversations/ConversationViewItem.m index 51b199501..0979ce195 100644 --- a/Session/Conversations/ConversationViewItem.m +++ b/Session/Conversations/ConversationViewItem.m @@ -1000,17 +1000,17 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) if (!message.isOpenGroupMessage) return; // Get the open group - SNOpenGroupV2 *openGroupV2 = [LKStorage.shared getV2OpenGroupForThreadID:groupThread.uniqueId]; - if (openGroupV2 == nil) return; + SNOpenGroupV2 *openGroup = [LKStorage.shared getOpenGroupForThreadID:groupThread.uniqueId]; + if (openGroup == nil) return; // If it's an incoming message the user must have moderator status if (self.interaction.interactionType == OWSInteractionType_IncomingMessage) { NSString *userPublicKey = [LKStorage.shared getUserPublicKey]; - if (![SNOpenGroupManager isUserModerator:userPublicKey forRoom:openGroupV2.room onServer:openGroupV2.server]) { return; } + if (![SNOpenGroupManager isUserModerator:userPublicKey forRoom:openGroup.room onServer:openGroup.server]) { return; } } // Delete the message - [[SNOpenGroupAPI deleteMessageWithServerID:message.openGroupServerMessageID fromRoom:openGroupV2.room onServer:openGroupV2.server].catch(^(NSError *error) { + [[SNOpenGroupAPI deleteMessageWithServerID:message.openGroupServerMessageID fromRoom:openGroup.room onServer:openGroup.server].catch(^(NSError *error) { // Roll back [self.interaction save]; }) retainUntilComplete]; @@ -1053,21 +1053,21 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) if (!message.isOpenGroupMessage) return; // Get the open group - SNOpenGroupV2 *openGroupV2 = [LKStorage.shared getV2OpenGroupForThreadID:groupThread.uniqueId]; - if (openGroup == nil && openGroupV2 == nil) return; + SNOpenGroupV2 *openGroup = [LKStorage.shared getOpenGroupForThreadID:groupThread.uniqueId]; + if (openGroup == nil && openGroup == nil) return; // If it's an incoming message the user must have moderator status if (self.interaction.interactionType == OWSInteractionType_IncomingMessage) { NSString *userPublicKey = [LKStorage.shared getUserPublicKey]; - if (openGroupV2 != nil) { - if (![SNOpenGroupManager isUserModerator:userPublicKey forRoom:openGroupV2.room onServer:openGroupV2.server]) { return; } + if (openGroup != nil) { + if (![SNOpenGroupManager isUserModerator:userPublicKey forRoom:openGroup.room onServer:openGroup.server]) { return; } } } // Delete the message BOOL wasSentByUser = (interationType == OWSInteractionType_OutgoingMessage); - if (openGroupV2 != nil) { - [[SNOpenGroupAPI deleteMessageWithServerID:message.openGroupServerMessageID fromRoom:openGroupV2.room onServer:openGroupV2.server].catch(^(NSError *error) { + if (openGroup != nil) { + [[SNOpenGroupAPI deleteMessageWithServerID:message.openGroupServerMessageID fromRoom:openGroup.room onServer:openGroup.server].catch(^(NSError *error) { // Roll back [self.interaction save]; }) retainUntilComplete]; @@ -1127,13 +1127,13 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) if (!message.isOpenGroupMessage) return true; // Ensure we have the details needed to contact the server - SNOpenGroupV2 *openGroupV2 = [LKStorage.shared getV2OpenGroupForThreadID:groupThread.uniqueId]; - if (openGroupV2 == nil) return true; + SNOpenGroupV2 *openGroup = [LKStorage.shared getOpenGroupForThreadID:groupThread.uniqueId]; + if (openGroup == nil) return true; if (interationType == OWSInteractionType_IncomingMessage) { // Only allow deletion on incoming messages if the user has moderation permission - if (openGroupV2 != nil) { - return [SNOpenGroupManager isUserModerator:[SNGeneralUtilities getUserPublicKey] forRoom:openGroupV2.room onServer:openGroupV2.server]; + if (openGroup != nil) { + return [SNOpenGroupManager isUserModerator:[SNGeneralUtilities getUserPublicKey] forRoom:openGroup.room onServer:openGroup.server]; } } else { return YES; @@ -1150,12 +1150,12 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) if (!message.isOpenGroupMessage) return false; // Ensure we have the details needed to contact the server - SNOpenGroupV2 *openGroupV2 = [LKStorage.shared getV2OpenGroupForThreadID:groupThread.uniqueId]; - if (openGroupV2 == nil) return false; + SNOpenGroupV2 *openGroup = [LKStorage.shared getOpenGroupForThreadID:groupThread.uniqueId]; + if (openGroup == nil) return false; // Check that we're a moderator - if (openGroupV2 != nil) { - return [SNOpenGroupManager isUserModerator:[SNGeneralUtilities getUserPublicKey] forRoom:openGroupV2.room onServer:openGroupV2.server]; + if (openGroup != nil) { + return [SNOpenGroupManager isUserModerator:[SNGeneralUtilities getUserPublicKey] forRoom:openGroup.room onServer:openGroup.server]; } } diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift index 9b78dc9e6..60c851e9f 100644 --- a/Session/Conversations/Input View/InputView.swift +++ b/Session/Conversations/Input View/InputView.swift @@ -319,9 +319,9 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, } func showMentionsUI(for candidates: [Mention], in thread: TSThread) { - if let openGroupV2 = Storage.shared.getV2OpenGroup(for: thread.uniqueId!) { - mentionsView.openGroupServer = openGroupV2.server - mentionsView.openGroupRoom = openGroupV2.room + if let openGroup = Storage.shared.getOpenGroup(for: thread.uniqueId!) { + mentionsView.openGroupServer = openGroup.server + mentionsView.openGroupRoom = openGroup.room } mentionsView.candidates = candidates let mentionCellHeight = Values.smallProfilePictureSize + 2 * Values.smallSpacing diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 96703a52b..9b351e31f 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -218,8 +218,8 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { profilePictureView.update(for: senderSessionID) } if let senderSessionID = senderSessionID, message.isOpenGroupMessage { - if let openGroupV2 = Storage.shared.getV2OpenGroup(for: message.uniqueThreadId) { - let isUserModerator = OpenGroupManager.isUserModerator(senderSessionID, for: openGroupV2.room, on: openGroupV2.server) + if let openGroup = Storage.shared.getOpenGroup(for: message.uniqueThreadId) { + let isUserModerator = OpenGroupManager.isUserModerator(senderSessionID, for: openGroup.room, on: openGroup.server) moderatorIconImageView.isHidden = !isUserModerator || profilePictureView.isHidden } else { moderatorIconImageView.isHidden = true diff --git a/Session/Conversations/Settings/OWSConversationSettingsViewController.m b/Session/Conversations/Settings/OWSConversationSettingsViewController.m index 345a09bc5..9d2e4cb2c 100644 --- a/Session/Conversations/Settings/OWSConversationSettingsViewController.m +++ b/Session/Conversations/Settings/OWSConversationSettingsViewController.m @@ -946,7 +946,7 @@ CGFloat kIconViewLength = 24; - (void)inviteUsersToOpenGroup { NSString *threadID = self.thread.uniqueId; - SNOpenGroupV2 *openGroup = [LKStorage.shared getV2OpenGroupForThreadID:threadID]; + SNOpenGroupV2 *openGroup = [LKStorage.shared getOpenGroupForThreadID:threadID]; NSString *url = [NSString stringWithFormat:@"%@/%@?public_key=%@", openGroup.server, openGroup.room, openGroup.publicKey]; SNUserSelectionVC *userSelectionVC = [[SNUserSelectionVC alloc] initWithTitle:NSLocalizedString(@"vc_conversation_settings_invite_button_title", @"") excluding:[NSSet new] diff --git a/Session/Conversations/Views & Modals/ConversationTitleView.swift b/Session/Conversations/Views & Modals/ConversationTitleView.swift index d80912b29..e8693e87b 100644 --- a/Session/Conversations/Views & Modals/ConversationTitleView.swift +++ b/Session/Conversations/Views & Modals/ConversationTitleView.swift @@ -106,8 +106,8 @@ final class ConversationTitleView : UIView { switch thread.groupModel.groupType { case .closedGroup: userCount = UInt64(thread.groupModel.groupMemberIds.count) case .openGroup: - guard let openGroupV2 = Storage.shared.getV2OpenGroup(for: self.thread.uniqueId!) else { return nil } - userCount = Storage.shared.getUserCount(forV2OpenGroupWithID: openGroupV2.id) + guard let openGroup = Storage.shared.getOpenGroup(for: self.thread.uniqueId!) else { return nil } + userCount = Storage.shared.getUserCount(forOpenGroupWithID: openGroup.id) default: break } if let userCount = userCount { diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 15db7dccc..5ffdcbdcb 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -521,11 +521,11 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv } private func delete(_ thread: TSThread) { - let openGroupV2 = Storage.shared.getV2OpenGroup(for: thread.uniqueId!) + let openGroup = Storage.shared.getOpenGroup(for: thread.uniqueId!) Storage.write { transaction in Storage.shared.cancelPendingMessageSendJobs(for: thread.uniqueId!, using: transaction) - if let openGroupV2 = openGroupV2 { - OpenGroupManager.shared.delete(openGroupV2, associatedWith: thread, using: transaction) + if let openGroup = openGroup { + OpenGroupManager.shared.delete(openGroup, associatedWith: thread, using: transaction) } else if let thread = thread as? TSGroupThread, thread.isClosedGroup == true { let groupID = thread.groupModel.groupId let groupPublicKey = LKGroupUtilities.getDecodedGroupID(groupID) diff --git a/Session/Utilities/BackgroundPoller.swift b/Session/Utilities/BackgroundPoller.swift index 7fb5524c6..b0bf06175 100644 --- a/Session/Utilities/BackgroundPoller.swift +++ b/Session/Utilities/BackgroundPoller.swift @@ -14,7 +14,7 @@ public final class BackgroundPoller: NSObject { .appending(pollForMessages()) .appending(pollForClosedGroupMessages()) .appending( - Set(Storage.shared.getAllV2OpenGroups().values.map { $0.server }) + Set(Storage.shared.getAllOpenGroups().values.map { $0.server }) .map { server in let poller = OpenGroupAPI.Poller(for: server) poller.stop() diff --git a/Session/Utilities/MentionUtilities.swift b/Session/Utilities/MentionUtilities.swift index f0f5be6c8..7bf6989be 100644 --- a/Session/Utilities/MentionUtilities.swift +++ b/Session/Utilities/MentionUtilities.swift @@ -9,7 +9,7 @@ public final class MentionUtilities : NSObject { } @objc public static func highlightMentions(in string: String, isOutgoingMessage: Bool, threadID: String, attributes: [NSAttributedString.Key:Any]) -> NSAttributedString { - let openGroupV2 = Storage.shared.getV2OpenGroup(for: threadID) + let openGroup = Storage.shared.getOpenGroup(for: threadID) OWSPrimaryStorage.shared().dbReadConnection.read { transaction in MentionsManager.populateUserPublicKeyCacheIfNeeded(for: threadID, in: transaction) } @@ -22,7 +22,7 @@ public final class MentionUtilities : NSObject { let publicKey = String((string as NSString).substring(with: match.range).dropFirst()) // Drop the @ let matchEnd: Int if knownPublicKeys.contains(publicKey) { - let context: Contact.Context = (openGroupV2 != nil) ? .openGroup : .regular + let context: Contact.Context = (openGroup != nil) ? .openGroup : .regular let displayName = Storage.shared.getContact(with: publicKey)?.displayName(for: context) if let displayName = displayName { string = (string as NSString).replacingCharacters(in: match.range, with: "@\(displayName)") diff --git a/SessionMessagingKit/Common Networking/Header.swift b/SessionMessagingKit/Common Networking/Header.swift index 56b37c988..41f8ad82c 100644 --- a/SessionMessagingKit/Common Networking/Header.swift +++ b/SessionMessagingKit/Common Networking/Header.swift @@ -12,7 +12,7 @@ enum Header: String { case sogsPubKey = "X-SOGS-Pubkey" case sogsNonce = "X-SOGS-Nonce" case sogsTimestamp = "X-SOGS-Timestamp" - case sogsHash = "X-SOGS-Hash" + case sogsSignature = "X-SOGS-Signature" } // MARK: - Convenience diff --git a/SessionMessagingKit/Database/Storage+Messaging.swift b/SessionMessagingKit/Database/Storage+Messaging.swift index 0035217f2..15b7b3664 100644 --- a/SessionMessagingKit/Database/Storage+Messaging.swift +++ b/SessionMessagingKit/Database/Storage+Messaging.swift @@ -7,7 +7,7 @@ extension Storage { let transaction = transaction as! YapDatabaseReadWriteTransaction var threadOrNil: TSThread? if let openGroupID = openGroupID { - if let threadID = Storage.shared.v2GetThreadID(for: openGroupID), + if let threadID = Storage.shared.getThreadID(for: openGroupID), let thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction) { threadOrNil = thread } diff --git a/SessionMessagingKit/Database/Storage+OpenGroups.swift b/SessionMessagingKit/Database/Storage+OpenGroups.swift index 9ef34143b..07c8228a0 100644 --- a/SessionMessagingKit/Database/Storage+OpenGroups.swift +++ b/SessionMessagingKit/Database/Storage+OpenGroups.swift @@ -1,46 +1,35 @@ -public protocol SessionMessagingKitOpenGroupStorageProtocol { - func getOpenGroupImage(for room: String, on server: String) -> Data? - func setOpenGroupImage(to data: Data, for room: String, on server: String, using transaction: Any) - - func getV2OpenGroup(for threadID: String) -> OpenGroupV2? - func setV2OpenGroup(_ openGroup: OpenGroupV2, for threadID: String, using transaction: Any) - - func getUserCount(forV2OpenGroupWithID openGroupID: String) -> UInt64? - func setUserCount(to newValue: UInt64, forV2OpenGroupWithID openGroupID: String, using transaction: Any) -} - -extension Storage: SessionMessagingKitOpenGroupStorageProtocol { +extension Storage { // MARK: - Open Groups private static let openGroupCollection = "SNOpenGroupCollection" - @objc public func getAllV2OpenGroups() -> [String:OpenGroupV2] { - var result = [String:OpenGroupV2]() + @objc public func getAllOpenGroups() -> [String: OpenGroup] { + var result = [String: OpenGroup]() Storage.read { transaction in transaction.enumerateKeysAndObjects(inCollection: Storage.openGroupCollection) { threadID, object, _ in - guard let openGroup = object as? OpenGroupV2 else { return } + guard let openGroup = object as? OpenGroup else { return } result[threadID] = openGroup } } return result } - @objc(getV2OpenGroupForThreadID:) - public func getV2OpenGroup(for threadID: String) -> OpenGroupV2? { - var result: OpenGroupV2? + @objc(getOpenGroupForThreadID:) + public func getOpenGroup(for threadID: String) -> OpenGroup? { + var result: OpenGroup? Storage.read { transaction in - result = transaction.object(forKey: threadID, inCollection: Storage.openGroupCollection) as? OpenGroupV2 + result = transaction.object(forKey: threadID, inCollection: Storage.openGroupCollection) as? OpenGroup } return result } - public func v2GetThreadID(for v2OpenGroupID: String) -> String? { + public func getThreadID(for openGroupID: String) -> String? { var result: String? Storage.read { transaction in transaction.enumerateKeysAndObjects(inCollection: Storage.openGroupCollection, using: { threadID, object, stop in - guard let openGroup = object as? OpenGroupV2, openGroup.id == v2OpenGroupID else { return } + guard let openGroup = object as? OpenGroup, openGroup.id == openGroupID else { return } result = threadID stop.pointee = true }) @@ -48,17 +37,27 @@ extension Storage: SessionMessagingKitOpenGroupStorageProtocol { return result } - @objc(setV2OpenGroup:forThreadWithID:using:) - public func setV2OpenGroup(_ openGroup: OpenGroupV2, for threadID: String, using transaction: Any) { + @objc(setOpenGroup:forThreadWithID:using:) + public func setOpenGroup(_ openGroup: OpenGroup, for threadID: String, using transaction: Any) { (transaction as! YapDatabaseReadWriteTransaction).setObject(openGroup, forKey: threadID, inCollection: Storage.openGroupCollection) } - @objc(removeV2OpenGroupForThreadID:using:) - public func removeV2OpenGroup(for threadID: String, using transaction: Any) { + @objc(removeOpenGroupForThreadID:using:) + public func removeOpenGroup(for threadID: String, using transaction: Any) { (transaction as! YapDatabaseReadWriteTransaction).removeObject(forKey: threadID, inCollection: Storage.openGroupCollection) } + public func getOpenGroupServer(name: String) -> OpenGroupAPI.Server? { + var result: OpenGroupAPI.Server? + Storage.read { transaction in + result = transaction.object(forKey: "SOGS.\(name)", inCollection: Storage.openGroupCollection) as? OpenGroupAPI.Server + } + return result + } + public func storeOpenGroupServer(_ server: OpenGroupAPI.Server, using transaction: Any) { + (transaction as! YapDatabaseReadWriteTransaction).setObject(server, forKey: "SOGS.\(server.name)", inCollection: Storage.openGroupCollection) + } // MARK: - Authorization @@ -171,7 +170,7 @@ extension Storage: SessionMessagingKitOpenGroupStorageProtocol { private static let openGroupUserCountCollection = "SNOpenGroupUserCountCollection" private static let openGroupImageCollection = "SNOpenGroupImageCollection" - public func getUserCount(forV2OpenGroupWithID openGroupID: String) -> UInt64? { + public func getUserCount(forOpenGroupWithID openGroupID: String) -> UInt64? { var result: UInt64? Storage.read { transaction in result = transaction.object(forKey: openGroupID, inCollection: Storage.openGroupUserCountCollection) as? UInt64 @@ -179,7 +178,7 @@ extension Storage: SessionMessagingKitOpenGroupStorageProtocol { return result } - public func setUserCount(to newValue: UInt64, forV2OpenGroupWithID openGroupID: String, using transaction: Any) { + public func setUserCount(to newValue: UInt64, forOpenGroupWithID openGroupID: String, using transaction: Any) { (transaction as! YapDatabaseReadWriteTransaction).setObject(newValue, forKey: openGroupID, inCollection: Storage.openGroupUserCountCollection) } diff --git a/SessionMessagingKit/File Server/FileServerAPIV2.swift b/SessionMessagingKit/File Server/FileServerAPIV2.swift index 4e18259a7..7ea5ea93f 100644 --- a/SessionMessagingKit/File Server/FileServerAPIV2.swift +++ b/SessionMessagingKit/File Server/FileServerAPIV2.swift @@ -96,7 +96,11 @@ public final class FileServerAPIV2 : NSObject { // TODO: Upgrade this to use the V4 onion requests once supported. return OnionRequestAPI.sendOnionRequest(urlRequest, to: server, using: .v3, with: serverPublicKey) - .map2 { json in try JSONSerialization.data(withJSONObject: json, options: []) } + .map2 { _, response in + guard let response: Data = response else { throw Error.parsingFailed } + + return response + } } // MARK: File Storage diff --git a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift index 30842c939..15d821fb7 100644 --- a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift @@ -97,12 +97,12 @@ public final class AttachmentDownloadJob : NSObject, Job, NSCoding { // NSObject self.handleFailure(error: error) } } - if let tsMessage = TSMessage.fetch(uniqueId: tsMessageID), let v2OpenGroup = storage.getV2OpenGroup(for: tsMessage.uniqueThreadId) { + if let tsMessage = TSMessage.fetch(uniqueId: tsMessageID), let openGroup = storage.getOpenGroup(for: tsMessage.uniqueThreadId) { guard let fileAsString = pointer.downloadURL.split(separator: "/").last, let file = UInt64(fileAsString) else { return handleFailure(Error.invalidURL) } // TODO: Upgrade this to use the non-legacy version - OpenGroupAPI.legacyDownload(file, from: v2OpenGroup.room, on: v2OpenGroup.server).done(on: DispatchQueue.global(qos: .userInitiated)) { data in + OpenGroupAPI.legacyDownload(file, from: openGroup.room, on: openGroup.server).done(on: DispatchQueue.global(qos: .userInitiated)) { data in self.handleDownloadedAttachment(data: data, temporaryFilePath: temporaryFilePath, pointer: pointer, failureHandler: handleFailure) }.catch(on: DispatchQueue.global()) { error in handleFailure(error) diff --git a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift index 0630cef7c..425d1c18c 100644 --- a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift @@ -68,12 +68,12 @@ public final class AttachmentUploadJob : NSObject, Job, NSCoding { // NSObject/N } guard !stream.isUploaded else { return handleSuccess() } // Should never occur let storage = SNMessagingKitConfiguration.shared.storage - if let v2OpenGroup = storage.getV2OpenGroup(for: threadID) { + if let openGroup = storage.getOpenGroup(for: threadID) { AttachmentUploadJob.upload( stream, using: { data in // TODO: Upgrade this to use the non-legacy version - return OpenGroupAPI.legacyUpload(data, to: v2OpenGroup.room, on: v2OpenGroup.server) + return OpenGroupAPI.legacyUpload(data, to: openGroup.room, on: openGroup.server) }, encrypt: false, onSuccess: handleSuccess, diff --git a/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift b/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift index 491054ac8..ec871f0f8 100644 --- a/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift +++ b/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift @@ -39,8 +39,8 @@ extension ConfigurationMessage { closedGroups.insert(closedGroup) case .openGroup: - if let v2OpenGroup = storage.getV2OpenGroup(for: thread.uniqueId!) { - openGroups.insert("\(v2OpenGroup.server)/\(v2OpenGroup.room)?public_key=\(v2OpenGroup.publicKey)") + if let openGroup = storage.getOpenGroup(for: thread.uniqueId!) { + openGroups.insert("\(openGroup.server)/\(openGroup.room)?public_key=\(openGroup.publicKey)") } default: break diff --git a/SessionMessagingKit/Messages/Message+Destination.swift b/SessionMessagingKit/Messages/Message+Destination.swift index 019ad1dd1..ebc52d6a4 100644 --- a/SessionMessagingKit/Messages/Message+Destination.swift +++ b/SessionMessagingKit/Messages/Message+Destination.swift @@ -25,7 +25,7 @@ public extension Message { } if let thread = thread as? TSGroupThread, thread.isOpenGroup { - let openGroup: OpenGroupV2 = Storage.shared.getV2OpenGroup(for: thread.uniqueId!)! + let openGroup: OpenGroup = Storage.shared.getOpenGroup(for: thread.uniqueId!)! return .openGroup(roomToken: openGroup.room, server: openGroup.server) } diff --git a/SessionMessagingKit/Open Groups/Models/Capabilities.swift b/SessionMessagingKit/Open Groups/Models/Capabilities.swift index 1e132ed11..deafcd0a5 100644 --- a/SessionMessagingKit/Open Groups/Models/Capabilities.swift +++ b/SessionMessagingKit/Open Groups/Models/Capabilities.swift @@ -4,12 +4,13 @@ import Foundation extension OpenGroupAPI { public struct Capabilities: Codable { - public enum Capability: CaseIterable, Codable { + public enum Capability: Equatable, CaseIterable, Codable { public static var allCases: [Capability] { - [.pysogs] + [.sogs, .blinding] } - case pysogs + case sogs + case blinding // TODO: Get official name /// Fallback case if the capability isn't supported by this version of the app case unsupported(String) @@ -25,9 +26,7 @@ extension OpenGroupAPI { // MARK: - Codable - public init(from decoder: Decoder) throws { - let container: SingleValueDecodingContainer = try decoder.singleValueContainer() - let valueString: String = try container.decode(String.self) + public init(from valueString: String) { let maybeValue: Capability? = Capability.allCases.first { $0.rawValue == valueString } self = (maybeValue ?? .unsupported(valueString)) @@ -38,3 +37,14 @@ extension OpenGroupAPI { public let missing: [Capability]? } } + +extension OpenGroupAPI.Capabilities.Capability { + // MARK: - Codable + + public init(from decoder: Decoder) throws { + let container: SingleValueDecodingContainer = try decoder.singleValueContainer() + let valueString: String = try container.decode(String.self) + + self = OpenGroupAPI.Capabilities.Capability(from: valueString) + } +} diff --git a/SessionMessagingKit/Open Groups/Models/OpenGroupV2.swift b/SessionMessagingKit/Open Groups/Models/OpenGroup.swift similarity index 54% rename from SessionMessagingKit/Open Groups/Models/OpenGroupV2.swift rename to SessionMessagingKit/Open Groups/Models/OpenGroup.swift index a95caee8d..d954bdf78 100644 --- a/SessionMessagingKit/Open Groups/Models/OpenGroupV2.swift +++ b/SessionMessagingKit/Open Groups/Models/OpenGroup.swift @@ -1,42 +1,66 @@ import Sodium import SessionUtilitiesKit +// FIXME: We need to leave the @objc name as `SNOpenGroupV2` otherwise YapDatabase won't be able to decode it @objc(SNOpenGroupV2) -public final class OpenGroupV2 : NSObject, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility +public final class OpenGroup: NSObject, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility @objc public let server: String @objc public let room: String public let id: String - @objc public let name: String + @objc public let publicKey: String + @objc public let name: String + @objc public let groupDescription: String? // API key is 'description' + /// The ID with which the image can be retrieved from the server. public let imageID: String? + + /// Monotonic room information counter that increases each time the room's metadata changes + public let infoUpdates: Int64 - public init(server: String, room: String, name: String, publicKey: String, imageID: String?) { + public init( + server: String, + room: String, + publicKey: String, + name: String, + groupDescription: String?, + imageID: String?, + infoUpdates: Int64 + ) { self.server = server.lowercased() self.room = room self.id = "\(server).\(room)" - self.name = name self.publicKey = publicKey + self.name = name + self.groupDescription = groupDescription self.imageID = imageID + self.infoUpdates = infoUpdates } - // MARK: Coding + // MARK: - Coding + public init?(coder: NSCoder) { server = coder.decodeObject(forKey: "server") as! String room = coder.decodeObject(forKey: "room") as! String self.id = "\(server).\(room)" - name = coder.decodeObject(forKey: "name") as! String + publicKey = coder.decodeObject(forKey: "publicKey") as! String + name = coder.decodeObject(forKey: "name") as! String + groupDescription = coder.decodeObject(forKey: "groupDescription") as? String imageID = coder.decodeObject(forKey: "imageID") as! String? + infoUpdates = ((coder.decodeObject(forKey: "infoUpdates") as? Int64) ?? 0) + super.init() } public func encode(with coder: NSCoder) { coder.encode(server, forKey: "server") coder.encode(room, forKey: "room") - coder.encode(name, forKey: "name") coder.encode(publicKey, forKey: "publicKey") + coder.encode(name, forKey: "name") + if let groupDescription = groupDescription { coder.encode(groupDescription, forKey: "groupDescription") } if let imageID = imageID { coder.encode(imageID, forKey: "imageID") } + coder.encode(infoUpdates, forKey: "infoUpdates") } override public var description: String { "\(name) (Server: \(server), Room: \(room)" } diff --git a/SessionMessagingKit/Open Groups/Models/Server.swift b/SessionMessagingKit/Open Groups/Models/Server.swift new file mode 100644 index 000000000..019939d15 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/Server.swift @@ -0,0 +1,46 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium +import SessionUtilitiesKit + +extension OpenGroupAPI { + @objc(SOGSServer) + public final class Server: NSObject, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility + @objc public let name: String + public let capabilities: Capabilities + + public init( + name: String, + capabilities: Capabilities + ) { + self.name = name.lowercased() + self.capabilities = capabilities + } + + // MARK: - Coding + + public init?(coder: NSCoder) { + let capabilitiesString: [String] = coder.decodeObject(forKey: "capabilities") as! [String] + let missingCapabilitiesString: [String]? = coder.decodeObject(forKey: "missingCapabilities") as? [String] + + name = coder.decodeObject(forKey: "name") as! String + capabilities = Capabilities( + capabilities: capabilitiesString.map { Capabilities.Capability(from: $0) }, + missing: missingCapabilitiesString?.map { Capabilities.Capability(from: $0) } + ) + + super.init() + } + + public func encode(with coder: NSCoder) { + coder.encode(name, forKey: "name") + coder.encode(capabilities.capabilities.map { $0.rawValue }, forKey: "capabilities") + coder.encode(capabilities.missing?.map { $0.rawValue }, forKey: "missingCapabilities") + } + + override public var description: String { + "\(name) (Capabilities: [\(capabilities.capabilities.map { $0.rawValue }.joined(separator: ", "))], Missing: [\((capabilities.missing ?? []).map { $0.rawValue }.joined(separator: ", "))])" + } + } +} diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index a077e01fd..7009ae873 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -62,8 +62,8 @@ public final class OpenGroupAPI: NSObject { ) ] .appending( - dependencies.storage.getAllV2OpenGroups().values - .filter { $0.server == server.lowercased() } // Note: The `OpenGroupV2` type converts to lowercase in init + dependencies.storage.getAllOpenGroups().values + .filter { $0.server == server.lowercased() } // Note: The `OpenGroup` type converts to lowercase in init .flatMap { openGroup -> [BatchRequestInfo] in let lastSeqNo: Int64? = dependencies.storage.getLastMessageServerID(for: openGroup.room, on: server) let targetSeqNo: Int64 = (lastSeqNo ?? 0) @@ -219,11 +219,9 @@ public final class OpenGroupAPI: NSObject { on server: String, whisperTo: String?, whisperMods: Bool, - with serverPublicKey: String, using dependencies: Dependencies = Dependencies() ) -> Promise<(OnionRequestResponseInfoType, Message)> { - // TODO: Change this to use '.blinded' once it's working. - guard let signedMessage: (data: Data, signature: Data) = sign(message: plaintext, for: .standard, with: serverPublicKey) else { + guard let signedMessage: (data: Data, signature: Data) = sign(message: plaintext, to: roomToken, on: server, using: dependencies) else { return Promise(error: Error.signingFailed) } @@ -265,11 +263,9 @@ public final class OpenGroupAPI: NSObject { plaintext: Data, in roomToken: String, on server: String, - with serverPublicKey: String, using dependencies: Dependencies = Dependencies() ) -> Promise<(OnionRequestResponseInfoType, Data?)> { - // TODO: Change this to use '.blinded' once it's working. - guard let signedMessage: (data: Data, signature: Data) = sign(message: plaintext, for: .standard, with: serverPublicKey) else { + guard let signedMessage: (data: Data, signature: Data) = sign(message: plaintext, to: roomToken, on: server, using: dependencies) else { return Promise(error: Error.signingFailed) } @@ -453,15 +449,14 @@ public final class OpenGroupAPI: NSObject { .decoded(as: [DirectMessage].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) } - public static func sendMessageRequest(_ plaintext: Data, to sessionId: String, on server: String, with serverPublicKey: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage])> { - // TODO: Change this to use '.blinded' once it's working - guard let signedMessage: (data: Data, signature: Data) = sign(message: plaintext, for: .standard, with: serverPublicKey) else { + public static func sendMessageRequest(_ plaintext: Data, to blindedSessionId: String, on server: String, with serverPublicKey: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage])> { + guard let signedMessage: Data = sign(message: plaintext, to: blindedSessionId, on: server, with: serverPublicKey, using: dependencies) else { return Promise(error: Error.signingFailed) } let requestBody: SendDirectMessageRequest = SendDirectMessageRequest( - data: signedMessage.data, - signature: signedMessage.signature + data: signedMessage +// signature: signedMessage.signature // TODO: Confirm whether this needs a signature?? ) guard let body: Data = try? JSONEncoder().encode(requestBody) else { @@ -471,7 +466,7 @@ public final class OpenGroupAPI: NSObject { let request: Request = Request( method: .post, server: server, - endpoint: .inboxFor(sessionId: sessionId), + endpoint: .inboxFor(sessionId: blindedSessionId), body: body ) @@ -591,12 +586,31 @@ public final class OpenGroupAPI: NSObject { // MARK: - Authentication - public static func sign(message: Data, for idType: IdPrefix, with publicKey: String, using dependencies: Dependencies = Dependencies()) -> (data: Data, signature: Data)? { - guard let userKeyPair: ECKeyPair = dependencies.storage.getUserKeyPair() else { - return nil + /// Sign a message to be sent to SOGS (handles both un-blinded and blinded signing based on the server capabilities) + public static func sign(message: Data, to roomToken: String, on serverName: String, using dependencies: Dependencies = Dependencies()) -> (data: Data, signature: Data)? { + let server: Server? = dependencies.storage.getOpenGroupServer(name: serverName) + let targetKeyPair: ECKeyPair + + // Determine if we want to sign using standard or blinded keys based on the server capabilities (assume + // unblinded if we have none) + // TODO: Remove this (blinding will be required) + if server?.capabilities.capabilities.contains(.blinding) == true { + // TODO: Validate this 'openGroupId' is correct for the 'getOpenGroup' call + let openGroupId: String = "\(serverName).\(roomToken)" + + // TODO: Validate this is the correct logic (Most likely not) + guard let openGroup: OpenGroup = Storage.shared.getOpenGroup(for: openGroupId) else { return nil } + guard let userEdKeyPair: Box.KeyPair = dependencies.storage.getUserED25519KeyPair() else { return nil } + guard let blindedKeyPair: Box.KeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: openGroup.publicKey, edKeyPair: userEdKeyPair, genericHash: dependencies.genericHash) else { + return nil + } + + targetKeyPair = blindedKeyPair } - guard let targetKeyPair: ECKeyPair = try? userKeyPair.convert(to: idType, with: publicKey) else { - return nil + else { + guard let userKeyPair: ECKeyPair = dependencies.storage.getUserKeyPair() else { return nil } + + targetKeyPair = userKeyPair } guard let signature = try? Ed25519.sign(message, with: targetKeyPair) else { @@ -607,7 +621,43 @@ public final class OpenGroupAPI: NSObject { return (message, signature) } - private static func sign(_ request: URLRequest, with publicKey: String, using dependencies: Dependencies = Dependencies()) -> URLRequest? { + /// Sign a blinded message request to be sent to a users inbox via SOGS v4 + private static func sign(message: Data, to blindedSessionId: String, on serverName: String, with serverPublicKey: String, using dependencies: Dependencies = Dependencies()) -> Data? { + guard let userEdKeyPair: Box.KeyPair = dependencies.storage.getUserED25519KeyPair() else { return nil } + guard let blindedKeyPair: BlindedECKeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: serverPublicKey, edKeyPair: userEdKeyPair, genericHash: dependencies.genericHash) else { + return nil + } + guard let blindedRecipientPublicKey: Data = String(blindedSessionId.suffix(from: blindedSessionId.index(blindedSessionId.startIndex, offsetBy: IdPrefix.blinded.rawValue.count))).dataFromHex() else { + return nil + } + + /// Generate the sharedSecret by "a kB || kA || kB" where + /// a, A are the users private and public keys respectively, + /// kA is the users blinded public key + /// kB is the recipients blinded public key + let maybeSharedSecret: Data? = dependencies.sodium + .sharedEdSecret(userEdKeyPair.secretKey, blindedRecipientPublicKey.bytes)? + .appending(blindedKeyPair.publicKey.bytes) + .appending(blindedRecipientPublicKey.bytes) + + guard let sharedSecret: Data = maybeSharedSecret else { return nil } + guard let intermediateHash: Bytes = dependencies.genericHash.hash(message: sharedSecret.bytes) else { return nil } + + /// Generate the inner message by "message || A" where + /// A is the sender's ed25519 master pubkey (**not** kA blinded pubkey) + let innerMessage: Bytes = (message.bytes + userEdKeyPair.publicKey) + guard let (ciphertext, nonce) = dependencies.aeadXChaCha20Poly1305Ietf.encrypt(message: innerMessage, secretKey: intermediateHash) else { + return nil + } + + /// Generate the final data by "b'\x00' + ciphertext + nonce" + let finalData: Bytes = [0] + ciphertext + nonce + + return Data(finalData) + } + + /// Sign a request to be sent to SOGS (handles both un-blinded and blinded signing based on the server capabilities) + private static func sign(_ request: URLRequest, for serverName: String, with serverPublicKey: String, using dependencies: Dependencies = Dependencies()) -> URLRequest? { guard let url: URL = request.url else { return nil } var updatedRequest: URLRequest = request @@ -616,55 +666,78 @@ public final class OpenGroupAPI: NSObject { let method: String = (request.httpMethod ?? "GET") let timestamp: Int = Int(floor(dependencies.date.timeIntervalSince1970)) let nonce: Data = Data(dependencies.nonceGenerator.nonce()) + let server: Server? = dependencies.storage.getOpenGroupServer(name: serverName) + let userPublicKeyHex: String + let signatureBytes: Bytes - guard let publicKeyData: Data = publicKey.dataFromHex() else { return nil } - guard let userKeyPair: ECKeyPair = dependencies.storage.getUserKeyPair() else { - return nil - } -// guard let blindedKeyPair: ECKeyPair = try? userKeyPair.convert(to: .blinded, with: publicKey) else { -// return nil -// } - // TODO: Change this back once you figure out why it's busted. - let blindedKeyPair: ECKeyPair = userKeyPair + guard let serverPublicKeyData: Data = serverPublicKey.dataFromHex() else { return nil } + guard let timestampBytes: Bytes = "\(timestamp)".data(using: .ascii)?.bytes else { return nil } + guard let userEdKeyPair: Box.KeyPair = dependencies.storage.getUserED25519KeyPair() else { return nil } - /// Generate the sharedSecret by "aB || A || B" where - /// a, A are the users private and public keys respectively, - /// B is the SOGS public key - let maybeSharedSecret: Data? = dependencies.sodium.sharedSecret(blindedKeyPair.privateKey.bytes, publicKeyData.bytes)? - .appending(blindedKeyPair.publicKey) - .appending(publicKeyData.bytes) + /// Get a hash of any body content + let bodyHash: Bytes? = { + // Note: We need the `!body.isEmpty` check because of the default `Data()` value when trying to + // init data from the httpBodyStream + guard let body: Data = (request.httpBody ?? request.httpBodyStream.map { ((try? Data(from: $0)) ?? Data()) }), !body.isEmpty else { + return nil + } + + return dependencies.genericHash.hash(message: body.bytes, outputLength: 64) + }() - /// Generate the hash to be sent along with the request - /// intermediateHash = Blake2B(sharedSecret, size=42, salt=noncebytes, person='sogs.shared_keys') - /// secretHash = Blake2B( - /// Method || Path || Timestamp || Body, - /// size=42, - /// key=r, - /// salt=noncebytes, - /// person='sogs.auth_header' - /// ) - let secretHashMessage: Bytes = method.bytes + /// Generate the signature message + /// "ServerPubkey || Nonce || Timestamp || Method || Path || Blake2b Hash(Body) + /// `ServerPubkey` + /// `Nonce` + /// `Timestamp` is the bytes of an ascii decimal string + /// `Method` + /// `Path` + /// `Body` is a Blake2b hash of the data (if there is a body) + let signatureMessageBytes: Bytes = serverPublicKeyData.bytes + .appending(nonce.bytes) + .appending(timestampBytes) + .appending(method.bytes) .appending(path.bytes) - .appending("\(timestamp)".bytes) - .appending(request.httpBody?.bytes ?? []) // TODO: Might need to do the 'httpBodyStream' as well???. - print("RAWR 1 \(blindedKeyPair.hexEncodedPublicKey)") - print("RAWR 2 \(maybeSharedSecret?.hexadecimalString)") - print("RAWR '\(String(describing: String(data: Data(secretHashMessage), encoding: .utf8)))'") - guard let sharedSecret: Data = maybeSharedSecret else { return nil } - guard let intermediateHash: Bytes = dependencies.genericHash.hashSaltPersonal(message: sharedSecret.bytes, outputLength: 42, key: nil, salt: nonce.bytes, personal: Personalization.sharedKeys.bytes) else { - return nil + .appending(bodyHash ?? []) + + // Determine if we want to sign using standard or blinded keys based on the server capabilities (assume + // unblinded if we have none) + // TODO: Remove this (blinding will be required) + if server?.capabilities.capabilities.contains(.blinding) == true { + // TODO: More testing of this blinded id signing (though it seems to be working!!!) + guard let blindedKeyPair: Box.KeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: serverPublicKey, edKeyPair: userEdKeyPair, genericHash: dependencies.genericHash) else { + return nil + } + + userPublicKeyHex = IdPrefix.blinded.hexEncodedPublicKey(for: blindedKeyPair.publicKey) + + guard let signatureResult: Bytes = Sodium().sogsSignature(message: signatureMessageBytes, secretKey: userEdKeyPair.secretKey, blindedSecretKey: blindedKeyPair.secretKey, blindedPublicKey: blindedKeyPair.publicKey) else { + return nil + } + + signatureBytes = signatureResult } - guard let secretHash: Bytes = dependencies.genericHash.hashSaltPersonal(message: secretHashMessage, outputLength: 42, key: intermediateHash, salt: nonce.bytes, personal: Personalization.authHeader.bytes) else { - return nil + else { + userPublicKeyHex = IdPrefix.unblinded.hexEncodedPublicKey(for: userEdKeyPair.publicKey) + + // TODO: shift this to dependencies + guard let signatureResult: Bytes = Sodium().sign.signature(message: signatureMessageBytes, secretKey: userEdKeyPair.secretKey) else { + return nil + } + + signatureBytes = signatureResult } - print("RAWR3 '\(intermediateHash.toHexString())'") // This is the one we can compare - print("RAWR4 '\(secretHash.toHexString())'") + + print("RAWR X-SOGS-Pubkey: \(userPublicKeyHex)") + print("RAWR X-SOGS-Timestamp: \(timestamp)") + print("RAWR X-SOGS-Nonce: \(nonce.base64EncodedString())") + print("RAWR X-SOGS-Signature: \(signatureBytes.toBase64())") updatedRequest.allHTTPHeaderFields = (request.allHTTPHeaderFields ?? [:]) .updated(with: [ - Header.sogsPubKey.rawValue: blindedKeyPair.hexEncodedPublicKey, + Header.sogsPubKey.rawValue: userPublicKeyHex, Header.sogsTimestamp.rawValue: "\(timestamp)", Header.sogsNonce.rawValue: nonce.base64EncodedString(), - Header.sogsHash.rawValue: secretHash.toBase64() + Header.sogsSignature.rawValue: signatureBytes.toBase64() ]) return updatedRequest @@ -683,13 +756,13 @@ public final class OpenGroupAPI: NSObject { urlRequest.httpBody = request.body if request.useOnionRouting { - guard let publicKey = dependencies.storage.getOpenGroupPublicKey(for: request.server) else { + guard let publicKey = SNMessagingKitConfiguration.shared.storage.getOpenGroupPublicKey(for: request.server) else { return Promise(error: Error.noPublicKey) } if request.isAuthRequired { // Attempt to sign the request with the new auth - guard let signedRequest: URLRequest = sign(urlRequest, with: publicKey, using: dependencies) else { + guard let signedRequest: URLRequest = sign(urlRequest, for: request.server, with: publicKey, using: dependencies) else { return Promise(error: Error.signingFailed) } @@ -826,7 +899,7 @@ public final class OpenGroupAPI: NSObject { @available(*, deprecated, message: "Use poll or batch instead") public static func legacyCompactPoll(_ server: String) -> Promise { let storage: SessionMessagingKitStorageProtocol = SNMessagingKitConfiguration.shared.storage - let rooms: [String] = storage.getAllV2OpenGroups().values + let rooms: [String] = storage.getAllOpenGroups().values .filter { $0.server == server } .map { $0.room } var getAuthTokenPromises: [String: Promise] = [:] @@ -1037,7 +1110,7 @@ public final class OpenGroupAPI: NSObject { let storage = SNMessagingKitConfiguration.shared.storage storage.write { transaction in - storage.setUserCount(to: response.memberCount, forV2OpenGroupWithID: "\(server).\(room)", using: transaction) + storage.setUserCount(to: response.memberCount, forOpenGroupWithID: "\(server).\(room)", using: transaction) } return response.memberCount diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index c6ac5b239..8a5763f74 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -19,7 +19,7 @@ public final class OpenGroupManager: NSObject { guard !isPolling else { return } isPolling = true - pollers = Set(Storage.shared.getAllV2OpenGroups().values.map { $0.server }) + pollers = Set(Storage.shared.getAllOpenGroups().values.map { $0.server }) .reduce(into: [:]) { prev, server in pollers[server]?.stop() // Should never occur @@ -58,8 +58,7 @@ public final class OpenGroupManager: NSObject { room, publicKey: publicKey, for: roomToken, - on: server, - isBackgroundPoll: false + on: server ) { seal.fulfill(()) } @@ -72,11 +71,11 @@ public final class OpenGroupManager: NSObject { return promise } - public func delete(_ openGroup: OpenGroupV2, associatedWith thread: TSThread, using transaction: YapDatabaseReadWriteTransaction) { + public func delete(_ openGroup: OpenGroup, associatedWith thread: TSThread, using transaction: YapDatabaseReadWriteTransaction) { let storage = SNMessagingKitConfiguration.shared.storage // Stop the poller if needed - let openGroups = storage.getAllV2OpenGroups().values.filter { $0.server == openGroup.server } + let openGroups = storage.getAllOpenGroups().values.filter { $0.server == openGroup.server } if openGroups.count == 1 && openGroups.last == openGroup { let poller = pollers[openGroup.server] poller?.stop() @@ -97,7 +96,7 @@ public final class OpenGroupManager: NSObject { let _ = OpenGroupAPI.legacyDeleteAuthToken(for: openGroup.room, on: openGroup.server) thread.removeAllThreadInteractions(with: transaction) thread.remove(with: transaction) - Storage.shared.removeV2OpenGroup(for: thread.uniqueId!, using: transaction) + Storage.shared.removeOpenGroup(for: thread.uniqueId!, using: transaction) // Only remove the open group public key if the user isn't in any other rooms if openGroups.count <= 1 { @@ -107,6 +106,21 @@ public final class OpenGroupManager: NSObject { // MARK: - Response Processing + internal static func handleCapabilities( + _ capabilities: OpenGroupAPI.Capabilities, + on server: String, + using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies() + ) { + dependencies.storage.write { transaction in + let updatedServer: OpenGroupAPI.Server = OpenGroupAPI.Server( + name: server, + capabilities: capabilities + ) + + dependencies.storage.storeOpenGroupServer(updatedServer, using: transaction) + } + } + internal static func handleMessages( _ messages: [OpenGroupAPI.Message], for roomToken: String, @@ -154,7 +168,7 @@ public final class OpenGroupManager: NSObject { // Handle any deletions that are needed guard !messageServerIDsToRemove.isEmpty else { return } guard let transaction: YapDatabaseReadWriteTransaction = transaction as? YapDatabaseReadWriteTransaction else { return } - guard let threadID = dependencies.storage.v2GetThreadID(for: openGroupID), let thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction) else { + guard let threadID = dependencies.storage.getThreadID(for: openGroupID), let thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction) else { return } @@ -174,7 +188,6 @@ public final class OpenGroupManager: NSObject { publicKey: String, for roomToken: String, on server: String, - isBackgroundPoll: Bool, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies(), completion: (() -> ())? = nil ) { @@ -183,7 +196,6 @@ public final class OpenGroupManager: NSObject { publicKey: publicKey, for: roomToken, on: server, - isBackgroundPoll: isBackgroundPoll, using: dependencies, completion: completion ) @@ -194,7 +206,6 @@ public final class OpenGroupManager: NSObject { publicKey maybePublicKey: String?, for roomToken: String, on server: String, - isBackgroundPoll: Bool, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies(), completion: (() -> ())? = nil ) { @@ -215,8 +226,8 @@ public final class OpenGroupManager: NSObject { with: { transaction in let transaction = transaction as! YapDatabaseReadWriteTransaction let thread = TSGroupThread.getOrCreateThread(with: initialModel, transaction: transaction) - let existingOpenGroup: OpenGroupV2? = thread.uniqueId.flatMap { uniqueId -> OpenGroupV2? in - dependencies.storage.getV2OpenGroup(for: uniqueId) + let existingOpenGroup: OpenGroup? = thread.uniqueId.flatMap { uniqueId -> OpenGroup? in + dependencies.storage.getOpenGroup(for: uniqueId) } guard let threadUniqueId: String = thread.uniqueId else { return } @@ -230,7 +241,7 @@ public final class OpenGroupManager: NSObject { groupType: .openGroup, adminIds: (pollInfo.admins ?? thread.groupModel.groupAdminIds) ) - let updatedOpenGroup: OpenGroupV2 = OpenGroupV2( + let updatedOpenGroup: OpenGroup = OpenGroup( server: server, room: (pollInfo.token ?? roomToken), publicKey: publicKey, @@ -239,7 +250,7 @@ public final class OpenGroupManager: NSObject { imageID: (pollInfo.imageId.map { "\($0)" } ?? existingOpenGroup?.imageID), infoUpdates: ((pollInfo.infoUpdates ?? existingOpenGroup?.infoUpdates) ?? 0) ) - let existingUserCount: UInt64? = dependencies.storage.getUserCount(forV2OpenGroupWithID: updatedOpenGroup.id) + let existingUserCount: UInt64? = dependencies.storage.getUserCount(forOpenGroupWithID: updatedOpenGroup.id) // - Thread changes thread.shouldBeVisible = true @@ -247,12 +258,12 @@ public final class OpenGroupManager: NSObject { thread.save(with: transaction) // - Open Group changes - dependencies.storage.setV2OpenGroup(updatedOpenGroup, for: threadUniqueId, using: transaction) + dependencies.storage.setOpenGroup(updatedOpenGroup, for: threadUniqueId, using: transaction) // - User Count dependencies.storage.setUserCount( to: ((pollInfo.activeUsers.map { UInt64($0) } ?? existingUserCount) ?? 0), - forV2OpenGroupWithID: updatedOpenGroup.id, + forOpenGroupWithID: updatedOpenGroup.id, using: transaction ) }, diff --git a/SessionMessagingKit/Open Groups/Types/Dependencies.swift b/SessionMessagingKit/Open Groups/Types/Dependencies.swift index ba2a9ceff..8b02b68b9 100644 --- a/SessionMessagingKit/Open Groups/Types/Dependencies.swift +++ b/SessionMessagingKit/Open Groups/Types/Dependencies.swift @@ -9,6 +9,7 @@ extension OpenGroupAPI { let api: OnionRequestAPIType.Type let storage: SessionMessagingKitStorageProtocol let sodium: SodiumType + let aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType let genericHash: GenericHashType let nonceGenerator: NonceGenerator16ByteType let date: Date @@ -17,6 +18,7 @@ extension OpenGroupAPI { api: OnionRequestAPIType.Type = OnionRequestAPI.self, storage: SessionMessagingKitStorageProtocol = SNMessagingKitConfiguration.shared.storage, sodium: SodiumType = Sodium(), + aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, genericHash: GenericHashType? = nil, nonceGenerator: NonceGenerator16ByteType = NonceGenerator16Byte(), date: Date = Date() @@ -24,6 +26,7 @@ extension OpenGroupAPI { self.api = api self.storage = storage self.sodium = sodium + self.aeadXChaCha20Poly1305Ietf = (aeadXChaCha20Poly1305Ietf ?? sodium.getAeadXChaCha20Poly1305Ietf()) self.genericHash = (genericHash ?? sodium.getGenericHash()) self.nonceGenerator = nonceGenerator self.date = date @@ -35,6 +38,7 @@ extension OpenGroupAPI { api: OnionRequestAPIType.Type? = nil, storage: SessionMessagingKitStorageProtocol? = nil, sodium: SodiumType? = nil, + aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, genericHash: GenericHashType? = nil, nonceGenerator: NonceGenerator16ByteType? = nil, date: Date? = nil @@ -43,6 +47,7 @@ extension OpenGroupAPI { api: (api ?? self.api), storage: (storage ?? self.storage), sodium: (sodium ?? self.sodium), + aeadXChaCha20Poly1305Ietf: (aeadXChaCha20Poly1305Ietf ?? self.aeadXChaCha20Poly1305Ietf), genericHash: (genericHash ?? self.genericHash), nonceGenerator: (nonceGenerator ?? self.nonceGenerator), date: (date ?? self.date) diff --git a/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift b/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift index ae6f42847..9d525d26e 100644 --- a/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift +++ b/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift @@ -5,23 +5,49 @@ import Sodium public protocol SodiumType { func getGenericHash() -> GenericHashType + func getAeadXChaCha20Poly1305Ietf() -> AeadXChaCha20Poly1305IetfType + func blindedKeyPair(serverPublicKey: String, edKeyPair: Box.KeyPair, genericHash: GenericHashType) -> Box.KeyPair? + func sharedEdSecret(_ firstKeyBytes: [UInt8], _ secondKeyBytes: [UInt8]) -> Sodium.SharedSecret? func sharedSecret(_ firstKeyBytes: [UInt8], _ secondKeyBytes: [UInt8]) -> Sodium.SharedSecret? } +public protocol AeadXChaCha20Poly1305IetfType { + func encrypt(message: Bytes, secretKey: Aead.XChaCha20Poly1305Ietf.Key, additionalData: Bytes?) -> (authenticatedCipherText: Bytes, nonce: Aead.XChaCha20Poly1305Ietf.Nonce)? +} + public protocol GenericHashType { + func hash(message: Bytes, key: Bytes?) -> Bytes? + func hash(message: Bytes, outputLength: Int) -> Bytes? func hashSaltPersonal(message: Bytes, outputLength: Int, key: Bytes?, salt: Bytes, personal: Bytes) -> Bytes? } +// MARK: - Default Values + +extension AeadXChaCha20Poly1305IetfType { + func encrypt(message: Bytes, secretKey: Aead.XChaCha20Poly1305Ietf.Key) -> (authenticatedCipherText: Bytes, nonce: Aead.XChaCha20Poly1305Ietf.Nonce)? { + return encrypt(message: message, secretKey: secretKey, additionalData: nil) + } +} + extension GenericHashType { + func hash(message: Bytes) -> Bytes? { return hash(message: message, key: nil) } + func hashSaltPersonal(message: Bytes, outputLength: Int, salt: Bytes, personal: Bytes) -> Bytes? { return hashSaltPersonal(message: message, outputLength: outputLength, key: nil, salt: salt, personal: personal) } } +// MARK: - Conformance + extension Sodium: SodiumType { public func getGenericHash() -> GenericHashType { return genericHash } + public func getAeadXChaCha20Poly1305Ietf() -> AeadXChaCha20Poly1305IetfType { return aead.xchacha20poly1305ietf } + + public func blindedKeyPair(serverPublicKey: String, edKeyPair: Box.KeyPair) -> Box.KeyPair? { + return blindedKeyPair(serverPublicKey: serverPublicKey, edKeyPair: edKeyPair, genericHash: getGenericHash()) + } } +extension Aead.XChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType {} extension GenericHash: GenericHashType {} - diff --git a/SessionMessagingKit/Sending & Receiving/Mentions/MentionsManager.swift b/SessionMessagingKit/Sending & Receiving/Mentions/MentionsManager.swift index 9463002b6..8dd5e7087 100644 --- a/SessionMessagingKit/Sending & Receiving/Mentions/MentionsManager.swift +++ b/SessionMessagingKit/Sending & Receiving/Mentions/MentionsManager.swift @@ -30,10 +30,10 @@ public final class MentionsManager : NSObject { guard let cache = userPublicKeyCache[threadID] else { return [] } var candidates: [Mention] = [] // Gather candidates - let openGroupV2 = Storage.shared.getV2OpenGroup(for: threadID) + let openGroup = Storage.shared.getOpenGroup(for: threadID) storage.dbReadConnection.read { transaction in candidates = cache.compactMap { publicKey in - let context: Contact.Context = (openGroupV2 != nil) ? .openGroup : .regular + let context: Contact.Context = (openGroup != nil) ? .openGroup : .regular let displayNameOrNil = Storage.shared.getContact(with: publicKey)?.displayName(for: context) guard let displayName = displayNameOrNil else { return nil } guard !displayName.hasPrefix("Anonymous") else { return nil } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index b6a3cdacd..f7bfec355 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -287,11 +287,11 @@ public final class MessageSender : NSObject { message.sentTimestamp = NSDate.millisecondTimestamp() } - guard let threadId: String = message.threadID, let openGroupV2 = Storage.shared.getV2OpenGroup(for: threadId) else { + guard let threadId: String = message.threadID, let openGroup = Storage.shared.getOpenGroup(for: threadId) else { preconditionFailure() } - - if let userDerivedKey: ECKeyPair = try? OWSIdentityManager.shared().identityKeyPair()?.convert(to: .blinded, with: openGroupV2.publicKey) { + // TODO: Check if blinding is enabled on this server? + if let userDerivedKey: ECKeyPair = try? OWSIdentityManager.shared().identityKeyPair()?.convert(to: .blinded, with: openGroup.publicKey) { message.sender = userDerivedKey.hexEncodedPublicKey } @@ -368,19 +368,13 @@ public final class MessageSender : NSObject { preconditionFailure() } - // TODO: Determine if the 'getV2OpenGroup' call will cause issues. - guard let threadId: String = message.threadID, let openGroupV2 = Storage.shared.getV2OpenGroup(for: threadId) else { - preconditionFailure() - } - OpenGroupAPI .send( plaintext, to: room, on: server, whisperTo: whisperTo, - whisperMods: whisperMods, - with: openGroupV2.publicKey + whisperMods: whisperMods ) .done(on: DispatchQueue.global(qos: .userInitiated)) { responseInfo, data in message.openGroupServerMessageID = given(data.seqNo) { UInt64($0) } diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift index 2e6a8f3a8..91b79943a 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift @@ -53,9 +53,8 @@ public final class PushNotificationAPI : NSObject { let promise: Promise = attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) { // TODO: Update this to use the V4 union requests once supported OnionRequestAPI.sendOnionRequest(request, to: server, using: .v2, with: serverPublicKey) - .map2 { json in try JSONSerialization.data(withJSONObject: json, options: []) } - .map2 { response in - guard let response: UnregisterResponse = try? response.decoded(as: UnregisterResponse.self) else { + .map2 { _, response in + guard let response: UnregisterResponse = try? response?.decoded(as: UnregisterResponse.self) else { return SNLog("Couldn't unregister from push notifications.") } guard response.code != 0 else { @@ -104,9 +103,8 @@ public final class PushNotificationAPI : NSObject { let promise: Promise = attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) { // TODO: Update this to use the V4 union requests once supported OnionRequestAPI.sendOnionRequest(request, to: server, using: .v2, with: serverPublicKey) - .map2 { json in try JSONSerialization.data(withJSONObject: json, options: []) } - .map2 { response in - guard let response: RegisterResponse = try? response.decoded(as: RegisterResponse.self) else { + .map2 { _, response in + guard let response: RegisterResponse = try? response?.decoded(as: RegisterResponse.self) else { return SNLog("Couldn't register device token.") } guard response.code != 0 else { @@ -152,9 +150,8 @@ public final class PushNotificationAPI : NSObject { let promise: Promise = attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) { // TODO: Update this to use the V4 union requests once supported OnionRequestAPI.sendOnionRequest(request, to: server, using: .v2, with: serverPublicKey) - .map2 { json in try JSONSerialization.data(withJSONObject: json, options: []) } - .map2 { response in - guard let response: RegisterResponse = try? response.decoded(as: RegisterResponse.self) else { + .map2 { _, response in + guard let response: RegisterResponse = try? response?.decoded(as: RegisterResponse.self) else { return SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey).") } guard response.code != 0 else { diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift index 384589e39..3c10343c1 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift @@ -82,31 +82,41 @@ extension OpenGroupAPI { private func handlePollResponse(_ response: [OpenGroupAPI.Endpoint: (info: OnionRequestResponseInfoType, data: Codable)], isBackgroundPoll: Bool) { response.forEach { endpoint, response in switch endpoint { + case .capabilities: + guard let responseData: BatchSubResponse = response.data as? BatchSubResponse else { + SNLog("Open group polling failed due to invalid data.") + return + } + + OpenGroupManager.handleCapabilities( + responseData.body, + on: server + ) + case .roomMessagesRecent(let roomToken), .roomMessagesBefore(let roomToken, _), .roomMessagesSince(let roomToken, _): - guard let responseData: [OpenGroupAPI.Message] = response.data as? [OpenGroupAPI.Message] else { + guard let responseData: BatchSubResponse<[Message]> = response.data as? BatchSubResponse<[Message]> else { SNLog("Open group polling failed due to invalid data.") return } OpenGroupManager.handleMessages( - responseData, + responseData.body, for: roomToken, on: server, isBackgroundPoll: isBackgroundPoll ) case .roomPollInfo(let roomToken, _): - guard let responseData: OpenGroupAPI.RoomPollInfo = response.data as? OpenGroupAPI.RoomPollInfo else { + guard let responseData: BatchSubResponse = response.data as? BatchSubResponse else { SNLog("Open group polling failed due to invalid data.") return } OpenGroupManager.handlePollInfo( - responseData, + responseData.body, publicKey: nil, for: roomToken, - on: server, - isBackgroundPoll: isBackgroundPoll + on: server ) default: break // No custom handling needed @@ -154,7 +164,7 @@ extension OpenGroupAPI { let deletedMessageServerIDs = Set((body.deletions ?? []).map { UInt64($0.deletedMessageID) }) storage.write { transaction in let transaction = transaction as! YapDatabaseReadWriteTransaction - guard let threadID = storage.v2GetThreadID(for: openGroupID), + guard let threadID = storage.getThreadID(for: openGroupID), let thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction) else { return } var messagesToRemove: [TSMessage] = [] diff --git a/SessionMessagingKit/Storage.swift b/SessionMessagingKit/Storage.swift index 8df85e35f..cad4ff361 100644 --- a/SessionMessagingKit/Storage.swift +++ b/SessionMessagingKit/Storage.swift @@ -1,7 +1,7 @@ import PromiseKit import Sodium -public protocol SessionMessagingKitStorageProtocol: SessionMessagingKitOpenGroupStorageProtocol { +public protocol SessionMessagingKitStorageProtocol { // MARK: - Shared @@ -45,11 +45,22 @@ public protocol SessionMessagingKitStorageProtocol: SessionMessagingKitOpenGroup // MARK: - Open Groups - func getAllV2OpenGroups() -> [String:OpenGroupV2] - func getV2OpenGroup(for threadID: String) -> OpenGroupV2? - func v2GetThreadID(for v2OpenGroupID: String) -> String? + func getAllOpenGroups() -> [String: OpenGroup] + func getThreadID(for openGroupID: String) -> String? func updateMessageIDCollectionByPruningMessagesWithIDs(_ messageIDs: Set, using transaction: Any) + func getOpenGroupImage(for room: String, on server: String) -> Data? + func setOpenGroupImage(to data: Data, for room: String, on server: String, using transaction: Any) + + func getOpenGroup(for threadID: String) -> OpenGroup? + func setOpenGroup(_ openGroup: OpenGroup, for threadID: String, using transaction: Any) + + func getUserCount(forOpenGroupWithID openGroupID: String) -> UInt64? + func setUserCount(to newValue: UInt64, forOpenGroupWithID openGroupID: String, using transaction: Any) + + func getOpenGroupServer(name: String) -> OpenGroupAPI.Server? + func storeOpenGroupServer(_ server: OpenGroupAPI.Server, using transaction: Any) + // MARK: - Open Group Public Keys func getOpenGroupPublicKey(for server: String) -> String? diff --git a/SessionMessagingKit/Utilities/Sodium+Utilities.swift b/SessionMessagingKit/Utilities/Sodium+Utilities.swift index d71891fd3..244d66236 100644 --- a/SessionMessagingKit/Utilities/Sodium+Utilities.swift +++ b/SessionMessagingKit/Utilities/Sodium+Utilities.swift @@ -17,7 +17,7 @@ extension Sign { &x25519PublicKey, ed25519PublicKey ) - + return x25519PublicKey } @@ -43,15 +43,136 @@ extension Sign { extension Sodium { public typealias SharedSecret = Data - private static let publicKeyBytes: Int = Int(crypto_scalarmult_bytes()) - private static let sharedSecretBytes: Int = Int(crypto_scalarmult_bytes()) + private static let scalarLength: Int = Int(crypto_core_ed25519_scalarbytes()) // 32 + private static let noClampLength: Int = Int(crypto_scalarmult_ed25519_bytes()) // 32 + private static let scalarMultLength: Int = Int(crypto_scalarmult_bytes()) // 32 + private static let publicKeyLength: Int = Int(crypto_scalarmult_bytes()) // 32 + private static let secretKeyLength: Int = Int(crypto_sign_secretkeybytes()) // 64 - public func sharedSecret(_ firstKeyBytes: [UInt8], _ secondKeyBytes: [UInt8]) -> SharedSecret? { - guard firstKeyBytes.count == Sodium.publicKeyBytes && secondKeyBytes.count == Sodium.publicKeyBytes else { + public func blindedKeyPair(serverPublicKey: String, edKeyPair: Box.KeyPair, genericHash: GenericHashType) -> Box.KeyPair? { + guard edKeyPair.publicKey.count == Sodium.publicKeyLength && edKeyPair.secretKey.count == Sodium.secretKeyLength else { return nil } - let sharedSecretPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.sharedSecretBytes) + /// 64-byte blake2b hash then reduce to get the blinding factor: + /// k = salt.crypto_core_ed25519_scalar_reduce(blake2b(server_pk, digest_size=64).digest()) + guard let serverPubKeyData: Data = serverPublicKey.dataFromHex() else { return nil } + guard let serverPublicKeyHashBytes: Bytes = genericHash.hash(message: [UInt8](serverPubKeyData), outputLength: 64) else { + return nil + } + + /// Reduce the server public key into an ed25519 scalar (`k`) + let kPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.scalarLength) + + let kResult = serverPublicKeyHashBytes.withUnsafeBytes { (serverPublicKeyHashPtr: UnsafeRawBufferPointer) -> Int32 in + guard let serverPublicKeyHashBaseAddress: UnsafePointer = serverPublicKeyHashPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return -1 + } + + crypto_core_ed25519_scalar_reduce(kPtr, serverPublicKeyHashBaseAddress) + return 0 + } + + /// Ensure the above worked + guard kResult == 0 else { return nil } + + /// Calculate k*a. To get 'a' (the Ed25519 private key scalar) we call the sodium function to + /// convert to an *x* secret key, which seems wrong--but isn't because converted keys use the + /// same secret scalar secret. (And so this is just the most convenient way to get 'a' out of + /// a sodium Ed25519 secret key). + /// a = s.to_curve25519_private_key().encode() + let secretKeyBytes: Bytes = [UInt8](edKeyPair.secretKey) + let aPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.scalarMultLength) + + let aResult = secretKeyBytes.withUnsafeBytes { (secretKeyPtr: UnsafeRawBufferPointer) -> Int32 in + guard let secretKeyBaseAddress: UnsafePointer = secretKeyPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return -1 + } + + return crypto_sign_ed25519_sk_to_curve25519(aPtr, secretKeyBaseAddress) + } + + /// Ensure the above worked + guard aResult == 0 else { return nil } + + /// Generate the blinded key pair `ka`, `kA` + let kaPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.secretKeyLength) + let kAPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.publicKeyLength) + crypto_core_ed25519_scalar_mul(kaPtr, kPtr, aPtr) + guard crypto_scalarmult_ed25519_base_noclamp(kAPtr, kaPtr) == 0 else { return nil } + + return Box.KeyPair( + publicKey: Data(bytes: kAPtr, count: Sodium.publicKeyLength).bytes, + secretKey: Data(bytes: kaPtr, count: Sodium.secretKeyLength).bytes + ) + } + + /// Constructs an Ed25519 signature from a root Ed25519 key and a blinded scalar/pubkey pair, with one tweak to the + /// construction: we add kA into the hashed value that yields r so that we have domain separation for different blinded + /// pubkeys (This doesn't affect verification at all). + public func sogsSignature(message: Bytes, secretKey: Bytes, blindedSecretKey ka: Bytes, blindedPublicKey kA: Bytes) -> Bytes? { + /// H_rh = sha512(s.encode()).digest()[32:] + let H_rh: Bytes = Bytes(secretKey.sha512().suffix(32)) + + /// r = salt.crypto_core_ed25519_scalar_reduce(sha512_multipart(H_rh, kA, message_parts)) + let combinedHashBytes: Bytes = (H_rh + kA + message).sha512() + let rPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.scalarLength) + + let rResult = combinedHashBytes.withUnsafeBytes { (combinedHashPtr: UnsafeRawBufferPointer) -> Int32 in + guard let combinedHashBaseAddress: UnsafePointer = combinedHashPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return -1 + } + + crypto_core_ed25519_scalar_reduce(rPtr, combinedHashBaseAddress) + return 0 + } + + /// Ensure the above worked + guard rResult == 0 else { return nil } + + /// sig_R = salt.crypto_scalarmult_ed25519_base_noclamp(r) + let sig_RPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.noClampLength) + guard crypto_scalarmult_ed25519_base_noclamp(sig_RPtr, rPtr) == 0 else { return nil } + + /// HRAM = salt.crypto_core_ed25519_scalar_reduce(sha512_multipart(sig_R, kA, message_parts)) + let sig_RBytes: Bytes = Data(bytes: sig_RPtr, count: Sodium.noClampLength).bytes + let HRAMHashBytes: Bytes = (sig_RBytes + kA + message).sha512() + let HRAMPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.scalarLength) + + let HRAMResult = HRAMHashBytes.withUnsafeBytes { (HRAMHashPtr: UnsafeRawBufferPointer) -> Int32 in + guard let HRAMHashBaseAddress: UnsafePointer = HRAMHashPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return -1 + } + + crypto_core_ed25519_scalar_reduce(HRAMPtr, HRAMHashBaseAddress) + return 0 + } + + /// Ensure the above worked + guard HRAMResult == 0 else { return nil } + + /// sig_s = salt.crypto_core_ed25519_scalar_add(r, salt.crypto_core_ed25519_scalar_mul(HRAM, ka)) + let sig_sMulPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.scalarLength) + let sig_sPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.scalarLength) + + let sig_sResult = ka.withUnsafeBytes { (kaPtr: UnsafeRawBufferPointer) -> Int32 in + guard let kaBaseAddress: UnsafePointer = kaPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return -1 + } + + crypto_core_ed25519_scalar_mul(sig_sMulPtr, HRAMPtr, kaBaseAddress) + crypto_core_ed25519_scalar_add(sig_sPtr, rPtr, sig_sMulPtr) + return 0 + } + + guard sig_sResult == 0 else { return nil } + + /// full_sig = sig_R + sig_s + return (Data(bytes: sig_RPtr, count: Sodium.noClampLength).bytes + Data(bytes: sig_sPtr, count: Sodium.scalarLength).bytes) + } + + public func sharedEdSecret(_ firstKeyBytes: [UInt8], _ secondKeyBytes: [UInt8]) -> SharedSecret? { + let sharedSecretPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.noClampLength) let result = secondKeyBytes.withUnsafeBytes { (secondKeyPtr: UnsafeRawBufferPointer) -> Int32 in return firstKeyBytes.withUnsafeBytes { (firstKeyPtr: UnsafeRawBufferPointer) -> Int32 in guard let firstKeyBaseAddress: UnsafePointer = firstKeyPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { @@ -61,13 +182,39 @@ extension Sodium { return -1 } + return crypto_scalarmult_ed25519_noclamp(sharedSecretPtr, firstKeyBaseAddress, secondKeyBaseAddress) + } + } + + guard result == 0 else { return nil } + + return Data(bytes: sharedSecretPtr, count: Sodium.scalarMultLength) + } + + public func sharedSecret(_ firstKeyBytes: [UInt8], _ secondKeyBytes: [UInt8]) -> SharedSecret? { + guard firstKeyBytes.count == Sodium.publicKeyLength && secondKeyBytes.count == Sodium.publicKeyLength else { + return nil + } + + let sharedSecretPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.scalarMultLength) + let result = secondKeyBytes.withUnsafeBytes { (secondKeyPtr: UnsafeRawBufferPointer) -> Int32 in + return firstKeyBytes.withUnsafeBytes { (firstKeyPtr: UnsafeRawBufferPointer) -> Int32 in + guard let firstKeyBaseAddress: UnsafePointer = firstKeyPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return -1 + } + guard let secondKeyBaseAddress: UnsafePointer = secondKeyPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return -1 + } + + //crypto_sign_ed25519_publickeybytes + //crypto_scalarmult_curve25519(<#T##q: UnsafeMutablePointer##UnsafeMutablePointer#>, <#T##n: UnsafePointer##UnsafePointer#>, <#T##p: UnsafePointer##UnsafePointer#>) return crypto_scalarmult(sharedSecretPtr, firstKeyBaseAddress, secondKeyBaseAddress) } } guard result == 0 else { return nil } - return Data(bytes: sharedSecretPtr, count: Sodium.sharedSecretBytes) + return Data(bytes: sharedSecretPtr, count: Sodium.scalarMultLength) } } diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift b/SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift index 4914f102c..1e8254ec1 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift @@ -80,8 +80,8 @@ class OpenGroupAPITests: XCTestCase { date: Date(timeIntervalSince1970: 1234567890) ) - testStorage.mockData[.allV2OpenGroups] = [ - "0": OpenGroupV2( + testStorage.mockData[.allOpenGroups] = [ + "0": OpenGroup( server: "testServer", room: "testRoom", publicKey: "7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d", @@ -446,9 +446,9 @@ class OpenGroupAPITests: XCTestCase { expect(requestData?.publicKey).to(equal("7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")) expect(requestData?.headers).to(haveCount(4)) expect(requestData?.headers[Header.sogsPubKey.rawValue]).to(equal("057aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")) - expect(requestData?.headers[Header.sogsNonce.rawValue]).to(equal("pK6YRtQApl4NhECGizF0Cg==")) - expect(requestData?.headers[Header.sogsHash.rawValue]).to(equal("fxqLy5ZDWCsLQpwLw0Dax+4xe7cG2vPRk1NlHORIm0DPd3o9UA24KLZY")) expect(requestData?.headers[Header.sogsTimestamp.rawValue]).to(equal("1234567890")) + expect(requestData?.headers[Header.sogsNonce.rawValue]).to(equal("pK6YRtQApl4NhECGizF0Cg==")) + expect(requestData?.headers[Header.sogsSignature.rawValue]).to(equal("fxqLy5ZDWCsLQpwLw0Dax+4xe7cG2vPRk1NlHORIm0DPd3o9UA24KLZY")) } func testItFailsToSignIfTheServerPublicKeyIsInvalid() throws { @@ -495,6 +495,11 @@ class OpenGroupAPITests: XCTestCase { class InvalidSodium: SodiumType { func getGenericHash() -> GenericHashType { return Sodium().genericHash } func sharedSecret(_ firstKeyBytes: [UInt8], _ secondKeyBytes: [UInt8]) -> Sodium.SharedSecret? { return nil } + func sharedEdSecret(_ firstKeyBytes: [UInt8], _ secondKeyBytes: [UInt8]) -> Sodium.SharedSecret? { return nil } + func getAeadXChaCha20Poly1305Ietf() -> AeadXChaCha20Poly1305IetfType { return Sodium().aead.xchacha20poly1305ietf } + func blindedKeyPair(serverPublicKey: String, edKeyPair: Box.KeyPair, genericHash: GenericHashType) -> Box.KeyPair? { + return nil + } } dependencies = dependencies.with(sodium: InvalidSodium()) @@ -518,6 +523,7 @@ class OpenGroupAPITests: XCTestCase { func testItFailsToSignIfTheIntermediateHashDoesNotGetGenerated() throws { class InvalidGenericHash: GenericHashType { + func hash(message: Bytes, key: Bytes?) -> Bytes? { return nil } func hashSaltPersonal(message: Bytes, outputLength: Int, key: Bytes?, salt: Bytes, personal: Bytes) -> Bytes? { return nil } @@ -546,6 +552,7 @@ class OpenGroupAPITests: XCTestCase { class InvalidSecondGenericHash: GenericHashType { static var didSucceedOnce: Bool = false + func hash(message: Bytes, key: Bytes?) -> Bytes? { return nil } func hashSaltPersonal(message: Bytes, outputLength: Int, key: Bytes?, salt: Bytes, personal: Bytes) -> Bytes? { if !InvalidSecondGenericHash.didSucceedOnce { InvalidSecondGenericHash.didSucceedOnce = true diff --git a/SessionMessagingKitTests/_TestUtilities/TestStorage.swift b/SessionMessagingKitTests/_TestUtilities/TestStorage.swift index fa697392c..28efb7bc0 100644 --- a/SessionMessagingKitTests/_TestUtilities/TestStorage.swift +++ b/SessionMessagingKitTests/_TestUtilities/TestStorage.swift @@ -10,10 +10,11 @@ class TestStorage: SessionMessagingKitStorageProtocol, Mockable { // MARK: - Mockable enum DataKey: Hashable { - case allV2OpenGroups + case allOpenGroups case openGroupPublicKeys case userKeyPair case openGroup + case openGroupServer case openGroupImage case openGroupUserCount } @@ -72,11 +73,28 @@ class TestStorage: SessionMessagingKitStorageProtocol, Mockable { // MARK: - Open Groups - func getAllV2OpenGroups() -> [String: OpenGroupV2] { return (mockData[.allV2OpenGroups] as! [String: OpenGroupV2]) } - func getV2OpenGroup(for threadID: String) -> OpenGroupV2? { return (mockData[.openGroup] as? OpenGroupV2) } - func v2GetThreadID(for v2OpenGroupID: String) -> String? { return nil } + func getAllOpenGroups() -> [String: OpenGroup] { return (mockData[.allOpenGroups] as! [String: OpenGroup]) } + func getThreadID(for v2OpenGroupID: String) -> String? { return nil } func updateMessageIDCollectionByPruningMessagesWithIDs(_ messageIDs: Set, using transaction: Any) {} + func getOpenGroupImage(for room: String, on server: String) -> Data? { return (mockData[.openGroupImage] as? Data) } + func setOpenGroupImage(to data: Data, for room: String, on server: String, using transaction: Any) { + mockData[.openGroupImage] = data + } + + func getOpenGroup(for threadID: String) -> OpenGroup? { return (mockData[.openGroup] as? OpenGroup) } + func setOpenGroup(_ openGroup: OpenGroup, for threadID: String, using transaction: Any) { mockData[.openGroup] = openGroup } + func getOpenGroupServer(name: String) -> OpenGroupAPI.Server? { return mockData[.openGroupServer] as? OpenGroupAPI.Server } + func storeOpenGroupServer(_ server: OpenGroupAPI.Server, using transaction: Any) { mockData[.openGroupServer] = server } + + func getUserCount(forOpenGroupWithID openGroupID: String) -> UInt64? { + return (mockData[.openGroupUserCount] as? UInt64) + } + + func setUserCount(to newValue: UInt64, forOpenGroupWithID openGroupID: String, using transaction: Any) { + mockData[.openGroupUserCount] = newValue + } + // MARK: - Open Group Public Keys func getOpenGroupPublicKey(for server: String) -> String? { @@ -111,24 +129,3 @@ class TestStorage: SessionMessagingKitStorageProtocol, Mockable { func setAttachmentState(to state: TSAttachmentPointerState, for pointer: TSAttachmentPointer, associatedWith tsIncomingMessageID: String, using transaction: Any) {} func persist(_ stream: TSAttachmentStream, associatedWith tsIncomingMessageID: String, using transaction: Any) {} } - -// MARK: - SessionMessagingKitOpenGroupStorageProtocol - -extension TestStorage: SessionMessagingKitOpenGroupStorageProtocol { - func getOpenGroupImage(for room: String, on server: String) -> Data? { return (mockData[.openGroupImage] as? Data) } - func setOpenGroupImage(to data: Data, for room: String, on server: String, using transaction: Any) { - mockData[.openGroupImage] = data - } - - func setV2OpenGroup(_ openGroup: OpenGroupV2, for threadID: String, using transaction: Any) { - mockData[.openGroup] = openGroup - } - - func getUserCount(forV2OpenGroupWithID openGroupID: String) -> UInt64? { - return (mockData[.openGroupUserCount] as? UInt64) - } - - func setUserCount(to newValue: UInt64, forV2OpenGroupWithID openGroupID: String, using transaction: Any) { - mockData[.openGroupUserCount] = newValue - } -} diff --git a/SessionSnodeKit/Utilities/Data+Utilities.swift b/SessionSnodeKit/Utilities/Data+Utilities.swift index d8d72aa9f..f597a1995 100644 --- a/SessionSnodeKit/Utilities/Data+Utilities.swift +++ b/SessionSnodeKit/Utilities/Data+Utilities.swift @@ -1,6 +1,6 @@ import Foundation -internal extension Data { +public extension Data { init(from inputStream: InputStream) throws { self.init() diff --git a/SessionUtilitiesKit/Crypto/ECKeyPair+Hexadecimal.swift b/SessionUtilitiesKit/Crypto/ECKeyPair+Hexadecimal.swift index c1ac78934..72f15c5de 100644 --- a/SessionUtilitiesKit/Crypto/ECKeyPair+Hexadecimal.swift +++ b/SessionUtilitiesKit/Crypto/ECKeyPair+Hexadecimal.swift @@ -20,9 +20,3 @@ public extension ECKeyPair { return true } } - -public extension BlindedECKeyPair { - @objc override var hexEncodedPublicKey: String { - return IdPrefix.blinded.rawValue + publicKey.map { String(format: "%02hhx", $0) }.joined() - } -} diff --git a/SessionUtilitiesKit/General/IdPrefix.swift b/SessionUtilitiesKit/General/IdPrefix.swift index a49a0f9ba..55487dd97 100644 --- a/SessionUtilitiesKit/General/IdPrefix.swift +++ b/SessionUtilitiesKit/General/IdPrefix.swift @@ -1,20 +1,24 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Sodium import Curve25519Kit -/// The `BlindedECKeyPair` is essentially the same as the `ECKeyPair` except it allows us to more easily distinguish between the two, -/// additionally when generating the `hexEncodedPublicKey` value it will apply the correct prefix -public class BlindedECKeyPair: ECKeyPair {} - public enum IdPrefix: String, CaseIterable { case standard = "05" // Used for identified users, open groups, etc. - case blinded = "15" // Used for participants in open groups + case blinded = "15" // Used for participants in open groups with blinding enabled + case unblinded = "00" // Used for participants in open groups with blinding disabled public init?(with sessionId: String) { + // TODO: Determine if we want this 'idPrefix' method (would need to validate both `ECKeyPair` and `Box.KeyPair` types) guard ECKeyPair.isValidHexEncodedPublicKey(candidate: sessionId) else { return nil } guard let targetPrefix: IdPrefix = IdPrefix(rawValue: String(sessionId.prefix(2))) else { return nil } self = targetPrefix } + + public func hexEncodedPublicKey(for publicKey: Bytes) -> String { + + return self.rawValue + publicKey.map { String(format: "%02hhx", $0) }.joined() + } } diff --git a/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift b/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift index 15bade4d6..b73c88258 100644 --- a/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift +++ b/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift @@ -43,16 +43,16 @@ extension MessageSender { let attachmentsToUpload = attachments.filter { !$0.isUploaded } let attachmentUploadPromises: [Promise] = attachmentsToUpload.map { stream in let storage = SNMessagingKitConfiguration.shared.storage - if let v2OpenGroup = storage.getV2OpenGroup(for: thread.uniqueId!) { + if let openGroup = storage.getOpenGroup(for: thread.uniqueId!) { let (promise, seal) = Promise.pending() AttachmentUploadJob.upload( stream, using: { data in - // TODO: Update to non-legacy version + // TODO: Update to non-legacy version. OpenGroupAPI.legacyUpload( data, - to: v2OpenGroup.room, - on: v2OpenGroup.server + to: openGroup.room, + on: openGroup.server ) }, encrypt: false, @@ -90,7 +90,7 @@ extension MessageSender { let attachmentsToUpload = attachments.filter { !$0.isUploaded } let attachmentUploadPromises: [Promise] = attachmentsToUpload.map { stream in let storage = SNMessagingKitConfiguration.shared.storage - if let v2OpenGroup = storage.getV2OpenGroup(for: thread.uniqueId!) { + if let openGroup = storage.getOpenGroup(for: thread.uniqueId!) { let (promise, seal) = Promise.pending() AttachmentUploadJob.upload( stream, @@ -98,8 +98,8 @@ extension MessageSender { // TODO: Update to non-legacy version OpenGroupAPI.legacyUpload( data, - to: v2OpenGroup.room, - on: v2OpenGroup.server + to: openGroup.room, + on: openGroup.server ) }, encrypt: false, From 1edd500dab829b4b4b03dd7c7e69629b90600b1b Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 21 Feb 2022 10:01:53 +1100 Subject: [PATCH 013/157] Updated to the latest blinding behaviour Added a couple more dependencies for unit testing injection Updated the MessageSender to set the sender of the message to the appropriate blinded/unblinded key Updated the OpenGroup Message to handle verification of both blinded and unblinded messages Updated the MessageSender to use dependency injection for it's sendToOpenGroupDestination method Updated the JSONDecoder to support getting dependencies (for signature verification) Fixed tests broken by updating the signing logic --- Session.xcodeproj/project.pbxproj | 32 ++- .../Open Groups/Models/BatchRequestInfo.swift | 8 +- .../Open Groups/Models/OGMessage.swift | 28 ++- .../Open Groups/OpenGroupAPI.swift | 173 +++++----------- .../Open Groups/Types/Dependencies.swift | 11 ++ .../Open Groups/Types/SodiumProtocols.swift | 19 +- .../Sending & Receiving/MessageSender.swift | 37 ++-- .../Utilities/Data+Utilities.swift | 23 +++ .../Utilities/ECKeyPair+Conversion.swift | 34 ---- .../Utilities/Promise+Utilities.swift | 8 +- .../Utilities/Sodium+Utilities.swift | 34 +--- .../Open Groups/OpenGroupAPIV2Tests.swift | 185 +++++++++++++----- .../_TestUtilities/Mockable.swift | 6 + .../TestAeadXChaCha20Poly1305Ietf.swift | 25 +++ .../_TestUtilities/TestEd25519.swift | 25 +++ .../_TestUtilities/TestGenericHash.swift | 35 ++++ .../_TestUtilities/TestSign.swift | 30 +++ .../_TestUtilities/TestSodium.swift | 46 +++++ .../_TestUtilities/TestStorage.swift | 3 +- .../General/Data+Utilities.swift | 13 -- 20 files changed, 496 insertions(+), 279 deletions(-) create mode 100644 SessionMessagingKit/Utilities/Data+Utilities.swift delete mode 100644 SessionMessagingKit/Utilities/ECKeyPair+Conversion.swift create mode 100644 SessionMessagingKitTests/_TestUtilities/TestAeadXChaCha20Poly1305Ietf.swift create mode 100644 SessionMessagingKitTests/_TestUtilities/TestEd25519.swift create mode 100644 SessionMessagingKitTests/_TestUtilities/TestGenericHash.swift create mode 100644 SessionMessagingKitTests/_TestUtilities/TestSign.swift create mode 100644 SessionMessagingKitTests/_TestUtilities/TestSodium.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index ad42a143f..e4cb5bf98 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -776,7 +776,6 @@ FD5D201127AA331F00FEA984 /* ConfigurationMessage+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D201027AA331F00FEA984 /* ConfigurationMessage+Convenience.swift */; }; FD5D201E27B0D87C00FEA984 /* IdPrefix.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D201D27B0D87C00FEA984 /* IdPrefix.swift */; }; FD5D202027B0E67900FEA984 /* String+Encoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D201F27B0E67800FEA984 /* String+Encoding.swift */; }; - FD5D202227B1D74F00FEA984 /* ECKeyPair+Conversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D202127B1D74F00FEA984 /* ECKeyPair+Conversion.swift */; }; FD659AC027A7649600F12C02 /* MessageRequestsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD659ABF27A7649600F12C02 /* MessageRequestsViewController.swift */; }; FD705A8C278CDB5600F16121 /* SAEScreenLockViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A8B278CDB5600F16121 /* SAEScreenLockViewController.swift */; }; FD705A8E278CE29800F16121 /* String+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A8D278CE29800F16121 /* String+Localization.swift */; }; @@ -784,6 +783,12 @@ FD705A92278D051200F16121 /* ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A91278D051200F16121 /* ReusableView.swift */; }; FD705A94278D052B00F16121 /* UITableView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A93278D052B00F16121 /* UITableView+ReusableView.swift */; }; FD705A98278E9F4D00F16121 /* UIColor+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A97278E9F4D00F16121 /* UIColor+Extensions.swift */; }; + FD859EF227BF6BA200510D0C /* Data+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF127BF6BA200510D0C /* Data+Utilities.swift */; }; + FD859EF427C2F49200510D0C /* TestSodium.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF327C2F49200510D0C /* TestSodium.swift */; }; + FD859EF627C2F52C00510D0C /* TestSign.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF527C2F52C00510D0C /* TestSign.swift */; }; + FD859EF827C2F58900510D0C /* TestAeadXChaCha20Poly1305Ietf.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF727C2F58900510D0C /* TestAeadXChaCha20Poly1305Ietf.swift */; }; + FD859EFA27C2F5C500510D0C /* TestGenericHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF927C2F5C500510D0C /* TestGenericHash.swift */; }; + FD859EFC27C2F60700510D0C /* TestEd25519.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EFB27C2F60700510D0C /* TestEd25519.swift */; }; FD88BAD927A7439C00BBC442 /* MessageRequestsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */; }; FD88BADB27A750F200BBC442 /* MessageRequestsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */; }; FDC4380927B31D4E00C60D73 /* Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4380827B31D4E00C60D73 /* Error.swift */; }; @@ -1912,7 +1917,6 @@ FD5D201027AA331F00FEA984 /* ConfigurationMessage+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConfigurationMessage+Convenience.swift"; sourceTree = ""; }; FD5D201D27B0D87C00FEA984 /* IdPrefix.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdPrefix.swift; sourceTree = ""; }; FD5D201F27B0E67800FEA984 /* String+Encoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Encoding.swift"; sourceTree = ""; }; - FD5D202127B1D74F00FEA984 /* ECKeyPair+Conversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ECKeyPair+Conversion.swift"; sourceTree = ""; }; FD659ABF27A7649600F12C02 /* MessageRequestsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsViewController.swift; sourceTree = ""; }; FD705A8B278CDB5600F16121 /* SAEScreenLockViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SAEScreenLockViewController.swift; sourceTree = ""; }; FD705A8D278CE29800F16121 /* String+Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Localization.swift"; sourceTree = ""; }; @@ -1920,6 +1924,14 @@ FD705A91278D051200F16121 /* ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReusableView.swift; sourceTree = ""; }; FD705A93278D052B00F16121 /* UITableView+ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITableView+ReusableView.swift"; sourceTree = ""; }; FD705A97278E9F4D00F16121 /* UIColor+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Extensions.swift"; sourceTree = ""; }; + 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 = ""; }; + FD859EF327C2F49200510D0C /* TestSodium.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestSodium.swift; sourceTree = ""; }; + FD859EF527C2F52C00510D0C /* TestSign.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestSign.swift; sourceTree = ""; }; + FD859EF727C2F58900510D0C /* TestAeadXChaCha20Poly1305Ietf.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAeadXChaCha20Poly1305Ietf.swift; sourceTree = ""; }; + FD859EF927C2F5C500510D0C /* TestGenericHash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestGenericHash.swift; sourceTree = ""; }; + FD859EFB27C2F60700510D0C /* TestEd25519.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestEd25519.swift; sourceTree = ""; }; FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsCell.swift; sourceTree = ""; }; FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsMigration.swift; sourceTree = ""; }; FD9039443F7CB729CF71350E /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig"; sourceTree = ""; }; @@ -3384,6 +3396,7 @@ C33FDB01255A580700E217F9 /* AppReadiness.h */, C33FDB75255A581000E217F9 /* AppReadiness.m */, FDC4383D27B4708600C60D73 /* Atomic.swift */, + FD859EF127BF6BA200510D0C /* Data+Utilities.swift */, C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */, C37F53E8255BA9BB002AEA92 /* Environment.h */, C37F5402255BA9ED002AEA92 /* Environment.m */, @@ -3423,7 +3436,6 @@ C38EEF09255B49A8007E1867 /* SNProtoEnvelope+Conversion.swift */, C3E7134E251C867C009649BB /* Sodium+Utilities.swift */, FDC4386827B4E6B700C60D73 /* String+Utlities.swift */, - FD5D202127B1D74F00FEA984 /* ECKeyPair+Conversion.swift */, FDC4387327B5BB9B00C60D73 /* Promise+Utilities.swift */, C33FDB31255A580A00E217F9 /* SSKEnvironment.h */, C33FDAF4255A580600E217F9 /* SSKEnvironment.m */, @@ -3539,6 +3551,8 @@ C3C2A7802553AA6300C340D1 /* Protos */ = { isa = PBXGroup; children = ( + FD859EEF27BF207700510D0C /* SessionProtos.proto */, + FD859EF027BF207C00510D0C /* WebSocketResources.proto */, C3C2A7812553AA9000C340D1 /* Generated */, ); path = Protos; @@ -3938,6 +3952,11 @@ children = ( FDC438BC27BB2AB400C60D73 /* Mockable.swift */, FDC4389C27BA01F000C60D73 /* TestStorage.swift */, + FD859EF327C2F49200510D0C /* TestSodium.swift */, + FD859EF527C2F52C00510D0C /* TestSign.swift */, + FD859EF727C2F58900510D0C /* TestAeadXChaCha20Poly1305Ietf.swift */, + FD859EF927C2F5C500510D0C /* TestGenericHash.swift */, + FD859EFB27C2F60700510D0C /* TestEd25519.swift */, ); path = _TestUtilities; sourceTree = ""; @@ -5110,6 +5129,7 @@ C32C5A13256DB7A5003C73A2 /* PushNotificationAPI.swift in Sources */, FDC4385F27B4C4A200C60D73 /* PinnedMessage.swift in Sources */, C32A026325A801AA000ED5D4 /* NSData+messagePadding.m in Sources */, + FD859EF227BF6BA200510D0C /* Data+Utilities.swift in Sources */, FDC4384927B47F4D00C60D73 /* LegacyCompactPollResponse.swift in Sources */, C352A3932557883D00338F3E /* JobDelegate.swift in Sources */, C32C5B84256DC54F003C73A2 /* SSKEnvironment.m in Sources */, @@ -5168,7 +5188,6 @@ FDC4382827B37FD300C60D73 /* LegacyModeratorsResponse.swift in Sources */, C32C5B3F256DC1DF003C73A2 /* TSQuotedMessage+Conversion.swift in Sources */, B8EB20EE2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift in Sources */, - FD5D202227B1D74F00FEA984 /* ECKeyPair+Conversion.swift in Sources */, FDC4381C27B354AC00C60D73 /* LegacyPublicKeyBody.swift in Sources */, FDC4382F27B383AF00C60D73 /* UnregisterResponse.swift in Sources */, FDC4386327B4D94E00C60D73 /* OGMessage.swift in Sources */, @@ -5463,8 +5482,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + FD859EFA27C2F5C500510D0C /* TestGenericHash.swift in Sources */, + FD859EF827C2F58900510D0C /* TestAeadXChaCha20Poly1305Ietf.swift in Sources */, + FD859EF427C2F49200510D0C /* TestSodium.swift in Sources */, + FD859EFC27C2F60700510D0C /* TestEd25519.swift in Sources */, FDC4389A27BA002500C60D73 /* OpenGroupAPIV2Tests.swift in Sources */, FDC438BD27BB2AB400C60D73 /* Mockable.swift in Sources */, + FD859EF627C2F52C00510D0C /* TestSign.swift in Sources */, FDC4389D27BA01F000C60D73 /* TestStorage.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift b/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift index 28525723d..89e35dc9c 100644 --- a/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift +++ b/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift @@ -62,13 +62,13 @@ extension OpenGroupAPI { // MARK: - Convenience public extension Decodable { - static func decoded(from data: Data) throws -> Self { - return try JSONDecoder().decode(Self.self, from: data) + static func decoded(from data: Data, customError: Error, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) throws -> Self { + return try data.decoded(as: Self.self, customError: customError, using: dependencies) } } extension Promise where T == (OnionRequestResponseInfoType, Data?) { - func decoded(as types: OpenGroupAPI.BatchResponseTypes, on queue: DispatchQueue? = nil, error: Error) -> Promise { + func decoded(as types: OpenGroupAPI.BatchResponseTypes, on queue: DispatchQueue? = nil, error: Error, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) -> Promise { self.map(on: queue) { responseInfo, maybeData -> OpenGroupAPI.BatchResponse in // Need to split the data into an array of data so each item can be Decoded correctly guard let data: Data = maybeData else { throw OpenGroupAPI.Error.parsingFailed } @@ -82,7 +82,7 @@ extension Promise where T == (OnionRequestResponseInfoType, Data?) { do { return try zip(dataArray, types) - .map { data, type in try type.decoded(from: data) } + .map { data, type in try type.decoded(from: data, customError: error, using: dependencies) } .map { data in (responseInfo, data) } } catch _ { diff --git a/SessionMessagingKit/Open Groups/Models/OGMessage.swift b/SessionMessagingKit/Open Groups/Models/OGMessage.swift index 4101dca7c..15ce06931 100644 --- a/SessionMessagingKit/Open Groups/Models/OGMessage.swift +++ b/SessionMessagingKit/Open Groups/Models/OGMessage.swift @@ -47,14 +47,30 @@ extension OpenGroupAPI.Message { guard let sender: String = maybeSender, let data = Data(base64Encoded: base64EncodedData), let signature = Data(base64Encoded: base64EncodedSignature) else { throw OpenGroupAPI.Error.parsingFailed } - - let publicKey: Data = Data(hex: sender.removingIdPrefixIfNeeded()) - let isValid: Bool = ((try? Ed25519.verifySignature(signature, publicKey: publicKey, data: data)) ?? false) - - guard isValid else { - SNLog("Ignoring message with invalid signature.") + guard let dependencies: OpenGroupAPI.Dependencies = decoder.userInfo[OpenGroupAPI.Dependencies.userInfoKey] as? OpenGroupAPI.Dependencies else { throw OpenGroupAPI.Error.parsingFailed } + + // Verify the signature based on the IdPrefix + let publicKey: Data = Data(hex: sender.removingIdPrefixIfNeeded()) + + switch IdPrefix(with: sender) { + case .blinded: + guard dependencies.sign.verify(message: data.bytes, publicKey: publicKey.bytes, signature: signature.bytes) else { + SNLog("Ignoring message with invalid signature.") + throw OpenGroupAPI.Error.parsingFailed + } + + case .standard, .unblinded: + guard (try? dependencies.ed25519.verifySignature(signature, publicKey: publicKey, data: data)) == true else { + SNLog("Ignoring message with invalid signature.") + throw OpenGroupAPI.Error.parsingFailed + } + + case .none: + SNLog("Ignoring message with invalid sender.") + throw OpenGroupAPI.Error.parsingFailed + } } self = OpenGroupAPI.Message( diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index 7009ae873..c369744a7 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -125,7 +125,7 @@ public final class OpenGroupAPI: NSObject { ) return send(request, using: dependencies) - .decoded(as: responseTypes, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) + .decoded(as: responseTypes, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) .map { result in result.enumerated() .reduce(into: [:]) { prev, next in @@ -156,7 +156,7 @@ public final class OpenGroupAPI: NSObject { // TODO: Handle a `412` response (ie. a required capability isn't supported) return send(request, using: dependencies) - .decoded(as: responseTypes, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) + .decoded(as: responseTypes, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) .map { result in result.enumerated() .reduce(into: [:]) { prev, next in @@ -176,7 +176,7 @@ public final class OpenGroupAPI: NSObject { // TODO: Handle a `412` response (ie. a required capability isn't supported) return send(request, using: dependencies) - .decoded(as: Capabilities.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) + .decoded(as: Capabilities.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) } // MARK: - Room @@ -188,7 +188,7 @@ public final class OpenGroupAPI: NSObject { ) return send(request, using: dependencies) - .decoded(as: [Room].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) + .decoded(as: [Room].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) } public static func room(for roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Room)> { @@ -198,7 +198,7 @@ public final class OpenGroupAPI: NSObject { ) return send(request, using: dependencies) - .decoded(as: Room.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) + .decoded(as: Room.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) } public static func roomPollInfo(lastUpdated: Int64, for roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, RoomPollInfo)> { @@ -208,7 +208,7 @@ public final class OpenGroupAPI: NSObject { ) return send(request, using: dependencies) - .decoded(as: RoomPollInfo.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) + .decoded(as: RoomPollInfo.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) } // MARK: - Messages @@ -221,13 +221,13 @@ public final class OpenGroupAPI: NSObject { whisperMods: Bool, using dependencies: Dependencies = Dependencies() ) -> Promise<(OnionRequestResponseInfoType, Message)> { - guard let signedMessage: (data: Data, signature: Data) = sign(message: plaintext, to: roomToken, on: server, using: dependencies) else { + guard let signResult: (publicKey: String, signature: Bytes) = sign(plaintext.bytes, for: server, using: dependencies) else { return Promise(error: Error.signingFailed) } let requestBody: SendMessageRequest = SendMessageRequest( - data: signedMessage.data, - signature: signedMessage.signature, + data: plaintext, + signature: Data(signResult.signature), whisperTo: whisperTo, whisperMods: whisperMods, fileIds: nil // TODO: Add support for 'fileIds'. @@ -245,7 +245,7 @@ public final class OpenGroupAPI: NSObject { ) return send(request, using: dependencies) - .decoded(as: Message.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) + .decoded(as: Message.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) } public static func message(_ id: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Message)> { @@ -255,7 +255,7 @@ public final class OpenGroupAPI: NSObject { ) return send(request, using: dependencies) - .decoded(as: Message.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) + .decoded(as: Message.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) } public static func messageUpdate( @@ -265,13 +265,13 @@ public final class OpenGroupAPI: NSObject { on server: String, using dependencies: Dependencies = Dependencies() ) -> Promise<(OnionRequestResponseInfoType, Data?)> { - guard let signedMessage: (data: Data, signature: Data) = sign(message: plaintext, to: roomToken, on: server, using: dependencies) else { + guard let signResult: (publicKey: String, signature: Bytes) = sign(plaintext.bytes, for: server, using: dependencies) else { return Promise(error: Error.signingFailed) } let requestBody: UpdateMessageRequest = UpdateMessageRequest( - data: signedMessage.data, - signature: signedMessage.signature + data: plaintext, + signature: Data(signResult.signature) ) guard let body: Data = try? JSONEncoder().encode(requestBody) else { @@ -302,7 +302,7 @@ public final class OpenGroupAPI: NSObject { ) return send(request, using: dependencies) - .decoded(as: [Message].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) + .decoded(as: [Message].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) } /// This is the direct request to retrieve recent messages from an Open Group so should be retrieved automatically from the `poll()` @@ -319,7 +319,7 @@ public final class OpenGroupAPI: NSObject { ) return send(request, using: dependencies) - .decoded(as: [Message].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) + .decoded(as: [Message].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) } /// This is the direct request to retrieve recent messages from an Open Group so should be retrieved automatically from the `poll()` @@ -335,7 +335,7 @@ public final class OpenGroupAPI: NSObject { ) return send(request, using: dependencies) - .decoded(as: [Message].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) + .decoded(as: [Message].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) } // MARK: - Pinning @@ -385,7 +385,7 @@ public final class OpenGroupAPI: NSObject { ) return send(request, using: dependencies) - .decoded(as: FileUploadResponse.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) + .decoded(as: FileUploadResponse.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) } /// Warning: This approach is less efficient as it expects the data to be base64Encoded (with is 33% larger than binary), please use the binary approach @@ -400,7 +400,7 @@ public final class OpenGroupAPI: NSObject { ) return send(request, using: dependencies) - .decoded(as: FileUploadResponse.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) + .decoded(as: FileUploadResponse.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) } public static func downloadFile(_ fileId: Int64, from roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Data)> { @@ -424,7 +424,7 @@ public final class OpenGroupAPI: NSObject { ) // TODO: This endpoint is getting rewritten to return just data (properties would come through as headers). return send(request, using: dependencies) - .decoded(as: FileDownloadResponse.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) + .decoded(as: FileDownloadResponse.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) } // MARK: - Inbox (Message Requests) @@ -436,7 +436,7 @@ public final class OpenGroupAPI: NSObject { ) return send(request, using: dependencies) - .decoded(as: [DirectMessage].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) + .decoded(as: [DirectMessage].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) } public static func messageRequestsSince(id: Int64, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage])> { @@ -446,7 +446,7 @@ public final class OpenGroupAPI: NSObject { ) return send(request, using: dependencies) - .decoded(as: [DirectMessage].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) + .decoded(as: [DirectMessage].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) } public static func sendMessageRequest(_ plaintext: Data, to blindedSessionId: String, on server: String, with serverPublicKey: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage])> { @@ -471,7 +471,7 @@ public final class OpenGroupAPI: NSObject { ) return send(request, using: dependencies) - .decoded(as: [DirectMessage].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) + .decoded(as: [DirectMessage].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) } // MARK: - Users @@ -581,79 +581,45 @@ public final class OpenGroupAPI: NSObject { ) return send(request, using: dependencies) - .decoded(as: UserDeleteMessagesResponse.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed) + .decoded(as: UserDeleteMessagesResponse.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) } // MARK: - Authentication /// Sign a message to be sent to SOGS (handles both un-blinded and blinded signing based on the server capabilities) - public static func sign(message: Data, to roomToken: String, on serverName: String, using dependencies: Dependencies = Dependencies()) -> (data: Data, signature: Data)? { + public static func sign(_ messageBytes: Bytes, for serverName: String, using dependencies: Dependencies = Dependencies()) -> (publicKey: String, signature: Bytes)? { + guard let userEdKeyPair: Box.KeyPair = dependencies.storage.getUserED25519KeyPair() else { return nil } + guard let serverPublicKey: String = dependencies.storage.getOpenGroupPublicKey(for: serverName) else { + return nil + } + let server: Server? = dependencies.storage.getOpenGroupServer(name: serverName) - let targetKeyPair: ECKeyPair - // Determine if we want to sign using standard or blinded keys based on the server capabilities (assume - // unblinded if we have none) - // TODO: Remove this (blinding will be required) + // Check if the server supports blinded keys, if so then sign using the blinded key if server?.capabilities.capabilities.contains(.blinding) == true { - // TODO: Validate this 'openGroupId' is correct for the 'getOpenGroup' call - let openGroupId: String = "\(serverName).\(roomToken)" - - // TODO: Validate this is the correct logic (Most likely not) - guard let openGroup: OpenGroup = Storage.shared.getOpenGroup(for: openGroupId) else { return nil } - guard let userEdKeyPair: Box.KeyPair = dependencies.storage.getUserED25519KeyPair() else { return nil } - guard let blindedKeyPair: Box.KeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: openGroup.publicKey, edKeyPair: userEdKeyPair, genericHash: dependencies.genericHash) else { + guard let blindedKeyPair: Box.KeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: serverPublicKey, edKeyPair: userEdKeyPair, genericHash: dependencies.genericHash) else { return nil } - - targetKeyPair = blindedKeyPair - } - else { - guard let userKeyPair: ECKeyPair = dependencies.storage.getUserKeyPair() else { return nil } - - targetKeyPair = userKeyPair + + guard let signatureResult: Bytes = dependencies.sodium.sogsSignature(message: messageBytes, secretKey: userEdKeyPair.secretKey, blindedSecretKey: blindedKeyPair.secretKey, blindedPublicKey: blindedKeyPair.publicKey) else { + return nil + } + + return ( + publicKey: IdPrefix.blinded.hexEncodedPublicKey(for: blindedKeyPair.publicKey), + signature: signatureResult + ) } - guard let signature = try? Ed25519.sign(message, with: targetKeyPair) else { - SNLog("Failed to sign open group message.") + // Otherwise fall back to sign using the unblinded key + guard let signatureResult: Bytes = dependencies.sign.signature(message: messageBytes, secretKey: userEdKeyPair.secretKey) else { return nil } - return (message, signature) - } - - /// Sign a blinded message request to be sent to a users inbox via SOGS v4 - private static func sign(message: Data, to blindedSessionId: String, on serverName: String, with serverPublicKey: String, using dependencies: Dependencies = Dependencies()) -> Data? { - guard let userEdKeyPair: Box.KeyPair = dependencies.storage.getUserED25519KeyPair() else { return nil } - guard let blindedKeyPair: BlindedECKeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: serverPublicKey, edKeyPair: userEdKeyPair, genericHash: dependencies.genericHash) else { - return nil - } - guard let blindedRecipientPublicKey: Data = String(blindedSessionId.suffix(from: blindedSessionId.index(blindedSessionId.startIndex, offsetBy: IdPrefix.blinded.rawValue.count))).dataFromHex() else { - return nil - } - - /// Generate the sharedSecret by "a kB || kA || kB" where - /// a, A are the users private and public keys respectively, - /// kA is the users blinded public key - /// kB is the recipients blinded public key - let maybeSharedSecret: Data? = dependencies.sodium - .sharedEdSecret(userEdKeyPair.secretKey, blindedRecipientPublicKey.bytes)? - .appending(blindedKeyPair.publicKey.bytes) - .appending(blindedRecipientPublicKey.bytes) - - guard let sharedSecret: Data = maybeSharedSecret else { return nil } - guard let intermediateHash: Bytes = dependencies.genericHash.hash(message: sharedSecret.bytes) else { return nil } - - /// Generate the inner message by "message || A" where - /// A is the sender's ed25519 master pubkey (**not** kA blinded pubkey) - let innerMessage: Bytes = (message.bytes + userEdKeyPair.publicKey) - guard let (ciphertext, nonce) = dependencies.aeadXChaCha20Poly1305Ietf.encrypt(message: innerMessage, secretKey: intermediateHash) else { - return nil - } - - /// Generate the final data by "b'\x00' + ciphertext + nonce" - let finalData: Bytes = [0] + ciphertext + nonce - - return Data(finalData) + return ( + publicKey: IdPrefix.unblinded.hexEncodedPublicKey(for: userEdKeyPair.publicKey), + signature: signatureResult + ) } /// Sign a request to be sent to SOGS (handles both un-blinded and blinded signing based on the server capabilities) @@ -666,13 +632,9 @@ public final class OpenGroupAPI: NSObject { let method: String = (request.httpMethod ?? "GET") let timestamp: Int = Int(floor(dependencies.date.timeIntervalSince1970)) let nonce: Data = Data(dependencies.nonceGenerator.nonce()) - let server: Server? = dependencies.storage.getOpenGroupServer(name: serverName) - let userPublicKeyHex: String - let signatureBytes: Bytes guard let serverPublicKeyData: Data = serverPublicKey.dataFromHex() else { return nil } guard let timestampBytes: Bytes = "\(timestamp)".data(using: .ascii)?.bytes else { return nil } - guard let userEdKeyPair: Box.KeyPair = dependencies.storage.getUserED25519KeyPair() else { return nil } /// Get a hash of any body content let bodyHash: Bytes? = { @@ -693,51 +655,24 @@ public final class OpenGroupAPI: NSObject { /// `Method` /// `Path` /// `Body` is a Blake2b hash of the data (if there is a body) - let signatureMessageBytes: Bytes = serverPublicKeyData.bytes + let messageBytes: Bytes = serverPublicKeyData.bytes .appending(nonce.bytes) .appending(timestampBytes) .appending(method.bytes) .appending(path.bytes) .appending(bodyHash ?? []) - // Determine if we want to sign using standard or blinded keys based on the server capabilities (assume - // unblinded if we have none) - // TODO: Remove this (blinding will be required) - if server?.capabilities.capabilities.contains(.blinding) == true { - // TODO: More testing of this blinded id signing (though it seems to be working!!!) - guard let blindedKeyPair: Box.KeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: serverPublicKey, edKeyPair: userEdKeyPair, genericHash: dependencies.genericHash) else { - return nil - } - - userPublicKeyHex = IdPrefix.blinded.hexEncodedPublicKey(for: blindedKeyPair.publicKey) - - guard let signatureResult: Bytes = Sodium().sogsSignature(message: signatureMessageBytes, secretKey: userEdKeyPair.secretKey, blindedSecretKey: blindedKeyPair.secretKey, blindedPublicKey: blindedKeyPair.publicKey) else { - return nil - } - - signatureBytes = signatureResult - } - else { - userPublicKeyHex = IdPrefix.unblinded.hexEncodedPublicKey(for: userEdKeyPair.publicKey) - - // TODO: shift this to dependencies - guard let signatureResult: Bytes = Sodium().sign.signature(message: signatureMessageBytes, secretKey: userEdKeyPair.secretKey) else { - return nil - } - - signatureBytes = signatureResult + /// Sign the above message + guard let signResult: (publicKey: String, signature: Bytes) = sign(messageBytes, for: serverName, using: dependencies) else { + return nil } - print("RAWR X-SOGS-Pubkey: \(userPublicKeyHex)") - print("RAWR X-SOGS-Timestamp: \(timestamp)") - print("RAWR X-SOGS-Nonce: \(nonce.base64EncodedString())") - print("RAWR X-SOGS-Signature: \(signatureBytes.toBase64())") updatedRequest.allHTTPHeaderFields = (request.allHTTPHeaderFields ?? [:]) .updated(with: [ - Header.sogsPubKey.rawValue: userPublicKeyHex, + Header.sogsPubKey.rawValue: signResult.publicKey, Header.sogsTimestamp.rawValue: "\(timestamp)", Header.sogsNonce.rawValue: nonce.base64EncodedString(), - Header.sogsSignature.rawValue: signatureBytes.toBase64() + Header.sogsSignature.rawValue: signResult.signature.toBase64() ]) return updatedRequest @@ -756,7 +691,7 @@ public final class OpenGroupAPI: NSObject { urlRequest.httpBody = request.body if request.useOnionRouting { - guard let publicKey = SNMessagingKitConfiguration.shared.storage.getOpenGroupPublicKey(for: request.server) else { + guard let publicKey = dependencies.storage.getOpenGroupPublicKey(for: request.server) else { return Promise(error: Error.noPublicKey) } diff --git a/SessionMessagingKit/Open Groups/Types/Dependencies.swift b/SessionMessagingKit/Open Groups/Types/Dependencies.swift index 8b02b68b9..d6872d7f1 100644 --- a/SessionMessagingKit/Open Groups/Types/Dependencies.swift +++ b/SessionMessagingKit/Open Groups/Types/Dependencies.swift @@ -10,16 +10,21 @@ extension OpenGroupAPI { let storage: SessionMessagingKitStorageProtocol let sodium: SodiumType let aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType + let sign: SignType let genericHash: GenericHashType + let ed25519: Ed25519Type.Type let nonceGenerator: NonceGenerator16ByteType let date: Date public init( api: OnionRequestAPIType.Type = OnionRequestAPI.self, storage: SessionMessagingKitStorageProtocol = SNMessagingKitConfiguration.shared.storage, + // TODO: Shift the next 3 to be abstracted behind a single "signing" class? sodium: SodiumType = Sodium(), aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, + sign: SignType? = nil, genericHash: GenericHashType? = nil, + ed25519: Ed25519Type.Type = Ed25519.self, nonceGenerator: NonceGenerator16ByteType = NonceGenerator16Byte(), date: Date = Date() ) { @@ -27,7 +32,9 @@ extension OpenGroupAPI { self.storage = storage self.sodium = sodium self.aeadXChaCha20Poly1305Ietf = (aeadXChaCha20Poly1305Ietf ?? sodium.getAeadXChaCha20Poly1305Ietf()) + self.sign = (sign ?? sodium.getSign()) self.genericHash = (genericHash ?? sodium.getGenericHash()) + self.ed25519 = ed25519 self.nonceGenerator = nonceGenerator self.date = date } @@ -39,7 +46,9 @@ extension OpenGroupAPI { storage: SessionMessagingKitStorageProtocol? = nil, sodium: SodiumType? = nil, aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, + sign: SignType? = nil, genericHash: GenericHashType? = nil, + ed25519: Ed25519Type.Type? = nil, nonceGenerator: NonceGenerator16ByteType? = nil, date: Date? = nil ) -> Dependencies { @@ -48,7 +57,9 @@ extension OpenGroupAPI { storage: (storage ?? self.storage), sodium: (sodium ?? self.sodium), aeadXChaCha20Poly1305Ietf: (aeadXChaCha20Poly1305Ietf ?? self.aeadXChaCha20Poly1305Ietf), + sign: (sign ?? self.sign), genericHash: (genericHash ?? self.genericHash), + ed25519: (ed25519 ?? self.ed25519), nonceGenerator: (nonceGenerator ?? self.nonceGenerator), date: (date ?? self.date) ) diff --git a/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift b/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift index 9d525d26e..421b5cff9 100644 --- a/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift +++ b/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift @@ -2,20 +2,32 @@ import Foundation import Sodium +import Curve25519Kit public protocol SodiumType { func getGenericHash() -> GenericHashType func getAeadXChaCha20Poly1305Ietf() -> AeadXChaCha20Poly1305IetfType + func getSign() -> SignType func blindedKeyPair(serverPublicKey: String, edKeyPair: Box.KeyPair, genericHash: GenericHashType) -> Box.KeyPair? - func sharedEdSecret(_ firstKeyBytes: [UInt8], _ secondKeyBytes: [UInt8]) -> Sodium.SharedSecret? - func sharedSecret(_ firstKeyBytes: [UInt8], _ secondKeyBytes: [UInt8]) -> Sodium.SharedSecret? + func sogsSignature(message: Bytes, secretKey: Bytes, blindedSecretKey ka: Bytes, blindedPublicKey kA: Bytes) -> Bytes? + + func sharedEdSecret(_ firstKeyBytes: [UInt8], _ secondKeyBytes: [UInt8]) -> Bytes? } public protocol AeadXChaCha20Poly1305IetfType { func encrypt(message: Bytes, secretKey: Aead.XChaCha20Poly1305Ietf.Key, additionalData: Bytes?) -> (authenticatedCipherText: Bytes, nonce: Aead.XChaCha20Poly1305Ietf.Nonce)? } +public protocol Ed25519Type { + static func verifySignature(_ signature: Data, publicKey: Data, data: Data) throws -> Bool +} + +public protocol SignType { + func signature(message: Bytes, secretKey: Bytes) -> Bytes? + func verify(message: Bytes, publicKey: Bytes, signature: Bytes) -> Bool +} + public protocol GenericHashType { func hash(message: Bytes, key: Bytes?) -> Bytes? func hash(message: Bytes, outputLength: Int) -> Bytes? @@ -42,6 +54,7 @@ extension GenericHashType { extension Sodium: SodiumType { public func getGenericHash() -> GenericHashType { return genericHash } + public func getSign() -> SignType { return sign } public func getAeadXChaCha20Poly1305Ietf() -> AeadXChaCha20Poly1305IetfType { return aead.xchacha20poly1305ietf } public func blindedKeyPair(serverPublicKey: String, edKeyPair: Box.KeyPair) -> Box.KeyPair? { @@ -50,4 +63,6 @@ extension Sodium: SodiumType { } extension Aead.XChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType {} +extension Sign: SignType {} extension GenericHash: GenericHashType {} +extension Ed25519: Ed25519Type {} diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index f7bfec355..5ca65cee4 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -1,6 +1,7 @@ import PromiseKit import SessionSnodeKit import SessionUtilitiesKit +import Sodium @objc(SNMessageSender) public final class MessageSender : NSObject { @@ -277,22 +278,34 @@ public final class MessageSender : NSObject { // MARK: - Open Groups - internal static func sendToOpenGroupDestination(_ destination: Message.Destination, message: Message, using transaction: Any) -> Promise { + internal static func sendToOpenGroupDestination(_ destination: Message.Destination, message: Message, using transaction: Any, dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) -> Promise { let (promise, seal) = Promise.pending() - let storage = SNMessagingKitConfiguration.shared.storage let transaction = transaction as! YapDatabaseReadWriteTransaction // Set the timestamp, sender and recipient if message.sentTimestamp == nil { // Visible messages will already have their sent timestamp set - message.sentTimestamp = NSDate.millisecondTimestamp() + message.sentTimestamp = UInt64(dependencies.date.timeIntervalSince1970 * 1000) // Should be in ms } - guard let threadId: String = message.threadID, let openGroup = Storage.shared.getOpenGroup(for: threadId) else { + guard let threadId: String = message.threadID, let openGroup = dependencies.storage.getOpenGroup(for: threadId) else { preconditionFailure() } - // TODO: Check if blinding is enabled on this server? - if let userDerivedKey: ECKeyPair = try? OWSIdentityManager.shared().identityKeyPair()?.convert(to: .blinded, with: openGroup.publicKey) { - message.sender = userDerivedKey.hexEncodedPublicKey + guard let userEdKeyPair: Box.KeyPair = dependencies.storage.getUserED25519KeyPair() else { preconditionFailure() } + + let server: OpenGroupAPI.Server? = dependencies.storage.getOpenGroupServer(name: openGroup.server) + + if server?.capabilities.capabilities.contains(.blinding) == true { + guard let serverPublicKey = dependencies.storage.getOpenGroupPublicKey(for: openGroup.server) else { + preconditionFailure() + } + guard let blindedKeyPair: Box.KeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: serverPublicKey, edKeyPair: userEdKeyPair, genericHash: dependencies.genericHash) else { + preconditionFailure() + } + + message.sender = IdPrefix.blinded.hexEncodedPublicKey(for: blindedKeyPair.publicKey) + } + else { + message.sender = IdPrefix.unblinded.hexEncodedPublicKey(for: userEdKeyPair.publicKey) } switch destination { @@ -332,12 +345,12 @@ public final class MessageSender : NSObject { } // Attach the user's profile - guard let name = storage.getUser()?.name else { + guard let name = dependencies.storage.getUser()?.name else { handleFailure(with: Error.noUsername, using: transaction) return promise } - if let profileKey = storage.getUser()?.profileEncryptionKey?.keyData, let profilePictureURL = storage.getUser()?.profilePictureURL { + if let profileKey = dependencies.storage.getUser()?.profileEncryptionKey?.keyData, let profilePictureURL = dependencies.storage.getUser()?.profilePictureURL { message.profile = VisibleMessage.Profile(displayName: name, profileKey: profileKey, profilePictureURL: profilePictureURL) } else { @@ -379,15 +392,15 @@ public final class MessageSender : NSObject { .done(on: DispatchQueue.global(qos: .userInitiated)) { responseInfo, data in message.openGroupServerMessageID = given(data.seqNo) { UInt64($0) } - Storage.shared.write { transaction in + dependencies.storage.write { transaction in MessageSender.handleSuccessfulMessageSend(message, to: destination, serverTimestamp: UInt64(floor(data.posted)), using: transaction) seal.fulfill(()) } } .catch(on: DispatchQueue.global(qos: .userInitiated)) { error in - storage.write(with: { transaction in + dependencies.storage.write { transaction in handleFailure(with: error, using: transaction as! YapDatabaseReadWriteTransaction) - }, completion: { }) + } } return promise diff --git a/SessionMessagingKit/Utilities/Data+Utilities.swift b/SessionMessagingKit/Utilities/Data+Utilities.swift new file mode 100644 index 000000000..38fb839e0 --- /dev/null +++ b/SessionMessagingKit/Utilities/Data+Utilities.swift @@ -0,0 +1,23 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +// MARK: - Decoding + +extension OpenGroupAPI.Dependencies { + static let userInfoKey: CodingUserInfoKey = CodingUserInfoKey(rawValue: "io.oxen.dependencies.codingOptions")! +} + +public extension Data { + func decoded(as type: T.Type, customError: Error? = nil, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) throws -> T { + do { + let decoder: JSONDecoder = JSONDecoder() + decoder.userInfo = [ OpenGroupAPI.Dependencies.userInfoKey: dependencies ] + + return try decoder.decode(type, from: self) + } + catch let error { + throw (customError ?? error) + } + } +} diff --git a/SessionMessagingKit/Utilities/ECKeyPair+Conversion.swift b/SessionMessagingKit/Utilities/ECKeyPair+Conversion.swift deleted file mode 100644 index f420b5164..000000000 --- a/SessionMessagingKit/Utilities/ECKeyPair+Conversion.swift +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Curve25519Kit -import SessionUtilitiesKit -import Sodium - -public extension ECKeyPair { - func convert(to targetPrefix: IdPrefix, with otherKey: String, using sodium: Sodium = Sodium()) throws -> ECKeyPair? { - guard let publicKeyPrefix: IdPrefix = IdPrefix(with: hexEncodedPublicKey) else { return nil } - - switch (publicKeyPrefix, targetPrefix) { - case (.standard, .blinded): // Only support standard -> blinded conversions - // TODO: Figure out why this is broken... -// guard let otherPubKeyData: Data = otherKey.data(using: .utf8) else { return nil } - guard let otherPubKeyData: Data = otherKey.dataFromHex() else { return nil } - guard let otherPubKeyHashBytes: Bytes = sodium.genericHash.hash(message: [UInt8](otherPubKeyData)) else { - return nil - } - guard let blindedPublicKey: Sodium.SharedSecret = sodium.sharedSecret(otherPubKeyHashBytes, [UInt8](publicKey)) else { - return nil - } - guard let blindedPrivateKey: Sodium.SharedSecret = sodium.sharedSecret(otherPubKeyHashBytes, [UInt8](privateKey)) else { - return nil - } - - return try BlindedECKeyPair(publicKeyData: blindedPublicKey, privateKeyData: blindedPrivateKey) - - case (.standard, .standard): return self - case (.blinded, .blinded): return self - default: return nil - } - } -} diff --git a/SessionMessagingKit/Utilities/Promise+Utilities.swift b/SessionMessagingKit/Utilities/Promise+Utilities.swift index b59ebdbc2..91d25c937 100644 --- a/SessionMessagingKit/Utilities/Promise+Utilities.swift +++ b/SessionMessagingKit/Utilities/Promise+Utilities.swift @@ -5,21 +5,21 @@ import PromiseKit import SessionSnodeKit extension Promise where T == Data { - func decoded(as type: R.Type, on queue: DispatchQueue? = nil, error: Error? = nil) -> Promise { + func decoded(as type: R.Type, on queue: DispatchQueue? = nil, error: Error? = nil, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) -> Promise { self.map(on: queue) { data -> R in - try data.decoded(as: type, customError: error) + try data.decoded(as: type, customError: error, using: dependencies) } } } extension Promise where T == (OnionRequestResponseInfoType, Data?) { - func decoded(as type: R.Type, on queue: DispatchQueue? = nil, error: Error? = nil) -> Promise<(OnionRequestResponseInfoType, R)> { + func decoded(as type: R.Type, on queue: DispatchQueue? = nil, error: Error? = nil, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) -> Promise<(OnionRequestResponseInfoType, R)> { self.map(on: queue) { responseInfo, maybeData -> (OnionRequestResponseInfoType, R) in guard let data: Data = maybeData else { throw OpenGroupAPI.Error.parsingFailed } - return (responseInfo, try data.decoded(as: type, customError: error)) + return (responseInfo, try data.decoded(as: type, customError: error, using: dependencies)) } } } diff --git a/SessionMessagingKit/Utilities/Sodium+Utilities.swift b/SessionMessagingKit/Utilities/Sodium+Utilities.swift index 244d66236..8a2c8f67c 100644 --- a/SessionMessagingKit/Utilities/Sodium+Utilities.swift +++ b/SessionMessagingKit/Utilities/Sodium+Utilities.swift @@ -41,14 +41,13 @@ extension Sign { } extension Sodium { - public typealias SharedSecret = Data - private static let scalarLength: Int = Int(crypto_core_ed25519_scalarbytes()) // 32 private static let noClampLength: Int = Int(crypto_scalarmult_ed25519_bytes()) // 32 private static let scalarMultLength: Int = Int(crypto_scalarmult_bytes()) // 32 private static let publicKeyLength: Int = Int(crypto_scalarmult_bytes()) // 32 private static let secretKeyLength: Int = Int(crypto_sign_secretkeybytes()) // 64 + /// Constructs a "blinded" key pair (`ka, kA`) based on an open group server `publicKey` and an ed25519 `keyPair` public func blindedKeyPair(serverPublicKey: String, edKeyPair: Box.KeyPair, genericHash: GenericHashType) -> Box.KeyPair? { guard edKeyPair.publicKey.count == Sodium.publicKeyLength && edKeyPair.secretKey.count == Sodium.secretKeyLength else { return nil @@ -171,7 +170,8 @@ extension Sodium { return (Data(bytes: sig_RPtr, count: Sodium.noClampLength).bytes + Data(bytes: sig_sPtr, count: Sodium.scalarLength).bytes) } - public func sharedEdSecret(_ firstKeyBytes: [UInt8], _ secondKeyBytes: [UInt8]) -> SharedSecret? { + // TODO: Determine if we still need this? (To generate the `kB` value for the `/inbox` API????) + public func sharedEdSecret(_ firstKeyBytes: [UInt8], _ secondKeyBytes: [UInt8]) -> Bytes? { let sharedSecretPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.noClampLength) let result = secondKeyBytes.withUnsafeBytes { (secondKeyPtr: UnsafeRawBufferPointer) -> Int32 in return firstKeyBytes.withUnsafeBytes { (firstKeyPtr: UnsafeRawBufferPointer) -> Int32 in @@ -188,33 +188,7 @@ extension Sodium { guard result == 0 else { return nil } - return Data(bytes: sharedSecretPtr, count: Sodium.scalarMultLength) - } - - public func sharedSecret(_ firstKeyBytes: [UInt8], _ secondKeyBytes: [UInt8]) -> SharedSecret? { - guard firstKeyBytes.count == Sodium.publicKeyLength && secondKeyBytes.count == Sodium.publicKeyLength else { - return nil - } - - let sharedSecretPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.scalarMultLength) - let result = secondKeyBytes.withUnsafeBytes { (secondKeyPtr: UnsafeRawBufferPointer) -> Int32 in - return firstKeyBytes.withUnsafeBytes { (firstKeyPtr: UnsafeRawBufferPointer) -> Int32 in - guard let firstKeyBaseAddress: UnsafePointer = firstKeyPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return -1 - } - guard let secondKeyBaseAddress: UnsafePointer = secondKeyPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return -1 - } - - //crypto_sign_ed25519_publickeybytes - //crypto_scalarmult_curve25519(<#T##q: UnsafeMutablePointer##UnsafeMutablePointer#>, <#T##n: UnsafePointer##UnsafePointer#>, <#T##p: UnsafePointer##UnsafePointer#>) - return crypto_scalarmult(sharedSecretPtr, firstKeyBaseAddress, secondKeyBaseAddress) - } - } - - guard result == 0 else { return nil } - - return Data(bytes: sharedSecretPtr, count: Sodium.scalarMultLength) + return Data(bytes: sharedSecretPtr, count: Sodium.scalarMultLength).bytes } } diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift b/SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift index 1e8254ec1..e8506c11f 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift @@ -67,15 +67,28 @@ class OpenGroupAPITests: XCTestCase { } var testStorage: TestStorage! + var testSodium: TestSodium! + var testAeadXChaCha20Poly1305Ietf: TestAeadXChaCha20Poly1305Ietf! + var testGenericHash: TestGenericHash! + var testSign: TestSign! var dependencies: OpenGroupAPI.Dependencies! // MARK: - Configuration override func setUpWithError() throws { testStorage = TestStorage() + testSodium = TestSodium() + testAeadXChaCha20Poly1305Ietf = TestAeadXChaCha20Poly1305Ietf() + testGenericHash = TestGenericHash() + testSign = TestSign() dependencies = OpenGroupAPI.Dependencies( api: TestApi.self, storage: testStorage, + sodium: testSodium, + aeadXChaCha20Poly1305Ietf: testAeadXChaCha20Poly1305Ietf, + sign: testSign, + genericHash: testGenericHash, + ed25519: TestEd25519.self, nonceGenerator: TestNonceGenerator(), date: Date(timeIntervalSince1970: 1234567890) ) @@ -100,6 +113,18 @@ class OpenGroupAPITests: XCTestCase { publicKeyData: Data.data(fromHex: "7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")!, privateKeyData: Data.data(fromHex: "881132ee03dbd2da065aa4c94f96081f62142dc8011d1b7a00de83e4aab38ce4")! ) + testStorage.mockData[.userEdKeyPair] = Box.KeyPair( + publicKey: Data.data(fromHex: "7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")!.bytes, + secretKey: Data.data(fromHex: "881132ee03dbd2da065aa4c94f96081f62142dc8011d1b7a00de83e4aab38ce4881132ee03dbd2da065aa4c94f96081f62142dc8011d1b7a00de83e4aab38ce4")!.bytes + ) + + testGenericHash.mockData[.hashOutputLength] = [] + testSodium.mockData[.blindedKeyPair] = Box.KeyPair( + publicKey: Data.data(fromHex: "7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")!.bytes, + secretKey: Data.data(fromHex: "881132ee03dbd2da065aa4c94f96081f62142dc8011d1b7a00de83e4aab38ce4881132ee03dbd2da065aa4c94f96081f62142dc8011d1b7a00de83e4aab38ce4")!.bytes + ) + testSodium.mockData[.sogsSignature] = "TestSogsSignature".bytes + testSign.mockData[.signature] = "TestSignature".bytes } override func tearDownWithError() throws { @@ -415,12 +440,16 @@ class OpenGroupAPITests: XCTestCase { // MARK: - Authentication - func testItSignsTheRequestCorrectly() throws { + func testItSignsTheUnblindedRequestCorrectly() throws { class LocalTestApi: TestApi { override class var mockResponse: Data? { return try! JSONEncoder().encode([OpenGroupAPI.Room]()) } } + testStorage.mockData[.openGroupServer] = OpenGroupAPI.Server( + name: "testServer", + capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: []) + ) dependencies = dependencies.with(api: LocalTestApi.self) var response: (OnionRequestResponseInfoType, [OpenGroupAPI.Room])? = nil @@ -445,14 +474,74 @@ class OpenGroupAPITests: XCTestCase { expect(requestData?.server).to(equal("testServer")) expect(requestData?.publicKey).to(equal("7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")) expect(requestData?.headers).to(haveCount(4)) - expect(requestData?.headers[Header.sogsPubKey.rawValue]).to(equal("057aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")) + expect(requestData?.headers[Header.sogsPubKey.rawValue]).to(equal("007aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")) expect(requestData?.headers[Header.sogsTimestamp.rawValue]).to(equal("1234567890")) expect(requestData?.headers[Header.sogsNonce.rawValue]).to(equal("pK6YRtQApl4NhECGizF0Cg==")) - expect(requestData?.headers[Header.sogsSignature.rawValue]).to(equal("fxqLy5ZDWCsLQpwLw0Dax+4xe7cG2vPRk1NlHORIm0DPd3o9UA24KLZY")) + expect(requestData?.headers[Header.sogsSignature.rawValue]).to(equal("TestSignature".bytes.toBase64())) + } + + func testItSignsTheBlindedRequestCorrectly() throws { + class LocalTestApi: TestApi { + override class var mockResponse: Data? { + return try! JSONEncoder().encode([OpenGroupAPI.Room]()) + } + } + testStorage.mockData[.openGroupServer] = OpenGroupAPI.Server( + name: "testServer", + capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs, .blinding], missing: []) + ) + dependencies = dependencies.with(api: LocalTestApi.self) + + var response: (OnionRequestResponseInfoType, [OpenGroupAPI.Room])? = nil + var error: Error? = nil + + OpenGroupAPI.rooms(for: "testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate signature headers + let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData + expect(requestData?.urlString).to(equal("testServer/rooms")) + expect(requestData?.httpMethod).to(equal("GET")) + expect(requestData?.server).to(equal("testServer")) + expect(requestData?.publicKey).to(equal("7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")) + expect(requestData?.headers).to(haveCount(4)) + expect(requestData?.headers[Header.sogsPubKey.rawValue]).to(equal("157aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")) + expect(requestData?.headers[Header.sogsTimestamp.rawValue]).to(equal("1234567890")) + expect(requestData?.headers[Header.sogsNonce.rawValue]).to(equal("pK6YRtQApl4NhECGizF0Cg==")) + expect(requestData?.headers[Header.sogsSignature.rawValue]).to(equal("TestSogsSignature".bytes.toBase64())) + } + + func testItFailsToSignIfThereIsNoUserEdKeyPair() throws { + testStorage.mockData[.userEdKeyPair] = nil + + var response: Any? = nil + var error: Error? = nil + + OpenGroupAPI.rooms(for: "testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) } func testItFailsToSignIfTheServerPublicKeyIsInvalid() throws { - testStorage.mockData[.openGroupPublicKeys] = ["testServer": ""] + testStorage.mockData[.openGroupPublicKeys] = [:] var response: Any? = nil var error: Error? = nil @@ -464,44 +553,32 @@ class OpenGroupAPITests: XCTestCase { expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + equal(OpenGroupAPI.Error.noPublicKey.localizedDescription), timeout: .milliseconds(100) ) expect(response).to(beNil()) } - func testItFailsToSignIfThereIsNoUserKeyPair() throws { - testStorage.mockData[.userKeyPair] = nil - - var response: Any? = nil - var error: Error? = nil - - OpenGroupAPI.rooms(for: "testServer", using: dependencies) - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPI.Error.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - func testItFailsToSignIfTheSharedSecretDoesNotGetGenerated() throws { + func testItFailsToSignIfBlindedAndTheBlindedKeyDoesNotGetGenerated() throws { class InvalidSodium: SodiumType { func getGenericHash() -> GenericHashType { return Sodium().genericHash } - func sharedSecret(_ firstKeyBytes: [UInt8], _ secondKeyBytes: [UInt8]) -> Sodium.SharedSecret? { return nil } - func sharedEdSecret(_ firstKeyBytes: [UInt8], _ secondKeyBytes: [UInt8]) -> Sodium.SharedSecret? { return nil } func getAeadXChaCha20Poly1305Ietf() -> AeadXChaCha20Poly1305IetfType { return Sodium().aead.xchacha20poly1305ietf } + func getSign() -> SignType { return Sodium().sign } + func blindedKeyPair(serverPublicKey: String, edKeyPair: Box.KeyPair, genericHash: GenericHashType) -> Box.KeyPair? { return nil } + func sogsSignature(message: Bytes, secretKey: Bytes, blindedSecretKey ka: Bytes, blindedPublicKey kA: Bytes) -> Bytes? { + return nil + } + + func sharedEdSecret(_ firstKeyBytes: [UInt8], _ secondKeyBytes: [UInt8]) -> Bytes? { return nil } } - + testStorage.mockData[.openGroupServer] = OpenGroupAPI.Server( + name: "testServer", + capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs, .blinding], missing: []) + ) dependencies = dependencies.with(sodium: InvalidSodium()) var response: Any? = nil @@ -521,15 +598,29 @@ class OpenGroupAPITests: XCTestCase { expect(response).to(beNil()) } - func testItFailsToSignIfTheIntermediateHashDoesNotGetGenerated() throws { - class InvalidGenericHash: GenericHashType { - func hash(message: Bytes, key: Bytes?) -> Bytes? { return nil } - func hashSaltPersonal(message: Bytes, outputLength: Int, key: Bytes?, salt: Bytes, personal: Bytes) -> Bytes? { + func testItFailsToSignIfBlindedAndTheSogsSignatureDoesNotGetGenerated() throws { + class InvalidSodium: SodiumType { + func getGenericHash() -> GenericHashType { return Sodium().genericHash } + func getAeadXChaCha20Poly1305Ietf() -> AeadXChaCha20Poly1305IetfType { return Sodium().aead.xchacha20poly1305ietf } + func getSign() -> SignType { return Sodium().sign } + + func blindedKeyPair(serverPublicKey: String, edKeyPair: Box.KeyPair, genericHash: GenericHashType) -> Box.KeyPair? { + return Box.KeyPair( + publicKey: Data.data(fromHex: "7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")!.bytes, + secretKey: Data.data(fromHex: "881132ee03dbd2da065aa4c94f96081f62142dc8011d1b7a00de83e4aab38ce4881132ee03dbd2da065aa4c94f96081f62142dc8011d1b7a00de83e4aab38ce4")!.bytes + ) + } + func sogsSignature(message: Bytes, secretKey: Bytes, blindedSecretKey ka: Bytes, blindedPublicKey kA: Bytes) -> Bytes? { return nil } + + func sharedEdSecret(_ firstKeyBytes: [UInt8], _ secondKeyBytes: [UInt8]) -> Bytes? { return nil } } - - dependencies = dependencies.with(genericHash: InvalidGenericHash()) + testStorage.mockData[.openGroupServer] = OpenGroupAPI.Server( + name: "testServer", + capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs, .blinding], missing: []) + ) + dependencies = dependencies.with(sodium: InvalidSodium()) var response: Any? = nil var error: Error? = nil @@ -548,22 +639,16 @@ class OpenGroupAPITests: XCTestCase { expect(response).to(beNil()) } - func testItFailsToSignIfTheSecretHashDoesNotGetGenerated() throws { - class InvalidSecondGenericHash: GenericHashType { - static var didSucceedOnce: Bool = false - - func hash(message: Bytes, key: Bytes?) -> Bytes? { return nil } - func hashSaltPersonal(message: Bytes, outputLength: Int, key: Bytes?, salt: Bytes, personal: Bytes) -> Bytes? { - if !InvalidSecondGenericHash.didSucceedOnce { - InvalidSecondGenericHash.didSucceedOnce = true - return Data().bytes - } - - return nil - } + func testItFailsToSignIfUnblindedAndTheSignatureDoesNotGetGenerated() throws { + class InvalidSign: SignType { + func signature(message: Bytes, secretKey: Bytes) -> Bytes? { return nil } + func verify(message: Bytes, publicKey: Bytes, signature: Bytes) -> Bool { return false } } - - dependencies = dependencies.with(genericHash: InvalidSecondGenericHash()) + testStorage.mockData[.openGroupServer] = OpenGroupAPI.Server( + name: "testServer", + capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: []) + ) + dependencies = dependencies.with(sign: InvalidSign()) var response: Any? = nil var error: Error? = nil diff --git a/SessionMessagingKitTests/_TestUtilities/Mockable.swift b/SessionMessagingKitTests/_TestUtilities/Mockable.swift index b903f0fa3..6c438d881 100644 --- a/SessionMessagingKitTests/_TestUtilities/Mockable.swift +++ b/SessionMessagingKitTests/_TestUtilities/Mockable.swift @@ -7,3 +7,9 @@ protocol Mockable { var mockData: [Key: Any] { get } } + +protocol StaticMockable { + associatedtype Key: Hashable + + static var mockData: [Key: Any] { get } +} diff --git a/SessionMessagingKitTests/_TestUtilities/TestAeadXChaCha20Poly1305Ietf.swift b/SessionMessagingKitTests/_TestUtilities/TestAeadXChaCha20Poly1305Ietf.swift new file mode 100644 index 000000000..0906b8e25 --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/TestAeadXChaCha20Poly1305Ietf.swift @@ -0,0 +1,25 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import PromiseKit +import Sodium + +@testable import SessionMessagingKit + +class TestAeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType, Mockable { + // MARK: - Mockable + + enum DataKey: Hashable { + case encrypt + } + + typealias Key = DataKey + + var mockData: [DataKey: Any] = [:] + + // MARK: - SignType + + func encrypt(message: Bytes, secretKey: Aead.XChaCha20Poly1305Ietf.Key, additionalData: Bytes?) -> (authenticatedCipherText: Bytes, nonce: Aead.XChaCha20Poly1305Ietf.Nonce)? { + return (mockData[.encrypt] as? (authenticatedCipherText: Bytes, nonce: Aead.XChaCha20Poly1305Ietf.Nonce)) + } +} diff --git a/SessionMessagingKitTests/_TestUtilities/TestEd25519.swift b/SessionMessagingKitTests/_TestUtilities/TestEd25519.swift new file mode 100644 index 000000000..43989397b --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/TestEd25519.swift @@ -0,0 +1,25 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import PromiseKit +import Sodium + +@testable import SessionMessagingKit + +class TestEd25519: Ed25519Type, StaticMockable { + // MARK: - Mockable + + enum DataKey: Hashable { + case verifySignature(signature: Data, publicKey: Data, data: Data) // TODO: Test the uniqueness of this + } + + typealias Key = DataKey + + static var mockData: [DataKey: Any] = [:] + + // MARK: - SignType + + static func verifySignature(_ signature: Data, publicKey: Data, data: Data) throws -> Bool { + return (mockData[.verifySignature(signature: signature, publicKey: publicKey, data: data)] as! Bool) + } +} diff --git a/SessionMessagingKitTests/_TestUtilities/TestGenericHash.swift b/SessionMessagingKitTests/_TestUtilities/TestGenericHash.swift new file mode 100644 index 000000000..f6b51cfcd --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/TestGenericHash.swift @@ -0,0 +1,35 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import PromiseKit +import Sodium + +@testable import SessionMessagingKit + +class TestGenericHash: GenericHashType, Mockable { + // MARK: - Mockable + + enum DataKey: Hashable { + case hash + case hashOutputLength + case hashSaltPersonal + } + + typealias Key = DataKey + + var mockData: [DataKey: Any] = [:] + + // MARK: - SignType + + func hash(message: Bytes, key: Bytes?) -> Bytes? { + return (mockData[.hash] as? Bytes) + } + + func hash(message: Bytes, outputLength: Int) -> Bytes? { + return (mockData[.hashOutputLength] as? Bytes) + } + + func hashSaltPersonal(message: Bytes, outputLength: Int, key: Bytes?, salt: Bytes, personal: Bytes) -> Bytes? { + return (mockData[.hashSaltPersonal] as? Bytes) + } +} diff --git a/SessionMessagingKitTests/_TestUtilities/TestSign.swift b/SessionMessagingKitTests/_TestUtilities/TestSign.swift new file mode 100644 index 000000000..a193b2a5f --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/TestSign.swift @@ -0,0 +1,30 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import PromiseKit +import Sodium + +@testable import SessionMessagingKit + +class TestSign: SignType, Mockable { + // MARK: - Mockable + + enum DataKey: Hashable { + case signature + case verify + } + + typealias Key = DataKey + + var mockData: [DataKey: Any] = [:] + + // MARK: - SignType + + func signature(message: Bytes, secretKey: Bytes) -> Bytes? { + return (mockData[.signature] as? Bytes) + } + + func verify(message: Bytes, publicKey: Bytes, signature: Bytes) -> Bool { + return (mockData[.verify] as! Bool) + } +} diff --git a/SessionMessagingKitTests/_TestUtilities/TestSodium.swift b/SessionMessagingKitTests/_TestUtilities/TestSodium.swift new file mode 100644 index 000000000..862cbfc8d --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/TestSodium.swift @@ -0,0 +1,46 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import PromiseKit +import Sodium + +@testable import SessionMessagingKit + +class TestSodium: SodiumType, Mockable { + // MARK: - Mockable + + enum DataKey: Hashable { + case genericHash + case aeadXChaCha20Poly1305Ietf + case sign + case blindedKeyPair + case sogsSignature + case sharedEdSecret + } + + typealias Key = DataKey + + var mockData: [DataKey: Any] = [:] + + // MARK: - SodiumType + + func getGenericHash() -> GenericHashType { return (mockData[.genericHash] as! GenericHashType) } + + func getAeadXChaCha20Poly1305Ietf() -> AeadXChaCha20Poly1305IetfType { + return (mockData[.aeadXChaCha20Poly1305Ietf] as! AeadXChaCha20Poly1305IetfType) + } + + func getSign() -> SignType { return (mockData[.sign] as! SignType) } + + func blindedKeyPair(serverPublicKey: String, edKeyPair: Box.KeyPair, genericHash: GenericHashType) -> Box.KeyPair? { + return (mockData[.blindedKeyPair] as? Box.KeyPair) + } + + func sogsSignature(message: Bytes, secretKey: Bytes, blindedSecretKey ka: Bytes, blindedPublicKey kA: Bytes) -> Bytes? { + return (mockData[.sogsSignature] as? Bytes) + } + + func sharedEdSecret(_ firstKeyBytes: [UInt8], _ secondKeyBytes: [UInt8]) -> Bytes? { + return (mockData[.sharedEdSecret] as? Bytes) + } +} diff --git a/SessionMessagingKitTests/_TestUtilities/TestStorage.swift b/SessionMessagingKitTests/_TestUtilities/TestStorage.swift index 28efb7bc0..1128e0f9c 100644 --- a/SessionMessagingKitTests/_TestUtilities/TestStorage.swift +++ b/SessionMessagingKitTests/_TestUtilities/TestStorage.swift @@ -13,6 +13,7 @@ class TestStorage: SessionMessagingKitStorageProtocol, Mockable { case allOpenGroups case openGroupPublicKeys case userKeyPair + case userEdKeyPair case openGroup case openGroupServer case openGroupImage @@ -43,7 +44,7 @@ class TestStorage: SessionMessagingKitStorageProtocol, Mockable { func getUserPublicKey() -> String? { return nil } func getUserKeyPair() -> ECKeyPair? { return (mockData[.userKeyPair] as? ECKeyPair) } - func getUserED25519KeyPair() -> Box.KeyPair? { return nil } + func getUserED25519KeyPair() -> Box.KeyPair? { return (mockData[.userEdKeyPair] as? Box.KeyPair) } func getUser() -> Contact? { return nil } func getAllContacts() -> Set { return Set() } diff --git a/SessionUtilitiesKit/General/Data+Utilities.swift b/SessionUtilitiesKit/General/Data+Utilities.swift index e0fddf1a3..54502935a 100644 --- a/SessionUtilitiesKit/General/Data+Utilities.swift +++ b/SessionUtilitiesKit/General/Data+Utilities.swift @@ -33,16 +33,3 @@ public extension Data { return result as NSData } } - -// MARK: - Decoding - -public extension Data { - func decoded(as type: T.Type, customError: Error? = nil) throws -> T { - do { - return try JSONDecoder().decode(type, from: self) - } - catch let error { - throw (customError ?? error) - } - } -} From f5e48cec0139deb18c3f66f6da8588215abed52f Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 21 Feb 2022 14:59:51 +1100 Subject: [PATCH 014/157] Updated the project to use the Oxen fork of Sodium (instead of my one) --- Podfile | 2 +- Podfile.lock | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Podfile b/Podfile index 44b0bb1b8..b2f5d93ca 100644 --- a/Podfile +++ b/Podfile @@ -9,7 +9,7 @@ abstract_target 'GlobalDependencies' do pod 'PromiseKit' pod 'CryptoSwift' # FIXME: If https://github.com/jedisct1/swift-sodium/pull/249 gets resolved then revert this back to the standard pod - pod 'Sodium', :git => 'https://github.com/mpretty-cyro/swift-sodium.git', branch: 'full-clibsodium-build' + pod 'Sodium', :git => 'https://github.com/oxen-io/session-ios-swift-sodium.git', branch: 'session-build' pod 'YapDatabase/SQLCipher', :git => 'https://github.com/oxen-io/session-ios-yap-database.git', branch: 'signal-release' target 'Session' do diff --git a/Podfile.lock b/Podfile.lock index 27b73ca97..372f13c9d 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -132,7 +132,7 @@ DEPENDENCIES: - Reachability - SAMKeychain - SignalCoreKit (from `https://github.com/oxen-io/session-ios-core-kit`, branch `session-version`) - - Sodium (from `https://github.com/mpretty-cyro/swift-sodium.git`, branch `full-clibsodium-build`) + - Sodium (from `https://github.com/oxen-io/session-ios-swift-sodium.git`, branch `session-build`) - SwiftProtobuf (~> 1.5.0) - YapDatabase/SQLCipher (from `https://github.com/oxen-io/session-ios-yap-database.git`, branch `signal-release`) - YYImage (from `https://github.com/signalapp/YYImage`) @@ -164,8 +164,8 @@ EXTERNAL SOURCES: :branch: session-version :git: https://github.com/oxen-io/session-ios-core-kit Sodium: - :branch: full-clibsodium-build - :git: https://github.com/mpretty-cyro/swift-sodium.git + :branch: session-build + :git: https://github.com/oxen-io/session-ios-swift-sodium.git YapDatabase: :branch: signal-release :git: https://github.com/oxen-io/session-ios-yap-database.git @@ -183,8 +183,8 @@ CHECKOUT OPTIONS: :commit: 4590c2737a2b5dc0ef4ace9f9019b581caccc1de :git: https://github.com/oxen-io/session-ios-core-kit Sodium: - :commit: eeb18f6fa8c28dc254e64cb340ee2c8f37f2ebe8 - :git: https://github.com/mpretty-cyro/swift-sodium.git + :commit: 6d4317cd4c67e7a617d474d7c5bf20d319aa4536 + :git: https://github.com/oxen-io/session-ios-swift-sodium.git YapDatabase: :commit: d84069e25e12a16ab4422e5258127a04b70489ad :git: https://github.com/oxen-io/session-ios-yap-database.git @@ -213,6 +213,6 @@ SPEC CHECKSUMS: YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331 ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: 011a84301a09b80dfc7343c105fc810eacb31074 +PODFILE CHECKSUM: 42874150fd08761ee6907c5bacf22b95ae849d8c COCOAPODS: 1.11.2 From faa8918cd4d03c11df9215191fe3f56474199bd1 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 24 Feb 2022 10:39:22 +1100 Subject: [PATCH 015/157] Replaced the remaining non-file legacy methods with their non-legacy equivalents Updated the OpenGroup polling to run on a non-main thread Updated the TSGroupModel to store moderatorIds as well as the adminIds (new endpoint is only going to give diffs) Updated the BatchRequest to support json, base64 encoded strings and raw bytes for it's body Replaced the 'lastMessageServerID' methods with 'OpenGroupSequenceNumber' methods (since we have swapped the property over) Added an alert when banning fails (previously it would fail silently) Fixed a bug where sent blinded messages were appearing as incoming messages Fixed a bug where the OpenGroup infoUpdates wasn't getting decoded correctly Fixed an issue where the ConversationVC wouldn't become the first responder again after the ban alerts disappeared Fixed an issue where I'd incorrectly used the message 'seqNo' in place of the message server id Fixed an issue where open group messages were setting their `sentTimestamp` to seconds instead of milliseconds for incoming messages --- .../ConversationVC+Interaction.swift | 27 +- Session/Conversations/ConversationViewItem.m | 8 +- .../Input View/MentionSelectionView.swift | 4 +- .../Message Cells/VisibleMessageCell.swift | 4 +- Session/Home/HomeVC.swift | 2 +- Session/Meta/AppDelegate.m | 2 +- .../Translations/de.lproj/Localizable.strings | 2 + .../Translations/en.lproj/Localizable.strings | 2 + .../Translations/es.lproj/Localizable.strings | 2 + .../Translations/fa.lproj/Localizable.strings | 2 + .../Translations/fi.lproj/Localizable.strings | 2 + .../Translations/fr.lproj/Localizable.strings | 2 + .../Translations/hi.lproj/Localizable.strings | 2 + .../Translations/hr.lproj/Localizable.strings | 2 + .../id-ID.lproj/Localizable.strings | 2 + .../Translations/it.lproj/Localizable.strings | 2 + .../Translations/ja.lproj/Localizable.strings | 2 + .../Translations/nl.lproj/Localizable.strings | 2 + .../Translations/pl.lproj/Localizable.strings | 2 + .../pt_BR.lproj/Localizable.strings | 2 + .../Translations/ru.lproj/Localizable.strings | 2 + .../Translations/si.lproj/Localizable.strings | 2 + .../Translations/sk.lproj/Localizable.strings | 2 + .../Translations/sv.lproj/Localizable.strings | 2 + .../Translations/th.lproj/Localizable.strings | 2 + .../vi-VN.lproj/Localizable.strings | 2 + .../zh-Hant.lproj/Localizable.strings | 2 + .../zh_CN.lproj/Localizable.strings | 2 + .../Database/Storage+Messaging.swift | 44 +++- .../Open Groups/Models/BatchRequestInfo.swift | 86 +++++-- .../Open Groups/Models/Capabilities.swift | 4 +- .../Open Groups/Models/OpenGroup.swift | 2 +- .../Open Groups/Models/RoomPollInfo.swift | 38 --- .../Models/SendDirectMessageRequest.swift | 2 - .../Open Groups/OpenGroupAPI+ObjC.swift | 9 +- .../Open Groups/OpenGroupAPI.swift | 230 +++++++++++------- .../Open Groups/OpenGroupManager.swift | 104 ++++++-- .../Open Groups/Types/Request.swift | 39 ++- .../MessageReceiver+Handling.swift | 10 +- .../MessageSender+ClosedGroups.swift | 12 +- .../Sending & Receiving/MessageSender.swift | 4 +- .../Pollers/OpenGroupPoller.swift | 6 +- SessionMessagingKit/Storage.swift | 16 +- SessionMessagingKit/Threads/TSGroupModel.h | 4 +- SessionMessagingKit/Threads/TSGroupModel.m | 6 + SessionMessagingKit/Threads/TSGroupThread.m | 3 +- .../Open Groups/OpenGroupAPIV2Tests.swift | 6 +- 47 files changed, 490 insertions(+), 226 deletions(-) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index faf1e07a3..3b2e23a2c 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -697,12 +697,20 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc let explanation = "This will ban the selected user from this room. It won't ban them from other rooms." let alert = UIAlertController(title: "Session", message: explanation, preferredStyle: .alert) let threadID = thread.uniqueId! - alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in + alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { [weak self] _ in let publicKey = message.authorId guard let openGroup = Storage.shared.getOpenGroup(for: threadID) else { return } - OpenGroupAPI.legacyBan(publicKey, from: openGroup.room, on: openGroup.server).retainUntilComplete() + let promise = OpenGroupAPI.userBan(publicKey, from: [openGroup.room], on: openGroup.server) + promise.catch(on: DispatchQueue.main) { _ in + OWSAlerts.showErrorAlert(message: NSLocalizedString("context_menu_ban_user_error_alert_message", comment: "")) + } + promise.retainUntilComplete() + + self?.becomeFirstResponder() + })) + alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: { [weak self] _ in + self?.becomeFirstResponder() })) - alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: nil)) present(alert, animated: true, completion: nil) } @@ -711,12 +719,19 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc let explanation = "This will ban the selected user from this room and delete all messages sent by them. It won't ban them from other rooms or delete the messages they sent there." let alert = UIAlertController(title: "Session", message: explanation, preferredStyle: .alert) let threadID = thread.uniqueId! - alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in + alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { [weak self] _ in let publicKey = message.authorId guard let openGroup = Storage.shared.getOpenGroup(for: threadID) else { return } - OpenGroupAPI.legacyBanAndDeleteAllMessages(publicKey, from: openGroup.room, on: openGroup.server).retainUntilComplete() + let promise = OpenGroupAPI.userBanAndDeleteAllMessage(publicKey, for: [openGroup.room], on: openGroup.server) + promise.catch(on: DispatchQueue.main) { _ in + OWSAlerts.showErrorAlert(message: NSLocalizedString("context_menu_ban_user_error_alert_message", comment: "")) + } + promise.retainUntilComplete() // TODO: Test This + self?.becomeFirstResponder() + })) + alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: { [weak self] _ in + self?.becomeFirstResponder() })) - alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: nil)) present(alert, animated: true, completion: nil) } diff --git a/Session/Conversations/ConversationViewItem.m b/Session/Conversations/ConversationViewItem.m index 0979ce195..9fe60412b 100644 --- a/Session/Conversations/ConversationViewItem.m +++ b/Session/Conversations/ConversationViewItem.m @@ -1006,7 +1006,7 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) // If it's an incoming message the user must have moderator status if (self.interaction.interactionType == OWSInteractionType_IncomingMessage) { NSString *userPublicKey = [LKStorage.shared getUserPublicKey]; - if (![SNOpenGroupManager isUserModerator:userPublicKey forRoom:openGroup.room onServer:openGroup.server]) { return; } + if (![SNOpenGroupManager isUserModeratorOrAdmin:userPublicKey forRoom:openGroup.room onServer:openGroup.server]) { return; } } // Delete the message @@ -1060,7 +1060,7 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) if (self.interaction.interactionType == OWSInteractionType_IncomingMessage) { NSString *userPublicKey = [LKStorage.shared getUserPublicKey]; if (openGroup != nil) { - if (![SNOpenGroupManager isUserModerator:userPublicKey forRoom:openGroup.room onServer:openGroup.server]) { return; } + if (![SNOpenGroupManager isUserModeratorOrAdmin:userPublicKey forRoom:openGroup.room onServer:openGroup.server]) { return; } } } @@ -1133,7 +1133,7 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) if (interationType == OWSInteractionType_IncomingMessage) { // Only allow deletion on incoming messages if the user has moderation permission if (openGroup != nil) { - return [SNOpenGroupManager isUserModerator:[SNGeneralUtilities getUserPublicKey] forRoom:openGroup.room onServer:openGroup.server]; + return [SNOpenGroupManager isUserModeratorOrAdmin:[SNGeneralUtilities getUserPublicKey] forRoom:openGroup.room onServer:openGroup.server]; } } else { return YES; @@ -1155,7 +1155,7 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) // Check that we're a moderator if (openGroup != nil) { - return [SNOpenGroupManager isUserModerator:[SNGeneralUtilities getUserPublicKey] forRoom:openGroup.room onServer:openGroup.server]; + return [SNOpenGroupManager isUserModeratorOrAdmin:[SNGeneralUtilities getUserPublicKey] forRoom:openGroup.room onServer:openGroup.server]; } } diff --git a/Session/Conversations/Input View/MentionSelectionView.swift b/Session/Conversations/Input View/MentionSelectionView.swift index a5cc636c4..2fc40ed8a 100644 --- a/Session/Conversations/Input View/MentionSelectionView.swift +++ b/Session/Conversations/Input View/MentionSelectionView.swift @@ -163,8 +163,8 @@ private extension MentionSelectionView { profilePictureView.publicKey = mentionCandidate.publicKey profilePictureView.update() if let server = openGroupServer, let room = openGroupRoom { - let isUserModerator = OpenGroupManager.isUserModerator(mentionCandidate.publicKey, for: room, on: server) - moderatorIconImageView.isHidden = !isUserModerator + let isUserModeratorOrAdmin = OpenGroupManager.isUserModeratorOrAdmin(mentionCandidate.publicKey, for: room, on: server) + moderatorIconImageView.isHidden = !isUserModeratorOrAdmin } else { moderatorIconImageView.isHidden = true } diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 9b351e31f..6e26feb19 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -219,8 +219,8 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { } if let senderSessionID = senderSessionID, message.isOpenGroupMessage { if let openGroup = Storage.shared.getOpenGroup(for: message.uniqueThreadId) { - let isUserModerator = OpenGroupManager.isUserModerator(senderSessionID, for: openGroup.room, on: openGroup.server) - moderatorIconImageView.isHidden = !isUserModerator || profilePictureView.isHidden + let isUserModeratorOrAdmin = OpenGroupManager.isUserModeratorOrAdmin(senderSessionID, for: openGroup.room, on: openGroup.server) + moderatorIconImageView.isHidden = !isUserModeratorOrAdmin || profilePictureView.isHidden } else { moderatorIconImageView.isHidden = true } diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 5ffdcbdcb..06af824a5 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -160,7 +160,7 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv let _ = IP2Country.shared.populateCacheIfNeeded() } // Get default open group rooms if needed - OpenGroupAPI.legacyGetDefaultRoomsIfNeeded() + OpenGroupManager.getDefaultRoomsIfNeeded() } override func viewDidAppear(_ animated: Bool) { diff --git a/Session/Meta/AppDelegate.m b/Session/Meta/AppDelegate.m index 124557489..f29618d37 100644 --- a/Session/Meta/AppDelegate.m +++ b/Session/Meta/AppDelegate.m @@ -383,7 +383,7 @@ static NSTimeInterval launchStartedAt; } if (CurrentAppContext().isMainApp) { - [SNOpenGroupAPI legacyGetDefaultRoomsIfNeeded]; + [SNOpenGroupManager getDefaultRoomsIfNeeded]; } [[SNSnodeAPI getSnodePool] retainUntilComplete]; diff --git a/Session/Meta/Translations/de.lproj/Localizable.strings b/Session/Meta/Translations/de.lproj/Localizable.strings index d4706454b..7a54da603 100644 --- a/Session/Meta/Translations/de.lproj/Localizable.strings +++ b/Session/Meta/Translations/de.lproj/Localizable.strings @@ -584,6 +584,7 @@ "context_menu_save" = "Save"; "context_menu_ban_user" = "Ban User"; "context_menu_ban_and_delete_all" = "Ban and Delete All"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "Add attachments"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "Document"; @@ -603,3 +604,4 @@ "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "Fehler"; diff --git a/Session/Meta/Translations/en.lproj/Localizable.strings b/Session/Meta/Translations/en.lproj/Localizable.strings index fdc42a4d4..c325fdc09 100644 --- a/Session/Meta/Translations/en.lproj/Localizable.strings +++ b/Session/Meta/Translations/en.lproj/Localizable.strings @@ -584,6 +584,7 @@ "context_menu_save" = "Save"; "context_menu_ban_user" = "Ban User"; "context_menu_ban_and_delete_all" = "Ban and Delete All"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "Add attachments"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "Document"; @@ -613,3 +614,4 @@ "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "Error"; diff --git a/Session/Meta/Translations/es.lproj/Localizable.strings b/Session/Meta/Translations/es.lproj/Localizable.strings index 300b28f3a..bd08f9b6f 100644 --- a/Session/Meta/Translations/es.lproj/Localizable.strings +++ b/Session/Meta/Translations/es.lproj/Localizable.strings @@ -584,6 +584,7 @@ "context_menu_save" = "Save"; "context_menu_ban_user" = "Ban User"; "context_menu_ban_and_delete_all" = "Ban and Delete All"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "Add attachments"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "Document"; @@ -603,3 +604,4 @@ "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "Fallo"; diff --git a/Session/Meta/Translations/fa.lproj/Localizable.strings b/Session/Meta/Translations/fa.lproj/Localizable.strings index a1f0e03c4..14fdea87c 100644 --- a/Session/Meta/Translations/fa.lproj/Localizable.strings +++ b/Session/Meta/Translations/fa.lproj/Localizable.strings @@ -584,6 +584,7 @@ "context_menu_save" = "Save"; "context_menu_ban_user" = "Ban User"; "context_menu_ban_and_delete_all" = "Ban and Delete All"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "Add attachments"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "Document"; @@ -603,3 +604,4 @@ "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "خطاء"; diff --git a/Session/Meta/Translations/fi.lproj/Localizable.strings b/Session/Meta/Translations/fi.lproj/Localizable.strings index 8aadcd63c..86a385870 100644 --- a/Session/Meta/Translations/fi.lproj/Localizable.strings +++ b/Session/Meta/Translations/fi.lproj/Localizable.strings @@ -584,6 +584,7 @@ "context_menu_save" = "Save"; "context_menu_ban_user" = "Ban User"; "context_menu_ban_and_delete_all" = "Ban and Delete All"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "Add attachments"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "Document"; @@ -603,3 +604,4 @@ "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "Virhe"; diff --git a/Session/Meta/Translations/fr.lproj/Localizable.strings b/Session/Meta/Translations/fr.lproj/Localizable.strings index 3474f2377..612498703 100644 --- a/Session/Meta/Translations/fr.lproj/Localizable.strings +++ b/Session/Meta/Translations/fr.lproj/Localizable.strings @@ -584,6 +584,7 @@ "context_menu_save" = "Save"; "context_menu_ban_user" = "Ban User"; "context_menu_ban_and_delete_all" = "Ban and Delete All"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "Add attachments"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "Document"; @@ -603,3 +604,4 @@ "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "Erreur"; diff --git a/Session/Meta/Translations/hi.lproj/Localizable.strings b/Session/Meta/Translations/hi.lproj/Localizable.strings index ab6480faf..e636710ac 100644 --- a/Session/Meta/Translations/hi.lproj/Localizable.strings +++ b/Session/Meta/Translations/hi.lproj/Localizable.strings @@ -584,6 +584,7 @@ "context_menu_save" = "Save"; "context_menu_ban_user" = "Ban User"; "context_menu_ban_and_delete_all" = "Ban and Delete All"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "Add attachments"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "Document"; @@ -603,3 +604,4 @@ "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "Error"; diff --git a/Session/Meta/Translations/hr.lproj/Localizable.strings b/Session/Meta/Translations/hr.lproj/Localizable.strings index 5cd489915..f454ffde9 100644 --- a/Session/Meta/Translations/hr.lproj/Localizable.strings +++ b/Session/Meta/Translations/hr.lproj/Localizable.strings @@ -584,6 +584,7 @@ "context_menu_save" = "Save"; "context_menu_ban_user" = "Ban User"; "context_menu_ban_and_delete_all" = "Ban and Delete All"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "Add attachments"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "Document"; @@ -603,3 +604,4 @@ "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "Greška"; diff --git a/Session/Meta/Translations/id-ID.lproj/Localizable.strings b/Session/Meta/Translations/id-ID.lproj/Localizable.strings index f04872b6d..b808d77bb 100644 --- a/Session/Meta/Translations/id-ID.lproj/Localizable.strings +++ b/Session/Meta/Translations/id-ID.lproj/Localizable.strings @@ -584,6 +584,7 @@ "context_menu_save" = "Save"; "context_menu_ban_user" = "Ban User"; "context_menu_ban_and_delete_all" = "Ban and Delete All"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "Add attachments"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "Document"; @@ -603,3 +604,4 @@ "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "Galat"; diff --git a/Session/Meta/Translations/it.lproj/Localizable.strings b/Session/Meta/Translations/it.lproj/Localizable.strings index c820fcb52..a5de8c899 100644 --- a/Session/Meta/Translations/it.lproj/Localizable.strings +++ b/Session/Meta/Translations/it.lproj/Localizable.strings @@ -584,6 +584,7 @@ "context_menu_save" = "Save"; "context_menu_ban_user" = "Ban User"; "context_menu_ban_and_delete_all" = "Ban and Delete All"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "Add attachments"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "Document"; @@ -603,3 +604,4 @@ "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "Errore"; diff --git a/Session/Meta/Translations/ja.lproj/Localizable.strings b/Session/Meta/Translations/ja.lproj/Localizable.strings index 5c4a31414..5b29bc60f 100644 --- a/Session/Meta/Translations/ja.lproj/Localizable.strings +++ b/Session/Meta/Translations/ja.lproj/Localizable.strings @@ -584,6 +584,7 @@ "context_menu_save" = "Save"; "context_menu_ban_user" = "Ban User"; "context_menu_ban_and_delete_all" = "Ban and Delete All"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "Add attachments"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "Document"; @@ -603,3 +604,4 @@ "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "エラー"; diff --git a/Session/Meta/Translations/nl.lproj/Localizable.strings b/Session/Meta/Translations/nl.lproj/Localizable.strings index 0faad477e..21698bff6 100644 --- a/Session/Meta/Translations/nl.lproj/Localizable.strings +++ b/Session/Meta/Translations/nl.lproj/Localizable.strings @@ -584,6 +584,7 @@ "context_menu_save" = "Save"; "context_menu_ban_user" = "Ban User"; "context_menu_ban_and_delete_all" = "Ban and Delete All"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "Add attachments"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "Document"; @@ -603,3 +604,4 @@ "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "Fout"; diff --git a/Session/Meta/Translations/pl.lproj/Localizable.strings b/Session/Meta/Translations/pl.lproj/Localizable.strings index 26af9658a..385f9230e 100644 --- a/Session/Meta/Translations/pl.lproj/Localizable.strings +++ b/Session/Meta/Translations/pl.lproj/Localizable.strings @@ -584,6 +584,7 @@ "context_menu_save" = "Save"; "context_menu_ban_user" = "Ban User"; "context_menu_ban_and_delete_all" = "Ban and Delete All"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "Add attachments"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "Document"; @@ -603,3 +604,4 @@ "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "Błąd"; diff --git a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings index cd1f5d568..da34f2f33 100644 --- a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings +++ b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings @@ -584,6 +584,7 @@ "context_menu_save" = "Save"; "context_menu_ban_user" = "Ban User"; "context_menu_ban_and_delete_all" = "Ban and Delete All"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "Add attachments"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "Document"; @@ -603,3 +604,4 @@ "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "Erro"; diff --git a/Session/Meta/Translations/ru.lproj/Localizable.strings b/Session/Meta/Translations/ru.lproj/Localizable.strings index 285c21272..c7fae1f14 100644 --- a/Session/Meta/Translations/ru.lproj/Localizable.strings +++ b/Session/Meta/Translations/ru.lproj/Localizable.strings @@ -584,6 +584,7 @@ "context_menu_save" = "Save"; "context_menu_ban_user" = "Ban User"; "context_menu_ban_and_delete_all" = "Ban and Delete All"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "Add attachments"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "Document"; @@ -603,3 +604,4 @@ "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "Ошибка"; diff --git a/Session/Meta/Translations/si.lproj/Localizable.strings b/Session/Meta/Translations/si.lproj/Localizable.strings index 81b1eef7e..5a18c6aed 100644 --- a/Session/Meta/Translations/si.lproj/Localizable.strings +++ b/Session/Meta/Translations/si.lproj/Localizable.strings @@ -584,6 +584,7 @@ "context_menu_save" = "Save"; "context_menu_ban_user" = "Ban User"; "context_menu_ban_and_delete_all" = "Ban and Delete All"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "Add attachments"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "Document"; @@ -604,3 +605,4 @@ "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "Error"; diff --git a/Session/Meta/Translations/sk.lproj/Localizable.strings b/Session/Meta/Translations/sk.lproj/Localizable.strings index 4e09d1d0d..25cb3c566 100644 --- a/Session/Meta/Translations/sk.lproj/Localizable.strings +++ b/Session/Meta/Translations/sk.lproj/Localizable.strings @@ -584,6 +584,7 @@ "context_menu_save" = "Save"; "context_menu_ban_user" = "Ban User"; "context_menu_ban_and_delete_all" = "Ban and Delete All"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "Add attachments"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "Document"; @@ -603,3 +604,4 @@ "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "Error"; diff --git a/Session/Meta/Translations/sv.lproj/Localizable.strings b/Session/Meta/Translations/sv.lproj/Localizable.strings index 62a0bb76f..0183db689 100644 --- a/Session/Meta/Translations/sv.lproj/Localizable.strings +++ b/Session/Meta/Translations/sv.lproj/Localizable.strings @@ -584,6 +584,7 @@ "context_menu_save" = "Save"; "context_menu_ban_user" = "Ban User"; "context_menu_ban_and_delete_all" = "Ban and Delete All"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "Add attachments"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "Document"; @@ -603,3 +604,4 @@ "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "Fel"; diff --git a/Session/Meta/Translations/th.lproj/Localizable.strings b/Session/Meta/Translations/th.lproj/Localizable.strings index 05c3bd3c0..cbfb75fa9 100644 --- a/Session/Meta/Translations/th.lproj/Localizable.strings +++ b/Session/Meta/Translations/th.lproj/Localizable.strings @@ -584,6 +584,7 @@ "context_menu_save" = "Save"; "context_menu_ban_user" = "Ban User"; "context_menu_ban_and_delete_all" = "Ban and Delete All"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "Add attachments"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "Document"; @@ -603,3 +604,4 @@ "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "ข้อผิดพลาด"; diff --git a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings index c2571518a..a22737e26 100644 --- a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings +++ b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings @@ -584,6 +584,7 @@ "context_menu_save" = "Save"; "context_menu_ban_user" = "Ban User"; "context_menu_ban_and_delete_all" = "Ban and Delete All"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "Add attachments"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "Document"; @@ -603,3 +604,4 @@ "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "Error"; diff --git a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings index fe73fb226..37948513e 100644 --- a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings @@ -584,6 +584,7 @@ "context_menu_save" = "Save"; "context_menu_ban_user" = "Ban User"; "context_menu_ban_and_delete_all" = "Ban and Delete All"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "Add attachments"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "Document"; @@ -603,3 +604,4 @@ "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "Error"; diff --git a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings index f0f688fd1..ed1757702 100644 --- a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings @@ -584,6 +584,7 @@ "context_menu_save" = "Save"; "context_menu_ban_user" = "Ban User"; "context_menu_ban_and_delete_all" = "Ban and Delete All"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "Add attachments"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "Document"; @@ -603,3 +604,4 @@ "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "错误"; diff --git a/SessionMessagingKit/Database/Storage+Messaging.swift b/SessionMessagingKit/Database/Storage+Messaging.swift index 15b7b3664..b139fa1f5 100644 --- a/SessionMessagingKit/Database/Storage+Messaging.swift +++ b/SessionMessagingKit/Database/Storage+Messaging.swift @@ -1,4 +1,5 @@ import PromiseKit +import Sodium extension Storage { @@ -27,7 +28,43 @@ extension Storage { guard let threadID = getOrCreateThread(for: message.syncTarget ?? message.sender!, groupPublicKey: groupPublicKey, openGroupID: openGroupID, using: transaction), let thread = TSThread.fetch(uniqueId: threadID, transaction: transaction) else { return nil } let tsMessage: TSMessage - if message.sender == getUserPublicKey() { + let isOutgoingMessage: Bool + + // Need to check if the blinded id matches for open groups + if let sender: String = message.sender, let openGroupID: String = openGroupID { + guard let userEdKeyPair: Box.KeyPair = Storage.shared.getUserED25519KeyPair() else { return nil } + + switch IdPrefix(with: sender) { + case .blinded: + let sodium: Sodium = Sodium() + let serverNameParts: [String.SubSequence] = openGroupID.split(separator: ".") + let serverName: String = serverNameParts[0..<(serverNameParts.count - 1)].joined(separator: ".") + + // Note: This is horrible but it doesn't look like there is going to be a nicer way to do it... + guard let serverPublicKey: String = Storage.shared.getOpenGroupPublicKey(for: serverName) else { + return nil + } + guard let blindedKeyPair: Box.KeyPair = sodium.blindedKeyPair(serverPublicKey: serverPublicKey, edKeyPair: userEdKeyPair, genericHash: sodium.genericHash) else { + return nil + } + + isOutgoingMessage = (sender == IdPrefix.blinded.hexEncodedPublicKey(for: blindedKeyPair.publicKey)) + + case .standard, .unblinded: + isOutgoingMessage = ( + message.sender == getUserPublicKey() || + sender == IdPrefix.unblinded.hexEncodedPublicKey(for: userEdKeyPair.publicKey) + ) + + case .none: + isOutgoingMessage = false + } + } + else { + isOutgoingMessage = (message.sender == getUserPublicKey()) + } + + if isOutgoingMessage { if let _ = TSOutgoingMessage.find(withTimestamp: message.sentTimestamp!) { return nil } let tsOutgoingMessage = TSOutgoingMessage.from(message, associatedWith: thread, using: transaction) var recipients: [String] = [] @@ -41,14 +78,17 @@ extension Storage { tsOutgoingMessage.update(withSentRecipient: recipient, wasSentByUD: true, transaction: transaction) } tsMessage = tsOutgoingMessage - } else { + } + else { tsMessage = TSIncomingMessage.from(message, quotedMessage: quotedMessage, linkPreview: linkPreview, associatedWith: thread) } + tsMessage.save(with: transaction) tsMessage.attachments(with: transaction).forEach { attachment in attachment.albumMessageId = tsMessage.uniqueId! attachment.save(with: transaction) } + return tsMessage.uniqueId! } diff --git a/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift b/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift index 89e35dc9c..3796d1478 100644 --- a/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift +++ b/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift @@ -8,27 +8,62 @@ import SessionSnodeKit extension OpenGroupAPI { // MARK: - BatchSubRequest - struct BatchSubRequest: Codable { + struct BatchSubRequest: Encodable { + enum CodingKeys: String, CodingKey { + case method + case path + case headers + case json + case b64 + case bytes + } + let method: HTTP.Verb let path: String let headers: [String: String]? - let json: String? - let b64: String? - init(request: Request) { + /// The `jsonBodyEncoder` is used to avoid having to make `BatchSubRequest` a generic type (haven't found a good way + /// to keep `BatchSubRequest` encodable using protocols unfortunately so need this work around) + private let jsonBodyEncoder: ((inout KeyedEncodingContainer, CodingKeys) throws -> ())? + private let b64: String? + private let bytes: [UInt8]? + + init(request: Request) { self.method = request.method self.path = request.urlPathAndParamsString self.headers = (request.headers.isEmpty ? nil : request.headers.toHTTPHeaders()) - // TODO: Differentiate between JSON and b64 body. - if let body: Data = request.body, let bodyString: String = String(data: body, encoding: .utf8) { - self.json = bodyString + // Note: Need to differentiate between JSON, b64 string and bytes body values to ensure they are + // encoded correctly so the server knows how to handle them + switch request.body { + case let bodyString as String: + self.jsonBodyEncoder = nil + self.b64 = bodyString + self.bytes = nil + + case let bodyBytes as [UInt8]: + self.jsonBodyEncoder = nil + self.b64 = nil + self.bytes = bodyBytes + + default: + self.jsonBodyEncoder = { [body = request.body] container, key in + try container.encodeIfPresent(body, forKey: key) + } + self.b64 = nil + self.bytes = nil } - else { - self.json = nil - } - - self.b64 = nil + } + + func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(method, forKey: .method) + try container.encode(path, forKey: .path) + try container.encodeIfPresent(headers, forKey: .headers) + try jsonBodyEncoder?(&container, .json) + try container.encodeIfPresent(b64, forKey: .b64) + try container.encodeIfPresent(bytes, forKey: .bytes) } } @@ -40,15 +75,21 @@ extension OpenGroupAPI { let body: T } - // MARK: - BatchRequestInfo + // MARK: - BatchRequestInfo - struct BatchRequestInfo { - let request: Request + struct BatchRequestInfo: BatchRequestInfoType { + let request: Request let responseType: Codable.Type - init(request: Request, responseType: T.Type) { + var endpoint: Endpoint { request.endpoint } + + init(request: Request, responseType: R.Type) { self.request = request - self.responseType = BatchSubResponse.self + self.responseType = BatchSubResponse.self + } + + func toSubRequest() -> BatchSubRequest { + return BatchSubRequest(request: request) } } @@ -59,6 +100,17 @@ extension OpenGroupAPI { typealias BatchResponse = [(OnionRequestResponseInfoType, Codable)] } +// MARK: - BatchRequestInfoType + +/// This protocol is designed to erase the types from `BatchRequestInfo` so multiple types can be used +/// in arrays when doing `/batch` and `/sequence` requests +protocol BatchRequestInfoType { + var responseType: Codable.Type { get } + var endpoint: OpenGroupAPI.Endpoint { get } + + func toSubRequest() -> OpenGroupAPI.BatchSubRequest +} + // MARK: - Convenience public extension Decodable { diff --git a/SessionMessagingKit/Open Groups/Models/Capabilities.swift b/SessionMessagingKit/Open Groups/Models/Capabilities.swift index deafcd0a5..627183ace 100644 --- a/SessionMessagingKit/Open Groups/Models/Capabilities.swift +++ b/SessionMessagingKit/Open Groups/Models/Capabilities.swift @@ -6,11 +6,11 @@ extension OpenGroupAPI { public struct Capabilities: Codable { public enum Capability: Equatable, CaseIterable, Codable { public static var allCases: [Capability] { - [.sogs, .blinding] + [.sogs, .blind] } case sogs - case blinding // TODO: Get official name + case blind /// Fallback case if the capability isn't supported by this version of the app case unsupported(String) diff --git a/SessionMessagingKit/Open Groups/Models/OpenGroup.swift b/SessionMessagingKit/Open Groups/Models/OpenGroup.swift index d954bdf78..2f2ba12a1 100644 --- a/SessionMessagingKit/Open Groups/Models/OpenGroup.swift +++ b/SessionMessagingKit/Open Groups/Models/OpenGroup.swift @@ -48,7 +48,7 @@ public final class OpenGroup: NSObject, NSCoding { // NSObject/NSCoding conforma name = coder.decodeObject(forKey: "name") as! String groupDescription = coder.decodeObject(forKey: "groupDescription") as? String imageID = coder.decodeObject(forKey: "imageID") as! String? - infoUpdates = ((coder.decodeObject(forKey: "infoUpdates") as? Int64) ?? 0) + infoUpdates = coder.decodeInt64(forKey: "infoUpdates") super.init() } diff --git a/SessionMessagingKit/Open Groups/Models/RoomPollInfo.swift b/SessionMessagingKit/Open Groups/Models/RoomPollInfo.swift index 85f0841d8..b6b68f294 100644 --- a/SessionMessagingKit/Open Groups/Models/RoomPollInfo.swift +++ b/SessionMessagingKit/Open Groups/Models/RoomPollInfo.swift @@ -7,26 +7,13 @@ extension OpenGroupAPI { public struct RoomPollInfo: Codable { enum CodingKeys: String, CodingKey { case token - case created - case name - case description - case imageId = "image_id" - - case infoUpdates = "info_updates" - case messageSequence = "message_sequence" case activeUsers = "active_users" - case activeUsersCutoff = "active_users_cutoff" - case pinnedMessages = "pinned_messages" case admin case globalAdmin = "global_admin" - case admins - case hiddenAdmins = "hidden_admins" case moderator case globalModerator = "global_moderator" - case moderators - case hiddenModerators = "hidden_moderators" case read case defaultRead = "default_read" @@ -39,26 +26,13 @@ extension OpenGroupAPI { } public let token: String? - public let created: TimeInterval? - public let name: String? - public let description: String? - public let imageId: Int64? - - public let infoUpdates: Int64? - public let messageSequence: Int64? public let activeUsers: Int64? - public let activeUsersCutoff: Int64? - public let pinnedMessages: [PinnedMessage]? public let admin: Bool? public let globalAdmin: Bool? - public let admins: [String]? - public let hiddenAdmins: [String]? public let moderator: Bool? public let globalModerator: Bool? - public let moderators: [String]? - public let hiddenModerators: [String]? public let read: Bool? public let defaultRead: Bool? @@ -78,23 +52,11 @@ extension OpenGroupAPI.RoomPollInfo { init(room: OpenGroupAPI.Room) { self.init( token: room.token, - created: room.created, - name: room.name, - description: room.description, - imageId: room.imageId, - infoUpdates: room.infoUpdates, - messageSequence: room.messageSequence, activeUsers: room.activeUsers, - activeUsersCutoff: room.activeUsersCutoff, - pinnedMessages: room.pinnedMessages, admin: room.admin, globalAdmin: room.globalAdmin, - admins: room.admins, - hiddenAdmins: room.hiddenAdmins, moderator: room.moderator, globalModerator: room.globalModerator, - moderators: room.moderators, - hiddenModerators: room.hiddenModerators, read: room.read, defaultRead: room.defaultRead, write: room.write, diff --git a/SessionMessagingKit/Open Groups/Models/SendDirectMessageRequest.swift b/SessionMessagingKit/Open Groups/Models/SendDirectMessageRequest.swift index 2fe3e44c1..64e824251 100644 --- a/SessionMessagingKit/Open Groups/Models/SendDirectMessageRequest.swift +++ b/SessionMessagingKit/Open Groups/Models/SendDirectMessageRequest.swift @@ -5,7 +5,6 @@ import Foundation extension OpenGroupAPI { public struct SendDirectMessageRequest: Codable { let data: Data - let signature: Data // MARK: - Encodable @@ -13,7 +12,6 @@ extension OpenGroupAPI { var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) try container.encode(data.base64EncodedString(), forKey: .data) - try container.encode(signature.base64EncodedString(), forKey: .signature) } } } diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI+ObjC.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI+ObjC.swift index 13ce1c4e1..43b0b9ca5 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI+ObjC.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI+ObjC.swift @@ -1,15 +1,8 @@ import PromiseKit extension OpenGroupAPI { - @objc(deleteMessageWithServerID:fromRoom:onServer:) public static func objc_deleteMessage(with serverID: Int64, from room: String, on server: String) -> AnyPromise { - // TODO: Upgrade this to use the non-legacy version. - return AnyPromise.from(legacyDeleteMessage(with: serverID, from: room, on: server)) - } - - @objc(legacyGetDefaultRoomsIfNeeded) - public static func objc_legacyGetDefaultRoomsIfNeeded() { - return legacyGetDefaultRoomsIfNeeded() + return AnyPromise.from(messageDelete(serverID, in: room, on: server)) } } diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index c369744a7..3e68749a1 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -51,9 +51,9 @@ public final class OpenGroupAPI: NSObject { UserDefaults.standard[.lastOpen] = Date() // Generate the requests - let requestResponseType: [BatchRequestInfo] = [ + let requestResponseType: [BatchRequestInfoType] = [ BatchRequestInfo( - request: Request( + request: Request( server: server, endpoint: .capabilities, queryParameters: [:] // TODO: Add any requirements '.required' @@ -64,8 +64,8 @@ public final class OpenGroupAPI: NSObject { .appending( dependencies.storage.getAllOpenGroups().values .filter { $0.server == server.lowercased() } // Note: The `OpenGroup` type converts to lowercase in init - .flatMap { openGroup -> [BatchRequestInfo] in - let lastSeqNo: Int64? = dependencies.storage.getLastMessageServerID(for: openGroup.room, on: server) + .flatMap { openGroup -> [BatchRequestInfoType] in + let lastSeqNo: Int64? = dependencies.storage.getOpenGroupSequenceNumber(for: openGroup.room, on: server) let targetSeqNo: Int64 = (lastSeqNo ?? 0) let shouldRetrieveRecentMessages: Bool = ( lastSeqNo == nil || ( @@ -79,14 +79,14 @@ public final class OpenGroupAPI: NSObject { return [ BatchRequestInfo( - request: Request( + request: Request( server: server, endpoint: .roomPollInfo(openGroup.room, openGroup.infoUpdates) ), responseType: RoomPollInfo.self ), BatchRequestInfo( - request: Request( + request: Request( server: server, endpoint: (shouldRetrieveRecentMessages ? .roomMessagesRecent(openGroup.room) : @@ -109,19 +109,15 @@ public final class OpenGroupAPI: NSObject { /// No guarantee is made as to the order in which sub-requests are processed; use the `/sequence` instead if you need that. /// /// For contained subrequests that specify a body (i.e. POST or PUT requests) exactly one of `json`, `b64`, or `bytes` must be provided with the request body. - private static func batch(_ server: String, requests: [BatchRequestInfo], using dependencies: Dependencies = Dependencies()) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable)]> { - let requestBody: BatchRequest = requests.map { BatchSubRequest(request: $0.request) } + private static func batch(_ server: String, requests: [BatchRequestInfoType], using dependencies: Dependencies = Dependencies()) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable)]> { + let requestBody: BatchRequest = requests.map { $0.toSubRequest() } let responseTypes = requests.map { $0.responseType } - guard let body: Data = try? JSONEncoder().encode(requestBody) else { - return Promise(error: HTTP.Error.invalidJSON) - } - let request: Request = Request( method: .post, server: server, endpoint: .batch, - body: body + body: requestBody ) return send(request, using: dependencies) @@ -129,7 +125,7 @@ public final class OpenGroupAPI: NSObject { .map { result in result.enumerated() .reduce(into: [:]) { prev, next in - prev[requests[next.offset].request.endpoint] = next.element + prev[requests[next.offset].endpoint] = next.element } } } @@ -139,19 +135,15 @@ public final class OpenGroupAPI: NSObject { /// For example, this can be used to ban and delete all of a user's messages by sequencing the ban followed by the `delete_all`: if the ban fails (e.g. because /// permission is denied) then the `delete_all` will not occur. The batch body and response are identical to the `/batch` endpoint; requests that are not /// carried out because of an earlier failure will have a response code of `412` (Precondition Failed)." - private static func sequence(_ server: String, requests: [BatchRequestInfo], using dependencies: Dependencies = Dependencies()) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable)]> { - let requestBody: BatchRequest = requests.map { BatchSubRequest(request: $0.request) } + private static func sequence(_ server: String, requests: [BatchRequestInfoType], using dependencies: Dependencies = Dependencies()) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable)]> { + let requestBody: BatchRequest = requests.map { $0.toSubRequest() } let responseTypes = requests.map { $0.responseType } - guard let body: Data = try? JSONEncoder().encode(requestBody) else { - return Promise(error: HTTP.Error.invalidJSON) - } - let request: Request = Request( method: .post, server: server, endpoint: .sequence, - body: body + body: requestBody ) // TODO: Handle a `412` response (ie. a required capability isn't supported) @@ -160,7 +152,7 @@ public final class OpenGroupAPI: NSObject { .map { result in result.enumerated() .reduce(into: [:]) { prev, next in - prev[requests[next.offset].request.endpoint] = next.element + prev[requests[next.offset].endpoint] = next.element } } } @@ -168,7 +160,7 @@ public final class OpenGroupAPI: NSObject { // MARK: - Capabilities public static func capabilities(on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Capabilities)> { - let request: Request = Request( + let request: Request = Request( server: server, endpoint: .capabilities, queryParameters: [:] // TODO: Add any requirements '.required'. @@ -182,7 +174,7 @@ public final class OpenGroupAPI: NSObject { // MARK: - Room public static func rooms(for server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [Room])> { - let request: Request = Request( + let request: Request = Request( server: server, endpoint: .rooms ) @@ -192,7 +184,7 @@ public final class OpenGroupAPI: NSObject { } public static func room(for roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Room)> { - let request: Request = Request( + let request: Request = Request( server: server, endpoint: .room(roomToken) ) @@ -202,7 +194,7 @@ public final class OpenGroupAPI: NSObject { } public static func roomPollInfo(lastUpdated: Int64, for roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, RoomPollInfo)> { - let request: Request = Request( + let request: Request = Request( server: server, endpoint: .roomPollInfo(roomToken, lastUpdated) ) @@ -233,15 +225,11 @@ public final class OpenGroupAPI: NSObject { fileIds: nil // TODO: Add support for 'fileIds'. ) - guard let body: Data = try? JSONEncoder().encode(requestBody) else { - return Promise(error: Error.parsingFailed) - } - let request = Request( method: .post, server: server, endpoint: .roomMessage(roomToken), - body: body + body: requestBody ) return send(request, using: dependencies) @@ -249,7 +237,7 @@ public final class OpenGroupAPI: NSObject { } public static func message(_ id: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Message)> { - let request: Request = Request( + let request: Request = Request( server: server, endpoint: .roomMessageIndividual(roomToken, id: id) ) @@ -274,27 +262,45 @@ public final class OpenGroupAPI: NSObject { signature: Data(signResult.signature) ) - guard let body: Data = try? JSONEncoder().encode(requestBody) else { - return Promise(error: Error.parsingFailed) - } - let request: Request = Request( method: .put, server: server, endpoint: .roomMessageIndividual(roomToken, id: id), - body: body + body: requestBody ) // TODO: Handle custom response info? return send(request, using: dependencies) } + + // TODO: Need to test this once the API has been implemented + public static func messageDelete( + _ id: Int64, + in roomToken: String, + on server: String, + using dependencies: Dependencies = Dependencies() + ) -> Promise<(OnionRequestResponseInfoType, Data?)> { + let request: Request = Request( + method: .delete, + server: server, + endpoint: .roomMessageIndividual(roomToken, id: id) + ) + + // TODO: Handle custom response info? Need to let the OpenGroupManager know to delete the message? + // TODO: !!!! This is currently broken - looks like there isn't currently a DELETE endpoint (but there should be) + return send(request, using: dependencies) + .map { response in + print("RAWR") + return response + } + } /// This is the direct request to retrieve recent messages from an Open Group so should be retrieved automatically from the `poll()` /// method, if the logic should change then remove the `@available` line and make sure to route the response of this method to /// the `OpenGroupManager` `handleMessages` method (otherwise the logic may not work correctly) @available(*, unavailable, message: "Avoid using this directly, use the pre-build `poll()` method instead") public static func recentMessages(in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [Message])> { - let request: Request = Request( + let request: Request = Request( server: server, endpoint: .roomMessagesRecent(roomToken) // TODO: Limit?. @@ -311,7 +317,7 @@ public final class OpenGroupAPI: NSObject { @available(*, unavailable, message: "Avoid using this directly, use the pre-build `poll()` method instead") public static func messagesBefore(messageId: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [Message])> { // TODO: Do we need to be able to load old messages? - let request: Request = Request( + let request: Request = Request( server: server, endpoint: .roomMessagesBefore(roomToken, id: messageId) // TODO: Limit?. @@ -327,7 +333,7 @@ public final class OpenGroupAPI: NSObject { /// the `OpenGroupManager` `handleMessages` method (otherwise the logic may not work correctly) @available(*, unavailable, message: "Avoid using this directly, use the pre-build `poll()` method instead") public static func messagesSince(seqNo: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [Message])> { - let request: Request = Request( + let request: Request = Request( server: server, endpoint: .roomMessagesSince(roomToken, seqNo: seqNo) // TODO: Limit?. @@ -341,7 +347,7 @@ public final class OpenGroupAPI: NSObject { // MARK: - Pinning public static func pinMessage(id: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise { - let request: Request = Request( + let request: Request = Request( method: .post, server: server, endpoint: .roomPinMessage(roomToken, id: id) @@ -352,7 +358,7 @@ public final class OpenGroupAPI: NSObject { } public static func unpinMessage(id: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise { - let request: Request = Request( + let request: Request = Request( method: .post, server: server, endpoint: .roomUnpinMessage(roomToken, id: id) @@ -363,7 +369,7 @@ public final class OpenGroupAPI: NSObject { } public static func unpinAll(in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise { - let request: Request = Request( + let request: Request = Request( method: .post, server: server, endpoint: .roomUnpinAll(roomToken) @@ -381,7 +387,7 @@ public final class OpenGroupAPI: NSObject { server: server, endpoint: .roomFile(roomToken), headers: [ .fileName: fileName ].compactMapValues { $0 }, - body: Data(bytes) + body: bytes ) return send(request, using: dependencies) @@ -396,7 +402,7 @@ public final class OpenGroupAPI: NSObject { server: server, endpoint: .roomFileJson(roomToken), headers: [ .fileName: fileName ].compactMapValues { $0 }, - body: Data(base64Encoded: base64EncodedString) + body: base64EncodedString ) return send(request, using: dependencies) @@ -404,7 +410,7 @@ public final class OpenGroupAPI: NSObject { } public static func downloadFile(_ fileId: Int64, from roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Data)> { - let request: Request = Request( + let request: Request = Request( server: server, endpoint: .roomFileIndividual(roomToken, fileId) ) @@ -418,7 +424,7 @@ public final class OpenGroupAPI: NSObject { } public static func downloadFileJson(_ fileId: Int64, from roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, FileDownloadResponse)> { - let request: Request = Request( + let request: Request = Request( server: server, endpoint: .roomFileIndividualJson(roomToken, fileId) ) @@ -430,7 +436,7 @@ public final class OpenGroupAPI: NSObject { // MARK: - Inbox (Message Requests) public static func messageRequests(on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage])> { - let request: Request = Request( + let request: Request = Request( server: server, endpoint: .inbox ) @@ -440,7 +446,7 @@ public final class OpenGroupAPI: NSObject { } public static func messageRequestsSince(id: Int64, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage])> { - let request: Request = Request( + let request: Request = Request( server: server, endpoint: .inboxSince(id: id) ) @@ -459,15 +465,11 @@ public final class OpenGroupAPI: NSObject { // signature: signedMessage.signature // TODO: Confirm whether this needs a signature?? ) - guard let body: Data = try? JSONEncoder().encode(requestBody) else { - return Promise(error: Error.parsingFailed) - } - let request: Request = Request( method: .post, server: server, endpoint: .inboxFor(sessionId: blindedSessionId), - body: body + body: requestBody ) return send(request, using: dependencies) @@ -483,15 +485,11 @@ public final class OpenGroupAPI: NSObject { timeout: timeout ) - guard let body: Data = try? JSONEncoder().encode(requestBody) else { - return Promise(error: Error.parsingFailed) - } - let request: Request = Request( method: .post, server: server, endpoint: .userBan(sessionId), - body: body + body: requestBody ) return send(request, using: dependencies) @@ -503,15 +501,11 @@ public final class OpenGroupAPI: NSObject { global: (roomTokens == nil ? true : nil) ) - guard let body: Data = try? JSONEncoder().encode(requestBody) else { - return Promise(error: Error.parsingFailed) - } - let request: Request = Request( method: .post, server: server, endpoint: .userUnban(sessionId), - body: body + body: requestBody ) return send(request, using: dependencies) @@ -526,15 +520,11 @@ public final class OpenGroupAPI: NSObject { upload: upload ) - guard let body: Data = try? JSONEncoder().encode(requestBody) else { - return Promise(error: Error.parsingFailed) - } - let request: Request = Request( method: .post, server: server, endpoint: .userPermission(sessionId), - body: body + body: requestBody ) return send(request, using: dependencies) @@ -549,15 +539,11 @@ public final class OpenGroupAPI: NSObject { visible: visible ) - guard let body: Data = try? JSONEncoder().encode(requestBody) else { - return Promise(error: Error.parsingFailed) - } - let request: Request = Request( method: .post, server: server, endpoint: .userModerator(sessionId), - body: body + body: requestBody ) return send(request, using: dependencies) @@ -569,23 +555,97 @@ public final class OpenGroupAPI: NSObject { global: (roomTokens == nil ? true : nil) ) - guard let body: Data = try? JSONEncoder().encode(requestBody) else { - return Promise(error: Error.parsingFailed) - } - let request: Request = Request( method: .post, server: server, endpoint: .userDeleteMessages(sessionId), - body: body + body: requestBody ) return send(request, using: dependencies) .decoded(as: UserDeleteMessagesResponse.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) } + public static func userBanAndDeleteAllMessage(_ sessionId: String, for roomTokens: [String]? = nil, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<[OnionRequestResponseInfoType]> { + let banRequestBody: UserBanRequest = UserBanRequest( + rooms: roomTokens, + global: (roomTokens == nil ? true : nil), + timeout: nil + ) + let deleteMessageRequestBody: UserDeleteMessagesRequest = UserDeleteMessagesRequest( + rooms: roomTokens, + global: (roomTokens == nil ? true : nil) + ) + + // Generate the requests + let requestResponseType: [BatchRequestInfoType] = [ + BatchRequestInfo( + request: Request( + method: .post, + server: server, + endpoint: .userBan(sessionId), + body: banRequestBody + ), + responseType: Data?.self + ), + BatchRequestInfo( + request: Request( + method: .post, + server: server, + endpoint: .userDeleteMessages(sessionId), + body: deleteMessageRequestBody + ), + responseType: UserDeleteMessagesResponse.self + ) + ] + + return sequence(server, requests: requestResponseType, using: dependencies) + .map { results in + // TODO: Handle deletions...???? Hand off to OpenGroupAPIManager? + return results.values.map { responseInfo, _ in responseInfo } + } + } + // MARK: - Authentication + // TODO: This is going to have to work differently (ie. `MessageSender+Encryption`) + /// Sign a blinded message request to be sent to a users inbox via SOGS v4 + private static func sign(message: Data, to blindedSessionId: String, on serverName: String, with serverPublicKey: String, using dependencies: Dependencies = Dependencies()) -> Data? { + guard let userEdKeyPair: Box.KeyPair = dependencies.storage.getUserED25519KeyPair() else { return nil } + // TODO: Re-do this + return nil +// guard let blindedKeyPair: BlindedECKeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: serverPublicKey, edKeyPair: userEdKeyPair, genericHash: dependencies.genericHash) else { +// return nil +// } +// guard let blindedRecipientPublicKey: Data = String(blindedSessionId.suffix(from: blindedSessionId.index(blindedSessionId.startIndex, offsetBy: IdPrefix.blinded.rawValue.count))).dataFromHex() else { +// return nil +// } +// +// /// Generate the sharedSecret by "a kB || kA || kB" where +// /// a, A are the users private and public keys respectively, +// /// kA is the users blinded public key +// /// kB is the recipients blinded public key +// let maybeSharedSecret: Data? = dependencies.sodium +// .sharedEdSecret(userEdKeyPair.secretKey, blindedRecipientPublicKey.bytes)? +// .appending(blindedKeyPair.publicKey.bytes) +// .appending(blindedRecipientPublicKey.bytes) +// +// guard let sharedSecret: Data = maybeSharedSecret else { return nil } +// guard let intermediateHash: Bytes = dependencies.genericHash.hash(message: sharedSecret.bytes) else { return nil } +// +// /// Generate the inner message by "message || A" where +// /// A is the sender's ed25519 master pubkey (**not** kA blinded pubkey) +// let innerMessage: Bytes = (message.bytes + userEdKeyPair.publicKey) +// guard let (ciphertext, nonce) = dependencies.aeadXChaCha20Poly1305Ietf.encrypt(message: innerMessage, secretKey: intermediateHash) else { +// return nil +// } +// +// /// Generate the final data by "b'\x00' + ciphertext + nonce" +// let finalData: Bytes = [0] + ciphertext + nonce +// +// return Data(finalData) + } + /// Sign a message to be sent to SOGS (handles both un-blinded and blinded signing based on the server capabilities) public static func sign(_ messageBytes: Bytes, for serverName: String, using dependencies: Dependencies = Dependencies()) -> (publicKey: String, signature: Bytes)? { guard let userEdKeyPair: Box.KeyPair = dependencies.storage.getUserED25519KeyPair() else { return nil } @@ -596,7 +656,7 @@ public final class OpenGroupAPI: NSObject { let server: Server? = dependencies.storage.getOpenGroupServer(name: serverName) // Check if the server supports blinded keys, if so then sign using the blinded key - if server?.capabilities.capabilities.contains(.blinding) == true { + if server?.capabilities.capabilities.contains(.blind) == true { guard let blindedKeyPair: Box.KeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: serverPublicKey, edKeyPair: userEdKeyPair, genericHash: dependencies.genericHash) else { return nil } @@ -680,7 +740,7 @@ public final class OpenGroupAPI: NSObject { // MARK: - Convenience - private static func send(_ request: Request, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Data?)> { + private static func send(_ request: Request, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Data?)> { guard let url: URL = request.url else { return Promise(error: Error.invalidURL) } var urlRequest: URLRequest = URLRequest(url: url) @@ -688,7 +748,13 @@ public final class OpenGroupAPI: NSObject { urlRequest.allHTTPHeaderFields = request.headers .setting(.room, request.room) // TODO: Is this needed anymore? Add at the request level?. .toHTTPHeaders() - urlRequest.httpBody = request.body + + do { + urlRequest.httpBody = try request.bodyData() + } + catch { + return Promise(error: Error.parsingFailed) + } if request.useOnionRouting { guard let publicKey = dependencies.storage.getOpenGroupPublicKey(for: request.server) else { diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 8a5763f74..d67a4786d 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -1,4 +1,6 @@ import PromiseKit +import Sodium +import SessionUtilitiesKit @objc(SNOpenGroupManager) public final class OpenGroupManager: NSObject { @@ -12,6 +14,7 @@ public final class OpenGroupManager: NSObject { public static var defaultRoomsPromise: Promise<[OpenGroupAPI.Room]>? private static var groupImagePromises: [String: Promise] = [:] private static var moderators: [String: [String: Set]] = [:] // Server URL to room ID to set of moderator IDs + private static var admins: [String: [String: Set]] = [:] // Server URL to room ID to set of admin IDs // MARK: - Polling @@ -41,8 +44,7 @@ public final class OpenGroupManager: NSObject { let storage = Storage.shared // Clear any existing data if needed - storage.removeLastMessageServerID(for: roomToken, on: server, using: transaction) - storage.removeLastDeletionServerID(for: roomToken, on: server, using: transaction) + storage.removeOpenGroupSequenceNumber(for: roomToken, on: server, using: transaction) storage.removeAuthToken(for: roomToken, on: server, using: transaction) // Store the public key @@ -91,9 +93,9 @@ public final class OpenGroupManager: NSObject { } storage.updateMessageIDCollectionByPruningMessagesWithIDs(messageIDs, using: transaction) Storage.shared.removeReceivedMessageTimestamps(messageTimestamps, using: transaction) - Storage.shared.removeLastMessageServerID(for: openGroup.room, on: openGroup.server, using: transaction) - Storage.shared.removeLastDeletionServerID(for: openGroup.room, on: openGroup.server, using: transaction) let _ = OpenGroupAPI.legacyDeleteAuthToken(for: openGroup.room, on: openGroup.server) + Storage.shared.removeOpenGroupSequenceNumber(for: openGroup.room, on: openGroup.server, using: transaction) + thread.removeAllThreadInteractions(with: transaction) thread.remove(with: transaction) Storage.shared.removeOpenGroup(for: thread.uniqueId!, using: transaction) @@ -133,31 +135,29 @@ public final class OpenGroupManager: NSObject { let sortedMessages: [OpenGroupAPI.Message] = messages .sorted { lhs, rhs in lhs.seqNo < rhs.seqNo } let seqNo: Int64 = (sortedMessages.last?.seqNo ?? 0) - let lastMessageSeqNo: Int64 = (dependencies.storage.getLastMessageServerID(for: roomToken, on: server) ?? 0) dependencies.storage.write { transaction in var messageServerIDsToRemove: [UInt64] = [] - // Update the 'lastMessageServerId' value if we've gotten a newer message - if seqNo > lastMessageSeqNo { - dependencies.storage.setLastMessageServerID(for: roomToken, on: server, to: seqNo, using: transaction) - } + // Update the 'openGroupSequenceNumber' value (Note: SOGS V4 uses the 'seqNo' instead of the 'serverId') + dependencies.storage.setOpenGroupSequenceNumber(for: roomToken, on: server, to: seqNo, using: transaction) // Process the messages sortedMessages.forEach { message in guard let base64EncodedString: String = message.base64EncodedData, let data = Data(base64Encoded: base64EncodedString), let sender: String = message.sender else { // A message with no data has been deleted so add it to the list to remove - messageServerIDsToRemove.append(UInt64(message.seqNo)) + messageServerIDsToRemove.append(UInt64(message.id)) return } - let envelope = SNProtoEnvelope.builder(type: .sessionMessage, timestamp: UInt64(floor(message.posted))) + // Note: The `posted` value is in seconds but all messages in the database use milliseconds for timestamps + let envelope = SNProtoEnvelope.builder(type: .sessionMessage, timestamp: UInt64(floor(message.posted * 1000))) envelope.setContent(data) envelope.setSource(sender) do { let data = try envelope.buildSerializedData() - let (message, proto) = try MessageReceiver.parse(data, openGroupMessageServerID: UInt64(message.seqNo), isRetry: false, using: transaction) + let (message, proto) = try MessageReceiver.parse(data, openGroupMessageServerID: UInt64(message.id), isRetry: false, using: transaction) try MessageReceiver.handle(message, associatedWithProto: proto, openGroupID: openGroupID, isBackgroundPoll: isBackgroundPoll, using: transaction) } catch { @@ -175,7 +175,9 @@ public final class OpenGroupManager: NSObject { var messagesToRemove: [TSMessage] = [] thread.enumerateInteractions(with: transaction) { interaction, stop in - guard let message: TSMessage = interaction as? TSMessage, messageServerIDsToRemove.contains(message.openGroupServerMessageID) else { return } + guard let message: TSMessage = interaction as? TSMessage, messageServerIDsToRemove.contains(message.openGroupServerMessageID) else { + return + } messagesToRemove.append(message) } @@ -213,13 +215,15 @@ public final class OpenGroupManager: NSObject { let groupId: Data = LKGroupUtilities.getEncodedOpenGroupIDAsData("\(server).\(roomToken)") let userPublicKey: String = getUserHexEncodedPublicKey() let initialModel: TSGroupModel = TSGroupModel( - title: pollInfo.name, + title: (pollInfo.details?.name ?? ""), memberIds: [ userPublicKey ], image: nil, groupId: groupId, groupType: .openGroup, - adminIds: (pollInfo.admins ?? []) + adminIds: (pollInfo.details?.admins ?? []), + moderatorIds: (pollInfo.details?.moderators ?? []) ) + var maybeUpdatedModel: TSGroupModel? = nil // Store/Update everything dependencies.storage.write( @@ -234,21 +238,23 @@ public final class OpenGroupManager: NSObject { guard let publicKey: String = (maybePublicKey ?? existingOpenGroup?.publicKey) else { return } let updatedModel: TSGroupModel = TSGroupModel( - title: (pollInfo.name ?? thread.groupModel.groupName), + title: (pollInfo.details?.name ?? thread.groupModel.groupName), memberIds: Array(Set(thread.groupModel.groupMemberIds).inserting(userPublicKey)), image: thread.groupModel.groupImage, groupId: groupId, groupType: .openGroup, - adminIds: (pollInfo.admins ?? thread.groupModel.groupAdminIds) + adminIds: (pollInfo.details?.admins ?? thread.groupModel.groupAdminIds), + moderatorIds: (pollInfo.details?.moderators ?? thread.groupModel.groupModeratorIds) ) + maybeUpdatedModel = updatedModel let updatedOpenGroup: OpenGroup = OpenGroup( server: server, room: (pollInfo.token ?? roomToken), publicKey: publicKey, - name: (pollInfo.name ?? thread.name()), - groupDescription: (pollInfo.description ?? existingOpenGroup?.description), - imageID: (pollInfo.imageId.map { "\($0)" } ?? existingOpenGroup?.imageID), - infoUpdates: ((pollInfo.infoUpdates ?? existingOpenGroup?.infoUpdates) ?? 0) + name: (pollInfo.details?.name ?? thread.name()), + groupDescription: (pollInfo.details?.description ?? existingOpenGroup?.description), + imageID: (pollInfo.details?.imageId.map { "\($0)" } ?? existingOpenGroup?.imageID), + infoUpdates: ((pollInfo.details?.infoUpdates ?? existingOpenGroup?.infoUpdates) ?? 0) ) let existingUserCount: UInt64? = dependencies.storage.getUserCount(forOpenGroupWithID: updatedOpenGroup.id) @@ -275,13 +281,19 @@ public final class OpenGroupManager: NSObject { } // - Moderators - if let moderators: [String] = pollInfo.moderators { + if let moderators: [String] = (pollInfo.details?.moderators ?? maybeUpdatedModel?.groupModeratorIds) { OpenGroupManager.moderators[server] = (OpenGroupManager.moderators[server] ?? [:]) .setting(roomToken, Set(moderators)) } + + // - Admins + if let admins: [String] = (pollInfo.details?.admins ?? maybeUpdatedModel?.groupAdminIds) { + OpenGroupManager.admins[server] = (OpenGroupManager.admins[server] ?? [:]) + .setting(roomToken, Set(admins)) + } // - Room image (if there is one) - if let imageId: Int64 = pollInfo.imageId { + if let imageId: Int64 = pollInfo.details?.imageId { OpenGroupManager.roomImage(imageId, for: roomToken, on: server) .done(on: DispatchQueue.global(qos: .userInitiated)) { data in dependencies.storage.write { transaction in @@ -303,9 +315,42 @@ public final class OpenGroupManager: NSObject { // MARK: - Convenience - @objc(isUserModerator:forRoom:onServer:) - public static func isUserModerator(_ publicKey: String, for room: String, on server: String) -> Bool { - return (OpenGroupManager.moderators[server]?[room]?.contains(publicKey) ?? false) + /// This method specifies if the given publicKey is a moderator or an admin within a specified Open Group + @objc(isUserModeratorOrAdmin:forRoom:onServer:) + public static func isUserModeratorOrAdmin(_ publicKey: String, for room: String, on server: String) -> Bool { + return isUserModeratorOrAdmin(publicKey, for: room, on: server, using: OpenGroupAPI.Dependencies()) + } + + public static func isUserModeratorOrAdmin(_ publicKey: String, for room: String, on server: String, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) -> Bool { + var targetKeys: [String] = [publicKey] + + // If we are checking for the current users public key then check for the blinded one as well + if publicKey == getUserHexEncodedPublicKey() { + guard let userEdKeyPair: Box.KeyPair = dependencies.storage.getUserED25519KeyPair() else { return false } + guard let serverPublicKey: String = dependencies.storage.getOpenGroupPublicKey(for: server) else { + return false + } + + // Add the unblinded key as an option + targetKeys.append(IdPrefix.unblinded.hexEncodedPublicKey(for: userEdKeyPair.publicKey)) + + let server: OpenGroupAPI.Server? = dependencies.storage.getOpenGroupServer(name: server) + + // Check if the server supports blinded keys, if so then sign using the blinded key + if server?.capabilities.capabilities.contains(.blind) == true { + guard let blindedKeyPair: Box.KeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: serverPublicKey, edKeyPair: userEdKeyPair, genericHash: dependencies.genericHash) else { + return false + } + + // Add the blinded key as an option + targetKeys.append(IdPrefix.blinded.hexEncodedPublicKey(for: blindedKeyPair.publicKey)) + } + } + + return ( + (OpenGroupManager.moderators[server]?[room]?.contains(where: { key in targetKeys.contains(key) }) ?? false) || + (OpenGroupManager.admins[server]?[room]?.contains(where: { key in targetKeys.contains(key) }) ?? false) + ) } public static func getDefaultRoomsIfNeeded(using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) { @@ -408,3 +453,10 @@ public final class OpenGroupManager: NSObject { return (room: room, server: server, publicKey: publicKey) } } + +extension OpenGroupManager { + @objc(getDefaultRoomsIfNeeded) + public static func objc_getDefaultRoomsIfNeeded() { + return getDefaultRoomsIfNeeded() + } +} diff --git a/SessionMessagingKit/Open Groups/Types/Request.swift b/SessionMessagingKit/Open Groups/Types/Request.swift index d6ad29671..3bb50dd05 100644 --- a/SessionMessagingKit/Open Groups/Types/Request.swift +++ b/SessionMessagingKit/Open Groups/Types/Request.swift @@ -4,18 +4,26 @@ import Foundation import SessionUtilitiesKit extension OpenGroupAPI { - struct Request { + struct NoBody: Encodable {} + + struct Request { let method: HTTP.Verb let server: String let room: String? // TODO: Remove this? let endpoint: Endpoint let queryParameters: [QueryParam: String] let headers: [Header: String] - let body: Data? + /// This is the body value sent during the request + /// + /// **Warning:** The `bodyData` value should be used to when making the actual request instead of this as there + /// is custom handling for certain data types + let body: T? let isAuthRequired: Bool /// Always `true` under normal circumstances. You might want to disable /// this when running over Lokinet. let useOnionRouting: Bool + + // MARK: - Initialization init( method: HTTP.Verb = .get, @@ -24,7 +32,7 @@ extension OpenGroupAPI { endpoint: Endpoint, queryParameters: [QueryParam: String] = [:], headers: [Header: String] = [:], - body: Data? = nil, + body: T? = nil, isAuthRequired: Bool = true, useOnionRouting: Bool = true ) { @@ -39,6 +47,8 @@ extension OpenGroupAPI { self.useOnionRouting = useOnionRouting } + // MARK: - Convenience + var url: URL? { return URL(string: "\(server)\(urlPathAndParamsString)") } @@ -54,5 +64,28 @@ extension OpenGroupAPI { .filter { !$0.isEmpty } .joined(separator: "?") } + + func bodyData() throws -> Data? { + // Note: Need to differentiate between JSON, b64 string and bytes body values to ensure they are + // encoded correctly so the server knows how to handle them + switch body { + case let bodyString as String: + // The only acceptable string body is a base64 encoded one + guard let encodedData: Data = Data(base64Encoded: bodyString) else { + throw OpenGroupAPI.Error.parsingFailed + } + + return encodedData + + case let bodyBytes as [UInt8]: + return Data(bodyBytes) + + default: + // Having no body is fine so just return nil + guard let body: T = body else { return nil } + + return try JSONEncoder().encode(body) + } + } } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index 0adea4a7b..28d6c5277 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -473,7 +473,7 @@ extension MessageReceiver { // Create the group let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey) - let group = TSGroupModel(title: name, memberIds: members, image: nil, groupId: groupID, groupType: .closedGroup, adminIds: admins) + let group = TSGroupModel(title: name, memberIds: members, image: nil, groupId: groupID, groupType: .closedGroup, adminIds: admins, moderatorIds: []) let thread: TSGroupThread if let t = TSGroupThread.fetch(uniqueId: TSGroupThread.threadId(fromGroupId: groupID), transaction: transaction) { @@ -568,7 +568,7 @@ extension MessageReceiver { let transaction = transaction as! YapDatabaseReadWriteTransaction performIfValid(for: message, using: transaction) { groupID, thread, group in // Update the group - let newGroupModel = TSGroupModel(title: name, memberIds: group.groupMemberIds, image: nil, groupId: groupID, groupType: .closedGroup, adminIds: group.groupAdminIds) + let newGroupModel = TSGroupModel(title: name, memberIds: group.groupMemberIds, image: nil, groupId: groupID, groupType: .closedGroup, adminIds: group.groupAdminIds, moderatorIds: group.groupModeratorIds) thread.setGroupModel(newGroupModel, with: transaction) // Notify the user if needed guard name != group.groupName else { return } @@ -586,7 +586,7 @@ extension MessageReceiver { // Update the group let addedMembers = membersAsData.map { $0.toHexString() } let members = Set(group.groupMemberIds).union(addedMembers) - let newGroupModel = TSGroupModel(title: group.groupName, memberIds: [String](members), image: nil, groupId: groupID, groupType: .closedGroup, adminIds: group.groupAdminIds) + let newGroupModel = TSGroupModel(title: group.groupName, memberIds: [String](members), image: nil, groupId: groupID, groupType: .closedGroup, adminIds: group.groupAdminIds, moderatorIds: group.groupModeratorIds) thread.setGroupModel(newGroupModel, with: transaction) // Send the latest encryption key pair to the added members if the current user is the admin of the group // @@ -656,7 +656,7 @@ extension MessageReceiver { let zombies = storage.getZombieMembers(for: groupPublicKey).subtracting(removedMembers) storage.setZombieMembers(for: groupPublicKey, to: zombies, using: transaction) // Update the group - let newGroupModel = TSGroupModel(title: group.groupName, memberIds: [String](members), image: nil, groupId: groupID, groupType: .closedGroup, adminIds: group.groupAdminIds) + let newGroupModel = TSGroupModel(title: group.groupName, memberIds: [String](members), image: nil, groupId: groupID, groupType: .closedGroup, adminIds: group.groupAdminIds, moderatorIds: group.groupModeratorIds) thread.setGroupModel(newGroupModel, with: transaction) // Notify the user if needed guard members != Set(group.groupMemberIds) else { return } @@ -689,7 +689,7 @@ extension MessageReceiver { storage.setZombieMembers(for: groupPublicKey, to: zombies, using: transaction) } // Update the group - let newGroupModel = TSGroupModel(title: group.groupName, memberIds: [String](members), image: nil, groupId: groupID, groupType: .closedGroup, adminIds: group.groupAdminIds) + let newGroupModel = TSGroupModel(title: group.groupName, memberIds: [String](members), image: nil, groupId: groupID, groupType: .closedGroup, adminIds: group.groupAdminIds, moderatorIds: group.groupModeratorIds) thread.setGroupModel(newGroupModel, with: transaction) // Notify the user if needed guard members != Set(group.groupMemberIds) else { return } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift index 93500c512..7e942438d 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift @@ -18,7 +18,7 @@ extension MessageSender { let admins = [ userPublicKey ] let adminsAsData = admins.map { Data(hex: $0) } let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey) - let group = TSGroupModel(title: name, memberIds: [String](members), image: nil, groupId: groupID, groupType: .closedGroup, adminIds: admins) + let group = TSGroupModel(title: name, memberIds: [String](members), image: nil, groupId: groupID, groupType: .closedGroup, adminIds: admins, moderatorIds: []) let thread = TSGroupThread.getOrCreateThread(with: group, transaction: transaction) thread.save(with: transaction) // Send a closed group update message to all members individually @@ -135,7 +135,7 @@ extension MessageSender { let closedGroupControlMessage = ClosedGroupControlMessage(kind: .nameChange(name: name)) MessageSender.send(closedGroupControlMessage, in: thread, using: transaction) // Update the group - let newGroupModel = TSGroupModel(title: name, memberIds: group.groupMemberIds, image: nil, groupId: groupID, groupType: .closedGroup, adminIds: group.groupAdminIds) + let newGroupModel = TSGroupModel(title: name, memberIds: group.groupMemberIds, image: nil, groupId: groupID, groupType: .closedGroup, adminIds: group.groupAdminIds, moderatorIds: group.groupModeratorIds) thread.setGroupModel(newGroupModel, with: transaction) // Notify the user let updateInfo = group.getInfoStringAboutUpdate(to: newGroupModel) @@ -181,7 +181,7 @@ extension MessageSender { MessageSender.send(closedGroupControlMessage, in: thread, using: transaction) } // Update the group - let newGroupModel = TSGroupModel(title: group.groupName, memberIds: members, image: nil, groupId: groupID, groupType: .closedGroup, adminIds: group.groupAdminIds) + let newGroupModel = TSGroupModel(title: group.groupName, memberIds: members, image: nil, groupId: groupID, groupType: .closedGroup, adminIds: group.groupAdminIds, moderatorIds: group.groupModeratorIds) thread.setGroupModel(newGroupModel, with: transaction) // Notify the user let updateInfo = group.getInfoStringAboutUpdate(to: newGroupModel) @@ -231,7 +231,7 @@ extension MessageSender { generateAndSendNewEncryptionKeyPair(for: groupPublicKey, to: members, using: transaction) }.map { _ in } // Update the group - let newGroupModel = TSGroupModel(title: group.groupName, memberIds: [String](members), image: nil, groupId: groupID, groupType: .closedGroup, adminIds: group.groupAdminIds) + let newGroupModel = TSGroupModel(title: group.groupName, memberIds: [String](members), image: nil, groupId: groupID, groupType: .closedGroup, adminIds: group.groupAdminIds, moderatorIds: group.groupModeratorIds) thread.setGroupModel(newGroupModel, with: transaction) // Notify the user if needed (not if only zombie members were removed) if !membersToRemove.subtracting(oldZombies).isEmpty { @@ -266,8 +266,10 @@ extension MessageSender { let group = thread.groupModel let userPublicKey = getUserHexEncodedPublicKey() let isCurrentUserAdmin = group.groupAdminIds.contains(userPublicKey) + let isCurrentUserModerator = group.groupModeratorIds.contains(userPublicKey) let members: Set = isCurrentUserAdmin ? [] : Set(group.groupMemberIds).subtracting([ userPublicKey ]) // If the admin leaves the group is disbanded let admins: Set = isCurrentUserAdmin ? [] : Set(group.groupAdminIds) + let moderators: Set = isCurrentUserModerator ? [] : Set(group.groupModeratorIds) // Send the update to the group let closedGroupControlMessage = ClosedGroupControlMessage(kind: .memberLeft) let promise = MessageSender.sendNonDurably(closedGroupControlMessage, in: thread, using: transaction).done { @@ -280,7 +282,7 @@ extension MessageSender { } }.map { _ in } // Update the group - let newGroupModel = TSGroupModel(title: group.groupName, memberIds: [String](members), image: nil, groupId: groupID, groupType: .closedGroup, adminIds: [String](admins)) + let newGroupModel = TSGroupModel(title: group.groupName, memberIds: [String](members), image: nil, groupId: groupID, groupType: .closedGroup, adminIds: [String](admins), moderatorIds: [String](moderators)) thread.setGroupModel(newGroupModel, with: transaction) // Notify the user let updateInfo = group.getInfoStringAboutUpdate(to: newGroupModel) diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 5ca65cee4..31b90d37c 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -294,7 +294,7 @@ public final class MessageSender : NSObject { let server: OpenGroupAPI.Server? = dependencies.storage.getOpenGroupServer(name: openGroup.server) - if server?.capabilities.capabilities.contains(.blinding) == true { + if server?.capabilities.capabilities.contains(.blind) == true { guard let serverPublicKey = dependencies.storage.getOpenGroupPublicKey(for: openGroup.server) else { preconditionFailure() } @@ -390,7 +390,7 @@ public final class MessageSender : NSObject { whisperMods: whisperMods ) .done(on: DispatchQueue.global(qos: .userInitiated)) { responseInfo, data in - message.openGroupServerMessageID = given(data.seqNo) { UInt64($0) } + message.openGroupServerMessageID = given(data.id) { UInt64($0) } dependencies.storage.write { transaction in MessageSender.handleSuccessfulMessageSend(message, to: destination, serverTimestamp: UInt64(floor(data.posted)), using: transaction) diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift index 3c10343c1..0dff4119b 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift @@ -25,9 +25,13 @@ extension OpenGroupAPI { DispatchQueue.main.async { [weak self] in // Timers don't do well on background queues self?.hasStarted = true self?.timer = Timer.scheduledTimer(withTimeInterval: Poller.pollInterval, repeats: true) { _ in + DispatchQueue.global().async { + self?.poll().retainUntilComplete() + } + } + DispatchQueue.global().async { self?.poll().retainUntilComplete() } - self?.poll().retainUntilComplete() } } diff --git a/SessionMessagingKit/Storage.swift b/SessionMessagingKit/Storage.swift index cad4ff361..22fff6763 100644 --- a/SessionMessagingKit/Storage.swift +++ b/SessionMessagingKit/Storage.swift @@ -61,22 +61,16 @@ public protocol SessionMessagingKitStorageProtocol { func getOpenGroupServer(name: String) -> OpenGroupAPI.Server? func storeOpenGroupServer(_ server: OpenGroupAPI.Server, using transaction: Any) - // MARK: - Open Group Public Keys + // MARK: - -- Open Group Public Keys func getOpenGroupPublicKey(for server: String) -> String? func setOpenGroupPublicKey(for server: String, to newValue: String, using transaction: Any) - // MARK: - Last Message Server ID + // MARK: - -- Open Group Sequence Number - func getLastMessageServerID(for room: String, on server: String) -> Int64? - func setLastMessageServerID(for room: String, on server: String, to newValue: Int64, using transaction: Any) - func removeLastMessageServerID(for room: String, on server: String, using transaction: Any) - - // MARK: - Last Deletion Server ID - - func getLastDeletionServerID(for room: String, on server: String) -> Int64? - func setLastDeletionServerID(for room: String, on server: String, to newValue: Int64, using transaction: Any) - func removeLastDeletionServerID(for room: String, on server: String, using transaction: Any) + func getOpenGroupSequenceNumber(for room: String, on server: String) -> Int64? + func setOpenGroupSequenceNumber(for room: String, on server: String, to newValue: Int64, using transaction: Any) + func removeOpenGroupSequenceNumber(for room: String, on server: String, using transaction: Any) // MARK: - Message Handling diff --git a/SessionMessagingKit/Threads/TSGroupModel.h b/SessionMessagingKit/Threads/TSGroupModel.h index f05879470..7788d3be4 100644 --- a/SessionMessagingKit/Threads/TSGroupModel.h +++ b/SessionMessagingKit/Threads/TSGroupModel.h @@ -18,6 +18,7 @@ extern const int32_t kGroupIdLength; @property (nonatomic) NSArray *groupMemberIds; @property (nonatomic) NSArray *groupAdminIds; +@property (nonatomic) NSArray *groupModeratorIds; @property (nullable, readonly, nonatomic) NSString *groupName; @property (readonly, nonatomic) NSData *groupId; @property (nonatomic) GroupType groupType; @@ -30,7 +31,8 @@ extern const int32_t kGroupIdLength; image:(nullable UIImage *)image groupId:(NSData *)groupId groupType:(GroupType)groupType - adminIds:(NSArray *)adminIds; + adminIds:(NSArray *)adminIds + moderatorIds:(NSArray *)groupModeratorIds; - (BOOL)isEqual:(id)other; - (BOOL)isEqualToGroupModel:(TSGroupModel *)model; diff --git a/SessionMessagingKit/Threads/TSGroupModel.m b/SessionMessagingKit/Threads/TSGroupModel.m index 2ad8f00bf..87b49dab8 100644 --- a/SessionMessagingKit/Threads/TSGroupModel.m +++ b/SessionMessagingKit/Threads/TSGroupModel.m @@ -32,6 +32,7 @@ const int32_t kGroupIdLength = 16; groupId:(NSData *)groupId groupType:(GroupType)groupType adminIds:(NSArray *)adminIds + moderatorIds:(NSArray *)moderatorIds { _groupName = title; _groupMemberIds = [memberIds copy]; @@ -39,6 +40,7 @@ const int32_t kGroupIdLength = 16; _groupType = groupType; _groupId = groupId; _groupAdminIds = [adminIds copy]; + _groupModeratorIds = [moderatorIds copy]; return self; } @@ -59,6 +61,10 @@ const int32_t kGroupIdLength = 16; if (_groupAdminIds == nil) { _groupAdminIds = [NSArray new]; } + + if (_groupModeratorIds == nil) { + _groupModeratorIds = [NSArray new]; + } return self; } diff --git a/SessionMessagingKit/Threads/TSGroupThread.m b/SessionMessagingKit/Threads/TSGroupThread.m index 61b2e706e..7f0d1253e 100644 --- a/SessionMessagingKit/Threads/TSGroupThread.m +++ b/SessionMessagingKit/Threads/TSGroupThread.m @@ -42,7 +42,8 @@ NSString *const TSGroupThread_NotificationKey_UniqueId = @"TSGroupThread_Notific image:nil groupId:groupId groupType:groupType - adminIds:@[ localNumber ]]; + adminIds:@[ localNumber ] + moderatorIds:@[]]; self = [self initWithGroupModel:groupModel]; diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift b/SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift index e8506c11f..ceae8ac77 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift @@ -488,7 +488,7 @@ class OpenGroupAPITests: XCTestCase { } testStorage.mockData[.openGroupServer] = OpenGroupAPI.Server( name: "testServer", - capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs, .blinding], missing: []) + capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs, .blind], missing: []) ) dependencies = dependencies.with(api: LocalTestApi.self) @@ -577,7 +577,7 @@ class OpenGroupAPITests: XCTestCase { } testStorage.mockData[.openGroupServer] = OpenGroupAPI.Server( name: "testServer", - capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs, .blinding], missing: []) + capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs, .blind], missing: []) ) dependencies = dependencies.with(sodium: InvalidSodium()) @@ -618,7 +618,7 @@ class OpenGroupAPITests: XCTestCase { } testStorage.mockData[.openGroupServer] = OpenGroupAPI.Server( name: "testServer", - capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs, .blinding], missing: []) + capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs, .blind], missing: []) ) dependencies = dependencies.with(sodium: InvalidSodium()) From dbead5e3c8510ff0b05245463b808f1e5207452e Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 25 Feb 2022 11:59:29 +1100 Subject: [PATCH 016/157] Got the '/inbox' APIs and encryption/decryption/validation working Added a few types to make the code more readable Added the inbox request to the polling Added a couple of properties to the TSContactThread to indicate the originating open group to support SOGS DMs Added code to store the latest message id for an open group inbox Added a bunch of documentation from the API docs into the OpenGroupAPI (and associated models) Updated the OpenGroupAPI to match the latest docs Fixed the incorrect structure of the SendDirectMessageRequest Fixed an incorrect inbox endpoint path Tweaked the batch response handling so it wouldn't fail to parse all responses if a single one failed Renamed IdPrefix to SessionId.Prefix and cleaned up the type to be more readable & self-documenting --- Session.xcodeproj/project.pbxproj | 20 +- .../Message Cells/MessageCell.swift | 1 + .../Message Cells/VisibleMessageCell.swift | 23 +- .../Database/Storage+Messaging.swift | 50 +-- SessionMessagingKit/Jobs/MessageSendJob.swift | 22 ++ .../Messages/Message+Destination.swift | 13 + .../Open Groups/Models/BatchRequestInfo.swift | 37 +- .../Open Groups/Models/DirectMessage.swift | 15 +- .../Open Groups/Models/OGMessage.swift | 4 +- .../Open Groups/Models/PinnedMessage.swift | 5 + .../Open Groups/Models/Room.swift | 114 +++++- .../Open Groups/Models/RoomPollInfo.swift | 90 ++++- .../Models/SendDirectMessageRequest.swift | 4 +- .../Models/SendMessageRequest.swift | 30 +- .../Models/UpdateMessageRequest.swift | 16 + .../Open Groups/Models/UserBanRequest.swift | 20 + .../Models/UserModeratorRequest.swift | 4 +- .../Models/UserPermissionsRequest.swift | 13 - .../Open Groups/Models/UserUnbanRequest.swift | 11 + .../Open Groups/OpenGroupAPI.swift | 363 +++++++++++++----- .../Open Groups/OpenGroupManager.swift | 59 ++- .../Open Groups/Types/Dependencies.swift | 15 +- .../Open Groups/Types/Endpoint.swift | 2 +- ...rator16Byte.swift => NonceGenerator.swift} | 12 +- .../Open Groups/Types/Request.swift | 5 +- .../Open Groups/Types/SodiumProtocols.swift | 21 +- .../MessageReceiver+Decryption.swift | 60 ++- .../Sending & Receiving/MessageReceiver.swift | 104 +++-- .../MessageSender+ClosedGroups.swift | 2 +- .../MessageSender+Encryption.swift | 50 ++- .../Sending & Receiving/MessageSender.swift | 109 +++++- .../Pollers/OpenGroupPoller.swift | 28 +- SessionMessagingKit/Storage.swift | 6 + .../Utilities/Sodium+Utilities.swift | 116 ++++-- .../Crypto/ECKeyPair+Hexadecimal.swift | 10 +- .../General/Data+Utilities.swift | 4 +- SessionUtilitiesKit/General/IdPrefix.swift | 24 -- SessionUtilitiesKit/General/SessionId.swift | 57 +++ .../General/String+Trimming.swift | 4 +- .../Profile Pictures/Identicon+ObjC.swift | 2 +- 40 files changed, 1221 insertions(+), 324 deletions(-) delete mode 100644 SessionMessagingKit/Open Groups/Models/UserPermissionsRequest.swift rename SessionMessagingKit/Open Groups/Types/{NonceGenerator16Byte.swift => NonceGenerator.swift} (53%) delete mode 100644 SessionUtilitiesKit/General/IdPrefix.swift create mode 100644 SessionUtilitiesKit/General/SessionId.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index e4cb5bf98..1508dffa0 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -774,7 +774,7 @@ FCB11D8C1A129A76002F93FB /* CoreMedia.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FCB11D8B1A129A76002F93FB /* CoreMedia.framework */; }; FD5D200F27AA2B6000FEA984 /* MessageRequestResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */; }; FD5D201127AA331F00FEA984 /* ConfigurationMessage+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D201027AA331F00FEA984 /* ConfigurationMessage+Convenience.swift */; }; - FD5D201E27B0D87C00FEA984 /* IdPrefix.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D201D27B0D87C00FEA984 /* IdPrefix.swift */; }; + FD5D201E27B0D87C00FEA984 /* SessionId.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D201D27B0D87C00FEA984 /* SessionId.swift */; }; FD5D202027B0E67900FEA984 /* String+Encoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D201F27B0E67800FEA984 /* String+Encoding.swift */; }; FD659AC027A7649600F12C02 /* MessageRequestsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD659ABF27A7649600F12C02 /* MessageRequestsViewController.swift */; }; FD705A8C278CDB5600F16121 /* SAEScreenLockViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A8B278CDB5600F16121 /* SAEScreenLockViewController.swift */; }; @@ -793,7 +793,7 @@ FD88BADB27A750F200BBC442 /* MessageRequestsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */; }; FDC4380927B31D4E00C60D73 /* Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4380827B31D4E00C60D73 /* Error.swift */; }; FDC4380B27B31D7E00C60D73 /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4380A27B31D7E00C60D73 /* Request.swift */; }; - FDC4381527B329CE00C60D73 /* NonceGenerator16Byte.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381427B329CE00C60D73 /* NonceGenerator16Byte.swift */; }; + FDC4381527B329CE00C60D73 /* NonceGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381427B329CE00C60D73 /* NonceGenerator.swift */; }; FDC4381727B32EC700C60D73 /* Personalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381627B32EC700C60D73 /* Personalization.swift */; }; FDC4381A27B34EBA00C60D73 /* LegacyCompactPollBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381927B34EBA00C60D73 /* LegacyCompactPollBody.swift */; }; FDC4381C27B354AC00C60D73 /* LegacyPublicKeyBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381B27B354AC00C60D73 /* LegacyPublicKeyBody.swift */; }; @@ -837,7 +837,6 @@ FDC4389D27BA01F000C60D73 /* TestStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4389C27BA01F000C60D73 /* TestStorage.swift */; }; FDC438A427BB107F00C60D73 /* UserBanRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438A327BB107F00C60D73 /* UserBanRequest.swift */; }; FDC438A627BB113A00C60D73 /* UserUnbanRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438A527BB113A00C60D73 /* UserUnbanRequest.swift */; }; - FDC438A827BB11CD00C60D73 /* UserPermissionsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438A727BB11CD00C60D73 /* UserPermissionsRequest.swift */; }; FDC438AA27BB12BB00C60D73 /* UserModeratorRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438A927BB12BB00C60D73 /* UserModeratorRequest.swift */; }; FDC438AC27BB145200C60D73 /* UserDeleteMessagesRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438AB27BB145200C60D73 /* UserDeleteMessagesRequest.swift */; }; FDC438AE27BB148700C60D73 /* UserDeleteMessagesResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438AD27BB148700C60D73 /* UserDeleteMessagesResponse.swift */; }; @@ -1915,7 +1914,7 @@ FCB11D8B1A129A76002F93FB /* CoreMedia.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreMedia.framework; path = System/Library/Frameworks/CoreMedia.framework; sourceTree = SDKROOT; }; FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestResponse.swift; sourceTree = ""; }; FD5D201027AA331F00FEA984 /* ConfigurationMessage+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConfigurationMessage+Convenience.swift"; sourceTree = ""; }; - FD5D201D27B0D87C00FEA984 /* IdPrefix.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdPrefix.swift; sourceTree = ""; }; + FD5D201D27B0D87C00FEA984 /* SessionId.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionId.swift; sourceTree = ""; }; FD5D201F27B0E67800FEA984 /* String+Encoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Encoding.swift"; sourceTree = ""; }; FD659ABF27A7649600F12C02 /* MessageRequestsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsViewController.swift; sourceTree = ""; }; FD705A8B278CDB5600F16121 /* SAEScreenLockViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SAEScreenLockViewController.swift; sourceTree = ""; }; @@ -1937,7 +1936,7 @@ FD9039443F7CB729CF71350E /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig"; sourceTree = ""; }; FDC4380827B31D4E00C60D73 /* Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Error.swift; sourceTree = ""; }; FDC4380A27B31D7E00C60D73 /* Request.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Request.swift; sourceTree = ""; }; - FDC4381427B329CE00C60D73 /* NonceGenerator16Byte.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonceGenerator16Byte.swift; sourceTree = ""; }; + FDC4381427B329CE00C60D73 /* NonceGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonceGenerator.swift; sourceTree = ""; }; FDC4381627B32EC700C60D73 /* Personalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Personalization.swift; sourceTree = ""; }; FDC4381927B34EBA00C60D73 /* LegacyCompactPollBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyCompactPollBody.swift; sourceTree = ""; }; FDC4381B27B354AC00C60D73 /* LegacyPublicKeyBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyPublicKeyBody.swift; sourceTree = ""; }; @@ -1979,7 +1978,6 @@ FDC4389C27BA01F000C60D73 /* TestStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestStorage.swift; sourceTree = ""; }; FDC438A327BB107F00C60D73 /* UserBanRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserBanRequest.swift; sourceTree = ""; }; FDC438A527BB113A00C60D73 /* UserUnbanRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserUnbanRequest.swift; sourceTree = ""; }; - FDC438A727BB11CD00C60D73 /* UserPermissionsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPermissionsRequest.swift; sourceTree = ""; }; FDC438A927BB12BB00C60D73 /* UserModeratorRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserModeratorRequest.swift; sourceTree = ""; }; FDC438AB27BB145200C60D73 /* UserDeleteMessagesRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDeleteMessagesRequest.swift; sourceTree = ""; }; FDC438AD27BB148700C60D73 /* UserDeleteMessagesResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDeleteMessagesResponse.swift; sourceTree = ""; }; @@ -2553,7 +2551,7 @@ C38EF23D255B6D66007E1867 /* UIView+OWS.h */, C38EF23E255B6D66007E1867 /* UIView+OWS.m */, C38EF2EF255B6DBB007E1867 /* Weak.swift */, - FD5D201D27B0D87C00FEA984 /* IdPrefix.swift */, + FD5D201D27B0D87C00FEA984 /* SessionId.swift */, ); path = General; sourceTree = ""; @@ -3846,7 +3844,7 @@ FDC4381F27B36ADC00C60D73 /* Endpoint.swift */, FDC4380827B31D4E00C60D73 /* Error.swift */, FDC4381627B32EC700C60D73 /* Personalization.swift */, - FDC4381427B329CE00C60D73 /* NonceGenerator16Byte.swift */, + FDC4381427B329CE00C60D73 /* NonceGenerator.swift */, FDC438C027BB4E6800C60D73 /* Dependencies.swift */, FDC438C227BB512200C60D73 /* SodiumProtocols.swift */, ); @@ -3871,7 +3869,6 @@ FDC438C827BB706500C60D73 /* SendDirectMessageRequest.swift */, FDC438A327BB107F00C60D73 /* UserBanRequest.swift */, FDC438A527BB113A00C60D73 /* UserUnbanRequest.swift */, - FDC438A727BB11CD00C60D73 /* UserPermissionsRequest.swift */, FDC438A927BB12BB00C60D73 /* UserModeratorRequest.swift */, FDC438AB27BB145200C60D73 /* UserDeleteMessagesRequest.swift */, FDC438AD27BB148700C60D73 /* UserDeleteMessagesResponse.swift */, @@ -5087,7 +5084,7 @@ B8F5F58325EC94A6003BF8D4 /* Collection+Subscripting.swift in Sources */, C33FDEF8255A656D00E217F9 /* Promise+Delaying.swift in Sources */, B8BC00C0257D90E30032E807 /* General.swift in Sources */, - FD5D201E27B0D87C00FEA984 /* IdPrefix.swift in Sources */, + FD5D201E27B0D87C00FEA984 /* SessionId.swift in Sources */, C32C5A24256DB7DB003C73A2 /* SNUserDefaults.swift in Sources */, C3D9E41F25676C870040E4F3 /* OWSPrimaryStorageProtocol.swift in Sources */, C3BBE0A72554D4DE0050F1E3 /* Promise+Retrying.swift in Sources */, @@ -5211,7 +5208,7 @@ FDC4381727B32EC700C60D73 /* Personalization.swift in Sources */, FDC4387827B5C35400C60D73 /* SendMessageRequest.swift in Sources */, C32C5EDC256DF501003C73A2 /* YapDatabaseConnection+OWS.m in Sources */, - FDC4381527B329CE00C60D73 /* NonceGenerator16Byte.swift in Sources */, + FDC4381527B329CE00C60D73 /* NonceGenerator.swift in Sources */, C3BBE0762554CDA60050F1E3 /* Configuration.swift in Sources */, FDC4387627B5BEF300C60D73 /* FileDownloadResponse.swift in Sources */, C35D76DB26606304009AA5FB /* ThreadUpdateBatcher.swift in Sources */, @@ -5278,7 +5275,6 @@ C32C5AAE256DBE8F003C73A2 /* TSInteraction.m in Sources */, C32C5D23256DD4C0003C73A2 /* Mention.swift in Sources */, C32C5FD6256E0346003C73A2 /* Notification+Thread.swift in Sources */, - FDC438A827BB11CD00C60D73 /* UserPermissionsRequest.swift in Sources */, C3BBE0C72554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift in Sources */, FDC438C127BB4E6800C60D73 /* Dependencies.swift in Sources */, FDC4383827B3863200C60D73 /* VersionResponse.swift in Sources */, diff --git a/Session/Conversations/Message Cells/MessageCell.swift b/Session/Conversations/Message Cells/MessageCell.swift index a2d846692..2c932517b 100644 --- a/Session/Conversations/Message Cells/MessageCell.swift +++ b/Session/Conversations/Message Cells/MessageCell.swift @@ -66,4 +66,5 @@ protocol MessageCellDelegate : AnyObject { func openURL(_ url: URL) func handleReplyButtonTapped(for viewItem: ConversationViewItem) func showUserDetails(for sessionID: String) + func startThread(with sessionID: String, openGroupServer: String, openGroupPublicKey: String) } diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 6e26feb19..36a49d256 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -497,12 +497,27 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { let location = gestureRecognizer.location(in: self) if profilePictureView.frame.contains(location) && VisibleMessageCell.shouldShowProfilePicture(for: viewItem) { guard let message = viewItem.interaction as? TSIncomingMessage else { return } - guard !message.isOpenGroupMessage else { return } // Do not show user details to prevent spam - delegate?.showUserDetails(for: message.authorId) - } else if replyButton.frame.contains(location) { + + // For open groups only attempt to start a conversation if the author has a blinded id + if message.isOpenGroupMessage { + guard SessionId.Prefix(from: message.authorId) == .blinded else { return } + guard let openGroup: OpenGroup = Storage.shared.getOpenGroup(for: message.uniqueThreadId) else { return } + + delegate?.startThread( + with: message.authorId, + openGroupServer: openGroup.server, + openGroupPublicKey: openGroup.publicKey + ) + } + else { + delegate?.showUserDetails(for: message.authorId) + } + } + else if replyButton.frame.contains(location) { UIImpactFeedbackGenerator(style: .heavy).impactOccurred() reply() - } else { + } + else { delegate?.handleViewItemTapped(viewItem, gestureRecognizer: gestureRecognizer) } } diff --git a/SessionMessagingKit/Database/Storage+Messaging.swift b/SessionMessagingKit/Database/Storage+Messaging.swift index b139fa1f5..aabf74b7d 100644 --- a/SessionMessagingKit/Database/Storage+Messaging.swift +++ b/SessionMessagingKit/Database/Storage+Messaging.swift @@ -34,30 +34,32 @@ extension Storage { if let sender: String = message.sender, let openGroupID: String = openGroupID { guard let userEdKeyPair: Box.KeyPair = Storage.shared.getUserED25519KeyPair() else { return nil } - switch IdPrefix(with: sender) { - case .blinded: - let sodium: Sodium = Sodium() - let serverNameParts: [String.SubSequence] = openGroupID.split(separator: ".") - let serverName: String = serverNameParts[0..<(serverNameParts.count - 1)].joined(separator: ".") - - // Note: This is horrible but it doesn't look like there is going to be a nicer way to do it... - guard let serverPublicKey: String = Storage.shared.getOpenGroupPublicKey(for: serverName) else { - return nil - } - guard let blindedKeyPair: Box.KeyPair = sodium.blindedKeyPair(serverPublicKey: serverPublicKey, edKeyPair: userEdKeyPair, genericHash: sodium.genericHash) else { - return nil - } - - isOutgoingMessage = (sender == IdPrefix.blinded.hexEncodedPublicKey(for: blindedKeyPair.publicKey)) - - case .standard, .unblinded: - isOutgoingMessage = ( - message.sender == getUserPublicKey() || - sender == IdPrefix.unblinded.hexEncodedPublicKey(for: userEdKeyPair.publicKey) - ) - - case .none: - isOutgoingMessage = false + if let senderSessionId: SessionId = SessionId(from: sender) { + switch senderSessionId.prefix { + case .blinded: + let sodium: Sodium = Sodium() + let serverNameParts: [String.SubSequence] = openGroupID.split(separator: ".") + let serverName: String = serverNameParts[0..<(serverNameParts.count - 1)].joined(separator: ".") + + // Note: This is horrible but it doesn't look like there is going to be a nicer way to do it... + guard let serverPublicKey: String = Storage.shared.getOpenGroupPublicKey(for: serverName) else { + return nil + } + guard let blindedKeyPair: Box.KeyPair = sodium.blindedKeyPair(serverPublicKey: serverPublicKey, edKeyPair: userEdKeyPair, genericHash: sodium.genericHash) else { + return nil + } + + isOutgoingMessage = (sender == SessionId(.blinded, publicKey: blindedKeyPair.publicKey).hexString) + + case .standard, .unblinded: + isOutgoingMessage = ( + message.sender == getUserPublicKey() || + sender == SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString + ) + } + } + else { + isOutgoingMessage = false } } else { diff --git a/SessionMessagingKit/Jobs/MessageSendJob.swift b/SessionMessagingKit/Jobs/MessageSendJob.swift index 79ad93b93..0d6d11070 100644 --- a/SessionMessagingKit/Jobs/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/MessageSendJob.swift @@ -110,6 +110,25 @@ public final class MessageSendJob : NSObject, Job, NSCoding { // NSObject/NSCodi fileIds: fileIds ) } + else if rawDestination.removePrefix("openGroupInbox(") { + guard rawDestination.removeSuffix(")") else { return nil } + + let components = rawDestination + .split(separator: ",") + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + + guard components.count == 3 else { return nil } + + let server: String = components[0] + let openGroupPublicKey: String = components[1] + let blindedPublicKey: String = components[2] + + destination = .openGroupInbox( + server: server, + openGroupPublicKey: openGroupPublicKey, + blindedPublicKey: blindedPublicKey + ) + } else { return nil } @@ -141,6 +160,9 @@ public final class MessageSendJob : NSObject, Job, NSCoding { // NSObject/NSCodi "openGroupV4(\(room), \(server), \(whisperToString), \(whisperModsString), [\(fileIdString)])", forKey: "destination" ) + + case .openGroupInbox(let server, let openGroupPublicKey, let blindedPublicKey): + coder.encode("openGroupInbox(\(server), \(openGroupPublicKey), \(blindedPublicKey)", forKey: "destination") } coder.encode(id, forKey: "id") diff --git a/SessionMessagingKit/Messages/Message+Destination.swift b/SessionMessagingKit/Messages/Message+Destination.swift index ebc52d6a4..8df03ee80 100644 --- a/SessionMessagingKit/Messages/Message+Destination.swift +++ b/SessionMessagingKit/Messages/Message+Destination.swift @@ -12,9 +12,22 @@ public extension Message { whisperMods: Bool = false, fileIds: [Int64]? = nil // TODO: Handle 'fileIds' ) + case openGroupInbox(server: String, openGroupPublicKey: String, blindedPublicKey: String) static func from(_ thread: TSThread) -> Message.Destination { if let thread = thread as? TSContactThread { + if SessionId.Prefix(from: thread.contactSessionID()) == .blinded { + guard let server: String = thread.originalOpenGroupServer, let publicKey: String = thread.originalOpenGroupPublicKey else { + preconditionFailure("Attempting to send message to blinded id without the Open Group information") + } + + return .openGroupInbox( + server: server, + openGroupPublicKey: publicKey, + blindedPublicKey: thread.contactSessionID() + ) + } + return .contact(publicKey: thread.contactSessionID()) } diff --git a/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift b/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift index 3796d1478..d31ee4014 100644 --- a/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift +++ b/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift @@ -70,24 +70,39 @@ extension OpenGroupAPI { // MARK: - BatchSubResponse struct BatchSubResponse: Codable { + /// The numeric http response code (e.g. 200 for success) let code: Int32 + + /// This should always include the content type of the request let headers: [String: String] - let body: T + + /// The body of the request; will be plain json if content-type is `application/json`, otherwise it will be base64 encoded data + let body: T? + + /// A flag to indicate that there was a body but it failed to parse + let failedToParseBody: Bool } // MARK: - BatchRequestInfo - struct BatchRequestInfo: BatchRequestInfoType { + struct BatchRequestInfo: BatchRequestInfoType { let request: Request let responseType: Codable.Type var endpoint: Endpoint { request.endpoint } - init(request: Request, responseType: R.Type) { + init(request: Request, responseType: R.Type) { self.request = request self.responseType = BatchSubResponse.self } + init(request: Request) { + self.init( + request: request, + responseType: NoResponse.self + ) + } + func toSubRequest() -> BatchSubRequest { return BatchSubRequest(request: request) } @@ -97,7 +112,21 @@ extension OpenGroupAPI { typealias BatchRequest = [BatchSubRequest] typealias BatchResponseTypes = [Codable.Type] - typealias BatchResponse = [(OnionRequestResponseInfoType, Codable)] + typealias BatchResponse = [(OnionRequestResponseInfoType, Codable?)] +} + +extension OpenGroupAPI.BatchSubResponse { + init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + let body: T? = try? container.decode(T.self, forKey: .body) + + self = OpenGroupAPI.BatchSubResponse( + code: try container.decode(Int32.self, forKey: .code), + headers: try container.decode([String: String].self, forKey: .headers), + body: body, + failedToParseBody: (body == nil && T.self != OpenGroupAPI.NoResponse.self) + ) + } } // MARK: - BatchRequestInfoType diff --git a/SessionMessagingKit/Open Groups/Models/DirectMessage.swift b/SessionMessagingKit/Open Groups/Models/DirectMessage.swift index 47976bb19..8ea11aaf1 100644 --- a/SessionMessagingKit/Open Groups/Models/DirectMessage.swift +++ b/SessionMessagingKit/Open Groups/Models/DirectMessage.swift @@ -7,13 +7,24 @@ extension OpenGroupAPI { enum CodingKeys: String, CodingKey { case id case sender + case posted = "posted_at" case expires = "expires_at" - case base64EncodedData = "data" + case base64EncodedMessage = "message" } + /// The unique integer message id public let id: Int64 + + /// The (blinded) Session ID of the sender of the message public let sender: String + + /// Unix timestamp when the message was received by SOGS + public let posted: TimeInterval + + /// Unix timestamp when SOGS will expire and delete the message public let expires: TimeInterval - public let base64EncodedData: String + + /// The encrypted message body + public let base64EncodedMessage: String } } diff --git a/SessionMessagingKit/Open Groups/Models/OGMessage.swift b/SessionMessagingKit/Open Groups/Models/OGMessage.swift index 15ce06931..aba49f877 100644 --- a/SessionMessagingKit/Open Groups/Models/OGMessage.swift +++ b/SessionMessagingKit/Open Groups/Models/OGMessage.swift @@ -51,10 +51,10 @@ extension OpenGroupAPI.Message { throw OpenGroupAPI.Error.parsingFailed } - // Verify the signature based on the IdPrefix + // Verify the signature based on the SessionId.Prefix type let publicKey: Data = Data(hex: sender.removingIdPrefixIfNeeded()) - switch IdPrefix(with: sender) { + switch SessionId.Prefix(from: sender) { case .blinded: guard dependencies.sign.verify(message: data.bytes, publicKey: publicKey.bytes, signature: signature.bytes) else { SNLog("Ignoring message with invalid signature.") diff --git a/SessionMessagingKit/Open Groups/Models/PinnedMessage.swift b/SessionMessagingKit/Open Groups/Models/PinnedMessage.swift index bd8e9625f..8ccdec795 100644 --- a/SessionMessagingKit/Open Groups/Models/PinnedMessage.swift +++ b/SessionMessagingKit/Open Groups/Models/PinnedMessage.swift @@ -10,8 +10,13 @@ extension OpenGroupAPI { case pinnedBy = "pinned_by" } + /// The numeric message id let id: Int64 + + /// The unix timestamp when the message was pinned let pinnedAt: TimeInterval + + /// The session ID of the admin who pinned this message (which is not necessarily the same as the author of the message) let pinnedBy: String } } diff --git a/SessionMessagingKit/Open Groups/Models/Room.swift b/SessionMessagingKit/Open Groups/Models/Room.swift index 77e4785d8..2a31baabe 100644 --- a/SessionMessagingKit/Open Groups/Models/Room.swift +++ b/SessionMessagingKit/Open Groups/Models/Room.swift @@ -6,15 +6,15 @@ extension OpenGroupAPI { public struct Room: Codable { enum CodingKeys: String, CodingKey { case token - case created case name case description - case imageId = "image_id" - case infoUpdates = "info_updates" case messageSequence = "message_sequence" + case created + case activeUsers = "active_users" case activeUsersCutoff = "active_users_cutoff" + case imageId = "image_id" case pinnedMessages = "pinned_messages" case admin @@ -29,40 +29,118 @@ extension OpenGroupAPI { case read case defaultRead = "default_read" + case defaultAccessible = "default_accessible" case write case defaultWrite = "default_write" case upload case defaultUpload = "default_upload" } + /// The room token as used in a URL, e.g. "sudoku" public let token: String - public let created: TimeInterval + + /// The room name typically shown to users, e.g. "Sodoku Solvers" public let name: String + + /// Text description of the room, e.g. "All the best sodoku discussion!" public let description: String? + + /// Monotonic integer counter that increases whenever the room's metadata changes + public let infoUpdates: Int64 + + /// Monotonic room post counter that increases each time a message is posted, edited, or deleted in this room + /// + /// Note that changes to this field do not imply an update the room's info_updates value, nor vice versa + public let messageSequence: Int64 + + /// Unix timestamp (as a float) of the room creation time. Note that unlike earlier versions of SOGS, this is a proper + /// seconds-since-epoch unix timestamp, not a javascript-style millisecond value + public let created: TimeInterval + + /// Number of recently active users in the room over a recent time period (as given in the active_users_cutoff value) + /// + /// Users are considered "active" if they have accessed the room (checking for new messages, etc.) at least once in the given period + /// + /// **Note:** changes to this field do not update the room's info_updates value + public let activeUsers: Int64 + + /// The length of time (in seconds) of the active_users period. Defaults to a week (604800), but the open group administrator can configure it + public let activeUsersCutoff: Int64 + + /// File ID of an uploaded file containing the room's image + /// + /// Omitted if there is no image public let imageId: Int64? - public let infoUpdates: Int64 - public let messageSequence: Int64 - public let activeUsers: Int64 - public let activeUsersCutoff: Int64 + /// Array of pinned message information (omitted entirely if there are no pinned messages) public let pinnedMessages: [PinnedMessage]? + /// This flag is `true` if the current user has admin permissions in the room public let admin: Bool + + /// This flag is `true` if the current user is a global admin + /// + /// This is not exclusive of `globalModerator`/`moderator`/`admin` (a global admin will have all four set to `true`) public let globalAdmin: Bool + + /// Array of Session IDs of the room's publicly viewable moderators + /// + /// This does not include room moderator nor hidden admins public let admins: [String] + + /// Array of Session IDs of the room's publicly hidden admins + /// + /// This field is only included if the requesting user has moderator or admin permissions, and is omitted if empty public let hiddenAdmins: [String]? + /// This flag is `true` if the current user has moderator permissions in the room public let moderator: Bool + + /// This flag is `true` if the current user is a global moderator + /// + /// This is not exclusive of `moderator` (a global moderator will have both flags set to `true`) public let globalModerator: Bool + + /// Array of Session IDs of the room's publicly viewable moderators + /// + /// This does not include room administrators nor hidden moderators public let moderators: [String] + + /// Array of Session IDs of the room's publicly hidden moderators + /// + /// This field is only included if the requesting user has moderator or admin permissions, and is omitted if empty public let hiddenModerators: [String]? + /// This flag indicates whether the **current** user has permission to read the room's messages + /// + /// **Note:** If this value is `false` the user only has access the room metadata public let read: Bool - public let defaultRead: Bool + + /// This field indicates whether new users have read permissions in the room + /// + /// It is included in the response only if the requesting user has moderator or admin permissions + public let defaultRead: Bool? + + /// This field indicates whether new users have access permissions in the room + /// + /// It is included in the response only if the requesting user has moderator or admin permissions + public let defaultAccessible: Bool? + + /// This flag indicates whether the **current** user has permission to post messages in the room public let write: Bool - public let defaultWrite: Bool + + /// This field indicates whether new users have write permissions in the room + /// + /// It is included in the response only if the requesting user has moderator or admin permissions + public let defaultWrite: Bool? + + /// This flag indicates whether the **current** user has permission to upload files to the room public let upload: Bool - public let defaultUpload: Bool + + /// This field indicates whether new users have upload permissions in the room + /// + /// It is included in the response only if the requesting user has moderator or admin permissions + public let defaultUpload: Bool? } } @@ -74,15 +152,15 @@ extension OpenGroupAPI.Room { self = OpenGroupAPI.Room( token: try container.decode(String.self, forKey: .token), - created: try container.decode(TimeInterval.self, forKey: .created), name: try container.decode(String.self, forKey: .name), description: try? container.decode(String.self, forKey: .description), - imageId: try? container.decode(Int64.self, forKey: .imageId), - infoUpdates: try container.decode(Int64.self, forKey: .infoUpdates), messageSequence: try container.decode(Int64.self, forKey: .messageSequence), + created: try container.decode(TimeInterval.self, forKey: .created), + activeUsers: try container.decode(Int64.self, forKey: .activeUsers), activeUsersCutoff: try container.decode(Int64.self, forKey: .activeUsersCutoff), + imageId: try? container.decode(Int64.self, forKey: .imageId), pinnedMessages: try? container.decode([OpenGroupAPI.PinnedMessage].self, forKey: .pinnedMessages), admin: ((try? container.decode(Bool.self, forKey: .admin)) ?? false), @@ -96,12 +174,12 @@ extension OpenGroupAPI.Room { hiddenModerators: try? container.decode([String].self, forKey: .hiddenModerators), read: try container.decode(Bool.self, forKey: .read), - defaultRead: ((try? container.decode(Bool.self, forKey: .defaultRead)) ?? false), + defaultRead: try? container.decode(Bool.self, forKey: .defaultRead), + defaultAccessible: try? container.decode(Bool.self, forKey: .defaultAccessible), write: try container.decode(Bool.self, forKey: .write), - defaultWrite: ((try? container.decode(Bool.self, forKey: .defaultWrite)) ?? false), + defaultWrite: try? container.decode(Bool.self, forKey: .defaultWrite), upload: try container.decode(Bool.self, forKey: .upload), - defaultUpload: ((try? container.decode(Bool.self, forKey: .defaultUpload)) ?? false) + defaultUpload: try? container.decode(Bool.self, forKey: .defaultUpload) ) } } - diff --git a/SessionMessagingKit/Open Groups/Models/RoomPollInfo.swift b/SessionMessagingKit/Open Groups/Models/RoomPollInfo.swift index b6b68f294..7d30da86b 100644 --- a/SessionMessagingKit/Open Groups/Models/RoomPollInfo.swift +++ b/SessionMessagingKit/Open Groups/Models/RoomPollInfo.swift @@ -17,6 +17,7 @@ extension OpenGroupAPI { case read case defaultRead = "default_read" + case defaultAccessible = "default_accessible" case write case defaultWrite = "default_write" case upload @@ -25,22 +26,65 @@ extension OpenGroupAPI { case details } + /// The room token as used in a URL, e.g. "sudoku" public let token: String? - public let activeUsers: Int64? - public let admin: Bool? - public let globalAdmin: Bool? + /// Number of recently active users in the room over a recent time period (as given in the active_users_cutoff value) + /// + /// Users are considered "active" if they have accessed the room (checking for new messages, etc.) at least once in the given period + /// + /// **Note:** changes to this field do not update the room's info_updates value + public let activeUsers: Int64 - public let moderator: Bool? - public let globalModerator: Bool? + /// This flag is `true` if the current user has admin permissions in the room + public let admin: Bool - public let read: Bool? + /// This flag is `true` if the current user is a global admin + /// + /// This is not exclusive of `globalModerator`/`moderator`/`admin` (a global admin will have all four set to `true`) + public let globalAdmin: Bool + + /// This flag is `true` if the current user has moderator permissions in the room + public let moderator: Bool + + /// This flag is `true` if the current user is a global moderator + /// + /// This is not exclusive of `moderator` (a global moderator will have both flags set to `true`) + public let globalModerator: Bool + + /// This flag indicates whether the **current** user has permission to read the room's messages + /// + /// **Note:** If this value is `false` the user only has access the room metadata + public let read: Bool + + /// This field indicates whether new users have read permissions in the room + /// + /// It is included in the response only if the requesting user has moderator or admin permissions public let defaultRead: Bool? - public let write: Bool? + + /// This field indicates whether new users have access permissions in the room + /// + /// It is included in the response only if the requesting user has moderator or admin permissions + public let defaultAccessible: Bool? + + /// This flag indicates whether the **current** user has permission to post messages in the room + public let write: Bool + + /// This field indicates whether new users have write permissions in the room + /// + /// It is included in the response only if the requesting user has moderator or admin permissions public let defaultWrite: Bool? - public let upload: Bool? + + /// This flag indicates whether the **current** user has permission to upload files to the room + public let upload: Bool + + /// This field indicates whether new users have upload permissions in the room + /// + /// It is included in the response only if the requesting user has moderator or admin permissions public let defaultUpload: Bool? + /// The full room metadata (as would be returned by the `/rooms/{roomToken}` endpoint) + /// /// Only populated and different if the `info_updates` counter differs from the provided `info_updated` value public let details: Room? } @@ -59,6 +103,7 @@ extension OpenGroupAPI.RoomPollInfo { globalModerator: room.globalModerator, read: room.read, defaultRead: room.defaultRead, + defaultAccessible: room.defaultAccessible, write: room.write, defaultWrite: room.defaultWrite, upload: room.upload, @@ -67,3 +112,32 @@ extension OpenGroupAPI.RoomPollInfo { ) } } + +// MARK: - Decoding + +extension OpenGroupAPI.RoomPollInfo { + public init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + self = OpenGroupAPI.RoomPollInfo( + token: try container.decode(String.self, forKey: .token), + activeUsers: try container.decode(Int64.self, forKey: .activeUsers), + + admin: ((try? container.decode(Bool.self, forKey: .admin)) ?? false), + globalAdmin: ((try? container.decode(Bool.self, forKey: .globalAdmin)) ?? false), + + moderator: ((try? container.decode(Bool.self, forKey: .moderator)) ?? false), + globalModerator: ((try? container.decode(Bool.self, forKey: .globalModerator)) ?? false), + + read: try container.decode(Bool.self, forKey: .read), + defaultRead: try? container.decode(Bool.self, forKey: .defaultRead), + defaultAccessible: try? container.decode(Bool.self, forKey: .defaultAccessible), + write: try container.decode(Bool.self, forKey: .write), + defaultWrite: try? container.decode(Bool.self, forKey: .defaultWrite), + upload: try container.decode(Bool.self, forKey: .upload), + defaultUpload: try? container.decode(Bool.self, forKey: .defaultUpload), + + details: try? container.decode(OpenGroupAPI.Room.self, forKey: .details) + ) + } +} diff --git a/SessionMessagingKit/Open Groups/Models/SendDirectMessageRequest.swift b/SessionMessagingKit/Open Groups/Models/SendDirectMessageRequest.swift index 64e824251..19df350f9 100644 --- a/SessionMessagingKit/Open Groups/Models/SendDirectMessageRequest.swift +++ b/SessionMessagingKit/Open Groups/Models/SendDirectMessageRequest.swift @@ -4,14 +4,14 @@ import Foundation extension OpenGroupAPI { public struct SendDirectMessageRequest: Codable { - let data: Data + let message: Data // MARK: - Encodable public func encode(to encoder: Encoder) throws { var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) - try container.encode(data.base64EncodedString(), forKey: .data) + try container.encode(message.base64EncodedString(), forKey: .message) } } } diff --git a/SessionMessagingKit/Open Groups/Models/SendMessageRequest.swift b/SessionMessagingKit/Open Groups/Models/SendMessageRequest.swift index 92e8db8ae..9a53c14d6 100644 --- a/SessionMessagingKit/Open Groups/Models/SendMessageRequest.swift +++ b/SessionMessagingKit/Open Groups/Models/SendMessageRequest.swift @@ -12,10 +12,34 @@ extension OpenGroupAPI { case fileIds = "files" } + /// The serialized message body (encoded in base64 when encoding) let data: Data + + /// A 64-byte Ed25519 signature of the message body, signed by the current user's keys (encoded in base64 when + /// encoding - ie. 88 base64 chars) let signature: Data + + /// If present this indicates that this message is a whisper that should only be shown to the given user (via their sessionId) let whisperTo: String? - let whisperMods: Bool + + /// If `true`, then this message will be visible to moderators but not ordinary users + /// + /// If this and `whisper_to` are used together then the message will be visible to the given user and any room + /// moderators (this can be used, for instance, to issue a warning to a user that only the user and other mods can see) + /// + /// **Note:** Only moderators may set this flag + let whisperMods: Bool? + + /// Array of file IDs of new files uploaded as attachments of this post + /// + /// This is required to preserve uploads for the default expiry period (15 days, unless otherwise configured by the SOGS + /// administrator); uploaded files that are not attached to a post will be deleted much sooner + /// + /// If any of the given file ids are already associated with another message then the association is ignored (i.e. the files remain + /// associated with the original message) + /// + /// When submitting a message edit this field must contain the IDs of any newly uploaded files that are part of the edit; existing + /// attachment IDs may also be included, but are not required let fileIds: [Int64]? // MARK: - Initialization @@ -24,7 +48,7 @@ extension OpenGroupAPI { data: Data, signature: Data, whisperTo: String? = nil, - whisperMods: Bool = false, + whisperMods: Bool? = nil, fileIds: [Int64]? = nil ) { self.data = data @@ -42,7 +66,7 @@ extension OpenGroupAPI { try container.encode(data.base64EncodedString(), forKey: .data) try container.encode(signature.base64EncodedString(), forKey: .signature) try container.encodeIfPresent(whisperTo, forKey: .whisperTo) - try container.encode(whisperMods, forKey: .whisperMods) + try container.encodeIfPresent(whisperMods, forKey: .whisperMods) try container.encodeIfPresent(fileIds, forKey: .fileIds) } } diff --git a/SessionMessagingKit/Open Groups/Models/UpdateMessageRequest.swift b/SessionMessagingKit/Open Groups/Models/UpdateMessageRequest.swift index aadff442b..f18f72a63 100644 --- a/SessionMessagingKit/Open Groups/Models/UpdateMessageRequest.swift +++ b/SessionMessagingKit/Open Groups/Models/UpdateMessageRequest.swift @@ -4,9 +4,25 @@ import Foundation extension OpenGroupAPI { public struct UpdateMessageRequest: Codable { + /// The serialized message body (encoded in base64 when encoding) let data: Data + + /// A 64-byte Ed25519 signature of the message body, signed by the current user's keys (encoded in base64 when + /// encoding - ie. 88 base64 chars) let signature: Data + /// Array of file IDs of new files uploaded as attachments of this post + /// + /// This is required to preserve uploads for the default expiry period (15 days, unless otherwise configured by the SOGS + /// administrator); uploaded files that are not attached to a post will be deleted much sooner + /// + /// If any of the given file ids are already associated with another message then the association is ignored (i.e. the files remain + /// associated with the original message) + /// + /// This field must contain the IDs of any newly uploaded files that are part of the edit; existing attachment IDs may also be + /// included, but are not required + let fileIds: [Int64]? + // MARK: - Encodable public func encode(to encoder: Encoder) throws { diff --git a/SessionMessagingKit/Open Groups/Models/UserBanRequest.swift b/SessionMessagingKit/Open Groups/Models/UserBanRequest.swift index 2e21bca42..caff1a17d 100644 --- a/SessionMessagingKit/Open Groups/Models/UserBanRequest.swift +++ b/SessionMessagingKit/Open Groups/Models/UserBanRequest.swift @@ -4,8 +4,28 @@ import Foundation extension OpenGroupAPI { struct UserBanRequest: Codable { + /// List of one or more room tokens from which the user should be banned (the invoking user must be a `moderator` + /// of all of the given rooms + /// + /// This may be set to the single-element list ["*"] to ban the user from all rooms in which the invoking user has `moderator` + /// permissions (the call will succeed if the calling user is a moderator in at least one channel) + /// + /// Exclusive of `global` let rooms: [String]? + + /// If true then apply the ban at the server-wide global level: the user will be banned from the server entirely—not merely from + /// all rooms, but also from calling any other server request (the invoking user must be a global `moderator` in order to add + /// a global ban + /// + /// Exclusive of rooms let global: Bool? + + /// Optional value specifying a time limit on the ban, in seconds + /// + /// The applied ban will expire and be removed after the given interval - If omitted (or `null`) then the ban is permanent + /// + /// If this endpoint is called multiple times then the timeout of the last call takes effect (eg. a permanent ban can be replaced + /// with a time-limited ban by calling the endpoint again with a timeout value, and vice versa) let timeout: TimeInterval? } } diff --git a/SessionMessagingKit/Open Groups/Models/UserModeratorRequest.swift b/SessionMessagingKit/Open Groups/Models/UserModeratorRequest.swift index 3e815ea86..ece21d2ba 100644 --- a/SessionMessagingKit/Open Groups/Models/UserModeratorRequest.swift +++ b/SessionMessagingKit/Open Groups/Models/UserModeratorRequest.swift @@ -25,7 +25,7 @@ extension OpenGroupAPI { /// that may also be present. /// /// See the `admin` parameter description for information on how `admin` and `moderator` parameters interact. - let moderator: Bool + let moderator: Bool? /// If `true` then this user will be granted moderator and admin permissions to the given rooms or server. Admin permissions are /// required to appoint new moderators or administrators and to alter room info such as the image, adding/removing pinned messages, @@ -51,7 +51,7 @@ extension OpenGroupAPI { /// - `moderator=false, admin=false`: exactly the same as above. /// - `moderator=false, admin=true`: this combination is *not* *permitted* (because admin permissions imply moderator /// permissions) and will result in Bad Request error if given. - let admin: Bool + let admin: Bool? /// Whether this user should be a "visible" moderator or admin in the specified rooms (or globally). Visible moderators are identified to all /// room users (e.g. via a special status badge in Session clients). diff --git a/SessionMessagingKit/Open Groups/Models/UserPermissionsRequest.swift b/SessionMessagingKit/Open Groups/Models/UserPermissionsRequest.swift deleted file mode 100644 index d67fa69b0..000000000 --- a/SessionMessagingKit/Open Groups/Models/UserPermissionsRequest.swift +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -extension OpenGroupAPI { - struct UserPermissionsRequest: Codable { - let rooms: [String] - let timeout: TimeInterval - let read: Bool - let write: Bool - let upload: Bool - } -} diff --git a/SessionMessagingKit/Open Groups/Models/UserUnbanRequest.swift b/SessionMessagingKit/Open Groups/Models/UserUnbanRequest.swift index 2d28b21cb..b0e8a2ab9 100644 --- a/SessionMessagingKit/Open Groups/Models/UserUnbanRequest.swift +++ b/SessionMessagingKit/Open Groups/Models/UserUnbanRequest.swift @@ -4,7 +4,18 @@ import Foundation extension OpenGroupAPI { struct UserUnbanRequest: Codable { + /// List of one or more room tokens from which the user should be banned (the invoking user must be a `moderator` + /// of all of the given rooms + /// + /// This may be set to the single-element list ["*"] to ban the user from all rooms in which the invoking user has `moderator` + /// permissions (the call will succeed if the calling user is a moderator in at least one channel) + /// + /// Exclusive of `global` let rooms: [String]? + + /// If true then remove a server-wide global ban + /// + /// Exclusive of rooms let global: Bool? } } diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index 3e68749a1..d895780d9 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -40,10 +40,13 @@ public final class OpenGroupAPI: NSObject { /// - For each room: /// - Poll Info /// - Messages (includes additions and deletions) - public static func poll(_ server: String, using dependencies: Dependencies = Dependencies()) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable)]> { + /// - Inbox for the server + public static func poll(_ server: String, using dependencies: Dependencies = Dependencies()) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable?)]> { // Store a local copy of the cached state for this server let hadPerformedInitialPoll: Bool = (hasPerformedInitialPoll[server] == true) let originalTimeSinceLastPoll: TimeInterval = (timeSinceLastPoll[server] ?? min(lastPollTime, timeSinceLastOpen)) + let maybeLastInboxMessageId: Int64? = dependencies.storage.getOpenGroupInboxLatestMessageId(for: server) + let lastInboxMessageId: Int64 = (maybeLastInboxMessageId ?? 0) // Update the cached state for this server hasPerformedInitialPoll[server] = true @@ -100,16 +103,32 @@ public final class OpenGroupAPI: NSObject { ] } ) + .appending( + // Inbox + BatchRequestInfo( + request: Request( + server: server, + endpoint: (maybeLastInboxMessageId == nil ? + .inbox : + .inboxSince(id: lastInboxMessageId) + ) + // TODO: Limit? +// queryParameters: [ .limit: 256 ] + ), + responseType: [DirectMessage].self + ) + ) return batch(server, requests: requestResponseType, using: dependencies) } - /// This is used, for example, to poll multiple rooms on the same server for updates in a single query rather than needing to make multiple requests for each room. + /// Submits multiple requests wrapped up in a single request, runs them all, then returns the result of each one /// - /// No guarantee is made as to the order in which sub-requests are processed; use the `/sequence` instead if you need that. + /// Requests are performed independently, that is, if one fails the others will still be attempted - there is no guarantee on the order in which requests will be + /// carried out (for sequential, related requests invoke via `/sequence` instead) /// /// For contained subrequests that specify a body (i.e. POST or PUT requests) exactly one of `json`, `b64`, or `bytes` must be provided with the request body. - private static func batch(_ server: String, requests: [BatchRequestInfoType], using dependencies: Dependencies = Dependencies()) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable)]> { + private static func batch(_ server: String, requests: [BatchRequestInfoType], using dependencies: Dependencies = Dependencies()) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable?)]> { let requestBody: BatchRequest = requests.map { $0.toSubRequest() } let responseTypes = requests.map { $0.responseType } @@ -130,12 +149,16 @@ public final class OpenGroupAPI: NSObject { } } - /// The requests are guaranteed to be performed sequentially in the order given in the request and will abort if any request does not return a status-`2xx` response. + /// This is like `/batch`, except that it guarantees to perform requests sequentially in the order provided and will stop processing requests if the previous request + /// returned a non-`2xx` response /// /// For example, this can be used to ban and delete all of a user's messages by sequencing the ban followed by the `delete_all`: if the ban fails (e.g. because /// permission is denied) then the `delete_all` will not occur. The batch body and response are identical to the `/batch` endpoint; requests that are not /// carried out because of an earlier failure will have a response code of `412` (Precondition Failed)." - private static func sequence(_ server: String, requests: [BatchRequestInfoType], using dependencies: Dependencies = Dependencies()) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable)]> { + /// + /// Like `/batch`, responses are returned in the same order as requests, but unlike `/batch` there may be fewer elements in the response list (if requests were + /// stopped because of a non-2xx response) - In such a case, the final, non-2xx response is still included as the final response value + private static func sequence(_ server: String, requests: [BatchRequestInfoType], using dependencies: Dependencies = Dependencies()) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable?)]> { let requestBody: BatchRequest = requests.map { $0.toSubRequest() } let responseTypes = requests.map { $0.responseType } @@ -159,6 +182,13 @@ public final class OpenGroupAPI: NSObject { // MARK: - Capabilities + /// Return the list of server features/capabilities + /// + /// Optionally takes a `required` parameter containing a comma-separated list of capabilites; if any are not satisfied a 412 (Precondition Failed) response + /// will be returned with missing requested capabilities in the `missing` key + /// + /// Eg. `GET /capabilities` could return `{"capabilities": ["sogs", "batch"]}` `GET /capabilities?required=magic,batch` + /// could return: `{"capabilities": ["sogs", "batch"], "missing": ["magic"]}` public static func capabilities(on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Capabilities)> { let request: Request = Request( server: server, @@ -173,6 +203,9 @@ public final class OpenGroupAPI: NSObject { // MARK: - Room + /// Returns a list of available rooms on the server + /// + /// Rooms to which the user does not have access (e.g. because they are banned, or the room has restricted access permissions) are not included public static func rooms(for server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [Room])> { let request: Request = Request( server: server, @@ -183,6 +216,7 @@ public final class OpenGroupAPI: NSObject { .decoded(as: [Room].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) } + /// Returns the details of a single room public static func room(for roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Room)> { let request: Request = Request( server: server, @@ -193,6 +227,15 @@ public final class OpenGroupAPI: NSObject { .decoded(as: Room.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) } + /// Polls a room for metadata updates + /// + /// The endpoint polls room metadata for this room, always including the instantaneous room details (such as the user's permission and current + /// number of active users), and including the full room metadata if the room's info_updated counter has changed from the provided value + /// + /// **Note:** This is the direct request to retrieve room updates so should be retrieved automatically from the `poll()` method, in order to call + /// this directly remove the `@available` line and make sure to route the response of this method to the `OpenGroupManager.handlePollInfo` + /// method to ensure things are processed correctly + @available(*, unavailable, message: "Avoid using this directly, use the pre-build `poll()` method instead") public static func roomPollInfo(lastUpdated: Int64, for roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, RoomPollInfo)> { let request: Request = Request( server: server, @@ -205,6 +248,7 @@ public final class OpenGroupAPI: NSObject { // MARK: - Messages + /// Posts a new message to a room public static func send( _ plaintext: Data, to roomToken: String, @@ -236,6 +280,7 @@ public final class OpenGroupAPI: NSObject { .decoded(as: Message.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) } + /// Returns a single message by ID public static func message(_ id: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Message)> { let request: Request = Request( server: server, @@ -246,9 +291,13 @@ public final class OpenGroupAPI: NSObject { .decoded(as: Message.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) } + /// Edits a message, replacing its existing content with new content and a new signature + /// + /// **Note:** This edit may only be initiated by the creator of the post, and the poster must currently have write permissions in the room public static func messageUpdate( _ id: Int64, plaintext: Data, + fileIds: [Int64]?, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies() @@ -259,7 +308,8 @@ public final class OpenGroupAPI: NSObject { let requestBody: UpdateMessageRequest = UpdateMessageRequest( data: plaintext, - signature: Data(signResult.signature) + signature: Data(signResult.signature), + fileIds: fileIds ) let request: Request = Request( @@ -294,10 +344,10 @@ public final class OpenGroupAPI: NSObject { return response } } - - /// This is the direct request to retrieve recent messages from an Open Group so should be retrieved automatically from the `poll()` - /// method, if the logic should change then remove the `@available` line and make sure to route the response of this method to - /// the `OpenGroupManager` `handleMessages` method (otherwise the logic may not work correctly) + + /// **Note:** This is the direct request to retrieve recent messages so should be retrieved automatically from the `poll()` method, in order to call + /// this directly remove the `@available` line and make sure to route the response of this method to the `OpenGroupManager.handleMessages` + /// method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-build `poll()` method instead") public static func recentMessages(in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [Message])> { let request: Request = Request( @@ -311,9 +361,9 @@ public final class OpenGroupAPI: NSObject { .decoded(as: [Message].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) } - /// This is the direct request to retrieve recent messages from an Open Group so should be retrieved automatically from the `poll()` - /// method, if the logic should change then remove the `@available` line and make sure to route the response of this method to - /// the `OpenGroupManager` `handleMessages` method (otherwise the logic may not work correctly) + /// **Note:** This is the direct request to retrieve recent messages before a given message and is currently unused, in order to call this directly + /// remove the `@available` line and make sure to route the response of this method to the `OpenGroupManager.handleMessages` + /// method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-build `poll()` method instead") public static func messagesBefore(messageId: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [Message])> { // TODO: Do we need to be able to load old messages? @@ -328,9 +378,9 @@ public final class OpenGroupAPI: NSObject { .decoded(as: [Message].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) } - /// This is the direct request to retrieve recent messages from an Open Group so should be retrieved automatically from the `poll()` - /// method, if the logic should change then remove the `@available` line and make sure to route the response of this method to - /// the `OpenGroupManager` `handleMessages` method (otherwise the logic may not work correctly) + /// **Note:** This is the direct request to retrieve messages since a given message `seqNo` so should be retrieved automatically from the + /// `poll()` method, in order to call this directly remove the `@available` line and make sure to route the response of this method to the + /// `OpenGroupManager.handleMessages` method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-build `poll()` method instead") public static func messagesSince(seqNo: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [Message])> { let request: Request = Request( @@ -346,6 +396,16 @@ public final class OpenGroupAPI: NSObject { // MARK: - Pinning + /// Adds a pinned message to this room + /// + /// **Note:** Existing pinned messages are not removed: the new message is added to the pinned message list (If you want to remove existing + /// pins then build a sequence request that first calls .../unpin/all) + /// + /// The user must have admin (not just moderator) permissions in the room in order to pin messages + /// + /// Pinned messages that are already pinned will be re-pinned (that is, their pin timestamp and pinning admin user will be updated) - because pinned + /// messages are returned in pinning-order this allows admins to order multiple pinned messages in a room by re-pinning (via this endpoint) in the + /// order in which pinned messages should be displayed public static func pinMessage(id: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise { let request: Request = Request( method: .post, @@ -357,6 +417,9 @@ public final class OpenGroupAPI: NSObject { .map { responseInfo, _ in responseInfo } } + /// Remove a message from this room's pinned message list + /// + /// The user must have `admin` (not just `moderator`) permissions in the room public static func unpinMessage(id: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise { let request: Request = Request( method: .post, @@ -368,6 +431,9 @@ public final class OpenGroupAPI: NSObject { .map { responseInfo, _ in responseInfo } } + /// Removes _all_ pinned messages from this room + /// + /// The user must have `admin` (not just `moderator`) permissions in the room public static func unpinAll(in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise { let request: Request = Request( method: .post, @@ -435,7 +501,13 @@ public final class OpenGroupAPI: NSObject { // MARK: - Inbox (Message Requests) - public static func messageRequests(on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage])> { + /// Retrieves all of the user's current DMs (up to limit) + /// + /// **Note:** This is the direct request to retrieve DMs for a specific Open Group so should be retrieved automatically from the `poll()` + /// method, in order to call this directly remove the `@available` line and make sure to route the response of this method to the + /// `OpenGroupManager.handleInbox` method to ensure things are processed correctly + @available(*, unavailable, message: "Avoid using this directly, use the pre-build `poll()` method instead") + public static func inbox(on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage])> { let request: Request = Request( server: server, endpoint: .inbox @@ -445,7 +517,13 @@ public final class OpenGroupAPI: NSObject { .decoded(as: [DirectMessage].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) } - public static func messageRequestsSince(id: Int64, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage])> { + /// Polls for any DMs received since the given id + /// + /// **Note:** This is the direct request to retrieve messages requests for a specific Open Group since a given messages so should be retrieved + /// automatically from the `poll()` method, in order to call this directly remove the `@available` line and make sure to route the response + /// of this method to the `OpenGroupManager.handleInbox` method to ensure things are processed correctly + @available(*, unavailable, message: "Avoid using this directly, use the pre-build `poll()` method instead") + public static func inboxSince(id: Int64, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage])> { let request: Request = Request( server: server, endpoint: .inboxSince(id: id) @@ -455,14 +533,12 @@ public final class OpenGroupAPI: NSObject { .decoded(as: [DirectMessage].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) } - public static func sendMessageRequest(_ plaintext: Data, to blindedSessionId: String, on server: String, with serverPublicKey: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage])> { - guard let signedMessage: Data = sign(message: plaintext, to: blindedSessionId, on: server, with: serverPublicKey, using: dependencies) else { - return Promise(error: Error.signingFailed) - } - + /// Delivers a direct message to a user via their blinded Session ID + /// + /// The body of this request is a JSON object containing a message key with a value of the encrypted-then-base64-encoded message to deliver + public static func send(_ ciphertext: Data, toInboxFor blindedSessionId: String, on server: String/*, with serverPublicKey: String*/, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Data?)> { let requestBody: SendDirectMessageRequest = SendDirectMessageRequest( - data: signedMessage -// signature: signedMessage.signature // TODO: Confirm whether this needs a signature?? + message: ciphertext ) let request: Request = Request( @@ -478,7 +554,44 @@ public final class OpenGroupAPI: NSObject { // MARK: - Users - public static func userBan(_ sessionId: String, for timeout: TimeInterval? = nil, from roomTokens: [String]? = nil, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Data?)> { + /// Applies a ban of a user from specific rooms, or from the server globally + /// + /// The invoking user must have `moderator` (or `admin`) permission in all given rooms when specifying rooms, and must be a + /// `globalModerator` (or `globalAdmin`) if using the global parameter + /// + /// **Note:** The user's messages are not deleted by this request - In order to ban and delete all messages use the `/sequence` endpoint to + /// bundle a `/user/.../ban` with a `/user/.../deleteMessages` request + /// + /// - Parameters: + /// - sessionId: The sessionId (either standard or blinded) of the user whose messages should be deleted + /// + /// - timeout: Value specifying a time limit on the ban, in seconds + /// + /// The applied ban will expire and be removed after the given interval - If omitted (or `null`) then the ban is permanent + /// + /// If this endpoint is called multiple times then the timeout of the last call takes effect (eg. a permanent ban can be replaced + /// with a time-limited ban by calling the endpoint again with a timeout value, and vice versa) + /// + /// - roomTokens: List of one or more room tokens from which the user should be banned from + /// + /// The invoking user **must** be a moderator of all of the given rooms. + /// + /// This may be set to the single-element list `["*"]` to ban the user from all rooms in which the current user has moderator + /// permissions (the call will succeed if the calling user is a moderator in at least one channel) + /// + /// **Note:** You can ban from all rooms on a server by providing a `nil` value for this parameter (the invoking user must be a + /// global moderator in order to add a global ban) + /// + /// - server: The server to delete messages from + /// + /// - dependencies: Injected dependencies (used for unit testing) + public static func userBan( + _ sessionId: String, + for timeout: TimeInterval? = nil, + from roomTokens: [String]? = nil, + on server: String, + using dependencies: Dependencies = Dependencies() + ) -> Promise<(OnionRequestResponseInfoType, Data?)> { let requestBody: UserBanRequest = UserBanRequest( rooms: roomTokens, global: (roomTokens == nil ? true : nil), @@ -495,7 +608,36 @@ public final class OpenGroupAPI: NSObject { return send(request, using: dependencies) } - public static func userUnban(_ sessionId: String, from roomTokens: [String]? = nil, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Data?)> { + /// Removes a user ban from specific rooms, or from the server globally + /// + /// The invoking user must have `moderator` (or `admin`) permission in all given rooms when specifying rooms, and must be a global server `moderator` + /// (or `admin`) if using the `global` parameter + /// + /// **Note:** Room and global bans are independent: if a user is banned globally and has a room-specific ban then removing the global ban does not remove + /// the room specific ban, and removing the room-specific ban does not remove the global ban (to fully unban a user globally and from all rooms, submit a + /// `/sequence` request with a global unban followed by a "rooms": ["*"] unban) + /// + /// - Parameters: + /// - sessionId: The sessionId (either standard or blinded) of the user whose messages should be deleted + /// + /// - roomTokens: List of one or more room tokens from which the user should be unbanned from + /// + /// The invoking user **must** be a moderator of all of the given rooms. + /// + /// This may be set to the single-element list `["*"]` to unban the user from all rooms in which the current user has moderator + /// permissions (the call will succeed if the calling user is a moderator in at least one channel) + /// + /// **Note:** You can ban from all rooms on a server by providing a `nil` value for this parameter + /// + /// - server: The server to delete messages from + /// + /// - dependencies: Injected dependencies (used for unit testing) + public static func userUnban( + _ sessionId: String, + from roomTokens: [String]?, + on server: String, + using dependencies: Dependencies = Dependencies() + ) -> Promise<(OnionRequestResponseInfoType, Data?)> { let requestBody: UserUnbanRequest = UserUnbanRequest( rooms: roomTokens, global: (roomTokens == nil ? true : nil) @@ -511,26 +653,68 @@ public final class OpenGroupAPI: NSObject { return send(request, using: dependencies) } - public static func userPermissionUpdate(_ sessionId: String, read: Bool, write: Bool, upload: Bool, for roomTokens: [String], timeout: TimeInterval, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Data?)> { - let requestBody: UserPermissionsRequest = UserPermissionsRequest( - rooms: roomTokens, - timeout: timeout, - read: read, - write: write, - upload: upload - ) + /// Appoints or removes a moderator or admin + /// + /// This endpoint is used to appoint or remove moderator/admin permissions either for specific rooms or for server-wide global moderator permissions + /// + /// Admins/moderators of rooms can only be appointed or removed by a user who has admin permissions in the room (including global admins) + /// + /// Global admins/moderators may only be appointed by a global admin + /// + /// The admin/moderator paramters interact as follows: + /// - **admin=true, moderator omitted:** This adds admin permissions, which automatically also implies moderator permissions + /// - **admin=true, moderator=true:** Exactly the same as above + /// - **admin=false, moderator=true:** Removes any existing admin permissions from the rooms (or globally), if present, and adds + /// moderator permissions to the rooms/globally (if not already present) + /// - **admin=false, moderator omitted:** This removes admin permissions but leaves moderator permissions, if present (this + /// effectively "downgrades" an admin to a moderator). Unlike the above this does **not** add moderator permissions to matching rooms + /// if not already present + /// - **moderator=true, admin omitted:** Adds moderator permissions to the given rooms (or globally), if not already present. If + /// the user already has admin permissions this does nothing (that is, admin permission is *not* removed, unlike the above) + /// - **moderator=false, admin omitted:** This removes moderator **and** admin permissions from all given rooms (or globally) + /// - **moderator=false, admin=false:** Exactly the same as above + /// - **moderator=false, admin=true:** This combination is **not permitted** (because admin permissions imply moderator + /// permissions) and will result in Bad Request error if given + /// + /// - Parameters: + /// - sessionId: The sessionId (either standard or blinded) of the user to modify the permissions of + /// + /// - moderator: Value indicating that this user should have moderator permissions added (true), removed (false), or left alone (null) + /// + /// - admin: Value indicating that this user should have admin permissions added (true), removed (false), or left alone (null) + /// + /// Granting admin permission automatically includes granting moderator permission (and thus it is an error to use admin=true with + /// moderator=false) + /// + /// - visible: Value indicating whether the moderator/admin should be made publicly visible as a moderator/admin of the room(s) + /// (if true) or hidden (false) + /// + /// Hidden moderators/admins still have all the same permissions as visible moderators/admins, but are visible only to other + /// moderators/admins; regular users in the room will not know their moderator status + /// + /// - roomTokens: List of one or more room tokens to which the permission changes should be applied + /// + /// The invoking user **must** be an admin of all of the given rooms. + /// + /// This may be set to the single-element list `["*"]` to add or remove the moderator from all rooms in which the current user has admin + /// permissions (the call will succeed if the calling user is an admin in at least one channel) + /// + /// **Note:** You can specify a change to global permisisons by providing a `nil` value for this parameter + /// + /// - server: The server to perform the permission changes on + /// + /// - dependencies: Injected dependencies (used for unit testing) + public static func userModeratorUpdate( + _ sessionId: String, + moderator: Bool? = nil, + admin: Bool? = nil, + visible: Bool, + for roomTokens: [String]?, + on server: String, + using dependencies: Dependencies = Dependencies() + ) -> Promise<(OnionRequestResponseInfoType, Data?)> { + guard (moderator != nil && admin == nil) || (moderator == nil && admin != nil) else { return Promise(error: Error.generic) } - let request: Request = Request( - method: .post, - server: server, - endpoint: .userPermission(sessionId), - body: requestBody - ) - - return send(request, using: dependencies) - } - - public static func userModeratorUpdate(_ sessionId: String, moderator: Bool, admin: Bool, visible: Bool, for roomTokens: [String]? = nil, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Data?)> { let requestBody: UserModeratorRequest = UserModeratorRequest( rooms: roomTokens, global: (roomTokens == nil ? true : nil), @@ -549,7 +733,31 @@ public final class OpenGroupAPI: NSObject { return send(request, using: dependencies) } - public static func userDeleteMessages(_ sessionId: String, for roomTokens: [String]? = nil, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, UserDeleteMessagesResponse)> { + // TODO: Need to test this once the API has been implemented + // TODO: Update docs to align with the API documentation once implemented + /// Deletes all messages from a given sessionId within the provided rooms (or globally) on a server + /// + /// - Parameters: + /// - sessionId: The sessionId (either standard or blinded) of the user whose messages should be deleted + /// + /// - roomTokens: List of one or more room tokens from which the messages should be deleted + /// + /// The invoking user **must** be an admin of all of the given rooms. + /// + /// This may be set to the single-element list `["*"]` to add or remove the moderator from all rooms in which the current user has admin + /// permissions (the call will succeed if the calling user is an admin in at least one channel) + /// + /// **Note:** You can delete messages from all rooms on a server by providing a `nil` value for this parameter + /// + /// - server: The server to delete messages from + /// + /// - dependencies: Injected dependencies (used for unit testing) + public static func userDeleteMessages( + _ sessionId: String, + for roomTokens: [String]?, + on server: String, + using dependencies: Dependencies = Dependencies() + ) -> Promise<(OnionRequestResponseInfoType, UserDeleteMessagesResponse)> { let requestBody: UserDeleteMessagesRequest = UserDeleteMessagesRequest( rooms: roomTokens, global: (roomTokens == nil ? true : nil) @@ -566,7 +774,15 @@ public final class OpenGroupAPI: NSObject { .decoded(as: UserDeleteMessagesResponse.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) } - public static func userBanAndDeleteAllMessage(_ sessionId: String, for roomTokens: [String]? = nil, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<[OnionRequestResponseInfoType]> { + // TODO: Need to test this once the API has been implemented + /// This is a convenience method which constructs a `/sequence` of the `userBan` and `userDeleteMessages` requests, refer to those + /// methods for the documented behaviour of each method + public static func userBanAndDeleteAllMessage( + _ sessionId: String, + for roomTokens: [String]?, + on server: String, + using dependencies: Dependencies = Dependencies() + ) -> Promise<[OnionRequestResponseInfoType]> { let banRequestBody: UserBanRequest = UserBanRequest( rooms: roomTokens, global: (roomTokens == nil ? true : nil), @@ -585,8 +801,7 @@ public final class OpenGroupAPI: NSObject { server: server, endpoint: .userBan(sessionId), body: banRequestBody - ), - responseType: Data?.self + ) ), BatchRequestInfo( request: Request( @@ -608,46 +823,8 @@ public final class OpenGroupAPI: NSObject { // MARK: - Authentication - // TODO: This is going to have to work differently (ie. `MessageSender+Encryption`) - /// Sign a blinded message request to be sent to a users inbox via SOGS v4 - private static func sign(message: Data, to blindedSessionId: String, on serverName: String, with serverPublicKey: String, using dependencies: Dependencies = Dependencies()) -> Data? { - guard let userEdKeyPair: Box.KeyPair = dependencies.storage.getUserED25519KeyPair() else { return nil } - // TODO: Re-do this - return nil -// guard let blindedKeyPair: BlindedECKeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: serverPublicKey, edKeyPair: userEdKeyPair, genericHash: dependencies.genericHash) else { -// return nil -// } -// guard let blindedRecipientPublicKey: Data = String(blindedSessionId.suffix(from: blindedSessionId.index(blindedSessionId.startIndex, offsetBy: IdPrefix.blinded.rawValue.count))).dataFromHex() else { -// return nil -// } -// -// /// Generate the sharedSecret by "a kB || kA || kB" where -// /// a, A are the users private and public keys respectively, -// /// kA is the users blinded public key -// /// kB is the recipients blinded public key -// let maybeSharedSecret: Data? = dependencies.sodium -// .sharedEdSecret(userEdKeyPair.secretKey, blindedRecipientPublicKey.bytes)? -// .appending(blindedKeyPair.publicKey.bytes) -// .appending(blindedRecipientPublicKey.bytes) -// -// guard let sharedSecret: Data = maybeSharedSecret else { return nil } -// guard let intermediateHash: Bytes = dependencies.genericHash.hash(message: sharedSecret.bytes) else { return nil } -// -// /// Generate the inner message by "message || A" where -// /// A is the sender's ed25519 master pubkey (**not** kA blinded pubkey) -// let innerMessage: Bytes = (message.bytes + userEdKeyPair.publicKey) -// guard let (ciphertext, nonce) = dependencies.aeadXChaCha20Poly1305Ietf.encrypt(message: innerMessage, secretKey: intermediateHash) else { -// return nil -// } -// -// /// Generate the final data by "b'\x00' + ciphertext + nonce" -// let finalData: Bytes = [0] + ciphertext + nonce -// -// return Data(finalData) - } - /// Sign a message to be sent to SOGS (handles both un-blinded and blinded signing based on the server capabilities) - public static func sign(_ messageBytes: Bytes, for serverName: String, using dependencies: Dependencies = Dependencies()) -> (publicKey: String, signature: Bytes)? { + private static func sign(_ messageBytes: Bytes, for serverName: String, using dependencies: Dependencies = Dependencies()) -> (publicKey: String, signature: Bytes)? { guard let userEdKeyPair: Box.KeyPair = dependencies.storage.getUserED25519KeyPair() else { return nil } guard let serverPublicKey: String = dependencies.storage.getOpenGroupPublicKey(for: serverName) else { return nil @@ -666,7 +843,7 @@ public final class OpenGroupAPI: NSObject { } return ( - publicKey: IdPrefix.blinded.hexEncodedPublicKey(for: blindedKeyPair.publicKey), + publicKey: SessionId(.blinded, publicKey: blindedKeyPair.publicKey).hexString, signature: signatureResult ) } @@ -677,7 +854,7 @@ public final class OpenGroupAPI: NSObject { } return ( - publicKey: IdPrefix.unblinded.hexEncodedPublicKey(for: userEdKeyPair.publicKey), + publicKey: SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString, signature: signatureResult ) } @@ -691,7 +868,7 @@ public final class OpenGroupAPI: NSObject { .appending(url.query.map { value in "?\(value)" }) let method: String = (request.httpMethod ?? "GET") let timestamp: Int = Int(floor(dependencies.date.timeIntervalSince1970)) - let nonce: Data = Data(dependencies.nonceGenerator.nonce()) + let nonce: Data = Data(dependencies.nonceGenerator16.nonce()) guard let serverPublicKeyData: Data = serverPublicKey.dataFromHex() else { return nil } guard let timestampBytes: Bytes = "\(timestamp)".data(using: .ascii)?.bytes else { return nil } diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index d67a4786d..48ed6d831 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -130,11 +130,12 @@ public final class OpenGroupManager: NSObject { isBackgroundPoll: Bool, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies() ) { - // Sorting the messages by server ID before importing them fixes an issue where messages that quote older messages can't find those older messages + // Sorting the messages by server ID before importing them fixes an issue where messages + // that quote older messages can't find those older messages let openGroupID = "\(server).\(roomToken)" let sortedMessages: [OpenGroupAPI.Message] = messages - .sorted { lhs, rhs in lhs.seqNo < rhs.seqNo } - let seqNo: Int64 = (sortedMessages.last?.seqNo ?? 0) + .sorted { lhs, rhs in lhs.id < rhs.id } + let seqNo: Int64 = (sortedMessages.map { $0.seqNo }.max() ?? 0) dependencies.storage.write { transaction in var messageServerIDsToRemove: [UInt64] = [] @@ -256,7 +257,6 @@ public final class OpenGroupManager: NSObject { imageID: (pollInfo.details?.imageId.map { "\($0)" } ?? existingOpenGroup?.imageID), infoUpdates: ((pollInfo.details?.infoUpdates ?? existingOpenGroup?.infoUpdates) ?? 0) ) - let existingUserCount: UInt64? = dependencies.storage.getUserCount(forOpenGroupWithID: updatedOpenGroup.id) // - Thread changes thread.shouldBeVisible = true @@ -268,7 +268,7 @@ public final class OpenGroupManager: NSObject { // - User Count dependencies.storage.setUserCount( - to: ((pollInfo.activeUsers.map { UInt64($0) } ?? existingUserCount) ?? 0), + to: UInt64(pollInfo.activeUsers), forOpenGroupWithID: updatedOpenGroup.id, using: transaction ) @@ -313,6 +313,51 @@ public final class OpenGroupManager: NSObject { ) } + internal static func handleInbox( + _ messages: [OpenGroupAPI.DirectMessage], + on server: String, + isBackgroundPoll: Bool, + using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies() + ) { + guard let serverPublicKey: String = dependencies.storage.getOpenGroupPublicKey(for: server) else { + SNLog("Couldn't receive inbox message.") + return + } + + // Sorting the messages by server ID before importing them fixes an issue where messages + // that quote older messages can't find those older messages + let sortedMessages: [OpenGroupAPI.DirectMessage] = messages + .sorted { lhs, rhs in lhs.id < rhs.id } + let latestMessageId: Int64 = (sortedMessages.last?.id ?? 0) + + dependencies.storage.write { transaction in + // Update the 'latestMessageId' value + dependencies.storage.setOpenGroupInboxLatestMessageId(for: server, to: latestMessageId, using: transaction) + + // Process the messages + sortedMessages.forEach { message in + guard let messageData = Data(base64Encoded: message.base64EncodedMessage) else { + SNLog("Couldn't receive inbox message.") + return + } + + // Note: The `posted` value is in seconds but all messages in the database use milliseconds for timestamps + let envelope = SNProtoEnvelope.builder(type: .sessionMessage, timestamp: UInt64(floor(message.posted * 1000))) + envelope.setContent(messageData) + envelope.setSource(message.sender) + + do { + let data = try envelope.buildSerializedData() + let (message, proto) = try MessageReceiver.parse(data, openGroupMessageServerID: nil, openGroupServerPublicKey: serverPublicKey, isRetry: false, using: transaction) + try MessageReceiver.handle(message, associatedWithProto: proto, openGroupID: nil, isBackgroundPoll: isBackgroundPoll, using: transaction) + } + catch let error { + SNLog("Couldn't receive inbox message due to error: \(error).") + } + } + } + } + // MARK: - Convenience /// This method specifies if the given publicKey is a moderator or an admin within a specified Open Group @@ -332,7 +377,7 @@ public final class OpenGroupManager: NSObject { } // Add the unblinded key as an option - targetKeys.append(IdPrefix.unblinded.hexEncodedPublicKey(for: userEdKeyPair.publicKey)) + targetKeys.append(SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString) let server: OpenGroupAPI.Server? = dependencies.storage.getOpenGroupServer(name: server) @@ -343,7 +388,7 @@ public final class OpenGroupManager: NSObject { } // Add the blinded key as an option - targetKeys.append(IdPrefix.blinded.hexEncodedPublicKey(for: blindedKeyPair.publicKey)) + targetKeys.append(SessionId(.blinded, publicKey: blindedKeyPair.publicKey).hexString) } } diff --git a/SessionMessagingKit/Open Groups/Types/Dependencies.swift b/SessionMessagingKit/Open Groups/Types/Dependencies.swift index d6872d7f1..b4e011d9c 100644 --- a/SessionMessagingKit/Open Groups/Types/Dependencies.swift +++ b/SessionMessagingKit/Open Groups/Types/Dependencies.swift @@ -13,7 +13,8 @@ extension OpenGroupAPI { let sign: SignType let genericHash: GenericHashType let ed25519: Ed25519Type.Type - let nonceGenerator: NonceGenerator16ByteType + let nonceGenerator16: NonceGenerator16ByteType + let nonceGenerator24: NonceGenerator24ByteType let date: Date public init( @@ -25,7 +26,8 @@ extension OpenGroupAPI { sign: SignType? = nil, genericHash: GenericHashType? = nil, ed25519: Ed25519Type.Type = Ed25519.self, - nonceGenerator: NonceGenerator16ByteType = NonceGenerator16Byte(), + nonceGenerator16: NonceGenerator16ByteType = NonceGenerator16Byte(), + nonceGenerator24: NonceGenerator24ByteType = NonceGenerator24Byte(), date: Date = Date() ) { self.api = api @@ -35,7 +37,8 @@ extension OpenGroupAPI { self.sign = (sign ?? sodium.getSign()) self.genericHash = (genericHash ?? sodium.getGenericHash()) self.ed25519 = ed25519 - self.nonceGenerator = nonceGenerator + self.nonceGenerator16 = nonceGenerator16 + self.nonceGenerator24 = nonceGenerator24 self.date = date } @@ -49,7 +52,8 @@ extension OpenGroupAPI { sign: SignType? = nil, genericHash: GenericHashType? = nil, ed25519: Ed25519Type.Type? = nil, - nonceGenerator: NonceGenerator16ByteType? = nil, + nonceGenerator16: NonceGenerator16ByteType? = nil, + nonceGenerator24: NonceGenerator24ByteType? = nil, date: Date? = nil ) -> Dependencies { return Dependencies( @@ -60,7 +64,8 @@ extension OpenGroupAPI { sign: (sign ?? self.sign), genericHash: (genericHash ?? self.genericHash), ed25519: (ed25519 ?? self.ed25519), - nonceGenerator: (nonceGenerator ?? self.nonceGenerator), + nonceGenerator16: (nonceGenerator16 ?? self.nonceGenerator16), + nonceGenerator24: (nonceGenerator24 ?? self.nonceGenerator24), date: (date ?? self.date) ) } diff --git a/SessionMessagingKit/Open Groups/Types/Endpoint.swift b/SessionMessagingKit/Open Groups/Types/Endpoint.swift index d7e70b4d1..e52fa6375 100644 --- a/SessionMessagingKit/Open Groups/Types/Endpoint.swift +++ b/SessionMessagingKit/Open Groups/Types/Endpoint.swift @@ -136,7 +136,7 @@ extension OpenGroupAPI { // Inbox (Message Requests) case .inbox: return "inbox" - case .inboxSince(let id): return "inbox/\(id)" + case .inboxSince(let id): return "inbox/since/\(id)" case .inboxFor(let sessionId): return "inbox/\(sessionId)" // Users diff --git a/SessionMessagingKit/Open Groups/Types/NonceGenerator16Byte.swift b/SessionMessagingKit/Open Groups/Types/NonceGenerator.swift similarity index 53% rename from SessionMessagingKit/Open Groups/Types/NonceGenerator16Byte.swift rename to SessionMessagingKit/Open Groups/Types/NonceGenerator.swift index e8e1626ad..a2ff6aad8 100644 --- a/SessionMessagingKit/Open Groups/Types/NonceGenerator16Byte.swift +++ b/SessionMessagingKit/Open Groups/Types/NonceGenerator.swift @@ -3,11 +3,15 @@ import Sodium public protocol NonceGenerator16ByteType { + var NonceBytes: Int { get } + func nonce() -> Array } -extension NonceGenerator16ByteType { +public protocol NonceGenerator24ByteType { + var NonceBytes: Int { get } + func nonce() -> Array } extension OpenGroupAPI { @@ -16,4 +20,10 @@ extension OpenGroupAPI { public init() {} } + + public class NonceGenerator24Byte: NonceGenerator, NonceGenerator24ByteType { + public var NonceBytes: Int { 24 } + + public init() {} + } } diff --git a/SessionMessagingKit/Open Groups/Types/Request.swift b/SessionMessagingKit/Open Groups/Types/Request.swift index 3bb50dd05..9c0efe01c 100644 --- a/SessionMessagingKit/Open Groups/Types/Request.swift +++ b/SessionMessagingKit/Open Groups/Types/Request.swift @@ -4,7 +4,10 @@ import Foundation import SessionUtilitiesKit extension OpenGroupAPI { - struct NoBody: Encodable {} + struct Empty: Codable {} + + typealias NoBody = Empty + typealias NoResponse = Empty struct Request { let method: HTTP.Verb diff --git a/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift b/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift index 421b5cff9..21799c354 100644 --- a/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift +++ b/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift @@ -9,14 +9,20 @@ public protocol SodiumType { func getAeadXChaCha20Poly1305Ietf() -> AeadXChaCha20Poly1305IetfType func getSign() -> SignType + func generateBlindingFactor(serverPublicKey: String) -> Bytes? func blindedKeyPair(serverPublicKey: String, edKeyPair: Box.KeyPair, genericHash: GenericHashType) -> Box.KeyPair? func sogsSignature(message: Bytes, secretKey: Bytes, blindedSecretKey ka: Bytes, blindedPublicKey kA: Bytes) -> Bytes? - func sharedEdSecret(_ firstKeyBytes: [UInt8], _ secondKeyBytes: [UInt8]) -> Bytes? + func combineKeys(lhsKeyBytes: Bytes, rhsKeyBytes: Bytes) -> Bytes? + func sharedBlindedEncryptionKey(secretKey a: Bytes, otherBlindedPublicKey: Bytes, fromBlindedPublicKey kA: Bytes, toBlindedPublicKey kB: Bytes, genericHash: GenericHashType) -> Bytes? } public protocol AeadXChaCha20Poly1305IetfType { - func encrypt(message: Bytes, secretKey: Aead.XChaCha20Poly1305Ietf.Key, additionalData: Bytes?) -> (authenticatedCipherText: Bytes, nonce: Aead.XChaCha20Poly1305Ietf.Nonce)? + var KeyBytes: Int { get } + var ABytes: Int { get } + + func encrypt(message: Bytes, secretKey: Bytes, nonce: Bytes, additionalData: Bytes?) -> Bytes? + func decrypt(authenticatedCipherText: Bytes, secretKey: Bytes, nonce: Bytes, additionalData: Bytes?) -> Bytes? } public protocol Ed25519Type { @@ -24,6 +30,9 @@ public protocol Ed25519Type { } public protocol SignType { + var PublicKeyBytes: Int { get } + + func toX25519(ed25519PublicKey: Bytes) -> Bytes? func signature(message: Bytes, secretKey: Bytes) -> Bytes? func verify(message: Bytes, publicKey: Bytes, signature: Bytes) -> Bool } @@ -37,8 +46,12 @@ public protocol GenericHashType { // MARK: - Default Values extension AeadXChaCha20Poly1305IetfType { - func encrypt(message: Bytes, secretKey: Aead.XChaCha20Poly1305Ietf.Key) -> (authenticatedCipherText: Bytes, nonce: Aead.XChaCha20Poly1305Ietf.Nonce)? { - return encrypt(message: message, secretKey: secretKey, additionalData: nil) + func encrypt(message: Bytes, secretKey: Bytes, nonce: Bytes) -> Bytes? { + return encrypt(message: message, secretKey: secretKey, nonce: nonce, additionalData: nil) + } + + func decrypt(authenticatedCipherText: Bytes, secretKey: Bytes, nonce: Bytes) -> Bytes? { + return decrypt(authenticatedCipherText: authenticatedCipherText, secretKey: secretKey, nonce: nonce, additionalData: nil) } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift index f810e4ff9..a1f05d07b 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift @@ -24,7 +24,65 @@ extension MessageReceiver { guard isValid else { throw Error.invalidSignature } // 4. ) Get the sender's X25519 public key guard let senderX25519PublicKey = sodium.sign.toX25519(ed25519PublicKey: senderED25519PublicKey) else { throw Error.decryptionFailed } + // TODO: Need to rework this as it'll be based on the blinded id + return (Data(plaintext), SessionId(.standard, publicKey: senderX25519PublicKey).hexString) + } + + internal static func decryptWithSessionBlindingProtocol(data: Data, fromBlindedPublicKey: String, with openGroupPublicKey: String, userEd25519KeyPair: Box.KeyPair, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) throws -> (plaintext: Data, senderX25519PublicKey: String) { + guard let blindedKeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: openGroupPublicKey, edKeyPair: userEd25519KeyPair, genericHash: dependencies.genericHash) else { + throw Error.decryptionFailed + } + + /// Step one: calculate the shared encryption key, receiving from A to B + let kA: Bytes = Data(hex: fromBlindedPublicKey.removingIdPrefixIfNeeded()).bytes + guard let dec_key: Bytes = dependencies.sodium.sharedBlindedEncryptionKey( + secretKey: userEd25519KeyPair.secretKey, + otherBlindedPublicKey: kA, + fromBlindedPublicKey: kA, + toBlindedPublicKey: blindedKeyPair.publicKey, + genericHash: dependencies.genericHash + ) else { + throw Error.decryptionFailed + } - return (Data(plaintext), IdPrefix.standard.rawValue + senderX25519PublicKey.toHexString()) + /// v, ct, nc = data[0], data[1:-24], data[-24:] + let version: UInt8 = data.bytes[0] + let ciphertext: Bytes = Bytes(data.bytes[1..<(data.count - dependencies.nonceGenerator24.NonceBytes)]) + let nonce: Bytes = Bytes(data.bytes[(data.count - dependencies.nonceGenerator24.NonceBytes).. dependencies.sign.PublicKeyBytes else { throw Error.decryptionFailed } + + /// Split up: the last 32 bytes are the sender's *unblinded* ed25519 key + let plaintext: Bytes = Bytes(innerBytes[ + 0...(innerBytes.count - 1 - dependencies.sign.PublicKeyBytes) + ]) + let sender_edpk: Bytes = Bytes(innerBytes[ + (innerBytes.count - dependencies.sign.PublicKeyBytes)...(innerBytes.count - 1) + ]) + + /// Verify that the inner sender_edpk (A) yields the same outer kA we got with the message + guard let blindingFactor: Bytes = dependencies.sodium.generateBlindingFactor(serverPublicKey: openGroupPublicKey) else { + throw Error.invalidSignature + } + guard let sharedSecret: Bytes = dependencies.sodium.combineKeys(lhsKeyBytes: blindingFactor, rhsKeyBytes: sender_edpk) else { + throw Error.invalidSignature + } + guard kA == sharedSecret else { throw Error.invalidSignature } + + /// Get the sender's X25519 public key + guard let senderSessionIdBytes: Bytes = dependencies.sign.toX25519(ed25519PublicKey: sender_edpk) else { + throw Error.decryptionFailed + } + + return (Data(plaintext), SessionId(.standard, publicKey: senderSessionIdBytes).hexString) } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index ba5a69bb8..67082b546 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -48,66 +48,90 @@ public enum MessageReceiver { } } - public static func parse(_ data: Data, openGroupMessageServerID: UInt64?, isRetry: Bool = false, using transaction: Any) throws -> (Message, SNProtoContent) { + public static func parse(_ data: Data, openGroupMessageServerID: UInt64?, openGroupServerPublicKey: String? = nil, isRetry: Bool = false, using transaction: Any) throws -> (Message, SNProtoContent) { let userPublicKey = SNMessagingKitConfiguration.shared.storage.getUserPublicKey() let isOpenGroupMessage = (openGroupMessageServerID != nil) + // Parse the envelope let envelope = try SNProtoEnvelope.parseData(data) let storage = SNMessagingKitConfiguration.shared.storage + // Decrypt the contents guard let ciphertext = envelope.content else { throw Error.noData } + var plaintext: Data! var sender: String! var groupPublicKey: String? = nil + if isOpenGroupMessage { (plaintext, sender) = (envelope.content!, envelope.source!) - } else { + } + else { switch envelope.type { - case .sessionMessage: - guard let userX25519KeyPair = SNMessagingKitConfiguration.shared.storage.getUserKeyPair() else { throw Error.noUserX25519KeyPair } - (plaintext, sender) = try decryptWithSessionProtocol(ciphertext: ciphertext, using: userX25519KeyPair) - case .closedGroupMessage: - guard let hexEncodedGroupPublicKey = envelope.source, SNMessagingKitConfiguration.shared.storage.isClosedGroup(hexEncodedGroupPublicKey) else { throw Error.invalidGroupPublicKey } - var encryptionKeyPairs = Storage.shared.getClosedGroupEncryptionKeyPairs(for: hexEncodedGroupPublicKey) - guard !encryptionKeyPairs.isEmpty else { throw Error.noGroupKeyPair } - // Loop through all known group key pairs in reverse order (i.e. try the latest key pair first (which'll more than - // likely be the one we want) but try older ones in case that didn't work) - var encryptionKeyPair = encryptionKeyPairs.removeLast() - func decrypt() throws { - do { - (plaintext, sender) = try decryptWithSessionProtocol(ciphertext: ciphertext, using: encryptionKeyPair) - } catch { - if !encryptionKeyPairs.isEmpty { - encryptionKeyPair = encryptionKeyPairs.removeLast() - try decrypt() - } else { - throw error + case .sessionMessage: + // Default to 'standard' as the old code didn't seem to require an `envelope.source` + switch (SessionId.Prefix(from: envelope.source) ?? .standard) { + case .standard, .unblinded: + guard let userX25519KeyPair = SNMessagingKitConfiguration.shared.storage.getUserKeyPair() else { + throw Error.noUserX25519KeyPair + } + + (plaintext, sender) = try decryptWithSessionProtocol(ciphertext: ciphertext, using: userX25519KeyPair) + + case .blinded: + guard let senderSessionId: String = envelope.source else { throw Error.noData } + guard let openGroupServerPublicKey: String = openGroupServerPublicKey else { + throw Error.invalidGroupPublicKey + } + guard let userEd25519KeyPair = SNMessagingKitConfiguration.shared.storage.getUserED25519KeyPair() else { + throw Error.noUserED25519KeyPair + } + + (plaintext, sender) = try decryptWithSessionBlindingProtocol( + data: ciphertext, + fromBlindedPublicKey: senderSessionId, + with: openGroupServerPublicKey, + userEd25519KeyPair: userEd25519KeyPair + ) + } + + case .closedGroupMessage: + guard let hexEncodedGroupPublicKey = envelope.source, SNMessagingKitConfiguration.shared.storage.isClosedGroup(hexEncodedGroupPublicKey) else { + throw Error.invalidGroupPublicKey + } + + var encryptionKeyPairs = Storage.shared.getClosedGroupEncryptionKeyPairs(for: hexEncodedGroupPublicKey) + + guard !encryptionKeyPairs.isEmpty else { throw Error.noGroupKeyPair } + + // Loop through all known group key pairs in reverse order (i.e. try the latest key pair first (which'll more than + // likely be the one we want) but try older ones in case that didn't work) + var encryptionKeyPair = encryptionKeyPairs.removeLast() + + func decrypt() throws { + do { + (plaintext, sender) = try decryptWithSessionProtocol(ciphertext: ciphertext, using: encryptionKeyPair) + } + catch { + if !encryptionKeyPairs.isEmpty { + encryptionKeyPair = encryptionKeyPairs.removeLast() + try decrypt() + } + else { + throw error + } } } - } - groupPublicKey = envelope.source - try decrypt() - /* - do { + groupPublicKey = envelope.source try decrypt() - } catch { - do { - let now = Date() - // Don't spam encryption key pair requests - let shouldRequestEncryptionKeyPair = given(lastEncryptionKeyPairRequest[groupPublicKey!]) { now.timeIntervalSince($0) > 30 } ?? true - if shouldRequestEncryptionKeyPair { - try MessageSender.requestEncryptionKeyPair(for: groupPublicKey!, using: transaction as! YapDatabaseReadWriteTransaction) - lastEncryptionKeyPairRequest[groupPublicKey!] = now - } - } - throw error // Throw the * decryption * error and not the error generated by requestEncryptionKeyPair (if it generated one) - } - */ - default: throw Error.unknownEnvelopeType + + default: throw Error.unknownEnvelopeType } } + // Don't process the envelope any further if the sender is blocked guard !isBlocked(sender) else { throw Error.senderBlocked } + // Parse the proto let proto: SNProtoContent do { diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift index 7e942438d..efbfa9e99 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift @@ -8,7 +8,7 @@ extension MessageSender { var members = members let userPublicKey = getUserHexEncodedPublicKey() // Generate the group's public key - let groupPublicKey = Curve25519.generateKeyPair().hexEncodedPublicKey // Includes the 'IdPrefix.standard' prefix + let groupPublicKey = Curve25519.generateKeyPair().hexEncodedPublicKey // Includes the 'SessionId.Prefix.standard' prefix // Generate the key pair that'll be used for encryption and decryption let encryptionKeyPair = Curve25519.generateKeyPair() // Ensure the current user is included in the member list diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift index da74d17f5..9d9d236ee 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift @@ -4,15 +4,59 @@ import Sodium extension MessageSender { internal static func encryptWithSessionProtocol(_ plaintext: Data, for recipientHexEncodedX25519PublicKey: String) throws -> Data { - guard let userED25519KeyPair = SNMessagingKitConfiguration.shared.storage.getUserED25519KeyPair() else { throw Error.noUserED25519KeyPair } + guard let userED25519KeyPair = SNMessagingKitConfiguration.shared.storage.getUserED25519KeyPair() else { + throw Error.noUserED25519KeyPair + } + let recipientX25519PublicKey = Data(hex: recipientHexEncodedX25519PublicKey.removingIdPrefixIfNeeded()) let sodium = Sodium() let verificationData = plaintext + Data(userED25519KeyPair.publicKey) + recipientX25519PublicKey - guard let signature = sodium.sign.signature(message: Bytes(verificationData), secretKey: userED25519KeyPair.secretKey) else { throw Error.signingFailed } + guard let signature = sodium.sign.signature(message: Bytes(verificationData), secretKey: userED25519KeyPair.secretKey) else { + throw Error.signingFailed + } + let plaintextWithMetadata = plaintext + Data(userED25519KeyPair.publicKey) + Data(signature) - guard let ciphertext = sodium.box.seal(message: Bytes(plaintextWithMetadata), recipientPublicKey: Bytes(recipientX25519PublicKey)) else { throw Error.encryptionFailed } + guard let ciphertext = sodium.box.seal(message: Bytes(plaintextWithMetadata), recipientPublicKey: Bytes(recipientX25519PublicKey)) else { + throw Error.encryptionFailed + } return Data(ciphertext) } + + internal static func encryptWithSessionBlindingProtocol(_ plaintext: Data, for recipientBlindedId: String, openGroupPublicKey: String, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) throws -> Data { + guard SessionId.Prefix(from: recipientBlindedId) == .blinded else { throw Error.signingFailed } + guard let userEd25519KeyPair = SNMessagingKitConfiguration.shared.storage.getUserED25519KeyPair() else { + throw Error.noUserED25519KeyPair + } + guard let blindedKeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: openGroupPublicKey, edKeyPair: userEd25519KeyPair, genericHash: dependencies.genericHash) else { + throw Error.signingFailed + } + + let recipientBlindedPublicKey = Data(hex: recipientBlindedId.removingIdPrefixIfNeeded()) + + /// Step one: calculate the shared encryption key, sending from A to B + guard let enc_key: Bytes = dependencies.sodium.sharedBlindedEncryptionKey( + secretKey: userEd25519KeyPair.secretKey, + otherBlindedPublicKey: recipientBlindedPublicKey.bytes, + fromBlindedPublicKey: blindedKeyPair.publicKey, + toBlindedPublicKey: recipientBlindedPublicKey.bytes, + genericHash: dependencies.genericHash + ) else { + throw Error.signingFailed + } + + /// Inner data: msg || A (i.e. the sender's ed25519 master pubkey, *not* kA blinded pubkey) + let innerBytes: Bytes = (plaintext.bytes + userEd25519KeyPair.publicKey) + + /// Encrypt using xchacha20-poly1305 + let nonce: Bytes = dependencies.nonceGenerator24.nonce() + + guard let ciphertext = dependencies.aeadXChaCha20Poly1305Ietf.encrypt(message: innerBytes, secretKey: enc_key, nonce: nonce) else { + throw Error.encryptionFailed + } + + /// data = b'\x00' + ciphertext + nonce + return Data(Bytes(arrayLiteral: 0) + ciphertext + nonce) + } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 31b90d37c..c053ebeb3 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -103,6 +103,9 @@ public final class MessageSender : NSObject { case .legacyOpenGroup, .openGroup: return sendToOpenGroupDestination(destination, message: message, using: transaction) + + case .openGroupInbox: + return sendToOpenGroupInboxDestination(destination, message: message, using: transaction) } } @@ -125,7 +128,7 @@ public final class MessageSender : NSObject { switch destination { case .contact(let publicKey): message.recipient = publicKey case .closedGroup(let groupPublicKey): message.recipient = groupPublicKey - case .legacyOpenGroup, .openGroup: preconditionFailure() + case .legacyOpenGroup, .openGroup, .openGroupInbox: preconditionFailure() } let isSelfSend = (message.recipient == userPublicKey) @@ -183,7 +186,7 @@ public final class MessageSender : NSObject { ciphertext = try encryptWithSessionProtocol(plaintext, for: encryptionKeyPair.hexEncodedPublicKey) - case .legacyOpenGroup, .openGroup: preconditionFailure() + case .legacyOpenGroup, .openGroup, .openGroupInbox: preconditionFailure() } } catch { @@ -205,7 +208,7 @@ public final class MessageSender : NSObject { kind = .closedGroupMessage senderPublicKey = groupPublicKey - case .legacyOpenGroup, .openGroup: preconditionFailure() + case .legacyOpenGroup, .openGroup, .openGroupInbox: preconditionFailure() } let wrappedMessage: Data @@ -302,15 +305,14 @@ public final class MessageSender : NSObject { preconditionFailure() } - message.sender = IdPrefix.blinded.hexEncodedPublicKey(for: blindedKeyPair.publicKey) + message.sender = SessionId(.blinded, publicKey: blindedKeyPair.publicKey).hexString } else { - message.sender = IdPrefix.unblinded.hexEncodedPublicKey(for: userEdKeyPair.publicKey) + message.sender = SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString } switch destination { - case .contact(_): preconditionFailure() - case .closedGroup(_): preconditionFailure() + case .contact, .closedGroup, .openGroupInbox: preconditionFailure() case .legacyOpenGroup(let channel, let server): message.recipient = "\(server).\(channel)" case .openGroup(let room, let server, let whisperTo, let whisperMods, _): @@ -405,6 +407,99 @@ public final class MessageSender : NSObject { return promise } + + internal static func sendToOpenGroupInboxDestination(_ destination: Message.Destination, message: Message, using transaction: Any, dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) -> Promise { + let (promise, seal) = Promise.pending() + let storage = SNMessagingKitConfiguration.shared.storage + let transaction = transaction as! YapDatabaseReadWriteTransaction + let userPublicKey = storage.getUserPublicKey() + + guard case .openGroupInbox(let server, let openGroupPublicKey, let recipientBlindedPublicKey) = destination else { + preconditionFailure() + } + + // Set the timestamp, sender and recipient + if message.sentTimestamp == nil { // Visible messages will already have their sent timestamp set + message.sentTimestamp = NSDate.millisecondTimestamp() + } + + message.sender = userPublicKey + message.recipient = recipientBlindedPublicKey + + // Set the failure handler (need it here already for precondition failure handling) + func handleFailure(with error: Swift.Error, using transaction: YapDatabaseReadWriteTransaction) { + MessageSender.handleFailedMessageSend(message, with: error, using: transaction) + seal.reject(error) + } + + // Attach the user's profile if needed + if let message = message as? VisibleMessage { + guard let name = storage.getUser()?.name else { + handleFailure(with: Error.noUsername, using: transaction) + return promise + } + + if let profileKey = storage.getUser()?.profileEncryptionKey?.keyData, let profilePictureURL = storage.getUser()?.profilePictureURL { + message.profile = VisibleMessage.Profile(displayName: name, profileKey: profileKey, profilePictureURL: profilePictureURL) + } + else { + message.profile = VisibleMessage.Profile(displayName: name) + } + } + + // Convert it to protobuf + guard let proto = message.toProto(using: transaction) else { + handleFailure(with: Error.protoConversionFailed, using: transaction) + return promise + } + + // Serialize the protobuf + let plaintext: Data + + do { + plaintext = (try proto.serializedData() as NSData).paddedMessageBody() + } + catch { + SNLog("Couldn't serialize proto due to error: \(error).") + handleFailure(with: error, using: transaction) + return promise + } + + // Encrypt the serialized protobuf + let ciphertext: Data + + do { + ciphertext = try encryptWithSessionBlindingProtocol(plaintext, for: recipientBlindedPublicKey, openGroupPublicKey: openGroupPublicKey, using: dependencies) + } + catch { + SNLog("Couldn't encrypt message for destination: \(destination) due to error: \(error).") + handleFailure(with: error, using: transaction) + return promise + } + + // Send the result + + OpenGroupAPI + .send( + ciphertext, + toInboxFor: recipientBlindedPublicKey, + on: server, + using: dependencies + ) + .done(on: DispatchQueue.global(qos: .userInitiated)) { responseInfo, data in + dependencies.storage.write { transaction in + MessageSender.handleSuccessfulMessageSend(message, to: destination, using: transaction) + seal.fulfill(()) + } + } + .catch(on: DispatchQueue.global(qos: .userInitiated)) { error in + dependencies.storage.write { transaction in + handleFailure(with: error, using: transaction as! YapDatabaseReadWriteTransaction) + } + } + + return promise + } // MARK: Success & Failure Handling public static func handleSuccessfulMessageSend(_ message: Message, to destination: Message.Destination, serverTimestamp: UInt64? = nil, isSyncMessage: Bool = false, using transaction: Any) { diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift index 0dff4119b..7aab7ae53 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift @@ -83,46 +83,58 @@ extension OpenGroupAPI { return promise } - private func handlePollResponse(_ response: [OpenGroupAPI.Endpoint: (info: OnionRequestResponseInfoType, data: Codable)], isBackgroundPoll: Bool) { - response.forEach { endpoint, response in + private func handlePollResponse(_ response: [OpenGroupAPI.Endpoint: (info: OnionRequestResponseInfoType, data: Codable?)], isBackgroundPoll: Bool) { + response.forEach { endpoint, endpointResponse in switch endpoint { case .capabilities: - guard let responseData: BatchSubResponse = response.data as? BatchSubResponse else { + guard let responseData: BatchSubResponse = endpointResponse.data as? BatchSubResponse, let responseBody: Capabilities = responseData.body else { SNLog("Open group polling failed due to invalid data.") return } OpenGroupManager.handleCapabilities( - responseData.body, + responseBody, on: server ) case .roomMessagesRecent(let roomToken), .roomMessagesBefore(let roomToken, _), .roomMessagesSince(let roomToken, _): - guard let responseData: BatchSubResponse<[Message]> = response.data as? BatchSubResponse<[Message]> else { + guard let responseData: BatchSubResponse<[Message]> = endpointResponse.data as? BatchSubResponse<[Message]>, let responseBody: [Message] = responseData.body else { SNLog("Open group polling failed due to invalid data.") return } OpenGroupManager.handleMessages( - responseData.body, + responseBody, for: roomToken, on: server, isBackgroundPoll: isBackgroundPoll ) case .roomPollInfo(let roomToken, _): - guard let responseData: BatchSubResponse = response.data as? BatchSubResponse else { + guard let responseData: BatchSubResponse = endpointResponse.data as? BatchSubResponse, let responseBody: RoomPollInfo = responseData.body else { SNLog("Open group polling failed due to invalid data.") return } OpenGroupManager.handlePollInfo( - responseData.body, + responseBody, publicKey: nil, for: roomToken, on: server ) + case .inbox, .inboxSince: + guard let responseData: BatchSubResponse<[DirectMessage]> = endpointResponse.data as? BatchSubResponse<[DirectMessage]>, let responseBody: [DirectMessage] = responseData.body else { + SNLog("Open group polling failed due to invalid data.") + return + } + + OpenGroupManager.handleInbox( + responseBody, + on: server, + isBackgroundPoll: isBackgroundPoll + ) + default: break // No custom handling needed } } diff --git a/SessionMessagingKit/Storage.swift b/SessionMessagingKit/Storage.swift index 22fff6763..414015fcd 100644 --- a/SessionMessagingKit/Storage.swift +++ b/SessionMessagingKit/Storage.swift @@ -71,6 +71,12 @@ public protocol SessionMessagingKitStorageProtocol { func getOpenGroupSequenceNumber(for room: String, on server: String) -> Int64? func setOpenGroupSequenceNumber(for room: String, on server: String, to newValue: Int64, using transaction: Any) func removeOpenGroupSequenceNumber(for room: String, on server: String, using transaction: Any) + + // MARK: - -- Open Group Inbox Latest Message Id + + func getOpenGroupInboxLatestMessageId(for server: String) -> Int64? + func setOpenGroupInboxLatestMessageId(for server: String, to newValue: Int64, using transaction: Any) + func removeOpenGroupInboxLatestMessageId(for server: String, using transaction: Any) // MARK: - Message Handling diff --git a/SessionMessagingKit/Utilities/Sodium+Utilities.swift b/SessionMessagingKit/Utilities/Sodium+Utilities.swift index 8a2c8f67c..43afd803b 100644 --- a/SessionMessagingKit/Utilities/Sodium+Utilities.swift +++ b/SessionMessagingKit/Utilities/Sodium+Utilities.swift @@ -47,14 +47,9 @@ extension Sodium { private static let publicKeyLength: Int = Int(crypto_scalarmult_bytes()) // 32 private static let secretKeyLength: Int = Int(crypto_sign_secretkeybytes()) // 64 - /// Constructs a "blinded" key pair (`ka, kA`) based on an open group server `publicKey` and an ed25519 `keyPair` - public func blindedKeyPair(serverPublicKey: String, edKeyPair: Box.KeyPair, genericHash: GenericHashType) -> Box.KeyPair? { - guard edKeyPair.publicKey.count == Sodium.publicKeyLength && edKeyPair.secretKey.count == Sodium.secretKeyLength else { - return nil - } - - /// 64-byte blake2b hash then reduce to get the blinding factor: - /// k = salt.crypto_core_ed25519_scalar_reduce(blake2b(server_pk, digest_size=64).digest()) + /// 64-byte blake2b hash then reduce to get the blinding factor + public func generateBlindingFactor(serverPublicKey: String) -> Bytes? { + /// k = salt.crypto_core_ed25519_scalar_reduce(blake2b(server_pk, digest_size=64).digest()) guard let serverPubKeyData: Data = serverPublicKey.dataFromHex() else { return nil } guard let serverPublicKeyHashBytes: Bytes = genericHash.hash(message: [UInt8](serverPubKeyData), outputLength: 64) else { return nil @@ -75,15 +70,18 @@ extension Sodium { /// Ensure the above worked guard kResult == 0 else { return nil } - /// Calculate k*a. To get 'a' (the Ed25519 private key scalar) we call the sodium function to - /// convert to an *x* secret key, which seems wrong--but isn't because converted keys use the - /// same secret scalar secret. (And so this is just the most convenient way to get 'a' out of - /// a sodium Ed25519 secret key). - /// a = s.to_curve25519_private_key().encode() - let secretKeyBytes: Bytes = [UInt8](edKeyPair.secretKey) + return Data(bytes: kPtr, count: Sodium.scalarLength).bytes + } + + /// Calculate k*a. To get 'a' (the Ed25519 private key scalar) we call the sodium function to + /// convert to an *x* secret key, which seems wrong--but isn't because converted keys use the + /// same secret scalar secret (and so this is just the most convenient way to get 'a' out of + /// a sodium Ed25519 secret key) + private func generatePrivateKeyScalar(secretKey: Bytes) -> Bytes? { + /// a = s.to_curve25519_private_key().encode() let aPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.scalarMultLength) - let aResult = secretKeyBytes.withUnsafeBytes { (secretKeyPtr: UnsafeRawBufferPointer) -> Int32 in + let aResult = secretKey.withUnsafeBytes { (secretKeyPtr: UnsafeRawBufferPointer) -> Int32 in guard let secretKeyBaseAddress: UnsafePointer = secretKeyPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return -1 } @@ -94,10 +92,38 @@ extension Sodium { /// Ensure the above worked guard aResult == 0 else { return nil } + return Data(bytes: aPtr, count: Sodium.scalarMultLength).bytes + } + + /// Constructs a "blinded" key pair (`ka, kA`) based on an open group server `publicKey` and an ed25519 `keyPair` + public func blindedKeyPair(serverPublicKey: String, edKeyPair: Box.KeyPair, genericHash: GenericHashType) -> Box.KeyPair? { + guard edKeyPair.publicKey.count == Sodium.publicKeyLength && edKeyPair.secretKey.count == Sodium.secretKeyLength else { + return nil + } + guard let kBytes: Bytes = generateBlindingFactor(serverPublicKey: serverPublicKey) else { return nil } + guard let aBytes: Bytes = generatePrivateKeyScalar(secretKey: edKeyPair.secretKey) else { return nil } + /// Generate the blinded key pair `ka`, `kA` let kaPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.secretKeyLength) let kAPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.publicKeyLength) - crypto_core_ed25519_scalar_mul(kaPtr, kPtr, aPtr) + + let kaResult = aBytes.withUnsafeBytes { (aPtr: UnsafeRawBufferPointer) -> Int32 in + return kBytes.withUnsafeBytes { (kPtr: UnsafeRawBufferPointer) -> Int32 in + guard let kBaseAddress: UnsafePointer = kPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return -1 + } + guard let aBaseAddress: UnsafePointer = aPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return -1 + } + + crypto_core_ed25519_scalar_mul(kaPtr, kBaseAddress, aBaseAddress) + return 0 + } + } + + /// Ensure the above worked + guard kaResult == 0 else { return nil } + guard crypto_scalarmult_ed25519_base_noclamp(kAPtr, kaPtr) == 0 else { return nil } return Box.KeyPair( @@ -108,7 +134,7 @@ extension Sodium { /// Constructs an Ed25519 signature from a root Ed25519 key and a blinded scalar/pubkey pair, with one tweak to the /// construction: we add kA into the hashed value that yields r so that we have domain separation for different blinded - /// pubkeys (This doesn't affect verification at all). + /// pubkeys (this doesn't affect verification at all) public func sogsSignature(message: Bytes, secretKey: Bytes, blindedSecretKey ka: Bytes, blindedPublicKey kA: Bytes) -> Bytes? { /// H_rh = sha512(s.encode()).digest()[32:] let H_rh: Bytes = Bytes(secretKey.sha512().suffix(32)) @@ -170,25 +196,41 @@ extension Sodium { return (Data(bytes: sig_RPtr, count: Sodium.noClampLength).bytes + Data(bytes: sig_sPtr, count: Sodium.scalarLength).bytes) } - // TODO: Determine if we still need this? (To generate the `kB` value for the `/inbox` API????) - public func sharedEdSecret(_ firstKeyBytes: [UInt8], _ secondKeyBytes: [UInt8]) -> Bytes? { - let sharedSecretPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.noClampLength) - let result = secondKeyBytes.withUnsafeBytes { (secondKeyPtr: UnsafeRawBufferPointer) -> Int32 in - return firstKeyBytes.withUnsafeBytes { (firstKeyPtr: UnsafeRawBufferPointer) -> Int32 in - guard let firstKeyBaseAddress: UnsafePointer = firstKeyPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + /// Combines two keys (`kA`) + public func combineKeys(lhsKeyBytes: Bytes, rhsKeyBytes: Bytes) -> Bytes? { + let combinedPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.noClampLength) + + let result = rhsKeyBytes.withUnsafeBytes { (rhsKeyBytesPtr: UnsafeRawBufferPointer) -> Int32 in + return lhsKeyBytes.withUnsafeBytes { (lhsKeyBytesPtr: UnsafeRawBufferPointer) -> Int32 in + guard let lhsKeyBytesBaseAddress: UnsafePointer = lhsKeyBytesPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return -1 } - guard let secondKeyBaseAddress: UnsafePointer = secondKeyPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + guard let rhsKeyBytesBaseAddress: UnsafePointer = rhsKeyBytesPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return -1 } - return crypto_scalarmult_ed25519_noclamp(sharedSecretPtr, firstKeyBaseAddress, secondKeyBaseAddress) + return crypto_scalarmult_ed25519_noclamp(combinedPtr, lhsKeyBytesBaseAddress, rhsKeyBytesBaseAddress) } } + /// Ensure the above worked guard result == 0 else { return nil } - return Data(bytes: sharedSecretPtr, count: Sodium.scalarMultLength).bytes + return Data(bytes: combinedPtr, count: Sodium.noClampLength).bytes + } + + /// Calculate a shared secret for a message from A to B: + /// + /// BLAKE2b(a kB || kA || kB) + /// + /// The receiver can calulate the same value via: + /// + /// BLAKE2b(b kA || kA || kB) + public func sharedBlindedEncryptionKey(secretKey: Bytes, otherBlindedPublicKey: Bytes, fromBlindedPublicKey kA: Bytes, toBlindedPublicKey kB: Bytes, genericHash: GenericHashType) -> Bytes? { + guard let aBytes: Bytes = generatePrivateKeyScalar(secretKey: secretKey) else { return nil } + guard let combinedKeyBytes: Bytes = combineKeys(lhsKeyBytes: aBytes, rhsKeyBytes: otherBlindedPublicKey) else { return nil } + + return genericHash.hash(message: (combinedKeyBytes + kA + kB), outputLength: 32) } } @@ -218,3 +260,25 @@ extension GenericHash { return output } } + +extension AeadXChaCha20Poly1305IetfType { + /// This method is the same as the standard AeadXChaCha20Poly1305IetfType `encrypt` method except it allows the + /// specification of a nonce which allows for deterministic behaviour with unit testing + public func encrypt(message: Bytes, secretKey: Bytes, nonce: Bytes, additionalData: Bytes? = nil) -> Bytes? { + guard secretKey.count == KeyBytes else { return nil } + + var authenticatedCipherText = Bytes(repeating: 0, count: message.count + ABytes) + var authenticatedCipherTextLen: UInt64 = 0 + + let result = crypto_aead_xchacha20poly1305_ietf_encrypt( + &authenticatedCipherText, &authenticatedCipherTextLen, + message, UInt64(message.count), + additionalData, UInt64(additionalData?.count ?? 0), + nil, nonce, secretKey + ) + + guard result == 0 else { return nil } + + return authenticatedCipherText + } +} diff --git a/SessionUtilitiesKit/Crypto/ECKeyPair+Hexadecimal.swift b/SessionUtilitiesKit/Crypto/ECKeyPair+Hexadecimal.swift index 72f15c5de..5dd86097d 100644 --- a/SessionUtilitiesKit/Crypto/ECKeyPair+Hexadecimal.swift +++ b/SessionUtilitiesKit/Crypto/ECKeyPair+Hexadecimal.swift @@ -7,15 +7,19 @@ public extension ECKeyPair { } @objc var hexEncodedPublicKey: String { - // Prefixing with 'IdPrefix.standard' is necessary for what seems to be a sort of Signal public key versioning system - return IdPrefix.standard.rawValue + publicKey.map { String(format: "%02hhx", $0) }.joined() + // Prefixing with 'SessionId.Prefix.standard' is necessary for what seems to be a sort of Signal public key versioning system + return SessionId(.standard, publicKey: publicKey.bytes).hexString } @objc static func isValidHexEncodedPublicKey(candidate: String) -> Bool { + // Note: If the logic in here changes ensure it doesn't break `SessionId.Prefix(from:)` // Check that it's a valid hexadecimal encoding guard Hex.isValid(candidate) else { return false } // Check that it has length 66 and a valid prefix - guard candidate.count == 66 && IdPrefix.allCases.first(where: { candidate.hasPrefix($0.rawValue) }) != nil else { return false } + guard candidate.count == 66 && SessionId.Prefix.allCases.first(where: { candidate.hasPrefix($0.rawValue) }) != nil else { + return false + } + // It appears to be a valid public key return true } diff --git a/SessionUtilitiesKit/General/Data+Utilities.swift b/SessionUtilitiesKit/General/Data+Utilities.swift index 54502935a..1478d4aad 100644 --- a/SessionUtilitiesKit/General/Data+Utilities.swift +++ b/SessionUtilitiesKit/General/Data+Utilities.swift @@ -4,7 +4,7 @@ public extension Data { func removingIdPrefixIfNeeded() -> Data { var result = self - if result.count == 33 && IdPrefix(with: result.toHexString()) != nil { result.removeFirst() } + if result.count == 33 && SessionId.Prefix(from: result.toHexString()) != nil { result.removeFirst() } return result } @@ -29,7 +29,7 @@ public extension Data { @objc func removingIdPrefixIfNeeded() -> NSData { var result = self as Data - if result.count == 33 && IdPrefix(with: result.toHexString()) != nil { result.removeFirst() } + if result.count == 33 && SessionId.Prefix(from: result.toHexString()) != nil { result.removeFirst() } return result as NSData } } diff --git a/SessionUtilitiesKit/General/IdPrefix.swift b/SessionUtilitiesKit/General/IdPrefix.swift deleted file mode 100644 index 55487dd97..000000000 --- a/SessionUtilitiesKit/General/IdPrefix.swift +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Sodium -import Curve25519Kit - -public enum IdPrefix: String, CaseIterable { - case standard = "05" // Used for identified users, open groups, etc. - case blinded = "15" // Used for participants in open groups with blinding enabled - case unblinded = "00" // Used for participants in open groups with blinding disabled - - public init?(with sessionId: String) { - // TODO: Determine if we want this 'idPrefix' method (would need to validate both `ECKeyPair` and `Box.KeyPair` types) - guard ECKeyPair.isValidHexEncodedPublicKey(candidate: sessionId) else { return nil } - guard let targetPrefix: IdPrefix = IdPrefix(rawValue: String(sessionId.prefix(2))) else { return nil } - - self = targetPrefix - } - - public func hexEncodedPublicKey(for publicKey: Bytes) -> String { - - return self.rawValue + publicKey.map { String(format: "%02hhx", $0) }.joined() - } -} diff --git a/SessionUtilitiesKit/General/SessionId.swift b/SessionUtilitiesKit/General/SessionId.swift new file mode 100644 index 000000000..a20c25620 --- /dev/null +++ b/SessionUtilitiesKit/General/SessionId.swift @@ -0,0 +1,57 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium +import Curve25519Kit + +public struct SessionId { + public enum Prefix: String, CaseIterable { + case standard = "05" // Used for identified users, open groups, etc. + case blinded = "15" // Used for participants in open groups with blinding enabled + case unblinded = "00" // Used for participants in open groups with blinding disabled + + public init?(from stringValue: String?) { + guard let stringValue: String = stringValue else { return nil } + + guard stringValue.count > 2 else { + guard let targetPrefix: Prefix = Prefix(rawValue: stringValue) else { return nil } + self = targetPrefix + return + } + + guard ECKeyPair.isValidHexEncodedPublicKey(candidate: stringValue) else { return nil } + guard let targetPrefix: Prefix = Prefix(rawValue: String(stringValue.prefix(2))) else { return nil } + + self = targetPrefix + } + } + + public let prefix: Prefix + public let publicKey: String + + public var hexString: String { + return prefix.rawValue + publicKey + } + + // MARK: - Initialization + + public init?(from idString: String?) { + guard let idString: String = idString, idString.count > 2 else { return nil } + guard let targetPrefix: Prefix = Prefix(from: idString) else { return nil } + + self.prefix = targetPrefix + self.publicKey = idString.substring(from: 2) + } + + public init?(_ type: Prefix, publicKey: String) { + guard ECKeyPair.isValidHexEncodedPublicKey(candidate: publicKey) else { return nil } + + self.prefix = type + self.publicKey = publicKey + } + + public init(_ type: Prefix, publicKey: Bytes) { + self.prefix = type + self.publicKey = publicKey.map { String(format: "%02hhx", $0) }.joined() + } +} diff --git a/SessionUtilitiesKit/General/String+Trimming.swift b/SessionUtilitiesKit/General/String+Trimming.swift index c7e247c97..53ccc91ee 100644 --- a/SessionUtilitiesKit/General/String+Trimming.swift +++ b/SessionUtilitiesKit/General/String+Trimming.swift @@ -3,7 +3,7 @@ public extension String { func removingIdPrefixIfNeeded() -> String { var result = self - if result.count == 66 && IdPrefix(with: result) != nil { result.removeFirst(2) } + if result.count == 66 && SessionId.Prefix(from: result) != nil { result.removeFirst(2) } return result } } @@ -12,7 +12,7 @@ public extension String { @objc func removingIdPrefixIfNeeded() -> NSString { var result = self as String - if result.count == 66 && IdPrefix(with: result) != nil { result.removeFirst(2) } + if result.count == 66 && SessionId.Prefix(from: result) != nil { result.removeFirst(2) } return result as NSString } } diff --git a/SignalUtilitiesKit/Profile Pictures/Identicon+ObjC.swift b/SignalUtilitiesKit/Profile Pictures/Identicon+ObjC.swift index b50ce81a2..07a3f00cf 100644 --- a/SignalUtilitiesKit/Profile Pictures/Identicon+ObjC.swift +++ b/SignalUtilitiesKit/Profile Pictures/Identicon+ObjC.swift @@ -5,7 +5,7 @@ public final class Identicon : NSObject { @objc public static func generatePlaceholderIcon(seed: String, text: String, size: CGFloat) -> UIImage { let icon = PlaceholderIcon(seed: seed) var content = text - if content.count > 2 && IdPrefix(with: content) != nil { + if content.count > 2 && SessionId.Prefix(from: content) != nil { content.removeFirst(2) } let layer = icon.generateLayer(with: size, text: content.substring(to: 1)) From cc2a077a6c4b9f3efacc263a89f304dede6fa1da Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 25 Feb 2022 17:48:09 +1100 Subject: [PATCH 017/157] Started working on `MessageRequestResponse` handling for SOGS message requests Pointing Curve25519 to use a fork that exposes an XEd25519 conversion method Fixed an issue where I had broken all message sending due to the SnodeAPI casting Onion responses to `Any` --- Podfile | 3 +- Podfile.lock | 11 +++-- .../Database/Storage+Messaging.swift | 28 +++++++++++ .../Open Groups/OpenGroupAPI.swift | 12 ++--- .../Open Groups/OpenGroupManager.swift | 2 + .../Open Groups/Types/SodiumProtocols.swift | 2 + .../Pollers/OpenGroupPoller.swift | 4 +- SessionMessagingKit/Storage.swift | 3 ++ .../Utilities/Sodium+Utilities.swift | 28 +++++++++++ .../OnionRequestAPI+Encryption.swift | 46 ++++++------------- SessionSnodeKit/OnionRequestAPI.swift | 6 +-- SessionSnodeKit/SnodeAPI.swift | 11 ++++- 12 files changed, 106 insertions(+), 50 deletions(-) diff --git a/Podfile b/Podfile index b2f5d93ca..a6b96cb29 100644 --- a/Podfile +++ b/Podfile @@ -24,7 +24,8 @@ abstract_target 'GlobalDependencies' do # Dependencies to be included only in all extensions/frameworks abstract_target 'FrameworkAndExtensionDependencies' do - pod 'Curve25519Kit', git: 'https://github.com/signalapp/Curve25519Kit.git' + # TODO: Swap this to use an oxen-io fork + pod 'Curve25519Kit', git: 'https://github.com/mpretty-cyro/session-ios-curve-25519-kit.git', branch: 'session' pod 'SignalCoreKit', git: 'https://github.com/oxen-io/session-ios-core-kit', branch: 'session-version' target 'SessionNotificationServiceExtension' diff --git a/Podfile.lock b/Podfile.lock index 372f13c9d..2158f8257 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -123,7 +123,7 @@ PODS: DEPENDENCIES: - AFNetworking - CryptoSwift - - Curve25519Kit (from `https://github.com/signalapp/Curve25519Kit.git`) + - Curve25519Kit (from `https://github.com/mpretty-cyro/session-ios-curve-25519-kit.git`, branch `session`) - Mantle (from `https://github.com/signalapp/Mantle`, branch `signal-master`) - Nimble - NVActivityIndicatorView @@ -156,7 +156,8 @@ SPEC REPOS: EXTERNAL SOURCES: Curve25519Kit: - :git: https://github.com/signalapp/Curve25519Kit.git + :branch: session + :git: https://github.com/mpretty-cyro/session-ios-curve-25519-kit.git Mantle: :branch: signal-master :git: https://github.com/signalapp/Mantle @@ -174,8 +175,8 @@ EXTERNAL SOURCES: CHECKOUT OPTIONS: Curve25519Kit: - :commit: 4fc1c10e98fff2534b5379a9bb587430fdb8e577 - :git: https://github.com/signalapp/Curve25519Kit.git + :commit: a23049232dc6c18928cdacfbcef287dad954c5c6 + :git: https://github.com/mpretty-cyro/session-ios-curve-25519-kit.git Mantle: :commit: e7e46253bb01ce39525d90aa69ed9e85e758bfc4 :git: https://github.com/signalapp/Mantle @@ -213,6 +214,6 @@ SPEC CHECKSUMS: YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331 ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: 42874150fd08761ee6907c5bacf22b95ae849d8c +PODFILE CHECKSUM: b3b9b5446a109dbcdb5381176ebe431f7762558d COCOAPODS: 1.11.2 diff --git a/SessionMessagingKit/Database/Storage+Messaging.swift b/SessionMessagingKit/Database/Storage+Messaging.swift index aabf74b7d..48421aa49 100644 --- a/SessionMessagingKit/Database/Storage+Messaging.swift +++ b/SessionMessagingKit/Database/Storage+Messaging.swift @@ -2,6 +2,34 @@ import PromiseKit import Sodium extension Storage { + + public func getAllMessageRequestThreads() -> [String: TSContactThread] { + var result: [String: TSContactThread] = [:] + + Storage.read { transaction in + result = self.getAllMessageRequestThreads(using: transaction) + } + + return result + } + + public func getAllMessageRequestThreads(using transaction: YapDatabaseReadTransaction) -> [String: TSContactThread] { + var result = [String: TSContactThread]() + + // FIXME: We might be able to optimise this further by filtering the SQL query `WHERE uniqueId LIKE '_c15' + let blindedThreadPrefix: String = TSContactThread.threadID(fromContactSessionID: SessionId.Prefix.blinded.rawValue) + + transaction.enumerateKeysAndObjects( + inCollection: TSContactThread.collection(), + using: { threadID, object, _ in + guard let contactThread = object as? TSContactThread else { return } + result[threadID] = contactThread + }, + withFilter: { key -> Bool in key.starts(with: blindedThreadPrefix) } + ) + + return result + } /// Returns the ID of the thread. public func getOrCreateThread(for publicKey: String, groupPublicKey: String?, openGroupID: String?, using transaction: Any) -> String? { diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index d895780d9..ecd7d9de3 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -115,7 +115,7 @@ public final class OpenGroupAPI: NSObject { // TODO: Limit? // queryParameters: [ .limit: 256 ] ), - responseType: [DirectMessage].self + responseType: [DirectMessage]?.self // 'inboxSince' will return a `304` with an empty response if no messages ) ) @@ -507,30 +507,30 @@ public final class OpenGroupAPI: NSObject { /// method, in order to call this directly remove the `@available` line and make sure to route the response of this method to the /// `OpenGroupManager.handleInbox` method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-build `poll()` method instead") - public static func inbox(on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage])> { + public static func inbox(on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> { let request: Request = Request( server: server, endpoint: .inbox ) return send(request, using: dependencies) - .decoded(as: [DirectMessage].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) + .decoded(as: [DirectMessage]?.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) } - /// Polls for any DMs received since the given id + /// Polls for any DMs received since the given id, this method will return a `304` with an empty response if there are no messages /// /// **Note:** This is the direct request to retrieve messages requests for a specific Open Group since a given messages so should be retrieved /// automatically from the `poll()` method, in order to call this directly remove the `@available` line and make sure to route the response /// of this method to the `OpenGroupManager.handleInbox` method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-build `poll()` method instead") - public static func inboxSince(id: Int64, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage])> { + public static func inboxSince(id: Int64, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> { let request: Request = Request( server: server, endpoint: .inboxSince(id: id) ) return send(request, using: dependencies) - .decoded(as: [DirectMessage].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) + .decoded(as: [DirectMessage]?.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) } /// Delivers a direct message to a user via their blinded Session ID diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 48ed6d831..ab669549b 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -319,6 +319,8 @@ public final class OpenGroupManager: NSObject { isBackgroundPoll: Bool, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies() ) { + // Don't need to do anything if we have no messages (it's a valid case) + guard !messages.isEmpty else { return } guard let serverPublicKey: String = dependencies.storage.getOpenGroupPublicKey(for: server) else { SNLog("Couldn't receive inbox message.") return diff --git a/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift b/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift index 21799c354..7a23b1903 100644 --- a/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift +++ b/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift @@ -15,6 +15,8 @@ public protocol SodiumType { func combineKeys(lhsKeyBytes: Bytes, rhsKeyBytes: Bytes) -> Bytes? func sharedBlindedEncryptionKey(secretKey a: Bytes, otherBlindedPublicKey: Bytes, fromBlindedPublicKey kA: Bytes, toBlindedPublicKey kB: Bytes, genericHash: GenericHashType) -> Bytes? + + func sessionId(_ sessionId: String, matchesBlindedId blindedSessionId: String, serverPublicKey: String) -> Bool } public protocol AeadXChaCha20Poly1305IetfType { diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift index 7aab7ae53..793200253 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift @@ -124,13 +124,13 @@ extension OpenGroupAPI { ) case .inbox, .inboxSince: - guard let responseData: BatchSubResponse<[DirectMessage]> = endpointResponse.data as? BatchSubResponse<[DirectMessage]>, let responseBody: [DirectMessage] = responseData.body else { + guard let responseData: BatchSubResponse<[DirectMessage]?> = endpointResponse.data as? BatchSubResponse<[DirectMessage]?>, let responseBody: [DirectMessage]? = responseData.body else { SNLog("Open group polling failed due to invalid data.") return } OpenGroupManager.handleInbox( - responseBody, + (responseBody ?? []), on: server, isBackgroundPoll: isBackgroundPoll ) diff --git a/SessionMessagingKit/Storage.swift b/SessionMessagingKit/Storage.swift index 414015fcd..7a86cbb23 100644 --- a/SessionMessagingKit/Storage.swift +++ b/SessionMessagingKit/Storage.swift @@ -80,6 +80,9 @@ public protocol SessionMessagingKitStorageProtocol { // MARK: - Message Handling + func getAllMessageRequestThreads() -> [String: TSContactThread] + func getAllMessageRequestThreads(using transaction: YapDatabaseReadTransaction) -> [String: TSContactThread] + func getReceivedMessageTimestamps(using transaction: Any) -> [UInt64] func addReceivedMessageTimestamp(_ timestamp: UInt64, using transaction: Any) /// Returns the ID of the thread. diff --git a/SessionMessagingKit/Utilities/Sodium+Utilities.swift b/SessionMessagingKit/Utilities/Sodium+Utilities.swift index 43afd803b..0409c6623 100644 --- a/SessionMessagingKit/Utilities/Sodium+Utilities.swift +++ b/SessionMessagingKit/Utilities/Sodium+Utilities.swift @@ -1,5 +1,6 @@ import Clibsodium import Sodium +import Curve25519Kit extension Sign { @@ -232,6 +233,33 @@ extension Sodium { return genericHash.hash(message: (combinedKeyBytes + kA + kB), outputLength: 32) } + + /// This method should be used to check if a users standard sessionId matches a blinded one + public func sessionId(_ standardSessionId: String, matchesBlindedId blindedSessionId: String, serverPublicKey: String) -> Bool { + // Only support generating blinded keys for standard session ids + guard let sessionId: SessionId = SessionId(from: standardSessionId), sessionId.prefix == .standard else { return false } + guard let blindedId: SessionId = SessionId(from: blindedSessionId), blindedId.prefix == .blinded else { return false } + guard let kBytes: Bytes = generateBlindingFactor(serverPublicKey: serverPublicKey) else { return false } + + /// From the session id (ignoring 05 prefix) we have two possible ed25519 pubkeys; the first is the positive (which is what + /// Signal's XEd25519 conversion always uses) + /// + /// Note: The below method is code we have exposed from the `curve25519_verify` method within the Curve25519 library + /// rather than custom code we have written + guard let xEd25519Key: Data = try? Ed25519.publicKey(from: Data(hex: sessionId.publicKey)) else { return false } + + /// Blind the positive public key + guard let pk1: Bytes = combineKeys(lhsKeyBytes: kBytes, rhsKeyBytes: xEd25519Key.bytes) else { return false } + + /// For the negative, what we're going to get out of the above is simply the negative of pk1, so flip the sign bit to get pk2 + /// pk2 = pk1[0:31] + bytes([pk1[31] ^ 0b1000_0000]) + let pk2: Bytes = (pk1[0..<31] + [(pk1[31] ^ 0b1000_0000)]) + + return ( + SessionId(.blinded, publicKey: pk1).publicKey == blindedId.publicKey || + SessionId(.blinded, publicKey: pk2).publicKey == blindedId.publicKey + ) + } } extension GenericHash { diff --git a/SessionSnodeKit/OnionRequestAPI+Encryption.swift b/SessionSnodeKit/OnionRequestAPI+Encryption.swift index deec2a30c..bcbb58a67 100644 --- a/SessionSnodeKit/OnionRequestAPI+Encryption.swift +++ b/SessionSnodeKit/OnionRequestAPI+Encryption.swift @@ -14,12 +14,24 @@ internal extension OnionRequestAPI { } /// Encrypts `payload` for `destination` and returns the result. Use this to build the core of an onion request. - static func encrypt(_ payload: String, for destination: Destination) -> Promise { + static func encrypt(_ payload: String, for destination: Destination, with version: Version) -> Promise { let (promise, seal) = Promise.pending() DispatchQueue.global(qos: .userInitiated).async { do { - guard let data = payload.data(using: .utf8) else { - throw Error.invalidRequestInfo + guard let payloadAsData: Data = payload.data(using: .utf8) else { throw Error.invalidRequestInfo } + + let data: Data + + switch version { + case .v2, .v3: + // Wrapping is only needed for snode requests + switch destination { + case .snode: data = try encode(ciphertext: payloadAsData, json: [ "headers" : "" ]) + case .server: data = payloadAsData + } + + case .v4: + data = payloadAsData } let result = try encrypt(data, for: destination) @@ -33,34 +45,6 @@ internal extension OnionRequestAPI { return promise } - static func encrypt(_ payload: JSON, for destination: Destination) -> Promise { - let (promise, seal) = Promise.pending() - DispatchQueue.global(qos: .userInitiated).async { - do { - guard JSONSerialization.isValidJSONObject(payload) else { return seal.reject(HTTP.Error.invalidJSON) } - - // Wrapping isn't needed for file server or open group onion requests - switch destination { - case .snode: - let payloadAsData = try JSONSerialization.data(withJSONObject: payload, options: [ .fragmentsAllowed ]) - let data = try encode(ciphertext: payloadAsData, json: [ "headers" : "" ]) - let result = try encrypt(data, for: destination) - seal.fulfill(result) - - case .server: - let data = try JSONSerialization.data(withJSONObject: payload, options: [ .fragmentsAllowed ]) - let result = try encrypt(data, for: destination) - seal.fulfill(result) - } - } - catch (let error) { - seal.reject(error) - } - } - - return promise - } - private static func encrypt(_ payload: Data, for destination: Destination) throws -> AESGCM.EncryptionResult { switch destination { case .snode(let snode): diff --git a/SessionSnodeKit/OnionRequestAPI.swift b/SessionSnodeKit/OnionRequestAPI.swift index 016a373ca..2d7daa083 100644 --- a/SessionSnodeKit/OnionRequestAPI.swift +++ b/SessionSnodeKit/OnionRequestAPI.swift @@ -245,7 +245,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { } /// Builds an onion around `payload` and returns the result. - private static func buildOnion(around payload: String, targetedAt destination: Destination) -> Promise { + private static func buildOnion(around payload: String, targetedAt destination: Destination, version: Version) -> Promise { var guardSnode: Snode! var targetSnodeSymmetricKey: Data! // Needed by invoke(_:on:with:) to decrypt the response sent back by the destination var encryptionResult: AESGCM.EncryptionResult! @@ -254,7 +254,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { return getPath(excluding: snodeToExclude).then2 { path -> Promise in guardSnode = path.first! // Encrypt in reverse order, i.e. the destination first - return encrypt(payload, for: destination).then2 { r -> Promise in + return encrypt(payload, for: destination, with: version).then2 { r -> Promise in targetSnodeSymmetricKey = r.symmetricKey // Recursively encrypt the layers of the onion (again in reverse order) encryptionResult = r @@ -328,7 +328,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { let (promise, seal) = Promise<(OnionRequestResponseInfoType, Data?)>.pending() var guardSnode: Snode? Threading.workQueue.async { // Avoid race conditions on `guardSnodes` and `paths` - buildOnion(around: payload, targetedAt: destination).done2 { intermediate in + buildOnion(around: payload, targetedAt: destination, version: version).done2 { intermediate in guardSnode = intermediate.guardSnode let url = "\(guardSnode!.address):\(guardSnode!.port)/onion_req/v2" let finalEncryptionResult = intermediate.finalEncryptionResult diff --git a/SessionSnodeKit/SnodeAPI.swift b/SessionSnodeKit/SnodeAPI.swift index aec599f1a..becc23930 100644 --- a/SessionSnodeKit/SnodeAPI.swift +++ b/SessionSnodeKit/SnodeAPI.swift @@ -131,8 +131,15 @@ public final class SnodeAPI : NSObject { // MARK: Internal API internal static func invoke(_ method: Snode.Method, on snode: Snode, associatedWith publicKey: String? = nil, parameters: JSON) -> RawResponsePromise { if Features.useOnionRequests { - // TODO: Ensure this should use the v3 request? - return OnionRequestAPI.sendOnionRequest(to: snode, invoking: method, with: parameters, using: .v3, associatedWith: publicKey).map2 { $0 as Any } + return OnionRequestAPI.sendOnionRequest(to: snode, invoking: method, with: parameters, using: .v3, associatedWith: publicKey) + .map2 { responseData in + guard let responseJson: JSON = try? JSONSerialization.jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? JSON else { + throw Error.generic + } + + // FIXME: Would be nice to change this to not send 'Any' + return responseJson as Any + } } else { let url = "\(snode.address):\(snode.port)/storage_rpc/v1" return HTTP.execute(.post, url, parameters: parameters).map2 { $0 as Any }.recover2 { error -> Promise in From a26ee12f8d57c3c535496f45cb83bdea3e377cc9 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 1 Mar 2022 14:06:37 +1100 Subject: [PATCH 018/157] Further work on Id Blinding Renamed the setter for the SOGS 'Server' object for consistency Updated the Curve25519Kit repo to use an Oxen fork Updated the MockDataGenerator to accomodate the latest changes Updated the ConversationVC to better support getting replaced when the conversion from blinded to unblinded happens while on that screen Added a cache for the mapping between blinded ids and standard ids (gets cached whenever a valid match is found) Added a migration to remove the old 'authToken, 'lastMessageServerId' and 'lastDeletionServerId' collections (redundant in SOGS V4) --- Podfile | 3 +- Podfile.lock | 12 +- Session.xcodeproj/project.pbxproj | 8 ++ .../ConversationVC+Interaction.swift | 78 +++++++++++ Session/Conversations/ConversationVC.swift | 106 +++++++++++++- .../Conversations/Input View/InputView.swift | 11 ++ .../Message Cells/MessageCell.swift | 2 +- Session/Utilities/ContactUtilities.swift | 42 ++++-- Session/Utilities/MockDataGenerator.swift | 35 ++++- .../Contacts/BlindedIdMapping.swift | 40 ++++++ .../Database/Storage+Contacts.swift | 40 ++++++ .../Database/Storage+Messaging.swift | 59 ++++---- .../Database/Storage+OpenGroups.swift | 79 +++-------- .../Messages/Signal/TSInteraction.h | 4 + .../Messages/Signal/TSInteraction.m | 6 + .../Open Groups/Models/Capabilities.swift | 7 + .../Open Groups/OpenGroupManager.swift | 3 +- .../MessageReceiver+Handling.swift | 132 ++++++++++++++++-- SessionMessagingKit/Storage.swift | 2 +- .../Threads/Notification+Thread.swift | 6 + SessionMessagingKit/Threads/TSContactThread.h | 11 ++ SessionMessagingKit/Threads/TSContactThread.m | 29 ++++ .../_TestUtilities/TestStorage.swift | 2 +- .../Database/Migrations/SOGSV4Migration.swift | 33 +++++ 24 files changed, 618 insertions(+), 132 deletions(-) create mode 100644 SessionMessagingKit/Contacts/BlindedIdMapping.swift create mode 100644 SignalUtilitiesKit/Database/Migrations/SOGSV4Migration.swift diff --git a/Podfile b/Podfile index a6b96cb29..c903f9d7d 100644 --- a/Podfile +++ b/Podfile @@ -24,8 +24,7 @@ abstract_target 'GlobalDependencies' do # Dependencies to be included only in all extensions/frameworks abstract_target 'FrameworkAndExtensionDependencies' do - # TODO: Swap this to use an oxen-io fork - pod 'Curve25519Kit', git: 'https://github.com/mpretty-cyro/session-ios-curve-25519-kit.git', branch: 'session' + pod 'Curve25519Kit', git: 'https://github.com/oxen-io/session-ios-curve-25519-kit.git', branch: 'session-version' pod 'SignalCoreKit', git: 'https://github.com/oxen-io/session-ios-core-kit', branch: 'session-version' target 'SessionNotificationServiceExtension' diff --git a/Podfile.lock b/Podfile.lock index 17abfa0e7..f504ecaa4 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -123,7 +123,7 @@ PODS: DEPENDENCIES: - AFNetworking - CryptoSwift - - Curve25519Kit (from `https://github.com/mpretty-cyro/session-ios-curve-25519-kit.git`, branch `session`) + - Curve25519Kit (from `https://github.com/oxen-io/session-ios-curve-25519-kit.git`, branch `session-version`) - Mantle (from `https://github.com/signalapp/Mantle`, branch `signal-master`) - Nimble - NVActivityIndicatorView @@ -156,8 +156,8 @@ SPEC REPOS: EXTERNAL SOURCES: Curve25519Kit: - :branch: session - :git: https://github.com/mpretty-cyro/session-ios-curve-25519-kit.git + :branch: session-version + :git: https://github.com/oxen-io/session-ios-curve-25519-kit.git Mantle: :branch: signal-master :git: https://github.com/signalapp/Mantle @@ -175,8 +175,8 @@ EXTERNAL SOURCES: CHECKOUT OPTIONS: Curve25519Kit: - :commit: a23049232dc6c18928cdacfbcef287dad954c5c6 - :git: https://github.com/mpretty-cyro/session-ios-curve-25519-kit.git + :commit: b79c2ace600bfd3784e9c33cf1f254b121312edc + :git: https://github.com/oxen-io/session-ios-curve-25519-kit.git Mantle: :commit: e7e46253bb01ce39525d90aa69ed9e85e758bfc4 :git: https://github.com/signalapp/Mantle @@ -214,6 +214,6 @@ SPEC CHECKSUMS: YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331 ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: 918ef11baf24eac2df681cd6a3781f536f9d384a +PODFILE CHECKSUM: 2cc64d50f25c3b1627c3e958ae50e25fead25564 COCOAPODS: 1.11.2 diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index fa898d92a..64c422af3 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -771,6 +771,8 @@ F5765D284BC6ECAC0C1D33F0 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionNotificationServiceExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E4A93ECA93B3DE800CC7D7F6 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionNotificationServiceExtension.framework */; }; FC3BD9881A30A790005B96BB /* Social.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FC3BD9871A30A790005B96BB /* Social.framework */; }; FCB11D8C1A129A76002F93FB /* CoreMedia.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FCB11D8B1A129A76002F93FB /* CoreMedia.framework */; }; + FD0BA51B27CD88EC00CC6805 /* BlindedIdMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0BA51A27CD88EC00CC6805 /* BlindedIdMapping.swift */; }; + FD0BA51D27CDC34600CC6805 /* SOGSV4Migration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0BA51C27CDC34600CC6805 /* SOGSV4Migration.swift */; }; FD5D200F27AA2B6000FEA984 /* MessageRequestResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */; }; FD5D201127AA331F00FEA984 /* ConfigurationMessage+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D201027AA331F00FEA984 /* ConfigurationMessage+Convenience.swift */; }; FD5D201E27B0D87C00FEA984 /* SessionId.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D201D27B0D87C00FEA984 /* SessionId.swift */; }; @@ -1906,6 +1908,8 @@ F9BBF530D71905BA9007675F /* Pods-SessionShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionShareExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SessionShareExtension/Pods-SessionShareExtension.debug.xcconfig"; sourceTree = ""; }; FC3BD9871A30A790005B96BB /* Social.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Social.framework; path = System/Library/Frameworks/Social.framework; sourceTree = SDKROOT; }; FCB11D8B1A129A76002F93FB /* CoreMedia.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreMedia.framework; path = System/Library/Frameworks/CoreMedia.framework; sourceTree = SDKROOT; }; + FD0BA51A27CD88EC00CC6805 /* BlindedIdMapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlindedIdMapping.swift; sourceTree = ""; }; + FD0BA51C27CDC34600CC6805 /* SOGSV4Migration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSV4Migration.swift; sourceTree = ""; }; FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestResponse.swift; sourceTree = ""; }; FD5D201027AA331F00FEA984 /* ConfigurationMessage+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConfigurationMessage+Convenience.swift"; sourceTree = ""; }; FD5D201D27B0D87C00FEA984 /* SessionId.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionId.swift; sourceTree = ""; }; @@ -2564,6 +2568,7 @@ isa = PBXGroup; children = ( B8B32020258B1A650020074B /* Contact.swift */, + FD0BA51A27CD88EC00CC6805 /* BlindedIdMapping.swift */, ); path = Contacts; sourceTree = ""; @@ -3237,6 +3242,7 @@ children = ( B8B32044258C117C0020074B /* ContactsMigration.swift */, FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */, + FD0BA51C27CDC34600CC6805 /* SOGSV4Migration.swift */, C38EF271255B6D79007E1867 /* OWSDatabaseMigration.h */, C38EF270255B6D79007E1867 /* OWSDatabaseMigration.m */, C38EF26F255B6D79007E1867 /* OWSDatabaseMigrationRunner.h */, @@ -4988,6 +4994,7 @@ C33FDC98255A582000E217F9 /* SwiftSingletons.swift in Sources */, C33FDC27255A581F00E217F9 /* YapDatabase+Promise.swift in Sources */, C33FDCD3255A582000E217F9 /* GroupUtilities.swift in Sources */, + FD0BA51D27CDC34600CC6805 /* SOGSV4Migration.swift in Sources */, FD88BADB27A750F200BBC442 /* MessageRequestsMigration.swift in Sources */, C38EF326255B6DBF007E1867 /* ConversationStyle.swift in Sources */, C38EF3B8255B6DE7007E1867 /* ImageEditorTextViewController.swift in Sources */, @@ -5117,6 +5124,7 @@ C3C2A74D2553A39700C340D1 /* VisibleMessage.swift in Sources */, C32C5AAD256DBE8F003C73A2 /* TSInfoMessage.m in Sources */, FD5D201127AA331F00FEA984 /* ConfigurationMessage+Convenience.swift in Sources */, + FD0BA51B27CD88EC00CC6805 /* BlindedIdMapping.swift in Sources */, FDC4386927B4E6B800C60D73 /* String+Utlities.swift in Sources */, C32C5A13256DB7A5003C73A2 /* PushNotificationAPI.swift in Sources */, FDC4385F27B4C4A200C60D73 /* PinnedMessage.swift in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index fbd7696a0..3aa4e499d 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -2,6 +2,7 @@ import UIKit import CoreServices import Photos import PhotosUI +import Sodium import SessionUtilitiesKit import SignalUtilitiesKit @@ -813,6 +814,83 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc userDetailsSheet.modalTransitionStyle = .crossDissolve present(userDetailsSheet, animated: true, completion: nil) } + + func startThread(with sessionId: String, openGroupServer: String, openGroupPublicKey: String) { + // If the sessionId is blinded then check if there is an existing un-blinded thread with the contact + if SessionId.Prefix(from: sessionId) == .blinded { + // TODO: Ensure the above case isn't going to be an issue due to legacy messages? + // Unfortunately the whole point of id-blinding is to make it hard to reverse-engineer a standard + // sessionId, as a result in order to see if there is an unblinded contact for this blindedId we + // can only really generate blinded ids for each contact and check if any match + // + // Due to this we have made a few optimisations to try and early-out as often as possible, first + // we try to retrieve a direct cached mapping + if let mapping: BlindedIdMapping = Storage.shared.getBlindedIdMapping(with: sessionId) { + let thread: TSContactThread = TSContactThread.getOrCreateThread(contactSessionID: mapping.sessionId) + let conversationVC: ConversationVC = ConversationVC(thread: thread) + + self.navigationController?.pushViewController(conversationVC, animated: true) + return + } + + var didFindContact: Bool = false + + // Then we try loop through all approved contact threads to see if one of those contacts can be blinded to match + ContactUtilities.enumerateApprovedContactThreads { contactThread, contact, stop in + guard Sodium().sessionId(contact.sessionID, matchesBlindedId: sessionId, serverPublicKey: openGroupPublicKey) else { + return + } + + // Cache the mapping + let mapping: BlindedIdMapping = BlindedIdMapping(blindedId: sessionId, sessionId: contact.sessionID, serverPublicKey: openGroupPublicKey) + Storage.shared.cacheBlindedIdMapping(mapping) + + // Open the existing thread + let conversationVC: ConversationVC = ConversationVC(thread: contactThread) + self.navigationController?.pushViewController(conversationVC, animated: true) + + didFindContact = true + stop.pointee = true + } + + // Don't continue if we found the contact + guard !didFindContact else { return } + + // Lastly loop through existing id mappings (in case the user is looking at a different SOGS but once had + // a thread with this contact in a different SOGS and had cached the mapping) + Storage.shared.enumerateBlindedIdMapping { mapping, stop in + guard mapping.serverPublicKey != openGroupPublicKey else { return } + guard Sodium().sessionId(mapping.sessionId, matchesBlindedId: sessionId, serverPublicKey: openGroupPublicKey) else { + return + } + + // Cache the new mapping + let thread: TSContactThread = TSContactThread.getOrCreateThread(contactSessionID: mapping.sessionId) + let newMapping: BlindedIdMapping = BlindedIdMapping(blindedId: sessionId, sessionId: mapping.sessionId, serverPublicKey: openGroupPublicKey) + Storage.shared.cacheBlindedIdMapping(newMapping) + + // Open the existing thread + let conversationVC: ConversationVC = ConversationVC(thread: thread) + self.navigationController?.pushViewController(conversationVC, animated: true) + + didFindContact = true + stop.pointee = true + } + + // Don't continue if we found the contact + guard !didFindContact else { return } + } + + // Just create a new thread with the provided sessionId + let thread = TSContactThread.getOrCreateThread( + contactSessionID: sessionId, + openGroupServer: openGroupServer, + openGroupPublicKey: openGroupPublicKey + ) + let conversationVC: ConversationVC = ConversationVC(thread: thread) + + self.navigationController?.pushViewController(conversationVC, animated: true) + } // MARK: Voice Message Playback @objc func handleAudioDidFinishPlayingNotification(_ notification: Notification) { diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 0e2159de4..d8d8dc079 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -1,3 +1,4 @@ +import UIKit import SessionUIKit import SessionMessagingKit @@ -13,9 +14,8 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat let focusedMessageID: String? // This is used for global search var focusedMessageIndexPath: IndexPath? var unreadViewItems: [ConversationViewItem] = [] - var scrollButtonBottomConstraint: NSLayoutConstraint? - var scrollButtonMessageRequestsBottomConstraint: NSLayoutConstraint? - var messageRequestsViewBotomConstraint: NSLayoutConstraint? + var isReplacingThread: Bool = false + // Search var isShowingSearchUI = false var lastSearchedText: String? @@ -40,7 +40,11 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat var audioSession: OWSAudioSession { Environment.shared.audioSession } var dbConnection: YapDatabaseConnection { OWSPrimaryStorage.shared().uiDatabaseConnection } var viewItems: [ConversationViewItem] { viewModel.viewState.viewItems } - override var canBecomeFirstResponder: Bool { true } + + override var canBecomeFirstResponder: Bool { + // Need to return false during the swap between threads to prevent keyboard dismissal + !isReplacingThread + } override var inputAccessoryView: UIView? { if let thread = thread as? TSGroupThread, thread.groupModel.groupType == .closedGroup && !thread.isCurrentUserMemberInGroup() { @@ -102,6 +106,10 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat private static let messageRequestButtonHeight: CGFloat = 34 + var scrollButtonBottomConstraint: NSLayoutConstraint? + var scrollButtonMessageRequestsBottomConstraint: NSLayoutConstraint? + var messageRequestsViewBotomConstraint: NSLayoutConstraint? + lazy var titleView: ConversationTitleView = { let result = ConversationTitleView(thread: thread) result.delegate = self @@ -363,6 +371,7 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat notificationCenter.addObserver(self, selector: #selector(handleGroupUpdatedNotification), name: .groupThreadUpdated, object: nil) notificationCenter.addObserver(self, selector: #selector(sendScreenshotNotificationIfNeeded), name: UIApplication.userDidTakeScreenshotNotification, object: nil) notificationCenter.addObserver(self, selector: #selector(handleMessageSentStatusChanged), name: .messageSentStatusDidChange, object: nil) + notificationCenter.addObserver(self, selector: #selector(handleContactThreadReplaced(_:)), name: .contactThreadReplaced, object: nil) // Mentions MentionsManager.populateUserPublicKeyCacheIfNeeded(for: thread.uniqueId!) // Draft @@ -428,6 +437,11 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) + + // Don't set the draft or resign the first responder if we are replacing the thread (want the keyboard + // to appear to remain focussed) + guard !isReplacingThread else { return } + let text = snInputView.text Storage.write { transaction in self.thread.setDraft(text, transaction: transaction) @@ -693,6 +707,90 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat } } + @objc private func handleContactThreadReplaced(_ notification: Notification) { + // Ensure the current thread is one of the removed ones + guard let newThreadId: String = notification.userInfo?[NotificationUserInfoKey.threadId] as? String else { return } + guard let removedThreadIds: [String] = notification.userInfo?[NotificationUserInfoKey.removedThreadIds] as? [String] else { + return + } + guard let threadId: String = thread.uniqueId, removedThreadIds.contains(threadId) else { return } + + // Then look to swap the current ConversationVC with a replacement one with the new thread + DispatchQueue.main.async { + guard let navController: UINavigationController = self.navigationController else { return } + guard let viewControllerIndex: Int = navController.viewControllers.firstIndex(of: self) else { return } + guard let newThread: TSContactThread = TSContactThread.fetch(uniqueId: newThreadId) else { return } + + // Let the view controller know we are replacing the thread + self.isReplacingThread = true + + // Create the new ConversationVC and swap the old one out for it + let conversationVC: ConversationVC = ConversationVC(thread: newThread) + let currentlyOnThisScreen: Bool = (navController.topViewController == self) + + navController.viewControllers = [ + (viewControllerIndex == 0 ? + [] : + navController.viewControllers[0.. Bool { inputTextView.resignFirstResponder() } + + func inputTextViewBecomeFirstResponder() { + inputTextView.becomeFirstResponder() + } func handleLongPress() { // Not relevant in this case diff --git a/Session/Conversations/Message Cells/MessageCell.swift b/Session/Conversations/Message Cells/MessageCell.swift index 2c932517b..347c36668 100644 --- a/Session/Conversations/Message Cells/MessageCell.swift +++ b/Session/Conversations/Message Cells/MessageCell.swift @@ -66,5 +66,5 @@ protocol MessageCellDelegate : AnyObject { func openURL(_ url: URL) func handleReplyButtonTapped(for viewItem: ConversationViewItem) func showUserDetails(for sessionID: String) - func startThread(with sessionID: String, openGroupServer: String, openGroupPublicKey: String) + func startThread(with sessionId: String, openGroupServer: String, openGroupPublicKey: String) } diff --git a/Session/Utilities/ContactUtilities.swift b/Session/Utilities/ContactUtilities.swift index 2ecaa2641..f48fa32e2 100644 --- a/Session/Utilities/ContactUtilities.swift +++ b/Session/Utilities/ContactUtilities.swift @@ -1,23 +1,24 @@ enum ContactUtilities { + private static func approvedContact(in threadObject: Any, using transaction: Any) -> Contact? { + guard let thread: TSContactThread = threadObject as? TSContactThread else { return nil } + guard thread.shouldBeVisible else { return nil } + guard let contact: Contact = Storage.shared.getContact(with: thread.contactSessionID(), using: transaction) else { + return nil + } + guard contact.didApproveMe else { return nil } + + return contact + } static func getAllContacts() -> [String] { // Collect all contacts - var result: [String] = [] + var result: [Contact] = [] Storage.read { transaction in TSContactThread.enumerateCollectionObjects(with: transaction) { object, _ in - guard - let thread: TSContactThread = object as? TSContactThread, - thread.shouldBeVisible, - Storage.shared.getContact( - with: thread.contactSessionID(), - using: transaction - )?.didApproveMe == true - else { - return - } + guard let contact: Contact = approvedContact(in: object, using: transaction) else { return } - result.append(thread.contactSessionID()) + result.append(contact) } } func getDisplayName(for publicKey: String) -> String { @@ -25,11 +26,24 @@ enum ContactUtilities { } // Remove the current user - if let index = result.firstIndex(of: getUserHexEncodedPublicKey()) { + if let index = result.firstIndex(where: { $0.sessionID == getUserHexEncodedPublicKey() }) { result.remove(at: index) } // Sort alphabetically - return result.sorted { getDisplayName(for: $0) < getDisplayName(for: $1) } + return result + .map { contact -> String in (contact.displayName(for: .regular) ?? contact.sessionID) } + .sorted() + } + + static func enumerateApprovedContactThreads(with block: @escaping (TSContactThread, Contact, UnsafeMutablePointer) -> ()) { + Storage.read { transaction in + TSContactThread.enumerateCollectionObjects(with: transaction) { object, stop in + guard let contactThread: TSContactThread = object as? TSContactThread else { return } + guard let contact: Contact = approvedContact(in: object, using: transaction) else { return } + + block(contactThread, contact, stop) + } + } } } diff --git a/Session/Utilities/MockDataGenerator.swift b/Session/Utilities/MockDataGenerator.swift index 5a559b6df..dc5e2fcab 100644 --- a/Session/Utilities/MockDataGenerator.swift +++ b/Session/Utilities/MockDataGenerator.swift @@ -189,7 +189,8 @@ enum MockDataGenerator { image: nil, groupId: groupId, groupType: .closedGroup, - adminIds: [members.randomElement(using: &cgThreadRandomGenerator) ?? userSessionId] + adminIds: [members.randomElement(using: &cgThreadRandomGenerator) ?? userSessionId], + moderatorIds: [members.randomElement(using: &cgThreadRandomGenerator) ?? userSessionId] ) let thread = TSGroupThread.getOrCreateThread(with: group, transaction: transaction) thread.shouldBeVisible = true @@ -232,23 +233,49 @@ enum MockDataGenerator { let randomGroupPublicKey: String = KeyPairUtilities.generate(from: data).x25519KeyPair.hexEncodedPublicKey let serverNameLength: Int = ((5..<20).randomElement(using: &ogThreadRandomGenerator) ?? 0) let roomNameLength: Int = ((5..<20).randomElement(using: &ogThreadRandomGenerator) ?? 0) + let groupDescriptionLength: Int = ((10..<50).randomElement(using: &ogThreadRandomGenerator) ?? 0) let serverName: String = (0.. BlindedIdMapping? { + var result: BlindedIdMapping? + Storage.read { transaction in + result = self.getBlindedIdMapping(with: blindedId, using: transaction) + } + return result + } + + public func getBlindedIdMapping(with blindedId: String, using transaction: YapDatabaseReadTransaction) -> BlindedIdMapping? { + return transaction.object(forKey: blindedId, inCollection: Storage.blindedIdCacheCollection) as? BlindedIdMapping + } + + public func cacheBlindedIdMapping(_ mapping: BlindedIdMapping) { + Storage.write { transaction in + self.cacheBlindedIdMapping(mapping, using: transaction) + } + } + + public func cacheBlindedIdMapping(_ mapping: BlindedIdMapping, using transaction: YapDatabaseReadWriteTransaction) { + transaction.setObject(mapping, forKey: mapping.blindedId, inCollection: Storage.blindedIdCacheCollection) + } + + public func enumerateBlindedIdMapping(with block: @escaping (BlindedIdMapping, UnsafeMutablePointer) -> ()) { + Storage.read { transaction in + self.enumerateBlindedIdMapping(with: block, transaction: transaction) + } + } + + public func enumerateBlindedIdMapping(with block: @escaping (BlindedIdMapping, UnsafeMutablePointer) -> (), transaction: YapDatabaseReadTransaction) { + transaction.enumerateRows(inCollection: Storage.blindedIdCacheCollection) { _, object, _, stop in + guard let mapping = object as? BlindedIdMapping else { return } + + block(mapping, stop) + } + } } diff --git a/SessionMessagingKit/Database/Storage+Messaging.swift b/SessionMessagingKit/Database/Storage+Messaging.swift index 48421aa49..482bdc39a 100644 --- a/SessionMessagingKit/Database/Storage+Messaging.swift +++ b/SessionMessagingKit/Database/Storage+Messaging.swift @@ -2,35 +2,6 @@ import PromiseKit import Sodium extension Storage { - - public func getAllMessageRequestThreads() -> [String: TSContactThread] { - var result: [String: TSContactThread] = [:] - - Storage.read { transaction in - result = self.getAllMessageRequestThreads(using: transaction) - } - - return result - } - - public func getAllMessageRequestThreads(using transaction: YapDatabaseReadTransaction) -> [String: TSContactThread] { - var result = [String: TSContactThread]() - - // FIXME: We might be able to optimise this further by filtering the SQL query `WHERE uniqueId LIKE '_c15' - let blindedThreadPrefix: String = TSContactThread.threadID(fromContactSessionID: SessionId.Prefix.blinded.rawValue) - - transaction.enumerateKeysAndObjects( - inCollection: TSContactThread.collection(), - using: { threadID, object, _ in - guard let contactThread = object as? TSContactThread else { return } - result[threadID] = contactThread - }, - withFilter: { key -> Bool in key.starts(with: blindedThreadPrefix) } - ) - - return result - } - /// Returns the ID of the thread. public func getOrCreateThread(for publicKey: String, groupPublicKey: String?, openGroupID: String?, using transaction: Any) -> String? { let transaction = transaction as! YapDatabaseReadWriteTransaction @@ -180,5 +151,35 @@ extension Storage { let transaction = transaction as! YapDatabaseReadWriteTransaction transaction.setObject(receivedMessageTimestamps, forKey: "receivedMessageTimestamps", inCollection: Storage.receivedMessageTimestampsCollection) } + + // MARK: - Message Request Handling + + public func getAllMessageRequestThreads() -> [String: TSContactThread] { + var result: [String: TSContactThread] = [:] + + Storage.read { transaction in + result = self.getAllMessageRequestThreads(using: transaction) + } + + return result + } + + public func getAllMessageRequestThreads(using transaction: YapDatabaseReadTransaction) -> [String: TSContactThread] { + var result = [String: TSContactThread]() + + // FIXME: We might be able to optimise this further by filtering the SQL query `WHERE uniqueId LIKE '_c15' + let blindedThreadPrefix: String = TSContactThread.threadID(fromContactSessionID: SessionId.Prefix.blinded.rawValue) + + transaction.enumerateKeysAndObjects( + inCollection: TSContactThread.collection(), + using: { threadID, object, _ in + guard let contactThread = object as? TSContactThread else { return } + result[threadID] = contactThread + }, + withFilter: { key -> Bool in key.starts(with: blindedThreadPrefix) } + ) + + return result + } } diff --git a/SessionMessagingKit/Database/Storage+OpenGroups.swift b/SessionMessagingKit/Database/Storage+OpenGroups.swift index 07c8228a0..5031bfe69 100644 --- a/SessionMessagingKit/Database/Storage+OpenGroups.swift +++ b/SessionMessagingKit/Database/Storage+OpenGroups.swift @@ -55,35 +55,9 @@ extension Storage { return result } - public func storeOpenGroupServer(_ server: OpenGroupAPI.Server, using transaction: Any) { + public func setOpenGroupServer(_ server: OpenGroupAPI.Server, using transaction: Any) { (transaction as! YapDatabaseReadWriteTransaction).setObject(server, forKey: "SOGS.\(server.name)", inCollection: Storage.openGroupCollection) } - - // MARK: - Authorization - - private static let authTokenCollection = "SNAuthTokenCollection" - - public func getAuthToken(for room: String, on server: String) -> String? { - let collection = Storage.authTokenCollection - let key = "\(server).\(room)" - var result: String? = nil - Storage.read { transaction in - result = transaction.object(forKey: key, inCollection: collection) as? String - } - return result - } - - public func setAuthToken(for room: String, on server: String, to newValue: String, using transaction: Any) { - let collection = Storage.authTokenCollection - let key = "\(server).\(room)" - (transaction as! YapDatabaseReadWriteTransaction).setObject(newValue, forKey: key, inCollection: collection) - } - - public func removeAuthToken(for room: String, on server: String, using transaction: Any) { - let collection = Storage.authTokenCollection - let key = "\(server).\(room)" - (transaction as! YapDatabaseReadWriteTransaction).removeObject(forKey: key, inCollection: collection) - } @@ -109,12 +83,12 @@ extension Storage { - // MARK: - Last Message Server ID + // MARK: - Open Group Sequence Number - public static let lastMessageServerIDCollection = "SNLastMessageServerIDCollection" + public static let openGroupSequenceNumberCollection = "SNOpenGroupSequenceNumberCollection" - public func getLastMessageServerID(for room: String, on server: String) -> Int64? { - let collection = Storage.lastMessageServerIDCollection + public func getOpenGroupSequenceNumber(for room: String, on server: String) -> Int64? { + let collection = Storage.openGroupSequenceNumberCollection let key = "\(server).\(room)" var result: Int64? = nil Storage.read { transaction in @@ -123,48 +97,41 @@ extension Storage { return result } - public func setLastMessageServerID(for room: String, on server: String, to newValue: Int64, using transaction: Any) { - let collection = Storage.lastMessageServerIDCollection + public func setOpenGroupSequenceNumber(for room: String, on server: String, to newValue: Int64, using transaction: Any) { + let collection = Storage.openGroupSequenceNumberCollection let key = "\(server).\(room)" (transaction as! YapDatabaseReadWriteTransaction).setObject(newValue, forKey: key, inCollection: collection) } - public func removeLastMessageServerID(for room: String, on server: String, using transaction: Any) { - let collection = Storage.lastMessageServerIDCollection + public func removeOpenGroupSequenceNumber(for room: String, on server: String, using transaction: Any) { + let collection = Storage.openGroupSequenceNumberCollection let key = "\(server).\(room)" (transaction as! YapDatabaseReadWriteTransaction).removeObject(forKey: key, inCollection: collection) } + // MARK: - -- Open Group Inbox Latest Message Id + + public static let openGroupInboxLatestMessageIdCollection = "SNOpenGroupInboxLatestMessageIdCollection" - - // MARK: - Last Deletion Server ID - - public static let lastDeletionServerIDCollection = "SNLastDeletionServerIDCollection" - - public func getLastDeletionServerID(for room: String, on server: String) -> Int64? { - let collection = Storage.lastDeletionServerIDCollection - let key = "\(server).\(room)" + public func getOpenGroupInboxLatestMessageId(for server: String) -> Int64? { + let collection = Storage.openGroupInboxLatestMessageIdCollection var result: Int64? = nil Storage.read { transaction in - result = transaction.object(forKey: key, inCollection: collection) as? Int64 + result = transaction.object(forKey: server, inCollection: collection) as? Int64 } return result } - - public func setLastDeletionServerID(for room: String, on server: String, to newValue: Int64, using transaction: Any) { - let collection = Storage.lastDeletionServerIDCollection - let key = "\(server).\(room)" - (transaction as! YapDatabaseReadWriteTransaction).setObject(newValue, forKey: key, inCollection: collection) + + public func setOpenGroupInboxLatestMessageId(for server: String, to newValue: Int64, using transaction: Any) { + let collection = Storage.openGroupInboxLatestMessageIdCollection + (transaction as! YapDatabaseReadWriteTransaction).setObject(newValue, forKey: server, inCollection: collection) } - - public func removeLastDeletionServerID(for room: String, on server: String, using transaction: Any) { - let collection = Storage.lastDeletionServerIDCollection - let key = "\(server).\(room)" - (transaction as! YapDatabaseReadWriteTransaction).removeObject(forKey: key, inCollection: collection) + + public func removeOpenGroupInboxLatestMessageId(for server: String, using transaction: Any) { + let collection = Storage.openGroupInboxLatestMessageIdCollection + (transaction as! YapDatabaseReadWriteTransaction).removeObject(forKey: server, inCollection: collection) } - - // MARK: - Metadata private static let openGroupUserCountCollection = "SNOpenGroupUserCountCollection" diff --git a/SessionMessagingKit/Messages/Signal/TSInteraction.h b/SessionMessagingKit/Messages/Signal/TSInteraction.h index e6b77faf3..9dd99764c 100644 --- a/SessionMessagingKit/Messages/Signal/TSInteraction.h +++ b/SessionMessagingKit/Messages/Signal/TSInteraction.h @@ -79,6 +79,10 @@ NSString *NSStringFromOWSInteractionType(OWSInteractionType value); - (void)updateTimestamp:(uint64_t)timestamp; +#pragma mark Message Request Thread Migration + +- (void)moveToThreadWithId:(NSString *)threadId; + @end NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Messages/Signal/TSInteraction.m b/SessionMessagingKit/Messages/Signal/TSInteraction.m index f3522712d..0ad46b0de 100644 --- a/SessionMessagingKit/Messages/Signal/TSInteraction.m +++ b/SessionMessagingKit/Messages/Signal/TSInteraction.m @@ -269,6 +269,12 @@ NSString *NSStringFromOWSInteractionType(OWSInteractionType value) } +#pragma mark - Message Request Thread Migration + +- (void)moveToThreadWithId:(NSString *)threadId { + _uniqueThreadId = threadId; +} + @end NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Open Groups/Models/Capabilities.swift b/SessionMessagingKit/Open Groups/Models/Capabilities.swift index 627183ace..3c5d7de12 100644 --- a/SessionMessagingKit/Open Groups/Models/Capabilities.swift +++ b/SessionMessagingKit/Open Groups/Models/Capabilities.swift @@ -35,6 +35,13 @@ extension OpenGroupAPI { public let capabilities: [Capability] public let missing: [Capability]? + + // MARK: - Initialization + + public init(capabilities: [Capability], missing: [Capability]? = nil) { + self.capabilities = capabilities + self.missing = missing + } } } diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index ab669549b..5a0bdeb1f 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -93,7 +93,6 @@ public final class OpenGroupManager: NSObject { } storage.updateMessageIDCollectionByPruningMessagesWithIDs(messageIDs, using: transaction) Storage.shared.removeReceivedMessageTimestamps(messageTimestamps, using: transaction) - let _ = OpenGroupAPI.legacyDeleteAuthToken(for: openGroup.room, on: openGroup.server) Storage.shared.removeOpenGroupSequenceNumber(for: openGroup.room, on: openGroup.server, using: transaction) thread.removeAllThreadInteractions(with: transaction) @@ -119,7 +118,7 @@ public final class OpenGroupManager: NSObject { capabilities: capabilities ) - dependencies.storage.storeOpenGroupServer(updatedServer, using: transaction) + dependencies.storage.setOpenGroupServer(updatedServer, using: transaction) } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index c030c9482..1915dd063 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -1,3 +1,5 @@ +import Foundation +import Sodium import SignalCoreKit import SessionSnodeKit @@ -238,8 +240,10 @@ extension MessageReceiver { thread.remove(with: transaction) } } - else { - // Otherwise create and save the thread + else if SessionId.Prefix(from: sessionID) != .blinded { + // Otherwise create and save the thread (if the contact isn't a blinded contact - we don't want to + // auto-create threads for blinded contacts if they have no messages) + // TODO: See what this will do with blinded->unblinded conversations? let thread = TSContactThread.getOrCreateThread(withContactSessionID: sessionID, transaction: transaction) thread.shouldBeVisible = true thread.save(with: transaction) @@ -839,26 +843,130 @@ extension MessageReceiver { public static func handleMessageRequestResponse(_ message: MessageRequestResponse, using transaction: Any) { let userPublicKey = getUserHexEncodedPublicKey() + var blindedContactIds: [String] = [] + var blindedThreadIds: [String] = [] // Ignore messages which were sent from the current user guard message.sender != userPublicKey else { return } guard let senderId: String = message.sender else { return } - - // Get the existing thead and notify the user - if let transaction: YapDatabaseReadWriteTransaction = transaction as? YapDatabaseReadWriteTransaction, let thread: TSContactThread = TSContactThread.getWithContactSessionID(senderId, transaction: transaction) { - let infoMessage = TSInfoMessage( - timestamp: (message.sentTimestamp ?? NSDate.ows_millisecondTimeStamp()), - in: thread, - messageType: .messageRequestAccepted - ) - infoMessage.save(with: transaction) + guard let transaction: YapDatabaseReadWriteTransaction = transaction as? YapDatabaseReadWriteTransaction else { + return } + // Prep the unblinded thread + let unblindedThreadId: String = TSContactThread.threadID(fromContactSessionID: senderId) + let unblindedThread: TSContactThread = TSContactThread.getOrCreateThread(withContactSessionID: senderId, transaction: transaction) + + // Need to handle a `MessageRequestResponse` sent to a blinded thread (ie. check if the sender matches + // the blinded ids of any threads) + let messageRequestThreads: [String: TSContactThread] = Storage.shared.getAllMessageRequestThreads(using: transaction) + + if !messageRequestThreads.isEmpty { + var interactionsToMove: [TSInteraction] = [] + var threadsToDelete: [TSContactThread] = [] + + // Loop through all blinded threads and extract any interactions relating to the user accepting + // the message request + for blindedThread in messageRequestThreads.values { + let blindedId: String = blindedThread.contactSessionID() + + // If the sessionId matches the blindedId then this thread needs to be converted to an un-blinded thread + guard let serverPublicKey: String = blindedThread.originalOpenGroupPublicKey else { continue } + guard Sodium().sessionId(senderId, matchesBlindedId: blindedId, serverPublicKey: serverPublicKey) else { continue } + guard let blindedThreadId: String = blindedThread.uniqueId else { continue } + guard let view: YapDatabaseAutoViewTransaction = transaction.ext(TSMessageDatabaseViewExtensionName) as? YapDatabaseAutoViewTransaction else { + continue + } + + // Cache the mapping + let mapping: BlindedIdMapping = BlindedIdMapping(blindedId: blindedId, sessionId: senderId, serverPublicKey: serverPublicKey) + Storage.shared.cacheBlindedIdMapping(mapping, using: transaction) + + // Add the `blindedId` to an array so we can remove them at the end of processing + blindedContactIds.append(blindedId) + blindedThreadIds.append(blindedThreadId) + + // Loop through all of the interactions and add them to a list to be moved to the new thread + view.enumerateRows(inGroup: blindedThreadId) { _, _, object, _, _, _ in + guard let interaction: TSInteraction = object as? TSInteraction else { + return + } + + interactionsToMove.append(interaction) + } + + threadsToDelete.append(blindedThread) + + // TODO: Pending jobs??? +// Storage.shared.getAllPendingJobs(of: <#T##Job.Type#>) + } + + // Sort the interactions by their `sortId` (which looks to be a global sort id for all interactions) just in case + // the behaviour changes in the future and the value can get reset (this way we process the interactions in the + // correct order regardless of how many threads they came from) + let sortedInteractionsToMove: [TSInteraction] = interactionsToMove + .sorted { lhs, rhs -> Bool in lhs.sortId < rhs.sortId } + + // Note: Unfortunately we need to move the interactions separately from enumerating them to avoid mutating the + // `TSMessageDatabaseViewExtensionName` while enumerating it (this does mean paying the cost of looping a second time) + for interaction in sortedInteractionsToMove { + interaction.moveToThread(withId: unblindedThreadId) + interaction.save(with: transaction) + } + + // Delete the old threads + for thread in threadsToDelete { + // TODO: This isn't updating the HomeVC... Race condition??? (Seems to not happen when stepping through with breakpoints) + thread.removeAllThreadInteractions(with: transaction) + thread.remove(with: transaction) + } + } + + // Update the `didApproveMe` state of the sender updateContactApprovalStatusIfNeeded( senderSessionId: senderId, threadId: nil, - forceConfigSync: true, + forceConfigSync: blindedContactIds.isEmpty, // Sync here if there are no blinded contacts using: transaction ) + + // If there were blinded contacts then we should remove them + if !blindedContactIds.isEmpty { + // Delete all of the processed blinded contacts (shouldn't need them anymore and don't want them taking up + // space in the config message) + for blindedId in blindedContactIds { + // TODO: OWSBlockingManager...??? + } + + // We should assume the 'sender' is a newly created contact and hence need to update it's `isApproved` state + updateContactApprovalStatusIfNeeded( + senderSessionId: userPublicKey, + threadId: unblindedThreadId, + forceConfigSync: true, + using: transaction + ) + } + + // Notify the user of their approval (Note: This will always appear in the un-blinded thread) + // Note: We want to do this last as it'll mean the un-blinded thread gets updated and the contact approval status + // will have been updated at this point (which will mean the `TSThread.isMessageRequest` will return correctly + // after this is saved + let infoMessage = TSInfoMessage( + timestamp: (message.sentTimestamp ?? NSDate.ows_millisecondTimeStamp()), + in: unblindedThread, + messageType: .messageRequestAccepted + ) + infoMessage.save(with: transaction) + + // Finally we need to send a notification that the thread was replaced so we can handle the case where the + // user might currently have the replaced thread open (only need to do this if we actually had blindedIds) + if !blindedThreadIds.isEmpty { + let userInfo: [NotificationUserInfoKey: Any] = [ + .threadId: unblindedThreadId, + .removedThreadIds: blindedThreadIds + ] + + NotificationCenter.default.post(name: .contactThreadReplaced, object: nil, userInfo: userInfo) + } } } diff --git a/SessionMessagingKit/Storage.swift b/SessionMessagingKit/Storage.swift index 2937c9de6..c73373d1e 100644 --- a/SessionMessagingKit/Storage.swift +++ b/SessionMessagingKit/Storage.swift @@ -60,7 +60,7 @@ public protocol SessionMessagingKitStorageProtocol { func setUserCount(to newValue: UInt64, forOpenGroupWithID openGroupID: String, using transaction: Any) func getOpenGroupServer(name: String) -> OpenGroupAPI.Server? - func storeOpenGroupServer(_ server: OpenGroupAPI.Server, using transaction: Any) + func setOpenGroupServer(_ server: OpenGroupAPI.Server, using transaction: Any) // MARK: - -- Open Group Public Keys diff --git a/SessionMessagingKit/Threads/Notification+Thread.swift b/SessionMessagingKit/Threads/Notification+Thread.swift index 4b61f8f1b..aad4c478f 100644 --- a/SessionMessagingKit/Threads/Notification+Thread.swift +++ b/SessionMessagingKit/Threads/Notification+Thread.swift @@ -4,6 +4,7 @@ public extension Notification.Name { static let groupThreadUpdated = Notification.Name("groupThreadUpdated") static let muteSettingUpdated = Notification.Name("muteSettingUpdated") static let messageSentStatusDidChange = Notification.Name("messageSentStatusDidChange") + static let contactThreadReplaced = Notification.Name("contactThreadReplaced") } @objc public extension NSNotification { @@ -12,3 +13,8 @@ public extension Notification.Name { @objc static let muteSettingUpdated = Notification.Name.muteSettingUpdated.rawValue as NSString @objc static let messageSentStatusDidChange = Notification.Name.messageSentStatusDidChange.rawValue as NSString } + +public enum NotificationUserInfoKey: String { + case threadId + case removedThreadIds +} diff --git a/SessionMessagingKit/Threads/TSContactThread.h b/SessionMessagingKit/Threads/TSContactThread.h index f40a7b98c..8a514c75b 100644 --- a/SessionMessagingKit/Threads/TSContactThread.h +++ b/SessionMessagingKit/Threads/TSContactThread.h @@ -10,13 +10,24 @@ extern NSString *const TSContactThreadPrefix; @interface TSContactThread : TSThread +@property (nonatomic, nullable) NSString *originalOpenGroupServer; +@property (nonatomic, nullable) NSString *originalOpenGroupPublicKey; + - (instancetype)initWithContactSessionID:(NSString *)contactSessionID; + (instancetype)getOrCreateThreadWithContactSessionID:(NSString *)contactSessionID NS_SWIFT_NAME(getOrCreateThread(contactSessionID:)); ++ (instancetype)getOrCreateThreadWithContactSessionID:(NSString *)contactSessionID + openGroupServer:(NSString *)openGroupServer + openGroupPublicKey:(NSString *)openGroupPublicKey NS_SWIFT_NAME(getOrCreateThread(contactSessionID:openGroupServer:openGroupPublicKey:)); + (instancetype)getOrCreateThreadWithContactSessionID:(NSString *)contactSessionID transaction:(YapDatabaseReadWriteTransaction *)transaction; ++ (instancetype)getOrCreateThreadWithContactSessionID:(NSString *)contactSessionID + openGroupServer:(NSString *)openGroupServer + openGroupPublicKey:(NSString *)openGroupPublicKey + transaction:(YapDatabaseReadWriteTransaction *)transaction; + // Unlike getOrCreateThreadWithContactSessionID, this will _NOT_ create a thread if one does not already exist. + (nullable instancetype)getThreadWithContactSessionID:(NSString *)contactSessionID transaction:(YapDatabaseReadTransaction *)transaction; diff --git a/SessionMessagingKit/Threads/TSContactThread.m b/SessionMessagingKit/Threads/TSContactThread.m index 0a2a1e9b6..5c4a9459c 100644 --- a/SessionMessagingKit/Threads/TSContactThread.m +++ b/SessionMessagingKit/Threads/TSContactThread.m @@ -33,6 +33,23 @@ NSString *const TSContactThreadPrefix = @"c"; return thread; } ++ (instancetype)getOrCreateThreadWithContactSessionID:(NSString *)contactSessionID + openGroupServer:(NSString *)openGroupServer + openGroupPublicKey:(NSString *)openGroupPublicKey + transaction:(YapDatabaseReadWriteTransaction *)transaction +{ + TSContactThread *thread = [self fetchObjectWithUniqueID:[self threadIDFromContactSessionID:contactSessionID] transaction:transaction]; + + if (!thread) { + thread = [[TSContactThread alloc] initWithContactSessionID:contactSessionID]; + thread.originalOpenGroupServer = openGroupServer; + thread.originalOpenGroupPublicKey = openGroupPublicKey; + [thread saveWithTransaction:transaction]; + } + + return thread; +} + + (instancetype)getOrCreateThreadWithContactSessionID:(NSString *)contactSessionID { __block TSContactThread *thread; @@ -43,6 +60,18 @@ NSString *const TSContactThreadPrefix = @"c"; return thread; } ++ (instancetype)getOrCreateThreadWithContactSessionID:(NSString *)contactSessionID + openGroupServer:(NSString *)openGroupServer + openGroupPublicKey:(NSString *)openGroupPublicKey +{ + __block TSContactThread *thread; + [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { + thread = [self getOrCreateThreadWithContactSessionID:contactSessionID openGroupServer:openGroupServer openGroupPublicKey:openGroupPublicKey transaction:transaction]; + }]; + + return thread; +} + + (nullable instancetype)getThreadWithContactSessionID:(NSString *)contactSessionID transaction:(YapDatabaseReadTransaction *)transaction; { return [TSContactThread fetchObjectWithUniqueID:[self threadIDFromContactSessionID:contactSessionID] transaction:transaction]; diff --git a/SessionMessagingKitTests/_TestUtilities/TestStorage.swift b/SessionMessagingKitTests/_TestUtilities/TestStorage.swift index 1128e0f9c..535a1a43f 100644 --- a/SessionMessagingKitTests/_TestUtilities/TestStorage.swift +++ b/SessionMessagingKitTests/_TestUtilities/TestStorage.swift @@ -86,7 +86,7 @@ class TestStorage: SessionMessagingKitStorageProtocol, Mockable { func getOpenGroup(for threadID: String) -> OpenGroup? { return (mockData[.openGroup] as? OpenGroup) } func setOpenGroup(_ openGroup: OpenGroup, for threadID: String, using transaction: Any) { mockData[.openGroup] = openGroup } func getOpenGroupServer(name: String) -> OpenGroupAPI.Server? { return mockData[.openGroupServer] as? OpenGroupAPI.Server } - func storeOpenGroupServer(_ server: OpenGroupAPI.Server, using transaction: Any) { mockData[.openGroupServer] = server } + func setOpenGroupServer(_ server: OpenGroupAPI.Server, using transaction: Any) { mockData[.openGroupServer] = server } func getUserCount(forOpenGroupWithID openGroupID: String) -> UInt64? { return (mockData[.openGroupUserCount] as? UInt64) diff --git a/SignalUtilitiesKit/Database/Migrations/SOGSV4Migration.swift b/SignalUtilitiesKit/Database/Migrations/SOGSV4Migration.swift new file mode 100644 index 000000000..2737611b1 --- /dev/null +++ b/SignalUtilitiesKit/Database/Migrations/SOGSV4Migration.swift @@ -0,0 +1,33 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +@objc(SNSOGSV4Migration) +public class SOGSV4Migration: OWSDatabaseMigration { + + @objc + class func migrationId() -> String { + return "003" + } + + override public func runUp(completion: @escaping OWSDatabaseMigrationCompletion) { + self.doMigrationAsync(completion: completion) + } + + private func doMigrationAsync(completion: @escaping OWSDatabaseMigrationCompletion) { + // These collections became redundant in SOGS V4 + let lastMessageServerIDCollection: String = "SNLastMessageServerIDCollection" + let lastDeletionServerIDCollection: String = "SNLastDeletionServerIDCollection" + let authTokenCollection: String = "SNAuthTokenCollection" + + Storage.write(with: { transaction in + transaction.removeAllObjects(inCollection: lastMessageServerIDCollection) + transaction.removeAllObjects(inCollection: lastDeletionServerIDCollection) + transaction.removeAllObjects(inCollection: authTokenCollection) + + self.save(with: transaction) // Intentionally capture self + }, completion: { + completion() + }) + } +} From 3a756392850fe5523a990032dad0524d3169c162 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 1 Mar 2022 17:30:35 +1100 Subject: [PATCH 019/157] Fixed a few of issues in the last commit Fixed a couple of build issues where I missed a couple of calls to removed functions Fixed a EXC_BAD_ACCESS issue where the 'poll' function could be called from multiple threads (which accesses and mutates variables) Cleaned up the MessageRequestResponse handling a little --- .../Open Groups/OpenGroupAPI.swift | 20 ++-- .../Open Groups/OpenGroupManager.swift | 1 - .../MessageReceiver+Handling.swift | 29 +++--- SessionMessagingKit/Storage.swift | 6 -- SessionMessagingKit/Utilities/Atomic.swift | 93 ++++++++++++++++--- 5 files changed, 103 insertions(+), 46 deletions(-) diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index ecd7d9de3..0b1512247 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -15,19 +15,19 @@ public final class OpenGroupAPI: NSObject { // MARK: - Polling State - private static var hasPerformedInitialPoll: [String: Bool] = [:] - private static var timeSinceLastPoll: [String: TimeInterval] = [:] - private static var lastPollTime: TimeInterval = .greatestFiniteMagnitude + private static var hasPerformedInitialPoll: AtomicDict = AtomicDict() + private static var timeSinceLastPoll: AtomicDict = AtomicDict() + private static var lastPollTime: Atomic = Atomic(.greatestFiniteMagnitude) - private static let timeSinceLastOpen: TimeInterval = { - guard let lastOpen = UserDefaults.standard[.lastOpen] else { return .greatestFiniteMagnitude } + private static let timeSinceLastOpen: Atomic = { + guard let lastOpen = UserDefaults.standard[.lastOpen] else { return Atomic(.greatestFiniteMagnitude) } - return Date().timeIntervalSince(lastOpen) + return Atomic(Date().timeIntervalSince(lastOpen)) }() // TODO: Remove these - private static var legacyAuthTokenPromises: Atomic<[String: Promise]> = Atomic([:]) + private static var legacyAuthTokenPromises: AtomicDict> = AtomicDict() private static var legacyHasUpdatedLastOpenDate = false private static var legacyGroupImagePromises: [String: Promise] = [:] @@ -44,13 +44,13 @@ public final class OpenGroupAPI: NSObject { public static func poll(_ server: String, using dependencies: Dependencies = Dependencies()) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable?)]> { // Store a local copy of the cached state for this server let hadPerformedInitialPoll: Bool = (hasPerformedInitialPoll[server] == true) - let originalTimeSinceLastPoll: TimeInterval = (timeSinceLastPoll[server] ?? min(lastPollTime, timeSinceLastOpen)) + let originalTimeSinceLastPoll: TimeInterval = (timeSinceLastPoll[server] ?? min(lastPollTime.wrappedValue, timeSinceLastOpen.wrappedValue)) let maybeLastInboxMessageId: Int64? = dependencies.storage.getOpenGroupInboxLatestMessageId(for: server) let lastInboxMessageId: Int64 = (maybeLastInboxMessageId ?? 0) // Update the cached state for this server - hasPerformedInitialPoll[server] = true - lastPollTime = min(lastPollTime, timeSinceLastOpen) + hasPerformedInitialPoll.wrappedValue[server] = true + lastPollTime.wrappedValue = min(lastPollTime.wrappedValue, timeSinceLastOpen.wrappedValue) UserDefaults.standard[.lastOpen] = Date() // Generate the requests diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 5a0bdeb1f..dd3e71863 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -45,7 +45,6 @@ public final class OpenGroupManager: NSObject { // Clear any existing data if needed storage.removeOpenGroupSequenceNumber(for: roomToken, on: server, using: transaction) - storage.removeAuthToken(for: roomToken, on: server, using: transaction) // Store the public key storage.setOpenGroupPublicKey(for: server, to: publicKey, using: transaction) diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index 1915dd063..4093628be 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -843,7 +843,7 @@ extension MessageReceiver { public static func handleMessageRequestResponse(_ message: MessageRequestResponse, using transaction: Any) { let userPublicKey = getUserHexEncodedPublicKey() - var blindedContactIds: [String] = [] + var hadBlindedContact: Bool = false var blindedThreadIds: [String] = [] // Ignore messages which were sent from the current user @@ -882,11 +882,16 @@ extension MessageReceiver { let mapping: BlindedIdMapping = BlindedIdMapping(blindedId: blindedId, sessionId: senderId, serverPublicKey: serverPublicKey) Storage.shared.cacheBlindedIdMapping(mapping, using: transaction) - // Add the `blindedId` to an array so we can remove them at the end of processing - blindedContactIds.append(blindedId) + // Flag that we had a blinded contact and add the `blindedThreadId` to an array so we can remove + // them at the end of processing + hadBlindedContact = true blindedThreadIds.append(blindedThreadId) // Loop through all of the interactions and add them to a list to be moved to the new thread + // Note: Pending `MessageSendJobs` _shouldn't_ be an issue as even if they are sent after the + // un-blinding of a thread, the logic when handling the sent messages should automatically + // assign them to the correct thread + // TODO: Validate the above note once `/outbox` has been implemented view.enumerateRows(inGroup: blindedThreadId) { _, _, object, _, _, _ in guard let interaction: TSInteraction = object as? TSInteraction else { return @@ -896,9 +901,6 @@ extension MessageReceiver { } threadsToDelete.append(blindedThread) - - // TODO: Pending jobs??? -// Storage.shared.getAllPendingJobs(of: <#T##Job.Type#>) } // Sort the interactions by their `sortId` (which looks to be a global sort id for all interactions) just in case @@ -916,7 +918,6 @@ extension MessageReceiver { // Delete the old threads for thread in threadsToDelete { - // TODO: This isn't updating the HomeVC... Race condition??? (Seems to not happen when stepping through with breakpoints) thread.removeAllThreadInteractions(with: transaction) thread.remove(with: transaction) } @@ -926,19 +927,13 @@ extension MessageReceiver { updateContactApprovalStatusIfNeeded( senderSessionId: senderId, threadId: nil, - forceConfigSync: blindedContactIds.isEmpty, // Sync here if there are no blinded contacts + forceConfigSync: !hadBlindedContact, // Sync here if there were no blinded contacts using: transaction ) - // If there were blinded contacts then we should remove them - if !blindedContactIds.isEmpty { - // Delete all of the processed blinded contacts (shouldn't need them anymore and don't want them taking up - // space in the config message) - for blindedId in blindedContactIds { - // TODO: OWSBlockingManager...??? - } - - // We should assume the 'sender' is a newly created contact and hence need to update it's `isApproved` state + // If there were blinded contacts then we need to assume that the 'sender' is a newly create contact and hence + // need to update it's `isApproved` state + if hadBlindedContact { updateContactApprovalStatusIfNeeded( senderSessionId: userPublicKey, threadId: unblindedThreadId, diff --git a/SessionMessagingKit/Storage.swift b/SessionMessagingKit/Storage.swift index c73373d1e..976b7d606 100644 --- a/SessionMessagingKit/Storage.swift +++ b/SessionMessagingKit/Storage.swift @@ -38,12 +38,6 @@ public protocol SessionMessagingKitStorageProtocol { func resumeMessageSendJobIfNeeded(_ messageSendJobID: String) func isJobCanceled(_ job: Job) -> Bool - // MARK: - Authorization - - func getAuthToken(for room: String, on server: String) -> String? - func setAuthToken(for room: String, on server: String, to newValue: String, using transaction: Any) - func removeAuthToken(for room: String, on server: String, using transaction: Any) - // MARK: - Open Groups func getAllOpenGroups() -> [String: OpenGroup] diff --git a/SessionMessagingKit/Utilities/Atomic.swift b/SessionMessagingKit/Utilities/Atomic.swift index 8ba7ca568..69d5ecf7c 100644 --- a/SessionMessagingKit/Utilities/Atomic.swift +++ b/SessionMessagingKit/Utilities/Atomic.swift @@ -2,26 +2,95 @@ import Foundation -/// The `Atomic` wrapper is a generic wrapper providing a thread-safe way to get and set a value +/// See https://www.donnywals.com/why-your-atomic-property-wrapper-doesnt-work-for-collection-types/ +/// for more information about the below types + +protocol UnsupportedType {} + +extension Array: UnsupportedType {} +extension Set: UnsupportedType {} +extension Dictionary: UnsupportedType {} + +// MARK: - Atomic + +/// The `Atomic` wrapper is a generic wrapper providing a thread-safe way to get and set a value @propertyWrapper struct Atomic { - private let lock = DispatchSemaphore(value: 1) + private let queue: DispatchQueue = DispatchQueue(label: "io.oxen.\(UUID().uuidString)", qos: .utility, attributes: .concurrent, autoreleaseFrequency: .inherit, target: .global()) private var value: Value init(_ initialValue: Value) { + if initialValue is UnsupportedType { preconditionFailure("Use the appropriate Aromic... type for collections") } + self.value = initialValue } var wrappedValue: Value { - get { - lock.wait() - defer { lock.signal() } - return value - } - set { - lock.wait() - value = newValue - lock.signal() - } + get { return queue.sync { return value } } + set { return queue.sync { value = newValue } } + } +} + +extension Atomic where Value: CustomDebugStringConvertible { + var debugDescription: String { + return value.debugDescription + } +} + +// MARK: - AtomicArray + +/// The `AtomicArray` wrapper is a generic wrapper providing a thread-safe way to get and set an array or one of it's values +/// +/// Note: This is a class rather than a struct as you need to modify a reference rather than a copy for the concurrency to work +@propertyWrapper +class AtomicArray: CustomDebugStringConvertible { + private let queue: DispatchQueue = DispatchQueue(label: "io.oxen.\(UUID().uuidString)", qos: .utility, attributes: .concurrent, autoreleaseFrequency: .inherit, target: .global()) + private var value: [Value] + + init(_ initialValue: [Value] = []) { + self.value = initialValue + } + + var wrappedValue: [Value] { + get { return queue.sync { return value } } + set { return queue.sync { value = newValue } } + } + + subscript(index: Int) -> Value { + get { queue.sync { value[index] }} + set { queue.async(flags: .barrier) { self.value[index] = newValue } } + } + + public var debugDescription: String { + return value.debugDescription + } +} + +// MARK: - AtomicDict + +/// The `AtomicDict` wrapper is a generic wrapper providing a thread-safe way to get and set a dictionaries or one of it's values +/// +/// Note: This is a class rather than a struct as you need to modify a reference rather than a copy for the concurrency to work +@propertyWrapper +class AtomicDict: CustomDebugStringConvertible { + private let queue: DispatchQueue = DispatchQueue(label: "io.oxen.\(UUID().uuidString)", qos: .utility, attributes: .concurrent, autoreleaseFrequency: .inherit, target: .global()) + private var value: [Key: Value] + + init(_ initialValue: [Key: Value] = [:]) { + self.value = initialValue + } + + var wrappedValue: [Key: Value] { + get { return queue.sync { return value } } + set { return queue.sync { value = newValue } } + } + + subscript(key: Key) -> Value? { + get { queue.sync { value[key] }} + set { queue.async(flags: .barrier) { self.value[key] = newValue } } + } + + var debugDescription: String { + return value.debugDescription } } From bdf5b3bc1bf01ac3234369484c056494dfd2f788 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 2 Mar 2022 11:24:08 +1100 Subject: [PATCH 020/157] Started resolving more TODOs and fixing up unit tests Updated the Dependencies to lazily create their fallback instances (optimisation to avoid unnecessary initialisations) Reworked the Atomic type again to have a more consistent behaviour across types Fixed the build issues with the unit tests (haven't fixed broken tests yet) --- .../Open Groups/OpenGroupAPI.swift | 18 +-- .../Open Groups/Types/Dependencies.swift | 142 +++++++++++++----- SessionMessagingKit/Utilities/Atomic.swift | 101 ++++--------- .../Open Groups/OpenGroupAPIV2Tests.swift | 96 +++++++++--- .../TestAeadXChaCha20Poly1305Ietf.swift | 12 +- .../_TestUtilities/TestSign.swift | 7 + .../_TestUtilities/TestSodium.swift | 19 ++- .../_TestUtilities/TestStorage.swift | 58 ++++--- 8 files changed, 288 insertions(+), 165 deletions(-) diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index 0b1512247..25556f9b3 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -15,8 +15,8 @@ public final class OpenGroupAPI: NSObject { // MARK: - Polling State - private static var hasPerformedInitialPoll: AtomicDict = AtomicDict() - private static var timeSinceLastPoll: AtomicDict = AtomicDict() + private static var hasPerformedInitialPoll: Atomic<[String: Bool]> = Atomic([:]) + private static var timeSinceLastPoll: Atomic<[String: TimeInterval]> = Atomic([:]) private static var lastPollTime: Atomic = Atomic(.greatestFiniteMagnitude) private static let timeSinceLastOpen: Atomic = { @@ -27,7 +27,7 @@ public final class OpenGroupAPI: NSObject { // TODO: Remove these - private static var legacyAuthTokenPromises: AtomicDict> = AtomicDict() + private static var legacyAuthTokenPromises: Atomic<[String: Promise]> = Atomic([:]) private static var legacyHasUpdatedLastOpenDate = false private static var legacyGroupImagePromises: [String: Promise] = [:] @@ -43,14 +43,14 @@ public final class OpenGroupAPI: NSObject { /// - Inbox for the server public static func poll(_ server: String, using dependencies: Dependencies = Dependencies()) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable?)]> { // Store a local copy of the cached state for this server - let hadPerformedInitialPoll: Bool = (hasPerformedInitialPoll[server] == true) - let originalTimeSinceLastPoll: TimeInterval = (timeSinceLastPoll[server] ?? min(lastPollTime.wrappedValue, timeSinceLastOpen.wrappedValue)) + let hadPerformedInitialPoll: Bool = (hasPerformedInitialPoll.wrappedValue[server] == true) + let originalTimeSinceLastPoll: TimeInterval = (timeSinceLastPoll.wrappedValue[server] ?? min(lastPollTime.wrappedValue, timeSinceLastOpen.wrappedValue)) let maybeLastInboxMessageId: Int64? = dependencies.storage.getOpenGroupInboxLatestMessageId(for: server) let lastInboxMessageId: Int64 = (maybeLastInboxMessageId ?? 0) // Update the cached state for this server - hasPerformedInitialPoll.wrappedValue[server] = true - lastPollTime.wrappedValue = min(lastPollTime.wrappedValue, timeSinceLastOpen.wrappedValue) + hasPerformedInitialPoll.mutate { $0[server] = true } + lastPollTime.mutate { $0 = min($0, timeSinceLastOpen.wrappedValue)} UserDefaults.standard[.lastOpen] = Date() // Generate the requests @@ -58,13 +58,13 @@ public final class OpenGroupAPI: NSObject { BatchRequestInfo( request: Request( server: server, - endpoint: .capabilities, - queryParameters: [:] // TODO: Add any requirements '.required' + endpoint: .capabilities ), responseType: Capabilities.self ) ] .appending( + // Per-room requests dependencies.storage.getAllOpenGroups().values .filter { $0.server == server.lowercased() } // Note: The `OpenGroup` type converts to lowercase in init .flatMap { openGroup -> [BatchRequestInfoType] in diff --git a/SessionMessagingKit/Open Groups/Types/Dependencies.swift b/SessionMessagingKit/Open Groups/Types/Dependencies.swift index b4e011d9c..fd64511b8 100644 --- a/SessionMessagingKit/Open Groups/Types/Dependencies.swift +++ b/SessionMessagingKit/Open Groups/Types/Dependencies.swift @@ -4,42 +4,94 @@ import Foundation import Sodium import SessionSnodeKit +// MARK: - Dependencies + extension OpenGroupAPI { - public struct Dependencies { - let api: OnionRequestAPIType.Type - let storage: SessionMessagingKitStorageProtocol - let sodium: SodiumType - let aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType - let sign: SignType - let genericHash: GenericHashType - let ed25519: Ed25519Type.Type - let nonceGenerator16: NonceGenerator16ByteType - let nonceGenerator24: NonceGenerator24ByteType - let date: Date + public class Dependencies { + private var _api: OnionRequestAPIType.Type? + var api: OnionRequestAPIType.Type { + get { getValueSettingIfNull(&_api) { OnionRequestAPI.self } } + set { _api = newValue } + } + + private var _storage: SessionMessagingKitStorageProtocol? + var storage: SessionMessagingKitStorageProtocol { + get { getValueSettingIfNull(&_storage) { SNMessagingKitConfiguration.shared.storage } } + set { _storage = newValue } + } + + private var _sodium: SodiumType? + var sodium: SodiumType { + get { getValueSettingIfNull(&_sodium) { Sodium() } } + set { _sodium = newValue } + } + + private var _aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? + var aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType { + get { getValueSettingIfNull(&_aeadXChaCha20Poly1305Ietf) { sodium.getAeadXChaCha20Poly1305Ietf() } } + set { _aeadXChaCha20Poly1305Ietf = newValue } + } + + private var _sign: SignType? + var sign: SignType { + get { getValueSettingIfNull(&_sign) { sodium.getSign() } } + set { _sign = newValue } + } + + private var _genericHash: GenericHashType? + var genericHash: GenericHashType { + get { getValueSettingIfNull(&_genericHash) { sodium.getGenericHash() } } + set { _genericHash = newValue } + } + + private var _ed25519: Ed25519Type.Type? + var ed25519: Ed25519Type.Type { + get { getValueSettingIfNull(&_ed25519) { Ed25519.self } } + set { _ed25519 = newValue } + } + + private var _nonceGenerator16: NonceGenerator16ByteType? + var nonceGenerator16: NonceGenerator16ByteType { + get { getValueSettingIfNull(&_nonceGenerator16) { NonceGenerator16Byte() } } + set { _nonceGenerator16 = newValue } + } + + private var _nonceGenerator24: NonceGenerator24ByteType? + var nonceGenerator24: NonceGenerator24ByteType { + get { getValueSettingIfNull(&_nonceGenerator24) { NonceGenerator24Byte() } } + set { _nonceGenerator24 = newValue } + } + + private var _date: Date? + var date: Date { + get { getValueSettingIfNull(&_date) { Date() } } + set { _date = newValue } + } + + // MARK: - Initialization public init( - api: OnionRequestAPIType.Type = OnionRequestAPI.self, - storage: SessionMessagingKitStorageProtocol = SNMessagingKitConfiguration.shared.storage, - // TODO: Shift the next 3 to be abstracted behind a single "signing" class? - sodium: SodiumType = Sodium(), + api: OnionRequestAPIType.Type? = nil, + storage: SessionMessagingKitStorageProtocol? = nil, + sodium: SodiumType? = nil, aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, sign: SignType? = nil, genericHash: GenericHashType? = nil, - ed25519: Ed25519Type.Type = Ed25519.self, - nonceGenerator16: NonceGenerator16ByteType = NonceGenerator16Byte(), - nonceGenerator24: NonceGenerator24ByteType = NonceGenerator24Byte(), - date: Date = Date() + ed25519: Ed25519Type.Type? = nil, + nonceGenerator16: NonceGenerator16ByteType? = nil, + nonceGenerator24: NonceGenerator24ByteType? = nil, + date: Date? = nil ) { - self.api = api - self.storage = storage - self.sodium = sodium - self.aeadXChaCha20Poly1305Ietf = (aeadXChaCha20Poly1305Ietf ?? sodium.getAeadXChaCha20Poly1305Ietf()) - self.sign = (sign ?? sodium.getSign()) - self.genericHash = (genericHash ?? sodium.getGenericHash()) - self.ed25519 = ed25519 - self.nonceGenerator16 = nonceGenerator16 - self.nonceGenerator24 = nonceGenerator24 - self.date = date + _api = api + _storage = storage + _sodium = sodium + _aeadXChaCha20Poly1305Ietf = aeadXChaCha20Poly1305Ietf + _sign = sign + _genericHash = genericHash + _ed25519 = ed25519 + _nonceGenerator16 = nonceGenerator16 + _nonceGenerator24 = nonceGenerator24 + _date = date } // MARK: - Convenience @@ -57,17 +109,29 @@ extension OpenGroupAPI { date: Date? = nil ) -> Dependencies { return Dependencies( - api: (api ?? self.api), - storage: (storage ?? self.storage), - sodium: (sodium ?? self.sodium), - aeadXChaCha20Poly1305Ietf: (aeadXChaCha20Poly1305Ietf ?? self.aeadXChaCha20Poly1305Ietf), - sign: (sign ?? self.sign), - genericHash: (genericHash ?? self.genericHash), - ed25519: (ed25519 ?? self.ed25519), - nonceGenerator16: (nonceGenerator16 ?? self.nonceGenerator16), - nonceGenerator24: (nonceGenerator24 ?? self.nonceGenerator24), - date: (date ?? self.date) + api: (api ?? self._api), + storage: (storage ?? self._storage), + sodium: (sodium ?? self._sodium), + aeadXChaCha20Poly1305Ietf: (aeadXChaCha20Poly1305Ietf ?? self._aeadXChaCha20Poly1305Ietf), + sign: (sign ?? self._sign), + genericHash: (genericHash ?? self._genericHash), + ed25519: (ed25519 ?? self._ed25519), + nonceGenerator16: (nonceGenerator16 ?? self._nonceGenerator16), + nonceGenerator24: (nonceGenerator24 ?? self._nonceGenerator24), + date: (date ?? self._date) ) } } } + +// MARK: - Convenience + +fileprivate func getValueSettingIfNull(_ maybeValue: inout T?, _ valueGenerator: () -> T) -> T { + guard let value: T = maybeValue else { + let value: T = valueGenerator() + maybeValue = value + return value + } + + return value +} diff --git a/SessionMessagingKit/Utilities/Atomic.swift b/SessionMessagingKit/Utilities/Atomic.swift index 69d5ecf7c..7d8b07d95 100644 --- a/SessionMessagingKit/Utilities/Atomic.swift +++ b/SessionMessagingKit/Utilities/Atomic.swift @@ -2,32 +2,41 @@ import Foundation -/// See https://www.donnywals.com/why-your-atomic-property-wrapper-doesnt-work-for-collection-types/ -/// for more information about the below types - -protocol UnsupportedType {} - -extension Array: UnsupportedType {} -extension Set: UnsupportedType {} -extension Dictionary: UnsupportedType {} - // MARK: - Atomic /// The `Atomic` wrapper is a generic wrapper providing a thread-safe way to get and set a value +/// +/// A write-up on the need for this class and it's approach can be found here: +/// https://www.vadimbulavin.com/swift-atomic-properties-with-property-wrappers/ +/// there is also another approach which can be taken but it requires separate types for collections and results in +/// a somewhat inconsistent interface between different `Atomic` wrappers @propertyWrapper -struct Atomic { - private let queue: DispatchQueue = DispatchQueue(label: "io.oxen.\(UUID().uuidString)", qos: .utility, attributes: .concurrent, autoreleaseFrequency: .inherit, target: .global()) +public class Atomic { + private let queue: DispatchQueue = DispatchQueue(label: "io.oxen.\(UUID().uuidString)") private var value: Value + + /// In order to change the value you **must** use the `mutate` function + public var wrappedValue: Value { + return queue.sync { return value } + } + + /// For more information see https://github.com/apple/swift-evolution/blob/master/proposals/0258-property-wrappers.md#projections + public var projectedValue: Atomic { + return self + } + + // MARK: - Initialization init(_ initialValue: Value) { - if initialValue is UnsupportedType { preconditionFailure("Use the appropriate Aromic... type for collections") } - self.value = initialValue } - - var wrappedValue: Value { - get { return queue.sync { return value } } - set { return queue.sync { value = newValue } } + + // MARK: - Functions + + func mutate(_ mutation: (inout Value) -> Void) { + return queue.sync { + mutation(&value) + } } } @@ -36,61 +45,3 @@ extension Atomic where Value: CustomDebugStringConvertible { return value.debugDescription } } - -// MARK: - AtomicArray - -/// The `AtomicArray` wrapper is a generic wrapper providing a thread-safe way to get and set an array or one of it's values -/// -/// Note: This is a class rather than a struct as you need to modify a reference rather than a copy for the concurrency to work -@propertyWrapper -class AtomicArray: CustomDebugStringConvertible { - private let queue: DispatchQueue = DispatchQueue(label: "io.oxen.\(UUID().uuidString)", qos: .utility, attributes: .concurrent, autoreleaseFrequency: .inherit, target: .global()) - private var value: [Value] - - init(_ initialValue: [Value] = []) { - self.value = initialValue - } - - var wrappedValue: [Value] { - get { return queue.sync { return value } } - set { return queue.sync { value = newValue } } - } - - subscript(index: Int) -> Value { - get { queue.sync { value[index] }} - set { queue.async(flags: .barrier) { self.value[index] = newValue } } - } - - public var debugDescription: String { - return value.debugDescription - } -} - -// MARK: - AtomicDict - -/// The `AtomicDict` wrapper is a generic wrapper providing a thread-safe way to get and set a dictionaries or one of it's values -/// -/// Note: This is a class rather than a struct as you need to modify a reference rather than a copy for the concurrency to work -@propertyWrapper -class AtomicDict: CustomDebugStringConvertible { - private let queue: DispatchQueue = DispatchQueue(label: "io.oxen.\(UUID().uuidString)", qos: .utility, attributes: .concurrent, autoreleaseFrequency: .inherit, target: .global()) - private var value: [Key: Value] - - init(_ initialValue: [Key: Value] = [:]) { - self.value = initialValue - } - - var wrappedValue: [Key: Value] { - get { return queue.sync { return value } } - set { return queue.sync { value = newValue } } - } - - subscript(key: Key) -> Value? { - get { queue.sync { value[key] }} - set { queue.async(flags: .barrier) { self.value[key] = newValue } } - } - - var debugDescription: String { - return value.debugDescription - } -} diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift b/SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift index ceae8ac77..566769417 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift @@ -21,9 +21,17 @@ class OpenGroupAPITests: XCTestCase { } } - struct TestNonceGenerator: NonceGenerator16ByteType { + struct TestNonce16Generator: NonceGenerator16ByteType { + var NonceBytes: Int = 16 + func nonce() -> Array { return Data(base64Encoded: "pK6YRtQApl4NhECGizF0Cg==")!.bytes } } + + struct TestNonce24Generator: NonceGenerator24ByteType { + var NonceBytes: Int = 24 + + func nonce() -> Array { return Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")!.bytes } + } class TestApi: OnionRequestAPIType { struct RequestData: Codable { @@ -89,7 +97,8 @@ class OpenGroupAPITests: XCTestCase { sign: testSign, genericHash: testGenericHash, ed25519: TestEd25519.self, - nonceGenerator: TestNonceGenerator(), + nonceGenerator16: TestNonce16Generator(), + nonceGenerator24: TestNonce24Generator(), date: Date(timeIntervalSince1970: 1234567890) ) @@ -142,21 +151,35 @@ class OpenGroupAPITests: XCTestCase { OpenGroupAPI.BatchSubResponse( code: 200, headers: [:], - body: OpenGroupAPI.Capabilities(capabilities: [], missing: nil) + body: OpenGroupAPI.Capabilities(capabilities: [], missing: nil), + failedToParseBody: false ) ), try! JSONEncoder().encode( OpenGroupAPI.BatchSubResponse( code: 200, headers: [:], - body: try! JSONDecoder().decode(OpenGroupAPI.RoomPollInfo.self, from: "{}".data(using: .utf8)!) + body: try! JSONDecoder().decode( + OpenGroupAPI.RoomPollInfo.self, + from: """ + { + \"token\":\"test\", + \"active_users\":1, + \"read\":true, + \"write\":true, + \"upload\":true + } + """.data(using: .utf8)! + ), + failedToParseBody: false ) ), try! JSONEncoder().encode( OpenGroupAPI.BatchSubResponse( code: 200, headers: [:], - body: [OpenGroupAPI.Message]() + body: [OpenGroupAPI.Message](), + failedToParseBody: false ) ) ] @@ -166,7 +189,7 @@ class OpenGroupAPITests: XCTestCase { } dependencies = dependencies.with(api: LocalTestApi.self) - var response: [OpenGroupAPI.Endpoint: (OnionRequestResponseInfoType, Codable)]? = nil + var response: [OpenGroupAPI.Endpoint: (OnionRequestResponseInfoType, Codable?)]? = nil var error: Error? = nil OpenGroupAPI.poll("testServer", using: dependencies) @@ -197,7 +220,7 @@ class OpenGroupAPITests: XCTestCase { } func testPollReturnsAnErrorWhenGivenNoData() throws { - var response: [OpenGroupAPI.Endpoint: (OnionRequestResponseInfoType, Codable)]? = nil + var response: [OpenGroupAPI.Endpoint: (OnionRequestResponseInfoType, Codable?)]? = nil var error: Error? = nil OpenGroupAPI.poll("testServer", using: dependencies) @@ -220,7 +243,7 @@ class OpenGroupAPITests: XCTestCase { } dependencies = dependencies.with(api: LocalTestApi.self) - var response: [OpenGroupAPI.Endpoint: (OnionRequestResponseInfoType, Codable)]? = nil + var response: [OpenGroupAPI.Endpoint: (OnionRequestResponseInfoType, Codable?)]? = nil var error: Error? = nil OpenGroupAPI.poll("testServer", using: dependencies) @@ -243,7 +266,7 @@ class OpenGroupAPITests: XCTestCase { } dependencies = dependencies.with(api: LocalTestApi.self) - var response: [OpenGroupAPI.Endpoint: (OnionRequestResponseInfoType, Codable)]? = nil + var response: [OpenGroupAPI.Endpoint: (OnionRequestResponseInfoType, Codable?)]? = nil var error: Error? = nil OpenGroupAPI.poll("testServer", using: dependencies) @@ -266,7 +289,7 @@ class OpenGroupAPITests: XCTestCase { } dependencies = dependencies.with(api: LocalTestApi.self) - var response: [OpenGroupAPI.Endpoint: (OnionRequestResponseInfoType, Codable)]? = nil + var response: [OpenGroupAPI.Endpoint: (OnionRequestResponseInfoType, Codable?)]? = nil var error: Error? = nil OpenGroupAPI.poll("testServer", using: dependencies) @@ -291,14 +314,27 @@ class OpenGroupAPITests: XCTestCase { OpenGroupAPI.BatchSubResponse( code: 200, headers: [:], - body: OpenGroupAPI.Capabilities(capabilities: [], missing: nil) + body: OpenGroupAPI.Capabilities(capabilities: [], missing: nil), + failedToParseBody: false ) ), try! JSONEncoder().encode( OpenGroupAPI.BatchSubResponse( code: 200, headers: [:], - body: try! JSONDecoder().decode(OpenGroupAPI.RoomPollInfo.self, from: "{}".data(using: .utf8)!) + body: try! JSONDecoder().decode( + OpenGroupAPI.RoomPollInfo.self, + from: """ + { + \"token\":\"test\", + \"active_users\":1, + \"read\":true, + \"write\":true, + \"upload\":true + } + """.data(using: .utf8)! + ), + failedToParseBody: false ) ) ] @@ -308,7 +344,7 @@ class OpenGroupAPITests: XCTestCase { } dependencies = dependencies.with(api: LocalTestApi.self) - var response: [OpenGroupAPI.Endpoint: (OnionRequestResponseInfoType, Codable)]? = nil + var response: [OpenGroupAPI.Endpoint: (OnionRequestResponseInfoType, Codable?)]? = nil var error: Error? = nil OpenGroupAPI.poll("testServer", using: dependencies) @@ -333,21 +369,24 @@ class OpenGroupAPITests: XCTestCase { OpenGroupAPI.BatchSubResponse( code: 200, headers: [:], - body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: "") + body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: ""), + failedToParseBody: false ) ), try! JSONEncoder().encode( OpenGroupAPI.BatchSubResponse( code: 200, headers: [:], - body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: "") + body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: ""), + failedToParseBody: false ) ), try! JSONEncoder().encode( OpenGroupAPI.BatchSubResponse( code: 200, headers: [:], - body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: "") + body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: ""), + failedToParseBody: false ) ) ] @@ -357,7 +396,7 @@ class OpenGroupAPITests: XCTestCase { } dependencies = dependencies.with(api: LocalTestApi.self) - var response: [OpenGroupAPI.Endpoint: (OnionRequestResponseInfoType, Codable)]? = nil + var response: [OpenGroupAPI.Endpoint: (OnionRequestResponseInfoType, Codable?)]? = nil var error: Error? = nil OpenGroupAPI.poll("testServer", using: dependencies) @@ -566,6 +605,7 @@ class OpenGroupAPITests: XCTestCase { func getAeadXChaCha20Poly1305Ietf() -> AeadXChaCha20Poly1305IetfType { return Sodium().aead.xchacha20poly1305ietf } func getSign() -> SignType { return Sodium().sign } + func generateBlindingFactor(serverPublicKey: String) -> Bytes? { return nil } func blindedKeyPair(serverPublicKey: String, edKeyPair: Box.KeyPair, genericHash: GenericHashType) -> Box.KeyPair? { return nil } @@ -573,7 +613,14 @@ class OpenGroupAPITests: XCTestCase { return nil } - func sharedEdSecret(_ firstKeyBytes: [UInt8], _ secondKeyBytes: [UInt8]) -> Bytes? { return nil } + func combineKeys(lhsKeyBytes: Bytes, rhsKeyBytes: Bytes) -> Bytes? { return nil } + func sharedBlindedEncryptionKey(secretKey a: Bytes, otherBlindedPublicKey: Bytes, fromBlindedPublicKey kA: Bytes, toBlindedPublicKey kB: Bytes, genericHash: GenericHashType) -> Bytes? { + return nil + } + + func sessionId(_ sessionId: String, matchesBlindedId blindedSessionId: String, serverPublicKey: String) -> Bool { + return false + } } testStorage.mockData[.openGroupServer] = OpenGroupAPI.Server( name: "testServer", @@ -604,6 +651,7 @@ class OpenGroupAPITests: XCTestCase { func getAeadXChaCha20Poly1305Ietf() -> AeadXChaCha20Poly1305IetfType { return Sodium().aead.xchacha20poly1305ietf } func getSign() -> SignType { return Sodium().sign } + func generateBlindingFactor(serverPublicKey: String) -> Bytes? { return nil } func blindedKeyPair(serverPublicKey: String, edKeyPair: Box.KeyPair, genericHash: GenericHashType) -> Box.KeyPair? { return Box.KeyPair( publicKey: Data.data(fromHex: "7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")!.bytes, @@ -614,7 +662,14 @@ class OpenGroupAPITests: XCTestCase { return nil } - func sharedEdSecret(_ firstKeyBytes: [UInt8], _ secondKeyBytes: [UInt8]) -> Bytes? { return nil } + func combineKeys(lhsKeyBytes: Bytes, rhsKeyBytes: Bytes) -> Bytes? { return nil } + func sharedBlindedEncryptionKey(secretKey a: Bytes, otherBlindedPublicKey: Bytes, fromBlindedPublicKey kA: Bytes, toBlindedPublicKey kB: Bytes, genericHash: GenericHashType) -> Bytes? { + return nil + } + + func sessionId(_ sessionId: String, matchesBlindedId blindedSessionId: String, serverPublicKey: String) -> Bool { + return false + } } testStorage.mockData[.openGroupServer] = OpenGroupAPI.Server( name: "testServer", @@ -641,8 +696,11 @@ class OpenGroupAPITests: XCTestCase { func testItFailsToSignIfUnblindedAndTheSignatureDoesNotGetGenerated() throws { class InvalidSign: SignType { + var PublicKeyBytes: Int = 32 + func signature(message: Bytes, secretKey: Bytes) -> Bytes? { return nil } func verify(message: Bytes, publicKey: Bytes, signature: Bytes) -> Bool { return false } + func toX25519(ed25519PublicKey: Bytes) -> Bytes? { return nil } } testStorage.mockData[.openGroupServer] = OpenGroupAPI.Server( name: "testServer", diff --git a/SessionMessagingKitTests/_TestUtilities/TestAeadXChaCha20Poly1305Ietf.swift b/SessionMessagingKitTests/_TestUtilities/TestAeadXChaCha20Poly1305Ietf.swift index 0906b8e25..13bffd852 100644 --- a/SessionMessagingKitTests/_TestUtilities/TestAeadXChaCha20Poly1305Ietf.swift +++ b/SessionMessagingKitTests/_TestUtilities/TestAeadXChaCha20Poly1305Ietf.swift @@ -7,10 +7,14 @@ import Sodium @testable import SessionMessagingKit class TestAeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType, Mockable { + var KeyBytes: Int = 32 + var ABytes: Int = 16 + // MARK: - Mockable enum DataKey: Hashable { case encrypt + case decrypt } typealias Key = DataKey @@ -19,7 +23,11 @@ class TestAeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType, Mockable { // MARK: - SignType - func encrypt(message: Bytes, secretKey: Aead.XChaCha20Poly1305Ietf.Key, additionalData: Bytes?) -> (authenticatedCipherText: Bytes, nonce: Aead.XChaCha20Poly1305Ietf.Nonce)? { - return (mockData[.encrypt] as? (authenticatedCipherText: Bytes, nonce: Aead.XChaCha20Poly1305Ietf.Nonce)) + func encrypt(message: Bytes, secretKey: Bytes, nonce: Bytes, additionalData: Bytes?) -> Bytes? { + return (mockData[.encrypt] as? Bytes) + } + + func decrypt(authenticatedCipherText: Bytes, secretKey: Bytes, nonce: Bytes, additionalData: Bytes?) -> Bytes? { + return (mockData[.decrypt] as? Bytes) } } diff --git a/SessionMessagingKitTests/_TestUtilities/TestSign.swift b/SessionMessagingKitTests/_TestUtilities/TestSign.swift index a193b2a5f..3d2402bbf 100644 --- a/SessionMessagingKitTests/_TestUtilities/TestSign.swift +++ b/SessionMessagingKitTests/_TestUtilities/TestSign.swift @@ -7,11 +7,14 @@ import Sodium @testable import SessionMessagingKit class TestSign: SignType, Mockable { + var PublicKeyBytes: Int = 32 + // MARK: - Mockable enum DataKey: Hashable { case signature case verify + case toX25519 } typealias Key = DataKey @@ -27,4 +30,8 @@ class TestSign: SignType, Mockable { func verify(message: Bytes, publicKey: Bytes, signature: Bytes) -> Bool { return (mockData[.verify] as! Bool) } + + func toX25519(ed25519PublicKey: Bytes) -> Bytes? { + return (mockData[.toX25519] as? Bytes) + } } diff --git a/SessionMessagingKitTests/_TestUtilities/TestSodium.swift b/SessionMessagingKitTests/_TestUtilities/TestSodium.swift index 862cbfc8d..b8132ba62 100644 --- a/SessionMessagingKitTests/_TestUtilities/TestSodium.swift +++ b/SessionMessagingKitTests/_TestUtilities/TestSodium.swift @@ -13,9 +13,12 @@ class TestSodium: SodiumType, Mockable { case genericHash case aeadXChaCha20Poly1305Ietf case sign + case blindingFactor case blindedKeyPair case sogsSignature - case sharedEdSecret + case combinedKeys + case sharedBlindedEncryptionKey + case sessionIdMatches } typealias Key = DataKey @@ -32,6 +35,8 @@ class TestSodium: SodiumType, Mockable { func getSign() -> SignType { return (mockData[.sign] as! SignType) } + func generateBlindingFactor(serverPublicKey: String) -> Bytes? { return (mockData[.blindingFactor] as? Bytes) } + func blindedKeyPair(serverPublicKey: String, edKeyPair: Box.KeyPair, genericHash: GenericHashType) -> Box.KeyPair? { return (mockData[.blindedKeyPair] as? Box.KeyPair) } @@ -40,7 +45,15 @@ class TestSodium: SodiumType, Mockable { return (mockData[.sogsSignature] as? Bytes) } - func sharedEdSecret(_ firstKeyBytes: [UInt8], _ secondKeyBytes: [UInt8]) -> Bytes? { - return (mockData[.sharedEdSecret] as? Bytes) + func combineKeys(lhsKeyBytes: Bytes, rhsKeyBytes: Bytes) -> Bytes? { + return (mockData[.combinedKeys] as? Bytes) + } + + func sharedBlindedEncryptionKey(secretKey a: Bytes, otherBlindedPublicKey: Bytes, fromBlindedPublicKey kA: Bytes, toBlindedPublicKey kB: Bytes, genericHash: GenericHashType) -> Bytes? { + return (mockData[.sharedBlindedEncryptionKey] as? Bytes) + } + + func sessionId(_ sessionId: String, matchesBlindedId blindedSessionId: String, serverPublicKey: String) -> Bool { + return ((mockData[.sessionIdMatches] as? Bool) ?? false) } } diff --git a/SessionMessagingKitTests/_TestUtilities/TestStorage.swift b/SessionMessagingKitTests/_TestUtilities/TestStorage.swift index 535a1a43f..494cf03c2 100644 --- a/SessionMessagingKitTests/_TestUtilities/TestStorage.swift +++ b/SessionMessagingKitTests/_TestUtilities/TestStorage.swift @@ -18,6 +18,8 @@ class TestStorage: SessionMessagingKitStorageProtocol, Mockable { case openGroupServer case openGroupImage case openGroupUserCount + case openGroupSequenceNumber + case openGroupLatestMessageId } typealias Key = DataKey @@ -47,6 +49,7 @@ class TestStorage: SessionMessagingKitStorageProtocol, Mockable { func getUserED25519KeyPair() -> Box.KeyPair? { return (mockData[.userEdKeyPair] as? Box.KeyPair) } func getUser() -> Contact? { return nil } func getAllContacts() -> Set { return Set() } + func getAllContacts(with transaction: YapDatabaseReadTransaction) -> Set { return Set() } // MARK: - Closed Groups @@ -66,12 +69,6 @@ class TestStorage: SessionMessagingKitStorageProtocol, Mockable { func resumeMessageSendJobIfNeeded(_ messageSendJobID: String) {} func isJobCanceled(_ job: Job) -> Bool { return true } - // MARK: - Authorization - - func getAuthToken(for room: String, on server: String) -> String? { return nil } - func setAuthToken(for room: String, on server: String, to newValue: String, using transaction: Any) {} - func removeAuthToken(for room: String, on server: String, using transaction: Any) {} - // MARK: - Open Groups func getAllOpenGroups() -> [String: OpenGroup] { return (mockData[.allOpenGroups] as! [String: OpenGroup]) } @@ -96,6 +93,40 @@ class TestStorage: SessionMessagingKitStorageProtocol, Mockable { mockData[.openGroupUserCount] = newValue } + func getOpenGroupSequenceNumber(for room: String, on server: String) -> Int64? { + let data: [String: Int64] = ((mockData[.openGroupSequenceNumber] as? [String: Int64]) ?? [:]) + return data["\(server).\(room)"] + } + + func setOpenGroupSequenceNumber(for room: String, on server: String, to newValue: Int64, using transaction: Any) { + var updatedData: [String: Int64] = ((mockData[.openGroupSequenceNumber] as? [String: Int64]) ?? [:]) + updatedData["\(server).\(room)"] = newValue + mockData[.openGroupSequenceNumber] = updatedData + } + + func removeOpenGroupSequenceNumber(for room: String, on server: String, using transaction: Any) { + var updatedData: [String: Int64] = ((mockData[.openGroupSequenceNumber] as? [String: Int64]) ?? [:]) + updatedData["\(server).\(room)"] = nil + mockData[.openGroupSequenceNumber] = updatedData + } + + func getOpenGroupInboxLatestMessageId(for server: String) -> Int64? { + let data: [String: Int64] = ((mockData[.openGroupLatestMessageId] as? [String: Int64]) ?? [:]) + return data[server] + } + + func setOpenGroupInboxLatestMessageId(for server: String, to newValue: Int64, using transaction: Any) { + var updatedData: [String: Int64] = ((mockData[.openGroupLatestMessageId] as? [String: Int64]) ?? [:]) + updatedData[server] = newValue + mockData[.openGroupLatestMessageId] = updatedData + } + + func removeOpenGroupInboxLatestMessageId(for server: String, using transaction: Any) { + var updatedData: [String: Int64] = ((mockData[.openGroupLatestMessageId] as? [String: Int64]) ?? [:]) + updatedData[server] = nil + mockData[.openGroupLatestMessageId] = updatedData + } + // MARK: - Open Group Public Keys func getOpenGroupPublicKey(for server: String) -> String? { @@ -108,19 +139,10 @@ class TestStorage: SessionMessagingKitStorageProtocol, Mockable { func setOpenGroupPublicKey(for server: String, to newValue: String, using transaction: Any) {} - // MARK: - Last Message Server ID - - func getLastMessageServerID(for room: String, on server: String) -> Int64? { return nil } - func setLastMessageServerID(for room: String, on server: String, to newValue: Int64, using transaction: Any) {} - func removeLastMessageServerID(for room: String, on server: String, using transaction: Any) {} - - // MARK: - Last Deletion Server ID - - func getLastDeletionServerID(for room: String, on server: String) -> Int64? { return nil } - func setLastDeletionServerID(for room: String, on server: String, to newValue: Int64, using transaction: Any) {} - func removeLastDeletionServerID(for room: String, on server: String, using transaction: Any) {} - // MARK: - Message Handling + + func getAllMessageRequestThreads() -> [String: TSContactThread] { return [:] } + func getAllMessageRequestThreads(using transaction: YapDatabaseReadTransaction) -> [String: TSContactThread] { return [:] } func getReceivedMessageTimestamps(using transaction: Any) -> [UInt64] { return [] } func addReceivedMessageTimestamp(_ timestamp: UInt64, using transaction: Any) {} From 6936f35f2ae0516857998d4a1e1dc71e245cc914 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 2 Mar 2022 13:09:45 +1100 Subject: [PATCH 021/157] Fixed a few issues uncovered while testing and some cleanup Fixed an incorrect optional in RoomPollInfo Fixed an incorrect parameter name in the ClosedGroupRequestBody Fixed a crash due to a change in the ContactUtilities Cleaned up the duplicate code in the OnionRequestAPI, HTTP and SnodeAPI to all use 'Data' response types Updated the SnodeAPI to casting types to Any (made it hard to catch breaking changes with HTTP and OnionRequestAPI) --- Session/Utilities/BackgroundPoller.swift | 4 +- Session/Utilities/ContactUtilities.swift | 6 +- .../Open Groups/Models/RoomPollInfo.swift | 2 +- .../Open Groups/OpenGroupAPI.swift | 1 - .../Notifications/PushNotificationAPI.swift | 10 +- .../Sending & Receiving/Pollers/Poller.swift | 4 +- SessionSnodeKit/Models/Error.swift | 2 +- SessionSnodeKit/OnionRequestAPI.swift | 70 ++-- SessionSnodeKit/SnodeAPI.swift | 326 ++++++++++-------- SessionUtilitiesKit/Networking/HTTP.swift | 100 ++---- 10 files changed, 262 insertions(+), 263 deletions(-) diff --git a/Session/Utilities/BackgroundPoller.swift b/Session/Utilities/BackgroundPoller.swift index b0bf06175..903301354 100644 --- a/Session/Utilities/BackgroundPoller.swift +++ b/Session/Utilities/BackgroundPoller.swift @@ -50,8 +50,8 @@ public final class BackgroundPoller: NSObject { return attempt(maxRetryCount: 4, recoveringOn: DispatchQueue.main) { return SnodeAPI.getRawMessages(from: snode, associatedWith: publicKey) - .then(on: DispatchQueue.main) { rawResponse -> Promise in - let messages = SnodeAPI.parseRawMessagesResponse(rawResponse, from: snode, associatedWith: publicKey) + .then(on: DispatchQueue.main) { responseData -> Promise in + let messages = SnodeAPI.parseRawMessagesResponse(responseData, from: snode, associatedWith: publicKey) let promises = messages .compactMap { json -> Promise? in // Use a best attempt approach here; we don't want to fail diff --git a/Session/Utilities/ContactUtilities.swift b/Session/Utilities/ContactUtilities.swift index f48fa32e2..6c7857374 100644 --- a/Session/Utilities/ContactUtilities.swift +++ b/Session/Utilities/ContactUtilities.swift @@ -32,8 +32,10 @@ enum ContactUtilities { // Sort alphabetically return result - .map { contact -> String in (contact.displayName(for: .regular) ?? contact.sessionID) } - .sorted() + .sorted(by: { lhs, rhs in + (lhs.displayName(for: .regular) ?? lhs.sessionID) < (rhs.displayName(for: .regular) ?? rhs.sessionID) + }) + .map { $0.sessionID } } static func enumerateApprovedContactThreads(with block: @escaping (TSContactThread, Contact, UnsafeMutablePointer) -> ()) { diff --git a/SessionMessagingKit/Open Groups/Models/RoomPollInfo.swift b/SessionMessagingKit/Open Groups/Models/RoomPollInfo.swift index 7d30da86b..9523f5d33 100644 --- a/SessionMessagingKit/Open Groups/Models/RoomPollInfo.swift +++ b/SessionMessagingKit/Open Groups/Models/RoomPollInfo.swift @@ -27,7 +27,7 @@ extension OpenGroupAPI { } /// The room token as used in a URL, e.g. "sudoku" - public let token: String? + public let token: String /// Number of recently active users in the room over a recent time period (as given in the active_users_cutoff value) /// diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index 25556f9b3..35a90cbcf 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -549,7 +549,6 @@ public final class OpenGroupAPI: NSObject { ) return send(request, using: dependencies) - .decoded(as: [DirectMessage].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) } // MARK: - Users diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift index 91b79943a..743a15539 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift @@ -9,7 +9,7 @@ public final class PushNotificationAPI : NSObject { } struct ClosedGroupRequestBody: Codable { - let token: String + let closedGroupPublicKey: String let pubKey: String } @@ -51,7 +51,6 @@ public final class PushNotificationAPI : NSObject { request.httpBody = body let promise: Promise = attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) { - // TODO: Update this to use the V4 union requests once supported OnionRequestAPI.sendOnionRequest(request, to: server, using: .v2, with: serverPublicKey) .map2 { _, response in guard let response: UnregisterResponse = try? response?.decoded(as: UnregisterResponse.self) else { @@ -101,7 +100,6 @@ public final class PushNotificationAPI : NSObject { request.httpBody = body let promise: Promise = attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) { - // TODO: Update this to use the V4 union requests once supported OnionRequestAPI.sendOnionRequest(request, to: server, using: .v2, with: serverPublicKey) .map2 { _, response in guard let response: RegisterResponse = try? response?.decoded(as: RegisterResponse.self) else { @@ -134,7 +132,10 @@ public final class PushNotificationAPI : NSObject { @discardableResult public static func performOperation(_ operation: ClosedGroupOperation, for closedGroupPublicKey: String, publicKey: String) -> Promise { let isUsingFullAPNs = UserDefaults.standard[.isUsingFullAPNs] - let requestBody: ClosedGroupRequestBody = ClosedGroupRequestBody(token: closedGroupPublicKey, pubKey: publicKey) + let requestBody: ClosedGroupRequestBody = ClosedGroupRequestBody( + closedGroupPublicKey: closedGroupPublicKey, + pubKey: publicKey + ) guard isUsingFullAPNs else { return Promise { $0.fulfill(()) } } guard let body: Data = try? JSONEncoder().encode(requestBody) else { @@ -148,7 +149,6 @@ public final class PushNotificationAPI : NSObject { request.httpBody = body let promise: Promise = attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) { - // TODO: Update this to use the V4 union requests once supported OnionRequestAPI.sendOnionRequest(request, to: server, using: .v2, with: serverPublicKey) .map2 { _, response in guard let response: RegisterResponse = try? response?.decoded(as: RegisterResponse.self) else { diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift index cfb3463ea..a8c2151ed 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift @@ -87,9 +87,9 @@ public final class Poller : NSObject { private func poll(_ snode: Snode, seal longTermSeal: Resolver) -> Promise { guard isPolling else { return Promise { $0.fulfill(()) } } let userPublicKey = getUserHexEncodedPublicKey() - return SnodeAPI.getRawMessages(from: snode, associatedWith: userPublicKey).then(on: DispatchQueue.main) { [weak self] rawResponse -> Promise in + return SnodeAPI.getRawMessages(from: snode, associatedWith: userPublicKey).then(on: DispatchQueue.main) { [weak self] responseData -> Promise in guard let strongSelf = self, strongSelf.isPolling else { return Promise { $0.fulfill(()) } } - let messages = SnodeAPI.parseRawMessagesResponse(rawResponse, from: snode, associatedWith: userPublicKey) + let messages = SnodeAPI.parseRawMessagesResponse(responseData, from: snode, associatedWith: userPublicKey) if !messages.isEmpty { SNLog("Received \(messages.count) new message(s).") } diff --git a/SessionSnodeKit/Models/Error.swift b/SessionSnodeKit/Models/Error.swift index d12635df8..e474ce5c8 100644 --- a/SessionSnodeKit/Models/Error.swift +++ b/SessionSnodeKit/Models/Error.swift @@ -5,7 +5,7 @@ import SessionUtilitiesKit extension OnionRequestAPI { public enum Error: LocalizedError { - case httpRequestFailedAtDestination(statusCode: UInt, json: JSON, destination: Destination) + case httpRequestFailedAtDestination(statusCode: UInt, data: Data, destination: Destination) case insufficientSnodes case invalidURL case missingSnodeVersion diff --git a/SessionSnodeKit/OnionRequestAPI.swift b/SessionSnodeKit/OnionRequestAPI.swift index 2d7daa083..de36cc088 100644 --- a/SessionSnodeKit/OnionRequestAPI.swift +++ b/SessionSnodeKit/OnionRequestAPI.swift @@ -9,10 +9,6 @@ public protocol OnionRequestAPIType { } public extension OnionRequestAPIType { - static func sendOnionRequest(to snode: Snode, invoking method: Snode.Method, with parameters: JSON, using version: OnionRequestAPI.Version = .v3) -> Promise { - return sendOnionRequest(to: snode, invoking: method, with: parameters, using: version, associatedWith: nil) - } - static func sendOnionRequest(_ request: URLRequest, to server: String, with x25519PublicKey: String) -> Promise<(OnionRequestResponseInfoType, Data?)> { sendOnionRequest(request, to: server, using: .v4, with: x25519PublicKey) } @@ -50,24 +46,32 @@ public enum OnionRequestAPI: OnionRequestAPIType { // MARK: Onion Building Result private typealias OnionBuildingResult = (guardSnode: Snode, finalEncryptionResult: AESGCM.EncryptionResult, destinationSymmetricKey: Data) - // MARK: Private API + // MARK: - Private API /// Tests the given snode. The returned promise errors out if the snode is faulty; the promise is fulfilled otherwise. private static func testSnode(_ snode: Snode) -> Promise { let (promise, seal) = Promise.pending() DispatchQueue.global(qos: .userInitiated).async { let url = "\(snode.address):\(snode.port)/get_stats/v1" let timeout: TimeInterval = 3 // Use a shorter timeout for testing - HTTP.execute(.get, url, timeout: timeout).done2 { json in - guard let version = json["version"] as? String else { return seal.reject(Error.missingSnodeVersion) } - if version >= "2.0.7" { - seal.fulfill(()) - } else { - SNLog("Unsupported snode version: \(version).") - seal.reject(Error.unsupportedSnodeVersion(version)) + + HTTP.execute(.get, url, timeout: timeout) + .done2 { responseData in + guard let responseJson: JSON = try? JSONSerialization.jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? JSON else { + throw HTTP.Error.invalidJSON + } + guard let version = responseJson["version"] as? String else { return seal.reject(Error.missingSnodeVersion) } + + if version >= "2.0.7" { + seal.fulfill(()) + } + else { + SNLog("Unsupported snode version: \(version).") + seal.reject(Error.unsupportedSnodeVersion(version)) + } + } + .catch2 { error in + seal.reject(error) } - }.catch2 { error in - seal.reject(error) - } } return promise } @@ -280,7 +284,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { // MARK: - Public API /// Sends an onion request to `snode`. Builds new paths as needed. - public static func sendOnionRequest(to snode: Snode, invoking method: Snode.Method, with parameters: JSON, using version: Version = .v3, associatedWith publicKey: String? = nil) -> Promise { + public static func sendOnionRequest(to snode: Snode, invoking method: Snode.Method, with parameters: JSON, using version: Version, associatedWith publicKey: String?) -> Promise { let payloadJson: JSON = [ "method": method.rawValue, "params": parameters ] guard let jsonData: Data = try? JSONSerialization.data(withJSONObject: payloadJson, options: []), let payload: String = String(data: jsonData, encoding: .utf8) else { @@ -294,11 +298,11 @@ public enum OnionRequestAPI: OnionRequestAPIType { return data } .recover2 { error -> Promise in - guard case OnionRequestAPI.Error.httpRequestFailedAtDestination(let statusCode, let json, _) = error else { + guard case OnionRequestAPI.Error.httpRequestFailedAtDestination(let statusCode, let data, _) = error else { throw error } - throw SnodeAPI.handleError(withStatusCode: statusCode, json: json, forSnode: snode, associatedWith: publicKey) ?? error + throw SnodeAPI.handleError(withStatusCode: statusCode, data: data, forSnode: snode, associatedWith: publicKey) ?? error } } @@ -347,7 +351,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { } let destinationSymmetricKey = intermediate.destinationSymmetricKey - HTTP.updatedExecute(.post, url, body: body) + HTTP.execute(.post, url, body: body) .done2 { responseData in handleResponse( responseData: responseData, @@ -366,7 +370,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { } promise.catch2 { error in // Must be invoked on Threading.workQueue - guard case HTTP.Error.httpRequestFailed(let statusCode, let json) = error, let guardSnode = guardSnode else { + guard case HTTP.Error.httpRequestFailed(let statusCode, let data) = error, let guardSnode = guardSnode else { return } @@ -381,7 +385,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { if pathFailureCount >= pathFailureThreshold { dropGuardSnode(guardSnode) path.forEach { snode in - SnodeAPI.handleError(withStatusCode: statusCode, json: json, forSnode: snode) // Intentionally don't throw + SnodeAPI.handleError(withStatusCode: statusCode, data: data, forSnode: snode) // Intentionally don't throw } drop(path) @@ -392,6 +396,17 @@ public enum OnionRequestAPI: OnionRequestAPIType { } let prefix = "Next node not found: " + let json: JSON? + + if let data: Data = data, let processedJson = try? JSONSerialization.jsonObject(with: data, options: [ .fragmentsAllowed ]) as? JSON { + json = processedJson + } + else if let data: Data = data, let result: String = String(data: data, encoding: .utf8) { + json = [ "result": result ] + } + else { + json = nil + } if let message = json?["result"] as? String, message.hasPrefix(prefix) { let ed25519PublicKey = message[message.index(message.startIndex, offsetBy: prefix.count)..= snodeFailureThreshold { - SnodeAPI.handleError(withStatusCode: statusCode, json: json, forSnode: snode) // Intentionally don't throw + SnodeAPI.handleError(withStatusCode: statusCode, data: data, forSnode: snode) // Intentionally don't throw do { try drop(snode) } @@ -483,7 +498,6 @@ public enum OnionRequestAPI: OnionRequestAPIType { case .v4: // Note: We need to remove the leading forward slash unless we are explicitly hitting a legacy // endpoint (in which case we need it to ensure the request signing works correctly - // TODO: Confirm the 'removingPrefix' isn't going to break the request signing on non-legacy endpoints let endpoint: String = url.path .appending(url.query.map { value in "?\(value)" }) @@ -493,9 +507,9 @@ public enum OnionRequestAPI: OnionRequestAPIType { headers: (request.allHTTPHeaderFields ?? [:]) .setting( "Content-Type", - // TODO: Determine what 'Content-Type' 'httpBodyStream' should have???. (request.httpBody == nil && request.httpBodyStream == nil ? nil : - ((request.allHTTPHeaderFields ?? [:])["Content-Type"] ?? "application/json") // Default to JSON if not defined + // Default to JSON if not defined + ((request.allHTTPHeaderFields ?? [:])["Content-Type"] ?? "application/json") ) ) .removingValue(forKey: "User-Agent") @@ -573,14 +587,14 @@ public enum OnionRequestAPI: OnionRequestAPIType { } guard 200...299 ~= statusCode else { - return seal.reject(Error.httpRequestFailedAtDestination(statusCode: UInt(statusCode), json: body, destination: destination)) + return seal.reject(Error.httpRequestFailedAtDestination(statusCode: UInt(statusCode), data: bodyAsData, destination: destination)) } return seal.fulfill((OnionRequestAPI.ResponseInfo(code: statusCode, headers: [:]), bodyAsData)) } guard 200...299 ~= statusCode else { - return seal.reject(Error.httpRequestFailedAtDestination(statusCode: UInt(statusCode), json: json, destination: destination)) + return seal.reject(Error.httpRequestFailedAtDestination(statusCode: UInt(statusCode), data: data, destination: destination)) } return seal.fulfill((OnionRequestAPI.ResponseInfo(code: statusCode, headers: [:]), data)) @@ -628,7 +642,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { return seal.reject( Error.httpRequestFailedAtDestination( statusCode: UInt(responseInfo.code), - json: [:], // TODO: Remove the 'json' value?? + data: data, destination: destination ) ) diff --git a/SessionSnodeKit/SnodeAPI.swift b/SessionSnodeKit/SnodeAPI.swift index becc23930..6244f0e5b 100644 --- a/SessionSnodeKit/SnodeAPI.swift +++ b/SessionSnodeKit/SnodeAPI.swift @@ -60,11 +60,6 @@ public final class SnodeAPI : NSObject { } } - // MARK: Type Aliases - public typealias MessageListPromise = Promise<[JSON]> - public typealias RawResponse = Any - public typealias RawResponsePromise = Promise - // MARK: Snode Pool Interaction private static func loadSnodePoolIfNeeded() { guard !hasLoadedSnodePool else { return } @@ -129,30 +124,26 @@ public final class SnodeAPI : NSObject { } // MARK: Internal API - internal static func invoke(_ method: Snode.Method, on snode: Snode, associatedWith publicKey: String? = nil, parameters: JSON) -> RawResponsePromise { + internal static func invoke(_ method: Snode.Method, on snode: Snode, associatedWith publicKey: String? = nil, parameters: JSON) -> Promise { if Features.useOnionRequests { return OnionRequestAPI.sendOnionRequest(to: snode, invoking: method, with: parameters, using: .v3, associatedWith: publicKey) - .map2 { responseData in - guard let responseJson: JSON = try? JSONSerialization.jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? JSON else { - throw Error.generic - } - - // FIXME: Would be nice to change this to not send 'Any' - return responseJson as Any - } - } else { + } + else { let url = "\(snode.address):\(snode.port)/storage_rpc/v1" - return HTTP.execute(.post, url, parameters: parameters).map2 { $0 as Any }.recover2 { error -> Promise in - guard case HTTP.Error.httpRequestFailed(let statusCode, let json) = error else { throw error } - throw SnodeAPI.handleError(withStatusCode: statusCode, json: json, forSnode: snode, associatedWith: publicKey) ?? error - } + return HTTP.execute(.post, url, parameters: parameters) + .recover2 { error -> Promise in + guard case HTTP.Error.httpRequestFailed(let statusCode, let data) = error else { throw error } + throw SnodeAPI.handleError(withStatusCode: statusCode, data: data, forSnode: snode, associatedWith: publicKey) ?? error + } } } private static func getNetworkTime(from snode: Snode) -> Promise { - return invoke(.getInfo, on: snode, parameters: [:]).map2 { rawResponse in - guard let json = rawResponse as? JSON, - let timestamp = json["timestamp"] as? UInt64 else { throw HTTP.Error.invalidJSON } + return invoke(.getInfo, on: snode, parameters: [:]).map2 { responseData in + guard let responseJson: JSON = try? JSONSerialization.jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? JSON else { + throw HTTP.Error.invalidJSON + } + guard let timestamp = responseJson["timestamp"] as? UInt64 else { throw HTTP.Error.invalidJSON } return timestamp } } @@ -179,21 +170,27 @@ public final class SnodeAPI : NSObject { let (promise, seal) = Promise>.pending() Threading.workQueue.async { attempt(maxRetryCount: 4, recoveringOn: Threading.workQueue) { - HTTP.execute(.post, url, parameters: parameters, useSeedNodeURLSession: true).map2 { json -> Set in - guard let intermediate = json["result"] as? JSON, let rawSnodes = intermediate["service_node_states"] as? [JSON] else { throw Error.snodePoolUpdatingFailed } - return Set(rawSnodes.compactMap { rawSnode in - guard let address = rawSnode["public_ip"] as? String, let port = rawSnode["storage_port"] as? Int, - let ed25519PublicKey = rawSnode["pubkey_ed25519"] as? String, let x25519PublicKey = rawSnode["pubkey_x25519"] as? String, address != "0.0.0.0" else { - SNLog("Failed to parse snode from: \(rawSnode).") - return nil + HTTP.execute(.post, url, parameters: parameters, useSeedNodeURLSession: true) + .map2 { responseData -> Set in + guard let responseJson: JSON = try? JSONSerialization.jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? JSON else { + throw HTTP.Error.invalidJSON } - return Snode(address: "https://\(address)", port: UInt16(port), publicKeySet: Snode.KeySet(ed25519Key: ed25519PublicKey, x25519Key: x25519PublicKey)) - }) - } - }.done2 { snodePool in + guard let intermediate = responseJson["result"] as? JSON, let rawSnodes = intermediate["service_node_states"] as? [JSON] else { throw Error.snodePoolUpdatingFailed } + return Set(rawSnodes.compactMap { rawSnode in + guard let address = rawSnode["public_ip"] as? String, let port = rawSnode["storage_port"] as? Int, + let ed25519PublicKey = rawSnode["pubkey_ed25519"] as? String, let x25519PublicKey = rawSnode["pubkey_x25519"] as? String, address != "0.0.0.0" else { + SNLog("Failed to parse snode from: \(rawSnode).") + return nil + } + return Snode(address: "https://\(address)", port: UInt16(port), publicKeySet: Snode.KeySet(ed25519Key: ed25519PublicKey, x25519Key: x25519PublicKey)) + }) + } + } + .done2 { snodePool in SNLog("Got snode pool from seed node: \(target).") seal.fulfill(snodePool) - }.catch2 { error in + } + .catch2 { error in SNLog("Failed to contact seed node at: \(target).") seal.reject(error) } @@ -223,20 +220,24 @@ public final class SnodeAPI : NSObject { ] ] ] - return invoke(.oxenDaemonRPCCall, on: snode, parameters: parameters).map2 { rawResponse in - guard let json = rawResponse as? JSON, let intermediate = json["result"] as? JSON, - let rawSnodes = intermediate["service_node_states"] as? [JSON] else { - throw Error.snodePoolUpdatingFailed - } - return Set(rawSnodes.compactMap { rawSnode in - guard let address = rawSnode["public_ip"] as? String, let port = rawSnode["storage_port"] as? Int, - let ed25519PublicKey = rawSnode["pubkey_ed25519"] as? String, let x25519PublicKey = rawSnode["pubkey_x25519"] as? String, address != "0.0.0.0" else { - SNLog("Failed to parse snode from: \(rawSnode).") - return nil + return invoke(.oxenDaemonRPCCall, on: snode, parameters: parameters) + .map2 { responseData in + guard let responseJson: JSON = try? JSONSerialization.jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? JSON else { + throw HTTP.Error.invalidJSON } - return Snode(address: "https://\(address)", port: UInt16(port), publicKeySet: Snode.KeySet(ed25519Key: ed25519PublicKey, x25519Key: x25519PublicKey)) - }) - } + guard let intermediate = responseJson["result"] as? JSON, + let rawSnodes = intermediate["service_node_states"] as? [JSON] else { + throw Error.snodePoolUpdatingFailed + } + return Set(rawSnodes.compactMap { rawSnode in + guard let address = rawSnode["public_ip"] as? String, let port = rawSnode["storage_port"] as? Int, + let ed25519PublicKey = rawSnode["pubkey_ed25519"] as? String, let x25519PublicKey = rawSnode["pubkey_x25519"] as? String, address != "0.0.0.0" else { + SNLog("Failed to parse snode from: \(rawSnode).") + return nil + } + return Snode(address: "https://\(address)", port: UInt16(port), publicKeySet: Snode.KeySet(ed25519Key: ed25519PublicKey, x25519Key: x25519PublicKey)) + }) + } } } let promise = when(fulfilled: snodePoolPromises).map2 { results -> Set in @@ -336,8 +337,11 @@ public final class SnodeAPI : NSObject { for result in results { switch result { case .rejected(let error): return seal.reject(error) - case .fulfilled(let rawResponse): - guard let json = rawResponse as? JSON, let intermediate = json["result"] as? JSON, + case .fulfilled(let responseData): + guard let responseJson: JSON = try? JSONSerialization.jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? JSON else { + throw HTTP.Error.invalidJSON + } + guard let intermediate = responseJson["result"] as? JSON, let hexEncodedCiphertext = intermediate["encrypted_value"] as? String else { return seal.reject(HTTP.Error.invalidJSON) } let ciphertext = [UInt8](Data(hex: hexEncodedCiphertext)) let isArgon2Based = (intermediate["nonce"] == nil) @@ -390,37 +394,23 @@ public final class SnodeAPI : NSObject { attempt(maxRetryCount: 4, recoveringOn: Threading.workQueue) { invoke(.getSwarm, on: snode, associatedWith: publicKey, parameters: parameters) } - }.map2 { rawSnodes in - let swarm = parseSnodes(from: rawSnodes) + }.map2 { responseData in + let swarm = parseSnodes(from: responseData) setSwarm(to: swarm, for: publicKey) return swarm } } } - public static func getRawMessages(from snode: Snode, associatedWith publicKey: String) -> RawResponsePromise { - let (promise, seal) = RawResponsePromise.pending() + public static func getRawMessages(from snode: Snode, associatedWith publicKey: String) -> Promise { + let (promise, seal) = Promise.pending() Threading.workQueue.async { getMessagesInternal(from: snode, associatedWith: publicKey).done2 { seal.fulfill($0) }.catch2 { seal.reject($0) } } return promise } - - public static func getMessages(for publicKey: String) -> Promise> { - let (promise, seal) = Promise>.pending() - Threading.workQueue.async { - attempt(maxRetryCount: maxRetryCount, recoveringOn: Threading.workQueue) { - getTargetSnodes(for: publicKey).mapValues2 { targetSnode in - return getMessagesInternal(from: targetSnode, associatedWith: publicKey).map2 { rawResponse in - parseRawMessagesResponse(rawResponse, from: targetSnode, associatedWith: publicKey) - } - }.map2 { Set($0) } - }.done2 { seal.fulfill($0) }.catch2 { seal.reject($0) } - } - return promise - } - private static func getMessagesInternal(from snode: Snode, associatedWith publicKey: String) -> RawResponsePromise { + private static func getMessagesInternal(from snode: Snode, associatedWith publicKey: String) -> Promise { let storage = SNSnodeKitConfiguration.shared.storage // NOTE: All authentication logic is currently commented out, the reason being that we can't currently support @@ -447,18 +437,21 @@ public final class SnodeAPI : NSObject { return invoke(.getMessages, on: snode, associatedWith: publicKey, parameters: parameters) } - public static func sendMessage(_ message: SnodeMessage) -> Promise> { - let (promise, seal) = Promise>.pending() + public static func sendMessage(_ message: SnodeMessage) -> Promise>> { + let (promise, seal) = Promise>>.pending() let publicKey = Features.useTestnet ? message.recipient.removingIdPrefixIfNeeded() : message.recipient Threading.workQueue.async { - getTargetSnodes(for: publicKey).map2 { targetSnodes in - let parameters = message.toJSON() - return Set(targetSnodes.map { targetSnode in - attempt(maxRetryCount: maxRetryCount, recoveringOn: Threading.workQueue) { - invoke(.sendMessage, on: targetSnode, associatedWith: publicKey, parameters: parameters) - } - }) - }.done2 { seal.fulfill($0) }.catch2 { seal.reject($0) } + getTargetSnodes(for: publicKey) + .map2 { targetSnodes in + let parameters = message.toJSON() + return Set(targetSnodes.map { targetSnode in + attempt(maxRetryCount: maxRetryCount, recoveringOn: Threading.workQueue) { + invoke(.sendMessage, on: targetSnode, associatedWith: publicKey, parameters: parameters) + } + }) + } + .done2 { seal.fulfill($0) } + .catch2 { seal.reject($0) } } return promise } @@ -485,29 +478,34 @@ public final class SnodeAPI : NSObject { "signature": signature.toBase64() ] return attempt(maxRetryCount: maxRetryCount, recoveringOn: Threading.workQueue) { - invoke(.deleteMessage, on: snode, associatedWith: publicKey, parameters: parameters).map2{ rawResponse -> [String:Bool] in - guard let json = rawResponse as? JSON, let swarm = json["swarm"] as? JSON else { throw HTTP.Error.invalidJSON } - var result: [String:Bool] = [:] - for (snodePublicKey, rawJSON) in swarm { - guard let json = rawJSON as? JSON else { throw HTTP.Error.invalidJSON } - let isFailed = json["failed"] as? Bool ?? false - if !isFailed { - guard let hashes = json["deleted"] as? [String], let signature = json["signature"] as? String else { throw HTTP.Error.invalidJSON } - // The signature format is ( PUBKEY_HEX || RMSG[0] || ... || RMSG[N] || DMSG[0] || ... || DMSG[M] ) - let verificationData = (userX25519PublicKey + serverHashes.joined(separator: "") + hashes.joined(separator: "")).data(using: String.Encoding.utf8)! - let isValid = sodium.sign.verify(message: Bytes(verificationData), publicKey: Bytes(Data(hex: snodePublicKey)), signature: Bytes(Data(base64Encoded: signature)!)) - result[snodePublicKey] = isValid - } else { - if let reason = json["reason"] as? String, let statusCode = json["code"] as? String { - SNLog("Couldn't delete data from: \(snodePublicKey) due to error: \(reason) (\(statusCode)).") - } else { - SNLog("Couldn't delete data from: \(snodePublicKey).") - } - result[snodePublicKey] = false + invoke(.deleteMessage, on: snode, associatedWith: publicKey, parameters: parameters) + .map2 { responseData -> [String: Bool] in + guard let responseJson: JSON = try? JSONSerialization.jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? JSON else { + throw HTTP.Error.invalidJSON } + guard let swarm = responseJson["swarm"] as? JSON else { throw HTTP.Error.invalidJSON } + + var result: [String: Bool] = [:] + for (snodePublicKey, rawJSON) in swarm { + guard let json = rawJSON as? JSON else { throw HTTP.Error.invalidJSON } + let isFailed = json["failed"] as? Bool ?? false + if !isFailed { + guard let hashes = json["deleted"] as? [String], let signature = json["signature"] as? String else { throw HTTP.Error.invalidJSON } + // The signature format is ( PUBKEY_HEX || RMSG[0] || ... || RMSG[N] || DMSG[0] || ... || DMSG[M] ) + let verificationData = (userX25519PublicKey + serverHashes.joined(separator: "") + hashes.joined(separator: "")).data(using: String.Encoding.utf8)! + let isValid = sodium.sign.verify(message: Bytes(verificationData), publicKey: Bytes(Data(hex: snodePublicKey)), signature: Bytes(Data(base64Encoded: signature)!)) + result[snodePublicKey] = isValid + } else { + if let reason = json["reason"] as? String, let statusCode = json["code"] as? String { + SNLog("Couldn't delete data from: \(snodePublicKey) due to error: \(reason) (\(statusCode)).") + } else { + SNLog("Couldn't delete data from: \(snodePublicKey).") + } + result[snodePublicKey] = false + } + } + return result } - return result - } } } } @@ -532,29 +530,36 @@ public final class SnodeAPI : NSObject { "signature" : signature.toBase64() ] return attempt(maxRetryCount: maxRetryCount, recoveringOn: Threading.workQueue) { - invoke(.clearAllData, on: snode, parameters: parameters).map2 { rawResponse -> [String:Bool] in - guard let json = rawResponse as? JSON, let swarm = json["swarm"] as? JSON else { throw HTTP.Error.invalidJSON } - var result: [String:Bool] = [:] - for (snodePublicKey, rawJSON) in swarm { - guard let json = rawJSON as? JSON else { throw HTTP.Error.invalidJSON } - let isFailed = json["failed"] as? Bool ?? false - if !isFailed { - guard let hashes = json["deleted"] as? [String], let signature = json["signature"] as? String else { throw HTTP.Error.invalidJSON } - // The signature format is ( PUBKEY_HEX || TIMESTAMP || DELETEDHASH[0] || ... || DELETEDHASH[N] ) - let verificationData = (userX25519PublicKey + String(timestamp) + hashes.joined(separator: "")).data(using: String.Encoding.utf8)! - let isValid = sodium.sign.verify(message: Bytes(verificationData), publicKey: Bytes(Data(hex: snodePublicKey)), signature: Bytes(Data(base64Encoded: signature)!)) - result[snodePublicKey] = isValid - } else { - if let reason = json["reason"] as? String, let statusCode = json["code"] as? String { - SNLog("Couldn't delete data from: \(snodePublicKey) due to error: \(reason) (\(statusCode)).") - } else { - SNLog("Couldn't delete data from: \(snodePublicKey).") - } - result[snodePublicKey] = false + invoke(.clearAllData, on: snode, parameters: parameters) + .map2 { responseData -> [String: Bool] in + guard let responseJson: JSON = try? JSONSerialization.jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? JSON else { + throw HTTP.Error.invalidJSON } + guard let swarm = responseJson["swarm"] as? JSON else { throw HTTP.Error.invalidJSON } + + var result: [String: Bool] = [:] + + for (snodePublicKey, rawJSON) in swarm { + guard let json = rawJSON as? JSON else { throw HTTP.Error.invalidJSON } + let isFailed = json["failed"] as? Bool ?? false + if !isFailed { + guard let hashes = json["deleted"] as? [String], let signature = json["signature"] as? String else { throw HTTP.Error.invalidJSON } + // The signature format is ( PUBKEY_HEX || TIMESTAMP || DELETEDHASH[0] || ... || DELETEDHASH[N] ) + let verificationData = (userX25519PublicKey + String(timestamp) + hashes.joined(separator: "")).data(using: String.Encoding.utf8)! + let isValid = sodium.sign.verify(message: Bytes(verificationData), publicKey: Bytes(Data(hex: snodePublicKey)), signature: Bytes(Data(base64Encoded: signature)!)) + result[snodePublicKey] = isValid + } else { + if let reason = json["reason"] as? String, let statusCode = json["code"] as? String { + SNLog("Couldn't delete data from: \(snodePublicKey) due to error: \(reason) (\(statusCode)).") + } else { + SNLog("Couldn't delete data from: \(snodePublicKey).") + } + result[snodePublicKey] = false + } + } + + return result } - return result - } } } } @@ -566,9 +571,13 @@ public final class SnodeAPI : NSObject { // The parsing utilities below use a best attempt approach to parsing; they warn for parsing failures but don't throw exceptions. - private static func parseSnodes(from rawResponse: Any) -> Set { - guard let json = rawResponse as? JSON, let rawSnodes = json["snodes"] as? [JSON] else { - SNLog("Failed to parse snodes from: \(rawResponse).") + private static func parseSnodes(from responseData: Data) -> Set { + guard let responseJson: JSON = try? JSONSerialization.jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? JSON else { + SNLog("Failed to parse snodes from response data.") + return [] + } + guard let rawSnodes = responseJson["snodes"] as? [JSON] else { + SNLog("Failed to parse snodes from: \(responseJson).") return [] } return Set(rawSnodes.compactMap { rawSnode in @@ -581,8 +590,11 @@ public final class SnodeAPI : NSObject { }) } - public static func parseRawMessagesResponse(_ rawResponse: Any, from snode: Snode, associatedWith publicKey: String) -> [JSON] { - guard let json = rawResponse as? JSON, let rawMessages = json["messages"] as? [JSON] else { return [] } + public static func parseRawMessagesResponse(_ responseData: Data, from snode: Snode, associatedWith publicKey: String) -> [JSON] { + guard let responseJson: JSON = try? JSONSerialization.jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? JSON else { + return [] + } + guard let rawMessages = responseJson["messages"] as? [JSON] else { return [] } updateLastMessageHashValueIfPossible(for: snode, associatedWith: publicKey, from: rawMessages) return removeDuplicates(from: rawMessages, associatedWith: publicKey) } @@ -622,7 +634,7 @@ public final class SnodeAPI : NSObject { // MARK: Error Handling /// - Note: Should only be invoked from `Threading.workQueue` to avoid race conditions. @discardableResult - internal static func handleError(withStatusCode statusCode: UInt, json: JSON?, forSnode snode: Snode, associatedWith publicKey: String? = nil) -> Error? { + internal static func handleError(withStatusCode statusCode: UInt, data: Data?, forSnode snode: Snode, associatedWith publicKey: String? = nil) -> Error? { #if DEBUG dispatchPrecondition(condition: .onQueue(Threading.workQueue)) #endif @@ -641,37 +653,47 @@ public final class SnodeAPI : NSObject { SnodeAPI.snodeFailureCount[snode] = 0 } } + switch statusCode { - case 500, 502, 503: - // The snode is unreachable - handleBadSnode() - case 406: - SNLog("The user's clock is out of sync with the service node network.") - return Error.clockOutOfSync - case 421: - // The snode isn't associated with the given public key anymore - if let publicKey = publicKey { - func invalidateSwarm() { - SNLog("Invalidating swarm for: \(publicKey).") - SnodeAPI.dropSnodeFromSwarmIfNeeded(snode, publicKey: publicKey) - } - if let json = json { - let snodes = parseSnodes(from: json) - if !snodes.isEmpty { - setSwarm(to: snodes, for: publicKey) - } else { + case 500, 502, 503: + // The snode is unreachable + handleBadSnode() + + case 406: + SNLog("The user's clock is out of sync with the service node network.") + return Error.clockOutOfSync + + case 421: + // The snode isn't associated with the given public key anymore + if let publicKey = publicKey { + func invalidateSwarm() { + SNLog("Invalidating swarm for: \(publicKey).") + SnodeAPI.dropSnodeFromSwarmIfNeeded(snode, publicKey: publicKey) + } + + if let data: Data = data { + let snodes = parseSnodes(from: data) + + if !snodes.isEmpty { + setSwarm(to: snodes, for: publicKey) + } + else { + invalidateSwarm() + } + } + else { invalidateSwarm() } - } else { - invalidateSwarm() } - } else { - SNLog("Got a 421 without an associated public key.") - } - default: - handleBadSnode() - SNLog("Unhandled response code: \(statusCode).") + else { + SNLog("Got a 421 without an associated public key.") + } + + default: + handleBadSnode() + SNLog("Unhandled response code: \(statusCode).") } + return nil } } diff --git a/SessionUtilitiesKit/Networking/HTTP.swift b/SessionUtilitiesKit/Networking/HTTP.swift index 6d8b7d45b..a13fffbb9 100644 --- a/SessionUtilitiesKit/Networking/HTTP.swift +++ b/SessionUtilitiesKit/Networking/HTTP.swift @@ -67,7 +67,8 @@ public enum HTTP { } } - // MARK: Verb + // MARK: - Verb + public enum Verb: String, Codable { case get = "GET" case put = "PUT" @@ -75,92 +76,47 @@ public enum HTTP { case delete = "DELETE" } - // MARK: Error + // MARK: - Error + public enum Error : LocalizedError { case generic - case httpRequestFailed(statusCode: UInt, json: JSON?) + case httpRequestFailed(statusCode: UInt, data: Data?) case invalidJSON case invalidResponse public var errorDescription: String? { switch self { - case .generic: return "An error occurred." - case .httpRequestFailed(let statusCode, _): return "HTTP request failed with status code: \(statusCode)." - case .invalidJSON: return "Invalid JSON." - case .invalidResponse: return "Invalid Response" + case .generic: return "An error occurred." + case .httpRequestFailed(let statusCode, _): return "HTTP request failed with status code: \(statusCode)." + case .invalidJSON: return "Invalid JSON." + case .invalidResponse: return "Invalid Response" } } } - // MARK: Main - public static func execute(_ verb: Verb, _ url: String, timeout: TimeInterval = HTTP.timeout, useSeedNodeURLSession: Bool = false) -> Promise { + // MARK: - Main + + public static func execute(_ verb: Verb, _ url: String, timeout: TimeInterval = HTTP.timeout, useSeedNodeURLSession: Bool = false) -> Promise { return execute(verb, url, body: nil, timeout: timeout, useSeedNodeURLSession: useSeedNodeURLSession) } - public static func execute(_ verb: Verb, _ url: String, parameters: JSON?, timeout: TimeInterval = HTTP.timeout, useSeedNodeURLSession: Bool = false) -> Promise { + public static func execute(_ verb: Verb, _ url: String, parameters: JSON?, timeout: TimeInterval = HTTP.timeout, useSeedNodeURLSession: Bool = false) -> Promise { if let parameters = parameters { do { guard JSONSerialization.isValidJSONObject(parameters) else { return Promise(error: Error.invalidJSON) } let body = try JSONSerialization.data(withJSONObject: parameters, options: [ .fragmentsAllowed ]) return execute(verb, url, body: body, timeout: timeout, useSeedNodeURLSession: useSeedNodeURLSession) - } catch (let error) { + } + catch (let error) { return Promise(error: error) } - } else { + } + else { return execute(verb, url, body: nil, timeout: timeout, useSeedNodeURLSession: useSeedNodeURLSession) } } - public static func execute(_ verb: Verb, _ url: String, body: Data?, timeout: TimeInterval = HTTP.timeout, useSeedNodeURLSession: Bool = false) -> Promise { - var request = URLRequest(url: URL(string: url)!) - request.httpMethod = verb.rawValue - request.httpBody = body - request.timeoutInterval = timeout - request.allHTTPHeaderFields?.removeValue(forKey: "User-Agent") - request.setValue("WhatsApp", forHTTPHeaderField: "User-Agent") // Set a fake value - request.setValue("en-us", forHTTPHeaderField: "Accept-Language") // Set a fake value - let (promise, seal) = Promise.pending() - let urlSession = useSeedNodeURLSession ? seedNodeURLSession : snodeURLSession - let task = urlSession.dataTask(with: request) { data, response, error in - guard let data = data, let response = response as? HTTPURLResponse else { - if let error = error { - SNLog("\(verb.rawValue) request to \(url) failed due to error: \(error).") - } else { - SNLog("\(verb.rawValue) request to \(url) failed.") - } - // Override the actual error so that we can correctly catch failed requests in sendOnionRequest(invoking:on:with:) - return seal.reject(Error.httpRequestFailed(statusCode: 0, json: nil)) - } - if let error = error { - SNLog("\(verb.rawValue) request to \(url) failed due to error: \(error).") - // Override the actual error so that we can correctly catch failed requests in sendOnionRequest(invoking:on:with:) - return seal.reject(Error.httpRequestFailed(statusCode: 0, json: nil)) - } - let statusCode = UInt(response.statusCode) - var json: JSON? = nil - if let j = try? JSONSerialization.jsonObject(with: data, options: [ .fragmentsAllowed ]) as? JSON { - json = j - } else if let result = String(data: data, encoding: .utf8) { - json = [ "result" : result ] - } - guard 200...299 ~= statusCode else { - let jsonDescription = json?.prettifiedDescription ?? "no debugging info provided" - SNLog("\(verb.rawValue) request to \(url) failed with status code: \(statusCode) (\(jsonDescription)).") - return seal.reject(Error.httpRequestFailed(statusCode: statusCode, json: json)) - } - if let json = json { - seal.fulfill(json) - } else { - SNLog("Couldn't parse JSON returned by \(verb.rawValue) request to \(url).") - return seal.reject(Error.invalidJSON) - } - } - task.resume() - return promise - } - - // TODO: Consilidate the above and this method - public static func updatedExecute(_ verb: Verb, _ url: String, body: Data?, timeout: TimeInterval = HTTP.timeout, useSeedNodeURLSession: Bool = false) -> Promise { + public static func execute(_ verb: Verb, _ url: String, body: Data?, timeout: TimeInterval = HTTP.timeout, useSeedNodeURLSession: Bool = false) -> Promise { var request = URLRequest(url: URL(string: url)!) request.httpMethod = verb.rawValue request.httpBody = body @@ -178,21 +134,27 @@ public enum HTTP { SNLog("\(verb.rawValue) request to \(url) failed.") } // Override the actual error so that we can correctly catch failed requests in sendOnionRequest(invoking:on:with:) - return seal.reject(Error.httpRequestFailed(statusCode: 0, json: nil)) + return seal.reject(Error.httpRequestFailed(statusCode: 0, data: nil)) } if let error = error { SNLog("\(verb.rawValue) request to \(url) failed due to error: \(error).") // Override the actual error so that we can correctly catch failed requests in sendOnionRequest(invoking:on:with:) - return seal.reject(Error.httpRequestFailed(statusCode: 0, json: nil)) + return seal.reject(Error.httpRequestFailed(statusCode: 0, data: data)) } let statusCode = UInt(response.statusCode) guard 200...299 ~= statusCode else { -// let jsonDescription = json?.prettifiedDescription ?? "no debugging info provided" -// SNLog("\(verb.rawValue) request to \(url) failed with status code: \(statusCode) (\(jsonDescription)).") -// return seal.reject(Error.httpRequestFailed(statusCode: statusCode, json: json)) - // TODO: Provide error from backend here - return seal.reject(Error.httpRequestFailed(statusCode: statusCode, json: [:])) + var json: JSON? = nil + if let processedJson: JSON = try? JSONSerialization.jsonObject(with: data, options: [ .fragmentsAllowed ]) as? JSON { + json = processedJson + } + else if let result: String = String(data: data, encoding: .utf8) { + json = [ "result": result ] + } + + let jsonDescription: String = (json?.prettifiedDescription ?? "no debugging info provided") + SNLog("\(verb.rawValue) request to \(url) failed with status code: \(statusCode) (\(jsonDescription)).") + return seal.reject(Error.httpRequestFailed(statusCode: statusCode, data: data)) } seal.fulfill(data) From 8a7db1d48feeb69dabac102e12bb6c5c4c7adb1b Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 2 Mar 2022 14:44:56 +1100 Subject: [PATCH 022/157] Started adding logic for the outbox endpoint Moved the BlindedIdMapping retrieval logic to ContactUtilities so it's reusable Added the 'outbox' endpoints (need testing as they aren't deployed to test yet) --- Session.xcodeproj/project.pbxproj | 8 +- Session/Closed Groups/EditClosedGroupVC.swift | 1 + Session/Closed Groups/NewClosedGroupVC.swift | 1 + .../ConversationVC+Interaction.swift | 67 ++----------- Session/Shared/UserSelectionVC.swift | 1 + Session/Utilities/ContactUtilities.swift | 51 ---------- .../Database/Storage+OpenGroups.swift | 23 +++++ .../Open Groups/Models/DirectMessage.swift | 8 +- .../Open Groups/OpenGroupAPI.swift | 61 ++++++++++-- .../Open Groups/OpenGroupManager.swift | 57 +++++++++-- .../Open Groups/Types/Dependencies.swift | 20 ++-- .../Open Groups/Types/Endpoint.swift | 10 +- .../Pollers/OpenGroupPoller.swift | 12 ++- SessionMessagingKit/Storage.swift | 16 +++ .../Utilities/ContactUtilities.swift | 99 +++++++++++++++++++ 15 files changed, 288 insertions(+), 147 deletions(-) delete mode 100644 Session/Utilities/ContactUtilities.swift create mode 100644 SessionMessagingKit/Utilities/ContactUtilities.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 64c422af3..3d58ff168 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -298,7 +298,6 @@ C31A6C5A247F214E001123EF /* UIView+Glow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31A6C59247F214E001123EF /* UIView+Glow.swift */; }; C31A6C5C247F2CF3001123EF /* CGRect+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31A6C5B247F2CF3001123EF /* CGRect+Utilities.swift */; }; C31D1DE32521718E005D4DA8 /* UserSelectionVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31D1DE22521718E005D4DA8 /* UserSelectionVC.swift */; }; - C31D1DE9252172D4005D4DA8 /* ContactUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31D1DE8252172D4005D4DA8 /* ContactUtilities.swift */; }; C31FFE57254A5FFE00F19441 /* KeyPairUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31FFE56254A5FFE00F19441 /* KeyPairUtilities.swift */; }; C32824D325C9F9790062D0A7 /* SessionSnodeKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A59F255385C100C340D1 /* SessionSnodeKit.framework */; }; C328250F25CA06020062D0A7 /* VoiceMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C328250E25CA06020062D0A7 /* VoiceMessageView.swift */; }; @@ -784,6 +783,7 @@ FD705A92278D051200F16121 /* ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A91278D051200F16121 /* ReusableView.swift */; }; FD705A94278D052B00F16121 /* UITableView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A93278D052B00F16121 /* UITableView+ReusableView.swift */; }; FD705A98278E9F4D00F16121 /* UIColor+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A97278E9F4D00F16121 /* UIColor+Extensions.swift */; }; + FD83B9AA27CF149D005E1583 /* ContactUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9A927CF149D005E1583 /* ContactUtilities.swift */; }; FD859EF227BF6BA200510D0C /* Data+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF127BF6BA200510D0C /* Data+Utilities.swift */; }; FD859EF427C2F49200510D0C /* TestSodium.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF327C2F49200510D0C /* TestSodium.swift */; }; FD859EF627C2F52C00510D0C /* TestSign.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF527C2F52C00510D0C /* TestSign.swift */; }; @@ -1410,7 +1410,6 @@ C31A6C5B247F2CF3001123EF /* CGRect+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGRect+Utilities.swift"; sourceTree = ""; }; C31D1DDC25217014005D4DA8 /* UserCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCell.swift; sourceTree = ""; }; C31D1DE22521718E005D4DA8 /* UserSelectionVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSelectionVC.swift; sourceTree = ""; }; - C31D1DE8252172D4005D4DA8 /* ContactUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactUtilities.swift; sourceTree = ""; }; C31FFE56254A5FFE00F19441 /* KeyPairUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyPairUtilities.swift; sourceTree = ""; }; C328250E25CA06020062D0A7 /* VoiceMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageView.swift; sourceTree = ""; }; C328251E25CA3A900062D0A7 /* QuoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuoteView.swift; sourceTree = ""; }; @@ -1921,6 +1920,7 @@ FD705A91278D051200F16121 /* ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReusableView.swift; sourceTree = ""; }; FD705A93278D052B00F16121 /* UITableView+ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITableView+ReusableView.swift"; sourceTree = ""; }; FD705A97278E9F4D00F16121 /* UIColor+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Extensions.swift"; sourceTree = ""; }; + FD83B9A927CF149D005E1583 /* ContactUtilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContactUtilities.swift; sourceTree = ""; }; 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 = ""; }; @@ -2229,7 +2229,6 @@ B8544E3223D50E4900299F14 /* SNAppearance.swift */, C3D0972A2510499C00F6E3E4 /* BackgroundPoller.swift */, C31A6C5B247F2CF3001123EF /* CGRect+Utilities.swift */, - C31D1DE8252172D4005D4DA8 /* ContactUtilities.swift */, C35E8AAD2485E51D00ACB629 /* IP2Country.swift */, C31FFE56254A5FFE00F19441 /* KeyPairUtilities.swift */, B84664F4235022F30083A1CD /* MentionUtilities.swift */, @@ -3395,6 +3394,7 @@ C33FDB01255A580700E217F9 /* AppReadiness.h */, C33FDB75255A581000E217F9 /* AppReadiness.m */, FDC4383D27B4708600C60D73 /* Atomic.swift */, + FD83B9A927CF149D005E1583 /* ContactUtilities.swift */, FD859EF127BF6BA200510D0C /* Data+Utilities.swift */, C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */, C37F53E8255BA9BB002AEA92 /* Environment.h */, @@ -5218,6 +5218,7 @@ FDC4382C27B380E300C60D73 /* LegacyMemberCountResponse.swift in Sources */, B8B32033258B235D0020074B /* Storage+Contacts.swift in Sources */, B8856D69256F141F001CE70E /* OWSWindowManager.m in Sources */, + FD83B9AA27CF149D005E1583 /* ContactUtilities.swift in Sources */, C3D9E3BE25676AD70040E4F3 /* TSAttachmentPointer.m in Sources */, C3ECBF7B257056B700EA7FCE /* Threading.swift in Sources */, C32C5AAB256DBE8F003C73A2 /* TSIncomingMessage+Conversion.swift in Sources */, @@ -5446,7 +5447,6 @@ C3AAFFF225AE99710089E6DD /* AppDelegate.swift in Sources */, B8BB82A5238F627000BA5194 /* HomeVC.swift in Sources */, C31A6C5A247F214E001123EF /* UIView+Glow.swift in Sources */, - C31D1DE9252172D4005D4DA8 /* ContactUtilities.swift in Sources */, 4521C3C01F59F3BA00B4C582 /* TextFieldHelper.swift in Sources */, 340FC8AC204DAC8D007AEB0F /* PrivacySettingsTableViewController.m in Sources */, B8569AE325CBB19A00DBA3DB /* DocumentView.swift in Sources */, diff --git a/Session/Closed Groups/EditClosedGroupVC.swift b/Session/Closed Groups/EditClosedGroupVC.swift index 3a74dea3e..e84b333b5 100644 --- a/Session/Closed Groups/EditClosedGroupVC.swift +++ b/Session/Closed Groups/EditClosedGroupVC.swift @@ -1,4 +1,5 @@ import PromiseKit +import SessionMessagingKit @objc(SNEditClosedGroupVC) final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegate { diff --git a/Session/Closed Groups/NewClosedGroupVC.swift b/Session/Closed Groups/NewClosedGroupVC.swift index 47fbbff43..57ddd3f12 100644 --- a/Session/Closed Groups/NewClosedGroupVC.swift +++ b/Session/Closed Groups/NewClosedGroupVC.swift @@ -1,4 +1,5 @@ import PromiseKit +import SessionMessagingKit private protocol TableViewTouchDelegate { diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 432167228..fc5b3e072 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -4,6 +4,7 @@ import Photos import PhotosUI import Sodium import PromiseKit +import SessionMessagingKit import SessionUtilitiesKit import SignalUtilitiesKit @@ -866,68 +867,12 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc func startThread(with sessionId: String, openGroupServer: String, openGroupPublicKey: String) { // If the sessionId is blinded then check if there is an existing un-blinded thread with the contact - if SessionId.Prefix(from: sessionId) == .blinded { - // TODO: Ensure the above case isn't going to be an issue due to legacy messages? - // Unfortunately the whole point of id-blinding is to make it hard to reverse-engineer a standard - // sessionId, as a result in order to see if there is an unblinded contact for this blindedId we - // can only really generate blinded ids for each contact and check if any match - // - // Due to this we have made a few optimisations to try and early-out as often as possible, first - // we try to retrieve a direct cached mapping - if let mapping: BlindedIdMapping = Storage.shared.getBlindedIdMapping(with: sessionId) { - let thread: TSContactThread = TSContactThread.getOrCreateThread(contactSessionID: mapping.sessionId) - let conversationVC: ConversationVC = ConversationVC(thread: thread) - - self.navigationController?.pushViewController(conversationVC, animated: true) - return - } + if SessionId.Prefix(from: sessionId) == .blinded, let mapping: BlindedIdMapping = ContactUtilities.mapping(for: sessionId, serverPublicKey: openGroupPublicKey) { + let thread: TSContactThread = TSContactThread.getOrCreateThread(contactSessionID: mapping.sessionId) + let conversationVC: ConversationVC = ConversationVC(thread: thread) - var didFindContact: Bool = false - - // Then we try loop through all approved contact threads to see if one of those contacts can be blinded to match - ContactUtilities.enumerateApprovedContactThreads { contactThread, contact, stop in - guard Sodium().sessionId(contact.sessionID, matchesBlindedId: sessionId, serverPublicKey: openGroupPublicKey) else { - return - } - - // Cache the mapping - let mapping: BlindedIdMapping = BlindedIdMapping(blindedId: sessionId, sessionId: contact.sessionID, serverPublicKey: openGroupPublicKey) - Storage.shared.cacheBlindedIdMapping(mapping) - - // Open the existing thread - let conversationVC: ConversationVC = ConversationVC(thread: contactThread) - self.navigationController?.pushViewController(conversationVC, animated: true) - - didFindContact = true - stop.pointee = true - } - - // Don't continue if we found the contact - guard !didFindContact else { return } - - // Lastly loop through existing id mappings (in case the user is looking at a different SOGS but once had - // a thread with this contact in a different SOGS and had cached the mapping) - Storage.shared.enumerateBlindedIdMapping { mapping, stop in - guard mapping.serverPublicKey != openGroupPublicKey else { return } - guard Sodium().sessionId(mapping.sessionId, matchesBlindedId: sessionId, serverPublicKey: openGroupPublicKey) else { - return - } - - // Cache the new mapping - let thread: TSContactThread = TSContactThread.getOrCreateThread(contactSessionID: mapping.sessionId) - let newMapping: BlindedIdMapping = BlindedIdMapping(blindedId: sessionId, sessionId: mapping.sessionId, serverPublicKey: openGroupPublicKey) - Storage.shared.cacheBlindedIdMapping(newMapping) - - // Open the existing thread - let conversationVC: ConversationVC = ConversationVC(thread: thread) - self.navigationController?.pushViewController(conversationVC, animated: true) - - didFindContact = true - stop.pointee = true - } - - // Don't continue if we found the contact - guard !didFindContact else { return } + self.navigationController?.pushViewController(conversationVC, animated: true) + return } // Just create a new thread with the provided sessionId diff --git a/Session/Shared/UserSelectionVC.swift b/Session/Shared/UserSelectionVC.swift index b62104da3..88ce0abb2 100644 --- a/Session/Shared/UserSelectionVC.swift +++ b/Session/Shared/UserSelectionVC.swift @@ -1,3 +1,4 @@ +import SessionMessagingKit @objc(SNUserSelectionVC) final class UserSelectionVC : BaseVC, UITableViewDataSource, UITableViewDelegate { diff --git a/Session/Utilities/ContactUtilities.swift b/Session/Utilities/ContactUtilities.swift deleted file mode 100644 index 6c7857374..000000000 --- a/Session/Utilities/ContactUtilities.swift +++ /dev/null @@ -1,51 +0,0 @@ - -enum ContactUtilities { - private static func approvedContact(in threadObject: Any, using transaction: Any) -> Contact? { - guard let thread: TSContactThread = threadObject as? TSContactThread else { return nil } - guard thread.shouldBeVisible else { return nil } - guard let contact: Contact = Storage.shared.getContact(with: thread.contactSessionID(), using: transaction) else { - return nil - } - guard contact.didApproveMe else { return nil } - - return contact - } - - static func getAllContacts() -> [String] { - // Collect all contacts - var result: [Contact] = [] - Storage.read { transaction in - TSContactThread.enumerateCollectionObjects(with: transaction) { object, _ in - guard let contact: Contact = approvedContact(in: object, using: transaction) else { return } - - result.append(contact) - } - } - func getDisplayName(for publicKey: String) -> String { - return Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey - } - - // Remove the current user - if let index = result.firstIndex(where: { $0.sessionID == getUserHexEncodedPublicKey() }) { - result.remove(at: index) - } - - // Sort alphabetically - return result - .sorted(by: { lhs, rhs in - (lhs.displayName(for: .regular) ?? lhs.sessionID) < (rhs.displayName(for: .regular) ?? rhs.sessionID) - }) - .map { $0.sessionID } - } - - static func enumerateApprovedContactThreads(with block: @escaping (TSContactThread, Contact, UnsafeMutablePointer) -> ()) { - Storage.read { transaction in - TSContactThread.enumerateCollectionObjects(with: transaction) { object, stop in - guard let contactThread: TSContactThread = object as? TSContactThread else { return } - guard let contact: Contact = approvedContact(in: object, using: transaction) else { return } - - block(contactThread, contact, stop) - } - } - } -} diff --git a/SessionMessagingKit/Database/Storage+OpenGroups.swift b/SessionMessagingKit/Database/Storage+OpenGroups.swift index 5031bfe69..f3b7cf933 100644 --- a/SessionMessagingKit/Database/Storage+OpenGroups.swift +++ b/SessionMessagingKit/Database/Storage+OpenGroups.swift @@ -131,6 +131,29 @@ extension Storage { let collection = Storage.openGroupInboxLatestMessageIdCollection (transaction as! YapDatabaseReadWriteTransaction).removeObject(forKey: server, inCollection: collection) } + + // MARK: - -- Open Group Outbox Latest Message Id + + public static let openGroupOutboxLatestMessageIdCollection = "SNOpenGroupOutboxLatestMessageIdCollection" + + public func getOpenGroupOutboxLatestMessageId(for server: String) -> Int64? { + let collection = Storage.openGroupOutboxLatestMessageIdCollection + var result: Int64? = nil + Storage.read { transaction in + result = transaction.object(forKey: server, inCollection: collection) as? Int64 + } + return result + } + + public func setOpenGroupOutboxLatestMessageId(for server: String, to newValue: Int64, using transaction: Any) { + let collection = Storage.openGroupOutboxLatestMessageIdCollection + (transaction as! YapDatabaseReadWriteTransaction).setObject(newValue, forKey: server, inCollection: collection) + } + + public func removeOpenGroupOutboxLatestMessageId(for server: String, using transaction: Any) { + let collection = Storage.openGroupOutboxLatestMessageIdCollection + (transaction as! YapDatabaseReadWriteTransaction).removeObject(forKey: server, inCollection: collection) + } // MARK: - Metadata diff --git a/SessionMessagingKit/Open Groups/Models/DirectMessage.swift b/SessionMessagingKit/Open Groups/Models/DirectMessage.swift index 8ea11aaf1..941f21b7e 100644 --- a/SessionMessagingKit/Open Groups/Models/DirectMessage.swift +++ b/SessionMessagingKit/Open Groups/Models/DirectMessage.swift @@ -7,6 +7,7 @@ extension OpenGroupAPI { enum CodingKeys: String, CodingKey { case id case sender + case recipient case posted = "posted_at" case expires = "expires_at" case base64EncodedMessage = "message" @@ -15,8 +16,11 @@ extension OpenGroupAPI { /// The unique integer message id public let id: Int64 - /// The (blinded) Session ID of the sender of the message - public let sender: String + /// The (blinded) Session ID of the sender of the message (null on outgoing messages) + public let sender: String? + + /// The (blinded) Session ID of the recipient of the message (null on incoming message) + public let recipient: String? /// Unix timestamp when the message was received by SOGS public let posted: TimeInterval diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index 35a90cbcf..ff06432da 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -41,12 +41,15 @@ public final class OpenGroupAPI: NSObject { /// - Poll Info /// - Messages (includes additions and deletions) /// - Inbox for the server + /// - Outbox for the server public static func poll(_ server: String, using dependencies: Dependencies = Dependencies()) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable?)]> { // Store a local copy of the cached state for this server let hadPerformedInitialPoll: Bool = (hasPerformedInitialPoll.wrappedValue[server] == true) let originalTimeSinceLastPoll: TimeInterval = (timeSinceLastPoll.wrappedValue[server] ?? min(lastPollTime.wrappedValue, timeSinceLastOpen.wrappedValue)) let maybeLastInboxMessageId: Int64? = dependencies.storage.getOpenGroupInboxLatestMessageId(for: server) + let maybeLastOutboxMessageId: Int64? = dependencies.storage.getOpenGroupOutboxLatestMessageId(for: server) let lastInboxMessageId: Int64 = (maybeLastInboxMessageId ?? 0) + let lastOutboxMessageId: Int64 = (maybeLastOutboxMessageId ?? 0) // Update the cached state for this server hasPerformedInitialPoll.mutate { $0[server] = true } @@ -95,15 +98,13 @@ public final class OpenGroupAPI: NSObject { .roomMessagesRecent(openGroup.room) : .roomMessagesSince(openGroup.room, seqNo: targetSeqNo) ) - // TODO: Limit? -// queryParameters: [ .limit: 256 ] ), responseType: [Message].self ) ] } ) - .appending( + .appending([ // Inbox BatchRequestInfo( request: Request( @@ -112,12 +113,22 @@ public final class OpenGroupAPI: NSObject { .inbox : .inboxSince(id: lastInboxMessageId) ) - // TODO: Limit? -// queryParameters: [ .limit: 256 ] ), responseType: [DirectMessage]?.self // 'inboxSince' will return a `304` with an empty response if no messages + ), + + // Outbox + BatchRequestInfo( + request: Request( + server: server, + endpoint: (maybeLastOutboxMessageId == nil ? + .outbox : + .outboxSince(id: lastOutboxMessageId) + ) + ), + responseType: [DirectMessage]?.self // 'outboxSince' will return a `304` with an empty response if no messages ) - ) + ]) return batch(server, requests: requestResponseType, using: dependencies) } @@ -499,13 +510,13 @@ public final class OpenGroupAPI: NSObject { .decoded(as: FileDownloadResponse.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) } - // MARK: - Inbox (Message Requests) + // MARK: - Inbox/Outbox (Message Requests) /// Retrieves all of the user's current DMs (up to limit) /// /// **Note:** This is the direct request to retrieve DMs for a specific Open Group so should be retrieved automatically from the `poll()` /// method, in order to call this directly remove the `@available` line and make sure to route the response of this method to the - /// `OpenGroupManager.handleInbox` method to ensure things are processed correctly + /// `OpenGroupManager.handleDirectMessages` method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-build `poll()` method instead") public static func inbox(on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> { let request: Request = Request( @@ -521,7 +532,7 @@ public final class OpenGroupAPI: NSObject { /// /// **Note:** This is the direct request to retrieve messages requests for a specific Open Group since a given messages so should be retrieved /// automatically from the `poll()` method, in order to call this directly remove the `@available` line and make sure to route the response - /// of this method to the `OpenGroupManager.handleInbox` method to ensure things are processed correctly + /// of this method to the `OpenGroupManager.handleDirectMessages` method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-build `poll()` method instead") public static func inboxSince(id: Int64, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> { let request: Request = Request( @@ -551,6 +562,38 @@ public final class OpenGroupAPI: NSObject { return send(request, using: dependencies) } + /// Retrieves all of the user's sent DMs (up to limit) + /// + /// **Note:** This is the direct request to retrieve DMs sent by the user for a specific Open Group so should be retrieved automatically + /// from the `poll()` method, in order to call this directly remove the `@available` line and make sure to route the response of + /// this method to the `OpenGroupManager.handleDirectMessages` method to ensure things are processed correctly + @available(*, unavailable, message: "Avoid using this directly, use the pre-build `poll()` method instead") + public static func outbox(on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> { + let request: Request = Request( + server: server, + endpoint: .outbox + ) + + return send(request, using: dependencies) + .decoded(as: [DirectMessage]?.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) + } + + /// Polls for any DMs sent since the given id, this method will return a `304` with an empty response if there are no messages + /// + /// **Note:** This is the direct request to retrieve messages requests sent by the user for a specific Open Group since a given messages so + /// should be retrieved automatically from the `poll()` method, in order to call this directly remove the `@available` line and make sure + /// to route the response of this method to the `OpenGroupManager.handleDirectMessages` method to ensure things are processed correctly + @available(*, unavailable, message: "Avoid using this directly, use the pre-build `poll()` method instead") + public static func outboxSince(id: Int64, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> { + let request: Request = Request( + server: server, + endpoint: .outboxSince(id: id) + ) + + return send(request, using: dependencies) + .decoded(as: [DirectMessage]?.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) + } + // MARK: - Users /// Applies a ban of a user from specific rooms, or from the server globally diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 37d5e339a..e80a03dd7 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -248,7 +248,7 @@ public final class OpenGroupManager: NSObject { maybeUpdatedModel = updatedModel let updatedOpenGroup: OpenGroup = OpenGroup( server: server, - room: (pollInfo.token ?? roomToken), + room: pollInfo.token, publicKey: publicKey, name: (pollInfo.details?.name ?? thread.name()), groupDescription: (pollInfo.details?.description ?? existingOpenGroup?.description), @@ -311,8 +311,11 @@ public final class OpenGroupManager: NSObject { ) } - internal static func handleInbox( + internal static func handleDirectMessages( _ messages: [OpenGroupAPI.DirectMessage], + // We could infer where the messages come from based on their sender/recipient values but being since they + // are different endpoints being explicit here reduces the chance a future change will break things + fromOutbox: Bool, on server: String, isBackgroundPoll: Bool, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies() @@ -329,10 +332,17 @@ public final class OpenGroupManager: NSObject { let sortedMessages: [OpenGroupAPI.DirectMessage] = messages .sorted { lhs, rhs in lhs.id < rhs.id } let latestMessageId: Int64 = (sortedMessages.last?.id ?? 0) + let userSessionId: String = getUserHexEncodedPublicKey() + var mappingCache: [String: BlindedIdMapping] = [:] dependencies.storage.write { transaction in // Update the 'latestMessageId' value - dependencies.storage.setOpenGroupInboxLatestMessageId(for: server, to: latestMessageId, using: transaction) + if fromOutbox { + dependencies.storage.setOpenGroupOutboxLatestMessageId(for: server, to: latestMessageId, using: transaction) + } + else { + dependencies.storage.setOpenGroupInboxLatestMessageId(for: server, to: latestMessageId, using: transaction) + } // Process the messages sortedMessages.forEach { message in @@ -344,12 +354,47 @@ public final class OpenGroupManager: NSObject { // Note: The `posted` value is in seconds but all messages in the database use milliseconds for timestamps let envelope = SNProtoEnvelope.builder(type: .sessionMessage, timestamp: UInt64(floor(message.posted * 1000))) envelope.setContent(messageData) - envelope.setSource(message.sender) + envelope.setSource(message.sender ?? userSessionId) // Outbox messages have no 'sender' so default to current user do { let data = try envelope.buildSerializedData() - let (message, proto) = try MessageReceiver.parse(data, openGroupMessageServerID: nil, openGroupServerPublicKey: serverPublicKey, isRetry: false, using: transaction) - try MessageReceiver.handle(message, associatedWithProto: proto, openGroupID: nil, isBackgroundPoll: isBackgroundPoll, using: transaction) + let (receivedMessage, proto) = try MessageReceiver.parse(data, openGroupMessageServerID: nil, openGroupServerPublicKey: serverPublicKey, isRetry: false, using: transaction) + + // TODO: Need to test and validate this unblinding logic + // If the message was an outgoing message then attempt to unblind the recipient (this will help put + // messages in the correct thread in case of message request approval race conditions as well as + // during device sync'ing and restoration) + if fromOutbox, let recipientBlindedId: String = message.recipient { + // Attempt to un-blind the 'message.recipient' + let mapping: BlindedIdMapping + + // Minor optimisation to avoid processing the same sender multiple times + if let result: BlindedIdMapping = mappingCache[recipientBlindedId] { + mapping = result + } + else if let result: BlindedIdMapping = ContactUtilities.mapping(for: recipientBlindedId, serverPublicKey: serverPublicKey) { + mapping = result + } + else { + // Cache an "invalid" mapping that has the 'sessionId' set to the recipient so we don't + // re-process this recipient if there is another message from them + mapping = BlindedIdMapping( + blindedId: "", + sessionId: recipientBlindedId, + serverPublicKey: "" + ) + } + + switch receivedMessage { + case let receivedMessage as VisibleMessage: receivedMessage.syncTarget = mapping.sessionId + case let receivedMessage as ExpirationTimerUpdate: receivedMessage.syncTarget = mapping.sessionId + default: break + } + + mappingCache[recipientBlindedId] = mapping + } + + try MessageReceiver.handle(receivedMessage, associatedWithProto: proto, openGroupID: nil, isBackgroundPoll: isBackgroundPoll, using: transaction) } catch let error { SNLog("Couldn't receive inbox message due to error: \(error).") diff --git a/SessionMessagingKit/Open Groups/Types/Dependencies.swift b/SessionMessagingKit/Open Groups/Types/Dependencies.swift index fd64511b8..86479e06f 100644 --- a/SessionMessagingKit/Open Groups/Types/Dependencies.swift +++ b/SessionMessagingKit/Open Groups/Types/Dependencies.swift @@ -9,61 +9,61 @@ import SessionSnodeKit extension OpenGroupAPI { public class Dependencies { private var _api: OnionRequestAPIType.Type? - var api: OnionRequestAPIType.Type { + public var api: OnionRequestAPIType.Type { get { getValueSettingIfNull(&_api) { OnionRequestAPI.self } } set { _api = newValue } } private var _storage: SessionMessagingKitStorageProtocol? - var storage: SessionMessagingKitStorageProtocol { + public var storage: SessionMessagingKitStorageProtocol { get { getValueSettingIfNull(&_storage) { SNMessagingKitConfiguration.shared.storage } } set { _storage = newValue } } private var _sodium: SodiumType? - var sodium: SodiumType { + public var sodium: SodiumType { get { getValueSettingIfNull(&_sodium) { Sodium() } } set { _sodium = newValue } } private var _aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? - var aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType { + public var aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType { get { getValueSettingIfNull(&_aeadXChaCha20Poly1305Ietf) { sodium.getAeadXChaCha20Poly1305Ietf() } } set { _aeadXChaCha20Poly1305Ietf = newValue } } private var _sign: SignType? - var sign: SignType { + public var sign: SignType { get { getValueSettingIfNull(&_sign) { sodium.getSign() } } set { _sign = newValue } } private var _genericHash: GenericHashType? - var genericHash: GenericHashType { + public var genericHash: GenericHashType { get { getValueSettingIfNull(&_genericHash) { sodium.getGenericHash() } } set { _genericHash = newValue } } private var _ed25519: Ed25519Type.Type? - var ed25519: Ed25519Type.Type { + public var ed25519: Ed25519Type.Type { get { getValueSettingIfNull(&_ed25519) { Ed25519.self } } set { _ed25519 = newValue } } private var _nonceGenerator16: NonceGenerator16ByteType? - var nonceGenerator16: NonceGenerator16ByteType { + public var nonceGenerator16: NonceGenerator16ByteType { get { getValueSettingIfNull(&_nonceGenerator16) { NonceGenerator16Byte() } } set { _nonceGenerator16 = newValue } } private var _nonceGenerator24: NonceGenerator24ByteType? - var nonceGenerator24: NonceGenerator24ByteType { + public var nonceGenerator24: NonceGenerator24ByteType { get { getValueSettingIfNull(&_nonceGenerator24) { NonceGenerator24Byte() } } set { _nonceGenerator24 = newValue } } private var _date: Date? - var date: Date { + public var date: Date { get { getValueSettingIfNull(&_date) { Date() } } set { _date = newValue } } diff --git a/SessionMessagingKit/Open Groups/Types/Endpoint.swift b/SessionMessagingKit/Open Groups/Types/Endpoint.swift index e52fa6375..9149aec5b 100644 --- a/SessionMessagingKit/Open Groups/Types/Endpoint.swift +++ b/SessionMessagingKit/Open Groups/Types/Endpoint.swift @@ -38,12 +38,15 @@ extension OpenGroupAPI { case roomFileIndividual(String, Int64) case roomFileIndividualJson(String, Int64) - // Inbox (Message Requests) + // Inbox/Outbox (Message Requests) case inbox case inboxSince(id: Int64) case inboxFor(sessionId: String) + case outbox + case outboxSince(id: Int64) + // Users case userBan(String) @@ -133,11 +136,14 @@ extension OpenGroupAPI { case .roomFileIndividualJson(let roomToken, let fileId): return "room/\(roomToken)/file/\(fileId)" - // Inbox (Message Requests) + // Inbox/Outbox (Message Requests) case .inbox: return "inbox" case .inboxSince(let id): return "inbox/since/\(id)" case .inboxFor(let sessionId): return "inbox/\(sessionId)" + + case .outbox: return "outbox" + case .outboxSince(let id): return "outbox/since/\(id)" // Users diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift index 793200253..224e5ed15 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift @@ -123,14 +123,22 @@ extension OpenGroupAPI { on: server ) - case .inbox, .inboxSince: + case .inbox, .inboxSince, .outbox, .outboxSince: guard let responseData: BatchSubResponse<[DirectMessage]?> = endpointResponse.data as? BatchSubResponse<[DirectMessage]?>, let responseBody: [DirectMessage]? = responseData.body else { SNLog("Open group polling failed due to invalid data.") return } - OpenGroupManager.handleInbox( + let fromOutbox: Bool = { + switch endpoint { + case .outbox, .outboxSince: return true + default: return false + } + }() + + OpenGroupManager.handleDirectMessages( (responseBody ?? []), + fromOutbox: fromOutbox, on: server, isBackgroundPoll: isBackgroundPoll ) diff --git a/SessionMessagingKit/Storage.swift b/SessionMessagingKit/Storage.swift index 976b7d606..bd37cdc49 100644 --- a/SessionMessagingKit/Storage.swift +++ b/SessionMessagingKit/Storage.swift @@ -1,5 +1,6 @@ import PromiseKit import Sodium +import YapDatabase public protocol SessionMessagingKitStorageProtocol { @@ -19,6 +20,15 @@ public protocol SessionMessagingKitStorageProtocol { func getUser() -> Contact? func getAllContacts() -> Set func getAllContacts(with transaction: YapDatabaseReadTransaction) -> Set + + // MARK: - Blinded Id cache + + func getBlindedIdMapping(with blindedId: String) -> BlindedIdMapping? + func getBlindedIdMapping(with blindedId: String, using transaction: YapDatabaseReadTransaction) -> BlindedIdMapping? + func cacheBlindedIdMapping(_ mapping: BlindedIdMapping) + func cacheBlindedIdMapping(_ mapping: BlindedIdMapping, using transaction: YapDatabaseReadWriteTransaction) + func enumerateBlindedIdMapping(with block: @escaping (BlindedIdMapping, UnsafeMutablePointer) -> ()) + func enumerateBlindedIdMapping(with block: @escaping (BlindedIdMapping, UnsafeMutablePointer) -> (), transaction: YapDatabaseReadTransaction) // MARK: - Closed Groups @@ -72,6 +82,12 @@ public protocol SessionMessagingKitStorageProtocol { func getOpenGroupInboxLatestMessageId(for server: String) -> Int64? func setOpenGroupInboxLatestMessageId(for server: String, to newValue: Int64, using transaction: Any) func removeOpenGroupInboxLatestMessageId(for server: String, using transaction: Any) + + // MARK: - -- Open Group Outbox Latest Message Id + + func getOpenGroupOutboxLatestMessageId(for server: String) -> Int64? + func setOpenGroupOutboxLatestMessageId(for server: String, to newValue: Int64, using transaction: Any) + func removeOpenGroupOutboxLatestMessageId(for server: String, using transaction: Any) // MARK: - Message Handling diff --git a/SessionMessagingKit/Utilities/ContactUtilities.swift b/SessionMessagingKit/Utilities/ContactUtilities.swift new file mode 100644 index 000000000..704fb3813 --- /dev/null +++ b/SessionMessagingKit/Utilities/ContactUtilities.swift @@ -0,0 +1,99 @@ +import SessionUtilitiesKit + +enum ContactUtilities { + private static func approvedContact(in threadObject: Any, using transaction: Any) -> Contact? { + guard let thread: TSContactThread = threadObject as? TSContactThread else { return nil } + guard thread.shouldBeVisible else { return nil } + guard let contact: Contact = Storage.shared.getContact(with: thread.contactSessionID(), using: transaction) else { + return nil + } + guard contact.didApproveMe else { return nil } + + return contact + } + + static func getAllContacts() -> [String] { + // Collect all contacts + var result: [Contact] = [] + Storage.read { transaction in + TSContactThread.enumerateCollectionObjects(with: transaction) { object, _ in + guard let contact: Contact = approvedContact(in: object, using: transaction) else { return } + + result.append(contact) + } + } + func getDisplayName(for publicKey: String) -> String { + return Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey + } + + // Remove the current user + if let index = result.firstIndex(where: { $0.sessionID == getUserHexEncodedPublicKey() }) { + result.remove(at: index) + } + + // Sort alphabetically + return result + .sorted(by: { lhs, rhs in + (lhs.displayName(for: .regular) ?? lhs.sessionID) < (rhs.displayName(for: .regular) ?? rhs.sessionID) + }) + .map { $0.sessionID } + } + + static func enumerateApprovedContactThreads(with block: @escaping (TSContactThread, Contact, UnsafeMutablePointer) -> ()) { + Storage.read { transaction in + TSContactThread.enumerateCollectionObjects(with: transaction) { object, stop in + guard let contactThread: TSContactThread = object as? TSContactThread else { return } + guard let contact: Contact = approvedContact(in: object, using: transaction) else { return } + + block(contactThread, contact, stop) + } + } + } + + static func mapping(for blindedId: String, serverPublicKey: String, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) -> BlindedIdMapping? { + // TODO: Ensure the above case isn't going to be an issue due to legacy messages?. + // Unfortunately the whole point of id-blinding is to make it hard to reverse-engineer a standard + // sessionId, as a result in order to see if there is an unblinded contact for this blindedId we + // can only really generate blinded ids for each contact and check if any match + // + // Due to this we have made a few optimisations to try and early-out as often as possible, first + // we try to retrieve a direct cached mapping + var mappingResult: BlindedIdMapping? = dependencies.storage.getBlindedIdMapping(with: blindedId) + + // No need to continue if we already have a result + if let mapping: BlindedIdMapping = mappingResult { return mapping } + + // Then we try loop through all approved contact threads to see if one of those contacts can be blinded to match + ContactUtilities.enumerateApprovedContactThreads { contactThread, contact, stop in + guard dependencies.sodium.sessionId(contact.sessionID, matchesBlindedId: blindedId, serverPublicKey: serverPublicKey) else { + return + } + + // Cache the mapping + let newMapping: BlindedIdMapping = BlindedIdMapping(blindedId: blindedId, sessionId: contact.sessionID, serverPublicKey: serverPublicKey) + dependencies.storage.cacheBlindedIdMapping(newMapping) + mappingResult = newMapping + stop.pointee = true + } + + // Finish if we have a result + if let mapping: BlindedIdMapping = mappingResult { return mapping } + + // Lastly loop through existing id mappings (in case the user is looking at a different SOGS but once had + // a thread with this contact in a different SOGS and had cached the mapping) + dependencies.storage.enumerateBlindedIdMapping { mapping, stop in + guard mapping.serverPublicKey != serverPublicKey else { return } + guard dependencies.sodium.sessionId(mapping.sessionId, matchesBlindedId: blindedId, serverPublicKey: serverPublicKey) else { + return + } + + // Cache the new mapping + let newMapping: BlindedIdMapping = BlindedIdMapping(blindedId: blindedId, sessionId: mapping.sessionId, serverPublicKey: serverPublicKey) + dependencies.storage.cacheBlindedIdMapping(newMapping) + mappingResult = newMapping + stop.pointee = true + } + + return mappingResult + } +} From c04d4544f2dcc1ac588aa3959346ff1e30ed7c3a Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 2 Mar 2022 17:11:18 +1100 Subject: [PATCH 023/157] Added more unit tests Refactored the existing unit tests to use Quick Started adding unit tests for a number of the OpenGroupAPI network models Added unit tests for the SessionId type --- Podfile | 8 + Podfile.lock | 6 +- .../Open Groups/Models/Capabilities.swift | 2 +- .../Open Groups/Models/OpenGroup.swift | 2 +- .../Utilities/ContactUtilities.swift | 8 +- .../Open Groups/Models/CapabilitiesSpec.swift | 97 +++ .../Open Groups/Models/OpenGroupSpec.swift | 76 ++ .../Open Groups/Models/ServerSpec.swift | 74 ++ .../Open Groups/OpenGroupAPISpec.swift | 655 ++++++++++++++++ .../Open Groups/OpenGroupAPIV2Tests.swift | 727 ------------------ .../_TestUtilities/TestStorage.swift | 43 +- SessionUtilitiesKit/General/SessionId.swift | 7 - .../General/SessionIdSpec.swift | 87 +++ SharedTest/TestConstants.swift | 10 + 14 files changed, 1055 insertions(+), 747 deletions(-) create mode 100644 SessionMessagingKitTests/Open Groups/Models/CapabilitiesSpec.swift create mode 100644 SessionMessagingKitTests/Open Groups/Models/OpenGroupSpec.swift create mode 100644 SessionMessagingKitTests/Open Groups/Models/ServerSpec.swift create mode 100644 SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift delete mode 100644 SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift create mode 100644 SessionUtilitiesKitTests/General/SessionIdSpec.swift create mode 100644 SharedTest/TestConstants.swift diff --git a/Podfile b/Podfile index c903f9d7d..fc32f6793 100644 --- a/Podfile +++ b/Podfile @@ -56,12 +56,20 @@ abstract_target 'GlobalDependencies' do target 'SessionMessagingKitTests' do inherit! :complete + pod 'Quick' pod 'Nimble' end end target 'SessionUtilitiesKit' do pod 'SAMKeychain' + + target 'SessionUtilitiesKitTests' do + inherit! :complete + + pod 'Quick' + pod 'Nimble' + end end end end diff --git a/Podfile.lock b/Podfile.lock index f504ecaa4..a4d945ac0 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -39,6 +39,7 @@ PODS: - PromiseKit/UIKit (6.15.3): - PromiseKit/CorePromise - PureLayout (3.1.9) + - Quick (4.0.0) - Reachability (3.2) - SAMKeychain (1.5.3) - SignalCoreKit (1.0.0): @@ -129,6 +130,7 @@ DEPENDENCIES: - NVActivityIndicatorView - PromiseKit - PureLayout (~> 3.1.8) + - Quick - Reachability - SAMKeychain - SignalCoreKit (from `https://github.com/oxen-io/session-ios-core-kit`, branch `session-version`) @@ -148,6 +150,7 @@ SPEC REPOS: - OpenSSL-Universal - PromiseKit - PureLayout + - Quick - Reachability - SAMKeychain - SQLCipher @@ -204,6 +207,7 @@ SPEC CHECKSUMS: OpenSSL-Universal: e7311447fd2419f57420c79524b641537387eff2 PromiseKit: 3b2b6995e51a954c46dbc550ce3da44fbfb563c5 PureLayout: 5fb5e5429519627d60d079ccb1eaa7265ce7cf88 + Quick: 6473349e43b9271a8d43839d9ba1c442ed1b7ac4 Reachability: 33e18b67625424e47b6cde6d202dce689ad7af96 SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c SignalCoreKit: 1fbd8732163ef76de16cd1107d1fa3684b607e5d @@ -214,6 +218,6 @@ SPEC CHECKSUMS: YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331 ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: 2cc64d50f25c3b1627c3e958ae50e25fead25564 +PODFILE CHECKSUM: b95d8bb031996cffdb5d9b9b49bce3b24d6026d7 COCOAPODS: 1.11.2 diff --git a/SessionMessagingKit/Open Groups/Models/Capabilities.swift b/SessionMessagingKit/Open Groups/Models/Capabilities.swift index 3c5d7de12..6cf98b51d 100644 --- a/SessionMessagingKit/Open Groups/Models/Capabilities.swift +++ b/SessionMessagingKit/Open Groups/Models/Capabilities.swift @@ -24,7 +24,7 @@ extension OpenGroupAPI { } } - // MARK: - Codable + // MARK: - Initialization public init(from valueString: String) { let maybeValue: Capability? = Capability.allCases.first { $0.rawValue == valueString } diff --git a/SessionMessagingKit/Open Groups/Models/OpenGroup.swift b/SessionMessagingKit/Open Groups/Models/OpenGroup.swift index 2f2ba12a1..36a6c4398 100644 --- a/SessionMessagingKit/Open Groups/Models/OpenGroup.swift +++ b/SessionMessagingKit/Open Groups/Models/OpenGroup.swift @@ -63,5 +63,5 @@ public final class OpenGroup: NSObject, NSCoding { // NSObject/NSCoding conforma coder.encode(infoUpdates, forKey: "infoUpdates") } - override public var description: String { "\(name) (Server: \(server), Room: \(room)" } + override public var description: String { "\(name) (Server: \(server), Room: \(room))" } } diff --git a/SessionMessagingKit/Utilities/ContactUtilities.swift b/SessionMessagingKit/Utilities/ContactUtilities.swift index 704fb3813..25e4e3cda 100644 --- a/SessionMessagingKit/Utilities/ContactUtilities.swift +++ b/SessionMessagingKit/Utilities/ContactUtilities.swift @@ -1,6 +1,6 @@ import SessionUtilitiesKit -enum ContactUtilities { +public enum ContactUtilities { private static func approvedContact(in threadObject: Any, using transaction: Any) -> Contact? { guard let thread: TSContactThread = threadObject as? TSContactThread else { return nil } guard thread.shouldBeVisible else { return nil } @@ -12,7 +12,7 @@ enum ContactUtilities { return contact } - static func getAllContacts() -> [String] { + public static func getAllContacts() -> [String] { // Collect all contacts var result: [Contact] = [] Storage.read { transaction in @@ -39,7 +39,7 @@ enum ContactUtilities { .map { $0.sessionID } } - static func enumerateApprovedContactThreads(with block: @escaping (TSContactThread, Contact, UnsafeMutablePointer) -> ()) { + public static func enumerateApprovedContactThreads(with block: @escaping (TSContactThread, Contact, UnsafeMutablePointer) -> ()) { Storage.read { transaction in TSContactThread.enumerateCollectionObjects(with: transaction) { object, stop in guard let contactThread: TSContactThread = object as? TSContactThread else { return } @@ -50,7 +50,7 @@ enum ContactUtilities { } } - static func mapping(for blindedId: String, serverPublicKey: String, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) -> BlindedIdMapping? { + public static func mapping(for blindedId: String, serverPublicKey: String, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) -> BlindedIdMapping? { // TODO: Ensure the above case isn't going to be an issue due to legacy messages?. // Unfortunately the whole point of id-blinding is to make it hard to reverse-engineer a standard // sessionId, as a result in order to see if there is an unblinded contact for this blindedId we diff --git a/SessionMessagingKitTests/Open Groups/Models/CapabilitiesSpec.swift b/SessionMessagingKitTests/Open Groups/Models/CapabilitiesSpec.swift new file mode 100644 index 000000000..707f8852d --- /dev/null +++ b/SessionMessagingKitTests/Open Groups/Models/CapabilitiesSpec.swift @@ -0,0 +1,97 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class CapabilitiesSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + describe("Capabilities") { + context("when initializing") { + it("assigns values correctly") { + let capabilities: OpenGroupAPI.Capabilities = OpenGroupAPI.Capabilities( + capabilities: [.sogs], + missing: [.sogs] + ) + + expect(capabilities.capabilities).to(equal([.sogs])) + expect(capabilities.missing).to(equal([.sogs])) + } + + it("defaults missing to nil") { + let capabilities: OpenGroupAPI.Capabilities = OpenGroupAPI.Capabilities( + capabilities: [.sogs] + ) + + expect(capabilities.capabilities).to(equal([.sogs])) + expect(capabilities.missing).to(beNil()) + } + } + } + + describe("a Capability") { + context("when initializing") { + it("succeeeds with a valid case") { + let capability: OpenGroupAPI.Capabilities.Capability = OpenGroupAPI.Capabilities.Capability( + from: "sogs" + ) + + expect(capability).to(equal(.sogs)) + } + + it("wraps an unknown value in the unsupported case") { + let capability: OpenGroupAPI.Capabilities.Capability = OpenGroupAPI.Capabilities.Capability( + from: "test" + ) + + expect(capability).to(equal(.unsupported("test"))) + } + } + + context("when accessing the rawValue") { + it("provides known cases exactly") { + expect(OpenGroupAPI.Capabilities.Capability.sogs.rawValue).to(equal("sogs")) + expect(OpenGroupAPI.Capabilities.Capability.blind.rawValue).to(equal("blind")) + } + + it("provides the wrapped value for unsupported cases") { + expect(OpenGroupAPI.Capabilities.Capability.unsupported("test").rawValue).to(equal("test")) + } + } + + context("when Decoding") { + it("decodes known cases exactly") { + expect( + try? JSONDecoder().decode( + OpenGroupAPI.Capabilities.Capability.self, + from: "\"sogs\"".data(using: .utf8)! + ) + ) + .to(equal(.sogs)) + expect( + try? JSONDecoder().decode( + OpenGroupAPI.Capabilities.Capability.self, + from: "\"blind\"".data(using: .utf8)! + ) + ) + .to(equal(.blind)) + } + + it("decodes unknown cases into the unsupported case") { + expect( + try? JSONDecoder().decode( + OpenGroupAPI.Capabilities.Capability.self, + from: "\"test\"".data(using: .utf8)! + ) + ) + .to(equal(.unsupported("test"))) + } + } + } + } +} diff --git a/SessionMessagingKitTests/Open Groups/Models/OpenGroupSpec.swift b/SessionMessagingKitTests/Open Groups/Models/OpenGroupSpec.swift new file mode 100644 index 000000000..b36ce7a48 --- /dev/null +++ b/SessionMessagingKitTests/Open Groups/Models/OpenGroupSpec.swift @@ -0,0 +1,76 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class OpenGroupSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + describe("an Open Group") { + context("when initializing") { + it("generates the id") { + let openGroup: OpenGroup = OpenGroup( + server: "server", + room: "room", + publicKey: "1234", + name: "name", + groupDescription: nil, + imageID: nil, + infoUpdates: 0 + ) + + expect(openGroup.id).to(equal("server.room")) + } + } + + context("when NSCoding") { + // Note: Unit testing NSCoder is horrible so we won't do it - wait until we refactor it to Codable + it("successfully encodes and decodes") { + let openGroupToEncode: OpenGroup = OpenGroup( + server: "server", + room: "room", + publicKey: "1234", + name: "name", + groupDescription: "desc", + imageID: "image", + infoUpdates: 1 + ) + let encodedData: Data = try! NSKeyedArchiver.archivedData(withRootObject: openGroupToEncode, requiringSecureCoding: false) + let openGroup: OpenGroup? = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(encodedData) as? OpenGroup + + expect(openGroup).toNot(beNil()) + expect(openGroup?.id).to(equal("server.room")) + expect(openGroup?.server).to(equal("server")) + expect(openGroup?.room).to(equal("room")) + expect(openGroup?.publicKey).to(equal("1234")) + expect(openGroup?.name).to(equal("name")) + expect(openGroup?.groupDescription).to(equal("desc")) + expect(openGroup?.imageID).to(equal("image")) + expect(openGroup?.infoUpdates).to(equal(1)) + } + } + + context("when describing") { + it("includes relevant information") { + let openGroup: OpenGroup = OpenGroup( + server: "server", + room: "room", + publicKey: "1234", + name: "name", + groupDescription: nil, + imageID: nil, + infoUpdates: 0 + ) + + expect(openGroup.description) + .to(equal("name (Server: server, Room: room)")) + } + } + } + } +} diff --git a/SessionMessagingKitTests/Open Groups/Models/ServerSpec.swift b/SessionMessagingKitTests/Open Groups/Models/ServerSpec.swift new file mode 100644 index 000000000..ccd8557d0 --- /dev/null +++ b/SessionMessagingKitTests/Open Groups/Models/ServerSpec.swift @@ -0,0 +1,74 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class ServerSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + describe("an Open Group Server") { + context("when initializing") { + it("converts the server name to lowercase") { + let server: OpenGroupAPI.Server = OpenGroupAPI.Server( + name: "TeSt", + capabilities: OpenGroupAPI.Capabilities(capabilities: [], missing: nil) + ) + + expect(server.name).to(equal("test")) + } + } + + context("when NSCoding") { + // Note: Unit testing NSCoder is horrible so we won't do it - wait until we refactor it to Codable + it("successfully encodes and decodes") { + let serverToEncode: OpenGroupAPI.Server = OpenGroupAPI.Server( + name: "test", + capabilities: OpenGroupAPI.Capabilities( + capabilities: [.sogs, .unsupported("other")], + missing: [.blind, .unsupported("other2")]) + ) + let encodedData: Data = try! NSKeyedArchiver.archivedData(withRootObject: serverToEncode, requiringSecureCoding: false) + let server: OpenGroupAPI.Server? = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(encodedData) as? OpenGroupAPI.Server + + expect(server).toNot(beNil()) + expect(server?.name).to(equal("test")) + expect(server?.capabilities.capabilities).to(equal([.sogs, .unsupported("other")])) + expect(server?.capabilities.missing).to(equal([.blind, .unsupported("other2")])) + } + } + + context("when describing") { + it("includes relevant information") { + let server: OpenGroupAPI.Server = OpenGroupAPI.Server( + name: "TeSt", + capabilities: OpenGroupAPI.Capabilities( + capabilities: [.sogs, .unsupported("other")], + missing: [.blind, .unsupported("other2")] + ) + ) + + expect(server.description) + .to(equal("test (Capabilities: [sogs, other], Missing: [blind, other2])")) + } + + it("handles nil missing capabilities") { + let server: OpenGroupAPI.Server = OpenGroupAPI.Server( + name: "TeSt", + capabilities: OpenGroupAPI.Capabilities( + capabilities: [.sogs, .unsupported("other")], + missing: nil + ) + ) + + expect(server.description) + .to(equal("test (Capabilities: [sogs, other], Missing: [])")) + } + } + } + } +} diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift new file mode 100644 index 000000000..31dd50c17 --- /dev/null +++ b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift @@ -0,0 +1,655 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import PromiseKit +import Sodium +import SessionSnodeKit + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class OpenGroupAPISpec: QuickSpec { + class TestResponseInfo: OnionRequestResponseInfoType { + let requestData: TestApi.RequestData + let code: Int + let headers: [String: String] + + init(requestData: TestApi.RequestData, code: Int, headers: [String: String]) { + self.requestData = requestData + self.code = code + self.headers = headers + } + } + + struct TestNonce16Generator: NonceGenerator16ByteType { + var NonceBytes: Int = 16 + + func nonce() -> Array { return Data(base64Encoded: "pK6YRtQApl4NhECGizF0Cg==")!.bytes } + } + + struct TestNonce24Generator: NonceGenerator24ByteType { + var NonceBytes: Int = 24 + + func nonce() -> Array { return Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")!.bytes } + } + + class TestApi: OnionRequestAPIType { + struct RequestData: Codable { + let urlString: String? + let httpMethod: String + let headers: [String: String] + let snodeMethod: String? + let body: Data? + + let server: String + let version: OnionRequestAPI.Version + let publicKey: String? + } + + class var mockResponse: Data? { return nil } + + static func sendOnionRequest(_ request: URLRequest, to server: String, using version: OnionRequestAPI.Version, with x25519PublicKey: String) -> Promise<(OnionRequestResponseInfoType, Data?)> { + let responseInfo: TestResponseInfo = TestResponseInfo( + requestData: RequestData( + urlString: request.url?.absoluteString, + httpMethod: (request.httpMethod ?? "GET"), + headers: (request.allHTTPHeaderFields ?? [:]), + snodeMethod: nil, + body: request.httpBody, + + server: server, + version: version, + publicKey: x25519PublicKey + ), + code: 200, + headers: [:] + ) + + return Promise.value((responseInfo, mockResponse)) + } + + static func sendOnionRequest(to snode: Snode, invoking method: Snode.Method, with parameters: JSON, using version: OnionRequestAPI.Version, associatedWith publicKey: String?) -> Promise { + // TODO: Test the 'responseInfo' somehow? + return Promise.value(mockResponse!) + } + } + + // MARK: - Spec + + override func spec() { + var testStorage: TestStorage! + var testSodium: TestSodium! + var testAeadXChaCha20Poly1305Ietf: TestAeadXChaCha20Poly1305Ietf! + var testGenericHash: TestGenericHash! + var testSign: TestSign! + var dependencies: OpenGroupAPI.Dependencies! + + var response: (OnionRequestResponseInfoType, Codable)? = nil + var pollResponse: [OpenGroupAPI.Endpoint: (OnionRequestResponseInfoType, Codable?)]? + var error: Error? + + describe("an OpenGroupAPI") { + // MARK: - Configuration + + beforeEach { + testStorage = TestStorage() + testSodium = TestSodium() + testAeadXChaCha20Poly1305Ietf = TestAeadXChaCha20Poly1305Ietf() + testGenericHash = TestGenericHash() + testSign = TestSign() + dependencies = OpenGroupAPI.Dependencies( + api: TestApi.self, + storage: testStorage, + sodium: testSodium, + aeadXChaCha20Poly1305Ietf: testAeadXChaCha20Poly1305Ietf, + sign: testSign, + genericHash: testGenericHash, + ed25519: TestEd25519.self, + nonceGenerator16: TestNonce16Generator(), + nonceGenerator24: TestNonce24Generator(), + date: Date(timeIntervalSince1970: 1234567890) + ) + + testStorage.mockData[.allOpenGroups] = [ + "0": OpenGroup( + server: "testServer", + room: "testRoom", + publicKey: TestConstants.publicKey, + name: "Test", + groupDescription: nil, + imageID: nil, + infoUpdates: 0 + ) + ] + testStorage.mockData[.openGroupPublicKeys] = [ + "testServer": TestConstants.publicKey + ] + testStorage.mockData[.userKeyPair] = try! ECKeyPair( + publicKeyData: Data.data(fromHex: TestConstants.publicKey)!, + privateKeyData: Data.data(fromHex: TestConstants.privateKey)! + ) + testStorage.mockData[.userEdKeyPair] = Box.KeyPair( + publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes + ) + + testGenericHash.mockData[.hashOutputLength] = [] + testSodium.mockData[.blindedKeyPair] = Box.KeyPair( + publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes + ) + testSodium.mockData[.sogsSignature] = "TestSogsSignature".bytes + testSign.mockData[.signature] = "TestSignature".bytes + } + + afterEach { + dependencies = nil + testStorage = nil + response = nil + pollResponse = nil + } + + // MARK: - Batching & Polling + + context("when polling") { + it("generates the correct request") { + class LocalTestApi: TestApi { + override class var mockResponse: Data? { + let responses: [Data] = [ + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: OpenGroupAPI.Capabilities(capabilities: [], missing: nil), + failedToParseBody: false + ) + ), + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: try! JSONDecoder().decode( + OpenGroupAPI.RoomPollInfo.self, + from: """ + { + \"token\":\"test\", + \"active_users\":1, + \"read\":true, + \"write\":true, + \"upload\":true + } + """.data(using: .utf8)! + ), + failedToParseBody: false + ) + ), + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: [OpenGroupAPI.Message](), + failedToParseBody: false + ) + ), + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: [OpenGroupAPI.DirectMessage](), + failedToParseBody: false + ) + ), + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: [OpenGroupAPI.DirectMessage](), + failedToParseBody: false + ) + ) + ] + + return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) + } + } + dependencies = dependencies.with(api: LocalTestApi.self) + + OpenGroupAPI.poll("testServer", using: dependencies) + .get { result in pollResponse = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(pollResponse) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate the response data + expect(pollResponse?.values).to(haveCount(5)) + expect(pollResponse?.keys).to(contain(.capabilities)) + expect(pollResponse?.keys).to(contain(.roomPollInfo("testRoom", 0))) + expect(pollResponse?.keys).to(contain(.roomMessagesRecent("testRoom"))) + expect(pollResponse?.keys).to(contain(.inbox)) + expect(pollResponse?.keys).to(contain(.outbox)) + expect(pollResponse?[.capabilities]?.0).to(beAKindOf(TestResponseInfo.self)) + + // Validate request data + let requestData: TestApi.RequestData? = (pollResponse?[.capabilities]?.0 as? TestResponseInfo)?.requestData + expect(requestData?.urlString).to(equal("testServer/batch")) + expect(requestData?.httpMethod).to(equal("POST")) + expect(requestData?.server).to(equal("testServer")) + expect(requestData?.publicKey).to(equal("7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")) + } + + it("errors when no data is returned") { + OpenGroupAPI.poll("testServer", using: dependencies) + .get { result in pollResponse = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPI.Error.parsingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(pollResponse).to(beNil()) + } + + it("errors when invalid data is returned") { + class LocalTestApi: TestApi { + override class var mockResponse: Data? { return Data() } + } + dependencies = dependencies.with(api: LocalTestApi.self) + + OpenGroupAPI.poll("testServer", using: dependencies) + .get { result in pollResponse = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPI.Error.parsingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(pollResponse).to(beNil()) + } + + it("errors when an empty array is returned") { + class LocalTestApi: TestApi { + override class var mockResponse: Data? { return "[]".data(using: .utf8) } + } + dependencies = dependencies.with(api: LocalTestApi.self) + + OpenGroupAPI.poll("testServer", using: dependencies) + .get { result in pollResponse = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPI.Error.parsingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(pollResponse).to(beNil()) + } + + it("errors when an empty object is returned") { + class LocalTestApi: TestApi { + override class var mockResponse: Data? { return "{}".data(using: .utf8) } + } + dependencies = dependencies.with(api: LocalTestApi.self) + + OpenGroupAPI.poll("testServer", using: dependencies) + .get { result in pollResponse = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPI.Error.parsingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(pollResponse).to(beNil()) + } + + it("errors when a different number of responses are returned") { + class LocalTestApi: TestApi { + override class var mockResponse: Data? { + let responses: [Data] = [ + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: OpenGroupAPI.Capabilities(capabilities: [], missing: nil), + failedToParseBody: false + ) + ), + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: try! JSONDecoder().decode( + OpenGroupAPI.RoomPollInfo.self, + from: """ + { + \"token\":\"test\", + \"active_users\":1, + \"read\":true, + \"write\":true, + \"upload\":true + } + """.data(using: .utf8)! + ), + failedToParseBody: false + ) + ) + ] + + return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) + } + } + dependencies = dependencies.with(api: LocalTestApi.self) + + OpenGroupAPI.poll("testServer", using: dependencies) + .get { result in pollResponse = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPI.Error.parsingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(pollResponse).to(beNil()) + } + + it("errors when an unexpected response is returned") { + class LocalTestApi: TestApi { + override class var mockResponse: Data? { + let responses: [Data] = [ + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: ""), + failedToParseBody: false + ) + ), + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: ""), + failedToParseBody: false + ) + ), + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: ""), + failedToParseBody: false + ) + ) + ] + + return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) + } + } + dependencies = dependencies.with(api: LocalTestApi.self) + + OpenGroupAPI.poll("testServer", using: dependencies) + .get { result in pollResponse = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPI.Error.parsingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(pollResponse).to(beNil()) + } + } + + // MARK: - Files + + context("when uploading files") { + it("doesn't add a fileName header when not provided") { + class LocalTestApi: TestApi { + override class var mockResponse: Data? { + return try! JSONEncoder().encode(FileUploadResponse(id: 1)) + } + } + dependencies = dependencies.with(api: LocalTestApi.self) + + OpenGroupAPI.uploadFile([], to: "testRoom", on: "testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate signature headers + let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData + expect(requestData?.urlString).to(equal("testServer/room/testRoom/file")) + expect(requestData?.httpMethod).to(equal("POST")) + expect(requestData?.headers).to(haveCount(4)) + expect(requestData?.headers.keys).toNot(contain(Header.fileName.rawValue)) + } + + it("adds a fileName header when provided") { + class LocalTestApi: TestApi { + override class var mockResponse: Data? { + return try! JSONEncoder().encode(FileUploadResponse(id: 1)) + } + } + dependencies = dependencies.with(api: LocalTestApi.self) + + OpenGroupAPI.uploadFile([], fileName: "TestFileName", to: "testRoom", on: "testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate signature headers + let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData + expect(requestData?.urlString).to(equal("testServer/room/testRoom/file")) + expect(requestData?.httpMethod).to(equal("POST")) + expect(requestData?.headers).to(haveCount(5)) + expect(requestData?.headers[Header.fileName.rawValue]).to(equal("TestFileName")) + } + } + + // MARK: - Authentication + + context("when signing") { + beforeEach { + class LocalTestApi: TestApi { + override class var mockResponse: Data? { + return try! JSONEncoder().encode([OpenGroupAPI.Room]()) + } + } + + dependencies = dependencies.with(api: LocalTestApi.self) + } + + it("fails when there is no userEdKeyPair") { + testStorage.mockData[.userEdKeyPair] = nil + + OpenGroupAPI.rooms(for: "testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + + it("fails when there is no serverPublicKey") { + testStorage.mockData[.openGroupPublicKeys] = [:] + + OpenGroupAPI.rooms(for: "testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPI.Error.noPublicKey.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + + context("when unblinded") { + beforeEach { + testStorage.mockData[.openGroupServer] = OpenGroupAPI.Server( + name: "testServer", + capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: []) + ) + } + + it("signs correctly") { + OpenGroupAPI.rooms(for: "testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate signature headers + let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData + expect(requestData?.urlString).to(equal("testServer/rooms")) + expect(requestData?.httpMethod).to(equal("GET")) + expect(requestData?.server).to(equal("testServer")) + expect(requestData?.publicKey).to(equal("7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")) + expect(requestData?.headers).to(haveCount(4)) + expect(requestData?.headers[Header.sogsPubKey.rawValue]) + .to(equal("007aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")) + expect(requestData?.headers[Header.sogsTimestamp.rawValue]).to(equal("1234567890")) + expect(requestData?.headers[Header.sogsNonce.rawValue]).to(equal("pK6YRtQApl4NhECGizF0Cg==")) + expect(requestData?.headers[Header.sogsSignature.rawValue]).to(equal("TestSignature".bytes.toBase64())) + } + + it("fails when the signature is not generated") { + testSign.mockData[.signature] = nil + + OpenGroupAPI.rooms(for: "testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + } + + context("when blinded") { + beforeEach { + testStorage.mockData[.openGroupServer] = OpenGroupAPI.Server( + name: "testServer", + capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs, .blind], missing: []) + ) + } + + it("signs correctly") { + OpenGroupAPI.rooms(for: "testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate signature headers + let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData + expect(requestData?.urlString).to(equal("testServer/rooms")) + expect(requestData?.httpMethod).to(equal("GET")) + expect(requestData?.server).to(equal("testServer")) + expect(requestData?.publicKey).to(equal("7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")) + expect(requestData?.headers).to(haveCount(4)) + expect(requestData?.headers[Header.sogsPubKey.rawValue]).to(equal("157aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")) + expect(requestData?.headers[Header.sogsTimestamp.rawValue]).to(equal("1234567890")) + expect(requestData?.headers[Header.sogsNonce.rawValue]).to(equal("pK6YRtQApl4NhECGizF0Cg==")) + expect(requestData?.headers[Header.sogsSignature.rawValue]).to(equal("TestSogsSignature".bytes.toBase64())) + } + + it("fails when the blindedKeyPair is not generated") { + testSodium.mockData[.blindedKeyPair] = nil + + OpenGroupAPI.rooms(for: "testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + + it("fails when the sogsSignature is not generated") { + testSodium.mockData[.sogsSignature] = nil + + OpenGroupAPI.rooms(for: "testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + } + } + } + } +} diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift b/SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift deleted file mode 100644 index 566769417..000000000 --- a/SessionMessagingKitTests/Open Groups/OpenGroupAPIV2Tests.swift +++ /dev/null @@ -1,727 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import XCTest -import Nimble -import PromiseKit -import Sodium -import SessionSnodeKit - -@testable import SessionMessagingKit - -class OpenGroupAPITests: XCTestCase { - class TestResponseInfo: OnionRequestResponseInfoType { - let requestData: TestApi.RequestData - let code: Int - let headers: [String: String] - - init(requestData: TestApi.RequestData, code: Int, headers: [String: String]) { - self.requestData = requestData - self.code = code - self.headers = headers - } - } - - struct TestNonce16Generator: NonceGenerator16ByteType { - var NonceBytes: Int = 16 - - func nonce() -> Array { return Data(base64Encoded: "pK6YRtQApl4NhECGizF0Cg==")!.bytes } - } - - struct TestNonce24Generator: NonceGenerator24ByteType { - var NonceBytes: Int = 24 - - func nonce() -> Array { return Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")!.bytes } - } - - class TestApi: OnionRequestAPIType { - struct RequestData: Codable { - let urlString: String? - let httpMethod: String - let headers: [String: String] - let snodeMethod: String? - let body: Data? - - let server: String - let version: OnionRequestAPI.Version - let publicKey: String? - } - - class var mockResponse: Data? { return nil } - - static func sendOnionRequest(_ request: URLRequest, to server: String, using version: OnionRequestAPI.Version, with x25519PublicKey: String) -> Promise<(OnionRequestResponseInfoType, Data?)> { - let responseInfo: TestResponseInfo = TestResponseInfo( - requestData: RequestData( - urlString: request.url?.absoluteString, - httpMethod: (request.httpMethod ?? "GET"), - headers: (request.allHTTPHeaderFields ?? [:]), - snodeMethod: nil, - body: request.httpBody, - - server: server, - version: version, - publicKey: x25519PublicKey - ), - code: 200, - headers: [:] - ) - - return Promise.value((responseInfo, mockResponse)) - } - - static func sendOnionRequest(to snode: Snode, invoking method: Snode.Method, with parameters: JSON, using version: OnionRequestAPI.Version, associatedWith publicKey: String?) -> Promise { - // TODO: Test the 'responseInfo' somehow? - return Promise.value(mockResponse!) - } - } - - var testStorage: TestStorage! - var testSodium: TestSodium! - var testAeadXChaCha20Poly1305Ietf: TestAeadXChaCha20Poly1305Ietf! - var testGenericHash: TestGenericHash! - var testSign: TestSign! - var dependencies: OpenGroupAPI.Dependencies! - - // MARK: - Configuration - - override func setUpWithError() throws { - testStorage = TestStorage() - testSodium = TestSodium() - testAeadXChaCha20Poly1305Ietf = TestAeadXChaCha20Poly1305Ietf() - testGenericHash = TestGenericHash() - testSign = TestSign() - dependencies = OpenGroupAPI.Dependencies( - api: TestApi.self, - storage: testStorage, - sodium: testSodium, - aeadXChaCha20Poly1305Ietf: testAeadXChaCha20Poly1305Ietf, - sign: testSign, - genericHash: testGenericHash, - ed25519: TestEd25519.self, - nonceGenerator16: TestNonce16Generator(), - nonceGenerator24: TestNonce24Generator(), - date: Date(timeIntervalSince1970: 1234567890) - ) - - testStorage.mockData[.allOpenGroups] = [ - "0": OpenGroup( - server: "testServer", - room: "testRoom", - publicKey: "7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d", - name: "Test", - groupDescription: nil, - imageID: nil, - infoUpdates: 0 - ) - ] - testStorage.mockData[.openGroupPublicKeys] = [ - "testServer": "7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d" - ] - - // Test Private key, not actually used (from here https://www.notion.so/oxen/SOGS-Authentication-dc64cc846cb24b2abbf7dd4bfd74abbb) - testStorage.mockData[.userKeyPair] = try! ECKeyPair( - publicKeyData: Data.data(fromHex: "7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")!, - privateKeyData: Data.data(fromHex: "881132ee03dbd2da065aa4c94f96081f62142dc8011d1b7a00de83e4aab38ce4")! - ) - testStorage.mockData[.userEdKeyPair] = Box.KeyPair( - publicKey: Data.data(fromHex: "7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")!.bytes, - secretKey: Data.data(fromHex: "881132ee03dbd2da065aa4c94f96081f62142dc8011d1b7a00de83e4aab38ce4881132ee03dbd2da065aa4c94f96081f62142dc8011d1b7a00de83e4aab38ce4")!.bytes - ) - - testGenericHash.mockData[.hashOutputLength] = [] - testSodium.mockData[.blindedKeyPair] = Box.KeyPair( - publicKey: Data.data(fromHex: "7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")!.bytes, - secretKey: Data.data(fromHex: "881132ee03dbd2da065aa4c94f96081f62142dc8011d1b7a00de83e4aab38ce4881132ee03dbd2da065aa4c94f96081f62142dc8011d1b7a00de83e4aab38ce4")!.bytes - ) - testSodium.mockData[.sogsSignature] = "TestSogsSignature".bytes - testSign.mockData[.signature] = "TestSignature".bytes - } - - override func tearDownWithError() throws { - dependencies = nil - testStorage = nil - } - - // MARK: - Batching & Polling - - func testPollGeneratesTheCorrectRequest() throws { - class LocalTestApi: TestApi { - override class var mockResponse: Data? { - let responses: [Data] = [ - try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( - code: 200, - headers: [:], - body: OpenGroupAPI.Capabilities(capabilities: [], missing: nil), - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( - code: 200, - headers: [:], - body: try! JSONDecoder().decode( - OpenGroupAPI.RoomPollInfo.self, - from: """ - { - \"token\":\"test\", - \"active_users\":1, - \"read\":true, - \"write\":true, - \"upload\":true - } - """.data(using: .utf8)! - ), - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( - code: 200, - headers: [:], - body: [OpenGroupAPI.Message](), - failedToParseBody: false - ) - ) - ] - - return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) - } - } - dependencies = dependencies.with(api: LocalTestApi.self) - - var response: [OpenGroupAPI.Endpoint: (OnionRequestResponseInfoType, Codable?)]? = nil - var error: Error? = nil - - OpenGroupAPI.poll("testServer", using: dependencies) - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate the response data - expect(response?.values).to(haveCount(3)) - expect(response?.keys).to(contain(.capabilities)) - expect(response?.keys).to(contain(.roomPollInfo("testRoom", 0))) - expect(response?.keys).to(contain(.roomMessagesRecent("testRoom"))) - expect(response?[.capabilities]?.0).to(beAKindOf(TestResponseInfo.self)) - - // Validate request data - let requestData: TestApi.RequestData? = (response?[.capabilities]?.0 as? TestResponseInfo)?.requestData - expect(requestData?.urlString).to(equal("testServer/batch")) - expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.server).to(equal("testServer")) - expect(requestData?.publicKey).to(equal("7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")) - } - - func testPollReturnsAnErrorWhenGivenNoData() throws { - var response: [OpenGroupAPI.Endpoint: (OnionRequestResponseInfoType, Codable?)]? = nil - var error: Error? = nil - - OpenGroupAPI.poll("testServer", using: dependencies) - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPI.Error.parsingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - func testPollReturnsAnErrorWhenGivenInvalidData() throws { - class LocalTestApi: TestApi { - override class var mockResponse: Data? { return Data() } - } - dependencies = dependencies.with(api: LocalTestApi.self) - - var response: [OpenGroupAPI.Endpoint: (OnionRequestResponseInfoType, Codable?)]? = nil - var error: Error? = nil - - OpenGroupAPI.poll("testServer", using: dependencies) - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPI.Error.parsingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - func testPollReturnsAnErrorWhenGivenAnEmptyResponse() throws { - class LocalTestApi: TestApi { - override class var mockResponse: Data? { return "[]".data(using: .utf8) } - } - dependencies = dependencies.with(api: LocalTestApi.self) - - var response: [OpenGroupAPI.Endpoint: (OnionRequestResponseInfoType, Codable?)]? = nil - var error: Error? = nil - - OpenGroupAPI.poll("testServer", using: dependencies) - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPI.Error.parsingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - func testPollReturnsAnErrorWhenGivenAnObjectResponse() throws { - class LocalTestApi: TestApi { - override class var mockResponse: Data? { return "{}".data(using: .utf8) } - } - dependencies = dependencies.with(api: LocalTestApi.self) - - var response: [OpenGroupAPI.Endpoint: (OnionRequestResponseInfoType, Codable?)]? = nil - var error: Error? = nil - - OpenGroupAPI.poll("testServer", using: dependencies) - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPI.Error.parsingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - func testPollReturnsAnErrorWhenGivenAnDifferentNumberOfResponses() throws { - class LocalTestApi: TestApi { - override class var mockResponse: Data? { - let responses: [Data] = [ - try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( - code: 200, - headers: [:], - body: OpenGroupAPI.Capabilities(capabilities: [], missing: nil), - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( - code: 200, - headers: [:], - body: try! JSONDecoder().decode( - OpenGroupAPI.RoomPollInfo.self, - from: """ - { - \"token\":\"test\", - \"active_users\":1, - \"read\":true, - \"write\":true, - \"upload\":true - } - """.data(using: .utf8)! - ), - failedToParseBody: false - ) - ) - ] - - return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) - } - } - dependencies = dependencies.with(api: LocalTestApi.self) - - var response: [OpenGroupAPI.Endpoint: (OnionRequestResponseInfoType, Codable?)]? = nil - var error: Error? = nil - - OpenGroupAPI.poll("testServer", using: dependencies) - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPI.Error.parsingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - func testPollReturnsAnErrorWhenGivenAnUnexpectedResponse() throws { - class LocalTestApi: TestApi { - override class var mockResponse: Data? { - let responses: [Data] = [ - try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( - code: 200, - headers: [:], - body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: ""), - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( - code: 200, - headers: [:], - body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: ""), - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( - code: 200, - headers: [:], - body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: ""), - failedToParseBody: false - ) - ) - ] - - return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) - } - } - dependencies = dependencies.with(api: LocalTestApi.self) - - var response: [OpenGroupAPI.Endpoint: (OnionRequestResponseInfoType, Codable?)]? = nil - var error: Error? = nil - - OpenGroupAPI.poll("testServer", using: dependencies) - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPI.Error.parsingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - // MARK: - Files - - func testItDoesNotAddAFileNameHeaderWhenNotProvided() throws { - class LocalTestApi: TestApi { - override class var mockResponse: Data? { - return try! JSONEncoder().encode(FileUploadResponse(id: 1)) - } - } - dependencies = dependencies.with(api: LocalTestApi.self) - - var response: (OnionRequestResponseInfoType, FileUploadResponse)? = nil - var error: Error? = nil - - OpenGroupAPI.uploadFile([], to: "testRoom", on: "testServer", using: dependencies) - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate signature headers - let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData - expect(requestData?.urlString).to(equal("testServer/room/testRoom/file")) - expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.headers).to(haveCount(4)) - expect(requestData?.headers.keys).toNot(contain(Header.fileName.rawValue)) - } - - func testItAddsAFileNameHeaderWhenProvided() throws { - class LocalTestApi: TestApi { - override class var mockResponse: Data? { - return try! JSONEncoder().encode(FileUploadResponse(id: 1)) - } - } - dependencies = dependencies.with(api: LocalTestApi.self) - - var response: (OnionRequestResponseInfoType, FileUploadResponse)? = nil - var error: Error? = nil - - OpenGroupAPI.uploadFile([], fileName: "TestFileName", to: "testRoom", on: "testServer", using: dependencies) - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate signature headers - let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData - expect(requestData?.urlString).to(equal("testServer/room/testRoom/file")) - expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.headers).to(haveCount(5)) - expect(requestData?.headers[Header.fileName.rawValue]).to(equal("TestFileName")) - } - - // MARK: - Authentication - - func testItSignsTheUnblindedRequestCorrectly() throws { - class LocalTestApi: TestApi { - override class var mockResponse: Data? { - return try! JSONEncoder().encode([OpenGroupAPI.Room]()) - } - } - testStorage.mockData[.openGroupServer] = OpenGroupAPI.Server( - name: "testServer", - capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: []) - ) - dependencies = dependencies.with(api: LocalTestApi.self) - - var response: (OnionRequestResponseInfoType, [OpenGroupAPI.Room])? = nil - var error: Error? = nil - - OpenGroupAPI.rooms(for: "testServer", using: dependencies) - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate signature headers - let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData - expect(requestData?.urlString).to(equal("testServer/rooms")) - expect(requestData?.httpMethod).to(equal("GET")) - expect(requestData?.server).to(equal("testServer")) - expect(requestData?.publicKey).to(equal("7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")) - expect(requestData?.headers).to(haveCount(4)) - expect(requestData?.headers[Header.sogsPubKey.rawValue]).to(equal("007aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")) - expect(requestData?.headers[Header.sogsTimestamp.rawValue]).to(equal("1234567890")) - expect(requestData?.headers[Header.sogsNonce.rawValue]).to(equal("pK6YRtQApl4NhECGizF0Cg==")) - expect(requestData?.headers[Header.sogsSignature.rawValue]).to(equal("TestSignature".bytes.toBase64())) - } - - func testItSignsTheBlindedRequestCorrectly() throws { - class LocalTestApi: TestApi { - override class var mockResponse: Data? { - return try! JSONEncoder().encode([OpenGroupAPI.Room]()) - } - } - testStorage.mockData[.openGroupServer] = OpenGroupAPI.Server( - name: "testServer", - capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs, .blind], missing: []) - ) - dependencies = dependencies.with(api: LocalTestApi.self) - - var response: (OnionRequestResponseInfoType, [OpenGroupAPI.Room])? = nil - var error: Error? = nil - - OpenGroupAPI.rooms(for: "testServer", using: dependencies) - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate signature headers - let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData - expect(requestData?.urlString).to(equal("testServer/rooms")) - expect(requestData?.httpMethod).to(equal("GET")) - expect(requestData?.server).to(equal("testServer")) - expect(requestData?.publicKey).to(equal("7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")) - expect(requestData?.headers).to(haveCount(4)) - expect(requestData?.headers[Header.sogsPubKey.rawValue]).to(equal("157aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")) - expect(requestData?.headers[Header.sogsTimestamp.rawValue]).to(equal("1234567890")) - expect(requestData?.headers[Header.sogsNonce.rawValue]).to(equal("pK6YRtQApl4NhECGizF0Cg==")) - expect(requestData?.headers[Header.sogsSignature.rawValue]).to(equal("TestSogsSignature".bytes.toBase64())) - } - - func testItFailsToSignIfThereIsNoUserEdKeyPair() throws { - testStorage.mockData[.userEdKeyPair] = nil - - var response: Any? = nil - var error: Error? = nil - - OpenGroupAPI.rooms(for: "testServer", using: dependencies) - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPI.Error.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - func testItFailsToSignIfTheServerPublicKeyIsInvalid() throws { - testStorage.mockData[.openGroupPublicKeys] = [:] - - var response: Any? = nil - var error: Error? = nil - - OpenGroupAPI.rooms(for: "testServer", using: dependencies) - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPI.Error.noPublicKey.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - func testItFailsToSignIfBlindedAndTheBlindedKeyDoesNotGetGenerated() throws { - class InvalidSodium: SodiumType { - func getGenericHash() -> GenericHashType { return Sodium().genericHash } - func getAeadXChaCha20Poly1305Ietf() -> AeadXChaCha20Poly1305IetfType { return Sodium().aead.xchacha20poly1305ietf } - func getSign() -> SignType { return Sodium().sign } - - func generateBlindingFactor(serverPublicKey: String) -> Bytes? { return nil } - func blindedKeyPair(serverPublicKey: String, edKeyPair: Box.KeyPair, genericHash: GenericHashType) -> Box.KeyPair? { - return nil - } - func sogsSignature(message: Bytes, secretKey: Bytes, blindedSecretKey ka: Bytes, blindedPublicKey kA: Bytes) -> Bytes? { - return nil - } - - func combineKeys(lhsKeyBytes: Bytes, rhsKeyBytes: Bytes) -> Bytes? { return nil } - func sharedBlindedEncryptionKey(secretKey a: Bytes, otherBlindedPublicKey: Bytes, fromBlindedPublicKey kA: Bytes, toBlindedPublicKey kB: Bytes, genericHash: GenericHashType) -> Bytes? { - return nil - } - - func sessionId(_ sessionId: String, matchesBlindedId blindedSessionId: String, serverPublicKey: String) -> Bool { - return false - } - } - testStorage.mockData[.openGroupServer] = OpenGroupAPI.Server( - name: "testServer", - capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs, .blind], missing: []) - ) - dependencies = dependencies.with(sodium: InvalidSodium()) - - var response: Any? = nil - var error: Error? = nil - - OpenGroupAPI.rooms(for: "testServer", using: dependencies) - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPI.Error.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - func testItFailsToSignIfBlindedAndTheSogsSignatureDoesNotGetGenerated() throws { - class InvalidSodium: SodiumType { - func getGenericHash() -> GenericHashType { return Sodium().genericHash } - func getAeadXChaCha20Poly1305Ietf() -> AeadXChaCha20Poly1305IetfType { return Sodium().aead.xchacha20poly1305ietf } - func getSign() -> SignType { return Sodium().sign } - - func generateBlindingFactor(serverPublicKey: String) -> Bytes? { return nil } - func blindedKeyPair(serverPublicKey: String, edKeyPair: Box.KeyPair, genericHash: GenericHashType) -> Box.KeyPair? { - return Box.KeyPair( - publicKey: Data.data(fromHex: "7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")!.bytes, - secretKey: Data.data(fromHex: "881132ee03dbd2da065aa4c94f96081f62142dc8011d1b7a00de83e4aab38ce4881132ee03dbd2da065aa4c94f96081f62142dc8011d1b7a00de83e4aab38ce4")!.bytes - ) - } - func sogsSignature(message: Bytes, secretKey: Bytes, blindedSecretKey ka: Bytes, blindedPublicKey kA: Bytes) -> Bytes? { - return nil - } - - func combineKeys(lhsKeyBytes: Bytes, rhsKeyBytes: Bytes) -> Bytes? { return nil } - func sharedBlindedEncryptionKey(secretKey a: Bytes, otherBlindedPublicKey: Bytes, fromBlindedPublicKey kA: Bytes, toBlindedPublicKey kB: Bytes, genericHash: GenericHashType) -> Bytes? { - return nil - } - - func sessionId(_ sessionId: String, matchesBlindedId blindedSessionId: String, serverPublicKey: String) -> Bool { - return false - } - } - testStorage.mockData[.openGroupServer] = OpenGroupAPI.Server( - name: "testServer", - capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs, .blind], missing: []) - ) - dependencies = dependencies.with(sodium: InvalidSodium()) - - var response: Any? = nil - var error: Error? = nil - - OpenGroupAPI.rooms(for: "testServer", using: dependencies) - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPI.Error.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - func testItFailsToSignIfUnblindedAndTheSignatureDoesNotGetGenerated() throws { - class InvalidSign: SignType { - var PublicKeyBytes: Int = 32 - - func signature(message: Bytes, secretKey: Bytes) -> Bytes? { return nil } - func verify(message: Bytes, publicKey: Bytes, signature: Bytes) -> Bool { return false } - func toX25519(ed25519PublicKey: Bytes) -> Bytes? { return nil } - } - testStorage.mockData[.openGroupServer] = OpenGroupAPI.Server( - name: "testServer", - capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: []) - ) - dependencies = dependencies.with(sign: InvalidSign()) - - var response: Any? = nil - var error: Error? = nil - - OpenGroupAPI.rooms(for: "testServer", using: dependencies) - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPI.Error.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } -} diff --git a/SessionMessagingKitTests/_TestUtilities/TestStorage.swift b/SessionMessagingKitTests/_TestUtilities/TestStorage.swift index 494cf03c2..08a0ebfbc 100644 --- a/SessionMessagingKitTests/_TestUtilities/TestStorage.swift +++ b/SessionMessagingKitTests/_TestUtilities/TestStorage.swift @@ -19,7 +19,8 @@ class TestStorage: SessionMessagingKitStorageProtocol, Mockable { case openGroupImage case openGroupUserCount case openGroupSequenceNumber - case openGroupLatestMessageId + case openGroupInboxLatestMessageId + case openGroupOutboxLatestMessageId } typealias Key = DataKey @@ -50,6 +51,19 @@ class TestStorage: SessionMessagingKitStorageProtocol, Mockable { func getUser() -> Contact? { return nil } func getAllContacts() -> Set { return Set() } func getAllContacts(with transaction: YapDatabaseReadTransaction) -> Set { return Set() } + + // MARK: - Blinded Id cache + + func getBlindedIdMapping(with blindedId: String) -> BlindedIdMapping? { return nil } + func getBlindedIdMapping(with blindedId: String, using transaction: YapDatabaseReadTransaction) -> BlindedIdMapping? { + return nil + } + + func cacheBlindedIdMapping(_ mapping: BlindedIdMapping) {} + func cacheBlindedIdMapping(_ mapping: BlindedIdMapping, using transaction: YapDatabaseReadWriteTransaction) {} + func enumerateBlindedIdMapping(with block: @escaping (BlindedIdMapping, UnsafeMutablePointer) -> ()) {} + func enumerateBlindedIdMapping(with block: @escaping (BlindedIdMapping, UnsafeMutablePointer) -> (), transaction: YapDatabaseReadTransaction) { + } // MARK: - Closed Groups @@ -111,20 +125,37 @@ class TestStorage: SessionMessagingKitStorageProtocol, Mockable { } func getOpenGroupInboxLatestMessageId(for server: String) -> Int64? { - let data: [String: Int64] = ((mockData[.openGroupLatestMessageId] as? [String: Int64]) ?? [:]) + let data: [String: Int64] = ((mockData[.openGroupInboxLatestMessageId] as? [String: Int64]) ?? [:]) return data[server] } func setOpenGroupInboxLatestMessageId(for server: String, to newValue: Int64, using transaction: Any) { - var updatedData: [String: Int64] = ((mockData[.openGroupLatestMessageId] as? [String: Int64]) ?? [:]) + var updatedData: [String: Int64] = ((mockData[.openGroupInboxLatestMessageId] as? [String: Int64]) ?? [:]) updatedData[server] = newValue - mockData[.openGroupLatestMessageId] = updatedData + mockData[.openGroupInboxLatestMessageId] = updatedData } func removeOpenGroupInboxLatestMessageId(for server: String, using transaction: Any) { - var updatedData: [String: Int64] = ((mockData[.openGroupLatestMessageId] as? [String: Int64]) ?? [:]) + var updatedData: [String: Int64] = ((mockData[.openGroupInboxLatestMessageId] as? [String: Int64]) ?? [:]) updatedData[server] = nil - mockData[.openGroupLatestMessageId] = updatedData + mockData[.openGroupInboxLatestMessageId] = updatedData + } + + func getOpenGroupOutboxLatestMessageId(for server: String) -> Int64? { + let data: [String: Int64] = ((mockData[.openGroupOutboxLatestMessageId] as? [String: Int64]) ?? [:]) + return data[server] + } + + func setOpenGroupOutboxLatestMessageId(for server: String, to newValue: Int64, using transaction: Any) { + var updatedData: [String: Int64] = ((mockData[.openGroupOutboxLatestMessageId] as? [String: Int64]) ?? [:]) + updatedData[server] = newValue + mockData[.openGroupOutboxLatestMessageId] = updatedData + } + + func removeOpenGroupOutboxLatestMessageId(for server: String, using transaction: Any) { + var updatedData: [String: Int64] = ((mockData[.openGroupOutboxLatestMessageId] as? [String: Int64]) ?? [:]) + updatedData[server] = nil + mockData[.openGroupOutboxLatestMessageId] = updatedData } // MARK: - Open Group Public Keys diff --git a/SessionUtilitiesKit/General/SessionId.swift b/SessionUtilitiesKit/General/SessionId.swift index a20c25620..3f81bc6b6 100644 --- a/SessionUtilitiesKit/General/SessionId.swift +++ b/SessionUtilitiesKit/General/SessionId.swift @@ -43,13 +43,6 @@ public struct SessionId { self.publicKey = idString.substring(from: 2) } - public init?(_ type: Prefix, publicKey: String) { - guard ECKeyPair.isValidHexEncodedPublicKey(candidate: publicKey) else { return nil } - - self.prefix = type - self.publicKey = publicKey - } - public init(_ type: Prefix, publicKey: Bytes) { self.prefix = type self.publicKey = publicKey.map { String(format: "%02hhx", $0) }.joined() diff --git a/SessionUtilitiesKitTests/General/SessionIdSpec.swift b/SessionUtilitiesKitTests/General/SessionIdSpec.swift new file mode 100644 index 000000000..99124712a --- /dev/null +++ b/SessionUtilitiesKitTests/General/SessionIdSpec.swift @@ -0,0 +1,87 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +import Quick +import Nimble + +@testable import SessionUtilitiesKit + +class SessionIdSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + describe("a SessionId") { + context("when initializing") { + context("with an idString") { + it("succeeds when correct") { + let sessionId: SessionId? = SessionId(from: "05\(TestConstants.publicKey)") + + expect(sessionId?.prefix).to(equal(.standard)) + expect(sessionId?.publicKey).to(equal(TestConstants.publicKey)) + } + + it("fails when too short") { + expect(SessionId(from: "")).to(beNil()) + } + + it("fails with an invalid prefix") { + expect(SessionId(from: "AB\(TestConstants.publicKey)")).to(beNil()) + } + } + + context("with a prefix and publicKey") { + it("converts the bytes into a hex string") { + let sessionId: SessionId? = SessionId(.standard, publicKey: [0, 1, 2, 3, 4, 5, 6, 7, 8]) + + expect(sessionId?.prefix).to(equal(.standard)) + expect(sessionId?.publicKey).to(equal("000102030405060708")) + } + } + } + + it("generates the correct hex string") { + expect(SessionId(.unblinded, publicKey: Data(hex: TestConstants.publicKey).bytes).hexString) + .to(equal("007aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")) + expect(SessionId(.standard, publicKey: Data(hex: TestConstants.publicKey).bytes).hexString) + .to(equal("057aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")) + expect(SessionId(.blinded, publicKey: Data(hex: TestConstants.publicKey).bytes).hexString) + .to(equal("157aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")) + } + } + + describe("a SessionId Prefix") { + context("when initializing") { + context("with just a prefix") { + it("succeeds when valid") { + expect(SessionId.Prefix(from: "00")).to(equal(.unblinded)) + expect(SessionId.Prefix(from: "05")).to(equal(.standard)) + expect(SessionId.Prefix(from: "15")).to(equal(.blinded)) + } + + it("fails when nil") { + expect(SessionId.Prefix(from: nil)).to(beNil()) + } + + it("fails when invalid") { + expect(SessionId.Prefix(from: "AB")).to(beNil()) + } + } + + context("with a longer string") { + it("fails with invalid hex") { + expect(SessionId.Prefix(from: "Hello!!!")).to(beNil()) + } + + it("fails with the wrong length") { + expect(SessionId.Prefix(from: String(TestConstants.publicKey.prefix(10)))).to(beNil()) + } + + it("fails with an invalid prefix") { + expect(SessionId.Prefix(from: "AB\(TestConstants.publicKey)")).to(beNil()) + } + } + } + } + } +} diff --git a/SharedTest/TestConstants.swift b/SharedTest/TestConstants.swift new file mode 100644 index 000000000..3e2bb9ef0 --- /dev/null +++ b/SharedTest/TestConstants.swift @@ -0,0 +1,10 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +enum TestConstants { + // Test Private key, not actually used (from here https://www.notion.so/oxen/SOGS-Authentication-dc64cc846cb24b2abbf7dd4bfd74abbb) + static let publicKey: String = "7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d" + static let privateKey: String = "881132ee03dbd2da065aa4c94f96081f62142dc8011d1b7a00de83e4aab38ce4" + static let edSecretKey: String = "881132ee03dbd2da065aa4c94f96081f62142dc8011d1b7a00de83e4aab38ce4881132ee03dbd2da065aa4c94f96081f62142dc8011d1b7a00de83e4aab38ce4" +} From 8ca00ca57834a80eebdce8d2a1670f9392ba43b9 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 3 Mar 2022 17:46:35 +1100 Subject: [PATCH 024/157] Fixed a number of bugs, resolved some TODOs and tested the outbox APIs Updated the join open group method to retrieve the capabilities as part of the initial request Updated the OpenGroupManager to require a transaction to be passed to the various 'handler' methods (allowing for everything to be processed within a single transaction) Fixed a few issues where we weren't storing the timestamp for open group messages and DMs which could result in duplicate messages Fixed an issue where we were setting the timestamp value for messages sent to an open group without converting it to be milliseconds to be consistent with other messages Fixed an issue where the BatchRequestInfo could incorrectly flag it's response as failing to parse even though the type was optional Fixed a bug where the open group would re-fetch all messages every other time Fixed a bug where the long press context menu wouldn't appear after failing to delete a message Fixed a bug where joining an open group would trigger the join behaviour and APIs twice --- Session.xcodeproj/project.pbxproj | 290 ++++++++++- .../xcshareddata/xcschemes/Session.xcscheme | 16 +- .../xcschemes/SessionMessagingKit.xcscheme | 4 +- .../xcschemes/SessionUtilitiesKit.xcscheme | 90 ++++ .../ConversationVC+Interaction.swift | 10 +- .../Views & Modals/JoinOpenGroupModal.swift | 2 +- Session/Open Groups/JoinOpenGroupVC.swift | 2 +- .../Common Networking/Header.swift | 2 +- .../Open Groups/Models/BatchRequestInfo.swift | 2 +- .../Open Groups/Models/DirectMessage.swift | 8 +- .../Models/SendDirectMessageResponse.swift | 30 ++ .../Open Groups/OpenGroupAPI.swift | 122 ++++- .../Open Groups/OpenGroupManager.swift | 488 ++++++++++-------- .../MessageReceiver+Decryption.swift | 10 +- .../MessageReceiver+Handling.swift | 2 +- .../Sending & Receiving/MessageReceiver.swift | 15 +- .../Sending & Receiving/MessageSender.swift | 5 +- .../Pollers/OpenGroupPoller.swift | 131 ++--- .../Messaging/OWSMessageUtils.m | 2 +- 19 files changed, 897 insertions(+), 334 deletions(-) create mode 100644 Session.xcodeproj/xcshareddata/xcschemes/SessionUtilitiesKit.xcscheme create mode 100644 SessionMessagingKit/Open Groups/Models/SendDirectMessageResponse.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 3d58ff168..4951ea3c6 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -145,6 +145,7 @@ 7BDCFC0B2421EB7600641C39 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = B6F509951AA53F760068F56A /* Localizable.strings */; }; 8DDB50527360BA38AE415C6F /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C5060C3B36A848B71CCE4685 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit.framework */; }; 9B0A583E9B89FEF0916B793A /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionMessagingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 278EF43EB1E6A0B83C9234F5 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionMessagingKit.framework */; }; + 9BF6299C8E265D8AC63E1D07 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit_SessionUtilitiesKitTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A06DA296F93403656DFA7991 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit_SessionUtilitiesKitTests.framework */; }; A11CD70D17FA230600A2D1B1 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A11CD70C17FA230600A2D1B1 /* QuartzCore.framework */; }; A163E8AB16F3F6AA0094D68B /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A163E8AA16F3F6A90094D68B /* Security.framework */; }; A1C32D5017A06538000A904E /* AddressBookUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A1C32D4F17A06537000A904E /* AddressBookUI.framework */; }; @@ -784,6 +785,14 @@ FD705A94278D052B00F16121 /* UITableView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A93278D052B00F16121 /* UITableView+ReusableView.swift */; }; FD705A98278E9F4D00F16121 /* UIColor+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A97278E9F4D00F16121 /* UIColor+Extensions.swift */; }; FD83B9AA27CF149D005E1583 /* ContactUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9A927CF149D005E1583 /* ContactUtilities.swift */; }; + FD83B9B327CF200A005E1583 /* SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */; platformFilter = ios; }; + FD83B9BB27CF20AF005E1583 /* SessionIdSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9BA27CF20AF005E1583 /* SessionIdSpec.swift */; }; + FD83B9BF27CF2294005E1583 /* TestConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9BD27CF2243005E1583 /* TestConstants.swift */; }; + FD83B9C027CF2294005E1583 /* TestConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9BD27CF2243005E1583 /* TestConstants.swift */; }; + FD83B9C327CF33F7005E1583 /* ServerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9C227CF33F7005E1583 /* ServerSpec.swift */; }; + FD83B9C527CF3E2A005E1583 /* OpenGroupSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9C427CF3E2A005E1583 /* OpenGroupSpec.swift */; }; + FD83B9C727CF3F10005E1583 /* CapabilitiesSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9C627CF3F10005E1583 /* CapabilitiesSpec.swift */; }; + FD83B9C927D0487A005E1583 /* SendDirectMessageResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9C827D0487A005E1583 /* SendDirectMessageResponse.swift */; }; FD859EF227BF6BA200510D0C /* Data+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF127BF6BA200510D0C /* Data+Utilities.swift */; }; FD859EF427C2F49200510D0C /* TestSodium.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF327C2F49200510D0C /* TestSodium.swift */; }; FD859EF627C2F52C00510D0C /* TestSign.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF527C2F52C00510D0C /* TestSign.swift */; }; @@ -834,7 +843,7 @@ FDC4387627B5BEF300C60D73 /* FileDownloadResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4387527B5BEF300C60D73 /* FileDownloadResponse.swift */; }; FDC4387827B5C35400C60D73 /* SendMessageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4387727B5C35400C60D73 /* SendMessageRequest.swift */; }; FDC4389227B9FFC700C60D73 /* SessionMessagingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A6F025539DE700C340D1 /* SessionMessagingKit.framework */; platformFilter = ios; }; - FDC4389A27BA002500C60D73 /* OpenGroupAPIV2Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4389927BA002500C60D73 /* OpenGroupAPIV2Tests.swift */; }; + FDC4389A27BA002500C60D73 /* OpenGroupAPISpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4389927BA002500C60D73 /* OpenGroupAPISpec.swift */; }; FDC4389D27BA01F000C60D73 /* TestStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4389C27BA01F000C60D73 /* TestStorage.swift */; }; FDC438A427BB107F00C60D73 /* UserBanRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438A327BB107F00C60D73 /* UserBanRequest.swift */; }; FDC438A627BB113A00C60D73 /* UserUnbanRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438A527BB113A00C60D73 /* UserUnbanRequest.swift */; }; @@ -969,6 +978,13 @@ remoteGlobalIDString = C33FD9AA255A548A00E217F9; remoteInfo = SignalUtilitiesKit; }; + FD83B9B427CF200A005E1583 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D221A080169C9E5E00537ABF /* Project object */; + proxyType = 1; + remoteGlobalIDString = C3C2A678255388CC00C340D1; + remoteInfo = SessionUtilitiesKit; + }; FDC4386E27B4E90300C60D73 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = D221A080169C9E5E00537ABF /* Project object */; @@ -1240,6 +1256,7 @@ 9B3329176C10E9640865E65B /* Pods-GlobalDependencies-Session.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-Session.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-Session/Pods-GlobalDependencies-Session.debug.xcconfig"; sourceTree = ""; }; 9B533A9FA46206D3D99C9ADA /* Pods-SignalMessaging.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SignalMessaging.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SignalMessaging/Pods-SignalMessaging.debug.xcconfig"; sourceTree = ""; }; 9C0469AC557930C01552CC83 /* Pods-SignalUtilitiesKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SignalUtilitiesKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SignalUtilitiesKit/Pods-SignalUtilitiesKit.app store release.xcconfig"; sourceTree = ""; }; + A06DA296F93403656DFA7991 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit_SessionUtilitiesKitTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit_SessionUtilitiesKitTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; A0FB43B511403A5FAFAC88B8 /* Pods-SessionMessagingKitTests.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionMessagingKitTests.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionMessagingKitTests/Pods-SessionMessagingKitTests.app store release.xcconfig"; sourceTree = ""; }; A11CD70C17FA230600A2D1B1 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; }; A163E8AA16F3F6A90094D68B /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; @@ -1249,7 +1266,9 @@ A5509EC91A69AB8B00ABA4BC /* Main.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = Main.storyboard; sourceTree = ""; }; A5C037C0D2746ABEE2684E70 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SignalUtilitiesKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SignalUtilitiesKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SignalUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SignalUtilitiesKit.debug.xcconfig"; sourceTree = ""; }; A6344D429FFAC3B44E6A06FA /* Pods-SessionSnodeKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionSnodeKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SessionSnodeKit/Pods-SessionSnodeKit.debug.xcconfig"; sourceTree = ""; }; + A87BC4AF5BA3E3DE713B08E5 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit-SessionUtilitiesKitTests.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit-SessionUtilitiesKitTests.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit-SessionUtilitiesKitTests/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit-SessionUtilitiesKitTests.app store release.xcconfig"; sourceTree = ""; }; A9F14F620D87A5BA98DDB608 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionShareExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionShareExtension/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionShareExtension.debug.xcconfig"; sourceTree = ""; }; + AAC5927BC89F6F265332C324 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit-SessionUtilitiesKitTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit-SessionUtilitiesKitTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit-SessionUtilitiesKitTests/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit-SessionUtilitiesKitTests.debug.xcconfig"; sourceTree = ""; }; AD2AB1207E8888E4262D781B /* Pods-SignalTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SignalTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SignalTests/Pods-SignalTests.debug.xcconfig"; sourceTree = ""; }; ADF724B347C8815D97258101 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SignalUtilitiesKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SignalUtilitiesKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SignalUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SignalUtilitiesKit.app store release.xcconfig"; sourceTree = ""; }; AEA8083C060FF9BAFF6E0C9F /* Pods-SessionProtocolKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionProtocolKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SessionProtocolKit/Pods-SessionProtocolKit.debug.xcconfig"; sourceTree = ""; }; @@ -1921,6 +1940,13 @@ FD705A93278D052B00F16121 /* UITableView+ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITableView+ReusableView.swift"; sourceTree = ""; }; FD705A97278E9F4D00F16121 /* UIColor+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Extensions.swift"; sourceTree = ""; }; FD83B9A927CF149D005E1583 /* ContactUtilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContactUtilities.swift; sourceTree = ""; }; + FD83B9AF27CF200A005E1583 /* SessionUtilitiesKitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SessionUtilitiesKitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + FD83B9BA27CF20AF005E1583 /* SessionIdSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionIdSpec.swift; sourceTree = ""; }; + FD83B9BD27CF2243005E1583 /* TestConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestConstants.swift; sourceTree = ""; }; + FD83B9C227CF33F7005E1583 /* ServerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSpec.swift; sourceTree = ""; }; + FD83B9C427CF3E2A005E1583 /* OpenGroupSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupSpec.swift; sourceTree = ""; }; + FD83B9C627CF3F10005E1583 /* CapabilitiesSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapabilitiesSpec.swift; sourceTree = ""; }; + FD83B9C827D0487A005E1583 /* SendDirectMessageResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendDirectMessageResponse.swift; sourceTree = ""; }; 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 = ""; }; @@ -1973,7 +1999,7 @@ FDC4387527B5BEF300C60D73 /* FileDownloadResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDownloadResponse.swift; sourceTree = ""; }; FDC4387727B5C35400C60D73 /* SendMessageRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageRequest.swift; sourceTree = ""; }; FDC4388E27B9FFC700C60D73 /* SessionMessagingKitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SessionMessagingKitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - FDC4389927BA002500C60D73 /* OpenGroupAPIV2Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupAPIV2Tests.swift; sourceTree = ""; }; + FDC4389927BA002500C60D73 /* OpenGroupAPISpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupAPISpec.swift; sourceTree = ""; }; FDC4389C27BA01F000C60D73 /* TestStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestStorage.swift; sourceTree = ""; }; FDC438A327BB107F00C60D73 /* UserBanRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserBanRequest.swift; sourceTree = ""; }; FDC438A527BB113A00C60D73 /* UserUnbanRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserUnbanRequest.swift; sourceTree = ""; }; @@ -2109,6 +2135,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FD83B9AC27CF200A005E1583 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + FD83B9B327CF200A005E1583 /* SessionUtilitiesKit.framework in Frameworks */, + 9BF6299C8E265D8AC63E1D07 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit_SessionUtilitiesKitTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; FDC4388B27B9FFC700C60D73 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -2329,6 +2364,8 @@ C8153B96A292A25045BE2C54 /* Pods-SessionTests.app store release.xcconfig */, 949F269926ABA08C125DCA9D /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests.debug.xcconfig */, 0208C84C4D15048D699BEC10 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests.app store release.xcconfig */, + AAC5927BC89F6F265332C324 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit-SessionUtilitiesKitTests.debug.xcconfig */, + A87BC4AF5BA3E3DE713B08E5 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit-SessionUtilitiesKitTests.app store release.xcconfig */, ); name = Pods; sourceTree = ""; @@ -3722,7 +3759,9 @@ C3C2A6F125539DE700C340D1 /* SessionMessagingKit */, C3C2A5A0255385C100C340D1 /* SessionSnodeKit */, C3C2A67A255388CC00C340D1 /* SessionUtilitiesKit */, + FD83B9BC27CF2215005E1583 /* SharedTest */, FDC4388F27B9FFC700C60D73 /* SessionMessagingKitTests */, + FD83B9B027CF200A005E1583 /* SessionUtilitiesKitTests */, D221A08C169C9E5E00537ABF /* Frameworks */, D221A08A169C9E5E00537ABF /* Products */, 9404664EC513585B05DF1350 /* Pods */, @@ -3741,6 +3780,7 @@ C331FF1B2558F9D300070591 /* SessionUIKit.framework */, C33FD9AB255A548A00E217F9 /* SignalUtilitiesKit.framework */, FDC4388E27B9FFC700C60D73 /* SessionMessagingKitTests.xctest */, + FD83B9AF27CF200A005E1583 /* SessionUtilitiesKitTests.xctest */, ); name = Products; sourceTree = ""; @@ -3795,6 +3835,7 @@ 8962372EEC51D3F56FE3A68A /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SignalUtilitiesKit.framework */, D2C155B76C8483CB9A6EA9B4 /* Pods_SessionTests.framework */, F1E0F51F17E4443731B94D32 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionMessagingKit_SessionMessagingKitTests.framework */, + A06DA296F93403656DFA7991 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit_SessionUtilitiesKitTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -3830,6 +3871,40 @@ path = "Message Requests"; sourceTree = ""; }; + FD83B9B027CF200A005E1583 /* SessionUtilitiesKitTests */ = { + isa = PBXGroup; + children = ( + FD83B9B927CF20A5005E1583 /* General */, + ); + path = SessionUtilitiesKitTests; + sourceTree = ""; + }; + FD83B9B927CF20A5005E1583 /* General */ = { + isa = PBXGroup; + children = ( + FD83B9BA27CF20AF005E1583 /* SessionIdSpec.swift */, + ); + path = General; + sourceTree = ""; + }; + FD83B9BC27CF2215005E1583 /* SharedTest */ = { + isa = PBXGroup; + children = ( + FD83B9BD27CF2243005E1583 /* TestConstants.swift */, + ); + path = SharedTest; + sourceTree = ""; + }; + FD83B9C127CF33EE005E1583 /* Models */ = { + isa = PBXGroup; + children = ( + FD83B9C227CF33F7005E1583 /* ServerSpec.swift */, + FD83B9C427CF3E2A005E1583 /* OpenGroupSpec.swift */, + FD83B9C627CF3F10005E1583 /* CapabilitiesSpec.swift */, + ); + path = Models; + sourceTree = ""; + }; FD88BAD727A7438E00BBC442 /* Views */ = { isa = PBXGroup; children = ( @@ -3868,6 +3943,7 @@ FDC4386227B4D94E00C60D73 /* OGMessage.swift */, FDC438C627BB6DF000C60D73 /* DirectMessage.swift */, FDC438C827BB706500C60D73 /* SendDirectMessageRequest.swift */, + FD83B9C827D0487A005E1583 /* SendDirectMessageResponse.swift */, FDC438A327BB107F00C60D73 /* UserBanRequest.swift */, FDC438A527BB113A00C60D73 /* UserUnbanRequest.swift */, FDC438A927BB12BB00C60D73 /* UserModeratorRequest.swift */, @@ -3940,7 +4016,8 @@ FDC4389827BA001800C60D73 /* Open Groups */ = { isa = PBXGroup; children = ( - FDC4389927BA002500C60D73 /* OpenGroupAPIV2Tests.swift */, + FD83B9C127CF33EE005E1583 /* Models */, + FDC4389927BA002500C60D73 /* OpenGroupAPISpec.swift */, ); path = "Open Groups"; sourceTree = ""; @@ -4305,6 +4382,26 @@ productReference = D221A089169C9E5E00537ABF /* Session.app */; productType = "com.apple.product-type.application"; }; + FD83B9AE27CF200A005E1583 /* SessionUtilitiesKitTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = FD83B9B627CF200A005E1583 /* Build configuration list for PBXNativeTarget "SessionUtilitiesKitTests" */; + buildPhases = ( + 96A0691CEB0B517292629903 /* [CP] Check Pods Manifest.lock */, + FD83B9AB27CF200A005E1583 /* Sources */, + FD83B9AC27CF200A005E1583 /* Frameworks */, + FD83B9AD27CF200A005E1583 /* Resources */, + 71F2A4CB38075EC3A9D35AE3 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + FD83B9B527CF200A005E1583 /* PBXTargetDependency */, + ); + name = SessionUtilitiesKitTests; + productName = SessionUtilitiesKitTests; + productReference = FD83B9AF27CF200A005E1583 /* SessionUtilitiesKitTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; FDC4388D27B9FFC700C60D73 /* SessionMessagingKitTests */ = { isa = PBXNativeTarget; buildConfigurationList = FDC4389527B9FFC700C60D73 /* Build configuration list for PBXNativeTarget "SessionMessagingKitTests" */; @@ -4423,6 +4520,9 @@ }; }; }; + FD83B9AE27CF200A005E1583 = { + CreatedOnToolsVersion = 13.2.1; + }; FDC4388D27B9FFC700C60D73 = { CreatedOnToolsVersion = 13.2.1; }; @@ -4471,6 +4571,7 @@ C3C2A59E255385C100C340D1 /* SessionSnodeKit */, C3C2A678255388CC00C340D1 /* SessionUtilitiesKit */, FDC4388D27B9FFC700C60D73 /* SessionMessagingKitTests */, + FD83B9AE27CF200A005E1583 /* SessionUtilitiesKitTests */, ); }; /* End PBXProject section */ @@ -4595,6 +4696,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FD83B9AD27CF200A005E1583 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; FDC4388C27B9FFC700C60D73 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -4702,6 +4810,23 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + 71F2A4CB38075EC3A9D35AE3 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit-SessionUtilitiesKitTests/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit-SessionUtilitiesKitTests-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit-SessionUtilitiesKitTests/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit-SessionUtilitiesKitTests-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit-SessionUtilitiesKitTests/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit-SessionUtilitiesKitTests-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; 7D43E8AB603234C5ADEF2812 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -4763,6 +4888,28 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + 96A0691CEB0B517292629903 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit-SessionUtilitiesKitTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; A067C0B8A52FC6C6FDA49939 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -5291,6 +5438,7 @@ FDC4386B27B4E88F00C60D73 /* BatchRequestInfo.swift in Sources */, FDC4385127B4807400C60D73 /* QueryParam.swift in Sources */, C32C5B62256DC333003C73A2 /* OWSDisappearingConfigurationUpdateInfoMessage.m in Sources */, + FD83B9C927D0487A005E1583 /* SendDirectMessageResponse.swift in Sources */, C352A2F525574B4700338F3E /* Job.swift in Sources */, FDC4385727B484B700C60D73 /* LegacyFileUploadResponse.swift in Sources */, FDC4385B27B485DE00C60D73 /* LegacyFileDownloadResponse.swift in Sources */, @@ -5477,15 +5625,28 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FD83B9AB27CF200A005E1583 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FD83B9BF27CF2294005E1583 /* TestConstants.swift in Sources */, + FD83B9BB27CF20AF005E1583 /* SessionIdSpec.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; FDC4388A27B9FFC700C60D73 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( FD859EFA27C2F5C500510D0C /* TestGenericHash.swift in Sources */, + FD83B9C327CF33F7005E1583 /* ServerSpec.swift in Sources */, + FD83B9C727CF3F10005E1583 /* CapabilitiesSpec.swift in Sources */, FD859EF827C2F58900510D0C /* TestAeadXChaCha20Poly1305Ietf.swift in Sources */, FD859EF427C2F49200510D0C /* TestSodium.swift in Sources */, FD859EFC27C2F60700510D0C /* TestEd25519.swift in Sources */, - FDC4389A27BA002500C60D73 /* OpenGroupAPIV2Tests.swift in Sources */, + FD83B9C027CF2294005E1583 /* TestConstants.swift in Sources */, + FDC4389A27BA002500C60D73 /* OpenGroupAPISpec.swift in Sources */, + FD83B9C527CF3E2A005E1583 /* OpenGroupSpec.swift in Sources */, FDC438BD27BB2AB400C60D73 /* Mockable.swift in Sources */, FD859EF627C2F52C00510D0C /* TestSign.swift in Sources */, FDC4389D27BA01F000C60D73 /* TestStorage.swift in Sources */, @@ -5575,6 +5736,12 @@ target = C33FD9AA255A548A00E217F9 /* SignalUtilitiesKit */; targetProxy = C3D90A7025773A44002C9DF5 /* PBXContainerItemProxy */; }; + FD83B9B527CF200A005E1583 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + platformFilter = ios; + target = C3C2A678255388CC00C340D1 /* SessionUtilitiesKit */; + targetProxy = FD83B9B427CF200A005E1583 /* PBXContainerItemProxy */; + }; FDC4386F27B4E90300C60D73 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = C3C2A678255388CC00C340D1 /* SessionUtilitiesKit */; @@ -6936,6 +7103,112 @@ }; name = "App Store Release"; }; + FD83B9B727CF200A005E1583 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AAC5927BC89F6F265332C324 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit-SessionUtilitiesKitTests.debug.xcconfig */; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.2; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = io.oxen.SessionUtilitiesKitTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + FD83B9B827CF200A005E1583 /* App Store Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A87BC4AF5BA3E3DE713B08E5 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit-SessionUtilitiesKitTests.app store release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.2; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = io.oxen.SessionUtilitiesKitTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = "App Store Release"; + }; FDC4389627B9FFC700C60D73 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 949F269926ABA08C125DCA9D /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests.debug.xcconfig */; @@ -7126,6 +7399,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = "App Store Release"; }; + FD83B9B627CF200A005E1583 /* Build configuration list for PBXNativeTarget "SessionUtilitiesKitTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FD83B9B727CF200A005E1583 /* Debug */, + FD83B9B827CF200A005E1583 /* App Store Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = "App Store Release"; + }; FDC4389527B9FFC700C60D73 /* Build configuration list for PBXNativeTarget "SessionMessagingKitTests" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Session.xcodeproj/xcshareddata/xcschemes/Session.xcscheme b/Session.xcodeproj/xcshareddata/xcschemes/Session.xcscheme index 3f9cd0749..bb11967c8 100644 --- a/Session.xcodeproj/xcshareddata/xcschemes/Session.xcscheme +++ b/Session.xcodeproj/xcshareddata/xcschemes/Session.xcscheme @@ -105,7 +105,9 @@ + skipped = "NO" + parallelizable = "YES" + testExecutionOrdering = "random"> + + + + + skipped = "NO" + parallelizable = "YES" + testExecutionOrdering = "random"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index fc5b3e072..99b9b8370 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -490,11 +490,17 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc // MARK: View Item Interaction func handleViewItemLongPressed(_ viewItem: ConversationViewItem) { - // Show the context menu if applicable - guard let index = viewItems.firstIndex(where: { $0 === viewItem }), + // Note: There seems to be some odd behaviours with how the UI and the data update and as a result the `viewItem` the + // user interacted with can be a different instance from what is in `viewItems` (likely the data updated but the user + // interacted with old UI which had cached the old value) + // + // By using an equality check on the interaction we avoid this odd behaviour + guard let index = viewItems.firstIndex(where: { $0.interaction == viewItem.interaction }), let cell = messagesTableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell, let snapshot = cell.bubbleView.snapshotView(afterScreenUpdates: false), contextMenuWindow == nil, !ContextMenuVC.actions(for: viewItem, delegate: self).isEmpty else { return } + + // Show the context menu if applicable UIImpactFeedbackGenerator(style: .heavy).impactOccurred() let frame = cell.convert(cell.bubbleView.frame, to: UIApplication.shared.keyWindow!) let window = ContextMenuWindow() diff --git a/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift b/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift index fc32e51f9..2d89c3047 100644 --- a/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift +++ b/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift @@ -72,7 +72,7 @@ final class JoinOpenGroupModal : Modal { Storage.shared.write { [presentingViewController = self.presentingViewController!] transaction in OpenGroupManager.shared - .add(roomToken: room, server: server, publicKey: publicKey, using: transaction) + .add(roomToken: room, server: server, publicKey: publicKey, isConfigMessage: false, using: transaction as! YapDatabaseReadWriteTransaction) .done(on: DispatchQueue.main) { _ in let appDelegate = UIApplication.shared.delegate as! AppDelegate appDelegate.forceSyncConfigurationNowIfNeeded().retainUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...) diff --git a/Session/Open Groups/JoinOpenGroupVC.swift b/Session/Open Groups/JoinOpenGroupVC.swift index 8973ce1aa..1d1b27af6 100644 --- a/Session/Open Groups/JoinOpenGroupVC.swift +++ b/Session/Open Groups/JoinOpenGroupVC.swift @@ -142,7 +142,7 @@ final class JoinOpenGroupVC : BaseVC, UIPageViewControllerDataSource, UIPageView ModalActivityIndicatorViewController.present(fromViewController: navigationController!, canCancel: false) { [weak self] _ in Storage.shared.write { transaction in OpenGroupManager.shared - .add(roomToken: roomToken, server: server, publicKey: publicKey, using: transaction) + .add(roomToken: roomToken, server: server, publicKey: publicKey, isConfigMessage: false, using: transaction as! YapDatabaseReadWriteTransaction) .done(on: DispatchQueue.main) { [weak self] _ in self?.presentingViewController?.dismiss(animated: true, completion: nil) let appDelegate = UIApplication.shared.delegate as! AppDelegate diff --git a/SessionMessagingKit/Common Networking/Header.swift b/SessionMessagingKit/Common Networking/Header.swift index 41f8ad82c..97ea01ef7 100644 --- a/SessionMessagingKit/Common Networking/Header.swift +++ b/SessionMessagingKit/Common Networking/Header.swift @@ -5,9 +5,9 @@ import Foundation enum Header: String { case authorization = "Authorization" case contentType = "Content-Type" + case contentDisposition = "Content-Disposition" case room = "Room" // TODO: Confirm this is needed - case fileName = "X-Filename" case sogsPubKey = "X-SOGS-Pubkey" case sogsNonce = "X-SOGS-Nonce" diff --git a/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift b/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift index d31ee4014..ef789bebc 100644 --- a/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift +++ b/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift @@ -124,7 +124,7 @@ extension OpenGroupAPI.BatchSubResponse { code: try container.decode(Int32.self, forKey: .code), headers: try container.decode([String: String].self, forKey: .headers), body: body, - failedToParseBody: (body == nil && T.self != OpenGroupAPI.NoResponse.self) + failedToParseBody: (body == nil && T.self != OpenGroupAPI.NoResponse.self && !(T.self is ExpressibleByNilLiteral.Type)) ) } } diff --git a/SessionMessagingKit/Open Groups/Models/DirectMessage.swift b/SessionMessagingKit/Open Groups/Models/DirectMessage.swift index 941f21b7e..f2e7421ef 100644 --- a/SessionMessagingKit/Open Groups/Models/DirectMessage.swift +++ b/SessionMessagingKit/Open Groups/Models/DirectMessage.swift @@ -16,11 +16,11 @@ extension OpenGroupAPI { /// The unique integer message id public let id: Int64 - /// The (blinded) Session ID of the sender of the message (null on outgoing messages) - public let sender: String? + /// The (blinded) Session ID of the sender of the message + public let sender: String - /// The (blinded) Session ID of the recipient of the message (null on incoming message) - public let recipient: String? + /// The (blinded) Session ID of the recipient of the message + public let recipient: String /// Unix timestamp when the message was received by SOGS public let posted: TimeInterval diff --git a/SessionMessagingKit/Open Groups/Models/SendDirectMessageResponse.swift b/SessionMessagingKit/Open Groups/Models/SendDirectMessageResponse.swift new file mode 100644 index 000000000..5768b863a --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/SendDirectMessageResponse.swift @@ -0,0 +1,30 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPI { + public struct SendDirectMessageResponse: Codable { + enum CodingKeys: String, CodingKey { + case id + case sender + case recipient + case posted = "posted_at" + case expires = "expires_at" + } + + /// The unique integer message id + public let id: Int64 + + /// The (blinded) Session ID of the sender of the message + public let sender: String + + /// The (blinded) Session ID of the recipient of the message + public let recipient: String + + /// Unix timestamp when the message was received by SOGS + public let posted: TimeInterval + + /// Unix timestamp when SOGS will expire and delete the message + public let expires: TimeInterval + } +} diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index ff06432da..9a27e00d2 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -228,6 +228,11 @@ public final class OpenGroupAPI: NSObject { } /// Returns the details of a single room + /// + /// **Note:** This is the direct request to retrieve a room so should only be called from either the `poll()` or `joinRoom()` methods, in order to call + /// this directly remove the `@available` line and make sure to route the response of this method to the `OpenGroupManager.handlePollInfo` + /// method to ensure things are processed correctly + @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") public static func room(for roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Room)> { let request: Request = Request( server: server, @@ -246,7 +251,7 @@ public final class OpenGroupAPI: NSObject { /// **Note:** This is the direct request to retrieve room updates so should be retrieved automatically from the `poll()` method, in order to call /// this directly remove the `@available` line and make sure to route the response of this method to the `OpenGroupManager.handlePollInfo` /// method to ensure things are processed correctly - @available(*, unavailable, message: "Avoid using this directly, use the pre-build `poll()` method instead") + @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") public static func roomPollInfo(lastUpdated: Int64, for roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, RoomPollInfo)> { let request: Request = Request( server: server, @@ -257,6 +262,66 @@ public final class OpenGroupAPI: NSObject { .decoded(as: RoomPollInfo.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) } + /// This is a convenience method which constructs a `/sequence` of the `capabilities` and `room` requests, refer to those + /// methods for the documented behaviour of each method + public static func capabilitiesAndRoom( + for roomToken: String, + on server: String, + using dependencies: Dependencies = Dependencies() + ) -> Promise<(capabilities: (OnionRequestResponseInfoType, Capabilities?), room: (OnionRequestResponseInfoType, Room?))> { + let requestResponseType: [BatchRequestInfoType] = [ + // Get the latest capabilities for the server (in case it's a new server or the cached ones are stale) + BatchRequestInfo( + request: Request( + server: server, + endpoint: .capabilities + ), + responseType: Capabilities.self + ), + + // And the room info + BatchRequestInfo( + request: Request( + server: server, + endpoint: .room(roomToken) + ), + responseType: Room.self + ) + ] + + return sequence(server, requests: requestResponseType, using: dependencies) + .map { response -> (capabilities: (OnionRequestResponseInfoType, Capabilities?), room: (OnionRequestResponseInfoType, Room?)) in + var capabilities: (OnionRequestResponseInfoType, Capabilities?)? = nil + var room: (OnionRequestResponseInfoType, Room?)? = nil + + try response.forEach { (endpoint: Endpoint, endpointResponse: (info: OnionRequestResponseInfoType, data: Codable?)) in + switch endpoint { + case .capabilities: + guard let responseData: BatchSubResponse = endpointResponse.data as? BatchSubResponse, let responseBody: Capabilities = responseData.body else { + throw Error.parsingFailed + } + + capabilities = (endpointResponse.info, responseBody) + + case .room: + guard let responseData: OpenGroupAPI.BatchSubResponse = endpointResponse.data as? OpenGroupAPI.BatchSubResponse, let responseBody: OpenGroupAPI.Room = responseData.body else { + throw Error.parsingFailed + } + + room = (endpointResponse.info, responseBody) + + default: break // No custom handling needed + } + } + + guard let capabilities: (OnionRequestResponseInfoType, Capabilities?) = capabilities, let room: (OnionRequestResponseInfoType, Room?) = room else { + throw Error.parsingFailed + } + + return (capabilities, room) + } + } + // MARK: - Messages /// Posts a new message to a room @@ -289,6 +354,15 @@ public final class OpenGroupAPI: NSObject { return send(request, using: dependencies) .decoded(as: Message.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) + .map { response, message in + // Store the 'message.posted' timestamp to prevent the sent message getting duplicated when it is later retrieved + dependencies.storage.write { transaction in + // The `posted` value is in seconds but we sent it in ms so need that for de-duping + dependencies.storage.addReceivedMessageTimestamp(UInt64(floor(message.posted * 1000)), using: transaction) + } + + return (response, message) + } } /// Returns a single message by ID @@ -334,7 +408,6 @@ public final class OpenGroupAPI: NSObject { return send(request, using: dependencies) } - // TODO: Need to test this once the API has been implemented public static func messageDelete( _ id: Int64, in roomToken: String, @@ -347,19 +420,13 @@ public final class OpenGroupAPI: NSObject { endpoint: .roomMessageIndividual(roomToken, id: id) ) - // TODO: Handle custom response info? Need to let the OpenGroupManager know to delete the message? - // TODO: !!!! This is currently broken - looks like there isn't currently a DELETE endpoint (but there should be) return send(request, using: dependencies) - .map { response in - print("RAWR") - return response - } } /// **Note:** This is the direct request to retrieve recent messages so should be retrieved automatically from the `poll()` method, in order to call /// this directly remove the `@available` line and make sure to route the response of this method to the `OpenGroupManager.handleMessages` /// method to ensure things are processed correctly - @available(*, unavailable, message: "Avoid using this directly, use the pre-build `poll()` method instead") + @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") public static func recentMessages(in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [Message])> { let request: Request = Request( server: server, @@ -375,7 +442,7 @@ public final class OpenGroupAPI: NSObject { /// **Note:** This is the direct request to retrieve recent messages before a given message and is currently unused, in order to call this directly /// remove the `@available` line and make sure to route the response of this method to the `OpenGroupManager.handleMessages` /// method to ensure things are processed correctly - @available(*, unavailable, message: "Avoid using this directly, use the pre-build `poll()` method instead") + @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") public static func messagesBefore(messageId: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [Message])> { // TODO: Do we need to be able to load old messages? let request: Request = Request( @@ -392,7 +459,7 @@ public final class OpenGroupAPI: NSObject { /// **Note:** This is the direct request to retrieve messages since a given message `seqNo` so should be retrieved automatically from the /// `poll()` method, in order to call this directly remove the `@available` line and make sure to route the response of this method to the /// `OpenGroupManager.handleMessages` method to ensure things are processed correctly - @available(*, unavailable, message: "Avoid using this directly, use the pre-build `poll()` method instead") + @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") public static func messagesSince(seqNo: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [Message])> { let request: Request = Request( server: server, @@ -463,7 +530,12 @@ public final class OpenGroupAPI: NSObject { method: .post, server: server, endpoint: .roomFile(roomToken), - headers: [ .fileName: fileName ].compactMapValues { $0 }, + headers: [ + .contentDisposition: [ "attachment", fileName.map { "filename=\"\($0)\"" } ] + .compactMap{ $0 } + .joined(separator: "; "), + .contentType: "application/octet-stream" + ], body: bytes ) @@ -478,7 +550,11 @@ public final class OpenGroupAPI: NSObject { method: .post, server: server, endpoint: .roomFileJson(roomToken), - headers: [ .fileName: fileName ].compactMapValues { $0 }, + headers: [ + .contentDisposition: [ "attachment", fileName.map { "filename=\"\($0)\"" } ] + .compactMap{ $0 } + .joined(separator: "; "), + ], body: base64EncodedString ) @@ -517,7 +593,7 @@ public final class OpenGroupAPI: NSObject { /// **Note:** This is the direct request to retrieve DMs for a specific Open Group so should be retrieved automatically from the `poll()` /// method, in order to call this directly remove the `@available` line and make sure to route the response of this method to the /// `OpenGroupManager.handleDirectMessages` method to ensure things are processed correctly - @available(*, unavailable, message: "Avoid using this directly, use the pre-build `poll()` method instead") + @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") public static func inbox(on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> { let request: Request = Request( server: server, @@ -533,7 +609,7 @@ public final class OpenGroupAPI: NSObject { /// **Note:** This is the direct request to retrieve messages requests for a specific Open Group since a given messages so should be retrieved /// automatically from the `poll()` method, in order to call this directly remove the `@available` line and make sure to route the response /// of this method to the `OpenGroupManager.handleDirectMessages` method to ensure things are processed correctly - @available(*, unavailable, message: "Avoid using this directly, use the pre-build `poll()` method instead") + @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") public static func inboxSince(id: Int64, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> { let request: Request = Request( server: server, @@ -547,7 +623,7 @@ public final class OpenGroupAPI: NSObject { /// Delivers a direct message to a user via their blinded Session ID /// /// The body of this request is a JSON object containing a message key with a value of the encrypted-then-base64-encoded message to deliver - public static func send(_ ciphertext: Data, toInboxFor blindedSessionId: String, on server: String/*, with serverPublicKey: String*/, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Data?)> { + public static func send(_ ciphertext: Data, toInboxFor blindedSessionId: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, SendDirectMessageResponse)> { let requestBody: SendDirectMessageRequest = SendDirectMessageRequest( message: ciphertext ) @@ -560,6 +636,16 @@ public final class OpenGroupAPI: NSObject { ) return send(request, using: dependencies) + .decoded(as: SendDirectMessageResponse.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) + .map { response, message in + // Store the 'message.posted' timestamp to prevent the sent message getting duplicated when it is later retrieved + dependencies.storage.write { transaction in + // The `posted` value is in seconds but we sent it in ms so need that for de-duping + dependencies.storage.addReceivedMessageTimestamp(UInt64(floor(message.posted * 1000)), using: transaction) + } + + return (response, message) + } } /// Retrieves all of the user's sent DMs (up to limit) @@ -567,7 +653,7 @@ public final class OpenGroupAPI: NSObject { /// **Note:** This is the direct request to retrieve DMs sent by the user for a specific Open Group so should be retrieved automatically /// from the `poll()` method, in order to call this directly remove the `@available` line and make sure to route the response of /// this method to the `OpenGroupManager.handleDirectMessages` method to ensure things are processed correctly - @available(*, unavailable, message: "Avoid using this directly, use the pre-build `poll()` method instead") + @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") public static func outbox(on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> { let request: Request = Request( server: server, @@ -583,7 +669,7 @@ public final class OpenGroupAPI: NSObject { /// **Note:** This is the direct request to retrieve messages requests sent by the user for a specific Open Group since a given messages so /// should be retrieved automatically from the `poll()` method, in order to call this directly remove the `@available` line and make sure /// to route the response of this method to the `OpenGroupManager.handleDirectMessages` method to ensure things are processed correctly - @available(*, unavailable, message: "Avoid using this directly, use the pre-build `poll()` method instead") + @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") public static func outboxSince(id: Int64, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> { let request: Request = Request( server: server, diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index e80a03dd7..1726c15b8 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -1,6 +1,7 @@ import PromiseKit import Sodium import SessionUtilitiesKit +import SessionSnodeKit @objc(SNOpenGroupManager) public final class OpenGroupManager: NSObject { @@ -40,28 +41,54 @@ public final class OpenGroupManager: NSObject { // MARK: - Adding & Removing - public func add(roomToken: String, server: String, publicKey: String, using transaction: Any) -> Promise { - let storage = Storage.shared + public func add(roomToken: String, server: String, publicKey: String, isConfigMessage: Bool, using transaction: YapDatabaseReadWriteTransaction, dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) -> Promise { + // If we are currently polling for this server and already have a TSGroupThread for this room the do nothing + let groupId: Data = LKGroupUtilities.getEncodedOpenGroupIDAsData("\(server).\(roomToken)") + + if OpenGroupManager.shared.pollers[server] != nil && TSGroupThread.fetch(uniqueId: TSGroupThread.threadId(fromGroupId: groupId), transaction: transaction) != nil { + SNLog("Ignoring join open group attempt, user initiated: \(!isConfigMessage)") + return Promise.value(()) + } // Clear any existing data if needed - storage.removeOpenGroupSequenceNumber(for: roomToken, on: server, using: transaction) + dependencies.storage.removeOpenGroupSequenceNumber(for: roomToken, on: server, using: transaction) // Store the public key - storage.setOpenGroupPublicKey(for: server, to: publicKey, using: transaction) + dependencies.storage.setOpenGroupPublicKey(for: server, to: publicKey, using: transaction) let (promise, seal) = Promise.pending() - let transaction = transaction as! YapDatabaseReadWriteTransaction transaction.addCompletionQueue(DispatchQueue.global(qos: .userInitiated)) { - OpenGroupAPI.room(for: roomToken, on: server) - .done(on: DispatchQueue.global(qos: .userInitiated)) { _, room in - OpenGroupManager.handleRoom( - room, - publicKey: publicKey, - for: roomToken, - on: server - ) { - seal.fulfill(()) + OpenGroupAPI.capabilitiesAndRoom(for: roomToken, on: server, using: dependencies) + .done(on: DispatchQueue.global(qos: .userInitiated)) { (capabilitiesResponse: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Capabilities?), roomResponse: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Room?)) in + guard let capabilities: OpenGroupAPI.Capabilities = capabilitiesResponse.data, let room: OpenGroupAPI.Room = roomResponse.data else { + SNLog("Failed to join open group due to invalid data.") + seal.reject(OpenGroupAPI.Error.generic) + return + } + + dependencies.storage.write { anyTransactionas in + guard let transaction: YapDatabaseReadWriteTransaction = anyTransactionas as? YapDatabaseReadWriteTransaction else { return } + + // Store the capabilities first + OpenGroupManager.handleCapabilities( + capabilities, + on: server, + using: transaction, + dependencies: dependencies + ) + + // Then the room + OpenGroupManager.handleRoom( + room, + publicKey: publicKey, + for: roomToken, + on: server, + using: transaction, + dependencies: dependencies + ) { + seal.fulfill(()) + } } } .catch(on: DispatchQueue.global(qos: .userInitiated)) { error in @@ -109,79 +136,15 @@ public final class OpenGroupManager: NSObject { internal static func handleCapabilities( _ capabilities: OpenGroupAPI.Capabilities, on server: String, - using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies() + using transaction: YapDatabaseReadWriteTransaction, + dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies() ) { - dependencies.storage.write { transaction in - let updatedServer: OpenGroupAPI.Server = OpenGroupAPI.Server( - name: server, - capabilities: capabilities - ) - - dependencies.storage.setOpenGroupServer(updatedServer, using: transaction) - } - } - - internal static func handleMessages( - _ messages: [OpenGroupAPI.Message], - for roomToken: String, - on server: String, - isBackgroundPoll: Bool, - using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies() - ) { - // Sorting the messages by server ID before importing them fixes an issue where messages - // that quote older messages can't find those older messages - let openGroupID = "\(server).\(roomToken)" - let sortedMessages: [OpenGroupAPI.Message] = messages - .sorted { lhs, rhs in lhs.id < rhs.id } - let seqNo: Int64 = (sortedMessages.map { $0.seqNo }.max() ?? 0) + let updatedServer: OpenGroupAPI.Server = OpenGroupAPI.Server( + name: server, + capabilities: capabilities + ) - dependencies.storage.write { transaction in - var messageServerIDsToRemove: [UInt64] = [] - - // Update the 'openGroupSequenceNumber' value (Note: SOGS V4 uses the 'seqNo' instead of the 'serverId') - dependencies.storage.setOpenGroupSequenceNumber(for: roomToken, on: server, to: seqNo, using: transaction) - - // Process the messages - sortedMessages.forEach { message in - guard let base64EncodedString: String = message.base64EncodedData, let data = Data(base64Encoded: base64EncodedString), let sender: String = message.sender else { - // A message with no data has been deleted so add it to the list to remove - messageServerIDsToRemove.append(UInt64(message.id)) - return - } - - // Note: The `posted` value is in seconds but all messages in the database use milliseconds for timestamps - let envelope = SNProtoEnvelope.builder(type: .sessionMessage, timestamp: UInt64(floor(message.posted * 1000))) - envelope.setContent(data) - envelope.setSource(sender) - - do { - let data = try envelope.buildSerializedData() - let (message, proto) = try MessageReceiver.parse(data, openGroupMessageServerID: UInt64(message.id), isRetry: false, using: transaction) - try MessageReceiver.handle(message, associatedWithProto: proto, openGroupID: openGroupID, isBackgroundPoll: isBackgroundPoll, using: transaction) - } - catch { - SNLog("Couldn't receive open group message due to error: \(error).") - } - } - - // Handle any deletions that are needed - guard !messageServerIDsToRemove.isEmpty else { return } - guard let transaction: YapDatabaseReadWriteTransaction = transaction as? YapDatabaseReadWriteTransaction else { return } - guard let threadID = dependencies.storage.getThreadID(for: openGroupID), let thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction) else { - return - } - - var messagesToRemove: [TSMessage] = [] - - thread.enumerateInteractions(with: transaction) { interaction, stop in - guard let message: TSMessage = interaction as? TSMessage, messageServerIDsToRemove.contains(message.openGroupServerMessageID) else { - return - } - messagesToRemove.append(message) - } - - messagesToRemove.forEach { $0.remove(with: transaction) } - } + dependencies.storage.setOpenGroupServer(updatedServer, using: transaction) } internal static func handleRoom( @@ -189,7 +152,8 @@ public final class OpenGroupManager: NSObject { publicKey: String, for roomToken: String, on server: String, - using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies(), + using transaction: YapDatabaseReadWriteTransaction, + dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies(), completion: (() -> ())? = nil ) { OpenGroupManager.handlePollInfo( @@ -197,7 +161,8 @@ public final class OpenGroupManager: NSObject { publicKey: publicKey, for: roomToken, on: server, - using: dependencies, + using: transaction, + dependencies: dependencies, completion: completion ) } @@ -207,7 +172,8 @@ public final class OpenGroupManager: NSObject { publicKey maybePublicKey: String?, for roomToken: String, on server: String, - using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies(), + using transaction: YapDatabaseReadWriteTransaction, + dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies(), completion: (() -> ())? = nil ) { // Create the open group model and get or create the thread @@ -225,90 +191,148 @@ public final class OpenGroupManager: NSObject { var maybeUpdatedModel: TSGroupModel? = nil // Store/Update everything - dependencies.storage.write( - with: { transaction in - let transaction = transaction as! YapDatabaseReadWriteTransaction - let thread = TSGroupThread.getOrCreateThread(with: initialModel, transaction: transaction) - let existingOpenGroup: OpenGroup? = thread.uniqueId.flatMap { uniqueId -> OpenGroup? in - dependencies.storage.getOpenGroup(for: uniqueId) - } + let thread = TSGroupThread.getOrCreateThread(with: initialModel, transaction: transaction) + let existingOpenGroup: OpenGroup? = thread.uniqueId.flatMap { uniqueId -> OpenGroup? in + dependencies.storage.getOpenGroup(for: uniqueId) + } - guard let threadUniqueId: String = thread.uniqueId else { return } - guard let publicKey: String = (maybePublicKey ?? existingOpenGroup?.publicKey) else { return } - - let updatedModel: TSGroupModel = TSGroupModel( - title: (pollInfo.details?.name ?? thread.groupModel.groupName), - memberIds: Array(Set(thread.groupModel.groupMemberIds).inserting(userPublicKey)), - image: thread.groupModel.groupImage, - groupId: groupId, - groupType: .openGroup, - adminIds: (pollInfo.details?.admins ?? thread.groupModel.groupAdminIds), - moderatorIds: (pollInfo.details?.moderators ?? thread.groupModel.groupModeratorIds) - ) - maybeUpdatedModel = updatedModel - let updatedOpenGroup: OpenGroup = OpenGroup( - server: server, - room: pollInfo.token, - publicKey: publicKey, - name: (pollInfo.details?.name ?? thread.name()), - groupDescription: (pollInfo.details?.description ?? existingOpenGroup?.description), - imageID: (pollInfo.details?.imageId.map { "\($0)" } ?? existingOpenGroup?.imageID), - infoUpdates: ((pollInfo.details?.infoUpdates ?? existingOpenGroup?.infoUpdates) ?? 0) - ) - - // - Thread changes - thread.shouldBeVisible = true - thread.groupModel = updatedModel - thread.save(with: transaction) - - // - Open Group changes - dependencies.storage.setOpenGroup(updatedOpenGroup, for: threadUniqueId, using: transaction) - - // - User Count - dependencies.storage.setUserCount( - to: UInt64(pollInfo.activeUsers), - forOpenGroupWithID: updatedOpenGroup.id, - using: transaction - ) - }, - completion: { - // Start the poller if needed - if OpenGroupManager.shared.pollers[server] == nil { - OpenGroupManager.shared.pollers[server] = OpenGroupAPI.Poller(for: server) - OpenGroupManager.shared.pollers[server]?.startIfNeeded() - } - - // - Moderators - if let moderators: [String] = (pollInfo.details?.moderators ?? maybeUpdatedModel?.groupModeratorIds) { - OpenGroupManager.moderators[server] = (OpenGroupManager.moderators[server] ?? [:]) - .setting(roomToken, Set(moderators)) - } - - // - Admins - if let admins: [String] = (pollInfo.details?.admins ?? maybeUpdatedModel?.groupAdminIds) { - OpenGroupManager.admins[server] = (OpenGroupManager.admins[server] ?? [:]) - .setting(roomToken, Set(admins)) - } - - // - Room image (if there is one) - if let imageId: Int64 = pollInfo.details?.imageId { - OpenGroupManager.roomImage(imageId, for: roomToken, on: server) - .done(on: DispatchQueue.global(qos: .userInitiated)) { data in - dependencies.storage.write { transaction in - // Update the thread - let transaction = transaction as! YapDatabaseReadWriteTransaction - let thread = TSGroupThread.getOrCreateThread(with: initialModel, transaction: transaction) - thread.groupModel.groupImage = UIImage(data: data) - thread.save(with: transaction) - } - } - .retainUntilComplete() - } - - // Finish - completion?() - } + guard let threadUniqueId: String = thread.uniqueId else { return } + guard let publicKey: String = (maybePublicKey ?? existingOpenGroup?.publicKey) else { return } + + let updatedModel: TSGroupModel = TSGroupModel( + title: (pollInfo.details?.name ?? thread.groupModel.groupName), + memberIds: Array(Set(thread.groupModel.groupMemberIds).inserting(userPublicKey)), + image: thread.groupModel.groupImage, + groupId: groupId, + groupType: .openGroup, + adminIds: (pollInfo.details?.admins ?? thread.groupModel.groupAdminIds), + moderatorIds: (pollInfo.details?.moderators ?? thread.groupModel.groupModeratorIds) ) + maybeUpdatedModel = updatedModel + let updatedOpenGroup: OpenGroup = OpenGroup( + server: server, + room: pollInfo.token, + publicKey: publicKey, + name: (pollInfo.details?.name ?? thread.name()), + groupDescription: (pollInfo.details?.description ?? existingOpenGroup?.description), + imageID: (pollInfo.details?.imageId.map { "\($0)" } ?? existingOpenGroup?.imageID), + infoUpdates: ((pollInfo.details?.infoUpdates ?? existingOpenGroup?.infoUpdates) ?? 0) + ) + + // - Thread changes + thread.shouldBeVisible = true + thread.groupModel = updatedModel + thread.save(with: transaction) + + // - Open Group changes + dependencies.storage.setOpenGroup(updatedOpenGroup, for: threadUniqueId, using: transaction) + + // - User Count + dependencies.storage.setUserCount( + to: UInt64(pollInfo.activeUsers), + forOpenGroupWithID: updatedOpenGroup.id, + using: transaction + ) + + transaction.addCompletionQueue(DispatchQueue.global(qos: .userInitiated)) { + // Start the poller if needed + if OpenGroupManager.shared.pollers[server] == nil { + OpenGroupManager.shared.pollers[server] = OpenGroupAPI.Poller(for: server) + OpenGroupManager.shared.pollers[server]?.startIfNeeded() + } + + // - Moderators + if let moderators: [String] = (pollInfo.details?.moderators ?? maybeUpdatedModel?.groupModeratorIds) { + OpenGroupManager.moderators[server] = (OpenGroupManager.moderators[server] ?? [:]) + .setting(roomToken, Set(moderators)) + } + + // - Admins + if let admins: [String] = (pollInfo.details?.admins ?? maybeUpdatedModel?.groupAdminIds) { + OpenGroupManager.admins[server] = (OpenGroupManager.admins[server] ?? [:]) + .setting(roomToken, Set(admins)) + } + + // - Room image (if there is one) + if let imageId: Int64 = pollInfo.details?.imageId { + OpenGroupManager.roomImage(imageId, for: roomToken, on: server) + .done(on: DispatchQueue.global(qos: .userInitiated)) { data in + dependencies.storage.write { transaction in + // Update the thread + let transaction = transaction as! YapDatabaseReadWriteTransaction + let thread = TSGroupThread.getOrCreateThread(with: initialModel, transaction: transaction) + thread.groupModel.groupImage = UIImage(data: data) + thread.save(with: transaction) + } + } + .retainUntilComplete() + } + + // Finish + completion?() + } + } + + internal static func handleMessages( + _ messages: [OpenGroupAPI.Message], + for roomToken: String, + on server: String, + isBackgroundPoll: Bool, + using transaction: YapDatabaseReadWriteTransaction, + dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies() + ) { + // Sorting the messages by server ID before importing them fixes an issue where messages + // that quote older messages can't find those older messages + let openGroupID = "\(server).\(roomToken)" + let sortedMessages: [OpenGroupAPI.Message] = messages + .sorted { lhs, rhs in lhs.id < rhs.id } + let seqNo: Int64? = sortedMessages.map { $0.seqNo }.max() + var messageServerIDsToRemove: [UInt64] = [] + + // Update the 'openGroupSequenceNumber' value (Note: SOGS V4 uses the 'seqNo' instead of the 'serverId') + if let seqNo: Int64 = seqNo { + dependencies.storage.setOpenGroupSequenceNumber(for: roomToken, on: server, to: seqNo, using: transaction) + } + + // Process the messages + sortedMessages.forEach { message in + guard let base64EncodedString: String = message.base64EncodedData, let data = Data(base64Encoded: base64EncodedString), let sender: String = message.sender else { + // A message with no data has been deleted so add it to the list to remove + messageServerIDsToRemove.append(UInt64(message.id)) + return + } + + // Note: The `posted` value is in seconds but all messages in the database use milliseconds for timestamps + let envelope = SNProtoEnvelope.builder(type: .sessionMessage, timestamp: UInt64(floor(message.posted * 1000))) + envelope.setContent(data) + envelope.setSource(sender) + + do { + let data = try envelope.buildSerializedData() + let (message, proto) = try MessageReceiver.parse(data, openGroupMessageServerID: UInt64(message.id), isRetry: false, using: transaction) + try MessageReceiver.handle(message, associatedWithProto: proto, openGroupID: openGroupID, isBackgroundPoll: isBackgroundPoll, using: transaction) + } + catch { + SNLog("Couldn't receive open group message due to error: \(error).") + } + } + + // Handle any deletions that are needed + guard !messageServerIDsToRemove.isEmpty else { return } + guard let threadID = dependencies.storage.getThreadID(for: openGroupID), let thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction) else { + return + } + + var messagesToRemove: [TSMessage] = [] + + thread.enumerateInteractions(with: transaction) { interaction, stop in + guard let message: TSMessage = interaction as? TSMessage, messageServerIDsToRemove.contains(message.openGroupServerMessageID) else { + return + } + messagesToRemove.append(message) + } + + messagesToRemove.forEach { $0.remove(with: transaction) } } internal static func handleDirectMessages( @@ -318,7 +342,8 @@ public final class OpenGroupManager: NSObject { fromOutbox: Bool, on server: String, isBackgroundPoll: Bool, - using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies() + using transaction: YapDatabaseReadWriteTransaction, + dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies() ) { // Don't need to do anything if we have no messages (it's a valid case) guard !messages.isEmpty else { return } @@ -332,73 +357,78 @@ public final class OpenGroupManager: NSObject { let sortedMessages: [OpenGroupAPI.DirectMessage] = messages .sorted { lhs, rhs in lhs.id < rhs.id } let latestMessageId: Int64 = (sortedMessages.last?.id ?? 0) - let userSessionId: String = getUserHexEncodedPublicKey() var mappingCache: [String: BlindedIdMapping] = [:] - dependencies.storage.write { transaction in - // Update the 'latestMessageId' value - if fromOutbox { - dependencies.storage.setOpenGroupOutboxLatestMessageId(for: server, to: latestMessageId, using: transaction) - } - else { - dependencies.storage.setOpenGroupInboxLatestMessageId(for: server, to: latestMessageId, using: transaction) + // Update the 'latestMessageId' value + if fromOutbox { + dependencies.storage.setOpenGroupOutboxLatestMessageId(for: server, to: latestMessageId, using: transaction) + } + else { + dependencies.storage.setOpenGroupInboxLatestMessageId(for: server, to: latestMessageId, using: transaction) + } + + // Process the messages + sortedMessages.forEach { message in + guard let messageData = Data(base64Encoded: message.base64EncodedMessage) else { + SNLog("Couldn't receive inbox message.") + return } - // Process the messages - sortedMessages.forEach { message in - guard let messageData = Data(base64Encoded: message.base64EncodedMessage) else { - SNLog("Couldn't receive inbox message.") - return - } + // Note: The `posted` value is in seconds but all messages in the database use milliseconds for timestamps + let envelope = SNProtoEnvelope.builder(type: .sessionMessage, timestamp: UInt64(floor(message.posted * 1000))) + envelope.setContent(messageData) + envelope.setSource(message.sender) // TODO: Need to un-blind/intercept outbox messages? (their sender will be the blinded id) - // Note: The `posted` value is in seconds but all messages in the database use milliseconds for timestamps - let envelope = SNProtoEnvelope.builder(type: .sessionMessage, timestamp: UInt64(floor(message.posted * 1000))) - envelope.setContent(messageData) - envelope.setSource(message.sender ?? userSessionId) // Outbox messages have no 'sender' so default to current user - - do { - let data = try envelope.buildSerializedData() - let (receivedMessage, proto) = try MessageReceiver.parse(data, openGroupMessageServerID: nil, openGroupServerPublicKey: serverPublicKey, isRetry: false, using: transaction) + do { + let data = try envelope.buildSerializedData() + let (receivedMessage, proto) = try MessageReceiver.parse( + data, + openGroupMessageServerID: nil, + openGroupServerPublicKey: serverPublicKey, + isOutgoing: fromOutbox, + otherBlindedPublicKey: (fromOutbox ? message.recipient : message.sender), + isRetry: false, + using: transaction + ) + + // TODO: Need to test and validate this unblinding logic. + // If the message was an outgoing message then attempt to unblind the recipient (this will help put + // messages in the correct thread in case of message request approval race conditions as well as + // during device sync'ing and restoration) + if fromOutbox { + // Attempt to un-blind the 'message.recipient' + let mapping: BlindedIdMapping - // TODO: Need to test and validate this unblinding logic - // If the message was an outgoing message then attempt to unblind the recipient (this will help put - // messages in the correct thread in case of message request approval race conditions as well as - // during device sync'ing and restoration) - if fromOutbox, let recipientBlindedId: String = message.recipient { - // Attempt to un-blind the 'message.recipient' - let mapping: BlindedIdMapping - - // Minor optimisation to avoid processing the same sender multiple times - if let result: BlindedIdMapping = mappingCache[recipientBlindedId] { - mapping = result - } - else if let result: BlindedIdMapping = ContactUtilities.mapping(for: recipientBlindedId, serverPublicKey: serverPublicKey) { - mapping = result - } - else { - // Cache an "invalid" mapping that has the 'sessionId' set to the recipient so we don't - // re-process this recipient if there is another message from them - mapping = BlindedIdMapping( - blindedId: "", - sessionId: recipientBlindedId, - serverPublicKey: "" - ) - } - - switch receivedMessage { - case let receivedMessage as VisibleMessage: receivedMessage.syncTarget = mapping.sessionId - case let receivedMessage as ExpirationTimerUpdate: receivedMessage.syncTarget = mapping.sessionId - default: break - } - - mappingCache[recipientBlindedId] = mapping + // Minor optimisation to avoid processing the same sender multiple times + if let result: BlindedIdMapping = mappingCache[message.recipient] { + mapping = result + } + else if let result: BlindedIdMapping = ContactUtilities.mapping(for: message.recipient, serverPublicKey: serverPublicKey) { + mapping = result + } + else { + // Cache an "invalid" mapping that has the 'sessionId' set to the recipient so we don't + // re-process this recipient if there is another message from them + mapping = BlindedIdMapping( + blindedId: "", + sessionId: message.recipient, + serverPublicKey: "" + ) } - try MessageReceiver.handle(receivedMessage, associatedWithProto: proto, openGroupID: nil, isBackgroundPoll: isBackgroundPoll, using: transaction) - } - catch let error { - SNLog("Couldn't receive inbox message due to error: \(error).") + switch receivedMessage { + case let receivedMessage as VisibleMessage: receivedMessage.syncTarget = mapping.sessionId + case let receivedMessage as ExpirationTimerUpdate: receivedMessage.syncTarget = mapping.sessionId + default: break + } + + mappingCache[message.recipient] = mapping } + + try MessageReceiver.handle(receivedMessage, associatedWithProto: proto, openGroupID: nil, isBackgroundPoll: isBackgroundPoll, using: transaction) + } + catch let error { + SNLog("Couldn't receive inbox message due to error: \(error).") } } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift index a1f05d07b..cf5b03e44 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift @@ -24,22 +24,22 @@ extension MessageReceiver { guard isValid else { throw Error.invalidSignature } // 4. ) Get the sender's X25519 public key guard let senderX25519PublicKey = sodium.sign.toX25519(ed25519PublicKey: senderED25519PublicKey) else { throw Error.decryptionFailed } - // TODO: Need to rework this as it'll be based on the blinded id return (Data(plaintext), SessionId(.standard, publicKey: senderX25519PublicKey).hexString) } - internal static func decryptWithSessionBlindingProtocol(data: Data, fromBlindedPublicKey: String, with openGroupPublicKey: String, userEd25519KeyPair: Box.KeyPair, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) throws -> (plaintext: Data, senderX25519PublicKey: String) { + internal static func decryptWithSessionBlindingProtocol(data: Data, isOutgoing: Bool, otherBlindedPublicKey: String, with openGroupPublicKey: String, userEd25519KeyPair: Box.KeyPair, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) throws -> (plaintext: Data, senderX25519PublicKey: String) { guard let blindedKeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: openGroupPublicKey, edKeyPair: userEd25519KeyPair, genericHash: dependencies.genericHash) else { throw Error.decryptionFailed } /// Step one: calculate the shared encryption key, receiving from A to B - let kA: Bytes = Data(hex: fromBlindedPublicKey.removingIdPrefixIfNeeded()).bytes + let otherKeyBytes: Bytes = Data(hex: otherBlindedPublicKey.removingIdPrefixIfNeeded()).bytes + let kA: Bytes = (isOutgoing ? blindedKeyPair.publicKey : otherKeyBytes) guard let dec_key: Bytes = dependencies.sodium.sharedBlindedEncryptionKey( secretKey: userEd25519KeyPair.secretKey, - otherBlindedPublicKey: kA, + otherBlindedPublicKey: otherKeyBytes, fromBlindedPublicKey: kA, - toBlindedPublicKey: blindedKeyPair.publicKey, + toBlindedPublicKey: (isOutgoing ? otherKeyBytes : blindedKeyPair.publicKey), genericHash: dependencies.genericHash ) else { throw Error.decryptionFailed diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index 55fd0a4d6..54a65d1a9 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -277,7 +277,7 @@ extension MessageReceiver { // Open groups for openGroupURL in message.openGroups { if let (room, server, publicKey) = OpenGroupManager.parseV2OpenGroup(from: openGroupURL) { - OpenGroupManager.shared.add(roomToken: room, server: server, publicKey: publicKey, using: transaction).retainUntilComplete() + OpenGroupManager.shared.add(roomToken: room, server: server, publicKey: publicKey, isConfigMessage: true, using: transaction).retainUntilComplete() } } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index 67082b546..d5bd8f39f 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -48,7 +48,15 @@ public enum MessageReceiver { } } - public static func parse(_ data: Data, openGroupMessageServerID: UInt64?, openGroupServerPublicKey: String? = nil, isRetry: Bool = false, using transaction: Any) throws -> (Message, SNProtoContent) { + public static func parse( + _ data: Data, + openGroupMessageServerID: UInt64?, + openGroupServerPublicKey: String? = nil, + isOutgoing: Bool? = nil, + otherBlindedPublicKey: String? = nil, + isRetry: Bool = false, + using transaction: Any + ) throws -> (Message, SNProtoContent) { let userPublicKey = SNMessagingKitConfiguration.shared.storage.getUserPublicKey() let isOpenGroupMessage = (openGroupMessageServerID != nil) @@ -79,7 +87,7 @@ public enum MessageReceiver { (plaintext, sender) = try decryptWithSessionProtocol(ciphertext: ciphertext, using: userX25519KeyPair) case .blinded: - guard let senderSessionId: String = envelope.source else { throw Error.noData } + guard let otherBlindedPublicKey: String = otherBlindedPublicKey else { throw Error.noData } guard let openGroupServerPublicKey: String = openGroupServerPublicKey else { throw Error.invalidGroupPublicKey } @@ -89,7 +97,8 @@ public enum MessageReceiver { (plaintext, sender) = try decryptWithSessionBlindingProtocol( data: ciphertext, - fromBlindedPublicKey: senderSessionId, + isOutgoing: (isOutgoing == true), + otherBlindedPublicKey: otherBlindedPublicKey, with: openGroupServerPublicKey, userEd25519KeyPair: userEd25519KeyPair ) diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index c053ebeb3..d547cbeea 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -392,10 +392,11 @@ public final class MessageSender : NSObject { whisperMods: whisperMods ) .done(on: DispatchQueue.global(qos: .userInitiated)) { responseInfo, data in - message.openGroupServerMessageID = given(data.id) { UInt64($0) } + message.openGroupServerMessageID = UInt64(data.id) dependencies.storage.write { transaction in - MessageSender.handleSuccessfulMessageSend(message, to: destination, serverTimestamp: UInt64(floor(data.posted)), using: transaction) + // The `posted` value is in seconds but we sent it in ms so need that for de-duping + MessageSender.handleSuccessfulMessageSend(message, to: destination, serverTimestamp: UInt64(floor(data.posted * 1000)), using: transaction) seal.fulfill(()) } } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift index 224e5ed15..103491930 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift @@ -84,66 +84,79 @@ extension OpenGroupAPI { } private func handlePollResponse(_ response: [OpenGroupAPI.Endpoint: (info: OnionRequestResponseInfoType, data: Codable?)], isBackgroundPoll: Bool) { - response.forEach { endpoint, endpointResponse in - switch endpoint { - case .capabilities: - guard let responseData: BatchSubResponse = endpointResponse.data as? BatchSubResponse, let responseBody: Capabilities = responseData.body else { - SNLog("Open group polling failed due to invalid data.") - return - } - - OpenGroupManager.handleCapabilities( - responseBody, - on: server - ) - - case .roomMessagesRecent(let roomToken), .roomMessagesBefore(let roomToken, _), .roomMessagesSince(let roomToken, _): - guard let responseData: BatchSubResponse<[Message]> = endpointResponse.data as? BatchSubResponse<[Message]>, let responseBody: [Message] = responseData.body else { - SNLog("Open group polling failed due to invalid data.") - return - } - - OpenGroupManager.handleMessages( - responseBody, - for: roomToken, - on: server, - isBackgroundPoll: isBackgroundPoll - ) - - case .roomPollInfo(let roomToken, _): - guard let responseData: BatchSubResponse = endpointResponse.data as? BatchSubResponse, let responseBody: RoomPollInfo = responseData.body else { - SNLog("Open group polling failed due to invalid data.") - return - } - - OpenGroupManager.handlePollInfo( - responseBody, - publicKey: nil, - for: roomToken, - on: server - ) - - case .inbox, .inboxSince, .outbox, .outboxSince: - guard let responseData: BatchSubResponse<[DirectMessage]?> = endpointResponse.data as? BatchSubResponse<[DirectMessage]?>, let responseBody: [DirectMessage]? = responseData.body else { - SNLog("Open group polling failed due to invalid data.") - return - } - - let fromOutbox: Bool = { - switch endpoint { - case .outbox, .outboxSince: return true - default: return false + let server: String = self.server + + Storage.shared.write { anyTransaction in + guard let transaction: YapDatabaseReadWriteTransaction = anyTransaction as? YapDatabaseReadWriteTransaction else { + SNLog("Open group polling failed due to invalid database transaction.") + return + } + + response.forEach { endpoint, endpointResponse in + switch endpoint { + case .capabilities: + guard let responseData: BatchSubResponse = endpointResponse.data as? BatchSubResponse, let responseBody: Capabilities = responseData.body else { + SNLog("Open group polling failed due to invalid data.") + return } - }() - - OpenGroupManager.handleDirectMessages( - (responseBody ?? []), - fromOutbox: fromOutbox, - on: server, - isBackgroundPoll: isBackgroundPoll - ) - - default: break // No custom handling needed + + OpenGroupManager.handleCapabilities( + responseBody, + on: server, + using: transaction + ) + + case .roomMessagesRecent(let roomToken), .roomMessagesBefore(let roomToken, _), .roomMessagesSince(let roomToken, _): + guard let responseData: BatchSubResponse<[Message]> = endpointResponse.data as? BatchSubResponse<[Message]>, let responseBody: [Message] = responseData.body else { + SNLog("Open group polling failed due to invalid data.") + return + } + + OpenGroupManager.handleMessages( + responseBody, + for: roomToken, + on: server, + isBackgroundPoll: isBackgroundPoll, + using: transaction + ) + + case .roomPollInfo(let roomToken, _): + guard let responseData: BatchSubResponse = endpointResponse.data as? BatchSubResponse, let responseBody: RoomPollInfo = responseData.body else { + SNLog("Open group polling failed due to invalid data.") + return + } + + OpenGroupManager.handlePollInfo( + responseBody, + publicKey: nil, + for: roomToken, + on: server, + using: transaction + ) + + case .inbox, .inboxSince, .outbox, .outboxSince: + guard let responseData: BatchSubResponse<[DirectMessage]?> = endpointResponse.data as? BatchSubResponse<[DirectMessage]?>, !responseData.failedToParseBody else { + SNLog("Open group polling failed due to invalid data.") + return + } + + let fromOutbox: Bool = { + switch endpoint { + case .outbox, .outboxSince: return true + default: return false + } + }() + + OpenGroupManager.handleDirectMessages( + ((responseData.body ?? []) ?? []), // Double optional because the server can return a `304` with an empty body + fromOutbox: fromOutbox, + on: server, + isBackgroundPoll: isBackgroundPoll, + using: transaction + ) + + default: break // No custom handling needed + } } } } diff --git a/SignalUtilitiesKit/Messaging/OWSMessageUtils.m b/SignalUtilitiesKit/Messaging/OWSMessageUtils.m index b07a80bf8..f8a932fa6 100644 --- a/SignalUtilitiesKit/Messaging/OWSMessageUtils.m +++ b/SignalUtilitiesKit/Messaging/OWSMessageUtils.m @@ -123,7 +123,7 @@ NS_ASSUME_NONNULL_BEGIN TSThread *thread = [TSThread fetchObjectWithUniqueID:groupID transaction:transaction]; // Only increase the count for message requests - if (!thread.isMessageRequest) { continue; } + if (![thread isMessageRequestUsingTransaction:transaction]) { continue; } [unreadMessages enumerateKeysAndObjectsInGroup:groupID usingBlock:^(NSString *collection, NSString *key, id object, NSUInteger index, BOOL *stop) { From 1c474955de05caf3b0689f7576177180173a6a15 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 4 Mar 2022 13:33:06 +1100 Subject: [PATCH 025/157] File upload working, further code cleanup Got the updated file upload working Removed the legacy 'room' header Consolidated a number of types between SOGS, FileServer and general requests Updated the OnionRequestAPI to deal with a Data payload (rather than encoding it to a string and then back to data) --- Session.xcodeproj/project.pbxproj | 44 +++-- .../ConversationVC+Interaction.swift | 2 +- .../Open Groups/OpenGroupSuggestionGrid.swift | 2 +- Session/Settings/SettingsVC.swift | 3 +- .../Models/FileDownloadResponse.swift | 4 +- .../Models/LegacyFileDownloadResponse.swift | 4 +- .../Common Networking/QueryParam.swift | 1 + .../Common Networking/Request.swift | 106 +++++++++++ .../Database/Storage+Jobs.swift | 7 +- .../File Server/FileServerAPIV2.swift | 162 ++++++----------- .../File Server/Types/FSEndpoint.swift | 19 ++ .../Jobs/AttachmentDownloadJob.swift | 2 +- .../Jobs/AttachmentUploadJob.swift | 91 +++++++--- SessionMessagingKit/Jobs/MessageSendJob.swift | 4 +- .../Messages/Message+Destination.swift | 6 +- .../Open Groups/Models/BatchRequestInfo.swift | 30 ++-- .../Models/LegacyAuthTokenResponse.swift | 2 +- .../Models/LegacyOpenGroupMessageV2.swift | 4 +- .../Open Groups/Models/Room.swift | 4 +- .../{OGMessage.swift => SOGSMessage.swift} | 10 +- .../Models/SendMessageRequest.swift | 4 +- .../Open Groups/OpenGroupAPI.swift | 166 ++++++++---------- .../Open Groups/OpenGroupManager.swift | 12 +- .../Open Groups/Types/Request.swift | 94 ---------- .../{Endpoint.swift => SOGSEndpoint.swift} | 8 +- .../Types/{Error.swift => SOGSError.swift} | 6 - .../Sending & Receiving/MessageSender.swift | 5 +- SessionMessagingKit/Storage.swift | 1 + .../Utilities/Promise+Utilities.swift | 13 +- .../Open Groups/OpenGroupAPISpec.swift | 17 +- .../OnionRequestAPI+Encryption.swift | 10 +- SessionSnodeKit/OnionRequestAPI.swift | 49 ++---- SessionUtilitiesKit/Networking/HTTP.swift | 15 +- .../MessageSender+Convenience.swift | 123 ++++++++----- 34 files changed, 534 insertions(+), 496 deletions(-) create mode 100644 SessionMessagingKit/Common Networking/Request.swift create mode 100644 SessionMessagingKit/File Server/Types/FSEndpoint.swift rename SessionMessagingKit/Open Groups/Models/{OGMessage.swift => SOGSMessage.swift} (92%) delete mode 100644 SessionMessagingKit/Open Groups/Types/Request.swift rename SessionMessagingKit/Open Groups/Types/{Endpoint.swift => SOGSEndpoint.swift} (97%) rename SessionMessagingKit/Open Groups/Types/{Error.swift => SOGSError.swift} (69%) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 4951ea3c6..a9142c3b6 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -793,6 +793,8 @@ FD83B9C527CF3E2A005E1583 /* OpenGroupSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9C427CF3E2A005E1583 /* OpenGroupSpec.swift */; }; FD83B9C727CF3F10005E1583 /* CapabilitiesSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9C627CF3F10005E1583 /* CapabilitiesSpec.swift */; }; FD83B9C927D0487A005E1583 /* SendDirectMessageResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9C827D0487A005E1583 /* SendDirectMessageResponse.swift */; }; + FD83B9CC27D179BC005E1583 /* FSEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9CB27D179BC005E1583 /* FSEndpoint.swift */; }; + FD83B9CE27D17A04005E1583 /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9CD27D17A04005E1583 /* Request.swift */; }; FD859EF227BF6BA200510D0C /* Data+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF127BF6BA200510D0C /* Data+Utilities.swift */; }; FD859EF427C2F49200510D0C /* TestSodium.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF327C2F49200510D0C /* TestSodium.swift */; }; FD859EF627C2F52C00510D0C /* TestSign.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF527C2F52C00510D0C /* TestSign.swift */; }; @@ -802,13 +804,12 @@ FD859F0027C4691300510D0C /* MockDataGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EFF27C4691300510D0C /* MockDataGenerator.swift */; }; FD88BAD927A7439C00BBC442 /* MessageRequestsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */; }; FD88BADB27A750F200BBC442 /* MessageRequestsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */; }; - FDC4380927B31D4E00C60D73 /* Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4380827B31D4E00C60D73 /* Error.swift */; }; - FDC4380B27B31D7E00C60D73 /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4380A27B31D7E00C60D73 /* Request.swift */; }; + FDC4380927B31D4E00C60D73 /* SOGSError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4380827B31D4E00C60D73 /* SOGSError.swift */; }; FDC4381527B329CE00C60D73 /* NonceGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381427B329CE00C60D73 /* NonceGenerator.swift */; }; FDC4381727B32EC700C60D73 /* Personalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381627B32EC700C60D73 /* Personalization.swift */; }; FDC4381A27B34EBA00C60D73 /* LegacyCompactPollBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381927B34EBA00C60D73 /* LegacyCompactPollBody.swift */; }; FDC4381C27B354AC00C60D73 /* LegacyPublicKeyBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381B27B354AC00C60D73 /* LegacyPublicKeyBody.swift */; }; - FDC4382027B36ADC00C60D73 /* Endpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381F27B36ADC00C60D73 /* Endpoint.swift */; }; + FDC4382027B36ADC00C60D73 /* SOGSEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381F27B36ADC00C60D73 /* SOGSEndpoint.swift */; }; FDC4382627B37F6900C60D73 /* LegacyDeletedMessagesResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382527B37F6900C60D73 /* LegacyDeletedMessagesResponse.swift */; }; FDC4382827B37FD300C60D73 /* LegacyModeratorsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382727B37FD300C60D73 /* LegacyModeratorsResponse.swift */; }; FDC4382A27B3802D00C60D73 /* LegacyRoomsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382927B3802D00C60D73 /* LegacyRoomsResponse.swift */; }; @@ -832,7 +833,7 @@ FDC4385D27B4C18900C60D73 /* Room.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4385C27B4C18900C60D73 /* Room.swift */; }; FDC4385F27B4C4A200C60D73 /* PinnedMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4385E27B4C4A200C60D73 /* PinnedMessage.swift */; }; FDC4386127B4CDDF00C60D73 /* FileResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4386027B4CDDF00C60D73 /* FileResponse.swift */; }; - FDC4386327B4D94E00C60D73 /* OGMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4386227B4D94E00C60D73 /* OGMessage.swift */; }; + FDC4386327B4D94E00C60D73 /* SOGSMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4386227B4D94E00C60D73 /* SOGSMessage.swift */; }; FDC4386527B4DE7600C60D73 /* RoomPollInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4386427B4DE7600C60D73 /* RoomPollInfo.swift */; }; FDC4386727B4E10E00C60D73 /* Capabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4386627B4E10E00C60D73 /* Capabilities.swift */; }; FDC4386927B4E6B800C60D73 /* String+Utlities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4386827B4E6B700C60D73 /* String+Utlities.swift */; }; @@ -1947,6 +1948,8 @@ FD83B9C427CF3E2A005E1583 /* OpenGroupSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupSpec.swift; sourceTree = ""; }; FD83B9C627CF3F10005E1583 /* CapabilitiesSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapabilitiesSpec.swift; sourceTree = ""; }; FD83B9C827D0487A005E1583 /* SendDirectMessageResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendDirectMessageResponse.swift; sourceTree = ""; }; + FD83B9CB27D179BC005E1583 /* FSEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FSEndpoint.swift; sourceTree = ""; }; + FD83B9CD27D17A04005E1583 /* Request.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Request.swift; sourceTree = ""; }; 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 = ""; }; @@ -1959,13 +1962,12 @@ FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsCell.swift; sourceTree = ""; }; FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsMigration.swift; sourceTree = ""; }; FD9039443F7CB729CF71350E /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig"; sourceTree = ""; }; - FDC4380827B31D4E00C60D73 /* Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Error.swift; sourceTree = ""; }; - FDC4380A27B31D7E00C60D73 /* Request.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Request.swift; sourceTree = ""; }; + FDC4380827B31D4E00C60D73 /* SOGSError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSError.swift; sourceTree = ""; }; FDC4381427B329CE00C60D73 /* NonceGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonceGenerator.swift; sourceTree = ""; }; FDC4381627B32EC700C60D73 /* Personalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Personalization.swift; sourceTree = ""; }; FDC4381927B34EBA00C60D73 /* LegacyCompactPollBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyCompactPollBody.swift; sourceTree = ""; }; FDC4381B27B354AC00C60D73 /* LegacyPublicKeyBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyPublicKeyBody.swift; sourceTree = ""; }; - FDC4381F27B36ADC00C60D73 /* Endpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Endpoint.swift; sourceTree = ""; }; + FDC4381F27B36ADC00C60D73 /* SOGSEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSEndpoint.swift; sourceTree = ""; }; FDC4382527B37F6900C60D73 /* LegacyDeletedMessagesResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyDeletedMessagesResponse.swift; sourceTree = ""; }; FDC4382727B37FD300C60D73 /* LegacyModeratorsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyModeratorsResponse.swift; sourceTree = ""; }; FDC4382927B3802D00C60D73 /* LegacyRoomsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyRoomsResponse.swift; sourceTree = ""; }; @@ -1989,7 +1991,7 @@ FDC4385C27B4C18900C60D73 /* Room.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Room.swift; sourceTree = ""; }; FDC4385E27B4C4A200C60D73 /* PinnedMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedMessage.swift; sourceTree = ""; }; FDC4386027B4CDDF00C60D73 /* FileResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileResponse.swift; sourceTree = ""; }; - FDC4386227B4D94E00C60D73 /* OGMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OGMessage.swift; sourceTree = ""; }; + FDC4386227B4D94E00C60D73 /* SOGSMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSMessage.swift; sourceTree = ""; }; FDC4386427B4DE7600C60D73 /* RoomPollInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPollInfo.swift; sourceTree = ""; }; FDC4386627B4E10E00C60D73 /* Capabilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Capabilities.swift; sourceTree = ""; }; FDC4386827B4E6B700C60D73 /* String+Utlities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Utlities.swift"; sourceTree = ""; }; @@ -3420,6 +3422,7 @@ isa = PBXGroup; children = ( FDC4383227B385B200C60D73 /* Models */, + FD83B9CA27D179AF005E1583 /* Types */, B87EF17026367CF800124B3C /* FileServerAPIV2.swift */, ); path = "File Server"; @@ -3905,6 +3908,14 @@ path = Models; sourceTree = ""; }; + FD83B9CA27D179AF005E1583 /* Types */ = { + isa = PBXGroup; + children = ( + FD83B9CB27D179BC005E1583 /* FSEndpoint.swift */, + ); + path = Types; + sourceTree = ""; + }; FD88BAD727A7438E00BBC442 /* Views */ = { isa = PBXGroup; children = ( @@ -3916,9 +3927,8 @@ FDC4380727B31D3A00C60D73 /* Types */ = { isa = PBXGroup; children = ( - FDC4380A27B31D7E00C60D73 /* Request.swift */, - FDC4381F27B36ADC00C60D73 /* Endpoint.swift */, - FDC4380827B31D4E00C60D73 /* Error.swift */, + FDC4381F27B36ADC00C60D73 /* SOGSEndpoint.swift */, + FDC4380827B31D4E00C60D73 /* SOGSError.swift */, FDC4381627B32EC700C60D73 /* Personalization.swift */, FDC4381427B329CE00C60D73 /* NonceGenerator.swift */, FDC438C027BB4E6800C60D73 /* Dependencies.swift */, @@ -3940,7 +3950,7 @@ FDC4386027B4CDDF00C60D73 /* FileResponse.swift */, FDC4387727B5C35400C60D73 /* SendMessageRequest.swift */, FDC438CA27BB7DB100C60D73 /* UpdateMessageRequest.swift */, - FDC4386227B4D94E00C60D73 /* OGMessage.swift */, + FDC4386227B4D94E00C60D73 /* SOGSMessage.swift */, FDC438C627BB6DF000C60D73 /* DirectMessage.swift */, FDC438C827BB706500C60D73 /* SendDirectMessageRequest.swift */, FD83B9C827D0487A005E1583 /* SendDirectMessageResponse.swift */, @@ -3988,6 +3998,7 @@ FDC4385527B484AE00C60D73 /* Models */, FDC4384E27B4804F00C60D73 /* Header.swift */, FDC4385027B4807400C60D73 /* QueryParam.swift */, + FD83B9CD27D17A04005E1583 /* Request.swift */, ); path = "Common Networking"; sourceTree = ""; @@ -5287,7 +5298,6 @@ C3D9E3BF25676AD70040E4F3 /* TSAttachmentStream.m in Sources */, C3C2A7562553A3AB00C340D1 /* VisibleMessage+Quote.swift in Sources */, B8B32021258B1A650020074B /* Contact.swift in Sources */, - FDC4380B27B31D7E00C60D73 /* Request.swift in Sources */, FDC4384027B4746D00C60D73 /* LegacyGetInfoResponse.swift in Sources */, C32C5C89256DD0D2003C73A2 /* Storage+Jobs.swift in Sources */, C300A5FC2554B0A000555489 /* MessageReceiver.swift in Sources */, @@ -5337,11 +5347,12 @@ B8EB20EE2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift in Sources */, FDC4381C27B354AC00C60D73 /* LegacyPublicKeyBody.swift in Sources */, FDC4382F27B383AF00C60D73 /* UnregisterResponse.swift in Sources */, - FDC4386327B4D94E00C60D73 /* OGMessage.swift in Sources */, + FDC4386327B4D94E00C60D73 /* SOGSMessage.swift in Sources */, C3C2A7712553A41E00C340D1 /* ControlMessage.swift in Sources */, C32C5D19256DD493003C73A2 /* OWSLinkPreview.swift in Sources */, C32C5CF0256DD3E4003C73A2 /* Storage+Shared.swift in Sources */, C300A5BD2554B00D00555489 /* ReadReceipt.swift in Sources */, + FD83B9CC27D179BC005E1583 /* FSEndpoint.swift in Sources */, C32C5AB5256DBE8F003C73A2 /* TSOutgoingMessage+Conversion.swift in Sources */, FDC4385927B484E800C60D73 /* FileUploadBody.swift in Sources */, C32C5E5B256DDF45003C73A2 /* OWSStorage.m in Sources */, @@ -5377,8 +5388,8 @@ B8D0A25925E367AC00C1835E /* Notification+MessageReceiver.swift in Sources */, FDC4387227B5BB3B00C60D73 /* FileUploadResponse.swift in Sources */, C32C599E256DB02B003C73A2 /* TypingIndicators.swift in Sources */, - FDC4380927B31D4E00C60D73 /* Error.swift in Sources */, - FDC4382027B36ADC00C60D73 /* Endpoint.swift in Sources */, + FDC4380927B31D4E00C60D73 /* SOGSError.swift in Sources */, + FDC4382027B36ADC00C60D73 /* SOGSEndpoint.swift in Sources */, C3DB66CC260AF1F3001EFC55 /* OpenGroupAPI+ObjC.swift in Sources */, C32C5BEF256DC8EE003C73A2 /* OWSDisappearingMessagesJob.m in Sources */, C34A977425A3E34A00852C71 /* ClosedGroupControlMessage.swift in Sources */, @@ -5403,6 +5414,7 @@ C300A5E72554B07300555489 /* ExpirationTimerUpdate.swift in Sources */, B88A1AC725C90A4700E6D421 /* TypingIndicatorInteraction.swift in Sources */, C3D9E3C025676AD70040E4F3 /* TSAttachment.m in Sources */, + FD83B9CE27D17A04005E1583 /* Request.swift in Sources */, FDC4384827B47F4D00C60D73 /* LegacyRoomInfo.swift in Sources */, C32C598A256D0664003C73A2 /* SNProtoEnvelope+Conversion.swift in Sources */, FDC438CB27BB7DB100C60D73 /* UpdateMessageRequest.swift in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 99b9b8370..5f5769205 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -568,7 +568,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc let attachment = mediaView.attachment if let pointer = attachment as? TSAttachmentPointer { if pointer.state == .failed { - // TODO: Tapped a failed incoming attachment + // TODO: Tapped a failed incoming attachment (Note: This is generally a permanent failure - see `AttachmentDownloadJob`) } } guard let stream = attachment as? TSAttachmentStream else { return } diff --git a/Session/Open Groups/OpenGroupSuggestionGrid.swift b/Session/Open Groups/OpenGroupSuggestionGrid.swift index 58e7633d7..f0e4fd077 100644 --- a/Session/Open Groups/OpenGroupSuggestionGrid.swift +++ b/Session/Open Groups/OpenGroupSuggestionGrid.swift @@ -182,7 +182,7 @@ extension OpenGroupSuggestionGrid { label.text = room.name - if let imageId: Int64 = room.imageId { + if let imageId: UInt64 = room.imageId { let promise = OpenGroupManager.roomImage(imageId, for: room.token, on: OpenGroupAPI.defaultServer) imageView.image = given(promise.value) { UIImage(data: $0)! } imageView.isHidden = (imageView.image == nil) diff --git a/Session/Settings/SettingsVC.swift b/Session/Settings/SettingsVC.swift index 9641f53dd..ea52be018 100644 --- a/Session/Settings/SettingsVC.swift +++ b/Session/Settings/SettingsVC.swift @@ -1,4 +1,5 @@ import UIKit +import SessionUtilitiesKit final class SettingsVC : BaseVC, AvatarViewHelperDelegate { private var profilePictureToBeUploaded: UIImage? @@ -382,7 +383,7 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { DispatchQueue.main.async { modalActivityIndicator.dismiss { var isMaxFileSizeExceeded = false - if let error = error as? FileServerAPIV2.Error { + if let error = error as? HTTP.Error { isMaxFileSizeExceeded = (error == .maxFileSizeExceeded) } let title = isMaxFileSizeExceeded ? "Maximum File Size Exceeded" : "Couldn't Update Profile" diff --git a/SessionMessagingKit/Common Networking/Models/FileDownloadResponse.swift b/SessionMessagingKit/Common Networking/Models/FileDownloadResponse.swift index 45f7c1989..9ca228205 100644 --- a/SessionMessagingKit/Common Networking/Models/FileDownloadResponse.swift +++ b/SessionMessagingKit/Common Networking/Models/FileDownloadResponse.swift @@ -37,9 +37,7 @@ extension FileDownloadResponse { let base64EncodedData: String = try container.decode(String.self, forKey: .base64EncodedData) - guard let data = Data(base64Encoded: base64EncodedData) else { - throw FileServerAPIV2.Error.parsingFailed - } + guard let data = Data(base64Encoded: base64EncodedData) else { throw HTTP.Error.parsingFailed } self = FileDownloadResponse( fileName: try container.decode(String.self, forKey: .fileName), diff --git a/SessionMessagingKit/Common Networking/Models/LegacyFileDownloadResponse.swift b/SessionMessagingKit/Common Networking/Models/LegacyFileDownloadResponse.swift index d05a3e251..7ce1c0da7 100644 --- a/SessionMessagingKit/Common Networking/Models/LegacyFileDownloadResponse.swift +++ b/SessionMessagingKit/Common Networking/Models/LegacyFileDownloadResponse.swift @@ -24,9 +24,7 @@ extension LegacyFileDownloadResponse { let base64EncodedData: String = try container.decode(String.self, forKey: .base64EncodedData) - guard let data = Data(base64Encoded: base64EncodedData) else { - throw FileServerAPIV2.Error.parsingFailed - } + guard let data = Data(base64Encoded: base64EncodedData) else { throw HTTP.Error.parsingFailed } self = LegacyFileDownloadResponse( data: data diff --git a/SessionMessagingKit/Common Networking/QueryParam.swift b/SessionMessagingKit/Common Networking/QueryParam.swift index 81e9d849e..7a1fe0f18 100644 --- a/SessionMessagingKit/Common Networking/QueryParam.swift +++ b/SessionMessagingKit/Common Networking/QueryParam.swift @@ -8,4 +8,5 @@ enum QueryParam: String { case required = "required" case limit // For messages - number between 1 and 256 (default is 100) + case platform // For file server session version check } diff --git a/SessionMessagingKit/Common Networking/Request.swift b/SessionMessagingKit/Common Networking/Request.swift new file mode 100644 index 000000000..a5dff15a7 --- /dev/null +++ b/SessionMessagingKit/Common Networking/Request.swift @@ -0,0 +1,106 @@ +import Foundation +import SessionUtilitiesKit + +// MARK: - Convenience Types + +struct Empty: Codable {} + +typealias NoBody = Empty +typealias NoResponse = Empty + +protocol EndpointType: Hashable { + var path: String { get } +} + +// MARK: - Request + +struct Request { + let method: HTTP.Verb + let server: String + let endpoint: Endpoint + let queryParameters: [QueryParam: String] + let headers: [Header: String] + /// This is the body value sent during the request + /// + /// **Warning:** The `bodyData` value should be used to when making the actual request instead of this as there + /// is custom handling for certain data types + let body: T? + let isAuthRequired: Bool + /// Always `true` under normal circumstances. You might want to disable + /// this when running over Lokinet. + let useOnionRouting: Bool + + // MARK: - Initialization + + init( + method: HTTP.Verb = .get, + server: String, + endpoint: Endpoint, + queryParameters: [QueryParam: String] = [:], + headers: [Header: String] = [:], + body: T? = nil, + isAuthRequired: Bool = true, + useOnionRouting: Bool = true + ) { + self.method = method + self.server = server + self.endpoint = endpoint + self.queryParameters = queryParameters + self.headers = headers + self.body = body + self.isAuthRequired = isAuthRequired + self.useOnionRouting = useOnionRouting + } + + // MARK: - Internal Methods + + private var url: URL? { + return URL(string: "\(server)\(urlPathAndParamsString)") + } + + private func bodyData() throws -> Data? { + // Note: Need to differentiate between JSON, b64 string and bytes body values to ensure they are + // encoded correctly so the server knows how to handle them + switch body { + case let bodyString as String: + // The only acceptable string body is a base64 encoded one + guard let encodedData: Data = Data(base64Encoded: bodyString) else { throw HTTP.Error.parsingFailed } + + return encodedData + + case let bodyBytes as [UInt8]: + return Data(bodyBytes) + + default: + // Having no body is fine so just return nil + guard let body: T = body else { return nil } + + return try JSONEncoder().encode(body) + } + } + + // MARK: - Request Generation + + var urlPathAndParamsString: String { + return [ + "/\(endpoint.path)", + queryParameters + .map { key, value in "\(key.rawValue)=\(value)" } + .joined(separator: "&") + ] + .compactMap { $0 } + .filter { !$0.isEmpty } + .joined(separator: "?") + } + + func generateUrlRequest() throws -> URLRequest { + guard let url: URL = url else { throw HTTP.Error.invalidURL } + + var urlRequest: URLRequest = URLRequest(url: url) + urlRequest.httpMethod = method.rawValue + urlRequest.allHTTPHeaderFields = headers.toHTTPHeaders() + urlRequest.httpBody = try bodyData() + + return urlRequest + } +} diff --git a/SessionMessagingKit/Database/Storage+Jobs.swift b/SessionMessagingKit/Database/Storage+Jobs.swift index fe3f31615..9928c1831 100644 --- a/SessionMessagingKit/Database/Storage+Jobs.swift +++ b/SessionMessagingKit/Database/Storage+Jobs.swift @@ -1,3 +1,4 @@ +import YapDatabase extension Storage { @@ -96,10 +97,14 @@ extension Storage { public func getMessageSendJob(for messageSendJobID: String) -> MessageSendJob? { var result: MessageSendJob? Storage.read { transaction in - result = transaction.object(forKey: messageSendJobID, inCollection: MessageSendJob.collection) as? MessageSendJob + result = self.getMessageSendJob(for: messageSendJobID, using: transaction) } return result } + + public func getMessageSendJob(for messageSendJobID: String, using transaction: Any) -> MessageSendJob? { + return (transaction as! YapDatabaseReadTransaction).object(forKey: messageSendJobID, inCollection: MessageSendJob.collection) as? MessageSendJob + } public func resumeMessageSendJobIfNeeded(_ messageSendJobID: String) { guard let job = getMessageSendJob(for: messageSendJobID) else { return } diff --git a/SessionMessagingKit/File Server/FileServerAPIV2.swift b/SessionMessagingKit/File Server/FileServerAPIV2.swift index 7ea5ea93f..f2bff83ed 100644 --- a/SessionMessagingKit/File Server/FileServerAPIV2.swift +++ b/SessionMessagingKit/File Server/FileServerAPIV2.swift @@ -4,7 +4,8 @@ import SessionSnodeKit @objc(SNFileServerAPIV2) public final class FileServerAPIV2 : NSObject { - // MARK: Settings + // MARK: - Settings + @objc public static let oldServer = "http://88.99.175.227" public static let oldServerPublicKey = "7cb31905b55cd5580c686911debf672577b3fb0bff81df4ce2d5c4cb3a7aaa69" @objc public static let server = "http://filev2.getsession.org" @@ -18,92 +19,12 @@ public final class FileServerAPIV2 : NSObject { /// possible after proof of work has been calculated and the onion request encryption has happened, which takes several seconds. public static let fileSizeORMultiplier: Double = 2 - // MARK: Initialization + // MARK: - Initialization + private override init() { } - // MARK: Error - public enum Error: LocalizedError { - case parsingFailed - case invalidURL - case maxFileSizeExceeded - - public var errorDescription: String? { - switch self { - case .parsingFailed: return "Invalid response." - case .invalidURL: return "Invalid URL." - case .maxFileSizeExceeded: return "Maximum file size exceeded." - } - } - } + // MARK: - File Storage - // MARK: Request - private struct Request { - let verb: HTTP.Verb - let endpoint: String - let queryParameters: [QueryParam: String] - let body: Data? - let headers: [Header: String] - /// Always `true` under normal circumstances. You might want to disable - /// this when running over Lokinet. - let useOnionRouting: Bool - - init(verb: HTTP.Verb, endpoint: String, queryParameters: [QueryParam: String] = [:], body: Data? = nil, - headers: [Header: String] = [:], useOnionRouting: Bool = true) { - self.verb = verb - self.endpoint = endpoint - self.queryParameters = queryParameters - self.body = body - self.headers = headers - self.useOnionRouting = useOnionRouting - } - } - - // MARK: - Convenience - - private static func send(_ request: Request, useOldServer: Bool) -> Promise { - let server = useOldServer ? oldServer : server - let serverPublicKey = useOldServer ? oldServerPublicKey : serverPublicKey - var urlRequest: URLRequest - // TODO: Combine this 'Request' with the the pattern in OpenGroupServerV2? - switch request.verb { - case .get: - var rawURL = "\(server)/\(request.endpoint)" - - if !request.queryParameters.isEmpty { - let queryString = request.queryParameters.map { key, value in "\(key)=\(value)" }.joined(separator: "&") - rawURL += "?\(queryString)" - } - - guard let url = URL(string: rawURL) else { return Promise(error: Error.invalidURL) } - - urlRequest = URLRequest(url: url) - - case .post, .put, .delete: - let rawURL = "\(server)/\(request.endpoint)" - - guard let url = URL(string: rawURL) else { return Promise(error: Error.invalidURL) } - - urlRequest = URLRequest(url: url) - urlRequest.httpMethod = request.verb.rawValue - urlRequest.httpBody = request.body - } - - urlRequest.allHTTPHeaderFields = request.headers.toHTTPHeaders() - - guard request.useOnionRouting else { - preconditionFailure("It's currently not allowed to send non onion routed requests.") - } - - // TODO: Upgrade this to use the V4 onion requests once supported. - return OnionRequestAPI.sendOnionRequest(urlRequest, to: server, using: .v3, with: serverPublicKey) - .map2 { _, response in - guard let response: Data = response else { throw Error.parsingFailed } - - return response - } - } - - // MARK: File Storage @objc(upload:) public static func objc_upload(file: Data) -> AnyPromise { return AnyPromise.from(upload(file).map { String($0) }) @@ -112,41 +33,72 @@ public final class FileServerAPIV2 : NSObject { public static func upload(_ file: Data) -> Promise { let requestBody: FileUploadBody = FileUploadBody(file: file.base64EncodedString()) - guard let body: Data = try? JSONEncoder().encode(requestBody) else { - return Promise(error: HTTP.Error.invalidJSON) - } + let request = Request( + method: .post, + server: server, + endpoint: Endpoint.files, + body: requestBody + ) - let request = Request(verb: .post, endpoint: "files", body: body) - return send(request, useOldServer: false).map(on: DispatchQueue.global(qos: .userInitiated)) { data in - let response: LegacyFileUploadResponse = try data.decoded(as: LegacyFileUploadResponse.self, customError: Error.parsingFailed) - - return response.fileId - } + return send(request, serverPublicKey: serverPublicKey) + .decoded(as: LegacyFileUploadResponse.self, on: .global(qos: .userInitiated), error: HTTP.Error.parsingFailed) + .map { response in response.fileId } } @objc(download:useOldServer:) public static func objc_download(file: String, useOldServer: Bool) -> AnyPromise { - guard let id = UInt64(file) else { return AnyPromise.from(Promise(error: Error.invalidURL)) } + guard let id = UInt64(file) else { return AnyPromise.from(Promise(error: HTTP.Error.invalidURL)) } return AnyPromise.from(download(id, useOldServer: useOldServer)) } public static func download(_ file: UInt64, useOldServer: Bool) -> Promise { - let request = Request(verb: .get, endpoint: "files/\(file)") + let serverPublicKey: String = (useOldServer ? oldServerPublicKey : serverPublicKey) + let request = Request( + server: (useOldServer ? oldServer : server), + endpoint: .file(fileId: file) + ) - return send(request, useOldServer: useOldServer).map(on: DispatchQueue.global(qos: .userInitiated)) { data in - let response: LegacyFileDownloadResponse = try data.decoded(as: LegacyFileDownloadResponse.self, customError: Error.parsingFailed) - - return response.data - } + return send(request, serverPublicKey: serverPublicKey) + .decoded(as: LegacyFileDownloadResponse.self, on: .global(qos: .userInitiated), error: HTTP.Error.parsingFailed) + .map { response in response.data } } public static func getVersion(_ platform: String) -> Promise { - let request = Request(verb: .get, endpoint: "session_version?platform=\(platform)") + let request = Request( + server: server, + endpoint: .sessionVersion, + queryParameters: [ + .platform: platform + ] + ) - return send(request, useOldServer: false).map(on: DispatchQueue.global(qos: .userInitiated)) { data in - let response: VersionResponse = try data.decoded(as: VersionResponse.self, customError: Error.parsingFailed) - - return response.version + return send(request, serverPublicKey: serverPublicKey) + .decoded(as: VersionResponse.self, on: .global(qos: .userInitiated), error: HTTP.Error.parsingFailed) + .map { response in response.version } + } + + // MARK: - Convenience + + private static func send(_ request: Request, serverPublicKey: String) -> Promise { + guard request.useOnionRouting else { + preconditionFailure("It's currently not allowed to send non onion routed requests.") } + + let urlRequest: URLRequest + + do { + urlRequest = try request.generateUrlRequest() + } + catch { + return Promise(error: error) + } + + // TODO: Rename file to be 'FileServerAPI' (drop the 'V2') + return OnionRequestAPI.sendOnionRequest(urlRequest, to: request.server, with: serverPublicKey) + .map2 { _, response in + guard let response: Data = response else { throw HTTP.Error.parsingFailed } + + return response + } } } diff --git a/SessionMessagingKit/File Server/Types/FSEndpoint.swift b/SessionMessagingKit/File Server/Types/FSEndpoint.swift new file mode 100644 index 000000000..06cdea310 --- /dev/null +++ b/SessionMessagingKit/File Server/Types/FSEndpoint.swift @@ -0,0 +1,19 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension FileServerAPIV2 { + public enum Endpoint: EndpointType { + case files + case file(fileId: UInt64) + case sessionVersion + + var path: String { + switch self { + case .files: return "files" + case .file(let fileId): return "files/\(fileId)" + case .sessionVersion: return "session_version" + } + } + } +} diff --git a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift index 15d821fb7..70cf5bfcb 100644 --- a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift @@ -98,7 +98,7 @@ public final class AttachmentDownloadJob : NSObject, Job, NSCoding { // NSObject } } if let tsMessage = TSMessage.fetch(uniqueId: tsMessageID), let openGroup = storage.getOpenGroup(for: tsMessage.uniqueThreadId) { - guard let fileAsString = pointer.downloadURL.split(separator: "/").last, let file = UInt64(fileAsString) else { + guard let fileAsString = pointer.downloadURL.split(separator: "/").last, let fileId = UInt64(fileAsString) else { return handleFailure(Error.invalidURL) } // TODO: Upgrade this to use the non-legacy version diff --git a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift index 425d1c18c..e069bb481 100644 --- a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift @@ -66,25 +66,33 @@ public final class AttachmentUploadJob : NSObject, Job, NSCoding { // NSObject/N guard let stream = TSAttachment.fetch(uniqueId: attachmentID) as? TSAttachmentStream else { return handleFailure(error: Error.noAttachment) } - guard !stream.isUploaded else { return handleSuccess() } // Should never occur + guard !stream.isUploaded else { return handleSuccess(stream.serverId) } // Should never occur + let storage = SNMessagingKitConfiguration.shared.storage if let openGroup = storage.getOpenGroup(for: threadID) { AttachmentUploadJob.upload( stream, using: { data in - // TODO: Upgrade this to use the non-legacy version - return OpenGroupAPI.legacyUpload(data, to: openGroup.room, on: openGroup.server) + OpenGroupAPI.uploadFile(data.bytes, to: openGroup.room, on: openGroup.server) + .map { _, response -> UInt64 in response.id } }, encrypt: false, - onSuccess: handleSuccess, + onSuccess: { [weak self] fileId in self?.handleSuccess(fileId) }, + onFailure: handleFailure + ) + } + else { + AttachmentUploadJob.upload( + stream, + using: FileServerAPIV2.upload, + encrypt: true, + onSuccess: { [weak self] fileId in self?.handleSuccess(fileId) }, onFailure: handleFailure ) - } else { - AttachmentUploadJob.upload(stream, using: FileServerAPIV2.upload, encrypt: true, onSuccess: handleSuccess, onFailure: handleFailure) } } - public static func upload(_ stream: TSAttachmentStream, using upload: (Data) -> Promise, encrypt: Bool, onSuccess: (() -> Void)?, onFailure: ((Swift.Error) -> Void)?) { + public static func upload(_ stream: TSAttachmentStream, using upload: (Data) -> Promise, encrypt: Bool, onSuccess: ((UInt64) -> Void)?, onFailure: ((Swift.Error) -> Void)?) { // Get the attachment guard var data = try? stream.readDataFromFile() else { SNLog("Couldn't read attachment from disk.") @@ -105,37 +113,74 @@ public final class AttachmentUploadJob : NSObject, Job, NSCoding { // NSObject/N // Check the file size SNLog("File size: \(data.count) bytes.") if Double(data.count) > Double(FileServerAPIV2.maxFileSize) / FileServerAPIV2.fileSizeORMultiplier { - onFailure?(FileServerAPIV2.Error.maxFileSizeExceeded); return + onFailure?(HTTP.Error.maxFileSizeExceeded) + return } + // Send the request stream.isUploaded = false stream.save() - upload(data).done(on: DispatchQueue.global(qos: .userInitiated)) { fileID in - let downloadURL = "\(FileServerAPIV2.server)/files/\(fileID)" - stream.serverId = fileID + upload(data).done(on: DispatchQueue.global(qos: .userInitiated)) { fileId in + let downloadURL = "\(FileServerAPIV2.server)/files/\(fileId)" + stream.serverId = fileId stream.isUploaded = true stream.downloadURL = downloadURL stream.save() - onSuccess?() + onSuccess?(fileId) }.catch { error in onFailure?(error) } } - private func handleSuccess() { + private func handleSuccess(_ fileId: UInt64) { SNLog("Attachment uploaded successfully.") delegate?.handleJobSucceeded(self) - SNMessagingKitConfiguration.shared.storage.resumeMessageSendJobIfNeeded(messageSendJobID) - Storage.shared.write(with: { transaction in - var message: TSMessage? - let transaction = transaction as! YapDatabaseReadWriteTransaction - TSDatabaseSecondaryIndexes.enumerateMessages(withTimestamp: self.message.sentTimestamp!, with: { _, key, _ in - message = TSMessage.fetch(uniqueId: key, transaction: transaction) - }, using: transaction) - if let message = message { - MessageInvalidator.invalidate(message, with: transaction) + + let messageSendJobId: String = messageSendJobID + + Storage.shared.write( + with: { transaction in + // Get the existing MessageSendJob and replace it with one that has it's destination updated + // to include the returned fileId + if let oldJob: MessageSendJob = SNMessagingKitConfiguration.shared.storage.getMessageSendJob(for: messageSendJobId, using: transaction) { + switch oldJob.destination { + case .openGroup(let roomToken, let server, let whisperTo, let whisperMods, let oldFileIds): + let job: MessageSendJob = MessageSendJob( + message: oldJob.message, + destination: .openGroup( + roomToken: roomToken, + server: server, + whisperTo: whisperTo, + whisperMods: whisperMods, + fileIds: (oldFileIds ?? []) + [fileId] + ) + ) + job.id = oldJob.id // Use the existing id so it gets overwritten + job.delegate = oldJob.delegate + job.failureCount = oldJob.failureCount + + // This method just writes the job directly and doesn't generate a new id (as we want) + SNMessagingKitConfiguration.shared.storage.persist(job, using: transaction) + + default: break + } + } + }, + completion: { + SNMessagingKitConfiguration.shared.storage.resumeMessageSendJobIfNeeded(messageSendJobId) + + Storage.shared.write(with: { transaction in + var message: TSMessage? + let transaction = transaction as! YapDatabaseReadWriteTransaction + TSDatabaseSecondaryIndexes.enumerateMessages(withTimestamp: self.message.sentTimestamp!, with: { _, key, _ in + message = TSMessage.fetch(uniqueId: key, transaction: transaction) + }, using: transaction) + if let message = message { + MessageInvalidator.invalidate(message, with: transaction) + } + }, completion: { }) } - }, completion: { }) + ) } private func handlePermanentFailure(error: Swift.Error) { diff --git a/SessionMessagingKit/Jobs/MessageSendJob.swift b/SessionMessagingKit/Jobs/MessageSendJob.swift index 0d6d11070..e5a877618 100644 --- a/SessionMessagingKit/Jobs/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/MessageSendJob.swift @@ -6,7 +6,7 @@ public final class MessageSendJob : NSObject, Job, NSCoding { // NSObject/NSCodi public let message: Message public let destination: Message.Destination public var delegate: JobDelegate? - public var id: String? + public var id: String? // This should only get set in either `JobQueue` or `AttachmentUploadJob` public var failureCount: UInt = 0 // MARK: - Settings @@ -100,7 +100,7 @@ public final class MessageSendJob : NSObject, Job, NSCoding { // NSObject/NSCodi .replacingOccurrences(of: "]", with: "") .split(separator: "|") .map { String($0) } - let fileIds: [Int64]? = (fileIdStrings.isEmpty ? nil : fileIdStrings.compactMap { Int64($0) }) + let fileIds: [UInt64]? = (fileIdStrings.isEmpty ? nil : fileIdStrings.compactMap { UInt64($0) }) destination = .openGroup( roomToken: roomToken, diff --git a/SessionMessagingKit/Messages/Message+Destination.swift b/SessionMessagingKit/Messages/Message+Destination.swift index 8df03ee80..34f99ca25 100644 --- a/SessionMessagingKit/Messages/Message+Destination.swift +++ b/SessionMessagingKit/Messages/Message+Destination.swift @@ -10,11 +10,11 @@ public extension Message { server: String, whisperTo: String? = nil, whisperMods: Bool = false, - fileIds: [Int64]? = nil // TODO: Handle 'fileIds' + fileIds: [UInt64]? = nil ) case openGroupInbox(server: String, openGroupPublicKey: String, blindedPublicKey: String) - static func from(_ thread: TSThread) -> Message.Destination { + static func from(_ thread: TSThread, fileIds: [UInt64]? = nil) -> Message.Destination { if let thread = thread as? TSContactThread { if SessionId.Prefix(from: thread.contactSessionID()) == .blinded { guard let server: String = thread.originalOpenGroupServer, let publicKey: String = thread.originalOpenGroupPublicKey else { @@ -40,7 +40,7 @@ public extension Message { if let thread = thread as? TSGroupThread, thread.isOpenGroup { let openGroup: OpenGroup = Storage.shared.getOpenGroup(for: thread.uniqueId!)! - return .openGroup(roomToken: openGroup.room, server: openGroup.server) + return .openGroup(roomToken: openGroup.room, server: openGroup.server, fileIds: fileIds) } preconditionFailure("TODO: Handle legacy closed groups.") diff --git a/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift b/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift index ef789bebc..2d961910c 100644 --- a/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift +++ b/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift @@ -28,7 +28,7 @@ extension OpenGroupAPI { private let b64: String? private let bytes: [UInt8]? - init(request: Request) { + init(request: Request) { self.method = request.method self.path = request.urlPathAndParamsString self.headers = (request.headers.isEmpty ? nil : request.headers.toHTTPHeaders()) @@ -86,17 +86,17 @@ extension OpenGroupAPI { // MARK: - BatchRequestInfo struct BatchRequestInfo: BatchRequestInfoType { - let request: Request + let request: Request let responseType: Codable.Type var endpoint: Endpoint { request.endpoint } - init(request: Request, responseType: R.Type) { + init(request: Request, responseType: R.Type) { self.request = request self.responseType = BatchSubResponse.self } - init(request: Request) { + init(request: Request) { self.init( request: request, responseType: NoResponse.self @@ -124,7 +124,7 @@ extension OpenGroupAPI.BatchSubResponse { code: try container.decode(Int32.self, forKey: .code), headers: try container.decode([String: String].self, forKey: .headers), body: body, - failedToParseBody: (body == nil && T.self != OpenGroupAPI.NoResponse.self && !(T.self is ExpressibleByNilLiteral.Type)) + failedToParseBody: (body == nil && T.self != NoResponse.self && !(T.self is ExpressibleByNilLiteral.Type)) ) } } @@ -143,31 +143,31 @@ protocol BatchRequestInfoType { // MARK: - Convenience public extension Decodable { - static func decoded(from data: Data, customError: Error, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) throws -> Self { - return try data.decoded(as: Self.self, customError: customError, using: dependencies) + static func decoded(from data: Data, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) throws -> Self { + return try data.decoded(as: Self.self, using: dependencies) } } extension Promise where T == (OnionRequestResponseInfoType, Data?) { - func decoded(as types: OpenGroupAPI.BatchResponseTypes, on queue: DispatchQueue? = nil, error: Error, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) -> Promise { + func decoded(as types: OpenGroupAPI.BatchResponseTypes, on queue: DispatchQueue? = nil, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) -> Promise { self.map(on: queue) { responseInfo, maybeData -> OpenGroupAPI.BatchResponse in // Need to split the data into an array of data so each item can be Decoded correctly - guard let data: Data = maybeData else { throw OpenGroupAPI.Error.parsingFailed } + guard let data: Data = maybeData else { throw HTTP.Error.parsingFailed } guard let jsonObject: Any = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) else { - throw OpenGroupAPI.Error.parsingFailed + throw HTTP.Error.parsingFailed } - guard let anyArray: [Any] = jsonObject as? [Any] else { throw OpenGroupAPI.Error.parsingFailed } + guard let anyArray: [Any] = jsonObject as? [Any] else { throw HTTP.Error.parsingFailed } let dataArray: [Data] = anyArray.compactMap { try? JSONSerialization.data(withJSONObject: $0) } - guard dataArray.count == types.count else { throw OpenGroupAPI.Error.parsingFailed } + guard dataArray.count == types.count else { throw HTTP.Error.parsingFailed } do { return try zip(dataArray, types) - .map { data, type in try type.decoded(from: data, customError: error, using: dependencies) } + .map { data, type in try type.decoded(from: data, using: dependencies) } .map { data in (responseInfo, data) } } - catch _ { - throw error + catch { + throw HTTP.Error.parsingFailed } } } diff --git a/SessionMessagingKit/Open Groups/Models/LegacyAuthTokenResponse.swift b/SessionMessagingKit/Open Groups/Models/LegacyAuthTokenResponse.swift index 1f02b8ac8..d15f21cc8 100644 --- a/SessionMessagingKit/Open Groups/Models/LegacyAuthTokenResponse.swift +++ b/SessionMessagingKit/Open Groups/Models/LegacyAuthTokenResponse.swift @@ -28,7 +28,7 @@ extension OpenGroupAPI.LegacyAuthTokenResponse.Challenge { let base64EncodedEphemeralPublicKey: String = try container.decode(String.self, forKey: .ephemeralPublicKey) guard let ciphertext = Data(base64Encoded: base64EncodedCiphertext), let ephemeralPublicKey = Data(base64Encoded: base64EncodedEphemeralPublicKey) else { - throw OpenGroupAPI.Error.parsingFailed + throw HTTP.Error.parsingFailed } self = OpenGroupAPI.LegacyAuthTokenResponse.Challenge( diff --git a/SessionMessagingKit/Open Groups/Models/LegacyOpenGroupMessageV2.swift b/SessionMessagingKit/Open Groups/Models/LegacyOpenGroupMessageV2.swift index 3c87036e0..17b0d5565 100644 --- a/SessionMessagingKit/Open Groups/Models/LegacyOpenGroupMessageV2.swift +++ b/SessionMessagingKit/Open Groups/Models/LegacyOpenGroupMessageV2.swift @@ -49,7 +49,7 @@ extension LegacyOpenGroupMessageV2 { // Validate the message signature guard let data = Data(base64Encoded: base64EncodedData), let signature = Data(base64Encoded: base64EncodedSignature) else { - throw OpenGroupAPI.Error.parsingFailed + throw HTTP.Error.parsingFailed } let publicKey = Data(hex: sender.removingIdPrefixIfNeeded()) @@ -57,7 +57,7 @@ extension LegacyOpenGroupMessageV2 { guard isValid else { SNLog("Ignoring message with invalid signature.") - throw OpenGroupAPI.Error.parsingFailed + throw HTTP.Error.parsingFailed } self = LegacyOpenGroupMessageV2( diff --git a/SessionMessagingKit/Open Groups/Models/Room.swift b/SessionMessagingKit/Open Groups/Models/Room.swift index 2a31baabe..06417848b 100644 --- a/SessionMessagingKit/Open Groups/Models/Room.swift +++ b/SessionMessagingKit/Open Groups/Models/Room.swift @@ -70,7 +70,7 @@ extension OpenGroupAPI { /// File ID of an uploaded file containing the room's image /// /// Omitted if there is no image - public let imageId: Int64? + public let imageId: UInt64? /// Array of pinned message information (omitted entirely if there are no pinned messages) public let pinnedMessages: [PinnedMessage]? @@ -160,7 +160,7 @@ extension OpenGroupAPI.Room { activeUsers: try container.decode(Int64.self, forKey: .activeUsers), activeUsersCutoff: try container.decode(Int64.self, forKey: .activeUsersCutoff), - imageId: try? container.decode(Int64.self, forKey: .imageId), + imageId: try? container.decode(UInt64.self, forKey: .imageId), pinnedMessages: try? container.decode([OpenGroupAPI.PinnedMessage].self, forKey: .pinnedMessages), admin: ((try? container.decode(Bool.self, forKey: .admin)) ?? false), diff --git a/SessionMessagingKit/Open Groups/Models/OGMessage.swift b/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift similarity index 92% rename from SessionMessagingKit/Open Groups/Models/OGMessage.swift rename to SessionMessagingKit/Open Groups/Models/SOGSMessage.swift index aba49f877..c50620960 100644 --- a/SessionMessagingKit/Open Groups/Models/OGMessage.swift +++ b/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift @@ -45,10 +45,10 @@ extension OpenGroupAPI.Message { // 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 { guard let sender: String = maybeSender, let data = Data(base64Encoded: base64EncodedData), let signature = Data(base64Encoded: base64EncodedSignature) else { - throw OpenGroupAPI.Error.parsingFailed + throw HTTP.Error.parsingFailed } guard let dependencies: OpenGroupAPI.Dependencies = decoder.userInfo[OpenGroupAPI.Dependencies.userInfoKey] as? OpenGroupAPI.Dependencies else { - throw OpenGroupAPI.Error.parsingFailed + throw HTTP.Error.parsingFailed } // Verify the signature based on the SessionId.Prefix type @@ -58,18 +58,18 @@ extension OpenGroupAPI.Message { case .blinded: guard dependencies.sign.verify(message: data.bytes, publicKey: publicKey.bytes, signature: signature.bytes) else { SNLog("Ignoring message with invalid signature.") - throw OpenGroupAPI.Error.parsingFailed + throw HTTP.Error.parsingFailed } case .standard, .unblinded: guard (try? dependencies.ed25519.verifySignature(signature, publicKey: publicKey, data: data)) == true else { SNLog("Ignoring message with invalid signature.") - throw OpenGroupAPI.Error.parsingFailed + throw HTTP.Error.parsingFailed } case .none: SNLog("Ignoring message with invalid sender.") - throw OpenGroupAPI.Error.parsingFailed + throw HTTP.Error.parsingFailed } } diff --git a/SessionMessagingKit/Open Groups/Models/SendMessageRequest.swift b/SessionMessagingKit/Open Groups/Models/SendMessageRequest.swift index 9a53c14d6..734f07d2a 100644 --- a/SessionMessagingKit/Open Groups/Models/SendMessageRequest.swift +++ b/SessionMessagingKit/Open Groups/Models/SendMessageRequest.swift @@ -40,7 +40,7 @@ extension OpenGroupAPI { /// /// When submitting a message edit this field must contain the IDs of any newly uploaded files that are part of the edit; existing /// attachment IDs may also be included, but are not required - let fileIds: [Int64]? + let fileIds: [UInt64]? // MARK: - Initialization @@ -49,7 +49,7 @@ extension OpenGroupAPI { signature: Data, whisperTo: String? = nil, whisperMods: Bool? = nil, - fileIds: [Int64]? = nil + fileIds: [UInt64]? = nil ) { self.data = data self.signature = signature diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index 9a27e00d2..3c08c416b 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -59,7 +59,7 @@ public final class OpenGroupAPI: NSObject { // Generate the requests let requestResponseType: [BatchRequestInfoType] = [ BatchRequestInfo( - request: Request( + request: Request( server: server, endpoint: .capabilities ), @@ -85,14 +85,14 @@ public final class OpenGroupAPI: NSObject { return [ BatchRequestInfo( - request: Request( + request: Request( server: server, endpoint: .roomPollInfo(openGroup.room, openGroup.infoUpdates) ), responseType: RoomPollInfo.self ), BatchRequestInfo( - request: Request( + request: Request( server: server, endpoint: (shouldRetrieveRecentMessages ? .roomMessagesRecent(openGroup.room) : @@ -107,7 +107,7 @@ public final class OpenGroupAPI: NSObject { .appending([ // Inbox BatchRequestInfo( - request: Request( + request: Request( server: server, endpoint: (maybeLastInboxMessageId == nil ? .inbox : @@ -119,7 +119,7 @@ public final class OpenGroupAPI: NSObject { // Outbox BatchRequestInfo( - request: Request( + request: Request( server: server, endpoint: (maybeLastOutboxMessageId == nil ? .outbox : @@ -146,12 +146,12 @@ public final class OpenGroupAPI: NSObject { let request: Request = Request( method: .post, server: server, - endpoint: .batch, + endpoint: Endpoint.batch, body: requestBody ) return send(request, using: dependencies) - .decoded(as: responseTypes, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) + .decoded(as: responseTypes, on: OpenGroupAPI.workQueue, using: dependencies) .map { result in result.enumerated() .reduce(into: [:]) { prev, next in @@ -176,13 +176,13 @@ public final class OpenGroupAPI: NSObject { let request: Request = Request( method: .post, server: server, - endpoint: .sequence, + endpoint: Endpoint.sequence, body: requestBody ) // TODO: Handle a `412` response (ie. a required capability isn't supported) return send(request, using: dependencies) - .decoded(as: responseTypes, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) + .decoded(as: responseTypes, on: OpenGroupAPI.workQueue, using: dependencies) .map { result in result.enumerated() .reduce(into: [:]) { prev, next in @@ -201,7 +201,7 @@ public final class OpenGroupAPI: NSObject { /// Eg. `GET /capabilities` could return `{"capabilities": ["sogs", "batch"]}` `GET /capabilities?required=magic,batch` /// could return: `{"capabilities": ["sogs", "batch"], "missing": ["magic"]}` public static func capabilities(on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Capabilities)> { - let request: Request = Request( + let request: Request = Request( server: server, endpoint: .capabilities, queryParameters: [:] // TODO: Add any requirements '.required'. @@ -209,7 +209,7 @@ public final class OpenGroupAPI: NSObject { // TODO: Handle a `412` response (ie. a required capability isn't supported) return send(request, using: dependencies) - .decoded(as: Capabilities.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) + .decoded(as: Capabilities.self, on: OpenGroupAPI.workQueue, using: dependencies) } // MARK: - Room @@ -218,13 +218,13 @@ public final class OpenGroupAPI: NSObject { /// /// Rooms to which the user does not have access (e.g. because they are banned, or the room has restricted access permissions) are not included public static func rooms(for server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [Room])> { - let request: Request = Request( + let request: Request = Request( server: server, endpoint: .rooms ) return send(request, using: dependencies) - .decoded(as: [Room].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) + .decoded(as: [Room].self, on: OpenGroupAPI.workQueue, using: dependencies) } /// Returns the details of a single room @@ -234,13 +234,13 @@ public final class OpenGroupAPI: NSObject { /// method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") public static func room(for roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Room)> { - let request: Request = Request( + let request: Request = Request( server: server, endpoint: .room(roomToken) ) return send(request, using: dependencies) - .decoded(as: Room.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) + .decoded(as: Room.self, on: OpenGroupAPI.workQueue, using: dependencies) } /// Polls a room for metadata updates @@ -253,13 +253,13 @@ public final class OpenGroupAPI: NSObject { /// method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") public static func roomPollInfo(lastUpdated: Int64, for roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, RoomPollInfo)> { - let request: Request = Request( + let request: Request = Request( server: server, endpoint: .roomPollInfo(roomToken, lastUpdated) ) return send(request, using: dependencies) - .decoded(as: RoomPollInfo.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) + .decoded(as: RoomPollInfo.self, on: OpenGroupAPI.workQueue, using: dependencies) } /// This is a convenience method which constructs a `/sequence` of the `capabilities` and `room` requests, refer to those @@ -272,7 +272,7 @@ public final class OpenGroupAPI: NSObject { let requestResponseType: [BatchRequestInfoType] = [ // Get the latest capabilities for the server (in case it's a new server or the cached ones are stale) BatchRequestInfo( - request: Request( + request: Request( server: server, endpoint: .capabilities ), @@ -281,7 +281,7 @@ public final class OpenGroupAPI: NSObject { // And the room info BatchRequestInfo( - request: Request( + request: Request( server: server, endpoint: .room(roomToken) ), @@ -298,14 +298,14 @@ public final class OpenGroupAPI: NSObject { switch endpoint { case .capabilities: guard let responseData: BatchSubResponse = endpointResponse.data as? BatchSubResponse, let responseBody: Capabilities = responseData.body else { - throw Error.parsingFailed + throw HTTP.Error.parsingFailed } capabilities = (endpointResponse.info, responseBody) case .room: guard let responseData: OpenGroupAPI.BatchSubResponse = endpointResponse.data as? OpenGroupAPI.BatchSubResponse, let responseBody: OpenGroupAPI.Room = responseData.body else { - throw Error.parsingFailed + throw HTTP.Error.parsingFailed } room = (endpointResponse.info, responseBody) @@ -315,7 +315,7 @@ public final class OpenGroupAPI: NSObject { } guard let capabilities: (OnionRequestResponseInfoType, Capabilities?) = capabilities, let room: (OnionRequestResponseInfoType, Room?) = room else { - throw Error.parsingFailed + throw HTTP.Error.parsingFailed } return (capabilities, room) @@ -331,6 +331,7 @@ public final class OpenGroupAPI: NSObject { on server: String, whisperTo: String?, whisperMods: Bool, + fileIds: [UInt64]?, using dependencies: Dependencies = Dependencies() ) -> Promise<(OnionRequestResponseInfoType, Message)> { guard let signResult: (publicKey: String, signature: Bytes) = sign(plaintext.bytes, for: server, using: dependencies) else { @@ -342,18 +343,18 @@ public final class OpenGroupAPI: NSObject { signature: Data(signResult.signature), whisperTo: whisperTo, whisperMods: whisperMods, - fileIds: nil // TODO: Add support for 'fileIds'. + fileIds: fileIds ) let request = Request( method: .post, server: server, - endpoint: .roomMessage(roomToken), + endpoint: Endpoint.roomMessage(roomToken), body: requestBody ) return send(request, using: dependencies) - .decoded(as: Message.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) + .decoded(as: Message.self, on: OpenGroupAPI.workQueue, using: dependencies) .map { response, message in // Store the 'message.posted' timestamp to prevent the sent message getting duplicated when it is later retrieved dependencies.storage.write { transaction in @@ -367,13 +368,13 @@ public final class OpenGroupAPI: NSObject { /// Returns a single message by ID public static func message(_ id: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Message)> { - let request: Request = Request( + let request: Request = Request( server: server, endpoint: .roomMessageIndividual(roomToken, id: id) ) return send(request, using: dependencies) - .decoded(as: Message.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) + .decoded(as: Message.self, on: OpenGroupAPI.workQueue, using: dependencies) } /// Edits a message, replacing its existing content with new content and a new signature @@ -400,7 +401,7 @@ public final class OpenGroupAPI: NSObject { let request: Request = Request( method: .put, server: server, - endpoint: .roomMessageIndividual(roomToken, id: id), + endpoint: Endpoint.roomMessageIndividual(roomToken, id: id), body: requestBody ) @@ -414,7 +415,7 @@ public final class OpenGroupAPI: NSObject { on server: String, using dependencies: Dependencies = Dependencies() ) -> Promise<(OnionRequestResponseInfoType, Data?)> { - let request: Request = Request( + let request: Request = Request( method: .delete, server: server, endpoint: .roomMessageIndividual(roomToken, id: id) @@ -428,7 +429,7 @@ public final class OpenGroupAPI: NSObject { /// method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") public static func recentMessages(in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [Message])> { - let request: Request = Request( + let request: Request = Request( server: server, endpoint: .roomMessagesRecent(roomToken) // TODO: Limit?. @@ -436,7 +437,7 @@ public final class OpenGroupAPI: NSObject { ) return send(request, using: dependencies) - .decoded(as: [Message].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) + .decoded(as: [Message].self, on: OpenGroupAPI.workQueue, using: dependencies) } /// **Note:** This is the direct request to retrieve recent messages before a given message and is currently unused, in order to call this directly @@ -445,7 +446,7 @@ public final class OpenGroupAPI: NSObject { @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") public static func messagesBefore(messageId: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [Message])> { // TODO: Do we need to be able to load old messages? - let request: Request = Request( + let request: Request = Request( server: server, endpoint: .roomMessagesBefore(roomToken, id: messageId) // TODO: Limit?. @@ -453,7 +454,7 @@ public final class OpenGroupAPI: NSObject { ) return send(request, using: dependencies) - .decoded(as: [Message].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) + .decoded(as: [Message].self, on: OpenGroupAPI.workQueue, using: dependencies) } /// **Note:** This is the direct request to retrieve messages since a given message `seqNo` so should be retrieved automatically from the @@ -461,7 +462,7 @@ public final class OpenGroupAPI: NSObject { /// `OpenGroupManager.handleMessages` method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") public static func messagesSince(seqNo: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [Message])> { - let request: Request = Request( + let request: Request = Request( server: server, endpoint: .roomMessagesSince(roomToken, seqNo: seqNo) // TODO: Limit?. @@ -469,7 +470,7 @@ public final class OpenGroupAPI: NSObject { ) return send(request, using: dependencies) - .decoded(as: [Message].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) + .decoded(as: [Message].self, on: OpenGroupAPI.workQueue, using: dependencies) } // MARK: - Pinning @@ -485,7 +486,7 @@ public final class OpenGroupAPI: NSObject { /// messages are returned in pinning-order this allows admins to order multiple pinned messages in a room by re-pinning (via this endpoint) in the /// order in which pinned messages should be displayed public static func pinMessage(id: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise { - let request: Request = Request( + let request: Request = Request( method: .post, server: server, endpoint: .roomPinMessage(roomToken, id: id) @@ -499,7 +500,7 @@ public final class OpenGroupAPI: NSObject { /// /// The user must have `admin` (not just `moderator`) permissions in the room public static func unpinMessage(id: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise { - let request: Request = Request( + let request: Request = Request( method: .post, server: server, endpoint: .roomUnpinMessage(roomToken, id: id) @@ -513,7 +514,7 @@ public final class OpenGroupAPI: NSObject { /// /// The user must have `admin` (not just `moderator`) permissions in the room public static func unpinAll(in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise { - let request: Request = Request( + let request: Request = Request( method: .post, server: server, endpoint: .roomUnpinAll(roomToken) @@ -529,7 +530,7 @@ public final class OpenGroupAPI: NSObject { let request: Request = Request( method: .post, server: server, - endpoint: .roomFile(roomToken), + endpoint: Endpoint.roomFile(roomToken), headers: [ .contentDisposition: [ "attachment", fileName.map { "filename=\"\($0)\"" } ] .compactMap{ $0 } @@ -540,50 +541,31 @@ public final class OpenGroupAPI: NSObject { ) return send(request, using: dependencies) - .decoded(as: FileUploadResponse.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) + .decoded(as: FileUploadResponse.self, on: OpenGroupAPI.workQueue, using: dependencies) } - /// Warning: This approach is less efficient as it expects the data to be base64Encoded (with is 33% larger than binary), please use the binary approach - /// whenever possible - public static func uploadFile(_ base64EncodedString: String, fileName: String? = nil, to roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, FileUploadResponse)> { - let request: Request = Request( - method: .post, - server: server, - endpoint: .roomFileJson(roomToken), - headers: [ - .contentDisposition: [ "attachment", fileName.map { "filename=\"\($0)\"" } ] - .compactMap{ $0 } - .joined(separator: "; "), - ], - body: base64EncodedString - ) - - return send(request, using: dependencies) - .decoded(as: FileUploadResponse.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) - } - - public static func downloadFile(_ fileId: Int64, from roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Data)> { - let request: Request = Request( + public static func downloadFile(_ fileId: UInt64, from roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Data)> { + let request: Request = Request( server: server, endpoint: .roomFileIndividual(roomToken, fileId) ) return send(request, using: dependencies) .map { responseInfo, maybeData in - guard let data: Data = maybeData else { throw Error.parsingFailed } + guard let data: Data = maybeData else { throw HTTP.Error.parsingFailed } return (responseInfo, data) } } - public static func downloadFileJson(_ fileId: Int64, from roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, FileDownloadResponse)> { - let request: Request = Request( + public static func downloadFileJson(_ fileId: UInt64, from roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, FileDownloadResponse)> { + let request: Request = Request( server: server, endpoint: .roomFileIndividualJson(roomToken, fileId) ) // TODO: This endpoint is getting rewritten to return just data (properties would come through as headers). return send(request, using: dependencies) - .decoded(as: FileDownloadResponse.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) + .decoded(as: FileDownloadResponse.self, on: OpenGroupAPI.workQueue, using: dependencies) } // MARK: - Inbox/Outbox (Message Requests) @@ -595,13 +577,13 @@ public final class OpenGroupAPI: NSObject { /// `OpenGroupManager.handleDirectMessages` method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") public static func inbox(on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> { - let request: Request = Request( + let request: Request = Request( server: server, endpoint: .inbox ) return send(request, using: dependencies) - .decoded(as: [DirectMessage]?.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) + .decoded(as: [DirectMessage]?.self, on: OpenGroupAPI.workQueue, using: dependencies) } /// Polls for any DMs received since the given id, this method will return a `304` with an empty response if there are no messages @@ -611,13 +593,13 @@ public final class OpenGroupAPI: NSObject { /// of this method to the `OpenGroupManager.handleDirectMessages` method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") public static func inboxSince(id: Int64, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> { - let request: Request = Request( + let request: Request = Request( server: server, endpoint: .inboxSince(id: id) ) return send(request, using: dependencies) - .decoded(as: [DirectMessage]?.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) + .decoded(as: [DirectMessage]?.self, on: OpenGroupAPI.workQueue, using: dependencies) } /// Delivers a direct message to a user via their blinded Session ID @@ -631,12 +613,12 @@ public final class OpenGroupAPI: NSObject { let request: Request = Request( method: .post, server: server, - endpoint: .inboxFor(sessionId: blindedSessionId), + endpoint: Endpoint.inboxFor(sessionId: blindedSessionId), body: requestBody ) return send(request, using: dependencies) - .decoded(as: SendDirectMessageResponse.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) + .decoded(as: SendDirectMessageResponse.self, on: OpenGroupAPI.workQueue, using: dependencies) .map { response, message in // Store the 'message.posted' timestamp to prevent the sent message getting duplicated when it is later retrieved dependencies.storage.write { transaction in @@ -655,13 +637,13 @@ public final class OpenGroupAPI: NSObject { /// this method to the `OpenGroupManager.handleDirectMessages` method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") public static func outbox(on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> { - let request: Request = Request( + let request: Request = Request( server: server, endpoint: .outbox ) return send(request, using: dependencies) - .decoded(as: [DirectMessage]?.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) + .decoded(as: [DirectMessage]?.self, on: OpenGroupAPI.workQueue, using: dependencies) } /// Polls for any DMs sent since the given id, this method will return a `304` with an empty response if there are no messages @@ -671,13 +653,13 @@ public final class OpenGroupAPI: NSObject { /// to route the response of this method to the `OpenGroupManager.handleDirectMessages` method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") public static func outboxSince(id: Int64, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> { - let request: Request = Request( + let request: Request = Request( server: server, endpoint: .outboxSince(id: id) ) return send(request, using: dependencies) - .decoded(as: [DirectMessage]?.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) + .decoded(as: [DirectMessage]?.self, on: OpenGroupAPI.workQueue, using: dependencies) } // MARK: - Users @@ -729,7 +711,7 @@ public final class OpenGroupAPI: NSObject { let request: Request = Request( method: .post, server: server, - endpoint: .userBan(sessionId), + endpoint: Endpoint.userBan(sessionId), body: requestBody ) @@ -774,7 +756,7 @@ public final class OpenGroupAPI: NSObject { let request: Request = Request( method: .post, server: server, - endpoint: .userUnban(sessionId), + endpoint: Endpoint.userUnban(sessionId), body: requestBody ) @@ -841,7 +823,9 @@ public final class OpenGroupAPI: NSObject { on server: String, using dependencies: Dependencies = Dependencies() ) -> Promise<(OnionRequestResponseInfoType, Data?)> { - guard (moderator != nil && admin == nil) || (moderator == nil && admin != nil) else { return Promise(error: Error.generic) } + guard (moderator != nil && admin == nil) || (moderator == nil && admin != nil) else { + return Promise(error: HTTP.Error.generic) + } let requestBody: UserModeratorRequest = UserModeratorRequest( rooms: roomTokens, @@ -854,7 +838,7 @@ public final class OpenGroupAPI: NSObject { let request: Request = Request( method: .post, server: server, - endpoint: .userModerator(sessionId), + endpoint: Endpoint.userModerator(sessionId), body: requestBody ) @@ -894,12 +878,12 @@ public final class OpenGroupAPI: NSObject { let request: Request = Request( method: .post, server: server, - endpoint: .userDeleteMessages(sessionId), + endpoint: Endpoint.userDeleteMessages(sessionId), body: requestBody ) return send(request, using: dependencies) - .decoded(as: UserDeleteMessagesResponse.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies) + .decoded(as: UserDeleteMessagesResponse.self, on: OpenGroupAPI.workQueue, using: dependencies) } // TODO: Need to test this once the API has been implemented @@ -1003,11 +987,7 @@ public final class OpenGroupAPI: NSObject { /// Get a hash of any body content let bodyHash: Bytes? = { - // Note: We need the `!body.isEmpty` check because of the default `Data()` value when trying to - // init data from the httpBodyStream - guard let body: Data = (request.httpBody ?? request.httpBodyStream.map { ((try? Data(from: $0)) ?? Data()) }), !body.isEmpty else { - return nil - } + guard let body: Data = request.httpBody else { return nil } return dependencies.genericHash.hash(message: body.bytes, outputLength: 64) }() @@ -1045,20 +1025,14 @@ public final class OpenGroupAPI: NSObject { // MARK: - Convenience - private static func send(_ request: Request, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Data?)> { - guard let url: URL = request.url else { return Promise(error: Error.invalidURL) } - - var urlRequest: URLRequest = URLRequest(url: url) - urlRequest.httpMethod = request.method.rawValue - urlRequest.allHTTPHeaderFields = request.headers - .setting(.room, request.room) // TODO: Is this needed anymore? Add at the request level?. - .toHTTPHeaders() + private static func send(_ request: Request, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Data?)> { + let urlRequest: URLRequest do { - urlRequest.httpBody = try request.bodyData() + urlRequest = try request.generateUrlRequest() } catch { - return Promise(error: Error.parsingFailed) + return Promise(error: error) } if request.useOnionRouting { diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 1726c15b8..d23ae1edd 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -46,7 +46,7 @@ public final class OpenGroupManager: NSObject { let groupId: Data = LKGroupUtilities.getEncodedOpenGroupIDAsData("\(server).\(roomToken)") if OpenGroupManager.shared.pollers[server] != nil && TSGroupThread.fetch(uniqueId: TSGroupThread.threadId(fromGroupId: groupId), transaction: transaction) != nil { - SNLog("Ignoring join open group attempt, user initiated: \(!isConfigMessage)") + SNLog("Ignoring join open group attempt (already joined), user initiated: \(!isConfigMessage)") return Promise.value(()) } @@ -63,7 +63,7 @@ public final class OpenGroupManager: NSObject { .done(on: DispatchQueue.global(qos: .userInitiated)) { (capabilitiesResponse: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Capabilities?), roomResponse: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Room?)) in guard let capabilities: OpenGroupAPI.Capabilities = capabilitiesResponse.data, let room: OpenGroupAPI.Room = roomResponse.data else { SNLog("Failed to join open group due to invalid data.") - seal.reject(OpenGroupAPI.Error.generic) + seal.reject(HTTP.Error.generic) return } @@ -254,7 +254,7 @@ public final class OpenGroupManager: NSObject { } // - Room image (if there is one) - if let imageId: Int64 = pollInfo.details?.imageId { + if let imageId: UInt64 = pollInfo.details?.imageId { OpenGroupManager.roomImage(imageId, for: roomToken, on: server) .done(on: DispatchQueue.global(qos: .userInitiated)) { data in dependencies.storage.write { transaction in @@ -493,8 +493,8 @@ public final class OpenGroupManager: NSObject { OpenGroupManager.defaultRoomsPromise? .done(on: OpenGroupAPI.workQueue) { items in items - .compactMap { room -> (Int64, String)? in - guard let imageId: Int64 = room.imageId else { return nil} + .compactMap { room -> (UInt64, String)? in + guard let imageId: UInt64 = room.imageId else { return nil} return (imageId, room.token) } @@ -511,7 +511,7 @@ public final class OpenGroupManager: NSObject { } public static func roomImage( - _ fileId: Int64, + _ fileId: UInt64, for roomToken: String, on server: String, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies() diff --git a/SessionMessagingKit/Open Groups/Types/Request.swift b/SessionMessagingKit/Open Groups/Types/Request.swift deleted file mode 100644 index 9c0efe01c..000000000 --- a/SessionMessagingKit/Open Groups/Types/Request.swift +++ /dev/null @@ -1,94 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionUtilitiesKit - -extension OpenGroupAPI { - struct Empty: Codable {} - - typealias NoBody = Empty - typealias NoResponse = Empty - - struct Request { - let method: HTTP.Verb - let server: String - let room: String? // TODO: Remove this? - let endpoint: Endpoint - let queryParameters: [QueryParam: String] - let headers: [Header: String] - /// This is the body value sent during the request - /// - /// **Warning:** The `bodyData` value should be used to when making the actual request instead of this as there - /// is custom handling for certain data types - let body: T? - let isAuthRequired: Bool - /// Always `true` under normal circumstances. You might want to disable - /// this when running over Lokinet. - let useOnionRouting: Bool - - // MARK: - Initialization - - init( - method: HTTP.Verb = .get, - server: String, - room: String? = nil, - endpoint: Endpoint, - queryParameters: [QueryParam: String] = [:], - headers: [Header: String] = [:], - body: T? = nil, - isAuthRequired: Bool = true, - useOnionRouting: Bool = true - ) { - self.method = method - self.server = server - self.room = room - self.endpoint = endpoint - self.queryParameters = queryParameters - self.headers = headers - self.body = body - self.isAuthRequired = isAuthRequired - self.useOnionRouting = useOnionRouting - } - - // MARK: - Convenience - - var url: URL? { - return URL(string: "\(server)\(urlPathAndParamsString)") - } - - var urlPathAndParamsString: String { - return [ - "/\(endpoint.path)", - queryParameters - .map { key, value in "\(key.rawValue)=\(value)" } - .joined(separator: "&") - ] - .compactMap { $0 } - .filter { !$0.isEmpty } - .joined(separator: "?") - } - - func bodyData() throws -> Data? { - // Note: Need to differentiate between JSON, b64 string and bytes body values to ensure they are - // encoded correctly so the server knows how to handle them - switch body { - case let bodyString as String: - // The only acceptable string body is a base64 encoded one - guard let encodedData: Data = Data(base64Encoded: bodyString) else { - throw OpenGroupAPI.Error.parsingFailed - } - - return encodedData - - case let bodyBytes as [UInt8]: - return Data(bodyBytes) - - default: - // Having no body is fine so just return nil - guard let body: T = body else { return nil } - - return try JSONEncoder().encode(body) - } - } - } -} diff --git a/SessionMessagingKit/Open Groups/Types/Endpoint.swift b/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift similarity index 97% rename from SessionMessagingKit/Open Groups/Types/Endpoint.swift rename to SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift index 9149aec5b..e8019e354 100644 --- a/SessionMessagingKit/Open Groups/Types/Endpoint.swift +++ b/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift @@ -3,7 +3,7 @@ import Foundation extension OpenGroupAPI { - public enum Endpoint: Hashable { + public enum Endpoint: EndpointType { // Utility case onion @@ -34,9 +34,8 @@ extension OpenGroupAPI { // Files case roomFile(String) - case roomFileJson(String) - case roomFileIndividual(String, Int64) - case roomFileIndividualJson(String, Int64) + case roomFileIndividual(String, UInt64) + case roomFileIndividualJson(String, UInt64) // Inbox/Outbox (Message Requests) @@ -126,7 +125,6 @@ extension OpenGroupAPI { // Files case .roomFile(let roomToken): return "room/\(roomToken)/file" - case .roomFileJson(let roomToken): return "room/\(roomToken)/fileJSON" case .roomFileIndividual(let roomToken, let fileId): // Note: The 'fileName' value is ignored by the server and is only used to distinguish // this from the 'Json' variant diff --git a/SessionMessagingKit/Open Groups/Types/Error.swift b/SessionMessagingKit/Open Groups/Types/SOGSError.swift similarity index 69% rename from SessionMessagingKit/Open Groups/Types/Error.swift rename to SessionMessagingKit/Open Groups/Types/SOGSError.swift index 87eb8b255..2d1b7e660 100644 --- a/SessionMessagingKit/Open Groups/Types/Error.swift +++ b/SessionMessagingKit/Open Groups/Types/SOGSError.swift @@ -4,20 +4,14 @@ import Foundation extension OpenGroupAPI { public enum Error: LocalizedError { - case generic - case parsingFailed case decryptionFailed case signingFailed - case invalidURL case noPublicKey public var errorDescription: String? { switch self { - case .generic: return "An error occurred." - case .parsingFailed: return "Invalid response." case .decryptionFailed: return "Couldn't decrypt response." case .signingFailed: return "Couldn't sign message." - case .invalidURL: return "Invalid URL." case .noPublicKey: return "Couldn't find server public key." } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index d547cbeea..073a274f6 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -379,7 +379,7 @@ public final class MessageSender : NSObject { // Send the result - guard case .openGroup(let room, let server, let whisperTo, let whisperMods, _) = destination else { + guard case .openGroup(let room, let server, let whisperTo, let whisperMods, let fileIds) = destination else { preconditionFailure() } @@ -389,7 +389,8 @@ public final class MessageSender : NSObject { to: room, on: server, whisperTo: whisperTo, - whisperMods: whisperMods + whisperMods: whisperMods, + fileIds: fileIds ) .done(on: DispatchQueue.global(qos: .userInitiated)) { responseInfo, data in message.openGroupServerMessageID = UInt64(data.id) diff --git a/SessionMessagingKit/Storage.swift b/SessionMessagingKit/Storage.swift index bd37cdc49..92d3d12c9 100644 --- a/SessionMessagingKit/Storage.swift +++ b/SessionMessagingKit/Storage.swift @@ -45,6 +45,7 @@ public protocol SessionMessagingKitStorageProtocol { func getAllPendingJobs(of type: Job.Type) -> [Job] func getAttachmentUploadJob(for attachmentID: String) -> AttachmentUploadJob? func getMessageSendJob(for messageSendJobID: String) -> MessageSendJob? + func getMessageSendJob(for messageSendJobID: String, using transaction: Any) -> MessageSendJob? func resumeMessageSendJobIfNeeded(_ messageSendJobID: String) func isJobCanceled(_ job: Job) -> Bool diff --git a/SessionMessagingKit/Utilities/Promise+Utilities.swift b/SessionMessagingKit/Utilities/Promise+Utilities.swift index 91d25c937..40278446a 100644 --- a/SessionMessagingKit/Utilities/Promise+Utilities.swift +++ b/SessionMessagingKit/Utilities/Promise+Utilities.swift @@ -13,13 +13,16 @@ extension Promise where T == Data { } extension Promise where T == (OnionRequestResponseInfoType, Data?) { - func decoded(as type: R.Type, on queue: DispatchQueue? = nil, error: Error? = nil, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) -> Promise<(OnionRequestResponseInfoType, R)> { + func decoded(as type: R.Type, on queue: DispatchQueue? = nil, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) -> Promise<(OnionRequestResponseInfoType, R)> { self.map(on: queue) { responseInfo, maybeData -> (OnionRequestResponseInfoType, R) in - guard let data: Data = maybeData else { - throw OpenGroupAPI.Error.parsingFailed - } + guard let data: Data = maybeData else { throw HTTP.Error.parsingFailed } - return (responseInfo, try data.decoded(as: type, customError: error, using: dependencies)) + do { + return (responseInfo, try data.decoded(as: type, using: dependencies)) + } + catch { + throw HTTP.Error.parsingFailed + } } } } diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift index 31dd50c17..e846d8b23 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift @@ -252,7 +252,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPI.Error.parsingFailed.localizedDescription), + equal(HTTP.Error.parsingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -272,7 +272,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPI.Error.parsingFailed.localizedDescription), + equal(HTTP.Error.parsingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -292,7 +292,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPI.Error.parsingFailed.localizedDescription), + equal(HTTP.Error.parsingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -312,7 +312,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPI.Error.parsingFailed.localizedDescription), + equal(HTTP.Error.parsingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -364,7 +364,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPI.Error.parsingFailed.localizedDescription), + equal(HTTP.Error.parsingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -413,7 +413,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPI.Error.parsingFailed.localizedDescription), + equal(HTTP.Error.parsingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -449,7 +449,8 @@ class OpenGroupAPISpec: QuickSpec { expect(requestData?.urlString).to(equal("testServer/room/testRoom/file")) expect(requestData?.httpMethod).to(equal("POST")) expect(requestData?.headers).to(haveCount(4)) - expect(requestData?.headers.keys).toNot(contain(Header.fileName.rawValue)) + expect(requestData?.headers[Header.contentDisposition.rawValue]) + .toNot(contain("filename")) } it("adds a fileName header when provided") { @@ -477,7 +478,7 @@ class OpenGroupAPISpec: QuickSpec { expect(requestData?.urlString).to(equal("testServer/room/testRoom/file")) expect(requestData?.httpMethod).to(equal("POST")) expect(requestData?.headers).to(haveCount(5)) - expect(requestData?.headers[Header.fileName.rawValue]).to(equal("TestFileName")) + expect(requestData?.headers[Header.contentDisposition.rawValue]).to(contain("TestFileName")) } } diff --git a/SessionSnodeKit/OnionRequestAPI+Encryption.swift b/SessionSnodeKit/OnionRequestAPI+Encryption.swift index bcbb58a67..069d6530a 100644 --- a/SessionSnodeKit/OnionRequestAPI+Encryption.swift +++ b/SessionSnodeKit/OnionRequestAPI+Encryption.swift @@ -14,24 +14,22 @@ internal extension OnionRequestAPI { } /// Encrypts `payload` for `destination` and returns the result. Use this to build the core of an onion request. - static func encrypt(_ payload: String, for destination: Destination, with version: Version) -> Promise { + static func encrypt(_ payload: Data, for destination: Destination, with version: Version) -> Promise { let (promise, seal) = Promise.pending() DispatchQueue.global(qos: .userInitiated).async { do { - guard let payloadAsData: Data = payload.data(using: .utf8) else { throw Error.invalidRequestInfo } - let data: Data switch version { case .v2, .v3: // Wrapping is only needed for snode requests switch destination { - case .snode: data = try encode(ciphertext: payloadAsData, json: [ "headers" : "" ]) - case .server: data = payloadAsData + case .snode: data = try encode(ciphertext: payload, json: [ "headers" : "" ]) + case .server: data = payload } case .v4: - data = payloadAsData + data = payload } let result = try encrypt(data, for: destination) diff --git a/SessionSnodeKit/OnionRequestAPI.swift b/SessionSnodeKit/OnionRequestAPI.swift index de36cc088..7a37d6e60 100644 --- a/SessionSnodeKit/OnionRequestAPI.swift +++ b/SessionSnodeKit/OnionRequestAPI.swift @@ -249,7 +249,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { } /// Builds an onion around `payload` and returns the result. - private static func buildOnion(around payload: String, targetedAt destination: Destination, version: Version) -> Promise { + private static func buildOnion(around payload: Data, targetedAt destination: Destination, version: Version) -> Promise { var guardSnode: Snode! var targetSnodeSymmetricKey: Data! // Needed by invoke(_:on:with:) to decrypt the response sent back by the destination var encryptionResult: AESGCM.EncryptionResult! @@ -287,7 +287,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { public static func sendOnionRequest(to snode: Snode, invoking method: Snode.Method, with parameters: JSON, using version: Version, associatedWith publicKey: String?) -> Promise { let payloadJson: JSON = [ "method": method.rawValue, "params": parameters ] - guard let jsonData: Data = try? JSONSerialization.data(withJSONObject: payloadJson, options: []), let payload: String = String(data: jsonData, encoding: .utf8) else { + guard let payload: Data = try? JSONSerialization.data(withJSONObject: payloadJson, options: []) else { return Promise(error: HTTP.Error.invalidJSON) } @@ -316,7 +316,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { let scheme: String? = url.scheme let port: UInt16? = url.port.map { UInt16($0) } - guard let payload: String = generatePayload(for: request, with: version) else { + guard let payload: Data = generatePayload(for: request, with: version) else { return Promise(error: Error.invalidRequestInfo) } @@ -328,7 +328,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { return promise } - public static func sendOnionRequest(with payload: String, to destination: Destination, version: Version) -> Promise<(OnionRequestResponseInfoType, Data?)> { + public static func sendOnionRequest(with payload: Data, to destination: Destination, version: Version) -> Promise<(OnionRequestResponseInfoType, Data?)> { let (promise, seal) = Promise<(OnionRequestResponseInfoType, Data?)>.pending() var guardSnode: Snode? Threading.workQueue.async { // Avoid race conditions on `guardSnodes` and `paths` @@ -451,7 +451,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { // MARK: - Version Handling - private static func generatePayload(for request: URLRequest, with version: Version) -> String? { + private static func generatePayload(for request: URLRequest, with version: Version) -> Data? { guard let url = request.url else { return nil } switch version { @@ -475,10 +475,6 @@ public enum OnionRequestAPI: OnionRequestAPIType { headers["Content-Type"] = "application/json" // Assume data is JSON bodyAsString = (String(data: body, encoding: .utf8) ?? "null") } - else if let inputStream: InputStream = request.httpBodyStream, let body: Data = try? Data(from: inputStream) { - headers["Content-Type"] = request.allHTTPHeaderFields!["Content-Type"] - bodyAsString = "{ \"fileUpload\" : \"\(String(data: body.base64EncodedData(), encoding: .utf8) ?? "null")\" }" - } else { bodyAsString = "null" } @@ -492,7 +488,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { guard let jsonData: Data = try? JSONSerialization.data(withJSONObject: payload, options: []) else { return nil } - return String(data: jsonData, encoding: .utf8) + return jsonData // V4 Onion Requests have a very different structure case .v4: @@ -502,12 +498,12 @@ public enum OnionRequestAPI: OnionRequestAPIType { .appending(url.query.map { value in "?\(value)" }) let requestInfo: RequestInfo = RequestInfo( - method: (request.httpMethod ?? "GET"), // Default (if nil) is 'GET' + method: (request.httpMethod ?? "GET"), // The default (if nil) is 'GET' endpoint: endpoint, headers: (request.allHTTPHeaderFields ?? [:]) .setting( "Content-Type", - (request.httpBody == nil && request.httpBodyStream == nil ? nil : + (request.httpBody == nil ? nil : // Default to JSON if not defined ((request.allHTTPHeaderFields ?? [:])["Content-Type"] ?? "application/json") ) @@ -515,26 +511,17 @@ public enum OnionRequestAPI: OnionRequestAPIType { .removingValue(forKey: "User-Agent") ) - guard let requestInfoData: Data = try? JSONEncoder().encode(requestInfo), let requestInfoString: String = String(data: requestInfoData, encoding: .ascii) else { + /// Generate the Bencoded payload in the form `l{requestInfoLength}:{requestInfo}{bodyLength}:{body}e` + guard let requestInfoData: Data = try? JSONEncoder().encode(requestInfo) else { return nil } + guard let prefixData: Data = "l\(requestInfoData.count):".data(using: .ascii), let suffixData: Data = "e".data(using: .ascii) else { return nil } - if let body: Data = request.httpBody { - guard let bodyString: String = String(data: body, encoding: .ascii) else { - return nil - } - - return "l\(requestInfoString.count):\(requestInfoString)\(bodyString.count):\(bodyString)e" - } - else if let inputStream: InputStream = request.httpBodyStream, let body: Data = try? Data(from: inputStream), let bodyString: String = String(data: body, encoding: .ascii) { - // TODO: Handle this properly - // headers["Content-Type"] = request.allHTTPHeaderFields!["Content-Type"] - // bodyAsString = "{ \"fileUpload\" : \"\(String(data: body.base64EncodedData(), encoding: .utf8) ?? "null")\" }" - return "l\(requestInfoString.count):\(requestInfoString)\(bodyString.count):\(bodyString)e" - } - else { - return "l\(requestInfoString.count):\(requestInfoString)e" + if let body: Data = request.httpBody, let bodyCountData: Data = "\(body.count):".data(using: .ascii) { + return (prefixData + requestInfoData + bodyCountData + body + suffixData) } + + return (prefixData + requestInfoData + suffixData) } } @@ -653,12 +640,6 @@ public enum OnionRequestAPI: OnionRequestAPIType { return seal.fulfill((responseInfo, nil)) } - // TODO: Is this going to be done anymore...??? -// if let timestamp = body["t"] as? Int64 { -// let offset = timestamp - Int64(NSDate.millisecondTimestamp()) -// SnodeAPI.clockOffset = offset -// } - // Extract the response data as well let dataString: String = String(responseString.suffix(from: infoStringEndIndex)) let dataStringParts: [String.SubSequence] = dataString.split(separator: ":") diff --git a/SessionUtilitiesKit/Networking/HTTP.swift b/SessionUtilitiesKit/Networking/HTTP.swift index a13fffbb9..06c7b7f13 100644 --- a/SessionUtilitiesKit/Networking/HTTP.swift +++ b/SessionUtilitiesKit/Networking/HTTP.swift @@ -78,18 +78,23 @@ public enum HTTP { // MARK: - Error - public enum Error : LocalizedError { + public enum Error: LocalizedError, Equatable { case generic - case httpRequestFailed(statusCode: UInt, data: Data?) + case invalidURL case invalidJSON + case parsingFailed case invalidResponse - + case maxFileSizeExceeded + case httpRequestFailed(statusCode: UInt, data: Data?) + public var errorDescription: String? { switch self { case .generic: return "An error occurred." - case .httpRequestFailed(let statusCode, _): return "HTTP request failed with status code: \(statusCode)." + case .invalidURL: return "Invalid URL." case .invalidJSON: return "Invalid JSON." - case .invalidResponse: return "Invalid Response" + case .parsingFailed, .invalidResponse: return "Invalid response." + case .maxFileSizeExceeded: return "Maximum file size exceeded." + case .httpRequestFailed(let statusCode, _): return "HTTP request failed with status code: \(statusCode)." } } } diff --git a/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift b/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift index b73c88258..a94af27b5 100644 --- a/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift +++ b/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift @@ -39,79 +39,108 @@ extension MessageSender { } public static func sendNonDurably(_ message: VisibleMessage, with attachmentIDs: [String], in thread: TSThread, using transaction: YapDatabaseReadWriteTransaction) -> Promise { - let attachments = attachmentIDs.compactMap { TSAttachment.fetch(uniqueId: $0, transaction: transaction) as? TSAttachmentStream } + let attachments = attachmentIDs.compactMap { + TSAttachment.fetch(uniqueId: $0, transaction: transaction) as? TSAttachmentStream + } let attachmentsToUpload = attachments.filter { !$0.isUploaded } - let attachmentUploadPromises: [Promise] = attachmentsToUpload.map { stream in + let attachmentUploadPromises: [Promise] = attachmentsToUpload.map { stream in let storage = SNMessagingKitConfiguration.shared.storage - if let openGroup = storage.getOpenGroup(for: thread.uniqueId!) { - let (promise, seal) = Promise.pending() + + if let threadId: String = thread.uniqueId, let openGroup = storage.getOpenGroup(for: threadId) { + let (promise, seal) = Promise.pending() AttachmentUploadJob.upload( stream, using: { data in - // TODO: Update to non-legacy version. - OpenGroupAPI.legacyUpload( - data, - to: openGroup.room, - on: openGroup.server - ) + OpenGroupAPI + .uploadFile( + data.bytes, + to: openGroup.room, + on: openGroup.server + ) + .map { _, response -> UInt64 in response.id } }, encrypt: false, - onSuccess: { seal.fulfill(()) }, + onSuccess: { fileId in seal.fulfill(fileId) }, onFailure: { seal.reject($0) } ) - return promise - } else { - let (promise, seal) = Promise.pending() - AttachmentUploadJob.upload(stream, using: FileServerAPIV2.upload, encrypt: true, onSuccess: { seal.fulfill(()) }, onFailure: { seal.reject($0) }) return promise } + + let (promise, seal) = Promise.pending() + AttachmentUploadJob.upload( + stream, + using: FileServerAPIV2.upload, + encrypt: true, + onSuccess: { fileId in seal.fulfill(fileId) }, + onFailure: { seal.reject($0) } + ) + return promise } - return when(resolved: attachmentUploadPromises).then(on: DispatchQueue.global(qos: .userInitiated)) { results -> Promise in - let errors = results.compactMap { result -> Swift.Error? in - if case .rejected(let error) = result { return error } else { return nil } + + return when(resolved: attachmentUploadPromises) + .then(on: DispatchQueue.global(qos: .userInitiated)) { results -> Promise in + let errors = results.compactMap { result -> Swift.Error? in + if case .rejected(let error) = result { return error } else { return nil } + } + if let error = errors.first { return Promise(error: error) } + let fileIds: [UInt64] = results.compactMap { result -> UInt64? in + switch result { + case .fulfilled(let fileId): return fileId + default: return nil + } + } + + return sendNonDurably(message, in: thread, with: fileIds, using: transaction) } - if let error = errors.first { return Promise(error: error) } - return sendNonDurably(message, in: thread, using: transaction) - } } - public static func sendNonDurably(_ message: Message, in thread: TSThread, using transaction: YapDatabaseReadWriteTransaction) -> Promise { + public static func sendNonDurably(_ message: Message, in thread: TSThread, with fileIds: [UInt64]? = nil, using transaction: YapDatabaseReadWriteTransaction) -> Promise { message.threadID = thread.uniqueId! - let destination = Message.Destination.from(thread) + let destination = Message.Destination.from(thread, fileIds: fileIds) return MessageSender.send(message, to: destination, using: transaction) } public static func sendNonDurably(_ message: VisibleMessage, with attachments: [SignalAttachment], in thread: TSThread) -> Promise { - Storage.writeSync{ transaction in + Storage.writeSync { transaction in prep(attachments, for: message, using: transaction) } let attachments = message.attachmentIDs.compactMap { TSAttachment.fetch(uniqueId: $0) as? TSAttachmentStream } let attachmentsToUpload = attachments.filter { !$0.isUploaded } - let attachmentUploadPromises: [Promise] = attachmentsToUpload.map { stream in + let attachmentUploadPromises: [Promise] = attachmentsToUpload.map { stream in let storage = SNMessagingKitConfiguration.shared.storage + if let openGroup = storage.getOpenGroup(for: thread.uniqueId!) { - let (promise, seal) = Promise.pending() + let (promise, seal) = Promise.pending() + AttachmentUploadJob.upload( stream, using: { data in - // TODO: Update to non-legacy version - OpenGroupAPI.legacyUpload( - data, - to: openGroup.room, - on: openGroup.server - ) + OpenGroupAPI + .uploadFile( + data.bytes, + to: openGroup.room, + on: openGroup.server + ) + .map { _, response in response.id } }, encrypt: false, - onSuccess: { seal.fulfill(()) }, + onSuccess: { fileId in seal.fulfill(fileId) }, onFailure: { seal.reject($0) } ) return promise - } else { - let (promise, seal) = Promise.pending() - AttachmentUploadJob.upload(stream, using: FileServerAPIV2.upload, encrypt: true, onSuccess: { seal.fulfill(()) }, onFailure: { seal.reject($0) }) - return promise } + + let (promise, seal) = Promise.pending() + AttachmentUploadJob.upload( + stream, + using: FileServerAPIV2.upload, + encrypt: true, + onSuccess: { fileId in seal.fulfill(fileId) }, + onFailure: { seal.reject($0) } + ) + + return promise } let (promise, seal) = Promise.pending() let results = when(resolved: attachmentUploadPromises).wait() @@ -119,13 +148,23 @@ extension MessageSender { if case .rejected(let error) = result { return error } else { return nil } } if let error = errors.first { seal.reject(error) } - Storage.write{ transaction in - sendNonDurably(message, in: thread, using: transaction).done { - seal.fulfill(()) - }.catch { error in - seal.reject(error) + let fileIds: [UInt64] = results.compactMap { result -> UInt64? in + switch result { + case .fulfilled(let fileId): return fileId + default: return nil } } + + Storage.write { transaction in + sendNonDurably(message, in: thread, with: fileIds, using: transaction) + .done { + seal.fulfill(()) + } + .catch { error in + seal.reject(error) + } + } + return promise } } From 81f563229f49c56f1d1b36a56bd2f80587dd9eb6 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 4 Mar 2022 16:17:03 +1100 Subject: [PATCH 026/157] Renamed FileServerAPIV2 to FileServerAPI Updated the direct file upload/download to use the non-base64 approaches as well Updated the attachment 'serverId' value to be a string instead of a UInt64 (future-proofing) Updated the OnionRequest V4 response handling to avoid converting the "response body" part to a string and processing that, instead just slice the byte array (ie. stopped it from being broken from multiple conversions) Removed the base64-based file upload/download endpoints (no use including them when they are inefficient and we don't want to use them) --- Session.xcodeproj/project.pbxproj | 28 ++------- .../Models/FileDownloadResponse.swift | 50 ---------------- .../Models/FileUploadBody.swift | 7 --- .../Models/FileUploadResponse.swift | 22 ++++++- .../Models/LegacyFileDownloadResponse.swift | 33 ----------- .../Models/LegacyFileUploadResponse.swift | 11 ---- ...eServerAPIV2.swift => FileServerAPI.swift} | 34 +++++------ .../File Server/Models/VersionResponse.swift | 2 +- .../File Server/Types/FSEndpoint.swift | 10 ++-- .../Jobs/AttachmentDownloadJob.swift | 58 ++++++++++++------- .../Jobs/AttachmentUploadJob.swift | 15 +++-- SessionMessagingKit/Jobs/MessageSendJob.swift | 2 +- .../Messages/Message+Destination.swift | 4 +- .../Open Groups/Models/FileResponse.swift | 19 ------ .../Models/SendMessageRequest.swift | 9 ++- .../Open Groups/OpenGroupAPI.swift | 12 +--- .../Open Groups/Types/SOGSEndpoint.swift | 10 +--- .../Attachments/TSAttachment.h | 2 +- .../Attachments/TSAttachment.m | 2 +- .../Attachments/TSAttachmentPointer.m | 6 +- .../Utilities/Data+Utilities.swift | 6 +- .../Utilities/Promise+Utilities.swift | 4 +- .../Open Groups/OpenGroupAPISpec.swift | 13 +++-- .../_TestUtilities/TestStorage.swift | 1 + SessionSnodeKit/OnionRequestAPI.swift | 14 ++--- SignalUtilitiesKit/Configuration.swift | 5 +- .../MessageSender+Convenience.swift | 32 +++++----- SignalUtilitiesKit/To Do/OWSProfileManager.m | 8 +-- 28 files changed, 154 insertions(+), 265 deletions(-) delete mode 100644 SessionMessagingKit/Common Networking/Models/FileDownloadResponse.swift delete mode 100644 SessionMessagingKit/Common Networking/Models/FileUploadBody.swift delete mode 100644 SessionMessagingKit/Common Networking/Models/LegacyFileDownloadResponse.swift delete mode 100644 SessionMessagingKit/Common Networking/Models/LegacyFileUploadResponse.swift rename SessionMessagingKit/File Server/{FileServerAPIV2.swift => FileServerAPI.swift} (79%) delete mode 100644 SessionMessagingKit/Open Groups/Models/FileResponse.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index a9142c3b6..11742491b 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -203,7 +203,7 @@ B875885A264503A6000E60D0 /* JoinOpenGroupModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8758859264503A6000E60D0 /* JoinOpenGroupModal.swift */; }; B8783E9E23EB948D00404FB8 /* UILabel+Interaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8783E9D23EB948D00404FB8 /* UILabel+Interaction.swift */; }; B879D449247E1BE300DB3608 /* PathVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B879D448247E1BE300DB3608 /* PathVC.swift */; }; - B87EF17126367CF800124B3C /* FileServerAPIV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = B87EF17026367CF800124B3C /* FileServerAPIV2.swift */; }; + B87EF17126367CF800124B3C /* FileServerAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = B87EF17026367CF800124B3C /* FileServerAPI.swift */; }; B87EF18126377A1D00124B3C /* Features.swift in Sources */ = {isa = PBXBuildFile; fileRef = B87EF18026377A1D00124B3C /* Features.swift */; }; B8856CA8256F0F42001CE70E /* OWSBackupFragment.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB07255A580700E217F9 /* OWSBackupFragment.m */; }; B8856CB1256F0F47001CE70E /* OWSBackupFragment.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDAEA255A580500E217F9 /* OWSBackupFragment.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -827,12 +827,8 @@ FDC4384C27B47F7700C60D73 /* OpenGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384B27B47F7700C60D73 /* OpenGroup.swift */; }; FDC4384F27B4804F00C60D73 /* Header.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384E27B4804F00C60D73 /* Header.swift */; }; FDC4385127B4807400C60D73 /* QueryParam.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4385027B4807400C60D73 /* QueryParam.swift */; }; - FDC4385727B484B700C60D73 /* LegacyFileUploadResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4385627B484B700C60D73 /* LegacyFileUploadResponse.swift */; }; - FDC4385927B484E800C60D73 /* FileUploadBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4385827B484E800C60D73 /* FileUploadBody.swift */; }; - FDC4385B27B485DE00C60D73 /* LegacyFileDownloadResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4385A27B485DE00C60D73 /* LegacyFileDownloadResponse.swift */; }; FDC4385D27B4C18900C60D73 /* Room.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4385C27B4C18900C60D73 /* Room.swift */; }; FDC4385F27B4C4A200C60D73 /* PinnedMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4385E27B4C4A200C60D73 /* PinnedMessage.swift */; }; - FDC4386127B4CDDF00C60D73 /* FileResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4386027B4CDDF00C60D73 /* FileResponse.swift */; }; FDC4386327B4D94E00C60D73 /* SOGSMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4386227B4D94E00C60D73 /* SOGSMessage.swift */; }; FDC4386527B4DE7600C60D73 /* RoomPollInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4386427B4DE7600C60D73 /* RoomPollInfo.swift */; }; FDC4386727B4E10E00C60D73 /* Capabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4386627B4E10E00C60D73 /* Capabilities.swift */; }; @@ -841,7 +837,6 @@ FDC4386C27B4E90300C60D73 /* SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */; }; FDC4387227B5BB3B00C60D73 /* FileUploadResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4387127B5BB3B00C60D73 /* FileUploadResponse.swift */; }; FDC4387427B5BB9B00C60D73 /* Promise+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4387327B5BB9B00C60D73 /* Promise+Utilities.swift */; }; - FDC4387627B5BEF300C60D73 /* FileDownloadResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4387527B5BEF300C60D73 /* FileDownloadResponse.swift */; }; FDC4387827B5C35400C60D73 /* SendMessageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4387727B5C35400C60D73 /* SendMessageRequest.swift */; }; FDC4389227B9FFC700C60D73 /* SessionMessagingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A6F025539DE700C340D1 /* SessionMessagingKit.framework */; platformFilter = ios; }; FDC4389A27BA002500C60D73 /* OpenGroupAPISpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4389927BA002500C60D73 /* OpenGroupAPISpec.swift */; }; @@ -1341,7 +1336,7 @@ B8783E9D23EB948D00404FB8 /* UILabel+Interaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UILabel+Interaction.swift"; sourceTree = ""; }; B879D448247E1BE300DB3608 /* PathVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathVC.swift; sourceTree = ""; }; B879D44A247E1D9200DB3608 /* PathStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathStatusView.swift; sourceTree = ""; }; - B87EF17026367CF800124B3C /* FileServerAPIV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileServerAPIV2.swift; sourceTree = ""; }; + B87EF17026367CF800124B3C /* FileServerAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileServerAPI.swift; sourceTree = ""; }; B87EF18026377A1D00124B3C /* Features.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Features.swift; sourceTree = ""; }; B883F8C5D6D90C5077B2FD14 /* Pods_GlobalDependencies_Session.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GlobalDependencies_Session.framework; sourceTree = BUILT_PRODUCTS_DIR; }; B8856D5F256F129B001CE70E /* OWSAlerts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSAlerts.swift; sourceTree = ""; }; @@ -1985,12 +1980,8 @@ FDC4384B27B47F7700C60D73 /* OpenGroup.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OpenGroup.swift; sourceTree = ""; }; FDC4384E27B4804F00C60D73 /* Header.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Header.swift; sourceTree = ""; }; FDC4385027B4807400C60D73 /* QueryParam.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryParam.swift; sourceTree = ""; }; - FDC4385627B484B700C60D73 /* LegacyFileUploadResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyFileUploadResponse.swift; sourceTree = ""; }; - FDC4385827B484E800C60D73 /* FileUploadBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUploadBody.swift; sourceTree = ""; }; - FDC4385A27B485DE00C60D73 /* LegacyFileDownloadResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyFileDownloadResponse.swift; sourceTree = ""; }; FDC4385C27B4C18900C60D73 /* Room.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Room.swift; sourceTree = ""; }; FDC4385E27B4C4A200C60D73 /* PinnedMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedMessage.swift; sourceTree = ""; }; - FDC4386027B4CDDF00C60D73 /* FileResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileResponse.swift; sourceTree = ""; }; FDC4386227B4D94E00C60D73 /* SOGSMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSMessage.swift; sourceTree = ""; }; FDC4386427B4DE7600C60D73 /* RoomPollInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPollInfo.swift; sourceTree = ""; }; FDC4386627B4E10E00C60D73 /* Capabilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Capabilities.swift; sourceTree = ""; }; @@ -1998,7 +1989,6 @@ FDC4386A27B4E88F00C60D73 /* BatchRequestInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchRequestInfo.swift; sourceTree = ""; }; FDC4387127B5BB3B00C60D73 /* FileUploadResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUploadResponse.swift; sourceTree = ""; }; FDC4387327B5BB9B00C60D73 /* Promise+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Promise+Utilities.swift"; sourceTree = ""; }; - FDC4387527B5BEF300C60D73 /* FileDownloadResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDownloadResponse.swift; sourceTree = ""; }; FDC4387727B5C35400C60D73 /* SendMessageRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageRequest.swift; sourceTree = ""; }; FDC4388E27B9FFC700C60D73 /* SessionMessagingKitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SessionMessagingKitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; FDC4389927BA002500C60D73 /* OpenGroupAPISpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupAPISpec.swift; sourceTree = ""; }; @@ -3423,7 +3413,7 @@ children = ( FDC4383227B385B200C60D73 /* Models */, FD83B9CA27D179AF005E1583 /* Types */, - B87EF17026367CF800124B3C /* FileServerAPIV2.swift */, + B87EF17026367CF800124B3C /* FileServerAPI.swift */, ); path = "File Server"; sourceTree = ""; @@ -3947,7 +3937,6 @@ FDC4385C27B4C18900C60D73 /* Room.swift */, FDC4386427B4DE7600C60D73 /* RoomPollInfo.swift */, FDC4385E27B4C4A200C60D73 /* PinnedMessage.swift */, - FDC4386027B4CDDF00C60D73 /* FileResponse.swift */, FDC4387727B5C35400C60D73 /* SendMessageRequest.swift */, FDC438CA27BB7DB100C60D73 /* UpdateMessageRequest.swift */, FDC4386227B4D94E00C60D73 /* SOGSMessage.swift */, @@ -4006,11 +3995,7 @@ FDC4385527B484AE00C60D73 /* Models */ = { isa = PBXGroup; children = ( - FDC4385827B484E800C60D73 /* FileUploadBody.swift */, FDC4387127B5BB3B00C60D73 /* FileUploadResponse.swift */, - FDC4387527B5BEF300C60D73 /* FileDownloadResponse.swift */, - FDC4385627B484B700C60D73 /* LegacyFileUploadResponse.swift */, - FDC4385A27B485DE00C60D73 /* LegacyFileDownloadResponse.swift */, ); path = Models; sourceTree = ""; @@ -5333,7 +5318,6 @@ C3C2A7842553AAF300C340D1 /* SNProto.swift in Sources */, C32C5DC0256DD743003C73A2 /* Poller.swift in Sources */, C3C2A7682553A3D900C340D1 /* VisibleMessage+Contact.swift in Sources */, - FDC4386127B4CDDF00C60D73 /* FileResponse.swift in Sources */, C3A3A171256E1D25004D228D /* SSKReachabilityManager.swift in Sources */, C3A71D0B2558989C0043A11F /* MessageWrapper.swift in Sources */, B8F5F60325EDE16F003BF8D4 /* DataExtractionNotification.swift in Sources */, @@ -5354,7 +5338,6 @@ C300A5BD2554B00D00555489 /* ReadReceipt.swift in Sources */, FD83B9CC27D179BC005E1583 /* FSEndpoint.swift in Sources */, C32C5AB5256DBE8F003C73A2 /* TSOutgoingMessage+Conversion.swift in Sources */, - FDC4385927B484E800C60D73 /* FileUploadBody.swift in Sources */, C32C5E5B256DDF45003C73A2 /* OWSStorage.m in Sources */, C32C5E15256DDC78003C73A2 /* SSKPreferences.swift in Sources */, FDC438C727BB6DF000C60D73 /* DirectMessage.swift in Sources */, @@ -5371,7 +5354,6 @@ C32C5EDC256DF501003C73A2 /* YapDatabaseConnection+OWS.m in Sources */, FDC4381527B329CE00C60D73 /* NonceGenerator.swift in Sources */, C3BBE0762554CDA60050F1E3 /* Configuration.swift in Sources */, - FDC4387627B5BEF300C60D73 /* FileDownloadResponse.swift in Sources */, C35D76DB26606304009AA5FB /* ThreadUpdateBatcher.swift in Sources */, FDC4382C27B380E300C60D73 /* LegacyMemberCountResponse.swift in Sources */, B8B32033258B235D0020074B /* Storage+Contacts.swift in Sources */, @@ -5427,7 +5409,7 @@ C32C5FBB256E0206003C73A2 /* OWSBackgroundTask.m in Sources */, B8856CA8256F0F42001CE70E /* OWSBackupFragment.m in Sources */, C32C5C3D256DCBAF003C73A2 /* AppReadiness.m in Sources */, - B87EF17126367CF800124B3C /* FileServerAPIV2.swift in Sources */, + B87EF17126367CF800124B3C /* FileServerAPI.swift in Sources */, C3A3A18A256E2092004D228D /* SignalRecipient.m in Sources */, C3C2A74425539EB700C340D1 /* Message.swift in Sources */, C32C5F11256DF79A003C73A2 /* SSKIncrementingIdFinder.swift in Sources */, @@ -5452,8 +5434,6 @@ C32C5B62256DC333003C73A2 /* OWSDisappearingConfigurationUpdateInfoMessage.m in Sources */, FD83B9C927D0487A005E1583 /* SendDirectMessageResponse.swift in Sources */, C352A2F525574B4700338F3E /* Job.swift in Sources */, - FDC4385727B484B700C60D73 /* LegacyFileUploadResponse.swift in Sources */, - FDC4385B27B485DE00C60D73 /* LegacyFileDownloadResponse.swift in Sources */, C32C5C01256DC9A0003C73A2 /* OWSIdentityManager.m in Sources */, FDC438AA27BB12BB00C60D73 /* UserModeratorRequest.swift in Sources */, C32C59C4256DB41F003C73A2 /* TSContactThread.m in Sources */, diff --git a/SessionMessagingKit/Common Networking/Models/FileDownloadResponse.swift b/SessionMessagingKit/Common Networking/Models/FileDownloadResponse.swift deleted file mode 100644 index 9ca228205..000000000 --- a/SessionMessagingKit/Common Networking/Models/FileDownloadResponse.swift +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -// TODO: Update this (looks like it's getting changed to just be the data, the properties are send through as headers) -public struct FileDownloadResponse: Codable { - enum CodingKeys: String, CodingKey { - case fileName = "filename" - case size - case uploaded - case expires - case base64EncodedData = "result" // TODO: Confirm the name of this value - } - - public let fileName: String - public let size: Int64 - public let uploaded: TimeInterval - public let expires: TimeInterval? - public let data: Data - - public func encode(to encoder: Encoder) throws { - var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) - - try container.encode(fileName, forKey: .fileName) - try container.encode(size, forKey: .size) - try container.encode(uploaded, forKey: .uploaded) - try container.encodeIfPresent(expires, forKey: .expires) - try container.encode(data.base64EncodedString(), forKey: .base64EncodedData) - } -} - -// MARK: - Decoder - -extension FileDownloadResponse { - public init(from decoder: Decoder) throws { - let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) - - let base64EncodedData: String = try container.decode(String.self, forKey: .base64EncodedData) - - guard let data = Data(base64Encoded: base64EncodedData) else { throw HTTP.Error.parsingFailed } - - self = FileDownloadResponse( - fileName: try container.decode(String.self, forKey: .fileName), - size: try container.decode(Int64.self, forKey: .size), - uploaded: try container.decode(TimeInterval.self, forKey: .uploaded), - expires: try? container.decode(TimeInterval.self, forKey: .expires), - data: data - ) - } -} diff --git a/SessionMessagingKit/Common Networking/Models/FileUploadBody.swift b/SessionMessagingKit/Common Networking/Models/FileUploadBody.swift deleted file mode 100644 index f62154b70..000000000 --- a/SessionMessagingKit/Common Networking/Models/FileUploadBody.swift +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -struct FileUploadBody: Codable { - let file: String -} diff --git a/SessionMessagingKit/Common Networking/Models/FileUploadResponse.swift b/SessionMessagingKit/Common Networking/Models/FileUploadResponse.swift index b787b0ceb..48cd04944 100644 --- a/SessionMessagingKit/Common Networking/Models/FileUploadResponse.swift +++ b/SessionMessagingKit/Common Networking/Models/FileUploadResponse.swift @@ -1,5 +1,25 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. public struct FileUploadResponse: Codable { - public let id: UInt64 + public let id: String +} + +// MARK: - Codable + +extension FileUploadResponse { + public init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + // Note: SOGS returns an 'int' value but we want to avoid handling both cases so parse + // that and convert the value to a string so we can be consistent (SOGS is able to handle + // an array of Strings for the `files` param when posting a message just fine) + if let intValue: Int64 = try? container.decode(Int64.self, forKey: .id) { + self = FileUploadResponse(id: "\(intValue)") + return + } + + self = FileUploadResponse( + id: try container.decode(String.self, forKey: .id) + ) + } } diff --git a/SessionMessagingKit/Common Networking/Models/LegacyFileDownloadResponse.swift b/SessionMessagingKit/Common Networking/Models/LegacyFileDownloadResponse.swift deleted file mode 100644 index 7ce1c0da7..000000000 --- a/SessionMessagingKit/Common Networking/Models/LegacyFileDownloadResponse.swift +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -struct LegacyFileDownloadResponse: Codable { - enum CodingKeys: String, CodingKey { - case base64EncodedData = "result" - } - - let data: Data - - public func encode(to encoder: Encoder) throws { - var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) - - try container.encode(data.base64EncodedString(), forKey: .base64EncodedData) - } -} - -// MARK: - Decoder - -extension LegacyFileDownloadResponse { - init(from decoder: Decoder) throws { - let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) - - let base64EncodedData: String = try container.decode(String.self, forKey: .base64EncodedData) - - guard let data = Data(base64Encoded: base64EncodedData) else { throw HTTP.Error.parsingFailed } - - self = LegacyFileDownloadResponse( - data: data - ) - } -} diff --git a/SessionMessagingKit/Common Networking/Models/LegacyFileUploadResponse.swift b/SessionMessagingKit/Common Networking/Models/LegacyFileUploadResponse.swift deleted file mode 100644 index fd22f5799..000000000 --- a/SessionMessagingKit/Common Networking/Models/LegacyFileUploadResponse.swift +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -struct LegacyFileUploadResponse: Codable { - enum CodingKeys: String, CodingKey { - case fileId = "result" - } - - public let fileId: UInt64 -} diff --git a/SessionMessagingKit/File Server/FileServerAPIV2.swift b/SessionMessagingKit/File Server/FileServerAPI.swift similarity index 79% rename from SessionMessagingKit/File Server/FileServerAPIV2.swift rename to SessionMessagingKit/File Server/FileServerAPI.swift index f2bff83ed..012ce514e 100644 --- a/SessionMessagingKit/File Server/FileServerAPIV2.swift +++ b/SessionMessagingKit/File Server/FileServerAPI.swift @@ -1,8 +1,8 @@ import PromiseKit import SessionSnodeKit -@objc(SNFileServerAPIV2) -public final class FileServerAPIV2 : NSObject { +@objc(SNFileServerAPI) +public final class FileServerAPI: NSObject { // MARK: - Settings @@ -19,30 +19,27 @@ public final class FileServerAPIV2 : NSObject { /// possible after proof of work has been calculated and the onion request encryption has happened, which takes several seconds. public static let fileSizeORMultiplier: Double = 2 - // MARK: - Initialization - - private override init() { } - // MARK: - File Storage @objc(upload:) public static func objc_upload(file: Data) -> AnyPromise { - return AnyPromise.from(upload(file).map { String($0) }) + return AnyPromise.from(upload(file).map { String($0.id) }) } - public static func upload(_ file: Data) -> Promise { - let requestBody: FileUploadBody = FileUploadBody(file: file.base64EncodedString()) - + public static func upload(_ file: Data) -> Promise { let request = Request( method: .post, server: server, - endpoint: Endpoint.files, - body: requestBody + endpoint: Endpoint.file, + headers: [ + .contentDisposition: "attachment", + .contentType: "application/octet-stream" + ], + body: Array(file) ) - + return send(request, serverPublicKey: serverPublicKey) - .decoded(as: LegacyFileUploadResponse.self, on: .global(qos: .userInitiated), error: HTTP.Error.parsingFailed) - .map { response in response.fileId } + .decoded(as: FileUploadResponse.self, on: .global(qos: .userInitiated)) } @objc(download:useOldServer:) @@ -55,12 +52,10 @@ public final class FileServerAPIV2 : NSObject { let serverPublicKey: String = (useOldServer ? oldServerPublicKey : serverPublicKey) let request = Request( server: (useOldServer ? oldServer : server), - endpoint: .file(fileId: file) + endpoint: .fileIndividual(fileId: file) ) return send(request, serverPublicKey: serverPublicKey) - .decoded(as: LegacyFileDownloadResponse.self, on: .global(qos: .userInitiated), error: HTTP.Error.parsingFailed) - .map { response in response.data } } public static func getVersion(_ platform: String) -> Promise { @@ -73,7 +68,7 @@ public final class FileServerAPIV2 : NSObject { ) return send(request, serverPublicKey: serverPublicKey) - .decoded(as: VersionResponse.self, on: .global(qos: .userInitiated), error: HTTP.Error.parsingFailed) + .decoded(as: VersionResponse.self, on: .global(qos: .userInitiated)) .map { response in response.version } } @@ -93,7 +88,6 @@ public final class FileServerAPIV2 : NSObject { return Promise(error: error) } - // TODO: Rename file to be 'FileServerAPI' (drop the 'V2') return OnionRequestAPI.sendOnionRequest(urlRequest, to: request.server, with: serverPublicKey) .map2 { _, response in guard let response: Data = response else { throw HTTP.Error.parsingFailed } diff --git a/SessionMessagingKit/File Server/Models/VersionResponse.swift b/SessionMessagingKit/File Server/Models/VersionResponse.swift index f72b4393a..fcb4a934c 100644 --- a/SessionMessagingKit/File Server/Models/VersionResponse.swift +++ b/SessionMessagingKit/File Server/Models/VersionResponse.swift @@ -2,7 +2,7 @@ import Foundation -extension FileServerAPIV2 { +extension FileServerAPI { struct VersionResponse: Codable { enum CodingKeys: String, CodingKey { case version = "version" diff --git a/SessionMessagingKit/File Server/Types/FSEndpoint.swift b/SessionMessagingKit/File Server/Types/FSEndpoint.swift index 06cdea310..54169bccb 100644 --- a/SessionMessagingKit/File Server/Types/FSEndpoint.swift +++ b/SessionMessagingKit/File Server/Types/FSEndpoint.swift @@ -2,16 +2,16 @@ import Foundation -extension FileServerAPIV2 { +extension FileServerAPI { public enum Endpoint: EndpointType { - case files - case file(fileId: UInt64) + case file + case fileIndividual(fileId: UInt64) case sessionVersion var path: String { switch self { - case .files: return "files" - case .file(let fileId): return "files/\(fileId)" + case .file: return "file" + case .fileIndividual(let fileId): return "file/\(fileId)" case .sessionVersion: return "session_version" } } diff --git a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift index 70cf5bfcb..086ea6918 100644 --- a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift @@ -63,60 +63,78 @@ public final class AttachmentDownloadJob : NSObject, Job, NSCoding { // NSObject if let id = id { JobQueue.currentlyExecutingJobs.insert(id) } + guard !isDeferred else { return } + if TSAttachment.fetch(uniqueId: attachmentID) is TSAttachmentStream { // FIXME: It's not clear * how * this happens, but apparently we can get to this point // from time to time with an already downloaded attachment. return handleSuccess() } + guard let pointer = TSAttachment.fetch(uniqueId: attachmentID) as? TSAttachmentPointer else { return handleFailure(error: Error.noAttachment) } + let storage = SNMessagingKitConfiguration.shared.storage storage.write(with: { transaction in storage.setAttachmentState(to: .downloading, for: pointer, associatedWith: self.tsMessageID, using: transaction) }, completion: { }) + let temporaryFilePath = URL(fileURLWithPath: OWSTemporaryDirectoryAccessibleAfterFirstAuth() + UUID().uuidString) let handleFailure: (Swift.Error) -> Void = { error in // Intentionally capture self OWSFileSystem.deleteFile(temporaryFilePath.absoluteString) + if let error = error as? Error, case .noAttachment = error { - storage.write(with: { transaction in + storage.write { transaction in storage.setAttachmentState(to: .failed, for: pointer, associatedWith: self.tsMessageID, using: transaction) - }, completion: { }) + } + self.handlePermanentFailure(error: error) - } else if let error = error as? OnionRequestAPI.Error, case .httpRequestFailedAtDestination(let statusCode, _, _) = error, - statusCode == 400 { + } + else if let error = error as? OnionRequestAPI.Error, case .httpRequestFailedAtDestination(let statusCode, _, _) = error, statusCode == 400 { // Otherwise, the attachment will show a state of downloading forever, // and the message won't be able to be marked as read. - storage.write(with: { transaction in + storage.write { transaction in storage.setAttachmentState(to: .failed, for: pointer, associatedWith: self.tsMessageID, using: transaction) - }, completion: { }) + } + // This usually indicates a file that has expired on the server, so there's no need to retry. self.handlePermanentFailure(error: error) - } else { + } + else { self.handleFailure(error: error) } } + if let tsMessage = TSMessage.fetch(uniqueId: tsMessageID), let openGroup = storage.getOpenGroup(for: tsMessage.uniqueThreadId) { guard let fileAsString = pointer.downloadURL.split(separator: "/").last, let fileId = UInt64(fileAsString) else { return handleFailure(Error.invalidURL) } - // TODO: Upgrade this to use the non-legacy version - OpenGroupAPI.legacyDownload(file, from: openGroup.room, on: openGroup.server).done(on: DispatchQueue.global(qos: .userInitiated)) { data in - self.handleDownloadedAttachment(data: data, temporaryFilePath: temporaryFilePath, pointer: pointer, failureHandler: handleFailure) - }.catch(on: DispatchQueue.global()) { error in - handleFailure(error) - } - } else { + + OpenGroupAPI + .downloadFile(fileId, from: openGroup.room, on: openGroup.server) + .done(on: DispatchQueue.global(qos: .userInitiated)) { [weak self] _, data in + self?.handleDownloadedAttachment(data: data, temporaryFilePath: temporaryFilePath, pointer: pointer, failureHandler: handleFailure) + } + .catch(on: DispatchQueue.global()) { error in + handleFailure(error) + } + } + else { guard let fileAsString = pointer.downloadURL.split(separator: "/").last, let file = UInt64(fileAsString) else { return handleFailure(Error.invalidURL) } - let useOldServer = pointer.downloadURL.contains(FileServerAPIV2.oldServer) - FileServerAPIV2.download(file, useOldServer: useOldServer).done(on: DispatchQueue.global(qos: .userInitiated)) { data in - self.handleDownloadedAttachment(data: data, temporaryFilePath: temporaryFilePath, pointer: pointer, failureHandler: handleFailure) - }.catch(on: DispatchQueue.global()) { error in - handleFailure(error) - } + + let useOldServer = pointer.downloadURL.contains(FileServerAPI.oldServer) + FileServerAPI + .download(file, useOldServer: useOldServer) + .done(on: DispatchQueue.global(qos: .userInitiated)) { [weak self] data in + self?.handleDownloadedAttachment(data: data, temporaryFilePath: temporaryFilePath, pointer: pointer, failureHandler: handleFailure) + } + .catch(on: DispatchQueue.global()) { error in + handleFailure(error) + } } } diff --git a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift index e069bb481..21f33d160 100644 --- a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift @@ -74,7 +74,7 @@ public final class AttachmentUploadJob : NSObject, Job, NSCoding { // NSObject/N stream, using: { data in OpenGroupAPI.uploadFile(data.bytes, to: openGroup.room, on: openGroup.server) - .map { _, response -> UInt64 in response.id } + .map { _, response -> String in response.id } }, encrypt: false, onSuccess: { [weak self] fileId in self?.handleSuccess(fileId) }, @@ -84,7 +84,10 @@ public final class AttachmentUploadJob : NSObject, Job, NSCoding { // NSObject/N else { AttachmentUploadJob.upload( stream, - using: FileServerAPIV2.upload, + using: { data in + FileServerAPI.upload(data) + .map { response -> String in response.id } + }, encrypt: true, onSuccess: { [weak self] fileId in self?.handleSuccess(fileId) }, onFailure: handleFailure @@ -92,7 +95,7 @@ public final class AttachmentUploadJob : NSObject, Job, NSCoding { // NSObject/N } } - public static func upload(_ stream: TSAttachmentStream, using upload: (Data) -> Promise, encrypt: Bool, onSuccess: ((UInt64) -> Void)?, onFailure: ((Swift.Error) -> Void)?) { + public static func upload(_ stream: TSAttachmentStream, using upload: (Data) -> Promise, encrypt: Bool, onSuccess: ((String) -> Void)?, onFailure: ((Swift.Error) -> Void)?) { // Get the attachment guard var data = try? stream.readDataFromFile() else { SNLog("Couldn't read attachment from disk.") @@ -112,7 +115,7 @@ public final class AttachmentUploadJob : NSObject, Job, NSCoding { // NSObject/N } // Check the file size SNLog("File size: \(data.count) bytes.") - if Double(data.count) > Double(FileServerAPIV2.maxFileSize) / FileServerAPIV2.fileSizeORMultiplier { + if Double(data.count) > Double(FileServerAPI.maxFileSize) / FileServerAPI.fileSizeORMultiplier { onFailure?(HTTP.Error.maxFileSizeExceeded) return } @@ -121,7 +124,7 @@ public final class AttachmentUploadJob : NSObject, Job, NSCoding { // NSObject/N stream.isUploaded = false stream.save() upload(data).done(on: DispatchQueue.global(qos: .userInitiated)) { fileId in - let downloadURL = "\(FileServerAPIV2.server)/files/\(fileId)" + let downloadURL = "\(FileServerAPI.server)/files/\(fileId)" stream.serverId = fileId stream.isUploaded = true stream.downloadURL = downloadURL @@ -132,7 +135,7 @@ public final class AttachmentUploadJob : NSObject, Job, NSCoding { // NSObject/N } } - private func handleSuccess(_ fileId: UInt64) { + private func handleSuccess(_ fileId: String) { SNLog("Attachment uploaded successfully.") delegate?.handleJobSucceeded(self) diff --git a/SessionMessagingKit/Jobs/MessageSendJob.swift b/SessionMessagingKit/Jobs/MessageSendJob.swift index e5a877618..f5d144ee2 100644 --- a/SessionMessagingKit/Jobs/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/MessageSendJob.swift @@ -100,7 +100,7 @@ public final class MessageSendJob : NSObject, Job, NSCoding { // NSObject/NSCodi .replacingOccurrences(of: "]", with: "") .split(separator: "|") .map { String($0) } - let fileIds: [UInt64]? = (fileIdStrings.isEmpty ? nil : fileIdStrings.compactMap { UInt64($0) }) + let fileIds: [String]? = (fileIdStrings.isEmpty ? nil : fileIdStrings) destination = .openGroup( roomToken: roomToken, diff --git a/SessionMessagingKit/Messages/Message+Destination.swift b/SessionMessagingKit/Messages/Message+Destination.swift index 34f99ca25..1b4329b41 100644 --- a/SessionMessagingKit/Messages/Message+Destination.swift +++ b/SessionMessagingKit/Messages/Message+Destination.swift @@ -10,11 +10,11 @@ public extension Message { server: String, whisperTo: String? = nil, whisperMods: Bool = false, - fileIds: [UInt64]? = nil + fileIds: [String]? = nil ) case openGroupInbox(server: String, openGroupPublicKey: String, blindedPublicKey: String) - static func from(_ thread: TSThread, fileIds: [UInt64]? = nil) -> Message.Destination { + static func from(_ thread: TSThread, fileIds: [String]? = nil) -> Message.Destination { if let thread = thread as? TSContactThread { if SessionId.Prefix(from: thread.contactSessionID()) == .blinded { guard let server: String = thread.originalOpenGroupServer, let publicKey: String = thread.originalOpenGroupPublicKey else { diff --git a/SessionMessagingKit/Open Groups/Models/FileResponse.swift b/SessionMessagingKit/Open Groups/Models/FileResponse.swift deleted file mode 100644 index d2c723e00..000000000 --- a/SessionMessagingKit/Open Groups/Models/FileResponse.swift +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -extension OpenGroupAPI { - public struct FileResponse: Codable { - enum CodingKeys: String, CodingKey { - case fileName = "filename" - case size - case uploaded - case expires - } - - let fileName: String? - let size: Int64 - let uploaded: TimeInterval - let expires: TimeInterval? - } -} diff --git a/SessionMessagingKit/Open Groups/Models/SendMessageRequest.swift b/SessionMessagingKit/Open Groups/Models/SendMessageRequest.swift index 734f07d2a..98007184a 100644 --- a/SessionMessagingKit/Open Groups/Models/SendMessageRequest.swift +++ b/SessionMessagingKit/Open Groups/Models/SendMessageRequest.swift @@ -40,7 +40,12 @@ extension OpenGroupAPI { /// /// When submitting a message edit this field must contain the IDs of any newly uploaded files that are part of the edit; existing /// attachment IDs may also be included, but are not required - let fileIds: [UInt64]? + /// + /// **Note:** The SOGS API actually expects an array of Int64 (ie. what is returned when uploading a file to SOGS) but + /// when uploading direct to the FileServer we get a string id back. In order to avoid supporting both cases we convert + /// the id returned by SOGS to a string and send those through - luckily SOGS converts the values to ints so supports + /// receipving an array of String values + let fileIds: [String]? // MARK: - Initialization @@ -49,7 +54,7 @@ extension OpenGroupAPI { signature: Data, whisperTo: String? = nil, whisperMods: Bool? = nil, - fileIds: [UInt64]? = nil + fileIds: [String]? = nil ) { self.data = data self.signature = signature diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index 3c08c416b..8ac37bc7f 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -331,7 +331,7 @@ public final class OpenGroupAPI: NSObject { on server: String, whisperTo: String?, whisperMods: Bool, - fileIds: [UInt64]?, + fileIds: [String]?, using dependencies: Dependencies = Dependencies() ) -> Promise<(OnionRequestResponseInfoType, Message)> { guard let signResult: (publicKey: String, signature: Bytes) = sign(plaintext.bytes, for: server, using: dependencies) else { @@ -558,16 +558,6 @@ public final class OpenGroupAPI: NSObject { } } - public static func downloadFileJson(_ fileId: UInt64, from roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, FileDownloadResponse)> { - let request: Request = Request( - server: server, - endpoint: .roomFileIndividualJson(roomToken, fileId) - ) - // TODO: This endpoint is getting rewritten to return just data (properties would come through as headers). - return send(request, using: dependencies) - .decoded(as: FileDownloadResponse.self, on: OpenGroupAPI.workQueue, using: dependencies) - } - // MARK: - Inbox/Outbox (Message Requests) /// Retrieves all of the user's current DMs (up to limit) diff --git a/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift b/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift index e8019e354..d664f682e 100644 --- a/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift +++ b/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift @@ -35,7 +35,6 @@ extension OpenGroupAPI { case roomFile(String) case roomFileIndividual(String, UInt64) - case roomFileIndividualJson(String, UInt64) // Inbox/Outbox (Message Requests) @@ -125,14 +124,7 @@ extension OpenGroupAPI { // Files case .roomFile(let roomToken): return "room/\(roomToken)/file" - case .roomFileIndividual(let roomToken, let fileId): - // Note: The 'fileName' value is ignored by the server and is only used to distinguish - // this from the 'Json' variant - let fileName: String = "" - return "room/\(roomToken)/file/\(fileId)/\(fileName)" - - case .roomFileIndividualJson(let roomToken, let fileId): - return "room/\(roomToken)/file/\(fileId)" + case .roomFileIndividual(let roomToken, let fileId): return "room/\(roomToken)/file/\(fileId)" // Inbox/Outbox (Message Requests) diff --git a/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachment.h b/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachment.h index d8f0d8936..b6334028b 100644 --- a/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachment.h +++ b/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachment.h @@ -23,7 +23,7 @@ typedef NS_ENUM(NSUInteger, TSAttachmentType) { // The attachmentSchemaVersion and serverId properties only apply to // TSAttachmentPointer, which can be distinguished by the isDownloaded // property. -@property (atomic, readwrite) UInt64 serverId; +@property (atomic, readwrite) NSString *serverId; @property (atomic, readwrite, nullable) NSData *encryptionKey; @property (nonatomic, readonly) NSString *contentType; @property (atomic, readwrite) BOOL isDownloaded; diff --git a/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachment.m b/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachment.m index f3722bd80..5f026f272 100644 --- a/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachment.m +++ b/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachment.m @@ -27,7 +27,7 @@ NSUInteger const TSAttachmentSchemaVersion = 4; // This constructor is used for new instances of TSAttachmentPointer, // i.e. undownloaded incoming attachments. -- (instancetype)initWithServerId:(UInt64)serverId +- (instancetype)initWithServerId:(NSString *)serverId encryptionKey:(nullable NSData *)encryptionKey byteCount:(UInt32)byteCount contentType:(NSString *)contentType diff --git a/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentPointer.m b/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentPointer.m index 6a675fa64..c5aa6fcf2 100644 --- a/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentPointer.m +++ b/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentPointer.m @@ -169,10 +169,8 @@ NS_ASSUME_NONNULL_BEGIN // Legacy instances of TSAttachmentPointer apparently used the serverId as their // uniqueId. if (attachmentSchemaVersion < 2 && self.serverId == 0) { - if ([self isDecimalNumberText:self.uniqueId]) { - // For legacy instances, try to parse the serverId from the uniqueId. - self.serverId = (UInt64)[self.uniqueId integerValue]; - } + // For legacy instances, try to parse the serverId from the uniqueId. + self.serverId = self.uniqueId; } } diff --git a/SessionMessagingKit/Utilities/Data+Utilities.swift b/SessionMessagingKit/Utilities/Data+Utilities.swift index 38fb839e0..47a0622ad 100644 --- a/SessionMessagingKit/Utilities/Data+Utilities.swift +++ b/SessionMessagingKit/Utilities/Data+Utilities.swift @@ -9,15 +9,15 @@ extension OpenGroupAPI.Dependencies { } public extension Data { - func decoded(as type: T.Type, customError: Error? = nil, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) throws -> T { + func decoded(as type: T.Type, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) throws -> T { do { let decoder: JSONDecoder = JSONDecoder() decoder.userInfo = [ OpenGroupAPI.Dependencies.userInfoKey: dependencies ] return try decoder.decode(type, from: self) } - catch let error { - throw (customError ?? error) + catch { + throw HTTP.Error.parsingFailed } } } diff --git a/SessionMessagingKit/Utilities/Promise+Utilities.swift b/SessionMessagingKit/Utilities/Promise+Utilities.swift index 40278446a..2bbcb78f5 100644 --- a/SessionMessagingKit/Utilities/Promise+Utilities.swift +++ b/SessionMessagingKit/Utilities/Promise+Utilities.swift @@ -5,9 +5,9 @@ import PromiseKit import SessionSnodeKit extension Promise where T == Data { - func decoded(as type: R.Type, on queue: DispatchQueue? = nil, error: Error? = nil, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) -> Promise { + func decoded(as type: R.Type, on queue: DispatchQueue? = nil, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) -> Promise { self.map(on: queue) { data -> R in - try data.decoded(as: type, customError: error, using: dependencies) + try data.decoded(as: type, using: dependencies) } } } diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift index e846d8b23..d70018b13 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift @@ -148,6 +148,7 @@ class OpenGroupAPISpec: QuickSpec { testStorage = nil response = nil pollResponse = nil + error = nil } // MARK: - Batching & Polling @@ -424,10 +425,10 @@ class OpenGroupAPISpec: QuickSpec { // MARK: - Files context("when uploading files") { - it("doesn't add a fileName header when not provided") { + it("doesn't add a fileName to the content-disposition header when not provided") { class LocalTestApi: TestApi { override class var mockResponse: Data? { - return try! JSONEncoder().encode(FileUploadResponse(id: 1)) + return try! JSONEncoder().encode(FileUploadResponse(id: "1")) } } dependencies = dependencies.with(api: LocalTestApi.self) @@ -448,15 +449,15 @@ class OpenGroupAPISpec: QuickSpec { let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData expect(requestData?.urlString).to(equal("testServer/room/testRoom/file")) expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.headers).to(haveCount(4)) + expect(requestData?.headers).to(haveCount(6)) expect(requestData?.headers[Header.contentDisposition.rawValue]) .toNot(contain("filename")) } - it("adds a fileName header when provided") { + it("adds the fileName to the content-disposition header when provided") { class LocalTestApi: TestApi { override class var mockResponse: Data? { - return try! JSONEncoder().encode(FileUploadResponse(id: 1)) + return try! JSONEncoder().encode(FileUploadResponse(id: "1")) } } dependencies = dependencies.with(api: LocalTestApi.self) @@ -477,7 +478,7 @@ class OpenGroupAPISpec: QuickSpec { let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData expect(requestData?.urlString).to(equal("testServer/room/testRoom/file")) expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.headers).to(haveCount(5)) + expect(requestData?.headers).to(haveCount(6)) expect(requestData?.headers[Header.contentDisposition.rawValue]).to(contain("TestFileName")) } } diff --git a/SessionMessagingKitTests/_TestUtilities/TestStorage.swift b/SessionMessagingKitTests/_TestUtilities/TestStorage.swift index 08a0ebfbc..bae468cd0 100644 --- a/SessionMessagingKitTests/_TestUtilities/TestStorage.swift +++ b/SessionMessagingKitTests/_TestUtilities/TestStorage.swift @@ -80,6 +80,7 @@ class TestStorage: SessionMessagingKitStorageProtocol, Mockable { func getAllPendingJobs(of type: Job.Type) -> [Job] { return [] } func getAttachmentUploadJob(for attachmentID: String) -> AttachmentUploadJob? { return nil } func getMessageSendJob(for messageSendJobID: String) -> MessageSendJob? { return nil } + func getMessageSendJob(for messageSendJobID: String, using transaction: Any) -> MessageSendJob? { return nil } func resumeMessageSendJobIfNeeded(_ messageSendJobID: String) {} func isJobCanceled(_ job: Job) -> Bool { return true } diff --git a/SessionSnodeKit/OnionRequestAPI.swift b/SessionSnodeKit/OnionRequestAPI.swift index 7a37d6e60..1d4128457 100644 --- a/SessionSnodeKit/OnionRequestAPI.swift +++ b/SessionSnodeKit/OnionRequestAPI.swift @@ -644,17 +644,15 @@ public enum OnionRequestAPI: OnionRequestAPIType { let dataString: String = String(responseString.suffix(from: infoStringEndIndex)) let dataStringParts: [String.SubSequence] = dataString.split(separator: ":") - guard dataStringParts.count > 1, let finalDataLength: Int = Int(dataStringParts[0]) else { + guard dataStringParts.count > 1, let finalDataLength: Int = Int(dataStringParts[0]), let suffixData: Data = "e".data(using: .utf8) else { return seal.reject(HTTP.Error.invalidResponse) } - let finalDataStringStartIndex: String.Index = responseString.index(infoStringEndIndex, offsetBy: "\(finalDataLength):".count) - let finalDataStringEndIndex: String.Index = responseString.index(finalDataStringStartIndex, offsetBy: finalDataLength) - let finalDataString: String = String(responseString[finalDataStringStartIndex.. = Array(data) + let dataEndIndex: Int = (dataBytes.count - suffixData.count) + let dataStartIndex: Int = (dataEndIndex - finalDataLength) + let finalDataBytes: ArraySlice = dataBytes[dataStartIndex..] = attachmentsToUpload.map { stream in + let attachmentUploadPromises: [Promise] = attachmentsToUpload.map { stream in let storage = SNMessagingKitConfiguration.shared.storage if let threadId: String = thread.uniqueId, let openGroup = storage.getOpenGroup(for: threadId) { - let (promise, seal) = Promise.pending() + let (promise, seal) = Promise.pending() AttachmentUploadJob.upload( stream, using: { data in @@ -57,7 +57,7 @@ extension MessageSender { to: openGroup.room, on: openGroup.server ) - .map { _, response -> UInt64 in response.id } + .map { _, response -> String in response.id } }, encrypt: false, onSuccess: { fileId in seal.fulfill(fileId) }, @@ -67,10 +67,13 @@ extension MessageSender { return promise } - let (promise, seal) = Promise.pending() + let (promise, seal) = Promise.pending() AttachmentUploadJob.upload( stream, - using: FileServerAPIV2.upload, + using: { data in + FileServerAPI.upload(data) + .map { response -> String in response.id } + }, encrypt: true, onSuccess: { fileId in seal.fulfill(fileId) }, onFailure: { seal.reject($0) } @@ -84,7 +87,7 @@ extension MessageSender { if case .rejected(let error) = result { return error } else { return nil } } if let error = errors.first { return Promise(error: error) } - let fileIds: [UInt64] = results.compactMap { result -> UInt64? in + let fileIds: [String] = results.compactMap { result -> String? in switch result { case .fulfilled(let fileId): return fileId default: return nil @@ -95,7 +98,7 @@ extension MessageSender { } } - public static func sendNonDurably(_ message: Message, in thread: TSThread, with fileIds: [UInt64]? = nil, using transaction: YapDatabaseReadWriteTransaction) -> Promise { + public static func sendNonDurably(_ message: Message, in thread: TSThread, with fileIds: [String]? = nil, using transaction: YapDatabaseReadWriteTransaction) -> Promise { message.threadID = thread.uniqueId! let destination = Message.Destination.from(thread, fileIds: fileIds) return MessageSender.send(message, to: destination, using: transaction) @@ -107,11 +110,11 @@ extension MessageSender { } let attachments = message.attachmentIDs.compactMap { TSAttachment.fetch(uniqueId: $0) as? TSAttachmentStream } let attachmentsToUpload = attachments.filter { !$0.isUploaded } - let attachmentUploadPromises: [Promise] = attachmentsToUpload.map { stream in + let attachmentUploadPromises: [Promise] = attachmentsToUpload.map { stream in let storage = SNMessagingKitConfiguration.shared.storage if let openGroup = storage.getOpenGroup(for: thread.uniqueId!) { - let (promise, seal) = Promise.pending() + let (promise, seal) = Promise.pending() AttachmentUploadJob.upload( stream, @@ -122,7 +125,7 @@ extension MessageSender { to: openGroup.room, on: openGroup.server ) - .map { _, response in response.id } + .map { _, response -> String in response.id } }, encrypt: false, onSuccess: { fileId in seal.fulfill(fileId) }, @@ -131,10 +134,13 @@ extension MessageSender { return promise } - let (promise, seal) = Promise.pending() + let (promise, seal) = Promise.pending() AttachmentUploadJob.upload( stream, - using: FileServerAPIV2.upload, + using: { data in + FileServerAPI.upload(data) + .map { response -> String in response.id } + }, encrypt: true, onSuccess: { fileId in seal.fulfill(fileId) }, onFailure: { seal.reject($0) } @@ -148,7 +154,7 @@ extension MessageSender { if case .rejected(let error) = result { return error } else { return nil } } if let error = errors.first { seal.reject(error) } - let fileIds: [UInt64] = results.compactMap { result -> UInt64? in + let fileIds: [String] = results.compactMap { result -> String? in switch result { case .fulfilled(let fileId): return fileId default: return nil diff --git a/SignalUtilitiesKit/To Do/OWSProfileManager.m b/SignalUtilitiesKit/To Do/OWSProfileManager.m index 729049c36..ad28e1ef2 100644 --- a/SignalUtilitiesKit/To Do/OWSProfileManager.m +++ b/SignalUtilitiesKit/To Do/OWSProfileManager.m @@ -268,10 +268,10 @@ typedef void (^ProfileManagerFailureBlock)(NSError *error); NSData *encryptedAvatarData = [self encryptProfileData:avatarData profileKey:newProfileKey]; OWSAssertDebug(encryptedAvatarData.length > 0); - AnyPromise *promise = [SNFileServerAPIV2 upload:encryptedAvatarData]; + AnyPromise *promise = [SNFileServerAPI upload:encryptedAvatarData]; [promise.thenOn(dispatch_get_main_queue(), ^(NSString *fileID) { - NSString *downloadURL = [NSString stringWithFormat:@"%@/files/%@", SNFileServerAPIV2.server, fileID]; + NSString *downloadURL = [NSString stringWithFormat:@"%@/files/%@", SNFileServerAPI.server, fileID]; [NSUserDefaults.standardUserDefaults setObject:[NSDate new] forKey:@"lastProfilePictureUpload"]; SNContact *user = [LKStorage.shared getUser]; @@ -502,8 +502,8 @@ typedef void (^ProfileManagerFailureBlock)(NSError *error); NSString *profilePictureURL = contact.profilePictureURL; NSString *file = [profilePictureURL lastPathComponent]; - BOOL useOldServer = [profilePictureURL containsString:SNFileServerAPIV2.oldServer]; - AnyPromise *promise = [SNFileServerAPIV2 download:file useOldServer:useOldServer]; + BOOL useOldServer = [profilePictureURL containsString:SNFileServerAPI.oldServer]; + AnyPromise *promise = [SNFileServerAPI download:file useOldServer:useOldServer]; [promise.then(^(NSData *data) { @synchronized(self.currentAvatarDownloads) From f9468219d95d6327ded540a7953c16023895ebbd Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 4 Mar 2022 18:02:38 +1100 Subject: [PATCH 027/157] Code cleanup and database transaction tweaks Updated the OpenGroupManager to be a bit more thread safe Updated the OpenGroupManager "isModOrAdmin" check to better support the various keys of the current user Fixed some blinding code to use an existing transaction rather than create it's own ones Removed the Legacy API calls, handling and types --- Session.xcodeproj/project.pbxproj | 48 -- .../Database/Storage+Contacts.swift | 4 +- .../Messages/Signal/TSIncomingMessage.h | 2 +- .../Models/LegacyAuthTokenResponse.swift | 46 -- .../Models/LegacyCompactPollBody.swift | 27 - .../Models/LegacyCompactPollResponse.swift | 25 - .../LegacyDeletedMessagesResponse.swift | 13 - .../Open Groups/Models/LegacyDeletion.swift | 23 - .../Models/LegacyGetInfoResponse.swift | 9 - .../Models/LegacyMemberCountResponse.swift | 13 - .../Models/LegacyModeratorsResponse.swift | 9 - .../Models/LegacyOpenGroupMessageV2.swift | 71 -- .../Models/LegacyPublicKeyBody.swift | 13 - .../Open Groups/Models/LegacyRoomInfo.swift | 17 - .../Models/LegacyRoomsResponse.swift | 9 - .../Open Groups/OpenGroupAPI.swift | 637 ------------------ .../Open Groups/OpenGroupManager.swift | 102 ++- .../Open Groups/Types/SOGSEndpoint.swift | 84 --- .../MessageReceiver+Handling.swift | 2 +- .../Pollers/OpenGroupPoller.swift | 66 -- SessionMessagingKit/Storage.swift | 2 +- .../Utilities/ContactUtilities.swift | 35 +- .../_TestUtilities/TestStorage.swift | 2 +- 23 files changed, 94 insertions(+), 1165 deletions(-) delete mode 100644 SessionMessagingKit/Open Groups/Models/LegacyAuthTokenResponse.swift delete mode 100644 SessionMessagingKit/Open Groups/Models/LegacyCompactPollBody.swift delete mode 100644 SessionMessagingKit/Open Groups/Models/LegacyCompactPollResponse.swift delete mode 100644 SessionMessagingKit/Open Groups/Models/LegacyDeletedMessagesResponse.swift delete mode 100644 SessionMessagingKit/Open Groups/Models/LegacyDeletion.swift delete mode 100644 SessionMessagingKit/Open Groups/Models/LegacyGetInfoResponse.swift delete mode 100644 SessionMessagingKit/Open Groups/Models/LegacyMemberCountResponse.swift delete mode 100644 SessionMessagingKit/Open Groups/Models/LegacyModeratorsResponse.swift delete mode 100644 SessionMessagingKit/Open Groups/Models/LegacyOpenGroupMessageV2.swift delete mode 100644 SessionMessagingKit/Open Groups/Models/LegacyPublicKeyBody.swift delete mode 100644 SessionMessagingKit/Open Groups/Models/LegacyRoomInfo.swift delete mode 100644 SessionMessagingKit/Open Groups/Models/LegacyRoomsResponse.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 11742491b..95257a5c4 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -807,23 +807,11 @@ FDC4380927B31D4E00C60D73 /* SOGSError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4380827B31D4E00C60D73 /* SOGSError.swift */; }; FDC4381527B329CE00C60D73 /* NonceGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381427B329CE00C60D73 /* NonceGenerator.swift */; }; FDC4381727B32EC700C60D73 /* Personalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381627B32EC700C60D73 /* Personalization.swift */; }; - FDC4381A27B34EBA00C60D73 /* LegacyCompactPollBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381927B34EBA00C60D73 /* LegacyCompactPollBody.swift */; }; - FDC4381C27B354AC00C60D73 /* LegacyPublicKeyBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381B27B354AC00C60D73 /* LegacyPublicKeyBody.swift */; }; FDC4382027B36ADC00C60D73 /* SOGSEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381F27B36ADC00C60D73 /* SOGSEndpoint.swift */; }; - FDC4382627B37F6900C60D73 /* LegacyDeletedMessagesResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382527B37F6900C60D73 /* LegacyDeletedMessagesResponse.swift */; }; - FDC4382827B37FD300C60D73 /* LegacyModeratorsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382727B37FD300C60D73 /* LegacyModeratorsResponse.swift */; }; - FDC4382A27B3802D00C60D73 /* LegacyRoomsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382927B3802D00C60D73 /* LegacyRoomsResponse.swift */; }; - FDC4382C27B380E300C60D73 /* LegacyMemberCountResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382B27B380E300C60D73 /* LegacyMemberCountResponse.swift */; }; FDC4382F27B383AF00C60D73 /* UnregisterResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382E27B383AF00C60D73 /* UnregisterResponse.swift */; }; FDC4383127B3841C00C60D73 /* RegisterResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4383027B3841C00C60D73 /* RegisterResponse.swift */; }; FDC4383827B3863200C60D73 /* VersionResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4383727B3863200C60D73 /* VersionResponse.swift */; }; - FDC4383A27B4696200C60D73 /* LegacyAuthTokenResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4383927B4696200C60D73 /* LegacyAuthTokenResponse.swift */; }; FDC4383E27B4708600C60D73 /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4383D27B4708600C60D73 /* Atomic.swift */; }; - FDC4384027B4746D00C60D73 /* LegacyGetInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4383F27B4746D00C60D73 /* LegacyGetInfoResponse.swift */; }; - FDC4384727B47F4D00C60D73 /* LegacyOpenGroupMessageV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384327B47F4D00C60D73 /* LegacyOpenGroupMessageV2.swift */; }; - FDC4384827B47F4D00C60D73 /* LegacyRoomInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384427B47F4D00C60D73 /* LegacyRoomInfo.swift */; }; - FDC4384927B47F4D00C60D73 /* LegacyCompactPollResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384527B47F4D00C60D73 /* LegacyCompactPollResponse.swift */; }; - FDC4384A27B47F4D00C60D73 /* LegacyDeletion.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384627B47F4D00C60D73 /* LegacyDeletion.swift */; }; FDC4384C27B47F7700C60D73 /* OpenGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384B27B47F7700C60D73 /* OpenGroup.swift */; }; FDC4384F27B4804F00C60D73 /* Header.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384E27B4804F00C60D73 /* Header.swift */; }; FDC4385127B4807400C60D73 /* QueryParam.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4385027B4807400C60D73 /* QueryParam.swift */; }; @@ -1960,23 +1948,11 @@ FDC4380827B31D4E00C60D73 /* SOGSError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSError.swift; sourceTree = ""; }; FDC4381427B329CE00C60D73 /* NonceGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonceGenerator.swift; sourceTree = ""; }; FDC4381627B32EC700C60D73 /* Personalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Personalization.swift; sourceTree = ""; }; - FDC4381927B34EBA00C60D73 /* LegacyCompactPollBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyCompactPollBody.swift; sourceTree = ""; }; - FDC4381B27B354AC00C60D73 /* LegacyPublicKeyBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyPublicKeyBody.swift; sourceTree = ""; }; FDC4381F27B36ADC00C60D73 /* SOGSEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSEndpoint.swift; sourceTree = ""; }; - FDC4382527B37F6900C60D73 /* LegacyDeletedMessagesResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyDeletedMessagesResponse.swift; sourceTree = ""; }; - FDC4382727B37FD300C60D73 /* LegacyModeratorsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyModeratorsResponse.swift; sourceTree = ""; }; - FDC4382927B3802D00C60D73 /* LegacyRoomsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyRoomsResponse.swift; sourceTree = ""; }; - FDC4382B27B380E300C60D73 /* LegacyMemberCountResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyMemberCountResponse.swift; sourceTree = ""; }; FDC4382E27B383AF00C60D73 /* UnregisterResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnregisterResponse.swift; sourceTree = ""; }; FDC4383027B3841C00C60D73 /* RegisterResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterResponse.swift; sourceTree = ""; }; FDC4383727B3863200C60D73 /* VersionResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionResponse.swift; sourceTree = ""; }; - FDC4383927B4696200C60D73 /* LegacyAuthTokenResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyAuthTokenResponse.swift; sourceTree = ""; }; FDC4383D27B4708600C60D73 /* Atomic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = ""; }; - FDC4383F27B4746D00C60D73 /* LegacyGetInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyGetInfoResponse.swift; sourceTree = ""; }; - FDC4384327B47F4D00C60D73 /* LegacyOpenGroupMessageV2.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LegacyOpenGroupMessageV2.swift; sourceTree = ""; }; - FDC4384427B47F4D00C60D73 /* LegacyRoomInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LegacyRoomInfo.swift; sourceTree = ""; }; - FDC4384527B47F4D00C60D73 /* LegacyCompactPollResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LegacyCompactPollResponse.swift; sourceTree = ""; }; - FDC4384627B47F4D00C60D73 /* LegacyDeletion.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LegacyDeletion.swift; sourceTree = ""; }; FDC4384B27B47F7700C60D73 /* OpenGroup.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OpenGroup.swift; sourceTree = ""; }; FDC4384E27B4804F00C60D73 /* Header.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Header.swift; sourceTree = ""; }; FDC4385027B4807400C60D73 /* QueryParam.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryParam.swift; sourceTree = ""; }; @@ -3948,18 +3924,6 @@ FDC438A927BB12BB00C60D73 /* UserModeratorRequest.swift */, FDC438AB27BB145200C60D73 /* UserDeleteMessagesRequest.swift */, FDC438AD27BB148700C60D73 /* UserDeleteMessagesResponse.swift */, - FDC4381B27B354AC00C60D73 /* LegacyPublicKeyBody.swift */, - FDC4383927B4696200C60D73 /* LegacyAuthTokenResponse.swift */, - FDC4381927B34EBA00C60D73 /* LegacyCompactPollBody.swift */, - FDC4384527B47F4D00C60D73 /* LegacyCompactPollResponse.swift */, - FDC4383F27B4746D00C60D73 /* LegacyGetInfoResponse.swift */, - FDC4382927B3802D00C60D73 /* LegacyRoomsResponse.swift */, - FDC4384427B47F4D00C60D73 /* LegacyRoomInfo.swift */, - FDC4382B27B380E300C60D73 /* LegacyMemberCountResponse.swift */, - FDC4382727B37FD300C60D73 /* LegacyModeratorsResponse.swift */, - FDC4382527B37F6900C60D73 /* LegacyDeletedMessagesResponse.swift */, - FDC4384627B47F4D00C60D73 /* LegacyDeletion.swift */, - FDC4384327B47F4D00C60D73 /* LegacyOpenGroupMessageV2.swift */, ); path = Models; sourceTree = ""; @@ -5256,7 +5220,6 @@ buildActionMask = 2147483647; files = ( B8856D08256F10F1001CE70E /* DeviceSleepManager.swift in Sources */, - FDC4382A27B3802D00C60D73 /* LegacyRoomsResponse.swift in Sources */, C3471F4C25553AB000297E91 /* MessageReceiver+Decryption.swift in Sources */, C300A5D32554B05A00555489 /* TypingIndicator.swift in Sources */, C3A3A156256E1B91004D228D /* ProtoUtils.m in Sources */, @@ -5273,7 +5236,6 @@ FDC4385F27B4C4A200C60D73 /* PinnedMessage.swift in Sources */, C32A026325A801AA000ED5D4 /* NSData+messagePadding.m in Sources */, FD859EF227BF6BA200510D0C /* Data+Utilities.swift in Sources */, - FDC4384927B47F4D00C60D73 /* LegacyCompactPollResponse.swift in Sources */, C352A3932557883D00338F3E /* JobDelegate.swift in Sources */, C32C5B84256DC54F003C73A2 /* SSKEnvironment.m in Sources */, FDC4386527B4DE7600C60D73 /* RoomPollInfo.swift in Sources */, @@ -5283,7 +5245,6 @@ C3D9E3BF25676AD70040E4F3 /* TSAttachmentStream.m in Sources */, C3C2A7562553A3AB00C340D1 /* VisibleMessage+Quote.swift in Sources */, B8B32021258B1A650020074B /* Contact.swift in Sources */, - FDC4384027B4746D00C60D73 /* LegacyGetInfoResponse.swift in Sources */, C32C5C89256DD0D2003C73A2 /* Storage+Jobs.swift in Sources */, C300A5FC2554B0A000555489 /* MessageReceiver.swift in Sources */, FDC438AC27BB145200C60D73 /* UserDeleteMessagesRequest.swift in Sources */, @@ -5298,7 +5259,6 @@ FDC4386727B4E10E00C60D73 /* Capabilities.swift in Sources */, C32C59C1256DB41F003C73A2 /* TSGroupThread.m in Sources */, C3A3A08F256E1728004D228D /* FullTextSearchFinder.swift in Sources */, - FDC4381A27B34EBA00C60D73 /* LegacyCompactPollBody.swift in Sources */, B8856D1A256F114D001CE70E /* ProximityMonitoringManager.swift in Sources */, C32C5B9F256DC739003C73A2 /* OWSBlockingManager.m in Sources */, C3D9E52725677DF20040E4F3 /* OWSThumbnailService.swift in Sources */, @@ -5323,13 +5283,10 @@ B8F5F60325EDE16F003BF8D4 /* DataExtractionNotification.swift in Sources */, C32C5D24256DD4C0003C73A2 /* MentionsManager.swift in Sources */, C3A71D1E25589AC30043A11F /* WebSocketProto.swift in Sources */, - FDC4384A27B47F4D00C60D73 /* LegacyDeletion.swift in Sources */, C3C2A7852553AAF300C340D1 /* SessionProtos.pb.swift in Sources */, B8566C63256F55930045A0B9 /* OWSLinkPreview+Conversion.swift in Sources */, - FDC4382827B37FD300C60D73 /* LegacyModeratorsResponse.swift in Sources */, C32C5B3F256DC1DF003C73A2 /* TSQuotedMessage+Conversion.swift in Sources */, B8EB20EE2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift in Sources */, - FDC4381C27B354AC00C60D73 /* LegacyPublicKeyBody.swift in Sources */, FDC4382F27B383AF00C60D73 /* UnregisterResponse.swift in Sources */, FDC4386327B4D94E00C60D73 /* SOGSMessage.swift in Sources */, C3C2A7712553A41E00C340D1 /* ControlMessage.swift in Sources */, @@ -5344,7 +5301,6 @@ C32C5D9C256DD6DC003C73A2 /* OWSOutgoingReceiptManager.m in Sources */, B8AE760B25ABFB5A001A84D2 /* GeneralUtilities.m in Sources */, C32C5C4F256DCC36003C73A2 /* Storage+OpenGroups.swift in Sources */, - FDC4384727B47F4D00C60D73 /* LegacyOpenGroupMessageV2.swift in Sources */, FDC4384F27B4804F00C60D73 /* Header.swift in Sources */, C3DA9C0725AE7396008F7C7E /* ConfigurationMessage.swift in Sources */, B8856CEE256F1054001CE70E /* OWSAudioPlayer.m in Sources */, @@ -5355,7 +5311,6 @@ FDC4381527B329CE00C60D73 /* NonceGenerator.swift in Sources */, C3BBE0762554CDA60050F1E3 /* Configuration.swift in Sources */, C35D76DB26606304009AA5FB /* ThreadUpdateBatcher.swift in Sources */, - FDC4382C27B380E300C60D73 /* LegacyMemberCountResponse.swift in Sources */, B8B32033258B235D0020074B /* Storage+Contacts.swift in Sources */, B8856D69256F141F001CE70E /* OWSWindowManager.m in Sources */, FD83B9AA27CF149D005E1583 /* ContactUtilities.swift in Sources */, @@ -5376,7 +5331,6 @@ C32C5BEF256DC8EE003C73A2 /* OWSDisappearingMessagesJob.m in Sources */, C34A977425A3E34A00852C71 /* ClosedGroupControlMessage.swift in Sources */, FDC4384C27B47F7700C60D73 /* OpenGroup.swift in Sources */, - FDC4382627B37F6900C60D73 /* LegacyDeletedMessagesResponse.swift in Sources */, B88FA7B826045D100049422F /* OpenGroupAPI.swift in Sources */, C32C5E97256DE0CB003C73A2 /* OWSPrimaryStorage.m in Sources */, FDC438C927BB706500C60D73 /* SendDirectMessageRequest.swift in Sources */, @@ -5385,7 +5339,6 @@ B8856E94256F1C37001CE70E /* OWSSounds.m in Sources */, C32C5BCC256DC830003C73A2 /* Storage+ClosedGroups.swift in Sources */, C3A3A0EC256E1949004D228D /* OWSRecipientIdentity.m in Sources */, - FDC4383A27B4696200C60D73 /* LegacyAuthTokenResponse.swift in Sources */, B8F5F56525EC8453003BF8D4 /* Notification+Contacts.swift in Sources */, C32C5AB2256DBE8F003C73A2 /* TSMessage.m in Sources */, C3A3A0FE256E1A3C004D228D /* TSDatabaseSecondaryIndexes.m in Sources */, @@ -5397,7 +5350,6 @@ B88A1AC725C90A4700E6D421 /* TypingIndicatorInteraction.swift in Sources */, C3D9E3C025676AD70040E4F3 /* TSAttachment.m in Sources */, FD83B9CE27D17A04005E1583 /* Request.swift in Sources */, - FDC4384827B47F4D00C60D73 /* LegacyRoomInfo.swift in Sources */, C32C598A256D0664003C73A2 /* SNProtoEnvelope+Conversion.swift in Sources */, FDC438CB27BB7DB100C60D73 /* UpdateMessageRequest.swift in Sources */, C352A2FF25574B6300338F3E /* MessageSendJob.swift in Sources */, diff --git a/SessionMessagingKit/Database/Storage+Contacts.swift b/SessionMessagingKit/Database/Storage+Contacts.swift index 285285069..a6b1680f6 100644 --- a/SessionMessagingKit/Database/Storage+Contacts.swift +++ b/SessionMessagingKit/Database/Storage+Contacts.swift @@ -97,11 +97,11 @@ extension Storage { public func enumerateBlindedIdMapping(with block: @escaping (BlindedIdMapping, UnsafeMutablePointer) -> ()) { Storage.read { transaction in - self.enumerateBlindedIdMapping(with: block, transaction: transaction) + self.enumerateBlindedIdMapping(using: transaction, with: block) } } - public func enumerateBlindedIdMapping(with block: @escaping (BlindedIdMapping, UnsafeMutablePointer) -> (), transaction: YapDatabaseReadTransaction) { + public func enumerateBlindedIdMapping(using transaction: YapDatabaseReadTransaction, with block: @escaping (BlindedIdMapping, UnsafeMutablePointer) -> ()) { transaction.enumerateRows(inCollection: Storage.blindedIdCacheCollection) { _, object, _, stop in guard let mapping = object as? BlindedIdMapping else { return } diff --git a/SessionMessagingKit/Messages/Signal/TSIncomingMessage.h b/SessionMessagingKit/Messages/Signal/TSIncomingMessage.h index b4a0edb0e..c965ff51b 100644 --- a/SessionMessagingKit/Messages/Signal/TSIncomingMessage.h +++ b/SessionMessagingKit/Messages/Signal/TSIncomingMessage.h @@ -85,7 +85,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, readonly) NSString *authorId; // convenience method for expiring a message which was just read -- (void)markAsReadNowWithSendReadReceipt:(BOOL)sendReadReceipt +- (void)markAsReadNowWithTrySendReadReceipt:(BOOL)trySendReadReceipt transaction:(YapDatabaseReadWriteTransaction *)transaction; - (void)setNotificationIdentifier:(NSString * _Nullable)notificationIdentifier diff --git a/SessionMessagingKit/Open Groups/Models/LegacyAuthTokenResponse.swift b/SessionMessagingKit/Open Groups/Models/LegacyAuthTokenResponse.swift deleted file mode 100644 index d15f21cc8..000000000 --- a/SessionMessagingKit/Open Groups/Models/LegacyAuthTokenResponse.swift +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -extension OpenGroupAPI { - struct LegacyAuthTokenResponse: Codable { - struct Challenge: Codable { - enum CodingKeys: String, CodingKey { - case ciphertext = "ciphertext" - case ephemeralPublicKey = "ephemeral_public_key" - } - - let ciphertext: Data - let ephemeralPublicKey: Data - } - - let challenge: Challenge - } -} - -// MARK: - Codable - -extension OpenGroupAPI.LegacyAuthTokenResponse.Challenge { - init(from decoder: Decoder) throws { - let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) - - let base64EncodedCiphertext: String = try container.decode(String.self, forKey: .ciphertext) - let base64EncodedEphemeralPublicKey: String = try container.decode(String.self, forKey: .ephemeralPublicKey) - - guard let ciphertext = Data(base64Encoded: base64EncodedCiphertext), let ephemeralPublicKey = Data(base64Encoded: base64EncodedEphemeralPublicKey) else { - throw HTTP.Error.parsingFailed - } - - self = OpenGroupAPI.LegacyAuthTokenResponse.Challenge( - ciphertext: ciphertext, - ephemeralPublicKey: ephemeralPublicKey - ) - } - - public func encode(to encoder: Encoder) throws { - var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) - - try container.encode(ciphertext.base64EncodedString(), forKey: .ciphertext) - try container.encode(ephemeralPublicKey.base64EncodedString(), forKey: .ephemeralPublicKey) - } -} diff --git a/SessionMessagingKit/Open Groups/Models/LegacyCompactPollBody.swift b/SessionMessagingKit/Open Groups/Models/LegacyCompactPollBody.swift deleted file mode 100644 index 70036c1e2..000000000 --- a/SessionMessagingKit/Open Groups/Models/LegacyCompactPollBody.swift +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -extension OpenGroupAPI { - struct LegacyCompactPollBody: Codable { - struct Room: Codable { - enum CodingKeys: String, CodingKey { - case id = "room_id" - case fromMessageServerId = "from_message_server_id" - case fromDeletionServerId = "from_deletion_server_id" - - // TODO: Remove this legacy value - case legacyAuthToken = "auth_token" - } - - let id: String - let fromMessageServerId: Int64? - let fromDeletionServerId: Int64? - - // TODO: This is a legacy value - let legacyAuthToken: String? - } - - let requests: [Room] - } -} diff --git a/SessionMessagingKit/Open Groups/Models/LegacyCompactPollResponse.swift b/SessionMessagingKit/Open Groups/Models/LegacyCompactPollResponse.swift deleted file mode 100644 index f699b3206..000000000 --- a/SessionMessagingKit/Open Groups/Models/LegacyCompactPollResponse.swift +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -extension OpenGroupAPI { - public struct LegacyCompactPollResponse: Codable { - public struct Result: Codable { - enum CodingKeys: String, CodingKey { - case room = "room_id" - case statusCode = "status_code" - case messages - case deletions - case moderators - } - - public let room: String - public let statusCode: UInt - public let messages: [LegacyOpenGroupMessageV2]? - public let deletions: [LegacyDeletion]? - public let moderators: [String]? - } - - public let results: [Result] - } -} diff --git a/SessionMessagingKit/Open Groups/Models/LegacyDeletedMessagesResponse.swift b/SessionMessagingKit/Open Groups/Models/LegacyDeletedMessagesResponse.swift deleted file mode 100644 index f063cc08c..000000000 --- a/SessionMessagingKit/Open Groups/Models/LegacyDeletedMessagesResponse.swift +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -extension OpenGroupAPI { - struct LegacyDeletedMessagesResponse: Codable { - enum CodingKeys: String, CodingKey { - case deletions = "ids" - } - - let deletions: [LegacyDeletion] - } -} diff --git a/SessionMessagingKit/Open Groups/Models/LegacyDeletion.swift b/SessionMessagingKit/Open Groups/Models/LegacyDeletion.swift deleted file mode 100644 index 03fc1ae0a..000000000 --- a/SessionMessagingKit/Open Groups/Models/LegacyDeletion.swift +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -extension OpenGroupAPI { - public struct LegacyDeletion: Codable { - enum CodingKeys: String, CodingKey { - case id - case deletedMessageID = "deleted_message_id" - } - - let id: Int64 - let deletedMessageID: Int64 - - public static func from(_ json: JSON) -> LegacyDeletion? { - guard let id = json["id"] as? Int64, let deletedMessageID = json["deleted_message_id"] as? Int64 else { - return nil - } - - return LegacyDeletion(id: id, deletedMessageID: deletedMessageID) - } - } -} diff --git a/SessionMessagingKit/Open Groups/Models/LegacyGetInfoResponse.swift b/SessionMessagingKit/Open Groups/Models/LegacyGetInfoResponse.swift deleted file mode 100644 index b3e4317f3..000000000 --- a/SessionMessagingKit/Open Groups/Models/LegacyGetInfoResponse.swift +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -extension OpenGroupAPI { - struct LegacyGetInfoResponse: Codable { - let room: LegacyRoomInfo - } -} diff --git a/SessionMessagingKit/Open Groups/Models/LegacyMemberCountResponse.swift b/SessionMessagingKit/Open Groups/Models/LegacyMemberCountResponse.swift deleted file mode 100644 index 1f7c13cfb..000000000 --- a/SessionMessagingKit/Open Groups/Models/LegacyMemberCountResponse.swift +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -extension OpenGroupAPI { - struct LegacyMemberCountResponse: Codable { - enum CodingKeys: String, CodingKey { - case memberCount = "member_count" - } - - let memberCount: UInt64 - } -} diff --git a/SessionMessagingKit/Open Groups/Models/LegacyModeratorsResponse.swift b/SessionMessagingKit/Open Groups/Models/LegacyModeratorsResponse.swift deleted file mode 100644 index 4846a5faf..000000000 --- a/SessionMessagingKit/Open Groups/Models/LegacyModeratorsResponse.swift +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -extension OpenGroupAPI { - struct LegacyModeratorsResponse: Codable { - let moderators: [String] - } -} diff --git a/SessionMessagingKit/Open Groups/Models/LegacyOpenGroupMessageV2.swift b/SessionMessagingKit/Open Groups/Models/LegacyOpenGroupMessageV2.swift deleted file mode 100644 index 17b0d5565..000000000 --- a/SessionMessagingKit/Open Groups/Models/LegacyOpenGroupMessageV2.swift +++ /dev/null @@ -1,71 +0,0 @@ -import Foundation -import SessionUtilitiesKit - -public struct LegacyOpenGroupMessageV2: Codable { - enum CodingKeys: String, CodingKey { - case serverID = "server_id" - case sender = "public_key" - case sentTimestamp = "timestamp" - case base64EncodedData = "data" - case base64EncodedSignature = "signature" - } - - public let serverID: Int64? - public let sender: String? - public let sentTimestamp: UInt64 - /// The serialized protobuf in base64 encoding. - public let base64EncodedData: String - /// When sending a message, the sender signs the serialized protobuf with their private key so that - /// a receiving user can verify that the message wasn't tampered with. - public let base64EncodedSignature: String? - - public func sign(with publicKey: String) -> LegacyOpenGroupMessageV2? { - guard let userKeyPair = SNMessagingKitConfiguration.shared.storage.getUserKeyPair() else { return nil } - guard let data = Data(base64Encoded: base64EncodedData) else { return nil } - guard let signature = try? Ed25519.sign(data, with: userKeyPair) else { - SNLog("Failed to sign open group message.") - return nil - } - - return LegacyOpenGroupMessageV2( - serverID: serverID, - sender: sender, - sentTimestamp: sentTimestamp, - base64EncodedData: base64EncodedData, - base64EncodedSignature: signature.base64EncodedString() - ) - } -} - -// MARK: - Decoder - -extension LegacyOpenGroupMessageV2 { - public init(from decoder: Decoder) throws { - let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) - - let sender: String = try container.decode(String.self, forKey: .sender) - let base64EncodedData: String = try container.decode(String.self, forKey: .base64EncodedData) - let base64EncodedSignature: String = try container.decode(String.self, forKey: .base64EncodedSignature) - - // Validate the message signature - guard let data = Data(base64Encoded: base64EncodedData), let signature = Data(base64Encoded: base64EncodedSignature) else { - throw HTTP.Error.parsingFailed - } - - let publicKey = Data(hex: sender.removingIdPrefixIfNeeded()) - let isValid = (try? Ed25519.verifySignature(signature, publicKey: publicKey, data: data)) ?? false - - guard isValid else { - SNLog("Ignoring message with invalid signature.") - throw HTTP.Error.parsingFailed - } - - self = LegacyOpenGroupMessageV2( - serverID: try? container.decode(Int64.self, forKey: .serverID), - sender: sender, - sentTimestamp: try container.decode(UInt64.self, forKey: .sentTimestamp), - base64EncodedData: base64EncodedData, - base64EncodedSignature: base64EncodedSignature - ) - } -} diff --git a/SessionMessagingKit/Open Groups/Models/LegacyPublicKeyBody.swift b/SessionMessagingKit/Open Groups/Models/LegacyPublicKeyBody.swift deleted file mode 100644 index 572bdbdbf..000000000 --- a/SessionMessagingKit/Open Groups/Models/LegacyPublicKeyBody.swift +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -extension OpenGroupAPI { - struct LegacyPublicKeyBody: Codable { - enum CodingKeys: String, CodingKey { - case publicKey = "public_key" - } - - let publicKey: String - } -} diff --git a/SessionMessagingKit/Open Groups/Models/LegacyRoomInfo.swift b/SessionMessagingKit/Open Groups/Models/LegacyRoomInfo.swift deleted file mode 100644 index bccd9ea93..000000000 --- a/SessionMessagingKit/Open Groups/Models/LegacyRoomInfo.swift +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -extension OpenGroupAPI { - public struct LegacyRoomInfo: Codable { - enum CodingKeys: String, CodingKey { - case id - case name - case imageID = "image_id" - } - - public let id: String - public let name: String - public let imageID: String? - } -} diff --git a/SessionMessagingKit/Open Groups/Models/LegacyRoomsResponse.swift b/SessionMessagingKit/Open Groups/Models/LegacyRoomsResponse.swift deleted file mode 100644 index 162e629fc..000000000 --- a/SessionMessagingKit/Open Groups/Models/LegacyRoomsResponse.swift +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -extension OpenGroupAPI { - struct LegacyRoomsResponse: Codable { - let rooms: [LegacyRoomInfo] - } -} diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index 8ac37bc7f..7ac3f7e94 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -1036,7 +1036,6 @@ public final class OpenGroupAPI: NSObject { return Promise(error: Error.signingFailed) } - // TODO: 'removeAuthToken' as a migration??? (would previously do this when getting a `401`) return dependencies.api.sendOnionRequest(signedRequest, to: request.server, with: publicKey) } @@ -1045,640 +1044,4 @@ public final class OpenGroupAPI: NSObject { preconditionFailure("It's currently not allowed to send non onion routed requests.") } - - // MARK: - - // MARK: - - // MARK: - Legacy Requests (To be removed) - // TODO: Remove the legacy requests (should be unused once we release - just here for testing) - - public static var legacyDefaultRoomsPromise: Promise<[LegacyRoomInfo]>? - - // MARK: -- Legacy Auth - - @available(*, deprecated, message: "Use request signing instead") - private static func legacyGetAuthToken(for room: String, on server: String) -> Promise { - let storage = SNMessagingKitConfiguration.shared.storage - - if let authToken: String = storage.getAuthToken(for: room, on: server) { - return Promise.value(authToken) - } - - if let authTokenPromise: Promise = legacyAuthTokenPromises.wrappedValue["\(server).\(room)"] { - return authTokenPromise - } - - let promise: Promise = legacyRequestNewAuthToken(for: room, on: server) - .then(on: OpenGroupAPI.workQueue) { legacyClaimAuthToken($0, for: room, on: server) } - .then(on: OpenGroupAPI.workQueue) { authToken -> Promise in - let (promise, seal) = Promise.pending() - storage.write(with: { transaction in - storage.setAuthToken(for: room, on: server, to: authToken, using: transaction) - }, completion: { - seal.fulfill(authToken) - }) - return promise - } - - promise - .done(on: OpenGroupAPI.workQueue) { _ in - legacyAuthTokenPromises.wrappedValue["\(server).\(room)"] = nil - } - .catch(on: OpenGroupAPI.workQueue) { _ in - legacyAuthTokenPromises.wrappedValue["\(server).\(room)"] = nil - } - - legacyAuthTokenPromises.wrappedValue["\(server).\(room)"] = promise - return promise - } - - @available(*, deprecated, message: "Use request signing instead") - public static func legacyRequestNewAuthToken(for room: String, on server: String) -> Promise { - SNLog("Requesting auth token for server: \(server).") - guard let userKeyPair: ECKeyPair = SNMessagingKitConfiguration.shared.storage.getUserKeyPair() else { - return Promise(error: Error.generic) - } - - let request: Request = Request( - server: server, - room: room, - endpoint: .legacyAuthTokenChallenge(legacyAuth: true), - queryParameters: [ - .publicKey: getUserHexEncodedPublicKey() - ], - isAuthRequired: false - ) - - return legacySend(request).map(on: OpenGroupAPI.workQueue) { _, maybeData in - guard let data: Data = maybeData else { throw Error.parsingFailed } - let response = try data.decoded(as: LegacyAuthTokenResponse.self, customError: Error.parsingFailed) - let symmetricKey = try AESGCM.generateSymmetricKey(x25519PublicKey: response.challenge.ephemeralPublicKey, x25519PrivateKey: userKeyPair.privateKey) - - guard let tokenAsData = try? AESGCM.decrypt(response.challenge.ciphertext, with: symmetricKey) else { - throw Error.decryptionFailed - } - - return tokenAsData.toHexString() - } - } - - @available(*, deprecated, message: "Use request signing instead") - public static func legacyClaimAuthToken(_ authToken: String, for room: String, on server: String) -> Promise { - let requestBody: LegacyPublicKeyBody = LegacyPublicKeyBody(publicKey: getUserHexEncodedPublicKey()) - - guard let body: Data = try? JSONEncoder().encode(requestBody) else { - return Promise(error: HTTP.Error.invalidJSON) - } - - let request: Request = Request( - method: .post, - server: server, - room: room, - endpoint: .legacyAuthTokenClaim(legacyAuth: true), - headers: [ - // Set explicitly here because is isn't in the database yet at this point - .authorization: authToken - ], - body: body, - isAuthRequired: false - ) - - return legacySend(request).map(on: OpenGroupAPI.workQueue) { _ in authToken } - } - - /// Should be called when leaving a group. - @available(*, deprecated, message: "Use request signing instead") - public static func legacyDeleteAuthToken(for room: String, on server: String) -> Promise { - let request: Request = Request( - method: .delete, - server: server, - room: room, - endpoint: .legacyAuthToken(legacyAuth: true) - ) - - return legacySend(request).map(on: OpenGroupAPI.workQueue) { _ in - let storage = SNMessagingKitConfiguration.shared.storage - - storage.write { transaction in - storage.removeAuthToken(for: room, on: server, using: transaction) - } - } - } - - // MARK: -- Legacy Requests - - @available(*, deprecated, message: "Use poll or batch instead") - public static func legacyCompactPoll(_ server: String) -> Promise { - let storage: SessionMessagingKitStorageProtocol = SNMessagingKitConfiguration.shared.storage - let rooms: [String] = storage.getAllOpenGroups().values - .filter { $0.server == server } - .map { $0.room } - var getAuthTokenPromises: [String: Promise] = [:] - let useMessageLimit = (hasPerformedInitialPoll[server] != true && timeSinceLastOpen > OpenGroupAPI.Poller.maxInactivityPeriod) - - hasPerformedInitialPoll[server] = true - - if !legacyHasUpdatedLastOpenDate { - UserDefaults.standard[.lastOpen] = Date() - legacyHasUpdatedLastOpenDate = true - } - - for room in rooms { - getAuthTokenPromises[room] = legacyGetAuthToken(for: room, on: server) - } - - let requestBody: LegacyCompactPollBody = LegacyCompactPollBody( - requests: rooms - .map { roomId -> LegacyCompactPollBody.Room in - LegacyCompactPollBody.Room( - id: roomId, - fromMessageServerId: (useMessageLimit ? nil : - storage.getLastMessageServerID(for: roomId, on: server) - ), - fromDeletionServerId: (useMessageLimit ? nil : - storage.getLastDeletionServerID(for: roomId, on: server) - ), - legacyAuthToken: nil - ) - } - ) - - return when(fulfilled: [Promise](getAuthTokenPromises.values)) - .then(on: OpenGroupAPI.workQueue) { _ -> Promise in - let requestBodyWithAuthTokens: LegacyCompactPollBody = LegacyCompactPollBody( - requests: requestBody.requests.compactMap { oldRoom -> LegacyCompactPollBody.Room? in - guard let authToken: String = getAuthTokenPromises[oldRoom.id]?.value else { return nil } - - return LegacyCompactPollBody.Room( - id: oldRoom.id, - fromMessageServerId: oldRoom.fromMessageServerId, - fromDeletionServerId: oldRoom.fromDeletionServerId, - legacyAuthToken: authToken - ) - } - ) - - guard let body: Data = try? JSONEncoder().encode(requestBodyWithAuthTokens) else { - return Promise(error: HTTP.Error.invalidJSON) - } - - let request = Request( - method: .post, - server: server, - endpoint: .legacyCompactPoll(legacyAuth: true), - body: body, - isAuthRequired: false - ) - - return legacySend(request) - .then(on: OpenGroupAPI.workQueue) { _, maybeData -> Promise in - guard let data: Data = maybeData else { throw Error.parsingFailed } - let response: LegacyCompactPollResponse = try data.decoded(as: LegacyCompactPollResponse.self, customError: Error.parsingFailed) - - return when( - fulfilled: response.results - .compactMap { (result: LegacyCompactPollResponse.Result) -> Promise<[LegacyDeletion]>? in - // A 401 means that we didn't provide a (valid) auth token for a route that - // required one. We use this as an indication that the token we're using has - // expired. Note that a 403 has a different meaning; it means that we provided - // a valid token but it doesn't have a high enough permission level for the - // route in question. - guard result.statusCode != 401 else { - storage.writeSync { transaction in - storage.removeAuthToken(for: result.room, on: server, using: transaction) - } - - return nil - } - - return legacyProcess(messages: result.messages, for: result.room, on: server) - .then(on: OpenGroupAPI.workQueue) { _ -> Promise<[LegacyDeletion]> in - legacyProcess(deletions: result.deletions, for: result.room, on: server) - } - } - ).then(on: OpenGroupAPI.workQueue) { _ in Promise.value(response) } - } - } - } - - @available(*, deprecated, message: "Use getDefaultRoomsIfNeeded instead") - public static func legacyGetDefaultRoomsIfNeeded() { - Storage.shared.write( - with: { transaction in - Storage.shared.setOpenGroupPublicKey(for: defaultServer, to: defaultServerPublicKey, using: transaction) - }, - completion: { - let promise = attempt(maxRetryCount: 8, recoveringOn: DispatchQueue.main) { - OpenGroupAPI.legacyGetAllRooms(from: defaultServer) - } - _ = promise.done(on: OpenGroupAPI.workQueue) { items in - items.forEach { legacyGetGroupImage(for: $0.id, on: defaultServer).retainUntilComplete() } - } - promise.catch(on: OpenGroupAPI.workQueue) { _ in - OpenGroupAPI.legacyDefaultRoomsPromise = nil - } - legacyDefaultRoomsPromise = promise - } - ) - } - - @available(*, deprecated, message: "Use rooms(for:) instead") - public static func legacyGetAllRooms(from server: String) -> Promise<[LegacyRoomInfo]> { - let request: Request = Request( - server: server, - endpoint: .legacyRooms, - isAuthRequired: false - ) - - return legacySend(request) - .map(on: OpenGroupAPI.workQueue) { _, maybeData in - guard let data: Data = maybeData else { throw Error.parsingFailed } - let response: LegacyRoomsResponse = try data.decoded(as: LegacyRoomsResponse.self, customError: Error.parsingFailed) - - return response.rooms - } - } - - @available(*, deprecated, message: "Use room(for:on:) instead") - public static func legacyGetRoomInfo(for room: String, on server: String) -> Promise { - let request: Request = Request( - server: server, - room: room, - endpoint: .legacyRoomInfo(room), - isAuthRequired: false - ) - - return legacySend(request) - .map(on: OpenGroupAPI.workQueue) { _, maybeData in - guard let data: Data = maybeData else { throw Error.parsingFailed } - let response: LegacyGetInfoResponse = try data.decoded(as: LegacyGetInfoResponse.self, customError: Error.parsingFailed) - - return response.room - } - } - - @available(*, deprecated, message: "Use roomImage(_:for:on:) instead") - public static func legacyGetGroupImage(for room: String, on server: String) -> Promise { - // Normally the image for a given group is stored with the group thread, so it's only - // fetched once. However, on the join open group screen we show images for groups the - // user * hasn't * joined yet. We don't want to re-fetch these images every time the - // user opens the app because that could slow the app down or be data-intensive. So - // instead we assume that these images don't change that often and just fetch them once - // a week. We also assume that they're all fetched at the same time as well, so that - // we only need to maintain one date in user defaults. On top of all of this we also - // don't double up on fetch requests by storing the existing request as a promise if - // there is one. - let lastOpenGroupImageUpdate: Date? = UserDefaults.standard[.lastOpenGroupImageUpdate] - let now: Date = Date() - let timeSinceLastUpdate: TimeInterval = (given(lastOpenGroupImageUpdate) { now.timeIntervalSince($0) } ?? .greatestFiniteMagnitude) - let updateInterval: TimeInterval = (7 * 24 * 60 * 60) - - if let data = Storage.shared.getOpenGroupImage(for: room, on: server), server == defaultServer, timeSinceLastUpdate < updateInterval { - return Promise.value(data) - } - - if let promise = legacyGroupImagePromises["\(server).\(room)"] { - return promise - } - - let request: Request = Request( - server: server, - room: room, - endpoint: .legacyRoomImage(room), - isAuthRequired: false - ) - - let promise: Promise = legacySend(request).map(on: OpenGroupAPI.workQueue) { _, maybeData in - guard let data: Data = maybeData else { throw Error.parsingFailed } - let response: LegacyFileDownloadResponse = try data.decoded(as: LegacyFileDownloadResponse.self, customError: Error.parsingFailed) - - if server == defaultServer { - Storage.shared.write { transaction in - Storage.shared.setOpenGroupImage(to: response.data, for: room, on: server, using: transaction) - } - UserDefaults.standard[.lastOpenGroupImageUpdate] = now - } - - return response.data - } - legacyGroupImagePromises["\(server).\(room)"] = promise - - return promise - } - - @available(*, deprecated, message: "Use room(for:on:) instead") - public static func legacyGetMemberCount(for room: String, on server: String) -> Promise { - let request: Request = Request( - server: server, - room: room, - endpoint: .legacyMemberCount(legacyAuth: true) - ) - - return legacySend(request) - .map(on: OpenGroupAPI.workQueue) { _, maybeData in - guard let data: Data = maybeData else { throw Error.parsingFailed } - let response: LegacyMemberCountResponse = try data.decoded(as: LegacyMemberCountResponse.self, customError: Error.parsingFailed) - - let storage = SNMessagingKitConfiguration.shared.storage - storage.write { transaction in - storage.setUserCount(to: response.memberCount, forOpenGroupWithID: "\(server).\(room)", using: transaction) - } - - return response.memberCount - } - } - - // MARK: - Legacy File Storage - - @available(*, deprecated, message: "Use uploadFile(_:fileName:to:on:) instead") - public static func legacyUpload(_ file: Data, to room: String, on server: String) -> Promise { - let requestBody: FileUploadBody = FileUploadBody(file: file.base64EncodedString()) - - guard let body: Data = try? JSONEncoder().encode(requestBody) else { - return Promise(error: HTTP.Error.invalidJSON) - } - - let request = Request(method: .post, server: server, room: room, endpoint: .legacyFiles, body: body) - - return legacySend(request).map(on: OpenGroupAPI.workQueue) { _, maybeData in - guard let data: Data = maybeData else { throw Error.parsingFailed } - let response: LegacyFileUploadResponse = try data.decoded(as: LegacyFileUploadResponse.self, customError: Error.parsingFailed) - - return response.fileId - } - } - - @available(*, deprecated, message: "Use downloadFile(_:from:on:) instead") - public static func legacyDownload(_ file: UInt64, from room: String, on server: String) -> Promise { - let request = Request(server: server, room: room, endpoint: .legacyFile(file)) - - return legacySend(request).map(on: OpenGroupAPI.workQueue) { _, maybeData in - guard let data: Data = maybeData else { throw Error.parsingFailed } - let response: LegacyFileDownloadResponse = try data.decoded(as: LegacyFileDownloadResponse.self, customError: Error.parsingFailed) - - return response.data - } - } - - // MARK: - Legacy Message Sending & Receiving - - @available(*, deprecated, message: "Use send(_:to:on:whisperTo:whisperMods:with:) instead") - public static func legacySend(_ message: LegacyOpenGroupMessageV2, to room: String, on server: String, with publicKey: String) -> Promise { - guard let signedMessage = message.sign(with: publicKey) else { return Promise(error: Error.signingFailed) } - guard let body: Data = try? JSONEncoder().encode(signedMessage) else { - return Promise(error: Error.parsingFailed) - } - let request = Request(method: .post, server: server, room: room, endpoint: .legacyMessages, body: body) - - return legacySend(request).map(on: OpenGroupAPI.workQueue) { _, maybeData in - guard let data: Data = maybeData else { throw Error.parsingFailed } - let message: LegacyOpenGroupMessageV2 = try data.decoded(as: LegacyOpenGroupMessageV2.self, customError: Error.parsingFailed) - Storage.shared.write { transaction in - Storage.shared.addReceivedMessageTimestamp(message.sentTimestamp, using: transaction) - } - return message - } - } - - @available(*, deprecated, message: "Use recentMessages(in:on:) or messagesSince(seqNo:in:on:) instead") - public static func legacyGetMessages(for room: String, on server: String) -> Promise<[LegacyOpenGroupMessageV2]> { - let storage = SNMessagingKitConfiguration.shared.storage - let request: Request = Request( - server: server, - room: room, - endpoint: .legacyMessages, - queryParameters: [ - .fromServerId: storage.getLastMessageServerID(for: room, on: server).map { String($0) } - ].compactMapValues { $0 } - ) - - return legacySend(request).then(on: OpenGroupAPI.workQueue) { _, maybeData -> Promise<[LegacyOpenGroupMessageV2]> in - guard let data: Data = maybeData else { throw Error.parsingFailed } - let messages: [LegacyOpenGroupMessageV2] = try data.decoded(as: [LegacyOpenGroupMessageV2].self, customError: Error.parsingFailed) - - return legacyProcess(messages: messages, for: room, on: server) - } - } - - // MARK: - Legacy Message Deletion - - // TODO: No delete method????. - @available(*, deprecated, message: "Use v4 endpoint instead") - public static func legacyDeleteMessage(with serverID: Int64, from room: String, on server: String) -> Promise { - let request: Request = Request( - method: .delete, - server: server, - room: room, - endpoint: .legacyMessagesForServer(serverID) - ) - - return legacySend(request).map(on: OpenGroupAPI.workQueue) { _ in } - } - - @available(*, deprecated, message: "Use v4 endpoint instead") - public static func legacyGetDeletedMessages(for room: String, on server: String) -> Promise<[LegacyDeletion]> { - let storage = SNMessagingKitConfiguration.shared.storage - - let request: Request = Request( - server: server, - room: room, - endpoint: .legacyDeletedMessages, - queryParameters: [ - .fromServerId: storage.getLastDeletionServerID(for: room, on: server).map { String($0) } - ].compactMapValues { $0 } - ) - - return legacySend(request).then(on: OpenGroupAPI.workQueue) { _, maybeData -> Promise<[LegacyDeletion]> in - guard let data: Data = maybeData else { throw Error.parsingFailed } - let response: LegacyDeletedMessagesResponse = try data.decoded(as: LegacyDeletedMessagesResponse.self, customError: Error.parsingFailed) - - return legacyProcess(deletions: response.deletions, for: room, on: server) - } - } - - // MARK: - Legacy Moderation - - @available(*, deprecated, message: "Use v4 endpoint instead") - public static func legacyGetModerators(for room: String, on server: String) -> Promise<[String]> { - let request: Request = Request( - server: server, - room: room, - endpoint: .legacyModerators - ) - - return legacySend(request) - .map(on: OpenGroupAPI.workQueue) { _, maybeData in - guard let data: Data = maybeData else { throw Error.parsingFailed } - let response: LegacyModeratorsResponse = try data.decoded(as: LegacyModeratorsResponse.self, customError: Error.parsingFailed) - - if var x = self.moderators[server] { - x[room] = Set(response.moderators) - self.moderators[server] = x - } - else { - self.moderators[server] = [room: Set(response.moderators)] - } - - return response.moderators - } - } - - @available(*, deprecated, message: "Use v4 endpoint instead") - public static func legacyBan(_ publicKey: String, from room: String, on server: String) -> Promise { - let requestBody: LegacyPublicKeyBody = LegacyPublicKeyBody(publicKey: getUserHexEncodedPublicKey()) - - guard let body: Data = try? JSONEncoder().encode(requestBody) else { - return Promise(error: HTTP.Error.invalidJSON) - } - - let request: Request = Request( - method: .post, - server: server, - room: room, - endpoint: .legacyBlockList, - body: body - ) - - return legacySend(request).map(on: OpenGroupAPI.workQueue) { _ in } - } - - @available(*, deprecated, message: "Use v4 endpoint instead") - public static func legacyBanAndDeleteAllMessages(_ publicKey: String, from room: String, on server: String) -> Promise { - let requestBody: LegacyPublicKeyBody = LegacyPublicKeyBody(publicKey: getUserHexEncodedPublicKey()) - - guard let body: Data = try? JSONEncoder().encode(requestBody) else { - return Promise(error: HTTP.Error.invalidJSON) - } - - let request: Request = Request( - method: .post, - server: server, - room: room, - endpoint: .legacyBanAndDeleteAll, - body: body - ) - - return legacySend(request).map(on: OpenGroupAPI.workQueue) { _ in } - } - - @available(*, deprecated, message: "Use v4 endpoint instead") - public static func legacyUnban(_ publicKey: String, from room: String, on server: String) -> Promise { - let request: Request = Request( - method: .delete, - server: server, - room: room, - endpoint: .legacyBlockListIndividual(publicKey) - ) - - return legacySend(request).map(on: OpenGroupAPI.workQueue) { _ in } - } - - // MARK: - Processing - // TODO: Move these methods to the OpenGroupManager? (seems odd for them to be in the API) - - @available(*, deprecated, message: "Use v4 endpoint instead") - private static func legacyProcess(messages: [LegacyOpenGroupMessageV2]?, for room: String, on server: String) -> Promise<[LegacyOpenGroupMessageV2]> { - guard let messages: [LegacyOpenGroupMessageV2] = messages, !messages.isEmpty else { return Promise.value([]) } - - let storage = SNMessagingKitConfiguration.shared.storage - let serverID: Int64 = (messages.compactMap { $0.serverID }.max() ?? 0) - let lastMessageServerID: Int64 = (storage.getLastMessageServerID(for: room, on: server) ?? 0) - - if serverID > lastMessageServerID { - let (promise, seal) = Promise<[LegacyOpenGroupMessageV2]>.pending() - - storage.write( - with: { transaction in - storage.setLastMessageServerID(for: room, on: server, to: serverID, using: transaction) - }, - completion: { - seal.fulfill(messages) - } - ) - - return promise - } - - return Promise.value(messages) - } - - @available(*, deprecated, message: "Use v4 endpoint instead") - private static func legacyProcess(deletions: [LegacyDeletion]?, for room: String, on server: String) -> Promise<[LegacyDeletion]> { - guard let deletions: [LegacyDeletion] = deletions else { return Promise.value([]) } - - let storage = SNMessagingKitConfiguration.shared.storage - let serverID: Int64 = (deletions.compactMap { $0.id }.max() ?? 0) - let lastDeletionServerID: Int64 = (storage.getLastDeletionServerID(for: room, on: server) ?? 0) - - if serverID > lastDeletionServerID { - let (promise, seal) = Promise<[LegacyDeletion]>.pending() - - storage.write( - with: { transaction in - storage.setLastDeletionServerID(for: room, on: server, to: serverID, using: transaction) - }, - completion: { - seal.fulfill(deletions) - } - ) - - return promise - } - - return Promise.value(deletions) - } - - // MARK: - Legacy Convenience - - @available(*, deprecated, message: "Use v4 endpoint instead") - private static func legacySend(_ request: Request, through api: OnionRequestAPIType.Type = OnionRequestAPI.self) -> Promise<(OnionRequestResponseInfoType, Data?)> { - guard let url: URL = request.url else { return Promise(error: Error.invalidURL) } - - var urlRequest: URLRequest = URLRequest(url: url) - urlRequest.httpMethod = request.method.rawValue - urlRequest.allHTTPHeaderFields = request.headers - .setting(.room, request.room) // TODO: Is this needed anymore? Add at the request level?. - .toHTTPHeaders() - urlRequest.httpBody = request.body - - if request.useOnionRouting { - guard let publicKey = SNMessagingKitConfiguration.shared.storage.getOpenGroupPublicKey(for: request.server) else { - return Promise(error: Error.noPublicKey) - } - - if request.isAuthRequired { - // Because legacy auth happens on a per-room basis, we need to have a room to - // make an authenticated request - guard let room = request.room else { - return api.sendOnionRequest(urlRequest, to: request.server, using: .v3, with: publicKey) - } - - return legacyGetAuthToken(for: room, on: request.server) - .then(on: OpenGroupAPI.workQueue) { authToken -> Promise<(OnionRequestResponseInfoType, Data?)> in - urlRequest.setValue(authToken, forHTTPHeaderField: Header.authorization.rawValue) - - let promise = api.sendOnionRequest(urlRequest, to: request.server, using: .v3, with: publicKey) - promise.catch(on: OpenGroupAPI.workQueue) { error in - // A 401 means that we didn't provide a (valid) auth token for a route - // that required one. We use this as an indication that the token we're - // using has expired. Note that a 403 has a different meaning; it means - // that we provided a valid token but it doesn't have a high enough - // permission level for the route in question. - if case OnionRequestAPI.Error.httpRequestFailedAtDestination(let statusCode, _, _) = error, statusCode == 401 { - let storage = SNMessagingKitConfiguration.shared.storage - - storage.writeSync { transaction in - storage.removeAuthToken(for: room, on: request.server, using: transaction) - } - } - } - - return promise - } - } - - return api.sendOnionRequest(urlRequest, to: request.server, using: .v3, with: publicKey) - } - - preconditionFailure("It's currently not allowed to send non onion routed requests.") - } } diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index d23ae1edd..c128a6bc9 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -14,8 +14,12 @@ public final class OpenGroupManager: NSObject { public static var defaultRoomsPromise: Promise<[OpenGroupAPI.Room]>? private static var groupImagePromises: [String: Promise] = [:] - private static var moderators: [String: [String: Set]] = [:] // Server URL to room ID to set of moderator IDs - private static var admins: [String: [String: Set]] = [:] // Server URL to room ID to set of admin IDs + + /// Server URL to room ID to set of moderator IDs + private static var moderators: Atomic<[String: [String: Set]]> = Atomic([:]) + + /// Server URL to room ID to set of admin IDs + private static var admins: Atomic<[String: [String: Set]]> = Atomic([:]) // MARK: - Polling @@ -243,14 +247,16 @@ public final class OpenGroupManager: NSObject { // - Moderators if let moderators: [String] = (pollInfo.details?.moderators ?? maybeUpdatedModel?.groupModeratorIds) { - OpenGroupManager.moderators[server] = (OpenGroupManager.moderators[server] ?? [:]) - .setting(roomToken, Set(moderators)) + OpenGroupManager.moderators.mutate { + $0[server] = ($0[server] ?? [:]).setting(roomToken, Set(moderators)) + } } // - Admins if let admins: [String] = (pollInfo.details?.admins ?? maybeUpdatedModel?.groupAdminIds) { - OpenGroupManager.admins[server] = (OpenGroupManager.admins[server] ?? [:]) - .setting(roomToken, Set(admins)) + OpenGroupManager.admins.mutate { + $0[server] = ($0[server] ?? [:]).setting(roomToken, Set(admins)) + } } // - Room image (if there is one) @@ -357,7 +363,7 @@ public final class OpenGroupManager: NSObject { let sortedMessages: [OpenGroupAPI.DirectMessage] = messages .sorted { lhs, rhs in lhs.id < rhs.id } let latestMessageId: Int64 = (sortedMessages.last?.id ?? 0) - var mappingCache: [String: BlindedIdMapping] = [:] + var mappingCache: [String: BlindedIdMapping] = [:] // Only want this cache to exist for the current loop // Update the 'latestMessageId' value if fromOutbox { @@ -377,7 +383,7 @@ public final class OpenGroupManager: NSObject { // Note: The `posted` value is in seconds but all messages in the database use milliseconds for timestamps let envelope = SNProtoEnvelope.builder(type: .sessionMessage, timestamp: UInt64(floor(message.posted * 1000))) envelope.setContent(messageData) - envelope.setSource(message.sender) // TODO: Need to un-blind/intercept outbox messages? (their sender will be the blinded id) + envelope.setSource(message.sender) do { let data = try envelope.buildSerializedData() @@ -391,7 +397,6 @@ public final class OpenGroupManager: NSObject { using: transaction ) - // TODO: Need to test and validate this unblinding logic. // If the message was an outgoing message then attempt to unblind the recipient (this will help put // messages in the correct thread in case of message request approval race conditions as well as // during device sync'ing and restoration) @@ -399,11 +404,13 @@ public final class OpenGroupManager: NSObject { // Attempt to un-blind the 'message.recipient' let mapping: BlindedIdMapping - // Minor optimisation to avoid processing the same sender multiple times + // 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: BlindedIdMapping = mappingCache[message.recipient] { mapping = result } - else if let result: BlindedIdMapping = ContactUtilities.mapping(for: message.recipient, serverPublicKey: serverPublicKey) { + else if let result: BlindedIdMapping = ContactUtilities.mapping(for: message.recipient, serverPublicKey: serverPublicKey, using: transaction) { mapping = result } else { @@ -426,6 +433,15 @@ public final class OpenGroupManager: NSObject { } try MessageReceiver.handle(receivedMessage, associatedWithProto: proto, openGroupID: nil, isBackgroundPoll: isBackgroundPoll, using: transaction) + + // If this message is from the outbox then we should add the open group details back to the + // thread just in case this is from a restore (otherwise the user won't be able to send a new + // message to the target inbox if they are still blinded) + if fromOutbox, let contactThread: TSContactThread = TSContactThread.fetch(uniqueId: TSContactThread.threadID(fromContactSessionID: message.recipient), transaction: transaction) { + contactThread.originalOpenGroupServer = server + contactThread.originalOpenGroupPublicKey = serverPublicKey + contactThread.save(with: transaction) + } } catch let error { SNLog("Couldn't receive inbox message due to error: \(error).") @@ -442,35 +458,51 @@ public final class OpenGroupManager: NSObject { } public static func isUserModeratorOrAdmin(_ publicKey: String, for room: String, on server: String, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) -> Bool { - var targetKeys: [String] = [publicKey] + let modAndAdminKeys: Set = (OpenGroupManager.moderators.wrappedValue[server]?[room] ?? Set()) + .union(OpenGroupManager.admins.wrappedValue[server]?[room] ?? Set()) + + // If the publicKey is in the set then return immediately, otherwise only continue if it's the + // current user + guard !modAndAdminKeys.contains(publicKey) else { return true } + guard let sessionId: SessionId = SessionId(from: publicKey) else { return false } - // If we are checking for the current users public key then check for the blinded one as well - if publicKey == getUserHexEncodedPublicKey() { - guard let userEdKeyPair: Box.KeyPair = dependencies.storage.getUserED25519KeyPair() else { return false } - guard let serverPublicKey: String = dependencies.storage.getOpenGroupPublicKey(for: server) else { - return false - } - - // Add the unblinded key as an option - targetKeys.append(SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString) - - let server: OpenGroupAPI.Server? = dependencies.storage.getOpenGroupServer(name: server) - - // Check if the server supports blinded keys, if so then sign using the blinded key - if server?.capabilities.capabilities.contains(.blind) == true { + // Conveniently the logic for these different cases works in order so we can fallthrough each + // case with only minor efficiency losses + switch sessionId.prefix { + case .standard: + guard publicKey == getUserHexEncodedPublicKey() else { return false } + fallthrough + + case .unblinded: + guard let userEdKeyPair: Box.KeyPair = dependencies.storage.getUserED25519KeyPair() else { return false } + guard sessionId.prefix != .unblinded || publicKey == SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString else { + return false + } + fallthrough + + case .blinded: + guard let userEdKeyPair: Box.KeyPair = dependencies.storage.getUserED25519KeyPair() else { return false } + guard let serverPublicKey: String = dependencies.storage.getOpenGroupPublicKey(for: server) else { + return false + } guard let blindedKeyPair: Box.KeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: serverPublicKey, edKeyPair: userEdKeyPair, genericHash: dependencies.genericHash) else { return false } - - // Add the blinded key as an option - targetKeys.append(SessionId(.blinded, publicKey: blindedKeyPair.publicKey).hexString) - } + guard sessionId.prefix != .blinded || publicKey == SessionId(.blinded, publicKey: blindedKeyPair.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([ + getUserHexEncodedPublicKey(), + SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString, + SessionId(.blinded, publicKey: blindedKeyPair.publicKey).hexString + ]) + + return !modAndAdminKeys.intersection(possibleKeys).isEmpty } - - return ( - (OpenGroupManager.moderators[server]?[room]?.contains(where: { key in targetKeys.contains(key) }) ?? false) || - (OpenGroupManager.admins[server]?[room]?.contains(where: { key in targetKeys.contains(key) }) ?? false) - ) } public static func getDefaultRoomsIfNeeded(using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) { diff --git a/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift b/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift index d664f682e..10c5088d3 100644 --- a/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift +++ b/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift @@ -53,31 +53,6 @@ extension OpenGroupAPI { case userModerator(String) case userDeleteMessages(String) - // Legacy endpoints (to be deprecated and removed) - - @available(*, deprecated, message: "Use v4 endpoint") case legacyFiles - @available(*, deprecated, message: "Use v4 endpoint") case legacyFile(UInt64) - - @available(*, deprecated, message: "Use v4 endpoint") case legacyMessages - @available(*, deprecated, message: "Use v4 endpoint") case legacyMessagesForServer(Int64) - @available(*, deprecated, message: "Use v4 endpoint") case legacyDeletedMessages - - @available(*, deprecated, message: "Use v4 endpoint") case legacyModerators - - @available(*, deprecated, message: "Use v4 endpoint") case legacyBlockList - @available(*, deprecated, message: "Use v4 endpoint") case legacyBlockListIndividual(String) - @available(*, deprecated, message: "Use v4 endpoint") case legacyBanAndDeleteAll - - @available(*, deprecated, message: "Use v4 endpoint") case legacyCompactPoll(legacyAuth: Bool) - @available(*, deprecated, message: "Use request signing") case legacyAuthToken(legacyAuth: Bool) - @available(*, deprecated, message: "Use request signing") case legacyAuthTokenChallenge(legacyAuth: Bool) - @available(*, deprecated, message: "Use request signing") case legacyAuthTokenClaim(legacyAuth: Bool) - - @available(*, deprecated, message: "Use v4 endpoint") case legacyRooms - @available(*, deprecated, message: "Use v4 endpoint") case legacyRoomInfo(String) - @available(*, deprecated, message: "Use v4 endpoint") case legacyRoomImage(String) - @available(*, deprecated, message: "Use v4 endpoint") case legacyMemberCount(legacyAuth: Bool) - var path: String { switch self { // Utility @@ -142,65 +117,6 @@ extension OpenGroupAPI { case .userPermission(let sessionId): return "user/\(sessionId)/permission" case .userModerator(let sessionId): return "user/\(sessionId)/moderator" case .userDeleteMessages(let sessionId): return "user/\(sessionId)/deleteMessages" - - // Legacy endpoints (to be deprecated and removed) - // TODO: Look for a nicer way to prepend 'legacy'? (OnionRequestAPI messes with this but the new auth needs it to be correct...) - - - case .legacyFiles: return "legacy/files" - case .legacyFile(let fileId): return "legacy/files/\(fileId)" - - case .legacyMessages: return "legacy/messages" - case .legacyMessagesForServer(let serverId): return "legacy/messages/\(serverId)" - case .legacyDeletedMessages: return "legacy/deleted_messages" - - case .legacyModerators: return "legacy/moderators" - - case .legacyBlockList: return "legacy/block_list" - case .legacyBlockListIndividual(let publicKey): return "legacy/block_list/\(publicKey)" - case .legacyBanAndDeleteAll: return "legacy/ban_and_delete_all" - - case .legacyCompactPoll(let useLegacyAuth): - return "\(useLegacyAuth ? "" : "legacy/")compact_poll" - - case .legacyAuthToken(let useLegacyAuth): - return "\(useLegacyAuth ? "" : "legacy/")auth_token" - - case .legacyAuthTokenChallenge(let useLegacyAuth): - return "\(useLegacyAuth ? "" : "legacy/")auth_token_challenge" - - case .legacyAuthTokenClaim(let useLegacyAuth): - return "\(useLegacyAuth ? "" : "legacy/")claim_auth_token" - - case .legacyRooms: return "legacy/rooms" - case .legacyRoomInfo(let roomName): return "legacy/rooms/\(roomName)" - case .legacyRoomImage(let roomName): return "legacy/rooms/\(roomName)/image" - - case .legacyMemberCount(let useLegacyAuth): - return "\(useLegacyAuth ? "" : "legacy/")member_count" - } - } - - var useLegacyAuth: Bool { - switch self { - // File upload/download should use legacy auth - case .legacyFiles, .legacyFile, .legacyMessages, - .legacyMessagesForServer, .legacyDeletedMessages, - .legacyModerators, .legacyBlockList, - .legacyBlockListIndividual, .legacyBanAndDeleteAll: - return true - - case .legacyCompactPoll(let useLegacyAuth), - .legacyAuthToken(let useLegacyAuth), - .legacyAuthTokenChallenge(let useLegacyAuth), - .legacyAuthTokenClaim(let useLegacyAuth), - .legacyMemberCount(let useLegacyAuth): - return useLegacyAuth - - case .legacyRooms, .legacyRoomInfo, .legacyRoomImage: - return true - - default: return false } } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index 54a65d1a9..3e1c1b283 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -300,7 +300,7 @@ extension MessageReceiver { } if let messageToDelete = localMessage { if let incomingMessage = messageToDelete as? TSIncomingMessage { - incomingMessage.markAsReadNow(withSendReadReceipt: false, transaction: transaction) + incomingMessage.markAsReadNow(withTrySendReadReceipt: false, transaction: transaction) if let notificationIdentifier = incomingMessage.notificationIdentifier, !notificationIdentifier.isEmpty { UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [notificationIdentifier]) UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [notificationIdentifier]) diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift index 103491930..a742a9639 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift @@ -66,19 +66,6 @@ extension OpenGroupAPI { self?.isPolling = false seal.fulfill(()) // The promise is just used to keep track of when we're done } - // OpenGroupAPI.compactPoll(server) - // OpenGroupAPI.legacyCompactPoll(server) - // .done(on: OpenGroupAPI.workQueue) { [weak self] response in - // guard let self = self else { return } - // self.isPolling = false - // response.results.forEach { self.handleCompactPollBody($0, isBackgroundPoll: isBackgroundPoll) } - // seal.fulfill(()) - // } - // .catch(on: OpenGroupAPI.workQueue) { error in - // SNLog("Open group polling failed due to error: \(error).") - // self.isPolling = false - // seal.fulfill(()) // The promise is just used to keep track of when we're done - // } return promise } @@ -160,58 +147,5 @@ extension OpenGroupAPI { } } } - - // MARK: - Legacy Handling - - private func handleCompactPollBody(_ body: OpenGroupAPI.LegacyCompactPollResponse.Result, isBackgroundPoll: Bool) { - let storage = SNMessagingKitConfiguration.shared.storage - // - Messages - // Sorting the messages by server ID before importing them fixes an issue where messages that quote older messages can't find those older messages - let openGroupID = "\(server).\(body.room)" - let messages = (body.messages ?? []).sorted { ($0.serverID ?? 0) < ($1.serverID ?? 0) } - - storage.write { transaction in - messages.forEach { message in - guard let data = Data(base64Encoded: message.base64EncodedData) else { - return SNLog("Ignoring open group message with invalid encoding.") - } - let envelope = SNProtoEnvelope.builder(type: .sessionMessage, timestamp: message.sentTimestamp) - envelope.setContent(data) - envelope.setSource(message.sender!) // Safe because messages with a nil sender are filtered out - do { - let data = try envelope.buildSerializedData() - let (message, proto) = try MessageReceiver.parse(data, openGroupMessageServerID: UInt64(message.serverID!), isRetry: false, using: transaction) - try MessageReceiver.handle(message, associatedWithProto: proto, openGroupID: openGroupID, isBackgroundPoll: isBackgroundPoll, using: transaction) - } catch { - SNLog("Couldn't receive open group message due to error: \(error).") - } - } - } - - // - Moderators - if var x = OpenGroupAPI.moderators[server] { - x[body.room] = Set(body.moderators ?? []) - OpenGroupAPI.moderators[server] = x - } - else { - OpenGroupAPI.moderators[server] = [ body.room : Set(body.moderators ?? []) ] - } - - // - Deletions - let deletedMessageServerIDs = Set((body.deletions ?? []).map { UInt64($0.deletedMessageID) }) - storage.write { transaction in - let transaction = transaction as! YapDatabaseReadWriteTransaction - guard let threadID = storage.getThreadID(for: openGroupID), - let thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction) else { return } - var messagesToRemove: [TSMessage] = [] - - thread.enumerateInteractions(with: transaction) { interaction, stop in - guard let message = interaction as? TSMessage, deletedMessageServerIDs.contains(message.openGroupServerMessageID) else { return } - messagesToRemove.append(message) - } - - messagesToRemove.forEach { $0.remove(with: transaction) } - } - } } } diff --git a/SessionMessagingKit/Storage.swift b/SessionMessagingKit/Storage.swift index 92d3d12c9..3021827e5 100644 --- a/SessionMessagingKit/Storage.swift +++ b/SessionMessagingKit/Storage.swift @@ -28,7 +28,7 @@ public protocol SessionMessagingKitStorageProtocol { func cacheBlindedIdMapping(_ mapping: BlindedIdMapping) func cacheBlindedIdMapping(_ mapping: BlindedIdMapping, using transaction: YapDatabaseReadWriteTransaction) func enumerateBlindedIdMapping(with block: @escaping (BlindedIdMapping, UnsafeMutablePointer) -> ()) - func enumerateBlindedIdMapping(with block: @escaping (BlindedIdMapping, UnsafeMutablePointer) -> (), transaction: YapDatabaseReadTransaction) + func enumerateBlindedIdMapping(using transaction: YapDatabaseReadTransaction, with block: @escaping (BlindedIdMapping, UnsafeMutablePointer) -> ()) // MARK: - Closed Groups diff --git a/SessionMessagingKit/Utilities/ContactUtilities.swift b/SessionMessagingKit/Utilities/ContactUtilities.swift index 25e4e3cda..12de6d358 100644 --- a/SessionMessagingKit/Utilities/ContactUtilities.swift +++ b/SessionMessagingKit/Utilities/ContactUtilities.swift @@ -39,39 +39,46 @@ public enum ContactUtilities { .map { $0.sessionID } } - public static func enumerateApprovedContactThreads(with block: @escaping (TSContactThread, Contact, UnsafeMutablePointer) -> ()) { - Storage.read { transaction in - TSContactThread.enumerateCollectionObjects(with: transaction) { object, stop in - guard let contactThread: TSContactThread = object as? TSContactThread else { return } - guard let contact: Contact = approvedContact(in: object, using: transaction) else { return } - - block(contactThread, contact, stop) - } + public static func enumerateApprovedContactThreads(using transaction: YapDatabaseReadTransaction, with block: @escaping (TSContactThread, Contact, UnsafeMutablePointer) -> ()) { + TSContactThread.enumerateCollectionObjects(with: transaction) { object, stop in + guard let contactThread: TSContactThread = object as? TSContactThread else { return } + guard let contact: Contact = approvedContact(in: object, using: transaction) else { return } + + block(contactThread, contact, stop) } } public static func mapping(for blindedId: String, serverPublicKey: String, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) -> BlindedIdMapping? { - // TODO: Ensure the above case isn't going to be an issue due to legacy messages?. + var result: BlindedIdMapping? + + Storage.write { transaction in + result = ContactUtilities.mapping(for: blindedId, serverPublicKey: serverPublicKey, using: transaction, dependencies: dependencies) + } + + return result + } + + public static func mapping(for blindedId: String, serverPublicKey: String, using transaction: YapDatabaseReadWriteTransaction, dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) -> BlindedIdMapping? { // Unfortunately the whole point of id-blinding is to make it hard to reverse-engineer a standard // sessionId, as a result in order to see if there is an unblinded contact for this blindedId we // can only really generate blinded ids for each contact and check if any match // // Due to this we have made a few optimisations to try and early-out as often as possible, first // we try to retrieve a direct cached mapping - var mappingResult: BlindedIdMapping? = dependencies.storage.getBlindedIdMapping(with: blindedId) + var mappingResult: BlindedIdMapping? = dependencies.storage.getBlindedIdMapping(with: blindedId, using: transaction) // No need to continue if we already have a result if let mapping: BlindedIdMapping = mappingResult { return mapping } // Then we try loop through all approved contact threads to see if one of those contacts can be blinded to match - ContactUtilities.enumerateApprovedContactThreads { contactThread, contact, stop in + ContactUtilities.enumerateApprovedContactThreads(using: transaction) { contactThread, contact, stop in guard dependencies.sodium.sessionId(contact.sessionID, matchesBlindedId: blindedId, serverPublicKey: serverPublicKey) else { return } // Cache the mapping let newMapping: BlindedIdMapping = BlindedIdMapping(blindedId: blindedId, sessionId: contact.sessionID, serverPublicKey: serverPublicKey) - dependencies.storage.cacheBlindedIdMapping(newMapping) + dependencies.storage.cacheBlindedIdMapping(newMapping, using: transaction) mappingResult = newMapping stop.pointee = true } @@ -81,7 +88,7 @@ public enum ContactUtilities { // Lastly loop through existing id mappings (in case the user is looking at a different SOGS but once had // a thread with this contact in a different SOGS and had cached the mapping) - dependencies.storage.enumerateBlindedIdMapping { mapping, stop in + dependencies.storage.enumerateBlindedIdMapping(using: transaction) { mapping, stop in guard mapping.serverPublicKey != serverPublicKey else { return } guard dependencies.sodium.sessionId(mapping.sessionId, matchesBlindedId: blindedId, serverPublicKey: serverPublicKey) else { return @@ -89,7 +96,7 @@ public enum ContactUtilities { // Cache the new mapping let newMapping: BlindedIdMapping = BlindedIdMapping(blindedId: blindedId, sessionId: mapping.sessionId, serverPublicKey: serverPublicKey) - dependencies.storage.cacheBlindedIdMapping(newMapping) + dependencies.storage.cacheBlindedIdMapping(newMapping, using: transaction) mappingResult = newMapping stop.pointee = true } diff --git a/SessionMessagingKitTests/_TestUtilities/TestStorage.swift b/SessionMessagingKitTests/_TestUtilities/TestStorage.swift index bae468cd0..3424a7749 100644 --- a/SessionMessagingKitTests/_TestUtilities/TestStorage.swift +++ b/SessionMessagingKitTests/_TestUtilities/TestStorage.swift @@ -62,7 +62,7 @@ class TestStorage: SessionMessagingKitStorageProtocol, Mockable { func cacheBlindedIdMapping(_ mapping: BlindedIdMapping) {} func cacheBlindedIdMapping(_ mapping: BlindedIdMapping, using transaction: YapDatabaseReadWriteTransaction) {} func enumerateBlindedIdMapping(with block: @escaping (BlindedIdMapping, UnsafeMutablePointer) -> ()) {} - func enumerateBlindedIdMapping(with block: @escaping (BlindedIdMapping, UnsafeMutablePointer) -> (), transaction: YapDatabaseReadTransaction) { + func enumerateBlindedIdMapping(using transaction: YapDatabaseReadTransaction, with block: @escaping (BlindedIdMapping, UnsafeMutablePointer) -> ()) { } // MARK: - Closed Groups From 17a9e510c57f3e141845a3eb7ef2774fd2eea3a3 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 7 Mar 2022 17:43:30 +1100 Subject: [PATCH 028/157] Further work on unit tests (and a couple of bug fixes found when testing) Removed a couple remaining TODOs Added 'standardUserDefaults' to the 'Dependencies' type Tweaked the OpenGroupAPI to only update the 'lastOpen' timestamp if it successfully polls Refactored a couple of methods in the ConversationViewItem into swift so we can clean up the OpenGroupAPI more Updated the OpenGroupAPI so it no longer has static variables for state (shifted to the OpenGroupManager and made them instance variables) Fixed an encoding issue with the Capabilities.Capability --- Session.xcodeproj/project.pbxproj | 25 +- .../ConversationViewItem+Refactor.swift | 126 ++ Session/Conversations/ConversationViewItem.h | 4 - Session/Conversations/ConversationViewItem.m | 103 -- .../Open Groups/OpenGroupSuggestionGrid.swift | 3 +- .../Open Groups/Models/Capabilities.swift | 8 +- .../Open Groups/Models/PinnedMessage.swift | 2 +- .../Open Groups/Models/Room.swift | 2 +- .../Open Groups/OpenGroupAPI+ObjC.swift | 8 - .../Open Groups/OpenGroupAPI.swift | 104 +- .../Open Groups/OpenGroupManager.swift | 80 +- .../Open Groups/Types/Dependencies.swift | 11 + .../Open Groups/Types/SOGSEndpoint.swift | 8 +- .../MessageReceiver+Handling.swift | 9 - .../Pollers/OpenGroupPoller.swift | 17 +- .../Open Groups/OpenGroupAPISpec.swift | 1033 +++++++++++++---- .../_TestUtilities/TestUserDefaults.swift | 27 + SessionSnodeKit/OnionRequestAPI.swift | 3 - .../General/SNUserDefaults.swift | 25 +- 19 files changed, 1118 insertions(+), 480 deletions(-) create mode 100644 Session/Conversations/ConversationViewItem+Refactor.swift delete mode 100644 SessionMessagingKit/Open Groups/OpenGroupAPI+ObjC.swift create mode 100644 SessionMessagingKitTests/_TestUtilities/TestUserDefaults.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 95257a5c4..266ed7a9f 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -753,7 +753,6 @@ C3DAB3242480CB2B00725F25 /* SRCopyableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DAB3232480CB2A00725F25 /* SRCopyableLabel.swift */; }; C3DB66AC260ACA42001EFC55 /* OpenGroupManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DB66AB260ACA42001EFC55 /* OpenGroupManager.swift */; }; C3DB66C3260ACCE6001EFC55 /* OpenGroupPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DB66C2260ACCE6001EFC55 /* OpenGroupPoller.swift */; }; - C3DB66CC260AF1F3001EFC55 /* OpenGroupAPI+ObjC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DB66CB260AF1F3001EFC55 /* OpenGroupAPI+ObjC.swift */; }; C3DFFAC623E96F0D0058DAF8 /* Sheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DFFAC523E96F0D0058DAF8 /* Sheet.swift */; }; C3E5C2FA251DBABB0040DFFC /* EditClosedGroupVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E5C2F9251DBABB0040DFFC /* EditClosedGroupVC.swift */; }; C3ECBF7B257056B700EA7FCE /* Threading.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3ECBF7A257056B700EA7FCE /* Threading.swift */; }; @@ -795,6 +794,8 @@ FD83B9C927D0487A005E1583 /* SendDirectMessageResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9C827D0487A005E1583 /* SendDirectMessageResponse.swift */; }; FD83B9CC27D179BC005E1583 /* FSEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9CB27D179BC005E1583 /* FSEndpoint.swift */; }; FD83B9CE27D17A04005E1583 /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9CD27D17A04005E1583 /* Request.swift */; }; + FD83B9D227D59495005E1583 /* TestUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9D127D59495005E1583 /* TestUserDefaults.swift */; }; + FD83B9D427D5A7D5005E1583 /* ConversationViewItem+Refactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9D327D5A7D5005E1583 /* ConversationViewItem+Refactor.swift */; }; FD859EF227BF6BA200510D0C /* Data+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF127BF6BA200510D0C /* Data+Utilities.swift */; }; FD859EF427C2F49200510D0C /* TestSodium.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF327C2F49200510D0C /* TestSodium.swift */; }; FD859EF627C2F52C00510D0C /* TestSign.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF527C2F52C00510D0C /* TestSign.swift */; }; @@ -990,13 +991,6 @@ remoteGlobalIDString = C3C2A678255388CC00C340D1; remoteInfo = SessionUtilitiesKit; }; - FDC438BA27BB276F00C60D73 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = D221A080169C9E5E00537ABF /* Project object */; - proxyType = 1; - remoteGlobalIDString = D221A088169C9E5E00537ABF; - remoteInfo = Session; - }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -1866,7 +1860,6 @@ C3DAB3232480CB2A00725F25 /* SRCopyableLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SRCopyableLabel.swift; sourceTree = ""; }; C3DB66AB260ACA42001EFC55 /* OpenGroupManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupManager.swift; sourceTree = ""; }; C3DB66C2260ACCE6001EFC55 /* OpenGroupPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupPoller.swift; sourceTree = ""; }; - C3DB66CB260AF1F3001EFC55 /* OpenGroupAPI+ObjC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OpenGroupAPI+ObjC.swift"; sourceTree = ""; }; C3DFFAC523E96F0D0058DAF8 /* Sheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sheet.swift; sourceTree = ""; }; C3E5C2F9251DBABB0040DFFC /* EditClosedGroupVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditClosedGroupVC.swift; sourceTree = ""; }; C3E7134E251C867C009649BB /* Sodium+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sodium+Utilities.swift"; sourceTree = ""; }; @@ -1933,6 +1926,8 @@ FD83B9C827D0487A005E1583 /* SendDirectMessageResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendDirectMessageResponse.swift; sourceTree = ""; }; FD83B9CB27D179BC005E1583 /* FSEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FSEndpoint.swift; sourceTree = ""; }; FD83B9CD27D17A04005E1583 /* Request.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Request.swift; sourceTree = ""; }; + FD83B9D127D59495005E1583 /* TestUserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestUserDefaults.swift; sourceTree = ""; }; + FD83B9D327D5A7D5005E1583 /* ConversationViewItem+Refactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConversationViewItem+Refactor.swift"; sourceTree = ""; }; 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 = ""; }; @@ -2410,6 +2405,7 @@ B8D84E9325DF72AF005A043E /* ConversationViewAction.h */, 34D1F06F1F8678AA0066283D /* ConversationViewItem.h */, 34D1F0701F8678AA0066283D /* ConversationViewItem.m */, + FD83B9D327D5A7D5005E1583 /* ConversationViewItem+Refactor.swift */, 341341ED2187467900192D59 /* ConversationViewModel.h */, 341341EE2187467900192D59 /* ConversationViewModel.m */, 34ABC0E321DD20C500ED9469 /* ConversationMessageMapping.swift */, @@ -3378,7 +3374,6 @@ FDC4381827B34EAD00C60D73 /* Models */, FDC4380727B31D3A00C60D73 /* Types */, B88FA7B726045D100049422F /* OpenGroupAPI.swift */, - C3DB66CB260AF1F3001EFC55 /* OpenGroupAPI+ObjC.swift */, C3DB66AB260ACA42001EFC55 /* OpenGroupManager.swift */, ); path = "Open Groups"; @@ -3992,6 +3987,7 @@ FD859EF727C2F58900510D0C /* TestAeadXChaCha20Poly1305Ietf.swift */, FD859EF927C2F5C500510D0C /* TestGenericHash.swift */, FD859EFB27C2F60700510D0C /* TestEd25519.swift */, + FD83B9D127D59495005E1583 /* TestUserDefaults.swift */, ); path = _TestUtilities; sourceTree = ""; @@ -4376,7 +4372,6 @@ ); dependencies = ( FDC4389427B9FFC700C60D73 /* PBXTargetDependency */, - FDC438BB27BB276F00C60D73 /* PBXTargetDependency */, ); name = SessionMessagingKitTests; productName = SessionMessagingKitTests; @@ -5327,7 +5322,6 @@ C32C599E256DB02B003C73A2 /* TypingIndicators.swift in Sources */, FDC4380927B31D4E00C60D73 /* SOGSError.swift in Sources */, FDC4382027B36ADC00C60D73 /* SOGSEndpoint.swift in Sources */, - C3DB66CC260AF1F3001EFC55 /* OpenGroupAPI+ObjC.swift in Sources */, C32C5BEF256DC8EE003C73A2 /* OWSDisappearingMessagesJob.m in Sources */, C34A977425A3E34A00852C71 /* ClosedGroupControlMessage.swift in Sources */, FDC4384C27B47F7700C60D73 /* OpenGroup.swift in Sources */, @@ -5547,6 +5541,7 @@ C35E8AAE2485E51D00ACB629 /* IP2Country.swift in Sources */, B835249B25C3AB650089A44F /* VisibleMessageCell.swift in Sources */, 340FC8AE204DAC8D007AEB0F /* OWSSoundSettingsViewController.m in Sources */, + FD83B9D427D5A7D5005E1583 /* ConversationViewItem+Refactor.swift in Sources */, B8D0A25025E3678700C1835E /* LinkDeviceVC.swift in Sources */, 3496957321A301A100DCFE74 /* OWSBackupJob.m in Sources */, B894D0752339EDCF00B4D94D /* NukeDataModal.swift in Sources */, @@ -5594,6 +5589,7 @@ FDC438BD27BB2AB400C60D73 /* Mockable.swift in Sources */, FD859EF627C2F52C00510D0C /* TestSign.swift in Sources */, FDC4389D27BA01F000C60D73 /* TestStorage.swift in Sources */, + FD83B9D227D59495005E1583 /* TestUserDefaults.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5703,11 +5699,6 @@ target = C3C2A678255388CC00C340D1 /* SessionUtilitiesKit */; targetProxy = FDC438A027BA2B8A00C60D73 /* PBXContainerItemProxy */; }; - FDC438BB27BB276F00C60D73 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = D221A088169C9E5E00537ABF /* Session */; - targetProxy = FDC438BA27BB276F00C60D73 /* PBXContainerItemProxy */; - }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ diff --git a/Session/Conversations/ConversationViewItem+Refactor.swift b/Session/Conversations/ConversationViewItem+Refactor.swift new file mode 100644 index 000000000..1c47d6cda --- /dev/null +++ b/Session/Conversations/ConversationViewItem+Refactor.swift @@ -0,0 +1,126 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionSnodeKit +import SessionMessagingKit + +extension ConversationViewItem { + func deleteLocallyAction() { + guard let message: TSMessage = self.interaction as? TSMessage else { return } + + Storage.write { transaction in + MessageInvalidator.invalidate(message, with: transaction) + message.remove(with: transaction) + + if message.interactionType() == .outgoingMessage { + Storage.shared.cancelPendingMessageSendJobIfNeeded(for: message.timestamp, using: transaction) + } + } + } + + func deleteRemotelyAction() { + guard let message: TSMessage = self.interaction as? TSMessage else { return } + + if isGroupThread { + guard let groupThread: TSGroupThread = message.thread as? TSGroupThread else { return } + + // Only allow deletion on incoming and outgoing messages + guard message.interactionType() == .incomingMessage || message.interactionType() == .outgoingMessage else { + return + } + + if groupThread.isOpenGroup { + // Make sure it's an open group message and get the open group + guard message.isOpenGroupMessage, let uniqueId: String = groupThread.uniqueId, let openGroup: OpenGroup = Storage.shared.getOpenGroup(for: uniqueId) else { + return + } + + // If it's an incoming message the user must have moderator status + if message.interactionType() == .incomingMessage { + guard let userPublicKey: String = Storage.shared.getUserPublicKey() else { return } + + if !OpenGroupManager.isUserModeratorOrAdmin(userPublicKey, for: openGroup.room, on: openGroup.server) { + return + } + } + + // Delete the message + OpenGroupAPI.messageDelete(message.openGroupServerMessageID, in: openGroup.room, on: openGroup.server) + .catch { _ in + // Roll back + message.save() + } + .retainUntilComplete() + } + else { + guard let serverHash: String = message.serverHash else { return } + + let groupPublicKey: String = LKGroupUtilities.getDecodedGroupID(groupThread.groupModel.groupId) + + SnodeAPI.deleteMessage(publicKey: groupPublicKey, serverHashes: [serverHash]) + .catch { _ in + // Roll back + message.save() + } + .retainUntilComplete() + } + } + else { + guard let contactThread: TSContactThread = message.thread as? TSContactThread, let serverHash: String = message.serverHash else { + return + } + + SnodeAPI.deleteMessage(publicKey: contactThread.contactSessionID(), serverHashes: [serverHash]) + .catch { _ in + // Roll back + message.save() + } + .retainUntilComplete() + } + } + + // Remove this after the unsend request is enabled + func deleteAction() { + Storage.write { transaction in + self.interaction.remove(with: transaction) + + if self.interaction.interactionType() == .outgoingMessage { + Storage.shared.cancelPendingMessageSendJobIfNeeded(for: self.interaction.timestamp, using: transaction) + } + } + + + if self.isGroupThread { + guard let message: TSMessage = self.interaction as? TSMessage, let groupThread: TSGroupThread = message.thread as? TSGroupThread else { + return + } + + // Only allow deletion on incoming and outgoing messages + guard message.interactionType() == .incomingMessage || message.interactionType() == .outgoingMessage else { + return + } + + // Make sure it's an open group message and get the open group + guard message.isOpenGroupMessage, let uniqueId: String = groupThread.uniqueId, let openGroup: OpenGroup = Storage.shared.getOpenGroup(for: uniqueId) else { + return + } + + // If it's an incoming message the user must have moderator status + if message.interactionType() == .incomingMessage { + guard let userPublicKey: String = Storage.shared.getUserPublicKey() else { return } + + if !OpenGroupManager.isUserModeratorOrAdmin(userPublicKey, for: openGroup.room, on: openGroup.server) { + return + } + } + + // Delete the message + OpenGroupAPI.messageDelete(message.openGroupServerMessageID, in: openGroup.room, on: openGroup.server) + .catch { _ in + // Roll back + message.save() + } + .retainUntilComplete() + } + } +} diff --git a/Session/Conversations/ConversationViewItem.h b/Session/Conversations/ConversationViewItem.h index 8aeaccd01..069dcf99f 100644 --- a/Session/Conversations/ConversationViewItem.h +++ b/Session/Conversations/ConversationViewItem.h @@ -133,10 +133,6 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType); - (void)copyTextAction; - (void)shareMediaAction; - (void)saveMediaAction; -- (void)deleteLocallyAction; -- (void)deleteRemotelyAction; - -- (void)deleteAction; // Remove this after the unsend request is enabled - (BOOL)canCopyMedia; - (BOOL)canSaveMedia; diff --git a/Session/Conversations/ConversationViewItem.m b/Session/Conversations/ConversationViewItem.m index 9fe60412b..43a68403c 100644 --- a/Session/Conversations/ConversationViewItem.m +++ b/Session/Conversations/ConversationViewItem.m @@ -972,109 +972,6 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) return [self saveMediaAlbumItems:mediaAlbumItems]; } -- (void)deleteLocallyAction -{ - TSMessage *message = (TSMessage *)self.interaction; - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [MessageInvalidator invalidate:message with:transaction]; - [self.interaction removeWithTransaction:transaction]; - if (self.interaction.interactionType == OWSInteractionType_OutgoingMessage) { - [LKStorage.shared cancelPendingMessageSendJobIfNeededForMessage:self.interaction.timestamp using:transaction]; - } - }]; -} - -- (void)deleteRemotelyAction -{ - TSMessage *message = (TSMessage *)self.interaction; - - if (self.isGroupThread) { - TSGroupThread *groupThread = (TSGroupThread *)self.interaction.thread; - - // Only allow deletion on incoming and outgoing messages - OWSInteractionType interationType = self.interaction.interactionType; - if (interationType != OWSInteractionType_IncomingMessage && interationType != OWSInteractionType_OutgoingMessage) return; - - if (groupThread.isOpenGroup) { - // Make sure it's an open group message - if (!message.isOpenGroupMessage) return; - - // Get the open group - SNOpenGroupV2 *openGroup = [LKStorage.shared getOpenGroupForThreadID:groupThread.uniqueId]; - if (openGroup == nil) return; - - // If it's an incoming message the user must have moderator status - if (self.interaction.interactionType == OWSInteractionType_IncomingMessage) { - NSString *userPublicKey = [LKStorage.shared getUserPublicKey]; - if (![SNOpenGroupManager isUserModeratorOrAdmin:userPublicKey forRoom:openGroup.room onServer:openGroup.server]) { return; } - } - - // Delete the message - [[SNOpenGroupAPI deleteMessageWithServerID:message.openGroupServerMessageID fromRoom:openGroup.room onServer:openGroup.server].catch(^(NSError *error) { - // Roll back - [self.interaction save]; - }) retainUntilComplete]; - } else { - NSString *groupPublicKey = [LKGroupUtilities getDecodedGroupID:groupThread.groupModel.groupId]; - [[SNSnodeAPI deleteMessageForPublickKey:groupPublicKey serverHashes:@[message.serverHash]].catch(^(NSError *error) { - // Roll back - [self.interaction save]; - }) retainUntilComplete]; - } - } else { - TSContactThread *contactThread = (TSContactThread *)self.interaction.thread; - [[SNSnodeAPI deleteMessageForPublickKey:contactThread.contactSessionID serverHashes:@[message.serverHash]].catch(^(NSError *error) { - // Roll back - [self.interaction save]; - }) retainUntilComplete]; - } - -} - -// Remove this after the unsend request is enabled -- (void)deleteAction -{ - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [self.interaction removeWithTransaction:transaction]; - if (self.interaction.interactionType == OWSInteractionType_OutgoingMessage) { - [LKStorage.shared cancelPendingMessageSendJobIfNeededForMessage:self.interaction.timestamp using:transaction]; - } - }]; - - if (self.isGroupThread) { - TSGroupThread *groupThread = (TSGroupThread *)self.interaction.thread; - - // Only allow deletion on incoming and outgoing messages - OWSInteractionType interationType = self.interaction.interactionType; - if (interationType != OWSInteractionType_IncomingMessage && interationType != OWSInteractionType_OutgoingMessage) return; - - // Make sure it's an open group message - TSMessage *message = (TSMessage *)self.interaction; - if (!message.isOpenGroupMessage) return; - - // Get the open group - SNOpenGroupV2 *openGroup = [LKStorage.shared getOpenGroupForThreadID:groupThread.uniqueId]; - if (openGroup == nil && openGroup == nil) return; - - // If it's an incoming message the user must have moderator status - if (self.interaction.interactionType == OWSInteractionType_IncomingMessage) { - NSString *userPublicKey = [LKStorage.shared getUserPublicKey]; - if (openGroup != nil) { - if (![SNOpenGroupManager isUserModeratorOrAdmin:userPublicKey forRoom:openGroup.room onServer:openGroup.server]) { return; } - } - } - - // Delete the message - BOOL wasSentByUser = (interationType == OWSInteractionType_OutgoingMessage); - if (openGroup != nil) { - [[SNOpenGroupAPI deleteMessageWithServerID:message.openGroupServerMessageID fromRoom:openGroup.room onServer:openGroup.server].catch(^(NSError *error) { - // Roll back - [self.interaction save]; - }) retainUntilComplete]; - } - } -} - - (BOOL)hasBodyTextActionContent { return self.hasBodyText && self.displayableBodyText.fullText.length > 0; diff --git a/Session/Open Groups/OpenGroupSuggestionGrid.swift b/Session/Open Groups/OpenGroupSuggestionGrid.swift index f0e4fd077..90c807ba6 100644 --- a/Session/Open Groups/OpenGroupSuggestionGrid.swift +++ b/Session/Open Groups/OpenGroupSuggestionGrid.swift @@ -1,5 +1,6 @@ import PromiseKit import NVActivityIndicatorView +import SessionMessagingKit final class OpenGroupSuggestionGrid : UIView, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { private let maxWidth: CGFloat @@ -64,7 +65,7 @@ final class OpenGroupSuggestionGrid : UIView, UICollectionViewDataSource, UIColl widthAnchor.constraint(greaterThanOrEqualToConstant: OpenGroupSuggestionGrid.cellHeight).isActive = true OpenGroupManager.getDefaultRoomsIfNeeded() - _ = OpenGroupManager.defaultRoomsPromise?.done { [weak self] rooms in + _ = OpenGroupManager.shared.cache.defaultRoomsPromise?.done { [weak self] rooms in self?.rooms = rooms } } diff --git a/SessionMessagingKit/Open Groups/Models/Capabilities.swift b/SessionMessagingKit/Open Groups/Models/Capabilities.swift index 6cf98b51d..30da0117b 100644 --- a/SessionMessagingKit/Open Groups/Models/Capabilities.swift +++ b/SessionMessagingKit/Open Groups/Models/Capabilities.swift @@ -3,7 +3,7 @@ import Foundation extension OpenGroupAPI { - public struct Capabilities: Codable { + public struct Capabilities: Codable, Equatable { public enum Capability: Equatable, CaseIterable, Codable { public static var allCases: [Capability] { [.sogs, .blind] @@ -54,4 +54,10 @@ extension OpenGroupAPI.Capabilities.Capability { self = OpenGroupAPI.Capabilities.Capability(from: valueString) } + + public func encode(to encoder: Encoder) throws { + var container: SingleValueEncodingContainer = encoder.singleValueContainer() + + try container.encode(rawValue) + } } diff --git a/SessionMessagingKit/Open Groups/Models/PinnedMessage.swift b/SessionMessagingKit/Open Groups/Models/PinnedMessage.swift index 8ccdec795..e8f1f7a8e 100644 --- a/SessionMessagingKit/Open Groups/Models/PinnedMessage.swift +++ b/SessionMessagingKit/Open Groups/Models/PinnedMessage.swift @@ -3,7 +3,7 @@ import Foundation extension OpenGroupAPI { - public struct PinnedMessage: Codable { + public struct PinnedMessage: Codable, Equatable { enum CodingKeys: String, CodingKey { case id case pinnedAt = "pinned_at" diff --git a/SessionMessagingKit/Open Groups/Models/Room.swift b/SessionMessagingKit/Open Groups/Models/Room.swift index 06417848b..535c76902 100644 --- a/SessionMessagingKit/Open Groups/Models/Room.swift +++ b/SessionMessagingKit/Open Groups/Models/Room.swift @@ -3,7 +3,7 @@ import Foundation extension OpenGroupAPI { - public struct Room: Codable { + public struct Room: Codable, Equatable { enum CodingKeys: String, CodingKey { case token case name diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI+ObjC.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI+ObjC.swift deleted file mode 100644 index 43b0b9ca5..000000000 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI+ObjC.swift +++ /dev/null @@ -1,8 +0,0 @@ -import PromiseKit - -extension OpenGroupAPI { - @objc(deleteMessageWithServerID:fromRoom:onServer:) - public static func objc_deleteMessage(with serverID: Int64, from room: String, on server: String) -> AnyPromise { - return AnyPromise.from(messageDelete(serverID, in: room, on: server)) - } -} diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index 7ac3f7e94..4962e1f47 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -3,34 +3,14 @@ import SessionSnodeKit import Sodium import Curve25519Kit -@objc(SNOpenGroupAPI) -public final class OpenGroupAPI: NSObject { - +public enum OpenGroupAPI { // MARK: - Settings public static let defaultServer = "http://116.203.70.33" public static let defaultServerPublicKey = "a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238" public static let workQueue = DispatchQueue(label: "OpenGroupAPI.workQueue", qos: .userInitiated) // It's important that this is a serial queue - - // MARK: - Polling State - - private static var hasPerformedInitialPoll: Atomic<[String: Bool]> = Atomic([:]) - private static var timeSinceLastPoll: Atomic<[String: TimeInterval]> = Atomic([:]) - private static var lastPollTime: Atomic = Atomic(.greatestFiniteMagnitude) - private static let timeSinceLastOpen: Atomic = { - guard let lastOpen = UserDefaults.standard[.lastOpen] else { return Atomic(.greatestFiniteMagnitude) } - - return Atomic(Date().timeIntervalSince(lastOpen)) - }() - - - // TODO: Remove these - private static var legacyAuthTokenPromises: Atomic<[String: Promise]> = Atomic([:]) - private static var legacyHasUpdatedLastOpenDate = false - private static var legacyGroupImagePromises: [String: Promise] = [:] - // MARK: - Batching & Polling @@ -42,20 +22,17 @@ public final class OpenGroupAPI: NSObject { /// - Messages (includes additions and deletions) /// - Inbox for the server /// - Outbox for the server - public static func poll(_ server: String, using dependencies: Dependencies = Dependencies()) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable?)]> { - // Store a local copy of the cached state for this server - let hadPerformedInitialPoll: Bool = (hasPerformedInitialPoll.wrappedValue[server] == true) - let originalTimeSinceLastPoll: TimeInterval = (timeSinceLastPoll.wrappedValue[server] ?? min(lastPollTime.wrappedValue, timeSinceLastOpen.wrappedValue)) + public static func poll( + _ server: String, + hasPerformedInitialPoll: Bool, + timeSinceLastPoll: TimeInterval, + using dependencies: Dependencies = Dependencies() + ) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable?)]> { let maybeLastInboxMessageId: Int64? = dependencies.storage.getOpenGroupInboxLatestMessageId(for: server) let maybeLastOutboxMessageId: Int64? = dependencies.storage.getOpenGroupOutboxLatestMessageId(for: server) let lastInboxMessageId: Int64 = (maybeLastInboxMessageId ?? 0) let lastOutboxMessageId: Int64 = (maybeLastOutboxMessageId ?? 0) - // Update the cached state for this server - hasPerformedInitialPoll.mutate { $0[server] = true } - lastPollTime.mutate { $0 = min($0, timeSinceLastOpen.wrappedValue)} - UserDefaults.standard[.lastOpen] = Date() - // Generate the requests let requestResponseType: [BatchRequestInfoType] = [ BatchRequestInfo( @@ -78,8 +55,8 @@ public final class OpenGroupAPI: NSObject { // If it's the first poll for this launch and it's been longer than // 'maxInactivityPeriod' then just retrieve recent messages instead // of trying to get all messages since the last one retrieved - !hadPerformedInitialPoll && - originalTimeSinceLastPoll > OpenGroupAPI.Poller.maxInactivityPeriod + !hasPerformedInitialPoll && + timeSinceLastPoll > OpenGroupAPI.Poller.maxInactivityPeriod ) ) @@ -180,7 +157,6 @@ public final class OpenGroupAPI: NSObject { body: requestBody ) - // TODO: Handle a `412` response (ie. a required capability isn't supported) return send(request, using: dependencies) .decoded(as: responseTypes, on: OpenGroupAPI.workQueue, using: dependencies) .map { result in @@ -203,11 +179,9 @@ public final class OpenGroupAPI: NSObject { public static func capabilities(on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Capabilities)> { let request: Request = Request( server: server, - endpoint: .capabilities, - queryParameters: [:] // TODO: Add any requirements '.required'. + endpoint: .capabilities ) - // TODO: Handle a `412` response (ie. a required capability isn't supported) return send(request, using: dependencies) .decoded(as: Capabilities.self, on: OpenGroupAPI.workQueue, using: dependencies) } @@ -290,31 +264,21 @@ public final class OpenGroupAPI: NSObject { ] return sequence(server, requests: requestResponseType, using: dependencies) - .map { response -> (capabilities: (OnionRequestResponseInfoType, Capabilities?), room: (OnionRequestResponseInfoType, Room?)) in - var capabilities: (OnionRequestResponseInfoType, Capabilities?)? = nil - var room: (OnionRequestResponseInfoType, Room?)? = nil + .map { (response: [Endpoint: (OnionRequestResponseInfoType, Codable?)]) -> (capabilities: (OnionRequestResponseInfoType, Capabilities?), room: (OnionRequestResponseInfoType, Room?)) in + let maybeCapabilities: (OnionRequestResponseInfoType, Capabilities?)? = response[.capabilities] + .map { info, data in (info, (data as? BatchSubResponse)?.body) } + let maybeRoomResponse: (OnionRequestResponseInfoType, Codable?)? = response + .first(where: { key, _ in + switch key { + case .room: return true + default: return false + } + }) + .map { _, value in value } + let maybeRoom: (OnionRequestResponseInfoType, Room?)? = maybeRoomResponse + .map { info, data in (info, (data as? BatchSubResponse)?.body) } - try response.forEach { (endpoint: Endpoint, endpointResponse: (info: OnionRequestResponseInfoType, data: Codable?)) in - switch endpoint { - case .capabilities: - guard let responseData: BatchSubResponse = endpointResponse.data as? BatchSubResponse, let responseBody: Capabilities = responseData.body else { - throw HTTP.Error.parsingFailed - } - - capabilities = (endpointResponse.info, responseBody) - - case .room: - guard let responseData: OpenGroupAPI.BatchSubResponse = endpointResponse.data as? OpenGroupAPI.BatchSubResponse, let responseBody: OpenGroupAPI.Room = responseData.body else { - throw HTTP.Error.parsingFailed - } - - room = (endpointResponse.info, responseBody) - - default: break // No custom handling needed - } - } - - guard let capabilities: (OnionRequestResponseInfoType, Capabilities?) = capabilities, let room: (OnionRequestResponseInfoType, Room?) = room else { + guard let capabilities: (OnionRequestResponseInfoType, Capabilities?) = maybeCapabilities, let room: (OnionRequestResponseInfoType, Room?) = maybeRoom else { throw HTTP.Error.parsingFailed } @@ -367,7 +331,7 @@ public final class OpenGroupAPI: NSObject { } /// Returns a single message by ID - public static func message(_ id: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Message)> { + public static func message(_ id: UInt64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Message)> { let request: Request = Request( server: server, endpoint: .roomMessageIndividual(roomToken, id: id) @@ -381,7 +345,7 @@ public final class OpenGroupAPI: NSObject { /// /// **Note:** This edit may only be initiated by the creator of the post, and the poster must currently have write permissions in the room public static func messageUpdate( - _ id: Int64, + _ id: UInt64, plaintext: Data, fileIds: [Int64]?, in roomToken: String, @@ -405,12 +369,11 @@ public final class OpenGroupAPI: NSObject { body: requestBody ) - // TODO: Handle custom response info? return send(request, using: dependencies) } public static func messageDelete( - _ id: Int64, + _ id: UInt64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies() @@ -432,8 +395,6 @@ public final class OpenGroupAPI: NSObject { let request: Request = Request( server: server, endpoint: .roomMessagesRecent(roomToken) - // TODO: Limit?. -// queryParameters: [ .limit: 50 ] ) return send(request, using: dependencies) @@ -444,13 +405,10 @@ public final class OpenGroupAPI: NSObject { /// remove the `@available` line and make sure to route the response of this method to the `OpenGroupManager.handleMessages` /// method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") - public static func messagesBefore(messageId: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [Message])> { - // TODO: Do we need to be able to load old messages? + public static func messagesBefore(messageId: UInt64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [Message])> { let request: Request = Request( server: server, endpoint: .roomMessagesBefore(roomToken, id: messageId) - // TODO: Limit?. -// queryParameters: [ .limit: 50 ] ) return send(request, using: dependencies) @@ -465,8 +423,6 @@ public final class OpenGroupAPI: NSObject { let request: Request = Request( server: server, endpoint: .roomMessagesSince(roomToken, seqNo: seqNo) - // TODO: Limit?. -// queryParameters: [ .limit: 50 ] ) return send(request, using: dependencies) @@ -485,7 +441,7 @@ public final class OpenGroupAPI: NSObject { /// Pinned messages that are already pinned will be re-pinned (that is, their pin timestamp and pinning admin user will be updated) - because pinned /// messages are returned in pinning-order this allows admins to order multiple pinned messages in a room by re-pinning (via this endpoint) in the /// order in which pinned messages should be displayed - public static func pinMessage(id: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise { + public static func pinMessage(id: UInt64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise { let request: Request = Request( method: .post, server: server, @@ -499,7 +455,7 @@ public final class OpenGroupAPI: NSObject { /// Remove a message from this room's pinned message list /// /// The user must have `admin` (not just `moderator`) permissions in the room - public static func unpinMessage(id: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise { + public static func unpinMessage(id: UInt64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise { let request: Request = Request( method: .post, server: server, diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index c128a6bc9..f968234ac 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -5,22 +5,44 @@ import SessionSnodeKit @objc(SNOpenGroupManager) public final class OpenGroupManager: NSObject { - @objc public static let shared = OpenGroupManager() + public class Cache { + public var defaultRoomsPromise: Promise<[OpenGroupAPI.Room]>? + fileprivate var groupImagePromises: [String: Promise] = [:] + + /// Server URL to room ID to set of user IDs + fileprivate var moderators: [String: [String: Set]] = [:] + fileprivate var admins: [String: [String: Set]] = [:] + + /// Server URL to value + public var hasPerformedInitialPoll: [String: Bool] = [:] + public var timeSinceLastPoll: [String: TimeInterval] = [:] + + fileprivate var _timeSinceLastOpen: TimeInterval? + public func getTimeSinceLastOpen(using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) -> TimeInterval { + if let storedTimeSinceLastOpen: TimeInterval = _timeSinceLastOpen { + return storedTimeSinceLastOpen + } + + guard let lastOpen: Date = dependencies.standardUserDefaults[.lastOpen] else { + _timeSinceLastOpen = .greatestFiniteMagnitude + return .greatestFiniteMagnitude + } + + _timeSinceLastOpen = dependencies.date.timeIntervalSince(lastOpen) + return dependencies.date.timeIntervalSince(lastOpen) + } + } + + // MARK: - Variables + + @objc public static let shared: OpenGroupManager = OpenGroupManager() + + public let mutableCache: Atomic = Atomic(Cache()) + public var cache: Cache { return mutableCache.wrappedValue } private var pollers: [String: OpenGroupAPI.Poller] = [:] // One for each server private var isPolling = false - // MARK: - Cache - - public static var defaultRoomsPromise: Promise<[OpenGroupAPI.Room]>? - private static var groupImagePromises: [String: Promise] = [:] - - /// Server URL to room ID to set of moderator IDs - private static var moderators: Atomic<[String: [String: Set]]> = Atomic([:]) - - /// Server URL to room ID to set of admin IDs - private static var admins: Atomic<[String: [String: Set]]> = Atomic([:]) - // MARK: - Polling @objc public func startPolling() { @@ -247,15 +269,15 @@ public final class OpenGroupManager: NSObject { // - Moderators if let moderators: [String] = (pollInfo.details?.moderators ?? maybeUpdatedModel?.groupModeratorIds) { - OpenGroupManager.moderators.mutate { - $0[server] = ($0[server] ?? [:]).setting(roomToken, Set(moderators)) + OpenGroupManager.shared.mutableCache.mutate { cache in + cache.moderators[server] = (cache.moderators[server] ?? [:]).setting(roomToken, Set(moderators)) } } // - Admins if let admins: [String] = (pollInfo.details?.admins ?? maybeUpdatedModel?.groupAdminIds) { - OpenGroupManager.admins.mutate { - $0[server] = ($0[server] ?? [:]).setting(roomToken, Set(admins)) + OpenGroupManager.shared.mutableCache.mutate { cache in + cache.admins[server] = (cache.admins[server] ?? [:]).setting(roomToken, Set(admins)) } } @@ -458,8 +480,8 @@ public final class OpenGroupManager: NSObject { } public static func isUserModeratorOrAdmin(_ publicKey: String, for room: String, on server: String, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) -> Bool { - let modAndAdminKeys: Set = (OpenGroupManager.moderators.wrappedValue[server]?[room] ?? Set()) - .union(OpenGroupManager.admins.wrappedValue[server]?[room] ?? Set()) + let modAndAdminKeys: Set = (OpenGroupManager.shared.cache.moderators[server]?[room] ?? Set()) + .union(OpenGroupManager.shared.cache.admins[server]?[room] ?? Set()) // If the publicKey is in the set then return immediately, otherwise only continue if it's the // current user @@ -507,7 +529,7 @@ public final class OpenGroupManager: NSObject { public static func getDefaultRoomsIfNeeded(using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) { // Note: If we already have a 'defaultRoomsPromise' then there is no need to get it again - guard OpenGroupManager.defaultRoomsPromise == nil else { return } + guard OpenGroupManager.shared.cache.defaultRoomsPromise == nil else { return } dependencies.storage.write( with: { transaction in @@ -518,11 +540,13 @@ public final class OpenGroupManager: NSObject { ) }, completion: { - OpenGroupManager.defaultRoomsPromise = attempt(maxRetryCount: 8, recoveringOn: DispatchQueue.main) { - OpenGroupAPI.rooms(for: OpenGroupAPI.defaultServer, using: dependencies) - .map { _, data in data } + OpenGroupManager.shared.mutableCache.mutate { cache in + cache.defaultRoomsPromise = attempt(maxRetryCount: 8, recoveringOn: DispatchQueue.main) { + OpenGroupAPI.rooms(for: OpenGroupAPI.defaultServer, using: dependencies) + .map { _, data in data } + } } - OpenGroupManager.defaultRoomsPromise? + OpenGroupManager.shared.cache.defaultRoomsPromise? .done(on: OpenGroupAPI.workQueue) { items in items .compactMap { room -> (UInt64, String)? in @@ -536,7 +560,9 @@ public final class OpenGroupManager: NSObject { } } .catch(on: OpenGroupAPI.workQueue) { _ in - OpenGroupManager.defaultRoomsPromise = nil + OpenGroupManager.shared.mutableCache.mutate { cache in + cache.defaultRoomsPromise = nil + } } } ) @@ -566,7 +592,7 @@ public final class OpenGroupManager: NSObject { return Promise.value(data) } - if let promise = OpenGroupManager.groupImagePromises["\(server).\(roomToken)"] { + if let promise = OpenGroupManager.shared.cache.groupImagePromises["\(server).\(roomToken)"] { return promise } @@ -581,7 +607,9 @@ public final class OpenGroupManager: NSObject { UserDefaults.standard[.lastOpenGroupImageUpdate] = now } } - OpenGroupManager.groupImagePromises["\(server).\(roomToken)"] = promise + OpenGroupManager.shared.mutableCache.mutate { cache in + cache.groupImagePromises["\(server).\(roomToken)"] = promise + } return promise } diff --git a/SessionMessagingKit/Open Groups/Types/Dependencies.swift b/SessionMessagingKit/Open Groups/Types/Dependencies.swift index 86479e06f..9ba55334c 100644 --- a/SessionMessagingKit/Open Groups/Types/Dependencies.swift +++ b/SessionMessagingKit/Open Groups/Types/Dependencies.swift @@ -3,6 +3,7 @@ import Foundation import Sodium import SessionSnodeKit +import SessionUtilitiesKit // MARK: - Dependencies @@ -62,6 +63,12 @@ extension OpenGroupAPI { set { _nonceGenerator24 = newValue } } + private var _standardUserDefaults: UserDefaultsType? + public var standardUserDefaults: UserDefaultsType { + get { getValueSettingIfNull(&_standardUserDefaults) { UserDefaults.standard } } + set { _standardUserDefaults = newValue } + } + private var _date: Date? public var date: Date { get { getValueSettingIfNull(&_date) { Date() } } @@ -80,6 +87,7 @@ extension OpenGroupAPI { ed25519: Ed25519Type.Type? = nil, nonceGenerator16: NonceGenerator16ByteType? = nil, nonceGenerator24: NonceGenerator24ByteType? = nil, + standardUserDefaults: UserDefaultsType? = nil, date: Date? = nil ) { _api = api @@ -91,6 +99,7 @@ extension OpenGroupAPI { _ed25519 = ed25519 _nonceGenerator16 = nonceGenerator16 _nonceGenerator24 = nonceGenerator24 + _standardUserDefaults = standardUserDefaults _date = date } @@ -106,6 +115,7 @@ extension OpenGroupAPI { ed25519: Ed25519Type.Type? = nil, nonceGenerator16: NonceGenerator16ByteType? = nil, nonceGenerator24: NonceGenerator24ByteType? = nil, + standardUserDefaults: UserDefaultsType? = nil, date: Date? = nil ) -> Dependencies { return Dependencies( @@ -118,6 +128,7 @@ extension OpenGroupAPI { ed25519: (ed25519 ?? self._ed25519), nonceGenerator16: (nonceGenerator16 ?? self._nonceGenerator16), nonceGenerator24: (nonceGenerator24 ?? self._nonceGenerator24), + standardUserDefaults: (standardUserDefaults ?? self._standardUserDefaults), date: (date ?? self._date) ) } diff --git a/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift b/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift index 10c5088d3..a1b8c9e94 100644 --- a/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift +++ b/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift @@ -20,15 +20,15 @@ extension OpenGroupAPI { // Messages case roomMessage(String) - case roomMessageIndividual(String, id: Int64) + case roomMessageIndividual(String, id: UInt64) case roomMessagesRecent(String) - case roomMessagesBefore(String, id: Int64) + case roomMessagesBefore(String, id: UInt64) case roomMessagesSince(String, seqNo: Int64) // Pinning - case roomPinMessage(String, id: Int64) - case roomUnpinMessage(String, id: Int64) + case roomPinMessage(String, id: UInt64) + case roomUnpinMessage(String, id: UInt64) case roomUnpinAll(String) // Files diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index 3e1c1b283..4d9b631f5 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -240,14 +240,6 @@ extension MessageReceiver { thread.remove(with: transaction) } } - else if SessionId.Prefix(from: sessionID) != .blinded { - // Otherwise create and save the thread (if the contact isn't a blinded contact - we don't want to - // auto-create threads for blinded contacts if they have no messages) - // TODO: See what this will do with blinded->unblinded conversations? - let thread = TSContactThread.getOrCreateThread(withContactSessionID: sessionID, transaction: transaction) - thread.shouldBeVisible = true - thread.save(with: transaction) - } } // FIXME: 'OWSBlockingManager' manages it's own dbConnection and transactions so we have to dispatch this to prevent deadlocks @@ -891,7 +883,6 @@ extension MessageReceiver { // Note: Pending `MessageSendJobs` _shouldn't_ be an issue as even if they are sent after the // un-blinding of a thread, the logic when handling the sent messages should automatically // assign them to the correct thread - // TODO: Validate the above note once `/outbox` has been implemented view.enumerateRows(inGroup: blindedThreadId) { _, _, object, _, _, _ in guard let interaction: TSInteraction = object as? TSInteraction else { return diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift index a742a9639..09be07beb 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift @@ -52,13 +52,28 @@ extension OpenGroupAPI { guard !self.isPolling else { return Promise.value(()) } self.isPolling = true + let server: String = self.server let (promise, seal) = Promise.pending() promise.retainUntilComplete() - OpenGroupAPI.poll(server) + OpenGroupAPI + .poll( + server, + hasPerformedInitialPoll: OpenGroupManager.shared.cache.hasPerformedInitialPoll[server] == true, + timeSinceLastPoll: ( + OpenGroupManager.shared.cache.timeSinceLastPoll[server] ?? + OpenGroupManager.shared.cache.getTimeSinceLastOpen() + ) + ) .done(on: OpenGroupAPI.workQueue) { [weak self] response in self?.isPolling = false self?.handlePollResponse(response, isBackgroundPoll: isBackgroundPoll) + + OpenGroupManager.shared.mutableCache.mutate { cache in + cache.hasPerformedInitialPoll[server] = true + cache.timeSinceLastPoll[server] = Date().timeIntervalSince1970 + UserDefaults.standard[.lastOpen] = Date() + } seal.fulfill(()) } .catch(on: OpenGroupAPI.workQueue) { [weak self] error in diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift index d70018b13..661c0a154 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift @@ -83,6 +83,7 @@ class OpenGroupAPISpec: QuickSpec { var testAeadXChaCha20Poly1305Ietf: TestAeadXChaCha20Poly1305Ietf! var testGenericHash: TestGenericHash! var testSign: TestSign! + var testUserDefaults: TestUserDefaults! var dependencies: OpenGroupAPI.Dependencies! var response: (OnionRequestResponseInfoType, Codable)? = nil @@ -98,6 +99,7 @@ class OpenGroupAPISpec: QuickSpec { testAeadXChaCha20Poly1305Ietf = TestAeadXChaCha20Poly1305Ietf() testGenericHash = TestGenericHash() testSign = TestSign() + testUserDefaults = TestUserDefaults() dependencies = OpenGroupAPI.Dependencies( api: TestApi.self, storage: testStorage, @@ -108,6 +110,7 @@ class OpenGroupAPISpec: QuickSpec { ed25519: TestEd25519.self, nonceGenerator16: TestNonce16Generator(), nonceGenerator24: TestNonce24Generator(), + standardUserDefaults: testUserDefaults, date: Date(timeIntervalSince1970: 1234567890) ) @@ -144,8 +147,14 @@ class OpenGroupAPISpec: QuickSpec { } afterEach { - dependencies = nil testStorage = nil + testSodium = nil + testAeadXChaCha20Poly1305Ietf = nil + testGenericHash = nil + testSign = nil + testUserDefaults = nil + dependencies = nil + response = nil pollResponse = nil error = nil @@ -154,74 +163,496 @@ class OpenGroupAPISpec: QuickSpec { // MARK: - Batching & Polling context("when polling") { - it("generates the correct request") { - class LocalTestApi: TestApi { - override class var mockResponse: Data? { - let responses: [Data] = [ - try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( - code: 200, - headers: [:], - body: OpenGroupAPI.Capabilities(capabilities: [], missing: nil), - failedToParseBody: false + context("and given a correct response") { + beforeEach { + class LocalTestApi: TestApi { + override class var mockResponse: Data? { + let responses: [Data] = [ + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: OpenGroupAPI.Capabilities(capabilities: [], missing: nil), + failedToParseBody: false + ) + ), + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: try! JSONDecoder().decode( + OpenGroupAPI.RoomPollInfo.self, + from: """ + { + \"token\":\"test\", + \"active_users\":1, + \"read\":true, + \"write\":true, + \"upload\":true + } + """.data(using: .utf8)! + ), + failedToParseBody: false + ) + ), + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: [OpenGroupAPI.Message](), + failedToParseBody: false + ) + ), + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: [OpenGroupAPI.DirectMessage](), + failedToParseBody: false + ) + ), + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: [OpenGroupAPI.DirectMessage](), + failedToParseBody: false + ) ) - ), - try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( - code: 200, - headers: [:], - body: try! JSONDecoder().decode( - OpenGroupAPI.RoomPollInfo.self, - from: """ - { - \"token\":\"test\", - \"active_users\":1, - \"read\":true, - \"write\":true, - \"upload\":true - } - """.data(using: .utf8)! - ), - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( - code: 200, - headers: [:], - body: [OpenGroupAPI.Message](), - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( - code: 200, - headers: [:], - body: [OpenGroupAPI.DirectMessage](), - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( - code: 200, - headers: [:], - body: [OpenGroupAPI.DirectMessage](), - failedToParseBody: false - ) - ) - ] - - return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) + ] + + return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) + } } + + dependencies = dependencies.with(api: LocalTestApi.self) + } + + it("generates the correct request") { + OpenGroupAPI.poll("testServer", hasPerformedInitialPoll: false, timeSinceLastPoll: 0, using: dependencies) + .get { result in pollResponse = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(pollResponse) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate the response data + expect(pollResponse?.values).to(haveCount(5)) + expect(pollResponse?.keys).to(contain(.capabilities)) + expect(pollResponse?.keys).to(contain(.roomPollInfo("testRoom", 0))) + expect(pollResponse?.keys).to(contain(.roomMessagesRecent("testRoom"))) + expect(pollResponse?.keys).to(contain(.inbox)) + expect(pollResponse?.keys).to(contain(.outbox)) + expect(pollResponse?[.capabilities]?.0).to(beAKindOf(TestResponseInfo.self)) + + // Validate request data + let requestData: TestApi.RequestData? = (pollResponse?[.capabilities]?.0 as? TestResponseInfo)?.requestData + expect(requestData?.urlString).to(equal("testServer/batch")) + expect(requestData?.httpMethod).to(equal("POST")) + expect(requestData?.server).to(equal("testServer")) + expect(requestData?.publicKey).to(equal("7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")) + } + + it("retrieves recent messages if there was no last message") { + OpenGroupAPI + .poll( + "testServer", + hasPerformedInitialPoll: false, + timeSinceLastPoll: 0, + using: dependencies + ) + .get { result in pollResponse = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(pollResponse) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + expect(pollResponse?.keys).to(contain(.roomMessagesRecent("testRoom"))) + } + + it("retrieves recent messages if there was a last message and it has not performed the initial poll and the last message was too long ago") { + testStorage.mockData[.openGroupSequenceNumber] = ["testServer.testRoom": Int64(123)] + + OpenGroupAPI + .poll( + "testServer", + hasPerformedInitialPoll: false, + timeSinceLastPoll: (OpenGroupAPI.Poller.maxInactivityPeriod + 1), + using: dependencies + ) + .get { result in pollResponse = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(pollResponse) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + expect(pollResponse?.keys).to(contain(.roomMessagesRecent("testRoom"))) + } + + it("retrieves recent messages if there was a last message and it has performed an initial poll but it was not too long ago") { + testStorage.mockData[.openGroupSequenceNumber] = ["testServer.testRoom": Int64(123)] + + OpenGroupAPI + .poll( + "testServer", + hasPerformedInitialPoll: false, + timeSinceLastPoll: 0, + using: dependencies + ) + .get { result in pollResponse = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(pollResponse) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + expect(pollResponse?.keys).to(contain(.roomMessagesSince("testRoom", seqNo: 123))) + } + + it("retrieves recent messages if there was a last message and there has already been a poll this session") { + testStorage.mockData[.openGroupSequenceNumber] = ["testServer.testRoom": Int64(123)] + + OpenGroupAPI + .poll( + "testServer", + hasPerformedInitialPoll: true, + timeSinceLastPoll: 0, + using: dependencies + ) + .get { result in pollResponse = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(pollResponse) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + expect(pollResponse?.keys).to(contain(.roomMessagesSince("testRoom", seqNo: 123))) + } + + it("retrieves recent inbox messages if there was no last message") { + OpenGroupAPI + .poll( + "testServer", + hasPerformedInitialPoll: true, + timeSinceLastPoll: 0, + using: dependencies + ) + .get { result in pollResponse = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(pollResponse) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + expect(pollResponse?.keys).to(contain(.inbox)) + } + + it("retrieves inbox messages since the last message if there was one") { + testStorage.mockData[.openGroupInboxLatestMessageId] = ["testServer": Int64(124)] + + OpenGroupAPI + .poll( + "testServer", + hasPerformedInitialPoll: true, + timeSinceLastPoll: 0, + using: dependencies + ) + .get { result in pollResponse = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(pollResponse) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + expect(pollResponse?.keys).to(contain(.inboxSince(id: 124))) + } + + it("retrieves recent outbox messages if there was no last message") { + OpenGroupAPI + .poll( + "testServer", + hasPerformedInitialPoll: true, + timeSinceLastPoll: 0, + using: dependencies + ) + .get { result in pollResponse = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(pollResponse) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + expect(pollResponse?.keys).to(contain(.outbox)) + } + + it("retrieves outbox messages since the last message if there was one") { + testStorage.mockData[.openGroupOutboxLatestMessageId] = ["testServer": Int64(125)] + + OpenGroupAPI + .poll( + "testServer", + hasPerformedInitialPoll: true, + timeSinceLastPoll: 0, + using: dependencies + ) + .get { result in pollResponse = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(pollResponse) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + expect(pollResponse?.keys).to(contain(.outboxSince(id: 125))) + } + } + + context("and given an invalid response") { + it("does not update the poll state") { + testStorage.mockData[.openGroupSequenceNumber] = ["testServer.testRoom": Int64(123)] + + OpenGroupAPI.poll("testServer", hasPerformedInitialPoll: false, timeSinceLastPoll: 0, using: dependencies) + .get { result in pollResponse = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(HTTP.Error.invalidResponse.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(pollResponse).to(beNil()) + expect(testUserDefaults[.lastOpen]).to(beNil()) + } + + it("errors when no data is returned") { + OpenGroupAPI.poll("testServer", hasPerformedInitialPoll: false, timeSinceLastPoll: 0, using: dependencies) + .get { result in pollResponse = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(HTTP.Error.parsingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(pollResponse).to(beNil()) + } + + it("errors when invalid data is returned") { + class LocalTestApi: TestApi { + override class var mockResponse: Data? { return Data() } + } + dependencies = dependencies.with(api: LocalTestApi.self) + + OpenGroupAPI.poll("testServer", hasPerformedInitialPoll: false, timeSinceLastPoll: 0, using: dependencies) + .get { result in pollResponse = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(HTTP.Error.parsingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(pollResponse).to(beNil()) + } + + it("errors when an empty array is returned") { + class LocalTestApi: TestApi { + override class var mockResponse: Data? { return "[]".data(using: .utf8) } + } + dependencies = dependencies.with(api: LocalTestApi.self) + + OpenGroupAPI.poll("testServer", hasPerformedInitialPoll: false, timeSinceLastPoll: 0, using: dependencies) + .get { result in pollResponse = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(HTTP.Error.parsingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(pollResponse).to(beNil()) + } + + it("errors when an empty object is returned") { + class LocalTestApi: TestApi { + override class var mockResponse: Data? { return "{}".data(using: .utf8) } + } + dependencies = dependencies.with(api: LocalTestApi.self) + + OpenGroupAPI.poll("testServer", hasPerformedInitialPoll: false, timeSinceLastPoll: 0, using: dependencies) + .get { result in pollResponse = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(HTTP.Error.parsingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(pollResponse).to(beNil()) + } + + it("errors when a different number of responses are returned") { + class LocalTestApi: TestApi { + override class var mockResponse: Data? { + let responses: [Data] = [ + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: OpenGroupAPI.Capabilities(capabilities: [], missing: nil), + failedToParseBody: false + ) + ), + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: try! JSONDecoder().decode( + OpenGroupAPI.RoomPollInfo.self, + from: """ + { + \"token\":\"test\", + \"active_users\":1, + \"read\":true, + \"write\":true, + \"upload\":true + } + """.data(using: .utf8)! + ), + failedToParseBody: false + ) + ) + ] + + return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) + } + } + dependencies = dependencies.with(api: LocalTestApi.self) + + OpenGroupAPI.poll("testServer", hasPerformedInitialPoll: false, timeSinceLastPoll: 0, using: dependencies) + .get { result in pollResponse = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(HTTP.Error.parsingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(pollResponse).to(beNil()) + } + + it("errors when an unexpected response is returned") { + class LocalTestApi: TestApi { + override class var mockResponse: Data? { + let responses: [Data] = [ + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: ""), + failedToParseBody: false + ) + ), + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: ""), + failedToParseBody: false + ) + ), + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: ""), + failedToParseBody: false + ) + ) + ] + + return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) + } + } + dependencies = dependencies.with(api: LocalTestApi.self) + + OpenGroupAPI.poll("testServer", hasPerformedInitialPoll: false, timeSinceLastPoll: 0, using: dependencies) + .get { result in pollResponse = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(HTTP.Error.parsingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(pollResponse).to(beNil()) + } + } + } + + // MARK: - Capabilities + + context("when doing a capabilities request") { + it("generates the request and handles the response correctly") { + class LocalTestApi: TestApi { + static let data: OpenGroupAPI.Capabilities = OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: nil) + + override class var mockResponse: Data? { try! JSONEncoder().encode(data) } } dependencies = dependencies.with(api: LocalTestApi.self) - OpenGroupAPI.poll("testServer", using: dependencies) - .get { result in pollResponse = result } + var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Capabilities)? + + OpenGroupAPI.capabilities(on: "testServer", using: dependencies) + .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() - expect(pollResponse) + expect(response) .toEventuallyNot( beNil(), timeout: .milliseconds(100) @@ -229,196 +660,346 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate the response data - expect(pollResponse?.values).to(haveCount(5)) - expect(pollResponse?.keys).to(contain(.capabilities)) - expect(pollResponse?.keys).to(contain(.roomPollInfo("testRoom", 0))) - expect(pollResponse?.keys).to(contain(.roomMessagesRecent("testRoom"))) - expect(pollResponse?.keys).to(contain(.inbox)) - expect(pollResponse?.keys).to(contain(.outbox)) - expect(pollResponse?[.capabilities]?.0).to(beAKindOf(TestResponseInfo.self)) + expect(response?.data).to(equal(LocalTestApi.data)) // Validate request data - let requestData: TestApi.RequestData? = (pollResponse?[.capabilities]?.0 as? TestResponseInfo)?.requestData - expect(requestData?.urlString).to(equal("testServer/batch")) - expect(requestData?.httpMethod).to(equal("POST")) + let requestData: TestApi.RequestData? = (response?.info as? TestResponseInfo)?.requestData + expect(requestData?.httpMethod).to(equal("GET")) expect(requestData?.server).to(equal("testServer")) - expect(requestData?.publicKey).to(equal("7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")) + expect(requestData?.urlString).to(equal("testServer/capabilities")) } - - it("errors when no data is returned") { - OpenGroupAPI.poll("testServer", using: dependencies) - .get { result in pollResponse = result } - .catch { requestError in error = requestError } - .retainUntilComplete() - - expect(error?.localizedDescription) - .toEventually( - equal(HTTP.Error.parsingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(pollResponse).to(beNil()) - } - - it("errors when invalid data is returned") { + } + + // MARK: - Rooms + + context("when doing a rooms request") { + it("generates the request and handles the response correctly") { class LocalTestApi: TestApi { - override class var mockResponse: Data? { return Data() } + static let data: [OpenGroupAPI.Room] = [ + OpenGroupAPI.Room( + token: "test", + name: "test", + description: nil, + infoUpdates: 0, + messageSequence: 0, + created: 0, + activeUsers: 0, + activeUsersCutoff: 0, + imageId: nil, + pinnedMessages: nil, + admin: false, + globalAdmin: false, + admins: [], + hiddenAdmins: nil, + moderator: false, + globalModerator: false, + moderators: [], + hiddenModerators: nil, + read: false, + defaultRead: nil, + defaultAccessible: nil, + write: false, + defaultWrite: nil, + upload: false, + defaultUpload: nil + ) + ] + + override class var mockResponse: Data? { return try! JSONEncoder().encode(data) } } dependencies = dependencies.with(api: LocalTestApi.self) - OpenGroupAPI.poll("testServer", using: dependencies) - .get { result in pollResponse = result } + var response: (info: OnionRequestResponseInfoType, data: [OpenGroupAPI.Room])? + + OpenGroupAPI.rooms(for: "testServer", using: dependencies) + .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() - expect(error?.localizedDescription) - .toEventually( - equal(HTTP.Error.parsingFailed.localizedDescription), + expect(response) + .toEventuallyNot( + beNil(), timeout: .milliseconds(100) ) + expect(error?.localizedDescription).to(beNil()) - expect(pollResponse).to(beNil()) + // Validate the response data + expect(response?.data).to(equal(LocalTestApi.data)) + + // Validate request data + let requestData: TestApi.RequestData? = (response?.info as? TestResponseInfo)?.requestData + expect(requestData?.httpMethod).to(equal("GET")) + expect(requestData?.server).to(equal("testServer")) + expect(requestData?.urlString).to(equal("testServer/rooms")) } - - it("errors when an empty array is returned") { - class LocalTestApi: TestApi { - override class var mockResponse: Data? { return "[]".data(using: .utf8) } - } - dependencies = dependencies.with(api: LocalTestApi.self) - - OpenGroupAPI.poll("testServer", using: dependencies) - .get { result in pollResponse = result } - .catch { requestError in error = requestError } - .retainUntilComplete() - - expect(error?.localizedDescription) - .toEventually( - equal(HTTP.Error.parsingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(pollResponse).to(beNil()) - } - - it("errors when an empty object is returned") { - class LocalTestApi: TestApi { - override class var mockResponse: Data? { return "{}".data(using: .utf8) } - } - dependencies = dependencies.with(api: LocalTestApi.self) - - OpenGroupAPI.poll("testServer", using: dependencies) - .get { result in pollResponse = result } - .catch { requestError in error = requestError } - .retainUntilComplete() - - expect(error?.localizedDescription) - .toEventually( - equal(HTTP.Error.parsingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(pollResponse).to(beNil()) - } - - it("errors when a different number of responses are returned") { - class LocalTestApi: TestApi { - override class var mockResponse: Data? { - let responses: [Data] = [ - try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( - code: 200, - headers: [:], - body: OpenGroupAPI.Capabilities(capabilities: [], missing: nil), - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( - code: 200, - headers: [:], - body: try! JSONDecoder().decode( - OpenGroupAPI.RoomPollInfo.self, - from: """ - { - \"token\":\"test\", - \"active_users\":1, - \"read\":true, - \"write\":true, - \"upload\":true - } - """.data(using: .utf8)! - ), - failedToParseBody: false - ) - ) - ] + } + + // MARK: - CapabilitiesAndRoom + + context("when doing a capabilitiesAndRoom request") { + context("and given a correct response") { + it("generates the request and handles the response correctly") { + class LocalTestApi: TestApi { + static let capabilitiesData: OpenGroupAPI.Capabilities = OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: nil) + static let roomData: OpenGroupAPI.Room = OpenGroupAPI.Room( + token: "test", + name: "test", + description: nil, + infoUpdates: 0, + messageSequence: 0, + created: 0, + activeUsers: 0, + activeUsersCutoff: 0, + imageId: nil, + pinnedMessages: nil, + admin: false, + globalAdmin: false, + admins: [], + hiddenAdmins: nil, + moderator: false, + globalModerator: false, + moderators: [], + hiddenModerators: nil, + read: false, + defaultRead: nil, + defaultAccessible: nil, + write: false, + defaultWrite: nil, + upload: false, + defaultUpload: nil + ) - return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) + override class var mockResponse: Data? { + let responses: [Data] = [ + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: capabilitiesData, + failedToParseBody: false + ) + ), + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: roomData, + failedToParseBody: false + ) + ) + ] + + return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) + } } + dependencies = dependencies.with(api: LocalTestApi.self) + + var response: (capabilities: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Capabilities?), room: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Room?))? + + OpenGroupAPI.capabilitiesAndRoom(for: "testRoom", on: "testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate the response data + expect(response?.capabilities.data).to(equal(LocalTestApi.capabilitiesData)) + expect(response?.room.data).to(equal(LocalTestApi.roomData)) + + // Validate request data + let requestData: TestApi.RequestData? = (response?.capabilities.info as? TestResponseInfo)?.requestData + expect(requestData?.httpMethod).to(equal("POST")) + expect(requestData?.server).to(equal("testServer")) + expect(requestData?.urlString).to(equal("testServer/sequence")) } - dependencies = dependencies.with(api: LocalTestApi.self) - - OpenGroupAPI.poll("testServer", using: dependencies) - .get { result in pollResponse = result } - .catch { requestError in error = requestError } - .retainUntilComplete() - - expect(error?.localizedDescription) - .toEventually( - equal(HTTP.Error.parsingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(pollResponse).to(beNil()) } - it("errors when an unexpected response is returned") { - class LocalTestApi: TestApi { - override class var mockResponse: Data? { - let responses: [Data] = [ - try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( - code: 200, - headers: [:], - body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: ""), - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( - code: 200, - headers: [:], - body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: ""), - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( - code: 200, - headers: [:], - body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: ""), - failedToParseBody: false - ) - ) - ] + context("and given an invalid response") { + it("errors when only a capabilities response is returned") { + class LocalTestApi: TestApi { + static let capabilitiesData: OpenGroupAPI.Capabilities = OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: nil) - return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) + override class var mockResponse: Data? { + let responses: [Data] = [ + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: capabilitiesData, + failedToParseBody: false + ) + ) + ] + + return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) + } } + dependencies = dependencies.with(api: LocalTestApi.self) + + var response: (capabilities: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Capabilities?), room: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Room?))? + + OpenGroupAPI.capabilitiesAndRoom(for: "testRoom", on: "testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(HTTP.Error.parsingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) } - dependencies = dependencies.with(api: LocalTestApi.self) - OpenGroupAPI.poll("testServer", using: dependencies) - .get { result in pollResponse = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + it("errors when only a room response is returned") { + class LocalTestApi: TestApi { + static let roomData: OpenGroupAPI.Room = OpenGroupAPI.Room( + token: "test", + name: "test", + description: nil, + infoUpdates: 0, + messageSequence: 0, + created: 0, + activeUsers: 0, + activeUsersCutoff: 0, + imageId: nil, + pinnedMessages: nil, + admin: false, + globalAdmin: false, + admins: [], + hiddenAdmins: nil, + moderator: false, + globalModerator: false, + moderators: [], + hiddenModerators: nil, + read: false, + defaultRead: nil, + defaultAccessible: nil, + write: false, + defaultWrite: nil, + upload: false, + defaultUpload: nil + ) + + override class var mockResponse: Data? { + let responses: [Data] = [ + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: roomData, + failedToParseBody: false + ) + ) + ] + + return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) + } + } + dependencies = dependencies.with(api: LocalTestApi.self) + + var response: (capabilities: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Capabilities?), room: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Room?))? + + OpenGroupAPI.capabilitiesAndRoom(for: "testRoom", on: "testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(HTTP.Error.parsingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } - expect(error?.localizedDescription) - .toEventually( - equal(HTTP.Error.parsingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(pollResponse).to(beNil()) + it("errors when an extra response is returned") { + class LocalTestApi: TestApi { + static let capabilitiesData: OpenGroupAPI.Capabilities = OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: nil) + static let roomData: OpenGroupAPI.Room = OpenGroupAPI.Room( + token: "test", + name: "test", + description: nil, + infoUpdates: 0, + messageSequence: 0, + created: 0, + activeUsers: 0, + activeUsersCutoff: 0, + imageId: nil, + pinnedMessages: nil, + admin: false, + globalAdmin: false, + admins: [], + hiddenAdmins: nil, + moderator: false, + globalModerator: false, + moderators: [], + hiddenModerators: nil, + read: false, + defaultRead: nil, + defaultAccessible: nil, + write: false, + defaultWrite: nil, + upload: false, + defaultUpload: nil + ) + + override class var mockResponse: Data? { + let responses: [Data] = [ + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: capabilitiesData, + failedToParseBody: false + ) + ), + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: roomData, + failedToParseBody: false + ) + ), + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: ""), + failedToParseBody: false + ) + ) + ] + + return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) + } + } + dependencies = dependencies.with(api: LocalTestApi.self) + + var response: (capabilities: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Capabilities?), room: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Room?))? + + OpenGroupAPI.capabilitiesAndRoom(for: "testRoom", on: "testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(HTTP.Error.parsingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } } } diff --git a/SessionMessagingKitTests/_TestUtilities/TestUserDefaults.swift b/SessionMessagingKitTests/_TestUtilities/TestUserDefaults.swift new file mode 100644 index 000000000..a01b5eda2 --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/TestUserDefaults.swift @@ -0,0 +1,27 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +class TestUserDefaults: UserDefaultsType { + var storage: [String: Any] = [:] + + func object(forKey defaultName: String) -> Any? { return storage[defaultName] } + func string(forKey defaultName: String) -> String? { return storage[defaultName] as? String } + func array(forKey defaultName: String) -> [Any]? { return storage[defaultName] as? [Any] } + func dictionary(forKey defaultName: String) -> [String: Any]? { return storage[defaultName] as? [String: Any] } + func data(forKey defaultName: String) -> Data? { return storage[defaultName] as? Data } + func stringArray(forKey defaultName: String) -> [String]? { return storage[defaultName] as? [String] } + func integer(forKey defaultName: String) -> Int { return ((storage[defaultName] as? Int) ?? 0) } + func float(forKey defaultName: String) -> Float { return ((storage[defaultName] as? Float) ?? 0) } + func double(forKey defaultName: String) -> Double { return ((storage[defaultName] as? Double) ?? 0) } + func bool(forKey defaultName: String) -> Bool { return ((storage[defaultName] as? Bool) ?? false) } + func url(forKey defaultName: String) -> URL? { return storage[defaultName] as? URL } + + func set(_ value: Any?, forKey defaultName: String) { storage[defaultName] = value } + func set(_ value: Int, forKey defaultName: String) { storage[defaultName] = value } + func set(_ value: Float, forKey defaultName: String) { storage[defaultName] = value } + func set(_ value: Double, forKey defaultName: String) { storage[defaultName] = value } + func set(_ value: Bool, forKey defaultName: String) { storage[defaultName] = value } + func set(_ url: URL?, forKey defaultName: String) { storage[defaultName] = url } +} diff --git a/SessionSnodeKit/OnionRequestAPI.swift b/SessionSnodeKit/OnionRequestAPI.swift index 1d4128457..6c7a4dd52 100644 --- a/SessionSnodeKit/OnionRequestAPI.swift +++ b/SessionSnodeKit/OnionRequestAPI.swift @@ -308,9 +308,6 @@ public enum OnionRequestAPI: OnionRequestAPIType { /// Sends an onion request to `server`. Builds new paths as needed. public static func sendOnionRequest(_ request: URLRequest, to server: String, using version: Version = .v4, with x25519PublicKey: String) -> Promise<(OnionRequestResponseInfoType, Data?)> { - guard version != .v4 || server == "https://chat.lokinet.dev" else { // TODO: Remove this - return sendOnionRequest(request, to: server, using: .v3, with: x25519PublicKey) - } guard let url = request.url, let host = request.url?.host else { return Promise(error: Error.invalidURL) } let scheme: String? = url.scheme diff --git a/SessionUtilitiesKit/General/SNUserDefaults.swift b/SessionUtilitiesKit/General/SNUserDefaults.swift index 0e27a2a3b..4799d5cc8 100644 --- a/SessionUtilitiesKit/General/SNUserDefaults.swift +++ b/SessionUtilitiesKit/General/SNUserDefaults.swift @@ -1,5 +1,28 @@ import Foundation +public protocol UserDefaultsType: AnyObject { + func object(forKey defaultName: String) -> Any? + func string(forKey defaultName: String) -> String? + func array(forKey defaultName: String) -> [Any]? + func dictionary(forKey defaultName: String) -> [String : Any]? + func data(forKey defaultName: String) -> Data? + func stringArray(forKey defaultName: String) -> [String]? + func integer(forKey defaultName: String) -> Int + func float(forKey defaultName: String) -> Float + func double(forKey defaultName: String) -> Double + func bool(forKey defaultName: String) -> Bool + func url(forKey defaultName: String) -> URL? + + func set(_ value: Any?, forKey defaultName: String) + func set(_ value: Int, forKey defaultName: String) + func set(_ value: Float, forKey defaultName: String) + func set(_ value: Double, forKey defaultName: String) + func set(_ value: Bool, forKey defaultName: String) + func set(_ url: URL?, forKey defaultName: String) +} + +extension UserDefaults: UserDefaultsType {} + public enum SNUserDefaults { public enum Bool : Swift.String { @@ -31,7 +54,7 @@ public enum SNUserDefaults { } } -public extension UserDefaults { +public extension UserDefaultsType { subscript(bool: SNUserDefaults.Bool) -> Bool { get { return self.bool(forKey: bool.rawValue) } From b6a6f77d4e93c339c4861c29ea3c3f3d1522c275 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 8 Mar 2022 10:45:58 +1100 Subject: [PATCH 029/157] Adding back code removed due to merge conflict --- .../General/Array+Utilities.swift | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/SessionUtilitiesKit/General/Array+Utilities.swift b/SessionUtilitiesKit/General/Array+Utilities.swift index 0b4d8b7fd..fc5bfbc6f 100644 --- a/SessionUtilitiesKit/General/Array+Utilities.swift +++ b/SessionUtilitiesKit/General/Array+Utilities.swift @@ -1,11 +1,27 @@ +import Foundation -public extension Array where Element : CustomStringConvertible { - +public extension Array where Element: CustomStringConvertible { var prettifiedDescription: String { return "[ " + map { $0.description }.joined(separator: ", ") + " ]" } } +public extension Array { + func appending(_ other: Element) -> [Element] { + var updatedArray: [Element] = self + updatedArray.append(other) + + return updatedArray + } + + func appending(_ other: [Element]) -> [Element] { + var updatedArray: [Element] = self + updatedArray.append(contentsOf: other) + + return updatedArray + } +} + public extension Array where Element: Hashable { func asSet() -> Set { return Set(self) From 65f14cf0a14d090a50eec7a3560c196da22b5e71 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 8 Mar 2022 16:16:36 +1100 Subject: [PATCH 030/157] Added more unit tests Removed an unused endpoint Moved 'Dependencies' into the Utilities folder (also out from being nested within 'OpenGroupAPI' since it can be broader than that) Finished adding unit tests for the OpenGroupAPI --- Session.xcodeproj/project.pbxproj | 54 +- .../ConversationVC+Interaction.swift | 3 +- .../Open Groups/Models/BatchRequestInfo.swift | 4 +- .../Open Groups/Models/RoomPollInfo.swift | 2 +- .../Open Groups/Models/SOGSMessage.swift | 4 +- .../Models/SendDirectMessageResponse.swift | 2 +- .../Models/UserDeleteMessagesResponse.swift | 2 +- .../Open Groups/OpenGroupAPI.swift | 4 +- .../Open Groups/OpenGroupManager.swift | 22 +- .../Open Groups/Types/Dependencies.swift | 148 -- .../Open Groups/Types/NonceGenerator.swift | 4 - .../Open Groups/Types/SOGSEndpoint.swift | 2 - .../MessageReceiver+Decryption.swift | 2 +- .../MessageSender+Encryption.swift | 2 +- .../Sending & Receiving/MessageSender.swift | 4 +- .../Utilities/ContactUtilities.swift | 4 +- .../Utilities/Data+Utilities.swift | 6 +- .../Utilities/Dependencies.swift | 146 ++ .../Utilities/Promise+Utilities.swift | 4 +- .../Open Groups/Models/RoomPollInfoSpec.swift | 124 ++ .../Open Groups/Models/RoomSpec.swift | 100 ++ .../Open Groups/Models/SOGSMessageSpec.swift | 253 ++++ .../Models/SendDirectMessageRequestSpec.swift | 29 + .../Models/SendMessageRequestSpec.swift | 61 + .../Models/UpdateMessageRequestSpec.swift | 44 + .../Open Groups/OpenGroupAPISpec.swift | 1307 ++++++++++++++++- .../Types/NonceGeneratorSpec.swift | 26 + .../Types/PersonalizationSpec.swift | 23 + .../Open Groups/Types/SOGSEndpointSpec.swift | 67 + .../Open Groups/Types/SOGSErrorSpec.swift | 25 + .../Types/SodiumProtocolsSpec.swift | 47 + .../_TestUtilities/TestStorage.swift | 11 +- 32 files changed, 2339 insertions(+), 197 deletions(-) delete mode 100644 SessionMessagingKit/Open Groups/Types/Dependencies.swift create mode 100644 SessionMessagingKit/Utilities/Dependencies.swift create mode 100644 SessionMessagingKitTests/Open Groups/Models/RoomPollInfoSpec.swift create mode 100644 SessionMessagingKitTests/Open Groups/Models/RoomSpec.swift create mode 100644 SessionMessagingKitTests/Open Groups/Models/SOGSMessageSpec.swift create mode 100644 SessionMessagingKitTests/Open Groups/Models/SendDirectMessageRequestSpec.swift create mode 100644 SessionMessagingKitTests/Open Groups/Models/SendMessageRequestSpec.swift create mode 100644 SessionMessagingKitTests/Open Groups/Models/UpdateMessageRequestSpec.swift create mode 100644 SessionMessagingKitTests/Open Groups/Types/NonceGeneratorSpec.swift create mode 100644 SessionMessagingKitTests/Open Groups/Types/PersonalizationSpec.swift create mode 100644 SessionMessagingKitTests/Open Groups/Types/SOGSEndpointSpec.swift create mode 100644 SessionMessagingKitTests/Open Groups/Types/SOGSErrorSpec.swift create mode 100644 SessionMessagingKitTests/Open Groups/Types/SodiumProtocolsSpec.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 266ed7a9f..0c736aa13 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -805,6 +805,17 @@ FD859F0027C4691300510D0C /* MockDataGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EFF27C4691300510D0C /* MockDataGenerator.swift */; }; FD88BAD927A7439C00BBC442 /* MessageRequestsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */; }; FD88BADB27A750F200BBC442 /* MessageRequestsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */; }; + FDC2908727D7047F005DAE71 /* RoomSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2908627D7047F005DAE71 /* RoomSpec.swift */; }; + FDC2908927D70656005DAE71 /* RoomPollInfoSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2908827D70656005DAE71 /* RoomPollInfoSpec.swift */; }; + FDC2908B27D707F3005DAE71 /* SendMessageRequestSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2908A27D707F3005DAE71 /* SendMessageRequestSpec.swift */; }; + FDC2908D27D70905005DAE71 /* UpdateMessageRequestSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2908C27D70905005DAE71 /* UpdateMessageRequestSpec.swift */; }; + FDC2908F27D70938005DAE71 /* SendDirectMessageRequestSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2908E27D70938005DAE71 /* SendDirectMessageRequestSpec.swift */; }; + FDC2909127D709CA005DAE71 /* SOGSMessageSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909027D709CA005DAE71 /* SOGSMessageSpec.swift */; }; + FDC2909427D710B4005DAE71 /* SOGSEndpointSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909327D710B4005DAE71 /* SOGSEndpointSpec.swift */; }; + FDC2909627D71252005DAE71 /* SOGSErrorSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909527D71252005DAE71 /* SOGSErrorSpec.swift */; }; + FDC2909827D7129B005DAE71 /* PersonalizationSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909727D7129B005DAE71 /* PersonalizationSpec.swift */; }; + FDC2909A27D71376005DAE71 /* NonceGeneratorSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909927D71376005DAE71 /* NonceGeneratorSpec.swift */; }; + FDC2909C27D713D2005DAE71 /* SodiumProtocolsSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909B27D713D2005DAE71 /* SodiumProtocolsSpec.swift */; }; FDC4380927B31D4E00C60D73 /* SOGSError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4380827B31D4E00C60D73 /* SOGSError.swift */; }; FDC4381527B329CE00C60D73 /* NonceGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381427B329CE00C60D73 /* NonceGenerator.swift */; }; FDC4381727B32EC700C60D73 /* Personalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381627B32EC700C60D73 /* Personalization.swift */; }; @@ -1940,6 +1951,17 @@ FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsCell.swift; sourceTree = ""; }; FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsMigration.swift; sourceTree = ""; }; FD9039443F7CB729CF71350E /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig"; sourceTree = ""; }; + FDC2908627D7047F005DAE71 /* RoomSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSpec.swift; sourceTree = ""; }; + FDC2908827D70656005DAE71 /* RoomPollInfoSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPollInfoSpec.swift; sourceTree = ""; }; + FDC2908A27D707F3005DAE71 /* SendMessageRequestSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageRequestSpec.swift; sourceTree = ""; }; + FDC2908C27D70905005DAE71 /* UpdateMessageRequestSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateMessageRequestSpec.swift; sourceTree = ""; }; + FDC2908E27D70938005DAE71 /* SendDirectMessageRequestSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendDirectMessageRequestSpec.swift; sourceTree = ""; }; + FDC2909027D709CA005DAE71 /* SOGSMessageSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSMessageSpec.swift; sourceTree = ""; }; + FDC2909327D710B4005DAE71 /* SOGSEndpointSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSEndpointSpec.swift; sourceTree = ""; }; + FDC2909527D71252005DAE71 /* SOGSErrorSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSErrorSpec.swift; sourceTree = ""; }; + FDC2909727D7129B005DAE71 /* PersonalizationSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalizationSpec.swift; sourceTree = ""; }; + FDC2909927D71376005DAE71 /* NonceGeneratorSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonceGeneratorSpec.swift; sourceTree = ""; }; + FDC2909B27D713D2005DAE71 /* SodiumProtocolsSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SodiumProtocolsSpec.swift; sourceTree = ""; }; FDC4380827B31D4E00C60D73 /* SOGSError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSError.swift; sourceTree = ""; }; FDC4381427B329CE00C60D73 /* NonceGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonceGenerator.swift; sourceTree = ""; }; FDC4381627B32EC700C60D73 /* Personalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Personalization.swift; sourceTree = ""; }; @@ -3397,6 +3419,7 @@ FDC4383D27B4708600C60D73 /* Atomic.swift */, FD83B9A927CF149D005E1583 /* ContactUtilities.swift */, FD859EF127BF6BA200510D0C /* Data+Utilities.swift */, + FDC438C027BB4E6800C60D73 /* Dependencies.swift */, C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */, C37F53E8255BA9BB002AEA92 /* Environment.h */, C37F5402255BA9ED002AEA92 /* Environment.m */, @@ -3865,6 +3888,12 @@ FD83B9C227CF33F7005E1583 /* ServerSpec.swift */, FD83B9C427CF3E2A005E1583 /* OpenGroupSpec.swift */, FD83B9C627CF3F10005E1583 /* CapabilitiesSpec.swift */, + FDC2908627D7047F005DAE71 /* RoomSpec.swift */, + FDC2908827D70656005DAE71 /* RoomPollInfoSpec.swift */, + FDC2908A27D707F3005DAE71 /* SendMessageRequestSpec.swift */, + FDC2908C27D70905005DAE71 /* UpdateMessageRequestSpec.swift */, + FDC2909027D709CA005DAE71 /* SOGSMessageSpec.swift */, + FDC2908E27D70938005DAE71 /* SendDirectMessageRequestSpec.swift */, ); path = Models; sourceTree = ""; @@ -3885,6 +3914,18 @@ path = Views; sourceTree = ""; }; + FDC2909227D710A9005DAE71 /* Types */ = { + isa = PBXGroup; + children = ( + FDC2909327D710B4005DAE71 /* SOGSEndpointSpec.swift */, + FDC2909527D71252005DAE71 /* SOGSErrorSpec.swift */, + FDC2909727D7129B005DAE71 /* PersonalizationSpec.swift */, + FDC2909927D71376005DAE71 /* NonceGeneratorSpec.swift */, + FDC2909B27D713D2005DAE71 /* SodiumProtocolsSpec.swift */, + ); + path = Types; + sourceTree = ""; + }; FDC4380727B31D3A00C60D73 /* Types */ = { isa = PBXGroup; children = ( @@ -3892,7 +3933,6 @@ FDC4380827B31D4E00C60D73 /* SOGSError.swift */, FDC4381627B32EC700C60D73 /* Personalization.swift */, FDC4381427B329CE00C60D73 /* NonceGenerator.swift */, - FDC438C027BB4E6800C60D73 /* Dependencies.swift */, FDC438C227BB512200C60D73 /* SodiumProtocols.swift */, ); path = Types; @@ -3972,6 +4012,7 @@ isa = PBXGroup; children = ( FD83B9C127CF33EE005E1583 /* Models */, + FDC2909227D710A9005DAE71 /* Types */, FDC4389927BA002500C60D73 /* OpenGroupAPISpec.swift */, ); path = "Open Groups"; @@ -5578,16 +5619,27 @@ buildActionMask = 2147483647; files = ( FD859EFA27C2F5C500510D0C /* TestGenericHash.swift in Sources */, + FDC2909427D710B4005DAE71 /* SOGSEndpointSpec.swift in Sources */, + FDC2909127D709CA005DAE71 /* SOGSMessageSpec.swift in Sources */, + FDC2909627D71252005DAE71 /* SOGSErrorSpec.swift in Sources */, + FDC2908727D7047F005DAE71 /* RoomSpec.swift in Sources */, + FDC2909C27D713D2005DAE71 /* SodiumProtocolsSpec.swift in Sources */, FD83B9C327CF33F7005E1583 /* ServerSpec.swift in Sources */, FD83B9C727CF3F10005E1583 /* CapabilitiesSpec.swift in Sources */, + FDC2909A27D71376005DAE71 /* NonceGeneratorSpec.swift in Sources */, FD859EF827C2F58900510D0C /* TestAeadXChaCha20Poly1305Ietf.swift in Sources */, + FDC2909827D7129B005DAE71 /* PersonalizationSpec.swift in Sources */, FD859EF427C2F49200510D0C /* TestSodium.swift in Sources */, FD859EFC27C2F60700510D0C /* TestEd25519.swift in Sources */, FD83B9C027CF2294005E1583 /* TestConstants.swift in Sources */, FDC4389A27BA002500C60D73 /* OpenGroupAPISpec.swift in Sources */, FD83B9C527CF3E2A005E1583 /* OpenGroupSpec.swift in Sources */, + FDC2908B27D707F3005DAE71 /* SendMessageRequestSpec.swift in Sources */, + FDC2908F27D70938005DAE71 /* SendDirectMessageRequestSpec.swift in Sources */, FDC438BD27BB2AB400C60D73 /* Mockable.swift in Sources */, FD859EF627C2F52C00510D0C /* TestSign.swift in Sources */, + FDC2908927D70656005DAE71 /* RoomPollInfoSpec.swift in Sources */, + FDC2908D27D70905005DAE71 /* UpdateMessageRequestSpec.swift in Sources */, FDC4389D27BA01F000C60D73 /* TestStorage.swift in Sources */, FD83B9D227D59495005E1583 /* TestUserDefaults.swift in Sources */, ); diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 5f5769205..d91c984e8 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -813,7 +813,8 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { [weak self] _ in let publicKey = message.authorId guard let openGroup = Storage.shared.getOpenGroup(for: threadID) else { return } - let promise = OpenGroupAPI.userBanAndDeleteAllMessage(publicKey, for: [openGroup.room], on: openGroup.server) + + let promise = OpenGroupAPI.userBanAndDeleteAllMessage(publicKey, from: [openGroup.room], on: openGroup.server) promise.catch(on: DispatchQueue.main) { _ in OWSAlerts.showErrorAlert(message: NSLocalizedString("context_menu_ban_user_error_alert_message", comment: "")) } diff --git a/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift b/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift index 2d961910c..fb3ac4e41 100644 --- a/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift +++ b/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift @@ -143,13 +143,13 @@ protocol BatchRequestInfoType { // MARK: - Convenience public extension Decodable { - static func decoded(from data: Data, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) throws -> Self { + static func decoded(from data: Data, using dependencies: Dependencies = Dependencies()) throws -> Self { return try data.decoded(as: Self.self, using: dependencies) } } extension Promise where T == (OnionRequestResponseInfoType, Data?) { - func decoded(as types: OpenGroupAPI.BatchResponseTypes, on queue: DispatchQueue? = nil, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) -> Promise { + func decoded(as types: OpenGroupAPI.BatchResponseTypes, on queue: DispatchQueue? = nil, using dependencies: Dependencies = Dependencies()) -> Promise { self.map(on: queue) { responseInfo, maybeData -> OpenGroupAPI.BatchResponse in // Need to split the data into an array of data so each item can be Decoded correctly guard let data: Data = maybeData else { throw HTTP.Error.parsingFailed } diff --git a/SessionMessagingKit/Open Groups/Models/RoomPollInfo.swift b/SessionMessagingKit/Open Groups/Models/RoomPollInfo.swift index 9523f5d33..de43a68a6 100644 --- a/SessionMessagingKit/Open Groups/Models/RoomPollInfo.swift +++ b/SessionMessagingKit/Open Groups/Models/RoomPollInfo.swift @@ -108,7 +108,7 @@ extension OpenGroupAPI.RoomPollInfo { defaultWrite: room.defaultWrite, upload: room.upload, defaultUpload: room.defaultUpload, - details: nil + details: room ) } } diff --git a/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift b/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift index c50620960..296bd8467 100644 --- a/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift +++ b/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift @@ -3,7 +3,7 @@ import Foundation extension OpenGroupAPI { - public struct Message: Codable { + public struct Message: Codable, Equatable { enum CodingKeys: String, CodingKey { case id case sender = "session_id" @@ -47,7 +47,7 @@ extension OpenGroupAPI.Message { guard let sender: String = maybeSender, let data = Data(base64Encoded: base64EncodedData), let signature = Data(base64Encoded: base64EncodedSignature) else { throw HTTP.Error.parsingFailed } - guard let dependencies: OpenGroupAPI.Dependencies = decoder.userInfo[OpenGroupAPI.Dependencies.userInfoKey] as? OpenGroupAPI.Dependencies else { + guard let dependencies: Dependencies = decoder.userInfo[Dependencies.userInfoKey] as? Dependencies else { throw HTTP.Error.parsingFailed } diff --git a/SessionMessagingKit/Open Groups/Models/SendDirectMessageResponse.swift b/SessionMessagingKit/Open Groups/Models/SendDirectMessageResponse.swift index 5768b863a..a8e998f8a 100644 --- a/SessionMessagingKit/Open Groups/Models/SendDirectMessageResponse.swift +++ b/SessionMessagingKit/Open Groups/Models/SendDirectMessageResponse.swift @@ -3,7 +3,7 @@ import Foundation extension OpenGroupAPI { - public struct SendDirectMessageResponse: Codable { + public struct SendDirectMessageResponse: Codable, Equatable { enum CodingKeys: String, CodingKey { case id case sender diff --git a/SessionMessagingKit/Open Groups/Models/UserDeleteMessagesResponse.swift b/SessionMessagingKit/Open Groups/Models/UserDeleteMessagesResponse.swift index 9cbde6f7f..5b5d5ac74 100644 --- a/SessionMessagingKit/Open Groups/Models/UserDeleteMessagesResponse.swift +++ b/SessionMessagingKit/Open Groups/Models/UserDeleteMessagesResponse.swift @@ -3,7 +3,7 @@ import Foundation extension OpenGroupAPI { - public struct UserDeleteMessagesResponse: Codable { + public struct UserDeleteMessagesResponse: Codable, Equatable { enum CodingKeys: String, CodingKey { case id case messagesDeleted = "messages_deleted" diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index 4962e1f47..953b14911 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -812,7 +812,7 @@ public enum OpenGroupAPI { /// - dependencies: Injected dependencies (used for unit testing) public static func userDeleteMessages( _ sessionId: String, - for roomTokens: [String]?, + from roomTokens: [String]?, on server: String, using dependencies: Dependencies = Dependencies() ) -> Promise<(OnionRequestResponseInfoType, UserDeleteMessagesResponse)> { @@ -837,7 +837,7 @@ public enum OpenGroupAPI { /// methods for the documented behaviour of each method public static func userBanAndDeleteAllMessage( _ sessionId: String, - for roomTokens: [String]?, + from roomTokens: [String]?, on server: String, using dependencies: Dependencies = Dependencies() ) -> Promise<[OnionRequestResponseInfoType]> { diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index f968234ac..2c1ac5be2 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -18,7 +18,7 @@ public final class OpenGroupManager: NSObject { public var timeSinceLastPoll: [String: TimeInterval] = [:] fileprivate var _timeSinceLastOpen: TimeInterval? - public func getTimeSinceLastOpen(using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) -> TimeInterval { + public func getTimeSinceLastOpen(using dependencies: Dependencies = Dependencies()) -> TimeInterval { if let storedTimeSinceLastOpen: TimeInterval = _timeSinceLastOpen { return storedTimeSinceLastOpen } @@ -67,7 +67,7 @@ public final class OpenGroupManager: NSObject { // MARK: - Adding & Removing - public func add(roomToken: String, server: String, publicKey: String, isConfigMessage: Bool, using transaction: YapDatabaseReadWriteTransaction, dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) -> Promise { + public func add(roomToken: String, server: String, publicKey: String, isConfigMessage: Bool, using transaction: YapDatabaseReadWriteTransaction, dependencies: Dependencies = Dependencies()) -> Promise { // If we are currently polling for this server and already have a TSGroupThread for this room the do nothing let groupId: Data = LKGroupUtilities.getEncodedOpenGroupIDAsData("\(server).\(roomToken)") @@ -163,7 +163,7 @@ public final class OpenGroupManager: NSObject { _ capabilities: OpenGroupAPI.Capabilities, on server: String, using transaction: YapDatabaseReadWriteTransaction, - dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies() + dependencies: Dependencies = Dependencies() ) { let updatedServer: OpenGroupAPI.Server = OpenGroupAPI.Server( name: server, @@ -179,7 +179,7 @@ public final class OpenGroupManager: NSObject { for roomToken: String, on server: String, using transaction: YapDatabaseReadWriteTransaction, - dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies(), + dependencies: Dependencies = Dependencies(), completion: (() -> ())? = nil ) { OpenGroupManager.handlePollInfo( @@ -199,7 +199,7 @@ public final class OpenGroupManager: NSObject { for roomToken: String, on server: String, using transaction: YapDatabaseReadWriteTransaction, - dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies(), + dependencies: Dependencies = Dependencies(), completion: (() -> ())? = nil ) { // Create the open group model and get or create the thread @@ -307,7 +307,7 @@ public final class OpenGroupManager: NSObject { on server: String, isBackgroundPoll: Bool, using transaction: YapDatabaseReadWriteTransaction, - dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies() + dependencies: Dependencies = Dependencies() ) { // Sorting the messages by server ID before importing them fixes an issue where messages // that quote older messages can't find those older messages @@ -371,7 +371,7 @@ public final class OpenGroupManager: NSObject { on server: String, isBackgroundPoll: Bool, using transaction: YapDatabaseReadWriteTransaction, - dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies() + dependencies: Dependencies = Dependencies() ) { // Don't need to do anything if we have no messages (it's a valid case) guard !messages.isEmpty else { return } @@ -476,10 +476,10 @@ public final class OpenGroupManager: NSObject { /// This method specifies if the given publicKey is a moderator or an admin within a specified Open Group @objc(isUserModeratorOrAdmin:forRoom:onServer:) public static func isUserModeratorOrAdmin(_ publicKey: String, for room: String, on server: String) -> Bool { - return isUserModeratorOrAdmin(publicKey, for: room, on: server, using: OpenGroupAPI.Dependencies()) + return isUserModeratorOrAdmin(publicKey, for: room, on: server) } - public static func isUserModeratorOrAdmin(_ publicKey: String, for room: String, on server: String, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) -> Bool { + public static func isUserModeratorOrAdmin(_ publicKey: String, for room: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Bool { let modAndAdminKeys: Set = (OpenGroupManager.shared.cache.moderators[server]?[room] ?? Set()) .union(OpenGroupManager.shared.cache.admins[server]?[room] ?? Set()) @@ -527,7 +527,7 @@ public final class OpenGroupManager: NSObject { } } - public static func getDefaultRoomsIfNeeded(using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) { + public static func getDefaultRoomsIfNeeded(using dependencies: Dependencies = Dependencies()) { // Note: If we already have a 'defaultRoomsPromise' then there is no need to get it again guard OpenGroupManager.shared.cache.defaultRoomsPromise == nil else { return } @@ -572,7 +572,7 @@ public final class OpenGroupManager: NSObject { _ fileId: UInt64, for roomToken: String, on server: String, - using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies() + using dependencies: Dependencies = Dependencies() ) -> Promise { // Normally the image for a given group is stored with the group thread, so it's only // fetched once. However, on the join open group screen we show images for groups the diff --git a/SessionMessagingKit/Open Groups/Types/Dependencies.swift b/SessionMessagingKit/Open Groups/Types/Dependencies.swift deleted file mode 100644 index 9ba55334c..000000000 --- a/SessionMessagingKit/Open Groups/Types/Dependencies.swift +++ /dev/null @@ -1,148 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Sodium -import SessionSnodeKit -import SessionUtilitiesKit - -// MARK: - Dependencies - -extension OpenGroupAPI { - public class Dependencies { - private var _api: OnionRequestAPIType.Type? - public var api: OnionRequestAPIType.Type { - get { getValueSettingIfNull(&_api) { OnionRequestAPI.self } } - set { _api = newValue } - } - - private var _storage: SessionMessagingKitStorageProtocol? - public var storage: SessionMessagingKitStorageProtocol { - get { getValueSettingIfNull(&_storage) { SNMessagingKitConfiguration.shared.storage } } - set { _storage = newValue } - } - - private var _sodium: SodiumType? - public var sodium: SodiumType { - get { getValueSettingIfNull(&_sodium) { Sodium() } } - set { _sodium = newValue } - } - - private var _aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? - public var aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType { - get { getValueSettingIfNull(&_aeadXChaCha20Poly1305Ietf) { sodium.getAeadXChaCha20Poly1305Ietf() } } - set { _aeadXChaCha20Poly1305Ietf = newValue } - } - - private var _sign: SignType? - public var sign: SignType { - get { getValueSettingIfNull(&_sign) { sodium.getSign() } } - set { _sign = newValue } - } - - private var _genericHash: GenericHashType? - public var genericHash: GenericHashType { - get { getValueSettingIfNull(&_genericHash) { sodium.getGenericHash() } } - set { _genericHash = newValue } - } - - private var _ed25519: Ed25519Type.Type? - public var ed25519: Ed25519Type.Type { - get { getValueSettingIfNull(&_ed25519) { Ed25519.self } } - set { _ed25519 = newValue } - } - - private var _nonceGenerator16: NonceGenerator16ByteType? - public var nonceGenerator16: NonceGenerator16ByteType { - get { getValueSettingIfNull(&_nonceGenerator16) { NonceGenerator16Byte() } } - set { _nonceGenerator16 = newValue } - } - - private var _nonceGenerator24: NonceGenerator24ByteType? - public var nonceGenerator24: NonceGenerator24ByteType { - get { getValueSettingIfNull(&_nonceGenerator24) { NonceGenerator24Byte() } } - set { _nonceGenerator24 = newValue } - } - - private var _standardUserDefaults: UserDefaultsType? - public var standardUserDefaults: UserDefaultsType { - get { getValueSettingIfNull(&_standardUserDefaults) { UserDefaults.standard } } - set { _standardUserDefaults = newValue } - } - - private var _date: Date? - public var date: Date { - get { getValueSettingIfNull(&_date) { Date() } } - set { _date = newValue } - } - - // MARK: - Initialization - - public init( - api: OnionRequestAPIType.Type? = nil, - storage: SessionMessagingKitStorageProtocol? = nil, - sodium: SodiumType? = nil, - aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, - sign: SignType? = nil, - genericHash: GenericHashType? = nil, - ed25519: Ed25519Type.Type? = nil, - nonceGenerator16: NonceGenerator16ByteType? = nil, - nonceGenerator24: NonceGenerator24ByteType? = nil, - standardUserDefaults: UserDefaultsType? = nil, - date: Date? = nil - ) { - _api = api - _storage = storage - _sodium = sodium - _aeadXChaCha20Poly1305Ietf = aeadXChaCha20Poly1305Ietf - _sign = sign - _genericHash = genericHash - _ed25519 = ed25519 - _nonceGenerator16 = nonceGenerator16 - _nonceGenerator24 = nonceGenerator24 - _standardUserDefaults = standardUserDefaults - _date = date - } - - // MARK: - Convenience - - public func with( - api: OnionRequestAPIType.Type? = nil, - storage: SessionMessagingKitStorageProtocol? = nil, - sodium: SodiumType? = nil, - aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, - sign: SignType? = nil, - genericHash: GenericHashType? = nil, - ed25519: Ed25519Type.Type? = nil, - nonceGenerator16: NonceGenerator16ByteType? = nil, - nonceGenerator24: NonceGenerator24ByteType? = nil, - standardUserDefaults: UserDefaultsType? = nil, - date: Date? = nil - ) -> Dependencies { - return Dependencies( - api: (api ?? self._api), - storage: (storage ?? self._storage), - sodium: (sodium ?? self._sodium), - aeadXChaCha20Poly1305Ietf: (aeadXChaCha20Poly1305Ietf ?? self._aeadXChaCha20Poly1305Ietf), - sign: (sign ?? self._sign), - genericHash: (genericHash ?? self._genericHash), - ed25519: (ed25519 ?? self._ed25519), - nonceGenerator16: (nonceGenerator16 ?? self._nonceGenerator16), - nonceGenerator24: (nonceGenerator24 ?? self._nonceGenerator24), - standardUserDefaults: (standardUserDefaults ?? self._standardUserDefaults), - date: (date ?? self._date) - ) - } - } -} - -// MARK: - Convenience - -fileprivate func getValueSettingIfNull(_ maybeValue: inout T?, _ valueGenerator: () -> T) -> T { - guard let value: T = maybeValue else { - let value: T = valueGenerator() - maybeValue = value - return value - } - - return value -} diff --git a/SessionMessagingKit/Open Groups/Types/NonceGenerator.swift b/SessionMessagingKit/Open Groups/Types/NonceGenerator.swift index a2ff6aad8..50bcf5db9 100644 --- a/SessionMessagingKit/Open Groups/Types/NonceGenerator.swift +++ b/SessionMessagingKit/Open Groups/Types/NonceGenerator.swift @@ -17,13 +17,9 @@ public protocol NonceGenerator24ByteType { extension OpenGroupAPI { public class NonceGenerator16Byte: NonceGenerator, NonceGenerator16ByteType { public var NonceBytes: Int { 16 } - - public init() {} } public class NonceGenerator24Byte: NonceGenerator, NonceGenerator24ByteType { public var NonceBytes: Int { 24 } - - public init() {} } } diff --git a/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift b/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift index a1b8c9e94..920eb2693 100644 --- a/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift +++ b/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift @@ -49,7 +49,6 @@ extension OpenGroupAPI { case userBan(String) case userUnban(String) - case userPermission(String) case userModerator(String) case userDeleteMessages(String) @@ -114,7 +113,6 @@ extension OpenGroupAPI { case .userBan(let sessionId): return "user/\(sessionId)/ban" case .userUnban(let sessionId): return "user/\(sessionId)/unban" - case .userPermission(let sessionId): return "user/\(sessionId)/permission" case .userModerator(let sessionId): return "user/\(sessionId)/moderator" case .userDeleteMessages(let sessionId): return "user/\(sessionId)/deleteMessages" } diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift index cf5b03e44..1565ed80e 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift @@ -27,7 +27,7 @@ extension MessageReceiver { return (Data(plaintext), SessionId(.standard, publicKey: senderX25519PublicKey).hexString) } - internal static func decryptWithSessionBlindingProtocol(data: Data, isOutgoing: Bool, otherBlindedPublicKey: String, with openGroupPublicKey: String, userEd25519KeyPair: Box.KeyPair, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) throws -> (plaintext: Data, senderX25519PublicKey: String) { + internal static func decryptWithSessionBlindingProtocol(data: Data, isOutgoing: Bool, otherBlindedPublicKey: String, with openGroupPublicKey: String, userEd25519KeyPair: Box.KeyPair, using dependencies: Dependencies = Dependencies()) throws -> (plaintext: Data, senderX25519PublicKey: String) { guard let blindedKeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: openGroupPublicKey, edKeyPair: userEd25519KeyPair, genericHash: dependencies.genericHash) else { throw Error.decryptionFailed } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift index 9d9d236ee..0e37d5e2a 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift @@ -24,7 +24,7 @@ extension MessageSender { return Data(ciphertext) } - internal static func encryptWithSessionBlindingProtocol(_ plaintext: Data, for recipientBlindedId: String, openGroupPublicKey: String, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) throws -> Data { + internal static func encryptWithSessionBlindingProtocol(_ plaintext: Data, for recipientBlindedId: String, openGroupPublicKey: String, using dependencies: Dependencies = Dependencies()) throws -> Data { guard SessionId.Prefix(from: recipientBlindedId) == .blinded else { throw Error.signingFailed } guard let userEd25519KeyPair = SNMessagingKitConfiguration.shared.storage.getUserED25519KeyPair() else { throw Error.noUserED25519KeyPair diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 073a274f6..ff54a524f 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -281,7 +281,7 @@ public final class MessageSender : NSObject { // MARK: - Open Groups - internal static func sendToOpenGroupDestination(_ destination: Message.Destination, message: Message, using transaction: Any, dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) -> Promise { + internal static func sendToOpenGroupDestination(_ destination: Message.Destination, message: Message, using transaction: Any, dependencies: Dependencies = Dependencies()) -> Promise { let (promise, seal) = Promise.pending() let transaction = transaction as! YapDatabaseReadWriteTransaction @@ -410,7 +410,7 @@ public final class MessageSender : NSObject { return promise } - internal static func sendToOpenGroupInboxDestination(_ destination: Message.Destination, message: Message, using transaction: Any, dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) -> Promise { + internal static func sendToOpenGroupInboxDestination(_ destination: Message.Destination, message: Message, using transaction: Any, dependencies: Dependencies = Dependencies()) -> Promise { let (promise, seal) = Promise.pending() let storage = SNMessagingKitConfiguration.shared.storage let transaction = transaction as! YapDatabaseReadWriteTransaction diff --git a/SessionMessagingKit/Utilities/ContactUtilities.swift b/SessionMessagingKit/Utilities/ContactUtilities.swift index 751a55e0a..80a730eb8 100644 --- a/SessionMessagingKit/Utilities/ContactUtilities.swift +++ b/SessionMessagingKit/Utilities/ContactUtilities.swift @@ -49,7 +49,7 @@ public enum ContactUtilities { } } - public static func mapping(for blindedId: String, serverPublicKey: String, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) -> BlindedIdMapping? { + public static func mapping(for blindedId: String, serverPublicKey: String, using dependencies: Dependencies = Dependencies()) -> BlindedIdMapping? { var result: BlindedIdMapping? Storage.write { transaction in @@ -59,7 +59,7 @@ public enum ContactUtilities { return result } - public static func mapping(for blindedId: String, serverPublicKey: String, using transaction: YapDatabaseReadWriteTransaction, dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) -> BlindedIdMapping? { + public static func mapping(for blindedId: String, serverPublicKey: String, using transaction: YapDatabaseReadWriteTransaction, dependencies: Dependencies = Dependencies()) -> BlindedIdMapping? { // Unfortunately the whole point of id-blinding is to make it hard to reverse-engineer a standard // sessionId, as a result in order to see if there is an unblinded contact for this blindedId we // can only really generate blinded ids for each contact and check if any match diff --git a/SessionMessagingKit/Utilities/Data+Utilities.swift b/SessionMessagingKit/Utilities/Data+Utilities.swift index 47a0622ad..b99c50da0 100644 --- a/SessionMessagingKit/Utilities/Data+Utilities.swift +++ b/SessionMessagingKit/Utilities/Data+Utilities.swift @@ -4,15 +4,15 @@ import Foundation // MARK: - Decoding -extension OpenGroupAPI.Dependencies { +extension Dependencies { static let userInfoKey: CodingUserInfoKey = CodingUserInfoKey(rawValue: "io.oxen.dependencies.codingOptions")! } public extension Data { - func decoded(as type: T.Type, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) throws -> T { + func decoded(as type: T.Type, using dependencies: Dependencies = Dependencies()) throws -> T { do { let decoder: JSONDecoder = JSONDecoder() - decoder.userInfo = [ OpenGroupAPI.Dependencies.userInfoKey: dependencies ] + decoder.userInfo = [ Dependencies.userInfoKey: dependencies ] return try decoder.decode(type, from: self) } diff --git a/SessionMessagingKit/Utilities/Dependencies.swift b/SessionMessagingKit/Utilities/Dependencies.swift new file mode 100644 index 000000000..583a2ec9e --- /dev/null +++ b/SessionMessagingKit/Utilities/Dependencies.swift @@ -0,0 +1,146 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium +import SessionSnodeKit +import SessionUtilitiesKit + +// MARK: - Dependencies + +public class Dependencies { + private var _api: OnionRequestAPIType.Type? + public var api: OnionRequestAPIType.Type { + get { getValueSettingIfNull(&_api) { OnionRequestAPI.self } } + set { _api = newValue } + } + + private var _storage: SessionMessagingKitStorageProtocol? + public var storage: SessionMessagingKitStorageProtocol { + get { getValueSettingIfNull(&_storage) { SNMessagingKitConfiguration.shared.storage } } + set { _storage = newValue } + } + + private var _sodium: SodiumType? + public var sodium: SodiumType { + get { getValueSettingIfNull(&_sodium) { Sodium() } } + set { _sodium = newValue } + } + + private var _aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? + public var aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType { + get { getValueSettingIfNull(&_aeadXChaCha20Poly1305Ietf) { sodium.getAeadXChaCha20Poly1305Ietf() } } + set { _aeadXChaCha20Poly1305Ietf = newValue } + } + + private var _sign: SignType? + public var sign: SignType { + get { getValueSettingIfNull(&_sign) { sodium.getSign() } } + set { _sign = newValue } + } + + private var _genericHash: GenericHashType? + public var genericHash: GenericHashType { + get { getValueSettingIfNull(&_genericHash) { sodium.getGenericHash() } } + set { _genericHash = newValue } + } + + private var _ed25519: Ed25519Type.Type? + public var ed25519: Ed25519Type.Type { + get { getValueSettingIfNull(&_ed25519) { Ed25519.self } } + set { _ed25519 = newValue } + } + + private var _nonceGenerator16: NonceGenerator16ByteType? + public var nonceGenerator16: NonceGenerator16ByteType { + get { getValueSettingIfNull(&_nonceGenerator16) { OpenGroupAPI.NonceGenerator16Byte() } } + set { _nonceGenerator16 = newValue } + } + + private var _nonceGenerator24: NonceGenerator24ByteType? + public var nonceGenerator24: NonceGenerator24ByteType { + get { getValueSettingIfNull(&_nonceGenerator24) { OpenGroupAPI.NonceGenerator24Byte() } } + set { _nonceGenerator24 = newValue } + } + + private var _standardUserDefaults: UserDefaultsType? + public var standardUserDefaults: UserDefaultsType { + get { getValueSettingIfNull(&_standardUserDefaults) { UserDefaults.standard } } + set { _standardUserDefaults = newValue } + } + + private var _date: Date? + public var date: Date { + get { getValueSettingIfNull(&_date) { Date() } } + set { _date = newValue } + } + + // MARK: - Initialization + + public init( + api: OnionRequestAPIType.Type? = nil, + storage: SessionMessagingKitStorageProtocol? = nil, + sodium: SodiumType? = nil, + aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, + sign: SignType? = nil, + genericHash: GenericHashType? = nil, + ed25519: Ed25519Type.Type? = nil, + nonceGenerator16: NonceGenerator16ByteType? = nil, + nonceGenerator24: NonceGenerator24ByteType? = nil, + standardUserDefaults: UserDefaultsType? = nil, + date: Date? = nil + ) { + _api = api + _storage = storage + _sodium = sodium + _aeadXChaCha20Poly1305Ietf = aeadXChaCha20Poly1305Ietf + _sign = sign + _genericHash = genericHash + _ed25519 = ed25519 + _nonceGenerator16 = nonceGenerator16 + _nonceGenerator24 = nonceGenerator24 + _standardUserDefaults = standardUserDefaults + _date = date + } + + // MARK: - Convenience + + public func with( + api: OnionRequestAPIType.Type? = nil, + storage: SessionMessagingKitStorageProtocol? = nil, + sodium: SodiumType? = nil, + aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, + sign: SignType? = nil, + genericHash: GenericHashType? = nil, + ed25519: Ed25519Type.Type? = nil, + nonceGenerator16: NonceGenerator16ByteType? = nil, + nonceGenerator24: NonceGenerator24ByteType? = nil, + standardUserDefaults: UserDefaultsType? = nil, + date: Date? = nil + ) -> Dependencies { + return Dependencies( + api: (api ?? self._api), + storage: (storage ?? self._storage), + sodium: (sodium ?? self._sodium), + aeadXChaCha20Poly1305Ietf: (aeadXChaCha20Poly1305Ietf ?? self._aeadXChaCha20Poly1305Ietf), + sign: (sign ?? self._sign), + genericHash: (genericHash ?? self._genericHash), + ed25519: (ed25519 ?? self._ed25519), + nonceGenerator16: (nonceGenerator16 ?? self._nonceGenerator16), + nonceGenerator24: (nonceGenerator24 ?? self._nonceGenerator24), + standardUserDefaults: (standardUserDefaults ?? self._standardUserDefaults), + date: (date ?? self._date) + ) + } +} + +// MARK: - Convenience + +fileprivate func getValueSettingIfNull(_ maybeValue: inout T?, _ valueGenerator: () -> T) -> T { + guard let value: T = maybeValue else { + let value: T = valueGenerator() + maybeValue = value + return value + } + + return value +} diff --git a/SessionMessagingKit/Utilities/Promise+Utilities.swift b/SessionMessagingKit/Utilities/Promise+Utilities.swift index 2bbcb78f5..cd72cd3c3 100644 --- a/SessionMessagingKit/Utilities/Promise+Utilities.swift +++ b/SessionMessagingKit/Utilities/Promise+Utilities.swift @@ -5,7 +5,7 @@ import PromiseKit import SessionSnodeKit extension Promise where T == Data { - func decoded(as type: R.Type, on queue: DispatchQueue? = nil, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) -> Promise { + func decoded(as type: R.Type, on queue: DispatchQueue? = nil, using dependencies: Dependencies = Dependencies()) -> Promise { self.map(on: queue) { data -> R in try data.decoded(as: type, using: dependencies) } @@ -13,7 +13,7 @@ extension Promise where T == Data { } extension Promise where T == (OnionRequestResponseInfoType, Data?) { - func decoded(as type: R.Type, on queue: DispatchQueue? = nil, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) -> Promise<(OnionRequestResponseInfoType, R)> { + func decoded(as type: R.Type, on queue: DispatchQueue? = nil, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, R)> { self.map(on: queue) { responseInfo, maybeData -> (OnionRequestResponseInfoType, R) in guard let data: Data = maybeData else { throw HTTP.Error.parsingFailed } diff --git a/SessionMessagingKitTests/Open Groups/Models/RoomPollInfoSpec.swift b/SessionMessagingKitTests/Open Groups/Models/RoomPollInfoSpec.swift new file mode 100644 index 000000000..b58227f43 --- /dev/null +++ b/SessionMessagingKitTests/Open Groups/Models/RoomPollInfoSpec.swift @@ -0,0 +1,124 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class RoomPollInfoSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + describe("a RoomPollInfo") { + context("when initializing with a room") { + it("copies all the relevant values across") { + let room: OpenGroupAPI.Room = OpenGroupAPI.Room( + token: "testToken", + name: "testName", + description: nil, + infoUpdates: 123, + messageSequence: 0, + created: 0, + activeUsers: 234, + activeUsersCutoff: 0, + imageId: nil, + pinnedMessages: nil, + admin: true, + globalAdmin: true, + admins: [], + hiddenAdmins: nil, + moderator: true, + globalModerator: true, + moderators: [], + hiddenModerators: nil, + read: true, + defaultRead: true, + defaultAccessible: true, + write: true, + defaultWrite: true, + upload: true, + defaultUpload: true + ) + let roomPollInfo: OpenGroupAPI.RoomPollInfo = OpenGroupAPI.RoomPollInfo(room: room) + + expect(roomPollInfo.token).to(equal(room.token)) + expect(roomPollInfo.activeUsers).to(equal(room.activeUsers)) + expect(roomPollInfo.admin).to(equal(room.admin)) + expect(roomPollInfo.globalAdmin).to(equal(room.globalAdmin)) + expect(roomPollInfo.moderator).to(equal(room.moderator)) + expect(roomPollInfo.globalModerator).to(equal(room.globalModerator)) + expect(roomPollInfo.read).to(equal(room.read)) + expect(roomPollInfo.defaultRead).to(equal(room.defaultRead)) + expect(roomPollInfo.defaultAccessible).to(equal(room.defaultAccessible)) + expect(roomPollInfo.write).to(equal(room.write)) + expect(roomPollInfo.defaultWrite).to(equal(room.defaultWrite)) + expect(roomPollInfo.upload).to(equal(room.upload)) + expect(roomPollInfo.defaultUpload).to(equal(room.defaultUpload)) + expect(roomPollInfo.details).to(equal(room)) + } + } + + context("when decoding") { + it("defaults admin and moderator values to false if omitted") { + let roomPollInfoJson: String = """ + { + "token": "testToken", + "active_users": 0, + + "read": true, + "default_read": true, + "default_accessible": true, + "write": true, + "default_write": true, + "upload": true, + "default_upload": true, + + "details": null + } + """ + let roomData: Data = roomPollInfoJson.data(using: .utf8)! + let result: OpenGroupAPI.RoomPollInfo = try! JSONDecoder().decode(OpenGroupAPI.RoomPollInfo.self, from: roomData) + + expect(result.admin).to(beFalse()) + expect(result.globalAdmin).to(beFalse()) + expect(result.moderator).to(beFalse()) + expect(result.globalModerator).to(beFalse()) + } + + it("sets the admin and moderator values when provided") { + let roomPollInfoJson: String = """ + { + "token": "testToken", + "active_users": 0, + + "admin": true, + "global_admin": true, + + "moderator": true, + "global_moderator": true, + + "read": true, + "default_read": true, + "default_accessible": true, + "write": true, + "default_write": true, + "upload": true, + "default_upload": true, + + "details": null + } + """ + let roomData: Data = roomPollInfoJson.data(using: .utf8)! + let result: OpenGroupAPI.RoomPollInfo = try! JSONDecoder().decode(OpenGroupAPI.RoomPollInfo.self, from: roomData) + + expect(result.admin).to(beTrue()) + expect(result.globalAdmin).to(beTrue()) + expect(result.moderator).to(beTrue()) + expect(result.globalModerator).to(beTrue()) + } + } + } + } +} diff --git a/SessionMessagingKitTests/Open Groups/Models/RoomSpec.swift b/SessionMessagingKitTests/Open Groups/Models/RoomSpec.swift new file mode 100644 index 000000000..16a3ab84b --- /dev/null +++ b/SessionMessagingKitTests/Open Groups/Models/RoomSpec.swift @@ -0,0 +1,100 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class RoomSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + describe("a Room") { + context("when decoding") { + it("defaults admin and moderator values to false if omitted") { + let roomJson: String = """ + { + "token": "testToken", + "name": "testName", + "description": "testDescription", + "info_updates": 0, + "message_sequence": 0, + "created": 1, + + "active_users": 0, + "active_users_cutoff": 0, + "image_id": 0, + "pinned_messages": [], + + "admins": [], + "hidden_admins": [], + + "moderators": [], + "hidden_moderators": [], + + "read": true, + "default_read": true, + "default_accessible": true, + "write": true, + "default_write": true, + "upload": true, + "default_upload": true + } + """ + let roomData: Data = roomJson.data(using: .utf8)! + let result: OpenGroupAPI.Room = try! JSONDecoder().decode(OpenGroupAPI.Room.self, from: roomData) + + expect(result.admin).to(beFalse()) + expect(result.globalAdmin).to(beFalse()) + expect(result.moderator).to(beFalse()) + expect(result.globalModerator).to(beFalse()) + } + + it("sets the admin and moderator values when provided") { + let roomJson: String = """ + { + "token": "testToken", + "name": "testName", + "description": "testDescription", + "info_updates": 0, + "message_sequence": 0, + "created": 1, + + "active_users": 0, + "active_users_cutoff": 0, + "image_id": 0, + "pinned_messages": [], + + "admin": true, + "global_admin": true, + "admins": [], + "hidden_admins": [], + + "moderator": true, + "global_moderator": true, + "moderators": [], + "hidden_moderators": [], + + "read": true, + "default_read": true, + "default_accessible": true, + "write": true, + "default_write": true, + "upload": true, + "default_upload": true + } + """ + let roomData: Data = roomJson.data(using: .utf8)! + let result: OpenGroupAPI.Room = try! JSONDecoder().decode(OpenGroupAPI.Room.self, from: roomData) + + expect(result.admin).to(beTrue()) + expect(result.globalAdmin).to(beTrue()) + expect(result.moderator).to(beTrue()) + expect(result.globalModerator).to(beTrue()) + } + } + } + } +} diff --git a/SessionMessagingKitTests/Open Groups/Models/SOGSMessageSpec.swift b/SessionMessagingKitTests/Open Groups/Models/SOGSMessageSpec.swift new file mode 100644 index 000000000..75c8ed404 --- /dev/null +++ b/SessionMessagingKitTests/Open Groups/Models/SOGSMessageSpec.swift @@ -0,0 +1,253 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class SOGSMessageSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + describe("a SOGSMessage") { + var messageJson: String! + var messageData: Data! + var decoder: JSONDecoder! + var testSign: TestSign! + var dependencies: Dependencies! + + beforeEach { + messageJson = """ + { + "id": 123, + "session_id": "05\(TestConstants.publicKey)", + "posted": 234, + "seqno": 345, + "whisper": false, + "whisper_mods": false, + + "data": "VGVzdERhdGE=", + "signature": "VGVzdERhdGE=" + } + """ + messageData = messageJson.data(using: .utf8)! + testSign = TestSign() + dependencies = Dependencies( + sign: testSign, + ed25519: TestEd25519.self + ) + decoder = JSONDecoder() + decoder.userInfo = [ Dependencies.userInfoKey: dependencies as Any ] + } + + context("when decoding") { + it("defaults the whisper values to false") { + messageJson = """ + { + "id": 123, + "posted": 234, + "seqno": 345 + } + """ + messageData = messageJson.data(using: .utf8)! + let result: OpenGroupAPI.Message? = try? decoder.decode(OpenGroupAPI.Message.self, from: messageData) + + expect(result).toNot(beNil()) + expect(result?.whisper).to(beFalse()) + expect(result?.whisperMods).to(beFalse()) + } + + context("and there is no content") { + it("does not need a sender") { + messageJson = """ + { + "id": 123, + "posted": 234, + "seqno": 345, + "whisper": false, + "whisper_mods": false + } + """ + messageData = messageJson.data(using: .utf8)! + let result: OpenGroupAPI.Message? = try? decoder.decode(OpenGroupAPI.Message.self, from: messageData) + + expect(result).toNot(beNil()) + expect(result?.sender).to(beNil()) + expect(result?.base64EncodedData).to(beNil()) + expect(result?.base64EncodedSignature).to(beNil()) + } + } + + context("and there is content") { + it("errors if there is no sender") { + messageJson = """ + { + "id": 123, + "posted": 234, + "seqno": 345, + "whisper": false, + "whisper_mods": false, + + "data": "VGVzdERhdGE=", + "signature": "VGVzdERhdGE=" + } + """ + messageData = messageJson.data(using: .utf8)! + + expect { + try decoder.decode(OpenGroupAPI.Message.self, from: messageData) + } + .to(throwError(HTTP.Error.parsingFailed)) + } + + it("errors if the data is not a base64 encoded string") { + messageJson = """ + { + "id": 123, + "session_id": "05\(TestConstants.publicKey)", + "posted": 234, + "seqno": 345, + "whisper": false, + "whisper_mods": false, + + "data": "Test!!!", + "signature": "VGVzdERhdGE=" + } + """ + messageData = messageJson.data(using: .utf8)! + + expect { + try decoder.decode(OpenGroupAPI.Message.self, from: messageData) + } + .to(throwError(HTTP.Error.parsingFailed)) + } + + it("errors if the signature is not a base64 encoded string") { + messageJson = """ + { + "id": 123, + "session_id": "05\(TestConstants.publicKey)", + "posted": 234, + "seqno": 345, + "whisper": false, + "whisper_mods": false, + + "data": "VGVzdERhdGE=", + "signature": "Test!!!" + } + """ + messageData = messageJson.data(using: .utf8)! + + expect { + try decoder.decode(OpenGroupAPI.Message.self, from: messageData) + } + .to(throwError(HTTP.Error.parsingFailed)) + } + + it("errors if the dependencies are not provided to the JSONDecoder") { + decoder = JSONDecoder() + + expect { + try decoder.decode(OpenGroupAPI.Message.self, from: messageData) + } + .to(throwError(HTTP.Error.parsingFailed)) + } + + it("errors if the session_id value is not valid") { + messageJson = """ + { + "id": 123, + "session_id": "TestId", + "posted": 234, + "seqno": 345, + "whisper": false, + "whisper_mods": false, + + "data": "VGVzdERhdGE=", + "signature": "VGVzdERhdGE=" + } + """ + messageData = messageJson.data(using: .utf8)! + + expect { + try decoder.decode(OpenGroupAPI.Message.self, from: messageData) + } + .to(throwError(HTTP.Error.parsingFailed)) + } + + + context("that is blinded") { + beforeEach { + messageJson = """ + { + "id": 123, + "session_id": "15\(TestConstants.publicKey)", + "posted": 234, + "seqno": 345, + "whisper": false, + "whisper_mods": false, + + "data": "VGVzdERhdGE=", + "signature": "VGVzdERhdGE=" + } + """ + messageData = messageJson.data(using: .utf8)! + } + + it("succeeds if it succeeds verification") { + testSign.mockData[.verify] = true + + expect { + try decoder.decode(OpenGroupAPI.Message.self, from: messageData) + } + .toNot(beNil()) + } + + it("throws if it fails verification") { + testSign.mockData[.verify] = false + + expect { + try decoder.decode(OpenGroupAPI.Message.self, from: messageData) + } + .to(throwError(HTTP.Error.parsingFailed)) + } + } + + context("that is unblinded") { + it("succeeds if it succeeds verification") { + TestEd25519.mockData[ + .verifySignature( + signature: Data(base64Encoded: "VGVzdERhdGE=")!, + publicKey: Data(hex: TestConstants.publicKey), + data: Data(base64Encoded: "VGVzdERhdGE=")! + ) + ] = true + + expect { + try decoder.decode(OpenGroupAPI.Message.self, from: messageData) + } + .toNot(beNil()) + } + + it("throws if it fails verification") { + TestEd25519.mockData[ + .verifySignature( + signature: Data(base64Encoded: "VGVzdERhdGE=")!, + publicKey: Data(hex: TestConstants.publicKey), + data: Data(base64Encoded: "VGVzdERhdGE=")! + ) + ] = false + + expect { + try decoder.decode(OpenGroupAPI.Message.self, from: messageData) + } + .to(throwError(HTTP.Error.parsingFailed)) + } + } + } + } + } + } +} diff --git a/SessionMessagingKitTests/Open Groups/Models/SendDirectMessageRequestSpec.swift b/SessionMessagingKitTests/Open Groups/Models/SendDirectMessageRequestSpec.swift new file mode 100644 index 000000000..228176a15 --- /dev/null +++ b/SessionMessagingKitTests/Open Groups/Models/SendDirectMessageRequestSpec.swift @@ -0,0 +1,29 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class SendDirectMessageRequestSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + describe("a SendDirectMessageRequest") { + context("when encoding") { + it("encodes the data as a base64 string") { + let request: OpenGroupAPI.SendDirectMessageRequest = OpenGroupAPI.SendDirectMessageRequest( + message: "TestData".data(using: .utf8)! + ) + let requestData: Data = try! JSONEncoder().encode(request) + let requestDataString: String = String(data: requestData, encoding: .utf8)! + + expect(requestDataString).toNot(contain("TestData")) + expect(requestDataString).to(contain("VGVzdERhdGE=")) + } + } + } + } +} diff --git a/SessionMessagingKitTests/Open Groups/Models/SendMessageRequestSpec.swift b/SessionMessagingKitTests/Open Groups/Models/SendMessageRequestSpec.swift new file mode 100644 index 000000000..7fd3554a3 --- /dev/null +++ b/SessionMessagingKitTests/Open Groups/Models/SendMessageRequestSpec.swift @@ -0,0 +1,61 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class SendMessageRequestSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + describe("a SendMessageRequest") { + context("when initializing") { + it("defaults the optional values to nil") { + let request: OpenGroupAPI.SendMessageRequest = OpenGroupAPI.SendMessageRequest( + data: "TestData".data(using: .utf8)!, + signature: "TestSignature".data(using: .utf8)! + ) + + expect(request.whisperTo).to(beNil()) + expect(request.whisperMods).to(beNil()) + expect(request.fileIds).to(beNil()) + } + } + + context("when encoding") { + it("encodes the data as a base64 string") { + let request: OpenGroupAPI.SendMessageRequest = OpenGroupAPI.SendMessageRequest( + data: "TestData".data(using: .utf8)!, + signature: "TestSignature".data(using: .utf8)!, + whisperTo: nil, + whisperMods: nil, + fileIds: nil + ) + let requestData: Data = try! JSONEncoder().encode(request) + let requestDataString: String = String(data: requestData, encoding: .utf8)! + + expect(requestDataString).toNot(contain("TestData")) + expect(requestDataString).to(contain("VGVzdERhdGE=")) + } + + it("encodes the signature as a base64 string") { + let request: OpenGroupAPI.SendMessageRequest = OpenGroupAPI.SendMessageRequest( + data: "TestData".data(using: .utf8)!, + signature: "TestSignature".data(using: .utf8)!, + whisperTo: nil, + whisperMods: nil, + fileIds: nil + ) + let requestData: Data = try! JSONEncoder().encode(request) + let requestDataString: String = String(data: requestData, encoding: .utf8)! + + expect(requestDataString).toNot(contain("TestSignature")) + expect(requestDataString).to(contain("VGVzdFNpZ25hdHVyZQ==")) + } + } + } + } +} diff --git a/SessionMessagingKitTests/Open Groups/Models/UpdateMessageRequestSpec.swift b/SessionMessagingKitTests/Open Groups/Models/UpdateMessageRequestSpec.swift new file mode 100644 index 000000000..f63b2e16c --- /dev/null +++ b/SessionMessagingKitTests/Open Groups/Models/UpdateMessageRequestSpec.swift @@ -0,0 +1,44 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class UpdateMessageRequestSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + describe("a UpdateMessageRequest") { + context("when encoding") { + it("encodes the data as a base64 string") { + let request: OpenGroupAPI.UpdateMessageRequest = OpenGroupAPI.UpdateMessageRequest( + data: "TestData".data(using: .utf8)!, + signature: "TestSignature".data(using: .utf8)!, + fileIds: nil + ) + let requestData: Data = try! JSONEncoder().encode(request) + let requestDataString: String = String(data: requestData, encoding: .utf8)! + + expect(requestDataString).toNot(contain("TestData")) + expect(requestDataString).to(contain("VGVzdERhdGE=")) + } + + it("encodes the signature as a base64 string") { + let request: OpenGroupAPI.UpdateMessageRequest = OpenGroupAPI.UpdateMessageRequest( + data: "TestData".data(using: .utf8)!, + signature: "TestSignature".data(using: .utf8)!, + fileIds: nil + ) + let requestData: Data = try! JSONEncoder().encode(request) + let requestDataString: String = String(data: requestData, encoding: .utf8)! + + expect(requestDataString).toNot(contain("TestSignature")) + expect(requestDataString).to(contain("VGVzdFNpZ25hdHVyZQ==")) + } + } + } + } +} diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift index 661c0a154..3f0c0c5d6 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift @@ -84,7 +84,7 @@ class OpenGroupAPISpec: QuickSpec { var testGenericHash: TestGenericHash! var testSign: TestSign! var testUserDefaults: TestUserDefaults! - var dependencies: OpenGroupAPI.Dependencies! + var dependencies: Dependencies! var response: (OnionRequestResponseInfoType, Codable)? = nil var pollResponse: [OpenGroupAPI.Endpoint: (OnionRequestResponseInfoType, Codable?)]? @@ -100,7 +100,7 @@ class OpenGroupAPISpec: QuickSpec { testGenericHash = TestGenericHash() testSign = TestSign() testUserDefaults = TestUserDefaults() - dependencies = OpenGroupAPI.Dependencies( + dependencies = Dependencies( api: TestApi.self, storage: testStorage, sodium: testSodium, @@ -1003,9 +1003,605 @@ class OpenGroupAPISpec: QuickSpec { } } + // MARK: - Messages + + context("when sending messages") { + var messageData: OpenGroupAPI.Message! + + beforeEach { + class LocalTestApi: TestApi { + static let data: OpenGroupAPI.Message = OpenGroupAPI.Message( + id: 126, + sender: "testSender", + posted: 321, + edited: nil, + seqNo: 10, + whisper: false, + whisperMods: false, + whisperTo: nil, + base64EncodedData: nil, + base64EncodedSignature: nil + ) + + override class var mockResponse: Data? { return try! JSONEncoder().encode(data) } + } + messageData = LocalTestApi.data + dependencies = dependencies.with(api: LocalTestApi.self) + + testStorage.mockData[.userEdKeyPair] = Box.KeyPair(publicKey: [], secretKey: []) + } + + afterEach { + messageData = nil + } + + it("correctly sends the message") { + var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? + + OpenGroupAPI + .send( + "test".data(using: .utf8)!, + to: "testRoom", + on: "testServer", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate the response data + expect(response?.data).to(equal(messageData)) + + // Validate signature headers + let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData + expect(requestData?.httpMethod).to(equal("POST")) + expect(requestData?.server).to(equal("testServer")) + expect(requestData?.urlString).to(equal("testServer/room/testRoom/message")) + } + + it("saves the received message timestamp to the database in milliseconds") { + var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? + + OpenGroupAPI + .send( + "test".data(using: .utf8)!, + to: "testRoom", + on: "testServer", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + expect(testStorage.mockData[.receivedMessageTimestamp] as? UInt64).to(equal(321000)) + } + + context("when unblinded") { + beforeEach { + testStorage.mockData[.openGroupServer] = OpenGroupAPI.Server( + name: "testServer", + capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: []) + ) + } + + it("signs the message correctly") { + var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? + + OpenGroupAPI + .send( + "test".data(using: .utf8)!, + to: "testRoom", + on: "testServer", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate request body + let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData + let requestBody: OpenGroupAPI.SendMessageRequest = try! JSONDecoder().decode(OpenGroupAPI.SendMessageRequest.self, from: requestData!.body!) + + expect(requestBody.data).to(equal("test".data(using: .utf8))) + expect(requestBody.signature).to(equal("TestSignature".data(using: .utf8))) + } + + it("fails to sign if there is no public key") { + testStorage.mockData[.openGroupPublicKeys] = [:] + + var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? + + OpenGroupAPI + .send( + "test".data(using: .utf8)!, + to: "testRoom", + on: "testServer", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + } + + context("when blinded") { + beforeEach { + testStorage.mockData[.openGroupServer] = OpenGroupAPI.Server( + name: "testServer", + capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs, .blind], missing: []) + ) + } + + it("signs the message correctly") { + var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? + + OpenGroupAPI + .send( + "test".data(using: .utf8)!, + to: "testRoom", + on: "testServer", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate request body + let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData + let requestBody: OpenGroupAPI.SendMessageRequest = try! JSONDecoder().decode(OpenGroupAPI.SendMessageRequest.self, from: requestData!.body!) + + expect(requestBody.data).to(equal("test".data(using: .utf8))) + expect(requestBody.signature).to(equal("TestSogsSignature".data(using: .utf8))) + } + + it("fails to sign if there is no public key") { + testStorage.mockData[.openGroupPublicKeys] = [:] + + var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? + + OpenGroupAPI + .send( + "test".data(using: .utf8)!, + to: "testRoom", + on: "testServer", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + } + } + + context("when getting an individual message") { + it("generates the request and handles the response correctly") { + class LocalTestApi: TestApi { + static let data: OpenGroupAPI.Message = OpenGroupAPI.Message( + id: 126, + sender: "testSender", + posted: 321, + edited: nil, + seqNo: 10, + whisper: false, + whisperMods: false, + whisperTo: nil, + base64EncodedData: nil, + base64EncodedSignature: nil + ) + + override class var mockResponse: Data? { return try! JSONEncoder().encode(data) } + } + dependencies = dependencies.with(api: LocalTestApi.self) + + var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? + + OpenGroupAPI.message(123, in: "testRoom", on: "testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate the response data + expect(response?.data).to(equal(LocalTestApi.data)) + + // Validate request data + let requestData: TestApi.RequestData? = (response?.info as? TestResponseInfo)?.requestData + expect(requestData?.httpMethod).to(equal("GET")) + expect(requestData?.server).to(equal("testServer")) + expect(requestData?.urlString).to(equal("testServer/room/testRoom/message/123")) + } + } + + context("when updating a message") { + beforeEach { + class LocalTestApi: TestApi { + override class var mockResponse: Data? { return Data() } + } + dependencies = dependencies.with(api: LocalTestApi.self) + + testStorage.mockData[.userEdKeyPair] = Box.KeyPair(publicKey: [], secretKey: []) + } + + it("correctly sends the update") { + var response: (info: OnionRequestResponseInfoType, data: Data?)? + + OpenGroupAPI + .messageUpdate( + 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testServer", + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate signature headers + let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData + expect(requestData?.httpMethod).to(equal("PUT")) + expect(requestData?.server).to(equal("testServer")) + expect(requestData?.urlString).to(equal("testServer/room/testRoom/message/123")) + } + + context("when unblinded") { + beforeEach { + testStorage.mockData[.openGroupServer] = OpenGroupAPI.Server( + name: "testServer", + capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: []) + ) + } + + it("signs the message correctly") { + var response: (info: OnionRequestResponseInfoType, data: Data?)? + + OpenGroupAPI + .messageUpdate( + 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testServer", + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate request body + let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData + let requestBody: OpenGroupAPI.UpdateMessageRequest = try! JSONDecoder().decode(OpenGroupAPI.UpdateMessageRequest.self, from: requestData!.body!) + + expect(requestBody.data).to(equal("test".data(using: .utf8))) + expect(requestBody.signature).to(equal("TestSignature".data(using: .utf8))) + } + + it("fails to sign if there is no public key") { + testStorage.mockData[.openGroupPublicKeys] = [:] + + var response: (info: OnionRequestResponseInfoType, data: Data?)? + + OpenGroupAPI + .messageUpdate( + 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testServer", + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + } + + context("when blinded") { + beforeEach { + testStorage.mockData[.openGroupServer] = OpenGroupAPI.Server( + name: "testServer", + capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs, .blind], missing: []) + ) + } + + it("signs the message correctly") { + var response: (info: OnionRequestResponseInfoType, data: Data?)? + + OpenGroupAPI + .messageUpdate( + 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testServer", + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate request body + let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData + let requestBody: OpenGroupAPI.UpdateMessageRequest = try! JSONDecoder().decode(OpenGroupAPI.UpdateMessageRequest.self, from: requestData!.body!) + + expect(requestBody.data).to(equal("test".data(using: .utf8))) + expect(requestBody.signature).to(equal("TestSogsSignature".data(using: .utf8))) + } + + it("fails to sign if there is no public key") { + testStorage.mockData[.openGroupPublicKeys] = [:] + + var response: (info: OnionRequestResponseInfoType, data: Data?)? + + OpenGroupAPI + .messageUpdate( + 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testServer", + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + } + } + + context("when deleting a message") { + it("generates the request and handles the response correctly") { + class LocalTestApi: TestApi { + override class var mockResponse: Data? { return Data() } + } + dependencies = dependencies.with(api: LocalTestApi.self) + + var response: (info: OnionRequestResponseInfoType, data: Data?)? + + OpenGroupAPI.messageDelete(123, in: "testRoom", on: "testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate request data + let requestData: TestApi.RequestData? = (response?.info as? TestResponseInfo)?.requestData + expect(requestData?.httpMethod).to(equal("DELETE")) + expect(requestData?.server).to(equal("testServer")) + expect(requestData?.urlString).to(equal("testServer/room/testRoom/message/123")) + } + } + + // MARK: - Pinning + + context("when pinning a message") { + it("generates the request and handles the response correctly") { + class LocalTestApi: TestApi { + override class var mockResponse: Data? { return Data() } + } + dependencies = dependencies.with(api: LocalTestApi.self) + + var response: OnionRequestResponseInfoType? + + OpenGroupAPI.pinMessage(id: 123, in: "testRoom", on: "testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate request data + let requestData: TestApi.RequestData? = (response as? TestResponseInfo)?.requestData + expect(requestData?.httpMethod).to(equal("POST")) + expect(requestData?.server).to(equal("testServer")) + expect(requestData?.urlString).to(equal("testServer/room/testRoom/pin/123")) + } + } + + context("when unpinning a message") { + it("generates the request and handles the response correctly") { + class LocalTestApi: TestApi { + override class var mockResponse: Data? { return Data() } + } + dependencies = dependencies.with(api: LocalTestApi.self) + + var response: OnionRequestResponseInfoType? + + OpenGroupAPI.unpinMessage(id: 123, in: "testRoom", on: "testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate request data + let requestData: TestApi.RequestData? = (response as? TestResponseInfo)?.requestData + expect(requestData?.httpMethod).to(equal("POST")) + expect(requestData?.server).to(equal("testServer")) + expect(requestData?.urlString).to(equal("testServer/room/testRoom/unpin/123")) + } + } + + context("when unpinning all messages") { + it("generates the request and handles the response correctly") { + class LocalTestApi: TestApi { + override class var mockResponse: Data? { return Data() } + } + dependencies = dependencies.with(api: LocalTestApi.self) + + var response: OnionRequestResponseInfoType? + + OpenGroupAPI.unpinAll(in: "testRoom", on: "testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate request data + let requestData: TestApi.RequestData? = (response as? TestResponseInfo)?.requestData + expect(requestData?.httpMethod).to(equal("POST")) + expect(requestData?.server).to(equal("testServer")) + expect(requestData?.urlString).to(equal("testServer/room/testRoom/unpin/all")) + } + } + // MARK: - Files context("when uploading files") { + it("generates the request and handles the response correctly") { + class LocalTestApi: TestApi { + override class var mockResponse: Data? { + return try! JSONEncoder().encode(FileUploadResponse(id: "1")) + } + } + dependencies = dependencies.with(api: LocalTestApi.self) + + OpenGroupAPI.uploadFile([], to: "testRoom", on: "testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate signature headers + let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData + expect(requestData?.httpMethod).to(equal("POST")) + expect(requestData?.server).to(equal("testServer")) + expect(requestData?.urlString).to(equal("testServer/room/testRoom/file")) + } + it("doesn't add a fileName to the content-disposition header when not provided") { class LocalTestApi: TestApi { override class var mockResponse: Data? { @@ -1028,9 +1624,6 @@ class OpenGroupAPISpec: QuickSpec { // Validate signature headers let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData - expect(requestData?.urlString).to(equal("testServer/room/testRoom/file")) - expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.headers).to(haveCount(6)) expect(requestData?.headers[Header.contentDisposition.rawValue]) .toNot(contain("filename")) } @@ -1057,13 +1650,694 @@ class OpenGroupAPISpec: QuickSpec { // Validate signature headers let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData - expect(requestData?.urlString).to(equal("testServer/room/testRoom/file")) - expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.headers).to(haveCount(6)) expect(requestData?.headers[Header.contentDisposition.rawValue]).to(contain("TestFileName")) } } + context("when downloading files") { + it("generates the request and handles the response correctly") { + class LocalTestApi: TestApi { + override class var mockResponse: Data? { + return Data() + } + } + dependencies = dependencies.with(api: LocalTestApi.self) + + OpenGroupAPI.downloadFile(1, from: "testRoom", on: "testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate signature headers + let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData + expect(requestData?.httpMethod).to(equal("GET")) + expect(requestData?.server).to(equal("testServer")) + expect(requestData?.urlString).to(equal("testServer/room/testRoom/file/1")) + } + } + + // MARK: - Inbox/Outbox (Message Requests) + + context("when sending message requests") { + var messageData: OpenGroupAPI.SendDirectMessageResponse! + + beforeEach { + class LocalTestApi: TestApi { + static let data: OpenGroupAPI.SendDirectMessageResponse = OpenGroupAPI.SendDirectMessageResponse( + id: 126, + sender: "testSender", + recipient: "testRecipient", + posted: 321, + expires: 456 + ) + + override class var mockResponse: Data? { return try! JSONEncoder().encode(data) } + } + messageData = LocalTestApi.data + dependencies = dependencies.with(api: LocalTestApi.self) + } + + afterEach { + messageData = nil + } + + it("correctly sends the message request") { + var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.SendDirectMessageResponse)? + + OpenGroupAPI + .send( + "test".data(using: .utf8)!, + toInboxFor: "testUserId", + on: "testServer", + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate the response data + expect(response?.data).to(equal(messageData)) + + // Validate signature headers + let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData + expect(requestData?.httpMethod).to(equal("POST")) + expect(requestData?.server).to(equal("testServer")) + expect(requestData?.urlString).to(equal("testServer/inbox/testUserId")) + } + + it("saves the received message timestamp to the database in milliseconds") { + var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.SendDirectMessageResponse)? + + OpenGroupAPI + .send( + "test".data(using: .utf8)!, + toInboxFor: "testUserId", + on: "testServer", + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + expect(testStorage.mockData[.receivedMessageTimestamp] as? UInt64).to(equal(321000)) + } + } + + // MARK: - Users + + context("when banning a user") { + var response: (info: OnionRequestResponseInfoType, data: Data?)? + + beforeEach { + class LocalTestApi: TestApi { + override class var mockResponse: Data? { return Data() } + } + dependencies = dependencies.with(api: LocalTestApi.self) + } + + afterEach { + response = nil + } + + it("generates the request and handles the response correctly") { + OpenGroupAPI + .userBan( + "testUserId", + for: nil, + from: nil, + on: "testServer", + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate request data + let requestData: TestApi.RequestData? = (response?.info as? TestResponseInfo)?.requestData + expect(requestData?.httpMethod).to(equal("POST")) + expect(requestData?.server).to(equal("testServer")) + expect(requestData?.urlString).to(equal("testServer/user/testUserId/ban")) + } + + it("does a global ban if no room tokens are provided") { + OpenGroupAPI + .userBan( + "testUserId", + for: nil, + from: nil, + on: "testServer", + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate request data + let requestData: TestApi.RequestData? = (response?.info as? TestResponseInfo)?.requestData + let requestBody: OpenGroupAPI.UserBanRequest = try! JSONDecoder().decode(OpenGroupAPI.UserBanRequest.self, from: requestData!.body!) + + expect(requestBody.global).to(beTrue()) + expect(requestBody.rooms).to(beNil()) + } + + it("does room specific bans if room tokens are provided") { + OpenGroupAPI + .userBan( + "testUserId", + for: nil, + from: ["testRoom"], + on: "testServer", + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate request data + let requestData: TestApi.RequestData? = (response?.info as? TestResponseInfo)?.requestData + let requestBody: OpenGroupAPI.UserBanRequest = try! JSONDecoder().decode(OpenGroupAPI.UserBanRequest.self, from: requestData!.body!) + + expect(requestBody.global).to(beNil()) + expect(requestBody.rooms).to(equal(["testRoom"])) + } + } + + context("when unbanning a user") { + var response: (info: OnionRequestResponseInfoType, data: Data?)? + + beforeEach { + class LocalTestApi: TestApi { + override class var mockResponse: Data? { return Data() } + } + dependencies = dependencies.with(api: LocalTestApi.self) + } + + afterEach { + response = nil + } + + it("generates the request and handles the response correctly") { + OpenGroupAPI + .userUnban( + "testUserId", + from: nil, + on: "testServer", + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate request data + let requestData: TestApi.RequestData? = (response?.info as? TestResponseInfo)?.requestData + expect(requestData?.httpMethod).to(equal("POST")) + expect(requestData?.server).to(equal("testServer")) + expect(requestData?.urlString).to(equal("testServer/user/testUserId/unban")) + } + + it("does a global ban if no room tokens are provided") { + OpenGroupAPI + .userUnban( + "testUserId", + from: nil, + on: "testServer", + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate request data + let requestData: TestApi.RequestData? = (response?.info as? TestResponseInfo)?.requestData + let requestBody: OpenGroupAPI.UserUnbanRequest = try! JSONDecoder().decode(OpenGroupAPI.UserUnbanRequest.self, from: requestData!.body!) + + expect(requestBody.global).to(beTrue()) + expect(requestBody.rooms).to(beNil()) + } + + it("does room specific bans if room tokens are provided") { + OpenGroupAPI + .userUnban( + "testUserId", + from: ["testRoom"], + on: "testServer", + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate request data + let requestData: TestApi.RequestData? = (response?.info as? TestResponseInfo)?.requestData + let requestBody: OpenGroupAPI.UserUnbanRequest = try! JSONDecoder().decode(OpenGroupAPI.UserUnbanRequest.self, from: requestData!.body!) + + expect(requestBody.global).to(beNil()) + expect(requestBody.rooms).to(equal(["testRoom"])) + } + } + + context("when updating a users permissions") { + var response: (info: OnionRequestResponseInfoType, data: Data?)? + + beforeEach { + class LocalTestApi: TestApi { + override class var mockResponse: Data? { return Data() } + } + dependencies = dependencies.with(api: LocalTestApi.self) + } + + afterEach { + response = nil + } + + it("generates the request and handles the response correctly") { + OpenGroupAPI + .userModeratorUpdate( + "testUserId", + moderator: true, + admin: nil, + visible: true, + for: nil, + on: "testServer", + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate request data + let requestData: TestApi.RequestData? = (response?.info as? TestResponseInfo)?.requestData + expect(requestData?.httpMethod).to(equal("POST")) + expect(requestData?.server).to(equal("testServer")) + expect(requestData?.urlString).to(equal("testServer/user/testUserId/moderator")) + } + + it("does a global update if no room tokens are provided") { + OpenGroupAPI + .userModeratorUpdate( + "testUserId", + moderator: true, + admin: nil, + visible: true, + for: nil, + on: "testServer", + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate request data + let requestData: TestApi.RequestData? = (response?.info as? TestResponseInfo)?.requestData + let requestBody: OpenGroupAPI.UserModeratorRequest = try! JSONDecoder().decode(OpenGroupAPI.UserModeratorRequest.self, from: requestData!.body!) + + expect(requestBody.global).to(beTrue()) + expect(requestBody.rooms).to(beNil()) + } + + it("does room specific updates if room tokens are provided") { + OpenGroupAPI + .userModeratorUpdate( + "testUserId", + moderator: true, + admin: nil, + visible: true, + for: ["testRoom"], + on: "testServer", + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate request data + let requestData: TestApi.RequestData? = (response?.info as? TestResponseInfo)?.requestData + let requestBody: OpenGroupAPI.UserModeratorRequest = try! JSONDecoder().decode(OpenGroupAPI.UserModeratorRequest.self, from: requestData!.body!) + + expect(requestBody.global).to(beNil()) + expect(requestBody.rooms).to(equal(["testRoom"])) + } + + it("fails if neither moderator or admin are set") { + OpenGroupAPI + .userModeratorUpdate( + "testUserId", + moderator: nil, + admin: nil, + visible: true, + for: nil, + on: "testServer", + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(HTTP.Error.generic.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + } + + context("when deleting a users messages") { + var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.UserDeleteMessagesResponse)? + var messageData: OpenGroupAPI.UserDeleteMessagesResponse! + + beforeEach { + class LocalTestApi: TestApi { + static let data: OpenGroupAPI.UserDeleteMessagesResponse = OpenGroupAPI.UserDeleteMessagesResponse( + id: "testId", + messagesDeleted: 10 + ) + + override class var mockResponse: Data? { return try! JSONEncoder().encode(data) } + } + messageData = LocalTestApi.data + dependencies = dependencies.with(api: LocalTestApi.self) + } + + afterEach { + response = nil + } + + it("generates the request and handles the response correctly") { + OpenGroupAPI + .userDeleteMessages( + "testUserId", + from: nil, + on: "testServer", + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate the response data + expect(response?.data).to(equal(messageData)) + + // Validate request data + let requestData: TestApi.RequestData? = (response?.info as? TestResponseInfo)?.requestData + expect(requestData?.httpMethod).to(equal("POST")) + expect(requestData?.server).to(equal("testServer")) + expect(requestData?.urlString).to(equal("testServer/user/testUserId/deleteMessages")) + } + + it("does a global delete if no room tokens are provided") { + OpenGroupAPI + .userDeleteMessages( + "testUserId", + from: nil, + on: "testServer", + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate request data + let requestData: TestApi.RequestData? = (response?.info as? TestResponseInfo)?.requestData + let requestBody: OpenGroupAPI.UserDeleteMessagesRequest = try! JSONDecoder().decode(OpenGroupAPI.UserDeleteMessagesRequest.self, from: requestData!.body!) + + expect(requestBody.global).to(beTrue()) + expect(requestBody.rooms).to(beNil()) + } + + it("does room specific bans if room tokens are provided") { + OpenGroupAPI + .userDeleteMessages( + "testUserId", + from: ["testRoom"], + on: "testServer", + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate request data + let requestData: TestApi.RequestData? = (response?.info as? TestResponseInfo)?.requestData + let requestBody: OpenGroupAPI.UserDeleteMessagesRequest = try! JSONDecoder().decode(OpenGroupAPI.UserDeleteMessagesRequest.self, from: requestData!.body!) + + expect(requestBody.global).to(beNil()) + expect(requestBody.rooms).to(equal(["testRoom"])) + } + } + + context("when banning and deleting all messages for a user") { + var response: [OnionRequestResponseInfoType]? + + beforeEach { + class LocalTestApi: TestApi { + static let deleteMessagesData: OpenGroupAPI.UserDeleteMessagesResponse = OpenGroupAPI.UserDeleteMessagesResponse( + id: "123", + messagesDeleted: 10 + ) + + override class var mockResponse: Data? { + let responses: [Data] = [ + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: nil, + failedToParseBody: false + ) + ), + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: deleteMessagesData, + failedToParseBody: false + ) + ) + ] + + return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) + } + } + dependencies = dependencies.with(api: LocalTestApi.self) + } + + afterEach { + response = nil + } + + it("generates the request and handles the response correctly") { + OpenGroupAPI + .userBanAndDeleteAllMessage( + "testUserId", + from: nil, + on: "testServer", + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate request data + let requestData: TestApi.RequestData? = (response?.first as? TestResponseInfo)?.requestData + expect(requestData?.httpMethod).to(equal("POST")) + expect(requestData?.server).to(equal("testServer")) + expect(requestData?.urlString).to(equal("testServer/sequence")) + } + + it("does a global ban and delete if no room tokens are provided") { + OpenGroupAPI + .userBanAndDeleteAllMessage( + "testUserId", + from: nil, + on: "testServer", + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate request data + let requestData: TestApi.RequestData? = (response?.first as? TestResponseInfo)?.requestData + let jsonObject: Any = try! JSONSerialization.jsonObject( + with: requestData!.body!, + options: [.fragmentsAllowed] + ) + let anyArray: [Any] = jsonObject as! [Any] + let dataArray: [Data] = anyArray.compactMap { + try! JSONSerialization.data(withJSONObject: ($0 as! [String: Any])["json"]!) + } + let firstRequestBody: OpenGroupAPI.UserBanRequest = try! JSONDecoder() + .decode(OpenGroupAPI.UserBanRequest.self, from: dataArray.first!) + let lastRequestBody: OpenGroupAPI.UserDeleteMessagesRequest = try! JSONDecoder() + .decode(OpenGroupAPI.UserDeleteMessagesRequest.self, from: dataArray.last!) + + expect(firstRequestBody.global).to(beTrue()) + expect(firstRequestBody.rooms).to(beNil()) + expect(lastRequestBody.global).to(beTrue()) + expect(lastRequestBody.rooms).to(beNil()) + } + + it("does room specific bans and deletes if room tokens are provided") { + OpenGroupAPI + .userBanAndDeleteAllMessage( + "testUserId", + from: ["testRoom"], + on: "testServer", + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate request data + let requestData: TestApi.RequestData? = (response?.first as? TestResponseInfo)?.requestData + let jsonObject: Any = try! JSONSerialization.jsonObject( + with: requestData!.body!, + options: [.fragmentsAllowed] + ) + let anyArray: [Any] = jsonObject as! [Any] + let dataArray: [Data] = anyArray.compactMap { + try! JSONSerialization.data(withJSONObject: ($0 as! [String: Any])["json"]!) + } + let firstRequestBody: OpenGroupAPI.UserBanRequest = try! JSONDecoder() + .decode(OpenGroupAPI.UserBanRequest.self, from: dataArray.first!) + let lastRequestBody: OpenGroupAPI.UserDeleteMessagesRequest = try! JSONDecoder() + .decode(OpenGroupAPI.UserDeleteMessagesRequest.self, from: dataArray.last!) + + expect(firstRequestBody.global).to(beNil()) + expect(firstRequestBody.rooms).to(equal(["testRoom"])) + expect(lastRequestBody.global).to(beNil()) + expect(lastRequestBody.rooms).to(equal(["testRoom"])) + } + } + // MARK: - Authentication context("when signing") { @@ -1111,6 +2385,23 @@ class OpenGroupAPISpec: QuickSpec { expect(response).to(beNil()) } + it("fails when the serverPublicKey is not a hex string") { + testStorage.mockData[.openGroupPublicKeys] = ["testServer": "TestString!!!"] + + OpenGroupAPI.rooms(for: "testServer", using: dependencies) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + context("when unblinded") { beforeEach { testStorage.mockData[.openGroupServer] = OpenGroupAPI.Server( diff --git a/SessionMessagingKitTests/Open Groups/Types/NonceGeneratorSpec.swift b/SessionMessagingKitTests/Open Groups/Types/NonceGeneratorSpec.swift new file mode 100644 index 000000000..b7db2898f --- /dev/null +++ b/SessionMessagingKitTests/Open Groups/Types/NonceGeneratorSpec.swift @@ -0,0 +1,26 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class NonceGeneratorSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + describe("a NonceGenerator16Byte") { + it("has the correct number of bytes") { + expect(OpenGroupAPI.NonceGenerator16Byte().NonceBytes).to(equal(16)) + } + } + + describe("a NonceGenerator24Byte") { + it("has the correct number of bytes") { + expect(OpenGroupAPI.NonceGenerator24Byte().NonceBytes).to(equal(24)) + } + } + } +} diff --git a/SessionMessagingKitTests/Open Groups/Types/PersonalizationSpec.swift b/SessionMessagingKitTests/Open Groups/Types/PersonalizationSpec.swift new file mode 100644 index 000000000..f82ccaed0 --- /dev/null +++ b/SessionMessagingKitTests/Open Groups/Types/PersonalizationSpec.swift @@ -0,0 +1,23 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class PersonalizationSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + describe("a Personalization") { + it("generates bytes correctly") { + expect(OpenGroupAPI.Personalization.sharedKeys.bytes) + .to(equal([115, 111, 103, 115, 46, 115, 104, 97, 114, 101, 100, 95, 107, 101, 121, 115])) + expect(OpenGroupAPI.Personalization.authHeader.bytes) + .to(equal([115, 111, 103, 115, 46, 97, 117, 116, 104, 95, 104, 101, 97, 100, 101, 114])) + } + } + } +} diff --git a/SessionMessagingKitTests/Open Groups/Types/SOGSEndpointSpec.swift b/SessionMessagingKitTests/Open Groups/Types/SOGSEndpointSpec.swift new file mode 100644 index 000000000..733ca8ef8 --- /dev/null +++ b/SessionMessagingKitTests/Open Groups/Types/SOGSEndpointSpec.swift @@ -0,0 +1,67 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class SOGSEndpointSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + describe("a SOGSEndpoint") { + it("generates the path value correctly") { + // Utility + + expect(OpenGroupAPI.Endpoint.onion.path).to(equal("oxen/v4/lsrpc")) + expect(OpenGroupAPI.Endpoint.batch.path).to(equal("batch")) + expect(OpenGroupAPI.Endpoint.sequence.path).to(equal("sequence")) + expect(OpenGroupAPI.Endpoint.capabilities.path).to(equal("capabilities")) + + // Rooms + + expect(OpenGroupAPI.Endpoint.rooms.path).to(equal("rooms")) + expect(OpenGroupAPI.Endpoint.room("test").path).to(equal("room/test")) + expect(OpenGroupAPI.Endpoint.roomPollInfo("test", 123).path).to(equal("room/test/pollInfo/123")) + + // Messages + + expect(OpenGroupAPI.Endpoint.roomMessage("test").path).to(equal("room/test/message")) + expect(OpenGroupAPI.Endpoint.roomMessageIndividual("test", id: 123).path).to(equal("room/test/message/123")) + expect(OpenGroupAPI.Endpoint.roomMessagesRecent("test").path).to(equal("room/test/messages/recent")) + expect(OpenGroupAPI.Endpoint.roomMessagesBefore("test", id: 123).path).to(equal("room/test/messages/before/123")) + expect(OpenGroupAPI.Endpoint.roomMessagesSince("test", seqNo: 123).path) + .to(equal("room/test/messages/since/123")) + + // Pinning + + expect(OpenGroupAPI.Endpoint.roomPinMessage("test", id: 123).path).to(equal("room/test/pin/123")) + expect(OpenGroupAPI.Endpoint.roomUnpinMessage("test", id: 123).path).to(equal("room/test/unpin/123")) + expect(OpenGroupAPI.Endpoint.roomUnpinAll("test").path).to(equal("room/test/unpin/all")) + + // Files + + expect(OpenGroupAPI.Endpoint.roomFile("test").path).to(equal("room/test/file")) + expect(OpenGroupAPI.Endpoint.roomFileIndividual("test", 123).path).to(equal("room/test/file/123")) + + // Inbox/Outbox (Message Requests) + + expect(OpenGroupAPI.Endpoint.inbox.path).to(equal("inbox")) + expect(OpenGroupAPI.Endpoint.inboxSince(id: 123).path).to(equal("inbox/since/123")) + expect(OpenGroupAPI.Endpoint.inboxFor(sessionId: "test").path).to(equal("inbox/test")) + + expect(OpenGroupAPI.Endpoint.outbox.path).to(equal("outbox")) + expect(OpenGroupAPI.Endpoint.outboxSince(id: 123).path).to(equal("outbox/since/123")) + + // Users + + expect(OpenGroupAPI.Endpoint.userBan("test").path).to(equal("user/test/ban")) + expect(OpenGroupAPI.Endpoint.userUnban("test").path).to(equal("user/test/unban")) + expect(OpenGroupAPI.Endpoint.userModerator("test").path).to(equal("user/test/moderator")) + expect(OpenGroupAPI.Endpoint.userDeleteMessages("test").path).to(equal("user/test/deleteMessages")) + } + } + } +} diff --git a/SessionMessagingKitTests/Open Groups/Types/SOGSErrorSpec.swift b/SessionMessagingKitTests/Open Groups/Types/SOGSErrorSpec.swift new file mode 100644 index 000000000..f637692b5 --- /dev/null +++ b/SessionMessagingKitTests/Open Groups/Types/SOGSErrorSpec.swift @@ -0,0 +1,25 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class SOGSErrorSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + describe("a SOGSError") { + it("generates the error description correctly") { + expect(OpenGroupAPI.Error.decryptionFailed.errorDescription) + .to(equal("Couldn't decrypt response.")) + expect(OpenGroupAPI.Error.signingFailed.errorDescription) + .to(equal("Couldn't sign message.")) + expect(OpenGroupAPI.Error.noPublicKey.errorDescription) + .to(equal("Couldn't find server public key.")) + } + } + } +} diff --git a/SessionMessagingKitTests/Open Groups/Types/SodiumProtocolsSpec.swift b/SessionMessagingKitTests/Open Groups/Types/SodiumProtocolsSpec.swift new file mode 100644 index 000000000..d746e40e5 --- /dev/null +++ b/SessionMessagingKitTests/Open Groups/Types/SodiumProtocolsSpec.swift @@ -0,0 +1,47 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class SodiumProtocolsSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + describe("an AeadXChaCha20Poly1305IetfType") { + let testValue: [UInt8] = [1, 2, 3] + + it("provides the default values in it's extensions") { + let testAead: TestAeadXChaCha20Poly1305Ietf = TestAeadXChaCha20Poly1305Ietf() + testAead.mockData[.encrypt] = testValue + testAead.mockData[.decrypt] = testValue + + expect(testAead.encrypt(message: [], secretKey: [], nonce: [])).to(equal(testValue)) + expect(testAead.encrypt(message: [], secretKey: [], nonce: [], additionalData: nil)).to(equal(testValue)) + expect(testAead.decrypt(authenticatedCipherText: [], secretKey: [], nonce: [])).to(equal(testValue)) + expect(testAead.decrypt(authenticatedCipherText: [], secretKey: [], nonce: [], additionalData: nil)) + .to(equal(testValue)) + } + } + + describe("a GenericHashType") { + let testValue: [UInt8] = [1, 2, 3] + + it("provides the default values in it's extensions") { + let testGenericHash: TestGenericHash = TestGenericHash() + testGenericHash.mockData[.hash] = testValue + testGenericHash.mockData[.hashSaltPersonal] = testValue + + expect(testGenericHash.hash(message: [])).to(equal(testValue)) + expect(testGenericHash.hash(message: [], key: nil)).to(equal(testValue)) + expect(testGenericHash.hashSaltPersonal(message: [], outputLength: 0, salt: [], personal: [])) + .to(equal(testValue)) + expect(testGenericHash.hashSaltPersonal(message: [], outputLength: 0, key: nil, salt: [], personal: [])) + .to(equal(testValue)) + } + } + } +} diff --git a/SessionMessagingKitTests/_TestUtilities/TestStorage.swift b/SessionMessagingKitTests/_TestUtilities/TestStorage.swift index 3424a7749..b5d71b3b5 100644 --- a/SessionMessagingKitTests/_TestUtilities/TestStorage.swift +++ b/SessionMessagingKitTests/_TestUtilities/TestStorage.swift @@ -21,6 +21,7 @@ class TestStorage: SessionMessagingKitStorageProtocol, Mockable { case openGroupSequenceNumber case openGroupInboxLatestMessageId case openGroupOutboxLatestMessageId + case receivedMessageTimestamp } typealias Key = DataKey @@ -176,8 +177,14 @@ class TestStorage: SessionMessagingKitStorageProtocol, Mockable { func getAllMessageRequestThreads() -> [String: TSContactThread] { return [:] } func getAllMessageRequestThreads(using transaction: YapDatabaseReadTransaction) -> [String: TSContactThread] { return [:] } - func getReceivedMessageTimestamps(using transaction: Any) -> [UInt64] { return [] } - func addReceivedMessageTimestamp(_ timestamp: UInt64, using transaction: Any) {} + func getReceivedMessageTimestamps(using transaction: Any) -> [UInt64] { + return ((mockData[.receivedMessageTimestamp] as? UInt64).map { [$0] } ?? []) + } + + func addReceivedMessageTimestamp(_ timestamp: UInt64, using transaction: Any) { + mockData[.receivedMessageTimestamp] = timestamp + } + func getOrCreateThread(for publicKey: String, groupPublicKey: String?, openGroupID: String?, using transaction: Any) -> String? { return nil } func persist(_ message: VisibleMessage, quotedMessage: TSQuotedMessage?, linkPreview: OWSLinkPreview?, groupPublicKey: String?, openGroupID: String?, using transaction: Any) -> String? { return nil } func persist(_ attachments: [VisibleMessage.Attachment], using transaction: Any) -> [String] { return [] } From 96338eacdaaae42641d3a334b994fdf78119a990 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 8 Mar 2022 17:12:12 +1100 Subject: [PATCH 031/157] Removed an incorrect dependency and fixed a bug from the last commit --- Session.xcodeproj/project.pbxproj | 27 ------------------- .../Open Groups/OpenGroupManager.swift | 2 +- 2 files changed, 1 insertion(+), 28 deletions(-) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 0c736aa13..afb9ebdb8 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -981,13 +981,6 @@ remoteGlobalIDString = C3C2A678255388CC00C340D1; remoteInfo = SessionUtilitiesKit; }; - FDC4386E27B4E90300C60D73 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = D221A080169C9E5E00537ABF /* Project object */; - proxyType = 1; - remoteGlobalIDString = C3C2A678255388CC00C340D1; - remoteInfo = SessionUtilitiesKit; - }; FDC4389327B9FFC700C60D73 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = D221A080169C9E5E00537ABF /* Project object */; @@ -995,13 +988,6 @@ remoteGlobalIDString = C3C2A6EF25539DE700C340D1; remoteInfo = SessionMessagingKit; }; - FDC438A027BA2B8A00C60D73 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = D221A080169C9E5E00537ABF /* Project object */; - proxyType = 1; - remoteGlobalIDString = C3C2A678255388CC00C340D1; - remoteInfo = SessionUtilitiesKit; - }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -4343,8 +4329,6 @@ buildRules = ( ); dependencies = ( - FDC4386F27B4E90300C60D73 /* PBXTargetDependency */, - FDC438A127BA2B8A00C60D73 /* PBXTargetDependency */, ); name = SessionMessagingKit; productName = SessionMessagingKit; @@ -5734,23 +5718,12 @@ target = C3C2A678255388CC00C340D1 /* SessionUtilitiesKit */; targetProxy = FD83B9B427CF200A005E1583 /* PBXContainerItemProxy */; }; - FDC4386F27B4E90300C60D73 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = C3C2A678255388CC00C340D1 /* SessionUtilitiesKit */; - targetProxy = FDC4386E27B4E90300C60D73 /* PBXContainerItemProxy */; - }; FDC4389427B9FFC700C60D73 /* PBXTargetDependency */ = { isa = PBXTargetDependency; platformFilter = ios; target = C3C2A6EF25539DE700C340D1 /* SessionMessagingKit */; targetProxy = FDC4389327B9FFC700C60D73 /* PBXContainerItemProxy */; }; - FDC438A127BA2B8A00C60D73 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - platformFilter = ios; - target = C3C2A678255388CC00C340D1 /* SessionUtilitiesKit */; - targetProxy = FDC438A027BA2B8A00C60D73 /* PBXContainerItemProxy */; - }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 2c1ac5be2..58850f33a 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -476,7 +476,7 @@ public final class OpenGroupManager: NSObject { /// This method specifies if the given publicKey is a moderator or an admin within a specified Open Group @objc(isUserModeratorOrAdmin:forRoom:onServer:) public static func isUserModeratorOrAdmin(_ publicKey: String, for room: String, on server: String) -> Bool { - return isUserModeratorOrAdmin(publicKey, for: room, on: server) + return isUserModeratorOrAdmin(publicKey, for: room, on: server, using: Dependencies()) } public static func isUserModeratorOrAdmin(_ publicKey: String, for room: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Bool { From a39afd603784ae4c44872c076b60e67ebd34192f Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 9 Mar 2022 14:04:18 +1100 Subject: [PATCH 032/157] Fixed build errors and mock data tweaks Fixed a couple of merge build errors Made some tweaks to the MockDataGenerator to more-properly create open groups Added some progress logging to the MockDataGenerator Updated the MockDataGenerator to support generating threads with a fixed number of messages (ie. to compare performance based on message count) --- .../ConversationVC+Interaction.swift | 14 +- Session/Utilities/MockDataGenerator.swift | 139 ++++++++++++++---- .../NotificationServiceExtension.swift | 4 +- 3 files changed, 125 insertions(+), 32 deletions(-) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 882595827..c489ce3fc 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -495,14 +495,20 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc // interacted with old UI which had cached the old value) // // By using an equality check on the interaction we avoid this odd behaviour - guard let index = viewItems.firstIndex(where: { $0.interaction == viewItem.interaction }), + guard + let index = viewItems.firstIndex(where: { $0.interaction == viewItem.interaction }), let cell = messagesTableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell, - let snapshot = cell.bubbleView.snapshotView(afterScreenUpdates: false), contextMenuWindow == nil, - !ContextMenuVC.actions(for: viewItem, delegate: self).isEmpty else { return } + let snapshot = cell.bubbleView.snapshotView(afterScreenUpdates: false), + contextMenuWindow == nil, + !ContextMenuVC.actions(for: viewItem, delegate: self).isEmpty, + let keyWindow: UIWindow = UIApplication.shared.keyWindow + else { + return + } // Show the context menu if applicable UIImpactFeedbackGenerator(style: .heavy).impactOccurred() - let frame = cell.convert(cell.bubbleView.frame, to: UIApplication.shared.keyWindow!) + let frame = cell.convert(cell.bubbleView.frame, to: keyWindow) let window = ContextMenuWindow() let contextMenuVC = ContextMenuVC(snapshot: snapshot, viewItem: viewItem, frame: frame, delegate: self) { [weak self] in window.isHidden = true diff --git a/Session/Utilities/MockDataGenerator.swift b/Session/Utilities/MockDataGenerator.swift index dc5e2fcab..3c7fe65f1 100644 --- a/Session/Utilities/MockDataGenerator.swift +++ b/Session/Utilities/MockDataGenerator.swift @@ -66,15 +66,23 @@ enum MockDataGenerator { } } + // MARK: - Generation + + static var printProgress: Bool = false + static var hasStartedGenerationThisRun: Bool = false + static func generateMockData() { // Don't re-generate the mock data if it already exists var existingMockDataThread: TSContactThread? - + Storage.read { transaction in existingMockDataThread = TSContactThread.getWithContactSessionID("MockDatabaseThread", transaction: transaction) } - guard existingMockDataThread == nil else { return } + guard !hasStartedGenerationThisRun && existingMockDataThread == nil else { + hasStartedGenerationThisRun = true + return + } /// The mock data generation is quite slow, there are 3 parts which take a decent amount of time (deleting the account afterwards will also take a long time): /// Generating the threads & content - ~3s per 100 @@ -83,33 +91,48 @@ enum MockDataGenerator { let dmThreadCount: Int = 100 let closedGroupThreadCount: Int = 0 let openGroupThreadCount: Int = 0 - let maxMessagesPerThread: Int = 50 + let messageRangePerThread: [ClosedRange] = [(0...50)] let dmRandomSeed: Int = 1111 let cgRandomSeed: Int = 2222 let ogRandomSeed: Int = 3333 + let openGroupBaseUrl: String = "https://chat.lokinet.dev" + let logProgress: (String, String) -> () = { title, event in + guard printProgress else { return } + + print("[MockDataGenerator] (\(Date().timeIntervalSince1970)) \(title) - \(event)") + } + + hasStartedGenerationThisRun = true // FIXME: Make sure this data doesn't go off device somehow? Storage.shared.write { anyTransaction in - guard let transaction: YapDatabaseReadWriteTransaction = anyTransaction as? YapDatabaseReadWriteTransaction else { return } + guard let transaction: YapDatabaseReadWriteTransaction = anyTransaction as? YapDatabaseReadWriteTransaction else { + return + } // First create the thread used to indicate that the mock data has been generated + logProgress("", "Start") _ = TSContactThread.getOrCreateThread(withContactSessionID: "MockDatabaseThread", transaction: transaction) // Multiple spaces to make it look more like words let stringContent: [String] = "abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ 0123456789 ".map { String($0) } + let wordContent: [String] = ["alias", "consequatur", "aut", "perferendis", "sit", "voluptatem", "accusantium", "doloremque", "aperiam", "eaque", "ipsa", "quae", "ab", "illo", "inventore", "veritatis", "et", "quasi", "architecto", "beatae", "vitae", "dicta", "sunt", "explicabo", "aspernatur", "aut", "odit", "aut", "fugit", "sed", "quia", "consequuntur", "magni", "dolores", "eos", "qui", "ratione", "voluptatem", "sequi", "nesciunt", "neque", "dolorem", "ipsum", "quia", "dolor", "sit", "amet", "consectetur", "adipisci", "velit", "sed", "quia", "non", "numquam", "eius", "modi", "tempora", "incidunt", "ut", "labore", "et", "dolore", "magnam", "aliquam", "quaerat", "voluptatem", "ut", "enim", "ad", "minima", "veniam", "quis", "nostrum", "exercitationem", "ullam", "corporis", "nemo", "enim", "ipsam", "voluptatem", "quia", "voluptas", "sit", "suscipit", "laboriosam", "nisi", "ut", "aliquid", "ex", "ea", "commodi", "consequatur", "quis", "autem", "vel", "eum", "iure", "reprehenderit", "qui", "in", "ea", "voluptate", "velit", "esse", "quam", "nihil", "molestiae", "et", "iusto", "odio", "dignissimos", "ducimus", "qui", "blanditiis", "praesentium", "laudantium", "totam", "rem", "voluptatum", "deleniti", "atque", "corrupti", "quos", "dolores", "et", "quas", "molestias", "excepturi", "sint", "occaecati", "cupiditate", "non", "provident", "sed", "ut", "perspiciatis", "unde", "omnis", "iste", "natus", "error", "similique", "sunt", "in", "culpa", "qui", "officia", "deserunt", "mollitia", "animi", "id", "est", "laborum", "et", "dolorum", "fuga", "et", "harum", "quidem", "rerum", "facilis", "est", "et", "expedita", "distinctio", "nam", "libero", "tempore", "cum", "soluta", "nobis", "est", "eligendi", "optio", "cumque", "nihil", "impedit", "quo", "porro", "quisquam", "est", "qui", "minus", "id", "quod", "maxime", "placeat", "facere", "possimus", "omnis", "voluptas", "assumenda", "est", "omnis", "dolor", "repellendus", "temporibus", "autem", "quibusdam", "et", "aut", "consequatur", "vel", "illum", "qui", "dolorem", "eum", "fugiat", "quo", "voluptas", "nulla", "pariatur", "at", "vero", "eos", "et", "accusamus", "officiis", "debitis", "aut", "rerum", "necessitatibus", "saepe", "eveniet", "ut", "et", "voluptates", "repudiandae", "sint", "et", "molestiae", "non", "recusandae", "itaque", "earum", "rerum", "hic", "tenetur", "a", "sapiente", "delectus", "ut", "aut", "reiciendis", "voluptatibus", "maiores", "doloribus", "asperiores", "repellat"] let timestampNow: TimeInterval = Date().timeIntervalSince1970 let userSessionId: String = getUserHexEncodedPublicKey() // MARK: - -- DM Thread var dmThreadRandomGenerator: ARC4RandomNumberGenerator = ARC4RandomNumberGenerator(seed: dmRandomSeed) + logProgress("DM Threads", "Start Generating \(dmThreadCount) threads") (0.. [Promise] { var promises: [Promise] = [] - let servers = Set(Storage.shared.getAllV2OpenGroups().values.map { $0.server }) + let servers = Set(Storage.shared.getAllOpenGroups().values.map { $0.server }) servers.forEach { server in - let poller = OpenGroupPollerV2(for: server) + let poller = OpenGroupAPI.Poller(for: server) let promise = poller.poll().timeout(seconds: 20, timeoutError: NotificationServiceError.timeout) promises.append(promise) } From 31ecd7873785c3d7010305e911c2d028b2817ed0 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 11 Mar 2022 16:57:28 +1100 Subject: [PATCH 033/157] Refactored the mocking code to use a better convention which also allows for call validation Added a Nimble predicate for checking a function on a mock was called Added the various remove methods to the Storage protocol Updated the Ed25519Type to be an instance-based protocol (needed for mocking) --- Podfile | 6 +- Podfile.lock | 15 +- Session.xcodeproj/project.pbxproj | 87 ++-- .../Database/Storage+OpenGroups.swift | 4 + .../Models/UserDeleteMessagesRequest.swift | 10 - .../Models/UserDeleteMessagesResponse.swift | 15 - .../Open Groups/Types/SodiumProtocols.swift | 9 +- SessionMessagingKit/Storage.swift | 4 + .../Utilities/Dependencies.swift | 10 +- .../Open Groups/Models/SOGSMessageSpec.swift | 74 ++- .../Open Groups/OpenGroupAPISpec.swift | 483 ++++++++---------- .../Open Groups/OpenGroupManagerSpec.swift | 3 + .../Types/SodiumProtocolsSpec.swift | 48 +- .../_TestUtilities/BoxKeyPair+Mocked.swift | 11 + ...ft => MockAeadXChaCha20Poly1305Ietf.swift} | 19 +- .../_TestUtilities/MockEd25519.swift | 13 + .../_TestUtilities/MockGenericHash.swift | 21 + .../{TestSign.swift => MockSign.swift} | 22 +- .../_TestUtilities/MockSodium.swift | 37 ++ .../_TestUtilities/MockStorage.swift | 179 +++++++ .../_TestUtilities/TestEd25519.swift | 25 - .../_TestUtilities/TestGenericHash.swift | 35 -- .../_TestUtilities/TestInteraction.swift | 3 + .../_TestUtilities/TestSodium.swift | 59 --- .../_TestUtilities/TestStorage.swift | 193 ------- .../_TestUtilities/TestThread.swift | 3 + SharedTest/Mock.swift | 181 +++++++ SharedTest/NimbleExtensions.swift | 223 ++++++++ 28 files changed, 1073 insertions(+), 719 deletions(-) delete mode 100644 SessionMessagingKit/Open Groups/Models/UserDeleteMessagesRequest.swift delete mode 100644 SessionMessagingKit/Open Groups/Models/UserDeleteMessagesResponse.swift create mode 100644 SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift create mode 100644 SessionMessagingKitTests/_TestUtilities/BoxKeyPair+Mocked.swift rename SessionMessagingKitTests/_TestUtilities/{TestAeadXChaCha20Poly1305Ietf.swift => MockAeadXChaCha20Poly1305Ietf.swift} (53%) create mode 100644 SessionMessagingKitTests/_TestUtilities/MockEd25519.swift create mode 100644 SessionMessagingKitTests/_TestUtilities/MockGenericHash.swift rename SessionMessagingKitTests/_TestUtilities/{TestSign.swift => MockSign.swift} (50%) create mode 100644 SessionMessagingKitTests/_TestUtilities/MockSodium.swift create mode 100644 SessionMessagingKitTests/_TestUtilities/MockStorage.swift delete mode 100644 SessionMessagingKitTests/_TestUtilities/TestEd25519.swift delete mode 100644 SessionMessagingKitTests/_TestUtilities/TestGenericHash.swift create mode 100644 SessionMessagingKitTests/_TestUtilities/TestInteraction.swift delete mode 100644 SessionMessagingKitTests/_TestUtilities/TestSodium.swift delete mode 100644 SessionMessagingKitTests/_TestUtilities/TestStorage.swift create mode 100644 SessionMessagingKitTests/_TestUtilities/TestThread.swift create mode 100644 SharedTest/Mock.swift create mode 100644 SharedTest/NimbleExtensions.swift diff --git a/Podfile b/Podfile index fc32f6793..4a7c40a79 100644 --- a/Podfile +++ b/Podfile @@ -57,7 +57,8 @@ abstract_target 'GlobalDependencies' do inherit! :complete pod 'Quick' - pod 'Nimble' + # FIXME: change this back to use the latest 'Nimble' once a version newer than 9.2.1 has been released + pod 'Nimble', :git => 'https://github.com/Quick/Nimble', :commit => 'cabe966' end end @@ -68,7 +69,8 @@ abstract_target 'GlobalDependencies' do inherit! :complete pod 'Quick' - pod 'Nimble' + # FIXME: change this back to use the latest 'Nimble' once a version newer than 9.2.1 has been released + pod 'Nimble', :git => 'https://github.com/Quick/Nimble', :commit => 'cabe966' end end end diff --git a/Podfile.lock b/Podfile.lock index a4d945ac0..6ad306249 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -24,7 +24,7 @@ PODS: - Mantle (2.1.0): - Mantle/extobjc (= 2.1.0) - Mantle/extobjc (2.1.0) - - Nimble (9.2.1) + - Nimble (9.2.0) - NVActivityIndicatorView (5.1.1): - NVActivityIndicatorView/Base (= 5.1.1) - NVActivityIndicatorView/Base (5.1.1) @@ -126,7 +126,7 @@ DEPENDENCIES: - CryptoSwift - Curve25519Kit (from `https://github.com/oxen-io/session-ios-curve-25519-kit.git`, branch `session-version`) - Mantle (from `https://github.com/signalapp/Mantle`, branch `signal-master`) - - Nimble + - Nimble (from `https://github.com/Quick/Nimble`, commit `cabe966`) - NVActivityIndicatorView - PromiseKit - PureLayout (~> 3.1.8) @@ -145,7 +145,6 @@ SPEC REPOS: - AFNetworking - CocoaLumberjack - CryptoSwift - - Nimble - NVActivityIndicatorView - OpenSSL-Universal - PromiseKit @@ -164,6 +163,9 @@ EXTERNAL SOURCES: Mantle: :branch: signal-master :git: https://github.com/signalapp/Mantle + Nimble: + :commit: cabe966 + :git: https://github.com/Quick/Nimble SignalCoreKit: :branch: session-version :git: https://github.com/oxen-io/session-ios-core-kit @@ -183,6 +185,9 @@ CHECKOUT OPTIONS: Mantle: :commit: e7e46253bb01ce39525d90aa69ed9e85e758bfc4 :git: https://github.com/signalapp/Mantle + Nimble: + :commit: cabe966 + :git: https://github.com/Quick/Nimble SignalCoreKit: :commit: 4590c2737a2b5dc0ef4ace9f9019b581caccc1de :git: https://github.com/oxen-io/session-ios-core-kit @@ -202,7 +207,7 @@ SPEC CHECKSUMS: CryptoSwift: a532e74ed010f8c95f611d00b8bbae42e9fe7c17 Curve25519Kit: e63f9859ede02438ae3defc5e1a87e09d1ec7ee6 Mantle: 2fa750afa478cd625a94230fbf1c13462f29395b - Nimble: e7e615c0335ee4bf5b0d786685451e62746117d5 + Nimble: 0526ae760c851747ff4a682f7646af07a0cc2013 NVActivityIndicatorView: 1f6c5687f1171810aa27a3296814dc2d7dec3667 OpenSSL-Universal: e7311447fd2419f57420c79524b641537387eff2 PromiseKit: 3b2b6995e51a954c46dbc550ce3da44fbfb563c5 @@ -218,6 +223,6 @@ SPEC CHECKSUMS: YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331 ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: b95d8bb031996cffdb5d9b9b49bce3b24d6026d7 +PODFILE CHECKSUM: cb9862059da2976422ff9c4fa94d406b68581456 COCOAPODS: 1.11.2 diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 5f47b6d69..b8afd5b76 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -801,11 +801,11 @@ FD83B9D227D59495005E1583 /* TestUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9D127D59495005E1583 /* TestUserDefaults.swift */; }; FD83B9D427D5A7D5005E1583 /* ConversationViewItem+Refactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9D327D5A7D5005E1583 /* ConversationViewItem+Refactor.swift */; }; FD859EF227BF6BA200510D0C /* Data+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF127BF6BA200510D0C /* Data+Utilities.swift */; }; - FD859EF427C2F49200510D0C /* TestSodium.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF327C2F49200510D0C /* TestSodium.swift */; }; - FD859EF627C2F52C00510D0C /* TestSign.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF527C2F52C00510D0C /* TestSign.swift */; }; - FD859EF827C2F58900510D0C /* TestAeadXChaCha20Poly1305Ietf.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF727C2F58900510D0C /* TestAeadXChaCha20Poly1305Ietf.swift */; }; - FD859EFA27C2F5C500510D0C /* TestGenericHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF927C2F5C500510D0C /* TestGenericHash.swift */; }; - FD859EFC27C2F60700510D0C /* TestEd25519.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EFB27C2F60700510D0C /* TestEd25519.swift */; }; + FD859EF427C2F49200510D0C /* MockSodium.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF327C2F49200510D0C /* MockSodium.swift */; }; + FD859EF627C2F52C00510D0C /* MockSign.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF527C2F52C00510D0C /* MockSign.swift */; }; + FD859EF827C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF727C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift */; }; + FD859EFA27C2F5C500510D0C /* MockGenericHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF927C2F5C500510D0C /* MockGenericHash.swift */; }; + FD859EFC27C2F60700510D0C /* MockEd25519.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EFB27C2F60700510D0C /* MockEd25519.swift */; }; FD859F0027C4691300510D0C /* MockDataGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EFF27C4691300510D0C /* MockDataGenerator.swift */; }; FD88BAD927A7439C00BBC442 /* MessageRequestsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */; }; FD88BADB27A750F200BBC442 /* MessageRequestsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */; }; @@ -820,6 +820,15 @@ FDC2909827D7129B005DAE71 /* PersonalizationSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909727D7129B005DAE71 /* PersonalizationSpec.swift */; }; FDC2909A27D71376005DAE71 /* NonceGeneratorSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909927D71376005DAE71 /* NonceGeneratorSpec.swift */; }; FDC2909C27D713D2005DAE71 /* SodiumProtocolsSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909B27D713D2005DAE71 /* SodiumProtocolsSpec.swift */; }; + FDC2909E27D85751005DAE71 /* OpenGroupManagerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909D27D85751005DAE71 /* OpenGroupManagerSpec.swift */; }; + FDC290A027D85826005DAE71 /* TestThread.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909F27D85826005DAE71 /* TestThread.swift */; }; + FDC290A227D85890005DAE71 /* TestInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A127D85890005DAE71 /* TestInteraction.swift */; }; + FDC290A627D860CE005DAE71 /* Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A527D860CE005DAE71 /* Mock.swift */; }; + FDC290A827D9B46D005DAE71 /* NimbleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */; }; + FDC290A927D9B46D005DAE71 /* NimbleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */; }; + FDC290AA27D9B6FD005DAE71 /* Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A527D860CE005DAE71 /* Mock.swift */; }; + FDC290AC27DB0B1C005DAE71 /* BoxKeyPair+Mocked.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290AB27DB0B1C005DAE71 /* BoxKeyPair+Mocked.swift */; }; + FDC290AD27DB0B1C005DAE71 /* BoxKeyPair+Mocked.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290AB27DB0B1C005DAE71 /* BoxKeyPair+Mocked.swift */; }; FDC4380927B31D4E00C60D73 /* SOGSError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4380827B31D4E00C60D73 /* SOGSError.swift */; }; FDC4381527B329CE00C60D73 /* NonceGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381427B329CE00C60D73 /* NonceGenerator.swift */; }; FDC4381727B32EC700C60D73 /* Personalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381627B32EC700C60D73 /* Personalization.swift */; }; @@ -844,12 +853,10 @@ FDC4387827B5C35400C60D73 /* SendMessageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4387727B5C35400C60D73 /* SendMessageRequest.swift */; }; FDC4389227B9FFC700C60D73 /* SessionMessagingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A6F025539DE700C340D1 /* SessionMessagingKit.framework */; platformFilter = ios; }; FDC4389A27BA002500C60D73 /* OpenGroupAPISpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4389927BA002500C60D73 /* OpenGroupAPISpec.swift */; }; - FDC4389D27BA01F000C60D73 /* TestStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4389C27BA01F000C60D73 /* TestStorage.swift */; }; + FDC4389D27BA01F000C60D73 /* MockStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4389C27BA01F000C60D73 /* MockStorage.swift */; }; FDC438A427BB107F00C60D73 /* UserBanRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438A327BB107F00C60D73 /* UserBanRequest.swift */; }; FDC438A627BB113A00C60D73 /* UserUnbanRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438A527BB113A00C60D73 /* UserUnbanRequest.swift */; }; FDC438AA27BB12BB00C60D73 /* UserModeratorRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438A927BB12BB00C60D73 /* UserModeratorRequest.swift */; }; - FDC438AC27BB145200C60D73 /* UserDeleteMessagesRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438AB27BB145200C60D73 /* UserDeleteMessagesRequest.swift */; }; - FDC438AE27BB148700C60D73 /* UserDeleteMessagesResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438AD27BB148700C60D73 /* UserDeleteMessagesResponse.swift */; }; FDC438B127BB159600C60D73 /* RequestInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438B027BB159600C60D73 /* RequestInfo.swift */; }; FDC438B327BB15B400C60D73 /* ResponseInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438B227BB15B400C60D73 /* ResponseInfo.swift */; }; FDC438B527BB15D400C60D73 /* Destination.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438B427BB15D400C60D73 /* Destination.swift */; }; @@ -863,7 +870,6 @@ FDC438CB27BB7DB100C60D73 /* UpdateMessageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438CA27BB7DB100C60D73 /* UpdateMessageRequest.swift */; }; FDC438CD27BC641200C60D73 /* Set+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438CC27BC641200C60D73 /* Set+Utilities.swift */; }; FDC438CF27BCA45400C60D73 /* Server.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438CE27BCA45400C60D73 /* Server.swift */; }; - FDC4389E27BA2B8A00C60D73 /* SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1937,11 +1943,11 @@ 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 = ""; }; - FD859EF327C2F49200510D0C /* TestSodium.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestSodium.swift; sourceTree = ""; }; - FD859EF527C2F52C00510D0C /* TestSign.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestSign.swift; sourceTree = ""; }; - FD859EF727C2F58900510D0C /* TestAeadXChaCha20Poly1305Ietf.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAeadXChaCha20Poly1305Ietf.swift; sourceTree = ""; }; - FD859EF927C2F5C500510D0C /* TestGenericHash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestGenericHash.swift; sourceTree = ""; }; - FD859EFB27C2F60700510D0C /* TestEd25519.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestEd25519.swift; sourceTree = ""; }; + FD859EF327C2F49200510D0C /* MockSodium.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSodium.swift; sourceTree = ""; }; + FD859EF527C2F52C00510D0C /* MockSign.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSign.swift; sourceTree = ""; }; + FD859EF727C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAeadXChaCha20Poly1305Ietf.swift; sourceTree = ""; }; + FD859EF927C2F5C500510D0C /* MockGenericHash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockGenericHash.swift; sourceTree = ""; }; + FD859EFB27C2F60700510D0C /* MockEd25519.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockEd25519.swift; sourceTree = ""; }; FD859EFF27C4691300510D0C /* MockDataGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDataGenerator.swift; sourceTree = ""; }; FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsCell.swift; sourceTree = ""; }; FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsMigration.swift; sourceTree = ""; }; @@ -1957,6 +1963,12 @@ FDC2909727D7129B005DAE71 /* PersonalizationSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalizationSpec.swift; sourceTree = ""; }; FDC2909927D71376005DAE71 /* NonceGeneratorSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonceGeneratorSpec.swift; sourceTree = ""; }; FDC2909B27D713D2005DAE71 /* SodiumProtocolsSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SodiumProtocolsSpec.swift; sourceTree = ""; }; + FDC2909D27D85751005DAE71 /* OpenGroupManagerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupManagerSpec.swift; sourceTree = ""; }; + FDC2909F27D85826005DAE71 /* TestThread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestThread.swift; sourceTree = ""; }; + FDC290A127D85890005DAE71 /* TestInteraction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestInteraction.swift; sourceTree = ""; }; + FDC290A527D860CE005DAE71 /* Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mock.swift; sourceTree = ""; }; + FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NimbleExtensions.swift; sourceTree = ""; }; + FDC290AB27DB0B1C005DAE71 /* BoxKeyPair+Mocked.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BoxKeyPair+Mocked.swift"; sourceTree = ""; }; FDC4380827B31D4E00C60D73 /* SOGSError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSError.swift; sourceTree = ""; }; FDC4381427B329CE00C60D73 /* NonceGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonceGenerator.swift; sourceTree = ""; }; FDC4381627B32EC700C60D73 /* Personalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Personalization.swift; sourceTree = ""; }; @@ -1980,12 +1992,10 @@ FDC4387727B5C35400C60D73 /* SendMessageRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageRequest.swift; sourceTree = ""; }; FDC4388E27B9FFC700C60D73 /* SessionMessagingKitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SessionMessagingKitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; FDC4389927BA002500C60D73 /* OpenGroupAPISpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupAPISpec.swift; sourceTree = ""; }; - FDC4389C27BA01F000C60D73 /* TestStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestStorage.swift; sourceTree = ""; }; + FDC4389C27BA01F000C60D73 /* MockStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockStorage.swift; sourceTree = ""; }; FDC438A327BB107F00C60D73 /* UserBanRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserBanRequest.swift; sourceTree = ""; }; FDC438A527BB113A00C60D73 /* UserUnbanRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserUnbanRequest.swift; sourceTree = ""; }; FDC438A927BB12BB00C60D73 /* UserModeratorRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserModeratorRequest.swift; sourceTree = ""; }; - FDC438AB27BB145200C60D73 /* UserDeleteMessagesRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDeleteMessagesRequest.swift; sourceTree = ""; }; - FDC438AD27BB148700C60D73 /* UserDeleteMessagesResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDeleteMessagesResponse.swift; sourceTree = ""; }; FDC438B027BB159600C60D73 /* RequestInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestInfo.swift; sourceTree = ""; }; FDC438B227BB15B400C60D73 /* ResponseInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResponseInfo.swift; sourceTree = ""; }; FDC438B427BB15D400C60D73 /* Destination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Destination.swift; sourceTree = ""; }; @@ -3876,7 +3886,9 @@ FD83B9BC27CF2215005E1583 /* SharedTest */ = { isa = PBXGroup; children = ( + FDC290A527D860CE005DAE71 /* Mock.swift */, FD83B9BD27CF2243005E1583 /* TestConstants.swift */, + FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */, ); path = SharedTest; sourceTree = ""; @@ -3956,8 +3968,6 @@ FDC438A327BB107F00C60D73 /* UserBanRequest.swift */, FDC438A527BB113A00C60D73 /* UserUnbanRequest.swift */, FDC438A927BB12BB00C60D73 /* UserModeratorRequest.swift */, - FDC438AB27BB145200C60D73 /* UserDeleteMessagesRequest.swift */, - FDC438AD27BB148700C60D73 /* UserDeleteMessagesResponse.swift */, ); path = Models; sourceTree = ""; @@ -4013,6 +4023,7 @@ FD83B9C127CF33EE005E1583 /* Models */, FDC2909227D710A9005DAE71 /* Types */, FDC4389927BA002500C60D73 /* OpenGroupAPISpec.swift */, + FDC2909D27D85751005DAE71 /* OpenGroupManagerSpec.swift */, ); path = "Open Groups"; sourceTree = ""; @@ -4021,13 +4032,16 @@ isa = PBXGroup; children = ( FDC438BC27BB2AB400C60D73 /* Mockable.swift */, - FDC4389C27BA01F000C60D73 /* TestStorage.swift */, - FD859EF327C2F49200510D0C /* TestSodium.swift */, - FD859EF527C2F52C00510D0C /* TestSign.swift */, - FD859EF727C2F58900510D0C /* TestAeadXChaCha20Poly1305Ietf.swift */, - FD859EF927C2F5C500510D0C /* TestGenericHash.swift */, - FD859EFB27C2F60700510D0C /* TestEd25519.swift */, + FDC4389C27BA01F000C60D73 /* MockStorage.swift */, + FD859EF327C2F49200510D0C /* MockSodium.swift */, + FD859EF527C2F52C00510D0C /* MockSign.swift */, + FD859EF727C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift */, + FD859EF927C2F5C500510D0C /* MockGenericHash.swift */, + FD859EFB27C2F60700510D0C /* MockEd25519.swift */, FD83B9D127D59495005E1583 /* TestUserDefaults.swift */, + FDC2909F27D85826005DAE71 /* TestThread.swift */, + FDC290A127D85890005DAE71 /* TestInteraction.swift */, + FDC290AB27DB0B1C005DAE71 /* BoxKeyPair+Mocked.swift */, ); path = _TestUtilities; sourceTree = ""; @@ -5285,7 +5299,6 @@ B8B32021258B1A650020074B /* Contact.swift in Sources */, C32C5C89256DD0D2003C73A2 /* Storage+Jobs.swift in Sources */, C300A5FC2554B0A000555489 /* MessageReceiver.swift in Sources */, - FDC438AC27BB145200C60D73 /* UserDeleteMessagesRequest.swift in Sources */, 7B1581E2271E743B00848B49 /* OWSSounds.swift in Sources */, FDC438A627BB113A00C60D73 /* UserUnbanRequest.swift in Sources */, FDC4383E27B4708600C60D73 /* Atomic.swift in Sources */, @@ -5393,7 +5406,6 @@ FDC438C327BB512200C60D73 /* SodiumProtocols.swift in Sources */, B8856D11256F112A001CE70E /* OWSAudioSession.swift in Sources */, C3DB66C3260ACCE6001EFC55 /* OpenGroupPoller.swift in Sources */, - FDC438AE27BB148700C60D73 /* UserDeleteMessagesResponse.swift in Sources */, C3C2A75F2553A3C500C340D1 /* VisibleMessage+LinkPreview.swift in Sources */, C32C5FBB256E0206003C73A2 /* OWSBackgroundTask.m in Sources */, B8856CA8256F0F42001CE70E /* OWSBackupFragment.m in Sources */, @@ -5611,8 +5623,11 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + FDC290AD27DB0B1C005DAE71 /* BoxKeyPair+Mocked.swift in Sources */, FD83B9BF27CF2294005E1583 /* TestConstants.swift in Sources */, FD83B9BB27CF20AF005E1583 /* SessionIdSpec.swift in Sources */, + FDC290A927D9B46D005DAE71 /* NimbleExtensions.swift in Sources */, + FDC290AA27D9B6FD005DAE71 /* Mock.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5620,7 +5635,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - FD859EFA27C2F5C500510D0C /* TestGenericHash.swift in Sources */, + FDC290AC27DB0B1C005DAE71 /* BoxKeyPair+Mocked.swift in Sources */, + FD859EFA27C2F5C500510D0C /* MockGenericHash.swift in Sources */, FDC2909427D710B4005DAE71 /* SOGSEndpointSpec.swift in Sources */, FDC2909127D709CA005DAE71 /* SOGSMessageSpec.swift in Sources */, FDC2909627D71252005DAE71 /* SOGSErrorSpec.swift in Sources */, @@ -5629,20 +5645,25 @@ FD83B9C327CF33F7005E1583 /* ServerSpec.swift in Sources */, FD83B9C727CF3F10005E1583 /* CapabilitiesSpec.swift in Sources */, FDC2909A27D71376005DAE71 /* NonceGeneratorSpec.swift in Sources */, - FD859EF827C2F58900510D0C /* TestAeadXChaCha20Poly1305Ietf.swift in Sources */, + FDC290A027D85826005DAE71 /* TestThread.swift in Sources */, + FD859EF827C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift in Sources */, FDC2909827D7129B005DAE71 /* PersonalizationSpec.swift in Sources */, - FD859EF427C2F49200510D0C /* TestSodium.swift in Sources */, - FD859EFC27C2F60700510D0C /* TestEd25519.swift in Sources */, + FD859EF427C2F49200510D0C /* MockSodium.swift in Sources */, + FD859EFC27C2F60700510D0C /* MockEd25519.swift in Sources */, + FDC290A627D860CE005DAE71 /* Mock.swift in Sources */, FD83B9C027CF2294005E1583 /* TestConstants.swift in Sources */, FDC4389A27BA002500C60D73 /* OpenGroupAPISpec.swift in Sources */, FD83B9C527CF3E2A005E1583 /* OpenGroupSpec.swift in Sources */, FDC2908B27D707F3005DAE71 /* SendMessageRequestSpec.swift in Sources */, + FDC290A827D9B46D005DAE71 /* NimbleExtensions.swift in Sources */, FDC2908F27D70938005DAE71 /* SendDirectMessageRequestSpec.swift in Sources */, FDC438BD27BB2AB400C60D73 /* Mockable.swift in Sources */, - FD859EF627C2F52C00510D0C /* TestSign.swift in Sources */, + FD859EF627C2F52C00510D0C /* MockSign.swift in Sources */, FDC2908927D70656005DAE71 /* RoomPollInfoSpec.swift in Sources */, + FDC2909E27D85751005DAE71 /* OpenGroupManagerSpec.swift in Sources */, FDC2908D27D70905005DAE71 /* UpdateMessageRequestSpec.swift in Sources */, - FDC4389D27BA01F000C60D73 /* TestStorage.swift in Sources */, + FDC290A227D85890005DAE71 /* TestInteraction.swift in Sources */, + FDC4389D27BA01F000C60D73 /* MockStorage.swift in Sources */, FD83B9D227D59495005E1583 /* TestUserDefaults.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/SessionMessagingKit/Database/Storage+OpenGroups.swift b/SessionMessagingKit/Database/Storage+OpenGroups.swift index f3b7cf933..06143d0ba 100644 --- a/SessionMessagingKit/Database/Storage+OpenGroups.swift +++ b/SessionMessagingKit/Database/Storage+OpenGroups.swift @@ -58,6 +58,10 @@ extension Storage { public func setOpenGroupServer(_ server: OpenGroupAPI.Server, using transaction: Any) { (transaction as! YapDatabaseReadWriteTransaction).setObject(server, forKey: "SOGS.\(server.name)", inCollection: Storage.openGroupCollection) } + + public func removeOpenGroupServer(name: String, using transaction: Any) { + (transaction as! YapDatabaseReadWriteTransaction).removeObject(forKey: "SOGS.\(name)", inCollection: Storage.openGroupCollection) + } diff --git a/SessionMessagingKit/Open Groups/Models/UserDeleteMessagesRequest.swift b/SessionMessagingKit/Open Groups/Models/UserDeleteMessagesRequest.swift deleted file mode 100644 index 0719528cc..000000000 --- a/SessionMessagingKit/Open Groups/Models/UserDeleteMessagesRequest.swift +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -extension OpenGroupAPI { - struct UserDeleteMessagesRequest: Codable { - let rooms: [String]? - let global: Bool? - } -} diff --git a/SessionMessagingKit/Open Groups/Models/UserDeleteMessagesResponse.swift b/SessionMessagingKit/Open Groups/Models/UserDeleteMessagesResponse.swift deleted file mode 100644 index 5b5d5ac74..000000000 --- a/SessionMessagingKit/Open Groups/Models/UserDeleteMessagesResponse.swift +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -extension OpenGroupAPI { - public struct UserDeleteMessagesResponse: Codable, Equatable { - enum CodingKeys: String, CodingKey { - case id - case messagesDeleted = "messages_deleted" - } - - let id: String - let messagesDeleted: Int64 - } -} diff --git a/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift b/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift index 7a23b1903..28f162b00 100644 --- a/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift +++ b/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift @@ -28,7 +28,7 @@ public protocol AeadXChaCha20Poly1305IetfType { } public protocol Ed25519Type { - static func verifySignature(_ signature: Data, publicKey: Data, data: Data) throws -> Bool + func verifySignature(_ signature: Data, publicKey: Data, data: Data) throws -> Bool } public protocol SignType { @@ -80,4 +80,9 @@ extension Sodium: SodiumType { extension Aead.XChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType {} extension Sign: SignType {} extension GenericHash: GenericHashType {} -extension Ed25519: Ed25519Type {} + +struct Ed25519Wrapper: Ed25519Type { + func verifySignature(_ signature: Data, publicKey: Data, data: Data) throws -> Bool { + return try Ed25519.verifySignature(signature, publicKey: publicKey, data: data) + } +} diff --git a/SessionMessagingKit/Storage.swift b/SessionMessagingKit/Storage.swift index 3021827e5..0fc22f249 100644 --- a/SessionMessagingKit/Storage.swift +++ b/SessionMessagingKit/Storage.swift @@ -60,17 +60,20 @@ public protocol SessionMessagingKitStorageProtocol { func getOpenGroup(for threadID: String) -> OpenGroup? func setOpenGroup(_ openGroup: OpenGroup, for threadID: String, using transaction: Any) + func removeOpenGroup(for threadID: String, using transaction: Any) func getUserCount(forOpenGroupWithID openGroupID: String) -> UInt64? func setUserCount(to newValue: UInt64, forOpenGroupWithID openGroupID: String, using transaction: Any) func getOpenGroupServer(name: String) -> OpenGroupAPI.Server? func setOpenGroupServer(_ server: OpenGroupAPI.Server, using transaction: Any) + func removeOpenGroupServer(name: String, using transaction: Any) // MARK: - -- Open Group Public Keys func getOpenGroupPublicKey(for server: String) -> String? func setOpenGroupPublicKey(for server: String, to newValue: String, using transaction: Any) + func removeOpenGroupPublicKey(for server: String, using transaction: Any) // MARK: - -- Open Group Sequence Number @@ -96,6 +99,7 @@ public protocol SessionMessagingKitStorageProtocol { func getAllMessageRequestThreads(using transaction: YapDatabaseReadTransaction) -> [String: TSContactThread] func getReceivedMessageTimestamps(using transaction: Any) -> [UInt64] + func removeReceivedMessageTimestamps(_ timestamps: Set, using transaction: Any) func addReceivedMessageTimestamp(_ timestamp: UInt64, using transaction: Any) /// Returns the ID of the thread. func getOrCreateThread(for publicKey: String, groupPublicKey: String?, openGroupID: String?, using transaction: Any) -> String? diff --git a/SessionMessagingKit/Utilities/Dependencies.swift b/SessionMessagingKit/Utilities/Dependencies.swift index 583a2ec9e..31afcf434 100644 --- a/SessionMessagingKit/Utilities/Dependencies.swift +++ b/SessionMessagingKit/Utilities/Dependencies.swift @@ -44,9 +44,9 @@ public class Dependencies { set { _genericHash = newValue } } - private var _ed25519: Ed25519Type.Type? - public var ed25519: Ed25519Type.Type { - get { getValueSettingIfNull(&_ed25519) { Ed25519.self } } + private var _ed25519: Ed25519Type? + public var ed25519: Ed25519Type { + get { getValueSettingIfNull(&_ed25519) { Ed25519Wrapper() } } set { _ed25519 = newValue } } @@ -83,7 +83,7 @@ public class Dependencies { aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, sign: SignType? = nil, genericHash: GenericHashType? = nil, - ed25519: Ed25519Type.Type? = nil, + ed25519: Ed25519Type? = nil, nonceGenerator16: NonceGenerator16ByteType? = nil, nonceGenerator24: NonceGenerator24ByteType? = nil, standardUserDefaults: UserDefaultsType? = nil, @@ -111,7 +111,7 @@ public class Dependencies { aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, sign: SignType? = nil, genericHash: GenericHashType? = nil, - ed25519: Ed25519Type.Type? = nil, + ed25519: Ed25519Type? = nil, nonceGenerator16: NonceGenerator16ByteType? = nil, nonceGenerator24: NonceGenerator24ByteType? = nil, standardUserDefaults: UserDefaultsType? = nil, diff --git a/SessionMessagingKitTests/Open Groups/Models/SOGSMessageSpec.swift b/SessionMessagingKitTests/Open Groups/Models/SOGSMessageSpec.swift index 75c8ed404..025f4b058 100644 --- a/SessionMessagingKitTests/Open Groups/Models/SOGSMessageSpec.swift +++ b/SessionMessagingKitTests/Open Groups/Models/SOGSMessageSpec.swift @@ -15,7 +15,8 @@ class SOGSMessageSpec: QuickSpec { var messageJson: String! var messageData: Data! var decoder: JSONDecoder! - var testSign: TestSign! + var mockSign: MockSign! + var mockEd25519: MockEd25519! var dependencies: Dependencies! beforeEach { @@ -29,19 +30,24 @@ class SOGSMessageSpec: QuickSpec { "whisper_mods": false, "data": "VGVzdERhdGE=", - "signature": "VGVzdERhdGE=" + "signature": "VGVzdFNpZ25hdHVyZQ==" } """ messageData = messageJson.data(using: .utf8)! - testSign = TestSign() + mockSign = MockSign() + mockEd25519 = MockEd25519() dependencies = Dependencies( - sign: testSign, - ed25519: TestEd25519.self + sign: mockSign, + ed25519: mockEd25519 ) decoder = JSONDecoder() decoder.userInfo = [ Dependencies.userInfoKey: dependencies as Any ] } + afterEach { + mockSign = nil + } + context("when decoding") { it("defaults the whisper values to false") { messageJson = """ @@ -91,7 +97,7 @@ class SOGSMessageSpec: QuickSpec { "whisper_mods": false, "data": "VGVzdERhdGE=", - "signature": "VGVzdERhdGE=" + "signature": "VGVzdFNpZ25hdHVyZQ==" } """ messageData = messageJson.data(using: .utf8)! @@ -113,7 +119,7 @@ class SOGSMessageSpec: QuickSpec { "whisper_mods": false, "data": "Test!!!", - "signature": "VGVzdERhdGE=" + "signature": "VGVzdFNpZ25hdHVyZQ==" } """ messageData = messageJson.data(using: .utf8)! @@ -166,7 +172,7 @@ class SOGSMessageSpec: QuickSpec { "whisper_mods": false, "data": "VGVzdERhdGE=", - "signature": "VGVzdERhdGE=" + "signature": "VGVzdFNpZ25hdHVyZQ==" } """ messageData = messageJson.data(using: .utf8)! @@ -190,14 +196,14 @@ class SOGSMessageSpec: QuickSpec { "whisper_mods": false, "data": "VGVzdERhdGE=", - "signature": "VGVzdERhdGE=" + "signature": "VGVzdFNpZ25hdHVyZQ==" } """ messageData = messageJson.data(using: .utf8)! } it("succeeds if it succeeds verification") { - testSign.mockData[.verify] = true + mockSign.when { $0.verify(message: any(), publicKey: any(), signature: any()) }.thenReturn(true) expect { try decoder.decode(OpenGroupAPI.Message.self, from: messageData) @@ -205,8 +211,23 @@ class SOGSMessageSpec: QuickSpec { .toNot(beNil()) } + it("provides the correct values as parameters") { + mockSign.when { $0.verify(message: any(), publicKey: any(), signature: any()) }.thenReturn(true) + + _ = try? decoder.decode(OpenGroupAPI.Message.self, from: messageData) + + expect(mockSign) + .to(call(matchingParameters: true) { + $0.verify( + message: Data(base64Encoded: "VGVzdERhdGE=")!.bytes, + publicKey: Data(hex: TestConstants.publicKey).bytes, + signature: Data(base64Encoded: "VGVzdFNpZ25hdHVyZQ==")!.bytes + ) + }) + } + it("throws if it fails verification") { - testSign.mockData[.verify] = false + mockSign.when { $0.verify(message: any(), publicKey: any(), signature: any()) }.thenReturn(false) expect { try decoder.decode(OpenGroupAPI.Message.self, from: messageData) @@ -217,13 +238,7 @@ class SOGSMessageSpec: QuickSpec { context("that is unblinded") { it("succeeds if it succeeds verification") { - TestEd25519.mockData[ - .verifySignature( - signature: Data(base64Encoded: "VGVzdERhdGE=")!, - publicKey: Data(hex: TestConstants.publicKey), - data: Data(base64Encoded: "VGVzdERhdGE=")! - ) - ] = true + mockEd25519.when { try $0.verifySignature(any(), publicKey: any(), data: any()) }.thenReturn(true) expect { try decoder.decode(OpenGroupAPI.Message.self, from: messageData) @@ -231,14 +246,23 @@ class SOGSMessageSpec: QuickSpec { .toNot(beNil()) } + it("provides the correct values as parameters") { + mockEd25519.when { try $0.verifySignature(any(), publicKey: any(), data: any()) }.thenReturn(true) + + _ = try? decoder.decode(OpenGroupAPI.Message.self, from: messageData) + + expect(mockEd25519) + .to(call(matchingParameters: true) { + try $0.verifySignature( + Data(base64Encoded: "VGVzdFNpZ25hdHVyZQ==")!, + publicKey: Data(hex: TestConstants.publicKey), + data: Data(base64Encoded: "VGVzdERhdGE=")! + ) + }) + } + it("throws if it fails verification") { - TestEd25519.mockData[ - .verifySignature( - signature: Data(base64Encoded: "VGVzdERhdGE=")!, - publicKey: Data(hex: TestConstants.publicKey), - data: Data(base64Encoded: "VGVzdERhdGE=")! - ) - ] = false + mockEd25519.when { try $0.verifySignature(any(), publicKey: any(), data: any()) }.thenReturn(false) expect { try decoder.decode(OpenGroupAPI.Message.self, from: messageData) diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift index 3f0c0c5d6..416172861 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift @@ -78,11 +78,11 @@ class OpenGroupAPISpec: QuickSpec { // MARK: - Spec override func spec() { - var testStorage: TestStorage! - var testSodium: TestSodium! - var testAeadXChaCha20Poly1305Ietf: TestAeadXChaCha20Poly1305Ietf! - var testGenericHash: TestGenericHash! - var testSign: TestSign! + var mockStorage: MockStorage! + var mockSodium: MockSodium! + var mockAeadXChaCha20Poly1305Ietf: MockAeadXChaCha20Poly1305Ietf! + var mockGenericHash: MockGenericHash! + var mockSign: MockSign! var testUserDefaults: TestUserDefaults! var dependencies: Dependencies! @@ -94,64 +94,110 @@ class OpenGroupAPISpec: QuickSpec { // MARK: - Configuration beforeEach { - testStorage = TestStorage() - testSodium = TestSodium() - testAeadXChaCha20Poly1305Ietf = TestAeadXChaCha20Poly1305Ietf() - testGenericHash = TestGenericHash() - testSign = TestSign() + mockStorage = MockStorage() + mockSodium = MockSodium() + mockAeadXChaCha20Poly1305Ietf = MockAeadXChaCha20Poly1305Ietf() + mockGenericHash = MockGenericHash() + mockSign = MockSign() testUserDefaults = TestUserDefaults() dependencies = Dependencies( api: TestApi.self, - storage: testStorage, - sodium: testSodium, - aeadXChaCha20Poly1305Ietf: testAeadXChaCha20Poly1305Ietf, - sign: testSign, - genericHash: testGenericHash, - ed25519: TestEd25519.self, + storage: mockStorage, + sodium: mockSodium, + aeadXChaCha20Poly1305Ietf: mockAeadXChaCha20Poly1305Ietf, + sign: mockSign, + genericHash: mockGenericHash, + ed25519: MockEd25519(), nonceGenerator16: TestNonce16Generator(), nonceGenerator24: TestNonce24Generator(), standardUserDefaults: testUserDefaults, date: Date(timeIntervalSince1970: 1234567890) ) - testStorage.mockData[.allOpenGroups] = [ - "0": OpenGroup( - server: "testServer", - room: "testRoom", - publicKey: TestConstants.publicKey, - name: "Test", - groupDescription: nil, - imageID: nil, - infoUpdates: 0 + mockStorage + .when { $0.write(with: { _ in }) } + .then { args in (args.first as? ((Any) -> Void))?(any()) } + .thenReturn(Promise.value(())) + mockStorage + .when { $0.write(with: { _ in }, completion: { }) } + .then { args in + (args.first as? ((Any) -> Void))?(any()) + (args.last as? (() -> Void))?() + } + .thenReturn(Promise.value(())) + mockStorage + .when { $0.getUserKeyPair() } + .thenReturn( + try! ECKeyPair( + publicKeyData: Data.data(fromHex: TestConstants.publicKey)!, + privateKeyData: Data.data(fromHex: TestConstants.privateKey)! + ) ) - ] - testStorage.mockData[.openGroupPublicKeys] = [ - "testServer": TestConstants.publicKey - ] - testStorage.mockData[.userKeyPair] = try! ECKeyPair( - publicKeyData: Data.data(fromHex: TestConstants.publicKey)!, - privateKeyData: Data.data(fromHex: TestConstants.privateKey)! - ) - testStorage.mockData[.userEdKeyPair] = Box.KeyPair( - publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, - secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes - ) + mockStorage + .when { $0.getUserED25519KeyPair() } + .thenReturn( + Box.KeyPair( + publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes + ) + ) + mockStorage + .when { $0.getAllOpenGroups() } + .thenReturn([ + "0": OpenGroup( + server: "testServer", + room: "testRoom", + publicKey: TestConstants.publicKey, + name: "Test", + groupDescription: nil, + imageID: nil, + infoUpdates: 0 + ) + ]) + mockStorage + .when { $0.getOpenGroupServer(name: any()) } + .thenReturn( + OpenGroupAPI.Server( + name: "testServer", + capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: []) + ) + ) + mockStorage + .when { $0.getOpenGroupPublicKey(for: any()) } + .thenReturn(TestConstants.publicKey) + mockStorage.when { $0.getOpenGroupSequenceNumber(for: any(), on: any()) }.thenReturn(nil) + mockStorage.when { $0.getOpenGroupInboxLatestMessageId(for: any()) }.thenReturn(nil) + mockStorage.when { $0.getOpenGroupOutboxLatestMessageId(for: any()) }.thenReturn(nil) + mockStorage.when { $0.addReceivedMessageTimestamp(any(), using: any()) }.thenReturn(()) - testGenericHash.mockData[.hashOutputLength] = [] - testSodium.mockData[.blindedKeyPair] = Box.KeyPair( - publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, - secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes - ) - testSodium.mockData[.sogsSignature] = "TestSogsSignature".bytes - testSign.mockData[.signature] = "TestSignature".bytes + mockGenericHash.when { $0.hash(message: any(), outputLength: any()) }.thenReturn([]) + mockSodium + .when { $0.blindedKeyPair(serverPublicKey: any(), edKeyPair: any(), genericHash: mockGenericHash) } + .thenReturn( + Box.KeyPair( + publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes + ) + ) + mockSodium + .when { + $0.sogsSignature( + message: any(), + secretKey: any(), + blindedSecretKey: any(), + blindedPublicKey: any() + ) + } + .thenReturn("TestSogsSignature".bytes) + mockSign.when { $0.signature(message: any(), secretKey: any()) }.thenReturn("TestSignature".bytes) } afterEach { - testStorage = nil - testSodium = nil - testAeadXChaCha20Poly1305Ietf = nil - testGenericHash = nil - testSign = nil + mockStorage = nil + mockSodium = nil + mockAeadXChaCha20Poly1305Ietf = nil + mockGenericHash = nil + mockSign = nil testUserDefaults = nil dependencies = nil @@ -280,7 +326,7 @@ class OpenGroupAPISpec: QuickSpec { } it("retrieves recent messages if there was a last message and it has not performed the initial poll and the last message was too long ago") { - testStorage.mockData[.openGroupSequenceNumber] = ["testServer.testRoom": Int64(123)] + mockStorage.when { $0.getOpenGroupSequenceNumber(for: any(), on: any()) }.thenReturn(123) OpenGroupAPI .poll( @@ -303,7 +349,7 @@ class OpenGroupAPISpec: QuickSpec { } it("retrieves recent messages if there was a last message and it has performed an initial poll but it was not too long ago") { - testStorage.mockData[.openGroupSequenceNumber] = ["testServer.testRoom": Int64(123)] + mockStorage.when { $0.getOpenGroupSequenceNumber(for: any(), on: any()) }.thenReturn(123) OpenGroupAPI .poll( @@ -326,7 +372,7 @@ class OpenGroupAPISpec: QuickSpec { } it("retrieves recent messages if there was a last message and there has already been a poll this session") { - testStorage.mockData[.openGroupSequenceNumber] = ["testServer.testRoom": Int64(123)] + mockStorage.when { $0.getOpenGroupSequenceNumber(for: any(), on: any()) }.thenReturn(123) OpenGroupAPI .poll( @@ -370,7 +416,7 @@ class OpenGroupAPISpec: QuickSpec { } it("retrieves inbox messages since the last message if there was one") { - testStorage.mockData[.openGroupInboxLatestMessageId] = ["testServer": Int64(124)] + mockStorage.when { $0.getOpenGroupInboxLatestMessageId(for: any()) }.thenReturn(124) OpenGroupAPI .poll( @@ -414,7 +460,7 @@ class OpenGroupAPISpec: QuickSpec { } it("retrieves outbox messages since the last message if there was one") { - testStorage.mockData[.openGroupOutboxLatestMessageId] = ["testServer": Int64(125)] + mockStorage.when { $0.getOpenGroupOutboxLatestMessageId(for: any()) }.thenReturn(125) OpenGroupAPI .poll( @@ -439,7 +485,7 @@ class OpenGroupAPISpec: QuickSpec { context("and given an invalid response") { it("does not update the poll state") { - testStorage.mockData[.openGroupSequenceNumber] = ["testServer.testRoom": Int64(123)] + mockStorage.when { $0.getOpenGroupSequenceNumber(for: any(), on: any()) }.thenReturn(123) OpenGroupAPI.poll("testServer", hasPerformedInitialPoll: false, timeSinceLastPoll: 0, using: dependencies) .get { result in pollResponse = result } @@ -1028,7 +1074,7 @@ class OpenGroupAPISpec: QuickSpec { messageData = LocalTestApi.data dependencies = dependencies.with(api: LocalTestApi.self) - testStorage.mockData[.userEdKeyPair] = Box.KeyPair(publicKey: [], secretKey: []) + mockStorage.when { $0.getUserED25519KeyPair() }.thenReturn(Box.KeyPair(publicKey: [], secretKey: [])) } afterEach { @@ -1092,15 +1138,22 @@ class OpenGroupAPISpec: QuickSpec { timeout: .milliseconds(100) ) expect(error?.localizedDescription).to(beNil()) - expect(testStorage.mockData[.receivedMessageTimestamp] as? UInt64).to(equal(321000)) + expect(mockStorage) + .to(call(matchingParameters: true) { + $0.addReceivedMessageTimestamp(321000, using: any()) + }) } context("when unblinded") { beforeEach { - testStorage.mockData[.openGroupServer] = OpenGroupAPI.Server( - name: "testServer", - capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: []) - ) + mockStorage + .when { $0.getOpenGroupServer(name: any()) } + .thenReturn( + OpenGroupAPI.Server( + name: "testServer", + capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: []) + ) + ) } it("signs the message correctly") { @@ -1136,7 +1189,7 @@ class OpenGroupAPISpec: QuickSpec { } it("fails to sign if there is no public key") { - testStorage.mockData[.openGroupPublicKeys] = [:] + mockStorage.when { $0.getOpenGroupPublicKey(for: any()) }.thenReturn(nil) var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? @@ -1166,10 +1219,14 @@ class OpenGroupAPISpec: QuickSpec { context("when blinded") { beforeEach { - testStorage.mockData[.openGroupServer] = OpenGroupAPI.Server( - name: "testServer", - capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs, .blind], missing: []) - ) + mockStorage + .when { $0.getOpenGroupServer(name: any()) } + .thenReturn( + OpenGroupAPI.Server( + name: "testServer", + capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs, .blind], missing: []) + ) + ) } it("signs the message correctly") { @@ -1205,7 +1262,7 @@ class OpenGroupAPISpec: QuickSpec { } it("fails to sign if there is no public key") { - testStorage.mockData[.openGroupPublicKeys] = [:] + mockStorage.when { $0.getOpenGroupPublicKey(for: any()) }.thenReturn(nil) var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? @@ -1286,7 +1343,7 @@ class OpenGroupAPISpec: QuickSpec { } dependencies = dependencies.with(api: LocalTestApi.self) - testStorage.mockData[.userEdKeyPair] = Box.KeyPair(publicKey: [], secretKey: []) + mockStorage.when { $0.getUserED25519KeyPair() }.thenReturn(Box.KeyPair(publicKey: [], secretKey: [])) } it("correctly sends the update") { @@ -1321,10 +1378,14 @@ class OpenGroupAPISpec: QuickSpec { context("when unblinded") { beforeEach { - testStorage.mockData[.openGroupServer] = OpenGroupAPI.Server( - name: "testServer", - capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: []) - ) + mockStorage + .when { $0.getOpenGroupServer(name: any()) } + .thenReturn( + OpenGroupAPI.Server( + name: "testServer", + capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: []) + ) + ) } it("signs the message correctly") { @@ -1359,7 +1420,7 @@ class OpenGroupAPISpec: QuickSpec { } it("fails to sign if there is no public key") { - testStorage.mockData[.openGroupPublicKeys] = [:] + mockStorage.when { $0.getOpenGroupPublicKey(for: any()) }.thenReturn(nil) var response: (info: OnionRequestResponseInfoType, data: Data?)? @@ -1388,10 +1449,14 @@ class OpenGroupAPISpec: QuickSpec { context("when blinded") { beforeEach { - testStorage.mockData[.openGroupServer] = OpenGroupAPI.Server( - name: "testServer", - capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs, .blind], missing: []) - ) + mockStorage + .when { $0.getOpenGroupServer(name: any()) } + .thenReturn( + OpenGroupAPI.Server( + name: "testServer", + capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs, .blind], missing: []) + ) + ) } it("signs the message correctly") { @@ -1426,7 +1491,7 @@ class OpenGroupAPISpec: QuickSpec { } it("fails to sign if there is no public key") { - testStorage.mockData[.openGroupPublicKeys] = [:] + mockStorage.when { $0.getOpenGroupPublicKey(for: any()) }.thenReturn(nil) var response: (info: OnionRequestResponseInfoType, data: Data?)? @@ -1483,6 +1548,47 @@ class OpenGroupAPISpec: QuickSpec { } } + context("when deleting all messages for a user") { + var response: (info: OnionRequestResponseInfoType, data: Data?)? + + beforeEach { + class LocalTestApi: TestApi { + override class var mockResponse: Data? { return Data() } + } + dependencies = dependencies.with(api: LocalTestApi.self) + } + + afterEach { + response = nil + } + + it("generates the request and handles the response correctly") { + OpenGroupAPI + .messagesDeleteAll( + "testUserId", + in: "testRoom", + on: "testServer", + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate request data + let requestData: TestApi.RequestData? = (response?.info as? TestResponseInfo)?.requestData + expect(requestData?.httpMethod).to(equal("DELETE")) + expect(requestData?.server).to(equal("testServer")) + expect(requestData?.urlString).to(equal("testServer/room/testRoom/all/testUserId")) + } + } + // MARK: - Pinning context("when pinning a message") { @@ -1759,7 +1865,10 @@ class OpenGroupAPISpec: QuickSpec { timeout: .milliseconds(100) ) expect(error?.localizedDescription).to(beNil()) - expect(testStorage.mockData[.receivedMessageTimestamp] as? UInt64).to(equal(321000)) + expect(mockStorage) + .to(call(matchingParameters: true) { + $0.addReceivedMessageTimestamp(321000, using: any()) + }) } } @@ -2086,121 +2195,11 @@ class OpenGroupAPISpec: QuickSpec { } } - context("when deleting a users messages") { - var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.UserDeleteMessagesResponse)? - var messageData: OpenGroupAPI.UserDeleteMessagesResponse! - - beforeEach { - class LocalTestApi: TestApi { - static let data: OpenGroupAPI.UserDeleteMessagesResponse = OpenGroupAPI.UserDeleteMessagesResponse( - id: "testId", - messagesDeleted: 10 - ) - - override class var mockResponse: Data? { return try! JSONEncoder().encode(data) } - } - messageData = LocalTestApi.data - dependencies = dependencies.with(api: LocalTestApi.self) - } - - afterEach { - response = nil - } - - it("generates the request and handles the response correctly") { - OpenGroupAPI - .userDeleteMessages( - "testUserId", - from: nil, - on: "testServer", - using: dependencies - ) - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate the response data - expect(response?.data).to(equal(messageData)) - - // Validate request data - let requestData: TestApi.RequestData? = (response?.info as? TestResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.server).to(equal("testServer")) - expect(requestData?.urlString).to(equal("testServer/user/testUserId/deleteMessages")) - } - - it("does a global delete if no room tokens are provided") { - OpenGroupAPI - .userDeleteMessages( - "testUserId", - from: nil, - on: "testServer", - using: dependencies - ) - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request data - let requestData: TestApi.RequestData? = (response?.info as? TestResponseInfo)?.requestData - let requestBody: OpenGroupAPI.UserDeleteMessagesRequest = try! JSONDecoder().decode(OpenGroupAPI.UserDeleteMessagesRequest.self, from: requestData!.body!) - - expect(requestBody.global).to(beTrue()) - expect(requestBody.rooms).to(beNil()) - } - - it("does room specific bans if room tokens are provided") { - OpenGroupAPI - .userDeleteMessages( - "testUserId", - from: ["testRoom"], - on: "testServer", - using: dependencies - ) - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request data - let requestData: TestApi.RequestData? = (response?.info as? TestResponseInfo)?.requestData - let requestBody: OpenGroupAPI.UserDeleteMessagesRequest = try! JSONDecoder().decode(OpenGroupAPI.UserDeleteMessagesRequest.self, from: requestData!.body!) - - expect(requestBody.global).to(beNil()) - expect(requestBody.rooms).to(equal(["testRoom"])) - } - } - context("when banning and deleting all messages for a user") { var response: [OnionRequestResponseInfoType]? beforeEach { class LocalTestApi: TestApi { - static let deleteMessagesData: OpenGroupAPI.UserDeleteMessagesResponse = OpenGroupAPI.UserDeleteMessagesResponse( - id: "123", - messagesDeleted: 10 - ) - override class var mockResponse: Data? { let responses: [Data] = [ try! JSONEncoder().encode( @@ -2212,10 +2211,10 @@ class OpenGroupAPISpec: QuickSpec { ) ), try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( + OpenGroupAPI.BatchSubResponse( code: 200, headers: [:], - body: deleteMessagesData, + body: nil, failedToParseBody: false ) ) @@ -2233,9 +2232,9 @@ class OpenGroupAPISpec: QuickSpec { it("generates the request and handles the response correctly") { OpenGroupAPI - .userBanAndDeleteAllMessage( + .userBanAndDeleteAllMessages( "testUserId", - from: nil, + in: "testRoom", on: "testServer", using: dependencies ) @@ -2257,11 +2256,11 @@ class OpenGroupAPISpec: QuickSpec { expect(requestData?.urlString).to(equal("testServer/sequence")) } - it("does a global ban and delete if no room tokens are provided") { + it("bans the user from the specified room rather than globally") { OpenGroupAPI - .userBanAndDeleteAllMessage( + .userBanAndDeleteAllMessages( "testUserId", - from: nil, + in: "testRoom", on: "testServer", using: dependencies ) @@ -2282,59 +2281,13 @@ class OpenGroupAPISpec: QuickSpec { with: requestData!.body!, options: [.fragmentsAllowed] ) - let anyArray: [Any] = jsonObject as! [Any] - let dataArray: [Data] = anyArray.compactMap { - try! JSONSerialization.data(withJSONObject: ($0 as! [String: Any])["json"]!) - } + let firstJsonObject: Any = ((jsonObject as! [Any]).first as! [String: Any])["json"]! + let firstJsonData: Data = try! JSONSerialization.data(withJSONObject: firstJsonObject) let firstRequestBody: OpenGroupAPI.UserBanRequest = try! JSONDecoder() - .decode(OpenGroupAPI.UserBanRequest.self, from: dataArray.first!) - let lastRequestBody: OpenGroupAPI.UserDeleteMessagesRequest = try! JSONDecoder() - .decode(OpenGroupAPI.UserDeleteMessagesRequest.self, from: dataArray.last!) - - expect(firstRequestBody.global).to(beTrue()) - expect(firstRequestBody.rooms).to(beNil()) - expect(lastRequestBody.global).to(beTrue()) - expect(lastRequestBody.rooms).to(beNil()) - } - - it("does room specific bans and deletes if room tokens are provided") { - OpenGroupAPI - .userBanAndDeleteAllMessage( - "testUserId", - from: ["testRoom"], - on: "testServer", - using: dependencies - ) - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request data - let requestData: TestApi.RequestData? = (response?.first as? TestResponseInfo)?.requestData - let jsonObject: Any = try! JSONSerialization.jsonObject( - with: requestData!.body!, - options: [.fragmentsAllowed] - ) - let anyArray: [Any] = jsonObject as! [Any] - let dataArray: [Data] = anyArray.compactMap { - try! JSONSerialization.data(withJSONObject: ($0 as! [String: Any])["json"]!) - } - let firstRequestBody: OpenGroupAPI.UserBanRequest = try! JSONDecoder() - .decode(OpenGroupAPI.UserBanRequest.self, from: dataArray.first!) - let lastRequestBody: OpenGroupAPI.UserDeleteMessagesRequest = try! JSONDecoder() - .decode(OpenGroupAPI.UserDeleteMessagesRequest.self, from: dataArray.last!) + .decode(OpenGroupAPI.UserBanRequest.self, from: firstJsonData) expect(firstRequestBody.global).to(beNil()) expect(firstRequestBody.rooms).to(equal(["testRoom"])) - expect(lastRequestBody.global).to(beNil()) - expect(lastRequestBody.rooms).to(equal(["testRoom"])) } } @@ -2352,7 +2305,7 @@ class OpenGroupAPISpec: QuickSpec { } it("fails when there is no userEdKeyPair") { - testStorage.mockData[.userEdKeyPair] = nil + mockStorage.when { $0.getUserED25519KeyPair() }.thenReturn(nil) OpenGroupAPI.rooms(for: "testServer", using: dependencies) .get { result in response = result } @@ -2369,7 +2322,7 @@ class OpenGroupAPISpec: QuickSpec { } it("fails when there is no serverPublicKey") { - testStorage.mockData[.openGroupPublicKeys] = [:] + mockStorage.when { $0.getOpenGroupPublicKey(for: any()) }.thenReturn(nil) OpenGroupAPI.rooms(for: "testServer", using: dependencies) .get { result in response = result } @@ -2386,7 +2339,7 @@ class OpenGroupAPISpec: QuickSpec { } it("fails when the serverPublicKey is not a hex string") { - testStorage.mockData[.openGroupPublicKeys] = ["testServer": "TestString!!!"] + mockStorage.when { $0.getOpenGroupPublicKey(for: any()) }.thenReturn("TestString!!!") OpenGroupAPI.rooms(for: "testServer", using: dependencies) .get { result in response = result } @@ -2404,10 +2357,14 @@ class OpenGroupAPISpec: QuickSpec { context("when unblinded") { beforeEach { - testStorage.mockData[.openGroupServer] = OpenGroupAPI.Server( - name: "testServer", - capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: []) - ) + mockStorage + .when { $0.getOpenGroupServer(name: any()) } + .thenReturn( + OpenGroupAPI.Server( + name: "testServer", + capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: []) + ) + ) } it("signs correctly") { @@ -2438,7 +2395,7 @@ class OpenGroupAPISpec: QuickSpec { } it("fails when the signature is not generated") { - testSign.mockData[.signature] = nil + mockSign.when { $0.signature(message: any(), secretKey: any()) }.thenReturn(nil) OpenGroupAPI.rooms(for: "testServer", using: dependencies) .get { result in response = result } @@ -2457,10 +2414,14 @@ class OpenGroupAPISpec: QuickSpec { context("when blinded") { beforeEach { - testStorage.mockData[.openGroupServer] = OpenGroupAPI.Server( - name: "testServer", - capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs, .blind], missing: []) - ) + mockStorage + .when { $0.getOpenGroupServer(name: any()) } + .thenReturn( + OpenGroupAPI.Server( + name: "testServer", + capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs, .blind], missing: []) + ) + ) } it("signs correctly") { @@ -2490,7 +2451,9 @@ class OpenGroupAPISpec: QuickSpec { } it("fails when the blindedKeyPair is not generated") { - testSodium.mockData[.blindedKeyPair] = nil + mockSodium + .when { $0.blindedKeyPair(serverPublicKey: any(), edKeyPair: any(), genericHash: mockGenericHash) } + .thenReturn(nil) OpenGroupAPI.rooms(for: "testServer", using: dependencies) .get { result in response = result } @@ -2507,7 +2470,9 @@ class OpenGroupAPISpec: QuickSpec { } it("fails when the sogsSignature is not generated") { - testSodium.mockData[.sogsSignature] = nil + mockSodium + .when { $0.blindedKeyPair(serverPublicKey: any(), edKeyPair: any(), genericHash: mockGenericHash) } + .thenReturn(nil) OpenGroupAPI.rooms(for: "testServer", using: dependencies) .get { result in response = result } diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift new file mode 100644 index 000000000..0a8ab0dac --- /dev/null +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -0,0 +1,3 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation diff --git a/SessionMessagingKitTests/Open Groups/Types/SodiumProtocolsSpec.swift b/SessionMessagingKitTests/Open Groups/Types/SodiumProtocolsSpec.swift index d746e40e5..8d698b04d 100644 --- a/SessionMessagingKitTests/Open Groups/Types/SodiumProtocolsSpec.swift +++ b/SessionMessagingKitTests/Open Groups/Types/SodiumProtocolsSpec.swift @@ -15,15 +15,24 @@ class SodiumProtocolsSpec: QuickSpec { let testValue: [UInt8] = [1, 2, 3] it("provides the default values in it's extensions") { - let testAead: TestAeadXChaCha20Poly1305Ietf = TestAeadXChaCha20Poly1305Ietf() - testAead.mockData[.encrypt] = testValue - testAead.mockData[.decrypt] = testValue + let mockAead: MockAeadXChaCha20Poly1305Ietf = MockAeadXChaCha20Poly1305Ietf() + mockAead + .when { $0.encrypt(message: any(), secretKey: any(), nonce: any(), additionalData: any()) } + .thenReturn(testValue) + mockAead + .when { $0.decrypt(authenticatedCipherText: any(), secretKey: any(), nonce: any(), additionalData: any()) } + .thenReturn(testValue) - expect(testAead.encrypt(message: [], secretKey: [], nonce: [])).to(equal(testValue)) - expect(testAead.encrypt(message: [], secretKey: [], nonce: [], additionalData: nil)).to(equal(testValue)) - expect(testAead.decrypt(authenticatedCipherText: [], secretKey: [], nonce: [])).to(equal(testValue)) - expect(testAead.decrypt(authenticatedCipherText: [], secretKey: [], nonce: [], additionalData: nil)) - .to(equal(testValue)) + _ = mockAead.encrypt(message: [], secretKey: [], nonce: []) + _ = mockAead.decrypt(authenticatedCipherText: [], secretKey: [], nonce: []) + + expect(mockAead) + .to(call { $0.encrypt(message: any(), secretKey: any(), nonce: any(), additionalData: any()) }) + + expect(mockAead) + .to(call { + $0.decrypt(authenticatedCipherText: any(), secretKey: any(), nonce: any(), additionalData: any()) + }) } } @@ -31,16 +40,21 @@ class SodiumProtocolsSpec: QuickSpec { let testValue: [UInt8] = [1, 2, 3] it("provides the default values in it's extensions") { - let testGenericHash: TestGenericHash = TestGenericHash() - testGenericHash.mockData[.hash] = testValue - testGenericHash.mockData[.hashSaltPersonal] = testValue + let mockGenericHash: MockGenericHash = MockGenericHash() + mockGenericHash.when { $0.hash(message: any(), key: any()) }.thenReturn(testValue) + mockGenericHash + .when { $0.hashSaltPersonal(message: any(), outputLength: any(), key: any(), salt: any(), personal: any()) } + .thenReturn(testValue) - expect(testGenericHash.hash(message: [])).to(equal(testValue)) - expect(testGenericHash.hash(message: [], key: nil)).to(equal(testValue)) - expect(testGenericHash.hashSaltPersonal(message: [], outputLength: 0, salt: [], personal: [])) - .to(equal(testValue)) - expect(testGenericHash.hashSaltPersonal(message: [], outputLength: 0, key: nil, salt: [], personal: [])) - .to(equal(testValue)) + _ = mockGenericHash.hash(message: []) + _ = mockGenericHash.hashSaltPersonal(message: [], outputLength: 0, salt: [], personal: []) + + expect(mockGenericHash) + .to(call { $0.hash(message: any(), key: any()) }) + expect(mockGenericHash) + .to(call { + $0.hashSaltPersonal(message: any(), outputLength: any(), key: any(), salt: any(), personal: any()) + }) } } } diff --git a/SessionMessagingKitTests/_TestUtilities/BoxKeyPair+Mocked.swift b/SessionMessagingKitTests/_TestUtilities/BoxKeyPair+Mocked.swift new file mode 100644 index 000000000..0a6d2ed2a --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/BoxKeyPair+Mocked.swift @@ -0,0 +1,11 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium + +extension Box.KeyPair: Mocked { + static var mockValue: Box.KeyPair = Box.KeyPair( + publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes + ) +} diff --git a/SessionMessagingKitTests/_TestUtilities/TestAeadXChaCha20Poly1305Ietf.swift b/SessionMessagingKitTests/_TestUtilities/MockAeadXChaCha20Poly1305Ietf.swift similarity index 53% rename from SessionMessagingKitTests/_TestUtilities/TestAeadXChaCha20Poly1305Ietf.swift rename to SessionMessagingKitTests/_TestUtilities/MockAeadXChaCha20Poly1305Ietf.swift index 13bffd852..09b0f9ce1 100644 --- a/SessionMessagingKitTests/_TestUtilities/TestAeadXChaCha20Poly1305Ietf.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockAeadXChaCha20Poly1305Ietf.swift @@ -6,28 +6,15 @@ import Sodium @testable import SessionMessagingKit -class TestAeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType, Mockable { +class MockAeadXChaCha20Poly1305Ietf: Mock, AeadXChaCha20Poly1305IetfType { var KeyBytes: Int = 32 var ABytes: Int = 16 - // MARK: - Mockable - - enum DataKey: Hashable { - case encrypt - case decrypt - } - - typealias Key = DataKey - - var mockData: [DataKey: Any] = [:] - - // MARK: - SignType - func encrypt(message: Bytes, secretKey: Bytes, nonce: Bytes, additionalData: Bytes?) -> Bytes? { - return (mockData[.encrypt] as? Bytes) + return accept(args: [message, secretKey, nonce, additionalData]) as? Bytes } func decrypt(authenticatedCipherText: Bytes, secretKey: Bytes, nonce: Bytes, additionalData: Bytes?) -> Bytes? { - return (mockData[.decrypt] as? Bytes) + return accept(args: [authenticatedCipherText, secretKey, nonce, additionalData]) as? Bytes } } diff --git a/SessionMessagingKitTests/_TestUtilities/MockEd25519.swift b/SessionMessagingKitTests/_TestUtilities/MockEd25519.swift new file mode 100644 index 000000000..ce05862dd --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/MockEd25519.swift @@ -0,0 +1,13 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import PromiseKit +import Sodium + +@testable import SessionMessagingKit + +class MockEd25519: Mock, Ed25519Type { + func verifySignature(_ signature: Data, publicKey: Data, data: Data) throws -> Bool { + return accept(args: [signature, publicKey, data]) as! Bool + } +} diff --git a/SessionMessagingKitTests/_TestUtilities/MockGenericHash.swift b/SessionMessagingKitTests/_TestUtilities/MockGenericHash.swift new file mode 100644 index 000000000..3a97611bf --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/MockGenericHash.swift @@ -0,0 +1,21 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import PromiseKit +import Sodium + +@testable import SessionMessagingKit + +class MockGenericHash: Mock, GenericHashType { + func hash(message: Bytes, key: Bytes?) -> Bytes? { + return accept(args: [message, key]) as? Bytes + } + + func hash(message: Bytes, outputLength: Int) -> Bytes? { + return accept(args: [message, outputLength]) as? Bytes + } + + func hashSaltPersonal(message: Bytes, outputLength: Int, key: Bytes?, salt: Bytes, personal: Bytes) -> Bytes? { + return accept(args: [message, outputLength, key, salt, personal]) as? Bytes + } +} diff --git a/SessionMessagingKitTests/_TestUtilities/TestSign.swift b/SessionMessagingKitTests/_TestUtilities/MockSign.swift similarity index 50% rename from SessionMessagingKitTests/_TestUtilities/TestSign.swift rename to SessionMessagingKitTests/_TestUtilities/MockSign.swift index 3d2402bbf..19f3b00de 100644 --- a/SessionMessagingKitTests/_TestUtilities/TestSign.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockSign.swift @@ -6,32 +6,18 @@ import Sodium @testable import SessionMessagingKit -class TestSign: SignType, Mockable { +class MockSign: Mock, SignType { var PublicKeyBytes: Int = 32 - // MARK: - Mockable - - enum DataKey: Hashable { - case signature - case verify - case toX25519 - } - - typealias Key = DataKey - - var mockData: [DataKey: Any] = [:] - - // MARK: - SignType - func signature(message: Bytes, secretKey: Bytes) -> Bytes? { - return (mockData[.signature] as? Bytes) + return accept(args: [message, secretKey]) as? Bytes } func verify(message: Bytes, publicKey: Bytes, signature: Bytes) -> Bool { - return (mockData[.verify] as! Bool) + return accept(args: [message, publicKey, signature]) as! Bool } func toX25519(ed25519PublicKey: Bytes) -> Bytes? { - return (mockData[.toX25519] as? Bytes) + return accept(args: [ed25519PublicKey]) as? Bytes } } diff --git a/SessionMessagingKitTests/_TestUtilities/MockSodium.swift b/SessionMessagingKitTests/_TestUtilities/MockSodium.swift new file mode 100644 index 000000000..502bec493 --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/MockSodium.swift @@ -0,0 +1,37 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import PromiseKit +import Sodium + +@testable import SessionMessagingKit + +class MockSodium: Mock, SodiumType { + func getGenericHash() -> GenericHashType { return accept() as! GenericHashType } + func getAeadXChaCha20Poly1305Ietf() -> AeadXChaCha20Poly1305IetfType { return accept() as! AeadXChaCha20Poly1305IetfType } + func getSign() -> SignType { return accept() as! SignType } + + func generateBlindingFactor(serverPublicKey: String) -> Bytes? { + return accept(args: [serverPublicKey]) as? Bytes + } + + func blindedKeyPair(serverPublicKey: String, edKeyPair: Box.KeyPair, genericHash: GenericHashType) -> Box.KeyPair? { + return accept(args: [serverPublicKey, edKeyPair, genericHash]) as? Box.KeyPair + } + + func sogsSignature(message: Bytes, secretKey: Bytes, blindedSecretKey ka: Bytes, blindedPublicKey kA: Bytes) -> Bytes? { + return accept(args: [message, secretKey, ka, kA]) as? Bytes + } + + func combineKeys(lhsKeyBytes: Bytes, rhsKeyBytes: Bytes) -> Bytes? { + return accept(args: [lhsKeyBytes, rhsKeyBytes]) as? Bytes + } + + func sharedBlindedEncryptionKey(secretKey a: Bytes, otherBlindedPublicKey: Bytes, fromBlindedPublicKey kA: Bytes, toBlindedPublicKey kB: Bytes, genericHash: GenericHashType) -> Bytes? { + return accept(args: [a, otherBlindedPublicKey, kA, kB, genericHash]) as? Bytes + } + + func sessionId(_ sessionId: String, matchesBlindedId blindedSessionId: String, serverPublicKey: String) -> Bool { + return accept(args: [sessionId, blindedSessionId, serverPublicKey]) as! Bool + } +} diff --git a/SessionMessagingKitTests/_TestUtilities/MockStorage.swift b/SessionMessagingKitTests/_TestUtilities/MockStorage.swift new file mode 100644 index 000000000..c8455ad10 --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/MockStorage.swift @@ -0,0 +1,179 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import PromiseKit +import Sodium + +@testable import SessionMessagingKit + +class MockStorage: Mock, SessionMessagingKitStorageProtocol { + // MARK: - Shared + + @discardableResult func write(with block: @escaping (Any) -> Void) -> Promise { + return accept(args: [block]) as! Promise + } + + @discardableResult func write(with block: @escaping (Any) -> Void, completion: @escaping () -> Void) -> Promise { + return accept(args: [block, completion]) as! Promise + } + + func writeSync(with block: @escaping (Any) -> Void) { + accept(args: [block]) + } + + // MARK: - General + + func getUserPublicKey() -> String? { return accept() as? String } + func getUserKeyPair() -> ECKeyPair? { return accept() as? ECKeyPair } + func getUserED25519KeyPair() -> Box.KeyPair? { return accept() as? Box.KeyPair } + func getUser() -> Contact? { return accept() as? Contact } + func getAllContacts() -> Set { return accept() as! Set } + func getAllContacts(with transaction: YapDatabaseReadTransaction) -> Set { return accept() as! Set } + + // MARK: - Blinded Id cache + + func getBlindedIdMapping(with blindedId: String) -> BlindedIdMapping? { + return accept(args: [blindedId]) as? BlindedIdMapping + } + + func getBlindedIdMapping(with blindedId: String, using transaction: YapDatabaseReadTransaction) -> BlindedIdMapping? { + return accept(args: [blindedId, transaction]) as? BlindedIdMapping + } + + func cacheBlindedIdMapping(_ mapping: BlindedIdMapping) { accept(args: [mapping]) } + func cacheBlindedIdMapping(_ mapping: BlindedIdMapping, using transaction: YapDatabaseReadWriteTransaction) { + accept(args: [mapping, transaction]) + } + func enumerateBlindedIdMapping(with block: @escaping (BlindedIdMapping, UnsafeMutablePointer) -> ()) { + accept(args: [block]) + } + func enumerateBlindedIdMapping(using transaction: YapDatabaseReadTransaction, with block: @escaping (BlindedIdMapping, UnsafeMutablePointer) -> ()) { + accept(args: [transaction, block]) + } + + // MARK: - Closed Groups + + func getUserClosedGroupPublicKeys() -> Set { return accept() as! Set } + func getZombieMembers(for groupPublicKey: String) -> Set { return accept() as! Set } + func setZombieMembers(for groupPublicKey: String, to zombies: Set, using transaction: Any) { + accept(args: [groupPublicKey, zombies, transaction]) + } + func isClosedGroup(_ publicKey: String) -> Bool { return accept() as! Bool } + + // MARK: - Jobs + + func persist(_ job: Job, using transaction: Any) { accept(args: [job, transaction]) } + func markJobAsSucceeded(_ job: Job, using transaction: Any) { accept(args: [job, transaction]) } + func markJobAsFailed(_ job: Job, using transaction: Any) { accept(args: [job, transaction]) } + func getAllPendingJobs(of type: Job.Type) -> [Job] { + return accept(args: [type]) as! [Job] + } + func getAttachmentUploadJob(for attachmentID: String) -> AttachmentUploadJob? { + return accept(args: [attachmentID]) as? AttachmentUploadJob + } + func getMessageSendJob(for messageSendJobID: String) -> MessageSendJob? { + return accept(args: [messageSendJobID]) as? MessageSendJob + } + func getMessageSendJob(for messageSendJobID: String, using transaction: Any) -> MessageSendJob? { + return accept(args: [messageSendJobID, transaction]) as? MessageSendJob + } + func resumeMessageSendJobIfNeeded(_ messageSendJobID: String) { accept(args: [messageSendJobID]) } + func isJobCanceled(_ job: Job) -> Bool { + return accept(args: [job]) as! Bool + } + + // MARK: - Open Groups + + func getAllOpenGroups() -> [String: OpenGroup] { return accept() as! [String: OpenGroup] } + func getThreadID(for v2OpenGroupID: String) -> String? { return accept(args: [v2OpenGroupID]) as? String } + func updateMessageIDCollectionByPruningMessagesWithIDs(_ messageIDs: Set, using transaction: Any) { + accept(args: [messageIDs, transaction]) + } + + func getOpenGroupImage(for room: String, on server: String) -> Data? { return accept(args: [room, server]) as? Data } + func setOpenGroupImage(to data: Data, for room: String, on server: String, using transaction: Any) { + accept(args: [data, room, server, transaction]) + } + + func getOpenGroup(for threadID: String) -> OpenGroup? { return accept(args: [threadID]) as? OpenGroup } + func setOpenGroup(_ openGroup: OpenGroup, for threadID: String, using transaction: Any) { + accept(args: [openGroup, threadID, transaction]) + } + func removeOpenGroup(for threadID: String, using transaction: Any) { accept(args: [threadID, transaction]) } + func getOpenGroupServer(name: String) -> OpenGroupAPI.Server? { return accept(args: [name]) as? OpenGroupAPI.Server } + func setOpenGroupServer(_ server: OpenGroupAPI.Server, using transaction: Any) { accept(args: [server, transaction]) } + func removeOpenGroupServer(name: String, using transaction: Any) { + accept(args: [name, transaction]) + } + + func getUserCount(forOpenGroupWithID openGroupID: String) -> UInt64? { return accept(args: [openGroupID]) as? UInt64 } + func setUserCount(to newValue: UInt64, forOpenGroupWithID openGroupID: String, using transaction: Any) { + accept(args: [newValue, openGroupID, transaction]) + } + + func getOpenGroupSequenceNumber(for room: String, on server: String) -> Int64? { + return accept(args: [room, server]) as? Int64 + } + func setOpenGroupSequenceNumber(for room: String, on server: String, to newValue: Int64, using transaction: Any) { + accept(args: [room, server, newValue, transaction]) + } + func removeOpenGroupSequenceNumber(for room: String, on server: String, using transaction: Any) { + accept(args: [room, server, transaction]) + } + + func getOpenGroupInboxLatestMessageId(for server: String) -> Int64? { return accept(args: [server]) as? Int64 } + func setOpenGroupInboxLatestMessageId(for server: String, to newValue: Int64, using transaction: Any) { + accept(args: [server, newValue, transaction]) + } + func removeOpenGroupInboxLatestMessageId(for server: String, using transaction: Any) { accept(args: [server, transaction]) } + + func getOpenGroupOutboxLatestMessageId(for server: String) -> Int64? { return accept(args: [server]) as? Int64 } + func setOpenGroupOutboxLatestMessageId(for server: String, to newValue: Int64, using transaction: Any) { + accept(args: [server, newValue, transaction]) + } + func removeOpenGroupOutboxLatestMessageId(for server: String, using transaction: Any) { + accept(args: [server, transaction]) + } + + // MARK: - Open Group Public Keys + + func getOpenGroupPublicKey(for server: String) -> String? { return accept(args: [server]) as? String } + func setOpenGroupPublicKey(for server: String, to newValue: String, using transaction: Any) { + accept(args: [server, newValue, transaction]) + } + func removeOpenGroupPublicKey(for server: String, using transaction: Any) { accept(args: [server, transaction]) } + + // MARK: - Message Handling + + func getAllMessageRequestThreads() -> [String: TSContactThread] { return accept() as! [String: TSContactThread] } + func getAllMessageRequestThreads(using transaction: YapDatabaseReadTransaction) -> [String: TSContactThread] { + return accept(args: [transaction]) as! [String: TSContactThread] + } + + func getReceivedMessageTimestamps(using transaction: Any) -> [UInt64] { + return accept(args: [transaction]) as! [UInt64] + } + + func removeReceivedMessageTimestamps(_ timestamps: Set, using transaction: Any) { + accept(args: [timestamps, transaction]) + } + func addReceivedMessageTimestamp(_ timestamp: UInt64, using transaction: Any) { + accept(args: [timestamp, transaction]) + } + + func getOrCreateThread(for publicKey: String, groupPublicKey: String?, openGroupID: String?, using transaction: Any) -> String? { + return accept(args: [publicKey, groupPublicKey, openGroupID, transaction]) as? String + } + func persist(_ message: VisibleMessage, quotedMessage: TSQuotedMessage?, linkPreview: OWSLinkPreview?, groupPublicKey: String?, openGroupID: String?, using transaction: Any) -> String? { + return accept(args: [message, quotedMessage, linkPreview, groupPublicKey, openGroupID, transaction]) as? String + } + func persist(_ attachments: [VisibleMessage.Attachment], using transaction: Any) -> [String] { + return accept(args: [attachments, transaction]) as! [String] + } + func setAttachmentState(to state: TSAttachmentPointerState, for pointer: TSAttachmentPointer, associatedWith tsIncomingMessageID: String, using transaction: Any) { + accept(args: [state, pointer, tsIncomingMessageID, transaction]) + } + func persist(_ stream: TSAttachmentStream, associatedWith tsIncomingMessageID: String, using transaction: Any) { + accept(args: [stream, tsIncomingMessageID, transaction]) + } +} diff --git a/SessionMessagingKitTests/_TestUtilities/TestEd25519.swift b/SessionMessagingKitTests/_TestUtilities/TestEd25519.swift deleted file mode 100644 index 43989397b..000000000 --- a/SessionMessagingKitTests/_TestUtilities/TestEd25519.swift +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import PromiseKit -import Sodium - -@testable import SessionMessagingKit - -class TestEd25519: Ed25519Type, StaticMockable { - // MARK: - Mockable - - enum DataKey: Hashable { - case verifySignature(signature: Data, publicKey: Data, data: Data) // TODO: Test the uniqueness of this - } - - typealias Key = DataKey - - static var mockData: [DataKey: Any] = [:] - - // MARK: - SignType - - static func verifySignature(_ signature: Data, publicKey: Data, data: Data) throws -> Bool { - return (mockData[.verifySignature(signature: signature, publicKey: publicKey, data: data)] as! Bool) - } -} diff --git a/SessionMessagingKitTests/_TestUtilities/TestGenericHash.swift b/SessionMessagingKitTests/_TestUtilities/TestGenericHash.swift deleted file mode 100644 index f6b51cfcd..000000000 --- a/SessionMessagingKitTests/_TestUtilities/TestGenericHash.swift +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import PromiseKit -import Sodium - -@testable import SessionMessagingKit - -class TestGenericHash: GenericHashType, Mockable { - // MARK: - Mockable - - enum DataKey: Hashable { - case hash - case hashOutputLength - case hashSaltPersonal - } - - typealias Key = DataKey - - var mockData: [DataKey: Any] = [:] - - // MARK: - SignType - - func hash(message: Bytes, key: Bytes?) -> Bytes? { - return (mockData[.hash] as? Bytes) - } - - func hash(message: Bytes, outputLength: Int) -> Bytes? { - return (mockData[.hashOutputLength] as? Bytes) - } - - func hashSaltPersonal(message: Bytes, outputLength: Int, key: Bytes?, salt: Bytes, personal: Bytes) -> Bytes? { - return (mockData[.hashSaltPersonal] as? Bytes) - } -} diff --git a/SessionMessagingKitTests/_TestUtilities/TestInteraction.swift b/SessionMessagingKitTests/_TestUtilities/TestInteraction.swift new file mode 100644 index 000000000..0a8ab0dac --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/TestInteraction.swift @@ -0,0 +1,3 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation diff --git a/SessionMessagingKitTests/_TestUtilities/TestSodium.swift b/SessionMessagingKitTests/_TestUtilities/TestSodium.swift deleted file mode 100644 index b8132ba62..000000000 --- a/SessionMessagingKitTests/_TestUtilities/TestSodium.swift +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import PromiseKit -import Sodium - -@testable import SessionMessagingKit - -class TestSodium: SodiumType, Mockable { - // MARK: - Mockable - - enum DataKey: Hashable { - case genericHash - case aeadXChaCha20Poly1305Ietf - case sign - case blindingFactor - case blindedKeyPair - case sogsSignature - case combinedKeys - case sharedBlindedEncryptionKey - case sessionIdMatches - } - - typealias Key = DataKey - - var mockData: [DataKey: Any] = [:] - - // MARK: - SodiumType - - func getGenericHash() -> GenericHashType { return (mockData[.genericHash] as! GenericHashType) } - - func getAeadXChaCha20Poly1305Ietf() -> AeadXChaCha20Poly1305IetfType { - return (mockData[.aeadXChaCha20Poly1305Ietf] as! AeadXChaCha20Poly1305IetfType) - } - - func getSign() -> SignType { return (mockData[.sign] as! SignType) } - - func generateBlindingFactor(serverPublicKey: String) -> Bytes? { return (mockData[.blindingFactor] as? Bytes) } - - func blindedKeyPair(serverPublicKey: String, edKeyPair: Box.KeyPair, genericHash: GenericHashType) -> Box.KeyPair? { - return (mockData[.blindedKeyPair] as? Box.KeyPair) - } - - func sogsSignature(message: Bytes, secretKey: Bytes, blindedSecretKey ka: Bytes, blindedPublicKey kA: Bytes) -> Bytes? { - return (mockData[.sogsSignature] as? Bytes) - } - - func combineKeys(lhsKeyBytes: Bytes, rhsKeyBytes: Bytes) -> Bytes? { - return (mockData[.combinedKeys] as? Bytes) - } - - func sharedBlindedEncryptionKey(secretKey a: Bytes, otherBlindedPublicKey: Bytes, fromBlindedPublicKey kA: Bytes, toBlindedPublicKey kB: Bytes, genericHash: GenericHashType) -> Bytes? { - return (mockData[.sharedBlindedEncryptionKey] as? Bytes) - } - - func sessionId(_ sessionId: String, matchesBlindedId blindedSessionId: String, serverPublicKey: String) -> Bool { - return ((mockData[.sessionIdMatches] as? Bool) ?? false) - } -} diff --git a/SessionMessagingKitTests/_TestUtilities/TestStorage.swift b/SessionMessagingKitTests/_TestUtilities/TestStorage.swift deleted file mode 100644 index b5d71b3b5..000000000 --- a/SessionMessagingKitTests/_TestUtilities/TestStorage.swift +++ /dev/null @@ -1,193 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import PromiseKit -import Sodium - -@testable import SessionMessagingKit - -class TestStorage: SessionMessagingKitStorageProtocol, Mockable { - // MARK: - Mockable - - enum DataKey: Hashable { - case allOpenGroups - case openGroupPublicKeys - case userKeyPair - case userEdKeyPair - case openGroup - case openGroupServer - case openGroupImage - case openGroupUserCount - case openGroupSequenceNumber - case openGroupInboxLatestMessageId - case openGroupOutboxLatestMessageId - case receivedMessageTimestamp - } - - typealias Key = DataKey - - var mockData: [DataKey: Any] = [:] - - // MARK: - Shared - - @discardableResult func write(with block: @escaping (Any) -> Void) -> Promise { - block(()) // TODO: Pass Transaction type to prevent force-cast crashes throughout codebase - return Promise.value(()) - } - - @discardableResult func write(with block: @escaping (Any) -> Void, completion: @escaping () -> Void) -> Promise { - block(()) // TODO: Pass Transaction type to prevent force-cast crashes throughout codebase - return Promise.value(()) - } - - func writeSync(with block: @escaping (Any) -> Void) { - block(()) // TODO: Pass Transaction type to prevent force-cast crashes throughout codebase - } - - // MARK: - General - - func getUserPublicKey() -> String? { return nil } - func getUserKeyPair() -> ECKeyPair? { return (mockData[.userKeyPair] as? ECKeyPair) } - func getUserED25519KeyPair() -> Box.KeyPair? { return (mockData[.userEdKeyPair] as? Box.KeyPair) } - func getUser() -> Contact? { return nil } - func getAllContacts() -> Set { return Set() } - func getAllContacts(with transaction: YapDatabaseReadTransaction) -> Set { return Set() } - - // MARK: - Blinded Id cache - - func getBlindedIdMapping(with blindedId: String) -> BlindedIdMapping? { return nil } - func getBlindedIdMapping(with blindedId: String, using transaction: YapDatabaseReadTransaction) -> BlindedIdMapping? { - return nil - } - - func cacheBlindedIdMapping(_ mapping: BlindedIdMapping) {} - func cacheBlindedIdMapping(_ mapping: BlindedIdMapping, using transaction: YapDatabaseReadWriteTransaction) {} - func enumerateBlindedIdMapping(with block: @escaping (BlindedIdMapping, UnsafeMutablePointer) -> ()) {} - func enumerateBlindedIdMapping(using transaction: YapDatabaseReadTransaction, with block: @escaping (BlindedIdMapping, UnsafeMutablePointer) -> ()) { - } - - // MARK: - Closed Groups - - func getUserClosedGroupPublicKeys() -> Set { return Set() } - func getZombieMembers(for groupPublicKey: String) -> Set { return Set() } - func setZombieMembers(for groupPublicKey: String, to zombies: Set, using transaction: Any) {} - func isClosedGroup(_ publicKey: String) -> Bool { return false } - - // MARK: - Jobs - - func persist(_ job: Job, using transaction: Any) {} - func markJobAsSucceeded(_ job: Job, using transaction: Any) {} - func markJobAsFailed(_ job: Job, using transaction: Any) {} - func getAllPendingJobs(of type: Job.Type) -> [Job] { return [] } - func getAttachmentUploadJob(for attachmentID: String) -> AttachmentUploadJob? { return nil } - func getMessageSendJob(for messageSendJobID: String) -> MessageSendJob? { return nil } - func getMessageSendJob(for messageSendJobID: String, using transaction: Any) -> MessageSendJob? { return nil } - func resumeMessageSendJobIfNeeded(_ messageSendJobID: String) {} - func isJobCanceled(_ job: Job) -> Bool { return true } - - // MARK: - Open Groups - - func getAllOpenGroups() -> [String: OpenGroup] { return (mockData[.allOpenGroups] as! [String: OpenGroup]) } - func getThreadID(for v2OpenGroupID: String) -> String? { return nil } - func updateMessageIDCollectionByPruningMessagesWithIDs(_ messageIDs: Set, using transaction: Any) {} - - func getOpenGroupImage(for room: String, on server: String) -> Data? { return (mockData[.openGroupImage] as? Data) } - func setOpenGroupImage(to data: Data, for room: String, on server: String, using transaction: Any) { - mockData[.openGroupImage] = data - } - - func getOpenGroup(for threadID: String) -> OpenGroup? { return (mockData[.openGroup] as? OpenGroup) } - func setOpenGroup(_ openGroup: OpenGroup, for threadID: String, using transaction: Any) { mockData[.openGroup] = openGroup } - func getOpenGroupServer(name: String) -> OpenGroupAPI.Server? { return mockData[.openGroupServer] as? OpenGroupAPI.Server } - func setOpenGroupServer(_ server: OpenGroupAPI.Server, using transaction: Any) { mockData[.openGroupServer] = server } - - func getUserCount(forOpenGroupWithID openGroupID: String) -> UInt64? { - return (mockData[.openGroupUserCount] as? UInt64) - } - - func setUserCount(to newValue: UInt64, forOpenGroupWithID openGroupID: String, using transaction: Any) { - mockData[.openGroupUserCount] = newValue - } - - func getOpenGroupSequenceNumber(for room: String, on server: String) -> Int64? { - let data: [String: Int64] = ((mockData[.openGroupSequenceNumber] as? [String: Int64]) ?? [:]) - return data["\(server).\(room)"] - } - - func setOpenGroupSequenceNumber(for room: String, on server: String, to newValue: Int64, using transaction: Any) { - var updatedData: [String: Int64] = ((mockData[.openGroupSequenceNumber] as? [String: Int64]) ?? [:]) - updatedData["\(server).\(room)"] = newValue - mockData[.openGroupSequenceNumber] = updatedData - } - - func removeOpenGroupSequenceNumber(for room: String, on server: String, using transaction: Any) { - var updatedData: [String: Int64] = ((mockData[.openGroupSequenceNumber] as? [String: Int64]) ?? [:]) - updatedData["\(server).\(room)"] = nil - mockData[.openGroupSequenceNumber] = updatedData - } - - func getOpenGroupInboxLatestMessageId(for server: String) -> Int64? { - let data: [String: Int64] = ((mockData[.openGroupInboxLatestMessageId] as? [String: Int64]) ?? [:]) - return data[server] - } - - func setOpenGroupInboxLatestMessageId(for server: String, to newValue: Int64, using transaction: Any) { - var updatedData: [String: Int64] = ((mockData[.openGroupInboxLatestMessageId] as? [String: Int64]) ?? [:]) - updatedData[server] = newValue - mockData[.openGroupInboxLatestMessageId] = updatedData - } - - func removeOpenGroupInboxLatestMessageId(for server: String, using transaction: Any) { - var updatedData: [String: Int64] = ((mockData[.openGroupInboxLatestMessageId] as? [String: Int64]) ?? [:]) - updatedData[server] = nil - mockData[.openGroupInboxLatestMessageId] = updatedData - } - - func getOpenGroupOutboxLatestMessageId(for server: String) -> Int64? { - let data: [String: Int64] = ((mockData[.openGroupOutboxLatestMessageId] as? [String: Int64]) ?? [:]) - return data[server] - } - - func setOpenGroupOutboxLatestMessageId(for server: String, to newValue: Int64, using transaction: Any) { - var updatedData: [String: Int64] = ((mockData[.openGroupOutboxLatestMessageId] as? [String: Int64]) ?? [:]) - updatedData[server] = newValue - mockData[.openGroupOutboxLatestMessageId] = updatedData - } - - func removeOpenGroupOutboxLatestMessageId(for server: String, using transaction: Any) { - var updatedData: [String: Int64] = ((mockData[.openGroupOutboxLatestMessageId] as? [String: Int64]) ?? [:]) - updatedData[server] = nil - mockData[.openGroupOutboxLatestMessageId] = updatedData - } - - // MARK: - Open Group Public Keys - - func getOpenGroupPublicKey(for server: String) -> String? { - guard let publicKeyMap: [String: String] = mockData[.openGroupPublicKeys] as? [String: String] else { - return (mockData[.openGroupPublicKeys] as? String) - } - - return publicKeyMap[server] - } - - func setOpenGroupPublicKey(for server: String, to newValue: String, using transaction: Any) {} - - // MARK: - Message Handling - - func getAllMessageRequestThreads() -> [String: TSContactThread] { return [:] } - func getAllMessageRequestThreads(using transaction: YapDatabaseReadTransaction) -> [String: TSContactThread] { return [:] } - - func getReceivedMessageTimestamps(using transaction: Any) -> [UInt64] { - return ((mockData[.receivedMessageTimestamp] as? UInt64).map { [$0] } ?? []) - } - - func addReceivedMessageTimestamp(_ timestamp: UInt64, using transaction: Any) { - mockData[.receivedMessageTimestamp] = timestamp - } - - func getOrCreateThread(for publicKey: String, groupPublicKey: String?, openGroupID: String?, using transaction: Any) -> String? { return nil } - func persist(_ message: VisibleMessage, quotedMessage: TSQuotedMessage?, linkPreview: OWSLinkPreview?, groupPublicKey: String?, openGroupID: String?, using transaction: Any) -> String? { return nil } - func persist(_ attachments: [VisibleMessage.Attachment], using transaction: Any) -> [String] { return [] } - func setAttachmentState(to state: TSAttachmentPointerState, for pointer: TSAttachmentPointer, associatedWith tsIncomingMessageID: String, using transaction: Any) {} - func persist(_ stream: TSAttachmentStream, associatedWith tsIncomingMessageID: String, using transaction: Any) {} -} diff --git a/SessionMessagingKitTests/_TestUtilities/TestThread.swift b/SessionMessagingKitTests/_TestUtilities/TestThread.swift new file mode 100644 index 000000000..0a8ab0dac --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/TestThread.swift @@ -0,0 +1,3 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation diff --git a/SharedTest/Mock.swift b/SharedTest/Mock.swift new file mode 100644 index 000000000..e22321b68 --- /dev/null +++ b/SharedTest/Mock.swift @@ -0,0 +1,181 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +// MARK: - Mocked + +protocol Mocked { static var mockValue: Self { get } } + +func any() -> R { R.mockValue } +func any() -> R { unsafeBitCast(0, to: R.self) } +func any() -> [R] { [] } +func any() -> [K: V] { [:] } +func any() -> Any { 0 } +func any() -> Float { 0 } +func any() -> Double { 0 } +func any() -> String { "" } +func any() -> Data { Data() } + +// MARK: - Mock + +public class Mock { + private let functionHandler: MockFunctionHandler + internal let functionConsumer: FunctionConsumer + + internal required init(functionHandler: MockFunctionHandler? = nil) { + self.functionConsumer = FunctionConsumer() + self.functionHandler = (functionHandler ?? self.functionConsumer) + } + + @discardableResult internal func accept(funcName: String = #function, args: [Any?] = []) -> Any? { + return accept(funcName: funcName, checkArgs: args, actionArgs: args) + } + + @discardableResult internal func accept(funcName: String = #function, checkArgs: [Any?], actionArgs: [Any?]) -> Any? { + return functionHandler.accept(funcName, parameterSummary: summary(for: checkArgs), actionArgs: actionArgs) + } + + internal func when(_ callBlock: @escaping (T) throws -> R) -> MockFunctionBuilder { + let builder: MockFunctionBuilder = MockFunctionBuilder(callBlock, mockInit: type(of: self).init) + functionConsumer.functionBuilders.append(builder.build) + + return builder + } + + private func summary(for argument: Any) -> String { + switch argument { + case let string as String: return string + case let array as [Any]: return "[\(array.map { summary(for: $0) }.joined(separator: ", "))]" + + case let dict as [String: Any]: + return "[\(dict.map { key, value in "\(summary(for: key)):\(summary(for: value))" }.joined(separator: ", "))]" + + default: return String(describing: argument) + } + } +} + +// MARK: - MockFunctionHandler + +protocol MockFunctionHandler { + func accept(_ functionName: String, parameterSummary: String, actionArgs: [Any?]) -> Any? +} + +// MARK: - MockFunction + +internal class MockFunction { + var name: String + var parameterSummary: String + var actions: [([Any?]) -> Void] + var returnValue: Any? + + init(name: String, parameterSummary: String, actions: [([Any?]) -> Void], returnValue: Any?) { + self.name = name + self.parameterSummary = parameterSummary + self.actions = actions + self.returnValue = returnValue + } +} + +// MARK: - MockFunctionBuilder + +internal class MockFunctionBuilder: MockFunctionHandler { + private let callBlock: (T) throws -> R + private let mockInit: (MockFunctionHandler?) -> Mock + private var functionName: String? + private var parameterSummary: String? + private var actions: [([Any?]) -> Void] = [] + private var returnValue: R? + internal var returnValueGenerator: ((String, String) -> R?)? + + // MARK: - Initialization + + init(_ callBlock: @escaping (T) throws -> R, mockInit: @escaping (MockFunctionHandler?) -> Mock) { + self.callBlock = callBlock + self.mockInit = mockInit + } + + // MARK: - Behaviours + + @discardableResult func then(_ block: @escaping ([Any?]) -> Void) -> MockFunctionBuilder { + actions.append(block) + return self + } + + func thenReturn(_ value: R?) { + returnValue = value + } + + // MARK: - MockFunctionHandler + + func accept(_ functionName: String, parameterSummary: String, actionArgs: [Any?]) -> Any? { + self.functionName = functionName + self.parameterSummary = parameterSummary + return (returnValue ?? returnValueGenerator?(functionName, parameterSummary)) + } + + // MARK: - Build + + func build() throws -> MockFunction { + let completionMock = mockInit(self) as! T + _ = try callBlock(completionMock) + + guard let name: String = functionName, let parameterSummary: String = parameterSummary else { + preconditionFailure("Attempted to build the MockFunction before it was called") + } + + return MockFunction(name: name, parameterSummary: parameterSummary, actions: actions, returnValue: returnValue) + } +} + +// MARK: - FunctionConsumer + +internal class FunctionConsumer: MockFunctionHandler { + var trackCalls: Bool = true + var functionBuilders: [() throws -> MockFunction?] = [] + var functionHandlers: [String: [String: MockFunction]] = [:] + var calls: [String: [String]] = [:] + + func accept(_ functionName: String, parameterSummary: String, actionArgs: [Any?]) -> Any? { + if !functionBuilders.isEmpty { + functionBuilders + .compactMap { try? $0() } + .forEach { function in + functionHandlers[function.name] = (functionHandlers[function.name] ?? [:]) + .setting(function.parameterSummary, function) + } + + functionBuilders.removeAll() + } + + guard let expectation: MockFunction = firstFunction(for: functionName, matchingParameterSummaryIfPossible: parameterSummary) else { + preconditionFailure("No expectations found for \(functionName)") + } + + // Record the call so it can be validated later (assuming we are tracking calls) + if trackCalls { + calls[functionName] = (calls[functionName] ?? []).appending(parameterSummary) + } + + for action in expectation.actions { + action(actionArgs) + } + + return expectation.returnValue + } + + func firstFunction(for name: String, matchingParameterSummaryIfPossible parameterSummary: String) -> MockFunction? { + guard let possibleExpectations: [String: MockFunction] = functionHandlers[name] else { return nil } + + guard let expectation: MockFunction = possibleExpectations[parameterSummary] else { + // A `nil` response might be value but in a lot of places we will need to force-cast + // so try to find a non-nil response first + return ( + possibleExpectations.values.first(where: { $0.returnValue != nil }) ?? + possibleExpectations.values.first + ) + } + + return expectation + } +} diff --git a/SharedTest/NimbleExtensions.swift b/SharedTest/NimbleExtensions.swift new file mode 100644 index 000000000..6fe5bafda --- /dev/null +++ b/SharedTest/NimbleExtensions.swift @@ -0,0 +1,223 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Nimble + +public enum CallAmount { + case atLeast(times: Int) + case exactly(times: Int) + case noMoreThan(times: Int) +} + +fileprivate func timeStr(_ value: Int) -> String { + return "\(value) time\(value == 1 ? "" : "s")" +} + +/// Validates whether the function called in `functionBlock` has been called according to the parameter constraints +/// +/// - Parameters: +/// - amount: An enum constraining the number of times the function can be called (Default is `.atLeast(times: 1)` +/// +/// - matchingParameters: A boolean indicating whether the parameters for the function call need to match exactly +/// +/// - exclusive: A boolean indicating whether no other functions should be called +/// +/// - functionBlock: A closure in which the function to be validated should be called +public func call( + _ amount: CallAmount = .atLeast(times: 1), + matchingParameters: Bool = false, + exclusive: Bool = false, + functionBlock: @escaping (T) throws -> R +) -> Predicate where M: Mock { + return Predicate.define { actualExpression in + let callInfo: CallInfo = generateCallInfo(actualExpression, functionBlock) + let matchingParameterRecords: [String] = callInfo.desiredFunctionCalls + .filter { !matchingParameters || callInfo.hasMatchingParameters($0) } + let exclusiveCallsValid: Bool = (!exclusive || callInfo.allFunctionsCalled.count <= 1) // '<=' to support '0' case + let (numParamMatchingCallsValid, timesError): (Bool, String?) = { + switch amount { + case .atLeast(let times): + return ( + (matchingParameterRecords.count >= times), + (times <= 1 ? nil : "at least \(timeStr(times))") + ) + + case .exactly(let times): + return ( + (matchingParameterRecords.count == times), + "exactly \(timeStr(times))" + ) + + case .noMoreThan(let times): + return ( + (matchingParameterRecords.count <= times), + (times <= 0 ? nil : "no more than \(timeStr(times))") + ) + } + }() + + let result = ( + numParamMatchingCallsValid && + exclusiveCallsValid + ) + let matchingParametersError: String? = (matchingParameters ? + "matching the parameters\(callInfo.desiredParameters.map { ": \($0)" } ?? "")" : + nil + ) + let distinctParameterCombinations: Set = Set(callInfo.desiredFunctionCalls) + let actualMessage: String + + if callInfo.caughtException != nil { + actualMessage = "a thrown assertion (might not have been called or has no mocked return value)" + } + else if callInfo.function == nil { + actualMessage = "no call details" + } + else if callInfo.desiredFunctionCalls.isEmpty { + actualMessage = "no calls" + } + else if !exclusiveCallsValid { + let otherFunctionsCalled: [String] = callInfo.allFunctionsCalled.filter { $0 != callInfo.functionName } + + actualMessage = "calls to other functions: [\(otherFunctionsCalled.joined(separator: ", "))]" + } + else { + let onlyMadeMatchingCalls: Bool = (matchingParameterRecords.count == callInfo.desiredFunctionCalls.count) + + switch (numParamMatchingCallsValid, onlyMadeMatchingCalls, distinctParameterCombinations.count) { + case (false, false, 1): + // No calls with the matching parameter requirements but only one parameter combination + // so include the param info + actualMessage = "called \(timeStr(callInfo.desiredFunctionCalls.count)) with different parameters: \(callInfo.desiredFunctionCalls[0])" + + case (false, true, _): + actualMessage = "called \(timeStr(callInfo.desiredFunctionCalls.count))" + + case (false, false, _): + actualMessage = "called \(timeStr(matchingParameterRecords.count)) with matching parameters, \(timeStr(callInfo.desiredFunctionCalls.count)) total" + + default: actualMessage = "" + } + } + + return PredicateResult( + bool: result, + message: .expectedCustomValueTo( + [ + "call '\(callInfo.functionName)'\(exclusive ? " exclusively" : "")", + timesError, + matchingParametersError + ] + .compactMap { $0 } + .joined(separator: " "), + actual: actualMessage + ) + ) + } +} + +// MARK: - Shared Code + +fileprivate struct CallInfo { + let didError: Bool + let caughtException: BadInstructionException? + let function: MockFunction? + let allFunctionsCalled: [String] + let desiredFunctionCalls: [String] + + var functionName: String { "\((function?.name).map { "\($0)" } ?? "a function")" } + var desiredParameters: String? { function?.parameterSummary } + + static var error: CallInfo { + CallInfo( + didError: true, + caughtException: nil, + function: nil, + allFunctionsCalled: [], + desiredFunctionCalls: [] + ) + } + + init( + didError: Bool = false, + caughtException: BadInstructionException?, + function: MockFunction?, + allFunctionsCalled: [String], + desiredFunctionCalls: [String] + ) { + self.didError = didError + self.caughtException = caughtException + self.function = function + self.allFunctionsCalled = allFunctionsCalled + self.desiredFunctionCalls = desiredFunctionCalls + } + + func hasMatchingParameters(_ parameters: String) -> Bool { + return (parameters == (function?.parameterSummary ?? "FALLBACK_NOT_FOUND")) + } +} + +fileprivate func generateCallInfo(_ actualExpression: Expression, _ functionBlock: @escaping (T) throws -> R) -> CallInfo where M: Mock { + var maybeFunction: MockFunction? + var allFunctionsCalled: [String] = [] + var desiredFunctionCalls: [String] = [] + let builderCreator: ((M) -> MockFunctionBuilder) = { validInstance in + let builder: MockFunctionBuilder = MockFunctionBuilder(functionBlock, mockInit: type(of: validInstance).init) + builder.returnValueGenerator = { name, parameterSummary in + validInstance.functionConsumer + .firstFunction(for: name, matchingParameterSummaryIfPossible: parameterSummary)? + .returnValue as? R + } + + return builder + } + + #if (arch(x86_64) || arch(arm64)) && (canImport(Darwin) || canImport(Glibc)) + var didError: Bool = false + let caughtException: BadInstructionException? = catchBadInstruction { + do { + guard let validInstance: M = try actualExpression.evaluate() else { + didError = true + return + } + + allFunctionsCalled = Array(validInstance.functionConsumer.calls.keys) + + let builder: MockFunctionBuilder = builderCreator(validInstance) + validInstance.functionConsumer.trackCalls = false + maybeFunction = try? builder.build() + desiredFunctionCalls = (validInstance.functionConsumer.calls[maybeFunction?.name ?? ""] ?? []) + validInstance.functionConsumer.trackCalls = true + } + catch { + didError = true + } + } + + // Make sure to switch this back on in case an assertion was thrown (which would meant this + // wouldn't have been reset) + (try? actualExpression.evaluate())?.functionConsumer.trackCalls = true + + guard !didError else { return CallInfo.error } + #else + let caughtException: BadInstructionException? = nil + + // Just hope for the best and if there is a force-cast there's not much we can do + guard let validInstance: M = try? actualExpression.evaluate() else { return CallInfo.error } + + allFunctionsCalled = Array(validInstance.functionConsumer.calls.keys) + + let builder: MockExpectationBuilder = builderCreator(validInstance) + validInstance.functionConsumer.trackCalls = false + maybeFunction = try? builder.build() + desiredFunctionCalls = (validInstance.functionConsumer.calls[maybeFunction?.name ?? ""] ?? []) + validInstance.functionConsumer.trackCalls = true + #endif + + return CallInfo( + caughtException: caughtException, + function: maybeFunction, + allFunctionsCalled: allFunctionsCalled, + desiredFunctionCalls: desiredFunctionCalls + ) +} From f9c2655df48154c41a174417fdead6ac8b869e2b Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 15 Mar 2022 15:19:23 +1100 Subject: [PATCH 034/157] Finalised the OpenGroupAPI and more tests Fixed an issue where messages where signed incorrectly when blinding wasn't enabled on a SOGS Fixed an issue where a single invalid message would result in all messages in that request being dropped Updated the final legacy endpoint (ban and delete all messages) Moved the OpenGroupManager poller values into the 'Cache' (so they are thread safe) Started adding unit tests for the OpenGroupManager Removed some redundant parameters from the 'Request' type --- Session.xcodeproj/project.pbxproj | 46 +- .../ConversationVC+Interaction.swift | 4 +- .../Common Networking/Request.swift | 10 +- .../File Server/FileServerAPI.swift | 4 - .../Open Groups/OpenGroupAPI.swift | 182 +++--- .../Open Groups/OpenGroupManager.swift | 96 +-- .../Open Groups/Types/SOGSEndpoint.swift | 6 +- .../Open Groups/Types/SodiumProtocols.swift | 5 + .../Sending & Receiving/MessageSender.swift | 3 +- .../Pollers/OpenGroupPoller.swift | 55 +- .../Utilities/Dependencies.swift | 16 +- SessionMessagingKit/Utilities/Failable.swift | 24 + .../Open Groups/OpenGroupAPISpec.swift | 525 ++++++++++------ .../Open Groups/OpenGroupManagerSpec.swift | 558 +++++++++++++++++- .../Open Groups/Types/SOGSEndpointSpec.swift | 3 +- .../_TestUtilities/MockEd25519.swift | 4 + .../_TestUtilities/MockUserDefaults.swift | 27 + .../_TestUtilities/Mockable.swift | 6 - .../_TestUtilities/MockedExtensions.swift | 23 + .../_TestUtilities/TestGroupThread.swift | 32 + .../_TestUtilities/TestInteraction.swift | 29 + .../_TestUtilities/TestOnionRequestAPI.swift | 60 ++ .../_TestUtilities/TestThread.swift | 23 + .../_TestUtilities/TestTransaction.swift | 31 + .../_TestUtilities/TestUserDefaults.swift | 27 - .../General}/Atomic.swift | 4 +- .../CommonMockedExtensions.swift | 10 + SharedTest/Mock.swift | 24 +- SharedTest/NimbleExtensions.swift | 8 +- 29 files changed, 1419 insertions(+), 426 deletions(-) create mode 100644 SessionMessagingKit/Utilities/Failable.swift create mode 100644 SessionMessagingKitTests/_TestUtilities/MockUserDefaults.swift create mode 100644 SessionMessagingKitTests/_TestUtilities/MockedExtensions.swift create mode 100644 SessionMessagingKitTests/_TestUtilities/TestGroupThread.swift create mode 100644 SessionMessagingKitTests/_TestUtilities/TestOnionRequestAPI.swift create mode 100644 SessionMessagingKitTests/_TestUtilities/TestTransaction.swift delete mode 100644 SessionMessagingKitTests/_TestUtilities/TestUserDefaults.swift rename {SessionMessagingKit/Utilities => SessionUtilitiesKit/General}/Atomic.swift (93%) rename SessionMessagingKitTests/_TestUtilities/BoxKeyPair+Mocked.swift => SharedTest/CommonMockedExtensions.swift (54%) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index e61bc81a7..34af08cf0 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -773,6 +773,10 @@ F5765D284BC6ECAC0C1D33F0 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionNotificationServiceExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E4A93ECA93B3DE800CC7D7F6 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionNotificationServiceExtension.framework */; }; FC3BD9881A30A790005B96BB /* Social.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FC3BD9871A30A790005B96BB /* Social.framework */; }; FCB11D8C1A129A76002F93FB /* CoreMedia.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FCB11D8B1A129A76002F93FB /* CoreMedia.framework */; }; + FD078E4627E02406000769AF /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4383D27B4708600C60D73 /* Atomic.swift */; }; + FD078E4827E02561000769AF /* CommonMockedExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E4727E02561000769AF /* CommonMockedExtensions.swift */; }; + FD078E4927E02576000769AF /* CommonMockedExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E4727E02561000769AF /* CommonMockedExtensions.swift */; }; + FD078E4B27E02C5D000769AF /* Failable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E4A27E02C5D000769AF /* Failable.swift */; }; FD0BA51B27CD88EC00CC6805 /* BlindedIdMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0BA51A27CD88EC00CC6805 /* BlindedIdMapping.swift */; }; FD0BA51D27CDC34600CC6805 /* SOGSV4Migration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0BA51C27CDC34600CC6805 /* SOGSV4Migration.swift */; }; FD5D200F27AA2B6000FEA984 /* MessageRequestResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */; }; @@ -797,7 +801,7 @@ FD83B9C927D0487A005E1583 /* SendDirectMessageResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9C827D0487A005E1583 /* SendDirectMessageResponse.swift */; }; FD83B9CC27D179BC005E1583 /* FSEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9CB27D179BC005E1583 /* FSEndpoint.swift */; }; FD83B9CE27D17A04005E1583 /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9CD27D17A04005E1583 /* Request.swift */; }; - FD83B9D227D59495005E1583 /* TestUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9D127D59495005E1583 /* TestUserDefaults.swift */; }; + FD83B9D227D59495005E1583 /* MockUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9D127D59495005E1583 /* MockUserDefaults.swift */; }; FD83B9D427D5A7D5005E1583 /* ConversationViewItem+Refactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9D327D5A7D5005E1583 /* ConversationViewItem+Refactor.swift */; }; FD859EF227BF6BA200510D0C /* Data+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF127BF6BA200510D0C /* Data+Utilities.swift */; }; FD859EF427C2F49200510D0C /* MockSodium.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF327C2F49200510D0C /* MockSodium.swift */; }; @@ -826,8 +830,10 @@ FDC290A827D9B46D005DAE71 /* NimbleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */; }; FDC290A927D9B46D005DAE71 /* NimbleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */; }; FDC290AA27D9B6FD005DAE71 /* Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A527D860CE005DAE71 /* Mock.swift */; }; - FDC290AC27DB0B1C005DAE71 /* BoxKeyPair+Mocked.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290AB27DB0B1C005DAE71 /* BoxKeyPair+Mocked.swift */; }; - FDC290AD27DB0B1C005DAE71 /* BoxKeyPair+Mocked.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290AB27DB0B1C005DAE71 /* BoxKeyPair+Mocked.swift */; }; + FDC290AC27DB0B1C005DAE71 /* MockedExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290AB27DB0B1C005DAE71 /* MockedExtensions.swift */; }; + FDC290AF27DFEE97005DAE71 /* TestTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290AE27DFEE97005DAE71 /* TestTransaction.swift */; }; + FDC290B327DFF9F5005DAE71 /* TestOnionRequestAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290B227DFF9F5005DAE71 /* TestOnionRequestAPI.swift */; }; + FDC290B727E00FDB005DAE71 /* TestGroupThread.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290B627E00FDB005DAE71 /* TestGroupThread.swift */; }; FDC4380927B31D4E00C60D73 /* SOGSError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4380827B31D4E00C60D73 /* SOGSError.swift */; }; FDC4381527B329CE00C60D73 /* NonceGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381427B329CE00C60D73 /* NonceGenerator.swift */; }; FDC4381727B32EC700C60D73 /* Personalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381627B32EC700C60D73 /* Personalization.swift */; }; @@ -835,7 +841,6 @@ FDC4382F27B383AF00C60D73 /* UnregisterResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382E27B383AF00C60D73 /* UnregisterResponse.swift */; }; FDC4383127B3841C00C60D73 /* RegisterResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4383027B3841C00C60D73 /* RegisterResponse.swift */; }; FDC4383827B3863200C60D73 /* VersionResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4383727B3863200C60D73 /* VersionResponse.swift */; }; - FDC4383E27B4708600C60D73 /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4383D27B4708600C60D73 /* Atomic.swift */; }; FDC4384C27B47F7700C60D73 /* OpenGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384B27B47F7700C60D73 /* OpenGroup.swift */; }; FDC4384F27B4804F00C60D73 /* Header.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384E27B4804F00C60D73 /* Header.swift */; }; FDC4385127B4807400C60D73 /* QueryParam.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4385027B4807400C60D73 /* QueryParam.swift */; }; @@ -1913,6 +1918,8 @@ F9BBF530D71905BA9007675F /* Pods-SessionShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionShareExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SessionShareExtension/Pods-SessionShareExtension.debug.xcconfig"; sourceTree = ""; }; FC3BD9871A30A790005B96BB /* Social.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Social.framework; path = System/Library/Frameworks/Social.framework; sourceTree = SDKROOT; }; FCB11D8B1A129A76002F93FB /* CoreMedia.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreMedia.framework; path = System/Library/Frameworks/CoreMedia.framework; sourceTree = SDKROOT; }; + FD078E4727E02561000769AF /* CommonMockedExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonMockedExtensions.swift; sourceTree = ""; }; + FD078E4A27E02C5D000769AF /* Failable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Failable.swift; sourceTree = ""; }; FD0BA51A27CD88EC00CC6805 /* BlindedIdMapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlindedIdMapping.swift; sourceTree = ""; }; FD0BA51C27CDC34600CC6805 /* SOGSV4Migration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSV4Migration.swift; sourceTree = ""; }; FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestResponse.swift; sourceTree = ""; }; @@ -1936,7 +1943,7 @@ FD83B9C827D0487A005E1583 /* SendDirectMessageResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendDirectMessageResponse.swift; sourceTree = ""; }; FD83B9CB27D179BC005E1583 /* FSEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FSEndpoint.swift; sourceTree = ""; }; FD83B9CD27D17A04005E1583 /* Request.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Request.swift; sourceTree = ""; }; - FD83B9D127D59495005E1583 /* TestUserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestUserDefaults.swift; sourceTree = ""; }; + FD83B9D127D59495005E1583 /* MockUserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockUserDefaults.swift; sourceTree = ""; }; FD83B9D327D5A7D5005E1583 /* ConversationViewItem+Refactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConversationViewItem+Refactor.swift"; sourceTree = ""; }; FD859EEF27BF207700510D0C /* SessionProtos.proto */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.protobuf; path = SessionProtos.proto; sourceTree = ""; }; FD859EF027BF207C00510D0C /* WebSocketResources.proto */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.protobuf; path = WebSocketResources.proto; sourceTree = ""; }; @@ -1966,7 +1973,10 @@ FDC290A127D85890005DAE71 /* TestInteraction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestInteraction.swift; sourceTree = ""; }; FDC290A527D860CE005DAE71 /* Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mock.swift; sourceTree = ""; }; FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NimbleExtensions.swift; sourceTree = ""; }; - FDC290AB27DB0B1C005DAE71 /* BoxKeyPair+Mocked.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BoxKeyPair+Mocked.swift"; sourceTree = ""; }; + FDC290AB27DB0B1C005DAE71 /* MockedExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockedExtensions.swift; sourceTree = ""; }; + FDC290AE27DFEE97005DAE71 /* TestTransaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestTransaction.swift; sourceTree = ""; }; + FDC290B227DFF9F5005DAE71 /* TestOnionRequestAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestOnionRequestAPI.swift; sourceTree = ""; }; + FDC290B627E00FDB005DAE71 /* TestGroupThread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestGroupThread.swift; sourceTree = ""; }; FDC4380827B31D4E00C60D73 /* SOGSError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSError.swift; sourceTree = ""; }; FDC4381427B329CE00C60D73 /* NonceGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonceGenerator.swift; sourceTree = ""; }; FDC4381627B32EC700C60D73 /* Personalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Personalization.swift; sourceTree = ""; }; @@ -2542,6 +2552,7 @@ C33FDB8A255A581200E217F9 /* AppContext.h */, C33FDB85255A581100E217F9 /* AppContext.m */, C3C2A5D12553860800C340D1 /* Array+Utilities.swift */, + FDC4383D27B4708600C60D73 /* Atomic.swift */, FDC438CC27BC641200C60D73 /* Set+Utilities.swift */, C33FDAA8255A57FF00E217F9 /* BuildConfiguration.swift */, B8F5F58225EC94A6003BF8D4 /* Collection+Subscripting.swift */, @@ -3422,13 +3433,13 @@ children = ( C33FDB01255A580700E217F9 /* AppReadiness.h */, C33FDB75255A581000E217F9 /* AppReadiness.m */, - FDC4383D27B4708600C60D73 /* Atomic.swift */, FD83B9A927CF149D005E1583 /* ContactUtilities.swift */, FD859EF127BF6BA200510D0C /* Data+Utilities.swift */, FDC438C027BB4E6800C60D73 /* Dependencies.swift */, C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */, C37F53E8255BA9BB002AEA92 /* Environment.h */, C37F5402255BA9ED002AEA92 /* Environment.m */, + FD078E4A27E02C5D000769AF /* Failable.swift */, C3BBE0C62554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift */, C33FDB7F255A581100E217F9 /* FullTextSearchFinder.swift */, C33FDBC1255A581700E217F9 /* General.swift */, @@ -3886,6 +3897,7 @@ FDC290A527D860CE005DAE71 /* Mock.swift */, FD83B9BD27CF2243005E1583 /* TestConstants.swift */, FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */, + FD078E4727E02561000769AF /* CommonMockedExtensions.swift */, ); path = SharedTest; sourceTree = ""; @@ -4035,10 +4047,13 @@ FD859EF727C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift */, FD859EF927C2F5C500510D0C /* MockGenericHash.swift */, FD859EFB27C2F60700510D0C /* MockEd25519.swift */, - FD83B9D127D59495005E1583 /* TestUserDefaults.swift */, + FD83B9D127D59495005E1583 /* MockUserDefaults.swift */, + FDC290B227DFF9F5005DAE71 /* TestOnionRequestAPI.swift */, + FDC290AE27DFEE97005DAE71 /* TestTransaction.swift */, FDC2909F27D85826005DAE71 /* TestThread.swift */, + FDC290B627E00FDB005DAE71 /* TestGroupThread.swift */, FDC290A127D85890005DAE71 /* TestInteraction.swift */, - FDC290AB27DB0B1C005DAE71 /* BoxKeyPair+Mocked.swift */, + FDC290AB27DB0B1C005DAE71 /* MockedExtensions.swift */, ); path = _TestUtilities; sourceTree = ""; @@ -5241,6 +5256,7 @@ B8F5F58325EC94A6003BF8D4 /* Collection+Subscripting.swift in Sources */, C33FDEF8255A656D00E217F9 /* Promise+Delaying.swift in Sources */, B8BC00C0257D90E30032E807 /* General.swift in Sources */, + FD078E4627E02406000769AF /* Atomic.swift in Sources */, FD5D201E27B0D87C00FEA984 /* SessionId.swift in Sources */, C32C5A24256DB7DB003C73A2 /* SNUserDefaults.swift in Sources */, C3D9E41F25676C870040E4F3 /* OWSPrimaryStorageProtocol.swift in Sources */, @@ -5297,7 +5313,6 @@ C300A5FC2554B0A000555489 /* MessageReceiver.swift in Sources */, 7B1581E2271E743B00848B49 /* OWSSounds.swift in Sources */, FDC438A627BB113A00C60D73 /* UserUnbanRequest.swift in Sources */, - FDC4383E27B4708600C60D73 /* Atomic.swift in Sources */, C32C5A76256DBBCF003C73A2 /* SignalAttachment.swift in Sources */, C32C5CA4256DD1DC003C73A2 /* TSAccountManager.m in Sources */, C352A3892557876500338F3E /* JobQueue.swift in Sources */, @@ -5400,6 +5415,7 @@ FDC438CB27BB7DB100C60D73 /* UpdateMessageRequest.swift in Sources */, C352A2FF25574B6300338F3E /* MessageSendJob.swift in Sources */, FDC438C327BB512200C60D73 /* SodiumProtocols.swift in Sources */, + FD078E4B27E02C5D000769AF /* Failable.swift in Sources */, B8856D11256F112A001CE70E /* OWSAudioSession.swift in Sources */, C3DB66C3260ACCE6001EFC55 /* OpenGroupPoller.swift in Sources */, C3C2A75F2553A3C500C340D1 /* VisibleMessage+LinkPreview.swift in Sources */, @@ -5619,7 +5635,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - FDC290AD27DB0B1C005DAE71 /* BoxKeyPair+Mocked.swift in Sources */, + FD078E4927E02576000769AF /* CommonMockedExtensions.swift in Sources */, FD83B9BF27CF2294005E1583 /* TestConstants.swift in Sources */, FD83B9BB27CF20AF005E1583 /* SessionIdSpec.swift in Sources */, FDC290A927D9B46D005DAE71 /* NimbleExtensions.swift in Sources */, @@ -5631,9 +5647,11 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - FDC290AC27DB0B1C005DAE71 /* BoxKeyPair+Mocked.swift in Sources */, + FDC290AC27DB0B1C005DAE71 /* MockedExtensions.swift in Sources */, FD859EFA27C2F5C500510D0C /* MockGenericHash.swift in Sources */, FDC2909427D710B4005DAE71 /* SOGSEndpointSpec.swift in Sources */, + FDC290AF27DFEE97005DAE71 /* TestTransaction.swift in Sources */, + FDC290B327DFF9F5005DAE71 /* TestOnionRequestAPI.swift in Sources */, FDC2909127D709CA005DAE71 /* SOGSMessageSpec.swift in Sources */, FDC2909627D71252005DAE71 /* SOGSErrorSpec.swift in Sources */, FDC2908727D7047F005DAE71 /* RoomSpec.swift in Sources */, @@ -5641,6 +5659,7 @@ FD83B9C327CF33F7005E1583 /* ServerSpec.swift in Sources */, FD83B9C727CF3F10005E1583 /* CapabilitiesSpec.swift in Sources */, FDC2909A27D71376005DAE71 /* NonceGeneratorSpec.swift in Sources */, + FD078E4827E02561000769AF /* CommonMockedExtensions.swift in Sources */, FDC290A027D85826005DAE71 /* TestThread.swift in Sources */, FD859EF827C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift in Sources */, FDC2909827D7129B005DAE71 /* PersonalizationSpec.swift in Sources */, @@ -5652,6 +5671,7 @@ FD83B9C527CF3E2A005E1583 /* OpenGroupSpec.swift in Sources */, FDC2908B27D707F3005DAE71 /* SendMessageRequestSpec.swift in Sources */, FDC290A827D9B46D005DAE71 /* NimbleExtensions.swift in Sources */, + FDC290B727E00FDB005DAE71 /* TestGroupThread.swift in Sources */, FDC2908F27D70938005DAE71 /* SendDirectMessageRequestSpec.swift in Sources */, FDC438BD27BB2AB400C60D73 /* Mockable.swift in Sources */, FD859EF627C2F52C00510D0C /* MockSign.swift in Sources */, @@ -5660,7 +5680,7 @@ FDC2908D27D70905005DAE71 /* UpdateMessageRequestSpec.swift in Sources */, FDC290A227D85890005DAE71 /* TestInteraction.swift in Sources */, FDC4389D27BA01F000C60D73 /* MockStorage.swift in Sources */, - FD83B9D227D59495005E1583 /* TestUserDefaults.swift in Sources */, + FD83B9D227D59495005E1583 /* MockUserDefaults.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index c489ce3fc..4e5071515 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -815,11 +815,11 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc let publicKey = message.authorId guard let openGroup = Storage.shared.getOpenGroup(for: threadID) else { return } - let promise = OpenGroupAPI.userBanAndDeleteAllMessage(publicKey, from: [openGroup.room], on: openGroup.server) + let promise = OpenGroupAPI.userBanAndDeleteAllMessages(publicKey, in: openGroup.room, on: openGroup.server) promise.catch(on: DispatchQueue.main) { _ in OWSAlerts.showErrorAlert(message: NSLocalizedString("context_menu_ban_user_error_alert_message", comment: "")) } - promise.retainUntilComplete() // TODO: Test This + promise.retainUntilComplete() self?.becomeFirstResponder() })) alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: { [weak self] _ in diff --git a/SessionMessagingKit/Common Networking/Request.swift b/SessionMessagingKit/Common Networking/Request.swift index a5dff15a7..19130eb98 100644 --- a/SessionMessagingKit/Common Networking/Request.swift +++ b/SessionMessagingKit/Common Networking/Request.swift @@ -25,10 +25,6 @@ struct Request { /// **Warning:** The `bodyData` value should be used to when making the actual request instead of this as there /// is custom handling for certain data types let body: T? - let isAuthRequired: Bool - /// Always `true` under normal circumstances. You might want to disable - /// this when running over Lokinet. - let useOnionRouting: Bool // MARK: - Initialization @@ -38,9 +34,7 @@ struct Request { endpoint: Endpoint, queryParameters: [QueryParam: String] = [:], headers: [Header: String] = [:], - body: T? = nil, - isAuthRequired: Bool = true, - useOnionRouting: Bool = true + body: T? = nil ) { self.method = method self.server = server @@ -48,8 +42,6 @@ struct Request { self.queryParameters = queryParameters self.headers = headers self.body = body - self.isAuthRequired = isAuthRequired - self.useOnionRouting = useOnionRouting } // MARK: - Internal Methods diff --git a/SessionMessagingKit/File Server/FileServerAPI.swift b/SessionMessagingKit/File Server/FileServerAPI.swift index 012ce514e..493af02f2 100644 --- a/SessionMessagingKit/File Server/FileServerAPI.swift +++ b/SessionMessagingKit/File Server/FileServerAPI.swift @@ -75,10 +75,6 @@ public final class FileServerAPI: NSObject { // MARK: - Convenience private static func send(_ request: Request, serverPublicKey: String) -> Promise { - guard request.useOnionRouting else { - preconditionFailure("It's currently not allowed to send non onion routed requests.") - } - let urlRequest: URLRequest do { diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index 953b14911..638f5be44 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -76,7 +76,7 @@ public enum OpenGroupAPI { .roomMessagesSince(openGroup.room, seqNo: targetSeqNo) ) ), - responseType: [Message].self + responseType: [Failable].self ) ] } @@ -242,7 +242,7 @@ public enum OpenGroupAPI { for roomToken: String, on server: String, using dependencies: Dependencies = Dependencies() - ) -> Promise<(capabilities: (OnionRequestResponseInfoType, Capabilities?), room: (OnionRequestResponseInfoType, Room?))> { + ) -> Promise<(capabilities: (info: OnionRequestResponseInfoType, data: Capabilities), room: (info: OnionRequestResponseInfoType, data: Room))> { let requestResponseType: [BatchRequestInfoType] = [ // Get the latest capabilities for the server (in case it's a new server or the cached ones are stale) BatchRequestInfo( @@ -264,8 +264,8 @@ public enum OpenGroupAPI { ] return sequence(server, requests: requestResponseType, using: dependencies) - .map { (response: [Endpoint: (OnionRequestResponseInfoType, Codable?)]) -> (capabilities: (OnionRequestResponseInfoType, Capabilities?), room: (OnionRequestResponseInfoType, Room?)) in - let maybeCapabilities: (OnionRequestResponseInfoType, Capabilities?)? = response[.capabilities] + .map { (response: [Endpoint: (OnionRequestResponseInfoType, Codable?)]) -> (capabilities: (OnionRequestResponseInfoType, Capabilities), room: (OnionRequestResponseInfoType, Room)) in + let maybeCapabilities: (info: OnionRequestResponseInfoType, data: Capabilities?)? = response[.capabilities] .map { info, data in (info, (data as? BatchSubResponse)?.body) } let maybeRoomResponse: (OnionRequestResponseInfoType, Codable?)? = response .first(where: { key, _ in @@ -275,14 +275,22 @@ public enum OpenGroupAPI { } }) .map { _, value in value } - let maybeRoom: (OnionRequestResponseInfoType, Room?)? = maybeRoomResponse + let maybeRoom: (info: OnionRequestResponseInfoType, data: Room?)? = maybeRoomResponse .map { info, data in (info, (data as? BatchSubResponse)?.body) } - guard let capabilities: (OnionRequestResponseInfoType, Capabilities?) = maybeCapabilities, let room: (OnionRequestResponseInfoType, Room?) = maybeRoom else { + guard + let capabilitiesInfo: OnionRequestResponseInfoType = maybeCapabilities?.info, + let capabilities: Capabilities = maybeCapabilities?.data, + let roomInfo: OnionRequestResponseInfoType = maybeRoom?.info, + let room: Room = maybeRoom?.data + else { throw HTTP.Error.parsingFailed } - return (capabilities, room) + return ( + (capabilitiesInfo, capabilities), + (roomInfo, room) + ) } } @@ -298,7 +306,7 @@ public enum OpenGroupAPI { fileIds: [String]?, using dependencies: Dependencies = Dependencies() ) -> Promise<(OnionRequestResponseInfoType, Message)> { - guard let signResult: (publicKey: String, signature: Bytes) = sign(plaintext.bytes, for: server, using: dependencies) else { + guard let signResult: (publicKey: String, signature: Bytes) = sign(plaintext.bytes, for: server, fallbackSigningType: .standard, using: dependencies) else { return Promise(error: Error.signingFailed) } @@ -352,7 +360,7 @@ public enum OpenGroupAPI { on server: String, using dependencies: Dependencies = Dependencies() ) -> Promise<(OnionRequestResponseInfoType, Data?)> { - guard let signResult: (publicKey: String, signature: Bytes) = sign(plaintext.bytes, for: server, using: dependencies) else { + guard let signResult: (publicKey: String, signature: Bytes) = sign(plaintext.bytes, for: server, fallbackSigningType: .standard, using: dependencies) else { return Promise(error: Error.signingFailed) } @@ -429,6 +437,34 @@ public enum OpenGroupAPI { .decoded(as: [Message].self, on: OpenGroupAPI.workQueue, using: dependencies) } + /// Deletes all messages from a given sessionId within the provided rooms (or globally) on a server + /// + /// - Parameters: + /// - sessionId: The sessionId (either standard or blinded) of the user whose messages should be deleted + /// + /// - roomToken: The room token from which the messages should be deleted + /// + /// The invoking user **must** be a moderator of the given room or an admin if trying to delete the messages + /// of another admin. + /// + /// - server: The server to delete messages from + /// + /// - dependencies: Injected dependencies (used for unit testing) + public static func messagesDeleteAll( + _ sessionId: String, + in roomToken: String, + on server: String, + using dependencies: Dependencies = Dependencies() + ) -> Promise<(OnionRequestResponseInfoType, Data?)> { + let request: Request = Request( + method: .delete, + server: server, + endpoint: Endpoint.roomDeleteMessages(roomToken, sessionId: sessionId) + ) + + return send(request, using: dependencies) + } + // MARK: - Pinning /// Adds a pinned message to this room @@ -791,65 +827,19 @@ public enum OpenGroupAPI { return send(request, using: dependencies) } - // TODO: Need to test this once the API has been implemented - // TODO: Update docs to align with the API documentation once implemented - /// Deletes all messages from a given sessionId within the provided rooms (or globally) on a server - /// - /// - Parameters: - /// - sessionId: The sessionId (either standard or blinded) of the user whose messages should be deleted - /// - /// - roomTokens: List of one or more room tokens from which the messages should be deleted - /// - /// The invoking user **must** be an admin of all of the given rooms. - /// - /// This may be set to the single-element list `["*"]` to add or remove the moderator from all rooms in which the current user has admin - /// permissions (the call will succeed if the calling user is an admin in at least one channel) - /// - /// **Note:** You can delete messages from all rooms on a server by providing a `nil` value for this parameter - /// - /// - server: The server to delete messages from - /// - /// - dependencies: Injected dependencies (used for unit testing) - public static func userDeleteMessages( - _ sessionId: String, - from roomTokens: [String]?, - on server: String, - using dependencies: Dependencies = Dependencies() - ) -> Promise<(OnionRequestResponseInfoType, UserDeleteMessagesResponse)> { - let requestBody: UserDeleteMessagesRequest = UserDeleteMessagesRequest( - rooms: roomTokens, - global: (roomTokens == nil ? true : nil) - ) - - let request: Request = Request( - method: .post, - server: server, - endpoint: Endpoint.userDeleteMessages(sessionId), - body: requestBody - ) - - return send(request, using: dependencies) - .decoded(as: UserDeleteMessagesResponse.self, on: OpenGroupAPI.workQueue, using: dependencies) - } - - // TODO: Need to test this once the API has been implemented /// This is a convenience method which constructs a `/sequence` of the `userBan` and `userDeleteMessages` requests, refer to those /// methods for the documented behaviour of each method - public static func userBanAndDeleteAllMessage( + public static func userBanAndDeleteAllMessages( _ sessionId: String, - from roomTokens: [String]?, + in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies() ) -> Promise<[OnionRequestResponseInfoType]> { let banRequestBody: UserBanRequest = UserBanRequest( - rooms: roomTokens, - global: (roomTokens == nil ? true : nil), + rooms: [roomToken], + global: nil, timeout: nil ) - let deleteMessageRequestBody: UserDeleteMessagesRequest = UserDeleteMessagesRequest( - rooms: roomTokens, - global: (roomTokens == nil ? true : nil) - ) // Generate the requests let requestResponseType: [BatchRequestInfoType] = [ @@ -862,27 +852,22 @@ public enum OpenGroupAPI { ) ), BatchRequestInfo( - request: Request( - method: .post, + request: Request( + method: .delete, server: server, - endpoint: .userDeleteMessages(sessionId), - body: deleteMessageRequestBody - ), - responseType: UserDeleteMessagesResponse.self + endpoint: Endpoint.roomDeleteMessages(roomToken, sessionId: sessionId) + ) ) ] return sequence(server, requests: requestResponseType, using: dependencies) - .map { results in - // TODO: Handle deletions...???? Hand off to OpenGroupAPIManager? - return results.values.map { responseInfo, _ in responseInfo } - } + .map { $0.values.map { responseInfo, _ in responseInfo } } } // MARK: - Authentication /// Sign a message to be sent to SOGS (handles both un-blinded and blinded signing based on the server capabilities) - private static func sign(_ messageBytes: Bytes, for serverName: String, using dependencies: Dependencies = Dependencies()) -> (publicKey: String, signature: Bytes)? { + private static func sign(_ messageBytes: Bytes, for serverName: String, fallbackSigningType signingType: SessionId.Prefix, using dependencies: Dependencies = Dependencies()) -> (publicKey: String, signature: Bytes)? { guard let userEdKeyPair: Box.KeyPair = dependencies.storage.getUserED25519KeyPair() else { return nil } guard let serverPublicKey: String = dependencies.storage.getOpenGroupPublicKey(for: serverName) else { return nil @@ -906,15 +891,30 @@ public enum OpenGroupAPI { ) } - // Otherwise fall back to sign using the unblinded key - guard let signatureResult: Bytes = dependencies.sign.signature(message: messageBytes, secretKey: userEdKeyPair.secretKey) else { - return nil + // Otherwise sign using the fallback type + switch signingType { + case .unblinded: + guard let signatureResult: Bytes = dependencies.sign.signature(message: messageBytes, secretKey: userEdKeyPair.secretKey) else { + return nil + } + + return ( + publicKey: SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString, + signature: signatureResult + ) + + // Default to using the 'standard' key + default: + guard let userKeyPair: ECKeyPair = dependencies.storage.getUserKeyPair() else { return nil } + guard let signatureResult: Bytes = try? dependencies.ed25519.sign(data: messageBytes, keyPair: userKeyPair) else { + return nil + } + + return ( + publicKey: SessionId(.standard, publicKey: userKeyPair.publicKey.bytes).hexString, + signature: signatureResult + ) } - - return ( - publicKey: SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString, - signature: signatureResult - ) } /// Sign a request to be sent to SOGS (handles both un-blinded and blinded signing based on the server capabilities) @@ -954,7 +954,7 @@ public enum OpenGroupAPI { .appending(bodyHash ?? []) /// Sign the above message - guard let signResult: (publicKey: String, signature: Bytes) = sign(messageBytes, for: serverName, using: dependencies) else { + guard let signResult: (publicKey: String, signature: Bytes) = sign(messageBytes, for: serverName, fallbackSigningType: .unblinded, using: dependencies) else { return nil } @@ -981,23 +981,15 @@ public enum OpenGroupAPI { return Promise(error: error) } - if request.useOnionRouting { - guard let publicKey = dependencies.storage.getOpenGroupPublicKey(for: request.server) else { - return Promise(error: Error.noPublicKey) - } - - if request.isAuthRequired { - // Attempt to sign the request with the new auth - guard let signedRequest: URLRequest = sign(urlRequest, for: request.server, with: publicKey, using: dependencies) else { - return Promise(error: Error.signingFailed) - } - - return dependencies.api.sendOnionRequest(signedRequest, to: request.server, with: publicKey) - } - - return dependencies.api.sendOnionRequest(urlRequest, to: request.server, with: publicKey) + guard let publicKey = dependencies.storage.getOpenGroupPublicKey(for: request.server) else { + return Promise(error: Error.noPublicKey) } - preconditionFailure("It's currently not allowed to send non onion routed requests.") + // Attempt to sign the request with the new auth + guard let signedRequest: URLRequest = sign(urlRequest, for: request.server, with: publicKey, using: dependencies) else { + return Promise(error: Error.signingFailed) + } + + return dependencies.onionApi.sendOnionRequest(signedRequest, to: request.server, with: publicKey) } } diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 8e096e5fb..38de61a63 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -9,6 +9,9 @@ public final class OpenGroupManager: NSObject { public var defaultRoomsPromise: Promise<[OpenGroupAPI.Room]>? fileprivate var groupImagePromises: [String: Promise] = [:] + public var pollers: [String: OpenGroupAPI.Poller] = [:] // One for each server + public var isPolling: Bool = false + /// Server URL to room ID to set of user IDs fileprivate var moderators: [String: [String: Set]] = [:] fileprivate var admins: [String: [String: Set]] = [:] @@ -40,29 +43,31 @@ public final class OpenGroupManager: NSObject { public let mutableCache: Atomic = Atomic(Cache()) public var cache: Cache { return mutableCache.wrappedValue } - private var pollers: [String: OpenGroupAPI.Poller] = [:] // One for each server - private var isPolling = false - // MARK: - Polling - - @objc public func startPolling() { - guard !isPolling else { return } + + public func startPolling(using dependencies: Dependencies = Dependencies()) { + guard !cache.isPolling else { return } - isPolling = true - pollers = Set(Storage.shared.getAllOpenGroups().values.map { $0.server }) - .reduce(into: [:]) { prev, server in - pollers[server]?.stop() // Should never occur - - let poller = OpenGroupAPI.Poller(for: server) - poller.startIfNeeded() - - prev[server] = poller - } + mutableCache.mutate { cache in + cache.isPolling = true + cache.pollers = Set(dependencies.storage.getAllOpenGroups().values.map { openGroup in openGroup.server }) + .reduce(into: [:]) { prev, server in + cache.pollers[server]?.stop() // Should never occur + + let poller = OpenGroupAPI.Poller(for: server) + poller.startIfNeeded(using: dependencies) + + prev[server] = poller + } + } } @objc public func stopPolling() { - pollers.forEach { (_, openGroupPoller) in openGroupPoller.stop() } - pollers.removeAll() + mutableCache.mutate { + $0.pollers.forEach { (_, openGroupPoller) in openGroupPoller.stop() } + $0.pollers.removeAll() + $0.isPolling = false + } } // MARK: - Adding & Removing @@ -71,7 +76,7 @@ public final class OpenGroupManager: NSObject { // If we are currently polling for this server and already have a TSGroupThread for this room the do nothing let groupId: Data = LKGroupUtilities.getEncodedOpenGroupIDAsData("\(server).\(roomToken)") - if OpenGroupManager.shared.pollers[server] != nil && TSGroupThread.fetch(uniqueId: TSGroupThread.threadId(fromGroupId: groupId), transaction: transaction) != nil { + if OpenGroupManager.shared.cache.pollers[server] != nil && TSGroupThread.fetch(uniqueId: TSGroupThread.threadId(fromGroupId: groupId), transaction: transaction) != nil { SNLog("Ignoring join open group attempt (already joined), user initiated: \(!isConfigMessage)") return Promise.value(()) } @@ -86,19 +91,13 @@ public final class OpenGroupManager: NSObject { transaction.addCompletionQueue(DispatchQueue.global(qos: .userInitiated)) { OpenGroupAPI.capabilitiesAndRoom(for: roomToken, on: server, using: dependencies) - .done(on: DispatchQueue.global(qos: .userInitiated)) { (capabilitiesResponse: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Capabilities?), roomResponse: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Room?)) in - guard let capabilities: OpenGroupAPI.Capabilities = capabilitiesResponse.data, let room: OpenGroupAPI.Room = roomResponse.data else { - SNLog("Failed to join open group due to invalid data.") - seal.reject(HTTP.Error.generic) - return - } - - dependencies.storage.write { anyTransactionas in - guard let transaction: YapDatabaseReadWriteTransaction = anyTransactionas as? YapDatabaseReadWriteTransaction else { return } + .done(on: DispatchQueue.global(qos: .userInitiated)) { response in + dependencies.storage.write { anyTransaction in + guard let transaction: YapDatabaseReadWriteTransaction = anyTransaction as? YapDatabaseReadWriteTransaction else { return } // Store the capabilities first OpenGroupManager.handleCapabilities( - capabilities, + response.capabilities.data, on: server, using: transaction, dependencies: dependencies @@ -106,7 +105,7 @@ public final class OpenGroupManager: NSObject { // Then the room OpenGroupManager.handleRoom( - room, + response.room.data, publicKey: publicKey, for: roomToken, on: server, @@ -118,6 +117,7 @@ public final class OpenGroupManager: NSObject { } } .catch(on: DispatchQueue.global(qos: .userInitiated)) { error in + SNLog("Failed to join open group.") seal.reject(error) } } @@ -125,15 +125,13 @@ public final class OpenGroupManager: NSObject { return promise } - public func delete(_ openGroup: OpenGroup, associatedWith thread: TSThread, using transaction: YapDatabaseReadWriteTransaction) { - let storage = SNMessagingKitConfiguration.shared.storage - + public func delete(_ openGroup: OpenGroup, associatedWith thread: TSThread, using transaction: YapDatabaseReadWriteTransaction, dependencies: Dependencies = Dependencies()) { // Stop the poller if needed - let openGroups = storage.getAllOpenGroups().values.filter { $0.server == openGroup.server } + let openGroups = dependencies.storage.getAllOpenGroups().values.filter { $0.server == openGroup.server } if openGroups.count == 1 && openGroups.last == openGroup { - let poller = pollers[openGroup.server] + let poller = cache.pollers[openGroup.server] poller?.stop() - pollers[openGroup.server] = nil + mutableCache.mutate { $0.pollers[openGroup.server] = nil } } // Remove all data @@ -143,17 +141,18 @@ public final class OpenGroupManager: NSObject { messageIDs.insert(interaction.uniqueId!) messageTimestamps.insert(interaction.timestamp) } - storage.updateMessageIDCollectionByPruningMessagesWithIDs(messageIDs, using: transaction) - Storage.shared.removeReceivedMessageTimestamps(messageTimestamps, using: transaction) - Storage.shared.removeOpenGroupSequenceNumber(for: openGroup.room, on: openGroup.server, using: transaction) + dependencies.storage.updateMessageIDCollectionByPruningMessagesWithIDs(messageIDs, using: transaction) + dependencies.storage.removeReceivedMessageTimestamps(messageTimestamps, using: transaction) + dependencies.storage.removeOpenGroupSequenceNumber(for: openGroup.room, on: openGroup.server, using: transaction) thread.removeAllThreadInteractions(with: transaction) thread.remove(with: transaction) - Storage.shared.removeOpenGroup(for: thread.uniqueId!, using: transaction) + dependencies.storage.removeOpenGroup(for: thread.uniqueId!, using: transaction) - // Only remove the open group public key if the user isn't in any other rooms + // Only remove the open group public key and server info if the user isn't in any other rooms if openGroups.count <= 1 { - Storage.shared.removeOpenGroupPublicKey(for: openGroup.server, using: transaction) + dependencies.storage.removeOpenGroupServer(name: openGroup.server, using: transaction) + dependencies.storage.removeOpenGroupPublicKey(for: openGroup.server, using: transaction) } } @@ -262,9 +261,11 @@ public final class OpenGroupManager: NSObject { transaction.addCompletionQueue(DispatchQueue.global(qos: .userInitiated)) { // Start the poller if needed - if OpenGroupManager.shared.pollers[server] == nil { - OpenGroupManager.shared.pollers[server] = OpenGroupAPI.Poller(for: server) - OpenGroupManager.shared.pollers[server]?.startIfNeeded() + if OpenGroupManager.shared.cache.pollers[server] == nil { + OpenGroupManager.shared.mutableCache.mutate { + $0.pollers[server] = OpenGroupAPI.Poller(for: server) + $0.pollers[server]?.startIfNeeded(using: dependencies) + } } // - Moderators @@ -649,6 +650,11 @@ public final class OpenGroupManager: NSObject { } extension OpenGroupManager { + @objc(startPolling) + public func objc_startPolling() { + startPolling() + } + @objc(getDefaultRoomsIfNeeded) public static func objc_getDefaultRoomsIfNeeded() { getDefaultRoomsIfNeeded() diff --git a/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift b/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift index 920eb2693..330647db6 100644 --- a/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift +++ b/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift @@ -24,6 +24,7 @@ extension OpenGroupAPI { case roomMessagesRecent(String) case roomMessagesBefore(String, id: UInt64) case roomMessagesSince(String, seqNo: Int64) + case roomDeleteMessages(String, sessionId: String) // Pinning @@ -50,7 +51,6 @@ extension OpenGroupAPI { case userBan(String) case userUnban(String) case userModerator(String) - case userDeleteMessages(String) var path: String { switch self { @@ -84,6 +84,9 @@ extension OpenGroupAPI { case .roomMessagesSince(let roomToken, let seqNo): return "room/\(roomToken)/messages/since/\(seqNo)" + case .roomDeleteMessages(let roomToken, let sessionId): + return "room/\(roomToken)/all/\(sessionId)" + // Pinning case .roomPinMessage(let roomToken, let messageId): @@ -114,7 +117,6 @@ extension OpenGroupAPI { case .userBan(let sessionId): return "user/\(sessionId)/ban" case .userUnban(let sessionId): return "user/\(sessionId)/unban" case .userModerator(let sessionId): return "user/\(sessionId)/moderator" - case .userDeleteMessages(let sessionId): return "user/\(sessionId)/deleteMessages" } } } diff --git a/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift b/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift index 28f162b00..d37137ee1 100644 --- a/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift +++ b/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift @@ -28,6 +28,7 @@ public protocol AeadXChaCha20Poly1305IetfType { } public protocol Ed25519Type { + func sign(data: Bytes, keyPair: ECKeyPair) throws -> Bytes? func verifySignature(_ signature: Data, publicKey: Data, data: Data) throws -> Bool } @@ -82,6 +83,10 @@ extension Sign: SignType {} extension GenericHash: GenericHashType {} struct Ed25519Wrapper: Ed25519Type { + func sign(data: Bytes, keyPair: ECKeyPair) throws -> Bytes? { + return try Ed25519.sign(Data(data), with: keyPair).bytes + } + func verifySignature(_ signature: Data, publicKey: Data, data: Data) throws -> Bool { return try Ed25519.verifySignature(signature, publicKey: publicKey, data: data) } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index ef60a0f22..8f37fe51c 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -391,7 +391,8 @@ public final class MessageSender : NSObject { on: server, whisperTo: whisperTo, whisperMods: whisperMods, - fileIds: fileIds + fileIds: fileIds, + using: dependencies ) .done(on: DispatchQueue.global(qos: .userInitiated)) { responseInfo, data in message.openGroupServerMessageID = UInt64(data.id) diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift index 95e46e58b..20b7de9da 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift @@ -18,15 +18,15 @@ extension OpenGroupAPI { public init(for server: String) { self.server = server } - - @objc public func startIfNeeded() { + + public func startIfNeeded(using dependencies: Dependencies = Dependencies()) { guard !hasStarted else { return } hasStarted = true timer = Timer.scheduledTimerOnMainThread(withTimeInterval: Poller.pollInterval, repeats: true) { _ in - self.poll().retainUntilComplete() + self.poll(using: dependencies).retainUntilComplete() } - poll().retainUntilComplete() + poll(using: dependencies).retainUntilComplete() } @objc public func stop() { @@ -37,12 +37,12 @@ extension OpenGroupAPI { // MARK: - Polling @discardableResult - public func poll() -> Promise { - return poll(isBackgroundPoll: false) + public func poll(using dependencies: Dependencies = Dependencies()) -> Promise { + return poll(isBackgroundPoll: false, using: dependencies) } @discardableResult - public func poll(isBackgroundPoll: Bool) -> Promise { + public func poll(isBackgroundPoll: Bool, using dependencies: Dependencies = Dependencies()) -> Promise { guard !self.isPolling else { return Promise.value(()) } self.isPolling = true @@ -57,12 +57,13 @@ extension OpenGroupAPI { hasPerformedInitialPoll: OpenGroupManager.shared.cache.hasPerformedInitialPoll[server] == true, timeSinceLastPoll: ( OpenGroupManager.shared.cache.timeSinceLastPoll[server] ?? - OpenGroupManager.shared.cache.getTimeSinceLastOpen() - ) + OpenGroupManager.shared.cache.getTimeSinceLastOpen(using: dependencies) + ), + using: dependencies ) .done(on: OpenGroupAPI.workQueue) { [weak self] response in self?.isPolling = false - self?.handlePollResponse(response, isBackgroundPoll: isBackgroundPoll) + self?.handlePollResponse(response, isBackgroundPoll: isBackgroundPoll, using: dependencies) OpenGroupManager.shared.mutableCache.mutate { cache in cache.hasPerformedInitialPoll[server] = true @@ -81,10 +82,10 @@ extension OpenGroupAPI { return promise } - private func handlePollResponse(_ response: [OpenGroupAPI.Endpoint: (info: OnionRequestResponseInfoType, data: Codable?)], isBackgroundPoll: Bool) { + private func handlePollResponse(_ response: [OpenGroupAPI.Endpoint: (info: OnionRequestResponseInfoType, data: Codable?)], isBackgroundPoll: Bool, using dependencies: Dependencies = Dependencies()) { let server: String = self.server - Storage.shared.write { anyTransaction in + dependencies.storage.write { anyTransaction in guard let transaction: YapDatabaseReadWriteTransaction = anyTransaction as? YapDatabaseReadWriteTransaction else { SNLog("Open group polling failed due to invalid database transaction.") return @@ -101,21 +102,30 @@ extension OpenGroupAPI { OpenGroupManager.handleCapabilities( responseBody, on: server, - using: transaction + using: transaction, + dependencies: dependencies ) case .roomMessagesRecent(let roomToken), .roomMessagesBefore(let roomToken, _), .roomMessagesSince(let roomToken, _): - guard let responseData: BatchSubResponse<[Message]> = endpointResponse.data as? BatchSubResponse<[Message]>, let responseBody: [Message] = responseData.body else { + guard let responseData: BatchSubResponse<[Failable]> = endpointResponse.data as? BatchSubResponse<[Failable]>, let responseBody: [Failable] = responseData.body else { SNLog("Open group polling failed due to invalid data.") return } + let successfulMessages: [Message] = responseBody.compactMap { $0.value } + + if successfulMessages.count != responseBody.count { + let droppedCount: Int = (responseBody.count - successfulMessages.count) + + SNLog("Dropped \(droppedCount) invalid open group message\(droppedCount == 1 ? "" : "s").") + } OpenGroupManager.handleMessages( - responseBody, + successfulMessages, for: roomToken, on: server, isBackgroundPoll: isBackgroundPoll, - using: transaction + using: transaction, + dependencies: dependencies ) case .roomPollInfo(let roomToken, _): @@ -129,7 +139,8 @@ extension OpenGroupAPI { publicKey: nil, for: roomToken, on: server, - using: transaction + using: transaction, + dependencies: dependencies ) case .inbox, .inboxSince, .outbox, .outboxSince: @@ -150,7 +161,8 @@ extension OpenGroupAPI { fromOutbox: fromOutbox, on: server, isBackgroundPoll: isBackgroundPoll, - using: transaction + using: transaction, + dependencies: dependencies ) default: break // No custom handling needed @@ -160,3 +172,10 @@ extension OpenGroupAPI { } } } + +extension OpenGroupAPI.Poller { + @objc(startIfNeeded) + public func objc_startIfNeeded() { + startIfNeeded() + } +} diff --git a/SessionMessagingKit/Utilities/Dependencies.swift b/SessionMessagingKit/Utilities/Dependencies.swift index 31afcf434..e1b405c80 100644 --- a/SessionMessagingKit/Utilities/Dependencies.swift +++ b/SessionMessagingKit/Utilities/Dependencies.swift @@ -8,10 +8,10 @@ import SessionUtilitiesKit // MARK: - Dependencies public class Dependencies { - private var _api: OnionRequestAPIType.Type? - public var api: OnionRequestAPIType.Type { - get { getValueSettingIfNull(&_api) { OnionRequestAPI.self } } - set { _api = newValue } + private var _onionApi: OnionRequestAPIType.Type? + public var onionApi: OnionRequestAPIType.Type { + get { getValueSettingIfNull(&_onionApi) { OnionRequestAPI.self } } + set { _onionApi = newValue } } private var _storage: SessionMessagingKitStorageProtocol? @@ -77,7 +77,7 @@ public class Dependencies { // MARK: - Initialization public init( - api: OnionRequestAPIType.Type? = nil, + onionApi: OnionRequestAPIType.Type? = nil, storage: SessionMessagingKitStorageProtocol? = nil, sodium: SodiumType? = nil, aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, @@ -89,7 +89,7 @@ public class Dependencies { standardUserDefaults: UserDefaultsType? = nil, date: Date? = nil ) { - _api = api + _onionApi = onionApi _storage = storage _sodium = sodium _aeadXChaCha20Poly1305Ietf = aeadXChaCha20Poly1305Ietf @@ -105,7 +105,7 @@ public class Dependencies { // MARK: - Convenience public func with( - api: OnionRequestAPIType.Type? = nil, + onionApi: OnionRequestAPIType.Type? = nil, storage: SessionMessagingKitStorageProtocol? = nil, sodium: SodiumType? = nil, aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, @@ -118,7 +118,7 @@ public class Dependencies { date: Date? = nil ) -> Dependencies { return Dependencies( - api: (api ?? self._api), + onionApi: (onionApi ?? self._onionApi), storage: (storage ?? self._storage), sodium: (sodium ?? self._sodium), aeadXChaCha20Poly1305Ietf: (aeadXChaCha20Poly1305Ietf ?? self._aeadXChaCha20Poly1305Ietf), diff --git a/SessionMessagingKit/Utilities/Failable.swift b/SessionMessagingKit/Utilities/Failable.swift new file mode 100644 index 000000000..80aa529a0 --- /dev/null +++ b/SessionMessagingKit/Utilities/Failable.swift @@ -0,0 +1,24 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +struct Failable: Codable { + let value: T? + + init(from decoder: Decoder) throws { + guard let container = try? decoder.singleValueContainer() else { + self.value = nil + return + } + + self.value = try? container.decode(T.self) + } + + func encode(to encoder: Encoder) throws { + guard let value: T = value else { return } + + var container: SingleValueEncodingContainer = encoder.singleValueContainer() + + try container.encode(value) + } +} diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift index 416172861..12fa35142 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift @@ -10,18 +10,6 @@ import Nimble @testable import SessionMessagingKit class OpenGroupAPISpec: QuickSpec { - class TestResponseInfo: OnionRequestResponseInfoType { - let requestData: TestApi.RequestData - let code: Int - let headers: [String: String] - - init(requestData: TestApi.RequestData, code: Int, headers: [String: String]) { - self.requestData = requestData - self.code = code - self.headers = headers - } - } - struct TestNonce16Generator: NonceGenerator16ByteType { var NonceBytes: Int = 16 @@ -34,56 +22,15 @@ class OpenGroupAPISpec: QuickSpec { func nonce() -> Array { return Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")!.bytes } } - class TestApi: OnionRequestAPIType { - struct RequestData: Codable { - let urlString: String? - let httpMethod: String - let headers: [String: String] - let snodeMethod: String? - let body: Data? - - let server: String - let version: OnionRequestAPI.Version - let publicKey: String? - } - - class var mockResponse: Data? { return nil } - - static func sendOnionRequest(_ request: URLRequest, to server: String, using version: OnionRequestAPI.Version, with x25519PublicKey: String) -> Promise<(OnionRequestResponseInfoType, Data?)> { - let responseInfo: TestResponseInfo = TestResponseInfo( - requestData: RequestData( - urlString: request.url?.absoluteString, - httpMethod: (request.httpMethod ?? "GET"), - headers: (request.allHTTPHeaderFields ?? [:]), - snodeMethod: nil, - body: request.httpBody, - - server: server, - version: version, - publicKey: x25519PublicKey - ), - code: 200, - headers: [:] - ) - - return Promise.value((responseInfo, mockResponse)) - } - - static func sendOnionRequest(to snode: Snode, invoking method: Snode.Method, with parameters: JSON, using version: OnionRequestAPI.Version, associatedWith publicKey: String?) -> Promise { - // TODO: Test the 'responseInfo' somehow? - return Promise.value(mockResponse!) - } - } - // MARK: - Spec override func spec() { var mockStorage: MockStorage! var mockSodium: MockSodium! var mockAeadXChaCha20Poly1305Ietf: MockAeadXChaCha20Poly1305Ietf! - var mockGenericHash: MockGenericHash! var mockSign: MockSign! - var testUserDefaults: TestUserDefaults! + var mockGenericHash: MockGenericHash! + var mockEd25519: MockEd25519! var dependencies: Dependencies! var response: (OnionRequestResponseInfoType, Codable)? = nil @@ -97,20 +44,19 @@ class OpenGroupAPISpec: QuickSpec { mockStorage = MockStorage() mockSodium = MockSodium() mockAeadXChaCha20Poly1305Ietf = MockAeadXChaCha20Poly1305Ietf() - mockGenericHash = MockGenericHash() mockSign = MockSign() - testUserDefaults = TestUserDefaults() + mockGenericHash = MockGenericHash() + mockEd25519 = MockEd25519() dependencies = Dependencies( - api: TestApi.self, + onionApi: TestOnionRequestAPI.self, storage: mockStorage, sodium: mockSodium, aeadXChaCha20Poly1305Ietf: mockAeadXChaCha20Poly1305Ietf, sign: mockSign, genericHash: mockGenericHash, - ed25519: MockEd25519(), + ed25519: mockEd25519, nonceGenerator16: TestNonce16Generator(), nonceGenerator24: TestNonce24Generator(), - standardUserDefaults: testUserDefaults, date: Date(timeIntervalSince1970: 1234567890) ) @@ -190,15 +136,16 @@ class OpenGroupAPISpec: QuickSpec { } .thenReturn("TestSogsSignature".bytes) mockSign.when { $0.signature(message: any(), secretKey: any()) }.thenReturn("TestSignature".bytes) + mockEd25519.when { try $0.sign(data: any(), keyPair: any()) }.thenReturn("TestStandardSignature".bytes) } afterEach { mockStorage = nil mockSodium = nil mockAeadXChaCha20Poly1305Ietf = nil - mockGenericHash = nil mockSign = nil - testUserDefaults = nil + mockGenericHash = nil + mockEd25519 = nil dependencies = nil response = nil @@ -211,7 +158,7 @@ class OpenGroupAPISpec: QuickSpec { context("when polling") { context("and given a correct response") { beforeEach { - class LocalTestApi: TestApi { + class TestApi: TestOnionRequestAPI { override class var mockResponse: Data? { let responses: [Data] = [ try! JSONEncoder().encode( @@ -271,7 +218,7 @@ class OpenGroupAPISpec: QuickSpec { } } - dependencies = dependencies.with(api: LocalTestApi.self) + dependencies = dependencies.with(onionApi: TestApi.self) } it("generates the correct request") { @@ -294,10 +241,10 @@ class OpenGroupAPISpec: QuickSpec { expect(pollResponse?.keys).to(contain(.roomMessagesRecent("testRoom"))) expect(pollResponse?.keys).to(contain(.inbox)) expect(pollResponse?.keys).to(contain(.outbox)) - expect(pollResponse?[.capabilities]?.0).to(beAKindOf(TestResponseInfo.self)) + expect(pollResponse?[.capabilities]?.0).to(beAKindOf(TestOnionRequestAPI.ResponseInfo.self)) // Validate request data - let requestData: TestApi.RequestData? = (pollResponse?[.capabilities]?.0 as? TestResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (pollResponse?[.capabilities]?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.urlString).to(equal("testServer/batch")) expect(requestData?.httpMethod).to(equal("POST")) expect(requestData?.server).to(equal("testServer")) @@ -484,24 +431,6 @@ class OpenGroupAPISpec: QuickSpec { } context("and given an invalid response") { - it("does not update the poll state") { - mockStorage.when { $0.getOpenGroupSequenceNumber(for: any(), on: any()) }.thenReturn(123) - - OpenGroupAPI.poll("testServer", hasPerformedInitialPoll: false, timeSinceLastPoll: 0, using: dependencies) - .get { result in pollResponse = result } - .catch { requestError in error = requestError } - .retainUntilComplete() - - expect(error?.localizedDescription) - .toEventually( - equal(HTTP.Error.invalidResponse.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(pollResponse).to(beNil()) - expect(testUserDefaults[.lastOpen]).to(beNil()) - } - it("errors when no data is returned") { OpenGroupAPI.poll("testServer", hasPerformedInitialPoll: false, timeSinceLastPoll: 0, using: dependencies) .get { result in pollResponse = result } @@ -518,10 +447,10 @@ class OpenGroupAPISpec: QuickSpec { } it("errors when invalid data is returned") { - class LocalTestApi: TestApi { + class TestApi: TestOnionRequestAPI { override class var mockResponse: Data? { return Data() } } - dependencies = dependencies.with(api: LocalTestApi.self) + dependencies = dependencies.with(onionApi: TestApi.self) OpenGroupAPI.poll("testServer", hasPerformedInitialPoll: false, timeSinceLastPoll: 0, using: dependencies) .get { result in pollResponse = result } @@ -538,10 +467,10 @@ class OpenGroupAPISpec: QuickSpec { } it("errors when an empty array is returned") { - class LocalTestApi: TestApi { + class TestApi: TestOnionRequestAPI { override class var mockResponse: Data? { return "[]".data(using: .utf8) } } - dependencies = dependencies.with(api: LocalTestApi.self) + dependencies = dependencies.with(onionApi: TestApi.self) OpenGroupAPI.poll("testServer", hasPerformedInitialPoll: false, timeSinceLastPoll: 0, using: dependencies) .get { result in pollResponse = result } @@ -558,10 +487,10 @@ class OpenGroupAPISpec: QuickSpec { } it("errors when an empty object is returned") { - class LocalTestApi: TestApi { + class TestApi: TestOnionRequestAPI { override class var mockResponse: Data? { return "{}".data(using: .utf8) } } - dependencies = dependencies.with(api: LocalTestApi.self) + dependencies = dependencies.with(onionApi: TestApi.self) OpenGroupAPI.poll("testServer", hasPerformedInitialPoll: false, timeSinceLastPoll: 0, using: dependencies) .get { result in pollResponse = result } @@ -578,7 +507,7 @@ class OpenGroupAPISpec: QuickSpec { } it("errors when a different number of responses are returned") { - class LocalTestApi: TestApi { + class TestApi: TestOnionRequestAPI { override class var mockResponse: Data? { let responses: [Data] = [ try! JSONEncoder().encode( @@ -613,7 +542,7 @@ class OpenGroupAPISpec: QuickSpec { return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) } } - dependencies = dependencies.with(api: LocalTestApi.self) + dependencies = dependencies.with(onionApi: TestApi.self) OpenGroupAPI.poll("testServer", hasPerformedInitialPoll: false, timeSinceLastPoll: 0, using: dependencies) .get { result in pollResponse = result } @@ -630,7 +559,7 @@ class OpenGroupAPISpec: QuickSpec { } it("errors when an unexpected response is returned") { - class LocalTestApi: TestApi { + class TestApi: TestOnionRequestAPI { override class var mockResponse: Data? { let responses: [Data] = [ try! JSONEncoder().encode( @@ -662,7 +591,7 @@ class OpenGroupAPISpec: QuickSpec { return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) } } - dependencies = dependencies.with(api: LocalTestApi.self) + dependencies = dependencies.with(onionApi: TestApi.self) OpenGroupAPI.poll("testServer", hasPerformedInitialPoll: false, timeSinceLastPoll: 0, using: dependencies) .get { result in pollResponse = result } @@ -684,12 +613,12 @@ class OpenGroupAPISpec: QuickSpec { context("when doing a capabilities request") { it("generates the request and handles the response correctly") { - class LocalTestApi: TestApi { + class TestApi: TestOnionRequestAPI { static let data: OpenGroupAPI.Capabilities = OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: nil) override class var mockResponse: Data? { try! JSONEncoder().encode(data) } } - dependencies = dependencies.with(api: LocalTestApi.self) + dependencies = dependencies.with(onionApi: TestApi.self) var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Capabilities)? @@ -706,10 +635,10 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate the response data - expect(response?.data).to(equal(LocalTestApi.data)) + expect(response?.data).to(equal(TestApi.data)) // Validate request data - let requestData: TestApi.RequestData? = (response?.info as? TestResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("GET")) expect(requestData?.server).to(equal("testServer")) expect(requestData?.urlString).to(equal("testServer/capabilities")) @@ -720,7 +649,7 @@ class OpenGroupAPISpec: QuickSpec { context("when doing a rooms request") { it("generates the request and handles the response correctly") { - class LocalTestApi: TestApi { + class TestApi: TestOnionRequestAPI { static let data: [OpenGroupAPI.Room] = [ OpenGroupAPI.Room( token: "test", @@ -753,7 +682,7 @@ class OpenGroupAPISpec: QuickSpec { override class var mockResponse: Data? { return try! JSONEncoder().encode(data) } } - dependencies = dependencies.with(api: LocalTestApi.self) + dependencies = dependencies.with(onionApi: TestApi.self) var response: (info: OnionRequestResponseInfoType, data: [OpenGroupAPI.Room])? @@ -770,10 +699,10 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate the response data - expect(response?.data).to(equal(LocalTestApi.data)) + expect(response?.data).to(equal(TestApi.data)) // Validate request data - let requestData: TestApi.RequestData? = (response?.info as? TestResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("GET")) expect(requestData?.server).to(equal("testServer")) expect(requestData?.urlString).to(equal("testServer/rooms")) @@ -785,7 +714,7 @@ class OpenGroupAPISpec: QuickSpec { context("when doing a capabilitiesAndRoom request") { context("and given a correct response") { it("generates the request and handles the response correctly") { - class LocalTestApi: TestApi { + class TestApi: TestOnionRequestAPI { static let capabilitiesData: OpenGroupAPI.Capabilities = OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: nil) static let roomData: OpenGroupAPI.Room = OpenGroupAPI.Room( token: "test", @@ -838,7 +767,7 @@ class OpenGroupAPISpec: QuickSpec { return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) } } - dependencies = dependencies.with(api: LocalTestApi.self) + dependencies = dependencies.with(onionApi: TestApi.self) var response: (capabilities: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Capabilities?), room: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Room?))? @@ -855,11 +784,11 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate the response data - expect(response?.capabilities.data).to(equal(LocalTestApi.capabilitiesData)) - expect(response?.room.data).to(equal(LocalTestApi.roomData)) + expect(response?.capabilities.data).to(equal(TestApi.capabilitiesData)) + expect(response?.room.data).to(equal(TestApi.roomData)) // Validate request data - let requestData: TestApi.RequestData? = (response?.capabilities.info as? TestResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.capabilities.info as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("POST")) expect(requestData?.server).to(equal("testServer")) expect(requestData?.urlString).to(equal("testServer/sequence")) @@ -868,7 +797,7 @@ class OpenGroupAPISpec: QuickSpec { context("and given an invalid response") { it("errors when only a capabilities response is returned") { - class LocalTestApi: TestApi { + class TestApi: TestOnionRequestAPI { static let capabilitiesData: OpenGroupAPI.Capabilities = OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: nil) override class var mockResponse: Data? { @@ -886,7 +815,7 @@ class OpenGroupAPISpec: QuickSpec { return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) } } - dependencies = dependencies.with(api: LocalTestApi.self) + dependencies = dependencies.with(onionApi: TestApi.self) var response: (capabilities: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Capabilities?), room: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Room?))? @@ -905,7 +834,7 @@ class OpenGroupAPISpec: QuickSpec { } it("errors when only a room response is returned") { - class LocalTestApi: TestApi { + class TestApi: TestOnionRequestAPI { static let roomData: OpenGroupAPI.Room = OpenGroupAPI.Room( token: "test", name: "test", @@ -949,7 +878,7 @@ class OpenGroupAPISpec: QuickSpec { return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) } } - dependencies = dependencies.with(api: LocalTestApi.self) + dependencies = dependencies.with(onionApi: TestApi.self) var response: (capabilities: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Capabilities?), room: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Room?))? @@ -968,7 +897,7 @@ class OpenGroupAPISpec: QuickSpec { } it("errors when an extra response is returned") { - class LocalTestApi: TestApi { + class TestApi: TestOnionRequestAPI { static let capabilitiesData: OpenGroupAPI.Capabilities = OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: nil) static let roomData: OpenGroupAPI.Room = OpenGroupAPI.Room( token: "test", @@ -1029,7 +958,7 @@ class OpenGroupAPISpec: QuickSpec { return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) } } - dependencies = dependencies.with(api: LocalTestApi.self) + dependencies = dependencies.with(onionApi: TestApi.self) var response: (capabilities: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Capabilities?), room: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Room?))? @@ -1055,7 +984,7 @@ class OpenGroupAPISpec: QuickSpec { var messageData: OpenGroupAPI.Message! beforeEach { - class LocalTestApi: TestApi { + class TestApi: TestOnionRequestAPI { static let data: OpenGroupAPI.Message = OpenGroupAPI.Message( id: 126, sender: "testSender", @@ -1071,10 +1000,8 @@ class OpenGroupAPISpec: QuickSpec { override class var mockResponse: Data? { return try! JSONEncoder().encode(data) } } - messageData = LocalTestApi.data - dependencies = dependencies.with(api: LocalTestApi.self) - - mockStorage.when { $0.getUserED25519KeyPair() }.thenReturn(Box.KeyPair(publicKey: [], secretKey: [])) + messageData = TestApi.data + dependencies = dependencies.with(onionApi: TestApi.self) } afterEach { @@ -1109,7 +1036,7 @@ class OpenGroupAPISpec: QuickSpec { expect(response?.data).to(equal(messageData)) // Validate signature headers - let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("POST")) expect(requestData?.server).to(equal("testServer")) expect(requestData?.urlString).to(equal("testServer/room/testRoom/message")) @@ -1181,11 +1108,11 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate request body - let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData let requestBody: OpenGroupAPI.SendMessageRequest = try! JSONDecoder().decode(OpenGroupAPI.SendMessageRequest.self, from: requestData!.body!) expect(requestBody.data).to(equal("test".data(using: .utf8))) - expect(requestBody.signature).to(equal("TestSignature".data(using: .utf8))) + expect(requestBody.signature).to(equal("TestStandardSignature".data(using: .utf8))) } it("fails to sign if there is no public key") { @@ -1215,6 +1142,63 @@ class OpenGroupAPISpec: QuickSpec { expect(response).to(beNil()) } + + it("fails to sign if there is no user key pair") { + mockStorage.when { $0.getUserKeyPair() }.thenReturn(nil) + + var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? + + OpenGroupAPI + .send( + "test".data(using: .utf8)!, + to: "testRoom", + on: "testServer", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + + it("fails to sign if no signature is generated") { + mockEd25519.reset() // The 'keyPair' value doesn't equate so have to explicitly reset + mockEd25519.when { try $0.sign(data: any(), keyPair: any()) }.thenReturn(nil) + + var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? + + OpenGroupAPI + .send( + "test".data(using: .utf8)!, + to: "testRoom", + on: "testServer", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } } context("when blinded") { @@ -1254,7 +1238,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate request body - let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData let requestBody: OpenGroupAPI.SendMessageRequest = try! JSONDecoder().decode(OpenGroupAPI.SendMessageRequest.self, from: requestData!.body!) expect(requestBody.data).to(equal("test".data(using: .utf8))) @@ -1288,12 +1272,70 @@ class OpenGroupAPISpec: QuickSpec { expect(response).to(beNil()) } + + it("fails to sign if there is no ed key pair key") { + mockStorage.when { $0.getUserED25519KeyPair() }.thenReturn(nil) + + var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? + + OpenGroupAPI + .send( + "test".data(using: .utf8)!, + to: "testRoom", + on: "testServer", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + + it("fails to sign if no signature is generated") { + mockSodium + .when { $0.sogsSignature(message: any(), secretKey: any(), blindedSecretKey: any(), blindedPublicKey: any()) } + .thenReturn(nil) + + var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? + + OpenGroupAPI + .send( + "test".data(using: .utf8)!, + to: "testRoom", + on: "testServer", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } } } context("when getting an individual message") { it("generates the request and handles the response correctly") { - class LocalTestApi: TestApi { + class TestApi: TestOnionRequestAPI { static let data: OpenGroupAPI.Message = OpenGroupAPI.Message( id: 126, sender: "testSender", @@ -1309,7 +1351,7 @@ class OpenGroupAPISpec: QuickSpec { override class var mockResponse: Data? { return try! JSONEncoder().encode(data) } } - dependencies = dependencies.with(api: LocalTestApi.self) + dependencies = dependencies.with(onionApi: TestApi.self) var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? @@ -1326,10 +1368,10 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate the response data - expect(response?.data).to(equal(LocalTestApi.data)) + expect(response?.data).to(equal(TestApi.data)) // Validate request data - let requestData: TestApi.RequestData? = (response?.info as? TestResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("GET")) expect(requestData?.server).to(equal("testServer")) expect(requestData?.urlString).to(equal("testServer/room/testRoom/message/123")) @@ -1338,10 +1380,10 @@ class OpenGroupAPISpec: QuickSpec { context("when updating a message") { beforeEach { - class LocalTestApi: TestApi { + class TestApi: TestOnionRequestAPI { override class var mockResponse: Data? { return Data() } } - dependencies = dependencies.with(api: LocalTestApi.self) + dependencies = dependencies.with(onionApi: TestApi.self) mockStorage.when { $0.getUserED25519KeyPair() }.thenReturn(Box.KeyPair(publicKey: [], secretKey: [])) } @@ -1370,7 +1412,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate signature headers - let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("PUT")) expect(requestData?.server).to(equal("testServer")) expect(requestData?.urlString).to(equal("testServer/room/testRoom/message/123")) @@ -1412,11 +1454,11 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate request body - let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData let requestBody: OpenGroupAPI.UpdateMessageRequest = try! JSONDecoder().decode(OpenGroupAPI.UpdateMessageRequest.self, from: requestData!.body!) expect(requestBody.data).to(equal("test".data(using: .utf8))) - expect(requestBody.signature).to(equal("TestSignature".data(using: .utf8))) + expect(requestBody.signature).to(equal("TestStandardSignature".data(using: .utf8))) } it("fails to sign if there is no public key") { @@ -1445,6 +1487,61 @@ class OpenGroupAPISpec: QuickSpec { expect(response).to(beNil()) } + + it("fails to sign if there is no user key pair") { + mockStorage.when { $0.getUserKeyPair() }.thenReturn(nil) + + var response: (info: OnionRequestResponseInfoType, data: Data?)? + + OpenGroupAPI + .messageUpdate( + 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testServer", + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + + it("fails to sign if no signature is generated") { + mockEd25519.reset() // The 'keyPair' value doesn't equate so have to explicitly reset + mockEd25519.when { try $0.sign(data: any(), keyPair: any()) }.thenReturn(nil) + + var response: (info: OnionRequestResponseInfoType, data: Data?)? + + OpenGroupAPI + .messageUpdate( + 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testServer", + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } } context("when blinded") { @@ -1483,7 +1580,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate request body - let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData let requestBody: OpenGroupAPI.UpdateMessageRequest = try! JSONDecoder().decode(OpenGroupAPI.UpdateMessageRequest.self, from: requestData!.body!) expect(requestBody.data).to(equal("test".data(using: .utf8))) @@ -1516,15 +1613,71 @@ class OpenGroupAPISpec: QuickSpec { expect(response).to(beNil()) } + + it("fails to sign if there is no ed key pair key") { + mockStorage.when { $0.getUserED25519KeyPair() }.thenReturn(nil) + + var response: (info: OnionRequestResponseInfoType, data: Data?)? + + OpenGroupAPI + .messageUpdate( + 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testServer", + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + + it("fails to sign if no signature is generated") { + mockSodium + .when { $0.sogsSignature(message: any(), secretKey: any(), blindedSecretKey: any(), blindedPublicKey: any()) } + .thenReturn(nil) + + var response: (info: OnionRequestResponseInfoType, data: Data?)? + + OpenGroupAPI + .messageUpdate( + 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testServer", + using: dependencies + ) + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } } } context("when deleting a message") { it("generates the request and handles the response correctly") { - class LocalTestApi: TestApi { + class TestApi: TestOnionRequestAPI { override class var mockResponse: Data? { return Data() } } - dependencies = dependencies.with(api: LocalTestApi.self) + dependencies = dependencies.with(onionApi: TestApi.self) var response: (info: OnionRequestResponseInfoType, data: Data?)? @@ -1541,7 +1694,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate request data - let requestData: TestApi.RequestData? = (response?.info as? TestResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("DELETE")) expect(requestData?.server).to(equal("testServer")) expect(requestData?.urlString).to(equal("testServer/room/testRoom/message/123")) @@ -1552,10 +1705,10 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: OnionRequestResponseInfoType, data: Data?)? beforeEach { - class LocalTestApi: TestApi { + class TestApi: TestOnionRequestAPI { override class var mockResponse: Data? { return Data() } } - dependencies = dependencies.with(api: LocalTestApi.self) + dependencies = dependencies.with(onionApi: TestApi.self) } afterEach { @@ -1582,7 +1735,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate request data - let requestData: TestApi.RequestData? = (response?.info as? TestResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("DELETE")) expect(requestData?.server).to(equal("testServer")) expect(requestData?.urlString).to(equal("testServer/room/testRoom/all/testUserId")) @@ -1593,10 +1746,10 @@ class OpenGroupAPISpec: QuickSpec { context("when pinning a message") { it("generates the request and handles the response correctly") { - class LocalTestApi: TestApi { + class TestApi: TestOnionRequestAPI { override class var mockResponse: Data? { return Data() } } - dependencies = dependencies.with(api: LocalTestApi.self) + dependencies = dependencies.with(onionApi: TestApi.self) var response: OnionRequestResponseInfoType? @@ -1613,7 +1766,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate request data - let requestData: TestApi.RequestData? = (response as? TestResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("POST")) expect(requestData?.server).to(equal("testServer")) expect(requestData?.urlString).to(equal("testServer/room/testRoom/pin/123")) @@ -1622,10 +1775,10 @@ class OpenGroupAPISpec: QuickSpec { context("when unpinning a message") { it("generates the request and handles the response correctly") { - class LocalTestApi: TestApi { + class TestApi: TestOnionRequestAPI { override class var mockResponse: Data? { return Data() } } - dependencies = dependencies.with(api: LocalTestApi.self) + dependencies = dependencies.with(onionApi: TestApi.self) var response: OnionRequestResponseInfoType? @@ -1642,7 +1795,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate request data - let requestData: TestApi.RequestData? = (response as? TestResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("POST")) expect(requestData?.server).to(equal("testServer")) expect(requestData?.urlString).to(equal("testServer/room/testRoom/unpin/123")) @@ -1651,10 +1804,10 @@ class OpenGroupAPISpec: QuickSpec { context("when unpinning all messages") { it("generates the request and handles the response correctly") { - class LocalTestApi: TestApi { + class TestApi: TestOnionRequestAPI { override class var mockResponse: Data? { return Data() } } - dependencies = dependencies.with(api: LocalTestApi.self) + dependencies = dependencies.with(onionApi: TestApi.self) var response: OnionRequestResponseInfoType? @@ -1671,7 +1824,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate request data - let requestData: TestApi.RequestData? = (response as? TestResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("POST")) expect(requestData?.server).to(equal("testServer")) expect(requestData?.urlString).to(equal("testServer/room/testRoom/unpin/all")) @@ -1682,12 +1835,12 @@ class OpenGroupAPISpec: QuickSpec { context("when uploading files") { it("generates the request and handles the response correctly") { - class LocalTestApi: TestApi { + class TestApi: TestOnionRequestAPI { override class var mockResponse: Data? { return try! JSONEncoder().encode(FileUploadResponse(id: "1")) } } - dependencies = dependencies.with(api: LocalTestApi.self) + dependencies = dependencies.with(onionApi: TestApi.self) OpenGroupAPI.uploadFile([], to: "testRoom", on: "testServer", using: dependencies) .get { result in response = result } @@ -1702,19 +1855,19 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate signature headers - let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("POST")) expect(requestData?.server).to(equal("testServer")) expect(requestData?.urlString).to(equal("testServer/room/testRoom/file")) } it("doesn't add a fileName to the content-disposition header when not provided") { - class LocalTestApi: TestApi { + class TestApi: TestOnionRequestAPI { override class var mockResponse: Data? { return try! JSONEncoder().encode(FileUploadResponse(id: "1")) } } - dependencies = dependencies.with(api: LocalTestApi.self) + dependencies = dependencies.with(onionApi: TestApi.self) OpenGroupAPI.uploadFile([], to: "testRoom", on: "testServer", using: dependencies) .get { result in response = result } @@ -1729,18 +1882,18 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate signature headers - let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.headers[Header.contentDisposition.rawValue]) .toNot(contain("filename")) } it("adds the fileName to the content-disposition header when provided") { - class LocalTestApi: TestApi { + class TestApi: TestOnionRequestAPI { override class var mockResponse: Data? { return try! JSONEncoder().encode(FileUploadResponse(id: "1")) } } - dependencies = dependencies.with(api: LocalTestApi.self) + dependencies = dependencies.with(onionApi: TestApi.self) OpenGroupAPI.uploadFile([], fileName: "TestFileName", to: "testRoom", on: "testServer", using: dependencies) .get { result in response = result } @@ -1755,19 +1908,19 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate signature headers - let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.headers[Header.contentDisposition.rawValue]).to(contain("TestFileName")) } } context("when downloading files") { it("generates the request and handles the response correctly") { - class LocalTestApi: TestApi { + class TestApi: TestOnionRequestAPI { override class var mockResponse: Data? { return Data() } } - dependencies = dependencies.with(api: LocalTestApi.self) + dependencies = dependencies.with(onionApi: TestApi.self) OpenGroupAPI.downloadFile(1, from: "testRoom", on: "testServer", using: dependencies) .get { result in response = result } @@ -1782,7 +1935,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate signature headers - let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("GET")) expect(requestData?.server).to(equal("testServer")) expect(requestData?.urlString).to(equal("testServer/room/testRoom/file/1")) @@ -1795,7 +1948,7 @@ class OpenGroupAPISpec: QuickSpec { var messageData: OpenGroupAPI.SendDirectMessageResponse! beforeEach { - class LocalTestApi: TestApi { + class TestApi: TestOnionRequestAPI { static let data: OpenGroupAPI.SendDirectMessageResponse = OpenGroupAPI.SendDirectMessageResponse( id: 126, sender: "testSender", @@ -1806,8 +1959,8 @@ class OpenGroupAPISpec: QuickSpec { override class var mockResponse: Data? { return try! JSONEncoder().encode(data) } } - messageData = LocalTestApi.data - dependencies = dependencies.with(api: LocalTestApi.self) + messageData = TestApi.data + dependencies = dependencies.with(onionApi: TestApi.self) } afterEach { @@ -1839,7 +1992,7 @@ class OpenGroupAPISpec: QuickSpec { expect(response?.data).to(equal(messageData)) // Validate signature headers - let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("POST")) expect(requestData?.server).to(equal("testServer")) expect(requestData?.urlString).to(equal("testServer/inbox/testUserId")) @@ -1878,10 +2031,10 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: OnionRequestResponseInfoType, data: Data?)? beforeEach { - class LocalTestApi: TestApi { + class TestApi: TestOnionRequestAPI { override class var mockResponse: Data? { return Data() } } - dependencies = dependencies.with(api: LocalTestApi.self) + dependencies = dependencies.with(onionApi: TestApi.self) } afterEach { @@ -1909,7 +2062,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate request data - let requestData: TestApi.RequestData? = (response?.info as? TestResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("POST")) expect(requestData?.server).to(equal("testServer")) expect(requestData?.urlString).to(equal("testServer/user/testUserId/ban")) @@ -1936,7 +2089,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate request data - let requestData: TestApi.RequestData? = (response?.info as? TestResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData let requestBody: OpenGroupAPI.UserBanRequest = try! JSONDecoder().decode(OpenGroupAPI.UserBanRequest.self, from: requestData!.body!) expect(requestBody.global).to(beTrue()) @@ -1964,7 +2117,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate request data - let requestData: TestApi.RequestData? = (response?.info as? TestResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData let requestBody: OpenGroupAPI.UserBanRequest = try! JSONDecoder().decode(OpenGroupAPI.UserBanRequest.self, from: requestData!.body!) expect(requestBody.global).to(beNil()) @@ -1976,10 +2129,10 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: OnionRequestResponseInfoType, data: Data?)? beforeEach { - class LocalTestApi: TestApi { + class TestApi: TestOnionRequestAPI { override class var mockResponse: Data? { return Data() } } - dependencies = dependencies.with(api: LocalTestApi.self) + dependencies = dependencies.with(onionApi: TestApi.self) } afterEach { @@ -2006,7 +2159,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate request data - let requestData: TestApi.RequestData? = (response?.info as? TestResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("POST")) expect(requestData?.server).to(equal("testServer")) expect(requestData?.urlString).to(equal("testServer/user/testUserId/unban")) @@ -2032,7 +2185,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate request data - let requestData: TestApi.RequestData? = (response?.info as? TestResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData let requestBody: OpenGroupAPI.UserUnbanRequest = try! JSONDecoder().decode(OpenGroupAPI.UserUnbanRequest.self, from: requestData!.body!) expect(requestBody.global).to(beTrue()) @@ -2059,7 +2212,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate request data - let requestData: TestApi.RequestData? = (response?.info as? TestResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData let requestBody: OpenGroupAPI.UserUnbanRequest = try! JSONDecoder().decode(OpenGroupAPI.UserUnbanRequest.self, from: requestData!.body!) expect(requestBody.global).to(beNil()) @@ -2071,10 +2224,10 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: OnionRequestResponseInfoType, data: Data?)? beforeEach { - class LocalTestApi: TestApi { + class TestApi: TestOnionRequestAPI { override class var mockResponse: Data? { return Data() } } - dependencies = dependencies.with(api: LocalTestApi.self) + dependencies = dependencies.with(onionApi: TestApi.self) } afterEach { @@ -2104,7 +2257,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate request data - let requestData: TestApi.RequestData? = (response?.info as? TestResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("POST")) expect(requestData?.server).to(equal("testServer")) expect(requestData?.urlString).to(equal("testServer/user/testUserId/moderator")) @@ -2133,7 +2286,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate request data - let requestData: TestApi.RequestData? = (response?.info as? TestResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData let requestBody: OpenGroupAPI.UserModeratorRequest = try! JSONDecoder().decode(OpenGroupAPI.UserModeratorRequest.self, from: requestData!.body!) expect(requestBody.global).to(beTrue()) @@ -2163,7 +2316,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate request data - let requestData: TestApi.RequestData? = (response?.info as? TestResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData let requestBody: OpenGroupAPI.UserModeratorRequest = try! JSONDecoder().decode(OpenGroupAPI.UserModeratorRequest.self, from: requestData!.body!) expect(requestBody.global).to(beNil()) @@ -2199,7 +2352,7 @@ class OpenGroupAPISpec: QuickSpec { var response: [OnionRequestResponseInfoType]? beforeEach { - class LocalTestApi: TestApi { + class TestApi: TestOnionRequestAPI { override class var mockResponse: Data? { let responses: [Data] = [ try! JSONEncoder().encode( @@ -2223,7 +2376,7 @@ class OpenGroupAPISpec: QuickSpec { return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) } } - dependencies = dependencies.with(api: LocalTestApi.self) + dependencies = dependencies.with(onionApi: TestApi.self) } afterEach { @@ -2250,7 +2403,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate request data - let requestData: TestApi.RequestData? = (response?.first as? TestResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.first as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("POST")) expect(requestData?.server).to(equal("testServer")) expect(requestData?.urlString).to(equal("testServer/sequence")) @@ -2276,7 +2429,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate request data - let requestData: TestApi.RequestData? = (response?.first as? TestResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.first as? TestOnionRequestAPI.ResponseInfo)?.requestData let jsonObject: Any = try! JSONSerialization.jsonObject( with: requestData!.body!, options: [.fragmentsAllowed] @@ -2295,13 +2448,13 @@ class OpenGroupAPISpec: QuickSpec { context("when signing") { beforeEach { - class LocalTestApi: TestApi { + class TestApi: TestOnionRequestAPI { override class var mockResponse: Data? { return try! JSONEncoder().encode([OpenGroupAPI.Room]()) } } - dependencies = dependencies.with(api: LocalTestApi.self) + dependencies = dependencies.with(onionApi: TestApi.self) } it("fails when there is no userEdKeyPair") { @@ -2381,7 +2534,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate signature headers - let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.urlString).to(equal("testServer/rooms")) expect(requestData?.httpMethod).to(equal("GET")) expect(requestData?.server).to(equal("testServer")) @@ -2438,7 +2591,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate signature headers - let requestData: TestApi.RequestData? = (response?.0 as? TestResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.urlString).to(equal("testServer/rooms")) expect(requestData?.httpMethod).to(equal("GET")) expect(requestData?.server).to(equal("testServer")) diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift index 0a8ab0dac..75d34aeb0 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -1,3 +1,559 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -import Foundation +import PromiseKit +import Sodium +import SessionSnodeKit + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class OpenGroupManagerSpec: QuickSpec { + class TestCapabilitiesAndRoomApi: TestOnionRequestAPI { + static let capabilitiesData: OpenGroupAPI.Capabilities = OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: nil) + static let roomData: OpenGroupAPI.Room = OpenGroupAPI.Room( + token: "test", + name: "test", + description: nil, + infoUpdates: 0, + messageSequence: 0, + created: 0, + activeUsers: 0, + activeUsersCutoff: 0, + imageId: nil, + pinnedMessages: nil, + admin: false, + globalAdmin: false, + admins: [], + hiddenAdmins: nil, + moderator: false, + globalModerator: false, + moderators: [], + hiddenModerators: nil, + read: false, + defaultRead: nil, + defaultAccessible: nil, + write: false, + defaultWrite: nil, + upload: false, + defaultUpload: nil + ) + + override class var mockResponse: Data? { + let responses: [Data] = [ + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: capabilitiesData, + failedToParseBody: false + ) + ), + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: roomData, + failedToParseBody: false + ) + ) + ] + + return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) + } + } + + // MARK: - Spec + + override func spec() { + var mockStorage: MockStorage! + var mockSodium: MockSodium! + var mockAeadXChaCha20Poly1305Ietf: MockAeadXChaCha20Poly1305Ietf! + var mockGenericHash: MockGenericHash! + var mockSign: MockSign! + var mockUserDefaults: MockUserDefaults! + var dependencies: Dependencies! + + var testInteraction: TestInteraction! + var testGroupThread: TestGroupThread! + var testTransaction: TestTransaction! + + describe("an OpenGroupAPI") { + // MARK: - Configuration + + beforeEach { + mockStorage = MockStorage() + mockSodium = MockSodium() + mockAeadXChaCha20Poly1305Ietf = MockAeadXChaCha20Poly1305Ietf() + mockGenericHash = MockGenericHash() + mockSign = MockSign() + mockUserDefaults = MockUserDefaults() + dependencies = Dependencies( + onionApi: TestCapabilitiesAndRoomApi.self, + storage: mockStorage, + sodium: mockSodium, + aeadXChaCha20Poly1305Ietf: mockAeadXChaCha20Poly1305Ietf, + sign: mockSign, + genericHash: mockGenericHash, + ed25519: MockEd25519(), + nonceGenerator16: OpenGroupAPISpec.TestNonce16Generator(), + nonceGenerator24: OpenGroupAPISpec.TestNonce24Generator(), + standardUserDefaults: mockUserDefaults, + date: Date(timeIntervalSince1970: 1234567890) + ) + testInteraction = TestInteraction() + testInteraction.mockData[.uniqueId] = "TestInteractionId" + testInteraction.mockData[.timestamp] = UInt64(123) + + testGroupThread = TestGroupThread() + testGroupThread.mockData[.groupModel] = TSGroupModel( + title: "TestTitle", + memberIds: [], + image: nil, + groupId: LKGroupUtilities.getEncodedOpenGroupIDAsData("testServer.testRoom"), + groupType: .openGroup, + adminIds: [], + moderatorIds: [] + ) + testGroupThread.mockData[.interactions] = [testInteraction] + + testTransaction = TestTransaction() + testTransaction.mockData[.objectForKey] = testGroupThread + + mockStorage + .when { $0.write(with: { _ in }) } + .then { args in (args.first as? ((Any) -> Void))?(testTransaction as Any) } + .thenReturn(Promise.value(())) + mockStorage + .when { $0.write(with: { _ in }, completion: { }) } + .then { args in + (args.first as? ((Any) -> Void))?(testTransaction as Any) + (args.last as? (() -> Void))?() + } + .thenReturn(Promise.value(())) + mockStorage + .when { $0.getUserKeyPair() } + .thenReturn( + try! ECKeyPair( + publicKeyData: Data.data(fromHex: TestConstants.publicKey)!, + privateKeyData: Data.data(fromHex: TestConstants.privateKey)! + ) + ) + mockStorage + .when { $0.getUserED25519KeyPair() } + .thenReturn( + Box.KeyPair( + publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes + ) + ) + mockStorage + .when { $0.getAllOpenGroups() } + .thenReturn([ + "0": OpenGroup( + server: "testServer", + room: "testRoom", + publicKey: TestConstants.publicKey, + name: "Test", + groupDescription: nil, + imageID: nil, + infoUpdates: 0 + ) + ]) + mockStorage + .when { $0.getOpenGroup(for: any()) } + .thenReturn( + OpenGroup( + server: "testServer", + room: "testRoom", + publicKey: TestConstants.publicKey, + name: "Test", + groupDescription: nil, + imageID: nil, + infoUpdates: 0 + ) + ) + mockStorage + .when { $0.getOpenGroupServer(name: any()) } + .thenReturn( + OpenGroupAPI.Server( + name: "testServer", + capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: []) + ) + ) + mockStorage + .when { $0.getOpenGroupPublicKey(for: any()) } + .thenReturn(TestConstants.publicKey) + + mockGenericHash.when { $0.hash(message: any(), outputLength: any()) }.thenReturn([]) + mockSodium + .when { $0.blindedKeyPair(serverPublicKey: any(), edKeyPair: any(), genericHash: mockGenericHash) } + .thenReturn( + Box.KeyPair( + publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes + ) + ) + mockSodium + .when { + $0.sogsSignature( + message: any(), + secretKey: any(), + blindedSecretKey: any(), + blindedPublicKey: any() + ) + } + .thenReturn("TestSogsSignature".bytes) + mockSign.when { $0.signature(message: any(), secretKey: any()) }.thenReturn("TestSignature".bytes) + } + + afterEach { + OpenGroupManager.shared.stopPolling() // Need to stop any pollers which get created during tests + + mockStorage = nil + mockSodium = nil + mockAeadXChaCha20Poly1305Ietf = nil + mockGenericHash = nil + mockSign = nil + mockUserDefaults = nil + dependencies = nil + + testInteraction = nil + testGroupThread = nil + testTransaction = nil + } + + // MARK: - Polling + + context("when starting polling") { + beforeEach { + mockStorage + .when { $0.getAllOpenGroups() } + .thenReturn([ + "0": OpenGroup( + server: "testServer", + room: "testRoom", + publicKey: TestConstants.publicKey, + name: "Test", + groupDescription: nil, + imageID: nil, + infoUpdates: 0 + ), + "1": OpenGroup( + server: "testServer1", + room: "testRoom1", + publicKey: TestConstants.publicKey, + name: "Test1", + groupDescription: nil, + imageID: nil, + infoUpdates: 0 + ) + ]) + mockStorage.when { $0.removeOpenGroupSequenceNumber(for: any(), on: any(), using: any()) }.thenReturn(()) + mockStorage.when { $0.setOpenGroupPublicKey(for: any(), to: any(), using: any()) }.thenReturn(()) + mockStorage.when { $0.setOpenGroupServer(any(), using: any()) }.thenReturn(()) + mockStorage.when { $0.setOpenGroup(any(), for: any(), using: any()) }.thenReturn(()) + mockStorage.when { $0.setUserCount(to: any(), forOpenGroupWithID: any(), using: any()) }.thenReturn(()) + mockStorage.when { $0.getOpenGroupInboxLatestMessageId(for: any()) }.thenReturn(nil) + mockStorage.when { $0.getOpenGroupOutboxLatestMessageId(for: any()) }.thenReturn(nil) + mockStorage.when { $0.getOpenGroupSequenceNumber(for: any(), on: any()) }.thenReturn(nil) + + mockUserDefaults + .when { $0.object(forKey: SNUserDefaults.Date.lastOpen.rawValue) } + .thenReturn(Date(timeIntervalSince1970: 1234567890)) + } + + it("creates pollers for all of the open groups") { + OpenGroupManager.shared.startPolling(using: dependencies) + + expect(OpenGroupManager.shared.cache.pollers.keys.map { String($0) }.sorted) + .to(equal(["testserver", "testserver1"])) + } + + it("updates the isPolling flag") { + OpenGroupManager.shared.startPolling(using: dependencies) + + expect(OpenGroupManager.shared.cache.isPolling).to(beTrue()) + } + + it("does nothing if already polling") { + OpenGroupManager.shared.mutableCache.mutate { $0.isPolling = true } + OpenGroupManager.shared.startPolling(using: dependencies) + + expect(OpenGroupManager.shared.cache.pollers.keys.map { String($0) }) + .to(equal([])) + } + } + + context("when stopping polling") { + beforeEach { + mockStorage + .when { $0.getAllOpenGroups() } + .thenReturn([ + "0": OpenGroup( + server: "testServer", + room: "testRoom", + publicKey: TestConstants.publicKey, + name: "Test", + groupDescription: nil, + imageID: nil, + infoUpdates: 0 + ), + "1": OpenGroup( + server: "testServer1", + room: "testRoom1", + publicKey: TestConstants.publicKey, + name: "Test1", + groupDescription: nil, + imageID: nil, + infoUpdates: 0 + ) + ]) + mockStorage.when { $0.removeOpenGroupSequenceNumber(for: any(), on: any(), using: any()) }.thenReturn(()) + mockStorage.when { $0.setOpenGroupPublicKey(for: any(), to: any(), using: any()) }.thenReturn(()) + mockStorage.when { $0.setOpenGroupServer(any(), using: any()) }.thenReturn(()) + mockStorage.when { $0.setOpenGroup(any(), for: any(), using: any()) }.thenReturn(()) + mockStorage.when { $0.setUserCount(to: any(), forOpenGroupWithID: any(), using: any()) }.thenReturn(()) + mockStorage.when { $0.getOpenGroupInboxLatestMessageId(for: any()) }.thenReturn(nil) + mockStorage.when { $0.getOpenGroupOutboxLatestMessageId(for: any()) }.thenReturn(nil) + mockStorage.when { $0.getOpenGroupSequenceNumber(for: any(), on: any()) }.thenReturn(nil) + + mockUserDefaults + .when { $0.object(forKey: SNUserDefaults.Date.lastOpen.rawValue) } + .thenReturn(Date(timeIntervalSince1970: 1234567890)) + + OpenGroupManager.shared.startPolling(using: dependencies) + } + + it("removes all pollers") { + OpenGroupManager.shared.stopPolling() + + expect(OpenGroupManager.shared.cache.pollers.keys.map { String($0) }) + .to(equal([])) + } + + it("updates the isPolling flag") { + OpenGroupManager.shared.stopPolling() + + expect(OpenGroupManager.shared.cache.isPolling).to(beFalse()) + } + } + + // MARK: - Adding & Removing + + context("when adding") { + beforeEach { + mockStorage.when { $0.removeOpenGroupSequenceNumber(for: any(), on: any(), using: any()) }.thenReturn(()) + mockStorage.when { $0.setOpenGroupPublicKey(for: any(), to: any(), using: any()) }.thenReturn(()) + mockStorage.when { $0.setOpenGroupServer(any(), using: any()) }.thenReturn(()) + mockStorage.when { $0.setOpenGroup(any(), for: any(), using: any()) }.thenReturn(()) + mockStorage.when { $0.setUserCount(to: any(), forOpenGroupWithID: any(), using: any()) }.thenReturn(()) + mockStorage.when { $0.getOpenGroupInboxLatestMessageId(for: any()) }.thenReturn(nil) + mockStorage.when { $0.getOpenGroupOutboxLatestMessageId(for: any()) }.thenReturn(nil) + mockStorage.when { $0.getOpenGroupSequenceNumber(for: any(), on: any()) }.thenReturn(nil) + + mockUserDefaults + .when { $0.object(forKey: SNUserDefaults.Date.lastOpen.rawValue) } + .thenReturn(Date(timeIntervalSince1970: 1234567890)) + } + + it("resets the sequence number of the open group") { + OpenGroupManager.shared + .add( + roomToken: "testRoom", + server: "testServer", + publicKey: "testKey", + isConfigMessage: false, + using: testTransaction, + dependencies: dependencies + ) + .retainUntilComplete() + + expect(mockStorage) + .to( + call(.exactly(times: 1)) { + $0.removeOpenGroupSequenceNumber( + for: "testRoom", + on: "testServer", + using: testTransaction as Any + ) + } + ) + } + + it("sets the public key of the open group server") { + OpenGroupManager.shared + .add( + roomToken: "testRoom", + server: "testServer", + publicKey: "testKey", + isConfigMessage: false, + using: testTransaction, + dependencies: dependencies + ) + .retainUntilComplete() + + expect(mockStorage) + .to( + call(.exactly(times: 1)) { + $0.setOpenGroupPublicKey( + for: "testRoom", + to: "testKey", + using: testTransaction as Any + ) + } + ) + } + + it("adds a poller") { + OpenGroupManager.shared + .add( + roomToken: "testRoom", + server: "testServer", + publicKey: "testKey", + isConfigMessage: false, + using: testTransaction, + dependencies: dependencies + ) + .retainUntilComplete() + + expect(OpenGroupManager.shared.cache.pollers["testServer"]) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + } + + context("an existing room") { + beforeEach { + OpenGroupManager.shared + .add( + roomToken: "testRoom", + server: "testServer", + publicKey: "testKey", + isConfigMessage: false, + using: testTransaction, + dependencies: dependencies + ) + .retainUntilComplete() + + // There is a bunch of async code in this function so we need to wait until if finishes + // processing before doing the actual tests + expect(mockStorage) + .toEventually( + call { $0.setOpenGroup(any(), for: "testServer", using: testTransaction as Any) }, + timeout: .milliseconds(100) + ) + + mockStorage.resetCallCounts() + } + + it("does not reset the sequence number or update the public key") { + OpenGroupManager.shared + .add( + roomToken: "testRoom", + server: "testServer", + publicKey: "testKey", + isConfigMessage: false, + using: testTransaction, + dependencies: dependencies + ) + .retainUntilComplete() + + expect(mockStorage) + .toEventuallyNot( + call { + $0.removeOpenGroupSequenceNumber( + for: "testRoom", + on: "testServer", + using: testTransaction as Any + ) + }, + timeout: .milliseconds(100) + ) + expect(mockStorage) + .toEventuallyNot( + call { + $0.setOpenGroupPublicKey( + for: "testRoom", + to: "testKey", + using: testTransaction as Any + ) + }, + timeout: .milliseconds(100) + ) + } + } + + context("with an invalid response") { + beforeEach { + class TestApi: TestOnionRequestAPI { + override class var mockResponse: Data? { return Data() } + } + dependencies = dependencies.with(onionApi: TestApi.self) + + mockUserDefaults + .when { $0.object(forKey: SNUserDefaults.Date.lastOpen.rawValue) } + .thenReturn(Date(timeIntervalSince1970: 1234567890)) + } + + it("fails with the error") { + var error: Error? + + let promise = OpenGroupManager.shared + .add( + roomToken: "testRoom", + server: "testServer", + publicKey: "testKey", + isConfigMessage: false, + using: testTransaction, + dependencies: dependencies + ) + promise.catch { error = $0 } + promise.retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(HTTP.Error.parsingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + } + } + } + + context("when removing") { + +// public func delete(_ openGroup: OpenGroup, associatedWith thread: TSThread, using transaction: YapDatabaseReadWriteTransaction, dependencies: Dependencies = Dependencies()) { +// // Stop the poller if needed +// let openGroups = dependencies.storage.getAllOpenGroups().values.filter { $0.server == openGroup.server } +// if openGroups.count == 1 && openGroups.last == openGroup { +// let poller = pollers[openGroup.server] +// poller?.stop() +// pollers[openGroup.server] = nil +// } +// +// // Remove all data +// var messageIDs: Set = [] +// var messageTimestamps: Set = [] +// thread.enumerateInteractions(with: transaction) { interaction, _ in +// messageIDs.insert(interaction.uniqueId!) +// messageTimestamps.insert(interaction.timestamp) +// } +// dependencies.storage.updateMessageIDCollectionByPruningMessagesWithIDs(messageIDs, using: transaction) +// dependencies.storage.removeReceivedMessageTimestamps(messageTimestamps, using: transaction) +// dependencies.storage.removeOpenGroupSequenceNumber(for: openGroup.room, on: openGroup.server, using: transaction) +// +// thread.removeAllThreadInteractions(with: transaction) +// thread.remove(with: transaction) +// dependencies.storage.removeOpenGroup(for: thread.uniqueId!, using: transaction) +// +// // Only remove the open group public key and server info if the user isn't in any other rooms +// if openGroups.count <= 1 { +// dependencies.storage.removeOpenGroupServer(name: openGroup.server, using: transaction) +// dependencies.storage.removeOpenGroupPublicKey(for: openGroup.server, using: transaction) +// } + } + } + } +} diff --git a/SessionMessagingKitTests/Open Groups/Types/SOGSEndpointSpec.swift b/SessionMessagingKitTests/Open Groups/Types/SOGSEndpointSpec.swift index 733ca8ef8..7147b95fa 100644 --- a/SessionMessagingKitTests/Open Groups/Types/SOGSEndpointSpec.swift +++ b/SessionMessagingKitTests/Open Groups/Types/SOGSEndpointSpec.swift @@ -34,6 +34,8 @@ class SOGSEndpointSpec: QuickSpec { expect(OpenGroupAPI.Endpoint.roomMessagesBefore("test", id: 123).path).to(equal("room/test/messages/before/123")) expect(OpenGroupAPI.Endpoint.roomMessagesSince("test", seqNo: 123).path) .to(equal("room/test/messages/since/123")) + expect(OpenGroupAPI.Endpoint.roomDeleteMessages("test", sessionId: "testId").path) + .to(equal("room/test/all/testId")) // Pinning @@ -60,7 +62,6 @@ class SOGSEndpointSpec: QuickSpec { expect(OpenGroupAPI.Endpoint.userBan("test").path).to(equal("user/test/ban")) expect(OpenGroupAPI.Endpoint.userUnban("test").path).to(equal("user/test/unban")) expect(OpenGroupAPI.Endpoint.userModerator("test").path).to(equal("user/test/moderator")) - expect(OpenGroupAPI.Endpoint.userDeleteMessages("test").path).to(equal("user/test/deleteMessages")) } } } diff --git a/SessionMessagingKitTests/_TestUtilities/MockEd25519.swift b/SessionMessagingKitTests/_TestUtilities/MockEd25519.swift index ce05862dd..23632d67e 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockEd25519.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockEd25519.swift @@ -7,6 +7,10 @@ import Sodium @testable import SessionMessagingKit class MockEd25519: Mock, Ed25519Type { + func sign(data: Bytes, keyPair: ECKeyPair) throws -> Bytes? { + return accept(args: [data, keyPair]) as? Bytes + } + func verifySignature(_ signature: Data, publicKey: Data, data: Data) throws -> Bool { return accept(args: [signature, publicKey, data]) as! Bool } diff --git a/SessionMessagingKitTests/_TestUtilities/MockUserDefaults.swift b/SessionMessagingKitTests/_TestUtilities/MockUserDefaults.swift new file mode 100644 index 000000000..4814016a5 --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/MockUserDefaults.swift @@ -0,0 +1,27 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +class MockUserDefaults: Mock, UserDefaultsType { + var storage: [String: Any] = [:] + + func object(forKey defaultName: String) -> Any? { return accept(args: [defaultName]) } + func string(forKey defaultName: String) -> String? { return accept(args: [defaultName]) as? String } + func array(forKey defaultName: String) -> [Any]? { return accept(args: [defaultName]) as? [Any] } + func dictionary(forKey defaultName: String) -> [String: Any]? { return accept(args: [defaultName]) as? [String: Any] } + func data(forKey defaultName: String) -> Data? { return accept(args: [defaultName]) as? Data } + func stringArray(forKey defaultName: String) -> [String]? { return accept(args: [defaultName]) as? [String] } + func integer(forKey defaultName: String) -> Int { return ((accept(args: [defaultName]) as? Int) ?? 0) } + func float(forKey defaultName: String) -> Float { return ((accept(args: [defaultName]) as? Float) ?? 0) } + func double(forKey defaultName: String) -> Double { return ((accept(args: [defaultName]) as? Double) ?? 0) } + func bool(forKey defaultName: String) -> Bool { return ((accept(args: [defaultName]) as? Bool) ?? false) } + func url(forKey defaultName: String) -> URL? { return accept(args: [defaultName]) as? URL } + + func set(_ value: Any?, forKey defaultName: String) { accept(args: [value, defaultName]) } + func set(_ value: Int, forKey defaultName: String) { accept(args: [value, defaultName]) } + func set(_ value: Float, forKey defaultName: String) { accept(args: [value, defaultName]) } + func set(_ value: Double, forKey defaultName: String) { accept(args: [value, defaultName]) } + func set(_ value: Bool, forKey defaultName: String) { accept(args: [value, defaultName]) } + func set(_ url: URL?, forKey defaultName: String) { accept(args: [url, defaultName]) } +} diff --git a/SessionMessagingKitTests/_TestUtilities/Mockable.swift b/SessionMessagingKitTests/_TestUtilities/Mockable.swift index 6c438d881..b903f0fa3 100644 --- a/SessionMessagingKitTests/_TestUtilities/Mockable.swift +++ b/SessionMessagingKitTests/_TestUtilities/Mockable.swift @@ -7,9 +7,3 @@ protocol Mockable { var mockData: [Key: Any] { get } } - -protocol StaticMockable { - associatedtype Key: Hashable - - static var mockData: [Key: Any] { get } -} diff --git a/SessionMessagingKitTests/_TestUtilities/MockedExtensions.swift b/SessionMessagingKitTests/_TestUtilities/MockedExtensions.swift new file mode 100644 index 000000000..0824acabf --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/MockedExtensions.swift @@ -0,0 +1,23 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionMessagingKit + +extension OpenGroup: Mocked { + static var mockValue: OpenGroup = OpenGroup( + server: any(), + room: any(), + publicKey: TestConstants.publicKey, + name: any(), + groupDescription: any(), + imageID: any(), + infoUpdates: any() + ) +} + +extension OpenGroupAPI.Server: Mocked { + static var mockValue: OpenGroupAPI.Server = OpenGroupAPI.Server( + name: any(), + capabilities: OpenGroupAPI.Capabilities(capabilities: any(), missing: any()) + ) +} diff --git a/SessionMessagingKitTests/_TestUtilities/TestGroupThread.swift b/SessionMessagingKitTests/_TestUtilities/TestGroupThread.swift new file mode 100644 index 000000000..4ec2469eb --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/TestGroupThread.swift @@ -0,0 +1,32 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionMessagingKit + +// FIXME: Turn this into a protocol to make mocking possible +class TestGroupThread: TSGroupThread, Mockable { + // MARK: - Mockable + + enum DataKey: Hashable { + case groupModel + case interactions + } + + typealias Key = DataKey + + var mockData: [DataKey: Any] = [:] + var didCallSave: Bool = false + + // MARK: - TSGroupThread + + override var groupModel: TSGroupModel { + get { (mockData[.groupModel] as! TSGroupModel) } + set {} + } + + override func enumerateInteractions(_ block: @escaping (TSInteraction) -> Void) { + ((mockData[.interactions] as? [TSInteraction]) ?? []).forEach(block) + } + + override func save(with transaction: YapDatabaseReadWriteTransaction) { didCallSave = true } +} diff --git a/SessionMessagingKitTests/_TestUtilities/TestInteraction.swift b/SessionMessagingKitTests/_TestUtilities/TestInteraction.swift index 0a8ab0dac..353c97a9b 100644 --- a/SessionMessagingKitTests/_TestUtilities/TestInteraction.swift +++ b/SessionMessagingKitTests/_TestUtilities/TestInteraction.swift @@ -1,3 +1,32 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import SessionMessagingKit + +// FIXME: Turn this into a protocol to make mocking possible +class TestInteraction: TSInteraction, Mockable { + // MARK: - Mockable + + enum DataKey: Hashable { + case uniqueId + case timestamp + } + + typealias Key = DataKey + + var mockData: [DataKey: Any] = [:] + var didCallSave: Bool = true + + // MARK: - TSInteraction + + override var uniqueId: String? { + get { (mockData[.uniqueId] as? String) } + set { mockData[.uniqueId] = newValue } + } + + override var timestamp: UInt64 { + (mockData[.timestamp] as! UInt64) + } + + override func save(with transaction: YapDatabaseReadWriteTransaction) { didCallSave = true } +} diff --git a/SessionMessagingKitTests/_TestUtilities/TestOnionRequestAPI.swift b/SessionMessagingKitTests/_TestUtilities/TestOnionRequestAPI.swift new file mode 100644 index 000000000..fd1789626 --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/TestOnionRequestAPI.swift @@ -0,0 +1,60 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import PromiseKit +import SessionSnodeKit + +@testable import SessionMessagingKit + +// FIXME: Change 'OnionRequestAPIType' to have instance methods instead of static methods once everything is updated to use 'Dependencies' +class TestOnionRequestAPI: OnionRequestAPIType { + struct RequestData: Codable { + let urlString: String? + let httpMethod: String + let headers: [String: String] + let snodeMethod: String? + let body: Data? + + let server: String + let version: OnionRequestAPI.Version + let publicKey: String? + } + class ResponseInfo: OnionRequestResponseInfoType { + let requestData: RequestData + let code: Int + let headers: [String: String] + + init(requestData: RequestData, code: Int, headers: [String: String]) { + self.requestData = requestData + self.code = code + self.headers = headers + } + } + + class var mockResponse: Data? { return nil } + + static func sendOnionRequest(_ request: URLRequest, to server: String, using version: OnionRequestAPI.Version, with x25519PublicKey: String) -> Promise<(OnionRequestResponseInfoType, Data?)> { + let responseInfo: ResponseInfo = ResponseInfo( + requestData: RequestData( + urlString: request.url?.absoluteString, + httpMethod: (request.httpMethod ?? "GET"), + headers: (request.allHTTPHeaderFields ?? [:]), + snodeMethod: nil, + body: request.httpBody, + + server: server, + version: version, + publicKey: x25519PublicKey + ), + code: 200, + headers: [:] + ) + + return Promise.value((responseInfo, mockResponse)) + } + + static func sendOnionRequest(to snode: Snode, invoking method: Snode.Method, with parameters: JSON, using version: OnionRequestAPI.Version, associatedWith publicKey: String?) -> Promise { + // TODO: Test the 'responseInfo' somehow? + return Promise.value(mockResponse!) + } +} diff --git a/SessionMessagingKitTests/_TestUtilities/TestThread.swift b/SessionMessagingKitTests/_TestUtilities/TestThread.swift index 0a8ab0dac..6ef2ce18a 100644 --- a/SessionMessagingKitTests/_TestUtilities/TestThread.swift +++ b/SessionMessagingKitTests/_TestUtilities/TestThread.swift @@ -1,3 +1,26 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import SessionMessagingKit + +// FIXME: Turn this into a protocol to make mocking possible +class TestThread: TSThread, Mockable { + // MARK: - Mockable + + enum DataKey: Hashable { + case interactions + } + + typealias Key = DataKey + + var mockData: [DataKey: Any] = [:] + var didCallSave: Bool = false + + // MARK: - TSThread + + override func enumerateInteractions(_ block: @escaping (TSInteraction) -> Void) { + ((mockData[.interactions] as? [TSInteraction]) ?? []).forEach(block) + } + + override func save(with transaction: YapDatabaseReadWriteTransaction) { didCallSave = true } +} diff --git a/SessionMessagingKitTests/_TestUtilities/TestTransaction.swift b/SessionMessagingKitTests/_TestUtilities/TestTransaction.swift new file mode 100644 index 000000000..0cd847d91 --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/TestTransaction.swift @@ -0,0 +1,31 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import YapDatabase + +// FIXME: Turn this into a protocol to make mocking possible +final class TestTransaction: YapDatabaseReadWriteTransaction, Mockable { + // MARK: - Mockable + + enum DataKey: Hashable { + case objectForKey + } + + typealias Key = DataKey + + var mockData: [DataKey: Any] = [:] + + // MARK: - YapDatabaseReadWriteTransaction + + override func object(forKey key: String, inCollection collection: String?) -> Any? { + return mockData[.objectForKey] + } + + override func addCompletionQueue(_ completionQueue: DispatchQueue?, completionBlock: @escaping () -> Void) { + completionBlock() + } +} + +extension TestTransaction: Mocked { + static var mockValue: TestTransaction = TestTransaction() +} diff --git a/SessionMessagingKitTests/_TestUtilities/TestUserDefaults.swift b/SessionMessagingKitTests/_TestUtilities/TestUserDefaults.swift deleted file mode 100644 index a01b5eda2..000000000 --- a/SessionMessagingKitTests/_TestUtilities/TestUserDefaults.swift +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionUtilitiesKit - -class TestUserDefaults: UserDefaultsType { - var storage: [String: Any] = [:] - - func object(forKey defaultName: String) -> Any? { return storage[defaultName] } - func string(forKey defaultName: String) -> String? { return storage[defaultName] as? String } - func array(forKey defaultName: String) -> [Any]? { return storage[defaultName] as? [Any] } - func dictionary(forKey defaultName: String) -> [String: Any]? { return storage[defaultName] as? [String: Any] } - func data(forKey defaultName: String) -> Data? { return storage[defaultName] as? Data } - func stringArray(forKey defaultName: String) -> [String]? { return storage[defaultName] as? [String] } - func integer(forKey defaultName: String) -> Int { return ((storage[defaultName] as? Int) ?? 0) } - func float(forKey defaultName: String) -> Float { return ((storage[defaultName] as? Float) ?? 0) } - func double(forKey defaultName: String) -> Double { return ((storage[defaultName] as? Double) ?? 0) } - func bool(forKey defaultName: String) -> Bool { return ((storage[defaultName] as? Bool) ?? false) } - func url(forKey defaultName: String) -> URL? { return storage[defaultName] as? URL } - - func set(_ value: Any?, forKey defaultName: String) { storage[defaultName] = value } - func set(_ value: Int, forKey defaultName: String) { storage[defaultName] = value } - func set(_ value: Float, forKey defaultName: String) { storage[defaultName] = value } - func set(_ value: Double, forKey defaultName: String) { storage[defaultName] = value } - func set(_ value: Bool, forKey defaultName: String) { storage[defaultName] = value } - func set(_ url: URL?, forKey defaultName: String) { storage[defaultName] = url } -} diff --git a/SessionMessagingKit/Utilities/Atomic.swift b/SessionUtilitiesKit/General/Atomic.swift similarity index 93% rename from SessionMessagingKit/Utilities/Atomic.swift rename to SessionUtilitiesKit/General/Atomic.swift index 7d8b07d95..e3c2bbf9b 100644 --- a/SessionMessagingKit/Utilities/Atomic.swift +++ b/SessionUtilitiesKit/General/Atomic.swift @@ -27,13 +27,13 @@ public class Atomic { // MARK: - Initialization - init(_ initialValue: Value) { + public init(_ initialValue: Value) { self.value = initialValue } // MARK: - Functions - func mutate(_ mutation: (inout Value) -> Void) { + public func mutate(_ mutation: (inout Value) -> Void) { return queue.sync { mutation(&value) } diff --git a/SessionMessagingKitTests/_TestUtilities/BoxKeyPair+Mocked.swift b/SharedTest/CommonMockedExtensions.swift similarity index 54% rename from SessionMessagingKitTests/_TestUtilities/BoxKeyPair+Mocked.swift rename to SharedTest/CommonMockedExtensions.swift index 0a6d2ed2a..bbc01747b 100644 --- a/SessionMessagingKitTests/_TestUtilities/BoxKeyPair+Mocked.swift +++ b/SharedTest/CommonMockedExtensions.swift @@ -2,6 +2,7 @@ import Foundation import Sodium +import Curve25519Kit extension Box.KeyPair: Mocked { static var mockValue: Box.KeyPair = Box.KeyPair( @@ -9,3 +10,12 @@ extension Box.KeyPair: Mocked { secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes ) } + +extension ECKeyPair: Mocked { + static var mockValue: Self { + try! Self.init( + publicKeyData: Data.data(fromHex: TestConstants.publicKey)!, + privateKeyData: Data.data(fromHex: TestConstants.privateKey)! + ) + } +} diff --git a/SharedTest/Mock.swift b/SharedTest/Mock.swift index e22321b68..35c270f15 100644 --- a/SharedTest/Mock.swift +++ b/SharedTest/Mock.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import SessionUtilitiesKit // MARK: - Mocked @@ -22,11 +23,15 @@ public class Mock { private let functionHandler: MockFunctionHandler internal let functionConsumer: FunctionConsumer + // MARK: - Initialization + internal required init(functionHandler: MockFunctionHandler? = nil) { self.functionConsumer = FunctionConsumer() self.functionHandler = (functionHandler ?? self.functionConsumer) } + // MARK: - MockFunctionHandler + @discardableResult internal func accept(funcName: String = #function, args: [Any?] = []) -> Any? { return accept(funcName: funcName, checkArgs: args, actionArgs: args) } @@ -35,6 +40,19 @@ public class Mock { return functionHandler.accept(funcName, parameterSummary: summary(for: checkArgs), actionArgs: actionArgs) } + // MARK: - Functions + + internal func reset() { + functionConsumer.trackCalls = true + functionConsumer.functionBuilders = [] + functionConsumer.functionHandlers = [:] + functionConsumer.calls.mutate { $0 = [:] } + } + + internal func resetCallCounts() { + functionConsumer.calls.mutate { $0 = [:] } + } + internal func when(_ callBlock: @escaping (T) throws -> R) -> MockFunctionBuilder { let builder: MockFunctionBuilder = MockFunctionBuilder(callBlock, mockInit: type(of: self).init) functionConsumer.functionBuilders.append(builder.build) @@ -42,6 +60,8 @@ public class Mock { return builder } + // MARK: - Convenience + private func summary(for argument: Any) -> String { switch argument { case let string as String: return string @@ -134,7 +154,7 @@ internal class FunctionConsumer: MockFunctionHandler { var trackCalls: Bool = true var functionBuilders: [() throws -> MockFunction?] = [] var functionHandlers: [String: [String: MockFunction]] = [:] - var calls: [String: [String]] = [:] + var calls: Atomic<[String: [String]]> = Atomic([:]) func accept(_ functionName: String, parameterSummary: String, actionArgs: [Any?]) -> Any? { if !functionBuilders.isEmpty { @@ -154,7 +174,7 @@ internal class FunctionConsumer: MockFunctionHandler { // Record the call so it can be validated later (assuming we are tracking calls) if trackCalls { - calls[functionName] = (calls[functionName] ?? []).appending(parameterSummary) + calls.mutate { $0[functionName] = ($0[functionName] ?? []).appending(parameterSummary) } } for action in expectation.actions { diff --git a/SharedTest/NimbleExtensions.swift b/SharedTest/NimbleExtensions.swift index 6fe5bafda..afed2e7b0 100644 --- a/SharedTest/NimbleExtensions.swift +++ b/SharedTest/NimbleExtensions.swift @@ -181,12 +181,12 @@ fileprivate func generateCallInfo(_ actualExpression: Expression, _ return } - allFunctionsCalled = Array(validInstance.functionConsumer.calls.keys) + allFunctionsCalled = Array(validInstance.functionConsumer.calls.wrappedValue.keys) let builder: MockFunctionBuilder = builderCreator(validInstance) validInstance.functionConsumer.trackCalls = false maybeFunction = try? builder.build() - desiredFunctionCalls = (validInstance.functionConsumer.calls[maybeFunction?.name ?? ""] ?? []) + desiredFunctionCalls = (validInstance.functionConsumer.calls.wrappedValue[maybeFunction?.name ?? ""] ?? []) validInstance.functionConsumer.trackCalls = true } catch { @@ -205,12 +205,12 @@ fileprivate func generateCallInfo(_ actualExpression: Expression, _ // Just hope for the best and if there is a force-cast there's not much we can do guard let validInstance: M = try? actualExpression.evaluate() else { return CallInfo.error } - allFunctionsCalled = Array(validInstance.functionConsumer.calls.keys) + allFunctionsCalled = Array(validInstance.functionConsumer.calls.wrappedValue.keys) let builder: MockExpectationBuilder = builderCreator(validInstance) validInstance.functionConsumer.trackCalls = false maybeFunction = try? builder.build() - desiredFunctionCalls = (validInstance.functionConsumer.calls[maybeFunction?.name ?? ""] ?? []) + desiredFunctionCalls = (validInstance.functionConsumer.calls.wrappedValue[maybeFunction?.name ?? ""] ?? []) validInstance.functionConsumer.trackCalls = true #endif From 3fdfda1960161e8b304b3979e1ff370acb145419 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 15 Mar 2022 15:25:14 +1100 Subject: [PATCH 035/157] Code consistency tweaks --- Session/Conversations/ConversationVC+Interaction.swift | 2 +- .../Home/Message Requests/MessageRequestsViewController.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 4e5071515..89fc64fcd 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -1171,7 +1171,7 @@ extension ConversationVC { // (it'll be updated with correct profile info if they accept the message request so this // shouldn't cause weird behaviours) let sessionId: String = contactThread.contactSessionID() - let contact: Contact = (Storage.shared.getContact(with: sessionId) ?? Contact(sessionID: sessionId)) + let contact: Contact = (Storage.shared.getContact(with: sessionId, using: transaction) ?? Contact(sessionID: sessionId)) guard !contact.isApproved else { return Promise.value(()) } diff --git a/Session/Home/Message Requests/MessageRequestsViewController.swift b/Session/Home/Message Requests/MessageRequestsViewController.swift index 4ecc55ea1..62dcb634b 100644 --- a/Session/Home/Message Requests/MessageRequestsViewController.swift +++ b/Session/Home/Message Requests/MessageRequestsViewController.swift @@ -313,7 +313,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat // Update the contact let sessionId: String = contactThread.contactSessionID() - if let contact: Contact = Storage.shared.getContact(with: sessionId), (contact.isApproved || !contact.isBlocked) { + if let contact: Contact = Storage.shared.getContact(with: sessionId, using: transaction), (contact.isApproved || !contact.isBlocked) { contact.isApproved = false contact.isBlocked = true From cb27da48b716d7678f3da493a45b11b6ebdda747 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 15 Mar 2022 16:51:10 +1100 Subject: [PATCH 036/157] Updated the code to handle a clock out of sync error (different error code from v2 & v3) --- SessionSnodeKit/OnionRequestAPI.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SessionSnodeKit/OnionRequestAPI.swift b/SessionSnodeKit/OnionRequestAPI.swift index 6c7a4dd52..acceac62d 100644 --- a/SessionSnodeKit/OnionRequestAPI.swift +++ b/SessionSnodeKit/OnionRequestAPI.swift @@ -615,8 +615,8 @@ public enum OnionRequestAPI: OnionRequestAPIType { return seal.reject(HTTP.Error.invalidResponse) } - // Custom handle a clock out of sync error - guard responseInfo.code != 406 else { + // Custom handle a clock out of sync error (v4 returns '425' but included the '406' just in case) + guard responseInfo.code != 406 && responseInfo.code != 425 else { SNLog("The user's clock is out of sync with the service node network.") return seal.reject(SnodeAPI.Error.clockOutOfSync) } From b1684f6b2369cf3a996f53f9fe6d275fb07350fb Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 16 Mar 2022 15:55:56 +1100 Subject: [PATCH 037/157] More unit tests, fixed a few issues uncovered by testing Updated the OpenGroupManager to support injecting the cache for better unit testing Updated the MessageReceiver to support Dependencies being passed as a parameter for visible message and storage purposes Added a debugDescription to the OpenGroup for more accurate unit testing Fixed an issue where the poll function would include the inbox and outbox endpoints even when the server was not blinded Fixed some test compilation time issues Fixed a bug where the OpenGroupAPI Room was using 'description' as a parameter name (used by Swift for other purposes) Fixed a bug where then OpenGroup was incorrectly using the system 'description' property in one place Renamed the parseV2OpenGroup to parseOpenGroup for consistency --- Session.xcodeproj/project.pbxproj | 16 +- .../Views & Modals/JoinOpenGroupModal.swift | 2 +- Session/Open Groups/JoinOpenGroupVC.swift | 2 +- .../Database/Storage+Contacts.swift | 2 + .../Open Groups/Models/OpenGroup.swift | 12 + .../Open Groups/Models/Room.swift | 6 +- .../Open Groups/OpenGroupAPI.swift | 55 +- .../Open Groups/OpenGroupManager.swift | 211 ++- .../MessageReceiver+Handling.swift | 28 +- .../Sending & Receiving/MessageReceiver.swift | 25 +- .../Pollers/OpenGroupPoller.swift | 16 +- SessionMessagingKit/Storage.swift | 11 + .../Utilities/Dependencies.swift | 90 +- .../Open Groups/Models/OpenGroupSpec.swift | 17 + .../Open Groups/Models/RoomPollInfoSpec.swift | 2 +- .../Open Groups/Models/SOGSMessageSpec.swift | 12 +- .../Open Groups/OpenGroupAPISpec.swift | 464 ++++-- .../Open Groups/OpenGroupManagerSpec.swift | 1463 +++++++++++++++-- .../Types/SodiumProtocolsSpec.swift | 53 +- .../_TestUtilities/DependencyExtensions.swift | 36 + .../_TestUtilities/MockOGMCache.swift | 52 + .../_TestUtilities/MockStorage.swift | 22 +- .../_TestUtilities/MockedExtensions.swift | 2 +- .../OGMDependencyExtensions.swift | 38 + .../_TestUtilities/TestGroupThread.swift | 31 +- SharedTest/Mock.swift | 29 +- SharedTest/NimbleExtensions.swift | 17 +- 27 files changed, 2173 insertions(+), 541 deletions(-) create mode 100644 SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift create mode 100644 SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift create mode 100644 SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index b0f3a695f..03d001906 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -777,6 +777,10 @@ FD078E4827E02561000769AF /* CommonMockedExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E4727E02561000769AF /* CommonMockedExtensions.swift */; }; FD078E4927E02576000769AF /* CommonMockedExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E4727E02561000769AF /* CommonMockedExtensions.swift */; }; FD078E4B27E02C5D000769AF /* Failable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E4A27E02C5D000769AF /* Failable.swift */; }; + FD078E4D27E17156000769AF /* MockOGMCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E4C27E17156000769AF /* MockOGMCache.swift */; }; + FD078E4F27E175F1000769AF /* DependencyExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E4E27E175F1000769AF /* DependencyExtensions.swift */; }; + FD078E5227E1760A000769AF /* OGMDependencyExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E5127E1760A000769AF /* OGMDependencyExtensions.swift */; }; + FD078E5427E197CA000769AF /* OpenGroupManagerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909D27D85751005DAE71 /* OpenGroupManagerSpec.swift */; }; FD0BA51B27CD88EC00CC6805 /* BlindedIdMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0BA51A27CD88EC00CC6805 /* BlindedIdMapping.swift */; }; FD0BA51D27CDC34600CC6805 /* SOGSV4Migration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0BA51C27CDC34600CC6805 /* SOGSV4Migration.swift */; }; FD5D200F27AA2B6000FEA984 /* MessageRequestResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */; }; @@ -823,7 +827,6 @@ FDC2909827D7129B005DAE71 /* PersonalizationSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909727D7129B005DAE71 /* PersonalizationSpec.swift */; }; FDC2909A27D71376005DAE71 /* NonceGeneratorSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909927D71376005DAE71 /* NonceGeneratorSpec.swift */; }; FDC2909C27D713D2005DAE71 /* SodiumProtocolsSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909B27D713D2005DAE71 /* SodiumProtocolsSpec.swift */; }; - FDC2909E27D85751005DAE71 /* OpenGroupManagerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909D27D85751005DAE71 /* OpenGroupManagerSpec.swift */; }; FDC290A027D85826005DAE71 /* TestThread.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909F27D85826005DAE71 /* TestThread.swift */; }; FDC290A227D85890005DAE71 /* TestInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A127D85890005DAE71 /* TestInteraction.swift */; }; FDC290A627D860CE005DAE71 /* Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A527D860CE005DAE71 /* Mock.swift */; }; @@ -1920,6 +1923,9 @@ FCB11D8B1A129A76002F93FB /* CoreMedia.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreMedia.framework; path = System/Library/Frameworks/CoreMedia.framework; sourceTree = SDKROOT; }; FD078E4727E02561000769AF /* CommonMockedExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonMockedExtensions.swift; sourceTree = ""; }; FD078E4A27E02C5D000769AF /* Failable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Failable.swift; sourceTree = ""; }; + FD078E4C27E17156000769AF /* MockOGMCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockOGMCache.swift; sourceTree = ""; }; + FD078E4E27E175F1000769AF /* DependencyExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DependencyExtensions.swift; sourceTree = ""; }; + FD078E5127E1760A000769AF /* OGMDependencyExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OGMDependencyExtensions.swift; sourceTree = ""; }; FD0BA51A27CD88EC00CC6805 /* BlindedIdMapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlindedIdMapping.swift; sourceTree = ""; }; FD0BA51C27CDC34600CC6805 /* SOGSV4Migration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSV4Migration.swift; sourceTree = ""; }; FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestResponse.swift; sourceTree = ""; }; @@ -4048,12 +4054,15 @@ FD859EF927C2F5C500510D0C /* MockGenericHash.swift */, FD859EFB27C2F60700510D0C /* MockEd25519.swift */, FD83B9D127D59495005E1583 /* MockUserDefaults.swift */, + FD078E4C27E17156000769AF /* MockOGMCache.swift */, FDC290B227DFF9F5005DAE71 /* TestOnionRequestAPI.swift */, FDC290AE27DFEE97005DAE71 /* TestTransaction.swift */, FDC2909F27D85826005DAE71 /* TestThread.swift */, FDC290B627E00FDB005DAE71 /* TestGroupThread.swift */, FDC290A127D85890005DAE71 /* TestInteraction.swift */, FDC290AB27DB0B1C005DAE71 /* MockedExtensions.swift */, + FD078E4E27E175F1000769AF /* DependencyExtensions.swift */, + FD078E5127E1760A000769AF /* OGMDependencyExtensions.swift */, ); path = _TestUtilities; sourceTree = ""; @@ -5655,6 +5664,7 @@ FDC2909127D709CA005DAE71 /* SOGSMessageSpec.swift in Sources */, FDC2909627D71252005DAE71 /* SOGSErrorSpec.swift in Sources */, FDC2908727D7047F005DAE71 /* RoomSpec.swift in Sources */, + FD078E4F27E175F1000769AF /* DependencyExtensions.swift in Sources */, FDC2909C27D713D2005DAE71 /* SodiumProtocolsSpec.swift in Sources */, FD83B9C327CF33F7005E1583 /* ServerSpec.swift in Sources */, FD83B9C727CF3F10005E1583 /* CapabilitiesSpec.swift in Sources */, @@ -5664,6 +5674,8 @@ FD859EF827C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift in Sources */, FDC2909827D7129B005DAE71 /* PersonalizationSpec.swift in Sources */, FD859EF427C2F49200510D0C /* MockSodium.swift in Sources */, + FD078E4D27E17156000769AF /* MockOGMCache.swift in Sources */, + FD078E5227E1760A000769AF /* OGMDependencyExtensions.swift in Sources */, FD859EFC27C2F60700510D0C /* MockEd25519.swift in Sources */, FDC290A627D860CE005DAE71 /* Mock.swift in Sources */, FD83B9C027CF2294005E1583 /* TestConstants.swift in Sources */, @@ -5676,9 +5688,9 @@ FDC438BD27BB2AB400C60D73 /* Mockable.swift in Sources */, FD859EF627C2F52C00510D0C /* MockSign.swift in Sources */, FDC2908927D70656005DAE71 /* RoomPollInfoSpec.swift in Sources */, - FDC2909E27D85751005DAE71 /* OpenGroupManagerSpec.swift in Sources */, FDC2908D27D70905005DAE71 /* UpdateMessageRequestSpec.swift in Sources */, FDC290A227D85890005DAE71 /* TestInteraction.swift in Sources */, + FD078E5427E197CA000769AF /* OpenGroupManagerSpec.swift in Sources */, FDC4389D27BA01F000C60D73 /* MockStorage.swift in Sources */, FD83B9D227D59495005E1583 /* MockUserDefaults.swift in Sources */, ); diff --git a/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift b/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift index 2d89c3047..732a633a9 100644 --- a/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift +++ b/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift @@ -63,7 +63,7 @@ final class JoinOpenGroupModal : Modal { // MARK: Interaction @objc private func joinOpenGroup() { - guard let (room, server, publicKey) = OpenGroupManager.parseV2OpenGroup(from: url) else { + guard let (room, server, publicKey) = OpenGroupManager.parseOpenGroup(from: url) else { let alert = UIAlertController(title: "Couldn't Join", message: nil, preferredStyle: .alert) alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil)) return presentingViewController!.present(alert, animated: true, completion: nil) diff --git a/Session/Open Groups/JoinOpenGroupVC.swift b/Session/Open Groups/JoinOpenGroupVC.swift index 1d1b27af6..914525266 100644 --- a/Session/Open Groups/JoinOpenGroupVC.swift +++ b/Session/Open Groups/JoinOpenGroupVC.swift @@ -127,7 +127,7 @@ final class JoinOpenGroupVC : BaseVC, UIPageViewControllerDataSource, UIPageView fileprivate func joinOpenGroup(with string: String) { // A V2 open group URL will look like: + + + + // The host doesn't parse if no explicit scheme is provided - if let (room, server, publicKey) = OpenGroupManager.parseV2OpenGroup(from: string) { + if let (room, server, publicKey) = OpenGroupManager.parseOpenGroup(from: string) { joinV2OpenGroup(roomToken: room, server: server, publicKey: publicKey) } else { let title = NSLocalizedString("invalid_url", comment: "") diff --git a/SessionMessagingKit/Database/Storage+Contacts.swift b/SessionMessagingKit/Database/Storage+Contacts.swift index a6b1680f6..5ff6d5d42 100644 --- a/SessionMessagingKit/Database/Storage+Contacts.swift +++ b/SessionMessagingKit/Database/Storage+Contacts.swift @@ -1,6 +1,8 @@ extension Storage { + // MARK: - Contacts + private static let contactCollection = "LokiContactCollection" @objc(getContactWithSessionID:) diff --git a/SessionMessagingKit/Open Groups/Models/OpenGroup.swift b/SessionMessagingKit/Open Groups/Models/OpenGroup.swift index 36a6c4398..72f149006 100644 --- a/SessionMessagingKit/Open Groups/Models/OpenGroup.swift +++ b/SessionMessagingKit/Open Groups/Models/OpenGroup.swift @@ -64,4 +64,16 @@ public final class OpenGroup: NSObject, NSCoding { // NSObject/NSCoding conforma } override public var description: String { "\(name) (Server: \(server), Room: \(room))" } + override public var debugDescription: String { + [ + "OpenGroup(server: \"\(server)\"", + "room: \"\(room)\"", + "id: \"\(id)\"", + "publicKey: \"\(publicKey)\"", + "name: \"\(name)\"", + "groupDescription: \(groupDescription.map { "\"\($0)\"" } ?? "null")", + "imageID: \(imageID ?? "null")", + "infoUpdates: \(infoUpdates))" + ].joined(separator: ", ") + } } diff --git a/SessionMessagingKit/Open Groups/Models/Room.swift b/SessionMessagingKit/Open Groups/Models/Room.swift index 535c76902..cfcddbc22 100644 --- a/SessionMessagingKit/Open Groups/Models/Room.swift +++ b/SessionMessagingKit/Open Groups/Models/Room.swift @@ -7,7 +7,7 @@ extension OpenGroupAPI { enum CodingKeys: String, CodingKey { case token case name - case description + case roomDescription = "description" case infoUpdates = "info_updates" case messageSequence = "message_sequence" case created @@ -43,7 +43,7 @@ extension OpenGroupAPI { public let name: String /// Text description of the room, e.g. "All the best sodoku discussion!" - public let description: String? + public let roomDescription: String? /// Monotonic integer counter that increases whenever the room's metadata changes public let infoUpdates: Int64 @@ -153,7 +153,7 @@ extension OpenGroupAPI.Room { self = OpenGroupAPI.Room( token: try container.decode(String.self, forKey: .token), name: try container.decode(String.self, forKey: .name), - description: try? container.decode(String.self, forKey: .description), + roomDescription: try? container.decode(String.self, forKey: .roomDescription), infoUpdates: try container.decode(Int64.self, forKey: .infoUpdates), messageSequence: try container.decode(Int64.self, forKey: .messageSequence), created: try container.decode(TimeInterval.self, forKey: .created), diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index 638f5be44..1a9cf7461 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -32,7 +32,8 @@ public enum OpenGroupAPI { let maybeLastOutboxMessageId: Int64? = dependencies.storage.getOpenGroupOutboxLatestMessageId(for: server) let lastInboxMessageId: Int64 = (maybeLastInboxMessageId ?? 0) let lastOutboxMessageId: Int64 = (maybeLastOutboxMessageId ?? 0) - + let serverConfig: Server? = dependencies.storage.getOpenGroupServer(name: server) + // Generate the requests let requestResponseType: [BatchRequestInfoType] = [ BatchRequestInfo( @@ -81,31 +82,35 @@ public enum OpenGroupAPI { ] } ) - .appending([ - // Inbox - BatchRequestInfo( - request: Request( - server: server, - endpoint: (maybeLastInboxMessageId == nil ? - .inbox : - .inboxSince(id: lastInboxMessageId) - ) + .appending( + // The 'inbox' and 'outbox' only work with blinded keys so don't bother polling them if not blinded + serverConfig?.capabilities.capabilities.contains(.blind) != true ? [] : + [ + // Inbox + BatchRequestInfo( + request: Request( + server: server, + endpoint: (maybeLastInboxMessageId == nil ? + .inbox : + .inboxSince(id: lastInboxMessageId) + ) + ), + responseType: [DirectMessage]?.self // 'inboxSince' will return a `304` with an empty response if no messages ), - responseType: [DirectMessage]?.self // 'inboxSince' will return a `304` with an empty response if no messages - ), - - // Outbox - BatchRequestInfo( - request: Request( - server: server, - endpoint: (maybeLastOutboxMessageId == nil ? - .outbox : - .outboxSince(id: lastOutboxMessageId) - ) - ), - responseType: [DirectMessage]?.self // 'outboxSince' will return a `304` with an empty response if no messages - ) - ]) + + // Outbox + BatchRequestInfo( + request: Request( + server: server, + endpoint: (maybeLastOutboxMessageId == nil ? + .outbox : + .outboxSince(id: lastOutboxMessageId) + ) + ), + responseType: [DirectMessage]?.self // 'outboxSince' will return a `304` with an empty response if no messages + ) + ] + ) return batch(server, requests: requestResponseType, using: dependencies) } diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index ee16f6048..7d7855a3f 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -3,18 +3,46 @@ import Sodium import SessionUtilitiesKit import SessionSnodeKit +// MARK: - OGMCacheType + +public protocol OGMCacheType { + var defaultRoomsPromise: Promise<[OpenGroupAPI.Room]>? { get set } + var groupImagePromises: [String: Promise] { get set } + + var pollers: [String: OpenGroupAPI.Poller] { get set } + var isPolling: Bool { get set } + + var moderators: [String: [String: Set]] { get set } + var admins: [String: [String: Set]] { get set } + + var hasPerformedInitialPoll: [String: Bool] { get set } + var timeSinceLastPoll: [String: TimeInterval] { get set } + + func getTimeSinceLastOpen(using dependencies: Dependencies) -> TimeInterval +} + +extension OGMCacheType { + func getTimeSinceLastOpen() -> TimeInterval { + return getTimeSinceLastOpen(using: Dependencies()) + } +} + +// MARK: - OpenGroupManager + @objc(SNOpenGroupManager) public final class OpenGroupManager: NSObject { - public class Cache { + // MARK: - Cache + + public class Cache: OGMCacheType { public var defaultRoomsPromise: Promise<[OpenGroupAPI.Room]>? - fileprivate var groupImagePromises: [String: Promise] = [:] + public var groupImagePromises: [String: Promise] = [:] public var pollers: [String: OpenGroupAPI.Poller] = [:] // One for each server public var isPolling: Bool = false /// Server URL to room ID to set of user IDs - fileprivate var moderators: [String: [String: Set]] = [:] - fileprivate var admins: [String: [String: Set]] = [:] + public var moderators: [String: [String: Set]] = [:] + public var admins: [String: [String: Set]] = [:] /// Server URL to value public var hasPerformedInitialPoll: [String: Bool] = [:] @@ -40,15 +68,15 @@ public final class OpenGroupManager: NSObject { @objc public static let shared: OpenGroupManager = OpenGroupManager() - public let mutableCache: Atomic = Atomic(Cache()) - public var cache: Cache { return mutableCache.wrappedValue } + /// Note: This should not be accessed directly but rather via the 'OGMDependencies' type + fileprivate let mutableCache: Atomic = Atomic(Cache()) // MARK: - Polling - public func startPolling(using dependencies: Dependencies = Dependencies()) { - guard !cache.isPolling else { return } + public func startPolling(using dependencies: OGMDependencies = OGMDependencies()) { + guard !dependencies.cache.isPolling else { return } - mutableCache.mutate { cache in + dependencies.mutableCache.mutate { cache in cache.isPolling = true cache.pollers = Set(dependencies.storage.getAllOpenGroups().values.map { openGroup in openGroup.server }) .reduce(into: [:]) { prev, server in @@ -62,8 +90,8 @@ public final class OpenGroupManager: NSObject { } } - @objc public func stopPolling() { - mutableCache.mutate { + public func stopPolling(using dependencies: OGMDependencies = OGMDependencies()) { + dependencies.mutableCache.mutate { $0.pollers.forEach { (_, openGroupPoller) in openGroupPoller.stop() } $0.pollers.removeAll() $0.isPolling = false @@ -72,11 +100,11 @@ public final class OpenGroupManager: NSObject { // MARK: - Adding & Removing - public func add(roomToken: String, server: String, publicKey: String, isConfigMessage: Bool, using transaction: YapDatabaseReadWriteTransaction, dependencies: Dependencies = Dependencies()) -> Promise { + public func add(roomToken: String, server: String, publicKey: String, isConfigMessage: Bool, using transaction: YapDatabaseReadWriteTransaction, dependencies: OGMDependencies = OGMDependencies()) -> Promise { // If we are currently polling for this server and already have a TSGroupThread for this room the do nothing let groupId: Data = LKGroupUtilities.getEncodedOpenGroupIDAsData("\(server).\(roomToken)") - if OpenGroupManager.shared.cache.pollers[server] != nil && TSGroupThread.fetch(groupId: groupId, transaction: transaction) != nil { + if dependencies.cache.pollers[server] != nil && TSGroupThread.fetch(groupId: groupId, transaction: transaction) != nil { SNLog("Ignoring join open group attempt (already joined), user initiated: \(!isConfigMessage)") return Promise.value(()) } @@ -104,8 +132,8 @@ public final class OpenGroupManager: NSObject { ) // Then the room - OpenGroupManager.handleRoom( - response.room.data, + OpenGroupManager.handlePollInfo( + OpenGroupAPI.RoomPollInfo(room: response.room.data), publicKey: publicKey, for: roomToken, on: server, @@ -125,13 +153,13 @@ public final class OpenGroupManager: NSObject { return promise } - public func delete(_ openGroup: OpenGroup, associatedWith thread: TSThread, using transaction: YapDatabaseReadWriteTransaction, dependencies: Dependencies = Dependencies()) { + public func delete(_ openGroup: OpenGroup, associatedWith thread: TSThread, using transaction: YapDatabaseReadWriteTransaction, dependencies: OGMDependencies = OGMDependencies()) { // Stop the poller if needed let openGroups = dependencies.storage.getAllOpenGroups().values.filter { $0.server == openGroup.server } if openGroups.count == 1 && openGroups.last == openGroup { - let poller = cache.pollers[openGroup.server] + let poller = dependencies.cache.pollers[openGroup.server] poller?.stop() - mutableCache.mutate { $0.pollers[openGroup.server] = nil } + dependencies.mutableCache.mutate { $0.pollers[openGroup.server] = nil } } // Remove all data @@ -162,7 +190,7 @@ public final class OpenGroupManager: NSObject { _ capabilities: OpenGroupAPI.Capabilities, on server: String, using transaction: YapDatabaseReadWriteTransaction, - dependencies: Dependencies = Dependencies() + dependencies: OGMDependencies = OGMDependencies() ) { let updatedServer: OpenGroupAPI.Server = OpenGroupAPI.Server( name: server, @@ -172,33 +200,13 @@ public final class OpenGroupManager: NSObject { dependencies.storage.setOpenGroupServer(updatedServer, using: transaction) } - internal static func handleRoom( - _ room: OpenGroupAPI.Room, - publicKey: String, - for roomToken: String, - on server: String, - using transaction: YapDatabaseReadWriteTransaction, - dependencies: Dependencies = Dependencies(), - completion: (() -> ())? = nil - ) { - OpenGroupManager.handlePollInfo( - OpenGroupAPI.RoomPollInfo(room: room), - publicKey: publicKey, - for: roomToken, - on: server, - using: transaction, - dependencies: dependencies, - completion: completion - ) - } - internal static func handlePollInfo( _ pollInfo: OpenGroupAPI.RoomPollInfo, publicKey maybePublicKey: String?, for roomToken: String, on server: String, using transaction: YapDatabaseReadWriteTransaction, - dependencies: Dependencies = Dependencies(), + dependencies: OGMDependencies = OGMDependencies(), completion: (() -> ())? = nil ) { // Create the open group model and get or create the thread @@ -239,7 +247,7 @@ public final class OpenGroupManager: NSObject { room: pollInfo.token, publicKey: publicKey, name: (pollInfo.details?.name ?? thread.name()), - groupDescription: (pollInfo.details?.description ?? existingOpenGroup?.description), + groupDescription: (pollInfo.details?.roomDescription ?? existingOpenGroup?.groupDescription), imageID: (pollInfo.details?.imageId.map { "\($0)" } ?? existingOpenGroup?.imageID), infoUpdates: ((pollInfo.details?.infoUpdates ?? existingOpenGroup?.infoUpdates) ?? 0) ) @@ -261,8 +269,8 @@ public final class OpenGroupManager: NSObject { transaction.addCompletionQueue(DispatchQueue.global(qos: .userInitiated)) { // Start the poller if needed - if OpenGroupManager.shared.cache.pollers[server] == nil { - OpenGroupManager.shared.mutableCache.mutate { + if dependencies.cache.pollers[server] == nil { + dependencies.mutableCache.mutate { $0.pollers[server] = OpenGroupAPI.Poller(for: server) $0.pollers[server]?.startIfNeeded(using: dependencies) } @@ -270,21 +278,21 @@ public final class OpenGroupManager: NSObject { // - Moderators if let moderators: [String] = (pollInfo.details?.moderators ?? maybeUpdatedModel?.groupModeratorIds) { - OpenGroupManager.shared.mutableCache.mutate { cache in + dependencies.mutableCache.mutate { cache in cache.moderators[server] = (cache.moderators[server] ?? [:]).setting(roomToken, Set(moderators)) } } // - Admins if let admins: [String] = (pollInfo.details?.admins ?? maybeUpdatedModel?.groupAdminIds) { - OpenGroupManager.shared.mutableCache.mutate { cache in + dependencies.mutableCache.mutate { cache in cache.admins[server] = (cache.admins[server] ?? [:]).setting(roomToken, Set(admins)) } } - // - Room image (if there is one) - if let imageId: UInt64 = pollInfo.details?.imageId { - OpenGroupManager.roomImage(imageId, for: roomToken, on: server) + // - Room image (if there is one and it's different from the existing one, or we don't have the existing one) + if let imageId: UInt64 = UInt64(updatedOpenGroup.imageID ?? ""), (updatedModel.groupImage == nil || updatedOpenGroup.imageID != existingOpenGroup?.imageID) { + OpenGroupManager.roomImage(imageId, for: roomToken, on: server, using: dependencies) .done(on: DispatchQueue.global(qos: .userInitiated)) { data in dependencies.storage.write { transaction in // Update the thread @@ -308,7 +316,7 @@ public final class OpenGroupManager: NSObject { on server: String, isBackgroundPoll: Bool, using transaction: YapDatabaseReadWriteTransaction, - dependencies: Dependencies = Dependencies() + dependencies: OGMDependencies = OGMDependencies() ) { // Sorting the messages by server ID before importing them fixes an issue where messages // that quote older messages can't find those older messages @@ -338,8 +346,8 @@ public final class OpenGroupManager: NSObject { do { let data = try envelope.buildSerializedData() - let (message, proto) = try MessageReceiver.parse(data, openGroupMessageServerID: UInt64(message.id), isRetry: false, using: transaction) - try MessageReceiver.handle(message, associatedWithProto: proto, openGroupID: openGroupID, isBackgroundPoll: isBackgroundPoll, using: transaction) + let (message, proto) = try MessageReceiver.parse(data, openGroupMessageServerID: UInt64(message.id), isRetry: false, using: transaction, dependencies: dependencies) + try MessageReceiver.handle(message, associatedWithProto: proto, openGroupID: openGroupID, isBackgroundPoll: isBackgroundPoll, using: transaction, dependencies: dependencies) } catch { SNLog("Couldn't receive open group message due to error: \(error).") @@ -348,9 +356,7 @@ public final class OpenGroupManager: NSObject { // Handle any deletions that are needed guard !messageServerIDsToRemove.isEmpty else { return } - guard let threadID = dependencies.storage.getThreadID(for: openGroupID), let thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction) else { - return - } + guard let thread = TSGroupThread.fetch(groupId: openGroupID, transaction: transaction) else { return } var messagesToRemove: [TSMessage] = [] @@ -372,7 +378,7 @@ public final class OpenGroupManager: NSObject { on server: String, isBackgroundPoll: Bool, using transaction: YapDatabaseReadWriteTransaction, - dependencies: Dependencies = Dependencies() + dependencies: OGMDependencies = OGMDependencies() ) { // Don't need to do anything if we have no messages (it's a valid case) guard !messages.isEmpty else { return } @@ -417,7 +423,8 @@ public final class OpenGroupManager: NSObject { isOutgoing: fromOutbox, otherBlindedPublicKey: (fromOutbox ? message.recipient : message.sender), isRetry: false, - using: transaction + using: transaction, + dependencies: dependencies ) // If the message was an outgoing message then attempt to unblind the recipient (this will help put @@ -455,7 +462,7 @@ public final class OpenGroupManager: NSObject { mappingCache[message.recipient] = mapping } - try MessageReceiver.handle(receivedMessage, associatedWithProto: proto, openGroupID: nil, isBackgroundPoll: isBackgroundPoll, using: transaction) + try MessageReceiver.handle(receivedMessage, associatedWithProto: proto, openGroupID: nil, isBackgroundPoll: isBackgroundPoll, using: transaction, dependencies: dependencies) // If this message is from the outbox then we should add the open group details back to the // thread just in case this is from a restore (otherwise the user won't be able to send a new @@ -475,14 +482,9 @@ public final class OpenGroupManager: NSObject { // MARK: - Convenience /// This method specifies if the given publicKey is a moderator or an admin within a specified Open Group - @objc(isUserModeratorOrAdmin:forRoom:onServer:) - public static func isUserModeratorOrAdmin(_ publicKey: String, for room: String, on server: String) -> Bool { - return isUserModeratorOrAdmin(publicKey, for: room, on: server, using: Dependencies()) - } - - public static func isUserModeratorOrAdmin(_ publicKey: String, for room: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Bool { - let modAndAdminKeys: Set = (OpenGroupManager.shared.cache.moderators[server]?[room] ?? Set()) - .union(OpenGroupManager.shared.cache.admins[server]?[room] ?? Set()) + public static func isUserModeratorOrAdmin(_ publicKey: String, for room: String, on server: String, using dependencies: OGMDependencies = OGMDependencies()) -> Bool { + let modAndAdminKeys: Set = (dependencies.cache.moderators[server]?[room] ?? Set()) + .union(dependencies.cache.admins[server]?[room] ?? Set()) // If the publicKey is in the set then return immediately, otherwise only continue if it's the // current user @@ -528,9 +530,9 @@ public final class OpenGroupManager: NSObject { } } - @discardableResult public static func getDefaultRoomsIfNeeded(using dependencies: Dependencies = Dependencies()) -> Promise<[OpenGroupAPI.Room]> { + @discardableResult public static func getDefaultRoomsIfNeeded(using dependencies: OGMDependencies = OGMDependencies()) -> Promise<[OpenGroupAPI.Room]> { // Note: If we already have a 'defaultRoomsPromise' then there is no need to get it again - if let existingPromise: Promise<[OpenGroupAPI.Room]> = OpenGroupManager.shared.cache.defaultRoomsPromise { + if let existingPromise: Promise<[OpenGroupAPI.Room]> = dependencies.cache.defaultRoomsPromise { return existingPromise } @@ -567,7 +569,7 @@ public final class OpenGroupManager: NSObject { internalPromise .catch(on: OpenGroupAPI.workQueue) { error in - OpenGroupManager.shared.mutableCache.mutate { cache in + dependencies.mutableCache.mutate { cache in cache.defaultRoomsPromise = nil } seal.reject(error) @@ -586,7 +588,7 @@ public final class OpenGroupManager: NSObject { _ fileId: UInt64, for roomToken: String, on server: String, - using dependencies: Dependencies = Dependencies() + using dependencies: OGMDependencies = OGMDependencies() ) -> Promise { // Normally the image for a given group is stored with the group thread, so it's only // fetched once. However, on the join open group screen we show images for groups the @@ -597,7 +599,7 @@ public final class OpenGroupManager: NSObject { // we only need to maintain one date in user defaults. On top of all of this we also // don't double up on fetch requests by storing the existing request as a promise if // there is one. - let lastOpenGroupImageUpdate: Date? = UserDefaults.standard[.lastOpenGroupImageUpdate] + let lastOpenGroupImageUpdate: Date? = dependencies.standardUserDefaults[.lastOpenGroupImageUpdate] let now: Date = dependencies.date let timeSinceLastUpdate: TimeInterval = (lastOpenGroupImageUpdate.map { now.timeIntervalSince($0) } ?? .greatestFiniteMagnitude) let updateInterval: TimeInterval = (7 * 24 * 60 * 60) @@ -606,7 +608,7 @@ public final class OpenGroupManager: NSObject { return Promise.value(data) } - if let promise = OpenGroupManager.shared.cache.groupImagePromises["\(server).\(roomToken)"] { + if let promise = dependencies.cache.groupImagePromises["\(server).\(roomToken)"] { return promise } @@ -621,21 +623,26 @@ public final class OpenGroupManager: NSObject { UserDefaults.standard[.lastOpenGroupImageUpdate] = now } } - OpenGroupManager.shared.mutableCache.mutate { cache in + dependencies.mutableCache.mutate { cache in cache.groupImagePromises["\(server).\(roomToken)"] = promise } return promise } - public static func parseV2OpenGroup(from string: String) -> (room: String, server: String, publicKey: String)? { + public static func parseOpenGroup(from string: String) -> (room: String, server: String, publicKey: String)? { guard let url = URL(string: string), let host = url.host ?? given(string.split(separator: "/").first, { String($0) }), let query = url.query else { return nil } // Inputs that should work: + // https://sessionopengroup.co/r/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c // https://sessionopengroup.co/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c + // http://sessionopengroup.co/r/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c // http://sessionopengroup.co/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c // sessionopengroup.co/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c (does NOT go to HTTPS) + // sessionopengroup.co/r/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c (does NOT go to HTTPS) + // https://143.198.213.225:443/r/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c // https://143.198.213.225:443/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c // 143.198.213.255:80/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c + // 143.198.213.255:80/r/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c let useTLS = (url.scheme == "https") let updatedPath = (url.path.starts(with: "/r/") ? url.path.substring(from: 2) : url.path) let room = String(updatedPath.dropFirst()) // Drop the leading slash @@ -649,14 +656,72 @@ public final class OpenGroupManager: NSObject { } } +// MARK: - Objective C Methods + extension OpenGroupManager { @objc(startPolling) public func objc_startPolling() { startPolling() } + @objc(stopPolling) + public func objc_stopPolling() { + stopPolling() + } + @objc(getDefaultRoomsIfNeeded) public static func objc_getDefaultRoomsIfNeeded() { getDefaultRoomsIfNeeded() } + + @objc(isUserModeratorOrAdmin:forRoom:onServer:) + public static func isUserModeratorOrAdmin(_ publicKey: String, for room: String, on server: String) -> Bool { + return isUserModeratorOrAdmin(publicKey, for: room, on: server, using: OGMDependencies()) + } +} + + +// MARK: - OGMDependencies + +extension OpenGroupManager { + public class OGMDependencies: Dependencies { + internal var _mutableCache: Atomic? + public var mutableCache: Atomic { + get { Dependencies.getValueSettingIfNull(&_mutableCache) { OpenGroupManager.shared.mutableCache } } + set { _mutableCache = newValue } + } + + public var cache: OGMCacheType { return mutableCache.wrappedValue } + + public init( + cache: Atomic? = nil, + onionApi: OnionRequestAPIType.Type? = nil, + storage: SessionMessagingKitStorageProtocol? = nil, + sodium: SodiumType? = nil, + aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, + sign: SignType? = nil, + genericHash: GenericHashType? = nil, + ed25519: Ed25519Type? = nil, + nonceGenerator16: NonceGenerator16ByteType? = nil, + nonceGenerator24: NonceGenerator24ByteType? = nil, + standardUserDefaults: UserDefaultsType? = nil, + date: Date? = nil + ) { + _mutableCache = cache + + super.init( + onionApi: onionApi, + storage: storage, + sodium: sodium, + aeadXChaCha20Poly1305Ietf: aeadXChaCha20Poly1305Ietf, + sign: sign, + genericHash: genericHash, + ed25519: ed25519, + nonceGenerator16: nonceGenerator16, + nonceGenerator24: nonceGenerator24, + standardUserDefaults: standardUserDefaults, + date: date + ) + } + } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index e22843dec..3394228b4 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -9,7 +9,7 @@ extension MessageReceiver { return SSKEnvironment.shared.blockingManager.isRecipientIdBlocked(publicKey) } - public static func handle(_ message: Message, associatedWithProto proto: SNProtoContent, openGroupID: String?, isBackgroundPoll: Bool, using transaction: Any) throws { + public static func handle(_ message: Message, associatedWithProto proto: SNProtoContent, openGroupID: String?, isBackgroundPoll: Bool, using transaction: Any, dependencies: Dependencies = Dependencies()) throws { switch message { case let message as ReadReceipt: handleReadReceipt(message, using: transaction) case let message as TypingIndicator: handleTypingIndicator(message, using: transaction) @@ -19,7 +19,7 @@ extension MessageReceiver { case let message as ConfigurationMessage: handleConfigurationMessage(message, using: transaction) case let message as UnsendRequest: handleUnsendRequest(message, using: transaction) case let message as MessageRequestResponse: handleMessageRequestResponse(message, using: transaction) - case let message as VisibleMessage: try handleVisibleMessage(message, associatedWithProto: proto, openGroupID: openGroupID, isBackgroundPoll: isBackgroundPoll, using: transaction) + case let message as VisibleMessage: try handleVisibleMessage(message, associatedWithProto: proto, openGroupID: openGroupID, isBackgroundPoll: isBackgroundPoll, using: transaction, dependencies: dependencies) default: fatalError() } @@ -29,8 +29,7 @@ extension MessageReceiver { } guard isMainAppAndActive else { return } // Touch the thread to update the home screen preview - let storage = SNMessagingKitConfiguration.shared.storage - guard let threadID = storage.getOrCreateThread(for: message.sender!, groupPublicKey: message.groupPublicKey, openGroupID: openGroupID, using: transaction) else { return } + guard let threadID = dependencies.storage.getOrCreateThread(for: message.sender!, groupPublicKey: message.groupPublicKey, openGroupID: openGroupID, using: transaction) else { return } ThreadUpdateBatcher.shared.touch(threadID) } @@ -277,7 +276,7 @@ extension MessageReceiver { // Open groups for openGroupURL in message.openGroups { - if let (room, server, publicKey) = OpenGroupManager.parseV2OpenGroup(from: openGroupURL) { + if let (room, server, publicKey) = OpenGroupManager.parseOpenGroup(from: openGroupURL) { OpenGroupManager.shared.add(roomToken: room, server: server, publicKey: publicKey, isConfigMessage: true, using: transaction).retainUntilComplete() } } @@ -324,8 +323,7 @@ extension MessageReceiver { // MARK: - Visible Messages @discardableResult - public static func handleVisibleMessage(_ message: VisibleMessage, associatedWithProto proto: SNProtoContent, openGroupID: String?, isBackgroundPoll: Bool, using transaction: Any) throws -> String { - let storage = SNMessagingKitConfiguration.shared.storage + public static func handleVisibleMessage(_ message: VisibleMessage, associatedWithProto proto: SNProtoContent, openGroupID: String?, isBackgroundPoll: Bool, using transaction: Any, dependencies: Dependencies = Dependencies()) throws -> String { let transaction = transaction as! YapDatabaseReadWriteTransaction var isMainAppAndActive = false if let sharedUserDefaults = UserDefaults(suiteName: "group.com.loki-project.loki-messenger") { @@ -336,7 +334,7 @@ extension MessageReceiver { guard let attachment = VisibleMessage.Attachment.fromProto(proto) else { return nil } return attachment.isValid ? attachment : nil } - let attachmentIDs = storage.persist(attachments, using: transaction) + let attachmentIDs = dependencies.storage.persist(attachments, using: transaction) message.attachmentIDs = attachmentIDs var attachmentsToDownload = attachmentIDs // Update profile if needed @@ -348,7 +346,7 @@ extension MessageReceiver { profileKey: contactProfileKey, sentTimestamp: message.sentTimestamp!, transaction: transaction) } // Get or create thread - guard let threadID = storage.getOrCreateThread(for: message.syncTarget ?? message.sender!, groupPublicKey: message.groupPublicKey, openGroupID: openGroupID, using: transaction) else { throw Error.noThread } + guard let threadID = dependencies.storage.getOrCreateThread(for: message.syncTarget ?? message.sender!, groupPublicKey: message.groupPublicKey, openGroupID: openGroupID, using: transaction) else { throw Error.noThread } // Parse quote if needed var tsQuotedMessage: TSQuotedMessage? = nil if message.quote != nil && proto.dataMessage?.quote != nil, let thread = TSThread.fetch(uniqueId: threadID, transaction: transaction) { @@ -366,11 +364,10 @@ extension MessageReceiver { } } // Persist the message - guard let tsMessageID = storage.persist(message, quotedMessage: tsQuotedMessage, linkPreview: owsLinkPreview, - groupPublicKey: message.groupPublicKey, openGroupID: openGroupID, using: transaction) else { throw Error.duplicateMessage } + guard let tsMessageID = dependencies.storage.persist(message, quotedMessage: tsQuotedMessage, linkPreview: owsLinkPreview, groupPublicKey: message.groupPublicKey, openGroupID: openGroupID, using: transaction) else { throw Error.duplicateMessage } message.threadID = threadID // Start attachment downloads if needed - let isContactTrusted = Storage.shared.getContact(with: message.sender!)?.isTrusted ?? false + let isContactTrusted = dependencies.storage.getContact(with: message.sender!)?.isTrusted ?? false let isGroup = message.groupPublicKey != nil || openGroupID != nil attachmentsToDownload.forEach { attachmentID in let downloadJob = AttachmentDownloadJob(attachmentID: attachmentID, tsMessageID: tsMessageID, threadID: threadID) @@ -426,11 +423,10 @@ extension MessageReceiver { // MARK: - Profile Updating - private static func updateProfileIfNeeded(publicKey: String, name: String?, profilePictureURL: String?, - profileKey: OWSAES256Key?, sentTimestamp: UInt64, transaction: YapDatabaseReadWriteTransaction) { + private static func updateProfileIfNeeded(publicKey: String, name: String?, profilePictureURL: String?, profileKey: OWSAES256Key?, sentTimestamp: UInt64, transaction: YapDatabaseReadWriteTransaction, dependencies: Dependencies = Dependencies()) { let isCurrentUser = (publicKey == getUserHexEncodedPublicKey()) let userDefaults = UserDefaults.standard - let contact = Storage.shared.getContact(with: publicKey) ?? Contact(sessionID: publicKey) // New API + let contact = (dependencies.storage.getContact(with: publicKey) ?? Contact(sessionID: publicKey)) // New API // Name if let name = name, name != contact.name { let shouldUpdate: Bool @@ -464,7 +460,7 @@ extension MessageReceiver { } } // Persist changes - Storage.shared.setContact(contact, using: transaction) + dependencies.storage.setContact(contact, using: transaction) // Download the profile picture if needed transaction.addCompletionQueue(DispatchQueue.main) { SSKEnvironment.shared.profileManager.downloadAvatar(forUserProfile: contact) diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index d5bd8f39f..857960017 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -55,14 +55,14 @@ public enum MessageReceiver { isOutgoing: Bool? = nil, otherBlindedPublicKey: String? = nil, isRetry: Bool = false, - using transaction: Any + using transaction: Any, + dependencies: Dependencies = Dependencies() ) throws -> (Message, SNProtoContent) { - let userPublicKey = SNMessagingKitConfiguration.shared.storage.getUserPublicKey() + let userPublicKey = dependencies.storage.getUserPublicKey() let isOpenGroupMessage = (openGroupMessageServerID != nil) // Parse the envelope let envelope = try SNProtoEnvelope.parseData(data) - let storage = SNMessagingKitConfiguration.shared.storage // Decrypt the contents guard let ciphertext = envelope.content else { throw Error.noData } @@ -80,7 +80,7 @@ public enum MessageReceiver { // Default to 'standard' as the old code didn't seem to require an `envelope.source` switch (SessionId.Prefix(from: envelope.source) ?? .standard) { case .standard, .unblinded: - guard let userX25519KeyPair = SNMessagingKitConfiguration.shared.storage.getUserKeyPair() else { + guard let userX25519KeyPair = dependencies.storage.getUserKeyPair() else { throw Error.noUserX25519KeyPair } @@ -91,7 +91,7 @@ public enum MessageReceiver { guard let openGroupServerPublicKey: String = openGroupServerPublicKey else { throw Error.invalidGroupPublicKey } - guard let userEd25519KeyPair = SNMessagingKitConfiguration.shared.storage.getUserED25519KeyPair() else { + guard let userEd25519KeyPair = dependencies.storage.getUserED25519KeyPair() else { throw Error.noUserED25519KeyPair } @@ -100,16 +100,17 @@ public enum MessageReceiver { isOutgoing: (isOutgoing == true), otherBlindedPublicKey: otherBlindedPublicKey, with: openGroupServerPublicKey, - userEd25519KeyPair: userEd25519KeyPair + userEd25519KeyPair: userEd25519KeyPair, + using: dependencies ) } case .closedGroupMessage: - guard let hexEncodedGroupPublicKey = envelope.source, SNMessagingKitConfiguration.shared.storage.isClosedGroup(hexEncodedGroupPublicKey) else { + guard let hexEncodedGroupPublicKey = envelope.source, dependencies.storage.isClosedGroup(hexEncodedGroupPublicKey) else { throw Error.invalidGroupPublicKey } - var encryptionKeyPairs = Storage.shared.getClosedGroupEncryptionKeyPairs(for: hexEncodedGroupPublicKey) + var encryptionKeyPairs = dependencies.storage.getClosedGroupEncryptionKeyPairs(for: hexEncodedGroupPublicKey) guard !encryptionKeyPairs.isEmpty else { throw Error.noGroupKeyPair } @@ -199,13 +200,13 @@ public enum MessageReceiver { // • Processing wasn't finished // • The user doesn't see the new closed group } else { - guard !Set(storage.getReceivedMessageTimestamps(using: transaction)).contains(envelope.timestamp) || isRetry else { throw Error.duplicateMessage } - storage.addReceivedMessageTimestamp(envelope.timestamp, using: transaction) + guard !Set(dependencies.storage.getReceivedMessageTimestamps(using: transaction)).contains(envelope.timestamp) || isRetry else { throw Error.duplicateMessage } + dependencies.storage.addReceivedMessageTimestamp(envelope.timestamp, using: transaction) } // Return return (message, proto) - } else { - throw Error.unknownMessage } + + throw Error.unknownMessage } } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift index 20b7de9da..5202863be 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift @@ -19,7 +19,7 @@ extension OpenGroupAPI { self.server = server } - public func startIfNeeded(using dependencies: Dependencies = Dependencies()) { + public func startIfNeeded(using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies()) { guard !hasStarted else { return } hasStarted = true @@ -37,12 +37,12 @@ extension OpenGroupAPI { // MARK: - Polling @discardableResult - public func poll(using dependencies: Dependencies = Dependencies()) -> Promise { + public func poll(using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies()) -> Promise { return poll(isBackgroundPoll: false, using: dependencies) } @discardableResult - public func poll(isBackgroundPoll: Bool, using dependencies: Dependencies = Dependencies()) -> Promise { + public func poll(isBackgroundPoll: Bool, using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies()) -> Promise { guard !self.isPolling else { return Promise.value(()) } self.isPolling = true @@ -54,10 +54,10 @@ extension OpenGroupAPI { OpenGroupAPI .poll( server, - hasPerformedInitialPoll: OpenGroupManager.shared.cache.hasPerformedInitialPoll[server] == true, + hasPerformedInitialPoll: dependencies.cache.hasPerformedInitialPoll[server] == true, timeSinceLastPoll: ( - OpenGroupManager.shared.cache.timeSinceLastPoll[server] ?? - OpenGroupManager.shared.cache.getTimeSinceLastOpen(using: dependencies) + dependencies.cache.timeSinceLastPoll[server] ?? + dependencies.cache.getTimeSinceLastOpen(using: dependencies) ), using: dependencies ) @@ -65,7 +65,7 @@ extension OpenGroupAPI { self?.isPolling = false self?.handlePollResponse(response, isBackgroundPoll: isBackgroundPoll, using: dependencies) - OpenGroupManager.shared.mutableCache.mutate { cache in + dependencies.mutableCache.mutate { cache in cache.hasPerformedInitialPoll[server] = true cache.timeSinceLastPoll[server] = Date().timeIntervalSince1970 UserDefaults.standard[.lastOpen] = Date() @@ -82,7 +82,7 @@ extension OpenGroupAPI { return promise } - private func handlePollResponse(_ response: [OpenGroupAPI.Endpoint: (info: OnionRequestResponseInfoType, data: Codable?)], isBackgroundPoll: Bool, using dependencies: Dependencies = Dependencies()) { + private func handlePollResponse(_ response: [OpenGroupAPI.Endpoint: (info: OnionRequestResponseInfoType, data: Codable?)], isBackgroundPoll: Bool, using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies()) { let server: String = self.server dependencies.storage.write { anyTransaction in diff --git a/SessionMessagingKit/Storage.swift b/SessionMessagingKit/Storage.swift index 0fc22f249..ed64c2ada 100644 --- a/SessionMessagingKit/Storage.swift +++ b/SessionMessagingKit/Storage.swift @@ -1,5 +1,6 @@ import PromiseKit import Sodium +import Curve25519Kit import YapDatabase public protocol SessionMessagingKitStorageProtocol { @@ -18,6 +19,12 @@ public protocol SessionMessagingKitStorageProtocol { func getUserKeyPair() -> ECKeyPair? func getUserED25519KeyPair() -> Box.KeyPair? func getUser() -> Contact? + + // MARK: - Contacts + + func getContact(with sessionID: String) -> Contact? + func getContact(with sessionID: String, using transaction: Any) -> Contact? + func setContact(_ contact: Contact, using transaction: Any) func getAllContacts() -> Set func getAllContacts(with transaction: YapDatabaseReadTransaction) -> Set @@ -32,6 +39,10 @@ public protocol SessionMessagingKitStorageProtocol { // MARK: - Closed Groups + func getClosedGroupEncryptionKeyPairs(for groupPublicKey: String) -> [ECKeyPair] + func getLatestClosedGroupEncryptionKeyPair(for groupPublicKey: String) -> ECKeyPair? + func addClosedGroupEncryptionKeyPair(_ keyPair: ECKeyPair, for groupPublicKey: String, using transaction: Any) + func removeAllClosedGroupEncryptionKeyPairs(for groupPublicKey: String, using transaction: Any) func getUserClosedGroupPublicKeys() -> Set func getZombieMembers(for groupPublicKey: String) -> Set func setZombieMembers(for groupPublicKey: String, to zombies: Set, using transaction: Any) diff --git a/SessionMessagingKit/Utilities/Dependencies.swift b/SessionMessagingKit/Utilities/Dependencies.swift index e1b405c80..bff71db56 100644 --- a/SessionMessagingKit/Utilities/Dependencies.swift +++ b/SessionMessagingKit/Utilities/Dependencies.swift @@ -5,72 +5,70 @@ import Sodium import SessionSnodeKit import SessionUtilitiesKit -// MARK: - Dependencies - public class Dependencies { - private var _onionApi: OnionRequestAPIType.Type? + internal var _onionApi: OnionRequestAPIType.Type? public var onionApi: OnionRequestAPIType.Type { - get { getValueSettingIfNull(&_onionApi) { OnionRequestAPI.self } } + get { Dependencies.getValueSettingIfNull(&_onionApi) { OnionRequestAPI.self } } set { _onionApi = newValue } } - private var _storage: SessionMessagingKitStorageProtocol? + internal var _storage: SessionMessagingKitStorageProtocol? public var storage: SessionMessagingKitStorageProtocol { - get { getValueSettingIfNull(&_storage) { SNMessagingKitConfiguration.shared.storage } } + get { Dependencies.getValueSettingIfNull(&_storage) { SNMessagingKitConfiguration.shared.storage } } set { _storage = newValue } } - private var _sodium: SodiumType? + internal var _sodium: SodiumType? public var sodium: SodiumType { - get { getValueSettingIfNull(&_sodium) { Sodium() } } + get { Dependencies.getValueSettingIfNull(&_sodium) { Sodium() } } set { _sodium = newValue } } - private var _aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? + internal var _aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? public var aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType { - get { getValueSettingIfNull(&_aeadXChaCha20Poly1305Ietf) { sodium.getAeadXChaCha20Poly1305Ietf() } } + get { Dependencies.getValueSettingIfNull(&_aeadXChaCha20Poly1305Ietf) { sodium.getAeadXChaCha20Poly1305Ietf() } } set { _aeadXChaCha20Poly1305Ietf = newValue } } - private var _sign: SignType? + internal var _sign: SignType? public var sign: SignType { - get { getValueSettingIfNull(&_sign) { sodium.getSign() } } + get { Dependencies.getValueSettingIfNull(&_sign) { sodium.getSign() } } set { _sign = newValue } } - private var _genericHash: GenericHashType? + internal var _genericHash: GenericHashType? public var genericHash: GenericHashType { - get { getValueSettingIfNull(&_genericHash) { sodium.getGenericHash() } } + get { Dependencies.getValueSettingIfNull(&_genericHash) { sodium.getGenericHash() } } set { _genericHash = newValue } } - private var _ed25519: Ed25519Type? + internal var _ed25519: Ed25519Type? public var ed25519: Ed25519Type { - get { getValueSettingIfNull(&_ed25519) { Ed25519Wrapper() } } + get { Dependencies.getValueSettingIfNull(&_ed25519) { Ed25519Wrapper() } } set { _ed25519 = newValue } } - private var _nonceGenerator16: NonceGenerator16ByteType? + internal var _nonceGenerator16: NonceGenerator16ByteType? public var nonceGenerator16: NonceGenerator16ByteType { - get { getValueSettingIfNull(&_nonceGenerator16) { OpenGroupAPI.NonceGenerator16Byte() } } + get { Dependencies.getValueSettingIfNull(&_nonceGenerator16) { OpenGroupAPI.NonceGenerator16Byte() } } set { _nonceGenerator16 = newValue } } - private var _nonceGenerator24: NonceGenerator24ByteType? + internal var _nonceGenerator24: NonceGenerator24ByteType? public var nonceGenerator24: NonceGenerator24ByteType { - get { getValueSettingIfNull(&_nonceGenerator24) { OpenGroupAPI.NonceGenerator24Byte() } } + get { Dependencies.getValueSettingIfNull(&_nonceGenerator24) { OpenGroupAPI.NonceGenerator24Byte() } } set { _nonceGenerator24 = newValue } } - private var _standardUserDefaults: UserDefaultsType? + internal var _standardUserDefaults: UserDefaultsType? public var standardUserDefaults: UserDefaultsType { - get { getValueSettingIfNull(&_standardUserDefaults) { UserDefaults.standard } } + get { Dependencies.getValueSettingIfNull(&_standardUserDefaults) { UserDefaults.standard } } set { _standardUserDefaults = newValue } } - private var _date: Date? + internal var _date: Date? public var date: Date { - get { getValueSettingIfNull(&_date) { Date() } } + get { Dependencies.getValueSettingIfNull(&_date) { Date() } } set { _date = newValue } } @@ -103,44 +101,14 @@ public class Dependencies { } // MARK: - Convenience - - public func with( - onionApi: OnionRequestAPIType.Type? = nil, - storage: SessionMessagingKitStorageProtocol? = nil, - sodium: SodiumType? = nil, - aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, - sign: SignType? = nil, - genericHash: GenericHashType? = nil, - ed25519: Ed25519Type? = nil, - nonceGenerator16: NonceGenerator16ByteType? = nil, - nonceGenerator24: NonceGenerator24ByteType? = nil, - standardUserDefaults: UserDefaultsType? = nil, - date: Date? = nil - ) -> Dependencies { - return Dependencies( - onionApi: (onionApi ?? self._onionApi), - storage: (storage ?? self._storage), - sodium: (sodium ?? self._sodium), - aeadXChaCha20Poly1305Ietf: (aeadXChaCha20Poly1305Ietf ?? self._aeadXChaCha20Poly1305Ietf), - sign: (sign ?? self._sign), - genericHash: (genericHash ?? self._genericHash), - ed25519: (ed25519 ?? self._ed25519), - nonceGenerator16: (nonceGenerator16 ?? self._nonceGenerator16), - nonceGenerator24: (nonceGenerator24 ?? self._nonceGenerator24), - standardUserDefaults: (standardUserDefaults ?? self._standardUserDefaults), - date: (date ?? self._date) - ) - } -} -// MARK: - Convenience - -fileprivate func getValueSettingIfNull(_ maybeValue: inout T?, _ valueGenerator: () -> T) -> T { - guard let value: T = maybeValue else { - let value: T = valueGenerator() - maybeValue = value + internal static func getValueSettingIfNull(_ maybeValue: inout T?, _ valueGenerator: () -> T) -> T { + guard let value: T = maybeValue else { + let value: T = valueGenerator() + maybeValue = value + return value + } + return value } - - return value } diff --git a/SessionMessagingKitTests/Open Groups/Models/OpenGroupSpec.swift b/SessionMessagingKitTests/Open Groups/Models/OpenGroupSpec.swift index b36ce7a48..b6d310e8b 100644 --- a/SessionMessagingKitTests/Open Groups/Models/OpenGroupSpec.swift +++ b/SessionMessagingKitTests/Open Groups/Models/OpenGroupSpec.swift @@ -71,6 +71,23 @@ class OpenGroupSpec: QuickSpec { .to(equal("name (Server: server, Room: room)")) } } + + context("when describing in debug") { + it("includes relevant information") { + let openGroup: OpenGroup = OpenGroup( + server: "server", + room: "room", + publicKey: "1234", + name: "name", + groupDescription: nil, + imageID: nil, + infoUpdates: 0 + ) + + expect(openGroup.debugDescription) + .to(equal("OpenGroup(server: \"server\", room: \"room\", id: \"server.room\", publicKey: \"1234\", name: \"name\", groupDescription: null, imageID: null, infoUpdates: 0)")) + } + } } } } diff --git a/SessionMessagingKitTests/Open Groups/Models/RoomPollInfoSpec.swift b/SessionMessagingKitTests/Open Groups/Models/RoomPollInfoSpec.swift index b58227f43..4d5156ef3 100644 --- a/SessionMessagingKitTests/Open Groups/Models/RoomPollInfoSpec.swift +++ b/SessionMessagingKitTests/Open Groups/Models/RoomPollInfoSpec.swift @@ -17,7 +17,7 @@ class RoomPollInfoSpec: QuickSpec { let room: OpenGroupAPI.Room = OpenGroupAPI.Room( token: "testToken", name: "testName", - description: nil, + roomDescription: nil, infoUpdates: 123, messageSequence: 0, created: 0, diff --git a/SessionMessagingKitTests/Open Groups/Models/SOGSMessageSpec.swift b/SessionMessagingKitTests/Open Groups/Models/SOGSMessageSpec.swift index 025f4b058..ca36ffcd8 100644 --- a/SessionMessagingKitTests/Open Groups/Models/SOGSMessageSpec.swift +++ b/SessionMessagingKitTests/Open Groups/Models/SOGSMessageSpec.swift @@ -203,7 +203,9 @@ class SOGSMessageSpec: QuickSpec { } it("succeeds if it succeeds verification") { - mockSign.when { $0.verify(message: any(), publicKey: any(), signature: any()) }.thenReturn(true) + mockSign + .when { $0.verify(message: anyArray(), publicKey: anyArray(), signature: anyArray()) } + .thenReturn(true) expect { try decoder.decode(OpenGroupAPI.Message.self, from: messageData) @@ -212,7 +214,9 @@ class SOGSMessageSpec: QuickSpec { } it("provides the correct values as parameters") { - mockSign.when { $0.verify(message: any(), publicKey: any(), signature: any()) }.thenReturn(true) + mockSign + .when { $0.verify(message: anyArray(), publicKey: anyArray(), signature: anyArray()) } + .thenReturn(true) _ = try? decoder.decode(OpenGroupAPI.Message.self, from: messageData) @@ -227,7 +231,9 @@ class SOGSMessageSpec: QuickSpec { } it("throws if it fails verification") { - mockSign.when { $0.verify(message: any(), publicKey: any(), signature: any()) }.thenReturn(false) + mockSign + .when { $0.verify(message: anyArray(), publicKey: anyArray(), signature: anyArray()) } + .thenReturn(false) expect { try decoder.decode(OpenGroupAPI.Message.self, from: messageData) diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift index 12fa35142..bf2c37c24 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift @@ -62,12 +62,12 @@ class OpenGroupAPISpec: QuickSpec { mockStorage .when { $0.write(with: { _ in }) } - .then { args in (args.first as? ((Any) -> Void))?(any()) } + .then { args in (args.first as? ((Any) -> Void))?(anyAny()) } .thenReturn(Promise.value(())) mockStorage .when { $0.write(with: { _ in }, completion: { }) } .then { args in - (args.first as? ((Any) -> Void))?(any()) + (args.first as? ((Any) -> Void))?(anyAny()) (args.last as? (() -> Void))?() } .thenReturn(Promise.value(())) @@ -114,9 +114,9 @@ class OpenGroupAPISpec: QuickSpec { mockStorage.when { $0.getOpenGroupSequenceNumber(for: any(), on: any()) }.thenReturn(nil) mockStorage.when { $0.getOpenGroupInboxLatestMessageId(for: any()) }.thenReturn(nil) mockStorage.when { $0.getOpenGroupOutboxLatestMessageId(for: any()) }.thenReturn(nil) - mockStorage.when { $0.addReceivedMessageTimestamp(any(), using: any()) }.thenReturn(()) + mockStorage.when { $0.addReceivedMessageTimestamp(any(), using: anyAny()) }.thenReturn(()) - mockGenericHash.when { $0.hash(message: any(), outputLength: any()) }.thenReturn([]) + mockGenericHash.when { $0.hash(message: anyArray(), outputLength: any()) }.thenReturn([]) mockSodium .when { $0.blindedKeyPair(serverPublicKey: any(), edKeyPair: any(), genericHash: mockGenericHash) } .thenReturn( @@ -128,15 +128,15 @@ class OpenGroupAPISpec: QuickSpec { mockSodium .when { $0.sogsSignature( - message: any(), - secretKey: any(), - blindedSecretKey: any(), - blindedPublicKey: any() + message: anyArray(), + secretKey: anyArray(), + blindedSecretKey: anyArray(), + blindedPublicKey: anyArray() ) } .thenReturn("TestSogsSignature".bytes) - mockSign.when { $0.signature(message: any(), secretKey: any()) }.thenReturn("TestSignature".bytes) - mockEd25519.when { try $0.sign(data: any(), keyPair: any()) }.thenReturn("TestStandardSignature".bytes) + mockSign.when { $0.signature(message: anyArray(), secretKey: anyArray()) }.thenReturn("TestSignature".bytes) + mockEd25519.when { try $0.sign(data: anyArray(), keyPair: any()) }.thenReturn("TestStandardSignature".bytes) } afterEach { @@ -195,22 +195,6 @@ class OpenGroupAPISpec: QuickSpec { body: [OpenGroupAPI.Message](), failedToParseBody: false ) - ), - try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( - code: 200, - headers: [:], - body: [OpenGroupAPI.DirectMessage](), - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( - code: 200, - headers: [:], - body: [OpenGroupAPI.DirectMessage](), - failedToParseBody: false - ) ) ] @@ -235,12 +219,10 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate the response data - expect(pollResponse?.values).to(haveCount(5)) + expect(pollResponse?.values).to(haveCount(3)) expect(pollResponse?.keys).to(contain(.capabilities)) expect(pollResponse?.keys).to(contain(.roomPollInfo("testRoom", 0))) expect(pollResponse?.keys).to(contain(.roomMessagesRecent("testRoom"))) - expect(pollResponse?.keys).to(contain(.inbox)) - expect(pollResponse?.keys).to(contain(.outbox)) expect(pollResponse?[.capabilities]?.0).to(beAKindOf(TestOnionRequestAPI.ResponseInfo.self)) // Validate request data @@ -341,96 +323,275 @@ class OpenGroupAPISpec: QuickSpec { expect(pollResponse?.keys).to(contain(.roomMessagesSince("testRoom", seqNo: 123))) } - it("retrieves recent inbox messages if there was no last message") { - OpenGroupAPI - .poll( - "testServer", - hasPerformedInitialPoll: true, - timeSinceLastPoll: 0, - using: dependencies - ) - .get { result in pollResponse = result } - .catch { requestError in error = requestError } - .retainUntilComplete() - - expect(pollResponse) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - expect(pollResponse?.keys).to(contain(.inbox)) + context("when unblinded") { + beforeEach { + mockStorage + .when { $0.getOpenGroupServer(name: any()) } + .thenReturn( + OpenGroupAPI.Server( + name: "testServer", + capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: []) + ) + ) + } + + it("does not call the inbox and outbox endpoints") { + OpenGroupAPI.poll("testServer", hasPerformedInitialPoll: false, timeSinceLastPoll: 0, using: dependencies) + .get { result in pollResponse = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(pollResponse) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate the response data + expect(pollResponse?.keys).toNot(contain(.inbox)) + expect(pollResponse?.keys).toNot(contain(.outbox)) + } } - it("retrieves inbox messages since the last message if there was one") { - mockStorage.when { $0.getOpenGroupInboxLatestMessageId(for: any()) }.thenReturn(124) - - OpenGroupAPI - .poll( - "testServer", - hasPerformedInitialPoll: true, - timeSinceLastPoll: 0, - using: dependencies - ) - .get { result in pollResponse = result } - .catch { requestError in error = requestError } - .retainUntilComplete() - - expect(pollResponse) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - expect(pollResponse?.keys).to(contain(.inboxSince(id: 124))) - } + context("when blinded") { + beforeEach { + class TestApi: TestOnionRequestAPI { + override class var mockResponse: Data? { + let responses: [Data] = [ + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: OpenGroupAPI.Capabilities(capabilities: [], missing: nil), + failedToParseBody: false + ) + ), + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: try! JSONDecoder().decode( + OpenGroupAPI.RoomPollInfo.self, + from: """ + { + \"token\":\"test\", + \"active_users\":1, + \"read\":true, + \"write\":true, + \"upload\":true + } + """.data(using: .utf8)! + ), + failedToParseBody: false + ) + ), + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: [OpenGroupAPI.Message](), + failedToParseBody: false + ) + ), + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: [OpenGroupAPI.DirectMessage](), + failedToParseBody: false + ) + ), + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: [OpenGroupAPI.DirectMessage](), + failedToParseBody: false + ) + ) + ] + + return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) + } + } + + dependencies = dependencies.with(onionApi: TestApi.self) + + mockStorage + .when { $0.getOpenGroupServer(name: any()) } + .thenReturn( + OpenGroupAPI.Server( + name: "testServer", + capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs, .blind], missing: []) + ) + ) + } - it("retrieves recent outbox messages if there was no last message") { - OpenGroupAPI - .poll( - "testServer", - hasPerformedInitialPoll: true, - timeSinceLastPoll: 0, - using: dependencies - ) - .get { result in pollResponse = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + it("includes the inbox and outbox endpoints") { + OpenGroupAPI.poll("testServer", hasPerformedInitialPoll: false, timeSinceLastPoll: 0, using: dependencies) + .get { result in pollResponse = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(pollResponse) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate the response data + expect(pollResponse?.keys).to(contain(.inbox)) + expect(pollResponse?.keys).to(contain(.outbox)) + } - expect(pollResponse) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - expect(pollResponse?.keys).to(contain(.outbox)) - } - - it("retrieves outbox messages since the last message if there was one") { - mockStorage.when { $0.getOpenGroupOutboxLatestMessageId(for: any()) }.thenReturn(125) + it("retrieves recent inbox messages if there was no last message") { + OpenGroupAPI + .poll( + "testServer", + hasPerformedInitialPoll: true, + timeSinceLastPoll: 0, + using: dependencies + ) + .get { result in pollResponse = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(pollResponse) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + expect(pollResponse?.keys).to(contain(.inbox)) + } - OpenGroupAPI - .poll( - "testServer", - hasPerformedInitialPoll: true, - timeSinceLastPoll: 0, - using: dependencies - ) - .get { result in pollResponse = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + it("retrieves inbox messages since the last message if there was one") { + mockStorage.when { $0.getOpenGroupInboxLatestMessageId(for: any()) }.thenReturn(124) + + OpenGroupAPI + .poll( + "testServer", + hasPerformedInitialPoll: true, + timeSinceLastPoll: 0, + using: dependencies + ) + .get { result in pollResponse = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(pollResponse) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + expect(pollResponse?.keys).to(contain(.inboxSince(id: 124))) + } - expect(pollResponse) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - expect(pollResponse?.keys).to(contain(.outboxSince(id: 125))) + it("retrieves recent outbox messages if there was no last message") { + OpenGroupAPI + .poll( + "testServer", + hasPerformedInitialPoll: true, + timeSinceLastPoll: 0, + using: dependencies + ) + .get { result in pollResponse = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(pollResponse) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + expect(pollResponse?.keys).to(contain(.outbox)) + } + + it("retrieves outbox messages since the last message if there was one") { + mockStorage.when { $0.getOpenGroupOutboxLatestMessageId(for: any()) }.thenReturn(125) + + OpenGroupAPI + .poll( + "testServer", + hasPerformedInitialPoll: true, + timeSinceLastPoll: 0, + using: dependencies + ) + .get { result in pollResponse = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(pollResponse) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + expect(pollResponse?.keys).to(contain(.outboxSince(id: 125))) + } } } context("and given an invalid response") { + it("succeeds but flags the bodies it failed to parse when an unexpected response is returned") { + class TestApi: TestOnionRequestAPI { + override class var mockResponse: Data? { + let responses: [Data] = [ + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: OpenGroupAPI.Capabilities(capabilities: [], missing: nil), + failedToParseBody: false + ) + ), + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: ""), + failedToParseBody: false + ) + ), + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: ""), + failedToParseBody: false + ) + ) + ] + + return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) + } + } + dependencies = dependencies.with(onionApi: TestApi.self) + + OpenGroupAPI.poll("testServer", hasPerformedInitialPoll: false, timeSinceLastPoll: 0, using: dependencies) + .get { result in pollResponse = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(pollResponse) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + let capabilitiesResponse: OpenGroupAPI.BatchSubResponse? = (pollResponse?[.capabilities]?.1 as? OpenGroupAPI.BatchSubResponse) + let pollInfoResponse: OpenGroupAPI.BatchSubResponse? = (pollResponse?[.roomPollInfo("testRoom", 0)]?.1 as? OpenGroupAPI.BatchSubResponse) + let messagesResponse: OpenGroupAPI.BatchSubResponse<[Failable]>? = (pollResponse?[.roomMessagesRecent("testRoom")]?.1 as? OpenGroupAPI.BatchSubResponse<[Failable]>) + expect(capabilitiesResponse?.failedToParseBody).to(beFalse()) + expect(pollInfoResponse?.failedToParseBody).to(beTrue()) + expect(messagesResponse?.failedToParseBody).to(beTrue()) + } + it("errors when no data is returned") { OpenGroupAPI.poll("testServer", hasPerformedInitialPoll: false, timeSinceLastPoll: 0, using: dependencies) .get { result in pollResponse = result } @@ -557,55 +718,6 @@ class OpenGroupAPISpec: QuickSpec { expect(pollResponse).to(beNil()) } - - it("errors when an unexpected response is returned") { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { - let responses: [Data] = [ - try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( - code: 200, - headers: [:], - body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: ""), - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( - code: 200, - headers: [:], - body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: ""), - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( - code: 200, - headers: [:], - body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: ""), - failedToParseBody: false - ) - ) - ] - - return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) - } - } - dependencies = dependencies.with(onionApi: TestApi.self) - - OpenGroupAPI.poll("testServer", hasPerformedInitialPoll: false, timeSinceLastPoll: 0, using: dependencies) - .get { result in pollResponse = result } - .catch { requestError in error = requestError } - .retainUntilComplete() - - expect(error?.localizedDescription) - .toEventually( - equal(HTTP.Error.parsingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(pollResponse).to(beNil()) - } } } @@ -654,7 +766,7 @@ class OpenGroupAPISpec: QuickSpec { OpenGroupAPI.Room( token: "test", name: "test", - description: nil, + roomDescription: nil, infoUpdates: 0, messageSequence: 0, created: 0, @@ -719,7 +831,7 @@ class OpenGroupAPISpec: QuickSpec { static let roomData: OpenGroupAPI.Room = OpenGroupAPI.Room( token: "test", name: "test", - description: nil, + roomDescription: nil, infoUpdates: 0, messageSequence: 0, created: 0, @@ -838,7 +950,7 @@ class OpenGroupAPISpec: QuickSpec { static let roomData: OpenGroupAPI.Room = OpenGroupAPI.Room( token: "test", name: "test", - description: nil, + roomDescription: nil, infoUpdates: 0, messageSequence: 0, created: 0, @@ -902,7 +1014,7 @@ class OpenGroupAPISpec: QuickSpec { static let roomData: OpenGroupAPI.Room = OpenGroupAPI.Room( token: "test", name: "test", - description: nil, + roomDescription: nil, infoUpdates: 0, messageSequence: 0, created: 0, @@ -1067,7 +1179,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) expect(mockStorage) .to(call(matchingParameters: true) { - $0.addReceivedMessageTimestamp(321000, using: any()) + $0.addReceivedMessageTimestamp(321000, using: anyAny()) }) } @@ -1173,7 +1285,7 @@ class OpenGroupAPISpec: QuickSpec { it("fails to sign if no signature is generated") { mockEd25519.reset() // The 'keyPair' value doesn't equate so have to explicitly reset - mockEd25519.when { try $0.sign(data: any(), keyPair: any()) }.thenReturn(nil) + mockEd25519.when { try $0.sign(data: anyArray(), keyPair: any()) }.thenReturn(nil) var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? @@ -1303,7 +1415,14 @@ class OpenGroupAPISpec: QuickSpec { it("fails to sign if no signature is generated") { mockSodium - .when { $0.sogsSignature(message: any(), secretKey: any(), blindedSecretKey: any(), blindedPublicKey: any()) } + .when { + $0.sogsSignature( + message: anyArray(), + secretKey: anyArray(), + blindedSecretKey: anyArray(), + blindedPublicKey: anyArray() + ) + } .thenReturn(nil) var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? @@ -1517,7 +1636,7 @@ class OpenGroupAPISpec: QuickSpec { it("fails to sign if no signature is generated") { mockEd25519.reset() // The 'keyPair' value doesn't equate so have to explicitly reset - mockEd25519.when { try $0.sign(data: any(), keyPair: any()) }.thenReturn(nil) + mockEd25519.when { try $0.sign(data: anyArray(), keyPair: any()) }.thenReturn(nil) var response: (info: OnionRequestResponseInfoType, data: Data?)? @@ -1643,7 +1762,14 @@ class OpenGroupAPISpec: QuickSpec { it("fails to sign if no signature is generated") { mockSodium - .when { $0.sogsSignature(message: any(), secretKey: any(), blindedSecretKey: any(), blindedPublicKey: any()) } + .when { + $0.sogsSignature( + message: anyArray(), + secretKey: anyArray(), + blindedSecretKey: anyArray(), + blindedPublicKey: anyArray() + ) + } .thenReturn(nil) var response: (info: OnionRequestResponseInfoType, data: Data?)? @@ -2020,7 +2146,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) expect(mockStorage) .to(call(matchingParameters: true) { - $0.addReceivedMessageTimestamp(321000, using: any()) + $0.addReceivedMessageTimestamp(321000, using: anyAny()) }) } } @@ -2548,7 +2674,7 @@ class OpenGroupAPISpec: QuickSpec { } it("fails when the signature is not generated") { - mockSign.when { $0.signature(message: any(), secretKey: any()) }.thenReturn(nil) + mockSign.when { $0.signature(message: anyArray(), secretKey: anyArray()) }.thenReturn(nil) OpenGroupAPI.rooms(for: "testServer", using: dependencies) .get { result in response = result } diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift index 75d34aeb0..edbbee882 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -9,14 +9,16 @@ import Nimble @testable import SessionMessagingKit +// MARK: - OpenGroupManagerSpec + class OpenGroupManagerSpec: QuickSpec { class TestCapabilitiesAndRoomApi: TestOnionRequestAPI { static let capabilitiesData: OpenGroupAPI.Capabilities = OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: nil) static let roomData: OpenGroupAPI.Room = OpenGroupAPI.Room( token: "test", name: "test", - description: nil, - infoUpdates: 0, + roomDescription: nil, + infoUpdates: 10, messageSequence: 0, created: 0, activeUsers: 0, @@ -67,29 +69,37 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: - Spec override func spec() { + var mockOGMCache: MockOGMCache! var mockStorage: MockStorage! var mockSodium: MockSodium! var mockAeadXChaCha20Poly1305Ietf: MockAeadXChaCha20Poly1305Ietf! var mockGenericHash: MockGenericHash! var mockSign: MockSign! var mockUserDefaults: MockUserDefaults! - var dependencies: Dependencies! + var dependencies: OpenGroupManager.OGMDependencies! var testInteraction: TestInteraction! var testGroupThread: TestGroupThread! var testTransaction: TestTransaction! + var testOpenGroup: OpenGroup! + var testPollInfo: OpenGroupAPI.RoomPollInfo! + + var cache: OpenGroupManager.Cache! + var openGroupManager: OpenGroupManager! - describe("an OpenGroupAPI") { + describe("an OpenGroupManager") { // MARK: - Configuration beforeEach { + mockOGMCache = MockOGMCache() mockStorage = MockStorage() mockSodium = MockSodium() mockAeadXChaCha20Poly1305Ietf = MockAeadXChaCha20Poly1305Ietf() mockGenericHash = MockGenericHash() mockSign = MockSign() mockUserDefaults = MockUserDefaults() - dependencies = Dependencies( + dependencies = OpenGroupManager.OGMDependencies( + cache: Atomic(mockOGMCache), onionApi: TestCapabilitiesAndRoomApi.self, storage: mockStorage, sodium: mockSodium, @@ -107,6 +117,7 @@ class OpenGroupManagerSpec: QuickSpec { testInteraction.mockData[.timestamp] = UInt64(123) testGroupThread = TestGroupThread() + testGroupThread.mockData[.uniqueId] = "TestGroupId" testGroupThread.mockData[.groupModel] = TSGroupModel( title: "TestTitle", memberIds: [], @@ -121,6 +132,32 @@ class OpenGroupManagerSpec: QuickSpec { testTransaction = TestTransaction() testTransaction.mockData[.objectForKey] = testGroupThread + testOpenGroup = OpenGroup( + server: "testServer", + room: "testRoom", + publicKey: TestConstants.publicKey, + name: "Test", + groupDescription: nil, + imageID: nil, + infoUpdates: 10 + ) + testPollInfo = OpenGroupAPI.RoomPollInfo( + token: "testRoom", + activeUsers: 10, + admin: false, + globalAdmin: false, + moderator: false, + globalModerator: false, + read: false, + defaultRead: nil, + defaultAccessible: nil, + write: false, + defaultWrite: nil, + upload: false, + defaultUpload: nil, + details: TestCapabilitiesAndRoomApi.roomData + ) + mockStorage .when { $0.write(with: { _ in }) } .then { args in (args.first as? ((Any) -> Void))?(testTransaction as Any) } @@ -151,29 +188,11 @@ class OpenGroupManagerSpec: QuickSpec { mockStorage .when { $0.getAllOpenGroups() } .thenReturn([ - "0": OpenGroup( - server: "testServer", - room: "testRoom", - publicKey: TestConstants.publicKey, - name: "Test", - groupDescription: nil, - imageID: nil, - infoUpdates: 0 - ) + "0": testOpenGroup ]) mockStorage .when { $0.getOpenGroup(for: any()) } - .thenReturn( - OpenGroup( - server: "testServer", - room: "testRoom", - publicKey: TestConstants.publicKey, - name: "Test", - groupDescription: nil, - imageID: nil, - infoUpdates: 0 - ) - ) + .thenReturn(testOpenGroup) mockStorage .when { $0.getOpenGroupServer(name: any()) } .thenReturn( @@ -186,7 +205,7 @@ class OpenGroupManagerSpec: QuickSpec { .when { $0.getOpenGroupPublicKey(for: any()) } .thenReturn(TestConstants.publicKey) - mockGenericHash.when { $0.hash(message: any(), outputLength: any()) }.thenReturn([]) + mockGenericHash.when { $0.hash(message: anyArray(), outputLength: any()) }.thenReturn([]) mockSodium .when { $0.blindedKeyPair(serverPublicKey: any(), edKeyPair: any(), genericHash: mockGenericHash) } .thenReturn( @@ -198,19 +217,24 @@ class OpenGroupManagerSpec: QuickSpec { mockSodium .when { $0.sogsSignature( - message: any(), - secretKey: any(), - blindedSecretKey: any(), - blindedPublicKey: any() + message: anyArray(), + secretKey: anyArray(), + blindedSecretKey: anyArray(), + blindedPublicKey: anyArray() ) } .thenReturn("TestSogsSignature".bytes) - mockSign.when { $0.signature(message: any(), secretKey: any()) }.thenReturn("TestSignature".bytes) + mockSign.when { $0.signature(message: anyArray(), secretKey: anyArray()) }.thenReturn("TestSignature".bytes) + + cache = OpenGroupManager.Cache() + openGroupManager = OpenGroupManager() } afterEach { OpenGroupManager.shared.stopPolling() // Need to stop any pollers which get created during tests + openGroupManager.stopPolling() // Assuming it's different from the above + mockOGMCache = nil mockStorage = nil mockSodium = nil mockAeadXChaCha20Poly1305Ietf = nil @@ -222,6 +246,50 @@ class OpenGroupManagerSpec: QuickSpec { testInteraction = nil testGroupThread = nil testTransaction = nil + testOpenGroup = nil + + openGroupManager = nil + } + + // MARK: - Cache + + context("cache data") { + it("defaults the time since last open to greatestFiniteMagnitude") { + mockUserDefaults + .when { $0.object(forKey: SNUserDefaults.Date.lastOpen.rawValue) } + .thenReturn(nil) + + expect(cache.getTimeSinceLastOpen(using: dependencies)) + .to(beCloseTo(.greatestFiniteMagnitude)) + } + + it("returns the time since the last open") { + mockUserDefaults + .when { $0.object(forKey: SNUserDefaults.Date.lastOpen.rawValue) } + .thenReturn(Date(timeIntervalSince1970: 1234567880)) + dependencies = dependencies.with(date: Date(timeIntervalSince1970: 1234567890)) + + expect(cache.getTimeSinceLastOpen(using: dependencies)) + .to(beCloseTo(10)) + } + + it("caches the time since the last open") { + mockUserDefaults + .when { $0.object(forKey: SNUserDefaults.Date.lastOpen.rawValue) } + .thenReturn(Date(timeIntervalSince1970: 1234567770)) + dependencies = dependencies.with(date: Date(timeIntervalSince1970: 1234567780)) + + expect(cache.getTimeSinceLastOpen(using: dependencies)) + .to(beCloseTo(10)) + + mockUserDefaults + .when { $0.object(forKey: SNUserDefaults.Date.lastOpen.rawValue) } + .thenReturn(Date(timeIntervalSince1970: 1234567890)) + + // Cached value shouldn't have been updated + expect(cache.getTimeSinceLastOpen(using: dependencies)) + .to(beCloseTo(10)) + } } // MARK: - Polling @@ -231,15 +299,7 @@ class OpenGroupManagerSpec: QuickSpec { mockStorage .when { $0.getAllOpenGroups() } .thenReturn([ - "0": OpenGroup( - server: "testServer", - room: "testRoom", - publicKey: TestConstants.publicKey, - name: "Test", - groupDescription: nil, - imageID: nil, - infoUpdates: 0 - ), + "0": testOpenGroup, "1": OpenGroup( server: "testServer1", room: "testRoom1", @@ -250,39 +310,50 @@ class OpenGroupManagerSpec: QuickSpec { infoUpdates: 0 ) ]) - mockStorage.when { $0.removeOpenGroupSequenceNumber(for: any(), on: any(), using: any()) }.thenReturn(()) - mockStorage.when { $0.setOpenGroupPublicKey(for: any(), to: any(), using: any()) }.thenReturn(()) - mockStorage.when { $0.setOpenGroupServer(any(), using: any()) }.thenReturn(()) - mockStorage.when { $0.setOpenGroup(any(), for: any(), using: any()) }.thenReturn(()) - mockStorage.when { $0.setUserCount(to: any(), forOpenGroupWithID: any(), using: any()) }.thenReturn(()) + mockStorage.when { $0.removeOpenGroupSequenceNumber(for: any(), on: any(), using: anyAny()) }.thenReturn(()) + mockStorage.when { $0.setOpenGroupPublicKey(for: any(), to: any(), using: anyAny()) }.thenReturn(()) + mockStorage.when { $0.setOpenGroupServer(any(), using: anyAny()) }.thenReturn(()) + mockStorage.when { $0.setOpenGroup(any(), for: any(), using: anyAny()) }.thenReturn(()) + mockStorage.when { $0.setUserCount(to: any(), forOpenGroupWithID: any(), using: anyAny()) }.thenReturn(()) mockStorage.when { $0.getOpenGroupInboxLatestMessageId(for: any()) }.thenReturn(nil) mockStorage.when { $0.getOpenGroupOutboxLatestMessageId(for: any()) }.thenReturn(nil) mockStorage.when { $0.getOpenGroupSequenceNumber(for: any(), on: any()) }.thenReturn(nil) + mockOGMCache.when { $0.hasPerformedInitialPoll }.thenReturn([:]) + mockOGMCache.when { $0.timeSinceLastPoll }.thenReturn([:]) + mockOGMCache.when { $0.getTimeSinceLastOpen(using: dependencies) }.thenReturn(0) + mockOGMCache.when { $0.isPolling }.thenReturn(false) + mockOGMCache.when { $0.pollers }.thenReturn([:]) + mockUserDefaults .when { $0.object(forKey: SNUserDefaults.Date.lastOpen.rawValue) } .thenReturn(Date(timeIntervalSince1970: 1234567890)) } it("creates pollers for all of the open groups") { - OpenGroupManager.shared.startPolling(using: dependencies) + openGroupManager.startPolling(using: dependencies) - expect(OpenGroupManager.shared.cache.pollers.keys.map { String($0) }.sorted) - .to(equal(["testserver", "testserver1"])) + expect(mockOGMCache) + .to(call(matchingParameters: true) { + $0.pollers = [ + "testserver": OpenGroupAPI.Poller(for: "testserver"), + "testserver1": OpenGroupAPI.Poller(for: "testserver1") + ] + }) } it("updates the isPolling flag") { - OpenGroupManager.shared.startPolling(using: dependencies) + openGroupManager.startPolling(using: dependencies) - expect(OpenGroupManager.shared.cache.isPolling).to(beTrue()) + expect(mockOGMCache).to(call(matchingParameters: true) { $0.isPolling = true }) } it("does nothing if already polling") { - OpenGroupManager.shared.mutableCache.mutate { $0.isPolling = true } - OpenGroupManager.shared.startPolling(using: dependencies) + mockOGMCache.when { $0.isPolling }.thenReturn(true) - expect(OpenGroupManager.shared.cache.pollers.keys.map { String($0) }) - .to(equal([])) + openGroupManager.startPolling(using: dependencies) + + expect(mockOGMCache).toNot(call { $0.pollers }) } } @@ -291,15 +362,7 @@ class OpenGroupManagerSpec: QuickSpec { mockStorage .when { $0.getAllOpenGroups() } .thenReturn([ - "0": OpenGroup( - server: "testServer", - room: "testRoom", - publicKey: TestConstants.publicKey, - name: "Test", - groupDescription: nil, - imageID: nil, - infoUpdates: 0 - ), + "0": testOpenGroup, "1": OpenGroup( server: "testServer1", room: "testRoom1", @@ -310,33 +373,35 @@ class OpenGroupManagerSpec: QuickSpec { infoUpdates: 0 ) ]) - mockStorage.when { $0.removeOpenGroupSequenceNumber(for: any(), on: any(), using: any()) }.thenReturn(()) - mockStorage.when { $0.setOpenGroupPublicKey(for: any(), to: any(), using: any()) }.thenReturn(()) - mockStorage.when { $0.setOpenGroupServer(any(), using: any()) }.thenReturn(()) - mockStorage.when { $0.setOpenGroup(any(), for: any(), using: any()) }.thenReturn(()) - mockStorage.when { $0.setUserCount(to: any(), forOpenGroupWithID: any(), using: any()) }.thenReturn(()) + mockStorage.when { $0.removeOpenGroupSequenceNumber(for: any(), on: any(), using: anyAny()) }.thenReturn(()) + mockStorage.when { $0.setOpenGroupPublicKey(for: any(), to: any(), using: anyAny()) }.thenReturn(()) + mockStorage.when { $0.setOpenGroupServer(any(), using: anyAny()) }.thenReturn(()) + mockStorage.when { $0.setOpenGroup(any(), for: any(), using: anyAny()) }.thenReturn(()) + mockStorage.when { $0.setUserCount(to: any(), forOpenGroupWithID: any(), using: anyAny()) }.thenReturn(()) mockStorage.when { $0.getOpenGroupInboxLatestMessageId(for: any()) }.thenReturn(nil) mockStorage.when { $0.getOpenGroupOutboxLatestMessageId(for: any()) }.thenReturn(nil) mockStorage.when { $0.getOpenGroupSequenceNumber(for: any(), on: any()) }.thenReturn(nil) + mockOGMCache.when { $0.isPolling }.thenReturn(true) + mockOGMCache.when { $0.pollers }.thenReturn(["testserver": OpenGroupAPI.Poller(for: "testserver")]) + mockUserDefaults .when { $0.object(forKey: SNUserDefaults.Date.lastOpen.rawValue) } .thenReturn(Date(timeIntervalSince1970: 1234567890)) - OpenGroupManager.shared.startPolling(using: dependencies) + openGroupManager.startPolling(using: dependencies) } it("removes all pollers") { - OpenGroupManager.shared.stopPolling() + openGroupManager.stopPolling(using: dependencies) - expect(OpenGroupManager.shared.cache.pollers.keys.map { String($0) }) - .to(equal([])) + expect(mockOGMCache).to(call(matchingParameters: true) { $0.pollers = [:] }) } it("updates the isPolling flag") { - OpenGroupManager.shared.stopPolling() + openGroupManager.stopPolling(using: dependencies) - expect(OpenGroupManager.shared.cache.isPolling).to(beFalse()) + expect(mockOGMCache).to(call(matchingParameters: true) { $0.isPolling = false }) } } @@ -344,22 +409,26 @@ class OpenGroupManagerSpec: QuickSpec { context("when adding") { beforeEach { - mockStorage.when { $0.removeOpenGroupSequenceNumber(for: any(), on: any(), using: any()) }.thenReturn(()) - mockStorage.when { $0.setOpenGroupPublicKey(for: any(), to: any(), using: any()) }.thenReturn(()) - mockStorage.when { $0.setOpenGroupServer(any(), using: any()) }.thenReturn(()) - mockStorage.when { $0.setOpenGroup(any(), for: any(), using: any()) }.thenReturn(()) - mockStorage.when { $0.setUserCount(to: any(), forOpenGroupWithID: any(), using: any()) }.thenReturn(()) + mockStorage.when { $0.removeOpenGroupSequenceNumber(for: any(), on: any(), using: anyAny()) }.thenReturn(()) + mockStorage.when { $0.setOpenGroupPublicKey(for: any(), to: any(), using: anyAny()) }.thenReturn(()) + mockStorage.when { $0.setOpenGroupServer(any(), using: anyAny()) }.thenReturn(()) + mockStorage.when { $0.setOpenGroup(any(), for: any(), using: anyAny()) }.thenReturn(()) + mockStorage.when { $0.setUserCount(to: any(), forOpenGroupWithID: any(), using: anyAny()) }.thenReturn(()) mockStorage.when { $0.getOpenGroupInboxLatestMessageId(for: any()) }.thenReturn(nil) mockStorage.when { $0.getOpenGroupOutboxLatestMessageId(for: any()) }.thenReturn(nil) mockStorage.when { $0.getOpenGroupSequenceNumber(for: any(), on: any()) }.thenReturn(nil) + mockOGMCache.when { $0.pollers }.thenReturn([:]) + mockOGMCache.when { $0.moderators }.thenReturn([:]) + mockOGMCache.when { $0.admins }.thenReturn([:]) + mockUserDefaults .when { $0.object(forKey: SNUserDefaults.Date.lastOpen.rawValue) } .thenReturn(Date(timeIntervalSince1970: 1234567890)) } it("resets the sequence number of the open group") { - OpenGroupManager.shared + openGroupManager .add( roomToken: "testRoom", server: "testServer", @@ -383,7 +452,7 @@ class OpenGroupManagerSpec: QuickSpec { } it("sets the public key of the open group server") { - OpenGroupManager.shared + openGroupManager .add( roomToken: "testRoom", server: "testServer", @@ -407,7 +476,7 @@ class OpenGroupManagerSpec: QuickSpec { } it("adds a poller") { - OpenGroupManager.shared + openGroupManager .add( roomToken: "testRoom", server: "testServer", @@ -418,39 +487,22 @@ class OpenGroupManagerSpec: QuickSpec { ) .retainUntilComplete() - expect(OpenGroupManager.shared.cache.pollers["testServer"]) - .toEventuallyNot( - beNil(), + expect(mockOGMCache) + .toEventually( + call(matchingParameters: true) { + $0.pollers = ["testServer": OpenGroupAPI.Poller(for: "testServer")] + }, timeout: .milliseconds(100) ) } context("an existing room") { beforeEach { - OpenGroupManager.shared - .add( - roomToken: "testRoom", - server: "testServer", - publicKey: "testKey", - isConfigMessage: false, - using: testTransaction, - dependencies: dependencies - ) - .retainUntilComplete() - - // There is a bunch of async code in this function so we need to wait until if finishes - // processing before doing the actual tests - expect(mockStorage) - .toEventually( - call { $0.setOpenGroup(any(), for: "testServer", using: testTransaction as Any) }, - timeout: .milliseconds(100) - ) - - mockStorage.resetCallCounts() + mockOGMCache.when { $0.pollers }.thenReturn(["testServer": OpenGroupAPI.Poller(for: "testServer")]) } it("does not reset the sequence number or update the public key") { - OpenGroupManager.shared + openGroupManager .add( roomToken: "testRoom", server: "testServer", @@ -501,7 +553,7 @@ class OpenGroupManagerSpec: QuickSpec { it("fails with the error") { var error: Error? - let promise = OpenGroupManager.shared + let promise = openGroupManager .add( roomToken: "testRoom", server: "testServer", @@ -523,37 +575,1176 @@ class OpenGroupManagerSpec: QuickSpec { } context("when removing") { + beforeEach { + mockStorage + .when { $0.updateMessageIDCollectionByPruningMessagesWithIDs(anySet(), using: anyAny()) } + .thenReturn(()) + mockStorage.when { $0.removeReceivedMessageTimestamps(anySet(), using: anyAny()) }.thenReturn(()) + mockStorage.when { $0.removeOpenGroupSequenceNumber(for: any(), on: any(), using: anyAny()) }.thenReturn(()) + mockStorage.when { $0.removeOpenGroup(for: any(), using: anyAny()) }.thenReturn(()) + mockStorage.when { $0.removeOpenGroupServer(name: any(), using: anyAny()) }.thenReturn(()) + mockStorage.when { $0.removeOpenGroupPublicKey(for: any(), using: anyAny()) }.thenReturn(()) + + mockOGMCache.when { $0.pollers }.thenReturn([:]) + } + + it("removes messages for the given thread") { + openGroupManager + .delete( + testOpenGroup, + associatedWith: testGroupThread, + using: testTransaction, + dependencies: dependencies + ) + + expect(mockStorage) + .to(call(matchingParameters: true) { + $0.updateMessageIDCollectionByPruningMessagesWithIDs( + Set(arrayLiteral: testInteraction.uniqueId!), + using: testTransaction! as Any + ) + }) + } + + it("removes received timestamps for the given thread") { + openGroupManager + .delete( + testOpenGroup, + associatedWith: testGroupThread, + using: testTransaction, + dependencies: dependencies + ) + + expect(mockStorage) + .to(call(matchingParameters: true) { + $0.removeReceivedMessageTimestamps( + Set(arrayLiteral: testInteraction.timestamp), + using: testTransaction! as Any + ) + }) + } + + it("removes the sequence number for the given thread") { + openGroupManager + .delete( + testOpenGroup, + associatedWith: testGroupThread, + using: testTransaction, + dependencies: dependencies + ) + + expect(mockStorage) + .to(call(matchingParameters: true) { + $0.removeOpenGroupSequenceNumber( + for: "testRoom", + on: "testserver", + using: testTransaction! as Any + ) + }) + } + + it("removes all interactions for the given thread") { + openGroupManager + .delete( + testOpenGroup, + associatedWith: testGroupThread, + using: YapDatabaseReadWriteTransaction(), + dependencies: dependencies + ) + + expect(testGroupThread.didCallRemoveAllThreadInteractions).to(beTrue()) + } + + it("removes the given thread") { + openGroupManager + .delete( + testOpenGroup, + associatedWith: testGroupThread, + using: YapDatabaseReadWriteTransaction(), + dependencies: dependencies + ) + + expect(testGroupThread.didCallRemove).to(beTrue()) + } + + it("removes the open group") { + openGroupManager + .delete( + testOpenGroup, + associatedWith: testGroupThread, + using: testTransaction, + dependencies: dependencies + ) + + expect(mockStorage) + .to(call(matchingParameters: true) { + $0.removeOpenGroup( + for: testGroupThread.uniqueId!, + using: testTransaction! as Any + ) + }) + } + + context("and there is only one open group for this server") { + it("stops the poller") { + mockOGMCache.when { $0.pollers }.thenReturn(["testserver": OpenGroupAPI.Poller(for: "testserver")]) + + openGroupManager + .delete( + testOpenGroup, + associatedWith: testGroupThread, + using: testTransaction, + dependencies: dependencies + ) + + expect(mockOGMCache).to(call(matchingParameters: true) { $0.pollers = [:] }) + } + + it("removes the open group server") { + openGroupManager + .delete( + testOpenGroup, + associatedWith: testGroupThread, + using: testTransaction, + dependencies: dependencies + ) + + expect(mockStorage) + .to(call(matchingParameters: true) { + $0.removeOpenGroupServer( + name: "testserver", + using: testTransaction! as Any + ) + }) + } + + it("removes the open group public key") { + openGroupManager + .delete( + testOpenGroup, + associatedWith: testGroupThread, + using: testTransaction, + dependencies: dependencies + ) + + expect(mockStorage) + .to(call(matchingParameters: true) { + $0.removeOpenGroupPublicKey( + for: "testserver", + using: testTransaction! as Any + ) + }) + } + } + + context("and the are multiple open groups for this server") { + beforeEach { + mockStorage + .when { $0.getAllOpenGroups() } + .thenReturn([ + "0": testOpenGroup, + "1": OpenGroup( + server: "testServer", + room: "testRoom1", + publicKey: TestConstants.publicKey, + name: "Test1", + groupDescription: nil, + imageID: nil, + infoUpdates: 0 + ) + ]) + } + + it("does not stop the poller") { + mockOGMCache.when { $0.pollers }.thenReturn(["testserver": OpenGroupAPI.Poller(for: "testserver")]) + + openGroupManager + .delete( + testOpenGroup, + associatedWith: testGroupThread, + using: testTransaction, + dependencies: dependencies + ) + + expect(mockOGMCache).toNot(call { $0.pollers }) + } + + it("does not remove the open group server") { + openGroupManager + .delete( + testOpenGroup, + associatedWith: testGroupThread, + using: testTransaction, + dependencies: dependencies + ) + + expect(mockStorage).toNot(call { $0.removeOpenGroupServer(name: any(), using: anyAny()) }) + } + + it("does not remove the open group public key") { + openGroupManager + .delete( + testOpenGroup, + associatedWith: testGroupThread, + using: testTransaction, + dependencies: dependencies + ) + + expect(mockStorage).toNot(call { $0.removeOpenGroupPublicKey(for: any(), using: anyAny()) }) + } + } + } -// public func delete(_ openGroup: OpenGroup, associatedWith thread: TSThread, using transaction: YapDatabaseReadWriteTransaction, dependencies: Dependencies = Dependencies()) { -// // Stop the poller if needed -// let openGroups = dependencies.storage.getAllOpenGroups().values.filter { $0.server == openGroup.server } -// if openGroups.count == 1 && openGroups.last == openGroup { -// let poller = pollers[openGroup.server] -// poller?.stop() -// pollers[openGroup.server] = nil -// } -// -// // Remove all data -// var messageIDs: Set = [] -// var messageTimestamps: Set = [] -// thread.enumerateInteractions(with: transaction) { interaction, _ in -// messageIDs.insert(interaction.uniqueId!) -// messageTimestamps.insert(interaction.timestamp) -// } -// dependencies.storage.updateMessageIDCollectionByPruningMessagesWithIDs(messageIDs, using: transaction) -// dependencies.storage.removeReceivedMessageTimestamps(messageTimestamps, using: transaction) -// dependencies.storage.removeOpenGroupSequenceNumber(for: openGroup.room, on: openGroup.server, using: transaction) -// -// thread.removeAllThreadInteractions(with: transaction) -// thread.remove(with: transaction) -// dependencies.storage.removeOpenGroup(for: thread.uniqueId!, using: transaction) -// -// // Only remove the open group public key and server info if the user isn't in any other rooms -// if openGroups.count <= 1 { -// dependencies.storage.removeOpenGroupServer(name: openGroup.server, using: transaction) -// dependencies.storage.removeOpenGroupPublicKey(for: openGroup.server, using: transaction) -// } + // MARK: - Response Processing + + // MARK: - --handleCapabilities + + context("when handling capabilities") { + beforeEach { + mockStorage.when { $0.setOpenGroupServer(any(), using: anyAny()) }.thenReturn(()) + + OpenGroupManager + .handleCapabilities( + OpenGroupAPI.Capabilities(capabilities: [], missing: []), + on: "testserver", + using: testTransaction, + dependencies: dependencies + ) + } + + it("stores the capabilities") { + expect(mockStorage).to(call { $0.setOpenGroupServer(any(), using: anyAny()) }) + } + } + + // MARK: - --handlePollInfo + + context("when handling room poll info") { + beforeEach { + mockStorage.when { $0.setOpenGroup(any(), for: any(), using: anyAny()) }.thenReturn(()) + mockStorage.when { $0.setOpenGroupServer(any(), using: anyAny()) }.thenReturn(()) + mockStorage.when { $0.setUserCount(to: any(), forOpenGroupWithID: any(), using: anyAny()) }.thenReturn(()) + mockStorage.when { $0.getOpenGroupSequenceNumber(for: any(), on: any()) }.thenReturn(nil) + mockStorage.when { $0.getOpenGroupInboxLatestMessageId(for: any()) }.thenReturn(nil) + mockStorage.when { $0.getOpenGroupOutboxLatestMessageId(for: any()) }.thenReturn(nil) + + mockOGMCache.when { $0.pollers }.thenReturn([:]) + mockOGMCache.when { $0.moderators }.thenReturn([:]) + mockOGMCache.when { $0.admins }.thenReturn([:]) + + mockUserDefaults + .when { $0.object(forKey: SNUserDefaults.Date.lastOpen.rawValue) } + .thenReturn(nil) + } + + it("attempts to retrieve the existing thread") { + OpenGroupManager.handlePollInfo( + testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + using: testTransaction, + dependencies: dependencies + ) + + // The 'testGroupThread' should be the one retrieved + expect(testGroupThread.numSaveCalls).to(equal(1)) + } + + it("attempts to retrieve the existing open group") { + OpenGroupManager.handlePollInfo( + testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + using: testTransaction, + dependencies: dependencies + ) + + expect(mockStorage).to(call { $0.getOpenGroup(for: any()) }) + } + + it("saves the thread") { + OpenGroupManager.handlePollInfo( + testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + using: testTransaction, + dependencies: dependencies + ) + + expect(testGroupThread.numSaveCalls).to(equal(1)) + } + + it("saves the open group") { + OpenGroupManager.handlePollInfo( + testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + using: testTransaction, + dependencies: dependencies + ) + + expect(mockStorage).to(call { $0.setOpenGroup(any(), for: any(), using: anyAny()) }) + } + + it("saves the updated user count") { + OpenGroupManager.handlePollInfo( + testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + using: testTransaction, + dependencies: dependencies + ) + + expect(mockStorage) + .to(call(matchingParameters: true) { + $0.setUserCount(to: 10, forOpenGroupWithID: "testServer.testRoom", using: testTransaction! as Any) + }) + } + + it("calls the completion block") { + var didCallComplete: Bool = false + + OpenGroupManager.handlePollInfo( + testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + using: testTransaction, + dependencies: dependencies + ) { + didCallComplete = true + } + + expect(didCallComplete) + .toEventually( + beTrue(), + timeout: .milliseconds(100) + ) + } + + context("and updating the moderator list") { + it("successfully updates") { + mockOGMCache.when { $0.moderators }.thenReturn([:]) + testPollInfo = OpenGroupAPI.RoomPollInfo( + token: "testRoom", + activeUsers: 10, + admin: false, + globalAdmin: false, + moderator: false, + globalModerator: false, + read: false, + defaultRead: nil, + defaultAccessible: nil, + write: false, + defaultWrite: nil, + upload: false, + defaultUpload: nil, + details: TestCapabilitiesAndRoomApi.roomData.with(moderators: ["TestMod"], admins: []) + ) + + OpenGroupManager.handlePollInfo( + testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + using: testTransaction, + dependencies: dependencies + ) + + expect(mockOGMCache) + .toEventually( + call(matchingParameters: true) { + $0.moderators = ["testServer": ["testRoom": Set(arrayLiteral: "TestMod")]] + }, + timeout: .milliseconds(100) + ) + } + + it("defaults to an empty array if no moderators are provided") { + mockOGMCache.when { $0.moderators }.thenReturn([:]) + testPollInfo = OpenGroupAPI.RoomPollInfo( + token: "testRoom", + activeUsers: 10, + admin: false, + globalAdmin: false, + moderator: false, + globalModerator: false, + read: false, + defaultRead: nil, + defaultAccessible: nil, + write: false, + defaultWrite: nil, + upload: false, + defaultUpload: nil, + details: nil + ) + + OpenGroupManager.handlePollInfo( + testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + using: testTransaction, + dependencies: dependencies + ) + + expect(mockOGMCache) + .toEventually( + call(matchingParameters: true) { + $0.moderators = ["testServer": ["testRoom": Set()]] + }, + timeout: .milliseconds(100) + ) + } + } + + context("and updating the admin list") { + it("successfully updates") { + mockOGMCache.when { $0.admins }.thenReturn([:]) + testPollInfo = OpenGroupAPI.RoomPollInfo( + token: "testRoom", + activeUsers: 10, + admin: false, + globalAdmin: false, + moderator: false, + globalModerator: false, + read: false, + defaultRead: nil, + defaultAccessible: nil, + write: false, + defaultWrite: nil, + upload: false, + defaultUpload: nil, + details: TestCapabilitiesAndRoomApi.roomData.with(moderators: [], admins: ["TestAdmin"]) + ) + + OpenGroupManager.handlePollInfo( + testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + using: testTransaction, + dependencies: dependencies + ) + + expect(mockOGMCache) + .toEventually( + call(matchingParameters: true) { + $0.admins = ["testServer": ["testRoom": Set(arrayLiteral: "TestAdmin")]] + }, + timeout: .milliseconds(100) + ) + } + + it("defaults to an empty array if no moderators are provided") { + mockOGMCache.when { $0.admins }.thenReturn([:]) + testPollInfo = OpenGroupAPI.RoomPollInfo( + token: "testRoom", + activeUsers: 10, + admin: false, + globalAdmin: false, + moderator: false, + globalModerator: false, + read: false, + defaultRead: nil, + defaultAccessible: nil, + write: false, + defaultWrite: nil, + upload: false, + defaultUpload: nil, + details: nil + ) + + OpenGroupManager.handlePollInfo( + testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + using: testTransaction, + dependencies: dependencies + ) + + expect(mockOGMCache) + .toEventually( + call(matchingParameters: true) { + $0.admins = ["testServer": ["testRoom": Set()]] + }, + timeout: .milliseconds(100) + ) + } + } + + context("when it cannot get the thread id") { + it("does not save the thread") { + testGroupThread.mockData[.uniqueId] = nil + + OpenGroupManager.handlePollInfo( + testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + using: testTransaction, + dependencies: dependencies + ) + + expect(testGroupThread.numSaveCalls).to(equal(0)) + } + } + + context("when not given a public key") { + it("saves the open group with the existing public key") { + OpenGroupManager.handlePollInfo( + testPollInfo, + publicKey: nil, + for: "testRoom", + on: "testServer", + using: testTransaction, + dependencies: dependencies + ) + + expect(mockStorage) + .to(call(matchingParameters: true) { + $0.setOpenGroup( + OpenGroup( + server: "testServer", + room: "testRoom", + publicKey: TestConstants.publicKey, + name: "test", + groupDescription: nil, + imageID: nil, + infoUpdates: 10 + ), + for: "TestGroupId", + using: testTransaction! as Any + ) + }) + } + } + + context("when it cannot get the public key") { + it("does not save the thread") { + mockStorage.when { $0.getOpenGroup(for: any()) }.thenReturn(nil) + + OpenGroupManager.handlePollInfo( + testPollInfo, + publicKey: nil, + for: "testRoom", + on: "testServer", + using: testTransaction, + dependencies: dependencies + ) + + expect(testGroupThread.numSaveCalls).to(equal(0)) + } + } + + context("when storing the open group") { + it("defaults the infoUpdates to zero") { + mockStorage.when { $0.getOpenGroup(for: any()) }.thenReturn(nil) + testPollInfo = OpenGroupAPI.RoomPollInfo( + token: "testRoom", + activeUsers: 10, + admin: false, + globalAdmin: false, + moderator: false, + globalModerator: false, + read: false, + defaultRead: nil, + defaultAccessible: nil, + write: false, + defaultWrite: nil, + upload: false, + defaultUpload: nil, + details: nil + ) + + OpenGroupManager.handlePollInfo( + testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + using: testTransaction, + dependencies: dependencies + ) + + expect(mockStorage) + .to(call(matchingParameters: true) { + $0.setOpenGroup( + OpenGroup( + server: "testServer", + room: "testRoom", + publicKey: TestConstants.publicKey, + name: "TestTitle", + groupDescription: nil, + imageID: nil, + infoUpdates: 0 + ), + for: "TestGroupId", + using: testTransaction! as Any + ) + }) + } + } + + context("when checking to start polling") { + it("starts a new poller when not already polling") { + mockOGMCache.when { $0.pollers }.thenReturn([:]) + + OpenGroupManager.handlePollInfo( + testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + using: testTransaction, + dependencies: dependencies + ) + + expect(mockOGMCache) + .to(call(matchingParameters: true) { + $0.pollers = ["testServer": OpenGroupAPI.Poller(for: "testServer")] + }) + } + + it("does not start a new poller when already polling") { + mockOGMCache.when { $0.pollers }.thenReturn(["testServer": OpenGroupAPI.Poller(for: "testServer")]) + + OpenGroupManager.handlePollInfo( + testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + using: testTransaction, + dependencies: dependencies + ) + + expect(mockOGMCache).to(call(.exactly(times: 1)) { $0.pollers }) + } + } + + context("when trying to get the room image") { + beforeEach { + let image: UIImage = UIImage(color: .red, size: CGSize(width: 1, height: 1)) + let imageData: Data = image.pngData()! + mockStorage.when { $0.getOpenGroupImage(for: any(), on: any()) }.thenReturn(nil) + + mockOGMCache.when { $0.groupImagePromises } + .thenReturn(["testServer.testRoom": Promise.value(imageData)]) + } + + it("uses the provided room image id if available") { + testPollInfo = OpenGroupAPI.RoomPollInfo( + token: "testRoom", + activeUsers: 10, + admin: false, + globalAdmin: false, + moderator: false, + globalModerator: false, + read: false, + defaultRead: nil, + defaultAccessible: nil, + write: false, + defaultWrite: nil, + upload: false, + defaultUpload: nil, + details: OpenGroupAPI.Room( + token: "test", + name: "test", + roomDescription: nil, + infoUpdates: 0, + messageSequence: 0, + created: 0, + activeUsers: 0, + activeUsersCutoff: 0, + imageId: 10, + pinnedMessages: nil, + admin: false, + globalAdmin: false, + admins: [], + hiddenAdmins: nil, + moderator: false, + globalModerator: false, + moderators: [], + hiddenModerators: nil, + read: false, + defaultRead: nil, + defaultAccessible: nil, + write: false, + defaultWrite: nil, + upload: false, + defaultUpload: nil + ) + ) + + OpenGroupManager.handlePollInfo( + testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + using: testTransaction, + dependencies: dependencies + ) + + expect(mockStorage) + .to(call(matchingParameters: true) { + $0.setOpenGroup( + OpenGroup( + server: "testServer", + room: "testRoom", + publicKey: TestConstants.publicKey, + name: "test", + groupDescription: nil, + imageID: "10", + infoUpdates: 0 + ), + for: "TestGroupId", + using: testTransaction! as Any + ) + }) + expect(testGroupThread.groupModel.groupImage) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(testGroupThread.numSaveCalls) + .toEventually( + equal(2), // Call to save the open group and then to save the image + timeout: .milliseconds(100) + ) + } + + it("uses the existing room image id if none is provided") { + mockStorage + .when { $0.getOpenGroup(for: any()) } + .thenReturn( + OpenGroup( + server: "testServer", + room: "testRoom", + publicKey: TestConstants.publicKey, + name: "Test", + groupDescription: nil, + imageID: "12", + infoUpdates: 10 + ) + ) + testPollInfo = OpenGroupAPI.RoomPollInfo( + token: "testRoom", + activeUsers: 10, + admin: false, + globalAdmin: false, + moderator: false, + globalModerator: false, + read: false, + defaultRead: nil, + defaultAccessible: nil, + write: false, + defaultWrite: nil, + upload: false, + defaultUpload: nil, + details: nil + ) + + OpenGroupManager.handlePollInfo( + testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + using: testTransaction, + dependencies: dependencies + ) + + expect(mockStorage) + .to(call(matchingParameters: true) { + $0.setOpenGroup( + OpenGroup( + server: "testServer", + room: "testRoom", + publicKey: TestConstants.publicKey, + name: "TestTitle", + groupDescription: nil, + imageID: "12", + infoUpdates: 10 + ), + for: "TestGroupId", + using: testTransaction! as Any + ) + }) + expect(testGroupThread.groupModel.groupImage) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(testGroupThread.numSaveCalls) + .toEventually( + equal(2), // Call to save the open group and then to save the image + timeout: .milliseconds(100) + ) + } + + it("uses the new room image id if there is an existing one") { + testGroupThread.mockData[.groupModel] = TSGroupModel( + title: "TestTitle", + memberIds: [], + image: UIImage(color: .blue, size: CGSize(width: 1, height: 1)), + groupId: LKGroupUtilities.getEncodedOpenGroupIDAsData("testServer.testRoom"), + groupType: .openGroup, + adminIds: [], + moderatorIds: [] + ) + mockStorage + .when { $0.getOpenGroup(for: any()) } + .thenReturn( + OpenGroup( + server: "testServer", + room: "testRoom", + publicKey: TestConstants.publicKey, + name: "Test", + groupDescription: nil, + imageID: "12", + infoUpdates: 10 + ) + ) + testPollInfo = OpenGroupAPI.RoomPollInfo( + token: "testRoom", + activeUsers: 10, + admin: false, + globalAdmin: false, + moderator: false, + globalModerator: false, + read: false, + defaultRead: nil, + defaultAccessible: nil, + write: false, + defaultWrite: nil, + upload: false, + defaultUpload: nil, + details: OpenGroupAPI.Room( + token: "test", + name: "test", + roomDescription: nil, + infoUpdates: 10, + messageSequence: 0, + created: 0, + activeUsers: 0, + activeUsersCutoff: 0, + imageId: 10, + pinnedMessages: nil, + admin: false, + globalAdmin: false, + admins: [], + hiddenAdmins: nil, + moderator: false, + globalModerator: false, + moderators: [], + hiddenModerators: nil, + read: false, + defaultRead: nil, + defaultAccessible: nil, + write: false, + defaultWrite: nil, + upload: false, + defaultUpload: nil + ) + ) + + OpenGroupManager.handlePollInfo( + testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + using: testTransaction, + dependencies: dependencies + ) + + expect(mockStorage) + .to(call(matchingParameters: true) { + $0.setOpenGroup( + OpenGroup( + server: "testServer", + room: "testRoom", + publicKey: TestConstants.publicKey, + name: "test", + groupDescription: nil, + imageID: "10", + infoUpdates: 10 + ), + for: "TestGroupId", + using: testTransaction! as Any + ) + }) + expect(testGroupThread.groupModel.groupImage) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(testGroupThread.numSaveCalls) + .toEventually( + equal(2), // Call to save the open group and then to save the image + timeout: .milliseconds(100) + ) + } + + it("does nothing if there is no room image") { + OpenGroupManager.handlePollInfo( + testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + using: testTransaction, + dependencies: dependencies + ) + + expect(testGroupThread.groupModel.groupImage) + .toEventually( + beNil(), + timeout: .milliseconds(100) + ) + expect(testGroupThread.numSaveCalls) + .toEventually( + equal(1), + timeout: .milliseconds(100) + ) + } + + it("does nothing if it fails to retrieve the room image") { + mockOGMCache.when { $0.groupImagePromises } + .thenReturn(["testServer.testRoom": Promise(error: HTTP.Error.generic)]) + + testPollInfo = OpenGroupAPI.RoomPollInfo( + token: "testRoom", + activeUsers: 10, + admin: false, + globalAdmin: false, + moderator: false, + globalModerator: false, + read: false, + defaultRead: nil, + defaultAccessible: nil, + write: false, + defaultWrite: nil, + upload: false, + defaultUpload: nil, + details: OpenGroupAPI.Room( + token: "test", + name: "test", + roomDescription: nil, + infoUpdates: 0, + messageSequence: 0, + created: 0, + activeUsers: 0, + activeUsersCutoff: 0, + imageId: 10, + pinnedMessages: nil, + admin: false, + globalAdmin: false, + admins: [], + hiddenAdmins: nil, + moderator: false, + globalModerator: false, + moderators: [], + hiddenModerators: nil, + read: false, + defaultRead: nil, + defaultAccessible: nil, + write: false, + defaultWrite: nil, + upload: false, + defaultUpload: nil + ) + ) + + OpenGroupManager.handlePollInfo( + testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + using: testTransaction, + dependencies: dependencies + ) + + expect(testGroupThread.groupModel.groupImage) + .toEventually( + beNil(), + timeout: .milliseconds(100) + ) + expect(testGroupThread.numSaveCalls) + .toEventually( + equal(1), + timeout: .milliseconds(100) + ) + } + + it("saves the retrieved room image") { + testPollInfo = OpenGroupAPI.RoomPollInfo( + token: "testRoom", + activeUsers: 10, + admin: false, + globalAdmin: false, + moderator: false, + globalModerator: false, + read: false, + defaultRead: nil, + defaultAccessible: nil, + write: false, + defaultWrite: nil, + upload: false, + defaultUpload: nil, + details: OpenGroupAPI.Room( + token: "test", + name: "test", + roomDescription: nil, + infoUpdates: 10, + messageSequence: 0, + created: 0, + activeUsers: 0, + activeUsersCutoff: 0, + imageId: 10, + pinnedMessages: nil, + admin: false, + globalAdmin: false, + admins: [], + hiddenAdmins: nil, + moderator: false, + globalModerator: false, + moderators: [], + hiddenModerators: nil, + read: false, + defaultRead: nil, + defaultAccessible: nil, + write: false, + defaultWrite: nil, + upload: false, + defaultUpload: nil + ) + ) + + OpenGroupManager.handlePollInfo( + testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + using: testTransaction, + dependencies: dependencies + ) + + expect(testGroupThread.groupModel.groupImage) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(testGroupThread.numSaveCalls) + .toEventually( + equal(2), // Call to save the open group and then to save the image + timeout: .milliseconds(100) + ) + } + } + } + + // MARK: - --handleMessages + + context("when handling messages") { + beforeEach { + mockStorage + .when { + $0.setOpenGroupSequenceNumber( + for: any(), + on: any(), + to: any(), + using: testTransaction as Any + ) + } + .thenReturn(()) + } + + it("updates the sequence number when there are messages") { + OpenGroupManager.handleMessages( + [ + OpenGroupAPI.Message( + id: 1, + sender: nil, + posted: 123, + edited: nil, + seqNo: 124, + whisper: false, + whisperMods: false, + whisperTo: nil, + base64EncodedData: nil, + base64EncodedSignature: nil + ) + ], + for: "testRoom", + on: "testServer", + isBackgroundPoll: false, + using: testTransaction, + dependencies: dependencies + ) + + expect(mockStorage) + .to(call(matchingParameters: true) { + $0.setOpenGroupSequenceNumber( + for: "testRoom", + on: "testServer", + to: 124, + using: testTransaction as Any + ) + }) + } + + it("does not update the sequence number if there are no messages") { + OpenGroupManager.handleMessages( + [], + for: "testRoom", + on: "testServer", + isBackgroundPoll: false, + using: testTransaction, + dependencies: dependencies + ) + + expect(mockStorage) + .toNot(call { + $0.setOpenGroupSequenceNumber(for: any(), on: any(), to: any(), using: testTransaction as Any) + }) + } } } } } + +// MARK: - Room Convenience Extensions + +extension OpenGroupAPI.Room { + func with(moderators: [String], admins: [String]) -> OpenGroupAPI.Room { + return OpenGroupAPI.Room( + token: self.token, + name: self.name, + roomDescription: self.roomDescription, + infoUpdates: self.infoUpdates, + messageSequence: self.messageSequence, + created: self.created, + activeUsers: self.activeUsers, + activeUsersCutoff: self.activeUsersCutoff, + imageId: self.imageId, + pinnedMessages: self.pinnedMessages, + admin: self.admin, + globalAdmin: self.globalAdmin, + admins: admins, + hiddenAdmins: self.hiddenAdmins, + moderator: self.moderator, + globalModerator: self.globalModerator, + moderators: moderators, + hiddenModerators: self.hiddenModerators, + read: self.read, + defaultRead: self.defaultRead, + defaultAccessible: self.defaultAccessible, + write: self.write, + defaultWrite: self.defaultWrite, + upload: self.upload, + defaultUpload: self.defaultUpload + ) + } +} diff --git a/SessionMessagingKitTests/Open Groups/Types/SodiumProtocolsSpec.swift b/SessionMessagingKitTests/Open Groups/Types/SodiumProtocolsSpec.swift index 8d698b04d..f27eee889 100644 --- a/SessionMessagingKitTests/Open Groups/Types/SodiumProtocolsSpec.swift +++ b/SessionMessagingKitTests/Open Groups/Types/SodiumProtocolsSpec.swift @@ -17,21 +17,42 @@ class SodiumProtocolsSpec: QuickSpec { it("provides the default values in it's extensions") { let mockAead: MockAeadXChaCha20Poly1305Ietf = MockAeadXChaCha20Poly1305Ietf() mockAead - .when { $0.encrypt(message: any(), secretKey: any(), nonce: any(), additionalData: any()) } + .when { + $0.encrypt( + message: anyArray(), + secretKey: anyArray(), + nonce: anyArray(), + additionalData: anyArray() + ) + } .thenReturn(testValue) mockAead - .when { $0.decrypt(authenticatedCipherText: any(), secretKey: any(), nonce: any(), additionalData: any()) } + .when { + $0.decrypt( + authenticatedCipherText: anyArray(), + secretKey: anyArray(), + nonce: anyArray(), + additionalData: anyArray() + ) + } .thenReturn(testValue) _ = mockAead.encrypt(message: [], secretKey: [], nonce: []) _ = mockAead.decrypt(authenticatedCipherText: [], secretKey: [], nonce: []) expect(mockAead) - .to(call { $0.encrypt(message: any(), secretKey: any(), nonce: any(), additionalData: any()) }) + .to(call { + $0.encrypt(message: anyArray(), secretKey: anyArray(), nonce: anyArray(), additionalData: anyArray()) + }) expect(mockAead) .to(call { - $0.decrypt(authenticatedCipherText: any(), secretKey: any(), nonce: any(), additionalData: any()) + $0.decrypt( + authenticatedCipherText: anyArray(), + secretKey: anyArray(), + nonce: anyArray(), + additionalData: anyArray() + ) }) } } @@ -41,19 +62,35 @@ class SodiumProtocolsSpec: QuickSpec { it("provides the default values in it's extensions") { let mockGenericHash: MockGenericHash = MockGenericHash() - mockGenericHash.when { $0.hash(message: any(), key: any()) }.thenReturn(testValue) mockGenericHash - .when { $0.hashSaltPersonal(message: any(), outputLength: any(), key: any(), salt: any(), personal: any()) } + .when { $0.hash(message: anyArray(), key: anyArray()) } + .thenReturn(testValue) + mockGenericHash + .when { + $0.hashSaltPersonal( + message: anyArray(), + outputLength: any(), + key: anyArray(), + salt: anyArray(), + personal: anyArray() + ) + } .thenReturn(testValue) _ = mockGenericHash.hash(message: []) _ = mockGenericHash.hashSaltPersonal(message: [], outputLength: 0, salt: [], personal: []) expect(mockGenericHash) - .to(call { $0.hash(message: any(), key: any()) }) + .to(call { $0.hash(message: anyArray(), key: anyArray()) }) expect(mockGenericHash) .to(call { - $0.hashSaltPersonal(message: any(), outputLength: any(), key: any(), salt: any(), personal: any()) + $0.hashSaltPersonal( + message: anyArray(), + outputLength: any(), + key: anyArray(), + salt: anyArray(), + personal: anyArray() + ) }) } } diff --git a/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift b/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift new file mode 100644 index 000000000..77bdcf2d7 --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift @@ -0,0 +1,36 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionSnodeKit + +@testable import SessionMessagingKit + +extension Dependencies { + public func with( + onionApi: OnionRequestAPIType.Type? = nil, + storage: SessionMessagingKitStorageProtocol? = nil, + sodium: SodiumType? = nil, + aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, + sign: SignType? = nil, + genericHash: GenericHashType? = nil, + ed25519: Ed25519Type? = nil, + nonceGenerator16: NonceGenerator16ByteType? = nil, + nonceGenerator24: NonceGenerator24ByteType? = nil, + standardUserDefaults: UserDefaultsType? = nil, + date: Date? = nil + ) -> Dependencies { + return Dependencies( + onionApi: (onionApi ?? self._onionApi), + storage: (storage ?? self._storage), + sodium: (sodium ?? self._sodium), + aeadXChaCha20Poly1305Ietf: (aeadXChaCha20Poly1305Ietf ?? self._aeadXChaCha20Poly1305Ietf), + sign: (sign ?? self._sign), + genericHash: (genericHash ?? self._genericHash), + ed25519: (ed25519 ?? self._ed25519), + nonceGenerator16: (nonceGenerator16 ?? self._nonceGenerator16), + nonceGenerator24: (nonceGenerator24 ?? self._nonceGenerator24), + standardUserDefaults: (standardUserDefaults ?? self._standardUserDefaults), + date: (date ?? self._date) + ) + } +} diff --git a/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift b/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift new file mode 100644 index 000000000..3bea2f036 --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift @@ -0,0 +1,52 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import PromiseKit + +@testable import SessionMessagingKit + +class MockOGMCache: Mock, OGMCacheType { + var defaultRoomsPromise: Promise<[OpenGroupAPI.Room]>? { + get { return accept() as? Promise<[OpenGroupAPI.Room]> } + set { accept(args: [newValue]) } + } + + var groupImagePromises: [String: Promise] { + get { return accept() as! [String: Promise] } + set { accept(args: [newValue]) } + } + + var pollers: [String: OpenGroupAPI.Poller] { + get { return accept() as! [String: OpenGroupAPI.Poller] } + set { accept(args: [newValue]) } + } + + var isPolling: Bool { + get { return accept() as! Bool } + set { accept(args: [newValue]) } + } + + var moderators: [String: [String: Set]] { + get { return accept() as! [String: [String: Set]] } + set { accept(args: [newValue]) } + } + + var admins: [String: [String: Set]] { + get { return accept() as! [String: [String: Set]] } + set { accept(args: [newValue]) } + } + + var hasPerformedInitialPoll: [String: Bool] { + get { return accept() as! [String: Bool] } + set { accept(args: [newValue]) } + } + + var timeSinceLastPoll: [String: TimeInterval] { + get { return accept() as! [String: TimeInterval] } + set { accept(args: [newValue]) } + } + + func getTimeSinceLastOpen(using dependencies: Dependencies) -> TimeInterval { + return accept(args: [dependencies]) as! TimeInterval + } +} diff --git a/SessionMessagingKitTests/_TestUtilities/MockStorage.swift b/SessionMessagingKitTests/_TestUtilities/MockStorage.swift index c8455ad10..7440c80da 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockStorage.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockStorage.swift @@ -27,6 +27,14 @@ class MockStorage: Mock, SessionMessagingKit func getUserKeyPair() -> ECKeyPair? { return accept() as? ECKeyPair } func getUserED25519KeyPair() -> Box.KeyPair? { return accept() as? Box.KeyPair } func getUser() -> Contact? { return accept() as? Contact } + + // MARK: - Contacts + + func getContact(with sessionID: String) -> Contact? { return accept(args: [sessionID]) as? Contact } + func getContact(with sessionID: String, using transaction: Any) -> Contact? { + return accept(args: [sessionID, transaction]) as? Contact + } + func setContact(_ contact: Contact, using transaction: Any) { accept(args: [contact, transaction]) } func getAllContacts() -> Set { return accept() as! Set } func getAllContacts(with transaction: YapDatabaseReadTransaction) -> Set { return accept() as! Set } @@ -52,7 +60,19 @@ class MockStorage: Mock, SessionMessagingKit } // MARK: - Closed Groups - + + func getClosedGroupEncryptionKeyPairs(for groupPublicKey: String) -> [ECKeyPair] { + return accept(args: [groupPublicKey]) as! [ECKeyPair] + } + func getLatestClosedGroupEncryptionKeyPair(for groupPublicKey: String) -> ECKeyPair? { + return accept(args: [groupPublicKey]) as? ECKeyPair + } + func addClosedGroupEncryptionKeyPair(_ keyPair: ECKeyPair, for groupPublicKey: String, using transaction: Any) { + accept(args: [keyPair, groupPublicKey, transaction]) + } + func removeAllClosedGroupEncryptionKeyPairs(for groupPublicKey: String, using transaction: Any) { + accept(args: [groupPublicKey, transaction]) + } func getUserClosedGroupPublicKeys() -> Set { return accept() as! Set } func getZombieMembers(for groupPublicKey: String) -> Set { return accept() as! Set } func setZombieMembers(for groupPublicKey: String, to zombies: Set, using transaction: Any) { diff --git a/SessionMessagingKitTests/_TestUtilities/MockedExtensions.swift b/SessionMessagingKitTests/_TestUtilities/MockedExtensions.swift index 0824acabf..1d5bce472 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockedExtensions.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockedExtensions.swift @@ -18,6 +18,6 @@ extension OpenGroup: Mocked { extension OpenGroupAPI.Server: Mocked { static var mockValue: OpenGroupAPI.Server = OpenGroupAPI.Server( name: any(), - capabilities: OpenGroupAPI.Capabilities(capabilities: any(), missing: any()) + capabilities: OpenGroupAPI.Capabilities(capabilities: anyArray(), missing: anyArray()) ) } diff --git a/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift b/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift new file mode 100644 index 000000000..39c1bfacd --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift @@ -0,0 +1,38 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionSnodeKit + +@testable import SessionMessagingKit + +extension OpenGroupManager.OGMDependencies { + public func with( + cache: Atomic? = nil, + onionApi: OnionRequestAPIType.Type? = nil, + storage: SessionMessagingKitStorageProtocol? = nil, + sodium: SodiumType? = nil, + aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, + sign: SignType? = nil, + genericHash: GenericHashType? = nil, + ed25519: Ed25519Type? = nil, + nonceGenerator16: NonceGenerator16ByteType? = nil, + nonceGenerator24: NonceGenerator24ByteType? = nil, + standardUserDefaults: UserDefaultsType? = nil, + date: Date? = nil + ) -> OpenGroupManager.OGMDependencies { + return OpenGroupManager.OGMDependencies( + cache: (cache ?? self._mutableCache), + onionApi: (onionApi ?? self._onionApi), + storage: (storage ?? self._storage), + sodium: (sodium ?? self._sodium), + aeadXChaCha20Poly1305Ietf: (aeadXChaCha20Poly1305Ietf ?? self._aeadXChaCha20Poly1305Ietf), + sign: (sign ?? self._sign), + genericHash: (genericHash ?? self._genericHash), + ed25519: (ed25519 ?? self._ed25519), + nonceGenerator16: (nonceGenerator16 ?? self._nonceGenerator16), + nonceGenerator24: (nonceGenerator24 ?? self._nonceGenerator24), + standardUserDefaults: (standardUserDefaults ?? self._standardUserDefaults), + date: (date ?? self._date) + ) + } +} diff --git a/SessionMessagingKitTests/_TestUtilities/TestGroupThread.swift b/SessionMessagingKitTests/_TestUtilities/TestGroupThread.swift index 4ec2469eb..49cff1341 100644 --- a/SessionMessagingKitTests/_TestUtilities/TestGroupThread.swift +++ b/SessionMessagingKitTests/_TestUtilities/TestGroupThread.swift @@ -8,6 +8,7 @@ class TestGroupThread: TSGroupThread, Mockable { // MARK: - Mockable enum DataKey: Hashable { + case uniqueId case groupModel case interactions } @@ -15,18 +16,42 @@ class TestGroupThread: TSGroupThread, Mockable { typealias Key = DataKey var mockData: [DataKey: Any] = [:] - var didCallSave: Bool = false + var numSaveCalls: Int = 0 + var didCallRemoveAllThreadInteractions: Bool = false + var didCallRemove: Bool = false // MARK: - TSGroupThread + override var uniqueId: String? { + get { (mockData[.uniqueId] as? String) } + set {} + } + override var groupModel: TSGroupModel { get { (mockData[.groupModel] as! TSGroupModel) } - set {} + set { mockData[.groupModel] = newValue } } override func enumerateInteractions(_ block: @escaping (TSInteraction) -> Void) { ((mockData[.interactions] as? [TSInteraction]) ?? []).forEach(block) } - override func save(with transaction: YapDatabaseReadWriteTransaction) { didCallSave = true } + override func enumerateInteractions(with transaction: YapDatabaseReadTransaction, using block: @escaping (TSInteraction, UnsafeMutablePointer) -> Void) { + var stop: ObjCBool = false + for interaction in ((mockData[.interactions] as? [TSInteraction]) ?? []) { + block(interaction, &stop) + + if stop.boolValue { break } + } + } + + override func removeAllThreadInteractions(with transaction: YapDatabaseReadWriteTransaction) { + didCallRemoveAllThreadInteractions = true + } + + override func remove(with transaction: YapDatabaseReadWriteTransaction) { + didCallRemove = true + } + + override func save(with transaction: YapDatabaseReadWriteTransaction) { numSaveCalls += 1 } } diff --git a/SharedTest/Mock.swift b/SharedTest/Mock.swift index 35c270f15..74e92d38a 100644 --- a/SharedTest/Mock.swift +++ b/SharedTest/Mock.swift @@ -9,14 +9,16 @@ protocol Mocked { static var mockValue: Self { get } } func any() -> R { R.mockValue } func any() -> R { unsafeBitCast(0, to: R.self) } -func any() -> [R] { [] } func any() -> [K: V] { [:] } -func any() -> Any { 0 } func any() -> Float { 0 } func any() -> Double { 0 } func any() -> String { "" } func any() -> Data { Data() } +func anyAny() -> Any { 0 } // Unique name for compilation performance reasons +func anyArray() -> [R] { [] } // Unique name for compilation performance reasons +func anySet() -> Set { Set() } // Unique name for compilation performance reasons + // MARK: - Mock public class Mock { @@ -49,11 +51,7 @@ public class Mock { functionConsumer.calls.mutate { $0 = [:] } } - internal func resetCallCounts() { - functionConsumer.calls.mutate { $0 = [:] } - } - - internal func when(_ callBlock: @escaping (T) throws -> R) -> MockFunctionBuilder { + internal func when(_ callBlock: @escaping (inout T) throws -> R) -> MockFunctionBuilder { let builder: MockFunctionBuilder = MockFunctionBuilder(callBlock, mockInit: type(of: self).init) functionConsumer.functionBuilders.append(builder.build) @@ -68,9 +66,14 @@ public class Mock { case let array as [Any]: return "[\(array.map { summary(for: $0) }.joined(separator: ", "))]" case let dict as [String: Any]: - return "[\(dict.map { key, value in "\(summary(for: key)):\(summary(for: value))" }.joined(separator: ", "))]" + if dict.isEmpty { return "[:]" } - default: return String(describing: argument) + let sortedValues: [String] = dict + .map { key, value in "\(summary(for: key)):\(summary(for: value))" } + .sorted() + return "[\(sortedValues.joined(separator: ", "))]" + + default: return String(reflecting: argument) // Default to the `debugDescription` if available } } } @@ -100,7 +103,7 @@ internal class MockFunction { // MARK: - MockFunctionBuilder internal class MockFunctionBuilder: MockFunctionHandler { - private let callBlock: (T) throws -> R + private let callBlock: (inout T) throws -> R private let mockInit: (MockFunctionHandler?) -> Mock private var functionName: String? private var parameterSummary: String? @@ -110,7 +113,7 @@ internal class MockFunctionBuilder: MockFunctionHandler { // MARK: - Initialization - init(_ callBlock: @escaping (T) throws -> R, mockInit: @escaping (MockFunctionHandler?) -> Mock) { + init(_ callBlock: @escaping (inout T) throws -> R, mockInit: @escaping (MockFunctionHandler?) -> Mock) { self.callBlock = callBlock self.mockInit = mockInit } @@ -137,8 +140,8 @@ internal class MockFunctionBuilder: MockFunctionHandler { // MARK: - Build func build() throws -> MockFunction { - let completionMock = mockInit(self) as! T - _ = try callBlock(completionMock) + var completionMock = mockInit(self) as! T + _ = try callBlock(&completionMock) guard let name: String = functionName, let parameterSummary: String = parameterSummary else { preconditionFailure("Attempted to build the MockFunction before it was called") diff --git a/SharedTest/NimbleExtensions.swift b/SharedTest/NimbleExtensions.swift index afed2e7b0..b18c5160b 100644 --- a/SharedTest/NimbleExtensions.swift +++ b/SharedTest/NimbleExtensions.swift @@ -27,7 +27,7 @@ public func call( _ amount: CallAmount = .atLeast(times: 1), matchingParameters: Bool = false, exclusive: Bool = false, - functionBlock: @escaping (T) throws -> R + functionBlock: @escaping (inout T) throws -> R ) -> Predicate where M: Mock { return Predicate.define { actualExpression in let callInfo: CallInfo = generateCallInfo(actualExpression, functionBlock) @@ -94,9 +94,18 @@ public func call( actualMessage = "called \(timeStr(callInfo.desiredFunctionCalls.count))" case (false, false, _): - actualMessage = "called \(timeStr(matchingParameterRecords.count)) with matching parameters, \(timeStr(callInfo.desiredFunctionCalls.count)) total" + let distinctSetterCombinations: Set = distinctParameterCombinations.filter { $0 != "[]" } + + // A getter/setter combo will have function calls split between no params and the set value + // if the setter didn't match then we still want to show the incorrect parameters + if distinctSetterCombinations.count == 1, let paramCombo: String = distinctSetterCombinations.first { + actualMessage = "called with: \(paramCombo)" + } + else { + actualMessage = "called \(timeStr(matchingParameterRecords.count)) with matching parameters, \(timeStr(callInfo.desiredFunctionCalls.count)) total" + } - default: actualMessage = "" + default: actualMessage = "\(exclusive ? " exclusive " : "")call to '\(callInfo.functionName)'" } } @@ -157,7 +166,7 @@ fileprivate struct CallInfo { } } -fileprivate func generateCallInfo(_ actualExpression: Expression, _ functionBlock: @escaping (T) throws -> R) -> CallInfo where M: Mock { +fileprivate func generateCallInfo(_ actualExpression: Expression, _ functionBlock: @escaping (inout T) throws -> R) -> CallInfo where M: Mock { var maybeFunction: MockFunction? var allFunctionsCalled: [String] = [] var desiredFunctionCalls: [String] = [] From 37f4d2ecca85785adca838b8f601b5f46c36143c Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 16 Mar 2022 17:43:40 +1100 Subject: [PATCH 038/157] Further work on OpenGroupManager tests Added tests for the OpenGroupManager handleMessages logic Updated some logic to do an optional unwrap instead of a forced one to prevent the code from crashing when running unit tests Fixed some tests which were flaky (could fail if other tests ran at the same time on other threads) Fixed a minor potential bug where a message with no sender data wouldn't get deleted (shouldn't be dependant on the sender value being populated) --- Session.xcodeproj/project.pbxproj | 4 + .../Open Groups/OpenGroupManager.swift | 6 +- .../MessageReceiver+Handling.swift | 11 +- .../Open Groups/OpenGroupManagerSpec.swift | 395 ++++++++++++++++-- .../_TestUtilities/MockedExtensions.swift | 4 + .../_TestUtilities/TestIncomingMessage.swift | 23 + .../_TestUtilities/TestInteraction.swift | 2 +- .../_TestUtilities/TestTransaction.swift | 4 + 8 files changed, 416 insertions(+), 33 deletions(-) create mode 100644 SessionMessagingKitTests/_TestUtilities/TestIncomingMessage.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 03d001906..d73eef2d9 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -781,6 +781,7 @@ FD078E4F27E175F1000769AF /* DependencyExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E4E27E175F1000769AF /* DependencyExtensions.swift */; }; FD078E5227E1760A000769AF /* OGMDependencyExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E5127E1760A000769AF /* OGMDependencyExtensions.swift */; }; FD078E5427E197CA000769AF /* OpenGroupManagerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909D27D85751005DAE71 /* OpenGroupManagerSpec.swift */; }; + FD078E5827E1B831000769AF /* TestIncomingMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E5727E1B831000769AF /* TestIncomingMessage.swift */; }; FD0BA51B27CD88EC00CC6805 /* BlindedIdMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0BA51A27CD88EC00CC6805 /* BlindedIdMapping.swift */; }; FD0BA51D27CDC34600CC6805 /* SOGSV4Migration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0BA51C27CDC34600CC6805 /* SOGSV4Migration.swift */; }; FD5D200F27AA2B6000FEA984 /* MessageRequestResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */; }; @@ -1926,6 +1927,7 @@ FD078E4C27E17156000769AF /* MockOGMCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockOGMCache.swift; sourceTree = ""; }; FD078E4E27E175F1000769AF /* DependencyExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DependencyExtensions.swift; sourceTree = ""; }; FD078E5127E1760A000769AF /* OGMDependencyExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OGMDependencyExtensions.swift; sourceTree = ""; }; + FD078E5727E1B831000769AF /* TestIncomingMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestIncomingMessage.swift; sourceTree = ""; }; FD0BA51A27CD88EC00CC6805 /* BlindedIdMapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlindedIdMapping.swift; sourceTree = ""; }; FD0BA51C27CDC34600CC6805 /* SOGSV4Migration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSV4Migration.swift; sourceTree = ""; }; FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestResponse.swift; sourceTree = ""; }; @@ -4060,6 +4062,7 @@ FDC2909F27D85826005DAE71 /* TestThread.swift */, FDC290B627E00FDB005DAE71 /* TestGroupThread.swift */, FDC290A127D85890005DAE71 /* TestInteraction.swift */, + FD078E5727E1B831000769AF /* TestIncomingMessage.swift */, FDC290AB27DB0B1C005DAE71 /* MockedExtensions.swift */, FD078E4E27E175F1000769AF /* DependencyExtensions.swift */, FD078E5127E1760A000769AF /* OGMDependencyExtensions.swift */, @@ -5657,6 +5660,7 @@ buildActionMask = 2147483647; files = ( FDC290AC27DB0B1C005DAE71 /* MockedExtensions.swift in Sources */, + FD078E5827E1B831000769AF /* TestIncomingMessage.swift in Sources */, FD859EFA27C2F5C500510D0C /* MockGenericHash.swift in Sources */, FDC2909427D710B4005DAE71 /* SOGSEndpointSpec.swift in Sources */, FDC290AF27DFEE97005DAE71 /* TestTransaction.swift in Sources */, diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 7d7855a3f..48989ece0 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -321,6 +321,7 @@ public final class OpenGroupManager: NSObject { // Sorting the messages by server ID before importing them fixes an issue where messages // that quote older messages can't find those older messages let openGroupID = "\(server).\(roomToken)" + let openGroupIdData: Data = LKGroupUtilities.getEncodedOpenGroupIDAsData(openGroupID) let sortedMessages: [OpenGroupAPI.Message] = messages .sorted { lhs, rhs in lhs.id < rhs.id } let seqNo: Int64? = sortedMessages.map { $0.seqNo }.max() @@ -333,11 +334,12 @@ public final class OpenGroupManager: NSObject { // Process the messages sortedMessages.forEach { message in - guard let base64EncodedString: String = message.base64EncodedData, let data = Data(base64Encoded: base64EncodedString), let sender: String = message.sender else { + guard let base64EncodedString: String = message.base64EncodedData, let data = Data(base64Encoded: base64EncodedString) else { // A message with no data has been deleted so add it to the list to remove messageServerIDsToRemove.append(UInt64(message.id)) return } + guard let sender: String = message.sender else { return } // Need a sender in order to process the message // Note: The `posted` value is in seconds but all messages in the database use milliseconds for timestamps let envelope = SNProtoEnvelope.builder(type: .sessionMessage, timestamp: UInt64(floor(message.posted * 1000))) @@ -356,7 +358,7 @@ public final class OpenGroupManager: NSObject { // Handle any deletions that are needed guard !messageServerIDsToRemove.isEmpty else { return } - guard let thread = TSGroupThread.fetch(groupId: openGroupID, transaction: transaction) else { return } + guard let thread = TSGroupThread.fetch(groupId: openGroupIdData, transaction: transaction) else { return } var messagesToRemove: [TSMessage] = [] diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index 3394228b4..4dedf704c 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -411,12 +411,17 @@ extension MessageReceiver { } // Notify the user if needed - guard let tsIncomingMessage = TSMessage.fetch(uniqueId: tsMessageID, transaction: transaction) as? TSIncomingMessage, - let thread = TSThread.fetch(uniqueId: threadID, transaction: transaction) else { return tsMessageID } + guard + let tsIncomingMessage = TSMessage.fetch(uniqueId: tsMessageID, transaction: transaction) as? TSIncomingMessage, + let thread = TSThread.fetch(uniqueId: threadID, transaction: transaction) + else { + return tsMessageID + } + // Use the same identifier for notifications when in backgroud polling to prevent spam let notificationIdentifier = isBackgroundPoll ? thread.uniqueId : UUID().uuidString tsIncomingMessage.setNotificationIdentifier(notificationIdentifier, transaction: transaction) - SSKEnvironment.shared.notificationsManager!.notifyUser(for: tsIncomingMessage, in: thread, transaction: transaction) + SSKEnvironment.shared.notificationsManager?.notifyUser(for: tsIncomingMessage, in: thread, transaction: transaction) return tsMessageID } diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift index edbbee882..953bcff2f 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -79,10 +79,12 @@ class OpenGroupManagerSpec: QuickSpec { var dependencies: OpenGroupManager.OGMDependencies! var testInteraction: TestInteraction! + var testIncomingMessage: TestIncomingMessage! var testGroupThread: TestGroupThread! var testTransaction: TestTransaction! var testOpenGroup: OpenGroup! var testPollInfo: OpenGroupAPI.RoomPollInfo! + var testMessage: OpenGroupAPI.Message! var cache: OpenGroupManager.Cache! var openGroupManager: OpenGroupManager! @@ -116,6 +118,9 @@ class OpenGroupManagerSpec: QuickSpec { testInteraction.mockData[.uniqueId] = "TestInteractionId" testInteraction.mockData[.timestamp] = UInt64(123) + testIncomingMessage = TestIncomingMessage(uniqueId: "TestMessageId") + testIncomingMessage.openGroupServerMessageID = 127 + testGroupThread = TestGroupThread() testGroupThread.mockData[.uniqueId] = "TestGroupId" testGroupThread.mockData[.groupModel] = TSGroupModel( @@ -127,7 +132,7 @@ class OpenGroupManagerSpec: QuickSpec { adminIds: [], moderatorIds: [] ) - testGroupThread.mockData[.interactions] = [testInteraction] + testGroupThread.mockData[.interactions] = [testInteraction, testIncomingMessage] testTransaction = TestTransaction() testTransaction.mockData[.objectForKey] = testGroupThread @@ -157,6 +162,30 @@ class OpenGroupManagerSpec: QuickSpec { defaultUpload: nil, details: TestCapabilitiesAndRoomApi.roomData ) + testMessage = OpenGroupAPI.Message( + id: 127, + sender: "05\(TestConstants.publicKey)", + posted: 123, + edited: nil, + seqNo: 124, + whisper: false, + whisperMods: false, + whisperTo: nil, + base64EncodedData: [ + "Cg0KC1Rlc3RNZXNzYWdlg", + "AAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAA", + "AA" + ].joined(), + base64EncodedSignature: nil + ) mockStorage .when { $0.write(with: { _ in }) } @@ -407,6 +436,8 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: - Adding & Removing + // MARK: - --add + context("when adding") { beforeEach { mockStorage.when { $0.removeOpenGroupSequenceNumber(for: any(), on: any(), using: anyAny()) }.thenReturn(()) @@ -428,6 +459,8 @@ class OpenGroupManagerSpec: QuickSpec { } it("resets the sequence number of the open group") { + var didComplete: Bool = false // Prevent multi-threading test bugs + openGroupManager .add( roomToken: "testRoom", @@ -437,21 +470,25 @@ class OpenGroupManagerSpec: QuickSpec { using: testTransaction, dependencies: dependencies ) + .map { _ -> Void in didComplete = true } .retainUntilComplete() + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) expect(mockStorage) .to( call(.exactly(times: 1)) { $0.removeOpenGroupSequenceNumber( for: "testRoom", on: "testServer", - using: testTransaction as Any + using: testTransaction! as Any ) } ) } it("sets the public key of the open group server") { + var didComplete: Bool = false // Prevent multi-threading test bugs + openGroupManager .add( roomToken: "testRoom", @@ -461,21 +498,25 @@ class OpenGroupManagerSpec: QuickSpec { using: testTransaction, dependencies: dependencies ) + .map { _ -> Void in didComplete = true } .retainUntilComplete() + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) expect(mockStorage) .to( call(.exactly(times: 1)) { $0.setOpenGroupPublicKey( for: "testRoom", to: "testKey", - using: testTransaction as Any + using: testTransaction! as Any ) } ) } it("adds a poller") { + var didComplete: Bool = false // Prevent multi-threading test bugs + openGroupManager .add( roomToken: "testRoom", @@ -485,8 +526,10 @@ class OpenGroupManagerSpec: QuickSpec { using: testTransaction, dependencies: dependencies ) + .map { _ -> Void in didComplete = true } .retainUntilComplete() + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) expect(mockOGMCache) .toEventually( call(matchingParameters: true) { @@ -502,6 +545,8 @@ class OpenGroupManagerSpec: QuickSpec { } it("does not reset the sequence number or update the public key") { + var didComplete: Bool = false // Prevent multi-threading test bugs + openGroupManager .add( roomToken: "testRoom", @@ -511,15 +556,17 @@ class OpenGroupManagerSpec: QuickSpec { using: testTransaction, dependencies: dependencies ) + .map { _ -> Void in didComplete = true } .retainUntilComplete() + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) expect(mockStorage) .toEventuallyNot( call { $0.removeOpenGroupSequenceNumber( for: "testRoom", on: "testServer", - using: testTransaction as Any + using: testTransaction! as Any ) }, timeout: .milliseconds(100) @@ -530,7 +577,7 @@ class OpenGroupManagerSpec: QuickSpec { $0.setOpenGroupPublicKey( for: "testRoom", to: "testKey", - using: testTransaction as Any + using: testTransaction! as Any ) }, timeout: .milliseconds(100) @@ -574,8 +621,12 @@ class OpenGroupManagerSpec: QuickSpec { } } - context("when removing") { + // MARK: - --delete + + context("when deleting") { beforeEach { + testGroupThread.mockData[.interactions] = [testInteraction] + mockStorage .when { $0.updateMessageIDCollectionByPruningMessagesWithIDs(anySet(), using: anyAny()) } .thenReturn(()) @@ -838,6 +889,8 @@ class OpenGroupManagerSpec: QuickSpec { } it("attempts to retrieve the existing thread") { + var didComplete: Bool = false // Prevent multi-threading test bugs + OpenGroupManager.handlePollInfo( testPollInfo, publicKey: TestConstants.publicKey, @@ -845,13 +898,15 @@ class OpenGroupManagerSpec: QuickSpec { on: "testServer", using: testTransaction, dependencies: dependencies - ) + ) { didComplete = true } - // The 'testGroupThread' should be the one retrieved + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) expect(testGroupThread.numSaveCalls).to(equal(1)) } it("attempts to retrieve the existing open group") { + var didComplete: Bool = false // Prevent multi-threading test bugs + OpenGroupManager.handlePollInfo( testPollInfo, publicKey: TestConstants.publicKey, @@ -859,12 +914,15 @@ class OpenGroupManagerSpec: QuickSpec { on: "testServer", using: testTransaction, dependencies: dependencies - ) + ) { didComplete = true } + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) expect(mockStorage).to(call { $0.getOpenGroup(for: any()) }) } it("saves the thread") { + var didComplete: Bool = false // Prevent multi-threading test bugs + OpenGroupManager.handlePollInfo( testPollInfo, publicKey: TestConstants.publicKey, @@ -872,12 +930,15 @@ class OpenGroupManagerSpec: QuickSpec { on: "testServer", using: testTransaction, dependencies: dependencies - ) + ) { didComplete = true } + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) expect(testGroupThread.numSaveCalls).to(equal(1)) } it("saves the open group") { + var didComplete: Bool = false // Prevent multi-threading test bugs + OpenGroupManager.handlePollInfo( testPollInfo, publicKey: TestConstants.publicKey, @@ -885,12 +946,15 @@ class OpenGroupManagerSpec: QuickSpec { on: "testServer", using: testTransaction, dependencies: dependencies - ) + ) { didComplete = true } + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) expect(mockStorage).to(call { $0.setOpenGroup(any(), for: any(), using: anyAny()) }) } it("saves the updated user count") { + var didComplete: Bool = false // Prevent multi-threading test bugs + OpenGroupManager.handlePollInfo( testPollInfo, publicKey: TestConstants.publicKey, @@ -898,8 +962,9 @@ class OpenGroupManagerSpec: QuickSpec { on: "testServer", using: testTransaction, dependencies: dependencies - ) + ) { didComplete = true } + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) expect(mockStorage) .to(call(matchingParameters: true) { $0.setUserCount(to: 10, forOpenGroupWithID: "testServer.testRoom", using: testTransaction! as Any) @@ -929,6 +994,8 @@ class OpenGroupManagerSpec: QuickSpec { context("and updating the moderator list") { it("successfully updates") { + var didComplete: Bool = false // Prevent multi-threading test bugs + mockOGMCache.when { $0.moderators }.thenReturn([:]) testPollInfo = OpenGroupAPI.RoomPollInfo( token: "testRoom", @@ -954,8 +1021,9 @@ class OpenGroupManagerSpec: QuickSpec { on: "testServer", using: testTransaction, dependencies: dependencies - ) + ) { didComplete = true } + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) expect(mockOGMCache) .toEventually( call(matchingParameters: true) { @@ -966,6 +1034,8 @@ class OpenGroupManagerSpec: QuickSpec { } it("defaults to an empty array if no moderators are provided") { + var didComplete: Bool = false // Prevent multi-threading test bugs + mockOGMCache.when { $0.moderators }.thenReturn([:]) testPollInfo = OpenGroupAPI.RoomPollInfo( token: "testRoom", @@ -991,8 +1061,9 @@ class OpenGroupManagerSpec: QuickSpec { on: "testServer", using: testTransaction, dependencies: dependencies - ) + ) { didComplete = true } + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) expect(mockOGMCache) .toEventually( call(matchingParameters: true) { @@ -1005,6 +1076,8 @@ class OpenGroupManagerSpec: QuickSpec { context("and updating the admin list") { it("successfully updates") { + var didComplete: Bool = false // Prevent multi-threading test bugs + mockOGMCache.when { $0.admins }.thenReturn([:]) testPollInfo = OpenGroupAPI.RoomPollInfo( token: "testRoom", @@ -1030,8 +1103,9 @@ class OpenGroupManagerSpec: QuickSpec { on: "testServer", using: testTransaction, dependencies: dependencies - ) + ) { didComplete = true } + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) expect(mockOGMCache) .toEventually( call(matchingParameters: true) { @@ -1042,6 +1116,8 @@ class OpenGroupManagerSpec: QuickSpec { } it("defaults to an empty array if no moderators are provided") { + var didComplete: Bool = false // Prevent multi-threading test bugs + mockOGMCache.when { $0.admins }.thenReturn([:]) testPollInfo = OpenGroupAPI.RoomPollInfo( token: "testRoom", @@ -1067,8 +1143,9 @@ class OpenGroupManagerSpec: QuickSpec { on: "testServer", using: testTransaction, dependencies: dependencies - ) + ) { didComplete = true } + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) expect(mockOGMCache) .toEventually( call(matchingParameters: true) { @@ -1098,6 +1175,8 @@ class OpenGroupManagerSpec: QuickSpec { context("when not given a public key") { it("saves the open group with the existing public key") { + var didComplete: Bool = false // Prevent multi-threading test bugs + OpenGroupManager.handlePollInfo( testPollInfo, publicKey: nil, @@ -1105,8 +1184,9 @@ class OpenGroupManagerSpec: QuickSpec { on: "testServer", using: testTransaction, dependencies: dependencies - ) + ) { didComplete = true } + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) expect(mockStorage) .to(call(matchingParameters: true) { $0.setOpenGroup( @@ -1145,6 +1225,8 @@ class OpenGroupManagerSpec: QuickSpec { context("when storing the open group") { it("defaults the infoUpdates to zero") { + var didComplete: Bool = false // Prevent multi-threading test bugs + mockStorage.when { $0.getOpenGroup(for: any()) }.thenReturn(nil) testPollInfo = OpenGroupAPI.RoomPollInfo( token: "testRoom", @@ -1170,8 +1252,9 @@ class OpenGroupManagerSpec: QuickSpec { on: "testServer", using: testTransaction, dependencies: dependencies - ) + ) { didComplete = true } + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) expect(mockStorage) .to(call(matchingParameters: true) { $0.setOpenGroup( @@ -1193,6 +1276,8 @@ class OpenGroupManagerSpec: QuickSpec { context("when checking to start polling") { it("starts a new poller when not already polling") { + var didComplete: Bool = false // Prevent multi-threading test bugs + mockOGMCache.when { $0.pollers }.thenReturn([:]) OpenGroupManager.handlePollInfo( @@ -1202,8 +1287,9 @@ class OpenGroupManagerSpec: QuickSpec { on: "testServer", using: testTransaction, dependencies: dependencies - ) + ) { didComplete = true } + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) expect(mockOGMCache) .to(call(matchingParameters: true) { $0.pollers = ["testServer": OpenGroupAPI.Poller(for: "testServer")] @@ -1211,6 +1297,8 @@ class OpenGroupManagerSpec: QuickSpec { } it("does not start a new poller when already polling") { + var didComplete: Bool = false // Prevent multi-threading test bugs + mockOGMCache.when { $0.pollers }.thenReturn(["testServer": OpenGroupAPI.Poller(for: "testServer")]) OpenGroupManager.handlePollInfo( @@ -1220,8 +1308,9 @@ class OpenGroupManagerSpec: QuickSpec { on: "testServer", using: testTransaction, dependencies: dependencies - ) + ) { didComplete = true } + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) expect(mockOGMCache).to(call(.exactly(times: 1)) { $0.pollers }) } } @@ -1237,6 +1326,8 @@ class OpenGroupManagerSpec: QuickSpec { } it("uses the provided room image id if available") { + var didComplete: Bool = false // Prevent multi-threading test bugs + testPollInfo = OpenGroupAPI.RoomPollInfo( token: "testRoom", activeUsers: 10, @@ -1287,8 +1378,9 @@ class OpenGroupManagerSpec: QuickSpec { on: "testServer", using: testTransaction, dependencies: dependencies - ) + ) { didComplete = true } + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) expect(mockStorage) .to(call(matchingParameters: true) { $0.setOpenGroup( @@ -1318,6 +1410,8 @@ class OpenGroupManagerSpec: QuickSpec { } it("uses the existing room image id if none is provided") { + var didComplete: Bool = false // Prevent multi-threading test bugs + mockStorage .when { $0.getOpenGroup(for: any()) } .thenReturn( @@ -1355,8 +1449,9 @@ class OpenGroupManagerSpec: QuickSpec { on: "testServer", using: testTransaction, dependencies: dependencies - ) + ) { didComplete = true } + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) expect(mockStorage) .to(call(matchingParameters: true) { $0.setOpenGroup( @@ -1386,6 +1481,8 @@ class OpenGroupManagerSpec: QuickSpec { } it("uses the new room image id if there is an existing one") { + var didComplete: Bool = false // Prevent multi-threading test bugs + testGroupThread.mockData[.groupModel] = TSGroupModel( title: "TestTitle", memberIds: [], @@ -1458,8 +1555,9 @@ class OpenGroupManagerSpec: QuickSpec { on: "testServer", using: testTransaction, dependencies: dependencies - ) + ) { didComplete = true } + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) expect(mockStorage) .to(call(matchingParameters: true) { $0.setOpenGroup( @@ -1489,6 +1587,8 @@ class OpenGroupManagerSpec: QuickSpec { } it("does nothing if there is no room image") { + var didComplete: Bool = false // Prevent multi-threading test bugs + OpenGroupManager.handlePollInfo( testPollInfo, publicKey: TestConstants.publicKey, @@ -1496,8 +1596,9 @@ class OpenGroupManagerSpec: QuickSpec { on: "testServer", using: testTransaction, dependencies: dependencies - ) + ) { didComplete = true } + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) expect(testGroupThread.groupModel.groupImage) .toEventually( beNil(), @@ -1511,6 +1612,8 @@ class OpenGroupManagerSpec: QuickSpec { } it("does nothing if it fails to retrieve the room image") { + var didComplete: Bool = false // Prevent multi-threading test bugs + mockOGMCache.when { $0.groupImagePromises } .thenReturn(["testServer.testRoom": Promise(error: HTTP.Error.generic)]) @@ -1564,8 +1667,9 @@ class OpenGroupManagerSpec: QuickSpec { on: "testServer", using: testTransaction, dependencies: dependencies - ) + ) { didComplete = true } + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) expect(testGroupThread.groupModel.groupImage) .toEventually( beNil(), @@ -1579,6 +1683,8 @@ class OpenGroupManagerSpec: QuickSpec { } it("saves the retrieved room image") { + var didComplete: Bool = false // Prevent multi-threading test bugs + testPollInfo = OpenGroupAPI.RoomPollInfo( token: "testRoom", activeUsers: 10, @@ -1629,8 +1735,9 @@ class OpenGroupManagerSpec: QuickSpec { on: "testServer", using: testTransaction, dependencies: dependencies - ) + ) { didComplete = true } + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) expect(testGroupThread.groupModel.groupImage) .toEventuallyNot( beNil(), @@ -1649,6 +1756,11 @@ class OpenGroupManagerSpec: QuickSpec { context("when handling messages") { beforeEach { + testTransaction.mockData[.objectForKey] = [ + "TestGroupId": testGroupThread, + "TestMessageId": testIncomingMessage + ] + mockStorage .when { $0.setOpenGroupSequenceNumber( @@ -1659,6 +1771,33 @@ class OpenGroupManagerSpec: QuickSpec { ) } .thenReturn(()) + mockStorage.when { $0.getUserPublicKey() }.thenReturn("05\(TestConstants.publicKey)") + mockStorage.when { $0.getReceivedMessageTimestamps(using: testTransaction as Any) }.thenReturn([]) + mockStorage.when { $0.addReceivedMessageTimestamp(any(), using: testTransaction as Any) }.thenReturn(()) + mockStorage.when { $0.persist(anyArray(), using: testTransaction as Any) }.thenReturn([]) + mockStorage + .when { + $0.getOrCreateThread( + for: any(), + groupPublicKey: any(), + openGroupID: any(), + using: testTransaction as Any + ) + } + .thenReturn("TestGroupId") + mockStorage + .when { + $0.persist( + any(), + quotedMessage: nil, + linkPreview: nil, + groupPublicKey: any(), + openGroupID: any(), + using: testTransaction as Any + ) + } + .thenReturn("TestMessageId") + mockStorage.when { $0.getContact(with: any()) }.thenReturn(nil) } it("updates the sequence number when there are messages") { @@ -1690,7 +1829,7 @@ class OpenGroupManagerSpec: QuickSpec { for: "testRoom", on: "testServer", to: 124, - using: testTransaction as Any + using: testTransaction! as Any ) }) } @@ -1710,6 +1849,208 @@ class OpenGroupManagerSpec: QuickSpec { $0.setOpenGroupSequenceNumber(for: any(), on: any(), to: any(), using: testTransaction as Any) }) } + + it("ignores a message with no sender") { + OpenGroupManager.handleMessages( + [ + OpenGroupAPI.Message( + id: 1, + sender: nil, + posted: 123, + edited: nil, + seqNo: 124, + whisper: false, + whisperMods: false, + whisperTo: nil, + base64EncodedData: Data([1, 2, 3]).base64EncodedString(), + base64EncodedSignature: nil + ) + ], + for: "testRoom", + on: "testServer", + isBackgroundPoll: false, + using: testTransaction, + dependencies: dependencies + ) + + expect(testIncomingMessage.didCallSave).toEventuallyNot(beTrue(), timeout: .milliseconds(50)) + expect(testIncomingMessage.didCallRemove).toEventuallyNot(beTrue(), timeout: .milliseconds(50)) + } + + it("ignores a message with invalid data") { + OpenGroupManager.handleMessages( + [ + OpenGroupAPI.Message( + id: 1, + sender: "05\(TestConstants.publicKey)", + posted: 123, + edited: nil, + seqNo: 124, + whisper: false, + whisperMods: false, + whisperTo: nil, + base64EncodedData: Data([1, 2, 3]).base64EncodedString(), + base64EncodedSignature: nil + ) + ], + for: "testRoom", + on: "testServer", + isBackgroundPoll: false, + using: testTransaction, + dependencies: dependencies + ) + + expect(testIncomingMessage.didCallSave).toEventuallyNot(beTrue(), timeout: .milliseconds(50)) + expect(testIncomingMessage.didCallRemove).toEventuallyNot(beTrue(), timeout: .milliseconds(50)) + } + + it("processes a message with valid data") { + OpenGroupManager.handleMessages( + [testMessage], + for: "testRoom", + on: "testServer", + isBackgroundPoll: false, + using: testTransaction, + dependencies: dependencies + ) + + expect(testIncomingMessage.didCallSave) + .toEventually( + beTrue(), + timeout: .milliseconds(100) + ) + } + + it("processes valid messages when combined with invalid ones") { + OpenGroupManager.handleMessages( + [ + OpenGroupAPI.Message( + id: 2, + sender: "05\(TestConstants.publicKey)", + posted: 122, + edited: nil, + seqNo: 123, + whisper: false, + whisperMods: false, + whisperTo: nil, + base64EncodedData: Data([1, 2, 3]).base64EncodedString(), + base64EncodedSignature: nil + ), + testMessage, + ], + for: "testRoom", + on: "testServer", + isBackgroundPoll: false, + using: testTransaction, + dependencies: dependencies + ) + + expect(testIncomingMessage.didCallSave) + .toEventually( + beTrue(), + timeout: .milliseconds(100) + ) + } + + context("with no data") { + it("deletes the message if we have the message") { + testTransaction.mockData[.objectForKey] = testGroupThread + + OpenGroupManager.handleMessages( + [ + OpenGroupAPI.Message( + id: 127, + sender: "05\(TestConstants.publicKey)", + posted: 123, + edited: nil, + seqNo: 123, + whisper: false, + whisperMods: false, + whisperTo: nil, + base64EncodedData: nil, + base64EncodedSignature: nil + ) + ], + for: "testRoom", + on: "testServer", + isBackgroundPoll: false, + using: testTransaction, + dependencies: dependencies + ) + + expect(testIncomingMessage.didCallRemove) + .toEventually( + beTrue(), + timeout: .milliseconds(100) + ) + } + + it("does nothing if we do not have the thread") { + testTransaction.mockData[.objectForKey] = nil + + OpenGroupManager.handleMessages( + [ + OpenGroupAPI.Message( + id: 1, + sender: "05\(TestConstants.publicKey)", + posted: 123, + edited: nil, + seqNo: 123, + whisper: false, + whisperMods: false, + whisperTo: nil, + base64EncodedData: nil, + base64EncodedSignature: nil + ) + ], + for: "testRoom", + on: "testServer", + isBackgroundPoll: false, + using: testTransaction, + dependencies: dependencies + ) + + expect(testIncomingMessage.didCallRemove) + .toEventuallyNot( + beTrue(), + timeout: .milliseconds(100) + ) + } + + it("does nothing if we do not have the message") { + testGroupThread.mockData[.interactions] = [testInteraction] + testTransaction.mockData[.objectForKey] = testGroupThread + + OpenGroupManager.handleMessages( + [ + OpenGroupAPI.Message( + id: 127, + sender: "05\(TestConstants.publicKey)", + posted: 123, + edited: nil, + seqNo: 123, + whisper: false, + whisperMods: false, + whisperTo: nil, + base64EncodedData: nil, + base64EncodedSignature: nil + ) + ], + for: "testRoom", + on: "testServer", + isBackgroundPoll: false, + using: testTransaction, + dependencies: dependencies + ) + + expect(testIncomingMessage.didCallRemove) + .toEventuallyNot( + beTrue(), + timeout: .milliseconds(100) + ) + } + } + } } } } diff --git a/SessionMessagingKitTests/_TestUtilities/MockedExtensions.swift b/SessionMessagingKitTests/_TestUtilities/MockedExtensions.swift index 1d5bce472..fde62b34c 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockedExtensions.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockedExtensions.swift @@ -21,3 +21,7 @@ extension OpenGroupAPI.Server: Mocked { capabilities: OpenGroupAPI.Capabilities(capabilities: anyArray(), missing: anyArray()) ) } + +extension VisibleMessage: Mocked { + static var mockValue: VisibleMessage = VisibleMessage() +} diff --git a/SessionMessagingKitTests/_TestUtilities/TestIncomingMessage.swift b/SessionMessagingKitTests/_TestUtilities/TestIncomingMessage.swift new file mode 100644 index 000000000..66743e49e --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/TestIncomingMessage.swift @@ -0,0 +1,23 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionMessagingKit + +// FIXME: Turn this into a protocol to make mocking possible +class TestIncomingMessage: TSIncomingMessage, Mockable { + // MARK: - Mockable + + enum DataKey: Hashable { + } + + typealias Key = DataKey + + var mockData: [DataKey: Any] = [:] + var didCallSave: Bool = false + var didCallRemove: Bool = false + + // MARK: - TSInteraction + + override func save(with transaction: YapDatabaseReadWriteTransaction) { didCallSave = true } + override func remove(with transaction: YapDatabaseReadWriteTransaction) { didCallRemove = true } +} diff --git a/SessionMessagingKitTests/_TestUtilities/TestInteraction.swift b/SessionMessagingKitTests/_TestUtilities/TestInteraction.swift index 353c97a9b..1c8a85160 100644 --- a/SessionMessagingKitTests/_TestUtilities/TestInteraction.swift +++ b/SessionMessagingKitTests/_TestUtilities/TestInteraction.swift @@ -15,7 +15,7 @@ class TestInteraction: TSInteraction, Mockable { typealias Key = DataKey var mockData: [DataKey: Any] = [:] - var didCallSave: Bool = true + var didCallSave: Bool = false // MARK: - TSInteraction diff --git a/SessionMessagingKitTests/_TestUtilities/TestTransaction.swift b/SessionMessagingKitTests/_TestUtilities/TestTransaction.swift index 0cd847d91..bfe5f0e4c 100644 --- a/SessionMessagingKitTests/_TestUtilities/TestTransaction.swift +++ b/SessionMessagingKitTests/_TestUtilities/TestTransaction.swift @@ -18,6 +18,10 @@ final class TestTransaction: YapDatabaseReadWriteTransaction, Mockable { // MARK: - YapDatabaseReadWriteTransaction override func object(forKey key: String, inCollection collection: String?) -> Any? { + if let dictionary: [String: Any] = mockData[.objectForKey] as? [String: Any] { + return dictionary[key] + } + return mockData[.objectForKey] } From ff480a37d56d45e0539758979cb410b91d3e8e98 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 17 Mar 2022 12:05:04 +1100 Subject: [PATCH 039/157] Added more unit tests for the OpenGroupManager Added the ability to mock the identity manager (ie. return a mock key for the users key) Swapped the Test Nonce16 & Nonce24 classes for Mock ones Fixed an issue where the ContactUtilities call from the OpenGroupManager wasn't injecting the dependencies Added unit tests for the handleDirectMessages and isUserModetatorOrAdmin methods --- Session.xcodeproj/project.pbxproj | 24 +- .../Contacts/BlindedIdMapping.swift | 2 +- .../IdentityManagerProtocol.swift | 10 + .../Open Groups/OpenGroupManager.swift | 14 +- .../MessageReceiver+Decryption.swift | 2 + .../Utilities/Dependencies.swift | 8 + SessionMessagingKit/Utilities/General.swift | 6 +- .../Open Groups/OpenGroupAPISpec.swift | 27 +- .../Open Groups/OpenGroupManagerSpec.swift | 961 +++++++++++++++++- .../_TestUtilities/DependencyExtensions.swift | 2 + .../_TestUtilities/MockIdentityManager.swift | 9 + .../_TestUtilities/MockNonce16Generator.swift | 11 + .../_TestUtilities/MockNonce24Generator.swift | 11 + .../_TestUtilities/MockedExtensions.swift | 8 + .../OGMDependencyExtensions.swift | 2 + .../_TestUtilities/TestContactThread.swift | 51 + .../_TestUtilities/TestThread.swift | 26 - 17 files changed, 1118 insertions(+), 56 deletions(-) create mode 100644 SessionMessagingKit/IdentityManagerProtocol.swift create mode 100644 SessionMessagingKitTests/_TestUtilities/MockIdentityManager.swift create mode 100644 SessionMessagingKitTests/_TestUtilities/MockNonce16Generator.swift create mode 100644 SessionMessagingKitTests/_TestUtilities/MockNonce24Generator.swift create mode 100644 SessionMessagingKitTests/_TestUtilities/TestContactThread.swift delete mode 100644 SessionMessagingKitTests/_TestUtilities/TestThread.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index d73eef2d9..8c139ea7d 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -782,6 +782,10 @@ FD078E5227E1760A000769AF /* OGMDependencyExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E5127E1760A000769AF /* OGMDependencyExtensions.swift */; }; FD078E5427E197CA000769AF /* OpenGroupManagerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909D27D85751005DAE71 /* OpenGroupManagerSpec.swift */; }; FD078E5827E1B831000769AF /* TestIncomingMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E5727E1B831000769AF /* TestIncomingMessage.swift */; }; + FD078E5A27E29F09000769AF /* MockNonce16Generator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E5927E29F09000769AF /* MockNonce16Generator.swift */; }; + FD078E5C27E29F78000769AF /* MockNonce24Generator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E5B27E29F78000769AF /* MockNonce24Generator.swift */; }; + FD078E5E27E2B9C2000769AF /* IdentityManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E5D27E2B9C2000769AF /* IdentityManagerProtocol.swift */; }; + FD078E6027E2BB36000769AF /* MockIdentityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E5F27E2BB36000769AF /* MockIdentityManager.swift */; }; FD0BA51B27CD88EC00CC6805 /* BlindedIdMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0BA51A27CD88EC00CC6805 /* BlindedIdMapping.swift */; }; FD0BA51D27CDC34600CC6805 /* SOGSV4Migration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0BA51C27CDC34600CC6805 /* SOGSV4Migration.swift */; }; FD5D200F27AA2B6000FEA984 /* MessageRequestResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */; }; @@ -828,7 +832,7 @@ FDC2909827D7129B005DAE71 /* PersonalizationSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909727D7129B005DAE71 /* PersonalizationSpec.swift */; }; FDC2909A27D71376005DAE71 /* NonceGeneratorSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909927D71376005DAE71 /* NonceGeneratorSpec.swift */; }; FDC2909C27D713D2005DAE71 /* SodiumProtocolsSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909B27D713D2005DAE71 /* SodiumProtocolsSpec.swift */; }; - FDC290A027D85826005DAE71 /* TestThread.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909F27D85826005DAE71 /* TestThread.swift */; }; + FDC290A027D85826005DAE71 /* TestContactThread.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909F27D85826005DAE71 /* TestContactThread.swift */; }; FDC290A227D85890005DAE71 /* TestInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A127D85890005DAE71 /* TestInteraction.swift */; }; FDC290A627D860CE005DAE71 /* Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A527D860CE005DAE71 /* Mock.swift */; }; FDC290A827D9B46D005DAE71 /* NimbleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */; }; @@ -1928,6 +1932,10 @@ FD078E4E27E175F1000769AF /* DependencyExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DependencyExtensions.swift; sourceTree = ""; }; FD078E5127E1760A000769AF /* OGMDependencyExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OGMDependencyExtensions.swift; sourceTree = ""; }; FD078E5727E1B831000769AF /* TestIncomingMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestIncomingMessage.swift; sourceTree = ""; }; + FD078E5927E29F09000769AF /* MockNonce16Generator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNonce16Generator.swift; sourceTree = ""; }; + FD078E5B27E29F78000769AF /* MockNonce24Generator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNonce24Generator.swift; sourceTree = ""; }; + FD078E5D27E2B9C2000769AF /* IdentityManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityManagerProtocol.swift; sourceTree = ""; }; + FD078E5F27E2BB36000769AF /* MockIdentityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockIdentityManager.swift; sourceTree = ""; }; FD0BA51A27CD88EC00CC6805 /* BlindedIdMapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlindedIdMapping.swift; sourceTree = ""; }; FD0BA51C27CDC34600CC6805 /* SOGSV4Migration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSV4Migration.swift; sourceTree = ""; }; FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestResponse.swift; sourceTree = ""; }; @@ -1977,7 +1985,7 @@ FDC2909927D71376005DAE71 /* NonceGeneratorSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonceGeneratorSpec.swift; sourceTree = ""; }; FDC2909B27D713D2005DAE71 /* SodiumProtocolsSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SodiumProtocolsSpec.swift; sourceTree = ""; }; FDC2909D27D85751005DAE71 /* OpenGroupManagerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupManagerSpec.swift; sourceTree = ""; }; - FDC2909F27D85826005DAE71 /* TestThread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestThread.swift; sourceTree = ""; }; + FDC2909F27D85826005DAE71 /* TestContactThread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestContactThread.swift; sourceTree = ""; }; FDC290A127D85890005DAE71 /* TestInteraction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestInteraction.swift; sourceTree = ""; }; FDC290A527D860CE005DAE71 /* Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mock.swift; sourceTree = ""; }; FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NimbleExtensions.swift; sourceTree = ""; }; @@ -3573,6 +3581,7 @@ C32C5BB9256DC7C4003C73A2 /* To Do */, C3BBE0752554CDA60050F1E3 /* Configuration.swift */, C3BBE07F2554CDD70050F1E3 /* Storage.swift */, + FD078E5D27E2B9C2000769AF /* IdentityManagerProtocol.swift */, FDC4384D27B47FD600C60D73 /* Common Networking */, B8B3201F258B1A540020074B /* Contacts */, C32C5BCB256DC818003C73A2 /* Database */, @@ -4049,18 +4058,21 @@ isa = PBXGroup; children = ( FDC438BC27BB2AB400C60D73 /* Mockable.swift */, + FD078E5F27E2BB36000769AF /* MockIdentityManager.swift */, FDC4389C27BA01F000C60D73 /* MockStorage.swift */, FD859EF327C2F49200510D0C /* MockSodium.swift */, FD859EF527C2F52C00510D0C /* MockSign.swift */, FD859EF727C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift */, FD859EF927C2F5C500510D0C /* MockGenericHash.swift */, FD859EFB27C2F60700510D0C /* MockEd25519.swift */, + FD078E5927E29F09000769AF /* MockNonce16Generator.swift */, + FD078E5B27E29F78000769AF /* MockNonce24Generator.swift */, FD83B9D127D59495005E1583 /* MockUserDefaults.swift */, FD078E4C27E17156000769AF /* MockOGMCache.swift */, FDC290B227DFF9F5005DAE71 /* TestOnionRequestAPI.swift */, FDC290AE27DFEE97005DAE71 /* TestTransaction.swift */, - FDC2909F27D85826005DAE71 /* TestThread.swift */, FDC290B627E00FDB005DAE71 /* TestGroupThread.swift */, + FDC2909F27D85826005DAE71 /* TestContactThread.swift */, FDC290A127D85890005DAE71 /* TestInteraction.swift */, FD078E5727E1B831000769AF /* TestIncomingMessage.swift */, FDC290AB27DB0B1C005DAE71 /* MockedExtensions.swift */, @@ -5350,6 +5362,7 @@ C32C59C3256DB41F003C73A2 /* TSGroupModel.m in Sources */, B8856ECE256F1E58001CE70E /* OWSPreferences.m in Sources */, C3C2A7842553AAF300C340D1 /* SNProto.swift in Sources */, + FD078E5E27E2B9C2000769AF /* IdentityManagerProtocol.swift in Sources */, C32C5DC0256DD743003C73A2 /* Poller.swift in Sources */, C3C2A7682553A3D900C340D1 /* VisibleMessage+Contact.swift in Sources */, C3A3A171256E1D25004D228D /* SSKReachabilityManager.swift in Sources */, @@ -5666,6 +5679,7 @@ FDC290AF27DFEE97005DAE71 /* TestTransaction.swift in Sources */, FDC290B327DFF9F5005DAE71 /* TestOnionRequestAPI.swift in Sources */, FDC2909127D709CA005DAE71 /* SOGSMessageSpec.swift in Sources */, + FD078E6027E2BB36000769AF /* MockIdentityManager.swift in Sources */, FDC2909627D71252005DAE71 /* SOGSErrorSpec.swift in Sources */, FDC2908727D7047F005DAE71 /* RoomSpec.swift in Sources */, FD078E4F27E175F1000769AF /* DependencyExtensions.swift in Sources */, @@ -5674,7 +5688,7 @@ FD83B9C727CF3F10005E1583 /* CapabilitiesSpec.swift in Sources */, FDC2909A27D71376005DAE71 /* NonceGeneratorSpec.swift in Sources */, FD078E4827E02561000769AF /* CommonMockedExtensions.swift in Sources */, - FDC290A027D85826005DAE71 /* TestThread.swift in Sources */, + FDC290A027D85826005DAE71 /* TestContactThread.swift in Sources */, FD859EF827C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift in Sources */, FDC2909827D7129B005DAE71 /* PersonalizationSpec.swift in Sources */, FD859EF427C2F49200510D0C /* MockSodium.swift in Sources */, @@ -5692,11 +5706,13 @@ FDC438BD27BB2AB400C60D73 /* Mockable.swift in Sources */, FD859EF627C2F52C00510D0C /* MockSign.swift in Sources */, FDC2908927D70656005DAE71 /* RoomPollInfoSpec.swift in Sources */, + FD078E5A27E29F09000769AF /* MockNonce16Generator.swift in Sources */, FDC2908D27D70905005DAE71 /* UpdateMessageRequestSpec.swift in Sources */, FDC290A227D85890005DAE71 /* TestInteraction.swift in Sources */, FD078E5427E197CA000769AF /* OpenGroupManagerSpec.swift in Sources */, FDC4389D27BA01F000C60D73 /* MockStorage.swift in Sources */, FD83B9D227D59495005E1583 /* MockUserDefaults.swift in Sources */, + FD078E5C27E29F78000769AF /* MockNonce24Generator.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/SessionMessagingKit/Contacts/BlindedIdMapping.swift b/SessionMessagingKit/Contacts/BlindedIdMapping.swift index 09a0cf7d4..5b289c96b 100644 --- a/SessionMessagingKit/Contacts/BlindedIdMapping.swift +++ b/SessionMessagingKit/Contacts/BlindedIdMapping.swift @@ -3,7 +3,7 @@ import Foundation @objc(SNBlindedIdMapping) -public class BlindedIdMapping: NSObject, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility +public final class BlindedIdMapping: NSObject, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility @objc public let blindedId: String @objc public let sessionId: String @objc public let serverPublicKey: String diff --git a/SessionMessagingKit/IdentityManagerProtocol.swift b/SessionMessagingKit/IdentityManagerProtocol.swift new file mode 100644 index 000000000..be40b4d0a --- /dev/null +++ b/SessionMessagingKit/IdentityManagerProtocol.swift @@ -0,0 +1,10 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Curve25519Kit + +public protocol IdentityManagerProtocol { + func identityKeyPair() -> ECKeyPair? +} + +extension OWSIdentityManager: IdentityManagerProtocol {} diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 48989ece0..94632cda6 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -211,7 +211,7 @@ public final class OpenGroupManager: NSObject { ) { // Create the open group model and get or create the thread let groupId: Data = LKGroupUtilities.getEncodedOpenGroupIDAsData("\(server).\(roomToken)") - let userPublicKey: String = getUserHexEncodedPublicKey() + let userPublicKey: String = getUserHexEncodedPublicKey(using: dependencies) let initialModel: TSGroupModel = TSGroupModel( title: (pollInfo.details?.name ?? ""), memberIds: [ userPublicKey ], @@ -374,8 +374,6 @@ public final class OpenGroupManager: NSObject { internal static func handleDirectMessages( _ messages: [OpenGroupAPI.DirectMessage], - // We could infer where the messages come from based on their sender/recipient values but being since they - // are different endpoints being explicit here reduces the chance a future change will break things fromOutbox: Bool, on server: String, isBackgroundPoll: Bool, @@ -393,7 +391,7 @@ public final class OpenGroupManager: NSObject { // that quote older messages can't find those older messages let sortedMessages: [OpenGroupAPI.DirectMessage] = messages .sorted { lhs, rhs in lhs.id < rhs.id } - let latestMessageId: Int64 = (sortedMessages.last?.id ?? 0) + let latestMessageId: Int64 = sortedMessages[sortedMessages.count - 1].id var mappingCache: [String: BlindedIdMapping] = [:] // Only want this cache to exist for the current loop // Update the 'latestMessageId' value @@ -442,7 +440,7 @@ public final class OpenGroupManager: NSObject { if let result: BlindedIdMapping = mappingCache[message.recipient] { mapping = result } - else if let result: BlindedIdMapping = ContactUtilities.mapping(for: message.recipient, serverPublicKey: serverPublicKey, using: transaction) { + else if let result: BlindedIdMapping = ContactUtilities.mapping(for: message.recipient, serverPublicKey: serverPublicKey, using: transaction, dependencies: dependencies) { mapping = result } else { @@ -497,7 +495,7 @@ public final class OpenGroupManager: NSObject { // case with only minor efficiency losses switch sessionId.prefix { case .standard: - guard publicKey == getUserHexEncodedPublicKey() else { return false } + guard publicKey == getUserHexEncodedPublicKey(using: dependencies) else { return false } fallthrough case .unblinded: @@ -523,7 +521,7 @@ public final class OpenGroupManager: NSObject { // 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([ - getUserHexEncodedPublicKey(), + getUserHexEncodedPublicKey(using: dependencies), SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString, SessionId(.blinded, publicKey: blindedKeyPair.publicKey).hexString ]) @@ -698,6 +696,7 @@ extension OpenGroupManager { public init( cache: Atomic? = nil, onionApi: OnionRequestAPIType.Type? = nil, + identityManager: IdentityManagerProtocol? = nil, storage: SessionMessagingKitStorageProtocol? = nil, sodium: SodiumType? = nil, aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, @@ -713,6 +712,7 @@ extension OpenGroupManager { super.init( onionApi: onionApi, + identityManager: identityManager, storage: storage, sodium: sodium, aeadXChaCha20Poly1305Ietf: aeadXChaCha20Poly1305Ietf, diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift index 1565ed80e..8491ed794 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift @@ -28,6 +28,8 @@ extension MessageReceiver { } internal static func decryptWithSessionBlindingProtocol(data: Data, isOutgoing: Bool, otherBlindedPublicKey: String, with openGroupPublicKey: String, userEd25519KeyPair: Box.KeyPair, using dependencies: Dependencies = Dependencies()) throws -> (plaintext: Data, senderX25519PublicKey: String) { + /// Ensure the data is at least long enough to have the required components + guard data.count > dependencies.nonceGenerator24.NonceBytes + 2 else { throw Error.decryptionFailed } guard let blindedKeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: openGroupPublicKey, edKeyPair: userEd25519KeyPair, genericHash: dependencies.genericHash) else { throw Error.decryptionFailed } diff --git a/SessionMessagingKit/Utilities/Dependencies.swift b/SessionMessagingKit/Utilities/Dependencies.swift index bff71db56..01273c567 100644 --- a/SessionMessagingKit/Utilities/Dependencies.swift +++ b/SessionMessagingKit/Utilities/Dependencies.swift @@ -12,6 +12,12 @@ public class Dependencies { set { _onionApi = newValue } } + internal var _identityManager: IdentityManagerProtocol? + public var identityManager: IdentityManagerProtocol { + get { Dependencies.getValueSettingIfNull(&_identityManager) { OWSIdentityManager.shared() } } + set { _identityManager = newValue } + } + internal var _storage: SessionMessagingKitStorageProtocol? public var storage: SessionMessagingKitStorageProtocol { get { Dependencies.getValueSettingIfNull(&_storage) { SNMessagingKitConfiguration.shared.storage } } @@ -76,6 +82,7 @@ public class Dependencies { public init( onionApi: OnionRequestAPIType.Type? = nil, + identityManager: IdentityManagerProtocol? = nil, storage: SessionMessagingKitStorageProtocol? = nil, sodium: SodiumType? = nil, aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, @@ -88,6 +95,7 @@ public class Dependencies { date: Date? = nil ) { _onionApi = onionApi + _identityManager = identityManager _storage = storage _sodium = sodium _aeadXChaCha20Poly1305Ietf = aeadXChaCha20Poly1305Ietf diff --git a/SessionMessagingKit/Utilities/General.swift b/SessionMessagingKit/Utilities/General.swift index 62fee4eb1..b7bee1ea3 100644 --- a/SessionMessagingKit/Utilities/General.swift +++ b/SessionMessagingKit/Utilities/General.swift @@ -1,7 +1,9 @@ +import Foundation -public func getUserHexEncodedPublicKey() -> String { - if let keyPair = OWSIdentityManager.shared().identityKeyPair() { // Can be nil under some circumstances +public func getUserHexEncodedPublicKey(using dependencies: Dependencies = Dependencies()) -> String { + if let keyPair = dependencies.identityManager.identityKeyPair() { // Can be nil under some circumstances return keyPair.hexEncodedPublicKey } + return "" } diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift index bf2c37c24..94bc382d8 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift @@ -10,18 +10,6 @@ import Nimble @testable import SessionMessagingKit class OpenGroupAPISpec: QuickSpec { - struct TestNonce16Generator: NonceGenerator16ByteType { - var NonceBytes: Int = 16 - - func nonce() -> Array { return Data(base64Encoded: "pK6YRtQApl4NhECGizF0Cg==")!.bytes } - } - - struct TestNonce24Generator: NonceGenerator24ByteType { - var NonceBytes: Int = 24 - - func nonce() -> Array { return Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")!.bytes } - } - // MARK: - Spec override func spec() { @@ -31,6 +19,8 @@ class OpenGroupAPISpec: QuickSpec { var mockSign: MockSign! var mockGenericHash: MockGenericHash! var mockEd25519: MockEd25519! + var mockNonce16Generator: MockNonce16Generator! + var mockNonce24Generator: MockNonce24Generator! var dependencies: Dependencies! var response: (OnionRequestResponseInfoType, Codable)? = nil @@ -46,6 +36,8 @@ class OpenGroupAPISpec: QuickSpec { mockAeadXChaCha20Poly1305Ietf = MockAeadXChaCha20Poly1305Ietf() mockSign = MockSign() mockGenericHash = MockGenericHash() + mockNonce16Generator = MockNonce16Generator() + mockNonce24Generator = MockNonce24Generator() mockEd25519 = MockEd25519() dependencies = Dependencies( onionApi: TestOnionRequestAPI.self, @@ -55,8 +47,8 @@ class OpenGroupAPISpec: QuickSpec { sign: mockSign, genericHash: mockGenericHash, ed25519: mockEd25519, - nonceGenerator16: TestNonce16Generator(), - nonceGenerator24: TestNonce24Generator(), + nonceGenerator16: mockNonce16Generator, + nonceGenerator24: mockNonce24Generator, date: Date(timeIntervalSince1970: 1234567890) ) @@ -137,6 +129,13 @@ class OpenGroupAPISpec: QuickSpec { .thenReturn("TestSogsSignature".bytes) mockSign.when { $0.signature(message: anyArray(), secretKey: anyArray()) }.thenReturn("TestSignature".bytes) mockEd25519.when { try $0.sign(data: anyArray(), keyPair: any()) }.thenReturn("TestStandardSignature".bytes) + + mockNonce16Generator + .when { $0.nonce() } + .thenReturn(Data(base64Encoded: "pK6YRtQApl4NhECGizF0Cg==")!.bytes) + mockNonce24Generator + .when { $0.nonce() } + .thenReturn(Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")!.bytes) } afterEach { diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift index 953bcff2f..9e4d1fbe2 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -70,21 +70,26 @@ class OpenGroupManagerSpec: QuickSpec { override func spec() { var mockOGMCache: MockOGMCache! + var mockIdentityManager: MockIdentityManager! var mockStorage: MockStorage! var mockSodium: MockSodium! var mockAeadXChaCha20Poly1305Ietf: MockAeadXChaCha20Poly1305Ietf! var mockGenericHash: MockGenericHash! var mockSign: MockSign! + var mockNonce16Generator: MockNonce16Generator! + var mockNonce24Generator: MockNonce24Generator! var mockUserDefaults: MockUserDefaults! var dependencies: OpenGroupManager.OGMDependencies! var testInteraction: TestInteraction! var testIncomingMessage: TestIncomingMessage! var testGroupThread: TestGroupThread! + var testContactThread: TestContactThread! var testTransaction: TestTransaction! var testOpenGroup: OpenGroup! var testPollInfo: OpenGroupAPI.RoomPollInfo! var testMessage: OpenGroupAPI.Message! + var testDirectMessage: OpenGroupAPI.DirectMessage! var cache: OpenGroupManager.Cache! var openGroupManager: OpenGroupManager! @@ -94,23 +99,27 @@ class OpenGroupManagerSpec: QuickSpec { beforeEach { mockOGMCache = MockOGMCache() + mockIdentityManager = MockIdentityManager() mockStorage = MockStorage() mockSodium = MockSodium() mockAeadXChaCha20Poly1305Ietf = MockAeadXChaCha20Poly1305Ietf() mockGenericHash = MockGenericHash() mockSign = MockSign() + mockNonce16Generator = MockNonce16Generator() + mockNonce24Generator = MockNonce24Generator() mockUserDefaults = MockUserDefaults() dependencies = OpenGroupManager.OGMDependencies( cache: Atomic(mockOGMCache), onionApi: TestCapabilitiesAndRoomApi.self, + identityManager: mockIdentityManager, storage: mockStorage, sodium: mockSodium, aeadXChaCha20Poly1305Ietf: mockAeadXChaCha20Poly1305Ietf, sign: mockSign, genericHash: mockGenericHash, ed25519: MockEd25519(), - nonceGenerator16: OpenGroupAPISpec.TestNonce16Generator(), - nonceGenerator24: OpenGroupAPISpec.TestNonce24Generator(), + nonceGenerator16: mockNonce16Generator, + nonceGenerator24: mockNonce24Generator, standardUserDefaults: mockUserDefaults, date: Date(timeIntervalSince1970: 1234567890) ) @@ -134,6 +143,10 @@ class OpenGroupManagerSpec: QuickSpec { ) testGroupThread.mockData[.interactions] = [testInteraction, testIncomingMessage] + testContactThread = TestContactThread() + testContactThread.mockData[.uniqueId] = "TestContactId" + testContactThread.mockData[.interactions] = [testInteraction, testIncomingMessage] + testTransaction = TestTransaction() testTransaction.mockData[.objectForKey] = testGroupThread @@ -186,7 +199,27 @@ class OpenGroupManagerSpec: QuickSpec { ].joined(), base64EncodedSignature: nil ) + testDirectMessage = OpenGroupAPI.DirectMessage( + id: 128, + sender: "15\(TestConstants.publicKey)", + recipient: "15\(TestConstants.publicKey)", + posted: 1234567890, + expires: 1234567990, + base64EncodedMessage: Data( + Bytes(arrayLiteral: 0) + + "TestMessage".bytes + + Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")!.bytes + ).base64EncodedString() + ) + mockIdentityManager + .when { $0.identityKeyPair() } + .thenReturn( + try! ECKeyPair( + publicKeyData: Data.data(fromHex: TestConstants.publicKey)!, + privateKeyData: Data.data(fromHex: TestConstants.privateKey)! + ) + ) mockStorage .when { $0.write(with: { _ in }) } .then { args in (args.first as? ((Any) -> Void))?(testTransaction as Any) } @@ -255,6 +288,13 @@ class OpenGroupManagerSpec: QuickSpec { .thenReturn("TestSogsSignature".bytes) mockSign.when { $0.signature(message: anyArray(), secretKey: anyArray()) }.thenReturn("TestSignature".bytes) + mockNonce16Generator + .when { $0.nonce() } + .thenReturn(Data(base64Encoded: "pK6YRtQApl4NhECGizF0Cg==")!.bytes) + mockNonce24Generator + .when { $0.nonce() } + .thenReturn(Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")!.bytes) + cache = OpenGroupManager.Cache() openGroupManager = OpenGroupManager() } @@ -274,6 +314,7 @@ class OpenGroupManagerSpec: QuickSpec { testInteraction = nil testGroupThread = nil + testContactThread = nil testTransaction = nil testOpenGroup = nil @@ -2051,6 +2092,922 @@ class OpenGroupManagerSpec: QuickSpec { } } } + + // MARK: - --handleDirectMessages + + context("when handling direct messages") { + beforeEach { + testTransaction.mockData[.objectForKey] = testContactThread + + mockStorage + .when { $0.setOpenGroupInboxLatestMessageId(for: any(), to: any(), using: testTransaction as Any) } + .thenReturn(()) + + mockStorage + .when { $0.setOpenGroupOutboxLatestMessageId(for: any(), to: any(), using: testTransaction as Any) } + .thenReturn(()) + mockStorage.when { $0.getUserPublicKey() }.thenReturn("05\(TestConstants.publicKey)") + mockStorage.when { $0.getReceivedMessageTimestamps(using: testTransaction as Any) }.thenReturn([]) + mockStorage.when { $0.addReceivedMessageTimestamp(any(), using: testTransaction as Any) }.thenReturn(()) + mockSodium + .when { + $0.sharedBlindedEncryptionKey( + secretKey: anyArray(), + otherBlindedPublicKey: anyArray(), + fromBlindedPublicKey: anyArray(), + toBlindedPublicKey: anyArray(), + genericHash: mockGenericHash + ) + } + .thenReturn([]) + mockSodium + .when { $0.generateBlindingFactor(serverPublicKey: any()) } + .thenReturn([]) + mockAeadXChaCha20Poly1305Ietf + .when { + $0.decrypt( + authenticatedCipherText: anyArray(), + secretKey: anyArray(), + nonce: anyArray() + ) + } + .thenReturn( + Data(base64Encoded:"ChQKC1Rlc3RNZXNzYWdlONCI7I/3Iw==")!.bytes + + [UInt8](repeating: 0, count: 32) + ) + mockSign + .when { $0.toX25519(ed25519PublicKey: anyArray()) } + .thenReturn(Data(hex: TestConstants.publicKey).bytes) + mockStorage.when { $0.persist(anyArray(), using: testTransaction as Any) }.thenReturn([]) + mockStorage + .when { + $0.getOrCreateThread( + for: any(), + groupPublicKey: any(), + openGroupID: any(), + using: testTransaction as Any + ) + } + .thenReturn("TestContactId") + mockStorage + .when { + $0.persist( + any(), + quotedMessage: nil, + linkPreview: nil, + groupPublicKey: any(), + openGroupID: any(), + using: testTransaction as Any + ) + } + .thenReturn("TestMessageId") + mockStorage.when { $0.getContact(with: any()) }.thenReturn(nil) + mockStorage + .when { $0.getBlindedIdMapping(with: any(), using: testTransaction) } + .thenReturn(nil) + mockStorage + .when { $0.enumerateBlindedIdMapping(using: testTransaction, with: { _, _ in }) } + .then { args in + guard let block = args.first as? (BlindedIdMapping, UnsafeMutablePointer) -> () else { + return + } + + var stop: ObjCBool = false + block(any(), &stop) + } + .thenReturn(()) + } + + it("does nothing if there are no messages") { + OpenGroupManager.handleDirectMessages( + [], + fromOutbox: false, + on: "testServer", + isBackgroundPoll: false, + using: testTransaction, + dependencies: dependencies + ) + + expect(testContactThread.numSaveCalls).to(equal(0)) + expect(mockStorage) + .toNot(call { + $0.setOpenGroupInboxLatestMessageId( + for: any(), + to: any(), + using: testTransaction! as Any + ) + }) + expect(mockStorage) + .toNot(call { + $0.setOpenGroupOutboxLatestMessageId( + for: any(), + to: any(), + using: testTransaction! as Any + ) + }) + } + + it("does nothing if it cannot get the open group public key") { + mockStorage + .when { $0.getOpenGroupPublicKey(for: any()) } + .thenReturn(nil) + + OpenGroupManager.handleDirectMessages( + [testDirectMessage], + fromOutbox: false, + on: "testServer", + isBackgroundPoll: false, + using: testTransaction, + dependencies: dependencies + ) + + expect(testContactThread.numSaveCalls).to(equal(0)) + expect(mockStorage) + .toNot(call { + $0.setOpenGroupInboxLatestMessageId( + for: any(), + to: any(), + using: testTransaction! as Any + ) + }) + expect(mockStorage) + .toNot(call { + $0.setOpenGroupOutboxLatestMessageId( + for: any(), + to: any(), + using: testTransaction! as Any + ) + }) + } + + it("ignores messages with non base64 encoded data") { + testDirectMessage = OpenGroupAPI.DirectMessage( + id: testDirectMessage.id, + sender: testDirectMessage.sender, + recipient: testDirectMessage.recipient, + posted: testDirectMessage.posted, + expires: testDirectMessage.expires, + base64EncodedMessage: "TestMessage%%%" + ) + + OpenGroupManager.handleDirectMessages( + [testDirectMessage], + fromOutbox: false, + on: "testServer", + isBackgroundPoll: false, + using: testTransaction, + dependencies: dependencies + ) + + expect(testContactThread.numSaveCalls).to(equal(0)) + } + + context("for the inbox") { + beforeEach { + mockSodium + .when { $0.combineKeys(lhsKeyBytes: anyArray(), rhsKeyBytes: anyArray()) } + .thenReturn(Data(hex: testDirectMessage.sender.removingIdPrefixIfNeeded()).bytes) + } + + it("updates the inbox latest message id") { + OpenGroupManager.handleDirectMessages( + [testDirectMessage], + fromOutbox: false, + on: "testServer", + isBackgroundPoll: false, + using: testTransaction, + dependencies: dependencies + ) + + expect(mockStorage) + .to(call(matchingParameters: true) { + $0.setOpenGroupInboxLatestMessageId( + for: "testServer", + to: 128, + using: testTransaction! as Any + ) + }) + } + + it("ignores a message with invalid data") { + testDirectMessage = OpenGroupAPI.DirectMessage( + id: testDirectMessage.id, + sender: testDirectMessage.sender, + recipient: testDirectMessage.recipient, + posted: testDirectMessage.posted, + expires: testDirectMessage.expires, + base64EncodedMessage: Data([1, 2, 3]).base64EncodedString() + ) + + OpenGroupManager.handleDirectMessages( + [testDirectMessage], + fromOutbox: false, + on: "testServer", + isBackgroundPoll: false, + using: testTransaction, + dependencies: dependencies + ) + + expect(testContactThread.numSaveCalls).to(equal(0)) + } + + it("processes a message with valid data") { + OpenGroupManager.handleDirectMessages( + [testDirectMessage], + fromOutbox: false, + on: "testServer", + isBackgroundPoll: false, + using: testTransaction, + dependencies: dependencies + ) + + // Saved once per valid inbox message + expect(testContactThread.numSaveCalls).to(equal(1)) + } + + it("processes valid messages when combined with invalid ones") { + OpenGroupManager.handleDirectMessages( + [ + OpenGroupAPI.DirectMessage( + id: testDirectMessage.id, + sender: testDirectMessage.sender, + recipient: testDirectMessage.recipient, + posted: testDirectMessage.posted, + expires: testDirectMessage.expires, + base64EncodedMessage: Data([1, 2, 3]).base64EncodedString() + ), + testDirectMessage + ], + fromOutbox: false, + on: "testServer", + isBackgroundPoll: false, + using: testTransaction, + dependencies: dependencies + ) + + // Saved once per valid inbox message + expect(testContactThread.numSaveCalls).to(equal(1)) + } + } + + context("for the outbox") { + beforeEach { + mockSodium + .when { $0.combineKeys(lhsKeyBytes: anyArray(), rhsKeyBytes: anyArray()) } + .thenReturn(Data(hex: testDirectMessage.recipient.removingIdPrefixIfNeeded()).bytes) + } + + it("updates the outbox latest message id") { + OpenGroupManager.handleDirectMessages( + [testDirectMessage], + fromOutbox: true, + on: "testServer", + isBackgroundPoll: false, + using: testTransaction, + dependencies: dependencies + ) + + expect(mockStorage) + .to(call { + $0.setOpenGroupOutboxLatestMessageId( + for: "testServer", + to: 128, + using: testTransaction! as Any + ) + }) + } + + it("retrieves an existing blinded id mapping") { + mockStorage + .when { $0.getBlindedIdMapping(with: any(), using: testTransaction) } + .thenReturn( + BlindedIdMapping( + blindedId: "15\(TestConstants.publicKey)", + sessionId: "TestSessionId", + serverPublicKey: "05\(TestConstants.publicKey)" + ) + ) + + OpenGroupManager.handleDirectMessages( + [testDirectMessage], + fromOutbox: true, + on: "testServer", + isBackgroundPoll: false, + using: testTransaction, + dependencies: dependencies + ) + + expect(mockStorage) + .to(call(.exactly(times: 1)) { + $0.getBlindedIdMapping(with: any(), using: testTransaction) + }) + expect(mockStorage) + .to(call(matchingParameters: true) { + $0.getOrCreateThread( + for: "TestSessionId", + groupPublicKey: nil, + openGroupID: nil, + using: testTransaction! as Any + ) + }) + + // Saved twice per valid outbox message + expect(testContactThread.numSaveCalls).to(equal(2)) + } + + it("locally caches blinded id mappings for the same recipient") { + mockStorage + .when { $0.getBlindedIdMapping(with: any(), using: testTransaction) } + .thenReturn( + BlindedIdMapping( + blindedId: "15\(TestConstants.publicKey)", + sessionId: "TestSessionId", + serverPublicKey: "05\(TestConstants.publicKey)" + ) + ) + + OpenGroupManager.handleDirectMessages( + [ + testDirectMessage, + OpenGroupAPI.DirectMessage( + id: testDirectMessage.id + 1, + sender: testDirectMessage.sender, + recipient: testDirectMessage.recipient, + posted: testDirectMessage.posted + 1, + expires: testDirectMessage.expires + 1, + base64EncodedMessage: testDirectMessage.base64EncodedMessage + ) + ], + fromOutbox: true, + on: "testServer", + isBackgroundPoll: false, + using: testTransaction, + dependencies: dependencies + ) + + expect(mockStorage) + .to(call(.exactly(times: 1)) { + $0.getBlindedIdMapping(with: any(), using: testTransaction) + }) + + // Saved twice per valid outbox message + expect(testContactThread.numSaveCalls).to(equal(4)) + } + + it("falls back to using the blinded id if no mapping is found") { + OpenGroupManager.handleDirectMessages( + [testDirectMessage], + fromOutbox: true, + on: "testServer", + isBackgroundPoll: false, + using: testTransaction, + dependencies: dependencies + ) + + expect(mockStorage) + .to(call(.exactly(times: 1)) { + $0.getBlindedIdMapping(with: any(), using: testTransaction) + }) + expect(mockStorage) + .to(call(matchingParameters: true) { + $0.getOrCreateThread( + for: "15\(TestConstants.publicKey)", + groupPublicKey: nil, + openGroupID: nil, + using: testTransaction! as Any + ) + }) + + // Saved twice per valid outbox message + expect(testContactThread.numSaveCalls).to(equal(2)) + } + + it("ignores a message with invalid data") { + testDirectMessage = OpenGroupAPI.DirectMessage( + id: testDirectMessage.id, + sender: testDirectMessage.sender, + recipient: testDirectMessage.recipient, + posted: testDirectMessage.posted, + expires: testDirectMessage.expires, + base64EncodedMessage: Data([1, 2, 3]).base64EncodedString() + ) + + OpenGroupManager.handleDirectMessages( + [testDirectMessage], + fromOutbox: true, + on: "testServer", + isBackgroundPoll: false, + using: testTransaction, + dependencies: dependencies + ) + + expect(testContactThread.numSaveCalls).to(equal(0)) + } + + it("processes a message with valid data") { + OpenGroupManager.handleDirectMessages( + [testDirectMessage], + fromOutbox: true, + on: "testServer", + isBackgroundPoll: false, + using: testTransaction, + dependencies: dependencies + ) + + // Saved twice per valid outbox message + expect(testContactThread.numSaveCalls).to(equal(2)) + } + + it("processes valid messages when combined with invalid ones") { + OpenGroupManager.handleDirectMessages( + [ + OpenGroupAPI.DirectMessage( + id: testDirectMessage.id, + sender: testDirectMessage.sender, + recipient: testDirectMessage.recipient, + posted: testDirectMessage.posted, + expires: testDirectMessage.expires, + base64EncodedMessage: Data([1, 2, 3]).base64EncodedString() + ), + testDirectMessage + ], + fromOutbox: true, + on: "testServer", + isBackgroundPoll: false, + using: testTransaction, + dependencies: dependencies + ) + + // Saved twice per valid outbox message + expect(testContactThread.numSaveCalls).to(equal(2)) + } + + it("updates the contact thread with the open group information") { + expect(testContactThread.originalOpenGroupServer).to(beNil()) + expect(testContactThread.originalOpenGroupPublicKey).to(beNil()) + + OpenGroupManager.handleDirectMessages( + [testDirectMessage], + fromOutbox: true, + on: "testServer", + isBackgroundPoll: false, + using: testTransaction, + dependencies: dependencies + ) + + expect(testContactThread.originalOpenGroupServer).to(equal("testServer")) + expect(testContactThread.originalOpenGroupPublicKey).to(equal(TestConstants.publicKey)) + } + } + } + + // MARK: - Convenience + + // MARK: - --isUserModeratorOrAdmin + + context("when determining if a user is a moderator or an admin") { + beforeEach { + mockOGMCache.when { $0.moderators }.thenReturn([:]) + mockOGMCache.when { $0.admins }.thenReturn([:]) + } + + it("uses an empty set for moderators by default") { + expect( + OpenGroupManager.isUserModeratorOrAdmin( + "05\(TestConstants.publicKey)", + for: "testRoom", + on: "testServer", + using: dependencies + ) + ).to(beFalse()) + } + + it("uses an empty set for admins by default") { + expect( + OpenGroupManager.isUserModeratorOrAdmin( + "05\(TestConstants.publicKey)", + for: "testRoom", + on: "testServer", + using: dependencies + ) + ).to(beFalse()) + } + + it("returns true if the key is in the moderator set") { + mockOGMCache.when { $0.moderators } + .thenReturn([ + "testServer": [ + "testRoom": Set(arrayLiteral: "05\(TestConstants.publicKey)") + ] + ]) + + expect( + OpenGroupManager.isUserModeratorOrAdmin( + "05\(TestConstants.publicKey)", + for: "testRoom", + on: "testServer", + using: dependencies + ) + ).to(beTrue()) + } + + it("returns true if the key is in the admin set") { + mockOGMCache.when { $0.admins } + .thenReturn([ + "testServer": [ + "testRoom": Set(arrayLiteral: "05\(TestConstants.publicKey)") + ] + ]) + + expect( + OpenGroupManager.isUserModeratorOrAdmin( + "05\(TestConstants.publicKey)", + for: "testRoom", + on: "testServer", + using: dependencies + ) + ).to(beTrue()) + } + + it("returns false if the key is not a valid session id") { + expect( + OpenGroupManager.isUserModeratorOrAdmin( + "InvalidValue", + for: "testRoom", + on: "testServer", + using: dependencies + ) + ).to(beFalse()) + } + + context("and the key is a standard session id") { + it("returns false if the key is not the users session id") { + let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") + mockIdentityManager + .when { $0.identityKeyPair() } + .thenReturn( + try! ECKeyPair( + publicKeyData: Data.data(fromHex: otherKey)!, + privateKeyData: Data.data(fromHex: TestConstants.privateKey)! + ) + ) + + expect( + OpenGroupManager.isUserModeratorOrAdmin( + "05\(TestConstants.publicKey)", + for: "testRoom", + on: "testServer", + using: dependencies + ) + ).to(beFalse()) + } + + it("returns true if the key is the current users and the users unblinded id is a moderator or admin") { + let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") + mockOGMCache.when { $0.moderators } + .thenReturn([ + "testServer": [ + "testRoom": Set(arrayLiteral: "00\(otherKey)") + ] + ]) + mockStorage + .when { $0.getUserED25519KeyPair() } + .thenReturn( + Box.KeyPair( + publicKey: Data.data(fromHex: otherKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes + ) + ) + + expect( + OpenGroupManager.isUserModeratorOrAdmin( + "05\(TestConstants.publicKey)", + for: "testRoom", + on: "testServer", + using: dependencies + ) + ).to(beTrue()) + } + + 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") + mockOGMCache.when { $0.moderators } + .thenReturn([ + "testServer": [ + "testRoom": Set(arrayLiteral: "15\(otherKey)") + ] + ]) + mockSodium + .when { + $0.blindedKeyPair( + serverPublicKey: any(), + edKeyPair: any(), + genericHash: mockGenericHash + ) + } + .thenReturn( + Box.KeyPair( + publicKey: Data.data(fromHex: otherKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes + ) + ) + + expect( + OpenGroupManager.isUserModeratorOrAdmin( + "05\(TestConstants.publicKey)", + for: "testRoom", + on: "testServer", + using: dependencies + ) + ).to(beTrue()) + } + } + + context("and the key is unblinded") { + it("returns false if unable to retrieve the user ed25519 key") { + mockStorage + .when { $0.getUserED25519KeyPair() } + .thenReturn(nil) + + expect( + OpenGroupManager.isUserModeratorOrAdmin( + "00\(TestConstants.publicKey)", + for: "testRoom", + on: "testServer", + using: dependencies + ) + ).to(beFalse()) + } + + it("returns false if the key is not the users unblinded id") { + let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") + mockStorage + .when { $0.getUserED25519KeyPair() } + .thenReturn( + Box.KeyPair( + publicKey: Data.data(fromHex: otherKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes + ) + ) + + expect( + OpenGroupManager.isUserModeratorOrAdmin( + "00\(TestConstants.publicKey)", + for: "testRoom", + on: "testServer", + using: dependencies + ) + ).to(beFalse()) + } + + 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") + mockOGMCache.when { $0.moderators } + .thenReturn([ + "testServer": [ + "testRoom": Set(arrayLiteral: "05\(otherKey)") + ] + ]) + mockIdentityManager + .when { $0.identityKeyPair() } + .thenReturn( + try! ECKeyPair( + publicKeyData: Data.data(fromHex: otherKey)!, + privateKeyData: Data.data(fromHex: TestConstants.privateKey)! + ) + ) + mockStorage + .when { $0.getUserED25519KeyPair() } + .thenReturn( + Box.KeyPair( + publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes + ) + ) + + expect( + OpenGroupManager.isUserModeratorOrAdmin( + "00\(TestConstants.publicKey)", + for: "testRoom", + on: "testServer", + using: dependencies + ) + ).to(beTrue()) + } + + 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") + mockOGMCache.when { $0.moderators } + .thenReturn([ + "testServer": [ + "testRoom": Set(arrayLiteral: "15\(otherKey)") + ] + ]) + mockSodium + .when { + $0.blindedKeyPair( + serverPublicKey: any(), + edKeyPair: any(), + genericHash: mockGenericHash + ) + } + .thenReturn( + Box.KeyPair( + publicKey: Data.data(fromHex: otherKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes + ) + ) + + expect( + OpenGroupManager.isUserModeratorOrAdmin( + "00\(TestConstants.publicKey)", + for: "testRoom", + on: "testServer", + using: dependencies + ) + ).to(beTrue()) + } + } + + context("and the key is blinded") { + it("returns false if unable to retrieve the user ed25519 key") { + mockStorage + .when { $0.getUserED25519KeyPair() } + .thenReturn(nil) + + expect( + OpenGroupManager.isUserModeratorOrAdmin( + "15\(TestConstants.publicKey)", + for: "testRoom", + on: "testServer", + using: dependencies + ) + ).to(beFalse()) + } + + it("returns false if unable to retrieve the public key for the open group server") { + mockStorage + .when { $0.getOpenGroupPublicKey(for: any()) } + .thenReturn(nil) + + expect( + OpenGroupManager.isUserModeratorOrAdmin( + "15\(TestConstants.publicKey)", + for: "testRoom", + on: "testServer", + using: dependencies + ) + ).to(beFalse()) + } + + it("returns false if unable generate a blinded key") { + mockSodium + .when { + $0.blindedKeyPair( + serverPublicKey: any(), + edKeyPair: any(), + genericHash: mockGenericHash + ) + } + .thenReturn(nil) + + expect( + OpenGroupManager.isUserModeratorOrAdmin( + "15\(TestConstants.publicKey)", + for: "testRoom", + on: "testServer", + using: dependencies + ) + ).to(beFalse()) + } + + it("returns false if the key is not the users blinded id") { + let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") + mockSodium + .when { + $0.blindedKeyPair( + serverPublicKey: any(), + edKeyPair: any(), + genericHash: mockGenericHash + ) + } + .thenReturn( + Box.KeyPair( + publicKey: Data.data(fromHex: otherKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes + ) + ) + + expect( + OpenGroupManager.isUserModeratorOrAdmin( + "15\(TestConstants.publicKey)", + for: "testRoom", + on: "testServer", + using: dependencies + ) + ).to(beFalse()) + } + + 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") + mockOGMCache.when { $0.moderators } + .thenReturn([ + "testServer": [ + "testRoom": Set(arrayLiteral: "05\(otherKey)") + ] + ]) + mockIdentityManager + .when { $0.identityKeyPair() } + .thenReturn( + try! ECKeyPair( + publicKeyData: Data.data(fromHex: otherKey)!, + privateKeyData: Data.data(fromHex: TestConstants.privateKey)! + ) + ) + mockStorage + .when { $0.getUserED25519KeyPair() } + .thenReturn( + Box.KeyPair( + publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes + ) + ) + mockSodium + .when { + $0.blindedKeyPair( + serverPublicKey: any(), + edKeyPair: any(), + genericHash: mockGenericHash + ) + } + .thenReturn( + Box.KeyPair( + publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes + ) + ) + + expect( + OpenGroupManager.isUserModeratorOrAdmin( + "15\(TestConstants.publicKey)", + for: "testRoom", + on: "testServer", + using: dependencies + ) + ).to(beTrue()) + } + + it("returns true if the key is the current users and the users unblinded id is a moderator or admin") { + let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") + mockOGMCache.when { $0.moderators } + .thenReturn([ + "testServer": [ + "testRoom": Set(arrayLiteral: "00\(otherKey)") + ] + ]) + mockIdentityManager + .when { $0.identityKeyPair() } + .thenReturn( + try! ECKeyPair( + publicKeyData: Data.data(fromHex: TestConstants.publicKey)!, + privateKeyData: Data.data(fromHex: TestConstants.privateKey)! + ) + ) + mockStorage + .when { $0.getUserED25519KeyPair() } + .thenReturn( + Box.KeyPair( + publicKey: Data.data(fromHex: otherKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes + ) + ) + mockSodium + .when { + $0.blindedKeyPair( + serverPublicKey: any(), + edKeyPair: any(), + genericHash: mockGenericHash + ) + } + .thenReturn( + Box.KeyPair( + publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes + ) + ) + + expect( + OpenGroupManager.isUserModeratorOrAdmin( + "15\(TestConstants.publicKey)", + for: "testRoom", + on: "testServer", + using: dependencies + ) + ).to(beTrue()) + } + } + } + } } } diff --git a/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift b/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift index 77bdcf2d7..437848f7e 100644 --- a/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift +++ b/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift @@ -8,6 +8,7 @@ import SessionSnodeKit extension Dependencies { public func with( onionApi: OnionRequestAPIType.Type? = nil, + identityManager: IdentityManagerProtocol? = nil, storage: SessionMessagingKitStorageProtocol? = nil, sodium: SodiumType? = nil, aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, @@ -21,6 +22,7 @@ extension Dependencies { ) -> Dependencies { return Dependencies( onionApi: (onionApi ?? self._onionApi), + identityManager: (identityManager ?? self._identityManager), storage: (storage ?? self._storage), sodium: (sodium ?? self._sodium), aeadXChaCha20Poly1305Ietf: (aeadXChaCha20Poly1305Ietf ?? self._aeadXChaCha20Poly1305Ietf), diff --git a/SessionMessagingKitTests/_TestUtilities/MockIdentityManager.swift b/SessionMessagingKitTests/_TestUtilities/MockIdentityManager.swift new file mode 100644 index 000000000..a5aec7c6a --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/MockIdentityManager.swift @@ -0,0 +1,9 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +@testable import SessionMessagingKit + +class MockIdentityManager: Mock, IdentityManagerProtocol { + func identityKeyPair() -> ECKeyPair? { return accept() as? ECKeyPair } +} diff --git a/SessionMessagingKitTests/_TestUtilities/MockNonce16Generator.swift b/SessionMessagingKitTests/_TestUtilities/MockNonce16Generator.swift new file mode 100644 index 000000000..3fcaab255 --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/MockNonce16Generator.swift @@ -0,0 +1,11 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +@testable import SessionMessagingKit + +class MockNonce16Generator: Mock, NonceGenerator16ByteType { + var NonceBytes: Int = 16 + + func nonce() -> Array { return accept() as! [UInt8] } +} diff --git a/SessionMessagingKitTests/_TestUtilities/MockNonce24Generator.swift b/SessionMessagingKitTests/_TestUtilities/MockNonce24Generator.swift new file mode 100644 index 000000000..8b733af64 --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/MockNonce24Generator.swift @@ -0,0 +1,11 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +@testable import SessionMessagingKit + +class MockNonce24Generator: Mock, NonceGenerator24ByteType { + var NonceBytes: Int = 24 + + func nonce() -> Array { return accept() as! [UInt8] } +} diff --git a/SessionMessagingKitTests/_TestUtilities/MockedExtensions.swift b/SessionMessagingKitTests/_TestUtilities/MockedExtensions.swift index fde62b34c..3763fd517 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockedExtensions.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockedExtensions.swift @@ -25,3 +25,11 @@ extension OpenGroupAPI.Server: Mocked { extension VisibleMessage: Mocked { static var mockValue: VisibleMessage = VisibleMessage() } + +extension BlindedIdMapping: Mocked { + static var mockValue: BlindedIdMapping = BlindedIdMapping( + blindedId: any(), + sessionId: any(), + serverPublicKey: any() + ) +} diff --git a/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift b/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift index 39c1bfacd..0685ea246 100644 --- a/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift +++ b/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift @@ -9,6 +9,7 @@ extension OpenGroupManager.OGMDependencies { public func with( cache: Atomic? = nil, onionApi: OnionRequestAPIType.Type? = nil, + identityManager: IdentityManagerProtocol? = nil, storage: SessionMessagingKitStorageProtocol? = nil, sodium: SodiumType? = nil, aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, @@ -23,6 +24,7 @@ extension OpenGroupManager.OGMDependencies { return OpenGroupManager.OGMDependencies( cache: (cache ?? self._mutableCache), onionApi: (onionApi ?? self._onionApi), + identityManager: (identityManager ?? self._identityManager), storage: (storage ?? self._storage), sodium: (sodium ?? self._sodium), aeadXChaCha20Poly1305Ietf: (aeadXChaCha20Poly1305Ietf ?? self._aeadXChaCha20Poly1305Ietf), diff --git a/SessionMessagingKitTests/_TestUtilities/TestContactThread.swift b/SessionMessagingKitTests/_TestUtilities/TestContactThread.swift new file mode 100644 index 000000000..6150599eb --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/TestContactThread.swift @@ -0,0 +1,51 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionMessagingKit + +// FIXME: Turn this into a protocol to make mocking possible +class TestContactThread: TSContactThread, Mockable { + // MARK: - Mockable + + enum DataKey: Hashable { + case uniqueId + case interactions + } + + typealias Key = DataKey + + var mockData: [DataKey: Any] = [:] + var numSaveCalls: Int = 0 + var didCallRemoveAllThreadInteractions: Bool = false + var didCallRemove: Bool = false + + // MARK: - TSContactThread + + override var uniqueId: String? { + get { (mockData[.uniqueId] as? String) } + set {} + } + + override func enumerateInteractions(_ block: @escaping (TSInteraction) -> Void) { + ((mockData[.interactions] as? [TSInteraction]) ?? []).forEach(block) + } + + override func enumerateInteractions(with transaction: YapDatabaseReadTransaction, using block: @escaping (TSInteraction, UnsafeMutablePointer) -> Void) { + var stop: ObjCBool = false + for interaction in ((mockData[.interactions] as? [TSInteraction]) ?? []) { + block(interaction, &stop) + + if stop.boolValue { break } + } + } + + override func removeAllThreadInteractions(with transaction: YapDatabaseReadWriteTransaction) { + didCallRemoveAllThreadInteractions = true + } + + override func remove(with transaction: YapDatabaseReadWriteTransaction) { + didCallRemove = true + } + + override func save(with transaction: YapDatabaseReadWriteTransaction) { numSaveCalls += 1 } +} diff --git a/SessionMessagingKitTests/_TestUtilities/TestThread.swift b/SessionMessagingKitTests/_TestUtilities/TestThread.swift deleted file mode 100644 index 6ef2ce18a..000000000 --- a/SessionMessagingKitTests/_TestUtilities/TestThread.swift +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionMessagingKit - -// FIXME: Turn this into a protocol to make mocking possible -class TestThread: TSThread, Mockable { - // MARK: - Mockable - - enum DataKey: Hashable { - case interactions - } - - typealias Key = DataKey - - var mockData: [DataKey: Any] = [:] - var didCallSave: Bool = false - - // MARK: - TSThread - - override func enumerateInteractions(_ block: @escaping (TSInteraction) -> Void) { - ((mockData[.interactions] as? [TSInteraction]) ?? []).forEach(block) - } - - override func save(with transaction: YapDatabaseReadWriteTransaction) { didCallSave = true } -} From 93dbc2f63ca5398b41d42a1f2df6dea6e961d079 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 17 Mar 2022 17:12:19 +1100 Subject: [PATCH 040/157] Added more OpenGroupManager unit tests --- .../Open Groups/OpenGroupAPI.swift | 3 +- .../Open Groups/OpenGroupManager.swift | 2 +- .../Open Groups/OpenGroupManagerSpec.swift | 238 +++++++++++++++++- .../_TestUtilities/TestOnionRequestAPI.swift | 1 - 4 files changed, 237 insertions(+), 7 deletions(-) diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index 1a9cf7461..b309fbe65 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -8,9 +8,8 @@ public enum OpenGroupAPI { public static let defaultServer = "http://116.203.70.33" public static let defaultServerPublicKey = "a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238" - - public static let workQueue = DispatchQueue(label: "OpenGroupAPI.workQueue", qos: .userInitiated) // It's important that this is a serial queue + public static let workQueue = DispatchQueue(label: "OpenGroupAPI.workQueue", qos: .userInitiated) // It's important that this is a serial queue // MARK: - Batching & Polling diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 94632cda6..f9726f1aa 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -577,7 +577,7 @@ public final class OpenGroupManager: NSObject { } ) - OpenGroupManager.shared.mutableCache.mutate { cache in + dependencies.mutableCache.mutate { cache in cache.defaultRoomsPromise = promise } diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift index 9e4d1fbe2..53410214b 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -222,12 +222,12 @@ class OpenGroupManagerSpec: QuickSpec { ) mockStorage .when { $0.write(with: { _ in }) } - .then { args in (args.first as? ((Any) -> Void))?(testTransaction as Any) } + .then { [testTransaction] args in (args.first as? ((Any) -> Void))?(testTransaction! as Any) } .thenReturn(Promise.value(())) mockStorage .when { $0.write(with: { _ in }, completion: { }) } - .then { args in - (args.first as? ((Any) -> Void))?(testTransaction as Any) + .then { [testTransaction] args in + (args.first as? ((Any) -> Void))?(testTransaction! as Any) (args.last as? (() -> Void))?() } .thenReturn(Promise.value(())) @@ -3008,6 +3008,238 @@ class OpenGroupManagerSpec: QuickSpec { } } + // MARK: - --getDefaultRoomsIfNeeded + + context("when getting the default rooms if needed") { + beforeEach { + class TestRoomsApi: TestOnionRequestAPI { + static let roomsData: [OpenGroupAPI.Room] = [ + TestCapabilitiesAndRoomApi.roomData, + OpenGroupAPI.Room( + token: "test2", + name: "test2", + roomDescription: nil, + infoUpdates: 11, + messageSequence: 0, + created: 0, + activeUsers: 0, + activeUsersCutoff: 0, + imageId: 12, + pinnedMessages: nil, + admin: false, + globalAdmin: false, + admins: [], + hiddenAdmins: nil, + moderator: false, + globalModerator: false, + moderators: [], + hiddenModerators: nil, + read: false, + defaultRead: nil, + defaultAccessible: nil, + write: false, + defaultWrite: nil, + upload: false, + defaultUpload: nil + ) + ] + + override class var mockResponse: Data? { + return try! JSONEncoder().encode(roomsData) + } + } + dependencies = dependencies.with(onionApi: TestRoomsApi.self) + + mockOGMCache.when { $0.defaultRoomsPromise }.thenReturn(nil) + mockOGMCache.when { $0.groupImagePromises }.thenReturn([:]) + mockStorage + .when { $0.setOpenGroupPublicKey(for: any(), to: any(), using: anyAny())} + .thenReturn(()) + mockStorage.when { $0.getOpenGroupImage(for: any(), on: any()) }.thenReturn(nil) + mockStorage + .when { $0.setOpenGroupImage(to: any(), for: any(), on: any(), using: anyAny()) } + .thenReturn(()) + mockUserDefaults.when { $0.object(forKey: any()) }.thenReturn(nil) + } + + it("caches the promise if there is no cached promise") { + let promise = OpenGroupManager.getDefaultRoomsIfNeeded(using: dependencies) + + expect(mockOGMCache) + .to(call(matchingParameters: true) { + $0.defaultRoomsPromise = promise + }) + } + + it("returns the cached promise if there is one") { + let (promise, _) = Promise<[OpenGroupAPI.Room]>.pending() + mockOGMCache.when { $0.defaultRoomsPromise }.thenReturn(promise) + + expect(OpenGroupManager.getDefaultRoomsIfNeeded(using: dependencies)) + .to(equal(promise)) + } + + it("stores the public key information") { + OpenGroupManager.getDefaultRoomsIfNeeded(using: dependencies) + + expect(mockStorage) + .to(call(matchingParameters: true) { + $0.setOpenGroupPublicKey( + for: "http://116.203.70.33", + to: "a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238", + using: testTransaction! as Any + ) + }) + } + + it("fetches rooms for the server") { + var response: [OpenGroupAPI.Room]? + + OpenGroupManager.getDefaultRoomsIfNeeded(using: dependencies) + .done { response = $0 } + .retainUntilComplete() + + expect(response) + .toEventually( + equal( + [ + TestCapabilitiesAndRoomApi.roomData, + OpenGroupAPI.Room( + token: "test2", + name: "test2", + roomDescription: nil, + infoUpdates: 11, + messageSequence: 0, + created: 0, + activeUsers: 0, + activeUsersCutoff: 0, + imageId: 12, + pinnedMessages: nil, + admin: false, + globalAdmin: false, + admins: [], + hiddenAdmins: nil, + moderator: false, + globalModerator: false, + moderators: [], + hiddenModerators: nil, + read: false, + defaultRead: nil, + defaultAccessible: nil, + write: false, + defaultWrite: nil, + upload: false, + defaultUpload: nil + ) + ] + ), + timeout: .milliseconds(100) + ) + } + + it("will retry fetching rooms 8 times before it fails") { + class TestRoomsApi: TestOnionRequestAPI { + static var callCounter: Int = 0 + + override class var mockResponse: Data? { + callCounter += 1 + return nil + } + } + dependencies = dependencies.with(onionApi: TestRoomsApi.self) + + var error: Error? + + OpenGroupManager.getDefaultRoomsIfNeeded(using: dependencies) + .catch { error = $0 } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(HTTP.Error.invalidResponse.localizedDescription), + timeout: .milliseconds(100) + ) + expect(TestRoomsApi.callCounter).to(equal(9)) // First attempt + 8 retries + } + + it("removes the cache promise if all retries fail") { + class TestRoomsApi: TestOnionRequestAPI { + override class var mockResponse: Data? { return nil } + } + dependencies = dependencies.with(onionApi: TestRoomsApi.self) + + var error: Error? + + OpenGroupManager.getDefaultRoomsIfNeeded(using: dependencies) + .catch { error = $0 } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(HTTP.Error.invalidResponse.localizedDescription), + timeout: .milliseconds(100) + ) + expect(mockOGMCache) + .to(call(matchingParameters: true) { + $0.defaultRoomsPromise = nil + }) + } + + it("fetches the image for any rooms with images") { + class TestRoomsApi: TestOnionRequestAPI { + static let roomsData: [OpenGroupAPI.Room] = [ + OpenGroupAPI.Room( + token: "test2", + name: "test2", + roomDescription: nil, + infoUpdates: 11, + messageSequence: 0, + created: 0, + activeUsers: 0, + activeUsersCutoff: 0, + imageId: 12, + pinnedMessages: nil, + admin: false, + globalAdmin: false, + admins: [], + hiddenAdmins: nil, + moderator: false, + globalModerator: false, + moderators: [], + hiddenModerators: nil, + read: false, + defaultRead: nil, + defaultAccessible: nil, + write: false, + defaultWrite: nil, + upload: false, + defaultUpload: nil + ) + ] + + override class var mockResponse: Data? { + return try! JSONEncoder().encode(roomsData) + } + } + dependencies = dependencies.with(onionApi: TestRoomsApi.self) + + OpenGroupManager.getDefaultRoomsIfNeeded(using: dependencies) + + expect(mockStorage) + .toEventually( + call(matchingParameters: true) { + $0.setOpenGroupImage( + to: TestRoomsApi.mockResponse!, + for: "test2", + on: "http://116.203.70.33", + using: testTransaction! as Any + ) + }, + timeout: .milliseconds(100) + ) + } + } + } } } diff --git a/SessionMessagingKitTests/_TestUtilities/TestOnionRequestAPI.swift b/SessionMessagingKitTests/_TestUtilities/TestOnionRequestAPI.swift index fd1789626..034a2b565 100644 --- a/SessionMessagingKitTests/_TestUtilities/TestOnionRequestAPI.swift +++ b/SessionMessagingKitTests/_TestUtilities/TestOnionRequestAPI.swift @@ -54,7 +54,6 @@ class TestOnionRequestAPI: OnionRequestAPIType { } static func sendOnionRequest(to snode: Snode, invoking method: Snode.Method, with parameters: JSON, using version: OnionRequestAPI.Version, associatedWith publicKey: String?) -> Promise { - // TODO: Test the 'responseInfo' somehow? return Promise.value(mockResponse!) } } From 046269f1df176379bd9b0ed99ee550c6676ed847 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 18 Mar 2022 10:15:57 +1100 Subject: [PATCH 041/157] Finished the OpenGroupManager unit tests Fixed a bug with how the open group URL processing was working (one of the example URLs wasn't getting processed correctly) --- .../Open Groups/OpenGroupManager.swift | 7 +- .../Open Groups/OpenGroupManagerSpec.swift | 487 ++++++++++++++++-- 2 files changed, 439 insertions(+), 55 deletions(-) diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index f9726f1aa..eeb57c317 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -620,7 +620,7 @@ public final class OpenGroupManager: NSObject { dependencies.storage.write { transaction in dependencies.storage.setOpenGroupImage(to: imageData, for: roomToken, on: server, using: transaction) } - UserDefaults.standard[.lastOpenGroupImageUpdate] = now + dependencies.standardUserDefaults[.lastOpenGroupImageUpdate] = now } } dependencies.mutableCache.mutate { cache in @@ -644,7 +644,10 @@ public final class OpenGroupManager: NSObject { // 143.198.213.255:80/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c // 143.198.213.255:80/r/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c let useTLS = (url.scheme == "https") - let updatedPath = (url.path.starts(with: "/r/") ? url.path.substring(from: 2) : url.path) + + // If there is no scheme then the host is included in the path (so handle that case) + let hostFreePath = (url.host != nil ? url.path : url.path.substring(from: host.count)) + let updatedPath = (hostFreePath.starts(with: "/r/") ? hostFreePath.substring(from: 2) : hostFreePath) let room = String(updatedPath.dropFirst()) // Drop the leading slash let queryParts = query.split(separator: "=") guard !room.isEmpty && !room.contains("/"), queryParts.count == 2, queryParts[0] == "public_key" else { return nil } diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift index 53410214b..c7c7b863a 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -514,7 +514,7 @@ class OpenGroupManagerSpec: QuickSpec { .map { _ -> Void in didComplete = true } .retainUntilComplete() - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockStorage) .to( call(.exactly(times: 1)) { @@ -542,7 +542,7 @@ class OpenGroupManagerSpec: QuickSpec { .map { _ -> Void in didComplete = true } .retainUntilComplete() - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockStorage) .to( call(.exactly(times: 1)) { @@ -570,13 +570,13 @@ class OpenGroupManagerSpec: QuickSpec { .map { _ -> Void in didComplete = true } .retainUntilComplete() - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockOGMCache) .toEventually( call(matchingParameters: true) { $0.pollers = ["testServer": OpenGroupAPI.Poller(for: "testServer")] }, - timeout: .milliseconds(100) + timeout: .milliseconds(50) ) } @@ -600,7 +600,7 @@ class OpenGroupManagerSpec: QuickSpec { .map { _ -> Void in didComplete = true } .retainUntilComplete() - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockStorage) .toEventuallyNot( call { @@ -610,7 +610,7 @@ class OpenGroupManagerSpec: QuickSpec { using: testTransaction! as Any ) }, - timeout: .milliseconds(100) + timeout: .milliseconds(50) ) expect(mockStorage) .toEventuallyNot( @@ -621,7 +621,7 @@ class OpenGroupManagerSpec: QuickSpec { using: testTransaction! as Any ) }, - timeout: .milliseconds(100) + timeout: .milliseconds(50) ) } } @@ -656,7 +656,7 @@ class OpenGroupManagerSpec: QuickSpec { expect(error?.localizedDescription) .toEventually( equal(HTTP.Error.parsingFailed.localizedDescription), - timeout: .milliseconds(100) + timeout: .milliseconds(50) ) } } @@ -941,7 +941,7 @@ class OpenGroupManagerSpec: QuickSpec { dependencies: dependencies ) { didComplete = true } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(testGroupThread.numSaveCalls).to(equal(1)) } @@ -957,7 +957,7 @@ class OpenGroupManagerSpec: QuickSpec { dependencies: dependencies ) { didComplete = true } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockStorage).to(call { $0.getOpenGroup(for: any()) }) } @@ -973,7 +973,7 @@ class OpenGroupManagerSpec: QuickSpec { dependencies: dependencies ) { didComplete = true } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(testGroupThread.numSaveCalls).to(equal(1)) } @@ -989,7 +989,7 @@ class OpenGroupManagerSpec: QuickSpec { dependencies: dependencies ) { didComplete = true } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockStorage).to(call { $0.setOpenGroup(any(), for: any(), using: anyAny()) }) } @@ -1005,7 +1005,7 @@ class OpenGroupManagerSpec: QuickSpec { dependencies: dependencies ) { didComplete = true } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockStorage) .to(call(matchingParameters: true) { $0.setUserCount(to: 10, forOpenGroupWithID: "testServer.testRoom", using: testTransaction! as Any) @@ -1029,7 +1029,7 @@ class OpenGroupManagerSpec: QuickSpec { expect(didCallComplete) .toEventually( beTrue(), - timeout: .milliseconds(100) + timeout: .milliseconds(50) ) } @@ -1064,13 +1064,13 @@ class OpenGroupManagerSpec: QuickSpec { dependencies: dependencies ) { didComplete = true } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockOGMCache) .toEventually( call(matchingParameters: true) { $0.moderators = ["testServer": ["testRoom": Set(arrayLiteral: "TestMod")]] }, - timeout: .milliseconds(100) + timeout: .milliseconds(50) ) } @@ -1104,13 +1104,13 @@ class OpenGroupManagerSpec: QuickSpec { dependencies: dependencies ) { didComplete = true } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockOGMCache) .toEventually( call(matchingParameters: true) { $0.moderators = ["testServer": ["testRoom": Set()]] }, - timeout: .milliseconds(100) + timeout: .milliseconds(50) ) } } @@ -1146,13 +1146,13 @@ class OpenGroupManagerSpec: QuickSpec { dependencies: dependencies ) { didComplete = true } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockOGMCache) .toEventually( call(matchingParameters: true) { $0.admins = ["testServer": ["testRoom": Set(arrayLiteral: "TestAdmin")]] }, - timeout: .milliseconds(100) + timeout: .milliseconds(50) ) } @@ -1186,13 +1186,13 @@ class OpenGroupManagerSpec: QuickSpec { dependencies: dependencies ) { didComplete = true } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockOGMCache) .toEventually( call(matchingParameters: true) { $0.admins = ["testServer": ["testRoom": Set()]] }, - timeout: .milliseconds(100) + timeout: .milliseconds(50) ) } } @@ -1227,7 +1227,7 @@ class OpenGroupManagerSpec: QuickSpec { dependencies: dependencies ) { didComplete = true } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockStorage) .to(call(matchingParameters: true) { $0.setOpenGroup( @@ -1295,7 +1295,7 @@ class OpenGroupManagerSpec: QuickSpec { dependencies: dependencies ) { didComplete = true } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockStorage) .to(call(matchingParameters: true) { $0.setOpenGroup( @@ -1330,7 +1330,7 @@ class OpenGroupManagerSpec: QuickSpec { dependencies: dependencies ) { didComplete = true } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockOGMCache) .to(call(matchingParameters: true) { $0.pollers = ["testServer": OpenGroupAPI.Poller(for: "testServer")] @@ -1351,7 +1351,7 @@ class OpenGroupManagerSpec: QuickSpec { dependencies: dependencies ) { didComplete = true } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockOGMCache).to(call(.exactly(times: 1)) { $0.pollers }) } } @@ -1421,7 +1421,7 @@ class OpenGroupManagerSpec: QuickSpec { dependencies: dependencies ) { didComplete = true } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockStorage) .to(call(matchingParameters: true) { $0.setOpenGroup( @@ -1441,12 +1441,12 @@ class OpenGroupManagerSpec: QuickSpec { expect(testGroupThread.groupModel.groupImage) .toEventuallyNot( beNil(), - timeout: .milliseconds(100) + timeout: .milliseconds(50) ) expect(testGroupThread.numSaveCalls) .toEventually( equal(2), // Call to save the open group and then to save the image - timeout: .milliseconds(100) + timeout: .milliseconds(50) ) } @@ -1492,7 +1492,7 @@ class OpenGroupManagerSpec: QuickSpec { dependencies: dependencies ) { didComplete = true } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockStorage) .to(call(matchingParameters: true) { $0.setOpenGroup( @@ -1512,12 +1512,12 @@ class OpenGroupManagerSpec: QuickSpec { expect(testGroupThread.groupModel.groupImage) .toEventuallyNot( beNil(), - timeout: .milliseconds(100) + timeout: .milliseconds(50) ) expect(testGroupThread.numSaveCalls) .toEventually( equal(2), // Call to save the open group and then to save the image - timeout: .milliseconds(100) + timeout: .milliseconds(50) ) } @@ -1598,7 +1598,7 @@ class OpenGroupManagerSpec: QuickSpec { dependencies: dependencies ) { didComplete = true } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockStorage) .to(call(matchingParameters: true) { $0.setOpenGroup( @@ -1618,12 +1618,12 @@ class OpenGroupManagerSpec: QuickSpec { expect(testGroupThread.groupModel.groupImage) .toEventuallyNot( beNil(), - timeout: .milliseconds(100) + timeout: .milliseconds(50) ) expect(testGroupThread.numSaveCalls) .toEventually( equal(2), // Call to save the open group and then to save the image - timeout: .milliseconds(100) + timeout: .milliseconds(50) ) } @@ -1639,16 +1639,16 @@ class OpenGroupManagerSpec: QuickSpec { dependencies: dependencies ) { didComplete = true } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(testGroupThread.groupModel.groupImage) .toEventually( beNil(), - timeout: .milliseconds(100) + timeout: .milliseconds(50) ) expect(testGroupThread.numSaveCalls) .toEventually( equal(1), - timeout: .milliseconds(100) + timeout: .milliseconds(50) ) } @@ -1710,16 +1710,16 @@ class OpenGroupManagerSpec: QuickSpec { dependencies: dependencies ) { didComplete = true } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(testGroupThread.groupModel.groupImage) .toEventually( beNil(), - timeout: .milliseconds(100) + timeout: .milliseconds(50) ) expect(testGroupThread.numSaveCalls) .toEventually( equal(1), - timeout: .milliseconds(100) + timeout: .milliseconds(50) ) } @@ -1778,16 +1778,16 @@ class OpenGroupManagerSpec: QuickSpec { dependencies: dependencies ) { didComplete = true } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(100)) + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(testGroupThread.groupModel.groupImage) .toEventuallyNot( beNil(), - timeout: .milliseconds(100) + timeout: .milliseconds(50) ) expect(testGroupThread.numSaveCalls) .toEventually( equal(2), // Call to save the open group and then to save the image - timeout: .milliseconds(100) + timeout: .milliseconds(50) ) } } @@ -1958,7 +1958,7 @@ class OpenGroupManagerSpec: QuickSpec { expect(testIncomingMessage.didCallSave) .toEventually( beTrue(), - timeout: .milliseconds(100) + timeout: .milliseconds(50) ) } @@ -1989,7 +1989,7 @@ class OpenGroupManagerSpec: QuickSpec { expect(testIncomingMessage.didCallSave) .toEventually( beTrue(), - timeout: .milliseconds(100) + timeout: .milliseconds(50) ) } @@ -2022,7 +2022,7 @@ class OpenGroupManagerSpec: QuickSpec { expect(testIncomingMessage.didCallRemove) .toEventually( beTrue(), - timeout: .milliseconds(100) + timeout: .milliseconds(50) ) } @@ -2054,7 +2054,7 @@ class OpenGroupManagerSpec: QuickSpec { expect(testIncomingMessage.didCallRemove) .toEventuallyNot( beTrue(), - timeout: .milliseconds(100) + timeout: .milliseconds(50) ) } @@ -2087,7 +2087,7 @@ class OpenGroupManagerSpec: QuickSpec { expect(testIncomingMessage.didCallRemove) .toEventuallyNot( beTrue(), - timeout: .milliseconds(100) + timeout: .milliseconds(50) ) } } @@ -3060,6 +3060,7 @@ class OpenGroupManagerSpec: QuickSpec { .when { $0.setOpenGroupImage(to: any(), for: any(), on: any(), using: anyAny()) } .thenReturn(()) mockUserDefaults.when { $0.object(forKey: any()) }.thenReturn(nil) + mockUserDefaults.when { $0.set(anyAny(), forKey: any()) }.thenReturn(()) } it("caches the promise if there is no cached promise") { @@ -3133,7 +3134,7 @@ class OpenGroupManagerSpec: QuickSpec { ) ] ), - timeout: .milliseconds(100) + timeout: .milliseconds(50) ) } @@ -3157,7 +3158,7 @@ class OpenGroupManagerSpec: QuickSpec { expect(error?.localizedDescription) .toEventually( equal(HTTP.Error.invalidResponse.localizedDescription), - timeout: .milliseconds(100) + timeout: .milliseconds(50) ) expect(TestRoomsApi.callCounter).to(equal(9)) // First attempt + 8 retries } @@ -3177,7 +3178,7 @@ class OpenGroupManagerSpec: QuickSpec { expect(error?.localizedDescription) .toEventually( equal(HTTP.Error.invalidResponse.localizedDescription), - timeout: .milliseconds(100) + timeout: .milliseconds(50) ) expect(mockOGMCache) .to(call(matchingParameters: true) { @@ -3235,11 +3236,391 @@ class OpenGroupManagerSpec: QuickSpec { using: testTransaction! as Any ) }, - timeout: .milliseconds(100) + timeout: .milliseconds(50) ) } } + // MARK: - --roomImage + + context("when getting a room image") { + beforeEach { + class TestImageApi: TestOnionRequestAPI { + override class var mockResponse: Data? { return Data([1, 2, 3]) } + } + dependencies = dependencies.with(onionApi: TestImageApi.self) + + mockUserDefaults.when { $0.object(forKey: any()) }.thenReturn(nil) + mockUserDefaults.when { $0.set(anyAny(), forKey: any()) }.thenReturn(()) + mockStorage.when { $0.getOpenGroupImage(for: any(), on: any()) }.thenReturn(nil) + mockStorage + .when { $0.setOpenGroupImage(to: any(), for: any(), on: any(), using: anyAny()) } + .thenReturn(()) + mockOGMCache.when { $0.groupImagePromises }.thenReturn([:]) + } + + it("retrieves the image retrieval promise from the cache if it exists") { + let (promise, _) = Promise.pending() + mockOGMCache + .when { $0.groupImagePromises } + .thenReturn(["testServer.testRoom": promise]) + + expect( + OpenGroupManager + .roomImage( + 1, + for: "testRoom", + on: "testServer", + using: dependencies + ) + ).to(equal(promise)) + } + + it("does not save the fetched image to storage") { + let promise = OpenGroupManager + .roomImage( + 1, + for: "testRoom", + on: "testServer", + using: dependencies + ) + promise.retainUntilComplete() + + expect(promise.isFulfilled).toEventually(beTrue(), timeout: .milliseconds(50)) + expect(mockStorage) + .toEventuallyNot( + call(matchingParameters: true) { + $0.setOpenGroupImage( + to: Data([1, 2, 3]), + for: "testRoom", + on: "testServer", + using: testTransaction! as Any + ) + }, + timeout: .milliseconds(50) + ) + } + + it("does not update the image update timestamp") { + let promise = OpenGroupManager + .roomImage( + 1, + for: "testRoom", + on: "testServer", + using: dependencies + ) + promise.retainUntilComplete() + + expect(promise.isFulfilled).toEventually(beTrue(), timeout: .milliseconds(50)) + expect(mockUserDefaults) + .toEventuallyNot( + call(matchingParameters: true) { + $0.set( + dependencies.date, + forKey: SNUserDefaults.Date.lastOpenGroupImageUpdate.rawValue + ) + }, + timeout: .milliseconds(50) + ) + } + + it("adds the image retrieval promise to the cache") { + class TestNeverReturningApi: OnionRequestAPIType { + static func sendOnionRequest(_ request: URLRequest, to server: String, using version: OnionRequestAPI.Version, with x25519PublicKey: String) -> Promise<(OnionRequestResponseInfoType, Data?)> { + return Promise<(OnionRequestResponseInfoType, Data?)>.pending().promise + } + + static func sendOnionRequest(to snode: Snode, invoking method: Snode.Method, with parameters: JSON, using version: OnionRequestAPI.Version, associatedWith publicKey: String?) -> Promise { + return Promise.value(Data()) + } + } + dependencies = dependencies.with(onionApi: TestNeverReturningApi.self) + + let promise = OpenGroupManager.roomImage( + 1, + for: "testRoom", + on: "testServer", + using: dependencies + ) + + expect(mockOGMCache) + .toEventually( + call(matchingParameters: true) { + $0.groupImagePromises = ["testServer.testRoom": promise] + }, + timeout: .milliseconds(50) + ) + } + + context("for the default server") { + it("fetches a new image if there is no cached one") { + var result: Data? + + let promise = OpenGroupManager + .roomImage( + 1, + for: "testRoom", + on: OpenGroupAPI.defaultServer, + using: dependencies + ) + .done { result = $0 } + promise.retainUntilComplete() + + expect(promise.isFulfilled).toEventually(beTrue(), timeout: .milliseconds(50)) + expect(result).toEventually(equal(Data([1, 2, 3])), timeout: .milliseconds(50)) + } + + it("saves the fetched image to storage") { + let promise = OpenGroupManager + .roomImage( + 1, + for: "testRoom", + on: OpenGroupAPI.defaultServer, + using: dependencies + ) + promise.retainUntilComplete() + + expect(promise.isFulfilled).toEventually(beTrue(), timeout: .milliseconds(50)) + expect(mockStorage) + .toEventually( + call(matchingParameters: true) { + $0.setOpenGroupImage( + to: Data([1, 2, 3]), + for: "testRoom", + on: OpenGroupAPI.defaultServer, + using: testTransaction! as Any + ) + }, + timeout: .milliseconds(50) + ) + } + + it("updates the image update timestamp") { + let promise = OpenGroupManager + .roomImage( + 1, + for: "testRoom", + on: OpenGroupAPI.defaultServer, + using: dependencies + ) + promise.retainUntilComplete() + + expect(promise.isFulfilled).toEventually(beTrue(), timeout: .milliseconds(50)) + expect(mockUserDefaults) + .toEventually( + call(matchingParameters: true) { + $0.set( + dependencies.date, + forKey: SNUserDefaults.Date.lastOpenGroupImageUpdate.rawValue + ) + }, + timeout: .milliseconds(50) + ) + } + + context("and there is a cached image") { + beforeEach { + mockUserDefaults.when { $0.object(forKey: any()) }.thenReturn(dependencies.date) + mockStorage + .when { $0.getOpenGroupImage(for: any(), on: any()) } + .thenReturn(Data([2, 3, 4])) + } + + it("retrieves the cached image") { + var result: Data? + + let promise = OpenGroupManager + .roomImage( + 1, + for: "testRoom", + on: OpenGroupAPI.defaultServer, + using: dependencies + ) + .done { result = $0 } + promise.retainUntilComplete() + + expect(promise.isFulfilled).toEventually(beTrue(), timeout: .milliseconds(50)) + expect(result).toEventually(equal(Data([2, 3, 4])), timeout: .milliseconds(50)) + } + + it("fetches a new image if the cached on is older than a week") { + mockUserDefaults + .when { $0.object(forKey: any()) } + .thenReturn( + Date(timeIntervalSince1970: + (dependencies.date.timeIntervalSince1970 - (7 * 24 * 60 * 60) - 1) + ) + ) + + var result: Data? + + let promise = OpenGroupManager + .roomImage( + 1, + for: "testRoom", + on: OpenGroupAPI.defaultServer, + using: dependencies + ) + .done { result = $0 } + promise.retainUntilComplete() + + expect(promise.isFulfilled).toEventually(beTrue(), timeout: .milliseconds(50)) + expect(result).toEventually(equal(Data([1, 2, 3])), timeout: .milliseconds(50)) + } + } + } + } + + // MARK: - --parseOpenGroup + + context("when parsing an open group url") { + it("handles the example urls correctly") { + let validUrls: [String] = [ + "https://sessionopengroup.co/r/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c", + "https://sessionopengroup.co/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c", + "http://sessionopengroup.co/r/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c", + "http://sessionopengroup.co/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c", + "sessionopengroup.co/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c", + "sessionopengroup.co/r/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c", + "https://143.198.213.225:443/r/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c", + "https://143.198.213.225:443/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c", + "143.198.213.255:80/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c", + "143.198.213.255:80/r/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" + ] + let processedValues: [(room: String, server: String, publicKey: String)] = validUrls + .map { OpenGroupManager.parseOpenGroup(from: $0) } + .compactMap { $0 } + let processedRooms: [String] = processedValues.map { $0.room } + let processedServers: [String] = processedValues.map { $0.server } + let processedPublicKeys: [String] = processedValues.map { $0.publicKey } + let expectedRooms: [String] = [String](repeating: "main", count: 10) + let expectedServers: [String] = [ + "https://sessionopengroup.co", + "https://sessionopengroup.co", + "http://sessionopengroup.co", + "http://sessionopengroup.co", + "http://sessionopengroup.co", + "http://sessionopengroup.co", + "https://143.198.213.225:443", + "https://143.198.213.225:443", + "http://143.198.213.255:80", + "http://143.198.213.255:80" + ] + let expectedPublicKeys: [String] = [String]( + repeating: "658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c", + count: 10 + ) + + expect(processedValues.count).to(equal(validUrls.count)) + expect(processedRooms).to(equal(expectedRooms)) + expect(processedServers).to(equal(expectedServers)) + expect(processedPublicKeys).to(equal(expectedPublicKeys)) + } + + it("handles the r prefix if present") { + let info = OpenGroupManager.parseOpenGroup( + from: [ + "https://sessionopengroup.co/r/main?", + "public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" + ].joined() + ) + + expect(info?.room).to(equal("main")) + expect(info?.server).to(equal("https://sessionopengroup.co")) + expect(info?.publicKey).to(equal("658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c")) + } + + it("fails if there is no room") { + let info = OpenGroupManager.parseOpenGroup( + from: [ + "https://sessionopengroup.co?", + "public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" + ].joined() + ) + + expect(info?.room).to(beNil()) + expect(info?.server).to(beNil()) + expect(info?.publicKey).to(beNil()) + } + + it("fails if there is no public key parameter") { + let info = OpenGroupManager.parseOpenGroup( + from: "https://sessionopengroup.co/r/main" + ) + + expect(info?.room).to(beNil()) + expect(info?.server).to(beNil()) + expect(info?.publicKey).to(beNil()) + } + + it("fails if the public key parameter is not 64 characters") { + let info = OpenGroupManager.parseOpenGroup( + from: [ + "https://sessionopengroup.co/r/main?", + "public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231" + ].joined() + ) + + expect(info?.room).to(beNil()) + expect(info?.server).to(beNil()) + expect(info?.publicKey).to(beNil()) + } + + it("fails if the public key parameter is not a hex string") { + let info = OpenGroupManager.parseOpenGroup( + from: [ + "https://sessionopengroup.co/r/main?", + "public_key=!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" + ].joined() + ) + + expect(info?.room).to(beNil()) + expect(info?.server).to(beNil()) + expect(info?.publicKey).to(beNil()) + } + + it("maintains the same TLS") { + let server1 = OpenGroupManager.parseOpenGroup( + from: [ + "sessionopengroup.co/r/main?", + "public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" + ].joined() + )?.server + let server2 = OpenGroupManager.parseOpenGroup( + from: [ + "http://sessionopengroup.co/r/main?", + "public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" + ].joined() + )?.server + let server3 = OpenGroupManager.parseOpenGroup( + from: [ + "https://sessionopengroup.co/r/main?", + "public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" + ].joined() + )?.server + + expect(server1).to(equal("http://sessionopengroup.co")) + expect(server2).to(equal("http://sessionopengroup.co")) + expect(server3).to(equal("https://sessionopengroup.co")) + } + + it("maintains the same port") { + let server1 = OpenGroupManager.parseOpenGroup( + from: [ + "https://sessionopengroup.co/r/main?", + "public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" + ].joined() + )?.server + let server2 = OpenGroupManager.parseOpenGroup( + from: [ + "https://sessionopengroup.co:1234/r/main?", + "public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" + ].joined() + )?.server + + expect(server1).to(equal("https://sessionopengroup.co")) + expect(server2).to(equal("https://sessionopengroup.co:1234")) + } } } } From 2851d5e8c7c8d19baca1ff277d9a5f44c82c1952 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 18 Mar 2022 10:20:08 +1100 Subject: [PATCH 042/157] Minor tweak to the updated URL parsing logic to be extra safe --- SessionMessagingKit/Open Groups/OpenGroupManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index eeb57c317..f6f10ca10 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -646,7 +646,7 @@ public final class OpenGroupManager: NSObject { let useTLS = (url.scheme == "https") // If there is no scheme then the host is included in the path (so handle that case) - let hostFreePath = (url.host != nil ? url.path : url.path.substring(from: host.count)) + let hostFreePath = (url.host != nil || !url.path.starts(with: host) ? url.path : url.path.substring(from: host.count)) let updatedPath = (hostFreePath.starts(with: "/r/") ? hostFreePath.substring(from: 2) : hostFreePath) let room = String(updatedPath.dropFirst()) // Drop the leading slash let queryParts = query.split(separator: "=") From c44256b1d6ca09f2ab3b711222d568ccec79ff19 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 18 Mar 2022 16:39:25 +1100 Subject: [PATCH 043/157] Added more unit tests Fixed a possible divide by zero error Cleaned up some of the id blinding methods (ie. removing handling for impossible error states) Added unit tests for the new Sodium methods (used for id blinding) Added unit tests for some of the shared code Added unit tests for the MessageSender+Encryption extension functions Added unit tests for the MessageReceiver+Decryption extension functions Updated the unit test key constants to be consistent with the SOGS auth-example keys for consistency --- Session.xcodeproj/project.pbxproj | 78 ++- .../Common Networking/Request.swift | 2 + .../Open Groups/OpenGroupManager.swift | 18 +- .../Open Groups/Types/SodiumProtocols.swift | 47 +- .../MessageReceiver+Decryption.swift | 35 +- .../MessageReceiver+Handling.swift | 8 +- .../MessageSender+Encryption.swift | 12 +- .../Utilities/ContactUtilities.swift | 4 +- .../Utilities/Dependencies.swift | 32 +- .../Utilities/Sodium+Utilities.swift | 81 +-- .../Common Networking/HeaderSpec.swift | 20 + .../Models/FileUploadResponseSpec.swift | 32 ++ .../Common Networking/RequestSpec.swift | 167 ++++++ .../Contacts/BlindedIdMappingSpec.swift | 48 ++ .../Models/BatchRequestInfoSpec.swift | 385 ++++++++++++++ .../Open Groups/Models/OpenGroupSpec.swift | 2 +- .../Open Groups/Models/ServerSpec.swift | 2 +- .../Open Groups/OpenGroupAPISpec.swift | 14 +- .../Open Groups/OpenGroupManagerSpec.swift | 13 +- .../MessageReceiverDecryptionSpec.swift | 500 ++++++++++++++++++ .../MessageSenderEncryptionSpec.swift | 272 ++++++++++ .../Utilities/SodiumUtilitiesSpec.swift | 351 ++++++++++++ .../_TestUtilities/DependencyExtensions.swift | 10 +- .../_TestUtilities/MockBox.swift | 17 + .../_TestUtilities/MockSign.swift | 1 + .../_TestUtilities/MockSodium.swift | 11 +- .../OGMDependencyExtensions.swift | 10 +- .../General/String+Encoding.swift | 1 + .../General/SessionIdSpec.swift | 6 +- SharedTest/TestConstants.swift | 12 +- 30 files changed, 2051 insertions(+), 140 deletions(-) create mode 100644 SessionMessagingKitTests/Common Networking/HeaderSpec.swift create mode 100644 SessionMessagingKitTests/Common Networking/Models/FileUploadResponseSpec.swift create mode 100644 SessionMessagingKitTests/Common Networking/RequestSpec.swift create mode 100644 SessionMessagingKitTests/Contacts/BlindedIdMappingSpec.swift create mode 100644 SessionMessagingKitTests/Open Groups/Models/BatchRequestInfoSpec.swift create mode 100644 SessionMessagingKitTests/Sending & Receiving/MessageReceiverDecryptionSpec.swift create mode 100644 SessionMessagingKitTests/Sending & Receiving/MessageSenderEncryptionSpec.swift create mode 100644 SessionMessagingKitTests/Utilities/SodiumUtilitiesSpec.swift create mode 100644 SessionMessagingKitTests/_TestUtilities/MockBox.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 8c139ea7d..8d93cf779 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -788,6 +788,15 @@ FD078E6027E2BB36000769AF /* MockIdentityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E5F27E2BB36000769AF /* MockIdentityManager.swift */; }; FD0BA51B27CD88EC00CC6805 /* BlindedIdMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0BA51A27CD88EC00CC6805 /* BlindedIdMapping.swift */; }; FD0BA51D27CDC34600CC6805 /* SOGSV4Migration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0BA51C27CDC34600CC6805 /* SOGSV4Migration.swift */; }; + FD3C905C27E3FBEF00CD579F /* BatchRequestInfoSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C905B27E3FBEF00CD579F /* BatchRequestInfoSpec.swift */; }; + FD3C906027E410F700CD579F /* FileUploadResponseSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C905F27E410F700CD579F /* FileUploadResponseSpec.swift */; }; + FD3C906227E411AF00CD579F /* HeaderSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906127E411AF00CD579F /* HeaderSpec.swift */; }; + FD3C906427E4122F00CD579F /* RequestSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906327E4122F00CD579F /* RequestSpec.swift */; }; + FD3C906727E416AF00CD579F /* BlindedIdMappingSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906627E416AF00CD579F /* BlindedIdMappingSpec.swift */; }; + FD3C906A27E417CE00CD579F /* SodiumUtilitiesSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906927E417CE00CD579F /* SodiumUtilitiesSpec.swift */; }; + FD3C906D27E43C4B00CD579F /* MessageSenderEncryptionSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906C27E43C4B00CD579F /* MessageSenderEncryptionSpec.swift */; }; + FD3C906F27E43E8700CD579F /* MockBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906E27E43E8700CD579F /* MockBox.swift */; }; + FD3C907127E445E500CD579F /* MessageReceiverDecryptionSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907027E445E500CD579F /* MessageReceiverDecryptionSpec.swift */; }; FD5D200F27AA2B6000FEA984 /* MessageRequestResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */; }; FD5D201127AA331F00FEA984 /* ConfigurationMessage+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D201027AA331F00FEA984 /* ConfigurationMessage+Convenience.swift */; }; FD5D201E27B0D87C00FEA984 /* SessionId.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D201D27B0D87C00FEA984 /* SessionId.swift */; }; @@ -1938,6 +1947,15 @@ FD078E5F27E2BB36000769AF /* MockIdentityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockIdentityManager.swift; sourceTree = ""; }; FD0BA51A27CD88EC00CC6805 /* BlindedIdMapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlindedIdMapping.swift; sourceTree = ""; }; FD0BA51C27CDC34600CC6805 /* SOGSV4Migration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSV4Migration.swift; sourceTree = ""; }; + FD3C905B27E3FBEF00CD579F /* BatchRequestInfoSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchRequestInfoSpec.swift; sourceTree = ""; }; + FD3C905F27E410F700CD579F /* FileUploadResponseSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUploadResponseSpec.swift; sourceTree = ""; }; + FD3C906127E411AF00CD579F /* HeaderSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderSpec.swift; sourceTree = ""; }; + FD3C906327E4122F00CD579F /* RequestSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestSpec.swift; sourceTree = ""; }; + FD3C906627E416AF00CD579F /* BlindedIdMappingSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlindedIdMappingSpec.swift; sourceTree = ""; }; + FD3C906927E417CE00CD579F /* SodiumUtilitiesSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SodiumUtilitiesSpec.swift; sourceTree = ""; }; + FD3C906C27E43C4B00CD579F /* MessageSenderEncryptionSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSenderEncryptionSpec.swift; sourceTree = ""; }; + FD3C906E27E43E8700CD579F /* MockBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBox.swift; sourceTree = ""; }; + FD3C907027E445E500CD579F /* MessageReceiverDecryptionSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReceiverDecryptionSpec.swift; sourceTree = ""; }; FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestResponse.swift; sourceTree = ""; }; FD5D201027AA331F00FEA984 /* ConfigurationMessage+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConfigurationMessage+Convenience.swift"; sourceTree = ""; }; FD5D201D27B0D87C00FEA984 /* SessionId.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionId.swift; sourceTree = ""; }; @@ -3884,6 +3902,49 @@ path = Session; sourceTree = ""; }; + FD3C905D27E410DB00CD579F /* Common Networking */ = { + isa = PBXGroup; + children = ( + FD3C905E27E410EE00CD579F /* Models */, + FD3C906127E411AF00CD579F /* HeaderSpec.swift */, + FD3C906327E4122F00CD579F /* RequestSpec.swift */, + ); + path = "Common Networking"; + sourceTree = ""; + }; + FD3C905E27E410EE00CD579F /* Models */ = { + isa = PBXGroup; + children = ( + FD3C905F27E410F700CD579F /* FileUploadResponseSpec.swift */, + ); + path = Models; + sourceTree = ""; + }; + FD3C906527E416A200CD579F /* Contacts */ = { + isa = PBXGroup; + children = ( + FD3C906627E416AF00CD579F /* BlindedIdMappingSpec.swift */, + ); + path = Contacts; + sourceTree = ""; + }; + FD3C906827E417B100CD579F /* Utilities */ = { + isa = PBXGroup; + children = ( + FD3C906927E417CE00CD579F /* SodiumUtilitiesSpec.swift */, + ); + path = Utilities; + sourceTree = ""; + }; + FD3C906B27E43C2400CD579F /* Sending & Receiving */ = { + isa = PBXGroup; + children = ( + FD3C906C27E43C4B00CD579F /* MessageSenderEncryptionSpec.swift */, + FD3C907027E445E500CD579F /* MessageReceiverDecryptionSpec.swift */, + ); + path = "Sending & Receiving"; + sourceTree = ""; + }; FD659ABE27A7648200F12C02 /* Message Requests */ = { isa = PBXGroup; children = ( @@ -3924,6 +3985,7 @@ children = ( FD83B9C227CF33F7005E1583 /* ServerSpec.swift */, FD83B9C427CF3E2A005E1583 /* OpenGroupSpec.swift */, + FD3C905B27E3FBEF00CD579F /* BatchRequestInfoSpec.swift */, FD83B9C627CF3F10005E1583 /* CapabilitiesSpec.swift */, FDC2908627D7047F005DAE71 /* RoomSpec.swift */, FDC2908827D70656005DAE71 /* RoomPollInfoSpec.swift */, @@ -4038,7 +4100,11 @@ isa = PBXGroup; children = ( FDC4389B27BA01E300C60D73 /* _TestUtilities */, + FD3C905D27E410DB00CD579F /* Common Networking */, + FD3C906527E416A200CD579F /* Contacts */, + FD3C906B27E43C2400CD579F /* Sending & Receiving */, FDC4389827BA001800C60D73 /* Open Groups */, + FD3C906827E417B100CD579F /* Utilities */, ); path = SessionMessagingKitTests; sourceTree = ""; @@ -4061,9 +4127,10 @@ FD078E5F27E2BB36000769AF /* MockIdentityManager.swift */, FDC4389C27BA01F000C60D73 /* MockStorage.swift */, FD859EF327C2F49200510D0C /* MockSodium.swift */, + FD3C906E27E43E8700CD579F /* MockBox.swift */, + FD859EF927C2F5C500510D0C /* MockGenericHash.swift */, FD859EF527C2F52C00510D0C /* MockSign.swift */, FD859EF727C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift */, - FD859EF927C2F5C500510D0C /* MockGenericHash.swift */, FD859EFB27C2F60700510D0C /* MockEd25519.swift */, FD078E5927E29F09000769AF /* MockNonce16Generator.swift */, FD078E5B27E29F78000769AF /* MockNonce24Generator.swift */, @@ -5673,20 +5740,25 @@ buildActionMask = 2147483647; files = ( FDC290AC27DB0B1C005DAE71 /* MockedExtensions.swift in Sources */, + FD3C905C27E3FBEF00CD579F /* BatchRequestInfoSpec.swift in Sources */, FD078E5827E1B831000769AF /* TestIncomingMessage.swift in Sources */, FD859EFA27C2F5C500510D0C /* MockGenericHash.swift in Sources */, FDC2909427D710B4005DAE71 /* SOGSEndpointSpec.swift in Sources */, FDC290AF27DFEE97005DAE71 /* TestTransaction.swift in Sources */, FDC290B327DFF9F5005DAE71 /* TestOnionRequestAPI.swift in Sources */, FDC2909127D709CA005DAE71 /* SOGSMessageSpec.swift in Sources */, + FD3C906A27E417CE00CD579F /* SodiumUtilitiesSpec.swift in Sources */, FD078E6027E2BB36000769AF /* MockIdentityManager.swift in Sources */, + FD3C907127E445E500CD579F /* MessageReceiverDecryptionSpec.swift in Sources */, FDC2909627D71252005DAE71 /* SOGSErrorSpec.swift in Sources */, FDC2908727D7047F005DAE71 /* RoomSpec.swift in Sources */, FD078E4F27E175F1000769AF /* DependencyExtensions.swift in Sources */, FDC2909C27D713D2005DAE71 /* SodiumProtocolsSpec.swift in Sources */, + FD3C906027E410F700CD579F /* FileUploadResponseSpec.swift in Sources */, FD83B9C327CF33F7005E1583 /* ServerSpec.swift in Sources */, FD83B9C727CF3F10005E1583 /* CapabilitiesSpec.swift in Sources */, FDC2909A27D71376005DAE71 /* NonceGeneratorSpec.swift in Sources */, + FD3C906427E4122F00CD579F /* RequestSpec.swift in Sources */, FD078E4827E02561000769AF /* CommonMockedExtensions.swift in Sources */, FDC290A027D85826005DAE71 /* TestContactThread.swift in Sources */, FD859EF827C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift in Sources */, @@ -5697,20 +5769,24 @@ FD859EFC27C2F60700510D0C /* MockEd25519.swift in Sources */, FDC290A627D860CE005DAE71 /* Mock.swift in Sources */, FD83B9C027CF2294005E1583 /* TestConstants.swift in Sources */, + FD3C906F27E43E8700CD579F /* MockBox.swift in Sources */, FDC4389A27BA002500C60D73 /* OpenGroupAPISpec.swift in Sources */, FD83B9C527CF3E2A005E1583 /* OpenGroupSpec.swift in Sources */, FDC2908B27D707F3005DAE71 /* SendMessageRequestSpec.swift in Sources */, FDC290A827D9B46D005DAE71 /* NimbleExtensions.swift in Sources */, + FD3C906227E411AF00CD579F /* HeaderSpec.swift in Sources */, FDC290B727E00FDB005DAE71 /* TestGroupThread.swift in Sources */, FDC2908F27D70938005DAE71 /* SendDirectMessageRequestSpec.swift in Sources */, FDC438BD27BB2AB400C60D73 /* Mockable.swift in Sources */, FD859EF627C2F52C00510D0C /* MockSign.swift in Sources */, FDC2908927D70656005DAE71 /* RoomPollInfoSpec.swift in Sources */, FD078E5A27E29F09000769AF /* MockNonce16Generator.swift in Sources */, + FD3C906D27E43C4B00CD579F /* MessageSenderEncryptionSpec.swift in Sources */, FDC2908D27D70905005DAE71 /* UpdateMessageRequestSpec.swift in Sources */, FDC290A227D85890005DAE71 /* TestInteraction.swift in Sources */, FD078E5427E197CA000769AF /* OpenGroupManagerSpec.swift in Sources */, FDC4389D27BA01F000C60D73 /* MockStorage.swift in Sources */, + FD3C906727E416AF00CD579F /* BlindedIdMappingSpec.swift in Sources */, FD83B9D227D59495005E1583 /* MockUserDefaults.swift in Sources */, FD078E5C27E29F78000769AF /* MockNonce24Generator.swift in Sources */, ); diff --git a/SessionMessagingKit/Common Networking/Request.swift b/SessionMessagingKit/Common Networking/Request.swift index 19130eb98..f32620bb2 100644 --- a/SessionMessagingKit/Common Networking/Request.swift +++ b/SessionMessagingKit/Common Networking/Request.swift @@ -96,3 +96,5 @@ struct Request { return urlRequest } } + +extension Request: Equatable where T: Equatable {} diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index f6f10ca10..3a7035910 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -21,12 +21,6 @@ public protocol OGMCacheType { func getTimeSinceLastOpen(using dependencies: Dependencies) -> TimeInterval } -extension OGMCacheType { - func getTimeSinceLastOpen() -> TimeInterval { - return getTimeSinceLastOpen(using: Dependencies()) - } -} - // MARK: - OpenGroupManager @objc(SNOpenGroupManager) @@ -49,7 +43,7 @@ public final class OpenGroupManager: NSObject { public var timeSinceLastPoll: [String: TimeInterval] = [:] fileprivate var _timeSinceLastOpen: TimeInterval? - public func getTimeSinceLastOpen(using dependencies: Dependencies = Dependencies()) -> TimeInterval { + public func getTimeSinceLastOpen(using dependencies: Dependencies) -> TimeInterval { if let storedTimeSinceLastOpen: TimeInterval = _timeSinceLastOpen { return storedTimeSinceLastOpen } @@ -702,9 +696,10 @@ extension OpenGroupManager { identityManager: IdentityManagerProtocol? = nil, storage: SessionMessagingKitStorageProtocol? = nil, sodium: SodiumType? = nil, - aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, - sign: SignType? = nil, + box: BoxType? = nil, genericHash: GenericHashType? = nil, + sign: SignType? = nil, + aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, ed25519: Ed25519Type? = nil, nonceGenerator16: NonceGenerator16ByteType? = nil, nonceGenerator24: NonceGenerator24ByteType? = nil, @@ -718,9 +713,10 @@ extension OpenGroupManager { identityManager: identityManager, storage: storage, sodium: sodium, - aeadXChaCha20Poly1305Ietf: aeadXChaCha20Poly1305Ietf, - sign: sign, + box: box, genericHash: genericHash, + sign: sign, + aeadXChaCha20Poly1305Ietf: aeadXChaCha20Poly1305Ietf, ed25519: ed25519, nonceGenerator16: nonceGenerator16, nonceGenerator24: nonceGenerator24, diff --git a/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift b/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift index d37137ee1..a9f15622a 100644 --- a/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift +++ b/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift @@ -5,18 +5,19 @@ import Sodium import Curve25519Kit public protocol SodiumType { + func getBox() -> BoxType func getGenericHash() -> GenericHashType - func getAeadXChaCha20Poly1305Ietf() -> AeadXChaCha20Poly1305IetfType func getSign() -> SignType + func getAeadXChaCha20Poly1305Ietf() -> AeadXChaCha20Poly1305IetfType - func generateBlindingFactor(serverPublicKey: String) -> Bytes? + func generateBlindingFactor(serverPublicKey: String, genericHash: GenericHashType) -> Bytes? func blindedKeyPair(serverPublicKey: String, edKeyPair: Box.KeyPair, genericHash: GenericHashType) -> Box.KeyPair? func sogsSignature(message: Bytes, secretKey: Bytes, blindedSecretKey ka: Bytes, blindedPublicKey kA: Bytes) -> Bytes? func combineKeys(lhsKeyBytes: Bytes, rhsKeyBytes: Bytes) -> Bytes? func sharedBlindedEncryptionKey(secretKey a: Bytes, otherBlindedPublicKey: Bytes, fromBlindedPublicKey kA: Bytes, toBlindedPublicKey kB: Bytes, genericHash: GenericHashType) -> Bytes? - func sessionId(_ sessionId: String, matchesBlindedId blindedSessionId: String, serverPublicKey: String) -> Bool + func sessionId(_ sessionId: String, matchesBlindedId blindedSessionId: String, serverPublicKey: String, genericHash: GenericHashType) -> Bool } public protocol AeadXChaCha20Poly1305IetfType { @@ -32,12 +33,9 @@ public protocol Ed25519Type { func verifySignature(_ signature: Data, publicKey: Data, data: Data) throws -> Bool } -public protocol SignType { - var PublicKeyBytes: Int { get } - - func toX25519(ed25519PublicKey: Bytes) -> Bytes? - func signature(message: Bytes, secretKey: Bytes) -> Bytes? - func verify(message: Bytes, publicKey: Bytes, signature: Bytes) -> Bool +public protocol BoxType { + func seal(message: Bytes, recipientPublicKey: Bytes) -> Bytes? + func open(anonymousCipherText: Bytes, recipientPublicKey: Bytes, recipientSecretKey: Bytes) -> Bytes? } public protocol GenericHashType { @@ -46,8 +44,25 @@ public protocol GenericHashType { func hashSaltPersonal(message: Bytes, outputLength: Int, key: Bytes?, salt: Bytes, personal: Bytes) -> Bytes? } +public protocol SignType { + var Bytes: Int { get } + var PublicKeyBytes: Int { get } + + func toX25519(ed25519PublicKey: Bytes) -> Bytes? + func signature(message: Bytes, secretKey: Bytes) -> Bytes? + func verify(message: Bytes, publicKey: Bytes, signature: Bytes) -> Bool +} + // MARK: - Default Values +extension GenericHashType { + func hash(message: Bytes) -> Bytes? { return hash(message: message, key: nil) } + + func hashSaltPersonal(message: Bytes, outputLength: Int, salt: Bytes, personal: Bytes) -> Bytes? { + return hashSaltPersonal(message: message, outputLength: outputLength, key: nil, salt: salt, personal: personal) + } +} + extension AeadXChaCha20Poly1305IetfType { func encrypt(message: Bytes, secretKey: Bytes, nonce: Bytes) -> Bytes? { return encrypt(message: message, secretKey: secretKey, nonce: nonce, additionalData: nil) @@ -58,17 +73,10 @@ extension AeadXChaCha20Poly1305IetfType { } } -extension GenericHashType { - func hash(message: Bytes) -> Bytes? { return hash(message: message, key: nil) } - - func hashSaltPersonal(message: Bytes, outputLength: Int, salt: Bytes, personal: Bytes) -> Bytes? { - return hashSaltPersonal(message: message, outputLength: outputLength, key: nil, salt: salt, personal: personal) - } -} - // MARK: - Conformance extension Sodium: SodiumType { + public func getBox() -> BoxType { return box } public func getGenericHash() -> GenericHashType { return genericHash } public func getSign() -> SignType { return sign } public func getAeadXChaCha20Poly1305Ietf() -> AeadXChaCha20Poly1305IetfType { return aead.xchacha20poly1305ietf } @@ -78,9 +86,10 @@ extension Sodium: SodiumType { } } -extension Aead.XChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType {} -extension Sign: SignType {} +extension Box: BoxType {} extension GenericHash: GenericHashType {} +extension Sign: SignType {} +extension Aead.XChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType {} struct Ed25519Wrapper: Ed25519Type { func sign(data: Bytes, keyPair: ECKeyPair) throws -> Bytes? { diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift index 8491ed794..a135ba0c1 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift @@ -3,27 +3,40 @@ import SessionUtilitiesKit import Sodium extension MessageReceiver { - - internal static func decryptWithSessionProtocol(ciphertext: Data, using x25519KeyPair: ECKeyPair) throws -> (plaintext: Data, senderX25519PublicKey: String) { + internal static func decryptWithSessionProtocol(ciphertext: Data, using x25519KeyPair: ECKeyPair, dependencies: Dependencies = Dependencies()) throws -> (plaintext: Data, senderX25519PublicKey: String) { let recipientX25519PrivateKey = x25519KeyPair.privateKey let recipientX25519PublicKey = Data(hex: x25519KeyPair.hexEncodedPublicKey.removingIdPrefixIfNeeded()) - let sodium = Sodium() - let signatureSize = sodium.sign.Bytes - let ed25519PublicKeySize = sodium.sign.PublicKeyBytes + let signatureSize = dependencies.sign.Bytes + let ed25519PublicKeySize = dependencies.sign.PublicKeyBytes // 1. ) Decrypt the message - guard let plaintextWithMetadata = sodium.box.open(anonymousCipherText: Bytes(ciphertext), recipientPublicKey: Box.PublicKey(Bytes(recipientX25519PublicKey)), - recipientSecretKey: Bytes(recipientX25519PrivateKey)), plaintextWithMetadata.count > (signatureSize + ed25519PublicKeySize) else { throw Error.decryptionFailed } + guard + let plaintextWithMetadata = dependencies.box.open( + anonymousCipherText: Bytes(ciphertext), + recipientPublicKey: Box.PublicKey(Bytes(recipientX25519PublicKey)), + recipientSecretKey: Bytes(recipientX25519PrivateKey) + ), + plaintextWithMetadata.count > (signatureSize + ed25519PublicKeySize) + else { + throw Error.decryptionFailed + } + // 2. ) Get the message parts let signature = Bytes(plaintextWithMetadata[plaintextWithMetadata.count - signatureSize ..< plaintextWithMetadata.count]) let senderED25519PublicKey = Bytes(plaintextWithMetadata[plaintextWithMetadata.count - (signatureSize + ed25519PublicKeySize) ..< plaintextWithMetadata.count - signatureSize]) let plaintext = Bytes(plaintextWithMetadata[0.. Data { - guard let userED25519KeyPair = SNMessagingKitConfiguration.shared.storage.getUserED25519KeyPair() else { + internal static func encryptWithSessionProtocol(_ plaintext: Data, for recipientHexEncodedX25519PublicKey: String, using dependencies: Dependencies = Dependencies()) throws -> Data { + guard let userED25519KeyPair = dependencies.storage.getUserED25519KeyPair() else { throw Error.noUserED25519KeyPair } let recipientX25519PublicKey = Data(hex: recipientHexEncodedX25519PublicKey.removingIdPrefixIfNeeded()) - let sodium = Sodium() let verificationData = plaintext + Data(userED25519KeyPair.publicKey) + recipientX25519PublicKey - guard let signature = sodium.sign.signature(message: Bytes(verificationData), secretKey: userED25519KeyPair.secretKey) else { + guard let signature = dependencies.sign.signature(message: Bytes(verificationData), secretKey: userED25519KeyPair.secretKey) else { throw Error.signingFailed } let plaintextWithMetadata = plaintext + Data(userED25519KeyPair.publicKey) + Data(signature) - guard let ciphertext = sodium.box.seal(message: Bytes(plaintextWithMetadata), recipientPublicKey: Bytes(recipientX25519PublicKey)) else { + guard let ciphertext = dependencies.box.seal(message: Bytes(plaintextWithMetadata), recipientPublicKey: Bytes(recipientX25519PublicKey)) else { throw Error.encryptionFailed } @@ -26,7 +24,7 @@ extension MessageSender { internal static func encryptWithSessionBlindingProtocol(_ plaintext: Data, for recipientBlindedId: String, openGroupPublicKey: String, using dependencies: Dependencies = Dependencies()) throws -> Data { guard SessionId.Prefix(from: recipientBlindedId) == .blinded else { throw Error.signingFailed } - guard let userEd25519KeyPair = SNMessagingKitConfiguration.shared.storage.getUserED25519KeyPair() else { + guard let userEd25519KeyPair = dependencies.storage.getUserED25519KeyPair() else { throw Error.noUserED25519KeyPair } guard let blindedKeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: openGroupPublicKey, edKeyPair: userEd25519KeyPair, genericHash: dependencies.genericHash) else { diff --git a/SessionMessagingKit/Utilities/ContactUtilities.swift b/SessionMessagingKit/Utilities/ContactUtilities.swift index 80a730eb8..7aeac54d7 100644 --- a/SessionMessagingKit/Utilities/ContactUtilities.swift +++ b/SessionMessagingKit/Utilities/ContactUtilities.swift @@ -73,7 +73,7 @@ public enum ContactUtilities { // Then we try loop through all approved contact threads to see if one of those contacts can be blinded to match ContactUtilities.enumerateApprovedContactThreads(using: transaction) { contactThread, contact, stop in - guard dependencies.sodium.sessionId(contact.sessionID, matchesBlindedId: blindedId, serverPublicKey: serverPublicKey) else { + guard dependencies.sodium.sessionId(contact.sessionID, matchesBlindedId: blindedId, serverPublicKey: serverPublicKey, genericHash: dependencies.genericHash) else { return } @@ -91,7 +91,7 @@ public enum ContactUtilities { // a thread with this contact in a different SOGS and had cached the mapping) dependencies.storage.enumerateBlindedIdMapping(using: transaction) { mapping, stop in guard mapping.serverPublicKey != serverPublicKey else { return } - guard dependencies.sodium.sessionId(mapping.sessionId, matchesBlindedId: blindedId, serverPublicKey: serverPublicKey) else { + guard dependencies.sodium.sessionId(mapping.sessionId, matchesBlindedId: blindedId, serverPublicKey: serverPublicKey, genericHash: dependencies.genericHash) else { return } diff --git a/SessionMessagingKit/Utilities/Dependencies.swift b/SessionMessagingKit/Utilities/Dependencies.swift index 01273c567..7eb2b56fe 100644 --- a/SessionMessagingKit/Utilities/Dependencies.swift +++ b/SessionMessagingKit/Utilities/Dependencies.swift @@ -30,10 +30,16 @@ public class Dependencies { set { _sodium = newValue } } - internal var _aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? - public var aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType { - get { Dependencies.getValueSettingIfNull(&_aeadXChaCha20Poly1305Ietf) { sodium.getAeadXChaCha20Poly1305Ietf() } } - set { _aeadXChaCha20Poly1305Ietf = newValue } + internal var _box: BoxType? + public var box: BoxType { + get { Dependencies.getValueSettingIfNull(&_box) { sodium.getBox() } } + set { _box = newValue } + } + + internal var _genericHash: GenericHashType? + public var genericHash: GenericHashType { + get { Dependencies.getValueSettingIfNull(&_genericHash) { sodium.getGenericHash() } } + set { _genericHash = newValue } } internal var _sign: SignType? @@ -42,10 +48,10 @@ public class Dependencies { set { _sign = newValue } } - internal var _genericHash: GenericHashType? - public var genericHash: GenericHashType { - get { Dependencies.getValueSettingIfNull(&_genericHash) { sodium.getGenericHash() } } - set { _genericHash = newValue } + internal var _aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? + public var aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType { + get { Dependencies.getValueSettingIfNull(&_aeadXChaCha20Poly1305Ietf) { sodium.getAeadXChaCha20Poly1305Ietf() } } + set { _aeadXChaCha20Poly1305Ietf = newValue } } internal var _ed25519: Ed25519Type? @@ -85,9 +91,10 @@ public class Dependencies { identityManager: IdentityManagerProtocol? = nil, storage: SessionMessagingKitStorageProtocol? = nil, sodium: SodiumType? = nil, - aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, - sign: SignType? = nil, + box: BoxType? = nil, genericHash: GenericHashType? = nil, + sign: SignType? = nil, + aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, ed25519: Ed25519Type? = nil, nonceGenerator16: NonceGenerator16ByteType? = nil, nonceGenerator24: NonceGenerator24ByteType? = nil, @@ -98,9 +105,10 @@ public class Dependencies { _identityManager = identityManager _storage = storage _sodium = sodium - _aeadXChaCha20Poly1305Ietf = aeadXChaCha20Poly1305Ietf - _sign = sign + _box = box _genericHash = genericHash + _sign = sign + _aeadXChaCha20Poly1305Ietf = aeadXChaCha20Poly1305Ietf _ed25519 = ed25519 _nonceGenerator16 = nonceGenerator16 _nonceGenerator24 = nonceGenerator24 diff --git a/SessionMessagingKit/Utilities/Sodium+Utilities.swift b/SessionMessagingKit/Utilities/Sodium+Utilities.swift index 0409c6623..feddd0df4 100644 --- a/SessionMessagingKit/Utilities/Sodium+Utilities.swift +++ b/SessionMessagingKit/Utilities/Sodium+Utilities.swift @@ -41,6 +41,15 @@ extension Sign { } } +/// These extenion methods are used to generate a sign "blinded" messages +/// +/// According to the Swift engineers the only situation when `UnsafeRawBufferPointer.baseAddress` is nil is when it's an +/// empty collection; as such our guard cases wihch return `-1` when unwrapping this value should never be hit and we can ignore +/// them as possible results. +/// +/// For more information see: +/// https://forums.swift.org/t/when-is-unsafemutablebufferpointer-baseaddress-nil/32136/5 +/// https://github.com/apple/swift-evolution/blob/master/proposals/0055-optional-unsafe-pointers.md#unsafebufferpointer extension Sodium { private static let scalarLength: Int = Int(crypto_core_ed25519_scalarbytes()) // 32 private static let noClampLength: Int = Int(crypto_scalarmult_ed25519_bytes()) // 32 @@ -49,7 +58,7 @@ extension Sodium { private static let secretKeyLength: Int = Int(crypto_sign_secretkeybytes()) // 64 /// 64-byte blake2b hash then reduce to get the blinding factor - public func generateBlindingFactor(serverPublicKey: String) -> Bytes? { + public func generateBlindingFactor(serverPublicKey: String, genericHash: GenericHashType) -> Bytes? { /// k = salt.crypto_core_ed25519_scalar_reduce(blake2b(server_pk, digest_size=64).digest()) guard let serverPubKeyData: Data = serverPublicKey.dataFromHex() else { return nil } guard let serverPublicKeyHashBytes: Bytes = genericHash.hash(message: [UInt8](serverPubKeyData), outputLength: 64) else { @@ -59,18 +68,15 @@ extension Sodium { /// Reduce the server public key into an ed25519 scalar (`k`) let kPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.scalarLength) - let kResult = serverPublicKeyHashBytes.withUnsafeBytes { (serverPublicKeyHashPtr: UnsafeRawBufferPointer) -> Int32 in + _ = serverPublicKeyHashBytes.withUnsafeBytes { (serverPublicKeyHashPtr: UnsafeRawBufferPointer) -> Int32 in guard let serverPublicKeyHashBaseAddress: UnsafePointer = serverPublicKeyHashPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return -1 + return -1 // Impossible case (refer to comments at top of extension) } crypto_core_ed25519_scalar_reduce(kPtr, serverPublicKeyHashBaseAddress) return 0 } - /// Ensure the above worked - guard kResult == 0 else { return nil } - return Data(bytes: kPtr, count: Sodium.scalarLength).bytes } @@ -78,21 +84,20 @@ extension Sodium { /// convert to an *x* secret key, which seems wrong--but isn't because converted keys use the /// same secret scalar secret (and so this is just the most convenient way to get 'a' out of /// a sodium Ed25519 secret key) - private func generatePrivateKeyScalar(secretKey: Bytes) -> Bytes? { + func generatePrivateKeyScalar(secretKey: Bytes) -> Bytes { /// a = s.to_curve25519_private_key().encode() let aPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.scalarMultLength) - let aResult = secretKey.withUnsafeBytes { (secretKeyPtr: UnsafeRawBufferPointer) -> Int32 in + /// Looks like the `crypto_sign_ed25519_sk_to_curve25519` function can't actually fail so no need to verify the result + /// See: https://github.com/jedisct1/libsodium/blob/master/src/libsodium/crypto_sign/ed25519/ref10/keypair.c#L70 + _ = secretKey.withUnsafeBytes { (secretKeyPtr: UnsafeRawBufferPointer) -> Int32 in guard let secretKeyBaseAddress: UnsafePointer = secretKeyPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return -1 + return -1 // Impossible case (refer to comments at top of extension) } return crypto_sign_ed25519_sk_to_curve25519(aPtr, secretKeyBaseAddress) } - /// Ensure the above worked - guard aResult == 0 else { return nil } - return Data(bytes: aPtr, count: Sodium.scalarMultLength).bytes } @@ -101,20 +106,22 @@ extension Sodium { guard edKeyPair.publicKey.count == Sodium.publicKeyLength && edKeyPair.secretKey.count == Sodium.secretKeyLength else { return nil } - guard let kBytes: Bytes = generateBlindingFactor(serverPublicKey: serverPublicKey) else { return nil } - guard let aBytes: Bytes = generatePrivateKeyScalar(secretKey: edKeyPair.secretKey) else { return nil } + guard let kBytes: Bytes = generateBlindingFactor(serverPublicKey: serverPublicKey, genericHash: genericHash) else { + return nil + } + let aBytes: Bytes = generatePrivateKeyScalar(secretKey: edKeyPair.secretKey) /// Generate the blinded key pair `ka`, `kA` let kaPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.secretKeyLength) let kAPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.publicKeyLength) - let kaResult = aBytes.withUnsafeBytes { (aPtr: UnsafeRawBufferPointer) -> Int32 in + _ = aBytes.withUnsafeBytes { (aPtr: UnsafeRawBufferPointer) -> Int32 in return kBytes.withUnsafeBytes { (kPtr: UnsafeRawBufferPointer) -> Int32 in guard let kBaseAddress: UnsafePointer = kPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return -1 + return -1 // Impossible case (refer to comments at top of extension) } guard let aBaseAddress: UnsafePointer = aPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return -1 + return -1 // Impossible case (refer to comments at top of extension) } crypto_core_ed25519_scalar_mul(kaPtr, kBaseAddress, aBaseAddress) @@ -122,9 +129,6 @@ extension Sodium { } } - /// Ensure the above worked - guard kaResult == 0 else { return nil } - guard crypto_scalarmult_ed25519_base_noclamp(kAPtr, kaPtr) == 0 else { return nil } return Box.KeyPair( @@ -144,18 +148,15 @@ extension Sodium { let combinedHashBytes: Bytes = (H_rh + kA + message).sha512() let rPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.scalarLength) - let rResult = combinedHashBytes.withUnsafeBytes { (combinedHashPtr: UnsafeRawBufferPointer) -> Int32 in + _ = combinedHashBytes.withUnsafeBytes { (combinedHashPtr: UnsafeRawBufferPointer) -> Int32 in guard let combinedHashBaseAddress: UnsafePointer = combinedHashPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return -1 + return -1 // Impossible case (refer to comments at top of extension) } crypto_core_ed25519_scalar_reduce(rPtr, combinedHashBaseAddress) return 0 } - /// Ensure the above worked - guard rResult == 0 else { return nil } - /// sig_R = salt.crypto_scalarmult_ed25519_base_noclamp(r) let sig_RPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.noClampLength) guard crypto_scalarmult_ed25519_base_noclamp(sig_RPtr, rPtr) == 0 else { return nil } @@ -165,25 +166,22 @@ extension Sodium { let HRAMHashBytes: Bytes = (sig_RBytes + kA + message).sha512() let HRAMPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.scalarLength) - let HRAMResult = HRAMHashBytes.withUnsafeBytes { (HRAMHashPtr: UnsafeRawBufferPointer) -> Int32 in + _ = HRAMHashBytes.withUnsafeBytes { (HRAMHashPtr: UnsafeRawBufferPointer) -> Int32 in guard let HRAMHashBaseAddress: UnsafePointer = HRAMHashPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return -1 + return -1 // Impossible case (refer to comments at top of extension) } crypto_core_ed25519_scalar_reduce(HRAMPtr, HRAMHashBaseAddress) return 0 } - /// Ensure the above worked - guard HRAMResult == 0 else { return nil } - /// sig_s = salt.crypto_core_ed25519_scalar_add(r, salt.crypto_core_ed25519_scalar_mul(HRAM, ka)) let sig_sMulPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.scalarLength) let sig_sPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.scalarLength) - let sig_sResult = ka.withUnsafeBytes { (kaPtr: UnsafeRawBufferPointer) -> Int32 in + _ = ka.withUnsafeBytes { (kaPtr: UnsafeRawBufferPointer) -> Int32 in guard let kaBaseAddress: UnsafePointer = kaPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return -1 + return -1 // Impossible case (refer to comments at top of extension) } crypto_core_ed25519_scalar_mul(sig_sMulPtr, HRAMPtr, kaBaseAddress) @@ -191,8 +189,6 @@ extension Sodium { return 0 } - guard sig_sResult == 0 else { return nil } - /// full_sig = sig_R + sig_s return (Data(bytes: sig_RPtr, count: Sodium.noClampLength).bytes + Data(bytes: sig_sPtr, count: Sodium.scalarLength).bytes) } @@ -204,10 +200,10 @@ extension Sodium { let result = rhsKeyBytes.withUnsafeBytes { (rhsKeyBytesPtr: UnsafeRawBufferPointer) -> Int32 in return lhsKeyBytes.withUnsafeBytes { (lhsKeyBytesPtr: UnsafeRawBufferPointer) -> Int32 in guard let lhsKeyBytesBaseAddress: UnsafePointer = lhsKeyBytesPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return -1 + return -1 // Impossible case (refer to comments at top of extension) } guard let rhsKeyBytesBaseAddress: UnsafePointer = rhsKeyBytesPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return -1 + return -1 // Impossible case (refer to comments at top of extension) } return crypto_scalarmult_ed25519_noclamp(combinedPtr, lhsKeyBytesBaseAddress, rhsKeyBytesBaseAddress) @@ -228,18 +224,23 @@ extension Sodium { /// /// BLAKE2b(b kA || kA || kB) public func sharedBlindedEncryptionKey(secretKey: Bytes, otherBlindedPublicKey: Bytes, fromBlindedPublicKey kA: Bytes, toBlindedPublicKey kB: Bytes, genericHash: GenericHashType) -> Bytes? { - guard let aBytes: Bytes = generatePrivateKeyScalar(secretKey: secretKey) else { return nil } - guard let combinedKeyBytes: Bytes = combineKeys(lhsKeyBytes: aBytes, rhsKeyBytes: otherBlindedPublicKey) else { return nil } + let aBytes: Bytes = generatePrivateKeyScalar(secretKey: secretKey) + + guard let combinedKeyBytes: Bytes = combineKeys(lhsKeyBytes: aBytes, rhsKeyBytes: otherBlindedPublicKey) else { + return nil + } return genericHash.hash(message: (combinedKeyBytes + kA + kB), outputLength: 32) } /// This method should be used to check if a users standard sessionId matches a blinded one - public func sessionId(_ standardSessionId: String, matchesBlindedId blindedSessionId: String, serverPublicKey: String) -> Bool { + public func sessionId(_ standardSessionId: String, matchesBlindedId blindedSessionId: String, serverPublicKey: String, genericHash: GenericHashType) -> Bool { // Only support generating blinded keys for standard session ids guard let sessionId: SessionId = SessionId(from: standardSessionId), sessionId.prefix == .standard else { return false } guard let blindedId: SessionId = SessionId(from: blindedSessionId), blindedId.prefix == .blinded else { return false } - guard let kBytes: Bytes = generateBlindingFactor(serverPublicKey: serverPublicKey) else { return false } + guard let kBytes: Bytes = generateBlindingFactor(serverPublicKey: serverPublicKey, genericHash: genericHash) else { + return false + } /// From the session id (ignoring 05 prefix) we have two possible ed25519 pubkeys; the first is the positive (which is what /// Signal's XEd25519 conversion always uses) diff --git a/SessionMessagingKitTests/Common Networking/HeaderSpec.swift b/SessionMessagingKitTests/Common Networking/HeaderSpec.swift new file mode 100644 index 000000000..df47313ff --- /dev/null +++ b/SessionMessagingKitTests/Common Networking/HeaderSpec.swift @@ -0,0 +1,20 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class HeaderSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + describe("a Dictionary of Header to String values") { + it("can be converted into a dictionary of String to String values") { + expect([Header.authorization: "test"].toHTTPHeaders()).to(equal(["Authorization": "test"])) + } + } + } +} diff --git a/SessionMessagingKitTests/Common Networking/Models/FileUploadResponseSpec.swift b/SessionMessagingKitTests/Common Networking/Models/FileUploadResponseSpec.swift new file mode 100644 index 000000000..499b8e658 --- /dev/null +++ b/SessionMessagingKitTests/Common Networking/Models/FileUploadResponseSpec.swift @@ -0,0 +1,32 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class FileUploadResponseSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + describe("a FileUploadResponse") { + context("when decoding") { + it("handles a string id value") { + let jsonData: Data = "{\"id\":\"123\"}".data(using: .utf8)! + let response: FileUploadResponse? = try? JSONDecoder().decode(FileUploadResponse.self, from: jsonData) + + expect(response?.id).to(equal("123")) + } + + it("handles an int id value") { + let jsonData: Data = "{\"id\":124}".data(using: .utf8)! + let response: FileUploadResponse? = try? JSONDecoder().decode(FileUploadResponse.self, from: jsonData) + + expect(response?.id).to(equal("124")) + } + } + } + } +} diff --git a/SessionMessagingKitTests/Common Networking/RequestSpec.swift b/SessionMessagingKitTests/Common Networking/RequestSpec.swift new file mode 100644 index 000000000..b23921fd3 --- /dev/null +++ b/SessionMessagingKitTests/Common Networking/RequestSpec.swift @@ -0,0 +1,167 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class RequestSpec: QuickSpec { + struct TestType: Codable, Equatable { + let stringValue: String + } + + // MARK: - Spec + + override func spec() { + describe("a Request") { + it("is initialized with the correct default values") { + let request: Request = Request( + server: "testServer", + endpoint: .batch + ) + + expect(request.method.rawValue).to(equal("GET")) + expect(request.queryParameters).to(equal([:])) + expect(request.headers).to(equal([:])) + expect(request.body).to(beNil()) + } + + context("when generating a URL") { + it("adds a leading forward slash to the endpoint path") { + let request: Request = Request( + server: "testServer", + endpoint: .batch + ) + + expect(request.urlPathAndParamsString).to(equal("/batch")) + } + + it("creates a valid URL with no query parameters") { + let request: Request = Request( + server: "testServer", + endpoint: .batch + ) + + expect(request.urlPathAndParamsString).to(equal("/batch")) + } + + it("creates a valid URL when query parameters are provided") { + let request: Request = Request( + server: "testServer", + endpoint: .batch, + queryParameters: [ + .limit: "123" + ] + ) + + expect(request.urlPathAndParamsString).to(equal("/batch?limit=123")) + } + } + + context("when generating a URLRequest") { + it("sets all the values correctly") { + let request: Request = Request( + method: .delete, + server: "testServer", + endpoint: .batch, + headers: [ + .authorization: "test" + ] + ) + let urlRequest: URLRequest? = try? request.generateUrlRequest() + + expect(urlRequest?.httpMethod).to(equal("DELETE")) + expect(urlRequest?.allHTTPHeaderFields).to(equal(["Authorization": "test"])) + expect(urlRequest?.httpBody).to(beNil()) + } + + it("throws an error if the URL is invalid") { + let request: Request = Request( + server: "testServer", + endpoint: .roomPollInfo("!!%%", 123) + ) + + expect { + try request.generateUrlRequest() + } + .to(throwError(HTTP.Error.invalidURL)) + } + + context("with a base64 string body") { + it("successfully encodes the body") { + let request: Request = Request( + server: "testServer", + endpoint: .batch, + body: "TestMessage".data(using: .utf8)!.base64EncodedString() + ) + + let urlRequest: URLRequest? = try? request.generateUrlRequest() + let requestBody: Data? = Data(base64Encoded: urlRequest?.httpBody?.base64EncodedString() ?? "") + let requestBodyString: String? = String(data: requestBody ?? Data(), encoding: .utf8) + + expect(requestBodyString).to(equal("TestMessage")) + } + + it("throws an error if the body is not base64 encoded") { + let request: Request = Request( + server: "testServer", + endpoint: .batch, + body: "TestMessage" + ) + + expect { + try request.generateUrlRequest() + } + .to(throwError(HTTP.Error.parsingFailed)) + } + } + + context("with a byte body") { + it("successfully encodes the body") { + let request: Request<[UInt8], OpenGroupAPI.Endpoint> = Request( + server: "testServer", + endpoint: .batch, + body: [1, 2, 3] + ) + + let urlRequest: URLRequest? = try? request.generateUrlRequest() + + expect(urlRequest?.httpBody?.bytes).to(equal([1, 2, 3])) + } + } + + context("with a JSON body") { + it("successfully encodes the body") { + let request: Request = Request( + server: "testServer", + endpoint: .batch, + body: TestType(stringValue: "test") + ) + + let urlRequest: URLRequest? = try? request.generateUrlRequest() + let requestBody: TestType? = try? JSONDecoder().decode( + TestType.self, + from: urlRequest?.httpBody ?? Data() + ) + + expect(requestBody).to(equal(TestType(stringValue: "test"))) + } + + it("successfully encodes no body") { + let request: Request = Request( + server: "testServer", + endpoint: .batch, + body: nil + ) + + expect { + try request.generateUrlRequest() + }.toNot(throwError()) + } + } + } + } + } +} diff --git a/SessionMessagingKitTests/Contacts/BlindedIdMappingSpec.swift b/SessionMessagingKitTests/Contacts/BlindedIdMappingSpec.swift new file mode 100644 index 000000000..3c2c26d40 --- /dev/null +++ b/SessionMessagingKitTests/Contacts/BlindedIdMappingSpec.swift @@ -0,0 +1,48 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class BlindedIdMappingSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + describe("a BlindedIdMapping") { + context("when initializing") { + it("sets the values correctly") { + let mapping: BlindedIdMapping = BlindedIdMapping( + blindedId: "testBlindedId", + sessionId: "testSessionId", + serverPublicKey: "testPublicKey" + ) + + expect(mapping.blindedId).to(equal("testBlindedId")) + expect(mapping.sessionId).to(equal("testSessionId")) + expect(mapping.serverPublicKey).to(equal("testPublicKey")) + } + } + + context("when NSCoding") { + // Note: Unit testing NSCoder is horrible so we won't do it properly - wait until we refactor it to Codable + it("successfully encodes and decodes") { + let mappingToEncode: BlindedIdMapping = BlindedIdMapping( + blindedId: "testBlindedId", + sessionId: "testSessionId", + serverPublicKey: "testPublicKey" + ) + let encodedData: Data = try! NSKeyedArchiver.archivedData(withRootObject: mappingToEncode, requiringSecureCoding: false) + let mapping: BlindedIdMapping? = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(encodedData) as? BlindedIdMapping + + expect(mapping).toNot(beNil()) + expect(mapping?.blindedId).to(equal("testBlindedId")) + expect(mapping?.sessionId).to(equal("testSessionId")) + expect(mapping?.serverPublicKey).to(equal("testPublicKey")) + } + } + } + } +} diff --git a/SessionMessagingKitTests/Open Groups/Models/BatchRequestInfoSpec.swift b/SessionMessagingKitTests/Open Groups/Models/BatchRequestInfoSpec.swift new file mode 100644 index 000000000..f988057a8 --- /dev/null +++ b/SessionMessagingKitTests/Open Groups/Models/BatchRequestInfoSpec.swift @@ -0,0 +1,385 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import PromiseKit +import SessionSnodeKit + +import Quick +import Nimble + +@testable import SessionMessagingKit +import AVFoundation + +class BatchRequestInfoSpec: QuickSpec { + struct TestType: Codable, Equatable { + let stringValue: String + } + + // MARK: - Spec + + override func spec() { + // MARK: - BatchSubRequest + + describe("a BatchSubRequest") { + var subRequest: OpenGroupAPI.BatchSubRequest! + + context("when initializing") { + it("sets the headers to nil if there aren't any") { + subRequest = OpenGroupAPI.BatchSubRequest( + request: Request( + server: "testServer", + endpoint: .batch + ) + ) + + expect(subRequest.headers).to(beNil()) + } + + it("converts the headers to HTTP headers") { + subRequest = OpenGroupAPI.BatchSubRequest( + request: Request( + method: .get, + server: "testServer", + endpoint: .batch, + queryParameters: [:], + headers: [.authorization: "testAuth"], + body: nil + ) + ) + + expect(subRequest.headers).to(equal(["Authorization": "testAuth"])) + } + } + + context("when encoding") { + it("successfully encodes a string body") { + subRequest = OpenGroupAPI.BatchSubRequest( + request: Request( + method: .get, + server: "testServer", + endpoint: .batch, + queryParameters: [:], + headers: [:], + body: "testBody" + ) + ) + let subRequestData: Data = try! JSONEncoder().encode(subRequest) + let subRequestString: String? = String(data: subRequestData, encoding: .utf8) + + expect(subRequestString) + .to(equal("{\"path\":\"\\/batch\",\"method\":\"GET\",\"b64\":\"testBody\"}")) + } + + it("successfully encodes a byte body") { + subRequest = OpenGroupAPI.BatchSubRequest( + request: Request<[UInt8], OpenGroupAPI.Endpoint>( + method: .get, + server: "testServer", + endpoint: .batch, + queryParameters: [:], + headers: [:], + body: [1, 2, 3] + ) + ) + let subRequestData: Data = try! JSONEncoder().encode(subRequest) + let subRequestString: String? = String(data: subRequestData, encoding: .utf8) + + expect(subRequestString) + .to(equal("{\"path\":\"\\/batch\",\"method\":\"GET\",\"bytes\":[1,2,3]}")) + } + + it("successfully encodes a JSON body") { + subRequest = OpenGroupAPI.BatchSubRequest( + request: Request( + method: .get, + server: "testServer", + endpoint: .batch, + queryParameters: [:], + headers: [:], + body: TestType(stringValue: "testValue") + ) + ) + let subRequestData: Data = try! JSONEncoder().encode(subRequest) + let subRequestString: String? = String(data: subRequestData, encoding: .utf8) + + expect(subRequestString) + .to(equal("{\"path\":\"\\/batch\",\"method\":\"GET\",\"json\":{\"stringValue\":\"testValue\"}}")) + } + } + } + + // MARK: - BatchSubResponse + + describe("a BatchSubResponse") { + context("when decoding") { + it("decodes correctly") { + let jsonString: String = """ + { + "code": 200, + "headers": { + "testKey": "testValue" + }, + "body": { + "stringValue": "testValue" + } + } + """ + let subResponse: OpenGroupAPI.BatchSubResponse? = try? JSONDecoder().decode( + OpenGroupAPI.BatchSubResponse.self, + from: jsonString.data(using: .utf8)! + ) + + expect(subResponse).toNot(beNil()) + expect(subResponse?.body).toNot(beNil()) + } + + it("decodes with invalid body data") { + let jsonString: String = """ + { + "code": 200, + "headers": { + "testKey": "testValue" + }, + "body": "Hello!!!" + } + """ + let subResponse: OpenGroupAPI.BatchSubResponse? = try? JSONDecoder().decode( + OpenGroupAPI.BatchSubResponse.self, + from: jsonString.data(using: .utf8)! + ) + + expect(subResponse).toNot(beNil()) + } + + it("flags invalid body data as invalid") { + let jsonString: String = """ + { + "code": 200, + "headers": { + "testKey": "testValue" + }, + "body": "Hello!!!" + } + """ + let subResponse: OpenGroupAPI.BatchSubResponse? = try? JSONDecoder().decode( + OpenGroupAPI.BatchSubResponse.self, + from: jsonString.data(using: .utf8)! + ) + + expect(subResponse).toNot(beNil()) + expect(subResponse?.body).to(beNil()) + expect(subResponse?.failedToParseBody).to(beTrue()) + } + + it("does not flag a missing or invalid optional body as invalid") { + let jsonString: String = """ + { + "code": 200, + "headers": { + "testKey": "testValue" + }, + } + """ + let subResponse: OpenGroupAPI.BatchSubResponse? = try? JSONDecoder().decode( + OpenGroupAPI.BatchSubResponse.self, + from: jsonString.data(using: .utf8)! + ) + + expect(subResponse).toNot(beNil()) + expect(subResponse?.body).to(beNil()) + expect(subResponse?.failedToParseBody).to(beFalse()) + } + + it("does not flag a NoResponse body as invalid") { + let jsonString: String = """ + { + "code": 200, + "headers": { + "testKey": "testValue" + }, + } + """ + let subResponse: OpenGroupAPI.BatchSubResponse? = try? JSONDecoder().decode( + OpenGroupAPI.BatchSubResponse.self, + from: jsonString.data(using: .utf8)! + ) + + expect(subResponse).toNot(beNil()) + expect(subResponse?.body).to(beNil()) + expect(subResponse?.failedToParseBody).to(beFalse()) + } + } + } + + // MARK: - BatchRequestInfo + + describe("a BatchRequestInfo") { + var request: Request! + + beforeEach { + request = Request( + method: .get, + server: "testServer", + endpoint: .batch, + queryParameters: [:], + headers: [:], + body: TestType(stringValue: "testValue") + ) + } + + it("initializes correctly when given a request") { + let requestInfo: OpenGroupAPI.BatchRequestInfo = OpenGroupAPI.BatchRequestInfo( + request: request + ) + + expect(requestInfo.request).to(equal(request)) + expect(requestInfo.responseType == OpenGroupAPI.BatchSubResponse.self).to(beTrue()) + } + + it("initializes correctly when given a request and a response type") { + let requestInfo: OpenGroupAPI.BatchRequestInfo = OpenGroupAPI.BatchRequestInfo( + request: request, + responseType: TestType.self + ) + + expect(requestInfo.request).to(equal(request)) + expect(requestInfo.responseType == OpenGroupAPI.BatchSubResponse.self).to(beTrue()) + } + + it("exposes the endpoint correctly") { + let requestInfo: OpenGroupAPI.BatchRequestInfo = OpenGroupAPI.BatchRequestInfo( + request: request + ) + + expect(requestInfo.endpoint.path).to(equal(request.endpoint.path)) + } + + it("generates a sub request correctly") { + let requestInfo: OpenGroupAPI.BatchRequestInfo = OpenGroupAPI.BatchRequestInfo( + request: request + ) + let subRequest: OpenGroupAPI.BatchSubRequest = requestInfo.toSubRequest() + + expect(subRequest.method).to(equal(request.method)) + expect(subRequest.path).to(equal(request.urlPathAndParamsString)) + expect(subRequest.headers).to(beNil()) + } + } + + // MARK: - Convenience + // MARK: --Decodable + + describe("a Decodable") { + it("decodes correctly") { + let jsonData: Data = "{\"stringValue\":\"testValue\"}".data(using: .utf8)! + let result: TestType? = try? TestType.decoded(from: jsonData) + + expect(result).to(equal(TestType(stringValue: "testValue"))) + } + } + + // MARK: - --Promise + + describe("an (OnionRequestResponseInfoType, Data?) Promise") { + var responseInfo: OnionRequestResponseInfoType! + var capabilities: OpenGroupAPI.Capabilities! + var pinnedMessage: OpenGroupAPI.PinnedMessage! + var data: Data! + + beforeEach { + responseInfo = OnionRequestAPI.ResponseInfo(code: 200, headers: [:]) + capabilities = OpenGroupAPI.Capabilities(capabilities: [], missing: nil) + pinnedMessage = OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 123, pinnedBy: "test") + data = """ + [\([ + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: capabilities, + failedToParseBody: false + ) + ), + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: pinnedMessage, + failedToParseBody: false + ) + ) + ] + .map { String(data: $0, encoding: .utf8)! } + .joined(separator: ","))] + """.data(using: .utf8)! + } + + it("decodes valid data correctly") { + let result = Promise.value((responseInfo, data)) + .decoded(as: [ + OpenGroupAPI.BatchSubResponse.self, + OpenGroupAPI.BatchSubResponse.self + ]) + + expect(result.value).toNot(beNil()) + expect((result.value?[0].1 as? OpenGroupAPI.BatchSubResponse)?.body) + .to(equal(capabilities)) + expect((result.value?[1].1 as? OpenGroupAPI.BatchSubResponse)?.body) + .to(equal(pinnedMessage)) + } + + it("fails if there is no data") { + let result = Promise.value((responseInfo, nil)).decoded(as: []) + + expect(result.error?.localizedDescription).to(equal(HTTP.Error.parsingFailed.localizedDescription)) + } + + it("fails if the data is not JSON") { + let result = Promise.value((responseInfo, Data([1, 2, 3]))).decoded(as: []) + + expect(result.error?.localizedDescription).to(equal(HTTP.Error.parsingFailed.localizedDescription)) + } + + it("fails if the data is not a JSON array") { + let result = Promise.value((responseInfo, "{}".data(using: .utf8))).decoded(as: []) + + expect(result.error?.localizedDescription).to(equal(HTTP.Error.parsingFailed.localizedDescription)) + } + + it("fails if the JSON array does not have the same number of items as the expected types") { + let result = Promise.value((responseInfo, data)) + .decoded(as: [ + OpenGroupAPI.BatchSubResponse.self, + OpenGroupAPI.BatchSubResponse.self, + OpenGroupAPI.BatchSubResponse.self + ]) + + expect(result.error?.localizedDescription).to(equal(HTTP.Error.parsingFailed.localizedDescription)) + } + + it("fails if one of the JSON array values fails to decode") { + data = """ + [\([ + try! JSONEncoder().encode( + OpenGroupAPI.BatchSubResponse( + code: 200, + headers: [:], + body: capabilities, + failedToParseBody: false + ) + ) + ] + .map { String(data: $0, encoding: .utf8)! } + .joined(separator: ",")),{"test": "test"}] + """.data(using: .utf8)! + let result = Promise.value((responseInfo, data)) + .decoded(as: [ + OpenGroupAPI.BatchSubResponse.self, + OpenGroupAPI.BatchSubResponse.self + ]) + + expect(result.error?.localizedDescription).to(equal(HTTP.Error.parsingFailed.localizedDescription)) + } + } + } +} diff --git a/SessionMessagingKitTests/Open Groups/Models/OpenGroupSpec.swift b/SessionMessagingKitTests/Open Groups/Models/OpenGroupSpec.swift index b6d310e8b..17cd0e496 100644 --- a/SessionMessagingKitTests/Open Groups/Models/OpenGroupSpec.swift +++ b/SessionMessagingKitTests/Open Groups/Models/OpenGroupSpec.swift @@ -29,7 +29,7 @@ class OpenGroupSpec: QuickSpec { } context("when NSCoding") { - // Note: Unit testing NSCoder is horrible so we won't do it - wait until we refactor it to Codable + // Note: Unit testing NSCoder is horrible so we won't do it properly - wait until we refactor it to Codable it("successfully encodes and decodes") { let openGroupToEncode: OpenGroup = OpenGroup( server: "server", diff --git a/SessionMessagingKitTests/Open Groups/Models/ServerSpec.swift b/SessionMessagingKitTests/Open Groups/Models/ServerSpec.swift index ccd8557d0..6b5d47a4e 100644 --- a/SessionMessagingKitTests/Open Groups/Models/ServerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/Models/ServerSpec.swift @@ -24,7 +24,7 @@ class ServerSpec: QuickSpec { } context("when NSCoding") { - // Note: Unit testing NSCoder is horrible so we won't do it - wait until we refactor it to Codable + // Note: Unit testing NSCoder is horrible so we won't do it properly - wait until we refactor it to Codable it("successfully encodes and decodes") { let serverToEncode: OpenGroupAPI.Server = OpenGroupAPI.Server( name: "test", diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift index 94bc382d8..cbf9262c9 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift @@ -43,9 +43,9 @@ class OpenGroupAPISpec: QuickSpec { onionApi: TestOnionRequestAPI.self, storage: mockStorage, sodium: mockSodium, - aeadXChaCha20Poly1305Ietf: mockAeadXChaCha20Poly1305Ietf, - sign: mockSign, genericHash: mockGenericHash, + sign: mockSign, + aeadXChaCha20Poly1305Ietf: mockAeadXChaCha20Poly1305Ietf, ed25519: mockEd25519, nonceGenerator16: mockNonce16Generator, nonceGenerator24: mockNonce24Generator, @@ -229,7 +229,7 @@ class OpenGroupAPISpec: QuickSpec { expect(requestData?.urlString).to(equal("testServer/batch")) expect(requestData?.httpMethod).to(equal("POST")) expect(requestData?.server).to(equal("testServer")) - expect(requestData?.publicKey).to(equal("7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")) + expect(requestData?.publicKey).to(equal("88672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) } it("retrieves recent messages if there was no last message") { @@ -2663,10 +2663,10 @@ class OpenGroupAPISpec: QuickSpec { expect(requestData?.urlString).to(equal("testServer/rooms")) expect(requestData?.httpMethod).to(equal("GET")) expect(requestData?.server).to(equal("testServer")) - expect(requestData?.publicKey).to(equal("7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")) + expect(requestData?.publicKey).to(equal("88672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) expect(requestData?.headers).to(haveCount(4)) expect(requestData?.headers[Header.sogsPubKey.rawValue]) - .to(equal("007aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")) + .to(equal("0088672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) expect(requestData?.headers[Header.sogsTimestamp.rawValue]).to(equal("1234567890")) expect(requestData?.headers[Header.sogsNonce.rawValue]).to(equal("pK6YRtQApl4NhECGizF0Cg==")) expect(requestData?.headers[Header.sogsSignature.rawValue]).to(equal("TestSignature".bytes.toBase64())) @@ -2720,9 +2720,9 @@ class OpenGroupAPISpec: QuickSpec { expect(requestData?.urlString).to(equal("testServer/rooms")) expect(requestData?.httpMethod).to(equal("GET")) expect(requestData?.server).to(equal("testServer")) - expect(requestData?.publicKey).to(equal("7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")) + expect(requestData?.publicKey).to(equal("88672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) expect(requestData?.headers).to(haveCount(4)) - expect(requestData?.headers[Header.sogsPubKey.rawValue]).to(equal("157aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")) + expect(requestData?.headers[Header.sogsPubKey.rawValue]).to(equal("1588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) expect(requestData?.headers[Header.sogsTimestamp.rawValue]).to(equal("1234567890")) expect(requestData?.headers[Header.sogsNonce.rawValue]).to(equal("pK6YRtQApl4NhECGizF0Cg==")) expect(requestData?.headers[Header.sogsSignature.rawValue]).to(equal("TestSogsSignature".bytes.toBase64())) diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift index c7c7b863a..dfdc6b0ef 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -114,9 +114,9 @@ class OpenGroupManagerSpec: QuickSpec { identityManager: mockIdentityManager, storage: mockStorage, sodium: mockSodium, - aeadXChaCha20Poly1305Ietf: mockAeadXChaCha20Poly1305Ietf, - sign: mockSign, genericHash: mockGenericHash, + sign: mockSign, + aeadXChaCha20Poly1305Ietf: mockAeadXChaCha20Poly1305Ietf, ed25519: MockEd25519(), nonceGenerator16: mockNonce16Generator, nonceGenerator24: mockNonce24Generator, @@ -1600,7 +1600,7 @@ class OpenGroupManagerSpec: QuickSpec { expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockStorage) - .to(call(matchingParameters: true) { + .toEventually(call(matchingParameters: true) { $0.setOpenGroup( OpenGroup( server: "testServer", @@ -1620,6 +1620,11 @@ class OpenGroupManagerSpec: QuickSpec { beNil(), timeout: .milliseconds(50) ) + expect(mockOGMCache) + .toEventually( + call(.exactly(times: 1)) { $0.groupImagePromises }, + timeout: .milliseconds(50) + ) expect(testGroupThread.numSaveCalls) .toEventually( equal(2), // Call to save the open group and then to save the image @@ -2121,7 +2126,7 @@ class OpenGroupManagerSpec: QuickSpec { } .thenReturn([]) mockSodium - .when { $0.generateBlindingFactor(serverPublicKey: any()) } + .when { $0.generateBlindingFactor(serverPublicKey: any(), genericHash: mockGenericHash) } .thenReturn([]) mockAeadXChaCha20Poly1305Ietf .when { diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverDecryptionSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverDecryptionSpec.swift new file mode 100644 index 000000000..c59f20e23 --- /dev/null +++ b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverDecryptionSpec.swift @@ -0,0 +1,500 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class MessageReceiverDecryptionSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + var mockStorage: MockStorage! + var mockSodium: MockSodium! + var mockBox: MockBox! + var mockGenericHash: MockGenericHash! + var mockSign: MockSign! + var mockAeadXChaCha: MockAeadXChaCha20Poly1305Ietf! + var mockNonce24Generator: MockNonce24Generator! + var dependencies: Dependencies! + + describe("a MessageReceiver") { + beforeEach { + mockStorage = MockStorage() + mockSodium = MockSodium() + mockBox = MockBox() + mockGenericHash = MockGenericHash() + mockSign = MockSign() + mockAeadXChaCha = MockAeadXChaCha20Poly1305Ietf() + mockNonce24Generator = MockNonce24Generator() + + mockAeadXChaCha + .when { $0.encrypt(message: anyArray(), secretKey: anyArray(), nonce: anyArray()) } + .thenReturn(nil) + + dependencies = Dependencies( + storage: mockStorage, + sodium: mockSodium, + box: mockBox, + genericHash: mockGenericHash, + sign: mockSign, + aeadXChaCha20Poly1305Ietf: mockAeadXChaCha, + nonceGenerator24: mockNonce24Generator + ) + + mockStorage + .when { $0.getUserED25519KeyPair() } + .thenReturn( + Box.KeyPair( + publicKey: Data(hex: TestConstants.edPublicKey).bytes, + secretKey: Data(hex: TestConstants.edSecretKey).bytes + ) + ) + mockBox + .when { + $0.open( + anonymousCipherText: anyArray(), + recipientPublicKey: anyArray(), + recipientSecretKey: anyArray() + ) + } + .thenReturn([UInt8](repeating: 0, count: 100)) + mockSodium + .when { $0.blindedKeyPair(serverPublicKey: any(), edKeyPair: any(), genericHash: mockGenericHash) } + .thenReturn( + Box.KeyPair( + publicKey: Data(hex: TestConstants.blindedPublicKey).bytes, + secretKey: Data(hex: TestConstants.edSecretKey).bytes + ) + ) + mockSodium + .when { + $0.sharedBlindedEncryptionKey( + secretKey: anyArray(), + otherBlindedPublicKey: anyArray(), + fromBlindedPublicKey: anyArray(), + toBlindedPublicKey: anyArray(), + genericHash: mockGenericHash + ) + } + .thenReturn([]) + mockSodium + .when { $0.generateBlindingFactor(serverPublicKey: any(), genericHash: mockGenericHash) } + .thenReturn([]) + mockSodium + .when { $0.combineKeys(lhsKeyBytes: anyArray(), rhsKeyBytes: anyArray()) } + .thenReturn(Data(hex: TestConstants.blindedPublicKey).bytes) + mockSign + .when { $0.toX25519(ed25519PublicKey: anyArray()) } + .thenReturn(Data(hex: TestConstants.publicKey).bytes) + mockSign + .when { $0.verify(message: anyArray(), publicKey: anyArray(), signature: anyArray()) } + .thenReturn(true) + mockAeadXChaCha + .when { $0.decrypt(authenticatedCipherText: anyArray(), secretKey: anyArray(), nonce: anyArray()) } + .thenReturn("TestMessage".data(using: .utf8)!.bytes + [UInt8](repeating: 0, count: 32)) + mockNonce24Generator + .when { $0.nonce() } + .thenReturn(Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")!.bytes) + } + + context("when decrypting with the session protocol") { + it("successfully decrypts a message") { + let result = try? MessageReceiver.decryptWithSessionProtocol( + ciphertext: Data( + base64Encoded: "SRP0eBUWh4ez6ppWjUs5/Wph5fhnPRgB5zsWWnTz+FBAw/YI3oS2pDpIfyetMTbU" + + "sFMhE5G4PbRtQFey1hsxLl221Qivc3ayaX2Mm/X89Dl8e45BC+Lb/KU9EdesxIK4pVgYXs9XrMtX3v8" + + "dt0eBaXneOBfr7qB8pHwwMZjtkOu1ED07T9nszgbWabBphUfWXe2U9K3PTRisSCI=" + )!, + using: try! ECKeyPair( + publicKeyData: Data.data(fromHex: TestConstants.publicKey)!, + privateKeyData: Data.data(fromHex: TestConstants.privateKey)! + ), + dependencies: Dependencies() + ) + + expect(String(data: (result?.plaintext ?? Data()), encoding: .utf8)).to(equal("TestMessage")) + expect(result?.senderX25519PublicKey) + .to(equal("0588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) + } + + it("throws an error if it cannot open the message") { + mockBox + .when { + $0.open( + anonymousCipherText: anyArray(), + recipientPublicKey: anyArray(), + recipientSecretKey: anyArray() + ) + } + .thenReturn(nil) + + expect { + try MessageReceiver.decryptWithSessionProtocol( + ciphertext: "TestMessage".data(using: .utf8)!, + using: try! ECKeyPair( + publicKeyData: Data.data(fromHex: TestConstants.publicKey)!, + privateKeyData: Data.data(fromHex: TestConstants.privateKey)! + ), + dependencies: dependencies + ) + } + .to(throwError(MessageReceiver.Error.decryptionFailed)) + } + + it("throws an error if the open message is too short") { + mockBox + .when { + $0.open( + anonymousCipherText: anyArray(), + recipientPublicKey: anyArray(), + recipientSecretKey: anyArray() + ) + } + .thenReturn([1, 2, 3]) + + expect { + try MessageReceiver.decryptWithSessionProtocol( + ciphertext: "TestMessage".data(using: .utf8)!, + using: try! ECKeyPair( + publicKeyData: Data.data(fromHex: TestConstants.publicKey)!, + privateKeyData: Data.data(fromHex: TestConstants.privateKey)! + ), + dependencies: dependencies + ) + } + .to(throwError(MessageReceiver.Error.decryptionFailed)) + } + + it("throws an error if it cannot verify the message") { + mockSign + .when { $0.verify(message: anyArray(), publicKey: anyArray(), signature: anyArray()) } + .thenReturn(false) + + expect { + try MessageReceiver.decryptWithSessionProtocol( + ciphertext: "TestMessage".data(using: .utf8)!, + using: try! ECKeyPair( + publicKeyData: Data.data(fromHex: TestConstants.publicKey)!, + privateKeyData: Data.data(fromHex: TestConstants.privateKey)! + ), + dependencies: dependencies + ) + } + .to(throwError(MessageReceiver.Error.invalidSignature)) + } + + it("throws an error if it cannot get the senders x25519 public key") { + mockSign.when { $0.toX25519(ed25519PublicKey: anyArray()) }.thenReturn(nil) + + expect { + try MessageReceiver.decryptWithSessionProtocol( + ciphertext: "TestMessage".data(using: .utf8)!, + using: try! ECKeyPair( + publicKeyData: Data.data(fromHex: TestConstants.publicKey)!, + privateKeyData: Data.data(fromHex: TestConstants.privateKey)! + ), + dependencies: dependencies + ) + } + .to(throwError(MessageReceiver.Error.decryptionFailed)) + } + } + + context("when decrypting with the blinded session protocol") { + it("successfully decrypts a message") { + let result = try? MessageReceiver.decryptWithSessionBlindingProtocol( + data: Data( + hex: "00db16b6687382811d69875a5376f66acad9c49fe5e26bcf770c7e6e9c230299" + + "f61b315299dd1fa700dd7f34305c0465af9e64dc791d7f4123f1eeafa5b4d48b3ade4" + + "f4b2a2764762e5a2c7900f254bd91633b43" + ), + isOutgoing: true, + otherBlindedPublicKey: "15\(TestConstants.blindedPublicKey)", + with: TestConstants.serverPublicKey, + userEd25519KeyPair: Box.KeyPair( + publicKey: Data.data(fromHex: TestConstants.edPublicKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes + ), + using: Dependencies() + ) + + expect(String(data: (result?.plaintext ?? Data()), encoding: .utf8)).to(equal("TestMessage")) + expect(result?.senderX25519PublicKey) + .to(equal("0588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) + } + + it("successfully decrypts a mocked incoming message") { + let result = try? MessageReceiver.decryptWithSessionBlindingProtocol( + data: ( + Data([0]) + + "TestMessage".data(using: .utf8)! + + Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")! + ), + isOutgoing: false, + otherBlindedPublicKey: "15\(TestConstants.blindedPublicKey)", + with: TestConstants.serverPublicKey, + userEd25519KeyPair: Box.KeyPair( + publicKey: Data.data(fromHex: TestConstants.edPublicKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes + ), + using: dependencies + ) + + expect(String(data: (result?.plaintext ?? Data()), encoding: .utf8)).to(equal("TestMessage")) + expect(result?.senderX25519PublicKey) + .to(equal("0588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) + } + + it("throws an error if the data is too short") { + expect { + try MessageReceiver.decryptWithSessionBlindingProtocol( + data: Data([1, 2, 3]), + isOutgoing: true, + otherBlindedPublicKey: "15\(TestConstants.blindedPublicKey)", + with: TestConstants.serverPublicKey, + userEd25519KeyPair: Box.KeyPair( + publicKey: Data.data(fromHex: TestConstants.edPublicKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes + ), + using: dependencies + ) + } + .to(throwError(MessageReceiver.Error.decryptionFailed)) + } + + it("throws an error if it cannot get the blinded keyPair") { + mockSodium + .when { $0.blindedKeyPair(serverPublicKey: any(), edKeyPair: any(), genericHash: mockGenericHash) } + .thenReturn(nil) + + expect { + try MessageReceiver.decryptWithSessionBlindingProtocol( + data: ( + Data([0]) + + "TestMessage".data(using: .utf8)! + + Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")! + ), + isOutgoing: true, + otherBlindedPublicKey: "15\(TestConstants.blindedPublicKey)", + with: TestConstants.serverPublicKey, + userEd25519KeyPair: Box.KeyPair( + publicKey: Data.data(fromHex: TestConstants.edPublicKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes + ), + using: dependencies + ) + } + .to(throwError(MessageReceiver.Error.decryptionFailed)) + } + + it("throws an error if it cannot get the decryption key") { + mockSodium + .when { + $0.sharedBlindedEncryptionKey( + secretKey: anyArray(), + otherBlindedPublicKey: anyArray(), + fromBlindedPublicKey: anyArray(), + toBlindedPublicKey: anyArray(), + genericHash: mockGenericHash + ) + } + .thenReturn(nil) + + expect { + try MessageReceiver.decryptWithSessionBlindingProtocol( + data: ( + Data([0]) + + "TestMessage".data(using: .utf8)! + + Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")! + ), + isOutgoing: true, + otherBlindedPublicKey: "15\(TestConstants.blindedPublicKey)", + with: TestConstants.serverPublicKey, + userEd25519KeyPair: Box.KeyPair( + publicKey: Data.data(fromHex: TestConstants.edPublicKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes + ), + using: dependencies + ) + } + .to(throwError(MessageReceiver.Error.decryptionFailed)) + } + + it("throws an error if the data version is not 0") { + expect { + try MessageReceiver.decryptWithSessionBlindingProtocol( + data: ( + Data([1]) + + "TestMessage".data(using: .utf8)! + + Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")! + ), + isOutgoing: true, + otherBlindedPublicKey: "15\(TestConstants.blindedPublicKey)", + with: TestConstants.serverPublicKey, + userEd25519KeyPair: Box.KeyPair( + publicKey: Data.data(fromHex: TestConstants.edPublicKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes + ), + using: dependencies + ) + } + .to(throwError(MessageReceiver.Error.decryptionFailed)) + } + + it("throws an error if it cannot decrypt the data") { + mockAeadXChaCha + .when { $0.decrypt(authenticatedCipherText: anyArray(), secretKey: anyArray(), nonce: anyArray()) } + .thenReturn(nil) + + expect { + try MessageReceiver.decryptWithSessionBlindingProtocol( + data: ( + Data([0]) + + "TestMessage".data(using: .utf8)! + + Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")! + ), + isOutgoing: true, + otherBlindedPublicKey: "15\(TestConstants.blindedPublicKey)", + with: TestConstants.serverPublicKey, + userEd25519KeyPair: Box.KeyPair( + publicKey: Data.data(fromHex: TestConstants.edPublicKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes + ), + using: dependencies + ) + } + .to(throwError(MessageReceiver.Error.decryptionFailed)) + } + + it("throws an error if the inner bytes are too short") { + mockAeadXChaCha + .when { $0.decrypt(authenticatedCipherText: anyArray(), secretKey: anyArray(), nonce: anyArray()) } + .thenReturn([1, 2, 3]) + + expect { + try MessageReceiver.decryptWithSessionBlindingProtocol( + data: ( + Data([0]) + + "TestMessage".data(using: .utf8)! + + Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")! + ), + isOutgoing: true, + otherBlindedPublicKey: "15\(TestConstants.blindedPublicKey)", + with: TestConstants.serverPublicKey, + userEd25519KeyPair: Box.KeyPair( + publicKey: Data.data(fromHex: TestConstants.edPublicKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes + ), + using: dependencies + ) + } + .to(throwError(MessageReceiver.Error.decryptionFailed)) + } + + it("throws an error if it cannot generate the blinding factor") { + mockSodium + .when { $0.generateBlindingFactor(serverPublicKey: any(), genericHash: mockGenericHash) } + .thenReturn(nil) + + expect { + try MessageReceiver.decryptWithSessionBlindingProtocol( + data: ( + Data([0]) + + "TestMessage".data(using: .utf8)! + + Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")! + ), + isOutgoing: true, + otherBlindedPublicKey: "15\(TestConstants.blindedPublicKey)", + with: TestConstants.serverPublicKey, + userEd25519KeyPair: Box.KeyPair( + publicKey: Data.data(fromHex: TestConstants.edPublicKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes + ), + using: dependencies + ) + } + .to(throwError(MessageReceiver.Error.invalidSignature)) + } + + it("throws an error if it cannot generate the combined key") { + mockSodium + .when { $0.combineKeys(lhsKeyBytes: anyArray(), rhsKeyBytes: anyArray()) } + .thenReturn(nil) + + expect { + try MessageReceiver.decryptWithSessionBlindingProtocol( + data: ( + Data([0]) + + "TestMessage".data(using: .utf8)! + + Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")! + ), + isOutgoing: true, + otherBlindedPublicKey: "15\(TestConstants.blindedPublicKey)", + with: TestConstants.serverPublicKey, + userEd25519KeyPair: Box.KeyPair( + publicKey: Data.data(fromHex: TestConstants.edPublicKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes + ), + using: dependencies + ) + } + .to(throwError(MessageReceiver.Error.invalidSignature)) + } + + it("throws an error if the combined key does not match kA") { + mockSodium + .when { $0.combineKeys(lhsKeyBytes: anyArray(), rhsKeyBytes: anyArray()) } + .thenReturn(Data(hex: TestConstants.publicKey).bytes) + + expect { + try MessageReceiver.decryptWithSessionBlindingProtocol( + data: ( + Data([0]) + + "TestMessage".data(using: .utf8)! + + Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")! + ), + isOutgoing: true, + otherBlindedPublicKey: "15\(TestConstants.blindedPublicKey)", + with: TestConstants.serverPublicKey, + userEd25519KeyPair: Box.KeyPair( + publicKey: Data.data(fromHex: TestConstants.edPublicKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes + ), + using: dependencies + ) + } + .to(throwError(MessageReceiver.Error.invalidSignature)) + } + + it("throws an error if it cannot get the senders x25519 public key") { + mockSign + .when { $0.toX25519(ed25519PublicKey: anyArray()) } + .thenReturn(nil) + + expect { + try MessageReceiver.decryptWithSessionBlindingProtocol( + data: ( + Data([0]) + + "TestMessage".data(using: .utf8)! + + Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")! + ), + isOutgoing: true, + otherBlindedPublicKey: "15\(TestConstants.blindedPublicKey)", + with: TestConstants.serverPublicKey, + userEd25519KeyPair: Box.KeyPair( + publicKey: Data.data(fromHex: TestConstants.edPublicKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes + ), + using: dependencies + ) + } + .to(throwError(MessageReceiver.Error.decryptionFailed)) + } + } + } + } +} diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageSenderEncryptionSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageSenderEncryptionSpec.swift new file mode 100644 index 000000000..77b888308 --- /dev/null +++ b/SessionMessagingKitTests/Sending & Receiving/MessageSenderEncryptionSpec.swift @@ -0,0 +1,272 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class MessageSenderEncryptionSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + var mockStorage: MockStorage! + var mockBox: MockBox! + var mockSign: MockSign! + var mockNonce24Generator: MockNonce24Generator! + var dependencies: Dependencies! + + describe("a MessageSender") { + beforeEach { + mockStorage = MockStorage() + mockBox = MockBox() + mockSign = MockSign() + mockNonce24Generator = MockNonce24Generator() + + dependencies = Dependencies( + storage: mockStorage, + box: mockBox, + sign: mockSign, + nonceGenerator24: mockNonce24Generator + ) + + mockStorage.when { $0.getUserED25519KeyPair() } + .thenReturn( + Box.KeyPair( + publicKey: Data(hex: TestConstants.edPublicKey).bytes, + secretKey: Data(hex: TestConstants.edSecretKey).bytes + ) + ) + mockNonce24Generator + .when { $0.nonce() } + .thenReturn(Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")!.bytes) + } + + context("when encrypting with the session protocol") { + beforeEach { + mockBox.when { $0.seal(message: anyArray(), recipientPublicKey: anyArray()) }.thenReturn([1, 2, 3]) + mockSign.when { $0.signature(message: anyArray(), secretKey: anyArray()) }.thenReturn([]) + } + + it("can encrypt correctly") { + let result = try? MessageSender.encryptWithSessionProtocol( + "TestMessage".data(using: .utf8)!, + for: "05\(TestConstants.publicKey)", + using: Dependencies(storage: mockStorage) + ) + + // Note: A Nonce is used for this so we can't compare the exact value when not mocked + expect(result).toNot(beNil()) + expect(result?.count).to(equal(155)) + } + + it("returns the correct value when mocked") { + let result = try? MessageSender.encryptWithSessionProtocol( + "TestMessage".data(using: .utf8)!, + for: "05\(TestConstants.publicKey)", + using: dependencies + ) + + expect(result?.bytes).to(equal([1, 2, 3])) + } + + it("throws an error if there is no ed25519 keyPair") { + mockStorage.when { $0.getUserED25519KeyPair() }.thenReturn(nil) + + expect { + try MessageSender.encryptWithSessionProtocol( + "TestMessage".data(using: .utf8)!, + for: "05\(TestConstants.publicKey)", + using: dependencies + ) + } + .to(throwError(MessageSender.Error.noUserED25519KeyPair)) + } + + it("throws an error if the signature generation fails") { + mockSign.when { $0.signature(message: anyArray(), secretKey: anyArray()) }.thenReturn(nil) + + expect { + try MessageSender.encryptWithSessionProtocol( + "TestMessage".data(using: .utf8)!, + for: "05\(TestConstants.publicKey)", + using: dependencies + ) + } + .to(throwError(MessageSender.Error.signingFailed)) + } + + it("throws an error if the encryption fails") { + mockBox.when { $0.seal(message: anyArray(), recipientPublicKey: anyArray()) }.thenReturn(nil) + + expect { + try MessageSender.encryptWithSessionProtocol( + "TestMessage".data(using: .utf8)!, + for: "05\(TestConstants.publicKey)", + using: dependencies + ) + } + .to(throwError(MessageSender.Error.encryptionFailed)) + } + } + + context("when encrypting with the blinded session protocol") { + it("successfully encrypts") { + let result = try? MessageSender.encryptWithSessionBlindingProtocol( + "TestMessage".data(using: .utf8)!, + for: "15\(TestConstants.blindedPublicKey)", + openGroupPublicKey: TestConstants.serverPublicKey, + using: dependencies + ) + + expect(result?.toHexString()) + .to(equal( + "00db16b6687382811d69875a5376f66acad9c49fe5e26bcf770c7e6e9c230299" + + "f61b315299dd1fa700dd7f34305c0465af9e64dc791d7f4123f1eeafa5b4d48b" + + "3ade4f4b2a2764762e5a2c7900f254bd91633b43" + )) + } + + it("includes a version at the start of the encrypted value") { + let result = try? MessageSender.encryptWithSessionBlindingProtocol( + "TestMessage".data(using: .utf8)!, + for: "15\(TestConstants.blindedPublicKey)", + openGroupPublicKey: TestConstants.serverPublicKey, + using: dependencies + ) + + expect(result?.toHexString().prefix(2)).to(equal("00")) + } + + it("includes the nonce at the end of the encrypted value") { + let maybeResult = try? MessageSender.encryptWithSessionBlindingProtocol( + "TestMessage".data(using: .utf8)!, + for: "15\(TestConstants.blindedPublicKey)", + openGroupPublicKey: TestConstants.serverPublicKey, + using: dependencies + ) + let result: [UInt8] = (maybeResult?.bytes ?? []) + let nonceBytes: [UInt8] = Array(result[max(0, (result.count - 24)).., BoxType { + func seal(message: Bytes, recipientPublicKey: Bytes) -> Bytes? { + return accept(args: [message, recipientPublicKey]) as? Bytes + } + + func open(anonymousCipherText: Bytes, recipientPublicKey: Bytes, recipientSecretKey: Bytes) -> Bytes? { + return accept(args: [anonymousCipherText, recipientPublicKey, recipientSecretKey]) as? Bytes + } +} diff --git a/SessionMessagingKitTests/_TestUtilities/MockSign.swift b/SessionMessagingKitTests/_TestUtilities/MockSign.swift index 19f3b00de..98f2887db 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockSign.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockSign.swift @@ -7,6 +7,7 @@ import Sodium @testable import SessionMessagingKit class MockSign: Mock, SignType { + var Bytes: Int = 64 var PublicKeyBytes: Int = 32 func signature(message: Bytes, secretKey: Bytes) -> Bytes? { diff --git a/SessionMessagingKitTests/_TestUtilities/MockSodium.swift b/SessionMessagingKitTests/_TestUtilities/MockSodium.swift index 502bec493..4ed5bb75f 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockSodium.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockSodium.swift @@ -7,12 +7,13 @@ import Sodium @testable import SessionMessagingKit class MockSodium: Mock, SodiumType { + func getBox() -> BoxType { return accept() as! BoxType } func getGenericHash() -> GenericHashType { return accept() as! GenericHashType } - func getAeadXChaCha20Poly1305Ietf() -> AeadXChaCha20Poly1305IetfType { return accept() as! AeadXChaCha20Poly1305IetfType } func getSign() -> SignType { return accept() as! SignType } + func getAeadXChaCha20Poly1305Ietf() -> AeadXChaCha20Poly1305IetfType { return accept() as! AeadXChaCha20Poly1305IetfType } - func generateBlindingFactor(serverPublicKey: String) -> Bytes? { - return accept(args: [serverPublicKey]) as? Bytes + func generateBlindingFactor(serverPublicKey: String, genericHash: GenericHashType) -> Bytes? { + return accept(args: [serverPublicKey, genericHash]) as? Bytes } func blindedKeyPair(serverPublicKey: String, edKeyPair: Box.KeyPair, genericHash: GenericHashType) -> Box.KeyPair? { @@ -31,7 +32,7 @@ class MockSodium: Mock, SodiumType { return accept(args: [a, otherBlindedPublicKey, kA, kB, genericHash]) as? Bytes } - func sessionId(_ sessionId: String, matchesBlindedId blindedSessionId: String, serverPublicKey: String) -> Bool { - return accept(args: [sessionId, blindedSessionId, serverPublicKey]) as! Bool + func sessionId(_ sessionId: String, matchesBlindedId blindedSessionId: String, serverPublicKey: String, genericHash: GenericHashType) -> Bool { + return accept(args: [sessionId, blindedSessionId, serverPublicKey, genericHash]) as! Bool } } diff --git a/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift b/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift index 0685ea246..fce02cfeb 100644 --- a/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift +++ b/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift @@ -12,9 +12,10 @@ extension OpenGroupManager.OGMDependencies { identityManager: IdentityManagerProtocol? = nil, storage: SessionMessagingKitStorageProtocol? = nil, sodium: SodiumType? = nil, - aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, - sign: SignType? = nil, + box: BoxType? = nil, genericHash: GenericHashType? = nil, + sign: SignType? = nil, + aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, ed25519: Ed25519Type? = nil, nonceGenerator16: NonceGenerator16ByteType? = nil, nonceGenerator24: NonceGenerator24ByteType? = nil, @@ -27,9 +28,10 @@ extension OpenGroupManager.OGMDependencies { identityManager: (identityManager ?? self._identityManager), storage: (storage ?? self._storage), sodium: (sodium ?? self._sodium), - aeadXChaCha20Poly1305Ietf: (aeadXChaCha20Poly1305Ietf ?? self._aeadXChaCha20Poly1305Ietf), - sign: (sign ?? self._sign), + box: (box ?? self._box), genericHash: (genericHash ?? self._genericHash), + sign: (sign ?? self._sign), + aeadXChaCha20Poly1305Ietf: (aeadXChaCha20Poly1305Ietf ?? self._aeadXChaCha20Poly1305Ietf), ed25519: (ed25519 ?? self._ed25519), nonceGenerator16: (nonceGenerator16 ?? self._nonceGenerator16), nonceGenerator24: (nonceGenerator24 ?? self._nonceGenerator24), diff --git a/SessionUtilitiesKit/General/String+Encoding.swift b/SessionUtilitiesKit/General/String+Encoding.swift index bb208adec..a794b2146 100644 --- a/SessionUtilitiesKit/General/String+Encoding.swift +++ b/SessionUtilitiesKit/General/String+Encoding.swift @@ -11,6 +11,7 @@ extension String { .map { index -> String in String(chars[index]) + String(chars[index + 1]) } .compactMap { (str: String) -> UInt8? in UInt8(str, radix: 16) } + guard bytes.count > 0 else { return nil } guard (self.count / bytes.count) == 2 else { return nil } return Data(bytes) diff --git a/SessionUtilitiesKitTests/General/SessionIdSpec.swift b/SessionUtilitiesKitTests/General/SessionIdSpec.swift index 99124712a..c3f22512a 100644 --- a/SessionUtilitiesKitTests/General/SessionIdSpec.swift +++ b/SessionUtilitiesKitTests/General/SessionIdSpec.swift @@ -42,11 +42,11 @@ class SessionIdSpec: QuickSpec { it("generates the correct hex string") { expect(SessionId(.unblinded, publicKey: Data(hex: TestConstants.publicKey).bytes).hexString) - .to(equal("007aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")) + .to(equal("0088672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) expect(SessionId(.standard, publicKey: Data(hex: TestConstants.publicKey).bytes).hexString) - .to(equal("057aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")) + .to(equal("0588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) expect(SessionId(.blinded, publicKey: Data(hex: TestConstants.publicKey).bytes).hexString) - .to(equal("157aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d")) + .to(equal("1588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) } } diff --git a/SharedTest/TestConstants.swift b/SharedTest/TestConstants.swift index 3e2bb9ef0..1b9fe29f5 100644 --- a/SharedTest/TestConstants.swift +++ b/SharedTest/TestConstants.swift @@ -3,8 +3,12 @@ import Foundation enum TestConstants { - // Test Private key, not actually used (from here https://www.notion.so/oxen/SOGS-Authentication-dc64cc846cb24b2abbf7dd4bfd74abbb) - static let publicKey: String = "7aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d" - static let privateKey: String = "881132ee03dbd2da065aa4c94f96081f62142dc8011d1b7a00de83e4aab38ce4" - static let edSecretKey: String = "881132ee03dbd2da065aa4c94f96081f62142dc8011d1b7a00de83e4aab38ce4881132ee03dbd2da065aa4c94f96081f62142dc8011d1b7a00de83e4aab38ce4" + // Test keys (from here https://github.com/jagerman/session-pysogs/blob/docs/contrib/auth-example.py) + static let publicKey: String = "88672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b" + static let privateKey: String = "30d796c1ddb4dc455fd998a98aa275c247494a9a7bde9c1fee86ae45cd585241" + static let edKeySeed: String = "c010d89eccbaf5d1c6d19df766c6eedf965d4a28a56f87c9fc819edb59896dd9" + static let edPublicKey: String = "bac6e71efd7dfa4a83c98ed24f254ab2c267f9ccdb172a5280a0444ad24e89cc" + static let edSecretKey: String = "c010d89eccbaf5d1c6d19df766c6eedf965d4a28a56f87c9fc819edb59896dd9bac6e71efd7dfa4a83c98ed24f254ab2c267f9ccdb172a5280a0444ad24e89cc" + static let blindedPublicKey: String = "98932d4bccbe595a8789d7eb1629cefc483a0eaddc7e20e8fe5c771efafd9af5" + static let serverPublicKey: String = "c3b3c6f32f0ab5a57f853cc4f30f5da7fda5624b0c77b3fb0829de562ada081d" } From 5710fe18fb5a15ed8d719145dd8351260c7a50c6 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 21 Mar 2022 11:08:21 +1100 Subject: [PATCH 044/157] Reverted a change to the type for the serverId of a TSAttachment (change didn't match the protobuf) --- SessionMessagingKit/Jobs/AttachmentUploadJob.swift | 9 +++++++-- .../Sending & Receiving/Attachments/TSAttachment.h | 2 +- .../Sending & Receiving/Attachments/TSAttachment.m | 2 +- .../Attachments/TSAttachmentPointer.m | 2 +- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift index 21f33d160..7688a65c6 100644 --- a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift @@ -66,7 +66,7 @@ public final class AttachmentUploadJob : NSObject, Job, NSCoding { // NSObject/N guard let stream = TSAttachment.fetch(uniqueId: attachmentID) as? TSAttachmentStream else { return handleFailure(error: Error.noAttachment) } - guard !stream.isUploaded else { return handleSuccess(stream.serverId) } // Should never occur + guard !stream.isUploaded else { return handleSuccess("\(stream.serverId)") } // Should never occur let storage = SNMessagingKitConfiguration.shared.storage if let openGroup = storage.getOpenGroup(for: threadID) { @@ -124,8 +124,13 @@ public final class AttachmentUploadJob : NSObject, Job, NSCoding { // NSObject/N stream.isUploaded = false stream.save() upload(data).done(on: DispatchQueue.global(qos: .userInitiated)) { fileId in + guard let intFileId: UInt64 = UInt64(fileId) else { + onFailure?(HTTP.Error.parsingFailed) + return + } + let downloadURL = "\(FileServerAPI.server)/files/\(fileId)" - stream.serverId = fileId + stream.serverId = intFileId stream.isUploaded = true stream.downloadURL = downloadURL stream.save() diff --git a/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachment.h b/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachment.h index b6334028b..d8f0d8936 100644 --- a/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachment.h +++ b/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachment.h @@ -23,7 +23,7 @@ typedef NS_ENUM(NSUInteger, TSAttachmentType) { // The attachmentSchemaVersion and serverId properties only apply to // TSAttachmentPointer, which can be distinguished by the isDownloaded // property. -@property (atomic, readwrite) NSString *serverId; +@property (atomic, readwrite) UInt64 serverId; @property (atomic, readwrite, nullable) NSData *encryptionKey; @property (nonatomic, readonly) NSString *contentType; @property (atomic, readwrite) BOOL isDownloaded; diff --git a/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachment.m b/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachment.m index 6fd590954..3d92cdca8 100644 --- a/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachment.m +++ b/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachment.m @@ -27,7 +27,7 @@ NSUInteger const TSAttachmentSchemaVersion = 4; // This constructor is used for new instances of TSAttachmentPointer, // i.e. undownloaded incoming attachments. -- (instancetype)initWithServerId:(NSString *)serverId +- (instancetype)initWithServerId:(UInt64)serverId encryptionKey:(nullable NSData *)encryptionKey byteCount:(UInt32)byteCount contentType:(NSString *)contentType diff --git a/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentPointer.m b/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentPointer.m index c5aa6fcf2..e27bccab2 100644 --- a/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentPointer.m +++ b/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentPointer.m @@ -170,7 +170,7 @@ NS_ASSUME_NONNULL_BEGIN // uniqueId. if (attachmentSchemaVersion < 2 && self.serverId == 0) { // For legacy instances, try to parse the serverId from the uniqueId. - self.serverId = self.uniqueId; + self.serverId = (UInt64)[self.uniqueId integerValue]; } } From 8b6b4be8e3627e75fc1314c2329200a449ce81a1 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 21 Mar 2022 12:01:23 +1100 Subject: [PATCH 045/157] Added back a missing type check --- .../Sending & Receiving/Attachments/TSAttachmentPointer.m | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentPointer.m b/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentPointer.m index e27bccab2..6a675fa64 100644 --- a/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentPointer.m +++ b/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentPointer.m @@ -169,8 +169,10 @@ NS_ASSUME_NONNULL_BEGIN // Legacy instances of TSAttachmentPointer apparently used the serverId as their // uniqueId. if (attachmentSchemaVersion < 2 && self.serverId == 0) { - // For legacy instances, try to parse the serverId from the uniqueId. - self.serverId = (UInt64)[self.uniqueId integerValue]; + if ([self isDecimalNumberText:self.uniqueId]) { + // For legacy instances, try to parse the serverId from the uniqueId. + self.serverId = (UInt64)[self.uniqueId integerValue]; + } } } From dad264c2390c8f4061c3517ffef79eedb2c4f6ae Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 21 Mar 2022 14:29:46 +1100 Subject: [PATCH 046/157] Included a fix for duplicate open groups and added unit tests for it --- .../Open Groups/OpenGroupAPI.swift | 1 + .../Open Groups/OpenGroupManager.swift | 47 +++- .../Pollers/OpenGroupPoller.swift | 1 + .../Open Groups/OpenGroupManagerSpec.swift | 229 ++++++++++++++++++ 4 files changed, 275 insertions(+), 3 deletions(-) diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index b309fbe65..14d30fa37 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -6,6 +6,7 @@ import Curve25519Kit public enum OpenGroupAPI { // MARK: - Settings + public static let legacyDefaultServerDNS = "open.getsession.org" public static let defaultServer = "http://116.203.70.33" public static let defaultServerPublicKey = "a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238" diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 3a7035910..5079d01d1 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -94,11 +94,52 @@ public final class OpenGroupManager: NSObject { // MARK: - Adding & Removing + public func hasExistingOpenGroup(roomToken: String, server: String, publicKey: String, using transaction: YapDatabaseReadWriteTransaction, dependencies: OGMDependencies = OGMDependencies()) -> Bool { + guard let serverUrl: URL = URL(string: server) else { return false } + + let schemeFreeServer: String = (serverUrl.host ?? server) + let schemeFreeDefaultServer: String = OpenGroupAPI.defaultServer.substring(from: "http://".count) + var serverOptions: Set = Set([ + schemeFreeServer, + "http://\(schemeFreeServer)", + "https://\(schemeFreeServer)" + ]) + + if schemeFreeServer == OpenGroupAPI.legacyDefaultServerDNS { + let defaultServerOptions: Set = Set([ + schemeFreeDefaultServer, + OpenGroupAPI.defaultServer, + "https://\(schemeFreeDefaultServer)" + ]) + serverOptions = serverOptions.union(defaultServerOptions) + } + else if schemeFreeServer == schemeFreeDefaultServer { + let legacyServerOptions: Set = Set([ + OpenGroupAPI.legacyDefaultServerDNS, + "http://\(OpenGroupAPI.legacyDefaultServerDNS)", + "https://\(OpenGroupAPI.legacyDefaultServerDNS)" + ]) + serverOptions = serverOptions.union(legacyServerOptions) + } + + // First check if there is no poller for the specified server + if serverOptions.first(where: { dependencies.cache.pollers[$0] != nil }) == nil { + return false + } + + // Then check if there is an existing open group thread + let hasExistingThread: Bool = serverOptions.contains(where: { serverName in + let groupId: Data = LKGroupUtilities.getEncodedOpenGroupIDAsData("\(serverName).\(roomToken)") + + return (TSGroupThread.fetch(groupId: groupId, transaction: transaction) != nil) + }) + + return hasExistingThread + } + public func add(roomToken: String, server: String, publicKey: String, isConfigMessage: Bool, using transaction: YapDatabaseReadWriteTransaction, dependencies: OGMDependencies = OGMDependencies()) -> Promise { // If we are currently polling for this server and already have a TSGroupThread for this room the do nothing - let groupId: Data = LKGroupUtilities.getEncodedOpenGroupIDAsData("\(server).\(roomToken)") - - if dependencies.cache.pollers[server] != nil && TSGroupThread.fetch(groupId: groupId, transaction: transaction) != nil { + if hasExistingOpenGroup(roomToken: roomToken, server: server, publicKey: publicKey, using: transaction, dependencies: dependencies) { SNLog("Ignoring join open group attempt (already joined), user initiated: \(!isConfigMessage)") return Promise.value(()) } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift index 5202863be..0fa3130a2 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift @@ -70,6 +70,7 @@ extension OpenGroupAPI { cache.timeSinceLastPoll[server] = Date().timeIntervalSince1970 UserDefaults.standard[.lastOpen] = Date() } + SNLog("Open group polling finished for \(server).") seal.fulfill(()) } .catch(on: OpenGroupAPI.workQueue) { [weak self] error in diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift index dfdc6b0ef..d01388efa 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -477,6 +477,235 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: - Adding & Removing + // MARK: - --hasExistingOpenGroup + + context("when checking it has an existing open group") { + context("when there is a thread for the room and the cache has a poller") { + beforeEach { + testTransaction.mockData[.objectForKey] = testGroupThread + } + + context("for the no-scheme variant") { + beforeEach { + mockOGMCache.when { $0.pollers }.thenReturn(["testServer": OpenGroupAPI.Poller(for: "testServer")]) + } + + it("returns true when no scheme is provided") { + expect( + openGroupManager + .hasExistingOpenGroup( + roomToken: "testRoom", + server: "testServer", + publicKey: "testKey", + using: testTransaction, + dependencies: dependencies + ) + ).to(beTrue()) + } + + it("returns true when a http scheme is provided") { + expect( + openGroupManager + .hasExistingOpenGroup( + roomToken: "testRoom", + server: "http://testServer", + publicKey: "testKey", + using: testTransaction, + dependencies: dependencies + ) + ).to(beTrue()) + } + + it("returns true when a https scheme is provided") { + expect( + openGroupManager + .hasExistingOpenGroup( + roomToken: "testRoom", + server: "https://testServer", + publicKey: "testKey", + using: testTransaction, + dependencies: dependencies + ) + ).to(beTrue()) + } + } + + context("for the http variant") { + beforeEach { + mockOGMCache.when { $0.pollers }.thenReturn(["http://testServer": OpenGroupAPI.Poller(for: "http://testServer")]) + } + + it("returns true when no scheme is provided") { + expect( + openGroupManager + .hasExistingOpenGroup( + roomToken: "testRoom", + server: "testServer", + publicKey: "testKey", + using: testTransaction, + dependencies: dependencies + ) + ).to(beTrue()) + } + + it("returns true when a http scheme is provided") { + expect( + openGroupManager + .hasExistingOpenGroup( + roomToken: "testRoom", + server: "http://testServer", + publicKey: "testKey", + using: testTransaction, + dependencies: dependencies + ) + ).to(beTrue()) + } + + it("returns true when a https scheme is provided") { + expect( + openGroupManager + .hasExistingOpenGroup( + roomToken: "testRoom", + server: "https://testServer", + publicKey: "testKey", + using: testTransaction, + dependencies: dependencies + ) + ).to(beTrue()) + } + } + + context("for the https variant") { + beforeEach { + mockOGMCache.when { $0.pollers }.thenReturn(["https://testServer": OpenGroupAPI.Poller(for: "https://testServer")]) + } + + it("returns true when no scheme is provided") { + expect( + openGroupManager + .hasExistingOpenGroup( + roomToken: "testRoom", + server: "testServer", + publicKey: "testKey", + using: testTransaction, + dependencies: dependencies + ) + ).to(beTrue()) + } + + it("returns true when a http scheme is provided") { + expect( + openGroupManager + .hasExistingOpenGroup( + roomToken: "testRoom", + server: "http://testServer", + publicKey: "testKey", + using: testTransaction, + dependencies: dependencies + ) + ).to(beTrue()) + } + + it("returns true when a https scheme is provided") { + expect( + openGroupManager + .hasExistingOpenGroup( + roomToken: "testRoom", + server: "https://testServer", + publicKey: "testKey", + using: testTransaction, + dependencies: dependencies + ) + ).to(beTrue()) + } + } + } + + context("when given the legacy DNS host and there is a cached poller for the default server") { + it("returns true") { + mockOGMCache.when { $0.pollers }.thenReturn(["http://116.203.70.33": OpenGroupAPI.Poller(for: "http://116.203.70.33")]) + testTransaction.mockData[.objectForKey] = testGroupThread + + expect( + openGroupManager + .hasExistingOpenGroup( + roomToken: "testRoom", + server: "http://open.getsession.org", + publicKey: "testKey", + using: testTransaction, + dependencies: dependencies + ) + ).to(beTrue()) + } + } + + context("when given the default server and there is a cached poller for the legacy DNS host") { + it("returns true") { + mockOGMCache.when { $0.pollers }.thenReturn(["http://open.getsession.org": OpenGroupAPI.Poller(for: "http://open.getsession.org")]) + testTransaction.mockData[.objectForKey] = testGroupThread + + expect( + openGroupManager + .hasExistingOpenGroup( + roomToken: "testRoom", + server: "http://116.203.70.33", + publicKey: "testKey", + using: testTransaction, + dependencies: dependencies + ) + ).to(beTrue()) + } + } + + it("returns false when given an invalid server") { + mockOGMCache.when { $0.pollers }.thenReturn(["testServer": OpenGroupAPI.Poller(for: "testServer")]) + testTransaction.mockData[.objectForKey] = testGroupThread + + expect( + openGroupManager + .hasExistingOpenGroup( + roomToken: "testRoom", + server: "%%%", + publicKey: "testKey", + using: testTransaction, + dependencies: dependencies + ) + ).to(beFalse()) + } + + it("returns false if there is not a poller for the server in the cache") { + mockOGMCache.when { $0.pollers }.thenReturn([:]) + testTransaction.mockData[.objectForKey] = testGroupThread + + expect( + openGroupManager + .hasExistingOpenGroup( + roomToken: "testRoom", + server: "testServer", + publicKey: "testKey", + using: testTransaction, + dependencies: dependencies + ) + ).to(beFalse()) + } + + it("returns false if there is a poller for the server in the cache but no thread for the room") { + mockOGMCache.when { $0.pollers }.thenReturn(["testServer": OpenGroupAPI.Poller(for: "testServer")]) + testTransaction.mockData[.objectForKey] = nil + + expect( + openGroupManager + .hasExistingOpenGroup( + roomToken: "testRoom", + server: "testServer", + publicKey: "testKey", + using: testTransaction, + dependencies: dependencies + ) + ).to(beFalse()) + } + } + // MARK: - --add context("when adding") { From ea7603bf3b7c340777ca47ba9086a9b0bf61618b Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 21 Mar 2022 14:45:33 +1100 Subject: [PATCH 047/157] Renamed some variables to make it a bit clearer and made sure it supported a port in the url --- .../Open Groups/OpenGroupManager.swift | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 5079d01d1..ff1c4cf70 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -97,23 +97,25 @@ public final class OpenGroupManager: NSObject { public func hasExistingOpenGroup(roomToken: String, server: String, publicKey: String, using transaction: YapDatabaseReadWriteTransaction, dependencies: OGMDependencies = OGMDependencies()) -> Bool { guard let serverUrl: URL = URL(string: server) else { return false } - let schemeFreeServer: String = (serverUrl.host ?? server) - let schemeFreeDefaultServer: String = OpenGroupAPI.defaultServer.substring(from: "http://".count) + let serverHost: String = (serverUrl.host ?? server) + let serverPort: String = (serverUrl.port.map { ":\($0)" } ?? "") + let defaultServerHost: String = OpenGroupAPI.defaultServer.substring(from: "http://".count) var serverOptions: Set = Set([ - schemeFreeServer, - "http://\(schemeFreeServer)", - "https://\(schemeFreeServer)" + server, + "\(serverHost)\(serverPort)", + "http://\(serverHost)\(serverPort)", + "https://\(serverHost)\(serverPort)" ]) - if schemeFreeServer == OpenGroupAPI.legacyDefaultServerDNS { + if serverHost == OpenGroupAPI.legacyDefaultServerDNS { let defaultServerOptions: Set = Set([ - schemeFreeDefaultServer, + defaultServerHost, OpenGroupAPI.defaultServer, - "https://\(schemeFreeDefaultServer)" + "https://\(defaultServerHost)" ]) serverOptions = serverOptions.union(defaultServerOptions) } - else if schemeFreeServer == schemeFreeDefaultServer { + else if serverHost == defaultServerHost { let legacyServerOptions: Set = Set([ OpenGroupAPI.legacyDefaultServerDNS, "http://\(OpenGroupAPI.legacyDefaultServerDNS)", From b1cfa4f50a371448eadb250406bc87db3d0bbace Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 23 Mar 2022 14:55:02 +1100 Subject: [PATCH 048/157] Updated the Podfile to include the needed libraries --- Podfile | 16 ++++++++++++++++ Podfile.lock | 17 ++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/Podfile b/Podfile index e2429bb66..390555a1a 100644 --- a/Podfile +++ b/Podfile @@ -9,6 +9,10 @@ abstract_target 'GlobalDependencies' do pod 'PromiseKit' pod 'CryptoSwift' pod 'Sodium', '~> 0.9.1' + pod 'GRDB.swift/SQLCipher' + pod 'SQLCipher', '~> 4.0' + + # FIXME: We want to remove this once it's been long enough since the migration to GRDB pod 'YapDatabase/SQLCipher', :git => 'https://github.com/oxen-io/session-ios-yap-database.git', branch: 'signal-release' target 'Session' do @@ -19,6 +23,7 @@ abstract_target 'GlobalDependencies' do pod 'YYImage', git: 'https://github.com/signalapp/YYImage' pod 'Mantle', git: 'https://github.com/signalapp/Mantle', branch: 'signal-master' pod 'ZXingObjC' + pod 'DifferenceKit' end # Dependencies to be included only in all extensions/frameworks @@ -67,6 +72,7 @@ target 'SessionUIKit' post_install do |installer| enable_whole_module_optimization_for_crypto_swift(installer) set_minimum_deployment_target(installer) + enable_fts5_support(installer) end def enable_whole_module_optimization_for_crypto_swift(installer) @@ -87,3 +93,13 @@ def set_minimum_deployment_target(installer) end end end + +# This is to ensure we enable support for FastTextSearch5 (might not be enabled by default) +# For more info see https://github.com/groue/GRDB.swift/blob/master/Documentation/FullTextSearch.md#enabling-fts5-support +def enable_fts5_support(installer) + installer.pods_project.targets.select { |target| target.name == "GRDB.swift" }.each do |target| + target.build_configurations.each do |config| + config.build_settings['OTHER_SWIFT_FLAGS'] = "$(inherited) -D SQLITE_ENABLE_FTS5" + end + end +end diff --git a/Podfile.lock b/Podfile.lock index fb59d482d..4d6331069 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -21,6 +21,14 @@ PODS: - Curve25519Kit (2.1.0): - CocoaLumberjack - SignalCoreKit + - DifferenceKit (1.2.0): + - DifferenceKit/Core (= 1.2.0) + - DifferenceKit/UIKitExtension (= 1.2.0) + - DifferenceKit/Core (1.2.0) + - DifferenceKit/UIKitExtension (1.2.0): + - DifferenceKit/Core + - GRDB.swift/SQLCipher (5.19.0): + - SQLCipher (>= 3.4.0) - Mantle (2.1.0): - Mantle/extobjc (= 2.1.0) - Mantle/extobjc (2.1.0) @@ -123,6 +131,8 @@ DEPENDENCIES: - AFNetworking - CryptoSwift - Curve25519Kit (from `https://github.com/signalapp/Curve25519Kit.git`) + - DifferenceKit + - GRDB.swift/SQLCipher - Mantle (from `https://github.com/signalapp/Mantle`, branch `signal-master`) - NVActivityIndicatorView - PromiseKit @@ -131,6 +141,7 @@ DEPENDENCIES: - SAMKeychain - SignalCoreKit (from `https://github.com/oxen-io/session-ios-core-kit`, branch `session-version`) - Sodium (~> 0.9.1) + - SQLCipher (~> 4.0) - SwiftProtobuf (~> 1.5.0) - YapDatabase/SQLCipher (from `https://github.com/oxen-io/session-ios-yap-database.git`, branch `signal-release`) - YYImage (from `https://github.com/signalapp/YYImage`) @@ -141,6 +152,8 @@ SPEC REPOS: - AFNetworking - CocoaLumberjack - CryptoSwift + - DifferenceKit + - GRDB.swift - NVActivityIndicatorView - OpenSSL-Universal - PromiseKit @@ -189,6 +202,8 @@ SPEC CHECKSUMS: CocoaLumberjack: 543c79c114dadc3b1aba95641d8738b06b05b646 CryptoSwift: a532e74ed010f8c95f611d00b8bbae42e9fe7c17 Curve25519Kit: e63f9859ede02438ae3defc5e1a87e09d1ec7ee6 + DifferenceKit: 5659c430bb7fe45876fa32ce5cba5d6167f0c805 + GRDB.swift: c00ff42d3cffbe90145fb4e364e26a099f997142 Mantle: 2fa750afa478cd625a94230fbf1c13462f29395b NVActivityIndicatorView: 1f6c5687f1171810aa27a3296814dc2d7dec3667 OpenSSL-Universal: e7311447fd2419f57420c79524b641537387eff2 @@ -204,6 +219,6 @@ SPEC CHECKSUMS: YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331 ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: 19ce2820c263e8f3c114817f7ca2da73a9382b6a +PODFILE CHECKSUM: 33a5ecfe231383831bf212de4ff6c99c047c344a COCOAPODS: 1.11.2 From 1a6c34e3b8d63c477e638064699e970f07308a22 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 24 Mar 2022 14:35:23 +1100 Subject: [PATCH 049/157] Removed the unused legacy OWSBackup code --- Session.xcodeproj/project.pbxproj | 56 - .../Backups/BackupRestoreViewController.swift | 175 --- Session/Backups/OWSBackup.h | 105 -- Session/Backups/OWSBackup.m | 912 ------------- Session/Backups/OWSBackupAPI.swift | 740 ----------- Session/Backups/OWSBackupExportJob.h | 15 - Session/Backups/OWSBackupExportJob.m | 1181 ----------------- Session/Backups/OWSBackupIO.h | 61 - Session/Backups/OWSBackupIO.m | 273 ---- Session/Backups/OWSBackupImportJob.h | 15 - Session/Backups/OWSBackupImportJob.m | 635 --------- Session/Backups/OWSBackupJob.h | 92 -- Session/Backups/OWSBackupJob.m | 316 ----- Session/Backups/OWSBackupLazyRestore.swift | 176 --- .../Backups/OWSBackupSettingsViewController.h | 13 - .../Backups/OWSBackupSettingsViewController.m | 214 --- Session/Meta/AppDelegate.m | 12 +- Session/Meta/AppEnvironment.swift | 8 - Session/Meta/Signal-Bridging-Header.h | 2 - SessionMessagingKit/To Do/TSAccountManager.h | 3 - SessionMessagingKit/To Do/TSAccountManager.m | 16 - 21 files changed, 1 insertion(+), 5019 deletions(-) delete mode 100644 Session/Backups/BackupRestoreViewController.swift delete mode 100644 Session/Backups/OWSBackup.h delete mode 100644 Session/Backups/OWSBackup.m delete mode 100644 Session/Backups/OWSBackupAPI.swift delete mode 100644 Session/Backups/OWSBackupExportJob.h delete mode 100644 Session/Backups/OWSBackupExportJob.m delete mode 100644 Session/Backups/OWSBackupIO.h delete mode 100644 Session/Backups/OWSBackupIO.m delete mode 100644 Session/Backups/OWSBackupImportJob.h delete mode 100644 Session/Backups/OWSBackupImportJob.m delete mode 100644 Session/Backups/OWSBackupJob.h delete mode 100644 Session/Backups/OWSBackupJob.m delete mode 100644 Session/Backups/OWSBackupLazyRestore.swift delete mode 100644 Session/Backups/OWSBackupSettingsViewController.h delete mode 100644 Session/Backups/OWSBackupSettingsViewController.m diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index f4998ffdb..45c178a6f 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -14,7 +14,6 @@ 340FC8AA204DAC8D007AEB0F /* NotificationSettingsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC87C204DAC8C007AEB0F /* NotificationSettingsViewController.m */; }; 340FC8AC204DAC8D007AEB0F /* PrivacySettingsTableViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC87E204DAC8C007AEB0F /* PrivacySettingsTableViewController.m */; }; 340FC8AE204DAC8D007AEB0F /* OWSSoundSettingsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC883204DAC8C007AEB0F /* OWSSoundSettingsViewController.m */; }; - 340FC8B4204DAC8D007AEB0F /* OWSBackupSettingsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC88E204DAC8C007AEB0F /* OWSBackupSettingsViewController.m */; }; 340FC8B6204DAC8D007AEB0F /* OWSQRCodeScanningViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC896204DAC8C007AEB0F /* OWSQRCodeScanningViewController.m */; }; 340FC8B7204DAC8D007AEB0F /* OWSConversationSettingsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC89A204DAC8D007AEB0F /* OWSConversationSettingsViewController.m */; }; 341341EF2187467A00192D59 /* ConversationViewModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 341341EE2187467900192D59 /* ConversationViewModel.m */; }; @@ -22,7 +21,6 @@ 3430FE181F7751D4000EC51B /* GiphyAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3430FE171F7751D4000EC51B /* GiphyAPI.swift */; }; 34330AA31E79686200DF2FB9 /* OWSProgressView.m in Sources */ = {isa = PBXBuildFile; fileRef = 34330AA21E79686200DF2FB9 /* OWSProgressView.m */; }; 34386A54207D271D009F5D9C /* NeverClearView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34386A53207D271C009F5D9C /* NeverClearView.swift */; }; - 3441FD9F21A3604F00BB9542 /* BackupRestoreViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3441FD9E21A3604F00BB9542 /* BackupRestoreViewController.swift */; }; 344825C6211390C800DB4BD8 /* OWSOrphanDataCleaner.m in Sources */ = {isa = PBXBuildFile; fileRef = 344825C5211390C800DB4BD8 /* OWSOrphanDataCleaner.m */; }; 346129991FD1E4DA00532771 /* SignalApp.m in Sources */ = {isa = PBXBuildFile; fileRef = 346129971FD1E4D900532771 /* SignalApp.m */; }; 34661FB820C1C0D60056EDD6 /* message_sent.aiff in Resources */ = {isa = PBXBuildFile; fileRef = 34661FB720C1C0D60056EDD6 /* message_sent.aiff */; }; @@ -35,13 +33,6 @@ 3496955D219B605E00DCFE74 /* PhotoCollectionPickerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3496955A219B605E00DCFE74 /* PhotoCollectionPickerController.swift */; }; 3496955E219B605E00DCFE74 /* PhotoLibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3496955B219B605E00DCFE74 /* PhotoLibrary.swift */; }; 3496956021A2FC8100DCFE74 /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3496955F21A2FC8100DCFE74 /* CloudKit.framework */; }; - 3496956E21A301A100DCFE74 /* OWSBackupExportJob.m in Sources */ = {isa = PBXBuildFile; fileRef = 3496956221A301A100DCFE74 /* OWSBackupExportJob.m */; }; - 3496956F21A301A100DCFE74 /* OWSBackupLazyRestore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3496956321A301A100DCFE74 /* OWSBackupLazyRestore.swift */; }; - 3496957021A301A100DCFE74 /* OWSBackupIO.m in Sources */ = {isa = PBXBuildFile; fileRef = 3496956521A301A100DCFE74 /* OWSBackupIO.m */; }; - 3496957121A301A100DCFE74 /* OWSBackupImportJob.m in Sources */ = {isa = PBXBuildFile; fileRef = 3496956621A301A100DCFE74 /* OWSBackupImportJob.m */; }; - 3496957221A301A100DCFE74 /* OWSBackup.m in Sources */ = {isa = PBXBuildFile; fileRef = 3496956921A301A100DCFE74 /* OWSBackup.m */; }; - 3496957321A301A100DCFE74 /* OWSBackupJob.m in Sources */ = {isa = PBXBuildFile; fileRef = 3496956A21A301A100DCFE74 /* OWSBackupJob.m */; }; - 3496957421A301A100DCFE74 /* OWSBackupAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3496956B21A301A100DCFE74 /* OWSBackupAPI.swift */; }; 34A6C28021E503E700B5B12E /* OWSImagePickerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A6C27F21E503E600B5B12E /* OWSImagePickerController.swift */; }; 34A8B3512190A40E00218A25 /* MediaAlbumView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A8B3502190A40E00218A25 /* MediaAlbumView.swift */; }; 34ABC0E421DD20C500ED9469 /* ConversationMessageMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34ABC0E321DD20C500ED9469 /* ConversationMessageMapping.swift */; }; @@ -956,12 +947,10 @@ 340FC87B204DAC8C007AEB0F /* NotificationSettingsOptionsViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = NotificationSettingsOptionsViewController.m; sourceTree = ""; }; 340FC87C204DAC8C007AEB0F /* NotificationSettingsViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = NotificationSettingsViewController.m; sourceTree = ""; }; 340FC87E204DAC8C007AEB0F /* PrivacySettingsTableViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PrivacySettingsTableViewController.m; sourceTree = ""; }; - 340FC87F204DAC8C007AEB0F /* OWSBackupSettingsViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSBackupSettingsViewController.h; sourceTree = ""; }; 340FC883204DAC8C007AEB0F /* OWSSoundSettingsViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSSoundSettingsViewController.m; sourceTree = ""; }; 340FC888204DAC8C007AEB0F /* OWSQRCodeScanningViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSQRCodeScanningViewController.h; sourceTree = ""; }; 340FC88A204DAC8C007AEB0F /* NotificationSettingsViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = NotificationSettingsViewController.h; sourceTree = ""; }; 340FC88B204DAC8C007AEB0F /* NotificationSettingsOptionsViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = NotificationSettingsOptionsViewController.h; sourceTree = ""; }; - 340FC88E204DAC8C007AEB0F /* OWSBackupSettingsViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSBackupSettingsViewController.m; sourceTree = ""; }; 340FC88F204DAC8C007AEB0F /* PrivacySettingsTableViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = PrivacySettingsTableViewController.h; sourceTree = ""; }; 340FC894204DAC8C007AEB0F /* OWSSoundSettingsViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSSoundSettingsViewController.h; sourceTree = ""; }; 340FC896204DAC8C007AEB0F /* OWSQRCodeScanningViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSQRCodeScanningViewController.m; sourceTree = ""; }; @@ -976,7 +965,6 @@ 34330AA11E79686200DF2FB9 /* OWSProgressView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSProgressView.h; sourceTree = ""; }; 34330AA21E79686200DF2FB9 /* OWSProgressView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSProgressView.m; sourceTree = ""; }; 34386A53207D271C009F5D9C /* NeverClearView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NeverClearView.swift; sourceTree = ""; }; - 3441FD9E21A3604F00BB9542 /* BackupRestoreViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackupRestoreViewController.swift; sourceTree = ""; }; 34480B371FD092A900BC14EF /* SignalShareExtension-Bridging-Header.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "SignalShareExtension-Bridging-Header.h"; sourceTree = ""; }; 34480B381FD092E300BC14EF /* SessionShareExtension-Prefix.pch */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "SessionShareExtension-Prefix.pch"; sourceTree = ""; }; 344825C4211390C700DB4BD8 /* OWSOrphanDataCleaner.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSOrphanDataCleaner.h; sourceTree = ""; }; @@ -991,18 +979,6 @@ 3496955A219B605E00DCFE74 /* PhotoCollectionPickerController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoCollectionPickerController.swift; sourceTree = ""; }; 3496955B219B605E00DCFE74 /* PhotoLibrary.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoLibrary.swift; sourceTree = ""; }; 3496955F21A2FC8100DCFE74 /* CloudKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CloudKit.framework; path = System/Library/Frameworks/CloudKit.framework; sourceTree = SDKROOT; }; - 3496956221A301A100DCFE74 /* OWSBackupExportJob.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSBackupExportJob.m; sourceTree = ""; }; - 3496956321A301A100DCFE74 /* OWSBackupLazyRestore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSBackupLazyRestore.swift; sourceTree = ""; }; - 3496956421A301A100DCFE74 /* OWSBackup.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSBackup.h; sourceTree = ""; }; - 3496956521A301A100DCFE74 /* OWSBackupIO.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSBackupIO.m; sourceTree = ""; }; - 3496956621A301A100DCFE74 /* OWSBackupImportJob.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSBackupImportJob.m; sourceTree = ""; }; - 3496956721A301A100DCFE74 /* OWSBackupJob.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSBackupJob.h; sourceTree = ""; }; - 3496956821A301A100DCFE74 /* OWSBackupExportJob.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSBackupExportJob.h; sourceTree = ""; }; - 3496956921A301A100DCFE74 /* OWSBackup.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSBackup.m; sourceTree = ""; }; - 3496956A21A301A100DCFE74 /* OWSBackupJob.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSBackupJob.m; sourceTree = ""; }; - 3496956B21A301A100DCFE74 /* OWSBackupAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSBackupAPI.swift; sourceTree = ""; }; - 3496956C21A301A100DCFE74 /* OWSBackupImportJob.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSBackupImportJob.h; sourceTree = ""; }; - 3496956D21A301A100DCFE74 /* OWSBackupIO.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSBackupIO.h; sourceTree = ""; }; 34A6C27F21E503E600B5B12E /* OWSImagePickerController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSImagePickerController.swift; sourceTree = ""; }; 34A8B3502190A40E00218A25 /* MediaAlbumView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaAlbumView.swift; sourceTree = ""; }; 34ABC0E321DD20C500ED9469 /* ConversationMessageMapping.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConversationMessageMapping.swift; sourceTree = ""; }; @@ -2962,28 +2938,6 @@ path = Notifications; sourceTree = ""; }; - C36096BC25AD1C3E008B62B2 /* Backups */ = { - isa = PBXGroup; - children = ( - 340FC87F204DAC8C007AEB0F /* OWSBackupSettingsViewController.h */, - 340FC88E204DAC8C007AEB0F /* OWSBackupSettingsViewController.m */, - 3441FD9E21A3604F00BB9542 /* BackupRestoreViewController.swift */, - 3496956421A301A100DCFE74 /* OWSBackup.h */, - 3496956921A301A100DCFE74 /* OWSBackup.m */, - 3496956B21A301A100DCFE74 /* OWSBackupAPI.swift */, - 3496956821A301A100DCFE74 /* OWSBackupExportJob.h */, - 3496956221A301A100DCFE74 /* OWSBackupExportJob.m */, - 3496956C21A301A100DCFE74 /* OWSBackupImportJob.h */, - 3496956621A301A100DCFE74 /* OWSBackupImportJob.m */, - 3496956D21A301A100DCFE74 /* OWSBackupIO.h */, - 3496956521A301A100DCFE74 /* OWSBackupIO.m */, - 3496956721A301A100DCFE74 /* OWSBackupJob.h */, - 3496956A21A301A100DCFE74 /* OWSBackupJob.m */, - 3496956321A301A100DCFE74 /* OWSBackupLazyRestore.swift */, - ); - path = Backups; - sourceTree = ""; - }; C36096ED25AD20FD008B62B2 /* Media Viewing & Editing */ = { isa = PBXGroup; children = ( @@ -3607,7 +3561,6 @@ isa = PBXGroup; children = ( C3F0A58F255C8E3D007BE2A3 /* Meta */, - C36096BC25AD1C3E008B62B2 /* Backups */, C360969C25AD18BA008B62B2 /* Closed Groups */, B835246C25C38AA20089A44F /* Conversations */, C32B405424A961E1001117B5 /* Dependencies */, @@ -4854,16 +4807,13 @@ B8041AA725C90927003C2166 /* TypingIndicatorCell.swift in Sources */, B8CCF63723961D6D0091D419 /* NewDMVC.swift in Sources */, 452EC6DF205E9E30000E787C /* MediaGalleryViewController.swift in Sources */, - 3496956E21A301A100DCFE74 /* OWSBackupExportJob.m in Sources */, 4C1885D2218F8E1C00B67051 /* PhotoGridViewCell.swift in Sources */, 34D1F0501F7D45A60066283D /* GifPickerCell.swift in Sources */, - 3496957421A301A100DCFE74 /* OWSBackupAPI.swift in Sources */, C3E5C2FA251DBABB0040DFFC /* EditClosedGroupVC.swift in Sources */, B8783E9E23EB948D00404FB8 /* UILabel+Interaction.swift in Sources */, B893063F2383961A005EAA8E /* ScanQRCodeWrapperVC.swift in Sources */, B879D449247E1BE300DB3608 /* PathVC.swift in Sources */, 454A84042059C787008B8C75 /* MediaTileViewController.swift in Sources */, - 340FC8B4204DAC8D007AEB0F /* OWSBackupSettingsViewController.m in Sources */, 34D1F0871F8678AA0066283D /* ConversationViewItem.m in Sources */, 451A13B11E13DED2000A50FD /* AppNotifications.swift in Sources */, 34D99CE4217509C2000AFB39 /* AppEnvironment.swift in Sources */, @@ -4899,7 +4849,6 @@ C3548F0624456447009433A8 /* PNModeVC.swift in Sources */, B80A579F23DFF1F300876683 /* NewClosedGroupVC.swift in Sources */, D221A09A169C9E5E00537ABF /* main.m in Sources */, - 3496957221A301A100DCFE74 /* OWSBackup.m in Sources */, B835247925C38D880089A44F /* MessageCell.swift in Sources */, B86BD08623399CEF000F5AE3 /* SeedModal.swift in Sources */, 34E3E5681EC4B19400495BAC /* AudioProgressView.swift in Sources */, @@ -4909,7 +4858,6 @@ B82149B825D60393009C0F2A /* BlockedModal.swift in Sources */, B82B408C239A068800A248E7 /* RegisterVC.swift in Sources */, 346129991FD1E4DA00532771 /* SignalApp.m in Sources */, - 3496957121A301A100DCFE74 /* OWSBackupImportJob.m in Sources */, 34BECE301F7ABCF800D7438D /* GifPickerLayout.swift in Sources */, C331FFFE2558FF3B00070591 /* ConversationCell.swift in Sources */, FD659AC027A7649600F12C02 /* MessageRequestsViewController.swift in Sources */, @@ -4957,7 +4905,6 @@ B875885A264503A6000E60D0 /* JoinOpenGroupModal.swift in Sources */, B8CCF6432397711F0091D419 /* SettingsVC.swift in Sources */, C354E75A23FE2A7600CE22E3 /* BaseVC.swift in Sources */, - 3441FD9F21A3604F00BB9542 /* BackupRestoreViewController.swift in Sources */, 45C0DC1B1E68FE9000E04C47 /* UIApplication+OWS.swift in Sources */, 4539B5861F79348F007141FF /* PushRegistrationManager.swift in Sources */, B8041A9525C8FA1D003C2166 /* MediaLoaderView.swift in Sources */, @@ -4979,7 +4926,6 @@ 34A6C28021E503E700B5B12E /* OWSImagePickerController.swift in Sources */, C31A6C5C247F2CF3001123EF /* CGRect+Utilities.swift in Sources */, 4C21D5D6223A9DC500EF8A77 /* UIAlerts+iOS9.m in Sources */, - 3496957021A301A100DCFE74 /* OWSBackupIO.m in Sources */, B8269D3325C7A8C600488AB4 /* InputViewButton.swift in Sources */, B8269D3D25C7B34D00488AB4 /* InputTextView.swift in Sources */, 76EB054018170B33006006FC /* AppDelegate.m in Sources */, @@ -4999,7 +4945,6 @@ B835249B25C3AB650089A44F /* VisibleMessageCell.swift in Sources */, 340FC8AE204DAC8D007AEB0F /* OWSSoundSettingsViewController.m in Sources */, B8D0A25025E3678700C1835E /* LinkDeviceVC.swift in Sources */, - 3496957321A301A100DCFE74 /* OWSBackupJob.m in Sources */, B894D0752339EDCF00B4D94D /* NukeDataModal.swift in Sources */, 7B4C75CD26BB92060000AC89 /* DeletedMessageView.swift in Sources */, B897621C25D201F7004F83B2 /* ScrollToBottomButton.swift in Sources */, @@ -5016,7 +4961,6 @@ 7BA7F4BD27A216B600B3A466 /* Storage+RecentSearchResults.swift in Sources */, B8269D2925C7A4B400488AB4 /* InputView.swift in Sources */, C374EEE225DA26740073A857 /* LinkPreviewModal.swift in Sources */, - 3496956F21A301A100DCFE74 /* OWSBackupLazyRestore.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Session/Backups/BackupRestoreViewController.swift b/Session/Backups/BackupRestoreViewController.swift deleted file mode 100644 index 1887575c0..000000000 --- a/Session/Backups/BackupRestoreViewController.swift +++ /dev/null @@ -1,175 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -import UIKit - -@objc -public class BackupRestoreViewController: OWSTableViewController { - - private var hasBegunImport = false - - // MARK: - Dependencies - - private var backup: OWSBackup { - return AppEnvironment.shared.backup - } - - // MARK: - - - override public func loadView() { - super.loadView() - - navigationItem.title = NSLocalizedString("SETTINGS_BACKUP", comment: "Label for the backup view in app settings.") - - navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(didPressCancelButton)) - } - - override public func viewDidLoad() { - super.viewDidLoad() - - NotificationCenter.default.addObserver(self, - selector: #selector(backupStateDidChange), - name: NSNotification.Name(NSNotificationNameBackupStateDidChange), - object: nil) - - updateTableContents() - } - - private func updateTableContents() { - if hasBegunImport { - updateProgressContents() - } else { - updateDecisionContents() - } - } - - private func updateDecisionContents() { - let contents = OWSTableContents() - - let section = OWSTableSection() - - section.headerTitle = NSLocalizedString("BACKUP_RESTORE_DECISION_TITLE", comment: "Label for the backup restore decision section.") - - section.add(OWSTableItem.actionItem(withText: NSLocalizedString("CHECK_FOR_BACKUP_DO_NOT_RESTORE", - comment: "The label for the 'do not restore backup' button."), actionBlock: { [weak self] in - guard let strongSelf = self else { - return - } - strongSelf.cancelAndDismiss() - })) - section.add(OWSTableItem.actionItem(withText: NSLocalizedString("CHECK_FOR_BACKUP_RESTORE", - comment: "The label for the 'restore backup' button."), actionBlock: { [weak self] in - guard let strongSelf = self else { - return - } - strongSelf.startImport() - })) - - contents.addSection(section) - self.contents = contents - } - - private var progressFormatter: NumberFormatter = { - let numberFormatter = NumberFormatter() - numberFormatter.numberStyle = .percent - numberFormatter.maximumFractionDigits = 0 - numberFormatter.multiplier = 1 - return numberFormatter - }() - - private func updateProgressContents() { - let contents = OWSTableContents() - - let section = OWSTableSection() - - section.add(OWSTableItem.label(withText: NSLocalizedString("BACKUP_RESTORE_STATUS", comment: "Label for the backup restore status."), accessoryText: NSStringForBackupImportState(backup.backupImportState))) - - if backup.backupImportState == .inProgress { - if let backupImportDescription = backup.backupImportDescription { - section.add(OWSTableItem.label(withText: NSLocalizedString("BACKUP_RESTORE_DESCRIPTION", comment: "Label for the backup restore description."), accessoryText: backupImportDescription)) - } - - if let backupImportProgress = backup.backupImportProgress { - let progressInt = backupImportProgress.floatValue * 100 - if let progressString = progressFormatter.string(from: NSNumber(value: progressInt)) { - section.add(OWSTableItem.label(withText: NSLocalizedString("BACKUP_RESTORE_PROGRESS", comment: "Label for the backup restore progress."), accessoryText: progressString)) - } else { - owsFailDebug("Could not format progress: \(progressInt)") - } - } - } - - contents.addSection(section) - self.contents = contents - - // TODO: Add cancel button. - } - - // MARK: Helpers - - @objc - private func didPressCancelButton(sender: UIButton) { - Logger.info("") - - // TODO: Cancel import. - - cancelAndDismiss() - } - - @objc - private func cancelAndDismiss() { - Logger.info("") - - backup.setHasPendingRestoreDecision(false) - - showHomeView() - } - - @objc - private func startImport() { - Logger.info("") - - hasBegunImport = true - - backup.tryToImport() - } - - private func showHomeView() { - // In production, this view will never be presented in a modal. - // During testing (debug UI, etc.), it may be a modal. - let isModal = navigationController?.presentingViewController != nil - if isModal { - dismiss(animated: true, completion: { - SignalApp.shared().showHomeView() - }) - } else { - SignalApp.shared().showHomeView() - } - - NotificationCenter.default.removeObserver(self) - } - - // MARK: - Notifications - - @objc func backupStateDidChange() { - AssertIsOnMainThread() - - Logger.verbose("backup.backupImportState: \(NSStringForBackupImportState(backup.backupImportState))") - Logger.flush() - - if backup.backupImportState == .succeeded { - backup.setHasPendingRestoreDecision(false) - - showHomeView() - } else { - updateTableContents() - } - } - - // MARK: Orientation - - public override var supportedInterfaceOrientations: UIInterfaceOrientationMask { - return .portrait - } -} diff --git a/Session/Backups/OWSBackup.h b/Session/Backups/OWSBackup.h deleted file mode 100644 index 4ad58c34e..000000000 --- a/Session/Backups/OWSBackup.h +++ /dev/null @@ -1,105 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -NS_ASSUME_NONNULL_BEGIN - -extern NSString *const NSNotificationNameBackupStateDidChange; - -typedef void (^OWSBackupBoolBlock)(BOOL value); -typedef void (^OWSBackupStringListBlock)(NSArray *value); -typedef void (^OWSBackupErrorBlock)(NSError *error); - -typedef NS_ENUM(NSUInteger, OWSBackupState) { - // Has never backed up, not trying to backup yet. - OWSBackupState_Idle = 0, - // Backing up. - OWSBackupState_InProgress, - // Last backup failed. - OWSBackupState_Failed, - // Last backup succeeded. - OWSBackupState_Succeeded, -}; - -NSString *NSStringForBackupExportState(OWSBackupState state); -NSString *NSStringForBackupImportState(OWSBackupState state); - -NSArray *MiscCollectionsToBackup(void); - -NSError *OWSBackupErrorWithDescription(NSString *description); - -@class AnyPromise; -@class OWSBackupIO; -@class TSAttachmentPointer; -@class TSThread; -@class YapDatabaseConnection; - -@interface OWSBackup : NSObject - -- (instancetype)init NS_DESIGNATED_INITIALIZER; - -+ (instancetype)sharedManager NS_SWIFT_NAME(shared()); - -#pragma mark - Backup Export - -@property (atomic, readonly) OWSBackupState backupExportState; - -// If a "backup export" is in progress (see backupExportState), -// backupExportDescription _might_ contain a string that describes -// the current phase and backupExportProgress _might_ contain a -// 0.0<=x<=1.0 progress value that indicates progress within the -// current phase. -@property (nonatomic, readonly, nullable) NSString *backupExportDescription; -@property (nonatomic, readonly, nullable) NSNumber *backupExportProgress; - -+ (BOOL)isFeatureEnabled; - -- (BOOL)isBackupEnabled; -- (void)setIsBackupEnabled:(BOOL)value; - -- (BOOL)hasPendingRestoreDecision; -- (void)setHasPendingRestoreDecision:(BOOL)value; - -- (void)tryToExportBackup; -- (void)cancelExportBackup; - -#pragma mark - Backup Import - -@property (atomic, readonly) OWSBackupState backupImportState; - -// If a "backup import" is in progress (see backupImportState), -// backupImportDescription _might_ contain a string that describes -// the current phase and backupImportProgress _might_ contain a -// 0.0<=x<=1.0 progress value that indicates progress within the -// current phase. -@property (nonatomic, readonly, nullable) NSString *backupImportDescription; -@property (nonatomic, readonly, nullable) NSNumber *backupImportProgress; - -- (void)allRecipientIdsWithManifestsInCloud:(OWSBackupStringListBlock)success failure:(OWSBackupErrorBlock)failure; - -- (AnyPromise *)ensureCloudKitAccess __attribute__((warn_unused_result)); - -- (void)checkCanImportBackup:(OWSBackupBoolBlock)success failure:(OWSBackupErrorBlock)failure; - -// TODO: After a successful import, we should enable backup and -// preserve our PIN and/or private key so that restored users -// continues to backup. -- (void)tryToImportBackup; -- (void)cancelImportBackup; - -- (void)logBackupRecords; -- (void)clearAllCloudKitRecords; - -- (void)logBackupMetadataCache:(YapDatabaseConnection *)dbConnection; - -#pragma mark - Lazy Restore - -- (NSArray *)attachmentRecordNamesForLazyRestore; - -- (NSArray *)attachmentIdsForLazyRestore; - -- (AnyPromise *)lazyRestoreAttachment:(TSAttachmentPointer *)attachment backupIO:(OWSBackupIO *)backupIO __attribute__((warn_unused_result)); - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Backups/OWSBackup.m b/Session/Backups/OWSBackup.m deleted file mode 100644 index 4f0ec36b0..000000000 --- a/Session/Backups/OWSBackup.m +++ /dev/null @@ -1,912 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "OWSBackup.h" -#import "OWSBackupExportJob.h" -#import "OWSBackupIO.h" -#import "OWSBackupImportJob.h" -#import "Session-Swift.h" -#import -#import -#import -#import - -@import CloudKit; - -NS_ASSUME_NONNULL_BEGIN - -NSString *const NSNotificationNameBackupStateDidChange = @"NSNotificationNameBackupStateDidChange"; - -NSString *const OWSPrimaryStorage_OWSBackupCollection = @"OWSPrimaryStorage_OWSBackupCollection"; -NSString *const OWSBackup_IsBackupEnabledKey = @"OWSBackup_IsBackupEnabledKey"; -NSString *const OWSBackup_LastExportSuccessDateKey = @"OWSBackup_LastExportSuccessDateKey"; -NSString *const OWSBackup_LastExportFailureDateKey = @"OWSBackup_LastExportFailureDateKey"; -NSString *const OWSBackupErrorDomain = @"OWSBackupErrorDomain"; - -NSString *NSStringForBackupExportState(OWSBackupState state) -{ - switch (state) { - case OWSBackupState_Idle: - return NSLocalizedString(@"SETTINGS_BACKUP_STATUS_IDLE", @"Indicates that app is not backing up."); - case OWSBackupState_InProgress: - return NSLocalizedString(@"SETTINGS_BACKUP_STATUS_IN_PROGRESS", @"Indicates that app is backing up."); - case OWSBackupState_Failed: - return NSLocalizedString(@"SETTINGS_BACKUP_STATUS_FAILED", @"Indicates that the last backup failed."); - case OWSBackupState_Succeeded: - return NSLocalizedString(@"SETTINGS_BACKUP_STATUS_SUCCEEDED", @"Indicates that the last backup succeeded."); - } -} - -NSString *NSStringForBackupImportState(OWSBackupState state) -{ - switch (state) { - case OWSBackupState_Idle: - return NSLocalizedString(@"SETTINGS_BACKUP_IMPORT_STATUS_IDLE", @"Indicates that app is not restoring up."); - case OWSBackupState_InProgress: - return NSLocalizedString( - @"SETTINGS_BACKUP_IMPORT_STATUS_IN_PROGRESS", @"Indicates that app is restoring up."); - case OWSBackupState_Failed: - return NSLocalizedString( - @"SETTINGS_BACKUP_IMPORT_STATUS_FAILED", @"Indicates that the last backup restore failed."); - case OWSBackupState_Succeeded: - return NSLocalizedString( - @"SETTINGS_BACKUP_IMPORT_STATUS_SUCCEEDED", @"Indicates that the last backup restore succeeded."); - } -} - -NSArray *MiscCollectionsToBackup(void) -{ - return @[ - kOWSBlockingManager_BlockListCollection, - OWSUserProfile.collection, - SSKIncrementingIdFinder.collectionName, - OWSPreferencesSignalDatabaseCollection, - ]; -} - -typedef NS_ENUM(NSInteger, OWSBackupErrorCode) { - OWSBackupErrorCodeAssertionFailure = 0, -}; - -NSError *OWSBackupErrorWithDescription(NSString *description) -{ - return [NSError errorWithDomain:@"OWSBackupErrorDomain" - code:OWSBackupErrorCodeAssertionFailure - userInfo:@{ NSLocalizedDescriptionKey : description }]; -} - -// TODO: Observe Reachability. -@interface OWSBackup () - -@property (nonatomic, readonly) YapDatabaseConnection *dbConnection; - -// This property should only be accessed on the main thread. -@property (nonatomic, nullable) OWSBackupExportJob *backupExportJob; - -// This property should only be accessed on the main thread. -@property (nonatomic, nullable) OWSBackupImportJob *backupImportJob; - -@property (nonatomic, nullable) NSString *backupExportDescription; -@property (nonatomic, nullable) NSNumber *backupExportProgress; - -@property (nonatomic, nullable) NSString *backupImportDescription; -@property (nonatomic, nullable) NSNumber *backupImportProgress; - -@property (atomic) OWSBackupState backupExportState; -@property (atomic) OWSBackupState backupImportState; - -@end - -#pragma mark - - -@implementation OWSBackup - -@synthesize dbConnection = _dbConnection; - -+ (instancetype)sharedManager -{ - OWSAssertDebug(AppEnvironment.shared.backup); - - return AppEnvironment.shared.backup; -} - -- (instancetype)init -{ - self = [super init]; - - if (!self) { - return self; - } - - self.backupExportState = OWSBackupState_Idle; - self.backupImportState = OWSBackupState_Idle; - - OWSSingletonAssert(); - - [AppReadiness runNowOrWhenAppDidBecomeReady:^{ - [self setup]; - }]; - - return self; -} - -- (void)dealloc -{ - [[NSNotificationCenter defaultCenter] removeObserver:self]; -} - -- (void)setup -{ - if (!OWSBackup.isFeatureEnabled) { - return; - } - - [OWSBackupAPI setup]; - - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(applicationDidBecomeActive:) - name:OWSApplicationDidBecomeActiveNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(registrationStateDidChange) - name:RegistrationStateDidChangeNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(ckAccountChanged) - name:CKAccountChangedNotification - object:nil]; - - // We want to start a backup if necessary on app launch, but app launch is a - // busy time and it's important to remain responsive, so wait a few seconds before - // starting the backup. - // - // TODO: Make this period longer. - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 2 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{ - [self ensureBackupExportState]; - }); -} - -- (YapDatabaseConnection *)dbConnection -{ - @synchronized(self) { - if (!_dbConnection) { - _dbConnection = self.primaryStorage.newDatabaseConnection; - } - return _dbConnection; - } -} - -#pragma mark - Dependencies - -- (OWSPrimaryStorage *)primaryStorage -{ - OWSAssertDebug(SSKEnvironment.shared.primaryStorage); - - return SSKEnvironment.shared.primaryStorage; -} - -- (TSAccountManager *)tsAccountManager -{ - OWSAssertDebug(SSKEnvironment.shared.tsAccountManager); - - return SSKEnvironment.shared.tsAccountManager; -} - -+ (BOOL)isFeatureEnabled -{ - return NO; -} - -#pragma mark - Backup Export - -- (void)tryToExportBackup -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(!self.backupExportJob); - - if (!self.canBackupExport) { - // TODO: Offer a reason in the UI. - return; - } - - if (!self.tsAccountManager.isRegisteredAndReady) { - OWSFailDebug(@"Can't backup; not registered and ready."); - return; - } - NSString *_Nullable recipientId = self.tsAccountManager.localNumber; - if (recipientId.length < 1) { - OWSFailDebug(@"Can't backup; missing recipientId."); - return; - } - - // In development, make sure there's no export or import in progress. - [self.backupExportJob cancel]; - self.backupExportJob = nil; - [self.backupImportJob cancel]; - self.backupImportJob = nil; - - self.backupExportState = OWSBackupState_InProgress; - - self.backupExportJob = [[OWSBackupExportJob alloc] initWithDelegate:self recipientId:recipientId]; - [self.backupExportJob start]; - - [self postDidChangeNotification]; -} - -- (void)cancelExportBackup -{ - [self.backupExportJob cancel]; - self.backupExportJob = nil; - - [self ensureBackupExportState]; -} - -- (void)setLastExportSuccessDate:(NSDate *)value -{ - OWSAssertDebug(value); - - [self.dbConnection setDate:value - forKey:OWSBackup_LastExportSuccessDateKey - inCollection:OWSPrimaryStorage_OWSBackupCollection]; -} - -- (nullable NSDate *)lastExportSuccessDate -{ - return [self.dbConnection dateForKey:OWSBackup_LastExportSuccessDateKey - inCollection:OWSPrimaryStorage_OWSBackupCollection]; -} - -- (void)setLastExportFailureDate:(NSDate *)value -{ - OWSAssertDebug(value); - - [self.dbConnection setDate:value - forKey:OWSBackup_LastExportFailureDateKey - inCollection:OWSPrimaryStorage_OWSBackupCollection]; -} - - -- (nullable NSDate *)lastExportFailureDate -{ - return [self.dbConnection dateForKey:OWSBackup_LastExportFailureDateKey - inCollection:OWSPrimaryStorage_OWSBackupCollection]; -} - -- (BOOL)isBackupEnabled -{ - return [self.dbConnection boolForKey:OWSBackup_IsBackupEnabledKey - inCollection:OWSPrimaryStorage_OWSBackupCollection - defaultValue:NO]; -} - -- (void)setIsBackupEnabled:(BOOL)value -{ - [self.dbConnection setBool:value - forKey:OWSBackup_IsBackupEnabledKey - inCollection:OWSPrimaryStorage_OWSBackupCollection]; - - if (!value) { - [self.dbConnection removeObjectForKey:OWSBackup_LastExportSuccessDateKey - inCollection:OWSPrimaryStorage_OWSBackupCollection]; - [self.dbConnection removeObjectForKey:OWSBackup_LastExportFailureDateKey - inCollection:OWSPrimaryStorage_OWSBackupCollection]; - } - - [self postDidChangeNotification]; - - [self ensureBackupExportState]; -} - -- (BOOL)hasPendingRestoreDecision -{ - return [self.tsAccountManager hasPendingBackupRestoreDecision]; -} - -- (void)setHasPendingRestoreDecision:(BOOL)value -{ - [self.tsAccountManager setHasPendingBackupRestoreDecision:value]; -} - -- (BOOL)canBackupExport -{ - if (!self.isBackupEnabled) { - return NO; - } - if (UIApplication.sharedApplication.applicationState != UIApplicationStateActive) { - // Don't start backups when app is in the background. - return NO; - } - if (![self.tsAccountManager isRegisteredAndReady]) { - return NO; - } - return YES; -} - -- (BOOL)shouldHaveBackupExport -{ - if (!self.canBackupExport) { - return NO; - } - if (self.backupExportJob) { - // If there's already a job in progress, let it complete. - return YES; - } - NSDate *_Nullable lastExportSuccessDate = self.lastExportSuccessDate; - NSDate *_Nullable lastExportFailureDate = self.lastExportFailureDate; - // Wait N hours before retrying after a success. - const NSTimeInterval kRetryAfterSuccess = 24 * kHourInterval; - if (lastExportSuccessDate && fabs(lastExportSuccessDate.timeIntervalSinceNow) < kRetryAfterSuccess) { - return NO; - } - // Wait N hours before retrying after a failure. - const NSTimeInterval kRetryAfterFailure = 6 * kHourInterval; - if (lastExportFailureDate && fabs(lastExportFailureDate.timeIntervalSinceNow) < kRetryAfterFailure) { - return NO; - } - // Don't export backup if there's an import in progress. - // - // This conflict shouldn't occur in production since we won't enable backup - // export until an import is complete, but this could happen in development. - if (self.backupImportJob) { - return NO; - } - - // TODO: There's other conditions that affect this decision, - // e.g. Reachability, wifi v. cellular, etc. - return YES; -} - -- (void)ensureBackupExportState -{ - OWSAssertIsOnMainThread(); - - if (!OWSBackup.isFeatureEnabled) { - return; - } - - if (!CurrentAppContext().isMainApp) { - return; - } - - if (!self.tsAccountManager.isRegisteredAndReady) { - OWSLogError(@"Can't backup; not registered and ready."); - return; - } - NSString *_Nullable recipientId = self.tsAccountManager.localNumber; - if (recipientId.length < 1) { - OWSFailDebug(@"Can't backup; missing recipientId."); - return; - } - - // Start or abort a backup export if neccessary. - if (!self.shouldHaveBackupExport && self.backupExportJob) { - [self.backupExportJob cancel]; - self.backupExportJob = nil; - } else if (self.shouldHaveBackupExport && !self.backupExportJob) { - self.backupExportJob = [[OWSBackupExportJob alloc] initWithDelegate:self recipientId:recipientId]; - [self.backupExportJob start]; - } - - // Update the state flag. - OWSBackupState backupExportState = OWSBackupState_Idle; - if (self.backupExportJob) { - backupExportState = OWSBackupState_InProgress; - } else { - NSDate *_Nullable lastExportSuccessDate = self.lastExportSuccessDate; - NSDate *_Nullable lastExportFailureDate = self.lastExportFailureDate; - if (!lastExportSuccessDate && !lastExportFailureDate) { - backupExportState = OWSBackupState_Idle; - } else if (lastExportSuccessDate && lastExportFailureDate) { - backupExportState = ([lastExportSuccessDate isAfterDate:lastExportFailureDate] ? OWSBackupState_Succeeded - : OWSBackupState_Failed); - } else if (lastExportSuccessDate) { - backupExportState = OWSBackupState_Succeeded; - } else if (lastExportFailureDate) { - backupExportState = OWSBackupState_Failed; - } else { - OWSFailDebug(@"unexpected condition."); - } - } - - BOOL stateDidChange = self.backupExportState != backupExportState; - self.backupExportState = backupExportState; - if (stateDidChange) { - [self postDidChangeNotification]; - } -} - -#pragma mark - Backup Import - -- (void)allRecipientIdsWithManifestsInCloud:(OWSBackupStringListBlock)success failure:(OWSBackupErrorBlock)failure -{ - OWSAssertIsOnMainThread(); - - OWSLogInfo(@""); - - [OWSBackupAPI - allRecipientIdsWithManifestsInCloudWithSuccess:^(NSArray *recipientIds) { - dispatch_async(dispatch_get_main_queue(), ^{ - success(recipientIds); - }); - } - failure:^(NSError *error) { - dispatch_async(dispatch_get_main_queue(), ^{ - failure(error); - }); - }]; -} - -- (AnyPromise *)ensureCloudKitAccess -{ - OWSAssertIsOnMainThread(); - - OWSLogInfo(@""); - - AnyPromise * (^failWithUnexpectedError)(void) = ^{ - NSError *error = [NSError errorWithDomain:OWSBackupErrorDomain - code:1 - userInfo:@{ - NSLocalizedDescriptionKey : NSLocalizedString(@"BACKUP_UNEXPECTED_ERROR", - @"Error shown when backup fails due to an unexpected error.") - }]; - return [AnyPromise promiseWithValue:error]; - }; - - if (!self.tsAccountManager.isRegisteredAndReady) { - OWSLogError(@"Can't backup; not registered and ready."); - return failWithUnexpectedError(); - } - NSString *_Nullable recipientId = self.tsAccountManager.localNumber; - if (recipientId.length < 1) { - OWSFailDebug(@"Can't backup; missing recipientId."); - return failWithUnexpectedError(); - } - - return [OWSBackupAPI ensureCloudKitAccessObjc]; -} - -- (void)checkCanImportBackup:(OWSBackupBoolBlock)success failure:(OWSBackupErrorBlock)failure -{ - OWSAssertIsOnMainThread(); - - OWSLogInfo(@""); - - if (!OWSBackup.isFeatureEnabled) { - dispatch_async(dispatch_get_main_queue(), ^{ - success(NO); - }); - return; - } - - void (^failWithUnexpectedError)(void) = ^{ - dispatch_async(dispatch_get_main_queue(), ^{ - NSError *error = - [NSError errorWithDomain:OWSBackupErrorDomain - code:1 - userInfo:@{ - NSLocalizedDescriptionKey : NSLocalizedString(@"BACKUP_UNEXPECTED_ERROR", - @"Error shown when backup fails due to an unexpected error.") - }]; - failure(error); - }); - }; - - if (!self.tsAccountManager.isRegisteredAndReady) { - OWSLogError(@"Can't backup; not registered and ready."); - return failWithUnexpectedError(); - } - NSString *_Nullable recipientId = self.tsAccountManager.localNumber; - if (recipientId.length < 1) { - OWSFailDebug(@"Can't backup; missing recipientId."); - return failWithUnexpectedError(); - } - - [[OWSBackupAPI ensureCloudKitAccessObjc] - .thenInBackground(^{ - return [OWSBackupAPI checkForManifestInCloudObjcWithRecipientId:recipientId]; - }) - .then(^(NSNumber *value) { - success(value.boolValue); - }) - .catch(^(NSError *error) { - failure(error); - }) retainUntilComplete]; -} - -- (void)tryToImportBackup -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(!self.backupImportJob); - - if (!self.tsAccountManager.isRegisteredAndReady) { - OWSLogError(@"Can't restore backup; not registered and ready."); - return; - } - NSString *_Nullable recipientId = self.tsAccountManager.localNumber; - if (recipientId.length < 1) { - OWSLogError(@"Can't restore backup; missing recipientId."); - return; - } - - // In development, make sure there's no export or import in progress. - [self.backupExportJob cancel]; - self.backupExportJob = nil; - [self.backupImportJob cancel]; - self.backupImportJob = nil; - - self.backupImportState = OWSBackupState_InProgress; - - self.backupImportJob = [[OWSBackupImportJob alloc] initWithDelegate:self recipientId:recipientId]; - [self.backupImportJob start]; - - [self postDidChangeNotification]; -} - -- (void)cancelImportBackup -{ - [self.backupImportJob cancel]; - self.backupImportJob = nil; - - self.backupImportState = OWSBackupState_Idle; - - [self postDidChangeNotification]; -} - -#pragma mark - - -- (void)applicationDidBecomeActive:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - - [self ensureBackupExportState]; -} - -- (void)registrationStateDidChange -{ - OWSAssertIsOnMainThread(); - - [self ensureBackupExportState]; - - [self postDidChangeNotification]; -} - -- (void)ckAccountChanged -{ - dispatch_async(dispatch_get_main_queue(), ^{ - [self ensureBackupExportState]; - - [self postDidChangeNotification]; - }); -} - -#pragma mark - OWSBackupJobDelegate - -// We use a delegate method to avoid storing this key in memory. -- (nullable NSData *)backupEncryptionKey -{ - // TODO: Use actual encryption key. - return [@"temp" dataUsingEncoding:NSUTF8StringEncoding]; -} - -- (void)backupJobDidSucceed:(OWSBackupJob *)backupJob -{ - OWSAssertIsOnMainThread(); - - OWSLogInfo(@"."); - - if (self.backupImportJob == backupJob) { - self.backupImportJob = nil; - - self.backupImportState = OWSBackupState_Succeeded; - } else if (self.backupExportJob == backupJob) { - self.backupExportJob = nil; - - [self setLastExportSuccessDate:[NSDate new]]; - - [self ensureBackupExportState]; - } else { - OWSLogWarn(@"obsolete job succeeded: %@", [backupJob class]); - return; - } - - [self postDidChangeNotification]; -} - -- (void)backupJobDidFail:(OWSBackupJob *)backupJob error:(NSError *)error -{ - OWSAssertIsOnMainThread(); - - OWSLogInfo(@": %@", error); - - if (self.backupImportJob == backupJob) { - self.backupImportJob = nil; - - self.backupImportState = OWSBackupState_Failed; - } else if (self.backupExportJob == backupJob) { - self.backupExportJob = nil; - - [self setLastExportFailureDate:[NSDate new]]; - - [self ensureBackupExportState]; - } else { - OWSLogInfo(@"obsolete backup job failed."); - return; - } - - [self postDidChangeNotification]; -} - -- (void)backupJobDidUpdate:(OWSBackupJob *)backupJob - description:(nullable NSString *)description - progress:(nullable NSNumber *)progress -{ - OWSAssertIsOnMainThread(); - - OWSLogInfo(@""); - - // TODO: Should we consolidate this state? - BOOL didChange; - if (self.backupImportJob == backupJob) { - didChange = !([NSObject isNullableObject:self.backupImportDescription equalTo:description] && - [NSObject isNullableObject:self.backupImportProgress equalTo:progress]); - - self.backupImportDescription = description; - self.backupImportProgress = progress; - } else if (self.backupExportJob == backupJob) { - didChange = !([NSObject isNullableObject:self.backupExportDescription equalTo:description] && - [NSObject isNullableObject:self.backupExportProgress equalTo:progress]); - - self.backupExportDescription = description; - self.backupExportProgress = progress; - } else { - return; - } - - if (didChange) { - [self postDidChangeNotification]; - } -} - -- (void)logBackupRecords -{ - OWSAssertIsOnMainThread(); - - OWSLogInfo(@""); - - if (!self.tsAccountManager.isRegisteredAndReady) { - OWSLogError(@"Can't interact with backup; not registered and ready."); - return; - } - NSString *_Nullable recipientId = self.tsAccountManager.localNumber; - if (recipientId.length < 1) { - OWSLogError(@"Can't interact with backup; missing recipientId."); - return; - } - - [OWSBackupAPI fetchAllRecordNamesWithRecipientId:recipientId - success:^(NSArray *recordNames) { - for (NSString *recordName in [recordNames sortedArrayUsingSelector:@selector(compare:)]) { - OWSLogInfo(@"\t %@", recordName); - } - OWSLogInfo(@"record count: %zd", recordNames.count); - } - failure:^(NSError *error) { - OWSLogError(@"Failed to retrieve backup records: %@", error); - }]; -} - -- (void)clearAllCloudKitRecords -{ - OWSAssertIsOnMainThread(); - - OWSLogInfo(@""); - - if (!self.tsAccountManager.isRegisteredAndReady) { - OWSLogError(@"Can't interact with backup; not registered and ready."); - return; - } - NSString *_Nullable recipientId = self.tsAccountManager.localNumber; - if (recipientId.length < 1) { - OWSLogError(@"Can't interact with backup; missing recipientId."); - return; - } - - [OWSBackupAPI fetchAllRecordNamesWithRecipientId:recipientId - success:^(NSArray *recordNames) { - if (recordNames.count < 1) { - OWSLogInfo(@"No CloudKit records found to clear."); - return; - } - [OWSBackupAPI deleteRecordsFromCloudWithRecordNames:recordNames - success:^{ - OWSLogInfo(@"Clear all CloudKit records succeeded."); - } - failure:^(NSError *error) { - OWSLogError(@"Clear all CloudKit records failed: %@.", error); - }]; - } - failure:^(NSError *error) { - OWSLogError(@"Failed to retrieve CloudKit records: %@", error); - }]; -} - -#pragma mark - Lazy Restore - -- (NSArray *)attachmentRecordNamesForLazyRestore -{ - NSMutableArray *recordNames = [NSMutableArray new]; - [self.dbConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - id ext = [transaction ext:TSLazyRestoreAttachmentsDatabaseViewExtensionName]; - if (!ext) { - OWSFailDebug(@"Could not load database view."); - return; - } - - [ext enumerateKeysAndObjectsInGroup:TSLazyRestoreAttachmentsGroup - usingBlock:^( - NSString *collection, NSString *key, id object, NSUInteger index, BOOL *stop) { - if (![object isKindOfClass:[TSAttachmentPointer class]]) { - OWSFailDebug( - @"Unexpected object: %@ in collection:%@", [object class], collection); - return; - } - TSAttachmentPointer *attachmentPointer = object; - if (!attachmentPointer.lazyRestoreFragment) { - OWSFailDebug( - @"Invalid object: %@ in collection:%@", [object class], collection); - return; - } - [recordNames addObject:attachmentPointer.lazyRestoreFragment.recordName]; - }]; - }]; - return recordNames; -} - -- (NSArray *)attachmentIdsForLazyRestore -{ - NSMutableArray *attachmentIds = [NSMutableArray new]; - [self.dbConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - id ext = [transaction ext:TSLazyRestoreAttachmentsDatabaseViewExtensionName]; - if (!ext) { - OWSFailDebug(@"Could not load database view."); - return; - } - - [ext enumerateKeysInGroup:TSLazyRestoreAttachmentsGroup - usingBlock:^(NSString *collection, NSString *key, NSUInteger index, BOOL *stop) { - [attachmentIds addObject:key]; - }]; - }]; - return attachmentIds; -} - -- (AnyPromise *)lazyRestoreAttachment:(TSAttachmentPointer *)attachment backupIO:(OWSBackupIO *)backupIO -{ - OWSAssertDebug(attachment); - OWSAssertDebug(backupIO); - - OWSBackupFragment *_Nullable lazyRestoreFragment = attachment.lazyRestoreFragment; - if (!lazyRestoreFragment) { - OWSLogError(@"Attachment missing lazy restore metadata."); - return - [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Attachment missing lazy restore metadata.")]; - } - if (lazyRestoreFragment.recordName.length < 1 || lazyRestoreFragment.encryptionKey.length < 1) { - OWSLogError(@"Incomplete lazy restore metadata."); - return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Incomplete lazy restore metadata.")]; - } - - // Use a predictable file path so that multiple "import backup" attempts - // will leverage successful file downloads from previous attempts. - // - // TODO: This will also require imports using a predictable jobTempDirPath. - NSString *tempFilePath = [backupIO generateTempFilePath]; - - return [OWSBackupAPI downloadFileFromCloudObjcWithRecordName:lazyRestoreFragment.recordName - toFileUrl:[NSURL fileURLWithPath:tempFilePath]] - .thenInBackground(^{ - return [self lazyRestoreAttachment:attachment - backupIO:backupIO - encryptedFilePath:tempFilePath - encryptionKey:lazyRestoreFragment.encryptionKey]; - }); -} - -- (AnyPromise *)lazyRestoreAttachment:(TSAttachmentPointer *)attachmentPointer - backupIO:(OWSBackupIO *)backupIO - encryptedFilePath:(NSString *)encryptedFilePath - encryptionKey:(NSData *)encryptionKey -{ - OWSAssertDebug(attachmentPointer); - OWSAssertDebug(backupIO); - OWSAssertDebug(encryptedFilePath.length > 0); - OWSAssertDebug(encryptionKey.length > 0); - - NSData *_Nullable data = [NSData dataWithContentsOfFile:encryptedFilePath]; - if (!data) { - OWSLogError(@"Could not load encrypted file."); - return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Could not load encrypted file.")]; - } - - NSString *decryptedFilePath = [backupIO generateTempFilePath]; - - @autoreleasepool { - if (![backupIO decryptFileAsFile:encryptedFilePath dstFilePath:decryptedFilePath encryptionKey:encryptionKey]) { - OWSLogError(@"Could not load decrypt file."); - return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Could not load decrypt file.")]; - } - } - - TSAttachmentStream *stream = [[TSAttachmentStream alloc] initWithPointer:attachmentPointer]; - - NSString *attachmentFilePath = stream.originalFilePath; - if (attachmentFilePath.length < 1) { - OWSLogError(@"Attachment has invalid file path."); - return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Attachment has invalid file path.")]; - } - - NSString *attachmentDirPath = [attachmentFilePath stringByDeletingLastPathComponent]; - if (![OWSFileSystem ensureDirectoryExists:attachmentDirPath]) { - OWSLogError(@"Couldn't create directory for attachment file."); - return [AnyPromise - promiseWithValue:OWSBackupErrorWithDescription(@"Couldn't create directory for attachment file.")]; - } - - if (![OWSFileSystem deleteFileIfExists:attachmentFilePath]) { - OWSFailDebug(@"Couldn't delete existing file at attachment path."); - return [AnyPromise - promiseWithValue:OWSBackupErrorWithDescription(@"Couldn't delete existing file at attachment path.")]; - } - - NSError *error; - BOOL success = - [NSFileManager.defaultManager moveItemAtPath:decryptedFilePath toPath:attachmentFilePath error:&error]; - if (!success || error) { - OWSLogError(@"Attachment file could not be restored: %@.", error); - return [AnyPromise promiseWithValue:error]; - } - - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - // This should overwrite the attachment pointer with an attachment stream. - [stream saveWithTransaction:transaction]; - }]; - - return [AnyPromise promiseWithValue:@(1)]; -} - -- (void)logBackupMetadataCache:(YapDatabaseConnection *)dbConnection -{ - OWSLogInfo(@""); - - [dbConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - [transaction enumerateKeysAndObjectsInCollection:[OWSBackupFragment collection] - usingBlock:^(NSString *key, OWSBackupFragment *fragment, BOOL *stop) { - OWSLogVerbose(@"fragment: %@, %@, %lu, %@, %@, %@, %@", - key, - fragment.recordName, - (unsigned long)fragment.encryptionKey.length, - fragment.relativeFilePath, - fragment.attachmentId, - fragment.downloadFilePath, - fragment.uncompressedDataLength); - }]; - OWSLogVerbose(@"Number of fragments: %lu", - (unsigned long)[transaction numberOfKeysInCollection:[OWSBackupFragment collection]]); - }]; -} - -#pragma mark - Notifications - -- (void)postDidChangeNotification -{ - OWSAssertIsOnMainThread(); - - [[NSNotificationCenter defaultCenter] postNotificationNameAsync:NSNotificationNameBackupStateDidChange - object:nil - userInfo:nil]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Backups/OWSBackupAPI.swift b/Session/Backups/OWSBackupAPI.swift deleted file mode 100644 index 02b7837c0..000000000 --- a/Session/Backups/OWSBackupAPI.swift +++ /dev/null @@ -1,740 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -import Foundation -import SignalUtilitiesKit -import CloudKit -import PromiseKit - -// We don't worry about atomic writes. Each backup export -// will diff against last successful backup. -// -// Note that all of our CloudKit records are immutable. -// "Persistent" records are only uploaded once. -// "Ephemeral" records are always uploaded to a new record name. -@objc public class OWSBackupAPI: NSObject { - - // If we change the record types, we need to ensure indices - // are configured properly in the CloudKit dashboard. - // - // TODO: Change the record types when we ship to production. - static let signalBackupRecordType = "signalBackup" - static let manifestRecordNameSuffix = "manifest" - static let payloadKey = "payload" - static let maxRetries = 5 - - private class func database() -> CKDatabase { - let myContainer = CKContainer.default() - let privateDatabase = myContainer.privateCloudDatabase - return privateDatabase - } - - private class func invalidServiceResponseError() -> Error { - return OWSErrorWithCodeDescription(.backupFailure, - NSLocalizedString("BACKUP_EXPORT_ERROR_INVALID_CLOUDKIT_RESPONSE", - comment: "Error indicating that the app received an invalid response from CloudKit.")) - } - - // MARK: - Upload - - @objc - public class func recordNameForTestFile(recipientId: String) -> String { - return "\(recordNamePrefix(forRecipientId: recipientId))test-\(NSUUID().uuidString)" - } - - // "Ephemeral" files are specific to this backup export and will always need to - // be saved. For example, a complete image of the database is exported each time. - // We wouldn't want to overwrite previous images until the entire backup export is - // complete. - @objc - public class func recordNameForEphemeralFile(recipientId: String, - label: String) -> String { - return "\(recordNamePrefix(forRecipientId: recipientId))ephemeral-\(label)-\(NSUUID().uuidString)" - } - - // "Persistent" files may be shared between backup export; they should only be saved - // once. For example, attachment files should only be uploaded once. Subsequent - // backups can reuse the same record. - @objc - public class func recordNameForPersistentFile(recipientId: String, - fileId: String) -> String { - return "\(recordNamePrefix(forRecipientId: recipientId))persistentFile-\(fileId)" - } - - // "Persistent" files may be shared between backup export; they should only be saved - // once. For example, attachment files should only be uploaded once. Subsequent - // backups can reuse the same record. - @objc - public class func recordNameForManifest(recipientId: String) -> String { - return "\(recordNamePrefix(forRecipientId: recipientId))\(manifestRecordNameSuffix)" - } - - private class func isManifest(recordName: String) -> Bool { - return recordName.hasSuffix(manifestRecordNameSuffix) - } - - private class func recordNamePrefix(forRecipientId recipientId: String) -> String { - return "\(recipientId)-" - } - - private class func recipientId(forRecordName recordName: String) -> String? { - let recipientIds = self.recipientIds(forRecordNames: [recordName]) - guard let recipientId = recipientIds.first else { - return nil - } - return recipientId - } - - private static var recordNamePrefixRegex = { - return try! NSRegularExpression(pattern: "^(\\+[0-9]+)\\-") - }() - - private class func recipientIds(forRecordNames recordNames: [String]) -> [String] { - var recipientIds = [String]() - for recordName in recordNames { - let regex = recordNamePrefixRegex - guard let match: NSTextCheckingResult = regex.firstMatch(in: recordName, options: [], range: NSRange(location: 0, length: recordName.utf16.count)) else { - Logger.warn("no match: \(recordName)") - continue - } - guard match.numberOfRanges > 0 else { - // Match must include first group. - Logger.warn("invalid match: \(recordName)") - continue - } - let firstRange = match.range(at: 1) - guard firstRange.location == 0, - firstRange.length > 0 else { - // Match must be at start of string and non-empty. - Logger.warn("invalid match: \(recordName) \(firstRange)") - continue - } - let recipientId = (recordName as NSString).substring(with: firstRange) as String - recipientIds.append(recipientId) - } - return recipientIds - } - - @objc - public class func record(forFileUrl fileUrl: URL, - recordName: String) -> CKRecord { - let recordType = signalBackupRecordType - let recordID = CKRecord.ID(recordName: recordName) - let record = CKRecord(recordType: recordType, recordID: recordID) - let asset = CKAsset(fileURL: fileUrl) - record[payloadKey] = asset - - return record - } - - @objc - public class func saveRecordsToCloudObjc(records: [CKRecord]) -> AnyPromise { - return AnyPromise(saveRecordsToCloud(records: records)) - } - - public class func saveRecordsToCloud(records: [CKRecord]) -> Promise { - - // CloudKit's internal limit is 400, but I haven't found a constant for this. - let kMaxBatchSize = 100 - return records.chunked(by: kMaxBatchSize).reduce(Promise.value(())) { (promise, batch) -> Promise in - return promise.then(on: .global()) { - saveRecordsToCloud(records: batch, remainingRetries: maxRetries) - }.done { - Logger.verbose("Saved batch: \(batch.count)") - } - } - } - - private class func saveRecordsToCloud(records: [CKRecord], - remainingRetries: Int) -> Promise { - - let recordNames = records.map { (record) in - return record.recordID.recordName - } - Logger.verbose("recordNames[\(recordNames.count)] \(recordNames[0..<10])...") - - return Promise { resolver in - let saveOperation = CKModifyRecordsOperation(recordsToSave: records, recordIDsToDelete: nil) - saveOperation.modifyRecordsCompletionBlock = { (savedRecords: [CKRecord]?, _, error) in - - let retry = { - // Only retry records which didn't already succeed. - var savedRecordNames = [String]() - if let savedRecords = savedRecords { - savedRecordNames = savedRecords.map { (record) in - return record.recordID.recordName - } - } - let retryRecords = records.filter({ (record) in - return !savedRecordNames.contains(record.recordID.recordName) - }) - - saveRecordsToCloud(records: retryRecords, - remainingRetries: remainingRetries - 1) - .done { _ in - resolver.fulfill(()) - }.catch { (error) in - resolver.reject(error) - }.retainUntilComplete() - } - - let outcome = outcomeForCloudKitError(error: error, - remainingRetries: remainingRetries, - label: "Save Records[\(recordNames.count)]") - switch outcome { - case .success: - resolver.fulfill(()) - case .failureDoNotRetry(let outcomeError): - resolver.reject(outcomeError) - case .failureRetryAfterDelay(let retryDelay): - DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + retryDelay, execute: { - retry() - }) - case .failureRetryWithoutDelay: - DispatchQueue.global().async { - retry() - } - case .unknownItem: - owsFailDebug("unexpected CloudKit response.") - resolver.reject(invalidServiceResponseError()) - } - } - saveOperation.isAtomic = false - saveOperation.savePolicy = .allKeys - - // TODO: use perRecordProgressBlock and perRecordCompletionBlock. -// open var perRecordProgressBlock: ((CKRecord, Double) -> Void)? -// open var perRecordCompletionBlock: ((CKRecord, Error?) -> Void)? - - // These APIs are only available in iOS 9.3 and later. - if #available(iOS 9.3, *) { - saveOperation.isLongLived = true - saveOperation.qualityOfService = .background - } - - database().add(saveOperation) - } - } - - // MARK: - Delete - - @objc - public class func deleteRecordsFromCloud(recordNames: [String], - success: @escaping () -> Void, - failure: @escaping (Error) -> Void) { - deleteRecordsFromCloud(recordNames: recordNames, - remainingRetries: maxRetries, - success: success, - failure: failure) - } - - private class func deleteRecordsFromCloud(recordNames: [String], - remainingRetries: Int, - success: @escaping () -> Void, - failure: @escaping (Error) -> Void) { - - let recordIDs = recordNames.map { CKRecord.ID(recordName: $0) } - let deleteOperation = CKModifyRecordsOperation(recordsToSave: nil, recordIDsToDelete: recordIDs) - deleteOperation.modifyRecordsCompletionBlock = { (records, recordIds, error) in - - let outcome = outcomeForCloudKitError(error: error, - remainingRetries: remainingRetries, - label: "Delete Records") - switch outcome { - case .success: - success() - case .failureDoNotRetry(let outcomeError): - failure(outcomeError) - case .failureRetryAfterDelay(let retryDelay): - DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + retryDelay, execute: { - deleteRecordsFromCloud(recordNames: recordNames, - remainingRetries: remainingRetries - 1, - success: success, - failure: failure) - }) - case .failureRetryWithoutDelay: - DispatchQueue.global().async { - deleteRecordsFromCloud(recordNames: recordNames, - remainingRetries: remainingRetries - 1, - success: success, - failure: failure) - } - case .unknownItem: - owsFailDebug("unexpected CloudKit response.") - failure(invalidServiceResponseError()) - } - } - database().add(deleteOperation) - } - - // MARK: - Exists? - - private class func checkForFileInCloud(recordName: String, - remainingRetries: Int) -> Promise { - - Logger.verbose("checkForFileInCloud \(recordName)") - - let (promise, resolver) = Promise.pending() - - let recordId = CKRecord.ID(recordName: recordName) - let fetchOperation = CKFetchRecordsOperation(recordIDs: [recordId ]) - // Don't download the file; we're just using the fetch to check whether or - // not this record already exists. - fetchOperation.desiredKeys = [] - fetchOperation.perRecordCompletionBlock = { (record, recordId, error) in - - let outcome = outcomeForCloudKitError(error: error, - remainingRetries: remainingRetries, - label: "Check for Record") - switch outcome { - case .success: - guard let record = record else { - owsFailDebug("missing fetching record.") - resolver.reject(invalidServiceResponseError()) - return - } - // Record found. - resolver.fulfill(record) - case .failureDoNotRetry(let outcomeError): - resolver.reject(outcomeError) - case .failureRetryAfterDelay(let retryDelay): - DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + retryDelay, execute: { - checkForFileInCloud(recordName: recordName, - remainingRetries: remainingRetries - 1) - .done { (record) in - resolver.fulfill(record) - }.catch { (error) in - resolver.reject(error) - }.retainUntilComplete() - }) - case .failureRetryWithoutDelay: - DispatchQueue.global().async { - checkForFileInCloud(recordName: recordName, - remainingRetries: remainingRetries - 1) - .done { (record) in - resolver.fulfill(record) - }.catch { (error) in - resolver.reject(error) - }.retainUntilComplete() - } - case .unknownItem: - // Record not found. - resolver.fulfill(nil) - } - } - database().add(fetchOperation) - return promise - } - - @objc - public class func checkForManifestInCloudObjc(recipientId: String) -> AnyPromise { - return AnyPromise(checkForManifestInCloud(recipientId: recipientId)) - } - - public class func checkForManifestInCloud(recipientId: String) -> Promise { - - let recordName = recordNameForManifest(recipientId: recipientId) - return checkForFileInCloud(recordName: recordName, - remainingRetries: maxRetries) - .map { (record) in - return record != nil - } - } - - @objc - public class func allRecipientIdsWithManifestsInCloud(success: @escaping ([String]) -> Void, - failure: @escaping (Error) -> Void) { - - let processResults = { (recordNames: [String]) in - DispatchQueue.global().async { - let manifestRecordNames = recordNames.filter({ (recordName) -> Bool in - self.isManifest(recordName: recordName) - }) - let recipientIds = self.recipientIds(forRecordNames: manifestRecordNames) - success(recipientIds) - } - } - - let query = CKQuery(recordType: signalBackupRecordType, predicate: NSPredicate(value: true)) - // Fetch the first page of results for this query. - fetchAllRecordNamesStep(recipientId: nil, - query: query, - previousRecordNames: [String](), - cursor: nil, - remainingRetries: maxRetries, - success: processResults, - failure: failure) - } - - @objc - public class func fetchAllRecordNames(recipientId: String, - success: @escaping ([String]) -> Void, - failure: @escaping (Error) -> Void) { - - let query = CKQuery(recordType: signalBackupRecordType, predicate: NSPredicate(value: true)) - // Fetch the first page of results for this query. - fetchAllRecordNamesStep(recipientId: recipientId, - query: query, - previousRecordNames: [String](), - cursor: nil, - remainingRetries: maxRetries, - success: success, - failure: failure) - } - - private class func fetchAllRecordNamesStep(recipientId: String?, - query: CKQuery, - previousRecordNames: [String], - cursor: CKQueryOperation.Cursor?, - remainingRetries: Int, - success: @escaping ([String]) -> Void, - failure: @escaping (Error) -> Void) { - - var allRecordNames = previousRecordNames - - let queryOperation = CKQueryOperation(query: query) - // If this isn't the first page of results for this query, resume - // where we left off. - queryOperation.cursor = cursor - // Don't download the file; we're just using the query to get a list of record names. - queryOperation.desiredKeys = [] - queryOperation.recordFetchedBlock = { (record) in - assert(record.recordID.recordName.count > 0) - - let recordName = record.recordID.recordName - - if let recipientId = recipientId { - let prefix = recordNamePrefix(forRecipientId: recipientId) - guard recordName.hasPrefix(prefix) else { - Logger.info("Ignoring record: \(recordName)") - return - } - } - - allRecordNames.append(recordName) - } - queryOperation.queryCompletionBlock = { (cursor, error) in - - let outcome = outcomeForCloudKitError(error: error, - remainingRetries: remainingRetries, - label: "Fetch All Records") - switch outcome { - case .success: - if let cursor = cursor { - Logger.verbose("fetching more record names \(allRecordNames.count).") - // There are more pages of results, continue fetching. - fetchAllRecordNamesStep(recipientId: recipientId, - query: query, - previousRecordNames: allRecordNames, - cursor: cursor, - remainingRetries: maxRetries, - success: success, - failure: failure) - return - } - Logger.info("fetched \(allRecordNames.count) record names.") - success(allRecordNames) - case .failureDoNotRetry(let outcomeError): - failure(outcomeError) - case .failureRetryAfterDelay(let retryDelay): - DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + retryDelay, execute: { - fetchAllRecordNamesStep(recipientId: recipientId, - query: query, - previousRecordNames: allRecordNames, - cursor: cursor, - remainingRetries: remainingRetries - 1, - success: success, - failure: failure) - }) - case .failureRetryWithoutDelay: - DispatchQueue.global().async { - fetchAllRecordNamesStep(recipientId: recipientId, - query: query, - previousRecordNames: allRecordNames, - cursor: cursor, - remainingRetries: remainingRetries - 1, - success: success, - failure: failure) - } - case .unknownItem: - owsFailDebug("unexpected CloudKit response.") - failure(invalidServiceResponseError()) - } - } - database().add(queryOperation) - } - - // MARK: - Download - - @objc - public class func downloadManifestFromCloudObjc(recipientId: String) -> AnyPromise { - return AnyPromise(downloadManifestFromCloud(recipientId: recipientId)) - } - - public class func downloadManifestFromCloud(recipientId: String) -> Promise { - - let recordName = recordNameForManifest(recipientId: recipientId) - return downloadDataFromCloud(recordName: recordName) - } - - @objc - public class func downloadDataFromCloudObjc(recordName: String) -> AnyPromise { - return AnyPromise(downloadDataFromCloud(recordName: recordName)) - } - - public class func downloadDataFromCloud(recordName: String) -> Promise { - return downloadFromCloud(recordName: recordName, - remainingRetries: maxRetries) - .map { (asset) -> Data in - guard let fileURL = asset.fileURL else { - throw invalidServiceResponseError() - } - return try Data(contentsOf: fileURL) - } - } - - @objc - public class func downloadFileFromCloudObjc(recordName: String, - toFileUrl: URL) -> AnyPromise { - return AnyPromise(downloadFileFromCloud(recordName: recordName, - toFileUrl: toFileUrl)) - } - - public class func downloadFileFromCloud(recordName: String, - toFileUrl: URL) -> Promise { - - return downloadFromCloud(recordName: recordName, - remainingRetries: maxRetries) - .done { asset in - guard let fileURL = asset.fileURL else { - throw invalidServiceResponseError() - } - try FileManager.default.copyItem(at: fileURL, to: toFileUrl) - } - } - - // We return the CKAsset and not its fileUrl because - // CloudKit offers no guarantees around how long it'll - // keep around the underlying file. Presumably we can - // defer cleanup by maintaining a strong reference to - // the asset. - private class func downloadFromCloud(recordName: String, - remainingRetries: Int) -> Promise { - - Logger.verbose("downloadFromCloud \(recordName)") - - let (promise, resolver) = Promise.pending() - - let recordId = CKRecord.ID(recordName: recordName) - let fetchOperation = CKFetchRecordsOperation(recordIDs: [recordId ]) - // Download all keys for this record. - fetchOperation.perRecordCompletionBlock = { (record, recordId, error) in - - let outcome = outcomeForCloudKitError(error: error, - remainingRetries: remainingRetries, - label: "Download Record") - switch outcome { - case .success: - guard let record = record else { - Logger.error("missing fetching record.") - resolver.reject(invalidServiceResponseError()) - return - } - guard let asset = record[payloadKey] as? CKAsset else { - Logger.error("record missing payload.") - resolver.reject(invalidServiceResponseError()) - return - } - resolver.fulfill(asset) - case .failureDoNotRetry(let outcomeError): - resolver.reject(outcomeError) - case .failureRetryAfterDelay(let retryDelay): - DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + retryDelay, execute: { - downloadFromCloud(recordName: recordName, - remainingRetries: remainingRetries - 1) - .done { (asset) in - resolver.fulfill(asset) - }.catch { (error) in - resolver.reject(error) - }.retainUntilComplete() - }) - case .failureRetryWithoutDelay: - DispatchQueue.global().async { - downloadFromCloud(recordName: recordName, - remainingRetries: remainingRetries - 1) - .done { (asset) in - resolver.fulfill(asset) - }.catch { (error) in - resolver.reject(error) - }.retainUntilComplete() - } - case .unknownItem: - Logger.error("missing fetching record.") - resolver.reject(invalidServiceResponseError()) - } - } - database().add(fetchOperation) - - return promise - } - - // MARK: - Access - - @objc public enum BackupError: Int, Error { - case couldNotDetermineAccountStatus - case noAccount - case restrictedAccountStatus - } - - @objc - public class func ensureCloudKitAccessObjc() -> AnyPromise { - return AnyPromise(ensureCloudKitAccess()) - } - - public class func ensureCloudKitAccess() -> Promise { - let (promise, resolver) = Promise.pending() - CKContainer.default().accountStatus { (accountStatus, error) in - if let error = error { - Logger.error("Unknown error: \(String(describing: error)).") - resolver.reject(error) - return - } - switch accountStatus { - case .couldNotDetermine: - Logger.error("could not determine CloudKit account status: \(String(describing: error)).") - resolver.reject(BackupError.couldNotDetermineAccountStatus) - case .noAccount: - Logger.error("no CloudKit account.") - resolver.reject(BackupError.noAccount) - case .restricted: - Logger.error("restricted CloudKit account.") - resolver.reject(BackupError.restrictedAccountStatus) - case .available: - Logger.verbose("CloudKit access okay.") - resolver.fulfill(()) - default: resolver.fulfill(()) - } - } - return promise - } - - @objc - public class func errorMessage(forCloudKitAccessError error: Error) -> String { - if let backupError = error as? BackupError { - Logger.error("Backup error: \(String(describing: backupError)).") - switch backupError { - case .couldNotDetermineAccountStatus: - return NSLocalizedString("CLOUDKIT_STATUS_COULD_NOT_DETERMINE", comment: "Error indicating that the app could not determine that user's iCloud account status") - case .noAccount: - return NSLocalizedString("CLOUDKIT_STATUS_NO_ACCOUNT", comment: "Error indicating that user does not have an iCloud account.") - case .restrictedAccountStatus: - return NSLocalizedString("CLOUDKIT_STATUS_RESTRICTED", comment: "Error indicating that the app was prevented from accessing the user's iCloud account.") - } - } else { - Logger.error("Unknown error: \(String(describing: error)).") - return NSLocalizedString("CLOUDKIT_STATUS_COULD_NOT_DETERMINE", comment: "Error indicating that the app could not determine that user's iCloud account status") - } - } - - // MARK: - Retry - - private enum APIOutcome { - case success - case failureDoNotRetry(error:Error) - case failureRetryAfterDelay(retryDelay: TimeInterval) - case failureRetryWithoutDelay - // This only applies to fetches. - case unknownItem - } - - private class func outcomeForCloudKitError(error: Error?, - remainingRetries: Int, - label: String) -> APIOutcome { - if let error = error as? CKError { - if error.code == CKError.unknownItem { - // This is not always an error for our purposes. - Logger.verbose("\(label) unknown item.") - return .unknownItem - } - - Logger.error("\(label) failed: \(error)") - - if remainingRetries < 1 { - Logger.verbose("\(label) no more retries.") - return .failureDoNotRetry(error:error) - } - - if #available(iOS 11, *) { - if error.code == CKError.serverResponseLost { - Logger.verbose("\(label) retry without delay.") - return .failureRetryWithoutDelay - } - } - - switch error { - case CKError.requestRateLimited, CKError.serviceUnavailable, CKError.zoneBusy: - let retryDelay = error.retryAfterSeconds ?? 3.0 - Logger.verbose("\(label) retry with delay: \(retryDelay).") - return .failureRetryAfterDelay(retryDelay:retryDelay) - case CKError.networkFailure: - Logger.verbose("\(label) retry without delay.") - return .failureRetryWithoutDelay - default: - Logger.verbose("\(label) unknown CKError.") - return .failureDoNotRetry(error:error) - } - } else if let error = error { - Logger.error("\(label) failed: \(error)") - if remainingRetries < 1 { - Logger.verbose("\(label) no more retries.") - return .failureDoNotRetry(error:error) - } - Logger.verbose("\(label) unknown error.") - return .failureDoNotRetry(error:error) - } else { - Logger.info("\(label) succeeded.") - return .success - } - } - - // MARK: - - - @objc - public class func setup() { - cancelAllLongLivedOperations() - } - - private class func cancelAllLongLivedOperations() { - // These APIs are only available in iOS 9.3 and later. - guard #available(iOS 9.3, *) else { - return - } - - let container = CKContainer.default() - container.fetchAllLongLivedOperationIDs { (operationIds, error) in - if let error = error { - Logger.error("Could not get all long lived operations: \(error)") - return - } - guard let operationIds = operationIds else { - Logger.error("No operation ids.") - return - } - - for operationId in operationIds { - container.fetchLongLivedOperation(withID: operationId, completionHandler: { (operation, error) in - if let error = error { - Logger.error("Could not get long lived operation [\(operationId)]: \(error)") - return - } - guard let operation = operation else { - Logger.error("No operation.") - return - } - operation.cancel() - }) - } - } - } -} diff --git a/Session/Backups/OWSBackupExportJob.h b/Session/Backups/OWSBackupExportJob.h deleted file mode 100644 index 8fa9b475e..000000000 --- a/Session/Backups/OWSBackupExportJob.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSBackupJob.h" - -NS_ASSUME_NONNULL_BEGIN - -@interface OWSBackupExportJob : OWSBackupJob - -- (void)start; - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Backups/OWSBackupExportJob.m b/Session/Backups/OWSBackupExportJob.m deleted file mode 100644 index 304570ac0..000000000 --- a/Session/Backups/OWSBackupExportJob.m +++ /dev/null @@ -1,1181 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSBackupExportJob.h" -#import "OWSBackupIO.h" -#import "OWSDatabaseMigration.h" -#import "Session-Swift.h" -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import - -@import CloudKit; - -NS_ASSUME_NONNULL_BEGIN - -@class OWSAttachmentExport; - -@interface OWSBackupExportItem : NSObject - -@property (nonatomic) OWSBackupEncryptedItem *encryptedItem; - -@property (nonatomic) NSString *recordName; - -// This property is optional and is only used for attachments. -@property (nonatomic, nullable) OWSAttachmentExport *attachmentExport; - -// This property is optional. -// -// See comments in `OWSBackupIO`. -@property (nonatomic, nullable) NSNumber *uncompressedDataLength; - -- (instancetype)init; - -@end - -#pragma mark - - -@implementation OWSBackupExportItem - -- (instancetype)initWithEncryptedItem:(OWSBackupEncryptedItem *)encryptedItem -{ - if (!(self = [super init])) { - return self; - } - - OWSAssertDebug(encryptedItem); - - self.encryptedItem = encryptedItem; - - return self; -} - -@end - -#pragma mark - - -// Used to serialize database snapshot contents. -// Writes db entities using protobufs into snapshot fragments. -// Snapshot fragments are compressed (they compress _very well_, -// around 20x smaller) then encrypted. Ordering matters in -// snapshot contents (entities should be restored in the same -// order they are serialized), so we are always careful to preserve -// ordering of entities within a snapshot AND ordering of snapshot -// fragments within a bakckup. -// -// This stream is used to write entities one at a time and takes -// care of sharding them into fragments, compressing and encrypting -// those fragments. Fragment size is fixed to reduce worst case -// memory usage. -@interface OWSDBExportStream : NSObject - -@property (nonatomic) OWSBackupIO *backupIO; - -@property (nonatomic) NSMutableArray *exportItems; - -@property (nonatomic, nullable) SignalIOSProtoBackupSnapshotBuilder *backupSnapshotBuilder; - -@property (nonatomic) NSUInteger cachedItemCount; - -@property (nonatomic) NSUInteger totalItemCount; - -- (instancetype)init NS_UNAVAILABLE; - -@end - -#pragma mark - - -@implementation OWSDBExportStream - -- (instancetype)initWithBackupIO:(OWSBackupIO *)backupIO -{ - if (!(self = [super init])) { - return self; - } - - OWSAssertDebug(backupIO); - - self.exportItems = [NSMutableArray new]; - self.backupIO = backupIO; - - return self; -} - - -// It isn't strictly necessary to capture the entity type (the importer doesn't -// use this state), but I think it'll be helpful to have around to future-proof -// this work, help with debugging issue, etc. -- (BOOL)writeObject:(NSObject *)object - collection:(NSString *)collection - key:(NSString *)key - entityType:(SignalIOSProtoBackupSnapshotBackupEntityType)entityType -{ - OWSAssertDebug(object); - OWSAssertDebug(collection.length > 0); - OWSAssertDebug(key.length > 0); - - NSData *_Nullable data = [NSKeyedArchiver archivedDataWithRootObject:object]; - if (!data) { - OWSFailDebug(@"couldn't serialize database object: %@", [object class]); - return NO; - } - - if (!self.backupSnapshotBuilder) { - self.backupSnapshotBuilder = [SignalIOSProtoBackupSnapshot builder]; - } - - SignalIOSProtoBackupSnapshotBackupEntityBuilder *entityBuilder = - [SignalIOSProtoBackupSnapshotBackupEntity builderWithType:entityType - entityData:data - collection:collection - key:key]; - - NSError *error; - SignalIOSProtoBackupSnapshotBackupEntity *_Nullable entity = [entityBuilder buildAndReturnError:&error]; - if (!entity || error) { - OWSFailDebug(@"couldn't build proto: %@", error); - return NO; - } - - [self.backupSnapshotBuilder addEntity:entity]; - - self.cachedItemCount = self.cachedItemCount + 1; - self.totalItemCount = self.totalItemCount + 1; - - static const int kMaxDBSnapshotSize = 1000; - if (self.cachedItemCount > kMaxDBSnapshotSize) { - @autoreleasepool { - return [self flush]; - } - } - - return YES; -} - -// Write cached data to disk, if necessary. -// -// Returns YES on success. -- (BOOL)flush -{ - if (!self.backupSnapshotBuilder) { - // No data to flush to disk. - return YES; - } - - // Try to release allocated buffers ASAP. - @autoreleasepool { - NSError *error; - NSData *_Nullable uncompressedData = [self.backupSnapshotBuilder buildSerializedDataAndReturnError:&error]; - if (!uncompressedData || error) { - OWSFailDebug(@"couldn't serialize proto: %@", error); - return NO; - } - - NSUInteger uncompressedDataLength = uncompressedData.length; - self.backupSnapshotBuilder = nil; - self.cachedItemCount = 0; - if (!uncompressedData) { - OWSFailDebug(@"couldn't convert database snapshot to data."); - return NO; - } - - NSData *compressedData = [self.backupIO compressData:uncompressedData]; - - OWSBackupEncryptedItem *_Nullable encryptedItem = [self.backupIO encryptDataAsTempFile:compressedData]; - if (!encryptedItem) { - OWSFailDebug(@"couldn't encrypt database snapshot."); - return NO; - } - - OWSBackupExportItem *exportItem = [[OWSBackupExportItem alloc] initWithEncryptedItem:encryptedItem]; - exportItem.uncompressedDataLength = @(uncompressedDataLength); - [self.exportItems addObject:exportItem]; - } - - return YES; -} - -@end - -#pragma mark - - -// This class is used to: -// -// * Lazy-encrypt and eagerly cleanup attachment uploads. -// To reduce disk footprint of backup export process, -// we only want to have one attachment export on disk -// at a time. -@interface OWSAttachmentExport : NSObject - -@property (nonatomic) OWSBackupIO *backupIO; -@property (nonatomic) NSString *attachmentId; -@property (nonatomic) NSString *attachmentFilePath; -@property (nonatomic, nullable) NSString *relativeFilePath; -@property (nonatomic) OWSBackupEncryptedItem *encryptedItem; - -- (instancetype)init NS_UNAVAILABLE; - -@end - -#pragma mark - - -@implementation OWSAttachmentExport - -- (instancetype)initWithBackupIO:(OWSBackupIO *)backupIO - attachmentId:(NSString *)attachmentId - attachmentFilePath:(NSString *)attachmentFilePath -{ - if (!(self = [super init])) { - return self; - } - - OWSAssertDebug(backupIO); - OWSAssertDebug(attachmentId.length > 0); - OWSAssertDebug(attachmentFilePath.length > 0); - - self.backupIO = backupIO; - self.attachmentId = attachmentId; - self.attachmentFilePath = attachmentFilePath; - - return self; -} - -- (void)dealloc -{ - // Surface memory leaks by logging the deallocation. - OWSLogVerbose(@"Dealloc: %@", self.class); - - [self cleanUp]; -} - -// On success, encryptedItem will be non-nil. -// -// Returns YES on success. -- (BOOL)prepareForUpload -{ - OWSAssertDebug(self.attachmentId.length > 0); - OWSAssertDebug(self.attachmentFilePath.length > 0); - - NSString *attachmentsDirPath = [TSAttachmentStream attachmentsFolder]; - if (![self.attachmentFilePath hasPrefix:attachmentsDirPath]) { - OWSFailDebug(@"attachment has unexpected path: %@", self.attachmentFilePath); - return NO; - } - NSString *relativeFilePath = [self.attachmentFilePath substringFromIndex:attachmentsDirPath.length]; - NSString *pathSeparator = @"/"; - if ([relativeFilePath hasPrefix:pathSeparator]) { - relativeFilePath = [relativeFilePath substringFromIndex:pathSeparator.length]; - } - self.relativeFilePath = relativeFilePath; - - OWSBackupEncryptedItem *_Nullable encryptedItem = [self.backupIO encryptFileAsTempFile:self.attachmentFilePath]; - if (!encryptedItem) { - OWSFailDebug(@"attachment could not be encrypted: %@", self.attachmentFilePath); - return NO; - } - self.encryptedItem = encryptedItem; - return YES; -} - -// Returns YES on success. -- (BOOL)cleanUp -{ - return [OWSFileSystem deleteFileIfExists:self.encryptedItem.filePath]; -} - -@end - -#pragma mark - - -@interface OWSBackupExportJob () - -@property (nonatomic, nullable) OWSBackgroundTask *backgroundTask; - -@property (nonatomic) OWSBackupIO *backupIO; - -@property (nonatomic) NSMutableArray *unsavedDatabaseItems; - -@property (nonatomic) NSMutableArray *unsavedAttachmentExports; - -@property (nonatomic) NSMutableArray *savedDatabaseItems; - -@property (nonatomic) NSMutableArray *savedAttachmentItems; - -@property (nonatomic, nullable) OWSBackupExportItem *localProfileAvatarItem; - -@property (nonatomic, nullable) OWSBackupExportItem *manifestItem; - -// If we are replacing an existing backup, we use some of its contents for continuity. -@property (nonatomic, nullable) NSSet *lastValidRecordNames; - -@property (nonatomic, nullable) YapDatabaseConnection *dbConnection; - -@end - -#pragma mark - - -@implementation OWSBackupExportJob - -#pragma mark - Dependencies - -- (OWSPrimaryStorage *)primaryStorage -{ - OWSAssertDebug(SSKEnvironment.shared.primaryStorage); - - return SSKEnvironment.shared.primaryStorage; -} - -- (OWSBackup *)backup -{ - OWSAssertDebug(AppEnvironment.shared.backup); - - return AppEnvironment.shared.backup; -} - -- (OWSProfileManager *)profileManager -{ - return [OWSProfileManager sharedManager]; -} - -#pragma mark - - -- (void)start -{ - OWSAssertIsOnMainThread(); - - OWSLogInfo(@""); - - self.backgroundTask = [OWSBackgroundTask backgroundTaskWithLabelStr:__PRETTY_FUNCTION__]; - - [self updateProgressWithDescription:nil progress:nil]; - - self.dbConnection = self.primaryStorage.newDatabaseConnection; - - [[self.backup ensureCloudKitAccess] - .thenInBackground(^{ - [self updateProgressWithDescription:NSLocalizedString(@"BACKUP_EXPORT_PHASE_CONFIGURATION", - @"Indicates that the backup export is being configured.") - progress:nil]; - - return [self configureExport]; - }) - .thenInBackground(^{ - return [self fetchAllRecords]; - }) - .thenInBackground(^{ - [self updateProgressWithDescription:NSLocalizedString(@"BACKUP_EXPORT_PHASE_EXPORT", - @"Indicates that the backup export data is being exported.") - progress:nil]; - - return [self exportDatabase]; - }) - .thenInBackground(^{ - return [self saveToCloud]; - }) - .thenInBackground(^{ - return [self cleanUp]; - }) - .thenInBackground(^{ - [self succeed]; - }) - .catch(^(NSError *error) { - OWSFailDebug(@"Backup export failed with error: %@.", error); - - [self failWithErrorDescription: - NSLocalizedString(@"BACKUP_EXPORT_ERROR_COULD_NOT_EXPORT", - @"Error indicating the backup export could not export the user's data.")]; - }) retainUntilComplete]; -} - -- (AnyPromise *)configureExport -{ - OWSLogVerbose(@""); - - if (self.isComplete) { - return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup export no longer active.")]; - } - - if (![self ensureJobTempDir]) { - OWSFailDebug(@"Could not create jobTempDirPath."); - return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Could not create jobTempDirPath.")]; - } - - self.backupIO = [[OWSBackupIO alloc] initWithJobTempDirPath:self.jobTempDirPath]; - - return [AnyPromise promiseWithValue:@(1)]; -} - -- (AnyPromise *)fetchAllRecords -{ - OWSLogVerbose(@""); - - if (self.isComplete) { - return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup export no longer active.")]; - } - - return [AnyPromise promiseWithResolverBlock:^(PMKResolver resolve) { - [OWSBackupAPI fetchAllRecordNamesWithRecipientId:self.recipientId - success:^(NSArray *recordNames) { - if (self.isComplete) { - return resolve(OWSBackupErrorWithDescription(@"Backup export no longer active.")); - } - self.lastValidRecordNames = [NSSet setWithArray:recordNames]; - resolve(@(1)); - } - failure:^(NSError *error) { - resolve(error); - }]; - }]; -} - -- (AnyPromise *)exportDatabase -{ - OWSAssertDebug(self.backupIO); - - OWSLogVerbose(@""); - - if (self.isComplete) { - return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup export no longer active.")]; - } - - return [AnyPromise promiseWithResolverBlock:^(PMKResolver resolve) { - if (![self performExportDatabase]) { - NSError *error = OWSBackupErrorWithDescription(@"Backup export failed."); - return resolve(error); - } - - resolve(@(1)); - }]; -} - -- (BOOL)performExportDatabase -{ - OWSAssertDebug(self.backupIO); - - OWSLogVerbose(@""); - - [self updateProgressWithDescription:NSLocalizedString(@"BACKUP_EXPORT_PHASE_DATABASE_EXPORT", - @"Indicates that the database data is being exported.") - progress:nil]; - - OWSDBExportStream *exportStream = [[OWSDBExportStream alloc] initWithBackupIO:self.backupIO]; - - __block BOOL aborted = NO; - typedef BOOL (^EntityFilter)(id object); - typedef NSUInteger (^ExportBlock)(YapDatabaseReadTransaction *, - NSString *, - Class, - EntityFilter _Nullable, - SignalIOSProtoBackupSnapshotBackupEntityType); - NSMutableSet *exportedCollections = [NSMutableSet new]; - ExportBlock exportEntities = ^(YapDatabaseReadTransaction *transaction, - NSString *collection, - Class expectedClass, - EntityFilter _Nullable filter, - SignalIOSProtoBackupSnapshotBackupEntityType entityType) { - [exportedCollections addObject:collection]; - - __block NSUInteger count = 0; - [transaction enumerateKeysAndObjectsInCollection:collection - usingBlock:^(NSString *key, id object, BOOL *stop) { - if (self.isComplete) { - *stop = YES; - return; - } - if (filter && !filter(object)) { - return; - } - if (![object isKindOfClass:expectedClass]) { - OWSFailDebug(@"unexpected class: %@", [object class]); - return; - } - NSObject *entity = object; - count++; - - if ([entity isKindOfClass:[TSAttachmentStream class]]) { - // Convert attachment streams to pointers, - // since we'll need to restore them. - TSAttachmentStream *attachmentStream - = (TSAttachmentStream *)entity; - TSAttachmentPointer *attachmentPointer = - [[TSAttachmentPointer alloc] - initForRestoreWithAttachmentStream:attachmentStream]; - entity = attachmentPointer; - } - - if (![exportStream writeObject:entity - collection:collection - key:key - entityType:entityType]) { - *stop = YES; - aborted = YES; - return; - } - }]; - return count; - }; - - __block NSUInteger copiedThreads = 0; - __block NSUInteger copiedInteractions = 0; - __block NSUInteger copiedAttachments = 0; - __block NSUInteger copiedMigrations = 0; - __block NSUInteger copiedMisc = 0; - self.unsavedAttachmentExports = [NSMutableArray new]; - [self.dbConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - copiedThreads = exportEntities(transaction, - [TSThread collection], - [TSThread class], - nil, - SignalIOSProtoBackupSnapshotBackupEntityTypeThread); - if (aborted) { - return; - } - - copiedAttachments = exportEntities(transaction, - [TSAttachment collection], - [TSAttachment class], - ^(id object) { - if (![object isKindOfClass:[TSAttachmentStream class]]) { - // No need to backup the contents (e.g. the file on disk) - // of attachment pointers. - // After a restore, users will be able "tap to retry". - return YES; - } - TSAttachmentStream *attachmentStream = object; - NSString *_Nullable filePath = attachmentStream.originalFilePath; - if (!filePath || ![NSFileManager.defaultManager fileExistsAtPath:filePath]) { - OWSFailDebug(@"attachment is missing file."); - return NO; - } - - // OWSAttachmentExport is used to lazily write an encrypted copy of the - // attachment to disk. - OWSAttachmentExport *attachmentExport = - [[OWSAttachmentExport alloc] initWithBackupIO:self.backupIO - attachmentId:attachmentStream.uniqueId - attachmentFilePath:filePath]; - [self.unsavedAttachmentExports addObject:attachmentExport]; - - return YES; - }, - SignalIOSProtoBackupSnapshotBackupEntityTypeAttachment); - if (aborted) { - return; - } - - // Interactions refer to threads and attachments, so copy after them. - copiedInteractions = exportEntities(transaction, - [TSInteraction collection], - [TSInteraction class], - ^(id object) { - // Ignore disappearing messages. - if ([object isKindOfClass:[TSMessage class]]) { - TSMessage *message = object; - if (message.isExpiringMessage) { - return NO; - } - } - TSInteraction *interaction = object; - // Ignore dynamic interactions. - if (interaction.isDynamicInteraction) { - return NO; - } - return YES; - }, - SignalIOSProtoBackupSnapshotBackupEntityTypeInteraction); - if (aborted) { - return; - } - - copiedMigrations = exportEntities(transaction, - [OWSDatabaseMigration collection], - [OWSDatabaseMigration class], - nil, - SignalIOSProtoBackupSnapshotBackupEntityTypeMigration); - if (aborted) { - return; - } - - for (NSString *collection in MiscCollectionsToBackup()) { - copiedMisc += exportEntities( - transaction, collection, [NSObject class], nil, SignalIOSProtoBackupSnapshotBackupEntityTypeMisc); - if (aborted) { - return; - } - } - - for (NSString *collection in [exportedCollections.allObjects sortedArrayUsingSelector:@selector(compare:)]) { - OWSLogVerbose(@"Exported collection: %@", collection); - } - OWSLogVerbose(@"Exported collections: %lu", (unsigned long)exportedCollections.count); - - NSSet *allCollections = [NSSet setWithArray:transaction.allCollections]; - NSMutableSet *unexportedCollections = [allCollections mutableCopy]; - [unexportedCollections minusSet:exportedCollections]; - for (NSString *collection in [unexportedCollections.allObjects sortedArrayUsingSelector:@selector(compare:)]) { - OWSLogVerbose(@"Unexported collection: %@", collection); - } - OWSLogVerbose(@"Unexported collections: %lu", (unsigned long)unexportedCollections.count); - }]; - - if (aborted || self.isComplete) { - return NO; - } - - @autoreleasepool { - if (![exportStream flush]) { - OWSFailDebug(@"Could not flush database snapshots."); - return NO; - } - } - - self.unsavedDatabaseItems = [exportStream.exportItems mutableCopy]; - - // TODO: Should we do a database checkpoint? - - OWSLogInfo(@"copiedThreads: %zd", copiedThreads); - OWSLogInfo(@"copiedMessages: %zd", copiedInteractions); - OWSLogInfo(@"copiedAttachments: %zd", copiedAttachments); - OWSLogInfo(@"copiedMigrations: %zd", copiedMigrations); - OWSLogInfo(@"copiedMisc: %zd", copiedMisc); - OWSLogInfo(@"copiedEntities: %zd", exportStream.totalItemCount); - - return YES; -} - -- (AnyPromise *)saveToCloud -{ - OWSLogVerbose(@""); - - if (self.isComplete) { - return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup export no longer active.")]; - } - - self.savedDatabaseItems = [NSMutableArray new]; - self.savedAttachmentItems = [NSMutableArray new]; - - unsigned long long totalFileSize = 0; - NSUInteger totalFileCount = 0; - { - unsigned long long databaseFileSize = 0; - for (OWSBackupExportItem *item in self.unsavedDatabaseItems) { - unsigned long long fileSize = - [OWSFileSystem fileSizeOfPath:item.encryptedItem.filePath].unsignedLongLongValue; - ows_add_overflow(databaseFileSize, fileSize, &databaseFileSize); - } - OWSLogInfo(@"exporting %@: count: %zd, bytes: %llu.", - @"database items", - self.unsavedDatabaseItems.count, - databaseFileSize); - ows_add_overflow(totalFileSize, databaseFileSize, &totalFileSize); - ows_add_overflow(totalFileCount, self.unsavedDatabaseItems.count, &totalFileCount); - } - { - unsigned long long attachmentFileSize = 0; - for (OWSAttachmentExport *attachmentExport in self.unsavedAttachmentExports) { - unsigned long long fileSize = - [OWSFileSystem fileSizeOfPath:attachmentExport.attachmentFilePath].unsignedLongLongValue; - ows_add_overflow(attachmentFileSize, fileSize, &attachmentFileSize); - } - OWSLogInfo(@"exporting %@: count: %zd, bytes: %llu.", - @"attachment items", - self.unsavedAttachmentExports.count, - attachmentFileSize); - ows_add_overflow(totalFileSize, attachmentFileSize, &totalFileSize); - ows_add_overflow(totalFileCount, self.unsavedAttachmentExports.count, &totalFileSize); - } - OWSLogInfo(@"exporting %@: count: %zd, bytes: %llu.", @"all items", totalFileCount, totalFileSize); - - // Add one for the manifest - NSUInteger unsavedCount = (self.unsavedDatabaseItems.count + self.unsavedAttachmentExports.count + 1); - NSUInteger savedCount = (self.savedDatabaseItems.count + self.savedAttachmentItems.count); - // Ignore localProfileAvatarItem for now. - - CGFloat progress = (savedCount / (CGFloat)(unsavedCount + savedCount)); - [self updateProgressWithDescription:NSLocalizedString(@"BACKUP_EXPORT_PHASE_UPLOAD", - @"Indicates that the backup export data is being uploaded.") - progress:@(progress)]; - - // Save attachment files _before_ anything else, since they - // are the only reusable backup records. - return [self saveAttachmentFilesToCloud] - .thenInBackground(^{ - return [self saveDatabaseFilesToCloud]; - }) - .thenInBackground(^{ - return [self saveLocalProfileAvatarToCloud]; - }) - .thenInBackground(^{ - return [self saveManifestFileToCloud]; - }); -} - -// This method returns YES IFF "work was done and there might be more work to do". -- (AnyPromise *)saveDatabaseFilesToCloud -{ - if (self.isComplete) { - return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup export no longer active.")]; - } - - NSArray *items = [self.unsavedDatabaseItems copy]; - NSMutableArray *records = [NSMutableArray new]; - for (OWSBackupExportItem *item in items) { - OWSAssertDebug(item.encryptedItem.filePath.length > 0); - - NSString *recordName = - [OWSBackupAPI recordNameForEphemeralFileWithRecipientId:self.recipientId label:@"database"]; - CKRecord *record = - [OWSBackupAPI recordForFileUrl:[NSURL fileURLWithPath:item.encryptedItem.filePath] recordName:recordName]; - [records addObject:record]; - } - - // TODO: Expose progress. - return [OWSBackupAPI saveRecordsToCloudObjcWithRecords:records].thenInBackground(^{ - OWSAssertDebug(items.count == records.count); - NSUInteger count = MIN(items.count, records.count); - for (NSUInteger i = 0; i < count; i++) { - OWSBackupExportItem *item = items[i]; - CKRecord *record = records[i]; - - OWSAssertDebug(record.recordID.recordName.length > 0); - item.recordName = record.recordID.recordName; - } - - [self.savedDatabaseItems addObjectsFromArray:items]; - [self.unsavedDatabaseItems removeObjectsInArray:items]; - }); -} - -// This method returns YES IFF "work was done and there might be more work to do". -- (AnyPromise *)saveAttachmentFilesToCloud -{ - if (self.isComplete) { - return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup export no longer active.")]; - } - - AnyPromise *promise = [AnyPromise promiseWithValue:@(1)]; - NSMutableArray *items = [NSMutableArray new]; - NSMutableArray *records = [NSMutableArray new]; - - for (OWSAttachmentExport *attachmentExport in self.unsavedAttachmentExports) { - if ([self tryToSkipAttachmentUpload:attachmentExport]) { - continue; - } - - promise = promise.thenInBackground(^{ - @autoreleasepool { - // OWSAttachmentExport is used to lazily write an encrypted copy of the - // attachment to disk. - if (![attachmentExport prepareForUpload]) { - // Attachment files are non-critical so any error preparing them is recoverable. - return @(1); - } - OWSAssertDebug(attachmentExport.relativeFilePath.length > 0); - OWSAssertDebug(attachmentExport.encryptedItem); - } - - NSURL *_Nullable fileUrl = ^{ - if (attachmentExport.encryptedItem.filePath.length < 1) { - OWSLogError(@"attachment export missing temp file path"); - return (NSURL *)nil; - } - if (attachmentExport.relativeFilePath.length < 1) { - OWSLogError(@"attachment export missing relative file path"); - return (NSURL *)nil; - } - return [NSURL fileURLWithPath:attachmentExport.encryptedItem.filePath]; - }(); - - if (!fileUrl) { - // Attachment files are non-critical so any error preparing them is recoverable. - return @(1); - } - - NSString *recordName = - [OWSBackupAPI recordNameForPersistentFileWithRecipientId:self.recipientId - fileId:attachmentExport.attachmentId]; - CKRecord *record = [OWSBackupAPI recordForFileUrl:fileUrl recordName:recordName]; - [records addObject:record]; - [items addObject:attachmentExport]; - return @(1); - }); - } - - void (^cleanup)(void) = ^{ - for (OWSAttachmentExport *attachmentExport in items) { - if (![attachmentExport cleanUp]) { - OWSLogError(@"couldn't clean up attachment export."); - // Attachment files are non-critical so any error uploading them is recoverable. - } - } - }; - - // TODO: Expose progress. - return promise - .thenInBackground(^{ - return [OWSBackupAPI saveRecordsToCloudObjcWithRecords:records]; - }) - .thenInBackground(^{ - OWSAssertDebug(items.count == records.count); - NSUInteger count = MIN(items.count, records.count); - for (NSUInteger i = 0; i < count; i++) { - OWSAttachmentExport *attachmentExport = items[i]; - CKRecord *record = records[i]; - NSString *recordName = record.recordID.recordName; - OWSAssertDebug(recordName.length > 0); - - OWSBackupExportItem *exportItem = [OWSBackupExportItem new]; - exportItem.encryptedItem = attachmentExport.encryptedItem; - exportItem.recordName = recordName; - exportItem.attachmentExport = attachmentExport; - [self.savedAttachmentItems addObject:exportItem]; - - // Immediately save the record metadata to facilitate export resume. - OWSBackupFragment *backupFragment = [[OWSBackupFragment alloc] initWithUniqueId:recordName]; - backupFragment.recordName = recordName; - backupFragment.encryptionKey = exportItem.encryptedItem.encryptionKey; - backupFragment.relativeFilePath = attachmentExport.relativeFilePath; - backupFragment.attachmentId = attachmentExport.attachmentId; - backupFragment.uncompressedDataLength = exportItem.uncompressedDataLength; - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [backupFragment saveWithTransaction:transaction]; - }]; - - OWSLogVerbose(@"saved attachment: %@ as %@", - attachmentExport.attachmentFilePath, - attachmentExport.relativeFilePath); - } - }) - .thenInBackground(^{ - cleanup(); - }) - .catchInBackground(^(NSError *error) { - cleanup(); - - return error; - }); -} - -- (BOOL)tryToSkipAttachmentUpload:(OWSAttachmentExport *)attachmentExport -{ - if (!self.lastValidRecordNames) { - return NO; - } - - // Wherever possible, we do incremental backups and re-use fragments of the last - // backup and/or restore. - // Recycling fragments doesn't just reduce redundant network activity, - // it allows us to skip the local export work, i.e. encryption. - // To do so, we must preserve the metadata for these fragments. - // - // We check two things: - // - // * That we already know the metadata for this fragment (from a previous backup - // or restore). - // * That this record does in fact exist in our CloudKit database. - NSString *recordName = - [OWSBackupAPI recordNameForPersistentFileWithRecipientId:self.recipientId fileId:attachmentExport.attachmentId]; - OWSBackupFragment *_Nullable lastBackupFragment = [OWSBackupFragment fetchObjectWithUniqueID:recordName]; - if (!lastBackupFragment || ![self.lastValidRecordNames containsObject:recordName]) { - return NO; - } - - OWSAssertDebug(lastBackupFragment.encryptionKey.length > 0); - OWSAssertDebug(lastBackupFragment.relativeFilePath.length > 0); - - // Recycle the metadata from the last backup's manifest. - OWSBackupEncryptedItem *encryptedItem = [OWSBackupEncryptedItem new]; - encryptedItem.encryptionKey = lastBackupFragment.encryptionKey; - attachmentExport.encryptedItem = encryptedItem; - attachmentExport.relativeFilePath = lastBackupFragment.relativeFilePath; - - OWSBackupExportItem *exportItem = [OWSBackupExportItem new]; - exportItem.encryptedItem = attachmentExport.encryptedItem; - exportItem.recordName = recordName; - exportItem.attachmentExport = attachmentExport; - [self.savedAttachmentItems addObject:exportItem]; - - OWSLogVerbose( - @"recycled attachment: %@ as %@", attachmentExport.attachmentFilePath, attachmentExport.relativeFilePath); - return YES; -} - -- (AnyPromise *)saveLocalProfileAvatarToCloud -{ - if (self.isComplete) { - return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup export no longer active.")]; - } - - NSData *_Nullable localProfileAvatarData = nil; // TODO: self.profileManager.localProfileAvatarData; - if (localProfileAvatarData.length < 1) { - // No profile avatar to backup. - return [AnyPromise promiseWithValue:@(1)]; - } - OWSBackupEncryptedItem *_Nullable encryptedItem = - [self.backupIO encryptDataAsTempFile:localProfileAvatarData encryptionKey:self.delegate.backupEncryptionKey]; - if (!encryptedItem) { - return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Could not encrypt local profile avatar.")]; - } - - OWSBackupExportItem *exportItem = [OWSBackupExportItem new]; - exportItem.encryptedItem = encryptedItem; - - NSString *recordName = - [OWSBackupAPI recordNameForEphemeralFileWithRecipientId:self.recipientId label:@"local-profile-avatar"]; - CKRecord *record = - [OWSBackupAPI recordForFileUrl:[NSURL fileURLWithPath:encryptedItem.filePath] recordName:recordName]; - return [OWSBackupAPI saveRecordsToCloudObjcWithRecords:@[ record ]].thenInBackground(^{ - exportItem.recordName = recordName; - self.localProfileAvatarItem = exportItem; - }); -} - -- (AnyPromise *)saveManifestFileToCloud -{ - if (self.isComplete) { - return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup export no longer active.")]; - } - - OWSBackupEncryptedItem *_Nullable encryptedItem = [self writeManifestFile]; - if (!encryptedItem) { - return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Could not generate manifest.")]; - } - - OWSBackupExportItem *exportItem = [OWSBackupExportItem new]; - exportItem.encryptedItem = encryptedItem; - - - NSString *recordName = [OWSBackupAPI recordNameForManifestWithRecipientId:self.recipientId]; - CKRecord *record = - [OWSBackupAPI recordForFileUrl:[NSURL fileURLWithPath:encryptedItem.filePath] recordName:recordName]; - return [OWSBackupAPI saveRecordsToCloudObjcWithRecords:@[ record ]].thenInBackground(^{ - exportItem.recordName = recordName; - self.manifestItem = exportItem; - }); -} - -- (nullable OWSBackupEncryptedItem *)writeManifestFile -{ - OWSAssertDebug(self.savedDatabaseItems.count > 0); - OWSAssertDebug(self.savedAttachmentItems); - OWSAssertDebug(self.jobTempDirPath.length > 0); - OWSAssertDebug(self.backupIO); - - NSMutableDictionary *json = [@{ - kOWSBackup_ManifestKey_DatabaseFiles : [self jsonForItems:self.savedDatabaseItems], - kOWSBackup_ManifestKey_AttachmentFiles : [self jsonForItems:self.savedAttachmentItems], - } mutableCopy]; - - NSString *_Nullable localProfileName = [[LKStorage.shared getUser] name]; - if (localProfileName.length > 0) { - json[kOWSBackup_ManifestKey_LocalProfileName] = localProfileName; - } - - if (self.localProfileAvatarItem) { - json[kOWSBackup_ManifestKey_LocalProfileAvatar] = [self jsonForItems:@[ self.localProfileAvatarItem ]]; - } - - OWSLogVerbose(@"json: %@", json); - - NSError *error; - NSData *_Nullable jsonData = - [NSJSONSerialization dataWithJSONObject:json options:NSJSONWritingPrettyPrinted error:&error]; - if (!jsonData || error) { - OWSFailDebug(@"error encoding manifest file: %@", error); - return nil; - } - return [self.backupIO encryptDataAsTempFile:jsonData encryptionKey:self.delegate.backupEncryptionKey]; -} - -- (NSArray *> *)jsonForItems:(NSArray *)items -{ - NSMutableArray *result = [NSMutableArray new]; - for (OWSBackupExportItem *item in items) { - NSMutableDictionary *itemJson = [NSMutableDictionary new]; - OWSAssertDebug(item.recordName.length > 0); - - itemJson[kOWSBackup_ManifestKey_RecordName] = item.recordName; - OWSAssertDebug(item.encryptedItem.encryptionKey.length > 0); - itemJson[kOWSBackup_ManifestKey_EncryptionKey] = item.encryptedItem.encryptionKey.base64EncodedString; - if (item.attachmentExport) { - OWSAssertDebug(item.attachmentExport.relativeFilePath.length > 0); - itemJson[kOWSBackup_ManifestKey_RelativeFilePath] = item.attachmentExport.relativeFilePath; - } - if (item.attachmentExport.attachmentId) { - OWSAssertDebug(item.attachmentExport.attachmentId.length > 0); - itemJson[kOWSBackup_ManifestKey_AttachmentId] = item.attachmentExport.attachmentId; - } - if (item.uncompressedDataLength) { - itemJson[kOWSBackup_ManifestKey_DataSize] = item.uncompressedDataLength; - } - [result addObject:itemJson]; - } - - return result; -} - -- (AnyPromise *)cleanUp -{ - if (self.isComplete) { - // Job was aborted. - return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup export no longer active.")]; - } - - OWSLogVerbose(@""); - - [self updateProgressWithDescription:NSLocalizedString(@"BACKUP_EXPORT_PHASE_CLEAN_UP", - @"Indicates that the cloud is being cleaned up.") - progress:nil]; - - // Now that our backup export has successfully completed, - // we try to clean up the cloud. We can safely delete any - // records not involved in this backup export. - NSMutableSet *activeRecordNames = [NSMutableSet new]; - - OWSAssertDebug(self.savedDatabaseItems.count > 0); - for (OWSBackupExportItem *item in self.savedDatabaseItems) { - OWSAssertDebug(item.recordName.length > 0); - OWSAssertDebug(![activeRecordNames containsObject:item.recordName]); - [activeRecordNames addObject:item.recordName]; - } - for (OWSBackupExportItem *item in self.savedAttachmentItems) { - OWSAssertDebug(item.recordName.length > 0); - OWSAssertDebug(![activeRecordNames containsObject:item.recordName]); - [activeRecordNames addObject:item.recordName]; - } - if (self.localProfileAvatarItem) { - OWSBackupExportItem *item = self.localProfileAvatarItem; - OWSAssertDebug(item.recordName.length > 0); - OWSAssertDebug(![activeRecordNames containsObject:item.recordName]); - [activeRecordNames addObject:item.recordName]; - } - OWSAssertDebug(self.manifestItem.recordName.length > 0); - OWSAssertDebug(![activeRecordNames containsObject:self.manifestItem.recordName]); - [activeRecordNames addObject:self.manifestItem.recordName]; - - // Because we do "lazy attachment restores", we need to include the record names for all - // records that haven't been restored yet. - NSArray *restoringRecordNames = [OWSBackup.sharedManager attachmentRecordNamesForLazyRestore]; - [activeRecordNames addObjectsFromArray:restoringRecordNames]; - - [self cleanUpMetadataCacheWithActiveRecordNames:activeRecordNames]; - - return [self cleanUpCloudWithActiveRecordNames:activeRecordNames]; -} - -- (void)cleanUpMetadataCacheWithActiveRecordNames:(NSSet *)activeRecordNames -{ - OWSAssertDebug(activeRecordNames.count > 0); - - if (self.isComplete) { - // Job was aborted. - return; - } - - // After every successful backup export, we can (and should) cull metadata - // for any backup fragment (i.e. CloudKit record) that wasn't involved in - // the latest backup export. - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - NSArray *allRecordNames = [transaction allKeysInCollection:[OWSBackupFragment collection]]; - - NSMutableSet *obsoleteRecordNames = [NSMutableSet new]; - [obsoleteRecordNames addObjectsFromArray:allRecordNames]; - [obsoleteRecordNames minusSet:activeRecordNames]; - - [transaction removeObjectsForKeys:obsoleteRecordNames.allObjects inCollection:[OWSBackupFragment collection]]; - }]; -} - -- (AnyPromise *)cleanUpCloudWithActiveRecordNames:(NSSet *)activeRecordNames -{ - OWSAssertDebug(activeRecordNames.count > 0); - - if (self.isComplete) { - // Job was aborted. - return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup export no longer active.")]; - } - - return [AnyPromise promiseWithResolverBlock:^(PMKResolver resolve) { - [OWSBackupAPI fetchAllRecordNamesWithRecipientId:self.recipientId - success:^(NSArray *recordNames) { - NSMutableSet *obsoleteRecordNames = [NSMutableSet new]; - [obsoleteRecordNames addObjectsFromArray:recordNames]; - [obsoleteRecordNames minusSet:activeRecordNames]; - - OWSLogVerbose(@"recordNames: %zd - activeRecordNames: %zd = obsoleteRecordNames: %zd", - recordNames.count, - activeRecordNames.count, - obsoleteRecordNames.count); - - [self deleteRecordsFromCloud:[obsoleteRecordNames.allObjects mutableCopy] - deletedCount:0 - completion:^(NSError *_Nullable error) { - // Cloud cleanup is non-critical so any error is recoverable. - resolve(@(1)); - }]; - } - failure:^(NSError *error) { - // Cloud cleanup is non-critical so any error is recoverable. - resolve(@(1)); - }]; - }]; -} - -- (void)deleteRecordsFromCloud:(NSMutableArray *)obsoleteRecordNames - deletedCount:(NSUInteger)deletedCount - completion:(OWSBackupJobCompletion)completion -{ - OWSAssertDebug(obsoleteRecordNames); - OWSAssertDebug(completion); - - OWSLogVerbose(@""); - - if (obsoleteRecordNames.count < 1) { - // No more records to delete; cleanup is complete. - return completion(nil); - } - - if (self.isComplete) { - // Job was aborted. - return completion(nil); - } - - CGFloat progress = (obsoleteRecordNames.count / (CGFloat)(obsoleteRecordNames.count + deletedCount)); - [self updateProgressWithDescription:NSLocalizedString(@"BACKUP_EXPORT_PHASE_CLEAN_UP", - @"Indicates that the cloud is being cleaned up.") - progress:@(progress)]; - - static const NSUInteger kMaxBatchSize = 100; - NSMutableArray *batchRecordNames = [NSMutableArray new]; - while (obsoleteRecordNames.count > 0 && batchRecordNames.count < kMaxBatchSize) { - NSString *recordName = obsoleteRecordNames.lastObject; - [obsoleteRecordNames removeLastObject]; - [batchRecordNames addObject:recordName]; - } - - [OWSBackupAPI deleteRecordsFromCloudWithRecordNames:batchRecordNames - success:^{ - [self deleteRecordsFromCloud:obsoleteRecordNames - deletedCount:deletedCount + batchRecordNames.count - completion:completion]; - } - failure:^(NSError *error) { - // Cloud cleanup is non-critical so any error is recoverable. - [self deleteRecordsFromCloud:obsoleteRecordNames - deletedCount:deletedCount + batchRecordNames.count - completion:completion]; - }]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Backups/OWSBackupIO.h b/Session/Backups/OWSBackupIO.h deleted file mode 100644 index ec9ef0575..000000000 --- a/Session/Backups/OWSBackupIO.h +++ /dev/null @@ -1,61 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -NS_ASSUME_NONNULL_BEGIN - -@interface OWSBackupEncryptedItem : NSObject - -@property (nonatomic) NSString *filePath; - -@property (nonatomic) NSData *encryptionKey; - -@end - -#pragma mark - - -@interface OWSBackupIO : NSObject - -- (instancetype)init NS_UNAVAILABLE; - -- (instancetype)initWithJobTempDirPath:(NSString *)jobTempDirPath; - -- (NSString *)generateTempFilePath; - -- (nullable NSString *)createTempFile; - -#pragma mark - Encrypt - -- (nullable OWSBackupEncryptedItem *)encryptFileAsTempFile:(NSString *)srcFilePath; - -- (nullable OWSBackupEncryptedItem *)encryptFileAsTempFile:(NSString *)srcFilePath - encryptionKey:(NSData *)encryptionKey; - -- (nullable OWSBackupEncryptedItem *)encryptDataAsTempFile:(NSData *)srcData; - -- (nullable OWSBackupEncryptedItem *)encryptDataAsTempFile:(NSData *)srcData encryptionKey:(NSData *)encryptionKey; - -#pragma mark - Decrypt - -- (BOOL)decryptFileAsFile:(NSString *)srcFilePath - dstFilePath:(NSString *)dstFilePath - encryptionKey:(NSData *)encryptionKey; - -- (nullable NSData *)decryptFileAsData:(NSString *)srcFilePath encryptionKey:(NSData *)encryptionKey; - -- (nullable NSData *)decryptDataAsData:(NSData *)srcData encryptionKey:(NSData *)encryptionKey; - -#pragma mark - Compression - -- (nullable NSData *)compressData:(NSData *)srcData; - -// I'm using the (new in iOS 9) compressionlib. One of its weaknesses is that it -// requires you to pre-allocate output buffers during compression and decompression. -// During decompression this is particularly tricky since there's no way to safely -// predict how large the output will be based on the input. So, we store the -// uncompressed size for compressed backup items. -- (nullable NSData *)decompressData:(NSData *)srcData uncompressedDataLength:(NSUInteger)uncompressedDataLength; - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Backups/OWSBackupIO.m b/Session/Backups/OWSBackupIO.m deleted file mode 100644 index ef95733e0..000000000 --- a/Session/Backups/OWSBackupIO.m +++ /dev/null @@ -1,273 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSBackupIO.h" -#import -#import - -@import Compression; - -NS_ASSUME_NONNULL_BEGIN - -// TODO: -static const NSUInteger kOWSBackupKeyLength = 32; - -// LZMA algorithm significantly outperforms the other compressionlib options -// for our database snapshots and is a widely adopted standard. -static const compression_algorithm SignalCompressionAlgorithm = COMPRESSION_LZMA; - -@implementation OWSBackupEncryptedItem - -@end - -#pragma mark - - -@interface OWSBackupIO () - -@property (nonatomic) NSString *jobTempDirPath; - -@end - -#pragma mark - - -@implementation OWSBackupIO - -- (instancetype)initWithJobTempDirPath:(NSString *)jobTempDirPath -{ - if (!(self = [super init])) { - return self; - } - - self.jobTempDirPath = jobTempDirPath; - - return self; -} - -- (NSString *)generateTempFilePath -{ - return [self.jobTempDirPath stringByAppendingPathComponent:[NSUUID UUID].UUIDString]; -} - -- (nullable NSString *)createTempFile -{ - NSString *filePath = [self generateTempFilePath]; - if (![OWSFileSystem ensureFileExists:filePath]) { - OWSFailDebug(@"could not create temp file."); - return nil; - } - return filePath; -} - -#pragma mark - Encrypt - -- (nullable OWSBackupEncryptedItem *)encryptFileAsTempFile:(NSString *)srcFilePath -{ - OWSAssertDebug(srcFilePath.length > 0); - - NSData *encryptionKey = [Randomness generateRandomBytes:(int)kOWSBackupKeyLength]; - - return [self encryptFileAsTempFile:srcFilePath encryptionKey:encryptionKey]; -} - -- (nullable OWSBackupEncryptedItem *)encryptFileAsTempFile:(NSString *)srcFilePath encryptionKey:(NSData *)encryptionKey -{ - OWSAssertDebug(srcFilePath.length > 0); - OWSAssertDebug(encryptionKey.length > 0); - - @autoreleasepool { - if (![[NSFileManager defaultManager] fileExistsAtPath:srcFilePath]) { - OWSFailDebug(@"Missing source file."); - return nil; - } - - // TODO: Encrypt the file without loading it into memory. - NSData *_Nullable srcData = [NSData dataWithContentsOfFile:srcFilePath]; - if (srcData.length < 1) { - OWSFailDebug(@"could not load file into memory for encryption."); - return nil; - } - return [self encryptDataAsTempFile:srcData encryptionKey:encryptionKey]; - } -} - -- (nullable OWSBackupEncryptedItem *)encryptDataAsTempFile:(NSData *)srcData -{ - OWSAssertDebug(srcData); - - NSData *encryptionKey = [Randomness generateRandomBytes:(int)kOWSBackupKeyLength]; - - return [self encryptDataAsTempFile:srcData encryptionKey:encryptionKey]; -} - -- (nullable OWSBackupEncryptedItem *)encryptDataAsTempFile:(NSData *)unencryptedData - encryptionKey:(NSData *)encryptionKey -{ - OWSAssertDebug(unencryptedData); - OWSAssertDebug(encryptionKey.length > 0); - - @autoreleasepool { - - // TODO: Encrypt the data using key; - NSData *encryptedData = unencryptedData; - - NSString *_Nullable dstFilePath = [self createTempFile]; - if (!dstFilePath) { - return nil; - } - NSError *error; - BOOL success = [encryptedData writeToFile:dstFilePath options:NSDataWritingAtomic error:&error]; - if (!success || error) { - OWSFailDebug(@"error writing encrypted data: %@", error); - return nil; - } - [OWSFileSystem protectFileOrFolderAtPath:dstFilePath]; - OWSBackupEncryptedItem *item = [OWSBackupEncryptedItem new]; - item.filePath = dstFilePath; - item.encryptionKey = encryptionKey; - return item; - } -} - -#pragma mark - Decrypt - -- (BOOL)decryptFileAsFile:(NSString *)srcFilePath - dstFilePath:(NSString *)dstFilePath - encryptionKey:(NSData *)encryptionKey -{ - OWSAssertDebug(srcFilePath.length > 0); - OWSAssertDebug(encryptionKey.length > 0); - - @autoreleasepool { - - // TODO: Decrypt the file without loading it into memory. - NSData *data = [self decryptFileAsData:srcFilePath encryptionKey:encryptionKey]; - - if (data.length < 1) { - return NO; - } - - NSError *error; - BOOL success = [data writeToFile:dstFilePath options:NSDataWritingAtomic error:&error]; - if (!success || error) { - OWSFailDebug(@"error writing decrypted data: %@", error); - return NO; - } - [OWSFileSystem protectFileOrFolderAtPath:dstFilePath]; - - return YES; - } -} - -- (nullable NSData *)decryptFileAsData:(NSString *)srcFilePath encryptionKey:(NSData *)encryptionKey -{ - OWSAssertDebug(srcFilePath.length > 0); - OWSAssertDebug(encryptionKey.length > 0); - - @autoreleasepool { - - if (![NSFileManager.defaultManager fileExistsAtPath:srcFilePath]) { - OWSLogError(@"missing downloaded file."); - return nil; - } - - NSData *_Nullable srcData = [NSData dataWithContentsOfFile:srcFilePath]; - if (srcData.length < 1) { - OWSFailDebug(@"could not load file into memory for decryption."); - return nil; - } - - NSData *_Nullable dstData = [self decryptDataAsData:srcData encryptionKey:encryptionKey]; - return dstData; - } -} - -- (nullable NSData *)decryptDataAsData:(NSData *)encryptedData encryptionKey:(NSData *)encryptionKey -{ - OWSAssertDebug(encryptedData); - OWSAssertDebug(encryptionKey.length > 0); - - @autoreleasepool { - - // TODO: Decrypt the data using key; - NSData *unencryptedData = encryptedData; - - return unencryptedData; - } -} - -#pragma mark - Compression - -- (nullable NSData *)compressData:(NSData *)srcData -{ - OWSAssertDebug(srcData); - - @autoreleasepool { - - if (!srcData) { - OWSFailDebug(@"missing unencrypted data."); - return nil; - } - - size_t srcLength = [srcData length]; - - // This assumes that dst will always be smaller than src. - // - // We slightly pad the buffer size to account for the worst case. - size_t dstBufferLength = srcLength + 64 * 1024; - NSMutableData *dstBufferData = [NSMutableData dataWithLength:dstBufferLength]; - if (!dstBufferData) { - OWSFailDebug(@"Failed to allocate buffer."); - return nil; - } - - size_t dstLength = compression_encode_buffer( - dstBufferData.mutableBytes, dstBufferLength, srcData.bytes, srcLength, NULL, SignalCompressionAlgorithm); - NSData *compressedData = [dstBufferData subdataWithRange:NSMakeRange(0, dstLength)]; - - OWSLogVerbose(@"compressed %zd -> %zd = %0.2f", - srcLength, - dstLength, - (srcLength > 0 ? (dstLength / (CGFloat)srcLength) : 0)); - - return compressedData; - } -} - -- (nullable NSData *)decompressData:(NSData *)srcData uncompressedDataLength:(NSUInteger)uncompressedDataLength -{ - OWSAssertDebug(srcData); - - @autoreleasepool { - - if (!srcData) { - OWSFailDebug(@"missing unencrypted data."); - return nil; - } - - size_t srcLength = [srcData length]; - - // We pad the buffer to be defensive. - size_t dstBufferLength = uncompressedDataLength + 1024; - NSMutableData *dstBufferData = [NSMutableData dataWithLength:dstBufferLength]; - if (!dstBufferData) { - OWSFailDebug(@"Failed to allocate buffer."); - return nil; - } - - size_t dstLength = compression_decode_buffer( - dstBufferData.mutableBytes, dstBufferLength, srcData.bytes, srcLength, NULL, SignalCompressionAlgorithm); - NSData *decompressedData = [dstBufferData subdataWithRange:NSMakeRange(0, dstLength)]; - OWSAssertDebug(decompressedData.length == uncompressedDataLength); - OWSLogVerbose(@"decompressed %zd -> %zd = %0.2f", - srcLength, - dstLength, - (dstLength > 0 ? (srcLength / (CGFloat)dstLength) : 0)); - - return decompressedData; - } -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Backups/OWSBackupImportJob.h b/Session/Backups/OWSBackupImportJob.h deleted file mode 100644 index 969e2c668..000000000 --- a/Session/Backups/OWSBackupImportJob.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSBackupJob.h" - -NS_ASSUME_NONNULL_BEGIN - -@interface OWSBackupImportJob : OWSBackupJob - -- (void)start; - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Backups/OWSBackupImportJob.m b/Session/Backups/OWSBackupImportJob.m deleted file mode 100644 index 46233da19..000000000 --- a/Session/Backups/OWSBackupImportJob.m +++ /dev/null @@ -1,635 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSBackupImportJob.h" -#import "OWSBackupIO.h" -#import "OWSDatabaseMigration.h" -#import "OWSDatabaseMigrationRunner.h" -#import "Session-Swift.h" -#import -#import -#import -#import -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -NSString *const kOWSBackup_ImportDatabaseKeySpec = @"kOWSBackup_ImportDatabaseKeySpec"; - -#pragma mark - - -@interface OWSBackupImportJob () - -@property (nonatomic, nullable) OWSBackgroundTask *backgroundTask; - -@property (nonatomic) OWSBackupIO *backupIO; - -@property (nonatomic) OWSBackupManifestContents *manifest; - -@property (nonatomic, nullable) YapDatabaseConnection *dbConnection; - -@end - -#pragma mark - - -@implementation OWSBackupImportJob - -#pragma mark - Dependencies - -- (OWSPrimaryStorage *)primaryStorage -{ - OWSAssertDebug(SSKEnvironment.shared.primaryStorage); - - return SSKEnvironment.shared.primaryStorage; -} - -- (OWSProfileManager *)profileManager -{ - return [OWSProfileManager sharedManager]; -} - -- (TSAccountManager *)tsAccountManager -{ - OWSAssertDebug(SSKEnvironment.shared.tsAccountManager); - - return SSKEnvironment.shared.tsAccountManager; -} - -- (OWSBackup *)backup -{ - OWSAssertDebug(AppEnvironment.shared.backup); - - return AppEnvironment.shared.backup; -} - -- (OWSBackupLazyRestore *)backupLazyRestore -{ - return AppEnvironment.shared.backupLazyRestore; -} - -#pragma mark - - -- (NSArray *)databaseItems -{ - OWSAssertDebug(self.manifest); - - return self.manifest.databaseItems; -} - -- (NSArray *)attachmentsItems -{ - OWSAssertDebug(self.manifest); - - return self.manifest.attachmentsItems; -} - -- (void)start -{ - OWSAssertIsOnMainThread(); - - OWSLogInfo(@""); - - self.backgroundTask = [OWSBackgroundTask backgroundTaskWithLabelStr:__PRETTY_FUNCTION__]; - - self.dbConnection = self.primaryStorage.newDatabaseConnection; - - [self updateProgressWithDescription:nil progress:nil]; - - [[self.backup ensureCloudKitAccess] - .thenInBackground(^{ - [self updateProgressWithDescription:NSLocalizedString(@"BACKUP_IMPORT_PHASE_CONFIGURATION", - @"Indicates that the backup import is being configured.") - progress:nil]; - - return [self configureImport]; - }) - .thenInBackground(^{ - if (self.isComplete) { - return - [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup import no longer active.")]; - } - - [self updateProgressWithDescription:NSLocalizedString(@"BACKUP_IMPORT_PHASE_IMPORT", - @"Indicates that the backup import data is being imported.") - progress:nil]; - - return [self downloadAndProcessManifestWithBackupIO:self.backupIO]; - }) - .thenInBackground(^(OWSBackupManifestContents *manifest) { - OWSCAssertDebug(manifest.databaseItems.count > 0); - OWSCAssertDebug(manifest.attachmentsItems); - - self.manifest = manifest; - - return [self downloadAndProcessImport]; - }) - .catch(^(NSError *error) { - [self failWithErrorDescription: - NSLocalizedString(@"BACKUP_IMPORT_ERROR_COULD_NOT_IMPORT", - @"Error indicating the backup import could not import the user's data.")]; - }) retainUntilComplete]; -} - -- (AnyPromise *)downloadAndProcessImport -{ - OWSAssertDebug(self.databaseItems); - OWSAssertDebug(self.attachmentsItems); - - // These items should be downloaded immediately. - NSMutableArray *allItems = [NSMutableArray new]; - [allItems addObjectsFromArray:self.databaseItems]; - - // Make a copy of the blockingItems before we add - // the optional items. - NSArray *blockingItems = [allItems copy]; - - // Local profile avatars are optional in the sense that if their - // download fails, we want to proceed with the import. - if (self.manifest.localProfileAvatarItem) { - [allItems addObject:self.manifest.localProfileAvatarItem]; - } - - // Attachment items can be downloaded later; - // they will can be lazy-restored. - [allItems addObjectsFromArray:self.attachmentsItems]; - - // Record metadata for all items, so that we can re-use them in incremental backups after the restore. - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - for (OWSBackupFragment *item in allItems) { - [item saveWithTransaction:transaction]; - } - }]; - - return [self downloadFilesFromCloud:blockingItems] - .thenInBackground(^{ - return [self restoreDatabase]; - }) - .thenInBackground(^{ - return [self ensureMigrations]; - }) - .thenInBackground(^{ - return [self restoreLocalProfile]; - }) - .thenInBackground(^{ - return [self restoreAttachmentFiles]; - }) - .then(^{ - // Kick off lazy restore on main thread. - [self.backupLazyRestore clearCompleteAndRunIfNecessary]; - - // Make sure backup is enabled once we complete - // a backup restore. - [OWSBackup.sharedManager setIsBackupEnabled:YES]; - }) - .thenInBackground(^{ - return [self.tsAccountManager updateAccountAttributes]; - }) - .thenInBackground(^{ - [self succeed]; - }); -} - -- (AnyPromise *)configureImport -{ - OWSLogVerbose(@""); - - if (![self ensureJobTempDir]) { - OWSFailDebug(@"Could not create jobTempDirPath."); - return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Could not create jobTempDirPath.")]; - } - - self.backupIO = [[OWSBackupIO alloc] initWithJobTempDirPath:self.jobTempDirPath]; - - return [AnyPromise promiseWithValue:@(1)]; -} - -- (AnyPromise *)downloadFilesFromCloud:(NSArray *)items -{ - OWSAssertDebug(items.count > 0); - - OWSLogVerbose(@""); - - NSUInteger recordCount = items.count; - - if (self.isComplete) { - // Job was aborted. - return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup import no longer active.")]; - } - - if (items.count < 1) { - // All downloads are complete; exit. - return [AnyPromise promiseWithValue:@(1)]; - } - - AnyPromise *promise = [AnyPromise promiseWithValue:@(1)]; - for (OWSBackupFragment *item in items) { - promise = promise - .thenInBackground(^{ - CGFloat progress - = (recordCount > 0 ? ((recordCount - items.count) / (CGFloat)recordCount) : 0.f); - [self updateProgressWithDescription: - NSLocalizedString(@"BACKUP_IMPORT_PHASE_DOWNLOAD", - @"Indicates that the backup import data is being downloaded.") - progress:@(progress)]; - }) - .thenInBackground(^{ - return [self downloadFileFromCloud:item]; - }); - } - - return promise; -} - -- (AnyPromise *)downloadFileFromCloud:(OWSBackupFragment *)item -{ - OWSAssertDebug(item); - - OWSLogVerbose(@""); - - if (self.isComplete) { - // Job was aborted. - return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup import no longer active.")]; - } - - return [AnyPromise promiseWithResolverBlock:^(PMKResolver resolve) { - // TODO: Use a predictable file path so that multiple "import backup" attempts - // will leverage successful file downloads from previous attempts. - // - // TODO: This will also require imports using a predictable jobTempDirPath. - NSString *tempFilePath = [self.jobTempDirPath stringByAppendingPathComponent:item.recordName]; - - // Skip redundant file download. - if ([NSFileManager.defaultManager fileExistsAtPath:tempFilePath]) { - [OWSFileSystem protectFileOrFolderAtPath:tempFilePath]; - - item.downloadFilePath = tempFilePath; - - return resolve(@(1)); - } - - [OWSBackupAPI downloadFileFromCloudObjcWithRecordName:item.recordName - toFileUrl:[NSURL fileURLWithPath:tempFilePath]] - .thenInBackground(^{ - [OWSFileSystem protectFileOrFolderAtPath:tempFilePath]; - item.downloadFilePath = tempFilePath; - - resolve(@(1)); - }) - .catchInBackground(^(NSError *error) { - resolve(error); - }); - }]; -} - -- (AnyPromise *)restoreLocalProfile -{ - OWSLogVerbose(@""); - - if (self.isComplete) { - // Job was aborted. - return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup import no longer active.")]; - } - - AnyPromise *promise = [AnyPromise promiseWithValue:@(1)]; - - if (self.manifest.localProfileAvatarItem) { - promise = promise.thenInBackground(^{ - return - [self downloadFileFromCloud:self.manifest.localProfileAvatarItem].catchInBackground(^(NSError *error) { - OWSLogInfo(@"Ignoring error; profiles are optional: %@", error); - }); - }); - } - - promise = promise.thenInBackground(^{ - return [self applyLocalProfile]; - }); - return promise; -} - -- (AnyPromise *)applyLocalProfile -{ - OWSLogVerbose(@""); - - if (self.isComplete) { - // Job was aborted. - return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup import no longer active.")]; - } - - NSString *_Nullable localProfileName = self.manifest.localProfileName; - UIImage *_Nullable localProfileAvatar = [self tryToLoadLocalProfileAvatar]; - - OWSLogVerbose(@"local profile name: %@, avatar: %d", localProfileName, localProfileAvatar != nil); - - if (localProfileName.length < 1 && !localProfileAvatar) { - return [AnyPromise promiseWithValue:@(1)]; - } - - return [AnyPromise promiseWithResolverBlock:^(PMKResolver resolve) { - [self.profileManager updateLocalProfileName:localProfileName - avatarImage:localProfileAvatar - success:^{ - resolve(@(1)); - } - failure:^(NSError *error) { - // Ignore errors related to local profile. - resolve(@(1)); - } - requiresSync:YES]; - }]; -} - -- (nullable UIImage *)tryToLoadLocalProfileAvatar -{ - if (!self.manifest.localProfileAvatarItem) { - return nil; - } - if (!self.manifest.localProfileAvatarItem.downloadFilePath) { - // Download of the avatar failed. - // We can safely ignore errors related to local profile. - OWSLogError(@"local profile avatar was not downloaded."); - return nil; - } - OWSBackupFragment *item = self.manifest.localProfileAvatarItem; - if (item.recordName.length < 1) { - OWSFailDebug(@"item missing record name."); - return nil; - } - - @autoreleasepool { - NSData *_Nullable data = - [self.backupIO decryptFileAsData:item.downloadFilePath encryptionKey:item.encryptionKey]; - if (!data) { - OWSLogError(@"could not decrypt local profile avatar."); - // Ignore errors related to local profile. - return nil; - } - // TODO: Verify that we're not compressing the profile avatar data. - UIImage *_Nullable image = [UIImage imageWithData:data]; - if (!image) { - OWSLogError(@"could not decrypt local profile avatar."); - // Ignore errors related to local profile. - return nil; - } - return image; - } -} - -- (AnyPromise *)restoreAttachmentFiles -{ - OWSLogVerbose(@": %zd", self.attachmentsItems.count); - - if (self.isComplete) { - // Job was aborted. - return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup import no longer active.")]; - } - - __block NSUInteger count = 0; - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - for (OWSBackupFragment *item in self.attachmentsItems) { - if (self.isComplete) { - return; - } - if (item.recordName.length < 1) { - OWSLogError(@"attachment was not downloaded."); - // Attachment-related errors are recoverable and can be ignored. - continue; - } - if (item.attachmentId.length < 1) { - OWSLogError(@"attachment missing attachment id."); - // Attachment-related errors are recoverable and can be ignored. - continue; - } - if (item.relativeFilePath.length < 1) { - OWSLogError(@"attachment missing relative file path."); - // Attachment-related errors are recoverable and can be ignored. - continue; - } - TSAttachmentPointer *_Nullable attachment = - [TSAttachmentPointer fetchObjectWithUniqueID:item.attachmentId transaction:transaction]; - if (!attachment) { - OWSLogError(@"attachment to restore could not be found."); - // Attachment-related errors are recoverable and can be ignored. - continue; - } - if (![attachment isKindOfClass:[TSAttachmentPointer class]]) { - OWSFailDebug(@"attachment has unexpected type: %@.", attachment.class); - // Attachment-related errors are recoverable and can be ignored. - continue; - } - [attachment markForLazyRestoreWithFragment:item transaction:transaction]; - count++; - [self updateProgressWithDescription:NSLocalizedString(@"BACKUP_IMPORT_PHASE_RESTORING_FILES", - @"Indicates that the backup import data is being restored.") - progress:@(count / (CGFloat)self.attachmentsItems.count)]; - } - }]; - - OWSLogError(@"enqueued lazy restore of %zd files.", count); - - return [AnyPromise promiseWithValue:@(1)]; -} - -- (AnyPromise *)restoreDatabase -{ - OWSLogVerbose(@""); - - if (self.isComplete) { - // Job was aborted. - return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup import no longer active.")]; - } - - // Order matters here. - NSArray *collectionsToRestore = @[ - [TSThread collection], - [TSAttachment collection], - // Interactions refer to threads and attachments, - // so copy them afterward. - [TSInteraction collection], - [OWSDatabaseMigration collection], - ]; - NSMutableDictionary *restoredEntityCounts = [NSMutableDictionary new]; - __block unsigned long long copiedEntities = 0; - __block BOOL aborted = NO; - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - for (NSString *collection in collectionsToRestore) { - if ([collection isEqualToString:[OWSDatabaseMigration collection]]) { - // It's okay if there are existing migrations; we'll clear those - // before restoring. - continue; - } - if ([transaction numberOfKeysInCollection:collection] > 0) { - OWSLogError(@"unexpected contents in database (%@).", collection); - } - } - - // Clear existing database contents. - // - // This should be safe since we only ever import into an empty database. - // - // Note that if the app receives a message after registering and before restoring - // backup, it will be lost. - // - // Note that this will clear all migrations. - for (NSString *collection in collectionsToRestore) { - [transaction removeAllObjectsInCollection:collection]; - } - - NSUInteger count = 0; - for (OWSBackupFragment *item in self.databaseItems) { - if (self.isComplete) { - return; - } - if (item.recordName.length < 1) { - OWSLogError(@"database snapshot was not downloaded."); - // Attachment-related errors are recoverable and can be ignored. - // Database-related errors are unrecoverable. - aborted = YES; - return; - } - if (!item.uncompressedDataLength || item.uncompressedDataLength.unsignedIntValue < 1) { - OWSLogError(@"database snapshot missing size."); - // Attachment-related errors are recoverable and can be ignored. - // Database-related errors are unrecoverable. - aborted = YES; - return; - } - - count++; - [self updateProgressWithDescription:NSLocalizedString(@"BACKUP_IMPORT_PHASE_RESTORING_DATABASE", - @"Indicates that the backup database is being restored.") - progress:@(count / (CGFloat)self.databaseItems.count)]; - - @autoreleasepool { - NSData *_Nullable compressedData = - [self.backupIO decryptFileAsData:item.downloadFilePath encryptionKey:item.encryptionKey]; - if (!compressedData) { - // Database-related errors are unrecoverable. - aborted = YES; - return; - } - NSData *_Nullable uncompressedData = - [self.backupIO decompressData:compressedData - uncompressedDataLength:item.uncompressedDataLength.unsignedIntValue]; - if (!uncompressedData) { - // Database-related errors are unrecoverable. - aborted = YES; - return; - } - NSError *error; - SignalIOSProtoBackupSnapshot *_Nullable entities = - [SignalIOSProtoBackupSnapshot parseData:uncompressedData error:&error]; - if (!entities || error) { - OWSLogError(@"could not parse proto: %@.", error); - // Database-related errors are unrecoverable. - aborted = YES; - return; - } - if (!entities || entities.entity.count < 1) { - OWSLogError(@"missing entities."); - // Database-related errors are unrecoverable. - aborted = YES; - return; - } - for (SignalIOSProtoBackupSnapshotBackupEntity *entity in entities.entity) { - NSData *_Nullable entityData = entity.entityData; - if (entityData.length < 1) { - OWSLogError(@"missing entity data."); - // Database-related errors are unrecoverable. - aborted = YES; - return; - } - - NSString *_Nullable collection = entity.collection; - if (collection.length < 1) { - OWSLogError(@"missing collection."); - // Database-related errors are unrecoverable. - aborted = YES; - return; - } - - NSString *_Nullable key = entity.key; - if (key.length < 1) { - OWSLogError(@"missing key."); - // Database-related errors are unrecoverable. - aborted = YES; - return; - } - - __block NSObject *object = nil; - @try { - NSKeyedUnarchiver *unarchiver = [[NSKeyedUnarchiver alloc] initForReadingWithData:entityData]; - object = [unarchiver decodeObjectForKey:@"root"]; - if (![object isKindOfClass:[object class]]) { - OWSLogError(@"invalid decoded entity: %@.", [object class]); - // Database-related errors are unrecoverable. - aborted = YES; - return; - } - } @catch (NSException *exception) { - OWSLogError(@"could not decode entity."); - // Database-related errors are unrecoverable. - aborted = YES; - return; - } - - [transaction setObject:object forKey:key inCollection:collection]; - copiedEntities++; - NSUInteger restoredEntityCount = restoredEntityCounts[collection].unsignedIntValue; - restoredEntityCounts[collection] = @(restoredEntityCount + 1); - } - } - } - }]; - - if (aborted) { - return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup import failed.")]; - } - if (self.isComplete) { - // Job was aborted. - return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup import no longer active.")]; - } - - for (NSString *collection in restoredEntityCounts) { - OWSLogInfo(@"copied %@: %@", collection, restoredEntityCounts[collection]); - } - OWSLogInfo(@"copiedEntities: %llu", copiedEntities); - - [self.primaryStorage logFileSizes]; - - return [AnyPromise promiseWithValue:@(1)]; -} - -- (AnyPromise *)ensureMigrations -{ - OWSLogVerbose(@""); - - if (self.isComplete) { - // Job was aborted. - return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup import no longer active.")]; - } - - [self updateProgressWithDescription:NSLocalizedString(@"BACKUP_IMPORT_PHASE_FINALIZING", - @"Indicates that the backup import data is being finalized.") - progress:nil]; - - - // It's okay that we do this in a separate transaction from the - // restoration of backup contents. If some of migrations don't - // complete, they'll be run the next time the app launches. - AnyPromise *promise = [AnyPromise promiseWithResolverBlock:^(PMKResolver resolve) { - dispatch_async(dispatch_get_main_queue(), ^{ - [[[OWSDatabaseMigrationRunner alloc] init] runAllOutstandingWithCompletion:^{ - resolve(@(1)); - }]; - }); - }]; - return promise; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Backups/OWSBackupJob.h b/Session/Backups/OWSBackupJob.h deleted file mode 100644 index a426dea57..000000000 --- a/Session/Backups/OWSBackupJob.h +++ /dev/null @@ -1,92 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "TSYapDatabaseObject.h" -#import - -NS_ASSUME_NONNULL_BEGIN - -extern NSString *const kOWSBackup_ManifestKey_DatabaseFiles; -extern NSString *const kOWSBackup_ManifestKey_AttachmentFiles; -extern NSString *const kOWSBackup_ManifestKey_RecordName; -extern NSString *const kOWSBackup_ManifestKey_EncryptionKey; -extern NSString *const kOWSBackup_ManifestKey_RelativeFilePath; -extern NSString *const kOWSBackup_ManifestKey_AttachmentId; -extern NSString *const kOWSBackup_ManifestKey_DataSize; -extern NSString *const kOWSBackup_ManifestKey_LocalProfileAvatar; -extern NSString *const kOWSBackup_ManifestKey_LocalProfileName; - -@class AnyPromise; -@class OWSBackupIO; -@class OWSBackupJob; -@class OWSBackupManifestContents; - -typedef void (^OWSBackupJobBoolCompletion)(BOOL success); -typedef void (^OWSBackupJobCompletion)(NSError *_Nullable error); -typedef void (^OWSBackupJobManifestSuccess)(OWSBackupManifestContents *manifest); -typedef void (^OWSBackupJobManifestFailure)(NSError *error); - -@interface OWSBackupManifestContents : NSObject - -@property (nonatomic) NSArray *databaseItems; -@property (nonatomic) NSArray *attachmentsItems; -@property (nonatomic, nullable) OWSBackupFragment *localProfileAvatarItem; -@property (nonatomic, nullable) NSString *localProfileName; - -@end - -#pragma mark - - -@protocol OWSBackupJobDelegate - -- (nullable NSData *)backupEncryptionKey; - -// Either backupJobDidSucceed:... or backupJobDidFail:... will -// be called exactly once on the main thread UNLESS: -// -// * The job was never started. -// * The job was cancelled. -- (void)backupJobDidSucceed:(OWSBackupJob *)backupJob; -- (void)backupJobDidFail:(OWSBackupJob *)backupJob error:(NSError *)error; - -- (void)backupJobDidUpdate:(OWSBackupJob *)backupJob - description:(nullable NSString *)description - progress:(nullable NSNumber *)progress; - -@end - -#pragma mark - - -@interface OWSBackupJob : NSObject - -@property (nonatomic, weak, readonly) id delegate; - -@property (nonatomic, readonly) NSString *recipientId; - -// Indicates that the backup succeeded, failed or was cancelled. -@property (atomic, readonly) BOOL isComplete; - -@property (nonatomic, readonly) NSString *jobTempDirPath; - -- (instancetype)init NS_UNAVAILABLE; - -- (instancetype)initWithDelegate:(id)delegate recipientId:(NSString *)recipientId; - -#pragma mark - Private - -- (BOOL)ensureJobTempDir; - -- (void)cancel; -- (void)succeed; -- (void)failWithErrorDescription:(NSString *)description; -- (void)failWithError:(NSError *)error; -- (void)updateProgressWithDescription:(nullable NSString *)description progress:(nullable NSNumber *)progress; - -#pragma mark - Manifest - -- (AnyPromise *)downloadAndProcessManifestWithBackupIO:(OWSBackupIO *)backupIO __attribute__((warn_unused_result)); - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Backups/OWSBackupJob.m b/Session/Backups/OWSBackupJob.m deleted file mode 100644 index 92aab7714..000000000 --- a/Session/Backups/OWSBackupJob.m +++ /dev/null @@ -1,316 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSBackupJob.h" -#import "OWSBackupIO.h" -#import "Session-Swift.h" -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -NSString *const kOWSBackup_ManifestKey_DatabaseFiles = @"database_files"; -NSString *const kOWSBackup_ManifestKey_AttachmentFiles = @"attachment_files"; -NSString *const kOWSBackup_ManifestKey_RecordName = @"record_name"; -NSString *const kOWSBackup_ManifestKey_EncryptionKey = @"encryption_key"; -NSString *const kOWSBackup_ManifestKey_RelativeFilePath = @"relative_file_path"; -NSString *const kOWSBackup_ManifestKey_AttachmentId = @"attachment_id"; -NSString *const kOWSBackup_ManifestKey_DataSize = @"data_size"; -NSString *const kOWSBackup_ManifestKey_LocalProfileAvatar = @"local_profile_avatar"; -NSString *const kOWSBackup_ManifestKey_LocalProfileName = @"local_profile_name"; - -NSString *const kOWSBackup_KeychainService = @"kOWSBackup_KeychainService"; - -@implementation OWSBackupManifestContents - -@end - -#pragma mark - - -@interface OWSBackupJob () - -@property (nonatomic, weak) id delegate; - -@property (nonatomic) NSString *recipientId; - -@property (atomic) BOOL isComplete; -@property (atomic) BOOL hasSucceeded; - -@property (nonatomic) NSString *jobTempDirPath; - -@end - -#pragma mark - - -@implementation OWSBackupJob - -- (instancetype)initWithDelegate:(id)delegate recipientId:(NSString *)recipientId -{ - self = [super init]; - - if (!self) { - return self; - } - - OWSAssertDebug(recipientId.length > 0); - OWSAssertDebug([OWSStorage isStorageReady]); - - self.delegate = delegate; - self.recipientId = recipientId; - - return self; -} - -- (void)dealloc -{ - // Surface memory leaks by logging the deallocation. - OWSLogVerbose(@"Dealloc: %@", self.class); - - [[NSNotificationCenter defaultCenter] removeObserver:self]; - - if (self.jobTempDirPath) { - [OWSFileSystem deleteFileIfExists:self.jobTempDirPath]; - } -} - -- (BOOL)ensureJobTempDir -{ - OWSLogVerbose(@""); - - // TODO: Exports should use a new directory each time, but imports - // might want to use a predictable directory so that repeated - // import attempts can reuse downloads from previous attempts. - NSString *temporaryDirectory = OWSTemporaryDirectory(); - self.jobTempDirPath = [temporaryDirectory stringByAppendingPathComponent:[NSUUID UUID].UUIDString]; - - if (![OWSFileSystem ensureDirectoryExists:self.jobTempDirPath]) { - OWSFailDebug(@"Could not create jobTempDirPath."); - return NO; - } - return YES; -} - -#pragma mark - - -- (void)cancel -{ - OWSAssertIsOnMainThread(); - - self.isComplete = YES; -} - -- (void)succeed -{ - OWSLogInfo(@""); - - dispatch_async(dispatch_get_main_queue(), ^{ - if (self.isComplete) { - OWSAssertDebug(!self.hasSucceeded); - return; - } - self.isComplete = YES; - - // There's a lot of asynchrony in these backup jobs; - // ensure we only end up finishing these jobs once. - OWSAssertDebug(!self.hasSucceeded); - self.hasSucceeded = YES; - - [self.delegate backupJobDidSucceed:self]; - }); -} - -- (void)failWithErrorDescription:(NSString *)description -{ - [self failWithError:OWSErrorWithCodeDescription(OWSErrorCodeImportBackupFailed, description)]; -} - -- (void)failWithError:(NSError *)error -{ - OWSFailDebug(@"%@", error); - - dispatch_async(dispatch_get_main_queue(), ^{ - OWSAssertDebug(!self.hasSucceeded); - if (self.isComplete) { - return; - } - self.isComplete = YES; - [self.delegate backupJobDidFail:self error:error]; - }); -} - -- (void)updateProgressWithDescription:(nullable NSString *)description progress:(nullable NSNumber *)progress -{ - OWSLogInfo(@""); - - dispatch_async(dispatch_get_main_queue(), ^{ - if (self.isComplete) { - return; - } - [self.delegate backupJobDidUpdate:self description:description progress:progress]; - }); -} - -#pragma mark - Manifest - -- (AnyPromise *)downloadAndProcessManifestWithBackupIO:(OWSBackupIO *)backupIO -{ - OWSAssertDebug(backupIO); - - OWSLogVerbose(@""); - - if (self.isComplete) { - // Job was aborted. - return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup job no longer active.")]; - } - - return - [OWSBackupAPI downloadManifestFromCloudObjcWithRecipientId:self.recipientId].thenInBackground(^(NSData *data) { - return [self processManifest:data backupIO:backupIO]; - }); -} - -- (AnyPromise *)processManifest:(NSData *)manifestDataEncrypted backupIO:(OWSBackupIO *)backupIO -{ - OWSAssertDebug(manifestDataEncrypted.length > 0); - OWSAssertDebug(backupIO); - - if (self.isComplete) { - // Job was aborted. - return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup job no longer active.")]; - } - - OWSLogVerbose(@""); - - NSData *_Nullable manifestDataDecrypted = - [backupIO decryptDataAsData:manifestDataEncrypted encryptionKey:self.delegate.backupEncryptionKey]; - if (!manifestDataDecrypted) { - OWSFailDebug(@"Could not decrypt manifest."); - return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Could not decrypt manifest.")]; - } - - NSError *error; - NSDictionary *_Nullable json = - [NSJSONSerialization JSONObjectWithData:manifestDataDecrypted options:0 error:&error]; - if (![json isKindOfClass:[NSDictionary class]]) { - OWSFailDebug(@"Could not download manifest."); - return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Could not download manifest.")]; - } - - OWSLogVerbose(@"json: %@", json); - - NSArray *_Nullable databaseItems = - [self parseManifestItems:json key:kOWSBackup_ManifestKey_DatabaseFiles]; - if (!databaseItems) { - return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"No database items in manifest.")]; - } - NSArray *_Nullable attachmentsItems = - [self parseManifestItems:json key:kOWSBackup_ManifestKey_AttachmentFiles]; - if (!attachmentsItems) { - return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"No attachment items in manifest.")]; - } - - NSArray *_Nullable localProfileAvatarItems; - if ([self parseManifestItem:json key:kOWSBackup_ManifestKey_LocalProfileAvatar]) { - localProfileAvatarItems = [self parseManifestItems:json key:kOWSBackup_ManifestKey_LocalProfileAvatar]; - } - - NSString *_Nullable localProfileName = [self parseManifestItem:json key:kOWSBackup_ManifestKey_LocalProfileName]; - - OWSBackupManifestContents *contents = [OWSBackupManifestContents new]; - contents.databaseItems = databaseItems; - contents.attachmentsItems = attachmentsItems; - contents.localProfileAvatarItem = localProfileAvatarItems.firstObject; - if ([localProfileName isKindOfClass:[NSString class]]) { - contents.localProfileName = localProfileName; - } else if (localProfileName) { - OWSFailDebug(@"Invalid localProfileName: %@", [localProfileName class]); - } - - return [AnyPromise promiseWithValue:contents]; -} - -- (nullable id)parseManifestItem:(id)json key:(NSString *)key -{ - OWSAssertDebug(json); - OWSAssertDebug(key.length); - - if (![json isKindOfClass:[NSDictionary class]]) { - OWSFailDebug(@"manifest has invalid data."); - return nil; - } - id _Nullable value = json[key]; - return value; -} - -- (nullable NSArray *)parseManifestItems:(id)json key:(NSString *)key -{ - OWSAssertDebug(json); - OWSAssertDebug(key.length); - - if (![json isKindOfClass:[NSDictionary class]]) { - OWSFailDebug(@"manifest has invalid data."); - return nil; - } - NSArray *itemMaps = json[key]; - if (![itemMaps isKindOfClass:[NSArray class]]) { - OWSFailDebug(@"manifest has invalid data."); - return nil; - } - NSMutableArray *items = [NSMutableArray new]; - for (NSDictionary *itemMap in itemMaps) { - if (![itemMap isKindOfClass:[NSDictionary class]]) { - OWSFailDebug(@"manifest has invalid item."); - return nil; - } - NSString *_Nullable recordName = itemMap[kOWSBackup_ManifestKey_RecordName]; - NSString *_Nullable encryptionKeyString = itemMap[kOWSBackup_ManifestKey_EncryptionKey]; - NSString *_Nullable relativeFilePath = itemMap[kOWSBackup_ManifestKey_RelativeFilePath]; - NSString *_Nullable attachmentId = itemMap[kOWSBackup_ManifestKey_AttachmentId]; - NSNumber *_Nullable uncompressedDataLength = itemMap[kOWSBackup_ManifestKey_DataSize]; - if (![recordName isKindOfClass:[NSString class]]) { - OWSFailDebug(@"manifest has invalid recordName: %@.", recordName); - return nil; - } - if (![encryptionKeyString isKindOfClass:[NSString class]]) { - OWSFailDebug(@"manifest has invalid encryptionKey."); - return nil; - } - // relativeFilePath is an optional field. - if (relativeFilePath && ![relativeFilePath isKindOfClass:[NSString class]]) { - OWSLogDebug(@"manifest has invalid relativeFilePath: %@.", relativeFilePath); - OWSFailDebug(@"manifest has invalid relativeFilePath"); - return nil; - } - // attachmentId is an optional field. - if (attachmentId && ![attachmentId isKindOfClass:[NSString class]]) { - OWSLogDebug(@"manifest has invalid attachmentId: %@.", attachmentId); - OWSFailDebug(@"manifest has invalid attachmentId"); - return nil; - } - NSData *_Nullable encryptionKey = [NSData dataFromBase64String:encryptionKeyString]; - if (!encryptionKey) { - OWSFailDebug(@"manifest has corrupt encryptionKey"); - return nil; - } - // uncompressedDataLength is an optional field. - if (uncompressedDataLength && ![uncompressedDataLength isKindOfClass:[NSNumber class]]) { - OWSFailDebug(@"manifest has invalid uncompressedDataLength: %@.", uncompressedDataLength); - return nil; - } - - OWSBackupFragment *item = [[OWSBackupFragment alloc] initWithUniqueId:recordName]; - item.recordName = recordName; - item.encryptionKey = encryptionKey; - item.relativeFilePath = relativeFilePath; - item.attachmentId = attachmentId; - item.uncompressedDataLength = uncompressedDataLength; - [items addObject:item]; - } - return items; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Backups/OWSBackupLazyRestore.swift b/Session/Backups/OWSBackupLazyRestore.swift deleted file mode 100644 index d29c6a72d..000000000 --- a/Session/Backups/OWSBackupLazyRestore.swift +++ /dev/null @@ -1,176 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -import Foundation -import PromiseKit -import SignalUtilitiesKit - -@objc(OWSBackupLazyRestore) -public class BackupLazyRestore: NSObject { - - // MARK: - Dependencies - - private var backup: OWSBackup { - return AppEnvironment.shared.backup - } - - private var primaryStorage: OWSPrimaryStorage { - return SSKEnvironment.shared.primaryStorage - } - - private var tsAccountManager: TSAccountManager { - return TSAccountManager.sharedInstance() - } - - // MARK: - - - private var isRunning = false - private var isComplete = false - - @objc - public required override init() { - super.init() - - SwiftSingletons.register(self) - - AppReadiness.runNowOrWhenAppDidBecomeReady { - self.runIfNecessary() - } - - NotificationCenter.default.addObserver(forName: .OWSApplicationDidBecomeActive, object: nil, queue: nil) { _ in - self.runIfNecessary() - } - NotificationCenter.default.addObserver(forName: .RegistrationStateDidChange, object: nil, queue: nil) { _ in - self.runIfNecessary() - } - NotificationCenter.default.addObserver(forName: .reachabilityChanged, object: nil, queue: nil) { _ in - self.runIfNecessary() - } - NotificationCenter.default.addObserver(forName: .reachabilityChanged, object: nil, queue: nil) { _ in - self.runIfNecessary() - } - NotificationCenter.default.addObserver(forName: NSNotification.Name(NSNotificationNameBackupStateDidChange), object: nil, queue: nil) { _ in - self.runIfNecessary() - } - } - - // MARK: - - - private let backgroundQueue = DispatchQueue.global(qos: .background) - - @objc - public func clearCompleteAndRunIfNecessary() { - AssertIsOnMainThread() - - isComplete = false - - runIfNecessary() - } - - @objc - public func isBackupImportInProgress() -> Bool { - return backup.backupImportState == .inProgress - } - - @objc - public func runIfNecessary() { - AssertIsOnMainThread() - - guard !CurrentAppContext().isRunningTests else { - return - } - guard AppReadiness.isAppReady() else { - return - } - guard CurrentAppContext().isMainAppAndActive else { - return - } - guard tsAccountManager.isRegisteredAndReady() else { - return - } - guard !isBackupImportInProgress() else { - return - } - guard !isRunning, !isComplete else { - return - } - - isRunning = true - - backgroundQueue.async { - self.restoreAttachments() - } - } - - private func restoreAttachments() { - let temporaryDirectory = OWSTemporaryDirectory() - let jobTempDirPath = (temporaryDirectory as NSString).appendingPathComponent(NSUUID().uuidString) - - guard OWSFileSystem.ensureDirectoryExists(jobTempDirPath) else { - Logger.error("could not create temp directory.") - complete(errorCount: 1) - return - } - - let backupIO = OWSBackupIO(jobTempDirPath: jobTempDirPath) - - let attachmentIds = backup.attachmentIdsForLazyRestore() - guard attachmentIds.count > 0 else { - Logger.info("No attachments need lazy restore.") - complete(errorCount: 0) - return - } - Logger.info("Lazy restoring \(attachmentIds.count) attachments.") - tryToRestoreNextAttachment(attachmentIds: attachmentIds, errorCount: 0, backupIO: backupIO) - } - - private func tryToRestoreNextAttachment(attachmentIds: [String], errorCount: UInt, backupIO: OWSBackupIO) { - guard !isBackupImportInProgress() else { - Logger.verbose("A backup import is in progress; abort.") - complete(errorCount: errorCount + 1) - return - } - - var attachmentIdsCopy = attachmentIds - guard let attachmentId = attachmentIdsCopy.popLast() else { - // This job is done. - Logger.verbose("job is done.") - complete(errorCount: errorCount) - return - } - guard let attachmentPointer = TSAttachment.fetch(uniqueId: attachmentId) as? TSAttachmentPointer else { - Logger.warn("could not load attachment.") - // Not necessarily an error. - // The attachment might have been deleted since the job began. - // Continue trying to restore the other attachments. - tryToRestoreNextAttachment(attachmentIds: attachmentIds, errorCount: errorCount + 1, backupIO: backupIO) - return - } - backup.lazyRestoreAttachment(attachmentPointer, - backupIO: backupIO) - .done(on: self.backgroundQueue) { _ in - Logger.info("Restored attachment.") - - // Continue trying to restore the other attachments. - self.tryToRestoreNextAttachment(attachmentIds: attachmentIdsCopy, errorCount: errorCount, backupIO: backupIO) - }.catch(on: self.backgroundQueue) { (error) in - Logger.error("Could not restore attachment: \(error)") - - // Continue trying to restore the other attachments. - self.tryToRestoreNextAttachment(attachmentIds: attachmentIdsCopy, errorCount: errorCount + 1, backupIO: backupIO) - }.retainUntilComplete() - } - - private func complete(errorCount: UInt) { - Logger.verbose("") - - DispatchQueue.main.async { - self.isRunning = false - - if errorCount == 0 { - self.isComplete = true - } - } - } -} diff --git a/Session/Backups/OWSBackupSettingsViewController.h b/Session/Backups/OWSBackupSettingsViewController.h deleted file mode 100644 index 34b03c1ab..000000000 --- a/Session/Backups/OWSBackupSettingsViewController.h +++ /dev/null @@ -1,13 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface OWSBackupSettingsViewController : OWSTableViewController - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Backups/OWSBackupSettingsViewController.m b/Session/Backups/OWSBackupSettingsViewController.m deleted file mode 100644 index 575d4a281..000000000 --- a/Session/Backups/OWSBackupSettingsViewController.m +++ /dev/null @@ -1,214 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "OWSBackupSettingsViewController.h" -#import "OWSBackup.h" -#import "Session-Swift.h" - -#import -#import -#import -#import -#import -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface OWSBackupSettingsViewController () - -@property (nonatomic, nullable) NSError *iCloudError; - -@end - -#pragma mark - - -@implementation OWSBackupSettingsViewController - -#pragma mark - Dependencies - -- (OWSBackup *)backup -{ - OWSAssertDebug(AppEnvironment.shared.backup); - - return AppEnvironment.shared.backup; -} - -#pragma mark - - -- (void)viewDidLoad -{ - [super viewDidLoad]; - - self.title = NSLocalizedString(@"SETTINGS_BACKUP", @"Label for the backup view in app settings."); - - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(backupStateDidChange:) - name:NSNotificationNameBackupStateDidChange - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(applicationDidBecomeActive:) - name:OWSApplicationDidBecomeActiveNotification - object:nil]; - - [self updateTableContents]; -} - -- (void)dealloc -{ - [[NSNotificationCenter defaultCenter] removeObserver:self]; -} - -- (void)viewDidAppear:(BOOL)animated -{ - [super viewDidAppear:animated]; - - [self updateTableContents]; - [self updateICloudStatus]; -} - -- (void)updateICloudStatus -{ - __weak OWSBackupSettingsViewController *weakSelf = self; - [[self.backup ensureCloudKitAccess] - .then(^{ - OWSAssertIsOnMainThread(); - - weakSelf.iCloudError = nil; - [weakSelf updateTableContents]; - }) - .catch(^(NSError *error) { - OWSAssertIsOnMainThread(); - - weakSelf.iCloudError = error; - [weakSelf updateTableContents]; - }) retainUntilComplete]; -} - -#pragma mark - Table Contents - -- (void)updateTableContents -{ - OWSTableContents *contents = [OWSTableContents new]; - - BOOL isBackupEnabled = [OWSBackup.sharedManager isBackupEnabled]; - - if (self.iCloudError) { - OWSTableSection *iCloudSection = [OWSTableSection new]; - iCloudSection.headerTitle = NSLocalizedString( - @"SETTINGS_BACKUP_ICLOUD_STATUS", @"Label for iCloud status row in the in the backup settings view."); - [iCloudSection - addItem:[OWSTableItem - longDisclosureItemWithText:[OWSBackupAPI errorMessageForCloudKitAccessError:self.iCloudError] - actionBlock:^{ - [[UIApplication sharedApplication] - openURL:[NSURL URLWithString:UIApplicationOpenSettingsURLString]]; - }]]; - [contents addSection:iCloudSection]; - } - - // TODO: This UI is temporary. - // Enabling backup will involve entering and registering a PIN. - OWSTableSection *enableSection = [OWSTableSection new]; - enableSection.headerTitle = NSLocalizedString(@"SETTINGS_BACKUP", @"Label for the backup view in app settings."); - [enableSection - addItem:[OWSTableItem switchItemWithText: - NSLocalizedString(@"SETTINGS_BACKUP_ENABLING_SWITCH", - @"Label for switch in settings that controls whether or not backup is enabled.") - isOnBlock:^{ - return [OWSBackup.sharedManager isBackupEnabled]; - } - target:self - selector:@selector(isBackupEnabledDidChange:)]]; - [contents addSection:enableSection]; - - if (isBackupEnabled) { - // TODO: This UI is temporary. - // Enabling backup will involve entering and registering a PIN. - OWSTableSection *progressSection = [OWSTableSection new]; - [progressSection - addItem:[OWSTableItem - labelItemWithText:NSLocalizedString(@"SETTINGS_BACKUP_STATUS", - @"Label for backup status row in the in the backup settings view.") - accessoryText:NSStringForBackupExportState(OWSBackup.sharedManager.backupExportState)]]; - if (OWSBackup.sharedManager.backupExportState == OWSBackupState_InProgress) { - if (OWSBackup.sharedManager.backupExportDescription) { - [progressSection - addItem:[OWSTableItem - labelItemWithText:NSLocalizedString(@"SETTINGS_BACKUP_PHASE", - @"Label for phase row in the in the backup settings view.") - accessoryText:OWSBackup.sharedManager.backupExportDescription]]; - if (OWSBackup.sharedManager.backupExportProgress) { - NSUInteger progressPercent - = (NSUInteger)round(OWSBackup.sharedManager.backupExportProgress.floatValue * 100); - NSNumberFormatter *numberFormatter = [[NSNumberFormatter alloc] init]; - [numberFormatter setNumberStyle:NSNumberFormatterPercentStyle]; - [numberFormatter setMaximumFractionDigits:0]; - [numberFormatter setMultiplier:@1]; - NSString *progressString = [numberFormatter stringFromNumber:@(progressPercent)]; - [progressSection - addItem:[OWSTableItem - labelItemWithText:NSLocalizedString(@"SETTINGS_BACKUP_PROGRESS", - @"Label for phase row in the in the backup settings view.") - accessoryText:progressString]]; - } - } - } - - switch (OWSBackup.sharedManager.backupExportState) { - case OWSBackupState_Idle: - case OWSBackupState_Failed: - case OWSBackupState_Succeeded: - [progressSection - addItem:[OWSTableItem disclosureItemWithText: - NSLocalizedString(@"SETTINGS_BACKUP_BACKUP_NOW", - @"Label for 'backup now' button in the backup settings view.") - actionBlock:^{ - [OWSBackup.sharedManager tryToExportBackup]; - }]]; - break; - case OWSBackupState_InProgress: - [progressSection - addItem:[OWSTableItem disclosureItemWithText: - NSLocalizedString(@"SETTINGS_BACKUP_CANCEL_BACKUP", - @"Label for 'cancel backup' button in the backup settings view.") - actionBlock:^{ - [OWSBackup.sharedManager cancelExportBackup]; - }]]; - break; - } - - [contents addSection:progressSection]; - } - - self.contents = contents; -} - -- (void)isBackupEnabledDidChange:(UISwitch *)sender -{ - [OWSBackup.sharedManager setIsBackupEnabled:sender.isOn]; - - [self updateTableContents]; -} - -#pragma mark - Events - -- (void)backupStateDidChange:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - - [self updateTableContents]; -} - -- (void)applicationDidBecomeActive:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - - [self updateICloudStatus]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Meta/AppDelegate.m b/Session/Meta/AppDelegate.m index 5219167c1..f0199f467 100644 --- a/Session/Meta/AppDelegate.m +++ b/Session/Meta/AppDelegate.m @@ -4,7 +4,6 @@ #import "AppDelegate.h" #import "MainAppContext.h" -#import "OWSBackup.h" #import "OWSOrphanDataCleaner.h" #import "OWSScreenLockUI.h" #import "Session-Swift.h" @@ -101,11 +100,6 @@ static NSTimeInterval launchStartedAt; return Environment.shared.windowManager; } -- (OWSBackup *)backup -{ - return AppEnvironment.shared.backup; -} - - (OWSNotificationPresenter *)notificationPresenter { return AppEnvironment.shared.notificationPresenter; @@ -552,11 +546,7 @@ static NSTimeInterval launchStartedAt; UIViewController *rootViewController; BOOL navigationBarHidden = NO; if ([self.tsAccountManager isRegistered]) { - if (self.backup.hasPendingRestoreDecision) { - rootViewController = [BackupRestoreViewController new]; - } else { - rootViewController = [HomeVC new]; - } + rootViewController = [HomeVC new]; } else { rootViewController = [LandingVC new]; navigationBarHidden = NO; diff --git a/Session/Meta/AppEnvironment.swift b/Session/Meta/AppEnvironment.swift index 805df0d81..ec6a1123b 100644 --- a/Session/Meta/AppEnvironment.swift +++ b/Session/Meta/AppEnvironment.swift @@ -34,9 +34,6 @@ import SignalUtilitiesKit @objc public var pushRegistrationManager: PushRegistrationManager - @objc - public var backup: OWSBackup - @objc public var fileLogger: DDFileLogger @@ -49,15 +46,10 @@ import SignalUtilitiesKit return _userNotificationActionHandler as! UserNotificationActionHandler } - @objc - public var backupLazyRestore: BackupLazyRestore - private override init() { self.accountManager = AccountManager() self.notificationPresenter = NotificationPresenter() self.pushRegistrationManager = PushRegistrationManager() - self.backup = OWSBackup() - self.backupLazyRestore = BackupLazyRestore() self._userNotificationActionHandler = UserNotificationActionHandler() self.fileLogger = DDFileLogger() diff --git a/Session/Meta/Signal-Bridging-Header.h b/Session/Meta/Signal-Bridging-Header.h index 956c13505..1b5f636b7 100644 --- a/Session/Meta/Signal-Bridging-Header.h +++ b/Session/Meta/Signal-Bridging-Header.h @@ -19,8 +19,6 @@ #import "NotificationSettingsViewController.h" #import "OWSAnyTouchGestureRecognizer.h" #import "OWSAudioPlayer.h" -#import "OWSBackup.h" -#import "OWSBackupIO.h" #import "OWSBezierPathView.h" #import "OWSConversationSettingsViewController.h" #import "OWSDatabaseMigration.h" diff --git a/SessionMessagingKit/To Do/TSAccountManager.h b/SessionMessagingKit/To Do/TSAccountManager.h index fae863d46..9c6013bf9 100644 --- a/SessionMessagingKit/To Do/TSAccountManager.h +++ b/SessionMessagingKit/To Do/TSAccountManager.h @@ -140,9 +140,6 @@ typedef NS_ENUM(NSUInteger, OWSRegistrationState) { - (BOOL)isDeregistered; - (void)setIsDeregistered:(BOOL)isDeregistered; -- (BOOL)hasPendingBackupRestoreDecision; -- (void)setHasPendingBackupRestoreDecision:(BOOL)value; - #pragma mark - Re-registration // Re-registration is the process of re-registering _with the same phone number_. diff --git a/SessionMessagingKit/To Do/TSAccountManager.m b/SessionMessagingKit/To Do/TSAccountManager.m index 03c1cfcc4..eed562917 100644 --- a/SessionMessagingKit/To Do/TSAccountManager.m +++ b/SessionMessagingKit/To Do/TSAccountManager.m @@ -418,22 +418,6 @@ NSString *const TSAccountManager_NeedsAccountAttributesUpdateKey = @"TSAccountMa inCollection:TSAccountManager_UserAccountCollection]; } -- (BOOL)hasPendingBackupRestoreDecision -{ - return [self.dbConnection boolForKey:TSAccountManager_HasPendingRestoreDecisionKey - inCollection:TSAccountManager_UserAccountCollection - defaultValue:NO]; -} - -- (void)setHasPendingBackupRestoreDecision:(BOOL)value -{ - [self.dbConnection setBool:value - forKey:TSAccountManager_HasPendingRestoreDecisionKey - inCollection:TSAccountManager_UserAccountCollection]; - - [self postRegistrationStateDidChangeNotification]; -} - - (BOOL)isManualMessageFetchEnabled { return [self.dbConnection boolForKey:TSAccountManager_ManualMessageFetchKey From 4d5d201493b8379ce4243dd8bbd0f69ef7b30c85 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 29 Mar 2022 09:15:59 +1100 Subject: [PATCH 050/157] Updated the SOGSV4Migration id to be larger than the other PR values --- SignalUtilitiesKit/Database/Migrations/SOGSV4Migration.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SignalUtilitiesKit/Database/Migrations/SOGSV4Migration.swift b/SignalUtilitiesKit/Database/Migrations/SOGSV4Migration.swift index 2737611b1..6c945b28b 100644 --- a/SignalUtilitiesKit/Database/Migrations/SOGSV4Migration.swift +++ b/SignalUtilitiesKit/Database/Migrations/SOGSV4Migration.swift @@ -7,7 +7,7 @@ public class SOGSV4Migration: OWSDatabaseMigration { @objc class func migrationId() -> String { - return "003" + return "005" } override public func runUp(completion: @escaping OWSDatabaseMigrationCompletion) { From e3622088ade7e480861bd7382004124658dc168c Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 29 Mar 2022 09:30:32 +1100 Subject: [PATCH 051/157] Fixed missed framework complication errors from merge --- Session.xcodeproj/project.pbxproj | 17 ++++++----------- .../ConversationVC+Interaction.swift | 2 +- .../Open Groups/OpenGroupManager.swift | 1 - .../MessageReceiver+Handling.swift | 4 ++-- SessionMessagingKit/Storage.swift | 1 + .../OpenGroupServerIdLookupMigration.swift | 3 ++- 6 files changed, 12 insertions(+), 16 deletions(-) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 3ee37ed60..039414203 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -241,7 +241,6 @@ B893063F2383961A005EAA8E /* ScanQRCodeWrapperVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B893063E2383961A005EAA8E /* ScanQRCodeWrapperVC.swift */; }; B894D0752339EDCF00B4D94D /* NukeDataModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B894D0742339EDCF00B4D94D /* NukeDataModal.swift */; }; B897621C25D201F7004F83B2 /* ScrollToBottomButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B897621B25D201F7004F83B2 /* ScrollToBottomButton.swift */; }; - B8AE75A425A6C6A6001A84D2 /* Data+Trimming.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8AE75A325A6C6A6001A84D2 /* Data+Trimming.swift */; }; B8AF4BB426A5204600583500 /* SendSeedModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8AF4BB326A5204600583500 /* SendSeedModal.swift */; }; B8B32021258B1A650020074B /* Contact.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B32020258B1A650020074B /* Contact.swift */; }; B8B32033258B235D0020074B /* Storage+Contacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B32032258B235D0020074B /* Storage+Contacts.swift */; }; @@ -673,7 +672,6 @@ C3A7219A2558C1660043A11F /* AnyPromise+Conversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A721992558C1660043A11F /* AnyPromise+Conversion.swift */; }; C3A7225E2558C38D0043A11F /* Promise+Retaining.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A7225D2558C38D0043A11F /* Promise+Retaining.swift */; }; C3A76A8D25DB83F90074CB90 /* PermissionMissingModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A76A8C25DB83F90074CB90 /* PermissionMissingModal.swift */; }; - C3AABDDF2553ECF00042FF4C /* Array+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D12553860800C340D1 /* Array+Utilities.swift */; }; C3AAFFF225AE99710089E6DD /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3AAFFF125AE99710089E6DD /* AppDelegate.swift */; }; C3ADC66126426688005F1414 /* ShareVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3ADC66026426688005F1414 /* ShareVC.swift */; }; C3BBE0762554CDA60050F1E3 /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3BBE0752554CDA60050F1E3 /* Configuration.swift */; }; @@ -890,7 +888,8 @@ FDC438CB27BB7DB100C60D73 /* UpdateMessageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438CA27BB7DB100C60D73 /* UpdateMessageRequest.swift */; }; FDC438CD27BC641200C60D73 /* Set+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438CC27BC641200C60D73 /* Set+Utilities.swift */; }; FDC438CF27BCA45400C60D73 /* Server.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438CE27BCA45400C60D73 /* Server.swift */; }; - FDFD645827EC1F4000808CA1 /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFD645727EC1F4000808CA1 /* Atomic.swift */; }; + FDFD645927F26C6800808CA1 /* Array+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D12553860800C340D1 /* Array+Utilities.swift */; }; + FDFD645B27F26D4600808CA1 /* Data+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFD645A27F26D4600808CA1 /* Data+Utilities.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1362,7 +1361,6 @@ B893063E2383961A005EAA8E /* ScanQRCodeWrapperVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanQRCodeWrapperVC.swift; sourceTree = ""; }; B894D0742339EDCF00B4D94D /* NukeDataModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NukeDataModal.swift; sourceTree = ""; }; B897621B25D201F7004F83B2 /* ScrollToBottomButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollToBottomButton.swift; sourceTree = ""; }; - B8AE75A325A6C6A6001A84D2 /* Data+Trimming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Trimming.swift"; sourceTree = ""; }; B8AF4BB326A5204600583500 /* SendSeedModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendSeedModal.swift; sourceTree = ""; }; B8B32020258B1A650020074B /* Contact.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Contact.swift; sourceTree = ""; }; B8B32032258B235D0020074B /* Storage+Contacts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+Contacts.swift"; sourceTree = ""; }; @@ -2050,7 +2048,7 @@ 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 = ""; }; FDC438CE27BCA45400C60D73 /* Server.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.swift; sourceTree = ""; }; - FDFD645727EC1F4000808CA1 /* Atomic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = ""; }; + FDFD645A27F26D4600808CA1 /* Data+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Data+Utilities.swift"; sourceTree = ""; }; FEDBAE1B98C49BBE8C87F575 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; sourceTree = ""; }; FF9BA33D021B115B1F5B4E46 /* Pods-SessionMessagingKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionMessagingKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionMessagingKit/Pods-SessionMessagingKit.app store release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -2587,10 +2585,9 @@ C3C2A5D12553860800C340D1 /* Array+Utilities.swift */, FDC4383D27B4708600C60D73 /* Atomic.swift */, FDC438CC27BC641200C60D73 /* Set+Utilities.swift */, - FDFD645727EC1F4000808CA1 /* Atomic.swift */, C33FDAA8255A57FF00E217F9 /* BuildConfiguration.swift */, B8F5F58225EC94A6003BF8D4 /* Collection+Subscripting.swift */, - B8AE75A325A6C6A6001A84D2 /* Data+Trimming.swift */, + FDFD645A27F26D4600808CA1 /* Data+Utilities.swift */, C3C2A5D52553860A00C340D1 /* Dictionary+Utilities.swift */, B87EF18026377A1D00124B3C /* Features.swift */, B8BC00BF257D90E30032E807 /* General.swift */, @@ -5306,9 +5303,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - C3AABDDF2553ECF00042FF4C /* Array+Utilities.swift in Sources */, + FDFD645927F26C6800808CA1 /* Array+Utilities.swift in Sources */, 7B1D74AC27BDE7510030B423 /* Promise+Timeout.swift in Sources */, - C3AABDDF2553ECF00042FF4C /* Array+Utilities.swift in Sources */, C32C5A47256DB8F0003C73A2 /* ECKeyPair+Hexadecimal.swift in Sources */, C3D9E41525676C320040E4F3 /* Storage.swift in Sources */, 7B1D74B027C365960030B423 /* Timer+MainThread.swift in Sources */, @@ -5339,13 +5335,11 @@ FDC438CD27BC641200C60D73 /* Set+Utilities.swift in Sources */, C3D9E3C925676AF30040E4F3 /* TSYapDatabaseObject.m in Sources */, FD705A92278D051200F16121 /* ReusableView.swift in Sources */, - B8AE75A425A6C6A6001A84D2 /* Data+Trimming.swift in Sources */, B8856DE6256F15F2001CE70E /* String+SSK.swift in Sources */, C3471ED42555386B00297E91 /* AESGCM.swift in Sources */, C32C5DDB256DD9FF003C73A2 /* ContentProxy.swift in Sources */, C3A71F892558BA9F0043A11F /* Mnemonic.swift in Sources */, B8F5F58325EC94A6003BF8D4 /* Collection+Subscripting.swift in Sources */, - FDFD645827EC1F4000808CA1 /* Atomic.swift in Sources */, C33FDEF8255A656D00E217F9 /* Promise+Delaying.swift in Sources */, B8BC00C0257D90E30032E807 /* General.swift in Sources */, FD078E4627E02406000769AF /* Atomic.swift in Sources */, @@ -5365,6 +5359,7 @@ C300A60D2554B31900555489 /* Logging.swift in Sources */, B8FF8EA625C11FEF004D1F22 /* IPv4.swift in Sources */, C3D9E35525675EE10040E4F3 /* MIMETypeUtil.m in Sources */, + FDFD645B27F26D4600808CA1 /* Data+Utilities.swift in Sources */, FD705A8E278CE29800F16121 /* String+Localization.swift in Sources */, C3A7219A2558C1660043A11F /* AnyPromise+Conversion.swift in Sources */, C300A6322554B6D100555489 /* NSDate+Timestamp.mm in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 75bebd8ef..7da3e5392 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -1155,7 +1155,7 @@ extension ConversationVC { // (it'll be updated with correct profile info if they accept the message request so this // shouldn't cause weird behaviours) let sessionId: String = contactThread.contactSessionID() - let contact: Contact = (Storage.shared.getContact(with: sessionId, using: transaction) ?? Contact(sessionID: sessionId)) + let contact: Contact = (Storage.shared.getContact(with: sessionId) ?? Contact(sessionID: sessionId)) guard !contact.isApproved else { return Promise.value(()) } diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 3bca3c22e..cd1bee054 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -358,7 +358,6 @@ public final class OpenGroupManager: NSObject { // Sorting the messages by server ID before importing them fixes an issue where messages // that quote older messages can't find those older messages let openGroupID = "\(server).\(roomToken)" - let openGroupIdData: Data = LKGroupUtilities.getEncodedOpenGroupIDAsData(openGroupID) let sortedMessages: [OpenGroupAPI.Message] = messages .sorted { lhs, rhs in lhs.id < rhs.id } let seqNo: Int64? = sortedMessages.map { $0.seqNo }.max() diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index 5ffc42924..4b052e252 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -388,8 +388,8 @@ extension MessageReceiver { tsMessage.openGroupServerMessageID = serverID // Create a lookup between the openGroupServerMessageId and the tsMessage id for easy lookup - if let openGroup: OpenGroupV2 = storage.getV2OpenGroup(for: threadID) { - storage.addOpenGroupServerIdLookup(serverID, tsMessageId: tsMessageID, in: openGroup.room, on: openGroup.server, using: transaction) + if let openGroup: OpenGroup = dependencies.storage.getOpenGroup(for: threadID) { + dependencies.storage.addOpenGroupServerIdLookup(serverID, tsMessageId: tsMessageID, in: openGroup.room, on: openGroup.server, using: transaction) } } diff --git a/SessionMessagingKit/Storage.swift b/SessionMessagingKit/Storage.swift index f5391f221..ba0f2e6cf 100644 --- a/SessionMessagingKit/Storage.swift +++ b/SessionMessagingKit/Storage.swift @@ -18,6 +18,7 @@ public protocol SessionMessagingKitStorageProtocol { func getUserPublicKey() -> String? func getUserKeyPair() -> ECKeyPair? func getUserED25519KeyPair() -> Box.KeyPair? + func getUser() -> Contact? func getUser(using transaction: YapDatabaseReadTransaction?) -> Contact? // MARK: - Contacts diff --git a/SignalUtilitiesKit/Database/Migrations/OpenGroupServerIdLookupMigration.swift b/SignalUtilitiesKit/Database/Migrations/OpenGroupServerIdLookupMigration.swift index a98a0da07..31f7077f6 100644 --- a/SignalUtilitiesKit/Database/Migrations/OpenGroupServerIdLookupMigration.swift +++ b/SignalUtilitiesKit/Database/Migrations/OpenGroupServerIdLookupMigration.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import SessionMessagingKit @objc(SNOpenGroupServerIdLookupMigration) public class OpenGroupServerIdLookupMigration: OWSDatabaseMigration { @@ -20,7 +21,7 @@ public class OpenGroupServerIdLookupMigration: OWSDatabaseMigration { TSGroupThread.enumerateCollectionObjects(with: transaction) { object, _ in guard let thread: TSGroupThread = object as? TSGroupThread else { return } guard let threadId: String = thread.uniqueId else { return } - guard let openGroup: OpenGroupV2 = Storage.shared.getV2OpenGroup(for: threadId) else { return } + guard let openGroup: OpenGroup = Storage.shared.getOpenGroup(for: threadId) else { return } thread.enumerateInteractions(with: transaction) { interaction, _ in guard let tsMessage: TSMessage = interaction as? TSMessage else { return } From 8344ed5d811b49f3aee5d3e17d6bab661090db10 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 29 Mar 2022 10:15:22 +1100 Subject: [PATCH 052/157] Fixed the unit tests broken by the merge Added the ability to mock the GeneralCache data Added a couple additional tests to validate some updated OpenGroupManager code --- Session.xcodeproj/project.pbxproj | 4 + Session/Settings/NukeDataModal.swift | 4 +- .../Open Groups/OpenGroupManager.swift | 2 + .../Utilities/Dependencies.swift | 8 ++ SessionMessagingKit/Utilities/General.swift | 15 +- .../Open Groups/OpenGroupManagerSpec.swift | 128 +++++++++++++++++- .../_TestUtilities/DependencyExtensions.swift | 2 + .../_TestUtilities/MockGeneralCache.swift | 12 ++ .../_TestUtilities/MockStorage.swift | 26 +++- .../OGMDependencyExtensions.swift | 2 + 10 files changed, 189 insertions(+), 14 deletions(-) create mode 100644 SessionMessagingKitTests/_TestUtilities/MockGeneralCache.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 039414203..b7509e9fd 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -890,6 +890,7 @@ FDC438CF27BCA45400C60D73 /* Server.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438CE27BCA45400C60D73 /* Server.swift */; }; FDFD645927F26C6800808CA1 /* Array+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D12553860800C340D1 /* Array+Utilities.swift */; }; FDFD645B27F26D4600808CA1 /* Data+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFD645A27F26D4600808CA1 /* Data+Utilities.swift */; }; + FDFD645D27F273F300808CA1 /* MockGeneralCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -2049,6 +2050,7 @@ FDC438CC27BC641200C60D73 /* Set+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Set+Utilities.swift"; sourceTree = ""; }; FDC438CE27BCA45400C60D73 /* Server.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.swift; sourceTree = ""; }; FDFD645A27F26D4600808CA1 /* Data+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Data+Utilities.swift"; sourceTree = ""; }; + FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockGeneralCache.swift; sourceTree = ""; }; FEDBAE1B98C49BBE8C87F575 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; sourceTree = ""; }; FF9BA33D021B115B1F5B4E46 /* Pods-SessionMessagingKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionMessagingKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionMessagingKit/Pods-SessionMessagingKit.app store release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -4122,6 +4124,7 @@ children = ( FDC438BC27BB2AB400C60D73 /* Mockable.swift */, FD078E5F27E2BB36000769AF /* MockIdentityManager.swift */, + FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */, FDC4389C27BA01F000C60D73 /* MockStorage.swift */, FD859EF327C2F49200510D0C /* MockSodium.swift */, FD3C906E27E43E8700CD579F /* MockBox.swift */, @@ -5775,6 +5778,7 @@ FDC438BD27BB2AB400C60D73 /* Mockable.swift in Sources */, FD859EF627C2F52C00510D0C /* MockSign.swift in Sources */, FDC2908927D70656005DAE71 /* RoomPollInfoSpec.swift in Sources */, + FDFD645D27F273F300808CA1 /* MockGeneralCache.swift in Sources */, FD078E5A27E29F09000769AF /* MockNonce16Generator.swift in Sources */, FD3C906D27E43C4B00CD579F /* MessageSenderEncryptionSpec.swift in Sources */, FDC2908D27D70905005DAE71 /* UpdateMessageRequestSpec.swift in Sources */, diff --git a/Session/Settings/NukeDataModal.swift b/Session/Settings/NukeDataModal.swift index dba878a22..d08b30d9a 100644 --- a/Session/Settings/NukeDataModal.swift +++ b/Session/Settings/NukeDataModal.swift @@ -128,7 +128,7 @@ final class NukeDataModal : Modal { appDelegate.forceSyncConfigurationNowIfNeeded().ensure(on: DispatchQueue.main) { self?.dismiss(animated: true, completion: nil) // Dismiss the loader UserDefaults.removeAll() // Not done in the nuke data implementation as unlinking requires this to happen later - General.Cache.cachedEncodedPublicKey.mutate { $0 = nil } // Remove the cached key so it gets re-cached on next access + General.cache.mutate { $0.encodedPublicKey = nil } // Remove the cached key so it gets re-cached on next access NotificationCenter.default.post(name: .dataNukeRequested, object: nil) }.retainUntilComplete() } @@ -140,7 +140,7 @@ final class NukeDataModal : Modal { self?.dismiss(animated: true, completion: nil) // Dismiss the loader let potentiallyMaliciousSnodes = confirmations.compactMap { $0.value == false ? $0.key : nil } if potentiallyMaliciousSnodes.isEmpty { - General.Cache.cachedEncodedPublicKey.mutate { $0 = nil } // Remove the cached key so it gets re-cached on next access + General.cache.mutate { $0.encodedPublicKey = nil } // Remove the cached key so it gets re-cached on next access UserDefaults.removeAll() // Not done in the nuke data implementation as unlinking requires this to happen later NotificationCenter.default.post(name: .dataNukeRequested, object: nil) } else { diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index cd1bee054..ad777f3d4 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -742,6 +742,7 @@ extension OpenGroupManager { cache: Atomic? = nil, onionApi: OnionRequestAPIType.Type? = nil, identityManager: IdentityManagerProtocol? = nil, + generalCache: Atomic? = nil, storage: SessionMessagingKitStorageProtocol? = nil, sodium: SodiumType? = nil, box: BoxType? = nil, @@ -759,6 +760,7 @@ extension OpenGroupManager { super.init( onionApi: onionApi, identityManager: identityManager, + generalCache: generalCache, storage: storage, sodium: sodium, box: box, diff --git a/SessionMessagingKit/Utilities/Dependencies.swift b/SessionMessagingKit/Utilities/Dependencies.swift index 7eb2b56fe..b4709d0f0 100644 --- a/SessionMessagingKit/Utilities/Dependencies.swift +++ b/SessionMessagingKit/Utilities/Dependencies.swift @@ -18,6 +18,12 @@ public class Dependencies { set { _identityManager = newValue } } + internal var _generalCache: Atomic? + public var generalCache: Atomic { + get { Dependencies.getValueSettingIfNull(&_generalCache) { General.cache } } + set { _generalCache = newValue } + } + internal var _storage: SessionMessagingKitStorageProtocol? public var storage: SessionMessagingKitStorageProtocol { get { Dependencies.getValueSettingIfNull(&_storage) { SNMessagingKitConfiguration.shared.storage } } @@ -89,6 +95,7 @@ public class Dependencies { public init( onionApi: OnionRequestAPIType.Type? = nil, identityManager: IdentityManagerProtocol? = nil, + generalCache: Atomic? = nil, storage: SessionMessagingKitStorageProtocol? = nil, sodium: SodiumType? = nil, box: BoxType? = nil, @@ -103,6 +110,7 @@ public class Dependencies { ) { _onionApi = onionApi _identityManager = identityManager + _generalCache = generalCache _storage = storage _sodium = sodium _box = box diff --git a/SessionMessagingKit/Utilities/General.swift b/SessionMessagingKit/Utilities/General.swift index 61d164333..017357c46 100644 --- a/SessionMessagingKit/Utilities/General.swift +++ b/SessionMessagingKit/Utilities/General.swift @@ -1,9 +1,16 @@ import Foundation +import SessionUtilitiesKit + +public protocol GeneralCacheType { + var encodedPublicKey: String? { get set } +} public enum General { - public enum Cache { - public static var cachedEncodedPublicKey: Atomic = Atomic(nil) + public class Cache: GeneralCacheType { + public var encodedPublicKey: String? = nil } + + public static var cache: Atomic = Atomic(Cache()) } @objc(SNGeneralUtilities) @@ -14,10 +21,10 @@ public class GeneralUtilities: NSObject { } public func getUserHexEncodedPublicKey(using dependencies: Dependencies = Dependencies()) -> String { - if let cachedKey: String = General.Cache.cachedEncodedPublicKey.wrappedValue { return cachedKey } + if let cachedKey: String = dependencies.generalCache.wrappedValue.encodedPublicKey { return cachedKey } if let keyPair = dependencies.identityManager.identityKeyPair() { // Can be nil under some circumstances - General.Cache.cachedEncodedPublicKey.mutate { $0 = keyPair.hexEncodedPublicKey } + dependencies.generalCache.mutate { $0.encodedPublicKey = keyPair.hexEncodedPublicKey } return keyPair.hexEncodedPublicKey } diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift index d01388efa..1205194b2 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -71,6 +71,7 @@ class OpenGroupManagerSpec: QuickSpec { override func spec() { var mockOGMCache: MockOGMCache! var mockIdentityManager: MockIdentityManager! + var mockGeneralCache: MockGeneralCache! var mockStorage: MockStorage! var mockSodium: MockSodium! var mockAeadXChaCha20Poly1305Ietf: MockAeadXChaCha20Poly1305Ietf! @@ -100,6 +101,7 @@ class OpenGroupManagerSpec: QuickSpec { beforeEach { mockOGMCache = MockOGMCache() mockIdentityManager = MockIdentityManager() + mockGeneralCache = MockGeneralCache() mockStorage = MockStorage() mockSodium = MockSodium() mockAeadXChaCha20Poly1305Ietf = MockAeadXChaCha20Poly1305Ietf() @@ -112,6 +114,7 @@ class OpenGroupManagerSpec: QuickSpec { cache: Atomic(mockOGMCache), onionApi: TestCapabilitiesAndRoomApi.self, identityManager: mockIdentityManager, + generalCache: Atomic(mockGeneralCache), storage: mockStorage, sodium: mockSodium, genericHash: mockGenericHash, @@ -220,6 +223,7 @@ class OpenGroupManagerSpec: QuickSpec { privateKeyData: Data.data(fromHex: TestConstants.privateKey)! ) ) + mockGeneralCache.when { $0.encodedPublicKey }.thenReturn("05\(TestConstants.publicKey)") mockStorage .when { $0.write(with: { _ in }) } .then { [testTransaction] args in (args.first as? ((Any) -> Void))?(testTransaction! as Any) } @@ -2046,6 +2050,23 @@ class OpenGroupManagerSpec: QuickSpec { ) } .thenReturn(()) + mockStorage + .when { + $0.addOpenGroupServerIdLookup( + any(), + tsMessageId: any(), + in: any(), + on: any(), + using: testTransaction + ) + } + .thenReturn(()) + mockStorage + .when { $0.getOpenGroupServerIdLookup(any(), in: any(), on: any(), using: testTransaction) } + .thenReturn(nil) + mockStorage + .when { $0.removeOpenGroupServerIdLookup(any(), in: any(), on: any(), using: testTransaction) } + .thenReturn(nil) mockStorage.when { $0.getUserPublicKey() }.thenReturn("05\(TestConstants.publicKey)") mockStorage.when { $0.getReceivedMessageTimestamps(using: testTransaction as Any) }.thenReturn([]) mockStorage.when { $0.addReceivedMessageTimestamp(any(), using: testTransaction as Any) }.thenReturn(()) @@ -2196,6 +2217,31 @@ class OpenGroupManagerSpec: QuickSpec { ) } + it("adds the open group server id lookup") { + OpenGroupManager.handleMessages( + [testMessage], + for: "testRoom", + on: "testServer", + isBackgroundPoll: false, + using: testTransaction, + dependencies: dependencies + ) + + expect(mockStorage) + .toEventually( + call(matchingParameters: true) { + $0.addOpenGroupServerIdLookup( + 127, + tsMessageId: "TestMessageId", + in: "testRoom", + on: "testserver", + using: testTransaction + ) + }, + timeout: .milliseconds(50) + ) + } + it("processes valid messages when combined with invalid ones") { OpenGroupManager.handleMessages( [ @@ -2229,7 +2275,16 @@ class OpenGroupManagerSpec: QuickSpec { context("with no data") { it("deletes the message if we have the message") { - testTransaction.mockData[.objectForKey] = testGroupThread + mockStorage + .when { $0.getOpenGroupServerIdLookup(any(), in: any(), on: any(), using: testTransaction) } + .thenReturn( + OpenGroupServerIdLookup( + server: "testServer", + room: "testRoom", + serverId: 127, + tsMessageId: "TestMessageId" + ) + ) OpenGroupManager.handleMessages( [ @@ -2260,13 +2315,63 @@ class OpenGroupManagerSpec: QuickSpec { ) } - it("does nothing if we do not have the thread") { - testTransaction.mockData[.objectForKey] = nil + it("deletes the open group server lookup id if we have the message") { + mockStorage + .when { $0.getOpenGroupServerIdLookup(any(), in: any(), on: any(), using: testTransaction) } + .thenReturn( + OpenGroupServerIdLookup( + server: "testServer", + room: "testRoom", + serverId: 127, + tsMessageId: "TestMessageId" + ) + ) OpenGroupManager.handleMessages( [ OpenGroupAPI.Message( - id: 1, + id: 127, + sender: "05\(TestConstants.publicKey)", + posted: 123, + edited: nil, + seqNo: 123, + whisper: false, + whisperMods: false, + whisperTo: nil, + base64EncodedData: nil, + base64EncodedSignature: nil + ) + ], + for: "testRoom", + on: "testServer", + isBackgroundPoll: false, + using: testTransaction, + dependencies: dependencies + ) + + expect(mockStorage) + .toEventually( + call(matchingParameters: true) { + $0.removeOpenGroupServerIdLookup( + 127, + in: "testRoom", + on: "testServer", + using: testTransaction + ) + }, + timeout: .milliseconds(50) + ) + } + + it("does nothing if we do not have the lookup") { + mockStorage + .when { $0.getOpenGroupServerIdLookup(any(), in: any(), on: any(), using: testTransaction) } + .thenReturn(nil) + + OpenGroupManager.handleMessages( + [ + OpenGroupAPI.Message( + id: 127, sender: "05\(TestConstants.publicKey)", posted: 123, edited: nil, @@ -2293,8 +2398,17 @@ class OpenGroupManagerSpec: QuickSpec { } it("does nothing if we do not have the message") { - testGroupThread.mockData[.interactions] = [testInteraction] - testTransaction.mockData[.objectForKey] = testGroupThread + mockStorage + .when { $0.getOpenGroupServerIdLookup(any(), in: any(), on: any(), using: testTransaction) } + .thenReturn( + OpenGroupServerIdLookup( + server: "testServer", + room: "testRoom", + serverId: 127, + tsMessageId: "TestMessageId" + ) + ) + testTransaction.mockData[.objectForKey] = nil OpenGroupManager.handleMessages( [ @@ -3010,6 +3124,7 @@ class OpenGroupManagerSpec: QuickSpec { privateKeyData: Data.data(fromHex: TestConstants.privateKey)! ) ) + mockGeneralCache.when { $0.encodedPublicKey }.thenReturn("05\(otherKey)") mockStorage .when { $0.getUserED25519KeyPair() } .thenReturn( @@ -3158,6 +3273,7 @@ class OpenGroupManagerSpec: QuickSpec { privateKeyData: Data.data(fromHex: TestConstants.privateKey)! ) ) + mockGeneralCache.when { $0.encodedPublicKey }.thenReturn("05\(otherKey)") mockStorage .when { $0.getUserED25519KeyPair() } .thenReturn( diff --git a/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift b/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift index 4002f5af6..cf5021489 100644 --- a/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift +++ b/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift @@ -9,6 +9,7 @@ extension Dependencies { public func with( onionApi: OnionRequestAPIType.Type? = nil, identityManager: IdentityManagerProtocol? = nil, + generalCache: Atomic? = nil, storage: SessionMessagingKitStorageProtocol? = nil, sodium: SodiumType? = nil, box: BoxType? = nil, @@ -24,6 +25,7 @@ extension Dependencies { return Dependencies( onionApi: (onionApi ?? self._onionApi), identityManager: (identityManager ?? self._identityManager), + generalCache: (generalCache ?? self._generalCache), storage: (storage ?? self._storage), sodium: (sodium ?? self._sodium), box: (box ?? self._box), diff --git a/SessionMessagingKitTests/_TestUtilities/MockGeneralCache.swift b/SessionMessagingKitTests/_TestUtilities/MockGeneralCache.swift new file mode 100644 index 000000000..9a0d5d0a6 --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/MockGeneralCache.swift @@ -0,0 +1,12 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +@testable import SessionMessagingKit + +class MockGeneralCache: Mock, GeneralCacheType { + var encodedPublicKey: String? { + get { return accept() as? String } + set { accept(args: [newValue]) } + } +} diff --git a/SessionMessagingKitTests/_TestUtilities/MockStorage.swift b/SessionMessagingKitTests/_TestUtilities/MockStorage.swift index 7440c80da..093345bfe 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockStorage.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockStorage.swift @@ -27,6 +27,9 @@ class MockStorage: Mock, SessionMessagingKit func getUserKeyPair() -> ECKeyPair? { return accept() as? ECKeyPair } func getUserED25519KeyPair() -> Box.KeyPair? { return accept() as? Box.KeyPair } func getUser() -> Contact? { return accept() as? Contact } + func getUser(using transaction: YapDatabaseReadTransaction?) -> Contact? { + return accept(args: [transaction]) as? Contact + } // MARK: - Contacts @@ -74,11 +77,17 @@ class MockStorage: Mock, SessionMessagingKit accept(args: [groupPublicKey, transaction]) } func getUserClosedGroupPublicKeys() -> Set { return accept() as! Set } - func getZombieMembers(for groupPublicKey: String) -> Set { return accept() as! Set } + func getUserClosedGroupPublicKeys(using transaction: YapDatabaseReadTransaction) -> Set { + return accept(args: [transaction]) as! Set + } + func getZombieMembers(for groupPublicKey: String) -> Set { return accept(args: [groupPublicKey]) as! Set } func setZombieMembers(for groupPublicKey: String, to zombies: Set, using transaction: Any) { accept(args: [groupPublicKey, zombies, transaction]) } - func isClosedGroup(_ publicKey: String) -> Bool { return accept() as! Bool } + func isClosedGroup(_ publicKey: String) -> Bool { return accept(args: [publicKey]) as! Bool } + func isClosedGroup(_ publicKey: String, using transaction: YapDatabaseReadTransaction) -> Bool { + return accept(args: [publicKey, transaction]) as! Bool + } // MARK: - Jobs @@ -141,6 +150,19 @@ class MockStorage: Mock, SessionMessagingKit accept(args: [room, server, transaction]) } + func getOpenGroupServerIdLookup(_ serverId: UInt64, in room: String, on server: String, using transaction: YapDatabaseReadTransaction) -> OpenGroupServerIdLookup? { + return accept(args: [serverId, room, server, transaction]) as? OpenGroupServerIdLookup + } + func addOpenGroupServerIdLookup(_ serverId: UInt64?, tsMessageId: String?, in room: String, on server: String, using transaction: YapDatabaseReadWriteTransaction) { + accept(args: [serverId, tsMessageId, room, server, transaction]) + } + func addOpenGroupServerIdLookup(_ lookup: OpenGroupServerIdLookup, using transaction: YapDatabaseReadWriteTransaction) { + accept(args: [lookup, transaction]) + } + func removeOpenGroupServerIdLookup(_ serverId: UInt64, in room: String, on server: String, using transaction: YapDatabaseReadWriteTransaction) { + accept(args: [serverId, room, server, transaction]) + } + func getOpenGroupInboxLatestMessageId(for server: String) -> Int64? { return accept(args: [server]) as? Int64 } func setOpenGroupInboxLatestMessageId(for server: String, to newValue: Int64, using transaction: Any) { accept(args: [server, newValue, transaction]) diff --git a/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift b/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift index fce02cfeb..dd0a8413b 100644 --- a/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift +++ b/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift @@ -10,6 +10,7 @@ extension OpenGroupManager.OGMDependencies { cache: Atomic? = nil, onionApi: OnionRequestAPIType.Type? = nil, identityManager: IdentityManagerProtocol? = nil, + generalCache: Atomic? = nil, storage: SessionMessagingKitStorageProtocol? = nil, sodium: SodiumType? = nil, box: BoxType? = nil, @@ -26,6 +27,7 @@ extension OpenGroupManager.OGMDependencies { cache: (cache ?? self._mutableCache), onionApi: (onionApi ?? self._onionApi), identityManager: (identityManager ?? self._identityManager), + generalCache: (generalCache ?? self._generalCache), storage: (storage ?? self._storage), sodium: (sodium ?? self._sodium), box: (box ?? self._box), From 529e416dd1981f3371deb8c01cb28e6fd64275f2 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 31 Mar 2022 11:47:09 +1100 Subject: [PATCH 053/157] Started work on GRDB logic and migrations Setup a migration pattern Setup the database configuration and security Started defining the database schema Started working on the migrations for SessionSnodeKit --- Session.xcodeproj/project.pbxproj | 154 +++++++++++- Session/Path/PathVC.swift | 2 +- SessionMessagingKit/Configuration.swift | 17 +- .../LegacyDatabase/SMKLegacyModels.swift | 6 + .../_001_InitialSetupMigration.swift | 49 ++++ .../Migrations/_002_YDBToGRDBMigration.swift | 14 ++ .../Sending & Receiving/Pollers/Poller.swift | 4 +- SessionSnodeKit/Configuration.swift | 15 ++ .../LegacyDatabase/SSKLegacyModels.swift | 80 +++++++ .../_001_InitialSetupMigration.swift | 47 ++++ .../Migrations/_002_YDBToGRDBMigration.swift | 138 +++++++++++ SessionSnodeKit/Database/Models/Snode.swift | 21 ++ .../Models/SnodeReceivedMessageInfo.swift | 19 ++ .../Database/Models/SnodeSet.swift | 27 +++ .../Database/Types/SSKSetting.swift | 3 + SessionSnodeKit/OnionRequestAPI.swift | 34 +-- SessionSnodeKit/Snode.swift | 65 ----- SessionSnodeKit/SnodeAPI.swift | 78 +++--- SessionSnodeKit/Storage+SnodeAPI.swift | 27 ++- SessionSnodeKit/Storage.swift | 14 +- .../Database/GRDBStorage.swift | 224 ++++++++++++++++++ .../Database/SSKKeychainStorage.swift | 30 ++- .../Database/Types/ColumnExpressible.swift | 8 + .../Database/Types/Migration.swift | 10 + .../Database/Types/SettingType.swift | 3 + .../Database/Types/TargetMigrations.swift | 59 +++++ .../Database/Types/TypedTableDefinition.swift | 33 +++ .../ColumnDefinition+Utilities.swift | 22 ++ .../Utilities/Database+Utilities.swift | 18 ++ .../DatabaseMigrator+Utilities.swift | 10 + .../Utilities/GRDB+Notifications.swift | 11 + .../General/Set+Utilities.swift | 19 ++ SignalUtilitiesKit/Configuration.swift | 24 +- 33 files changed, 1126 insertions(+), 159 deletions(-) create mode 100644 SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift create mode 100644 SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift create mode 100644 SessionMessagingKit/Database/Migrations/_002_YDBToGRDBMigration.swift create mode 100644 SessionSnodeKit/Database/LegacyDatabase/SSKLegacyModels.swift create mode 100644 SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift create mode 100644 SessionSnodeKit/Database/Migrations/_002_YDBToGRDBMigration.swift create mode 100644 SessionSnodeKit/Database/Models/Snode.swift create mode 100644 SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift create mode 100644 SessionSnodeKit/Database/Models/SnodeSet.swift create mode 100644 SessionSnodeKit/Database/Types/SSKSetting.swift delete mode 100644 SessionSnodeKit/Snode.swift create mode 100644 SessionUtilitiesKit/Database/GRDBStorage.swift create mode 100644 SessionUtilitiesKit/Database/Types/ColumnExpressible.swift create mode 100644 SessionUtilitiesKit/Database/Types/Migration.swift create mode 100644 SessionUtilitiesKit/Database/Types/SettingType.swift create mode 100644 SessionUtilitiesKit/Database/Types/TargetMigrations.swift create mode 100644 SessionUtilitiesKit/Database/Types/TypedTableDefinition.swift create mode 100644 SessionUtilitiesKit/Database/Utilities/ColumnDefinition+Utilities.swift create mode 100644 SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift create mode 100644 SessionUtilitiesKit/Database/Utilities/DatabaseMigrator+Utilities.swift create mode 100644 SessionUtilitiesKit/Database/Utilities/GRDB+Notifications.swift create mode 100644 SessionUtilitiesKit/General/Set+Utilities.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 45c178a6f..d5544efd6 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -765,6 +765,26 @@ F5765D284BC6ECAC0C1D33F0 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionNotificationServiceExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E4A93ECA93B3DE800CC7D7F6 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionNotificationServiceExtension.framework */; }; FC3BD9881A30A790005B96BB /* Social.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FC3BD9871A30A790005B96BB /* Social.framework */; }; FCB11D8C1A129A76002F93FB /* CoreMedia.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FCB11D8B1A129A76002F93FB /* CoreMedia.framework */; }; + FD17D79927F40AB800122BE0 /* _002_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79827F40AB800122BE0 /* _002_YDBToGRDBMigration.swift */; }; + FD17D79C27F40B2E00122BE0 /* SMKLegacyModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */; }; + FD17D7A027F40CC800122BE0 /* _001_InitialSetupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79F27F40CC800122BE0 /* _001_InitialSetupMigration.swift */; }; + FD17D7A127F40D2500122BE0 /* GRDBStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD28A4F527EAD44C00FF65E7 /* GRDBStorage.swift */; }; + FD17D7A227F40F0500122BE0 /* _001_InitialSetupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79527F3E04600122BE0 /* _001_InitialSetupMigration.swift */; }; + FD17D7A427F40F8100122BE0 /* _002_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7A327F40F8100122BE0 /* _002_YDBToGRDBMigration.swift */; }; + FD17D7A727F41AF000122BE0 /* SSKLegacyModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7A627F41AF000122BE0 /* SSKLegacyModels.swift */; }; + FD17D7AA27F41BF500122BE0 /* SnodeSet.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7A927F41BF500122BE0 /* SnodeSet.swift */; }; + FD17D7AE27F41C4300122BE0 /* SnodeReceivedMessageInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7AD27F41C4300122BE0 /* SnodeReceivedMessageInfo.swift */; }; + FD17D7B027F4225C00122BE0 /* Set+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7AF27F4225C00122BE0 /* Set+Utilities.swift */; }; + FD17D7B327F51E5B00122BE0 /* SSKSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7B227F51E5B00122BE0 /* SSKSetting.swift */; }; + FD17D7B627F51E7300122BE0 /* SettingType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7B527F51E7300122BE0 /* SettingType.swift */; }; + FD17D7B827F51ECA00122BE0 /* Migration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7B727F51ECA00122BE0 /* Migration.swift */; }; + FD17D7BA27F51F2100122BE0 /* TargetMigrations.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7B927F51F2100122BE0 /* TargetMigrations.swift */; }; + FD17D7BD27F51F6900122BE0 /* GRDB+Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7BC27F51F6900122BE0 /* GRDB+Notifications.swift */; }; + FD17D7BF27F51F8200122BE0 /* ColumnExpressible.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7BE27F51F8200122BE0 /* ColumnExpressible.swift */; }; + FD17D7C127F5200100122BE0 /* TypedTableDefinition.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7C027F5200100122BE0 /* TypedTableDefinition.swift */; }; + FD17D7C327F5204C00122BE0 /* Database+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7C227F5204C00122BE0 /* Database+Utilities.swift */; }; + FD17D7C527F5206300122BE0 /* ColumnDefinition+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7C427F5206300122BE0 /* ColumnDefinition+Utilities.swift */; }; + FD17D7C727F5207C00122BE0 /* DatabaseMigrator+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7C627F5207C00122BE0 /* DatabaseMigrator+Utilities.swift */; }; FD5D200F27AA2B6000FEA984 /* MessageRequestResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */; }; FD5D201127AA331F00FEA984 /* ConfigurationMessage+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D201027AA331F00FEA984 /* ConfigurationMessage+Convenience.swift */; }; FD659AC027A7649600F12C02 /* MessageRequestsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD659ABF27A7649600F12C02 /* MessageRequestsViewController.swift */; }; @@ -1792,6 +1812,26 @@ F9BBF530D71905BA9007675F /* Pods-SessionShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionShareExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SessionShareExtension/Pods-SessionShareExtension.debug.xcconfig"; sourceTree = ""; }; FC3BD9871A30A790005B96BB /* Social.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Social.framework; path = System/Library/Frameworks/Social.framework; sourceTree = SDKROOT; }; FCB11D8B1A129A76002F93FB /* CoreMedia.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreMedia.framework; path = System/Library/Frameworks/CoreMedia.framework; sourceTree = SDKROOT; }; + FD17D79527F3E04600122BE0 /* _001_InitialSetupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _001_InitialSetupMigration.swift; sourceTree = ""; }; + FD17D79827F40AB800122BE0 /* _002_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_YDBToGRDBMigration.swift; sourceTree = ""; }; + FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMKLegacyModels.swift; sourceTree = ""; }; + FD17D79F27F40CC800122BE0 /* _001_InitialSetupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _001_InitialSetupMigration.swift; sourceTree = ""; }; + FD17D7A327F40F8100122BE0 /* _002_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_YDBToGRDBMigration.swift; sourceTree = ""; }; + FD17D7A627F41AF000122BE0 /* SSKLegacyModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSKLegacyModels.swift; sourceTree = ""; }; + FD17D7A927F41BF500122BE0 /* SnodeSet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnodeSet.swift; sourceTree = ""; }; + FD17D7AD27F41C4300122BE0 /* SnodeReceivedMessageInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnodeReceivedMessageInfo.swift; sourceTree = ""; }; + FD17D7AF27F4225C00122BE0 /* Set+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Set+Utilities.swift"; sourceTree = ""; }; + FD17D7B227F51E5B00122BE0 /* SSKSetting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSKSetting.swift; sourceTree = ""; }; + FD17D7B527F51E7300122BE0 /* SettingType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingType.swift; sourceTree = ""; }; + FD17D7B727F51ECA00122BE0 /* Migration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Migration.swift; sourceTree = ""; }; + FD17D7B927F51F2100122BE0 /* TargetMigrations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TargetMigrations.swift; sourceTree = ""; }; + FD17D7BC27F51F6900122BE0 /* GRDB+Notifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GRDB+Notifications.swift"; sourceTree = ""; }; + FD17D7BE27F51F8200122BE0 /* ColumnExpressible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColumnExpressible.swift; sourceTree = ""; }; + FD17D7C027F5200100122BE0 /* TypedTableDefinition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypedTableDefinition.swift; sourceTree = ""; }; + FD17D7C227F5204C00122BE0 /* Database+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Database+Utilities.swift"; sourceTree = ""; }; + FD17D7C427F5206300122BE0 /* ColumnDefinition+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ColumnDefinition+Utilities.swift"; sourceTree = ""; }; + FD17D7C627F5207C00122BE0 /* DatabaseMigrator+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DatabaseMigrator+Utilities.swift"; sourceTree = ""; }; + FD28A4F527EAD44C00FF65E7 /* GRDBStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GRDBStorage.swift; sourceTree = ""; }; FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestResponse.swift; sourceTree = ""; }; FD5D201027AA331F00FEA984 /* ConfigurationMessage+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConfigurationMessage+Convenience.swift"; sourceTree = ""; }; FD659ABF27A7649600F12C02 /* MessageRequestsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsViewController.swift; sourceTree = ""; }; @@ -2243,6 +2283,9 @@ B8A582AB258C64E800AFD84C /* Database */ = { isa = PBXGroup; children = ( + FD17D7B427F51E6700122BE0 /* Types */, + FD17D7BB27F51F5C00122BE0 /* Utilities */, + FD28A4F527EAD44C00FF65E7 /* GRDBStorage.swift */, C33FDBAB255A581500E217F9 /* OWSFileSystem.h */, C33FDA8E255A57FD00E217F9 /* OWSFileSystem.m */, C3D9E41E25676C870040E4F3 /* OWSPrimaryStorageProtocol.swift */, @@ -2338,6 +2381,7 @@ C33FDB51255A580D00E217F9 /* NSUserDefaults+OWS.h */, C33FDB77255A581000E217F9 /* NSUserDefaults+OWS.m */, C33FDB14255A580800E217F9 /* OWSMath.h */, + FD17D7AF27F4225C00122BE0 /* Set+Utilities.swift */, C33FDB6B255A580F00E217F9 /* SNUserDefaults.swift */, C33FDB3F255A580C00E217F9 /* String+SSK.swift */, C3C2AC2D2553CBEB00C340D1 /* String+Trimming.swift */, @@ -2654,6 +2698,8 @@ C32C5BCB256DC818003C73A2 /* Database */ = { isa = PBXGroup; children = ( + FD17D79A27F40ADA00122BE0 /* LegacyDatabase */, + FD17D79427F3E03300122BE0 /* Migrations */, B8F5F56425EC8453003BF8D4 /* Notification+Contacts.swift */, C33FDAEA255A580500E217F9 /* OWSBackupFragment.h */, C33FDB07255A580700E217F9 /* OWSBackupFragment.m */, @@ -3220,11 +3266,11 @@ isa = PBXGroup; children = ( C3C2A5B0255385C700C340D1 /* Meta */, + FD17D79D27F40CAA00122BE0 /* Database */, C3C2A5B9255385ED00C340D1 /* Configuration.swift */, C3C2A5BD255385EE00C340D1 /* Notification+OnionRequestAPI.swift */, C3C2A5BA255385ED00C340D1 /* OnionRequestAPI.swift */, C3C2A5BB255385ED00C340D1 /* OnionRequestAPI+Encryption.swift */, - C3C2A5B7255385EC00C340D1 /* Snode.swift */, C3C2A5BE255385EE00C340D1 /* SnodeAPI.swift */, C3C2A5B6255385EC00C340D1 /* SnodeMessage.swift */, C3C2A5B8255385EC00C340D1 /* Storage.swift */, @@ -3579,6 +3625,92 @@ path = Session; sourceTree = ""; }; + FD17D79427F3E03300122BE0 /* Migrations */ = { + isa = PBXGroup; + children = ( + FD17D79527F3E04600122BE0 /* _001_InitialSetupMigration.swift */, + FD17D79827F40AB800122BE0 /* _002_YDBToGRDBMigration.swift */, + ); + path = Migrations; + sourceTree = ""; + }; + FD17D79A27F40ADA00122BE0 /* LegacyDatabase */ = { + isa = PBXGroup; + children = ( + FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */, + ); + path = LegacyDatabase; + sourceTree = ""; + }; + FD17D79D27F40CAA00122BE0 /* Database */ = { + isa = PBXGroup; + children = ( + FD17D7A527F41ADE00122BE0 /* LegacyDatabase */, + FD17D79E27F40CC000122BE0 /* Migrations */, + FD17D7A827F41BE300122BE0 /* Models */, + FD17D7B127F51E2B00122BE0 /* Types */, + ); + path = Database; + sourceTree = ""; + }; + FD17D79E27F40CC000122BE0 /* Migrations */ = { + isa = PBXGroup; + children = ( + FD17D79F27F40CC800122BE0 /* _001_InitialSetupMigration.swift */, + FD17D7A327F40F8100122BE0 /* _002_YDBToGRDBMigration.swift */, + ); + path = Migrations; + sourceTree = ""; + }; + FD17D7A527F41ADE00122BE0 /* LegacyDatabase */ = { + isa = PBXGroup; + children = ( + FD17D7A627F41AF000122BE0 /* SSKLegacyModels.swift */, + ); + path = LegacyDatabase; + sourceTree = ""; + }; + FD17D7A827F41BE300122BE0 /* Models */ = { + isa = PBXGroup; + children = ( + C3C2A5B7255385EC00C340D1 /* Snode.swift */, + FD17D7A927F41BF500122BE0 /* SnodeSet.swift */, + FD17D7AD27F41C4300122BE0 /* SnodeReceivedMessageInfo.swift */, + ); + path = Models; + sourceTree = ""; + }; + FD17D7B127F51E2B00122BE0 /* Types */ = { + isa = PBXGroup; + children = ( + FD17D7B227F51E5B00122BE0 /* SSKSetting.swift */, + ); + path = Types; + sourceTree = ""; + }; + FD17D7B427F51E6700122BE0 /* Types */ = { + isa = PBXGroup; + children = ( + FD17D7BE27F51F8200122BE0 /* ColumnExpressible.swift */, + FD17D7B727F51ECA00122BE0 /* Migration.swift */, + FD17D7B527F51E7300122BE0 /* SettingType.swift */, + FD17D7B927F51F2100122BE0 /* TargetMigrations.swift */, + FD17D7C027F5200100122BE0 /* TypedTableDefinition.swift */, + ); + path = Types; + sourceTree = ""; + }; + FD17D7BB27F51F5C00122BE0 /* Utilities */ = { + isa = PBXGroup; + children = ( + FD17D7C427F5206300122BE0 /* ColumnDefinition+Utilities.swift */, + FD17D7C227F5204C00122BE0 /* Database+Utilities.swift */, + FD17D7C627F5207C00122BE0 /* DatabaseMigrator+Utilities.swift */, + FD17D7BC27F51F6900122BE0 /* GRDB+Notifications.swift */, + ); + path = Utilities; + sourceTree = ""; + }; FD659ABE27A7648200F12C02 /* Message Requests */ = { isa = PBXGroup; children = ( @@ -4576,16 +4708,22 @@ C3C2A5E02553860B00C340D1 /* Threading.swift in Sources */, C3C2A5BF255385EE00C340D1 /* SnodeMessage.swift in Sources */, C3C2A5C0255385EE00C340D1 /* Snode.swift in Sources */, + FD17D7A027F40CC800122BE0 /* _001_InitialSetupMigration.swift in Sources */, C3C2A5C7255385EE00C340D1 /* SnodeAPI.swift in Sources */, C32C5CBF256DD282003C73A2 /* Storage+SnodeAPI.swift in Sources */, C3C2A5C6255385EE00C340D1 /* Notification+OnionRequestAPI.swift in Sources */, + FD17D7AA27F41BF500122BE0 /* SnodeSet.swift in Sources */, + FD17D7A427F40F8100122BE0 /* _002_YDBToGRDBMigration.swift in Sources */, C32C5CBE256DD282003C73A2 /* Storage+OnionRequests.swift in Sources */, C3C2A5DC2553860B00C340D1 /* Promise+Threading.swift in Sources */, C3C2A5C4255385EE00C340D1 /* OnionRequestAPI+Encryption.swift in Sources */, C3C2A5DE2553860B00C340D1 /* String+Trimming.swift in Sources */, C3C2A5DB2553860B00C340D1 /* Promise+Hashing.swift in Sources */, C3C2A5E42553860B00C340D1 /* Data+Utilities.swift in Sources */, + FD17D7A727F41AF000122BE0 /* SSKLegacyModels.swift in Sources */, C3C2A5C2255385EE00C340D1 /* Configuration.swift in Sources */, + FD17D7B327F51E5B00122BE0 /* SSKSetting.swift in Sources */, + FD17D7AE27F41C4300122BE0 /* SnodeReceivedMessageInfo.swift in Sources */, C3C2A5C3255385EE00C340D1 /* OnionRequestAPI.swift in Sources */, C3C2A5C1255385EE00C340D1 /* Storage.swift in Sources */, ); @@ -4597,6 +4735,7 @@ files = ( C3AABDDF2553ECF00042FF4C /* Array+Utilities.swift in Sources */, 7B1D74AC27BDE7510030B423 /* Promise+Timeout.swift in Sources */, + FD17D7B627F51E7300122BE0 /* SettingType.swift in Sources */, C3AABDDF2553ECF00042FF4C /* Array+Utilities.swift in Sources */, C32C5A47256DB8F0003C73A2 /* ECKeyPair+Hexadecimal.swift in Sources */, C3D9E41525676C320040E4F3 /* Storage.swift in Sources */, @@ -4604,6 +4743,7 @@ C32C5D83256DD5B6003C73A2 /* SSKKeychainStorage.swift in Sources */, C3D9E39B256763C20040E4F3 /* AppContext.m in Sources */, C3BBE0A82554D4DE0050F1E3 /* JSON.swift in Sources */, + FD17D7C127F5200100122BE0 /* TypedTableDefinition.swift in Sources */, C352A36D2557858E00338F3E /* NSTimer+Proxying.m in Sources */, C32C5A2D256DB849003C73A2 /* LKGroupUtilities.m in Sources */, C3C2ABD22553C6C900C340D1 /* Data+SecureRandom.swift in Sources */, @@ -4617,6 +4757,7 @@ C3A7211A2558BCA10043A11F /* DiffieHellman.swift in Sources */, C32C5FA1256DFED5003C73A2 /* NSArray+Functional.m in Sources */, C3A7225E2558C38D0043A11F /* Promise+Retaining.swift in Sources */, + FD17D7BD27F51F6900122BE0 /* GRDB+Notifications.swift in Sources */, C3D9E4D12567777D0040E4F3 /* OWSMediaUtils.swift in Sources */, C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Description.swift in Sources */, C3D9E4DA256778410040E4F3 /* UIImage+OWS.m in Sources */, @@ -4624,21 +4765,28 @@ C3D9E35E25675F640040E4F3 /* OWSFileSystem.m in Sources */, C3C2AC2E2553CBEB00C340D1 /* String+Trimming.swift in Sources */, C32C5B48256DC211003C73A2 /* NSNotificationCenter+OWS.m in Sources */, + FD17D7C727F5207C00122BE0 /* DatabaseMigrator+Utilities.swift in Sources */, C3D9E3C925676AF30040E4F3 /* TSYapDatabaseObject.m in Sources */, C352A3A62557B60D00338F3E /* TSRequest.m in Sources */, FD705A92278D051200F16121 /* ReusableView.swift in Sources */, + FD17D7BA27F51F2100122BE0 /* TargetMigrations.swift in Sources */, + FD17D7C327F5204C00122BE0 /* Database+Utilities.swift in Sources */, B8AE75A425A6C6A6001A84D2 /* Data+Trimming.swift in Sources */, + FD17D7C527F5206300122BE0 /* ColumnDefinition+Utilities.swift in Sources */, B8856DE6256F15F2001CE70E /* String+SSK.swift in Sources */, C3471ED42555386B00297E91 /* AESGCM.swift in Sources */, + FD17D7B827F51ECA00122BE0 /* Migration.swift in Sources */, C32C5DDB256DD9FF003C73A2 /* ContentProxy.swift in Sources */, C3A71F892558BA9F0043A11F /* Mnemonic.swift in Sources */, B8F5F58325EC94A6003BF8D4 /* Collection+Subscripting.swift in Sources */, C33FDEF8255A656D00E217F9 /* Promise+Delaying.swift in Sources */, B8BC00C0257D90E30032E807 /* General.swift in Sources */, + FD17D7A127F40D2500122BE0 /* GRDBStorage.swift in Sources */, C32C5A24256DB7DB003C73A2 /* SNUserDefaults.swift in Sources */, C3D9E41F25676C870040E4F3 /* OWSPrimaryStorageProtocol.swift in Sources */, C3BBE0A72554D4DE0050F1E3 /* Promise+Retrying.swift in Sources */, B8856D7B256F14F4001CE70E /* UIView+OWS.m in Sources */, + FD17D7B027F4225C00122BE0 /* Set+Utilities.swift in Sources */, B88FA7FB26114EA70049422F /* Hex.swift in Sources */, C35D0DB525AE5F1200B6BF49 /* UIEdgeInsets.swift in Sources */, C3D9E4F4256778AF0040E4F3 /* NSData+Image.m in Sources */, @@ -4646,6 +4794,7 @@ C3BBE0A92554D4DE0050F1E3 /* HTTP.swift in Sources */, B8856D23256F116B001CE70E /* Weak.swift in Sources */, C32C5A48256DB8F0003C73A2 /* BuildConfiguration.swift in Sources */, + FD17D7BF27F51F8200122BE0 /* ColumnExpressible.swift in Sources */, B87EF18126377A1D00124B3C /* Features.swift in Sources */, C300A60D2554B31900555489 /* Logging.swift in Sources */, B8FF8EA625C11FEF004D1F22 /* IPv4.swift in Sources */, @@ -4705,6 +4854,7 @@ C3A3A107256E1A5C004D228D /* OWSDisappearingMessagesFinder.m in Sources */, C32C59C3256DB41F003C73A2 /* TSGroupModel.m in Sources */, B8856ECE256F1E58001CE70E /* OWSPreferences.m in Sources */, + FD17D79927F40AB800122BE0 /* _002_YDBToGRDBMigration.swift in Sources */, C3C2A7842553AAF300C340D1 /* SNProto.swift in Sources */, C32C5DC0256DD743003C73A2 /* Poller.swift in Sources */, C3C2A7682553A3D900C340D1 /* VisibleMessage+Contact.swift in Sources */, @@ -4717,6 +4867,7 @@ B8566C63256F55930045A0B9 /* OWSLinkPreview+Conversion.swift in Sources */, C32C5B3F256DC1DF003C73A2 /* TSQuotedMessage+Conversion.swift in Sources */, B8EB20EE2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift in Sources */, + FD17D7A227F40F0500122BE0 /* _001_InitialSetupMigration.swift in Sources */, C3C2A7712553A41E00C340D1 /* ControlMessage.swift in Sources */, C32C5D19256DD493003C73A2 /* OWSLinkPreview.swift in Sources */, C32C5CF0256DD3E4003C73A2 /* Storage+Shared.swift in Sources */, @@ -4786,6 +4937,7 @@ C32C5FD6256E0346003C73A2 /* Notification+Thread.swift in Sources */, C3BBE0C72554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift in Sources */, C32C5C88256DD0D2003C73A2 /* Storage+Messaging.swift in Sources */, + FD17D79C27F40B2E00122BE0 /* SMKLegacyModels.swift in Sources */, C32C59C7256DB41F003C73A2 /* TSThread.m in Sources */, C300A5B22554AF9800555489 /* VisibleMessage+Profile.swift in Sources */, C32C5A75256DBBCF003C73A2 /* TSAttachmentPointer+Conversion.swift in Sources */, diff --git a/Session/Path/PathVC.swift b/Session/Path/PathVC.swift index 703f24e20..649501e6f 100644 --- a/Session/Path/PathVC.swift +++ b/Session/Path/PathVC.swift @@ -156,7 +156,7 @@ final class PathVC : BaseVC { return stackView } - private func getPathRow(snode: Snode, location: LineView.Location, dotAnimationStartDelay: Double, dotAnimationRepeatInterval: Double, isGuardSnode: Bool) -> UIStackView { + private func getPathRow(snode: Legacy.Snode, location: LineView.Location, dotAnimationStartDelay: Double, dotAnimationRepeatInterval: Double, isGuardSnode: Bool) -> UIStackView { let country = IP2Country.isInitialized ? (IP2Country.shared.countryNamesCache[snode.ip] ?? "Resolving...") : "Resolving..." let title = isGuardSnode ? NSLocalizedString("vc_path_guard_node_row_title", comment: "") : NSLocalizedString("vc_path_service_node_row_title", comment: "") return getPathRow(title: title, subtitle: country, location: location, dotAnimationStartDelay: dotAnimationStartDelay, dotAnimationRepeatInterval: dotAnimationRepeatInterval) diff --git a/SessionMessagingKit/Configuration.swift b/SessionMessagingKit/Configuration.swift index 2af8d4c48..197827a2d 100644 --- a/SessionMessagingKit/Configuration.swift +++ b/SessionMessagingKit/Configuration.swift @@ -1,3 +1,5 @@ +import Foundation +import SessionUtilitiesKit @objc public final class SNMessagingKitConfiguration : NSObject { @@ -11,7 +13,20 @@ public final class SNMessagingKitConfiguration : NSObject { } public enum SNMessagingKit { // Just to make the external API nice - + public static func migrations() -> TargetMigrations { + return TargetMigrations( + identifier: .messagingKit, + migrations: [ + [ + _001_InitialSetupMigration.self + ], + [ + _002_YDBToGRDBMigration.self + ] + ] + ) + } + public static func configure(storage: SessionMessagingKitStorageProtocol) { SNMessagingKitConfiguration.shared = SNMessagingKitConfiguration(storage: storage) } diff --git a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift new file mode 100644 index 000000000..79e14cd58 --- /dev/null +++ b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift @@ -0,0 +1,6 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +enum Legacy { +} diff --git a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift new file mode 100644 index 000000000..3971a1e96 --- /dev/null +++ b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -0,0 +1,49 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +// TODO: Remove/Move these +struct Place: Codable, FetchableRecord, PersistableRecord, ColumnExpressible { + static var databaseTableName: String { "place" } + + public enum Columns: String, CodingKey, ColumnExpression { + case id + case name + } + + let id: String + let name: String +} + +struct Setting: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + static var databaseTableName: String { "settings" } + + public enum Columns: String, CodingKey, ColumnExpression { + case key + case value + } + + let key: String + let value: Data +} + +enum _001_InitialSetupMigration: Migration { + static let identifier: String = "initialSetup" + + static func migrate(_ db: Database) throws { + try db.create(table: Setting.self) { t in + t.column(.key, .text) + .notNull() + .unique(onConflict: .abort) + .primaryKey() + t.column(.value, .blob).notNull() + } + + try db.create(table: Place.self) { t in + t.column(.id, .text).notNull().primaryKey() + t.column(.name, .text).notNull() + } + } +} diff --git a/SessionMessagingKit/Database/Migrations/_002_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_002_YDBToGRDBMigration.swift new file mode 100644 index 000000000..3ccc6a5cf --- /dev/null +++ b/SessionMessagingKit/Database/Migrations/_002_YDBToGRDBMigration.swift @@ -0,0 +1,14 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +enum _002_YDBToGRDBMigration: Migration { + static let identifier: String = "YDBToGRDBMigration" + + static func migrate(_ db: Database) throws { + + + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift index 623acf1ff..b7aaca193 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift @@ -5,7 +5,7 @@ import PromiseKit public final class Poller : NSObject { private let storage = OWSPrimaryStorage.shared() private var isPolling = false - private var usedSnodes = Set() + private var usedSnodes = Set() private var pollCount = 0 // MARK: Settings @@ -89,7 +89,7 @@ public final class Poller : NSObject { } } - private func poll(_ snode: Snode, seal longTermSeal: Resolver) -> Promise { + private func poll(_ snode: SessionSnodeKit.Legacy.Snode, seal longTermSeal: Resolver) -> Promise { guard isPolling else { return Promise { $0.fulfill(()) } } let userPublicKey = getUserHexEncodedPublicKey() return SnodeAPI.getRawMessages(from: snode, associatedWith: userPublicKey).then(on: Threading.pollerQueue) { [weak self] rawResponse -> Promise in diff --git a/SessionSnodeKit/Configuration.swift b/SessionSnodeKit/Configuration.swift index b880ae6e1..24a3074d5 100644 --- a/SessionSnodeKit/Configuration.swift +++ b/SessionSnodeKit/Configuration.swift @@ -1,3 +1,5 @@ +import Foundation +import SessionUtilitiesKit public struct SNSnodeKitConfiguration { public let storage: SessionSnodeKitStorageProtocol @@ -6,6 +8,19 @@ public struct SNSnodeKitConfiguration { } public enum SNSnodeKit { // Just to make the external API nice + public static func migrations() -> TargetMigrations { + return TargetMigrations( + identifier: .snodeKit, + migrations: [ + [ + _001_InitialSetupMigration.self + ], + [ + _002_YDBToGRDBMigration.self + ] + ] + ) + } public static func configure(storage: SessionSnodeKitStorageProtocol) { SNSnodeKitConfiguration.shared = SNSnodeKitConfiguration(storage: storage) diff --git a/SessionSnodeKit/Database/LegacyDatabase/SSKLegacyModels.swift b/SessionSnodeKit/Database/LegacyDatabase/SSKLegacyModels.swift new file mode 100644 index 000000000..52d873245 --- /dev/null +++ b/SessionSnodeKit/Database/LegacyDatabase/SSKLegacyModels.swift @@ -0,0 +1,80 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum Legacy { + // MARK: - Collections and Keys + + internal static let swarmCollectionPrefix = "LokiSwarmCollection-" + internal static let snodePoolCollection = "LokiSnodePoolCollection" + internal static let onionRequestPathCollection = "LokiOnionRequestPathCollection" + internal static let lastSnodePoolRefreshDateCollection = "LokiLastSnodePoolRefreshDateCollection" + internal static let lastMessageHashCollection = "LokiLastMessageHashCollection" // TODO: Remove this one? (make it a query??) + internal static let receivedMessagesCollection = "LokiReceivedMessagesCollection" + // TODO: - "lastSnodePoolRefreshDate" + + // MARK: - Types + + public typealias LegacyOnionRequestAPIPath = [Snode] + + @objc(Snode) + public final class Snode: NSObject, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility + public let address: String + public let port: UInt16 + public let publicKeySet: KeySet + + public var ip: String { + guard let range = address.range(of: "https://"), range.lowerBound == address.startIndex else { return address } + return String(address[range.upperBound.. Bool { + guard let other = other as? Snode else { return false } + return address == other.address && port == other.port + } + + override public var hash: Int { // Override NSObject.hash and not Hashable.hashValue or Hashable.hash(into:) + return address.hashValue ^ port.hashValue + } + } +} diff --git a/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift new file mode 100644 index 000000000..37523b470 --- /dev/null +++ b/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -0,0 +1,47 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +enum _001_InitialSetupMigration: Migration { + static let identifier: String = "initialSetup" + + static func migrate(_ db: Database) throws { + try db.create(table: Snode.self) { t in + t.column(.address, .text).notNull() + t.column(.port, .integer).notNull() + t.column(.ed25519PublicKey, .text).notNull() + t.column(.x25519PublicKey, .text).notNull() + + t.primaryKey([.address, .port]) + } + + try db.create(table: SnodeSet.self) { t in + t.column(.key, .text).notNull() + t.column(.nodeIndex, .integer).notNull() + t.column(.address, .text).notNull() + t.column(.port, .integer).notNull() + + t.foreignKey( + [.address, .port], + references: Snode.self, + columns: [.address, .port], + onDelete: .cascade + ) + t.primaryKey([.key, .nodeIndex]) + } + + try db.create(table: SnodeReceivedMessageInfo.self) { t in + t.column(.key, .text) + .notNull() + .indexed() + t.column(.hash, .text).notNull() + t.column(.expirationDateMs, .integer) + .notNull() + .indexed() + + t.primaryKey([.key, .hash]) + } + } +} diff --git a/SessionSnodeKit/Database/Migrations/_002_YDBToGRDBMigration.swift b/SessionSnodeKit/Database/Migrations/_002_YDBToGRDBMigration.swift new file mode 100644 index 000000000..4a173265f --- /dev/null +++ b/SessionSnodeKit/Database/Migrations/_002_YDBToGRDBMigration.swift @@ -0,0 +1,138 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +enum _002_YDBToGRDBMigration: Migration { + static let identifier: String = "YDBToGRDBMigration" + + // TODO: Autorelease pool??? + static func migrate(_ db: Database) throws { + // MARK: - OnionRequestPath, Snode Pool & Swarm + + // Note: Want to exclude the Snode's we already added from the 'onionRequestPathResult' + var snodeResult: Set = [] + var snodeSetResult: [String: Set] = [:] + + Storage.read { transaction in + // Process the OnionRequestPaths + if + let path0Snode0 = transaction.object(forKey: "0-0", inCollection: Legacy.onionRequestPathCollection) as? Legacy.Snode, + let path0Snode1 = transaction.object(forKey: "0-1", inCollection: Legacy.onionRequestPathCollection) as? Legacy.Snode, + let path0Snode2 = transaction.object(forKey: "0-2", inCollection: Legacy.onionRequestPathCollection) as? Legacy.Snode + { + snodeResult.insert(path0Snode0) + snodeResult.insert(path0Snode1) + snodeResult.insert(path0Snode2) + snodeSetResult["\(SnodeSet.onionRequestPathPrefix)0"] = [ path0Snode0, path0Snode1, path0Snode2 ] + + if + let path1Snode0 = transaction.object(forKey: "1-0", inCollection: Legacy.onionRequestPathCollection) as? Legacy.Snode, + let path1Snode1 = transaction.object(forKey: "1-1", inCollection: Legacy.onionRequestPathCollection) as? Legacy.Snode, + let path1Snode2 = transaction.object(forKey: "1-2", inCollection: Legacy.onionRequestPathCollection) as? Legacy.Snode + { + snodeResult.insert(path1Snode0) + snodeResult.insert(path1Snode1) + snodeResult.insert(path1Snode2) + snodeSetResult["\(SnodeSet.onionRequestPathPrefix)1"] = [ path1Snode0, path1Snode1, path1Snode2 ] + } + } + + // Process the SnodePool + transaction.enumerateKeysAndObjects(inCollection: Legacy.snodePoolCollection) { _, object, _ in + guard let snode = object as? Legacy.Snode else { return } + snodeResult.insert(snode) + } + + // Process the Swarms + var swarmCollections: Set = [] + + transaction.enumerateCollections { collectionName, _ in + if collectionName.starts(with: Legacy.swarmCollectionPrefix) { + swarmCollections.insert(collectionName.substring(from: Legacy.swarmCollectionPrefix.count)) + } + } + + for swarmCollection in swarmCollections { + let collection: String = "\(Legacy.swarmCollectionPrefix)\(swarmCollection)" + + transaction.enumerateKeysAndObjects(inCollection: collection) { _, object, _ in + guard let snode = object as? Legacy.Snode else { return } + snodeResult.insert(snode) + snodeSetResult[swarmCollection] = (snodeSetResult[swarmCollection] ?? Set()).inserting(snode) + } + } + } + + try snodeResult.forEach { legacySnode in + try Snode( + address: legacySnode.address, + port: legacySnode.port, + ed25519PublicKey: legacySnode.publicKeySet.ed25519Key, + x25519PublicKey: legacySnode.publicKeySet.x25519Key + ).insert(db) + } + + try snodeSetResult.forEach { key, legacySnodeSet in + try legacySnodeSet.enumerated().forEach { nodeIndex, legacySnode in + // Note: In this case the 'nodeIndex' is irrelivant + try SnodeSet( + key: key, + nodeIndex: UInt(nodeIndex), + address: legacySnode.address, + port: legacySnode.port + ).insert(db) + } + } + + // TODO: This +// public func setLastSnodePoolRefreshDate(to date: Date, using transaction: Any) { +// (transaction as! YapDatabaseReadWriteTransaction).setObject(date, forKey: "lastSnodePoolRefreshDate", inCollection: Storage.lastSnodePoolRefreshDateCollection) +// } + + print("RAWR") + + // MARK: - Received Messages & Last Message Hash + + var lastMessageResults: [String: (hash: String, json: JSON)] = [:] + var receivedMessageResults: [String: Set] = [:] + + Storage.read { transaction in + // Extract the received message hashes + transaction.enumerateKeysAndObjects(inCollection: Legacy.receivedMessagesCollection) { key, object, _ in + guard let hashSet = object as? Set else { return } + receivedMessageResults[key] = hashSet + } + + // Retrieve the last message info + transaction.enumerateKeysAndObjects(inCollection: Legacy.lastMessageHashCollection) { key, object, _ in + guard let lastMessageJson = object as? JSON else { return } + guard let lastMessageHash: String = lastMessageJson["hash"] as? String else { return } + + // Note: We remove the value from 'receivedMessageResults' as we don't want to default it's + // expiration value to 0 + lastMessageResults[key] = (lastMessageHash, lastMessageJson) + receivedMessageResults[key] = receivedMessageResults[key]?.removing(lastMessageHash) + } + } + + try receivedMessageResults.forEach { key, hashes in + try hashes.forEach { hash in + try SnodeReceivedMessageInfo( + key: key, + hash: hash, + expirationDateMs: 0 + ).insert(db) + } + } + + try lastMessageResults.forEach { key, data in + try SnodeReceivedMessageInfo( + key: key, + hash: data.hash, + expirationDateMs: ((data.json["expirationDate"] as? Int64) ?? 0) + ).insert(db) + } + } +} diff --git a/SessionSnodeKit/Database/Models/Snode.swift b/SessionSnodeKit/Database/Models/Snode.swift new file mode 100644 index 000000000..7e4a2afc6 --- /dev/null +++ b/SessionSnodeKit/Database/Models/Snode.swift @@ -0,0 +1,21 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public struct Snode: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible, Hashable { + public static var databaseTableName: String { "snode" } + + public enum Columns: String, CodingKey, ColumnExpression { + case address + case port + case ed25519PublicKey + case x25519PublicKey + } + + let address: String + let port: UInt16 + let ed25519PublicKey: String + let x25519PublicKey: String +} diff --git a/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift b/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift new file mode 100644 index 000000000..d6c80c16d --- /dev/null +++ b/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift @@ -0,0 +1,19 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +struct SnodeReceivedMessageInfo: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + static var databaseTableName: String { "snodeReceivedMessageInfo" } + + public enum Columns: String, CodingKey, ColumnExpression { + case key + case hash + case expirationDateMs + } + + let key: String + let hash: String + let expirationDateMs: Int64 +} diff --git a/SessionSnodeKit/Database/Models/SnodeSet.swift b/SessionSnodeKit/Database/Models/SnodeSet.swift new file mode 100644 index 000000000..4668d2e40 --- /dev/null +++ b/SessionSnodeKit/Database/Models/SnodeSet.swift @@ -0,0 +1,27 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +struct SnodeSet: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + static var databaseTableName: String { "snodeSet" } + static let nodes = hasMany(Snode.self) + static let onionRequestPathPrefix = "OnionRequestPath-" + + public enum Columns: String, CodingKey, ColumnExpression { + case key + case nodeIndex + case address + case port + } + + let key: String + let nodeIndex: UInt + let address: String + let port: UInt16 + + var nodes: QueryInterfaceRequest { + request(for: SnodeSet.nodes) + } +} diff --git a/SessionSnodeKit/Database/Types/SSKSetting.swift b/SessionSnodeKit/Database/Types/SSKSetting.swift new file mode 100644 index 000000000..0a8ab0dac --- /dev/null +++ b/SessionSnodeKit/Database/Types/SSKSetting.swift @@ -0,0 +1,3 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation diff --git a/SessionSnodeKit/OnionRequestAPI.swift b/SessionSnodeKit/OnionRequestAPI.swift index 7a03b16f0..b50bee214 100644 --- a/SessionSnodeKit/OnionRequestAPI.swift +++ b/SessionSnodeKit/OnionRequestAPI.swift @@ -8,9 +8,9 @@ public enum OnionRequestAPI { /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. private static var pathFailureCount: [Path:UInt] = [:] /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. - private static var snodeFailureCount: [Snode:UInt] = [:] + private static var snodeFailureCount: [Legacy.Snode:UInt] = [:] /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. - public static var guardSnodes: Set = [] + public static var guardSnodes: Set = [] public static var paths: [Path] = [] // Not a set to ensure we consistently show the same path to the user // MARK: Settings @@ -29,7 +29,7 @@ public enum OnionRequestAPI { // MARK: Destination public enum Destination : CustomStringConvertible { - case snode(Snode) + case snode(Legacy.Snode) case server(host: String, target: String, x25519PublicKey: String, scheme: String?, port: UInt16?) public var description: String { @@ -67,14 +67,14 @@ public enum OnionRequestAPI { } // MARK: Path - public typealias Path = [Snode] + public typealias Path = [Legacy.Snode] // MARK: Onion Building Result - private typealias OnionBuildingResult = (guardSnode: Snode, finalEncryptionResult: AESGCM.EncryptionResult, destinationSymmetricKey: Data) + private typealias OnionBuildingResult = (guardSnode: Legacy.Snode, finalEncryptionResult: AESGCM.EncryptionResult, destinationSymmetricKey: Data) // MARK: Private API /// Tests the given snode. The returned promise errors out if the snode is faulty; the promise is fulfilled otherwise. - private static func testSnode(_ snode: Snode) -> Promise { + private static func testSnode(_ snode: Legacy.Snode) -> Promise { let (promise, seal) = Promise.pending() DispatchQueue.global(qos: .userInitiated).async { let url = "\(snode.address):\(snode.port)/get_stats/v1" @@ -96,17 +96,17 @@ public enum OnionRequestAPI { /// Finds `targetGuardSnodeCount` guard snodes to use for path building. The returned promise errors out with `Error.insufficientSnodes` /// if not enough (reliable) snodes are available. - private static func getGuardSnodes(reusing reusableGuardSnodes: [Snode]) -> Promise> { + private static func getGuardSnodes(reusing reusableGuardSnodes: [Legacy.Snode]) -> Promise> { if guardSnodes.count >= targetGuardSnodeCount { - return Promise> { $0.fulfill(guardSnodes) } + return Promise> { $0.fulfill(guardSnodes) } } else { SNLog("Populating guard snode cache.") var unusedSnodes = SnodeAPI.snodePool.subtracting(reusableGuardSnodes) // Sync on LokiAPI.workQueue let reusableGuardSnodeCount = UInt(reusableGuardSnodes.count) guard unusedSnodes.count >= (targetGuardSnodeCount - reusableGuardSnodeCount) else { return Promise(error: Error.insufficientSnodes) } - func getGuardSnode() -> Promise { + func getGuardSnode() -> Promise { // randomElement() uses the system's default random generator, which is cryptographically secure - guard let candidate = unusedSnodes.randomElement() else { return Promise { $0.reject(Error.insufficientSnodes) } } + guard let candidate = unusedSnodes.randomElement() else { return Promise { $0.reject(Error.insufficientSnodes) } } unusedSnodes.remove(candidate) // All used snodes should be unique SNLog("Testing guard snode: \(candidate).") // Loop until a reliable guard snode is found @@ -167,7 +167,7 @@ public enum OnionRequestAPI { } /// Returns a `Path` to be used for building an onion request. Builds new paths as needed. - private static func getPath(excluding snode: Snode?) -> Promise { + private static func getPath(excluding snode: Legacy.Snode?) -> Promise { guard pathSize >= 1 else { preconditionFailure("Can't build path of size zero.") } var paths = OnionRequestAPI.paths if paths.isEmpty { @@ -216,14 +216,14 @@ public enum OnionRequestAPI { } } - private static func dropGuardSnode(_ snode: Snode) { + private static func dropGuardSnode(_ snode: Legacy.Snode) { #if DEBUG dispatchPrecondition(condition: .onQueue(Threading.workQueue)) #endif guardSnodes = guardSnodes.filter { $0 != snode } } - private static func drop(_ snode: Snode) throws { + private static func drop(_ snode: Legacy.Snode) throws { #if DEBUG dispatchPrecondition(condition: .onQueue(Threading.workQueue)) #endif @@ -272,10 +272,10 @@ public enum OnionRequestAPI { /// Builds an onion around `payload` and returns the result. private static func buildOnion(around payload: JSON, targetedAt destination: Destination) -> Promise { - var guardSnode: Snode! + var guardSnode: Legacy.Snode! var targetSnodeSymmetricKey: Data! // Needed by invoke(_:on:with:) to decrypt the response sent back by the destination var encryptionResult: AESGCM.EncryptionResult! - var snodeToExclude: Snode? + var snodeToExclude: Legacy.Snode? if case .snode(let snode) = destination { snodeToExclude = snode } return getPath(excluding: snodeToExclude).then2 { path -> Promise in guardSnode = path.first! @@ -305,7 +305,7 @@ public enum OnionRequestAPI { // MARK: Public API /// Sends an onion request to `snode`. Builds new paths as needed. - public static func sendOnionRequest(to snode: Snode, invoking method: Snode.Method, with parameters: JSON, associatedWith publicKey: String? = nil) -> Promise { + public static func sendOnionRequest(to snode: Legacy.Snode, invoking method: Legacy.Snode.Method, with parameters: JSON, associatedWith publicKey: String? = nil) -> Promise { let payload: JSON = [ "method" : method.rawValue, "params" : parameters ] return sendOnionRequest(with: payload, to: Destination.snode(snode)).recover2 { error -> Promise in guard case OnionRequestAPI.Error.httpRequestFailedAtDestination(let statusCode, let json, _) = error else { throw error } @@ -365,7 +365,7 @@ public enum OnionRequestAPI { public static func sendOnionRequest(with payload: JSON, to destination: Destination) -> Promise { let (promise, seal) = Promise.pending() - var guardSnode: Snode? + var guardSnode: Legacy.Snode? Threading.workQueue.async { // Avoid race conditions on `guardSnodes` and `paths` buildOnion(around: payload, targetedAt: destination).done2 { intermediate in guardSnode = intermediate.guardSnode diff --git a/SessionSnodeKit/Snode.swift b/SessionSnodeKit/Snode.swift deleted file mode 100644 index bb36f2586..000000000 --- a/SessionSnodeKit/Snode.swift +++ /dev/null @@ -1,65 +0,0 @@ -import Foundation - -public final class Snode : NSObject, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility - public let address: String - public let port: UInt16 - public let publicKeySet: KeySet - - public var ip: String { - address.removingPrefix("https://") - } - - // MARK: Nested Types - public enum Method : String { - case getSwarm = "get_snodes_for_pubkey" - case getMessages = "retrieve" - case sendMessage = "store" - case deleteMessage = "delete" - case oxenDaemonRPCCall = "oxend_request" - case getInfo = "info" - case clearAllData = "delete_all" - } - - public struct KeySet { - public let ed25519Key: String - public let x25519Key: String - } - - // MARK: Initialization - internal init(address: String, port: UInt16, publicKeySet: KeySet) { - self.address = address - self.port = port - self.publicKeySet = publicKeySet - } - - // MARK: Coding - public init?(coder: NSCoder) { - address = coder.decodeObject(forKey: "address") as! String - port = coder.decodeObject(forKey: "port") as! UInt16 - guard let idKey = coder.decodeObject(forKey: "idKey") as? String, - let encryptionKey = coder.decodeObject(forKey: "encryptionKey") as? String else { return nil } - publicKeySet = KeySet(ed25519Key: idKey, x25519Key: encryptionKey) - super.init() - } - - public func encode(with coder: NSCoder) { - coder.encode(address, forKey: "address") - coder.encode(port, forKey: "port") - coder.encode(publicKeySet.ed25519Key, forKey: "idKey") - coder.encode(publicKeySet.x25519Key, forKey: "encryptionKey") - } - - // MARK: Equality - override public func isEqual(_ other: Any?) -> Bool { - guard let other = other as? Snode else { return false } - return address == other.address && port == other.port - } - - // MARK: Hashing - override public var hash: Int { // Override NSObject.hash and not Hashable.hashValue or Hashable.hash(into:) - return address.hashValue ^ port.hashValue - } - - // MARK: Description - override public var description: String { return "\(address):\(port)" } -} diff --git a/SessionSnodeKit/SnodeAPI.swift b/SessionSnodeKit/SnodeAPI.swift index 7602242bf..6e802beb9 100644 --- a/SessionSnodeKit/SnodeAPI.swift +++ b/SessionSnodeKit/SnodeAPI.swift @@ -8,12 +8,12 @@ public final class SnodeAPI : NSObject { private static var hasLoadedSnodePool = false private static var loadedSwarms: Set = [] - private static var getSnodePoolPromise: Promise>? + private static var getSnodePoolPromise: Promise>? /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. - internal static var snodeFailureCount: [Snode:UInt] = [:] + internal static var snodeFailureCount: [Legacy.Snode:UInt] = [:] /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. - internal static var snodePool: Set = [] + internal static var snodePool: Set = [] /// The offset between the user's clock and the Service Node's clock. Used in cases where the /// user's clock is incorrect. @@ -21,7 +21,7 @@ public final class SnodeAPI : NSObject { /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. public static var clockOffset: Int64 = 0 /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. - public static var swarmCache: [String:Set] = [:] + public static var swarmCache: [String:Set] = [:] // MARK: Settings private static let maxRetryCount: UInt = 8 @@ -72,7 +72,7 @@ public final class SnodeAPI : NSObject { hasLoadedSnodePool = true } - private static func setSnodePool(to newValue: Set, using transaction: Any? = nil) { + private static func setSnodePool(to newValue: Set, using transaction: Any? = nil) { snodePool = newValue let storage = SNSnodeKitConfiguration.shared.storage if let transaction = transaction { @@ -84,7 +84,7 @@ public final class SnodeAPI : NSObject { } } - private static func dropSnodeFromSnodePool(_ snode: Snode) { + private static func dropSnodeFromSnodePool(_ snode: Legacy.Snode) { #if DEBUG dispatchPrecondition(condition: .onQueue(Threading.workQueue)) #endif @@ -107,7 +107,7 @@ public final class SnodeAPI : NSObject { loadedSwarms.insert(publicKey) } - private static func setSwarm(to newValue: Set, for publicKey: String, persist: Bool = true) { + private static func setSwarm(to newValue: Set, for publicKey: String, persist: Bool = true) { #if DEBUG dispatchPrecondition(condition: .onQueue(Threading.workQueue)) #endif @@ -118,7 +118,7 @@ public final class SnodeAPI : NSObject { } } - public static func dropSnodeFromSwarmIfNeeded(_ snode: Snode, publicKey: String) { + public static func dropSnodeFromSwarmIfNeeded(_ snode: Legacy.Snode, publicKey: String) { #if DEBUG dispatchPrecondition(condition: .onQueue(Threading.workQueue)) #endif @@ -129,7 +129,7 @@ public final class SnodeAPI : NSObject { } // MARK: Internal API - internal static func invoke(_ method: Snode.Method, on snode: Snode, associatedWith publicKey: String? = nil, parameters: JSON) -> RawResponsePromise { + internal static func invoke(_ method: Legacy.Snode.Method, on snode: Legacy.Snode, associatedWith publicKey: String? = nil, parameters: JSON) -> RawResponsePromise { if Features.useOnionRequests { return OnionRequestAPI.sendOnionRequest(to: snode, invoking: method, with: parameters, associatedWith: publicKey).map2 { $0 as Any } } else { @@ -141,7 +141,7 @@ public final class SnodeAPI : NSObject { } } - private static func getNetworkTime(from snode: Snode) -> Promise { + private static func getNetworkTime(from snode: Legacy.Snode) -> Promise { return invoke(.getInfo, on: snode, parameters: [:]).map2 { rawResponse in guard let json = rawResponse as? JSON, let timestamp = json["timestamp"] as? UInt64 else { throw HTTP.Error.invalidJSON } @@ -149,12 +149,12 @@ public final class SnodeAPI : NSObject { } } - internal static func getRandomSnode() -> Promise { + internal static func getRandomSnode() -> Promise { // randomElement() uses the system's default random generator, which is cryptographically secure return getSnodePool().map2 { $0.randomElement()! } } - private static func getSnodePoolFromSeedNode() -> Promise> { + private static func getSnodePoolFromSeedNode() -> Promise> { let target = seedNodePool.randomElement()! let url = "\(target)/json_rpc" let parameters: JSON = [ @@ -168,10 +168,10 @@ public final class SnodeAPI : NSObject { ] ] SNLog("Populating snode pool using seed node: \(target).") - let (promise, seal) = Promise>.pending() + let (promise, seal) = Promise>.pending() Threading.workQueue.async { attempt(maxRetryCount: 4, recoveringOn: Threading.workQueue) { - HTTP.execute(.post, url, parameters: parameters, useSeedNodeURLSession: true).map2 { json -> Set in + HTTP.execute(.post, url, parameters: parameters, useSeedNodeURLSession: true).map2 { json -> Set in guard let intermediate = json["result"] as? JSON, let rawSnodes = intermediate["service_node_states"] as? [JSON] else { throw Error.snodePoolUpdatingFailed } return Set(rawSnodes.compactMap { rawSnode in guard let address = rawSnode["public_ip"] as? String, let port = rawSnode["storage_port"] as? Int, @@ -179,7 +179,7 @@ public final class SnodeAPI : NSObject { SNLog("Failed to parse snode from: \(rawSnode).") return nil } - return Snode(address: "https://\(address)", port: UInt16(port), publicKeySet: Snode.KeySet(ed25519Key: ed25519PublicKey, x25519Key: x25519PublicKey)) + return Legacy.Snode(address: "https://\(address)", port: UInt16(port), publicKeySet: Legacy.Snode.KeySet(ed25519Key: ed25519PublicKey, x25519Key: x25519PublicKey)) }) } }.done2 { snodePool in @@ -193,15 +193,15 @@ public final class SnodeAPI : NSObject { return promise } - private static func getSnodePoolFromSnode() -> Promise> { + private static func getSnodePoolFromSnode() -> Promise> { var snodePool = SnodeAPI.snodePool - var snodes: Set = [] + var snodes: Set = [] (0..<3).forEach { _ in let snode = snodePool.randomElement()! snodePool.remove(snode) snodes.insert(snode) } - let snodePoolPromises: [Promise>] = snodes.map { snode in + let snodePoolPromises: [Promise>] = snodes.map { snode in return attempt(maxRetryCount: 4, recoveringOn: Threading.workQueue) { // Don't specify a limit in the request. Service nodes return a shuffled // list of nodes so if we specify a limit the 3 responses we get might have @@ -226,18 +226,18 @@ public final class SnodeAPI : NSObject { SNLog("Failed to parse snode from: \(rawSnode).") return nil } - return Snode(address: "https://\(address)", port: UInt16(port), publicKeySet: Snode.KeySet(ed25519Key: ed25519PublicKey, x25519Key: x25519PublicKey)) + return Legacy.Snode(address: "https://\(address)", port: UInt16(port), publicKeySet: Legacy.Snode.KeySet(ed25519Key: ed25519PublicKey, x25519Key: x25519PublicKey)) }) } } } - let promise = when(fulfilled: snodePoolPromises).map2 { results -> Set in - var result: Set = results[0] + let promise = when(fulfilled: snodePoolPromises).map2 { results -> Set in + var result: Set = results[0] results.forEach { result = result.intersection($0) } if result.count > 24 { // We want the snodes to agree on at least this many snodes // Limit the snode pool size to 256 so that we don't go too long without // refreshing it - return (result.count > 256) ? Set([Snode](result)[0..<256]) : result + return (result.count > 256) ? Set([Legacy.Snode](result)[0..<256]) : result } else { throw Error.inconsistentSnodePools } @@ -251,7 +251,7 @@ public final class SnodeAPI : NSObject { AnyPromise.from(getSnodePool()) } - public static func getSnodePool() -> Promise> { + public static func getSnodePool() -> Promise> { loadSnodePoolIfNeeded() let now = Date() let hasSnodePoolExpired = given(Storage.shared.getLastSnodePoolRefreshDate()) { now.timeIntervalSince($0) > 2 * 60 * 60 } ?? true @@ -259,7 +259,7 @@ public final class SnodeAPI : NSObject { let hasInsufficientSnodes = (snodePool.count < minSnodePoolCount) if hasInsufficientSnodes || hasSnodePoolExpired { if let getSnodePoolPromise = getSnodePoolPromise { return getSnodePoolPromise } - let promise: Promise> + let promise: Promise> if snodePool.count < minSnodePoolCount { promise = getSnodePoolFromSeedNode() } else { @@ -268,15 +268,15 @@ public final class SnodeAPI : NSObject { } } getSnodePoolPromise = promise - promise.map2 { snodePool -> Set in + promise.map2 { snodePool -> Set in if snodePool.isEmpty { throw Error.snodePoolUpdatingFailed } else { return snodePool } } - promise.then2 { snodePool -> Promise> in - let (promise, seal) = Promise>.pending() + promise.then2 { snodePool -> Promise> in + let (promise, seal) = Promise>.pending() SNSnodeKitConfiguration.shared.storage.write(with: { transaction in Storage.shared.setLastSnodePoolRefreshDate(to: now, using: transaction) setSnodePool(to: snodePool, using: transaction) @@ -366,15 +366,15 @@ public final class SnodeAPI : NSObject { return promise } - public static func getTargetSnodes(for publicKey: String) -> Promise<[Snode]> { + public static func getTargetSnodes(for publicKey: String) -> Promise<[Legacy.Snode]> { // shuffled() uses the system's default random generator, which is cryptographically secure return getSwarm(for: publicKey).map2 { Array($0.shuffled().prefix(targetSwarmSnodeCount)) } } - public static func getSwarm(for publicKey: String) -> Promise> { + public static func getSwarm(for publicKey: String) -> Promise> { loadSwarmIfNeeded(for: publicKey) if let cachedSwarm = swarmCache[publicKey], cachedSwarm.count >= minSwarmSnodeCount { - return Promise> { $0.fulfill(cachedSwarm) } + return Promise> { $0.fulfill(cachedSwarm) } } else { SNLog("Getting swarm for: \((publicKey == SNSnodeKitConfiguration.shared.storage.getUserPublicKey()) ? "self" : publicKey).") let parameters: [String:Any] = [ "pubKey" : Features.useTestnet ? publicKey.removing05PrefixIfNeeded() : publicKey ] @@ -390,7 +390,7 @@ public final class SnodeAPI : NSObject { } } - public static func getRawMessages(from snode: Snode, associatedWith publicKey: String) -> RawResponsePromise { + public static func getRawMessages(from snode: Legacy.Snode, associatedWith publicKey: String) -> RawResponsePromise { let (promise, seal) = RawResponsePromise.pending() Threading.workQueue.async { getMessagesInternal(from: snode, associatedWith: publicKey).done2 { seal.fulfill($0) }.catch2 { seal.reject($0) } @@ -412,7 +412,7 @@ public final class SnodeAPI : NSObject { return promise } - private static func getMessagesInternal(from snode: Snode, associatedWith publicKey: String) -> RawResponsePromise { + private static func getMessagesInternal(from snode: Legacy.Snode, associatedWith publicKey: String) -> RawResponsePromise { let storage = SNSnodeKitConfiguration.shared.storage // NOTE: All authentication logic is currently commented out, the reason being that we can't currently support @@ -468,7 +468,7 @@ public final class SnodeAPI : NSObject { return attempt(maxRetryCount: maxRetryCount, recoveringOn: Threading.workQueue) { getSwarm(for: publicKey).then2 { swarm -> Promise<[String:Bool]> in let snode = swarm.randomElement()! - let verificationData = (Snode.Method.deleteMessage.rawValue + serverHashes.joined(separator: "")).data(using: String.Encoding.utf8)! + let verificationData = (Legacy.Snode.Method.deleteMessage.rawValue + serverHashes.joined(separator: "")).data(using: String.Encoding.utf8)! guard let signature = sodium.sign.signature(message: Bytes(verificationData), secretKey: userED25519KeyPair.secretKey) else { throw Error.signingFailed } let parameters: JSON = [ "pubkey" : userX25519PublicKey, @@ -515,7 +515,7 @@ public final class SnodeAPI : NSObject { let snode = swarm.randomElement()! return attempt(maxRetryCount: maxRetryCount, recoveringOn: Threading.workQueue) { getNetworkTime(from: snode).then2 { timestamp -> Promise<[String:Bool]> in - let verificationData = (Snode.Method.clearAllData.rawValue + String(timestamp)).data(using: String.Encoding.utf8)! + let verificationData = (Legacy.Snode.Method.clearAllData.rawValue + String(timestamp)).data(using: String.Encoding.utf8)! guard let signature = sodium.sign.signature(message: Bytes(verificationData), secretKey: userED25519KeyPair.secretKey) else { throw Error.signingFailed } let parameters: JSON = [ "pubkey" : userX25519PublicKey, @@ -558,7 +558,7 @@ public final class SnodeAPI : NSObject { // The parsing utilities below use a best attempt approach to parsing; they warn for parsing failures but don't throw exceptions. - private static func parseSnodes(from rawResponse: Any) -> Set { + private static func parseSnodes(from rawResponse: Any) -> Set { guard let json = rawResponse as? JSON, let rawSnodes = json["snodes"] as? [JSON] else { SNLog("Failed to parse snodes from: \(rawResponse).") return [] @@ -569,17 +569,17 @@ public final class SnodeAPI : NSObject { SNLog("Failed to parse snode from: \(rawSnode).") return nil } - return Snode(address: "https://\(address)", port: port, publicKeySet: Snode.KeySet(ed25519Key: ed25519PublicKey, x25519Key: x25519PublicKey)) + return Legacy.Snode(address: "https://\(address)", port: port, publicKeySet: Legacy.Snode.KeySet(ed25519Key: ed25519PublicKey, x25519Key: x25519PublicKey)) }) } - public static func parseRawMessagesResponse(_ rawResponse: Any, from snode: Snode, associatedWith publicKey: String) -> [JSON] { + public static func parseRawMessagesResponse(_ rawResponse: Any, from snode: Legacy.Snode, associatedWith publicKey: String) -> [JSON] { guard let json = rawResponse as? JSON, let rawMessages = json["messages"] as? [JSON] else { return [] } updateLastMessageHashValueIfPossible(for: snode, associatedWith: publicKey, from: rawMessages) return removeDuplicates(from: rawMessages, associatedWith: publicKey) } - private static func updateLastMessageHashValueIfPossible(for snode: Snode, associatedWith publicKey: String, from rawMessages: [JSON]) { + private static func updateLastMessageHashValueIfPossible(for snode: Legacy.Snode, associatedWith publicKey: String, from rawMessages: [JSON]) { if let lastMessage = rawMessages.last, let lastHash = lastMessage["hash"] as? String, let expirationDate = lastMessage["expiration"] as? UInt64 { SNSnodeKitConfiguration.shared.storage.writeSync { transaction in SNSnodeKitConfiguration.shared.storage.setLastMessageHashInfo(for: snode, associatedWith: publicKey, @@ -614,7 +614,7 @@ public final class SnodeAPI : NSObject { // MARK: Error Handling /// - Note: Should only be invoked from `Threading.workQueue` to avoid race conditions. @discardableResult - internal static func handleError(withStatusCode statusCode: UInt, json: JSON?, forSnode snode: Snode, associatedWith publicKey: String? = nil) -> Error? { + internal static func handleError(withStatusCode statusCode: UInt, json: JSON?, forSnode snode: Legacy.Snode, associatedWith publicKey: String? = nil) -> Error? { #if DEBUG dispatchPrecondition(condition: .onQueue(Threading.workQueue)) #endif diff --git a/SessionSnodeKit/Storage+SnodeAPI.swift b/SessionSnodeKit/Storage+SnodeAPI.swift index 24b36365f..71df7c0a2 100644 --- a/SessionSnodeKit/Storage+SnodeAPI.swift +++ b/SessionSnodeKit/Storage+SnodeAPI.swift @@ -7,18 +7,18 @@ extension Storage { private static let snodePoolCollection = "LokiSnodePoolCollection" private static let lastSnodePoolRefreshDateCollection = "LokiLastSnodePoolRefreshDateCollection" - public func getSnodePool() -> Set { - var result: Set = [] + public func getSnodePool() -> Set { + var result: Set = [] Storage.read { transaction in transaction.enumerateKeysAndObjects(inCollection: Storage.snodePoolCollection) { _, object, _ in - guard let snode = object as? Snode else { return } + guard let snode = object as? Legacy.Snode else { return } result.insert(snode) } } return result } - public func setSnodePool(to snodePool: Set, using transaction: Any) { + public func setSnodePool(to snodePool: Set, using transaction: Any) { clearSnodePool(in: transaction) snodePool.forEach { snode in (transaction as! YapDatabaseReadWriteTransaction).setObject(snode, forKey: snode.description, inCollection: Storage.snodePoolCollection) @@ -49,20 +49,21 @@ extension Storage { return "LokiSwarmCollection-\(publicKey)" } - public func getSwarm(for publicKey: String) -> Set { - var result: Set = [] + public func getSwarm(for publicKey: String) -> Set { + var result: Set = [] let collection = Storage.getSwarmCollection(for: publicKey) Storage.read { transaction in transaction.enumerateKeysAndObjects(inCollection: collection) { _, object, _ in - guard let snode = object as? Snode else { return } + guard let snode = object as? Legacy.Snode else { return } result.insert(snode) } } return result } - public func setSwarm(to swarm: Set, for publicKey: String, using transaction: Any) { + public func setSwarm(to swarm: Set, for publicKey: String, using transaction: Any) { clearSwarm(for: publicKey, in: transaction) + let tmp = getSnodePool() let collection = Storage.getSwarmCollection(for: publicKey) swarm.forEach { snode in (transaction as! YapDatabaseReadWriteTransaction).setObject(snode, forKey: snode.description, inCollection: collection) @@ -80,7 +81,7 @@ extension Storage { private static let lastMessageHashCollection = "LokiLastMessageHashCollection" - public func getLastMessageHashInfo(for snode: Snode, associatedWith publicKey: String) -> JSON? { + public func getLastMessageHashInfo(for snode: Legacy.Snode, associatedWith publicKey: String) -> JSON? { let key = "\(snode.address):\(snode.port).\(publicKey)" var result: JSON? Storage.read { transaction in @@ -93,17 +94,17 @@ extension Storage { return result } - public func getLastMessageHash(for snode: Snode, associatedWith publicKey: String) -> String? { + public func getLastMessageHash(for snode: Legacy.Snode, associatedWith publicKey: String) -> String? { return getLastMessageHashInfo(for: snode, associatedWith: publicKey)?["hash"] as? String } - public func setLastMessageHashInfo(for snode: Snode, associatedWith publicKey: String, to lastMessageHashInfo: JSON, using transaction: Any) { + public func setLastMessageHashInfo(for snode: Legacy.Snode, associatedWith publicKey: String, to lastMessageHashInfo: JSON, using transaction: Any) { let key = "\(snode.address):\(snode.port).\(publicKey)" guard lastMessageHashInfo.count == 2 && lastMessageHashInfo["hash"] as? String != nil && lastMessageHashInfo["expirationDate"] as? NSNumber != nil else { return } (transaction as! YapDatabaseReadWriteTransaction).setObject(lastMessageHashInfo, forKey: key, inCollection: Storage.lastMessageHashCollection) } - public func pruneLastMessageHashInfoIfExpired(for snode: Snode, associatedWith publicKey: String) { + public func pruneLastMessageHashInfoIfExpired(for snode: Legacy.Snode, associatedWith publicKey: String) { guard let lastMessageHashInfo = getLastMessageHashInfo(for: snode, associatedWith: publicKey), (lastMessageHashInfo["hash"] as? String) != nil, let expirationDate = (lastMessageHashInfo["expirationDate"] as? NSNumber)?.uint64Value else { return } let now = NSDate.millisecondTimestamp() @@ -114,7 +115,7 @@ extension Storage { } } - public func removeLastMessageHashInfo(for snode: Snode, associatedWith publicKey: String, using transaction: Any) { + public func removeLastMessageHashInfo(for snode: Legacy.Snode, associatedWith publicKey: String, using transaction: Any) { let key = "\(snode.address):\(snode.port).\(publicKey)" (transaction as! YapDatabaseReadWriteTransaction).removeObject(forKey: key, inCollection: Storage.lastMessageHashCollection) } diff --git a/SessionSnodeKit/Storage.swift b/SessionSnodeKit/Storage.swift index ce0a90f81..7ea5e0d3d 100644 --- a/SessionSnodeKit/Storage.swift +++ b/SessionSnodeKit/Storage.swift @@ -14,15 +14,15 @@ public protocol SessionSnodeKitStorageProtocol { func getUserED25519KeyPair() -> Box.KeyPair? func getOnionRequestPaths() -> [OnionRequestAPI.Path] func setOnionRequestPaths(to paths: [OnionRequestAPI.Path], using transaction: Any) - func getSnodePool() -> Set - func setSnodePool(to snodePool: Set, using transaction: Any) + func getSnodePool() -> Set + func setSnodePool(to snodePool: Set, using transaction: Any) func getLastSnodePoolRefreshDate() -> Date? func setLastSnodePoolRefreshDate(to date: Date, using transaction: Any) - func getSwarm(for publicKey: String) -> Set - func setSwarm(to swarm: Set, for publicKey: String, using transaction: Any) - func getLastMessageHash(for snode: Snode, associatedWith publicKey: String) -> String? - func setLastMessageHashInfo(for snode: Snode, associatedWith publicKey: String, to lastMessageHashInfo: JSON, using transaction: Any) - func pruneLastMessageHashInfoIfExpired(for snode: Snode, associatedWith publicKey: String) + func getSwarm(for publicKey: String) -> Set + func setSwarm(to swarm: Set, for publicKey: String, using transaction: Any) + func getLastMessageHash(for snode: Legacy.Snode, associatedWith publicKey: String) -> String? + func setLastMessageHashInfo(for snode: Legacy.Snode, associatedWith publicKey: String, to lastMessageHashInfo: JSON, using transaction: Any) + func pruneLastMessageHashInfoIfExpired(for snode: Legacy.Snode, associatedWith publicKey: String) func getReceivedMessages(for publicKey: String) -> Set func setReceivedMessages(to receivedMessages: Set, for publicKey: String, using transaction: Any) } diff --git a/SessionUtilitiesKit/Database/GRDBStorage.swift b/SessionUtilitiesKit/Database/GRDBStorage.swift new file mode 100644 index 000000000..a628cb220 --- /dev/null +++ b/SessionUtilitiesKit/Database/GRDBStorage.swift @@ -0,0 +1,224 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SignalCoreKit + +enum GRDBStorageError: Error { // TODO: Rename to `StorageError` + case invalidKeySpec +} + +// TODO: Protocol for storage (just need to have 'read' and 'write' methods and mock 'Database'? + +// TODO: Rename to `Storage` +public final class GRDBStorage { + public static var shared: GRDBStorage! // TODO: Figure out how/if we want to do this + + private static let dbFileName: String = "Session.sqlite" + private static let keychainService: String = "TSKeyChainService" + private static let dbCipherKeySpecKey: String = "GRDBDatabaseCipherKeySpec" + private static let kSQLCipherKeySpecLength: Int32 = 48 + + private static var sharedDatabaseDirectoryPath: String { "\(OWSFileSystem.appSharedDataDirectoryPath())/database" } + private static var databasePath: String { "\(GRDBStorage.sharedDatabaseDirectoryPath)/\(GRDBStorage.dbFileName)" } + private static var databasePathShm: String { "\(GRDBStorage.sharedDatabaseDirectoryPath)/\(GRDBStorage.dbFileName)-shm" } + private static var databasePathWal: String { "\(GRDBStorage.sharedDatabaseDirectoryPath)/\(GRDBStorage.dbFileName)-wal" } + + private let dbPool: DatabasePool + private let migrator: DatabaseMigrator + + // MARK: - Initialization + + public init?( + migrations: [TargetMigrations] + ) throws { + print("RAWR START \("\(GRDBStorage.sharedDatabaseDirectoryPath)/\(GRDBStorage.dbFileName)")") + GRDBStorage.deleteDatabaseFiles() // TODO: Remove this + try! GRDBStorage.deleteDbKeys() // TODO: Remove this + + // 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 + OWSFileSystem.ensureDirectoryExists(GRDBStorage.sharedDatabaseDirectoryPath) + OWSFileSystem.protectFileOrFolder(atPath: GRDBStorage.sharedDatabaseDirectoryPath) + + // Generate the database KeySpec if needed (this MUST be done before we try to access the database + // as a different thread might attempt to access the database before the key is successfully created) + // + // Note: We reset the bytes immediately after generation to ensure the database key doesn't hang + // around in memory unintentionally + var tmpKeySpec: Data = GRDBStorage.getOrGenerateDatabaseKeySpec() + tmpKeySpec.resetBytes(in: 0.. Data { + return try CurrentAppContext().keychainStorage().data(forService: keychainService, key: dbCipherKeySpecKey) + } + + @discardableResult private static func getOrGenerateDatabaseKeySpec() -> Data { + do { + var keySpec: Data = try getDatabaseCipherKeySpec() + defer { keySpec.resetBytes(in: 0..(updates: (Database) throws -> T) throws -> T { + return try dbPool.write(updates) + } + + public func writeAsync(updates: @escaping (Database) throws -> T, completion: @escaping (Database, Result) -> Void) { + dbPool.asyncWrite(updates, completion: completion) + } + + public func read(_ value: (Database) throws -> T) throws -> T { + return try dbPool.read(value) + } +} diff --git a/SessionUtilitiesKit/Database/SSKKeychainStorage.swift b/SessionUtilitiesKit/Database/SSKKeychainStorage.swift index e9243901f..175725798 100644 --- a/SessionUtilitiesKit/Database/SSKKeychainStorage.swift +++ b/SessionUtilitiesKit/Database/SSKKeychainStorage.swift @@ -6,12 +6,18 @@ import Foundation import SAMKeychain public enum KeychainStorageError: Error { - case failure(description: String) + case failure(code: Int32?, description: String) + + public var code: Int32? { + switch self { + case .failure(let code, _): return code + } + } } // MARK: - -@objc public protocol SSKKeychainStorage: class { +@objc public protocol SSKKeychainStorage: AnyObject { @objc func string(forService service: String, key: String) throws -> String @@ -40,10 +46,10 @@ public class SSKDefaultKeychainStorage: NSObject, SSKKeychainStorage { var error: NSError? let result = SAMKeychain.password(forService: service, account: key, error: &error) if let error = error { - throw KeychainStorageError.failure(description: "\(logTag) error retrieving string: \(error)") + throw KeychainStorageError.failure(code: Int32(error.code), description: "\(logTag) error retrieving string: \(error)") } guard let string = result else { - throw KeychainStorageError.failure(description: "\(logTag) could not retrieve string") + throw KeychainStorageError.failure(code: nil, description: "\(logTag) could not retrieve string") } return string } @@ -55,10 +61,10 @@ public class SSKDefaultKeychainStorage: NSObject, SSKKeychainStorage { var error: NSError? let result = SAMKeychain.setPassword(string, forService: service, account: key, error: &error) if let error = error { - throw KeychainStorageError.failure(description: "\(logTag) error setting string: \(error)") + throw KeychainStorageError.failure(code: Int32(error.code), description: "\(logTag) error setting string: \(error)") } guard result else { - throw KeychainStorageError.failure(description: "\(logTag) could not set string") + throw KeychainStorageError.failure(code: nil, description: "\(logTag) could not set string") } } @@ -66,10 +72,10 @@ public class SSKDefaultKeychainStorage: NSObject, SSKKeychainStorage { var error: NSError? let result = SAMKeychain.passwordData(forService: service, account: key, error: &error) if let error = error { - throw KeychainStorageError.failure(description: "\(logTag) error retrieving data: \(error)") + throw KeychainStorageError.failure(code: Int32(error.code), description: "\(logTag) error retrieving data: \(error)") } guard let data = result else { - throw KeychainStorageError.failure(description: "\(logTag) could not retrieve data") + throw KeychainStorageError.failure(code: nil, description: "\(logTag) could not retrieve data") } return data } @@ -81,10 +87,10 @@ public class SSKDefaultKeychainStorage: NSObject, SSKKeychainStorage { var error: NSError? let result = SAMKeychain.setPasswordData(data, forService: service, account: key, error: &error) if let error = error { - throw KeychainStorageError.failure(description: "\(logTag) error setting data: \(error)") + throw KeychainStorageError.failure(code: Int32(error.code), description: "\(logTag) error setting data: \(error)") } guard result else { - throw KeychainStorageError.failure(description: "\(logTag) could not set data") + throw KeychainStorageError.failure(code: nil, description: "\(logTag) could not set data") } } @@ -96,10 +102,10 @@ public class SSKDefaultKeychainStorage: NSObject, SSKKeychainStorage { if error.code == errSecItemNotFound { return } - throw KeychainStorageError.failure(description: "\(logTag) error removing data: \(error)") + throw KeychainStorageError.failure(code: Int32(error.code), description: "\(logTag) error removing data: \(error)") } guard result else { - throw KeychainStorageError.failure(description: "\(logTag) could not remove data") + throw KeychainStorageError.failure(code: nil, description: "\(logTag) could not remove data") } } } diff --git a/SessionUtilitiesKit/Database/Types/ColumnExpressible.swift b/SessionUtilitiesKit/Database/Types/ColumnExpressible.swift new file mode 100644 index 000000000..434af47e4 --- /dev/null +++ b/SessionUtilitiesKit/Database/Types/ColumnExpressible.swift @@ -0,0 +1,8 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +public protocol ColumnExpressible { + associatedtype Columns: ColumnExpression +} diff --git a/SessionUtilitiesKit/Database/Types/Migration.swift b/SessionUtilitiesKit/Database/Types/Migration.swift new file mode 100644 index 000000000..493e00d06 --- /dev/null +++ b/SessionUtilitiesKit/Database/Types/Migration.swift @@ -0,0 +1,10 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +public protocol Migration { + static var identifier: String { get } + + static func migrate(_ db: Database) throws +} diff --git a/SessionUtilitiesKit/Database/Types/SettingType.swift b/SessionUtilitiesKit/Database/Types/SettingType.swift new file mode 100644 index 000000000..0a8ab0dac --- /dev/null +++ b/SessionUtilitiesKit/Database/Types/SettingType.swift @@ -0,0 +1,3 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation diff --git a/SessionUtilitiesKit/Database/Types/TargetMigrations.swift b/SessionUtilitiesKit/Database/Types/TargetMigrations.swift new file mode 100644 index 000000000..83cb310a5 --- /dev/null +++ b/SessionUtilitiesKit/Database/Types/TargetMigrations.swift @@ -0,0 +1,59 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public struct TargetMigrations: Comparable { + /// This identifier is used to determine the order each set of migrations should run in. + /// + /// All migrations within a specific set will run first, followed by all migrations for the same set index in + /// the next `Identifier` before moving on to the next `MigrationSet`. So given the migrations: + /// + /// `{a: [1], [2, 3]}, {b: [4, 5], [6]}` + /// + /// the migrations will run in the following order: + /// + /// `a1, b4, b5, a2, a3, b6` + public enum Identifier: String, CaseIterable, Comparable { + // WARNING: The string version of these cases are used as migration identifiers so + // changing them will result in the migrations running again + case snodeKit + case messagingKit + + public static func < (lhs: Self, rhs: Self) -> Bool { + let lhsIndex: Int = (Identifier.allCases.firstIndex(of: lhs) ?? Identifier.allCases.count) + let rhsIndex: Int = (Identifier.allCases.firstIndex(of: rhs) ?? Identifier.allCases.count) + + return (lhsIndex < rhsIndex) + } + } + + public typealias MigrationSet = [Migration.Type] + + let identifier: Identifier + let migrations: [MigrationSet] + + // MARK: - Initialization + + public init( + identifier: Identifier, + migrations: [MigrationSet] + ) { + self.identifier = identifier + self.migrations = migrations + } + + // MARK: - Equatable + + public static func == (lhs: TargetMigrations, rhs: TargetMigrations) -> Bool { + return ( + lhs.identifier == rhs.identifier && + lhs.migrations.count == rhs.migrations.count + ) + } + + // MARK: - Comparable + + public static func < (lhs: Self, rhs: Self) -> Bool { + return (lhs.identifier < rhs.identifier) + } +} diff --git a/SessionUtilitiesKit/Database/Types/TypedTableDefinition.swift b/SessionUtilitiesKit/Database/Types/TypedTableDefinition.swift new file mode 100644 index 000000000..8a5b86c92 --- /dev/null +++ b/SessionUtilitiesKit/Database/Types/TypedTableDefinition.swift @@ -0,0 +1,33 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +/// This is a convenience wrapper around the GRDB `TableDefinition` class which allows for shorthand +/// when creating tables +public class TypedTableDefinition where T: TableRecord, T: ColumnExpressible { + let definition: TableDefinition + + init(definition: TableDefinition) { + self.definition = definition + } + + @discardableResult public func column(_ key: T.Columns, _ type: Database.ColumnType? = nil) -> ColumnDefinition { + return definition.column(key.name, type) + } + + public func primaryKey(_ columns: [T.Columns], onConflict: Database.ConflictResolution? = nil) { + definition.primaryKey(columns.map { $0.name }, onConflict: onConflict) + } + + public func foreignKey(_ columns: [T.Columns], references table: Other.Type, columns destinationColumns: [Other.Columns]? = nil, onDelete: Database.ForeignKeyAction? = nil, onUpdate: Database.ForeignKeyAction? = nil, deferred: Bool = false) where Other: TableRecord, Other: ColumnExpressible { + return definition.foreignKey( + columns.map { $0.name }, + references: table.databaseTableName, + columns: destinationColumns?.map { $0.name }, + onDelete: onDelete, + onUpdate: onUpdate, + deferred: deferred + ) + } +} diff --git a/SessionUtilitiesKit/Database/Utilities/ColumnDefinition+Utilities.swift b/SessionUtilitiesKit/Database/Utilities/ColumnDefinition+Utilities.swift new file mode 100644 index 000000000..1684441ff --- /dev/null +++ b/SessionUtilitiesKit/Database/Utilities/ColumnDefinition+Utilities.swift @@ -0,0 +1,22 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +public extension ColumnDefinition { + @discardableResult func references( + _ table: T.Type, + column: T.Columns? = nil, + onDelete deleteAction: Database.ForeignKeyAction? = nil, + onUpdate updateAction: Database.ForeignKeyAction? = nil, + deferred: Bool = false + ) -> Self where T: TableRecord, T: ColumnExpressible { + return references( + T.databaseTableName, + column: column?.name, + onDelete: deleteAction, + onUpdate: updateAction, + deferred: deferred + ) + } +} diff --git a/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift b/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift new file mode 100644 index 000000000..7ab658bb5 --- /dev/null +++ b/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift @@ -0,0 +1,18 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +public extension Database { + func create( + table: T.Type, + options: TableOptions = [], + body: (TypedTableDefinition) throws -> Void + ) throws where T: TableRecord, T: ColumnExpressible { + try create(table: T.databaseTableName, options: options) { tableDefinition in + let typedDefinition: TypedTableDefinition = TypedTableDefinition(definition: tableDefinition) + + try body(typedDefinition) + } + } +} diff --git a/SessionUtilitiesKit/Database/Utilities/DatabaseMigrator+Utilities.swift b/SessionUtilitiesKit/Database/Utilities/DatabaseMigrator+Utilities.swift new file mode 100644 index 000000000..ccc073f35 --- /dev/null +++ b/SessionUtilitiesKit/Database/Utilities/DatabaseMigrator+Utilities.swift @@ -0,0 +1,10 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +public extension DatabaseMigrator { + mutating func registerMigration(_ identifier: TargetMigrations.Identifier, migration: Migration.Type, foreignKeyChecks: ForeignKeyChecks = .deferred) { + self.registerMigration("\(identifier).\(migration.identifier)", migrate: migration.migrate) + } +} diff --git a/SessionUtilitiesKit/Database/Utilities/GRDB+Notifications.swift b/SessionUtilitiesKit/Database/Utilities/GRDB+Notifications.swift new file mode 100644 index 000000000..fe1d4f95e --- /dev/null +++ b/SessionUtilitiesKit/Database/Utilities/GRDB+Notifications.swift @@ -0,0 +1,11 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension Notification.Name { + static let resetStorage = Notification.Name("resetStorage") +} + +@objc public extension NSNotification { + @objc static let resetStorage = Notification.Name.resetStorage.rawValue as NSString +} diff --git a/SessionUtilitiesKit/General/Set+Utilities.swift b/SessionUtilitiesKit/General/Set+Utilities.swift new file mode 100644 index 000000000..a5880927d --- /dev/null +++ b/SessionUtilitiesKit/General/Set+Utilities.swift @@ -0,0 +1,19 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension Set { + func inserting(_ value: Element) -> Set { + var updatedSet: Set = self + updatedSet.insert(value) + + return updatedSet + } + + func removing(_ value: Element) -> Set { + var updatedSet: Set = self + updatedSet.remove(value) + + return updatedSet + } +} diff --git a/SignalUtilitiesKit/Configuration.swift b/SignalUtilitiesKit/Configuration.swift index 11234855e..b32479eb4 100644 --- a/SignalUtilitiesKit/Configuration.swift +++ b/SignalUtilitiesKit/Configuration.swift @@ -3,12 +3,34 @@ import SessionSnodeKit extension OWSPrimaryStorage : OWSPrimaryStorageProtocol { } +var isSetup: Bool = false // TODO: Remove this + @objc(SNConfiguration) public final class Configuration : NSObject { + @objc public static func performMainSetup() { + // Need to do this first to ensure the legacy database exists + SNUtilitiesKit.configure( + owsPrimaryStorage: OWSPrimaryStorage.shared(), + maxFileSize: UInt(Double(FileServerAPIV2.maxFileSize) / FileServerAPIV2.fileSizeORMultiplier) + ) + + if !isSetup { + isSetup = true + + // TODO: Need to store this result somewhere? + // TODO: This function seems to get called multiple times + //DispatchQueue.main.once + let storage: GRDBStorage? = try? GRDBStorage( + migrations: [ + SNSnodeKit.migrations(), + SNMessagingKit.migrations() + ] + ) + } + SNMessagingKit.configure(storage: Storage.shared) SNSnodeKit.configure(storage: Storage.shared) - SNUtilitiesKit.configure(owsPrimaryStorage: OWSPrimaryStorage.shared(), maxFileSize: UInt(Double(FileServerAPIV2.maxFileSize) / FileServerAPIV2.fileSizeORMultiplier)) } } From a1b4554cdbaaf768d4b75387468148142d876a02 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 1 Apr 2022 17:22:45 +1100 Subject: [PATCH 054/157] Migrated the SessionSnodeKit from YapDatabase to GRDB Changed the min OS version to iOS 13.0 (support for 'Identifiable') Removed the alternate approaches to fetching the userKeyPair and userPublicKeyHexString (no consistently routed through the caching method) Migrated the 'OWSIdentityManager' logic to use the new 'Identity' type Added the 'Setting' table and got the pattern working fairly nicely (unfortunately there isn't a good way to avoid key collision without proper enums) Updated the SessionSnodeKit to migration it's data from YDB to GRDB Updated the SessionSnodeKit to use GRDB throughout it's logic --- Session.xcodeproj/project.pbxproj | 164 +++-- Session/Conversations/ConversationVC.swift | 16 +- Session/Conversations/ConversationViewItem.m | 4 +- .../OWSConversationSettingsViewController.m | 11 - Session/Home/HomeVC.swift | 7 +- Session/Meta/AppDelegate.m | 2 +- Session/Meta/AppDelegate.swift | 7 +- Session/Meta/MainAppContext.m | 1 - Session/Meta/Signal-Bridging-Header.h | 1 - Session/Notifications/AppNotifications.swift | 4 - Session/Onboarding/LandingVC.swift | 6 +- Session/Onboarding/LinkDeviceVC.swift | 9 +- Session/Onboarding/Onboarding.swift | 44 +- Session/Onboarding/RegisterVC.swift | 2 +- Session/Onboarding/RestoreVC.swift | 8 +- Session/Onboarding/SeedVC.swift | 18 +- Session/Path/PathStatusView.swift | 5 +- Session/Path/PathVC.swift | 57 +- Session/Settings/SeedModal.swift | 19 +- Session/Utilities/BackgroundPoller.swift | 29 +- Session/Utilities/IP2Country.swift | 11 +- Session/Utilities/KeyPairUtilities.swift | 28 - Session/Utilities/MockDataGenerator.swift | 8 +- .../_001_InitialSetupMigration.swift | 20 - .../Database/Storage+ClosedGroups.swift | 4 + .../Database/Storage+Messaging.swift | 2 +- .../Database/Storage+Shared.swift | 18 - .../ClosedGroupControlMessage.swift | 4 + .../ConfigurationMessage.swift | 4 + .../Meta/SessionMessagingKit.h | 1 - .../Open Groups/OpenGroupAPIV2.swift | 11 +- .../Open Groups/OpenGroupMessageV2.swift | 12 +- .../MessageReceiver+Decryption.swift | 6 +- .../MessageReceiver+Handling.swift | 6 +- .../Sending & Receiving/MessageReceiver.swift | 9 +- .../MessageSender+ClosedGroups.swift | 6 +- .../MessageSender+Encryption.swift | 7 +- .../Sending & Receiving/MessageSender.swift | 4 +- .../Pollers/ClosedGroupPoller.swift | 31 +- .../Sending & Receiving/Pollers/Poller.swift | 35 +- SessionMessagingKit/Storage.swift | 3 - .../To Do/OWSRecipientIdentity.m | 1 - SessionMessagingKit/Utilities/General.swift | 25 - .../Utilities/OWSIdentityManager.h | 74 --- .../Utilities/OWSIdentityManager.m | 405 ------------- .../SNProtoEnvelope+Conversion.swift | 16 +- .../Utilities/SSKEnvironment.h | 1 - .../Utilities/SSKEnvironment.m | 3 - .../NSENotificationPresenter.swift | 2 +- SessionSnodeKit/Configuration.swift | 5 +- .../LegacyDatabase/SSKLegacyModels.swift | 11 +- .../Migrations/_002_YDBToGRDBMigration.swift | 20 +- SessionSnodeKit/Database/Models/Snode.swift | 102 +++- .../Models/SnodeReceivedMessageInfo.swift | 52 +- .../Database/Models/SnodeSet.swift | 59 +- .../Database/Types/SSKSetting.swift | 5 + .../Models/SnodeReceivedMessage.swift | 33 + SessionSnodeKit/Models/SwarmSnode.swift | 58 ++ .../OnionRequestAPI+Encryption.swift | 6 +- SessionSnodeKit/OnionRequestAPI.swift | 335 ++++++----- SessionSnodeKit/SnodeAPI.swift | 566 ++++++++++-------- SessionSnodeKit/Storage+OnionRequests.swift | 49 -- SessionSnodeKit/Storage+SnodeAPI.swift | 140 ----- SessionSnodeKit/Storage.swift | 28 - SessionSnodeKit/Types/SSKDestination.swift | 17 + SessionSnodeKit/Types/SSKEndpoint.swift | 15 + SessionSnodeKit/Types/SSKError.swift | 63 ++ SessionUtilitiesKit/Configuration.swift | 11 + .../Database/GRDBStorage.swift | 8 +- .../LegacyDatabase/SUKLegacyModels.swift | 25 + .../_001_InitialSetupMigration.swift | 26 + .../Migrations/_002_YDBToGRDBMigration.swift | 89 +++ .../Database/Models/Identity.swift | 129 ++++ .../Database/Models/Setting.swift | 149 +++++ .../Database/Types/SettingType.swift | 3 - .../Database/Types/TargetMigrations.swift | 1 + .../Database/Types/TypedTableDefinition.swift | 9 +- .../General/Array+Utilities.swift | 19 + .../General/Dictionary+Description.swift | 10 + SessionUtilitiesKit/General/General.swift | 32 + SessionUtilitiesKit/Utilities/Failable.swift | 26 + .../Utilities/Sodium+Conversion.swift | 3 + SignalUtilitiesKit/Configuration.swift | 3 +- .../Database/Storage+Conformances.swift | 5 +- .../Database/TSStorageHeaders.h | 2 +- .../To Do/OWSPrimaryStorage+Loki.m | 1 - SignalUtilitiesKit/Utilities/AppSetup.m | 3 - 87 files changed, 1822 insertions(+), 1467 deletions(-) delete mode 100644 Session/Utilities/KeyPairUtilities.swift delete mode 100644 SessionMessagingKit/Utilities/General.swift delete mode 100644 SessionMessagingKit/Utilities/OWSIdentityManager.h delete mode 100644 SessionMessagingKit/Utilities/OWSIdentityManager.m create mode 100644 SessionSnodeKit/Models/SnodeReceivedMessage.swift create mode 100644 SessionSnodeKit/Models/SwarmSnode.swift delete mode 100644 SessionSnodeKit/Storage+OnionRequests.swift delete mode 100644 SessionSnodeKit/Storage+SnodeAPI.swift delete mode 100644 SessionSnodeKit/Storage.swift create mode 100644 SessionSnodeKit/Types/SSKDestination.swift create mode 100644 SessionSnodeKit/Types/SSKEndpoint.swift create mode 100644 SessionSnodeKit/Types/SSKError.swift create mode 100644 SessionUtilitiesKit/Database/LegacyDatabase/SUKLegacyModels.swift create mode 100644 SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift create mode 100644 SessionUtilitiesKit/Database/Migrations/_002_YDBToGRDBMigration.swift create mode 100644 SessionUtilitiesKit/Database/Models/Identity.swift create mode 100644 SessionUtilitiesKit/Database/Models/Setting.swift delete mode 100644 SessionUtilitiesKit/Database/Types/SettingType.swift create mode 100644 SessionUtilitiesKit/Utilities/Failable.swift rename {SessionMessagingKit => SessionUtilitiesKit}/Utilities/Sodium+Conversion.swift (94%) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 2efcd923b..7909b4ffe 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -188,7 +188,6 @@ B8566C7D256F62030045A0B9 /* OWSUserProfile.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF2D3255B6DAF007E1867 /* OWSUserProfile.h */; settings = {ATTRIBUTES = (Public, ); }; }; B8569AC325CB5D2900DBA3DB /* ConversationVC+Interaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8569AC225CB5D2900DBA3DB /* ConversationVC+Interaction.swift */; }; B8569AE325CBB19A00DBA3DB /* DocumentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8569AE225CBB19A00DBA3DB /* DocumentView.swift */; }; - B866CE112581C1A900535CC4 /* Sodium+Conversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E7134E251C867C009649BB /* Sodium+Conversion.swift */; }; B86BD08423399ACF000F5AE3 /* Modal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B86BD08323399ACF000F5AE3 /* Modal.swift */; }; B86BD08623399CEF000F5AE3 /* SeedModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B86BD08523399CEF000F5AE3 /* SeedModal.swift */; }; B875885A264503A6000E60D0 /* JoinOpenGroupModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8758859264503A6000E60D0 /* JoinOpenGroupModal.swift */; }; @@ -287,7 +286,6 @@ C31A6C5C247F2CF3001123EF /* CGRect+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31A6C5B247F2CF3001123EF /* CGRect+Utilities.swift */; }; C31D1DE32521718E005D4DA8 /* UserSelectionVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31D1DE22521718E005D4DA8 /* UserSelectionVC.swift */; }; C31D1DE9252172D4005D4DA8 /* ContactUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31D1DE8252172D4005D4DA8 /* ContactUtilities.swift */; }; - C31FFE57254A5FFE00F19441 /* KeyPairUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31FFE56254A5FFE00F19441 /* KeyPairUtilities.swift */; }; C3227FF6260AAD66006EA627 /* OpenGroupMessageV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3227FF5260AAD66006EA627 /* OpenGroupMessageV2.swift */; }; C32824D325C9F9790062D0A7 /* SessionSnodeKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A59F255385C100C340D1 /* SessionSnodeKit.framework */; }; C328250F25CA06020062D0A7 /* VoiceMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C328250E25CA06020062D0A7 /* VoiceMessageView.swift */; }; @@ -348,9 +346,6 @@ C32C5BE6256DC891003C73A2 /* OWSReadReceiptManager.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB1D255A580900E217F9 /* OWSReadReceiptManager.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5BEF256DC8EE003C73A2 /* OWSDisappearingMessagesJob.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBDD255A581900E217F9 /* OWSDisappearingMessagesJob.m */; }; C32C5BF8256DC8F6003C73A2 /* OWSDisappearingMessagesJob.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDA80255A57FC00E217F9 /* OWSDisappearingMessagesJob.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C32C5C01256DC9A0003C73A2 /* OWSIdentityManager.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBA9255A581500E217F9 /* OWSIdentityManager.m */; }; - C32C5C0A256DC9B4003C73A2 /* OWSIdentityManager.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBF1255A581B00E217F9 /* OWSIdentityManager.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C32C5C1B256DC9E0003C73A2 /* General.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBC1255A581700E217F9 /* General.swift */; }; C32C5C24256DCB30003C73A2 /* NotificationsProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB7A255A581000E217F9 /* NotificationsProtocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5C3D256DCBAF003C73A2 /* AppReadiness.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB75255A581000E217F9 /* AppReadiness.m */; }; C32C5C46256DCBB2003C73A2 /* AppReadiness.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB01255A580700E217F9 /* AppReadiness.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -359,8 +354,6 @@ C32C5C89256DD0D2003C73A2 /* Storage+Jobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8D8F17625661AFA0092EF10 /* Storage+Jobs.swift */; }; C32C5CA4256DD1DC003C73A2 /* TSAccountManager.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB88255A581200E217F9 /* TSAccountManager.m */; }; C32C5CAD256DD1DF003C73A2 /* TSAccountManager.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB94255A581300E217F9 /* TSAccountManager.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C32C5CBE256DD282003C73A2 /* Storage+OnionRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8D8F1BC25661C6F0092EF10 /* Storage+OnionRequests.swift */; }; - C32C5CBF256DD282003C73A2 /* Storage+SnodeAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3F0A607255C98A6007BE2A3 /* Storage+SnodeAPI.swift */; }; C32C5CF0256DD3E4003C73A2 /* Storage+Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3F0A5FD255C988A007BE2A3 /* Storage+Shared.swift */; }; C32C5D19256DD493003C73A2 /* OWSLinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBA8255A581500E217F9 /* OWSLinkPreview.swift */; }; C32C5D23256DD4C0003C73A2 /* Mention.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA7E255A57FB00E217F9 /* Mention.swift */; }; @@ -670,7 +663,6 @@ C3C2A5A7255385C100C340D1 /* SessionSnodeKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A59F255385C100C340D1 /* SessionSnodeKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; C3C2A5BF255385EE00C340D1 /* SnodeMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5B6255385EC00C340D1 /* SnodeMessage.swift */; }; C3C2A5C0255385EE00C340D1 /* Snode.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5B7255385EC00C340D1 /* Snode.swift */; }; - C3C2A5C1255385EE00C340D1 /* Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5B8255385EC00C340D1 /* Storage.swift */; }; C3C2A5C2255385EE00C340D1 /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5B9255385ED00C340D1 /* Configuration.swift */; }; C3C2A5C3255385EE00C340D1 /* OnionRequestAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5BA255385ED00C340D1 /* OnionRequestAPI.swift */; }; C3C2A5C4255385EE00C340D1 /* OnionRequestAPI+Encryption.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5BB255385ED00C340D1 /* OnionRequestAPI+Encryption.swift */; }; @@ -754,6 +746,9 @@ F5765D284BC6ECAC0C1D33F0 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionNotificationServiceExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E4A93ECA93B3DE800CC7D7F6 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionNotificationServiceExtension.framework */; }; FC3BD9881A30A790005B96BB /* Social.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FC3BD9871A30A790005B96BB /* Social.framework */; }; FCB11D8C1A129A76002F93FB /* CoreMedia.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FCB11D8B1A129A76002F93FB /* CoreMedia.framework */; }; + FD09796727F6B0B600936362 /* Sodium+Conversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E7134E251C867C009649BB /* Sodium+Conversion.swift */; }; + FD09796927F6BEA700936362 /* SwarmSnode.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09796827F6BEA700936362 /* SwarmSnode.swift */; }; + FD09796B27F6C67500936362 /* Failable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09796A27F6C67500936362 /* Failable.swift */; }; FD17D79927F40AB800122BE0 /* _002_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79827F40AB800122BE0 /* _002_YDBToGRDBMigration.swift */; }; FD17D79C27F40B2E00122BE0 /* SMKLegacyModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */; }; FD17D7A027F40CC800122BE0 /* _001_InitialSetupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79F27F40CC800122BE0 /* _001_InitialSetupMigration.swift */; }; @@ -765,7 +760,6 @@ FD17D7AE27F41C4300122BE0 /* SnodeReceivedMessageInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7AD27F41C4300122BE0 /* SnodeReceivedMessageInfo.swift */; }; FD17D7B027F4225C00122BE0 /* Set+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7AF27F4225C00122BE0 /* Set+Utilities.swift */; }; FD17D7B327F51E5B00122BE0 /* SSKSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7B227F51E5B00122BE0 /* SSKSetting.swift */; }; - FD17D7B627F51E7300122BE0 /* SettingType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7B527F51E7300122BE0 /* SettingType.swift */; }; FD17D7B827F51ECA00122BE0 /* Migration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7B727F51ECA00122BE0 /* Migration.swift */; }; FD17D7BA27F51F2100122BE0 /* TargetMigrations.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7B927F51F2100122BE0 /* TargetMigrations.swift */; }; FD17D7BD27F51F6900122BE0 /* GRDB+Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7BC27F51F6900122BE0 /* GRDB+Notifications.swift */; }; @@ -774,6 +768,15 @@ FD17D7C327F5204C00122BE0 /* Database+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7C227F5204C00122BE0 /* Database+Utilities.swift */; }; FD17D7C527F5206300122BE0 /* ColumnDefinition+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7C427F5206300122BE0 /* ColumnDefinition+Utilities.swift */; }; FD17D7C727F5207C00122BE0 /* DatabaseMigrator+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7C627F5207C00122BE0 /* DatabaseMigrator+Utilities.swift */; }; + FD17D7CA27F546D900122BE0 /* _001_InitialSetupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7C927F546D900122BE0 /* _001_InitialSetupMigration.swift */; }; + FD17D7CD27F546FF00122BE0 /* Setting.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7CC27F546FF00122BE0 /* Setting.swift */; }; + FD17D7D227F5797A00122BE0 /* SSKEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7D127F5797A00122BE0 /* SSKEndpoint.swift */; }; + FD17D7D427F6584600122BE0 /* SSKError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7D327F6584600122BE0 /* SSKError.swift */; }; + FD17D7D827F658E200122BE0 /* SSKDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7D727F658E200122BE0 /* SSKDestination.swift */; }; + FD17D7E127F67BD400122BE0 /* SnodeReceivedMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E027F67BD400122BE0 /* SnodeReceivedMessage.swift */; }; + FD17D7E527F6A09900122BE0 /* Identity.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E427F6A09900122BE0 /* Identity.swift */; }; + FD17D7E727F6A16700122BE0 /* _002_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E627F6A16700122BE0 /* _002_YDBToGRDBMigration.swift */; }; + FD17D7EA27F6A1C600122BE0 /* SUKLegacyModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E927F6A1C600122BE0 /* SUKLegacyModels.swift */; }; FD28A4F227E990E800FF65E7 /* BlockingManagerRemovalMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD28A4F127E990E800FF65E7 /* BlockingManagerRemovalMigration.swift */; }; FD28A4F427EA79F800FF65E7 /* BlockListUIUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD28A4F327EA79F800FF65E7 /* BlockListUIUtils.swift */; }; FD3C907327E8387300CD579F /* OpenGroupServerIdLookupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907227E8387300CD579F /* OpenGroupServerIdLookupMigration.swift */; }; @@ -1274,7 +1277,6 @@ B8D8F17625661AFA0092EF10 /* Storage+Jobs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+Jobs.swift"; sourceTree = ""; }; B8D8F18825661BA50092EF10 /* Storage+OpenGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+OpenGroups.swift"; sourceTree = ""; }; B8D8F19225661BF80092EF10 /* Storage+Messaging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+Messaging.swift"; sourceTree = ""; }; - B8D8F1BC25661C6F0092EF10 /* Storage+OnionRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+OnionRequests.swift"; sourceTree = ""; }; B8D8F1EF256621180092EF10 /* MessageSender+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "MessageSender+Convenience.swift"; path = "../../SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift"; sourceTree = ""; }; B8EB20E6263F7E4B00773E52 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = ""; }; B8EB20ED2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VisibleMessage+OpenGroupInvitation.swift"; sourceTree = ""; }; @@ -1307,7 +1309,6 @@ C31D1DDC25217014005D4DA8 /* UserCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCell.swift; sourceTree = ""; }; C31D1DE22521718E005D4DA8 /* UserSelectionVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSelectionVC.swift; sourceTree = ""; }; C31D1DE8252172D4005D4DA8 /* ContactUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactUtilities.swift; sourceTree = ""; }; - C31FFE56254A5FFE00F19441 /* KeyPairUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyPairUtilities.swift; sourceTree = ""; }; C3227FF5260AAD66006EA627 /* OpenGroupMessageV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupMessageV2.swift; sourceTree = ""; }; C328250E25CA06020062D0A7 /* VoiceMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageView.swift; sourceTree = ""; }; C328251E25CA3A900062D0A7 /* QuoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuoteView.swift; sourceTree = ""; }; @@ -1461,7 +1462,6 @@ C33FDBA1255A581400E217F9 /* OWSOperation.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSOperation.h; sourceTree = ""; }; C33FDBA4255A581400E217F9 /* OWSDisappearingMessagesConfiguration.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSDisappearingMessagesConfiguration.m; sourceTree = ""; }; C33FDBA8255A581500E217F9 /* OWSLinkPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSLinkPreview.swift; sourceTree = ""; }; - C33FDBA9255A581500E217F9 /* OWSIdentityManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSIdentityManager.m; sourceTree = ""; }; C33FDBAB255A581500E217F9 /* OWSFileSystem.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSFileSystem.h; sourceTree = ""; }; C33FDBAE255A581500E217F9 /* SignalAccount.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SignalAccount.h; sourceTree = ""; }; C33FDBB4255A581600E217F9 /* NSURLSessionDataTask+StatusCode.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSURLSessionDataTask+StatusCode.m"; sourceTree = ""; }; @@ -1472,7 +1472,6 @@ C33FDBBA255A581600E217F9 /* OWSPrimaryStorage+keyFromIntLong.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "OWSPrimaryStorage+keyFromIntLong.h"; sourceTree = ""; }; C33FDBBB255A581600E217F9 /* OWSPrimaryStorage+Loki.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "OWSPrimaryStorage+Loki.h"; sourceTree = ""; }; C33FDBBC255A581600E217F9 /* SSKKeychainStorage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SSKKeychainStorage.swift; sourceTree = ""; }; - C33FDBC1255A581700E217F9 /* General.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = General.swift; sourceTree = ""; }; C33FDBC2255A581700E217F9 /* SSKAsserts.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SSKAsserts.h; sourceTree = ""; }; C33FDBCA255A581700E217F9 /* LKGroupUtilities.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = LKGroupUtilities.h; sourceTree = ""; }; C33FDBD3255A581800E217F9 /* OWSSignalAddress.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSSignalAddress.swift; sourceTree = ""; }; @@ -1484,7 +1483,6 @@ C33FDBE9255A581A00E217F9 /* TSInteraction.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSInteraction.m; sourceTree = ""; }; C33FDBEC255A581B00E217F9 /* OWSRecipientIdentity.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSRecipientIdentity.m; sourceTree = ""; }; C33FDBEF255A581B00E217F9 /* TSStorageKeys.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSStorageKeys.h; sourceTree = ""; }; - C33FDBF1255A581B00E217F9 /* OWSIdentityManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSIdentityManager.h; sourceTree = ""; }; C33FDBF6255A581C00E217F9 /* NSURLSessionDataTask+StatusCode.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSURLSessionDataTask+StatusCode.h"; sourceTree = ""; }; C33FDBF8255A581C00E217F9 /* NSArray+OWS.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSArray+OWS.h"; sourceTree = ""; }; C33FDBF9255A581C00E217F9 /* OWSError.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSError.h; sourceTree = ""; }; @@ -1706,7 +1704,6 @@ C3C2A5A2255385C100C340D1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; C3C2A5B6255385EC00C340D1 /* SnodeMessage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SnodeMessage.swift; sourceTree = ""; }; C3C2A5B7255385EC00C340D1 /* Snode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Snode.swift; sourceTree = ""; }; - C3C2A5B8255385EC00C340D1 /* Storage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = ""; }; C3C2A5B9255385ED00C340D1 /* Configuration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = ""; }; C3C2A5BA255385ED00C340D1 /* OnionRequestAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OnionRequestAPI.swift; sourceTree = ""; }; C3C2A5BB255385ED00C340D1 /* OnionRequestAPI+Encryption.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "OnionRequestAPI+Encryption.swift"; sourceTree = ""; }; @@ -1764,7 +1761,6 @@ C3F0A5B2255C915C007BE2A3 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; C3F0A5EB255C970D007BE2A3 /* Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = ""; }; C3F0A5FD255C988A007BE2A3 /* Storage+Shared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+Shared.swift"; sourceTree = ""; }; - C3F0A607255C98A6007BE2A3 /* Storage+SnodeAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+SnodeAPI.swift"; sourceTree = ""; }; C5060C3B36A848B71CCE4685 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C88965DE4F4EC4FC919BEC4E /* Pods-SessionUIKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionUIKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SessionUIKit/Pods-SessionUIKit.debug.xcconfig"; sourceTree = ""; }; C98441E849C3CA7FE8220D33 /* Pods-SessionNotificationServiceExtension.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionNotificationServiceExtension.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionNotificationServiceExtension/Pods-SessionNotificationServiceExtension.app store release.xcconfig"; sourceTree = ""; }; @@ -1795,6 +1791,8 @@ F9BBF530D71905BA9007675F /* Pods-SessionShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionShareExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SessionShareExtension/Pods-SessionShareExtension.debug.xcconfig"; sourceTree = ""; }; FC3BD9871A30A790005B96BB /* Social.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Social.framework; path = System/Library/Frameworks/Social.framework; sourceTree = SDKROOT; }; FCB11D8B1A129A76002F93FB /* CoreMedia.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreMedia.framework; path = System/Library/Frameworks/CoreMedia.framework; sourceTree = SDKROOT; }; + FD09796827F6BEA700936362 /* SwarmSnode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwarmSnode.swift; sourceTree = ""; }; + FD09796A27F6C67500936362 /* Failable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Failable.swift; sourceTree = ""; }; FD17D79527F3E04600122BE0 /* _001_InitialSetupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _001_InitialSetupMigration.swift; sourceTree = ""; }; FD17D79827F40AB800122BE0 /* _002_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_YDBToGRDBMigration.swift; sourceTree = ""; }; FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMKLegacyModels.swift; sourceTree = ""; }; @@ -1805,7 +1803,6 @@ FD17D7AD27F41C4300122BE0 /* SnodeReceivedMessageInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnodeReceivedMessageInfo.swift; sourceTree = ""; }; FD17D7AF27F4225C00122BE0 /* Set+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Set+Utilities.swift"; sourceTree = ""; }; FD17D7B227F51E5B00122BE0 /* SSKSetting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSKSetting.swift; sourceTree = ""; }; - FD17D7B527F51E7300122BE0 /* SettingType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingType.swift; sourceTree = ""; }; FD17D7B727F51ECA00122BE0 /* Migration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Migration.swift; sourceTree = ""; }; FD17D7B927F51F2100122BE0 /* TargetMigrations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TargetMigrations.swift; sourceTree = ""; }; FD17D7BC27F51F6900122BE0 /* GRDB+Notifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GRDB+Notifications.swift"; sourceTree = ""; }; @@ -1814,9 +1811,18 @@ FD17D7C227F5204C00122BE0 /* Database+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Database+Utilities.swift"; sourceTree = ""; }; FD17D7C427F5206300122BE0 /* ColumnDefinition+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ColumnDefinition+Utilities.swift"; sourceTree = ""; }; FD17D7C627F5207C00122BE0 /* DatabaseMigrator+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DatabaseMigrator+Utilities.swift"; sourceTree = ""; }; - FD28A4F527EAD44C00FF65E7 /* GRDBStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GRDBStorage.swift; sourceTree = ""; }; + FD17D7C927F546D900122BE0 /* _001_InitialSetupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _001_InitialSetupMigration.swift; sourceTree = ""; }; + FD17D7CC27F546FF00122BE0 /* Setting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Setting.swift; sourceTree = ""; }; + FD17D7D127F5797A00122BE0 /* SSKEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSKEndpoint.swift; sourceTree = ""; }; + FD17D7D327F6584600122BE0 /* SSKError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSKError.swift; sourceTree = ""; }; + FD17D7D727F658E200122BE0 /* SSKDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSKDestination.swift; sourceTree = ""; }; + FD17D7E027F67BD400122BE0 /* SnodeReceivedMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnodeReceivedMessage.swift; sourceTree = ""; }; + FD17D7E427F6A09900122BE0 /* Identity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Identity.swift; sourceTree = ""; }; + FD17D7E627F6A16700122BE0 /* _002_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_YDBToGRDBMigration.swift; sourceTree = ""; }; + FD17D7E927F6A1C600122BE0 /* SUKLegacyModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SUKLegacyModels.swift; sourceTree = ""; }; FD28A4F127E990E800FF65E7 /* BlockingManagerRemovalMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockingManagerRemovalMigration.swift; sourceTree = ""; }; FD28A4F327EA79F800FF65E7 /* BlockListUIUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockListUIUtils.swift; sourceTree = ""; }; + FD28A4F527EAD44C00FF65E7 /* GRDBStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GRDBStorage.swift; sourceTree = ""; }; FD3C907227E8387300CD579F /* OpenGroupServerIdLookupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookupMigration.swift; sourceTree = ""; }; FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookup.swift; sourceTree = ""; }; FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestResponse.swift; sourceTree = ""; }; @@ -2062,7 +2068,6 @@ C31A6C5B247F2CF3001123EF /* CGRect+Utilities.swift */, C31D1DE8252172D4005D4DA8 /* ContactUtilities.swift */, C35E8AAD2485E51D00ACB629 /* IP2Country.swift */, - C31FFE56254A5FFE00F19441 /* KeyPairUtilities.swift */, B84664F4235022F30083A1CD /* MentionUtilities.swift */, B886B4A82398BA1500211ABE /* QRCode.swift */, B8783E9D23EB948D00404FB8 /* UILabel+Interaction.swift */, @@ -2271,6 +2276,9 @@ B8A582AB258C64E800AFD84C /* Database */ = { isa = PBXGroup; children = ( + FD17D7E827F6A1B800122BE0 /* LegacyDatabase */, + FD17D7C827F546CE00122BE0 /* Migrations */, + FD17D7CB27F546F500122BE0 /* Models */, FD17D7B427F51E6700122BE0 /* Types */, FD17D7BB27F51F5C00122BE0 /* Utilities */, FD28A4F527EAD44C00FF65E7 /* GRDBStorage.swift */, @@ -3183,7 +3191,6 @@ C37F5402255BA9ED002AEA92 /* Environment.m */, C3BBE0C62554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift */, C33FDB7F255A581100E217F9 /* FullTextSearchFinder.swift */, - C33FDBC1255A581700E217F9 /* General.swift */, B82A0C3726B9098200C1BCE3 /* MessageInvalidator.swift */, C3A71D0A2558989C0043A11F /* MessageWrapper.swift */, C3A71D4E25589FF30043A11F /* NSData+messagePadding.h */, @@ -3195,8 +3202,6 @@ C33FDC1B255A581F00E217F9 /* OWSBackgroundTask.m */, C33FDC05255A581D00E217F9 /* OWSDisappearingMessagesFinder.h */, C33FDA86255A57FC00E217F9 /* OWSDisappearingMessagesFinder.m */, - C33FDBF1255A581B00E217F9 /* OWSIdentityManager.h */, - C33FDBA9255A581500E217F9 /* OWSIdentityManager.m */, C33FDAC0255A580100E217F9 /* OWSIncomingMessageFinder.h */, C33FDB1E255A580900E217F9 /* OWSIncomingMessageFinder.m */, C33FDB67255A580F00E217F9 /* OWSMediaGalleryFinder.h */, @@ -3213,7 +3218,6 @@ C33FDB91255A581200E217F9 /* ProtoUtils.h */, C33FDA6C255A57FA00E217F9 /* ProtoUtils.m */, C38EEF09255B49A8007E1867 /* SNProtoEnvelope+Conversion.swift */, - C3E7134E251C867C009649BB /* Sodium+Conversion.swift */, C33FDB31255A580A00E217F9 /* SSKEnvironment.h */, C33FDAF4255A580600E217F9 /* SSKEnvironment.m */, C33FDB32255A580A00E217F9 /* SSKIncrementingIdFinder.swift */, @@ -3233,15 +3237,14 @@ children = ( C3C2A5B0255385C700C340D1 /* Meta */, FD17D79D27F40CAA00122BE0 /* Database */, + FD17D7DF27F67BC400122BE0 /* Models */, + FD17D7D027F5795300122BE0 /* Types */, C3C2A5B9255385ED00C340D1 /* Configuration.swift */, C3C2A5BD255385EE00C340D1 /* Notification+OnionRequestAPI.swift */, C3C2A5BA255385ED00C340D1 /* OnionRequestAPI.swift */, C3C2A5BB255385ED00C340D1 /* OnionRequestAPI+Encryption.swift */, C3C2A5BE255385EE00C340D1 /* SnodeAPI.swift */, C3C2A5B6255385EC00C340D1 /* SnodeMessage.swift */, - C3C2A5B8255385EC00C340D1 /* Storage.swift */, - B8D8F1BC25661C6F0092EF10 /* Storage+OnionRequests.swift */, - C3F0A607255C98A6007BE2A3 /* Storage+SnodeAPI.swift */, C3C2A5CD255385F300C340D1 /* Utilities */, ); path = SessionSnodeKit; @@ -3279,6 +3282,7 @@ B8A582B9258C696200AFD84C /* Messaging */, B8A582AE258C65D000AFD84C /* Networking */, B8A582AD258C655E00AFD84C /* PromiseKit */, + FD09796527F6B0A800936362 /* Utilities */, C3D9E43025676D3D0040E4F3 /* Configuration.swift */, ); path = SessionUtilitiesKit; @@ -3591,6 +3595,15 @@ path = Session; sourceTree = ""; }; + FD09796527F6B0A800936362 /* Utilities */ = { + isa = PBXGroup; + children = ( + FD09796A27F6C67500936362 /* Failable.swift */, + C3E7134E251C867C009649BB /* Sodium+Conversion.swift */, + ); + path = Utilities; + sourceTree = ""; + }; FD17D79427F3E03300122BE0 /* Migrations */ = { isa = PBXGroup; children = ( @@ -3659,7 +3672,6 @@ children = ( FD17D7BE27F51F8200122BE0 /* ColumnExpressible.swift */, FD17D7B727F51ECA00122BE0 /* Migration.swift */, - FD17D7B527F51E7300122BE0 /* SettingType.swift */, FD17D7B927F51F2100122BE0 /* TargetMigrations.swift */, FD17D7C027F5200100122BE0 /* TypedTableDefinition.swift */, ); @@ -3677,6 +3689,51 @@ path = Utilities; sourceTree = ""; }; + FD17D7C827F546CE00122BE0 /* Migrations */ = { + isa = PBXGroup; + children = ( + FD17D7C927F546D900122BE0 /* _001_InitialSetupMigration.swift */, + FD17D7E627F6A16700122BE0 /* _002_YDBToGRDBMigration.swift */, + ); + path = Migrations; + sourceTree = ""; + }; + FD17D7CB27F546F500122BE0 /* Models */ = { + isa = PBXGroup; + children = ( + FD17D7E427F6A09900122BE0 /* Identity.swift */, + FD17D7CC27F546FF00122BE0 /* Setting.swift */, + ); + path = Models; + sourceTree = ""; + }; + FD17D7D027F5795300122BE0 /* Types */ = { + isa = PBXGroup; + children = ( + FD17D7D127F5797A00122BE0 /* SSKEndpoint.swift */, + FD17D7D327F6584600122BE0 /* SSKError.swift */, + FD17D7D727F658E200122BE0 /* SSKDestination.swift */, + ); + path = Types; + sourceTree = ""; + }; + FD17D7DF27F67BC400122BE0 /* Models */ = { + isa = PBXGroup; + children = ( + FD17D7E027F67BD400122BE0 /* SnodeReceivedMessage.swift */, + FD09796827F6BEA700936362 /* SwarmSnode.swift */, + ); + path = Models; + sourceTree = ""; + }; + FD17D7E827F6A1B800122BE0 /* LegacyDatabase */ = { + isa = PBXGroup; + children = ( + FD17D7E927F6A1C600122BE0 /* SUKLegacyModels.swift */, + ); + path = LegacyDatabase; + sourceTree = ""; + }; FD659ABE27A7648200F12C02 /* Message Requests */ = { isa = PBXGroup; children = ( @@ -3843,7 +3900,6 @@ C3A3A15F256E1BB4004D228D /* ProtoUtils.h in Headers */, C3A3A145256E1B49004D228D /* OWSMediaGalleryFinder.h in Headers */, B8856E33256F18D5001CE70E /* OWSStorage+Subclass.h in Headers */, - C32C5C0A256DC9B4003C73A2 /* OWSIdentityManager.h in Headers */, B8856E9D256F1C3D001CE70E /* OWSSounds.h in Headers */, C32C5E64256DDFD6003C73A2 /* OWSStorage.h in Headers */, ); @@ -4664,27 +4720,29 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + FD09796927F6BEA700936362 /* SwarmSnode.swift in Sources */, C3C2A5E02553860B00C340D1 /* Threading.swift in Sources */, C3C2A5BF255385EE00C340D1 /* SnodeMessage.swift in Sources */, + FD17D7E127F67BD400122BE0 /* SnodeReceivedMessage.swift in Sources */, C3C2A5C0255385EE00C340D1 /* Snode.swift in Sources */, FD17D7A027F40CC800122BE0 /* _001_InitialSetupMigration.swift in Sources */, C3C2A5C7255385EE00C340D1 /* SnodeAPI.swift in Sources */, - C32C5CBF256DD282003C73A2 /* Storage+SnodeAPI.swift in Sources */, C3C2A5C6255385EE00C340D1 /* Notification+OnionRequestAPI.swift in Sources */, FD17D7AA27F41BF500122BE0 /* SnodeSet.swift in Sources */, FD17D7A427F40F8100122BE0 /* _002_YDBToGRDBMigration.swift in Sources */, - C32C5CBE256DD282003C73A2 /* Storage+OnionRequests.swift in Sources */, C3C2A5DC2553860B00C340D1 /* Promise+Threading.swift in Sources */, C3C2A5C4255385EE00C340D1 /* OnionRequestAPI+Encryption.swift in Sources */, + FD17D7D227F5797A00122BE0 /* SSKEndpoint.swift in Sources */, C3C2A5DE2553860B00C340D1 /* String+Trimming.swift in Sources */, C3C2A5DB2553860B00C340D1 /* Promise+Hashing.swift in Sources */, C3C2A5E42553860B00C340D1 /* Data+Utilities.swift in Sources */, FD17D7A727F41AF000122BE0 /* SSKLegacyModels.swift in Sources */, C3C2A5C2255385EE00C340D1 /* Configuration.swift in Sources */, + FD17D7D827F658E200122BE0 /* SSKDestination.swift in Sources */, FD17D7B327F51E5B00122BE0 /* SSKSetting.swift in Sources */, FD17D7AE27F41C4300122BE0 /* SnodeReceivedMessageInfo.swift in Sources */, C3C2A5C3255385EE00C340D1 /* OnionRequestAPI.swift in Sources */, - C3C2A5C1255385EE00C340D1 /* Storage.swift in Sources */, + FD17D7D427F6584600122BE0 /* SSKError.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4694,7 +4752,6 @@ files = ( C3AABDDF2553ECF00042FF4C /* Array+Utilities.swift in Sources */, 7B1D74AC27BDE7510030B423 /* Promise+Timeout.swift in Sources */, - FD17D7B627F51E7300122BE0 /* SettingType.swift in Sources */, C3AABDDF2553ECF00042FF4C /* Array+Utilities.swift in Sources */, C32C5A47256DB8F0003C73A2 /* ECKeyPair+Hexadecimal.swift in Sources */, C3D9E41525676C320040E4F3 /* Storage.swift in Sources */, @@ -4703,11 +4760,13 @@ C3D9E39B256763C20040E4F3 /* AppContext.m in Sources */, C3BBE0A82554D4DE0050F1E3 /* JSON.swift in Sources */, FD17D7C127F5200100122BE0 /* TypedTableDefinition.swift in Sources */, + FD17D7EA27F6A1C600122BE0 /* SUKLegacyModels.swift in Sources */, C352A36D2557858E00338F3E /* NSTimer+Proxying.m in Sources */, C32C5A2D256DB849003C73A2 /* LKGroupUtilities.m in Sources */, C3C2ABD22553C6C900C340D1 /* Data+SecureRandom.swift in Sources */, B8856E09256F1676001CE70E /* UIDevice+featureSupport.swift in Sources */, B8856DEF256F161F001CE70E /* NSString+SSK.m in Sources */, + FD09796727F6B0B600936362 /* Sodium+Conversion.swift in Sources */, C32C5DC9256DD935003C73A2 /* ProxiedContentDownloader.swift in Sources */, C3D9E4C02567767F0040E4F3 /* DataSource.m in Sources */, C3D9E43125676D3D0040E4F3 /* Configuration.swift in Sources */, @@ -4717,6 +4776,7 @@ C32C5FA1256DFED5003C73A2 /* NSArray+Functional.m in Sources */, C3A7225E2558C38D0043A11F /* Promise+Retaining.swift in Sources */, FD17D7BD27F51F6900122BE0 /* GRDB+Notifications.swift in Sources */, + FD17D7CA27F546D900122BE0 /* _001_InitialSetupMigration.swift in Sources */, C3D9E4D12567777D0040E4F3 /* OWSMediaUtils.swift in Sources */, C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Description.swift in Sources */, C3D9E4DA256778410040E4F3 /* UIImage+OWS.m in Sources */, @@ -4727,6 +4787,7 @@ FD17D7C727F5207C00122BE0 /* DatabaseMigrator+Utilities.swift in Sources */, C3D9E3C925676AF30040E4F3 /* TSYapDatabaseObject.m in Sources */, C352A3A62557B60D00338F3E /* TSRequest.m in Sources */, + FD09796B27F6C67500936362 /* Failable.swift in Sources */, FD705A92278D051200F16121 /* ReusableView.swift in Sources */, FD17D7BA27F51F2100122BE0 /* TargetMigrations.swift in Sources */, FD17D7C327F5204C00122BE0 /* Database+Utilities.swift in Sources */, @@ -4753,8 +4814,10 @@ C32C5E0C256DDAFA003C73A2 /* NSRegularExpression+SSK.swift in Sources */, C3BBE0A92554D4DE0050F1E3 /* HTTP.swift in Sources */, B8856D23256F116B001CE70E /* Weak.swift in Sources */, + FD17D7CD27F546FF00122BE0 /* Setting.swift in Sources */, C32C5A48256DB8F0003C73A2 /* BuildConfiguration.swift in Sources */, FD17D7BF27F51F8200122BE0 /* ColumnExpressible.swift in Sources */, + FD17D7E527F6A09900122BE0 /* Identity.swift in Sources */, B87EF18126377A1D00124B3C /* Features.swift in Sources */, C300A60D2554B31900555489 /* Logging.swift in Sources */, B8FF8EA625C11FEF004D1F22 /* IPv4.swift in Sources */, @@ -4762,6 +4825,7 @@ FD705A8E278CE29800F16121 /* String+Localization.swift in Sources */, C3A7219A2558C1660043A11F /* AnyPromise+Conversion.swift in Sources */, C300A6322554B6D100555489 /* NSDate+Timestamp.mm in Sources */, + FD17D7E727F6A16700122BE0 /* _002_YDBToGRDBMigration.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4848,9 +4912,7 @@ C3D9E3BE25676AD70040E4F3 /* TSAttachmentPointer.m in Sources */, C3ECBF7B257056B700EA7FCE /* Threading.swift in Sources */, C32C5AAB256DBE8F003C73A2 /* TSIncomingMessage+Conversion.swift in Sources */, - B866CE112581C1A900535CC4 /* Sodium+Conversion.swift in Sources */, C32C5A88256DBCF9003C73A2 /* MessageReceiver+Handling.swift in Sources */, - C32C5C1B256DC9E0003C73A2 /* General.swift in Sources */, C32C5A02256DB658003C73A2 /* MessageSender+Convenience.swift in Sources */, B8566C6C256F60F50045A0B9 /* OWSUserProfile.m in Sources */, B8D0A25925E367AC00C1835E /* Notification+MessageReceiver.swift in Sources */, @@ -4905,7 +4967,6 @@ C32C5EBA256DE130003C73A2 /* OWSQuotedReplyModel.m in Sources */, C32C5B62256DC333003C73A2 /* OWSDisappearingConfigurationUpdateInfoMessage.m in Sources */, C352A2F525574B4700338F3E /* Job.swift in Sources */, - C32C5C01256DC9A0003C73A2 /* OWSIdentityManager.m in Sources */, C32C59C4256DB41F003C73A2 /* TSContactThread.m in Sources */, C32C5AB0256DBE8F003C73A2 /* TSOutgoingMessage.m in Sources */, B82A0C3826B9098200C1BCE3 /* MessageInvalidator.swift in Sources */, @@ -4975,7 +5036,6 @@ FD659AC027A7649600F12C02 /* MessageRequestsViewController.swift in Sources */, B8F5F72325F1B4CA003BF8D4 /* DownloadAttachmentModal.swift in Sources */, C3DFFAC623E96F0D0058DAF8 /* Sheet.swift in Sources */, - C31FFE57254A5FFE00F19441 /* KeyPairUtilities.swift in Sources */, B8D84EA325DF745A005A043E /* LinkPreviewState.swift in Sources */, 45C0DC1E1E69011F00E04C47 /* UIStoryboard+OWS.swift in Sources */, FD88BAD927A7439C00BBC442 /* MessageRequestsCell.swift in Sources */, @@ -5248,7 +5308,7 @@ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = SessionShareExtension/Meta/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5326,7 +5386,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; INFOPLIST_FILE = SessionShareExtension/Meta/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5385,7 +5445,7 @@ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = SessionNotificationServiceExtension/Meta/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5464,7 +5524,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; INFOPLIST_FILE = SessionNotificationServiceExtension/Meta/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5521,7 +5581,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = SessionUIKit/Meta/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5600,7 +5660,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; INFOPLIST_FILE = SessionUIKit/Meta/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5668,7 +5728,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = SignalUtilitiesKit/Meta/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5755,7 +5815,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; INFOPLIST_FILE = SignalUtilitiesKit/Meta/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5815,7 +5875,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = SessionSnodeKit/Meta/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5894,7 +5954,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; INFOPLIST_FILE = SessionSnodeKit/Meta/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5954,7 +6014,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = SessionUtilitiesKit/Meta/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -6042,7 +6102,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; INFOPLIST_FILE = SessionUtilitiesKit/Meta/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -6111,7 +6171,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = SessionMessagingKit/Meta/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -6190,7 +6250,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; INFOPLIST_FILE = SessionMessagingKit/Meta/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -6407,7 +6467,7 @@ "\"$(SRCROOT)/Libraries\"/**", ); INFOPLIST_FILE = "Session/Meta/Session-Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -6478,7 +6538,7 @@ "\"$(SRCROOT)/Libraries\"/**", ); INFOPLIST_FILE = "Session/Meta/Session-Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 886352a59..2482b4242 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -1,6 +1,9 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit import SessionUIKit import SessionMessagingKit -import UIKit +import SessionUtilitiesKit // TODO: // • Slight paging glitch when scrolling up and loading more content @@ -69,13 +72,12 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat } lazy var mnemonic: String = { - let identityManager = OWSIdentityManager.shared() - let databaseConnection = identityManager.value(forKey: "dbConnection") as! YapDatabaseConnection - var hexEncodedSeed: String! = databaseConnection.object(forKey: "LKLokiSeed", inCollection: OWSPrimaryStorageIdentityKeyStoreCollection) as! String? - if hexEncodedSeed == nil { - hexEncodedSeed = identityManager.identityKeyPair()!.hexEncodedPrivateKey // Legacy account + if let hexEncodedSeed: String = Identity.fetchHexEncodedSeed() { + return Mnemonic.encode(hexEncodedString: hexEncodedSeed) } - return Mnemonic.encode(hexEncodedString: hexEncodedSeed) + + // Legacy account + return Mnemonic.encode(hexEncodedString: Identity.fetchUserKeyPair()!.hexEncodedPrivateKey) }() lazy var viewModel = ConversationViewModel(thread: thread, focusMessageIdOnOpen: nil, delegate: self) diff --git a/Session/Conversations/ConversationViewItem.m b/Session/Conversations/ConversationViewItem.m index 4ebdd168a..5be802798 100644 --- a/Session/Conversations/ConversationViewItem.m +++ b/Session/Conversations/ConversationViewItem.m @@ -1005,7 +1005,7 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) // If it's an incoming message the user must have moderator status if (self.interaction.interactionType == OWSInteractionType_IncomingMessage) { - NSString *userPublicKey = [LKStorage.shared getUserPublicKey]; + NSString *userPublicKey = [SNGeneralUtilities getUserPublicKey]; if (![SNOpenGroupAPIV2 isUserModerator:userPublicKey forRoom:openGroupV2.room onServer:openGroupV2.server]) { return; } } @@ -1058,7 +1058,7 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) // If it's an incoming message the user must have moderator status if (self.interaction.interactionType == OWSInteractionType_IncomingMessage) { - NSString *userPublicKey = [LKStorage.shared getUserPublicKey]; + NSString *userPublicKey = [SNGeneralUtilities getUserPublicKey]; if (openGroupV2 != nil) { if (![SNOpenGroupAPIV2 isUserModerator:userPublicKey forRoom:openGroupV2.room onServer:openGroupV2.server]) { return; } } diff --git a/Session/Conversations/Settings/OWSConversationSettingsViewController.m b/Session/Conversations/Settings/OWSConversationSettingsViewController.m index 60244cc88..7d5112ab3 100644 --- a/Session/Conversations/Settings/OWSConversationSettingsViewController.m +++ b/Session/Conversations/Settings/OWSConversationSettingsViewController.m @@ -114,10 +114,6 @@ CGFloat kIconViewLength = 24; - (void)observeNotifications { - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(identityStateDidChange:) - name:kNSNotificationName_IdentityStateDidChange - object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(otherUsersProfileDidChange:) name:kNSNotificationName_OtherUsersProfileDidChange @@ -1069,13 +1065,6 @@ CGFloat kIconViewLength = 24; #pragma mark - Notifications -- (void)identityStateDidChange:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - - [self updateTableContents]; -} - - (void)otherUsersProfileDidChange:(NSNotification *)notification { OWSAssertIsOnMainThread(); diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 1cba65a15..629e83f37 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -1,3 +1,8 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SessionMessagingKit +import SessionUtilitiesKit // See https://github.com/yapstudios/YapDatabase/wiki/LongLivedReadTransactions and // https://github.com/yapstudios/YapDatabase/wiki/YapDatabaseModifiedNotification for @@ -162,7 +167,7 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv self.threads.update(with: transaction) // Perform the initial update } // Start polling if needed (i.e. if the user just created or restored their Session ID) - if OWSIdentityManager.shared().identityKeyPair() != nil { + if Identity.fetchUserKeyPair() != nil { let appDelegate = UIApplication.shared.delegate as! AppDelegate appDelegate.startPollerIfNeeded() appDelegate.startClosedGroupPoller() diff --git a/Session/Meta/AppDelegate.m b/Session/Meta/AppDelegate.m index 67357b6eb..965c4c299 100644 --- a/Session/Meta/AppDelegate.m +++ b/Session/Meta/AppDelegate.m @@ -748,7 +748,7 @@ static NSTimeInterval launchStartedAt; [[LKPushNotificationAPI unregisterToken:deviceToken] retainUntilComplete]; } [ThreadUtil deleteAllContent]; - [SSKEnvironment.shared.identityManager clearIdentityKey]; + [SUKIdentity clearUserKeyPair]; [SNSnodeAPI clearSnodePool]; [self stopPoller]; [self stopClosedGroupPoller]; diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index ce3c93df8..b0719086a 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -1,5 +1,9 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation import PromiseKit import SessionMessagingKit +import SessionUtilitiesKit extension AppDelegate { @@ -22,7 +26,8 @@ extension AppDelegate { } @objc func startClosedGroupPoller() { - guard OWSIdentityManager.shared().identityKeyPair() != nil else { return } + guard Identity.fetchUserKeyPair() != nil else { return } + ClosedGroupPoller.shared.start() } diff --git a/Session/Meta/MainAppContext.m b/Session/Meta/MainAppContext.m index 2fad58d41..b5e82f87d 100644 --- a/Session/Meta/MainAppContext.m +++ b/Session/Meta/MainAppContext.m @@ -8,7 +8,6 @@ #import #import #import -#import NS_ASSUME_NONNULL_BEGIN diff --git a/Session/Meta/Signal-Bridging-Header.h b/Session/Meta/Signal-Bridging-Header.h index 1b5f636b7..a3b196317 100644 --- a/Session/Meta/Signal-Bridging-Header.h +++ b/Session/Meta/Signal-Bridging-Header.h @@ -63,7 +63,6 @@ #import #import #import -#import #import #import #import diff --git a/Session/Notifications/AppNotifications.swift b/Session/Notifications/AppNotifications.swift index 1c68a4d06..df3b6a0ef 100644 --- a/Session/Notifications/AppNotifications.swift +++ b/Session/Notifications/AppNotifications.swift @@ -121,10 +121,6 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { // MARK: - Dependencies - var identityManager: OWSIdentityManager { - return OWSIdentityManager.shared() - } - var preferences: OWSPreferences { return Environment.shared.preferences } diff --git a/Session/Onboarding/LandingVC.swift b/Session/Onboarding/LandingVC.swift index ab79a8fbc..f3bb8c7a2 100644 --- a/Session/Onboarding/LandingVC.swift +++ b/Session/Onboarding/LandingVC.swift @@ -1,5 +1,9 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -final class LandingVC : BaseVC { +import UIKit +import SessionUIKit + +final class LandingVC: BaseVC { // MARK: Components private lazy var fakeChatView: FakeChatView = { diff --git a/Session/Onboarding/LinkDeviceVC.swift b/Session/Onboarding/LinkDeviceVC.swift index e2defa126..9c5edaeae 100644 --- a/Session/Onboarding/LinkDeviceVC.swift +++ b/Session/Onboarding/LinkDeviceVC.swift @@ -1,4 +1,8 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit import PromiseKit +import SessionUtilitiesKit final class LinkDeviceVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControllerDelegate, OWSQRScannerDelegate { private let pageVC = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil) @@ -130,7 +134,7 @@ final class LinkDeviceVC : BaseVC, UIPageViewControllerDataSource, UIPageViewCon presentAlert(alert) return } - let (ed25519KeyPair, x25519KeyPair) = KeyPairUtilities.generate(from: seed) + let (ed25519KeyPair, x25519KeyPair) = try! Identity.generate(from: seed) Onboarding.Flow.link.preregister(with: seed, ed25519KeyPair: ed25519KeyPair, x25519KeyPair: x25519KeyPair) TSAccountManager.sharedInstance().didRegister() NotificationCenter.default.addObserver(self, selector: #selector(handleInitialConfigurationMessageReceived), name: .initialConfigurationMessageReceived, object: nil) @@ -140,7 +144,8 @@ final class LinkDeviceVC : BaseVC, UIPageViewControllerDataSource, UIPageViewCon } @objc private func handleInitialConfigurationMessageReceived(_ notification: Notification) { - TSAccountManager.sharedInstance().phoneNumberAwaitingVerification = OWSIdentityManager.shared().identityKeyPair()!.hexEncodedPublicKey + TSAccountManager.sharedInstance().phoneNumberAwaitingVerification = getUserHexEncodedPublicKey() + DispatchQueue.main.async { self.navigationController!.dismiss(animated: true) { let pnModeVC = PNModeVC() diff --git a/Session/Onboarding/Onboarding.swift b/Session/Onboarding/Onboarding.swift index dc89f53b5..00a373e02 100644 --- a/Session/Onboarding/Onboarding.swift +++ b/Session/Onboarding/Onboarding.swift @@ -1,4 +1,8 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation import Sodium +import SessionUtilitiesKit enum Onboarding { @@ -7,7 +11,7 @@ enum Onboarding { func preregister(with seed: Data, ed25519KeyPair: Sign.KeyPair, x25519KeyPair: ECKeyPair) { let userDefaults = UserDefaults.standard - KeyPairUtilities.store(seed: seed, ed25519KeyPair: ed25519KeyPair, x25519KeyPair: x25519KeyPair) + Identity.store(seed: seed, ed25519KeyPair: ed25519KeyPair, x25519KeyPair: x25519KeyPair) let x25519PublicKey = x25519KeyPair.hexEncodedPublicKey TSAccountManager.sharedInstance().phoneNumberAwaitingVerification = x25519PublicKey Storage.writeSync { transaction in @@ -16,25 +20,31 @@ enum Onboarding { user.didApproveMe = true Storage.shared.setContact(user, using: transaction) } + switch self { - case .register: - userDefaults[.hasViewedSeed] = false - // Set hasSyncedInitialConfiguration to true so that when we hit the home screen a configuration sync - // is triggered (yes, the logic is a bit weird). This is needed so that if the user registers and - // immediately links a device, there'll be a configuration in their swarm. - userDefaults[.hasSyncedInitialConfiguration] = true - case .recover, .link: - userDefaults[.hasViewedSeed] = true // No need to show it again if the user is restoring or linking - userDefaults[.hasSyncedInitialConfiguration] = false + case .register: + userDefaults[.hasViewedSeed] = false + // Set hasSyncedInitialConfiguration to true so that when we hit the + // home screen a configuration sync is triggered (yes, the logic is a + // bit weird). This is needed so that if the user registers and + // immediately links a device, there'll be a configuration in their swarm. + userDefaults[.hasSyncedInitialConfiguration] = true + + case .recover, .link: + // No need to show it again if the user is restoring or linking + userDefaults[.hasViewedSeed] = true + userDefaults[.hasSyncedInitialConfiguration] = false } + switch self { - case .register, .recover: - // Set both lastDisplayNameUpdate and lastProfilePictureUpdate to the current date, so that - // we don't overwrite what the user set in the display name step with whatever we find in - // their swarm. - userDefaults[.lastDisplayNameUpdate] = Date() - userDefaults[.lastProfilePictureUpdate] = Date() - case .link: break + case .register, .recover: + // Set both lastDisplayNameUpdate and lastProfilePictureUpdate to the + // current date, so that we don't overwrite what the user set in the + // display name step with whatever we find in their swarm. + userDefaults[.lastDisplayNameUpdate] = Date() + userDefaults[.lastProfilePictureUpdate] = Date() + + case .link: break } } } diff --git a/Session/Onboarding/RegisterVC.swift b/Session/Onboarding/RegisterVC.swift index 3aed52229..0bb1f5945 100644 --- a/Session/Onboarding/RegisterVC.swift +++ b/Session/Onboarding/RegisterVC.swift @@ -136,7 +136,7 @@ final class RegisterVC : BaseVC { } private func updateKeyPair() { - (ed25519KeyPair, x25519KeyPair) = KeyPairUtilities.generate(from: seed) + (ed25519KeyPair, x25519KeyPair) = try! Identity.generate(from: seed) } private func updatePublicKeyLabel() { diff --git a/Session/Onboarding/RestoreVC.swift b/Session/Onboarding/RestoreVC.swift index ad4dbc4ff..762b1e5c1 100644 --- a/Session/Onboarding/RestoreVC.swift +++ b/Session/Onboarding/RestoreVC.swift @@ -1,5 +1,9 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -final class RestoreVC : BaseVC { +import UIKit +import SessionUtilitiesKit + +final class RestoreVC: BaseVC { private var spacer1HeightConstraint: NSLayoutConstraint! private var spacer2HeightConstraint: NSLayoutConstraint! private var spacer3HeightConstraint: NSLayoutConstraint! @@ -164,7 +168,7 @@ final class RestoreVC : BaseVC { do { let hexEncodedSeed = try Mnemonic.decode(mnemonic: mnemonic) let seed = Data(hex: hexEncodedSeed) - let (ed25519KeyPair, x25519KeyPair) = KeyPairUtilities.generate(from: seed) + let (ed25519KeyPair, x25519KeyPair) = try! Identity.generate(from: seed) Onboarding.Flow.recover.preregister(with: seed, ed25519KeyPair: ed25519KeyPair, x25519KeyPair: x25519KeyPair) mnemonicTextView.resignFirstResponder() Timer.scheduledTimer(withTimeInterval: 0.25, repeats: false) { _ in diff --git a/Session/Onboarding/SeedVC.swift b/Session/Onboarding/SeedVC.swift index a80fed60d..cc993691e 100644 --- a/Session/Onboarding/SeedVC.swift +++ b/Session/Onboarding/SeedVC.swift @@ -1,14 +1,16 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -final class SeedVC : BaseVC { - +import UIKit +import SessionUtilitiesKit + +final class SeedVC: BaseVC { private let mnemonic: String = { - let identityManager = OWSIdentityManager.shared() - let databaseConnection = identityManager.value(forKey: "dbConnection") as! YapDatabaseConnection - var hexEncodedSeed: String! = databaseConnection.object(forKey: "LKLokiSeed", inCollection: OWSPrimaryStorageIdentityKeyStoreCollection) as! String? - if hexEncodedSeed == nil { - hexEncodedSeed = identityManager.identityKeyPair()!.hexEncodedPrivateKey // Legacy account + if let hexEncodedSeed: String = Identity.fetchHexEncodedSeed() { + return Mnemonic.encode(hexEncodedString: hexEncodedSeed) } - return Mnemonic.encode(hexEncodedString: hexEncodedSeed) + + // Legacy account + return Mnemonic.encode(hexEncodedString: Identity.fetchUserKeyPair()!.hexEncodedPrivateKey) }() private lazy var redactedMnemonic: String = { diff --git a/Session/Path/PathStatusView.swift b/Session/Path/PathStatusView.swift index 18ba2dafb..f3dec6a7e 100644 --- a/Session/Path/PathStatusView.swift +++ b/Session/Path/PathStatusView.swift @@ -19,10 +19,7 @@ final class PathStatusView : UIView { private func setUpViewHierarchy() { layer.cornerRadius = PathStatusView.size / 2 layer.masksToBounds = false - if OnionRequestAPI.paths.isEmpty { - OnionRequestAPI.paths = Storage.shared.getOnionRequestPaths() - } - let color = (!OnionRequestAPI.paths.isEmpty) ? Colors.accent : Colors.pathsBuilding + let color = (!OnionRequestAPI.paths.isEmpty ? Colors.accent : Colors.pathsBuilding) setColor(to: color, isAnimated: false) } diff --git a/Session/Path/PathVC.swift b/Session/Path/PathVC.swift index 649501e6f..1e84d1b34 100644 --- a/Session/Path/PathVC.swift +++ b/Session/Path/PathVC.swift @@ -106,26 +106,49 @@ final class PathVC : BaseVC { private func update() { pathStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } - if !OnionRequestAPI.paths.isEmpty { - let pathToDisplay = OnionRequestAPI.paths.first! - let dotAnimationRepeatInterval = Double(pathToDisplay.count) + 2 - let snodeRows: [UIStackView] = pathToDisplay.enumerated().map { index, snode in - let isGuardSnode = (snode == pathToDisplay.first!) - return getPathRow(snode: snode, location: .middle, dotAnimationStartDelay: Double(index) + 2, dotAnimationRepeatInterval: dotAnimationRepeatInterval, isGuardSnode: isGuardSnode) - } - let youRow = getPathRow(title: NSLocalizedString("vc_path_device_row_title", comment: ""), subtitle: nil, location: .top, dotAnimationStartDelay: 1, dotAnimationRepeatInterval: dotAnimationRepeatInterval) - let destinationRow = getPathRow(title: NSLocalizedString("vc_path_destination_row_title", comment: ""), subtitle: nil, location: .bottom, dotAnimationStartDelay: Double(pathToDisplay.count) + 2, dotAnimationRepeatInterval: dotAnimationRepeatInterval) - let rows = [ youRow ] + snodeRows + [ destinationRow ] - rows.forEach { pathStackView.addArrangedSubview($0) } - spinner.stopAnimating() - UIView.animate(withDuration: 0.25) { - self.spinner.alpha = 0 - } - } else { + + guard let pathToDisplay: [Snode] = OnionRequestAPI.paths.first else { spinner.startAnimating() + UIView.animate(withDuration: 0.25) { self.spinner.alpha = 1 } + return + } + + let dotAnimationRepeatInterval = Double(pathToDisplay.count) + 2 + let snodeRows: [UIStackView] = pathToDisplay.enumerated().map { index, snode in + let isGuardSnode = (snode == pathToDisplay.first) + + return getPathRow( + snode: snode, + location: .middle, + dotAnimationStartDelay: Double(index) + 2, + dotAnimationRepeatInterval: dotAnimationRepeatInterval, + isGuardSnode: isGuardSnode + ) + } + + let youRow = getPathRow( + title: NSLocalizedString("vc_path_device_row_title", comment: ""), + subtitle: nil, + location: .top, + dotAnimationStartDelay: 1, + dotAnimationRepeatInterval: dotAnimationRepeatInterval + ) + let destinationRow = getPathRow( + title: NSLocalizedString("vc_path_destination_row_title", comment: ""), + subtitle: nil, + location: .bottom, + dotAnimationStartDelay: Double(pathToDisplay.count) + 2, + dotAnimationRepeatInterval: dotAnimationRepeatInterval + ) + let rows = [ youRow ] + snodeRows + [ destinationRow ] + rows.forEach { pathStackView.addArrangedSubview($0) } + spinner.stopAnimating() + + UIView.animate(withDuration: 0.25) { + self.spinner.alpha = 0 } } @@ -156,7 +179,7 @@ final class PathVC : BaseVC { return stackView } - private func getPathRow(snode: Legacy.Snode, location: LineView.Location, dotAnimationStartDelay: Double, dotAnimationRepeatInterval: Double, isGuardSnode: Bool) -> UIStackView { + private func getPathRow(snode: Snode, location: LineView.Location, dotAnimationStartDelay: Double, dotAnimationRepeatInterval: Double, isGuardSnode: Bool) -> UIStackView { let country = IP2Country.isInitialized ? (IP2Country.shared.countryNamesCache[snode.ip] ?? "Resolving...") : "Resolving..." let title = isGuardSnode ? NSLocalizedString("vc_path_guard_node_row_title", comment: "") : NSLocalizedString("vc_path_service_node_row_title", comment: "") return getPathRow(title: title, subtitle: country, location: location, dotAnimationStartDelay: dotAnimationStartDelay, dotAnimationRepeatInterval: dotAnimationRepeatInterval) diff --git a/Session/Settings/SeedModal.swift b/Session/Settings/SeedModal.swift index 11528450b..28e448c25 100644 --- a/Session/Settings/SeedModal.swift +++ b/Session/Settings/SeedModal.swift @@ -1,15 +1,18 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SessionUtilitiesKit @objc(LKSeedModal) -final class SeedModal : Modal { - +final class SeedModal: Modal { + private let mnemonic: String = { - let identityManager = OWSIdentityManager.shared() - let databaseConnection = identityManager.value(forKey: "dbConnection") as! YapDatabaseConnection - var hexEncodedSeed: String! = databaseConnection.object(forKey: "LKLokiSeed", inCollection: OWSPrimaryStorageIdentityKeyStoreCollection) as! String? - if hexEncodedSeed == nil { - hexEncodedSeed = identityManager.identityKeyPair()!.hexEncodedPrivateKey // Legacy account + if let hexEncodedSeed: String = Identity.fetchHexEncodedSeed() { + return Mnemonic.encode(hexEncodedString: hexEncodedSeed) } - return Mnemonic.encode(hexEncodedString: hexEncodedSeed) + + // Legacy account + return Mnemonic.encode(hexEncodedString: Identity.fetchUserKeyPair()!.hexEncodedPrivateKey) }() // MARK: Lifecycle diff --git a/Session/Utilities/BackgroundPoller.swift b/Session/Utilities/BackgroundPoller.swift index ff31d0965..ff572ac87 100644 --- a/Session/Utilities/BackgroundPoller.swift +++ b/Session/Utilities/BackgroundPoller.swift @@ -42,18 +42,29 @@ public final class BackgroundPoller : NSObject { guard let snode = swarm.randomElement() else { throw SnodeAPI.Error.generic } return attempt(maxRetryCount: 4, recoveringOn: DispatchQueue.main) { return SnodeAPI.getRawMessages(from: snode, associatedWith: publicKey).then(on: DispatchQueue.main) { rawResponse -> Promise in - let (messages, lastRawMessage) = SnodeAPI.parseRawMessagesResponse(rawResponse, from: snode, associatedWith: publicKey) - let promises = messages.compactMap { json -> Promise? in - // Use a best attempt approach here; we don't want to fail the entire process if one of the - // messages failed to parse. - guard let envelope = SNProtoEnvelope.from(json), - let data = try? envelope.serializedData() else { return nil } - let job = MessageReceiveJob(data: data, serverHash: json["hash"] as? String, isBackgroundPoll: true) + let messages: [SnodeReceivedMessage] = SnodeAPI.parseRawMessagesResponse(rawResponse, from: snode, associatedWith: publicKey) + let promises = messages.compactMap { message -> Promise? in + // Use a best attempt approach here; we don't want to fail the entire process + // if one of the messages failed to parse + guard + let envelope = SNProtoEnvelope.from(message), + let data = try? envelope.serializedData() + else { return nil } + + let job = MessageReceiveJob( + data: data, + serverHash: message.info.hash, + isBackgroundPoll: true + ) return job.execute() } - // Now that the MessageReceiveJob's have been created we can update the `lastMessageHash` value - SnodeAPI.updateLastMessageHashValueIfPossible(for: snode, associatedWith: publicKey, from: lastRawMessage) + // Now that the MessageReceiveJob's have been created we can persist the received messages + if !messages.isEmpty { + GRDBStorage.shared.write { db in + messages.forEach { try? $0.info.save(db) } + } + } return when(fulfilled: promises) // The promise returned by MessageReceiveJob never rejects } diff --git a/Session/Utilities/IP2Country.swift b/Session/Utilities/IP2Country.swift index 3f34f7040..052331a3d 100644 --- a/Session/Utilities/IP2Country.swift +++ b/Session/Utilities/IP2Country.swift @@ -1,3 +1,6 @@ +import Foundation +import GRDB +import SessionSnodeKit final class IP2Country { var countryNamesCache: [String:String] = [:] @@ -53,12 +56,8 @@ final class IP2Country { } func populateCacheIfNeeded() -> Bool { - if OnionRequestAPI.paths.isEmpty { - OnionRequestAPI.paths = Storage.shared.getOnionRequestPaths() - } - let paths = OnionRequestAPI.paths - guard !paths.isEmpty else { return false } - let pathToDisplay = paths.first! + guard let pathToDisplay: [Snode] = OnionRequestAPI.paths.first else { return false } + pathToDisplay.forEach { snode in let _ = self.cacheCountry(for: snode.ip) // Preload if needed } diff --git a/Session/Utilities/KeyPairUtilities.swift b/Session/Utilities/KeyPairUtilities.swift deleted file mode 100644 index 8a0984808..000000000 --- a/Session/Utilities/KeyPairUtilities.swift +++ /dev/null @@ -1,28 +0,0 @@ -import Sodium - -enum KeyPairUtilities { - - static func generate(from seed: Data) -> (ed25519KeyPair: Sign.KeyPair, x25519KeyPair: ECKeyPair) { - assert(seed.count == 16) - let padding = Data(repeating: 0, count: 16) - let ed25519KeyPair = Sodium().sign.keyPair(seed: (seed + padding).bytes)! - let x25519PublicKey = Sodium().sign.toX25519(ed25519PublicKey: ed25519KeyPair.publicKey)! - let x25519SecretKey = Sodium().sign.toX25519(ed25519SecretKey: ed25519KeyPair.secretKey)! - let x25519KeyPair = try! ECKeyPair(publicKeyData: Data(x25519PublicKey), privateKeyData: Data(x25519SecretKey)) - return (ed25519KeyPair: ed25519KeyPair, x25519KeyPair: x25519KeyPair) - } - - static func store(seed: Data, ed25519KeyPair: Sign.KeyPair, x25519KeyPair: ECKeyPair) { - let dbConnection = OWSIdentityManager.shared().dbConnection - let collection = OWSPrimaryStorageIdentityKeyStoreCollection - dbConnection.setObject(seed.toHexString(), forKey: LKSeedKey, inCollection: collection) - dbConnection.setObject(ed25519KeyPair.secretKey.toHexString(), forKey: LKED25519SecretKey, inCollection: collection) - dbConnection.setObject(ed25519KeyPair.publicKey.toHexString(), forKey: LKED25519PublicKey, inCollection: collection) - dbConnection.setObject(x25519KeyPair, forKey: OWSPrimaryStorageIdentityKeyStoreIdentityKey, inCollection: collection) - } - - static func hasV2KeyPair() -> Bool { - let dbConnection = OWSIdentityManager.shared().dbConnection - return (dbConnection.object(forKey: LKED25519SecretKey, inCollection: OWSPrimaryStorageIdentityKeyStoreCollection) != nil) - } -} diff --git a/Session/Utilities/MockDataGenerator.swift b/Session/Utilities/MockDataGenerator.swift index 5a559b6df..8313d807a 100644 --- a/Session/Utilities/MockDataGenerator.swift +++ b/Session/Utilities/MockDataGenerator.swift @@ -106,7 +106,7 @@ enum MockDataGenerator { (0.. String? { - return OWSIdentityManager.shared().identityKeyPair()?.hexEncodedPublicKey - } - - public func getUserKeyPair() -> ECKeyPair? { - return OWSIdentityManager.shared().identityKeyPair() - } - - public func getUserED25519KeyPair() -> Box.KeyPair? { - let dbConnection = OWSIdentityManager.shared().dbConnection - let collection = OWSPrimaryStorageIdentityKeyStoreCollection - guard let hexEncodedPublicKey = dbConnection.object(forKey: LKED25519PublicKey, inCollection: collection) as? String, - let hexEncodedSecretKey = dbConnection.object(forKey: LKED25519SecretKey, inCollection: collection) as? String else { return nil } - let publicKey = Box.KeyPair.PublicKey(hex: hexEncodedPublicKey) - let secretKey = Box.KeyPair.SecretKey(hex: hexEncodedSecretKey) - return Box.KeyPair(publicKey: publicKey, secretKey: secretKey) - } - @objc public func getUser() -> Contact? { return getUser(using: nil) } diff --git a/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift b/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift index ce25b18f0..f529e5f3d 100644 --- a/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift @@ -1,3 +1,7 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Curve25519Kit import SessionUtilitiesKit public final class ClosedGroupControlMessage : ControlMessage { diff --git a/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage.swift b/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage.swift index 00eea0aa0..af4b26ffa 100644 --- a/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage.swift @@ -1,3 +1,7 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Curve25519Kit import SessionUtilitiesKit @objc(SNConfigurationMessage) diff --git a/SessionMessagingKit/Meta/SessionMessagingKit.h b/SessionMessagingKit/Meta/SessionMessagingKit.h index da0a86ab6..59bf8c918 100644 --- a/SessionMessagingKit/Meta/SessionMessagingKit.h +++ b/SessionMessagingKit/Meta/SessionMessagingKit.h @@ -14,7 +14,6 @@ FOUNDATION_EXPORT const unsigned char SessionMessagingKitVersionString[]; #import #import #import -#import #import #import #import diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift b/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift index d8206b604..b4bf3d196 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift @@ -1,10 +1,15 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation import PromiseKit +import Curve25519Kit import SessionSnodeKit +import SessionUtilitiesKit @objc(SNOpenGroupAPIV2) public final class OpenGroupAPIV2 : NSObject { - private static var authTokenPromises: [String:Promise] = [:] - private static var hasPerformedInitialPoll: [String:Bool] = [:] + private static var authTokenPromises: [String: Promise] = [:] + private static var hasPerformedInitialPoll: [String: Bool] = [:] private static var hasUpdatedLastOpenDate = false public static let workQueue = DispatchQueue(label: "OpenGroupAPIV2.workQueue", qos: .userInitiated) // It's important that this is a serial queue public static var moderators: [String:[String:Set]] = [:] // Server URL to room ID to set of moderator IDs @@ -237,7 +242,7 @@ public final class OpenGroupAPIV2 : NSObject { public static func requestNewAuthToken(for room: String, on server: String) -> Promise { SNLog("Requesting auth token for server: \(server).") - guard let userKeyPair = SNMessagingKitConfiguration.shared.storage.getUserKeyPair() else { return Promise(error: Error.generic) } + guard let userKeyPair = Identity.fetchUserKeyPair() else { return Promise(error: Error.generic) } let queryParameters = [ "public_key" : getUserHexEncodedPublicKey() ] let request = Request(verb: .get, room: room, server: server, endpoint: "auth_token_challenge", queryParameters: queryParameters, isAuthRequired: false) return send(request).map(on: OpenGroupAPIV2.workQueue) { json in diff --git a/SessionMessagingKit/Open Groups/OpenGroupMessageV2.swift b/SessionMessagingKit/Open Groups/OpenGroupMessageV2.swift index bc82ad7ce..3a3cb7d3a 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupMessageV2.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupMessageV2.swift @@ -1,3 +1,8 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Curve25519Kit +import SessionUtilitiesKit public struct OpenGroupMessageV2 { public let serverID: Int64? @@ -10,8 +15,11 @@ public struct OpenGroupMessageV2 { public let base64EncodedSignature: String? public func sign() -> OpenGroupMessageV2? { - let userKeyPair = SNMessagingKitConfiguration.shared.storage.getUserKeyPair()! - let data = Data(base64Encoded: base64EncodedData)! + guard + let userKeyPair = Identity.fetchUserKeyPair(), + let data: Data = Data(base64Encoded: base64EncodedData) + else { return nil } + guard let signature = try? Ed25519.sign(data, with: userKeyPair) else { SNLog("Failed to sign open group message.") return nil diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift index 20846e9d5..0e84e01e2 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift @@ -1,6 +1,10 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation import CryptoSwift -import SessionUtilitiesKit import Sodium +import Curve25519Kit +import SessionUtilitiesKit extension MessageReceiver { diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index 3bbec95a7..719400d6e 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -1,3 +1,7 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Curve25519Kit import SignalCoreKit import SessionSnodeKit @@ -556,7 +560,7 @@ extension MessageReceiver { let groupPublicKey = explicitGroupPublicKey?.toHexString() ?? message.groupPublicKey else { return } let transaction = transaction as! YapDatabaseReadWriteTransaction let userPublicKey = getUserHexEncodedPublicKey() - guard let userKeyPair = SNMessagingKitConfiguration.shared.storage.getUserKeyPair() else { + guard let userKeyPair = Identity.fetchUserKeyPair() else { return SNLog("Couldn't find user X25519 key pair.") } let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey) diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index 654b644b9..ef96124f0 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -1,7 +1,10 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation import SessionUtilitiesKit public enum MessageReceiver { - private static var lastEncryptionKeyPairRequest: [String:Date] = [:] + private static var lastEncryptionKeyPairRequest: [String: Date] = [:] public enum Error : LocalizedError { case duplicateMessage @@ -49,7 +52,7 @@ public enum MessageReceiver { } public static func parse(_ data: Data, openGroupMessageServerID: UInt64?, isRetry: Bool = false, using transaction: Any) throws -> (Message, SNProtoContent) { - let userPublicKey = SNMessagingKitConfiguration.shared.storage.getUserPublicKey() + let userPublicKey = getUserHexEncodedPublicKey() let isOpenGroupMessage = (openGroupMessageServerID != nil) // Parse the envelope let envelope = try SNProtoEnvelope.parseData(data) @@ -64,7 +67,7 @@ public enum MessageReceiver { } else { switch envelope.type { case .sessionMessage: - guard let userX25519KeyPair = SNMessagingKitConfiguration.shared.storage.getUserKeyPair() else { throw Error.noUserX25519KeyPair } + guard let userX25519KeyPair = Identity.fetchUserKeyPair() else { throw Error.noUserX25519KeyPair } (plaintext, sender) = try decryptWithSessionProtocol(ciphertext: ciphertext, using: userX25519KeyPair) case .closedGroupMessage: guard let hexEncodedGroupPublicKey = envelope.source, SNMessagingKitConfiguration.shared.storage.isClosedGroup(hexEncodedGroupPublicKey) else { throw Error.invalidGroupPublicKey } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift index 8b75e2f44..7f335b4ef 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift @@ -1,7 +1,11 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Curve25519Kit import PromiseKit extension MessageSender { - public static var distributingClosedGroupEncryptionKeyPairs: [String:[ECKeyPair]] = [:] + public static var distributingClosedGroupEncryptionKeyPairs: [String: [ECKeyPair]] = [:] public static func createClosedGroup(name: String, members: Set, transaction: YapDatabaseReadWriteTransaction) -> Promise { // Prepare diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift index 6fee9e1c9..5c5d70821 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift @@ -1,10 +1,13 @@ -import SessionUtilitiesKit +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation import Sodium +import SessionUtilitiesKit extension MessageSender { internal static func encryptWithSessionProtocol(_ plaintext: Data, for recipientHexEncodedX25519PublicKey: String) throws -> Data { - guard let userED25519KeyPair = SNMessagingKitConfiguration.shared.storage.getUserED25519KeyPair() else { throw Error.noUserED25519KeyPair } + guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { throw Error.noUserED25519KeyPair } let recipientX25519PublicKey = Data(hex: recipientHexEncodedX25519PublicKey.removing05PrefixIfNeeded()) let sodium = Sodium() diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index c02ef9088..f111b632f 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -108,7 +108,7 @@ public final class MessageSender : NSObject { let (promise, seal) = Promise.pending() let storage = SNMessagingKitConfiguration.shared.storage let transaction = transaction as! YapDatabaseReadWriteTransaction - let userPublicKey = storage.getUserPublicKey() + let userPublicKey = getUserHexEncodedPublicKey() var isMainAppAndActive = false if let sharedUserDefaults = UserDefaults(suiteName: "group.com.loki-project.loki-messenger") { isMainAppAndActive = sharedUserDefaults.bool(forKey: "isMainAppActive") @@ -267,7 +267,7 @@ public final class MessageSender : NSObject { if message.sentTimestamp == nil { // Visible messages will already have their sent timestamp set message.sentTimestamp = NSDate.millisecondTimestamp() } - message.sender = storage.getUserPublicKey() + message.sender = getUserHexEncodedPublicKey() switch destination { case .contact(_): preconditionFailure() case .closedGroup(_): preconditionFailure() diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift index 24ecdb2f8..1ce54f4d8 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift @@ -100,26 +100,29 @@ public final class ClosedGroupPoller : NSObject { private func poll(_ groupPublicKey: String) -> Promise { guard isPolling(for: groupPublicKey) else { return Promise.value(()) } - let promise = SnodeAPI.getSwarm(for: groupPublicKey).then2 { [weak self] swarm -> Promise<(SessionSnodeKit.Legacy.Snode, [JSON], JSON?)> in + let promise = SnodeAPI.getSwarm(for: groupPublicKey).then2 { [weak self] swarm -> Promise<(Snode, [SnodeReceivedMessage])> in // randomElement() uses the system's default random generator, which is cryptographically secure guard let snode = swarm.randomElement() else { return Promise(error: Error.insufficientSnodes) } - guard let self = self, self.isPolling(for: groupPublicKey) else { return Promise(error: Error.pollingCanceled) } + guard let self = self, self.isPolling(for: groupPublicKey) else { + return Promise(error: Error.pollingCanceled) + } + return SnodeAPI.getRawMessages(from: snode, associatedWith: groupPublicKey).map2 { - let (rawMessages, lastRawMessage) = SnodeAPI.parseRawMessagesResponse($0, from: snode, associatedWith: groupPublicKey) + let messages: [SnodeReceivedMessage] = SnodeAPI.parseRawMessagesResponse($0, from: snode, associatedWith: groupPublicKey) - return (snode, rawMessages, lastRawMessage) + return (snode, messages) } } - promise.done2 { [weak self] snode, rawMessages, lastRawMessage in + promise.done2 { [weak self] snode, messages in guard let self = self, self.isPolling(for: groupPublicKey) else { return } - if !rawMessages.isEmpty { - SNLog("Received \(rawMessages.count) new message(s) in closed group with public key: \(groupPublicKey).") + if !messages.isEmpty { + SNLog("Received \(messages.count) new message(s) in closed group with public key: \(groupPublicKey).") } - rawMessages.forEach { json in - guard let envelope = SNProtoEnvelope.from(json) else { return } + messages.forEach { message in + guard let envelope = SNProtoEnvelope.from(message) else { return } do { let data = try envelope.serializedData() - let job = MessageReceiveJob(data: data, serverHash: json["hash"] as? String, isBackgroundPoll: false) + let job = MessageReceiveJob(data: data, serverHash: message.info.hash, isBackgroundPoll: false) SNMessagingKitConfiguration.shared.storage.write { transaction in SessionMessagingKit.JobQueue.shared.add(job, using: transaction) } @@ -128,8 +131,12 @@ public final class ClosedGroupPoller : NSObject { } } - // Now that the MessageReceiveJob's have been created we can update the `lastMessageHash` value - SnodeAPI.updateLastMessageHashValueIfPossible(for: snode, associatedWith: groupPublicKey, from: lastRawMessage) + // Now that the MessageReceiveJob's have been created we can persist the received messages + if !messages.isEmpty { + GRDBStorage.shared.write { db in + messages.forEach { try? $0.info.save(db) } + } + } } promise.catch2 { error in SNLog("Polling failed for closed group with public key: \(groupPublicKey) due to error: \(error).") diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift index c61bc3b71..db0dde51d 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift @@ -5,7 +5,7 @@ import PromiseKit public final class Poller : NSObject { private let storage = OWSPrimaryStorage.shared() private var isPolling = false - private var usedSnodes = Set() + private var usedSnodes = Set() private var pollCount = 0 // MARK: Settings @@ -66,7 +66,7 @@ public final class Poller : NSObject { private func pollNextSnode(seal: Resolver) { let userPublicKey = getUserHexEncodedPublicKey() let swarm = SnodeAPI.swarmCache[userPublicKey] ?? [] - let unusedSnodes = Set(swarm).subtracting(usedSnodes) + let unusedSnodes = swarm.subtracting(usedSnodes) if !unusedSnodes.isEmpty { // randomElement() uses the system's default random generator, which is cryptographically secure let nextSnode = unusedSnodes.randomElement()! @@ -89,20 +89,20 @@ public final class Poller : NSObject { } } - private func poll(_ snode: SessionSnodeKit.Legacy.Snode, seal longTermSeal: Resolver) -> Promise { + private func poll(_ snode: Snode, seal longTermSeal: Resolver) -> Promise { guard isPolling else { return Promise { $0.fulfill(()) } } let userPublicKey = getUserHexEncodedPublicKey() return SnodeAPI.getRawMessages(from: snode, associatedWith: userPublicKey).then(on: Threading.pollerQueue) { [weak self] rawResponse -> Promise in guard let strongSelf = self, strongSelf.isPolling else { return Promise { $0.fulfill(()) } } - let (messages, lastRawMessage) = SnodeAPI.parseRawMessagesResponse(rawResponse, from: snode, associatedWith: userPublicKey) + let messages: [SnodeReceivedMessage] = SnodeAPI.parseRawMessagesResponse(rawResponse, from: snode, associatedWith: userPublicKey) if !messages.isEmpty { SNLog("Received \(messages.count) new message(s).") } - messages.forEach { json in - guard let envelope = SNProtoEnvelope.from(json) else { return } + messages.forEach { message in + guard let envelope = SNProtoEnvelope.from(message) else { return } do { let data = try envelope.serializedData() - let job = MessageReceiveJob(data: data, serverHash: json["hash"] as? String, isBackgroundPoll: false) + let job = MessageReceiveJob(data: data, serverHash: message.info.hash, isBackgroundPoll: false) SNMessagingKitConfiguration.shared.storage.write { transaction in SessionMessagingKit.JobQueue.shared.add(job, using: transaction) } @@ -111,17 +111,22 @@ public final class Poller : NSObject { } } - // Now that the MessageReceiveJob's have been created we can update the `lastMessageHash` value - SnodeAPI.updateLastMessageHashValueIfPossible(for: snode, associatedWith: userPublicKey, from: lastRawMessage) + // Now that the MessageReceiveJob's have been created we can persist the received messages + if !messages.isEmpty { + GRDBStorage.shared.write { db in + messages.forEach { try? $0.info.save(db) } + } + } strongSelf.pollCount += 1 - if strongSelf.pollCount == Poller.maxPollCount { + + guard strongSelf.pollCount < Poller.maxPollCount else { throw Error.pollLimitReached - } else { - return withDelay(Poller.pollInterval, completionQueue: Threading.pollerQueue) { - guard let strongSelf = self, strongSelf.isPolling else { return Promise { $0.fulfill(()) } } - return strongSelf.poll(snode, seal: longTermSeal) - } + } + + return withDelay(Poller.pollInterval, completionQueue: Threading.pollerQueue) { + guard let strongSelf = self, strongSelf.isPolling else { return Promise { $0.fulfill(()) } } + return strongSelf.poll(snode, seal: longTermSeal) } } } diff --git a/SessionMessagingKit/Storage.swift b/SessionMessagingKit/Storage.swift index 6de688097..d866cab4f 100644 --- a/SessionMessagingKit/Storage.swift +++ b/SessionMessagingKit/Storage.swift @@ -13,9 +13,6 @@ public protocol SessionMessagingKitStorageProtocol { // MARK: - General - func getUserPublicKey() -> String? - func getUserKeyPair() -> ECKeyPair? - func getUserED25519KeyPair() -> Box.KeyPair? func getUser() -> Contact? func getUser(using transaction: YapDatabaseReadTransaction?) -> Contact? func getAllContacts() -> Set diff --git a/SessionMessagingKit/To Do/OWSRecipientIdentity.m b/SessionMessagingKit/To Do/OWSRecipientIdentity.m index 5cd1dcec2..d402c6a91 100644 --- a/SessionMessagingKit/To Do/OWSRecipientIdentity.m +++ b/SessionMessagingKit/To Do/OWSRecipientIdentity.m @@ -3,7 +3,6 @@ // #import "OWSRecipientIdentity.h" -#import "OWSIdentityManager.h" #import "OWSPrimaryStorage.h" #import #import diff --git a/SessionMessagingKit/Utilities/General.swift b/SessionMessagingKit/Utilities/General.swift deleted file mode 100644 index d2e4e0c96..000000000 --- a/SessionMessagingKit/Utilities/General.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Foundation - -public enum General { - public enum Cache { - public static var cachedEncodedPublicKey: Atomic = Atomic(nil) - } -} - -@objc(SNGeneralUtilities) -public class GeneralUtilities: NSObject { - @objc public static func getUserPublicKey() -> String { - return getUserHexEncodedPublicKey() - } -} - -public func getUserHexEncodedPublicKey() -> String { - if let cachedKey: String = General.Cache.cachedEncodedPublicKey.wrappedValue { return cachedKey } - - if let keyPair = OWSIdentityManager.shared().identityKeyPair() { // Can be nil under some circumstances - General.Cache.cachedEncodedPublicKey.mutate { $0 = keyPair.hexEncodedPublicKey } - return keyPair.hexEncodedPublicKey - } - - return "" -} diff --git a/SessionMessagingKit/Utilities/OWSIdentityManager.h b/SessionMessagingKit/Utilities/OWSIdentityManager.h deleted file mode 100644 index 9972facbc..000000000 --- a/SessionMessagingKit/Utilities/OWSIdentityManager.h +++ /dev/null @@ -1,74 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import -#import - -@class OWSPrimaryStorage; - -NS_ASSUME_NONNULL_BEGIN - -extern NSString *const OWSPrimaryStorageIdentityKeyStoreIdentityKey; -extern NSString *const LKSeedKey; -extern NSString *const LKED25519SecretKey; -extern NSString *const LKED25519PublicKey; -extern NSString *const OWSPrimaryStorageIdentityKeyStoreCollection; - -extern NSString *const OWSPrimaryStorageTrustedKeysCollection; - -// This notification will be fired whenever identities are created -// or their verification state changes. -extern NSString *const kNSNotificationName_IdentityStateDidChange; - -// number of bytes in a signal identity key, excluding the key-type byte. -extern const NSUInteger kIdentityKeyLength; - -#ifdef DEBUG -extern const NSUInteger kStoredIdentityKeyLength; -#endif - -@class OWSRecipientIdentity; -@class OWSStorage; -@class SNProtoVerified; -@class YapDatabaseReadWriteTransaction; - -// This class can be safely accessed and used from any thread. -@interface OWSIdentityManager : NSObject - -@property (nonatomic, readonly) YapDatabaseConnection *dbConnection; - -- (instancetype)init NS_UNAVAILABLE; -- (instancetype)initWithPrimaryStorage:(OWSPrimaryStorage *)primaryStorage NS_DESIGNATED_INITIALIZER; - -+ (instancetype)sharedManager; - -- (void)generateNewIdentityKeyPair; -- (void)clearIdentityKey; - -- (nullable OWSRecipientIdentity *)recipientIdentityForRecipientId:(NSString *)recipientId; - -/** - * @param recipientId unique stable identifier for the recipient, e.g. e164 phone number - * @returns nil if the recipient does not exist, or is trusted for sending - * else returns the untrusted recipient. - */ -- (nullable OWSRecipientIdentity *)untrustedIdentityForSendingToRecipientId:(NSString *)recipientId; - -- (BOOL)saveRemoteIdentity:(NSData *)identityKey recipientId:(NSString *)recipientId; - -- (nullable ECKeyPair *)identityKeyPair; - -#pragma mark - Debug - -#if DEBUG -// Clears everything except the local identity key. -- (void)clearIdentityState:(YapDatabaseReadWriteTransaction *)transaction; - -- (void)snapshotIdentityState:(YapDatabaseReadWriteTransaction *)transaction; -- (void)restoreIdentityState:(YapDatabaseReadWriteTransaction *)transaction; -#endif - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Utilities/OWSIdentityManager.m b/SessionMessagingKit/Utilities/OWSIdentityManager.m deleted file mode 100644 index a4a017baf..000000000 --- a/SessionMessagingKit/Utilities/OWSIdentityManager.m +++ /dev/null @@ -1,405 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSIdentityManager.h" -#import "AppContext.h" -#import "AppReadiness.h" -#import "NSNotificationCenter+OWS.h" -#import "NotificationsProtocol.h" -#import "OWSFileSystem.h" -#import "OWSPrimaryStorage.h" -#import "OWSRecipientIdentity.h" -#import "OWSIdentityManager.h" -#import "SSKEnvironment.h" -#import "TSAccountManager.h" -#import "TSContactThread.h" -#import "TSGroupThread.h" -#import "TSMessage.h" -#import "YapDatabaseConnection+OWS.h" -#import "YapDatabaseTransaction+OWS.h" -#import -#import -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -// Storing our own identity key -NSString *const OWSPrimaryStorageIdentityKeyStoreIdentityKey = @"TSStorageManagerIdentityKeyStoreIdentityKey"; -NSString *const LKSeedKey = @"LKLokiSeed"; -NSString *const LKED25519SecretKey = @"LKED25519SecretKey"; -NSString *const LKED25519PublicKey = @"LKED25519PublicKey"; -NSString *const OWSPrimaryStorageIdentityKeyStoreCollection = @"TSStorageManagerIdentityKeyStoreCollection"; - -// Storing recipients identity keys -NSString *const OWSPrimaryStorageTrustedKeysCollection = @"TSStorageManagerTrustedKeysCollection"; - -NSString *const OWSIdentityManager_QueuedVerificationStateSyncMessages = - @"OWSIdentityManager_QueuedVerificationStateSyncMessages"; - -// Don't trust an identity for sending to unless they've been around for at least this long -const NSTimeInterval kIdentityKeyStoreNonBlockingSecondsThreshold = 5.0; - -// The canonical key includes 32 bytes of identity material plus one byte specifying the key type -const NSUInteger kIdentityKeyLength = 33; - -// Cryptographic operations do not use the "type" byte of the identity key, so, for legacy reasons we store just -// the identity material. -// TODO: migrate to storing the full 33 byte representation. -const NSUInteger kStoredIdentityKeyLength = 32; - -NSString *const kNSNotificationName_IdentityStateDidChange = @"kNSNotificationName_IdentityStateDidChange"; - -@interface OWSIdentityManager () - -@property (nonatomic, readonly) OWSPrimaryStorage *primaryStorage; - -@end - -#pragma mark - - -@implementation OWSIdentityManager - -+ (instancetype)sharedManager -{ - return SSKEnvironment.shared.identityManager; -} - -- (instancetype)initWithPrimaryStorage:(OWSPrimaryStorage *)primaryStorage -{ - self = [super init]; - - if (!self) { - return self; - } - - _primaryStorage = primaryStorage; - _dbConnection = primaryStorage.newDatabaseConnection; - self.dbConnection.objectCacheEnabled = NO; - - [self observeNotifications]; - - return self; -} - -- (void)dealloc -{ - [[NSNotificationCenter defaultCenter] removeObserver:self]; -} - -#pragma mark - - -- (void)observeNotifications -{ - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(applicationDidBecomeActive:) - name:UIApplicationDidBecomeActiveNotification - object:nil]; -} - -- (void)generateNewIdentityKeyPair -{ - ECKeyPair *keyPair = [Curve25519 generateKeyPair]; - [self.dbConnection setObject:keyPair forKey:OWSPrimaryStorageIdentityKeyStoreIdentityKey inCollection:OWSPrimaryStorageIdentityKeyStoreCollection]; -} - -- (void)clearIdentityKey -{ - [self.dbConnection removeObjectForKey:OWSPrimaryStorageIdentityKeyStoreIdentityKey - inCollection:OWSPrimaryStorageIdentityKeyStoreCollection]; -} - -- (nullable NSData *)identityKeyForRecipientId:(NSString *)recipientId -{ - __block NSData *_Nullable result = nil; - [self.dbConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - result = [self identityKeyForRecipientId:recipientId transaction:transaction]; - }]; - return result; -} - -- (nullable NSData *)identityKeyForRecipientId:(NSString *)recipientId protocolContext:(nullable id)protocolContext -{ - YapDatabaseReadTransaction *transaction = protocolContext; - - return [self identityKeyForRecipientId:recipientId transaction:transaction]; -} - -- (nullable NSData *)identityKeyForRecipientId:(NSString *)recipientId - transaction:(YapDatabaseReadTransaction *)transaction -{ - return [OWSRecipientIdentity fetchObjectWithUniqueID:recipientId transaction:transaction].identityKey; -} - -- (nullable ECKeyPair *)identityKeyPair -{ - __block ECKeyPair *_Nullable identityKeyPair = nil; - [self.dbConnection readWithBlock:^(YapDatabaseReadTransaction *_Nonnull transaction) { - identityKeyPair = [self identityKeyPairWithTransaction:transaction]; - }]; - return identityKeyPair; -} - -// This method should only be called from SignalProtocolKit, which doesn't know about YapDatabaseTransactions. -// Whenever possible, prefer to call the strongly typed variant: `identityKeyPairWithTransaction:`. -- (nullable ECKeyPair *)identityKeyPair:(nullable id)protocolContext -{ - YapDatabaseReadTransaction *transaction = protocolContext; - - return [self identityKeyPairWithTransaction:transaction]; -} - -- (nullable ECKeyPair *)identityKeyPairWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - ECKeyPair *_Nullable identityKeyPair = [transaction keyPairForKey:OWSPrimaryStorageIdentityKeyStoreIdentityKey - inCollection:OWSPrimaryStorageIdentityKeyStoreCollection]; - return identityKeyPair; -} - -- (int)localRegistrationId:(nullable id)protocolContext -{ - YapDatabaseReadWriteTransaction *transaction = protocolContext; - - return (int)[TSAccountManager getOrGenerateRegistrationId:transaction]; -} - -- (BOOL)saveRemoteIdentity:(NSData *)identityKey recipientId:(NSString *)recipientId -{ - __block BOOL result; - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - result = [self saveRemoteIdentity:identityKey recipientId:recipientId protocolContext:transaction]; - }]; - - return result; -} - -- (BOOL)saveRemoteIdentity:(NSData *)identityKey - recipientId:(NSString *)recipientId - protocolContext:(nullable id)protocolContext -{ - YapDatabaseReadWriteTransaction *transaction = protocolContext; - - // Deprecated. We actually no longer use the OWSPrimaryStorageTrustedKeysCollection for trust - // decisions, but it's desirable to try to keep it up to date with our trusted identitys - // while we're switching between versions, e.g. so we don't get into a state where we have a - // session for an identity not in our key store. - [transaction setObject:identityKey forKey:recipientId inCollection:OWSPrimaryStorageTrustedKeysCollection]; - - OWSRecipientIdentity *existingIdentity = - [OWSRecipientIdentity fetchObjectWithUniqueID:recipientId transaction:transaction]; - - if (existingIdentity == nil) { - [[[OWSRecipientIdentity alloc] initWithRecipientId:recipientId - identityKey:identityKey - isFirstKnownKey:YES - createdAt:[NSDate new] - verificationState:OWSVerificationStateDefault] - saveWithTransaction:transaction]; - - [self fireIdentityStateChangeNotification]; - - return NO; - } - - if (![existingIdentity.identityKey isEqual:identityKey]) { - OWSVerificationState verificationState; - switch (existingIdentity.verificationState) { - case OWSVerificationStateDefault: - verificationState = OWSVerificationStateDefault; - break; - case OWSVerificationStateVerified: - case OWSVerificationStateNoLongerVerified: - verificationState = OWSVerificationStateNoLongerVerified; - break; - } - - [[[OWSRecipientIdentity alloc] initWithRecipientId:recipientId - identityKey:identityKey - isFirstKnownKey:NO - createdAt:[NSDate new] - verificationState:verificationState] saveWithTransaction:transaction]; - - [self fireIdentityStateChangeNotification]; - - return YES; - } - - return NO; -} - -- (nullable OWSRecipientIdentity *)recipientIdentityForRecipientId:(NSString *)recipientId -{ - __block OWSRecipientIdentity *_Nullable result; - [self.dbConnection readWithBlock:^(YapDatabaseReadTransaction *_Nonnull transaction) { - result = [OWSRecipientIdentity fetchObjectWithUniqueID:recipientId transaction:transaction]; - }]; - return result; -} - -- (nullable OWSRecipientIdentity *)untrustedIdentityForSendingToRecipientId:(NSString *)recipientId -{ - __block OWSRecipientIdentity *_Nullable result; - [self.dbConnection readWithBlock:^(YapDatabaseReadTransaction *_Nonnull transaction) { - OWSRecipientIdentity *_Nullable recipientIdentity = - [OWSRecipientIdentity fetchObjectWithUniqueID:recipientId transaction:transaction]; - - if (recipientIdentity == nil) { - // trust on first use - return; - } - - BOOL isTrusted = [self isTrustedIdentityKey:recipientIdentity.identityKey - recipientId:recipientId - direction:TSMessageDirectionOutgoing - transaction:transaction]; - if (isTrusted) { - return; - } else { - result = recipientIdentity; - } - }]; - return result; -} - -- (void)fireIdentityStateChangeNotification -{ - [[NSNotificationCenter defaultCenter] postNotificationNameAsync:kNSNotificationName_IdentityStateDidChange - object:nil - userInfo:nil]; -} - -- (BOOL)isTrustedIdentityKey:(NSData *)identityKey - recipientId:(NSString *)recipientId - direction:(TSMessageDirection)direction - protocolContext:(nullable id)protocolContext -{ - YapDatabaseReadWriteTransaction *transaction = protocolContext; - - return [self isTrustedIdentityKey:identityKey recipientId:recipientId direction:direction transaction:transaction]; -} - -- (BOOL)isTrustedIdentityKey:(NSData *)identityKey - recipientId:(NSString *)recipientId - direction:(TSMessageDirection)direction - transaction:(YapDatabaseReadTransaction *)transaction -{ - if ([[TSAccountManager localNumber] isEqualToString:recipientId]) { - ECKeyPair *_Nullable localIdentityKeyPair = [self identityKeyPairWithTransaction:transaction]; - - if ([localIdentityKeyPair.publicKey isEqualToData:identityKey]) { - return YES; - } else { - return NO; - } - } - - switch (direction) { - case TSMessageDirectionIncoming: { - return YES; - } - case TSMessageDirectionOutgoing: { - OWSRecipientIdentity *existingIdentity = - [OWSRecipientIdentity fetchObjectWithUniqueID:recipientId transaction:transaction]; - return [self isTrustedKey:identityKey forSendingToIdentity:existingIdentity]; - } - default: { - return NO; - } - } -} - -- (BOOL)isTrustedKey:(NSData *)identityKey forSendingToIdentity:(nullable OWSRecipientIdentity *)recipientIdentity -{ - if (recipientIdentity == nil) { - return YES; - } - - if (![recipientIdentity.identityKey isEqualToData:identityKey]) { - return NO; - } - - if ([recipientIdentity isFirstKnownKey]) { - return YES; - } - - switch (recipientIdentity.verificationState) { - case OWSVerificationStateDefault: { - BOOL isNew = (fabs([recipientIdentity.createdAt timeIntervalSinceNow]) - < kIdentityKeyStoreNonBlockingSecondsThreshold); - if (isNew) { - return NO; - } else { - return YES; - } - } - case OWSVerificationStateVerified: - return YES; - case OWSVerificationStateNoLongerVerified: - return NO; - } -} - -#pragma mark - Debug - -#if DEBUG -- (void)clearIdentityState:(YapDatabaseReadWriteTransaction *)transaction -{ - NSMutableArray *identityKeysToRemove = [NSMutableArray new]; - [transaction enumerateKeysInCollection:OWSPrimaryStorageIdentityKeyStoreCollection - usingBlock:^(NSString *_Nonnull key, BOOL *_Nonnull stop) { - if ([key isEqualToString:OWSPrimaryStorageIdentityKeyStoreIdentityKey]) { - // Don't delete our own key. - return; - } - [identityKeysToRemove addObject:key]; - }]; - for (NSString *key in identityKeysToRemove) { - [transaction removeObjectForKey:key inCollection:OWSPrimaryStorageIdentityKeyStoreCollection]; - } - [transaction removeAllObjectsInCollection:OWSPrimaryStorageTrustedKeysCollection]; -} - -- (NSString *)identityKeySnapshotFilePath -{ - // Prefix name with period "." so that backups will ignore these snapshots. - NSString *dirPath = [OWSFileSystem appDocumentDirectoryPath]; - return [dirPath stringByAppendingPathComponent:@".identity-key-snapshot"]; -} - -- (NSString *)trustedKeySnapshotFilePath -{ - // Prefix name with period "." so that backups will ignore these snapshots. - NSString *dirPath = [OWSFileSystem appDocumentDirectoryPath]; - return [dirPath stringByAppendingPathComponent:@".trusted-key-snapshot"]; -} - -- (void)snapshotIdentityState:(YapDatabaseReadWriteTransaction *)transaction -{ - [transaction snapshotCollection:OWSPrimaryStorageIdentityKeyStoreCollection - snapshotFilePath:self.identityKeySnapshotFilePath]; - [transaction snapshotCollection:OWSPrimaryStorageTrustedKeysCollection - snapshotFilePath:self.trustedKeySnapshotFilePath]; -} - -- (void)restoreIdentityState:(YapDatabaseReadWriteTransaction *)transaction -{ - [transaction restoreSnapshotOfCollection:OWSPrimaryStorageIdentityKeyStoreCollection - snapshotFilePath:self.identityKeySnapshotFilePath]; - [transaction restoreSnapshotOfCollection:OWSPrimaryStorageTrustedKeysCollection - snapshotFilePath:self.trustedKeySnapshotFilePath]; -} - -#endif - -#pragma mark - Notifications - -- (void)applicationDidBecomeActive:(NSNotification *)notification -{ - -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Utilities/SNProtoEnvelope+Conversion.swift b/SessionMessagingKit/Utilities/SNProtoEnvelope+Conversion.swift index b186b8cf5..d17f65563 100644 --- a/SessionMessagingKit/Utilities/SNProtoEnvelope+Conversion.swift +++ b/SessionMessagingKit/Utilities/SNProtoEnvelope+Conversion.swift @@ -1,15 +1,15 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionSnodeKit public extension SNProtoEnvelope { - - static func from(_ json: JSON) -> SNProtoEnvelope? { - guard let base64EncodedData = json["data"] as? String, let data = Data(base64Encoded: base64EncodedData) else { - SNLog("Failed to decode data for message: \(json).") - return nil - } - guard let result = try? MessageWrapper.unwrap(data: data) else { - SNLog("Failed to unwrap data for message: \(json).") + static func from(_ message: SnodeReceivedMessage) -> SNProtoEnvelope? { + guard let result = try? MessageWrapper.unwrap(data: message.data) else { + SNLog("Failed to unwrap data for message: \(String(reflecting: message)).") return nil } + return result } } diff --git a/SessionMessagingKit/Utilities/SSKEnvironment.h b/SessionMessagingKit/Utilities/SSKEnvironment.h index d44250910..c1e3112f5 100644 --- a/SessionMessagingKit/Utilities/SSKEnvironment.h +++ b/SessionMessagingKit/Utilities/SSKEnvironment.h @@ -38,7 +38,6 @@ NS_ASSUME_NONNULL_BEGIN - (instancetype)initWithProfileManager:(id)profileManager primaryStorage:(OWSPrimaryStorage *)primaryStorage - identityManager:(OWSIdentityManager *)identityManager tsAccountManager:(TSAccountManager *)tsAccountManager disappearingMessagesJob:(OWSDisappearingMessagesJob *)disappearingMessagesJob readReceiptManager:(OWSReadReceiptManager *)readReceiptManager diff --git a/SessionMessagingKit/Utilities/SSKEnvironment.m b/SessionMessagingKit/Utilities/SSKEnvironment.m index 2c9046129..959487585 100644 --- a/SessionMessagingKit/Utilities/SSKEnvironment.m +++ b/SessionMessagingKit/Utilities/SSKEnvironment.m @@ -14,7 +14,6 @@ static SSKEnvironment *sharedSSKEnvironment; @property (nonatomic) id profileManager; @property (nonatomic) OWSPrimaryStorage *primaryStorage; -@property (nonatomic) OWSIdentityManager *identityManager; @property (nonatomic) TSAccountManager *tsAccountManager; @property (nonatomic) OWSDisappearingMessagesJob *disappearingMessagesJob; @property (nonatomic) OWSReadReceiptManager *readReceiptManager; @@ -36,7 +35,6 @@ static SSKEnvironment *sharedSSKEnvironment; - (instancetype)initWithProfileManager:(id)profileManager primaryStorage:(OWSPrimaryStorage *)primaryStorage - identityManager:(OWSIdentityManager *)identityManager tsAccountManager:(TSAccountManager *)tsAccountManager disappearingMessagesJob:(OWSDisappearingMessagesJob *)disappearingMessagesJob readReceiptManager:(OWSReadReceiptManager *)readReceiptManager @@ -52,7 +50,6 @@ static SSKEnvironment *sharedSSKEnvironment; _profileManager = profileManager; _primaryStorage = primaryStorage; - _identityManager = identityManager; _tsAccountManager = tsAccountManager; _disappearingMessagesJob = disappearingMessagesJob; _readReceiptManager = readReceiptManager; diff --git a/SessionNotificationServiceExtension/NSENotificationPresenter.swift b/SessionNotificationServiceExtension/NSENotificationPresenter.swift index 148876540..4f1b8b940 100644 --- a/SessionNotificationServiceExtension/NSENotificationPresenter.swift +++ b/SessionNotificationServiceExtension/NSENotificationPresenter.swift @@ -28,7 +28,7 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { } let senderPublicKey = incomingMessage.authorId - let userPublicKey = GeneralUtilities.getUserPublicKey() + let userPublicKey = getUserHexEncodedPublicKey() guard senderPublicKey != userPublicKey else { // Ignore PNs for messages sent by the current user // after handling the message. Otherwise the closed diff --git a/SessionSnodeKit/Configuration.swift b/SessionSnodeKit/Configuration.swift index 24a3074d5..2c063507a 100644 --- a/SessionSnodeKit/Configuration.swift +++ b/SessionSnodeKit/Configuration.swift @@ -2,8 +2,6 @@ import Foundation import SessionUtilitiesKit public struct SNSnodeKitConfiguration { - public let storage: SessionSnodeKitStorageProtocol - internal static var shared: SNSnodeKitConfiguration! } @@ -22,7 +20,6 @@ public enum SNSnodeKit { // Just to make the external API nice ) } - public static func configure(storage: SessionSnodeKitStorageProtocol) { - SNSnodeKitConfiguration.shared = SNSnodeKitConfiguration(storage: storage) + public static func configure() { } } diff --git a/SessionSnodeKit/Database/LegacyDatabase/SSKLegacyModels.swift b/SessionSnodeKit/Database/LegacyDatabase/SSKLegacyModels.swift index 52d873245..72db8da97 100644 --- a/SessionSnodeKit/Database/LegacyDatabase/SSKLegacyModels.swift +++ b/SessionSnodeKit/Database/LegacyDatabase/SSKLegacyModels.swift @@ -6,12 +6,12 @@ public enum Legacy { // MARK: - Collections and Keys internal static let swarmCollectionPrefix = "LokiSwarmCollection-" + internal static let lastSnodePoolRefreshDateKey = "lastSnodePoolRefreshDate" internal static let snodePoolCollection = "LokiSnodePoolCollection" internal static let onionRequestPathCollection = "LokiOnionRequestPathCollection" internal static let lastSnodePoolRefreshDateCollection = "LokiLastSnodePoolRefreshDateCollection" internal static let lastMessageHashCollection = "LokiLastMessageHashCollection" // TODO: Remove this one? (make it a query??) internal static let receivedMessagesCollection = "LokiReceivedMessagesCollection" - // TODO: - "lastSnodePoolRefreshDate" // MARK: - Types @@ -29,15 +29,6 @@ public enum Legacy { } // MARK: Nested Types - public enum Method : String { - case getSwarm = "get_snodes_for_pubkey" - case getMessages = "retrieve" - case sendMessage = "store" - case deleteMessage = "delete" - case oxenDaemonRPCCall = "oxend_request" - case getInfo = "info" - case clearAllData = "delete_all" - } public struct KeySet { public let ed25519Key: String diff --git a/SessionSnodeKit/Database/Migrations/_002_YDBToGRDBMigration.swift b/SessionSnodeKit/Database/Migrations/_002_YDBToGRDBMigration.swift index 4a173265f..416f5d65d 100644 --- a/SessionSnodeKit/Database/Migrations/_002_YDBToGRDBMigration.swift +++ b/SessionSnodeKit/Database/Migrations/_002_YDBToGRDBMigration.swift @@ -14,8 +14,15 @@ enum _002_YDBToGRDBMigration: Migration { // Note: Want to exclude the Snode's we already added from the 'onionRequestPathResult' var snodeResult: Set = [] var snodeSetResult: [String: Set] = [:] + var lastSnodePoolRefreshDate: Date? = nil Storage.read { transaction in + // Process the lastSnodePoolRefreshDate + lastSnodePoolRefreshDate = transaction.object( + forKey: Legacy.lastSnodePoolRefreshDateKey, + inCollection: Legacy.lastSnodePoolRefreshDateCollection + ) as? Date + // Process the OnionRequestPaths if let path0Snode0 = transaction.object(forKey: "0-0", inCollection: Legacy.onionRequestPathCollection) as? Legacy.Snode, @@ -65,6 +72,10 @@ enum _002_YDBToGRDBMigration: Migration { } } + // Insert the data into GRDB + + db[.lastSnodePoolRefreshDate] = lastSnodePoolRefreshDate + try snodeResult.forEach { legacySnode in try Snode( address: legacySnode.address, @@ -79,20 +90,13 @@ enum _002_YDBToGRDBMigration: Migration { // Note: In this case the 'nodeIndex' is irrelivant try SnodeSet( key: key, - nodeIndex: UInt(nodeIndex), + nodeIndex: nodeIndex, address: legacySnode.address, port: legacySnode.port ).insert(db) } } - // TODO: This -// public func setLastSnodePoolRefreshDate(to date: Date, using transaction: Any) { -// (transaction as! YapDatabaseReadWriteTransaction).setObject(date, forKey: "lastSnodePoolRefreshDate", inCollection: Storage.lastSnodePoolRefreshDateCollection) -// } - - print("RAWR") - // MARK: - Received Messages & Last Message Hash var lastMessageResults: [String: (hash: String, json: JSON)] = [:] diff --git a/SessionSnodeKit/Database/Models/Snode.swift b/SessionSnodeKit/Database/Models/Snode.swift index 7e4a2afc6..1e18066cd 100644 --- a/SessionSnodeKit/Database/Models/Snode.swift +++ b/SessionSnodeKit/Database/Models/Snode.swift @@ -4,18 +4,100 @@ import Foundation import GRDB import SessionUtilitiesKit -public struct Snode: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible, Hashable { +public struct Snode: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible, Hashable, CustomStringConvertible { public static var databaseTableName: String { "snode" } + static let snodeSet = hasMany(SnodeSet.self) + static let snodeSetForeignKey = ForeignKey( + [Columns.address, Columns.port], + to: [SnodeSet.Columns.address, SnodeSet.Columns.port] + ) - public enum Columns: String, CodingKey, ColumnExpression { - case address - case port - case ed25519PublicKey - case x25519PublicKey + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case address = "public_ip" + case port = "storage_port" + case ed25519PublicKey = "pubkey_ed25519" + case x25519PublicKey = "pubkey_x25519" + } + + public let address: String + public let port: UInt16 + public let ed25519PublicKey: String + public let x25519PublicKey: String + + public var ip: String { + guard let range = address.range(of: "https://"), range.lowerBound == address.startIndex else { + return address + } + + return String(address[range.upperBound.. { + request(for: Snode.snodeSet) + } + + public var description: String { return "\(address):\(port)" } +} + +// MARK: - Decoder + +extension Snode { + public init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + do { + let address: String = try container.decode(String.self, forKey: .address) + + guard address != "0.0.0.0" else { throw SnodeAPI.Error.invalidIP } + + self = Snode( + address: (address.starts(with: "https://") ? address : "https://\(address)"), + port: try container.decode(UInt16.self, forKey: .port), + ed25519PublicKey: try container.decode(String.self, forKey: .ed25519PublicKey), + x25519PublicKey: try container.decode(String.self, forKey: .x25519PublicKey) + ) + } + catch { + SNLog("Failed to parse snode: \(error.localizedDescription).") + throw HTTP.Error.invalidJSON + } + } +} + +// MARK: - Convenience + +internal extension Snode { + static func fetchSet(_ db: Database, publicKey: String) throws -> Set { + return try Snode + .joining( + required: Snode.snodeSet + .filter(SnodeSet.Columns.key == publicKey) + .order(SnodeSet.Columns.nodeIndex) + ) + .fetchSet(db) + } +} + +internal extension Collection where Element == Snode { + func save(_ db: Database, key: String) throws { + try self.enumerated().forEach { nodeIndex, node in + try node.save(db) + + try SnodeSet( + key: key, + nodeIndex: nodeIndex, + address: node.address, + port: node.port + ).save(db) + } + } +} + +internal extension Collection where Element == [Snode] { + func save(_ db: Database) throws { + try self.enumerated().forEach { pathIndex, path in + try path.save(db, key: "\(SnodeSet.onionRequestPathPrefix)\(pathIndex)") + } + } } diff --git a/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift b/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift index d6c80c16d..7c1a2ade7 100644 --- a/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift +++ b/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift @@ -4,16 +4,56 @@ import Foundation import GRDB import SessionUtilitiesKit -struct SnodeReceivedMessageInfo: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { - static var databaseTableName: String { "snodeReceivedMessageInfo" } +public struct SnodeReceivedMessageInfo: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "snodeReceivedMessageInfo" } - public enum Columns: String, CodingKey, ColumnExpression { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { case key case hash case expirationDateMs } - let key: String - let hash: String - let expirationDateMs: Int64 + public let key: String + public let hash: String + public let expirationDateMs: Int64 +} + +// MARK: - Convenience + +public extension SnodeReceivedMessageInfo { + private static func key(for snode: Snode, publicKey: String) -> String { + return "\(snode.address):\(snode.port).\(publicKey)" + } + + init( + snode: Snode, + publicKey: String, + hash: String, + expirationDateMs: Int64? + ) { + self.key = SnodeReceivedMessageInfo.key(for: snode, publicKey: publicKey) + self.hash = hash + self.expirationDateMs = (expirationDateMs ?? 0) + } + + static func pruneLastMessageHashInfoIfExpired(for snode: Snode, associatedWith publicKey: String) { + // Clear out the 'expirationDateMs' value for all expired (but non-0) message infos + GRDBStorage.shared.write { db in + try? SnodeReceivedMessageInfo + .filter(SnodeReceivedMessageInfo.Columns.key == key(for: snode, publicKey: publicKey)) + .filter(SnodeReceivedMessageInfo.Columns.expirationDateMs > 0) + .updateAll(db, SnodeReceivedMessageInfo.Columns.expirationDateMs.set(to: 0)) + } + } + + static func fetchLastNotExpired(for snode: Snode, associatedWith publicKey: String) -> SnodeReceivedMessageInfo? { + return GRDBStorage.shared.read { db in + try? SnodeReceivedMessageInfo + .filter(SnodeReceivedMessageInfo.Columns.key == key(for: snode, publicKey: publicKey)) + .order(SnodeReceivedMessageInfo.Columns.expirationDateMs) + .reversed() + .fetchOne(db) + } + } } diff --git a/SessionSnodeKit/Database/Models/SnodeSet.swift b/SessionSnodeKit/Database/Models/SnodeSet.swift index 4668d2e40..597597ea8 100644 --- a/SessionSnodeKit/Database/Models/SnodeSet.swift +++ b/SessionSnodeKit/Database/Models/SnodeSet.swift @@ -4,24 +4,59 @@ import Foundation import GRDB import SessionUtilitiesKit -struct SnodeSet: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { - static var databaseTableName: String { "snodeSet" } - static let nodes = hasMany(Snode.self) - static let onionRequestPathPrefix = "OnionRequestPath-" - - public enum Columns: String, CodingKey, ColumnExpression { +public struct SnodeSet: Codable, FetchableRecord, EncodableRecord, PersistableRecord, TableRecord, ColumnExpressible { + public static let onionRequestPathPrefix = "OnionRequestPath-" + public static var databaseTableName: String { "snodeSetAssociation" } + static let node = hasOne(Snode.self, using: Snode.snodeSetForeignKey) + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { case key case nodeIndex case address case port } - let key: String - let nodeIndex: UInt - let address: String - let port: UInt16 + public let key: String + public let nodeIndex: Int + public let address: String + public let port: UInt16 - var nodes: QueryInterfaceRequest { - request(for: SnodeSet.nodes) + public var node: QueryInterfaceRequest { + request(for: SnodeSet.node) + } +} + +// MARK: - Convenience + +internal extension SnodeSet { + static func fetchAllOnionRequestPaths(_ db: Database) throws -> [[Snode]] { + struct ResultWrapper: Decodable, FetchableRecord { + let key: String + let nodeIndex: Int + let address: String + let port: UInt16 + let snode: Snode + } + + return try SnodeSet + .filter(SnodeSet.Columns.key.like("\(SnodeSet.onionRequestPathPrefix)%")) + .order(SnodeSet.Columns.nodeIndex) + .order(SnodeSet.Columns.key) + .including(required: SnodeSet.node) + .asRequest(of: ResultWrapper.self) + .fetchAll(db) + .reduce(into: [:]) { prev, next in // Reducing will lose the 'key' sorting + prev[next.key] = (prev[next.key] ?? []).appending(next.snode) + } + .asArray() + .sorted(by: { lhs, rhs in lhs.key < rhs.key }) + .compactMap { _, nodes in !nodes.isEmpty ? nodes : nil } // Exclude empty sets + } + + static func clearOnionRequestPaths(_ db: Database) throws { + try SnodeSet + .filter(SnodeSet.Columns.key.like("\(SnodeSet.onionRequestPathPrefix)%")) + .deleteAll(db) } } diff --git a/SessionSnodeKit/Database/Types/SSKSetting.swift b/SessionSnodeKit/Database/Types/SSKSetting.swift index 0a8ab0dac..7db9629d9 100644 --- a/SessionSnodeKit/Database/Types/SSKSetting.swift +++ b/SessionSnodeKit/Database/Types/SSKSetting.swift @@ -1,3 +1,8 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import SessionUtilitiesKit + +extension Setting.DateKey { + static let lastSnodePoolRefreshDate: Setting.DateKey = "lastSnodePoolRefreshDate" +} diff --git a/SessionSnodeKit/Models/SnodeReceivedMessage.swift b/SessionSnodeKit/Models/SnodeReceivedMessage.swift new file mode 100644 index 000000000..36bd94cc9 --- /dev/null +++ b/SessionSnodeKit/Models/SnodeReceivedMessage.swift @@ -0,0 +1,33 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +public struct SnodeReceivedMessage: CustomDebugStringConvertible { + public let info: SnodeReceivedMessageInfo + public let data: Data + + init?(snode: Snode, publicKey: String, rawMessage: JSON) { + guard let hash: String = rawMessage["hash"] as? String else { return nil } + + guard + let base64EncodedString: String = rawMessage["data"] as? String, + let data: Data = Data(base64Encoded: base64EncodedString) + else { + SNLog("Failed to decode data for message: \(rawMessage).") + return nil + } + + self.info = SnodeReceivedMessageInfo( + snode: snode, + publicKey: publicKey, + hash: hash, + expirationDateMs: rawMessage["expiration"] as? Int64 + ) + self.data = data + } + + public var debugDescription: String { + return "{\"hash\":\(info.hash),\"expiration\":\(info.expirationDateMs),\"data\":\"\(data.base64EncodedString())\"}" + } +} diff --git a/SessionSnodeKit/Models/SwarmSnode.swift b/SessionSnodeKit/Models/SwarmSnode.swift new file mode 100644 index 000000000..09dc2963e --- /dev/null +++ b/SessionSnodeKit/Models/SwarmSnode.swift @@ -0,0 +1,58 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +/// It looks like the structure for the service node returned from `get_snodes_for_pubkey` is different from +/// the usual structure, this type is used as an intemediary to convert to the usual 'Snode' type +// FIXME: Hopefully at some point this different Snode structure will be deprecated and can be removed +internal struct SwarmSnode: Codable { + public enum CodingKeys: String, CodingKey { + case address = "ip" + case port + case ed25519PublicKey = "pubkey_ed25519" + case x25519PublicKey = "pubkey_x25519" + } + + let address: String + let port: UInt16 + let ed25519PublicKey: String + let x25519PublicKey: String +} + +// MARK: - Convenience + +extension SwarmSnode { + public init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + do { + let address: String = try container.decode(String.self, forKey: .address) + let portString: String = try container.decode(String.self, forKey: .port) + + guard address != "0.0.0.0", let port: UInt16 = UInt16(portString) else { + throw SnodeAPI.Error.invalidIP + } + + self = SwarmSnode( + address: (address.starts(with: "https://") ? address : "https://\(address)"), + port: port, + ed25519PublicKey: try container.decode(String.self, forKey: .ed25519PublicKey), + x25519PublicKey: try container.decode(String.self, forKey: .x25519PublicKey) + ) + } + catch { + SNLog("Failed to parse snode: \(error.localizedDescription).") + throw HTTP.Error.invalidJSON + } + } + + func toSnode() -> Snode { + return Snode( + address: address, + port: port, + ed25519PublicKey: ed25519PublicKey, + x25519PublicKey: x25519PublicKey + ) + } +} diff --git a/SessionSnodeKit/OnionRequestAPI+Encryption.swift b/SessionSnodeKit/OnionRequestAPI+Encryption.swift index 27a7ab31c..fda01bfe7 100644 --- a/SessionSnodeKit/OnionRequestAPI+Encryption.swift +++ b/SessionSnodeKit/OnionRequestAPI+Encryption.swift @@ -22,7 +22,7 @@ internal extension OnionRequestAPI { // Wrapping isn't needed for file server or open group onion requests switch destination { case .snode(let snode): - let snodeX25519PublicKey = snode.publicKeySet.x25519Key + let snodeX25519PublicKey = snode.x25519PublicKey let payloadAsData = try JSONSerialization.data(withJSONObject: payload, options: [ .fragmentsAllowed ]) let plaintext = try encode(ciphertext: payloadAsData, json: [ "headers" : "" ]) let result = try AESGCM.encrypt(plaintext, for: snodeX25519PublicKey) @@ -46,7 +46,7 @@ internal extension OnionRequestAPI { var parameters: JSON switch rhs { case .snode(let snode): - let snodeED25519PublicKey = snode.publicKeySet.ed25519Key + let snodeED25519PublicKey = snode.ed25519PublicKey parameters = [ "destination" : snodeED25519PublicKey ] case .server(let host, let target, _, let scheme, let port): let scheme = scheme ?? "https" @@ -57,7 +57,7 @@ internal extension OnionRequestAPI { let x25519PublicKey: String switch lhs { case .snode(let snode): - let snodeX25519PublicKey = snode.publicKeySet.x25519Key + let snodeX25519PublicKey = snode.x25519PublicKey x25519PublicKey = snodeX25519PublicKey case .server(_, _, let serverX25519PublicKey, _, _): x25519PublicKey = serverX25519PublicKey diff --git a/SessionSnodeKit/OnionRequestAPI.swift b/SessionSnodeKit/OnionRequestAPI.swift index b50bee214..2ae59b8a7 100644 --- a/SessionSnodeKit/OnionRequestAPI.swift +++ b/SessionSnodeKit/OnionRequestAPI.swift @@ -1,19 +1,40 @@ +import Foundation import CryptoSwift +import GRDB import PromiseKit import SessionUtilitiesKit /// See the "Onion Requests" section of [The Session Whitepaper](https://arxiv.org/pdf/2002.04609.pdf) for more information. public enum OnionRequestAPI { - private static var buildPathsPromise: Promise<[Path]>? = nil + private static var buildPathsPromise: Promise<[[Snode]]>? = nil + /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. - private static var pathFailureCount: [Path:UInt] = [:] + private static var pathFailureCount: [[Snode]: UInt] = [:] + /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. - private static var snodeFailureCount: [Legacy.Snode:UInt] = [:] + private static var snodeFailureCount: [Snode: UInt] = [:] + /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. - public static var guardSnodes: Set = [] - public static var paths: [Path] = [] // Not a set to ensure we consistently show the same path to the user + public static var guardSnodes: Set = [] + + // Not a set to ensure we consistently show the same path to the user + private static var _paths: [[Snode]]? + public static var paths: [[Snode]] { + get { + if let paths: [[Snode]] = _paths { return paths } + + let results: [[Snode]]? = GRDBStorage.shared.read { db in + try? SnodeSet.fetchAllOnionRequestPaths(db) + } + + if results?.isEmpty == false { _paths = results } + return (results ?? []) + } + set { _paths = newValue } + } - // MARK: Settings + // MARK: - Settings + public static let maxRequestSize = 10_000_000 // 10 MB /// The number of snodes (including the guard snode) in a path. private static let pathSize: UInt = 3 @@ -27,97 +48,80 @@ public enum OnionRequestAPI { /// The number of guard snodes required to maintain `targetPathCount` paths. private static var targetGuardSnodeCount: UInt { return targetPathCount } // One per path - // MARK: Destination - public enum Destination : CustomStringConvertible { - case snode(Legacy.Snode) - case server(host: String, target: String, x25519PublicKey: String, scheme: String?, port: UInt16?) - - public var description: String { - switch self { - case .snode(let snode): return "Service node \(snode.ip):\(snode.port)" - case .server(let host, _, _, _, _): return host - } - } - } - - // MARK: Error - public enum Error : LocalizedError { - case httpRequestFailedAtDestination(statusCode: UInt, json: JSON, destination: Destination) - case insufficientSnodes - case invalidURL - case missingSnodeVersion - case snodePublicKeySetMissing - case unsupportedSnodeVersion(String) - - public var errorDescription: String? { - switch self { - case .httpRequestFailedAtDestination(let statusCode, _, let destination): - if statusCode == 429 { - return "Rate limited." - } else { - return "HTTP request failed at destination (\(destination)) with status code: \(statusCode)." - } - case .insufficientSnodes: return "Couldn't find enough Service Nodes to build a path." - case .invalidURL: return "Invalid URL" - case .missingSnodeVersion: return "Missing Service Node version." - case .snodePublicKeySetMissing: return "Missing Service Node public key set." - case .unsupportedSnodeVersion(let version): return "Unsupported Service Node version: \(version)." - } - } - } - - // MARK: Path - public typealias Path = [Legacy.Snode] - // MARK: Onion Building Result - private typealias OnionBuildingResult = (guardSnode: Legacy.Snode, finalEncryptionResult: AESGCM.EncryptionResult, destinationSymmetricKey: Data) + private typealias OnionBuildingResult = (guardSnode: Snode, finalEncryptionResult: AESGCM.EncryptionResult, destinationSymmetricKey: Data) // MARK: Private API /// Tests the given snode. The returned promise errors out if the snode is faulty; the promise is fulfilled otherwise. - private static func testSnode(_ snode: Legacy.Snode) -> Promise { + private static func testSnode(_ snode: Snode) -> Promise { let (promise, seal) = Promise.pending() + DispatchQueue.global(qos: .userInitiated).async { let url = "\(snode.address):\(snode.port)/get_stats/v1" let timeout: TimeInterval = 3 // Use a shorter timeout for testing - HTTP.execute(.get, url, timeout: timeout).done2 { json in - guard let version = json["version"] as? String else { return seal.reject(Error.missingSnodeVersion) } - if version >= "2.0.7" { - seal.fulfill(()) - } else { - SNLog("Unsupported snode version: \(version).") - seal.reject(Error.unsupportedSnodeVersion(version)) + + HTTP.execute(.get, url, timeout: timeout) + .done2 { json in + guard let version = json["version"] as? String else { + return seal.reject(Error.missingSnodeVersion) + } + + if version >= "2.0.7" { + seal.fulfill(()) + } + else { + SNLog("Unsupported snode version: \(version).") + seal.reject(Error.unsupportedSnodeVersion(version)) + } + } + .catch2 { error in + seal.reject(error) } - }.catch2 { error in - seal.reject(error) - } } + return promise } /// Finds `targetGuardSnodeCount` guard snodes to use for path building. The returned promise errors out with `Error.insufficientSnodes` /// if not enough (reliable) snodes are available. - private static func getGuardSnodes(reusing reusableGuardSnodes: [Legacy.Snode]) -> Promise> { + private static func getGuardSnodes(reusing reusableGuardSnodes: [Snode]) -> Promise> { if guardSnodes.count >= targetGuardSnodeCount { - return Promise> { $0.fulfill(guardSnodes) } - } else { + return Promise> { $0.fulfill(guardSnodes) } + } + else { SNLog("Populating guard snode cache.") - var unusedSnodes = SnodeAPI.snodePool.subtracting(reusableGuardSnodes) // Sync on LokiAPI.workQueue + // Sync on LokiAPI.workQueue + var unusedSnodes = SnodeAPI.snodePool.subtracting(reusableGuardSnodes) let reusableGuardSnodeCount = UInt(reusableGuardSnodes.count) - guard unusedSnodes.count >= (targetGuardSnodeCount - reusableGuardSnodeCount) else { return Promise(error: Error.insufficientSnodes) } - func getGuardSnode() -> Promise { - // randomElement() uses the system's default random generator, which is cryptographically secure - guard let candidate = unusedSnodes.randomElement() else { return Promise { $0.reject(Error.insufficientSnodes) } } + + guard unusedSnodes.count >= (targetGuardSnodeCount - reusableGuardSnodeCount) else { + return Promise(error: Error.insufficientSnodes) + } + + func getGuardSnode() -> Promise { + // randomElement() uses the system's default random generator, which + // is cryptographically secure + guard let candidate = unusedSnodes.randomElement() else { + return Promise { $0.reject(Error.insufficientSnodes) } + } + unusedSnodes.remove(candidate) // All used snodes should be unique SNLog("Testing guard snode: \(candidate).") + // Loop until a reliable guard snode is found return testSnode(candidate).map2 { candidate }.recover(on: DispatchQueue.main) { _ in withDelay(0.1, completionQueue: Threading.workQueue) { getGuardSnode() } } } - let promises = (0..<(targetGuardSnodeCount - reusableGuardSnodeCount)).map { _ in getGuardSnode() } + + let promises = (0..<(targetGuardSnodeCount - reusableGuardSnodeCount)).map { _ in + getGuardSnode() + } + return when(fulfilled: promises).map2 { guardSnodes in let guardSnodesAsSet = Set(guardSnodes + reusableGuardSnodes) OnionRequestAPI.guardSnodes = guardSnodesAsSet + return guardSnodesAsSet } } @@ -126,40 +130,50 @@ public enum OnionRequestAPI { /// Builds and returns `targetPathCount` paths. The returned promise errors out with `Error.insufficientSnodes` /// if not enough (reliable) snodes are available. @discardableResult - private static func buildPaths(reusing reusablePaths: [Path]) -> Promise<[Path]> { + private static func buildPaths(reusing reusablePaths: [[Snode]]) -> Promise<[[Snode]]> { if let existingBuildPathsPromise = buildPathsPromise { return existingBuildPathsPromise } SNLog("Building onion request paths.") DispatchQueue.main.async { NotificationCenter.default.post(name: .buildingPaths, object: nil) } let reusableGuardSnodes = reusablePaths.map { $0[0] } - let promise: Promise<[Path]> = getGuardSnodes(reusing: reusableGuardSnodes).map2 { guardSnodes -> [Path] in - var unusedSnodes = SnodeAPI.snodePool.subtracting(guardSnodes).subtracting(reusablePaths.flatMap { $0 }) - let reusableGuardSnodeCount = UInt(reusableGuardSnodes.count) - let pathSnodeCount = (targetGuardSnodeCount - reusableGuardSnodeCount) * pathSize - (targetGuardSnodeCount - reusableGuardSnodeCount) - guard unusedSnodes.count >= pathSnodeCount else { throw Error.insufficientSnodes } - // Don't test path snodes as this would reveal the user's IP to them - return guardSnodes.subtracting(reusableGuardSnodes).map { guardSnode in - let result = [ guardSnode ] + (0..<(pathSize - 1)).map { _ in - // randomElement() uses the system's default random generator, which is cryptographically secure - let pathSnode = unusedSnodes.randomElement()! // Safe because of the pathSnodeCount check above - unusedSnodes.remove(pathSnode) // All used snodes should be unique - return pathSnode + let promise: Promise<[[Snode]]> = getGuardSnodes(reusing: reusableGuardSnodes) + .map2 { guardSnodes -> [[Snode]] in + var unusedSnodes = SnodeAPI.snodePool + .subtracting(guardSnodes) + .subtracting(reusablePaths.flatMap { $0 }) + let reusableGuardSnodeCount = UInt(reusableGuardSnodes.count) + let pathSnodeCount = (targetGuardSnodeCount - reusableGuardSnodeCount) * pathSize - (targetGuardSnodeCount - reusableGuardSnodeCount) + + guard unusedSnodes.count >= pathSnodeCount else { throw Error.insufficientSnodes } + + // Don't test path snodes as this would reveal the user's IP to them + return guardSnodes.subtracting(reusableGuardSnodes).map { guardSnode in + let result = [ guardSnode ] + (0..<(pathSize - 1)).map { _ in + // randomElement() uses the system's default random generator, which is cryptographically secure + let pathSnode = unusedSnodes.randomElement()! // Safe because of the pathSnodeCount check above + unusedSnodes.remove(pathSnode) // All used snodes should be unique + return pathSnode + } + + SNLog("Built new onion request path: \(result.prettifiedDescription).") + return result } - SNLog("Built new onion request path: \(result.prettifiedDescription).") - return result } - }.map2 { paths in - OnionRequestAPI.paths = paths + reusablePaths - SNSnodeKitConfiguration.shared.storage.writeSync { transaction in - SNLog("Persisting onion request paths to database.") - SNSnodeKitConfiguration.shared.storage.setOnionRequestPaths(to: paths, using: transaction) + .map2 { paths in + OnionRequestAPI.paths = paths + reusablePaths + + GRDBStorage.shared.write { db in + SNLog("Persisting onion request paths to database.") + try? paths.save(db) + } + + DispatchQueue.main.async { + NotificationCenter.default.post(name: .pathsBuilt, object: nil) + } + return paths } - DispatchQueue.main.async { - NotificationCenter.default.post(name: .pathsBuilt, object: nil) - } - return paths - } + promise.done2 { _ in buildPathsPromise = nil } promise.catch2 { _ in buildPathsPromise = nil } buildPathsPromise = promise @@ -167,63 +181,68 @@ public enum OnionRequestAPI { } /// Returns a `Path` to be used for building an onion request. Builds new paths as needed. - private static func getPath(excluding snode: Legacy.Snode?) -> Promise { + private static func getPath(excluding snode: Snode?) -> Promise<[Snode]> { guard pathSize >= 1 else { preconditionFailure("Can't build path of size zero.") } - var paths = OnionRequestAPI.paths - if paths.isEmpty { - paths = SNSnodeKitConfiguration.shared.storage.getOnionRequestPaths() - OnionRequestAPI.paths = paths - if !paths.isEmpty { - guardSnodes.formUnion([ paths[0][0] ]) - if paths.count >= 2 { - guardSnodes.formUnion([ paths[1][0] ]) - } + + let paths: [[Snode]] = OnionRequestAPI.paths + + if !paths.isEmpty { + guardSnodes.formUnion([ paths[0][0] ]) + + if paths.count >= 2 { + guardSnodes.formUnion([ paths[1][0] ]) } } + // randomElement() uses the system's default random generator, which is cryptographically secure if paths.count >= targetPathCount { - if let snode = snode { + if let snode: Snode = snode { return Promise { $0.fulfill(paths.filter { !$0.contains(snode) }.randomElement()!) } - } else { + } + else { return Promise { $0.fulfill(paths.randomElement()!) } } - } else if !paths.isEmpty { + } + else if !paths.isEmpty { if let snode = snode { if let path = paths.first(where: { !$0.contains(snode) }) { buildPaths(reusing: paths) // Re-build paths in the background return Promise { $0.fulfill(path) } - } else { + } + else { return buildPaths(reusing: paths).map2 { paths in return paths.filter { !$0.contains(snode) }.randomElement()! } } - } else { + } + else { buildPaths(reusing: paths) // Re-build paths in the background return Promise { $0.fulfill(paths.randomElement()!) } } - } else { + } + else { return buildPaths(reusing: []).map2 { paths in if let snode = snode { if let path = paths.filter({ !$0.contains(snode) }).randomElement() { return path - } else { - throw Error.insufficientSnodes } - } else { - return paths.randomElement()! + + throw Error.insufficientSnodes } + + return paths.randomElement()! } } } - private static func dropGuardSnode(_ snode: Legacy.Snode) { + private static func dropGuardSnode(_ snode: Snode) { #if DEBUG dispatchPrecondition(condition: .onQueue(Threading.workQueue)) #endif guardSnodes = guardSnodes.filter { $0 != snode } } - private static func drop(_ snode: Legacy.Snode) throws { + private static func drop(_ snode: Snode) throws { #if DEBUG dispatchPrecondition(condition: .onQueue(Threading.workQueue)) #endif @@ -244,13 +263,14 @@ public enum OnionRequestAPI { oldPaths.remove(at: pathIndex) let newPaths = oldPaths + [ path ] paths = newPaths - SNSnodeKitConfiguration.shared.storage.writeSync { transaction in + + GRDBStorage.shared.write { db in SNLog("Persisting onion request paths to database.") - SNSnodeKitConfiguration.shared.storage.setOnionRequestPaths(to: newPaths, using: transaction) + try? newPaths.save(db) } } - private static func drop(_ path: Path) { + private static func drop(_ path: [Snode]) { #if DEBUG dispatchPrecondition(condition: .onQueue(Threading.workQueue)) #endif @@ -259,56 +279,69 @@ public enum OnionRequestAPI { guard let pathIndex = paths.firstIndex(of: path) else { return } paths.remove(at: pathIndex) OnionRequestAPI.paths = paths - SNSnodeKitConfiguration.shared.storage.writeSync { transaction in - if !paths.isEmpty { - SNLog("Persisting onion request paths to database.") - SNSnodeKitConfiguration.shared.storage.setOnionRequestPaths(to: paths, using: transaction) - } else { + + GRDBStorage.shared.write { db in + guard !paths.isEmpty else { SNLog("Clearing onion request paths.") - SNSnodeKitConfiguration.shared.storage.setOnionRequestPaths(to: [], using: transaction) + try? SnodeSet.clearOnionRequestPaths(db) + return } + + SNLog("Persisting onion request paths to database.") + try? paths.save(db) } } /// Builds an onion around `payload` and returns the result. private static func buildOnion(around payload: JSON, targetedAt destination: Destination) -> Promise { - var guardSnode: Legacy.Snode! + var guardSnode: Snode! var targetSnodeSymmetricKey: Data! // Needed by invoke(_:on:with:) to decrypt the response sent back by the destination var encryptionResult: AESGCM.EncryptionResult! - var snodeToExclude: Legacy.Snode? + var snodeToExclude: Snode? + if case .snode(let snode) = destination { snodeToExclude = snode } - return getPath(excluding: snodeToExclude).then2 { path -> Promise in - guardSnode = path.first! - // Encrypt in reverse order, i.e. the destination first - return encrypt(payload, for: destination).then2 { r -> Promise in - targetSnodeSymmetricKey = r.symmetricKey - // Recursively encrypt the layers of the onion (again in reverse order) - encryptionResult = r - var path = path - var rhs = destination - func addLayer() -> Promise { - if path.isEmpty { - return Promise { $0.fulfill(encryptionResult) } - } else { - let lhs = Destination.snode(path.removeLast()) - return OnionRequestAPI.encryptHop(from: lhs, to: rhs, using: encryptionResult).then2 { r -> Promise in - encryptionResult = r - rhs = lhs - return addLayer() + + return getPath(excluding: snodeToExclude) + .then2 { path -> Promise in + guardSnode = path.first! + // Encrypt in reverse order, i.e. the destination first + return encrypt(payload, for: destination) + .then2 { r -> Promise in + targetSnodeSymmetricKey = r.symmetricKey + + // Recursively encrypt the layers of the onion (again in reverse order) + encryptionResult = r + var path = path + var rhs = destination + + func addLayer() -> Promise { + guard !path.isEmpty else { + return Promise { $0.fulfill(encryptionResult) } + } + + let lhs = Destination.snode(path.removeLast()) + return OnionRequestAPI + .encryptHop(from: lhs, to: rhs, using: encryptionResult) + .then2 { r -> Promise in + encryptionResult = r + rhs = lhs + return addLayer() + } } + + return addLayer() } - } - return addLayer() } - }.map2 { _ in (guardSnode, encryptionResult, targetSnodeSymmetricKey) } + .map2 { _ in (guardSnode, encryptionResult, targetSnodeSymmetricKey) } } // MARK: Public API /// Sends an onion request to `snode`. Builds new paths as needed. - public static func sendOnionRequest(to snode: Legacy.Snode, invoking method: Legacy.Snode.Method, with parameters: JSON, associatedWith publicKey: String? = nil) -> Promise { + public static func sendOnionRequest(to snode: Snode, invoking method: SnodeAPI.Endpoint, with parameters: JSON, associatedWith publicKey: String? = nil) -> Promise { let payload: JSON = [ "method" : method.rawValue, "params" : parameters ] return sendOnionRequest(with: payload, to: Destination.snode(snode)).recover2 { error -> Promise in guard case OnionRequestAPI.Error.httpRequestFailedAtDestination(let statusCode, let json, _) = error else { throw error } + throw SnodeAPI.handleError(withStatusCode: statusCode, json: json, forSnode: snode, associatedWith: publicKey) ?? error } } @@ -365,7 +398,7 @@ public enum OnionRequestAPI { public static func sendOnionRequest(with payload: JSON, to destination: Destination) -> Promise { let (promise, seal) = Promise.pending() - var guardSnode: Legacy.Snode? + var guardSnode: Snode? Threading.workQueue.async { // Avoid race conditions on `guardSnodes` and `paths` buildOnion(around: payload, targetedAt: destination).done2 { intermediate in guardSnode = intermediate.guardSnode @@ -442,7 +475,7 @@ public enum OnionRequestAPI { let prefix = "Next node not found: " if let message = json?["result"] as? String, message.hasPrefix(prefix) { let ed25519PublicKey = message[message.index(message.startIndex, offsetBy: prefix.count)..= snodeFailureThreshold { diff --git a/SessionSnodeKit/SnodeAPI.swift b/SessionSnodeKit/SnodeAPI.swift index acd3a19c9..83c088905 100644 --- a/SessionSnodeKit/SnodeAPI.swift +++ b/SessionSnodeKit/SnodeAPI.swift @@ -1,6 +1,7 @@ import PromiseKit -import SessionUtilitiesKit import Sodium +import GRDB +import SessionUtilitiesKit @objc(SNSnodeAPI) public final class SnodeAPI : NSObject { @@ -8,12 +9,12 @@ public final class SnodeAPI : NSObject { private static var hasLoadedSnodePool = false private static var loadedSwarms: Set = [] - private static var getSnodePoolPromise: Promise>? + private static var getSnodePoolPromise: Promise>? /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. - internal static var snodeFailureCount: [Legacy.Snode:UInt] = [:] + internal static var snodeFailureCount: [Snode: UInt] = [:] /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. - internal static var snodePool: Set = [] + internal static var snodePool: Set = [] /// The offset between the user's clock and the Service Node's clock. Used in cases where the /// user's clock is incorrect. @@ -21,7 +22,7 @@ public final class SnodeAPI : NSObject { /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. public static var clockOffset: Int64 = 0 /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. - public static var swarmCache: [String:Set] = [:] + public static var swarmCache: [String: Set] = [:] // MARK: Settings private static let maxRetryCount: UInt = 8 @@ -30,35 +31,6 @@ public final class SnodeAPI : NSObject { private static let snodeFailureThreshold = 3 private static let targetSwarmSnodeCount = 2 private static let minSnodePoolCount = 12 - - // MARK: Error - public enum Error : LocalizedError { - case generic - case clockOutOfSync - case snodePoolUpdatingFailed - case inconsistentSnodePools - case noKeyPair - case signingFailed - // ONS - case decryptionFailed - case hashingFailed - case validationFailed - - public var errorDescription: String? { - switch self { - case .generic: return "An error occurred." - case .clockOutOfSync: return "Your clock is out of sync with the Service Node network. Please check that your device's clock is set to automatic time." - case .snodePoolUpdatingFailed: return "Failed to update the Service Node pool." - case .inconsistentSnodePools: return "Received inconsistent Service Node pool information from the Service Node network." - case .noKeyPair: return "Missing user key pair." - case .signingFailed: return "Couldn't sign message." - // ONS - case .decryptionFailed: return "Couldn't decrypt ONS name." - case .hashingFailed: return "Couldn't compute ONS name hash." - case .validationFailed: return "ONS name validation failed." - } - } - } // MARK: Type Aliases public typealias MessageListPromise = Promise<[JSON]> @@ -68,23 +40,30 @@ public final class SnodeAPI : NSObject { // MARK: Snode Pool Interaction private static func loadSnodePoolIfNeeded() { guard !hasLoadedSnodePool else { return } - snodePool = SNSnodeKitConfiguration.shared.storage.getSnodePool() + + GRDBStorage.shared.read { db in + snodePool = ((try? Snode.fetchSet(db)) ?? Set()) + } + hasLoadedSnodePool = true } - private static func setSnodePool(to newValue: Set, using transaction: Any? = nil) { + private static func setSnodePool(to newValue: Set, db: Database? = nil) { snodePool = newValue - let storage = SNSnodeKitConfiguration.shared.storage - if let transaction = transaction { - storage.setSnodePool(to: newValue, using: transaction) - } else { - storage.writeSync { transaction in - storage.setSnodePool(to: newValue, using: transaction) + + if let db: Database = db { + _ = try? Snode.deleteAll(db) + newValue.forEach { try? $0.save(db) } + } + else { + GRDBStorage.shared.write { db in + _ = try? Snode.deleteAll(db) + newValue.forEach { try? $0.save(db) } } } } - private static func dropSnodeFromSnodePool(_ snode: Legacy.Snode) { + private static func dropSnodeFromSnodePool(_ snode: Snode) { #if DEBUG dispatchPrecondition(condition: .onQueue(Threading.workQueue)) #endif @@ -95,6 +74,7 @@ public final class SnodeAPI : NSObject { @objc public static func clearSnodePool() { snodePool.removeAll() + Threading.workQueue.async { setSnodePool(to: []) } @@ -103,22 +83,27 @@ public final class SnodeAPI : NSObject { // MARK: Swarm Interaction private static func loadSwarmIfNeeded(for publicKey: String) { guard !loadedSwarms.contains(publicKey) else { return } - swarmCache[publicKey] = SNSnodeKitConfiguration.shared.storage.getSwarm(for: publicKey) + + GRDBStorage.shared.read { db in + swarmCache[publicKey] = ((try? Snode.fetchSet(db, publicKey: publicKey)) ?? []) + } + loadedSwarms.insert(publicKey) } - private static func setSwarm(to newValue: Set, for publicKey: String, persist: Bool = true) { + private static func setSwarm(to newValue: Set, for publicKey: String, persist: Bool = true) { #if DEBUG dispatchPrecondition(condition: .onQueue(Threading.workQueue)) #endif swarmCache[publicKey] = newValue guard persist else { return } - SNSnodeKitConfiguration.shared.storage.writeSync { transaction in - SNSnodeKitConfiguration.shared.storage.setSwarm(to: newValue, for: publicKey, using: transaction) + + GRDBStorage.shared.write { db in + try? newValue.save(db, key: publicKey) } } - public static func dropSnodeFromSwarmIfNeeded(_ snode: Legacy.Snode, publicKey: String) { + public static func dropSnodeFromSwarmIfNeeded(_ snode: Snode, publicKey: String) { #if DEBUG dispatchPrecondition(condition: .onQueue(Threading.workQueue)) #endif @@ -129,19 +114,21 @@ public final class SnodeAPI : NSObject { } // MARK: Internal API - internal static func invoke(_ method: Legacy.Snode.Method, on snode: Legacy.Snode, associatedWith publicKey: String? = nil, parameters: JSON) -> RawResponsePromise { + internal static func invoke(_ method: Endpoint, on snode: Snode, associatedWith publicKey: String? = nil, parameters: JSON) -> RawResponsePromise { if Features.useOnionRequests { return OnionRequestAPI.sendOnionRequest(to: snode, invoking: method, with: parameters, associatedWith: publicKey).map2 { $0 as Any } - } else { + } + else { let url = "\(snode.address):\(snode.port)/storage_rpc/v1" return HTTP.execute(.post, url, parameters: parameters).map2 { $0 as Any }.recover2 { error -> Promise in guard case HTTP.Error.httpRequestFailed(let statusCode, let json) = error else { throw error } + throw SnodeAPI.handleError(withStatusCode: statusCode, json: json, forSnode: snode, associatedWith: publicKey) ?? error } } } - private static func getNetworkTime(from snode: Legacy.Snode) -> Promise { + private static func getNetworkTime(from snode: Snode) -> Promise { return invoke(.getInfo, on: snode, parameters: [:]).map2 { rawResponse in guard let json = rawResponse as? JSON, let timestamp = json["timestamp"] as? UInt64 else { throw HTTP.Error.invalidJSON } @@ -149,140 +136,165 @@ public final class SnodeAPI : NSObject { } } - internal static func getRandomSnode() -> Promise { + internal static func getRandomSnode() -> Promise { // randomElement() uses the system's default random generator, which is cryptographically secure return getSnodePool().map2 { $0.randomElement()! } } - private static func getSnodePoolFromSeedNode() -> Promise> { + private static func getSnodePoolFromSeedNode() -> Promise> { let target = seedNodePool.randomElement()! let url = "\(target)/json_rpc" let parameters: JSON = [ - "method" : "get_n_service_nodes", - "params" : [ - "active_only" : true, - "limit" : 256, - "fields" : [ - "public_ip" : true, "storage_port" : true, "pubkey_ed25519" : true, "pubkey_x25519" : true + "method": "get_n_service_nodes", + "params": [ + "active_only": true, + "limit": 256, + "fields": [ + "public_ip": true, + "storage_port": true, + "pubkey_ed25519": true, + "pubkey_x25519": true ] ] ] SNLog("Populating snode pool using seed node: \(target).") - let (promise, seal) = Promise>.pending() + let (promise, seal) = Promise>.pending() + Threading.workQueue.async { attempt(maxRetryCount: 4, recoveringOn: Threading.workQueue) { - HTTP.execute(.post, url, parameters: parameters, useSeedNodeURLSession: true).map2 { json -> Set in - guard let intermediate = json["result"] as? JSON, let rawSnodes = intermediate["service_node_states"] as? [JSON] else { throw Error.snodePoolUpdatingFailed } - return Set(rawSnodes.compactMap { rawSnode in - guard let address = rawSnode["public_ip"] as? String, let port = rawSnode["storage_port"] as? Int, - let ed25519PublicKey = rawSnode["pubkey_ed25519"] as? String, let x25519PublicKey = rawSnode["pubkey_x25519"] as? String, address != "0.0.0.0" else { - SNLog("Failed to parse snode from: \(rawSnode).") - return nil + HTTP.execute(.post, url, parameters: parameters, useSeedNodeURLSession: true) + .map2 { json -> Set in + guard + let intermediate = json["result"] as? JSON, + let rawSnodes = intermediate["service_node_states"] as? [JSON], + let snodeData: Data = try? JSONSerialization.data(withJSONObject: rawSnodes, options: []) + else { + throw Error.snodePoolUpdatingFailed } - return Legacy.Snode(address: "https://\(address)", port: UInt16(port), publicKeySet: Legacy.Snode.KeySet(ed25519Key: ed25519PublicKey, x25519Key: x25519PublicKey)) - }) - } - }.done2 { snodePool in + + return ((try? JSONDecoder().decode([Failable].self, from: snodeData)) ?? []) + .compactMap { $0.value } + .asSet() + } + } + .done2 { snodePool in SNLog("Got snode pool from seed node: \(target).") seal.fulfill(snodePool) - }.catch2 { error in + } + .catch2 { error in SNLog("Failed to contact seed node at: \(target).") seal.reject(error) } } + return promise } - private static func getSnodePoolFromSnode() -> Promise> { + private static func getSnodePoolFromSnode() -> Promise> { var snodePool = SnodeAPI.snodePool - var snodes: Set = [] + var snodes: Set = [] (0..<3).forEach { _ in - let snode = snodePool.randomElement()! + guard let snode = snodePool.randomElement() else { return } + snodePool.remove(snode) snodes.insert(snode) } - let snodePoolPromises: [Promise>] = snodes.map { snode in + + let snodePoolPromises: [Promise>] = snodes.map { snode in return attempt(maxRetryCount: 4, recoveringOn: Threading.workQueue) { // Don't specify a limit in the request. Service nodes return a shuffled // list of nodes so if we specify a limit the 3 responses we get might have // very little overlap. let parameters: JSON = [ - "endpoint" : "get_service_nodes", - "params" : [ - "active_only" : true, - "fields" : [ - "public_ip" : true, "storage_port" : true, "pubkey_ed25519" : true, "pubkey_x25519" : true + "endpoint": "get_service_nodes", + "params": [ + "active_only": true, + "fields": [ + "public_ip": true, + "storage_port": true, + "pubkey_ed25519": true, + "pubkey_x25519": true ] ] ] return invoke(.oxenDaemonRPCCall, on: snode, parameters: parameters).map2 { rawResponse in - guard let json = rawResponse as? JSON, let intermediate = json["result"] as? JSON, - let rawSnodes = intermediate["service_node_states"] as? [JSON] else { + guard + let json = rawResponse as? JSON, + let intermediate = json["result"] as? JSON, + let rawSnodes = intermediate["service_node_states"] as? [JSON], + let snodeData: Data = try? JSONSerialization.data(withJSONObject: rawSnodes, options: []) + else { throw Error.snodePoolUpdatingFailed } - return Set(rawSnodes.compactMap { rawSnode in - guard let address = rawSnode["public_ip"] as? String, let port = rawSnode["storage_port"] as? Int, - let ed25519PublicKey = rawSnode["pubkey_ed25519"] as? String, let x25519PublicKey = rawSnode["pubkey_x25519"] as? String, address != "0.0.0.0" else { - SNLog("Failed to parse snode from: \(rawSnode).") - return nil - } - return Legacy.Snode(address: "https://\(address)", port: UInt16(port), publicKeySet: Legacy.Snode.KeySet(ed25519Key: ed25519PublicKey, x25519Key: x25519PublicKey)) - }) + + return ((try? JSONDecoder().decode([Failable].self, from: snodeData)) ?? []) + .compactMap { $0.value } + .asSet() } } } - let promise = when(fulfilled: snodePoolPromises).map2 { results -> Set in - var result: Set = results[0] - results.forEach { result = result.intersection($0) } - if result.count > 24 { // We want the snodes to agree on at least this many snodes - // Limit the snode pool size to 256 so that we don't go too long without - // refreshing it - return (result.count > 256) ? Set([Legacy.Snode](result)[0..<256]) : result - } else { - throw Error.inconsistentSnodePools - } + + let promise = when(fulfilled: snodePoolPromises).map2 { results -> Set in + let result: Set = results.reduce(Set()) { prev, next in prev.intersection(next) } + + // We want the snodes to agree on at least this many snodes + guard result.count > 24 else { throw Error.inconsistentSnodePools } + + // Limit the snode pool size to 256 so that we don't go too long without + // refreshing it + return Set(result.prefix(256)) } + return promise } // MARK: Public API + @objc(getSnodePool) public static func objc_getSnodePool() -> AnyPromise { AnyPromise.from(getSnodePool()) } - public static func getSnodePool() -> Promise> { + public static func getSnodePool() -> Promise> { loadSnodePoolIfNeeded() let now = Date() - let hasSnodePoolExpired = given(Storage.shared.getLastSnodePoolRefreshDate()) { now.timeIntervalSince($0) > 2 * 60 * 60 } ?? true + let hasSnodePoolExpired = given(GRDBStorage.shared[.lastSnodePoolRefreshDate]) { now.timeIntervalSince($0) > 2 * 60 * 60 } ?? true let snodePool = SnodeAPI.snodePool let hasInsufficientSnodes = (snodePool.count < minSnodePoolCount) + if hasInsufficientSnodes || hasSnodePoolExpired { if let getSnodePoolPromise = getSnodePoolPromise { return getSnodePoolPromise } - let promise: Promise> + + let promise: Promise> if snodePool.count < minSnodePoolCount { promise = getSnodePoolFromSeedNode() - } else { + } + else { promise = getSnodePoolFromSnode().recover2 { _ in getSnodePoolFromSeedNode() } } + getSnodePoolPromise = promise - promise.map2 { snodePool -> Set in - if snodePool.isEmpty { - throw Error.snodePoolUpdatingFailed - } else { - return snodePool - } + promise.map2 { snodePool -> Set in + guard !snodePool.isEmpty else { throw Error.snodePoolUpdatingFailed } + + return snodePool } - promise.then2 { snodePool -> Promise> in - let (promise, seal) = Promise>.pending() - SNSnodeKitConfiguration.shared.storage.write(with: { transaction in - Storage.shared.setLastSnodePoolRefreshDate(to: now, using: transaction) - setSnodePool(to: snodePool, using: transaction) - }, completion: { - seal.fulfill(snodePool) - }) + + promise.then2 { snodePool -> Promise> in + let (promise, seal) = Promise>.pending() + + GRDBStorage.shared.writeAsync( + updates: { db in + db[.lastSnodePoolRefreshDate] = now + setSnodePool(to: snodePool, db: db) + }, + completion: { _, _ in + seal.fulfill(snodePool) + } + ) + return promise } promise.done2 { _ in @@ -291,10 +303,11 @@ public final class SnodeAPI : NSObject { promise.catch2 { _ in getSnodePoolPromise = nil } + return promise - } else { - return Promise.value(snodePool) } + + return Promise.value(snodePool) } public static func getSessionID(for onsName: String) -> Promise { @@ -366,49 +379,55 @@ public final class SnodeAPI : NSObject { return promise } - public static func getTargetSnodes(for publicKey: String) -> Promise<[Legacy.Snode]> { + public static func getTargetSnodes(for publicKey: String) -> Promise<[Snode]> { // shuffled() uses the system's default random generator, which is cryptographically secure return getSwarm(for: publicKey).map2 { Array($0.shuffled().prefix(targetSwarmSnodeCount)) } } - public static func getSwarm(for publicKey: String) -> Promise> { + public static func getSwarm(for publicKey: String) -> Promise> { loadSwarmIfNeeded(for: publicKey) + if let cachedSwarm = swarmCache[publicKey], cachedSwarm.count >= minSwarmSnodeCount { - return Promise> { $0.fulfill(cachedSwarm) } - } else { - SNLog("Getting swarm for: \((publicKey == SNSnodeKitConfiguration.shared.storage.getUserPublicKey()) ? "self" : publicKey).") - let parameters: [String:Any] = [ "pubKey" : Features.useTestnet ? publicKey.removing05PrefixIfNeeded() : publicKey ] - return getRandomSnode().then2 { snode in + return Promise> { $0.fulfill(cachedSwarm) } + } + + SNLog("Getting swarm for: \((publicKey == getUserHexEncodedPublicKey()) ? "self" : publicKey).") + let parameters: [String: Any] = [ + "pubKey": Features.useTestnet ? publicKey.removing05PrefixIfNeeded() : publicKey + ] + + return getRandomSnode() + .then2 { snode in attempt(maxRetryCount: 4, recoveringOn: Threading.workQueue) { invoke(.getSwarm, on: snode, associatedWith: publicKey, parameters: parameters) } - }.map2 { rawSnodes in + } + .map2 { rawSnodes in let swarm = parseSnodes(from: rawSnodes) setSwarm(to: swarm, for: publicKey) return swarm } - } } - public static func getRawMessages(from snode: Legacy.Snode, associatedWith publicKey: String) -> RawResponsePromise { + public static func getRawMessages(from snode: Snode, associatedWith publicKey: String) -> RawResponsePromise { let (promise, seal) = RawResponsePromise.pending() Threading.workQueue.async { - getMessagesInternal(from: snode, associatedWith: publicKey).done2 { seal.fulfill($0) }.catch2 { seal.reject($0) } + getMessagesInternal(from: snode, associatedWith: publicKey) + .done2 { seal.fulfill($0) } + .catch2 { seal.reject($0) } } return promise } - private static func getMessagesInternal(from snode: Legacy.Snode, associatedWith publicKey: String) -> RawResponsePromise { - let storage = SNSnodeKitConfiguration.shared.storage - + private static func getMessagesInternal(from snode: Snode, associatedWith publicKey: String) -> RawResponsePromise { // NOTE: All authentication logic is currently commented out, the reason being that we can't currently support // it yet for closed groups. The Storage Server requires an ed25519 key pair, but we don't have that for our // closed groups. // guard let userED25519KeyPair = storage.getUserED25519KeyPair() else { return Promise(error: Error.noKeyPair) } // Get last message hash - storage.pruneLastMessageHashInfoIfExpired(for: snode, associatedWith: publicKey) - let lastHash = storage.getLastMessageHash(for: snode, associatedWith: publicKey) ?? "" + SnodeReceivedMessageInfo.pruneLastMessageHashInfoIfExpired(for: snode, associatedWith: publicKey) + let lastHash = SnodeReceivedMessageInfo.fetchLastNotExpired(for: snode, associatedWith: publicKey)?.hash ?? "" // Construct signature // let timestamp = UInt64(Int64(NSDate.millisecondTimestamp()) + SnodeAPI.clockOffset) // let ed25519PublicKey = userED25519KeyPair.publicKey.toHexString() @@ -427,17 +446,28 @@ public final class SnodeAPI : NSObject { public static func sendMessage(_ message: SnodeMessage) -> Promise> { let (promise, seal) = Promise>.pending() - let publicKey = Features.useTestnet ? message.recipient.removing05PrefixIfNeeded() : message.recipient + let publicKey = (Features.useTestnet ? + message.recipient.removing05PrefixIfNeeded() : + message.recipient + ) + Threading.workQueue.async { - getTargetSnodes(for: publicKey).map2 { targetSnodes in - let parameters = message.toJSON() - return Set(targetSnodes.map { targetSnode in - attempt(maxRetryCount: maxRetryCount, recoveringOn: Threading.workQueue) { - invoke(.sendMessage, on: targetSnode, associatedWith: publicKey, parameters: parameters) - } - }) - }.done2 { seal.fulfill($0) }.catch2 { seal.reject($0) } + getTargetSnodes(for: publicKey) + .map2 { targetSnodes in + let parameters = message.toJSON() + + return targetSnodes + .map { targetSnode in + attempt(maxRetryCount: maxRetryCount, recoveringOn: Threading.workQueue) { + invoke(.sendMessage, on: targetSnode, associatedWith: publicKey, parameters: parameters) + } + } + .asSet() + } + .done2 { seal.fulfill($0) } + .catch2 { seal.reject($0) } } + return promise } @@ -446,75 +476,123 @@ public final class SnodeAPI : NSObject { AnyPromise.from(deleteMessage(publicKey: publicKey, serverHashes: serverHashes)) } - public static func deleteMessage(publicKey: String, serverHashes: [String]) -> Promise<[String:Bool]> { - let storage = SNSnodeKitConfiguration.shared.storage - guard let userX25519PublicKey = storage.getUserPublicKey(), - let userED25519KeyPair = storage.getUserED25519KeyPair() else { return Promise(error: Error.noKeyPair) } - let publicKey = Features.useTestnet ? publicKey.removing05PrefixIfNeeded() : publicKey + public static func deleteMessage(publicKey: String, serverHashes: [String]) -> Promise<[String: Bool]> { + guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { + return Promise(error: Error.noKeyPair) + } + + let publicKey = (Features.useTestnet ? publicKey.removing05PrefixIfNeeded() : publicKey) + let userX25519PublicKey: String = getUserHexEncodedPublicKey() + return attempt(maxRetryCount: maxRetryCount, recoveringOn: Threading.workQueue) { - getSwarm(for: publicKey).then2 { swarm -> Promise<[String:Bool]> in - let snode = swarm.randomElement()! - let verificationData = (Legacy.Snode.Method.deleteMessage.rawValue + serverHashes.joined(separator: "")).data(using: String.Encoding.utf8)! - guard let signature = sodium.sign.signature(message: Bytes(verificationData), secretKey: userED25519KeyPair.secretKey) else { throw Error.signingFailed } - let parameters: JSON = [ - "pubkey" : userX25519PublicKey, - "pubkey_ed25519" : userED25519KeyPair.publicKey.toHexString(), - "messages": serverHashes, - "signature": signature.toBase64() - ] - return attempt(maxRetryCount: maxRetryCount, recoveringOn: Threading.workQueue) { - invoke(.deleteMessage, on: snode, associatedWith: publicKey, parameters: parameters).map2{ rawResponse -> [String:Bool] in - guard let json = rawResponse as? JSON, let swarm = json["swarm"] as? JSON else { throw HTTP.Error.invalidJSON } - var result: [String:Bool] = [:] - for (snodePublicKey, rawJSON) in swarm { - guard let json = rawJSON as? JSON else { throw HTTP.Error.invalidJSON } - let isFailed = json["failed"] as? Bool ?? false - if !isFailed { - guard let hashes = json["deleted"] as? [String], let signature = json["signature"] as? String else { throw HTTP.Error.invalidJSON } - // The signature format is ( PUBKEY_HEX || RMSG[0] || ... || RMSG[N] || DMSG[0] || ... || DMSG[M] ) - let verificationData = (userX25519PublicKey + serverHashes.joined(separator: "") + hashes.joined(separator: "")).data(using: String.Encoding.utf8)! - let isValid = sodium.sign.verify(message: Bytes(verificationData), publicKey: Bytes(Data(hex: snodePublicKey)), signature: Bytes(Data(base64Encoded: signature)!)) - result[snodePublicKey] = isValid - } else { - if let reason = json["reason"] as? String, let statusCode = json["code"] as? String { - SNLog("Couldn't delete data from: \(snodePublicKey) due to error: \(reason) (\(statusCode)).") - } else { - SNLog("Couldn't delete data from: \(snodePublicKey).") - } - result[snodePublicKey] = false + getSwarm(for: publicKey) + .then2 { swarm -> Promise<[String: Bool]> in + guard + let snode = swarm.randomElement(), + let verificationData = (Endpoint.deleteMessage.rawValue + serverHashes.joined()).data(using: String.Encoding.utf8), + let signature = sodium.sign.signature(message: Bytes(verificationData), secretKey: userED25519KeyPair.secretKey) + else { + throw Error.signingFailed + } + + let parameters: JSON = [ + "pubkey" : userX25519PublicKey, + "pubkey_ed25519" : userED25519KeyPair.publicKey.toHexString(), + "messages": serverHashes, + "signature": signature.toBase64() + ] + + return attempt(maxRetryCount: maxRetryCount, recoveringOn: Threading.workQueue) { + invoke(.deleteMessage, on: snode, associatedWith: publicKey, parameters: parameters) + .map2 { rawResponse -> [String: Bool] in + guard let json = rawResponse as? JSON, let swarm = json["swarm"] as? JSON else { + throw HTTP.Error.invalidJSON } + + var result: [String: Bool] = [:] + + for (snodePublicKey, rawJSON) in swarm { + guard let json = rawJSON as? JSON else { throw HTTP.Error.invalidJSON } + + let isFailed = (json["failed"] as? Bool ?? false) + + if !isFailed { + guard + let hashes = json["deleted"] as? [String], + let signature = json["signature"] as? String + else { + throw HTTP.Error.invalidJSON + } + + // The signature format is ( PUBKEY_HEX || RMSG[0] || ... || RMSG[N] || DMSG[0] || ... || DMSG[M] ) + let verificationData = [ + userX25519PublicKey, + serverHashes.joined(), + hashes.joined() + ] + .joined() + .data(using: String.Encoding.utf8)! + let isValid = sodium.sign.verify( + message: Bytes(verificationData), + publicKey: Bytes(Data(hex: snodePublicKey)), + signature: Bytes(Data(base64Encoded: signature)!) + ) + result[snodePublicKey] = isValid + } + else { + if let reason = json["reason"] as? String, let statusCode = json["code"] as? String { + SNLog("Couldn't delete data from: \(snodePublicKey) due to error: \(reason) (\(statusCode)).") + } else { + SNLog("Couldn't delete data from: \(snodePublicKey).") + } + result[snodePublicKey] = false + } + } + + return result } - return result } } - } } } /// Clears all the user's data from their swarm. Returns a dictionary of snode public key to deletion confirmation. public static func clearAllData() -> Promise<[String:Bool]> { - let storage = SNSnodeKitConfiguration.shared.storage - guard let userX25519PublicKey = storage.getUserPublicKey(), - let userED25519KeyPair = storage.getUserED25519KeyPair() else { return Promise(error: Error.noKeyPair) } + guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { + return Promise(error: Error.noKeyPair) + } + + let userX25519PublicKey: String = getUserHexEncodedPublicKey() + return attempt(maxRetryCount: maxRetryCount, recoveringOn: Threading.workQueue) { getSwarm(for: userX25519PublicKey).then2 { swarm -> Promise<[String:Bool]> in let snode = swarm.randomElement()! return attempt(maxRetryCount: maxRetryCount, recoveringOn: Threading.workQueue) { getNetworkTime(from: snode).then2 { timestamp -> Promise<[String:Bool]> in - let verificationData = (Legacy.Snode.Method.clearAllData.rawValue + String(timestamp)).data(using: String.Encoding.utf8)! - guard let signature = sodium.sign.signature(message: Bytes(verificationData), secretKey: userED25519KeyPair.secretKey) else { throw Error.signingFailed } + let verificationData = (Endpoint.clearAllData.rawValue + String(timestamp)).data(using: String.Encoding.utf8)! + guard let signature = sodium.sign.signature(message: Bytes(verificationData), secretKey: userED25519KeyPair.secretKey) else { + throw Error.signingFailed + } + let parameters: JSON = [ - "pubkey" : userX25519PublicKey, - "pubkey_ed25519" : userED25519KeyPair.publicKey.toHexString(), - "timestamp" : timestamp, - "signature" : signature.toBase64() + "pubkey": userX25519PublicKey, + "pubkey_ed25519": userED25519KeyPair.publicKey.toHexString(), + "timestamp": timestamp, + "signature": signature.toBase64() ] + return attempt(maxRetryCount: maxRetryCount, recoveringOn: Threading.workQueue) { - invoke(.clearAllData, on: snode, parameters: parameters).map2 { rawResponse -> [String:Bool] in - guard let json = rawResponse as? JSON, let swarm = json["swarm"] as? JSON else { throw HTTP.Error.invalidJSON } - var result: [String:Bool] = [:] + invoke(.clearAllData, on: snode, parameters: parameters).map2 { rawResponse -> [String: Bool] in + guard + let json = rawResponse as? JSON, + let swarm = json["swarm"] as? JSON + else { throw HTTP.Error.invalidJSON } + + var result: [String: Bool] = [:] + for (snodePublicKey, rawJSON) in swarm { guard let json = rawJSON as? JSON else { throw HTTP.Error.invalidJSON } + let isFailed = json["failed"] as? Bool ?? false if !isFailed { guard let hashes = json["deleted"] as? [String], let signature = json["signature"] as? String else { throw HTTP.Error.invalidJSON } @@ -531,6 +609,7 @@ public final class SnodeAPI : NSObject { result[snodePublicKey] = false } } + return result } } @@ -544,66 +623,73 @@ public final class SnodeAPI : NSObject { // The parsing utilities below use a best attempt approach to parsing; they warn for parsing failures but don't throw exceptions. - private static func parseSnodes(from rawResponse: Any) -> Set { + private static func parseSnodes(from rawResponse: Any) -> Set { guard let json = rawResponse as? JSON, let rawSnodes = json["snodes"] as? [JSON] else { SNLog("Failed to parse snodes from: \(rawResponse).") return [] } - return Set(rawSnodes.compactMap { rawSnode in - guard let address = rawSnode["ip"] as? String, let portAsString = rawSnode["port"] as? String, let port = UInt16(portAsString), - let ed25519PublicKey = rawSnode["pubkey_ed25519"] as? String, let x25519PublicKey = rawSnode["pubkey_x25519"] as? String, address != "0.0.0.0" else { - SNLog("Failed to parse snode from: \(rawSnode).") - return nil - } - return Legacy.Snode(address: "https://\(address)", port: port, publicKeySet: Legacy.Snode.KeySet(ed25519Key: ed25519PublicKey, x25519Key: x25519PublicKey)) - }) - } - - public static func parseRawMessagesResponse(_ rawResponse: Any, from snode: Legacy.Snode, associatedWith publicKey: String) -> (messages: [JSON], lastRawMessage: JSON?) { - guard let json = rawResponse as? JSON, let rawMessages = json["messages"] as? [JSON] else { return ([], nil) } - - return ( - removeDuplicates(from: rawMessages, associatedWith: publicKey), - rawMessages.last - ) - } - - public static func updateLastMessageHashValueIfPossible(for snode: Legacy.Snode, associatedWith publicKey: String, from lastRawMessage: JSON?) { - if let lastMessage = lastRawMessage, let lastHash = lastMessage["hash"] as? String, let expirationDate = lastMessage["expiration"] as? UInt64 { - SNSnodeKitConfiguration.shared.storage.writeSync { transaction in - SNSnodeKitConfiguration.shared.storage.setLastMessageHashInfo(for: snode, associatedWith: publicKey, - to: [ "hash" : lastHash, "expirationDate" : NSNumber(value: expirationDate) ], using: transaction) - } - } else if (lastRawMessage != nil) { - SNLog("Failed to update last message hash value from: \(String(describing: lastRawMessage)).") + + guard let snodeData: Data = try? JSONSerialization.data(withJSONObject: rawSnodes, options: []) else { + return [] } + + // FIXME: Hopefully at some point this different Snode structure will be deprecated and can be removed + if + let swarmSnodes: [SwarmSnode] = try? JSONDecoder().decode([Failable].self, from: snodeData).compactMap({ $0.value }), + !swarmSnodes.isEmpty + { + return swarmSnodes.map { $0.toSnode() }.asSet() + } + + return ((try? JSONDecoder().decode([Failable].self, from: snodeData)) ?? []) + .compactMap { $0.value } + .asSet() + } + + public static func parseRawMessagesResponse(_ rawResponse: Any, from snode: Snode, associatedWith publicKey: String) -> [SnodeReceivedMessage] { + guard let json = rawResponse as? JSON, let rawMessages = json["messages"] as? [JSON] else { + return [] + } + + return removeDuplicates(from: rawMessages, associatedWith: publicKey) + .compactMap { rawMessage -> SnodeReceivedMessage? in + return SnodeReceivedMessage( + snode: snode, + publicKey: publicKey, + rawMessage: rawMessage + ) + } } private static func removeDuplicates(from rawMessages: [JSON], associatedWith publicKey: String) -> [JSON] { - let oldReceivedMessages = SNSnodeKitConfiguration.shared.storage.getReceivedMessages(for: publicKey) - var newReceivedMessages = oldReceivedMessages - let result = rawMessages.filter { rawMessage in - guard let hash = rawMessage["hash"] as? String else { - SNLog("Missing hash value for message: \(rawMessage).") - return false - } - let isDuplicate = newReceivedMessages.contains(hash) - newReceivedMessages.insert(hash) - return !isDuplicate + var oldReceivedMessages: [SnodeReceivedMessageInfo] = [] + + GRDBStorage.shared.read { db in + oldReceivedMessages = oldReceivedMessages.appending( + contentsOf: try? SnodeReceivedMessageInfo + .filter(SnodeReceivedMessageInfo.Columns.key.like("%\(publicKey)")) + .fetchAll(db) + ) } - // Avoid the sync write transaction if possible - if oldReceivedMessages != newReceivedMessages { - SNSnodeKitConfiguration.shared.storage.writeSync { transaction in - SNSnodeKitConfiguration.shared.storage.setReceivedMessages(to: newReceivedMessages, for: publicKey, using: transaction) + + let oldMessageHashes: Set = oldReceivedMessages.map { $0.hash }.asSet() + + return rawMessages + .compactMap { rawMessage -> JSON? in + guard let hash: String = rawMessage["hash"] as? String else { + SNLog("Missing hash value for message: \(rawMessage).") + return nil + } + guard !oldMessageHashes.contains(hash) else { return nil } + + return rawMessage } - } - return result } // MARK: Error Handling /// - Note: Should only be invoked from `Threading.workQueue` to avoid race conditions. @discardableResult - internal static func handleError(withStatusCode statusCode: UInt, json: JSON?, forSnode snode: Legacy.Snode, associatedWith publicKey: String? = nil) -> Error? { + internal static func handleError(withStatusCode statusCode: UInt, json: JSON?, forSnode snode: Snode, associatedWith publicKey: String? = nil) -> Error? { #if DEBUG dispatchPrecondition(condition: .onQueue(Threading.workQueue)) #endif diff --git a/SessionSnodeKit/Storage+OnionRequests.swift b/SessionSnodeKit/Storage+OnionRequests.swift deleted file mode 100644 index f99b3c6ff..000000000 --- a/SessionSnodeKit/Storage+OnionRequests.swift +++ /dev/null @@ -1,49 +0,0 @@ -import SessionUtilitiesKit - -extension Storage { - - private static let onionRequestPathCollection = "LokiOnionRequestPathCollection" - - public func getOnionRequestPaths() -> [OnionRequestAPI.Path] { - let collection = Storage.onionRequestPathCollection - var result: [OnionRequestAPI.Path] = [] - Storage.read { transaction in - if - let path0Snode0 = transaction.object(forKey: "0-0", inCollection: collection) as? Legacy.Snode, - let path0Snode1 = transaction.object(forKey: "0-1", inCollection: collection) as? Legacy.Snode, - let path0Snode2 = transaction.object(forKey: "0-2", inCollection: collection) as? Legacy.Snode { - result.append([ path0Snode0, path0Snode1, path0Snode2 ]) - if - let path1Snode0 = transaction.object(forKey: "1-0", inCollection: collection) as? Legacy.Snode, - let path1Snode1 = transaction.object(forKey: "1-1", inCollection: collection) as? Legacy.Snode, - let path1Snode2 = transaction.object(forKey: "1-2", inCollection: collection) as? Legacy.Snode { - result.append([ path1Snode0, path1Snode1, path1Snode2 ]) - } - } - } - return result - } - - public func setOnionRequestPaths(to paths: [OnionRequestAPI.Path], using transaction: Any) { - let collection = Storage.onionRequestPathCollection - // FIXME: This approach assumes either 1 or 2 paths of length 3 each. We should do better than this. - clearOnionRequestPaths(using: transaction) - guard let transaction = transaction as? YapDatabaseReadWriteTransaction else { return } - guard paths.count >= 1 else { return } - let path0 = paths[0] - guard path0.count == 3 else { return } - transaction.setObject(path0[0], forKey: "0-0", inCollection: collection) - transaction.setObject(path0[1], forKey: "0-1", inCollection: collection) - transaction.setObject(path0[2], forKey: "0-2", inCollection: collection) - guard paths.count >= 2 else { return } - let path1 = paths[1] - guard path1.count == 3 else { return } - transaction.setObject(path1[0], forKey: "1-0", inCollection: collection) - transaction.setObject(path1[1], forKey: "1-1", inCollection: collection) - transaction.setObject(path1[2], forKey: "1-2", inCollection: collection) - } - - func clearOnionRequestPaths(using transaction: Any) { - (transaction as! YapDatabaseReadWriteTransaction).removeAllObjects(inCollection: Storage.onionRequestPathCollection) - } -} diff --git a/SessionSnodeKit/Storage+SnodeAPI.swift b/SessionSnodeKit/Storage+SnodeAPI.swift deleted file mode 100644 index 71df7c0a2..000000000 --- a/SessionSnodeKit/Storage+SnodeAPI.swift +++ /dev/null @@ -1,140 +0,0 @@ -import SessionUtilitiesKit - -extension Storage { - - // MARK: - Snode Pool - - private static let snodePoolCollection = "LokiSnodePoolCollection" - private static let lastSnodePoolRefreshDateCollection = "LokiLastSnodePoolRefreshDateCollection" - - public func getSnodePool() -> Set { - var result: Set = [] - Storage.read { transaction in - transaction.enumerateKeysAndObjects(inCollection: Storage.snodePoolCollection) { _, object, _ in - guard let snode = object as? Legacy.Snode else { return } - result.insert(snode) - } - } - return result - } - - public func setSnodePool(to snodePool: Set, using transaction: Any) { - clearSnodePool(in: transaction) - snodePool.forEach { snode in - (transaction as! YapDatabaseReadWriteTransaction).setObject(snode, forKey: snode.description, inCollection: Storage.snodePoolCollection) - } - } - - public func clearSnodePool(in transaction: Any) { - (transaction as! YapDatabaseReadWriteTransaction).removeAllObjects(inCollection: Storage.snodePoolCollection) - } - - public func getLastSnodePoolRefreshDate() -> Date? { - var result: Date? - Storage.read { transaction in - result = transaction.object(forKey: "lastSnodePoolRefreshDate", inCollection: Storage.lastSnodePoolRefreshDateCollection) as? Date - } - return result - } - - public func setLastSnodePoolRefreshDate(to date: Date, using transaction: Any) { - (transaction as! YapDatabaseReadWriteTransaction).setObject(date, forKey: "lastSnodePoolRefreshDate", inCollection: Storage.lastSnodePoolRefreshDateCollection) - } - - - - // MARK: - Swarm - - private static func getSwarmCollection(for publicKey: String) -> String { - return "LokiSwarmCollection-\(publicKey)" - } - - public func getSwarm(for publicKey: String) -> Set { - var result: Set = [] - let collection = Storage.getSwarmCollection(for: publicKey) - Storage.read { transaction in - transaction.enumerateKeysAndObjects(inCollection: collection) { _, object, _ in - guard let snode = object as? Legacy.Snode else { return } - result.insert(snode) - } - } - return result - } - - public func setSwarm(to swarm: Set, for publicKey: String, using transaction: Any) { - clearSwarm(for: publicKey, in: transaction) - let tmp = getSnodePool() - let collection = Storage.getSwarmCollection(for: publicKey) - swarm.forEach { snode in - (transaction as! YapDatabaseReadWriteTransaction).setObject(snode, forKey: snode.description, inCollection: collection) - } - } - - public func clearSwarm(for publicKey: String, in transaction: Any) { - let collection = Storage.getSwarmCollection(for: publicKey) - (transaction as! YapDatabaseReadWriteTransaction).removeAllObjects(inCollection: collection) - } - - - - // MARK: - Last Message Hash - - private static let lastMessageHashCollection = "LokiLastMessageHashCollection" - - public func getLastMessageHashInfo(for snode: Legacy.Snode, associatedWith publicKey: String) -> JSON? { - let key = "\(snode.address):\(snode.port).\(publicKey)" - var result: JSON? - Storage.read { transaction in - result = transaction.object(forKey: key, inCollection: Storage.lastMessageHashCollection) as? JSON - } - if let result = result { - guard result["hash"] as? String != nil else { return nil } - guard result["expirationDate"] as? NSNumber != nil else { return nil } - } - return result - } - - public func getLastMessageHash(for snode: Legacy.Snode, associatedWith publicKey: String) -> String? { - return getLastMessageHashInfo(for: snode, associatedWith: publicKey)?["hash"] as? String - } - - public func setLastMessageHashInfo(for snode: Legacy.Snode, associatedWith publicKey: String, to lastMessageHashInfo: JSON, using transaction: Any) { - let key = "\(snode.address):\(snode.port).\(publicKey)" - guard lastMessageHashInfo.count == 2 && lastMessageHashInfo["hash"] as? String != nil && lastMessageHashInfo["expirationDate"] as? NSNumber != nil else { return } - (transaction as! YapDatabaseReadWriteTransaction).setObject(lastMessageHashInfo, forKey: key, inCollection: Storage.lastMessageHashCollection) - } - - public func pruneLastMessageHashInfoIfExpired(for snode: Legacy.Snode, associatedWith publicKey: String) { - guard let lastMessageHashInfo = getLastMessageHashInfo(for: snode, associatedWith: publicKey), - (lastMessageHashInfo["hash"] as? String) != nil, let expirationDate = (lastMessageHashInfo["expirationDate"] as? NSNumber)?.uint64Value else { return } - let now = NSDate.millisecondTimestamp() - if now >= expirationDate { - Storage.writeSync { transaction in - self.removeLastMessageHashInfo(for: snode, associatedWith: publicKey, using: transaction) - } - } - } - - public func removeLastMessageHashInfo(for snode: Legacy.Snode, associatedWith publicKey: String, using transaction: Any) { - let key = "\(snode.address):\(snode.port).\(publicKey)" - (transaction as! YapDatabaseReadWriteTransaction).removeObject(forKey: key, inCollection: Storage.lastMessageHashCollection) - } - - - - // MARK: - Received Messages - - private static let receivedMessagesCollection = "LokiReceivedMessagesCollection" - - public func getReceivedMessages(for publicKey: String) -> Set { - var result: Set? - Storage.read { transaction in - result = transaction.object(forKey: publicKey, inCollection: Storage.receivedMessagesCollection) as? Set - } - return result ?? [] - } - - public func setReceivedMessages(to receivedMessages: Set, for publicKey: String, using transaction: Any) { - (transaction as! YapDatabaseReadWriteTransaction).setObject(receivedMessages, forKey: publicKey, inCollection: Storage.receivedMessagesCollection) - } -} diff --git a/SessionSnodeKit/Storage.swift b/SessionSnodeKit/Storage.swift deleted file mode 100644 index 7ea5e0d3d..000000000 --- a/SessionSnodeKit/Storage.swift +++ /dev/null @@ -1,28 +0,0 @@ -import SessionUtilitiesKit -import PromiseKit -import Sodium - -public protocol SessionSnodeKitStorageProtocol { - - @discardableResult - func write(with block: @escaping (Any) -> Void) -> Promise - @discardableResult - func write(with block: @escaping (Any) -> Void, completion: @escaping () -> Void) -> Promise - func writeSync(with block: @escaping (Any) -> Void) - - func getUserPublicKey() -> String? - func getUserED25519KeyPair() -> Box.KeyPair? - func getOnionRequestPaths() -> [OnionRequestAPI.Path] - func setOnionRequestPaths(to paths: [OnionRequestAPI.Path], using transaction: Any) - func getSnodePool() -> Set - func setSnodePool(to snodePool: Set, using transaction: Any) - func getLastSnodePoolRefreshDate() -> Date? - func setLastSnodePoolRefreshDate(to date: Date, using transaction: Any) - func getSwarm(for publicKey: String) -> Set - func setSwarm(to swarm: Set, for publicKey: String, using transaction: Any) - func getLastMessageHash(for snode: Legacy.Snode, associatedWith publicKey: String) -> String? - func setLastMessageHashInfo(for snode: Legacy.Snode, associatedWith publicKey: String, to lastMessageHashInfo: JSON, using transaction: Any) - func pruneLastMessageHashInfoIfExpired(for snode: Legacy.Snode, associatedWith publicKey: String) - func getReceivedMessages(for publicKey: String) -> Set - func setReceivedMessages(to receivedMessages: Set, for publicKey: String, using transaction: Any) -} diff --git a/SessionSnodeKit/Types/SSKDestination.swift b/SessionSnodeKit/Types/SSKDestination.swift new file mode 100644 index 000000000..f879c1034 --- /dev/null +++ b/SessionSnodeKit/Types/SSKDestination.swift @@ -0,0 +1,17 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OnionRequestAPI { + public enum Destination: CustomStringConvertible { + case snode(Snode) + case server(host: String, target: String, x25519PublicKey: String, scheme: String?, port: UInt16?) + + public var description: String { + switch self { + case .snode(let snode): return "Service node \(snode.ip):\(snode.port)" + case .server(let host, _, _, _, _): return host + } + } + } +} diff --git a/SessionSnodeKit/Types/SSKEndpoint.swift b/SessionSnodeKit/Types/SSKEndpoint.swift new file mode 100644 index 000000000..26dcc0c01 --- /dev/null +++ b/SessionSnodeKit/Types/SSKEndpoint.swift @@ -0,0 +1,15 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension SnodeAPI { + public enum Endpoint: String { + case getSwarm = "get_snodes_for_pubkey" + case getMessages = "retrieve" + case sendMessage = "store" + case deleteMessage = "delete" + case oxenDaemonRPCCall = "oxend_request" + case getInfo = "info" + case clearAllData = "delete_all" + } +} diff --git a/SessionSnodeKit/Types/SSKError.swift b/SessionSnodeKit/Types/SSKError.swift new file mode 100644 index 000000000..7fdd38a5f --- /dev/null +++ b/SessionSnodeKit/Types/SSKError.swift @@ -0,0 +1,63 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +extension OnionRequestAPI { + public enum Error: LocalizedError { + case httpRequestFailedAtDestination(statusCode: UInt, json: JSON, destination: Destination) + case insufficientSnodes + case invalidURL + case missingSnodeVersion + case snodePublicKeySetMissing + case unsupportedSnodeVersion(String) + + public var errorDescription: String? { + switch self { + case .httpRequestFailedAtDestination(let statusCode, _, let destination): + if statusCode == 429 { return "Rate limited." } + + return "HTTP request failed at destination (\(destination)) with status code: \(statusCode)." + + case .insufficientSnodes: return "Couldn't find enough Service Nodes to build a path." + case .invalidURL: return "Invalid URL" + case .missingSnodeVersion: return "Missing Service Node version." + case .snodePublicKeySetMissing: return "Missing Service Node public key set." + case .unsupportedSnodeVersion(let version): return "Unsupported Service Node version: \(version)." + } + } + } +} + +extension SnodeAPI { + public enum Error: LocalizedError { + case generic + case clockOutOfSync + case snodePoolUpdatingFailed + case inconsistentSnodePools + case noKeyPair + case signingFailed + case invalidIP + + // ONS + case decryptionFailed + case hashingFailed + case validationFailed + + public var errorDescription: String? { + switch self { + case .generic: return "An error occurred." + case .clockOutOfSync: return "Your clock is out of sync with the Service Node network. Please check that your device's clock is set to automatic time." + case .snodePoolUpdatingFailed: return "Failed to update the Service Node pool." + case .inconsistentSnodePools: return "Received inconsistent Service Node pool information from the Service Node network." + case .noKeyPair: return "Missing user key pair." + case .signingFailed: return "Couldn't sign message." + case .invalidIP: return "Invalid IP." + // ONS + case .decryptionFailed: return "Couldn't decrypt ONS name." + case .hashingFailed: return "Couldn't compute ONS name hash." + case .validationFailed: return "ONS name validation failed." + } + } + } +} diff --git a/SessionUtilitiesKit/Configuration.swift b/SessionUtilitiesKit/Configuration.swift index ca72ad9df..c1b19b512 100644 --- a/SessionUtilitiesKit/Configuration.swift +++ b/SessionUtilitiesKit/Configuration.swift @@ -13,6 +13,17 @@ public final class SNUtilitiesKitConfiguration : NSObject { } public enum SNUtilitiesKit { // Just to make the external API nice + public static func migrations() -> TargetMigrations { + return TargetMigrations( + identifier: .utilitiesKit, + migrations: [ + [ + _001_InitialSetupMigration.self, + _002_YDBToGRDBMigration.self + ] + ] + ) + } public static func configure(owsPrimaryStorage: OWSPrimaryStorageProtocol, maxFileSize: UInt) { SNUtilitiesKitConfiguration.shared = SNUtilitiesKitConfiguration(owsPrimaryStorage: owsPrimaryStorage, maxFileSize: maxFileSize) diff --git a/SessionUtilitiesKit/Database/GRDBStorage.swift b/SessionUtilitiesKit/Database/GRDBStorage.swift index a628cb220..9aea43d52 100644 --- a/SessionUtilitiesKit/Database/GRDBStorage.swift +++ b/SessionUtilitiesKit/Database/GRDBStorage.swift @@ -210,15 +210,15 @@ public final class GRDBStorage { // MARK: - Functions - public func write(updates: (Database) throws -> T) throws -> T { - return try dbPool.write(updates) + @discardableResult public func write(updates: (Database) throws -> T?) -> T? { + return try? dbPool.write(updates) } public func writeAsync(updates: @escaping (Database) throws -> T, completion: @escaping (Database, Result) -> Void) { dbPool.asyncWrite(updates, completion: completion) } - public func read(_ value: (Database) throws -> T) throws -> T { - return try dbPool.read(value) + @discardableResult public func read(_ value: (Database) throws -> T?) -> T? { + return try? dbPool.read(value) } } diff --git a/SessionUtilitiesKit/Database/LegacyDatabase/SUKLegacyModels.swift b/SessionUtilitiesKit/Database/LegacyDatabase/SUKLegacyModels.swift new file mode 100644 index 000000000..d355dd8b4 --- /dev/null +++ b/SessionUtilitiesKit/Database/LegacyDatabase/SUKLegacyModels.swift @@ -0,0 +1,25 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Curve25519Kit + +public enum Legacy { + // MARK: - Collections and Keys + + internal static let userAccountRegisteredNumberKey = "TSStorageRegisteredNumberKey" + internal static let userAccountCollection = "TSStorageUserAccountCollection" + + internal static let identityKeyStoreSeedKey = "LKLokiSeed" + internal static let identityKeyStoreEd25519SecretKey = "LKED25519SecretKey" + internal static let identityKeyStoreEd25519PublicKey = "LKED25519PublicKey" + internal static let identityKeyStoreIdentityKey = "TSStorageManagerIdentityKeyStoreIdentityKey" + internal static let identityKeyStoreCollection = "TSStorageManagerIdentityKeyStoreCollection" +} + +// MARK: - Legacy Extensions + +internal extension YapDatabaseReadTransaction { + func keyPair(forKey key: String, in collection: String) -> ECKeyPair? { + return (self.object(forKey: key, inCollection: collection) as? ECKeyPair) + } +} diff --git a/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift new file mode 100644 index 000000000..b67b006d9 --- /dev/null +++ b/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -0,0 +1,26 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +enum _001_InitialSetupMigration: Migration { + static let identifier: String = "initialSetup" + + static func migrate(_ db: Database) throws { + try db.create(table: Identity.self) { t in + t.column(.variant, .text) + .notNull() + .unique() + .primaryKey() + t.column(.data, .blob).notNull() + } + + try db.create(table: Setting.self) { t in + t.column(.key, .text) + .notNull() + .unique() + .primaryKey() + t.column(.value, .blob).notNull() + } + } +} diff --git a/SessionUtilitiesKit/Database/Migrations/_002_YDBToGRDBMigration.swift b/SessionUtilitiesKit/Database/Migrations/_002_YDBToGRDBMigration.swift new file mode 100644 index 000000000..0c53f6fcc --- /dev/null +++ b/SessionUtilitiesKit/Database/Migrations/_002_YDBToGRDBMigration.swift @@ -0,0 +1,89 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import Curve25519Kit + +enum _002_YDBToGRDBMigration: Migration { + static let identifier: String = "YDBToGRDBMigration" + + static func migrate(_ db: Database) throws { + // MARK: - Identity keys + + // Note: Want to exclude the Snode's we already added from the 'onionRequestPathResult' + var registeredNumber: String? + var seedHexString: String? + var userEd25519SecretKeyHexString: String? + var userEd25519PublicKeyHexString: String? + var userX25519KeyPair: ECKeyPair? + + Storage.read { transaction in + registeredNumber = transaction.object( + forKey: Legacy.userAccountRegisteredNumberKey, + inCollection: Legacy.userAccountCollection + ) as? String + + // Note: The 'seed', 'ed25519SecretKey' and 'ed25519PublicKey' were + // all previously stored as hex strings, so we need to convert them + // to data before we store them in the new database + seedHexString = transaction.object( + forKey: Legacy.identityKeyStoreSeedKey, + inCollection: Legacy.identityKeyStoreCollection + ) as? String + + userEd25519SecretKeyHexString = transaction.object( + forKey: Legacy.identityKeyStoreEd25519SecretKey, + inCollection: Legacy.identityKeyStoreCollection + ) as? String + + userEd25519PublicKeyHexString = transaction.object( + forKey: Legacy.identityKeyStoreEd25519PublicKey, + inCollection: Legacy.identityKeyStoreCollection + ) as? String + + userX25519KeyPair = transaction.keyPair( + forKey: Legacy.identityKeyStoreIdentityKey, + in: Legacy.identityKeyStoreCollection + ) + } + + // No need to continue if the user isn't registered + if registeredNumber == nil { return } + + // If the user is registered then it's all-or-nothing for these values + guard + let seedHexString: String = seedHexString, + let userEd25519SecretKeyHexString: String = userEd25519SecretKeyHexString, + let userEd25519PublicKeyHexString: String = userEd25519PublicKeyHexString, + let userX25519KeyPair: ECKeyPair = userX25519KeyPair + else { + throw GRDBStorageError.migrationFailed + } + + // Insert the data into GRDB + try Identity( + variant: .seed, + data: Data(hex: seedHexString) + ).insert(db) + + try Identity( + variant: .ed25519SecretKey, + data: Data(hex: userEd25519SecretKeyHexString) + ).insert(db) + + try Identity( + variant: .ed25519PublicKey, + data: Data(hex: userEd25519PublicKeyHexString) + ).insert(db) + + try Identity( + variant: .x25519PrivateKey, + data: userX25519KeyPair.privateKey + ).insert(db) + + try Identity( + variant: .x25519PublicKey, + data: userX25519KeyPair.publicKey + ).insert(db) + } +} diff --git a/SessionUtilitiesKit/Database/Models/Identity.swift b/SessionUtilitiesKit/Database/Models/Identity.swift new file mode 100644 index 000000000..3e55ee0a3 --- /dev/null +++ b/SessionUtilitiesKit/Database/Models/Identity.swift @@ -0,0 +1,129 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import Sodium +import Curve25519Kit +import CryptoSwift + +public struct Identity: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "identity" } + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case variant + case data + } + + public enum Variant: String, Codable, DatabaseValueConvertible { + case seed + case ed25519SecretKey + case ed25519PublicKey + case x25519PrivateKey + case x25519PublicKey + } + + public var id: Variant { variant } + + let variant: Variant + let data: Data +} + +// MARK: - Convenience + +extension ECKeyPair { + func toData() -> Data { + var targetValue: ECKeyPair = self + + return Data(bytes: &targetValue, count: MemoryLayout.size(ofValue: targetValue)) + } +} + +// MARK: - User Identity + +public extension Identity { + static func generate(from seed: Data) throws -> (ed25519KeyPair: Sign.KeyPair, x25519KeyPair: ECKeyPair) { + assert(seed.count == 16) + let padding = Data(repeating: 0, count: 16) + + guard + let ed25519KeyPair = Sodium().sign.keyPair(seed: (seed + padding).bytes), + let x25519PublicKey = Sodium().sign.toX25519(ed25519PublicKey: ed25519KeyPair.publicKey), + let x25519SecretKey = Sodium().sign.toX25519(ed25519SecretKey: ed25519KeyPair.secretKey) + else { + throw GeneralError.keyGenerationFailed + } + + let x25519KeyPair = try ECKeyPair(publicKeyData: Data(x25519PublicKey), privateKeyData: Data(x25519SecretKey)) + + return (ed25519KeyPair: ed25519KeyPair, x25519KeyPair: x25519KeyPair) + } + + static func store(seed: Data, ed25519KeyPair: Sign.KeyPair, x25519KeyPair: ECKeyPair) { + GRDBStorage.shared.write { db in + try Identity(variant: .seed, data: seed).save(db) + try Identity(variant: .ed25519SecretKey, data: Data(ed25519KeyPair.secretKey)).save(db) + try Identity(variant: .ed25519PublicKey, data: Data(ed25519KeyPair.publicKey)).save(db) + try Identity(variant: .x25519PrivateKey, data: x25519KeyPair.privateKey).save(db) + try Identity(variant: .x25519PublicKey, data: x25519KeyPair.publicKey).save(db) + } + } + + static func fetchUserKeyPair() -> ECKeyPair? { + return GRDBStorage.shared.read { db -> ECKeyPair? in + guard + let publicKey: Identity = try? Identity.fetchOne(db, id: .x25519PublicKey), + let privateKey: Identity = try? Identity.fetchOne(db, id: .x25519PrivateKey) + else { + return nil + } + + return try? ECKeyPair( + publicKeyData: publicKey.data, + privateKeyData: privateKey.data + ) + } + } + + static func fetchUserEd25519KeyPair() -> Box.KeyPair? { + return GRDBStorage.shared.read { db -> Box.KeyPair? in + guard + let publicKey: Identity = try? Identity.fetchOne(db, id: .ed25519PublicKey), + let secretKey: Identity = try? Identity.fetchOne(db, id: .ed25519SecretKey) + else { + return nil + } + + return Box.KeyPair( + publicKey: publicKey.data.bytes, + secretKey: secretKey.data.bytes + ) + } + } + + static func fetchHexEncodedSeed() -> String? { + return GRDBStorage.shared.read { db in + guard let value: Identity = try? Identity.fetchOne(db, id: .seed) else { + return nil + } + + return value.data.toHexString() + } + } + + // TODO: Should this actually clear all identity values??? + static func clearUserKeyPair() { + GRDBStorage.shared.write { db in + try Identity.deleteOne(db, id: .x25519PublicKey) + try Identity.deleteOne(db, id: .x25519PrivateKey) + } + } +} + +@objc(SUKIdentity) +public class objc_Identity: NSObject { + @objc(clearUserKeyPair) + public static func objc_clearUserKeyPair() { + Identity.clearUserKeyPair() + } +} diff --git a/SessionUtilitiesKit/Database/Models/Setting.swift b/SessionUtilitiesKit/Database/Models/Setting.swift new file mode 100644 index 000000000..a0e5295c1 --- /dev/null +++ b/SessionUtilitiesKit/Database/Models/Setting.swift @@ -0,0 +1,149 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +// MARK: - Setting + +public struct Setting: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "settings" } + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case key + case value + } + + public var id: String { key } + + let key: String + let value: Data +} + +extension Setting { + fileprivate init?(key: String, value: T?) { + guard let value: T = value else { return nil } + + var targetValue: T = value + + self.key = key + self.value = Data(bytes: &targetValue, count: MemoryLayout.size(ofValue: targetValue)) + } + + fileprivate func value(as type: T.Type) -> T { + return value.withUnsafeBytes { $0.load(as: T.self) } + } +} + +// MARK: - Keys + +public extension Setting { + struct BoolKey: RawRepresentable, ExpressibleByStringLiteral { + public let rawValue: String + + public init(_ rawValue: String) { self.rawValue = rawValue } + public init?(rawValue: String) { self.rawValue = rawValue } + public init(stringLiteral value: String) { self.init(value) } + public init(unicodeScalarLiteral value: String) { self.init(value) } + public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } + } + + struct DateKey: RawRepresentable, ExpressibleByStringLiteral { + public let rawValue: String + + public init(_ rawValue: String) { self.rawValue = rawValue } + public init?(rawValue: String) { self.rawValue = rawValue } + public init(stringLiteral value: String) { self.init(value) } + public init(unicodeScalarLiteral value: String) { self.init(value) } + public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } + } + + struct DoubleKey: RawRepresentable, ExpressibleByStringLiteral { + public let rawValue: String + + public init(_ rawValue: String) { self.rawValue = rawValue } + public init?(rawValue: String) { self.rawValue = rawValue } + public init(stringLiteral value: String) { self.init(value) } + public init(unicodeScalarLiteral value: String) { self.init(value) } + public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } + } + + struct IntKey: RawRepresentable, ExpressibleByStringLiteral { + public let rawValue: String + + public init(_ rawValue: String) { self.rawValue = rawValue } + public init?(rawValue: String) { self.rawValue = rawValue } + public init(stringLiteral value: String) { self.init(value) } + public init(unicodeScalarLiteral value: String) { self.init(value) } + public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } + } + + struct StringKey: RawRepresentable, ExpressibleByStringLiteral { + public let rawValue: String + + public init(_ rawValue: String) { self.rawValue = rawValue } + public init?(rawValue: String) { self.rawValue = rawValue } + public init(stringLiteral value: String) { self.init(value) } + public init(unicodeScalarLiteral value: String) { self.init(value) } + public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } + } +} + +// MARK: - Database Access + +public extension GRDBStorage { + subscript(key: Setting.BoolKey) -> Bool? { return read { db in db[key] } } + subscript(key: Setting.DoubleKey) -> Double? { return read { db in db[key] } } + subscript(key: Setting.IntKey) -> Int? { return read { db in db[key] } } + subscript(key: Setting.StringKey) -> String? { return read { db in db[key] } } + subscript(key: Setting.DateKey) -> Date? { return read { db in db[key] } } +} + +public extension Database { + private subscript(key: String) -> Setting? { + get { try? Setting.filter(id: key).fetchOne(self) } + set { + guard let newValue: Setting = newValue else { + _ = try? Setting.filter(id: key).deleteAll(self) + return + } + + try? newValue.save(self) + } + } + + subscript(key: Setting.BoolKey) -> Bool? { + get { self[key.rawValue]?.value(as: Bool.self) } + set { self[key.rawValue] = Setting(key: key.rawValue, value: newValue) } + } + + subscript(key: Setting.DoubleKey) -> Double? { + get { self[key.rawValue]?.value(as: Double.self) } + set { self[key.rawValue] = Setting(key: key.rawValue, value: newValue) } + } + + subscript(key: Setting.IntKey) -> Int? { + get { self[key.rawValue]?.value(as: Int.self) } + set { self[key.rawValue] = Setting(key: key.rawValue, value: newValue) } + } + + subscript(key: Setting.StringKey) -> String? { + get { self[key.rawValue]?.value(as: String.self) } + set { self[key.rawValue] = Setting(key: key.rawValue, value: newValue) } + } + + /// Value will be stored as a timestamp in seconds since 1970 + subscript(key: Setting.DateKey) -> Date? { + get { + let timestamp: TimeInterval? = self[key.rawValue]?.value(as: TimeInterval.self) + + return timestamp.map { Date(timeIntervalSince1970: $0) } + } + set { + self[key.rawValue] = Setting( + key: key.rawValue, + value: newValue.map { $0.timeIntervalSince1970 } + ) + } + } +} diff --git a/SessionUtilitiesKit/Database/Types/SettingType.swift b/SessionUtilitiesKit/Database/Types/SettingType.swift deleted file mode 100644 index 0a8ab0dac..000000000 --- a/SessionUtilitiesKit/Database/Types/SettingType.swift +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation diff --git a/SessionUtilitiesKit/Database/Types/TargetMigrations.swift b/SessionUtilitiesKit/Database/Types/TargetMigrations.swift index 83cb310a5..4718d5714 100644 --- a/SessionUtilitiesKit/Database/Types/TargetMigrations.swift +++ b/SessionUtilitiesKit/Database/Types/TargetMigrations.swift @@ -16,6 +16,7 @@ public struct TargetMigrations: Comparable { public enum Identifier: String, CaseIterable, Comparable { // WARNING: The string version of these cases are used as migration identifiers so // changing them will result in the migrations running again + case utilitiesKit case snodeKit case messagingKit diff --git a/SessionUtilitiesKit/Database/Types/TypedTableDefinition.swift b/SessionUtilitiesKit/Database/Types/TypedTableDefinition.swift index 8a5b86c92..db2157576 100644 --- a/SessionUtilitiesKit/Database/Types/TypedTableDefinition.swift +++ b/SessionUtilitiesKit/Database/Types/TypedTableDefinition.swift @@ -20,7 +20,14 @@ public class TypedTableDefinition where T: TableRecord, T: ColumnExpressible definition.primaryKey(columns.map { $0.name }, onConflict: onConflict) } - public func foreignKey(_ columns: [T.Columns], references table: Other.Type, columns destinationColumns: [Other.Columns]? = nil, onDelete: Database.ForeignKeyAction? = nil, onUpdate: Database.ForeignKeyAction? = nil, deferred: Bool = false) where Other: TableRecord, Other: ColumnExpressible { + public func foreignKey( + _ columns: [T.Columns], + references table: Other.Type, + columns destinationColumns: [Other.Columns]? = nil, + onDelete: Database.ForeignKeyAction? = nil, + onUpdate: Database.ForeignKeyAction? = nil, + deferred: Bool = false + ) where Other: TableRecord, Other: ColumnExpressible { return definition.foreignKey( columns.map { $0.name }, references: table.databaseTableName, diff --git a/SessionUtilitiesKit/General/Array+Utilities.swift b/SessionUtilitiesKit/General/Array+Utilities.swift index 0b4d8b7fd..43d74f007 100644 --- a/SessionUtilitiesKit/General/Array+Utilities.swift +++ b/SessionUtilitiesKit/General/Array+Utilities.swift @@ -6,6 +6,25 @@ public extension Array where Element : CustomStringConvertible { } } +public extension Array { + func appending(_ other: Element?) -> [Element] { + guard let other: Element = other else { return self } + + var updatedArray: [Element] = self + updatedArray.append(other) + return updatedArray + } + + func appending(contentsOf other: [Element]?) -> [Element] { + guard let other: [Element] = other else { return self } + + var updatedArray: [Element] = self + updatedArray.append(contentsOf: other) + return updatedArray + } +} + + public extension Array where Element: Hashable { func asSet() -> Set { return Set(self) diff --git a/SessionUtilitiesKit/General/Dictionary+Description.swift b/SessionUtilitiesKit/General/Dictionary+Description.swift index f402736ac..927c7c8e5 100644 --- a/SessionUtilitiesKit/General/Dictionary+Description.swift +++ b/SessionUtilitiesKit/General/Dictionary+Description.swift @@ -10,4 +10,14 @@ public extension Dictionary { return keyDescription + " : " + truncatedValueDescription }.joined(separator: ", ") + " ]" } + + func asArray() -> [(key: Key, value: Value)] { + return Array(self) + } +} + +public extension Dictionary.Values { + func asArray() -> [Value] { + return Array(self) + } } diff --git a/SessionUtilitiesKit/General/General.swift b/SessionUtilitiesKit/General/General.swift index 7fb5bacdf..d25e5b774 100644 --- a/SessionUtilitiesKit/General/General.swift +++ b/SessionUtilitiesKit/General/General.swift @@ -1,3 +1,35 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Curve25519Kit + +public enum General { + public enum Cache { + public static var cachedEncodedPublicKey: Atomic = Atomic(nil) + } +} + +public enum GeneralError: Error { + case keyGenerationFailed +} + +@objc(SNGeneralUtilities) +public class GeneralUtilities: NSObject { + @objc public static func getUserPublicKey() -> String { + return getUserHexEncodedPublicKey() + } +} + +public func getUserHexEncodedPublicKey() -> String { + if let cachedKey: String = General.Cache.cachedEncodedPublicKey.wrappedValue { return cachedKey } + + if let keyPair: ECKeyPair = Identity.fetchUserKeyPair() { // Can be nil under some circumstances + General.Cache.cachedEncodedPublicKey.mutate { $0 = keyPair.hexEncodedPublicKey } + return keyPair.hexEncodedPublicKey + } + + return "" +} /// Does nothing, but is never inlined and thus evaluating its argument will never be optimized away. /// diff --git a/SessionUtilitiesKit/Utilities/Failable.swift b/SessionUtilitiesKit/Utilities/Failable.swift new file mode 100644 index 000000000..47ff9227d --- /dev/null +++ b/SessionUtilitiesKit/Utilities/Failable.swift @@ -0,0 +1,26 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +/// The `Failable` type allows for coding an array of values without failing the entire array if a single +/// value fails to encode/decode correctly +public struct Failable: Codable { + public let value: T? + + public init(from decoder: Decoder) throws { + guard let container = try? decoder.singleValueContainer() else { + self.value = nil + return + } + + self.value = try? container.decode(T.self) + } + + public func encode(to encoder: Encoder) throws { + guard let value: T = value else { return } + + var container: SingleValueEncodingContainer = encoder.singleValueContainer() + + try container.encode(value) + } +} diff --git a/SessionMessagingKit/Utilities/Sodium+Conversion.swift b/SessionUtilitiesKit/Utilities/Sodium+Conversion.swift similarity index 94% rename from SessionMessagingKit/Utilities/Sodium+Conversion.swift rename to SessionUtilitiesKit/Utilities/Sodium+Conversion.swift index c522bdf92..fc6ead795 100644 --- a/SessionMessagingKit/Utilities/Sodium+Conversion.swift +++ b/SessionUtilitiesKit/Utilities/Sodium+Conversion.swift @@ -1,3 +1,6 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation import Clibsodium import Sodium diff --git a/SignalUtilitiesKit/Configuration.swift b/SignalUtilitiesKit/Configuration.swift index b32479eb4..362a47f17 100644 --- a/SignalUtilitiesKit/Configuration.swift +++ b/SignalUtilitiesKit/Configuration.swift @@ -24,6 +24,7 @@ public final class Configuration : NSObject { //DispatchQueue.main.once let storage: GRDBStorage? = try? GRDBStorage( migrations: [ + SNUtilitiesKit.migrations(), SNSnodeKit.migrations(), SNMessagingKit.migrations() ] @@ -31,6 +32,6 @@ public final class Configuration : NSObject { } SNMessagingKit.configure(storage: Storage.shared) - SNSnodeKit.configure(storage: Storage.shared) + SNSnodeKit.configure() } } diff --git a/SignalUtilitiesKit/Database/Storage+Conformances.swift b/SignalUtilitiesKit/Database/Storage+Conformances.swift index 21c74ee43..b6c9604bc 100644 --- a/SignalUtilitiesKit/Database/Storage+Conformances.swift +++ b/SignalUtilitiesKit/Database/Storage+Conformances.swift @@ -1,5 +1,8 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -extension Storage : SessionMessagingKitStorageProtocol, SessionSnodeKitStorageProtocol { +import Foundation + +extension Storage : SessionMessagingKitStorageProtocol { public func updateMessageIDCollectionByPruningMessagesWithIDs(_ messageIDs: Set, using transaction: Any) { let transaction = transaction as! YapDatabaseReadWriteTransaction diff --git a/SignalUtilitiesKit/Database/TSStorageHeaders.h b/SignalUtilitiesKit/Database/TSStorageHeaders.h index 9f7894e8e..11615a843 100644 --- a/SignalUtilitiesKit/Database/TSStorageHeaders.h +++ b/SignalUtilitiesKit/Database/TSStorageHeaders.h @@ -4,7 +4,7 @@ #ifndef Signal_TSStorageHeaders_h #define Signal_TSStorageHeaders_h -#import + #import #import diff --git a/SignalUtilitiesKit/To Do/OWSPrimaryStorage+Loki.m b/SignalUtilitiesKit/To Do/OWSPrimaryStorage+Loki.m index 97e2f4e8a..2e1e1056c 100644 --- a/SignalUtilitiesKit/To Do/OWSPrimaryStorage+Loki.m +++ b/SignalUtilitiesKit/To Do/OWSPrimaryStorage+Loki.m @@ -1,6 +1,5 @@ #import "OWSPrimaryStorage+Loki.h" #import "OWSPrimaryStorage+keyFromIntLong.h" -#import "OWSIdentityManager.h" #import "NSDate+OWS.h" #import "TSAccountManager.h" #import "YapDatabaseConnection+OWS.h" diff --git a/SignalUtilitiesKit/Utilities/AppSetup.m b/SignalUtilitiesKit/Utilities/AppSetup.m index 062c572d9..3e0fcbd40 100644 --- a/SignalUtilitiesKit/Utilities/AppSetup.m +++ b/SignalUtilitiesKit/Utilities/AppSetup.m @@ -9,7 +9,6 @@ #import #import #import -#import #import #import #import @@ -51,7 +50,6 @@ NS_ASSUME_NONNULL_BEGIN OWSPreferences *preferences = [OWSPreferences new]; OWSProfileManager *profileManager = [[OWSProfileManager alloc] initWithPrimaryStorage:primaryStorage]; - OWSIdentityManager *identityManager = [[OWSIdentityManager alloc] initWithPrimaryStorage:primaryStorage]; TSAccountManager *tsAccountManager = [[TSAccountManager alloc] initWithPrimaryStorage:primaryStorage]; OWSDisappearingMessagesJob *disappearingMessagesJob = [[OWSDisappearingMessagesJob alloc] initWithPrimaryStorage:primaryStorage]; @@ -75,7 +73,6 @@ NS_ASSUME_NONNULL_BEGIN [SSKEnvironment setShared:[[SSKEnvironment alloc] initWithProfileManager:profileManager primaryStorage:primaryStorage - identityManager:identityManager tsAccountManager:tsAccountManager disappearingMessagesJob:disappearingMessagesJob readReceiptManager:readReceiptManager From 63db2a4e3d624ed64adabe7e99345b1d22836a14 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 4 Apr 2022 09:33:12 +1000 Subject: [PATCH 055/157] Updated the 'SwarmSnode' to use the 'port_https' key instead of the 'port' key (deprecated) --- SessionSnodeKit/Models/SwarmSnode.swift | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/SessionSnodeKit/Models/SwarmSnode.swift b/SessionSnodeKit/Models/SwarmSnode.swift index 09dc2963e..d4adc2ded 100644 --- a/SessionSnodeKit/Models/SwarmSnode.swift +++ b/SessionSnodeKit/Models/SwarmSnode.swift @@ -9,7 +9,7 @@ import SessionUtilitiesKit internal struct SwarmSnode: Codable { public enum CodingKeys: String, CodingKey { case address = "ip" - case port + case port = "port_https" // Note: The 'port' key was deprecated inplace of the 'port_https' key case ed25519PublicKey = "pubkey_ed25519" case x25519PublicKey = "pubkey_x25519" } @@ -28,15 +28,12 @@ extension SwarmSnode { do { let address: String = try container.decode(String.self, forKey: .address) - let portString: String = try container.decode(String.self, forKey: .port) - guard address != "0.0.0.0", let port: UInt16 = UInt16(portString) else { - throw SnodeAPI.Error.invalidIP - } + guard address != "0.0.0.0" else { throw SnodeAPI.Error.invalidIP } self = SwarmSnode( address: (address.starts(with: "https://") ? address : "https://\(address)"), - port: port, + port: try container.decode(UInt16.self, forKey: .port), ed25519PublicKey: try container.decode(String.self, forKey: .ed25519PublicKey), x25519PublicKey: try container.decode(String.self, forKey: .x25519PublicKey) ) From 410f37f0d5ea45c9097977d6783f1e5016845bc7 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 4 Apr 2022 09:52:48 +1000 Subject: [PATCH 056/157] Updated the SnodeSet table name to match the type Shifted all GRDB Snode convenience methods to be extensions on Snode (instead of SnodeSet) for consistency --- SessionSnodeKit/Database/Models/Snode.swift | 35 +++++++++++++++++- .../Models/SnodeReceivedMessageInfo.swift | 6 +++- .../Database/Models/SnodeSet.swift | 36 +------------------ SessionSnodeKit/OnionRequestAPI.swift | 4 +-- 4 files changed, 42 insertions(+), 39 deletions(-) diff --git a/SessionSnodeKit/Database/Models/Snode.swift b/SessionSnodeKit/Database/Models/Snode.swift index 1e18066cd..d6303394d 100644 --- a/SessionSnodeKit/Database/Models/Snode.swift +++ b/SessionSnodeKit/Database/Models/Snode.swift @@ -65,7 +65,7 @@ extension Snode { } } -// MARK: - Convenience +// MARK: - GRDB Interactions internal extension Snode { static func fetchSet(_ db: Database, publicKey: String) throws -> Set { @@ -77,9 +77,41 @@ internal extension Snode { ) .fetchSet(db) } + + static func fetchAllOnionRequestPaths(_ db: Database) throws -> [[Snode]] { + struct ResultWrapper: Decodable, FetchableRecord { + let key: String + let nodeIndex: Int + let address: String + let port: UInt16 + let snode: Snode + } + + return try SnodeSet + .filter(SnodeSet.Columns.key.like("\(SnodeSet.onionRequestPathPrefix)%")) + .order(SnodeSet.Columns.nodeIndex) + .order(SnodeSet.Columns.key) + .including(required: SnodeSet.node) + .asRequest(of: ResultWrapper.self) + .fetchAll(db) + .reduce(into: [:]) { prev, next in // Reducing will lose the 'key' sorting + prev[next.key] = (prev[next.key] ?? []).appending(next.snode) + } + .asArray() + .sorted(by: { lhs, rhs in lhs.key < rhs.key }) + .compactMap { _, nodes in !nodes.isEmpty ? nodes : nil } // Exclude empty sets + } + + static func clearOnionRequestPaths(_ db: Database) throws { + try SnodeSet + .filter(SnodeSet.Columns.key.like("\(SnodeSet.onionRequestPathPrefix)%")) + .deleteAll(db) + } } + internal extension Collection where Element == Snode { + /// This method is used to save Swarms func save(_ db: Database, key: String) throws { try self.enumerated().forEach { nodeIndex, node in try node.save(db) @@ -95,6 +127,7 @@ internal extension Collection where Element == Snode { } internal extension Collection where Element == [Snode] { + /// This method is used to save onion reuqest paths func save(_ db: Database) throws { try self.enumerated().forEach { pathIndex, path in try path.save(db, key: "\(SnodeSet.onionRequestPathPrefix)\(pathIndex)") diff --git a/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift b/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift index 7c1a2ade7..7577ec8c0 100644 --- a/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift +++ b/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift @@ -36,7 +36,11 @@ public extension SnodeReceivedMessageInfo { self.hash = hash self.expirationDateMs = (expirationDateMs ?? 0) } - +} + +// MARK: - GRDB Interactions + +public extension SnodeReceivedMessageInfo { static func pruneLastMessageHashInfoIfExpired(for snode: Snode, associatedWith publicKey: String) { // Clear out the 'expirationDateMs' value for all expired (but non-0) message infos GRDBStorage.shared.write { db in diff --git a/SessionSnodeKit/Database/Models/SnodeSet.swift b/SessionSnodeKit/Database/Models/SnodeSet.swift index 597597ea8..209a5d95f 100644 --- a/SessionSnodeKit/Database/Models/SnodeSet.swift +++ b/SessionSnodeKit/Database/Models/SnodeSet.swift @@ -6,7 +6,7 @@ import SessionUtilitiesKit public struct SnodeSet: Codable, FetchableRecord, EncodableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static let onionRequestPathPrefix = "OnionRequestPath-" - public static var databaseTableName: String { "snodeSetAssociation" } + public static var databaseTableName: String { "snodeSet" } static let node = hasOne(Snode.self, using: Snode.snodeSetForeignKey) public typealias Columns = CodingKeys @@ -26,37 +26,3 @@ public struct SnodeSet: Codable, FetchableRecord, EncodableRecord, PersistableRe request(for: SnodeSet.node) } } - -// MARK: - Convenience - -internal extension SnodeSet { - static func fetchAllOnionRequestPaths(_ db: Database) throws -> [[Snode]] { - struct ResultWrapper: Decodable, FetchableRecord { - let key: String - let nodeIndex: Int - let address: String - let port: UInt16 - let snode: Snode - } - - return try SnodeSet - .filter(SnodeSet.Columns.key.like("\(SnodeSet.onionRequestPathPrefix)%")) - .order(SnodeSet.Columns.nodeIndex) - .order(SnodeSet.Columns.key) - .including(required: SnodeSet.node) - .asRequest(of: ResultWrapper.self) - .fetchAll(db) - .reduce(into: [:]) { prev, next in // Reducing will lose the 'key' sorting - prev[next.key] = (prev[next.key] ?? []).appending(next.snode) - } - .asArray() - .sorted(by: { lhs, rhs in lhs.key < rhs.key }) - .compactMap { _, nodes in !nodes.isEmpty ? nodes : nil } // Exclude empty sets - } - - static func clearOnionRequestPaths(_ db: Database) throws { - try SnodeSet - .filter(SnodeSet.Columns.key.like("\(SnodeSet.onionRequestPathPrefix)%")) - .deleteAll(db) - } -} diff --git a/SessionSnodeKit/OnionRequestAPI.swift b/SessionSnodeKit/OnionRequestAPI.swift index 2ae59b8a7..932dadbae 100644 --- a/SessionSnodeKit/OnionRequestAPI.swift +++ b/SessionSnodeKit/OnionRequestAPI.swift @@ -24,7 +24,7 @@ public enum OnionRequestAPI { if let paths: [[Snode]] = _paths { return paths } let results: [[Snode]]? = GRDBStorage.shared.read { db in - try? SnodeSet.fetchAllOnionRequestPaths(db) + try? Snode.fetchAllOnionRequestPaths(db) } if results?.isEmpty == false { _paths = results } @@ -283,7 +283,7 @@ public enum OnionRequestAPI { GRDBStorage.shared.write { db in guard !paths.isEmpty else { SNLog("Clearing onion request paths.") - try? SnodeSet.clearOnionRequestPaths(db) + try? Snode.clearOnionRequestPaths(db) return } From 72eeb1c796307495057675643247ec762e09e695 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 4 Apr 2022 09:58:47 +1000 Subject: [PATCH 057/157] Updated the Identity type to clear everything instead of just the x25519 pair --- SessionUtilitiesKit/Database/Models/Identity.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/SessionUtilitiesKit/Database/Models/Identity.swift b/SessionUtilitiesKit/Database/Models/Identity.swift index 3e55ee0a3..67b3eabf7 100644 --- a/SessionUtilitiesKit/Database/Models/Identity.swift +++ b/SessionUtilitiesKit/Database/Models/Identity.swift @@ -111,11 +111,9 @@ public extension Identity { } } - // TODO: Should this actually clear all identity values??? static func clearUserKeyPair() { GRDBStorage.shared.write { db in - try Identity.deleteOne(db, id: .x25519PublicKey) - try Identity.deleteOne(db, id: .x25519PrivateKey) + try Identity.deleteAll(db) } } } From 4ee4b3ffb34a02e98ec9485149ea1e69961fcd29 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 4 Apr 2022 13:18:14 +1000 Subject: [PATCH 058/157] Started adding migration logic for contacts Updated the getUserHexEncodedPublicKey to take an optional db value so we can retrieve it during the initial migration --- Session.xcodeproj/project.pbxproj | 20 +- Session/Utilities/MockDataGenerator.swift | 136 +++++++++--- SessionMessagingKit/Contacts/Contact.swift | 127 ----------- .../LegacyDatabase/SMKLegacyModels.swift | 209 +++++++++++++++++- .../_001_InitialSetupMigration.swift | 44 ++-- .../Migrations/_002_YDBToGRDBMigration.swift | 57 +++++ .../Database/Models/Contact.swift | 44 ++++ .../Database/Models/Profile.swift | 174 +++++++++++++++ .../LegacyDatabase/SSKLegacyModels.swift | 2 +- .../Database/Models/Identity.swift | 12 +- SessionUtilitiesKit/General/General.swift | 5 +- 11 files changed, 652 insertions(+), 178 deletions(-) delete mode 100644 SessionMessagingKit/Contacts/Contact.swift create mode 100644 SessionMessagingKit/Database/Models/Contact.swift create mode 100644 SessionMessagingKit/Database/Models/Profile.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 7909b4ffe..1a46ff9a3 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -231,7 +231,6 @@ B897621C25D201F7004F83B2 /* ScrollToBottomButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B897621B25D201F7004F83B2 /* ScrollToBottomButton.swift */; }; B8AE75A425A6C6A6001A84D2 /* Data+Trimming.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8AE75A325A6C6A6001A84D2 /* Data+Trimming.swift */; }; B8AF4BB426A5204600583500 /* SendSeedModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8AF4BB326A5204600583500 /* SendSeedModal.swift */; }; - B8B32021258B1A650020074B /* Contact.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B32020258B1A650020074B /* Contact.swift */; }; B8B32033258B235D0020074B /* Storage+Contacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B32032258B235D0020074B /* Storage+Contacts.swift */; }; B8B3204E258C15C80020074B /* ContactsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B32044258C117C0020074B /* ContactsMigration.swift */; }; B8B320B7258C30D70020074B /* HTMLMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B320B6258C30D70020074B /* HTMLMetadata.swift */; }; @@ -749,6 +748,8 @@ FD09796727F6B0B600936362 /* Sodium+Conversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E7134E251C867C009649BB /* Sodium+Conversion.swift */; }; FD09796927F6BEA700936362 /* SwarmSnode.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09796827F6BEA700936362 /* SwarmSnode.swift */; }; FD09796B27F6C67500936362 /* Failable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09796A27F6C67500936362 /* Failable.swift */; }; + FD09796E27FA6D0000936362 /* Contact.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09796D27FA6D0000936362 /* Contact.swift */; }; + FD09797027FA6FF300936362 /* Profile.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09796F27FA6FF300936362 /* Profile.swift */; }; FD17D79927F40AB800122BE0 /* _002_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79827F40AB800122BE0 /* _002_YDBToGRDBMigration.swift */; }; FD17D79C27F40B2E00122BE0 /* SMKLegacyModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */; }; FD17D7A027F40CC800122BE0 /* _001_InitialSetupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79F27F40CC800122BE0 /* _001_InitialSetupMigration.swift */; }; @@ -1239,7 +1240,6 @@ B897621B25D201F7004F83B2 /* ScrollToBottomButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollToBottomButton.swift; sourceTree = ""; }; B8AE75A325A6C6A6001A84D2 /* Data+Trimming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Trimming.swift"; sourceTree = ""; }; B8AF4BB326A5204600583500 /* SendSeedModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendSeedModal.swift; sourceTree = ""; }; - B8B32020258B1A650020074B /* Contact.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Contact.swift; sourceTree = ""; }; B8B32032258B235D0020074B /* Storage+Contacts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+Contacts.swift"; sourceTree = ""; }; B8B32044258C117C0020074B /* ContactsMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsMigration.swift; sourceTree = ""; }; B8B320B6258C30D70020074B /* HTMLMetadata.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTMLMetadata.swift; sourceTree = ""; }; @@ -1793,6 +1793,8 @@ FCB11D8B1A129A76002F93FB /* CoreMedia.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreMedia.framework; path = System/Library/Frameworks/CoreMedia.framework; sourceTree = SDKROOT; }; FD09796827F6BEA700936362 /* SwarmSnode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwarmSnode.swift; sourceTree = ""; }; FD09796A27F6C67500936362 /* Failable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Failable.swift; sourceTree = ""; }; + FD09796D27FA6D0000936362 /* Contact.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Contact.swift; sourceTree = ""; }; + FD09796F27FA6FF300936362 /* Profile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Profile.swift; sourceTree = ""; }; FD17D79527F3E04600122BE0 /* _001_InitialSetupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _001_InitialSetupMigration.swift; sourceTree = ""; }; FD17D79827F40AB800122BE0 /* _002_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_YDBToGRDBMigration.swift; sourceTree = ""; }; FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMKLegacyModels.swift; sourceTree = ""; }; @@ -2406,7 +2408,6 @@ B8B3201F258B1A540020074B /* Contacts */ = { isa = PBXGroup; children = ( - B8B32020258B1A650020074B /* Contact.swift */, ); path = Contacts; sourceTree = ""; @@ -2687,6 +2688,7 @@ children = ( FD17D79A27F40ADA00122BE0 /* LegacyDatabase */, FD17D79427F3E03300122BE0 /* Migrations */, + FD09796C27FA6C8B00936362 /* Models */, B8F5F56425EC8453003BF8D4 /* Notification+Contacts.swift */, C33FDAEA255A580500E217F9 /* OWSBackupFragment.h */, C33FDB07255A580700E217F9 /* OWSBackupFragment.m */, @@ -3604,6 +3606,15 @@ path = Utilities; sourceTree = ""; }; + FD09796C27FA6C8B00936362 /* Models */ = { + isa = PBXGroup; + children = ( + FD09796D27FA6D0000936362 /* Contact.swift */, + FD09796F27FA6FF300936362 /* Profile.swift */, + ); + path = Models; + sourceTree = ""; + }; FD17D79427F3E03300122BE0 /* Migrations */ = { isa = PBXGroup; children = ( @@ -4854,7 +4865,6 @@ C3D9E3BF25676AD70040E4F3 /* TSAttachmentStream.m in Sources */, C3C2A7562553A3AB00C340D1 /* VisibleMessage+Quote.swift in Sources */, C3227FF6260AAD66006EA627 /* OpenGroupMessageV2.swift in Sources */, - B8B32021258B1A650020074B /* Contact.swift in Sources */, C32C5C89256DD0D2003C73A2 /* Storage+Jobs.swift in Sources */, C300A5FC2554B0A000555489 /* MessageReceiver.swift in Sources */, 7B1581E2271E743B00848B49 /* OWSSounds.swift in Sources */, @@ -4929,6 +4939,7 @@ C3A3A0EC256E1949004D228D /* OWSRecipientIdentity.m in Sources */, B8F5F56525EC8453003BF8D4 /* Notification+Contacts.swift in Sources */, C32C5AB2256DBE8F003C73A2 /* TSMessage.m in Sources */, + FD09796E27FA6D0000936362 /* Contact.swift in Sources */, C3A3A0FE256E1A3C004D228D /* TSDatabaseSecondaryIndexes.m in Sources */, C38D5E8D2575011E00B6A65C /* MessageSender+ClosedGroups.swift in Sources */, C32C5B1C256DC19D003C73A2 /* TSQuotedMessage.m in Sources */, @@ -4954,6 +4965,7 @@ C32C5EEE256DF54E003C73A2 /* TSDatabaseView.m in Sources */, C352A35B2557824E00338F3E /* AttachmentUploadJob.swift in Sources */, C3A3A13C256E1B27004D228D /* OWSMediaGalleryFinder.m in Sources */, + FD09797027FA6FF300936362 /* Profile.swift in Sources */, C32C5AAE256DBE8F003C73A2 /* TSInteraction.m in Sources */, C32C5D23256DD4C0003C73A2 /* Mention.swift in Sources */, C32C5FD6256E0346003C73A2 /* Notification+Thread.swift in Sources */, diff --git a/Session/Utilities/MockDataGenerator.swift b/Session/Utilities/MockDataGenerator.swift index 8313d807a..67adf0a60 100644 --- a/Session/Utilities/MockDataGenerator.swift +++ b/Session/Utilities/MockDataGenerator.swift @@ -66,6 +66,11 @@ enum MockDataGenerator { } } + // MARK: - Generation + + static var printProgress: Bool = true + static var hasStartedGenerationThisRun: Bool = false + static func generateMockData() { // Don't re-generate the mock data if it already exists var existingMockDataThread: TSContactThread? @@ -74,29 +79,43 @@ enum MockDataGenerator { existingMockDataThread = TSContactThread.getWithContactSessionID("MockDatabaseThread", transaction: transaction) } - guard existingMockDataThread == nil else { return } + guard !hasStartedGenerationThisRun && existingMockDataThread == nil else { + hasStartedGenerationThisRun = true + return + } /// The mock data generation is quite slow, there are 3 parts which take a decent amount of time (deleting the account afterwards will also take a long time): /// Generating the threads & content - ~3s per 100 /// Writing to the database - ~10s per 1000 /// Updating the UI - ~10s per 1000 - let dmThreadCount: Int = 100 - let closedGroupThreadCount: Int = 0 - let openGroupThreadCount: Int = 0 - let maxMessagesPerThread: Int = 50 + let dmThreadCount: Int = 1000 + let closedGroupThreadCount: Int = 50 + let openGroupThreadCount: Int = 20 + let messageRangePerThread: [ClosedRange] = [(0...500)] let dmRandomSeed: Int = 1111 let cgRandomSeed: Int = 2222 let ogRandomSeed: Int = 3333 + let logProgress: (String, String) -> () = { title, event in + guard printProgress else { return } + + print("[MockDataGenerator] (\(Date().timeIntervalSince1970)) \(title) - \(event)") + } + + hasStartedGenerationThisRun = true // FIXME: Make sure this data doesn't go off device somehow? Storage.shared.write { anyTransaction in - guard let transaction: YapDatabaseReadWriteTransaction = anyTransaction as? YapDatabaseReadWriteTransaction else { return } + guard let transaction: YapDatabaseReadWriteTransaction = anyTransaction as? YapDatabaseReadWriteTransaction else { + return + } // First create the thread used to indicate that the mock data has been generated + logProgress("", "Start") _ = TSContactThread.getOrCreateThread(withContactSessionID: "MockDatabaseThread", transaction: transaction) // Multiple spaces to make it look more like words let stringContent: [String] = "abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ 0123456789 ".map { String($0) } + let wordContent: [String] = ["alias", "consequatur", "aut", "perferendis", "sit", "voluptatem", "accusantium", "doloremque", "aperiam", "eaque", "ipsa", "quae", "ab", "illo", "inventore", "veritatis", "et", "quasi", "architecto", "beatae", "vitae", "dicta", "sunt", "explicabo", "aspernatur", "aut", "odit", "aut", "fugit", "sed", "quia", "consequuntur", "magni", "dolores", "eos", "qui", "ratione", "voluptatem", "sequi", "nesciunt", "neque", "dolorem", "ipsum", "quia", "dolor", "sit", "amet", "consectetur", "adipisci", "velit", "sed", "quia", "non", "numquam", "eius", "modi", "tempora", "incidunt", "ut", "labore", "et", "dolore", "magnam", "aliquam", "quaerat", "voluptatem", "ut", "enim", "ad", "minima", "veniam", "quis", "nostrum", "exercitationem", "ullam", "corporis", "nemo", "enim", "ipsam", "voluptatem", "quia", "voluptas", "sit", "suscipit", "laboriosam", "nisi", "ut", "aliquid", "ex", "ea", "commodi", "consequatur", "quis", "autem", "vel", "eum", "iure", "reprehenderit", "qui", "in", "ea", "voluptate", "velit", "esse", "quam", "nihil", "molestiae", "et", "iusto", "odio", "dignissimos", "ducimus", "qui", "blanditiis", "praesentium", "laudantium", "totam", "rem", "voluptatum", "deleniti", "atque", "corrupti", "quos", "dolores", "et", "quas", "molestias", "excepturi", "sint", "occaecati", "cupiditate", "non", "provident", "sed", "ut", "perspiciatis", "unde", "omnis", "iste", "natus", "error", "similique", "sunt", "in", "culpa", "qui", "officia", "deserunt", "mollitia", "animi", "id", "est", "laborum", "et", "dolorum", "fuga", "et", "harum", "quidem", "rerum", "facilis", "est", "et", "expedita", "distinctio", "nam", "libero", "tempore", "cum", "soluta", "nobis", "est", "eligendi", "optio", "cumque", "nihil", "impedit", "quo", "porro", "quisquam", "est", "qui", "minus", "id", "quod", "maxime", "placeat", "facere", "possimus", "omnis", "voluptas", "assumenda", "est", "omnis", "dolor", "repellendus", "temporibus", "autem", "quibusdam", "et", "aut", "consequatur", "vel", "illum", "qui", "dolorem", "eum", "fugiat", "quo", "voluptas", "nulla", "pariatur", "at", "vero", "eos", "et", "accusamus", "officiis", "debitis", "aut", "rerum", "necessitatibus", "saepe", "eveniet", "ut", "et", "voluptates", "repudiandae", "sint", "et", "molestiae", "non", "recusandae", "itaque", "earum", "rerum", "hic", "tenetur", "a", "sapiente", "delectus", "ut", "aut", "reiciendis", "voluptatibus", "maiores", "doloribus", "asperiores", "repellat"] let timestampNow: TimeInterval = Date().timeIntervalSince1970 let userSessionId: String = getUserHexEncodedPublicKey() @@ -104,40 +123,46 @@ enum MockDataGenerator { var dmThreadRandomGenerator: ARC4RandomNumberGenerator = ARC4RandomNumberGenerator(seed: dmRandomSeed) (0.. String? { - if let nickname = nickname { return nickname } - switch context { - case .regular: return name - case .openGroup: - // In open groups, where it's more likely that multiple users have the same name, we display a bit of the Session ID after - // a user's display name for added context. - guard let name = name else { return nil } - let endIndex = sessionID.endIndex - let cutoffIndex = sessionID.index(endIndex, offsetBy: -8) - return "\(name) (...\(sessionID[cutoffIndex.. Bool { - guard let other = other as? Contact else { return false } - return sessionID == other.sessionID - } - - // MARK: Hashing - override public var hash: Int { // Override NSObject.hash and not Hashable.hashValue or Hashable.hash(into:) - return sessionID.hash - } - - // MARK: Description - override public var description: String { - nickname ?? name ?? sessionID - } - - // MARK: Convenience - @objc(contextForThread:) - public static func context(for thread: TSThread) -> Context { - return ((thread as? TSGroupThread)?.isOpenGroup == true) ? .openGroup : .regular - } -} diff --git a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift index 79e14cd58..459a8470d 100644 --- a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift +++ b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift @@ -2,5 +2,212 @@ import Foundation -enum Legacy { +public enum Legacy { + // MARK: - Collections and Keys + + internal static let contactThreadPrefix = "c" + internal static let threadCollection = "TSThread" + internal static let contactCollection = "LokiContactCollection" + + // MARK: - Types + + public typealias Contact = _LegacyContact + + @objc(SNProfile) + public class Profile: NSObject, NSCoding { + public var displayName: String? + public var profileKey: Data? + public var profilePictureURL: String? + + internal init(displayName: String, profileKey: Data? = nil, profilePictureURL: String? = nil) { + self.displayName = displayName + self.profileKey = profileKey + self.profilePictureURL = profilePictureURL + } + + public required init?(coder: NSCoder) { + if let displayName = coder.decodeObject(forKey: "displayName") as! String? { self.displayName = displayName } + if let profileKey = coder.decodeObject(forKey: "profileKey") as! Data? { self.profileKey = profileKey } + if let profilePictureURL = coder.decodeObject(forKey: "profilePictureURL") as! String? { self.profilePictureURL = profilePictureURL } + } + + public func encode(with coder: NSCoder) { + coder.encode(displayName, forKey: "displayName") + coder.encode(profileKey, forKey: "profileKey") + coder.encode(profilePictureURL, forKey: "profilePictureURL") + } + + public static func fromProto(_ proto: SNProtoDataMessage) -> Profile? { + guard let profileProto = proto.profile, let displayName = profileProto.displayName else { return nil } + let profileKey = proto.profileKey + let profilePictureURL = profileProto.profilePicture + if let profileKey = profileKey, let profilePictureURL = profilePictureURL { + return Profile(displayName: displayName, profileKey: profileKey, profilePictureURL: profilePictureURL) + } else { + return Profile(displayName: displayName) + } + } + + public func toProto() -> SNProtoDataMessage? { + guard let displayName = displayName else { + SNLog("Couldn't construct profile proto from: \(self).") + return nil + } + let dataMessageProto = SNProtoDataMessage.builder() + let profileProto = SNProtoDataMessageLokiProfile.builder() + profileProto.setDisplayName(displayName) + if let profileKey = profileKey, let profilePictureURL = profilePictureURL { + dataMessageProto.setProfileKey(profileKey) + profileProto.setProfilePicture(profilePictureURL) + } + do { + dataMessageProto.setProfile(try profileProto.build()) + return try dataMessageProto.build() + } catch { + SNLog("Couldn't construct profile proto from: \(self).") + return nil + } + } + + // MARK: Description + public override var description: String { + """ + Profile( + displayName: \(displayName ?? "null"), + profileKey: \(profileKey?.description ?? "null"), + profilePictureURL: \(profilePictureURL ?? "null") + ) + """ + } + } +} + +// Note: Looks like Swift doesn't expose nested types well (in the `-Swift` header this was +// appearing with `SWIFT_CLASS_NAME("Contact")` which conflicts with the new type and has a +// different structure) as a result we cannot nest this cleanly +@objc(SNContact) +public class _LegacyContact: NSObject, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility + @objc public let sessionID: String + /// The URL from which to fetch the contact's profile picture. + @objc public var profilePictureURL: String? + /// The file name of the contact's profile picture on local storage. + @objc public var profilePictureFileName: String? + /// The key with which the profile is encrypted. + @objc public var profileEncryptionKey: OWSAES256Key? + /// The ID of the thread associated with this contact. + @objc public var threadID: String? + /// This flag is used to determine whether we should auto-download files sent by this contact. + @objc public var isTrusted = false + /// This flag is used to determine whether message requests from this contact are approved + @objc public var isApproved = false + /// This flag is used to determine whether message requests from this contact are blocked + @objc public var isBlocked = false { + didSet { + if isBlocked { + hasBeenBlocked = true + } + } + } + /// This flag is used to determine whether this contact has approved the current users message request + @objc public var didApproveMe = false + /// This flag is used to determine whether this contact has ever been blocked (will be included in the config message if so) + @objc public var hasBeenBlocked = false + + // MARK: Name + /// The name of the contact. Use this whenever you need the "real", underlying name of a user (e.g. when sending a message). + @objc public var name: String? + /// The contact's nickname, if the user set one. + @objc public var nickname: String? + /// The name to display in the UI. For local use only. + @objc public func displayName(for context: Context) -> String? { + if let nickname = nickname { return nickname } + switch context { + case .regular: return name + case .openGroup: + // In open groups, where it's more likely that multiple users have the same name, we display a bit of the Session ID after + // a user's display name for added context. + guard let name = name else { return nil } + let endIndex = sessionID.endIndex + let cutoffIndex = sessionID.index(endIndex, offsetBy: -8) + return "\(name) (...\(sessionID[cutoffIndex.. Bool { + guard let other = other as? _LegacyContact else { return false } + return sessionID == other.sessionID + } + + // MARK: Hashing + override public var hash: Int { // Override NSObject.hash and not Hashable.hashValue or Hashable.hash(into:) + return sessionID.hash + } + + // MARK: Description + override public var description: String { + nickname ?? name ?? sessionID + } + + // MARK: Convenience + @objc(contextForThread:) + public static func context(for thread: TSThread) -> Context { + return ((thread as? TSGroupThread)?.isOpenGroup == true) ? .openGroup : .regular + } } diff --git a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift index 0c5de884d..80e43bf44 100644 --- a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -4,26 +4,40 @@ import Foundation import GRDB import SessionUtilitiesKit -// TODO: Remove/Move these -struct Place: Codable, FetchableRecord, PersistableRecord, ColumnExpressible { - static var databaseTableName: String { "place" } - - public enum Columns: String, CodingKey, ColumnExpression { - case id - case name - } - - let id: String - let name: String -} - enum _001_InitialSetupMigration: Migration { static let identifier: String = "initialSetup" static func migrate(_ db: Database) throws { - try db.create(table: Place.self) { t in - t.column(.id, .text).notNull().primaryKey() + try db.create(table: Contact.self) { t in + t.column(.id, .text) + .notNull() + .primaryKey() + t.column(.isTrusted, .boolean) + .notNull() + .defaults(to: false) + t.column(.isApproved, .boolean) + .notNull() + .defaults(to: false) + t.column(.isBlocked, .boolean) + .notNull() + .defaults(to: false) + t.column(.didApproveMe, .boolean) + .notNull() + .defaults(to: false) + t.column(.hasBeenBlocked, .boolean) + .notNull() + .defaults(to: false) + } + + try db.create(table: Profile.self) { t in + t.column(.id, .text) + .notNull() + .primaryKey() t.column(.name, .text).notNull() + t.column(.nickname, .text) + t.column(.profilePictureUrl, .text) + t.column(.profilePictureFileName, .text) + t.column(.profileEncryptionKey, .blob) } } } diff --git a/SessionMessagingKit/Database/Migrations/_002_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_002_YDBToGRDBMigration.swift index 3ccc6a5cf..9559a38d6 100644 --- a/SessionMessagingKit/Database/Migrations/_002_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_002_YDBToGRDBMigration.swift @@ -7,8 +7,65 @@ import SessionUtilitiesKit enum _002_YDBToGRDBMigration: Migration { static let identifier: String = "YDBToGRDBMigration" + // TODO: Autorelease pool???. static func migrate(_ db: Database) throws { + // MARK: - Contacts + var contacts: Set = [] + var contactThreadIds: Set = [] + Storage.read { transaction in + // Process the Contacts + transaction.enumerateRows(inCollection: Legacy.contactCollection) { _, object, _, _ in + guard let contact = object as? Legacy.Contact else { return } + contacts.insert(contact) + } + + // Process the contact threads (only want to create "real" contacts in the new structure) + transaction.enumerateKeys(inCollection: Legacy.threadCollection) { key, _ in + guard key.starts(with: Legacy.contactThreadPrefix) else { return } + contactThreadIds.insert(key) + } + } + + // Insert the data into GRDB + + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) + + try contacts.forEach { contact in + let isCurrentUser: Bool = (contact.sessionID == currentUserPublicKey) + let contactThreadId: String = TSContactThread.threadID(fromContactSessionID: contact.sessionID) + + // Determine if this contact is a "real" contact + if + // TODO: Thread.shouldBeVisible??? + isCurrentUser || + contactThreadIds.contains(contactThreadId) || + contact.isApproved || + contact.didApproveMe || + contact.isBlocked || + contact.hasBeenBlocked { + // Create the contact + // TODO: Closed group admins??? + try Contact( + id: contact.sessionID, + isTrusted: (isCurrentUser || contact.isTrusted), + isApproved: (isCurrentUser || contact.isApproved), + isBlocked: (!isCurrentUser && contact.isBlocked), + didApproveMe: (isCurrentUser || contact.didApproveMe), + hasBeenBlocked: (!isCurrentUser && (contact.hasBeenBlocked || contact.isBlocked)) + ).insert(db) + } + + // Create the "Profile" for the legacy contact + try Profile( + id: contact.sessionID, + name: (contact.name ?? contact.sessionID), + nickname: contact.nickname, + profilePictureUrl: contact.profilePictureURL, + profilePictureFileName: contact.profilePictureFileName, + profileEncryptionKey: contact.profileEncryptionKey + ).insert(db) + } } } diff --git a/SessionMessagingKit/Database/Models/Contact.swift b/SessionMessagingKit/Database/Models/Contact.swift new file mode 100644 index 000000000..9294d5c23 --- /dev/null +++ b/SessionMessagingKit/Database/Models/Contact.swift @@ -0,0 +1,44 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public struct Contact: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "contact" } + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case id + + case isTrusted + case isApproved + case isBlocked + case didApproveMe + case hasBeenBlocked + } + + /// The id for the contact (Note: This could be a sessionId, a blindedId or some future variant) + public let id: String + + /// This flag is used to determine whether we should auto-download files sent by this contact. + public var isTrusted = false + + /// This flag is used to determine whether message requests from this contact are approved + public var isApproved = false + + /// This flag is used to determine whether message requests from this contact are blocked + public var isBlocked = false { + didSet { + if isBlocked { + hasBeenBlocked = true + } + } + } + + /// This flag is used to determine whether this contact has approved the current users message request + public var didApproveMe = false + + /// This flag is used to determine whether this contact has ever been blocked (will be included in the config message if so) + public var hasBeenBlocked = false +} diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift new file mode 100644 index 000000000..ad743862d --- /dev/null +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -0,0 +1,174 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public struct Profile: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible, CustomStringConvertible { + public static var databaseTableName: String { "profile" } + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case id + + case name = "displayName" + case nickname + + case profilePictureUrl = "profilePictureURL" + case profilePictureFileName + case profileEncryptionKey + } + + /// The id for the user that owns the profile (Note: This could be a sessionId, a blindedId or some future variant) + public let id: String + + /// The name of the contact. Use this whenever you need the "real", underlying name of a user (e.g. when sending a message). + public var name: String + + /// A custom name for the profile set by the current user + public var nickname: String? + + /// The URL from which to fetch the contact's profile picture. + public var profilePictureUrl: String? + + /// The file name of the contact's profile picture on local storage. + public var profilePictureFileName: String? + + /// The key with which the profile is encrypted. + public var profileEncryptionKey: OWSAES256Key? + + // MARK: - Description + + public var description: String { + """ + Profile( + displayName: \(name), + profileKey: \(profileEncryptionKey?.keyData.description ?? "null"), + profilePictureURL: \(profilePictureUrl ?? "null") + ) + """ + } +} + +// MARK: - Codable + +public extension Profile { + init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + var profileKey: OWSAES256Key? + var profilePictureUrl: String? + + // If we have both a `profileKey` and a `profilePicture` then the key MUST be valid + if + let profileKeyData: Data = try? container.decode(Data.self, forKey: .profileEncryptionKey), + let profilePictureUrlValue: String = try? container.decode(String.self, forKey: .profilePictureUrl) + { + guard let validProfileKey: OWSAES256Key = OWSAES256Key(data: profileKeyData) else { + owsFailDebug("Failed to make profile key for key data") + throw GRDBStorageError.decodingFailed + } + + profileKey = validProfileKey + profilePictureUrl = profilePictureUrlValue + } + + self = Profile( + id: try container.decode(String.self, forKey: .id), + name: try container.decode(String.self, forKey: .name), + nickname: try? container.decode(String.self, forKey: .nickname), + profilePictureUrl: profilePictureUrl, + profilePictureFileName: try? container.decode(String.self, forKey: .profilePictureFileName), + profileEncryptionKey: profileKey + ) + } + + func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) + try container.encode(nickname, forKey: .nickname) + try container.encode(profilePictureUrl, forKey: .profilePictureUrl) + try container.encode(profilePictureFileName, forKey: .profilePictureFileName) + try container.encode(profileEncryptionKey?.keyData, forKey: .profileEncryptionKey) + } +} + +// MARK: - Protobuf + +public extension Profile { + static func fromProto(_ proto: SNProtoDataMessage, id: String) -> Profile? { + guard let profileProto = proto.profile, let displayName = profileProto.displayName else { return nil } + + var profileKey: OWSAES256Key? + var profilePictureUrl: String? + + // If we have both a `profileKey` and a `profilePicture` then the key MUST be valid + if let profileKeyData: Data = proto.profileKey, profileProto.profilePicture != nil { + guard let validProfileKey: OWSAES256Key = OWSAES256Key(data: profileKeyData) else { + owsFailDebug("Failed to make profile key for key data") + return nil + } + + profileKey = validProfileKey + profilePictureUrl = profileProto.profilePicture + } + + return Profile( + id: id, + name: displayName, + nickname: nil, + profilePictureUrl: profilePictureUrl, + profilePictureFileName: nil, + profileEncryptionKey: profileKey + ) + } + + func toProto() -> SNProtoDataMessage? { + let dataMessageProto = SNProtoDataMessage.builder() + let profileProto = SNProtoDataMessageLokiProfile.builder() + profileProto.setDisplayName(name) + + if let profileKey: OWSAES256Key = profileEncryptionKey, let profilePictureUrl: String = profilePictureUrl { + dataMessageProto.setProfileKey(profileKey.keyData) + profileProto.setProfilePicture(profilePictureUrl) + } + + do { + dataMessageProto.setProfile(try profileProto.build()) + return try dataMessageProto.build() + } + catch { + SNLog("Couldn't construct profile proto from: \(self).") + return nil + } + } +} + +// MARK: - Convenience + +public extension Profile { + // MARK: - Context + + enum Context: Int { + case regular + case openGroup + } + + /// The name to display in the UI. For local use only. + func displayName(for context: Context) -> String? { + if let nickname: String = nickname { return nickname } + + switch context { + case .regular: return name + + case .openGroup: + // In open groups, where it's more likely that multiple users have the same name, we display a bit of the Session ID after + // a user's display name for added context. + let endIndex = id.endIndex + let cutoffIndex = id.index(endIndex, offsetBy: -8) + return "\(name) (...\(id[cutoffIndex.. ECKeyPair? { - return GRDBStorage.shared.read { db -> ECKeyPair? in + static func fetchUserKeyPair(_ db: Database? = nil) -> ECKeyPair? { + let fetchKeys: (Database) -> ECKeyPair? = { db in guard let publicKey: Identity = try? Identity.fetchOne(db, id: .x25519PublicKey), let privateKey: Identity = try? Identity.fetchOne(db, id: .x25519PrivateKey) @@ -83,6 +83,14 @@ public extension Identity { privateKeyData: privateKey.data ) } + + if let db: Database = db { + return fetchKeys(db) + } + + return GRDBStorage.shared.read { db -> ECKeyPair? in + return fetchKeys(db) + } } static func fetchUserEd25519KeyPair() -> Box.KeyPair? { diff --git a/SessionUtilitiesKit/General/General.swift b/SessionUtilitiesKit/General/General.swift index d25e5b774..9a67b6675 100644 --- a/SessionUtilitiesKit/General/General.swift +++ b/SessionUtilitiesKit/General/General.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB import Curve25519Kit public enum General { @@ -20,10 +21,10 @@ public class GeneralUtilities: NSObject { } } -public func getUserHexEncodedPublicKey() -> String { +public func getUserHexEncodedPublicKey(_ db: Database? = nil) -> String { if let cachedKey: String = General.Cache.cachedEncodedPublicKey.wrappedValue { return cachedKey } - if let keyPair: ECKeyPair = Identity.fetchUserKeyPair() { // Can be nil under some circumstances + if let keyPair: ECKeyPair = Identity.fetchUserKeyPair(db) { // Can be nil under some circumstances General.Cache.cachedEncodedPublicKey.mutate { $0 = keyPair.hexEncodedPublicKey } return keyPair.hexEncodedPublicKey } From cf66edb72341a3d47ca846f265620d6bc6a7c9f4 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 6 Apr 2022 15:43:26 +1000 Subject: [PATCH 059/157] Further work on SessionMessagingKit migrations Added migrations for contacts and started working through thread migration (have contact and closed group threads migrating) Deprecated usage of ECKeyPair in the migrations (want to be able to remove Curve25519Kit in the future) --- Session.xcodeproj/project.pbxproj | 64 +- Session/Closed Groups/EditClosedGroupVC.swift | 12 +- Session/Closed Groups/NewClosedGroupVC.swift | 11 +- .../ConversationVC+Interaction.swift | 226 +-- Session/Conversations/ConversationVC.swift | 40 +- Session/Conversations/ConversationViewModel.m | 11 +- .../Content Views/QuoteView.swift | 8 +- .../Message Cells/InfoMessageCell.swift | 9 +- .../Message Cells/VisibleMessageCell.swift | 28 +- .../OWSConversationSettingsViewController.m | 37 +- .../Views & Modals/BlockedModal.swift | 26 +- .../ConversationTitleView.swift | 29 +- .../DownloadAttachmentModal.swift | 35 +- .../Views & Modals/JoinOpenGroupModal.swift | 4 +- .../Views & Modals/UserDetailsSheet.swift | 8 +- Session/DMs/NewDMVC.swift | 5 + Session/Home/HomeVC.swift | 71 +- .../MessageRequestsViewController.swift | 136 +- .../MediaPageViewController.swift | 4 +- Session/Meta/AppDelegate.m | 17 +- Session/Meta/AppDelegate.swift | 28 +- Session/Notifications/AppNotifications.swift | 3 +- Session/Onboarding/DisplayNameVC.swift | 6 +- Session/Onboarding/Onboarding.swift | 15 +- Session/Onboarding/RegisterVC.swift | 4 + Session/Onboarding/SeedVC.swift | 2 +- Session/Open Groups/JoinOpenGroupVC.swift | 6 +- Session/Settings/NukeDataModal.swift | 16 +- Session/Settings/QRCodeVC.swift | 5 + Session/Settings/SeedModal.swift | 2 +- Session/Settings/SettingsVC.swift | 72 +- Session/Shared/ConversationCell.swift | 25 +- Session/Shared/UserCell.swift | 2 +- Session/Shared/UserSelectionVC.swift | 6 +- Session/Utilities/AccountManager.swift | 4 - Session/Utilities/MentionUtilities.swift | 4 +- Session/Utilities/MockDataGenerator.swift | 13 +- .../LegacyDatabase/SMKLegacyModels.swift | 16 +- .../_001_InitialSetupMigration.swift | 51 + .../Migrations/_002_YDBToGRDBMigration.swift | 215 ++- .../Database/Models/Capability.swift | 22 + .../Database/Models/ClosedGroup.swift | 48 + .../Database/Models/ClosedGroupKeyPair.swift | 22 + .../Database/Models/Contact.swift | 132 +- .../DisappearingMessageConfiguration.swift | 64 + .../Database/Models/GroupMember.swift | 27 + .../Database/Models/OpenGroup.swift | 50 + .../Database/Models/Profile.swift | 208 ++- .../Database/Models/SessionThread.swift | 59 + .../Database/Notification+Contacts.swift | 19 +- .../Database/Storage+ClosedGroups.swift | 19 +- .../Database/Storage+Contacts.swift | 77 - .../Database/Storage+Shared.swift | 40 +- SessionMessagingKit/Database/TSDatabaseView.m | 2 +- .../Jobs/AttachmentUploadJob.swift | 3 + .../Jobs/MessageReceiveJob.swift | 46 +- .../ClosedGroupControlMessage.swift | 28 +- .../ConfigurationMessage+Convenience.swift | 121 +- .../ConfigurationMessage.swift | 18 +- .../DataExtractionNotification.swift | 2 +- .../ExpirationTimerUpdate.swift | 2 +- .../MessageRequestResponse.swift | 2 +- .../Control Messages/ReadReceipt.swift | 2 +- .../Control Messages/TypingIndicator.swift | 2 +- .../Control Messages/UnsendRequest.swift | 2 +- SessionMessagingKit/Messages/Message.swift | 4 +- .../Messages/Signal/TSMessage.m | 2 +- .../Signal/TSOutgoingMessage+Conversion.swift | 9 +- .../Signal/TypingIndicatorInteraction.swift | 5 +- .../VisibleMessage+Profile.swift | 144 +- .../Visible Messages/VisibleMessage.swift | 10 +- .../Open Groups/OpenGroupAPIV2.swift | 12 +- .../Open Groups/OpenGroupMessageV2.swift | 3 +- ...ataExtractionNotificationInfoMessage.swift | 25 +- ...sappearingConfigurationUpdateInfoMessage.m | 3 +- .../OWSDisappearingMessagesConfiguration.h | 66 +- .../OWSDisappearingMessagesConfiguration.m | 256 +-- .../Expiration/OWSDisappearingMessagesJob.m | 7 +- .../Link Previews/OWSLinkPreview.swift | 5 +- .../Mentions/MentionsManager.swift | 6 +- .../MessageReceiver+Decryption.swift | 6 +- .../MessageReceiver+Handling.swift | 180 +- .../Sending & Receiving/MessageReceiver.swift | 27 +- .../MessageSender+ClosedGroups.swift | 29 +- .../Sending & Receiving/MessageSender.swift | 2 +- .../Pollers/OpenGroupPollerV2.swift | 30 +- SessionMessagingKit/Storage.swift | 9 +- SessionMessagingKit/Threads/TSContactThread.m | 14 +- SessionMessagingKit/Threads/TSGroupModel.m | 6 +- SessionMessagingKit/Threads/TSThread.h | 1 + SessionMessagingKit/Threads/TSThread.m | 1 - SessionMessagingKit/To Do/OWSUserProfile.h | 8 +- SessionMessagingKit/To Do/OWSUserProfile.m | 34 +- .../To Do/ProfileManagerProtocol.h | 56 +- .../Utilities/BoxKeyPair+Utilities.swift | 12 + .../Utilities/FullTextSearchFinder.swift | 16 +- .../Utilities/OWSAES256Key+Utilities.swift | 12 + .../Utilities/OWSAudioSession.swift | 5 +- SessionMessagingKit/Utilities/OWSSounds.swift | 5 + .../Utilities/ProfileManager.swift | 351 ++++ SessionMessagingKit/Utilities/ProtoUtils.h | 44 +- SessionMessagingKit/Utilities/ProtoUtils.m | 96 +- .../ProximityMonitoringManager.swift | 7 +- .../Utilities/SSKEnvironment.h | 3 +- .../Utilities/SSKEnvironment.m | 5 +- .../NSENotificationPresenter.swift | 11 +- .../NotificationServiceExtension.swift | 60 +- SessionShareExtension/ShareVC.swift | 4 +- .../SimplifiedConversationCell.swift | 8 +- SessionSnodeKit/Configuration.swift | 2 + SessionUtilitiesKit/Configuration.swift | 3 + .../Database/GRDBStorage.swift | 5 +- .../LegacyDatabase/SUKLegacyModels.swift | 37 +- .../Migrations/_002_YDBToGRDBMigration.swift | 18 +- .../Database/Models/Identity.swift | 53 +- .../Database/Models/Setting.swift | 4 +- SessionUtilitiesKit/General/General.swift | 7 +- .../General/SNUserDefaults.swift | 1 + SessionUtilitiesKit/Media/Data+Image.swift | 154 ++ SessionUtilitiesKit/Media/ImageFormat.swift | 12 + SessionUtilitiesKit/Media/Updatable.swift | 113 ++ .../Utilities/Notification+Utilities.swift | 39 + .../Utilities/Optional+Utilities.swift | 23 + .../BlockingManagerRemovalMigration.swift | 12 +- .../Migrations/ContactsMigration.swift | 16 +- .../Migrations/MessageRequestsMigration.swift | 52 +- .../Database/ThreadViewHelper.h | 54 +- .../Database/ThreadViewHelper.m | 434 ++--- .../MessageApprovalViewController.swift | 6 +- .../Messaging/BlockListUIUtils.swift | 39 +- .../Messaging/FullTextSearcher.swift | 5 +- .../MessageSender+Convenience.swift | 44 +- SignalUtilitiesKit/Meta/SignalUtilitiesKit.h | 1 - .../Profile Pictures/ProfilePictureView.swift | 14 +- SignalUtilitiesKit/To Do/ContactCellView.m | 15 +- SignalUtilitiesKit/To Do/OWSProfileManager.h | 124 +- SignalUtilitiesKit/To Do/OWSProfileManager.m | 1468 ++++++++--------- SignalUtilitiesKit/Utilities/AppSetup.m | 5 +- 138 files changed, 4255 insertions(+), 2397 deletions(-) create mode 100644 SessionMessagingKit/Database/Models/Capability.swift create mode 100644 SessionMessagingKit/Database/Models/ClosedGroup.swift create mode 100644 SessionMessagingKit/Database/Models/ClosedGroupKeyPair.swift create mode 100644 SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift create mode 100644 SessionMessagingKit/Database/Models/GroupMember.swift create mode 100644 SessionMessagingKit/Database/Models/OpenGroup.swift create mode 100644 SessionMessagingKit/Database/Models/SessionThread.swift delete mode 100644 SessionMessagingKit/Database/Storage+Contacts.swift create mode 100644 SessionMessagingKit/Utilities/BoxKeyPair+Utilities.swift create mode 100644 SessionMessagingKit/Utilities/OWSAES256Key+Utilities.swift create mode 100644 SessionMessagingKit/Utilities/ProfileManager.swift create mode 100644 SessionUtilitiesKit/Media/Data+Image.swift create mode 100644 SessionUtilitiesKit/Media/ImageFormat.swift create mode 100644 SessionUtilitiesKit/Media/Updatable.swift create mode 100644 SessionUtilitiesKit/Utilities/Notification+Utilities.swift create mode 100644 SessionUtilitiesKit/Utilities/Optional+Utilities.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 0dbabb85a..33dfe7c0c 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -230,7 +230,6 @@ B897621C25D201F7004F83B2 /* ScrollToBottomButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B897621B25D201F7004F83B2 /* ScrollToBottomButton.swift */; }; B8AE75A425A6C6A6001A84D2 /* Data+Trimming.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8AE75A325A6C6A6001A84D2 /* Data+Trimming.swift */; }; B8AF4BB426A5204600583500 /* SendSeedModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8AF4BB326A5204600583500 /* SendSeedModal.swift */; }; - B8B32033258B235D0020074B /* Storage+Contacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B32032258B235D0020074B /* Storage+Contacts.swift */; }; B8B3204E258C15C80020074B /* ContactsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B32044258C117C0020074B /* ContactsMigration.swift */; }; B8B320B7258C30D70020074B /* HTMLMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B320B6258C30D70020074B /* HTMLMetadata.swift */; }; B8BB82A5238F627000BA5194 /* HomeVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8BB82A4238F627000BA5194 /* HomeVC.swift */; }; @@ -739,6 +738,21 @@ FD09796B27F6C67500936362 /* Failable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09796A27F6C67500936362 /* Failable.swift */; }; FD09796E27FA6D0000936362 /* Contact.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09796D27FA6D0000936362 /* Contact.swift */; }; FD09797027FA6FF300936362 /* Profile.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09796F27FA6FF300936362 /* Profile.swift */; }; + FD09797227FAA2F500936362 /* Optional+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09797127FAA2F500936362 /* Optional+Utilities.swift */; }; + FD09797527FAB64300936362 /* ProfileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09797327FAB3E200936362 /* ProfileManager.swift */; }; + FD09797727FAB7A600936362 /* Data+Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09797627FAB7A600936362 /* Data+Image.swift */; }; + FD09797927FAB7E800936362 /* ImageFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09797827FAB7E800936362 /* ImageFormat.swift */; }; + FD09797B27FBB25900936362 /* Updatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09797A27FBB25900936362 /* Updatable.swift */; }; + FD09797D27FBDB2000936362 /* Notification+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09797C27FBDB2000936362 /* Notification+Utilities.swift */; }; + FD09797F27FCFBFF00936362 /* OWSAES256Key+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09797E27FCFBFF00936362 /* OWSAES256Key+Utilities.swift */; }; + FD09798127FCFEE800936362 /* SessionThread.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09798027FCFEE800936362 /* SessionThread.swift */; }; + FD09798327FD1A1500936362 /* ClosedGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09798227FD1A1500936362 /* ClosedGroup.swift */; }; + FD09798527FD1A6500936362 /* ClosedGroupKeyPair.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09798427FD1A6500936362 /* ClosedGroupKeyPair.swift */; }; + FD09798727FD1B7800936362 /* GroupMember.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09798627FD1B7800936362 /* GroupMember.swift */; }; + FD09798927FD1C5A00936362 /* OpenGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09798827FD1C5A00936362 /* OpenGroup.swift */; }; + FD09798B27FD1CFE00936362 /* Capability.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09798A27FD1CFE00936362 /* Capability.swift */; }; + FD09798D27FD1D8900936362 /* DisappearingMessageConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09798C27FD1D8900936362 /* DisappearingMessageConfiguration.swift */; }; + FD09799127FD499200936362 /* BoxKeyPair+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799027FD499200936362 /* BoxKeyPair+Utilities.swift */; }; FD17D79927F40AB800122BE0 /* _002_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79827F40AB800122BE0 /* _002_YDBToGRDBMigration.swift */; }; FD17D79C27F40B2E00122BE0 /* SMKLegacyModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */; }; FD17D7A027F40CC800122BE0 /* _001_InitialSetupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79F27F40CC800122BE0 /* _001_InitialSetupMigration.swift */; }; @@ -1227,7 +1241,6 @@ B897621B25D201F7004F83B2 /* ScrollToBottomButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollToBottomButton.swift; sourceTree = ""; }; B8AE75A325A6C6A6001A84D2 /* Data+Trimming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Trimming.swift"; sourceTree = ""; }; B8AF4BB326A5204600583500 /* SendSeedModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendSeedModal.swift; sourceTree = ""; }; - B8B32032258B235D0020074B /* Storage+Contacts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+Contacts.swift"; sourceTree = ""; }; B8B32044258C117C0020074B /* ContactsMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsMigration.swift; sourceTree = ""; }; B8B320B6258C30D70020074B /* HTMLMetadata.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTMLMetadata.swift; sourceTree = ""; }; B8B5BCEB2394D869003823C9 /* Button.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Button.swift; sourceTree = ""; }; @@ -1772,6 +1785,21 @@ FD09796A27F6C67500936362 /* Failable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Failable.swift; sourceTree = ""; }; FD09796D27FA6D0000936362 /* Contact.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Contact.swift; sourceTree = ""; }; FD09796F27FA6FF300936362 /* Profile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Profile.swift; sourceTree = ""; }; + FD09797127FAA2F500936362 /* Optional+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Utilities.swift"; sourceTree = ""; }; + FD09797327FAB3E200936362 /* ProfileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileManager.swift; sourceTree = ""; }; + FD09797627FAB7A600936362 /* Data+Image.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Image.swift"; sourceTree = ""; }; + FD09797827FAB7E800936362 /* ImageFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageFormat.swift; sourceTree = ""; }; + FD09797A27FBB25900936362 /* Updatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Updatable.swift; sourceTree = ""; }; + FD09797C27FBDB2000936362 /* Notification+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Utilities.swift"; sourceTree = ""; }; + FD09797E27FCFBFF00936362 /* OWSAES256Key+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OWSAES256Key+Utilities.swift"; sourceTree = ""; }; + FD09798027FCFEE800936362 /* SessionThread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionThread.swift; sourceTree = ""; }; + FD09798227FD1A1500936362 /* ClosedGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClosedGroup.swift; sourceTree = ""; }; + FD09798427FD1A6500936362 /* ClosedGroupKeyPair.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClosedGroupKeyPair.swift; sourceTree = ""; }; + FD09798627FD1B7800936362 /* GroupMember.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMember.swift; sourceTree = ""; }; + FD09798827FD1C5A00936362 /* OpenGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroup.swift; sourceTree = ""; }; + FD09798A27FD1CFE00936362 /* Capability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Capability.swift; sourceTree = ""; }; + FD09798C27FD1D8900936362 /* DisappearingMessageConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisappearingMessageConfiguration.swift; sourceTree = ""; }; + FD09799027FD499200936362 /* BoxKeyPair+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BoxKeyPair+Utilities.swift"; sourceTree = ""; }; FD17D79527F3E04600122BE0 /* _001_InitialSetupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _001_InitialSetupMigration.swift; sourceTree = ""; }; FD17D79827F40AB800122BE0 /* _002_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_YDBToGRDBMigration.swift; sourceTree = ""; }; FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMKLegacyModels.swift; sourceTree = ""; }; @@ -2314,13 +2342,16 @@ children = ( C33FDB54255A580D00E217F9 /* DataSource.h */, C33FDBB6255A581600E217F9 /* DataSource.m */, + FD09797827FAB7E800936362 /* ImageFormat.swift */, C33FDAFC255A580600E217F9 /* MIMETypeUtil.h */, C33FDB41255A580C00E217F9 /* MIMETypeUtil.m */, C33FDB29255A580A00E217F9 /* NSData+Image.h */, C33FDAEF255A580500E217F9 /* NSData+Image.m */, + FD09797627FAB7A600936362 /* Data+Image.swift */, C33FDB22255A580900E217F9 /* OWSMediaUtils.swift */, C33FDB1C255A580900E217F9 /* UIImage+OWS.h */, C33FDB81255A581100E217F9 /* UIImage+OWS.m */, + FD09797A27FBB25900936362 /* Updatable.swift */, ); path = Media; sourceTree = ""; @@ -2666,7 +2697,6 @@ C33FDAB1255A580000E217F9 /* OWSStorage.m */, C33FDAB9255A580100E217F9 /* OWSStorage+Subclass.h */, B8D8F1372566120F0092EF10 /* Storage+ClosedGroups.swift */, - B8B32032258B235D0020074B /* Storage+Contacts.swift */, B8D8F17625661AFA0092EF10 /* Storage+Jobs.swift */, B8D8F19225661BF80092EF10 /* Storage+Messaging.swift */, B8D8F18825661BA50092EF10 /* Storage+OpenGroups.swift */, @@ -3148,6 +3178,7 @@ children = ( C33FDB01255A580700E217F9 /* AppReadiness.h */, C33FDB75255A581000E217F9 /* AppReadiness.m */, + FD09799027FD499200936362 /* BoxKeyPair+Utilities.swift */, C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */, C37F53E8255BA9BB002AEA92 /* Environment.h */, C37F5402255BA9ED002AEA92 /* Environment.m */, @@ -3157,6 +3188,7 @@ C3A71D0A2558989C0043A11F /* MessageWrapper.swift */, C3A71D4E25589FF30043A11F /* NSData+messagePadding.h */, C3A71D4825589FF20043A11F /* NSData+messagePadding.m */, + FD09797E27FCFBFF00936362 /* OWSAES256Key+Utilities.swift */, C38EF2F5255B6DBC007E1867 /* OWSAudioPlayer.h */, C38EF2F7255B6DBC007E1867 /* OWSAudioPlayer.m */, C38EF281255B6D84007E1867 /* OWSAudioSession.swift */, @@ -3175,6 +3207,7 @@ 7B1581E1271E743B00848B49 /* OWSSounds.swift */, C38EF2FB255B6DBD007E1867 /* OWSWindowManager.h */, C38EF306255B6DBE007E1867 /* OWSWindowManager.m */, + FD09797327FAB3E200936362 /* ProfileManager.swift */, C38EF2EC255B6DBA007E1867 /* ProximityMonitoringManager.swift */, C3BBE0B42554F0E10050F1E3 /* ProofOfWork.swift */, C33FDB91255A581200E217F9 /* ProtoUtils.h */, @@ -3558,6 +3591,8 @@ isa = PBXGroup; children = ( FD09796A27F6C67500936362 /* Failable.swift */, + FD09797127FAA2F500936362 /* Optional+Utilities.swift */, + FD09797C27FBDB2000936362 /* Notification+Utilities.swift */, C3E7134E251C867C009649BB /* Sodium+Conversion.swift */, ); path = Utilities; @@ -3568,6 +3603,13 @@ children = ( FD09796D27FA6D0000936362 /* Contact.swift */, FD09796F27FA6FF300936362 /* Profile.swift */, + FD09798027FCFEE800936362 /* SessionThread.swift */, + FD09798C27FD1D8900936362 /* DisappearingMessageConfiguration.swift */, + FD09798227FD1A1500936362 /* ClosedGroup.swift */, + FD09798427FD1A6500936362 /* ClosedGroupKeyPair.swift */, + FD09798827FD1C5A00936362 /* OpenGroup.swift */, + FD09798627FD1B7800936362 /* GroupMember.swift */, + FD09798A27FD1CFE00936362 /* Capability.swift */, ); path = Models; sourceTree = ""; @@ -4720,11 +4762,13 @@ FD17D7C127F5200100122BE0 /* TypedTableDefinition.swift in Sources */, FD17D7EA27F6A1C600122BE0 /* SUKLegacyModels.swift in Sources */, C352A36D2557858E00338F3E /* NSTimer+Proxying.m in Sources */, + FD09797B27FBB25900936362 /* Updatable.swift in Sources */, C32C5A2D256DB849003C73A2 /* LKGroupUtilities.m in Sources */, C3C2ABD22553C6C900C340D1 /* Data+SecureRandom.swift in Sources */, B8856E09256F1676001CE70E /* UIDevice+featureSupport.swift in Sources */, B8856DEF256F161F001CE70E /* NSString+SSK.m in Sources */, FD09796727F6B0B600936362 /* Sodium+Conversion.swift in Sources */, + FD09797927FAB7E800936362 /* ImageFormat.swift in Sources */, C32C5DC9256DD935003C73A2 /* ProxiedContentDownloader.swift in Sources */, C3D9E4C02567767F0040E4F3 /* DataSource.m in Sources */, C3D9E43125676D3D0040E4F3 /* Configuration.swift in Sources */, @@ -4740,6 +4784,7 @@ C3D9E4DA256778410040E4F3 /* UIImage+OWS.m in Sources */, C32C600F256E07F5003C73A2 /* NSUserDefaults+OWS.m in Sources */, C3D9E35E25675F640040E4F3 /* OWSFileSystem.m in Sources */, + FD09797D27FBDB2000936362 /* Notification+Utilities.swift in Sources */, C3C2AC2E2553CBEB00C340D1 /* String+Trimming.swift in Sources */, C32C5B48256DC211003C73A2 /* NSNotificationCenter+OWS.m in Sources */, FD17D7C727F5207C00122BE0 /* DatabaseMigrator+Utilities.swift in Sources */, @@ -4777,10 +4822,12 @@ FD17D7BF27F51F8200122BE0 /* ColumnExpressible.swift in Sources */, FD17D7E527F6A09900122BE0 /* Identity.swift in Sources */, B87EF18126377A1D00124B3C /* Features.swift in Sources */, + FD09797727FAB7A600936362 /* Data+Image.swift in Sources */, C300A60D2554B31900555489 /* Logging.swift in Sources */, B8FF8EA625C11FEF004D1F22 /* IPv4.swift in Sources */, C3D9E35525675EE10040E4F3 /* MIMETypeUtil.m in Sources */, FD705A8E278CE29800F16121 /* String+Localization.swift in Sources */, + FD09797227FAA2F500936362 /* Optional+Utilities.swift in Sources */, C3A7219A2558C1660043A11F /* AnyPromise+Conversion.swift in Sources */, C300A6322554B6D100555489 /* NSDate+Timestamp.mm in Sources */, FD17D7E727F6A16700122BE0 /* _002_YDBToGRDBMigration.swift in Sources */, @@ -4816,6 +4863,7 @@ C300A5FC2554B0A000555489 /* MessageReceiver.swift in Sources */, 7B1581E2271E743B00848B49 /* OWSSounds.swift in Sources */, C32C5A76256DBBCF003C73A2 /* SignalAttachment.swift in Sources */, + FD09798927FD1C5A00936362 /* OpenGroup.swift in Sources */, C32C5CA4256DD1DC003C73A2 /* TSAccountManager.m in Sources */, C352A3892557876500338F3E /* JobQueue.swift in Sources */, C3BBE0B52554F0E10050F1E3 /* ProofOfWork.swift in Sources */, @@ -4824,14 +4872,19 @@ B8856D1A256F114D001CE70E /* ProximityMonitoringManager.swift in Sources */, C3D9E52725677DF20040E4F3 /* OWSThumbnailService.swift in Sources */, C32C5E75256DE020003C73A2 /* YapDatabaseTransaction+OWS.m in Sources */, + FD09797527FAB64300936362 /* ProfileManager.swift in Sources */, C3BBE0802554CDD70050F1E3 /* Storage.swift in Sources */, C3DB66AC260ACA42001EFC55 /* OpenGroupManagerV2.swift in Sources */, B8F5F61B25EDE4BF003BF8D4 /* DataExtractionNotificationInfoMessage.swift in Sources */, C379DCF4256735770002D4EB /* VisibleMessage+Attachment.swift in Sources */, + FD09797F27FCFBFF00936362 /* OWSAES256Key+Utilities.swift in Sources */, + FD09798327FD1A1500936362 /* ClosedGroup.swift in Sources */, B8856D34256F1192001CE70E /* Environment.m in Sources */, B8B320B7258C30D70020074B /* HTMLMetadata.swift in Sources */, C32C5AB1256DBE8F003C73A2 /* TSIncomingMessage.m in Sources */, C3A3A107256E1A5C004D228D /* OWSDisappearingMessagesFinder.m in Sources */, + FD09798727FD1B7800936362 /* GroupMember.swift in Sources */, + FD09799127FD499200936362 /* BoxKeyPair+Utilities.swift in Sources */, C32C59C3256DB41F003C73A2 /* TSGroupModel.m in Sources */, B8856ECE256F1E58001CE70E /* OWSPreferences.m in Sources */, FD17D79927F40AB800122BE0 /* _002_YDBToGRDBMigration.swift in Sources */, @@ -4861,10 +4914,10 @@ C3DA9C0725AE7396008F7C7E /* ConfigurationMessage.swift in Sources */, B8856CEE256F1054001CE70E /* OWSAudioPlayer.m in Sources */, FD5D200F27AA2B6000FEA984 /* MessageRequestResponse.swift in Sources */, + FD09798D27FD1D8900936362 /* DisappearingMessageConfiguration.swift in Sources */, C32C5EDC256DF501003C73A2 /* YapDatabaseConnection+OWS.m in Sources */, C3BBE0762554CDA60050F1E3 /* Configuration.swift in Sources */, C35D76DB26606304009AA5FB /* ThreadUpdateBatcher.swift in Sources */, - B8B32033258B235D0020074B /* Storage+Contacts.swift in Sources */, B8856D69256F141F001CE70E /* OWSWindowManager.m in Sources */, C3D9E3BE25676AD70040E4F3 /* TSAttachmentPointer.m in Sources */, C3ECBF7B257056B700EA7FCE /* Threading.swift in Sources */, @@ -4908,6 +4961,7 @@ C3A3A18A256E2092004D228D /* SignalRecipient.m in Sources */, C3C2A74425539EB700C340D1 /* Message.swift in Sources */, C32C5F11256DF79A003C73A2 /* SSKIncrementingIdFinder.swift in Sources */, + FD09798527FD1A6500936362 /* ClosedGroupKeyPair.swift in Sources */, C32C5DBF256DD743003C73A2 /* ClosedGroupPoller.swift in Sources */, C32C5EEE256DF54E003C73A2 /* TSDatabaseView.m in Sources */, C352A35B2557824E00338F3E /* AttachmentUploadJob.swift in Sources */, @@ -4916,11 +4970,13 @@ C32C5AAE256DBE8F003C73A2 /* TSInteraction.m in Sources */, C32C5D23256DD4C0003C73A2 /* Mention.swift in Sources */, C32C5FD6256E0346003C73A2 /* Notification+Thread.swift in Sources */, + FD09798B27FD1CFE00936362 /* Capability.swift in Sources */, C3BBE0C72554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift in Sources */, C32C5C88256DD0D2003C73A2 /* Storage+Messaging.swift in Sources */, FD17D79C27F40B2E00122BE0 /* SMKLegacyModels.swift in Sources */, C32C59C7256DB41F003C73A2 /* TSThread.m in Sources */, C300A5B22554AF9800555489 /* VisibleMessage+Profile.swift in Sources */, + FD09798127FCFEE800936362 /* SessionThread.swift in Sources */, C32C5A75256DBBCF003C73A2 /* TSAttachmentPointer+Conversion.swift in Sources */, C32C5AF8256DC051003C73A2 /* OWSDisappearingMessagesConfiguration.m in Sources */, C32C5EBA256DE130003C73A2 /* OWSQuotedReplyModel.m in Sources */, diff --git a/Session/Closed Groups/EditClosedGroupVC.swift b/Session/Closed Groups/EditClosedGroupVC.swift index 3a74dea3e..3faf66b3a 100644 --- a/Session/Closed Groups/EditClosedGroupVC.swift +++ b/Session/Closed Groups/EditClosedGroupVC.swift @@ -1,4 +1,8 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit import PromiseKit +import SessionMessagingKit @objc(SNEditClosedGroupVC) final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegate { @@ -73,7 +77,7 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega backButton.tintColor = Colors.text navigationItem.backBarButtonItem = backButton func getDisplayName(for publicKey: String) -> String { - return Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey + return Profile.displayName(for: publicKey) } setUpViewHierarchy() // Always show zombies at the bottom @@ -107,7 +111,7 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega membersLabel.font = .systemFont(ofSize: Values.mediumFontSize) membersLabel.text = "Members" // Add members button - let hasContactsToAdd = !Set(ContactUtilities.getAllContacts()).subtracting(self.membersAndZombies).isEmpty + let hasContactsToAdd = !Set(Contact.fetchAllIds()).subtracting(self.membersAndZombies).isEmpty if (!hasContactsToAdd) { addMembersButton.isUserInteractionEnabled = false let disabledColor = Colors.text.withAlphaComponent(Values.mediumOpacity) @@ -246,10 +250,10 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega var members = self.membersAndZombies members.append(contentsOf: selectedUsers) func getDisplayName(for publicKey: String) -> String { - return Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey + return Profile.displayName(for: publicKey) } self.membersAndZombies = members.sorted { getDisplayName(for: $0) < getDisplayName(for: $1) } - let hasContactsToAdd = !Set(ContactUtilities.getAllContacts()).subtracting(self.membersAndZombies).isEmpty + let hasContactsToAdd = !Set(Contact.fetchAllIds()).subtracting(self.membersAndZombies).isEmpty self.addMembersButton.isUserInteractionEnabled = hasContactsToAdd let color = hasContactsToAdd ? Colors.accent : Colors.text.withAlphaComponent(Values.mediumOpacity) self.addMembersButton.layer.borderColor = color.cgColor diff --git a/Session/Closed Groups/NewClosedGroupVC.swift b/Session/Closed Groups/NewClosedGroupVC.swift index 6b31ff423..40d81b645 100644 --- a/Session/Closed Groups/NewClosedGroupVC.swift +++ b/Session/Closed Groups/NewClosedGroupVC.swift @@ -1,4 +1,8 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit import PromiseKit +import SessionMessagingKit private protocol TableViewTouchDelegate { @@ -15,7 +19,7 @@ private final class TableView : UITableView { } final class NewClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegate, TableViewTouchDelegate, UITextFieldDelegate, UIScrollViewDelegate { - private let contacts = ContactUtilities.getAllContacts() + private let contacts = Contact.fetchAllIds() private var selectedContacts: Set = [] // MARK: Components @@ -174,7 +178,10 @@ final class NewClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegat promise = MessageSender.createClosedGroup(name: name, members: selectedContacts, transaction: transaction) } let _ = promise.done(on: DispatchQueue.main) { thread in - MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete() + GRDBStorage.shared.write { db in + MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() + } + self?.presentingViewController?.dismiss(animated: true, completion: nil) SignalApp.shared().presentConversation(for: thread, action: .compose, animated: false) } diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index e8d77f76e..84535c89a 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -1,8 +1,11 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + import UIKit import CoreServices import Photos import PhotosUI import PromiseKit +import GRDB import SessionUtilitiesKit import SignalUtilitiesKit @@ -14,7 +17,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc // Don't take the user to settings for message requests guard let contactThread: TSContactThread = thread as? TSContactThread, - let contact: Contact = Storage.shared.getContact(with: contactThread.contactSessionID()), + let contact: Contact = GRDBStorage.shared.read({ db in try Contact.fetchOne(db, id: contactThread.contactSessionID()) }), contact.isApproved, contact.didApproveMe else { @@ -44,23 +47,31 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc @objc func unblock() { guard let thread = thread as? TSContactThread else { return } let publicKey = thread.contactSessionID() - UIView.animate(withDuration: 0.25, animations: { - self.blockedBanner.alpha = 0 - }, completion: { _ in - if let contact: Contact = Storage.shared.getContact(with: publicKey) { - Storage.shared.write( - with: { transaction in - guard let transaction = transaction as? YapDatabaseReadWriteTransaction else { return } - - contact.isBlocked = false - Storage.shared.setContact(contact, using: transaction) + + UIView.animate( + withDuration: 0.25, + animations: { + self.blockedBanner.alpha = 0 + }, + completion: { _ in + GRDBStorage.shared.writeAsync( + updates: { db in + try Contact + .fetchOne(db, id: publicKey)? + .with(isBlocked: false) + .update(db) }, - completion: { - MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete() + completion: { db, result in + switch result { + case .success: + MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() + + default: break + } } ) } - }) + ) } func showBlockedModalIfNeeded() -> Bool { @@ -374,7 +385,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc // Update the input state if this is a contact thread if let contactThread: TSContactThread = thread as? TSContactThread { - let contact: Contact? = Storage.shared.getContact(with: contactThread.contactSessionID()) + let contact: Contact? = GRDBStorage.shared.read { db in try Contact.fetchOne(db, id: contactThread.contactSessionID()) } // If the contact doesn't exist yet then it's a message request without the first message sent // so only allow text-based messages @@ -525,9 +536,11 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc } else { switch viewItem.messageCellType { case .audio: - if viewItem.interaction is TSIncomingMessage, + if + viewItem.interaction is TSIncomingMessage, let thread = self.thread as? TSContactThread, - Storage.shared.getContact(with: thread.contactSessionID())?.isTrusted != true { + let contact: Contact? = GRDBStorage.shared.read({ db in try Contact.fetchOne(db, id: thread.contactSessionID()) }), + contact?.isTrusted != true { confirmDownload() } else { playOrPauseAudio(for: viewItem) @@ -535,9 +548,11 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc case .mediaMessage: guard let index = viewItems.firstIndex(where: { $0 === viewItem }), let cell = messagesTableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell else { return } - if viewItem.interaction is TSIncomingMessage, + if + viewItem.interaction is TSIncomingMessage, let thread = self.thread as? TSContactThread, - Storage.shared.getContact(with: thread.contactSessionID())?.isTrusted != true { + let contact: Contact? = GRDBStorage.shared.read({ db in try Contact.fetchOne(db, id: thread.contactSessionID()) }), + contact?.isTrusted != true { confirmDownload() } else { guard let albumView = cell.albumView else { return } @@ -559,9 +574,11 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc gallery.presentDetailView(fromViewController: self, mediaAttachment: stream) } case .genericAttachment: - if viewItem.interaction is TSIncomingMessage, + if + viewItem.interaction is TSIncomingMessage, let thread = self.thread as? TSContactThread, - Storage.shared.getContact(with: thread.contactSessionID())?.isTrusted != true { + let contact: Contact? = GRDBStorage.shared.read({ db in try Contact.fetchOne(db, id: thread.contactSessionID()) }), + contact?.isTrusted != true { confirmDownload() } else if ( @@ -1108,9 +1125,13 @@ extension ConversationVC { // (it'll be updated with correct profile info if they accept the message request so this // shouldn't cause weird behaviours) let sessionId: String = contactThread.contactSessionID() - let contact: Contact = (Storage.shared.getContact(with: sessionId) ?? Contact(sessionID: sessionId)) - guard !contact.isApproved else { return Promise.value(()) } + guard + let contact: Contact = GRDBStorage.shared.read({ db in Contact.fetchOrCreate(db, id: sessionId) }), + !contact.isApproved + else { + return Promise.value(()) + } return Promise.value(()) .then { [weak self] _ -> Promise in @@ -1151,49 +1172,58 @@ extension ConversationVC { } .map { _ in // Default 'didApproveMe' to true for the person approving the message request - Storage.write { transaction in - contact.isApproved = true - contact.didApproveMe = (contact.didApproveMe || !isNewThread) - Storage.shared.setContact(contact, using: transaction) - } - - // Send a sync message with the details of the contact - MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete() - - // Hide the 'messageRequestView' since the request has been approved and force a config - // sync to propagate the contact approval state (both must run on the main thread) - DispatchQueue.main.async { [weak self] in - let messageRequestViewWasVisible: Bool = (self?.messageRequestView.isHidden == false) - - UIView.animate(withDuration: 0.3) { - self?.messageRequestView.isHidden = true - self?.scrollButtonMessageRequestsBottomConstraint?.isActive = false - self?.scrollButtonBottomConstraint?.isActive = true - - // Update the table content inset and offset to account for the dissapearance of - // the messageRequestsView - if messageRequestViewWasVisible { - let messageRequestsOffset: CGFloat = ((self?.messageRequestView.bounds.height ?? 0) + 16) - let oldContentInset: UIEdgeInsets = (self?.messagesTableView.contentInset ?? UIEdgeInsets.zero) - self?.messagesTableView.contentInset = UIEdgeInsets( - top: 0, - leading: 0, - bottom: max(oldContentInset.bottom - messageRequestsOffset, 0), - trailing: 0 + GRDBStorage.shared.writeAsync( + updates: { db in + try contact + .with( + isApproved: true, + didApproveMe: .update(contact.didApproveMe || !isNewThread) ) + .save(db) + }, + completion: { db, _ in + // Send a sync message with the details of the contact + MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() + + // Hide the 'messageRequestView' since the request has been approved + DispatchQueue.main.async { [weak self] in + let messageRequestViewWasVisible: Bool = (self?.messageRequestView.isHidden == false) + + UIView.animate(withDuration: 0.3) { + self?.messageRequestView.isHidden = true + self?.scrollButtonMessageRequestsBottomConstraint?.isActive = false + self?.scrollButtonBottomConstraint?.isActive = true + + // Update the table content inset and offset to account for + // the dissapearance of the messageRequestsView + if messageRequestViewWasVisible { + let messageRequestsOffset: CGFloat = ((self?.messageRequestView.bounds.height ?? 0) + 16) + let oldContentInset: UIEdgeInsets = (self?.messagesTableView.contentInset ?? UIEdgeInsets.zero) + self?.messagesTableView.contentInset = UIEdgeInsets( + top: 0, + leading: 0, + bottom: max(oldContentInset.bottom - messageRequestsOffset, 0), + trailing: 0 + ) + } + } + + // Update UI + self?.updateNavBarButtons() + + // Remove the 'MessageRequestsViewController' from the nav hierarchy if present + if + let viewControllers: [UIViewController] = self?.navigationController?.viewControllers, + let messageRequestsIndex = viewControllers.firstIndex(where: { $0 is MessageRequestsViewController }), + messageRequestsIndex > 0 + { + var newViewControllers = viewControllers + newViewControllers.remove(at: messageRequestsIndex) + self?.navigationController?.setViewControllers(newViewControllers, animated: false) + } } } - - // Update UI - self?.updateNavBarButtons() - if let viewControllers: [UIViewController] = self?.navigationController?.viewControllers, - let messageRequestsIndex = viewControllers.firstIndex(where: { $0 is MessageRequestsViewController }), - messageRequestsIndex > 0 { - var newViewControllers = viewControllers - newViewControllers.remove(at: messageRequestsIndex) - self?.navigationController?.setViewControllers(newViewControllers, animated: false) - } - } + ) } } @@ -1220,44 +1250,52 @@ extension ConversationVC { let alertVC: UIAlertController = UIAlertController(title: NSLocalizedString("MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON", comment: ""), message: nil, preferredStyle: .actionSheet) alertVC.addAction(UIAlertAction(title: NSLocalizedString("TXT_DELETE_TITLE", comment: ""), style: .destructive) { _ in // Delete the request - Storage.write( - with: { [weak self] transaction in - Storage.shared.cancelPendingMessageSendJobs(for: uniqueId, using: transaction) - + GRDBStorage.shared.writeAsync( + updates: { [weak self] db in // Update the contact if let contactThread: TSContactThread = self?.thread as? TSContactThread { let sessionId: String = contactThread.contactSessionID() - if let contact: Contact = Storage.shared.getContact(with: sessionId) { - // Stop observing the `BlockListDidChange` notification (we are about to pop the screen - // so showing the banner just looks buggy) - if let strongSelf = self { - NotificationCenter.default.removeObserver(strongSelf, name: .contactBlockedStateChanged, object: nil) - } - - contact.isApproved = false - contact.isBlocked = true - - // Note: We set this to true so the current user will be able to send a - // message to the person who originally sent them the message request in - // the future if they unblock them - contact.didApproveMe = true - - Storage.shared.setContact(contact, using: transaction) + // Stop observing the `BlockListDidChange` notification (we are about to pop the screen + // so showing the banner just looks buggy) + if let strongSelf = self { + NotificationCenter.default.removeObserver(strongSelf, name: .contactBlockedStateChanged, object: nil) } + + try? Contact + .fetchOne(db, id: sessionId)? + .with( + isApproved: false, + isBlocked: true, + + // Note: We set this to true so the current user will be able to send a + // message to the person who originally sent them the message request in + // the future if they unblock them + didApproveMe: true + ) + .update(db) } - - // Delete all thread content - self?.thread.removeAllThreadInteractions(with: transaction) - self?.thread.remove(with: transaction) }, - completion: { [weak self] in - // Force a config sync and pop to the previous screen - MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete() - - DispatchQueue.main.async { - self?.navigationController?.popViewController(animated: true) - } + completion: { db, _ in + Storage.write( + with: { [weak self] transaction in + // TODO: This should be above the contact updating + Storage.shared.cancelPendingMessageSendJobs(for: uniqueId, using: transaction) + + // Delete all thread content + self?.thread.removeAllThreadInteractions(with: transaction) + self?.thread.remove(with: transaction) + }, + completion: { [weak self] in + // Force a config sync and pop to the previous screen + // TODO: This might cause an "incorrect thread" crash + MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() + + DispatchQueue.main.async { + self?.navigationController?.popViewController(animated: true) + } + } + ) } ) }) diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 2482b4242..b627e4a40 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -77,7 +77,7 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat } // Legacy account - return Mnemonic.encode(hexEncodedString: Identity.fetchUserKeyPair()!.hexEncodedPrivateKey) + return Mnemonic.encode(hexEncodedString: Identity.fetchUserPrivateKey()!.toHexString()) }() lazy var viewModel = ConversationViewModel(thread: thread, focusMessageIdOnOpen: nil, delegate: self) @@ -150,10 +150,9 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat lazy var blockedBanner: InfoBanner = { let name: String if let thread = thread as? TSContactThread { - let publicKey = thread.contactSessionID() - let context = Contact.context(for: thread) - name = Storage.shared.getContact(with: publicKey)?.displayName(for: context) ?? publicKey - } else { + name = Profile.displayName(for: thread.contactSessionID(), thread: thread) + } + else { name = "Thread" } let message = "\(name) is blocked. Unblock them?" @@ -378,7 +377,7 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat // Update the input state if this is a contact thread if let contactThread: TSContactThread = thread as? TSContactThread { - let contact: Contact? = Storage.shared.getContact(with: contactThread.contactSessionID()) + let contact: Contact? = GRDBStorage.shared.read { db in try Contact.fetchOne(db, id: contactThread.contactSessionID()) } // If the contact doesn't exist yet then it's a message request without the first message sent // so only allow text-based messages @@ -473,7 +472,11 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat else { if let contactThread: TSContactThread = thread as? TSContactThread { // Don't show the settings button for message requests - if let contact: Contact = Storage.shared.getContact(with: contactThread.contactSessionID()), contact.isApproved, contact.didApproveMe { + if + let contact: Contact = GRDBStorage.shared.read({ db in try Contact.fetchOne(db, id: contactThread.contactSessionID()) }), + contact.isApproved, + contact.didApproveMe + { let size = Values.verySmallProfilePictureSize let profilePictureView = ProfilePictureView() profilePictureView.size = size @@ -657,7 +660,7 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat // Update the input state if this is a contact thread if let contactThread: TSContactThread = thread as? TSContactThread { - let contact: Contact? = Storage.shared.getContact(with: contactThread.contactSessionID()) + let contact: Contact? = GRDBStorage.shared.read { db in try Contact.fetchOne(db, id: contactThread.contactSessionID()) } // If the contact doesn't exist yet then it's a message request without the first message sent // so only allow text-based messages @@ -718,18 +721,17 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat } } - // MARK: General + // MARK: - General + @objc func addOrRemoveBlockedBanner() { - func detach() { - blockedBanner.removeFromSuperview() - } - guard let thread = thread as? TSContactThread else { return detach() } - if thread.isBlocked() { - view.addSubview(blockedBanner) - blockedBanner.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.HorizontalEdge.right ], to: view) - } - else { - detach() + DispatchQueue.main.async { + guard let thread = self.thread as? TSContactThread, thread.isBlocked() else { + self.blockedBanner.removeFromSuperview() + return + } + + self.view.addSubview(self.blockedBanner) + self.blockedBanner.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.HorizontalEdge.right ], to: self.view) } } diff --git a/Session/Conversations/ConversationViewModel.m b/Session/Conversations/ConversationViewModel.m index 181d31b27..1ba2ef903 100644 --- a/Session/Conversations/ConversationViewModel.m +++ b/Session/Conversations/ConversationViewModel.m @@ -18,6 +18,7 @@ #import #import #import +#import #import #import #import @@ -258,11 +259,6 @@ NS_ASSUME_NONNULL_BEGIN return SSKEnvironment.shared.tsAccountManager; } -- (OWSProfileManager *)profileManager -{ - return [OWSProfileManager sharedManager]; -} - #pragma mark - - (void)addNotificationListeners @@ -281,7 +277,7 @@ NS_ASSUME_NONNULL_BEGIN object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(localProfileDidChange:) - name:kNSNotificationName_LocalProfileDidChange + name:NSNotification.localProfileDidChange object:nil]; } @@ -1271,8 +1267,7 @@ NS_ASSUME_NONNULL_BEGIN } if (shouldShowSenderName) { - SNContactContext context = [SNContact contextForThread:self.thread]; - senderName = [[NSAttributedString alloc] initWithString:[[LKStorage.shared getContactWithSessionID:incomingSenderId] displayNameFor:context] ?: incomingSenderId]; + senderName = [[NSAttributedString alloc] initWithString:[SMKProfile displayNameWithId:incomingSenderId thread:self.thread]]; } // Show the sender profile picture for incoming group messages unless the diff --git a/Session/Conversations/Message Cells/Content Views/QuoteView.swift b/Session/Conversations/Message Cells/Content Views/QuoteView.swift index efd23a388..1fc2aef94 100644 --- a/Session/Conversations/Message Cells/Content Views/QuoteView.swift +++ b/Session/Conversations/Message Cells/Content Views/QuoteView.swift @@ -1,3 +1,8 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SessionUIKit +import SessionMessagingKit final class QuoteView : UIView { private let mode: Mode @@ -180,8 +185,7 @@ final class QuoteView : UIView { if let groupThread = thread as? TSGroupThread { let authorLabel = UILabel() authorLabel.lineBreakMode = .byTruncatingTail - let context: Contact.Context = groupThread.isOpenGroup ? .openGroup : .regular - authorLabel.text = Storage.shared.getContact(with: authorID)?.displayName(for: context) ?? authorID + authorLabel.text = Profile.displayName(for: authorID, thread: groupThread) authorLabel.textColor = textColor authorLabel.font = .boldSystemFont(ofSize: Values.smallFontSize) let authorLabelSize = authorLabel.systemLayoutSizeFitting(availableSpace) diff --git a/Session/Conversations/Message Cells/InfoMessageCell.swift b/Session/Conversations/Message Cells/InfoMessageCell.swift index 6520435aa..cb1ceb4ff 100644 --- a/Session/Conversations/Message Cells/InfoMessageCell.swift +++ b/Session/Conversations/Message Cells/InfoMessageCell.swift @@ -1,5 +1,10 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -final class InfoMessageCell : MessageCell { +import UIKit +import SessionUIKit +import SessionMessagingKit + +final class InfoMessageCell: MessageCell { private lazy var iconImageViewWidthConstraint = iconImageView.set(.width, to: InfoMessageCell.iconSize) private lazy var iconImageViewHeightConstraint = iconImageView.set(.height, to: InfoMessageCell.iconSize) @@ -48,7 +53,7 @@ final class InfoMessageCell : MessageCell { let icon: UIImage? switch message.messageType { case .disappearingMessagesUpdate: - var configuration: OWSDisappearingMessagesConfiguration? + var configuration: SessionMessagingKit.Legacy.DisappearingMessagesConfiguration? Storage.read { transaction in configuration = message.thread(with: transaction).disappearingMessagesConfiguration(with: transaction) } diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index ee2f9658f..f1933ddef 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -1,3 +1,4 @@ +import SessionUtilitiesKit final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { private var unloadContent: (() -> Void)? @@ -351,11 +352,14 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { stackView.pin(to: snContentView, withInset: inset) } case .mediaMessage: - if viewItem.interaction is TSIncomingMessage, + if + viewItem.interaction is TSIncomingMessage, let thread = thread as? TSContactThread, - Storage.shared.getContact(with: thread.contactSessionID())?.isTrusted != true { + let contact: Contact? = GRDBStorage.shared.read({ db in try Contact.fetchOne(db, id: thread.contactSessionID()) }), + contact?.isTrusted != true { showMediaPlaceholder() - } else { + } + else { guard let cache = delegate?.getMediaCache() else { preconditionFailure() } // Stack view let stackView = UIStackView(arrangedSubviews: []) @@ -385,11 +389,14 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { stackView.pin(to: snContentView) } case .audio: - if viewItem.interaction is TSIncomingMessage, + if + viewItem.interaction is TSIncomingMessage, let thread = thread as? TSContactThread, - Storage.shared.getContact(with: thread.contactSessionID())?.isTrusted != true { + let contact: Contact? = GRDBStorage.shared.read({ db in try Contact.fetchOne(db, id: thread.contactSessionID()) }), + contact?.isTrusted != true { showMediaPlaceholder() - } else { + } + else { let voiceMessageView = VoiceMessageView(viewItem: viewItem) snContentView.addSubview(voiceMessageView) voiceMessageView.pin(to: snContentView) @@ -397,11 +404,14 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { viewItem.lastAudioMessageView = voiceMessageView } case .genericAttachment: - if viewItem.interaction is TSIncomingMessage, + if + viewItem.interaction is TSIncomingMessage, let thread = thread as? TSContactThread, - Storage.shared.getContact(with: thread.contactSessionID())?.isTrusted != true { + let contact: Contact? = GRDBStorage.shared.read({ db in try Contact.fetchOne(db, id: thread.contactSessionID()) }), + contact?.isTrusted != true { showMediaPlaceholder() - } else { + } + else { let inset: CGFloat = 12 let maxWidth = VisibleMessageCell.getMaxWidth(for: viewItem) - 2 * inset // Stack view diff --git a/Session/Conversations/Settings/OWSConversationSettingsViewController.m b/Session/Conversations/Settings/OWSConversationSettingsViewController.m index 7d5112ab3..d9b80457e 100644 --- a/Session/Conversations/Settings/OWSConversationSettingsViewController.m +++ b/Session/Conversations/Settings/OWSConversationSettingsViewController.m @@ -105,18 +105,13 @@ CGFloat kIconViewLength = 24; return SSKEnvironment.shared.tsAccountManager; } -- (OWSProfileManager *)profileManager -{ - return [OWSProfileManager sharedManager]; -} - #pragma mark - (void)observeNotifications { [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(otherUsersProfileDidChange:) - name:kNSNotificationName_OtherUsersProfileDidChange + name:NSNotification.otherUsersProfileDidChange object:nil]; } @@ -130,7 +125,7 @@ CGFloat kIconViewLength = 24; NSString *threadName = self.thread.name; if ([self.thread isKindOfClass:TSContactThread.class]) { TSContactThread *thread = (TSContactThread *)self.thread; - return [[LKStorage.shared getContactWithSessionID:thread.contactSessionID] displayNameFor:SNContactContextRegular] ?: @"Anonymous"; + return [SMKProfile displayNameWithId:thread.contactSessionID customFallback: @"Anonymous"]; } else if (threadName.length == 0 && [self isGroupThread]) { threadName = [MessageStrings newGroupDefaultTitle]; } @@ -235,13 +230,12 @@ CGFloat kIconViewLength = 24; SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _disappearingMessagesDurationLabel); self.disappearingMessagesDurations = [OWSDisappearingMessagesConfiguration validDurationsSeconds]; - + self.disappearingMessagesConfiguration = - [OWSDisappearingMessagesConfiguration fetchObjectWithUniqueID:self.thread.uniqueId]; + [OWSDisappearingMessagesConfiguration fetchObjectWithUniqueId:self.thread.uniqueId]; if (!self.disappearingMessagesConfiguration) { - self.disappearingMessagesConfiguration = - [[OWSDisappearingMessagesConfiguration alloc] initDefaultWithThreadId:self.thread.uniqueId]; + self.disappearingMessagesConfiguration = [OWSDisappearingMessagesConfiguration defaultWith: self.thread.uniqueId]; } [self updateTableContents]; @@ -361,7 +355,7 @@ CGFloat kIconViewLength = 24; displayName = @"the group"; } else { TSContactThread *thread = (TSContactThread *)self.thread; - displayName = [[LKStorage.shared getContactWithSessionID:thread.contactSessionID] displayNameFor:SNContactContextRegular] ?: @"anonymous"; + displayName = [SMKProfile displayNameWithId:thread.contactSessionID customFallback:@"anonymous"]; } subtitleLabel.text = [NSString stringWithFormat:NSLocalizedString(@"When enabled, messages between you and %@ will disappear after they have been seen.", ""), displayName]; subtitleLabel.textColor = LKColors.text; @@ -762,7 +756,7 @@ CGFloat kIconViewLength = 24; [infoMessage saveWithTransaction:transaction]; SNExpirationTimerUpdate *expirationTimerUpdate = [SNExpirationTimerUpdate new]; - BOOL isEnabled = self.disappearingMessagesConfiguration.enabled; + BOOL isEnabled = self.disappearingMessagesConfiguration.isEnabled; expirationTimerUpdate.duration = isEnabled ? self.disappearingMessagesConfiguration.durationSeconds : 0; [SNMessageSender send:expirationTimerUpdate inThread:self.thread usingTransaction:transaction]; }]; @@ -908,7 +902,7 @@ CGFloat kIconViewLength = 24; - (void)toggleDisappearingMessages:(BOOL)flag { - self.disappearingMessagesConfiguration.enabled = flag; + self.disappearingMessagesConfiguration.isEnabled = flag; [self updateTableContents]; } @@ -1027,16 +1021,11 @@ CGFloat kIconViewLength = 24; { if (![self.thread isKindOfClass:TSContactThread.class]) { return; } NSString *sessionID = ((TSContactThread *)self.thread).contactSessionID; - SNContact *contact = [LKStorage.shared getContactWithSessionID:sessionID]; - if (contact == nil) { - contact = [[SNContact alloc] initWithSessionID:sessionID]; - } + SMKProfile *profile = [SMKProfile fetchOrCreateWithId:sessionID]; NSString *text = [self.displayNameTextField.text stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; - contact.nickname = text.length > 0 ? text : nil; - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [LKStorage.shared setContact:contact usingTransaction:transaction]; - }]; - self.displayNameLabel.text = text.length > 0 ? text : contact.name; + profile.nickname = text.length > 0 ? text : nil; + [SMKProfile saveProfile: profile]; + self.displayNameLabel.text = text.length > 0 ? text : profile.name; [self hideEditNameUI]; } @@ -1069,7 +1058,7 @@ CGFloat kIconViewLength = 24; { OWSAssertIsOnMainThread(); - NSString *recipientId = notification.userInfo[kNSNotificationKey_ProfileRecipientId]; + NSString *recipientId = notification.userInfo[NSNotification.profileRecipientIdKey]; OWSAssertDebug(recipientId.length > 0); if (recipientId.length > 0 && [self.thread isKindOfClass:[TSContactThread class]] && diff --git a/Session/Conversations/Views & Modals/BlockedModal.swift b/Session/Conversations/Views & Modals/BlockedModal.swift index de6a87f3b..d353f43e9 100644 --- a/Session/Conversations/Views & Modals/BlockedModal.swift +++ b/Session/Conversations/Views & Modals/BlockedModal.swift @@ -1,3 +1,9 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import GRDB +import SessionUIKit +import SessionUtilitiesKit import SessionMessagingKit final class BlockedModal: Modal { @@ -19,7 +25,7 @@ final class BlockedModal: Modal { override func populateContentView() { // Name - let name = Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey + let name = Profile.displayName(for: publicKey) // Title let titleLabel = UILabel() titleLabel.textColor = Colors.text @@ -66,17 +72,15 @@ final class BlockedModal: Modal { @objc private func unblock() { let publicKey: String = self.publicKey - Storage.shared.write( - with: { transaction in - guard let transaction = transaction as? YapDatabaseReadWriteTransaction, let contact: Contact = Storage.shared.getContact(with: publicKey, using: transaction) else { - return - } - - contact.isBlocked = false - Storage.shared.setContact(contact, using: transaction as Any) + GRDBStorage.shared.writeAsync( + updates: { db in + try? Contact + .fetchOne(db, id: publicKey)? + .with(isBlocked: true) + .update(db) }, - completion: { - MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete() + completion: { db, _ in + MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() } ) diff --git a/Session/Conversations/Views & Modals/ConversationTitleView.swift b/Session/Conversations/Views & Modals/ConversationTitleView.swift index a557e3666..f33d3ff31 100644 --- a/Session/Conversations/Views & Modals/ConversationTitleView.swift +++ b/Session/Conversations/Views & Modals/ConversationTitleView.swift @@ -52,7 +52,7 @@ final class ConversationTitleView : UIView { let notificationCenter = NotificationCenter.default notificationCenter.addObserver(self, selector: #selector(update), name: Notification.Name.groupThreadUpdated, object: nil) notificationCenter.addObserver(self, selector: #selector(update), name: Notification.Name.muteSettingUpdated, object: nil) - notificationCenter.addObserver(self, selector: #selector(update), name: Notification.Name.contactUpdated, object: nil) + notificationCenter.addObserver(self, selector: #selector(update), name: Notification.Name.profileUpdated, object: nil) update() } @@ -62,11 +62,18 @@ final class ConversationTitleView : UIView { // MARK: Updating @objc private func update() { - titleLabel.text = getTitle() - let subtitle = getSubtitle() - subtitleLabel.attributedText = subtitle - let titleFontSize = (subtitle != nil) ? Values.mediumFontSize : Values.veryLargeFontSize - titleLabel.font = .boldSystemFont(ofSize: titleFontSize) + DispatchQueue.main.async { + self.titleLabel.text = self.getTitle() + + let subtitle: NSAttributedString? = self.getSubtitle() + self.subtitleLabel.attributedText = subtitle + self.titleLabel.font = .boldSystemFont( + ofSize: (subtitle != nil ? + Values.mediumFontSize : + Values.veryLargeFontSize + ) + ) + } } // MARK: General @@ -79,13 +86,9 @@ final class ConversationTitleView : UIView { } else { let sessionID = (thread as! TSContactThread).contactSessionID() - var result = sessionID - Storage.read { transaction in - let displayName: String = ((Storage.shared.getContact(with: sessionID)?.displayName(for: .regular)) ?? sessionID) - let middleTruncatedHexKey: String = "\(sessionID.prefix(4))...\(sessionID.suffix(4))" - result = (displayName == sessionID ? middleTruncatedHexKey : displayName) - } - return result + let middleTruncatedHexKey: String = "\(sessionID.prefix(4))...\(sessionID.suffix(4))" + + return Profile.displayName(for: sessionID, customFallback: middleTruncatedHexKey) } } diff --git a/Session/Conversations/Views & Modals/DownloadAttachmentModal.swift b/Session/Conversations/Views & Modals/DownloadAttachmentModal.swift index 1c8c88842..e73e91692 100644 --- a/Session/Conversations/Views & Modals/DownloadAttachmentModal.swift +++ b/Session/Conversations/Views & Modals/DownloadAttachmentModal.swift @@ -1,3 +1,10 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import GRDB +import SessionUIKit +import SessionUtilitiesKit +import SessionMessagingKit final class DownloadAttachmentModal : Modal { private let viewItem: ConversationViewItem @@ -19,7 +26,7 @@ final class DownloadAttachmentModal : Modal { override func populateContentView() { guard let publicKey = (viewItem.interaction as? TSIncomingMessage)?.authorId else { return } // Name - let name = Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey + let name = Profile.displayName(for: publicKey) // Title let titleLabel = UILabel() titleLabel.textColor = Colors.text @@ -65,15 +72,23 @@ final class DownloadAttachmentModal : Modal { // MARK: Interaction @objc private func trust() { guard let message = viewItem.interaction as? TSIncomingMessage else { return } - let publicKey = message.authorId - let contact = Storage.shared.getContact(with: publicKey) ?? Contact(sessionID: publicKey) - contact.isTrusted = true - Storage.write(with: { transaction in - Storage.shared.setContact(contact, using: transaction) - MessageInvalidator.invalidate(message, with: transaction) - }, completion: { - Storage.shared.resumeAttachmentDownloadJobsIfNeeded(for: message.uniqueThreadId) - }) + + GRDBStorage.shared.writeAsync( + updates: { db in + try? Contact + .fetchOrCreate(db, id: message.authorId) + .with(isTrusted: true) + .save(db) + }, + completion: { _, _ in + Storage.write(with: { transaction in + MessageInvalidator.invalidate(message, with: transaction) + }, completion: { + Storage.shared.resumeAttachmentDownloadJobsIfNeeded(for: message.uniqueThreadId) + }) + } + ) + presentingViewController?.dismiss(animated: true, completion: nil) } } diff --git a/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift b/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift index 1e9175c26..7f8d8ba50 100644 --- a/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift +++ b/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift @@ -72,7 +72,9 @@ final class JoinOpenGroupModal : Modal { Storage.shared.write { [presentingViewController = self.presentingViewController!] transaction in OpenGroupManagerV2.shared.add(room: room, server: server, publicKey: publicKey, using: transaction) .done(on: DispatchQueue.main) { _ in - MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...) + GRDBStorage.shared.write { db in + MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...) + } } .catch(on: DispatchQueue.main) { error in let alert = UIAlertController(title: "Couldn't Join", message: error.localizedDescription, preferredStyle: .alert) diff --git a/Session/Conversations/Views & Modals/UserDetailsSheet.swift b/Session/Conversations/Views & Modals/UserDetailsSheet.swift index 5afc5518b..44a9dd302 100644 --- a/Session/Conversations/Views & Modals/UserDetailsSheet.swift +++ b/Session/Conversations/Views & Modals/UserDetailsSheet.swift @@ -1,5 +1,9 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -final class UserDetailsSheet : Sheet { +import UIKit +import SessionMessagingKit + +final class UserDetailsSheet: Sheet { private let sessionID: String init(for sessionID: String) { @@ -26,7 +30,7 @@ final class UserDetailsSheet : Sheet { profilePictureView.update() // Display name label let displayNameLabel = UILabel() - let displayName = Storage.shared.getContact(with: sessionID)?.displayName(for: .regular) ?? sessionID + let displayName = Profile.displayName(for: sessionID) displayNameLabel.text = displayName displayNameLabel.font = .boldSystemFont(ofSize: Values.largeFontSize) displayNameLabel.textColor = Colors.text diff --git a/Session/DMs/NewDMVC.swift b/Session/DMs/NewDMVC.swift index e98a5b02e..036853adc 100644 --- a/Session/DMs/NewDMVC.swift +++ b/Session/DMs/NewDMVC.swift @@ -1,3 +1,8 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import Curve25519Kit +import SessionUtilitiesKit final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControllerDelegate, OWSQRScannerDelegate { private let pageVC = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil) diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 629e83f37..6d64b792d 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import GRDB import SessionMessagingKit import SessionUtilitiesKit @@ -155,8 +156,8 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv // Notifications let notificationCenter = NotificationCenter.default notificationCenter.addObserver(self, selector: #selector(handleYapDatabaseModifiedNotification(_:)), name: .YapDatabaseModified, object: OWSPrimaryStorage.shared().dbNotificationObject) - notificationCenter.addObserver(self, selector: #selector(handleProfileDidChangeNotification(_:)), name: NSNotification.Name(rawValue: kNSNotificationName_OtherUsersProfileDidChange), object: nil) - notificationCenter.addObserver(self, selector: #selector(handleLocalProfileDidChangeNotification(_:)), name: Notification.Name(kNSNotificationName_LocalProfileDidChange), object: nil) + notificationCenter.addObserver(self, selector: #selector(handleProfileDidChangeNotification(_:)), name: Notification.Name.otherUsersProfileDidChange, object: nil) + notificationCenter.addObserver(self, selector: #selector(handleLocalProfileDidChangeNotification(_:)), name: Notification.Name.localProfileDidChange, object: nil) notificationCenter.addObserver(self, selector: #selector(handleSeedViewedNotification(_:)), name: .seedViewed, object: nil) notificationCenter.addObserver(self, selector: #selector(handleBlockedContactsUpdatedNotification(_:)), name: .blockedContactsUpdated, object: nil) notificationCenter.addObserver(self, selector: #selector(applicationDidBecomeActive(_:)), name: .OWSApplicationDidBecomeActive, object: nil) @@ -167,7 +168,7 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv self.threads.update(with: transaction) // Perform the initial update } // Start polling if needed (i.e. if the user just created or restored their Session ID) - if Identity.fetchUserKeyPair() != nil { + if Identity.userExists() { let appDelegate = UIApplication.shared.delegate as! AppDelegate appDelegate.startPollerIfNeeded() appDelegate.startClosedGroupPoller() @@ -399,7 +400,9 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv } @objc private func handleLocalProfileDidChangeNotification(_ notification: Notification) { - updateNavBarButtons() + DispatchQueue.main.async { + self.updateNavBarButtons() + } } @objc private func handleSeedViewedNotification(_ notification: Notification) { @@ -531,40 +534,48 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv let publicKey = thread.contactSessionID() let block = UITableViewRowAction(style: .normal, title: NSLocalizedString("BLOCK_LIST_BLOCK_BUTTON", comment: "")) { _, _ in - Storage.shared.write( - with: { transaction in - guard let transaction = transaction as? YapDatabaseReadWriteTransaction, let contact: Contact = Storage.shared.getContact(with: publicKey, using: transaction) else { - return - } - - contact.isBlocked = true - Storage.shared.setContact(contact, using: transaction as Any) + GRDBStorage.shared.writeAsync( + updates: { db in + try Contact + .fetchOrCreate(db, id: publicKey) + .with(isBlocked: true) + .save(db) }, - completion: { - MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete() - - DispatchQueue.main.async { - tableView.reloadRows(at: [ indexPath ], with: UITableView.RowAnimation.fade) + completion: { db, result in + switch result { + case .success: + MessageSender.syncConfiguration(db, forceSyncNow: true) + .retainUntilComplete() + + DispatchQueue.main.async { + tableView.reloadRows(at: [ indexPath ], with: UITableView.RowAnimation.fade) + } + + default: break } } ) } block.backgroundColor = Colors.unimportant let unblock = UITableViewRowAction(style: .normal, title: NSLocalizedString("BLOCK_LIST_UNBLOCK_BUTTON", comment: "")) { _, _ in - Storage.shared.write( - with: { transaction in - guard let transaction = transaction as? YapDatabaseReadWriteTransaction, let contact: Contact = Storage.shared.getContact(with: publicKey, using: transaction) else { - return - } - - contact.isBlocked = false - Storage.shared.setContact(contact, using: transaction as Any) + GRDBStorage.shared.writeAsync( + updates: { db in + try Contact + .fetchOrCreate(db, id: publicKey) + .with(isBlocked: false) + .save(db) }, - completion: { - MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete() - - DispatchQueue.main.async { - tableView.reloadRows(at: [ indexPath ], with: UITableView.RowAnimation.fade) + completion: { db, result in + switch result { + case .success: + MessageSender.syncConfiguration(db, forceSyncNow: true) + .retainUntilComplete() + + DispatchQueue.main.async { + tableView.reloadRows(at: [ indexPath ], with: UITableView.RowAnimation.fade) + } + + default: break } } ) diff --git a/Session/Home/Message Requests/MessageRequestsViewController.swift b/Session/Home/Message Requests/MessageRequestsViewController.swift index 0d3e61a13..2f87f4942 100644 --- a/Session/Home/Message Requests/MessageRequestsViewController.swift +++ b/Session/Home/Message Requests/MessageRequestsViewController.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import GRDB import SessionUIKit import SessionMessagingKit @@ -107,7 +108,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat NotificationCenter.default.addObserver( self, selector: #selector(handleProfileDidChangeNotification(_:)), - name: NSNotification.Name(rawValue: kNSNotificationName_OtherUsersProfileDidChange), + name: Notification.Name.otherUsersProfileDidChange, object: nil ) NotificationCenter.default.addObserver( @@ -302,32 +303,6 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat // MARK: - Interaction - private func updateContactAndThread(thread: TSThread, with transaction: YapDatabaseReadWriteTransaction, onComplete: ((Bool) -> ())? = nil) { - guard let contactThread: TSContactThread = thread as? TSContactThread else { - onComplete?(false) - return - } - - var needsSync: Bool = false - - // Update the contact - let sessionId: String = contactThread.contactSessionID() - - if let contact: Contact = Storage.shared.getContact(with: sessionId), (contact.isApproved || !contact.isBlocked) { - contact.isApproved = false - contact.isBlocked = true - - Storage.shared.setContact(contact, using: transaction) - needsSync = true - } - - // Delete all thread content - thread.removeAllThreadInteractions(with: transaction) - thread.remove(with: transaction) - - onComplete?(needsSync) - } - @objc private func clearAllTapped() { let threadCount: Int = Int(messageRequestCount) let threads: [TSThread] = (0.. String { switch message { case let incomingMessage as TSIncomingMessage: - let publicKey = incomingMessage.authorId - let context = Contact.context(for: incomingMessage.thread) - return Storage.shared.getContact(with: publicKey)?.displayName(for: context) ?? publicKey + return Profile.displayName(for: incomingMessage.authorId, thread: incomingMessage.thread) case is TSOutgoingMessage: return NSLocalizedString("MEDIA_GALLERY_SENDER_NAME_YOU", comment: "Short sender label for media sent by you") default: diff --git a/Session/Meta/AppDelegate.m b/Session/Meta/AppDelegate.m index 6a603116c..e08cb49af 100644 --- a/Session/Meta/AppDelegate.m +++ b/Session/Meta/AppDelegate.m @@ -56,11 +56,6 @@ static NSTimeInterval launchStartedAt; #pragma mark - Dependencies -- (OWSProfileManager *)profileManager -{ - return [OWSProfileManager sharedManager]; -} - - (OWSReadReceiptManager *)readReceiptManager { return [OWSReadReceiptManager sharedManager]; @@ -365,14 +360,10 @@ static NSTimeInterval launchStartedAt; NSDate *now = [NSDate new]; NSDate *lastProfilePictureUpload = (NSDate *)[userDefaults objectForKey:@"lastProfilePictureUpload"]; if (lastProfilePictureUpload != nil && [now timeIntervalSinceDate:lastProfilePictureUpload] > 14 * 24 * 60 * 60) { - OWSProfileManager *profileManager = OWSProfileManager.sharedManager; - NSString *name = [[LKStorage.shared getUser] name]; - UIImage *profilePicture = [profileManager profileAvatarForRecipientId:userPublicKey]; - [profileManager updateLocalProfileName:name avatarImage:profilePicture success:^{ - // Do nothing; the user defaults flag is updated in LokiFileServerAPI - } failure:^(NSError *error) { - // Do nothing - } requiresSync:YES]; + // The user defaults flag is updated in ProfileManager + NSString *name = [SMKProfile fetchCurrentUserName]; + UIImage *profilePicture = [SMKProfileManager profileAvatarWithRecipientId:userPublicKey]; + [SMKProfileManager updateLocalWithProfileName:name avatarImage:profilePicture requiresSync:YES]; } if (CurrentAppContext().isMainApp) { diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index b0719086a..45fb800df 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -9,24 +9,26 @@ extension AppDelegate { @objc(syncConfigurationIfNeeded) func syncConfigurationIfNeeded() { - guard Storage.shared.getUser()?.name != nil else { return } - let userDefaults = UserDefaults.standard - let lastSync = userDefaults[.lastConfigurationSync] ?? .distantPast - guard Date().timeIntervalSince(lastSync) > 7 * 24 * 60 * 60 else { return } // Sync every 2 days + let lastSync: Date = (UserDefaults.standard[.lastConfigurationSync] ?? .distantPast) - MessageSender.syncConfiguration(forceSyncNow: false) - .done { - // Only update the 'lastConfigurationSync' timestamp if we have done the first sync (Don't want - // a new device config sync to override config syncs from other devices) - if userDefaults[.hasSyncedInitialConfiguration] { - userDefaults[.lastConfigurationSync] = Date() + guard Date().timeIntervalSince(lastSync) > (7 * 24 * 60 * 60) else { return } // Sync every 2 days + + GRDBStorage.shared.write { db in + MessageSender.syncConfiguration(db, forceSyncNow: false) + .done { + // Only update the 'lastConfigurationSync' timestamp if we have done the + // first sync (Don't want a new device config sync to override config + // syncs from other devices) + if UserDefaults.standard[.hasSyncedInitialConfiguration] { + UserDefaults.standard[.lastConfigurationSync] = Date() + } } - } - .retainUntilComplete() + .retainUntilComplete() + } } @objc func startClosedGroupPoller() { - guard Identity.fetchUserKeyPair() != nil else { return } + guard Identity.userExists() else { return } ClosedGroupPoller.shared.start() } diff --git a/Session/Notifications/AppNotifications.swift b/Session/Notifications/AppNotifications.swift index df3b6a0ef..0f8d0f06d 100644 --- a/Session/Notifications/AppNotifications.swift +++ b/Session/Notifications/AppNotifications.swift @@ -197,8 +197,7 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { return } - let context = Contact.context(for: thread) - let senderName = Storage.shared.getContact(with: incomingMessage.authorId, using: transaction)?.displayName(for: context) ?? incomingMessage.authorId + let senderName = Profile.displayName(for: incomingMessage.authorId, thread: incomingMessage.thread) let notificationTitle: String? var notificationBody: String? diff --git a/Session/Onboarding/DisplayNameVC.swift b/Session/Onboarding/DisplayNameVC.swift index 0fdf23c90..374b7ffeb 100644 --- a/Session/Onboarding/DisplayNameVC.swift +++ b/Session/Onboarding/DisplayNameVC.swift @@ -131,10 +131,12 @@ final class DisplayNameVC : BaseVC { guard !displayName.isEmpty else { return showError(title: NSLocalizedString("vc_display_name_display_name_missing_error", comment: "")) } - guard !OWSProfileManager.shared().isProfileNameTooLong(displayName) else { + guard !ProfileManager.isToLong(profileName: displayName) else { return showError(title: NSLocalizedString("vc_display_name_display_name_too_long_error", comment: "")) } - OWSProfileManager.shared().updateLocalProfileName(displayName, avatarImage: nil, success: { }, failure: { _ in }, requiresSync: false) // Try to save the user name but ignore the result + + // Try to save the user name but ignore the result + ProfileManager.updateLocal(profileName: displayName, avatarImage: nil, requiredSync: false) let pnModeVC = PNModeVC() navigationController!.pushViewController(pnModeVC, animated: true) } diff --git a/Session/Onboarding/Onboarding.swift b/Session/Onboarding/Onboarding.swift index 00a373e02..f8772252f 100644 --- a/Session/Onboarding/Onboarding.swift +++ b/Session/Onboarding/Onboarding.swift @@ -2,7 +2,10 @@ import Foundation import Sodium +import GRDB +import Curve25519Kit import SessionUtilitiesKit +import SessionMessagingKit enum Onboarding { @@ -14,11 +17,13 @@ enum Onboarding { Identity.store(seed: seed, ed25519KeyPair: ed25519KeyPair, x25519KeyPair: x25519KeyPair) let x25519PublicKey = x25519KeyPair.hexEncodedPublicKey TSAccountManager.sharedInstance().phoneNumberAwaitingVerification = x25519PublicKey - Storage.writeSync { transaction in - let user = Contact(sessionID: x25519PublicKey) - user.isApproved = true - user.didApproveMe = true - Storage.shared.setContact(user, using: transaction) + GRDBStorage.shared.write { db in + try Contact(id: x25519PublicKey) + .with( + isApproved: true, + didApproveMe: true + ) + .save(db) } switch self { diff --git a/Session/Onboarding/RegisterVC.swift b/Session/Onboarding/RegisterVC.swift index 0bb1f5945..3dd5c657b 100644 --- a/Session/Onboarding/RegisterVC.swift +++ b/Session/Onboarding/RegisterVC.swift @@ -1,4 +1,8 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit import Sodium +import Curve25519Kit final class RegisterVC : BaseVC { private var seed: Data! { didSet { updateKeyPair() } } diff --git a/Session/Onboarding/SeedVC.swift b/Session/Onboarding/SeedVC.swift index cc993691e..3a3405ead 100644 --- a/Session/Onboarding/SeedVC.swift +++ b/Session/Onboarding/SeedVC.swift @@ -10,7 +10,7 @@ final class SeedVC: BaseVC { } // Legacy account - return Mnemonic.encode(hexEncodedString: Identity.fetchUserKeyPair()!.hexEncodedPrivateKey) + return Mnemonic.encode(hexEncodedString: Identity.fetchUserPrivateKey()!.toHexString()) }() private lazy var redactedMnemonic: String = { diff --git a/Session/Open Groups/JoinOpenGroupVC.swift b/Session/Open Groups/JoinOpenGroupVC.swift index 152a0d6b1..1cde67788 100644 --- a/Session/Open Groups/JoinOpenGroupVC.swift +++ b/Session/Open Groups/JoinOpenGroupVC.swift @@ -1,3 +1,4 @@ +import SessionUtilitiesKit final class JoinOpenGroupVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControllerDelegate, OWSQRScannerDelegate { private let pageVC = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil) @@ -143,8 +144,11 @@ final class JoinOpenGroupVC : BaseVC, UIPageViewControllerDataSource, UIPageView Storage.shared.write { transaction in OpenGroupManagerV2.shared.add(room: room, server: server, publicKey: publicKey, using: transaction) .done(on: DispatchQueue.main) { [weak self] _ in + GRDBStorage.shared.write { db in + MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...) + } + self?.presentingViewController?.dismiss(animated: true, completion: nil) - MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...) } .catch(on: DispatchQueue.main) { [weak self] error in self?.dismiss(animated: true, completion: nil) // Dismiss the loader diff --git a/Session/Settings/NukeDataModal.swift b/Session/Settings/NukeDataModal.swift index c3b3ef87c..d95a1a292 100644 --- a/Session/Settings/NukeDataModal.swift +++ b/Session/Settings/NukeDataModal.swift @@ -124,12 +124,16 @@ final class NukeDataModal : Modal { @objc private func clearDeviceOnly() { ModalActivityIndicatorViewController.present(fromViewController: self, canCancel: false) { [weak self] _ in - MessageSender.syncConfiguration(forceSyncNow: true).ensure(on: DispatchQueue.main) { - self?.dismiss(animated: true, completion: nil) // Dismiss the loader - UserDefaults.removeAll() // Not done in the nuke data implementation as unlinking requires this to happen later - General.Cache.cachedEncodedPublicKey.mutate { $0 = nil } // Remove the cached key so it gets re-cached on next access - NotificationCenter.default.post(name: .dataNukeRequested, object: nil) - }.retainUntilComplete() + GRDBStorage.shared.write { db in + MessageSender.syncConfiguration(db, forceSyncNow: true) + .ensure(on: DispatchQueue.main) { + self?.dismiss(animated: true, completion: nil) // Dismiss the loader + UserDefaults.removeAll() // Not done in the nuke data implementation as unlinking requires this to happen later + General.Cache.cachedEncodedPublicKey.mutate { $0 = nil } // Remove the cached key so it gets re-cached on next access + NotificationCenter.default.post(name: .dataNukeRequested, object: nil) + } + .retainUntilComplete() + } } } diff --git a/Session/Settings/QRCodeVC.swift b/Session/Settings/QRCodeVC.swift index 158b88927..41731b779 100644 --- a/Session/Settings/QRCodeVC.swift +++ b/Session/Settings/QRCodeVC.swift @@ -1,3 +1,8 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import Curve25519Kit +import SessionUtilitiesKit final class QRCodeVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControllerDelegate, OWSQRScannerDelegate { private let pageVC = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil) diff --git a/Session/Settings/SeedModal.swift b/Session/Settings/SeedModal.swift index 28e448c25..6146dced3 100644 --- a/Session/Settings/SeedModal.swift +++ b/Session/Settings/SeedModal.swift @@ -12,7 +12,7 @@ final class SeedModal: Modal { } // Legacy account - return Mnemonic.encode(hexEncodedString: Identity.fetchUserKeyPair()!.hexEncodedPrivateKey) + return Mnemonic.encode(hexEncodedString: Identity.fetchUserPrivateKey()!.toHexString()) }() // MARK: Lifecycle diff --git a/Session/Settings/SettingsVC.swift b/Session/Settings/SettingsVC.swift index 6c585366c..200a81df7 100644 --- a/Session/Settings/SettingsVC.swift +++ b/Session/Settings/SettingsVC.swift @@ -147,7 +147,7 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { profilePictureView.publicKey = getUserHexEncodedPublicKey() profilePictureView.update() // Display name label - displayNameLabel.text = Storage.shared.getUser()?.name + displayNameLabel.text = Profile.fetchOrCreateCurrentUser().name // Display name container let displayNameContainer = UIView() displayNameContainer.accessibilityLabel = "Edit display name text field" @@ -346,7 +346,7 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { } func avatarDidChange(_ image: UIImage) { - let maxSize = Int(kOWSProfileManager_MaxAvatarDiameter) + let maxSize = Int(ProfileManager.maxAvatarDiameter) profilePictureToBeUploaded = image.resizedImage(toFillPixelSize: CGSize(width: maxSize, height: maxSize)) updateProfile(isUpdatingDisplayName: false, isUpdatingProfilePicture: true) } @@ -358,41 +358,47 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { private func updateProfile(isUpdatingDisplayName: Bool, isUpdatingProfilePicture: Bool) { let userDefaults = UserDefaults.standard - let name = displayNameToBeUploaded ?? Storage.shared.getUser()?.name - let profilePicture = profilePictureToBeUploaded ?? OWSProfileManager.shared().profileAvatar(forRecipientId: getUserHexEncodedPublicKey()) + let name: String? = (displayNameToBeUploaded ?? Profile.fetchOrCreateCurrentUser().name) + let profilePicture: UIImage? = (profilePictureToBeUploaded ?? ProfileManager.profileAvatar(for: getUserHexEncodedPublicKey())) ModalActivityIndicatorViewController.present(fromViewController: navigationController!, canCancel: false) { [weak self, displayNameToBeUploaded, profilePictureToBeUploaded] modalActivityIndicator in - OWSProfileManager.shared().updateLocalProfileName(name, avatarImage: profilePicture, success: { - if displayNameToBeUploaded != nil { - userDefaults[.lastDisplayNameUpdate] = Date() - } - if profilePictureToBeUploaded != nil { - userDefaults[.lastProfilePictureUpdate] = Date() - } - MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete() - DispatchQueue.main.async { - modalActivityIndicator.dismiss { - guard let self = self else { return } - self.profilePictureView.update() - self.displayNameLabel.text = name - self.profilePictureToBeUploaded = nil - self.displayNameToBeUploaded = nil + ProfileManager.updateLocal( + profileName: (name ?? ""), + avatarImage: profilePicture, + requiredSync: true, + success: { + if displayNameToBeUploaded != nil { + userDefaults[.lastDisplayNameUpdate] = Date() } - } - }, failure: { error in - DispatchQueue.main.async { - modalActivityIndicator.dismiss { - var isMaxFileSizeExceeded = false - if let error = error as? FileServerAPIV2.Error { - isMaxFileSizeExceeded = (error == .maxFileSizeExceeded) + if profilePictureToBeUploaded != nil { + userDefaults[.lastProfilePictureUpdate] = Date() + } + GRDBStorage.shared.write { db in + MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() + } + + DispatchQueue.main.async { + modalActivityIndicator.dismiss { + guard let self = self else { return } + self.profilePictureView.update() + self.displayNameLabel.text = name + self.profilePictureToBeUploaded = nil + self.displayNameToBeUploaded = nil + } + } + }, + failure: { error in + DispatchQueue.main.async { + modalActivityIndicator.dismiss { + let isMaxFileSizeExceeded = (error == .avatarUploadMaxFileSizeExceeded) + let title = isMaxFileSizeExceeded ? "Maximum File Size Exceeded" : "Couldn't Update Profile" + let message = isMaxFileSizeExceeded ? "Please select a smaller photo and try again" : "Please check your internet connection and try again" + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil)) + self?.present(alert, animated: true, completion: nil) } - let title = isMaxFileSizeExceeded ? "Maximum File Size Exceeded" : "Couldn't Update Profile" - let message = isMaxFileSizeExceeded ? "Please select a smaller photo and try again" : "Please check your internet connection and try again" - let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil)) - self?.present(alert, animated: true, completion: nil) } } - }, requiresSync: true) + ) } } @@ -461,7 +467,7 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { guard !displayName.isEmpty else { return showError(title: NSLocalizedString("vc_settings_display_name_missing_error", comment: "")) } - guard !OWSProfileManager.shared().isProfileNameTooLong(displayName) else { + guard !ProfileManager.isToLong(profileName: displayName) else { return showError(title: NSLocalizedString("vc_settings_display_name_too_long_error", comment: "")) } isEditingDisplayName = false diff --git a/Session/Shared/ConversationCell.swift b/Session/Shared/ConversationCell.swift index 16ccb648d..a69c3b171 100644 --- a/Session/Shared/ConversationCell.swift +++ b/Session/Shared/ConversationCell.swift @@ -239,10 +239,9 @@ final class ConversationCell : UITableViewCell { // Contact if threadViewModel.isGroupThread, let thread = threadViewModel.threadRecord as? TSGroupThread { displayNameLabel.attributedText = getHighlightedSnippet(snippet: getDisplayName(), searchText: normalizedSearchText, fontSize: Values.mediumFontSize) - let context: Contact.Context = thread.isOpenGroup ? .openGroup : .regular var rawSnippet: String = "" - thread.groupModel.groupMemberIds.forEach{ id in - if let displayName = Storage.shared.getContact(with: id)?.displayName(for: context) { + thread.groupModel.groupMemberIds.forEach { id in + if let displayName = Profile.displayNameNoFallback(for: id, thread: thread) { if !rawSnippet.isEmpty { rawSnippet += ", \(displayName)" } @@ -348,7 +347,7 @@ final class ConversationCell : UITableViewCell { private func getMessageAuthorName(message: TSMessage) -> String? { guard threadViewModel.isGroupThread else { return nil } if let incomingMessage = message as? TSIncomingMessage { - return Storage.shared.getContact(with: incomingMessage.authorId)?.displayName(for: .regular) ?? "Anonymous" + return Profile.displayName(for: incomingMessage.authorId, customFallback: "Anonymous") } return nil } @@ -356,14 +355,14 @@ final class ConversationCell : UITableViewCell { private func getDisplayNameForSearch(_ sessionID: String) -> String { if threadViewModel.threadRecord.isNoteToSelf() { return NSLocalizedString("NOTE_TO_SELF", comment: "") - } else { - var result = sessionID - if let contact = Storage.shared.getContact(with: sessionID), let name = contact.name { - result = name - if let nickname = contact.nickname { result += "(\(nickname))"} - } - return result } + + return [ + Profile.displayName(for: sessionID), + Profile.fetchOrCreate(id: sessionID).nickname.map { "(\($0)" } + ] + .compactMap { $0 } + .joined(separator: " ") } private func getDisplayName() -> String { @@ -381,9 +380,9 @@ final class ConversationCell : UITableViewCell { } else { let hexEncodedPublicKey: String = threadViewModel.contactSessionID! - let displayName: String = (Storage.shared.getContact(with: hexEncodedPublicKey)?.displayName(for: .regular) ?? hexEncodedPublicKey) let middleTruncatedHexKey: String = "\(hexEncodedPublicKey.prefix(4))...\(hexEncodedPublicKey.suffix(4))" - return (displayName == hexEncodedPublicKey ? middleTruncatedHexKey : displayName) + + return Profile.displayName(for: hexEncodedPublicKey, customFallback: middleTruncatedHexKey) } } } diff --git a/Session/Shared/UserCell.swift b/Session/Shared/UserCell.swift index 8fd199a28..9e3b56811 100644 --- a/Session/Shared/UserCell.swift +++ b/Session/Shared/UserCell.swift @@ -82,7 +82,7 @@ final class UserCell : UITableViewCell { func update() { profilePictureView.publicKey = publicKey profilePictureView.update() - displayNameLabel.text = Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey + displayNameLabel.text = Profile.displayName(for: publicKey) switch accessory { case .none: accessoryImageView.isHidden = true diff --git a/Session/Shared/UserSelectionVC.swift b/Session/Shared/UserSelectionVC.swift index b62104da3..16e4d2be4 100644 --- a/Session/Shared/UserSelectionVC.swift +++ b/Session/Shared/UserSelectionVC.swift @@ -1,3 +1,7 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SessionMessagingKit @objc(SNUserSelectionVC) final class UserSelectionVC : BaseVC, UITableViewDataSource, UITableViewDelegate { @@ -7,7 +11,7 @@ final class UserSelectionVC : BaseVC, UITableViewDataSource, UITableViewDelegate private var selectedUsers: Set = [] private lazy var users: [String] = { - var result = ContactUtilities.getAllContacts() + var result = Contact.fetchAllIds() result.removeAll { usersToExclude.contains($0) } return result }() diff --git a/Session/Utilities/AccountManager.swift b/Session/Utilities/AccountManager.swift index 60a6ed448..3c7b3d5c1 100644 --- a/Session/Utilities/AccountManager.swift +++ b/Session/Utilities/AccountManager.swift @@ -15,10 +15,6 @@ public class AccountManager: NSObject { // MARK: - Dependencies - var profileManager: OWSProfileManager { - return OWSProfileManager.shared() - } - private var preferences: OWSPreferences { return Environment.shared.preferences } diff --git a/Session/Utilities/MentionUtilities.swift b/Session/Utilities/MentionUtilities.swift index fb0fb0d82..9e9eb811d 100644 --- a/Session/Utilities/MentionUtilities.swift +++ b/Session/Utilities/MentionUtilities.swift @@ -17,9 +17,7 @@ public final class MentionUtilities : NSObject { while let match = outerMatch { let publicKey = String((string as NSString).substring(with: match.range).dropFirst()) // Drop the @ let matchEnd: Int - let context: Contact.Context = (openGroupV2 != nil) ? .openGroup : .regular - let displayName = Storage.shared.getContact(with: publicKey)?.displayName(for: context) - if let displayName = displayName { + if let displayName = Profile.displayNameNoFallback(for: publicKey, context: (openGroupV2 != nil ? .openGroup : .regular)) { string = (string as NSString).replacingCharacters(in: match.range, with: "@\(displayName)") mentions.append((range: NSRange(location: match.range.location, length: displayName.utf16.count + 1), publicKey: publicKey)) // + 1 to include the @ matchEnd = match.range.location + displayName.utf16.count diff --git a/Session/Utilities/MockDataGenerator.swift b/Session/Utilities/MockDataGenerator.swift index 67adf0a60..1074d1669 100644 --- a/Session/Utilities/MockDataGenerator.swift +++ b/Session/Utilities/MockDataGenerator.swift @@ -2,6 +2,7 @@ import Foundation import Sodium +import Curve25519Kit import SessionMessagingKit enum MockDataGenerator { @@ -146,7 +147,7 @@ enum MockDataGenerator { !isMessageRequest && (((0..<10).randomElement(using: &dmThreadRandomGenerator) ?? 0) < 8) // 80% approved the current user ) - Storage.shared.setContact(contact, using: transaction) + transaction.setObject(contact, forKey: contact.sessionID, inCollection: Legacy.contactCollection) // Generate the message history (Note: Unapproved message requests will only include incoming messages) logProgress("DM Thread \(threadIndex)", "Generate \(numMessages) Messages") @@ -209,7 +210,7 @@ enum MockDataGenerator { contact.name = (0.. = [] var contactThreadIds: Set = [] + var threads: Set = [] + var disappearingMessagesConfiguration: [String: Legacy.DisappearingMessagesConfiguration] = [:] + var closedGroupKeys: [String: (timestamp: TimeInterval, keys: SessionUtilitiesKit.Legacy.KeyPair)] = [:] + var closedGroupName: [String: String] = [:] + var closedGroupFormation: [String: UInt64] = [:] + var closedGroupModel: [String: TSGroupModel] = [:] + var closedGroupZombieMemberIds: [String: Set] = [:] + var openGroupInfo: [String: OpenGroupV2] = [:] Storage.read { transaction in // Process the Contacts @@ -21,24 +31,105 @@ enum _002_YDBToGRDBMigration: Migration { contacts.insert(contact) } - // Process the contact threads (only want to create "real" contacts in the new structure) - transaction.enumerateKeys(inCollection: Legacy.threadCollection) { key, _ in - guard key.starts(with: Legacy.contactThreadPrefix) else { return } - contactThreadIds.insert(key) + let userClosedGroupPublicKeys: [String] = transaction.allKeys(inCollection: Legacy.closedGroupPublicKeyCollection) + + // Process the threads + transaction.enumerateKeysAndObjects(inCollection: Legacy.threadCollection) { key, object, _ in + guard let thread: TSThread = object as? TSThread else { return } + guard let threadId: String = thread.uniqueId else { return } + + threads.insert(thread) + + // Want to exclude threads which aren't visible (ie. threads which we started + // but the user never ended up sending a message) + if key.starts(with: Legacy.contactThreadPrefix) && thread.shouldBeVisible { + contactThreadIds.insert(key) + } + + // Get the disappearing messages config + disappearingMessagesConfiguration[threadId] = transaction + .object(forKey: threadId, inCollection: Legacy.disappearingMessagesCollection) + .asType(Legacy.DisappearingMessagesConfiguration.self) + .defaulting(to: Legacy.DisappearingMessagesConfiguration.defaultWith(threadId)) + + // Process group-specific info + guard let groupThread: TSGroupThread = thread as? TSGroupThread else { return } + + if groupThread.isClosedGroup { + // The old threadId for closed groups was in the below format, we don't + // really need the unnecessary complexity so process the key and extract + // the publicKey from it + // `g{base64String(Data(__textsecure_group__!{publicKey}))} + let base64GroupId: String = String(threadId.suffix(from: threadId.index(after: threadId.startIndex))) + guard + let groupIdData: Data = Data(base64Encoded: base64GroupId), + let groupId: String = String(data: groupIdData, encoding: .utf8), + let publicKey: String = groupId.split(separator: "!").last.map({ String($0) }), + let formationTimestamp: UInt64 = transaction.object(forKey: publicKey, inCollection: Legacy.closedGroupFormationTimestampCollection) as? UInt64 + else { + SNLog("Unable to decode Closed Group during migration") + shouldFailMigration = true + return + } + guard userClosedGroupPublicKeys.contains(publicKey) else { + SNLog("Found unexpected invalid closed group public key during migration") + shouldFailMigration = true + return + } + + let keyCollection: String = "\(Legacy.closedGroupKeyPairPrefix)\(publicKey)" + + closedGroupName[threadId] = groupThread.name(with: transaction) + closedGroupModel[threadId] = groupThread.groupModel + closedGroupFormation[threadId] = formationTimestamp + closedGroupZombieMemberIds[threadId] = transaction.object( + forKey: publicKey, + inCollection: Legacy.closedGroupZombieMembersCollection + ) as? Set + + transaction.enumerateKeysAndObjects(inCollection: keyCollection) { key, object, _ in + guard let timestamp: TimeInterval = TimeInterval(key), let keyPair: SessionUtilitiesKit.Legacy.KeyPair = object as? SessionUtilitiesKit.Legacy.KeyPair else { + return + } + + closedGroupKeys[threadId] = (timestamp, keyPair) + } + } + else if groupThread.isOpenGroup { + + } + + + } } + // We can't properly throw within the 'enumerateKeysAndObjects' block so have to throw here + guard !shouldFailMigration else { throw GRDBStorageError.migrationFailed } + // Insert the data into GRDB + // MARK: - Insert Contacts + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) try contacts.forEach { contact in let isCurrentUser: Bool = (contact.sessionID == currentUserPublicKey) let contactThreadId: String = TSContactThread.threadID(fromContactSessionID: contact.sessionID) - // Determine if this contact is a "real" contact + // Create the "Profile" for the legacy contact + try Profile( + id: contact.sessionID, + name: (contact.name ?? contact.sessionID), + nickname: contact.nickname, + profilePictureUrl: contact.profilePictureURL, + profilePictureFileName: contact.profilePictureFileName, + profileEncryptionKey: contact.profileEncryptionKey + ).insert(db) + + // Determine if this contact is a "real" contact (don't want to create contacts for + // every user in the new structure but still want profiles for every user) if - // TODO: Thread.shouldBeVisible??? isCurrentUser || contactThreadIds.contains(contactThreadId) || contact.isApproved || @@ -56,16 +147,110 @@ enum _002_YDBToGRDBMigration: Migration { hasBeenBlocked: (!isCurrentUser && (contact.hasBeenBlocked || contact.isBlocked)) ).insert(db) } + } + + // MARK: - Insert Threads + + try threads.forEach { thread in + guard let legacyThreadId: String = thread.uniqueId else { return } - // Create the "Profile" for the legacy contact - try Profile( - id: contact.sessionID, - name: (contact.name ?? contact.sessionID), - nickname: contact.nickname, - profilePictureUrl: contact.profilePictureURL, - profilePictureFileName: contact.profilePictureFileName, - profileEncryptionKey: contact.profileEncryptionKey + let id: String + let variant: SessionThread.Variant + let notificationMode: SessionThread.NotificationMode + + switch thread { + case let groupThread as TSGroupThread: + if groupThread.isOpenGroup { + id = legacyThreadId//openGroup.id + variant = .openGroup + } + else { + guard let publicKey: Data = closedGroupKeys[legacyThreadId]?.keys.publicKey else { + throw GRDBStorageError.migrationFailed + } + + id = publicKey.toHexString() + variant = .closedGroup + } + + notificationMode = (thread.isMuted ? .none : + (groupThread.isOnlyNotifyingForMentions ? + .mentionsOnly : + .all + ) + ) + + default: + id = legacyThreadId.substring(from: Legacy.contactThreadPrefix.count) + variant = .contact + notificationMode = (thread.isMuted ? .none : .all) + } + + try SessionThread( + id: id, + variant: variant, + creationDateTimestamp: thread.creationDate.timeIntervalSince1970, + shouldBeVisible: thread.shouldBeVisible, + isPinned: thread.isPinned, + messageDraft: thread.messageDraft, + notificationMode: notificationMode, + mutedUntilTimestamp: thread.mutedUntilDate?.timeIntervalSince1970 ).insert(db) + + // Disappearing Messages Configuration + if let config: Legacy.DisappearingMessagesConfiguration = disappearingMessagesConfiguration[id] { + try DisappearingMessagesConfiguration( + id: id, + isEnabled: config.isEnabled, + durationSeconds: TimeInterval(config.durationSeconds) + ).insert(db) + } + + // Closed Groups + if (thread as? TSGroupThread)?.isClosedGroup == true { + guard + let keyInfo = closedGroupKeys[legacyThreadId], + let name: String = closedGroupName[legacyThreadId], + let groupModel: TSGroupModel = closedGroupModel[legacyThreadId], + let formationTimestamp: UInt64 = closedGroupFormation[legacyThreadId] + else { throw GRDBStorageError.migrationFailed } + + try ClosedGroup( + publicKey: keyInfo.keys.publicKey.toHexString(), + name: name, + formationTimestamp: TimeInterval(formationTimestamp) + ).insert(db) + + try ClosedGroupKeyPair( + publicKey: keyInfo.keys.publicKey.toHexString(), + secretKey: keyInfo.keys.privateKey, + receivedTimestamp: keyInfo.timestamp + ).insert(db) + + try groupModel.groupMemberIds.forEach { memberId in + try GroupMember( + groupId: id, + profileId: memberId, + role: .standard + ).insert(db) + } + + try groupModel.groupAdminIds.forEach { adminId in + try GroupMember( + groupId: id, + profileId: adminId, + role: .admin + ).insert(db) + } + + try (closedGroupZombieMemberIds[legacyThreadId] ?? []).forEach { zombieId in + try GroupMember( + groupId: id, + profileId: zombieId, + role: .zombie + ).insert(db) + } + } } } } diff --git a/SessionMessagingKit/Database/Models/Capability.swift b/SessionMessagingKit/Database/Models/Capability.swift new file mode 100644 index 000000000..f4c51e2fe --- /dev/null +++ b/SessionMessagingKit/Database/Models/Capability.swift @@ -0,0 +1,22 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public struct Capability: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "capability" } + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case server + case room + case capability + case isMissing + } + + public let server: String + public let room: String + public let capability: String + public let isMissing: Bool +} diff --git a/SessionMessagingKit/Database/Models/ClosedGroup.swift b/SessionMessagingKit/Database/Models/ClosedGroup.swift new file mode 100644 index 000000000..cd554a1a7 --- /dev/null +++ b/SessionMessagingKit/Database/Models/ClosedGroup.swift @@ -0,0 +1,48 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public struct ClosedGroup: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "closedGroup" } + static let keyPairs = hasMany(ClosedGroupKeyPair.self) + static let members = hasMany(GroupMember.self) + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case publicKey + case name + case formationTimestamp + } + + public var id: String { publicKey } + + public let publicKey: String + public let name: String + public let formationTimestamp: TimeInterval + + public var keyPairs: QueryInterfaceRequest { + request(for: ClosedGroup.keyPairs) + } + + public var memberIds: QueryInterfaceRequest { + request(for: ClosedGroup.members) + .filter(GroupMember.Columns.role == GroupMember.Role.standard) + } + + public var zombieIds: QueryInterfaceRequest { + request(for: ClosedGroup.members) + .filter(GroupMember.Columns.role == GroupMember.Role.zombie) + } + + public var moderatorIds: QueryInterfaceRequest { + request(for: ClosedGroup.members) + .filter(GroupMember.Columns.role == GroupMember.Role.moderator) + } + + public var adminIds: QueryInterfaceRequest { + request(for: ClosedGroup.members) + .filter(GroupMember.Columns.role == GroupMember.Role.admin) + } +} diff --git a/SessionMessagingKit/Database/Models/ClosedGroupKeyPair.swift b/SessionMessagingKit/Database/Models/ClosedGroupKeyPair.swift new file mode 100644 index 000000000..95980a19e --- /dev/null +++ b/SessionMessagingKit/Database/Models/ClosedGroupKeyPair.swift @@ -0,0 +1,22 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public struct ClosedGroupKeyPair: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "closedGroupKeyPair" } + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case publicKey + case secretKey + case receivedTimestamp + } + + public var id: String { publicKey } + + public let publicKey: String + public let secretKey: Data + public let receivedTimestamp: TimeInterval +} diff --git a/SessionMessagingKit/Database/Models/Contact.swift b/SessionMessagingKit/Database/Models/Contact.swift index 9294d5c23..6d95348c9 100644 --- a/SessionMessagingKit/Database/Models/Contact.swift +++ b/SessionMessagingKit/Database/Models/Contact.swift @@ -4,7 +4,7 @@ import Foundation import GRDB import SessionUtilitiesKit -public struct Contact: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct Contact: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "contact" } public typealias Columns = CodingKeys @@ -22,23 +22,129 @@ public struct Contact: Codable, FetchableRecord, PersistableRecord, TableRecord, public let id: String /// This flag is used to determine whether we should auto-download files sent by this contact. - public var isTrusted = false + public let isTrusted: Bool /// This flag is used to determine whether message requests from this contact are approved - public var isApproved = false + public let isApproved: Bool /// This flag is used to determine whether message requests from this contact are blocked - public var isBlocked = false { - didSet { - if isBlocked { - hasBeenBlocked = true + public let isBlocked: Bool + + /// This flag is used to determine whether this contact has approved the current users message request + public let didApproveMe: Bool + + /// This flag is used to determine whether this contact has ever been blocked (will be included in the config message if so) + public let hasBeenBlocked: Bool + + // MARK: - Initialization + + public init( + id: String, + isTrusted: Bool = false, + isApproved: Bool = false, + isBlocked: Bool = false, + didApproveMe: Bool = false, + hasBeenBlocked: Bool = false + ) { + self.id = id + self.isTrusted = ( + isTrusted || + id == getUserHexEncodedPublicKey() // Always trust ourselves + ) + self.isApproved = isApproved + self.isBlocked = isBlocked + self.didApproveMe = didApproveMe + self.hasBeenBlocked = (isBlocked || hasBeenBlocked) + } + + // MARK: - PersistableRecord + + public func save(_ db: Database) throws { + let oldContact: Contact? = try? Contact.fetchOne(db, id: id) + + try performSave(db) + + db.afterNextTransactionCommit { db in + if isBlocked != oldContact?.isBlocked { + NotificationCenter.default.post(name: .contactBlockedStateChanged, object: id) } } } - - /// This flag is used to determine whether this contact has approved the current users message request - public var didApproveMe = false - - /// This flag is used to determine whether this contact has ever been blocked (will be included in the config message if so) - public var hasBeenBlocked = false } + +// MARK: - Convenience + +public extension Contact { + func with( + isTrusted: Updatable = .existing, + isApproved: Updatable = .existing, + isBlocked: Updatable = .existing, + didApproveMe: Updatable = .existing + ) -> Contact { + return Contact( + id: id, + isTrusted: ( + (isTrusted ?? self.isTrusted) || + self.id == getUserHexEncodedPublicKey() // Always trust ourselves + ), + isApproved: (isApproved ?? self.isApproved), + isBlocked: (isBlocked ?? self.isBlocked), + didApproveMe: (didApproveMe ?? self.didApproveMe), + hasBeenBlocked: ((isBlocked ?? self.isBlocked) || self.hasBeenBlocked) + ) + } +} + +// MARK: - GRDB Interactions + +public extension Contact { + static func fetchOrCreate(_ db: Database, id: ID) -> Contact { + return ((try? fetchOne(db, id: id)) ?? Contact(id: id)) + } + + static func fetchAllIds() -> [String] { + return GRDBStorage.shared + .read { db in + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let contacts: [Contact] = try Contact + .filter(Contact.Columns.id != userPublicKey) + .filter(Contact.Columns.didApproveMe == true) + .fetchAll(db) + let profiles: [Profile] = try Profile + .fetchAll(db, ids: contacts.map { $0.id }) + + // Sort the contacts by their displayName value + return profiles + .sorted(by: { lhs, rhs -> Bool in lhs.displayName() < rhs.displayName() }) + .map { $0.id } + } + .defaulting(to: []) + } +} + +// MARK: - Objective-C Support +@objc(SMKContact) +public class SMKContact: NSObject { + @objc let isApproved: Bool + @objc let isBlocked: Bool + @objc let didApproveMe: Bool + + init(isApproved: Bool, isBlocked: Bool, didApproveMe: Bool) { + self.isApproved = isApproved + self.isBlocked = isBlocked + self.didApproveMe = didApproveMe + } + + @objc public static func fetchOrCreate(id: String) -> SMKContact { + let existingContact: Contact? = GRDBStorage.shared.read { db in + try Contact.fetchOne(db, id: id) + } + + return SMKContact( + isApproved: existingContact?.isApproved ?? false, + isBlocked: existingContact?.isBlocked ?? false, + didApproveMe: existingContact?.didApproveMe ?? false + ) + } +} + diff --git a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift new file mode 100644 index 000000000..d8aac5d0c --- /dev/null +++ b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift @@ -0,0 +1,64 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public struct DisappearingMessagesConfiguration: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "disappearingMessagesConfiguration" } + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case id + case isEnabled + case durationSeconds + } + + public let id: String + public let isEnabled: Bool + public let durationSeconds: TimeInterval +} + +// MARK: - Convenience + +extension DisappearingMessagesConfiguration { + public var durationIndex: Int { + return DisappearingMessagesConfiguration.validDurationsSeconds + .firstIndex(of: durationSeconds) + .defaulting(to: 0) + } + + public var durationString: String { + NSString.formatDurationSeconds(UInt32(durationSeconds), useShortFormat: false) + } +} + +// MARK: - UI Constraints + +extension DisappearingMessagesConfiguration { + public static var validDurationsSeconds: [TimeInterval] { + return [ + 5, + 10, + 30, + (1 * 60), + (5 * 60), + (30 * 60), + (1 * 60 * 60), + (6 * 60 * 60), + (12 * 60 * 60), + (24 * 60 * 60), + (7 * 24 * 60 * 60) + ] + } + + public static var maxDurationSeconds: TimeInterval = { + return (validDurationsSeconds.max() ?? 0) + }() +} + +// MARK: - Objective-C Support +@objc(SMKDisappearingMessagesConfiguration) +public class SMKDisappearingMessagesConfiguration: NSObject { + @objc public static var maxDurationSeconds: UInt = UInt(DisappearingMessagesConfiguration.maxDurationSeconds) +} diff --git a/SessionMessagingKit/Database/Models/GroupMember.swift b/SessionMessagingKit/Database/Models/GroupMember.swift new file mode 100644 index 000000000..2e2e93345 --- /dev/null +++ b/SessionMessagingKit/Database/Models/GroupMember.swift @@ -0,0 +1,27 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public struct GroupMember: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "groupMember" } + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case groupId + case profileId + case role + } + + public enum Role: Int, Codable, DatabaseValueConvertible { + case standard + case zombie + case moderator + case admin + } + + public let groupId: String + public let profileId: String + public let role: Role +} diff --git a/SessionMessagingKit/Database/Models/OpenGroup.swift b/SessionMessagingKit/Database/Models/OpenGroup.swift new file mode 100644 index 000000000..221447839 --- /dev/null +++ b/SessionMessagingKit/Database/Models/OpenGroup.swift @@ -0,0 +1,50 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public struct OpenGroup: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "openGroup" } + static let capabilities = hasMany(Capability.self) + static let members = hasMany(GroupMember.self) + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case server + case room + case publicKey + case name + case groupDescription = "description" + case imageId + case imageData + case userCount + case infoUpdates + } + + public var id: String { "\(server).\(room)" } + + public let server: String + public let room: String + public let publicKey: String + public let name: String + public let groupDescription: String? + public let imageId: Int? + public let imageData: Data? + public let userCount: Int + public let infoUpdates: Int + + public var capabilities: QueryInterfaceRequest { + request(for: OpenGroup.capabilities) + } + + public var moderatorIds: QueryInterfaceRequest { + request(for: OpenGroup.members) + .filter(GroupMember.Columns.role == GroupMember.Role.moderator) + } + + public var adminIds: QueryInterfaceRequest { + request(for: OpenGroup.members) + .filter(GroupMember.Columns.role == GroupMember.Role.admin) + } +} diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index ad743862d..cdbc7a21c 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -2,9 +2,10 @@ import Foundation import GRDB +import SignalCoreKit import SessionUtilitiesKit -public struct Profile: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible, CustomStringConvertible { +public struct Profile: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible, CustomStringConvertible { public static var databaseTableName: String { "profile" } public typealias Columns = CodingKeys @@ -23,19 +24,19 @@ public struct Profile: Codable, FetchableRecord, PersistableRecord, TableRecord, public let id: String /// The name of the contact. Use this whenever you need the "real", underlying name of a user (e.g. when sending a message). - public var name: String + public let name: String /// A custom name for the profile set by the current user - public var nickname: String? + public let nickname: String? /// The URL from which to fetch the contact's profile picture. - public var profilePictureUrl: String? + public let profilePictureUrl: String? /// The file name of the contact's profile picture on local storage. - public var profilePictureFileName: String? + public let profilePictureFileName: String? /// The key with which the profile is encrypted. - public var profileEncryptionKey: OWSAES256Key? + public let profileEncryptionKey: OWSAES256Key? // MARK: - Description @@ -48,6 +49,33 @@ public struct Profile: Codable, FetchableRecord, PersistableRecord, TableRecord, ) """ } + + // MARK: - PersistableRecord + + public func save(_ db: Database) throws { + let oldProfile: Profile? = try? Profile.fetchOne(db, id: id) + + try performSave(db) + + db.afterNextTransactionCommit { db in + // Delete old profile picture if needed + if let oldProfilePictureFileName: String = oldProfile?.profilePictureFileName, oldProfilePictureFileName != profilePictureFileName { + let path: String = OWSUserProfile.profileAvatarFilepath(withFilename: oldProfilePictureFileName) + DispatchQueue.global(qos: .default).async { + OWSFileSystem.deleteFileIfExists(path) + } + } + NotificationCenter.default.post(name: .profileUpdated, object: id) + + if id == getUserHexEncodedPublicKey(db) { + NotificationCenter.default.post(name: .localProfileDidChange, object: nil) + } + else { + let userInfo = [ Notification.Key.profileRecipientId.rawValue: id ] + NotificationCenter.default.post(name: .otherUsersProfileDidChange, object: nil, userInfo: userInfo) + } + } + } } // MARK: - Codable @@ -149,15 +177,32 @@ public extension Profile { // MARK: - Convenience public extension Profile { + func with( + name: String? = nil, + nickname: Updatable = .existing, + profilePictureUrl: Updatable = .existing, + profilePictureFileName: Updatable = .existing, + profileEncryptionKey: Updatable = .existing + ) -> Profile { + return Profile( + id: id, + name: (name ?? self.name), + nickname: (nickname ?? self.nickname), + profilePictureUrl: (profilePictureUrl ?? self.profilePictureUrl), + profilePictureFileName: (profilePictureFileName ?? self.profilePictureFileName), + profileEncryptionKey: (profileEncryptionKey ?? self.profileEncryptionKey) + ) + } + // MARK: - Context - enum Context: Int { + @objc enum Context: Int { case regular case openGroup } /// The name to display in the UI. For local use only. - func displayName(for context: Context) -> String? { + func displayName(for context: Context = .regular) -> String { if let nickname: String = nickname { return nickname } switch context { @@ -172,3 +217,150 @@ public extension Profile { } } } + +// MARK: - GRDB Interactions + +public extension Profile { + static func displayName(for id: ID, thread: TSThread, customFallback: String? = nil) -> String { + return displayName( + for: id, + context: ((thread as? TSGroupThread)?.isOpenGroup == true ? .openGroup : .regular), + customFallback: customFallback + ) + } + + static func displayName(for id: ID, context: Context = .regular, customFallback: String? = nil) -> String { + let existingDisplayName: String? = GRDBStorage.shared + .read { db in try Profile.fetchOne(db, id: id) }? + .displayName(for: context) + + return (existingDisplayName ?? (customFallback ?? id)) + } + + static func displayNameNoFallback(for id: ID, thread: TSThread) -> String? { + return displayName( + for: id, + context: ((thread as? TSGroupThread)?.isOpenGroup == true ? .openGroup : .regular) + ) + } + + static func displayNameNoFallback(for id: ID, context: Context = .regular) -> String? { + return GRDBStorage.shared + .read { db in try Profile.fetchOne(db, id: id) }? + .displayName(for: context) + } + + // MARK: - Fetch or Create + + private static func defaultFor(_ id: String) -> Profile { + return Profile( + id: id, + name: id, + nickname: nil, + profilePictureUrl: nil, + profilePictureFileName: nil, + profileEncryptionKey: nil + ) + } + + static func fetchOrCreateCurrentUser() -> Profile { + var userPublicKey: String = "" + + let exisingProfile: Profile? = GRDBStorage.shared.read { db in + userPublicKey = getUserHexEncodedPublicKey(db) + + return try Profile.fetchOne(db, id: userPublicKey) + } + + return (exisingProfile ?? defaultFor(userPublicKey)) + } + + static func fetchOrCreateCurrentUser(_ db: Database) -> Profile { + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + return ( + (try? Profile.fetchOne(db, id: userPublicKey)) ?? + defaultFor(userPublicKey) + ) + } + + static func fetchOrCreate(id: String) -> Profile { + let exisingProfile: Profile? = GRDBStorage.shared.read { db in + try Profile.fetchOne(db, id: id) + } + + return (exisingProfile ?? defaultFor(id)) + } + + static func fetchOrCreate(_ db: Database, id: String) -> Profile { + return ( + (try? Profile.fetchOne(db, id: id)) ?? + defaultFor(id) + ) + } +} + +// MARK: - Objective-C Support +@objc(SMKProfile) +public class SMKProfile: NSObject { + var id: String + @objc var name: String + @objc var nickname: String? + + init(id: String, name: String, nickname: String?) { + self.id = id + self.name = name + self.nickname = nickname + } + + @objc public static func fetchCurrentUserName() -> String { + let existingProfile: Profile? = GRDBStorage.shared.read { db in + Profile.fetchOrCreateCurrentUser(db) + } + + return (existingProfile?.name ?? "") + } + + @objc public static func fetchOrCreate(id: String) -> SMKProfile { + let profile: Profile = Profile.fetchOrCreate(id: id) + + return SMKProfile( + id: id, + name: profile.name, + nickname: profile.nickname + ) + } + + @objc public static func saveProfile(_ profile: SMKProfile) { + GRDBStorage.shared.write { db in + try? Profile + .fetchOrCreate(db, id: profile.id) + .with(nickname: .updateTo(profile.nickname)) + .save(db) + } + } + + @objc public static func displayName(id: String) -> String { + return Profile.displayName(for: id) + } + + @objc public static func displayName(id: String, customFallback: String) -> String { + return Profile.displayName(for: id, customFallback: customFallback) + } + + @objc public static func displayName(id: String, context: Profile.Context = .regular) -> String { + let existingProfile: Profile? = GRDBStorage.shared.read { db in + Profile.fetchOrCreateCurrentUser(db) + } + + return (existingProfile?.name ?? id) + } + + @objc public static func displayName(id: String, thread: TSThread) -> String { + return Profile.displayName(for: id, thread: thread) + } + + @objc public static var localProfileKey: OWSAES256Key? { + Profile.fetchOrCreateCurrentUser().profileEncryptionKey + } +} diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift new file mode 100644 index 000000000..efeabb7d8 --- /dev/null +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -0,0 +1,59 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public struct SessionThread: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "thread" } + static let disappearingMessagesConfiguration = hasOne(DisappearingMessagesConfiguration.self) + static let closedGroup = hasOne(ClosedGroup.self) + static let openGroup = hasOne(OpenGroup.self) + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case id + case variant + case creationDateTimestamp + case shouldBeVisible + case isPinned + case messageDraft + case notificationMode + case mutedUntilTimestamp + } + + public enum Variant: Int, Codable, DatabaseValueConvertible { + case contact + case closedGroup + case openGroup + } + + public enum NotificationMode: Int, Codable, DatabaseValueConvertible { + case all + case none + case mentionsOnly // Only applicable to group threads + } + + public let id: String + public let variant: Variant + public let creationDateTimestamp: TimeInterval + public let shouldBeVisible: Bool + public let isPinned: Bool + public let messageDraft: String? + public let notificationMode: NotificationMode + public let mutedUntilTimestamp: TimeInterval? + + public var disappearingMessagesConfiguration: QueryInterfaceRequest { + request(for: SessionThread.disappearingMessagesConfiguration) + } + +// public var lastInteraction + + public var closedGroup: QueryInterfaceRequest { + request(for: SessionThread.closedGroup) + } + + public var openGroup: QueryInterfaceRequest { + request(for: SessionThread.openGroup) + } +} diff --git a/SessionMessagingKit/Database/Notification+Contacts.swift b/SessionMessagingKit/Database/Notification+Contacts.swift index 74d855ea0..2b96b37ba 100644 --- a/SessionMessagingKit/Database/Notification+Contacts.swift +++ b/SessionMessagingKit/Database/Notification+Contacts.swift @@ -1,12 +1,27 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import SessionUtilitiesKit public extension Notification.Name { - static let contactUpdated = Notification.Name("contactUpdated") + static let profileUpdated = Notification.Name("profileUpdated") + static let localProfileDidChange = Notification.Name("localProfileDidChange") + static let otherUsersProfileDidChange = Notification.Name("otherUsersProfileDidChange") static let contactBlockedStateChanged = Notification.Name("contactBlockedStateChanged") } @objc public extension NSNotification { - @objc static let contactUpdated = Notification.Name.contactUpdated.rawValue as NSString + @objc static let profileUpdated = Notification.Name.profileUpdated.rawValue as NSString + @objc static let localProfileDidChange = Notification.Name.localProfileDidChange.rawValue as NSString + @objc static let otherUsersProfileDidChange = Notification.Name.otherUsersProfileDidChange.rawValue as NSString @objc static let contactBlockedStateChanged = Notification.Name.contactBlockedStateChanged.rawValue as NSString } + +extension Notification.Key { + static let profileRecipientId = Notification.Key("profileRecipientId") +} + +@objc public extension NSNotification { + static let profileRecipientIdKey = Notification.Key.profileRecipientId.rawValue as NSString +} diff --git a/SessionMessagingKit/Database/Storage+ClosedGroups.swift b/SessionMessagingKit/Database/Storage+ClosedGroups.swift index c94ecc912..901105af6 100644 --- a/SessionMessagingKit/Database/Storage+ClosedGroups.swift +++ b/SessionMessagingKit/Database/Storage+ClosedGroups.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Sodium import Curve25519Kit extension Storage { @@ -13,12 +14,18 @@ extension Storage { private static let closedGroupFormationTimestampCollection = "SNClosedGroupFormationTimestampCollection" private static let closedGroupZombieMembersCollection = "SNClosedGroupZombieMembersCollection" - public func getClosedGroupEncryptionKeyPairs(for groupPublicKey: String) -> [ECKeyPair] { + public func getClosedGroupEncryptionKeyPairs(for groupPublicKey: String) -> [Box.KeyPair] { var result: [ECKeyPair] = [] Storage.read { transaction in result = self.getClosedGroupEncryptionKeyPairs(for: groupPublicKey, using: transaction) } return result + .map { keyPair -> Box.KeyPair in + Box.KeyPair( + publicKey: keyPair.publicKey.bytes, + secretKey: keyPair.privateKey.bytes + ) + } } public func getClosedGroupEncryptionKeyPairs(for groupPublicKey: String, using transaction: YapDatabaseReadTransaction) -> [ECKeyPair] { @@ -31,7 +38,7 @@ extension Storage { return timestampsAndKeyPairs.sorted { $0.timestamp < $1.timestamp }.map { $0.keyPair } } - public func getLatestClosedGroupEncryptionKeyPair(for groupPublicKey: String) -> ECKeyPair? { + public func getLatestClosedGroupEncryptionKeyPair(for groupPublicKey: String) -> Box.KeyPair? { return getClosedGroupEncryptionKeyPairs(for: groupPublicKey).last } @@ -39,10 +46,14 @@ extension Storage { return getClosedGroupEncryptionKeyPairs(for: groupPublicKey, using: transaction).last } - public func addClosedGroupEncryptionKeyPair(_ keyPair: ECKeyPair, for groupPublicKey: String, using transaction: Any) { + public func addClosedGroupEncryptionKeyPair(_ keyPair: Box.KeyPair, for groupPublicKey: String, using transaction: Any) { + let ecKeyPair: ECKeyPair = try! ECKeyPair( + publicKeyData: Data(keyPair.publicKey), + privateKeyData: Data(keyPair.secretKey) + ) let collection = Storage.getClosedGroupEncryptionKeyPairCollection(for: groupPublicKey) let timestamp = String(Date().timeIntervalSince1970) - (transaction as! YapDatabaseReadWriteTransaction).setObject(keyPair, forKey: timestamp, inCollection: collection) + (transaction as! YapDatabaseReadWriteTransaction).setObject(ecKeyPair, forKey: timestamp, inCollection: collection) } public func removeAllClosedGroupEncryptionKeyPairs(for groupPublicKey: String, using transaction: Any) { diff --git a/SessionMessagingKit/Database/Storage+Contacts.swift b/SessionMessagingKit/Database/Storage+Contacts.swift deleted file mode 100644 index 96aaac03e..000000000 --- a/SessionMessagingKit/Database/Storage+Contacts.swift +++ /dev/null @@ -1,77 +0,0 @@ - -extension Storage { - - private static let contactCollection = "LokiContactCollection" - - @objc(getContactWithSessionID:) - public func getContact(with sessionID: String) -> Contact? { - var result: Contact? - Storage.read { transaction in - result = self.getContact(with: sessionID, using: transaction) - } - return result - } - - @objc(getContactWithSessionID:using:) - public func getContact(with sessionID: String, using transaction: Any) -> Contact? { - var result: Contact? - let transaction = transaction as! YapDatabaseReadTransaction - result = transaction.object(forKey: sessionID, inCollection: Storage.contactCollection) as? Contact - if let result = result, result.sessionID == getUserHexEncodedPublicKey() { - result.isTrusted = true // Always trust ourselves - } - return result - } - - @objc(setContact:usingTransaction:) - public func setContact(_ contact: Contact, using transaction: Any) { - let transaction = transaction as! YapDatabaseReadWriteTransaction - let oldContact = getContact(with: contact.sessionID, using: transaction) - if contact.sessionID == getUserHexEncodedPublicKey() { - contact.isTrusted = true // Always trust ourselves - } - transaction.setObject(contact, forKey: contact.sessionID, inCollection: Storage.contactCollection) - transaction.addCompletionQueue(DispatchQueue.main) { - // Delete old profile picture if needed - if let oldProfilePictureFileName = oldContact?.profilePictureFileName, - oldProfilePictureFileName != contact.profilePictureFileName { - let path = OWSUserProfile.profileAvatarFilepath(withFilename: oldProfilePictureFileName) - DispatchQueue.global(qos: .default).async { - OWSFileSystem.deleteFileIfExists(path) - } - } - // Post notification - let notificationCenter = NotificationCenter.default - notificationCenter.post(name: .contactUpdated, object: contact.sessionID) - - if contact.sessionID == getUserHexEncodedPublicKey() { - notificationCenter.post(name: Notification.Name(kNSNotificationName_LocalProfileDidChange), object: nil) - } - else { - let userInfo = [ kNSNotificationKey_ProfileRecipientId : contact.sessionID ] - notificationCenter.post(name: Notification.Name(kNSNotificationName_OtherUsersProfileDidChange), object: nil, userInfo: userInfo) - } - - if contact.isBlocked != oldContact?.isBlocked { - notificationCenter.post(name: .contactBlockedStateChanged, object: contact.sessionID) - } - } - } - - @objc public func getAllContacts() -> Set { - var result: Set = [] - Storage.read { transaction in - result = self.getAllContacts(with: transaction) - } - return result - } - - @objc public func getAllContacts(with transaction: YapDatabaseReadTransaction) -> Set { - var result: Set = [] - transaction.enumerateRows(inCollection: Storage.contactCollection) { _, object, _, _ in - guard let contact = object as? Contact else { return } - result.insert(contact) - } - return result - } -} diff --git a/SessionMessagingKit/Database/Storage+Shared.swift b/SessionMessagingKit/Database/Storage+Shared.swift index ca71fa284..394f3b98c 100644 --- a/SessionMessagingKit/Database/Storage+Shared.swift +++ b/SessionMessagingKit/Database/Storage+Shared.swift @@ -1,3 +1,6 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation import PromiseKit import Sodium @@ -16,23 +19,22 @@ extension Storage { public func writeSync(with block: @escaping (Any) -> Void) { Storage.writeSync { block($0) } } - - @objc public func getUser() -> Contact? { - return getUser(using: nil) - } - - public func getUser(using transaction: YapDatabaseReadTransaction?) -> Contact? { - let userPublicKey = getUserHexEncodedPublicKey() - var result: Contact? - - if let transaction = transaction { - result = Storage.shared.getContact(with: userPublicKey, using: transaction) - } - else { - Storage.read { transaction in - result = Storage.shared.getContact(with: userPublicKey, using: transaction) - } - } - return result - } +// @objc public func getUser() -> Legacy.Contact? { +// return getUser(using: nil) +// } +// +// public func getUser(using transaction: YapDatabaseReadTransaction?) -> Legacy.Contact? { +// let userPublicKey = getUserHexEncodedPublicKey() +// var result: Legacy.Contact? +// +// if let transaction = transaction { +// result = Storage.shared.getContact(with: userPublicKey, using: transaction) +// } +// else { +// Storage.read { transaction in +// result = Storage.shared.getContact(with: userPublicKey, using: transaction) +// } +// } +// return result +// } } diff --git a/SessionMessagingKit/Database/TSDatabaseView.m b/SessionMessagingKit/Database/TSDatabaseView.m index ec47b53b7..af8a31f81 100644 --- a/SessionMessagingKit/Database/TSDatabaseView.m +++ b/SessionMessagingKit/Database/TSDatabaseView.m @@ -318,7 +318,7 @@ NSString *const TSLazyRestoreAttachmentsGroup = @"TSLazyRestoreAttachmentsGroup" if (!thread.isGroupThread) { TSContactThread *contactThead = (TSContactThread *)thread; - SNContact *contact = [LKStorage.shared getContactWithSessionID:[contactThead contactSessionID]]; + SMKContact *contact = [SMKContact fetchOrCreateWithId:[contactThead contactSessionID]]; if (contact == nil || !contact.didApproveMe) { return nil; diff --git a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift index 401824b69..a6c9bfd7b 100644 --- a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift @@ -1,4 +1,7 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + import PromiseKit +import SignalCoreKit import SessionUtilitiesKit public final class AttachmentUploadJob : NSObject, Job, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility diff --git a/SessionMessagingKit/Jobs/MessageReceiveJob.swift b/SessionMessagingKit/Jobs/MessageReceiveJob.swift index c9c690c2b..eaebc38c6 100644 --- a/SessionMessagingKit/Jobs/MessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/MessageReceiveJob.swift @@ -62,25 +62,37 @@ public final class MessageReceiveJob : NSObject, Job, NSCoding { // NSObject/NSC JobQueue.currentlyExecutingJobs.insert(id) } let (promise, seal) = Promise.pending() - SNMessagingKitConfiguration.shared.storage.write(with: { transaction in // Intentionally capture self - do { - let isRetry = (self.failureCount != 0) - let (message, proto) = try MessageReceiver.parse(self.data, openGroupMessageServerID: self.openGroupMessageServerID, isRetry: isRetry, using: transaction) - message.serverHash = self.serverHash - try MessageReceiver.handle(message, associatedWithProto: proto, openGroupID: self.openGroupID, isBackgroundPoll: self.isBackgroundPoll, using: transaction) - self.handleSuccess() - seal.fulfill(()) - } catch { - if let error = error as? MessageReceiver.Error, !error.isRetryable { - SNLog("Message receive job permanently failed due to error: \(error).") - self.handlePermanentFailure(error: error) - } else { - SNLog("Couldn't receive message due to error: \(error).") - self.handleFailure(error: error) + + GRDBStorage.shared.writeAsync( + updates: { db in + SNMessagingKitConfiguration.shared.storage.write(with: { transaction in // Intentionally capture self + do { + let isRetry = (self.failureCount != 0) + let (message, proto) = try MessageReceiver.parse(db, self.data, openGroupMessageServerID: self.openGroupMessageServerID, isRetry: isRetry, using: transaction) + message.serverHash = self.serverHash + try MessageReceiver.handle(db, message, associatedWithProto: proto, openGroupID: self.openGroupID, isBackgroundPoll: self.isBackgroundPoll, using: transaction) + self.handleSuccess() + seal.fulfill(()) + } catch { + if let error = error as? MessageReceiver.Error, !error.isRetryable { + SNLog("Message receive job permanently failed due to error: \(error).") + self.handlePermanentFailure(error: error) + } else { + SNLog("Couldn't receive message due to error: \(error).") + self.handleFailure(error: error) + } + seal.fulfill(()) // The promise is just used to keep track of when we're done + } + }, completion: { }) + }, + completion: { _, result in + switch result { + case .failure(let error): self.handleFailure(error: error) + default: break } - seal.fulfill(()) // The promise is just used to keep track of when we're done } - }, completion: { }) + ) + return promise } diff --git a/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift b/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift index f529e5f3d..a5088c0a4 100644 --- a/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Sodium import Curve25519Kit import SessionUtilitiesKit @@ -18,7 +19,7 @@ public final class ClosedGroupControlMessage : ControlMessage { // MARK: Kind public enum Kind : CustomStringConvertible { - case new(publicKey: Data, name: String, encryptionKeyPair: ECKeyPair, members: [Data], admins: [Data], expirationTimer: UInt32) + case new(publicKey: Data, name: String, encryptionKeyPair: Box.KeyPair, members: [Data], admins: [Data], expirationTimer: UInt32) /// An encryption key pair encrypted for each member individually. /// /// - Note: `publicKey` is only set when an encryption key pair is sent in a one-to-one context (i.e. not in a group). @@ -95,7 +96,7 @@ public final class ClosedGroupControlMessage : ControlMessage { switch kind { case .new(let publicKey, let name, let encryptionKeyPair, let members, let admins, let expirationTimer): return !publicKey.isEmpty && !name.isEmpty && !encryptionKeyPair.publicKey.isEmpty - && !encryptionKeyPair.privateKey.isEmpty && !members.isEmpty && !admins.isEmpty + && !encryptionKeyPair.secretKey.isEmpty && !members.isEmpty && !admins.isEmpty case .encryptionKeyPair: return true case .nameChange(let name): return !name.isEmpty case .membersAdded(let members): return !members.isEmpty @@ -113,11 +114,15 @@ public final class ClosedGroupControlMessage : ControlMessage { case "new": guard let publicKey = coder.decodeObject(forKey: "publicKey") as? Data, let name = coder.decodeObject(forKey: "name") as? String, - let encryptionKeyPair = coder.decodeObject(forKey: "encryptionKeyPair") as? ECKeyPair, + let encryptionKeyPair = coder.decodeObject(forKey: "encryptionKeyPair") as? SessionUtilitiesKit.Legacy.KeyPair, let members = coder.decodeObject(forKey: "members") as? [Data], let admins = coder.decodeObject(forKey: "admins") as? [Data] else { return nil } let expirationTimer = coder.decodeObject(forKey: "expirationTimer") as? UInt32 ?? 0 - self.kind = .new(publicKey: publicKey, name: name, encryptionKeyPair: encryptionKeyPair, members: members, admins: admins, expirationTimer: expirationTimer) + let keyPair: Box.KeyPair = Box.KeyPair( + publicKey: encryptionKeyPair.publicKey.bytes, + secretKey: encryptionKeyPair.privateKey.bytes + ) + self.kind = .new(publicKey: publicKey, name: name, encryptionKeyPair: keyPair, members: members, admins: admins, expirationTimer: expirationTimer) case "encryptionKeyPair": let publicKey = coder.decodeObject(forKey: "publicKey") as? Data guard let wrappers = coder.decodeObject(forKey: "wrappers") as? [KeyPairWrapper] else { return nil } @@ -172,7 +177,7 @@ public final class ClosedGroupControlMessage : ControlMessage { } // MARK: Proto Conversion - public override class func fromProto(_ proto: SNProtoContent) -> ClosedGroupControlMessage? { + public override class func fromProto(_ proto: SNProtoContent, sender: String) -> ClosedGroupControlMessage? { guard let closedGroupControlMessageProto = proto.dataMessage?.closedGroupControlMessage else { return nil } let kind: Kind switch closedGroupControlMessageProto.type { @@ -180,14 +185,9 @@ public final class ClosedGroupControlMessage : ControlMessage { guard let publicKey = closedGroupControlMessageProto.publicKey, let name = closedGroupControlMessageProto.name, let encryptionKeyPairAsProto = closedGroupControlMessageProto.encryptionKeyPair else { return nil } let expirationTimer = closedGroupControlMessageProto.expirationTimer - do { - let encryptionKeyPair = try ECKeyPair(publicKeyData: encryptionKeyPairAsProto.publicKey.removing05PrefixIfNeeded(), privateKeyData: encryptionKeyPairAsProto.privateKey) - kind = .new(publicKey: publicKey, name: name, encryptionKeyPair: encryptionKeyPair, - members: closedGroupControlMessageProto.members, admins: closedGroupControlMessageProto.admins, expirationTimer: expirationTimer) - } catch { - SNLog("Couldn't parse key pair.") - return nil - } + let encryptionKeyPair = Box.KeyPair(publicKey: encryptionKeyPairAsProto.publicKey.removing05PrefixIfNeeded().bytes, secretKey: encryptionKeyPairAsProto.privateKey.bytes) + kind = .new(publicKey: publicKey, name: name, encryptionKeyPair: encryptionKeyPair, + members: closedGroupControlMessageProto.members, admins: closedGroupControlMessageProto.admins, expirationTimer: expirationTimer) case .encryptionKeyPair: let publicKey = closedGroupControlMessageProto.publicKey let wrappers = closedGroupControlMessageProto.wrappers.compactMap { KeyPairWrapper.fromProto($0) } @@ -219,7 +219,7 @@ public final class ClosedGroupControlMessage : ControlMessage { closedGroupControlMessage = SNProtoDataMessageClosedGroupControlMessage.builder(type: .new) closedGroupControlMessage.setPublicKey(publicKey) closedGroupControlMessage.setName(name) - let encryptionKeyPairAsProto = SNProtoKeyPair.builder(publicKey: encryptionKeyPair.publicKey, privateKey: encryptionKeyPair.privateKey) + let encryptionKeyPairAsProto = SNProtoKeyPair.builder(publicKey: Data(encryptionKeyPair.publicKey), privateKey: Data(encryptionKeyPair.secretKey)) do { closedGroupControlMessage.setEncryptionKeyPair(try encryptionKeyPairAsProto.build()) } catch { diff --git a/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift b/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift index d227ec9e4..e200fb82a 100644 --- a/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift +++ b/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift @@ -1,88 +1,73 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB import SessionUtilitiesKit extension ConfigurationMessage { - public static func getCurrent(with transaction: YapDatabaseReadTransaction) -> ConfigurationMessage? { - let storage = Storage.shared - guard let user = storage.getUser(using: transaction) else { return nil } + public static func getCurrent(_ db: Database) throws -> ConfigurationMessage? { + let profile: Profile = Profile.fetchOrCreateCurrentUser(db) - let displayName = user.name - let profilePictureURL = user.profilePictureURL - let profileKey = user.profileEncryptionKey?.keyData + let displayName: String = profile.name + let profilePictureUrl: String? = profile.profilePictureUrl + let profileKey: Data? = profile.profileEncryptionKey?.keyData var closedGroups: Set = [] var openGroups: Set = [] - var contacts: Set = [] - TSGroupThread.enumerateCollectionObjects(with: transaction) { object, _ in - guard let thread = object as? TSGroupThread else { return } - - switch thread.groupModel.groupType { - case .closedGroup: - guard thread.isCurrentUserMemberInGroup() else { return } - - let groupID = thread.groupModel.groupId - let groupPublicKey = LKGroupUtilities.getDecodedGroupID(groupID) - - guard - storage.isClosedGroup(groupPublicKey, using: transaction), - let encryptionKeyPair = storage.getLatestClosedGroupEncryptionKeyPair(for: groupPublicKey, using: transaction) - else { - return - } - - let closedGroup = ClosedGroup( - publicKey: groupPublicKey, - name: (thread.groupModel.groupName ?? ""), - encryptionKeyPair: encryptionKeyPair, - members: Set(thread.groupModel.groupMemberIds), - admins: Set(thread.groupModel.groupAdminIds), - expirationTimer: thread.disappearingMessagesDuration(with: transaction) - ) - closedGroups.insert(closedGroup) - - case .openGroup: - if let threadId: String = thread.uniqueId, let v2OpenGroup = storage.getV2OpenGroup(for: threadId) { - openGroups.insert("\(v2OpenGroup.server)/\(v2OpenGroup.room)?public_key=\(v2OpenGroup.publicKey)") - } + Storage.read { transaction in + TSGroupThread.enumerateCollectionObjects(with: transaction) { object, _ in + guard let thread = object as? TSGroupThread else { return } + + switch thread.groupModel.groupType { + case .closedGroup: + guard thread.isCurrentUserMemberInGroup() else { return } + + let groupID = thread.groupModel.groupId + let groupPublicKey = LKGroupUtilities.getDecodedGroupID(groupID) + + guard + Storage.shared.isClosedGroup(groupPublicKey, using: transaction), + let encryptionKeyPair = Storage.shared.getLatestClosedGroupEncryptionKeyPair(for: groupPublicKey, using: transaction) + else { + return + } + + let closedGroup = ClosedGroup( + publicKey: groupPublicKey, + name: (thread.groupModel.groupName ?? ""), + encryptionKeyPair: encryptionKeyPair, + members: Set(thread.groupModel.groupMemberIds), + admins: Set(thread.groupModel.groupAdminIds), + expirationTimer: thread.disappearingMessagesDuration(with: transaction) + ) + closedGroups.insert(closedGroup) + + case .openGroup: + if let threadId: String = thread.uniqueId, let v2OpenGroup = Storage.shared.getV2OpenGroup(for: threadId) { + openGroups.insert("\(v2OpenGroup.server)/\(v2OpenGroup.room)?public_key=\(v2OpenGroup.publicKey)") + } - default: break + default: break + } } } let currentUserPublicKey: String = getUserHexEncodedPublicKey() - contacts = storage.getAllContacts(with: transaction) - .compactMap { contact -> ConfigurationMessage.Contact? in - let threadID = TSContactThread.threadID(fromContactSessionID: contact.sessionID) - - guard - // Skip the current user - contact.sessionID != currentUserPublicKey && - // Contacts which have visible threads - TSContactThread.fetch(uniqueId: threadID, transaction: transaction)?.shouldBeVisible == true && ( - - // Include already approved contacts - contact.isApproved || - contact.didApproveMe || - - // Sync blocked contacts - contact.isBlocked || - contact.hasBeenBlocked - ) - else { - return nil - } + let contacts: Set = try Contact.fetchAll(db) + .compactMap { contact -> CMContact? in + guard contact.id != currentUserPublicKey else { return nil } // Can just default the 'hasX' values to true as they will be set to this // when converting to proto anyway - let profilePictureURL = contact.profilePictureURL - let profileKey = contact.profileEncryptionKey?.keyData + let profile: Profile? = try? Profile.fetchOne(db, id: contact.id) - return ConfigurationMessage.Contact( - publicKey: contact.sessionID, - displayName: (contact.name ?? contact.sessionID), - profilePictureURL: profilePictureURL, - profileKey: profileKey, + return CMContact( + publicKey: contact.id, + displayName: (profile?.name ?? contact.id), + profilePictureURL: profile?.profilePictureUrl, + profileKey: profile?.profileEncryptionKey?.keyData, hasIsApproved: true, isApproved: contact.isApproved, hasIsBlocked: true, @@ -95,7 +80,7 @@ extension ConfigurationMessage { return ConfigurationMessage( displayName: displayName, - profilePictureURL: profilePictureURL, + profilePictureURL: profilePictureUrl, profileKey: profileKey, closedGroups: closedGroups, openGroups: openGroups, diff --git a/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage.swift b/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage.swift index af4b26ffa..c50c92e44 100644 --- a/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage.swift @@ -11,14 +11,14 @@ public final class ConfigurationMessage : ControlMessage { public var displayName: String? public var profilePictureURL: String? public var profileKey: Data? - public var contacts: Set = [] + public var contacts: Set = [] public override var isSelfSendValid: Bool { true } // MARK: Initialization public override init() { super.init() } - public init(displayName: String?, profilePictureURL: String?, profileKey: Data?, closedGroups: Set, openGroups: Set, contacts: Set) { + public init(displayName: String?, profilePictureURL: String?, profileKey: Data?, closedGroups: Set, openGroups: Set, contacts: Set) { super.init() self.displayName = displayName self.profilePictureURL = profilePictureURL @@ -36,7 +36,7 @@ public final class ConfigurationMessage : ControlMessage { if let displayName = coder.decodeObject(forKey: "displayName") as! String? { self.displayName = displayName } if let profilePictureURL = coder.decodeObject(forKey: "profilePictureURL") as! String? { self.profilePictureURL = profilePictureURL } if let profileKey = coder.decodeObject(forKey: "profileKey") as! Data? { self.profileKey = profileKey } - if let contacts = coder.decodeObject(forKey: "contacts") as! Set? { self.contacts = contacts } + if let contacts = coder.decodeObject(forKey: "contacts") as! Set? { self.contacts = contacts } } public override func encode(with coder: NSCoder) { @@ -50,14 +50,14 @@ public final class ConfigurationMessage : ControlMessage { } // MARK: Proto Conversion - public override class func fromProto(_ proto: SNProtoContent) -> ConfigurationMessage? { + public override class func fromProto(_ proto: SNProtoContent, sender: String) -> ConfigurationMessage? { guard let configurationProto = proto.configurationMessage else { return nil } let displayName = configurationProto.displayName let profilePictureURL = configurationProto.profilePicture let profileKey = configurationProto.profileKey let closedGroups = Set(configurationProto.closedGroups.compactMap { ClosedGroup.fromProto($0) }) let openGroups = Set(configurationProto.openGroups) - let contacts = Set(configurationProto.contacts.compactMap { Contact.fromProto($0) }) + let contacts = Set(configurationProto.contacts.compactMap { CMContact.fromProto($0) }) return ConfigurationMessage(displayName: displayName, profilePictureURL: profilePictureURL, profileKey: profileKey, closedGroups: closedGroups, openGroups: openGroups, contacts: contacts) } @@ -89,7 +89,7 @@ public final class ConfigurationMessage : ControlMessage { displayName: \(displayName ?? "null"), profilePictureURL: \(profilePictureURL ?? "null"), profileKey: \(profileKey?.toHexString() ?? "null"), - contacts: \([Contact](contacts).prettifiedDescription) + contacts: \([CMContact](contacts).prettifiedDescription) ) """ } @@ -192,7 +192,7 @@ extension ConfigurationMessage { extension ConfigurationMessage { @objc(SNConfigurationMessageContact) - public final class Contact : NSObject, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility + public final class CMContact : NSObject, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility public var publicKey: String? public var displayName: String? public var profilePictureURL: String? @@ -259,8 +259,8 @@ extension ConfigurationMessage { coder.encode(didApproveMe, forKey: "didApproveMe") } - public static func fromProto(_ proto: SNProtoConfigurationMessageContact) -> Contact? { - let result: Contact = Contact( + public static func fromProto(_ proto: SNProtoConfigurationMessageContact) -> CMContact? { + let result: CMContact = CMContact( publicKey: proto.publicKey.toHexString(), displayName: proto.name, profilePictureURL: proto.profilePicture, diff --git a/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift b/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift index c71e35a49..fa901d7c7 100644 --- a/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift +++ b/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift @@ -60,7 +60,7 @@ public final class DataExtractionNotification : ControlMessage { } // MARK: Proto Conversion - public override class func fromProto(_ proto: SNProtoContent) -> DataExtractionNotification? { + public override class func fromProto(_ proto: SNProtoContent, sender: String) -> DataExtractionNotification? { guard let dataExtractionNotification = proto.dataExtractionNotification else { return nil } let kind: Kind switch dataExtractionNotification.type { diff --git a/SessionMessagingKit/Messages/Control Messages/ExpirationTimerUpdate.swift b/SessionMessagingKit/Messages/Control Messages/ExpirationTimerUpdate.swift index 98d42f357..0f55cc8bc 100644 --- a/SessionMessagingKit/Messages/Control Messages/ExpirationTimerUpdate.swift +++ b/SessionMessagingKit/Messages/Control Messages/ExpirationTimerUpdate.swift @@ -39,7 +39,7 @@ public final class ExpirationTimerUpdate : ControlMessage { } // MARK: Proto Conversion - public override class func fromProto(_ proto: SNProtoContent) -> ExpirationTimerUpdate? { + public override class func fromProto(_ proto: SNProtoContent, sender: String) -> ExpirationTimerUpdate? { guard let dataMessageProto = proto.dataMessage else { return nil } let isExpirationTimerUpdate = (dataMessageProto.flags & UInt32(SNProtoDataMessage.SNProtoDataMessageFlags.expirationTimerUpdate.rawValue)) != 0 guard isExpirationTimerUpdate else { return nil } diff --git a/SessionMessagingKit/Messages/Control Messages/MessageRequestResponse.swift b/SessionMessagingKit/Messages/Control Messages/MessageRequestResponse.swift index 29b57684f..f88a7c9bd 100644 --- a/SessionMessagingKit/Messages/Control Messages/MessageRequestResponse.swift +++ b/SessionMessagingKit/Messages/Control Messages/MessageRequestResponse.swift @@ -30,7 +30,7 @@ public final class MessageRequestResponse: ControlMessage { // MARK: - Proto Conversion - public override class func fromProto(_ proto: SNProtoContent) -> MessageRequestResponse? { + public override class func fromProto(_ proto: SNProtoContent, sender: String) -> MessageRequestResponse? { guard let messageRequestResponseProto = proto.messageRequestResponse else { return nil } let isApproved = messageRequestResponseProto.isApproved diff --git a/SessionMessagingKit/Messages/Control Messages/ReadReceipt.swift b/SessionMessagingKit/Messages/Control Messages/ReadReceipt.swift index cdce7ae1e..af40f0c6e 100644 --- a/SessionMessagingKit/Messages/Control Messages/ReadReceipt.swift +++ b/SessionMessagingKit/Messages/Control Messages/ReadReceipt.swift @@ -31,7 +31,7 @@ public final class ReadReceipt : ControlMessage { } // MARK: Proto Conversion - public override class func fromProto(_ proto: SNProtoContent) -> ReadReceipt? { + public override class func fromProto(_ proto: SNProtoContent, sender: String) -> ReadReceipt? { guard let receiptProto = proto.receiptMessage, receiptProto.type == .read else { return nil } let timestamps = receiptProto.timestamp guard !timestamps.isEmpty else { return nil } diff --git a/SessionMessagingKit/Messages/Control Messages/TypingIndicator.swift b/SessionMessagingKit/Messages/Control Messages/TypingIndicator.swift index 965fdfa38..2b5957328 100644 --- a/SessionMessagingKit/Messages/Control Messages/TypingIndicator.swift +++ b/SessionMessagingKit/Messages/Control Messages/TypingIndicator.swift @@ -58,7 +58,7 @@ public final class TypingIndicator : ControlMessage { } // MARK: Proto Conversion - public override class func fromProto(_ proto: SNProtoContent) -> TypingIndicator? { + public override class func fromProto(_ proto: SNProtoContent, sender: String) -> TypingIndicator? { guard let typingIndicatorProto = proto.typingMessage else { return nil } let kind = Kind.fromProto(typingIndicatorProto.action) return TypingIndicator(kind: kind) diff --git a/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift b/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift index 791b18c58..4593879ab 100644 --- a/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift +++ b/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift @@ -36,7 +36,7 @@ public final class UnsendRequest: ControlMessage { } // MARK: Proto Conversion - public override class func fromProto(_ proto: SNProtoContent) -> UnsendRequest? { + public override class func fromProto(_ proto: SNProtoContent, sender: String) -> UnsendRequest? { guard let unsendRequestProto = proto.unsendRequest else { return nil } let timestamp = unsendRequestProto.timestamp let author = unsendRequestProto.author diff --git a/SessionMessagingKit/Messages/Message.swift b/SessionMessagingKit/Messages/Message.swift index b1d8786a2..e57774222 100644 --- a/SessionMessagingKit/Messages/Message.swift +++ b/SessionMessagingKit/Messages/Message.swift @@ -53,8 +53,8 @@ public class Message : NSObject, NSCoding { // NSObject/NSCoding conformance is } // MARK: Proto Conversion - public class func fromProto(_ proto: SNProtoContent) -> Self? { - preconditionFailure("fromProto(_:) is abstract and must be overridden.") + public class func fromProto(_ proto: SNProtoContent, sender: String) -> Self? { + preconditionFailure("fromProto(_:sender:) is abstract and must be overridden.") } public func toProto(using transaction: YapDatabaseReadWriteTransaction) -> SNProtoContent? { diff --git a/SessionMessagingKit/Messages/Signal/TSMessage.m b/SessionMessagingKit/Messages/Signal/TSMessage.m index 59548cae7..e390a4257 100644 --- a/SessionMessagingKit/Messages/Signal/TSMessage.m +++ b/SessionMessagingKit/Messages/Signal/TSMessage.m @@ -144,7 +144,7 @@ const NSUInteger kOversizeTextMessageSizeThreshold = 2 * 1024; - (void)setExpiresInSeconds:(uint32_t)expiresInSeconds { - uint32_t maxExpirationDuration = [OWSDisappearingMessagesConfiguration maxDurationSeconds]; + uint32_t maxExpirationDuration = [SMKDisappearingMessagesConfiguration maxDurationSeconds]; _expiresInSeconds = MIN(expiresInSeconds, maxExpirationDuration); [self updateExpiresAt]; diff --git a/SessionMessagingKit/Messages/Signal/TSOutgoingMessage+Conversion.swift b/SessionMessagingKit/Messages/Signal/TSOutgoingMessage+Conversion.swift index 1509af0f3..8cea810d1 100644 --- a/SessionMessagingKit/Messages/Signal/TSOutgoingMessage+Conversion.swift +++ b/SessionMessagingKit/Messages/Signal/TSOutgoingMessage+Conversion.swift @@ -1,3 +1,6 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation import SessionUtilitiesKit @objc public extension TSOutgoingMessage { @@ -9,11 +12,11 @@ import SessionUtilitiesKit static func from(_ visibleMessage: VisibleMessage, associatedWith thread: TSThread, using transaction: YapDatabaseReadWriteTransaction? = nil) -> TSOutgoingMessage { var expiration: UInt32 = 0 - let disappearingMessagesConfigurationOrNil: OWSDisappearingMessagesConfiguration? + let disappearingMessagesConfigurationOrNil: Legacy.DisappearingMessagesConfiguration? if let transaction = transaction { - disappearingMessagesConfigurationOrNil = OWSDisappearingMessagesConfiguration.fetch(uniqueId: thread.uniqueId!, transaction: transaction) + disappearingMessagesConfigurationOrNil = Legacy.DisappearingMessagesConfiguration.fetch(uniqueId: thread.uniqueId!, transaction: transaction) } else { - disappearingMessagesConfigurationOrNil = OWSDisappearingMessagesConfiguration.fetch(uniqueId: thread.uniqueId!) + disappearingMessagesConfigurationOrNil = Legacy.DisappearingMessagesConfiguration.fetch(uniqueId: thread.uniqueId!) } if let disappearingMessagesConfiguration = disappearingMessagesConfigurationOrNil { expiration = disappearingMessagesConfiguration.isEnabled ? disappearingMessagesConfiguration.durationSeconds : 0 diff --git a/SessionMessagingKit/Messages/Signal/TypingIndicatorInteraction.swift b/SessionMessagingKit/Messages/Signal/TypingIndicatorInteraction.swift index 4d72fc30b..6bb271d05 100644 --- a/SessionMessagingKit/Messages/Signal/TypingIndicatorInteraction.swift +++ b/SessionMessagingKit/Messages/Signal/TypingIndicatorInteraction.swift @@ -1,8 +1,7 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import SignalCoreKit @objc(OWSTypingIndicatorInteraction) public class TypingIndicatorInteraction: TSInteraction { diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift index d9e82f7e5..737d8c476 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift @@ -1,72 +1,72 @@ -import SessionUtilitiesKit - -public extension VisibleMessage { - - @objc(SNProfile) - class Profile : NSObject, NSCoding { - public var displayName: String? - public var profileKey: Data? - public var profilePictureURL: String? - - internal init(displayName: String, profileKey: Data? = nil, profilePictureURL: String? = nil) { - self.displayName = displayName - self.profileKey = profileKey - self.profilePictureURL = profilePictureURL - } - - public required init?(coder: NSCoder) { - if let displayName = coder.decodeObject(forKey: "displayName") as! String? { self.displayName = displayName } - if let profileKey = coder.decodeObject(forKey: "profileKey") as! Data? { self.profileKey = profileKey } - if let profilePictureURL = coder.decodeObject(forKey: "profilePictureURL") as! String? { self.profilePictureURL = profilePictureURL } - } - - public func encode(with coder: NSCoder) { - coder.encode(displayName, forKey: "displayName") - coder.encode(profileKey, forKey: "profileKey") - coder.encode(profilePictureURL, forKey: "profilePictureURL") - } - - public static func fromProto(_ proto: SNProtoDataMessage) -> Profile? { - guard let profileProto = proto.profile, let displayName = profileProto.displayName else { return nil } - let profileKey = proto.profileKey - let profilePictureURL = profileProto.profilePicture - if let profileKey = profileKey, let profilePictureURL = profilePictureURL { - return Profile(displayName: displayName, profileKey: profileKey, profilePictureURL: profilePictureURL) - } else { - return Profile(displayName: displayName) - } - } - - public func toProto() -> SNProtoDataMessage? { - guard let displayName = displayName else { - SNLog("Couldn't construct profile proto from: \(self).") - return nil - } - let dataMessageProto = SNProtoDataMessage.builder() - let profileProto = SNProtoDataMessageLokiProfile.builder() - profileProto.setDisplayName(displayName) - if let profileKey = profileKey, let profilePictureURL = profilePictureURL { - dataMessageProto.setProfileKey(profileKey) - profileProto.setProfilePicture(profilePictureURL) - } - do { - dataMessageProto.setProfile(try profileProto.build()) - return try dataMessageProto.build() - } catch { - SNLog("Couldn't construct profile proto from: \(self).") - return nil - } - } - - // MARK: Description - public override var description: String { - """ - Profile( - displayName: \(displayName ?? "null"), - profileKey: \(profileKey?.description ?? "null"), - profilePictureURL: \(profilePictureURL ?? "null") - ) - """ - } - } -} +//import SessionUtilitiesKit +// +//public extension VisibleMessage { +// +// @objc(SNProfile) +// class Profile : NSObject, NSCoding { +// public var displayName: String? +// public var profileKey: Data? +// public var profilePictureURL: String? +// +// internal init(displayName: String, profileKey: Data? = nil, profilePictureURL: String? = nil) { +// self.displayName = displayName +// self.profileKey = profileKey +// self.profilePictureURL = profilePictureURL +// } +// +// public required init?(coder: NSCoder) { +// if let displayName = coder.decodeObject(forKey: "displayName") as! String? { self.displayName = displayName } +// if let profileKey = coder.decodeObject(forKey: "profileKey") as! Data? { self.profileKey = profileKey } +// if let profilePictureURL = coder.decodeObject(forKey: "profilePictureURL") as! String? { self.profilePictureURL = profilePictureURL } +// } +// +// public func encode(with coder: NSCoder) { +// coder.encode(displayName, forKey: "displayName") +// coder.encode(profileKey, forKey: "profileKey") +// coder.encode(profilePictureURL, forKey: "profilePictureURL") +// } +// +// public static func fromProto(_ proto: SNProtoDataMessage, sessionId: String) -> Profile? { +// guard let profileProto = proto.profile, let displayName = profileProto.displayName else { return nil } +// let profileKey = proto.profileKey +// let profilePictureURL = profileProto.profilePicture +// if let profileKey = profileKey, let profilePictureURL = profilePictureURL { +// return Profile(displayName: displayName, profileKey: profileKey, profilePictureURL: profilePictureURL) +// } else { +// return Profile(displayName: displayName) +// } +// } +// +// public func toProto() -> SNProtoDataMessage? { +// guard let displayName = displayName else { +// SNLog("Couldn't construct profile proto from: \(self).") +// return nil +// } +// let dataMessageProto = SNProtoDataMessage.builder() +// let profileProto = SNProtoDataMessageLokiProfile.builder() +// profileProto.setDisplayName(displayName) +// if let profileKey = profileKey, let profilePictureURL = profilePictureURL { +// dataMessageProto.setProfileKey(profileKey) +// profileProto.setProfilePicture(profilePictureURL) +// } +// do { +// dataMessageProto.setProfile(try profileProto.build()) +// return try dataMessageProto.build() +// } catch { +// SNLog("Couldn't construct profile proto from: \(self).") +// return nil +// } +// } +// +// // MARK: Description +// public override var description: String { +// """ +// Profile( +// displayName: \(displayName ?? "null"), +// profileKey: \(profileKey?.description ?? "null"), +// profilePictureURL: \(profilePictureURL ?? "null") +// ) +// """ +// } +// } +//} diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift index 21c1a41be..3980fcffd 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift @@ -10,8 +10,8 @@ public final class VisibleMessage : Message { @objc public var attachmentIDs: [String] = [] @objc public var quote: Quote? @objc public var linkPreview: LinkPreview? - @objc public var contact: Contact? - @objc public var profile: Profile? + @objc public var contact: Legacy.Contact? + @objc public var profile: Legacy.Profile? @objc public var openGroupInvitation: OpenGroupInvitation? public override var isSelfSendValid: Bool { true } @@ -37,7 +37,7 @@ public final class VisibleMessage : Message { if let quote = coder.decodeObject(forKey: "quote") as! Quote? { self.quote = quote } if let linkPreview = coder.decodeObject(forKey: "linkPreview") as! LinkPreview? { self.linkPreview = linkPreview } // TODO: Contact - if let profile = coder.decodeObject(forKey: "profile") as! Profile? { self.profile = profile } + if let profile = coder.decodeObject(forKey: "profile") as! Legacy.Profile? { self.profile = profile } if let openGroupInvitation = coder.decodeObject(forKey: "openGroupInvitation") as! OpenGroupInvitation? { self.openGroupInvitation = openGroupInvitation } } @@ -54,7 +54,7 @@ public final class VisibleMessage : Message { } // MARK: Proto Conversion - public override class func fromProto(_ proto: SNProtoContent) -> VisibleMessage? { + public override class func fromProto(_ proto: SNProtoContent, sender: String) -> VisibleMessage? { guard let dataMessage = proto.dataMessage else { return nil } let result = VisibleMessage() result.text = dataMessage.body @@ -62,7 +62,7 @@ public final class VisibleMessage : Message { if let quoteProto = dataMessage.quote, let quote = Quote.fromProto(quoteProto) { result.quote = quote } if let linkPreviewProto = dataMessage.preview.first, let linkPreview = LinkPreview.fromProto(linkPreviewProto) { result.linkPreview = linkPreview } // TODO: Contact - if let profile = Profile.fromProto(dataMessage) { result.profile = profile } + if let profile = Legacy.Profile.fromProto(dataMessage) { result.profile = profile } if let openGroupInvitationProto = dataMessage.openGroupInvitation, let openGroupInvitation = OpenGroupInvitation.fromProto(openGroupInvitationProto) { result.openGroupInvitation = openGroupInvitation } result.syncTarget = dataMessage.syncTarget diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift b/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift index b4bf3d196..02adda022 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift @@ -8,7 +8,7 @@ import SessionUtilitiesKit @objc(SNOpenGroupAPIV2) public final class OpenGroupAPIV2 : NSObject { - private static var authTokenPromises: [String: Promise] = [:] + private static var authTokenPromises: Atomic<[String: Promise]> = Atomic([:]) private static var hasPerformedInitialPoll: [String: Bool] = [:] private static var hasUpdatedLastOpenDate = false public static let workQueue = DispatchQueue(label: "OpenGroupAPIV2.workQueue", qos: .userInitiated) // It's important that this is a serial queue @@ -215,7 +215,7 @@ public final class OpenGroupAPIV2 : NSObject { if let authToken = storage.getAuthToken(for: room, on: server) { return Promise.value(authToken) } else { - if let authTokenPromise = authTokenPromises["\(server).\(room)"] { + if let authTokenPromise = authTokenPromises.wrappedValue["\(server).\(room)"] { return authTokenPromise } else { let promise = requestNewAuthToken(for: room, on: server) @@ -230,11 +230,11 @@ public final class OpenGroupAPIV2 : NSObject { return promise } promise.done(on: OpenGroupAPIV2.workQueue) { _ in - authTokenPromises["\(server).\(room)"] = nil + authTokenPromises.mutate { $0["\(server).\(room)"] = nil } }.catch(on: OpenGroupAPIV2.workQueue) { _ in - authTokenPromises["\(server).\(room)"] = nil + authTokenPromises.mutate { $0["\(server).\(room)"] = nil } } - authTokenPromises["\(server).\(room)"] = promise + authTokenPromises.mutate { $0["\(server).\(room)"] = promise } return promise } } @@ -251,7 +251,7 @@ public final class OpenGroupAPIV2 : NSObject { let ephemeralPublicKey = Data(base64Encoded: base64EncodedEphemeralPublicKey) else { throw Error.parsingFailed } - let symmetricKey = try AESGCM.generateSymmetricKey(x25519PublicKey: ephemeralPublicKey, x25519PrivateKey: userKeyPair.privateKey) + let symmetricKey = try AESGCM.generateSymmetricKey(x25519PublicKey: ephemeralPublicKey, x25519PrivateKey: Data(userKeyPair.secretKey)) guard let tokenAsData = try? AESGCM.decrypt(ciphertext, with: symmetricKey) else { throw Error.decryptionFailed } return tokenAsData.toHexString() } diff --git a/SessionMessagingKit/Open Groups/OpenGroupMessageV2.swift b/SessionMessagingKit/Open Groups/OpenGroupMessageV2.swift index 3a3cb7d3a..a0671dbbb 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupMessageV2.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupMessageV2.swift @@ -17,10 +17,11 @@ public struct OpenGroupMessageV2 { public func sign() -> OpenGroupMessageV2? { guard let userKeyPair = Identity.fetchUserKeyPair(), + let legacyKeyPair: ECKeyPair = try? ECKeyPair(publicKeyData: Data(userKeyPair.publicKey), privateKeyData: Data(userKeyPair.secretKey)), let data: Data = Data(base64Encoded: base64EncodedData) else { return nil } - guard let signature = try? Ed25519.sign(data, with: userKeyPair) else { + guard let signature = try? Ed25519.sign(data, with: legacyKeyPair) else { SNLog("Failed to sign open group message.") return nil } diff --git a/SessionMessagingKit/Sending & Receiving/Data Extraction/DataExtractionNotificationInfoMessage.swift b/SessionMessagingKit/Sending & Receiving/Data Extraction/DataExtractionNotificationInfoMessage.swift index 18ceef7f5..2e0ce41cf 100644 --- a/SessionMessagingKit/Sending & Receiving/Data Extraction/DataExtractionNotificationInfoMessage.swift +++ b/SessionMessagingKit/Sending & Receiving/Data Extraction/DataExtractionNotificationInfoMessage.swift @@ -1,3 +1,8 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit @objc(SNDataExtractionNotificationInfoMessage) final class DataExtractionNotificationInfoMessage : TSInfoMessage { @@ -10,20 +15,24 @@ final class DataExtractionNotificationInfoMessage : TSInfoMessage { super.init(coder: coder) } - required init(dictionary dictionaryValue: [String:Any]!) throws { + required init(dictionary dictionaryValue: [String: Any]!) throws { try super.init(dictionary: dictionaryValue) } override func previewText(with transaction: YapDatabaseReadTransaction) -> String { guard let thread = thread as? TSContactThread else { return "" } // Should never occur - let sessionID = thread.contactSessionID() - let displayName = Storage.shared.getContact(with: sessionID)?.displayName(for: .regular) ?? sessionID + + let displayName = Profile.displayName(for: thread.contactSessionID()) + switch messageType { - case .screenshotNotification: return String(format: NSLocalizedString("screenshot_taken", comment: ""), displayName) - case .mediaSavedNotification: - // TODO: Use referencedAttachmentTimestamp to tell the user * which * media was saved - return String(format: NSLocalizedString("meida_saved", comment: ""), displayName) - default: preconditionFailure() + case .screenshotNotification: + return String(format: NSLocalizedString("screenshot_taken", comment: ""), displayName) + + case .mediaSavedNotification: + // TODO: Use referencedAttachmentTimestamp to tell the user * which * media was saved + return String(format: NSLocalizedString("meida_saved", comment: ""), displayName) + + default: preconditionFailure() } } } diff --git a/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingConfigurationUpdateInfoMessage.m b/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingConfigurationUpdateInfoMessage.m index 82c3071ae..00fb99acc 100644 --- a/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingConfigurationUpdateInfoMessage.m +++ b/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingConfigurationUpdateInfoMessage.m @@ -3,9 +3,10 @@ // #import "OWSDisappearingConfigurationUpdateInfoMessage.h" -#import "OWSDisappearingMessagesConfiguration.h" +#import #import + NS_ASSUME_NONNULL_BEGIN @interface OWSDisappearingConfigurationUpdateInfoMessage () diff --git a/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingMessagesConfiguration.h b/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingMessagesConfiguration.h index dfbee9b3a..367b787d8 100644 --- a/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingMessagesConfiguration.h +++ b/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingMessagesConfiguration.h @@ -1,35 +1,35 @@ +//// +//// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +//// // -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +//#import +//#import // - -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -#define OWSDisappearingMessagesConfigurationDefaultExpirationDuration kDayInterval - -@class YapDatabaseReadTransaction; - -@interface OWSDisappearingMessagesConfiguration : TSYapDatabaseObject - -- (instancetype)initDefaultWithThreadId:(NSString *)threadId; - -- (instancetype)initWithThreadId:(NSString *)threadId enabled:(BOOL)isEnabled durationSeconds:(uint32_t)seconds; - -@property (nonatomic, getter=isEnabled) BOOL enabled; -@property (nonatomic) uint32_t durationSeconds; -@property (nonatomic, readonly) NSUInteger durationIndex; -@property (nonatomic, readonly) NSString *durationString; -@property (nonatomic, readonly) BOOL dictionaryValueDidChange; -@property (readonly, getter=isNewRecord) BOOL newRecord; - -+ (instancetype)fetchOrBuildDefaultWithThreadId:(NSString *)threadId - transaction:(YapDatabaseReadTransaction *)transaction; - -+ (NSArray *)validDurationsSeconds; -+ (uint32_t)maxDurationSeconds; - -@end - -NS_ASSUME_NONNULL_END +//NS_ASSUME_NONNULL_BEGIN +// +//#define OWSDisappearingMessagesConfigurationDefaultExpirationDuration kDayInterval +// +//@class YapDatabaseReadTransaction; +// +//@interface OWSDisappearingMessagesConfiguration : TSYapDatabaseObject +// +//- (instancetype)initDefaultWithThreadId:(NSString *)threadId; +// +//- (instancetype)initWithThreadId:(NSString *)threadId enabled:(BOOL)isEnabled durationSeconds:(uint32_t)seconds; +// +//@property (nonatomic, getter=isEnabled) BOOL enabled; +//@property (nonatomic) uint32_t durationSeconds; +//@property (nonatomic, readonly) NSUInteger durationIndex; +//@property (nonatomic, readonly) NSString *durationString; +//@property (nonatomic, readonly) BOOL dictionaryValueDidChange; +//@property (readonly, getter=isNewRecord) BOOL newRecord; +// +//+ (instancetype)fetchOrBuildDefaultWithThreadId:(NSString *)threadId +// transaction:(YapDatabaseReadTransaction *)transaction; +// +//+ (NSArray *)validDurationsSeconds; +//+ (uint32_t)maxDurationSeconds; +// +//@end +// +//NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingMessagesConfiguration.m b/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingMessagesConfiguration.m index 9e93fbbff..8d7f5b12f 100644 --- a/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingMessagesConfiguration.m +++ b/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingMessagesConfiguration.m @@ -1,130 +1,130 @@ +//// +//// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +//// // -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +//#import "OWSDisappearingMessagesConfiguration.h" +//#import +//#import // - -#import "OWSDisappearingMessagesConfiguration.h" -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface OWSDisappearingMessagesConfiguration () - -// Transient record lifecycle attributes. -@property (atomic) NSDictionary *originalDictionaryValue; -@property (atomic, getter=isNewRecord) BOOL newRecord; - -@end - -@implementation OWSDisappearingMessagesConfiguration - -- (instancetype)initDefaultWithThreadId:(NSString *)threadId -{ - return [self initWithThreadId:threadId - enabled:NO - durationSeconds:(NSTimeInterval)OWSDisappearingMessagesConfigurationDefaultExpirationDuration]; -} - -- (nullable instancetype)initWithCoder:(NSCoder *)coder -{ - self = [super initWithCoder:coder]; - - _originalDictionaryValue = [self dictionaryValue]; - _newRecord = NO; - - return self; -} - -- (instancetype)initWithThreadId:(NSString *)threadId enabled:(BOOL)isEnabled durationSeconds:(uint32_t)seconds -{ - self = [super initWithUniqueId:threadId]; - if (!self) { - return self; - } - - _enabled = isEnabled; - _durationSeconds = seconds; - _newRecord = YES; - _originalDictionaryValue = self.dictionaryValue; - - return self; -} - -+ (instancetype)fetchOrBuildDefaultWithThreadId:(NSString *)threadId - transaction:(YapDatabaseReadTransaction *)transaction -{ - OWSDisappearingMessagesConfiguration *savedConfiguration = - [self fetchObjectWithUniqueID:threadId transaction:transaction]; - if (savedConfiguration) { - return savedConfiguration; - } else { - return [[self alloc] initDefaultWithThreadId:threadId]; - } -} - -+ (NSArray *)validDurationsSeconds -{ - return @[ - @(5 * kSecondInterval), - @(10 * kSecondInterval), - @(30 * kSecondInterval), - @(1 * kMinuteInterval), - @(5 * kMinuteInterval), - @(30 * kMinuteInterval), - @(1 * kHourInterval), - @(6 * kHourInterval), - @(12 * kHourInterval), - @(24 * kHourInterval), - @(1 * kWeekInterval) - ]; -} - -+ (uint32_t)maxDurationSeconds -{ - static uint32_t max; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - max = [[self.validDurationsSeconds valueForKeyPath:@"@max.intValue"] unsignedIntValue]; - }); - - return max; -} - -- (NSUInteger)durationIndex -{ - return [[self.class validDurationsSeconds] indexOfObject:@(self.durationSeconds)]; -} - -- (NSString *)durationString -{ - return [NSString formatDurationSeconds:self.durationSeconds useShortFormat:NO]; -} - -#pragma mark - Dirty Tracking - -+ (MTLPropertyStorage)storageBehaviorForPropertyWithKey:(NSString *)propertyKey -{ - // Don't persist transient properties - if ([propertyKey isEqualToString:@"originalDictionaryValue"] - ||[propertyKey isEqualToString:@"newRecord"]) { - return MTLPropertyStorageNone; - } else { - return [super storageBehaviorForPropertyWithKey:propertyKey]; - } -} - -- (BOOL)dictionaryValueDidChange -{ - return ![self.originalDictionaryValue isEqual:[self dictionaryValue]]; -} - -- (void)saveWithTransaction:(YapDatabaseReadWriteTransaction *)transaction -{ - [super saveWithTransaction:transaction]; - self.originalDictionaryValue = [self dictionaryValue]; - self.newRecord = NO; -} - -@end - -NS_ASSUME_NONNULL_END +//NS_ASSUME_NONNULL_BEGIN +// +//@interface OWSDisappearingMessagesConfiguration () +// +//// Transient record lifecycle attributes. +//@property (atomic) NSDictionary *originalDictionaryValue; +//@property (atomic, getter=isNewRecord) BOOL newRecord; +// +//@end +// +//@implementation OWSDisappearingMessagesConfiguration +// +//- (instancetype)initDefaultWithThreadId:(NSString *)threadId +//{ +// return [self initWithThreadId:threadId +// enabled:NO +// durationSeconds:(NSTimeInterval)OWSDisappearingMessagesConfigurationDefaultExpirationDuration]; +//} +// +//- (nullable instancetype)initWithCoder:(NSCoder *)coder +//{ +// self = [super initWithCoder:coder]; +// +// _originalDictionaryValue = [self dictionaryValue]; +// _newRecord = NO; +// +// return self; +//} +// +//- (instancetype)initWithThreadId:(NSString *)threadId enabled:(BOOL)isEnabled durationSeconds:(uint32_t)seconds +//{ +// self = [super initWithUniqueId:threadId]; +// if (!self) { +// return self; +// } +// +// _enabled = isEnabled; +// _durationSeconds = seconds; +// _newRecord = YES; +// _originalDictionaryValue = self.dictionaryValue; +// +// return self; +//} +// +//+ (instancetype)fetchOrBuildDefaultWithThreadId:(NSString *)threadId +// transaction:(YapDatabaseReadTransaction *)transaction +//{ +// OWSDisappearingMessagesConfiguration *savedConfiguration = +// [self fetchObjectWithUniqueID:threadId transaction:transaction]; +// if (savedConfiguration) { +// return savedConfiguration; +// } else { +// return [[self alloc] initDefaultWithThreadId:threadId]; +// } +//} +// +//+ (NSArray *)validDurationsSeconds +//{ +// return @[ +// @(5 * kSecondInterval), +// @(10 * kSecondInterval), +// @(30 * kSecondInterval), +// @(1 * kMinuteInterval), +// @(5 * kMinuteInterval), +// @(30 * kMinuteInterval), +// @(1 * kHourInterval), +// @(6 * kHourInterval), +// @(12 * kHourInterval), +// @(24 * kHourInterval), +// @(1 * kWeekInterval) +// ]; +//} +// +//+ (uint32_t)maxDurationSeconds +//{ +// static uint32_t max; +// static dispatch_once_t onceToken; +// dispatch_once(&onceToken, ^{ +// max = [[self.validDurationsSeconds valueForKeyPath:@"@max.intValue"] unsignedIntValue]; +// }); +// +// return max; +//} +// +//- (NSUInteger)durationIndex +//{ +// return [[self.class validDurationsSeconds] indexOfObject:@(self.durationSeconds)]; +//} +// +//- (NSString *)durationString +//{ +// return [NSString formatDurationSeconds:self.durationSeconds useShortFormat:NO]; +//} +// +//#pragma mark - Dirty Tracking +// +//+ (MTLPropertyStorage)storageBehaviorForPropertyWithKey:(NSString *)propertyKey +//{ +// // Don't persist transient properties +// if ([propertyKey isEqualToString:@"originalDictionaryValue"] +// ||[propertyKey isEqualToString:@"newRecord"]) { +// return MTLPropertyStorageNone; +// } else { +// return [super storageBehaviorForPropertyWithKey:propertyKey]; +// } +//} +// +//- (BOOL)dictionaryValueDidChange +//{ +// return ![self.originalDictionaryValue isEqual:[self dictionaryValue]]; +//} +// +//- (void)saveWithTransaction:(YapDatabaseReadWriteTransaction *)transaction +//{ +// [super saveWithTransaction:transaction]; +// self.originalDictionaryValue = [self dictionaryValue]; +// self.newRecord = NO; +//} +// +//@end +// +//NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingMessagesJob.m b/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingMessagesJob.m index 31e797171..0c39211c7 100644 --- a/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingMessagesJob.m +++ b/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingMessagesJob.m @@ -188,8 +188,7 @@ void AssertIsOnDisappearingMessagesQueue() NSString *_Nullable remoteContactName = nil; if (remoteRecipientId) { - SNContactContext context = [SNContact contextForThread:thread]; - remoteContactName = [[LKStorage.shared getContactWithSessionID:remoteRecipientId] displayNameFor:context] ?: remoteRecipientId; + remoteContactName = [SMKProfile displayNameWithId:remoteRecipientId thread:thread]; } // Become eventually consistent in the case that the remote changed their settings at the same time. @@ -198,9 +197,9 @@ void AssertIsOnDisappearingMessagesQueue() [thread disappearingMessagesConfigurationWithTransaction:transaction]; if (duration == 0) { - disappearingMessagesConfiguration.enabled = NO; + disappearingMessagesConfiguration.isEnabled = NO; } else { - disappearingMessagesConfiguration.enabled = YES; + disappearingMessagesConfiguration.isEnabled = YES; disappearingMessagesConfiguration.durationSeconds = duration; } diff --git a/SessionMessagingKit/Sending & Receiving/Link Previews/OWSLinkPreview.swift b/SessionMessagingKit/Sending & Receiving/Link Previews/OWSLinkPreview.swift index cb0cd1919..1c4f8ddfb 100644 --- a/SessionMessagingKit/Sending & Receiving/Link Previews/OWSLinkPreview.swift +++ b/SessionMessagingKit/Sending & Receiving/Link Previews/OWSLinkPreview.swift @@ -1,10 +1,9 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import AFNetworking import Foundation import PromiseKit +import SignalCoreKit @objc public enum LinkPreviewError: Int, Error { diff --git a/SessionMessagingKit/Sending & Receiving/Mentions/MentionsManager.swift b/SessionMessagingKit/Sending & Receiving/Mentions/MentionsManager.swift index 9463002b6..075913ca4 100644 --- a/SessionMessagingKit/Sending & Receiving/Mentions/MentionsManager.swift +++ b/SessionMessagingKit/Sending & Receiving/Mentions/MentionsManager.swift @@ -33,9 +33,9 @@ public final class MentionsManager : NSObject { let openGroupV2 = Storage.shared.getV2OpenGroup(for: threadID) storage.dbReadConnection.read { transaction in candidates = cache.compactMap { publicKey in - let context: Contact.Context = (openGroupV2 != nil) ? .openGroup : .regular - let displayNameOrNil = Storage.shared.getContact(with: publicKey)?.displayName(for: context) - guard let displayName = displayNameOrNil else { return nil } + guard let displayName: String = Profile.displayNameNoFallback(for: publicKey, context: (openGroupV2 != nil ? .openGroup : .regular)) else { + return nil + } guard !displayName.hasPrefix("Anonymous") else { return nil } return Mention(publicKey: publicKey, displayName: displayName) } diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift index 0e84e01e2..b49bb38fb 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift @@ -8,9 +8,9 @@ import SessionUtilitiesKit extension MessageReceiver { - internal static func decryptWithSessionProtocol(ciphertext: Data, using x25519KeyPair: ECKeyPair) throws -> (plaintext: Data, senderX25519PublicKey: String) { - let recipientX25519PrivateKey = x25519KeyPair.privateKey - let recipientX25519PublicKey = Data(hex: x25519KeyPair.hexEncodedPublicKey.removing05PrefixIfNeeded()) + internal static func decryptWithSessionProtocol(ciphertext: Data, using x25519KeyPair: Box.KeyPair) throws -> (plaintext: Data, senderX25519PublicKey: String) { + let recipientX25519PrivateKey = x25519KeyPair.secretKey + let recipientX25519PublicKey = x25519KeyPair.publicKey let sodium = Sodium() let signatureSize = sodium.sign.Bytes let ed25519PublicKeySize = sodium.sign.PublicKeyBytes diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index 719400d6e..ddc23d903 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -1,23 +1,25 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB +import Sodium import Curve25519Kit import SignalCoreKit import SessionSnodeKit extension MessageReceiver { - public static func handle(_ message: Message, associatedWithProto proto: SNProtoContent, openGroupID: String?, isBackgroundPoll: Bool, using transaction: Any) throws { + public static func handle(_ db: Database, _ message: Message, associatedWithProto proto: SNProtoContent, openGroupID: String?, isBackgroundPoll: Bool, using transaction: Any) throws { switch message { case let message as ReadReceipt: handleReadReceipt(message, using: transaction) case let message as TypingIndicator: handleTypingIndicator(message, using: transaction) - case let message as ClosedGroupControlMessage: handleClosedGroupControlMessage(message, using: transaction) + case let message as ClosedGroupControlMessage: handleClosedGroupControlMessage(db, message, using: transaction) case let message as DataExtractionNotification: handleDataExtractionNotification(message, using: transaction) case let message as ExpirationTimerUpdate: handleExpirationTimerUpdate(message, using: transaction) - case let message as ConfigurationMessage: handleConfigurationMessage(message, using: transaction) + case let message as ConfigurationMessage: handleConfigurationMessage(db, message, using: transaction) case let message as UnsendRequest: handleUnsendRequest(message, using: transaction) - case let message as MessageRequestResponse: handleMessageRequestResponse(message, using: transaction) - case let message as VisibleMessage: try handleVisibleMessage(message, associatedWithProto: proto, openGroupID: openGroupID, isBackgroundPoll: isBackgroundPoll, using: transaction) + case let message as MessageRequestResponse: handleMessageRequestResponse(db, message, using: transaction) + case let message as VisibleMessage: try handleVisibleMessage(db, message, associatedWithProto: proto, openGroupID: openGroupID, isBackgroundPoll: isBackgroundPoll, using: transaction) default: fatalError() } @@ -145,11 +147,11 @@ extension MessageReceiver { threadOrNil = TSContactThread.getWithContactSessionID(syncTarget ?? senderPublicKey, transaction: transaction) } guard let thread = threadOrNil else { return } - let configuration = OWSDisappearingMessagesConfiguration(threadId: thread.uniqueId!, enabled: true, durationSeconds: duration) + let configuration = Legacy.DisappearingMessagesConfiguration(threadId: thread.uniqueId!, enabled: true, durationSeconds: duration) configuration.save(with: transaction) var senderDisplayName: String? = nil if senderPublicKey != getUserHexEncodedPublicKey() { - senderDisplayName = Storage.shared.getContact(with: senderPublicKey)?.displayName(for: .regular) ?? senderPublicKey + senderDisplayName = Profile.displayName(for: senderPublicKey) } let message = OWSDisappearingConfigurationUpdateInfoMessage(timestamp: messageSentTimestamp, thread: thread, configuration: configuration, createdByRemoteName: senderDisplayName, createdInExistingGroup: false) @@ -168,11 +170,11 @@ extension MessageReceiver { threadOrNil = TSContactThread.getWithContactSessionID(syncTarget ?? senderPublicKey, transaction: transaction) } guard let thread = threadOrNil else { return } - let configuration = OWSDisappearingMessagesConfiguration(threadId: thread.uniqueId!, enabled: false, durationSeconds: 24 * 60 * 60) + let configuration = Legacy.DisappearingMessagesConfiguration(threadId: thread.uniqueId!, enabled: false, durationSeconds: 24 * 60 * 60) configuration.save(with: transaction) var senderDisplayName: String? = nil if senderPublicKey != getUserHexEncodedPublicKey() { - senderDisplayName = Storage.shared.getContact(with: senderPublicKey)?.displayName(for: .regular) ?? senderPublicKey + senderDisplayName = Profile.displayName(for: senderPublicKey) } let message = OWSDisappearingConfigurationUpdateInfoMessage(timestamp: messageSentTimestamp, thread: thread, configuration: configuration, createdByRemoteName: senderDisplayName, createdInExistingGroup: false) @@ -184,7 +186,7 @@ extension MessageReceiver { // MARK: - Configuration Messages - private static func handleConfigurationMessage(_ message: ConfigurationMessage, using transaction: Any) { + private static func handleConfigurationMessage(_ db: Database, _ message: ConfigurationMessage, using transaction: Any) { let userPublicKey = getUserHexEncodedPublicKey() guard message.sender == userPublicKey else { return } SNLog("Configuration message received.") @@ -195,10 +197,14 @@ extension MessageReceiver { let lastConfigTimestamp: TimeInterval = (UserDefaults.standard[.lastConfigurationSync]?.timeIntervalSince1970 ?? Date(timeIntervalSince1970: 0).timeIntervalSince1970) // Profile - var userProfileKey: OWSAES256Key? = nil - if let profileKey = message.profileKey { userProfileKey = OWSAES256Key(data: profileKey) } - updateProfileIfNeeded(publicKey: userPublicKey, name: message.displayName, profilePictureURL: message.profilePictureURL, - profileKey: userProfileKey, sentTimestamp: message.sentTimestamp!, transaction: transaction) + updateProfileIfNeeded( + publicKey: userPublicKey, + name: message.displayName, + profilePictureURL: message.profilePictureURL, + profileKey: OWSAES256Key(data: message.profileKey), + sentTimestamp: message.sentTimestamp!, + transaction: transaction + ) if isInitialSync || messageSentTimestamp > lastConfigTimestamp { if isInitialSync { @@ -211,11 +217,18 @@ extension MessageReceiver { // Contacts for contactInfo in message.contacts { let sessionID = contactInfo.publicKey! - let contact = (Storage.shared.getContact(with: sessionID, using: transaction) ?? Contact(sessionID: sessionID)) - let contactWasBlocked: Bool = contact.isBlocked - if let profileKey = contactInfo.profileKey { contact.profileEncryptionKey = OWSAES256Key(data: profileKey) } - contact.profilePictureURL = contactInfo.profilePictureURL - contact.name = contactInfo.displayName + let contact: Contact = Contact.fetchOrCreate(db, id: sessionID) + let profile: Profile = Profile.fetchOrCreate(db, id: sessionID) + + try? profile + .with( + name: contactInfo.displayName, + profilePictureUrl: .updateIf(contactInfo.profilePictureURL), + profileEncryptionKey: .updateIf( + contactInfo.profileKey.map { OWSAES256Key(data: $0) } + ) + ) + .save(db) // Note: We only update these values if the proto actually has values for them (this is to // prevent an edge case where an old client could override the values with default values @@ -225,12 +238,22 @@ extension MessageReceiver { // config message setting *isApproved* and *didApproveMe* to true. This may prevent some // weird edge cases where a config message swapping *isApproved* and *didApproveMe* to // false. - if contactInfo.hasIsApproved && contactInfo.isApproved { contact.isApproved = true } - if contactInfo.hasDidApproveMe && contactInfo.didApproveMe { contact.didApproveMe = true } - - if contactInfo.hasIsBlocked { contact.isBlocked = contactInfo.isBlocked } - - Storage.shared.setContact(contact, using: transaction) + try? contact + .with( + isApproved: (contactInfo.hasIsApproved && contactInfo.isApproved ? + .existing : + true + ), + isBlocked: (contactInfo.hasIsBlocked && contactInfo.isBlocked ? + .existing : + true + ), + didApproveMe: (contactInfo.hasDidApproveMe && contactInfo.didApproveMe ? + .existing : + true + ) + ) + .save(db) // If the contact is blocked if contactInfo.hasIsBlocked && contactInfo.isBlocked { @@ -238,7 +261,7 @@ extension MessageReceiver { // associated with them that is a message request thread then delete it (assume // that the current user had deleted that message request) if - contactInfo.isBlocked != contactWasBlocked, + contactInfo.isBlocked != contact.isBlocked, let thread: TSContactThread = TSContactThread.getWithContactSessionID(sessionID, transaction: transaction), thread.isMessageRequest(using: transaction) { @@ -258,7 +281,11 @@ extension MessageReceiver { let allClosedGroupPublicKeys = storage.getUserClosedGroupPublicKeys() for closedGroup in message.closedGroups { guard !allClosedGroupPublicKeys.contains(closedGroup.publicKey) else { continue } - handleNewClosedGroup(groupPublicKey: closedGroup.publicKey, name: closedGroup.name, encryptionKeyPair: closedGroup.encryptionKeyPair, + let keyPair: Box.KeyPair = Box.KeyPair( + publicKey: closedGroup.encryptionKeyPair.publicKey.bytes, + secretKey: closedGroup.encryptionKeyPair.privateKey.bytes + ) + handleNewClosedGroup(db, groupPublicKey: closedGroup.publicKey, name: closedGroup.name, encryptionKeyPair: keyPair, members: [String](closedGroup.members), admins: [String](closedGroup.admins), expirationTimer: closedGroup.expirationTimer, messageSentTimestamp: message.sentTimestamp!, using: transaction) } @@ -313,7 +340,8 @@ extension MessageReceiver { // MARK: - Visible Messages @discardableResult - public static func handleVisibleMessage(_ message: VisibleMessage, associatedWithProto proto: SNProtoContent, openGroupID: String?, isBackgroundPoll: Bool, using transaction: Any) throws -> String { + public static func handleVisibleMessage(_ db: Database, _ message: VisibleMessage, associatedWithProto proto: SNProtoContent, openGroupID: String?, isBackgroundPoll: Bool, using transaction: Any) throws -> String { + let sender: String = message.sender! let storage = SNMessagingKitConfiguration.shared.storage let transaction = transaction as! YapDatabaseReadWriteTransaction var isMainAppAndActive = false @@ -337,7 +365,7 @@ extension MessageReceiver { profileKey: contactProfileKey, sentTimestamp: message.sentTimestamp!, transaction: transaction) } // Get or create thread - guard let threadID = storage.getOrCreateThread(for: message.syncTarget ?? message.sender!, groupPublicKey: message.groupPublicKey, openGroupID: openGroupID, using: transaction) else { throw Error.noThread } + guard let threadID = storage.getOrCreateThread(for: message.syncTarget ?? sender, groupPublicKey: message.groupPublicKey, openGroupID: openGroupID, using: transaction) else { throw Error.noThread } // Parse quote if needed var tsQuotedMessage: TSQuotedMessage? = nil if message.quote != nil && proto.dataMessage?.quote != nil, let thread = TSThread.fetch(uniqueId: threadID, transaction: transaction) { @@ -359,7 +387,9 @@ extension MessageReceiver { groupPublicKey: message.groupPublicKey, openGroupID: openGroupID, using: transaction) else { throw Error.duplicateMessage } message.threadID = threadID // Start attachment downloads if needed - let isContactTrusted = Storage.shared.getContact(with: message.sender!)?.isTrusted ?? false + // TODO: Swap this back + let isContactTrusted: Bool = (GRDBStorage.shared.read({ db in try Contact.fetchOne(db, id: sender) })?.isTrusted ?? false) +// let isContactTrusted: Bool = ((try? Contact.fetchOne(db, id: sender))?.isTrusted ?? false) let isGroup = message.groupPublicKey != nil || openGroupID != nil attachmentsToDownload.forEach { attachmentID in let downloadJob = AttachmentDownloadJob(attachmentID: attachmentID, tsMessageID: tsMessageID, threadID: threadID) @@ -403,10 +433,10 @@ extension MessageReceiver { // by using the approval process if !isGroup, let senderSessionId: String = message.sender { updateContactApprovalStatusIfNeeded( + db, senderSessionId: senderSessionId, threadId: message.threadID, - forceConfigSync: false, - using: transaction + forceConfigSync: false ) } @@ -427,9 +457,10 @@ extension MessageReceiver { profileKey: OWSAES256Key?, sentTimestamp: UInt64, transaction: YapDatabaseReadWriteTransaction) { let isCurrentUser = (publicKey == getUserHexEncodedPublicKey()) let userDefaults = UserDefaults.standard - let contact = Storage.shared.getContact(with: publicKey) ?? Contact(sessionID: publicKey) // New API + var profile: Profile = Profile.fetchOrCreate(id: publicKey) + // Name - if let name = name, name != contact.name { + if let name = name, name != profile.name { let shouldUpdate: Bool if isCurrentUser { shouldUpdate = given(userDefaults[.lastDisplayNameUpdate]) { sentTimestamp > UInt64($0.timeIntervalSince1970 * 1000) } ?? true @@ -440,40 +471,54 @@ extension MessageReceiver { if isCurrentUser { userDefaults[.lastDisplayNameUpdate] = Date(timeIntervalSince1970: TimeInterval(sentTimestamp / 1000)) } - contact.name = name + + profile = profile.with(name: name) } } + // Profile picture & profile key - if let profileKey = profileKey, let profilePictureURL = profilePictureURL, - profileKey.keyData.count == kAES256_KeyByteLength, profileKey != contact.profileEncryptionKey { + if + let profileKey = profileKey, + let profilePictureURL = profilePictureURL, + profileKey.keyData.count == kAES256_KeyByteLength, + profileKey != profile.profileEncryptionKey + { let shouldUpdate: Bool if isCurrentUser { shouldUpdate = given(userDefaults[.lastProfilePictureUpdate]) { sentTimestamp > UInt64($0.timeIntervalSince1970 * 1000) } ?? true } else { shouldUpdate = true } + if shouldUpdate { if isCurrentUser { userDefaults[.lastProfilePictureUpdate] = Date(timeIntervalSince1970: TimeInterval(sentTimestamp / 1000)) } - contact.profilePictureURL = profilePictureURL - contact.profileEncryptionKey = profileKey + + profile = profile.with( + profilePictureUrl: .update(profilePictureURL), + profileEncryptionKey: .update(profileKey) + ) } } + // Persist changes - Storage.shared.setContact(contact, using: transaction) + GRDBStorage.shared.write { db in + try profile.save(db) + } + // Download the profile picture if needed transaction.addCompletionQueue(DispatchQueue.main) { - SSKEnvironment.shared.profileManager.downloadAvatar(forUserProfile: contact) + ProfileManager.downloadAvatar(for: profile) } } // MARK: - Closed Groups - public static func handleClosedGroupControlMessage(_ message: ClosedGroupControlMessage, using transaction: Any) { + public static func handleClosedGroupControlMessage(_ db: Database, _ message: ClosedGroupControlMessage, using transaction: Any) { switch message.kind! { - case .new: handleNewClosedGroup(message, using: transaction) + case .new: handleNewClosedGroup(db, message, using: transaction) case .encryptionKeyPair: handleClosedGroupEncryptionKeyPair(message, using: transaction) case .nameChange: handleClosedGroupNameChanged(message, using: transaction) case .membersAdded: handleClosedGroupMembersAdded(message, using: transaction) @@ -483,16 +528,16 @@ extension MessageReceiver { } } - private static func handleNewClosedGroup(_ message: ClosedGroupControlMessage, using transaction: Any) { + private static func handleNewClosedGroup(_ db: Database, _ message: ClosedGroupControlMessage, using transaction: Any) { guard case let .new(publicKeyAsData, name, encryptionKeyPair, membersAsData, adminsAsData, expirationTimer) = message.kind else { return } let groupPublicKey = publicKeyAsData.toHexString() let members = membersAsData.map { $0.toHexString() } let admins = adminsAsData.map { $0.toHexString() } - handleNewClosedGroup(groupPublicKey: groupPublicKey, name: name, encryptionKeyPair: encryptionKeyPair, + handleNewClosedGroup(db, groupPublicKey: groupPublicKey, name: name, encryptionKeyPair: encryptionKeyPair, members: members, admins: admins, expirationTimer: expirationTimer, messageSentTimestamp: message.sentTimestamp!, using: transaction) } - private static func handleNewClosedGroup(groupPublicKey: String, name: String, encryptionKeyPair: ECKeyPair, members: [String], admins: [String], expirationTimer: UInt32, messageSentTimestamp: UInt64, using transaction: Any) { + private static func handleNewClosedGroup(_ db: Database, groupPublicKey: String, name: String, encryptionKeyPair: Box.KeyPair, members: [String], admins: [String], expirationTimer: UInt32, messageSentTimestamp: UInt64, using transaction: Any) { let transaction = transaction as! YapDatabaseReadWriteTransaction // With new closed groups we only want to create them if the admin creating the closed group is an @@ -501,7 +546,7 @@ extension MessageReceiver { var hasApprovedAdmin: Bool = false for adminId in admins { - if let contact: Contact = Storage.shared.getContact(with: adminId), contact.isApproved { + if let contact: Contact = try? Contact.fetchOne(db, id: adminId), contact.isApproved { hasApprovedAdmin = true break } @@ -533,7 +578,7 @@ extension MessageReceiver { let isExpirationTimerEnabled = (expirationTimer > 0) let expirationTimerDuration = (isExpirationTimerEnabled ? expirationTimer : 24 * 60 * 60) - let configuration = OWSDisappearingMessagesConfiguration( + let configuration = Legacy.DisappearingMessagesConfiguration( threadId: thread.uniqueId!, enabled: isExpirationTimerEnabled, durationSeconds: expirationTimerDuration @@ -560,7 +605,7 @@ extension MessageReceiver { let groupPublicKey = explicitGroupPublicKey?.toHexString() ?? message.groupPublicKey else { return } let transaction = transaction as! YapDatabaseReadWriteTransaction let userPublicKey = getUserHexEncodedPublicKey() - guard let userKeyPair = Identity.fetchUserKeyPair() else { + guard let userKeyPair: Box.KeyPair = Identity.fetchUserKeyPair() else { return SNLog("Couldn't find user X25519 key pair.") } let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey) @@ -586,12 +631,10 @@ extension MessageReceiver { } catch { return SNLog("Couldn't parse closed group encryption key pair.") } - let keyPair: ECKeyPair - do { - keyPair = try ECKeyPair(publicKeyData: proto.publicKey.removing05PrefixIfNeeded(), privateKeyData: proto.privateKey) - } catch { - return SNLog("Couldn't parse closed group encryption key pair.") - } + let keyPair: Box.KeyPair = Box.KeyPair( + publicKey: proto.publicKey.removing05PrefixIfNeeded().bytes, + secretKey: proto.privateKey.bytes + ) // Store it if needed let closedGroupEncryptionKeyPairs = Storage.shared.getClosedGroupEncryptionKeyPairs(for: groupPublicKey) guard !closedGroupEncryptionKeyPairs.contains(keyPair) else { @@ -711,6 +754,8 @@ extension MessageReceiver { /// • Unsubscribe from PNs, delete the group public key, etc. as the group will be disbanded. private static func handleClosedGroupMemberLeft(_ message: ClosedGroupControlMessage, using transaction: Any) { guard case .memberLeft = message.kind else { return } + + let sender: String = message.sender! let transaction = transaction as! YapDatabaseReadWriteTransaction guard let groupPublicKey = message.groupPublicKey else { return } performIfValid(for: message, using: transaction) { groupID, thread, group in @@ -731,13 +776,16 @@ extension MessageReceiver { thread.setGroupModel(newGroupModel, with: transaction) // Notify the user if needed guard members != Set(group.groupMemberIds) else { return } - let contact = Storage.shared.getContact(with: message.sender!) + let updateInfo: String - if let displayName = contact?.displayName(for: Contact.Context.regular) { + + if let displayName = Profile.displayNameNoFallback(for: sender) { updateInfo = String(format: NSLocalizedString("GROUP_MEMBER_LEFT", comment: ""), displayName) - } else { + } + else { updateInfo = NSLocalizedString("GROUP_UPDATED", comment: "") } + let infoMessage = TSInfoMessage(timestamp: message.sentTimestamp!, in: thread, messageType: .groupUpdated, customMessage: updateInfo) infoMessage.save(with: transaction) } @@ -787,14 +835,12 @@ extension MessageReceiver { // MARK: - Message Requests private static func updateContactApprovalStatusIfNeeded( + _ db: Database, senderSessionId: String, threadId: String?, - forceConfigSync: Bool, - using transaction: Any + forceConfigSync: Bool ) { - guard let transaction: YapDatabaseReadWriteTransaction = transaction as? YapDatabaseReadWriteTransaction else { return } - - let userPublicKey: String = getUserHexEncodedPublicKey() + let userPublicKey: String = getUserHexEncodedPublicKey(db) // If the sender of the message was the current user if senderSessionId == userPublicKey { @@ -824,8 +870,8 @@ extension MessageReceiver { MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete() } - public static func handleMessageRequestResponse(_ message: MessageRequestResponse, using transaction: Any) { - let userPublicKey = getUserHexEncodedPublicKey() + public static func handleMessageRequestResponse(_ db: Database, _ message: MessageRequestResponse, using transaction: Any) { + let userPublicKey = getUserHexEncodedPublicKey(db) // Ignore messages which were sent from the current user guard message.sender != userPublicKey else { return } @@ -842,10 +888,10 @@ extension MessageReceiver { } updateContactApprovalStatusIfNeeded( + db, senderSessionId: senderId, threadId: nil, - forceConfigSync: true, - using: transaction + forceConfigSync: true ) } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index ef96124f0..4845bc945 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -1,6 +1,8 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB +import Sodium import SessionUtilitiesKit public enum MessageReceiver { @@ -51,7 +53,7 @@ public enum MessageReceiver { } } - public static func parse(_ data: Data, openGroupMessageServerID: UInt64?, isRetry: Bool = false, using transaction: Any) throws -> (Message, SNProtoContent) { + public static func parse(_ db: Database, _ data: Data, openGroupMessageServerID: UInt64?, isRetry: Bool = false, using transaction: Any) throws -> (Message, SNProtoContent) { let userPublicKey = getUserHexEncodedPublicKey() let isOpenGroupMessage = (openGroupMessageServerID != nil) // Parse the envelope @@ -67,7 +69,7 @@ public enum MessageReceiver { } else { switch envelope.type { case .sessionMessage: - guard let userX25519KeyPair = Identity.fetchUserKeyPair() else { throw Error.noUserX25519KeyPair } + guard let userX25519KeyPair: Box.KeyPair = Identity.fetchUserKeyPair() else { throw Error.noUserX25519KeyPair } (plaintext, sender) = try decryptWithSessionProtocol(ciphertext: ciphertext, using: userX25519KeyPair) case .closedGroupMessage: guard let hexEncodedGroupPublicKey = envelope.source, SNMessagingKitConfiguration.shared.storage.isClosedGroup(hexEncodedGroupPublicKey) else { throw Error.invalidGroupPublicKey } @@ -111,7 +113,8 @@ public enum MessageReceiver { } // Don't process the envelope any further if the sender is blocked - guard Storage.shared.getContact(with: sender, using: transaction)?.isBlocked != true else { + guard GRDBStorage.shared.read({ db in try Contact.fetchOne(db, id: sender) })?.isBlocked != true else { +// guard (try? Contact.fetchOne(db, id: sender))?.isBlocked != true else { throw Error.senderBlocked } @@ -125,15 +128,15 @@ public enum MessageReceiver { } // Parse the message let message: Message? = { - if let readReceipt = ReadReceipt.fromProto(proto) { return readReceipt } - if let typingIndicator = TypingIndicator.fromProto(proto) { return typingIndicator } - if let closedGroupControlMessage = ClosedGroupControlMessage.fromProto(proto) { return closedGroupControlMessage } - if let dataExtractionNotification = DataExtractionNotification.fromProto(proto) { return dataExtractionNotification } - if let expirationTimerUpdate = ExpirationTimerUpdate.fromProto(proto) { return expirationTimerUpdate } - if let configurationMessage = ConfigurationMessage.fromProto(proto) { return configurationMessage } - if let unsendRequest = UnsendRequest.fromProto(proto) { return unsendRequest } - if let messageRequestResponse = MessageRequestResponse.fromProto(proto) { return messageRequestResponse } - if let visibleMessage = VisibleMessage.fromProto(proto) { return visibleMessage } + if let readReceipt = ReadReceipt.fromProto(proto, sender: sender) { return readReceipt } + if let typingIndicator = TypingIndicator.fromProto(proto, sender: sender) { return typingIndicator } + if let closedGroupControlMessage = ClosedGroupControlMessage.fromProto(proto, sender: sender) { return closedGroupControlMessage } + if let dataExtractionNotification = DataExtractionNotification.fromProto(proto, sender: sender) { return dataExtractionNotification } + if let expirationTimerUpdate = ExpirationTimerUpdate.fromProto(proto, sender: sender) { return expirationTimerUpdate } + if let configurationMessage = ConfigurationMessage.fromProto(proto, sender: sender) { return configurationMessage } + if let unsendRequest = UnsendRequest.fromProto(proto, sender: sender) { return unsendRequest } + if let messageRequestResponse = MessageRequestResponse.fromProto(proto, sender: sender) { return messageRequestResponse } + if let visibleMessage = VisibleMessage.fromProto(proto, sender: sender) { return visibleMessage } return nil }() if let message = message { diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift index 7f335b4ef..0ec1f0e81 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift @@ -1,11 +1,12 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Sodium import Curve25519Kit import PromiseKit extension MessageSender { - public static var distributingClosedGroupEncryptionKeyPairs: [String: [ECKeyPair]] = [:] + public static var distributingClosedGroupEncryptionKeyPairs: [String: [Box.KeyPair]] = [:] public static func createClosedGroup(name: String, members: Set, transaction: YapDatabaseReadWriteTransaction) -> Promise { // Prepare @@ -30,8 +31,12 @@ extension MessageSender { for member in members { let thread = TSContactThread.getOrCreateThread(withContactSessionID: member, transaction: transaction) thread.save(with: transaction) + let keyPair: Box.KeyPair = Box.KeyPair( + publicKey: encryptionKeyPair.publicKey.bytes, + secretKey: encryptionKeyPair.privateKey.bytes + ) let closedGroupControlMessageKind = ClosedGroupControlMessage.Kind.new(publicKey: Data(hex: groupPublicKey), name: name, - encryptionKeyPair: encryptionKeyPair, members: membersAsData, admins: adminsAsData, expirationTimer: 0) + encryptionKeyPair: keyPair, members: membersAsData, admins: adminsAsData, expirationTimer: 0) let closedGroupControlMessage = ClosedGroupControlMessage(kind: closedGroupControlMessageKind) // Sending this non-durably is okay because we show a loader to the user. If they close the app while the // loader is still showing, it's within expectation that the group creation might be incomplete. @@ -41,7 +46,11 @@ extension MessageSender { // Add the group to the user's set of public keys to poll for Storage.shared.addClosedGroupPublicKey(groupPublicKey, using: transaction) // Store the key pair - Storage.shared.addClosedGroupEncryptionKeyPair(encryptionKeyPair, for: groupPublicKey, using: transaction) + let keyPair: Box.KeyPair = Box.KeyPair( + publicKey: encryptionKeyPair.publicKey.bytes, + secretKey: encryptionKeyPair.privateKey.bytes + ) + Storage.shared.addClosedGroupEncryptionKeyPair(keyPair, for: groupPublicKey, using: transaction) // Notify the PN server promises.append(PushNotificationAPI.performOperation(.subscribe, for: groupPublicKey, publicKey: userPublicKey)) // Notify the user @@ -72,10 +81,14 @@ extension MessageSender { return Promise(error: Error.invalidClosedGroupUpdate) } // Generate the new encryption key pair - let newKeyPair = Curve25519.generateKeyPair() + let newLegacyKeyPair = Curve25519.generateKeyPair() + let newKeyPair: Box.KeyPair = Box.KeyPair( + publicKey: newLegacyKeyPair.publicKey.bytes, + secretKey: newLegacyKeyPair.privateKey.bytes + ) // Distribute it - let proto = try! SNProtoKeyPair.builder(publicKey: newKeyPair.publicKey, - privateKey: newKeyPair.privateKey).build() + let proto = try! SNProtoKeyPair.builder(publicKey: Data(newKeyPair.publicKey), + privateKey: Data(newKeyPair.secretKey)).build() let plaintext = try! proto.serializedData() let wrappers = targetMembers.compactMap { publicKey -> ClosedGroupControlMessage.KeyPairWrapper in let ciphertext = try! MessageSender.encryptWithSessionProtocol(plaintext, for: publicKey) @@ -329,8 +342,8 @@ extension MessageSender { guard let encryptionKeyPair = distributingClosedGroupEncryptionKeyPairs[groupPublicKey]?.last ?? Storage.shared.getLatestClosedGroupEncryptionKeyPair(for: groupPublicKey) else { return } // Send it - guard let proto = try? SNProtoKeyPair.builder(publicKey: encryptionKeyPair.publicKey, - privateKey: encryptionKeyPair.privateKey).build(), let plaintext = try? proto.serializedData() else { return } + guard let proto = try? SNProtoKeyPair.builder(publicKey: Data(encryptionKeyPair.publicKey), + privateKey: Data(encryptionKeyPair.secretKey)).build(), let plaintext = try? proto.serializedData() else { return } let contactThread = TSContactThread.getOrCreateThread(withContactSessionID: publicKey, transaction: transaction) guard let ciphertext = try? MessageSender.encryptWithSessionProtocol(plaintext, for: publicKey) else { return } SNLog("Sending latest encryption key pair to: \(publicKey).") diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index f111b632f..73e35c53e 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -171,7 +171,7 @@ public final class MessageSender : NSObject { case .contact(let publicKey): ciphertext = try encryptWithSessionProtocol(plaintext, for: publicKey) case .closedGroup(let groupPublicKey): guard let encryptionKeyPair = Storage.shared.getLatestClosedGroupEncryptionKeyPair(for: groupPublicKey) else { throw Error.noKeyPair } - ciphertext = try encryptWithSessionProtocol(plaintext, for: encryptionKeyPair.hexEncodedPublicKey) + ciphertext = try encryptWithSessionProtocol(plaintext, for: "05\(encryptionKeyPair.publicKey.toHexString())") case .openGroup(_, _), .openGroupV2(_, _): preconditionFailure() } } catch { diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift index 930361510..38ba3d31d 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift @@ -65,20 +65,22 @@ public final class OpenGroupPollerV2 : NSObject { // Sorting the messages by server ID before importing them fixes an issue where messages that quote older messages can't find those older messages let openGroupID = "\(server).\(body.room)" let messages = body.messages.sorted { $0.serverID! < $1.serverID! } // Safe because messages with a nil serverID are filtered out - storage.write { transaction in - messages.forEach { message in - guard let data = Data(base64Encoded: message.base64EncodedData) else { - return SNLog("Ignoring open group message with invalid encoding.") - } - let envelope = SNProtoEnvelope.builder(type: .sessionMessage, timestamp: message.sentTimestamp) - envelope.setContent(data) - envelope.setSource(message.sender!) // Safe because messages with a nil sender are filtered out - do { - let data = try envelope.buildSerializedData() - let (message, proto) = try MessageReceiver.parse(data, openGroupMessageServerID: UInt64(message.serverID!), isRetry: false, using: transaction) - try MessageReceiver.handle(message, associatedWithProto: proto, openGroupID: openGroupID, isBackgroundPoll: isBackgroundPoll, using: transaction) - } catch { - SNLog("Couldn't receive open group message due to error: \(error).") + GRDBStorage.shared.write { db in + storage.write { transaction in + messages.forEach { message in + guard let data = Data(base64Encoded: message.base64EncodedData) else { + return SNLog("Ignoring open group message with invalid encoding.") + } + let envelope = SNProtoEnvelope.builder(type: .sessionMessage, timestamp: message.sentTimestamp) + envelope.setContent(data) + envelope.setSource(message.sender!) // Safe because messages with a nil sender are filtered out + do { + let data = try envelope.buildSerializedData() + let (message, proto) = try MessageReceiver.parse(db, data, openGroupMessageServerID: UInt64(message.serverID!), isRetry: false, using: transaction) + try MessageReceiver.handle(db, message, associatedWithProto: proto, openGroupID: openGroupID, isBackgroundPoll: isBackgroundPoll, using: transaction) + } catch { + SNLog("Couldn't receive open group message due to error: \(error).") + } } } } diff --git a/SessionMessagingKit/Storage.swift b/SessionMessagingKit/Storage.swift index 9ea349b79..bb2875bad 100644 --- a/SessionMessagingKit/Storage.swift +++ b/SessionMessagingKit/Storage.swift @@ -12,13 +12,6 @@ public protocol SessionMessagingKitStorageProtocol { func write(with block: @escaping (Any) -> Void, completion: @escaping () -> Void) -> Promise func writeSync(with block: @escaping (Any) -> Void) - // MARK: - General - - func getUser() -> Contact? - func getUser(using transaction: YapDatabaseReadTransaction?) -> Contact? - func getAllContacts() -> Set - func getAllContacts(with transaction: YapDatabaseReadTransaction) -> Set - // MARK: - Closed Groups func getUserClosedGroupPublicKeys() -> Set @@ -95,4 +88,4 @@ public protocol SessionMessagingKitStorageProtocol { func persist(_ stream: TSAttachmentStream, associatedWith tsIncomingMessageID: String, using transaction: Any) } -extension Storage: SessionMessagingKitStorageProtocol, SessionSnodeKitStorageProtocol {} +extension Storage: SessionMessagingKitStorageProtocol {} diff --git a/SessionMessagingKit/Threads/TSContactThread.m b/SessionMessagingKit/Threads/TSContactThread.m index dba2333dd..4eb4ead40 100644 --- a/SessionMessagingKit/Threads/TSContactThread.m +++ b/SessionMessagingKit/Threads/TSContactThread.m @@ -59,7 +59,7 @@ NSString *const TSContactThreadPrefix = @"c"; - (BOOL)isMessageRequest { NSString *sessionID = self.contactSessionID; - SNContact *contact = [LKStorage.shared getContactWithSessionID:sessionID]; + SMKContact *contact = [SMKContact fetchOrCreateWithId: sessionID]; return ( self.shouldBeVisible && @@ -72,7 +72,7 @@ NSString *const TSContactThreadPrefix = @"c"; - (BOOL)isMessageRequestUsingTransaction:(YapDatabaseReadTransaction *)transaction { NSString *sessionID = self.contactSessionID; - SNContact *contact = [LKStorage.shared getContactWithSessionID:sessionID using:transaction]; + SMKContact *contact = [SMKContact fetchOrCreateWithId: sessionID]; return ( self.shouldBeVisible && @@ -85,14 +85,14 @@ NSString *const TSContactThreadPrefix = @"c"; - (BOOL)isBlocked { NSString *sessionID = self.contactSessionID; - SNContact *contact = [LKStorage.shared getContactWithSessionID:sessionID]; + SMKContact *contact = [SMKContact fetchOrCreateWithId: sessionID]; return (contact.isBlocked == YES); } - (BOOL)isBlockedUsingTransaction:(YapDatabaseReadTransaction *)transaction { NSString *sessionID = self.contactSessionID; - SNContact *contact = [LKStorage.shared getContactWithSessionID:sessionID using:transaction]; + SMKContact *contact = [SMKContact fetchOrCreateWithId: sessionID]; return (contact.isBlocked == YES); } @@ -105,15 +105,13 @@ NSString *const TSContactThreadPrefix = @"c"; - (NSString *)name { NSString *sessionID = self.contactSessionID; - SNContact *contact = [LKStorage.shared getContactWithSessionID:sessionID]; - return [contact displayNameFor:SNContactContextRegular] ?: sessionID; + return [SMKProfile displayNameWithId:sessionID]; } - (NSString *)nameWithTransaction:(YapDatabaseReadTransaction *)transaction { NSString *sessionID = self.contactSessionID; - SNContact *contact = [LKStorage.shared getContactWithSessionID:sessionID using:transaction]; - return [contact displayNameFor:SNContactContextRegular] ?: sessionID; + return [SMKProfile displayNameWithId:sessionID]; } + (NSString *)threadIDFromContactSessionID:(NSString *)contactSessionID { diff --git a/SessionMessagingKit/Threads/TSGroupModel.m b/SessionMessagingKit/Threads/TSGroupModel.m index 2ad8f00bf..8af3d8b52 100644 --- a/SessionMessagingKit/Threads/TSGroupModel.m +++ b/SessionMessagingKit/Threads/TSGroupModel.m @@ -123,8 +123,7 @@ const int32_t kGroupIdLength = 16; if (removedMembersMinusSelf.count > 0) { NSArray *removedMemberNames = [removedMembers.allObjects map:^NSString *(NSString *publicKey) { - SNContact *contact = [LKStorage.shared getContactWithSessionID:publicKey]; - return [contact displayNameFor:SNContactContextRegular] ?: publicKey; + return [SMKProfile displayNameWithId:publicKey]; }]; NSString *format = removedMembers.count > 1 ? NSLocalizedString(@"GROUP_MEMBERS_REMOVED", @"") : NSLocalizedString(@"GROUP_MEMBER_REMOVED", @""); updatedGroupInfoString = [updatedGroupInfoString @@ -135,8 +134,7 @@ const int32_t kGroupIdLength = 16; if (addedMembers.count > 0) { NSArray *addedMemberNames = [[addedMembers allObjects] map:^NSString*(NSString* publicKey) { - SNContact *contact = [LKStorage.shared getContactWithSessionID:publicKey]; - return [contact displayNameFor:SNContactContextRegular] ?: publicKey; + return [SMKProfile displayNameWithId:publicKey]; }]; updatedGroupInfoString = [updatedGroupInfoString stringByAppendingString:[NSString diff --git a/SessionMessagingKit/Threads/TSThread.h b/SessionMessagingKit/Threads/TSThread.h index b6629794e..7968ca7ad 100644 --- a/SessionMessagingKit/Threads/TSThread.h +++ b/SessionMessagingKit/Threads/TSThread.h @@ -22,6 +22,7 @@ BOOL IsNoteToSelfEnabled(void); @property (nonatomic, readonly, nullable) NSDate *lastInteractionDate; @property (nonatomic, readonly) TSInteraction *lastInteraction; @property (atomic, readonly) BOOL isMuted; +@property (nonatomic, copy, nullable) NSString *messageDraft; @property (atomic, readonly, nullable) NSDate *mutedUntilDate; /** diff --git a/SessionMessagingKit/Threads/TSThread.m b/SessionMessagingKit/Threads/TSThread.m index 889dde49a..5b178af25 100644 --- a/SessionMessagingKit/Threads/TSThread.m +++ b/SessionMessagingKit/Threads/TSThread.m @@ -23,7 +23,6 @@ BOOL IsNoteToSelfEnabled(void) @property (nonatomic) NSDate *creationDate; @property (nonatomic, nullable) NSDate *lastInteractionDate; @property (nonatomic, nullable) NSNumber *archivedAsOfMessageSortId; -@property (nonatomic, copy, nullable) NSString *messageDraft; @property (atomic, nullable) NSDate *mutedUntilDate; @end diff --git a/SessionMessagingKit/To Do/OWSUserProfile.h b/SessionMessagingKit/To Do/OWSUserProfile.h index b45356ac5..ddc23c062 100644 --- a/SessionMessagingKit/To Do/OWSUserProfile.h +++ b/SessionMessagingKit/To Do/OWSUserProfile.h @@ -6,9 +6,9 @@ NS_ASSUME_NONNULL_BEGIN -extern NSString *const kNSNotificationName_LocalProfileDidChange; -extern NSString *const kNSNotificationName_OtherUsersProfileDidChange; -extern NSString *const kNSNotificationKey_ProfileRecipientId; +//extern NSString *const kNSNotificationName_LocalProfileDidChange; +//extern NSString *const kNSNotificationName_OtherUsersProfileDidChange; +//extern NSString *const kNSNotificationKey_ProfileRecipientId; @interface OWSUserProfile : TSYapDatabaseObject @@ -18,7 +18,7 @@ extern NSString *const kNSNotificationKey_ProfileRecipientId; + (NSString *)sharedDataProfileAvatarsDirPath; + (NSString *)profileAvatarsDirPath; + (void)resetProfileStorage; -+ (NSSet *)allProfileAvatarFilePaths; +//+ (NSSet *)allProfileAvatarFilePaths; @end diff --git a/SessionMessagingKit/To Do/OWSUserProfile.m b/SessionMessagingKit/To Do/OWSUserProfile.m index b01b70503..80088ffe1 100644 --- a/SessionMessagingKit/To Do/OWSUserProfile.m +++ b/SessionMessagingKit/To Do/OWSUserProfile.m @@ -18,10 +18,6 @@ NS_ASSUME_NONNULL_BEGIN -NSString *const kNSNotificationName_LocalProfileDidChange = @"kNSNotificationName_LocalProfileDidChange"; -NSString *const kNSNotificationName_OtherUsersProfileDidChange = @"kNSNotificationName_OtherUsersProfileDidChange"; -NSString *const kNSNotificationKey_ProfileRecipientId = @"kNSNotificationKey_ProfileRecipientId"; - @interface OWSUserProfile () @end @@ -69,21 +65,21 @@ NSString *const kNSNotificationKey_ProfileRecipientId = @"kNSNotificationKey_Pro [[NSFileManager defaultManager] removeItemAtPath:[self profileAvatarsDirPath] error:&error]; } -+ (NSSet *)allProfileAvatarFilePaths -{ - NSString *profileAvatarsDirPath = self.profileAvatarsDirPath; - NSMutableSet *profileAvatarFilePaths = [NSMutableSet new]; - - NSSet *allContacts = [LKStorage.shared getAllContacts]; - - for (SNContact *contact in allContacts) { - if (contact.profilePictureFileName == nil) { continue; } - NSString *filePath = [profileAvatarsDirPath stringByAppendingPathComponent:contact.profilePictureFileName]; - [profileAvatarFilePaths addObject:filePath]; - } - - return [profileAvatarFilePaths copy]; -} +//+ (NSSet *)allProfileAvatarFilePaths +//{ +// NSString *profileAvatarsDirPath = self.profileAvatarsDirPath; +// NSMutableSet *profileAvatarFilePaths = [NSMutableSet new]; +// +// NSSet *allContacts = [LKStorage.shared getAllContacts]; +// +// for (SNContact *contact in allContacts) { +// if (contact.profilePictureFileName == nil) { continue; } +// NSString *filePath = [profileAvatarsDirPath stringByAppendingPathComponent:contact.profilePictureFileName]; +// [profileAvatarFilePaths addObject:filePath]; +// } +// +// return [profileAvatarFilePaths copy]; +//} @end diff --git a/SessionMessagingKit/To Do/ProfileManagerProtocol.h b/SessionMessagingKit/To Do/ProfileManagerProtocol.h index 6dfb991ab..21d52e304 100644 --- a/SessionMessagingKit/To Do/ProfileManagerProtocol.h +++ b/SessionMessagingKit/To Do/ProfileManagerProtocol.h @@ -1,30 +1,30 @@ +//// +//// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +//// // -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +//@class OWSAES256Key; +//@class TSThread; +//@class YapDatabaseReadWriteTransaction; +//@class SNContact; // - -@class OWSAES256Key; -@class TSThread; -@class YapDatabaseReadWriteTransaction; -@class SNContact; - -NS_ASSUME_NONNULL_BEGIN - -@protocol ProfileManagerProtocol - -#pragma mark - Local Profile - -- (void)updateServiceWithProfileName:(nullable NSString *)localProfileName avatarURL:(nullable NSString *)avatarURL; - -#pragma mark - Other User's Profiles - -- (nullable NSData *)profileKeyDataForRecipientId:(NSString *)recipientId; -- (void)setProfileKeyData:(NSData *)profileKeyData forRecipientId:(NSString *)recipientId; -- (void)setProfileKeyData:(NSData *)profileKeyData forRecipientId:(NSString *)recipientId avatarURL:(nullable NSString *)avatarURL; - -#pragma mark - Other - -- (void)downloadAvatarForUserProfile:(SNContact *)userProfile; - -@end - -NS_ASSUME_NONNULL_END +//NS_ASSUME_NONNULL_BEGIN +// +//@protocol ProfileManagerProtocol +// +//#pragma mark - Local Profile +// +//- (void)updateServiceWithProfileName:(nullable NSString *)localProfileName avatarURL:(nullable NSString *)avatarURL; +// +//#pragma mark - Other User's Profiles +// +//- (nullable NSData *)profileKeyDataForRecipientId:(NSString *)recipientId; +//- (void)setProfileKeyData:(NSData *)profileKeyData forRecipientId:(NSString *)recipientId; +//- (void)setProfileKeyData:(NSData *)profileKeyData forRecipientId:(NSString *)recipientId avatarURL:(nullable NSString *)avatarURL; +// +//#pragma mark - Other +// +//- (void)downloadAvatarForUserProfile:(SNContact *)userProfile; +// +//@end +// +//NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Utilities/BoxKeyPair+Utilities.swift b/SessionMessagingKit/Utilities/BoxKeyPair+Utilities.swift new file mode 100644 index 000000000..9bbf84f83 --- /dev/null +++ b/SessionMessagingKit/Utilities/BoxKeyPair+Utilities.swift @@ -0,0 +1,12 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium +extension Box.KeyPair: Equatable { + public static func == (lhs: Box.KeyPair, rhs: Box.KeyPair) -> Bool { + return ( + lhs.publicKey == rhs.publicKey && + lhs.secretKey == rhs.secretKey + ) + } +} diff --git a/SessionMessagingKit/Utilities/FullTextSearchFinder.swift b/SessionMessagingKit/Utilities/FullTextSearchFinder.swift index 0320aeaf2..94af03021 100644 --- a/SessionMessagingKit/Utilities/FullTextSearchFinder.swift +++ b/SessionMessagingKit/Utilities/FullTextSearchFinder.swift @@ -178,12 +178,16 @@ public class FullTextSearchFinder: NSObject { } private static let recipientIndexer: SearchIndexer = SearchIndexer { (recipientId: String, transaction: YapDatabaseReadTransaction) in - var result = "\(recipientId)" - if let contact = Storage.shared.getContact(with: recipientId) { - if let name = contact.name { result += " \(name)" } - if let nickname = contact.nickname { result += " \(nickname)" } - } - return result + let profile: Profile? = GRDBStorage.shared.read { db in try Profile.fetchOne(db, id: recipientId) } + + return [ + recipientId, + profile?.name, + profile?.nickname + ] + .compactMap { $0 } + .filter { !$0.isEmpty } + .joined(separator: " ") } private static let messageIndexer: SearchIndexer = SearchIndexer { (message: TSMessage, transaction: YapDatabaseReadTransaction) in diff --git a/SessionMessagingKit/Utilities/OWSAES256Key+Utilities.swift b/SessionMessagingKit/Utilities/OWSAES256Key+Utilities.swift new file mode 100644 index 000000000..295c78ed9 --- /dev/null +++ b/SessionMessagingKit/Utilities/OWSAES256Key+Utilities.swift @@ -0,0 +1,12 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SignalCoreKit + +public extension OWSAES256Key { + convenience init?(data: Data?) { + guard let existingData: Data = data else { return nil } + + self.init(data: existingData) + } +} diff --git a/SessionMessagingKit/Utilities/OWSAudioSession.swift b/SessionMessagingKit/Utilities/OWSAudioSession.swift index 1f9653118..890c25055 100644 --- a/SessionMessagingKit/Utilities/OWSAudioSession.swift +++ b/SessionMessagingKit/Utilities/OWSAudioSession.swift @@ -1,9 +1,8 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation import AVFoundation +import SignalCoreKit @objc(OWSAudioActivity) public class AudioActivity: NSObject { diff --git a/SessionMessagingKit/Utilities/OWSSounds.swift b/SessionMessagingKit/Utilities/OWSSounds.swift index bd51d9345..97caad633 100644 --- a/SessionMessagingKit/Utilities/OWSSounds.swift +++ b/SessionMessagingKit/Utilities/OWSSounds.swift @@ -1,3 +1,8 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SignalCoreKit + extension OWSSound { public func notificationSound(isQuiet: Bool) -> UNNotificationSound { diff --git a/SessionMessagingKit/Utilities/ProfileManager.swift b/SessionMessagingKit/Utilities/ProfileManager.swift new file mode 100644 index 000000000..3a63eb760 --- /dev/null +++ b/SessionMessagingKit/Utilities/ProfileManager.swift @@ -0,0 +1,351 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import GRDB +import PromiseKit +import SignalCoreKit +import SessionUtilitiesKit + +public struct ProfileManager { + public enum Error: LocalizedError { + case avatarImageTooLarge + case avatarWriteFailed + case avatarEncryptionFailed + case avatarUploadFailed + case avatarUploadMaxFileSizeExceeded + + var localizedDescription: String { + switch self { + case .avatarImageTooLarge: return "Avatar image too large." + case .avatarWriteFailed: return "Avatar write failed." + case .avatarEncryptionFailed: return "Avatar encryption failed." + case .avatarUploadFailed: return "Avatar upload failed." + case .avatarUploadMaxFileSizeExceeded: return "Maximum file size exceeded." + } + } + } + + // The max bytes for a user's profile name, encoded in UTF8. + // Before encrypting and submitting we NULL pad the name data to this length. + private static let nameDataLength: UInt = 26 + public static let maxAvatarDiameter: CGFloat = 640 + + private static var profileAvatarCache: Atomic<[String: UIImage]> = Atomic([:]) + private static var currentAvatarDownloads: Atomic> = Atomic([]) + + // MARK: - Functions + + public static func isToLong(profileName: String) -> Bool { + return ((profileName.data(using: .utf8)?.count ?? 0) > nameDataLength) + } + + public static func profileAvatar(for id: String) -> UIImage? { + guard let profile: Profile = GRDBStorage.shared.read({ db in try Profile.fetchOne(db, id: id) }) else { + return nil + } + + if let profileFileName: String = profile.profilePictureFileName, !profileFileName.isEmpty { + return loadProfileAvatar(for: profileFileName) + } + + if let profilePictureUrl: String = profile.profilePictureUrl, !profilePictureUrl.isEmpty { + downloadAvatar(for: profile) + } + + return nil + } + + private static func loadProfileAvatar(for fileName: String) -> UIImage? { + if let cachedImage: UIImage = profileAvatarCache.wrappedValue[fileName] { + return cachedImage + } + + guard + !fileName.isEmpty, + let data: Data = loadProfileData(with: fileName), + data.isValidImage, + let image: UIImage = UIImage(data: data) + else { + return nil + } + + profileAvatarCache.mutate { $0[fileName] = image } + return image + } + + private static func loadProfileData(with fileName: String) -> Data? { + let filePath: String = OWSUserProfile.profileAvatarFilepath(withFilename: fileName) + + return try? Data(contentsOf: URL(fileURLWithPath: filePath)) + } + + // MARK: - Profile Encryption + + private static func encryptProfileData(data: Data, key: OWSAES256Key) -> Data? { + guard key.keyData.count == kAES256_KeyByteLength else { return nil } + + return Cryptography.encryptAESGCMProfileData(plainTextData: data, key: key) + } + + private static func decryptProfileData(data: Data, key: OWSAES256Key) -> Data? { + guard key.keyData.count == kAES256_KeyByteLength else { return nil } + + return Cryptography.decryptAESGCMProfileData(encryptedData: data, key: key) + } + + // MARK: - Other Users' Profiles + + public static func downloadAvatar(for profile: Profile, funcName: String = #function) { + guard !currentAvatarDownloads.wrappedValue.contains(profile.id) else { + // Download already in flight; ignore + return + } + guard + let profileUrlStringAtStart: String = profile.profilePictureUrl, + let profileUrlAtStart: URL = URL(string: profileUrlStringAtStart) + else { + SNLog("Skipping downloading avatar for \(profile.id) because url is not set") + return + } + guard + let fileId: UInt64 = UInt64(profileUrlAtStart.lastPathComponent), + let profileKeyAtStart: OWSAES256Key = profile.profileEncryptionKey, + profileKeyAtStart.keyData.count > 0 + else { + return + } + + let fileName: String = UUID().uuidString.appendingFileExtension("jpg") + let filePath: String = OWSUserProfile.profileAvatarFilepath(withFilename: fileName) + var backgroundTask: OWSBackgroundTask? = OWSBackgroundTask(label: funcName) + + DispatchQueue.global(qos: .default).async { + OWSLogger.verbose("downloading profile avatar: \(profile.id)") + currentAvatarDownloads.mutate { $0.insert(profile.id) } + + let useOldServer: Bool = (profileUrlStringAtStart.contains(FileServerAPIV2.oldServer)) + + FileServerAPIV2 + .download(fileId, useOldServer: useOldServer) + .done { data in + currentAvatarDownloads.mutate { $0.remove(profile.id) } + + GRDBStorage.shared.write { db in + guard let latestProfile: Profile = try Profile.fetchOne(db, id: profile.id) else { + return + } + + guard + let latestProfileKey: OWSAES256Key = latestProfile.profileEncryptionKey, + !latestProfileKey.keyData.isEmpty, + latestProfileKey == profileKeyAtStart + else { + OWSLogger.warn("Ignoring avatar download for obsolete user profile.") + return + } + + guard profileUrlStringAtStart == latestProfile.profilePictureUrl else { + OWSLogger.warn("Avatar url has changed during download.") + + if latestProfile.profilePictureUrl?.isEmpty == false { + self.downloadAvatar(for: latestProfile) + } + return + } + + guard let decryptedData: Data = decryptProfileData(data: data, key: profileKeyAtStart) else { + OWSLogger.warn("Avatar data for \(profile.id) could not be decrypted.") + return + } + + try? decryptedData.write(to: URL(fileURLWithPath: filePath), options: [.atomic]) + + guard let image: UIImage = UIImage(contentsOfFile: filePath) else { + OWSLogger.warn("Avatar image for \(profile.id) could not be loaded.") + return + } + + try? latestProfile + .with(profilePictureFileName: .update(fileName)) + .update(db) + profileAvatarCache.mutate { $0[fileName] = image } + } + + // Redundant but without reading 'backgroundTask' it will warn that the variable + // isn't used + if backgroundTask != nil { backgroundTask = nil } + } + .retainUntilComplete() + } + } + + // MARK: - Current User Profile + + public static func updateLocal( + profileName: String, + avatarImage: UIImage?, + requiredSync: Bool, + success: (() -> ())? = nil, + failure: ((Error) -> ())? = nil + ) { + DispatchQueue.global(qos: .default).async { + // If the profile avatar was updated or removed then encrypt with a new profile key + // to ensure that other users know that our profile picture was updated + let newProfileKey: OWSAES256Key = OWSAES256Key.generateRandom() + + guard let avatarImage: UIImage = avatarImage else { + // If we have no image then we need to make sure to remove it from the profile + GRDBStorage.shared.writeAsync( + updates: { db in + let existingProfile: Profile = Profile.fetchOrCreateCurrentUser(db) + + OWSLogger.verbose(existingProfile.profilePictureUrl != nil ? + "Updating local profile on service with cleared avatar." : + "Updating local profile on service with no avatar." + ) + + try? existingProfile + .with( + name: profileName, + profilePictureUrl: nil, + profilePictureFileName: nil, + profileEncryptionKey: (existingProfile.profilePictureUrl != nil ? + .update(newProfileKey) : + .existing + ) + ) + .save(db) + + // Remove any cached avatar image value + if let fileName: String = existingProfile.profilePictureFileName { + profileAvatarCache.mutate { $0[fileName] = nil } + } + }, + completion: { _, _ in + SNLog("Successfully updated service with profile.") + + DispatchQueue.main.async { + success?() + } + } + ) + return + } + + // If we have a new avatar image, we must first: + // + // * Encode it to JPEG. + // * Write it to disk. + // * Encrypt it + // * Upload it to asset service + // * Send asset service info to Signal Service + OWSLogger.verbose("Updating local profile on service with new avatar.") + let maxAvatarBytes: UInt = (5 * 1000 * 1000) + var image: UIImage = avatarImage + + if image.size.width != maxAvatarDiameter || image.size.height != maxAvatarDiameter { + // To help ensure the user is being shown the same cropping of their avatar as + // everyone else will see, we want to be sure that the image was resized before this point. + SNLog("Avatar image should have been resized before trying to upload") + image = image.resizedImage(toFillPixelSize: CGSize(width: maxAvatarDiameter, height: maxAvatarDiameter)) + } + + guard let data: Data = image.jpegData(compressionQuality: 0.95) else { + DispatchQueue.main.async { + SNLog("Updating service with profile failed.") + failure?(.avatarWriteFailed) + } + return + } + + guard data.count <= maxAvatarBytes else { + // Our avatar dimensions are so small that it's incredibly unlikely we wouldn't + // be able to fit our profile photo (eg. generating pure noise at our resolution + // compresses to ~200k) + DispatchQueue.main.async { + SNLog("Suprised to find profile avatar was too large. Was it scaled properly? image: \(image)") + SNLog("Updating service with profile failed.") + failure?(.avatarImageTooLarge) + } + return + } + + let fileName: String = UUID().uuidString.appendingFileExtension("jpg") + let filePath: String = OWSUserProfile.profileAvatarFilepath(withFilename: fileName) + + // Write the avatar to disk + do { try data.write(to: URL(fileURLWithPath: filePath), options: [.atomic]) } + catch { + DispatchQueue.main.async { + SNLog("Updating service with profile failed.") + failure?(.avatarWriteFailed) + } + return + } + + // Encrypt the avatar for upload + guard let encryptedAvatarData: Data = encryptProfileData(data: data, key: newProfileKey) else { + DispatchQueue.main.async { + SNLog("Updating service with profile failed.") + failure?(.avatarEncryptionFailed) + } + return + } + + // Upload the avatar to the FileServer + FileServerAPIV2 + .upload(encryptedAvatarData) + .done { fileId in + let downloadUrl: String = "\(FileServerAPIV2.server)/files/\(fileId)" + UserDefaults.standard[.lastProfilePictureUpload] = Date() + + GRDBStorage.shared.writeAsync( + updates: { db in + try? Profile + .fetchOrCreateCurrentUser(db) + .with( + name: profileName, + profilePictureUrl: .update(downloadUrl), + profilePictureFileName: .update(fileName), + profileEncryptionKey: .update(newProfileKey) + ) + .save(db) + }, + completion: { _, _ in + // Update the cached avatar image value + profileAvatarCache.mutate { $0[fileName] = avatarImage } + + DispatchQueue.main.async { + SNLog("Successfully updated service with profile.") + success?() + } + } + ) + } + .recover { error in + DispatchQueue.main.async { + SNLog("Updating service with profile failed.") + + let isMaxFileSizeExceeded: Bool = ((error as? FileServerAPIV2.Error) == FileServerAPIV2.Error.maxFileSizeExceeded) + failure?(isMaxFileSizeExceeded ? + .avatarUploadMaxFileSizeExceeded : + .avatarUploadFailed + ) + } + } + .retainUntilComplete() + } + } +} + +// MARK: - Objective-C Support +@objc(SMKProfileManager) +public class SMKProfileManager: NSObject { + @objc public static func profileAvatar(recipientId: String) -> UIImage? { + return ProfileManager.profileAvatar(for: recipientId) + } + + @objc public static func updateLocal(profileName: String, avatarImage: UIImage?, requiresSync: Bool) { + ProfileManager.updateLocal(profileName: profileName, avatarImage: avatarImage, requiredSync: requiresSync) + } +} diff --git a/SessionMessagingKit/Utilities/ProtoUtils.h b/SessionMessagingKit/Utilities/ProtoUtils.h index bda2a8e38..cac16bed8 100644 --- a/SessionMessagingKit/Utilities/ProtoUtils.h +++ b/SessionMessagingKit/Utilities/ProtoUtils.h @@ -1,24 +1,24 @@ +//// +//// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +//// // -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +//#import // - -#import - -NS_ASSUME_NONNULL_BEGIN - -@class SNProtoDataMessageBuilder; -@class TSThread; - -@interface ProtoUtils : NSObject - -- (instancetype)init NS_UNAVAILABLE; - -+ (void)addLocalProfileKeyIfNecessary:(TSThread *)thread - recipientId:(NSString *_Nullable)recipientId - dataMessageBuilder:(SNProtoDataMessageBuilder *)dataMessageBuilder; - -+ (void)addLocalProfileKeyToDataMessageBuilder:(SNProtoDataMessageBuilder *)dataMessageBuilder; - -@end - -NS_ASSUME_NONNULL_END +//NS_ASSUME_NONNULL_BEGIN +// +//@class SNProtoDataMessageBuilder; +//@class TSThread; +// +//@interface ProtoUtils : NSObject +// +//- (instancetype)init NS_UNAVAILABLE; +// +//+ (void)addLocalProfileKeyIfNecessary:(TSThread *)thread +// recipientId:(NSString *_Nullable)recipientId +// dataMessageBuilder:(SNProtoDataMessageBuilder *)dataMessageBuilder; +// +//+ (void)addLocalProfileKeyToDataMessageBuilder:(SNProtoDataMessageBuilder *)dataMessageBuilder; +// +//@end +// +//NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Utilities/ProtoUtils.m b/SessionMessagingKit/Utilities/ProtoUtils.m index 0a1ac6874..57bb38b11 100644 --- a/SessionMessagingKit/Utilities/ProtoUtils.m +++ b/SessionMessagingKit/Utilities/ProtoUtils.m @@ -1,50 +1,50 @@ +//// +//// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +//// // -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +//#import "ProtoUtils.h" +//#import "ProfileManagerProtocol.h" +//#import "SSKEnvironment.h" +//#import "TSThread.h" +//#import +//#import // - -#import "ProtoUtils.h" -#import "ProfileManagerProtocol.h" -#import "SSKEnvironment.h" -#import "TSThread.h" -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@implementation ProtoUtils - -#pragma mark - Dependencies - -+ (id)profileManager { - return SSKEnvironment.shared.profileManager; -} - -+ (OWSAES256Key *)localProfileKey -{ - return [[LKStorage.shared getUser] profileEncryptionKey]; -} - -#pragma mark - - -+ (BOOL)shouldMessageHaveLocalProfileKey:(TSThread *)thread recipientId:(NSString *_Nullable)recipientId -{ - return YES; -} - -+ (void)addLocalProfileKeyIfNecessary:(TSThread *)thread - recipientId:(NSString *_Nullable)recipientId - dataMessageBuilder:(SNProtoDataMessageBuilder *)dataMessageBuilder -{ - if ([self shouldMessageHaveLocalProfileKey:thread recipientId:recipientId]) { - [dataMessageBuilder setProfileKey:self.localProfileKey.keyData]; - } -} - -+ (void)addLocalProfileKeyToDataMessageBuilder:(SNProtoDataMessageBuilder *)dataMessageBuilder -{ - [dataMessageBuilder setProfileKey:self.localProfileKey.keyData]; -} - -@end - -NS_ASSUME_NONNULL_END +//NS_ASSUME_NONNULL_BEGIN +// +//@implementation ProtoUtils +// +//#pragma mark - Dependencies +// +////+ (id)profileManager { +//// return SSKEnvironment.shared.profileManager; +////} +// +////+ (OWSAES256Key *)localProfileKey +////{ +//// return [[LKStorage.shared getUser] profileEncryptionKey]; +////} +// +//#pragma mark - +// +//+ (BOOL)shouldMessageHaveLocalProfileKey:(TSThread *)thread recipientId:(NSString *_Nullable)recipientId +//{ +// return YES; +//} +// +//+ (void)addLocalProfileKeyIfNecessary:(TSThread *)thread +// recipientId:(NSString *_Nullable)recipientId +// dataMessageBuilder:(SNProtoDataMessageBuilder *)dataMessageBuilder +//{ +// if ([self shouldMessageHaveLocalProfileKey:thread recipientId:recipientId]) { +// [dataMessageBuilder setProfileKey:[SMKProfile localProfileKey].keyData]; +// } +//} +// +//+ (void)addLocalProfileKeyToDataMessageBuilder:(SNProtoDataMessageBuilder *)dataMessageBuilder +//{ +// [dataMessageBuilder setProfileKey:[SMKProfile localProfileKey].keyData]; +//} +// +//@end +// +//NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Utilities/ProximityMonitoringManager.swift b/SessionMessagingKit/Utilities/ProximityMonitoringManager.swift index 5dff015a7..5a870cdd0 100644 --- a/SessionMessagingKit/Utilities/ProximityMonitoringManager.swift +++ b/SessionMessagingKit/Utilities/ProximityMonitoringManager.swift @@ -1,6 +1,7 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SignalCoreKit @objc public protocol OWSProximityMonitoringManager: AnyObject { diff --git a/SessionMessagingKit/Utilities/SSKEnvironment.h b/SessionMessagingKit/Utilities/SSKEnvironment.h index c1e3112f5..63751c3d5 100644 --- a/SessionMessagingKit/Utilities/SSKEnvironment.h +++ b/SessionMessagingKit/Utilities/SSKEnvironment.h @@ -36,8 +36,7 @@ NS_ASSUME_NONNULL_BEGIN @interface SSKEnvironment : NSObject -- (instancetype)initWithProfileManager:(id)profileManager - primaryStorage:(OWSPrimaryStorage *)primaryStorage +- (instancetype)initWithPrimaryStorage:(OWSPrimaryStorage *)primaryStorage tsAccountManager:(TSAccountManager *)tsAccountManager disappearingMessagesJob:(OWSDisappearingMessagesJob *)disappearingMessagesJob readReceiptManager:(OWSReadReceiptManager *)readReceiptManager diff --git a/SessionMessagingKit/Utilities/SSKEnvironment.m b/SessionMessagingKit/Utilities/SSKEnvironment.m index 959487585..75f0ead69 100644 --- a/SessionMessagingKit/Utilities/SSKEnvironment.m +++ b/SessionMessagingKit/Utilities/SSKEnvironment.m @@ -12,7 +12,6 @@ static SSKEnvironment *sharedSSKEnvironment; @interface SSKEnvironment () -@property (nonatomic) id profileManager; @property (nonatomic) OWSPrimaryStorage *primaryStorage; @property (nonatomic) TSAccountManager *tsAccountManager; @property (nonatomic) OWSDisappearingMessagesJob *disappearingMessagesJob; @@ -33,8 +32,7 @@ static SSKEnvironment *sharedSSKEnvironment; @synthesize migrationDBConnection = _migrationDBConnection; @synthesize analyticsDBConnection = _analyticsDBConnection; -- (instancetype)initWithProfileManager:(id)profileManager - primaryStorage:(OWSPrimaryStorage *)primaryStorage +- (instancetype)initWithPrimaryStorage:(OWSPrimaryStorage *)primaryStorage tsAccountManager:(TSAccountManager *)tsAccountManager disappearingMessagesJob:(OWSDisappearingMessagesJob *)disappearingMessagesJob readReceiptManager:(OWSReadReceiptManager *)readReceiptManager @@ -48,7 +46,6 @@ static SSKEnvironment *sharedSSKEnvironment; return self; } - _profileManager = profileManager; _primaryStorage = primaryStorage; _tsAccountManager = tsAccountManager; _disappearingMessagesJob = disappearingMessagesJob; diff --git a/SessionNotificationServiceExtension/NSENotificationPresenter.swift b/SessionNotificationServiceExtension/NSENotificationPresenter.swift index 4f1b8b940..37e6d612c 100644 --- a/SessionNotificationServiceExtension/NSENotificationPresenter.swift +++ b/SessionNotificationServiceExtension/NSENotificationPresenter.swift @@ -1,7 +1,9 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -import SignalUtilitiesKit +import Foundation import UserNotifications +import SignalUtilitiesKit +import SessionMessagingKit public class NSENotificationPresenter: NSObject, NotificationsProtocol { @@ -36,8 +38,7 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { return } - let context = Contact.context(for: thread) - let senderName = Storage.shared.getContact(with: senderPublicKey, using: transaction)?.displayName(for: context) ?? senderPublicKey + let senderName = Profile.displayName(for: senderPublicKey, thread: thread) var notificationTitle = senderName if let group = thread as? TSGroupThread { @@ -128,8 +129,8 @@ private extension String { 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 - let displayName = Storage.shared.getContact(with: publicKey, using: transaction)?.displayName(for: .regular) - if let displayName = displayName { + + if let displayName: String = Profile.displayNameNoFallback(for: publicKey) { result = (result as NSString).replacingCharacters(in: m1.range, with: "@\(displayName)") 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 diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index 92043b15f..6408d6c8c 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -44,35 +44,37 @@ public final class NotificationServiceExtension : UNNotificationServiceExtension // HACK: It is important to use writeSync() here to avoid a race condition // where the completeSilenty() is called before the local notification request // is added to notification center. - Storage.writeSync { transaction in // Intentionally capture self - do { - let (message, proto) = try MessageReceiver.parse(envelopeAsData, openGroupMessageServerID: nil, using: transaction) - switch message { - case let visibleMessage as VisibleMessage: - let tsMessageID = try MessageReceiver.handleVisibleMessage(visibleMessage, associatedWithProto: proto, openGroupID: nil, isBackgroundPoll: false, using: transaction) - - // Remove the notificaitons if there is an outgoing messages from a linked device - if let tsMessage = TSMessage.fetch(uniqueId: tsMessageID, transaction: transaction), tsMessage.isKind(of: TSOutgoingMessage.self), let threadID = tsMessage.thread(with: transaction).uniqueId { - let semaphore = DispatchSemaphore(value: 0) - let center = UNUserNotificationCenter.current() - center.getDeliveredNotifications { notifications in - let matchingNotifications = notifications.filter({ $0.request.content.userInfo[NotificationServiceExtension.threadIdKey] as? String == threadID}) - center.removeDeliveredNotifications(withIdentifiers: matchingNotifications.map({ $0.request.identifier })) - // Hack: removeDeliveredNotifications seems to be async,need to wait for some time before the delivered notifications can be removed. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { semaphore.signal() } + GRDBStorage.shared.write { db in + Storage.writeSync { transaction in // Intentionally capture self + do { + let (message, proto) = try MessageReceiver.parse(db, envelopeAsData, openGroupMessageServerID: nil, using: transaction) + switch message { + case let visibleMessage as VisibleMessage: + let tsMessageID = try MessageReceiver.handleVisibleMessage(db, visibleMessage, associatedWithProto: proto, openGroupID: nil, isBackgroundPoll: false, using: transaction) + + // Remove the notificaitons if there is an outgoing messages from a linked device + if let tsMessage = TSMessage.fetch(uniqueId: tsMessageID, transaction: transaction), tsMessage.isKind(of: TSOutgoingMessage.self), let threadID = tsMessage.thread(with: transaction).uniqueId { + let semaphore = DispatchSemaphore(value: 0) + let center = UNUserNotificationCenter.current() + center.getDeliveredNotifications { notifications in + let matchingNotifications = notifications.filter({ $0.request.content.userInfo[NotificationServiceExtension.threadIdKey] as? String == threadID}) + center.removeDeliveredNotifications(withIdentifiers: matchingNotifications.map({ $0.request.identifier })) + // Hack: removeDeliveredNotifications seems to be async,need to wait for some time before the delivered notifications can be removed. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { semaphore.signal() } + } + semaphore.wait() } - semaphore.wait() + + case let unsendRequest as UnsendRequest: + MessageReceiver.handleUnsendRequest(unsendRequest, using: transaction) + case let closedGroupControlMessage as ClosedGroupControlMessage: + MessageReceiver.handleClosedGroupControlMessage(db, closedGroupControlMessage, using: transaction) + default: break + } + } catch { + if let error = error as? MessageReceiver.Error, error.isRetryable { + self.handleFailure(for: notificationContent) } - - case let unsendRequest as UnsendRequest: - MessageReceiver.handleUnsendRequest(unsendRequest, using: transaction) - case let closedGroupControlMessage as ClosedGroupControlMessage: - MessageReceiver.handleClosedGroupControlMessage(closedGroupControlMessage, using: transaction) - default: break - } - } catch { - if let error = error as? MessageReceiver.Error, error.isRetryable { - self.handleFailure(for: notificationContent) } } } @@ -126,7 +128,9 @@ public final class NotificationServiceExtension : UNNotificationServiceExtension // If we need a config sync then trigger it now if needsConfigSync { - MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete() + GRDBStorage.shared.write { db in + MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() + } } checkIsAppReady() diff --git a/SessionShareExtension/ShareVC.swift b/SessionShareExtension/ShareVC.swift index 651cd5e0b..4d0bfb1b0 100644 --- a/SessionShareExtension/ShareVC.swift +++ b/SessionShareExtension/ShareVC.swift @@ -81,7 +81,9 @@ final class ShareVC : UINavigationController, ShareViewDelegate, AppModeManagerD // If we need a config sync then trigger it now if needsConfigSync { - MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete() + GRDBStorage.shared.write { db in + MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() + } } checkIsAppReady() diff --git a/SessionShareExtension/SimplifiedConversationCell.swift b/SessionShareExtension/SimplifiedConversationCell.swift index 1c7896c54..604011fb4 100644 --- a/SessionShareExtension/SimplifiedConversationCell.swift +++ b/SessionShareExtension/SimplifiedConversationCell.swift @@ -1,5 +1,8 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + import UIKit import SessionUIKit +import SessionMessagingKit final class SimplifiedConversationCell : UITableViewCell { var threadViewModel: ThreadViewModel! { didSet { update() } } @@ -116,9 +119,6 @@ final class SimplifiedConversationCell : UITableViewCell { return "Unknown" } - return ( - Storage.shared.getContact(with: hexEncodedPublicKey)?.displayName(for: .regular) ?? - hexEncodedPublicKey - ) + return Profile.displayName(for: hexEncodedPublicKey) } } diff --git a/SessionSnodeKit/Configuration.swift b/SessionSnodeKit/Configuration.swift index 2c063507a..7d4b0a333 100644 --- a/SessionSnodeKit/Configuration.swift +++ b/SessionSnodeKit/Configuration.swift @@ -1,3 +1,5 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + import Foundation import SessionUtilitiesKit diff --git a/SessionUtilitiesKit/Configuration.swift b/SessionUtilitiesKit/Configuration.swift index c1b19b512..cfc16ae73 100644 --- a/SessionUtilitiesKit/Configuration.swift +++ b/SessionUtilitiesKit/Configuration.swift @@ -18,6 +18,9 @@ public enum SNUtilitiesKit { // Just to make the external API nice identifier: .utilitiesKit, migrations: [ [ + // Intentionally including the '_002_YDBToGRDBMigration' in the first migration + // set to ensure the 'Identity' data is migrated before any other migrations are + // run (some need access to the users publicKey) _001_InitialSetupMigration.self, _002_YDBToGRDBMigration.self ] diff --git a/SessionUtilitiesKit/Database/GRDBStorage.swift b/SessionUtilitiesKit/Database/GRDBStorage.swift index 9aea43d52..342a4a6e8 100644 --- a/SessionUtilitiesKit/Database/GRDBStorage.swift +++ b/SessionUtilitiesKit/Database/GRDBStorage.swift @@ -4,8 +4,11 @@ import Foundation import GRDB import SignalCoreKit -enum GRDBStorageError: Error { // TODO: Rename to `StorageError` +public enum GRDBStorageError: Error { // TODO: Rename to `StorageError` + case generic + case migrationFailed case invalidKeySpec + case decodingFailed } // TODO: Protocol for storage (just need to have 'read' and 'write' methods and mock 'Database'? diff --git a/SessionUtilitiesKit/Database/LegacyDatabase/SUKLegacyModels.swift b/SessionUtilitiesKit/Database/LegacyDatabase/SUKLegacyModels.swift index d355dd8b4..0773d38d7 100644 --- a/SessionUtilitiesKit/Database/LegacyDatabase/SUKLegacyModels.swift +++ b/SessionUtilitiesKit/Database/LegacyDatabase/SUKLegacyModels.swift @@ -1,7 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import Curve25519Kit public enum Legacy { // MARK: - Collections and Keys @@ -14,12 +13,34 @@ public enum Legacy { internal static let identityKeyStoreEd25519PublicKey = "LKED25519PublicKey" internal static let identityKeyStoreIdentityKey = "TSStorageManagerIdentityKeyStoreIdentityKey" internal static let identityKeyStoreCollection = "TSStorageManagerIdentityKeyStoreCollection" -} - -// MARK: - Legacy Extensions - -internal extension YapDatabaseReadTransaction { - func keyPair(forKey key: String, in collection: String) -> ECKeyPair? { - return (self.object(forKey: key, inCollection: collection) as? ECKeyPair) + + @objc(ECKeyPair) + public class KeyPair: NSObject, NSCoding { + private static let keyLength: Int = 32 + private static let publicKeyKey: String = "TSECKeyPairPublicKey" + private static let privateKeyKey: String = "TSECKeyPairPrivateKey" + + public let publicKey: Data + public let privateKey: Data + + public required init?(coder: NSCoder) { + var pubKeyLength: Int = 0 + var privKeyLength: Int = 0 + + guard + let pubKeyBytes: UnsafePointer = coder.decodeBytes(forKey: KeyPair.publicKeyKey, returnedLength: &pubKeyLength), + let privateKeyBytes: UnsafePointer = coder.decodeBytes(forKey: KeyPair.privateKeyKey, returnedLength: &privKeyLength), + pubKeyLength == KeyPair.keyLength, + privKeyLength == KeyPair.keyLength + else { + // Fail if the keys aren't the correct length + return nil + } + + publicKey = Data(bytes: pubKeyBytes, count: pubKeyLength) + privateKey = Data(bytes: privateKeyBytes, count: privKeyLength) + } + + public func encode(with coder: NSCoder) { fatalError("Shouldn't be encoding this type") } } } diff --git a/SessionUtilitiesKit/Database/Migrations/_002_YDBToGRDBMigration.swift b/SessionUtilitiesKit/Database/Migrations/_002_YDBToGRDBMigration.swift index 0c53f6fcc..b82883f98 100644 --- a/SessionUtilitiesKit/Database/Migrations/_002_YDBToGRDBMigration.swift +++ b/SessionUtilitiesKit/Database/Migrations/_002_YDBToGRDBMigration.swift @@ -2,7 +2,6 @@ import Foundation import GRDB -import Curve25519Kit enum _002_YDBToGRDBMigration: Migration { static let identifier: String = "YDBToGRDBMigration" @@ -15,7 +14,7 @@ enum _002_YDBToGRDBMigration: Migration { var seedHexString: String? var userEd25519SecretKeyHexString: String? var userEd25519PublicKeyHexString: String? - var userX25519KeyPair: ECKeyPair? + var userX25519KeyPair: Legacy.KeyPair? Storage.read { transaction in registeredNumber = transaction.object( @@ -41,10 +40,10 @@ enum _002_YDBToGRDBMigration: Migration { inCollection: Legacy.identityKeyStoreCollection ) as? String - userX25519KeyPair = transaction.keyPair( + userX25519KeyPair = transaction.object( forKey: Legacy.identityKeyStoreIdentityKey, - in: Legacy.identityKeyStoreCollection - ) + inCollection: Legacy.identityKeyStoreCollection + ) as? Legacy.KeyPair } // No need to continue if the user isn't registered @@ -55,8 +54,15 @@ enum _002_YDBToGRDBMigration: Migration { let seedHexString: String = seedHexString, let userEd25519SecretKeyHexString: String = userEd25519SecretKeyHexString, let userEd25519PublicKeyHexString: String = userEd25519PublicKeyHexString, - let userX25519KeyPair: ECKeyPair = userX25519KeyPair + let userX25519KeyPair: Legacy.KeyPair = userX25519KeyPair else { + // If this is a fresh install then we would have created all of the Identity + // values directly within the 'Identity' table so this is actually a valid + // case and we don't need to throw + if try Identity.fetchCount(db) == Identity.Variant.allCases.count { + return + } + throw GRDBStorageError.migrationFailed } diff --git a/SessionUtilitiesKit/Database/Models/Identity.swift b/SessionUtilitiesKit/Database/Models/Identity.swift index 645e20a95..7f57b461e 100644 --- a/SessionUtilitiesKit/Database/Models/Identity.swift +++ b/SessionUtilitiesKit/Database/Models/Identity.swift @@ -15,7 +15,7 @@ public struct Identity: Codable, Identifiable, FetchableRecord, PersistableRecor case data } - public enum Variant: String, Codable, DatabaseValueConvertible { + public enum Variant: String, Codable, CaseIterable, DatabaseValueConvertible { case seed case ed25519SecretKey case ed25519PublicKey @@ -39,7 +39,7 @@ extension ECKeyPair { } } -// MARK: - User Identity +// MARK: - GRDB Interactions public extension Identity { static func generate(from seed: Data) throws -> (ed25519KeyPair: Sign.KeyPair, x25519KeyPair: ECKeyPair) { @@ -69,8 +69,45 @@ public extension Identity { } } - static func fetchUserKeyPair(_ db: Database? = nil) -> ECKeyPair? { - let fetchKeys: (Database) -> ECKeyPair? = { db in + static func userExists(_ db: Database? = nil) -> Bool { + let userExists: (Database) -> Bool = { db in + return ( + (try? Identity.fetchOne(db, id: .x25519PublicKey)) != nil && + (try? Identity.fetchOne(db, id: .x25519PrivateKey)) != nil + ) + } + + if let db: Database = db { + return userExists(db) + } + + return GRDBStorage.shared + .read { db -> Bool in userExists(db) } + .defaulting(to: false) + } + + static func fetchUserPublicKey(_ db: Database? = nil) -> Data? { + if let db: Database = db { + return try? Identity.fetchOne(db, id: .x25519PublicKey)?.data + } + + return GRDBStorage.shared.read { db -> Data? in + try Identity.fetchOne(db, id: .x25519PublicKey)?.data + } + } + + static func fetchUserPrivateKey(_ db: Database? = nil) -> Data? { + if let db: Database = db { + return try? Identity.fetchOne(db, id: .x25519PrivateKey)?.data + } + + return GRDBStorage.shared.read { db -> Data? in + try Identity.fetchOne(db, id: .x25519PrivateKey)?.data + } + } + + static func fetchUserKeyPair(_ db: Database? = nil) -> Box.KeyPair? { + let fetchKeys: (Database) -> Box.KeyPair? = { db in guard let publicKey: Identity = try? Identity.fetchOne(db, id: .x25519PublicKey), let privateKey: Identity = try? Identity.fetchOne(db, id: .x25519PrivateKey) @@ -78,9 +115,9 @@ public extension Identity { return nil } - return try? ECKeyPair( - publicKeyData: publicKey.data, - privateKeyData: privateKey.data + return try? Box.KeyPair( + publicKey: publicKey.data.bytes, + secretKey: privateKey.data.bytes ) } @@ -88,7 +125,7 @@ public extension Identity { return fetchKeys(db) } - return GRDBStorage.shared.read { db -> ECKeyPair? in + return GRDBStorage.shared.read { db -> Box.KeyPair? in return fetchKeys(db) } } diff --git a/SessionUtilitiesKit/Database/Models/Setting.swift b/SessionUtilitiesKit/Database/Models/Setting.swift index a0e5295c1..1170f1dfa 100644 --- a/SessionUtilitiesKit/Database/Models/Setting.swift +++ b/SessionUtilitiesKit/Database/Models/Setting.swift @@ -6,7 +6,7 @@ import GRDB // MARK: - Setting public struct Setting: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { - public static var databaseTableName: String { "settings" } + public static var databaseTableName: String { "setting" } public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -89,7 +89,7 @@ public extension Setting { } } -// MARK: - Database Access +// MARK: - GRDB Interactions public extension GRDBStorage { subscript(key: Setting.BoolKey) -> Bool? { return read { db in db[key] } } diff --git a/SessionUtilitiesKit/General/General.swift b/SessionUtilitiesKit/General/General.swift index 9a67b6675..2adcab603 100644 --- a/SessionUtilitiesKit/General/General.swift +++ b/SessionUtilitiesKit/General/General.swift @@ -24,9 +24,10 @@ public class GeneralUtilities: NSObject { public func getUserHexEncodedPublicKey(_ db: Database? = nil) -> String { if let cachedKey: String = General.Cache.cachedEncodedPublicKey.wrappedValue { return cachedKey } - if let keyPair: ECKeyPair = Identity.fetchUserKeyPair(db) { // Can be nil under some circumstances - General.Cache.cachedEncodedPublicKey.mutate { $0 = keyPair.hexEncodedPublicKey } - return keyPair.hexEncodedPublicKey + // TODO: Refactor this to be a sessionId instead of custom creating it + if let publicKey: Data = Identity.fetchUserPublicKey(db) { // Can be nil under some circumstances + General.Cache.cachedEncodedPublicKey.mutate { $0 = "05\(publicKey.toHexString())" } + return "05\(publicKey.toHexString())" } return "" diff --git a/SessionUtilitiesKit/General/SNUserDefaults.swift b/SessionUtilitiesKit/General/SNUserDefaults.swift index 0e27a2a3b..dcbdc5d9e 100644 --- a/SessionUtilitiesKit/General/SNUserDefaults.swift +++ b/SessionUtilitiesKit/General/SNUserDefaults.swift @@ -14,6 +14,7 @@ public enum SNUserDefaults { case lastConfigurationSync case lastDisplayNameUpdate case lastProfilePictureUpdate + case lastProfilePictureUpload case lastOpenGroupImageUpdate case lastOpen } diff --git a/SessionUtilitiesKit/Media/Data+Image.swift b/SessionUtilitiesKit/Media/Data+Image.swift new file mode 100644 index 000000000..57adb8a4d --- /dev/null +++ b/SessionUtilitiesKit/Media/Data+Image.swift @@ -0,0 +1,154 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import ImageIO + +public extension Data { + var isValidImage: Bool { + let imageFormat: ImageFormat = self.guessedImageFormat + let isAnimated: Bool = (imageFormat == .gif) + let maxFileSize: UInt = (isAnimated ? + OWSMediaUtils.kMaxFileSizeAnimatedImage : + OWSMediaUtils.kMaxFileSizeImage + ) + + return ( + count < maxFileSize && + isValidImage(mimeType: nil, format: imageFormat) && + hasValidImageDimensions(isAnimated: isAnimated) + ) + } + + var guessedImageFormat: ImageFormat { + let twoBytesLength: Int = 2 + + guard count > twoBytesLength else { return .unknown } + + var bytes: [UInt8] = [UInt8](repeating: 0, count: twoBytesLength) + self.copyBytes(to: &bytes, from: (self.startIndex.. bufferLength else { return false } + + var bytes: [UInt8] = [UInt8](repeating: 0, count: bufferLength) + self.copyBytes(to: &bytes, from: (self.startIndex.. 0 && width < maxValidSize && height > 0 && height < maxValidSize) + } + + func hasValidImageDimensions(isAnimated: Bool) -> Bool { + guard + let dataPtr: CFData = CFDataCreate(kCFAllocatorDefault, self.bytes, self.count), + let imageSource = CGImageSourceCreateWithData(dataPtr, nil) + else { return false } + + return Data.hasValidImageDimension(source: imageSource, isAnimated: isAnimated) + } + + func isValidImage(mimeType: String?, format: ImageFormat) -> Bool { + // Don't trust the file extension; iOS (e.g. UIKit, Core Graphics) will happily + // load a .gif with a .png file extension + // + // Instead, use the "magic numbers" in the file data to determine the image format + // + // If the image has a declared MIME type, ensure that agrees with the + // deduced image format + switch format { + case .unknown: return false + case .png: return (mimeType == nil || mimeType == OWSMimeTypeImagePng) + case .jpeg: return (mimeType == nil || mimeType == OWSMimeTypeImageJpeg) + + case .gif: + guard hasValidGifSize else { return false } + + return (mimeType == nil || mimeType == OWSMimeTypeImageGif) + + case .tiff: + return ( + mimeType == nil || + mimeType == OWSMimeTypeImageTiff1 || + mimeType == OWSMimeTypeImageTiff2 + ) + + case .bmp: + return ( + mimeType == nil || + mimeType == OWSMimeTypeImageBmp1 || + mimeType == OWSMimeTypeImageBmp2 + ) + } + } + + static func hasValidImageDimension(source: CGImageSource, isAnimated: Bool) -> Bool { + guard let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] else { return false } + guard let width = properties[kCGImagePropertyPixelWidth] as? Double else { return false } + guard let height = properties[kCGImagePropertyPixelHeight] as? Double else { return false } + + // The number of bits in each color sample of each pixel. The value of this key is a CFNumberRef + guard let depthBits = properties[kCGImagePropertyDepth] as? UInt else { return false } + + // This should usually be 1. + let depthBytes: CGFloat = ceil(CGFloat(depthBits) / 8.0) + + // The color model of the image such as "RGB", "CMYK", "Gray", or "Lab" + // The value of this key is CFStringRef + guard + let colorModel = properties[kCGImagePropertyColorModel] as? String, + ( + colorModel != (kCGImagePropertyColorModelRGB as String) || + colorModel != (kCGImagePropertyColorModelGray as String) + ) + else { return false } + + // We only support (A)RGB and (A)Grayscale, so worst case is 4. + let worseCastComponentsPerPixel: CGFloat = 4 + let bytesPerPixel: CGFloat = (worseCastComponentsPerPixel * depthBytes) + + let expectedBytePerPixel: CGFloat = 4 + let maxValidImageDimension: CGFloat = CGFloat(isAnimated ? + OWSMediaUtils.kMaxAnimatedImageDimensions : + OWSMediaUtils.kMaxStillImageDimensions + ) + let maxBytes: CGFloat = (maxValidImageDimension * maxValidImageDimension * expectedBytePerPixel) + let actualBytes: CGFloat = (width * height * bytesPerPixel) + + return (actualBytes <= maxBytes) + } +} diff --git a/SessionUtilitiesKit/Media/ImageFormat.swift b/SessionUtilitiesKit/Media/ImageFormat.swift new file mode 100644 index 000000000..e31f408c8 --- /dev/null +++ b/SessionUtilitiesKit/Media/ImageFormat.swift @@ -0,0 +1,12 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum ImageFormat { + case unknown + case png + case gif + case tiff + case jpeg + case bmp +} diff --git a/SessionUtilitiesKit/Media/Updatable.swift b/SessionUtilitiesKit/Media/Updatable.swift new file mode 100644 index 000000000..ccd7fa03b --- /dev/null +++ b/SessionUtilitiesKit/Media/Updatable.swift @@ -0,0 +1,113 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum Updatable: ExpressibleByNilLiteral { + /// A cleared value. + /// + /// In code, the cleared of a value is typically written using the `nil` + /// literal rather than the explicit `.remove` enumeration case. + case remove + + /// The existing value, this will leave whatever value is currently available. + case existing + + /// An updated value, stored as `Wrapped`. + case update(Wrapped) + + // MARK: - ExpressibleByNilLiteral + + public init(nilLiteral: ()) { + self = .remove + } + + public static func updateIf(_ maybeValue: Wrapped?) -> Updatable { + switch maybeValue { + case .some(let value): return .update(value) + default: return .existing + } + } + + public static func updateTo(_ maybeValue: Wrapped?) -> Updatable { + switch maybeValue { + case .some(let value): return .update(value) + default: return .remove + } + } + + // MARK: - Functions + + public func value(existing: Wrapped) -> Wrapped? { + switch self { + case .remove: return nil + case .existing: return existing + case .update(let newValue): return newValue + } + } + + public func value(existing: Wrapped) -> Wrapped { + switch self { + case .remove: fatalError("Attempted to assign a 'removed' value to a non-null") + case .existing: return existing + case .update(let newValue): return newValue + } + } +} + +// MARK: - Coalesing-nil operator + +public func ?? (updatable: Updatable, existingValue: @autoclosure () throws -> T) rethrows -> T { + switch updatable { + case .remove: fatalError("Attempted to assign a 'removed' value to a non-null") + case .existing: return try existingValue() + case .update(let newValue): return newValue + } +} + +public func ?? (updatable: Updatable, existingValue: @autoclosure () throws -> T?) rethrows -> T? { + switch updatable { + case .remove: return nil + case .existing: return try existingValue() + case .update(let newValue): return newValue + } +} + +// MARK: - ExpressibleBy Conformance + +extension Updatable { + public init(_ value: Wrapped) { + self = .update(value) + } +} + +extension Updatable: ExpressibleByUnicodeScalarLiteral, ExpressibleByExtendedGraphemeClusterLiteral, ExpressibleByStringLiteral where Wrapped == String { + public init(stringLiteral value: Wrapped) { + self = .update(value) + } + + public init(extendedGraphemeClusterLiteral value: Wrapped) { + self = .update(value) + } + + public init(unicodeScalarLiteral value: Wrapped) { + self = .update(value) + } +} + +extension Updatable: ExpressibleByIntegerLiteral where Wrapped == Int { + public init(integerLiteral value: Int) { + self = .update(value) + } +} + +extension Updatable: ExpressibleByFloatLiteral where Wrapped == Double { + public init(floatLiteral value: Double) { + self = .update(value) + } +} + +extension Updatable: ExpressibleByBooleanLiteral where Wrapped == Bool { + public init(booleanLiteral value: Bool) { + self = .update(value) + } +} diff --git a/SessionUtilitiesKit/Utilities/Notification+Utilities.swift b/SessionUtilitiesKit/Utilities/Notification+Utilities.swift new file mode 100644 index 000000000..d20b709ce --- /dev/null +++ b/SessionUtilitiesKit/Utilities/Notification+Utilities.swift @@ -0,0 +1,39 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension Notification { + public struct Key: RawRepresentable, Hashable, ExpressibleByUnicodeScalarLiteral, ExpressibleByExtendedGraphemeClusterLiteral, ExpressibleByStringLiteral { + public typealias RawValue = String + + public var rawValue: String + + public init(_ rawValue: String) { + self.rawValue = rawValue + } + + // MARK: - RawRepresentable + + public init?(rawValue: String) { + self.rawValue = rawValue + } + + // MARK: - ExpressibleByStringLiteral + + public init(stringLiteral value: String) { + self.rawValue = value + } + + // MARK: - ExpressibleByExtendedGraphemeClusterLiteral + + public init(extendedGraphemeClusterLiteral value: String) { + self.rawValue = value + } + + // MARK: - ExpressibleByUnicodeScalarLiteral + + public init(unicodeScalarLiteral value: String) { + self.rawValue = value + } + } +} diff --git a/SessionUtilitiesKit/Utilities/Optional+Utilities.swift b/SessionUtilitiesKit/Utilities/Optional+Utilities.swift new file mode 100644 index 000000000..cd6374b96 --- /dev/null +++ b/SessionUtilitiesKit/Utilities/Optional+Utilities.swift @@ -0,0 +1,23 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension Optional { + public func map(_ transform: (Wrapped) throws -> U?) rethrows -> U? { + switch self { + case .some(let value): return try transform(value) + default: return nil + } + } + + public func asType(_ type: R.Type) -> R? { + switch self { + case .some(let value): return (value as? R) + default: return nil + } + } + + public func defaulting(to value: Wrapped) -> Wrapped { + return (self ?? value) + } +} diff --git a/SignalUtilitiesKit/Database/Migrations/BlockingManagerRemovalMigration.swift b/SignalUtilitiesKit/Database/Migrations/BlockingManagerRemovalMigration.swift index 518a4bc7e..e643e3633 100644 --- a/SignalUtilitiesKit/Database/Migrations/BlockingManagerRemovalMigration.swift +++ b/SignalUtilitiesKit/Database/Migrations/BlockingManagerRemovalMigration.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import SessionMessagingKit @objc(SNBlockingManagerRemovalMigration) public class BlockingManagerRemovalMigration: OWSDatabaseMigration { @@ -27,11 +28,18 @@ public class BlockingManagerRemovalMigration: OWSDatabaseMigration { Storage.write( with: { transaction in - Storage.shared.getAllContacts(with: transaction) + var result: Set = [] + + transaction.enumerateRows(inCollection: Legacy.contactCollection) { _, object, _, _ in + guard let contact = object as? SessionMessagingKit.Legacy.Contact else { return } + result.insert(contact) + } + + result .filter { contact -> Bool in blockedSessionIds.contains(contact.sessionID) } .forEach { contact in contact.isBlocked = true - Storage.shared.setContact(contact, using: transaction) + transaction.setObject(contact, forKey: contact.sessionID, inCollection: Legacy.contactCollection) } // Now that the values have been migrated we can clear out the old collection diff --git a/SignalUtilitiesKit/Database/Migrations/ContactsMigration.swift b/SignalUtilitiesKit/Database/Migrations/ContactsMigration.swift index 8dc9708fe..2e0a78b66 100644 --- a/SignalUtilitiesKit/Database/Migrations/ContactsMigration.swift +++ b/SignalUtilitiesKit/Database/Migrations/ContactsMigration.swift @@ -1,3 +1,7 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionMessagingKit @objc(SNContactsMigration) public class ContactsMigration : OWSDatabaseMigration { @@ -12,18 +16,24 @@ public class ContactsMigration : OWSDatabaseMigration { } private func doMigrationAsync(completion: @escaping OWSDatabaseMigrationCompletion) { - var contacts: [Contact] = [] + var contacts: [SessionMessagingKit.Legacy.Contact] = [] TSContactThread.enumerateCollectionObjects { object, _ in guard let thread = object as? TSContactThread else { return } let sessionID = thread.contactSessionID() - if let contact = Storage.shared.getContact(with: sessionID) { + var contact: SessionMessagingKit.Legacy.Contact? + + Storage.read { transaction in + contact = transaction.object(forKey: sessionID, inCollection: Legacy.contactCollection) as? SessionMessagingKit.Legacy.Contact + } + + if let contact: SessionMessagingKit.Legacy.Contact = contact { contact.isTrusted = true contacts.append(contact) } } Storage.write(with: { transaction in contacts.forEach { contact in - Storage.shared.setContact(contact, using: transaction) + transaction.setObject(contact, forKey: contact.sessionID, inCollection: Legacy.contactCollection) } self.save(with: transaction) // Intentionally capture self }, completion: { diff --git a/SignalUtilitiesKit/Database/Migrations/MessageRequestsMigration.swift b/SignalUtilitiesKit/Database/Migrations/MessageRequestsMigration.swift index 441d9fc00..aeb57e122 100644 --- a/SignalUtilitiesKit/Database/Migrations/MessageRequestsMigration.swift +++ b/SignalUtilitiesKit/Database/Migrations/MessageRequestsMigration.swift @@ -1,3 +1,8 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionMessagingKit + @objc(SNMessageRequestsMigration) public class MessageRequestsMigration : OWSDatabaseMigration { @@ -11,46 +16,51 @@ public class MessageRequestsMigration : OWSDatabaseMigration { } private func doMigrationAsync(completion: @escaping OWSDatabaseMigrationCompletion) { - var contacts: Set = Set() + var contacts: Set = Set() var threads: [TSThread] = [] TSThread.enumerateCollectionObjects { object, _ in guard let thread: TSThread = object as? TSThread else { return } - if let contactThread: TSContactThread = thread as? TSContactThread { - let sessionId: String = contactThread.contactSessionID() - - if let contact: Contact = Storage.shared.getContact(with: sessionId) { - contact.isApproved = true - contact.didApproveMe = true - contacts.insert(contact) - } - } - else if let groupThread: TSGroupThread = thread as? TSGroupThread, groupThread.isClosedGroup { - let groupAdmins: [String] = groupThread.groupModel.groupAdminIds - - groupAdmins.forEach { sessionId in - if let contact: Contact = Storage.shared.getContact(with: sessionId) { + Storage.read { transaction in + if let contactThread: TSContactThread = thread as? TSContactThread { + let sessionId: String = contactThread.contactSessionID() + + if let contact: SessionMessagingKit.Legacy.Contact = transaction.object(forKey: sessionId, inCollection: Legacy.contactCollection) as? SessionMessagingKit.Legacy.Contact { contact.isApproved = true contact.didApproveMe = true contacts.insert(contact) } } + else if let groupThread: TSGroupThread = thread as? TSGroupThread, groupThread.isClosedGroup { + let groupAdmins: [String] = groupThread.groupModel.groupAdminIds + + groupAdmins.forEach { sessionId in + if let contact: SessionMessagingKit.Legacy.Contact = transaction.object(forKey: sessionId, inCollection: Legacy.contactCollection) as? SessionMessagingKit.Legacy.Contact { + contact.isApproved = true + contact.didApproveMe = true + contacts.insert(contact) + } + } + } } threads.append(thread) } - if let user = Storage.shared.getUser() { - user.isApproved = true - user.didApproveMe = true - contacts.insert(user) - } + let userPublicKey: String = getUserHexEncodedPublicKey() + Storage.read { transaction in + if let user = transaction.object(forKey: userPublicKey, inCollection: Legacy.contactCollection) as? SessionMessagingKit.Legacy.Contact { + user.isApproved = true + user.didApproveMe = true + contacts.insert(user) + } + } Storage.write(with: { transaction in contacts.forEach { contact in - Storage.shared.setContact(contact, using: transaction) + transaction.setObject(contact, forKey: contact.sessionID, inCollection: Legacy.contactCollection) } threads.forEach { thread in thread.save(with: transaction) diff --git a/SignalUtilitiesKit/Database/ThreadViewHelper.h b/SignalUtilitiesKit/Database/ThreadViewHelper.h index d6d98b638..9f6f9c11c 100644 --- a/SignalUtilitiesKit/Database/ThreadViewHelper.h +++ b/SignalUtilitiesKit/Database/ThreadViewHelper.h @@ -1,30 +1,30 @@ +//// +//// Copyright (c) 2017 Open Whisper Systems. All rights reserved. +//// // -// Copyright (c) 2017 Open Whisper Systems. All rights reserved. +//NS_ASSUME_NONNULL_BEGIN // - -NS_ASSUME_NONNULL_BEGIN - -@protocol ThreadViewHelperDelegate - -- (void)threadListDidChange; - -@end - -#pragma mark - - -@class TSThread; - -// A helper class for views that want to present the list of threads -// that show up in home view, and in the same order. +//@protocol ThreadViewHelperDelegate // -// It observes changes to the threads & their ordering and informs -// its delegate when they happen. -@interface ThreadViewHelper : NSObject - -@property (nonatomic, weak) id delegate; - -@property (nonatomic, readonly) NSMutableArray *threads; - -@end - -NS_ASSUME_NONNULL_END +//- (void)threadListDidChange; +// +//@end +// +//#pragma mark - +// +//@class TSThread; +// +//// A helper class for views that want to present the list of threads +//// that show up in home view, and in the same order. +//// +//// It observes changes to the threads & their ordering and informs +//// its delegate when they happen. +//@interface ThreadViewHelper : NSObject +// +//@property (nonatomic, weak) id delegate; +// +//@property (nonatomic, readonly) NSMutableArray *threads; +// +//@end +// +//NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Database/ThreadViewHelper.m b/SignalUtilitiesKit/Database/ThreadViewHelper.m index ae9e317b5..1ab0d1699 100644 --- a/SignalUtilitiesKit/Database/ThreadViewHelper.m +++ b/SignalUtilitiesKit/Database/ThreadViewHelper.m @@ -1,220 +1,220 @@ +//// +//// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +//// // -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +//#import "ThreadViewHelper.h" +//#import +//#import +//#import +//#import +//#import +//#import +//#import +//#import +//#import // - -#import "ThreadViewHelper.h" -#import -#import -#import -#import -#import -#import -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface ThreadViewHelper () - -@property (nonatomic) YapDatabaseConnection *uiDatabaseConnection; -@property (nonatomic) YapDatabaseViewMappings *threadMappings; -@property (nonatomic) BOOL shouldObserveDBModifications; - -@end - -#pragma mark - - -@implementation ThreadViewHelper - -- (instancetype)init -{ - self = [super init]; - if (!self) { - return self; - } - - [self initializeMapping]; - - return self; -} - -- (void)dealloc -{ - [[NSNotificationCenter defaultCenter] removeObserver:self]; -} - -- (void)initializeMapping -{ - OWSAssertIsOnMainThread(); - - NSString *grouping = TSInboxGroup; - - self.threadMappings = - [[YapDatabaseViewMappings alloc] initWithGroups:@[ grouping ] view:TSThreadDatabaseViewExtensionName]; - [self.threadMappings setIsReversed:YES forGroup:grouping]; - - self.uiDatabaseConnection = [OWSPrimaryStorage.sharedManager newDatabaseConnection]; - [self.uiDatabaseConnection beginLongLivedReadTransaction]; - - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(applicationDidBecomeActive:) - name:OWSApplicationDidBecomeActiveNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(applicationWillResignActive:) - name:OWSApplicationWillResignActiveNotification - object:nil]; - - [self updateShouldObserveDBModifications]; -} - -- (void)applicationDidBecomeActive:(NSNotification *)notification -{ - [self updateShouldObserveDBModifications]; -} - -- (void)applicationWillResignActive:(NSNotification *)notification -{ - [self updateShouldObserveDBModifications]; -} - -- (void)updateShouldObserveDBModifications -{ - self.shouldObserveDBModifications = CurrentAppContext().isAppForegroundAndActive; -} - -// Don't observe database change notifications when the app is in the background. +//NS_ASSUME_NONNULL_BEGIN // -// Instead, rebuild model state when app enters foreground. -- (void)setShouldObserveDBModifications:(BOOL)shouldObserveDBModifications -{ - if (_shouldObserveDBModifications == shouldObserveDBModifications) { - return; - } - - _shouldObserveDBModifications = shouldObserveDBModifications; - - if (shouldObserveDBModifications) { - [self.uiDatabaseConnection beginLongLivedReadTransaction]; - [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - [self.threadMappings updateWithTransaction:transaction]; - }]; - [self updateThreads]; - [self.delegate threadListDidChange]; - - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(yapDatabaseModified:) - name:YapDatabaseModifiedNotification - object:OWSPrimaryStorage.sharedManager.dbNotificationObject]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(yapDatabaseModifiedExternally:) - name:YapDatabaseModifiedExternallyNotification - object:nil]; - } else { - [[NSNotificationCenter defaultCenter] removeObserver:self - name:YapDatabaseModifiedNotification - object:OWSPrimaryStorage.sharedManager.dbNotificationObject]; - [[NSNotificationCenter defaultCenter] removeObserver:self - name:YapDatabaseModifiedExternallyNotification - object:nil]; - } -} - -#pragma mark - Database - -- (YapDatabaseConnection *)uiDatabaseConnection -{ - OWSAssertIsOnMainThread(); - - return _uiDatabaseConnection; -} - -- (void)yapDatabaseModifiedExternally:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - - OWSLogVerbose(@""); - - if (self.shouldObserveDBModifications) { - // External database modifications can't be converted into incremental updates, - // so rebuild everything. This is expensive and usually isn't necessary, but - // there's no alternative. - // - // We don't need to do this if we're not observing db modifications since we'll - // do it when we resume. - [self.uiDatabaseConnection beginLongLivedReadTransaction]; - [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - [self.threadMappings updateWithTransaction:transaction]; - }]; - - [self updateThreads]; - [self.delegate threadListDidChange]; - } -} - -- (void)yapDatabaseModified:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - - OWSLogVerbose(@""); - - NSArray *notifications = [self.uiDatabaseConnection beginLongLivedReadTransaction]; - - if (! - [[self.uiDatabaseConnection ext:TSMessageDatabaseViewExtensionName] hasChangesForNotifications:notifications]) { - [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - [self.threadMappings updateWithTransaction:transaction]; - }]; - return; - } - - NSArray *sectionChanges = nil; - NSArray *rowChanges = nil; - [[self.uiDatabaseConnection ext:TSThreadDatabaseViewExtensionName] getSectionChanges:§ionChanges - rowChanges:&rowChanges - forNotifications:notifications - withMappings:self.threadMappings]; - - if (sectionChanges.count == 0 && rowChanges.count == 0) { - // Ignore irrelevant modifications. - return; - } - - [self updateThreads]; - - [self.delegate threadListDidChange]; -} - -- (void)updateThreads -{ - OWSAssertIsOnMainThread(); - - NSMutableArray *threads = [NSMutableArray new]; - [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - NSUInteger numberOfSections = [self.threadMappings numberOfSections]; - OWSAssertDebug(numberOfSections == 1); - for (NSUInteger section = 0; section < numberOfSections; section++) { - NSUInteger numberOfItems = [self.threadMappings numberOfItemsInSection:section]; - for (NSUInteger item = 0; item < numberOfItems; item++) { - TSThread *thread = [[transaction extension:TSThreadDatabaseViewExtensionName] - objectAtIndexPath:[NSIndexPath indexPathForItem:(NSInteger)item inSection:(NSInteger)section] - withMappings:self.threadMappings]; - if (!thread.shouldBeVisible) { continue; } - if ([thread isKindOfClass:TSContactThread.class]) { - NSString *publicKey = ((TSContactThread *)thread).contactSessionID; - if ([[LKStorage.shared getContactWithSessionID:publicKey] name] == nil) { continue; } - [threads addObject:thread]; - } else { - [threads addObject:thread]; - } - } - } - }]; - - _threads = [threads copy]; -} - -@end - -NS_ASSUME_NONNULL_END +//@interface ThreadViewHelper () +// +//@property (nonatomic) YapDatabaseConnection *uiDatabaseConnection; +//@property (nonatomic) YapDatabaseViewMappings *threadMappings; +//@property (nonatomic) BOOL shouldObserveDBModifications; +// +//@end +// +//#pragma mark - +// +//@implementation ThreadViewHelper +// +//- (instancetype)init +//{ +// self = [super init]; +// if (!self) { +// return self; +// } +// +// [self initializeMapping]; +// +// return self; +//} +// +//- (void)dealloc +//{ +// [[NSNotificationCenter defaultCenter] removeObserver:self]; +//} +// +//- (void)initializeMapping +//{ +// OWSAssertIsOnMainThread(); +// +// NSString *grouping = TSInboxGroup; +// +// self.threadMappings = +// [[YapDatabaseViewMappings alloc] initWithGroups:@[ grouping ] view:TSThreadDatabaseViewExtensionName]; +// [self.threadMappings setIsReversed:YES forGroup:grouping]; +// +// self.uiDatabaseConnection = [OWSPrimaryStorage.sharedManager newDatabaseConnection]; +// [self.uiDatabaseConnection beginLongLivedReadTransaction]; +// +// [[NSNotificationCenter defaultCenter] addObserver:self +// selector:@selector(applicationDidBecomeActive:) +// name:OWSApplicationDidBecomeActiveNotification +// object:nil]; +// [[NSNotificationCenter defaultCenter] addObserver:self +// selector:@selector(applicationWillResignActive:) +// name:OWSApplicationWillResignActiveNotification +// object:nil]; +// +// [self updateShouldObserveDBModifications]; +//} +// +//- (void)applicationDidBecomeActive:(NSNotification *)notification +//{ +// [self updateShouldObserveDBModifications]; +//} +// +//- (void)applicationWillResignActive:(NSNotification *)notification +//{ +// [self updateShouldObserveDBModifications]; +//} +// +//- (void)updateShouldObserveDBModifications +//{ +// self.shouldObserveDBModifications = CurrentAppContext().isAppForegroundAndActive; +//} +// +//// Don't observe database change notifications when the app is in the background. +//// +//// Instead, rebuild model state when app enters foreground. +//- (void)setShouldObserveDBModifications:(BOOL)shouldObserveDBModifications +//{ +// if (_shouldObserveDBModifications == shouldObserveDBModifications) { +// return; +// } +// +// _shouldObserveDBModifications = shouldObserveDBModifications; +// +// if (shouldObserveDBModifications) { +// [self.uiDatabaseConnection beginLongLivedReadTransaction]; +// [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { +// [self.threadMappings updateWithTransaction:transaction]; +// }]; +// [self updateThreads]; +// [self.delegate threadListDidChange]; +// +// [[NSNotificationCenter defaultCenter] addObserver:self +// selector:@selector(yapDatabaseModified:) +// name:YapDatabaseModifiedNotification +// object:OWSPrimaryStorage.sharedManager.dbNotificationObject]; +// [[NSNotificationCenter defaultCenter] addObserver:self +// selector:@selector(yapDatabaseModifiedExternally:) +// name:YapDatabaseModifiedExternallyNotification +// object:nil]; +// } else { +// [[NSNotificationCenter defaultCenter] removeObserver:self +// name:YapDatabaseModifiedNotification +// object:OWSPrimaryStorage.sharedManager.dbNotificationObject]; +// [[NSNotificationCenter defaultCenter] removeObserver:self +// name:YapDatabaseModifiedExternallyNotification +// object:nil]; +// } +//} +// +//#pragma mark - Database +// +//- (YapDatabaseConnection *)uiDatabaseConnection +//{ +// OWSAssertIsOnMainThread(); +// +// return _uiDatabaseConnection; +//} +// +//- (void)yapDatabaseModifiedExternally:(NSNotification *)notification +//{ +// OWSAssertIsOnMainThread(); +// +// OWSLogVerbose(@""); +// +// if (self.shouldObserveDBModifications) { +// // External database modifications can't be converted into incremental updates, +// // so rebuild everything. This is expensive and usually isn't necessary, but +// // there's no alternative. +// // +// // We don't need to do this if we're not observing db modifications since we'll +// // do it when we resume. +// [self.uiDatabaseConnection beginLongLivedReadTransaction]; +// [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { +// [self.threadMappings updateWithTransaction:transaction]; +// }]; +// +// [self updateThreads]; +// [self.delegate threadListDidChange]; +// } +//} +// +//- (void)yapDatabaseModified:(NSNotification *)notification +//{ +// OWSAssertIsOnMainThread(); +// +// OWSLogVerbose(@""); +// +// NSArray *notifications = [self.uiDatabaseConnection beginLongLivedReadTransaction]; +// +// if (! +// [[self.uiDatabaseConnection ext:TSMessageDatabaseViewExtensionName] hasChangesForNotifications:notifications]) { +// [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { +// [self.threadMappings updateWithTransaction:transaction]; +// }]; +// return; +// } +// +// NSArray *sectionChanges = nil; +// NSArray *rowChanges = nil; +// [[self.uiDatabaseConnection ext:TSThreadDatabaseViewExtensionName] getSectionChanges:§ionChanges +// rowChanges:&rowChanges +// forNotifications:notifications +// withMappings:self.threadMappings]; +// +// if (sectionChanges.count == 0 && rowChanges.count == 0) { +// // Ignore irrelevant modifications. +// return; +// } +// +// [self updateThreads]; +// +// [self.delegate threadListDidChange]; +//} +// +//- (void)updateThreads +//{ +// OWSAssertIsOnMainThread(); +// +// NSMutableArray *threads = [NSMutableArray new]; +// [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { +// NSUInteger numberOfSections = [self.threadMappings numberOfSections]; +// OWSAssertDebug(numberOfSections == 1); +// for (NSUInteger section = 0; section < numberOfSections; section++) { +// NSUInteger numberOfItems = [self.threadMappings numberOfItemsInSection:section]; +// for (NSUInteger item = 0; item < numberOfItems; item++) { +// TSThread *thread = [[transaction extension:TSThreadDatabaseViewExtensionName] +// objectAtIndexPath:[NSIndexPath indexPathForItem:(NSInteger)item inSection:(NSInteger)section] +// withMappings:self.threadMappings]; +// if (!thread.shouldBeVisible) { continue; } +// if ([thread isKindOfClass:TSContactThread.class]) { +// NSString *publicKey = ((TSContactThread *)thread).contactSessionID; +// if ([[LKStorage.shared getContactWithSessionID:publicKey] name] == nil) { continue; } +// [threads addObject:thread]; +// } else { +// [threads addObject:thread]; +// } +// } +// } +// }]; +// +// _threads = [threads copy]; +//} +// +//@end +// +//NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Media Viewing & Editing/MessageApprovalViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/MessageApprovalViewController.swift index 5cea0fcd6..e3888fb43 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/MessageApprovalViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/MessageApprovalViewController.swift @@ -163,8 +163,7 @@ public class MessageApprovalViewController: OWSViewController, UITextViewDelegat return recipientRow } - let publicKey = contactThread.contactSessionID() - nameLabel.text = Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey + nameLabel.text = Profile.displayName(for: contactThread.contactSessionID()) nameLabel.textColor = Colors.text if let profileName = self.profileName(contactThread: contactThread) { @@ -189,8 +188,7 @@ public class MessageApprovalViewController: OWSViewController, UITextViewDelegat } private func profileName(contactThread: TSContactThread) -> String? { - let publicKey = contactThread.contactSessionID() - return Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey + return Profile.displayName(for: contactThread.contactSessionID()) } // MARK: - Event Handlers diff --git a/SignalUtilitiesKit/Messaging/BlockListUIUtils.swift b/SignalUtilitiesKit/Messaging/BlockListUIUtils.swift index d933bc53e..5ac73dec3 100644 --- a/SignalUtilitiesKit/Messaging/BlockListUIUtils.swift +++ b/SignalUtilitiesKit/Messaging/BlockListUIUtils.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import GRDB import SessionMessagingKit @objc public class BlockListUIUtils: NSObject { @@ -12,12 +13,12 @@ import SessionMessagingKit @objc public static func showBlockThreadActionSheet(_ thread: TSContactThread, from viewController: UIViewController, completionBlock: ((Bool) -> ())? = nil) { let userPublicKey = getUserHexEncodedPublicKey() - guard thread.contactSessionID() != userPublicKey, let contact: Contact = Storage.shared.getContact(with: thread.contactSessionID()) else { + guard thread.contactSessionID() != userPublicKey else { completionBlock?(false) return } - let displayName: String = (contact.displayName(for: .regular) ?? thread.contactSessionID()) + let displayName: String = Profile.displayName(for: thread.contactSessionID()) let actionSheet: UIAlertController = UIAlertController( title: String( format: "BLOCK_LIST_BLOCK_USER_TITLE_FORMAT".localized(), @@ -31,12 +32,14 @@ import SessionMessagingKit accessibilityIdentifier: "\(type(of: self).self).block", style: .destructive, handler: { _ in - Storage.write( - with: { transaction in - contact.isBlocked = true - Storage.shared.setContact(contact, using: transaction) + GRDBStorage.shared.writeAsync( + updates: { db in + try? Contact + .fetchOrCreate(db, id: thread.contactSessionID()) + .with(isBlocked: true) + .save(db) }, - completion: { + completion: { _, _ in self.showOkAlert( title: "BLOCK_LIST_VIEW_BLOCKED_ALERT_TITLE".localized(), message: String( @@ -46,7 +49,8 @@ import SessionMessagingKit from: viewController, completionBlock: { _ in completionBlock?(true) } ) - }) + } + ) } )) actionSheet.addAction(UIAlertAction( @@ -65,12 +69,7 @@ import SessionMessagingKit /// /// **Note:** Make sure to force a config sync in the `completionBlock` if the blocked state was successfully changed @objc public static func showUnblockThreadActionSheet(_ thread: TSContactThread, from viewController: UIViewController, completionBlock: ((Bool) -> ())? = nil) { - guard let contact: Contact = Storage.shared.getContact(with: thread.contactSessionID()) else { - completionBlock?(true) - return - } - - let displayName: String = (contact.displayName(for: .regular) ?? thread.contactSessionID()) + let displayName: String = Profile.displayName(for: thread.contactSessionID()) let actionSheet: UIAlertController = UIAlertController( title: String( format: "BLOCK_LIST_UNBLOCK_TITLE_FORMAT".localized(), @@ -84,12 +83,14 @@ import SessionMessagingKit accessibilityIdentifier: "\(type(of: self).self).unblock", style: .destructive, handler: { _ in - Storage.write( - with: { transaction in - contact.isBlocked = false - Storage.shared.setContact(contact, using: transaction) + GRDBStorage.shared.writeAsync( + updates: { db in + try? Contact + .fetchOrCreate(db, id: thread.contactSessionID()) + .with(isBlocked: false) + .save(db) }, - completion: { + completion: { _, _ in self.showOkAlert( title: String( format: "BLOCK_LIST_VIEW_UNBLOCKED_ALERT_TITLE_FORMAT".localized(), diff --git a/SignalUtilitiesKit/Messaging/FullTextSearcher.swift b/SignalUtilitiesKit/Messaging/FullTextSearcher.swift index 8efe00335..0686a153b 100644 --- a/SignalUtilitiesKit/Messaging/FullTextSearcher.swift +++ b/SignalUtilitiesKit/Messaging/FullTextSearcher.swift @@ -3,7 +3,7 @@ // import Foundation - +import SessionMessagingKit public typealias MessageSortKey = UInt64 public struct ConversationSortKey: Comparable { @@ -395,7 +395,6 @@ public class FullTextSearcher: NSObject { } private func indexingString(recipientId: String) -> String { - let profileName = Storage.shared.getContact(with: recipientId)?.name - return "\(recipientId) \(profileName ?? "")" + return "\(recipientId) \(Profile.fetchOrCreate(id: recipientId).name)" } } diff --git a/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift b/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift index 50f678b5c..5f2e4e146 100644 --- a/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift +++ b/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift @@ -1,3 +1,7 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB import PromiseKit import SessionUtilitiesKit @@ -106,23 +110,31 @@ extension MessageSender { return promise } - public static func syncConfiguration(forceSyncNow: Bool = true) -> Promise { - let (promise, seal) = Promise.pending() - let destination: Message.Destination = Message.Destination.contact(publicKey: getUserHexEncodedPublicKey()) + /// This method requires the `db` value to be passed in because if it's called within a `writeAsync` completion block + /// it will throw a "re-entrant" fatal error when attempting to write again + public static func syncConfiguration(_ db: Database, forceSyncNow: Bool = true) -> Promise { + // If we don't have a userKeyPair yet then there is no need to sync the configuration + // as the user doesn't exist yet (this will get triggered on the first launch of a + // fresh install due to the migrations getting run) + guard Identity.userExists(db) else { + return Promise(error: GRDBStorageError.generic) + } + + let destination: Message.Destination = Message.Destination.contact(publicKey: getUserHexEncodedPublicKey(db)) + + guard let configurationMessage = try? ConfigurationMessage.getCurrent(db) else { + return Promise(error: GRDBStorageError.generic) + } + + let (promise, seal) = Promise.pending() - // Note: SQLite only supports a single write thread so we can be sure this will retrieve the most up-to-date data Storage.writeSync { transaction in - guard Storage.shared.getUser(using: transaction)?.name != nil, let configurationMessage = ConfigurationMessage.getCurrent(with: transaction) else { - seal.fulfill(()) - return - } - if forceSyncNow { - MessageSender.send(configurationMessage, to: destination, using: transaction).done { - seal.fulfill(()) - }.catch { _ in - seal.fulfill(()) // Fulfill even if this failed; the configuration in the swarm should be at most 2 days old - }.retainUntilComplete() + MessageSender + .send(configurationMessage, to: destination, using: transaction) + .done { seal.fulfill(()) } + .catch { _ in seal.reject(GRDBStorageError.generic) } + .retainUntilComplete() } else { let job = MessageSendJob(message: configurationMessage, destination: destination) @@ -138,6 +150,8 @@ extension MessageSender { extension MessageSender { @objc(forceSyncConfigurationNow) public static func objc_forceSyncConfigurationNow() { - return syncConfiguration(forceSyncNow: true).retainUntilComplete() + GRDBStorage.shared.write { db in + syncConfiguration(db, forceSyncNow: true).retainUntilComplete() + } } } diff --git a/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h b/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h index 95bd1d014..aa3215500 100644 --- a/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h +++ b/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h @@ -43,7 +43,6 @@ FOUNDATION_EXPORT const unsigned char SignalUtilitiesKitVersionString[]; #import #import #import -#import #import #import #import diff --git a/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift b/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift index e60c6fe5e..1d6530b7e 100644 --- a/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift +++ b/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift @@ -90,16 +90,18 @@ public final class ProfilePictureView : UIView { AssertIsOnMainThread() func getProfilePicture(of size: CGFloat, for publicKey: String) -> UIImage? { guard !publicKey.isEmpty else { return nil } - if let profilePicture = OWSProfileManager.shared().profileAvatar(forRecipientId: publicKey) { + + if let profilePicture: UIImage = ProfileManager.profileAvatar(for: publicKey) { hasTappableProfilePicture = true return profilePicture - } else { - hasTappableProfilePicture = false - // TODO: Pass in context? - let displayName = Storage.shared.getContact(with: publicKey)?.name ?? publicKey - return Identicon.generatePlaceholderIcon(seed: publicKey, text: displayName, size: size) } + + hasTappableProfilePicture = false + // TODO: Pass in context? + let displayName: String = Profile.displayName(for: publicKey) + return Identicon.generatePlaceholderIcon(seed: publicKey, text: displayName, size: size) } + let size: CGFloat if let additionalPublicKey = additionalPublicKey, !useFallbackPicture, openGroupProfilePicture == nil { if self.size == 40 { diff --git a/SignalUtilitiesKit/To Do/ContactCellView.m b/SignalUtilitiesKit/To Do/ContactCellView.m index 8ebbbbf69..5f6140bdf 100644 --- a/SignalUtilitiesKit/To Do/ContactCellView.m +++ b/SignalUtilitiesKit/To Do/ContactCellView.m @@ -141,13 +141,12 @@ const CGFloat kContactCellAvatarTextMargin = 12; NSFontAttributeName : self.nameLabel.font, }]; } else { - SNContactContext context = [SNContact contextForThread:self.thread]; - self.nameLabel.text = [[LKStorage.shared getContactWithSessionID:recipientId] displayNameFor:context] ?: recipientId; + self.nameLabel.text = [SMKProfile displayNameWithId:recipientId thread:self.thread]; } [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(otherUsersProfileDidChange:) - name:kNSNotificationName_OtherUsersProfileDidChange + name:NSNotification.otherUsersProfileDidChange object:nil]; [self updateProfileName]; [self updateAvatar]; @@ -185,7 +184,7 @@ const CGFloat kContactCellAvatarTextMargin = 12; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(otherUsersProfileDidChange:) - name:kNSNotificationName_OtherUsersProfileDidChange + name:NSNotification.otherUsersProfileDidChange object:nil]; [self updateProfileName]; } else { @@ -218,11 +217,7 @@ const CGFloat kContactCellAvatarTextMargin = 12; - (void)updateProfileName { - NSString *publicKey = self.recipientId; - NSString *threadID = self.thread.uniqueId; - SNContactContext context = [SNContact contextForThread:self.thread]; - NSString *displayName = [[LKStorage.shared getContactWithSessionID:publicKey] displayNameFor:context] ?: publicKey; - self.nameLabel.text = displayName; + self.nameLabel.text = [SMKProfile displayNameWithId:self.recipientId thread:self.thread]; [self.nameLabel setNeedsLayout]; } @@ -245,7 +240,7 @@ const CGFloat kContactCellAvatarTextMargin = 12; { OWSAssertIsOnMainThread(); - NSString *recipientId = notification.userInfo[kNSNotificationKey_ProfileRecipientId]; + NSString *recipientId = notification.userInfo[NSNotification.profileRecipientIdKey]; OWSAssertDebug(recipientId.length > 0); if (recipientId.length > 0 && [self.recipientId isEqualToString:recipientId]) { diff --git a/SignalUtilitiesKit/To Do/OWSProfileManager.h b/SignalUtilitiesKit/To Do/OWSProfileManager.h index d584604ec..75249838e 100644 --- a/SignalUtilitiesKit/To Do/OWSProfileManager.h +++ b/SignalUtilitiesKit/To Do/OWSProfileManager.h @@ -1,65 +1,65 @@ +//// +//// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +//// // -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +//#import // - -#import - -NS_ASSUME_NONNULL_BEGIN - -extern const NSUInteger kOWSProfileManager_NameDataLength; -extern const NSUInteger kOWSProfileManager_MaxAvatarDiameter; - -@class OWSAES256Key; -@class OWSMessageSender; -@class OWSPrimaryStorage; -@class TSNetworkManager; -@class TSThread; -@class YapDatabaseReadWriteTransaction; - -// This class can be safely accessed and used from any thread. -@interface OWSProfileManager : NSObject - -- (instancetype)init NS_UNAVAILABLE; - -- (instancetype)initWithPrimaryStorage:(OWSPrimaryStorage *)primaryStorage; - -+ (instancetype)sharedManager; - -#pragma mark - Local Profile - -// localUserProfileExists is true if there is _ANY_ local profile. -- (BOOL)localProfileExists; -// hasLocalProfile is true if there is a local profile with a name or avatar. -- (BOOL)hasLocalProfile; - -// This method is used to update the "local profile" state on the client -// and the service. Client state is only updated if service state is -// successfully updated. +//NS_ASSUME_NONNULL_BEGIN // -// This method should only be called from the main thread. -- (void)updateLocalProfileName:(nullable NSString *)profileName - avatarImage:(nullable UIImage *)avatarImage - success:(void (^)(void))successBlock - failure:(void (^)(NSError *))failureBlock - requiresSync:(BOOL)requiresSync; - -- (BOOL)isProfileNameTooLong:(nullable NSString *)profileName; - -- (void)regenerateLocalProfile; - -#pragma mark - Other Users' Profiles - -- (nullable UIImage *)profileAvatarForRecipientId:(NSString *)recipientId; -- (nullable NSData *)profileAvatarDataForRecipientId:(NSString *)recipientId; - -- (void)updateProfileForRecipientId:(NSString *)recipientId - profileNameEncrypted:(nullable NSData *)profileNameEncrypted - avatarUrlPath:(nullable NSString *)avatarUrlPath; - -#pragma mark - Other - -- (void)downloadAvatarForUserProfile:(SNContact *)contact; - -@end - -NS_ASSUME_NONNULL_END +//extern const NSUInteger kOWSProfileManager_NameDataLength; +//extern const NSUInteger kOWSProfileManager_MaxAvatarDiameter; +// +//@class OWSAES256Key; +//@class OWSMessageSender; +//@class OWSPrimaryStorage; +//@class TSNetworkManager; +//@class TSThread; +//@class YapDatabaseReadWriteTransaction; +// +//// This class can be safely accessed and used from any thread. +//@interface OWSProfileManager : NSObject +// +//- (instancetype)init NS_UNAVAILABLE; +// +//- (instancetype)initWithPrimaryStorage:(OWSPrimaryStorage *)primaryStorage; +// +//+ (instancetype)sharedManager; +// +//#pragma mark - Local Profile +// +//// localUserProfileExists is true if there is _ANY_ local profile. +//- (BOOL)localProfileExists; +//// hasLocalProfile is true if there is a local profile with a name or avatar. +//- (BOOL)hasLocalProfile; +// +//// This method is used to update the "local profile" state on the client +//// and the service. Client state is only updated if service state is +//// successfully updated. +//// +//// This method should only be called from the main thread. +//- (void)updateLocalProfileName:(nullable NSString *)profileName +// avatarImage:(nullable UIImage *)avatarImage +// success:(void (^)(void))successBlock +// failure:(void (^)(NSError *))failureBlock +// requiresSync:(BOOL)requiresSync; +// +//- (BOOL)isProfileNameTooLong:(nullable NSString *)profileName; +// +//- (void)regenerateLocalProfile; +// +//#pragma mark - Other Users' Profiles +// +//- (nullable UIImage *)profileAvatarForRecipientId:(NSString *)recipientId; +//- (nullable NSData *)profileAvatarDataForRecipientId:(NSString *)recipientId; +// +//- (void)updateProfileForRecipientId:(NSString *)recipientId +// profileNameEncrypted:(nullable NSData *)profileNameEncrypted +// avatarUrlPath:(nullable NSString *)avatarUrlPath; +// +//#pragma mark - Other +// +//- (void)downloadAvatarForUserProfile:(SNContact *)contact; +// +//@end +// +//NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/To Do/OWSProfileManager.m b/SignalUtilitiesKit/To Do/OWSProfileManager.m index 9208cca03..fe3233d3c 100644 --- a/SignalUtilitiesKit/To Do/OWSProfileManager.m +++ b/SignalUtilitiesKit/To Do/OWSProfileManager.m @@ -1,736 +1,736 @@ +//// +//// Copyright (c) 2019 Open Whisper Systems. All rights reserved. +//// // -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. +//#import "OWSProfileManager.h" +//#import "Environment.h" +//#import "OWSUserProfile.h" +//#import +//#import +//#import "UIUtil.h" +//#import +//#import +//#import +//#import +//#import +//#import +//#import +//#import +//#import +//#import +//#import +//#import +//#import +//#import // - -#import "OWSProfileManager.h" -#import "Environment.h" -#import "OWSUserProfile.h" -#import -#import -#import "UIUtil.h" -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -// The max bytes for a user's profile name, encoded in UTF8. -// Before encrypting and submitting we NULL pad the name data to this length. -const NSUInteger kOWSProfileManager_NameDataLength = 26; -const NSUInteger kOWSProfileManager_MaxAvatarDiameter = 640; - -typedef void (^ProfileManagerFailureBlock)(NSError *error); - -@interface OWSProfileManager () - -@property (nonatomic, readonly) YapDatabaseConnection *dbConnection; - -// This property can be accessed on any thread, while synchronized on self. -@property (atomic, readonly) NSCache *profileAvatarImageCache; - -// This property can be accessed on any thread, while synchronized on self. -@property (atomic, readonly) NSMutableSet *currentAvatarDownloads; - -@end - -#pragma mark - - -// Access to most state should happen while synchronized on the profile manager. -// Writes should happen off the main thread, wherever possible. -@implementation OWSProfileManager - -+ (instancetype)sharedManager -{ - return SSKEnvironment.shared.profileManager; -} - -- (instancetype)initWithPrimaryStorage:(OWSPrimaryStorage *)primaryStorage -{ - self = [super init]; - - if (!self) { - return self; - } - - OWSAssertIsOnMainThread(); - OWSAssertDebug(primaryStorage); - - _dbConnection = primaryStorage.newDatabaseConnection; - - _profileAvatarImageCache = [NSCache new]; - _currentAvatarDownloads = [NSMutableSet new]; - - OWSSingletonAssert(); - - return self; -} - -- (void)dealloc -{ - [[NSNotificationCenter defaultCenter] removeObserver:self]; -} - -#pragma mark - Dependencies - -- (TSAccountManager *)tsAccountManager -{ - return TSAccountManager.sharedInstance; -} - -- (OWSIdentityManager *)identityManager -{ - return SSKEnvironment.shared.identityManager; -} - -- (void)updateLocalProfileName:(nullable NSString *)profileName - avatarImage:(nullable UIImage *)avatarImage - success:(void (^)(void))successBlockParameter - failure:(void (^)(NSError *))failureBlockParameter - requiresSync:(BOOL)requiresSync -{ - OWSAssertDebug(successBlockParameter); - OWSAssertDebug(failureBlockParameter); - - // Ensure that the success and failure blocks are called on the main thread. - void (^failureBlock)(NSError *) = ^(NSError *error) { - OWSLogError(@"Updating service with profile failed."); - - dispatch_async(dispatch_get_main_queue(), ^{ - failureBlockParameter(error); - }); - }; - void (^successBlock)(void) = ^{ - OWSLogInfo(@"Successfully updated service with profile."); - - dispatch_async(dispatch_get_main_queue(), ^{ - successBlockParameter(); - }); - }; - - // The final steps are to: - // - // * Try to update the service. - // * Update client state on success. - void (^tryToUpdateService)(NSString *_Nullable, NSString *_Nullable) = ^( - NSString *_Nullable avatarUrlPath, NSString *_Nullable avatarFileName) { - [self updateServiceWithProfileName:profileName - avatarUrl:avatarUrlPath - success:^{ - SNContact *userProfile = [LKStorage.shared getUser]; - OWSAssertDebug(userProfile); - - userProfile.name = profileName; - userProfile.profilePictureURL = avatarUrlPath; - userProfile.profilePictureFileName = avatarFileName; - - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [LKStorage.shared setContact:userProfile usingTransaction:transaction]; - } completion:^{ - if (avatarFileName != nil) { - [self updateProfileAvatarCache:avatarImage filename:avatarFileName]; - } - - successBlock(); - }]; - } - failure:^(NSError *error) { - failureBlock(error); - }]; - }; - - SNContact *userProfile = [LKStorage.shared getUser]; - OWSAssertDebug(userProfile); - - if (avatarImage) { - // If we have a new avatar image, we must first: - // - // * Encode it to JPEG. - // * Write it to disk. - // * Encrypt it - // * Upload it to asset service - // * Send asset service info to Signal Service - OWSLogVerbose(@"Updating local profile on service with new avatar."); - [self writeAvatarToDisk:avatarImage - success:^(NSData *data, NSString *fileName) { - [self uploadAvatarToService:data - success:^(NSString *_Nullable avatarUrlPath) { - tryToUpdateService(avatarUrlPath, fileName); - } - failure:^(NSError *error) { - failureBlock(error); - }]; - } - failure:^(NSError *error) { - failureBlock(error); - }]; - } else if (userProfile.profilePictureURL) { - OWSLogVerbose(@"Updating local profile on service with cleared avatar."); - [self uploadAvatarToService:nil - success:^(NSString *_Nullable avatarUrlPath) { - tryToUpdateService(nil, nil); - } - failure:^(NSError *error) { - failureBlock(error); - }]; - } else { - OWSLogVerbose(@"Updating local profile on service with no avatar."); - tryToUpdateService(nil, nil); - } -} - -- (void)writeAvatarToDisk:(UIImage *)avatar - success:(void (^)(NSData *data, NSString *fileName))successBlock - failure:(ProfileManagerFailureBlock)failureBlock { - OWSAssertDebug(avatar); - OWSAssertDebug(successBlock); - OWSAssertDebug(failureBlock); - - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - if (avatar) { - NSData *data = [self processedImageDataForRawAvatar:avatar]; - OWSAssertDebug(data); - if (data) { - NSString *fileName = [self generateAvatarFilename]; - NSString *filePath = [OWSUserProfile profileAvatarFilepathWithFilename:fileName]; - BOOL success = [data writeToFile:filePath atomically:YES]; - OWSAssertDebug(success); - if (success) { - return successBlock(data, fileName); - } - } - } - failureBlock(OWSErrorWithCodeDescription(OWSErrorCodeAvatarWriteFailed, @"Avatar write failed.")); - }); -} - -- (NSData *)processedImageDataForRawAvatar:(UIImage *)image -{ - NSUInteger kMaxAvatarBytes = 5 * 1000 * 1000; - - if (image.size.width != kOWSProfileManager_MaxAvatarDiameter - || image.size.height != kOWSProfileManager_MaxAvatarDiameter) { - // To help ensure the user is being shown the same cropping of their avatar as - // everyone else will see, we want to be sure that the image was resized before this point. - OWSFailDebug(@"Avatar image should have been resized before trying to upload"); - image = [image resizedImageToFillPixelSize:CGSizeMake(kOWSProfileManager_MaxAvatarDiameter, - kOWSProfileManager_MaxAvatarDiameter)]; - } - - NSData *_Nullable data = UIImageJPEGRepresentation(image, 0.95f); - if (data.length > kMaxAvatarBytes) { - // Our avatar dimensions are so small that it's incredibly unlikely we wouldn't be able to fit our profile - // photo. e.g. generating pure noise at our resolution compresses to ~200k. - OWSFailDebug(@"Suprised to find profile avatar was too large. Was it scaled properly? image: %@", image); - } - - return data; -} - -// If avatarData is nil, we are clearing the avatar. -- (void)uploadAvatarToService:(NSData *_Nullable)avatarData - success:(void (^)(NSString *_Nullable avatarUrlPath))successBlock - failure:(ProfileManagerFailureBlock)failureBlock { - OWSAssertDebug(successBlock); - OWSAssertDebug(failureBlock); - OWSAssertDebug(avatarData == nil || avatarData.length > 0); - - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - // We always want to encrypt a profile with a new profile key - // This ensures that other users know that our profile picture was updated - OWSAES256Key *newProfileKey = [OWSAES256Key generateRandomKey]; - - if (avatarData) { - NSData *encryptedAvatarData = [self encryptProfileData:avatarData profileKey:newProfileKey]; - OWSAssertDebug(encryptedAvatarData.length > 0); - - AnyPromise *promise = [SNFileServerAPIV2 upload:encryptedAvatarData]; - - [promise.thenOn(dispatch_get_main_queue(), ^(NSString *fileID) { - NSString *downloadURL = [NSString stringWithFormat:@"%@/files/%@", SNFileServerAPIV2.server, fileID]; - [NSUserDefaults.standardUserDefaults setObject:[NSDate new] forKey:@"lastProfilePictureUpload"]; - - SNContact *user = [LKStorage.shared getUser]; - user.profileEncryptionKey = newProfileKey; - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [LKStorage.shared setContact:user usingTransaction:transaction]; - } completion:^{ - successBlock(downloadURL); - }]; - }) - .catchOn(dispatch_get_main_queue(), ^(id result) { - // There appears to be a bug in PromiseKit that sometimes causes catchOn - // to be invoked with the fulfilled promise's value as the error. The below - // is a quick and dirty workaround. - if ([result isKindOfClass:NSString.class]) { - SNContact *user = [LKStorage.shared getUser]; - user.profileEncryptionKey = newProfileKey; - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [LKStorage.shared setContact:user usingTransaction:transaction]; - } completion:^{ - successBlock(result); - }]; - } else { - failureBlock(result); - } - }) retainUntilComplete]; - } else { - // Update our profile key and set the url to nil if avatar data is nil - SNContact *user = [LKStorage.shared getUser]; - user.profileEncryptionKey = newProfileKey; - user.profilePictureURL = nil; - user.profilePictureFileName = nil; - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [LKStorage.shared setContact:user usingTransaction:transaction]; - } completion:^{ - successBlock(nil); - }]; - } - }); -} - -- (void)updateServiceWithProfileName:(nullable NSString *)localProfileName - avatarUrl:(nullable NSString *)avatarURL - success:(void (^)(void))successBlock - failure:(ProfileManagerFailureBlock)failureBlock { - successBlock(); -} - -- (void)updateServiceWithProfileName:(nullable NSString *)localProfileName avatarURL:(nullable NSString *)avatarURL { - [self updateServiceWithProfileName:localProfileName avatarUrl:avatarURL success:^{} failure:^(NSError * _Nonnull error) {}]; -} - -#pragma mark - Profile Key Rotation - -- (nullable NSString *)groupKeyForGroupId:(NSData *)groupId { - NSString *groupIdKey = [groupId hexadecimalString]; - return groupIdKey; -} - -- (nullable NSData *)groupIdForGroupKey:(NSString *)groupKey { - NSMutableData *groupId = [NSMutableData new]; - - if (groupKey.length % 2 != 0) { - OWSFailDebug(@"Group key has unexpected length: %@ (%lu)", groupKey, (unsigned long)groupKey.length); - return nil; - } - for (NSUInteger i = 0; i + 2 <= groupKey.length; i += 2) { - NSString *_Nullable byteString = [groupKey substringWithRange:NSMakeRange(i, 2)]; - if (!byteString) { - OWSFailDebug(@"Couldn't slice group key."); - return nil; - } - unsigned byteValue; - if (![[NSScanner scannerWithString:byteString] scanHexInt:&byteValue]) { - OWSFailDebug(@"Couldn't parse hex byte: %@.", byteString); - return nil; - } - if (byteValue > 0xff) { - OWSFailDebug(@"Invalid hex byte: %@ (%d).", byteString, byteValue); - return nil; - } - uint8_t byte = (uint8_t)(0xff & byteValue); - [groupId appendBytes:&byte length:1]; - } - return [groupId copy]; -} - -- (void)regenerateLocalProfile -{ - NSString *userPublicKey = [SNGeneralUtilities getUserPublicKey]; - SNContact *contact = [LKStorage.shared getContactWithSessionID:userPublicKey]; - contact.profileEncryptionKey = [OWSAES256Key generateRandomKey]; - contact.profilePictureURL = nil; - contact.profilePictureFileName = nil; - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [LKStorage.shared setContact:contact usingTransaction:transaction]; - } completion:^{ - [[self.tsAccountManager updateAccountAttributes] retainUntilComplete]; - }]; -} - -#pragma mark - Other Users' Profiles - -- (void)setProfileKeyData:(NSData *)profileKeyData forRecipientId:(NSString *)recipientId avatarURL:(nullable NSString *)avatarURL -{ - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - OWSAES256Key *_Nullable profileKey = [OWSAES256Key keyWithData:profileKeyData]; - if (profileKey == nil) { - OWSFailDebug(@"Failed to make profile key for key data"); - return; - } - - SNContact *contact = [LKStorage.shared getContactWithSessionID:recipientId]; - - OWSAssertDebug(contact); - if (contact.profileEncryptionKey != nil && [contact.profileEncryptionKey.keyData isEqual:profileKey.keyData]) { - // Ignore redundant update. - return; - } - - contact.profileEncryptionKey = profileKey; - contact.profilePictureURL = nil; - contact.profilePictureFileName = nil; - - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [LKStorage.shared setContact:contact usingTransaction:transaction]; - } completion:^{ - contact.profilePictureURL = avatarURL; - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [LKStorage.shared setContact:contact usingTransaction:transaction]; - } completion:^{ - [self downloadAvatarForUserProfile:contact]; - }]; - }]; - }); -} - -- (void)setProfileKeyData:(NSData *)profileKeyData forRecipientId:(NSString *)recipientId -{ - [self setProfileKeyData:profileKeyData forRecipientId:recipientId avatarURL:nil]; -} - -- (nullable NSData *)profileKeyDataForRecipientId:(NSString *)recipientId -{ - return [self profileKeyForRecipientId:recipientId].keyData; -} - -- (nullable OWSAES256Key *)profileKeyForRecipientId:(NSString *)recipientId -{ - OWSAssertDebug(recipientId.length > 0); - - SNContact *contact = [LKStorage.shared getContactWithSessionID:recipientId]; - OWSAssertDebug(contact); - - return contact.profileEncryptionKey; -} - -- (nullable UIImage *)profileAvatarForRecipientId:(NSString *)recipientId -{ - OWSAssertDebug(recipientId.length > 0); - - SNContact *contact = [LKStorage.shared getContactWithSessionID:recipientId]; - - if (contact.profilePictureFileName != nil && contact.profilePictureFileName.length > 0) { - return [self loadProfileAvatarWithFilename:contact.profilePictureFileName]; - } - - if (contact.profilePictureURL != nil && contact.profilePictureURL.length > 0) { - [self downloadAvatarForUserProfile:contact]; - } - - return nil; -} - -- (nullable NSData *)profileAvatarDataForRecipientId:(NSString *)recipientId -{ - OWSAssertDebug(recipientId.length > 0); - - SNContact *contact = [LKStorage.shared getContactWithSessionID:recipientId]; - - if (contact.profilePictureFileName != nil && contact.profilePictureFileName.length > 0) { - return [self loadProfileDataWithFilename:contact.profilePictureFileName]; - } - - return nil; -} - -- (NSString *)generateAvatarFilename -{ - return [[NSUUID UUID].UUIDString stringByAppendingPathExtension:@"jpg"]; -} - -- (void)downloadAvatarForUserProfile:(SNContact *)contact -{ - OWSAssertDebug(contact); - - __block OWSBackgroundTask *backgroundTask = [OWSBackgroundTask backgroundTaskWithLabelStr:__PRETTY_FUNCTION__]; - - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - BOOL hasProfilePictureURL = (contact.profilePictureURL != nil && contact.profilePictureURL.length > 0); - if (!hasProfilePictureURL) { - OWSLogDebug(@"Skipping downloading avatar for %@ because url is not set", contact.sessionID); - return; - } - NSString *_Nullable avatarUrlPathAtStart = contact.profilePictureURL; - - BOOL hasProfileEncryptionKey = (contact.profileEncryptionKey != nil && contact.profileEncryptionKey.keyData.length > 0); - if (!hasProfileEncryptionKey || !hasProfilePictureURL) { - return; - } - - OWSAES256Key *profileKeyAtStart = contact.profileEncryptionKey; - - NSString *fileName = [self generateAvatarFilename]; - NSString *filePath = [OWSUserProfile profileAvatarFilepathWithFilename:fileName]; - - @synchronized(self.currentAvatarDownloads) - { - if ([self.currentAvatarDownloads containsObject:contact.sessionID]) { - // Download already in flight; ignore. - return; - } - [self.currentAvatarDownloads addObject:contact.sessionID]; - } - - OWSLogVerbose(@"downloading profile avatar: %@", contact.sessionID); - - NSString *profilePictureURL = contact.profilePictureURL; - - NSString *file = [profilePictureURL lastPathComponent]; - BOOL useOldServer = [profilePictureURL containsString:SNFileServerAPIV2.oldServer]; - AnyPromise *promise = [SNFileServerAPIV2 download:file useOldServer:useOldServer]; - - [promise.then(^(NSData *data) { - @synchronized(self.currentAvatarDownloads) - { - [self.currentAvatarDownloads removeObject:contact.sessionID]; - } - NSData *_Nullable encryptedData = data; - NSData *_Nullable decryptedData = [self decryptProfileData:encryptedData profileKey:profileKeyAtStart]; - UIImage *_Nullable image = nil; - if (decryptedData) { - BOOL success = [decryptedData writeToFile:filePath atomically:YES]; - if (success) { - image = [UIImage imageWithContentsOfFile:filePath]; - } - } - - SNContact *latestContact = [LKStorage.shared getContactWithSessionID:contact.sessionID]; - - BOOL hasProfileEncryptionKey = (latestContact.profileEncryptionKey != nil - && latestContact.profileEncryptionKey.keyData.length > 0); - if (!hasProfileEncryptionKey || ![latestContact.profileEncryptionKey isEqual:contact.profileEncryptionKey]) { - OWSLogWarn(@"Ignoring avatar download for obsolete user profile."); - } else if (![avatarUrlPathAtStart isEqualToString:latestContact.profilePictureURL]) { - OWSLogInfo(@"avatar url has changed during download"); - if (latestContact.profilePictureURL != nil && latestContact.profilePictureURL.length > 0) { - [self downloadAvatarForUserProfile:latestContact]; - } - } else if (!encryptedData) { - OWSLogError(@"avatar encrypted data for %@ could not be read.", contact.sessionID); - } else if (!decryptedData) { - OWSLogError(@"avatar data for %@ could not be decrypted.", contact.sessionID); - } else if (!image) { - OWSLogError(@"avatar image for %@ could not be loaded.", contact.sessionID); - } else { - latestContact.profilePictureFileName = fileName; - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [LKStorage.shared setContact:latestContact usingTransaction:transaction]; - }]; - [self updateProfileAvatarCache:image filename:fileName]; - } - - OWSAssertDebug(backgroundTask); - backgroundTask = nil; - }) retainUntilComplete]; - }); -} - -- (void)updateProfileForRecipientId:(NSString *)recipientId - profileNameEncrypted:(nullable NSData *)profileNameEncrypted - avatarUrlPath:(nullable NSString *)avatarUrlPath -{ - OWSAssertDebug(recipientId.length > 0); - - OWSLogDebug(@"update profile for: %@ name: %@ avatar: %@", recipientId, profileNameEncrypted, avatarUrlPath); - - // Ensure decryption, etc. off main thread. - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - SNContact *contact = [LKStorage.shared getContactWithSessionID:recipientId]; - - if (!contact.profileEncryptionKey) { return; } - - NSString *_Nullable profileName = - [self decryptProfileNameData:profileNameEncrypted profileKey:contact.profileEncryptionKey]; - - contact.name = profileName; - contact.profilePictureURL = avatarUrlPath; - - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [LKStorage.shared setContact:contact usingTransaction:transaction]; - }]; - - // Whenever we change avatarUrlPath, OWSUserProfile clears avatarFileName. - // So if avatarUrlPath is set and avatarFileName is not set, we should to - // download this avatar. downloadAvatarForUserProfile will de-bounce - // downloads. - BOOL hasProfilePictureURL = (contact.profilePictureURL != nil && contact.profilePictureURL.length > 0); - BOOL hasProfilePictureFileName = (contact.profilePictureFileName != nil && contact.profilePictureFileName.length > 0); - if (hasProfilePictureURL && !hasProfilePictureFileName) { - [self downloadAvatarForUserProfile:contact]; - } - }); -} - -- (BOOL)isNullableDataEqual:(NSData *_Nullable)left toData:(NSData *_Nullable)right -{ - if (left == nil && right == nil) { - return YES; - } else if (left == nil || right == nil) { - return YES; - } else { - return [left isEqual:right]; - } -} - -- (BOOL)isNullableStringEqual:(NSString *_Nullable)left toString:(NSString *_Nullable)right -{ - if (left == nil && right == nil) { - return YES; - } else if (left == nil || right == nil) { - return YES; - } else { - return [left isEqualToString:right]; - } -} - -#pragma mark - Profile Encryption - -- (nullable NSData *)encryptProfileData:(nullable NSData *)encryptedData profileKey:(OWSAES256Key *)profileKey -{ - OWSAssertDebug(profileKey.keyData.length == kAES256_KeyByteLength); - - if (!encryptedData) { - return nil; - } - - return [Cryptography encryptAESGCMWithProfileData:encryptedData key:profileKey]; -} - -- (nullable NSData *)decryptProfileData:(nullable NSData *)encryptedData profileKey:(OWSAES256Key *)profileKey -{ - OWSAssertDebug(profileKey.keyData.length == kAES256_KeyByteLength); - - if (!encryptedData) { - return nil; - } - - return [Cryptography decryptAESGCMWithProfileData:encryptedData key:profileKey]; -} - -- (nullable NSString *)decryptProfileNameData:(nullable NSData *)encryptedData profileKey:(OWSAES256Key *)profileKey -{ - OWSAssertDebug(profileKey.keyData.length == kAES256_KeyByteLength); - - NSData *_Nullable decryptedData = [self decryptProfileData:encryptedData profileKey:profileKey]; - if (decryptedData.length < 1) { - return nil; - } - - - // Unpad profile name. - NSUInteger unpaddedLength = 0; - const char *bytes = decryptedData.bytes; - - // Work through the bytes until we encounter our first - // padding byte (our padding scheme is NULL bytes) - for (NSUInteger i = 0; i < decryptedData.length; i++) { - if (bytes[i] == 0x00) { - break; - } - unpaddedLength = i + 1; - } - - NSData *unpaddedData = [decryptedData subdataWithRange:NSMakeRange(0, unpaddedLength)]; - - return [[NSString alloc] initWithData:unpaddedData encoding:NSUTF8StringEncoding]; -} - -- (nullable NSData *)encryptProfileData:(nullable NSData *)data -{ - OWSAES256Key *localProfileKey = [LKStorage.shared getUser].profileEncryptionKey; - - return [self encryptProfileData:data profileKey:localProfileKey]; -} - -- (BOOL)isProfileNameTooLong:(nullable NSString *)profileName -{ - OWSAssertIsOnMainThread(); - - NSData *nameData = [profileName dataUsingEncoding:NSUTF8StringEncoding]; - return nameData.length > kOWSProfileManager_NameDataLength; -} - -- (nullable NSData *)encryptProfileNameWithUnpaddedName:(NSString *)name -{ - NSData *nameData = [name dataUsingEncoding:NSUTF8StringEncoding]; - if (nameData.length > kOWSProfileManager_NameDataLength) { - OWSFailDebug(@"name data is too long with length:%lu", (unsigned long)nameData.length); - return nil; - } - - NSUInteger paddingByteCount = kOWSProfileManager_NameDataLength - nameData.length; - - NSMutableData *paddedNameData = [nameData mutableCopy]; - // Since we want all encrypted profile names to be the same length on the server, we use `increaseLengthBy` - // to pad out any remaining length with 0 bytes. - [paddedNameData increaseLengthBy:paddingByteCount]; - OWSAssertDebug(paddedNameData.length == kOWSProfileManager_NameDataLength); - - OWSAES256Key *localProfileKey = [LKStorage.shared getUser].profileEncryptionKey; - - return [self encryptProfileData:[paddedNameData copy] profileKey:localProfileKey]; -} - -#pragma mark - Avatar Disk Cache - -- (nullable NSData *)loadProfileDataWithFilename:(NSString *)filename -{ - if (filename.length <= 0) { return nil; }; - - NSString *filePath = [OWSUserProfile profileAvatarFilepathWithFilename:filename]; - return [NSData dataWithContentsOfFile:filePath]; -} - -- (nullable UIImage *)loadProfileAvatarWithFilename:(NSString *)filename -{ - if (filename.length == 0) { - return nil; - } - - UIImage *_Nullable image = nil; - @synchronized(self.profileAvatarImageCache) - { - image = [self.profileAvatarImageCache objectForKey:filename]; - } - if (image) { - return image; - } - - NSData *data = [self loadProfileDataWithFilename:filename]; - if (![data ows_isValidImage]) { - return nil; - } - image = [UIImage imageWithData:data]; - [self updateProfileAvatarCache:image filename:filename]; - return image; -} - -- (void)updateProfileAvatarCache:(nullable UIImage *)image filename:(NSString *)filename -{ - if (filename.length <= 0) { return; }; - - @synchronized(self.profileAvatarImageCache) - { - if (image) { - [self.profileAvatarImageCache setObject:image forKey:filename]; - } else { - [self.profileAvatarImageCache removeObjectForKey:filename]; - } - } -} - -@end - -NS_ASSUME_NONNULL_END +//NS_ASSUME_NONNULL_BEGIN +// +//// The max bytes for a user's profile name, encoded in UTF8. +//// Before encrypting and submitting we NULL pad the name data to this length. +//const NSUInteger kOWSProfileManager_NameDataLength = 26; +//const NSUInteger kOWSProfileManager_MaxAvatarDiameter = 640; +// +//typedef void (^ProfileManagerFailureBlock)(NSError *error); +// +//@interface OWSProfileManager () +// +//@property (nonatomic, readonly) YapDatabaseConnection *dbConnection; +// +//// This property can be accessed on any thread, while synchronized on self. +//@property (atomic, readonly) NSCache *profileAvatarImageCache; +// +//// This property can be accessed on any thread, while synchronized on self. +//@property (atomic, readonly) NSMutableSet *currentAvatarDownloads; +// +//@end +// +//#pragma mark - +// +//// Access to most state should happen while synchronized on the profile manager. +//// Writes should happen off the main thread, wherever possible. +//@implementation OWSProfileManager +// +//+ (instancetype)sharedManager +//{ +// return SSKEnvironment.shared.profileManager; +//} +// +//- (instancetype)initWithPrimaryStorage:(OWSPrimaryStorage *)primaryStorage +//{ +// self = [super init]; +// +// if (!self) { +// return self; +// } +// +// OWSAssertIsOnMainThread(); +// OWSAssertDebug(primaryStorage); +// +// _dbConnection = primaryStorage.newDatabaseConnection; +// +// _profileAvatarImageCache = [NSCache new]; +// _currentAvatarDownloads = [NSMutableSet new]; +// +// OWSSingletonAssert(); +// +// return self; +//} +// +//- (void)dealloc +//{ +// [[NSNotificationCenter defaultCenter] removeObserver:self]; +//} +// +//#pragma mark - Dependencies +// +//- (TSAccountManager *)tsAccountManager +//{ +// return TSAccountManager.sharedInstance; +//} +// +//- (OWSIdentityManager *)identityManager +//{ +// return SSKEnvironment.shared.identityManager; +//} +// +//- (void)updateLocalProfileName:(nullable NSString *)profileName +// avatarImage:(nullable UIImage *)avatarImage +// success:(void (^)(void))successBlockParameter +// failure:(void (^)(NSError *))failureBlockParameter +// requiresSync:(BOOL)requiresSync +//{ +// OWSAssertDebug(successBlockParameter); +// OWSAssertDebug(failureBlockParameter); +// +// // Ensure that the success and failure blocks are called on the main thread. +// void (^failureBlock)(NSError *) = ^(NSError *error) { +// OWSLogError(@"Updating service with profile failed."); +// +// dispatch_async(dispatch_get_main_queue(), ^{ +// failureBlockParameter(error); +// }); +// }; +// void (^successBlock)(void) = ^{ +// OWSLogInfo(@"Successfully updated service with profile."); +// +// dispatch_async(dispatch_get_main_queue(), ^{ +// successBlockParameter(); +// }); +// }; +// +// // The final steps are to: +// // +// // * Try to update the service. +// // * Update client state on success. +// void (^tryToUpdateService)(NSString *_Nullable, NSString *_Nullable) = ^( +// NSString *_Nullable avatarUrlPath, NSString *_Nullable avatarFileName) { +// [self updateServiceWithProfileName:profileName +// avatarUrl:avatarUrlPath +// success:^{ +// SNContact *userProfile = [LKStorage.shared getUser]; +// OWSAssertDebug(userProfile); +// +// userProfile.name = profileName; +// userProfile.profilePictureURL = avatarUrlPath; +// userProfile.profilePictureFileName = avatarFileName; +// +// [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { +// [LKStorage.shared setContact:userProfile usingTransaction:transaction]; +// } completion:^{ +// if (avatarFileName != nil) { +// [self updateProfileAvatarCache:avatarImage filename:avatarFileName]; +// } +// +// successBlock(); +// }]; +// } +// failure:^(NSError *error) { +// failureBlock(error); +// }]; +// }; +// +// SNContact *userProfile = [LKStorage.shared getUser]; +// OWSAssertDebug(userProfile); +// +// if (avatarImage) { +// // If we have a new avatar image, we must first: +// // +// // * Encode it to JPEG. +// // * Write it to disk. +// // * Encrypt it +// // * Upload it to asset service +// // * Send asset service info to Signal Service +// OWSLogVerbose(@"Updating local profile on service with new avatar."); +// [self writeAvatarToDisk:avatarImage +// success:^(NSData *data, NSString *fileName) { +// [self uploadAvatarToService:data +// success:^(NSString *_Nullable avatarUrlPath) { +// tryToUpdateService(avatarUrlPath, fileName); +// } +// failure:^(NSError *error) { +// failureBlock(error); +// }]; +// } +// failure:^(NSError *error) { +// failureBlock(error); +// }]; +// } else if (userProfile.profilePictureURL) { +// OWSLogVerbose(@"Updating local profile on service with cleared avatar."); +// [self uploadAvatarToService:nil +// success:^(NSString *_Nullable avatarUrlPath) { +// tryToUpdateService(nil, nil); +// } +// failure:^(NSError *error) { +// failureBlock(error); +// }]; +// } else { +// OWSLogVerbose(@"Updating local profile on service with no avatar."); +// tryToUpdateService(nil, nil); +// } +//} +// +//- (void)writeAvatarToDisk:(UIImage *)avatar +// success:(void (^)(NSData *data, NSString *fileName))successBlock +// failure:(ProfileManagerFailureBlock)failureBlock { +// OWSAssertDebug(avatar); +// OWSAssertDebug(successBlock); +// OWSAssertDebug(failureBlock); +// +// dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ +// if (avatar) { +// NSData *data = [self processedImageDataForRawAvatar:avatar]; +// OWSAssertDebug(data); +// if (data) { +// NSString *fileName = [self generateAvatarFilename]; +// NSString *filePath = [OWSUserProfile profileAvatarFilepathWithFilename:fileName]; +// BOOL success = [data writeToFile:filePath atomically:YES]; +// OWSAssertDebug(success); +// if (success) { +// return successBlock(data, fileName); +// } +// } +// } +// failureBlock(OWSErrorWithCodeDescription(OWSErrorCodeAvatarWriteFailed, @"Avatar write failed.")); +// }); +//} +// +//- (NSData *)processedImageDataForRawAvatar:(UIImage *)image +//{ +// NSUInteger kMaxAvatarBytes = 5 * 1000 * 1000; +// +// if (image.size.width != kOWSProfileManager_MaxAvatarDiameter +// || image.size.height != kOWSProfileManager_MaxAvatarDiameter) { +// // To help ensure the user is being shown the same cropping of their avatar as +// // everyone else will see, we want to be sure that the image was resized before this point. +// OWSFailDebug(@"Avatar image should have been resized before trying to upload"); +// image = [image resizedImageToFillPixelSize:CGSizeMake(kOWSProfileManager_MaxAvatarDiameter, +// kOWSProfileManager_MaxAvatarDiameter)]; +// } +// +// NSData *_Nullable data = UIImageJPEGRepresentation(image, 0.95f); +// if (data.length > kMaxAvatarBytes) { +// // Our avatar dimensions are so small that it's incredibly unlikely we wouldn't be able to fit our profile +// // photo. e.g. generating pure noise at our resolution compresses to ~200k. +// OWSFailDebug(@"Suprised to find profile avatar was too large. Was it scaled properly? image: %@", image); +// } +// +// return data; +//} +// +//// If avatarData is nil, we are clearing the avatar. +//- (void)uploadAvatarToService:(NSData *_Nullable)avatarData +// success:(void (^)(NSString *_Nullable avatarUrlPath))successBlock +// failure:(ProfileManagerFailureBlock)failureBlock { +// OWSAssertDebug(successBlock); +// OWSAssertDebug(failureBlock); +// OWSAssertDebug(avatarData == nil || avatarData.length > 0); +// +// dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ +// // We always want to encrypt a profile with a new profile key +// // This ensures that other users know that our profile picture was updated +// OWSAES256Key *newProfileKey = [OWSAES256Key generateRandomKey]; +// +// if (avatarData) { +// NSData *encryptedAvatarData = [self encryptProfileData:avatarData profileKey:newProfileKey]; +// OWSAssertDebug(encryptedAvatarData.length > 0); +// +// AnyPromise *promise = [SNFileServerAPIV2 upload:encryptedAvatarData]; +// +// [promise.thenOn(dispatch_get_main_queue(), ^(NSString *fileID) { +// NSString *downloadURL = [NSString stringWithFormat:@"%@/files/%@", SNFileServerAPIV2.server, fileID]; +// [NSUserDefaults.standardUserDefaults setObject:[NSDate new] forKey:@"lastProfilePictureUpload"]; +// +// SNContact *user = [LKStorage.shared getUser]; +// user.profileEncryptionKey = newProfileKey; +// [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { +// [LKStorage.shared setContact:user usingTransaction:transaction]; +// } completion:^{ +// successBlock(downloadURL); +// }]; +// }) +// .catchOn(dispatch_get_main_queue(), ^(id result) { +// // There appears to be a bug in PromiseKit that sometimes causes catchOn +// // to be invoked with the fulfilled promise's value as the error. The below +// // is a quick and dirty workaround. +// if ([result isKindOfClass:NSString.class]) { +// SNContact *user = [LKStorage.shared getUser]; +// user.profileEncryptionKey = newProfileKey; +// [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { +// [LKStorage.shared setContact:user usingTransaction:transaction]; +// } completion:^{ +// successBlock(result); +// }]; +// } else { +// failureBlock(result); +// } +// }) retainUntilComplete]; +// } else { +// // Update our profile key and set the url to nil if avatar data is nil +// SNContact *user = [LKStorage.shared getUser]; +// user.profileEncryptionKey = newProfileKey; +// user.profilePictureURL = nil; +// user.profilePictureFileName = nil; +// [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { +// [LKStorage.shared setContact:user usingTransaction:transaction]; +// } completion:^{ +// successBlock(nil); +// }]; +// } +// }); +//} +// +//- (void)updateServiceWithProfileName:(nullable NSString *)localProfileName +// avatarUrl:(nullable NSString *)avatarURL +// success:(void (^)(void))successBlock +// failure:(ProfileManagerFailureBlock)failureBlock { +// successBlock(); +//} +// +//- (void)updateServiceWithProfileName:(nullable NSString *)localProfileName avatarURL:(nullable NSString *)avatarURL { +// [self updateServiceWithProfileName:localProfileName avatarUrl:avatarURL success:^{} failure:^(NSError * _Nonnull error) {}]; +//} +// +//#pragma mark - Profile Key Rotation +// +//- (nullable NSString *)groupKeyForGroupId:(NSData *)groupId { +// NSString *groupIdKey = [groupId hexadecimalString]; +// return groupIdKey; +//} +// +//- (nullable NSData *)groupIdForGroupKey:(NSString *)groupKey { +// NSMutableData *groupId = [NSMutableData new]; +// +// if (groupKey.length % 2 != 0) { +// OWSFailDebug(@"Group key has unexpected length: %@ (%lu)", groupKey, (unsigned long)groupKey.length); +// return nil; +// } +// for (NSUInteger i = 0; i + 2 <= groupKey.length; i += 2) { +// NSString *_Nullable byteString = [groupKey substringWithRange:NSMakeRange(i, 2)]; +// if (!byteString) { +// OWSFailDebug(@"Couldn't slice group key."); +// return nil; +// } +// unsigned byteValue; +// if (![[NSScanner scannerWithString:byteString] scanHexInt:&byteValue]) { +// OWSFailDebug(@"Couldn't parse hex byte: %@.", byteString); +// return nil; +// } +// if (byteValue > 0xff) { +// OWSFailDebug(@"Invalid hex byte: %@ (%d).", byteString, byteValue); +// return nil; +// } +// uint8_t byte = (uint8_t)(0xff & byteValue); +// [groupId appendBytes:&byte length:1]; +// } +// return [groupId copy]; +//} +// +//- (void)regenerateLocalProfile +//{ +// NSString *userPublicKey = [SNGeneralUtilities getUserPublicKey]; +// SNContact *contact = [LKStorage.shared getContactWithSessionID:userPublicKey]; +// contact.profileEncryptionKey = [OWSAES256Key generateRandomKey]; +// contact.profilePictureURL = nil; +// contact.profilePictureFileName = nil; +// [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { +// [LKStorage.shared setContact:contact usingTransaction:transaction]; +// } completion:^{ +// [[self.tsAccountManager updateAccountAttributes] retainUntilComplete]; +// }]; +//} +// +//#pragma mark - Other Users' Profiles +// +//- (void)setProfileKeyData:(NSData *)profileKeyData forRecipientId:(NSString *)recipientId avatarURL:(nullable NSString *)avatarURL +//{ +// dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ +// OWSAES256Key *_Nullable profileKey = [OWSAES256Key keyWithData:profileKeyData]; +// if (profileKey == nil) { +// OWSFailDebug(@"Failed to make profile key for key data"); +// return; +// } +// +// SNContact *contact = [LKStorage.shared getContactWithSessionID:recipientId]; +// +// OWSAssertDebug(contact); +// if (contact.profileEncryptionKey != nil && [contact.profileEncryptionKey.keyData isEqual:profileKey.keyData]) { +// // Ignore redundant update. +// return; +// } +// +// contact.profileEncryptionKey = profileKey; +// contact.profilePictureURL = nil; +// contact.profilePictureFileName = nil; +// +// [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { +// [LKStorage.shared setContact:contact usingTransaction:transaction]; +// } completion:^{ +// contact.profilePictureURL = avatarURL; +// [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { +// [LKStorage.shared setContact:contact usingTransaction:transaction]; +// } completion:^{ +// [self downloadAvatarForUserProfile:contact]; +// }]; +// }]; +// }); +//} +// +//- (void)setProfileKeyData:(NSData *)profileKeyData forRecipientId:(NSString *)recipientId +//{ +// [self setProfileKeyData:profileKeyData forRecipientId:recipientId avatarURL:nil]; +//} +// +//- (nullable NSData *)profileKeyDataForRecipientId:(NSString *)recipientId +//{ +// return [self profileKeyForRecipientId:recipientId].keyData; +//} +// +//- (nullable OWSAES256Key *)profileKeyForRecipientId:(NSString *)recipientId +//{ +// OWSAssertDebug(recipientId.length > 0); +// +// SNContact *contact = [LKStorage.shared getContactWithSessionID:recipientId]; +// OWSAssertDebug(contact); +// +// return contact.profileEncryptionKey; +//} +// +//- (nullable UIImage *)profileAvatarForRecipientId:(NSString *)recipientId +//{ +// OWSAssertDebug(recipientId.length > 0); +// +// SNContact *contact = [LKStorage.shared getContactWithSessionID:recipientId]; +// +// if (contact.profilePictureFileName != nil && contact.profilePictureFileName.length > 0) { +// return [self loadProfileAvatarWithFilename:contact.profilePictureFileName]; +// } +// +// if (contact.profilePictureURL != nil && contact.profilePictureURL.length > 0) { +// [self downloadAvatarForUserProfile:contact]; +// } +// +// return nil; +//} +// +//- (nullable NSData *)profileAvatarDataForRecipientId:(NSString *)recipientId +//{ +// OWSAssertDebug(recipientId.length > 0); +// +// SNContact *contact = [LKStorage.shared getContactWithSessionID:recipientId]; +// +// if (contact.profilePictureFileName != nil && contact.profilePictureFileName.length > 0) { +// return [self loadProfileDataWithFilename:contact.profilePictureFileName]; +// } +// +// return nil; +//} +// +//- (NSString *)generateAvatarFilename +//{ +// return [[NSUUID UUID].UUIDString stringByAppendingPathExtension:@"jpg"]; +//} +// +//- (void)downloadAvatarForUserProfile:(SNContact *)contact +//{ +// OWSAssertDebug(contact); +// +// __block OWSBackgroundTask *backgroundTask = [OWSBackgroundTask backgroundTaskWithLabelStr:__PRETTY_FUNCTION__]; +// +// dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ +// BOOL hasProfilePictureURL = (contact.profilePictureURL != nil && contact.profilePictureURL.length > 0); +// if (!hasProfilePictureURL) { +// OWSLogDebug(@"Skipping downloading avatar for %@ because url is not set", contact.sessionID); +// return; +// } +// NSString *_Nullable avatarUrlPathAtStart = contact.profilePictureURL; +// +// BOOL hasProfileEncryptionKey = (contact.profileEncryptionKey != nil && contact.profileEncryptionKey.keyData.length > 0); +// if (!hasProfileEncryptionKey || !hasProfilePictureURL) { +// return; +// } +// +// OWSAES256Key *profileKeyAtStart = contact.profileEncryptionKey; +// +// NSString *fileName = [self generateAvatarFilename]; +// NSString *filePath = [OWSUserProfile profileAvatarFilepathWithFilename:fileName]; +// +// @synchronized(self.currentAvatarDownloads) +// { +// if ([self.currentAvatarDownloads containsObject:contact.sessionID]) { +// // Download already in flight; ignore. +// return; +// } +// [self.currentAvatarDownloads addObject:contact.sessionID]; +// } +// +// OWSLogVerbose(@"downloading profile avatar: %@", contact.sessionID); +// +// NSString *profilePictureURL = contact.profilePictureURL; +// +// NSString *file = [profilePictureURL lastPathComponent]; +// BOOL useOldServer = [profilePictureURL containsString:SNFileServerAPIV2.oldServer]; +// AnyPromise *promise = [SNFileServerAPIV2 download:file useOldServer:useOldServer]; +// +// [promise.then(^(NSData *data) { +// @synchronized(self.currentAvatarDownloads) +// { +// [self.currentAvatarDownloads removeObject:contact.sessionID]; +// } +// NSData *_Nullable encryptedData = data; +// NSData *_Nullable decryptedData = [self decryptProfileData:encryptedData profileKey:profileKeyAtStart]; +// UIImage *_Nullable image = nil; +// if (decryptedData) { +// BOOL success = [decryptedData writeToFile:filePath atomically:YES]; +// if (success) { +// image = [UIImage imageWithContentsOfFile:filePath]; +// } +// } +// +// SNContact *latestContact = [LKStorage.shared getContactWithSessionID:contact.sessionID]; +// +// BOOL hasProfileEncryptionKey = (latestContact.profileEncryptionKey != nil +// && latestContact.profileEncryptionKey.keyData.length > 0); +// if (!hasProfileEncryptionKey || ![latestContact.profileEncryptionKey isEqual:contact.profileEncryptionKey]) { +// OWSLogWarn(@"Ignoring avatar download for obsolete user profile."); +// } else if (![avatarUrlPathAtStart isEqualToString:latestContact.profilePictureURL]) { +// OWSLogInfo(@"avatar url has changed during download"); +// if (latestContact.profilePictureURL != nil && latestContact.profilePictureURL.length > 0) { +// [self downloadAvatarForUserProfile:latestContact]; +// } +// } else if (!encryptedData) { +// OWSLogError(@"avatar encrypted data for %@ could not be read.", contact.sessionID); +// } else if (!decryptedData) { +// OWSLogError(@"avatar data for %@ could not be decrypted.", contact.sessionID); +// } else if (!image) { +// OWSLogError(@"avatar image for %@ could not be loaded.", contact.sessionID); +// } else { +// latestContact.profilePictureFileName = fileName; +// [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { +// [LKStorage.shared setContact:latestContact usingTransaction:transaction]; +// }]; +// [self updateProfileAvatarCache:image filename:fileName]; +// } +// +// OWSAssertDebug(backgroundTask); +// backgroundTask = nil; +// }) retainUntilComplete]; +// }); +//} +// +//- (void)updateProfileForRecipientId:(NSString *)recipientId +// profileNameEncrypted:(nullable NSData *)profileNameEncrypted +// avatarUrlPath:(nullable NSString *)avatarUrlPath +//{ +// OWSAssertDebug(recipientId.length > 0); +// +// OWSLogDebug(@"update profile for: %@ name: %@ avatar: %@", recipientId, profileNameEncrypted, avatarUrlPath); +// +// // Ensure decryption, etc. off main thread. +// dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ +// SNContact *contact = [LKStorage.shared getContactWithSessionID:recipientId]; +// +// if (!contact.profileEncryptionKey) { return; } +// +// NSString *_Nullable profileName = +// [self decryptProfileNameData:profileNameEncrypted profileKey:contact.profileEncryptionKey]; +// +// contact.name = profileName; +// contact.profilePictureURL = avatarUrlPath; +// +// [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { +// [LKStorage.shared setContact:contact usingTransaction:transaction]; +// }]; +// +// // Whenever we change avatarUrlPath, OWSUserProfile clears avatarFileName. +// // So if avatarUrlPath is set and avatarFileName is not set, we should to +// // download this avatar. downloadAvatarForUserProfile will de-bounce +// // downloads. +// BOOL hasProfilePictureURL = (contact.profilePictureURL != nil && contact.profilePictureURL.length > 0); +// BOOL hasProfilePictureFileName = (contact.profilePictureFileName != nil && contact.profilePictureFileName.length > 0); +// if (hasProfilePictureURL && !hasProfilePictureFileName) { +// [self downloadAvatarForUserProfile:contact]; +// } +// }); +//} +// +//- (BOOL)isNullableDataEqual:(NSData *_Nullable)left toData:(NSData *_Nullable)right +//{ +// if (left == nil && right == nil) { +// return YES; +// } else if (left == nil || right == nil) { +// return YES; +// } else { +// return [left isEqual:right]; +// } +//} +// +//- (BOOL)isNullableStringEqual:(NSString *_Nullable)left toString:(NSString *_Nullable)right +//{ +// if (left == nil && right == nil) { +// return YES; +// } else if (left == nil || right == nil) { +// return YES; +// } else { +// return [left isEqualToString:right]; +// } +//} +// +//#pragma mark - Profile Encryption +// +//- (nullable NSData *)encryptProfileData:(nullable NSData *)encryptedData profileKey:(OWSAES256Key *)profileKey +//{ +// OWSAssertDebug(profileKey.keyData.length == kAES256_KeyByteLength); +// +// if (!encryptedData) { +// return nil; +// } +// +// return [Cryptography encryptAESGCMWithProfileData:encryptedData key:profileKey]; +//} +// +//- (nullable NSData *)decryptProfileData:(nullable NSData *)encryptedData profileKey:(OWSAES256Key *)profileKey +//{ +// OWSAssertDebug(profileKey.keyData.length == kAES256_KeyByteLength); +// +// if (!encryptedData) { +// return nil; +// } +// +// return [Cryptography decryptAESGCMWithProfileData:encryptedData key:profileKey]; +//} +// +//- (nullable NSString *)decryptProfileNameData:(nullable NSData *)encryptedData profileKey:(OWSAES256Key *)profileKey +//{ +// OWSAssertDebug(profileKey.keyData.length == kAES256_KeyByteLength); +// +// NSData *_Nullable decryptedData = [self decryptProfileData:encryptedData profileKey:profileKey]; +// if (decryptedData.length < 1) { +// return nil; +// } +// +// +// // Unpad profile name. +// NSUInteger unpaddedLength = 0; +// const char *bytes = decryptedData.bytes; +// +// // Work through the bytes until we encounter our first +// // padding byte (our padding scheme is NULL bytes) +// for (NSUInteger i = 0; i < decryptedData.length; i++) { +// if (bytes[i] == 0x00) { +// break; +// } +// unpaddedLength = i + 1; +// } +// +// NSData *unpaddedData = [decryptedData subdataWithRange:NSMakeRange(0, unpaddedLength)]; +// +// return [[NSString alloc] initWithData:unpaddedData encoding:NSUTF8StringEncoding]; +//} +// +//- (nullable NSData *)encryptProfileData:(nullable NSData *)data +//{ +// OWSAES256Key *localProfileKey = [LKStorage.shared getUser].profileEncryptionKey; +// +// return [self encryptProfileData:data profileKey:localProfileKey]; +//} +// +//- (BOOL)isProfileNameTooLong:(nullable NSString *)profileName +//{ +// OWSAssertIsOnMainThread(); +// +// NSData *nameData = [profileName dataUsingEncoding:NSUTF8StringEncoding]; +// return nameData.length > kOWSProfileManager_NameDataLength; +//} +// +//- (nullable NSData *)encryptProfileNameWithUnpaddedName:(NSString *)name +//{ +// NSData *nameData = [name dataUsingEncoding:NSUTF8StringEncoding]; +// if (nameData.length > kOWSProfileManager_NameDataLength) { +// OWSFailDebug(@"name data is too long with length:%lu", (unsigned long)nameData.length); +// return nil; +// } +// +// NSUInteger paddingByteCount = kOWSProfileManager_NameDataLength - nameData.length; +// +// NSMutableData *paddedNameData = [nameData mutableCopy]; +// // Since we want all encrypted profile names to be the same length on the server, we use `increaseLengthBy` +// // to pad out any remaining length with 0 bytes. +// [paddedNameData increaseLengthBy:paddingByteCount]; +// OWSAssertDebug(paddedNameData.length == kOWSProfileManager_NameDataLength); +// +// OWSAES256Key *localProfileKey = [LKStorage.shared getUser].profileEncryptionKey; +// +// return [self encryptProfileData:[paddedNameData copy] profileKey:localProfileKey]; +//} +// +//#pragma mark - Avatar Disk Cache +// +//- (nullable NSData *)loadProfileDataWithFilename:(NSString *)filename +//{ +// if (filename.length <= 0) { return nil; }; +// +// NSString *filePath = [OWSUserProfile profileAvatarFilepathWithFilename:filename]; +// return [NSData dataWithContentsOfFile:filePath]; +//} +// +//- (nullable UIImage *)loadProfileAvatarWithFilename:(NSString *)filename +//{ +// if (filename.length == 0) { +// return nil; +// } +// +// UIImage *_Nullable image = nil; +// @synchronized(self.profileAvatarImageCache) +// { +// image = [self.profileAvatarImageCache objectForKey:filename]; +// } +// if (image) { +// return image; +// } +// +// NSData *data = [self loadProfileDataWithFilename:filename]; +// if (![data ows_isValidImage]) { +// return nil; +// } +// image = [UIImage imageWithData:data]; +// [self updateProfileAvatarCache:image filename:filename]; +// return image; +//} +// +//- (void)updateProfileAvatarCache:(nullable UIImage *)image filename:(NSString *)filename +//{ +// if (filename.length <= 0) { return; }; +// +// @synchronized(self.profileAvatarImageCache) +// { +// if (image) { +// [self.profileAvatarImageCache setObject:image forKey:filename]; +// } else { +// [self.profileAvatarImageCache removeObjectForKey:filename]; +// } +// } +//} +// +//@end +// +//NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/AppSetup.m b/SignalUtilitiesKit/Utilities/AppSetup.m index 3e0fcbd40..0cdf5a0f1 100644 --- a/SignalUtilitiesKit/Utilities/AppSetup.m +++ b/SignalUtilitiesKit/Utilities/AppSetup.m @@ -6,7 +6,6 @@ #import "Environment.h" #import "VersionMigrations.h" #import -#import #import #import #import @@ -49,7 +48,6 @@ NS_ASSUME_NONNULL_BEGIN OWSPreferences *preferences = [OWSPreferences new]; - OWSProfileManager *profileManager = [[OWSProfileManager alloc] initWithPrimaryStorage:primaryStorage]; TSAccountManager *tsAccountManager = [[TSAccountManager alloc] initWithPrimaryStorage:primaryStorage]; OWSDisappearingMessagesJob *disappearingMessagesJob = [[OWSDisappearingMessagesJob alloc] initWithPrimaryStorage:primaryStorage]; @@ -71,8 +69,7 @@ NS_ASSUME_NONNULL_BEGIN sounds:sounds windowManager:windowManager]]; - [SSKEnvironment setShared:[[SSKEnvironment alloc] initWithProfileManager:profileManager - primaryStorage:primaryStorage + [SSKEnvironment setShared:[[SSKEnvironment alloc] initWithPrimaryStorage:primaryStorage tsAccountManager:tsAccountManager disappearingMessagesJob:disappearingMessagesJob readReceiptManager:readReceiptManager From 4380f1975cac4f5f6cf57762b8ac737ffd72291d Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 8 Apr 2022 16:56:33 +1000 Subject: [PATCH 060/157] Further work on the DB refactoring Added the rest of the interaction structure to the database (testing some migration logic now - still needs to be finalised) Updated the YDBToGRDB migrations to wrap their inserts in autorelease pools (helps memory slightly, unfortunately it's caching the YDB data which uses the most memory but we have opted for speed over RAM at the moment) Updated the MockDataGenerator so it should now "chunk" the code generation (crazy large figures were previously resulting in excessive memory usage) --- Session.xcodeproj/project.pbxproj | 20 + Session/Home/HomeVC.swift | 4 +- Session/Notifications/AppNotifications.swift | 2 +- Session/Utilities/BackgroundPoller.swift | 5 + Session/Utilities/MockDataGenerator.swift | 451 ++++++++++-------- .../LegacyDatabase/SMKLegacyModels.swift | 94 ++++ .../_001_InitialSetupMigration.swift | 160 ++++++- .../Migrations/_002_YDBToGRDBMigration.swift | 446 +++++++++++++---- .../Database/Models/Attachment.swift | 131 +++++ .../Database/Models/Capability.swift | 14 +- .../Database/Models/ClosedGroup.swift | 35 +- .../Database/Models/ClosedGroupKeyPair.swift | 11 + .../DisappearingMessageConfiguration.swift | 14 +- .../Database/Models/GroupMember.swift | 20 + .../Database/Models/Interaction.swift | 211 ++++++++ .../Database/Models/LinkPreview.swift | 38 ++ .../Database/Models/OpenGroup.swift | 76 ++- .../Database/Models/Profile.swift | 3 + .../Database/Models/Quote.swift | 57 +++ .../Database/Models/RecipientState.swift | 55 +++ .../Database/Models/SessionThread.swift | 27 +- .../Messages/Signal/TSOutgoingMessage.h | 2 + .../Messages/Signal/TSOutgoingMessage.m | 1 - .../Migrations/_002_YDBToGRDBMigration.swift | 64 +-- .../Migrations/_002_YDBToGRDBMigration.swift | 53 +- .../Database/Types/TypedTableDefinition.swift | 4 + SignalUtilitiesKit/Configuration.swift | 8 +- SignalUtilitiesKit/Utilities/AppSetup.m | 3 + 28 files changed, 1610 insertions(+), 399 deletions(-) create mode 100644 SessionMessagingKit/Database/Models/Attachment.swift create mode 100644 SessionMessagingKit/Database/Models/Interaction.swift create mode 100644 SessionMessagingKit/Database/Models/LinkPreview.swift create mode 100644 SessionMessagingKit/Database/Models/Quote.swift create mode 100644 SessionMessagingKit/Database/Models/RecipientState.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 33dfe7c0c..6db2a1dbc 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -753,6 +753,10 @@ FD09798B27FD1CFE00936362 /* Capability.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09798A27FD1CFE00936362 /* Capability.swift */; }; FD09798D27FD1D8900936362 /* DisappearingMessageConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09798C27FD1D8900936362 /* DisappearingMessageConfiguration.swift */; }; FD09799127FD499200936362 /* BoxKeyPair+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799027FD499200936362 /* BoxKeyPair+Utilities.swift */; }; + FD09799527FE7B8E00936362 /* Interaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799227FE693200936362 /* Interaction.swift */; }; + FD09799727FFA84A00936362 /* RecipientState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799627FFA84900936362 /* RecipientState.swift */; }; + FD09799927FFC1A300936362 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799827FFC1A300936362 /* Attachment.swift */; }; + FD09799B27FFC82D00936362 /* Quote.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799A27FFC82D00936362 /* Quote.swift */; }; FD17D79927F40AB800122BE0 /* _002_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79827F40AB800122BE0 /* _002_YDBToGRDBMigration.swift */; }; FD17D79C27F40B2E00122BE0 /* SMKLegacyModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */; }; FD17D7A027F40CC800122BE0 /* _001_InitialSetupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79F27F40CC800122BE0 /* _001_InitialSetupMigration.swift */; }; @@ -798,6 +802,7 @@ FD88BAD927A7439C00BBC442 /* MessageRequestsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */; }; FD88BADB27A750F200BBC442 /* MessageRequestsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */; }; FDC4389E27BA2B8A00C60D73 /* SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */; }; + FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */; }; FDFD645827EC1F4000808CA1 /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFD645727EC1F4000808CA1 /* Atomic.swift */; }; /* End PBXBuildFile section */ @@ -1800,6 +1805,10 @@ FD09798A27FD1CFE00936362 /* Capability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Capability.swift; sourceTree = ""; }; FD09798C27FD1D8900936362 /* DisappearingMessageConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisappearingMessageConfiguration.swift; sourceTree = ""; }; FD09799027FD499200936362 /* BoxKeyPair+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BoxKeyPair+Utilities.swift"; sourceTree = ""; }; + FD09799227FE693200936362 /* Interaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Interaction.swift; sourceTree = ""; }; + FD09799627FFA84900936362 /* RecipientState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipientState.swift; sourceTree = ""; }; + FD09799827FFC1A300936362 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; + FD09799A27FFC82D00936362 /* Quote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Quote.swift; sourceTree = ""; }; FD17D79527F3E04600122BE0 /* _001_InitialSetupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _001_InitialSetupMigration.swift; sourceTree = ""; }; FD17D79827F40AB800122BE0 /* _002_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_YDBToGRDBMigration.swift; sourceTree = ""; }; FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMKLegacyModels.swift; sourceTree = ""; }; @@ -1845,6 +1854,7 @@ FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsCell.swift; sourceTree = ""; }; FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsMigration.swift; sourceTree = ""; }; FD9039443F7CB729CF71350E /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig"; sourceTree = ""; }; + FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreview.swift; sourceTree = ""; }; FDFD645727EC1F4000808CA1 /* Atomic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = ""; }; FEDBAE1B98C49BBE8C87F575 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; sourceTree = ""; }; FF9BA33D021B115B1F5B4E46 /* Pods-SessionMessagingKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionMessagingKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionMessagingKit/Pods-SessionMessagingKit.app store release.xcconfig"; sourceTree = ""; }; @@ -3610,6 +3620,11 @@ FD09798827FD1C5A00936362 /* OpenGroup.swift */, FD09798627FD1B7800936362 /* GroupMember.swift */, FD09798A27FD1CFE00936362 /* Capability.swift */, + FD09799227FE693200936362 /* Interaction.swift */, + FD09799627FFA84900936362 /* RecipientState.swift */, + FD09799827FFC1A300936362 /* Attachment.swift */, + FD09799A27FFC82D00936362 /* Quote.swift */, + FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */, ); path = Models; sourceTree = ""; @@ -4842,6 +4857,7 @@ C3471F4C25553AB000297E91 /* MessageReceiver+Decryption.swift in Sources */, C300A5D32554B05A00555489 /* TypingIndicator.swift in Sources */, C3A3A156256E1B91004D228D /* ProtoUtils.m in Sources */, + FD09799927FFC1A300936362 /* Attachment.swift in Sources */, C3471ECB2555356A00297E91 /* MessageSender+Encryption.swift in Sources */, C352A32F2557549C00338F3E /* NotifyPNServerJob.swift in Sources */, 7B4C75CB26B37E0F0000AC89 /* UnsendRequest.swift in Sources */, @@ -4860,6 +4876,7 @@ C3C2A7562553A3AB00C340D1 /* VisibleMessage+Quote.swift in Sources */, C3227FF6260AAD66006EA627 /* OpenGroupMessageV2.swift in Sources */, C32C5C89256DD0D2003C73A2 /* Storage+Jobs.swift in Sources */, + FD09799727FFA84A00936362 /* RecipientState.swift in Sources */, C300A5FC2554B0A000555489 /* MessageReceiver.swift in Sources */, 7B1581E2271E743B00848B49 /* OWSSounds.swift in Sources */, C32C5A76256DBBCF003C73A2 /* SignalAttachment.swift in Sources */, @@ -4872,6 +4889,7 @@ B8856D1A256F114D001CE70E /* ProximityMonitoringManager.swift in Sources */, C3D9E52725677DF20040E4F3 /* OWSThumbnailService.swift in Sources */, C32C5E75256DE020003C73A2 /* YapDatabaseTransaction+OWS.m in Sources */, + FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */, FD09797527FAB64300936362 /* ProfileManager.swift in Sources */, C3BBE0802554CDD70050F1E3 /* Storage.swift in Sources */, C3DB66AC260ACA42001EFC55 /* OpenGroupManagerV2.swift in Sources */, @@ -4930,6 +4948,7 @@ C3DB66CC260AF1F3001EFC55 /* OpenGroupAPIV2+ObjC.swift in Sources */, C32C5BEF256DC8EE003C73A2 /* OWSDisappearingMessagesJob.m in Sources */, C34A977425A3E34A00852C71 /* ClosedGroupControlMessage.swift in Sources */, + FD09799527FE7B8E00936362 /* Interaction.swift in Sources */, B88FA7B826045D100049422F /* OpenGroupAPIV2.swift in Sources */, C32C5E97256DE0CB003C73A2 /* OWSPrimaryStorage.m in Sources */, C32C5EB9256DE130003C73A2 /* OWSQuotedReplyModel+Conversion.swift in Sources */, @@ -4958,6 +4977,7 @@ B8856CA8256F0F42001CE70E /* OWSBackupFragment.m in Sources */, C32C5C3D256DCBAF003C73A2 /* AppReadiness.m in Sources */, B87EF17126367CF800124B3C /* FileServerAPIV2.swift in Sources */, + FD09799B27FFC82D00936362 /* Quote.swift in Sources */, C3A3A18A256E2092004D228D /* SignalRecipient.m in Sources */, C3C2A74425539EB700C340D1 /* Message.swift in Sources */, C32C5F11256DF79A003C73A2 /* SSKIncrementingIdFinder.swift in Sources */, diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 6d64b792d..4f8b8bfa3 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -396,7 +396,9 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv } @objc private func handleProfileDidChangeNotification(_ notification: Notification) { - tableView.reloadData() // TODO: Just reload the affected cell + DispatchQueue.main.async { + self.tableView.reloadData() // TODO: Just reload the affected cell + } } @objc private func handleLocalProfileDidChangeNotification(_ notification: Notification) { diff --git a/Session/Notifications/AppNotifications.swift b/Session/Notifications/AppNotifications.swift index 0f8d0f06d..36842635d 100644 --- a/Session/Notifications/AppNotifications.swift +++ b/Session/Notifications/AppNotifications.swift @@ -197,7 +197,7 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { return } - let senderName = Profile.displayName(for: incomingMessage.authorId, thread: incomingMessage.thread) + let senderName = Profile.displayName(for: incomingMessage.authorId, thread: thread) let notificationTitle: String? var notificationBody: String? diff --git a/Session/Utilities/BackgroundPoller.swift b/Session/Utilities/BackgroundPoller.swift index ff572ac87..bbdd9d9f8 100644 --- a/Session/Utilities/BackgroundPoller.swift +++ b/Session/Utilities/BackgroundPoller.swift @@ -1,5 +1,9 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation import PromiseKit import SessionSnodeKit +import SessionMessagingKit @objc(LKBackgroundPoller) public final class BackgroundPoller : NSObject { @@ -13,6 +17,7 @@ public final class BackgroundPoller : NSObject { promises = [] promises.append(pollForMessages()) promises.append(contentsOf: pollForClosedGroupMessages()) + let v2OpenGroupServers = Set(Storage.shared.getAllV2OpenGroups().values.map { $0.server }) v2OpenGroupServers.forEach { server in let poller = OpenGroupPollerV2(for: server) diff --git a/Session/Utilities/MockDataGenerator.swift b/Session/Utilities/MockDataGenerator.swift index 1074d1669..a3e98db18 100644 --- a/Session/Utilities/MockDataGenerator.swift +++ b/Session/Utilities/MockDataGenerator.swift @@ -96,6 +96,11 @@ enum MockDataGenerator { let dmRandomSeed: Int = 1111 let cgRandomSeed: Int = 2222 let ogRandomSeed: Int = 3333 + let chunkSize: Int = 1000 // Chunk up the thread writing to prevent memory issues + let stringContent: [String] = "abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ 0123456789 ".map { String($0) } + let wordContent: [String] = ["alias", "consequatur", "aut", "perferendis", "sit", "voluptatem", "accusantium", "doloremque", "aperiam", "eaque", "ipsa", "quae", "ab", "illo", "inventore", "veritatis", "et", "quasi", "architecto", "beatae", "vitae", "dicta", "sunt", "explicabo", "aspernatur", "aut", "odit", "aut", "fugit", "sed", "quia", "consequuntur", "magni", "dolores", "eos", "qui", "ratione", "voluptatem", "sequi", "nesciunt", "neque", "dolorem", "ipsum", "quia", "dolor", "sit", "amet", "consectetur", "adipisci", "velit", "sed", "quia", "non", "numquam", "eius", "modi", "tempora", "incidunt", "ut", "labore", "et", "dolore", "magnam", "aliquam", "quaerat", "voluptatem", "ut", "enim", "ad", "minima", "veniam", "quis", "nostrum", "exercitationem", "ullam", "corporis", "nemo", "enim", "ipsam", "voluptatem", "quia", "voluptas", "sit", "suscipit", "laboriosam", "nisi", "ut", "aliquid", "ex", "ea", "commodi", "consequatur", "quis", "autem", "vel", "eum", "iure", "reprehenderit", "qui", "in", "ea", "voluptate", "velit", "esse", "quam", "nihil", "molestiae", "et", "iusto", "odio", "dignissimos", "ducimus", "qui", "blanditiis", "praesentium", "laudantium", "totam", "rem", "voluptatum", "deleniti", "atque", "corrupti", "quos", "dolores", "et", "quas", "molestias", "excepturi", "sint", "occaecati", "cupiditate", "non", "provident", "sed", "ut", "perspiciatis", "unde", "omnis", "iste", "natus", "error", "similique", "sunt", "in", "culpa", "qui", "officia", "deserunt", "mollitia", "animi", "id", "est", "laborum", "et", "dolorum", "fuga", "et", "harum", "quidem", "rerum", "facilis", "est", "et", "expedita", "distinctio", "nam", "libero", "tempore", "cum", "soluta", "nobis", "est", "eligendi", "optio", "cumque", "nihil", "impedit", "quo", "porro", "quisquam", "est", "qui", "minus", "id", "quod", "maxime", "placeat", "facere", "possimus", "omnis", "voluptas", "assumenda", "est", "omnis", "dolor", "repellendus", "temporibus", "autem", "quibusdam", "et", "aut", "consequatur", "vel", "illum", "qui", "dolorem", "eum", "fugiat", "quo", "voluptas", "nulla", "pariatur", "at", "vero", "eos", "et", "accusamus", "officiis", "debitis", "aut", "rerum", "necessitatibus", "saepe", "eveniet", "ut", "et", "voluptates", "repudiandae", "sint", "et", "molestiae", "non", "recusandae", "itaque", "earum", "rerum", "hic", "tenetur", "a", "sapiente", "delectus", "ut", "aut", "reiciendis", "voluptatibus", "maiores", "doloribus", "asperiores", "repellat"] + let timestampNow: TimeInterval = Date().timeIntervalSince1970 + let userSessionId: String = getUserHexEncodedPublicKey() let logProgress: (String, String) -> () = { title, event in guard printProgress else { return } @@ -105,240 +110,270 @@ enum MockDataGenerator { hasStartedGenerationThisRun = true // FIXME: Make sure this data doesn't go off device somehow? - Storage.shared.write { anyTransaction in - guard let transaction: YapDatabaseReadWriteTransaction = anyTransaction as? YapDatabaseReadWriteTransaction else { - return - } - - // First create the thread used to indicate that the mock data has been generated - logProgress("", "Start") + logProgress("", "Start") + + // First create the thread used to indicate that the mock data has been generated + Storage.write { transaction in _ = TSContactThread.getOrCreateThread(withContactSessionID: "MockDatabaseThread", transaction: transaction) + } + + // MARK: - -- DM Thread + + var dmThreadRandomGenerator: ARC4RandomNumberGenerator = ARC4RandomNumberGenerator(seed: dmRandomSeed) + var dmThreadIndex: Int = 0 + logProgress("DM Threads", "Start Generating \(dmThreadCount) threads") + + while dmThreadIndex < dmThreadCount { + let remainingThreads: Int = (dmThreadCount - dmThreadIndex) - // Multiple spaces to make it look more like words - let stringContent: [String] = "abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ 0123456789 ".map { String($0) } - let wordContent: [String] = ["alias", "consequatur", "aut", "perferendis", "sit", "voluptatem", "accusantium", "doloremque", "aperiam", "eaque", "ipsa", "quae", "ab", "illo", "inventore", "veritatis", "et", "quasi", "architecto", "beatae", "vitae", "dicta", "sunt", "explicabo", "aspernatur", "aut", "odit", "aut", "fugit", "sed", "quia", "consequuntur", "magni", "dolores", "eos", "qui", "ratione", "voluptatem", "sequi", "nesciunt", "neque", "dolorem", "ipsum", "quia", "dolor", "sit", "amet", "consectetur", "adipisci", "velit", "sed", "quia", "non", "numquam", "eius", "modi", "tempora", "incidunt", "ut", "labore", "et", "dolore", "magnam", "aliquam", "quaerat", "voluptatem", "ut", "enim", "ad", "minima", "veniam", "quis", "nostrum", "exercitationem", "ullam", "corporis", "nemo", "enim", "ipsam", "voluptatem", "quia", "voluptas", "sit", "suscipit", "laboriosam", "nisi", "ut", "aliquid", "ex", "ea", "commodi", "consequatur", "quis", "autem", "vel", "eum", "iure", "reprehenderit", "qui", "in", "ea", "voluptate", "velit", "esse", "quam", "nihil", "molestiae", "et", "iusto", "odio", "dignissimos", "ducimus", "qui", "blanditiis", "praesentium", "laudantium", "totam", "rem", "voluptatum", "deleniti", "atque", "corrupti", "quos", "dolores", "et", "quas", "molestias", "excepturi", "sint", "occaecati", "cupiditate", "non", "provident", "sed", "ut", "perspiciatis", "unde", "omnis", "iste", "natus", "error", "similique", "sunt", "in", "culpa", "qui", "officia", "deserunt", "mollitia", "animi", "id", "est", "laborum", "et", "dolorum", "fuga", "et", "harum", "quidem", "rerum", "facilis", "est", "et", "expedita", "distinctio", "nam", "libero", "tempore", "cum", "soluta", "nobis", "est", "eligendi", "optio", "cumque", "nihil", "impedit", "quo", "porro", "quisquam", "est", "qui", "minus", "id", "quod", "maxime", "placeat", "facere", "possimus", "omnis", "voluptas", "assumenda", "est", "omnis", "dolor", "repellendus", "temporibus", "autem", "quibusdam", "et", "aut", "consequatur", "vel", "illum", "qui", "dolorem", "eum", "fugiat", "quo", "voluptas", "nulla", "pariatur", "at", "vero", "eos", "et", "accusamus", "officiis", "debitis", "aut", "rerum", "necessitatibus", "saepe", "eveniet", "ut", "et", "voluptates", "repudiandae", "sint", "et", "molestiae", "non", "recusandae", "itaque", "earum", "rerum", "hic", "tenetur", "a", "sapiente", "delectus", "ut", "aut", "reiciendis", "voluptatibus", "maiores", "doloribus", "asperiores", "repellat"] - let timestampNow: TimeInterval = Date().timeIntervalSince1970 - let userSessionId: String = getUserHexEncodedPublicKey() - - // MARK: - -- DM Thread - var dmThreadRandomGenerator: ARC4RandomNumberGenerator = ARC4RandomNumberGenerator(seed: dmRandomSeed) - - (0.. Context { return ((thread as? TSGroupThread)?.isOpenGroup == true) ? .openGroup : .regular + +@objc(OWSDisappearingMessagesConfiguration) +public class _LegacyDisappearingMessagesConfiguration: MTLModel { + public let uniqueId: String + @objc public var isEnabled: Bool + @objc public var durationSeconds: UInt32 + + @objc public var durationIndex: UInt32 = 0 + @objc public var durationString: String? + + var originalDictionaryValue: [String: Any]? + @objc public var isNewRecord: Bool = false + + @objc public static func defaultWith(_ threadId: String) -> Legacy.DisappearingMessagesConfiguration { + return Legacy.DisappearingMessagesConfiguration( + threadId: threadId, + enabled: false, + durationSeconds: (24 * 60 * 60) + ) + } + + public static func fetch(uniqueId: String, transaction: YapDatabaseReadTransaction? = nil) -> Legacy.DisappearingMessagesConfiguration? { + return nil + } + + @objc public static func fetchObject(uniqueId: String) -> Legacy.DisappearingMessagesConfiguration? { + return nil + } + + @objc public static func fetchOrBuildDefault(threadId: String, transaction: YapDatabaseReadTransaction) -> Legacy.DisappearingMessagesConfiguration? { + return defaultWith(threadId) + } + + @objc public static var validDurationsSeconds: [UInt32] = [] + + // MARK: - Initialization + + init(threadId: String, enabled: Bool, durationSeconds: UInt32) { + self.uniqueId = threadId + self.isEnabled = enabled + self.durationSeconds = durationSeconds + self.isNewRecord = true + + super.init() + } + + required init(coder: NSCoder) { + self.uniqueId = coder.decodeObject(forKey: "uniqueId") as! String + self.isEnabled = coder.decodeObject(forKey: "enabled") as! Bool + self.durationSeconds = coder.decodeObject(forKey: "durationSeconds") as! UInt32 + + // Intentionally not calling 'super.init(coder:) here + super.init() + } + + required init(dictionary dictionaryValue: [String : Any]!) throws { + fatalError("init(dictionary:) has not been implemented") + } + + // MARK: - Dirty Tracking + + @objc public override static func storageBehaviorForProperty(withKey propertyKey: String) -> MTLPropertyStorage { + // Don't persist transient properties + if + propertyKey == "TAG" || + propertyKey == "originalDictionaryValue" || + propertyKey == "newRecord" + { + return MTLPropertyStorageNone + } + + return super.storageBehaviorForProperty(withKey: propertyKey) + } + + @objc public var dictionaryValueDidChange: Bool { + return false + } + + @objc(saveWithTransaction:) + public func save(with transaction: YapDatabaseReadWriteTransaction) { + self.originalDictionaryValue = self.dictionaryValue + self.isNewRecord = false } } diff --git a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift index 036ff774c..0b94044d2 100644 --- a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -49,15 +49,17 @@ enum _001_InitialSetupMigration: Migration { t.column(.shouldBeVisible, .boolean).notNull() t.column(.isPinned, .boolean).notNull() t.column(.messageDraft, .text) - t.column(.notificationMode, .integer).notNull() + t.column(.notificationMode, .integer) + .notNull() + .defaults(to: SessionThread.NotificationMode.all) t.column(.mutedUntilTimestamp, .double) } try db.create(table: DisappearingMessagesConfiguration.self) { t in - t.column(.id, .text) + t.column(.threadId, .text) .notNull() .primaryKey() - .references(SessionThread.self) + .references(SessionThread.self, onDelete: .cascade) // Delete if Thread deleted t.column(.isEnabled, .boolean) .defaults(to: false) .notNull() @@ -67,9 +69,10 @@ enum _001_InitialSetupMigration: Migration { } try db.create(table: ClosedGroup.self) { t in - t.column(.publicKey, .text) + t.column(.threadId, .text) .notNull() .primaryKey() + .references(SessionThread.self, onDelete: .cascade) // Delete if Thread deleted t.column(.name, .text).notNull() t.column(.formationTimestamp, .double).notNull() } @@ -77,18 +80,161 @@ enum _001_InitialSetupMigration: Migration { try db.create(table: ClosedGroupKeyPair.self) { t in t.column(.publicKey, .text) .notNull() - .indexed() - .references(ClosedGroup.self) + .indexed() // Quicker querying + .references(ClosedGroup.self, onDelete: .cascade) // Delete if ClosedGroup deleted t.column(.secretKey, .blob).notNull() t.column(.receivedTimestamp, .double).notNull() } + try db.create(table: OpenGroup.self) { t in + t.column(.threadId, .text) + .notNull() + .primaryKey() + .references(SessionThread.self, onDelete: .cascade) // Delete if Thread deleted + t.column(.server, .text).notNull() + t.column(.room, .text).notNull() + t.column(.publicKey, .text).notNull() + t.column(.name, .text).notNull() + t.column(.groupDescription, .text) + t.column(.imageId, .text) + t.column(.imageData, .blob) + t.column(.userCount, .integer).notNull() + t.column(.infoUpdates, .integer).notNull() + } + + try db.create(table: Capability.self) { t in + t.column(.openGroupId, .text) + .notNull() + .indexed() // Quicker querying + .references(OpenGroup.self, onDelete: .cascade) // Delete if OpenGroup deleted + t.column(.capability, .text).notNull() + t.column(.isMissing, .boolean).notNull() + + t.primaryKey([.openGroupId, .capability]) + } + try db.create(table: GroupMember.self) { t in + // Note: Not adding a "proper" foreign key constraint as this + // table gets used by both 'OpenGroup' and 'ClosedGroup' types t.column(.groupId, .text) .notNull() - .indexed() + .indexed() // Quicker querying t.column(.profileId, .text).notNull() t.column(.role, .integer).notNull() } + + try db.create(table: Interaction.self) { t in + t.column(.id, .integer) + .notNull() + .primaryKey(autoincrement: true) + t.column(.serverHash, .text) + t.column(.threadId, .text) + .notNull() + .indexed() // Quicker querying + .references(SessionThread.self, onDelete: .cascade) // Delete if Thread deleted + t.column(.authorId, .text) + .notNull() + .indexed() // Quicker querying + .references(Profile.self) + + t.column(.variant, .integer).notNull() + t.column(.body, .text) + t.column(.timestampMs, .double) + .notNull() + .indexed() // Quicker querying + t.column(.receivedAtTimestampMs, .double).notNull() + t.column(.expiresInSeconds, .double) + t.column(.expiresStartedAtMs, .double) + + t.column(.openGroupInvitationName, .text) + t.column(.openGroupInvitationUrl, .text) + + t.column(.openGroupServerMessageId, .integer) + .indexed() // Quicker querying + t.column(.openGroupWhisperMods, .boolean) + .notNull() + .defaults(to: false) + t.column(.openGroupWhisperTo, .text) + + // Null is not unique in SQLite which allows us to do this and we do + // a joint constraint with the `threadId` on the off chance there is + // a collision between different hashes on different servers + t.uniqueKey([.threadId, .serverHash]) + + // The `openGroupServerMessageId` is unique on a per-thread basis + t.uniqueKey([.threadId, .openGroupServerMessageId]) + + // Note: The timestamp will be unique on a per-message basis so we + // need to add the below unique constraint to handle cases where + // the `serverHash` and `openGroupServerMessageId` can both be null + // to try and prevent duplicate messages (it's theoretically possible + // to get a collision with this constraint but is astronomically unlikely) + t.uniqueKey([.threadId, .serverHash, .openGroupServerMessageId, .timestampMs]) + } + + try db.create(table: RecipientState.self) { t in + t.column(.interactionId, .integer) + .notNull() + .indexed() // Quicker querying + .references(Interaction.self, onDelete: .cascade) // Delete if interaction deleted + t.column(.recipientId, .text) + .notNull() + .references(Profile.self) + t.column(.state, .integer).notNull() + t.column(.readTimestampMs, .double) + + // We want to ensure that a recipient can only have a single state for + // each interaction + t.uniqueKey([.interactionId, .recipientId]) + } + + try db.create(table: Quote.self) { t in + t.column(.interactionId, .integer) + .notNull() + .primaryKey() + .references(Interaction.self, onDelete: .cascade) // Delete if interaction deleted + t.column(.authorId, .text) + .notNull() + .references(Profile.self) + t.column(.timestampMs, .double).notNull() + t.column(.body, .text) + } + + try db.create(table: LinkPreview.self) { t in + t.column(.url, .text) + .notNull() + .primaryKey() + t.column(.interactionId, .integer) + .notNull() + .indexed() // Quicker querying + .references(Interaction.self, onDelete: .cascade) // Delete if interaction deleted + t.column(.title, .text) + } + + try db.create(table: Attachment.self) { t in + t.column(.interactionId, .integer) + .notNull() + .indexed() // Quicker querying + .references(Interaction.self, onDelete: .cascade) // Delete if interaction deleted + t.column(.serverId, .text) + t.column(.variant, .integer).notNull() + t.column(.state, .integer).notNull() + t.column(.contentType, .text).notNull() + t.column(.byteCount, .integer) + .notNull() + .defaults(to: 0) + t.column(.creationTimestamp, .double) + t.column(.sourceFilename, .text) + t.column(.downloadUrl, .text) + t.column(.width, .integer) + t.column(.height, .integer) + t.column(.encryptionKey, .blob) + t.column(.digest, .blob) + t.column(.caption, .text) + t.column(.quoteId, .text) + .references(Quote.self, onDelete: .cascade) // Delete if Quote deleted + t.column(.linkPreviewUrl, .text) + .references(LinkPreview.self, onDelete: .cascade) // Delete if LinkPreview deleted + } } } diff --git a/SessionMessagingKit/Database/Migrations/_002_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_002_YDBToGRDBMigration.swift index a8d699335..fd14227d1 100644 --- a/SessionMessagingKit/Database/Migrations/_002_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_002_YDBToGRDBMigration.swift @@ -15,14 +15,25 @@ enum _002_YDBToGRDBMigration: Migration { var shouldFailMigration: Bool = false var contacts: Set = [] var contactThreadIds: Set = [] + var threads: Set = [] var disappearingMessagesConfiguration: [String: Legacy.DisappearingMessagesConfiguration] = [:] + var closedGroupKeys: [String: (timestamp: TimeInterval, keys: SessionUtilitiesKit.Legacy.KeyPair)] = [:] var closedGroupName: [String: String] = [:] var closedGroupFormation: [String: UInt64] = [:] var closedGroupModel: [String: TSGroupModel] = [:] var closedGroupZombieMemberIds: [String: Set] = [:] + var openGroupInfo: [String: OpenGroupV2] = [:] + var openGroupUserCount: [String: Int] = [:] + var openGroupImage: [String: Data] = [:] + var openGroupLastMessageServerId: [String: Int64] = [:] // Optional + var openGroupLastDeletionServerId: [String: Int64] = [:] // Optional + + var interactions: [String: [TSInteraction]] = [:] + var attachments: [String: TSAttachment] = [:] + var readReceipts: [String: [Double]] = [:] Storage.read { transaction in // Process the Contacts @@ -30,7 +41,8 @@ enum _002_YDBToGRDBMigration: Migration { guard let contact = object as? Legacy.Contact else { return } contacts.insert(contact) } - + + print("RAWR [\(Date().timeIntervalSince1970)] - Process threads - Start") let userClosedGroupPublicKeys: [String] = transaction.allKeys(inCollection: Legacy.closedGroupPublicKeyCollection) // Process the threads @@ -52,6 +64,8 @@ enum _002_YDBToGRDBMigration: Migration { .asType(Legacy.DisappearingMessagesConfiguration.self) .defaulting(to: Legacy.DisappearingMessagesConfiguration.defaultWith(threadId)) + // Process the interactions + // Process group-specific info guard let groupThread: TSGroupThread = thread as? TSGroupThread else { return } @@ -64,15 +78,14 @@ enum _002_YDBToGRDBMigration: Migration { guard let groupIdData: Data = Data(base64Encoded: base64GroupId), let groupId: String = String(data: groupIdData, encoding: .utf8), - let publicKey: String = groupId.split(separator: "!").last.map({ String($0) }), - let formationTimestamp: UInt64 = transaction.object(forKey: publicKey, inCollection: Legacy.closedGroupFormationTimestampCollection) as? UInt64 + let publicKey: String = groupId.split(separator: "!").last.map({ String($0) }) else { - SNLog("Unable to decode Closed Group during migration") + SNLog("[Migration Error] Unable to decode Closed Group") shouldFailMigration = true return } guard userClosedGroupPublicKeys.contains(publicKey) else { - SNLog("Found unexpected invalid closed group public key during migration") + SNLog("[Migration Error] Found unexpected invalid closed group public key") shouldFailMigration = true return } @@ -81,7 +94,7 @@ enum _002_YDBToGRDBMigration: Migration { closedGroupName[threadId] = groupThread.name(with: transaction) closedGroupModel[threadId] = groupThread.groupModel - closedGroupFormation[threadId] = formationTimestamp + closedGroupFormation[threadId] = ((transaction.object(forKey: publicKey, inCollection: Legacy.closedGroupFormationTimestampCollection) as? UInt64) ?? 0) closedGroupZombieMemberIds[threadId] = transaction.object( forKey: publicKey, inCollection: Legacy.closedGroupZombieMembersCollection @@ -96,11 +109,48 @@ enum _002_YDBToGRDBMigration: Migration { } } else if groupThread.isOpenGroup { + guard let openGroup: OpenGroupV2 = transaction.object(forKey: threadId, inCollection: Legacy.openGroupCollection) as? OpenGroupV2 else { + SNLog("[Migration Error] Unable to find open group info") + shouldFailMigration = true + return + } + openGroupInfo[threadId] = openGroup + openGroupUserCount[threadId] = ((transaction.object(forKey: openGroup.id, inCollection: Legacy.openGroupUserCountCollection) as? Int) ?? 0) + openGroupImage[threadId] = transaction.object(forKey: openGroup.id, inCollection: Legacy.openGroupImageCollection) as? Data + openGroupLastMessageServerId[threadId] = transaction.object(forKey: openGroup.id, inCollection: Legacy.openGroupLastMessageServerIDCollection) as? Int64 + openGroupLastDeletionServerId[threadId] = transaction.object(forKey: openGroup.id, inCollection: Legacy.openGroupLastDeletionServerIDCollection) as? Int64 + } + } + print("RAWR [\(Date().timeIntervalSince1970)] - Process threads - End") + + // Process interactions + print("RAWR [\(Date().timeIntervalSince1970)] - Process interactions - Start") + transaction.enumerateKeysAndObjects(inCollection: Legacy.interactionCollection) { _, object, _ in + guard let interaction: TSInteraction = object as? TSInteraction else { + SNLog("[Migration Error] Unable to process interaction") + shouldFailMigration = true + return } + interactions[interaction.uniqueThreadId] = (interactions[interaction.uniqueThreadId] ?? []) + .appending(interaction) + } + print("RAWR [\(Date().timeIntervalSince1970)] - Process interactions - End") + + // Process attachments + print("RAWR [\(Date().timeIntervalSince1970)] - Process attachments - Start") + transaction.enumerateKeysAndObjects(inCollection: Legacy.attachmentsCollection) { key, object, _ in + guard let attachment: TSAttachment = object as? TSAttachment else { + SNLog("[Migration Error] Unable to process attachment") + shouldFailMigration = true + return + } - + attachments[key] = attachment + } + print("RAWR [\(Date().timeIntervalSince1970)] - Process attachments - End") + } } @@ -111,46 +161,49 @@ enum _002_YDBToGRDBMigration: Migration { // MARK: - Insert Contacts - let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) - - try contacts.forEach { contact in - let isCurrentUser: Bool = (contact.sessionID == currentUserPublicKey) - let contactThreadId: String = TSContactThread.threadID(fromContactSessionID: contact.sessionID) + try autoreleasepool { + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) - // Create the "Profile" for the legacy contact - try Profile( - id: contact.sessionID, - name: (contact.name ?? contact.sessionID), - nickname: contact.nickname, - profilePictureUrl: contact.profilePictureURL, - profilePictureFileName: contact.profilePictureFileName, - profileEncryptionKey: contact.profileEncryptionKey - ).insert(db) - - // Determine if this contact is a "real" contact (don't want to create contacts for - // every user in the new structure but still want profiles for every user) - if - isCurrentUser || - contactThreadIds.contains(contactThreadId) || - contact.isApproved || - contact.didApproveMe || - contact.isBlocked || - contact.hasBeenBlocked { - // Create the contact - // TODO: Closed group admins??? - try Contact( + try contacts.forEach { contact in + let isCurrentUser: Bool = (contact.sessionID == currentUserPublicKey) + let contactThreadId: String = TSContactThread.threadID(fromContactSessionID: contact.sessionID) + + // Create the "Profile" for the legacy contact + try Profile( id: contact.sessionID, - isTrusted: (isCurrentUser || contact.isTrusted), - isApproved: (isCurrentUser || contact.isApproved), - isBlocked: (!isCurrentUser && contact.isBlocked), - didApproveMe: (isCurrentUser || contact.didApproveMe), - hasBeenBlocked: (!isCurrentUser && (contact.hasBeenBlocked || contact.isBlocked)) + name: (contact.name ?? contact.sessionID), + nickname: contact.nickname, + profilePictureUrl: contact.profilePictureURL, + profilePictureFileName: contact.profilePictureFileName, + profileEncryptionKey: contact.profileEncryptionKey ).insert(db) + + // Determine if this contact is a "real" contact (don't want to create contacts for + // every user in the new structure but still want profiles for every user) + if + isCurrentUser || + contactThreadIds.contains(contactThreadId) || + contact.isApproved || + contact.didApproveMe || + contact.isBlocked || + contact.hasBeenBlocked { + // Create the contact + try Contact( + id: contact.sessionID, + isTrusted: (isCurrentUser || contact.isTrusted), + isApproved: (isCurrentUser || contact.isApproved), + isBlocked: (!isCurrentUser && contact.isBlocked), + didApproveMe: (isCurrentUser || contact.didApproveMe), + hasBeenBlocked: (!isCurrentUser && (contact.hasBeenBlocked || contact.isBlocked)) + ).insert(db) + } } } // MARK: - Insert Threads + print("RAWR [\(Date().timeIntervalSince1970)] - Process thread inserts - Start") + try threads.forEach { thread in guard let legacyThreadId: String = thread.uniqueId else { return } @@ -161,11 +214,17 @@ enum _002_YDBToGRDBMigration: Migration { switch thread { case let groupThread as TSGroupThread: if groupThread.isOpenGroup { - id = legacyThreadId//openGroup.id + guard let openGroup: OpenGroupV2 = openGroupInfo[legacyThreadId] else { + SNLog("[Migration Error] Open group missing required data") + throw GRDBStorageError.migrationFailed + } + + id = openGroup.id variant = .openGroup } else { guard let publicKey: Data = closedGroupKeys[legacyThreadId]?.keys.publicKey else { + SNLog("[Migration Error] Closed group missing public key") throw GRDBStorageError.migrationFailed } @@ -186,71 +245,262 @@ enum _002_YDBToGRDBMigration: Migration { notificationMode = (thread.isMuted ? .none : .all) } - try SessionThread( - id: id, - variant: variant, - creationDateTimestamp: thread.creationDate.timeIntervalSince1970, - shouldBeVisible: thread.shouldBeVisible, - isPinned: thread.isPinned, - messageDraft: thread.messageDraft, - notificationMode: notificationMode, - mutedUntilTimestamp: thread.mutedUntilDate?.timeIntervalSince1970 - ).insert(db) - - // Disappearing Messages Configuration - if let config: Legacy.DisappearingMessagesConfiguration = disappearingMessagesConfiguration[id] { - try DisappearingMessagesConfiguration( + try autoreleasepool { + try SessionThread( id: id, - isEnabled: config.isEnabled, - durationSeconds: TimeInterval(config.durationSeconds) + variant: variant, + creationDateTimestamp: thread.creationDate.timeIntervalSince1970, + shouldBeVisible: thread.shouldBeVisible, + isPinned: thread.isPinned, + messageDraft: thread.messageDraft, + notificationMode: notificationMode, + mutedUntilTimestamp: thread.mutedUntilDate?.timeIntervalSince1970 ).insert(db) + + // Disappearing Messages Configuration + if let config: Legacy.DisappearingMessagesConfiguration = disappearingMessagesConfiguration[id] { + try DisappearingMessagesConfiguration( + threadId: id, + isEnabled: config.isEnabled, + durationSeconds: TimeInterval(config.durationSeconds) + ).insert(db) + } + + // Closed Groups + if (thread as? TSGroupThread)?.isClosedGroup == true { + guard + let keyInfo = closedGroupKeys[legacyThreadId], + let name: String = closedGroupName[legacyThreadId], + let groupModel: TSGroupModel = closedGroupModel[legacyThreadId], + let formationTimestamp: UInt64 = closedGroupFormation[legacyThreadId] + else { + SNLog("[Migration Error] Closed group missing required data") + throw GRDBStorageError.migrationFailed + } + + try ClosedGroup( + threadId: id, + name: name, + formationTimestamp: TimeInterval(formationTimestamp) + ).insert(db) + + try ClosedGroupKeyPair( + publicKey: keyInfo.keys.publicKey.toHexString(), + secretKey: keyInfo.keys.privateKey, + receivedTimestamp: keyInfo.timestamp + ).insert(db) + + try groupModel.groupMemberIds.forEach { memberId in + try GroupMember( + groupId: id, + profileId: memberId, + role: .standard + ).insert(db) + } + + try groupModel.groupAdminIds.forEach { adminId in + try GroupMember( + groupId: id, + profileId: adminId, + role: .admin + ).insert(db) + } + + try (closedGroupZombieMemberIds[legacyThreadId] ?? []).forEach { zombieId in + try GroupMember( + groupId: id, + profileId: zombieId, + role: .zombie + ).insert(db) + } + } + + // Open Groups + if (thread as? TSGroupThread)?.isOpenGroup == true { + guard let openGroup: OpenGroupV2 = openGroupInfo[legacyThreadId] else { + SNLog("[Migration Error] Open group missing required data") + throw GRDBStorageError.migrationFailed + } + + try OpenGroup( + server: openGroup.server, + room: openGroup.room, + publicKey: openGroup.publicKey, + name: openGroup.name, + groupDescription: nil, // TODO: Add with SOGS V4 + imageId: nil, // TODO: Add with SOGS V4 + imageData: openGroupImage[legacyThreadId], + userCount: (openGroupUserCount[legacyThreadId] ?? 0), // Will be updated next poll + infoUpdates: 0 // TODO: Add with SOGS V4 + ).insert(db) + } } - // Closed Groups - if (thread as? TSGroupThread)?.isClosedGroup == true { - guard - let keyInfo = closedGroupKeys[legacyThreadId], - let name: String = closedGroupName[legacyThreadId], - let groupModel: TSGroupModel = closedGroupModel[legacyThreadId], - let formationTimestamp: UInt64 = closedGroupFormation[legacyThreadId] - else { throw GRDBStorageError.migrationFailed } + try autoreleasepool { + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) - try ClosedGroup( - publicKey: keyInfo.keys.publicKey.toHexString(), - name: name, - formationTimestamp: TimeInterval(formationTimestamp) - ).insert(db) - - try ClosedGroupKeyPair( - publicKey: keyInfo.keys.publicKey.toHexString(), - secretKey: keyInfo.keys.privateKey, - receivedTimestamp: keyInfo.timestamp - ).insert(db) - - try groupModel.groupMemberIds.forEach { memberId in - try GroupMember( - groupId: id, - profileId: memberId, - role: .standard - ).insert(db) - } - - try groupModel.groupAdminIds.forEach { adminId in - try GroupMember( - groupId: id, - profileId: adminId, - role: .admin - ).insert(db) - } - - try (closedGroupZombieMemberIds[legacyThreadId] ?? []).forEach { zombieId in - try GroupMember( - groupId: id, - profileId: zombieId, - role: .zombie - ).insert(db) + try interactions[legacyThreadId]? + .sorted(by: { lhs, rhs in lhs.sortId < rhs.sortId }) // Maintain sort order + .forEach { legacyInteraction in + let serverHash: String? + let variant: Interaction.Variant + let authorId: String + let body: String? + let expiresInSeconds: UInt32? + let expiresStartedAtMs: UInt64? + let openGroupInvitationName: String? + let openGroupInvitationUrl: String? + let openGroupServerMessageId: UInt64? + let recipientStateMap: [String: TSOutgoingMessageRecipientState]? + let attachmentIds: [String] + + // Handle the common 'TSMessage' values first + if let legacyMessage: TSMessage = legacyInteraction as? TSMessage { + serverHash = legacyMessage.serverHash + openGroupInvitationName = legacyMessage.openGroupInvitationName + openGroupInvitationUrl = legacyMessage.openGroupInvitationURL + + // The legacy code only considered '!= 0' ids as valid so set those + // values to be null to avoid the unique constraint (it's also more + // correct for the values to be null) + openGroupServerMessageId = (legacyMessage.openGroupServerMessageID == 0 ? + nil : + legacyMessage.openGroupServerMessageID + ) + attachmentIds = try legacyMessage.attachmentIds.map { legacyId in + guard let attachmentId: String = legacyId as? String else { + SNLog("[Migration Error] Unable to process attachment id") + throw GRDBStorageError.migrationFailed + } + + return attachmentId + } + } + else { + serverHash = nil + openGroupInvitationName = nil + openGroupInvitationUrl = nil + openGroupServerMessageId = nil + attachmentIds = [] + } + + // Then handle the behaviours for each message type + switch legacyInteraction { + case let incomingMessage as TSIncomingMessage: + variant = .standardIncoming + authorId = incomingMessage.authorId + body = incomingMessage.body + expiresInSeconds = incomingMessage.expiresInSeconds + expiresStartedAtMs = incomingMessage.expireStartedAt + recipientStateMap = [:] + + + case let outgoingMessage as TSOutgoingMessage: + variant = .standardOutgoing + authorId = currentUserPublicKey + body = outgoingMessage.body + expiresInSeconds = outgoingMessage.expiresInSeconds + expiresStartedAtMs = outgoingMessage.expireStartedAt + recipientStateMap = outgoingMessage.recipientStateMap + + case let infoMessage as TSInfoMessage: + authorId = currentUserPublicKey + body = ((infoMessage.body ?? "").isEmpty ? + infoMessage.customMessage : + infoMessage.body + ) + expiresInSeconds = nil // Info messages don't expire + expiresStartedAtMs = nil // Info messages don't expire + recipientStateMap = [:] + + switch infoMessage.messageType { + case .groupCreated: variant = .infoClosedGroupCreated + case .groupUpdated: variant = .infoClosedGroupUpdated + case .groupCurrentUserLeft: variant = .infoClosedGroupCurrentUserLeft + case .disappearingMessagesUpdate: variant = .infoDisappearingMessagesUpdate + case .messageRequestAccepted: variant = .infoMessageRequestAccepted + case .screenshotNotification: variant = .infoScreenshotNotification + case .mediaSavedNotification: variant = .infoMediaSavedNotification + + @unknown default: + SNLog("[Migration Error] Unsupported info message type") + throw GRDBStorageError.migrationFailed + } + + default: + SNLog("[Migration Error] Unsupported interaction type") + throw GRDBStorageError.migrationFailed + } + + // Insert the data + let interaction = try Interaction( + serverHash: serverHash, + threadId: id, + authorId: authorId, + variant: variant, + body: body, + timestampMs: Double(legacyInteraction.timestamp), + receivedAtTimestampMs: Double(legacyInteraction.receivedAtTimestamp), + expiresInSeconds: expiresInSeconds.map { TimeInterval($0) }, + expiresStartedAtMs: expiresStartedAtMs.map { Double($0) }, + openGroupInvitationName: openGroupInvitationName, + openGroupInvitationUrl: openGroupInvitationUrl, + openGroupServerMessageId: openGroupServerMessageId.map { Int64($0) }, + openGroupWhisperMods: false, // TODO: This + openGroupWhisperTo: nil // TODO: This + ).inserted(db) + + guard let interactionId: Int64 = interaction.id else { + SNLog("[Migration Error] Failed to insert interaction") + throw GRDBStorageError.migrationFailed + } + + try recipientStateMap?.forEach { recipientId, legacyState in + try RecipientState( + interactionId: interactionId, + recipientId: recipientId, + state: { + switch legacyState.state { + case .failed: return .failed + case .sending: return .sending + case .skipped: return .skipped + case .sent: return .sent + @unknown default: throw GRDBStorageError.migrationFailed + } + }(), + readTimestampMs: legacyState.readTimestamp?.doubleValue + ).insert(db) + } + try attachmentIds.forEach { attachmentId in + guard let attachment: TSAttachment = attachments[attachmentId] else { + SNLog("[Migration Error] Unsupported interaction type") + throw GRDBStorageError.migrationFailed + } + try Attachment( + interactionId: interactionId, + serverId: "\(attachment.serverId)", + variant: (attachment.isVoiceMessage ? .voiceMessage : .standard), + state: .pending, // TODO: This + contentType: attachment.contentType, + byteCount: UInt(attachment.byteCount), + creationTimestamp: 0, // TODO: This + sourceFilename: attachment.sourceFilename, + downloadUrl: attachment.downloadURL, + width: 0, // TODO: This attachment.mediaSize, + height: 0, // TODO: This attachment.mediaSize, + encryptionKey: attachment.encryptionKey, + digest: nil, // TODO: This attachment.digest, + caption: attachment.caption, + quoteId: nil, // TODO: THis + linkPreviewUrl: nil // TODO: This + ).insert(db) + } } } } + + print("RAWR [\(Date().timeIntervalSince1970)] - Process thread inserts - End") + + print("RAWR Done!!!") } } diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift new file mode 100644 index 000000000..464555c31 --- /dev/null +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -0,0 +1,131 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public struct Attachment: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "attachment" } + internal static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id]) + internal static let quoteForeignKey = ForeignKey([Columns.quoteId], to: [Quote.Columns.interactionId]) + internal static let linkPreviewForeignKey = ForeignKey( + [Columns.linkPreviewUrl], + to: [LinkPreview.Columns.url] + ) + private static let interaction = belongsTo(Interaction.self, using: interactionForeignKey) + private static let quote = belongsTo(Quote.self, using: quoteForeignKey) + private static let linkPreview = belongsTo(LinkPreview.self, using: linkPreviewForeignKey) + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case interactionId + case serverId + case variant + case state + case contentType + case byteCount + case creationTimestamp + case sourceFilename + case downloadUrl + case width + case height + case encryptionKey + case digest + case caption + case quoteId + case linkPreviewUrl + } + + public enum Variant: Int, Codable, DatabaseValueConvertible { + case standard + case voiceMessage + } + + public enum State: Int, Codable, DatabaseValueConvertible { + case pending + case downloading + case downloaded + case uploading + case uploaded + case failed + } + + /// The id for the interaction this attachment belongs to + public let interactionId: Int64 + + /// The id for the attachment returned by the server + /// + /// This will be null for attachments which haven’t completed uploading + /// + /// **Note:** This value is not unique as multiple SOGS could end up having the same file id + public let serverId: String? + + /// The type of this attachment, used to distinguish logic handling + public let variant: Variant + + /// The current state of the attachment + public let state: State + + /// The MIMEType for the attachment + public let contentType: String + + /// The size of the attachment in bytes + /// + /// **Note:** This may be `0` for some legacy attachments + public let byteCount: UInt + + /// Timestamp in seconds since epoch for when this attachment was created + /// + /// **Uploaded:** This will be the timestamp the file finished uploading + /// **Downloaded:** This will be the timestamp the file finished downloading + public let creationTimestamp: TimeInterval? + + /// Represents the "source" filename sent or received in the protos, not the filename on disk + public let sourceFilename: String? + + /// The url the attachment can be downloaded from, this will be `null` for attachments which haven’t yet been uploaded + /// + /// **Note:** The url is a fully constructed url but the clients just extract the id from the end of the url to perform the actual download + public let downloadUrl: String? + + /// The width of the attachment, this will be `null` for non-visual attachment types + public let width: UInt? + + /// The height of the attachment, this will be `null` for non-visual attachment types + public let height: UInt? + + /// The key used to decrypt the attachment + public let encryptionKey: Data? + + /// The computed digest for the attachment (generated from `iv || encrypted data || hmac`) + public let digest: Data? + + /// Caption for the attachment + public let caption: String? + + /// The id for the QuotedMessage if this attachment belongs to one + /// + /// **Note:** If this value is present then this attachment shouldn't be returned as a + /// standard attachment for the interaction + public let quoteId: String? + + /// The id for the LinkPreview if this attachment belongs to one + /// + /// **Note:** If this value is present then this attachment shouldn't be returned as a + /// standard attachment for the interaction + public let linkPreviewUrl: String? + + // MARK: - Relationships + + public var interaction: QueryInterfaceRequest { + request(for: Attachment.interaction) + } + + public var quote: QueryInterfaceRequest { + request(for: Attachment.quote) + } + + public var linkPreview: QueryInterfaceRequest { + request(for: Attachment.linkPreview) + } +} diff --git a/SessionMessagingKit/Database/Models/Capability.swift b/SessionMessagingKit/Database/Models/Capability.swift index f4c51e2fe..4b5be2f0b 100644 --- a/SessionMessagingKit/Database/Models/Capability.swift +++ b/SessionMessagingKit/Database/Models/Capability.swift @@ -6,17 +6,23 @@ import SessionUtilitiesKit public struct Capability: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "capability" } + internal static let openGroupForeignKey = ForeignKey([Columns.openGroupId], to: [OpenGroup.Columns.threadId]) + private static let openGroup = belongsTo(OpenGroup.self, using: openGroupForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { - case server - case room + case openGroupId case capability case isMissing } - public let server: String - public let room: String + public let openGroupId: String public let capability: String public let isMissing: Bool + + // MARK: - Relationships + + public var openGroup: QueryInterfaceRequest { + request(for: Capability.openGroup) + } } diff --git a/SessionMessagingKit/Database/Models/ClosedGroup.swift b/SessionMessagingKit/Database/Models/ClosedGroup.swift index cd554a1a7..b39114288 100644 --- a/SessionMessagingKit/Database/Models/ClosedGroup.swift +++ b/SessionMessagingKit/Database/Models/ClosedGroup.swift @@ -6,22 +6,37 @@ import SessionUtilitiesKit public struct ClosedGroup: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "closedGroup" } - static let keyPairs = hasMany(ClosedGroupKeyPair.self) - static let members = hasMany(GroupMember.self) + internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id]) + private static let thread = belongsTo(SessionThread.self, using: threadForeignKey) + private static let keyPairs = hasMany( + ClosedGroupKeyPair.self, + using: ClosedGroupKeyPair.closedGroupForeignKey + ) + private static let members = hasMany(GroupMember.self, using: GroupMember.closedGroupForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { - case publicKey + case threadId case name case formationTimestamp } - public var id: String { publicKey } + public var id: String { threadId } // Identifiable + public var publicKey: String { threadId } - public let publicKey: String + /// The id for the thread this closed group belongs to + /// + /// **Note:** This value will always be publicKey for the closed group + public let threadId: String public let name: String public let formationTimestamp: TimeInterval + // MARK: - Relationships + + public var thread: QueryInterfaceRequest { + request(for: ClosedGroup.thread) + } + public var keyPairs: QueryInterfaceRequest { request(for: ClosedGroup.keyPairs) } @@ -45,4 +60,14 @@ public struct ClosedGroup: Codable, Identifiable, FetchableRecord, PersistableRe request(for: ClosedGroup.members) .filter(GroupMember.Columns.role == GroupMember.Role.admin) } + + // MARK: - Custom Database Interaction + + public func delete(_ db: Database) throws -> Bool { + // Delete all 'GroupMember' records associated with this ClosedGroup (can't + // have a proper ForeignKey constraint as 'GroupMember' is reused for the + // 'OpenGroup' table as well) + try request(for: ClosedGroup.members).deleteAll(db) + return try performDelete(db) + } } diff --git a/SessionMessagingKit/Database/Models/ClosedGroupKeyPair.swift b/SessionMessagingKit/Database/Models/ClosedGroupKeyPair.swift index 95980a19e..b1b5eac3b 100644 --- a/SessionMessagingKit/Database/Models/ClosedGroupKeyPair.swift +++ b/SessionMessagingKit/Database/Models/ClosedGroupKeyPair.swift @@ -6,6 +6,11 @@ import SessionUtilitiesKit public struct ClosedGroupKeyPair: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "closedGroupKeyPair" } + internal static let closedGroupForeignKey = ForeignKey( + [Columns.publicKey], + to: [ClosedGroup.Columns.threadId] + ) + private static let closedGroup = belongsTo(ClosedGroup.self, using: closedGroupForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -19,4 +24,10 @@ public struct ClosedGroupKeyPair: Codable, Identifiable, FetchableRecord, Persis public let publicKey: String public let secretKey: Data public let receivedTimestamp: TimeInterval + + // MARK: - Relationships + + public var closedGroup: QueryInterfaceRequest { + request(for: ClosedGroupKeyPair.closedGroup) + } } diff --git a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift index d8aac5d0c..2a5d4db4e 100644 --- a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift +++ b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift @@ -6,17 +6,27 @@ import SessionUtilitiesKit public struct DisappearingMessagesConfiguration: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "disappearingMessagesConfiguration" } + internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id]) + private static let thread = belongsTo(SessionThread.self, using: threadForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { - case id + case threadId case isEnabled case durationSeconds } + + public var id: String { threadId } // Identifiable - public let id: String + public let threadId: String public let isEnabled: Bool public let durationSeconds: TimeInterval + + // MARK: - Relationships + + public var thread: QueryInterfaceRequest { + request(for: DisappearingMessagesConfiguration.thread) + } } // MARK: - Convenience diff --git a/SessionMessagingKit/Database/Models/GroupMember.swift b/SessionMessagingKit/Database/Models/GroupMember.swift index 2e2e93345..f5a5aa45a 100644 --- a/SessionMessagingKit/Database/Models/GroupMember.swift +++ b/SessionMessagingKit/Database/Models/GroupMember.swift @@ -6,6 +6,12 @@ import SessionUtilitiesKit public struct GroupMember: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "groupMember" } + internal static let openGroupForeignKey = ForeignKey([Columns.groupId], to: [OpenGroup.Columns.threadId]) + internal static let closedGroupForeignKey = ForeignKey([Columns.groupId], to: [ClosedGroup.Columns.threadId]) + internal static let profileForeignKey = ForeignKey([Columns.profileId], to: [Profile.Columns.id]) + private static let openGroup = belongsTo(OpenGroup.self, using: openGroupForeignKey) + private static let closedGroup = belongsTo(ClosedGroup.self, using: closedGroupForeignKey) + private static let profile = hasOne(Profile.self, using: profileForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -24,4 +30,18 @@ public struct GroupMember: Codable, FetchableRecord, PersistableRecord, TableRec public let groupId: String public let profileId: String public let role: Role + + // MARK: - Relationships + + public var openGroup: QueryInterfaceRequest { + request(for: GroupMember.openGroup) + } + + public var closedGroup: QueryInterfaceRequest { + request(for: GroupMember.closedGroup) + } + + public var profile: QueryInterfaceRequest { + request(for: GroupMember.profile) + } } diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift new file mode 100644 index 000000000..eb16d54eb --- /dev/null +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -0,0 +1,211 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public struct Interaction: Codable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "interaction" } + internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id]) + internal static let profileForeignKey = ForeignKey([Columns.authorId], to: [Profile.Columns.id]) + private static let thread = belongsTo(SessionThread.self, using: threadForeignKey) + private static let profile = hasOne(Profile.self, using: profileForeignKey) + private static let attachments = hasMany(Attachment.self, using: Attachment.interactionForeignKey) + private static let quote = hasOne(Quote.self, using: Quote.interactionForeignKey) + private static let linkPreview = hasOne(LinkPreview.self, using: LinkPreview.interactionForeignKey) + private static let recipientStates = hasMany(RecipientState.self, using: RecipientState.interactionForeignKey) + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case id + case serverHash + case threadId + case authorId + + case variant + case body + case timestampMs + case receivedAtTimestampMs + + case expiresInSeconds + case expiresStartedAtMs + + case openGroupInvitationName + case openGroupInvitationUrl + + // Open Group specific properties + + case openGroupServerMessageId + case openGroupWhisperMods + case openGroupWhisperTo + } + + public enum Variant: Int, Codable, DatabaseValueConvertible { + case standardIncoming + case standardOutgoing + + // Info Message Types (spacing the values out to make it easier to extend) + case infoClosedGroupCreated = 1000 + case infoClosedGroupUpdated + case infoClosedGroupCurrentUserLeft + + case infoDisappearingMessagesUpdate = 2000 + + case infoScreenshotNotification = 3000 + case infoMediaSavedNotification + + case infoMessageRequestAccepted = 4000 + } + + /// The `id` value is auto incremented by the database, if the `Interaction` hasn't been inserted into + /// the database yet this value will be `nil` + public var id: Int64? = nil + + /// The hash returned by the server when this message was created on the server + /// + /// **Note:** This will only be populated for `standardIncoming`/`standardOutgoing` interactions + /// from either `contact` or `closedGroup` threads + public let serverHash: String? + + /// The id of the thread that this interaction belongs to (used to expose the `thread` variable) + private let threadId: String + + /// The id of the user who sent the message, also used to expose the `profile` variable) + public let authorId: String + + /// The type of interaction + public let variant: Variant + + /// The body of this interaction + public let body: String? + + /// When the interaction was created in milliseconds since epoch + public let timestampMs: Double + + /// When the interaction was received in milliseconds since epoch + public let receivedAtTimestampMs: Double + + /// The number of seconds until this message should expire + public fileprivate(set) var expiresInSeconds: TimeInterval? = nil + + /// The timestamp in milliseconds since 1970 at which this messages expiration timer started counting + /// down (this is stored in order to allow the `expiresInSeconds` value to be updated before a + /// message has expired) + public fileprivate(set) var expiresStartedAtMs: Double? = nil + + /// When sending an Open Group invitation this will be populated with the name of the open group + public let openGroupInvitationName: String? + + /// When sending an Open Group invitation this will be populated with the url of the open group + public let openGroupInvitationUrl: String? + + // Open Group specific properties + + /// The `openGroupServerMessageId` value will only be set for messages from SOGS + public fileprivate(set) var openGroupServerMessageId: Int64? = nil + + /// This flag indicates whether this interaction is a whisper to the mods of an Open Group + public let openGroupWhisperMods: Bool + + /// This value is the id of the user within an Open Group who is the target of this whisper interaction + public let openGroupWhisperTo: String? + + // MARK: - Relationships + + public var thread: QueryInterfaceRequest { + request(for: Interaction.thread) + } + + public var profile: QueryInterfaceRequest { + request(for: Interaction.profile) + } + + public var attachments: QueryInterfaceRequest { + request(for: Interaction.attachments) + .filter( + Attachment.Columns.quoteId == nil && + Attachment.Columns.linkPreviewUrl == nil + ) + } + + public var quote: QueryInterfaceRequest { + request(for: Interaction.quote) + } + + public var linkPreview: QueryInterfaceRequest { + request(for: Interaction.linkPreview) + } + + public var recipientStates: QueryInterfaceRequest { + request(for: Interaction.recipientStates) + } + + // MARK: - Initialization + + // TODO: Do we actually want these values to have defaults? (check how messages are getting created - convenience constructors??) + init( + serverHash: String?, + threadId: String, + authorId: String, + variant: Variant, + body: String?, + timestampMs: Double, + receivedAtTimestampMs: Double, + expiresInSeconds: TimeInterval?, + expiresStartedAtMs: Double?, + openGroupInvitationName: String?, + openGroupInvitationUrl: String?, + openGroupServerMessageId: Int64?, + openGroupWhisperMods: Bool, + openGroupWhisperTo: String? + ) { + self.serverHash = serverHash + self.threadId = threadId + self.authorId = authorId + self.variant = variant + self.body = body + self.timestampMs = timestampMs + self.receivedAtTimestampMs = receivedAtTimestampMs + self.expiresInSeconds = expiresInSeconds + self.expiresStartedAtMs = expiresStartedAtMs + self.openGroupInvitationName = openGroupInvitationName + self.openGroupInvitationUrl = openGroupInvitationUrl + self.openGroupServerMessageId = openGroupServerMessageId + self.openGroupWhisperMods = openGroupWhisperMods + self.openGroupWhisperTo = openGroupWhisperTo + } + + // MARK: - Custom Database Interaction + + public mutating func didInsert(with rowID: Int64, for column: String?) { + self.id = rowID + } +} + +// MARK: - Convenience + +public extension Interaction { + // MARK: - Variables + + var isExpiringMessage: Bool { + guard variant == .standardIncoming || variant == .standardOutgoing else { return false } + + return (expiresInSeconds ?? 0 > 0) + } + + var openGroupWhisper: Bool { return (openGroupWhisperMods || (openGroupWhisperTo != nil)) } + + // MARK: - Functions + + func with( + expiresInSeconds: TimeInterval? = nil, + expiresStartedAtMs: Double? = nil, + openGroupServerMessageId: Int64? = nil + ) -> Interaction { + var updatedInteraction: Interaction = self + updatedInteraction.expiresInSeconds = (expiresInSeconds ?? updatedInteraction.expiresInSeconds) + updatedInteraction.expiresStartedAtMs = (expiresStartedAtMs ?? updatedInteraction.expiresStartedAtMs) + updatedInteraction.openGroupServerMessageId = (openGroupServerMessageId ?? updatedInteraction.openGroupServerMessageId) + return updatedInteraction + } +} diff --git a/SessionMessagingKit/Database/Models/LinkPreview.swift b/SessionMessagingKit/Database/Models/LinkPreview.swift new file mode 100644 index 000000000..15d92651e --- /dev/null +++ b/SessionMessagingKit/Database/Models/LinkPreview.swift @@ -0,0 +1,38 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public struct LinkPreview: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "linkPreview" } + internal static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id]) + private static let interaction = belongsTo(Interaction.self, using: interactionForeignKey) + private static let attachment = hasOne(Attachment.self, using: Attachment.linkPreviewForeignKey) + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case url + case interactionId + case title + } + + /// The url for the link preview + public let url: String + + /// The id for the interaction this LinkPreview belongs to + public let interactionId: Int64 + + /// The title for the link + public let title: String? + + // MARK: - Relationships + + public var interaction: QueryInterfaceRequest { + request(for: LinkPreview.interaction) + } + + public var attachment: QueryInterfaceRequest { + request(for: LinkPreview.attachment) + } +} diff --git a/SessionMessagingKit/Database/Models/OpenGroup.swift b/SessionMessagingKit/Database/Models/OpenGroup.swift index 221447839..96a28a541 100644 --- a/SessionMessagingKit/Database/Models/OpenGroup.swift +++ b/SessionMessagingKit/Database/Models/OpenGroup.swift @@ -6,11 +6,14 @@ import SessionUtilitiesKit public struct OpenGroup: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "openGroup" } - static let capabilities = hasMany(Capability.self) - static let members = hasMany(GroupMember.self) + internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id]) + private static let thread = belongsTo(SessionThread.self, using: threadForeignKey) + private static let capabilities = hasMany(Capability.self, using: Capability.openGroupForeignKey) + private static let members = hasMany(GroupMember.self, using: GroupMember.openGroupForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { + case threadId case server case room case publicKey @@ -22,18 +25,47 @@ public struct OpenGroup: Codable, Identifiable, FetchableRecord, PersistableReco case infoUpdates } - public var id: String { "\(server).\(room)" } - + public var id: String { threadId } // Identifiable + + /// The id for the thread this open group belongs to + /// + /// **Note:** This value will always be `\(server).\(room)` (This needs it’s own column to + /// allow for db joining to the Thread table) + public let threadId: String + + /// The server for the group public let server: String + + /// The specific room on the server for the group public let room: String + + /// The public key for the group public let publicKey: String + + /// The name for the group public let name: String + + /// The description for the group public let groupDescription: String? + + /// The ID with which the image can be retrieved from the server public let imageId: Int? + + /// The image for the group public let imageData: Data? + + /// The number of users in the group public let userCount: Int + + /// Monotonic room information counter that increases each time the room's metadata changes public let infoUpdates: Int + // MARK: - Relationships + + public var thread: QueryInterfaceRequest { + request(for: OpenGroup.thread) + } + public var capabilities: QueryInterfaceRequest { request(for: OpenGroup.capabilities) } @@ -47,4 +79,40 @@ public struct OpenGroup: Codable, Identifiable, FetchableRecord, PersistableReco request(for: OpenGroup.members) .filter(GroupMember.Columns.role == GroupMember.Role.admin) } + + // MARK: - Initialization + + init( + server: String, + room: String, + publicKey: String, + name: String, + groupDescription: String?, + imageId: Int?, + imageData: Data?, + userCount: Int, + infoUpdates: Int + ) { + // Always force the server to lowercase + self.threadId = "\(server.lowercased()).\(room)" // TODO: Validate this (doesn't seem to happen in the old code...) + self.server = server.lowercased() + self.room = room + self.publicKey = publicKey + self.name = name + self.groupDescription = groupDescription + self.imageId = imageId + self.imageData = imageData + self.userCount = userCount + self.infoUpdates = infoUpdates + } + + // MARK: - Custom Database Interaction + + public func delete(_ db: Database) throws -> Bool { + // Delete all 'GroupMember' records associated with this OpenGroup (can't + // have a proper ForeignKey constraint as 'GroupMember' is reused for the + // 'ClosedGroup' table as well) + try request(for: OpenGroup.members).deleteAll(db) + return try performDelete(db) + } } diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index cdbc7a21c..d88ef045b 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -65,6 +65,9 @@ public struct Profile: Codable, Identifiable, FetchableRecord, PersistableRecord OWSFileSystem.deleteFileIfExists(path) } } + + // Since it's possible this profile is currently being displayed, send notifications + // indicating that it has been updated NotificationCenter.default.post(name: .profileUpdated, object: id) if id == getUserHexEncodedPublicKey(db) { diff --git a/SessionMessagingKit/Database/Models/Quote.swift b/SessionMessagingKit/Database/Models/Quote.swift new file mode 100644 index 000000000..e3f8f8ad5 --- /dev/null +++ b/SessionMessagingKit/Database/Models/Quote.swift @@ -0,0 +1,57 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public struct Quote: Codable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "quote" } + internal static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id]) + internal static let originalInteractionForeignKey = ForeignKey( + [Columns.timestampMs, Columns.authorId], + to: [Interaction.Columns.timestampMs, Interaction.Columns.authorId] + ) + internal static let profileForeignKey = ForeignKey([Columns.authorId], to: [Profile.Columns.id]) + private static let interaction = belongsTo(Interaction.self, using: interactionForeignKey) + private static let profile = hasOne(Profile.self, using: profileForeignKey) + private static let attachment = hasOne(Attachment.self, using: Attachment.quoteForeignKey) + private static let quotedInteraction = hasOne(Interaction.self, using: originalInteractionForeignKey) + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case interactionId + case authorId + case timestampMs + case body + } + + /// The id for the interaction this Quote belongs to + public let interactionId: Int64 + + /// The id for the author this Quote belongs to + public let authorId: String + + /// The timestamp in milliseconds since epoch when the quoted interaction was sent + public let timestampMs: Double + + /// The body of the quoted message if the user is quoting a text message or an attachment with a caption + public let body: String? + + // MARK: - Relationships + + public var interaction: QueryInterfaceRequest { + request(for: Quote.interaction) + } + + public var profile: QueryInterfaceRequest { + request(for: Quote.profile) + } + + public var attachment: QueryInterfaceRequest { + request(for: Quote.attachment) + } + + public var originalInteraction: QueryInterfaceRequest { + request(for: Quote.quotedInteraction) + } +} diff --git a/SessionMessagingKit/Database/Models/RecipientState.swift b/SessionMessagingKit/Database/Models/RecipientState.swift new file mode 100644 index 000000000..7cd50f9a0 --- /dev/null +++ b/SessionMessagingKit/Database/Models/RecipientState.swift @@ -0,0 +1,55 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public struct RecipientState: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "recipientState" } + internal static let profileForeignKey = ForeignKey([Columns.recipientId], to: [Profile.Columns.id]) + internal static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id]) + private static let profile = hasOne(Profile.self, using: profileForeignKey) + private static let interaction = belongsTo(Interaction.self, using: interactionForeignKey) + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case interactionId + case recipientId + case state + case readTimestampMs + } + + public enum State: Int, Codable, DatabaseValueConvertible { + case failed + case sending + case skipped + case sent + } + + /// The id for the interaction this state belongs to + public let interactionId: Int64 + + /// The id for the recipient this state belongs to + public let recipientId: String + + /// The current state for the recipient + public let state: State + + /// When the interaction was read in milliseconds since epoch + /// + /// This value will be null for outgoing messages + /// + /// **Note:** This currently will be set when opening the thread for the first time after receiving this interaction + /// rather than when the interaction actually appears on the screen + public fileprivate(set) var readTimestampMs: Double? = nil // TODO: Add setter + + // MARK: - Relationships + + public var interaction: QueryInterfaceRequest { + request(for: RecipientState.interaction) + } + + public var profile: QueryInterfaceRequest { + request(for: RecipientState.profile) + } +} diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index efeabb7d8..dbb20be31 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -6,9 +6,13 @@ import SessionUtilitiesKit public struct SessionThread: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "thread" } - static let disappearingMessagesConfiguration = hasOne(DisappearingMessagesConfiguration.self) - static let closedGroup = hasOne(ClosedGroup.self) - static let openGroup = hasOne(OpenGroup.self) + private static let closedGroup = hasOne(ClosedGroup.self, using: ClosedGroup.threadForeignKey) + private static let openGroup = hasOne(OpenGroup.self, using: OpenGroup.threadForeignKey) + private static let disappearingMessagesConfiguration = hasOne( + DisappearingMessagesConfiguration.self, + using: DisappearingMessagesConfiguration.threadForeignKey + ) + private static let interactions = hasMany(Interaction.self, using: Interaction.threadForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -29,8 +33,8 @@ public struct SessionThread: Codable, Identifiable, FetchableRecord, Persistable } public enum NotificationMode: Int, Codable, DatabaseValueConvertible { - case all case none + case all case mentionsOnly // Only applicable to group threads } @@ -43,11 +47,7 @@ public struct SessionThread: Codable, Identifiable, FetchableRecord, Persistable public let notificationMode: NotificationMode public let mutedUntilTimestamp: TimeInterval? - public var disappearingMessagesConfiguration: QueryInterfaceRequest { - request(for: SessionThread.disappearingMessagesConfiguration) - } - -// public var lastInteraction + // MARK: - Relationships public var closedGroup: QueryInterfaceRequest { request(for: SessionThread.closedGroup) @@ -56,4 +56,13 @@ public struct SessionThread: Codable, Identifiable, FetchableRecord, Persistable public var openGroup: QueryInterfaceRequest { request(for: SessionThread.openGroup) } + + public var disappearingMessagesConfiguration: QueryInterfaceRequest { + request(for: SessionThread.disappearingMessagesConfiguration) + } + + public var interactions: QueryInterfaceRequest { + request(for: SessionThread.interactions) + } + } diff --git a/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.h b/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.h index b61bada56..5a671c9a8 100644 --- a/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.h +++ b/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.h @@ -73,6 +73,8 @@ typedef NS_ENUM(NSInteger, TSGroupMetaMessage) { @interface TSOutgoingMessage : TSMessage +@property (atomic, nullable) NSDictionary *recipientStateMap; + - (instancetype)initMessageWithTimestamp:(uint64_t)timestamp inThread:(nullable TSThread *)thread messageBody:(nullable NSString *)body diff --git a/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.m b/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.m index f2985104d..aaf1f33d6 100644 --- a/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.m +++ b/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.m @@ -79,7 +79,6 @@ NSString *NSStringForOutgoingMessageRecipientState(OWSOutgoingMessageRecipientSt @property (atomic) NSString *customMessage; @property (atomic) NSString *mostRecentFailureText; @property (atomic) TSGroupMetaMessage groupMetaMessage; -@property (atomic, nullable) NSDictionary *recipientStateMap; @end diff --git a/SessionSnodeKit/Database/Migrations/_002_YDBToGRDBMigration.swift b/SessionSnodeKit/Database/Migrations/_002_YDBToGRDBMigration.swift index 416f5d65d..71bebacf4 100644 --- a/SessionSnodeKit/Database/Migrations/_002_YDBToGRDBMigration.swift +++ b/SessionSnodeKit/Database/Migrations/_002_YDBToGRDBMigration.swift @@ -74,27 +74,29 @@ enum _002_YDBToGRDBMigration: Migration { // Insert the data into GRDB - db[.lastSnodePoolRefreshDate] = lastSnodePoolRefreshDate - - try snodeResult.forEach { legacySnode in - try Snode( - address: legacySnode.address, - port: legacySnode.port, - ed25519PublicKey: legacySnode.publicKeySet.ed25519Key, - x25519PublicKey: legacySnode.publicKeySet.x25519Key - ).insert(db) - } - - try snodeSetResult.forEach { key, legacySnodeSet in - try legacySnodeSet.enumerated().forEach { nodeIndex, legacySnode in - // Note: In this case the 'nodeIndex' is irrelivant - try SnodeSet( - key: key, - nodeIndex: nodeIndex, + try autoreleasepool { + db[.lastSnodePoolRefreshDate] = lastSnodePoolRefreshDate + + try snodeResult.forEach { legacySnode in + try Snode( address: legacySnode.address, - port: legacySnode.port + port: legacySnode.port, + ed25519PublicKey: legacySnode.publicKeySet.ed25519Key, + x25519PublicKey: legacySnode.publicKeySet.x25519Key ).insert(db) } + + try snodeSetResult.forEach { key, legacySnodeSet in + try legacySnodeSet.enumerated().forEach { nodeIndex, legacySnode in + // Note: In this case the 'nodeIndex' is irrelivant + try SnodeSet( + key: key, + nodeIndex: nodeIndex, + address: legacySnode.address, + port: legacySnode.port + ).insert(db) + } + } } // MARK: - Received Messages & Last Message Hash @@ -121,22 +123,24 @@ enum _002_YDBToGRDBMigration: Migration { } } - try receivedMessageResults.forEach { key, hashes in - try hashes.forEach { hash in + try autoreleasepool { + try receivedMessageResults.forEach { key, hashes in + try hashes.forEach { hash in + try SnodeReceivedMessageInfo( + key: key, + hash: hash, + expirationDateMs: 0 + ).insert(db) + } + } + + try lastMessageResults.forEach { key, data in try SnodeReceivedMessageInfo( key: key, - hash: hash, - expirationDateMs: 0 + hash: data.hash, + expirationDateMs: ((data.json["expirationDate"] as? Int64) ?? 0) ).insert(db) } } - - try lastMessageResults.forEach { key, data in - try SnodeReceivedMessageInfo( - key: key, - hash: data.hash, - expirationDateMs: ((data.json["expirationDate"] as? Int64) ?? 0) - ).insert(db) - } } } diff --git a/SessionUtilitiesKit/Database/Migrations/_002_YDBToGRDBMigration.swift b/SessionUtilitiesKit/Database/Migrations/_002_YDBToGRDBMigration.swift index b82883f98..61065617b 100644 --- a/SessionUtilitiesKit/Database/Migrations/_002_YDBToGRDBMigration.swift +++ b/SessionUtilitiesKit/Database/Migrations/_002_YDBToGRDBMigration.swift @@ -65,31 +65,32 @@ enum _002_YDBToGRDBMigration: Migration { throw GRDBStorageError.migrationFailed } - - // Insert the data into GRDB - try Identity( - variant: .seed, - data: Data(hex: seedHexString) - ).insert(db) - - try Identity( - variant: .ed25519SecretKey, - data: Data(hex: userEd25519SecretKeyHexString) - ).insert(db) - - try Identity( - variant: .ed25519PublicKey, - data: Data(hex: userEd25519PublicKeyHexString) - ).insert(db) - - try Identity( - variant: .x25519PrivateKey, - data: userX25519KeyPair.privateKey - ).insert(db) - - try Identity( - variant: .x25519PublicKey, - data: userX25519KeyPair.publicKey - ).insert(db) + try autoreleasepool { + // Insert the data into GRDB + try Identity( + variant: .seed, + data: Data(hex: seedHexString) + ).insert(db) + + try Identity( + variant: .ed25519SecretKey, + data: Data(hex: userEd25519SecretKeyHexString) + ).insert(db) + + try Identity( + variant: .ed25519PublicKey, + data: Data(hex: userEd25519PublicKeyHexString) + ).insert(db) + + try Identity( + variant: .x25519PrivateKey, + data: userX25519KeyPair.privateKey + ).insert(db) + + try Identity( + variant: .x25519PublicKey, + data: userX25519KeyPair.publicKey + ).insert(db) + } } } diff --git a/SessionUtilitiesKit/Database/Types/TypedTableDefinition.swift b/SessionUtilitiesKit/Database/Types/TypedTableDefinition.swift index db2157576..67ce68016 100644 --- a/SessionUtilitiesKit/Database/Types/TypedTableDefinition.swift +++ b/SessionUtilitiesKit/Database/Types/TypedTableDefinition.swift @@ -20,6 +20,10 @@ public class TypedTableDefinition where T: TableRecord, T: ColumnExpressible definition.primaryKey(columns.map { $0.name }, onConflict: onConflict) } + public func uniqueKey(_ columns: [T.Columns], onConflict: Database.ConflictResolution? = nil) { + definition.uniqueKey(columns.map { $0.name }, onConflict: onConflict) + } + public func foreignKey( _ columns: [T.Columns], references table: Other.Type, diff --git a/SignalUtilitiesKit/Configuration.swift b/SignalUtilitiesKit/Configuration.swift index 362a47f17..f8382dacc 100644 --- a/SignalUtilitiesKit/Configuration.swift +++ b/SignalUtilitiesKit/Configuration.swift @@ -16,6 +16,11 @@ public final class Configuration : NSObject { maxFileSize: UInt(Double(FileServerAPIV2.maxFileSize) / FileServerAPIV2.fileSizeORMultiplier) ) + SNMessagingKit.configure(storage: Storage.shared) + SNSnodeKit.configure() + } + + @objc public static func performDatabaseSetup() { if !isSetup { isSetup = true @@ -30,8 +35,5 @@ public final class Configuration : NSObject { ] ) } - - SNMessagingKit.configure(storage: Storage.shared) - SNSnodeKit.configure() } } diff --git a/SignalUtilitiesKit/Utilities/AppSetup.m b/SignalUtilitiesKit/Utilities/AppSetup.m index 0cdf5a0f1..b93ecf3c4 100644 --- a/SignalUtilitiesKit/Utilities/AppSetup.m +++ b/SignalUtilitiesKit/Utilities/AppSetup.m @@ -100,6 +100,9 @@ NS_ASSUME_NONNULL_BEGIN }]; }); }]; + + // Must happen after the performUpdateCheck above to ensure all legacy database migrations have run + [SNConfiguration performDatabaseSetup]; }); } From 28553b218bd7173456bf87b6b8d5df641affc66a Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 11 Apr 2022 17:30:42 +1000 Subject: [PATCH 061/157] Updated the migration to handle quotes and link previews --- .../_001_InitialSetupMigration.swift | 17 +-- .../Migrations/_002_YDBToGRDBMigration.swift | 128 ++++++++++++++---- .../Database/Models/Attachment.swift | 34 +---- .../Database/Models/Interaction.swift | 69 +++++++--- .../Database/Models/LinkPreview.swift | 45 ++++-- .../Database/Models/Quote.swift | 7 +- .../Attachments/TSAttachmentStream.h | 1 + .../General/Dictionary+Description.swift | 9 ++ 8 files changed, 206 insertions(+), 104 deletions(-) diff --git a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift index 0b94044d2..7aa21f300 100644 --- a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -145,9 +145,7 @@ enum _001_InitialSetupMigration: Migration { t.column(.receivedAtTimestampMs, .double).notNull() t.column(.expiresInSeconds, .double) t.column(.expiresStartedAtMs, .double) - - t.column(.openGroupInvitationName, .text) - t.column(.openGroupInvitationUrl, .text) + t.column(.linkPreviewUrl, .text) t.column(.openGroupServerMessageId, .integer) .indexed() // Quicker querying @@ -203,17 +201,18 @@ enum _001_InitialSetupMigration: Migration { try db.create(table: LinkPreview.self) { t in t.column(.url, .text) .notNull() - .primaryKey() - t.column(.interactionId, .integer) + .indexed() // Quicker querying + t.column(.timestamp, .double) .notNull() .indexed() // Quicker querying - .references(Interaction.self, onDelete: .cascade) // Delete if interaction deleted + t.column(.variant, .integer).notNull() t.column(.title, .text) + + t.primaryKey([.url, .timestamp]) } try db.create(table: Attachment.self) { t in t.column(.interactionId, .integer) - .notNull() .indexed() // Quicker querying .references(Interaction.self, onDelete: .cascade) // Delete if interaction deleted t.column(.serverId, .text) @@ -231,10 +230,6 @@ enum _001_InitialSetupMigration: Migration { t.column(.encryptionKey, .blob) t.column(.digest, .blob) t.column(.caption, .text) - t.column(.quoteId, .text) - .references(Quote.self, onDelete: .cascade) // Delete if Quote deleted - t.column(.linkPreviewUrl, .text) - .references(LinkPreview.self, onDelete: .cascade) // Delete if LinkPreview deleted } } } diff --git a/SessionMessagingKit/Database/Migrations/_002_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_002_YDBToGRDBMigration.swift index fd14227d1..b19a38276 100644 --- a/SessionMessagingKit/Database/Migrations/_002_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_002_YDBToGRDBMigration.swift @@ -348,17 +348,16 @@ enum _002_YDBToGRDBMigration: Migration { let body: String? let expiresInSeconds: UInt32? let expiresStartedAtMs: UInt64? - let openGroupInvitationName: String? - let openGroupInvitationUrl: String? let openGroupServerMessageId: UInt64? let recipientStateMap: [String: TSOutgoingMessageRecipientState]? - let attachmentIds: [String] + let quotedMessage: TSQuotedMessage? + let linkPreview: OWSLinkPreview? + let linkPreviewVariant: LinkPreview.Variant + var attachmentIds: [String] // Handle the common 'TSMessage' values first if let legacyMessage: TSMessage = legacyInteraction as? TSMessage { serverHash = legacyMessage.serverHash - openGroupInvitationName = legacyMessage.openGroupInvitationName - openGroupInvitationUrl = legacyMessage.openGroupInvitationURL // The legacy code only considered '!= 0' ids as valid so set those // values to be null to avoid the unique constraint (it's also more @@ -367,27 +366,52 @@ enum _002_YDBToGRDBMigration: Migration { nil : legacyMessage.openGroupServerMessageID ) - attachmentIds = try legacyMessage.attachmentIds.map { legacyId in - guard let attachmentId: String = legacyId as? String else { - SNLog("[Migration Error] Unable to process attachment id") - throw GRDBStorageError.migrationFailed - } - - return attachmentId + quotedMessage = legacyMessage.quotedMessage + + // Convert the 'OpenGroupInvitation' into a LinkPreview + if let openGroupInvitationName: String = legacyMessage.openGroupInvitationName, let openGroupInvitationUrl: String = legacyMessage.openGroupInvitationURL { + linkPreviewVariant = .openGroupInvitation + linkPreview = OWSLinkPreview( + urlString: openGroupInvitationUrl, + title: openGroupInvitationName, + imageAttachmentId: nil + ) } + else { + linkPreviewVariant = .standard + linkPreview = legacyMessage.linkPreview + } + + // Attachments for deleted messages won't exist + attachmentIds = (legacyMessage.isDeleted ? + [] : + try legacyMessage.attachmentIds.map { legacyId in + guard let attachmentId: String = legacyId as? String else { + SNLog("[Migration Error] Unable to process attachment id") + throw GRDBStorageError.migrationFailed + } + + return attachmentId + } + ) } else { serverHash = nil - openGroupInvitationName = nil - openGroupInvitationUrl = nil openGroupServerMessageId = nil + quotedMessage = nil + linkPreviewVariant = .standard + linkPreview = nil attachmentIds = [] } // Then handle the behaviours for each message type switch legacyInteraction { case let incomingMessage as TSIncomingMessage: - variant = .standardIncoming + // Note: We want to distinguish deleted messages from normal ones + variant = (incomingMessage.isDeleted ? + .standardIncomingDeleted : + .standardIncoming + ) authorId = incomingMessage.authorId body = incomingMessage.body expiresInSeconds = incomingMessage.expiresInSeconds @@ -443,8 +467,7 @@ enum _002_YDBToGRDBMigration: Migration { receivedAtTimestampMs: Double(legacyInteraction.receivedAtTimestamp), expiresInSeconds: expiresInSeconds.map { TimeInterval($0) }, expiresStartedAtMs: expiresStartedAtMs.map { Double($0) }, - openGroupInvitationName: openGroupInvitationName, - openGroupInvitationUrl: openGroupInvitationUrl, + linkPreviewUrl: linkPreview?.urlString, // Only a soft link so save to set openGroupServerMessageId: openGroupServerMessageId.map { Int64($0) }, openGroupWhisperMods: false, // TODO: This openGroupWhisperTo: nil // TODO: This @@ -455,6 +478,8 @@ enum _002_YDBToGRDBMigration: Migration { throw GRDBStorageError.migrationFailed } + // Handle the recipient states + try recipientStateMap?.forEach { recipientId, legacyState in try RecipientState( interactionId: interactionId, @@ -471,11 +496,68 @@ enum _002_YDBToGRDBMigration: Migration { readTimestampMs: legacyState.readTimestamp?.doubleValue ).insert(db) } + + // Handle any quote + + if let quotedMessage: TSQuotedMessage = quotedMessage { + try Quote( + interactionId: interactionId, + authorId: quotedMessage.authorId, + timestampMs: Double(quotedMessage.timestamp), + body: quotedMessage.body + ).insert(db) + + // Ensure the quote thumbnail works properly + + + // Note: Quote attachments are now attached directly to the interaction + attachmentIds = attachmentIds.appending( + contentsOf: quotedMessage.quotedAttachments.compactMap { attachmentInfo in + if let attachmentId: String = attachmentInfo.attachmentId { + return attachmentId + } + else if let attachmentId: String = attachmentInfo.thumbnailAttachmentPointerId { + return attachmentId + } + // TODO: Looks like some of these might be busted??? + return attachmentInfo.thumbnailAttachmentStreamId + } + ) + } + + // Handle any LinkPreview + + if let linkPreview: OWSLinkPreview = linkPreview, let urlString: String = linkPreview.urlString { + // Note: The `legacyInteraction.timestamp` value is in milliseconds + let timestamp: TimeInterval = LinkPreview.timestampFor(sentTimestampMs: Double(legacyInteraction.timestamp)) + + // Note: It's possible for there to be duplicate values here so we use 'save' + // instead of insert (ie. upsert) + try LinkPreview( + url: urlString, + timestamp: timestamp, + variant: linkPreviewVariant, + title: linkPreview.title + ).save(db) + + // Note: LinkPreview attachments are now attached directly to the interaction + attachmentIds = attachmentIds.appending(linkPreview.imageAttachmentId) + } + + // Handle any attachments try attachmentIds.forEach { attachmentId in guard let attachment: TSAttachment = attachments[attachmentId] else { SNLog("[Migration Error] Unsupported interaction type") throw GRDBStorageError.migrationFailed } + + let size: CGSize = { + switch attachment { + case let stream as TSAttachmentStream: return stream.calculateImageSize() + case let pointer as TSAttachmentPointer: return pointer.mediaSize + default: return CGSize.zero + } + }() try Attachment( interactionId: interactionId, serverId: "\(attachment.serverId)", @@ -483,16 +565,14 @@ enum _002_YDBToGRDBMigration: Migration { state: .pending, // TODO: This contentType: attachment.contentType, byteCount: UInt(attachment.byteCount), - creationTimestamp: 0, // TODO: This + creationTimestamp: (attachment as? TSAttachmentStream)?.creationTimestamp.timeIntervalSince1970, sourceFilename: attachment.sourceFilename, downloadUrl: attachment.downloadURL, - width: 0, // TODO: This attachment.mediaSize, - height: 0, // TODO: This attachment.mediaSize, + width: (size == .zero ? nil : UInt(size.width)), + height: (size == .zero ? nil : UInt(size.height)), encryptionKey: attachment.encryptionKey, - digest: nil, // TODO: This attachment.digest, - caption: attachment.caption, - quoteId: nil, // TODO: THis - linkPreviewUrl: nil // TODO: This + digest: (attachment as? TSAttachmentStream)?.digest, + caption: attachment.caption ).insert(db) } } diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index 464555c31..8672ce4a7 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -7,14 +7,7 @@ import SessionUtilitiesKit public struct Attachment: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "attachment" } internal static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id]) - internal static let quoteForeignKey = ForeignKey([Columns.quoteId], to: [Quote.Columns.interactionId]) - internal static let linkPreviewForeignKey = ForeignKey( - [Columns.linkPreviewUrl], - to: [LinkPreview.Columns.url] - ) private static let interaction = belongsTo(Interaction.self, using: interactionForeignKey) - private static let quote = belongsTo(Quote.self, using: quoteForeignKey) - private static let linkPreview = belongsTo(LinkPreview.self, using: linkPreviewForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -32,8 +25,6 @@ public struct Attachment: Codable, FetchableRecord, PersistableRecord, TableReco case encryptionKey case digest case caption - case quoteId - case linkPreviewUrl } public enum Variant: Int, Codable, DatabaseValueConvertible { @@ -50,8 +41,8 @@ public struct Attachment: Codable, FetchableRecord, PersistableRecord, TableReco case failed } - /// The id for the interaction this attachment belongs to - public let interactionId: Int64 + /// The id for the Interaction this attachment belongs to + public let interactionId: Int64? /// The id for the attachment returned by the server /// @@ -78,6 +69,7 @@ public struct Attachment: Codable, FetchableRecord, PersistableRecord, TableReco /// /// **Uploaded:** This will be the timestamp the file finished uploading /// **Downloaded:** This will be the timestamp the file finished downloading + /// **Other:** This will be null public let creationTimestamp: TimeInterval? /// Represents the "source" filename sent or received in the protos, not the filename on disk @@ -103,29 +95,9 @@ public struct Attachment: Codable, FetchableRecord, PersistableRecord, TableReco /// Caption for the attachment public let caption: String? - /// The id for the QuotedMessage if this attachment belongs to one - /// - /// **Note:** If this value is present then this attachment shouldn't be returned as a - /// standard attachment for the interaction - public let quoteId: String? - - /// The id for the LinkPreview if this attachment belongs to one - /// - /// **Note:** If this value is present then this attachment shouldn't be returned as a - /// standard attachment for the interaction - public let linkPreviewUrl: String? - // MARK: - Relationships public var interaction: QueryInterfaceRequest { request(for: Attachment.interaction) } - - public var quote: QueryInterfaceRequest { - request(for: Attachment.quote) - } - - public var linkPreview: QueryInterfaceRequest { - request(for: Attachment.linkPreview) - } } diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index eb16d54eb..bc58647fd 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -8,6 +8,10 @@ public struct Interaction: Codable, FetchableRecord, MutablePersistableRecord, T public static var databaseTableName: String { "interaction" } internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id]) internal static let profileForeignKey = ForeignKey([Columns.authorId], to: [Profile.Columns.id]) + internal static let linkPreviewForeignKey = ForeignKey( + [Columns.linkPreviewUrl], + to: [LinkPreview.Columns.url] + ) private static let thread = belongsTo(SessionThread.self, using: threadForeignKey) private static let profile = hasOne(Profile.self, using: profileForeignKey) private static let attachments = hasMany(Attachment.self, using: Attachment.interactionForeignKey) @@ -29,9 +33,7 @@ public struct Interaction: Codable, FetchableRecord, MutablePersistableRecord, T case expiresInSeconds case expiresStartedAtMs - - case openGroupInvitationName - case openGroupInvitationUrl + case linkPreviewUrl // Open Group specific properties @@ -43,6 +45,7 @@ public struct Interaction: Codable, FetchableRecord, MutablePersistableRecord, T public enum Variant: Int, Codable, DatabaseValueConvertible { case standardIncoming case standardOutgoing + case standardIncomingDeleted // Info Message Types (spacing the values out to make it easier to extend) case infoClosedGroupCreated = 1000 @@ -93,11 +96,10 @@ public struct Interaction: Codable, FetchableRecord, MutablePersistableRecord, T /// message has expired) public fileprivate(set) var expiresStartedAtMs: Double? = nil - /// When sending an Open Group invitation this will be populated with the name of the open group - public let openGroupInvitationName: String? - - /// When sending an Open Group invitation this will be populated with the url of the open group - public let openGroupInvitationUrl: String? + /// This value is the url for the link preview for this interaction + /// + /// **Note:** This is also used for open group invitations + public let linkPreviewUrl: String? // Open Group specific properties @@ -122,10 +124,6 @@ public struct Interaction: Codable, FetchableRecord, MutablePersistableRecord, T public var attachments: QueryInterfaceRequest { request(for: Interaction.attachments) - .filter( - Attachment.Columns.quoteId == nil && - Attachment.Columns.linkPreviewUrl == nil - ) } public var quote: QueryInterfaceRequest { @@ -133,7 +131,20 @@ public struct Interaction: Codable, FetchableRecord, MutablePersistableRecord, T } public var linkPreview: QueryInterfaceRequest { - request(for: Interaction.linkPreview) + let linkPreviewAlias: TableAlias = TableAlias() + + return LinkPreview + .aliased(linkPreviewAlias) + .joining( + required: LinkPreview.interactions + .filter(literal: [ + "(ROUND((\(Interaction.Columns.timestampMs) / 1000 / 100000) - 0.5) * 100000)", + "=", + "\(linkPreviewAlias[LinkPreview.Columns.timestamp])" + ].joined(separator: " ")) + .limit(1) // Avoid joining to multiple interactions + ) + .limit(1) // Avoid joining to multiple interactions } public var recipientStates: QueryInterfaceRequest { @@ -153,8 +164,7 @@ public struct Interaction: Codable, FetchableRecord, MutablePersistableRecord, T receivedAtTimestampMs: Double, expiresInSeconds: TimeInterval?, expiresStartedAtMs: Double?, - openGroupInvitationName: String?, - openGroupInvitationUrl: String?, + linkPreviewUrl: String?, openGroupServerMessageId: Int64?, openGroupWhisperMods: Bool, openGroupWhisperTo: String? @@ -168,8 +178,7 @@ public struct Interaction: Codable, FetchableRecord, MutablePersistableRecord, T self.receivedAtTimestampMs = receivedAtTimestampMs self.expiresInSeconds = expiresInSeconds self.expiresStartedAtMs = expiresStartedAtMs - self.openGroupInvitationName = openGroupInvitationName - self.openGroupInvitationUrl = openGroupInvitationUrl + self.linkPreviewUrl = linkPreviewUrl self.openGroupServerMessageId = openGroupServerMessageId self.openGroupWhisperMods = openGroupWhisperMods self.openGroupWhisperTo = openGroupWhisperTo @@ -180,6 +189,32 @@ public struct Interaction: Codable, FetchableRecord, MutablePersistableRecord, T public mutating func didInsert(with rowID: Int64, for column: String?) { self.id = rowID } + + public func delete(_ db: Database) throws -> Bool { + // If we have a LinkPreview then check if this is the only interaction that has it + // and delete the LinkPreview if so + if linkPreviewUrl != nil { + let interactionAlias: TableAlias = TableAlias() + let numInteractions: Int? = try? Interaction + .aliased(interactionAlias) + .joining( + required: Interaction.linkPreview + .filter(literal: [ + "(ROUND((\(interactionAlias[Columns.timestampMs]) / 1000 / 100000) - 0.5) * 100000)", + "=", + "\(LinkPreview.Columns.timestamp)" + ].joined(separator: " ")) + ) + .fetchCount(db) + let tmp = try linkPreview.fetchAll(db) + + if numInteractions == 1 { + try linkPreview.deleteAll(db) + } + } + + return try performDelete(db) + } } // MARK: - Convenience diff --git a/SessionMessagingKit/Database/Models/LinkPreview.swift b/SessionMessagingKit/Database/Models/LinkPreview.swift index 15d92651e..8b2bcc6b4 100644 --- a/SessionMessagingKit/Database/Models/LinkPreview.swift +++ b/SessionMessagingKit/Database/Models/LinkPreview.swift @@ -6,33 +6,48 @@ import SessionUtilitiesKit public struct LinkPreview: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "linkPreview" } - internal static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id]) - private static let interaction = belongsTo(Interaction.self, using: interactionForeignKey) - private static let attachment = hasOne(Attachment.self, using: Attachment.linkPreviewForeignKey) + internal static let interactionForeignKey = ForeignKey( + [Columns.url], + to: [Interaction.Columns.linkPreviewUrl] + ) + internal static let interactions = hasMany(Interaction.self, using: Interaction.linkPreviewForeignKey) + + /// We want to cache url previews to the nearest 100,000 seconds (~28 hours - simpler than 86,400) to ensure the user isn't shown a preview that is too stale + internal static let timstampResolution: Double = 100000 public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { case url - case interactionId + case timestamp + case variant case title } + public enum Variant: Int, Codable, DatabaseValueConvertible { + case standard + case openGroupInvitation + } + /// The url for the link preview public let url: String - /// The id for the interaction this LinkPreview belongs to - public let interactionId: Int64 + /// The number of seconds since epoch rounded down to the nearest 100,000 seconds (~day) - This + /// allows us to optimise against duplicate urls without having “stale” data last too long + public let timestamp: TimeInterval + + /// The type of link preview + public let variant: Variant /// The title for the link public let title: String? - - // MARK: - Relationships - - public var interaction: QueryInterfaceRequest { - request(for: LinkPreview.interaction) - } - - public var attachment: QueryInterfaceRequest { - request(for: LinkPreview.attachment) +} + +// MARK: - Convenience + +public extension LinkPreview { + static func timestampFor(sentTimestampMs: Double) -> TimeInterval { + // We want to round the timestamp down to the nearest 100,000 seconds (~28 hours - simpler than 86,400) to optimise + // LinkPreview storage without having too stale data + return (floor(sentTimestampMs / 1000 / LinkPreview.timstampResolution) * LinkPreview.timstampResolution) } } diff --git a/SessionMessagingKit/Database/Models/Quote.swift b/SessionMessagingKit/Database/Models/Quote.swift index e3f8f8ad5..83ef2f1a2 100644 --- a/SessionMessagingKit/Database/Models/Quote.swift +++ b/SessionMessagingKit/Database/Models/Quote.swift @@ -4,7 +4,7 @@ import Foundation import GRDB import SessionUtilitiesKit -public struct Quote: Codable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible { +public struct Quote: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "quote" } internal static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id]) internal static let originalInteractionForeignKey = ForeignKey( @@ -14,7 +14,6 @@ public struct Quote: Codable, FetchableRecord, MutablePersistableRecord, TableRe internal static let profileForeignKey = ForeignKey([Columns.authorId], to: [Profile.Columns.id]) private static let interaction = belongsTo(Interaction.self, using: interactionForeignKey) private static let profile = hasOne(Profile.self, using: profileForeignKey) - private static let attachment = hasOne(Attachment.self, using: Attachment.quoteForeignKey) private static let quotedInteraction = hasOne(Interaction.self, using: originalInteractionForeignKey) public typealias Columns = CodingKeys @@ -47,10 +46,6 @@ public struct Quote: Codable, FetchableRecord, MutablePersistableRecord, TableRe request(for: Quote.profile) } - public var attachment: QueryInterfaceRequest { - request(for: Quote.attachment) - } - public var originalInteraction: QueryInterfaceRequest { request(for: Quote.quotedInteraction) } diff --git a/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentStream.h b/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentStream.h index a663f8eaf..3625ebcf2 100644 --- a/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentStream.h +++ b/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentStream.h @@ -60,6 +60,7 @@ typedef void (^OWSThumbnailFailure)(void); - (BOOL)shouldHaveImageSize; - (CGSize)imageSize; +- (CGSize)calculateImageSize; - (CGFloat)audioDurationSeconds; diff --git a/SessionUtilitiesKit/General/Dictionary+Description.swift b/SessionUtilitiesKit/General/Dictionary+Description.swift index 927c7c8e5..2f4ba938d 100644 --- a/SessionUtilitiesKit/General/Dictionary+Description.swift +++ b/SessionUtilitiesKit/General/Dictionary+Description.swift @@ -16,6 +16,15 @@ public extension Dictionary { } } +public extension Dictionary { + func setting(_ key: Key, _ value: Value?) -> [Key: Value] { + var updatedDictionary: [Key: Value] = self + updatedDictionary[key] = value + + return updatedDictionary + } +} + public extension Dictionary.Values { func asArray() -> [Value] { return Array(self) From 11231599db3ed8383bbb684c89ebf4e705760909 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 21 Apr 2022 16:42:35 +1000 Subject: [PATCH 062/157] Further work on migrations and message pipeline refactoring Refactored the AppDelegate from Objective C to Swift Updated the HomeVC to use GRDB Refactored a number of the Job types to be driven via GRDB and the new JobRunner Fixed a bug where the LinkPreviewView wouldn't render correctly in dark mode --- Podfile | 1 + Podfile.lock | 2 +- Session.xcodeproj/project.pbxproj | 168 ++- .../ConversationVC+Interaction.swift | 2 +- .../Content Views/LinkPreviewView.swift | 2 +- .../OWSConversationSettingsViewController.m | 4 +- .../Views & Modals/BlockedModal.swift | 4 +- .../ConversationTitleView.swift | 2 +- .../Views & Modals/JoinOpenGroupModal.swift | 38 +- .../Views & Modals/UserDetailsSheet.swift | 2 +- Session/Home/HomeVC.swift | 385 ++--- Session/Home/HomeViewModel.swift | 119 ++ Session/Meta/AppDelegate.h | 16 - Session/Meta/AppDelegate.m | 787 ----------- Session/Meta/AppDelegate.swift | 471 ++++++- Session/Meta/AppEnvironment.swift | 4 +- Session/Meta/Main.storyboard | 11 - Session/Meta/Signal-Bridging-Header.h | 3 +- Session/Meta/SignalApp.m | 1 - .../Translations/en.lproj/Localizable.strings | 2 +- Session/Meta/main.m | 8 - Session/Notifications/AppNotifications.swift | 168 +-- Session/Notifications/SyncPushTokensJob.swift | 209 +-- .../UserNotificationsAdaptee.swift | 12 +- Session/Onboarding/PNModeVC.swift | 5 +- .../NotificationSettingsViewController.m | 4 +- Session/Settings/NukeDataModal.swift | 2 +- .../PrivacySettingsTableViewController.m | 26 +- Session/Settings/SettingsVC.swift | 4 +- Session/Shared/ConversationCell.swift | 293 ++-- Session/Shared/UserCell.swift | 2 +- Session/Utilities/AccountManager.swift | 18 +- Session/Utilities/MentionUtilities.swift | 15 +- Session/Utilities/UIApplication+OWS.swift | 21 +- SessionMessagingKit/Configuration.swift | 14 +- .../LegacyDatabase/SMKLegacyModels.swift | 340 ++++- .../_001_InitialSetupMigration.swift | 154 +- .../Migrations/_002_SetupStandardJobs.swift | 49 + ...on.swift => _003_YDBToGRDBMigration.swift} | 588 +++++++- .../Database/Models/Attachment.swift | 450 +++++- .../Database/Models/ClosedGroup.swift | 34 +- .../Database/Models/ClosedGroupKeyPair.swift | 27 +- .../Database/Models/Contact.swift | 1 + .../Models/ControlMessageProcessRecord.swift | 23 + .../DisappearingMessageConfiguration.swift | 53 +- .../Database/Models/GroupMember.swift | 12 + .../Database/Models/Interaction.swift | 466 ++++++- .../Models/InteractionAttachment.swift | 51 + SessionMessagingKit/Database/Models/Job.swift | 218 +++ .../Database/Models/LinkPreview.swift | 187 ++- .../Database/Models/OpenGroup.swift | 10 +- .../Database/Models/Profile.swift | 42 +- .../Database/Models/Quote.swift | 116 +- .../Database/Models/RecipientState.swift | 43 +- .../Database/Models/SessionThread.swift | 95 +- .../Database/SSKPreferences.swift | 24 + .../Jobs/AttachmentDownloadJob.swift | 2 + SessionMessagingKit/Jobs/Job.swift | 12 - SessionMessagingKit/Jobs/JobRunner.swift | 395 ++++++ SessionMessagingKit/Jobs/JobRunnerError.swift | 12 + .../Jobs/MessageReceiveJob.swift | 111 -- SessionMessagingKit/Jobs/MessageSendJob.swift | 144 -- .../Jobs/NotifyPNServerJob.swift | 69 - .../Jobs/Types/DisappearingMessagesJob.swift | 100 ++ .../Types/FailedAttachmentDownloadsJob.swift | 29 + .../Jobs/Types/FailedMessagesJob.swift | 29 + .../Jobs/Types/MessageReceiveJob.swift | 74 + .../Jobs/Types/MessageSendJob.swift | 350 +++++ .../Jobs/Types/NotifyPushServerJob.swift | 66 + .../Jobs/Types/SendReadReceiptsJob.swift | 136 ++ .../ClosedGroupControlMessage.swift | 197 ++- .../ConfigurationMessage+Convenience.swift | 2 +- .../ConfigurationMessage.swift | 114 +- .../Control Messages/ControlMessage.swift | 5 +- .../DataExtractionNotification.swift | 30 +- .../ExpirationTimerUpdate.swift | 44 +- .../MessageRequestResponse.swift | 28 +- .../Control Messages/ReadReceipt.swift | 28 +- .../Control Messages/TypingIndicator.swift | 30 +- .../Control Messages/UnsendRequest.swift | 31 +- .../Messages/Message+Destination.swift | 30 +- SessionMessagingKit/Messages/Message.swift | 22 +- .../VisibleMessage+Attachment.swift | 7 +- .../VisibleMessage+Contact.swift | 11 - .../VisibleMessage+LinkPreview.swift | 29 +- .../VisibleMessage+OpenGroupInvitation.swift | 19 +- .../VisibleMessage+Profile.swift | 147 +- .../VisibleMessage+Quote.swift | 37 +- .../Visible Messages/VisibleMessage.swift | 124 +- .../Meta/SessionMessagingKit.h | 2 - .../Attachments/OWSThumbnailService.swift | 33 +- .../Errors/MessageReceiverError.swift | 51 + .../Errors/MessageSenderError.swift | 45 + .../Mentions/MentionsManager.swift | 2 +- .../MessageReceiver+Decryption.swift | 42 +- .../MessageReceiver+Handling.swift | 1233 +++++++++++------ .../Sending & Receiving/MessageReceiver.swift | 242 ++-- .../MessageSender+ClosedGroups.swift | 789 +++++++---- .../MessageSender+Convenience.swift | 176 +++ .../MessageSender+Encryption.swift | 16 +- .../Sending & Receiving/MessageSender.swift | 597 +++++--- .../Notifications/NotificationsProtocol.h | 28 - .../Notifications/NotificationsProtocol.swift | 10 + .../Pollers/ClosedGroupPoller.swift | 70 +- .../Sending & Receiving/Pollers/Poller.swift | 98 +- .../Quotes/QuotedReplyModel.swift | 114 ++ .../Utilities/DeviceSleepManager.swift | 40 +- .../Utilities/Preferences.swift | 122 ++ .../Utilities/ProfileManager.swift | 9 +- .../Utilities/SSKEnvironment.swift | 57 + .../NSENotificationPresenter.swift | 82 +- .../NotificationServiceExtension.swift | 71 +- SessionShareExtension/ShareVC.swift | 6 +- .../SimplifiedConversationCell.swift | 8 +- SessionSnodeKit/SnodeAPI.swift | 12 +- SessionSnodeKit/SnodeMessage.swift | 83 +- .../Database/GRDBStorage.swift | 57 +- .../Database/Models/Identity.swift | 10 +- .../Database/Models/Setting.swift | 38 +- .../PersistableRecord+Utilities.swift | 16 + .../General/Array+Utilities.swift | 6 + SessionUtilitiesKit/General/Atomic.swift | 6 +- .../General/SNUserDefaults.swift | 3 +- .../General/Set+Utilities.swift | 8 +- .../Utilities/Codable+Utilities.swift | 9 + .../MessageApprovalViewController.swift | 4 +- .../Messaging/BlockListUIUtils.swift | 4 +- .../Messaging/FullTextSearcher.swift | 4 +- .../MessageSender+Convenience.swift | 157 --- .../Messaging/ThreadViewModel.swift | 89 +- .../Profile Pictures/ProfilePictureView.swift | 94 +- SignalUtilitiesKit/Utilities/AppSetup.m | 15 +- .../Utilities/Differentiable+Utilities.swift | 10 + .../Utilities/NoopNotificationsManager.swift | 8 +- .../Utilities/Notification+Loki.swift | 3 + 135 files changed, 9073 insertions(+), 3778 deletions(-) create mode 100644 Session/Home/HomeViewModel.swift delete mode 100644 Session/Meta/AppDelegate.h delete mode 100644 Session/Meta/AppDelegate.m delete mode 100644 Session/Meta/Main.storyboard delete mode 100644 Session/Meta/main.m create mode 100644 SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift rename SessionMessagingKit/Database/Migrations/{_002_YDBToGRDBMigration.swift => _003_YDBToGRDBMigration.swift} (52%) create mode 100644 SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift create mode 100644 SessionMessagingKit/Database/Models/InteractionAttachment.swift create mode 100644 SessionMessagingKit/Database/Models/Job.swift delete mode 100644 SessionMessagingKit/Jobs/Job.swift create mode 100644 SessionMessagingKit/Jobs/JobRunner.swift create mode 100644 SessionMessagingKit/Jobs/JobRunnerError.swift delete mode 100644 SessionMessagingKit/Jobs/MessageReceiveJob.swift delete mode 100644 SessionMessagingKit/Jobs/MessageSendJob.swift delete mode 100644 SessionMessagingKit/Jobs/NotifyPNServerJob.swift create mode 100644 SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift create mode 100644 SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift create mode 100644 SessionMessagingKit/Jobs/Types/FailedMessagesJob.swift create mode 100644 SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift create mode 100644 SessionMessagingKit/Jobs/Types/MessageSendJob.swift create mode 100644 SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift create mode 100644 SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift delete mode 100644 SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Contact.swift create mode 100644 SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift create mode 100644 SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift create mode 100644 SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift delete mode 100644 SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.h create mode 100644 SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.swift create mode 100644 SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift create mode 100644 SessionMessagingKit/Utilities/Preferences.swift create mode 100644 SessionMessagingKit/Utilities/SSKEnvironment.swift create mode 100644 SessionUtilitiesKit/Database/Utilities/PersistableRecord+Utilities.swift create mode 100644 SessionUtilitiesKit/Utilities/Codable+Utilities.swift delete mode 100644 SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift create mode 100644 SignalUtilitiesKit/Utilities/Differentiable+Utilities.swift diff --git a/Podfile b/Podfile index 390555a1a..72e34dc1c 100644 --- a/Podfile +++ b/Podfile @@ -50,6 +50,7 @@ abstract_target 'GlobalDependencies' do pod 'SAMKeychain' pod 'SwiftProtobuf', '~> 1.5.0' pod 'YYImage', git: 'https://github.com/signalapp/YYImage' + pod 'DifferenceKit' end target 'SessionMessagingKit' do diff --git a/Podfile.lock b/Podfile.lock index 4d6331069..9f0a321d1 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -219,6 +219,6 @@ SPEC CHECKSUMS: YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331 ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: 33a5ecfe231383831bf212de4ff6c99c047c344a +PODFILE CHECKSUM: 50ae96076a7cd581c63b3276679615844c88ac44 COCOAPODS: 1.11.2 diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 6db2a1dbc..dbd32e632 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -121,7 +121,6 @@ 70377AAB1918450100CAF501 /* MobileCoreServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 70377AAA1918450100CAF501 /* MobileCoreServices.framework */; }; 768A1A2B17FC9CD300E00ED8 /* libz.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = 768A1A2A17FC9CD300E00ED8 /* libz.dylib */; }; 76C87F19181EFCE600C4ACAB /* MediaPlayer.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 76C87F18181EFCE600C4ACAB /* MediaPlayer.framework */; }; - 76EB054018170B33006006FC /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 76EB03C318170B33006006FC /* AppDelegate.m */; }; 7B1581E2271E743B00848B49 /* OWSSounds.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1581E1271E743B00848B49 /* OWSSounds.swift */; }; 7B1D74AA27BCC16E0030B423 /* NSENotificationPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1D74A927BCC16E0030B423 /* NSENotificationPresenter.swift */; }; 7B1D74AC27BDE7510030B423 /* Promise+Timeout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1D74AB27BDE7510030B423 /* Promise+Timeout.swift */; }; @@ -142,7 +141,6 @@ A163E8AB16F3F6AA0094D68B /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A163E8AA16F3F6A90094D68B /* Security.framework */; }; A1C32D5017A06538000A904E /* AddressBookUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A1C32D4F17A06537000A904E /* AddressBookUI.framework */; }; A1C32D5117A06544000A904E /* AddressBook.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A1C32D4D17A0652C000A904E /* AddressBook.framework */; }; - A5509ECA1A69AB8B00ABA4BC /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = A5509EC91A69AB8B00ABA4BC /* Main.storyboard */; }; B66DBF4A19D5BBC8006EA940 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B66DBF4919D5BBC8006EA940 /* Images.xcassets */; }; B67EBF5D19194AC60084CCFD /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = B67EBF5C19194AC60084CCFD /* Settings.bundle */; }; B6B226971BE4B7D200860F4D /* ContactsUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B6B226961BE4B7D200860F4D /* ContactsUI.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; @@ -303,7 +301,6 @@ C32C59C5256DB41F003C73A2 /* TSGroupModel.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB0A255A580700E217F9 /* TSGroupModel.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C59C6256DB41F003C73A2 /* TSGroupThread.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDA79255A57FB00E217F9 /* TSGroupThread.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C59C7256DB41F003C73A2 /* TSThread.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBB8255A581600E217F9 /* TSThread.m */; }; - C32C5A02256DB658003C73A2 /* MessageSender+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8D8F1EF256621180092EF10 /* MessageSender+Convenience.swift */; }; C32C5A13256DB7A5003C73A2 /* PushNotificationAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */; }; C32C5A24256DB7DB003C73A2 /* SNUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB6B255A580F00E217F9 /* SNUserDefaults.swift */; }; C32C5A2D256DB849003C73A2 /* LKGroupUtilities.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBE1255A581A00E217F9 /* LKGroupUtilities.m */; }; @@ -343,7 +340,6 @@ C32C5BE6256DC891003C73A2 /* OWSReadReceiptManager.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB1D255A580900E217F9 /* OWSReadReceiptManager.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5BEF256DC8EE003C73A2 /* OWSDisappearingMessagesJob.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBDD255A581900E217F9 /* OWSDisappearingMessagesJob.m */; }; C32C5BF8256DC8F6003C73A2 /* OWSDisappearingMessagesJob.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDA80255A57FC00E217F9 /* OWSDisappearingMessagesJob.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C32C5C24256DCB30003C73A2 /* NotificationsProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB7A255A581000E217F9 /* NotificationsProtocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5C3D256DCBAF003C73A2 /* AppReadiness.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB75255A581000E217F9 /* AppReadiness.m */; }; C32C5C46256DCBB2003C73A2 /* AppReadiness.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB01255A580700E217F9 /* AppReadiness.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5C4F256DCC36003C73A2 /* Storage+OpenGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8D8F18825661BA50092EF10 /* Storage+OpenGroups.swift */; }; @@ -471,11 +467,11 @@ C3471F4C25553AB000297E91 /* MessageReceiver+Decryption.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3471F4B25553AB000297E91 /* MessageReceiver+Decryption.swift */; }; C34A977425A3E34A00852C71 /* ClosedGroupControlMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C34A977325A3E34A00852C71 /* ClosedGroupControlMessage.swift */; }; C34C8F7423A7830B00D82669 /* SpaceMono-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = C34C8F7323A7830A00D82669 /* SpaceMono-Bold.ttf */; }; - C352A2F525574B4700338F3E /* Job.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A2F425574B4700338F3E /* Job.swift */; }; + C352A2F525574B4700338F3E /* LegacyJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A2F425574B4700338F3E /* LegacyJob.swift */; }; C352A2FF25574B6300338F3E /* MessageSendJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A2FE25574B6300338F3E /* MessageSendJob.swift */; }; C352A30925574D8500338F3E /* Message+Destination.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A30825574D8400338F3E /* Message+Destination.swift */; }; C352A31325574F5200338F3E /* MessageReceiveJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A31225574F5200338F3E /* MessageReceiveJob.swift */; }; - C352A32F2557549C00338F3E /* NotifyPNServerJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A32E2557549C00338F3E /* NotifyPNServerJob.swift */; }; + C352A32F2557549C00338F3E /* NotifyPushServerJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A32E2557549C00338F3E /* NotifyPushServerJob.swift */; }; C352A349255781F400338F3E /* AttachmentDownloadJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A348255781F400338F3E /* AttachmentDownloadJob.swift */; }; C352A35B2557824E00338F3E /* AttachmentUploadJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A35A2557824E00338F3E /* AttachmentUploadJob.swift */; }; C352A36D2557858E00338F3E /* NSTimer+Proxying.m in Sources */ = {isa = PBXBuildFile; fileRef = C352A36C2557858D00338F3E /* NSTimer+Proxying.m */; }; @@ -520,9 +516,7 @@ C38EF276255B6D7A007E1867 /* OWSDatabaseMigration.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF270255B6D79007E1867 /* OWSDatabaseMigration.m */; }; C38EF277255B6D7A007E1867 /* OWSDatabaseMigration.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF271255B6D79007E1867 /* OWSDatabaseMigration.h */; settings = {ATTRIBUTES = (Public, ); }; }; C38EF28F255B6D86007E1867 /* VersionMigrations.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF283255B6D84007E1867 /* VersionMigrations.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C38EF290255B6D86007E1867 /* AppSetup.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF284255B6D84007E1867 /* AppSetup.h */; settings = {ATTRIBUTES = (Public, ); }; }; C38EF292255B6D86007E1867 /* VersionMigrations.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF286255B6D85007E1867 /* VersionMigrations.m */; }; - C38EF293255B6D86007E1867 /* AppSetup.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF287255B6D85007E1867 /* AppSetup.m */; }; C38EF2A5255B6D93007E1867 /* Identicon+ObjC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2A2255B6D93007E1867 /* Identicon+ObjC.swift */; }; C38EF2A6255B6D93007E1867 /* PlaceholderIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2A3255B6D93007E1867 /* PlaceholderIcon.swift */; }; C38EF2A7255B6D93007E1867 /* ProfilePictureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2A4255B6D93007E1867 /* ProfilePictureView.swift */; }; @@ -671,7 +665,6 @@ C3C2A74D2553A39700C340D1 /* VisibleMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A74C2553A39700C340D1 /* VisibleMessage.swift */; }; C3C2A7562553A3AB00C340D1 /* VisibleMessage+Quote.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7552553A3AB00C340D1 /* VisibleMessage+Quote.swift */; }; C3C2A75F2553A3C500C340D1 /* VisibleMessage+LinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A75E2553A3C500C340D1 /* VisibleMessage+LinkPreview.swift */; }; - C3C2A7682553A3D900C340D1 /* VisibleMessage+Contact.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7672553A3D900C340D1 /* VisibleMessage+Contact.swift */; }; C3C2A7712553A41E00C340D1 /* ControlMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7702553A41E00C340D1 /* ControlMessage.swift */; }; C3C2A7842553AAF300C340D1 /* SNProto.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7822553AAF200C340D1 /* SNProto.swift */; }; C3C2A7852553AAF300C340D1 /* SessionProtos.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7832553AAF300C340D1 /* SessionProtos.pb.swift */; }; @@ -724,7 +717,6 @@ D2179CFE16BB0B480006F3AB /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2179CFD16BB0B480006F3AB /* SystemConfiguration.framework */; }; D221A08E169C9E5E00537ABF /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D221A08D169C9E5E00537ABF /* UIKit.framework */; }; D221A090169C9E5E00537ABF /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D221A08F169C9E5E00537ABF /* Foundation.framework */; }; - D221A09A169C9E5E00537ABF /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = D221A099169C9E5E00537ABF /* main.m */; }; D221A0E8169DFFC500537ABF /* AVFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D221A0E7169DFFC500537ABF /* AVFoundation.framework */; }; D24B5BD5169F568C00681372 /* AudioToolbox.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D24B5BD4169F568C00681372 /* AudioToolbox.framework */; }; D2AEACDC16C426DA00C364C0 /* CFNetwork.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2AEACDB16C426DA00C364C0 /* CFNetwork.framework */; }; @@ -757,7 +749,7 @@ FD09799727FFA84A00936362 /* RecipientState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799627FFA84900936362 /* RecipientState.swift */; }; FD09799927FFC1A300936362 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799827FFC1A300936362 /* Attachment.swift */; }; FD09799B27FFC82D00936362 /* Quote.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799A27FFC82D00936362 /* Quote.swift */; }; - FD17D79927F40AB800122BE0 /* _002_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79827F40AB800122BE0 /* _002_YDBToGRDBMigration.swift */; }; + FD17D79927F40AB800122BE0 /* _003_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79827F40AB800122BE0 /* _003_YDBToGRDBMigration.swift */; }; FD17D79C27F40B2E00122BE0 /* SMKLegacyModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */; }; FD17D7A027F40CC800122BE0 /* _001_InitialSetupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79F27F40CC800122BE0 /* _001_InitialSetupMigration.swift */; }; FD17D7A127F40D2500122BE0 /* GRDBStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD28A4F527EAD44C00FF65E7 /* GRDBStorage.swift */; }; @@ -801,8 +793,31 @@ FD859F0027C4691300510D0C /* MockDataGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EFF27C4691300510D0C /* MockDataGenerator.swift */; }; FD88BAD927A7439C00BBC442 /* MessageRequestsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */; }; FD88BADB27A750F200BBC442 /* MessageRequestsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */; }; + FDA8EAFE280E8B78002B68E5 /* FailedMessagesJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA8EAFD280E8B78002B68E5 /* FailedMessagesJob.swift */; }; + FDA8EB00280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA8EAFF280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift */; }; + FDA8EB09280E90FB002B68E5 /* AppSetup.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF287255B6D85007E1867 /* AppSetup.m */; }; + FDA8EB0A280E9103002B68E5 /* AppSetup.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF284255B6D84007E1867 /* AppSetup.h */; settings = {ATTRIBUTES = (Public, ); }; }; + FDA8EB10280F8238002B68E5 /* Codable+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA8EB0F280F8238002B68E5 /* Codable+Utilities.swift */; }; FDC4389E27BA2B8A00C60D73 /* SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */; }; + FDCDB8DE2810F73B00352A0C /* Differentiable+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DD2810F73B00352A0C /* Differentiable+Utilities.swift */; }; + FDCDB8E02811007F00352A0C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */; }; + FDE77F69280F9EDA002CFC5D /* JobRunnerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE77F68280F9EDA002CFC5D /* JobRunnerError.swift */; }; + FDE77F6B280FEB28002CFC5D /* ControlMessageProcessRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */; }; FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */; }; + FDF0B740280402C4004C14C5 /* Job.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B73F280402C4004C14C5 /* Job.swift */; }; + FDF0B7422804EA4F004C14C5 /* _002_SetupStandardJobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7412804EA4F004C14C5 /* _002_SetupStandardJobs.swift */; }; + FDF0B7442804EF1B004C14C5 /* JobRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7432804EF1B004C14C5 /* JobRunner.swift */; }; + FDF0B7472804F0CE004C14C5 /* DisappearingMessagesJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7462804F0CE004C14C5 /* DisappearingMessagesJob.swift */; }; + FDF0B74928060D13004C14C5 /* QuotedReplyModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B74828060D13004C14C5 /* QuotedReplyModel.swift */; }; + FDF0B74B28061F7A004C14C5 /* InteractionAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B74A28061F7A004C14C5 /* InteractionAttachment.swift */; }; + FDF0B74D280664E9004C14C5 /* PersistableRecord+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B74C280664E9004C14C5 /* PersistableRecord+Utilities.swift */; }; + FDF0B74F28079E5E004C14C5 /* SendReadReceiptsJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B74E28079E5E004C14C5 /* SendReadReceiptsJob.swift */; }; + FDF0B7512807BA56004C14C5 /* NotificationsProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7502807BA56004C14C5 /* NotificationsProtocol.swift */; }; + FDF0B7552807C4BB004C14C5 /* SSKEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7542807C4BB004C14C5 /* SSKEnvironment.swift */; }; + FDF0B7582807F368004C14C5 /* MessageReceiverError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7572807F368004C14C5 /* MessageReceiverError.swift */; }; + FDF0B75A2807F3A3004C14C5 /* MessageSenderError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7592807F3A3004C14C5 /* MessageSenderError.swift */; }; + FDF0B75C2807F41D004C14C5 /* MessageSender+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B75B2807F41D004C14C5 /* MessageSender+Convenience.swift */; }; + FDF0B75E280AAF35004C14C5 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B75D280AAF35004C14C5 /* Preferences.swift */; }; FDFD645827EC1F4000808CA1 /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFD645727EC1F4000808CA1 /* Atomic.swift */; }; /* End PBXBuildFile section */ @@ -1120,8 +1135,6 @@ 748A5CAEDD7C919FC64C6807 /* Pods_SignalTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SignalTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 768A1A2A17FC9CD300E00ED8 /* libz.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libz.dylib; path = usr/lib/libz.dylib; sourceTree = SDKROOT; }; 76C87F18181EFCE600C4ACAB /* MediaPlayer.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaPlayer.framework; path = System/Library/Frameworks/MediaPlayer.framework; sourceTree = SDKROOT; }; - 76EB03C218170B33006006FC /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; - 76EB03C318170B33006006FC /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; 7ABE4694B110C1BBCB0E46A2 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.app store release.xcconfig"; sourceTree = ""; }; 7B1581E1271E743B00848B49 /* OWSSounds.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSSounds.swift; sourceTree = ""; }; 7B1D74A927BCC16E0030B423 /* NSENotificationPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSENotificationPresenter.swift; sourceTree = ""; }; @@ -1157,7 +1170,6 @@ A1C32D4D17A0652C000A904E /* AddressBook.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AddressBook.framework; path = System/Library/Frameworks/AddressBook.framework; sourceTree = SDKROOT; }; A1C32D4F17A06537000A904E /* AddressBookUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AddressBookUI.framework; path = System/Library/Frameworks/AddressBookUI.framework; sourceTree = SDKROOT; }; A1FDCBEE16DAA6C300868894 /* AVFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVFoundation.framework; path = System/Library/Frameworks/AVFoundation.framework; sourceTree = SDKROOT; }; - A5509EC91A69AB8B00ABA4BC /* Main.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = Main.storyboard; sourceTree = ""; }; A5C037C0D2746ABEE2684E70 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SignalUtilitiesKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SignalUtilitiesKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SignalUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SignalUtilitiesKit.debug.xcconfig"; sourceTree = ""; }; A6344D429FFAC3B44E6A06FA /* Pods-SessionSnodeKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionSnodeKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SessionSnodeKit/Pods-SessionSnodeKit.debug.xcconfig"; sourceTree = ""; }; A9F14F620D87A5BA98DDB608 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionShareExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionShareExtension/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionShareExtension.debug.xcconfig"; sourceTree = ""; }; @@ -1282,7 +1294,6 @@ B8D8F17625661AFA0092EF10 /* Storage+Jobs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+Jobs.swift"; sourceTree = ""; }; B8D8F18825661BA50092EF10 /* Storage+OpenGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+OpenGroups.swift"; sourceTree = ""; }; B8D8F19225661BF80092EF10 /* Storage+Messaging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+Messaging.swift"; sourceTree = ""; }; - B8D8F1EF256621180092EF10 /* MessageSender+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "MessageSender+Convenience.swift"; path = "../../SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift"; sourceTree = ""; }; B8EB20E6263F7E4B00773E52 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = ""; }; B8EB20ED2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VisibleMessage+OpenGroupInvitation.swift"; sourceTree = ""; }; B8EB20EF2640F7F000773E52 /* OpenGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupInvitationView.swift; sourceTree = ""; }; @@ -1448,7 +1459,6 @@ C33FDB75255A581000E217F9 /* AppReadiness.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppReadiness.m; sourceTree = ""; }; C33FDB77255A581000E217F9 /* NSUserDefaults+OWS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSUserDefaults+OWS.m"; sourceTree = ""; }; C33FDB78255A581000E217F9 /* OWSOperation.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSOperation.m; sourceTree = ""; }; - C33FDB7A255A581000E217F9 /* NotificationsProtocol.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = NotificationsProtocol.h; sourceTree = ""; }; C33FDB7F255A581100E217F9 /* FullTextSearchFinder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FullTextSearchFinder.swift; sourceTree = ""; }; C33FDB80255A581100E217F9 /* Notification+Loki.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Notification+Loki.swift"; sourceTree = ""; }; C33FDB81255A581100E217F9 /* UIImage+OWS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "UIImage+OWS.m"; sourceTree = ""; }; @@ -1505,11 +1515,11 @@ C3471F4B25553AB000297E91 /* MessageReceiver+Decryption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+Decryption.swift"; sourceTree = ""; }; C34A977325A3E34A00852C71 /* ClosedGroupControlMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClosedGroupControlMessage.swift; sourceTree = ""; }; C34C8F7323A7830A00D82669 /* SpaceMono-Bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "SpaceMono-Bold.ttf"; sourceTree = ""; }; - C352A2F425574B4700338F3E /* Job.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Job.swift; sourceTree = ""; }; + C352A2F425574B4700338F3E /* LegacyJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyJob.swift; sourceTree = ""; }; C352A2FE25574B6300338F3E /* MessageSendJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSendJob.swift; sourceTree = ""; }; C352A30825574D8400338F3E /* Message+Destination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+Destination.swift"; sourceTree = ""; }; C352A31225574F5200338F3E /* MessageReceiveJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReceiveJob.swift; sourceTree = ""; }; - C352A32E2557549C00338F3E /* NotifyPNServerJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotifyPNServerJob.swift; sourceTree = ""; }; + C352A32E2557549C00338F3E /* NotifyPushServerJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotifyPushServerJob.swift; sourceTree = ""; }; C352A348255781F400338F3E /* AttachmentDownloadJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentDownloadJob.swift; sourceTree = ""; }; C352A35A2557824E00338F3E /* AttachmentUploadJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentUploadJob.swift; sourceTree = ""; }; C352A36C2557858D00338F3E /* NSTimer+Proxying.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSTimer+Proxying.m"; sourceTree = ""; }; @@ -1728,7 +1738,6 @@ C3C2A74C2553A39700C340D1 /* VisibleMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisibleMessage.swift; sourceTree = ""; }; C3C2A7552553A3AB00C340D1 /* VisibleMessage+Quote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VisibleMessage+Quote.swift"; sourceTree = ""; }; C3C2A75E2553A3C500C340D1 /* VisibleMessage+LinkPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VisibleMessage+LinkPreview.swift"; sourceTree = ""; }; - C3C2A7672553A3D900C340D1 /* VisibleMessage+Contact.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VisibleMessage+Contact.swift"; sourceTree = ""; }; C3C2A7702553A41E00C340D1 /* ControlMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlMessage.swift; sourceTree = ""; }; C3C2A7822553AAF200C340D1 /* SNProto.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SNProto.swift; sourceTree = ""; }; C3C2A7832553AAF300C340D1 /* SessionProtos.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SessionProtos.pb.swift; sourceTree = ""; }; @@ -1766,7 +1775,6 @@ D221A08F169C9E5E00537ABF /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; }; D221A091169C9E5E00537ABF /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; }; D221A095169C9E5E00537ABF /* Session-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Session-Info.plist"; sourceTree = ""; }; - D221A099169C9E5E00537ABF /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; D221A09B169C9E5E00537ABF /* Session-Prefix.pch */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Session-Prefix.pch"; sourceTree = ""; }; D221A0E7169DFFC500537ABF /* AVFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVFoundation.framework; path = ../../../../../../System/Library/Frameworks/AVFoundation.framework; sourceTree = ""; }; D24B5BD4169F568C00681372 /* AudioToolbox.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AudioToolbox.framework; path = ../../../../../../System/Library/Frameworks/AudioToolbox.framework; sourceTree = ""; }; @@ -1810,7 +1818,7 @@ FD09799827FFC1A300936362 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; FD09799A27FFC82D00936362 /* Quote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Quote.swift; sourceTree = ""; }; FD17D79527F3E04600122BE0 /* _001_InitialSetupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _001_InitialSetupMigration.swift; sourceTree = ""; }; - FD17D79827F40AB800122BE0 /* _002_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_YDBToGRDBMigration.swift; sourceTree = ""; }; + FD17D79827F40AB800122BE0 /* _003_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _003_YDBToGRDBMigration.swift; sourceTree = ""; }; FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMKLegacyModels.swift; sourceTree = ""; }; FD17D79F27F40CC800122BE0 /* _001_InitialSetupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _001_InitialSetupMigration.swift; sourceTree = ""; }; FD17D7A327F40F8100122BE0 /* _002_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_YDBToGRDBMigration.swift; sourceTree = ""; }; @@ -1854,7 +1862,28 @@ FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsCell.swift; sourceTree = ""; }; FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsMigration.swift; sourceTree = ""; }; FD9039443F7CB729CF71350E /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig"; sourceTree = ""; }; + FDA8EAFD280E8B78002B68E5 /* FailedMessagesJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedMessagesJob.swift; sourceTree = ""; }; + FDA8EAFF280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedAttachmentDownloadsJob.swift; sourceTree = ""; }; + FDA8EB0F280F8238002B68E5 /* Codable+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Codable+Utilities.swift"; sourceTree = ""; }; + FDCDB8DD2810F73B00352A0C /* Differentiable+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Differentiable+Utilities.swift"; sourceTree = ""; }; + FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.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 = ""; }; FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreview.swift; sourceTree = ""; }; + FDF0B73F280402C4004C14C5 /* Job.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Job.swift; sourceTree = ""; }; + FDF0B7412804EA4F004C14C5 /* _002_SetupStandardJobs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_SetupStandardJobs.swift; sourceTree = ""; }; + FDF0B7432804EF1B004C14C5 /* JobRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobRunner.swift; sourceTree = ""; }; + FDF0B7462804F0CE004C14C5 /* DisappearingMessagesJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisappearingMessagesJob.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 = ""; }; + FDF0B74C280664E9004C14C5 /* PersistableRecord+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistableRecord+Utilities.swift"; sourceTree = ""; }; + FDF0B74E28079E5E004C14C5 /* SendReadReceiptsJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendReadReceiptsJob.swift; sourceTree = ""; }; + FDF0B7502807BA56004C14C5 /* NotificationsProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsProtocol.swift; sourceTree = ""; }; + FDF0B7542807C4BB004C14C5 /* SSKEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSKEnvironment.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 = ""; }; + FDF0B75D280AAF35004C14C5 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; FDFD645727EC1F4000808CA1 /* Atomic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = ""; }; FEDBAE1B98C49BBE8C87F575 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; sourceTree = ""; }; FF9BA33D021B115B1F5B4E46 /* Pods-SessionMessagingKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionMessagingKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionMessagingKit/Pods-SessionMessagingKit.app store release.xcconfig"; sourceTree = ""; }; @@ -2483,7 +2512,6 @@ C379DCF3256735770002D4EB /* VisibleMessage+Attachment.swift */, C3C2A7552553A3AB00C340D1 /* VisibleMessage+Quote.swift */, C3C2A75E2553A3C500C340D1 /* VisibleMessage+LinkPreview.swift */, - C3C2A7672553A3D900C340D1 /* VisibleMessage+Contact.swift */, C300A5B12554AF9800555489 /* VisibleMessage+Profile.swift */, B8EB20ED2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift */, ); @@ -2510,6 +2538,7 @@ C300A5F02554B08500555489 /* Sending & Receiving */ = { isa = PBXGroup; children = ( + FDF0B7562807F35E004C14C5 /* Errors */, C3D9E3B52567685D0040E4F3 /* Attachments */, B8F5F61925EDE4B0003BF8D4 /* Data Extraction */, C32C5B01256DC054003C73A2 /* Expiration */, @@ -2523,7 +2552,7 @@ B8D0A25825E367AC00C1835E /* Notification+MessageReceiver.swift */, C300A5F12554B09800555489 /* MessageSender.swift */, C38D5E8C2575011E00B6A65C /* MessageSender+ClosedGroups.swift */, - B8D8F1EF256621180092EF10 /* MessageSender+Convenience.swift */, + FDF0B75B2807F41D004C14C5 /* MessageSender+Convenience.swift */, C3471ECA2555356A00297E91 /* MessageSender+Encryption.swift */, C300A5FB2554B0A000555489 /* MessageReceiver.swift */, C3471F4B25553AB000297E91 /* MessageReceiver+Decryption.swift */, @@ -2668,6 +2697,7 @@ children = ( C38EF398255B6DD9007E1867 /* OWSQuotedReplyModel.h */, C38EF39A255B6DD9007E1867 /* OWSQuotedReplyModel.m */, + FDF0B74828060D13004C14C5 /* QuotedReplyModel.swift */, B840729F2565F1670037CB17 /* OWSQuotedReplyModel+Conversion.swift */, C33FDAD5255A580300E217F9 /* TSQuotedMessage.h */, C33FDB83255A581100E217F9 /* TSQuotedMessage.m */, @@ -2828,14 +2858,14 @@ C352A2F325574B3300338F3E /* Jobs */ = { isa = PBXGroup; children = ( - C352A2F425574B4700338F3E /* Job.swift */, + FDF0B7452804F0A8004C14C5 /* Types */, + FDF0B7432804EF1B004C14C5 /* JobRunner.swift */, + FDE77F68280F9EDA002CFC5D /* JobRunnerError.swift */, + C352A2F425574B4700338F3E /* LegacyJob.swift */, C352A3922557883D00338F3E /* JobDelegate.swift */, C352A3882557876500338F3E /* JobQueue.swift */, - C352A348255781F400338F3E /* AttachmentDownloadJob.swift */, C352A35A2557824E00338F3E /* AttachmentUploadJob.swift */, - C352A31225574F5200338F3E /* MessageReceiveJob.swift */, - C352A2FE25574B6300338F3E /* MessageSendJob.swift */, - C352A32E2557549C00338F3E /* NotifyPNServerJob.swift */, + C352A348255781F400338F3E /* AttachmentDownloadJob.swift */, ); path = Jobs; sourceTree = ""; @@ -2864,6 +2894,7 @@ 7BA7F4B9279F9F3700B3A466 /* GlobalSearch */, FD659ABE27A7648200F12C02 /* Message Requests */, FD88BAD727A7438E00BBC442 /* Views */, + FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */, B8BB82A4238F627000BA5194 /* HomeVC.swift */, B83F2B85240C7B8F000A54AB /* NewConversationButtonSet.swift */, ); @@ -3034,7 +3065,7 @@ C379DC6825672B5E0002D4EB /* Notifications */ = { isa = PBXGroup; children = ( - C33FDB7A255A581000E217F9 /* NotificationsProtocol.h */, + FDF0B7502807BA56004C14C5 /* NotificationsProtocol.swift */, C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */, ); path = Notifications; @@ -3212,6 +3243,7 @@ C33FDB71255A581000E217F9 /* OWSMediaGalleryFinder.m */, C38EF2F1255B6DBB007E1867 /* OWSPreferences.h */, C38EF308255B6DBE007E1867 /* OWSPreferences.m */, + FDF0B75D280AAF35004C14C5 /* Preferences.swift */, C38EF288255B6D85007E1867 /* OWSSounds.h */, C38EF28B255B6D86007E1867 /* OWSSounds.m */, 7B1581E1271E743B00848B49 /* OWSSounds.swift */, @@ -3225,6 +3257,7 @@ C38EEF09255B49A8007E1867 /* SNProtoEnvelope+Conversion.swift */, C33FDB31255A580A00E217F9 /* SSKEnvironment.h */, C33FDAF4255A580600E217F9 /* SSKEnvironment.m */, + FDF0B7542807C4BB004C14C5 /* SSKEnvironment.swift */, C33FDB32255A580A00E217F9 /* SSKIncrementingIdFinder.swift */, C3A3A170256E1D25004D228D /* SSKReachabilityManager.swift */, C3ECBF7A257056B700EA7FCE /* Threading.swift */, @@ -3364,8 +3397,11 @@ C3CA3B11255CF17200F4C6D4 /* Utilities */ = { isa = PBXGroup; children = ( + C38EF284255B6D84007E1867 /* AppSetup.h */, + C38EF287255B6D85007E1867 /* AppSetup.m */, C38EF302255B6DBE007E1867 /* OWSAnyTouchGestureRecognizer.h */, C38EF2F0255B6DBB007E1867 /* OWSAnyTouchGestureRecognizer.m */, + FDCDB8DD2810F73B00352A0C /* Differentiable+Utilities.swift */, C38EF2ED255B6DBB007E1867 /* DisplayableText.swift */, C38EF3DC255B6DF1007E1867 /* DirectionalPanGestureRecognizer.swift */, C38EF240255B6D67007E1867 /* UIView+OWS.swift */, @@ -3403,8 +3439,6 @@ C38EF2F2255B6DBC007E1867 /* Searcher.swift */, B8C2B33B2563770800551B4D /* ThreadUtil.h */, B8C2B331256376F000551B4D /* ThreadUtil.m */, - C38EF284255B6D84007E1867 /* AppSetup.h */, - C38EF287255B6D85007E1867 /* AppSetup.m */, B8856D5F256F129B001CE70E /* OWSAlerts.swift */, C3F0A52F255C80BC007BE2A3 /* NoopNotificationsManager.swift */, C38EF283255B6D84007E1867 /* VersionMigrations.h */, @@ -3462,8 +3496,6 @@ C3F0A58F255C8E3D007BE2A3 /* Meta */ = { isa = PBXGroup; children = ( - 76EB03C218170B33006006FC /* AppDelegate.h */, - 76EB03C318170B33006006FC /* AppDelegate.m */, C3AAFFF125AE99710089E6DD /* AppDelegate.swift */, 34D99CE3217509C1000AFB39 /* AppEnvironment.swift */, B81D260326158DF5004D1FE1 /* Certificates */, @@ -3471,8 +3503,6 @@ 34330A581E7875FB00DF2FB9 /* Fonts */, B66DBF4919D5BBC8006EA940 /* Images.xcassets */, 45CB2FA71CB7146C00E1B343 /* Launch Screen.storyboard */, - A5509EC91A69AB8B00ABA4BC /* Main.storyboard */, - D221A099169C9E5E00537ABF /* main.m */, 34B0796C1FCF46B000E248C2 /* MainAppContext.h */, 34B0796B1FCF46B000E248C2 /* MainAppContext.m */, C3CA3AA0255CDA7000F4C6D4 /* Mnemonic */, @@ -3604,6 +3634,7 @@ FD09797127FAA2F500936362 /* Optional+Utilities.swift */, FD09797C27FBDB2000936362 /* Notification+Utilities.swift */, C3E7134E251C867C009649BB /* Sodium+Conversion.swift */, + FDA8EB0F280F8238002B68E5 /* Codable+Utilities.swift */, ); path = Utilities; sourceTree = ""; @@ -3622,9 +3653,12 @@ FD09798A27FD1CFE00936362 /* Capability.swift */, FD09799227FE693200936362 /* Interaction.swift */, FD09799627FFA84900936362 /* RecipientState.swift */, + FDF0B74A28061F7A004C14C5 /* InteractionAttachment.swift */, FD09799827FFC1A300936362 /* Attachment.swift */, FD09799A27FFC82D00936362 /* Quote.swift */, FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */, + FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */, + FDF0B73F280402C4004C14C5 /* Job.swift */, ); path = Models; sourceTree = ""; @@ -3633,7 +3667,8 @@ isa = PBXGroup; children = ( FD17D79527F3E04600122BE0 /* _001_InitialSetupMigration.swift */, - FD17D79827F40AB800122BE0 /* _002_YDBToGRDBMigration.swift */, + FDF0B7412804EA4F004C14C5 /* _002_SetupStandardJobs.swift */, + FD17D79827F40AB800122BE0 /* _003_YDBToGRDBMigration.swift */, ); path = Migrations; sourceTree = ""; @@ -3710,6 +3745,7 @@ FD17D7C227F5204C00122BE0 /* Database+Utilities.swift */, FD17D7C627F5207C00122BE0 /* DatabaseMigrator+Utilities.swift */, FD17D7BC27F51F6900122BE0 /* GRDB+Notifications.swift */, + FDF0B74C280664E9004C14C5 /* PersistableRecord+Utilities.swift */, ); path = Utilities; sourceTree = ""; @@ -3775,6 +3811,29 @@ path = Views; sourceTree = ""; }; + FDF0B7452804F0A8004C14C5 /* Types */ = { + isa = PBXGroup; + children = ( + FDF0B7462804F0CE004C14C5 /* DisappearingMessagesJob.swift */, + FDA8EAFD280E8B78002B68E5 /* FailedMessagesJob.swift */, + FDA8EAFF280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift */, + C352A2FE25574B6300338F3E /* MessageSendJob.swift */, + C352A31225574F5200338F3E /* MessageReceiveJob.swift */, + C352A32E2557549C00338F3E /* NotifyPushServerJob.swift */, + FDF0B74E28079E5E004C14C5 /* SendReadReceiptsJob.swift */, + ); + path = Types; + sourceTree = ""; + }; + FDF0B7562807F35E004C14C5 /* Errors */ = { + isa = PBXGroup; + children = ( + FDF0B7572807F368004C14C5 /* MessageReceiverError.swift */, + FDF0B7592807F3A3004C14C5 /* MessageSenderError.swift */, + ); + path = Errors; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -3800,6 +3859,7 @@ C33FDD7C255A582000E217F9 /* SSKAsserts.h in Headers */, C38EF3F6255B6DF7007E1867 /* OWSTextView.h in Headers */, C38EF24C255B6D67007E1867 /* NSAttributedString+OWS.h in Headers */, + FDA8EB0A280E9103002B68E5 /* AppSetup.h in Headers */, C38EF32B255B6DBF007E1867 /* OWSFormat.h in Headers */, C33FDC2C255A581F00E217F9 /* OWSFailedAttachmentDownloadsJob.h in Headers */, C38EF22A255B6D5D007E1867 /* AttachmentSharing.h in Headers */, @@ -3826,7 +3886,6 @@ C38EF404255B6DF7007E1867 /* ContactTableViewCell.h in Headers */, C33FDDB2255A582000E217F9 /* NSArray+OWS.h in Headers */, C38EF2D7255B6DAF007E1867 /* OWSProfileManager.h in Headers */, - C38EF290255B6D86007E1867 /* AppSetup.h in Headers */, C38EF367255B6DCC007E1867 /* OWSTableViewController.h in Headers */, C38EF246255B6D67007E1867 /* UIFont+OWS.h in Headers */, C33FD9AF255A548A00E217F9 /* SignalUtilitiesKit.h in Headers */, @@ -3889,7 +3948,6 @@ C3A3A122256E1A97004D228D /* OWSDisappearingMessagesFinder.h in Headers */, C3A3A12B256E1AD5004D228D /* TSDatabaseSecondaryIndexes.h in Headers */, C32C5FC4256E0209003C73A2 /* OWSBackgroundTask.h in Headers */, - C32C5C24256DCB30003C73A2 /* NotificationsProtocol.h in Headers */, C32C59C0256DB41F003C73A2 /* TSThread.h in Headers */, C32C5AB3256DBE8F003C73A2 /* TSInteraction.h in Headers */, C32C5DA5256DD6E5003C73A2 /* OWSOutgoingReceiptManager.h in Headers */, @@ -4309,7 +4367,6 @@ 4C63CC00210A620B003AE45C /* SignalTSan.supp in Resources */, 4C6F527C20FFE8400097DEEE /* SignalUBSan.supp in Resources */, 34CF078A203E6B78005C4D61 /* end_call_tone_cept.caf in Resources */, - A5509ECA1A69AB8B00ABA4BC /* Main.storyboard in Resources */, C3CA3AA2255CDADA00F4C6D4 /* english.txt in Resources */, B6F509971AA53F760068F56A /* Localizable.strings in Resources */, C3A01E05261D24C400290BEB /* public-loki-foundation.der in Resources */, @@ -4613,6 +4670,7 @@ C33FDD49255A582000E217F9 /* ParamParser.swift in Sources */, C38EF3C5255B6DE7007E1867 /* OWSViewController+ImageEditor.swift in Sources */, C38EF39B255B6DDA007E1867 /* ThreadViewModel.swift in Sources */, + FDA8EB09280E90FB002B68E5 /* AppSetup.m in Sources */, C38EF2A5255B6D93007E1867 /* Identicon+ObjC.swift in Sources */, C38EF273255B6D7A007E1867 /* OWSDatabaseMigrationRunner.m in Sources */, C38EF31A255B6DBF007E1867 /* OWSAnyTouchGestureRecognizer.m in Sources */, @@ -4695,12 +4753,12 @@ C38EF3BD255B6DE7007E1867 /* ImageEditorTransform.swift in Sources */, C38EF24F255B6D67007E1867 /* UIColor+OWS.m in Sources */, C33FDC9A255A582000E217F9 /* ByteParser.m in Sources */, - C38EF293255B6D86007E1867 /* AppSetup.m in Sources */, C33FDDC0255A582000E217F9 /* SignalAccount.m in Sources */, C33FDC58255A582000E217F9 /* ReverseDispatchQueue.swift in Sources */, C38EF3F4255B6DF7007E1867 /* ContactCellView.m in Sources */, C33FDC78255A582000E217F9 /* TSConstants.m in Sources */, C38EF324255B6DBF007E1867 /* Bench.swift in Sources */, + FDCDB8DE2810F73B00352A0C /* Differentiable+Utilities.swift in Sources */, C38EF292255B6D86007E1867 /* VersionMigrations.m in Sources */, C38EF3F2255B6DF7007E1867 /* DisappearingTimerConfigurationView.swift in Sources */, C38EF3F9255B6DF7007E1867 /* OWSLayerView.swift in Sources */, @@ -4776,6 +4834,7 @@ C3BBE0A82554D4DE0050F1E3 /* JSON.swift in Sources */, FD17D7C127F5200100122BE0 /* TypedTableDefinition.swift in Sources */, FD17D7EA27F6A1C600122BE0 /* SUKLegacyModels.swift in Sources */, + FDA8EB10280F8238002B68E5 /* Codable+Utilities.swift in Sources */, C352A36D2557858E00338F3E /* NSTimer+Proxying.m in Sources */, FD09797B27FBB25900936362 /* Updatable.swift in Sources */, C32C5A2D256DB849003C73A2 /* LKGroupUtilities.m in Sources */, @@ -4783,6 +4842,7 @@ B8856E09256F1676001CE70E /* UIDevice+featureSupport.swift in Sources */, B8856DEF256F161F001CE70E /* NSString+SSK.m in Sources */, FD09796727F6B0B600936362 /* Sodium+Conversion.swift in Sources */, + FDF0B74D280664E9004C14C5 /* PersistableRecord+Utilities.swift in Sources */, FD09797927FAB7E800936362 /* ImageFormat.swift in Sources */, C32C5DC9256DD935003C73A2 /* ProxiedContentDownloader.swift in Sources */, C3D9E4C02567767F0040E4F3 /* DataSource.m in Sources */, @@ -4859,7 +4919,9 @@ C3A3A156256E1B91004D228D /* ProtoUtils.m in Sources */, FD09799927FFC1A300936362 /* Attachment.swift in Sources */, C3471ECB2555356A00297E91 /* MessageSender+Encryption.swift in Sources */, - C352A32F2557549C00338F3E /* NotifyPNServerJob.swift in Sources */, + FDE77F69280F9EDA002CFC5D /* JobRunnerError.swift in Sources */, + FDF0B74928060D13004C14C5 /* QuotedReplyModel.swift in Sources */, + C352A32F2557549C00338F3E /* NotifyPushServerJob.swift in Sources */, 7B4C75CB26B37E0F0000AC89 /* UnsendRequest.swift in Sources */, C300A5F22554B09800555489 /* MessageSender.swift in Sources */, C3C2A74D2553A39700C340D1 /* VisibleMessage.swift in Sources */, @@ -4876,21 +4938,26 @@ C3C2A7562553A3AB00C340D1 /* VisibleMessage+Quote.swift in Sources */, C3227FF6260AAD66006EA627 /* OpenGroupMessageV2.swift in Sources */, C32C5C89256DD0D2003C73A2 /* Storage+Jobs.swift in Sources */, + FDF0B75C2807F41D004C14C5 /* MessageSender+Convenience.swift in Sources */, FD09799727FFA84A00936362 /* RecipientState.swift in Sources */, C300A5FC2554B0A000555489 /* MessageReceiver.swift in Sources */, 7B1581E2271E743B00848B49 /* OWSSounds.swift in Sources */, C32C5A76256DBBCF003C73A2 /* SignalAttachment.swift in Sources */, + FDA8EB00280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift in Sources */, FD09798927FD1C5A00936362 /* OpenGroup.swift in Sources */, C32C5CA4256DD1DC003C73A2 /* TSAccountManager.m in Sources */, C352A3892557876500338F3E /* JobQueue.swift in Sources */, C3BBE0B52554F0E10050F1E3 /* ProofOfWork.swift in Sources */, + FDF0B740280402C4004C14C5 /* Job.swift in Sources */, C32C59C1256DB41F003C73A2 /* TSGroupThread.m in Sources */, C3A3A08F256E1728004D228D /* FullTextSearchFinder.swift in Sources */, + FDF0B7472804F0CE004C14C5 /* DisappearingMessagesJob.swift in Sources */, B8856D1A256F114D001CE70E /* ProximityMonitoringManager.swift in Sources */, C3D9E52725677DF20040E4F3 /* OWSThumbnailService.swift in Sources */, C32C5E75256DE020003C73A2 /* YapDatabaseTransaction+OWS.m in Sources */, FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */, FD09797527FAB64300936362 /* ProfileManager.swift in Sources */, + FDA8EAFE280E8B78002B68E5 /* FailedMessagesJob.swift in Sources */, C3BBE0802554CDD70050F1E3 /* Storage.swift in Sources */, C3DB66AC260ACA42001EFC55 /* OpenGroupManagerV2.swift in Sources */, B8F5F61B25EDE4BF003BF8D4 /* DataExtractionNotificationInfoMessage.swift in Sources */, @@ -4903,20 +4970,24 @@ C3A3A107256E1A5C004D228D /* OWSDisappearingMessagesFinder.m in Sources */, FD09798727FD1B7800936362 /* GroupMember.swift in Sources */, FD09799127FD499200936362 /* BoxKeyPair+Utilities.swift in Sources */, + FDF0B7552807C4BB004C14C5 /* SSKEnvironment.swift in Sources */, C32C59C3256DB41F003C73A2 /* TSGroupModel.m in Sources */, B8856ECE256F1E58001CE70E /* OWSPreferences.m in Sources */, - FD17D79927F40AB800122BE0 /* _002_YDBToGRDBMigration.swift in Sources */, + FD17D79927F40AB800122BE0 /* _003_YDBToGRDBMigration.swift in Sources */, C3C2A7842553AAF300C340D1 /* SNProto.swift in Sources */, + FDF0B7512807BA56004C14C5 /* NotificationsProtocol.swift in Sources */, C32C5DC0256DD743003C73A2 /* Poller.swift in Sources */, - C3C2A7682553A3D900C340D1 /* VisibleMessage+Contact.swift in Sources */, C3A3A171256E1D25004D228D /* SSKReachabilityManager.swift in Sources */, C3A71D0B2558989C0043A11F /* MessageWrapper.swift in Sources */, + FDE77F6B280FEB28002CFC5D /* ControlMessageProcessRecord.swift in Sources */, B8F5F60325EDE16F003BF8D4 /* DataExtractionNotification.swift in Sources */, C32C5D24256DD4C0003C73A2 /* MentionsManager.swift in Sources */, C3A71D1E25589AC30043A11F /* WebSocketProto.swift in Sources */, C3C2A7852553AAF300C340D1 /* SessionProtos.pb.swift in Sources */, B8566C63256F55930045A0B9 /* OWSLinkPreview+Conversion.swift in Sources */, C32C5B3F256DC1DF003C73A2 /* TSQuotedMessage+Conversion.swift in Sources */, + FDF0B74F28079E5E004C14C5 /* SendReadReceiptsJob.swift in Sources */, + FDF0B7422804EA4F004C14C5 /* _002_SetupStandardJobs.swift in Sources */, B8EB20EE2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift in Sources */, FD17D7A227F40F0500122BE0 /* _001_InitialSetupMigration.swift in Sources */, C3C2A7712553A41E00C340D1 /* ControlMessage.swift in Sources */, @@ -4924,6 +4995,7 @@ FD3C907527E83AC200CD579F /* OpenGroupServerIdLookup.swift in Sources */, C32C5CF0256DD3E4003C73A2 /* Storage+Shared.swift in Sources */, C300A5BD2554B00D00555489 /* ReadReceipt.swift in Sources */, + FDF0B7582807F368004C14C5 /* MessageReceiverError.swift in Sources */, C32C5AB5256DBE8F003C73A2 /* TSOutgoingMessage+Conversion.swift in Sources */, C32C5E5B256DDF45003C73A2 /* OWSStorage.m in Sources */, C32C5E15256DDC78003C73A2 /* SSKPreferences.swift in Sources */, @@ -4933,6 +5005,7 @@ B8856CEE256F1054001CE70E /* OWSAudioPlayer.m in Sources */, FD5D200F27AA2B6000FEA984 /* MessageRequestResponse.swift in Sources */, FD09798D27FD1D8900936362 /* DisappearingMessageConfiguration.swift in Sources */, + FDF0B75A2807F3A3004C14C5 /* MessageSenderError.swift in Sources */, C32C5EDC256DF501003C73A2 /* YapDatabaseConnection+OWS.m in Sources */, C3BBE0762554CDA60050F1E3 /* Configuration.swift in Sources */, C35D76DB26606304009AA5FB /* ThreadUpdateBatcher.swift in Sources */, @@ -4941,7 +5014,6 @@ C3ECBF7B257056B700EA7FCE /* Threading.swift in Sources */, C32C5AAB256DBE8F003C73A2 /* TSIncomingMessage+Conversion.swift in Sources */, C32C5A88256DBCF9003C73A2 /* MessageReceiver+Handling.swift in Sources */, - C32C5A02256DB658003C73A2 /* MessageSender+Convenience.swift in Sources */, B8566C6C256F60F50045A0B9 /* OWSUserProfile.m in Sources */, B8D0A25925E367AC00C1835E /* Notification+MessageReceiver.swift in Sources */, C32C599E256DB02B003C73A2 /* TypingIndicators.swift in Sources */, @@ -4956,7 +5028,9 @@ B8856E94256F1C37001CE70E /* OWSSounds.m in Sources */, C32C5BCC256DC830003C73A2 /* Storage+ClosedGroups.swift in Sources */, C3A3A0EC256E1949004D228D /* OWSRecipientIdentity.m in Sources */, + FDF0B74B28061F7A004C14C5 /* InteractionAttachment.swift in Sources */, B8F5F56525EC8453003BF8D4 /* Notification+Contacts.swift in Sources */, + FDF0B7442804EF1B004C14C5 /* JobRunner.swift in Sources */, C32C5AB2256DBE8F003C73A2 /* TSMessage.m in Sources */, FD09796E27FA6D0000936362 /* Contact.swift in Sources */, C3A3A0FE256E1A3C004D228D /* TSDatabaseSecondaryIndexes.m in Sources */, @@ -5001,8 +5075,9 @@ C32C5AF8256DC051003C73A2 /* OWSDisappearingMessagesConfiguration.m in Sources */, C32C5EBA256DE130003C73A2 /* OWSQuotedReplyModel.m in Sources */, C32C5B62256DC333003C73A2 /* OWSDisappearingConfigurationUpdateInfoMessage.m in Sources */, - C352A2F525574B4700338F3E /* Job.swift in Sources */, + C352A2F525574B4700338F3E /* LegacyJob.swift in Sources */, C32C59C4256DB41F003C73A2 /* TSContactThread.m in Sources */, + FDF0B75E280AAF35004C14C5 /* Preferences.swift in Sources */, C32C5AB0256DBE8F003C73A2 /* TSOutgoingMessage.m in Sources */, B82A0C3826B9098200C1BCE3 /* MessageInvalidator.swift in Sources */, ); @@ -5035,6 +5110,7 @@ B83524A525C3BA4B0089A44F /* InfoMessageCell.swift in Sources */, B84A89BC25DE328A0040017D /* ProfilePictureVC.swift in Sources */, 34386A54207D271D009F5D9C /* NeverClearView.swift in Sources */, + FDCDB8E02811007F00352A0C /* HomeViewModel.swift in Sources */, 451166C01FD86B98000739BA /* AccountManager.swift in Sources */, C374EEF425DB31D40073A857 /* VoiceMessageRecordingView.swift in Sources */, B83F2B88240CB75A000A54AB /* UIImage+Scaling.swift in Sources */, @@ -5056,7 +5132,6 @@ C3D0972B2510499C00F6E3E4 /* BackgroundPoller.swift in Sources */, C3548F0624456447009433A8 /* PNModeVC.swift in Sources */, B80A579F23DFF1F300876683 /* NewClosedGroupVC.swift in Sources */, - D221A09A169C9E5E00537ABF /* main.m in Sources */, B835247925C38D880089A44F /* MessageCell.swift in Sources */, B86BD08623399CEF000F5AE3 /* SeedModal.swift in Sources */, 34E3E5681EC4B19400495BAC /* AudioProgressView.swift in Sources */, @@ -5134,7 +5209,6 @@ 4C21D5D6223A9DC500EF8A77 /* UIAlerts+iOS9.m in Sources */, B8269D3325C7A8C600488AB4 /* InputViewButton.swift in Sources */, B8269D3D25C7B34D00488AB4 /* InputTextView.swift in Sources */, - 76EB054018170B33006006FC /* AppDelegate.m in Sources */, 340FC8B6204DAC8D007AEB0F /* OWSQRCodeScanningViewController.m in Sources */, C33100082558FF6D00070591 /* NewConversationButtonSet.swift in Sources */, 7BA7F4BB279F9F5800B3A466 /* EmptySearchResultCell.swift in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 84535c89a..c97206312 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -1118,7 +1118,7 @@ extension ConversationVC: UIDocumentInteractionControllerDelegate { extension ConversationVC { - fileprivate func approveMessageRequestIfNeeded(for thread: TSThread?, isNewThread: Bool, timestamp: UInt64) -> Promise { + fileprivate func approveMessageRequestIfNeeded(for thread: TSThread?, isNewThread: Bool, timestamp: Double) -> Promise { guard let contactThread: TSContactThread = thread as? TSContactThread else { return Promise.value(()) } // If the contact doesn't exist then we should create it so we can store the 'isApproved' state diff --git a/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift b/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift index 2132d417b..0de2178b6 100644 --- a/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift +++ b/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift @@ -11,7 +11,7 @@ final class LinkPreviewView : UIView { private lazy var sentLinkPreviewTextColor: UIColor = { let isOutgoing = (viewItem?.interaction.interactionType() == .outgoingMessage) switch (isOutgoing, AppModeManager.shared.currentAppMode) { - case (true, .dark), (false, .light): return .black + case (false, .light): return .black case (true, .light): return Colors.grey default: return .white } diff --git a/Session/Conversations/Settings/OWSConversationSettingsViewController.m b/Session/Conversations/Settings/OWSConversationSettingsViewController.m index d9b80457e..2da6b6b56 100644 --- a/Session/Conversations/Settings/OWSConversationSettingsViewController.m +++ b/Session/Conversations/Settings/OWSConversationSettingsViewController.m @@ -815,9 +815,7 @@ CGFloat kIconViewLength = 24; if (gThread.isClosedGroup) { NSString *groupPublicKey = [LKGroupUtilities getDecodedGroupID:gThread.groupModel.groupId]; - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) { - [[SNMessageSender leaveClosedGroupWithPublicKey:groupPublicKey using:transaction] retainUntilComplete]; - }]; + [[SNMessageSender leaveClosedGroupWithPublicKey:groupPublicKey] retainUntilComplete]; } [self.navigationController popViewControllerAnimated:YES]; diff --git a/Session/Conversations/Views & Modals/BlockedModal.swift b/Session/Conversations/Views & Modals/BlockedModal.swift index d353f43e9..5e57d8e6b 100644 --- a/Session/Conversations/Views & Modals/BlockedModal.swift +++ b/Session/Conversations/Views & Modals/BlockedModal.swift @@ -25,7 +25,7 @@ final class BlockedModal: Modal { override func populateContentView() { // Name - let name = Profile.displayName(for: publicKey) + let name = Profile.displayName(id: publicKey) // Title let titleLabel = UILabel() titleLabel.textColor = Colors.text @@ -80,7 +80,7 @@ final class BlockedModal: Modal { .update(db) }, completion: { db, _ in - MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() + try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() } ) diff --git a/Session/Conversations/Views & Modals/ConversationTitleView.swift b/Session/Conversations/Views & Modals/ConversationTitleView.swift index f33d3ff31..33784dd5e 100644 --- a/Session/Conversations/Views & Modals/ConversationTitleView.swift +++ b/Session/Conversations/Views & Modals/ConversationTitleView.swift @@ -88,7 +88,7 @@ final class ConversationTitleView : UIView { let sessionID = (thread as! TSContactThread).contactSessionID() let middleTruncatedHexKey: String = "\(sessionID.prefix(4))...\(sessionID.suffix(4))" - return Profile.displayName(for: sessionID, customFallback: middleTruncatedHexKey) + return Profile.displayName(id: sessionID, customFallback: middleTruncatedHexKey) } } diff --git a/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift b/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift index 7f8d8ba50..5dbbebce9 100644 --- a/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift +++ b/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift @@ -1,5 +1,11 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -final class JoinOpenGroupModal : Modal { +import UIKit +import GRDB +import SessionMessagingKit +import SessionUtilitiesKit + +final class JoinOpenGroupModal: Modal { private let name: String private let url: String @@ -63,24 +69,28 @@ final class JoinOpenGroupModal : Modal { // MARK: Interaction @objc private func joinOpenGroup() { + guard let presentingViewController: UIViewController = self.presentingViewController else { return } guard let (room, server, publicKey) = OpenGroupManagerV2.parseV2OpenGroup(from: url) else { let alert = UIAlertController(title: "Couldn't Join", message: nil, preferredStyle: .alert) alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil)) - return presentingViewController!.present(alert, animated: true, completion: nil) + return presentingViewController.present(alert, animated: true, completion: nil) } - presentingViewController!.dismiss(animated: true, completion: nil) - Storage.shared.write { [presentingViewController = self.presentingViewController!] transaction in - OpenGroupManagerV2.shared.add(room: room, server: server, publicKey: publicKey, using: transaction) - .done(on: DispatchQueue.main) { _ in - GRDBStorage.shared.write { db in - MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...) - } - } - .catch(on: DispatchQueue.main) { error in - let alert = UIAlertController(title: "Couldn't Join", message: error.localizedDescription, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil)) - presentingViewController.present(alert, animated: true, completion: nil) + + presentingViewController.dismiss(animated: true, completion: nil) + + GRDBStorage.shared.write { db in + OpenGroupManagerV2.shared + .add(db, room: room, server: server, publicKey: publicKey) + } + .done(on: DispatchQueue.main) { _ in + GRDBStorage.shared.write { db in + try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...) } } + .catch(on: DispatchQueue.main) { error in + let alert = UIAlertController(title: "Couldn't Join", message: error.localizedDescription, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil)) + presentingViewController.present(alert, animated: true, completion: nil) + } } } diff --git a/Session/Conversations/Views & Modals/UserDetailsSheet.swift b/Session/Conversations/Views & Modals/UserDetailsSheet.swift index 44a9dd302..3e5b1bad9 100644 --- a/Session/Conversations/Views & Modals/UserDetailsSheet.swift +++ b/Session/Conversations/Views & Modals/UserDetailsSheet.swift @@ -30,7 +30,7 @@ final class UserDetailsSheet: Sheet { profilePictureView.update() // Display name label let displayNameLabel = UILabel() - let displayName = Profile.displayName(for: sessionID) + let displayName = Profile.displayName(id: sessionID) displayNameLabel.text = displayName displayNameLabel.font = .boldSystemFont(ofSize: Values.largeFontSize) displayNameLabel.textColor = Colors.text diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 4f8b8bfa3..f2750a931 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -2,44 +2,20 @@ import UIKit import GRDB +import DifferenceKit import SessionMessagingKit import SessionUtilitiesKit +import SignalUtilitiesKit -// See https://github.com/yapstudios/YapDatabase/wiki/LongLivedReadTransactions and -// https://github.com/yapstudios/YapDatabase/wiki/YapDatabaseModifiedNotification for -// more information on database handling. -final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConversationButtonSetDelegate, SeedReminderViewDelegate { - private var threads: YapDatabaseViewMappings! - private var threadViewModelCache: [String:ThreadViewModel] = [:] // Thread ID to ThreadViewModel +final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConversationButtonSetDelegate, SeedReminderViewDelegate { + private let viewModel: HomeViewModel = HomeViewModel() + private var dataChangeObservable: DatabaseCancellable? + private var hasLoadedInitialData: Bool = false + + // MARK: - UI + private var tableViewTopConstraint: NSLayoutConstraint! - private var unreadMessageRequestCount: UInt { - var count: UInt = 0 - - dbConnection.read { transaction in - let ext = transaction.ext(TSThreadDatabaseViewExtensionName) as! YapDatabaseViewTransaction - ext.enumerateRows(inGroup: TSMessageRequestGroup) { _, _, object, _, _, _ in - if ((object as? TSThread)?.unreadMessageCount(transaction: transaction) ?? 0) > 0 { - count += 1 - } - } - } - - return count - } - private var threadCount: UInt { - threads.numberOfItems(inGroup: TSInboxGroup) - } - - private lazy var dbConnection: YapDatabaseConnection = { - let result = OWSPrimaryStorage.shared().newDatabaseConnection() - result.objectCacheLimit = 500 - return result - }() - - private var isReloading = false - - // MARK: UI Components private lazy var seedReminderView: SeedReminderView = { let result = SeedReminderView(hasContinueButton: true) let title = "You're almost finished! 80%" @@ -49,6 +25,7 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv result.subtitle = NSLocalizedString("view_seed_reminder_subtitle_1", comment: "") result.setProgress(0.8, animated: false) result.delegate = self + return result }() @@ -56,11 +33,23 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv let result = UITableView() result.backgroundColor = .clear result.separatorStyle = .none + result.contentInset = UIEdgeInsets( + top: 0, + left: 0, + bottom: ( + Values.newConversationButtonBottomOffset + + NewConversationButtonSet.expandedButtonSize + + Values.largeSpacing + + NewConversationButtonSet.collapsedButtonSize + ), + right: 0 + ) + result.showsVerticalScrollIndicator = false result.register(MessageRequestsCell.self, forCellReuseIdentifier: MessageRequestsCell.reuseIdentifier) result.register(ConversationCell.self, forCellReuseIdentifier: ConversationCell.reuseIdentifier) - let bottomInset = Values.newConversationButtonBottomOffset + NewConversationButtonSet.expandedButtonSize + Values.largeSpacing + NewConversationButtonSet.collapsedButtonSize - result.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: bottomInset, right: 0) - result.showsVerticalScrollIndicator = false + result.dataSource = self + result.delegate = self + return result }() @@ -75,6 +64,7 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv let gradient = Gradients.homeVCFade result.setGradient(gradient) result.isUserInteractionEnabled = false + return result }() @@ -95,20 +85,20 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv result.spacing = Values.mediumSpacing result.alignment = .center result.isHidden = true + return result }() - // MARK: Lifecycle + // MARK: - Lifecycle + override func viewDidLoad() { super.viewDidLoad() - // Note: This is a hack to ensure `isRTL` is initially gets run on the main thread so the value is cached (it gets - // called on background threads and if it hasn't cached the value then it can cause odd performance issues since - // it accesses UIKit) + // Note: This is a hack to ensure `isRTL` is initially gets run on the main thread so the value + // is cached (it gets called on background threads and if it hasn't cached the value then it can + // cause odd performance issues since it accesses UIKit) _ = CurrentAppContext().isRTL - // Threads (part 1) - dbConnection.beginLongLivedReadTransaction() // Freeze the connection for use on the main thread (this gives us a stable data source that doesn't change until we tell it to) // Preparation SignalApp.shared().homeViewController = self // Gradient & nav bar @@ -126,9 +116,8 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv seedReminderView.pin(.top, to: .top, of: view) seedReminderView.pin(.trailing, to: .trailing, of: view) } + // Table view - tableView.dataSource = self - tableView.delegate = self view.addSubview(tableView) tableView.pin(.leading, to: .leading, of: view) if !hasViewedSeed { @@ -144,255 +133,119 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv fadeView.pin(.top, to: .top, of: view, withInset: topInset) fadeView.pin(.trailing, to: .trailing, of: view) fadeView.pin(.bottom, to: .bottom, of: view) + // Empty state view view.addSubview(emptyStateView) emptyStateView.center(.horizontal, in: view) let verticalCenteringConstraint = emptyStateView.center(.vertical, in: view) verticalCenteringConstraint.constant = -16 // Makes things appear centered visually + // New conversation button set view.addSubview(newConversationButtonSet) newConversationButtonSet.center(.horizontal, in: view) newConversationButtonSet.pin(.bottom, to: .bottom, of: view, withInset: -Values.newConversationButtonBottomOffset) // Negative due to how the constraint is set up + // Notifications let notificationCenter = NotificationCenter.default - notificationCenter.addObserver(self, selector: #selector(handleYapDatabaseModifiedNotification(_:)), name: .YapDatabaseModified, object: OWSPrimaryStorage.shared().dbNotificationObject) - notificationCenter.addObserver(self, selector: #selector(handleProfileDidChangeNotification(_:)), name: Notification.Name.otherUsersProfileDidChange, object: nil) - notificationCenter.addObserver(self, selector: #selector(handleLocalProfileDidChangeNotification(_:)), name: Notification.Name.localProfileDidChange, object: nil) + notificationCenter.addObserver(self, selector: #selector(applicationDidBecomeActive(_:)), name: UIApplication.didBecomeActiveNotification, object: nil) + notificationCenter.addObserver(self, selector: #selector(applicationDidResignActive(_:)), name: UIApplication.didEnterBackgroundNotification, object: nil) + + notificationCenter.addObserver(self, selector: #selector(handleProfileDidChangeNotification(_:)), name: .otherUsersProfileDidChange, object: nil) + notificationCenter.addObserver(self, selector: #selector(handleLocalProfileDidChangeNotification(_:)), name: .localProfileDidChange, object: nil) notificationCenter.addObserver(self, selector: #selector(handleSeedViewedNotification(_:)), name: .seedViewed, object: nil) notificationCenter.addObserver(self, selector: #selector(handleBlockedContactsUpdatedNotification(_:)), name: .blockedContactsUpdated, object: nil) - notificationCenter.addObserver(self, selector: #selector(applicationDidBecomeActive(_:)), name: .OWSApplicationDidBecomeActive, object: nil) - // Threads (part 2) - threads = YapDatabaseViewMappings(groups: [ TSMessageRequestGroup, TSInboxGroup ], view: TSThreadDatabaseViewExtensionName) // The extension should be registered at this point - threads.setIsReversed(true, forGroup: TSInboxGroup) - dbConnection.read { transaction in - self.threads.update(with: transaction) // Perform the initial update - } + // Start polling if needed (i.e. if the user just created or restored their Session ID) - if Identity.userExists() { - let appDelegate = UIApplication.shared.delegate as! AppDelegate - appDelegate.startPollerIfNeeded() - appDelegate.startClosedGroupPoller() - appDelegate.startOpenGroupPollersIfNeeded() + if Identity.userExists(), let appDelegate: AppDelegate = UIApplication.shared.delegate as? AppDelegate { + appDelegate.startPollersIfNeeded() + // Do this only if we created a new Session ID, or if we already received the initial configuration message if UserDefaults.standard[.hasSyncedInitialConfiguration] { appDelegate.syncConfigurationIfNeeded() } } + // Re-populate snode pool if needed SnodeAPI.getSnodePool().retainUntilComplete() + // Onion request path countries cache DispatchQueue.global(qos: .utility).sync { let _ = IP2Country.shared.populateCacheIfNeeded() } + // Get default open group rooms if needed OpenGroupAPIV2.getDefaultRoomsIfNeeded() } - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - reload() + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + startObservingChanges() } - @objc private func applicationDidBecomeActive(_ notification: Notification) { - reload() + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + // Stop observing database changes + dataChangeObservable?.cancel() + } + + @objc func applicationDidBecomeActive(_ notification: Notification) { + startObservingChanges() + } + + @objc func applicationDidResignActive(_ notification: Notification) { + // Stop observing database changes + dataChangeObservable?.cancel() } deinit { NotificationCenter.default.removeObserver(self) } - - // MARK: - UITableViewDataSource - - func numberOfSections(in tableView: UITableView) -> Int { - return 2 - } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - switch section { - case 0: - if unreadMessageRequestCount > 0 && !CurrentAppContext().appUserDefaults()[.hasHiddenMessageRequests] { - return 1 - } - - return 0 - - case 1: return Int(threadCount) - default: return 0 - } - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - switch indexPath.section { - case 0: - let cell = tableView.dequeueReusableCell(withIdentifier: MessageRequestsCell.reuseIdentifier) as! MessageRequestsCell - cell.update(with: Int(unreadMessageRequestCount)) - return cell - - default: - let cell = tableView.dequeueReusableCell(withIdentifier: ConversationCell.reuseIdentifier) as! ConversationCell - cell.threadViewModel = threadViewModel(at: indexPath.row) - return cell - } - } - // MARK: Updating + // MARK: - Updating - private func reload() { - AssertIsOnMainThread() - guard !isReloading else { return } - isReloading = true - dbConnection.beginLongLivedReadTransaction() // Jump to the latest commit - dbConnection.read { transaction in - self.threads.update(with: transaction) - } - threadViewModelCache.removeAll() - tableView.reloadData() - emptyStateView.isHidden = (threadCount != 0) - isReloading = false + private func startObservingChanges() { + // Start observing for data changes + dataChangeObservable = GRDBStorage.shared.start( + viewModel.observableViewData, + onError: { error in + print("Update error!!!!") + }, + onChange: { [weak self] viewData in + // The defaul scheduler emits changes on the main thread + self?.handleUpdates(viewData) + } + ) } - @objc private func handleYapDatabaseModifiedNotification(_ yapDatabase: YapDatabase) { - // NOTE: This code is very finicky and crashes easily. Modify with care. - AssertIsOnMainThread() - // If we don't capture `threads` here, a race condition can occur where the - // `thread.snapshotOfLastUpdate != firstSnapshot - 1` check below evaluates to - // `false`, but `threads` then changes between that check and the - // `ext.getSectionChanges(§ionChanges, rowChanges: &rowChanges, for: notifications, with: threads)` - // line. This causes `tableView.endUpdates()` to crash with an `NSInternalInconsistencyException`. - let threads = threads! - // Create a stable state for the connection and jump to the latest commit - let notifications = dbConnection.beginLongLivedReadTransaction() - guard !notifications.isEmpty else { return } - let ext = dbConnection.ext(TSThreadDatabaseViewExtensionName) as! YapDatabaseViewConnection - let hasChanges = ( - ext.hasChanges(forGroup: TSMessageRequestGroup, in: notifications) || - ext.hasChanges(forGroup: TSInboxGroup, in: notifications) + private func handleUpdates(_ updatedViewData: [ArraySection]) { + // Ensure the first load runs without animations (if we don't do this the cells will animate + // in from a frame of CGRect.zero) + guard hasLoadedInitialData else { + hasLoadedInitialData = true + UIView.performWithoutAnimation { handleUpdates(updatedViewData) } + return + } + + // Show the empty state if there is no data + emptyStateView.isHidden = ( + !updatedViewData.isEmpty && + updatedViewData.contains(where: { !$0.elements.isEmpty }) ) - guard hasChanges else { return } - - if let firstChangeSet = notifications[0].userInfo { - let firstSnapshot = firstChangeSet[YapDatabaseSnapshotKey] as! UInt64 - - // The 'getSectionChanges' code below will crash if we try to process multiple commits at once - // so just force a full reload - if threads.snapshotOfLastUpdate != firstSnapshot - 1 { - // Check if we inserted a new message request (if so then unhide the message request banner) - if - let extensions: [String: Any] = firstChangeSet[YapDatabaseExtensionsKey] as? [String: Any], - let viewExtensions: [String: Any] = extensions[TSThreadDatabaseViewExtensionName] as? [String: Any] - { - // Note: We do a 'flatMap' here rather than explicitly grab the desired key because - // the key we need is 'changeset_key_changes' in 'YapDatabaseViewPrivate.h' so could - // change due to an update and silently break this - this approach is a bit safer - let allChanges: [Any] = Array(viewExtensions.values).compactMap { $0 as? [Any] }.flatMap { $0 } - let messageRequestInserts = allChanges - .compactMap { $0 as? YapDatabaseViewRowChange } - .filter { $0.finalGroup == TSMessageRequestGroup && $0.type == .insert } - - if !messageRequestInserts.isEmpty && CurrentAppContext().appUserDefaults()[.hasHiddenMessageRequests] { - CurrentAppContext().appUserDefaults()[.hasHiddenMessageRequests] = false - } - } - - // If there are no unread message requests then hide the message request banner - if unreadMessageRequestCount == 0 { - CurrentAppContext().appUserDefaults()[.hasHiddenMessageRequests] = true - } - - return reload() - } + // Reload the table content (animate changes after the first load) + tableView.reload( + using: StagedChangeset(source: viewModel.viewData, target: updatedViewData), + with: .automatic, + interrupt: { + print("Interrupt change check: \($0.changeCount)") + return $0.changeCount > 100 + } // Prevent too many changes from causing performance issues + ) { [weak self] updatedData in + self?.viewModel.updateData(updatedData) } - var sectionChanges = NSArray() - var rowChanges = NSArray() - ext.getSectionChanges(§ionChanges, rowChanges: &rowChanges, for: notifications, with: threads) - - // Separate out the changes for new message requests and the inbox (so we can avoid updating for - // new messages within an existing message request) - let messageRequestChanges = rowChanges - .compactMap { $0 as? YapDatabaseViewRowChange } - .filter { $0.originalGroup == TSMessageRequestGroup || $0.finalGroup == TSMessageRequestGroup } - let inboxRowChanges = rowChanges - .compactMap { $0 as? YapDatabaseViewRowChange } - .filter { $0.originalGroup == TSInboxGroup || $0.finalGroup == TSInboxGroup } - - guard sectionChanges.count > 0 || inboxRowChanges.count > 0 || messageRequestChanges.count > 0 else { return } - - tableView.beginUpdates() - - // If we need to unhide the message request row and then re-insert it - if !messageRequestChanges.isEmpty { - - // If there are no unread message requests then hide the message request banner - if unreadMessageRequestCount == 0 && tableView.numberOfRows(inSection: 0) == 1 { - CurrentAppContext().appUserDefaults()[.hasHiddenMessageRequests] = true - tableView.deleteRows(at: [IndexPath(row: 0, section: 0)], with: .automatic) - } - else { - if tableView.numberOfRows(inSection: 0) == 1 && Int(unreadMessageRequestCount) <= 0 { - tableView.deleteRows(at: [IndexPath(row: 0, section: 0)], with: .automatic) - } - else if tableView.numberOfRows(inSection: 0) == 0 && Int(unreadMessageRequestCount) > 0 && !CurrentAppContext().appUserDefaults()[.hasHiddenMessageRequests] { - tableView.insertRows(at: [IndexPath(row: 0, section: 0)], with: .automatic) - } - } - } - - inboxRowChanges.forEach { rowChange in - let key = rowChange.collectionKey.key - threadViewModelCache[key] = nil - - switch rowChange.type { - case .delete: - tableView.deleteRows(at: [ rowChange.indexPath! ], with: .automatic) - - case .insert: - tableView.insertRows(at: [ rowChange.newIndexPath! ], with: .automatic) - - case .update: - tableView.reloadRows(at: [ rowChange.indexPath! ], with: .automatic) - - case .move: - // Note: We need to handle the move from the message requests section to the inbox (since - // we are only showing a single row for message requests we need to custom handle this as - // an insert as the change won't be defined correctly) - if rowChange.originalGroup == TSMessageRequestGroup && rowChange.finalGroup == TSInboxGroup { - tableView.insertRows(at: [ rowChange.newIndexPath! ], with: .automatic) - } - else if rowChange.originalGroup == TSInboxGroup && rowChange.finalGroup == TSMessageRequestGroup { - tableView.deleteRows(at: [ rowChange.indexPath! ], with: .automatic) - } - - default: break - } - } - tableView.endUpdates() - // HACK: Moves can have conflicts with the other 3 types of change. - // Just batch perform all the moves separately to prevent crashing. - // Since all the changes are from the original state to the final state, - // it will still be correct if we pick the moves out. - tableView.beginUpdates() - rowChanges.forEach { rowChange in - let rowChange = rowChange as! YapDatabaseViewRowChange - let key = rowChange.collectionKey.key - threadViewModelCache[key] = nil - - switch rowChange.type { - case .move: - // Since we are custom handling this specific movement in the above 'updates' call we need - // to avoid trying to handle it here - if rowChange.originalGroup == TSMessageRequestGroup || rowChange.finalGroup == TSMessageRequestGroup { - return - } - - tableView.moveRow(at: rowChange.indexPath!, to: rowChange.newIndexPath!) - - default: break - } - } - tableView.endUpdates() - emptyStateView.isHidden = (threadCount != 0) } @objc private func handleProfileDidChangeNotification(_ notification: Notification) { @@ -427,13 +280,16 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv profilePictureView.update() profilePictureView.set(.width, to: profilePictureSize) profilePictureView.set(.height, to: profilePictureSize) + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(openSettings)) profilePictureView.addGestureRecognizer(tapGestureRecognizer) + // Path status indicator let pathStatusView = PathStatusView() pathStatusView.accessibilityLabel = "Current onion routing path indicator" pathStatusView.set(.width, to: PathStatusView.size) pathStatusView.set(.height, to: PathStatusView.size) + // Container view let profilePictureViewContainer = UIView() profilePictureViewContainer.accessibilityLabel = "Settings button" @@ -458,11 +314,36 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv @objc override internal func handleAppModeChangedNotification(_ notification: Notification) { super.handleAppModeChangedNotification(notification) + let gradient = Gradients.homeVCFade fadeView.setGradient(gradient) // Re-do the gradient tableView.reloadData() } + // MARK: - UITableViewDataSource + + func numberOfSections(in tableView: UITableView) -> Int { + return viewModel.viewData.count + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return viewModel.viewData[section].elements.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + switch viewModel.viewData[indexPath.section].model { + case .messageRequests: + let cell = tableView.dequeueReusableCell(withIdentifier: MessageRequestsCell.reuseIdentifier) as! MessageRequestsCell + cell.update(with: viewModel.viewData[indexPath.section].elements[indexPath.row].unreadCount) + return cell + + case .threads: + let cell = tableView.dequeueReusableCell(withIdentifier: ConversationCell.reuseIdentifier) as! ConversationCell + cell.update(with: viewModel.viewData[indexPath.section].elements[indexPath.row].threadViewModel) + return cell + } + } + // MARK: - UITableViewDelegate func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { @@ -485,10 +366,10 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv } func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? { - switch indexPath.section { - case 0: + switch viewModel.viewData[indexPath.section].model { + case .messageRequests: let hide = UITableViewRowAction(style: .destructive, title: NSLocalizedString("TXT_HIDE_TITLE", comment: "")) { [weak self] _, _ in - CurrentAppContext().appUserDefaults()[.hasHiddenMessageRequests] = true + GRDBStorage.shared.write { db in db[.hasHiddenMessageRequests] = true } // Animate the row removal self?.tableView.beginUpdates() diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift new file mode 100644 index 000000000..e67748908 --- /dev/null +++ b/Session/Home/HomeViewModel.swift @@ -0,0 +1,119 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import DifferenceKit +import SignalUtilitiesKit + +public class HomeViewModel { + public enum Section: Differentiable { + case messageRequests + case threads + } + + public struct Item: Equatable, Differentiable { + public var differenceIdentifier: String { + return (threadViewModel?.thread.id ?? "\(unreadCount)") + } + + let unreadCount: Int + let threadViewModel: ThreadViewModel? + } + + /// This value is the current state of the view + public private(set) var viewData: [ArraySection] = [] + + /// This is all the data the HomeVC needs to populate itself, please see the following link for tips to help optimise + /// performance https://github.com/groue/GRDB.swift#valueobservation-performance + public lazy var observableViewData = ValueObservation.tracking { db -> [ArraySection] in + // If message requests are hidden then don't bother fetching the unread count + let unreadMessageRequestCount: Int = (db[.hasHiddenMessageRequests] ? + 0 : + try SessionThread + .messageRequestThreads(db) + .joining( + required: SessionThread.interactions + .filter(Interaction.Columns.wasRead == false) + ) + .fetchCount(db) + ) + + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let threadViewModels = try SessionThread + .fetchAll(db) + .compactMap { thread -> ThreadViewModel? in + let lastInteraction: Interaction? = try thread + .interactions + .order(Interaction.Columns.id.desc) + .fetchOne(db) + + // Only show the 'Note to Self' thread if it has interactions + guard !thread.isNoteToSelf(db) || lastInteraction != nil else { return nil } + + let unreadMessageCount: Int = try thread + .interactions + .filter(Interaction.Columns.wasRead == false) + .fetchCount(db) + let quoteAlias: TableAlias = TableAlias() + let unreadMentionCount: Int = try thread + .interactions + .filter(Interaction.Columns.wasRead == false) + .joining( + optional: Interaction.quote + .aliased(quoteAlias)// TODO: Test that this works + ) + .filter( + Interaction.Columns.body.like("%@\(userPublicKey)") || + quoteAlias[Quote.Columns.authorId] == userPublicKey + ) + .fetchCount(db) + + return ThreadViewModel( + thread: thread, + name: thread.name(db), + unreadCount: UInt(unreadMessageCount), + unreadMentionCount: UInt(unreadMentionCount), + lastInteraction: lastInteraction, + lastInteractionDate: ( + lastInteraction.map { Date(timeIntervalSince1970: Double($0.timestampMs / 1000)) } ?? + Date(timeIntervalSince1970: thread.creationDateTimestamp) + ), + lastInteractionText: lastInteraction?.previewText(db), + lastInteractionState: try lastInteraction?.state(db) + ) + } + + return [ + ArraySection( + model: .messageRequests, + elements: [ + // If there are no unread message requests then hide the message request banner + (unreadMessageRequestCount == 0 ? + nil : + Item( + unreadCount: unreadMessageRequestCount, + threadViewModel: nil + ) + ) + ].compactMap { $0 } + ), + ArraySection( + model: .threads, + elements: threadViewModels + .sorted(by: { lhs, rhs in lhs.lastInteractionDate > rhs.lastInteractionDate }) + .map { + Item( + unreadCount: Int($0.unreadCount), + threadViewModel: $0 + ) + } + ), + ] + } + + // MARK: - Functions + + public func updateData(_ updatedData: [ArraySection]) { + self.viewData = updatedData + } +} diff --git a/Session/Meta/AppDelegate.h b/Session/Meta/AppDelegate.h deleted file mode 100644 index 76cf25ce5..000000000 --- a/Session/Meta/AppDelegate.h +++ /dev/null @@ -1,16 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import - -extern NSString *const AppDelegateStoryboardMain; - -@interface AppDelegate : UIResponder - -- (void)startPollerIfNeeded; -- (void)stopPoller; -- (void)startOpenGroupPollersIfNeeded; -- (void)stopOpenGroupPollers; - -@end diff --git a/Session/Meta/AppDelegate.m b/Session/Meta/AppDelegate.m deleted file mode 100644 index e08cb49af..000000000 --- a/Session/Meta/AppDelegate.m +++ /dev/null @@ -1,787 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "AppDelegate.h" -#import "MainAppContext.h" -#import "OWSScreenLockUI.h" -#import "Session-Swift.h" -#import "SignalApp.h" -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import - -@import Intents; - -NSString *const AppDelegateStoryboardMain = @"Main"; - -static NSString *const kInitialViewControllerIdentifier = @"UserInitialViewController"; -static NSString *const kURLSchemeSGNLKey = @"sgnl"; -static NSString *const kURLHostVerifyPrefix = @"verify"; - -static NSTimeInterval launchStartedAt; - -@interface AppDelegate () - -@property (nonatomic) BOOL hasInitialRootViewController; -@property (nonatomic) BOOL areVersionMigrationsComplete; -@property (nonatomic) BOOL didAppLaunchFail; -@property (nonatomic) LKPoller *poller; - -@end - -#pragma mark - - -@implementation AppDelegate - -@synthesize window = _window; - -#pragma mark - Dependencies - -- (OWSReadReceiptManager *)readReceiptManager -{ - return [OWSReadReceiptManager sharedManager]; -} - -- (OWSPrimaryStorage *)primaryStorage -{ - OWSAssertDebug(SSKEnvironment.shared.primaryStorage); - - return SSKEnvironment.shared.primaryStorage; -} - -- (PushRegistrationManager *)pushRegistrationManager -{ - OWSAssertDebug(AppEnvironment.shared.pushRegistrationManager); - - return AppEnvironment.shared.pushRegistrationManager; -} - -- (TSAccountManager *)tsAccountManager -{ - OWSAssertDebug(SSKEnvironment.shared.tsAccountManager); - - return SSKEnvironment.shared.tsAccountManager; -} - -- (OWSDisappearingMessagesJob *)disappearingMessagesJob -{ - OWSAssertDebug(SSKEnvironment.shared.disappearingMessagesJob); - - return SSKEnvironment.shared.disappearingMessagesJob; -} - -- (OWSWindowManager *)windowManager -{ - return Environment.shared.windowManager; -} - -- (OWSNotificationPresenter *)notificationPresenter -{ - return AppEnvironment.shared.notificationPresenter; -} - -- (OWSUserNotificationActionHandler *)userNotificationActionHandler -{ - return AppEnvironment.shared.userNotificationActionHandler; -} - -#pragma mark - Lifecycle - -- (void)applicationDidEnterBackground:(UIApplication *)application -{ - [DDLog flushLog]; - - [self stopPoller]; - [self stopClosedGroupPoller]; - [self stopOpenGroupPollers]; -} - -- (void)applicationDidReceiveMemoryWarning:(UIApplication *)application -{ - OWSLogInfo(@"applicationDidReceiveMemoryWarning"); -} - -- (void)applicationWillTerminate:(UIApplication *)application -{ - [DDLog flushLog]; - - [self stopPoller]; - [self stopClosedGroupPoller]; - [self stopOpenGroupPollers]; -} - -- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { - - // This should be the first thing we do - SetCurrentAppContext([MainAppContext new]); - - launchStartedAt = CACurrentMediaTime(); - - [LKAppModeManager configureWithDelegate:self]; - - // OWSLinkPreview is now in SessionMessagingKit, so to still be able to deserialize them we - // need to tell NSKeyedUnarchiver about the changes. - [NSKeyedUnarchiver setClass:OWSLinkPreview.class forClassName:@"SessionServiceKit.OWSLinkPreview"]; - - [Cryptography seedRandom]; - - // XXX - careful when moving this. It must happen before we initialize OWSPrimaryStorage. - [self verifyDBKeysAvailableBeforeBackgroundLaunch]; - - [AppVersion sharedInstance]; - - // Prevent the device from sleeping during database view async registration - // (e.g. long database upgrades). - // - // This block will be cleared in storageIsReady. - [DeviceSleepManager.sharedInstance addBlockWithBlockObject:self]; - - [AppSetup - setupEnvironmentWithAppSpecificSingletonBlock:^{ - // Create AppEnvironment - [AppEnvironment.shared setup]; - [SignalApp.sharedApp setup]; - } - migrationCompletion:^(BOOL successful, BOOL needsConfigSync){ - OWSAssertIsOnMainThread(); - - [self versionMigrationsDidCompleteNeedingConfigSync:needsConfigSync]; - }]; - - [SNConfiguration performMainSetup]; - - [SNAppearance switchToSessionAppearance]; - - if (CurrentAppContext().isRunningTests) { - return YES; - } - - UIWindow *mainWindow = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]]; - self.window = mainWindow; - CurrentAppContext().mainWindow = mainWindow; - // Show LoadingViewController until the async database view registrations are complete. - mainWindow.rootViewController = [LoadingViewController new]; - [mainWindow makeKeyAndVisible]; - - LKAppMode appMode = [LKAppModeManager getAppModeOrSystemDefault]; - [self adaptAppMode:appMode]; - - if (@available(iOS 11, *)) { - // This must happen in appDidFinishLaunching or earlier to ensure we don't - // miss notifications. - // Setting the delegate also seems to prevent us from getting the legacy notification - // notification callbacks upon launch e.g. 'didReceiveLocalNotification' - UNUserNotificationCenter.currentNotificationCenter.delegate = self; - } - - [OWSScreenLockUI.sharedManager setupWithRootWindow:self.window]; - [[OWSWindowManager sharedManager] setupWithRootWindow:self.window - screenBlockingWindow:OWSScreenLockUI.sharedManager.screenBlockingWindow]; - [OWSScreenLockUI.sharedManager startObserving]; - - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(storageIsReady) - name:StorageIsReadyNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(registrationStateDidChange) - name:RegistrationStateDidChangeNotification - object:nil]; - - // Loki - Observe data nuke request notifications - [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(handleDataNukeRequested:) name:NSNotification.dataNukeRequested object:nil]; - - OWSLogInfo(@"application: didFinishLaunchingWithOptions completed."); - - return YES; -} - -- (void)applicationDidBecomeActive:(UIApplication *)application { - OWSAssertIsOnMainThread(); - - if (self.didAppLaunchFail) { - OWSFailDebug(@"App launch failed"); - return; - } - - if (CurrentAppContext().isRunningTests) { - return; - } - - NSUserDefaults *sharedUserDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.com.loki-project.loki-messenger"]; - [sharedUserDefaults setBool:YES forKey:@"isMainAppActive"]; - [sharedUserDefaults synchronize]; - - [self ensureRootViewController]; - - LKAppMode appMode = [LKAppModeManager getAppModeOrSystemDefault]; - [self adaptAppMode:appMode]; - - [AppReadiness runNowOrWhenAppDidBecomeReady:^{ - [self handleActivation]; - }]; - - // Clear all notifications whenever we become active. - // When opening the app from a notification, - // AppDelegate.didReceiveLocalNotification will always - // be called _before_ we become active. - [self clearAllNotificationsAndRestoreBadgeCount]; - - // On every activation, clear old temp directories. - ClearOldTemporaryDirectories(); -} - -- (void)applicationWillResignActive:(UIApplication *)application -{ - OWSAssertIsOnMainThread(); - - if (self.didAppLaunchFail) { - OWSFailDebug(@"App launch failed"); - return; - } - - [self clearAllNotificationsAndRestoreBadgeCount]; - - NSUserDefaults *sharedUserDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.com.loki-project.loki-messenger"]; - [sharedUserDefaults setBool:NO forKey:@"isMainAppActive"]; - [sharedUserDefaults synchronize]; - - [DDLog flushLog]; -} - -#pragma mark - Orientation - -- (UIInterfaceOrientationMask)application:(UIApplication *)application supportedInterfaceOrientationsForWindow:(nullable UIWindow *)window -{ - return UIInterfaceOrientationMaskPortrait; -} - -#pragma mark - Background Fetching - -- (void)application:(UIApplication *)application performFetchWithCompletionHandler:(void (^)(UIBackgroundFetchResult result))completionHandler -{ - [AppReadiness runNowOrWhenAppDidBecomeReady:^{ - [LKBackgroundPoller pollWithCompletionHandler:completionHandler]; - }]; -} - -#pragma mark - App Readiness - -/** - * The user must unlock the device once after reboot before the database encryption key can be accessed. - */ -- (void)verifyDBKeysAvailableBeforeBackgroundLaunch -{ - if ([UIApplication sharedApplication].applicationState != UIApplicationStateBackground) { return; } - - if (!OWSPrimaryStorage.isDatabasePasswordAccessible) { - OWSLogInfo(@"Exiting because we are in the background and the database password is not accessible."); - - UILocalNotification *notification = [UILocalNotification new]; - NSString *messageFormat = NSLocalizedString(@"NOTIFICATION_BODY_PHONE_LOCKED_FORMAT", - @"Lock screen notification text presented after user powers on their device without unlocking. Embeds " - @"{{device model}} (either 'iPad' or 'iPhone')"); - notification.alertBody = [NSString stringWithFormat:messageFormat, UIDevice.currentDevice.localizedModel]; - - // Make sure we clear any existing notifications so that they don't start stacking up - // if the user receives multiple pushes. - [UIApplication.sharedApplication cancelAllLocalNotifications]; - [UIApplication.sharedApplication setApplicationIconBadgeNumber:0]; - - [UIApplication.sharedApplication scheduleLocalNotification:notification]; - [UIApplication.sharedApplication setApplicationIconBadgeNumber:1]; - - [DDLog flushLog]; - exit(0); - } -} - -- (void)enableBackgroundRefreshIfNecessary -{ - [AppReadiness runNowOrWhenAppDidBecomeReady:^{ - [UIApplication.sharedApplication setMinimumBackgroundFetchInterval:UIApplicationBackgroundFetchIntervalMinimum]; - }]; -} - -- (void)handleActivation -{ - OWSAssertIsOnMainThread(); - - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - if ([self.tsAccountManager isRegistered]) { - // At this point, potentially lengthy DB locking migrations could be running. - // Avoid blocking app launch by putting all further possible DB access in async block - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - OWSLogInfo(@"Running post launch block for registered user: %@.", [self.tsAccountManager localNumber]); - - // Clean up any messages that expired since last launch immediately - // and continue cleaning in the background. - [self.disappearingMessagesJob startIfNecessary]; - - [self enableBackgroundRefreshIfNecessary]; - - // Mark all "attempting out" messages as "unsent", i.e. any messages that were not successfully - // sent before the app exited should be marked as failures. - [[[OWSFailedMessagesJob alloc] initWithPrimaryStorage:self.primaryStorage] run]; - [[[OWSFailedAttachmentDownloadsJob alloc] initWithPrimaryStorage:self.primaryStorage] run]; - }); - } - }); // end dispatchOnce for first time we become active - - // Every time we become active... - if ([self.tsAccountManager isRegistered]) { - // At this point, potentially lengthy DB locking migrations could be running. - // Avoid blocking app launch by putting all further possible DB access in async block - dispatch_async(dispatch_get_main_queue(), ^{ - NSString *userPublicKey = self.tsAccountManager.localNumber; - - // Update profile picture if needed - NSUserDefaults *userDefaults = NSUserDefaults.standardUserDefaults; - NSDate *now = [NSDate new]; - NSDate *lastProfilePictureUpload = (NSDate *)[userDefaults objectForKey:@"lastProfilePictureUpload"]; - if (lastProfilePictureUpload != nil && [now timeIntervalSinceDate:lastProfilePictureUpload] > 14 * 24 * 60 * 60) { - // The user defaults flag is updated in ProfileManager - NSString *name = [SMKProfile fetchCurrentUserName]; - UIImage *profilePicture = [SMKProfileManager profileAvatarWithRecipientId:userPublicKey]; - [SMKProfileManager updateLocalWithProfileName:name avatarImage:profilePicture requiresSync:YES]; - } - - if (CurrentAppContext().isMainApp) { - [SNOpenGroupAPIV2 getDefaultRoomsIfNeeded]; - } - - [[SNSnodeAPI getSnodePool] retainUntilComplete]; - - [self startPollerIfNeeded]; - [self startClosedGroupPoller]; - [self startOpenGroupPollersIfNeeded]; - - if (![UIApplication sharedApplication].isRegisteredForRemoteNotifications) { - OWSLogInfo(@"Retrying remote notification registration since user hasn't registered yet."); - // Push tokens don't normally change while the app is launched, so checking once during launch is - // usually sufficient, but e.g. on iOS11, users who have disabled "Allow Notifications" and disabled - // "Background App Refresh" will not be able to obtain an APN token. Enabling those settings does not - // restart the app, so we check every activation for users who haven't yet registered. - __unused AnyPromise *promise = - [OWSSyncPushTokensJob runWithAccountManager:AppEnvironment.shared.accountManager - preferences:Environment.shared.preferences]; - } - - if (CurrentAppContext().isMainApp) { - [SNJobQueue.shared resumePendingJobs]; - [self syncConfigurationIfNeeded]; - } - }); - } -} - -- (void)versionMigrationsDidCompleteNeedingConfigSync:(BOOL)needsConfigSync -{ - OWSAssertIsOnMainThread(); - - self.areVersionMigrationsComplete = YES; - - // If we need a config sync then trigger it now - if (needsConfigSync) { - [SNMessageSender forceSyncConfigurationNow]; - } - - [self checkIfAppIsReady]; -} - -- (void)storageIsReady -{ - OWSAssertIsOnMainThread(); - - [self checkIfAppIsReady]; -} - -- (void)checkIfAppIsReady -{ - OWSAssertIsOnMainThread(); - - // App isn't ready until storage is ready AND all version migrations are complete - if (!self.areVersionMigrationsComplete) { - return; - } - if (![OWSStorage isStorageReady]) { - return; - } - if ([AppReadiness isAppReady]) { - // Only mark the app as ready once - return; - } - - [SNConfiguration performMainSetup]; - - // Note that this does much more than set a flag; - // it will also run all deferred blocks. - [AppReadiness setAppIsReady]; - - if (CurrentAppContext().isRunningTests) { return; } - - if ([self.tsAccountManager isRegistered]) { - - // This should happen at any launch, background or foreground - __unused AnyPromise *pushTokenpromise = - [OWSSyncPushTokensJob runWithAccountManager:AppEnvironment.shared.accountManager - preferences:Environment.shared.preferences]; - } - - [DeviceSleepManager.sharedInstance removeBlockWithBlockObject:self]; - - [AppVersion.sharedInstance mainAppLaunchDidComplete]; - - [Environment.shared.audioSession setup]; - - [SSKEnvironment.shared.reachabilityManager setup]; - - if (!Environment.shared.preferences.hasGeneratedThumbnails) { - [self.primaryStorage.newDatabaseConnection - asyncReadWithBlock:^(YapDatabaseReadTransaction *_Nonnull transaction) { - [TSAttachmentStream enumerateCollectionObjectsUsingBlock:^(id _Nonnull obj, BOOL *_Nonnull stop){ - // no-op. It's sufficient to initWithCoder: each object. - }]; - } - completionBlock:^{ - [Environment.shared.preferences setHasGeneratedThumbnails:YES]; - }]; - } - - [self.readReceiptManager prepareCachedValues]; - - // Disable the SAE until the main app has successfully completed launch process - // at least once in the post-SAE world. - [OWSPreferences setIsReadyForAppExtensions]; - - [self ensureRootViewController]; - - [self preheatDatabaseViews]; - - [self.primaryStorage touchDbAsync]; - - // Every time the user upgrades to a new version: - // - // * Update account attributes. - // * Sync configuration. - if ([self.tsAccountManager isRegistered]) { - AppVersion *appVersion = AppVersion.sharedInstance; - if (appVersion.lastAppVersion.length > 0 - && ![appVersion.lastAppVersion isEqualToString:appVersion.currentAppVersion]) { - [[self.tsAccountManager updateAccountAttributes] retainUntilComplete]; - } - } -} - -- (void)preheatDatabaseViews -{ - [self.primaryStorage.uiDatabaseConnection asyncReadWithBlock:^(YapDatabaseReadTransaction *transaction) { - for (NSString *viewName in @[ - TSThreadDatabaseViewExtensionName, - TSMessageDatabaseViewExtensionName, - TSThreadOutgoingMessageDatabaseViewExtensionName, - TSUnreadDatabaseViewExtensionName, - TSUnseenDatabaseViewExtensionName, - ]) { - YapDatabaseViewTransaction *databaseView = [transaction ext:viewName]; - OWSAssertDebug([databaseView isKindOfClass:[YapDatabaseViewTransaction class]]); - } - }]; -} - -- (void)registrationStateDidChange -{ - OWSAssertIsOnMainThread(); - - [self enableBackgroundRefreshIfNecessary]; - - if ([self.tsAccountManager isRegistered]) { - // Start running the disappearing messages job in case the newly registered user - // enables this feature - [self.disappearingMessagesJob startIfNecessary]; - - [self startPollerIfNeeded]; - [self startClosedGroupPoller]; - [self startOpenGroupPollersIfNeeded]; - } -} - -- (void)registrationLockDidChange:(NSNotification *)notification -{ - [self enableBackgroundRefreshIfNecessary]; -} - -- (void)ensureRootViewController -{ - OWSAssertIsOnMainThread(); - - if (!AppReadiness.isAppReady || self.hasInitialRootViewController) { return; } - self.hasInitialRootViewController = YES; - - UIViewController *rootViewController; - BOOL navigationBarHidden = NO; - if ([self.tsAccountManager isRegistered]) { - rootViewController = [HomeVC new]; - } else { - rootViewController = [LandingVC new]; - navigationBarHidden = NO; - } - OWSAssertDebug(rootViewController); - OWSNavigationController *navigationController = - [[OWSNavigationController alloc] initWithRootViewController:rootViewController]; - navigationController.navigationBarHidden = navigationBarHidden; - self.window.rootViewController = navigationController; - - [UIViewController attemptRotationToDeviceOrientation]; -} - -#pragma mark - Notifications - -- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken -{ - OWSAssertIsOnMainThread(); - - if (self.didAppLaunchFail) { - OWSFailDebug(@"App launch failed"); - return; - } - - [self.pushRegistrationManager didReceiveVanillaPushToken:deviceToken]; - - OWSLogInfo(@"Registering for push notifications with token: %@.", deviceToken); -} - -- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error -{ - OWSAssertIsOnMainThread(); - - if (self.didAppLaunchFail) { - OWSFailDebug(@"App launch failed"); - return; - } - - OWSLogError(@"Failed to register push token with error: %@.", error); -#ifdef DEBUG - OWSLogWarn(@"We're in debug mode. Faking success for remote registration with a fake push identifier."); - [self.pushRegistrationManager didReceiveVanillaPushToken:[[NSMutableData dataWithLength:32] copy]]; -#else - [self.pushRegistrationManager didFailToReceiveVanillaPushTokenWithError:error]; -#endif -} - -- (void)clearAllNotificationsAndRestoreBadgeCount -{ - OWSAssertIsOnMainThread(); - - [AppReadiness runNowOrWhenAppDidBecomeReady:^{ - [AppEnvironment.shared.notificationPresenter clearAllNotifications]; - [OWSMessageUtils.sharedManager updateApplicationBadgeCount]; - }]; -} - -- (void)application:(UIApplication *)application performActionForShortcutItem:(UIApplicationShortcutItem *)shortcutItem completionHandler:(void (^)(BOOL succeeded))completionHandler -{ - OWSAssertIsOnMainThread(); - - if (self.didAppLaunchFail) { - OWSFailDebug(@"App launch failed"); - completionHandler(NO); - return; - } - - [AppReadiness runNowOrWhenAppDidBecomeReady:^{ - if (![self.tsAccountManager isRegisteredAndReady]) { return; } - [SignalApp.sharedApp.homeViewController createNewDM]; - completionHandler(YES); - }]; -} - -// The method will be called on the delegate only if the application is in the foreground. If the method is not -// implemented or the handler is not called in a timely manner then the notification will not be presented. The -// application can choose to have the notification presented as a sound, badge, alert and/or in the notification list. -// This decision should be based on whether the information in the notification is otherwise visible to the user. -- (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler - __IOS_AVAILABLE(10.0)__TVOS_AVAILABLE(10.0)__WATCHOS_AVAILABLE(3.0)__OSX_AVAILABLE(10.14) -{ - if (notification.request.content.userInfo[@"remote"]) { - OWSLogInfo(@"[Loki] Ignoring remote notifications while the app is in the foreground."); - return; - } - [AppReadiness runNowOrWhenAppDidBecomeReady:^() { - // We need to respect the in-app notification sound preference. This method, which is called - // for modern UNUserNotification users, could be a place to do that, but since we'd still - // need to handle this behavior for legacy UINotification users anyway, we "allow" all - // notification options here, and rely on the shared logic in NotificationPresenter to - // honor notification sound preferences for both modern and legacy users. - UNNotificationPresentationOptions options = UNNotificationPresentationOptionAlert | UNNotificationPresentationOptionBadge | UNNotificationPresentationOptionSound; - completionHandler(options); - }]; -} - -// The method will be called on the delegate when the user responded to the notification by opening the application, -// dismissing the notification or choosing a UNNotificationAction. The delegate must be set before the application -// returns from application:didFinishLaunchingWithOptions:. -- (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void (^)(void))completionHandler __IOS_AVAILABLE(10.0)__WATCHOS_AVAILABLE(3.0) - __OSX_AVAILABLE(10.14)__TVOS_PROHIBITED -{ - [AppReadiness runNowOrWhenAppDidBecomeReady:^() { - [self.userNotificationActionHandler handleNotificationResponse:response completionHandler:completionHandler]; - }]; -} - -// The method will be called on the delegate when the application is launched in response to the user's request to view -// in-app notification settings. Add UNAuthorizationOptionProvidesAppNotificationSettings as an option in -// requestAuthorizationWithOptions:completionHandler: to add a button to inline notification settings view and the -// notification settings view in Settings. The notification will be nil when opened from Settings. -- (void)userNotificationCenter:(UNUserNotificationCenter *)center openSettingsForNotification:(nullable UNNotification *)notification __IOS_AVAILABLE(12.0) - __OSX_AVAILABLE(10.14)__WATCHOS_PROHIBITED __TVOS_PROHIBITED -{ - -} - -#pragma mark - Polling - -- (void)startPollerIfNeeded -{ - if (self.poller == nil) { - NSString *userPublicKey = [SNGeneralUtilities getUserPublicKey]; - if (userPublicKey != nil) { - self.poller = [[LKPoller alloc] init]; - } - } - [self.poller startIfNeeded]; -} - -- (void)stopPoller { [self.poller stop]; } - -- (void)startOpenGroupPollersIfNeeded -{ - [SNOpenGroupManagerV2.shared startPolling]; -} - -- (void)stopOpenGroupPollers { - [SNOpenGroupManagerV2.shared stopPolling]; -} - -# pragma mark - App Mode - -- (void)adaptAppMode:(LKAppMode)appMode -{ - UIWindow *window = UIApplication.sharedApplication.keyWindow; - if (window == nil) { return; } - switch (appMode) { - case LKAppModeLight: { - if (@available(iOS 13.0, *)) { - window.overrideUserInterfaceStyle = UIUserInterfaceStyleLight; - } - window.backgroundColor = UIColor.whiteColor; - break; - } - case LKAppModeDark: { - if (@available(iOS 13.0, *)) { - window.overrideUserInterfaceStyle = UIUserInterfaceStyleDark; - } - window.backgroundColor = UIColor.blackColor; - break; - } - } - if (LKAppModeUtilities.isSystemDefault) { - if (@available(iOS 13.0, *)) { - window.overrideUserInterfaceStyle = UIUserInterfaceStyleUnspecified; - } - } - [NSNotificationCenter.defaultCenter postNotificationName:NSNotification.appModeChanged object:nil]; -} - -- (void)setCurrentAppMode:(LKAppMode)appMode -{ - [NSUserDefaults.standardUserDefaults setInteger:appMode forKey:@"appMode"]; - [self adaptAppMode:appMode]; -} - -- (void)setAppModeToSystemDefault -{ - [NSUserDefaults.standardUserDefaults removeObjectForKey:@"appMode"]; - LKAppMode appMode = [LKAppModeManager getAppModeOrSystemDefault]; - [self adaptAppMode:appMode]; -} - -# pragma mark - Other - -- (void)handleDataNukeRequested:(NSNotification *)notification -{ - NSUserDefaults *userDefaults = NSUserDefaults.standardUserDefaults; - BOOL isUsingFullAPNs = [userDefaults boolForKey:@"isUsingFullAPNs"]; - NSString *hexEncodedDeviceToken = [userDefaults stringForKey:@"deviceToken"]; - if (isUsingFullAPNs && hexEncodedDeviceToken != nil) { - NSData *deviceToken = [NSData dataFromHexString:hexEncodedDeviceToken]; - [[LKPushNotificationAPI unregisterToken:deviceToken] retainUntilComplete]; - } - [ThreadUtil deleteAllContent]; - [SUKIdentity clearUserKeyPair]; - [SNSnodeAPI clearSnodePool]; - [self stopPoller]; - [self stopClosedGroupPoller]; - [self stopOpenGroupPollers]; - BOOL wasUnlinked = [NSUserDefaults.standardUserDefaults boolForKey:@"wasUnlinked"]; - [SignalApp resetAppData:^{ - // Resetting the data clears the old user defaults. We need to restore the unlink default. - [NSUserDefaults.standardUserDefaults setBool:wasUnlinked forKey:@"wasUnlinked"]; - }]; -} - -# pragma mark - App Link - -- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary *)options -{ - NSURLComponents *components = [[NSURLComponents alloc] initWithURL:url resolvingAgainstBaseURL:true]; - - // URL Scheme is sessionmessenger://DM?sessionID=1234 - // We can later add more parameters like message etc. - NSString *intent = components.host; - if (intent != nil && [intent isEqualToString:@"DM"]) { - NSArray *params = [components queryItems]; - NSPredicate *sessionIDPredicate = [NSPredicate predicateWithFormat:@"name == %@", @"sessionID"]; - NSArray *matches = [params filteredArrayUsingPredicate:sessionIDPredicate]; - if (matches.count > 0) { - NSString *sessionID = matches.firstObject.value; - [self createNewDMFromDeepLink:sessionID]; - return YES; - } - } - return NO; -} - -- (void)createNewDMFromDeepLink:(NSString *)sessionID -{ - UIViewController *viewController = self.window.rootViewController; - if ([viewController class] == [OWSNavigationController class]) { - UIViewController *visibleVC = ((OWSNavigationController *)viewController).visibleViewController; - if ([visibleVC isKindOfClass:HomeVC.class]) { - HomeVC *homeVC = (HomeVC *)visibleVC; - [homeVC createNewDMFromDeepLink:sessionID]; - } - } -} - -@end diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 45fb800df..dcbc72f48 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -4,17 +4,472 @@ import Foundation import PromiseKit import SessionMessagingKit import SessionUtilitiesKit +import SessionUIKit +import UserNotifications +import UIKit +import SignalUtilitiesKit -extension AppDelegate { +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate, AppModeManagerDelegate { + var window: UIWindow? + var backgroundSnapshotBlockerWindow: UIWindow? + var appStartupWindow: UIWindow? + var poller: Poller = Poller() + + // MARK: - Lifecycle - @objc(syncConfigurationIfNeeded) + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // These should be the first things we do (the startup process can fail without them) + SetCurrentAppContext(MainAppContext()) + verifyDBKeysAvailableBeforeBackgroundLaunch() + + AppModeManager.configure(delegate: self) + + // OWSLinkPreview is now in SessionMessagingKit, so to still be able to deserialize them we + // need to tell NSKeyedUnarchiver about the changes. + // FIXME: Remove this once YapDatabase gets removed + NSKeyedUnarchiver.setClass(OWSLinkPreview.self, forClassName: "SessionServiceKit.OWSLinkPreview") + + Cryptography.seedRandom() + + AppVersion.sharedInstance() // TODO: ??? + + // Prevent the device from sleeping during database view async registration + // (e.g. long database upgrades). + // + // This block will be cleared in storageIsReady. + DeviceSleepManager.sharedInstance.addBlock(blockObject: self) + + AppSetup.setupEnvironment( + appSpecificSingletonBlock: { + // Create AppEnvironment + AppEnvironment.shared.setup() + SignalApp.shared().setup() + }, + migrationCompletion: { [weak self] successful, needsConfigSync in + guard let strongSelf = self else { return } + + JobRunner.add(executor: SyncPushTokensJob.self, for: .syncPushTokens) + + // Trigger any launch-specific jobs and start the JobRunner + JobRunner.appDidFinishLaunching() + + // Note that this does much more than set a flag; + // it will also run all deferred blocks (including the JobRunner + // 'appDidBecomeActive' method) + AppReadiness.setAppIsReady() + + DeviceSleepManager.sharedInstance.removeBlock(blockObject: strongSelf) + AppVersion.sharedInstance().mainAppLaunchDidComplete() + Environment.shared.audioSession.setup() + SSKEnvironment.shared.reachabilityManager.setup() + + if !Environment.shared.preferences.hasGeneratedThumbnails() { + + // Disable the SAE until the main app has successfully completed launch process + // at least once in the post-SAE world. + OWSPreferences.setIsReadyForAppExtensions() + + // Setup the UI + self?.ensureRootViewController() + + + // Every time the user upgrades to a new version: + // + // * Update account attributes. + // * Sync configuration. + if Identity.userExists() { + // TODO: This +// AppVersion *appVersion = AppVersion.sharedInstance; +// if (appVersion.lastAppVersion.length > 0 +// && ![appVersion.lastAppVersion isEqualToString:appVersion.currentAppVersion]) { +// [[self.tsAccountManager updateAccountAttributes] retainUntilComplete]; +// } + } + + // If we need a config sync then trigger it now + if (needsConfigSync) { + GRDBStorage.shared.write { db in + try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() + } + } + } + ) + + Configuration.performMainSetup() + SNAppearance.switchToSessionAppearance() + + // No point continuing if we are running tests + guard !CurrentAppContext().isRunningTests else { return true } + + let mainWindow: UIWindow = UIWindow(frame: UIScreen.main.bounds) + self.window = mainWindow + CurrentAppContext().mainWindow = mainWindow + + // Show LoadingViewController until the async database view registrations are complete. + mainWindow.rootViewController = LoadingViewController() + mainWindow.makeKeyAndVisible() + + adapt(appMode: AppModeManager.getAppModeOrSystemDefault()) + + // This must happen in appDidFinishLaunching or earlier to ensure we don't + // miss notifications. + // Setting the delegate also seems to prevent us from getting the legacy notification + // notification callbacks upon launch e.g. 'didReceiveLocalNotification' + UNUserNotificationCenter.current().delegate = self + + OWSScreenLockUI.sharedManager().setup(withRootWindow: mainWindow) + OWSWindowManager.shared().setup( + withRootWindow: mainWindow, + screenBlockingWindow: OWSScreenLockUI.sharedManager().screenBlockingWindow + ) + OWSScreenLockUI.sharedManager().startObserving() + + NotificationCenter.default.addObserver( + self, + selector: #selector(registrationStateDidChange), + name: .registrationStateDidChange, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleDataNukeRequested), // TODO: This differently??? + name: .dataNukeRequested, + object: nil + ) + + Logger.info("application: didFinishLaunchingWithOptions completed.") + + return true + } + + func applicationDidEnterBackground(_ application: UIApplication) { + DDLog.flushLog() + + stopPollers() + } + + func applicationDidReceiveMemoryWarning(_ application: UIApplication) { + Logger.info("applicationDidReceiveMemoryWarning") + } + + func applicationWillTerminate(_ application: UIApplication) { + DDLog.flushLog() + + stopPollers() + } + + func applicationDidBecomeActive(_ application: UIApplication) { + guard !CurrentAppContext().isRunningTests else { return } + + // FIXME: We should move this somewhere to prevent typos from breaking it + let sharedUserDefaults: UserDefaults? = UserDefaults(suiteName: "group.com.loki-project.loki-messenger") + sharedUserDefaults?[.isMainAppActive] = true + + ensureRootViewController() + adapt(appMode: AppModeManager.getAppModeOrSystemDefault()) + + AppReadiness.runNowOrWhenAppDidBecomeReady { [weak self] in + self?.handleActivation() + } + + // Clear all notifications whenever we become active. + // When opening the app from a notification, + // AppDelegate.didReceiveLocalNotification will always + // be called _before_ we become active. + clearAllNotificationsAndRestoreBadgeCount() + + // On every activation, clear old temp directories. + ClearOldTemporaryDirectories(); + } + + func applicationWillResignActive(_ application: UIApplication) { + clearAllNotificationsAndRestoreBadgeCount() + + let sharedUserDefaults: UserDefaults? = UserDefaults(suiteName: "group.com.loki-project.loki-messenger") + sharedUserDefaults?[.isMainAppActive] = false + + DDLog.flushLog() + } + + // MARK: - Orientation + + func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { + return .portrait + } + + // MARK: - Background Fetching + + func application(_ application: UIApplication, performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + AppReadiness.runNowOrWhenAppDidBecomeReady { + BackgroundPoller.poll(completionHandler: completionHandler) + } + } + + // MARK: - App Readiness + + /// The user must unlock the device once after reboot before the database encryption key can be accessed. + private func verifyDBKeysAvailableBeforeBackgroundLaunch() { + guard UIApplication.shared.applicationState == .background else { return } + + let migrationHasRun: Bool = false + + let databasePasswordAccessible: Bool = ( + (migrationHasRun && GRDBStorage.isDatabasePasswordAccessible) || // GRDB password access + OWSStorage.isDatabasePasswordAccessible() // YapDatabase password access + ) + + guard !databasePasswordAccessible else { return } // All good + + Logger.info("Exiting because we are in the background and the database password is not accessible.") + + let notificationContent: UNMutableNotificationContent = UNMutableNotificationContent() + notificationContent.body = String( + format: NSLocalizedString("NOTIFICATION_BODY_PHONE_LOCKED_FORMAT", comment: ""), + UIDevice.current.localizedModel + ) + let notificationRequest: UNNotificationRequest = UNNotificationRequest( + identifier: UUID().uuidString, + content: notificationContent, + trigger: nil + ) + + // Make sure we clear any existing notifications so that they don't start stacking up + // if the user receives multiple pushes. + UNUserNotificationCenter.current().removeAllPendingNotificationRequests() + UIApplication.shared.applicationIconBadgeNumber = 0 + + UNUserNotificationCenter.current().add(notificationRequest, withCompletionHandler: nil) + UIApplication.shared.applicationIconBadgeNumber = 1 + + DDLog.flushLog() + exit(0) + } + + private func enableBackgroundRefreshIfNecessary() { + AppReadiness.runNowOrWhenAppDidBecomeReady { + UIApplication.shared.setMinimumBackgroundFetchInterval(UIApplication.backgroundFetchIntervalMinimum) + } + } + + private func handleActivation() { + guard Identity.userExists() else { return } + + enableBackgroundRefreshIfNecessary() + JobRunner.appDidBecomeActive() + + SnodeAPI.getSnodePool().retainUntilComplete() + startPollersIfNeeded() + + if CurrentAppContext().isMainApp { + syncConfigurationIfNeeded() + } + } + + private func ensureRootViewController() { + // TODO: Add 'MigrationProcessingViewController' in here as well + guard self.window?.rootViewController is LoadingViewController else { return } + + let navController: UINavigationController = OWSNavigationController( + rootViewController: (Identity.userExists() ? + HomeVC() : + LandingVC() + ) + ) + navController.isNavigationBarHidden = !(navController.viewControllers.first is HomeVC) + self.window?.rootViewController = navController + UIViewController.attemptRotationToDeviceOrientation() + } + + // MARK: - Notifications + + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + PushRegistrationManager.shared.didReceiveVanillaPushToken(deviceToken) + Logger.info("Registering for push notifications with token: \(deviceToken).") + } + + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + Logger.error("Failed to register push token with error: \(error).") + + #if DEBUG + Logger.warn("We're in debug mode. Faking success for remote registration with a fake push identifier.") + PushRegistrationManager.shared.didReceiveVanillaPushToken(Data(count: 32)) + #else + PushRegistrationManager.shared.didFailToReceiveVanillaPushToken(error: error) + #endif + } + + private func clearAllNotificationsAndRestoreBadgeCount() { + AppReadiness.runNowOrWhenAppDidBecomeReady { + AppEnvironment.shared.notificationPresenter.clearAllNotifications() + OWSMessageUtils.sharedManager().updateApplicationBadgeCount() + } + } + + func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) { + AppReadiness.runNowOrWhenAppDidBecomeReady { + guard Identity.userExists() else { return } + + SignalApp.shared().homeViewController?.createNewDM() + completionHandler(true) + } + } + + /// The method will be called on the delegate only if the application is in the foreground. If the method is not implemented or the + /// handler is not called in a timely manner then the notification will not be presented. The application can choose to have the + /// notification presented as a sound, badge, alert and/or in the notification list. + /// + /// This decision should be based on whether the information in the notification is otherwise visible to the user. + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + if notification.request.content.userInfo["remote"] != nil { + Logger.info("[Loki] Ignoring remote notifications while the app is in the foreground.") + return + } + + AppReadiness.runNowOrWhenAppDidBecomeReady { + // We need to respect the in-app notification sound preference. This method, which is called + // for modern UNUserNotification users, could be a place to do that, but since we'd still + // need to handle this behavior for legacy UINotification users anyway, we "allow" all + // notification options here, and rely on the shared logic in NotificationPresenter to + // honor notification sound preferences for both modern and legacy users. + completionHandler([.alert, .badge, .sound]) + } + } + + /// The method will be called on the delegate when the user responded to the notification by opening the application, dismissing + /// the notification or choosing a UNNotificationAction. The delegate must be set before the application returns from + /// application:didFinishLaunchingWithOptions:. + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + AppReadiness.runNowOrWhenAppDidBecomeReady { + AppEnvironment.shared.userNotificationActionHandler.handleNotificationResponse(response, completionHandler: completionHandler) + } + } + + /// The method will be called on the delegate when the application is launched in response to the user's request to view in-app + /// notification settings. Add UNAuthorizationOptionProvidesAppNotificationSettings as an option in + /// requestAuthorizationWithOptions:completionHandler: to add a button to inline notification settings view and the notification + /// settings view in Settings. The notification will be nil when opened from Settings. + func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) { + } + + // MARK: - Notification Handling + + @objc private func registrationStateDidChange() { + enableBackgroundRefreshIfNecessary() + + guard Identity.userExists() else { return } + + startPollersIfNeeded() + } + + @objc public func handleDataNukeRequested() { + let isUsingFullAPNs: Bool = UserDefaults.standard[.isUsingFullAPNs] + let maybeDeviceToken: String? = UserDefaults.standard[.deviceToken] + // TODO: Clean up how this works + if isUsingFullAPNs, let deviceToken: String = maybeDeviceToken { + let data: Data = Data(hex: deviceToken) + PushNotificationAPI.unregister(data).retainUntilComplete() + } + + ThreadUtil.deleteAllContent() + Identity.clearAll() + SnodeAPI.clearSnodePool() + stopPollers() + + let wasUnlinked: Bool = UserDefaults.standard[.wasUnlinked] + SignalApp.resetAppData { + // Resetting the data clears the old user defaults. We need to restore the unlink default. + UserDefaults.standard[.wasUnlinked] = wasUnlinked + } + } + + // MARK: - Polling + + public func startPollersIfNeeded() { + guard Identity.userExists() else { return } + + poller.startIfNeeded() + ClosedGroupPoller.shared.start() + OpenGroupManagerV2.shared.startPolling() + } + + public func stopPollers() { + poller.stop() + ClosedGroupPoller.shared.stop() + OpenGroupManagerV2.shared.stopPolling() + } + + // MARK: - App Mode + + private func adapt(appMode: AppMode) { + guard let window: UIWindow = UIApplication.shared.keyWindow else { return } + + switch (appMode) { + case .light: + window.overrideUserInterfaceStyle = .light + window.backgroundColor = .white + + case .dark: + window.overrideUserInterfaceStyle = .dark + window.backgroundColor = .black + } + + if LKAppModeUtilities.isSystemDefault { + window.overrideUserInterfaceStyle = .unspecified + } + + NotificationCenter.default.post(name: .appModeChanged, object: nil) + } + + func setCurrentAppMode(to appMode: AppMode) { + UserDefaults.standard[.appMode] = appMode.rawValue + adapt(appMode: appMode) + } + + func setAppModeToSystemDefault() { + UserDefaults.standard.removeObject(forKey: SNUserDefaults.Int.appMode.rawValue) + adapt(appMode: AppModeManager.getAppModeOrSystemDefault()) + } + + // MARK: - App Link + + func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { + guard let components: URLComponents = URLComponents(url: url, resolvingAgainstBaseURL: true) else { + return false + } + + // URL Scheme is sessionmessenger://DM?sessionID=1234 + // We can later add more parameters like message etc. + if components.host == "DM" { + let matches: [URLQueryItem] = (components.queryItems ?? []) + .filter { item in item.name == "sessionID" } + + if let sessionId: String = matches.first?.value { + createNewDMFromDeepLink(sessionId: sessionId) + return true + } + } + + return false + } + + private func createNewDMFromDeepLink(sessionId: String) { + guard let homeViewController: HomeVC = (window?.rootViewController as? OWSNavigationController)?.visibleViewController as? HomeVC else { + return + } + + homeViewController.createNewDMFromDeepLink(sessionID: sessionId) + } + + // MARK: - Config Sync + func syncConfigurationIfNeeded() { let lastSync: Date = (UserDefaults.standard[.lastConfigurationSync] ?? .distantPast) guard Date().timeIntervalSince(lastSync) > (7 * 24 * 60 * 60) else { return } // Sync every 2 days GRDBStorage.shared.write { db in - MessageSender.syncConfiguration(db, forceSyncNow: false) + try MessageSender.syncConfiguration(db, forceSyncNow: false) .done { // Only update the 'lastConfigurationSync' timestamp if we have done the // first sync (Don't want a new device config sync to override config @@ -26,14 +481,4 @@ extension AppDelegate { .retainUntilComplete() } } - - @objc func startClosedGroupPoller() { - guard Identity.userExists() else { return } - - ClosedGroupPoller.shared.start() - } - - @objc func stopClosedGroupPoller() { - ClosedGroupPoller.shared.stop() - } } diff --git a/Session/Meta/AppEnvironment.swift b/Session/Meta/AppEnvironment.swift index ec6a1123b..f6226f557 100644 --- a/Session/Meta/AppEnvironment.swift +++ b/Session/Meta/AppEnvironment.swift @@ -61,7 +61,9 @@ import SignalUtilitiesKit @objc public func setup() { // Hang certain singletons on SSKEnvironment too. - SSKEnvironment.shared.notificationsManager = notificationPresenter + SSKEnvironment.shared.notificationsManager.mutate { + $0 = notificationPresenter + } setupLogFiles() } diff --git a/Session/Meta/Main.storyboard b/Session/Meta/Main.storyboard deleted file mode 100644 index 3b1417e92..000000000 --- a/Session/Meta/Main.storyboard +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/Session/Meta/Signal-Bridging-Header.h b/Session/Meta/Signal-Bridging-Header.h index a3b196317..e785154a7 100644 --- a/Session/Meta/Signal-Bridging-Header.h +++ b/Session/Meta/Signal-Bridging-Header.h @@ -7,7 +7,6 @@ #import // Separate iOS Frameworks from other imports. -#import "AppDelegate.h" #import "AvatarViewHelper.h" #import "AVAudioSession+OWS.h" #import "ContactCellView.h" @@ -25,10 +24,12 @@ #import "OWSMessageTimerView.h" #import "OWSNavigationController.h" #import "OWSProgressView.h" +#import "OWSScreenLockUI.h" #import "OWSWindowManager.h" #import "PrivacySettingsTableViewController.h" #import "OWSQRCodeScanningViewController.h" #import "SignalApp.h" +#import "MainAppContext.h" #import "UIViewController+Permissions.h" #import #import diff --git a/Session/Meta/SignalApp.m b/Session/Meta/SignalApp.m index 2e6e6d688..7c1e40fcd 100644 --- a/Session/Meta/SignalApp.m +++ b/Session/Meta/SignalApp.m @@ -3,7 +3,6 @@ // #import "SignalApp.h" -#import "AppDelegate.h" #import "Session-Swift.h" #import #import diff --git a/Session/Meta/Translations/en.lproj/Localizable.strings b/Session/Meta/Translations/en.lproj/Localizable.strings index 4f5674bd3..07b2d3498 100644 --- a/Session/Meta/Translations/en.lproj/Localizable.strings +++ b/Session/Meta/Translations/en.lproj/Localizable.strings @@ -607,7 +607,7 @@ "light_mode_theme" = "Light"; "PIN_BUTTON_TEXT" = "Pin"; "UNPIN_BUTTON_TEXT" = "Unpin"; -"meida_saved" = "Media saved by %@."; +"media_saved" = "Media saved by %@."; "screenshot_taken" = "%@ took a screenshot."; "SEARCH_SECTION_CONTACTS" = "Contacts and Groups"; "SEARCH_SECTION_MESSAGES" = "Messages"; diff --git a/Session/Meta/main.m b/Session/Meta/main.m deleted file mode 100644 index fef928bbc..000000000 --- a/Session/Meta/main.m +++ /dev/null @@ -1,8 +0,0 @@ -#import "AppDelegate.h" - -int main(int argc, char *argv[]) -{ - @autoreleasepool { - return UIApplicationMain(argc, argv, nil, NSStringFromClass(AppDelegate.class)); - } -} diff --git a/Session/Notifications/AppNotifications.swift b/Session/Notifications/AppNotifications.swift index 36842635d..b067296a7 100644 --- a/Session/Notifications/AppNotifications.swift +++ b/Session/Notifications/AppNotifications.swift @@ -3,6 +3,7 @@ // import Foundation +import GRDB import PromiseKit import SessionMessagingKit import SignalUtilitiesKit @@ -98,7 +99,7 @@ protocol NotificationPresenterAdaptee: AnyObject { func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: OWSSound?, replacingIdentifier: String?) func cancelNotifications(threadId: String) - func cancelNotification(identifier: String) + func cancelNotifications(identifiers: [String]) func clearAllNotifications() } @@ -154,74 +155,75 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { return adaptee.registerNotificationSettings() } - public func notifyUser(for incomingMessage: TSIncomingMessage, in thread: TSThread, transaction: YapDatabaseReadTransaction) { - guard !thread.isMuted else { return } - guard let threadId = thread.uniqueId else { return } - let isMessageRequest = thread.isMessageRequest(using: transaction) + public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, isBackgroundPoll: Bool) { + guard thread.notificationMode != .none else { return } + + let isMessageRequest = thread.isMessageRequest(db) // If the thread is a message request and the user hasn't hidden message requests then we need // to check if this is the only message request thread (group threads can't be message requests // so just ignore those and if the user has hidden message requests then we want to show the // notification regardless of how many message requests there are) - if !thread.isGroupThread() && isMessageRequest && !CurrentAppContext().appUserDefaults()[.hasHiddenMessageRequests] { - let threads = transaction.ext(TSThreadDatabaseViewExtensionName) as! YapDatabaseViewTransaction - let numMessageRequests = threads.numberOfItems(inGroup: TSMessageRequestGroup) - - // Allow this to show a notification if there are no message requests (ie. this is the first one) - guard numMessageRequests == 0 else { return } - } - else if isMessageRequest && CurrentAppContext().appUserDefaults()[.hasHiddenMessageRequests] { - // If there are other interactions on this thread already then don't show the notification - if thread.numberOfInteractions(with: transaction) > 1 { return } - - CurrentAppContext().appUserDefaults()[.hasHiddenMessageRequests] = false + if thread.variant == .contact { + if isMessageRequest && !db[.hasHiddenMessageRequests] { + let numMessageRequestThreads: Int? = try? SessionThread.messageRequestThreads(db) + .fetchCount(db) + + // Allow this to show a notification if there are no message requests (ie. this is the first one) + guard (numMessageRequestThreads ?? 0) == 0 else { return } + } + else if isMessageRequest && db[.hasHiddenMessageRequests] { + // If there are other interactions on this thread already then don't show the notification + if ((try? thread.interactions.fetchCount(db)) ?? 0) > 1 { return } + + db[.hasHiddenMessageRequests] = false + } } - let identifier: String = incomingMessage.notificationIdentifier ?? UUID().uuidString - - let isBackgroudPoll = identifier == threadId + let identifier: String = interaction.notificationIdentifier(isBackgroundPoll: isBackgroundPoll) // While batch processing, some of the necessary changes have not been commited. - let rawMessageText = incomingMessage.previewText(with: transaction) + let rawMessageText = interaction.previewText(db) // iOS strips anything that looks like a printf formatting character from // the notification body, so if we want to dispay a literal "%" in a notification // it must be escaped. // see https://developer.apple.com/documentation/uikit/uilocalnotification/1616646-alertbody // for more details. - let messageText = DisplayableText.filterNotificationText(rawMessageText) + let messageText: String? = DisplayableText.filterNotificationText(rawMessageText) // Don't fire the notification if the current user isn't mentioned // and isOnlyNotifyingForMentions is on. - if let groupThread = thread as? TSGroupThread, groupThread.isOnlyNotifyingForMentions && !incomingMessage.isUserMentioned { + if thread.notificationMode == .mentionsOnly && !interaction.isUserMentioned(db) { return } - let senderName = Profile.displayName(for: incomingMessage.authorId, thread: thread) - let notificationTitle: String? var notificationBody: String? - let previewType = preferences.notificationPreviewType(with: transaction) + + let senderName = Profile.displayName(db, id: interaction.authorId, thread: thread) + let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType] + .defaulting(to: .nameAndPreview) switch previewType { case .noNameNoPreview: notificationTitle = "Session" - case .nameNoPreview, .namePreview: - switch thread { - case is TSContactThread: + case .nameNoPreview, .nameAndPreview: + switch thread.variant { + case .contact: notificationTitle = (isMessageRequest ? "Session" : senderName) - case is TSGroupThread: - var groupName = thread.name(with: transaction) - if groupName.count < 1 { - groupName = MessageStrings.newGroupDefaultTitle - } - notificationTitle = isBackgroudPoll ? groupName : String(format: NotificationStrings.incomingGroupMessageTitleFormat, senderName, groupName) + case .closedGroup, .openGroup: + let groupName: String = thread.name(db) - default: - owsFailDebug("unexpected thread: \(thread)") - return + notificationTitle = (isBackgroundPoll ? groupName: + String( + format: NotificationStrings.incomingGroupMessageTitleFormat, + senderName, + groupName + ) + ) } default: @@ -230,14 +232,14 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { switch previewType { case .noNameNoPreview, .nameNoPreview: notificationBody = NotificationStrings.incomingMessageBody - case .namePreview: notificationBody = messageText + case .nameAndPreview: notificationBody = messageText default: notificationBody = NotificationStrings.incomingMessageBody } // If it's a message request then overwrite the body to be something generic (only show a notification // when receiving a new message request if there aren't any others or the user had hidden them) if isMessageRequest { - notificationBody = NSLocalizedString("MESSAGE_REQUESTS_NOTIFICATION", comment: "") + notificationBody = "MESSAGE_REQUESTS_NOTIFICATION".localized() } assert((notificationBody ?? notificationTitle) != nil) @@ -247,11 +249,14 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { let category = AppNotificationCategory.incomingMessage let userInfo = [ - AppNotificationUserInfoKey.threadId: threadId + AppNotificationUserInfoKey.threadId: thread.id ] DispatchQueue.main.async { - notificationBody = MentionUtilities.highlightMentions(in: notificationBody!, threadID: thread.uniqueId!) + notificationBody = MentionUtilities.highlightMentions( + in: (notificationBody ?? ""), + threadId: thread.id + ) let sound = self.requestSound(thread: thread) self.adaptee.notify( @@ -265,42 +270,37 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { } } - public func notifyForFailedSend(inThread thread: TSThread) { + public func notifyForFailedSend(_ db: Database, in thread: SessionThread) { let notificationTitle: String? + switch previewType { - case .noNameNoPreview: - notificationTitle = nil - case .nameNoPreview, .namePreview: - notificationTitle = thread.name() - default: - notificationTitle = nil + case .noNameNoPreview: notificationTitle = nil + case .nameNoPreview, .namePreview: notificationTitle = thread.name(db) + default: notificationTitle = nil } let notificationBody = NotificationStrings.failedToSendBody - guard let threadId = thread.uniqueId else { - owsFailDebug("threadId was unexpectedly nil") - return - } - let userInfo = [ - AppNotificationUserInfoKey.threadId: threadId + AppNotificationUserInfoKey.threadId: thread.id ] DispatchQueue.main.async { let sound = self.requestSound(thread: thread) - self.adaptee.notify(category: .errorMessage, - title: notificationTitle, - body: notificationBody, - userInfo: userInfo, - sound: sound) + self.adaptee.notify( + category: .errorMessage, + title: notificationTitle, + body: notificationBody, + userInfo: userInfo, + sound: sound + ) } } @objc - public func cancelNotification(_ identifier: String) { + public func cancelNotifications(identifiers: [String]) { DispatchQueue.main.async { - self.adaptee.cancelNotification(identifier: identifier) + self.adaptee.cancelNotifications(identifiers: identifiers) } } @@ -318,12 +318,12 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { var mostRecentNotifications = TruncatedList(maxLength: kAudioNotificationsThrottleCount) - private func requestSound(thread: TSThread) -> OWSSound? { + private func requestSound(thread: SessionThread) -> OWSSound? { guard checkIfShouldPlaySound() else { return nil } - return OWSSounds.notificationSound(for: thread) + return OWSSounds.notificationSound(forThreadId: thread.id) } private func checkIfShouldPlaySound() -> Bool { @@ -388,27 +388,31 @@ class NotificationActionHandler { throw NotificationError.failDebug("threadId was unexpectedly nil") } - guard let thread = TSThread.fetch(uniqueId: threadId) else { + guard let thread: SessionThread = GRDBStorage.shared.read({ db in try SessionThread.fetchOne(db, id: threadId) }) else { throw NotificationError.failDebug("unable to find thread with id: \(threadId)") } - - return markAsRead(thread: thread).then { () -> Promise in - let message = VisibleMessage() - message.sentTimestamp = NSDate.millisecondTimestamp() - message.text = replyText - let tsMessage = TSOutgoingMessage.from(message, associatedWith: thread) - Storage.write { transaction in - tsMessage.save(with: transaction) - } - var promise: Promise! - Storage.writeSync { transaction in - promise = MessageSender.sendNonDurably(message, in: thread, using: transaction) - } - promise.catch { [weak self] error in - self?.notificationPresenter.notifyForFailedSend(inThread: thread) - } - return promise + + let promise: Promise = GRDBStorage.shared.write { db in + let interaction: Interaction = try Interaction( + threadId: thread.id, + authorId: getUserHexEncodedPublicKey(db), + variant: .standardOutgoing, + body: replyText, + timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) + ).inserted(db) + + _ = try interaction.markingAsRead(db, includingOlder: true, trySendReadReceipt: true) + + return MessageSender.sendNonDurably(db, interaction: interaction, in: thread) } + + promise.catch { [weak self] error in + GRDBStorage.shared.read { db in + self?.notificationPresenter.notifyForFailedSend(db, in: thread) + } + } + + return promise } func showThread(userInfo: [AnyHashable: Any]) throws -> Promise { diff --git a/Session/Notifications/SyncPushTokensJob.swift b/Session/Notifications/SyncPushTokensJob.swift index 1377451ff..28f41d7e3 100644 --- a/Session/Notifications/SyncPushTokensJob.swift +++ b/Session/Notifications/SyncPushTokensJob.swift @@ -1,107 +1,128 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. +import Foundation +import GRDB import PromiseKit -import SignalUtilitiesKit +import SignalCoreKit +import SessionMessagingKit +import SessionUtilitiesKit -@objc(OWSSyncPushTokensJob) -class SyncPushTokensJob: NSObject { - - @objc public static let PushTokensDidChange = Notification.Name("PushTokensDidChange") - - // MARK: Dependencies - let accountManager: AccountManager - let preferences: OWSPreferences - var pushRegistrationManager: PushRegistrationManager { - return PushRegistrationManager.shared - } - - @objc var uploadOnlyIfStale = true - - @objc - required init(accountManager: AccountManager, preferences: OWSPreferences) { - self.accountManager = accountManager - self.preferences = preferences - } - - class func run(accountManager: AccountManager, preferences: OWSPreferences) -> Promise { - let job = self.init(accountManager: accountManager, preferences: preferences) - return job.run() - } - - func run() -> Promise { - - let runPromise = firstly { - return self.pushRegistrationManager.requestPushTokens() - }.then { (pushToken: String, voipToken: String) -> Promise in - var shouldUploadTokens = false - - if self.preferences.getPushToken() != pushToken || self.preferences.getVoipToken() != voipToken { - shouldUploadTokens = true - } else if !self.uploadOnlyIfStale { - shouldUploadTokens = true - } - - if AppVersion.sharedInstance().lastAppVersion != AppVersion.sharedInstance().currentAppVersion { - shouldUploadTokens = true - } - - guard shouldUploadTokens else { - return Promise.value(()) - } - - return firstly { - self.accountManager.updatePushTokens(pushToken: pushToken, voipToken: voipToken, isForcedUpdate: shouldUploadTokens) - }.done { _ in - self.recordPushTokensLocally(pushToken: pushToken, voipToken: voipToken) - } +public enum SyncPushTokensJob: JobExecutor { + public static let maxFailureCount: UInt = 0 + public static let requiresThreadId: Bool = false + + public static func run( + _ job: Job, + success: @escaping (Job, Bool) -> (), + failure: @escaping (Job, Error?, Bool) -> (), + deferred: @escaping (Job) -> () + ) { + // Don't schedule run when inactive or not in main app + var isMainAppActive = false + if let sharedUserDefaults = UserDefaults(suiteName: "group.com.loki-project.loki-messenger") { + isMainAppActive = sharedUserDefaults[.isMainAppActive] } - - runPromise.retainUntilComplete() - - return runPromise - } - - // MARK: - objc wrappers, since objc can't use swift parameterized types - - @objc - class func run(accountManager: AccountManager, preferences: OWSPreferences) -> AnyPromise { - let promise: Promise = self.run(accountManager: accountManager, preferences: preferences) - return AnyPromise(promise) - } - - @objc - func run() -> AnyPromise { - let promise: Promise = self.run() - return AnyPromise(promise) - } - - // MARK: - - private func recordPushTokensLocally(pushToken: String, voipToken: String) { - Logger.warn("Recording push tokens locally. pushToken: \(redact(pushToken)), voipToken: \(redact(voipToken))") - - var didTokensChange = false - - if (pushToken != self.preferences.getPushToken()) { - Logger.info("Recording new plain push token") - self.preferences.setPushToken(pushToken) - didTokensChange = true + guard isMainAppActive else { + deferred(job) // Don't need to do anything if it's not the main app + return } - - if (voipToken != self.preferences.getVoipToken()) { - Logger.info("Recording new voip token") - self.preferences.setVoipToken(voipToken) - didTokensChange = true + + // We need to check a UIApplication setting which needs to run on the main thread so if we aren't on + // the main thread then swap to it + guard Thread.isMainThread else { + DispatchQueue.main.async { + run(job, success: success, failure: failure, deferred: deferred) + } + return } - - if (didTokensChange) { - NotificationCenter.default.postNotificationNameAsync(SyncPushTokensJob.PushTokensDidChange, object: nil) + guard !UIApplication.shared.isRegisteredForRemoteNotifications else { + deferred(job) // Don't need to do anything if push notifications are already registered + return } + + Logger.info("Retrying remote notification registration since user hasn't registered yet.") + + // Determine if we want to upload only if stale (Note: This should default to true, and be true if + // 'details' isn't provided) + // TODO: Double check on a real device + let uploadOnlyIfStale: Bool = ((try? JSONDecoder().decode(Details.self, from: job.details ?? Data()))?.uploadOnlyIfStale ?? true) + + // Get the app version info (used to determine if we want to update the push tokens) + let lastAppVersion: String? = AppVersion.sharedInstance().lastAppVersion + let currentAppVersion: String? = AppVersion.sharedInstance().currentAppVersion + + PushRegistrationManager.shared.requestPushTokens() + .then { (pushToken: String, voipToken: String) -> Promise in + let lastPushToken: String? = GRDBStorage.shared.read { db in db[.lastRecordedPushToken] } + let lastVoipToken: String? = GRDBStorage.shared.read { db in db[.lastRecordedVoipToken] } + let shouldUploadTokens: Bool = ( + !uploadOnlyIfStale || ( + lastPushToken != pushToken || + lastVoipToken != voipToken + ) || + lastAppVersion != currentAppVersion + ) + + guard shouldUploadTokens else { return Promise.value(()) } + + let (promise, seal) = Promise.pending() + + SSKEnvironment.shared.tsAccountManager + .registerForPushNotifications( + pushToken: pushToken, + voipToken: voipToken, + isForcedUpdate: shouldUploadTokens, + success: { seal.fulfill(()) }, + failure: seal.reject + ) + + return promise + .done { _ in + Logger.warn("Recording push tokens locally. pushToken: \(redact(pushToken)), voipToken: \(redact(voipToken))") + + GRDBStorage.shared.write { db in + db[.lastRecordedPushToken] = pushToken + db[.lastRecordedVoipToken] = voipToken + } + } + } + .ensure { success(job, false) } // We want to complete this job regardless of success or failure + .retainUntilComplete() + } + + public static func run(uploadOnlyIfStale: Bool) { + guard let job: Job = Job(variant: .syncPushTokens, details: SyncPushTokensJob.Details(uploadOnlyIfStale: uploadOnlyIfStale)) else { + return + } + + SyncPushTokensJob.run( + job, + success: { _, _ in }, + failure: { _, _, _ in }, + deferred: { _ in } + ) } } +// MARK: - SyncPushTokensJob.Details + +extension SyncPushTokensJob { + public struct Details: Codable { + public let uploadOnlyIfStale: Bool + } +} + +// MARK: - Convenience + private func redact(_ string: String) -> String { return OWSIsDebugBuild() ? string : "[ READACTED \(string.prefix(2))...\(string.suffix(2)) ]" } + +// MARK: - Objective C Support + +@objc(OWSSyncPushTokensJob) +class OWSSyncPushTokensJob: NSObject { + @objc static func run() { + SyncPushTokensJob.run(uploadOnlyIfStale: false) + } +} diff --git a/Session/Notifications/UserNotificationsAdaptee.swift b/Session/Notifications/UserNotificationsAdaptee.swift index e5997f9a9..7e9daaa35 100644 --- a/Session/Notifications/UserNotificationsAdaptee.swift +++ b/Session/Notifications/UserNotificationsAdaptee.swift @@ -139,21 +139,21 @@ extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee { let request = UNNotificationRequest(identifier: notificationIdentifier, content: content, trigger: trigger) Logger.debug("presenting notification with identifier: \(notificationIdentifier)") - if isReplacingNotification { cancelNotification(identifier: notificationIdentifier) } + if isReplacingNotification { cancelNotifications(identifiers: [notificationIdentifier]) } notificationCenter.add(request) notifications[notificationIdentifier] = request } - func cancelNotification(identifier: String) { + func cancelNotifications(identifiers: [String]) { AssertIsOnMainThread() - notifications.removeValue(forKey: identifier) - notificationCenter.removeDeliveredNotifications(withIdentifiers: [identifier]) - notificationCenter.removePendingNotificationRequests(withIdentifiers: [identifier]) + identifiers.forEach { notifications.removeValue(forKey: $0) } + notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiers) + notificationCenter.removePendingNotificationRequests(withIdentifiers: identifiers) } func cancelNotification(_ notification: UNNotificationRequest) { AssertIsOnMainThread() - cancelNotification(identifier: notification.identifier) + cancelNotifications(identifiers: [notification.identifier]) } func cancelNotifications(threadId: String) { diff --git a/Session/Onboarding/PNModeVC.swift b/Session/Onboarding/PNModeVC.swift index 56f2c9afd..371701e02 100644 --- a/Session/Onboarding/PNModeVC.swift +++ b/Session/Onboarding/PNModeVC.swift @@ -97,8 +97,7 @@ final class PNModeVC : BaseVC, OptionViewDelegate { TSAccountManager.sharedInstance().didRegister() let homeVC = HomeVC() navigationController!.setViewControllers([ homeVC ], animated: true) - let syncTokensJob = SyncPushTokensJob(accountManager: AppEnvironment.shared.accountManager, preferences: Environment.shared.preferences) - syncTokensJob.uploadOnlyIfStale = false - let _: Promise = syncTokensJob.run() + + SyncPushTokensJob.run(uploadOnlyIfStale: false) } } diff --git a/Session/Settings/NotificationSettingsViewController.m b/Session/Settings/NotificationSettingsViewController.m index b884aa121..d087b8f67 100644 --- a/Session/Settings/NotificationSettingsViewController.m +++ b/Session/Settings/NotificationSettingsViewController.m @@ -117,9 +117,7 @@ - (void)didToggleAPNsSwitch:(UISwitch *)sender { [NSUserDefaults.standardUserDefaults setBool:sender.on forKey:@"isUsingFullAPNs"]; - OWSSyncPushTokensJob *syncTokensJob = [[OWSSyncPushTokensJob alloc] initWithAccountManager:AppEnvironment.shared.accountManager preferences:Environment.shared.preferences]; - syncTokensJob.uploadOnlyIfStale = NO; - [[syncTokensJob run] retainUntilComplete]; + [OWSSyncPushTokensJob run]; // FIXME: Only usage of 'OWSSyncPushTokensJob' - remove when gone } @end diff --git a/Session/Settings/NukeDataModal.swift b/Session/Settings/NukeDataModal.swift index d95a1a292..edfe578f4 100644 --- a/Session/Settings/NukeDataModal.swift +++ b/Session/Settings/NukeDataModal.swift @@ -125,7 +125,7 @@ final class NukeDataModal : Modal { @objc private func clearDeviceOnly() { ModalActivityIndicatorViewController.present(fromViewController: self, canCancel: false) { [weak self] _ in GRDBStorage.shared.write { db in - MessageSender.syncConfiguration(db, forceSyncNow: true) + try MessageSender.syncConfiguration(db, forceSyncNow: true) .ensure(on: DispatchQueue.main) { self?.dismiss(animated: true, completion: nil) // Dismiss the loader UserDefaults.removeAll() // Not done in the nuke data implementation as unlinking requires this to happen later diff --git a/Session/Settings/PrivacySettingsTableViewController.m b/Session/Settings/PrivacySettingsTableViewController.m index 81f2f18a4..ea6ac16ef 100644 --- a/Session/Settings/PrivacySettingsTableViewController.m +++ b/Session/Settings/PrivacySettingsTableViewController.m @@ -14,6 +14,7 @@ #import #import #import +#import #import NS_ASSUME_NONNULL_BEGIN @@ -54,23 +55,6 @@ static NSString *const kSealedSenderInfoURL = @"https://signal.org/blog/sealed-s [[NSNotificationCenter defaultCenter] removeObserver:self]; } -#pragma mark - Dependencies - -- (OWSPreferences *)preferences -{ - return Environment.shared.preferences; -} - -- (OWSReadReceiptManager *)readReceiptManager -{ - return OWSReadReceiptManager.sharedManager; -} - -- (id)typingIndicators -{ - return SSKEnvironment.shared.typingIndicators; -} - #pragma mark - Table Contents - (void)updateTableContents @@ -107,7 +91,7 @@ static NSString *const kSealedSenderInfoURL = @"https://signal.org/blog/sealed-s @"Label for the 'typing indicators' setting.") accessibilityIdentifier:[NSString stringWithFormat:@"settings.privacy.%@", @"typing_indicators"] isOnBlock:^{ - return [SSKEnvironment.shared.typingIndicators areTypingIndicatorsEnabled]; + return [SSKPreferences areTypingIndicatorsEnabled]; } isEnabledBlock:^{ return YES; @@ -236,21 +220,21 @@ static NSString *const kSealedSenderInfoURL = @"https://signal.org/blog/sealed-s { BOOL enabled = sender.isOn; OWSLogInfo(@"toggled screen security: %@", enabled ? @"ON" : @"OFF"); - [self.preferences setScreenSecurity:enabled]; + [SSKPreferences setScreenSecurity:enabled]; } - (void)didToggleReadReceiptsSwitch:(UISwitch *)sender { BOOL enabled = sender.isOn; OWSLogInfo(@"toggled areReadReceiptsEnabled: %@", enabled ? @"ON" : @"OFF"); - [self.readReceiptManager setAreReadReceiptsEnabled:enabled]; + [SSKPreferences setAreReadReceiptsEnabled:enabled]; } - (void)didToggleTypingIndicatorsSwitch:(UISwitch *)sender { BOOL enabled = sender.isOn; OWSLogInfo(@"toggled areTypingIndicatorsEnabled: %@", enabled ? @"ON" : @"OFF"); - [self.typingIndicators setTypingIndicatorsEnabledWithValue:enabled]; + [SSKPreferences setTypingIndicatorsEnabled:enabled]; } - (void)didToggleLinkPreviewsEnabled:(UISwitch *)sender diff --git a/Session/Settings/SettingsVC.swift b/Session/Settings/SettingsVC.swift index 200a81df7..cb64e2c5f 100644 --- a/Session/Settings/SettingsVC.swift +++ b/Session/Settings/SettingsVC.swift @@ -359,7 +359,7 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { private func updateProfile(isUpdatingDisplayName: Bool, isUpdatingProfilePicture: Bool) { let userDefaults = UserDefaults.standard let name: String? = (displayNameToBeUploaded ?? Profile.fetchOrCreateCurrentUser().name) - let profilePicture: UIImage? = (profilePictureToBeUploaded ?? ProfileManager.profileAvatar(for: getUserHexEncodedPublicKey())) + let profilePicture: UIImage? = (profilePictureToBeUploaded ?? ProfileManager.profileAvatar(id: getUserHexEncodedPublicKey())) ModalActivityIndicatorViewController.present(fromViewController: navigationController!, canCancel: false) { [weak self, displayNameToBeUploaded, profilePictureToBeUploaded] modalActivityIndicator in ProfileManager.updateLocal( profileName: (name ?? ""), @@ -373,7 +373,7 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { userDefaults[.lastProfilePictureUpdate] = Date() } GRDBStorage.shared.write { db in - MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() + try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() } DispatchQueue.main.async { diff --git a/Session/Shared/ConversationCell.swift b/Session/Shared/ConversationCell.swift index a69c3b171..81ff07352 100644 --- a/Session/Shared/ConversationCell.swift +++ b/Session/Shared/ConversationCell.swift @@ -1,21 +1,17 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + import UIKit import SessionUIKit +import SignalUtilitiesKit final class ConversationCell : UITableViewCell { - var isShowingGlobalSearchResult = false - var threadViewModel: ThreadViewModel! { - didSet { - isShowingGlobalSearchResult ? updateForSearchResult() : update() - } - } - static let reuseIdentifier = "ConversationCell" - + // MARK: UI Components private let accentLineView = UIView() - + private lazy var profilePictureView = ProfilePictureView() - + private lazy var displayNameLabel: UILabel = { let result = UILabel() result.font = .boldSystemFont(ofSize: Values.mediumFontSize) @@ -23,7 +19,7 @@ final class ConversationCell : UITableViewCell { result.lineBreakMode = .byTruncatingTail return result }() - + private lazy var unreadCountView: UIView = { let result = UIView() result.backgroundColor = Colors.text.withAlphaComponent(Values.veryLowOpacity) @@ -34,7 +30,7 @@ final class ConversationCell : UITableViewCell { result.layer.cornerRadius = size / 2 return result }() - + private lazy var unreadCountLabel: UILabel = { let result = UILabel() result.font = .boldSystemFont(ofSize: Values.verySmallFontSize) @@ -42,7 +38,7 @@ final class ConversationCell : UITableViewCell { result.textAlignment = .center return result }() - + private lazy var hasMentionView: UIView = { let result = UIView() result.backgroundColor = Colors.accent @@ -53,7 +49,7 @@ final class ConversationCell : UITableViewCell { result.layer.cornerRadius = size / 2 return result }() - + private lazy var hasMentionLabel: UILabel = { let result = UILabel() result.font = .boldSystemFont(ofSize: Values.verySmallFontSize) @@ -62,7 +58,7 @@ final class ConversationCell : UITableViewCell { result.textAlignment = .center return result }() - + private lazy var isPinnedIcon: UIImageView = { let result = UIImageView(image: UIImage(named: "Pin")!.withRenderingMode(.alwaysTemplate)) result.contentMode = .scaleAspectFit @@ -73,7 +69,7 @@ final class ConversationCell : UITableViewCell { result.layer.masksToBounds = true return result }() - + private lazy var timestampLabel: UILabel = { let result = UILabel() result.font = .systemFont(ofSize: Values.smallFontSize) @@ -82,7 +78,7 @@ final class ConversationCell : UITableViewCell { result.alpha = Values.lowOpacity return result }() - + private lazy var snippetLabel: UILabel = { let result = UILabel() result.font = .systemFont(ofSize: Values.smallFontSize) @@ -90,9 +86,9 @@ final class ConversationCell : UITableViewCell { result.lineBreakMode = .byTruncatingTail return result }() - + private lazy var typingIndicatorView = TypingIndicatorView() - + private lazy var statusIndicatorView: UIImageView = { let result = UIImageView() result.contentMode = .scaleAspectFit @@ -100,7 +96,7 @@ final class ConversationCell : UITableViewCell { result.layer.masksToBounds = true return result }() - + private lazy var topLabelStackView: UIStackView = { let result = UIStackView() result.axis = .horizontal @@ -108,7 +104,7 @@ final class ConversationCell : UITableViewCell { result.spacing = Values.smallSpacing / 2 // Effectively Values.smallSpacing because there'll be spacing before and after the invisible spacer return result }() - + private lazy var bottomLabelStackView: UIStackView = { let result = UIStackView() result.axis = .horizontal @@ -116,23 +112,23 @@ final class ConversationCell : UITableViewCell { result.spacing = Values.smallSpacing / 2 // Effectively Values.smallSpacing because there'll be spacing before and after the invisible spacer return result }() - + // MARK: Settings - + public static let unreadCountViewSize: CGFloat = 20 private static let statusIndicatorSize: CGFloat = 14 - + // MARK: Initialization override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) setUpViewHierarchy() } - + required init?(coder: NSCoder) { super.init(coder: coder) setUpViewHierarchy() } - + private func setUpViewHierarchy() { let cellHeight: CGFloat = 68 // Background color @@ -203,8 +199,21 @@ final class ConversationCell : UITableViewCell { stackView.set(.height, to: cellHeight) } - // MARK: Updating for search results - private func updateForSearchResult() { + // MARK: - Content + + public func update(with threadViewModel: ThreadViewModel?, isGlobalSearchResult: Bool = false) { + guard let threadViewModel: ThreadViewModel = threadViewModel else { return } + guard !isGlobalSearchResult else { + updateForSearchResult(threadViewModel) + return + } + + update(threadViewModel) + } + + // MARK: - Updating for search results + + private func updateForSearchResult(_ threadViewModel: ThreadViewModel) { AssertIsOnMainThread() guard let thread = threadViewModel?.threadRecord else { return } profilePictureView.update(for: thread) @@ -212,15 +221,15 @@ final class ConversationCell : UITableViewCell { unreadCountView.isHidden = true hasMentionView.isHidden = true } - - public func configureForRecent() { - displayNameLabel.attributedText = NSMutableAttributedString(string: getDisplayName(), attributes: [.foregroundColor:Colors.text]) + + public func configureForRecent(_ threadViewModel: ThreadViewModel) { + displayNameLabel.attributedText = NSMutableAttributedString(string: getDisplayName(for: threadViewModel.thread), attributes: [.foregroundColor:Colors.text]) bottomLabelStackView.isHidden = false - let snippet = String(format: NSLocalizedString("RECENT_SEARCH_LAST_MESSAGE_DATETIME", comment: ""), DateUtil.formatDate(forDisplay: threadViewModel.lastMessageDate)) + let snippet = String(format: NSLocalizedString("RECENT_SEARCH_LAST_MESSAGE_DATETIME", comment: ""), DateUtil.formatDate(forDisplay: threadViewModel.lastInteractionDate)) snippetLabel.attributedText = NSMutableAttributedString(string: snippet, attributes: [.foregroundColor:Colors.text.withAlphaComponent(Values.lowOpacity)]) timestampLabel.isHidden = true } - + public func configure(snippet: String?, searchText: String, message: TSMessage? = nil) { let normalizedSearchText = searchText.lowercased() if let messageTimestamp = message?.timestamp, let snippet = snippet { @@ -263,150 +272,182 @@ final class ConversationCell : UITableViewCell { timestampLabel.isHidden = true } } - + private func getHighlightedSnippet(snippet: String, searchText: String, fontSize: CGFloat) -> NSMutableAttributedString { guard snippet != NSLocalizedString("NOTE_TO_SELF", comment: "") else { return NSMutableAttributedString(string: snippet, attributes: [.foregroundColor:Colors.text]) } - + let result = NSMutableAttributedString(string: snippet, attributes: [.foregroundColor:Colors.text.withAlphaComponent(Values.lowOpacity)]) let normalizedSnippet = snippet.lowercased() as NSString - + guard normalizedSnippet.contains(searchText) else { return result } - + let range = normalizedSnippet.range(of: searchText) result.addAttribute(.foregroundColor, value: Colors.text, range: range) result.addAttribute(.font, value: UIFont.boldSystemFont(ofSize: fontSize), range: range) return result } + + // MARK: - Updating - // MARK: Updating - private func update() { - AssertIsOnMainThread() - guard let thread = threadViewModel?.threadRecord else { return } - backgroundColor = threadViewModel.isPinned ? Colors.cellPinned : Colors.cellBackground + private func update(_ threadViewModel: ThreadViewModel) { + let thread: SessionThread = threadViewModel.thread - if thread.isBlocked() { + backgroundColor = (thread.isPinned ? Colors.cellPinned : Colors.cellBackground) + + if GRDBStorage.shared.read({ db in try thread.contact.fetchOne(db)?.isBlocked }) == true { accentLineView.backgroundColor = Colors.destructive accentLineView.alpha = 1 } else { accentLineView.backgroundColor = Colors.accent - accentLineView.alpha = threadViewModel.hasUnreadMessages ? 1 : 0.0001 // Setting the alpha to exactly 0 causes an issue on iOS 12 + accentLineView.alpha = (threadViewModel.unreadCount > 0 ? 1 : 0.0001) // Setting the alpha to exactly 0 causes an issue on iOS 12 } - isPinnedIcon.isHidden = !threadViewModel.isPinned - unreadCountView.isHidden = !threadViewModel.hasUnreadMessages - let unreadCount = threadViewModel.unreadCount - unreadCountLabel.text = unreadCount < 10000 ? "\(unreadCount)" : "9999+" - let fontSize = (unreadCount < 10000) ? Values.verySmallFontSize : 8 - unreadCountLabel.font = .boldSystemFont(ofSize: fontSize) - hasMentionView.isHidden = !(threadViewModel.hasUnreadMentions && thread.isGroupThread()) - profilePictureView.update(for: thread) - displayNameLabel.text = getDisplayName() - timestampLabel.text = DateUtil.formatDate(forDisplay: threadViewModel.lastMessageDate) - if SSKEnvironment.shared.typingIndicators.typingRecipientId(forThread: thread) != nil { - snippetLabel.text = "" - typingIndicatorView.isHidden = false - typingIndicatorView.startAnimation() - } else { - snippetLabel.attributedText = getSnippet() + + isPinnedIcon.isHidden = !thread.isPinned + unreadCountView.isHidden = (threadViewModel.unreadCount <= 0) + unreadCountLabel.text = (threadViewModel.unreadCount < 10000 ? "\(threadViewModel.unreadCount)" : "9999+") + unreadCountLabel.font = .boldSystemFont( + ofSize: (threadViewModel.unreadCount < 10000 ? Values.verySmallFontSize : 8) + ) + hasMentionView.isHidden = !( + (threadViewModel.unreadMentionCount > 0) && + (thread.variant == .closedGroup || thread.variant == .openGroup) + ) + GRDBStorage.shared.read { db in profilePictureView.update(db, thread: thread) } + displayNameLabel.text = getDisplayName(for: thread) + timestampLabel.text = DateUtil.formatDate(forDisplay: threadViewModel.lastInteractionDate) + // TODO: Add this back +// if SSKEnvironment.shared.typingIndicators.typingRecipientId(forThread: thread) != nil { +// snippetLabel.text = "" +// typingIndicatorView.isHidden = false +// typingIndicatorView.startAnimation() +// } else { + snippetLabel.attributedText = getSnippet(threadViewModel: threadViewModel) typingIndicatorView.isHidden = true typingIndicatorView.stopAnimation() - } +// } + statusIndicatorView.backgroundColor = nil - let lastMessage = threadViewModel.lastMessageForInbox - if let lastMessage = lastMessage as? TSOutgoingMessage { - let status = MessageRecipientStatusUtils.recipientStatus(outgoingMessage: lastMessage) + + switch (threadViewModel.lastInteraction?.variant, threadViewModel.lastInteractionState) { + case (.standardOutgoing, .sending): + statusIndicatorView.image = #imageLiteral(resourceName: "CircleDotDotDot").withRenderingMode(.alwaysTemplate) + statusIndicatorView.tintColor = Colors.text + statusIndicatorView.isHidden = false + + case (.standardOutgoing, .sent): + statusIndicatorView.image = #imageLiteral(resourceName: "CircleCheck").withRenderingMode(.alwaysTemplate) + statusIndicatorView.tintColor = Colors.text + statusIndicatorView.isHidden = false + + case (.standardOutgoing, .failed): + statusIndicatorView.image = #imageLiteral(resourceName: "message_status_failed").withRenderingMode(.alwaysTemplate) + statusIndicatorView.tintColor = Colors.destructive + statusIndicatorView.isHidden = false - switch status { - case .uploading, .sending: - statusIndicatorView.image = #imageLiteral(resourceName: "CircleDotDotDot").withRenderingMode(.alwaysTemplate) - statusIndicatorView.tintColor = Colors.text - - case .sent, .skipped, .delivered: - statusIndicatorView.image = #imageLiteral(resourceName: "CircleCheck").withRenderingMode(.alwaysTemplate) - statusIndicatorView.tintColor = Colors.text - - case .read: - statusIndicatorView.image = isLightMode ? #imageLiteral(resourceName: "FilledCircleCheckLightMode") : #imageLiteral(resourceName: "FilledCircleCheckDarkMode") - statusIndicatorView.tintColor = nil - statusIndicatorView.backgroundColor = (isLightMode ? .black : .white) - - case .failed: - statusIndicatorView.image = #imageLiteral(resourceName: "message_status_failed").withRenderingMode(.alwaysTemplate) - statusIndicatorView.tintColor = Colors.destructive - } - - statusIndicatorView.isHidden = false - } - else { - statusIndicatorView.isHidden = true + default: + statusIndicatorView.isHidden = false } } - - private func getMessageAuthorName(message: TSMessage) -> String? { - guard threadViewModel.isGroupThread else { return nil } - if let incomingMessage = message as? TSIncomingMessage { - return Profile.displayName(for: incomingMessage.authorId, customFallback: "Anonymous") + + private func getAuthorName(thread: SessionThread, interaction: Interaction) -> String? { + switch (thread.variant, interaction.variant) { + case (.contact, .standardIncoming): + return Profile.displayName(id: interaction.authorId, customFallback: "Anonymous") + + default: return nil } - return nil } - + private func getDisplayNameForSearch(_ sessionID: String) -> String { if threadViewModel.threadRecord.isNoteToSelf() { return NSLocalizedString("NOTE_TO_SELF", comment: "") } return [ - Profile.displayName(for: sessionID), + Profile.displayName(id: sessionID), Profile.fetchOrCreate(id: sessionID).nickname.map { "(\($0)" } ] .compactMap { $0 } .joined(separator: " ") } - - private func getDisplayName() -> String { - if threadViewModel.isGroupThread { - if threadViewModel.name.isEmpty { - return "Unknown Group" - } - else { - return threadViewModel.name - } + + private func getDisplayName(for thread: SessionThread) -> String { + if thread.variant == .closedGroup || thread.variant == .openGroup { + return GRDBStorage.shared.read({ db in thread.name(db) }) + .defaulting(to: "Unknown Group") } - else { - if threadViewModel.threadRecord.isNoteToSelf() { - return NSLocalizedString("NOTE_TO_SELF", comment: "") - } - else { - let hexEncodedPublicKey: String = threadViewModel.contactSessionID! - let middleTruncatedHexKey: String = "\(hexEncodedPublicKey.prefix(4))...\(hexEncodedPublicKey.suffix(4))" - - return Profile.displayName(for: hexEncodedPublicKey, customFallback: middleTruncatedHexKey) - } + + if GRDBStorage.shared.read({ db in thread.isNoteToSelf(db) }) == true { + return "NOTE_TO_SELF".localized() } + + let hexEncodedPublicKey: String = thread.id + let middleTruncatedHexKey: String = "\(hexEncodedPublicKey.prefix(4))...\(hexEncodedPublicKey.suffix(4))" + + return Profile.displayName(id: hexEncodedPublicKey, customFallback: middleTruncatedHexKey) } - - private func getSnippet() -> NSMutableAttributedString { + + private func getSnippet(threadViewModel: ThreadViewModel) -> NSMutableAttributedString { let result = NSMutableAttributedString() - if threadViewModel.isMuted { - result.append(NSAttributedString(string: "\u{e067} ", attributes: [ .font : UIFont.ows_elegantIconsFont(10), .foregroundColor : Colors.unimportant ])) - } else if threadViewModel.isOnlyNotifyingForMentions { + + if (threadViewModel.thread.notificationMode == .none) { + result.append(NSAttributedString( + string: "\u{e067} ", + attributes: [ + .font: UIFont.ows_elegantIconsFont(10), + .foregroundColor :Colors.unimportant + ] + )) + } + else if threadViewModel.thread.notificationMode == .mentionsOnly { let imageAttachment = NSTextAttachment() imageAttachment.image = UIImage(named: "NotifyMentions.png")?.asTintedImage(color: Colors.unimportant) imageAttachment.bounds = CGRect(x: 0, y: -2, width: Values.smallFontSize, height: Values.smallFontSize) + let imageString = NSAttributedString(attachment: imageAttachment) result.append(imageString) - result.append(NSAttributedString(string: " ", attributes: [ .font : UIFont.ows_elegantIconsFont(10), .foregroundColor : Colors.unimportant ])) + result.append(NSAttributedString( + string: " ", + attributes: [ + .font: UIFont.ows_elegantIconsFont(10), + .foregroundColor: Colors.unimportant + ] + )) } - let font = threadViewModel.hasUnreadMessages ? UIFont.boldSystemFont(ofSize: Values.smallFontSize) : UIFont.systemFont(ofSize: Values.smallFontSize) - if threadViewModel.isGroupThread, let message = threadViewModel.lastMessageForInbox as? TSMessage, let name = getMessageAuthorName(message: message) { - result.append(NSAttributedString(string: "\(name): ", attributes: [ .font : font, .foregroundColor : Colors.text ])) + + let font: UIFont = (threadViewModel.unreadCount > 0 ? + .boldSystemFont(ofSize: Values.smallFontSize) : + .systemFont(ofSize: Values.smallFontSize) + ) + + if + (threadViewModel.thread.variant == .closedGroup || threadViewModel.thread.variant == .openGroup), + let lastInteraction: Interaction = threadViewModel.lastInteraction, + let authorName: String = getAuthorName(thread: threadViewModel.thread, interaction: lastInteraction) + { + result.append(NSAttributedString( + string: "\(authorName): ", + attributes: [ + .font: font, + .foregroundColor: Colors.text + ] + )) } - if let rawSnippet = threadViewModel.lastMessageText { - let snippet = MentionUtilities.highlightMentions(in: rawSnippet, threadID: threadViewModel.threadRecord.uniqueId!) - result.append(NSAttributedString(string: snippet, attributes: [ .font : font, .foregroundColor : Colors.text ])) + + if let rawSnippet: String = threadViewModel.lastInteractionText { + let snippet = MentionUtilities.highlightMentions(in: rawSnippet, threadId: threadViewModel.thread.id) + result.append(NSAttributedString( + string: snippet, + attributes: [ + .font: font, + .foregroundColor: Colors.text + ] + )) } + return result } } diff --git a/Session/Shared/UserCell.swift b/Session/Shared/UserCell.swift index 9e3b56811..af665877f 100644 --- a/Session/Shared/UserCell.swift +++ b/Session/Shared/UserCell.swift @@ -82,7 +82,7 @@ final class UserCell : UITableViewCell { func update() { profilePictureView.publicKey = publicKey profilePictureView.update() - displayNameLabel.text = Profile.displayName(for: publicKey) + displayNameLabel.text = Profile.displayName(id: publicKey) switch accessory { case .none: accessoryImageView.isHidden = true diff --git a/Session/Utilities/AccountManager.swift b/Session/Utilities/AccountManager.swift index 3c7b3d5c1..fc083d442 100644 --- a/Session/Utilities/AccountManager.swift +++ b/Session/Utilities/AccountManager.swift @@ -85,9 +85,21 @@ public class AccountManager: NSObject { private func syncPushTokens() -> Promise { Logger.info("") - let job = SyncPushTokensJob(accountManager: self, preferences: self.preferences) - job.uploadOnlyIfStale = false - return job.run() + + guard let job: Job = Job(variant: .syncPushTokens, details: SyncPushTokensJob.Details(uploadOnlyIfStale: false)) else { + return Promise(error: GRDBStorageError.decodingFailed) + } + + let (promise, seal) = Promise.pending() + + SyncPushTokensJob.run( + job, + success: { _, _ in seal.fulfill(()) }, + failure: { _, error, _ in seal.reject(error ?? GRDBStorageError.generic) }, + deferred: { _ in seal.reject(GRDBStorageError.generic) } + ) + + return promise } private func completeRegistration() { diff --git a/Session/Utilities/MentionUtilities.swift b/Session/Utilities/MentionUtilities.swift index 9e9eb811d..7babfb128 100644 --- a/Session/Utilities/MentionUtilities.swift +++ b/Session/Utilities/MentionUtilities.swift @@ -4,12 +4,17 @@ public final class MentionUtilities : NSObject { override private init() { } - @objc public static func highlightMentions(in string: String, threadID: String) -> String { - return highlightMentions(in: string, isOutgoingMessage: false, threadID: threadID, attributes: [:]).string // isOutgoingMessage and attributes are irrelevant + @objc public static func highlightMentions(in string: String, threadId: String) -> String { + return highlightMentions( + in: string, + isOutgoingMessage: false, + threadId: threadId, + attributes: [:] + ).string // isOutgoingMessage and attributes are irrelevant } - @objc public static func highlightMentions(in string: String, isOutgoingMessage: Bool, threadID: String, attributes: [NSAttributedString.Key:Any]) -> NSAttributedString { - let openGroupV2 = Storage.shared.getV2OpenGroup(for: threadID) + @objc public static func highlightMentions(in string: String, isOutgoingMessage: Bool, threadId: String, attributes: [NSAttributedString.Key:Any]) -> NSAttributedString { + let openGroupV2 = Storage.shared.getV2OpenGroup(for: threadId) var string = string let regex = try! NSRegularExpression(pattern: "@[0-9a-fA-F]{66}", options: []) var mentions: [(range: NSRange, publicKey: String)] = [] @@ -17,7 +22,7 @@ public final class MentionUtilities : NSObject { while let match = outerMatch { let publicKey = String((string as NSString).substring(with: match.range).dropFirst()) // Drop the @ let matchEnd: Int - if let displayName = Profile.displayNameNoFallback(for: publicKey, context: (openGroupV2 != nil ? .openGroup : .regular)) { + if let displayName = Profile.displayNameNoFallback(id: publicKey, context: (openGroupV2 != nil ? .openGroup : .regular)) { string = (string as NSString).replacingCharacters(in: match.range, with: "@\(displayName)") mentions.append((range: NSRange(location: match.range.location, length: displayName.utf16.count + 1), publicKey: publicKey)) // + 1 to include the @ matchEnd = match.range.location + displayName.utf16.count diff --git a/Session/Utilities/UIApplication+OWS.swift b/Session/Utilities/UIApplication+OWS.swift index b6410c284..8cd6d7055 100644 --- a/Session/Utilities/UIApplication+OWS.swift +++ b/Session/Utilities/UIApplication+OWS.swift @@ -1,33 +1,32 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import SessionUtilitiesKit +import UIKit @objc public extension UIApplication { - public var frontmostViewControllerIgnoringAlerts: UIViewController? { + var frontmostViewControllerIgnoringAlerts: UIViewController? { return findFrontmostViewController(ignoringAlerts: true) } - public var frontmostViewController: UIViewController? { + var frontmostViewController: UIViewController? { return findFrontmostViewController(ignoringAlerts: false) } internal func findFrontmostViewController(ignoringAlerts: Bool) -> UIViewController? { - guard let window = CurrentAppContext().mainWindow else { - return nil - } + guard let window: UIWindow = CurrentAppContext().mainWindow else { return nil } + Logger.error("findFrontmostViewController: \(window)") - guard let viewController = window.rootViewController else { + + guard let viewController: UIViewController = window.rootViewController else { owsFailDebug("Missing root view controller.") return nil } return viewController.findFrontmostViewController(ignoringAlerts) } - public func openSystemSettings() { + func openSystemSettings() { openURL(URL(string: UIApplication.openSettingsURLString)!) } - } diff --git a/SessionMessagingKit/Configuration.swift b/SessionMessagingKit/Configuration.swift index 197827a2d..d69b554aa 100644 --- a/SessionMessagingKit/Configuration.swift +++ b/SessionMessagingKit/Configuration.swift @@ -18,16 +18,26 @@ public enum SNMessagingKit { // Just to make the external API nice identifier: .messagingKit, migrations: [ [ - _001_InitialSetupMigration.self + _001_InitialSetupMigration.self, + _002_SetupStandardJobs.self ], [ - _002_YDBToGRDBMigration.self + _003_YDBToGRDBMigration.self ] ] ) } public static func configure(storage: SessionMessagingKitStorageProtocol) { + // Configure the job executors + JobRunner.add(executor: DisappearingMessagesJob.self, for: .disappearingMessages) + JobRunner.add(executor: FailedMessagesJob.self, for: .failedMessages) + JobRunner.add(executor: FailedAttachmentDownloadsJob.self, for: .failedAttachmentDownloads) + JobRunner.add(executor: MessageSendJob.self, for: .messageSend) + JobRunner.add(executor: MessageReceiveJob.self, for: .messageReceive) + JobRunner.add(executor: NotifyPushServerJob.self, for: .notifyPushServer) + JobRunner.add(executor: SendReadReceiptsJob.self, for: .sendReadReceipts) + SNMessagingKitConfiguration.shared = SNMessagingKitConfiguration(storage: storage) } } diff --git a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift index e1e6ee527..19a5895f4 100644 --- a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift +++ b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift @@ -30,7 +30,30 @@ public enum Legacy { internal static let interactionCollection = "TSInteraction" internal static let attachmentsCollection = "TSAttachements" - internal static let readReceiptManagerCollection = "kOutgoingReadReceiptManagerCollection" + internal static let outgoingReadReceiptManagerCollection = "kOutgoingReadReceiptManagerCollection" + + internal static let notifyPushServerJobCollection = "NotifyPNServerJobCollection" + internal static let messageReceiveJobCollection = "MessageReceiveJobCollection" + internal static let messageSendJobCollection = "MessageSendJobCollection" + internal static let attachmentUploadJobCollection = "AttachmentUploadJobCollection" + internal static let attachmentDownloadJobCollection = "AttachmentDownloadJobCollection" + + internal static let preferencesCollection = "SignalPreferences" + internal static let preferencesKeyNotificationPreviewType = "preferencesKeyNotificationPreviewType" + internal static let preferencesKeyScreenSecurityDisabled = "Screen Security Key" + internal static let preferencesKeyLastRecordedPushToken = "LastRecordedPushToken" + internal static let preferencesKeyLastRecordedVoipToken = "LastRecordedVoipToken" + + internal static let readReceiptManagerCollection = "OWSReadReceiptManagerCollection" + internal static let readReceiptManagerAreReadReceiptsEnabled = "areReadReceiptsEnabled" + + internal static let typingIndicatorsCollection = "TypingIndicators" + internal static let typingIndicatorsEnabledKey = "kDatabaseKey_TypingIndicatorsEnabled" + + internal static let soundsStorageNotificationCollection = "kOWSSoundsStorageNotificationCollection" + internal static let soundsGlobalNotificationKey = "kOWSSoundsStorageGlobalNotificationKey" + + internal static let userDefaultsHasHiddenMessageRequests = "hasHiddenMessageRequests" // MARK: - Types @@ -43,67 +66,276 @@ public enum Legacy { public var profileKey: Data? public var profilePictureURL: String? - internal init(displayName: String, profileKey: Data? = nil, profilePictureURL: String? = nil) { - self.displayName = displayName - self.profileKey = profileKey - self.profilePictureURL = profilePictureURL - } + @objc(NotifyPNServerJob) + internal final class NotifyPNServerJob: NSObject, NSCoding { + @objc(SnodeMessage) + internal final class SnodeMessage: NSObject, NSCoding { + public let recipient: String + public let data: LosslessStringConvertible + public let ttl: UInt64 + public let timestamp: UInt64 // Milliseconds - public required init?(coder: NSCoder) { - if let displayName = coder.decodeObject(forKey: "displayName") as! String? { self.displayName = displayName } - if let profileKey = coder.decodeObject(forKey: "profileKey") as! Data? { self.profileKey = profileKey } - if let profilePictureURL = coder.decodeObject(forKey: "profilePictureURL") as! String? { self.profilePictureURL = profilePictureURL } - } - - public func encode(with coder: NSCoder) { - coder.encode(displayName, forKey: "displayName") - coder.encode(profileKey, forKey: "profileKey") - coder.encode(profilePictureURL, forKey: "profilePictureURL") - } - - public static func fromProto(_ proto: SNProtoDataMessage) -> Profile? { - guard let profileProto = proto.profile, let displayName = profileProto.displayName else { return nil } - let profileKey = proto.profileKey - let profilePictureURL = profileProto.profilePicture - if let profileKey = profileKey, let profilePictureURL = profilePictureURL { - return Profile(displayName: displayName, profileKey: profileKey, profilePictureURL: profilePictureURL) - } else { - return Profile(displayName: displayName) + // MARK: - Coding + + public init?(coder: NSCoder) { + guard + let recipient = coder.decodeObject(forKey: "recipient") as! String?, + let data = coder.decodeObject(forKey: "data") as! String?, + let ttl = coder.decodeObject(forKey: "ttl") as! UInt64?, + let timestamp = coder.decodeObject(forKey: "timestamp") as! UInt64? + else { return nil } + + self.recipient = recipient + self.data = data + self.ttl = ttl + self.timestamp = timestamp + + super.init() } - } - public func toProto() -> SNProtoDataMessage? { - guard let displayName = displayName else { - SNLog("Couldn't construct profile proto from: \(self).") - return nil - } - let dataMessageProto = SNProtoDataMessage.builder() - let profileProto = SNProtoDataMessageLokiProfile.builder() - profileProto.setDisplayName(displayName) - if let profileKey = profileKey, let profilePictureURL = profilePictureURL { - dataMessageProto.setProfileKey(profileKey) - profileProto.setProfilePicture(profilePictureURL) - } - do { - dataMessageProto.setProfile(try profileProto.build()) - return try dataMessageProto.build() - } catch { - SNLog("Couldn't construct profile proto from: \(self).") - return nil + public func encode(with coder: NSCoder) { + coder.encode(recipient, forKey: "recipient") + coder.encode(data, forKey: "data") + coder.encode(ttl, forKey: "ttl") + coder.encode(timestamp, forKey: "timestamp") } } - // MARK: Description - public override var description: String { - """ - Profile( - displayName: \(displayName ?? "null"), - profileKey: \(profileKey?.description ?? "null"), - profilePictureURL: \(profilePictureURL ?? "null") - ) - """ + public let message: SnodeMessage + public var id: String? + public var failureCount: UInt = 0 + + // MARK: - Coding + + public init?(coder: NSCoder) { + guard + let message = coder.decodeObject(forKey: "message") as! SnodeMessage?, + let id = coder.decodeObject(forKey: "id") as! String? + else { return nil } + + self.message = message + self.id = id + self.failureCount = ((coder.decodeObject(forKey: "failureCount") as? UInt) ?? 0) + } + + public func encode(with coder: NSCoder) { + coder.encode(message, forKey: "message") + coder.encode(id, forKey: "id") + coder.encode(failureCount, forKey: "failureCount") } } + + @objc(MessageReceiveJob) + public final class MessageReceiveJob: NSObject, NSCoding { + public let data: Data + public let serverHash: String? + public let openGroupMessageServerID: UInt64? + public let openGroupID: String? + public let isBackgroundPoll: Bool + public var id: String? + public var failureCount: UInt = 0 + + // MARK: - Coding + + public init?(coder: NSCoder) { + guard + let data = coder.decodeObject(forKey: "data") as! Data?, + let id = coder.decodeObject(forKey: "id") as! String?, + let isBackgroundPoll = coder.decodeObject(forKey: "isBackgroundPoll") as! Bool? + else { return nil } + + self.data = data + self.serverHash = coder.decodeObject(forKey: "serverHash") as! String? + self.openGroupMessageServerID = coder.decodeObject(forKey: "openGroupMessageServerID") as! UInt64? + self.openGroupID = coder.decodeObject(forKey: "openGroupID") as! String? + self.isBackgroundPoll = isBackgroundPoll + self.id = id + self.failureCount = ((coder.decodeObject(forKey: "failureCount") as? UInt) ?? 0) + } + + public func encode(with coder: NSCoder) { + coder.encode(data, forKey: "data") + coder.encode(serverHash, forKey: "serverHash") + coder.encode(openGroupMessageServerID, forKey: "openGroupMessageServerID") + coder.encode(openGroupID, forKey: "openGroupID") + coder.encode(isBackgroundPoll, forKey: "isBackgroundPoll") + coder.encode(id, forKey: "id") + coder.encode(failureCount, forKey: "failureCount") + } + } + + @objc(SNMessageSendJob) + public final class MessageSendJob: NSObject, NSCoding { + public let message: Message + public let destination: Message.Destination + public var id: String? + public var failureCount: UInt = 0 + + // MARK: - Coding + + public init?(coder: NSCoder) { + guard let message = coder.decodeObject(forKey: "message") as! Message?, + let rawDestination = coder.decodeObject(forKey: "destination") as! String?, + let id = coder.decodeObject(forKey: "id") as! String? + else { return nil } + + self.message = message + + if let destString: String = MessageSendJob.process(rawDestination, type: "contact") { + destination = .contact(publicKey: destString) + } + else if let destString: String = MessageSendJob.process(rawDestination, type: "closedGroup") { + destination = .closedGroup(groupPublicKey: destString) + } + else if let destString: String = MessageSendJob.process(rawDestination, type: "openGroup") { + let components = destString + .split(separator: ",") + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + + guard components.count == 2, let channel = UInt64(components[0]) else { return nil } + + let server = components[1] + destination = .openGroup(channel: channel, server: server) + } + else if let destString: String = MessageSendJob.process(rawDestination, type: "openGroupV2") { + let components = destString + .split(separator: ",") + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + + guard components.count == 2 else { return nil } + + let room = components[0] + let server = components[1] + destination = .openGroupV2(room: room, server: server) + } + else { + return nil + } + + self.id = id + self.failureCount = ((coder.decodeObject(forKey: "failureCount") as? UInt) ?? 0) + } + + public func encode(with coder: NSCoder) { + coder.encode(message, forKey: "message") + + switch destination { + case .contact(let publicKey): + coder.encode("contact(\(publicKey))", forKey: "destination") + + case .closedGroup(let groupPublicKey): + coder.encode("closedGroup(\(groupPublicKey))", forKey: "destination") + + case .openGroup(let channel, let server): + coder.encode("openGroup(\(channel), \(server))", forKey: "destination") + + case .openGroupV2(let room, let server): + coder.encode("openGroupV2(\(room), \(server))", forKey: "destination") + } + + coder.encode(id, forKey: "id") + coder.encode(failureCount, forKey: "failureCount") + } + + // MARK: - Convenience + + private static func process(_ value: String, type: String) -> String? { + guard value.hasPrefix("\(type)(") else { return nil } + guard value.hasSuffix(")") else { return nil } + + var updatedValue: String = value + updatedValue.removeFirst("\(type)(".count) + updatedValue.removeLast(")".count) + + return updatedValue + } + } + + @objc(AttachmentUploadJob) + public final class AttachmentUploadJob: NSObject, NSCoding { + public let attachmentID: String + public let threadID: String + public let message: Message + public let messageSendJobID: String + public var id: String? + public var failureCount: UInt = 0 + + // MARK: - Coding + + public init?(coder: NSCoder) { + guard + let attachmentID = coder.decodeObject(forKey: "attachmentID") as! String?, + let threadID = coder.decodeObject(forKey: "threadID") as! String?, + let message = coder.decodeObject(forKey: "message") as! Message?, + let messageSendJobID = coder.decodeObject(forKey: "messageSendJobID") as! String?, + let id = coder.decodeObject(forKey: "id") as! String? + else { return nil } + + self.attachmentID = attachmentID + self.threadID = threadID + self.message = message + self.messageSendJobID = messageSendJobID + self.id = id + self.failureCount = coder.decodeObject(forKey: "failureCount") as! UInt? ?? 0 + } + + public func encode(with coder: NSCoder) { + coder.encode(attachmentID, forKey: "attachmentID") + coder.encode(threadID, forKey: "threadID") + coder.encode(message, forKey: "message") + coder.encode(messageSendJobID, forKey: "messageSendJobID") + coder.encode(id, forKey: "id") + coder.encode(failureCount, forKey: "failureCount") + } + } + + @objc(AttachmentDownloadJob) + public final class AttachmentDownloadJob: NSObject, NSCoding { + public let attachmentID: String + public let tsMessageID: String + public let threadID: String + public var id: String? + public var failureCount: UInt = 0 + public var isDeferred = false + + // MARK: - Coding + + public init?(coder: NSCoder) { + guard + let attachmentID = coder.decodeObject(forKey: "attachmentID") as! String?, + let tsMessageID = coder.decodeObject(forKey: "tsIncomingMessageID") as! String?, + let threadID = coder.decodeObject(forKey: "threadID") as! String?, + let id = coder.decodeObject(forKey: "id") as! String? + else { return nil } + + self.attachmentID = attachmentID + self.tsMessageID = tsMessageID + self.threadID = threadID + self.id = id + self.failureCount = coder.decodeObject(forKey: "failureCount") as! UInt? ?? 0 + self.isDeferred = coder.decodeBool(forKey: "isDeferred") + } + + public func encode(with coder: NSCoder) { + coder.encode(attachmentID, forKey: "attachmentID") + coder.encode(tsMessageID, forKey: "tsIncomingMessageID") + coder.encode(threadID, forKey: "threadID") + coder.encode(id, forKey: "id") + coder.encode(failureCount, forKey: "failureCount") + coder.encode(isDeferred, forKey: "isDeferred") + } + } +} + +@objc(SNJob) +public protocol _LegacyJob : NSCoding { + var id: String? { get set } + var failureCount: UInt { get set } + + static var collection: String { get } + static var maxFailureCount: UInt { get } + + func execute() } // Note: Looks like Swift doesn't expose nested types well (in the `-Swift` header this was diff --git a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift index 7aa21f300..4a096cf4a 100644 --- a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -52,6 +52,7 @@ enum _001_InitialSetupMigration: Migration { t.column(.notificationMode, .integer) .notNull() .defaults(to: SessionThread.NotificationMode.all) + t.column(.notificationSound, .integer) t.column(.mutedUntilTimestamp, .double) } @@ -139,10 +140,14 @@ enum _001_InitialSetupMigration: Migration { t.column(.variant, .integer).notNull() t.column(.body, .text) - t.column(.timestampMs, .double) + t.column(.timestampMs, .integer) .notNull() .indexed() // Quicker querying - t.column(.receivedAtTimestampMs, .double).notNull() + t.column(.receivedAtTimestampMs, .integer).notNull() + t.column(.wasRead, .boolean) + .notNull() + .indexed() // Quicker querying + .defaults(to: false) t.column(.expiresInSeconds, .double) t.column(.expiresStartedAtMs, .double) t.column(.linkPreviewUrl, .text) @@ -154,20 +159,26 @@ enum _001_InitialSetupMigration: Migration { .defaults(to: false) t.column(.openGroupWhisperTo, .text) - // Null is not unique in SQLite which allows us to do this and we do - // a joint constraint with the `threadId` on the off chance there is - // a collision between different hashes on different servers - t.uniqueKey([.threadId, .serverHash]) - - // The `openGroupServerMessageId` is unique on a per-thread basis + /// Note: The below unique constraints are added to prevent messages being duplicated, we need + /// multiple constraints because `null` is not unique in SQLite which means any unique constraint + /// which contained a nullable column would not be seen as unique if the value is null (this is good to + /// avoid outgoing message from conflicting due to not having a `serverHash` but bad when different + /// columns are only unique in certain circumstances) + /// + /// The values have the following behaviours: + /// + /// Threads with variants: [`contact`, `closedGroup`]: + /// `threadId` - Unique per thread + /// `serverHash` - Unique per message for service-node-based messages + /// **Note:** Some InfoMessage's will have this intentionally left `null` + /// as we want to ignore any collisions and re-process them + /// `timestampMs` - Very low chance of collision (especially combined with other two) + /// + /// Threads with variants: [`openGroup`]: + /// `threadId` - Unique per thread + /// `openGroupServerMessageId` - Unique for VisibleMessage's on an OpenGroup server + t.uniqueKey([.threadId, .serverHash, .timestampMs]) t.uniqueKey([.threadId, .openGroupServerMessageId]) - - // Note: The timestamp will be unique on a per-message basis so we - // need to add the below unique constraint to handle cases where - // the `serverHash` and `openGroupServerMessageId` can both be null - // to try and prevent duplicate messages (it's theoretically possible - // to get a collision with this constraint but is astronomically unlikely) - t.uniqueKey([.threadId, .serverHash, .openGroupServerMessageId, .timestampMs]) } try db.create(table: RecipientState.self) { t in @@ -177,47 +188,28 @@ enum _001_InitialSetupMigration: Migration { .references(Interaction.self, onDelete: .cascade) // Delete if interaction deleted t.column(.recipientId, .text) .notNull() + .indexed() // Quicker querying .references(Profile.self) - t.column(.state, .integer).notNull() + t.column(.state, .integer) + .notNull() + .indexed() // Quicker querying t.column(.readTimestampMs, .double) + t.column(.mostRecentFailureText, .text) // We want to ensure that a recipient can only have a single state for // each interaction - t.uniqueKey([.interactionId, .recipientId]) - } - - try db.create(table: Quote.self) { t in - t.column(.interactionId, .integer) - .notNull() - .primaryKey() - .references(Interaction.self, onDelete: .cascade) // Delete if interaction deleted - t.column(.authorId, .text) - .notNull() - .references(Profile.self) - t.column(.timestampMs, .double).notNull() - t.column(.body, .text) - } - - try db.create(table: LinkPreview.self) { t in - t.column(.url, .text) - .notNull() - .indexed() // Quicker querying - t.column(.timestamp, .double) - .notNull() - .indexed() // Quicker querying - t.column(.variant, .integer).notNull() - t.column(.title, .text) - - t.primaryKey([.url, .timestamp]) + t.primaryKey([.interactionId, .recipientId]) } try db.create(table: Attachment.self) { t in - t.column(.interactionId, .integer) - .indexed() // Quicker querying - .references(Interaction.self, onDelete: .cascade) // Delete if interaction deleted + t.column(.id, .text) + .notNull() + .primaryKey() t.column(.serverId, .text) t.column(.variant, .integer).notNull() - t.column(.state, .integer).notNull() + t.column(.state, .integer) + .notNull() + .indexed() // Quicker querying t.column(.contentType, .text).notNull() t.column(.byteCount, .integer) .notNull() @@ -231,5 +223,75 @@ enum _001_InitialSetupMigration: Migration { t.column(.digest, .blob) t.column(.caption, .text) } + + try db.create(table: InteractionAttachment.self) { t in + t.column(.interactionId, .integer) + .notNull() + .indexed() // Quicker querying + .references(Interaction.self, onDelete: .cascade) // Delete if interaction deleted + t.column(.attachmentId, .text) + .notNull() + .indexed() // Quicker querying + .references(Attachment.self, onDelete: .cascade) // Delete if attachment deleted + } + + try db.create(table: Quote.self) { t in + t.column(.interactionId, .integer) + .notNull() + .primaryKey() + .references(Interaction.self, onDelete: .cascade) // Delete if interaction deleted + t.column(.authorId, .text) + .notNull() + .references(Profile.self) + t.column(.timestampMs, .double).notNull() + t.column(.body, .text) + t.column(.attachmentId, .text) + .references(Attachment.self, onDelete: .setNull) // Clear if attachment deleted + } + + try db.create(table: LinkPreview.self) { t in + t.column(.url, .text) + .notNull() + .indexed() // Quicker querying + t.column(.timestamp, .double) + .notNull() + .indexed() // Quicker querying + t.column(.variant, .integer).notNull() + t.column(.title, .text) + t.column(.attachmentId, .text) + .references(Attachment.self, onDelete: .setNull) // Clear if attachment deleted + + t.primaryKey([.url, .timestamp]) + } + + try db.create(table: ControlMessageProcessRecord.self) { t in + t.column(.threadId, .text).notNull() + t.column(.sentTimestampMs, .integer).notNull() + t.column(.serverHash, .text).notNull() + t.column(.openGroupMessageServerId, .integer).notNull() + + t.uniqueKey([.threadId, .sentTimestampMs, .serverHash, .openGroupMessageServerId]) + } + + try db.create(table: Job.self) { t in + t.column(.id, .integer) + .notNull() + .primaryKey(autoincrement: true) + t.column(.failureCount, .integer) + .notNull() + .defaults(to: 0) + t.column(.variant, .integer) + .notNull() + .indexed() // Quicker querying + t.column(.behaviour, .integer).notNull() // TODO: Indexed??? + t.column(.nextRunTimestamp, .double) + .notNull() // TODO: Should this just be nullable??? (or do we want to fetch by this?) + .indexed() // Quicker querying + .defaults(to: 0) + t.column(.threadId, .text) + .indexed() // Quicker querying + .references(SessionThread.self, onDelete: .cascade) // Delete if thread deleted + t.column(.details, .blob) + } } } diff --git a/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift new file mode 100644 index 000000000..000b36cf0 --- /dev/null +++ b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -0,0 +1,49 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import Curve25519Kit +import SessionUtilitiesKit +import SessionSnodeKit + +/// This migration sets up the standard jobs, since we want these jobs to run before any "once-off" jobs we do this migration +/// before running the `YDBToGRDBMigration` +enum _002_SetupStandardJobs: Migration { + static let identifier: String = "SetupStandardJobs" + + static func migrate(_ db: Database) throws { + // Start by adding the jobs that don't have collections (in the jobs like these + // will be added via migrations) + try autoreleasepool { + // TODO: Add additional jobs from the AppDelegate + _ = try Job( + failureCount: 0, + variant: .disappearingMessages, + behaviour: .recurringOnLaunch, + nextRunTimestamp: 0 + ).inserted(db) + + _ = try Job( + failureCount: 0, + variant: .failedMessages, + behaviour: .recurringOnLaunch, + nextRunTimestamp: 0 + ).inserted(db) + + _ = try Job( + failureCount: 0, + variant: .failedAttachmentDownloads, + behaviour: .recurringOnLaunch, + nextRunTimestamp: 0 + ).inserted(db) + + // Note: This job exists in the 'Session' target but that doesn't have it's own migrations + _ = try Job( + failureCount: 0, + variant: .syncPushTokens, + behaviour: .recurringOnLaunch, + nextRunTimestamp: 0 + ).inserted(db) + } + } +} diff --git a/SessionMessagingKit/Database/Migrations/_002_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift similarity index 52% rename from SessionMessagingKit/Database/Migrations/_002_YDBToGRDBMigration.swift rename to SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index b19a38276..7868ba662 100644 --- a/SessionMessagingKit/Database/Migrations/_002_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -4,13 +4,15 @@ import Foundation import GRDB import Curve25519Kit import SessionUtilitiesKit +import SessionSnodeKit -enum _002_YDBToGRDBMigration: Migration { +// Note: Looks like the oldest iOS device we support (min iOS 13.0) has 2Gb of RAM, processing +// ~250k messages and ~1000 threads seems to take up +enum _003_YDBToGRDBMigration: Migration { static let identifier: String = "YDBToGRDBMigration" - // TODO: Autorelease pool???. static func migrate(_ db: Database) throws { - // MARK: - Contacts & Threads + // MARK: - Process Contacts, Threads & Interactions var shouldFailMigration: Bool = false var contacts: Set = [] @@ -30,10 +32,11 @@ enum _002_YDBToGRDBMigration: Migration { var openGroupImage: [String: Data] = [:] var openGroupLastMessageServerId: [String: Int64] = [:] // Optional var openGroupLastDeletionServerId: [String: Int64] = [:] // Optional +// var openGroupServerToUniqueIdLookup: [String: [String]] = [:] // TODO: Not needed???? var interactions: [String: [TSInteraction]] = [:] var attachments: [String: TSAttachment] = [:] - var readReceipts: [String: [Double]] = [:] + var outgoingReadReceiptsTimestampsMs: [String: Set] = [:] Storage.read { transaction in // Process the Contacts @@ -85,6 +88,7 @@ enum _002_YDBToGRDBMigration: Migration { return } guard userClosedGroupPublicKeys.contains(publicKey) else { + // TODO: Determine if we want to remove this SNLog("[Migration Error] Found unexpected invalid closed group public key") shouldFailMigration = true return @@ -151,7 +155,109 @@ enum _002_YDBToGRDBMigration: Migration { } print("RAWR [\(Date().timeIntervalSince1970)] - Process attachments - End") + // Process read receipts + transaction.enumerateKeysAndObjects(inCollection: Legacy.outgoingReadReceiptManagerCollection) { key, object, _ in + guard let timestampsMs: Set = object as? Set else { return } + + outgoingReadReceiptsTimestampsMs[key] = (outgoingReadReceiptsTimestampsMs[key] ?? Set()) + .union(timestampsMs) } + + /* + guard let view = transaction.ext(viewName) as? YapDatabaseAutoViewTransaction else { + owsFailDebug("Could not load view.") + return + } + guard let group = group else { + owsFailDebug("No group.") + return + } + + // Deserializing interactions is expensive, so we only + // do that when necessary. + let sortIdForItemId: (String) -> UInt64? = { (itemId) in + guard let interaction = TSInteraction.fetch(uniqueId: itemId, transaction: transaction) else { + owsFailDebug("Could not load interaction.") + return nil + } + return interaction.sortId + } + self.viewName = TSMessageDatabaseViewExtensionName + self.group = group + // If we have a "pivot", load all items AFTER the pivot and up to minDesiredLength items BEFORE the pivot. + // If we do not have a "pivot", load up to minDesiredLength BEFORE the pivot. + var newItemIds = [ItemId]() + var canLoadMore = false + let desiredLength = self.desiredLength + // Not all items "count" towards the desired length. On an initial load, all items count. Subsequently, + // only items above the pivot count. + var afterPivotCount: UInt = 0 + var beforePivotCount: UInt = 0 + // (void (^)(NSString *collection, NSString *key, id object, NSUInteger index, BOOL *stop))block; + view.enumerateKeys(inGroup: group, with: NSEnumerationOptions.reverse) { (_, key, _, stop) in + let itemId = key + + // Load "uncounted" items after the pivot if possible. + // + // As an optimization, we can skip this check (which requires + // deserializing the interaction) if beforePivotCount is non-zero, + // e.g. after we "pass" the pivot. + if beforePivotCount == 0, + let pivotSortId = self.pivotSortId { + if let sortId = sortIdForItemId(itemId) { + let isAfterPivot = sortId > pivotSortId + if isAfterPivot { + newItemIds.append(itemId) + afterPivotCount += 1 + return + } + } else { + owsFailDebug("Could not determine sort id for interaction: \(itemId)") + } + } + + // Load "counted" items unless the load window overflows. + if beforePivotCount >= desiredLength { + // Overflow + canLoadMore = true + stop.pointee = true + } else { + newItemIds.append(itemId) + beforePivotCount += 1 + } + } + NSMutableSet *interactionIds = [NSMutableSet new]; + [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { + NSMutableArray *interactions = [NSMutableArray new]; + + YapDatabaseViewTransaction *viewTransaction = [transaction ext:TSMessageDatabaseViewExtensionName]; + OWSAssertDebug(viewTransaction); + for (NSString *uniqueId in loadedUniqueIds) { + TSInteraction *_Nullable interaction = + [TSInteraction fetchObjectWithUniqueID:uniqueId transaction:transaction]; + if (!interaction) { + OWSFailDebug(@"missing interaction in message mapping: %@.", uniqueId); + hasError = YES; + continue; + } + if (!interaction.uniqueId) { + OWSFailDebug(@"invalid interaction in message mapping: %@.", interaction); + hasError = YES; + continue; + } + [interactions addObject:interaction]; + if ([interactionIds containsObject:interaction.uniqueId]) { + OWSFailDebug(@"Duplicate interaction: %@", interaction.uniqueId); + continue; + } + [interactionIds addObject:interaction.uniqueId]; + } + + for (TSInteraction *interaction in interactions) { + tryToAddViewItem(interaction, transaction); + } + }]; + */ } // We can't properly throw within the 'enumerateKeysAndObjects' block so have to throw here @@ -168,6 +274,7 @@ enum _002_YDBToGRDBMigration: Migration { let isCurrentUser: Bool = (contact.sessionID == currentUserPublicKey) let contactThreadId: String = TSContactThread.threadID(fromContactSessionID: contact.sessionID) + // TODO: Contact 'hasOne' profile??? // Create the "Profile" for the legacy contact try Profile( id: contact.sessionID, @@ -188,6 +295,7 @@ enum _002_YDBToGRDBMigration: Migration { contact.isBlocked || contact.hasBeenBlocked { // Create the contact + // TODO: Closed group admins??? try Contact( id: contact.sessionID, isTrusted: (isCurrentUser || contact.isTrusted), @@ -203,6 +311,29 @@ enum _002_YDBToGRDBMigration: Migration { // MARK: - Insert Threads print("RAWR [\(Date().timeIntervalSince1970)] - Process thread inserts - Start") + var legacyThreadIdToIdMap: [String: String] = [:] + var legacyInteractionToIdMap: [String: Int64] = [:] + var legacyInteractionIdentifierToIdMap: [String: Int64] = [:] + + func identifier(for threadId: String, sentTimestamp: UInt64, recipients: [String], destination: Message.Destination? = nil) -> String { + let recipientString: String = { + if let destination: Message.Destination = destination { + switch destination { + case .contact(let publicKey): return publicKey + default: break + } + } + + return (recipients.first ?? "0") + }() + + return [ + "\(sentTimestamp)", + recipientString, + threadId + ] + .joined(separator: "-") + } try threads.forEach { thread in guard let legacyThreadId: String = thread.uniqueId else { return } @@ -246,6 +377,8 @@ enum _002_YDBToGRDBMigration: Migration { } try autoreleasepool { + legacyThreadIdToIdMap[thread.uniqueId ?? ""] = id + try SessionThread( id: id, variant: variant, @@ -327,11 +460,11 @@ enum _002_YDBToGRDBMigration: Migration { room: openGroup.room, publicKey: openGroup.publicKey, name: openGroup.name, - groupDescription: nil, // TODO: Add with SOGS V4 - imageId: nil, // TODO: Add with SOGS V4 + groupDescription: nil, // TODO: Add with SOGS V4. + imageId: nil, // TODO: Add with SOGS V4. imageData: openGroupImage[legacyThreadId], userCount: (openGroupUserCount[legacyThreadId] ?? 0), // Will be updated next poll - infoUpdates: 0 // TODO: Add with SOGS V4 + infoUpdates: 0 // TODO: Add with SOGS V4. ).insert(db) } } @@ -346,10 +479,12 @@ enum _002_YDBToGRDBMigration: Migration { let variant: Interaction.Variant let authorId: String let body: String? + let wasRead: Bool let expiresInSeconds: UInt32? let expiresStartedAtMs: UInt64? let openGroupServerMessageId: UInt64? let recipientStateMap: [String: TSOutgoingMessageRecipientState]? + let mostRecentFailureText: String? let quotedMessage: TSQuotedMessage? let linkPreview: OWSLinkPreview? let linkPreviewVariant: LinkPreview.Variant @@ -414,18 +549,22 @@ enum _002_YDBToGRDBMigration: Migration { ) authorId = incomingMessage.authorId body = incomingMessage.body + wasRead = incomingMessage.wasRead expiresInSeconds = incomingMessage.expiresInSeconds expiresStartedAtMs = incomingMessage.expireStartedAt recipientStateMap = [:] + mostRecentFailureText = nil case let outgoingMessage as TSOutgoingMessage: variant = .standardOutgoing authorId = currentUserPublicKey body = outgoingMessage.body + wasRead = true // Outgoing messages are read by default expiresInSeconds = outgoingMessage.expiresInSeconds expiresStartedAtMs = outgoingMessage.expireStartedAt recipientStateMap = outgoingMessage.recipientStateMap + mostRecentFailureText = outgoingMessage.mostRecentFailureText case let infoMessage as TSInfoMessage: authorId = currentUserPublicKey @@ -433,9 +572,11 @@ enum _002_YDBToGRDBMigration: Migration { infoMessage.customMessage : infoMessage.body ) + wasRead = infoMessage.wasRead expiresInSeconds = nil // Info messages don't expire expiresStartedAtMs = nil // Info messages don't expire recipientStateMap = [:] + mostRecentFailureText = nil switch infoMessage.messageType { case .groupCreated: variant = .infoClosedGroupCreated @@ -452,34 +593,48 @@ enum _002_YDBToGRDBMigration: Migration { } default: + // TODO: What message types have no body? SNLog("[Migration Error] Unsupported interaction type") throw GRDBStorageError.migrationFailed } // Insert the data - let interaction = try Interaction( + let interaction: Interaction = try Interaction( serverHash: serverHash, threadId: id, authorId: authorId, variant: variant, body: body, - timestampMs: Double(legacyInteraction.timestamp), - receivedAtTimestampMs: Double(legacyInteraction.receivedAtTimestamp), + timestampMs: Int64(legacyInteraction.timestamp), + receivedAtTimestampMs: Int64(legacyInteraction.receivedAtTimestamp), + wasRead: wasRead, expiresInSeconds: expiresInSeconds.map { TimeInterval($0) }, expiresStartedAtMs: expiresStartedAtMs.map { Double($0) }, linkPreviewUrl: linkPreview?.urlString, // Only a soft link so save to set openGroupServerMessageId: openGroupServerMessageId.map { Int64($0) }, - openGroupWhisperMods: false, // TODO: This - openGroupWhisperTo: nil // TODO: This + openGroupWhisperMods: false, // TODO: This in SOGSV4 + openGroupWhisperTo: nil // TODO: This in SOGSV4 ).inserted(db) guard let interactionId: Int64 = interaction.id else { + // TODO: Is it possible the old database has duplicates which could hit this case? SNLog("[Migration Error] Failed to insert interaction") throw GRDBStorageError.migrationFailed } + // Store the interactionId in the lookup map to simplify job creation later + let legacyIdentifier: String = identifier( + for: legacyInteraction.uniqueThreadId, + sentTimestamp: legacyInteraction.timestamp, + recipients: ((legacyInteraction as? TSOutgoingMessage)?.recipientIds() ?? []) + ) + legacyInteractionToIdMap[legacyInteraction.uniqueId ?? ""] = interactionId + legacyInteractionIdentifierToIdMap[legacyIdentifier] = interactionId + // Handle the recipient states + // Note: Inserting an Interaction into the database will automatically create a 'RecipientState' + // for outgoing messages try recipientStateMap?.forEach { recipientId, legacyState in try RecipientState( interactionId: interactionId, @@ -493,26 +648,19 @@ enum _002_YDBToGRDBMigration: Migration { @unknown default: throw GRDBStorageError.migrationFailed } }(), - readTimestampMs: legacyState.readTimestamp?.doubleValue - ).insert(db) + readTimestampMs: legacyState.readTimestamp?.int64Value, + mostRecentFailureText: (legacyState.state == .failed ? + mostRecentFailureText : + nil + ) + ).save(db) } // Handle any quote if let quotedMessage: TSQuotedMessage = quotedMessage { - try Quote( - interactionId: interactionId, - authorId: quotedMessage.authorId, - timestampMs: Double(quotedMessage.timestamp), - body: quotedMessage.body - ).insert(db) - - // Ensure the quote thumbnail works properly - - - // Note: Quote attachments are now attached directly to the interaction - attachmentIds = attachmentIds.appending( - contentsOf: quotedMessage.quotedAttachments.compactMap { attachmentInfo in + let quoteAttachmentId: String? = quotedMessage.quotedAttachments + .compactMap { attachmentInfo in if let attachmentId: String = attachmentInfo.attachmentId { return attachmentId } @@ -522,7 +670,21 @@ enum _002_YDBToGRDBMigration: Migration { // TODO: Looks like some of these might be busted??? return attachmentInfo.thumbnailAttachmentStreamId } - ) + .first { attachments[$0] != nil } + + guard quotedMessage.quotedAttachments.isEmpty || quoteAttachmentId != nil else { + // TODO: Is it possible to hit this case if a quoted attachment hasn't been downloaded? + SNLog("[Migration Error] Missing quote attachment") + throw GRDBStorageError.migrationFailed + } + + try Quote( + interactionId: interactionId, + authorId: quotedMessage.authorId, + timestampMs: Int64(quotedMessage.timestamp), + body: quotedMessage.body, + attachmentId: try attachmentId(db, for: quoteAttachmentId, attachments: attachments) + ).insert(db) } // Handle any LinkPreview @@ -531,56 +693,370 @@ enum _002_YDBToGRDBMigration: Migration { // Note: The `legacyInteraction.timestamp` value is in milliseconds let timestamp: TimeInterval = LinkPreview.timestampFor(sentTimestampMs: Double(legacyInteraction.timestamp)) + guard linkPreview.imageAttachmentId == nil || attachments[linkPreview.imageAttachmentId ?? ""] != nil else { + // TODO: Is it possible to hit this case if a quoted attachment hasn't been downloaded? + SNLog("[Migration Error] Missing link preview attachment") + throw GRDBStorageError.migrationFailed + } + // Note: It's possible for there to be duplicate values here so we use 'save' // instead of insert (ie. upsert) try LinkPreview( url: urlString, timestamp: timestamp, variant: linkPreviewVariant, - title: linkPreview.title + title: linkPreview.title, + attachmentId: try attachmentId(db, for: linkPreview.imageAttachmentId, attachments: attachments) ).save(db) - - // Note: LinkPreview attachments are now attached directly to the interaction - attachmentIds = attachmentIds.appending(linkPreview.imageAttachmentId) } // Handle any attachments - try attachmentIds.forEach { attachmentId in - guard let attachment: TSAttachment = attachments[attachmentId] else { - SNLog("[Migration Error] Unsupported interaction type") + + print("ASD \(attachmentIds)") + try attachmentIds.forEach { legacyAttachmentId in + guard let attachmentId: String = try attachmentId(db, for: legacyAttachmentId, interactionVariant: variant, attachments: attachments) else { + // TODO: Is it possible to hit this case if an interaction hasn't been viewed? + SNLog("[Migration Error] Missing interaction attachment") throw GRDBStorageError.migrationFailed } - let size: CGSize = { - switch attachment { - case let stream as TSAttachmentStream: return stream.calculateImageSize() - case let pointer as TSAttachmentPointer: return pointer.mediaSize - default: return CGSize.zero - } - }() - try Attachment( + try InteractionAttachment( interactionId: interactionId, - serverId: "\(attachment.serverId)", - variant: (attachment.isVoiceMessage ? .voiceMessage : .standard), - state: .pending, // TODO: This - contentType: attachment.contentType, - byteCount: UInt(attachment.byteCount), - creationTimestamp: (attachment as? TSAttachmentStream)?.creationTimestamp.timeIntervalSince1970, - sourceFilename: attachment.sourceFilename, - downloadUrl: attachment.downloadURL, - width: (size == .zero ? nil : UInt(size.width)), - height: (size == .zero ? nil : UInt(size.height)), - encryptionKey: attachment.encryptionKey, - digest: (attachment as? TSAttachmentStream)?.digest, - caption: attachment.caption + attachmentId: attachmentId ).insert(db) } } } } + // Clear out processed data (give the memory a change to be freed) + + contacts = [] + contactThreadIds = [] + + threads = [] + disappearingMessagesConfiguration = [:] + + closedGroupKeys = [:] + closedGroupName = [:] + closedGroupFormation = [:] + closedGroupModel = [:] + closedGroupZombieMemberIds = [:] + + openGroupInfo = [:] + openGroupUserCount = [:] + openGroupImage = [:] + openGroupLastMessageServerId = [:] + openGroupLastDeletionServerId = [:] + + interactions = [:] + attachments = [:] + + // MARK: - Process Legacy Jobs + + var notifyPushServerJobs: Set = [] + var messageReceiveJobs: Set = [] + var messageSendJobs: Set = [] + var attachmentUploadJobs: Set = [] + var attachmentDownloadJobs: Set = [] + + Storage.read { transaction in + transaction.enumerateRows(inCollection: Legacy.notifyPushServerJobCollection) { _, object, _, _ in + guard let job = object as? Legacy.NotifyPNServerJob else { return } + notifyPushServerJobs.insert(job) + } + + transaction.enumerateRows(inCollection: Legacy.messageReceiveJobCollection) { _, object, _, _ in + guard let job = object as? Legacy.MessageReceiveJob else { return } + messageReceiveJobs.insert(job) + } + + transaction.enumerateRows(inCollection: Legacy.messageSendJobCollection) { _, object, _, _ in + guard let job = object as? Legacy.MessageSendJob else { return } + messageSendJobs.insert(job) + } + + transaction.enumerateRows(inCollection: Legacy.attachmentUploadJobCollection) { _, object, _, _ in + guard let job = object as? Legacy.AttachmentUploadJob else { return } + attachmentUploadJobs.insert(job) + } + + transaction.enumerateRows(inCollection: Legacy.attachmentDownloadJobCollection) { _, object, _, _ in + guard let job = object as? Legacy.AttachmentDownloadJob else { return } + attachmentDownloadJobs.insert(job) + } + } + + // MARK: - Insert Jobs + + // MARK: - --notifyPushServer + + try autoreleasepool { + try notifyPushServerJobs.forEach { legacyJob in + _ = try Job( + failureCount: legacyJob.failureCount, + variant: .notifyPushServer, + behaviour: .runOnce, + nextRunTimestamp: 0, + details: String( + data: try JSONEncoder().encode( + SnodeMessage( + recipient: legacyJob.message.recipient, + data: legacyJob.message.data.description, // TODO: Test this (looks like it should be fine) + ttl: legacyJob.message.ttl, + timestampMs: legacyJob.message.timestamp + ) + ), + encoding: .utf8 + ) + )?.inserted(db) + } + } + + // MARK: - --messageReceive + + try autoreleasepool { + try messageReceiveJobs.forEach { legacyJob in + // We haven't supported OpenGroup messageReceive jobs for a long time so if + // we see any then just ignore them + if legacyJob.openGroupID != nil && legacyJob.openGroupMessageServerID != nil { + return + } + + _ = try Job( + failureCount: legacyJob.failureCount, + variant: .messageReceive, + behaviour: .runOnce, + nextRunTimestamp: 0, + details: String( + data: try JSONEncoder().encode( + MessageReceiveJob.Details( + data: legacyJob.data, + serverHash: legacyJob.serverHash, + isBackgroundPoll: legacyJob.isBackgroundPoll + ) + ), + encoding: .utf8 + ) + )?.inserted(db) + } + } + + // MARK: - --messageSend + + var messageSendJobIdMap: [String: Int64] = [:] + + try autoreleasepool { + try messageSendJobs.forEach { legacyJob in + let legacyIdentifier: String = identifier( + for: (legacyJob.message.threadID ?? ""), + sentTimestamp: (legacyJob.message.sentTimestamp ?? 0), + recipients: (legacyJob.message.recipient.map { [$0] } ?? []), + destination: legacyJob.destination + ) + + // Fetch the interaction this job should be associated with + + let job: Job? = try Job( + failureCount: legacyJob.failureCount, + variant: .messageSend, + behaviour: .runOnce, + nextRunTimestamp: 0, + threadId: legacyThreadIdToIdMap[legacyJob.message.threadID ?? ""], + details: MessageSendJob.Details( + // Note: There are some cases where there isn't actually a link between the 'MessageSendJob' and + // it's associated interaction (ie. any ControlMessage), in these cases the 'interactionId' value + // will be nil + interactionId: legacyInteractionIdentifierToIdMap[legacyIdentifier], + destination: legacyJob.destination, + message: legacyJob.message + ) + )?.inserted(db) + + if let oldId: String = legacyJob.id, let newId: Int64 = job?.id { + messageSendJobIdMap[oldId] = newId + } + } + } + + // MARK: - --attachmentUpload + + try autoreleasepool { + try attachmentUploadJobs.forEach { legacyJob in + guard let sendJobId: Int64 = messageSendJobIdMap[legacyJob.messageSendJobID] else { + SNLog("[Migration Error] attachmentUpload job missing associated MessageSendJob") + throw GRDBStorageError.migrationFailed + } + + _ = try Job( + failureCount: legacyJob.failureCount, + variant: .attachmentUpload, + behaviour: .runOnce, + nextRunTimestamp: 0, + details: String( + data: try JSONEncoder().encode( + AttachmentUploadJob.Details( + threadId: legacyJob.threadID, + attachmentId: legacyJob.attachmentID, + messageSendJobId: sendJobId + ) + ), + encoding: .utf8 + ) + )?.inserted(db) + } + } + + // MARK: - --attachmentDownload + + try autoreleasepool { + try attachmentDownloadJobs.forEach { legacyJob in + guard let interactionId: Int64 = legacyInteractionToIdMap[legacyJob.tsMessageID] else { + SNLog("[Migration Error] attachmentDownload job unable to find interaction") + throw GRDBStorageError.migrationFailed + } + + _ = try Job( + failureCount: legacyJob.failureCount, + variant: .attachmentDownload, + behaviour: .runOnce, + nextRunTimestamp: 0, + details: String( + data: try JSONEncoder().encode( + AttachmentDownloadJob.Details( + threadId: legacyJob.threadID, + attachmentId: legacyJob.attachmentID + ) + ), + encoding: .utf8 + ) + )?.inserted(db) + } + } + + // MARK: - --sendReadReceipts + + try autoreleasepool { + try outgoingReadReceiptsTimestampsMs.forEach { threadId, timestampsMs in + _ = try Job( + variant: .sendReadReceipts, + behaviour: .recurring, + threadId: threadId, + details: SendReadReceiptsJob.Details( + destination: .contact(publicKey: threadId), + timestampMsValues: timestampsMs + ) + )?.inserted(db) + } + } + + // MARK: - Process Preferences + + var legacyPreferences: [String: Any] = [:] + + Storage.read { transaction in + transaction.enumerateKeysAndObjects(inCollection: Legacy.preferencesCollection) { key, object, _ in + legacyPreferences[key] = object + } + + // Note: The 'int(forKey:inCollection:)' defaults to `0` which is an incorrect value for the notification + // sound so catch it and default + let globalNotificationSoundValue: Int32 = transaction.int( + forKey: Legacy.soundsGlobalNotificationKey, + inCollection: Legacy.soundsStorageNotificationCollection + ) + legacyPreferences[Legacy.soundsGlobalNotificationKey] = (globalNotificationSoundValue > 0 ? + Int(globalNotificationSoundValue) : + Preferences.Sound.defaultNotificationSound.rawValue + ) + + legacyPreferences[Legacy.readReceiptManagerAreReadReceiptsEnabled] = (transaction.bool( + forKey: Legacy.readReceiptManagerAreReadReceiptsEnabled, + inCollection: Legacy.readReceiptManagerCollection, + defaultValue: false + ) ? 1 : 0) + + legacyPreferences[Legacy.typingIndicatorsEnabledKey] = (transaction.bool( + forKey: Legacy.typingIndicatorsEnabledKey, + inCollection: Legacy.typingIndicatorsCollection, + defaultValue: false + ) ? 1 : 0) + } + + db[.preferencesNotificationPreviewType] = Preferences.NotificationPreviewType(rawValue: legacyPreferences[Legacy.preferencesKeyNotificationPreviewType] as? Int ?? -1) + .defaulting(to: .nameAndPreview) + db[.defaultNotificationSound] = Preferences.Sound(rawValue: legacyPreferences[Legacy.soundsGlobalNotificationKey] as? Int ?? -1) + .defaulting(to: Preferences.Sound.defaultNotificationSound) + + if let lastPushToken: String = legacyPreferences[Legacy.preferencesKeyLastRecordedPushToken] as? String { + db[.lastRecordedPushToken] = lastPushToken + } + + if let lastVoipToken: String = legacyPreferences[Legacy.preferencesKeyLastRecordedVoipToken] as? String { + db[.lastRecordedVoipToken] = lastVoipToken + } + + // Note: The 'preferencesKeyScreenSecurityDisabled' value previously controlled whether the setting + // was disabled, this has been inverted to 'preferencesAppSwitcherPreviewEnabled' so it can default + // to 'false' (as most Bool values do) + db[.preferencesAppSwitcherPreviewEnabled] = (legacyPreferences[Legacy.preferencesKeyScreenSecurityDisabled] as? Bool == false) + db[.areReadReceiptsEnabled] = (legacyPreferences[Legacy.readReceiptManagerAreReadReceiptsEnabled] as? Bool == true) + db[.typingIndicatorsEnabled] = (legacyPreferences[Legacy.typingIndicatorsEnabledKey] as? Bool == true) + + db[.hasHiddenMessageRequests] = CurrentAppContext().appUserDefaults() + .bool(forKey: Legacy.userDefaultsHasHiddenMessageRequests) + print("RAWR [\(Date().timeIntervalSince1970)] - Process thread inserts - End") print("RAWR Done!!!") } + + // MARK: - Convenience + + private static func attachmentId(_ db: Database, for legacyAttachmentId: String?, interactionVariant: Interaction.Variant? = nil, attachments: [String: TSAttachment]) throws -> String? { + guard let legacyAttachmentId: String = legacyAttachmentId else { return nil } + + guard let legacyAttachment: TSAttachment = attachments[legacyAttachmentId] else { + SNLog("[Migration Error] Missing attachment") + throw GRDBStorageError.migrationFailed + } + + let state: Attachment.State = { + switch legacyAttachment { + case let stream as TSAttachmentStream: // Outgoing or already downloaded + switch interactionVariant { + case .standardOutgoing: return (stream.isUploaded ? .uploaded : .pending) + default: return .downloaded + } + + // All other cases can just be set to 'pending' + default: return .pending + } + }() + let size: CGSize = { + switch legacyAttachment { + case let stream as TSAttachmentStream: return stream.calculateImageSize() + case let pointer as TSAttachmentPointer: return pointer.mediaSize + default: return CGSize.zero + } + }() + + let attachment: Attachment = try Attachment( + serverId: "\(legacyAttachment.serverId)", + variant: (legacyAttachment.isVoiceMessage ? .voiceMessage : .standard), + state: state, + contentType: legacyAttachment.contentType, + byteCount: UInt(legacyAttachment.byteCount), + creationTimestamp: (legacyAttachment as? TSAttachmentStream)?.creationTimestamp.timeIntervalSince1970, + sourceFilename: legacyAttachment.sourceFilename, + downloadUrl: legacyAttachment.downloadURL, + width: (size == .zero ? nil : UInt(size.width)), + height: (size == .zero ? nil : UInt(size.height)), + encryptionKey: legacyAttachment.encryptionKey, + digest: (legacyAttachment as? TSAttachmentStream)?.digest, + caption: legacyAttachment.caption + ).inserted(db) + + return attachment.id + } } diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index 8672ce4a7..43f79190b 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -4,14 +4,15 @@ import Foundation import GRDB import SessionUtilitiesKit -public struct Attachment: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct Attachment: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "attachment" } - internal static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id]) - private static let interaction = belongsTo(Interaction.self, using: interactionForeignKey) + internal static let interactionAttachments = belongsTo(InteractionAttachment.self) + fileprivate static let quote = belongsTo(Quote.self) + fileprivate static let linkPreview = belongsTo(LinkPreview.self) public typealias Columns = CodingKeys - public enum CodingKeys: String, CodingKey, ColumnExpression { - case interactionId + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case id case serverId case variant case state @@ -41,8 +42,8 @@ public struct Attachment: Codable, FetchableRecord, PersistableRecord, TableReco case failed } - /// The id for the Interaction this attachment belongs to - public let interactionId: Int64? + /// A unique identifier for the attachment + public let id: String = UUID().uuidString /// The id for the attachment returned by the server /// @@ -95,9 +96,438 @@ public struct Attachment: Codable, FetchableRecord, PersistableRecord, TableReco /// Caption for the attachment public let caption: String? - // MARK: - Relationships + // MARK: - Initialization - public var interaction: QueryInterfaceRequest { - request(for: Attachment.interaction) + public init( + serverId: String? = nil, + variant: Variant, + state: State = .pending, + contentType: String, + byteCount: UInt, + creationTimestamp: TimeInterval? = nil, + sourceFilename: String? = nil, + downloadUrl: String? = nil, + width: UInt? = nil, + height: UInt? = nil, + encryptionKey: Data? = nil, + digest: Data? = nil, + caption: String? = nil + ) { + self.serverId = serverId + self.variant = variant + self.state = state + self.contentType = contentType + self.byteCount = byteCount + self.creationTimestamp = creationTimestamp + self.sourceFilename = sourceFilename + self.downloadUrl = downloadUrl + self.width = width + self.height = height + self.encryptionKey = encryptionKey + self.digest = digest + self.caption = caption + } + + public init?( + variant: Variant = .standard, + contentType: String, + dataSource: DataSource + ) { + guard + let originalFilePath: String = Attachment.originalFilePath(id: self.id, mimeType: contentType, sourceFilename: nil) + else { + return nil + } + guard dataSource.write(toPath: originalFilePath) else { return nil } + + let imageSize: CGSize? = Attachment.imageSize( + contentType: contentType, + originalFilePath: originalFilePath + ) + + self.serverId = nil + self.variant = variant + self.state = .pending + self.contentType = contentType + self.byteCount = dataSource.dataLength() + self.creationTimestamp = nil + self.sourceFilename = nil + self.downloadUrl = nil + self.width = imageSize.map { UInt(floor($0.width)) } + self.height = imageSize.map { UInt(floor($0.height)) } + self.encryptionKey = nil + self.digest = nil + self.caption = nil + } +} + +// MARK: - CustomStringConvertible + +extension Attachment: CustomStringConvertible { + public var description: String { + if MIMETypeUtil.isAudio(contentType) { + // a missing filename is the legacy way to determine if an audio attachment is + // a voice note vs. other arbitrary audio attachments. + if variant == .voiceMessage || self.sourceFilename == nil || (self.sourceFilename?.count ?? 0) == 0 { + return "🎙️ \("ATTACHMENT_TYPE_VOICE_MESSAGE".localized())" + } + } + + return "\("ATTACHMENT".localized()) \(emojiForMimeType)" + } +} + +// MARK: - Mutation + +public extension Attachment { + func with( + serverId: String? = nil, + state: State? = nil, + downloadUrl: String? = nil, + encryptionKey: Data? = nil, + digest: Data? = nil + ) -> Attachment { + return Attachment( + serverId: (serverId ?? self.serverId), + variant: variant, + state: (state ?? self.state), + contentType: contentType, + byteCount: byteCount, + creationTimestamp: creationTimestamp, + sourceFilename: sourceFilename, + downloadUrl: (downloadUrl ?? self.downloadUrl), + width: width, + height: height, + encryptionKey: (encryptionKey ?? self.encryptionKey), + digest: (digest ?? self.digest), + caption: self.caption + ) + } +} + +// MARK: - Protobuf + +public extension Attachment { + init(proto: SNProtoAttachmentPointer) { + func inferContentType(from filename: String?) -> String { + guard + let fileName: String = filename, + let fileExtension: String = URL(string: fileName)?.pathExtension + else { return OWSMimeTypeApplicationOctetStream } + + return (MIMETypeUtil.mimeType(forFileExtension: fileExtension) ?? OWSMimeTypeApplicationOctetStream) + } + + self.serverId = nil + self.variant = { + let voiceMessageFlag: Int32 = SNProtoAttachmentPointer.SNProtoAttachmentPointerFlags + .voiceMessage + .rawValue + + guard proto.hasFlags && ((proto.flags & UInt32(voiceMessageFlag)) > 0) else { + return .standard + } + + return .voiceMessage + }() + self.state = .pending + self.contentType = (proto.contentType ?? inferContentType(from: proto.fileName)) + self.byteCount = UInt(proto.size) + self.creationTimestamp = nil + self.sourceFilename = proto.fileName + self.downloadUrl = proto.url + self.width = (proto.hasWidth && proto.width > 0 ? UInt(proto.width) : nil) + self.height = (proto.hasHeight && proto.height > 0 ? UInt(proto.height) : nil) + self.encryptionKey = proto.key + self.digest = proto.digest + self.caption = (proto.hasCaption ? proto.caption : nil) + } + + func buildProto() -> SNProtoAttachmentPointer? { + guard let serverId: UInt64 = UInt64(self.serverId ?? "") else { return nil } + + let builder = SNProtoAttachmentPointer.builder(id: serverId) + builder.setContentType(contentType) + + if let sourceFilename: String = sourceFilename, !sourceFilename.isEmpty { + builder.setFileName(sourceFilename) + } + + if let caption: String = self.caption, !caption.isEmpty { + builder.setCaption(caption) + } + + builder.setSize(UInt32(byteCount)) + builder.setFlags(variant == .voiceMessage ? + UInt32(SNProtoAttachmentPointer.SNProtoAttachmentPointerFlags.voiceMessage.rawValue) : + 0 + ) + + if let encryptionKey: Data = encryptionKey, let digest: Data = digest { + builder.setKey(encryptionKey) + builder.setDigest(digest) + } + + if + let width: UInt = self.width, + let height: UInt = self.height, + width > 0, + width < Int.max, + height > 0, + height < Int.max + { + builder.setWidth(UInt32(width)) + builder.setHeight(UInt32(height)) + } + + if let downloadUrl: String = self.downloadUrl { + builder.setUrl(downloadUrl) + } + + do { + return try builder.build() + } + catch { + SNLog("Couldn't construct attachment proto from: \(self).") + return nil + } + } +} + +// MARK: - GRDB Interactions + +public extension Attachment { + static func fetchAllPendingAttachments(_ db: Database, for threadId: String) throws -> [Attachment] { + return try Attachment + .select(Attachment.Columns.allCases + [Interaction.Columns.id]) + .filter(Columns.variant == Variant.standard) + .filter(Columns.state == State.pending) + .joining( + optional: Attachment.interactionAttachments + .filter(Interaction.Columns.threadId == threadId) + ) + .joining( + optional: Attachment.quote + .joining( + required: Quote.interaction + .filter(Interaction.Columns.threadId == threadId) + ) + )//tmp.authorId + .joining( + optional: Attachment.linkPreview + .joining( + required: LinkPreview.interactions + .filter(Interaction.Columns.threadId == threadId) + ) + ) + .order(Interaction.Columns.id.desc) // Newest attachments first + .fetchAll(db) + } +} + +// MARK: - Convenience - Static + +public extension Attachment { + private static let thumbnailDimensionSmall: UInt = 200 + private static let thumbnailDimensionMedium: UInt = 450 + + /// This size is large enough to render full screen + private static var thumbnailDimensionsLarge: CGFloat = { + let screenSizePoints: CGSize = UIScreen.main.bounds.size + let minZoomFactor: CGFloat = 2 // TODO: Should this be screen scale? + + return (max(screenSizePoints.width, screenSizePoints.height) * minZoomFactor) + }() + + private static var sharedDataAttachmentsDirPath: String = { + OWSFileSystem.appSharedDataDirectoryPath().appending("/Attachments") + }() + + private static var attachmentsFolder: String = { + let attachmentsFolder: String = sharedDataAttachmentsDirPath + OWSFileSystem.ensureDirectoryExists(attachmentsFolder) + + return attachmentsFolder + }() + + private static var thumbnailsFolder: String = { + let attachmentsFolder: String = sharedDataAttachmentsDirPath + OWSFileSystem.ensureDirectoryExists(attachmentsFolder) + + return attachmentsFolder + }() + + private static func originalFilePath(id: String, mimeType: String, sourceFilename: String?) -> String? { + let maybeFilePath: String? = MIMETypeUtil.filePath( + forAttachment: id, // TODO: Can we avoid this??? + ofMIMEType: mimeType, + sourceFilename: sourceFilename, + inFolder: Attachment.attachmentsFolder + ) + + guard let filePath: String = maybeFilePath else { return nil } + guard filePath.hasPrefix(Attachment.attachmentsFolder) else { return nil } + + let localRelativeFilePath: String = filePath.substring(from: Attachment.attachmentsFolder.count) + + guard !localRelativeFilePath.isEmpty else { return nil } + + return localRelativeFilePath + } + + static func imageSize(contentType: String, originalFilePath: String) -> CGSize? { + let isVideo: Bool = MIMETypeUtil.isVideo(contentType) + let isImage: Bool = MIMETypeUtil.isImage(contentType) + let isAnimated: Bool = MIMETypeUtil.isAnimated(contentType) + + guard isVideo || isImage || isAnimated else { return nil } + + if isVideo { + guard OWSMediaUtils.isValidVideo(path: originalFilePath) else { return nil } + + return Attachment.videoStillImage(filePath: originalFilePath)?.size + } + + return NSData.imageSize(forFilePath: originalFilePath, mimeType: contentType) + } + + static func videoStillImage(filePath: String) -> UIImage? { + return try? OWSMediaUtils.thumbnail( + forVideoAtPath: filePath, + maxDimension: Attachment.thumbnailDimensionsLarge + ) + } +} + +// MARK: - Convenience + +extension Attachment { + var originalFilePath: String? { + return Attachment.originalFilePath( + id: self.id, + mimeType: self.contentType, + sourceFilename: self.sourceFilename + ) + } + + var localRelativeFilePath: String? { + return originalFilePath?.substring(from: Attachment.attachmentsFolder.count) + } + + var thumbnailsDirPath: String { + // Thumbnails are written to the caches directory, so that iOS can + // remove them if necessary + return "\(OWSFileSystem.cachesDirectoryPath())/\(id)-thumbnails" + } + + var originalImage: UIImage? { + guard let originalFilePath: String = originalFilePath else { return nil } + + if isVideo { + return Attachment.videoStillImage(filePath: originalFilePath) + } + + guard isImage || isAnimated else { return nil } + guard NSData.ows_isValidImage(atPath: originalFilePath, mimeType: contentType) else { + return nil + } + + return UIImage(contentsOfFile: originalFilePath) + } + + var emojiForMimeType: String { + if MIMETypeUtil.isImage(contentType) { + return "📷" + } + else if MIMETypeUtil.isVideo(contentType) { + return "🎥" + } + else if MIMETypeUtil.isAudio(contentType) { + return "🎧" + } + else if MIMETypeUtil.isAnimated(contentType) { + return "🎡" + } + + return "📎" + } + + var isImage: Bool { MIMETypeUtil.isImage(contentType) } + var isVideo: Bool { MIMETypeUtil.isVideo(contentType) } + var isAnimated: Bool { MIMETypeUtil.isAnimated(contentType) } + + func readDataFromFile() throws -> Data? { + guard let filePath: String = Attachment.originalFilePath(id: self.id, mimeType: self.contentType, sourceFilename: self.sourceFilename) else { + return nil + } + + return try Data(contentsOf: URL(fileURLWithPath: filePath)) + } + + public func thumbnailPath(for dimensions: UInt) -> String { + return "\(thumbnailsDirPath)/thumbnail-\(dimensions).jpg" + } + + private func loadThumbnail(with dimensions: UInt, success: @escaping (UIImage) -> (), failure: @escaping () -> ()) { + guard let width: UInt = self.width, let height: UInt = self.height, width > 1, height > 1 else { + failure() + return + } + + // There's no point in generating a thumbnail if the original is smaller than the + // thumbnail size + if width < dimensions || height < dimensions { + guard let image: UIImage = originalImage else { + failure() + return + } + + success(image) + return + } + + let thumbnailPath = thumbnailPath(for: dimensions) + + if FileManager.default.fileExists(atPath: thumbnailPath) { + guard let image: UIImage = UIImage(contentsOfFile: thumbnailPath) else { + failure() + return + } + + success(image) + return + } + + OWSThumbnailService.shared.ensureThumbnail( + for: self, + dimensions: dimensions, + success: { loadedThumbnail in success(loadedThumbnail.image) }, + failure: { _ in failure() } + ) + } + + func thumbnailImageSmallSync() -> UIImage? { + guard isVideo || isImage || isAnimated else { return nil } + + let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) + var image: UIImage? + + loadThumbnail( + with: Attachment.thumbnailDimensionSmall, + success: { loadedImage in + image = loadedImage + semaphore.signal() + }, + failure: { semaphore.signal() } + ) + + // Wait up to 5 seconds for the thumbnail to be loaded + _ = semaphore.wait(timeout: .now() + .seconds(5)) + + return image + } + + public func cloneAsThumbnail() -> Attachment { + fatalError("TODO: Add this back") } } diff --git a/SessionMessagingKit/Database/Models/ClosedGroup.swift b/SessionMessagingKit/Database/Models/ClosedGroup.swift index b39114288..39fe9d249 100644 --- a/SessionMessagingKit/Database/Models/ClosedGroup.swift +++ b/SessionMessagingKit/Database/Models/ClosedGroup.swift @@ -41,22 +41,26 @@ public struct ClosedGroup: Codable, Identifiable, FetchableRecord, PersistableRe request(for: ClosedGroup.keyPairs) } - public var memberIds: QueryInterfaceRequest { + public var allMembers: QueryInterfaceRequest { + request(for: ClosedGroup.members) + } + + public var members: QueryInterfaceRequest { request(for: ClosedGroup.members) .filter(GroupMember.Columns.role == GroupMember.Role.standard) } - public var zombieIds: QueryInterfaceRequest { + public var zombies: QueryInterfaceRequest { request(for: ClosedGroup.members) .filter(GroupMember.Columns.role == GroupMember.Role.zombie) } - public var moderatorIds: QueryInterfaceRequest { + public var moderators: QueryInterfaceRequest { request(for: ClosedGroup.members) .filter(GroupMember.Columns.role == GroupMember.Role.moderator) } - public var adminIds: QueryInterfaceRequest { + public var admins: QueryInterfaceRequest { request(for: ClosedGroup.members) .filter(GroupMember.Columns.role == GroupMember.Role.admin) } @@ -71,3 +75,25 @@ public struct ClosedGroup: Codable, Identifiable, FetchableRecord, PersistableRe return try performDelete(db) } } + +// MARK: - Mutation + +public extension ClosedGroup { + func with(name: String) -> ClosedGroup { + return ClosedGroup( + threadId: threadId, + name: name, + formationTimestamp: formationTimestamp + ) + } +} + +// MARK: - GRDB Interactions + +public extension ClosedGroup { + func fetchLatestKeyPair(_ db: Database) throws -> ClosedGroupKeyPair? { + return try keyPairs + .order(ClosedGroupKeyPair.Columns.receivedTimestamp.desc) + .fetchOne(db) + } +} diff --git a/SessionMessagingKit/Database/Models/ClosedGroupKeyPair.swift b/SessionMessagingKit/Database/Models/ClosedGroupKeyPair.swift index b1b5eac3b..85eeb10da 100644 --- a/SessionMessagingKit/Database/Models/ClosedGroupKeyPair.swift +++ b/SessionMessagingKit/Database/Models/ClosedGroupKeyPair.swift @@ -4,7 +4,7 @@ import Foundation import GRDB import SessionUtilitiesKit -public struct ClosedGroupKeyPair: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct ClosedGroupKeyPair: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "closedGroupKeyPair" } internal static let closedGroupForeignKey = ForeignKey( [Columns.publicKey], @@ -19,8 +19,6 @@ public struct ClosedGroupKeyPair: Codable, Identifiable, FetchableRecord, Persis case receivedTimestamp } - public var id: String { publicKey } - public let publicKey: String public let secretKey: Data public let receivedTimestamp: TimeInterval @@ -30,4 +28,27 @@ public struct ClosedGroupKeyPair: Codable, Identifiable, FetchableRecord, Persis public var closedGroup: QueryInterfaceRequest { request(for: ClosedGroupKeyPair.closedGroup) } + + // MARK: - Initialization + + public init( + publicKey: String, + secretKey: Data, + receivedTimestamp: TimeInterval + ) { + self.publicKey = publicKey + self.secretKey = secretKey + self.receivedTimestamp = receivedTimestamp + } +} + +// MARK: - GRDB Interactions + +public extension ClosedGroupKeyPair { + static func fetchLatestKeyPair(_ db: Database, publicKey: String) throws -> ClosedGroupKeyPair? { + return try ClosedGroupKeyPair + .filter(Columns.publicKey == publicKey) + .order(Columns.receivedTimestamp.desc) + .fetchOne(db) + } } diff --git a/SessionMessagingKit/Database/Models/Contact.swift b/SessionMessagingKit/Database/Models/Contact.swift index 6d95348c9..2edf6b56b 100644 --- a/SessionMessagingKit/Database/Models/Contact.swift +++ b/SessionMessagingKit/Database/Models/Contact.swift @@ -6,6 +6,7 @@ import SessionUtilitiesKit public struct Contact: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "contact" } + internal static let threadForeignKey = ForeignKey([Columns.id], to: [SessionThread.Columns.id]) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { diff --git a/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift b/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift new file mode 100644 index 000000000..ae7d02a23 --- /dev/null +++ b/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift @@ -0,0 +1,23 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public struct ControlMessageProcessRecord: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "controlMessageProcessRecord" } + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case threadId + case sentTimestampMs + case serverHash + case openGroupMessageServerId + } + + public let threadId: String + public let sentTimestampMs: Int64 + public let serverHash: String + public let openGroupMessageServerId: Int64 + +} diff --git a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift index 2a5d4db4e..0bfb78415 100644 --- a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift +++ b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift @@ -29,18 +29,65 @@ public struct DisappearingMessagesConfiguration: Codable, Identifiable, Fetchabl } } +// MARK: - Mutation + +public extension DisappearingMessagesConfiguration { + static let defaultDuration: TimeInterval = (24 * 60 * 60) + + static func defaultWith(_ threadId: String) -> DisappearingMessagesConfiguration { + return DisappearingMessagesConfiguration( + threadId: threadId, + isEnabled: false, + durationSeconds: defaultDuration + ) + } + + func with( + isEnabled: Bool? = nil, + durationSeconds: TimeInterval? = nil + ) -> DisappearingMessagesConfiguration { + return DisappearingMessagesConfiguration( + threadId: threadId, + isEnabled: (isEnabled ?? self.isEnabled), + durationSeconds: (durationSeconds ?? self.durationSeconds) + ) + } +} + // MARK: - Convenience -extension DisappearingMessagesConfiguration { - public var durationIndex: Int { +public extension DisappearingMessagesConfiguration { + var durationIndex: Int { return DisappearingMessagesConfiguration.validDurationsSeconds .firstIndex(of: durationSeconds) .defaulting(to: 0) } - public var durationString: String { + var durationString: String { NSString.formatDurationSeconds(UInt32(durationSeconds), useShortFormat: false) } + + func infoUpdateMessage(with senderName: String?) -> String { + guard let senderName: String = senderName else { + // Changed by localNumber on this device or via synced transcript + guard isEnabled, durationSeconds > 0 else { return "YOU_DISABLED_DISAPPEARING_MESSAGES_CONFIGURATION".localized() } + + return String( + format: "YOU_UPDATED_DISAPPEARING_MESSAGES_CONFIGURATION".localized(), + NSString.formatDurationSeconds(UInt32(floor(durationSeconds)), useShortFormat: false) + ) + } + + guard isEnabled, durationSeconds > 0 else { + return String(format: "OTHER_DISABLED_DISAPPEARING_MESSAGES_CONFIGURATION".localized(), senderName) + } + + return String( + format: "OTHER_UPDATED_DISAPPEARING_MESSAGES_CONFIGURATION".localized(), + NSString.formatDurationSeconds(UInt32(floor(durationSeconds)), useShortFormat: false), + senderName + ) + } } // MARK: - UI Constraints diff --git a/SessionMessagingKit/Database/Models/GroupMember.swift b/SessionMessagingKit/Database/Models/GroupMember.swift index f5a5aa45a..c1a8bef50 100644 --- a/SessionMessagingKit/Database/Models/GroupMember.swift +++ b/SessionMessagingKit/Database/Models/GroupMember.swift @@ -44,4 +44,16 @@ public struct GroupMember: Codable, FetchableRecord, PersistableRecord, TableRec public var profile: QueryInterfaceRequest { request(for: GroupMember.profile) } + + // MARK: - Initialization + + public init( + groupId: String, + profileId: String, + role: Role + ) { + self.groupId = groupId + self.profileId = profileId + self.role = role + } } diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index bc58647fd..9b9d85b6f 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -4,7 +4,7 @@ import Foundation import GRDB import SessionUtilitiesKit -public struct Interaction: Codable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible { +public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "interaction" } internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id]) internal static let profileForeignKey = ForeignKey([Columns.authorId], to: [Profile.Columns.id]) @@ -12,11 +12,19 @@ public struct Interaction: Codable, FetchableRecord, MutablePersistableRecord, T [Columns.linkPreviewUrl], to: [LinkPreview.Columns.url] ) - private static let thread = belongsTo(SessionThread.self, using: threadForeignKey) + internal static let thread = belongsTo(SessionThread.self, using: threadForeignKey) private static let profile = hasOne(Profile.self, using: profileForeignKey) - private static let attachments = hasMany(Attachment.self, using: Attachment.interactionForeignKey) - private static let quote = hasOne(Quote.self, using: Quote.interactionForeignKey) - private static let linkPreview = hasOne(LinkPreview.self, using: LinkPreview.interactionForeignKey) + internal static let interactionAttachments = hasMany( + InteractionAttachment.self, + using: InteractionAttachment.interactionForeignKey + ) + internal static let attachments = hasMany( + Attachment.self, + through: interactionAttachments, + using: InteractionAttachment.attachment + ) + public static let quote = hasOne(Quote.self, using: Quote.interactionForeignKey) + internal static let linkPreview = hasOne(LinkPreview.self, using: LinkPreview.interactionForeignKey) private static let recipientStates = hasMany(RecipientState.self, using: RecipientState.interactionForeignKey) public typealias Columns = CodingKeys @@ -30,6 +38,7 @@ public struct Interaction: Codable, FetchableRecord, MutablePersistableRecord, T case body case timestampMs case receivedAtTimestampMs + case wasRead case expiresInSeconds case expiresStartedAtMs @@ -71,9 +80,9 @@ public struct Interaction: Codable, FetchableRecord, MutablePersistableRecord, T public let serverHash: String? /// The id of the thread that this interaction belongs to (used to expose the `thread` variable) - private let threadId: String + public let threadId: String - /// The id of the user who sent the message, also used to expose the `profile` variable) + /// The id of the user who sent the interaction, also used to expose the `profile` variable) public let authorId: String /// The type of interaction @@ -83,18 +92,28 @@ public struct Interaction: Codable, FetchableRecord, MutablePersistableRecord, T public let body: String? /// When the interaction was created in milliseconds since epoch - public let timestampMs: Double + /// + /// **Note:** This value will be `0` if it hasn't been set yet + public let timestampMs: Int64 /// When the interaction was received in milliseconds since epoch - public let receivedAtTimestampMs: Double + /// + /// **Note:** This value will be `0` if it hasn't been set yet + public let receivedAtTimestampMs: Int64 + + /// A flag indicating whether the interaction has been read (this is a flag rather than a timestamp because + /// we couldn’t know if a read timestamp is accurate) + /// + /// **Note:** This flag is not applicable to standardOutgoing or standardIncomingDeleted interactions + public let wasRead: Bool /// The number of seconds until this message should expire - public fileprivate(set) var expiresInSeconds: TimeInterval? = nil + public let expiresInSeconds: TimeInterval? /// The timestamp in milliseconds since 1970 at which this messages expiration timer started counting /// down (this is stored in order to allow the `expiresInSeconds` value to be updated before a /// message has expired) - public fileprivate(set) var expiresStartedAtMs: Double? = nil + public let expiresStartedAtMs: Double? /// This value is the url for the link preview for this interaction /// @@ -104,7 +123,7 @@ public struct Interaction: Codable, FetchableRecord, MutablePersistableRecord, T // Open Group specific properties /// The `openGroupServerMessageId` value will only be set for messages from SOGS - public fileprivate(set) var openGroupServerMessageId: Int64? = nil + public let openGroupServerMessageId: Int64? /// This flag indicates whether this interaction is a whisper to the mods of an Open Group public let openGroupWhisperMods: Bool @@ -122,6 +141,12 @@ public struct Interaction: Codable, FetchableRecord, MutablePersistableRecord, T request(for: Interaction.profile) } + /// Depending on the data associated to this interaction this array will represent different things, these + /// cases are mutually exclusive: + /// + /// **Quote:** The thumbnails associated to the `Quote` + /// **LinkPreview:** The thumbnails associated to the `LinkPreview` + /// **Other:** The files directly attached to the interaction public var attachments: QueryInterfaceRequest { request(for: Interaction.attachments) } @@ -153,15 +178,16 @@ public struct Interaction: Codable, FetchableRecord, MutablePersistableRecord, T // MARK: - Initialization - // TODO: Do we actually want these values to have defaults? (check how messages are getting created - convenience constructors??) - init( + internal init( + id: Int64? = nil, serverHash: String?, threadId: String, authorId: String, variant: Variant, body: String?, - timestampMs: Double, - receivedAtTimestampMs: Double, + timestampMs: Int64, + receivedAtTimestampMs: Int64, + wasRead: Bool, expiresInSeconds: TimeInterval?, expiresStartedAtMs: Double?, linkPreviewUrl: String?, @@ -169,6 +195,7 @@ public struct Interaction: Codable, FetchableRecord, MutablePersistableRecord, T openGroupWhisperMods: Bool, openGroupWhisperTo: String? ) { + self.id = id self.serverHash = serverHash self.threadId = threadId self.authorId = authorId @@ -176,6 +203,45 @@ public struct Interaction: Codable, FetchableRecord, MutablePersistableRecord, T self.body = body self.timestampMs = timestampMs self.receivedAtTimestampMs = receivedAtTimestampMs + self.wasRead = wasRead + self.expiresInSeconds = expiresInSeconds + self.expiresStartedAtMs = expiresStartedAtMs + self.linkPreviewUrl = linkPreviewUrl + self.openGroupServerMessageId = openGroupServerMessageId + self.openGroupWhisperMods = openGroupWhisperMods + self.openGroupWhisperTo = openGroupWhisperTo + } + + public init( + serverHash: String? = nil, + threadId: String, + authorId: String, + variant: Variant, + body: String? = nil, + timestampMs: Int64 = 0, + wasRead: Bool = false, + expiresInSeconds: TimeInterval? = nil, + expiresStartedAtMs: Double? = nil, + linkPreviewUrl: String? = nil, + openGroupServerMessageId: Int64? = nil, + openGroupWhisperMods: Bool = false, + openGroupWhisperTo: String? = nil + ) throws { + self.serverHash = serverHash + self.threadId = threadId + self.authorId = authorId + self.variant = variant + self.body = body + self.timestampMs = timestampMs + self.receivedAtTimestampMs = { + switch variant { + case .standardIncoming, .standardOutgoing: return Int64(Date().timeIntervalSince1970 * 1000) + + /// For TSInteractions which are not `standardIncoming` and `standardOutgoing` use the `timestampMs` value + default: return timestampMs + } + }() + self.wasRead = wasRead self.expiresInSeconds = expiresInSeconds self.expiresStartedAtMs = expiresStartedAtMs self.linkPreviewUrl = linkPreviewUrl @@ -186,8 +252,66 @@ public struct Interaction: Codable, FetchableRecord, MutablePersistableRecord, T // MARK: - Custom Database Interaction - public mutating func didInsert(with rowID: Int64, for column: String?) { - self.id = rowID + public mutating func insert(_ db: Database) throws { + try performInsert(db) + + // Since we need to do additional logic upon insert we can just set the 'id' value + // here directly instead of in the 'didInsert' method (if you look at the docs the + // 'db.lastInsertedRowID' value is the row id of the newly inserted row which the + // interaction uses as it's id) + let interactionId: Int64 = db.lastInsertedRowID + self.id = interactionId + + guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: threadId) else { + SNLog("Inserted an interaction but couldn't find it's associated thead") + return + } + + switch variant { + case .standardOutgoing: + // New outgoing messages should immediately determine their recipient list + // from current thread state + switch thread.variant { + case .contact: + try RecipientState( + interactionId: interactionId, + recipientId: threadId, // Will be the contact id + state: .sending + ).insert(db) + + case .closedGroup: + guard + let closedGroup: ClosedGroup = try? thread.closedGroup.fetchOne(db), + let members: [GroupMember] = try? closedGroup.members.fetchAll(db) + else { + SNLog("Inserted an interaction but couldn't find it's associated thread members") + return + } + + try members.forEach { member in + try RecipientState( + interactionId: interactionId, + recipientId: member.profileId, + state: .sending + ).insert(db) + } + + case .openGroup: + // Since we use the 'RecipientState' type to manage the message state + // we need to ensure we have a state for all threads; so for open groups + // we just use the open group id as the 'recipientId' value + try RecipientState( + interactionId: interactionId, + recipientId: threadId, // Will be the open group id + state: .sending + ).insert(db) + } + + default: break + } + + } + } public func delete(_ db: Database) throws -> Bool { @@ -217,9 +341,140 @@ public struct Interaction: Codable, FetchableRecord, MutablePersistableRecord, T } } +// MARK: - Mutation + +public extension Interaction { + func with( + serverHash: String? = nil, + authorId: String? = nil, + timestampMs: Int64? = nil, + wasRead: Bool? = nil, + expiresInSeconds: TimeInterval? = nil, + expiresStartedAtMs: Double? = nil, + openGroupServerMessageId: Int64? = nil + ) -> Interaction { + return Interaction( + id: id, + serverHash: (serverHash ?? self.serverHash), + threadId: threadId, + authorId: (authorId ?? self.authorId), + variant: variant, + body: body, + timestampMs: (timestampMs ?? self.timestampMs), + receivedAtTimestampMs: receivedAtTimestampMs, + wasRead: (wasRead ?? self.wasRead), + expiresInSeconds: (expiresInSeconds ?? self.expiresInSeconds), + expiresStartedAtMs: (expiresStartedAtMs ?? self.expiresStartedAtMs), + linkPreviewUrl: linkPreviewUrl, + openGroupServerMessageId: (openGroupServerMessageId ?? self.openGroupServerMessageId), + openGroupWhisperMods: openGroupWhisperMods, + openGroupWhisperTo: openGroupWhisperTo + ) + } +} + +// MARK: - GRDB Interactions + +public extension Interaction { + /// Immutable version of the `markAsRead(_:includingOlder:trySendReadReceipt:)` function + func markingAsRead(_ db: Database, includingOlder: Bool, trySendReadReceipt: Bool) throws -> Interaction { + var updatedInteraction: Interaction = self + try updatedInteraction.markAsRead(db, includingOlder: includingOlder, trySendReadReceipt: trySendReadReceipt) + + return updatedInteraction + } + + /// This will update the `wasRead` state the the interaction + /// + /// - Parameters + /// - includingOlder: Setting this to `true` will updated the `wasRead` flag for all older interactions as well + /// - trySendReadReceipt: Setting this to `true` will schedule a `ReadReceiptJob` + mutating func markAsRead(_ db: Database, includingOlder: Bool, trySendReadReceipt: Bool) throws { + // Once all of the below is done schedule the jobs + func scheduleJobs(interactionIds: [Int64]) { + // Add the 'DisappearingMessagesJob' if needed - this will update any expiring + // messages `expiresStartedAtMs` values + JobRunner.add( + db, + job: Job( + variant: .disappearingMessages, + details: DisappearingMessagesJob.updateNextRunIfNeeded( + db, + interactionIds: interactionIds, + startedAtMs: (Date().timeIntervalSince1970 * 1000) + ) + ) + ) + + // If we want to send read receipts then try to add the 'SendReadReceiptsJob' + if trySendReadReceipt { + JobRunner.upsert( + db, + job: SendReadReceiptsJob.createOrUpdateIfNeeded( + db, + threadId: threadId, + interactionIds: interactionIds + ) + ) + } + } + + // If we aren't including older interactions then update and save the current one + guard includingOlder else { + let updatedInteraction: Interaction = try self + .with(wasRead: true) + .saved(db) + + guard let id: Int64 = updatedInteraction.id else { throw GRDBStorageError.objectNotFound } + + scheduleJobs(interactionIds: [id]) + return + } + + // Need an id in order to continue + guard let id: Int64 = self.id else { throw GRDBStorageError.objectNotFound } + + let interactionQuery = Interaction + .filter(Columns.threadId == threadId) + .filter(Columns.id <= id) + // The `wasRead` flag doesn't apply to `standardOutgoing` or `standardIncomingDeleted` + .filter(Columns.variant != Variant.standardOutgoing && Columns.variant != Variant.standardIncomingDeleted) + + // Update the `wasRead` flag to true + try interactionQuery.updateAll(db, Columns.wasRead.set(to: true)) + + // Retrieve the interaction ids we want to update + scheduleJobs( + interactionIds: try Int64.fetchAll( + db, + interactionQuery.select(Interaction.Columns.id) + ) + ) + } + + static func markAsRead(_ db: Database, recipientId: String, timestampMsValues: [Double], readTimestampMs: Double) throws { + guard db[.areReadReceiptsEnabled] == true else { return } + + try RecipientState + .filter(RecipientState.Columns.recipientId == recipientId) + .joining( + required: RecipientState.interaction + .filter(Columns.variant == Variant.standardOutgoing) + .filter(timestampMsValues.contains(Columns.timestampMs)) + ) + .updateAll( + db, + RecipientState.Columns.readTimestampMs.set(to: readTimestampMs), + RecipientState.Columns.state.set(to: RecipientState.State.sent) + ) + } +} + // MARK: - Convenience public extension Interaction { + static let oversizeTextMessageSizeThreshold: UInt = (2 * 1024) + // MARK: - Variables var isExpiringMessage: Bool { @@ -230,17 +485,172 @@ public extension Interaction { var openGroupWhisper: Bool { return (openGroupWhisperMods || (openGroupWhisperTo != nil)) } + var notificationIdentifiers: [String] { + [ + notificationIdentifier(isBackgroundPoll: true), + notificationIdentifier(isBackgroundPoll: false) + ] + } + // MARK: - Functions - func with( - expiresInSeconds: TimeInterval? = nil, - expiresStartedAtMs: Double? = nil, - openGroupServerMessageId: Int64? = nil - ) -> Interaction { - var updatedInteraction: Interaction = self - updatedInteraction.expiresInSeconds = (expiresInSeconds ?? updatedInteraction.expiresInSeconds) - updatedInteraction.expiresStartedAtMs = (expiresStartedAtMs ?? updatedInteraction.expiresStartedAtMs) - updatedInteraction.openGroupServerMessageId = (openGroupServerMessageId ?? updatedInteraction.openGroupServerMessageId) - return updatedInteraction + func notificationIdentifier(isBackgroundPoll: Bool) -> String { + // When the app is in the background we want the notifications to be grouped to prevent spam + guard isBackgroundPoll else { return threadId } + + return "\(threadId)-\(id ?? 0)" + } + + func markingAsDeleted() -> Interaction { + return Interaction( + id: id, + serverHash: nil, + threadId: threadId, + authorId: authorId, + variant: .standardIncomingDeleted, + body: nil, + timestampMs: timestampMs, + receivedAtTimestampMs: receivedAtTimestampMs, + wasRead: wasRead, + expiresInSeconds: expiresInSeconds, + expiresStartedAtMs: expiresStartedAtMs, + linkPreviewUrl: linkPreviewUrl, + openGroupServerMessageId: openGroupServerMessageId, + openGroupWhisperMods: openGroupWhisperMods, + openGroupWhisperTo: openGroupWhisperTo + ) + } + + func isUserMentioned(_ db: Database) -> Bool { + guard variant == .standardIncoming else { return false } + + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + return ( + ( + body != nil && + (body ?? "").contains("@\(userPublicKey)") + ) || ( + (try? quote.fetchOne(db))?.authorId == userPublicKey + ) + ) + } + + func previewText(_ db: Database) -> String { + switch variant { + case .standardIncomingDeleted: return "" + + case .standardIncoming, .standardOutgoing: + var bodyDescription: String? + + if let body: String = self.body, !body.isEmpty { + bodyDescription = body + } + + if bodyDescription == nil { + let maybeTextAttachment: Attachment? = try? attachments + .filter(Attachment.Columns.contentType == OWSMimeTypeOversizeTextMessage) + .fetchOne(db) + + if + let attachment: Attachment = maybeTextAttachment, + attachment.state == .downloaded, + let filePath: String = attachment.originalFilePath, + let data: Data = try? Data(contentsOf: URL(fileURLWithPath: filePath)), + let dataString: String = String(data: data, encoding: .utf8) + { + bodyDescription = dataString.filterForDisplay + } + } + + var attachmentDescription: String? + let maybeMediaAttachment: Attachment? = try? attachments + .filter(Attachment.Columns.contentType != OWSMimeTypeOversizeTextMessage) + .fetchOne(db) + + if let attachment: Attachment = maybeMediaAttachment { + attachmentDescription = attachment.description + } + + if + let attachmentDescription: String = attachmentDescription, + let bodyDescription: String = bodyDescription, + !attachmentDescription.isEmpty, + !bodyDescription.isEmpty + { + if CurrentAppContext().isRTL { + return "\(bodyDescription): \(attachmentDescription)" + } + + return "\(attachmentDescription): \(bodyDescription)" + } + + if let bodyDescription: String = bodyDescription, !bodyDescription.isEmpty { + return bodyDescription + } + + if let attachmentDescription: String = attachmentDescription, !attachmentDescription.isEmpty { + return attachmentDescription + } + + if let linkPreview: LinkPreview = try? linkPreview.fetchOne(db), linkPreview.variant == .openGroupInvitation { + return "😎 Open group invitation" + } + + // TODO: We should do better here + return "" + + case .infoMediaSavedNotification: + // Note: This should only occur in 'contact' threads so the `threadId` + // is the contact id + let displayName: String = Profile.displayName(id: threadId) + + // TODO: Use referencedAttachmentTimestamp to tell the user * which * media was saved + return String(format: "media_saved".localized(), displayName) + + case .infoScreenshotNotification: + // Note: This should only occur in 'contact' threads so the `threadId` + // is the contact id + let displayName: String = Profile.displayName(id: threadId) + + return String(format: "screenshot_taken".localized(), displayName) + + case .infoClosedGroupCreated: return "GROUP_CREATED".localized() + case .infoClosedGroupCurrentUserLeft: return "GROUP_YOU_LEFT".localized() + case .infoClosedGroupUpdated: return (body ?? "GROUP_UPDATED".localized()) + case .infoMessageRequestAccepted: return (body ?? "MESSAGE_REQUESTS_ACCEPTED".localized()) + + case .infoDisappearingMessagesUpdate: + // TODO: We should do better here + return (body ?? "") + } + } + + func state(_ db: Database) throws -> RecipientState.State { + let states: [RecipientState.State] = try recipientStates + .fetchAll(db) + .map { $0.state } + var hasFailed: Bool = false + + for state in states { + switch state { + // If there are any "sending" recipients, consider this message "sending" + case .sending: return .sending + + case .failed: + hasFailed = true + break + + default: break + } + } + + // If there are any "failed" recipients, consider this message "failed" + guard !hasFailed else { return .failed } + + // Otherwise, consider the message "sent" + // + // Note: This includes messages with no recipients + return .sent } } diff --git a/SessionMessagingKit/Database/Models/InteractionAttachment.swift b/SessionMessagingKit/Database/Models/InteractionAttachment.swift new file mode 100644 index 000000000..ecfa280fb --- /dev/null +++ b/SessionMessagingKit/Database/Models/InteractionAttachment.swift @@ -0,0 +1,51 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public struct InteractionAttachment: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "interactionAttachment" } + internal static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id]) + private static let attachmentForeignKey = ForeignKey([Columns.attachmentId], to: [Attachment.Columns.id]) + internal static let interaction = belongsTo(Interaction.self, using: interactionForeignKey) + internal static let attachment = belongsTo(Attachment.self, using: attachmentForeignKey) + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case interactionId + case attachmentId + } + + public let interactionId: Int64 + public let attachmentId: String + + // MARK: - Relationships + + public var interaction: QueryInterfaceRequest { + request(for: InteractionAttachment.interaction) + } + + public var attachment: QueryInterfaceRequest { + request(for: InteractionAttachment.attachment) + } + + // MARK: - Custom Database Interaction + + public func delete(_ db: Database) throws -> Bool { + // If we have an Attachment then check if this is the only type that is referencing it + // and delete the Attachment if so + let quoteUses: Int? = try? Quote + .filter(Quote.Columns.attachmentId == attachmentId) + .fetchCount(db) + let linkPreviewUses: Int? = try? LinkPreview + .filter(LinkPreview.Columns.attachmentId == attachmentId) + .fetchCount(db) + + if (quoteUses ?? 0) == 0 && (linkPreviewUses ?? 0) == 0 { + try attachment.deleteAll(db) + } + + return try performDelete(db) + } +} diff --git a/SessionMessagingKit/Database/Models/Job.swift b/SessionMessagingKit/Database/Models/Job.swift new file mode 100644 index 000000000..bdc87f812 --- /dev/null +++ b/SessionMessagingKit/Database/Models/Job.swift @@ -0,0 +1,218 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit +import SwiftProtobuf + +public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "job" } + internal static let threadForeignKey = ForeignKey( + [Columns.threadId], + to: [Interaction.Columns.threadId] + ) + internal static let thread = hasOne(SessionThread.self, using: Job.threadForeignKey) + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case id + case failureCount + case variant + case behaviour + case nextRunTimestamp + case threadId + case details + } + + public enum Variant: Int, Codable, DatabaseValueConvertible { + /// This is a recurring job that handles the removal of disappearing messages and is triggered + /// at the timestamp of the next disappearing message + case disappearingMessages + + + /// This is a recurring job that runs on launch and flags any messages marked as 'sending' to + /// be in their 'failed' state + case failedMessages = 1000 + + /// This is a recurring job that runs on launch and flags any attachments marked as 'uploading' to + /// be in their 'failed' state + case failedAttachmentDownloads + + /// This is a recurring job that runs on return from background and registeres and uploads the + /// latest device push tokens + case syncPushTokens = 2000 + + /// This is a job that runs once whenever a message is sent to notify the push notification server + /// about the message + case notifyPushServer + + /// This is a job that runs once at most every 3 seconds per thread whenever a message is marked as read + /// (if read receipts are enabled) to notify other members in a conversation that their message was read + case sendReadReceipts + + /// This is a job that runs once whenever a message is received to attempt to decode and properly + /// process the message + case messageReceive = 3000 + + /// This is a job that runs once whenever a message is sent to attempt to encode and properly + /// send the message + case messageSend + + /// This is a job that runs once whenever an attachment is uploaded to attempt to encode and properly + /// upload the attachment + case attachmentUpload + + /// This is a job that runs once whenever an attachment is downloaded to attempt to decode and properly + /// download the attachment + case attachmentDownload + } + + public enum Behaviour: Int, Codable, DatabaseValueConvertible { + /// This job will run once and then be removed from the jobs table + case runOnce + + /// This job will run once the next time the app launches and then be removed from the jobs table + case runOnceNextLaunch + + /// This job will run and then will be updated with a new `nextRunTimestamp` (at least 1 second in + /// the future) in order to be run again + case recurring + + /// This job will run once each launch + case recurringOnLaunch + + /// This job will run once each whenever the app becomes active (launch and return from background) + case recurringOnActive + } + + /// The `id` value is auto incremented by the database, if the `Job` hasn't been inserted into + /// the database yet this value will be `nil` + public var id: Int64? = nil + + /// A counter for the number of times this job has failed + public let failureCount: UInt + + /// The type of job + public let variant: Variant + + /// The type of job + public let behaviour: Behaviour + + /// Seconds since epoch to indicate the next datetime that this job should run + public let nextRunTimestamp: TimeInterval + + /// The id of the thread this job is associated with + /// + /// **Note:** This will only be populated for Jobs associated to threads + public let threadId: String? + + /// JSON encoded data required for the job + public let details: Data? + + // MARK: - Relationships + + public var thread: QueryInterfaceRequest { + request(for: Job.thread) + } + + // MARK: - Initialization + + fileprivate init( + id: Int64?, + failureCount: UInt, + variant: Variant, + behaviour: Behaviour, + nextRunTimestamp: TimeInterval, + threadId: String?, + details: Data? + ) { + self.id = id + self.failureCount = failureCount + self.variant = variant + self.behaviour = behaviour + self.nextRunTimestamp = nextRunTimestamp + self.threadId = threadId + self.details = details + } + + public init( + failureCount: UInt = 0, + variant: Variant, + behaviour: Behaviour = .runOnce, + nextRunTimestamp: TimeInterval = 0, + threadId: String? = nil + ) { + self.failureCount = failureCount + self.variant = variant + self.behaviour = behaviour + self.nextRunTimestamp = nextRunTimestamp + self.threadId = threadId + self.details = nil + } + + public init?( + failureCount: UInt = 0, + variant: Variant, + behaviour: Behaviour = .runOnce, + nextRunTimestamp: TimeInterval = 0, + threadId: String? = nil, + details: T? = nil + ) { + let detailsData: Data? + + if let details: T = details { + guard let encodedDetails: Data = try? JSONEncoder().encode(details) else { return nil } + + detailsData = encodedDetails + } + else { + detailsData = nil + } + + self.failureCount = failureCount + self.variant = variant + self.behaviour = behaviour + self.nextRunTimestamp = nextRunTimestamp + self.threadId = threadId + self.details = detailsData + } + + // MARK: - Custom Database Interaction + + public mutating func didInsert(with rowID: Int64, for column: String?) { + self.id = rowID + } +} + +// MARK: - Convenience + +public extension Job { + internal func with( + failureCount: UInt = 0, + nextRunTimestamp: TimeInterval? + ) -> Job { + return Job( + id: id, + failureCount: failureCount, + variant: variant, + behaviour: behaviour, + nextRunTimestamp: (nextRunTimestamp ?? self.nextRunTimestamp), + threadId: threadId, + details: details + ) + } + + internal func with(details: T) -> Job? { + guard let detailsData: Data = try? JSONEncoder().encode(details) else { return nil } + + return Job( + id: id, + failureCount: failureCount, + variant: variant, + behaviour: behaviour, + nextRunTimestamp: nextRunTimestamp, + threadId: threadId, + details: detailsData + ) + } +} diff --git a/SessionMessagingKit/Database/Models/LinkPreview.swift b/SessionMessagingKit/Database/Models/LinkPreview.swift index 8b2bcc6b4..30c33d26e 100644 --- a/SessionMessagingKit/Database/Models/LinkPreview.swift +++ b/SessionMessagingKit/Database/Models/LinkPreview.swift @@ -10,7 +10,9 @@ public struct LinkPreview: Codable, FetchableRecord, PersistableRecord, TableRec [Columns.url], to: [Interaction.Columns.linkPreviewUrl] ) + private static let attachmentForeignKey = ForeignKey([Columns.attachmentId], to: [Attachment.Columns.id]) internal static let interactions = hasMany(Interaction.self, using: Interaction.linkPreviewForeignKey) + internal static let attachment = hasOne(Attachment.self, using: attachmentForeignKey) /// We want to cache url previews to the nearest 100,000 seconds (~28 hours - simpler than 86,400) to ensure the user isn't shown a preview that is too stale internal static let timstampResolution: Double = 100000 @@ -21,6 +23,7 @@ public struct LinkPreview: Codable, FetchableRecord, PersistableRecord, TableRec case timestamp case variant case title + case attachmentId } public enum Variant: Int, Codable, DatabaseValueConvertible { @@ -40,14 +43,194 @@ public struct LinkPreview: Codable, FetchableRecord, PersistableRecord, TableRec /// The title for the link public let title: String? + + /// The id for the attachment for the link preview image + public let attachmentId: String? + + // MARK: - Relationships + + public var attachment: QueryInterfaceRequest { + request(for: LinkPreview.attachment) + } + + // MARK: - Initialization + + public init( + url: String, + timestamp: TimeInterval = LinkPreview.timestampFor( + sentTimestampMs: (Date().timeIntervalSince1970 * 1000) // Default to now + ), + variant: Variant = .standard, + title: String?, + attachmentId: String? = nil + ) { + self.url = url + self.timestamp = timestamp + self.variant = variant + self.title = title + self.attachmentId = attachmentId + } + + // MARK: - Custom Database Interaction + + public func delete(_ db: Database) throws -> Bool { + // If we have an Attachment then check if this is the only type that is referencing it + // and delete the Attachment if so + if let attachmentId: String = attachmentId { + let interactionUses: Int? = try? InteractionAttachment + .filter(InteractionAttachment.Columns.attachmentId == attachmentId) + .fetchCount(db) + let quoteUses: Int? = try? Quote + .filter(Quote.Columns.attachmentId == attachmentId) + .fetchCount(db) + + if (interactionUses ?? 0) == 0 && (quoteUses ?? 0) == 0 { + try attachment.deleteAll(db) + } + } + + return try performDelete(db) + } +} + +// MARK: - Protobuf + +public extension LinkPreview { + init?(_ db: Database, proto: SNProtoDataMessage, body: String?) throws { + guard OWSLinkPreview.featureEnabled else { throw LinkPreviewError.noPreview } + guard let previewProto = proto.preview.first else { throw LinkPreviewError.noPreview } + guard proto.attachments.count < 1 else { throw LinkPreviewError.invalidInput } + guard URL(string: previewProto.url) != nil else { throw LinkPreviewError.invalidInput } + guard LinkPreview.isValidLinkUrl(previewProto.url) else { throw LinkPreviewError.invalidInput } + guard let body: String = body else { throw LinkPreviewError.invalidInput } + guard LinkPreview.allPreviewUrls(forMessageBodyText: body).contains(previewProto.url) else { + throw LinkPreviewError.invalidInput + } + + // Try to get an existing link preview first + let timestamp: TimeInterval = LinkPreview.timestampFor(sentTimestampMs: Double(proto.timestamp)) + let maybeLinkPreview: LinkPreview? = try? LinkPreview + .filter(LinkPreview.Columns.url == previewProto.url) + .filter(LinkPreview.Columns.timestamp == LinkPreview.timestampFor( + sentTimestampMs: Double(proto.timestamp) + )) + .fetchOne(db) + + if let linkPreview: LinkPreview = maybeLinkPreview { + self = linkPreview + return + } + + self.url = previewProto.url + self.timestamp = timestamp + self.variant = .standard + self.title = LinkPreview.normalizeTitle(title: previewProto.title) + + if let imageProto = previewProto.image { + let attachment: Attachment = Attachment(proto: imageProto) + try attachment.insert(db) + + self.attachmentId = attachment.id + } + else { + self.attachmentId = nil + } + + // Make sure the quote is valid before completing + guard self.title != nil || self.attachmentId != nil else { throw LinkPreviewError.invalidInput } + } } // MARK: - Convenience public extension LinkPreview { + struct URLMatchResult { + let urlString: String + let matchRange: NSRange + } + static func timestampFor(sentTimestampMs: Double) -> TimeInterval { - // We want to round the timestamp down to the nearest 100,000 seconds (~28 hours - simpler than 86,400) to optimise - // LinkPreview storage without having too stale data + // We want to round the timestamp down to the nearest 100,000 seconds (~28 hours - simpler + // than 86,400) to optimise LinkPreview storage without having too stale data return (floor(sentTimestampMs / 1000 / LinkPreview.timstampResolution) * LinkPreview.timstampResolution) } + + @discardableResult static func saveAttachmentIfPossible(_ db: Database, imageData: Data?, mimeType: String) throws -> String? { + guard let imageData: Data = imageData, !imageData.isEmpty else { return nil } + guard let fileExtension: String = MIMETypeUtil.fileExtension(forMIMEType: mimeType) else { return nil } + + let filePath = OWSFileSystem.temporaryFilePath(withFileExtension: fileExtension) + try imageData.write(to: NSURL.fileURL(withPath: filePath), options: .atomicWrite) + + guard let dataSource = DataSourcePath.dataSource(withFilePath: filePath, shouldDeleteOnDeallocation: true) else { + return nil + } + + return try Attachment(contentType: mimeType, dataSource: dataSource)? + .inserted(db) + .id + } + + static func isValidLinkUrl(_ urlString: String) -> Bool { + return URL(string: urlString) != nil + } + + static func allPreviewUrls(forMessageBodyText body: String) -> [String] { + return allPreviewUrlMatches(forMessageBodyText: body).map { $0.urlString } + } + + // MARK: - Private Methods + + private static func allPreviewUrlMatches(forMessageBodyText body: String) -> [URLMatchResult] { + let detector: NSDataDetector + do { + detector = try NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) + } + catch { + return [] + } + + var urlMatches: [URLMatchResult] = [] + let matches = detector.matches(in: body, options: [], range: NSRange(location: 0, length: body.count)) + for match in matches { + guard let matchURL = match.url else { continue } + + // If the URL entered didn't have a scheme it will default to 'http', we want to catch this and + // set the scheme to 'https' instead as we don't load previews for 'http' so this will result + // in more previews actually getting loaded without forcing the user to enter 'https://' before + // every URL they enter + let urlString: String = (matchURL.absoluteString == "http://\(body)" ? + "https://\(body)" : + matchURL.absoluteString + ) + + if isValidLinkUrl(urlString) { + let matchResult = URLMatchResult(urlString: urlString, matchRange: match.range) + urlMatches.append(matchResult) + } + } + + return urlMatches + } + + fileprivate static func normalizeTitle(title: String?) -> String? { + guard var result: String = title, !result.isEmpty else { return nil } + + // Truncate title after 2 lines of text. + let maxLineCount = 2 + var components = result.components(separatedBy: .newlines) + + if components.count > maxLineCount { + components = Array(components[0.. maxCharacterCount { + let endIndex = result.index(result.startIndex, offsetBy: maxCharacterCount) + result = String(result[.. String { + return "\(server.lowercased()).\(room)" + } +} diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index d88ef045b..cedb3b173 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -224,32 +224,42 @@ public extension Profile { // MARK: - GRDB Interactions public extension Profile { - static func displayName(for id: ID, thread: TSThread, customFallback: String? = nil) -> String { + static func displayName(_ db: Database? = nil, id: ID, thread: SessionThread, customFallback: String? = nil) -> String { return displayName( - for: id, - context: ((thread as? TSGroupThread)?.isOpenGroup == true ? .openGroup : .regular), + db, + id: id, + context: (thread.variant == .openGroup ? .openGroup : .regular), customFallback: customFallback ) } - static func displayName(for id: ID, context: Context = .regular, customFallback: String? = nil) -> String { - let existingDisplayName: String? = GRDBStorage.shared - .read { db in try Profile.fetchOne(db, id: id) }? + static func displayName(_ db: Database? = nil, id: ID, context: Context = .regular, customFallback: String? = nil) -> String { + guard let db: Database = db else { + return GRDBStorage.shared + .read { db in displayName(db, id: id, context: context, customFallback: customFallback) } + .defaulting(to: (customFallback ?? id)) + } + + let existingDisplayName: String? = (try? Profile.fetchOne(db, id: id))? .displayName(for: context) return (existingDisplayName ?? (customFallback ?? id)) } - static func displayNameNoFallback(for id: ID, thread: TSThread) -> String? { + static func displayNameNoFallback(_ db: Database? = nil, id: ID, thread: SessionThread) -> String? { return displayName( - for: id, - context: ((thread as? TSGroupThread)?.isOpenGroup == true ? .openGroup : .regular) + db, + id: id, + context: (thread.variant == .openGroup ? .openGroup : .regular) ) } - static func displayNameNoFallback(for id: ID, context: Context = .regular) -> String? { - return GRDBStorage.shared - .read { db in try Profile.fetchOne(db, id: id) }? + static func displayNameNoFallback(_ db: Database? = nil, id: ID, context: Context = .regular) -> String? { + guard let db: Database = db else { + return GRDBStorage.shared.read { db in displayNameNoFallback(db, id: id, context: context) } + } + + return (try? Profile.fetchOne(db, id: id))? .displayName(for: context) } @@ -344,11 +354,11 @@ public class SMKProfile: NSObject { } @objc public static func displayName(id: String) -> String { - return Profile.displayName(for: id) + return Profile.displayName(id: id) } @objc public static func displayName(id: String, customFallback: String) -> String { - return Profile.displayName(for: id, customFallback: customFallback) + return Profile.displayName(id: id, customFallback: customFallback) } @objc public static func displayName(id: String, context: Profile.Context = .regular) -> String { @@ -359,8 +369,8 @@ public class SMKProfile: NSObject { return (existingProfile?.name ?? id) } - @objc public static func displayName(id: String, thread: TSThread) -> String { - return Profile.displayName(for: id, thread: thread) + public static func displayName(id: String, thread: SessionThread) -> String { + return Profile.displayName(id: id, thread: thread) } @objc public static var localProfileKey: OWSAES256Key? { diff --git a/SessionMessagingKit/Database/Models/Quote.swift b/SessionMessagingKit/Database/Models/Quote.swift index 83ef2f1a2..d1d49b86a 100644 --- a/SessionMessagingKit/Database/Models/Quote.swift +++ b/SessionMessagingKit/Database/Models/Quote.swift @@ -12,9 +12,11 @@ public struct Quote: Codable, FetchableRecord, PersistableRecord, TableRecord, C to: [Interaction.Columns.timestampMs, Interaction.Columns.authorId] ) internal static let profileForeignKey = ForeignKey([Columns.authorId], to: [Profile.Columns.id]) - private static let interaction = belongsTo(Interaction.self, using: interactionForeignKey) + private static let attachmentForeignKey = ForeignKey([Columns.attachmentId], to: [Attachment.Columns.id]) + internal static let interaction = belongsTo(Interaction.self, using: interactionForeignKey) private static let profile = hasOne(Profile.self, using: profileForeignKey) private static let quotedInteraction = hasOne(Interaction.self, using: originalInteractionForeignKey) + internal static let attachment = hasOne(Attachment.self, using: attachmentForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -22,6 +24,7 @@ public struct Quote: Codable, FetchableRecord, PersistableRecord, TableRecord, C case authorId case timestampMs case body + case attachmentId } /// The id for the interaction this Quote belongs to @@ -31,11 +34,14 @@ public struct Quote: Codable, FetchableRecord, PersistableRecord, TableRecord, C public let authorId: String /// The timestamp in milliseconds since epoch when the quoted interaction was sent - public let timestampMs: Double + public let timestampMs: Int64 /// The body of the quoted message if the user is quoting a text message or an attachment with a caption public let body: String? + /// The id for the attachment this Quote is associated with + public let attachmentId: String? + // MARK: - Relationships public var interaction: QueryInterfaceRequest { @@ -46,7 +52,113 @@ public struct Quote: Codable, FetchableRecord, PersistableRecord, TableRecord, C request(for: Quote.profile) } + public var attachment: QueryInterfaceRequest { + request(for: Quote.attachment) + } + public var originalInteraction: QueryInterfaceRequest { request(for: Quote.quotedInteraction) } + + // MARK: - Interaction + + public init( + interactionId: Int64, + authorId: String, + timestampMs: Int64, + body: String?, + attachmentId: String? + ) { + self.interactionId = interactionId + self.authorId = authorId + self.timestampMs = timestampMs + self.body = body + self.attachmentId = attachmentId + } + + // MARK: - Custom Database Interaction + + public func delete(_ db: Database) throws -> Bool { + // If we have an Attachment then check if this is the only type that is referencing it + // and delete the Attachment if so + if let attachmentId: String = attachmentId { + let interactionUses: Int? = try? InteractionAttachment + .filter(InteractionAttachment.Columns.attachmentId == attachmentId) + .fetchCount(db) + let linkPreviewUses: Int? = try? LinkPreview + .filter(LinkPreview.Columns.attachmentId == attachmentId) + .fetchCount(db) + + if (interactionUses ?? 0) == 0 && (linkPreviewUses ?? 0) == 0 { + try attachment.deleteAll(db) + } + } + + return try performDelete(db) + } +} + +// MARK: - Protobuf + +public extension Quote { + init?(_ db: Database, proto: SNProtoDataMessage, interactionId: Int64, thread: SessionThread) throws { + guard + let quote = proto.quote, + quote.id != 0, + !quote.author.isEmpty + else { return nil } + self.interactionId = interactionId + self.timestampMs = Int64(quote.id) + self.authorId = quote.author + + // Prefer to generate the text snippet locally if available. + let quotedInteraction: Interaction? = try? thread + .interactions + .filter(Interaction.Columns.authorId == quote.author) + .filter(Interaction.Columns.timestampMs == Double(quote.id)) + .fetchOne(db) + + if let quotedInteraction: Interaction = quotedInteraction, quotedInteraction.body?.isEmpty == false { + self.body = quotedInteraction.body + } + else if let body: String = proto.body, !body.isEmpty { + self.body = body + } + else { + self.body = nil + } + + // We only use the first attachment + if let attachment = proto.attachments.first { + let thumbnailAttachment: Attachment + + // We prefer deriving any thumbnail locally rather than fetching one from the network + if let quotedInteraction: Interaction = quotedInteraction { + if let attachment: Attachment = try? quotedInteraction.attachments.fetchOne(db) { + thumbnailAttachment = attachment.cloneAsThumbnail() + } + else if let linkPreviewAttachment: Attachment = try? quotedInteraction.linkPreview.fetchOne(db)?.attachment.fetchOne(db) { + thumbnailAttachment = linkPreviewAttachment.cloneAsThumbnail() + } + else { + thumbnailAttachment = Attachment(proto: attachment) + } + } + else { + thumbnailAttachment = Attachment(proto: attachment) + } + + try thumbnailAttachment.save(db) + self.attachmentId = thumbnailAttachment.id + } + else { + self.attachmentId = nil + } + + // Make sure the quote is valid before completing + if self.body == nil && self.attachmentId == nil { + return nil + } + + } } diff --git a/SessionMessagingKit/Database/Models/RecipientState.swift b/SessionMessagingKit/Database/Models/RecipientState.swift index 7cd50f9a0..b35d5e743 100644 --- a/SessionMessagingKit/Database/Models/RecipientState.swift +++ b/SessionMessagingKit/Database/Models/RecipientState.swift @@ -4,12 +4,12 @@ import Foundation import GRDB import SessionUtilitiesKit -public struct RecipientState: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct RecipientState: Codable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "recipientState" } internal static let profileForeignKey = ForeignKey([Columns.recipientId], to: [Profile.Columns.id]) internal static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id]) private static let profile = hasOne(Profile.self, using: profileForeignKey) - private static let interaction = belongsTo(Interaction.self, using: interactionForeignKey) + internal static let interaction = belongsTo(Interaction.self, using: interactionForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -17,6 +17,7 @@ public struct RecipientState: Codable, FetchableRecord, PersistableRecord, Table case recipientId case state case readTimestampMs + case mostRecentFailureText } public enum State: Int, Codable, DatabaseValueConvertible { @@ -41,7 +42,9 @@ public struct RecipientState: Codable, FetchableRecord, PersistableRecord, Table /// /// **Note:** This currently will be set when opening the thread for the first time after receiving this interaction /// rather than when the interaction actually appears on the screen - public fileprivate(set) var readTimestampMs: Double? = nil // TODO: Add setter + public let readTimestampMs: Int64? + + public let mostRecentFailureText: String? // MARK: - Relationships @@ -52,4 +55,38 @@ public struct RecipientState: Codable, FetchableRecord, PersistableRecord, Table public var profile: QueryInterfaceRequest { request(for: RecipientState.profile) } + + // MARK: - Initialization + + public init( + interactionId: Int64, + recipientId: String, + state: State, + readTimestampMs: Int64? = nil, + mostRecentFailureText: String? = nil + ) { + self.interactionId = interactionId + self.recipientId = recipientId + self.state = state + self.readTimestampMs = readTimestampMs + self.mostRecentFailureText = mostRecentFailureText + } +} + +// MARK: - Mutation + +public extension RecipientState { + func with( + state: State? = nil, + readTimestampMs: Int64? = nil, + mostRecentFailureText: String? = nil + ) -> RecipientState { + return RecipientState( + interactionId: interactionId, + recipientId: recipientId, + state: (state ?? self.state), + readTimestampMs: (readTimestampMs ?? self.readTimestampMs), + mostRecentFailureText: (mostRecentFailureText ?? self.mostRecentFailureText) + ) + } } diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index dbb20be31..7b8411041 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -4,15 +4,16 @@ import Foundation import GRDB import SessionUtilitiesKit -public struct SessionThread: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "thread" } + private static let contact = hasOne(Contact.self, using: Contact.threadForeignKey) private static let closedGroup = hasOne(ClosedGroup.self, using: ClosedGroup.threadForeignKey) private static let openGroup = hasOne(OpenGroup.self, using: OpenGroup.threadForeignKey) private static let disappearingMessagesConfiguration = hasOne( DisappearingMessagesConfiguration.self, using: DisappearingMessagesConfiguration.threadForeignKey ) - private static let interactions = hasMany(Interaction.self, using: Interaction.threadForeignKey) + public static let interactions = hasMany(Interaction.self, using: Interaction.threadForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -23,6 +24,7 @@ public struct SessionThread: Codable, Identifiable, FetchableRecord, Persistable case isPinned case messageDraft case notificationMode + case notificationSound case mutedUntilTimestamp } @@ -45,10 +47,15 @@ public struct SessionThread: Codable, Identifiable, FetchableRecord, Persistable public let isPinned: Bool public let messageDraft: String? public let notificationMode: NotificationMode + public let notificationSound: Preferences.Sound? public let mutedUntilTimestamp: TimeInterval? // MARK: - Relationships + public var contact: QueryInterfaceRequest { + request(for: SessionThread.contact) + } + public var closedGroup: QueryInterfaceRequest { request(for: SessionThread.closedGroup) } @@ -65,4 +72,88 @@ public struct SessionThread: Codable, Identifiable, FetchableRecord, Persistable request(for: SessionThread.interactions) } + + // MARK: - Initialization + + public init( + id: String, + variant: Variant, + creationDateTimestamp: TimeInterval = Date().timeIntervalSince1970, + shouldBeVisible: Bool = false, + isPinned: Bool = false, + messageDraft: String? = nil, + notificationMode: NotificationMode = .all, + notificationSound: Preferences.Sound? = nil, + mutedUntilTimestamp: TimeInterval? = nil + ) { + self.id = id + self.variant = variant + self.creationDateTimestamp = creationDateTimestamp + self.shouldBeVisible = shouldBeVisible + self.isPinned = isPinned + self.messageDraft = messageDraft + self.notificationMode = notificationMode + self.notificationSound = notificationSound + self.mutedUntilTimestamp = mutedUntilTimestamp + } +} + +// MARK: - GRDB Interactions + +public extension SessionThread { + /// The `variant` will be ignored if an existing thread is found + static func fetchOrCreate(_ db: Database, id: ID, variant: Variant) -> SessionThread { + return ((try? fetchOne(db, id: id)) ?? SessionThread(id: id, variant: variant)) + } + + static func messageRequestThreads(_ db: Database) -> QueryInterfaceRequest { + return SessionThread + .filter(Columns.shouldBeVisible == true) + .filter(Columns.variant == Variant.contact) + .filter(Columns.id != getUserHexEncodedPublicKey(db)) + .joining( + optional: contact + .filter(Contact.Columns.isApproved == false) + ) + } + + func isMessageRequest(_ db: Database) -> Bool { + return ( + shouldBeVisible && + variant == .contact && + id != getUserHexEncodedPublicKey(db) && // Note to self + (try? Contact.fetchOne(db, id: id))?.isApproved != true + ) + } +} + +// MARK: - Convenience + +public extension SessionThread { + func isNoteToSelf(_ db: Database? = nil) -> Bool { + return ( + variant == .contact && + id == getUserHexEncodedPublicKey(db) + ) + } + + func name(_ db: Database) -> String { + switch variant { + case .contact: return Profile.displayName(db, id: id) + + case .closedGroup: + guard let name: String = try? closedGroup.fetchOne(db)?.name, !name.isEmpty else { + return "Group" + } + + return name + + case .openGroup: + guard let name: String = try? openGroup.fetchOne(db)?.name, !name.isEmpty else { + return "Group" + } + + return name + } + } } diff --git a/SessionMessagingKit/Database/SSKPreferences.swift b/SessionMessagingKit/Database/SSKPreferences.swift index 9d4c34f5d..297266f7b 100644 --- a/SessionMessagingKit/Database/SSKPreferences.swift +++ b/SessionMessagingKit/Database/SSKPreferences.swift @@ -56,3 +56,27 @@ public class SSKPreferences: NSObject { OWSPrimaryStorage.dbReadWriteConnection().setBool(value, forKey: key, inCollection: collection) } } + +// MARK: - Objective C Support + +public extension SSKPreferences { + @objc(setScreenSecurity:) + static func objc_setScreenSecurity(_ enabled: Bool) { + GRDBStorage.shared.write { db in db[.preferencesAppSwitcherPreviewEnabled] = enabled } + } + + @objc(setAreReadReceiptsEnabled:) + static func objc_setAreReadReceiptsEnabled(_ enabled: Bool) { + GRDBStorage.shared.write { db in db[.areReadReceiptsEnabled] = enabled } + } + + @objc(setTypingIndicatorsEnabled:) + static func objc_setTypingIndicatorsEnabled(_ enabled: Bool) { + GRDBStorage.shared.write { db in db[.typingIndicatorsEnabled] = enabled } + } + + @objc(areTypingIndicatorsEnabled) + static func objc_areTypingIndicatorsEnabled() -> Bool { + return (GRDBStorage.shared.read { db in db[.typingIndicatorsEnabled] } == true) + } +} diff --git a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift index 164f422a9..7da24d920 100644 --- a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift @@ -1,3 +1,5 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + import Foundation import SessionUtilitiesKit import SessionSnodeKit diff --git a/SessionMessagingKit/Jobs/Job.swift b/SessionMessagingKit/Jobs/Job.swift deleted file mode 100644 index 20f9bdc43..000000000 --- a/SessionMessagingKit/Jobs/Job.swift +++ /dev/null @@ -1,12 +0,0 @@ - -@objc(SNJob) -public protocol Job : NSCoding { - var delegate: JobDelegate? { get set } - var id: String? { get set } - var failureCount: UInt { get set } - - static var collection: String { get } - static var maxFailureCount: UInt { get } - - func execute() -} diff --git a/SessionMessagingKit/Jobs/JobRunner.swift b/SessionMessagingKit/Jobs/JobRunner.swift new file mode 100644 index 000000000..9049b4086 --- /dev/null +++ b/SessionMessagingKit/Jobs/JobRunner.swift @@ -0,0 +1,395 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SignalCoreKit +import SessionUtilitiesKit + +public protocol JobExecutor { + static var maxFailureCount: UInt { get } + static var requiresThreadId: Bool { get } + + /// This method contains the logic needed to complete a job + /// + /// **Note:** The code in this method should run synchronously and the various + /// "result" blocks should not be called within a database closure + /// + /// - Parameters: + /// - job: The job which is being run + /// - success: The closure which is called when the job succeeds (with an + /// updated `job` and a flag indicating whether the job should forcibly stop running) + /// - failure: The closure which is called when the job fails (with an updated + /// `job`, an `Error` (if applicable) and a flag indicating whether it was a permanent + /// failure) + /// - deferred: The closure which is called when the job is deferred (with an + /// updated `job`) + static func run( + _ job: Job, + success: @escaping (Job, Bool) -> (), + failure: @escaping (Job, Error?, Bool) -> (), + deferred: @escaping (Job) -> () + ) +} + +public final class JobRunner { + private class Trigger { + private var timer: Timer? + + static func create(timestamp: TimeInterval) -> Trigger { + let trigger: Trigger = Trigger() + trigger.timer = Timer.scheduledTimer( + timeInterval: timestamp, + target: self, + selector: #selector(start), + userInfo: nil, + repeats: false + ) + + return trigger + } + + deinit { timer?.invalidate() } + + @objc func start() { + JobRunner.start() + } + } + + // TODO: Could this be a bottleneck? (single serial queue to process all these jobs? Group by thread?) + // TODO: Multi-thread support + private static let minRetryInterval: TimeInterval = 1 + private static let queueKey: DispatchSpecificKey = DispatchSpecificKey() + private static let queueContext: String = "JobRunner" + private static let internalQueue: DispatchQueue = { + let result: DispatchQueue = DispatchQueue(label: queueContext) + result.setSpecific(key: queueKey, value: queueContext) + + return result + }() + + internal static var executorMap: Atomic<[Job.Variant: JobExecutor.Type]> = Atomic([:]) + private static var nextTrigger: Atomic = Atomic(nil) + private static var isRunning: Atomic = Atomic(false) + private static var jobQueue: Atomic<[Job]> = Atomic([]) + + private static var jobsCurrentlyRunning: Atomic> = Atomic([]) + + // MARK: - Configuration + + public static func add(executor: JobExecutor.Type, for variant: Job.Variant) { + executorMap.mutate { $0[variant] = executor } + } + + // MARK: - Execution + + public static func add(_ db: Database, job: Job?, canStartJob: Bool = true) { + // Store the job into the database (getting an id for it) + guard let updatedJob: Job = try? job?.inserted(db) else { + SNLog("[JobRunner] Unable to add \(job.map { "\($0.variant)" } ?? "unknown") job") + return + } + + switch (canStartJob, updatedJob.behaviour) { + case (false, _), (_, .runOnceNextLaunch): return + default: break + } + + jobQueue.mutate { $0.append(updatedJob) } + + // Start the job runner if needed + db.afterNextTransactionCommit { _ in + if !isRunning.wrappedValue { + start() + } + } + } + + public static func upsert(_ db: Database, job: Job?, canStartJob: Bool = true) { + guard let job: Job = job else { return } // Ignore null jobs + guard let jobId: Int64 = job.id else { + add(db, job: job, canStartJob: canStartJob) + return + } + + // Lock the queue while checking the index and inserting to ensure we don't run into + // any multi-threading shenanigans + var didUpdateExistingJob: Bool = false + + jobQueue.mutate { queue in + if let jobIndex: Array.Index = queue.firstIndex(where: { $0.id == jobId }) { + queue[jobIndex] = job + didUpdateExistingJob = true + } + } + + // If we didn't update an existing job then we need to add it to the queue + guard !didUpdateExistingJob else { return } + + add(db, job: job, canStartJob: canStartJob) + } + + public static func insert(_ db: Database, job: Job?, before otherJob: Job) { + switch job?.behaviour { + case .recurringOnActive, .recurringOnLaunch, .runOnceNextLaunch: + SNLog("[JobRunner] Attempted to insert \(job.map { "\($0.variant)" } ?? "unknown") job before the current one even though it's behaviour is \(job.map { "\($0.behaviour)" } ?? "unknown")") + return + + default: break + } + + // Store the job into the database (getting an id for it) + guard let updatedJob: Job = try? job?.inserted(db) else { + SNLog("[JobRunner] Unable to add \(job.map { "\($0.variant)" } ?? "unknown") job") + return + } + + // Insert the job before the current job (re-adding the current job to + // the start of the queue if it's not in there) - this will mean the new + // job will run and then the otherJob will run (or run again) once it's + // done + jobQueue.mutate { + if !$0.contains(otherJob) { + $0.insert(otherJob, at: 0) + } + + guard let otherJobIndex: Int = $0.firstIndex(of: otherJob) else { return } + + $0.insert(updatedJob, at: otherJobIndex) + } + } + + public static func appDidFinishLaunching() { + // Note: 'appDidBecomeActive' will run on first launch anyway so we can + // leave those jobs out and can wait until then to start the JobRunner + let maybeJobsToRun: [Job]? = GRDBStorage.shared.read { db in + try Job + .filter( + [ + Job.Behaviour.recurringOnLaunch, + Job.Behaviour.runOnceNextLaunch + ].contains(Job.Columns.behaviour) + ) + .fetchAll(db) + } + + guard let jobsToRun: [Job] = maybeJobsToRun else { return } + + jobQueue.mutate { $0.append(contentsOf: jobsToRun) } + } + + public static func appDidBecomeActive() { + let maybeJobsToRun: [Job]? = GRDBStorage.shared.read { db in + try Job + .filter(Job.Columns.behaviour == Job.Behaviour.recurringOnActive) + .fetchAll(db) + } + + guard let jobsToRun: [Job] = maybeJobsToRun else { return } + + jobQueue.mutate { $0.append(contentsOf: jobsToRun) } + + // Start the job runner if needed + if !isRunning.wrappedValue { + start() + } + } + + public static func isCurrentlyRunning(_ job: Job?) -> Bool { + guard let job: Job = job, let jobId: Int64 = job.id else { return false } + + return jobsCurrentlyRunning.wrappedValue.contains(jobId) + } + + // MARK: - Job Running + + public static func start() { + // We only want the JobRunner to run in the main app + guard CurrentAppContext().isMainApp else { return } + guard !isRunning.wrappedValue else { return } + + // The JobRunner runs synchronously we need to ensure this doesn't start + // on the main thread (if it is on the main thread then swap to a different thread) + guard DispatchQueue.getSpecific(key: queueKey) == queueContext else { + internalQueue.async { + start() + } + return + } + + // Get any pending jobs + let maybeJobsToRun: [Job]? = GRDBStorage.shared.read { db in + try Job + .filter( + [ + Job.Behaviour.runOnce, + Job.Behaviour.recurring + ].contains(Job.Columns.behaviour) + ) + .filter(Job.Columns.nextRunTimestamp <= Date().timeIntervalSince1970) + .order(Job.Columns.nextRunTimestamp) + .fetchAll(db) + } + + // If there are no pending jobs then schedule the JobRunner to start again + // when the next scheduled job should start + guard let jobsToRun: [Job] = maybeJobsToRun else { + scheduleNextSoonestJob() + return + } + + // Add the jobs to the queue and run the first job in the queue + jobQueue.mutate { $0.append(contentsOf: jobsToRun) } + runNextJob() + } + + private static func runNextJob() { + // Ensure this is running on the correct queue + guard DispatchQueue.getSpecific(key: queueKey) == queueContext else { + internalQueue.async { + runNextJob() + } + return + } + guard let nextJob: Job = jobQueue.mutate({ $0.popFirst() }) else { + scheduleNextSoonestJob() + isRunning.mutate { $0 = false } + return + } + guard let jobExecutor: JobExecutor.Type = executorMap.wrappedValue[nextJob.variant] else { + SNLog("[JobRunner] Unable to run \(nextJob.variant) job due to missing executor") + handleJobFailed(nextJob, error: JobRunnerError.executorMissing, permanentFailure: true) + return + } + guard !jobExecutor.requiresThreadId || nextJob.threadId != nil else { + SNLog("[JobRunner] Unable to run \(nextJob.variant) job due to missing required threadId") + handleJobFailed(nextJob, error: JobRunnerError.requiredThreadIdMissing, permanentFailure: true) + return + } + + // Update the state to indicate it's running + // + // Note: We need to store 'numJobsRemaining' in it's own variable because + // the 'SNLog' seems to dispatch to it's own queue which ends up getting + // blocked by the JobRunner's queue becuase 'jobQueue' is Atomic + let numJobsRemaining: Int = jobQueue.wrappedValue.count + nextTrigger.mutate { $0 = nil } + isRunning.mutate { $0 = true } + jobsCurrentlyRunning.mutate { $0 = $0.inserting(nextJob.id) } + SNLog("[JobRunner] Start job (\(numJobsRemaining) remaining)") + + jobExecutor.run( + nextJob, + success: handleJobSucceeded, + failure: handleJobFailed, + deferred: handleJobDeferred + ) + } + + private static func scheduleNextSoonestJob() { + let maybeJob: Job? = GRDBStorage.shared.read { db in + try Job + .filter( + [ + Job.Behaviour.runOnce, + Job.Behaviour.recurring + ].contains(Job.Columns.behaviour) + ) + .order(Job.Columns.nextRunTimestamp) + .fetchOne(db) + } + let targetTimestamp: TimeInterval = (maybeJob?.nextRunTimestamp ?? (Date().timeIntervalSince1970 + minRetryInterval)) + nextTrigger.mutate { $0 = Trigger.create(timestamp: targetTimestamp) } + } + + // MARK: - Handling Results + + /// This function is called when a job succeeds + private static func handleJobSucceeded(_ job: Job, shouldStop: Bool) { + switch job.behaviour { + case .runOnce, .runOnceNextLaunch: + GRDBStorage.shared.write { db in + try job.delete(db) + } + + case .recurring where shouldStop == true: + GRDBStorage.shared.write { db in + try job.delete(db) + } + + case .recurring where job.nextRunTimestamp <= Date().timeIntervalSince1970: + // For `recurring` jobs we want the job to run again but want at least 1 second to pass + GRDBStorage.shared.write { db in + var updatedJob: Job = job.with( + nextRunTimestamp: (Date().timeIntervalSince1970 + 1) + ) + try updatedJob.save(db) + } + + default: break + } + + // The job is removed from the queue before it runs so all we need to to is remove it + // from the 'currentlyRunning' set and start the next one + jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) } + runNextJob() + } + + /// This function is called when a job fails, if it's wasn't a permanent failure then the 'failureCount' for the job will be incremented and it'll + /// be re-run after a retry interval has passed + private static func handleJobFailed(_ job: Job, error: Error?, permanentFailure: Bool) { + guard GRDBStorage.shared.read({ db in try Job.exists(db, id: job.id ?? -1) }) == true else { + SNLog("[JobRunner] \(job.variant) job canceled") + jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) } + runNextJob() + return + } + + GRDBStorage.shared.write { db in + // Check if the job has a 'maxFailureCount' (a value of '0' means it will always retry) + let maxFailureCount: UInt = (executorMap.wrappedValue[job.variant]?.maxFailureCount ?? 0) + + guard + !permanentFailure && + maxFailureCount > 0 && + job.failureCount + 1 < maxFailureCount + else { + // If the job permanently failed or we have performed all of our retry attempts + // then delete the job (it'll probably never succeed) + try job.delete(db) + return + } + + SNLog("[JobRunner] \(job.variant) job failed; scheduling retry (failure count is \(job.failureCount + 1))") + _ = try job + .with( + failureCount: (job.failureCount + 1), + nextRunTimestamp: (Date().timeIntervalSince1970 + getRetryInterval(for: job)) + ) + .saved(db) + } + + jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) } + runNextJob() + } + + /// This function is called when a job neither succeeds or fails (this should only occur if the job has specific logic that makes it dependant + /// on other jobs, and it should automatically manage those dependencies) + private static func handleJobDeferred(_ job: Job) { + jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) } + runNextJob() + } + + // MARK: - Convenience + + private static func getRetryInterval(for job: Job) -> TimeInterval { + // Arbitrary backoff factor... + // try 1 delay: 0.5s + // try 2 delay: 1s + // ... + // try 5 delay: 16s + // ... + // try 11 delay: 512s + let maxBackoff: Double = 10 * 60 // 10 minutes + return 0.25 * min(maxBackoff, pow(2, Double(job.failureCount))) + } +} diff --git a/SessionMessagingKit/Jobs/JobRunnerError.swift b/SessionMessagingKit/Jobs/JobRunnerError.swift new file mode 100644 index 000000000..0cedc9968 --- /dev/null +++ b/SessionMessagingKit/Jobs/JobRunnerError.swift @@ -0,0 +1,12 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum JobRunnerError: Error { + case generic + + case executorMissing + case requiredThreadIdMissing + + case missingRequiredDetails +} diff --git a/SessionMessagingKit/Jobs/MessageReceiveJob.swift b/SessionMessagingKit/Jobs/MessageReceiveJob.swift deleted file mode 100644 index eaebc38c6..000000000 --- a/SessionMessagingKit/Jobs/MessageReceiveJob.swift +++ /dev/null @@ -1,111 +0,0 @@ -import SessionUtilitiesKit -import PromiseKit - -public final class MessageReceiveJob : NSObject, Job, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility - public let data: Data - public let serverHash: String? - public let openGroupMessageServerID: UInt64? - public let openGroupID: String? - public let isBackgroundPoll: Bool - public var delegate: JobDelegate? - public var id: String? - public var failureCount: UInt = 0 - - // MARK: Settings - public class var collection: String { return "MessageReceiveJobCollection" } - public static let maxFailureCount: UInt = 10 - - // MARK: Initialization - public init(data: Data, serverHash: String? = nil, openGroupMessageServerID: UInt64? = nil, openGroupID: String? = nil, isBackgroundPoll: Bool) { - self.data = data - self.serverHash = serverHash - self.openGroupMessageServerID = openGroupMessageServerID - self.openGroupID = openGroupID - self.isBackgroundPoll = isBackgroundPoll - #if DEBUG - if openGroupMessageServerID != nil { assert(openGroupID != nil) } - if openGroupID != nil { assert(openGroupMessageServerID != nil) } - #endif - } - - // MARK: Coding - public init?(coder: NSCoder) { - guard let data = coder.decodeObject(forKey: "data") as! Data?, - let id = coder.decodeObject(forKey: "id") as! String?, - let isBackgroundPoll = coder.decodeObject(forKey: "isBackgroundPoll") as! Bool? else { return nil } - self.data = data - self.serverHash = coder.decodeObject(forKey: "serverHash") as! String? - self.openGroupMessageServerID = coder.decodeObject(forKey: "openGroupMessageServerID") as! UInt64? - self.openGroupID = coder.decodeObject(forKey: "openGroupID") as! String? - self.isBackgroundPoll = isBackgroundPoll - self.id = id - self.failureCount = coder.decodeObject(forKey: "failureCount") as! UInt? ?? 0 - } - - public func encode(with coder: NSCoder) { - coder.encode(data, forKey: "data") - coder.encode(serverHash, forKey: "serverHash") - coder.encode(openGroupMessageServerID, forKey: "openGroupMessageServerID") - coder.encode(openGroupID, forKey: "openGroupID") - coder.encode(isBackgroundPoll, forKey: "isBackgroundPoll") - coder.encode(id, forKey: "id") - coder.encode(failureCount, forKey: "failureCount") - } - - // MARK: Running - public func execute() { - let _: Promise = execute() - } - - public func execute() -> Promise { - if let id = id { // Can be nil (e.g. when background polling) - JobQueue.currentlyExecutingJobs.insert(id) - } - let (promise, seal) = Promise.pending() - - GRDBStorage.shared.writeAsync( - updates: { db in - SNMessagingKitConfiguration.shared.storage.write(with: { transaction in // Intentionally capture self - do { - let isRetry = (self.failureCount != 0) - let (message, proto) = try MessageReceiver.parse(db, self.data, openGroupMessageServerID: self.openGroupMessageServerID, isRetry: isRetry, using: transaction) - message.serverHash = self.serverHash - try MessageReceiver.handle(db, message, associatedWithProto: proto, openGroupID: self.openGroupID, isBackgroundPoll: self.isBackgroundPoll, using: transaction) - self.handleSuccess() - seal.fulfill(()) - } catch { - if let error = error as? MessageReceiver.Error, !error.isRetryable { - SNLog("Message receive job permanently failed due to error: \(error).") - self.handlePermanentFailure(error: error) - } else { - SNLog("Couldn't receive message due to error: \(error).") - self.handleFailure(error: error) - } - seal.fulfill(()) // The promise is just used to keep track of when we're done - } - }, completion: { }) - }, - completion: { _, result in - switch result { - case .failure(let error): self.handleFailure(error: error) - default: break - } - } - ) - - return promise - } - - private func handleSuccess() { - delegate?.handleJobSucceeded(self) - } - - private func handlePermanentFailure(error: Error) { - delegate?.handleJobFailedPermanently(self, with: error) - } - - private func handleFailure(error: Error) { - delegate?.handleJobFailed(self, with: error) - } -} - diff --git a/SessionMessagingKit/Jobs/MessageSendJob.swift b/SessionMessagingKit/Jobs/MessageSendJob.swift deleted file mode 100644 index e1cbad17d..000000000 --- a/SessionMessagingKit/Jobs/MessageSendJob.swift +++ /dev/null @@ -1,144 +0,0 @@ -import SessionUtilitiesKit -import SessionSnodeKit - -@objc(SNMessageSendJob) -public final class MessageSendJob : NSObject, Job, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility - public let message: Message - public let destination: Message.Destination - public var delegate: JobDelegate? - public var id: String? - public var failureCount: UInt = 0 - - // MARK: Settings - public class var collection: String { return "MessageSendJobCollection" } - public static let maxFailureCount: UInt = 10 - - // MARK: Initialization - @objc public convenience init(message: Message, publicKey: String) { self.init(message: message, destination: .contact(publicKey: publicKey)) } - @objc public convenience init(message: Message, groupPublicKey: String) { self.init(message: message, destination: .closedGroup(groupPublicKey: groupPublicKey)) } - - public init(message: Message, destination: Message.Destination) { - self.message = message - self.destination = destination - } - - // MARK: Coding - public init?(coder: NSCoder) { - guard let message = coder.decodeObject(forKey: "message") as! Message?, - var rawDestination = coder.decodeObject(forKey: "destination") as! String?, - let id = coder.decodeObject(forKey: "id") as! String? else { return nil } - self.message = message - if rawDestination.removePrefix("contact(") { - guard rawDestination.removeSuffix(")") else { return nil } - let publicKey = rawDestination - destination = .contact(publicKey: publicKey) - } else if rawDestination.removePrefix("closedGroup(") { - guard rawDestination.removeSuffix(")") else { return nil } - let groupPublicKey = rawDestination - destination = .closedGroup(groupPublicKey: groupPublicKey) - } else if rawDestination.removePrefix("openGroup(") { - guard rawDestination.removeSuffix(")") else { return nil } - let components = rawDestination.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } - guard components.count == 2, let channel = UInt64(components[0]) else { return nil } - let server = components[1] - destination = .openGroup(channel: channel, server: server) - } else if rawDestination.removePrefix("openGroupV2(") { - guard rawDestination.removeSuffix(")") else { return nil } - let components = rawDestination.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } - guard components.count == 2 else { return nil } - let room = components[0] - let server = components[1] - destination = .openGroupV2(room: room, server: server) - } else { - return nil - } - self.id = id - self.failureCount = coder.decodeObject(forKey: "failureCount") as! UInt? ?? 0 - } - - public func encode(with coder: NSCoder) { - coder.encode(message, forKey: "message") - switch destination { - case .contact(let publicKey): coder.encode("contact(\(publicKey))", forKey: "destination") - case .closedGroup(let groupPublicKey): coder.encode("closedGroup(\(groupPublicKey))", forKey: "destination") - case .openGroup(let channel, let server): coder.encode("openGroup(\(channel), \(server))", forKey: "destination") - case .openGroupV2(let room, let server): coder.encode("openGroupV2(\(room), \(server))", forKey: "destination") - } - coder.encode(id, forKey: "id") - coder.encode(failureCount, forKey: "failureCount") - } - - // MARK: Running - public func execute() { - if let id = id { - JobQueue.currentlyExecutingJobs.insert(id) - } - let storage = SNMessagingKitConfiguration.shared.storage - if let message = message as? VisibleMessage { - guard TSOutgoingMessage.find(withTimestamp: message.sentTimestamp!) != nil else { return } // The message has been deleted - let attachments = message.attachmentIDs.compactMap { TSAttachment.fetch(uniqueId: $0) as? TSAttachmentStream } - let attachmentsToUpload = attachments.filter { !$0.isUploaded } - attachmentsToUpload.forEach { attachment in - if storage.getAttachmentUploadJob(for: attachment.uniqueId!) != nil { - // Wait for it to finish - } else { - let job = AttachmentUploadJob(attachmentID: attachment.uniqueId!, threadID: message.threadID!, message: message, messageSendJobID: id!) - storage.write(with: { transaction in - JobQueue.shared.add(job, using: transaction) - }, completion: { }) - } - } - if !attachmentsToUpload.isEmpty { return } // Wait for all attachments to upload before continuing - } - storage.write(with: { transaction in // Intentionally capture self - MessageSender.send(self.message, to: self.destination, using: transaction).done(on: DispatchQueue.global(qos: .userInitiated)) { - self.handleSuccess() - }.catch(on: DispatchQueue.global(qos: .userInitiated)) { error in - SNLog("Couldn't send message due to error: \(error).") - if let error = error as? MessageSender.Error, !error.isRetryable { - self.handlePermanentFailure(error: error) - } else if let error = error as? OnionRequestAPI.Error, case .httpRequestFailedAtDestination(let statusCode, _, _) = error, - statusCode == 429 { // Rate limited - self.handlePermanentFailure(error: error) - } else { - self.handleFailure(error: error) - } - } - }, completion: { }) - } - - private func handleSuccess() { - delegate?.handleJobSucceeded(self) - } - - private func handlePermanentFailure(error: Error) { - delegate?.handleJobFailedPermanently(self, with: error) - } - - private func handleFailure(error: Error) { - SNLog("Failed to send \(type(of: message)).") - if let message = message as? VisibleMessage { - guard TSOutgoingMessage.find(withTimestamp: message.sentTimestamp!) != nil else { return } // The message has been deleted - } - delegate?.handleJobFailed(self, with: error) - } -} - -// MARK: Convenience -private extension String { - - @discardableResult - mutating func removePrefix(_ prefix: T) -> Bool { - guard hasPrefix(prefix) else { return false } - removeFirst(prefix.count) - return true - } - - @discardableResult - mutating func removeSuffix(_ suffix: T) -> Bool { - guard hasSuffix(suffix) else { return false } - removeLast(suffix.count) - return true - } -} - diff --git a/SessionMessagingKit/Jobs/NotifyPNServerJob.swift b/SessionMessagingKit/Jobs/NotifyPNServerJob.swift deleted file mode 100644 index 82c57b3f3..000000000 --- a/SessionMessagingKit/Jobs/NotifyPNServerJob.swift +++ /dev/null @@ -1,69 +0,0 @@ -import PromiseKit -import SessionSnodeKit -import SessionUtilitiesKit - -public final class NotifyPNServerJob : NSObject, Job, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility - public let message: SnodeMessage - public var delegate: JobDelegate? - public var id: String? - public var failureCount: UInt = 0 - - // MARK: Settings - public class var collection: String { return "NotifyPNServerJobCollection" } - public static let maxFailureCount: UInt = 20 - - // MARK: Initialization - init(message: SnodeMessage) { - self.message = message - } - - // MARK: Coding - public init?(coder: NSCoder) { - guard let message = coder.decodeObject(forKey: "message") as! SnodeMessage?, - let id = coder.decodeObject(forKey: "id") as! String? else { return nil } - self.message = message - self.id = id - self.failureCount = coder.decodeObject(forKey: "failureCount") as! UInt? ?? 0 - } - - public func encode(with coder: NSCoder) { - coder.encode(message, forKey: "message") - coder.encode(id, forKey: "id") - coder.encode(failureCount, forKey: "failureCount") - } - - // MARK: Running - public func execute() { - let _: Promise = execute() - } - - public func execute() -> Promise { - if let id = id { - JobQueue.currentlyExecutingJobs.insert(id) - } - let server = PushNotificationAPI.server - let parameters = [ "data" : message.data.description, "send_to" : message.recipient ] - let url = URL(string: "\(server)/notify")! - let request = TSRequest(url: url, method: "POST", parameters: parameters) - request.allHTTPHeaderFields = [ "Content-Type" : "application/json" ] - let promise = attempt(maxRetryCount: 4, recoveringOn: DispatchQueue.global()) { - OnionRequestAPI.sendOnionRequest(request, to: server, target: "/loki/v2/lsrpc", using: PushNotificationAPI.serverPublicKey).map { _ in } - } - let _ = promise.done(on: DispatchQueue.global()) { // Intentionally capture self - self.handleSuccess() - } - promise.catch(on: DispatchQueue.global()) { error in - self.handleFailure(error: error) - } - return promise - } - - private func handleSuccess() { - delegate?.handleJobSucceeded(self) - } - - private func handleFailure(error: Error) { - delegate?.handleJobFailed(self, with: error) - } -} - diff --git a/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift b/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift new file mode 100644 index 000000000..aa7b8569d --- /dev/null +++ b/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift @@ -0,0 +1,100 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public enum DisappearingMessagesJob: JobExecutor { + public static let maxFailureCount: UInt = 0 + public static let requiresThreadId: Bool = false + + public static func run( + _ job: Job, + success: @escaping (Job, Bool) -> (), + failure: @escaping (Job, Error?, Bool) -> (), + deferred: @escaping (Job) -> () + ) { + // The 'backgroundTask' gets captured and cleared within the 'completion' block + let timestampNowMs: TimeInterval = (Date().timeIntervalSince1970 * 1000) + var backgroundTask: OWSBackgroundTask? = OWSBackgroundTask(label: #function) + + let updatedJob: Job? = GRDBStorage.shared.write { db in + _ = try Interaction + .filter(Interaction.Columns.expiresStartedAtMs != nil) + .filter(sql: "(\(Interaction.Columns.expiresStartedAtMs) + (\(Interaction.Columns.expiresInSeconds) * 1000) <= \(timestampNowMs)") + .deleteAll(db) + + // Update the next run timestamp for the DisappearingMessagesJob + return updateNextRunIfNeeded(db) + } + + success(updatedJob ?? job, false) + + // The 'if' is only there to prevent the "variable never read" warning from showing + if backgroundTask != nil { backgroundTask = nil } + } +} + +// MARK: - Convenience + +public extension DisappearingMessagesJob { + @discardableResult static func updateNextRunIfNeeded(_ db: Database) -> Job? { + // Don't schedule run when inactive or not in main app + var isMainAppActive = false + if let sharedUserDefaults = UserDefaults(suiteName: "group.com.loki-project.loki-messenger") { + isMainAppActive = sharedUserDefaults[.isMainAppActive] + } + guard isMainAppActive else { return nil } + + // If there is another expiring message then update the job to run 1 second after it's meant to expire + let nextExpirationTimestampMs: Double? = try? Double + .fetchOne( + db, + Interaction + .select(sql: "(\(Interaction.Columns.expiresStartedAtMs) + (\(Interaction.Columns.expiresInSeconds) * 1000)") + .order(sql: "(\(Interaction.Columns.expiresStartedAtMs) + (\(Interaction.Columns.expiresInSeconds) * 1000) asc") + ) + + guard let nextExpirationTimestampMs: Double = nextExpirationTimestampMs else { return nil } + + return try? Job + .filter(Job.Columns.variant == Job.Variant.disappearingMessages) + .fetchOne(db)? + .with(nextRunTimestamp: ((nextExpirationTimestampMs / 1000) + 1)) + .saved(db) + } + + @discardableResult static func updateNextRunIfNeeded(_ db: Database, interactionIds: [Int64], startedAtMs: Double) -> Bool { + // Update the expiring messages expiresStartedAtMs value + let changeCount: Int? = try? Interaction + .filter(interactionIds.contains(Interaction.Columns.id)) + .filter(Interaction.Columns.expiresInSeconds != nil && Interaction.Columns.expiresStartedAtMs == nil) + .updateAll(db, Interaction.Columns.expiresStartedAtMs.set(to: startedAtMs)) + + // If there were no changes then none of the provided `interactionIds` are expiring messages + guard (changeCount ?? 0) > 0 else { return false } + + return (updateNextRunIfNeeded(db) != nil) + } + + @discardableResult static func updateNextRunIfNeeded(_ db: Database, interaction: Interaction, startedAtMs: Double) -> Bool { + guard interaction.isExpiringMessage else { return false } + + // Don't clobber if multiple actions simultaneously triggered expiration + guard interaction.expiresStartedAtMs == nil || (interaction.expiresStartedAtMs ?? 0) > startedAtMs else { + return false + } + + do { + guard let interactionId: Int64 = try? (interaction.id ?? interaction.inserted(db).id) else { + throw GRDBStorageError.objectNotFound + } + + return updateNextRunIfNeeded(db, interactionIds: [interactionId], startedAtMs: startedAtMs) + } + catch { + SNLog("Failed to update the expiring messages timer on an interaction") + return false + } + } +} diff --git a/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift b/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift new file mode 100644 index 000000000..ceb84c01b --- /dev/null +++ b/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift @@ -0,0 +1,29 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SignalCoreKit +import SessionUtilitiesKit + +public enum FailedAttachmentDownloadsJob: JobExecutor { + public static let maxFailureCount: UInt = 0 + public static let requiresThreadId: Bool = false + + public static func run( + _ job: Job, + success: @escaping (Job, Bool) -> (), + failure: @escaping (Job, Error?, Bool) -> (), + deferred: @escaping (Job) -> () + ) { + // Update all 'sending' message states to 'failed' + GRDBStorage.shared.write { db in + let changeCount: Int = try Attachment + .filter(Attachment.Columns.state == Attachment.State.downloading) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failed)) + + Logger.debug("Marked \(changeCount) attachments as failed") + } + + success(job, false) + } +} diff --git a/SessionMessagingKit/Jobs/Types/FailedMessagesJob.swift b/SessionMessagingKit/Jobs/Types/FailedMessagesJob.swift new file mode 100644 index 000000000..0017d680c --- /dev/null +++ b/SessionMessagingKit/Jobs/Types/FailedMessagesJob.swift @@ -0,0 +1,29 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SignalCoreKit +import SessionUtilitiesKit + +public enum FailedMessagesJob: JobExecutor { + public static let maxFailureCount: UInt = 0 + public static let requiresThreadId: Bool = false + + public static func run( + _ job: Job, + success: @escaping (Job, Bool) -> (), + failure: @escaping (Job, Error?, Bool) -> (), + deferred: @escaping (Job) -> () + ) { + // Update all 'sending' message states to 'failed' + GRDBStorage.shared.write { db in + let changeCount: Int = try RecipientState + .filter(RecipientState.Columns.state == RecipientState.State.sending) + .updateAll(db, RecipientState.Columns.state.set(to: RecipientState.State.failed)) + + Logger.debug("Marked \(changeCount) messages as failed") + } + + success(job, false) + } +} diff --git a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift new file mode 100644 index 000000000..759ffe2f1 --- /dev/null +++ b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift @@ -0,0 +1,74 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import PromiseKit +import SessionUtilitiesKit + +public enum MessageReceiveJob: JobExecutor { + public static var maxFailureCount: UInt = 10 + public static var requiresThreadId: Bool = true + + public static func run( + _ job: Job, + success: @escaping (Job, Bool) -> (), + failure: @escaping (Job, Error?, Bool) -> (), + deferred: @escaping (Job) -> () + ) { + guard + let detailsData: Data = job.details, + let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData) + else { + failure(job, JobRunnerError.missingRequiredDetails, false) + return + } + + var processingError: Error? + + GRDBStorage.shared.write { db in + do { + let isRetry: Bool = (job.failureCount > 0) + let (message, proto) = try MessageReceiver.parse( + db, + data: details.data, + isRetry: isRetry + ) + message.serverHash = details.serverHash + + try MessageReceiver.handle( + db, + message: message, + associatedWithProto: proto, + openGroupId: nil, + isBackgroundPoll: details.isBackgroundPoll + ) + } + catch { + processingError = error + } + } + + // Handle the result + switch processingError { + case let error as MessageReceiverError where !error.isRetryable: + SNLog("Message receive job permanently failed due to error: \(error)") + failure(job, error, true) + + case .some(let error): + SNLog("Couldn't receive message due to error: \(error)") + failure(job, error, true) + + case .none: + success(job, false) + } + } +} + +// MARK: - MessageReceiveJob.Details + +extension MessageReceiveJob { + public struct Details: Codable { + public let data: Data + public let serverHash: String? + public let isBackgroundPoll: Bool + } +} diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift new file mode 100644 index 000000000..af927afc1 --- /dev/null +++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift @@ -0,0 +1,350 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import PromiseKit +import SignalCoreKit +import SessionUtilitiesKit +import SessionSnodeKit + +public enum MessageSendJob: JobExecutor { + public static var maxFailureCount: UInt = 10 + public static var requiresThreadId: Bool = true + + public static func run( + _ job: Job, + success: @escaping (Job, Bool) -> (), + failure: @escaping (Job, Error?, Bool) -> (), + deferred: @escaping (Job) -> () + ) { + guard + let jobId: Int64 = job.id, // Need the 'job.id' in order to execute a MessageSendJob + let detailsData: Data = job.details, + let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData) + else { + failure(job, JobRunnerError.missingRequiredDetails, false) + return + } + + if details.message is VisibleMessage { + guard + let interactionId: Int64 = details.interactionId, + let threadId: String = job.threadId, + let interaction: Interaction = GRDBStorage.shared.read({ db in try Interaction.fetchOne(db, id: interactionId) }) + else { + failure(job, JobRunnerError.missingRequiredDetails, false) + return + } + + var shouldDeferJob: Bool = false + + GRDBStorage.shared.read { db in + // Fetch all associated attachments + let attachments: [Attachment] = try interaction.attachments.fetchAll(db) + + // Create jobs for any pending attachment jobs and insert them into the + // queue before the current job (this will mean the current job will re-run + // after these inserted jobs complete) + let pendingAttachments: [Attachment] = attachments.filter { $0.state == .pending } + pendingAttachments + .forEach { attachment in + JobRunner.insert( + db, + job: Job( + variant: .attachmentUpload, + behaviour: .runOnce, + threadId: job.threadId, + details: AttachmentUploadJob.Details( + threadId: threadId, + attachmentId: attachment.id, + messageSendJobId: jobId + ) + ), + before: job + ) + } + + // If there were pending or uploading attachments then stop here (we want to + // upload them first and then re-run this send job - the 'JobRunner.insert' + // method will take care of this) + shouldDeferJob = ( + !pendingAttachments.isEmpty || + attachments.contains(where: { $0.state == .uploading }) + ) + } + + // Only continue if we don't want to defer the job + guard !shouldDeferJob else { + deferred(job) + return + } + } + + // Perform the actual message sending + GRDBStorage.shared.write { db -> Promise in + try MessageSender.send( + db, + message: details.message, + to: details.destination, + interactionId: details.interactionId + ) + } + .done2 { _ in success(job, false) } + .catch2 { error in + SNLog("Couldn't send message due to error: \(error).") + + switch error { + case let senderError as MessageSenderError where !senderError.isRetryable: + failure(job, error, true) + + case OnionRequestAPI.Error.httpRequestFailedAtDestination(let statusCode, _, _) where statusCode == 429: // Rate limited + failure(job, error, true) + + default: + SNLog("Failed to send \(type(of: details.message)).") + + if details.message is VisibleMessage { + guard + let interactionId: Int64 = details.interactionId, + GRDBStorage.shared.read({ db in try Interaction.exists(db, id: interactionId) }) == true + else { + // The message has been deleted so permanently fail the job + failure(job, error, true) + return + } + } + + failure(job, error, false) + } + } + } +} + +// MARK: - MessageSendJob.Details + +extension MessageSendJob { + public struct Details: Codable { + // Note: This approach is less than ideal (since it needs to be manually maintained) but + // I couldn't think of an easy way to support a generic decoded type for the 'message' + // value in the database while using Codable + private static let supportedMessageTypes: [String: Message.Type] = [ + "VisibleMessage": VisibleMessage.self, + + "ReadReceipt": ReadReceipt.self, + "TypingIndicator": TypingIndicator.self, + "ClosedGroupControlMessage": ClosedGroupControlMessage.self, + "DataExtractionNotification": DataExtractionNotification.self, + "ExpirationTimerUpdate": ExpirationTimerUpdate.self, + "ConfigurationMessage": ConfigurationMessage.self, + "UnsendRequest": UnsendRequest.self, + "MessageRequestResponse": MessageRequestResponse.self + ] + + private enum CodingKeys: String, CodingKey { + case interactionId + case destination + case messageType + case message + } + + public let interactionId: Int64? + public let destination: Message.Destination + public let message: Message + + // MARK: - Initialization + + public init( + interactionId: Int64? = nil, + destination: Message.Destination, + message: Message + ) { + self.interactionId = interactionId + self.destination = destination + self.message = message + } + + public init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + guard let messageType: String = try? container.decode(String.self, forKey: .messageType) else { + Logger.error("Unable to decode messageSend job due to missing messageType") + throw GRDBStorageError.decodingFailed + } + + /// Note: This **MUST** be a `Codable.Type` rather than a `Message.Type` otherwise the decoding will result + /// in a `Message` object being returned rather than the desired subclass + guard let MessageType: Codable.Type = MessageSendJob.Details.supportedMessageTypes[messageType] else { + Logger.error("Unable to decode messageSend job due to unsupported messageType") + throw GRDBStorageError.decodingFailed + } + guard let message: Message = try MessageType.decoded(with: container, forKey: .message) as? Message else { + Logger.error("Unable to decode messageSend job due to message conversion issue") + throw GRDBStorageError.decodingFailed + } + + self = Details( + interactionId: try? container.decode(Int64.self, forKey: .interactionId), + destination: try container.decode(Message.Destination.self, forKey: .destination), + message: message + ) + } + + public func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + let messageType: Codable.Type = type(of: message) + let maybeMessageTypeString: String? = MessageSendJob.Details.supportedMessageTypes + .first(where: { _, type in messageType == type })? + .key + + guard let messageTypeString: String = maybeMessageTypeString else { + Logger.error("Unable to encode messageSend job due to unsupported messageType") + throw GRDBStorageError.objectNotFound + } + + try container.encodeIfPresent(interactionId, forKey: .interactionId) + try container.encode(destination, forKey: .destination) + try container.encode(messageTypeString, forKey: .messageType) + try container.encode(message, forKey: .message) + } + } +} + +// public let message: Message +// public let destination: Message.Destination +// public var delegate: JobDelegate? +// public var id: String? +// public var failureCount: UInt = 0 +// +// // MARK: Settings +// public class var collection: String { return "MessageSendJobCollection" } +// public static let maxFailureCount: UInt = 10 +// +// // MARK: Initialization +// @objc public convenience init(message: Message, publicKey: String) { self.init(message: message, destination: .contact(publicKey: publicKey)) } +// @objc public convenience init(message: Message, groupPublicKey: String) { self.init(message: message, destination: .closedGroup(groupPublicKey: groupPublicKey)) } +// +// public init(message: Message, destination: Message.Destination) { +// self.message = message +// self.destination = destination +// } +// +// // MARK: Coding +// public init?(coder: NSCoder) { +// guard let message = coder.decodeObject(forKey: "message") as! Message?, +// var rawDestination = coder.decodeObject(forKey: "destination") as! String?, +// let id = coder.decodeObject(forKey: "id") as! String? else { return nil } +// self.message = message +// if rawDestination.removePrefix("contact(") { +// guard rawDestination.removeSuffix(")") else { return nil } +// let publicKey = rawDestination +// destination = .contact(publicKey: publicKey) +// } else if rawDestination.removePrefix("closedGroup(") { +// guard rawDestination.removeSuffix(")") else { return nil } +// let groupPublicKey = rawDestination +// destination = .closedGroup(groupPublicKey: groupPublicKey) +// } else if rawDestination.removePrefix("openGroup(") { +// guard rawDestination.removeSuffix(")") else { return nil } +// let components = rawDestination.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } +// guard components.count == 2, let channel = UInt64(components[0]) else { return nil } +// let server = components[1] +// destination = .openGroup(channel: channel, server: server) +// } else if rawDestination.removePrefix("openGroupV2(") { +// guard rawDestination.removeSuffix(")") else { return nil } +// let components = rawDestination.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } +// guard components.count == 2 else { return nil } +// let room = components[0] +// let server = components[1] +// destination = .openGroupV2(room: room, server: server) +// } else { +// return nil +// } +// self.id = id +// self.failureCount = coder.decodeObject(forKey: "failureCount") as! UInt? ?? 0 +// } +// +// public func encode(with coder: NSCoder) { +// coder.encode(message, forKey: "message") +// switch destination { +// case .contact(let publicKey): coder.encode("contact(\(publicKey))", forKey: "destination") +// case .closedGroup(let groupPublicKey): coder.encode("closedGroup(\(groupPublicKey))", forKey: "destination") +// case .openGroup(let channel, let server): coder.encode("openGroup(\(channel), \(server))", forKey: "destination") +// case .openGroupV2(let room, let server): coder.encode("openGroupV2(\(room), \(server))", forKey: "destination") +// } +// coder.encode(id, forKey: "id") +// coder.encode(failureCount, forKey: "failureCount") +// } +// +// // MARK: Running +// public func execute() { +// if let id = id { +// JobQueue.currentlyExecutingJobs.insert(id) +// } +// let storage = SNMessagingKitConfiguration.shared.storage +// if let message = message as? VisibleMessage { +// guard TSOutgoingMessage.find(withTimestamp: message.sentTimestamp!) != nil else { return } // The message has been deleted +// let attachments = message.attachmentIDs.compactMap { TSAttachment.fetch(uniqueId: $0) as? TSAttachmentStream } +// let attachmentsToUpload = attachments.filter { !$0.isUploaded } +// attachmentsToUpload.forEach { attachment in +// if storage.getAttachmentUploadJob(for: attachment.uniqueId!) != nil { +// // Wait for it to finish +// } else { +// let job = AttachmentUploadJob(attachmentID: attachment.uniqueId!, threadID: message.threadID!, message: message, messageSendJobID: id!) +// storage.write(with: { transaction in +// JobQueue.shared.add(job, using: transaction) +// }, completion: { }) +// } +// } +// if !attachmentsToUpload.isEmpty { return } // Wait for all attachments to upload before continuing +// } +// storage.write(with: { transaction in // Intentionally capture self +// MessageSender.send(self.message, to: self.destination, using: transaction).done(on: DispatchQueue.global(qos: .userInitiated)) { +// self.handleSuccess() +// }.catch(on: DispatchQueue.global(qos: .userInitiated)) { error in +// SNLog("Couldn't send message due to error: \(error).") +// if let error = error as? MessageSender.Error, !error.isRetryable { +// self.handlePermanentFailure(error: error) +// } else if let error = error as? OnionRequestAPI.Error, case .httpRequestFailedAtDestination(let statusCode, _, _) = error, +// statusCode == 429 { // Rate limited +// self.handlePermanentFailure(error: error) +// } else { +// self.handleFailure(error: error) +// } +// } +// }, completion: { }) +// } +// +// private func handleSuccess() { +// delegate?.handleJobSucceeded(self) +// } +// +// private func handlePermanentFailure(error: Error) { +// delegate?.handleJobFailedPermanently(self, with: error) +// } +// +// private func handleFailure(error: Error) { +// SNLog("Failed to send \(type(of: message)).") +// if let message = message as? VisibleMessage { +// guard TSOutgoingMessage.find(withTimestamp: message.sentTimestamp!) != nil else { return } // The message has been deleted +// } +// delegate?.handleJobFailed(self, with: error) +// } +//} +// +//// MARK: Convenience +//private extension String { +// +// @discardableResult +// mutating func removePrefix(_ prefix: T) -> Bool { +// guard hasPrefix(prefix) else { return false } +// removeFirst(prefix.count) +// return true +// } +// +// @discardableResult +// mutating func removeSuffix(_ suffix: T) -> Bool { +// guard hasSuffix(suffix) else { return false } +// removeLast(suffix.count) +// return true +// } +//} +// diff --git a/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift b/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift new file mode 100644 index 000000000..17475fdf1 --- /dev/null +++ b/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift @@ -0,0 +1,66 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import PromiseKit +import SessionSnodeKit +import SessionUtilitiesKit + +public enum NotifyPushServerJob: JobExecutor { + public static var maxFailureCount: UInt = 20 + public static var requiresThreadId: Bool = false + + public static func run( + _ job: Job, + success: @escaping (Job, Bool) -> (), + failure: @escaping (Job, Error?, Bool) -> (), + deferred: @escaping (Job) -> () + ) { + let server: String = PushNotificationAPI.server + + guard + let url: URL = URL(string: "\(server)/notify"), + let detailsData: Data = job.details, + let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData) + else { + failure(job, JobRunnerError.missingRequiredDetails, false) + return + } + + let parameters: JSON = [ + "data": details.message.data.description, + "send_to": details.message.recipient + ] + + let request = TSRequest(url: url, method: "POST", parameters: parameters) + request.allHTTPHeaderFields = [ + "Content-Type": "application/json" + ] + + let promise = attempt(maxRetryCount: 4, recoveringOn: DispatchQueue.global()) { + OnionRequestAPI + .sendOnionRequest( + request, + to: server, + target: "/loki/v2/lsrpc", + using: PushNotificationAPI.serverPublicKey + ) + .map { _ in } + } + .done { _ in + success(job, false) + } + + promise.catch { error in + failure(job, error, false) + } + promise.retainUntilComplete() + } +} + +// MARK: - NotifyPushServerJob.Details + +extension NotifyPushServerJob { + public struct Details: Codable { + public let message: SnodeMessage + } +} diff --git a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift new file mode 100644 index 000000000..36c31c595 --- /dev/null +++ b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift @@ -0,0 +1,136 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import PromiseKit +import SessionUtilitiesKit + +public enum SendReadReceiptsJob: JobExecutor { + public static let maxFailureCount: UInt = 0 + public static let requiresThreadId: Bool = false + private static let minRunFrequency: TimeInterval = 3 + + public static func run( + _ job: Job, + success: @escaping (Job, Bool) -> (), + failure: @escaping (Job, Error?, Bool) -> (), + deferred: @escaping (Job) -> () + ) { + guard + let threadId: String = job.threadId, + let detailsData: Data = job.details, + let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData) + else { + failure(job, JobRunnerError.missingRequiredDetails, false) + return + } + + // If there are no timestampMs values then the job can just complete (next time + // something is marked as read we want to try and run immediately so don't scuedule + // another run in this case) + guard !details.timestampMsValues.isEmpty else { + success(job, true) + return + } + + GRDBStorage.shared + .write { db in + try MessageSender.send( + db, + message: ReadReceipt( + timestamps: details.timestampMsValues.map { UInt64($0) } + ), + to: details.destination, + interactionId: nil + ) + } + .done { + // When we complete the 'SendReadReceiptsJob' we want to immediately schedule + // another one for the same thread but with a 'nextRunTimestamp' set to the + // 'minRunFrequency' value to throttle the read receipt requests + GRDBStorage.shared.write { db in + _ = try createOrUpdateIfNeeded(db, threadId: threadId, interactionIds: [])? + .with(nextRunTimestamp: (Date().timeIntervalSince1970 + minRunFrequency)) + .saved(db) + } + + success(job, false) + } + .catch { error in failure(job, error, false) } + .retainUntilComplete() + } +} + + +// MARK: - SendReadReceiptsJob.Details + +extension SendReadReceiptsJob { + public struct Details: Codable { + public let destination: Message.Destination + public let timestampMsValues: Set + } +} + +// MARK: - Convenience + +public extension SendReadReceiptsJob { + @discardableResult static func createOrUpdateIfNeeded(_ db: Database, threadId: String, interactionIds: [Int64]) -> Job? { + guard db[.areReadReceiptsEnabled] == true else { return nil } + + // Retrieve the timestampMs values for the specified interactions + let maybeTimestampMsValues: [Int64]? = try? Int64.fetchAll( + db, + Interaction + .select(Interaction.Columns.timestampMs) + .filter(interactionIds.contains(Interaction.Columns.id)) + // Only `standardIncoming` incoming interactions should have read receipts sent + .filter(Interaction.Columns.variant == Interaction.Variant.standardIncoming) + .joining( + // Don't send read receipts in group threads + required: Interaction.thread + .filter(SessionThread.Columns.variant != SessionThread.Variant.closedGroup) + .filter(SessionThread.Columns.variant != SessionThread.Variant.openGroup) + ) + .distinct() + ) + + // If there are no timestamp values then do nothing + guard let timestampMsValues: [Int64] = maybeTimestampMsValues else { return nil } + + // Try to get an existing job (if there is one that's not running) + if + let existingJob: Job = try? Job + .filter(Job.Columns.variant == Job.Variant.sendReadReceipts) + .filter(Job.Columns.threadId == threadId) + .fetchOne(db), + !JobRunner.isCurrentlyRunning(existingJob), + let existingDetailsData: Data = existingJob.details, + let existingDetails: Details = try? JSONDecoder().decode(Details.self, from: existingDetailsData) + { + let maybeUpdatedJob: Job? = existingJob + .with( + details: Details( + destination: existingDetails.destination, + timestampMsValues: existingDetails.timestampMsValues + .union(timestampMsValues) + ) + ) + + guard let updatedJob: Job = maybeUpdatedJob else { return nil } + + return try? updatedJob + .saved(db) + } + + // Otherwise create a new job + return Job( + variant: .sendReadReceipts, + behaviour: .recurring, + threadId: threadId, + details: Details( + destination: .contact(publicKey: threadId), + timestampMsValues: timestampMsValues.asSet() + ) + ) + } +} diff --git a/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift b/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift index a5088c0a4..bf8462f49 100644 --- a/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift @@ -1,11 +1,16 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB import Sodium import Curve25519Kit import SessionUtilitiesKit public final class ClosedGroupControlMessage : ControlMessage { + private enum CodingKeys: String, CodingKey { + case kind + } + public var kind: Kind? public override var ttl: UInt64 { @@ -18,7 +23,19 @@ public final class ClosedGroupControlMessage : ControlMessage { public override var isSelfSendValid: Bool { true } // MARK: Kind - public enum Kind : CustomStringConvertible { + public enum Kind: CustomStringConvertible, Codable { + private enum CodingKeys: String, CodingKey { + case description + case publicKey + case name + case encryptionPublicKey + case encryptionSecretKey + case members + case admins + case expirationTimer + case wrappers + } + case new(publicKey: Data, name: String, encryptionKeyPair: Box.KeyPair, members: [Data], admins: [Data], expirationTimer: UInt32) /// An encryption key pair encrypted for each member individually. /// @@ -41,11 +58,101 @@ public final class ClosedGroupControlMessage : ControlMessage { case .encryptionKeyPairRequest: return "encryptionKeyPairRequest" } } + + public init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + // Compare the descriptions to find the appropriate case + let description: String = try container.decode(String.self, forKey: .description) + let newDescription: String = Kind.new( + publicKey: Data(), + name: "", + encryptionKeyPair: Box.KeyPair(publicKey: [], secretKey: []), + members: [], + admins: [], + expirationTimer: 0 + ).description + + switch description { + case newDescription: + self = .new( + publicKey: try container.decode(Data.self, forKey: .publicKey), + name: try container.decode(String.self, forKey: .name), + encryptionKeyPair: Box.KeyPair( + publicKey: try container.decode([UInt8].self, forKey: .encryptionPublicKey), + secretKey: try container.decode([UInt8].self, forKey: .encryptionSecretKey) + ), + members: try container.decode([Data].self, forKey: .members), + admins: try container.decode([Data].self, forKey: .admins), + expirationTimer: try container.decode(UInt32.self, forKey: .expirationTimer) + ) + + case Kind.encryptionKeyPair(publicKey: nil, wrappers: []).description: + self = .encryptionKeyPair( + publicKey: try? container.decode(Data.self, forKey: .publicKey), + wrappers: try container.decode([ClosedGroupControlMessage.KeyPairWrapper].self, forKey: .wrappers) + ) + + case Kind.nameChange(name: "").description: + self = .nameChange( + name: try container.decode(String.self, forKey: .name) + ) + + case Kind.membersAdded(members: []).description: + self = .membersAdded( + members: try container.decode([Data].self, forKey: .members) + ) + + case Kind.membersRemoved(members: []).description: + self = .membersRemoved( + members: try container.decode([Data].self, forKey: .members) + ) + + case Kind.memberLeft.description: + self = .memberLeft + + case Kind.encryptionKeyPairRequest.description: + self = .encryptionKeyPairRequest + + default: fatalError("Invalid case when trying to decode ClosedGroupControlMessage.Kind") + } + } + + public func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(description, forKey: .description) + + // Note: If you modify the below make sure to update the above 'init(from:)' method + switch self { + case .new(let publicKey, let name, let encryptionKeyPair, let members, let admins, let expirationTimer): + try container.encode(publicKey, forKey: .publicKey) + try container.encode(name, forKey: .name) + try container.encode(encryptionKeyPair.publicKey, forKey: .encryptionPublicKey) + try container.encode(encryptionKeyPair.secretKey, forKey: .encryptionSecretKey) + try container.encode(members, forKey: .members) + try container.encode(admins, forKey: .admins) + try container.encode(expirationTimer, forKey: .expirationTimer) + + case .encryptionKeyPair(let publicKey, let wrappers): + try container.encode(publicKey, forKey: .publicKey) + try container.encode(wrappers, forKey: .wrappers) + + case .nameChange(let name): + try container.encode(name, forKey: .name) + + case .membersAdded(let members), .membersRemoved(let members): + try container.encode(members, forKey: .members) + + case .memberLeft: break // Only 'description' + case .encryptionKeyPairRequest: break // Only 'description' + } + } } // MARK: Key Pair Wrapper @objc(SNKeyPairWrapper) - public final class KeyPairWrapper : NSObject, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility + public final class KeyPairWrapper: NSObject, Codable, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility public var publicKey: String? public var encryptedKeyPair: Data? @@ -143,7 +250,7 @@ public final class ClosedGroupControlMessage : ControlMessage { default: return nil } } - + public override func encode(with coder: NSCoder) { super.encode(with: coder) guard let kind = kind else { return } @@ -175,6 +282,24 @@ public final class ClosedGroupControlMessage : ControlMessage { coder.encode("encryptionKeyPairRequest", forKey: "kind") } } + + // MARK: - Codable + + required init(from decoder: Decoder) throws { + try super.init(from: decoder) + + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + kind = try container.decode(Kind.self, forKey: .kind) + } + + public override func encode(to encoder: Encoder) throws { + try super.encode(to: encoder) + + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(kind, forKey: .kind) + } // MARK: Proto Conversion public override class func fromProto(_ proto: SNProtoContent, sender: String) -> ClosedGroupControlMessage? { @@ -207,7 +332,7 @@ public final class ClosedGroupControlMessage : ControlMessage { return ClosedGroupControlMessage(kind: kind) } - public override func toProto(using transaction: YapDatabaseReadWriteTransaction) -> SNProtoContent? { + public override func toProto(_ db: Database) -> SNProtoContent? { guard let kind = kind else { SNLog("Couldn't construct closed group update proto from: \(self).") return nil @@ -253,7 +378,7 @@ public final class ClosedGroupControlMessage : ControlMessage { let dataMessageProto = SNProtoDataMessage.builder() dataMessageProto.setClosedGroupControlMessage(try closedGroupControlMessage.build()) // Group context - try setGroupContextIfNeeded(on: dataMessageProto, using: transaction) + try setGroupContextIfNeeded(db, on: dataMessageProto) contentProto.setDataMessage(try dataMessageProto.build()) return try contentProto.build() } catch { @@ -271,3 +396,65 @@ public final class ClosedGroupControlMessage : ControlMessage { """ } } + +// MARK: - Convenience + +public extension ClosedGroupControlMessage.Kind { + func infoMessage(_ db: Database, sender: String) throws -> String? { + switch self { + case .nameChange(let name): + return String(format: "GROUP_TITLE_CHANGED".localized(), name) + + case .membersAdded(let membersAsData): + let addedMemberNames: [String] = try Profile + .fetchAll(db, ids: membersAsData.map { $0.toHexString() }) + .map { $0.displayName() } + + return String( + format: "GROUP_MEMBER_JOINED".localized(), + addedMemberNames.joined(separator: ", ") + ) + + case .membersRemoved(let membersAsData): + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let memberIds: Set = membersAsData + .map { $0.toHexString() } + .asSet() + + var infoMessage: String = "" + + if !memberIds.removing(userPublicKey).isEmpty { + let removedMemberNames: [String] = try Profile + .fetchAll(db, ids: memberIds.removing(userPublicKey)) + .map { $0.displayName() } + let format: String = (removedMemberNames.count > 1 ? + "GROUP_MEMBERS_REMOVED".localized() : + "GROUP_MEMBER_REMOVED".localized() + ) + + infoMessage = infoMessage.appending( + String(format: format, removedMemberNames.joined(separator: ", ")) + ) + } + + if memberIds.contains(userPublicKey) { + infoMessage = infoMessage.appending("YOU_WERE_REMOVED".localized()) + } + + return infoMessage + + case .memberLeft: + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + guard sender != userPublicKey else { return "GROUP_YOU_LEFT".localized() } + + if let displayName: String = Profile.displayNameNoFallback(db, id: sender) { + return String(format: "GROUP_MEMBER_LEFT".localized(), displayName) + } + + return "GROUP_UPDATED".localized() + + default: return nil + } + } +} diff --git a/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift b/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift index e200fb82a..880398c0b 100644 --- a/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift +++ b/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift @@ -6,7 +6,7 @@ import SessionUtilitiesKit extension ConfigurationMessage { - public static func getCurrent(_ db: Database) throws -> ConfigurationMessage? { + public static func getCurrent(_ db: Database) throws -> ConfigurationMessage { let profile: Profile = Profile.fetchOrCreateCurrentUser(db) let displayName: String = profile.name diff --git a/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage.swift b/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage.swift index c50c92e44..630c56220 100644 --- a/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage.swift @@ -1,11 +1,21 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB import Curve25519Kit import SessionUtilitiesKit @objc(SNConfigurationMessage) public final class ConfigurationMessage : ControlMessage { + private enum CodingKeys: String, CodingKey { + case closedGroups + case openGroups + case displayName + case profilePictureURL + case profileKey + case contacts + } + public var closedGroups: Set = [] public var openGroups: Set = [] public var displayName: String? @@ -48,6 +58,34 @@ public final class ConfigurationMessage : ControlMessage { coder.encode(profileKey, forKey: "profileKey") coder.encode(contacts, forKey: "contacts") } + + // MARK: - Codable + + required init(from decoder: Decoder) throws { + try super.init(from: decoder) + + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + closedGroups = ((try? container.decode(Set.self, forKey: .closedGroups)) ?? []) + openGroups = ((try? container.decode(Set.self, forKey: .openGroups)) ?? []) + displayName = try? container.decode(String.self, forKey: .displayName) + profilePictureURL = try? container.decode(String.self, forKey: .profilePictureURL) + profileKey = try? container.decode(Data.self, forKey: .profileKey) + contacts = ((try? container.decode(Set.self, forKey: .contacts)) ?? []) + } + + public override func encode(to encoder: Encoder) throws { + try super.encode(to: encoder) + + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encodeIfPresent(closedGroups, forKey: .closedGroups) + try container.encodeIfPresent(openGroups, forKey: .openGroups) + try container.encodeIfPresent(displayName, forKey: .displayName) + try container.encodeIfPresent(profilePictureURL, forKey: .profilePictureURL) + try container.encodeIfPresent(profileKey, forKey: .profileKey) + try container.encodeIfPresent(contacts, forKey: .contacts) + } // MARK: Proto Conversion public override class func fromProto(_ proto: SNProtoContent, sender: String) -> ConfigurationMessage? { @@ -62,7 +100,7 @@ public final class ConfigurationMessage : ControlMessage { closedGroups: closedGroups, openGroups: openGroups, contacts: contacts) } - public override func toProto(using transaction: YapDatabaseReadWriteTransaction) -> SNProtoContent? { + public override func toProto(_ db: Database) -> SNProtoContent? { let configurationProto = SNProtoConfigurationMessage.builder() if let displayName = displayName { configurationProto.setDisplayName(displayName) } if let profilePictureURL = profilePictureURL { configurationProto.setProfilePicture(profilePictureURL) } @@ -99,7 +137,17 @@ public final class ConfigurationMessage : ControlMessage { extension ConfigurationMessage { @objc(SNClosedGroup) - public final class ClosedGroup : NSObject, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility + public final class ClosedGroup: NSObject, Codable, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility + private enum CodingKeys: String, CodingKey { + case publicKey + case name + case encryptionKeyPublicKey + case encryptionKeySecretKey + case members + case admins + case expirationTimer + } + public let publicKey: String public let name: String public let encryptionKeyPair: ECKeyPair @@ -141,6 +189,34 @@ extension ConfigurationMessage { coder.encode(admins, forKey: "admins") coder.encode(expirationTimer, forKey: "expirationTimer") } + + // MARK: - Codable + + public required init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + publicKey = try container.decode(String.self, forKey: .publicKey) + name = try container.decode(String.self, forKey: .name) + encryptionKeyPair = try ECKeyPair( + publicKeyData: try container.decode(Data.self, forKey: .encryptionKeyPublicKey), + privateKeyData: try container.decode(Data.self, forKey: .encryptionKeySecretKey) + ) + members = try container.decode(Set.self, forKey: .members) + admins = try container.decode(Set.self, forKey: .admins) + expirationTimer = try container.decode(UInt32.self, forKey: .expirationTimer) + } + + public func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(publicKey, forKey: .publicKey) + try container.encode(name, forKey: .name) + try container.encode(encryptionKeyPair.publicKey, forKey: .encryptionKeyPublicKey) + try container.encode(encryptionKeyPair.privateKey, forKey: .encryptionKeySecretKey) + try container.encode(members, forKey: .members) + try container.encode(admins, forKey: .admins) + try container.encode(expirationTimer, forKey: .expirationTimer) + } public static func fromProto(_ proto: SNProtoConfigurationMessageClosedGroup) -> ClosedGroup? { guard let publicKey = proto.publicKey?.toHexString(), @@ -192,7 +268,21 @@ extension ConfigurationMessage { extension ConfigurationMessage { @objc(SNConfigurationMessageContact) - public final class CMContact : NSObject, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility + public final class CMContact: NSObject, Codable, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility + private enum CodingKeys: String, CodingKey { + case publicKey + case displayName + case profilePictureURL + case profileKey + + case hasIsApproved + case isApproved + case hasIsBlocked + case isBlocked + case hasDidApproveMe + case didApproveMe + } + public var publicKey: String? public var displayName: String? public var profilePictureURL: String? @@ -258,6 +348,24 @@ extension ConfigurationMessage { coder.encode(hasDidApproveMe, forKey: "hasDidApproveMe") coder.encode(didApproveMe, forKey: "didApproveMe") } + + // MARK: - Codable + + public required init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + publicKey = try? container.decode(String.self, forKey: .publicKey) + displayName = try? container.decode(String.self, forKey: .displayName) + profilePictureURL = try? container.decode(String.self, forKey: .profilePictureURL) + profileKey = try? container.decode(Data.self, forKey: .profileKey) + + hasIsApproved = try container.decode(Bool.self, forKey: .hasIsApproved) + isApproved = try container.decode(Bool.self, forKey: .isApproved) + hasIsBlocked = try container.decode(Bool.self, forKey: .hasIsBlocked) + isBlocked = try container.decode(Bool.self, forKey: .isBlocked) + hasDidApproveMe = try container.decode(Bool.self, forKey: .hasDidApproveMe) + didApproveMe = try container.decode(Bool.self, forKey: .didApproveMe) + } public static func fromProto(_ proto: SNProtoConfigurationMessageContact) -> CMContact? { let result: CMContact = CMContact( diff --git a/SessionMessagingKit/Messages/Control Messages/ControlMessage.swift b/SessionMessagingKit/Messages/Control Messages/ControlMessage.swift index efa4d3862..a9b476153 100644 --- a/SessionMessagingKit/Messages/Control Messages/ControlMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/ControlMessage.swift @@ -1,3 +1,6 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation @objc(SNControlMessage) -public class ControlMessage : Message { } +public class ControlMessage: Message { } diff --git a/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift b/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift index fa901d7c7..0e05bdad0 100644 --- a/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift +++ b/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift @@ -1,10 +1,18 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB import SessionUtilitiesKit public final class DataExtractionNotification : ControlMessage { + private enum CodingKeys: String, CodingKey { + case kind + } + public var kind: Kind? // MARK: Kind - public enum Kind : CustomStringConvertible { + public enum Kind: CustomStringConvertible, Codable { case screenshot case mediaSaved(timestamp: UInt64) @@ -58,6 +66,24 @@ public final class DataExtractionNotification : ControlMessage { coder.encode(timestamp, forKey: "timestamp") } } + + // MARK: - Codable + + required init(from decoder: Decoder) throws { + try super.init(from: decoder) + + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + kind = try? container.decode(Kind.self, forKey: .kind) + } + + public override func encode(to encoder: Encoder) throws { + try super.encode(to: encoder) + + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encodeIfPresent(kind, forKey: .kind) + } // MARK: Proto Conversion public override class func fromProto(_ proto: SNProtoContent, sender: String) -> DataExtractionNotification? { @@ -72,7 +98,7 @@ public final class DataExtractionNotification : ControlMessage { return DataExtractionNotification(kind: kind) } - public override func toProto(using transaction: YapDatabaseReadWriteTransaction) -> SNProtoContent? { + public override func toProto(_ db: Database) -> SNProtoContent? { guard let kind = kind else { SNLog("Couldn't construct data extraction notification proto from: \(self).") return nil diff --git a/SessionMessagingKit/Messages/Control Messages/ExpirationTimerUpdate.swift b/SessionMessagingKit/Messages/Control Messages/ExpirationTimerUpdate.swift index 0f55cc8bc..5426d8cf1 100644 --- a/SessionMessagingKit/Messages/Control Messages/ExpirationTimerUpdate.swift +++ b/SessionMessagingKit/Messages/Control Messages/ExpirationTimerUpdate.swift @@ -1,7 +1,16 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB import SessionUtilitiesKit @objc(SNExpirationTimerUpdate) public final class ExpirationTimerUpdate : ControlMessage { + private enum CodingKeys: String, CodingKey { + case syncTarget + case duration + } + /// In the case of a sync message, the public key of the person the message was targeted at. /// /// - Note: `nil` if this isn't a sync message. @@ -31,24 +40,47 @@ public final class ExpirationTimerUpdate : ControlMessage { if let syncTarget = coder.decodeObject(forKey: "syncTarget") as! String? { self.syncTarget = syncTarget } if let duration = coder.decodeObject(forKey: "durationSeconds") as! UInt32? { self.duration = duration } } - + public override func encode(with coder: NSCoder) { super.encode(with: coder) coder.encode(syncTarget, forKey: "syncTarget") coder.encode(duration, forKey: "durationSeconds") } + + // MARK: - Codable + + required init(from decoder: Decoder) throws { + try super.init(from: decoder) + + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + syncTarget = try? container.decode(String.self, forKey: .syncTarget) + duration = try? container.decode(UInt32.self, forKey: .duration) + } + + public override func encode(to encoder: Encoder) throws { + try super.encode(to: encoder) + + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encodeIfPresent(syncTarget, forKey: .syncTarget) + try container.encodeIfPresent(duration, forKey: .duration) + } // MARK: Proto Conversion public override class func fromProto(_ proto: SNProtoContent, sender: String) -> ExpirationTimerUpdate? { guard let dataMessageProto = proto.dataMessage else { return nil } + let isExpirationTimerUpdate = (dataMessageProto.flags & UInt32(SNProtoDataMessage.SNProtoDataMessageFlags.expirationTimerUpdate.rawValue)) != 0 guard isExpirationTimerUpdate else { return nil } - let syncTarget = dataMessageProto.syncTarget - let duration = dataMessageProto.expireTimer - return ExpirationTimerUpdate(syncTarget: syncTarget, duration: duration) + + return ExpirationTimerUpdate( + syncTarget: dataMessageProto.syncTarget, + duration: dataMessageProto.expireTimer + ) } - public override func toProto(using transaction: YapDatabaseReadWriteTransaction) -> SNProtoContent? { + public override func toProto(_ db: Database) -> SNProtoContent? { guard let duration = duration else { SNLog("Couldn't construct expiration timer update proto from: \(self).") return nil @@ -59,7 +91,7 @@ public final class ExpirationTimerUpdate : ControlMessage { if let syncTarget = syncTarget { dataMessageProto.setSyncTarget(syncTarget) } // Group context do { - try setGroupContextIfNeeded(on: dataMessageProto, using: transaction) + try setGroupContextIfNeeded(db, on: dataMessageProto) } catch { SNLog("Couldn't construct expiration timer update proto from: \(self).") return nil diff --git a/SessionMessagingKit/Messages/Control Messages/MessageRequestResponse.swift b/SessionMessagingKit/Messages/Control Messages/MessageRequestResponse.swift index f88a7c9bd..b508660c8 100644 --- a/SessionMessagingKit/Messages/Control Messages/MessageRequestResponse.swift +++ b/SessionMessagingKit/Messages/Control Messages/MessageRequestResponse.swift @@ -1,7 +1,15 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB import SessionUtilitiesKit @objc(SNMessageRequestResponse) public final class MessageRequestResponse: ControlMessage { + private enum CodingKeys: String, CodingKey { + case isApproved + } + public var isApproved: Bool // MARK: - Initialization @@ -28,6 +36,24 @@ public final class MessageRequestResponse: ControlMessage { coder.encode(isApproved, forKey: "isApproved") } + // MARK: - Codable + + required init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + isApproved = try container.decode(Bool.self, forKey: .isApproved) + + try super.init(from: decoder) + } + + public override func encode(to encoder: Encoder) throws { + try super.encode(to: encoder) + + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encodeIfPresent(isApproved, forKey: .isApproved) + } + // MARK: - Proto Conversion public override class func fromProto(_ proto: SNProtoContent, sender: String) -> MessageRequestResponse? { @@ -38,7 +64,7 @@ public final class MessageRequestResponse: ControlMessage { return MessageRequestResponse(isApproved: isApproved) } - public override func toProto(using transaction: YapDatabaseReadWriteTransaction) -> SNProtoContent? { + public override func toProto(_ db: Database) -> SNProtoContent? { let messageRequestResponseProto = SNProtoMessageRequestResponse.builder(isApproved: isApproved) let contentProto = SNProtoContent.builder() diff --git a/SessionMessagingKit/Messages/Control Messages/ReadReceipt.swift b/SessionMessagingKit/Messages/Control Messages/ReadReceipt.swift index af40f0c6e..686a58b74 100644 --- a/SessionMessagingKit/Messages/Control Messages/ReadReceipt.swift +++ b/SessionMessagingKit/Messages/Control Messages/ReadReceipt.swift @@ -1,7 +1,15 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB import SessionUtilitiesKit @objc(SNReadReceipt) public final class ReadReceipt : ControlMessage { + private enum CodingKeys: String, CodingKey { + case timestamps + } + @objc public var timestamps: [UInt64]? // MARK: Initialization @@ -29,6 +37,24 @@ public final class ReadReceipt : ControlMessage { super.encode(with: coder) coder.encode(timestamps, forKey: "messageTimestamps") } + + // MARK: - Codable + + required init(from decoder: Decoder) throws { + try super.init(from: decoder) + + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + timestamps = try? container.decode([UInt64].self, forKey: .timestamps) + } + + public override func encode(to encoder: Encoder) throws { + try super.encode(to: encoder) + + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encodeIfPresent(timestamps, forKey: .timestamps) + } // MARK: Proto Conversion public override class func fromProto(_ proto: SNProtoContent, sender: String) -> ReadReceipt? { @@ -38,7 +64,7 @@ public final class ReadReceipt : ControlMessage { return ReadReceipt(timestamps: timestamps) } - public override func toProto(using transaction: YapDatabaseReadWriteTransaction) -> SNProtoContent? { + public override func toProto(_ db: Database) -> SNProtoContent? { guard let timestamps = timestamps else { SNLog("Couldn't construct read receipt proto from: \(self).") return nil diff --git a/SessionMessagingKit/Messages/Control Messages/TypingIndicator.swift b/SessionMessagingKit/Messages/Control Messages/TypingIndicator.swift index 2b5957328..d92881360 100644 --- a/SessionMessagingKit/Messages/Control Messages/TypingIndicator.swift +++ b/SessionMessagingKit/Messages/Control Messages/TypingIndicator.swift @@ -1,13 +1,21 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB import SessionUtilitiesKit @objc(SNTypingIndicator) public final class TypingIndicator : ControlMessage { + private enum CodingKeys: String, CodingKey { + case kind + } + public var kind: Kind? public override var ttl: UInt64 { 20 * 1000 } // MARK: Kind - public enum Kind : Int, CustomStringConvertible { + public enum Kind: Int, Codable, CustomStringConvertible { case started, stopped static func fromProto(_ proto: SNProtoTypingMessage.SNProtoTypingMessageAction) -> Kind { @@ -56,6 +64,24 @@ public final class TypingIndicator : ControlMessage { super.encode(with: coder) coder.encode(kind?.rawValue, forKey: "action") } + + // MARK: - Codable + + required init(from decoder: Decoder) throws { + try super.init(from: decoder) + + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + kind = try? container.decode(Kind.self, forKey: .kind) + } + + public override func encode(to encoder: Encoder) throws { + try super.encode(to: encoder) + + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encodeIfPresent(kind, forKey: .kind) + } // MARK: Proto Conversion public override class func fromProto(_ proto: SNProtoContent, sender: String) -> TypingIndicator? { @@ -64,7 +90,7 @@ public final class TypingIndicator : ControlMessage { return TypingIndicator(kind: kind) } - public override func toProto(using transaction: YapDatabaseReadWriteTransaction) -> SNProtoContent? { + public override func toProto(_ db: Database) -> SNProtoContent? { guard let timestamp = sentTimestamp, let kind = kind else { SNLog("Couldn't construct typing indicator proto from: \(self).") return nil diff --git a/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift b/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift index 4593879ab..f784d2c9b 100644 --- a/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift +++ b/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift @@ -1,7 +1,16 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB import SessionUtilitiesKit @objc(SNUnsendRequest) public final class UnsendRequest: ControlMessage { + private enum CodingKeys: String, CodingKey { + case timestamp + case author + } + public var timestamp: UInt64? public var author: String? @@ -35,6 +44,26 @@ public final class UnsendRequest: ControlMessage { coder.encode(author, forKey: "author") } + // MARK: - Codable + + required init(from decoder: Decoder) throws { + try super.init(from: decoder) + + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + timestamp = try? container.decode(UInt64.self, forKey: .timestamp) + author = try? container.decode(String.self, forKey: .author) + } + + public override func encode(to encoder: Encoder) throws { + try super.encode(to: encoder) + + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encodeIfPresent(timestamp, forKey: .timestamp) + try container.encodeIfPresent(author, forKey: .author) + } + // MARK: Proto Conversion public override class func fromProto(_ proto: SNProtoContent, sender: String) -> UnsendRequest? { guard let unsendRequestProto = proto.unsendRequest else { return nil } @@ -43,7 +72,7 @@ public final class UnsendRequest: ControlMessage { return UnsendRequest(timestamp: timestamp, author: author) } - public override func toProto(using transaction: YapDatabaseReadWriteTransaction) -> SNProtoContent? { + public override func toProto(_ db: Database) -> SNProtoContent? { guard let timestamp = timestamp, let author = author else { SNLog("Couldn't construct unsend request proto from: \(self).") return nil diff --git a/SessionMessagingKit/Messages/Message+Destination.swift b/SessionMessagingKit/Messages/Message+Destination.swift index 8b0252fa6..c129e83bb 100644 --- a/SessionMessagingKit/Messages/Message+Destination.swift +++ b/SessionMessagingKit/Messages/Message+Destination.swift @@ -1,24 +1,26 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit public extension Message { - - enum Destination { + enum Destination: Codable { case contact(publicKey: String) case closedGroup(groupPublicKey: String) case openGroup(channel: UInt64, server: String) case openGroupV2(room: String, server: String) - static func from(_ thread: TSThread) -> Message.Destination { - if let thread = thread as? TSContactThread { - return .contact(publicKey: thread.contactSessionID()) - } else if let thread = thread as? TSGroupThread, thread.isClosedGroup { - let groupID = thread.groupModel.groupId - let groupPublicKey = LKGroupUtilities.getDecodedGroupID(groupID) - return .closedGroup(groupPublicKey: groupPublicKey) - } else if let thread = thread as? TSGroupThread, thread.isOpenGroup { - let openGroupV2 = Storage.shared.getV2OpenGroup(for: thread.uniqueId!)! - return .openGroupV2(room: openGroupV2.room, server: openGroupV2.server) - } else { - preconditionFailure("TODO: Handle legacy closed groups.") + static func from(_ db: Database, thread: SessionThread) throws -> Message.Destination { + switch thread.variant { + case .contact: return .contact(publicKey: thread.id) + case .closedGroup: return .closedGroup(groupPublicKey: thread.id) + case .openGroup: + guard let openGroup: OpenGroup = try thread.openGroup.fetchOne(db) else { + throw GRDBStorageError.objectNotFound + } + + return .openGroupV2(room: openGroup.room, server: openGroup.server) } } } diff --git a/SessionMessagingKit/Messages/Message.swift b/SessionMessagingKit/Messages/Message.swift index e57774222..7407e70bb 100644 --- a/SessionMessagingKit/Messages/Message.swift +++ b/SessionMessagingKit/Messages/Message.swift @@ -1,7 +1,11 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB /// Abstract base class for `VisibleMessage` and `ControlMessage`. @objc(SNMessage) -public class Message : NSObject, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility +public class Message: NSObject, Codable, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility public var id: String? @objc public var threadID: String? public var sentTimestamp: UInt64? @@ -57,14 +61,20 @@ public class Message : NSObject, NSCoding { // NSObject/NSCoding conformance is preconditionFailure("fromProto(_:sender:) is abstract and must be overridden.") } - public func toProto(using transaction: YapDatabaseReadWriteTransaction) -> SNProtoContent? { - preconditionFailure("toProto(using:) is abstract and must be overridden.") + public func toProto(_ db: Database) -> SNProtoContent? { + preconditionFailure("toProto(_:) is abstract and must be overridden.") } - public func setGroupContextIfNeeded(on dataMessage: SNProtoDataMessage.SNProtoDataMessageBuilder, using transaction: YapDatabaseReadTransaction) throws { - guard let thread = TSThread.fetch(uniqueId: threadID!, transaction: transaction) as? TSGroupThread, thread.isClosedGroup else { return } + public func setGroupContextIfNeeded(_ db: Database, on dataMessage: SNProtoDataMessage.SNProtoDataMessageBuilder) throws { + guard + let threadId: String = threadID, + let thread: SessionThread = try? SessionThread.fetchOne(db, id: threadId), + thread.variant == .closedGroup, + let legacyGroupId: Data = "\(Legacy.closedGroupIdPrefix)\(threadId)".data(using: .utf8) + else { return } + // Android needs a group context or it'll interpret the message as a one-to-one message - let groupProto = SNProtoGroupContext.builder(id: thread.groupModel.groupId, type: .deliver) + let groupProto = SNProtoGroupContext.builder(id: legacyGroupId, type: .deliver) dataMessage.setGroup(try groupProto.build()) } diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Attachment.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Attachment.swift index f1454ff35..8b2d9daef 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Attachment.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Attachment.swift @@ -1,10 +1,13 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation import CoreGraphics import SessionUtilitiesKit public extension VisibleMessage { @objc(SNAttachment) - class Attachment : NSObject, NSCoding { + class Attachment: NSObject, Codable, NSCoding { public var fileName: String? public var contentType: String? public var key: Data? @@ -20,7 +23,7 @@ public extension VisibleMessage { contentType != nil && kind != nil && size != nil && sizeInBytes != nil && url != nil } - public enum Kind : String { + public enum Kind: String, Codable { case voiceMessage, generic } diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Contact.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Contact.swift deleted file mode 100644 index 2a6105447..000000000 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Contact.swift +++ /dev/null @@ -1,11 +0,0 @@ - -public extension VisibleMessage { - - @objc(SNMessageContact) - class Contact : NSObject, NSCoding { - - public required init?(coder: NSCoder) { } - - public func encode(with coder: NSCoder) { } - } -} diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift index 984f78ab0..ae3183b1f 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift @@ -1,9 +1,13 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB import SessionUtilitiesKit public extension VisibleMessage { @objc(SNLinkPreview) - class LinkPreview : NSObject, NSCoding { + class LinkPreview: NSObject, Codable, NSCoding { public var title: String? public var url: String? public var attachmentID: String? @@ -38,17 +42,22 @@ public extension VisibleMessage { preconditionFailure("Use toProto(using:) instead.") } - public func toProto(using transaction: YapDatabaseReadWriteTransaction) -> SNProtoDataMessagePreview? { + public func toProto(_ db: Database) -> SNProtoDataMessagePreview? { guard let url = url else { SNLog("Couldn't construct link preview proto from: \(self).") return nil } let linkPreviewProto = SNProtoDataMessagePreview.builder(url: url) if let title = title { linkPreviewProto.setTitle(title) } - if let attachmentID = attachmentID, let stream = TSAttachment.fetch(uniqueId: attachmentID, transaction: transaction) as? TSAttachmentStream, - let attachmentProto = stream.buildProto() { + + if + let attachmentID = attachmentID, + let attachment: SessionMessagingKit.Attachment = try? SessionMessagingKit.Attachment.fetchOne(db, id: attachmentID), + let attachmentProto = attachment.buildProto() + { linkPreviewProto.setImage(attachmentProto) } + do { return try linkPreviewProto.build() } catch { @@ -69,3 +78,15 @@ public extension VisibleMessage { } } } + +// MARK: - Database Type Conversion + +public extension VisibleMessage.LinkPreview { + static func from(_ db: Database, linkPreview: LinkPreview) -> VisibleMessage.LinkPreview { + return VisibleMessage.LinkPreview( + title: linkPreview.title, + url: linkPreview.url, + attachmentID: linkPreview.attachmentId + ) + } +} diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+OpenGroupInvitation.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+OpenGroupInvitation.swift index 678eeb04a..034f61b3b 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+OpenGroupInvitation.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+OpenGroupInvitation.swift @@ -1,9 +1,13 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB import SessionUtilitiesKit public extension VisibleMessage { @objc(SNOpenGroupInvitation) - class OpenGroupInvitation : NSObject, NSCoding { + class OpenGroupInvitation: NSObject, Codable, NSCoding { public var name: String? public var url: String? @@ -54,3 +58,16 @@ public extension VisibleMessage { } } } + +// MARK: - Database Type Conversion + +public extension VisibleMessage.OpenGroupInvitation { + static func from(_ db: Database, linkPreview: LinkPreview) -> VisibleMessage.OpenGroupInvitation? { + guard let name: String = linkPreview.title else { return nil } + + return VisibleMessage.OpenGroupInvitation( + name: name, + url: linkPreview.url + ) + } +} diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift index 737d8c476..60a5caec8 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift @@ -1,72 +1,75 @@ -//import SessionUtilitiesKit -// -//public extension VisibleMessage { -// -// @objc(SNProfile) -// class Profile : NSObject, NSCoding { -// public var displayName: String? -// public var profileKey: Data? -// public var profilePictureURL: String? -// -// internal init(displayName: String, profileKey: Data? = nil, profilePictureURL: String? = nil) { -// self.displayName = displayName -// self.profileKey = profileKey -// self.profilePictureURL = profilePictureURL -// } -// -// public required init?(coder: NSCoder) { -// if let displayName = coder.decodeObject(forKey: "displayName") as! String? { self.displayName = displayName } -// if let profileKey = coder.decodeObject(forKey: "profileKey") as! Data? { self.profileKey = profileKey } -// if let profilePictureURL = coder.decodeObject(forKey: "profilePictureURL") as! String? { self.profilePictureURL = profilePictureURL } -// } -// -// public func encode(with coder: NSCoder) { -// coder.encode(displayName, forKey: "displayName") -// coder.encode(profileKey, forKey: "profileKey") -// coder.encode(profilePictureURL, forKey: "profilePictureURL") -// } -// -// public static func fromProto(_ proto: SNProtoDataMessage, sessionId: String) -> Profile? { -// guard let profileProto = proto.profile, let displayName = profileProto.displayName else { return nil } -// let profileKey = proto.profileKey -// let profilePictureURL = profileProto.profilePicture -// if let profileKey = profileKey, let profilePictureURL = profilePictureURL { -// return Profile(displayName: displayName, profileKey: profileKey, profilePictureURL: profilePictureURL) -// } else { -// return Profile(displayName: displayName) -// } -// } -// -// public func toProto() -> SNProtoDataMessage? { -// guard let displayName = displayName else { -// SNLog("Couldn't construct profile proto from: \(self).") -// return nil -// } -// let dataMessageProto = SNProtoDataMessage.builder() -// let profileProto = SNProtoDataMessageLokiProfile.builder() -// profileProto.setDisplayName(displayName) -// if let profileKey = profileKey, let profilePictureURL = profilePictureURL { -// dataMessageProto.setProfileKey(profileKey) -// profileProto.setProfilePicture(profilePictureURL) -// } -// do { -// dataMessageProto.setProfile(try profileProto.build()) -// return try dataMessageProto.build() -// } catch { -// SNLog("Couldn't construct profile proto from: \(self).") -// return nil -// } -// } -// -// // MARK: Description -// public override var description: String { -// """ -// Profile( -// displayName: \(displayName ?? "null"), -// profileKey: \(profileKey?.description ?? "null"), -// profilePictureURL: \(profilePictureURL ?? "null") -// ) -// """ -// } -// } -//} +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +public extension VisibleMessage { + + @objc(SNProfile) + class Profile: NSObject, Codable, NSCoding { + public var displayName: String? + public var profileKey: Data? + public var profilePictureURL: String? + + internal init(displayName: String, profileKey: Data? = nil, profilePictureURL: String? = nil) { + self.displayName = displayName + self.profileKey = profileKey + self.profilePictureURL = profilePictureURL + } + + public required init?(coder: NSCoder) { + if let displayName = coder.decodeObject(forKey: "displayName") as! String? { self.displayName = displayName } + if let profileKey = coder.decodeObject(forKey: "profileKey") as! Data? { self.profileKey = profileKey } + if let profilePictureURL = coder.decodeObject(forKey: "profilePictureURL") as! String? { self.profilePictureURL = profilePictureURL } + } + + public func encode(with coder: NSCoder) { + coder.encode(displayName, forKey: "displayName") + coder.encode(profileKey, forKey: "profileKey") + coder.encode(profilePictureURL, forKey: "profilePictureURL") + } + + public static func fromProto(_ proto: SNProtoDataMessage) -> Profile? { + guard let profileProto = proto.profile, let displayName = profileProto.displayName else { return nil } + let profileKey = proto.profileKey + let profilePictureURL = profileProto.profilePicture + if let profileKey = profileKey, let profilePictureURL = profilePictureURL { + return Profile(displayName: displayName, profileKey: profileKey, profilePictureURL: profilePictureURL) + } else { + return Profile(displayName: displayName) + } + } + + public func toProto() -> SNProtoDataMessage? { + guard let displayName = displayName else { + SNLog("Couldn't construct profile proto from: \(self).") + return nil + } + let dataMessageProto = SNProtoDataMessage.builder() + let profileProto = SNProtoDataMessageLokiProfile.builder() + profileProto.setDisplayName(displayName) + if let profileKey = profileKey, let profilePictureURL = profilePictureURL { + dataMessageProto.setProfileKey(profileKey) + profileProto.setProfilePicture(profilePictureURL) + } + do { + dataMessageProto.setProfile(try profileProto.build()) + return try dataMessageProto.build() + } catch { + SNLog("Couldn't construct profile proto from: \(self).") + return nil + } + } + + // MARK: Description + public override var description: String { + """ + Profile( + displayName: \(displayName ?? "null"), + profileKey: \(profileKey?.description ?? "null"), + profilePictureURL: \(profilePictureURL ?? "null") + ) + """ + } + } +} diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift index 2a0f9bf93..21cbe0fb2 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift @@ -1,9 +1,13 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB import SessionUtilitiesKit public extension VisibleMessage { @objc(SNQuote) - class Quote : NSObject, NSCoding { + class Quote: NSObject, Codable, NSCoding { public var timestamp: UInt64? public var publicKey: String? public var text: String? @@ -45,14 +49,14 @@ public extension VisibleMessage { preconditionFailure("Use toProto(using:) instead.") } - public func toProto(using transaction: YapDatabaseReadWriteTransaction) -> SNProtoDataMessageQuote? { + public func toProto(_ db: Database) -> SNProtoDataMessageQuote? { guard let timestamp = timestamp, let publicKey = publicKey else { SNLog("Couldn't construct quote proto from: \(self).") return nil } let quoteProto = SNProtoDataMessageQuote.builder(id: timestamp, author: publicKey) if let text = text { quoteProto.setText(text) } - addAttachmentsIfNeeded(to: quoteProto, using: transaction) + addAttachmentsIfNeeded(db, to: quoteProto) do { return try quoteProto.build() } catch { @@ -61,9 +65,12 @@ public extension VisibleMessage { } } - private func addAttachmentsIfNeeded(to quoteProto: SNProtoDataMessageQuote.SNProtoDataMessageQuoteBuilder, using transaction: YapDatabaseReadWriteTransaction) { + private func addAttachmentsIfNeeded(_ db: Database, to quoteProto: SNProtoDataMessageQuote.SNProtoDataMessageQuoteBuilder) { guard let attachmentID = attachmentID else { return } - guard let stream = TSAttachment.fetch(uniqueId: attachmentID, transaction: transaction) as? TSAttachmentStream, stream.isUploaded else { + guard + let attachment: SessionMessagingKit.Attachment = try? SessionMessagingKit.Attachment.fetchOne(db, id: attachmentID), + attachment.state != .uploaded + else { #if DEBUG preconditionFailure("Sending a message before all associated attachments have been uploaded.") #else @@ -71,9 +78,9 @@ public extension VisibleMessage { #endif } let quotedAttachmentProto = SNProtoDataMessageQuoteQuotedAttachment.builder() - quotedAttachmentProto.setContentType(stream.contentType) - if let fileName = stream.sourceFilename { quotedAttachmentProto.setFileName(fileName) } - guard let attachmentProto = stream.buildProto() else { + quotedAttachmentProto.setContentType(attachment.contentType) + if let fileName = attachment.sourceFilename { quotedAttachmentProto.setFileName(fileName) } + guard let attachmentProto = attachment.buildProto() else { return SNLog("Ignoring invalid attachment for quoted message.") } quotedAttachmentProto.setThumbnail(attachmentProto) @@ -97,3 +104,17 @@ public extension VisibleMessage { } } } + +// MARK: - Database Type Conversion + +public extension VisibleMessage.Quote { + static func from(_ db: Database, quote: Quote) -> VisibleMessage.Quote { + let result = VisibleMessage.Quote() + result.timestamp = UInt64(quote.timestampMs) + result.publicKey = quote.authorId + result.text = quote.body + result.attachmentID = quote.attachmentId + + return result + } +} diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift index 3980fcffd..7ac2758b9 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift @@ -1,7 +1,21 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB import SessionUtilitiesKit @objc(SNVisibleMessage) -public final class VisibleMessage : Message { +public final class VisibleMessage: Message { + private enum CodingKeys: String, CodingKey { + case syncTarget + case text = "body" + case attachmentIDs = "attachments" + case quote + case linkPreview + case profile + case openGroupInvitation + } + /// In the case of a sync message, the public key of the person the message was targeted at. /// /// - Note: `nil` if this isn't a sync message. @@ -11,7 +25,7 @@ public final class VisibleMessage : Message { @objc public var quote: Quote? @objc public var linkPreview: LinkPreview? @objc public var contact: Legacy.Contact? - @objc public var profile: Legacy.Profile? + @objc public var profile: Profile? @objc public var openGroupInvitation: OpenGroupInvitation? public override var isSelfSendValid: Bool { true } @@ -36,11 +50,10 @@ public final class VisibleMessage : Message { if let attachmentIDs = coder.decodeObject(forKey: "attachments") as! [String]? { self.attachmentIDs = attachmentIDs } if let quote = coder.decodeObject(forKey: "quote") as! Quote? { self.quote = quote } if let linkPreview = coder.decodeObject(forKey: "linkPreview") as! LinkPreview? { self.linkPreview = linkPreview } - // TODO: Contact - if let profile = coder.decodeObject(forKey: "profile") as! Legacy.Profile? { self.profile = profile } + if let profile = coder.decodeObject(forKey: "profile") as! Profile? { self.profile = profile } if let openGroupInvitation = coder.decodeObject(forKey: "openGroupInvitation") as! OpenGroupInvitation? { self.openGroupInvitation = openGroupInvitation } } - + public override func encode(with coder: NSCoder) { super.encode(with: coder) coder.encode(syncTarget, forKey: "syncTarget") @@ -48,10 +61,39 @@ public final class VisibleMessage : Message { coder.encode(attachmentIDs, forKey: "attachments") coder.encode(quote, forKey: "quote") coder.encode(linkPreview, forKey: "linkPreview") - // TODO: Contact coder.encode(profile, forKey: "profile") coder.encode(openGroupInvitation, forKey: "openGroupInvitation") } + + // MARK: - Codable + + required init(from decoder: Decoder) throws { + try super.init(from: decoder) + + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + syncTarget = try? container.decode(String.self, forKey: .syncTarget) + text = try? container.decode(String.self, forKey: .text) + attachmentIDs = ((try? container.decode([String].self, forKey: .attachmentIDs)) ?? []) + quote = try? container.decode(Quote.self, forKey: .quote) + linkPreview = try? container.decode(LinkPreview.self, forKey: .linkPreview) + profile = try? container.decode(Profile.self, forKey: .profile) + openGroupInvitation = try? container.decode(OpenGroupInvitation.self, forKey: .openGroupInvitation) + } + + public override func encode(to encoder: Encoder) throws { + try super.encode(to: encoder) + + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encodeIfPresent(syncTarget, forKey: .syncTarget) + try container.encodeIfPresent(text, forKey: .text) + try container.encodeIfPresent(attachmentIDs, forKey: .attachmentIDs) + try container.encodeIfPresent(quote, forKey: .quote) + try container.encodeIfPresent(linkPreview, forKey: .linkPreview) + try container.encodeIfPresent(profile, forKey: .profile) + try container.encodeIfPresent(openGroupInvitation, forKey: .openGroupInvitation) + } // MARK: Proto Conversion public override class func fromProto(_ proto: SNProtoContent, sender: String) -> VisibleMessage? { @@ -62,50 +104,68 @@ public final class VisibleMessage : Message { if let quoteProto = dataMessage.quote, let quote = Quote.fromProto(quoteProto) { result.quote = quote } if let linkPreviewProto = dataMessage.preview.first, let linkPreview = LinkPreview.fromProto(linkPreviewProto) { result.linkPreview = linkPreview } // TODO: Contact - if let profile = Legacy.Profile.fromProto(dataMessage) { result.profile = profile } + if let profile = Profile.fromProto(dataMessage) { result.profile = profile } if let openGroupInvitationProto = dataMessage.openGroupInvitation, let openGroupInvitation = OpenGroupInvitation.fromProto(openGroupInvitationProto) { result.openGroupInvitation = openGroupInvitation } result.syncTarget = dataMessage.syncTarget return result } - public override func toProto(using transaction: YapDatabaseReadWriteTransaction) -> SNProtoContent? { + public override func toProto(_ db: Database) -> SNProtoContent? { let proto = SNProtoContent.builder() var attachmentIDs = self.attachmentIDs let dataMessage: SNProtoDataMessage.SNProtoDataMessageBuilder + // Profile if let profile = profile, let profileProto = profile.toProto() { dataMessage = profileProto.asBuilder() - } else { + } + else { dataMessage = SNProtoDataMessage.builder() } + // Text if let text = text { dataMessage.setBody(text) } + // Quote + if let quotedAttachmentID = quote?.attachmentID, let index = attachmentIDs.firstIndex(of: quotedAttachmentID) { attachmentIDs.remove(at: index) } - if let quote = quote, let quoteProto = quote.toProto(using: transaction) { dataMessage.setQuote(quoteProto) } + + if let quote = quote, let quoteProto = quote.toProto(db) { + dataMessage.setQuote(quoteProto) + } + // Link preview if let linkPreviewAttachmentID = linkPreview?.attachmentID, let index = attachmentIDs.firstIndex(of: linkPreviewAttachmentID) { attachmentIDs.remove(at: index) } - if let linkPreview = linkPreview, let linkPreviewProto = linkPreview.toProto(using: transaction) { dataMessage.setPreview([ linkPreviewProto ]) } + + if let linkPreview = linkPreview, let linkPreviewProto = linkPreview.toProto(db) { + dataMessage.setPreview([ linkPreviewProto ]) + } + // Attachments - let attachments = attachmentIDs.compactMap { TSAttachment.fetch(uniqueId: $0, transaction: transaction) as? TSAttachmentStream } - if !attachments.allSatisfy({ $0.isUploaded }) { + + let attachments: [SessionMessagingKit.Attachment]? = try? SessionMessagingKit.Attachment.fetchAll(db, ids: self.attachmentIDs) + + if !(attachments ?? []).allSatisfy({ $0.state == .uploaded }) { #if DEBUG preconditionFailure("Sending a message before all associated attachments have been uploaded.") #endif } - let attachmentProtos = attachments.compactMap { $0.buildProto() } + let attachmentProtos = (attachments ?? []).compactMap { $0.buildProto() } dataMessage.setAttachments(attachmentProtos) + // TODO: Contact + // Open group invitation if let openGroupInvitation = openGroupInvitation, let openGroupInvitationProto = openGroupInvitation.toProto() { dataMessage.setOpenGroupInvitation(openGroupInvitationProto) } + // Group context do { - try setGroupContextIfNeeded(on: dataMessage, using: transaction) + try setGroupContextIfNeeded(db, on: dataMessage) } catch { SNLog("Couldn't construct visible message proto from: \(self).") return nil @@ -139,3 +199,37 @@ public final class VisibleMessage : Message { """ } } + +// MARK: - Database Type Conversion + +public extension VisibleMessage { + static func from(_ db: Database, interaction: Interaction) -> VisibleMessage { + let result = VisibleMessage() + result.sentTimestamp = UInt64(interaction.timestampMs) + result.recipient = (try? interaction.recipientStates.fetchOne(db))?.recipientId + + if let thread: SessionThread = try? interaction.thread.fetchOne(db), thread.variant == .closedGroup { + result.groupPublicKey = thread.id + } + + result.text = interaction.body + result.attachmentIDs = ((try? interaction.attachments.fetchAll(db)) ?? []).map { $0.id } + result.quote = (try? interaction.quote.fetchOne(db)) + .map { VisibleMessage.Quote.from(db, quote: $0) } + + if let linkPreview: SessionMessagingKit.LinkPreview = try? interaction.linkPreview.fetchOne(db) { + switch linkPreview.variant { + case .standard: + result.linkPreview = VisibleMessage.LinkPreview.from(db, linkPreview: linkPreview) + + case .openGroupInvitation: + result.openGroupInvitation = VisibleMessage.OpenGroupInvitation.from( + db, + linkPreview: linkPreview + ) + } + } + + return result + } +} diff --git a/SessionMessagingKit/Meta/SessionMessagingKit.h b/SessionMessagingKit/Meta/SessionMessagingKit.h index 59bf8c918..c4d6d61b3 100644 --- a/SessionMessagingKit/Meta/SessionMessagingKit.h +++ b/SessionMessagingKit/Meta/SessionMessagingKit.h @@ -5,7 +5,6 @@ FOUNDATION_EXPORT const unsigned char SessionMessagingKitVersionString[]; #import #import -#import #import #import #import @@ -28,7 +27,6 @@ FOUNDATION_EXPORT const unsigned char SessionMessagingKitVersionString[]; #import #import #import -#import #import #import #import diff --git a/SessionMessagingKit/Sending & Receiving/Attachments/OWSThumbnailService.swift b/SessionMessagingKit/Sending & Receiving/Attachments/OWSThumbnailService.swift index ac20fe716..7bd9c4928 100644 --- a/SessionMessagingKit/Sending & Receiving/Attachments/OWSThumbnailService.swift +++ b/SessionMessagingKit/Sending & Receiving/Attachments/OWSThumbnailService.swift @@ -44,17 +44,10 @@ private struct OWSThumbnailRequest { public typealias SuccessBlock = (OWSLoadedThumbnail) -> Void public typealias FailureBlock = (Error) -> Void - let attachment: TSAttachmentStream - let thumbnailDimensionPoints: UInt + let attachment: Attachment + let dimensions: UInt let success: SuccessBlock let failure: FailureBlock - - init(attachment: TSAttachmentStream, thumbnailDimensionPoints: UInt, success: @escaping SuccessBlock, failure: @escaping FailureBlock) { - self.attachment = attachment - self.thumbnailDimensionPoints = thumbnailDimensionPoints - self.success = success - self.failure = failure - } } @objc public class OWSThumbnailService: NSObject { @@ -75,7 +68,7 @@ private struct OWSThumbnailRequest { // arrive so that we prioritize the most recent view state. private var thumbnailRequestStack = [OWSThumbnailRequest]() - private func canThumbnailAttachment(attachment: TSAttachmentStream) -> Bool { + private func canThumbnailAttachment(attachment: Attachment) -> Bool { return attachment.isImage || attachment.isAnimated || attachment.isVideo } @@ -88,6 +81,22 @@ private struct OWSThumbnailRequest { serialQueue.async { let thumbnailRequest = OWSThumbnailRequest(attachment: attachment, thumbnailDimensionPoints: thumbnailDimensionPoints, success: success, failure: failure) self.thumbnailRequestStack.append(thumbnailRequest) + + public func ensureThumbnail( + for attachment: Attachment, + dimensions: UInt, + success: @escaping SuccessBlock, + failure: @escaping FailureBlock + ) { + serialQueue.async { + self.thumbnailRequestStack.append( + OWSThumbnailRequest( + attachment: attachment, + dimensions: dimensions, + success: success, + failure: failure + ) + ) self.processNextRequestSync() } @@ -130,7 +139,7 @@ private struct OWSThumbnailRequest { guard canThumbnailAttachment(attachment: attachment) else { throw OWSThumbnailError.failure(description: "Cannot thumbnail attachment.") } - let thumbnailPath = attachment.path(forThumbnailDimensionPoints: thumbnailRequest.thumbnailDimensionPoints) + let thumbnailPath = attachment.thumbnailPath(for: thumbnailRequest.dimensions) if FileManager.default.fileExists(atPath: thumbnailPath) { guard let image = UIImage(contentsOfFile: thumbnailPath) else { throw OWSThumbnailError.failure(description: "Could not load thumbnail.") @@ -145,7 +154,7 @@ private struct OWSThumbnailRequest { guard let originalFilePath = attachment.originalFilePath else { throw OWSThumbnailError.failure(description: "Missing original file path.") } - let maxDimension = CGFloat(thumbnailRequest.thumbnailDimensionPoints) + let maxDimension = CGFloat(thumbnailRequest.dimensions) let thumbnailImage: UIImage if attachment.isImage || attachment.isAnimated { thumbnailImage = try OWSMediaUtils.thumbnail(forImageAtPath: originalFilePath, maxDimension: maxDimension) diff --git a/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift b/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift new file mode 100644 index 000000000..9fcc17398 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift @@ -0,0 +1,51 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum MessageReceiverError: LocalizedError { + case duplicateMessage + case invalidMessage + case unknownMessage + case unknownEnvelopeType + case noUserX25519KeyPair + case noUserED25519KeyPair + case invalidSignature + case noData + case senderBlocked + case noThread + case selfSend + case decryptionFailed + case invalidGroupPublicKey + case noGroupKeyPair + + public var isRetryable: Bool { + switch self { + case .duplicateMessage, .invalidMessage, .unknownMessage, .unknownEnvelopeType, + .invalidSignature, .noData, .senderBlocked, .noThread, .selfSend, .decryptionFailed: + return false + + default: return true + } + } + + public var errorDescription: String? { + switch self { + case .duplicateMessage: return "Duplicate message." + case .invalidMessage: return "Invalid message." + case .unknownMessage: return "Unknown message type." + case .unknownEnvelopeType: return "Unknown envelope type." + case .noUserX25519KeyPair: return "Couldn't find user X25519 key pair." + case .noUserED25519KeyPair: return "Couldn't find user ED25519 key pair." + case .invalidSignature: return "Invalid message signature." + case .noData: return "Received an empty envelope." + case .senderBlocked: return "Received a message from a blocked user." + case .noThread: return "Couldn't find thread for message." + case .selfSend: return "Message addressed at self." + case .decryptionFailed: return "Decryption failed." + + // Shared sender keys + case .invalidGroupPublicKey: return "Invalid group public key." + case .noGroupKeyPair: return "Missing group key pair." + } + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift b/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift new file mode 100644 index 000000000..fb7a304d6 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift @@ -0,0 +1,45 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum MessageSenderError: LocalizedError { + case invalidMessage + case protoConversionFailed + case noUserX25519KeyPair + case noUserED25519KeyPair + case signingFailed + case encryptionFailed + case noUsername + + // Closed groups + case noThread + case noKeyPair + case invalidClosedGroupUpdate + + case other(Error) + + internal var isRetryable: Bool { + switch self { + case .invalidMessage, .protoConversionFailed, .invalidClosedGroupUpdate, .signingFailed, .encryptionFailed: return false + default: return true + } + } + + public var errorDescription: String? { + switch self { + case .invalidMessage: return "Invalid message." + case .protoConversionFailed: return "Couldn't convert message to proto." + case .noUserX25519KeyPair: return "Couldn't find user X25519 key pair." + case .noUserED25519KeyPair: return "Couldn't find user ED25519 key pair." + case .signingFailed: return "Couldn't sign message." + case .encryptionFailed: return "Couldn't encrypt message." + case .noUsername: return "Missing username." + + // Closed groups + case .noThread: return "Couldn't find a thread associated with the given group public key." + case .noKeyPair: return "Couldn't find a private key associated with the given group public key." + case .invalidClosedGroupUpdate: return "Invalid group update." + case .other(let error): return error.localizedDescription + } + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Mentions/MentionsManager.swift b/SessionMessagingKit/Sending & Receiving/Mentions/MentionsManager.swift index 075913ca4..87a21fe20 100644 --- a/SessionMessagingKit/Sending & Receiving/Mentions/MentionsManager.swift +++ b/SessionMessagingKit/Sending & Receiving/Mentions/MentionsManager.swift @@ -33,7 +33,7 @@ public final class MentionsManager : NSObject { let openGroupV2 = Storage.shared.getV2OpenGroup(for: threadID) storage.dbReadConnection.read { transaction in candidates = cache.compactMap { publicKey in - guard let displayName: String = Profile.displayNameNoFallback(for: publicKey, context: (openGroupV2 != nil ? .openGroup : .regular)) else { + guard let displayName: String = Profile.displayNameNoFallback(id: publicKey, context: (openGroupV2 != nil ? .openGroup : .regular)) else { return nil } guard !displayName.hasPrefix("Anonymous") else { return nil } diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift index b49bb38fb..32f5aa9fa 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift @@ -1,12 +1,46 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import CryptoSwift +import GRDB import Sodium +import CryptoSwift import Curve25519Kit import SessionUtilitiesKit extension MessageReceiver { + + internal static func extractSenderPublicKey(_ db: Database, from envelope: SNProtoEnvelope) -> String? { + guard + let ciphertext: Data = envelope.content, + let userX25519KeyPair: Box.KeyPair = Identity.fetchUserKeyPair(db) + else { return nil } + + let recipientX25519PrivateKey = userX25519KeyPair.secretKey + let recipientX25519PublicKey = userX25519KeyPair.publicKey + let sodium = Sodium() + let signatureSize = sodium.sign.Bytes + let ed25519PublicKeySize = sodium.sign.PublicKeyBytes + + // 1. ) Decrypt the message + guard + let plaintextWithMetadata = sodium.box.open( + anonymousCipherText: Bytes(ciphertext), + recipientPublicKey: Box.PublicKey(Bytes(recipientX25519PublicKey)), + recipientSecretKey: Bytes(recipientX25519PrivateKey) + ), + plaintextWithMetadata.count > (signatureSize + ed25519PublicKeySize) + else { return nil } + + // 2. ) Get the message parts + let senderED25519PublicKey = Bytes(plaintextWithMetadata[plaintextWithMetadata.count - (signatureSize + ed25519PublicKeySize) ..< plaintextWithMetadata.count - signatureSize]) + + // 3. ) Get the sender's X25519 public key + guard let senderX25519PublicKey = sodium.sign.toX25519(ed25519PublicKey: senderED25519PublicKey) else { + return nil + } + + return "05\(senderX25519PublicKey.toHexString())" + } internal static func decryptWithSessionProtocol(ciphertext: Data, using x25519KeyPair: Box.KeyPair) throws -> (plaintext: Data, senderX25519PublicKey: String) { let recipientX25519PrivateKey = x25519KeyPair.secretKey @@ -17,7 +51,7 @@ extension MessageReceiver { // 1. ) Decrypt the message guard let plaintextWithMetadata = sodium.box.open(anonymousCipherText: Bytes(ciphertext), recipientPublicKey: Box.PublicKey(Bytes(recipientX25519PublicKey)), - recipientSecretKey: Bytes(recipientX25519PrivateKey)), plaintextWithMetadata.count > (signatureSize + ed25519PublicKeySize) else { throw Error.decryptionFailed } + recipientSecretKey: Bytes(recipientX25519PrivateKey)), plaintextWithMetadata.count > (signatureSize + ed25519PublicKeySize) else { throw MessageReceiverError.decryptionFailed } // 2. ) Get the message parts let signature = Bytes(plaintextWithMetadata[plaintextWithMetadata.count - signatureSize ..< plaintextWithMetadata.count]) let senderED25519PublicKey = Bytes(plaintextWithMetadata[plaintextWithMetadata.count - (signatureSize + ed25519PublicKeySize) ..< plaintextWithMetadata.count - signatureSize]) @@ -25,9 +59,9 @@ extension MessageReceiver { // 3. ) Verify the signature let verificationData = plaintext + senderED25519PublicKey + recipientX25519PublicKey let isValid = sodium.sign.verify(message: verificationData, publicKey: senderED25519PublicKey, signature: signature) - guard isValid else { throw Error.invalidSignature } + guard isValid else { throw MessageReceiverError.invalidSignature } // 4. ) Get the sender's X25519 public key - guard let senderX25519PublicKey = sodium.sign.toX25519(ed25519PublicKey: senderED25519PublicKey) else { throw Error.decryptionFailed } + guard let senderX25519PublicKey = sodium.sign.toX25519(ed25519PublicKey: senderED25519PublicKey) else { throw MessageReceiverError.decryptionFailed } return (Data(plaintext), "05" + senderX25519PublicKey.toHexString()) } diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index ddc23d903..a8930b995 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -9,25 +9,32 @@ import SessionSnodeKit extension MessageReceiver { - public static func handle(_ db: Database, _ message: Message, associatedWithProto proto: SNProtoContent, openGroupID: String?, isBackgroundPoll: Bool, using transaction: Any) throws { + public static func handle(_ db: Database, message: Message, associatedWithProto proto: SNProtoContent, openGroupId: String?, isBackgroundPoll: Bool) throws { switch message { - case let message as ReadReceipt: handleReadReceipt(message, using: transaction) - case let message as TypingIndicator: handleTypingIndicator(message, using: transaction) - case let message as ClosedGroupControlMessage: handleClosedGroupControlMessage(db, message, using: transaction) - case let message as DataExtractionNotification: handleDataExtractionNotification(message, using: transaction) - case let message as ExpirationTimerUpdate: handleExpirationTimerUpdate(message, using: transaction) - case let message as ConfigurationMessage: handleConfigurationMessage(db, message, using: transaction) - case let message as UnsendRequest: handleUnsendRequest(message, using: transaction) - case let message as MessageRequestResponse: handleMessageRequestResponse(db, message, using: transaction) - case let message as VisibleMessage: try handleVisibleMessage(db, message, associatedWithProto: proto, openGroupID: openGroupID, isBackgroundPoll: isBackgroundPoll, using: transaction) - default: fatalError() + case let message as ReadReceipt: try handleReadReceipt(db, message: message) + case let message as TypingIndicator: try handleTypingIndicator(db, message: message) + case let message as ClosedGroupControlMessage: try handleClosedGroupControlMessage(db, message) + + case let message as DataExtractionNotification: + try handleDataExtractionNotification(db, message: message) + + case let message as ExpirationTimerUpdate: try handleExpirationTimerUpdate(db, message: message) + case let message as ConfigurationMessage: try handleConfigurationMessage(db, message) + case let message as UnsendRequest: try handleUnsendRequest(db, message: message) + case let message as MessageRequestResponse: try handleMessageRequestResponse(db, message) + + case let message as VisibleMessage: + try handleVisibleMessage(db, message: message, associatedWithProto: proto, openGroupId: openGroupId, isBackgroundPoll: isBackgroundPoll) + + default: fatalError() } - var isMainAppAndActive = false + var isMainAppActive = false if let sharedUserDefaults = UserDefaults(suiteName: "group.com.loki-project.loki-messenger") { - isMainAppAndActive = sharedUserDefaults.bool(forKey: "isMainAppActive") + isMainAppActive = sharedUserDefaults[.isMainAppActive] } - guard isMainAppAndActive else { return } + guard isMainAppActive else { return } + // Touch the thread to update the home screen preview let storage = SNMessagingKitConfiguration.shared.storage guard let threadID = storage.getOrCreateThread(for: message.sender!, groupPublicKey: message.groupPublicKey, openGroupID: openGroupID, using: transaction) else { return } @@ -38,22 +45,35 @@ extension MessageReceiver { // MARK: - Read Receipts - private static func handleReadReceipt(_ message: ReadReceipt, using transaction: Any) { - SSKEnvironment.shared.readReceiptManager.processReadReceipts(fromRecipientId: message.sender!, sentTimestamps: message.timestamps!.map { NSNumber(value: $0) }, readTimestamp: message.receivedTimestamp!) + private static func handleReadReceipt(_ db: Database, message: ReadReceipt) throws { + guard let sender: String = message.sender else { return } + guard let timestampMsValues: [Double] = message.timestamps?.map({ Double($0) }) else { return } + guard let readTimestampMs: Double = message.receivedTimestamp.map({ Double($0) }) else { return } + + try Interaction.markAsRead( + db, + recipientId: sender, + timestampMsValues: timestampMsValues, + readTimestampMs: readTimestampMs + ) } - - // MARK: - Typing Indicators - private static func handleTypingIndicator(_ message: TypingIndicator, using transaction: Any) { - switch message.kind! { - case .started: showTypingIndicatorIfNeeded(for: message.sender!) - case .stopped: hideTypingIndicatorIfNeeded(for: message.sender!) + private static func handleTypingIndicator(_ db: Database, message: TypingIndicator) throws { + switch message.kind { + case .started: try showTypingIndicatorIfNeeded(db, for: message.sender) + case .stopped: try hideTypingIndicatorIfNeeded(db, for: message.sender) + + default: + SNLog("Unknown TypingIndicator Kind ignored") + return } } - public static func showTypingIndicatorIfNeeded(for senderPublicKey: String) { + private static func showTypingIndicatorIfNeeded(_ db: Database, for senderPublicKey: String?) throws { + guard let senderPublicKey: String = senderPublicKey else { return } + var threadOrNil: TSContactThread? Storage.read { transaction in threadOrNil = TSContactThread.getWithContactSessionID(senderPublicKey, transaction: transaction) @@ -71,7 +91,9 @@ extension MessageReceiver { } } - public static func hideTypingIndicatorIfNeeded(for senderPublicKey: String) { + private static func hideTypingIndicatorIfNeeded(_ db: Database, for senderPublicKey: String?) throws { + guard let senderPublicKey: String = senderPublicKey else { return } + var threadOrNil: TSContactThread? Storage.read { transaction in threadOrNil = TSContactThread.getWithContactSessionID(senderPublicKey, transaction: transaction) @@ -111,99 +133,105 @@ extension MessageReceiver { // MARK: - Data Extraction Notification - private static func handleDataExtractionNotification(_ message: DataExtractionNotification, using transaction: Any) { - let transaction = transaction as! YapDatabaseReadWriteTransaction - guard message.groupPublicKey == nil, - let thread = TSContactThread.getWithContactSessionID(message.sender!, transaction: transaction) else { return } - let type: TSInfoMessageType - switch message.kind! { - case .screenshot: type = .screenshotNotification - case .mediaSaved: type = .mediaSavedNotification - } - let message = DataExtractionNotificationInfoMessage(type: type, sentTimestamp: message.sentTimestamp!, thread: thread, referencedAttachmentTimestamp: nil) - message.save(with: transaction) + private static func handleDataExtractionNotification(_ db: Database, message: DataExtractionNotification) throws { + guard + let sender: String = message.sender, + let messageKind: DataExtractionNotification.Kind = message.kind, + let thread: SessionThread = try? SessionThread.fetchOne(db, id: sender), + thread.variant == .contact + else { return } + + _ = try Interaction( + serverHash: message.serverHash, // TODO: Test this? (make sure it won't break anything?) + threadId: thread.id, + authorId: sender, // TODO: Confirm this + variant: { + switch messageKind { + case .screenshot: return .infoScreenshotNotification + case .mediaSaved: return .infoMediaSavedNotification + } + }() + ).inserted(db) } - - // MARK: - Expiration Timers - private static func handleExpirationTimerUpdate(_ message: ExpirationTimerUpdate, using transaction: Any) { - if message.duration! > 0 { - setExpirationTimer(to: message.duration!, for: message.sender!, syncTarget: message.syncTarget, groupPublicKey: message.groupPublicKey, messageSentTimestamp: message.sentTimestamp!, using: transaction) - } else { - disableExpirationTimer(for: message.sender!, syncTarget: message.syncTarget, groupPublicKey: message.groupPublicKey, messageSentTimestamp: message.sentTimestamp!, using: transaction) - } + private static func handleExpirationTimerUpdate(_ db: Database, message: ExpirationTimerUpdate) throws { + let targetId: String? = { + if let groupPublicKey: String = message.groupPublicKey { return groupPublicKey } + + return (message.syncTarget ?? message.sender) + }() + + // Get the target thread + guard + let targetId: String = targetId, + let sender: String = message.sender, + let thread: SessionThread = try? SessionThread.fetchOne(db, id: targetId) + else { return } + + // Update the configuration + // + // Note: Messages which had been sent during the previous configuration will still + // use it's settings (so if you enable, send a message and then disable disappearing + // message then the message you had sent will still disappear) + let config: DisappearingMessagesConfiguration = try thread.disappearingMessagesConfiguration + .fetchOne(db) + .defaulting(to: DisappearingMessagesConfiguration.defaultWith(thread.id)) + .with( + // If there is no duration then we should disable the expiration timer + isEnabled: (message.duration != nil), + durationSeconds: ( + message.duration.map { TimeInterval($0) } ?? + DisappearingMessagesConfiguration.defaultDuration + ) + ) + .saved(db) + + // Add an info message for the user + // + // Note: If it's a duplicate message (which the 'ExpirationTimerUpdate' frequently can be) + // then the write transaction will fail meaning the above config update won't be applied + // so we don't need to worry about order-of-execution) + _ = try Interaction( + serverHash: message.serverHash, + threadId: thread.id, + authorId: sender, + variant: .infoDisappearingMessagesUpdate, + body: config.infoUpdateMessage( + with: (sender != getUserHexEncodedPublicKey(db) ? + Profile.displayName(db, id: sender) : + nil + ) + ), + timestampMs: Int64(message.sentTimestamp ?? 0) // Default to `0` if not set + ).inserted(db) } - - public static func setExpirationTimer(to duration: UInt32, for senderPublicKey: String, syncTarget: String?, groupPublicKey: String?, messageSentTimestamp: UInt64, using transaction: Any) { - let transaction = transaction as! YapDatabaseReadWriteTransaction - var threadOrNil: TSThread? - if let groupPublicKey = groupPublicKey { - guard Storage.shared.isClosedGroup(groupPublicKey) else { return } - let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey) - threadOrNil = TSGroupThread.fetch(uniqueId: TSGroupThread.threadId(fromGroupId: groupID), transaction: transaction) - } else { - threadOrNil = TSContactThread.getWithContactSessionID(syncTarget ?? senderPublicKey, transaction: transaction) - } - guard let thread = threadOrNil else { return } - let configuration = Legacy.DisappearingMessagesConfiguration(threadId: thread.uniqueId!, enabled: true, durationSeconds: duration) - configuration.save(with: transaction) - var senderDisplayName: String? = nil - if senderPublicKey != getUserHexEncodedPublicKey() { - senderDisplayName = Profile.displayName(for: senderPublicKey) - } - let message = OWSDisappearingConfigurationUpdateInfoMessage(timestamp: messageSentTimestamp, thread: thread, - configuration: configuration, createdByRemoteName: senderDisplayName, createdInExistingGroup: false) - message.save(with: transaction) - SSKEnvironment.shared.disappearingMessagesJob.startIfNecessary() - } - - public static func disableExpirationTimer(for senderPublicKey: String, syncTarget: String?, groupPublicKey: String?, messageSentTimestamp: UInt64, using transaction: Any) { - let transaction = transaction as! YapDatabaseReadWriteTransaction - var threadOrNil: TSThread? - if let groupPublicKey = groupPublicKey { - guard Storage.shared.isClosedGroup(groupPublicKey) else { return } - let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey) - threadOrNil = TSGroupThread.fetch(uniqueId: TSGroupThread.threadId(fromGroupId: groupID), transaction: transaction) - } else { - threadOrNil = TSContactThread.getWithContactSessionID(syncTarget ?? senderPublicKey, transaction: transaction) - } - guard let thread = threadOrNil else { return } - let configuration = Legacy.DisappearingMessagesConfiguration(threadId: thread.uniqueId!, enabled: false, durationSeconds: 24 * 60 * 60) - configuration.save(with: transaction) - var senderDisplayName: String? = nil - if senderPublicKey != getUserHexEncodedPublicKey() { - senderDisplayName = Profile.displayName(for: senderPublicKey) - } - let message = OWSDisappearingConfigurationUpdateInfoMessage(timestamp: messageSentTimestamp, thread: thread, - configuration: configuration, createdByRemoteName: senderDisplayName, createdInExistingGroup: false) - message.save(with: transaction) - SSKEnvironment.shared.disappearingMessagesJob.startIfNecessary() - } - - // MARK: - Configuration Messages - private static func handleConfigurationMessage(_ db: Database, _ message: ConfigurationMessage, using transaction: Any) { - let userPublicKey = getUserHexEncodedPublicKey() + private static func handleConfigurationMessage(_ db: Database, _ message: ConfigurationMessage) throws { + let userPublicKey = getUserHexEncodedPublicKey(db) + guard message.sender == userPublicKey else { return } + SNLog("Configuration message received.") - let storage = SNMessagingKitConfiguration.shared.storage - let transaction = transaction as! YapDatabaseReadWriteTransaction + + // Note: `message.sentTimestamp` is in ms let isInitialSync: Bool = (!UserDefaults.standard[.hasSyncedInitialConfiguration]) - let messageSentTimestamp: TimeInterval = TimeInterval((message.sentTimestamp ?? 0) / 1000) // `sentTimestamp` is in ms - let lastConfigTimestamp: TimeInterval = (UserDefaults.standard[.lastConfigurationSync]?.timeIntervalSince1970 ?? Date(timeIntervalSince1970: 0).timeIntervalSince1970) + let messageSentTimestamp: TimeInterval = TimeInterval((message.sentTimestamp ?? 0) / 1000) + let lastConfigTimestamp: TimeInterval = UserDefaults.standard[.lastConfigurationSync] + .defaulting(to: Date(timeIntervalSince1970: 0)) + .timeIntervalSince1970 // Profile - updateProfileIfNeeded( + try updateProfileIfNeeded( + db, publicKey: userPublicKey, name: message.displayName, - profilePictureURL: message.profilePictureURL, + profilePictureUrl: message.profilePictureURL, profileKey: OWSAES256Key(data: message.profileKey), - sentTimestamp: message.sentTimestamp!, - transaction: transaction + sentTimestamp: messageSentTimestamp ) if isInitialSync || messageSentTimestamp > lastConfigTimestamp { @@ -215,12 +243,13 @@ extension MessageReceiver { UserDefaults.standard[.lastConfigurationSync] = Date(timeIntervalSince1970: messageSentTimestamp) // Contacts - for contactInfo in message.contacts { - let sessionID = contactInfo.publicKey! - let contact: Contact = Contact.fetchOrCreate(db, id: sessionID) - let profile: Profile = Profile.fetchOrCreate(db, id: sessionID) + try message.contacts.forEach { contactInfo in + guard let sessionId: String = contactInfo.publicKey else { return } - try? profile + let contact: Contact = Contact.fetchOrCreate(db, id: sessionId) + let profile: Profile = Profile.fetchOrCreate(db, id: sessionId) + + try profile .with( name: contactInfo.displayName, profilePictureUrl: .updateIf(contactInfo.profilePictureURL), @@ -238,7 +267,7 @@ extension MessageReceiver { // config message setting *isApproved* and *didApproveMe* to true. This may prevent some // weird edge cases where a config message swapping *isApproved* and *didApproveMe* to // false. - try? contact + try contact .with( isApproved: (contactInfo.hasIsApproved && contactInfo.isApproved ? .existing : @@ -262,11 +291,10 @@ extension MessageReceiver { // that the current user had deleted that message request) if contactInfo.isBlocked != contact.isBlocked, - let thread: TSContactThread = TSContactThread.getWithContactSessionID(sessionID, transaction: transaction), - thread.isMessageRequest(using: transaction) + let thread: SessionThread = try? SessionThread.fetchOne(db, id: sessionId), + thread.isMessageRequest(db) { - thread.removeAllThreadInteractions(with: transaction) - thread.remove(with: transaction) + try thread.delete(db) } } } @@ -278,151 +306,307 @@ extension MessageReceiver { // get added via the 'handleNewClosedGroup' method anyway as they will have come through in the // past two weeks) if isInitialSync { - let allClosedGroupPublicKeys = storage.getUserClosedGroupPublicKeys() - for closedGroup in message.closedGroups { - guard !allClosedGroupPublicKeys.contains(closedGroup.publicKey) else { continue } + let existingClosedGroupsIds: [String] = (try? SessionThread + .filter(SessionThread.Columns.variant == SessionThread.Variant.closedGroup) + .fetchAll(db)) + .defaulting(to: []) + .map { $0.id } + + try message.closedGroups.forEach { closedGroup in + guard !existingClosedGroupsIds.contains(closedGroup.publicKey) else { return } + let keyPair: Box.KeyPair = Box.KeyPair( publicKey: closedGroup.encryptionKeyPair.publicKey.bytes, secretKey: closedGroup.encryptionKeyPair.privateKey.bytes ) - handleNewClosedGroup(db, groupPublicKey: closedGroup.publicKey, name: closedGroup.name, encryptionKeyPair: keyPair, - members: [String](closedGroup.members), admins: [String](closedGroup.admins), expirationTimer: closedGroup.expirationTimer, - messageSentTimestamp: message.sentTimestamp!, using: transaction) + + try handleNewClosedGroup( + db, + groupPublicKey: closedGroup.publicKey, + name: closedGroup.name, + encryptionKeyPair: keyPair, + members: [String](closedGroup.members), + admins: [String](closedGroup.admins), + expirationTimer: closedGroup.expirationTimer, + messageSentTimestamp: message.sentTimestamp! + ) } } // Open groups for openGroupURL in message.openGroups { if let (room, server, publicKey) = OpenGroupManagerV2.parseV2OpenGroup(from: openGroupURL) { - OpenGroupManagerV2.shared.add(room: room, server: server, publicKey: publicKey, using: transaction).retainUntilComplete() + OpenGroupManagerV2.shared + .add(db, room: room, server: server, publicKey: publicKey) + .retainUntilComplete() } } } } - - // MARK: - Unsend Requests - public static func handleUnsendRequest(_ message: UnsendRequest, using transaction: Any) { - let userPublicKey = getUserHexEncodedPublicKey() + public static func handleUnsendRequest(_ db: Database, message: UnsendRequest) throws { + let userPublicKey: String = getUserHexEncodedPublicKey(db) + guard message.sender == message.author || userPublicKey == message.sender else { return } - let transaction = transaction as! YapDatabaseReadWriteTransaction - if let author = message.author, let timestamp = message.timestamp { - let localMessage: TSMessage? - if userPublicKey == author { - localMessage = TSOutgoingMessage.find(withTimestamp: timestamp) - } else { - localMessage = TSIncomingMessage.find(withAuthorId: author, timestamp: timestamp, transaction: transaction) - } - if let messageToDelete = localMessage { - if let incomingMessage = messageToDelete as? TSIncomingMessage { - incomingMessage.markAsReadNow(withTrySendReadReceipt: false, transaction: transaction) - if let notificationIdentifier = incomingMessage.notificationIdentifier, !notificationIdentifier.isEmpty { - UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [notificationIdentifier]) - UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [notificationIdentifier]) - } - } - if author == message.sender { - if let serverHash = messageToDelete.serverHash { - SnodeAPI.deleteMessage(publicKey: author, serverHashes: [serverHash]).retainUntilComplete() - } - messageToDelete.updateForDeletion(with: transaction) - } else { - messageToDelete.remove(with: transaction) - } + guard let author: String = message.author, let timestampMs: UInt64 = message.timestamp else { return } + + let maybeInteraction: Interaction? = try Interaction + .filter(Interaction.Columns.timestampMs == Int64(timestampMs)) + .filter(Interaction.Columns.authorId == author) + .fetchOne(db) + + guard let interaction: Interaction = maybeInteraction else { return } + + // Mark incoming messages as read and remove any of their notifications + if interaction.variant == .standardIncoming { + _ = try interaction.markingAsRead(db, includingOlder: false, trySendReadReceipt: false) + + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: interaction.notificationIdentifiers) + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: interaction.notificationIdentifiers) + } + + if author == message.sender { + if let serverHash: String = interaction.serverHash { + SnodeAPI.deleteMessage(publicKey: author, serverHashes: [serverHash]).retainUntilComplete() } + + _ = try interaction + .markingAsDeleted() + .saved(db) + + _ = try interaction.attachments + .deleteAll(db) + } + else { + _ = try interaction.delete(db) } } - - // MARK: - Visible Messages - @discardableResult - public static func handleVisibleMessage(_ db: Database, _ message: VisibleMessage, associatedWithProto proto: SNProtoContent, openGroupID: String?, isBackgroundPoll: Bool, using transaction: Any) throws -> String { - let sender: String = message.sender! - let storage = SNMessagingKitConfiguration.shared.storage - let transaction = transaction as! YapDatabaseReadWriteTransaction - var isMainAppAndActive = false + @discardableResult public static func handleVisibleMessage( + _ db: Database, + message: VisibleMessage, + associatedWithProto proto: SNProtoContent, + openGroupId: String?, + isBackgroundPoll: Bool + ) throws -> Int64 { + guard let sender: String = message.sender, let dataMessage = proto.dataMessage else { + throw MessageReceiverError.invalidMessage + } + + // Note: `message.sentTimestamp` is in ms + let messageSentTimestamp: TimeInterval = TimeInterval((message.sentTimestamp ?? 0) / 1000) + + var isMainAppActive = false if let sharedUserDefaults = UserDefaults(suiteName: "group.com.loki-project.loki-messenger") { - isMainAppAndActive = sharedUserDefaults.bool(forKey: "isMainAppActive") + isMainAppActive = sharedUserDefaults[.isMainAppActive] } + // Parse & persist attachments - let attachments: [VisibleMessage.Attachment] = proto.dataMessage!.attachments.compactMap { proto in - guard let attachment = VisibleMessage.Attachment.fromProto(proto) else { return nil } - return attachment.isValid ? attachment : nil - } - let attachmentIDs = storage.persist(attachments, using: transaction) - message.attachmentIDs = attachmentIDs - var attachmentsToDownload = attachmentIDs + + let attachments: [Attachment] = dataMessage.attachments + .compactMap { proto in + let attachment: Attachment = Attachment(proto: proto) + + // Attachments on received messages must have a 'downloadUrl' otherwise + // they are invalid and we can ignore them + return (attachment.downloadUrl != nil ? attachment : nil) + } + try attachments.saveAll(db) + + message.attachmentIDs = attachments.map { $0.id } + // Update profile if needed if let profile = message.profile { - let sessionID = message.sender! var contactProfileKey: OWSAES256Key? = nil if let profileKey = profile.profileKey { contactProfileKey = OWSAES256Key(data: profileKey) } - updateProfileIfNeeded(publicKey: sessionID, name: profile.displayName, profilePictureURL: profile.profilePictureURL, - profileKey: contactProfileKey, sentTimestamp: message.sentTimestamp!, transaction: transaction) + + try updateProfileIfNeeded( + db, + publicKey: sender, + name: profile.displayName, + profilePictureUrl: profile.profilePictureURL, + profileKey: contactProfileKey, + sentTimestamp: messageSentTimestamp + ) } + // Get or create thread - guard let threadID = storage.getOrCreateThread(for: message.syncTarget ?? sender, groupPublicKey: message.groupPublicKey, openGroupID: openGroupID, using: transaction) else { throw Error.noThread } - // Parse quote if needed - var tsQuotedMessage: TSQuotedMessage? = nil - if message.quote != nil && proto.dataMessage?.quote != nil, let thread = TSThread.fetch(uniqueId: threadID, transaction: transaction) { - tsQuotedMessage = TSQuotedMessage(for: proto.dataMessage!, thread: thread, transaction: transaction) - if let id = tsQuotedMessage?.thumbnailAttachmentStreamId() ?? tsQuotedMessage?.thumbnailAttachmentPointerId() { - attachmentsToDownload.append(id) - } - } - // Parse link preview if needed - var owsLinkPreview: OWSLinkPreview? - if message.linkPreview != nil && proto.dataMessage?.preview.isEmpty == false { - owsLinkPreview = try? OWSLinkPreview.buildValidatedLinkPreview(dataMessage: proto.dataMessage!, body: message.text, transaction: transaction) - if let id = owsLinkPreview?.imageAttachmentId { - attachmentsToDownload.append(id) - } - } - // Persist the message - guard let tsMessageID = storage.persist(message, quotedMessage: tsQuotedMessage, linkPreview: owsLinkPreview, - groupPublicKey: message.groupPublicKey, openGroupID: openGroupID, using: transaction) else { throw Error.duplicateMessage } - message.threadID = threadID - // Start attachment downloads if needed - // TODO: Swap this back - let isContactTrusted: Bool = (GRDBStorage.shared.read({ db in try Contact.fetchOne(db, id: sender) })?.isTrusted ?? false) -// let isContactTrusted: Bool = ((try? Contact.fetchOne(db, id: sender))?.isTrusted ?? false) - let isGroup = message.groupPublicKey != nil || openGroupID != nil - attachmentsToDownload.forEach { attachmentID in - let downloadJob = AttachmentDownloadJob(attachmentID: attachmentID, tsMessageID: tsMessageID, threadID: threadID) - downloadJob.isDeferred = !isContactTrusted && !isGroup - if isMainAppAndActive { - JobQueue.shared.add(downloadJob, using: transaction) - } else { - JobQueue.shared.addWithoutExecuting(downloadJob, using: transaction) - } - } - // Cancel any typing indicators if needed - if isMainAppAndActive { - cancelTypingIndicatorsIfNeeded(for: message.sender!) - } - if let tsMessage = TSMessage.fetch(uniqueId: tsMessageID, transaction: transaction) { - // Keep track of the open group server message ID ↔ message ID relationship - if let serverID = message.openGroupServerMessageID { - tsMessage.openGroupServerMessageID = serverID + let threadInfo: (id: String, variant: SessionThread.Variant)? = { + if let openGroupId: String = openGroupId { + // Note: We don't want to create a thread for an open group if it doesn't exist + if (try? SessionThread.exists(db, id: openGroupId)) != true { return nil } - // Create a lookup between the openGroupServerMessageId and the tsMessage id for easy lookup - if let openGroup: OpenGroupV2 = storage.getV2OpenGroup(for: threadID) { - storage.addOpenGroupServerIdLookup(serverID, tsMessageId: tsMessageID, in: openGroup.room, on: openGroup.server, using: transaction) + return (openGroupId, .openGroup) + } + + if let groupPublicKey: String = message.groupPublicKey { + // Note: We don't want to create a thread for a closed group if it doesn't exist + if (try? SessionThread.exists(db, id: groupPublicKey)) != true { return nil } + + return (groupPublicKey, .closedGroup) + } + + return ((message.syncTarget ?? sender), .contact) + }() + guard let threadInfo: (id: String, variant: SessionThread.Variant) = threadInfo else { + throw MessageReceiverError.noThread + } + + let thread: SessionThread = SessionThread.fetchOrCreate(db, id: threadInfo.id, variant: threadInfo.variant) + let interaction: Interaction + let interactionId: Int64 + + do { + // Store the message variant so we can run variant-specific behaviours + let variant: Interaction.Variant = { + if sender == getUserHexEncodedPublicKey(db) { + return .standardOutgoing + } + + return .standardIncoming + }() + + // Check if there is an existing message with the same timestamp, variant and sender + let existingInteraction: Interaction? = try? thread.interactions + .filter(Interaction.Columns.timestampMs == (messageSentTimestamp * 1000)) + .filter(Interaction.Columns.variant == variant) + .filter(Interaction.Columns.authorId == sender) + .fetchOne(db) + + if let existingInteraction: Interaction = existingInteraction { + // These values might not have been set yet for outgoing interactions so update them + interaction = try existingInteraction + .with( + serverHash: message.serverHash, // Keep track of server hash + openGroupServerMessageId: message.openGroupServerMessageID.map { Int64($0) } + ) + .saved(db) + + guard let existingInteractionId: Int64 = interaction.id else { throw GRDBStorageError.failedToSave } + + interactionId = existingInteractionId + } + else { + let disappearingMessagesConfiguration: DisappearingMessagesConfiguration = (try? thread.disappearingMessagesConfiguration.fetchOne(db)) + .defaulting(to: DisappearingMessagesConfiguration.defaultWith(thread.id)) + + interaction = try Interaction( + serverHash: message.serverHash, // Keep track of server hash + threadId: thread.id, + authorId: sender, + variant: variant, + body: message.text, + timestampMs: Int64(messageSentTimestamp * 1000), + // Note: Ensure we don't ever expire open group messages + expiresInSeconds: (disappearingMessagesConfiguration.isEnabled && message.openGroupServerMessageID == nil ? + disappearingMessagesConfiguration.durationSeconds : + nil + ), + expiresStartedAtMs: nil, + // OpenGroupInvitations are stored as LinkPreview's in the database + linkPreviewUrl: (message.linkPreview?.url ?? message.openGroupInvitation?.url), + // Keep track of the open group server message ID ↔ message ID relationship + openGroupServerMessageId: message.openGroupServerMessageID.map { Int64($0) }, + openGroupWhisperMods: false, // TODO: SOGSV4 + openGroupWhisperTo: nil // TODO: SOGSV4 + ).inserted(db) + + guard let newInteractionId: Int64 = interaction.id else { throw GRDBStorageError.failedToSave } + + interactionId = newInteractionId + + // For newly created outgoing messages upsert the recipient states to sent + if variant == .standardOutgoing { + if let syncTarget: String = message.syncTarget { + try RecipientState( + interactionId: interactionId, + recipientId: syncTarget, + state: .sent + ).save(db) + } + else if + let closedGroup: ClosedGroup = try? thread.closedGroup.fetchOne(db), + let members: [GroupMember] = try? closedGroup.members.fetchAll(db) + { + try members.forEach { member in + try RecipientState( + interactionId: interactionId, + recipientId: member.profileId, + state: .sent + ).save(db) + } + } } } - // Keep track of server hash - if let serverHash = message.serverHash { tsMessage.serverHash = serverHash } - tsMessage.save(with: transaction) + // For outgoing messages mark it and all older interactions as read + if variant == .standardOutgoing { + _ = try interaction.markingAsRead(db, includingOlder: true, trySendReadReceipt: true) + } + } - if let tsOutgoingMessage = TSMessage.fetch(uniqueId: tsMessageID, transaction: transaction) as? TSOutgoingMessage, - let thread = TSThread.fetch(uniqueId: threadID, transaction: transaction) { - // Mark previous messages as read if there is a sync message - OWSReadReceiptManager.shared().markAsReadLocally(beforeSortId: tsOutgoingMessage.sortId, thread: thread, trySendReadReceipt: true) + catch { + throw error + } + + // Persist quote if needed + let quote: Quote? = try? Quote( + db, + proto: dataMessage, + interactionId: interactionId, + thread: thread + )?.inserted(db) + + // Parse link preview if needed + let linkPreview: LinkPreview? = try? LinkPreview( + db, + proto: dataMessage, + body: message.text + )?.saved(db) + + // Open group invitations are stored as LinkPreview values so create one if needed + if + let openGroupInvitationUrl: String = message.openGroupInvitation?.url, + let openGroupInvitationName: String = message.openGroupInvitation?.name + { + try LinkPreview( + url: openGroupInvitationUrl, + timestamp: LinkPreview.timestampFor(sentTimestampMs: (messageSentTimestamp * 1000)), + variant: .openGroupInvitation, + title: openGroupInvitationName + ).save(db) + } + + // Start attachment downloads if needed (ie. trusted contact or group thread) + let isContactTrusted: Bool = ((try? Contact.fetchOne(db, id: sender))?.isTrusted ?? false) + + if isContactTrusted || thread.variant != .contact { + attachments + .map { $0.id } + .appending(quote?.attachmentId) + .appending(linkPreview?.attachmentId) + .forEach { attachmentId in + JobRunner.add( + db, + job: Job( + variant: .attachmentDownload, + details: AttachmentDownloadJob.Details( + threadId: thread.id, + attachmentId: attachmentId + ) + ), + canStartJob: isMainAppActive + ) + } + } + + // Cancel any typing indicators if needed + if isMainAppActive { + cancelTypingIndicatorsIfNeeded(for: message.sender!) } // Update the contact's approval status of the current user if needed (if we are getting messages from @@ -431,45 +615,58 @@ extension MessageReceiver { // Note: This is to resolve a rare edge-case where a conversation was started with a user on an old // version of the app and their message request approval state was set via a migration rather than // by using the approval process - if !isGroup, let senderSessionId: String = message.sender { - updateContactApprovalStatusIfNeeded( + if thread.variant == .contact { + try updateContactApprovalStatusIfNeeded( db, - senderSessionId: senderSessionId, - threadId: message.threadID, + senderSessionId: sender, + threadId: thread.id, forceConfigSync: false ) } // Notify the user if needed - guard let tsIncomingMessage = TSMessage.fetch(uniqueId: tsMessageID, transaction: transaction) as? TSIncomingMessage, - let thread = TSThread.fetch(uniqueId: threadID, transaction: transaction) else { return tsMessageID } + guard interaction.variant == .standardIncoming else { return interactionId } + // Use the same identifier for notifications when in backgroud polling to prevent spam - let notificationIdentifier = isBackgroundPoll ? thread.uniqueId : UUID().uuidString - tsIncomingMessage.setNotificationIdentifier(notificationIdentifier, transaction: transaction) - SSKEnvironment.shared.notificationsManager!.notifyUser(for: tsIncomingMessage, in: thread, transaction: transaction) - return tsMessageID + SSKEnvironment.shared.notificationsManager.wrappedValue? + .notifyUser( + db, + for: interaction, + in: thread, + isBackgroundPoll: isBackgroundPoll + ) + + return interactionId } - - // MARK: - Profile Updating - private static func updateProfileIfNeeded(publicKey: String, name: String?, profilePictureURL: String?, - profileKey: OWSAES256Key?, sentTimestamp: UInt64, transaction: YapDatabaseReadWriteTransaction) { - let isCurrentUser = (publicKey == getUserHexEncodedPublicKey()) - let userDefaults = UserDefaults.standard + + private static func updateProfileIfNeeded( + _ db: Database, publicKey: String, + name: String?, + profilePictureUrl: String?, + profileKey: OWSAES256Key?, + sentTimestamp: TimeInterval + ) throws { + let isCurrentUser = (publicKey == getUserHexEncodedPublicKey(db)) var profile: Profile = Profile.fetchOrCreate(id: publicKey) // Name if let name = name, name != profile.name { let shouldUpdate: Bool if isCurrentUser { - shouldUpdate = given(userDefaults[.lastDisplayNameUpdate]) { sentTimestamp > UInt64($0.timeIntervalSince1970 * 1000) } ?? true - } else { + shouldUpdate = given(UserDefaults.standard[.lastDisplayNameUpdate]) { + sentTimestamp > $0.timeIntervalSince1970 + } + .defaulting(to: true) + } + else { shouldUpdate = true } + if shouldUpdate { if isCurrentUser { - userDefaults[.lastDisplayNameUpdate] = Date(timeIntervalSince1970: TimeInterval(sentTimestamp / 1000)) + UserDefaults.standard[.lastDisplayNameUpdate] = Date(timeIntervalSince1970: sentTimestamp) } profile = profile.with(name: name) @@ -478,68 +675,86 @@ extension MessageReceiver { // Profile picture & profile key if - let profileKey = profileKey, - let profilePictureURL = profilePictureURL, + let profileKey: OWSAES256Key = profileKey, + let profilePictureUrl: String = profilePictureUrl, profileKey.keyData.count == kAES256_KeyByteLength, profileKey != profile.profileEncryptionKey { let shouldUpdate: Bool if isCurrentUser { - shouldUpdate = given(userDefaults[.lastProfilePictureUpdate]) { sentTimestamp > UInt64($0.timeIntervalSince1970 * 1000) } ?? true - } else { + shouldUpdate = given(UserDefaults.standard[.lastProfilePictureUpdate]) { + sentTimestamp > $0.timeIntervalSince1970 + } + .defaulting(to: true) + } + else { shouldUpdate = true } if shouldUpdate { if isCurrentUser { - userDefaults[.lastProfilePictureUpdate] = Date(timeIntervalSince1970: TimeInterval(sentTimestamp / 1000)) + UserDefaults.standard[.lastProfilePictureUpdate] = Date(timeIntervalSince1970: sentTimestamp) } profile = profile.with( - profilePictureUrl: .update(profilePictureURL), + profilePictureUrl: .update(profilePictureUrl), profileEncryptionKey: .update(profileKey) ) } } // Persist changes - GRDBStorage.shared.write { db in - try profile.save(db) - } + try profile.save(db) // Download the profile picture if needed - transaction.addCompletionQueue(DispatchQueue.main) { + db.afterNextTransactionCommit { _ in ProfileManager.downloadAvatar(for: profile) } } - - // MARK: - Closed Groups - public static func handleClosedGroupControlMessage(_ db: Database, _ message: ClosedGroupControlMessage, using transaction: Any) { + + public static func handleClosedGroupControlMessage(_ db: Database, _ message: ClosedGroupControlMessage) throws { switch message.kind! { - case .new: handleNewClosedGroup(db, message, using: transaction) - case .encryptionKeyPair: handleClosedGroupEncryptionKeyPair(message, using: transaction) - case .nameChange: handleClosedGroupNameChanged(message, using: transaction) - case .membersAdded: handleClosedGroupMembersAdded(message, using: transaction) - case .membersRemoved: handleClosedGroupMembersRemoved(message, using: transaction) - case .memberLeft: handleClosedGroupMemberLeft(message, using: transaction) - case .encryptionKeyPairRequest: handleClosedGroupEncryptionKeyPairRequest(message, using: transaction) // Currently not used + case .new: try handleNewClosedGroup(db, message: message) + case .encryptionKeyPair: try handleClosedGroupEncryptionKeyPair(db, message: message) + case .nameChange: try handleClosedGroupNameChanged(db, message: message) + case .membersAdded: try handleClosedGroupMembersAdded(db, message: message) + case .membersRemoved: try handleClosedGroupMembersRemoved(db, message: message) + case .memberLeft: try handleClosedGroupMemberLeft(db, message: message) + case .encryptionKeyPairRequest: + handleClosedGroupEncryptionKeyPairRequest(db, message: message) // Currently not used } } - private static func handleNewClosedGroup(_ db: Database, _ message: ClosedGroupControlMessage, using transaction: Any) { - guard case let .new(publicKeyAsData, name, encryptionKeyPair, membersAsData, adminsAsData, expirationTimer) = message.kind else { return } - let groupPublicKey = publicKeyAsData.toHexString() - let members = membersAsData.map { $0.toHexString() } - let admins = adminsAsData.map { $0.toHexString() } - handleNewClosedGroup(db, groupPublicKey: groupPublicKey, name: name, encryptionKeyPair: encryptionKeyPair, - members: members, admins: admins, expirationTimer: expirationTimer, messageSentTimestamp: message.sentTimestamp!, using: transaction) + private static func handleNewClosedGroup(_ db: Database, message: ClosedGroupControlMessage) throws { + guard case let .new(publicKeyAsData, name, encryptionKeyPair, membersAsData, adminsAsData, expirationTimer) = message.kind else { + return + } + guard let sentTimestamp: UInt64 = message.sentTimestamp else { return } + + try handleNewClosedGroup( + db, + groupPublicKey: publicKeyAsData.toHexString(), + name: name, + encryptionKeyPair: encryptionKeyPair, + members: membersAsData.map { $0.toHexString() }, + admins: adminsAsData.map { $0.toHexString() }, + expirationTimer: expirationTimer, + messageSentTimestamp: sentTimestamp + ) } - private static func handleNewClosedGroup(_ db: Database, groupPublicKey: String, name: String, encryptionKeyPair: Box.KeyPair, members: [String], admins: [String], expirationTimer: UInt32, messageSentTimestamp: UInt64, using transaction: Any) { - let transaction = transaction as! YapDatabaseReadWriteTransaction - + private static func handleNewClosedGroup( + _ db: Database, + groupPublicKey: String, + name: String, + encryptionKeyPair: Box.KeyPair, + members: [String], + admins: [String], + expirationTimer: UInt32, + messageSentTimestamp: UInt64 + ) throws { // With new closed groups we only want to create them if the admin creating the closed group is an // approved contact (to prevent spam via closed groups getting around message requests if users are // on old or modified clients) @@ -555,149 +770,224 @@ extension MessageReceiver { guard hasApprovedAdmin else { return } // Create the group - let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey) - let group = TSGroupModel(title: name, memberIds: members, image: nil, groupId: groupID, groupType: .closedGroup, adminIds: admins) - let thread: TSGroupThread + let groupAlreadyExisted: Bool = ((try? SessionThread.exists(db, id: groupPublicKey)) ?? false) + let thread: SessionThread = try SessionThread + .fetchOrCreate(db, id: groupPublicKey, variant: .closedGroup) + .saved(db) + let closedGroup: ClosedGroup = try ClosedGroup( + threadId: groupPublicKey, + name: name, + formationTimestamp: Date().timeIntervalSince1970 + ).saved(db) - if let t = TSGroupThread.fetch(uniqueId: TSGroupThread.threadId(fromGroupId: groupID), transaction: transaction) { - thread = t - thread.setGroupModel(group, with: transaction) - // Clear the zombie list if the group wasn't active - let storage = SNMessagingKitConfiguration.shared.storage - if !storage.isClosedGroup(groupPublicKey) { - storage.setZombieMembers(for: groupPublicKey, to: [], using: transaction) - } - } - else { - thread = TSGroupThread.getOrCreateThread(with: group, transaction: transaction) - thread.save(with: transaction) - // Notify the user - let infoMessage = TSInfoMessage(timestamp: messageSentTimestamp, in: thread, messageType: .groupCreated) - infoMessage.save(with: transaction) + // Clear the zombie list if the group wasn't active (ie. had no keys) + if ((try? closedGroup.keyPairs.fetchCount(db)) ?? 0) == 0 { + try closedGroup.zombies.deleteAll(db) } - let isExpirationTimerEnabled = (expirationTimer > 0) - let expirationTimerDuration = (isExpirationTimerEnabled ? expirationTimer : 24 * 60 * 60) - let configuration = Legacy.DisappearingMessagesConfiguration( - threadId: thread.uniqueId!, - enabled: isExpirationTimerEnabled, - durationSeconds: expirationTimerDuration - ) - configuration.save(with: transaction) + // Notify the user + if !groupAlreadyExisted { + // Note: We don't provide a `serverHash` in this case as we want to allow duplicates + // to avoid the following situation: + // • The app performed a background poll or received a push notification + // • This method was invoked and the received message timestamps table was updated + // • Processing wasn't finished + // • The user doesn't see the new closed group + _ = try Interaction( + threadId: thread.id, + authorId: getUserHexEncodedPublicKey(db), + variant: .infoClosedGroupCreated, + timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) + ).inserted(db) + } + + // Update the DisappearingMessages config + try thread.disappearingMessagesConfiguration + .fetchOne(db) + .defaulting(to: DisappearingMessagesConfiguration.defaultWith(thread.id)) + .with( + isEnabled: (expirationTimer > 0), + durationSeconds: TimeInterval(expirationTimer > 0 ? + expirationTimer : + (24 * 60 * 60) + ) + ) + .save(db) // Add the group to the user's set of public keys to poll for Storage.shared.addClosedGroupPublicKey(groupPublicKey, using: transaction) // Store the key pair - Storage.shared.addClosedGroupEncryptionKeyPair(encryptionKeyPair, for: groupPublicKey, using: transaction) - // Store the formation timestamp - Storage.shared.setClosedGroupFormationTimestamp(to: messageSentTimestamp, for: groupPublicKey, using: transaction) + try ClosedGroupKeyPair( + publicKey: groupPublicKey, + secretKey: Data(encryptionKeyPair.secretKey), + receivedTimestamp: Date().timeIntervalSince1970 + ).insert(db) + // Start polling ClosedGroupPoller.shared.startPolling(for: groupPublicKey) + // Notify the PN server let _ = PushNotificationAPI.performOperation(.subscribe, for: groupPublicKey, publicKey: getUserHexEncodedPublicKey()) } /// Extracts and adds the new encryption key pair to our list of key pairs if there is one for our public key, AND the message was /// sent by the group admin. - private static func handleClosedGroupEncryptionKeyPair(_ message: ClosedGroupControlMessage, using transaction: Any) { - // Prepare - guard case let .encryptionKeyPair(explicitGroupPublicKey, wrappers) = message.kind, - let groupPublicKey = explicitGroupPublicKey?.toHexString() ?? message.groupPublicKey else { return } - let transaction = transaction as! YapDatabaseReadWriteTransaction - let userPublicKey = getUserHexEncodedPublicKey() - guard let userKeyPair: Box.KeyPair = Identity.fetchUserKeyPair() else { + private static func handleClosedGroupEncryptionKeyPair(_ db: Database, message: ClosedGroupControlMessage) throws { + guard + case let .encryptionKeyPair(explicitGroupPublicKey, wrappers) = message.kind, + let groupPublicKey: String = (explicitGroupPublicKey?.toHexString() ?? message.groupPublicKey) + else { return } + guard let userKeyPair: Box.KeyPair = Identity.fetchUserKeyPair(db) else { return SNLog("Couldn't find user X25519 key pair.") } - let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey) - let threadID = TSGroupThread.threadId(fromGroupId: groupID) - guard let thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction) else { + guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: groupPublicKey) else { return SNLog("Ignoring closed group encryption key pair for nonexistent group.") } - guard thread.groupModel.groupAdminIds.contains(message.sender!) else { + guard let closedGroup: ClosedGroup = try? thread.closedGroup.fetchOne(db) else { return } + guard let groupAdmins: [GroupMember] = try? closedGroup.admins.fetchAll(db) else { return } + guard let sender: String = message.sender, groupAdmins.contains(where: { $0.profileId == sender }) else { return SNLog("Ignoring closed group encryption key pair from non-admin.") } // Find our wrapper and decrypt it if possible - guard let wrapper = wrappers.first(where: { $0.publicKey == userPublicKey }), let encryptedKeyPair = wrapper.encryptedKeyPair else { return } + let userPublicKey: String = userKeyPair.publicKey.toHexString() + + guard + let wrapper = wrappers.first(where: { $0.publicKey == userPublicKey }), + let encryptedKeyPair = wrapper.encryptedKeyPair + else { return } + let plaintext: Data do { - plaintext = try MessageReceiver.decryptWithSessionProtocol(ciphertext: encryptedKeyPair, using: userKeyPair).plaintext - } catch { + plaintext = try MessageReceiver.decryptWithSessionProtocol( + ciphertext: encryptedKeyPair, + using: userKeyPair + ).plaintext + } + catch { return SNLog("Couldn't decrypt closed group encryption key pair.") } + // Parse it let proto: SNProtoKeyPair do { proto = try SNProtoKeyPair.parseData(plaintext) - } catch { + } + catch { return SNLog("Couldn't parse closed group encryption key pair.") } + let keyPair: Box.KeyPair = Box.KeyPair( publicKey: proto.publicKey.removing05PrefixIfNeeded().bytes, secretKey: proto.privateKey.bytes ) + // Store it if needed - let closedGroupEncryptionKeyPairs = Storage.shared.getClosedGroupEncryptionKeyPairs(for: groupPublicKey) - guard !closedGroupEncryptionKeyPairs.contains(keyPair) else { + let keyPairs: [ClosedGroupKeyPair] = ((try? closedGroup.keyPairs.fetchAll(db)) ?? []) + let secretKeyData: Data = Data(keyPair.secretKey) + + guard !keyPairs.contains(where: { $0.secretKey == secretKeyData }) else { return SNLog("Ignoring duplicate closed group encryption key pair.") } - Storage.shared.addClosedGroupEncryptionKeyPair(keyPair, for: groupPublicKey, using: transaction) + + try ClosedGroupKeyPair( + publicKey: keyPair.publicKey.toHexString(), // Should match 'groupPublicKey' + secretKey: secretKeyData, + receivedTimestamp: Date().timeIntervalSince1970 + ).insert(db) SNLog("Received a new closed group encryption key pair.") } - private static func handleClosedGroupNameChanged(_ message: ClosedGroupControlMessage, using transaction: Any) { + private static func handleClosedGroupNameChanged(_ db: Database, message: ClosedGroupControlMessage) throws { guard case let .nameChange(name) = message.kind else { return } - let transaction = transaction as! YapDatabaseReadWriteTransaction - performIfValid(for: message, using: transaction) { groupID, thread, group in - // Update the group - let newGroupModel = TSGroupModel(title: name, memberIds: group.groupMemberIds, image: nil, groupId: groupID, groupType: .closedGroup, adminIds: group.groupAdminIds) - thread.setGroupModel(newGroupModel, with: transaction) + + try performIfValid(db, message: message) { id, sender, thread, closedGroup in + try closedGroup + .with(name: name) + .save(db) + // Notify the user if needed - guard name != group.groupName else { return } - let updateInfo = group.getInfoStringAboutUpdate(to: newGroupModel) - let infoMessage = TSInfoMessage(timestamp: message.sentTimestamp!, in: thread, messageType: .groupUpdated, customMessage: updateInfo) - infoMessage.save(with: transaction) + guard name != closedGroup.name else { return } + + _ = try Interaction( + serverHash: message.serverHash, // TODO: Test this? (make sure it won't break anything?) + threadId: thread.id, + authorId: sender, + variant: .infoClosedGroupUpdated, + body: ClosedGroupControlMessage.Kind + .nameChange(name: name) + .infoMessage(db, sender: sender), + timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) + ).inserted(db) } } - private static func handleClosedGroupMembersAdded(_ message: ClosedGroupControlMessage, using transaction: Any) { + private static func handleClosedGroupMembersAdded(_ db: Database, message: ClosedGroupControlMessage) throws { guard case let .membersAdded(membersAsData) = message.kind else { return } - let transaction = transaction as! YapDatabaseReadWriteTransaction - guard let groupPublicKey = message.groupPublicKey else { return } - performIfValid(for: message, using: transaction) { groupID, thread, group in + + try performIfValid(db, message: message) { id, sender, thread, closedGroup in + guard let groupMembers: [GroupMember] = try? closedGroup.members.fetchAll(db) else { return } + guard let groupAdmins: [GroupMember] = try? closedGroup.admins.fetchAll(db) else { return } + // Update the group - let addedMembers = membersAsData.map { $0.toHexString() } - let members = Set(group.groupMemberIds).union(addedMembers) - let newGroupModel = TSGroupModel(title: group.groupName, memberIds: [String](members), image: nil, groupId: groupID, groupType: .closedGroup, adminIds: group.groupAdminIds) - thread.setGroupModel(newGroupModel, with: transaction) - // Send the latest encryption key pair to the added members if the current user is the admin of the group + let addedMembers: [String] = membersAsData.map { $0.toHexString() } + let members: Set = Set(groupMembers.map { $0.profileId }).union(addedMembers) + + try addedMembers.forEach { memberId in + try GroupMember( + groupId: id, + profileId: memberId, + role: .standard + ).save(db) + } + + // Send the latest encryption key pair to the added members if the current user is + // the admin of the group // // This fixes a race condition where: // • A member removes another member. // • A member adds someone to the group and sends them the latest group key pair. // • The admin is offline during all of this. - // • When the admin comes back online they see the member removed message and generate + distribute a new key pair, - // but they don't know about the added member yet. + // • When the admin comes back online they see the member removed message and generate + + // distribute a new key pair, but they don't know about the added member yet. // • Now they see the member added message. // - // Without the code below, the added member(s) would never get the key pair that was generated by the admin when they saw - // the member removed message. - let isCurrentUserAdmin = group.groupAdminIds.contains(getUserHexEncodedPublicKey()) - if isCurrentUserAdmin { - for member in addedMembers { - MessageSender.sendLatestEncryptionKeyPair(to: member, for: message.groupPublicKey!, using: transaction) + // Without the code below, the added member(s) would never get the key pair that was + // generated by the admin when they saw the member removed message. + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + if groupAdmins.contains(where: { $0.profileId == userPublicKey }) { + addedMembers.forEach { memberId in + MessageSender.sendLatestEncryptionKeyPair(db, to: memberId, for: id) } } + // Update zombie members in case the added members are zombies - let storage = SNMessagingKitConfiguration.shared.storage - let zombies = storage.getZombieMembers(for: groupPublicKey) - if !zombies.intersection(addedMembers).isEmpty { - storage.setZombieMembers(for: groupPublicKey, to: zombies.subtracting(addedMembers), using: transaction) + let zombies: [GroupMember] = ((try? closedGroup.zombies.fetchAll(db)) ?? []) + + if !zombies.map { $0.profileId }.asSet().intersection(addedMembers).isEmpty { + try zombies + .filter { !addedMembers.contains($0.profileId) } + .deleteAll(db) } + // Notify the user if needed - guard members != Set(group.groupMemberIds) else { return } - let updateInfo = group.getInfoStringAboutUpdate(to: newGroupModel) - let infoMessage = TSInfoMessage(timestamp: message.sentTimestamp!, in: thread, messageType: .groupUpdated, customMessage: updateInfo) - infoMessage.save(with: transaction) + guard members != Set(groupMembers.map { $0.profileId }) else { return } + + _ = try Interaction( + serverHash: message.serverHash, // TODO: Test this? (make sure it won't break anything?) + threadId: thread.id, + authorId: sender, + variant: .infoClosedGroupUpdated, + body: ClosedGroupControlMessage.Kind + .membersAdded( + members: addedMembers + .asSet() + .subtracting(groupMembers.map { $0.profileId }) + .map { Data(hex: $0) } + ) + .infoMessage(db, sender: sender), + timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) + ).inserted(db) } } @@ -706,45 +996,71 @@ extension MessageReceiver { /// • the admin sent the message (only the admin can truly remove members). /// If we're among the users that were removed, delete all encryption key pairs and the group public key, unsubscribe /// from push notifications for this closed group, and remove the given members from the zombie list for this group. - private static func handleClosedGroupMembersRemoved(_ message: ClosedGroupControlMessage, using transaction: Any) { + private static func handleClosedGroupMembersRemoved(_ db: Database, message: ClosedGroupControlMessage) throws { guard case let .membersRemoved(membersAsData) = message.kind else { return } - let transaction = transaction as! YapDatabaseReadWriteTransaction - guard let groupPublicKey = message.groupPublicKey else { return } - performIfValid(for: message, using: transaction) { groupID, thread, group in + + try performIfValid(db, message: message) { id, sender, thread, closedGroup in // Check that the admin wasn't removed + guard let groupMembers: [GroupMember] = try? closedGroup.members.fetchAll(db) else { return } + guard let groupAdmins: [GroupMember] = try? closedGroup.admins.fetchAll(db) else { return } + let removedMembers = membersAsData.map { $0.toHexString() } - let members = Set(group.groupMemberIds).subtracting(removedMembers) - guard members.contains(group.groupAdminIds.first!) else { + let members = Set(groupMembers.map { $0.profileId }).subtracting(removedMembers) + + guard let firstAdminId: String = groupAdmins.first?.profileId, members.contains(firstAdminId) else { return SNLog("Ignoring invalid closed group update.") } // Check that the message was sent by the group admin - guard group.groupAdminIds.contains(message.sender!) else { + guard groupAdmins.contains(where: { $0.profileId == sender }) else { return SNLog("Ignoring invalid closed group update.") } + // If the current user was removed: // • Stop polling for the group // • Remove the key pairs associated with the group // • Notify the PN server let userPublicKey = getUserHexEncodedPublicKey() let wasCurrentUserRemoved = !members.contains(userPublicKey) + if wasCurrentUserRemoved { - Storage.shared.removeClosedGroupPublicKey(groupPublicKey, using: transaction) - Storage.shared.removeAllClosedGroupEncryptionKeyPairs(for: groupPublicKey, using: transaction) - ClosedGroupPoller.shared.stopPolling(for: groupPublicKey) - let _ = PushNotificationAPI.performOperation(.unsubscribe, for: groupPublicKey, publicKey: userPublicKey) + ClosedGroupPoller.shared.stopPolling(for: id) + + _ = try closedGroup + .keyPairs + .deleteAll(db) + let _ = PushNotificationAPI.performOperation( + .unsubscribe, + for: id, + publicKey: userPublicKey + ) } - let storage = SNMessagingKitConfiguration.shared.storage - let zombies = storage.getZombieMembers(for: groupPublicKey).subtracting(removedMembers) - storage.setZombieMembers(for: groupPublicKey, to: zombies, using: transaction) - // Update the group - let newGroupModel = TSGroupModel(title: group.groupName, memberIds: [String](members), image: nil, groupId: groupID, groupType: .closedGroup, adminIds: group.groupAdminIds) - thread.setGroupModel(newGroupModel, with: transaction) + + // Remove the member from the group and it's zombies + try closedGroup.members + .filter(removedMembers.contains(GroupMember.Columns.profileId)) + .deleteAll(db) + try closedGroup.zombies + .filter(removedMembers.contains(GroupMember.Columns.profileId)) + .deleteAll(db) + // Notify the user if needed - guard members != Set(group.groupMemberIds) else { return } - let infoMessageType: TSInfoMessageType = wasCurrentUserRemoved ? .groupCurrentUserLeft : .groupUpdated - let updateInfo = group.getInfoStringAboutUpdate(to: newGroupModel) - let infoMessage = TSInfoMessage(timestamp: message.sentTimestamp!, in: thread, messageType: infoMessageType, customMessage: updateInfo) - infoMessage.save(with: transaction) + guard members != Set(groupMembers.map { $0.profileId }) else { return } + + _ = try Interaction( + serverHash: message.serverHash, // TODO: Test this? (make sure it won't break anything?) + threadId: thread.id, + authorId: sender, + variant: (wasCurrentUserRemoved ? .infoClosedGroupCurrentUserLeft : .infoClosedGroupUpdated), + body: ClosedGroupControlMessage.Kind + .membersRemoved( + members: removedMembers + .asSet() + .subtracting(groupMembers.map { $0.profileId }) + .map { Data(hex: $0) } + ) + .infoMessage(db, sender: sender), + timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) + ).inserted(db) } } @@ -752,46 +1068,70 @@ extension MessageReceiver { /// • Mark them as a zombie (to be removed by the admin later). /// If the admin left: /// • Unsubscribe from PNs, delete the group public key, etc. as the group will be disbanded. - private static func handleClosedGroupMemberLeft(_ message: ClosedGroupControlMessage, using transaction: Any) { + private static func handleClosedGroupMemberLeft(_ db: Database, message: ClosedGroupControlMessage) throws { guard case .memberLeft = message.kind else { return } - let sender: String = message.sender! - let transaction = transaction as! YapDatabaseReadWriteTransaction - guard let groupPublicKey = message.groupPublicKey else { return } - performIfValid(for: message, using: transaction) { groupID, thread, group in - let didAdminLeave = group.groupAdminIds.contains(message.sender!) - let members: Set = didAdminLeave ? [] : Set(group.groupMemberIds).subtracting([ message.sender! ]) // If the admin leaves the group is disbanded + try performIfValid(db, message: message) { id, sender, thread, closedGroup in + guard let allGroupMembers: [GroupMember] = try? closedGroup.allMembers.fetchAll(db) else { + return + } + + let didAdminLeave: Bool = allGroupMembers.contains(where: { member in + member.role == .admin && member.profileId == sender + }) + let members: [GroupMember] = allGroupMembers.filter { $0.role == .standard } + let membersToRemove: [GroupMember] = members + .filter { member in + didAdminLeave || // If the admin leaves the group is disbanded + member.profileId == sender + } + let updatedMemberIds: Set = members + .map { $0.profileId } + .asSet() + .subtracting(membersToRemove.map { $0.profileId }) + if didAdminLeave { // Remove the group from the database and unsubscribe from PNs - Storage.shared.removeAllClosedGroupEncryptionKeyPairs(for: groupPublicKey, using: transaction) - Storage.shared.removeClosedGroupPublicKey(groupPublicKey, using: transaction) - let _ = PushNotificationAPI.performOperation(.unsubscribe, for: groupPublicKey, publicKey: getUserHexEncodedPublicKey()) - } else { - let storage = SNMessagingKitConfiguration.shared.storage - let zombies = storage.getZombieMembers(for: groupPublicKey).union([ message.sender! ]) - storage.setZombieMembers(for: groupPublicKey, to: zombies, using: transaction) - } - // Update the group - let newGroupModel = TSGroupModel(title: group.groupName, memberIds: [String](members), image: nil, groupId: groupID, groupType: .closedGroup, adminIds: group.groupAdminIds) - thread.setGroupModel(newGroupModel, with: transaction) - // Notify the user if needed - guard members != Set(group.groupMemberIds) else { return } - - let updateInfo: String - - if let displayName = Profile.displayNameNoFallback(for: sender) { - updateInfo = String(format: NSLocalizedString("GROUP_MEMBER_LEFT", comment: ""), displayName) + ClosedGroupPoller.shared.stopPolling(for: id) + + _ = try closedGroup + .keyPairs + .deleteAll(db) + let _ = PushNotificationAPI.performOperation( + .unsubscribe, + for: id, + publicKey: getUserHexEncodedPublicKey(db) + ) } else { - updateInfo = NSLocalizedString("GROUP_UPDATED", comment: "") + try GroupMember( + groupId: id, + profileId: sender, + role: .zombie + ).save(db) } - let infoMessage = TSInfoMessage(timestamp: message.sentTimestamp!, in: thread, messageType: .groupUpdated, customMessage: updateInfo) - infoMessage.save(with: transaction) + // Update the group + try membersToRemove + .deleteAll(db) + + // Notify the user if needed + guard updatedMemberIds != Set(members.map { $0.profileId }) else { return } + + _ = try Interaction( + serverHash: message.serverHash, // TODO: Test this? (make sure it won't break anything?) + threadId: thread.id, + authorId: sender, + variant: .infoClosedGroupUpdated, + body: ClosedGroupControlMessage.Kind + .memberLeft + .infoMessage(db, sender: sender), + timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) + ).inserted(db) } } - private static func handleClosedGroupEncryptionKeyPairRequest(_ message: ClosedGroupControlMessage, using transaction: Any) { + private static func handleClosedGroupEncryptionKeyPairRequest(_ db: Database, message: ClosedGroupControlMessage) { /* guard case .encryptionKeyPairRequest = message.kind else { return } let transaction = transaction as! YapDatabaseReadWriteTransaction @@ -807,29 +1147,31 @@ extension MessageReceiver { */ } - private static func performIfValid(for message: ClosedGroupControlMessage, using transaction: Any, _ update: (Data, TSGroupThread, TSGroupModel) -> Void) { - // Prepare - let transaction = transaction as! YapDatabaseReadWriteTransaction - // Get the group - guard let groupPublicKey = message.groupPublicKey else { return } - let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey) - let threadID = TSGroupThread.threadId(fromGroupId: groupID) - guard let thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction) else { + private static func performIfValid( + _ db: Database, + message: ClosedGroupControlMessage, + _ update: (String, String, SessionThread, ClosedGroup + ) throws -> Void) throws { + guard let groupPublicKey: String = message.groupPublicKey else { return } + guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: groupPublicKey) else { return SNLog("Ignoring closed group update for nonexistent group.") } - let group = thread.groupModel + guard let closedGroup: ClosedGroup = try? thread.closedGroup.fetchOne(db) else { return } + // Check that the message isn't from before the group was created - if let formationTimestamp = Storage.shared.getClosedGroupFormationTimestamp(for: groupPublicKey) { - guard message.sentTimestamp! > formationTimestamp else { - return SNLog("Ignoring closed group update from before thread was created.") - } + guard Double(message.sentTimestamp ?? 0) > closedGroup.formationTimestamp else { + return SNLog("Ignoring closed group update from before thread was created.") } + + guard let sender: String = message.sender else { return } + guard let members: [GroupMember] = try? closedGroup.members.fetchAll(db) else { return } + // Check that the sender is a member of the group - guard Set(group.groupMemberIds).contains(message.sender!) else { + guard members.contains(where: { $0.profileId == sender }) else { return SNLog("Ignoring closed group update from non-member.") } - // Perform the update - update(groupID, thread, group) + + try update(groupPublicKey, sender, thread, closedGroup) } // MARK: - Message Requests @@ -839,38 +1181,45 @@ extension MessageReceiver { senderSessionId: String, threadId: String?, forceConfigSync: Bool - ) { + ) throws { let userPublicKey: String = getUserHexEncodedPublicKey(db) // If the sender of the message was the current user if senderSessionId == userPublicKey { - // Retrieve the contact for the thread the message was sent to (excluding 'NoteToSelf' threads) and if - // the contact isn't flagged as approved then do so - guard let threadId: String = threadId else { return } - guard let thread: TSContactThread = TSContactThread.fetch(uniqueId: threadId, transaction: transaction), !thread.isNoteToSelf() else { return } - guard let contact: Contact = Storage.shared.getContact(with: thread.contactSessionID(), using: transaction) else { return } - guard !contact.isApproved else { return } + // Retrieve the contact for the thread the message was sent to (excluding 'NoteToSelf' + // threads) and if the contact isn't flagged as approved then do so + guard + let threadId: String = threadId, + let thread: SessionThread = try? SessionThread.fetchOne(db, id: threadId), + !thread.isNoteToSelf(db), + let contact: Contact = try? thread.contact.fetchOne(db), + !contact.isApproved + else { return } - contact.isApproved = true - Storage.shared.setContact(contact, using: transaction) + try? contact + .with(isApproved: true) + .update(db) } else { // The message was sent to the current user so flag their 'didApproveMe' as true (can't send a message to // someone without approving them) - guard let contact: Contact = Storage.shared.getContact(with: senderSessionId, using: transaction) else { return } - guard !contact.didApproveMe else { return } - - contact.didApproveMe = true - Storage.shared.setContact(contact, using: transaction) + guard + let contact: Contact = try? Contact.fetchOne(db, id: senderSessionId), + !contact.didApproveMe + else { return } + + try? contact + .with(didApproveMe: true) + .update(db) } // Force a config sync to ensure all devices know the contact approval state if desired guard forceConfigSync else { return } - MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete() + try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() } - public static func handleMessageRequestResponse(_ db: Database, _ message: MessageRequestResponse, using transaction: Any) { + private static func handleMessageRequestResponse(_ db: Database, _ message: MessageRequestResponse) throws { let userPublicKey = getUserHexEncodedPublicKey(db) // Ignore messages which were sent from the current user @@ -878,16 +1227,20 @@ extension MessageReceiver { guard let senderId: String = message.sender else { return } // Get the existing thead and notify the user - if let transaction: YapDatabaseReadWriteTransaction = transaction as? YapDatabaseReadWriteTransaction, let thread: TSContactThread = TSContactThread.getWithContactSessionID(senderId, transaction: transaction) { - let infoMessage = TSInfoMessage( - timestamp: (message.sentTimestamp ?? NSDate.ows_millisecondTimeStamp()), - in: thread, - messageType: .messageRequestAccepted - ) - infoMessage.save(with: transaction) + if let thread: SessionThread = try? SessionThread.fetchOne(db, id: senderId) { + _ = try Interaction( + serverHash: message.serverHash, // TODO: Test this? (make sure it won't break anything?) + threadId: thread.id, + authorId: senderId, + variant: .infoMessageRequestAccepted, + timestampMs: ( + message.sentTimestamp.map { Int64($0) } ?? + Int64(floor(Date().timeIntervalSince1970 * 1000)) + ) + ).inserted(db) } - updateContactApprovalStatusIfNeeded( + try updateContactApprovalStatusIfNeeded( db, senderSessionId: senderId, threadId: nil, diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index 4845bc945..719141e75 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -8,124 +8,114 @@ import SessionUtilitiesKit public enum MessageReceiver { private static var lastEncryptionKeyPairRequest: [String: Date] = [:] - public enum Error : LocalizedError { - case duplicateMessage - case invalidMessage - case unknownMessage - case unknownEnvelopeType - case noUserX25519KeyPair - case noUserED25519KeyPair - case invalidSignature - case noData - case senderBlocked - case noThread - case selfSend - case decryptionFailed - case invalidGroupPublicKey - case noGroupKeyPair - - public var isRetryable: Bool { - switch self { - case .duplicateMessage, .invalidMessage, .unknownMessage, .unknownEnvelopeType, - .invalidSignature, .noData, .senderBlocked, .noThread, .selfSend, .decryptionFailed: return false - default: return true - } - } - - public var errorDescription: String? { - switch self { - case .duplicateMessage: return "Duplicate message." - case .invalidMessage: return "Invalid message." - case .unknownMessage: return "Unknown message type." - case .unknownEnvelopeType: return "Unknown envelope type." - case .noUserX25519KeyPair: return "Couldn't find user X25519 key pair." - case .noUserED25519KeyPair: return "Couldn't find user ED25519 key pair." - case .invalidSignature: return "Invalid message signature." - case .noData: return "Received an empty envelope." - case .senderBlocked: return "Received a message from a blocked user." - case .noThread: return "Couldn't find thread for message." - case .selfSend: return "Message addressed at self." - case .decryptionFailed: return "Decryption failed." - // Shared sender keys - case .invalidGroupPublicKey: return "Invalid group public key." - case .noGroupKeyPair: return "Missing group key pair." - } - } - } - - public static func parse(_ db: Database, _ data: Data, openGroupMessageServerID: UInt64?, isRetry: Bool = false, using transaction: Any) throws -> (Message, SNProtoContent) { - let userPublicKey = getUserHexEncodedPublicKey() - let isOpenGroupMessage = (openGroupMessageServerID != nil) + public static func parse( + _ db: Database, + data: Data, + openGroupId: String? = nil, + openGroupMessageServerId: UInt64? = nil, + isRetry: Bool = false + ) throws -> (Message, SNProtoContent) { + let userPublicKey: String = getUserHexEncodedPublicKey() + let isOpenGroupMessage: Bool = (openGroupMessageServerId != nil) + // Parse the envelope let envelope = try SNProtoEnvelope.parseData(data) - let storage = SNMessagingKitConfiguration.shared.storage + // Decrypt the contents - guard let ciphertext = envelope.content else { throw Error.noData } - var plaintext: Data! - var sender: String! + guard let ciphertext = envelope.content else { throw MessageReceiverError.noData } + + var plaintext: Data + var sender: String var groupPublicKey: String? = nil + if isOpenGroupMessage { (plaintext, sender) = (envelope.content!, envelope.source!) - } else { + } + else { switch envelope.type { - case .sessionMessage: - guard let userX25519KeyPair: Box.KeyPair = Identity.fetchUserKeyPair() else { throw Error.noUserX25519KeyPair } - (plaintext, sender) = try decryptWithSessionProtocol(ciphertext: ciphertext, using: userX25519KeyPair) - case .closedGroupMessage: - guard let hexEncodedGroupPublicKey = envelope.source, SNMessagingKitConfiguration.shared.storage.isClosedGroup(hexEncodedGroupPublicKey) else { throw Error.invalidGroupPublicKey } - var encryptionKeyPairs = Storage.shared.getClosedGroupEncryptionKeyPairs(for: hexEncodedGroupPublicKey) - guard !encryptionKeyPairs.isEmpty else { throw Error.noGroupKeyPair } - // Loop through all known group key pairs in reverse order (i.e. try the latest key pair first (which'll more than - // likely be the one we want) but try older ones in case that didn't work) - var encryptionKeyPair = encryptionKeyPairs.removeLast() - func decrypt() throws { + case .sessionMessage: + guard let userX25519KeyPair: Box.KeyPair = Identity.fetchUserKeyPair() else { + throw MessageReceiverError.noUserX25519KeyPair + } + + (plaintext, sender) = try decryptWithSessionProtocol(ciphertext: ciphertext, using: userX25519KeyPair) + + case .closedGroupMessage: + guard + let hexEncodedGroupPublicKey = envelope.source, + let closedGroup: ClosedGroup = try? ClosedGroup.fetchOne(db, id: hexEncodedGroupPublicKey) + else { + throw MessageReceiverError.invalidGroupPublicKey + } + guard + let encryptionKeyPairs: [ClosedGroupKeyPair] = try? closedGroup.keyPairs.order(ClosedGroupKeyPair.Columns.receivedTimestamp.desc).fetchAll(db), + !encryptionKeyPairs.isEmpty + else { + throw MessageReceiverError.noGroupKeyPair + } + + // Loop through all known group key pairs in reverse order (i.e. try the latest key + // pair first (which'll more than likely be the one we want) but try older ones in + // case that didn't work) + func decrypt(keyPairs: [ClosedGroupKeyPair], lastError: Error? = nil) throws -> (Data, String) { + guard let keyPair: ClosedGroupKeyPair = keyPairs.first else { + throw (lastError ?? MessageReceiverError.decryptionFailed) + } + + do { + return try decryptWithSessionProtocol( + ciphertext: ciphertext, + using: Box.KeyPair( + publicKey: Data(hex: keyPair.publicKey).bytes, + secretKey: keyPair.secretKey.bytes + ) + ) + } + catch { + return try decrypt(keyPairs: Array(keyPairs.suffix(from: 1)), lastError: error) + } + } + + groupPublicKey = hexEncodedGroupPublicKey + (plaintext, sender) = try decrypt(keyPairs: encryptionKeyPairs) + + /* do { - (plaintext, sender) = try decryptWithSessionProtocol(ciphertext: ciphertext, using: encryptionKeyPair) + try decrypt() } catch { - if !encryptionKeyPairs.isEmpty { - encryptionKeyPair = encryptionKeyPairs.removeLast() - try decrypt() - } else { - throw error + do { + let now = Date() + // Don't spam encryption key pair requests + let shouldRequestEncryptionKeyPair = given(lastEncryptionKeyPairRequest[groupPublicKey!]) { now.timeIntervalSince($0) > 30 } ?? true + if shouldRequestEncryptionKeyPair { + try MessageSender.requestEncryptionKeyPair(for: groupPublicKey!, using: transaction as! YapDatabaseReadWriteTransaction) + lastEncryptionKeyPairRequest[groupPublicKey!] = now + } } + throw error // Throw the * decryption * error and not the error generated by requestEncryptionKeyPair (if it generated one) } - } - groupPublicKey = envelope.source - try decrypt() - /* - do { - try decrypt() - } catch { - do { - let now = Date() - // Don't spam encryption key pair requests - let shouldRequestEncryptionKeyPair = given(lastEncryptionKeyPairRequest[groupPublicKey!]) { now.timeIntervalSince($0) > 30 } ?? true - if shouldRequestEncryptionKeyPair { - try MessageSender.requestEncryptionKeyPair(for: groupPublicKey!, using: transaction as! YapDatabaseReadWriteTransaction) - lastEncryptionKeyPairRequest[groupPublicKey!] = now - } - } - throw error // Throw the * decryption * error and not the error generated by requestEncryptionKeyPair (if it generated one) - } - */ - default: throw Error.unknownEnvelopeType + */ + + default: throw MessageReceiverError.unknownEnvelopeType } } // Don't process the envelope any further if the sender is blocked - guard GRDBStorage.shared.read({ db in try Contact.fetchOne(db, id: sender) })?.isBlocked != true else { -// guard (try? Contact.fetchOne(db, id: sender))?.isBlocked != true else { - throw Error.senderBlocked + guard (try? Contact.fetchOne(db, id: sender))?.isBlocked != true else { + throw MessageReceiverError.senderBlocked } // Parse the proto let proto: SNProtoContent + do { proto = try SNProtoContent.parseData((plaintext as NSData).removePadding()) - } catch { + } + catch { SNLog("Couldn't parse proto due to error: \(error).") throw error } + // Parse the message let message: Message? = { if let readReceipt = ReadReceipt.fromProto(proto, sender: sender) { return readReceipt } @@ -139,50 +129,82 @@ public enum MessageReceiver { if let visibleMessage = VisibleMessage.fromProto(proto, sender: sender) { return visibleMessage } return nil }() + if let message = message { // Ignore self sends if needed if !message.isSelfSendValid { - guard sender != userPublicKey else { throw Error.selfSend } + guard sender != userPublicKey else { throw MessageReceiverError.selfSend } } + // Guard against control messages in open groups if isOpenGroupMessage { - guard message is VisibleMessage else { throw Error.invalidMessage } + guard message is VisibleMessage else { throw MessageReceiverError.invalidMessage } } + // Finish parsing message.sender = sender message.recipient = userPublicKey message.sentTimestamp = envelope.timestamp - message.receivedTimestamp = NSDate.millisecondTimestamp() - if isOpenGroupMessage { - message.openGroupServerTimestamp = envelope.serverTimestamp - } + message.receivedTimestamp = UInt64((Date().timeIntervalSince1970) * 1000) message.groupPublicKey = groupPublicKey - message.openGroupServerMessageID = openGroupMessageServerID + message.openGroupServerMessageID = openGroupMessageServerId + // Validate - var isValid = message.isValid + var isValid: Bool = message.isValid if message is VisibleMessage && !isValid && proto.dataMessage?.attachments.isEmpty == false { isValid = true } + guard isValid else { - throw Error.invalidMessage + throw MessageReceiverError.invalidMessage } + // If the message failed to process the first time around we retry it later (if the error is retryable). In this case the timestamp // will already be in the database but we don't want to treat the message as a duplicate. The isRetry flag is a simple workaround // for this issue. - if let message = message as? ClosedGroupControlMessage, case .new = message.kind { + + switch (isRetry, message, (message as? ClosedGroupControlMessage)?.kind) { // Allow duplicates in this case to avoid the following situation: // • The app performed a background poll or received a push notification // • This method was invoked and the received message timestamps table was updated // • Processing wasn't finished - // • The user doesn't see the new closed group - } else { - guard !Set(storage.getReceivedMessageTimestamps(using: transaction)).contains(envelope.timestamp) || isRetry else { throw Error.duplicateMessage } - storage.addReceivedMessageTimestamp(envelope.timestamp, using: transaction) + // • The user doesn't see theO new closed group + case (_, _, .new): break + + // All `VisibleMessage` values will have an associated `Interaction` so just let + // the unique constraints on that table prevent duplicate messages + case is (Bool, VisibleMessage, ClosedGroupControlMessage.Kind?): break + + // If the message failed to process and we are retrying then there will already + // be a `ControlMessageProcessRecord`, so just allow this through + case (true, _, _): break + + default: + do { + try ControlMessageProcessRecord( + threadId: { + if let openGroupId: String = openGroupId { + return openGroupId + } + + if let groupPublicKey: String = groupPublicKey { + return groupPublicKey + } + + return sender + }(), + sentTimestampMs: Int64(envelope.timestamp), + serverHash: (message.serverHash ?? ""), + openGroupMessageServerId: (openGroupMessageServerId.map { Int64($0) } ?? 0) + ).insert(db) + } + catch { throw MessageReceiverError.duplicateMessage } } + // Return return (message, proto) - } else { - throw Error.unknownMessage } + + throw MessageReceiverError.unknownMessage } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift index 0ec1f0e81..da870d85c 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift @@ -1,84 +1,137 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB import Sodium import Curve25519Kit import PromiseKit +import SessionUtilitiesKit extension MessageSender { public static var distributingClosedGroupEncryptionKeyPairs: [String: [Box.KeyPair]] = [:] - public static func createClosedGroup(name: String, members: Set, transaction: YapDatabaseReadWriteTransaction) -> Promise { - // Prepare - var members = members - let userPublicKey = getUserHexEncodedPublicKey() + public static func createClosedGroup(_ db: Database, name: String, members: Set) throws -> Promise { + let userPublicKey: String = getUserHexEncodedPublicKey() + var members: Set = members + // Generate the group's public key let groupPublicKey = Curve25519.generateKeyPair().hexEncodedPublicKey // Includes the "05" prefix // Generate the key pair that'll be used for encryption and decryption let encryptionKeyPair = Curve25519.generateKeyPair() - // Ensure the current user is included in the member list - members.insert(userPublicKey) - let membersAsData = members.map { Data(hex: $0) } - // Create the group - let admins = [ userPublicKey ] - let adminsAsData = admins.map { Data(hex: $0) } - let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey) - let group = TSGroupModel(title: name, memberIds: [String](members), image: nil, groupId: groupID, groupType: .closedGroup, adminIds: admins) - let thread = TSGroupThread.getOrCreateThread(with: group, transaction: transaction) - thread.save(with: transaction) - // Send a closed group update message to all members individually - var promises: [Promise] = [] - for member in members { - let thread = TSContactThread.getOrCreateThread(withContactSessionID: member, transaction: transaction) - thread.save(with: transaction) - let keyPair: Box.KeyPair = Box.KeyPair( - publicKey: encryptionKeyPair.publicKey.bytes, - secretKey: encryptionKeyPair.privateKey.bytes - ) - let closedGroupControlMessageKind = ClosedGroupControlMessage.Kind.new(publicKey: Data(hex: groupPublicKey), name: name, - encryptionKeyPair: keyPair, members: membersAsData, admins: adminsAsData, expirationTimer: 0) - let closedGroupControlMessage = ClosedGroupControlMessage(kind: closedGroupControlMessageKind) - // Sending this non-durably is okay because we show a loader to the user. If they close the app while the - // loader is still showing, it's within expectation that the group creation might be incomplete. - let promise = MessageSender.sendNonDurably(closedGroupControlMessage, in: thread, using: transaction) - promises.append(promise) - } - // Add the group to the user's set of public keys to poll for - Storage.shared.addClosedGroupPublicKey(groupPublicKey, using: transaction) - // Store the key pair let keyPair: Box.KeyPair = Box.KeyPair( publicKey: encryptionKeyPair.publicKey.bytes, secretKey: encryptionKeyPair.privateKey.bytes ) - Storage.shared.addClosedGroupEncryptionKeyPair(keyPair, for: groupPublicKey, using: transaction) + + // Create the group + members.insert(userPublicKey) // Ensure the current user is included in the member list + let membersAsData = members.map { Data(hex: $0) } + let admins = [ userPublicKey ] + let adminsAsData = admins.map { Data(hex: $0) } + + let thread: SessionThread = try SessionThread + .fetchOrCreate(db, id: groupPublicKey, variant: .closedGroup) + .saved(db) + try ClosedGroup( + threadId: groupPublicKey, + name: name, + formationTimestamp: Date().timeIntervalSince1970 + ).insert(db) + + try admins.forEach { adminId in + try GroupMember( + groupId: groupPublicKey, + profileId: adminId, + role: .admin + ).insert(db) + } + + // Send a closed group update message to all members individually + var promises: [Promise] = [] + + try members.forEach { adminId in + try GroupMember( + groupId: groupPublicKey, + profileId: adminId, + role: .admin + ).insert(db) + } + + try members.forEach { memberId in + let contactThread: SessionThread = try SessionThread + .fetchOrCreate(db, id: memberId, variant: .contact) + .saved(db) + + // Sending this non-durably is okay because we show a loader to the user. If they + // close the app while the loader is still showing, it's within expectation that + // the group creation might be incomplete. + promises.append( + try MessageSender.sendNonDurably( + db, + message: ClosedGroupControlMessage( + kind: .new( + publicKey: Data(hex: groupPublicKey), + name: name, + encryptionKeyPair: keyPair, + members: membersAsData, + admins: adminsAsData, + expirationTimer: 0 + ) + ), + interactionId: nil, + in: contactThread + ) + ) + } + + // Store the key pair + try ClosedGroupKeyPair( + publicKey: keyPair.publicKey.toHexString(), + secretKey: Data(keyPair.secretKey), + receivedTimestamp: Date().timeIntervalSince1970 + ).insert(db) + // Notify the PN server - promises.append(PushNotificationAPI.performOperation(.subscribe, for: groupPublicKey, publicKey: userPublicKey)) + promises.append( + PushNotificationAPI.performOperation( + .subscribe, + for: groupPublicKey, + publicKey: userPublicKey + ) + ) + // Notify the user - let infoMessage = TSInfoMessage(timestamp: NSDate.ows_millisecondTimeStamp(), in: thread, messageType: .groupCreated) - infoMessage.save(with: transaction) + // + // Note: Intentionally don't want a 'serverHash' for closed group creation + _ = try Interaction( + threadId: thread.id, + authorId: userPublicKey, + variant: .infoClosedGroupCreated, + timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) + ).inserted(db) + // Start polling ClosedGroupPoller.shared.startPolling(for: groupPublicKey) - // Return + return when(fulfilled: promises).map2 { thread } } - /// Generates and distributes a new encryption key pair for the group with the given `groupPublicKey`. This sends a `ENCRYPTION_KEY_PAIR` message to the group. The - /// message contains a list of key pair wrappers. Each key pair wrapper consists of the public key for which the wrapper is intended along with the newly generated key pair + /// Generates and distributes a new encryption key pair for the group with the given closed group. This sends an + /// `ENCRYPTION_KEY_PAIR` message to the group. The message contains a list of key pair wrappers. Each key + /// pair wrapper consists of the public key for which the wrapper is intended along with the newly generated key pair /// encrypted for that public key. /// /// The returned promise is fulfilled when the message has been sent to the group. - public static func generateAndSendNewEncryptionKeyPair(for groupPublicKey: String, to targetMembers: Set, using transaction: Any) -> Promise { - // Prepare - let transaction = transaction as! YapDatabaseReadWriteTransaction - let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey) - let threadID = TSGroupThread.threadId(fromGroupId: groupID) - guard let thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction) else { - SNLog("Can't distribute new encryption key pair for nonexistent closed group.") - return Promise(error: Error.noThread) - } - guard thread.groupModel.groupAdminIds.contains(getUserHexEncodedPublicKey()) else { - SNLog("Can't distribute new encryption key pair as a non-admin.") - return Promise(error: Error.invalidClosedGroupUpdate) + private static func generateAndSendNewEncryptionKeyPair( + _ db: Database, + targetMembers: Set, + userPublicKey: String, + allGroupMembers: [GroupMember], + closedGroup: ClosedGroup, + thread: SessionThread + ) throws -> Promise { + guard allGroupMembers.contains(where: { $0.role == .admin && $0.profileId == userPublicKey }) else { + return Promise(error: MessageSenderError.invalidClosedGroupUpdate) } // Generate the new encryption key pair let newLegacyKeyPair = Curve25519.generateKeyPair() @@ -86,223 +139,435 @@ extension MessageSender { publicKey: newLegacyKeyPair.publicKey.bytes, secretKey: newLegacyKeyPair.privateKey.bytes ) + // Distribute it - let proto = try! SNProtoKeyPair.builder(publicKey: Data(newKeyPair.publicKey), + let proto = try SNProtoKeyPair.builder(publicKey: Data(newKeyPair.publicKey), privateKey: Data(newKeyPair.secretKey)).build() - let plaintext = try! proto.serializedData() - let wrappers = targetMembers.compactMap { publicKey -> ClosedGroupControlMessage.KeyPairWrapper in - let ciphertext = try! MessageSender.encryptWithSessionProtocol(plaintext, for: publicKey) - return ClosedGroupControlMessage.KeyPairWrapper(publicKey: publicKey, encryptedKeyPair: ciphertext) - } - let closedGroupControlMessage = ClosedGroupControlMessage(kind: .encryptionKeyPair(publicKey: nil, wrappers: wrappers)) - var distributingKeyPairs = distributingClosedGroupEncryptionKeyPairs[groupPublicKey] ?? [] + let plaintext = try proto.serializedData() + + var distributingKeyPairs = (distributingClosedGroupEncryptionKeyPairs[closedGroup.id] ?? []) distributingKeyPairs.append(newKeyPair) - distributingClosedGroupEncryptionKeyPairs[groupPublicKey] = distributingKeyPairs - return MessageSender.sendNonDurably(closedGroupControlMessage, in: thread, using: transaction).done { - // Store it * after * having sent out the message to the group - SNMessagingKitConfiguration.shared.storage.write { transaction in - Storage.shared.addClosedGroupEncryptionKeyPair(newKeyPair, for: groupPublicKey, using: transaction) - } - var distributingKeyPairs = distributingClosedGroupEncryptionKeyPairs[groupPublicKey] ?? [] - if let index = distributingKeyPairs.firstIndex(of: newKeyPair) { - distributingKeyPairs.remove(at: index) - } - distributingClosedGroupEncryptionKeyPairs[groupPublicKey] = distributingKeyPairs - }.map { _ in } + distributingClosedGroupEncryptionKeyPairs[closedGroup.id] = distributingKeyPairs + + do { + return try MessageSender + .sendNonDurably( + db, + message: ClosedGroupControlMessage( + kind: .encryptionKeyPair( + publicKey: nil, + wrappers: targetMembers.map { memberPublicKey in + ClosedGroupControlMessage.KeyPairWrapper( + publicKey: memberPublicKey, + encryptedKeyPair: try MessageSender.encryptWithSessionProtocol( + plaintext, + for: memberPublicKey + ) + ) + } + ) + ), + interactionId: nil, + in: thread + ) + .done { + /// Store it **after** having sent out the message to the group + GRDBStorage.shared.write { db in + try ClosedGroupKeyPair( + publicKey: newKeyPair.publicKey.toHexString(), + secretKey: Data(newKeyPair.secretKey), + receivedTimestamp: Date().timeIntervalSince1970 + ).insert(db) + + var distributingKeyPairs = (distributingClosedGroupEncryptionKeyPairs[closedGroup.id] ?? []) + + if let index = distributingKeyPairs.firstIndex(of: newKeyPair) { + distributingKeyPairs.remove(at: index) + distributingClosedGroupEncryptionKeyPairs[closedGroup.id] = distributingKeyPairs + } + } + } + .map { _ in } + } + catch { + return Promise(error: MessageSenderError.invalidClosedGroupUpdate) + } } - public static func update(_ groupPublicKey: String, with members: Set, name: String, transaction: YapDatabaseReadWriteTransaction) -> Promise { + public static func update( + _ db: Database, + groupPublicKey: String, + with members: Set, + name: String + ) throws -> Promise { // Get the group, check preconditions & prepare - let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey) - let threadID = TSGroupThread.threadId(fromGroupId: groupID) - guard let thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction) else { + guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: groupPublicKey) else { SNLog("Can't update nonexistent closed group.") - return Promise(error: Error.noThread) + return Promise(error: MessageSenderError.noThread) } - let group = thread.groupModel - var promises: [Promise] = [] - let zombies = SNMessagingKitConfiguration.shared.storage.getZombieMembers(for: groupPublicKey) + guard let closedGroup: ClosedGroup = try? thread.closedGroup.fetchOne(db) else { + return Promise(error: MessageSenderError.invalidClosedGroupUpdate) + } + + let userPublicKey: String = getUserHexEncodedPublicKey(db) + // Update name if needed - if name != group.groupName { promises.append(setName(to: name, for: groupPublicKey, using: transaction)) } + if name != closedGroup.name { + // Update the group + let updatedClosedGroup: ClosedGroup = closedGroup.with(name: name) + try updatedClosedGroup.save(db) + + // Notify the user + let interaction: Interaction = try Interaction( + threadId: thread.id, + authorId: userPublicKey, + variant: .infoClosedGroupUpdated, + body: ClosedGroupControlMessage.Kind + .nameChange(name: name) + .infoMessage(db, sender: userPublicKey), + timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) + ).inserted(db) + + guard let interactionId: Int64 = interaction.id else { + throw GRDBStorageError.objectNotSaved + } + + // Send the update to the group + let closedGroupControlMessage = ClosedGroupControlMessage(kind: .nameChange(name: name)) + try MessageSender.send( + db, + message: closedGroupControlMessage, + interactionId: interactionId, + in: thread + ) + } + + // Retrieve member info + guard let allGroupMembers: [GroupMember] = try? closedGroup.allMembers.fetchAll(db) else { + return Promise(error: MessageSenderError.invalidClosedGroupUpdate) + } + + let standardAndZombieMemberIds: [String] = allGroupMembers + .filter { $0.role == .standard || $0.role == .zombie } + .map { $0.profileId } + let addedMembers: Set = members.subtracting(standardAndZombieMemberIds) + // Add members if needed - let addedMembers = members.subtracting(group.groupMemberIds + zombies) - if !addedMembers.isEmpty { promises.append(addMembers(addedMembers, to: groupPublicKey, using: transaction)) } + if !addedMembers.isEmpty { + do { + try addMembers( + db, + addedMembers: addedMembers, + userPublicKey: userPublicKey, + allGroupMembers: allGroupMembers, + closedGroup: closedGroup, + thread: thread + ) + } + catch { + return Promise(error: MessageSenderError.invalidClosedGroupUpdate) + } + } + // Remove members if needed - let removedMembers = Set(group.groupMemberIds + zombies).subtracting(members) - if !removedMembers.isEmpty{ promises.append(removeMembers(removedMembers, to: groupPublicKey, using: transaction)) } - // Return - return when(fulfilled: promises).map2 { _ in } - } - - /// Sets the name to `name` for the group with the given `groupPublicKey`. This sends a `NAME_CHANGE` message to the group. - public static func setName(to name: String, for groupPublicKey: String, using transaction: YapDatabaseReadWriteTransaction) -> Promise { - // Get the group, check preconditions & prepare - let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey) - let threadID = TSGroupThread.threadId(fromGroupId: groupID) - guard let thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction) else { - SNLog("Can't change name for nonexistent closed group.") - return Promise(error: Error.noThread) + let removedMembers: Set = Set(standardAndZombieMemberIds).subtracting(members) + + if !removedMembers.isEmpty { + do { + return try removeMembers( + db, + removedMembers: removedMembers, + userPublicKey: userPublicKey, + allGroupMembers: allGroupMembers, + closedGroup: closedGroup, + thread: thread + ) + } + catch { + return Promise(error: MessageSenderError.invalidClosedGroupUpdate) + } } - guard !name.isEmpty else { - SNLog("Can't set closed group name to an empty value.") - return Promise(error: Error.invalidClosedGroupUpdate) - } - let group = thread.groupModel - // Send the update to the group - let closedGroupControlMessage = ClosedGroupControlMessage(kind: .nameChange(name: name)) - MessageSender.send(closedGroupControlMessage, in: thread, using: transaction) - // Update the group - let newGroupModel = TSGroupModel(title: name, memberIds: group.groupMemberIds, image: nil, groupId: groupID, groupType: .closedGroup, adminIds: group.groupAdminIds) - thread.setGroupModel(newGroupModel, with: transaction) - // Notify the user - let updateInfo = group.getInfoStringAboutUpdate(to: newGroupModel) - let infoMessage = TSInfoMessage(timestamp: NSDate.ows_millisecondTimeStamp(), in: thread, messageType: .groupUpdated, customMessage: updateInfo) - infoMessage.save(with: transaction) - // Return + return Promise.value(()) } - /// Adds `newMembers` to the group with the given `groupPublicKey`. This sends a `MEMBERS_ADDED` message to the group, and a + + /// Adds `newMembers` to the group with the given closed group. This sends a `MEMBERS_ADDED` message to the group, and a /// `NEW` message to the members that were added (using one-on-one channels). - public static func addMembers(_ newMembers: Set, to groupPublicKey: String, using transaction: YapDatabaseReadWriteTransaction) -> Promise { - // Get the group, check preconditions & prepare - let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey) - let threadID = TSGroupThread.threadId(fromGroupId: groupID) - guard let thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction) else { - SNLog("Can't add members to nonexistent closed group.") - return Promise(error: Error.noThread) + private static func addMembers( + _ db: Database, + addedMembers: Set, + userPublicKey: String, + allGroupMembers: [GroupMember], + closedGroup: ClosedGroup, + thread: SessionThread + ) throws { + guard let disappearingMessagesConfig: DisappearingMessagesConfiguration = try thread.disappearingMessagesConfiguration.fetchOne(db) else { + throw GRDBStorageError.objectNotFound } - guard !newMembers.isEmpty else { - SNLog("Invalid closed group update.") - return Promise(error: Error.invalidClosedGroupUpdate) + guard let encryptionKeyPair: ClosedGroupKeyPair = try closedGroup.fetchLatestKeyPair(db) else { + throw GRDBStorageError.objectNotFound } - let group = thread.groupModel - let members = [String](Set(group.groupMemberIds).union(newMembers)) - let membersAsData = members.map { Data(hex: $0) } - let adminsAsData = group.groupAdminIds.map { Data(hex: $0) } - let expirationTimer = thread.disappearingMessagesDuration(with: transaction) - guard let encryptionKeyPair = Storage.shared.getLatestClosedGroupEncryptionKeyPair(for: groupPublicKey) else { - SNLog("Couldn't find encryption key pair for closed group: \(groupPublicKey).") - return Promise(error: Error.noKeyPair) - } - // Send the update to the group - let closedGroupControlMessage = ClosedGroupControlMessage(kind: .membersAdded(members: newMembers.map { Data(hex: $0) })) - MessageSender.send(closedGroupControlMessage, in: thread, using: transaction) - // Send updates to the new members individually - for member in newMembers { - let thread = TSContactThread.getOrCreateThread(withContactSessionID: member, transaction: transaction) - thread.save(with: transaction) - let closedGroupControlMessageKind = ClosedGroupControlMessage.Kind.new(publicKey: Data(hex: groupPublicKey), name: group.groupName!, - encryptionKeyPair: encryptionKeyPair, members: membersAsData, admins: adminsAsData, expirationTimer: expirationTimer) - let closedGroupControlMessage = ClosedGroupControlMessage(kind: closedGroupControlMessageKind) - MessageSender.send(closedGroupControlMessage, in: thread, using: transaction) - } - // Update the group - let newGroupModel = TSGroupModel(title: group.groupName, memberIds: members, image: nil, groupId: groupID, groupType: .closedGroup, adminIds: group.groupAdminIds) - thread.setGroupModel(newGroupModel, with: transaction) + + let groupMemberIds: [String] = allGroupMembers + .filter { $0.role == .standard } + .map { $0.profileId } + let groupAdminIds: [String] = allGroupMembers + .filter { $0.role == .admin } + .map { $0.profileId } + let members: Set = Set(groupMemberIds).union(addedMembers) + let membersAsData: [Data] = members.map { Data(hex: $0) } + let adminsAsData: [Data] = groupAdminIds.map { Data(hex: $0) } + // Notify the user - let updateInfo = group.getInfoStringAboutUpdate(to: newGroupModel) - let infoMessage = TSInfoMessage(timestamp: NSDate.ows_millisecondTimeStamp(), in: thread, messageType: .groupUpdated, customMessage: updateInfo) - infoMessage.save(with: transaction) - // Return - return Promise.value(()) + let interaction: Interaction = try Interaction( + threadId: thread.id, + authorId: userPublicKey, + variant: .infoClosedGroupUpdated, + body: ClosedGroupControlMessage.Kind + .membersAdded(members: addedMembers.map { Data(hex: $0) }) + .infoMessage(db, sender: userPublicKey), + timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) + ).inserted(db) + + guard let interactionId: Int64 = interaction.id else { + throw GRDBStorageError.objectNotSaved + } + + // Send the update to the group + try MessageSender.send( + db, + message: ClosedGroupControlMessage( + kind: .membersAdded(members: addedMembers.map { Data(hex: $0) }) + ), + interactionId: interactionId, + in: thread + ) + + try addedMembers.forEach { member in + // Send updates to the new members individually + let thread: SessionThread = try SessionThread + .fetchOrCreate(db, id: member, variant: .contact) + .saved(db) + + try MessageSender.send( + db, + message: ClosedGroupControlMessage( + kind: .new( + publicKey: Data(hex: closedGroup.id), + name: closedGroup.name, + encryptionKeyPair: Box.KeyPair( + publicKey: encryptionKeyPair.publicKey.bytes, + secretKey: encryptionKeyPair.secretKey.bytes + ), + members: membersAsData, + admins: adminsAsData, + expirationTimer: (disappearingMessagesConfig.isEnabled ? + UInt32(floor(disappearingMessagesConfig.durationSeconds)) : + 0 + ) + ) + ), + interactionId: nil, + in: thread + ) + + // Add the users to the group + try GroupMember( + groupId: closedGroup.id, + profileId: member, + role: .standard + ).insert(db) + } } - + /// Removes `membersToRemove` from the group with the given `groupPublicKey`. Only the admin can remove members, and when they do /// they generate and distribute a new encryption key pair for the group. A member cannot leave a group using this method. For that they should use /// `leave(:using:)`. /// /// The returned promise is fulfilled when the `MEMBERS_REMOVED` message has been sent to the group AND the new encryption key pair has been /// generated and distributed. - public static func removeMembers(_ membersToRemove: Set, to groupPublicKey: String, using transaction: YapDatabaseReadWriteTransaction) -> Promise { - // Get the group, check preconditions & prepare - let userPublicKey = getUserHexEncodedPublicKey() - let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey) - let threadID = TSGroupThread.threadId(fromGroupId: groupID) - let storage = SNMessagingKitConfiguration.shared.storage - guard let thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction) else { - SNLog("Can't remove members from nonexistent closed group.") - return Promise(error: Error.noThread) - } - guard !membersToRemove.isEmpty else { + private static func removeMembers( + _ db: Database, + removedMembers: Set, + userPublicKey: String, + allGroupMembers: [GroupMember], + closedGroup: ClosedGroup, + thread: SessionThread + ) throws -> Promise { + guard !removedMembers.contains(userPublicKey) else { SNLog("Invalid closed group update.") - return Promise(error: Error.invalidClosedGroupUpdate) + throw MessageSenderError.invalidClosedGroupUpdate } - guard !membersToRemove.contains(userPublicKey) else { - SNLog("Invalid closed group update.") - return Promise(error: Error.invalidClosedGroupUpdate) - } - let group = thread.groupModel - guard group.groupAdminIds.contains(userPublicKey) else { + guard allGroupMembers.contains(where: { $0.role == .admin && $0.profileId == userPublicKey }) else { SNLog("Only an admin can remove members from a group.") - return Promise(error: Error.invalidClosedGroupUpdate) + throw MessageSenderError.invalidClosedGroupUpdate } - let members = Set(group.groupMemberIds).subtracting(membersToRemove) - // Update zombie list - let oldZombies = storage.getZombieMembers(for: groupPublicKey) - let newZombies = oldZombies.subtracting(membersToRemove) - storage.setZombieMembers(for: groupPublicKey, to: newZombies, using: transaction) - // Send the update to the group and generate + distribute a new encryption key pair - let closedGroupControlMessage = ClosedGroupControlMessage(kind: .membersRemoved(members: membersToRemove.map { Data(hex: $0) })) - let promise = MessageSender.sendNonDurably(closedGroupControlMessage, in: thread, using: transaction).map { - generateAndSendNewEncryptionKeyPair(for: groupPublicKey, to: members, using: transaction) - }.map { _ in } - // Update the group - let newGroupModel = TSGroupModel(title: group.groupName, memberIds: [String](members), image: nil, groupId: groupID, groupType: .closedGroup, adminIds: group.groupAdminIds) - thread.setGroupModel(newGroupModel, with: transaction) + + let groupMemberIds: [String] = allGroupMembers + .filter { $0.role == .standard } + .map { $0.profileId } + let groupZombieIds: [String] = allGroupMembers + .filter { $0.role == .zombie } + .map { $0.profileId } + let members: Set = Set(groupMemberIds).subtracting(removedMembers) + + // Update zombie * member list + try allGroupMembers + .filter { member in + removedMembers.contains(member.profileId) && ( + member.role == .standard || + member.role == .zombie + ) + } + .forEach { try $0.delete(db) } + + let interactionId: Int64? + // Notify the user if needed (not if only zombie members were removed) - if !membersToRemove.subtracting(oldZombies).isEmpty { - let updateInfo = group.getInfoStringAboutUpdate(to: newGroupModel) - let infoMessage = TSInfoMessage(timestamp: NSDate.ows_millisecondTimeStamp(), in: thread, messageType: .groupUpdated, customMessage: updateInfo) - infoMessage.save(with: transaction) + if !removedMembers.subtracting(groupZombieIds).isEmpty { + let interaction: Interaction = try Interaction( + threadId: thread.id, + authorId: userPublicKey, + variant: .infoClosedGroupUpdated, + body: ClosedGroupControlMessage.Kind + .membersRemoved(members: removedMembers.map { Data(hex: $0) }) + .infoMessage(db, sender: userPublicKey), + timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) + ).inserted(db) + + guard let newInteractionId: Int64 = interaction.id else { + throw GRDBStorageError.objectNotSaved + } + + interactionId = newInteractionId } - // Return + else { + interactionId = nil + } + + // Send the update to the group and generate + distribute a new encryption key pair + let promise = try MessageSender + .sendNonDurably( + db, + message: ClosedGroupControlMessage( + kind: .membersRemoved( + members: removedMembers.map { Data(hex: $0) } + ) + ), + interactionId: interactionId, + in: thread + ) + .map { _ in + try generateAndSendNewEncryptionKeyPair( + db, + targetMembers: members, + userPublicKey: userPublicKey, + allGroupMembers: allGroupMembers, + closedGroup: closedGroup, + thread: thread + ) + } + .map { _ in } + return promise } - @objc(leaveClosedGroupWithPublicKey:using:) - public static func objc_leave(_ groupPublicKey: String, using transaction: YapDatabaseReadWriteTransaction) -> AnyPromise { - return AnyPromise.from(leave(groupPublicKey, using: transaction)) + @objc(leaveClosedGroupWithPublicKey:) + public static func objc_leave(_ groupPublicKey: String) -> AnyPromise { + let promise = GRDBStorage.shared.write { db in + try leave(db, groupPublicKey: groupPublicKey) + } + + return AnyPromise.from(promise) } - /// Leave the group with the given `groupPublicKey`. If the current user is the admin, the group is disbanded entirely. If the user is a regular - /// member they'll be marked as a "zombie" member by the other users in the group (upon receiving the leave message). The admin can then truly - /// remove them later. + /// Leave the group with the given `groupPublicKey`. If the current user is the admin, the group is disbanded entirely. If the + /// user is a regular member they'll be marked as a "zombie" member by the other users in the group (upon receiving the leave + /// message). The admin can then truly remove them later. /// - /// This function also removes all encryption key pairs associated with the closed group and the group's public key, and unregisters from push notifications. + /// This function also removes all encryption key pairs associated with the closed group and the group's public key, and + /// unregisters from push notifications. /// /// The returned promise is fulfilled when the `MEMBER_LEFT` message has been sent to the group. - public static func leave(_ groupPublicKey: String, using transaction: YapDatabaseReadWriteTransaction) -> Promise { - // Get the group, check preconditions & prepare - let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey) - let threadID = TSGroupThread.threadId(fromGroupId: groupID) - guard let thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction) else { + public static func leave(_ db: Database, groupPublicKey: String) throws -> Promise { + guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: groupPublicKey) else { SNLog("Can't leave nonexistent closed group.") - return Promise(error: Error.noThread) + return Promise(error: MessageSenderError.noThread) } - let group = thread.groupModel - let userPublicKey = getUserHexEncodedPublicKey() - let isCurrentUserAdmin = group.groupAdminIds.contains(userPublicKey) - let members: Set = isCurrentUserAdmin ? [] : Set(group.groupMemberIds).subtracting([ userPublicKey ]) // If the admin leaves the group is disbanded - let admins: Set = isCurrentUserAdmin ? [] : Set(group.groupAdminIds) - // Send the update to the group - let closedGroupControlMessage = ClosedGroupControlMessage(kind: .memberLeft) - let promise = MessageSender.sendNonDurably(closedGroupControlMessage, in: thread, using: transaction).done { - SNMessagingKitConfiguration.shared.storage.write { transaction in - // Remove the group from the database and unsubscribe from PNs - Storage.shared.removeAllClosedGroupEncryptionKeyPairs(for: groupPublicKey, using: transaction) - Storage.shared.removeClosedGroupPublicKey(groupPublicKey, using: transaction) - ClosedGroupPoller.shared.stopPolling(for: groupPublicKey) - let _ = PushNotificationAPI.performOperation(.unsubscribe, for: groupPublicKey, publicKey: userPublicKey) + guard let closedGroup: ClosedGroup = try? thread.closedGroup.fetchOne(db) else { + return Promise(error: MessageSenderError.invalidClosedGroupUpdate) + } + guard let allGroupMembers: [GroupMember] = try? closedGroup.allMembers.fetchAll(db) else { + return Promise(error: MessageSenderError.invalidClosedGroupUpdate) + } + + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let isCurrentUserAdmin: Bool = allGroupMembers.contains(where: { + $0.role == .admin && $0.profileId == userPublicKey + }) + let membersToRemove: [GroupMember] = allGroupMembers + .filter { member in + member.role == .standard && ( + isCurrentUserAdmin || // If the admin leaves the group is disbanded + member.profileId == userPublicKey + ) } - }.map { _ in } - // Update the group - let newGroupModel = TSGroupModel(title: group.groupName, memberIds: [String](members), image: nil, groupId: groupID, groupType: .closedGroup, adminIds: [String](admins)) - thread.setGroupModel(newGroupModel, with: transaction) + let adminsToRemove: [GroupMember] = allGroupMembers + .filter { member in + member.role == .admin && ( + isCurrentUserAdmin || // If the admin leaves the group is disbanded + member.profileId == userPublicKey + ) + } + // Notify the user - let updateInfo = group.getInfoStringAboutUpdate(to: newGroupModel) - let infoMessage = TSInfoMessage(timestamp: NSDate.ows_millisecondTimeStamp(), in: thread, messageType: .groupCurrentUserLeft, customMessage: updateInfo) - infoMessage.save(with: transaction) + let interaction: Interaction = try Interaction( + threadId: thread.id, + authorId: userPublicKey, + variant: .infoClosedGroupCurrentUserLeft, + body: ClosedGroupControlMessage.Kind + .memberLeft + .infoMessage(db, sender: userPublicKey), + timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) + ).inserted(db) + + guard let interactionId: Int64 = interaction.id else { + throw GRDBStorageError.objectNotSaved + } + + // Send the update to the group + let promise = try MessageSender + .sendNonDurably( + db, + message: ClosedGroupControlMessage( + kind: .memberLeft + ), + interactionId: interactionId, + in: thread + ) + .done { + GRDBStorage.shared.write { db in + // Remove the group from the database and unsubscribe from PNs + ClosedGroupPoller.shared.stopPolling(for: groupPublicKey) + + _ = try closedGroup + .keyPairs + .deleteAll(db) + + let _ = PushNotificationAPI.performOperation( + .unsubscribe, + for: groupPublicKey, + publicKey: userPublicKey + ) + } + } + .map { _ in } + + // Update the group + try membersToRemove.deleteAll(db) + try adminsToRemove.deleteAll(db) + // Return return promise } @@ -327,28 +592,66 @@ extension MessageSender { } */ - public static func sendLatestEncryptionKeyPair(to publicKey: String, for groupPublicKey: String, using transaction: YapDatabaseReadWriteTransaction) { - // Check that the user in question is part of the closed group - let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey) - let threadID = TSGroupThread.threadId(fromGroupId: groupID) - guard let groupThread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction) else { + public static func sendLatestEncryptionKeyPair(_ db: Database, to publicKey: String, for groupPublicKey: String) { + guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: groupPublicKey) else { return SNLog("Couldn't send key pair for nonexistent closed group.") } - let group = groupThread.groupModel - guard group.groupMemberIds.contains(publicKey) else { + guard let closedGroup: ClosedGroup = try? thread.closedGroup.fetchOne(db) else { + return + } + guard let allGroupMembers: [GroupMember] = try? closedGroup.allMembers.fetchAll(db) else { + return + } + guard allGroupMembers.contains(where: { $0.role == .standard && $0.profileId == publicKey }) else { return SNLog("Refusing to send latest encryption key pair to non-member.") } + // Get the latest encryption key pair - guard let encryptionKeyPair = distributingClosedGroupEncryptionKeyPairs[groupPublicKey]?.last - ?? Storage.shared.getLatestClosedGroupEncryptionKeyPair(for: groupPublicKey) else { return } + var maybeEncryptionKeyPair: Box.KeyPair? = distributingClosedGroupEncryptionKeyPairs[groupPublicKey]?.last + + if maybeEncryptionKeyPair == nil { + guard let encryptionKeyPair: ClosedGroupKeyPair = try? closedGroup.fetchLatestKeyPair(db) else { + return + } + + maybeEncryptionKeyPair = Box.KeyPair( + publicKey: Data(hex: encryptionKeyPair.publicKey).bytes, + secretKey: encryptionKeyPair.secretKey.bytes + ) + } + + guard let encryptionKeyPair: Box.KeyPair = maybeEncryptionKeyPair else { return } + // Send it - guard let proto = try? SNProtoKeyPair.builder(publicKey: Data(encryptionKeyPair.publicKey), - privateKey: Data(encryptionKeyPair.secretKey)).build(), let plaintext = try? proto.serializedData() else { return } - let contactThread = TSContactThread.getOrCreateThread(withContactSessionID: publicKey, transaction: transaction) - guard let ciphertext = try? MessageSender.encryptWithSessionProtocol(plaintext, for: publicKey) else { return } - SNLog("Sending latest encryption key pair to: \(publicKey).") - let wrapper = ClosedGroupControlMessage.KeyPairWrapper(publicKey: publicKey, encryptedKeyPair: ciphertext) - let closedGroupControlMessage = ClosedGroupControlMessage(kind: .encryptionKeyPair(publicKey: Data(hex: groupPublicKey), wrappers: [ wrapper ])) - MessageSender.send(closedGroupControlMessage, in: contactThread, using: transaction) + do { + let proto = try SNProtoKeyPair.builder( + publicKey: Data(encryptionKeyPair.publicKey), + privateKey: Data(encryptionKeyPair.secretKey) + ).build() + let plaintext = try proto.serializedData() + let thread: SessionThread = try SessionThread + .fetchOrCreate(db, id: publicKey, variant: .contact) + .saved(db) + let ciphertext = try MessageSender.encryptWithSessionProtocol(plaintext, for: publicKey) + + SNLog("Sending latest encryption key pair to: \(publicKey).") + try MessageSender.send( + db, + message: ClosedGroupControlMessage( + kind: .encryptionKeyPair( + publicKey: Data(hex: groupPublicKey), + wrappers: [ + ClosedGroupControlMessage.KeyPairWrapper( + publicKey: publicKey, + encryptedKeyPair: ciphertext + ) + ] + ) + ), + interactionId: nil, + in: thread + ) + } + catch {} } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift new file mode 100644 index 000000000..af9fed03f --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -0,0 +1,176 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import PromiseKit +import SessionUtilitiesKit + +extension MessageSender { + + // MARK: Durable + + public static func send(_ db: Database, interaction: Interaction, with attachments: [SignalAttachment], in thread: SessionThread) throws { + guard let interactionId: Int64 = interaction.id else { throw GRDBStorageError.objectNotSaved } + // TODO: Is the 'prep' method needed anymore? +// prep(db, attachments, for: message) + try send( + db, + message: VisibleMessage.from(db, interaction: interaction), + interactionId: interactionId, + in: thread + ) + } + + public static func send(_ db: Database, interaction: Interaction, in thread: SessionThread) throws { + guard let interactionId: Int64 = interaction.id else { throw GRDBStorageError.objectNotSaved } + + return try send(db, message: VisibleMessage.from(db, interaction: interaction), interactionId: interactionId, in: thread) + } + + public static func send(_ db: Database, message: Message, interactionId: Int64?, in thread: SessionThread) throws { + + JobRunner.add( + db, + job: Job( + variant: .messageSend, + details: MessageSendJob.Details( + interactionId: interactionId, + destination: try Message.Destination.from(db, thread: thread), + message: message + ) + ) + ) + } + + + public static func sendNonDurably(_ db: Database, interaction: Interaction, in thread: SessionThread) -> Promise { + guard let interactionId: Int64 = interaction.id else { + return Promise(error: GRDBStorageError.objectNotSaved) + } + + let attachments: [Attachment]? = try? Attachment + .filter(Attachment.Columns.state == Attachment.State.pending) + .joining( + required: Attachment.interactionAttachments + .filter(InteractionAttachment.Columns.interactionId == interactionId) + ) + .fetchAll(db) + + let attachmentUploadPromises: [Promise] = (attachments ?? []) + .map { attachment in + let (promise, seal) = Promise.pending() + + if let openGroup: OpenGroup = try? thread.openGroup.fetchOne(db) { + AttachmentUploadJob.upload( + db, + attachment: attachment, + using: { data in OpenGroupAPIV2.upload(data, to: openGroup.room, on: openGroup.server) }, + encrypt: false, + success: { seal.fulfill(()) }, + failure: { seal.reject($0) } + ) + } + else { + AttachmentUploadJob.upload( + db, + attachment: attachment, + using: FileServerAPIV2.upload, + encrypt: true, + success: { seal.fulfill(()) }, + failure: { seal.reject($0) } + ) + } + + return promise + } + + return when(resolved: attachmentUploadPromises) + .then(on: DispatchQueue.global(qos: .userInitiated)) { results -> Promise in + let errors = results + .compactMap { result -> Swift.Error? in + if case .rejected(let error) = result { return error } + + return nil + } + + if let error = errors.first { return Promise(error: error) } + + return sendNonDurably(db, interaction: interaction, in: thread) + } + } + + public static func sendNonDurably(_ db: Database, _ message: VisibleMessage, with attachmentIds: [String], in thread: TSThread) -> Promise { + } + + + public static func sendNonDurably(_ db: Database, message: Message, interactionId: Int64?, in thread: SessionThread) throws -> Promise { + return try MessageSender.send( + db, + message: message, + to: try Message.Destination.from(db, thread: thread), + interactionId: interactionId + ) + } + + public static func sendNonDurably(_ message: VisibleMessage, with attachments: [SignalAttachment], in thread: TSThread) -> Promise { + } + + public static func sendNonDurably(_ db: Database, message: Message, interactionId: Int64?, to destination: Message.Destination) throws -> Promise { + return try MessageSender.send( + db, + message: message, + to: destination, + interactionId: interactionId + ) + } + + /// This method requires the `db` value to be passed in because if it's called within a `writeAsync` completion block + /// it will throw a "re-entrant" fatal error when attempting to write again + public static func syncConfiguration(_ db: Database, forceSyncNow: Bool = true) throws -> Promise { + // If we don't have a userKeyPair yet then there is no need to sync the configuration + // as the user doesn't exist yet (this will get triggered on the first launch of a + // fresh install due to the migrations getting run) + guard Identity.userExists(db) else { + return Promise(error: GRDBStorageError.generic) + } + + let destination: Message.Destination = Message.Destination.contact( + publicKey: getUserHexEncodedPublicKey(db) + ) + let configurationMessage = try ConfigurationMessage.getCurrent(db) + let (promise, seal) = Promise.pending() + + if forceSyncNow { + try MessageSender + .send(db, message: configurationMessage, to: destination, interactionId: nil) + .done { seal.fulfill(()) } + .catch { _ in seal.reject(GRDBStorageError.generic) } + .retainUntilComplete() + } + else { + JobRunner.add( + db, + job: Job( + variant: .messageSend, + details: MessageSendJob.Details( + interactionId: nil, + destination: destination, + message: configurationMessage + ) + ) + ) + seal.fulfill(()) + } + + return promise + } +} + +extension MessageSender { + @objc(forceSyncConfigurationNow) + public static func objc_forceSyncConfigurationNow() { + GRDBStorage.shared.write { db in + try syncConfiguration(db, forceSyncNow: true).retainUntilComplete() + } + } +} diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift index 5c5d70821..c52e1bf9b 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift @@ -7,14 +7,24 @@ import SessionUtilitiesKit extension MessageSender { internal static func encryptWithSessionProtocol(_ plaintext: Data, for recipientHexEncodedX25519PublicKey: String) throws -> Data { - guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { throw Error.noUserED25519KeyPair } + guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { + throw MessageSenderError.noUserED25519KeyPair + } + let recipientX25519PublicKey = Data(hex: recipientHexEncodedX25519PublicKey.removing05PrefixIfNeeded()) let sodium = Sodium() let verificationData = plaintext + Data(userED25519KeyPair.publicKey) + recipientX25519PublicKey - guard let signature = sodium.sign.signature(message: Bytes(verificationData), secretKey: userED25519KeyPair.secretKey) else { throw Error.signingFailed } + + guard let signature = sodium.sign.signature(message: Bytes(verificationData), secretKey: userED25519KeyPair.secretKey) else { + throw MessageSenderError.signingFailed + } + let plaintextWithMetadata = plaintext + Data(userED25519KeyPair.publicKey) + Data(signature) - guard let ciphertext = sodium.box.seal(message: Bytes(plaintextWithMetadata), recipientPublicKey: Bytes(recipientX25519PublicKey)) else { throw Error.encryptionFailed } + + guard let ciphertext = sodium.box.seal(message: Bytes(plaintextWithMetadata), recipientPublicKey: Bytes(recipientX25519PublicKey)) else { + throw MessageSenderError.encryptionFailed + } return Data(ciphertext) } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 73e35c53e..ea279c12f 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -1,53 +1,23 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB import PromiseKit import SessionSnodeKit import SessionUtilitiesKit @objc(SNMessageSender) public final class MessageSender : NSObject { - - // MARK: Error - public enum Error : LocalizedError { - case invalidMessage - case protoConversionFailed - case noUserX25519KeyPair - case noUserED25519KeyPair - case signingFailed - case encryptionFailed - case noUsername - // Closed groups - case noThread - case noKeyPair - case invalidClosedGroupUpdate - - internal var isRetryable: Bool { - switch self { - case .invalidMessage, .protoConversionFailed, .invalidClosedGroupUpdate, .signingFailed, .encryptionFailed: return false - default: return true - } - } - - public var errorDescription: String? { - switch self { - case .invalidMessage: return "Invalid message." - case .protoConversionFailed: return "Couldn't convert message to proto." - case .noUserX25519KeyPair: return "Couldn't find user X25519 key pair." - case .noUserED25519KeyPair: return "Couldn't find user ED25519 key pair." - case .signingFailed: return "Couldn't sign message." - case .encryptionFailed: return "Couldn't encrypt message." - case .noUsername: return "Missing username." - // Closed groups - case .noThread: return "Couldn't find a thread associated with the given group public key." - case .noKeyPair: return "Couldn't find a private key associated with the given group public key." - case .invalidClosedGroupUpdate: return "Invalid group update." - } - } - } - // MARK: Initialization private override init() { } - // MARK: Preparation - public static func prep(_ signalAttachments: [SignalAttachment], for message: VisibleMessage, using transaction: YapDatabaseReadWriteTransaction) { + // MARK: - Preparation + + public static func prep( + _ db: Database, + signalAttachments: [SignalAttachment], + for message: VisibleMessage + ) { guard let tsMessage = TSOutgoingMessage.find(withTimestamp: message.sentTimestamp!) else { #if DEBUG preconditionFailure() @@ -95,188 +65,291 @@ public final class MessageSender : NSObject { tsMessage.save(with: transaction) } - // MARK: Convenience - public static func send(_ message: Message, to destination: Message.Destination, using transaction: Any) -> Promise { + // MARK: - Convenience + + public static func send(_ db: Database, message: Message, to destination: Message.Destination, interactionId: Int64?) throws -> Promise { switch destination { - case .contact(_), .closedGroup(_): return sendToSnodeDestination(destination, message: message, using: transaction) - case .openGroup(_, _), .openGroupV2(_, _): return sendToOpenGroupDestination(destination, message: message, using: transaction) + case .contact(_), .closedGroup(_): + return try sendToSnodeDestination(db, message: message, to: destination, interactionId: interactionId) + + case .openGroup(_, _), .openGroupV2(_, _): + return sendToOpenGroupDestination(db, message: message, to: destination, interactionId: interactionId) } } // MARK: One-on-One Chats & Closed Groups - internal static func sendToSnodeDestination(_ destination: Message.Destination, message: Message, using transaction: Any, isSyncMessage: Bool = false) -> Promise { + + internal static func sendToSnodeDestination( + _ db: Database, + message: Message, + to destination: Message.Destination, + interactionId: Int64?, + isSyncMessage: Bool = false + ) throws -> Promise { let (promise, seal) = Promise.pending() - let storage = SNMessagingKitConfiguration.shared.storage - let transaction = transaction as! YapDatabaseReadWriteTransaction - let userPublicKey = getUserHexEncodedPublicKey() - var isMainAppAndActive = false + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + var isMainAppActive = false if let sharedUserDefaults = UserDefaults(suiteName: "group.com.loki-project.loki-messenger") { - isMainAppAndActive = sharedUserDefaults.bool(forKey: "isMainAppActive") + isMainAppActive = sharedUserDefaults[.isMainAppActive] } + // Set the timestamp, sender and recipient if message.sentTimestamp == nil { // Visible messages will already have their sent timestamp set message.sentTimestamp = NSDate.millisecondTimestamp() } + + let isSelfSend: Bool = (message.recipient == userPublicKey) message.sender = userPublicKey - switch destination { - case .contact(let publicKey): message.recipient = publicKey - case .closedGroup(let groupPublicKey): message.recipient = groupPublicKey - case .openGroup(_, _), .openGroupV2(_, _): preconditionFailure() - } - let isSelfSend = (message.recipient == userPublicKey) + message.recipient = { + switch destination { + case .contact(let publicKey): return publicKey + case .closedGroup(let groupPublicKey): return groupPublicKey + case .openGroup(_, _), .openGroupV2(_, _): preconditionFailure() + } + }() + // Set the failure handler (need it here already for precondition failure handling) - func handleFailure(with error: Swift.Error, using transaction: YapDatabaseReadWriteTransaction) { - MessageSender.handleFailedMessageSend(message, with: error, using: transaction) + func handleFailure(_ db: Database, with error: MessageSenderError) { + MessageSender.handleFailedMessageSend(db, message: message, with: error, interactionId: interactionId) seal.reject(error) } + // Validate the message - guard message.isValid else { handleFailure(with: Error.invalidMessage, using: transaction); return promise } + guard message.isValid else { + handleFailure(db, with: .invalidMessage) + return promise + } + // Stop here if this is a self-send, unless it's: // • a configuration message // • a sync message // • a closed group control message of type `new` // • an unsend request - let isNewClosedGroupControlMessage = given(message as? ClosedGroupControlMessage) { if case .new = $0.kind { return true } else { return false } } ?? false - guard !isSelfSend || message is ConfigurationMessage || isSyncMessage || isNewClosedGroupControlMessage || message is UnsendRequest else { - storage.write(with: { transaction in - MessageSender.handleSuccessfulMessageSend(message, to: destination, using: transaction) - seal.fulfill(()) - }, completion: { }) + let isNewClosedGroupControlMessage: Bool = { + switch (message as? ClosedGroupControlMessage)?.kind { + case .new: return true + default: return false + } + }() + + guard + !isSelfSend || + message is ConfigurationMessage || + isSyncMessage || + isNewClosedGroupControlMessage || + message is UnsendRequest + else { + try MessageSender.handleSuccessfulMessageSend(db, message: message, to: destination, interactionId: interactionId) + seal.fulfill(()) return promise } + // Attach the user's profile if needed - if let message = message as? VisibleMessage { - guard let name = storage.getUser()?.name else { handleFailure(with: Error.noUsername, using: transaction); return promise } - if let profileKey = storage.getUser()?.profileEncryptionKey?.keyData, let profilePictureURL = storage.getUser()?.profilePictureURL { - message.profile = VisibleMessage.Profile(displayName: name, profileKey: profileKey, profilePictureURL: profilePictureURL) - } else { - message.profile = VisibleMessage.Profile(displayName: name) + if let message: VisibleMessage = message as? VisibleMessage { + let profile: Profile = Profile.fetchOrCreateCurrentUser(db) + + if let profileKey: Data = profile.profileEncryptionKey?.keyData, let profilePictureUrl: String = profile.profilePictureUrl { + message.profile = VisibleMessage.Profile(displayName: profile.name, profileKey: profileKey, profilePictureURL: profilePictureUrl) + } + else { + message.profile = VisibleMessage.Profile(displayName: profile.name) } } + // Convert it to protobuf - guard let proto = message.toProto(using: transaction) else { handleFailure(with: Error.protoConversionFailed, using: transaction); return promise } + guard let proto = message.toProto(db) else { + handleFailure(db, with: .protoConversionFailed) + return promise + } + // Serialize the protobuf let plaintext: Data do { plaintext = (try proto.serializedData() as NSData).paddedMessageBody() - } catch { + } + catch { SNLog("Couldn't serialize proto due to error: \(error).") - handleFailure(with: error, using: transaction) + handleFailure(db, with: .other(error)) return promise } + // Encrypt the serialized protobuf let ciphertext: Data do { switch destination { - case .contact(let publicKey): ciphertext = try encryptWithSessionProtocol(plaintext, for: publicKey) - case .closedGroup(let groupPublicKey): - guard let encryptionKeyPair = Storage.shared.getLatestClosedGroupEncryptionKeyPair(for: groupPublicKey) else { throw Error.noKeyPair } - ciphertext = try encryptWithSessionProtocol(plaintext, for: "05\(encryptionKeyPair.publicKey.toHexString())") - case .openGroup(_, _), .openGroupV2(_, _): preconditionFailure() + case .contact(let publicKey): + ciphertext = try encryptWithSessionProtocol(plaintext, for: publicKey) + + case .closedGroup(let groupPublicKey): + guard let encryptionKeyPair: ClosedGroupKeyPair = try? ClosedGroupKeyPair.fetchLatestKeyPair(db, publicKey: groupPublicKey) else { + throw MessageSenderError.noKeyPair + } + + ciphertext = try encryptWithSessionProtocol(plaintext, for: "05\(encryptionKeyPair.publicKey)") + + case .openGroup(_, _), .openGroupV2(_, _): preconditionFailure() } - } catch { + } + catch { SNLog("Couldn't encrypt message for destination: \(destination) due to error: \(error).") - handleFailure(with: error, using: transaction) + handleFailure(db, with: .other(error)) return promise } + // Wrap the result let kind: SNProtoEnvelope.SNProtoEnvelopeType let senderPublicKey: String + switch destination { - case .contact(_): - kind = .sessionMessage - senderPublicKey = "" - case .closedGroup(let groupPublicKey): - kind = .closedGroupMessage - senderPublicKey = groupPublicKey - case .openGroup(_, _), .openGroupV2(_, _): preconditionFailure() + case .contact(_): + kind = .sessionMessage + senderPublicKey = "" + + case .closedGroup(let groupPublicKey): + kind = .closedGroupMessage + senderPublicKey = groupPublicKey + + case .openGroup(_, _), .openGroupV2(_, _): preconditionFailure() } + let wrappedMessage: Data do { wrappedMessage = try MessageWrapper.wrap(type: kind, timestamp: message.sentTimestamp!, senderPublicKey: senderPublicKey, base64EncodedContent: ciphertext.base64EncodedString()) - } catch { + } + catch { SNLog("Couldn't wrap message due to error: \(error).") - handleFailure(with: error, using: transaction) + handleFailure(db, with: .other(error)) return promise } + // Send the result let base64EncodedData = wrappedMessage.base64EncodedString() let timestamp = UInt64(Int64(message.sentTimestamp!) + SnodeAPI.clockOffset) - let snodeMessage = SnodeMessage(recipient: message.recipient!, data: base64EncodedData, ttl: message.ttl, timestamp: timestamp) - SnodeAPI.sendMessage(snodeMessage).done(on: DispatchQueue.global(qos: .userInitiated)) { promises in - var isSuccess = false - let promiseCount = promises.count - var errorCount = 0 - promises.forEach { - let _ = $0.done(on: DispatchQueue.global(qos: .userInitiated)) { rawResponse in - guard !isSuccess else { return } // Succeed as soon as the first promise succeeds - isSuccess = true - storage.write(with: { transaction in - let json = rawResponse as? JSON - let hash = json?["hash"] as? String - message.serverHash = hash - MessageSender.handleSuccessfulMessageSend(message, to: destination, isSyncMessage: isSyncMessage, using: transaction) - var shouldNotify = ((message is VisibleMessage || message is UnsendRequest) && !isSyncMessage) - /* - if let closedGroupControlMessage = message as? ClosedGroupControlMessage, case .new = closedGroupControlMessage.kind { - shouldNotify = true - } - */ - if shouldNotify { - let notifyPNServerJob = NotifyPNServerJob(message: snodeMessage) - if isMainAppAndActive { - JobQueue.shared.add(notifyPNServerJob, using: transaction) - seal.fulfill(()) - } else { - notifyPNServerJob.execute().done(on: DispatchQueue.global(qos: .userInitiated)) { - seal.fulfill(()) - }.catch(on: DispatchQueue.global(qos: .userInitiated)) { _ in - seal.fulfill(()) // Always fulfill because the notify PN server job isn't critical. - } + let snodeMessage = SnodeMessage( + recipient: message.recipient!, + data: base64EncodedData, + ttl: message.ttl, + timestampMs: timestamp + ) + + SnodeAPI.sendMessage(snodeMessage) + .done(on: DispatchQueue.global(qos: .userInitiated)) { promises in + let promiseCount = promises.count + var isSuccess = false + var errorCount = 0 + + promises.forEach { + let _ = $0.done(on: DispatchQueue.global(qos: .userInitiated)) { rawResponse in + guard !isSuccess else { return } // Succeed as soon as the first promise succeeds + isSuccess = true + + GRDBStorage.shared.write { db in + let json = rawResponse as? JSON + let hash = json?["hash"] as? String + message.serverHash = hash + + try MessageSender.handleSuccessfulMessageSend( + db, + message: message, + to: destination, + interactionId: interactionId, + isSyncMessage: isSyncMessage + ) + + let shouldNotify = ( + (message is VisibleMessage || message is UnsendRequest) && + !isSyncMessage + ) + + /* + if let closedGroupControlMessage = message as? ClosedGroupControlMessage, case .new = closedGroupControlMessage.kind { + shouldNotify = true + } + */ + guard shouldNotify else { + seal.fulfill(()) + return + } + + let job: Job? = Job( + variant: .notifyPushServer, + behaviour: .runOnce, + details: NotifyPushServerJob.Details(message: snodeMessage) + ) + + if isMainAppActive { + JobRunner.add(db, job: job) + seal.fulfill(()) + } + else if let job: Job = job { + NotifyPushServerJob.run( + job, + success: { _, _ in seal.fulfill(()) }, + failure: { _, _, _ in + // Always fulfill because the notify PN server job isn't critical. + seal.fulfill(()) + }, + deferred: { _ in + // Always fulfill because the notify PN server job isn't critical. + seal.fulfill(()) + } + ) + } + else { + // Always fulfill because the notify PN server job isn't critical. + seal.fulfill(()) } - } else { - seal.fulfill(()) } - }, completion: { }) - } - $0.catch(on: DispatchQueue.global(qos: .userInitiated)) { error in - errorCount += 1 - guard errorCount == promiseCount else { return } // Only error out if all promises failed - storage.write(with: { transaction in - handleFailure(with: error, using: transaction as! YapDatabaseReadWriteTransaction) - }, completion: { }) + } + $0.catch(on: DispatchQueue.global(qos: .userInitiated)) { error in + errorCount += 1 + guard errorCount == promiseCount else { return } // Only error out if all promises failed + + GRDBStorage.shared.write { db in + handleFailure(db, with: .other(error)) + } + } } } - }.catch(on: DispatchQueue.global(qos: .userInitiated)) { error in - SNLog("Couldn't send message due to error: \(error).") - storage.write(with: { transaction in - handleFailure(with: error, using: transaction as! YapDatabaseReadWriteTransaction) - }, completion: { }) - } - // Return + .catch(on: DispatchQueue.global(qos: .userInitiated)) { error in + SNLog("Couldn't send message due to error: \(error).") + + GRDBStorage.shared.write { db in + handleFailure(db, with: .other(error)) + } + } + return promise } // MARK: Open Groups - internal static func sendToOpenGroupDestination(_ destination: Message.Destination, message: Message, using transaction: Any) -> Promise { + + internal static func sendToOpenGroupDestination( + _ db: Database, + message: Message, + to destination: Message.Destination, + interactionId: Int64? + ) -> Promise { let (promise, seal) = Promise.pending() - let storage = SNMessagingKitConfiguration.shared.storage - let transaction = transaction as! YapDatabaseReadWriteTransaction + // Set the timestamp, sender and recipient if message.sentTimestamp == nil { // Visible messages will already have their sent timestamp set message.sentTimestamp = NSDate.millisecondTimestamp() } message.sender = getUserHexEncodedPublicKey() + switch destination { - case .contact(_): preconditionFailure() - case .closedGroup(_): preconditionFailure() - case .openGroup(let channel, let server): message.recipient = "\(server).\(channel)" - case .openGroupV2(let room, let server): message.recipient = "\(server).\(room)" + case .contact(_): preconditionFailure() + case .closedGroup(_): preconditionFailure() + case .openGroup(let channel, let server): message.recipient = "\(server).\(channel)" + case .openGroupV2(let room, let server): message.recipient = "\(server).\(room)" } + // Set the failure handler (need it here already for precondition failure handling) - func handleFailure(with error: Swift.Error, using transaction: YapDatabaseReadWriteTransaction) { - MessageSender.handleFailedMessageSend(message, with: error, using: transaction) + func handleFailure(_ db: Database, with error: MessageSenderError) { + MessageSender.handleFailedMessageSend(db, message: message, with: error, interactionId: interactionId) seal.reject(error) } // Validate the message @@ -288,92 +361,137 @@ public final class MessageSender : NSObject { return promise #endif } - guard message.isValid else { handleFailure(with: Error.invalidMessage, using: transaction); return promise } - // Attach the user's profile - guard let name = storage.getUser()?.name else { handleFailure(with: Error.noUsername, using: transaction); return promise } - if let profileKey = storage.getUser()?.profileEncryptionKey?.keyData, let profilePictureURL = storage.getUser()?.profilePictureURL { - message.profile = VisibleMessage.Profile(displayName: name, profileKey: profileKey, profilePictureURL: profilePictureURL) - } else { - message.profile = VisibleMessage.Profile(displayName: name) + guard message.isValid else { + handleFailure(db, with: .invalidMessage) + return promise } // Convert it to protobuf - guard let proto = message.toProto(using: transaction) else { handleFailure(with: Error.protoConversionFailed, using: transaction); return promise } + guard let proto = message.toProto(db) else { + handleFailure(db, with: .protoConversionFailed) + return promise + } + // Serialize the protobuf let plaintext: Data do { plaintext = (try proto.serializedData() as NSData).paddedMessageBody() - } catch { + } + catch { SNLog("Couldn't serialize proto due to error: \(error).") - handleFailure(with: error, using: transaction) + handleFailure(db, with: .other(error)) return promise } + // Send the result guard case .openGroupV2(let room, let server) = destination else { preconditionFailure() } - let openGroupMessage = OpenGroupMessageV2(serverID: nil, sender: nil, sentTimestamp: message.sentTimestamp!, - base64EncodedData: plaintext.base64EncodedString(), base64EncodedSignature: nil) - OpenGroupAPIV2.send(openGroupMessage, to: room, on: server).done(on: DispatchQueue.global(qos: .userInitiated)) { openGroupMessage in - message.openGroupServerMessageID = given(openGroupMessage.serverID) { UInt64($0) } + + let openGroupMessage = OpenGroupMessageV2( + serverID: nil, + sender: nil, + sentTimestamp: message.sentTimestamp!, + base64EncodedData: plaintext.base64EncodedString(), + base64EncodedSignature: nil + ) + + OpenGroupAPIV2 + .send( + openGroupMessage, + to: room, + on: server + ) + .done(on: DispatchQueue.global(qos: .userInitiated)) { openGroupMessage in + message.openGroupServerMessageID = given(openGroupMessage.serverID) { UInt64($0) } - storage.write(with: { transaction in - MessageSender.handleSuccessfulMessageSend(message, to: destination, serverTimestamp: openGroupMessage.sentTimestamp, using: transaction) - seal.fulfill(()) - }, completion: { }) - }.catch(on: DispatchQueue.global(qos: .userInitiated)) { error in - storage.write(with: { transaction in - handleFailure(with: error, using: transaction as! YapDatabaseReadWriteTransaction) - }, completion: { }) - } - // Return + GRDBStorage.shared.write { db in + try MessageSender.handleSuccessfulMessageSend( + db, + message: message, + to: destination, + interactionId: interactionId, + serverTimestampMs: openGroupMessage.sentTimestamp + ) + seal.fulfill(()) + } + } + .catch(on: DispatchQueue.global(qos: .userInitiated)) { error in + GRDBStorage.shared.write { db in + handleFailure(db, with: .other(error)) + } + } + return promise } // MARK: Success & Failure Handling - public static func handleSuccessfulMessageSend(_ message: Message, to destination: Message.Destination, serverTimestamp: UInt64? = nil, isSyncMessage: Bool = false, using transaction: Any) { - let transaction = transaction as! YapDatabaseReadWriteTransaction + + private static func handleSuccessfulMessageSend( + _ db: Database, + message: Message, + to destination: Message.Destination, + interactionId: Int64?, + serverTimestampMs: UInt64? = nil, + isSyncMessage: Bool = false + ) throws { + let interaction: Interaction? = try interaction(db, for: message, interactionId: interactionId) + // Get the visible message if possible - if let tsMessage = TSOutgoingMessage.find(withTimestamp: message.sentTimestamp!) { + if let interaction: Interaction = interaction { // When the sync message is successfully sent, the hash value of this TSOutgoingMessage // will be replaced by the hash value of the sync message. Since the hash value of the // real message has no use when we delete a message. It is OK to let it be. - tsMessage.serverHash = message.serverHash - // Track the open group server message ID and update server timestamp - if let openGroupServerMessageID = message.openGroupServerMessageID, let timestamp = serverTimestamp { - // Use server timestamp for open group messages - // Otherwise the quote messages may not be able - // to be found by the timestamp on other devices - tsMessage.updateOpenGroupServerID(openGroupServerMessageID, serverTimeStamp: timestamp) + try interaction.with( + serverHash: message.serverHash, - // Create a lookup between the openGroupServerMessageId and the tsMessage id for easy lookup - switch destination { - case .openGroupV2(let room, let server): - Storage.shared.addOpenGroupServerIdLookup( - openGroupServerMessageID, - tsMessageId: tsMessage.uniqueId, - in: room, - on: server, - using: transaction - ) - - default: break - } - } + // Track the open group server message ID and update server timestamp (use server + // timestamp for open group messages otherwise the quote messages may not be able + // to be found by the timestamp on other devices + timestampMs: (message.openGroupServerMessageID == nil ? + nil : + serverTimestampMs.map { Int64($0) } + ), + openGroupServerMessageId: message.openGroupServerMessageID.map { Int64($0) } + ).update(db) + // Mark the message as sent - var recipients = [ message.recipient! ] - if case .closedGroup(_) = destination, let threadID = message.threadID, // threadID should always be set at this point - let thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction), thread.isClosedGroup { - recipients = thread.groupModel.groupMemberIds - } - recipients.forEach { recipient in - tsMessage.update(withSentRecipient: recipient, wasSentByUD: true, transaction: transaction) - } - tsMessage.save(with: transaction) + try interaction.recipientStates + .fetchAll(db) + .map { $0.with(state: .sent) } + .saveAll(db) + NotificationCenter.default.post(name: .messageSentStatusDidChange, object: nil, userInfo: nil) + // Start the disappearing messages timer if needed - OWSDisappearingMessagesJob.shared().startAnyExpiration(for: tsMessage, expirationStartedAt: NSDate.millisecondTimestamp(), transaction: transaction) + DisappearingMessagesJob.updateNextRunIfNeeded( + db, + interaction: interaction, + startedAtMs: (Date().timeIntervalSince1970 * 1000) + ) } + // Prevent the same ExpirationTimerUpdate to be handled twice - if let message = message as? ExpirationTimerUpdate { - Storage.shared.addReceivedMessageTimestamp(message.sentTimestamp!, using: transaction) + if message is ControlMessage { + try? ControlMessageProcessRecord( + threadId: { + switch destination { + case .contact(let publicKey): return publicKey + case .closedGroup(let groupPublicKey): return groupPublicKey + case .openGroupV2(let room, let server): + return OpenGroup.idFor(room: room, server: server) + + // FIXME: Remove support for V1 SOGS + case .openGroup: return getUserHexEncodedPublicKey(db) + } + }(), + sentTimestampMs: { + if message.openGroupServerMessageID != nil { + return (serverTimestampMs.map { Int64($0) } ?? 0) + } + + return (message.sentTimestamp.map { Int64($0) } ?? 0) + }(), + serverHash: (message.serverHash ?? ""), + openGroupMessageServerId: (message.openGroupServerMessageID.map { Int64($0) } ?? 0) + ).insert(db) } // Sync the message if: // • it's a visible message or an expiration timer update @@ -383,17 +501,66 @@ public final class MessageSender : NSObject { if case .contact(let publicKey) = destination, !isSyncMessage { if let message = message as? VisibleMessage { message.syncTarget = publicKey } if let message = message as? ExpirationTimerUpdate { message.syncTarget = publicKey } + // FIXME: Make this a job - sendToSnodeDestination(.contact(publicKey: userPublicKey), message: message, using: transaction, isSyncMessage: true).retainUntilComplete() + try sendToSnodeDestination( + db, + message: message, + to: .contact(publicKey: userPublicKey), + interactionId: interactionId, + isSyncMessage: true + ).retainUntilComplete() } } - public static func handleFailedMessageSend(_ message: Message, with error: Swift.Error, using transaction: Any) { - guard let tsMessage = TSOutgoingMessage.find(withTimestamp: message.sentTimestamp!) else { return } + public static func handleFailedMessageSend( + _ db: Database, + message: Message, + with error: MessageSenderError, + interactionId: Int64? + ) { + guard let interaction: Interaction = try? interaction(db, for: message, interactionId: interactionId) else { + return + } + + // Mark any "sending" recipients as "failed" + try? interaction.recipientStates + .fetchAll(db) + .forEach { oldState in + guard oldState.state == .sending else { return } + + try? oldState.with( + state: .failed, + mostRecentFailureText: error.localizedDescription + ).save(db) + } + // Remove the message timestamps if it fails - Storage.shared.removeReceivedMessageTimestamps([message.sentTimestamp!], using: transaction) - let transaction = transaction as! YapDatabaseReadWriteTransaction - tsMessage.update(sendingError: error, transaction: transaction) - MessageInvalidator.invalidate(tsMessage, with: transaction) + } + + // MARK: - Convenience + + private static func interaction(_ db: Database, for message: Message, interactionId: Int64?) throws -> Interaction? { + if let interactionId: Int64 = interactionId { + return try Interaction.fetchOne(db, id: interactionId) + } + else if let sentTimestamp: Double = message.sentTimestamp.map({ Double($0) }) { + // If we have a threadId then include that in the filter to make the request smaller + if + let threadId: String = message.threadID, + !threadId.isEmpty, + let thread: SessionThread = try? SessionThread.fetchOne(db, id: threadId) + { + return try thread.interactions + .filter(Interaction.Columns.timestampMs == sentTimestamp) + .fetchOne(db) + } + + return try Interaction + .filter(Interaction.Columns.timestampMs == sentTimestamp) + .fetchOne(db) + } + + return nil } } diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.h b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.h deleted file mode 100644 index 4408ea47d..000000000 --- a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.h +++ /dev/null @@ -1,28 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@class TSErrorMessage; -@class TSIncomingMessage; -@class TSThread; -@class YapDatabaseReadTransaction; -@class YapDatabaseReadWriteTransaction; - -@protocol ContactsManagerProtocol; - -@protocol NotificationsProtocol - -- (void)notifyUserForIncomingMessage:(TSIncomingMessage *)incomingMessage - inThread:(TSThread *)thread - transaction:(YapDatabaseReadTransaction *)transaction; - -- (void)cancelNotification:(NSString *)identifier; -- (void)clearAllNotifications; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.swift b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.swift new file mode 100644 index 000000000..b07a0ed9c --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.swift @@ -0,0 +1,10 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +public protocol NotificationsProtocol { + func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, isBackgroundPoll: Bool) + func cancelNotifications(identifiers: [String]) + func clearAllNotifications() +} diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift index 1ce54f4d8..bcaba688b 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift @@ -100,41 +100,55 @@ public final class ClosedGroupPoller : NSObject { private func poll(_ groupPublicKey: String) -> Promise { guard isPolling(for: groupPublicKey) else { return Promise.value(()) } - let promise = SnodeAPI.getSwarm(for: groupPublicKey).then2 { [weak self] swarm -> Promise<(Snode, [SnodeReceivedMessage])> in - // randomElement() uses the system's default random generator, which is cryptographically secure - guard let snode = swarm.randomElement() else { return Promise(error: Error.insufficientSnodes) } - guard let self = self, self.isPolling(for: groupPublicKey) else { - return Promise(error: Error.pollingCanceled) - } - - return SnodeAPI.getRawMessages(from: snode, associatedWith: groupPublicKey).map2 { - let messages: [SnodeReceivedMessage] = SnodeAPI.parseRawMessagesResponse($0, from: snode, associatedWith: groupPublicKey) + + let promise = SnodeAPI.getSwarm(for: groupPublicKey) + .then2 { [weak self] swarm -> Promise<(Snode, [SnodeReceivedMessage])> in + // randomElement() uses the system's default random generator, which is cryptographically secure + guard let snode = swarm.randomElement() else { return Promise(error: Error.insufficientSnodes) } + guard let self = self, self.isPolling(for: groupPublicKey) else { + return Promise(error: Error.pollingCanceled) + } - return (snode, messages) + return SnodeAPI.getRawMessages(from: snode, associatedWith: groupPublicKey) + .map2 { + let messages: [SnodeReceivedMessage] = SnodeAPI.parseRawMessagesResponse($0, from: snode, associatedWith: groupPublicKey) + + return (snode, messages) + } } - } + promise.done2 { [weak self] snode, messages in guard let self = self, self.isPolling(for: groupPublicKey) else { return } + if !messages.isEmpty { SNLog("Received \(messages.count) new message(s) in closed group with public key: \(groupPublicKey).") - } - messages.forEach { message in - guard let envelope = SNProtoEnvelope.from(message) else { return } - do { - let data = try envelope.serializedData() - let job = MessageReceiveJob(data: data, serverHash: message.info.hash, isBackgroundPoll: false) - SNMessagingKitConfiguration.shared.storage.write { transaction in - SessionMessagingKit.JobQueue.shared.add(job, using: transaction) - } - } catch { - SNLog("Failed to deserialize envelope due to error: \(error).") - } - } - - // Now that the MessageReceiveJob's have been created we can persist the received messages - if !messages.isEmpty { + GRDBStorage.shared.write { db in - messages.forEach { try? $0.info.save(db) } + messages.forEach { message in + guard let envelope = SNProtoEnvelope.from(message) else { return } + + do { + JobRunner.add( + db, + job: Job( + variant: .messageReceive, + behaviour: .runOnce, + threadId: groupPublicKey, + details: MessageReceiveJob.Details( + data: try envelope.serializedData(), + serverHash: message.info.hash, + isBackgroundPoll: false + ) + ) + ) + + // Persist the received message after the MessageReceiveJob is created + try message.info.save(db) + } + catch { + SNLog("Failed to deserialize envelope due to error: \(error).") + } + } } } } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift index db0dde51d..61786bc41 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift @@ -1,5 +1,9 @@ -import SessionSnodeKit +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation import PromiseKit +import Sodium +import SessionSnodeKit @objc(LKPoller) public final class Poller : NSObject { @@ -92,42 +96,64 @@ public final class Poller : NSObject { private func poll(_ snode: Snode, seal longTermSeal: Resolver) -> Promise { guard isPolling else { return Promise { $0.fulfill(()) } } let userPublicKey = getUserHexEncodedPublicKey() - return SnodeAPI.getRawMessages(from: snode, associatedWith: userPublicKey).then(on: Threading.pollerQueue) { [weak self] rawResponse -> Promise in - guard let strongSelf = self, strongSelf.isPolling else { return Promise { $0.fulfill(()) } } - let messages: [SnodeReceivedMessage] = SnodeAPI.parseRawMessagesResponse(rawResponse, from: snode, associatedWith: userPublicKey) - if !messages.isEmpty { - SNLog("Received \(messages.count) new message(s).") - } - messages.forEach { message in - guard let envelope = SNProtoEnvelope.from(message) else { return } - do { - let data = try envelope.serializedData() - let job = MessageReceiveJob(data: data, serverHash: message.info.hash, isBackgroundPoll: false) - SNMessagingKitConfiguration.shared.storage.write { transaction in - SessionMessagingKit.JobQueue.shared.add(job, using: transaction) - } - } catch { - SNLog("Failed to deserialize envelope due to error: \(error).") - } - } - - // Now that the MessageReceiveJob's have been created we can persist the received messages - if !messages.isEmpty { - GRDBStorage.shared.write { db in - messages.forEach { try? $0.info.save(db) } - } - } - - strongSelf.pollCount += 1 - - guard strongSelf.pollCount < Poller.maxPollCount else { - throw Error.pollLimitReached - } - - return withDelay(Poller.pollInterval, completionQueue: Threading.pollerQueue) { + return SnodeAPI.getRawMessages(from: snode, associatedWith: userPublicKey) + .then(on: Threading.pollerQueue) { [weak self] rawResponse -> Promise in guard let strongSelf = self, strongSelf.isPolling else { return Promise { $0.fulfill(()) } } - return strongSelf.poll(snode, seal: longTermSeal) + + let messages: [SnodeReceivedMessage] = SnodeAPI.parseRawMessagesResponse(rawResponse, from: snode, associatedWith: userPublicKey) + + if !messages.isEmpty { + SNLog("Received \(messages.count) new message(s).") + + GRDBStorage.shared.write { db in + messages.forEach { message in + guard let envelope = SNProtoEnvelope.from(message) else { return } + + // Extract the sender public key (used as the threadId in contact threads) and add + // that to the messageReceive job for multi-threading and garbage collection purposes + // + // Note: This is a slightly optimised version of the message decryption which + // just skips the validation (handled when the job actually runs) and doesn't throw + let threadId: String? = MessageReceiver.extractSenderPublicKey(db, from: envelope) + + if threadId == nil { + } + + do { + JobRunner.add( + db, + job: Job( + variant: .messageReceive, + behaviour: .runOnce, + threadId: threadId, + details: MessageReceiveJob.Details( + data: try envelope.serializedData(), + serverHash: message.info.hash, + isBackgroundPoll: false + ) + ) + ) + + // Persist the received message after the MessageReceiveJob is created + try message.info.save(db) + } + catch { + SNLog("Failed to deserialize envelope due to error: \(error).") + } + } + } + } + + strongSelf.pollCount += 1 + + guard strongSelf.pollCount < Poller.maxPollCount else { + throw Error.pollLimitReached + } + + return withDelay(Poller.pollInterval, completionQueue: Threading.pollerQueue) { + guard let strongSelf = self, strongSelf.isPolling else { return Promise { $0.fulfill(()) } } + return strongSelf.poll(snode, seal: longTermSeal) + } } - } } } diff --git a/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift b/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift new file mode 100644 index 000000000..739b2e554 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift @@ -0,0 +1,114 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +public struct QuotedReplyModel { + public let threadId: String + public let authorId: String + public let timestampMs: Int64 + public let body: String? + public let attachment: Attachment? + public let thumbnailImage: UIImage? + public let contentType: String? + public let sourceFileName: String? + public let thumbnailDownloadFailed: Bool + + // MARK: - Initialization + + init( + threadId: String, + authorId: String, + timestampMs: Int64, + body: String?, + attachment: Attachment?, + thumbnailImage: UIImage?, + contentType: String?, + sourceFileName: String?, + thumbnailDownloadFailed: Bool + ) { + self.attachment = attachment + self.threadId = threadId + self.authorId = authorId + self.timestampMs = timestampMs + self.body = body + self.thumbnailImage = thumbnailImage + self.contentType = contentType + self.sourceFileName = sourceFileName + self.thumbnailDownloadFailed = thumbnailDownloadFailed + } + + public static func quotedReplyForSending( + _ db: Database, + interaction: Interaction, + linkPreview: LinkPreview? + ) -> QuotedReplyModel? { + guard interaction.variant == .standardOutgoing || interaction.variant == .standardOutgoing else { + return nil + } + + var quotedText: String? = interaction.body + var quotedAttachment: Attachment? = try? interaction.attachments.fetchOne(db) + + // If the attachment is "oversize text", try the quote as a reply to text, not as + // a reply to an attachment + if + quotedText?.isEmpty == true, + let attachment: Attachment = quotedAttachment, + attachment.contentType == OWSMimeTypeOversizeTextMessage, + ( + (interaction.variant == .standardIncoming && attachment.state == .downloaded) || + attachment.state != .failed + ), + let originalFilePath: String = attachment.originalFilePath + { + quotedText = "" + + if + let textData: Data = try? Data(contentsOf: URL(fileURLWithPath: originalFilePath)), + let oversizeText: String = String(data: textData, encoding: .utf8) + { + // The attachment is going to be sent as text instead + quotedAttachment = nil + + // We don't need to include the entire text body of the message, just + // enough to render a snippet. kOversizeTextMessageSizeThreshold is our + // limit on how long text should be in protos since they'll be stored in + // the database. We apply this constant here for the same reasons. + // + // First, truncate to the rough max characters + var truncatedText: String = oversizeText.substring(to: Int(Interaction.oversizeTextMessageSizeThreshold - 1)) + + // But kOversizeTextMessageSizeThreshold is in _bytes_, not characters, + // so we need to continue to trim the string until it fits. + while truncatedText.lengthOfBytes(using: .utf8) >= Interaction.oversizeTextMessageSizeThreshold { + // A very coarse binary search by halving is acceptable, since + // kOversizeTextMessageSizeThreshold is much longer than our target + // length of "three short lines of text on any device we might + // display this on. + // + // The search will always converge since in the worst case (namely + // a single character which in utf-8 is >= 1024 bytes) the loop will + // exit when the string is empty. + truncatedText = truncatedText.substring(to: truncatedText.count / 2) + } + + if truncatedText.lengthOfBytes(using: .utf8) < Interaction.oversizeTextMessageSizeThreshold { + quotedText = truncatedText + } + } + } + + return QuotedReplyModel( + threadId: interaction.threadId, + authorId: interaction.authorId, + timestampMs: interaction.timestampMs, + body: (quotedText == nil && quotedAttachment == nil ? "" : quotedText), + attachment: quotedAttachment, + thumbnailImage: quotedAttachment?.thumbnailImageSmallSync(), + contentType: quotedAttachment?.contentType, + sourceFileName: quotedAttachment?.sourceFilename, + thumbnailDownloadFailed: false + ) + } +} diff --git a/SessionMessagingKit/Utilities/DeviceSleepManager.swift b/SessionMessagingKit/Utilities/DeviceSleepManager.swift index 05ee60834..ff0d470b8 100644 --- a/SessionMessagingKit/Utilities/DeviceSleepManager.swift +++ b/SessionMessagingKit/Utilities/DeviceSleepManager.swift @@ -1,25 +1,21 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import SessionUtilitiesKit - -// This entity has responsibility for blocking the device from sleeping if -// certain behaviors (e.g. recording or playing voice messages) are in progress. -// -// Sleep blocking is keyed using "block objects" whose lifetime corresponds to -// the duration of the block. For example, sleep blocking during audio playback -// can be keyed to the audio player. This provides a measure of robustness. -// On the one hand, we can use weak references to track block objects and stop -// blocking if the block object is deallocated even if removeBlock() is not -// called. On the other hand, we will also get correct behavior to addBlock() -// being called twice with the same block object. +/// This entity has responsibility for blocking the device from sleeping if certain behaviors (e.g. recording or +/// playing voice messages) are in progress. +/// +/// Sleep blocking is keyed using "block objects" whose lifetime corresponds to the duration of the block. For +/// example, sleep blocking during audio playback can be keyed to the audio player. This provides a measure +/// of robustness. +/// +/// On the one hand, we can use weak references to track block objects and stop blocking if the block object is +/// deallocated even if removeBlock() is not called. On the other hand, we will also get correct behavior to addBlock() +/// being called twice with the same block object. @objc public class DeviceSleepManager: NSObject { - - @objc - public static let sharedInstance = DeviceSleepManager() + @objc public static let sharedInstance = DeviceSleepManager() private class SleepBlock: CustomDebugStringConvertible { weak var blockObject: NSObject? @@ -37,10 +33,12 @@ public class DeviceSleepManager: NSObject { private override init() { super.init() - NotificationCenter.default.addObserver(self, - selector: #selector(didEnterBackground), - name: NSNotification.Name.OWSApplicationDidEnterBackground, - object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(didEnterBackground), + name: NSNotification.Name.OWSApplicationDidEnterBackground, + object: nil + ) } deinit { diff --git a/SessionMessagingKit/Utilities/Preferences.swift b/SessionMessagingKit/Utilities/Preferences.swift new file mode 100644 index 000000000..5309e0142 --- /dev/null +++ b/SessionMessagingKit/Utilities/Preferences.swift @@ -0,0 +1,122 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public extension Setting.EnumKey { + /// Controls how notifications should appear for the user (See `NotificationPreviewType` for the options) + static let preferencesNotificationPreviewType: Setting.EnumKey = "preferencesNotificationPreviewType" + + /// Controls what the default sound for notifications is (See `Sound` for the options) + static let defaultNotificationSound: Setting.EnumKey = "defaultNotificationSound" +} + +public extension Setting.BoolKey { + /// Controls whether the preview screen in the app switcher should be enabled + /// + /// **Note:** In the legacy setting this flag controlled whether the preview was "disabled" (and defaulted to + /// true), by inverting this flag we can default it to false as is standard for Bool values + static let preferencesAppSwitcherPreviewEnabled: Setting.BoolKey = "preferencesAppSwitcherPreviewEnabled" + + /// Controls whether typing indicators are enabled + /// + /// **Note:** Only works if both participants in a "contact" thread have this setting enabled + static let areReadReceiptsEnabled: Setting.BoolKey = "areReadReceiptsEnabled" + + /// Controls whether typing indicators are enabled + /// + /// **Note:** Only works if both participants in a "contact" thread have this setting enabled + static let typingIndicatorsEnabled: Setting.BoolKey = "typingIndicatorsEnabled" + + /// Controls whether the message requests item has been hidden on the home screen + static let hasHiddenMessageRequests: Setting.BoolKey = "hasHiddenMessageRequests" +} + +public extension Setting.StringKey { + /// This is the most recently recorded Push Notifications token + static let lastRecordedPushToken: Setting.StringKey = "lastRecordedPushToken" + + /// This is the most recently recorded Voip token + static let lastRecordedVoipToken: Setting.StringKey = "lastRecordedVoipToken" +} + +public enum Preferences { + public enum NotificationPreviewType: Int, EnumSetting { + /// Notifications should include both the sender name and a preview of the message content + case nameAndPreview + + /// Notifications should include the sender name but no preview + case nameNoPreview + + /// Notifications should be a generic message + case noNameNoPreview + + var name: String { + switch self { + case .nameAndPreview: return "NOTIFICATIONS_SENDER_AND_MESSAGE".localized() + case .nameNoPreview: return "NOTIFICATIONS_SENDER_ONLY".localized() + case .noNameNoPreview: return "NOTIFICATIONS_NONE".localized() + } + } + + var accessibilityIdentifier: String { + return "NotificationSettingsOptionsViewController.\(name)" + } + } + + public enum Sound: Int, Codable, DatabaseValueConvertible, EnumSetting { + static var defaultiOSIncomingRingtone: Sound = .opening + static var defaultNotificationSound: Sound = .note + + case `default` + + // Notification Sounds + case aurora = 1000 + case bamboo + case chord + case circles + case complete + case hello + case input + case keys + case note + case popcorn + case pulse + case synth + case signalClassic + + // Ringtone Sounds + case opening = 2000 + + // Calls + case callConnecting = 3000 + case callOutboundRinging + case callBusy + case callFailure + + // Other + case messageSent = 4000 + case none + + static var notificationSounds: [Sound] { + return [ + // None and Note (default) should be first. + .none, + .note, + + .aurora, + .bamboo, + .chord, + .circles, + .complete, + .hello, + .input, + .keys, + .popcorn, + .pulse, + .synth + ] + } + } +} diff --git a/SessionMessagingKit/Utilities/ProfileManager.swift b/SessionMessagingKit/Utilities/ProfileManager.swift index 3a63eb760..3ed78927b 100644 --- a/SessionMessagingKit/Utilities/ProfileManager.swift +++ b/SessionMessagingKit/Utilities/ProfileManager.swift @@ -39,10 +39,11 @@ public struct ProfileManager { return ((profileName.data(using: .utf8)?.count ?? 0) > nameDataLength) } - public static func profileAvatar(for id: String) -> UIImage? { - guard let profile: Profile = GRDBStorage.shared.read({ db in try Profile.fetchOne(db, id: id) }) else { - return nil + public static func profileAvatar(_ db: Database? = nil, id: String) -> UIImage? { + guard let db: Database = db else { + return GRDBStorage.shared.read { db in profileAvatar(db, id: id) } } + guard let profile: Profile = try? Profile.fetchOne(db, id: id) else { return nil } if let profileFileName: String = profile.profilePictureFileName, !profileFileName.isEmpty { return loadProfileAvatar(for: profileFileName) @@ -342,7 +343,7 @@ public struct ProfileManager { @objc(SMKProfileManager) public class SMKProfileManager: NSObject { @objc public static func profileAvatar(recipientId: String) -> UIImage? { - return ProfileManager.profileAvatar(for: recipientId) + return ProfileManager.profileAvatar(id: recipientId) } @objc public static func updateLocal(profileName: String, avatarImage: UIImage?, requiresSync: Bool) { diff --git a/SessionMessagingKit/Utilities/SSKEnvironment.swift b/SessionMessagingKit/Utilities/SSKEnvironment.swift new file mode 100644 index 000000000..f599d7347 --- /dev/null +++ b/SessionMessagingKit/Utilities/SSKEnvironment.swift @@ -0,0 +1,57 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +@objc +public class SSKEnvironment: NSObject { + @objc public let primaryStorage: OWSPrimaryStorage + public let tsAccountManager: TSAccountManager + public let reachabilityManager: SSKReachabilityManager + @objc public let typingIndicators: TypingIndicators + + // Note: This property is configured after Environment is created. + public let notificationsManager: Atomic = Atomic(nil) + + @objc public static var shared: SSKEnvironment! + + public var isComplete: Bool { + (notificationsManager.wrappedValue != nil) + } + + public var objectReadWriteConnection: YapDatabaseConnection + public var sessionStoreDBConnection: YapDatabaseConnection + public var migrationDBConnection: YapDatabaseConnection + public var analyticsDBConnection: YapDatabaseConnection + + // MARK: - Initialization + + @objc public init( + primaryStorage: OWSPrimaryStorage, + tsAccountManager: TSAccountManager, + reachabilityManager: SSKReachabilityManager, + typingIndicators: TypingIndicators + ) { + self.primaryStorage = primaryStorage + self.tsAccountManager = tsAccountManager + self.reachabilityManager = reachabilityManager + self.typingIndicators = typingIndicators + + self.objectReadWriteConnection = primaryStorage.newDatabaseConnection() + self.sessionStoreDBConnection = primaryStorage.newDatabaseConnection() + self.migrationDBConnection = primaryStorage.newDatabaseConnection() + self.analyticsDBConnection = primaryStorage.newDatabaseConnection() + + super.init() + + if SSKEnvironment.shared == nil { + SSKEnvironment.shared = self + } + } + + // MARK: - Functions + + public static func clearSharedForTests() { + shared = nil + } +} diff --git a/SessionNotificationServiceExtension/NSENotificationPresenter.swift b/SessionNotificationServiceExtension/NSENotificationPresenter.swift index 37e6d612c..dbbe189e5 100644 --- a/SessionNotificationServiceExtension/NSENotificationPresenter.swift +++ b/SessionNotificationServiceExtension/NSENotificationPresenter.swift @@ -1,36 +1,41 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB import UserNotifications import SignalUtilitiesKit import SessionMessagingKit public class NSENotificationPresenter: NSObject, NotificationsProtocol { - public func notifyUser(for incomingMessage: TSIncomingMessage, in thread: TSThread, transaction: YapDatabaseReadTransaction) { - guard !thread.isMuted else { return } - guard let threadID = thread.uniqueId else { return } + public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, isBackgroundPoll: Bool) { + guard thread.notificationMode != .none else { return } + + let isMessageRequest = thread.isMessageRequest(db) // If the thread is a message request and the user hasn't hidden message requests then we need // to check if this is the only message request thread (group threads can't be message requests // so just ignore those and if the user has hidden message requests then we want to show the // notification regardless of how many message requests there are) - if !thread.isGroupThread() && thread.isMessageRequest(using: transaction) && !CurrentAppContext().appUserDefaults()[.hasHiddenMessageRequests] { - let threads = transaction.ext(TSThreadDatabaseViewExtensionName) as! YapDatabaseViewTransaction - let numMessageRequests = threads.numberOfItems(inGroup: TSMessageRequestGroup) - - // Allow this to show a notification if there are no message requests (ie. this is the first one) - guard numMessageRequests == 0 else { return } - } - else if thread.isMessageRequest(using: transaction) && CurrentAppContext().appUserDefaults()[.hasHiddenMessageRequests] { - // If there are other interactions on this thread already then don't show the notification - if thread.numberOfInteractions(with: transaction) > 1 { return } - - CurrentAppContext().appUserDefaults()[.hasHiddenMessageRequests] = false + if thread.variant == .contact { + if isMessageRequest && !db[.hasHiddenMessageRequests] { + let numMessageRequestThreads: Int? = try? SessionThread.messageRequestThreads(db) + .fetchCount(db) + + // Allow this to show a notification if there are no message requests (ie. this is the first one) + guard (numMessageRequestThreads ?? 0) == 0 else { return } + } + else if isMessageRequest && db[.hasHiddenMessageRequests] { + // If there are other interactions on this thread already then don't show the notification + if ((try? thread.interactions.fetchCount(db)) ?? 0) > 1 { return } + + db[.hasHiddenMessageRequests] = false + } } - let senderPublicKey = incomingMessage.authorId - let userPublicKey = getUserHexEncodedPublicKey() + let senderPublicKey: String = interaction.authorId + let userPublicKey: String = getUserHexEncodedPublicKey() + guard senderPublicKey != userPublicKey else { // Ignore PNs for messages sent by the current user // after handling the message. Otherwise the closed @@ -38,31 +43,39 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { return } - let senderName = Profile.displayName(for: senderPublicKey, thread: thread) + let senderName = Profile.displayName(db, id: senderPublicKey, thread: thread) var notificationTitle = senderName - if let group = thread as? TSGroupThread { - if group.isOnlyNotifyingForMentions && !incomingMessage.isUserMentioned { + + if thread.variant == .closedGroup || thread.variant == .openGroup { + if thread.notificationMode == .mentionsOnly && !interaction.isUserMentioned(db) { // Ignore PNs if the group is set to only notify for mentions return } - var groupName = thread.name(with: transaction) + var groupName = thread.name(db) if groupName.count < 1 { groupName = MessageStrings.newGroupDefaultTitle } - notificationTitle = String(format: NotificationStrings.incomingGroupMessageTitleFormat, senderName, groupName) + notificationTitle = String( + format: NotificationStrings.incomingGroupMessageTitleFormat, + senderName, + groupName + ) } - let snippet = incomingMessage.previewText(with: transaction).filterForDisplay?.replacingMentions(for: threadID, using: transaction) - ?? "APN_Message".localized() - var userInfo: [String:Any] = [ NotificationServiceExtension.isFromRemoteKey : true ] - userInfo[NotificationServiceExtension.threadIdKey] = threadID + let snippet = interaction.previewText(db) + .filterForDisplay? + .replacingMentions(for: thread.id) ?? "APN_Message".localized() + + var userInfo: [String: Any] = [ NotificationServiceExtension.isFromRemoteKey: true ] + userInfo[NotificationServiceExtension.threadIdKey] = thread.id let notificationContent = UNMutableNotificationContent() notificationContent.userInfo = userInfo - notificationContent.sound = OWSSounds.notificationSound(for: thread).notificationSound(isQuiet: false) + notificationContent.sound = OWSSounds.notificationSound(forThreadId: thread.id) + .notificationSound(isQuiet: false) // Badge Number let newBadgeNumber = CurrentAppContext().appUserDefaults().integer(forKey: "currentBadgeNumber") + 1 @@ -86,14 +99,15 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { // 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 thread.isMessageRequest(using: transaction) { + if isMessageRequest { notificationContent.title = "Session" notificationContent.body = "MESSAGE_REQUESTS_NOTIFICATION".localized() } // Add request - let identifier = incomingMessage.notificationIdentifier ?? UUID().uuidString + let identifier = interaction.notificationIdentifier(isBackgroundPoll: isBackgroundPoll) let request = UNNotificationRequest(identifier: identifier, content: notificationContent, trigger: nil) + SNLog("Add remote notification request: \(notificationContent.body)") let semaphore = DispatchSemaphore(value: 0) UNUserNotificationCenter.current().add(request) { error in @@ -106,10 +120,10 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { SNLog("Finish adding remote notification request") } - public func cancelNotification(_ identifier: String) { + public func cancelNotifications(identifiers: [String]) { let notificationCenter = UNUserNotificationCenter.current() - notificationCenter.removePendingNotificationRequests(withIdentifiers: [ identifier ]) - notificationCenter.removeDeliveredNotifications(withIdentifiers: [ identifier ]) + notificationCenter.removePendingNotificationRequests(withIdentifiers: identifiers) + notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiers) } public func clearAllNotifications() { @@ -121,7 +135,7 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { private extension String { - func replacingMentions(for threadID: String, using transaction: YapDatabaseReadTransaction) -> String { + func replacingMentions(for threadID: String) -> String { var result = self let regex = try! NSRegularExpression(pattern: "@[0-9a-fA-F]{66}", options: []) var mentions: [(range: NSRange, publicKey: String)] = [] @@ -130,7 +144,7 @@ private extension String { 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(for: publicKey) { + if let displayName: String = Profile.displayNameNoFallback(id: publicKey) { result = (result as NSString).replacingCharacters(in: m1.range, with: "@\(displayName)") 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 diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index 6408d6c8c..0524b6a7f 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -19,11 +19,11 @@ public final class NotificationServiceExtension : UNNotificationServiceExtension self.notificationContent = request.content.mutableCopy() as? UNMutableNotificationContent // Abort if the main app is running - var isMainAppAndActive = false + var isMainAppActive = false if let sharedUserDefaults = UserDefaults(suiteName: "group.com.loki-project.loki-messenger") { - isMainAppAndActive = sharedUserDefaults.bool(forKey: "isMainAppActive") + isMainAppActive = sharedUserDefaults.bool(forKey: "isMainAppActive") } - guard !isMainAppAndActive else { return self.completeSilenty() } + guard !isMainAppActive else { return self.completeSilenty() } // Perform main setup DispatchQueue.main.sync { self.setUpIfNecessary() { } } @@ -41,40 +41,45 @@ public final class NotificationServiceExtension : UNNotificationServiceExtension let envelope = try? MessageWrapper.unwrap(data: data), let envelopeAsData = try? envelope.serializedData() else { return self.handleFailure(for: notificationContent) } - // HACK: It is important to use writeSync() here to avoid a race condition + + // 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. + // is added to notification center GRDBStorage.shared.write { db in - Storage.writeSync { transaction in // Intentionally capture self - do { - let (message, proto) = try MessageReceiver.parse(db, envelopeAsData, openGroupMessageServerID: nil, using: transaction) - switch message { + do { + let (message, proto) = try MessageReceiver.parse(db, data: envelopeAsData) + switch message { case let visibleMessage as VisibleMessage: - let tsMessageID = try MessageReceiver.handleVisibleMessage(db, visibleMessage, associatedWithProto: proto, openGroupID: nil, isBackgroundPoll: false, using: transaction) - - // Remove the notificaitons if there is an outgoing messages from a linked device - if let tsMessage = TSMessage.fetch(uniqueId: tsMessageID, transaction: transaction), tsMessage.isKind(of: TSOutgoingMessage.self), let threadID = tsMessage.thread(with: transaction).uniqueId { - let semaphore = DispatchSemaphore(value: 0) - let center = UNUserNotificationCenter.current() - center.getDeliveredNotifications { notifications in - let matchingNotifications = notifications.filter({ $0.request.content.userInfo[NotificationServiceExtension.threadIdKey] as? String == threadID}) - center.removeDeliveredNotifications(withIdentifiers: matchingNotifications.map({ $0.request.identifier })) - // Hack: removeDeliveredNotifications seems to be async,need to wait for some time before the delivered notifications can be removed. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { semaphore.signal() } - } - semaphore.wait() + let interactionId: Int64 = try MessageReceiver.handleVisibleMessage(db, message: visibleMessage, associatedWithProto: proto, openGroupId: nil, isBackgroundPoll: false) + + // Remove the notifications if there is an outgoing messages from a linked device + if + let interaction: Interaction = try? Interaction.fetchOne(db, id: interactionId), + interaction.variant == .standardOutgoing + { + let semaphore = DispatchSemaphore(value: 0) + let center = UNUserNotificationCenter.current() + center.getDeliveredNotifications { notifications in + let matchingNotifications = notifications.filter({ $0.request.content.userInfo[NotificationServiceExtension.threadIdKey] as? String == interaction.threadId }) + center.removeDeliveredNotifications(withIdentifiers: matchingNotifications.map({ $0.request.identifier })) + // Hack: removeDeliveredNotifications seems to be async,need to wait for some time before the delivered notifications can be removed. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { semaphore.signal() } } - + semaphore.wait() + } + case let unsendRequest as UnsendRequest: - MessageReceiver.handleUnsendRequest(unsendRequest, using: transaction) + try MessageReceiver.handleUnsendRequest(db, message: unsendRequest) + case let closedGroupControlMessage as ClosedGroupControlMessage: - MessageReceiver.handleClosedGroupControlMessage(db, closedGroupControlMessage, using: transaction) + try MessageReceiver.handleClosedGroupControlMessage(db, closedGroupControlMessage) + default: break - } - } catch { - if let error = error as? MessageReceiver.Error, error.isRetryable { - self.handleFailure(for: notificationContent) - } + } + } + catch { + if let error = error as? MessageReceiverError, error.isRetryable { + self.handleFailure(for: notificationContent) } } } @@ -109,7 +114,9 @@ public final class NotificationServiceExtension : UNNotificationServiceExtension AppSetup.setupEnvironment( appSpecificSingletonBlock: { - SSKEnvironment.shared.notificationsManager = NSENotificationPresenter() + SSKEnvironment.shared.notificationsManager.mutate { + $0 = NSENotificationPresenter() + } }, migrationCompletion: { [weak self] _, needsConfigSync in self?.versionMigrationsDidComplete(needsConfigSync: needsConfigSync) @@ -129,7 +136,7 @@ public final class NotificationServiceExtension : UNNotificationServiceExtension // If we need a config sync then trigger it now if needsConfigSync { GRDBStorage.shared.write { db in - MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() + try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() } } diff --git a/SessionShareExtension/ShareVC.swift b/SessionShareExtension/ShareVC.swift index 4d0bfb1b0..1dbb279ad 100644 --- a/SessionShareExtension/ShareVC.swift +++ b/SessionShareExtension/ShareVC.swift @@ -44,7 +44,9 @@ final class ShareVC : UINavigationController, ShareViewDelegate, AppModeManagerD AppSetup.setupEnvironment( appSpecificSingletonBlock: { - SSKEnvironment.shared.notificationsManager = NoopNotificationsManager() + SSKEnvironment.shared.notificationsManager.mutate { + $0 = NoopNotificationsManager() + } }, migrationCompletion: { [weak self] _, needsConfigSync in AssertIsOnMainThread() @@ -82,7 +84,7 @@ final class ShareVC : UINavigationController, ShareViewDelegate, AppModeManagerD // If we need a config sync then trigger it now if needsConfigSync { GRDBStorage.shared.write { db in - MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() + try? MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() } } diff --git a/SessionShareExtension/SimplifiedConversationCell.swift b/SessionShareExtension/SimplifiedConversationCell.swift index 604011fb4..386b2da83 100644 --- a/SessionShareExtension/SimplifiedConversationCell.swift +++ b/SessionShareExtension/SimplifiedConversationCell.swift @@ -93,7 +93,7 @@ final class SimplifiedConversationCell : UITableViewCell { private func update() { AssertIsOnMainThread() - guard let thread = threadViewModel?.threadRecord else { return } + guard let thread = threadViewModel?.thread else { return } accentLineView.alpha = (thread.isBlocked() ? 1 : 0) profilePictureView.update(for: thread) @@ -101,7 +101,7 @@ final class SimplifiedConversationCell : UITableViewCell { } private func getDisplayName() -> String { - if threadViewModel.isGroupThread { + if threadViewModel.thread.variant == .closedGroup || threadViewModel.thread.variant == .openGroup { if threadViewModel.name.isEmpty { // TODO: Localization return "Unknown Group" @@ -114,11 +114,11 @@ final class SimplifiedConversationCell : UITableViewCell { return "NOTE_TO_SELF".localized() } - guard let hexEncodedPublicKey: String = threadViewModel.contactSessionID else { + guard threadViewModel.thread.variant == .contact else { // TODO: Localization return "Unknown" } - return Profile.displayName(for: hexEncodedPublicKey) + return Profile.displayName(id: threadViewModel.thread.id) } } diff --git a/SessionSnodeKit/SnodeAPI.swift b/SessionSnodeKit/SnodeAPI.swift index 83c088905..fe2305425 100644 --- a/SessionSnodeKit/SnodeAPI.swift +++ b/SessionSnodeKit/SnodeAPI.swift @@ -454,7 +454,17 @@ public final class SnodeAPI : NSObject { Threading.workQueue.async { getTargetSnodes(for: publicKey) .map2 { targetSnodes in - let parameters = message.toJSON() + // TODO: This as standard JSONEncoder (id blinding should mostly do this anyway???) + let parameters: JSON = [ + "pubKey": (Features.useTestnet ? + message.recipient.removing05PrefixIfNeeded() : + message.recipient + ), + "data" : message.data, + "ttl" : String(message.ttl), + "timestamp" : String(message.timestampMs), + "nonce" : "" + ] return targetSnodes .map { targetSnode in diff --git a/SessionSnodeKit/SnodeMessage.swift b/SessionSnodeKit/SnodeMessage.swift index f767a7d35..e305f3e6c 100644 --- a/SessionSnodeKit/SnodeMessage.swift +++ b/SessionSnodeKit/SnodeMessage.swift @@ -1,54 +1,63 @@ import PromiseKit import SessionUtilitiesKit -public final class SnodeMessage : NSObject, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility +public final class SnodeMessage: Codable { + private enum CodingKeys: String, CodingKey { + case recipient = "pubKey" + case data + case ttl + case timestampMs = "timestamp" + case nonce + } + /// The hex encoded public key of the recipient. public let recipient: String + /// The content of the message. - public let data: LosslessStringConvertible + public let data: String + /// The time to live for the message in milliseconds. public let ttl: UInt64 + /// When the proof of work was calculated. /// /// - Note: Expressed as milliseconds since 00:00:00 UTC on 1 January 1970. - public let timestamp: UInt64 + public let timestampMs: UInt64 - // MARK: Initialization - public init(recipient: String, data: LosslessStringConvertible, ttl: UInt64, timestamp: UInt64) { + // MARK: - Initialization + + public init(recipient: String, data: String, ttl: UInt64, timestampMs: UInt64) { self.recipient = recipient self.data = data self.ttl = ttl - self.timestamp = timestamp - } - - // MARK: Coding - public init?(coder: NSCoder) { - guard let recipient = coder.decodeObject(forKey: "recipient") as! String?, - let data = coder.decodeObject(forKey: "data") as! String?, - let ttl = coder.decodeObject(forKey: "ttl") as! UInt64?, - let timestamp = coder.decodeObject(forKey: "timestamp") as! UInt64? else { return nil } - self.recipient = recipient - self.data = data - self.ttl = ttl - self.timestamp = timestamp - super.init() - } - - public func encode(with coder: NSCoder) { - coder.encode(recipient, forKey: "recipient") - coder.encode(data, forKey: "data") - coder.encode(ttl, forKey: "ttl") - coder.encode(timestamp, forKey: "timestamp") - } - - // MARK: JSON Conversion - public func toJSON() -> JSON { - return [ - "pubKey" : Features.useTestnet ? recipient.removing05PrefixIfNeeded() : recipient, - "data" : data.description, - "ttl" : String(ttl), - "timestamp" : String(timestamp), - "nonce" : "" - ] + self.timestampMs = timestampMs + } +} + +// MARK: - Codable + +extension SnodeMessage { + public convenience init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + self.init( + recipient: try container.decode(String.self, forKey: .recipient), + data: try container.decode(String.self, forKey: .data), + ttl: try container.decode(UInt64.self, forKey: .ttl), + timestampMs: try container.decode(UInt64.self, forKey: .timestampMs) + ) + } + + public func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encode( + (Features.useTestnet ? recipient.removing05PrefixIfNeeded() : recipient), + forKey: .recipient + ) + try container.encode(data, forKey: .data) + try container.encode(ttl, forKey: .ttl) + try container.encode(timestampMs, forKey: .timestampMs) + try container.encode("", forKey: .nonce) } } diff --git a/SessionUtilitiesKit/Database/GRDBStorage.swift b/SessionUtilitiesKit/Database/GRDBStorage.swift index 342a4a6e8..d5e322cf0 100644 --- a/SessionUtilitiesKit/Database/GRDBStorage.swift +++ b/SessionUtilitiesKit/Database/GRDBStorage.swift @@ -2,6 +2,7 @@ import Foundation import GRDB +import PromiseKit import SignalCoreKit public enum GRDBStorageError: Error { // TODO: Rename to `StorageError` @@ -9,6 +10,10 @@ public enum GRDBStorageError: Error { // TODO: Rename to `StorageError` case migrationFailed case invalidKeySpec case decodingFailed + + case failedToSave + case objectNotFound + case objectNotSaved } // TODO: Protocol for storage (just need to have 'read' and 'write' methods and mock 'Database'? @@ -27,6 +32,12 @@ public final class GRDBStorage { private static var databasePathShm: String { "\(GRDBStorage.sharedDatabaseDirectoryPath)/\(GRDBStorage.dbFileName)-shm" } private static var databasePathWal: String { "\(GRDBStorage.sharedDatabaseDirectoryPath)/\(GRDBStorage.dbFileName)-wal" } + public static var isDatabasePasswordAccessible: Bool { + guard (try? getDatabaseCipherKeySpec()) != nil else { return false } + + return true + } + private let dbPool: DatabasePool private let migrator: DatabaseMigrator @@ -217,11 +228,53 @@ public final class GRDBStorage { return try? dbPool.write(updates) } - public func writeAsync(updates: @escaping (Database) throws -> T, completion: @escaping (Database, Result) -> Void) { - dbPool.asyncWrite(updates, completion: completion) + public func writeAsync(updates: @escaping (Database) throws -> T, completion: @escaping (Database, Swift.Result) throws -> Void) { + dbPool.asyncWrite( + updates, + completion: { db, result in + try? completion(db, result) + } + ) } @discardableResult public func read(_ value: (Database) throws -> T?) -> T? { return try? dbPool.read(value) } + + /// Rever to the `ValueObservation.start` method for full documentation + /// + /// - parameter observation: The observation to start + /// - parameter scheduler: A Scheduler. By default, fresh values are + /// dispatched asynchronously on the main queue. + /// - parameter onError: A closure that is provided eventual errors that + /// happen during observation + /// - parameter onChange: A closure that is provided fresh values + /// - returns: a DatabaseCancellable + public func start( + _ observation: ValueObservation, + scheduling scheduler: ValueObservationScheduler = .async(onQueue: .main), + onError: @escaping (Error) -> Void, + onChange: @escaping (Reducer.Value) -> Void + ) -> DatabaseCancellable { + observation.start( + in: dbPool, + scheduling: scheduler, + onError: onError, + onChange: onChange + ) + } +} + +// MARK: - Promise Extensions + +public extension GRDBStorage { + // FIXME: Would be good to replace this with Swift Combine + @discardableResult func write(updates: (Database) throws -> Promise) -> Promise { + do { + return try dbPool.write(updates) + } + catch { + return Promise(error: error) + } + } } diff --git a/SessionUtilitiesKit/Database/Models/Identity.swift b/SessionUtilitiesKit/Database/Models/Identity.swift index 7f57b461e..480cf932f 100644 --- a/SessionUtilitiesKit/Database/Models/Identity.swift +++ b/SessionUtilitiesKit/Database/Models/Identity.swift @@ -115,7 +115,7 @@ public extension Identity { return nil } - return try? Box.KeyPair( + return Box.KeyPair( publicKey: publicKey.data.bytes, secretKey: privateKey.data.bytes ) @@ -156,7 +156,7 @@ public extension Identity { } } - static func clearUserKeyPair() { + static func clearAll() { GRDBStorage.shared.write { db in try Identity.deleteAll(db) } @@ -165,8 +165,8 @@ public extension Identity { @objc(SUKIdentity) public class objc_Identity: NSObject { - @objc(clearUserKeyPair) - public static func objc_clearUserKeyPair() { - Identity.clearUserKeyPair() + @objc(clearAll) + public static func objc_clearAll() { + Identity.clearAll() } } diff --git a/SessionUtilitiesKit/Database/Models/Setting.swift b/SessionUtilitiesKit/Database/Models/Setting.swift index 1170f1dfa..4cf28c67a 100644 --- a/SessionUtilitiesKit/Database/Models/Setting.swift +++ b/SessionUtilitiesKit/Database/Models/Setting.swift @@ -87,16 +87,34 @@ public extension Setting { public init(unicodeScalarLiteral value: String) { self.init(value) } public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } } + + struct EnumKey: RawRepresentable, ExpressibleByStringLiteral { + public let rawValue: String + + public init(_ rawValue: String) { self.rawValue = rawValue } + public init?(rawValue: String) { self.rawValue = rawValue } + public init(stringLiteral value: String) { self.init(value) } + public init(unicodeScalarLiteral value: String) { self.init(value) } + public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } + } } +public protocol EnumSetting: RawRepresentable where RawValue == Int {} + // MARK: - GRDB Interactions public extension GRDBStorage { - subscript(key: Setting.BoolKey) -> Bool? { return read { db in db[key] } } + subscript(key: Setting.BoolKey) -> Bool { + // Default to false if it doesn't exist + return (read { db in db[key] } ?? false) + } + subscript(key: Setting.DoubleKey) -> Double? { return read { db in db[key] } } subscript(key: Setting.IntKey) -> Int? { return read { db in db[key] } } subscript(key: Setting.StringKey) -> String? { return read { db in db[key] } } subscript(key: Setting.DateKey) -> Date? { return read { db in db[key] } } + + subscript(key: Setting.EnumKey) -> T? { return read { db in db[key] } } } public extension Database { @@ -112,8 +130,11 @@ public extension Database { } } - subscript(key: Setting.BoolKey) -> Bool? { - get { self[key.rawValue]?.value(as: Bool.self) } + subscript(key: Setting.BoolKey) -> Bool { + get { + // Default to false if it doesn't exist + (self[key.rawValue]?.value(as: Bool.self) ?? false) + } set { self[key.rawValue] = Setting(key: key.rawValue, value: newValue) } } @@ -132,6 +153,17 @@ public extension Database { set { self[key.rawValue] = Setting(key: key.rawValue, value: newValue) } } + subscript(key: Setting.EnumKey) -> T? { + get { + guard let rawValue: Int = self[key.rawValue]?.value(as: Int.self) else { + return nil + } + + return T(rawValue: rawValue) + } + set { self[key.rawValue] = Setting(key: key.rawValue, value: newValue) } + } + /// Value will be stored as a timestamp in seconds since 1970 subscript(key: Setting.DateKey) -> Date? { get { diff --git a/SessionUtilitiesKit/Database/Utilities/PersistableRecord+Utilities.swift b/SessionUtilitiesKit/Database/Utilities/PersistableRecord+Utilities.swift new file mode 100644 index 000000000..de92207c1 --- /dev/null +++ b/SessionUtilitiesKit/Database/Utilities/PersistableRecord+Utilities.swift @@ -0,0 +1,16 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +public extension Array where Element: PersistableRecord { + @discardableResult func deleteAll(_ db: Database) throws -> Bool { + return try self.reduce(true) { prev, next in + try (prev && next.delete(db)) + } + } + + @discardableResult func saveAll(_ db: Database) throws { + try forEach { try $0.save(db) } + } +} diff --git a/SessionUtilitiesKit/General/Array+Utilities.swift b/SessionUtilitiesKit/General/Array+Utilities.swift index 43d74f007..887bf9052 100644 --- a/SessionUtilitiesKit/General/Array+Utilities.swift +++ b/SessionUtilitiesKit/General/Array+Utilities.swift @@ -22,6 +22,12 @@ public extension Array { updatedArray.append(contentsOf: other) return updatedArray } + + mutating func popFirst() -> Element? { + guard !self.isEmpty else { return nil } + + return self.removeFirst() + } } diff --git a/SessionUtilitiesKit/General/Atomic.swift b/SessionUtilitiesKit/General/Atomic.swift index 14baeed68..39b4ef708 100644 --- a/SessionUtilitiesKit/General/Atomic.swift +++ b/SessionUtilitiesKit/General/Atomic.swift @@ -29,10 +29,10 @@ public class Atomic { } // MARK: - Functions - - public func mutate(_ mutation: (inout Value) -> Void) { + + @discardableResult public func mutate(_ mutation: (inout Value) -> T) -> T { return queue.sync { - mutation(&value) + return mutation(&value) } } } diff --git a/SessionUtilitiesKit/General/SNUserDefaults.swift b/SessionUtilitiesKit/General/SNUserDefaults.swift index dcbdc5d9e..c2f361957 100644 --- a/SessionUtilitiesKit/General/SNUserDefaults.swift +++ b/SessionUtilitiesKit/General/SNUserDefaults.swift @@ -7,7 +7,8 @@ public enum SNUserDefaults { case hasViewedSeed case hasSeenLinkPreviewSuggestion case isUsingFullAPNs - case hasHiddenMessageRequests + case wasUnlinked + case isMainAppActive } public enum Date : Swift.String { diff --git a/SessionUtilitiesKit/General/Set+Utilities.swift b/SessionUtilitiesKit/General/Set+Utilities.swift index a5880927d..2bc8205ca 100644 --- a/SessionUtilitiesKit/General/Set+Utilities.swift +++ b/SessionUtilitiesKit/General/Set+Utilities.swift @@ -3,14 +3,18 @@ import Foundation public extension Set { - func inserting(_ value: Element) -> Set { + func inserting(_ value: Element?) -> Set { + guard let value: Element = value else { return self } + var updatedSet: Set = self updatedSet.insert(value) return updatedSet } - func removing(_ value: Element) -> Set { + func removing(_ value: Element?) -> Set { + guard let value: Element = value else { return self } + var updatedSet: Set = self updatedSet.remove(value) diff --git a/SessionUtilitiesKit/Utilities/Codable+Utilities.swift b/SessionUtilitiesKit/Utilities/Codable+Utilities.swift new file mode 100644 index 000000000..d09d6295c --- /dev/null +++ b/SessionUtilitiesKit/Utilities/Codable+Utilities.swift @@ -0,0 +1,9 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension Decodable { + static func decoded(with container: KeyedDecodingContainer, forKey key: CodingKeys) throws -> Self { + return try container.decode(Self.self, forKey: key) + } +} diff --git a/SignalUtilitiesKit/Media Viewing & Editing/MessageApprovalViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/MessageApprovalViewController.swift index e3888fb43..e57317abc 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/MessageApprovalViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/MessageApprovalViewController.swift @@ -163,7 +163,7 @@ public class MessageApprovalViewController: OWSViewController, UITextViewDelegat return recipientRow } - nameLabel.text = Profile.displayName(for: contactThread.contactSessionID()) + nameLabel.text = Profile.displayName(id: contactThread.contactSessionID()) nameLabel.textColor = Colors.text if let profileName = self.profileName(contactThread: contactThread) { @@ -188,7 +188,7 @@ public class MessageApprovalViewController: OWSViewController, UITextViewDelegat } private func profileName(contactThread: TSContactThread) -> String? { - return Profile.displayName(for: contactThread.contactSessionID()) + return Profile.displayName(id: contactThread.contactSessionID()) } // MARK: - Event Handlers diff --git a/SignalUtilitiesKit/Messaging/BlockListUIUtils.swift b/SignalUtilitiesKit/Messaging/BlockListUIUtils.swift index 5ac73dec3..1b3e53877 100644 --- a/SignalUtilitiesKit/Messaging/BlockListUIUtils.swift +++ b/SignalUtilitiesKit/Messaging/BlockListUIUtils.swift @@ -18,7 +18,7 @@ import SessionMessagingKit return } - let displayName: String = Profile.displayName(for: thread.contactSessionID()) + let displayName: String = Profile.displayName(id: thread.contactSessionID()) let actionSheet: UIAlertController = UIAlertController( title: String( format: "BLOCK_LIST_BLOCK_USER_TITLE_FORMAT".localized(), @@ -69,7 +69,7 @@ import SessionMessagingKit /// /// **Note:** Make sure to force a config sync in the `completionBlock` if the blocked state was successfully changed @objc public static func showUnblockThreadActionSheet(_ thread: TSContactThread, from viewController: UIViewController, completionBlock: ((Bool) -> ())? = nil) { - let displayName: String = Profile.displayName(for: thread.contactSessionID()) + let displayName: String = Profile.displayName(id: thread.contactSessionID()) let actionSheet: UIAlertController = UIAlertController( title: String( format: "BLOCK_LIST_UNBLOCK_TITLE_FORMAT".localized(), diff --git a/SignalUtilitiesKit/Messaging/FullTextSearcher.swift b/SignalUtilitiesKit/Messaging/FullTextSearcher.swift index 0686a153b..c7487bf85 100644 --- a/SignalUtilitiesKit/Messaging/FullTextSearcher.swift +++ b/SignalUtilitiesKit/Messaging/FullTextSearcher.swift @@ -44,7 +44,7 @@ public class ConversationSearchResult: Comparable where SortKey: Compar // MARK: Equatable public static func == (lhs: ConversationSearchResult, rhs: ConversationSearchResult) -> Bool { - return lhs.thread.threadRecord.uniqueId == rhs.thread.threadRecord.uniqueId && + return lhs.thread.thread == rhs.thread.thread && lhs.message?.uniqueId == rhs.message?.uniqueId } } @@ -103,7 +103,7 @@ public class GroupSearchResult: NSObject, Comparable { // MARK: Equatable public static func == (lhs: GroupSearchResult, rhs: GroupSearchResult) -> Bool { - return lhs.thread.threadRecord.uniqueId == rhs.thread.threadRecord.uniqueId + return lhs.thread.thread == rhs.thread.thread } } diff --git a/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift b/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift deleted file mode 100644 index 5f2e4e146..000000000 --- a/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift +++ /dev/null @@ -1,157 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import GRDB -import PromiseKit -import SessionUtilitiesKit - -extension MessageSender { - - // MARK: Durable - @objc(send:withAttachments:inThread:usingTransaction:) - public static func send(_ message: VisibleMessage, with attachments: [SignalAttachment], in thread: TSThread, using transaction: YapDatabaseReadWriteTransaction) { - prep(attachments, for: message, using: transaction) - send(message, in: thread, using: transaction) - } - - @objc(send:inThread:usingTransaction:) - public static func send(_ message: Message, in thread: TSThread, using transaction: YapDatabaseReadWriteTransaction) { - message.threadID = thread.uniqueId! - let destination = Message.Destination.from(thread) - let job = MessageSendJob(message: message, destination: destination) - JobQueue.shared.add(job, using: transaction) - } - - // MARK: Non-Durable - @objc(sendNonDurably:withAttachments:inThread:usingTransaction:) - public static func objc_sendNonDurably(_ message: VisibleMessage, with attachments: [SignalAttachment], in thread: TSThread, using transaction: YapDatabaseReadWriteTransaction) -> AnyPromise { - return AnyPromise.from(sendNonDurably(message, with: attachments, in: thread, using: transaction)) - } - - @objc(sendNonDurably:withAttachmentIDs:inThread:usingTransaction:) - public static func objc_sendNonDurably(_ message: VisibleMessage, with attachmentIDs: [String], in thread: TSThread, using transaction: YapDatabaseReadWriteTransaction) -> AnyPromise { - return AnyPromise.from(sendNonDurably(message, with: attachmentIDs, in: thread, using: transaction)) - } - - @objc(sendNonDurably:inThread:usingTransaction:) - public static func objc_sendNonDurably(_ message: Message, in thread: TSThread, using transaction: YapDatabaseReadWriteTransaction) -> AnyPromise { - return AnyPromise.from(sendNonDurably(message, in: thread, using: transaction)) - } - - public static func sendNonDurably(_ message: VisibleMessage, with attachments: [SignalAttachment], in thread: TSThread, using transaction: YapDatabaseReadWriteTransaction) -> Promise { - prep(attachments, for: message, using: transaction) - return sendNonDurably(message, with: message.attachmentIDs, in: thread, using: transaction) - } - - public static func sendNonDurably(_ message: VisibleMessage, with attachmentIDs: [String], in thread: TSThread, using transaction: YapDatabaseReadWriteTransaction) -> Promise { - let attachments = attachmentIDs.compactMap { TSAttachment.fetch(uniqueId: $0, transaction: transaction) as? TSAttachmentStream } - let attachmentsToUpload = attachments.filter { !$0.isUploaded } - let attachmentUploadPromises: [Promise] = attachmentsToUpload.map { stream in - let storage = SNMessagingKitConfiguration.shared.storage - if let v2OpenGroup = storage.getV2OpenGroup(for: thread.uniqueId!) { - let (promise, seal) = Promise.pending() - AttachmentUploadJob.upload(stream, using: { data in return OpenGroupAPIV2.upload(data, to: v2OpenGroup.room, on: v2OpenGroup.server) }, encrypt: false, onSuccess: { seal.fulfill(()) }, onFailure: { seal.reject($0) }) - return promise - } else { - let (promise, seal) = Promise.pending() - AttachmentUploadJob.upload(stream, using: FileServerAPIV2.upload, encrypt: true, onSuccess: { seal.fulfill(()) }, onFailure: { seal.reject($0) }) - return promise - } - } - return when(resolved: attachmentUploadPromises).then(on: DispatchQueue.global(qos: .userInitiated)) { results -> Promise in - let errors = results.compactMap { result -> Swift.Error? in - if case .rejected(let error) = result { return error } else { return nil } - } - if let error = errors.first { return Promise(error: error) } - return sendNonDurably(message, in: thread, using: transaction) - } - } - - public static func sendNonDurably(_ message: Message, in thread: TSThread, using transaction: YapDatabaseReadWriteTransaction) -> Promise { - message.threadID = thread.uniqueId! - let destination = Message.Destination.from(thread) - return MessageSender.send(message, to: destination, using: transaction) - } - - public static func sendNonDurably(_ message: VisibleMessage, with attachments: [SignalAttachment], in thread: TSThread) -> Promise { - Storage.writeSync{ transaction in - prep(attachments, for: message, using: transaction) - } - let attachments = message.attachmentIDs.compactMap { TSAttachment.fetch(uniqueId: $0) as? TSAttachmentStream } - let attachmentsToUpload = attachments.filter { !$0.isUploaded } - let attachmentUploadPromises: [Promise] = attachmentsToUpload.map { stream in - let storage = SNMessagingKitConfiguration.shared.storage - if let v2OpenGroup = storage.getV2OpenGroup(for: thread.uniqueId!) { - let (promise, seal) = Promise.pending() - AttachmentUploadJob.upload(stream, using: { data in return OpenGroupAPIV2.upload(data, to: v2OpenGroup.room, on: v2OpenGroup.server) }, encrypt: false, onSuccess: { seal.fulfill(()) }, onFailure: { seal.reject($0) }) - return promise - } else { - let (promise, seal) = Promise.pending() - AttachmentUploadJob.upload(stream, using: FileServerAPIV2.upload, encrypt: true, onSuccess: { seal.fulfill(()) }, onFailure: { seal.reject($0) }) - return promise - } - } - let (promise, seal) = Promise.pending() - let results = when(resolved: attachmentUploadPromises).wait() - let errors = results.compactMap { result -> Swift.Error? in - if case .rejected(let error) = result { return error } else { return nil } - } - if let error = errors.first { - seal.reject(error) - } else { - Storage.write{ transaction in - sendNonDurably(message, in: thread, using: transaction).done { - seal.fulfill(()) - }.catch { error in - seal.reject(error) - } - } - } - return promise - } - - /// This method requires the `db` value to be passed in because if it's called within a `writeAsync` completion block - /// it will throw a "re-entrant" fatal error when attempting to write again - public static func syncConfiguration(_ db: Database, forceSyncNow: Bool = true) -> Promise { - // If we don't have a userKeyPair yet then there is no need to sync the configuration - // as the user doesn't exist yet (this will get triggered on the first launch of a - // fresh install due to the migrations getting run) - guard Identity.userExists(db) else { - return Promise(error: GRDBStorageError.generic) - } - - let destination: Message.Destination = Message.Destination.contact(publicKey: getUserHexEncodedPublicKey(db)) - - guard let configurationMessage = try? ConfigurationMessage.getCurrent(db) else { - return Promise(error: GRDBStorageError.generic) - } - - let (promise, seal) = Promise.pending() - - Storage.writeSync { transaction in - if forceSyncNow { - MessageSender - .send(configurationMessage, to: destination, using: transaction) - .done { seal.fulfill(()) } - .catch { _ in seal.reject(GRDBStorageError.generic) } - .retainUntilComplete() - } - else { - let job = MessageSendJob(message: configurationMessage, destination: destination) - JobQueue.shared.add(job, using: transaction) - seal.fulfill(()) - } - } - - return promise - } -} - -extension MessageSender { - @objc(forceSyncConfigurationNow) - public static func objc_forceSyncConfigurationNow() { - GRDBStorage.shared.write { db in - syncConfiguration(db, forceSyncNow: true).retainUntilComplete() - } - } -} diff --git a/SignalUtilitiesKit/Messaging/ThreadViewModel.swift b/SignalUtilitiesKit/Messaging/ThreadViewModel.swift index 12bcfd560..0135063e4 100644 --- a/SignalUtilitiesKit/Messaging/ThreadViewModel.swift +++ b/SignalUtilitiesKit/Messaging/ThreadViewModel.swift @@ -1,66 +1,37 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import SessionMessagingKit -@objc -public class ThreadViewModel: NSObject { - @objc public let hasUnreadMessages: Bool - @objc public let lastMessageDate: Date - @objc public let isGroupThread: Bool - @objc public let threadRecord: TSThread - @objc public let unreadCount: UInt - @objc public let contactSessionID: String? - @objc public let name: String - @objc public let isMuted: Bool - @objc public let isPinned: Bool - @objc public let isOnlyNotifyingForMentions: Bool - @objc public let hasUnreadMentions: Bool +public struct ThreadViewModel: Equatable { + public let thread: SessionThread + public let name: String + public let unreadCount: UInt + public let unreadMentionCount: UInt - var isContactThread: Bool { - return !isGroupThread - } - - @objc public let lastMessageText: String? - @objc public let lastMessageForInbox: TSInteraction? - - @objc - public init(thread: TSThread, transaction: YapDatabaseReadTransaction) { - self.threadRecord = thread - - self.isGroupThread = thread.isGroupThread() - self.name = thread.name(with: transaction) - self.isMuted = thread.isMuted - self.isPinned = thread.isPinned - self.lastMessageText = thread.lastMessageText(transaction: transaction) - let lastInteraction = thread.lastInteractionForInbox(transaction: transaction) - self.lastMessageForInbox = lastInteraction - self.lastMessageDate = lastInteraction?.dateForUI() ?? thread.creationDate - - if let contactThread = thread as? TSContactThread { - self.contactSessionID = contactThread.contactSessionID() - } else { - self.contactSessionID = nil - } + public let lastInteraction: Interaction? + public let lastInteractionDate: Date + public let lastInteractionText: String? + public let lastInteractionState: RecipientState.State? + + public init( + thread: SessionThread, + name: String, + unreadCount: UInt, + unreadMentionCount: UInt, + lastInteraction: Interaction?, + lastInteractionDate: Date, + lastInteractionText: String?, + lastInteractionState: RecipientState.State? + ) { + self.thread = thread + self.name = name + self.unreadCount = unreadCount + self.unreadMentionCount = unreadMentionCount - if let groupThread = thread as? TSGroupThread { - self.isOnlyNotifyingForMentions = groupThread.isOnlyNotifyingForMentions - } else { - self.isOnlyNotifyingForMentions = false - } - - self.unreadCount = thread.unreadMessageCount(transaction: transaction) - self.hasUnreadMessages = unreadCount > 0 - self.hasUnreadMentions = thread.unreadMentionMessageCount(with: transaction) > 0 - } - - @objc - override public func isEqual(_ object: Any?) -> Bool { - guard let otherThread = object as? ThreadViewModel else { - return super.isEqual(object) - } - - return threadRecord.isEqual(otherThread.threadRecord) + self.lastInteraction = lastInteraction + self.lastInteractionDate = lastInteractionDate + self.lastInteractionText = lastInteractionText + self.lastInteractionState = lastInteractionState } } diff --git a/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift b/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift index 1d6530b7e..9220f1b35 100644 --- a/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift +++ b/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift @@ -1,7 +1,12 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import GRDB import SessionUIKit +import SessionMessagingKit @objc(LKProfilePictureView) -public final class ProfilePictureView : UIView { +public final class ProfilePictureView: UIView { private var hasTappableProfilePicture: Bool = false @objc public var size: CGFloat = 0 // Not an implicitly unwrapped optional due to Obj-C limitations @objc public var useFallbackPicture = false @@ -49,56 +54,74 @@ public final class ProfilePictureView : UIView { // MARK: Updating @objc(updateForContact:) - public func update(for publicKey: String) { + public func update(for publicKey: String) { // TODO: Confirm this is still used + GRDBStorage.shared.read { db in update(db, publicKey: publicKey) } + } + + public func update(_ db: Database, publicKey: String) { openGroupProfilePicture = nil self.publicKey = publicKey additionalPublicKey = nil useFallbackPicture = false - update() + update(db) } - @objc(updateForThread:) - public func update(for thread: TSThread) { + public func update(_ db: Database, thread: SessionThread) { openGroupProfilePicture = nil - if let thread = thread as? TSGroupThread { - if let openGroupProfilePicture = thread.groupModel.groupImage { // An open group with a profile picture - self.openGroupProfilePicture = openGroupProfilePicture - useFallbackPicture = false - hasTappableProfilePicture = true - } else if thread.groupModel.groupType == .openGroup { // An open group without a profile picture or an RSS feed - publicKey = "" - useFallbackPicture = true - } else { // A closed group - var users = Set(thread.groupModel.groupMemberIds) - users.remove(getUserHexEncodedPublicKey()) - var randomUsers = users.sorted() // Sort to provide a level of stability - if users.count == 1 { - randomUsers.insert(getUserHexEncodedPublicKey(), at: 0) // Ensure the current user is at the back visually + + switch thread.variant { + case .contact: update(db, publicKey: thread.id) + + case .closedGroup: + let userPublicKey: String = getUserHexEncodedPublicKey(db) + var randomUsers: [String] = (try? thread.closedGroup + .fetchOne(db)? + .members + .fetchAll(db) + .map { $0.profileId } + .filter { $0 != userPublicKey } + .sorted()) // Sort to provide a level of stability + .defaulting(to: []) + + if randomUsers.count == 1 { + // Ensure the current user is at the back visually + randomUsers.insert(userPublicKey, at: 0) } - publicKey = randomUsers.count >= 1 ? randomUsers[0] : "" - additionalPublicKey = randomUsers.count >= 2 ? randomUsers[1] : "" + + publicKey = (randomUsers.first ?? "") + additionalPublicKey = (randomUsers.count >= 2 ? randomUsers[1] : "") useFallbackPicture = false - } - update() - } else { // A one-to-one chat - let thread = thread as! TSContactThread - update(for: thread.contactSessionID()) + update(db) + + case .openGroup: + openGroupProfilePicture = (try? thread.openGroup + .fetchOne(db)? + .imageData) + .map { UIImage(data: $0) } + publicKey = "" + useFallbackPicture = (openGroupProfilePicture == nil) + hasTappableProfilePicture = (openGroupProfilePicture != nil) + update(db) } } - @objc public func update() { + @objc public func update() { // TODO: Confirm this is still used + GRDBStorage.shared.read { db in update(db) } + } + + public func update(_ db: Database) { AssertIsOnMainThread() func getProfilePicture(of size: CGFloat, for publicKey: String) -> UIImage? { guard !publicKey.isEmpty else { return nil } - if let profilePicture: UIImage = ProfileManager.profileAvatar(for: publicKey) { + if let profilePicture: UIImage = ProfileManager.profileAvatar(db, id: publicKey) { hasTappableProfilePicture = true return profilePicture } hasTappableProfilePicture = false // TODO: Pass in context? - let displayName: String = Profile.displayName(for: publicKey) + let displayName: String = Profile.displayName(db, id: publicKey) return Identicon.generatePlaceholderIcon(seed: publicKey, text: displayName, size: size) } @@ -111,30 +134,35 @@ public final class ProfilePictureView : UIView { } else { size = Values.smallProfilePictureSize } + imageViewWidthConstraint.constant = size imageViewHeightConstraint.constant = size additionalImageViewWidthConstraint.constant = size additionalImageViewHeightConstraint.constant = size additionalImageView.isHidden = false additionalImageView.image = getProfilePicture(of: size, for: additionalPublicKey) - } else { + } + else { size = self.size imageViewWidthConstraint.constant = size imageViewHeightConstraint.constant = size additionalImageView.isHidden = true additionalImageView.image = nil } + guard publicKey != nil || openGroupProfilePicture != nil else { return } + imageView.image = useFallbackPicture ? nil : (openGroupProfilePicture ?? getProfilePicture(of: size, for: publicKey)) imageView.backgroundColor = useFallbackPicture ? UIColor(rgbHex: 0x353535) : Colors.unimportant imageView.layer.cornerRadius = size / 2 additionalImageView.layer.cornerRadius = size / 2 imageView.contentMode = useFallbackPicture ? .center : .scaleAspectFit + if useFallbackPicture { switch size { - case Values.smallProfilePictureSize.. Bool { + return (self == source) + } +} diff --git a/SignalUtilitiesKit/Utilities/NoopNotificationsManager.swift b/SignalUtilitiesKit/Utilities/NoopNotificationsManager.swift index ffe624689..51987bd7d 100644 --- a/SignalUtilitiesKit/Utilities/NoopNotificationsManager.swift +++ b/SignalUtilitiesKit/Utilities/NoopNotificationsManager.swift @@ -2,14 +2,18 @@ // Copyright (c) 2019 Open Whisper Systems. All rights reserved. // +import Foundation +import GRDB +import SessionMessagingKit + @objc public class NoopNotificationsManager: NSObject, NotificationsProtocol { - public func notifyUser(for incomingMessage: TSIncomingMessage, in thread: TSThread, transaction: YapDatabaseReadTransaction) { + public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, isBackgroundPoll: Bool) { owsFailDebug("") } - public func cancelNotification(_ identifier: String) { + public func cancelNotifications(identifiers: [String]) { owsFailDebug("") } diff --git a/SignalUtilitiesKit/Utilities/Notification+Loki.swift b/SignalUtilitiesKit/Utilities/Notification+Loki.swift index 5f1d9f101..705ed8b87 100644 --- a/SignalUtilitiesKit/Utilities/Notification+Loki.swift +++ b/SignalUtilitiesKit/Utilities/Notification+Loki.swift @@ -1,7 +1,9 @@ +import UIKit public extension Notification.Name { // State changes + static let registrationStateDidChange = Notification.Name("registrationStateDidChange") static let blockedContactsUpdated = Notification.Name("blockedContactsUpdated") static let contactOnlineStatusChanged = Notification.Name("contactOnlineStatusChanged") static let threadDeleted = Notification.Name("threadDeleted") @@ -15,6 +17,7 @@ public extension Notification.Name { @objc public extension NSNotification { // State changes + @objc static let registrationStateDidChange = Notification.Name.registrationStateDidChange.rawValue as NSString @objc static let blockedContactsUpdated = Notification.Name.blockedContactsUpdated.rawValue as NSString @objc static let contactOnlineStatusChanged = Notification.Name.contactOnlineStatusChanged.rawValue as NSString @objc static let threadDeleted = Notification.Name.threadDeleted.rawValue as NSString From ed9f4ea6c631da6867053316ede9659b8afe4b2e Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 22 Apr 2022 18:47:11 +1000 Subject: [PATCH 063/157] Fixed a few closed group and job issues Fixed a few job migration issues Fixed an issue with the closed group key pair management (wasn't storing keys correctly) Refactored the OWSSound (now Preferences.Sound) Added the logic for the AttachmentDownloadJob and enabled jobs to be cascade deleted via interactions Optimised the HomeViewModel database observation query (fetch specific columns so changes outside those don't trigger updates) Updated to the latest GRDB (ran into a deadlock which should be fixed in a newer version) --- Podfile.lock | 4 +- Session.xcodeproj/project.pbxproj | 14 +- Session/Closed Groups/NewClosedGroupVC.swift | 11 +- Session/Meta/Signal-Bridging-Header.h | 1 - Session/Notifications/AppNotifications.swift | 17 +- Session/Notifications/SyncPushTokensJob.swift | 1 + .../UserNotificationsAdaptee.swift | 7 +- .../NotificationSettingsViewController.m | 4 +- .../Settings/OWSSoundSettingsViewController.h | 2 +- .../Settings/OWSSoundSettingsViewController.m | 39 +- Session/Shared/ConversationCell.swift | 5 +- SessionMessagingKit/Configuration.swift | 1 + .../_001_InitialSetupMigration.swift | 13 +- .../Migrations/_003_YDBToGRDBMigration.swift | 415 +++++++++--------- .../Database/Models/Attachment.swift | 92 ++-- .../Database/Models/ClosedGroupKeyPair.swift | 16 +- .../Database/Models/Interaction.swift | 81 +++- .../Models/InteractionAttachment.swift | 2 + SessionMessagingKit/Database/Models/Job.swift | 47 +- .../Database/Models/OpenGroup.swift | 1 - .../Database/Models/SessionThread.swift | 28 +- .../Jobs/AttachmentDownloadJob.swift | 166 ------- SessionMessagingKit/Jobs/JobRunner.swift | 113 +++-- SessionMessagingKit/Jobs/JobRunnerError.swift | 1 + .../Jobs/Types/AttachmentDownloadJob.swift | 309 +++++++++++++ .../Jobs/Types/DisappearingMessagesJob.swift | 15 +- .../Types/FailedAttachmentDownloadsJob.swift | 1 + .../Jobs/Types/FailedMessagesJob.swift | 1 + .../Jobs/Types/MessageReceiveJob.swift | 1 + .../Jobs/Types/MessageSendJob.swift | 1 + .../Jobs/Types/NotifyPushServerJob.swift | 8 +- .../Jobs/Types/SendReadReceiptsJob.swift | 1 + .../Meta/SessionMessagingKit.h | 1 - .../MessageReceiver+Decryption.swift | 4 + .../MessageReceiver+Handling.swift | 34 +- .../Sending & Receiving/MessageReceiver.swift | 2 +- .../MessageSender+ClosedGroups.swift | 74 ++-- .../Sending & Receiving/MessageSender.swift | 21 +- .../Sending & Receiving/Pollers/Poller.swift | 7 +- SessionMessagingKit/Utilities/Environment.h | 3 - SessionMessagingKit/Utilities/Environment.m | 4 - SessionMessagingKit/Utilities/OWSSounds.h | 79 ---- SessionMessagingKit/Utilities/OWSSounds.m | 365 --------------- SessionMessagingKit/Utilities/OWSSounds.swift | 15 - .../Utilities/Preferences.swift | 211 ++++++++- .../NSENotificationPresenter.swift | 3 +- .../Database/Models/Identity.swift | 76 +--- .../Database/Models/Setting.swift | 10 +- .../General/Array+Utilities.swift | 6 + SessionUtilitiesKit/General/LRUCache.swift | 26 -- SignalUtilitiesKit/Utilities/AppSetup.m | 3 - 51 files changed, 1188 insertions(+), 1174 deletions(-) delete mode 100644 SessionMessagingKit/Jobs/AttachmentDownloadJob.swift create mode 100644 SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift delete mode 100644 SessionMessagingKit/Utilities/OWSSounds.h delete mode 100644 SessionMessagingKit/Utilities/OWSSounds.m delete mode 100644 SessionMessagingKit/Utilities/OWSSounds.swift diff --git a/Podfile.lock b/Podfile.lock index 9f0a321d1..81d738229 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -27,7 +27,7 @@ PODS: - DifferenceKit/Core (1.2.0) - DifferenceKit/UIKitExtension (1.2.0): - DifferenceKit/Core - - GRDB.swift/SQLCipher (5.19.0): + - GRDB.swift/SQLCipher (5.23.0): - SQLCipher (>= 3.4.0) - Mantle (2.1.0): - Mantle/extobjc (= 2.1.0) @@ -203,7 +203,7 @@ SPEC CHECKSUMS: CryptoSwift: a532e74ed010f8c95f611d00b8bbae42e9fe7c17 Curve25519Kit: e63f9859ede02438ae3defc5e1a87e09d1ec7ee6 DifferenceKit: 5659c430bb7fe45876fa32ce5cba5d6167f0c805 - GRDB.swift: c00ff42d3cffbe90145fb4e364e26a099f997142 + GRDB.swift: e4a950fe99d113ea5d24571d49eaae0062303c14 Mantle: 2fa750afa478cd625a94230fbf1c13462f29395b NVActivityIndicatorView: 1f6c5687f1171810aa27a3296814dc2d7dec3667 OpenSSL-Universal: e7311447fd2419f57420c79524b641537387eff2 diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index dbd32e632..aaea0791e 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -121,7 +121,6 @@ 70377AAB1918450100CAF501 /* MobileCoreServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 70377AAA1918450100CAF501 /* MobileCoreServices.framework */; }; 768A1A2B17FC9CD300E00ED8 /* libz.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = 768A1A2A17FC9CD300E00ED8 /* libz.dylib */; }; 76C87F19181EFCE600C4ACAB /* MediaPlayer.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 76C87F18181EFCE600C4ACAB /* MediaPlayer.framework */; }; - 7B1581E2271E743B00848B49 /* OWSSounds.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1581E1271E743B00848B49 /* OWSSounds.swift */; }; 7B1D74AA27BCC16E0030B423 /* NSENotificationPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1D74A927BCC16E0030B423 /* NSENotificationPresenter.swift */; }; 7B1D74AC27BDE7510030B423 /* Promise+Timeout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1D74AB27BDE7510030B423 /* Promise+Timeout.swift */; }; 7B1D74B027C365960030B423 /* Timer+MainThread.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1D74AF27C365960030B423 /* Timer+MainThread.swift */; }; @@ -213,8 +212,6 @@ B8856E09256F1676001CE70E /* UIDevice+featureSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF237255B6D65007E1867 /* UIDevice+featureSupport.swift */; }; B8856E1A256F1700001CE70E /* OWSMath.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB14255A580800E217F9 /* OWSMath.h */; settings = {ATTRIBUTES = (Public, ); }; }; B8856E33256F18D5001CE70E /* OWSStorage+Subclass.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDAB9255A580100E217F9 /* OWSStorage+Subclass.h */; settings = {ATTRIBUTES = (Public, ); }; }; - B8856E94256F1C37001CE70E /* OWSSounds.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF28B255B6D86007E1867 /* OWSSounds.m */; }; - B8856E9D256F1C3D001CE70E /* OWSSounds.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF288255B6D85007E1867 /* OWSSounds.h */; settings = {ATTRIBUTES = (Public, ); }; }; B8856ECE256F1E58001CE70E /* OWSPreferences.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF308255B6DBE007E1867 /* OWSPreferences.m */; }; B8856ED7256F1EB4001CE70E /* OWSPreferences.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF2F1255B6DBB007E1867 /* OWSPreferences.h */; settings = {ATTRIBUTES = (Public, ); }; }; B886B4A72398B23E00211ABE /* QRCodeVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B886B4A62398B23E00211ABE /* QRCodeVC.swift */; }; @@ -1136,7 +1133,6 @@ 768A1A2A17FC9CD300E00ED8 /* libz.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libz.dylib; path = usr/lib/libz.dylib; sourceTree = SDKROOT; }; 76C87F18181EFCE600C4ACAB /* MediaPlayer.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaPlayer.framework; path = System/Library/Frameworks/MediaPlayer.framework; sourceTree = SDKROOT; }; 7ABE4694B110C1BBCB0E46A2 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.app store release.xcconfig"; sourceTree = ""; }; - 7B1581E1271E743B00848B49 /* OWSSounds.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSSounds.swift; sourceTree = ""; }; 7B1D74A927BCC16E0030B423 /* NSENotificationPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSENotificationPresenter.swift; sourceTree = ""; }; 7B1D74AB27BDE7510030B423 /* Promise+Timeout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Promise+Timeout.swift"; sourceTree = ""; }; 7B1D74AF27C365960030B423 /* Timer+MainThread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Timer+MainThread.swift"; sourceTree = ""; }; @@ -1574,8 +1570,6 @@ C38EF284255B6D84007E1867 /* AppSetup.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppSetup.h; path = SignalUtilitiesKit/Utilities/AppSetup.h; sourceTree = SOURCE_ROOT; }; C38EF286255B6D85007E1867 /* VersionMigrations.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = VersionMigrations.m; path = SignalUtilitiesKit/Utilities/VersionMigrations.m; sourceTree = SOURCE_ROOT; }; C38EF287255B6D85007E1867 /* AppSetup.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = AppSetup.m; path = SignalUtilitiesKit/Utilities/AppSetup.m; sourceTree = SOURCE_ROOT; }; - C38EF288255B6D85007E1867 /* OWSSounds.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSSounds.h; path = SessionMessagingKit/Utilities/OWSSounds.h; sourceTree = SOURCE_ROOT; }; - C38EF28B255B6D86007E1867 /* OWSSounds.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = OWSSounds.m; path = SessionMessagingKit/Utilities/OWSSounds.m; sourceTree = SOURCE_ROOT; }; C38EF2A2255B6D93007E1867 /* Identicon+ObjC.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "Identicon+ObjC.swift"; path = "SignalUtilitiesKit/Profile Pictures/Identicon+ObjC.swift"; sourceTree = SOURCE_ROOT; }; C38EF2A3255B6D93007E1867 /* PlaceholderIcon.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = PlaceholderIcon.swift; path = "SignalUtilitiesKit/Profile Pictures/PlaceholderIcon.swift"; sourceTree = SOURCE_ROOT; }; C38EF2A4255B6D93007E1867 /* ProfilePictureView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ProfilePictureView.swift; path = "SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift"; sourceTree = SOURCE_ROOT; }; @@ -2865,7 +2859,6 @@ C352A3922557883D00338F3E /* JobDelegate.swift */, C352A3882557876500338F3E /* JobQueue.swift */, C352A35A2557824E00338F3E /* AttachmentUploadJob.swift */, - C352A348255781F400338F3E /* AttachmentDownloadJob.swift */, ); path = Jobs; sourceTree = ""; @@ -3244,9 +3237,6 @@ C38EF2F1255B6DBB007E1867 /* OWSPreferences.h */, C38EF308255B6DBE007E1867 /* OWSPreferences.m */, FDF0B75D280AAF35004C14C5 /* Preferences.swift */, - C38EF288255B6D85007E1867 /* OWSSounds.h */, - C38EF28B255B6D86007E1867 /* OWSSounds.m */, - 7B1581E1271E743B00848B49 /* OWSSounds.swift */, C38EF2FB255B6DBD007E1867 /* OWSWindowManager.h */, C38EF306255B6DBE007E1867 /* OWSWindowManager.m */, FD09797327FAB3E200936362 /* ProfileManager.swift */, @@ -3821,6 +3811,7 @@ C352A31225574F5200338F3E /* MessageReceiveJob.swift */, C352A32E2557549C00338F3E /* NotifyPushServerJob.swift */, FDF0B74E28079E5E004C14C5 /* SendReadReceiptsJob.swift */, + C352A348255781F400338F3E /* AttachmentDownloadJob.swift */, ); path = Types; sourceTree = ""; @@ -3978,7 +3969,6 @@ C3A3A15F256E1BB4004D228D /* ProtoUtils.h in Headers */, C3A3A145256E1B49004D228D /* OWSMediaGalleryFinder.h in Headers */, B8856E33256F18D5001CE70E /* OWSStorage+Subclass.h in Headers */, - B8856E9D256F1C3D001CE70E /* OWSSounds.h in Headers */, C32C5E64256DDFD6003C73A2 /* OWSStorage.h in Headers */, ); runOnlyForDeploymentPostprocessing = 0; @@ -4941,7 +4931,6 @@ FDF0B75C2807F41D004C14C5 /* MessageSender+Convenience.swift in Sources */, FD09799727FFA84A00936362 /* RecipientState.swift in Sources */, C300A5FC2554B0A000555489 /* MessageReceiver.swift in Sources */, - 7B1581E2271E743B00848B49 /* OWSSounds.swift in Sources */, C32C5A76256DBBCF003C73A2 /* SignalAttachment.swift in Sources */, FDA8EB00280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift in Sources */, FD09798927FD1C5A00936362 /* OpenGroup.swift in Sources */, @@ -5025,7 +5014,6 @@ C32C5E97256DE0CB003C73A2 /* OWSPrimaryStorage.m in Sources */, C32C5EB9256DE130003C73A2 /* OWSQuotedReplyModel+Conversion.swift in Sources */, C3A71D1F25589AC30043A11F /* WebSocketResources.pb.swift in Sources */, - B8856E94256F1C37001CE70E /* OWSSounds.m in Sources */, C32C5BCC256DC830003C73A2 /* Storage+ClosedGroups.swift in Sources */, C3A3A0EC256E1949004D228D /* OWSRecipientIdentity.m in Sources */, FDF0B74B28061F7A004C14C5 /* InteractionAttachment.swift in Sources */, diff --git a/Session/Closed Groups/NewClosedGroupVC.swift b/Session/Closed Groups/NewClosedGroupVC.swift index 40d81b645..2558f1975 100644 --- a/Session/Closed Groups/NewClosedGroupVC.swift +++ b/Session/Closed Groups/NewClosedGroupVC.swift @@ -173,20 +173,21 @@ final class NewClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegat let selectedContacts = self.selectedContacts let message: String? = (selectedContacts.count > 20) ? "Please wait while the group is created..." : nil ModalActivityIndicatorViewController.present(fromViewController: navigationController!, message: message) { [weak self] _ in - var promise: Promise! - Storage.writeSync { transaction in - promise = MessageSender.createClosedGroup(name: name, members: selectedContacts, transaction: transaction) + let promise: Promise = GRDBStorage.shared.write { db in + try MessageSender.createClosedGroup(db, name: name, members: selectedContacts) } + let _ = promise.done(on: DispatchQueue.main) { thread in GRDBStorage.shared.write { db in - MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() + try? MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() } self?.presentingViewController?.dismiss(animated: true, completion: nil) SignalApp.shared().presentConversation(for: thread, action: .compose, animated: false) } - promise.catch(on: DispatchQueue.main) { _ in + promise.catch(on: DispatchQueue.main) { [weak self] _ in self?.dismiss(animated: true, completion: nil) // Dismiss the loader + let title = "Couldn't Create Group" let message = "Please check your internet connection and try again." let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) diff --git a/Session/Meta/Signal-Bridging-Header.h b/Session/Meta/Signal-Bridging-Header.h index e785154a7..0eb31c11d 100644 --- a/Session/Meta/Signal-Bridging-Header.h +++ b/Session/Meta/Signal-Bridging-Header.h @@ -47,7 +47,6 @@ #import #import #import -#import #import #import #import diff --git a/Session/Notifications/AppNotifications.swift b/Session/Notifications/AppNotifications.swift index b067296a7..40964537a 100644 --- a/Session/Notifications/AppNotifications.swift +++ b/Session/Notifications/AppNotifications.swift @@ -95,8 +95,8 @@ protocol NotificationPresenterAdaptee: AnyObject { func registerNotificationSettings() -> Promise - func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: OWSSound?) - func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: OWSSound?, replacingIdentifier: String?) + func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: Preferences.Sound?) + func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: Preferences.Sound?, replacingIdentifier: String?) func cancelNotifications(threadId: String) func cancelNotifications(identifiers: [String]) @@ -225,15 +225,11 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { ) ) } - - default: - notificationTitle = "Session" } switch previewType { case .noNameNoPreview, .nameNoPreview: notificationBody = NotificationStrings.incomingMessageBody case .nameAndPreview: notificationBody = messageText - default: notificationBody = NotificationStrings.incomingMessageBody } // If it's a message request then overwrite the body to be something generic (only show a notification @@ -257,7 +253,7 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { in: (notificationBody ?? ""), threadId: thread.id ) - let sound = self.requestSound(thread: thread) + let sound: Preferences.Sound? = self.requestSound(thread: thread) self.adaptee.notify( category: category, @@ -286,7 +282,8 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { ] DispatchQueue.main.async { - let sound = self.requestSound(thread: thread) + let sound: Preferences.Sound? = self.requestSound(thread: thread) + self.adaptee.notify( category: .errorMessage, title: notificationTitle, @@ -318,12 +315,12 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { var mostRecentNotifications = TruncatedList(maxLength: kAudioNotificationsThrottleCount) - private func requestSound(thread: SessionThread) -> OWSSound? { + private func requestSound(thread: SessionThread) -> Preferences.Sound? { guard checkIfShouldPlaySound() else { return nil } - return OWSSounds.notificationSound(forThreadId: thread.id) + return thread.notificationSound } private func checkIfShouldPlaySound() -> Bool { diff --git a/Session/Notifications/SyncPushTokensJob.swift b/Session/Notifications/SyncPushTokensJob.swift index 28f41d7e3..fabce3628 100644 --- a/Session/Notifications/SyncPushTokensJob.swift +++ b/Session/Notifications/SyncPushTokensJob.swift @@ -10,6 +10,7 @@ import SessionUtilitiesKit public enum SyncPushTokensJob: JobExecutor { public static let maxFailureCount: UInt = 0 public static let requiresThreadId: Bool = false + public static let requiresInteractionId: Bool = false public static func run( _ job: Job, diff --git a/Session/Notifications/UserNotificationsAdaptee.swift b/Session/Notifications/UserNotificationsAdaptee.swift index 7e9daaa35..23349fb82 100644 --- a/Session/Notifications/UserNotificationsAdaptee.swift +++ b/Session/Notifications/UserNotificationsAdaptee.swift @@ -5,6 +5,7 @@ import Foundation import UserNotifications import PromiseKit +import SessionMessagingKit class UserNotificationConfig { @@ -85,12 +86,12 @@ extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee { } } - func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: OWSSound?) { + func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: Preferences.Sound?) { AssertIsOnMainThread() notify(category: category, title: title, body: body, userInfo: userInfo, sound: sound, replacingIdentifier: nil) } - func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: OWSSound?, replacingIdentifier: String?) { + func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: Preferences.Sound?, replacingIdentifier: String?) { AssertIsOnMainThread() let content = UNMutableNotificationContent() @@ -103,7 +104,7 @@ extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee { isBackgroudPoll = replacingIdentifier == threadIdentifier } let isAppActive = UIApplication.shared.applicationState == .active - if let sound = sound, sound != OWSSound.none { + if let sound = sound, sound != .none { content.sound = sound.notificationSound(isQuiet: isAppActive) } diff --git a/Session/Settings/NotificationSettingsViewController.m b/Session/Settings/NotificationSettingsViewController.m index d087b8f67..ce0099053 100644 --- a/Session/Settings/NotificationSettingsViewController.m +++ b/Session/Settings/NotificationSettingsViewController.m @@ -9,7 +9,7 @@ #import "OWSSoundSettingsViewController.h" #import #import -#import +#import #import #import "Session-Swift.h" @@ -66,7 +66,7 @@ addItem:[OWSTableItem disclosureItemWithText: NSLocalizedString(@"SETTINGS_ITEM_NOTIFICATION_SOUND", @"Label for settings view that allows user to change the notification sound.") - detailText:[OWSSounds displayNameForSound:[OWSSounds globalNotificationSound]] + detailText:[SMKSound displayNameFor:[SMKSound defaultNotificationSound]] accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"message_sound") actionBlock:^{ OWSSoundSettingsViewController *vc = [OWSSoundSettingsViewController new]; diff --git a/Session/Settings/OWSSoundSettingsViewController.h b/Session/Settings/OWSSoundSettingsViewController.h index 27b89e488..9a86798bc 100644 --- a/Session/Settings/OWSSoundSettingsViewController.h +++ b/Session/Settings/OWSSoundSettingsViewController.h @@ -12,7 +12,7 @@ NS_ASSUME_NONNULL_BEGIN // This property is optional. If it is not set, we are // editing the global notification sound. -@property (nonatomic, nullable) TSThread *thread; +@property (nonatomic, nullable) NSString *threadId; @end diff --git a/Session/Settings/OWSSoundSettingsViewController.m b/Session/Settings/OWSSoundSettingsViewController.m index 4c086577a..77d33e6b6 100644 --- a/Session/Settings/OWSSoundSettingsViewController.m +++ b/Session/Settings/OWSSoundSettingsViewController.m @@ -5,7 +5,7 @@ #import "OWSSoundSettingsViewController.h" #import #import -#import +#import #import #import #import "Session-Swift.h" @@ -16,7 +16,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic) BOOL isDirty; -@property (nonatomic) OWSSound currentSound; +@property (nonatomic) NSInteger currentSound; @property (nonatomic, nullable) OWSAudioPlayer *audioPlayer; @@ -32,9 +32,8 @@ NS_ASSUME_NONNULL_BEGIN [self setTitle:NSLocalizedString(@"SETTINGS_ITEM_NOTIFICATION_SOUND", @"Label for settings view that allows user to change the notification sound.")]; - self.currentSound - = (self.thread ? [OWSSounds notificationSoundForThread:self.thread] : [OWSSounds globalNotificationSound]); - + self.currentSound = [SMKSound notificationSoundFor:self.threadId]; + [self updateTableContents]; [self updateNavigationItems]; @@ -85,33 +84,34 @@ NS_ASSUME_NONNULL_BEGIN soundsSection.headerTitle = NSLocalizedString( @"NOTIFICATIONS_SECTION_SOUNDS", @"Label for settings UI that allows user to change the notification sound."); - NSArray *allSounds = [OWSSounds allNotificationSounds]; + NSArray *allSounds = [SMKSound notificationSounds]; for (NSNumber *nsValue in allSounds) { - OWSSound sound = (OWSSound)nsValue.intValue; + NSInteger sound = nsValue.integerValue; OWSTableItem *item; NSString *soundLabelText = ^{ - NSString *baseName = [OWSSounds displayNameForSound:sound]; - if (sound == OWSSound_Note) { + NSString *baseName = [SMKSound displayNameFor:sound]; + if ([SMKSound isNote:sound]) { NSString *noteStringFormat = NSLocalizedString(@"SETTINGS_AUDIO_DEFAULT_TONE_LABEL_FORMAT", @"Format string for the default 'Note' sound. Embeds the system {{sound name}}."); return [NSString stringWithFormat:noteStringFormat, baseName]; - } else { - return [OWSSounds displayNameForSound:sound]; + } + else { + return [SMKSound displayNameFor:sound]; } }(); if (sound == self.currentSound) { item = [OWSTableItem checkmarkItemWithText:soundLabelText - accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, [OWSSounds displayNameForSound:sound]) + accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, [SMKSound displayNameFor:sound]) actionBlock:^{ [weakSelf soundWasSelected:sound]; }]; } else { item = [OWSTableItem actionItemWithText:soundLabelText - accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, [OWSSounds displayNameForSound:sound]) + accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, [SMKSound displayNameFor:sound]) actionBlock:^{ [weakSelf soundWasSelected:sound]; }]; @@ -126,10 +126,10 @@ NS_ASSUME_NONNULL_BEGIN #pragma mark - Events -- (void)soundWasSelected:(OWSSound)sound +- (void)soundWasSelected:(NSInteger)sound { [self.audioPlayer stop]; - self.audioPlayer = [OWSSounds audioPlayerForSound:sound audioBehavior:OWSAudioBehavior_Playback]; + self.audioPlayer = [SMKSound audioPlayerFor:sound audioBehavior:OWSAudioBehavior_Playback]; // Suppress looping in this view. self.audioPlayer.isLooping = NO; [self.audioPlayer play]; @@ -153,10 +153,11 @@ NS_ASSUME_NONNULL_BEGIN - (void)saveWasPressed:(id)sender { - if (self.thread) { - [OWSSounds setNotificationSound:self.currentSound forThread:self.thread]; - } else { - [OWSSounds setGlobalNotificationSound:self.currentSound]; + if (self.threadId) { + [SMKSound setNotificationSound:self.currentSound forThreadId:self.threadId]; + } + else { + [SMKSound setGlobalNotificationSound:self.currentSound]; } [self.audioPlayer stop]; diff --git a/Session/Shared/ConversationCell.swift b/Session/Shared/ConversationCell.swift index 81ff07352..b72e839a3 100644 --- a/Session/Shared/ConversationCell.swift +++ b/Session/Shared/ConversationCell.swift @@ -214,9 +214,8 @@ final class ConversationCell : UITableViewCell { // MARK: - Updating for search results private func updateForSearchResult(_ threadViewModel: ThreadViewModel) { - AssertIsOnMainThread() - guard let thread = threadViewModel?.threadRecord else { return } - profilePictureView.update(for: thread) + GRDBStorage.shared.read { db in profilePictureView.update(db, thread: threadViewModel.thread) } + isPinnedIcon.isHidden = true unreadCountView.isHidden = true hasMentionView.isHidden = true diff --git a/SessionMessagingKit/Configuration.swift b/SessionMessagingKit/Configuration.swift index d69b554aa..cb62884f2 100644 --- a/SessionMessagingKit/Configuration.swift +++ b/SessionMessagingKit/Configuration.swift @@ -37,6 +37,7 @@ public enum SNMessagingKit { // Just to make the external API nice JobRunner.add(executor: MessageReceiveJob.self, for: .messageReceive) JobRunner.add(executor: NotifyPushServerJob.self, for: .notifyPushServer) JobRunner.add(executor: SendReadReceiptsJob.self, for: .sendReadReceipts) + JobRunner.add(executor: AttachmentDownloadJob.self, for: .attachmentDownload) SNMessagingKitConfiguration.shared = SNMessagingKitConfiguration(storage: storage) } diff --git a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift index 4a096cf4a..bb4cc6518 100644 --- a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -79,12 +79,17 @@ enum _001_InitialSetupMigration: Migration { } try db.create(table: ClosedGroupKeyPair.self) { t in - t.column(.publicKey, .text) + t.column(.threadId, .text) .notNull() .indexed() // Quicker querying .references(ClosedGroup.self, onDelete: .cascade) // Delete if ClosedGroup deleted + t.column(.publicKey, .blob).notNull() t.column(.secretKey, .blob).notNull() - t.column(.receivedTimestamp, .double).notNull() + t.column(.receivedTimestamp, .double) + .notNull() + .indexed() // Quicker querying + + t.uniqueKey([.publicKey, .secretKey, .receivedTimestamp]) } try db.create(table: OpenGroup.self) { t in @@ -217,6 +222,7 @@ enum _001_InitialSetupMigration: Migration { t.column(.creationTimestamp, .double) t.column(.sourceFilename, .text) t.column(.downloadUrl, .text) + t.column(.localRelativeFilePath, .text) t.column(.width, .integer) t.column(.height, .integer) t.column(.encryptionKey, .blob) @@ -291,6 +297,9 @@ enum _001_InitialSetupMigration: Migration { t.column(.threadId, .text) .indexed() // Quicker querying .references(SessionThread.self, onDelete: .cascade) // Delete if thread deleted + t.column(.interactionId, .text) + .indexed() // Quicker querying + .references(Interaction.self, onDelete: .cascade) // Delete if interaction deleted t.column(.details, .blob) } } diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 7868ba662..e14d89ff9 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -18,10 +18,11 @@ enum _003_YDBToGRDBMigration: Migration { var contacts: Set = [] var contactThreadIds: Set = [] + var legacyThreadIdToIdMap: [String: String] = [:] var threads: Set = [] var disappearingMessagesConfiguration: [String: Legacy.DisappearingMessagesConfiguration] = [:] - var closedGroupKeys: [String: (timestamp: TimeInterval, keys: SessionUtilitiesKit.Legacy.KeyPair)] = [:] + var closedGroupKeys: [String: [TimeInterval: SessionUtilitiesKit.Legacy.KeyPair]] = [:] var closedGroupName: [String: String] = [:] var closedGroupFormation: [String: UInt64] = [:] var closedGroupModel: [String: TSGroupModel] = [:] @@ -67,10 +68,11 @@ enum _003_YDBToGRDBMigration: Migration { .asType(Legacy.DisappearingMessagesConfiguration.self) .defaulting(to: Legacy.DisappearingMessagesConfiguration.defaultWith(threadId)) - // Process the interactions - // Process group-specific info - guard let groupThread: TSGroupThread = thread as? TSGroupThread else { return } + guard let groupThread: TSGroupThread = thread as? TSGroupThread else { + legacyThreadIdToIdMap[threadId] = threadId.substring(from: Legacy.contactThreadPrefix.count) + return + } if groupThread.isClosedGroup { // The old threadId for closed groups was in the below format, we don't @@ -96,6 +98,7 @@ enum _003_YDBToGRDBMigration: Migration { let keyCollection: String = "\(Legacy.closedGroupKeyPairPrefix)\(publicKey)" + legacyThreadIdToIdMap[threadId] = publicKey closedGroupName[threadId] = groupThread.name(with: transaction) closedGroupModel[threadId] = groupThread.groupModel closedGroupFormation[threadId] = ((transaction.object(forKey: publicKey, inCollection: Legacy.closedGroupFormationTimestampCollection) as? UInt64) ?? 0) @@ -109,7 +112,8 @@ enum _003_YDBToGRDBMigration: Migration { return } - closedGroupKeys[threadId] = (timestamp, keyPair) + closedGroupKeys[threadId] = (closedGroupKeys[threadId] ?? [:]) + .setting(timestamp, keyPair) } } else if groupThread.isOpenGroup { @@ -119,6 +123,10 @@ enum _003_YDBToGRDBMigration: Migration { return } + legacyThreadIdToIdMap[threadId] = OpenGroup.idFor( + room: openGroup.room, + server: openGroup.server + ) openGroupInfo[threadId] = openGroup openGroupUserCount[threadId] = ((transaction.object(forKey: openGroup.id, inCollection: Legacy.openGroupUserCountCollection) as? Int) ?? 0) openGroupImage[threadId] = transaction.object(forKey: openGroup.id, inCollection: Legacy.openGroupImageCollection) as? Data @@ -163,101 +171,6 @@ enum _003_YDBToGRDBMigration: Migration { .union(timestampsMs) } - /* - guard let view = transaction.ext(viewName) as? YapDatabaseAutoViewTransaction else { - owsFailDebug("Could not load view.") - return - } - guard let group = group else { - owsFailDebug("No group.") - return - } - - // Deserializing interactions is expensive, so we only - // do that when necessary. - let sortIdForItemId: (String) -> UInt64? = { (itemId) in - guard let interaction = TSInteraction.fetch(uniqueId: itemId, transaction: transaction) else { - owsFailDebug("Could not load interaction.") - return nil - } - return interaction.sortId - } - self.viewName = TSMessageDatabaseViewExtensionName - self.group = group - // If we have a "pivot", load all items AFTER the pivot and up to minDesiredLength items BEFORE the pivot. - // If we do not have a "pivot", load up to minDesiredLength BEFORE the pivot. - var newItemIds = [ItemId]() - var canLoadMore = false - let desiredLength = self.desiredLength - // Not all items "count" towards the desired length. On an initial load, all items count. Subsequently, - // only items above the pivot count. - var afterPivotCount: UInt = 0 - var beforePivotCount: UInt = 0 - // (void (^)(NSString *collection, NSString *key, id object, NSUInteger index, BOOL *stop))block; - view.enumerateKeys(inGroup: group, with: NSEnumerationOptions.reverse) { (_, key, _, stop) in - let itemId = key - - // Load "uncounted" items after the pivot if possible. - // - // As an optimization, we can skip this check (which requires - // deserializing the interaction) if beforePivotCount is non-zero, - // e.g. after we "pass" the pivot. - if beforePivotCount == 0, - let pivotSortId = self.pivotSortId { - if let sortId = sortIdForItemId(itemId) { - let isAfterPivot = sortId > pivotSortId - if isAfterPivot { - newItemIds.append(itemId) - afterPivotCount += 1 - return - } - } else { - owsFailDebug("Could not determine sort id for interaction: \(itemId)") - } - } - - // Load "counted" items unless the load window overflows. - if beforePivotCount >= desiredLength { - // Overflow - canLoadMore = true - stop.pointee = true - } else { - newItemIds.append(itemId) - beforePivotCount += 1 - } - } - NSMutableSet *interactionIds = [NSMutableSet new]; - [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - NSMutableArray *interactions = [NSMutableArray new]; - - YapDatabaseViewTransaction *viewTransaction = [transaction ext:TSMessageDatabaseViewExtensionName]; - OWSAssertDebug(viewTransaction); - for (NSString *uniqueId in loadedUniqueIds) { - TSInteraction *_Nullable interaction = - [TSInteraction fetchObjectWithUniqueID:uniqueId transaction:transaction]; - if (!interaction) { - OWSFailDebug(@"missing interaction in message mapping: %@.", uniqueId); - hasError = YES; - continue; - } - if (!interaction.uniqueId) { - OWSFailDebug(@"invalid interaction in message mapping: %@.", interaction); - hasError = YES; - continue; - } - [interactions addObject:interaction]; - if ([interactionIds containsObject:interaction.uniqueId]) { - OWSFailDebug(@"Duplicate interaction: %@", interaction.uniqueId); - continue; - } - [interactionIds addObject:interaction.uniqueId]; - } - - for (TSInteraction *interaction in interactions) { - tryToAddViewItem(interaction, transaction); - } - }]; - */ } // We can't properly throw within the 'enumerateKeysAndObjects' block so have to throw here @@ -311,11 +224,19 @@ enum _003_YDBToGRDBMigration: Migration { // MARK: - Insert Threads print("RAWR [\(Date().timeIntervalSince1970)] - Process thread inserts - Start") - var legacyThreadIdToIdMap: [String: String] = [:] var legacyInteractionToIdMap: [String: Int64] = [:] var legacyInteractionIdentifierToIdMap: [String: Int64] = [:] + var legacyInteractionIdentifierToIdFallbackMap: [String: Int64] = [:] + var legacyAttachmentToIdMap: [String: String] = [:] - func identifier(for threadId: String, sentTimestamp: UInt64, recipients: [String], destination: Message.Destination? = nil) -> String { + func identifier( + for threadId: String, + sentTimestamp: UInt64, + recipients: [String], + destination: Message.Destination?, + variant: Interaction.Variant?, + useFallback: Bool + ) -> String { let recipientString: String = { if let destination: Message.Destination = destination { switch destination { @@ -328,41 +249,34 @@ enum _003_YDBToGRDBMigration: Migration { }() return [ - "\(sentTimestamp)", + (useFallback ? + // Fallback to seconds-based accuracy (instead of milliseconds) + String("\(sentTimestamp)".prefix("\(Int(Date().timeIntervalSince1970))".count)) : + "\(sentTimestamp)" + ), + (useFallback ? variant.map { "\($0)" } : nil), recipientString, threadId ] + .compactMap { $0 } .joined(separator: "-") } try threads.forEach { thread in - guard let legacyThreadId: String = thread.uniqueId else { return } + guard + let legacyThreadId: String = thread.uniqueId, + let threadId: String = legacyThreadIdToIdMap[legacyThreadId] + else { + SNLog("[Migration Error] Unable to migrate thread with no id mapping") + throw GRDBStorageError.migrationFailed + } - let id: String - let variant: SessionThread.Variant + let threadVariant: SessionThread.Variant let notificationMode: SessionThread.NotificationMode switch thread { case let groupThread as TSGroupThread: - if groupThread.isOpenGroup { - guard let openGroup: OpenGroupV2 = openGroupInfo[legacyThreadId] else { - SNLog("[Migration Error] Open group missing required data") - throw GRDBStorageError.migrationFailed - } - - id = openGroup.id - variant = .openGroup - } - else { - guard let publicKey: Data = closedGroupKeys[legacyThreadId]?.keys.publicKey else { - SNLog("[Migration Error] Closed group missing public key") - throw GRDBStorageError.migrationFailed - } - - id = publicKey.toHexString() - variant = .closedGroup - } - + threadVariant = (groupThread.isOpenGroup ? .openGroup : .closedGroup) notificationMode = (thread.isMuted ? .none : (groupThread.isOnlyNotifyingForMentions ? .mentionsOnly : @@ -371,17 +285,14 @@ enum _003_YDBToGRDBMigration: Migration { ) default: - id = legacyThreadId.substring(from: Legacy.contactThreadPrefix.count) - variant = .contact + threadVariant = .contact notificationMode = (thread.isMuted ? .none : .all) } try autoreleasepool { - legacyThreadIdToIdMap[thread.uniqueId ?? ""] = id - try SessionThread( - id: id, - variant: variant, + id: threadId, + variant: threadVariant, creationDateTimestamp: thread.creationDate.timeIntervalSince1970, shouldBeVisible: thread.shouldBeVisible, isPinned: thread.isPinned, @@ -391,9 +302,9 @@ enum _003_YDBToGRDBMigration: Migration { ).insert(db) // Disappearing Messages Configuration - if let config: Legacy.DisappearingMessagesConfiguration = disappearingMessagesConfiguration[id] { + if let config: Legacy.DisappearingMessagesConfiguration = disappearingMessagesConfiguration[threadId] { try DisappearingMessagesConfiguration( - threadId: id, + threadId: threadId, isEnabled: config.isEnabled, durationSeconds: TimeInterval(config.durationSeconds) ).insert(db) @@ -402,7 +313,7 @@ enum _003_YDBToGRDBMigration: Migration { // Closed Groups if (thread as? TSGroupThread)?.isClosedGroup == true { guard - let keyInfo = closedGroupKeys[legacyThreadId], + let legacyKeys = closedGroupKeys[legacyThreadId], let name: String = closedGroupName[legacyThreadId], let groupModel: TSGroupModel = closedGroupModel[legacyThreadId], let formationTimestamp: UInt64 = closedGroupFormation[legacyThreadId] @@ -412,20 +323,23 @@ enum _003_YDBToGRDBMigration: Migration { } try ClosedGroup( - threadId: id, + threadId: threadId, name: name, formationTimestamp: TimeInterval(formationTimestamp) ).insert(db) - try ClosedGroupKeyPair( - publicKey: keyInfo.keys.publicKey.toHexString(), - secretKey: keyInfo.keys.privateKey, - receivedTimestamp: keyInfo.timestamp - ).insert(db) + try legacyKeys.forEach { timestamp, legacyKeys in + try ClosedGroupKeyPair( + threadId: threadId, + publicKey: legacyKeys.publicKey, + secretKey: legacyKeys.privateKey, + receivedTimestamp: timestamp + ).insert(db) + } try groupModel.groupMemberIds.forEach { memberId in try GroupMember( - groupId: id, + groupId: threadId, profileId: memberId, role: .standard ).insert(db) @@ -433,7 +347,7 @@ enum _003_YDBToGRDBMigration: Migration { try groupModel.groupAdminIds.forEach { adminId in try GroupMember( - groupId: id, + groupId: threadId, profileId: adminId, role: .admin ).insert(db) @@ -441,7 +355,7 @@ enum _003_YDBToGRDBMigration: Migration { try (closedGroupZombieMemberIds[legacyThreadId] ?? []).forEach { zombieId in try GroupMember( - groupId: id, + groupId: threadId, profileId: zombieId, role: .zombie ).insert(db) @@ -601,7 +515,7 @@ enum _003_YDBToGRDBMigration: Migration { // Insert the data let interaction: Interaction = try Interaction( serverHash: serverHash, - threadId: id, + threadId: threadId, authorId: authorId, variant: variant, body: body, @@ -624,12 +538,25 @@ enum _003_YDBToGRDBMigration: Migration { // Store the interactionId in the lookup map to simplify job creation later let legacyIdentifier: String = identifier( - for: legacyInteraction.uniqueThreadId, + for: threadId, sentTimestamp: legacyInteraction.timestamp, - recipients: ((legacyInteraction as? TSOutgoingMessage)?.recipientIds() ?? []) + recipients: ((legacyInteraction as? TSOutgoingMessage)?.recipientIds() ?? []), + destination: (threadVariant == .contact ? .contact(publicKey: threadId) : nil), + variant: variant, + useFallback: false ) + let legacyIdentifierFallback: String = identifier( + for: threadId, + sentTimestamp: legacyInteraction.timestamp, + recipients: ((legacyInteraction as? TSOutgoingMessage)?.recipientIds() ?? []), + destination: (threadVariant == .contact ? .contact(publicKey: threadId) : nil), + variant: variant, + useFallback: true + ) + legacyInteractionToIdMap[legacyInteraction.uniqueId ?? ""] = interactionId legacyInteractionIdentifierToIdMap[legacyIdentifier] = interactionId + legacyInteractionIdentifierToIdFallbackMap[legacyIdentifierFallback] = interactionId // Handle the recipient states @@ -678,12 +605,24 @@ enum _003_YDBToGRDBMigration: Migration { throw GRDBStorageError.migrationFailed } + // Setup the attachment and add it to the lookup (if it exists) + let attachmentId: String? = try attachmentId( + db, + for: quoteAttachmentId, + attachments: attachments + ) + + if let quoteAttachmentId: String = quoteAttachmentId, let attachmentId: String = attachmentId { + legacyAttachmentToIdMap[quoteAttachmentId] = attachmentId + } + + // Create the quote try Quote( interactionId: interactionId, authorId: quotedMessage.authorId, timestampMs: Int64(quotedMessage.timestamp), body: quotedMessage.body, - attachmentId: try attachmentId(db, for: quoteAttachmentId, attachments: attachments) + attachmentId: attachmentId ).insert(db) } @@ -699,6 +638,17 @@ enum _003_YDBToGRDBMigration: Migration { throw GRDBStorageError.migrationFailed } + // Setup the attachment and add it to the lookup (if it exists) + let attachmentId: String? = try attachmentId( + db, + for: linkPreview.imageAttachmentId, + attachments: attachments + ) + + if let legacyAttachmentId: String = linkPreview.imageAttachmentId, let attachmentId: String = attachmentId { + legacyAttachmentToIdMap[legacyAttachmentId] = attachmentId + } + // Note: It's possible for there to be duplicate values here so we use 'save' // instead of insert (ie. upsert) try LinkPreview( @@ -706,13 +656,12 @@ enum _003_YDBToGRDBMigration: Migration { timestamp: timestamp, variant: linkPreviewVariant, title: linkPreview.title, - attachmentId: try attachmentId(db, for: linkPreview.imageAttachmentId, attachments: attachments) + attachmentId: attachmentId ).save(db) } // Handle any attachments - print("ASD \(attachmentIds)") try attachmentIds.forEach { legacyAttachmentId in guard let attachmentId: String = try attachmentId(db, for: legacyAttachmentId, interactionVariant: variant, attachments: attachments) else { // TODO: Is it possible to hit this case if an interaction hasn't been viewed? @@ -720,10 +669,13 @@ enum _003_YDBToGRDBMigration: Migration { throw GRDBStorageError.migrationFailed } + // Link the attachment to the interaction and add to the id lookup try InteractionAttachment( interactionId: interactionId, attachmentId: attachmentId ).insert(db) + + legacyAttachmentToIdMap[legacyAttachmentId] = attachmentId } } } @@ -760,6 +712,31 @@ enum _003_YDBToGRDBMigration: Migration { var attachmentUploadJobs: Set = [] var attachmentDownloadJobs: Set = [] + // Map the Legacy types for the NSKeyedUnarchiver + NSKeyedUnarchiver.setClass( + Legacy.NotifyPNServerJob.self, + forClassName: "SessionMessagingKit.NotifyPNServerJob" + ) + NSKeyedUnarchiver.setClass( + Legacy.NotifyPNServerJob.SnodeMessage.self, + forClassName: "SessionSnodeKit.SnodeMessage" + ) + NSKeyedUnarchiver.setClass( + Legacy.MessageSendJob.self, + forClassName: "SessionMessagingKit.SNMessageSendJob" + ) + NSKeyedUnarchiver.setClass( + Legacy.MessageReceiveJob.self, + forClassName: "SessionMessagingKit.MessageReceiveJob" + ) + NSKeyedUnarchiver.setClass( + Legacy.AttachmentUploadJob.self, + forClassName: "SessionMessagingKit.AttachmentUploadJob" + ) + NSKeyedUnarchiver.setClass( + Legacy.AttachmentDownloadJob.self, + forClassName: "SessionMessagingKit.AttachmentDownloadJob" + ) Storage.read { transaction in transaction.enumerateRows(inCollection: Legacy.notifyPushServerJobCollection) { _, object, _, _ in guard let job = object as? Legacy.NotifyPNServerJob else { return } @@ -798,16 +775,15 @@ enum _003_YDBToGRDBMigration: Migration { variant: .notifyPushServer, behaviour: .runOnce, nextRunTimestamp: 0, - details: String( - data: try JSONEncoder().encode( - SnodeMessage( - recipient: legacyJob.message.recipient, - data: legacyJob.message.data.description, // TODO: Test this (looks like it should be fine) - ttl: legacyJob.message.ttl, - timestampMs: legacyJob.message.timestamp - ) - ), - encoding: .utf8 + details: NotifyPushServerJob.Details( + message: SnodeMessage( + recipient: legacyJob.message.recipient, + // Note: The legacy type had 'LosslessStringConvertible' so we need + // to use '.description' to get it as a basic string + data: legacyJob.message.data.description, + ttl: legacyJob.message.ttl, + timestampMs: legacyJob.message.timestamp + ) ) )?.inserted(db) } @@ -823,20 +799,24 @@ enum _003_YDBToGRDBMigration: Migration { return } + // We need to extract the `threadId` from the legacyJob data as the new + // MessageReceiveJob requires it for multi-threading and garbage collection purposes + guard let envelope: SNProtoEnvelope = try? SNProtoEnvelope.parseData(legacyJob.data) else { + return + } + + let threadId: String? = MessageReceiver.extractSenderPublicKey(db, from: envelope) + _ = try Job( failureCount: legacyJob.failureCount, variant: .messageReceive, behaviour: .runOnce, nextRunTimestamp: 0, - details: String( - data: try JSONEncoder().encode( - MessageReceiveJob.Details( - data: legacyJob.data, - serverHash: legacyJob.serverHash, - isBackgroundPoll: legacyJob.isBackgroundPoll - ) - ), - encoding: .utf8 + threadId: threadId, + details: MessageReceiveJob.Details( + data: legacyJob.data, + serverHash: legacyJob.serverHash, + isBackgroundPoll: legacyJob.isBackgroundPoll ) )?.inserted(db) } @@ -848,26 +828,68 @@ enum _003_YDBToGRDBMigration: Migration { try autoreleasepool { try messageSendJobs.forEach { legacyJob in - let legacyIdentifier: String = identifier( - for: (legacyJob.message.threadID ?? ""), - sentTimestamp: (legacyJob.message.sentTimestamp ?? 0), - recipients: (legacyJob.message.recipient.map { [$0] } ?? []), - destination: legacyJob.destination - ) - - // Fetch the interaction this job should be associated with + // Fetch the threadId and interactionId this job should be associated with + let threadId: String = { + switch legacyJob.destination { + case .contact(let publicKey): return publicKey + case .closedGroup(let groupPublicKey): return groupPublicKey + case .openGroupV2(let room, let server): + return OpenGroup.idFor(room: room, server: server) + + case .openGroup: return "" + } + }() + let interactionId: Int64? = { + // The 'Legacy.Job' 'id' value was "(timestamp)(num jobs for this timestamp)" + // so we can reverse-engineer an approximate timestamp by extracting it from + // the id (this value is unlikely to match exactly though) + let fallbackTimestamp: UInt64 = legacyJob.id + .map { UInt64($0.prefix("\(Int(Date().timeIntervalSince1970 * 1000))".count)) } + .defaulting(to: 0) + let legacyIdentifier: String = identifier( + for: threadId, + sentTimestamp: (legacyJob.message.sentTimestamp ?? fallbackTimestamp), + recipients: (legacyJob.message.recipient.map { [$0] } ?? []), + destination: legacyJob.destination, + variant: nil, + useFallback: false + ) + + if let matchingId: Int64 = legacyInteractionIdentifierToIdMap[legacyIdentifier] { + return matchingId + } + + // If we didn't find the correct interaction then we need to try the "fallback" + // identifier which is less accurate (during testing this only happened for + // 'ExpirationTimerUpdate' send jobs) + let fallbackIdentifier: String = identifier( + for: threadId, + sentTimestamp: (legacyJob.message.sentTimestamp ?? fallbackTimestamp), + recipients: (legacyJob.message.recipient.map { [$0] } ?? []), + destination: legacyJob.destination, + variant: { + switch legacyJob.message { + case is ExpirationTimerUpdate: return .infoDisappearingMessagesUpdate + default: return nil + } + }(), + useFallback: true + ) + + return legacyInteractionIdentifierToIdFallbackMap[fallbackIdentifier] + }() let job: Job? = try Job( failureCount: legacyJob.failureCount, variant: .messageSend, behaviour: .runOnce, nextRunTimestamp: 0, - threadId: legacyThreadIdToIdMap[legacyJob.message.threadID ?? ""], + threadId: threadId, details: MessageSendJob.Details( - // Note: There are some cases where there isn't actually a link between the 'MessageSendJob' and - // it's associated interaction (ie. any ControlMessage), in these cases the 'interactionId' value - // will be nil - interactionId: legacyInteractionIdentifierToIdMap[legacyIdentifier], + // Note: There are some cases where there isn't a link between a + // 'MessageSendJob' and an interaction (eg. ConfigurationMessage), + // in these cases the 'interactionId' value will be nil + interactionId: interactionId, destination: legacyJob.destination, message: legacyJob.message ) @@ -893,15 +915,10 @@ enum _003_YDBToGRDBMigration: Migration { variant: .attachmentUpload, behaviour: .runOnce, nextRunTimestamp: 0, - details: String( - data: try JSONEncoder().encode( - AttachmentUploadJob.Details( - threadId: legacyJob.threadID, - attachmentId: legacyJob.attachmentID, - messageSendJobId: sendJobId - ) - ), - encoding: .utf8 + details: AttachmentUploadJob.Details( + threadId: legacyJob.threadID, + attachmentId: legacyJob.attachmentID, + messageSendJobId: sendJobId ) )?.inserted(db) } @@ -915,20 +932,20 @@ enum _003_YDBToGRDBMigration: Migration { SNLog("[Migration Error] attachmentDownload job unable to find interaction") throw GRDBStorageError.migrationFailed } - + guard let attachmentId: String = legacyAttachmentToIdMap[legacyJob.attachmentID] else { + SNLog("[Migration Error] attachmentDownload job unable to find attachment") + throw GRDBStorageError.migrationFailed + } + _ = try Job( failureCount: legacyJob.failureCount, variant: .attachmentDownload, behaviour: .runOnce, nextRunTimestamp: 0, - details: String( - data: try JSONEncoder().encode( - AttachmentDownloadJob.Details( - threadId: legacyJob.threadID, - attachmentId: legacyJob.attachmentID - ) - ), - encoding: .utf8 + threadId: legacyThreadIdToIdMap[legacyJob.threadID], + interactionId: interactionId, + details: AttachmentDownloadJob.Details( + attachmentId: attachmentId ) )?.inserted(db) } diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index 43f79190b..b6ec24101 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -21,6 +21,7 @@ public struct Attachment: Codable, Identifiable, FetchableRecord, PersistableRec case creationTimestamp case sourceFilename case downloadUrl + case localRelativeFilePath case width case height case encryptionKey @@ -81,6 +82,11 @@ public struct Attachment: Codable, Identifiable, FetchableRecord, PersistableRec /// **Note:** The url is a fully constructed url but the clients just extract the id from the end of the url to perform the actual download public let downloadUrl: String? + /// The file path for the attachment relative to the attachments folder + /// + /// **Note:** We store this path so that file path generation changes don’t break existing attachments + public let localRelativeFilePath: String? + /// The width of the attachment, this will be `null` for non-visual attachment types public let width: UInt? @@ -107,6 +113,7 @@ public struct Attachment: Codable, Identifiable, FetchableRecord, PersistableRec creationTimestamp: TimeInterval? = nil, sourceFilename: String? = nil, downloadUrl: String? = nil, + localRelativeFilePath: String? = nil, width: UInt? = nil, height: UInt? = nil, encryptionKey: Data? = nil, @@ -121,6 +128,7 @@ public struct Attachment: Codable, Identifiable, FetchableRecord, PersistableRec self.creationTimestamp = creationTimestamp self.sourceFilename = sourceFilename self.downloadUrl = downloadUrl + self.localRelativeFilePath = localRelativeFilePath self.width = width self.height = height self.encryptionKey = encryptionKey @@ -153,6 +161,7 @@ public struct Attachment: Codable, Identifiable, FetchableRecord, PersistableRec self.creationTimestamp = nil self.sourceFilename = nil self.downloadUrl = nil + self.localRelativeFilePath = nil self.width = imageSize.map { UInt(floor($0.width)) } self.height = imageSize.map { UInt(floor($0.height)) } self.encryptionKey = nil @@ -164,16 +173,41 @@ public struct Attachment: Codable, Identifiable, FetchableRecord, PersistableRec // MARK: - CustomStringConvertible extension Attachment: CustomStringConvertible { - public var description: String { + public static func description(for variant: Variant, contentType: String, sourceFilename: String?) -> String { if MIMETypeUtil.isAudio(contentType) { // a missing filename is the legacy way to determine if an audio attachment is // a voice note vs. other arbitrary audio attachments. - if variant == .voiceMessage || self.sourceFilename == nil || (self.sourceFilename?.count ?? 0) == 0 { + if variant == .voiceMessage || sourceFilename == nil || (sourceFilename?.count ?? 0) == 0 { return "🎙️ \("ATTACHMENT_TYPE_VOICE_MESSAGE".localized())" } } - return "\("ATTACHMENT".localized()) \(emojiForMimeType)" + return "\("ATTACHMENT".localized()) \(emoji(for: contentType))" + } + + public static func emoji(for contentType: String) -> String { + if MIMETypeUtil.isImage(contentType) { + return "📷" + } + else if MIMETypeUtil.isVideo(contentType) { + return "🎥" + } + else if MIMETypeUtil.isAudio(contentType) { + return "🎧" + } + else if MIMETypeUtil.isAnimated(contentType) { + return "🎡" + } + + return "📎" + } + + public var description: String { + return Attachment.description( + for: variant, + contentType: contentType, + sourceFilename: sourceFilename + ) } } @@ -183,7 +217,9 @@ public extension Attachment { func with( serverId: String? = nil, state: State? = nil, + creationTimestamp: TimeInterval? = nil, downloadUrl: String? = nil, + localRelativeFilePath: String? = nil, encryptionKey: Data? = nil, digest: Data? = nil ) -> Attachment { @@ -193,9 +229,10 @@ public extension Attachment { state: (state ?? self.state), contentType: contentType, byteCount: byteCount, - creationTimestamp: creationTimestamp, + creationTimestamp: (creationTimestamp ?? self.creationTimestamp), sourceFilename: sourceFilename, downloadUrl: (downloadUrl ?? self.downloadUrl), + localRelativeFilePath: (localRelativeFilePath ?? self.localRelativeFilePath), width: width, height: height, encryptionKey: (encryptionKey ?? self.encryptionKey), @@ -236,6 +273,7 @@ public extension Attachment { self.creationTimestamp = nil self.sourceFilename = proto.fileName self.downloadUrl = proto.url + self.localRelativeFilePath = nil self.width = (proto.hasWidth && proto.width > 0 ? UInt(proto.width) : nil) self.height = (proto.hasHeight && proto.height > 0 ? UInt(proto.height) : nil) self.encryptionKey = proto.key @@ -343,7 +381,7 @@ public extension Attachment { OWSFileSystem.appSharedDataDirectoryPath().appending("/Attachments") }() - private static var attachmentsFolder: String = { + internal static var attachmentsFolder: String = { let attachmentsFolder: String = sharedDataAttachmentsDirPath OWSFileSystem.ensureDirectoryExists(attachmentsFolder) @@ -357,22 +395,13 @@ public extension Attachment { return attachmentsFolder }() - private static func originalFilePath(id: String, mimeType: String, sourceFilename: String?) -> String? { - let maybeFilePath: String? = MIMETypeUtil.filePath( - forAttachment: id, // TODO: Can we avoid this??? + internal static func originalFilePath(id: String, mimeType: String, sourceFilename: String?) -> String? { + return MIMETypeUtil.filePath( + forAttachment: id, ofMIMEType: mimeType, sourceFilename: sourceFilename, inFolder: Attachment.attachmentsFolder ) - - guard let filePath: String = maybeFilePath else { return nil } - guard filePath.hasPrefix(Attachment.attachmentsFolder) else { return nil } - - let localRelativeFilePath: String = filePath.substring(from: Attachment.attachmentsFolder.count) - - guard !localRelativeFilePath.isEmpty else { return nil } - - return localRelativeFilePath } static func imageSize(contentType: String, originalFilePath: String) -> CGSize? { @@ -410,10 +439,6 @@ extension Attachment { ) } - var localRelativeFilePath: String? { - return originalFilePath?.substring(from: Attachment.attachmentsFolder.count) - } - var thumbnailsDirPath: String { // Thumbnails are written to the caches directory, so that iOS can // remove them if necessary @@ -435,23 +460,6 @@ extension Attachment { return UIImage(contentsOfFile: originalFilePath) } - var emojiForMimeType: String { - if MIMETypeUtil.isImage(contentType) { - return "📷" - } - else if MIMETypeUtil.isVideo(contentType) { - return "🎥" - } - else if MIMETypeUtil.isAudio(contentType) { - return "🎧" - } - else if MIMETypeUtil.isAnimated(contentType) { - return "🎡" - } - - return "📎" - } - var isImage: Bool { MIMETypeUtil.isImage(contentType) } var isVideo: Bool { MIMETypeUtil.isVideo(contentType) } var isAnimated: Bool { MIMETypeUtil.isAnimated(contentType) } @@ -530,4 +538,12 @@ extension Attachment { public func cloneAsThumbnail() -> Attachment { fatalError("TODO: Add this back") } + + public func write(data: Data) throws -> Bool { + guard let originalFilePath: String = originalFilePath else { return false } + + try data.write(to: URL(fileURLWithPath: originalFilePath)) + + return true + } } diff --git a/SessionMessagingKit/Database/Models/ClosedGroupKeyPair.swift b/SessionMessagingKit/Database/Models/ClosedGroupKeyPair.swift index 85eeb10da..509fa0c9e 100644 --- a/SessionMessagingKit/Database/Models/ClosedGroupKeyPair.swift +++ b/SessionMessagingKit/Database/Models/ClosedGroupKeyPair.swift @@ -4,22 +4,24 @@ import Foundation import GRDB import SessionUtilitiesKit -public struct ClosedGroupKeyPair: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct ClosedGroupKeyPair: Codable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "closedGroupKeyPair" } internal static let closedGroupForeignKey = ForeignKey( - [Columns.publicKey], + [Columns.threadId], to: [ClosedGroup.Columns.threadId] ) private static let closedGroup = belongsTo(ClosedGroup.self, using: closedGroupForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { + case threadId case publicKey case secretKey case receivedTimestamp } - public let publicKey: String + public let threadId: String + public let publicKey: Data public let secretKey: Data public let receivedTimestamp: TimeInterval @@ -32,10 +34,12 @@ public struct ClosedGroupKeyPair: Codable, FetchableRecord, PersistableRecord, T // MARK: - Initialization public init( - publicKey: String, + threadId: String, + publicKey: Data, secretKey: Data, receivedTimestamp: TimeInterval ) { + self.threadId = threadId self.publicKey = publicKey self.secretKey = secretKey self.receivedTimestamp = receivedTimestamp @@ -45,9 +49,9 @@ public struct ClosedGroupKeyPair: Codable, FetchableRecord, PersistableRecord, T // MARK: - GRDB Interactions public extension ClosedGroupKeyPair { - static func fetchLatestKeyPair(_ db: Database, publicKey: String) throws -> ClosedGroupKeyPair? { + static func fetchLatestKeyPair(_ db: Database, threadId: String) throws -> ClosedGroupKeyPair? { return try ClosedGroupKeyPair - .filter(Columns.publicKey == publicKey) + .filter(Columns.threadId == threadId) .order(Columns.receivedTimestamp.desc) .fetchOne(db) } diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 9b9d85b6f..deb9d7c72 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -309,9 +309,6 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu default: break } - - } - } public func delete(_ db: Database) throws -> Bool { @@ -394,7 +391,7 @@ public extension Interaction { func scheduleJobs(interactionIds: [Int64]) { // Add the 'DisappearingMessagesJob' if needed - this will update any expiring // messages `expiresStartedAtMs` values - JobRunner.add( + JobRunner.upsert( db, job: Job( variant: .disappearingMessages, @@ -541,6 +538,13 @@ public extension Interaction { case .standardIncomingDeleted: return "" case .standardIncoming, .standardOutgoing: + struct AttachmentDescriptionInfo: Decodable, FetchableRecord { + let id: String + let variant: Attachment.Variant + let contentType: String + let sourceFilename: String? + } + var bodyDescription: String? if let body: String = self.body, !body.isEmpty { @@ -548,14 +552,35 @@ public extension Interaction { } if bodyDescription == nil { - let maybeTextAttachment: Attachment? = try? attachments - .filter(Attachment.Columns.contentType == OWSMimeTypeOversizeTextMessage) - .fetchOne(db) + struct AttachmentBodyInfo: Decodable, FetchableRecord { + let id: String + let variant: Attachment.Variant + let contentType: String + let sourceFilename: String? + } + + let maybeTextInfo: AttachmentDescriptionInfo? = try? AttachmentDescriptionInfo + .fetchOne( + db, + attachments + .select( + Attachment.Columns.id, + Attachment.Columns.state, + Attachment.Columns.variant, + Attachment.Columns.contentType, + Attachment.Columns.sourceFilename + ) + .filter(Attachment.Columns.contentType == OWSMimeTypeOversizeTextMessage) + .filter(Attachment.Columns.state == Attachment.State.downloaded) + ) if - let attachment: Attachment = maybeTextAttachment, - attachment.state == .downloaded, - let filePath: String = attachment.originalFilePath, + let textInfo: AttachmentDescriptionInfo = maybeTextInfo, + let filePath: String = Attachment.originalFilePath( + id: textInfo.id, + mimeType: textInfo.contentType, + sourceFilename: textInfo.sourceFilename + ), let data: Data = try? Data(contentsOf: URL(fileURLWithPath: filePath)), let dataString: String = String(data: data, encoding: .utf8) { @@ -563,14 +588,25 @@ public extension Interaction { } } - var attachmentDescription: String? - let maybeMediaAttachment: Attachment? = try? attachments - .filter(Attachment.Columns.contentType != OWSMimeTypeOversizeTextMessage) - .fetchOne(db) - - if let attachment: Attachment = maybeMediaAttachment { - attachmentDescription = attachment.description - } + let attachmentDescription: String? = try? AttachmentDescriptionInfo + .fetchOne( + db, + attachments + .select( + Attachment.Columns.id, + Attachment.Columns.variant, + Attachment.Columns.contentType, + Attachment.Columns.sourceFilename + ) + .filter(Attachment.Columns.contentType != OWSMimeTypeOversizeTextMessage) + ) + .map { info -> String in + Attachment.description( + for: info.variant, + contentType: info.contentType, + sourceFilename: info.sourceFilename + ) + } if let attachmentDescription: String = attachmentDescription, @@ -627,9 +663,12 @@ public extension Interaction { } func state(_ db: Database) throws -> RecipientState.State { - let states: [RecipientState.State] = try recipientStates - .fetchAll(db) - .map { $0.state } + let states: [RecipientState.State] = try RecipientState.State + .fetchAll( + db, + recipientStates + .select(RecipientState.Columns.state) + ) var hasFailed: Bool = false for state in states { diff --git a/SessionMessagingKit/Database/Models/InteractionAttachment.swift b/SessionMessagingKit/Database/Models/InteractionAttachment.swift index ecfa280fb..b61007329 100644 --- a/SessionMessagingKit/Database/Models/InteractionAttachment.swift +++ b/SessionMessagingKit/Database/Models/InteractionAttachment.swift @@ -36,9 +36,11 @@ public struct InteractionAttachment: Codable, FetchableRecord, PersistableRecord // If we have an Attachment then check if this is the only type that is referencing it // and delete the Attachment if so let quoteUses: Int? = try? Quote + .select(Quote.Columns.attachmentId) .filter(Quote.Columns.attachmentId == attachmentId) .fetchCount(db) let linkPreviewUses: Int? = try? LinkPreview + .select(LinkPreview.Columns.attachmentId) .filter(LinkPreview.Columns.attachmentId == attachmentId) .fetchCount(db) diff --git a/SessionMessagingKit/Database/Models/Job.swift b/SessionMessagingKit/Database/Models/Job.swift index bdc87f812..a6159af22 100644 --- a/SessionMessagingKit/Database/Models/Job.swift +++ b/SessionMessagingKit/Database/Models/Job.swift @@ -9,9 +9,14 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer public static var databaseTableName: String { "job" } internal static let threadForeignKey = ForeignKey( [Columns.threadId], - to: [Interaction.Columns.threadId] + to: [SessionThread.Columns.id] + ) + internal static let interactionForeignKey = ForeignKey( + [Columns.interactionId], + to: [Interaction.Columns.id] ) internal static let thread = hasOne(SessionThread.self, using: Job.threadForeignKey) + internal static let interaction = hasOne(Interaction.self, using: Job.interactionForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -21,6 +26,7 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer case behaviour case nextRunTimestamp case threadId + case interactionId case details } @@ -101,11 +107,18 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer /// Seconds since epoch to indicate the next datetime that this job should run public let nextRunTimestamp: TimeInterval - /// The id of the thread this job is associated with + /// The id of the thread this job is associated with, if the associated thread is deleted this job will + /// also be deleted /// /// **Note:** This will only be populated for Jobs associated to threads public let threadId: String? + /// The id of the interaction this job is associated with, if the associated interaction is deleted this + /// job will also be deleted + /// + /// **Note:** This will only be populated for Jobs associated to interactions + public let interactionId: Int64? + /// JSON encoded data required for the job public let details: Data? @@ -115,6 +128,10 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer request(for: Job.thread) } + public var interaction: QueryInterfaceRequest { + request(for: Job.interaction) + } + // MARK: - Initialization fileprivate init( @@ -124,6 +141,7 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer behaviour: Behaviour, nextRunTimestamp: TimeInterval, threadId: String?, + interactionId: Int64?, details: Data? ) { self.id = id @@ -132,6 +150,7 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer self.behaviour = behaviour self.nextRunTimestamp = nextRunTimestamp self.threadId = threadId + self.interactionId = interactionId self.details = details } @@ -140,13 +159,15 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer variant: Variant, behaviour: Behaviour = .runOnce, nextRunTimestamp: TimeInterval = 0, - threadId: String? = nil + threadId: String? = nil, + interactionId: Int64? = nil ) { self.failureCount = failureCount self.variant = variant self.behaviour = behaviour self.nextRunTimestamp = nextRunTimestamp self.threadId = threadId + self.interactionId = interactionId self.details = nil } @@ -156,24 +177,20 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer behaviour: Behaviour = .runOnce, nextRunTimestamp: TimeInterval = 0, threadId: String? = nil, - details: T? = nil + interactionId: Int64? = nil, + details: T? ) { - let detailsData: Data? - - if let details: T = details { - guard let encodedDetails: Data = try? JSONEncoder().encode(details) else { return nil } - - detailsData = encodedDetails - } - else { - detailsData = nil - } + guard + let details: T = details, + let detailsData: Data = try? JSONEncoder().encode(details) + else { return nil } self.failureCount = failureCount self.variant = variant self.behaviour = behaviour self.nextRunTimestamp = nextRunTimestamp self.threadId = threadId + self.interactionId = interactionId self.details = detailsData } @@ -198,6 +215,7 @@ public extension Job { behaviour: behaviour, nextRunTimestamp: (nextRunTimestamp ?? self.nextRunTimestamp), threadId: threadId, + interactionId: interactionId, details: details ) } @@ -212,6 +230,7 @@ public extension Job { behaviour: behaviour, nextRunTimestamp: nextRunTimestamp, threadId: threadId, + interactionId: interactionId, details: detailsData ) } diff --git a/SessionMessagingKit/Database/Models/OpenGroup.swift b/SessionMessagingKit/Database/Models/OpenGroup.swift index 8d478415d..acaa31c76 100644 --- a/SessionMessagingKit/Database/Models/OpenGroup.swift +++ b/SessionMessagingKit/Database/Models/OpenGroup.swift @@ -93,7 +93,6 @@ public struct OpenGroup: Codable, Identifiable, FetchableRecord, PersistableReco userCount: Int, infoUpdates: Int ) { - // Always force the server to lowercase self.threadId = OpenGroup.idFor(room: room, server: server) self.server = server.lowercased() self.room = room diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index 7b8411041..068f41e29 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -40,14 +40,38 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, case mentionsOnly // Only applicable to group threads } + /// Unique identifier for a thread (formerly known as uniqueId) + /// + /// This value will depend on the variant: + /// **contact:** The contact id + /// **closedGroup:** The closed group public key + /// **openGroup:** The `\(server.lowercased()).\(room)` value public let id: String + + /// Enum indicating what type of thread this is public let variant: Variant + + /// A timestamp indicating when this thread was created public let creationDateTimestamp: TimeInterval + + /// A flag indicating whether the thread should be visible public let shouldBeVisible: Bool + + /// A flag indicating whether the thread is pinned public let isPinned: Bool + + /// The value the user started entering into the input field before they left the conversation screen public let messageDraft: String? + + /// The notification mode this thread is set to public let notificationMode: NotificationMode + + /// The sound which should be used when receiving a notification for this thread + /// + /// **Note:** If unset this will use the `Preferences.Sound.defaultNotificationSound` public let notificationSound: Preferences.Sound? + + /// Timestamp (seconds since epoch) for when this thread should stop being muted public let mutedUntilTimestamp: TimeInterval? // MARK: - Relationships @@ -142,14 +166,14 @@ public extension SessionThread { case .contact: return Profile.displayName(db, id: id) case .closedGroup: - guard let name: String = try? closedGroup.fetchOne(db)?.name, !name.isEmpty else { + guard let name: String = try? String.fetchOne(db, closedGroup.select(ClosedGroup.Columns.name)), !name.isEmpty else { return "Group" } return name case .openGroup: - guard let name: String = try? openGroup.fetchOne(db)?.name, !name.isEmpty else { + guard let name: String = try? String.fetchOne(db, openGroup.select(OpenGroup.Columns.name)), !name.isEmpty else { return "Group" } diff --git a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift deleted file mode 100644 index 7da24d920..000000000 --- a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift +++ /dev/null @@ -1,166 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionUtilitiesKit -import SessionSnodeKit -import SignalCoreKit - -public final class AttachmentDownloadJob : NSObject, Job, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility - public let attachmentID: String - public let tsMessageID: String - public let threadID: String - public var delegate: JobDelegate? - public var id: String? - public var failureCount: UInt = 0 - public var isDeferred = false - - public enum Error : LocalizedError { - case noAttachment - case invalidURL - - public var errorDescription: String? { - switch self { - case .noAttachment: return "No such attachment." - case .invalidURL: return "Invalid file URL." - } - } - } - - // MARK: Settings - public class var collection: String { return "AttachmentDownloadJobCollection" } - public static let maxFailureCount: UInt = 20 - - // MARK: Initialization - public init(attachmentID: String, tsMessageID: String, threadID: String) { - self.attachmentID = attachmentID - self.tsMessageID = tsMessageID - self.threadID = threadID - } - - // MARK: Coding - public init?(coder: NSCoder) { - guard let attachmentID = coder.decodeObject(forKey: "attachmentID") as! String?, - let tsMessageID = coder.decodeObject(forKey: "tsIncomingMessageID") as! String?, - let threadID = coder.decodeObject(forKey: "threadID") as! String?, - let id = coder.decodeObject(forKey: "id") as! String? else { return nil } - self.attachmentID = attachmentID - self.tsMessageID = tsMessageID - self.threadID = threadID - self.id = id - self.failureCount = coder.decodeObject(forKey: "failureCount") as! UInt? ?? 0 - self.isDeferred = coder.decodeBool(forKey: "isDeferred") - } - - public func encode(with coder: NSCoder) { - coder.encode(attachmentID, forKey: "attachmentID") - coder.encode(tsMessageID, forKey: "tsIncomingMessageID") - coder.encode(threadID, forKey: "threadID") - coder.encode(id, forKey: "id") - coder.encode(failureCount, forKey: "failureCount") - coder.encode(isDeferred, forKey: "isDeferred") - } - - // MARK: Running - public func execute() { - if let id = id { - JobQueue.currentlyExecutingJobs.insert(id) - } - guard !isDeferred else { return } - if TSAttachment.fetch(uniqueId: attachmentID) is TSAttachmentStream { - // FIXME: It's not clear * how * this happens, but apparently we can get to this point - // from time to time with an already downloaded attachment. - return handleSuccess() - } - guard let pointer = TSAttachment.fetch(uniqueId: attachmentID) as? TSAttachmentPointer else { - return handleFailure(error: Error.noAttachment) - } - let storage = SNMessagingKitConfiguration.shared.storage - storage.write(with: { transaction in - storage.setAttachmentState(to: .downloading, for: pointer, associatedWith: self.tsMessageID, using: transaction) - }, completion: { }) - let temporaryFilePath = URL(fileURLWithPath: OWSTemporaryDirectoryAccessibleAfterFirstAuth() + UUID().uuidString) - let handleFailure: (Swift.Error) -> Void = { error in // Intentionally capture self - OWSFileSystem.deleteFile(temporaryFilePath.absoluteString) - if let error = error as? Error, case .noAttachment = error { - storage.write(with: { transaction in - storage.setAttachmentState(to: .failed, for: pointer, associatedWith: self.tsMessageID, using: transaction) - }, completion: { }) - self.handlePermanentFailure(error: error) - } else if let error = error as? OnionRequestAPI.Error, case .httpRequestFailedAtDestination(let statusCode, _, _) = error, - statusCode == 400 { - // Otherwise, the attachment will show a state of downloading forever, - // and the message won't be able to be marked as read. - storage.write(with: { transaction in - storage.setAttachmentState(to: .failed, for: pointer, associatedWith: self.tsMessageID, using: transaction) - }, completion: { }) - // This usually indicates a file that has expired on the server, so there's no need to retry. - self.handlePermanentFailure(error: error) - } else { - self.handleFailure(error: error) - } - } - if let tsMessage = TSMessage.fetch(uniqueId: tsMessageID), let v2OpenGroup = storage.getV2OpenGroup(for: tsMessage.uniqueThreadId) { - guard let fileAsString = pointer.downloadURL.split(separator: "/").last, let file = UInt64(fileAsString) else { - return handleFailure(Error.invalidURL) - } - OpenGroupAPIV2.download(file, from: v2OpenGroup.room, on: v2OpenGroup.server).done(on: DispatchQueue.global(qos: .userInitiated)) { data in - self.handleDownloadedAttachment(data: data, temporaryFilePath: temporaryFilePath, pointer: pointer, failureHandler: handleFailure) - }.catch(on: DispatchQueue.global()) { error in - handleFailure(error) - } - } else { - guard let fileAsString = pointer.downloadURL.split(separator: "/").last, let file = UInt64(fileAsString) else { - return handleFailure(Error.invalidURL) - } - let useOldServer = pointer.downloadURL.contains(FileServerAPIV2.oldServer) - FileServerAPIV2.download(file, useOldServer: useOldServer).done(on: DispatchQueue.global(qos: .userInitiated)) { data in - self.handleDownloadedAttachment(data: data, temporaryFilePath: temporaryFilePath, pointer: pointer, failureHandler: handleFailure) - }.catch(on: DispatchQueue.global()) { error in - handleFailure(error) - } - } - } - - private func handleDownloadedAttachment(data: Data, temporaryFilePath: URL, pointer: TSAttachmentPointer, failureHandler: (Swift.Error) -> Void) { - let storage = SNMessagingKitConfiguration.shared.storage - do { - try data.write(to: temporaryFilePath, options: .atomic) - } catch { - return failureHandler(error) - } - let plaintext: Data - if let key = pointer.encryptionKey, let digest = pointer.digest, key.count > 0 && digest.count > 0 { - do { - plaintext = try Cryptography.decryptAttachment(data, withKey: key, digest: digest, unpaddedSize: pointer.byteCount) - } catch { - return failureHandler(error) - } - } else { - plaintext = data // Open group attachments are unencrypted - } - let stream = TSAttachmentStream(pointer: pointer) - do { - try stream.write(plaintext) - } catch { - return failureHandler(error) - } - OWSFileSystem.deleteFile(temporaryFilePath.absoluteString) - storage.write(with: { transaction in - storage.persist(stream, associatedWith: self.tsMessageID, using: transaction) - }, completion: { - self.handleSuccess() - }) - } - - private func handleSuccess() { - delegate?.handleJobSucceeded(self) - } - - private func handlePermanentFailure(error: Swift.Error) { - delegate?.handleJobFailedPermanently(self, with: error) - } - - private func handleFailure(error: Swift.Error) { - delegate?.handleJobFailed(self, with: error) - } -} diff --git a/SessionMessagingKit/Jobs/JobRunner.swift b/SessionMessagingKit/Jobs/JobRunner.swift index 9049b4086..0f9d154b3 100644 --- a/SessionMessagingKit/Jobs/JobRunner.swift +++ b/SessionMessagingKit/Jobs/JobRunner.swift @@ -8,6 +8,7 @@ import SessionUtilitiesKit public protocol JobExecutor { static var maxFailureCount: UInt { get } static var requiresThreadId: Bool { get } + static var requiresInteractionId: Bool { get } /// This method contains the logic needed to complete a job /// @@ -35,10 +36,11 @@ public final class JobRunner { private class Trigger { private var timer: Timer? - static func create(timestamp: TimeInterval) -> Trigger { + static func create(timestamp: TimeInterval) -> Trigger? { + // Setup the trigger (wait at least 1 second before triggering) let trigger: Trigger = Trigger() trigger.timer = Timer.scheduledTimer( - timeInterval: timestamp, + timeInterval: max(1, (timestamp - Date().timeIntervalSince1970)), target: self, selector: #selector(start), userInfo: nil, @@ -57,7 +59,6 @@ public final class JobRunner { // TODO: Could this be a bottleneck? (single serial queue to process all these jobs? Group by thread?) // TODO: Multi-thread support - private static let minRetryInterval: TimeInterval = 1 private static let queueKey: DispatchSpecificKey = DispatchSpecificKey() private static let queueContext: String = "JobRunner" private static let internalQueue: DispatchQueue = { @@ -82,6 +83,11 @@ public final class JobRunner { // MARK: - Execution + /// Add a job onto the queue, if the queue isn't currently running and 'canStartJob' is true then this will start + /// the JobRunner + /// + /// **Note:** If the job has a `behaviour` of `runOnceNextLaunch` or the `nextRunTimestamp` + /// is in the future then the job won't be started public static func add(_ db: Database, job: Job?, canStartJob: Bool = true) { // Store the job into the database (getting an id for it) guard let updatedJob: Job = try? job?.inserted(db) else { @@ -89,10 +95,12 @@ public final class JobRunner { return } - switch (canStartJob, updatedJob.behaviour) { - case (false, _), (_, .runOnceNextLaunch): return - default: break - } + // Check if the job should be added to the queue + guard + canStartJob, + updatedJob.behaviour != .runOnceNextLaunch, + updatedJob.nextRunTimestamp <= Date().timeIntervalSince1970 + else { return } jobQueue.mutate { $0.append(updatedJob) } @@ -104,6 +112,11 @@ public final class JobRunner { } } + /// Upsert a job onto the queue, if the queue isn't currently running and 'canStartJob' is true then this will start + /// the JobRunner + /// + /// **Note:** If the job has a `behaviour` of `runOnceNextLaunch` or the `nextRunTimestamp` + /// is in the future then the job won't be started public static func upsert(_ db: Database, job: Job?, canStartJob: Bool = true) { guard let job: Job = job else { return } // Ignore null jobs guard let jobId: Int64 = job.id else { @@ -113,6 +126,9 @@ public final class JobRunner { // Lock the queue while checking the index and inserting to ensure we don't run into // any multi-threading shenanigans + // + // Note: currently running jobs are removed from the queue so we don't need to check + // the 'jobsCurrentlyRunning' set var didUpdateExistingJob: Bool = false jobQueue.mutate { queue in @@ -230,15 +246,28 @@ public final class JobRunner { .fetchAll(db) } + // Determine the number of jobs to run + var jobCount: Int = 0 + + jobQueue.mutate { queue in + // Add the jobs to the queue + if let jobsToRun: [Job] = maybeJobsToRun { + queue.append(contentsOf: jobsToRun) + } + + jobCount = queue.count + } + // If there are no pending jobs then schedule the JobRunner to start again // when the next scheduled job should start - guard let jobsToRun: [Job] = maybeJobsToRun else { + guard jobCount > 0 else { + isRunning.mutate { $0 = false } scheduleNextSoonestJob() return } - // Add the jobs to the queue and run the first job in the queue - jobQueue.mutate { $0.append(contentsOf: jobsToRun) } + // Run the first job in the queue + SNLog("[JobRunner] Starting with (\(jobCount) job\(jobCount != 1 ? "s" : ""))") runNextJob() } @@ -250,9 +279,9 @@ public final class JobRunner { } return } - guard let nextJob: Job = jobQueue.mutate({ $0.popFirst() }) else { - scheduleNextSoonestJob() + guard let (nextJob, numJobsRemaining): (Job, Int) = jobQueue.mutate({ queue in queue.popFirst().map { ($0, queue.count) } }) else { isRunning.mutate { $0 = false } + scheduleNextSoonestJob() return } guard let jobExecutor: JobExecutor.Type = executorMap.wrappedValue[nextJob.variant] else { @@ -265,13 +294,17 @@ public final class JobRunner { handleJobFailed(nextJob, error: JobRunnerError.requiredThreadIdMissing, permanentFailure: true) return } + guard !jobExecutor.requiresInteractionId || nextJob.interactionId != nil else { + SNLog("[JobRunner] Unable to run \(nextJob.variant) job due to missing required interactionId") + handleJobFailed(nextJob, error: JobRunnerError.requiredInteractionIdMissing, permanentFailure: true) + return + } // Update the state to indicate it's running // // Note: We need to store 'numJobsRemaining' in it's own variable because // the 'SNLog' seems to dispatch to it's own queue which ends up getting // blocked by the JobRunner's queue becuase 'jobQueue' is Atomic - let numJobsRemaining: Int = jobQueue.wrappedValue.count nextTrigger.mutate { $0 = nil } isRunning.mutate { $0 = true } jobsCurrentlyRunning.mutate { $0 = $0.inserting(nextJob.id) } @@ -286,19 +319,38 @@ public final class JobRunner { } private static func scheduleNextSoonestJob() { - let maybeJob: Job? = GRDBStorage.shared.read { db in - try Job - .filter( - [ - Job.Behaviour.runOnce, - Job.Behaviour.recurring - ].contains(Job.Columns.behaviour) - ) - .order(Job.Columns.nextRunTimestamp) - .fetchOne(db) + let nextJobTimestamp: TimeInterval? = GRDBStorage.shared + .read { db in + try TimeInterval + .fetchOne( + db, + Job + .select(Job.Columns.nextRunTimestamp) + .filter( + [ + Job.Behaviour.runOnce, + Job.Behaviour.recurring + ].contains(Job.Columns.behaviour) + ) + .order(Job.Columns.nextRunTimestamp) + ) + } + guard let nextJobTimestamp: TimeInterval = nextJobTimestamp else { return } + + // If the next job isn't scheduled in the future then just restart the JobRunner immediately + let secondsUntilNextJob: TimeInterval = (nextJobTimestamp - Date().timeIntervalSince1970) + guard secondsUntilNextJob > 0 else { + SNLog("[JobRunner] Restarting immediately for job scheduled \(Int(ceil(abs(secondsUntilNextJob)))) second\(Int(ceil(abs(secondsUntilNextJob))) == 1 ? "" : "s")) ago") + + internalQueue.async { + JobRunner.start() + } + return } - let targetTimestamp: TimeInterval = (maybeJob?.nextRunTimestamp ?? (Date().timeIntervalSince1970 + minRetryInterval)) - nextTrigger.mutate { $0 = Trigger.create(timestamp: targetTimestamp) } + + // Setup a trigger + SNLog("[JobRunner] Stopping until next job in \(Int(ceil(abs(secondsUntilNextJob))))) second\(Int(ceil(abs(secondsUntilNextJob))) == 1 ? "" : "s"))") + nextTrigger.mutate { $0 = Trigger.create(timestamp: nextJobTimestamp) } } // MARK: - Handling Results @@ -316,13 +368,14 @@ public final class JobRunner { try job.delete(db) } + // For `recurring` jobs which have already run, they should automatically run again + // but we want at least 1 second to pass before doing so - the job itself should + // really update it's own 'nextRunTimestamp' (this is just a safety net) case .recurring where job.nextRunTimestamp <= Date().timeIntervalSince1970: - // For `recurring` jobs we want the job to run again but want at least 1 second to pass GRDBStorage.shared.write { db in - var updatedJob: Job = job.with( - nextRunTimestamp: (Date().timeIntervalSince1970 + 1) - ) - try updatedJob.save(db) + _ = try job + .with(nextRunTimestamp: (Date().timeIntervalSince1970 + 1)) + .saved(db) } default: break diff --git a/SessionMessagingKit/Jobs/JobRunnerError.swift b/SessionMessagingKit/Jobs/JobRunnerError.swift index 0cedc9968..8a88fa80e 100644 --- a/SessionMessagingKit/Jobs/JobRunnerError.swift +++ b/SessionMessagingKit/Jobs/JobRunnerError.swift @@ -7,6 +7,7 @@ public enum JobRunnerError: Error { case executorMissing case requiredThreadIdMissing + case requiredInteractionIdMissing case missingRequiredDetails } diff --git a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift new file mode 100644 index 000000000..990b90273 --- /dev/null +++ b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift @@ -0,0 +1,309 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import PromiseKit +import SessionUtilitiesKit +import SessionSnodeKit +import SignalCoreKit + +public enum AttachmentDownloadJob: JobExecutor { + public static var maxFailureCount: UInt = 10 + public static var requiresThreadId: Bool = true + public static let requiresInteractionId: Bool = true + + public static func run( + _ job: Job, + success: @escaping (Job, Bool) -> (), + failure: @escaping (Job, Error?, Bool) -> (), + deferred: @escaping (Job) -> () + ) { + guard + let threadId: String = job.threadId, + let detailsData: Data = job.details, + let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData), + var attachment: Attachment = GRDBStorage.shared + .read({ db in try Attachment.fetchOne(db, id: details.attachmentId) }) + else { + failure(job, JobRunnerError.missingRequiredDetails, false) + return + } + guard attachment.state != .downloaded else { + // FIXME: It's not clear * how * this happens, but apparently we can get to this point from time to time with an already downloaded attachment. + success(job, false) + return + } + + // Update to the 'downloading' state + attachment = GRDBStorage.shared + .write { db in + try attachment + .with(state: .downloading) + .saved(db) + } + .defaulting(to: attachment) + + let temporaryFilePath: URL = URL( + fileURLWithPath: OWSTemporaryDirectoryAccessibleAfterFirstAuth() + UUID().uuidString + ) + let downloadPromise: Promise = { + guard + let downloadUrl: String = attachment.downloadUrl, + let fileAsString: String = downloadUrl.split(separator: "/").last.map({ String($0) }), + let file: UInt64 = UInt64(fileAsString) + else { + return Promise(error: AttachmentDownloadError.invalidUrl) + } + + if let openGroup: OpenGroup = GRDBStorage.shared.read({ db in try OpenGroup.fetchOne(db, id: threadId) }) { + return OpenGroupAPIV2.download(file, from: openGroup.room, on: openGroup.server) + } + + return FileServerAPIV2.download(file, useOldServer: downloadUrl.contains(FileServerAPIV2.oldServer)) + }() + + downloadPromise + .then { data -> Promise in + try data.write(to: temporaryFilePath, options: .atomic) + + let plaintext: Data = try { + guard + let key: Data = attachment.encryptionKey, + let digest: Data = attachment.digest, + key.count > 0, + digest.count > 0 + else { return data } // Open group attachments are unencrypted + + return try Cryptography.decryptAttachment( + data, + withKey: key, + digest: digest, + unpaddedSize: UInt32(attachment.byteCount) + ) + }() + + guard try attachment.write(data: plaintext) else { + throw AttachmentDownloadError.failedToSaveFile + } + + return Promise.value(()) + } + .done { + // Remove the temporary file + OWSFileSystem.deleteFile(temporaryFilePath.absoluteString) + + // Update the attachment state + GRDBStorage.shared.write { db in + try attachment + .with( + state: .downloaded, + creationTimestamp: Date().timeIntervalSince1970, + localRelativeFilePath: attachment.originalFilePath? + .substring(from: Attachment.attachmentsFolder.count) + ) + .save(db) + } + + success(job, false) + } + .catch { error in + OWSFileSystem.deleteFile(temporaryFilePath.absoluteString) + + switch error { + case OnionRequestAPI.Error.httpRequestFailedAtDestination(let statusCode, _, _) where statusCode == 400: + // Otherwise, the attachment will show a state of downloading forever, + // and the message won't be able to be marked as read + GRDBStorage.shared.write { db in + try attachment + .with(state: .failed) + .save(db) + } + + // This usually indicates a file that has expired on the server, so there's no need to retry + failure(job, error, true) + + default: + failure(job, error, false) + } + } + } +} + +// MARK: - AttachmentDownloadJob.Details + +extension AttachmentDownloadJob { + public struct Details: Codable { + public let attachmentId: String + } + + public enum AttachmentDownloadError: LocalizedError { + case failedToSaveFile + case invalidUrl + + public var errorDescription: String? { + switch self { + case .failedToSaveFile: return "Failed to save file" + case .invalidUrl: return "Invalid file URL" + } + } + } +} +// TODO: MessageInvalidator.invalidate(tsMessage, with: transaction) + +// public let attachmentID: String +// public let tsMessageID: String +// public let threadID: String +// public var delegate: JobDelegate? +// public var id: String? +// public var failureCount: UInt = 0 +// public var isDeferred = false +// +// public enum Error : LocalizedError { +// case noAttachment +// case invalidURL +// +// public var errorDescription: String? { +// switch self { +// case .noAttachment: return "No such attachment." +// case .invalidURL: return "Invalid file URL." +// } +// } +// } +// +// // MARK: Settings +// public class var collection: String { return "AttachmentDownloadJobCollection" } +// public static let maxFailureCount: UInt = 20 +// +// // MARK: Initialization +// public init(attachmentID: String, tsMessageID: String, threadID: String) { +// self.attachmentID = attachmentID +// self.tsMessageID = tsMessageID +// self.threadID = threadID +// } +// +// // MARK: Coding +// public init?(coder: NSCoder) { +// guard let attachmentID = coder.decodeObject(forKey: "attachmentID") as! String?, +// let tsMessageID = coder.decodeObject(forKey: "tsIncomingMessageID") as! String?, +// let threadID = coder.decodeObject(forKey: "threadID") as! String?, +// let id = coder.decodeObject(forKey: "id") as! String? else { return nil } +// self.attachmentID = attachmentID +// self.tsMessageID = tsMessageID +// self.threadID = threadID +// self.id = id +// self.failureCount = coder.decodeObject(forKey: "failureCount") as! UInt? ?? 0 +// self.isDeferred = coder.decodeBool(forKey: "isDeferred") +// } +// +// public func encode(with coder: NSCoder) { +// coder.encode(attachmentID, forKey: "attachmentID") +// coder.encode(tsMessageID, forKey: "tsIncomingMessageID") +// coder.encode(threadID, forKey: "threadID") +// coder.encode(id, forKey: "id") +// coder.encode(failureCount, forKey: "failureCount") +// coder.encode(isDeferred, forKey: "isDeferred") +// } +// +// // MARK: Running +// public func execute() { +// if let id = id { +// JobQueue.currentlyExecutingJobs.insert(id) +// } +// guard !isDeferred else { return } +// if TSAttachment.fetch(uniqueId: attachmentID) is TSAttachmentStream { +// // FIXME: It's not clear * how * this happens, but apparently we can get to this point +// // from time to time with an already downloaded attachment. +// return handleSuccess() +// } +// guard let pointer = TSAttachment.fetch(uniqueId: attachmentID) as? TSAttachmentPointer else { +// return handleFailure(error: Error.noAttachment) +// } +// let storage = SNMessagingKitConfiguration.shared.storage +// storage.write(with: { transaction in +// storage.setAttachmentState(to: .downloading, for: pointer, associatedWith: self.tsMessageID, using: transaction) +// }, completion: { }) +// let temporaryFilePath = URL(fileURLWithPath: OWSTemporaryDirectoryAccessibleAfterFirstAuth() + UUID().uuidString) +// let handleFailure: (Swift.Error) -> Void = { error in // Intentionally capture self +// OWSFileSystem.deleteFile(temporaryFilePath.absoluteString) +// if let error = error as? Error, case .noAttachment = error { +// storage.write(with: { transaction in +// storage.setAttachmentState(to: .failed, for: pointer, associatedWith: self.tsMessageID, using: transaction) +// }, completion: { }) +// self.handlePermanentFailure(error: error) +// } else if let error = error as? OnionRequestAPI.Error, case .httpRequestFailedAtDestination(let statusCode, _, _) = error, +// statusCode == 400 { +// // Otherwise, the attachment will show a state of downloading forever, +// // and the message won't be able to be marked as read. +// storage.write(with: { transaction in +// storage.setAttachmentState(to: .failed, for: pointer, associatedWith: self.tsMessageID, using: transaction) +// }, completion: { }) +// // This usually indicates a file that has expired on the server, so there's no need to retry. +// self.handlePermanentFailure(error: error) +// } else { +// self.handleFailure(error: error) +// } +// } +// if let tsMessage = TSMessage.fetch(uniqueId: tsMessageID), let v2OpenGroup = storage.getV2OpenGroup(for: tsMessage.uniqueThreadId) { +// guard let fileAsString = pointer.downloadURL.split(separator: "/").last, let file = UInt64(fileAsString) else { +// return handleFailure(Error.invalidURL) +// } +// OpenGroupAPIV2.download(file, from: v2OpenGroup.room, on: v2OpenGroup.server).done(on: DispatchQueue.global(qos: .userInitiated)) { data in +// self.handleDownloadedAttachment(data: data, temporaryFilePath: temporaryFilePath, pointer: pointer, failureHandler: handleFailure) +// }.catch(on: DispatchQueue.global()) { error in +// handleFailure(error) +// } +// } else { +// guard let fileAsString = pointer.downloadURL.split(separator: "/").last, let file = UInt64(fileAsString) else { +// return handleFailure(Error.invalidURL) +// } +// let useOldServer = pointer.downloadURL.contains(FileServerAPIV2.oldServer) +// FileServerAPIV2.download(file, useOldServer: useOldServer).done(on: DispatchQueue.global(qos: .userInitiated)) { data in +// self.handleDownloadedAttachment(data: data, temporaryFilePath: temporaryFilePath, pointer: pointer, failureHandler: handleFailure) +// }.catch(on: DispatchQueue.global()) { error in +// handleFailure(error) +// } +// } +// } +// +// private func handleDownloadedAttachment(data: Data, temporaryFilePath: URL, pointer: TSAttachmentPointer, failureHandler: (Swift.Error) -> Void) { +// let storage = SNMessagingKitConfiguration.shared.storage +// do { +// try data.write(to: temporaryFilePath, options: .atomic) +// } catch { +// return failureHandler(error) +// } +// let plaintext: Data +// if let key = pointer.encryptionKey, let digest = pointer.digest, key.count > 0 && digest.count > 0 { +// do { +// plaintext = try Cryptography.decryptAttachment(data, withKey: key, digest: digest, unpaddedSize: pointer.byteCount) +// } catch { +// return failureHandler(error) +// } +// } else { +// plaintext = data // Open group attachments are unencrypted +// } +// let stream = TSAttachmentStream(pointer: pointer) +// do { +// try stream.write(plaintext) +// } catch { +// return failureHandler(error) +// } +// OWSFileSystem.deleteFile(temporaryFilePath.absoluteString) +// storage.write(with: { transaction in +// storage.persist(stream, associatedWith: self.tsMessageID, using: transaction) +// }, completion: { +// self.handleSuccess() +// }) +// } +// +// private func handleSuccess() { +// delegate?.handleJobSucceeded(self) +// } +// +// private func handlePermanentFailure(error: Swift.Error) { +// delegate?.handleJobFailedPermanently(self, with: error) +// } +// +// private func handleFailure(error: Swift.Error) { +// delegate?.handleJobFailed(self, with: error) +// } +//} diff --git a/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift b/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift index aa7b8569d..acc59cab1 100644 --- a/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift +++ b/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift @@ -7,6 +7,7 @@ import SessionUtilitiesKit public enum DisappearingMessagesJob: JobExecutor { public static let maxFailureCount: UInt = 0 public static let requiresThreadId: Bool = false + public static let requiresInteractionId: Bool = false public static func run( _ job: Job, @@ -64,7 +65,7 @@ public extension DisappearingMessagesJob { .saved(db) } - @discardableResult static func updateNextRunIfNeeded(_ db: Database, interactionIds: [Int64], startedAtMs: Double) -> Bool { + @discardableResult static func updateNextRunIfNeeded(_ db: Database, interactionIds: [Int64], startedAtMs: Double) -> Job? { // Update the expiring messages expiresStartedAtMs value let changeCount: Int? = try? Interaction .filter(interactionIds.contains(Interaction.Columns.id)) @@ -72,17 +73,17 @@ public extension DisappearingMessagesJob { .updateAll(db, Interaction.Columns.expiresStartedAtMs.set(to: startedAtMs)) // If there were no changes then none of the provided `interactionIds` are expiring messages - guard (changeCount ?? 0) > 0 else { return false } + guard (changeCount ?? 0) > 0 else { return nil } - return (updateNextRunIfNeeded(db) != nil) + return updateNextRunIfNeeded(db) } - @discardableResult static func updateNextRunIfNeeded(_ db: Database, interaction: Interaction, startedAtMs: Double) -> Bool { - guard interaction.isExpiringMessage else { return false } + @discardableResult static func updateNextRunIfNeeded(_ db: Database, interaction: Interaction, startedAtMs: Double) -> Job? { + guard interaction.isExpiringMessage else { return nil } // Don't clobber if multiple actions simultaneously triggered expiration guard interaction.expiresStartedAtMs == nil || (interaction.expiresStartedAtMs ?? 0) > startedAtMs else { - return false + return nil } do { @@ -94,7 +95,7 @@ public extension DisappearingMessagesJob { } catch { SNLog("Failed to update the expiring messages timer on an interaction") - return false + return nil } } } diff --git a/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift b/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift index ceb84c01b..2c10422da 100644 --- a/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift +++ b/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift @@ -8,6 +8,7 @@ import SessionUtilitiesKit public enum FailedAttachmentDownloadsJob: JobExecutor { public static let maxFailureCount: UInt = 0 public static let requiresThreadId: Bool = false + public static let requiresInteractionId: Bool = false public static func run( _ job: Job, diff --git a/SessionMessagingKit/Jobs/Types/FailedMessagesJob.swift b/SessionMessagingKit/Jobs/Types/FailedMessagesJob.swift index 0017d680c..30104c176 100644 --- a/SessionMessagingKit/Jobs/Types/FailedMessagesJob.swift +++ b/SessionMessagingKit/Jobs/Types/FailedMessagesJob.swift @@ -8,6 +8,7 @@ import SessionUtilitiesKit public enum FailedMessagesJob: JobExecutor { public static let maxFailureCount: UInt = 0 public static let requiresThreadId: Bool = false + public static let requiresInteractionId: Bool = false public static func run( _ job: Job, diff --git a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift index 759ffe2f1..59027e7e6 100644 --- a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift @@ -7,6 +7,7 @@ import SessionUtilitiesKit public enum MessageReceiveJob: JobExecutor { public static var maxFailureCount: UInt = 10 public static var requiresThreadId: Bool = true + public static let requiresInteractionId: Bool = false public static func run( _ job: Job, diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift index af927afc1..2f6dc5cc3 100644 --- a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift @@ -9,6 +9,7 @@ import SessionSnodeKit public enum MessageSendJob: JobExecutor { public static var maxFailureCount: UInt = 10 public static var requiresThreadId: Bool = true + public static let requiresInteractionId: Bool = false // Some messages don't have interactions public static func run( _ job: Job, diff --git a/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift b/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift index 17475fdf1..95f00bf27 100644 --- a/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift +++ b/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift @@ -8,6 +8,7 @@ import SessionUtilitiesKit public enum NotifyPushServerJob: JobExecutor { public static var maxFailureCount: UInt = 20 public static var requiresThreadId: Bool = false + public static let requiresInteractionId: Bool = false public static func run( _ job: Job, @@ -36,7 +37,7 @@ public enum NotifyPushServerJob: JobExecutor { "Content-Type": "application/json" ] - let promise = attempt(maxRetryCount: 4, recoveringOn: DispatchQueue.global()) { + attempt(maxRetryCount: 4, recoveringOn: DispatchQueue.global()) { OnionRequestAPI .sendOnionRequest( request, @@ -49,11 +50,10 @@ public enum NotifyPushServerJob: JobExecutor { .done { _ in success(job, false) } - - promise.catch { error in + .`catch` { error in failure(job, error, false) } - promise.retainUntilComplete() + .retainUntilComplete() } } diff --git a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift index 36c31c595..9f6d8dfd1 100644 --- a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift +++ b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift @@ -8,6 +8,7 @@ import SessionUtilitiesKit public enum SendReadReceiptsJob: JobExecutor { public static let maxFailureCount: UInt = 0 public static let requiresThreadId: Bool = false + public static let requiresInteractionId: Bool = false private static let minRunFrequency: TimeInterval = 3 public static func run( diff --git a/SessionMessagingKit/Meta/SessionMessagingKit.h b/SessionMessagingKit/Meta/SessionMessagingKit.h index c4d6d61b3..207086f95 100644 --- a/SessionMessagingKit/Meta/SessionMessagingKit.h +++ b/SessionMessagingKit/Meta/SessionMessagingKit.h @@ -22,7 +22,6 @@ FOUNDATION_EXPORT const unsigned char SessionMessagingKitVersionString[]; #import #import #import -#import #import #import #import diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift index 32f5aa9fa..5791f5e85 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift @@ -9,6 +9,10 @@ import SessionUtilitiesKit extension MessageReceiver { + /// Extract the sender public key (used as the threadId in contact threads) + /// + /// **Note:** This is a slightly optimised version of the `decryptWithSessionProtocol` function which just skips + /// the validation (handled when the job actually runs) and doesn't throw internal static func extractSenderPublicKey(_ db: Database, from envelope: SNProtoEnvelope) -> String? { guard let ciphertext: Data = envelope.content, diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index a8930b995..7746af472 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -594,8 +594,9 @@ extension MessageReceiver { db, job: Job( variant: .attachmentDownload, + threadId: thread.id, + interactionId: interactionId, details: AttachmentDownloadJob.Details( - threadId: thread.id, attachmentId: attachmentId ) ), @@ -818,7 +819,8 @@ extension MessageReceiver { Storage.shared.addClosedGroupPublicKey(groupPublicKey, using: transaction) // Store the key pair try ClosedGroupKeyPair( - publicKey: groupPublicKey, + threadId: groupPublicKey, + publicKey: Data(encryptionKeyPair.publicKey), secretKey: Data(encryptionKeyPair.secretKey), receivedTimestamp: Date().timeIntervalSince1970 ).insert(db) @@ -876,24 +878,18 @@ extension MessageReceiver { return SNLog("Couldn't parse closed group encryption key pair.") } - let keyPair: Box.KeyPair = Box.KeyPair( - publicKey: proto.publicKey.removing05PrefixIfNeeded().bytes, - secretKey: proto.privateKey.bytes - ) - - // Store it if needed - let keyPairs: [ClosedGroupKeyPair] = ((try? closedGroup.keyPairs.fetchAll(db)) ?? []) - let secretKeyData: Data = Data(keyPair.secretKey) - - guard !keyPairs.contains(where: { $0.secretKey == secretKeyData }) else { + do { + try ClosedGroupKeyPair( + threadId: groupPublicKey, + publicKey: proto.publicKey.removing05PrefixIfNeeded(), + secretKey: proto.privateKey, + receivedTimestamp: Date().timeIntervalSince1970 + ).insert(db) + } + catch { return SNLog("Ignoring duplicate closed group encryption key pair.") } - try ClosedGroupKeyPair( - publicKey: keyPair.publicKey.toHexString(), // Should match 'groupPublicKey' - secretKey: secretKeyData, - receivedTimestamp: Date().timeIntervalSince1970 - ).insert(db) SNLog("Received a new closed group encryption key pair.") } @@ -1019,8 +1015,8 @@ extension MessageReceiver { // • Stop polling for the group // • Remove the key pairs associated with the group // • Notify the PN server - let userPublicKey = getUserHexEncodedPublicKey() - let wasCurrentUserRemoved = !members.contains(userPublicKey) + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let wasCurrentUserRemoved: Bool = !members.contains(userPublicKey) if wasCurrentUserRemoved { ClosedGroupPoller.shared.stopPolling(for: id) diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index 719141e75..2fb678ac1 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -66,7 +66,7 @@ public enum MessageReceiver { return try decryptWithSessionProtocol( ciphertext: ciphertext, using: Box.KeyPair( - publicKey: Data(hex: keyPair.publicKey).bytes, + publicKey: keyPair.publicKey.bytes, secretKey: keyPair.secretKey.bytes ) ) diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift index da870d85c..c5c40756f 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift @@ -8,7 +8,7 @@ import PromiseKit import SessionUtilitiesKit extension MessageSender { - public static var distributingClosedGroupEncryptionKeyPairs: [String: [Box.KeyPair]] = [:] + public static var distributingKeyPairs: Atomic<[String: [ClosedGroupKeyPair]]> = Atomic([:]) public static func createClosedGroup(_ db: Database, name: String, members: Set) throws -> Promise { let userPublicKey: String = getUserHexEncodedPublicKey() @@ -18,10 +18,6 @@ extension MessageSender { let groupPublicKey = Curve25519.generateKeyPair().hexEncodedPublicKey // Includes the "05" prefix // Generate the key pair that'll be used for encryption and decryption let encryptionKeyPair = Curve25519.generateKeyPair() - let keyPair: Box.KeyPair = Box.KeyPair( - publicKey: encryptionKeyPair.publicKey.bytes, - secretKey: encryptionKeyPair.privateKey.bytes - ) // Create the group members.insert(userPublicKey) // Ensure the current user is included in the member list @@ -72,7 +68,10 @@ extension MessageSender { kind: .new( publicKey: Data(hex: groupPublicKey), name: name, - encryptionKeyPair: keyPair, + encryptionKeyPair: Box.KeyPair( + publicKey: encryptionKeyPair.publicKey.bytes, + secretKey: encryptionKeyPair.privateKey.bytes + ), members: membersAsData, admins: adminsAsData, expirationTimer: 0 @@ -86,8 +85,9 @@ extension MessageSender { // Store the key pair try ClosedGroupKeyPair( - publicKey: keyPair.publicKey.toHexString(), - secretKey: Data(keyPair.secretKey), + threadId: groupPublicKey, + publicKey: encryptionKeyPair.publicKey, + secretKey: encryptionKeyPair.privateKey, receivedTimestamp: Date().timeIntervalSince1970 ).insert(db) @@ -134,20 +134,25 @@ extension MessageSender { return Promise(error: MessageSenderError.invalidClosedGroupUpdate) } // Generate the new encryption key pair - let newLegacyKeyPair = Curve25519.generateKeyPair() - let newKeyPair: Box.KeyPair = Box.KeyPair( - publicKey: newLegacyKeyPair.publicKey.bytes, - secretKey: newLegacyKeyPair.privateKey.bytes + let legacyNewKeyPair: ECKeyPair = Curve25519.generateKeyPair() + let newKeyPair: ClosedGroupKeyPair = ClosedGroupKeyPair( + threadId: closedGroup.threadId, + publicKey: legacyNewKeyPair.publicKey, + secretKey: legacyNewKeyPair.privateKey, + receivedTimestamp: Date().timeIntervalSince1970 ) // Distribute it - let proto = try SNProtoKeyPair.builder(publicKey: Data(newKeyPair.publicKey), - privateKey: Data(newKeyPair.secretKey)).build() + let proto = try SNProtoKeyPair.builder( + publicKey: newKeyPair.publicKey, + privateKey: newKeyPair.secretKey + ).build() let plaintext = try proto.serializedData() - var distributingKeyPairs = (distributingClosedGroupEncryptionKeyPairs[closedGroup.id] ?? []) - distributingKeyPairs.append(newKeyPair) - distributingClosedGroupEncryptionKeyPairs[closedGroup.id] = distributingKeyPairs + distributingKeyPairs.mutate { + $0[closedGroup.id] = ($0[closedGroup.id] ?? []) + .appending(newKeyPair) + } do { return try MessageSender @@ -173,17 +178,13 @@ extension MessageSender { .done { /// Store it **after** having sent out the message to the group GRDBStorage.shared.write { db in - try ClosedGroupKeyPair( - publicKey: newKeyPair.publicKey.toHexString(), - secretKey: Data(newKeyPair.secretKey), - receivedTimestamp: Date().timeIntervalSince1970 - ).insert(db) + try newKeyPair.insert(db) - var distributingKeyPairs = (distributingClosedGroupEncryptionKeyPairs[closedGroup.id] ?? []) - - if let index = distributingKeyPairs.firstIndex(of: newKeyPair) { - distributingKeyPairs.remove(at: index) - distributingClosedGroupEncryptionKeyPairs[closedGroup.id] = distributingKeyPairs + distributingKeyPairs.mutate { + if let index = ($0[closedGroup.id] ?? []).firstIndex(of: newKeyPair) { + $0[closedGroup.id] = ($0[closedGroup.id] ?? []) + .removing(index: index) + } } } } @@ -607,26 +608,19 @@ extension MessageSender { } // Get the latest encryption key pair - var maybeEncryptionKeyPair: Box.KeyPair? = distributingClosedGroupEncryptionKeyPairs[groupPublicKey]?.last + var maybeKeyPair: ClosedGroupKeyPair? = distributingKeyPairs.wrappedValue[groupPublicKey]?.last - if maybeEncryptionKeyPair == nil { - guard let encryptionKeyPair: ClosedGroupKeyPair = try? closedGroup.fetchLatestKeyPair(db) else { - return - } - - maybeEncryptionKeyPair = Box.KeyPair( - publicKey: Data(hex: encryptionKeyPair.publicKey).bytes, - secretKey: encryptionKeyPair.secretKey.bytes - ) + if maybeKeyPair == nil { + maybeKeyPair = try? closedGroup.fetchLatestKeyPair(db) } - guard let encryptionKeyPair: Box.KeyPair = maybeEncryptionKeyPair else { return } + guard let keyPair: ClosedGroupKeyPair = maybeKeyPair else { return } // Send it do { let proto = try SNProtoKeyPair.builder( - publicKey: Data(encryptionKeyPair.publicKey), - privateKey: Data(encryptionKeyPair.secretKey) + publicKey: keyPair.publicKey, + privateKey: keyPair.secretKey ).build() let plaintext = try proto.serializedData() let thread: SessionThread = try SessionThread diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index ea279c12f..8f2246a54 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -182,11 +182,14 @@ public final class MessageSender : NSObject { ciphertext = try encryptWithSessionProtocol(plaintext, for: publicKey) case .closedGroup(let groupPublicKey): - guard let encryptionKeyPair: ClosedGroupKeyPair = try? ClosedGroupKeyPair.fetchLatestKeyPair(db, publicKey: groupPublicKey) else { + guard let encryptionKeyPair: ClosedGroupKeyPair = try? ClosedGroupKeyPair.fetchLatestKeyPair(db, threadId: groupPublicKey) else { throw MessageSenderError.noKeyPair } - ciphertext = try encryptWithSessionProtocol(plaintext, for: "05\(encryptionKeyPair.publicKey)") + ciphertext = try encryptWithSessionProtocol( + plaintext, + for: "05\(encryptionKeyPair.publicKey.toHexString())" + ) case .openGroup(_, _), .openGroupV2(_, _): preconditionFailure() } @@ -357,7 +360,7 @@ public final class MessageSender : NSObject { #if DEBUG preconditionFailure() #else - handleFailure(with: Error.invalidMessage, using: transaction) + handleFailure(db, with: MessageSenderError.invalidMessage) return promise #endif } @@ -461,10 +464,16 @@ public final class MessageSender : NSObject { NotificationCenter.default.post(name: .messageSentStatusDidChange, object: nil, userInfo: nil) // Start the disappearing messages timer if needed - DisappearingMessagesJob.updateNextRunIfNeeded( + JobRunner.upsert( db, - interaction: interaction, - startedAtMs: (Date().timeIntervalSince1970 * 1000) + job: Job( + variant: .disappearingMessages, + details: DisappearingMessagesJob.updateNextRunIfNeeded( + db, + interaction: interaction, + startedAtMs: (Date().timeIntervalSince1970 * 1000) + ) + ) ) } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift index 61786bc41..3ff6d224f 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift @@ -109,11 +109,8 @@ public final class Poller : NSObject { messages.forEach { message in guard let envelope = SNProtoEnvelope.from(message) else { return } - // Extract the sender public key (used as the threadId in contact threads) and add - // that to the messageReceive job for multi-threading and garbage collection purposes - // - // Note: This is a slightly optimised version of the message decryption which - // just skips the validation (handled when the job actually runs) and doesn't throw + // Extract the threadId and add that to the messageReceive job for + // multi-threading and garbage collection purposes let threadId: String? = MessageReceiver.extractSenderPublicKey(db, from: envelope) if threadId == nil { diff --git a/SessionMessagingKit/Utilities/Environment.h b/SessionMessagingKit/Utilities/Environment.h index edbd77376..16b2f8004 100644 --- a/SessionMessagingKit/Utilities/Environment.h +++ b/SessionMessagingKit/Utilities/Environment.h @@ -2,7 +2,6 @@ @class OWSAudioSession; @class OWSPreferences; -@class OWSSounds; @class OWSWindowManager; @protocol OWSProximityMonitoringManager; @@ -21,13 +20,11 @@ - (instancetype)initWithAudioSession:(OWSAudioSession *)audioSession preferences:(OWSPreferences *)preferences proximityMonitoringManager:(id)proximityMonitoringManager - sounds:(OWSSounds *)sounds windowManager:(OWSWindowManager *)windowManager; @property (nonatomic, readonly) OWSAudioSession *audioSession; @property (nonatomic, readonly) id proximityMonitoringManager; @property (nonatomic, readonly) OWSPreferences *preferences; -@property (nonatomic, readonly) OWSSounds *sounds; @property (nonatomic, readonly) OWSWindowManager *windowManager; // We don't want to cover the window when we request the photo library permission @property (nonatomic, readwrite) BOOL isRequestingPermission; diff --git a/SessionMessagingKit/Utilities/Environment.m b/SessionMessagingKit/Utilities/Environment.m index 81a39ccf2..ee8f5c284 100644 --- a/SessionMessagingKit/Utilities/Environment.m +++ b/SessionMessagingKit/Utilities/Environment.m @@ -3,7 +3,6 @@ #import "OWSWindowManager.h" #import #import "OWSPreferences.h" -#import "OWSSounds.h" static Environment *sharedEnvironment = nil; @@ -12,7 +11,6 @@ static Environment *sharedEnvironment = nil; @property (nonatomic) OWSAudioSession *audioSession; @property (nonatomic) OWSPreferences *preferences; @property (nonatomic) id proximityMonitoringManager; -@property (nonatomic) OWSSounds *sounds; @property (nonatomic) OWSWindowManager *windowManager; @end @@ -44,7 +42,6 @@ static Environment *sharedEnvironment = nil; - (instancetype)initWithAudioSession:(OWSAudioSession *)audioSession preferences:(OWSPreferences *)preferences proximityMonitoringManager:(id)proximityMonitoringManager - sounds:(OWSSounds *)sounds windowManager:(OWSWindowManager *)windowManager { self = [super init]; @@ -56,7 +53,6 @@ static Environment *sharedEnvironment = nil; _audioSession = audioSession; _preferences = preferences; _proximityMonitoringManager = proximityMonitoringManager; - _sounds = sounds; _windowManager = windowManager; _isRequestingPermission = false; diff --git a/SessionMessagingKit/Utilities/OWSSounds.h b/SessionMessagingKit/Utilities/OWSSounds.h deleted file mode 100644 index 22e576ca6..000000000 --- a/SessionMessagingKit/Utilities/OWSSounds.h +++ /dev/null @@ -1,79 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import -#import -#import "OWSAudioPlayer.h" - -NS_ASSUME_NONNULL_BEGIN - -typedef NS_ENUM(NSUInteger, OWSSound) { - OWSSound_Default = 0, - - // Notification Sounds - OWSSound_Aurora, - OWSSound_Bamboo, - OWSSound_Chord, - OWSSound_Circles, - OWSSound_Complete, - OWSSound_Hello, - OWSSound_Input, - OWSSound_Keys, - OWSSound_Note, - OWSSound_Popcorn, - OWSSound_Pulse, - OWSSound_Synth, - OWSSound_SignalClassic, - - // Ringtone Sounds - OWSSound_Opening, - - // Calls - OWSSound_CallConnecting, - OWSSound_CallOutboundRinging, - OWSSound_CallBusy, - OWSSound_CallFailure, - - // Other - OWSSound_MessageSent, - OWSSound_None, - OWSSound_DefaultiOSIncomingRingtone = OWSSound_Opening, -}; - -@class OWSAudioPlayer; -@class OWSPrimaryStorage; -@class TSThread; -@class YapDatabaseReadWriteTransaction; - -@interface OWSSounds : NSObject - -- (instancetype)init NS_UNAVAILABLE; - -- (instancetype)initWithPrimaryStorage:(OWSPrimaryStorage *)primaryStorage NS_DESIGNATED_INITIALIZER; - -+ (NSString *)displayNameForSound:(OWSSound)sound; - -+ (nullable NSString *)filenameForSound:(OWSSound)sound; -+ (nullable NSString *)filenameForSound:(OWSSound)sound quiet:(BOOL)quiet; - -#pragma mark - Notifications - -+ (NSArray *)allNotificationSounds; - -+ (OWSSound)globalNotificationSound; -+ (void)setGlobalNotificationSound:(OWSSound)sound; -+ (void)setGlobalNotificationSound:(OWSSound)sound transaction:(YapDatabaseReadWriteTransaction *)transaction; - -+ (OWSSound)notificationSoundForThread:(TSThread *)thread; -+ (SystemSoundID)systemSoundIDForSound:(OWSSound)sound quiet:(BOOL)quiet; -+ (void)setNotificationSound:(OWSSound)sound forThread:(TSThread *)thread; - -#pragma mark - AudioPlayer - -+ (nullable OWSAudioPlayer *)audioPlayerForSound:(OWSSound)sound - audioBehavior:(OWSAudioBehavior)audioBehavior; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Utilities/OWSSounds.m b/SessionMessagingKit/Utilities/OWSSounds.m deleted file mode 100644 index 4415402db..000000000 --- a/SessionMessagingKit/Utilities/OWSSounds.m +++ /dev/null @@ -1,365 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "OWSSounds.h" -#import "Environment.h" -#import "OWSAudioPlayer.h" -#import -#import -#import -#import -#import - -NSString *const kOWSSoundsStorageNotificationCollection = @"kOWSSoundsStorageNotificationCollection"; -NSString *const kOWSSoundsStorageGlobalNotificationKey = @"kOWSSoundsStorageGlobalNotificationKey"; - -@interface OWSSystemSound : NSObject - -@property (nonatomic, readonly) SystemSoundID soundID; -@property (nonatomic, readonly) NSURL *soundURL; - -- (instancetype)init NS_UNAVAILABLE; -- (instancetype)initWithURL:(NSURL *)url NS_DESIGNATED_INITIALIZER; - -@end - -@implementation OWSSystemSound - -- (instancetype)initWithURL:(NSURL *)url -{ - self = [super init]; - - if (!self) { - return self; - } - - _soundURL = url; - - SystemSoundID newSoundID; - _soundID = newSoundID; - - return self; -} - -- (void)dealloc -{ - -} - -@end - -@interface OWSSounds () - -@property (nonatomic, readonly) YapDatabaseConnection *dbConnection; -@property (nonatomic, readonly) AnyLRUCache *cachedSystemSounds; - -@end - -#pragma mark - - -@implementation OWSSounds - -+ (instancetype)sharedManager -{ - return Environment.shared.sounds; -} - -- (instancetype)initWithPrimaryStorage:(OWSPrimaryStorage *)primaryStorage -{ - self = [super init]; - - if (!self) { - return self; - } - - _dbConnection = primaryStorage.newDatabaseConnection; - - // Don't store too many sounds in memory. Most users will only use 1 or 2 sounds anyway. - _cachedSystemSounds = [[AnyLRUCache alloc] initWithMaxSize:4]; - - return self; -} - -+ (NSArray *)allNotificationSounds -{ - return @[ - // None and Note (default) should be first. - @(OWSSound_None), - @(OWSSound_Note), - - @(OWSSound_Aurora), - @(OWSSound_Bamboo), - @(OWSSound_Chord), - @(OWSSound_Circles), - @(OWSSound_Complete), - @(OWSSound_Hello), - @(OWSSound_Input), - @(OWSSound_Keys), - @(OWSSound_Popcorn), - @(OWSSound_Pulse), - @(OWSSound_Synth), - ]; -} - -+ (NSString *)displayNameForSound:(OWSSound)sound -{ - // TODO: Should we localize these sound names? - switch (sound) { - case OWSSound_Default: - return @""; - - // Notification Sounds - case OWSSound_Aurora: - return @"Aurora"; - case OWSSound_Bamboo: - return @"Bamboo"; - case OWSSound_Chord: - return @"Chord"; - case OWSSound_Circles: - return @"Circles"; - case OWSSound_Complete: - return @"Complete"; - case OWSSound_Hello: - return @"Hello"; - case OWSSound_Input: - return @"Input"; - case OWSSound_Keys: - return @"Keys"; - case OWSSound_Note: - return @"Note"; - case OWSSound_Popcorn: - return @"Popcorn"; - case OWSSound_Pulse: - return @"Pulse"; - case OWSSound_Synth: - return @"Synth"; - case OWSSound_SignalClassic: - return @"Signal Classic"; - - // Call Audio - case OWSSound_Opening: - return @"Opening"; - case OWSSound_CallConnecting: - return @"Call Connecting"; - case OWSSound_CallOutboundRinging: - return @"Call Outboung Ringing"; - case OWSSound_CallBusy: - return @"Call Busy"; - case OWSSound_CallFailure: - return @"Call Failure"; - case OWSSound_MessageSent: - return @"Message Sent"; - - // Other - case OWSSound_None: - return NSLocalizedString(@"SOUNDS_NONE", - @"Label for the 'no sound' option that allows users to disable sounds for notifications, " - @"etc."); - } -} - -+ (nullable NSString *)filenameForSound:(OWSSound)sound -{ - return [self filenameForSound:sound quiet:NO]; -} - -+ (nullable NSString *)filenameForSound:(OWSSound)sound quiet:(BOOL)quiet -{ - switch (sound) { - case OWSSound_Default: - return @""; - - // Notification Sounds - case OWSSound_Aurora: - return (quiet ? @"aurora-quiet.aifc" : @"aurora.aifc"); - case OWSSound_Bamboo: - return (quiet ? @"bamboo-quiet.aifc" : @"bamboo.aifc"); - case OWSSound_Chord: - return (quiet ? @"chord-quiet.aifc" : @"chord.aifc"); - case OWSSound_Circles: - return (quiet ? @"circles-quiet.aifc" : @"circles.aifc"); - case OWSSound_Complete: - return (quiet ? @"complete-quiet.aifc" : @"complete.aifc"); - case OWSSound_Hello: - return (quiet ? @"hello-quiet.aifc" : @"hello.aifc"); - case OWSSound_Input: - return (quiet ? @"input-quiet.aifc" : @"input.aifc"); - case OWSSound_Keys: - return (quiet ? @"keys-quiet.aifc" : @"keys.aifc"); - case OWSSound_Note: - return (quiet ? @"note-quiet.aifc" : @"note.aifc"); - case OWSSound_Popcorn: - return (quiet ? @"popcorn-quiet.aifc" : @"popcorn.aifc"); - case OWSSound_Pulse: - return (quiet ? @"pulse-quiet.aifc" : @"pulse.aifc"); - case OWSSound_Synth: - return (quiet ? @"synth-quiet.aifc" : @"synth.aifc"); - case OWSSound_SignalClassic: - return (quiet ? @"classic-quiet.aifc" : @"classic.aifc"); - - // Ringtone Sounds - case OWSSound_Opening: - return @"Opening.m4r"; - - // Calls - case OWSSound_CallConnecting: - return @"ringback_tone_ansi.caf"; - case OWSSound_CallOutboundRinging: - return @"ringback_tone_ansi.caf"; - case OWSSound_CallBusy: - return @"busy_tone_ansi.caf"; - case OWSSound_CallFailure: - return @"end_call_tone_cept.caf"; - case OWSSound_MessageSent: - return @"message_sent.aiff"; - - // Other - case OWSSound_None: - return nil; - } -} - -+ (nullable NSURL *)soundURLForSound:(OWSSound)sound quiet:(BOOL)quiet -{ - NSString *_Nullable filename = [self filenameForSound:sound quiet:quiet]; - if (!filename) { - return nil; - } - NSURL *_Nullable url = [[NSBundle mainBundle] URLForResource:filename.stringByDeletingPathExtension - withExtension:filename.pathExtension]; - return url; -} - -+ (SystemSoundID)systemSoundIDForSound:(OWSSound)sound quiet:(BOOL)quiet -{ - return [self.sharedManager systemSoundIDForSound:(OWSSound)sound quiet:quiet]; -} - -- (SystemSoundID)systemSoundIDForSound:(OWSSound)sound quiet:(BOOL)quiet -{ - NSString *cacheKey = [NSString stringWithFormat:@"%lu:%d", (unsigned long)sound, quiet]; - OWSSystemSound *_Nullable cachedSound = (OWSSystemSound *)[self.cachedSystemSounds getWithKey:cacheKey]; - - if (cachedSound) { - return cachedSound.soundID; - } - - NSURL *soundURL = [self.class soundURLForSound:sound quiet:quiet]; - OWSSystemSound *newSound = [[OWSSystemSound alloc] initWithURL:soundURL]; - [self.cachedSystemSounds setWithKey:cacheKey value:newSound]; - - return newSound.soundID; -} - -#pragma mark - Notifications - -+ (OWSSound)defaultNotificationSound -{ - return OWSSound_Note; -} - -+ (OWSSound)globalNotificationSound -{ - OWSSounds *instance = OWSSounds.sharedManager; - NSNumber *_Nullable value = [instance.dbConnection objectForKey:kOWSSoundsStorageGlobalNotificationKey - inCollection:kOWSSoundsStorageNotificationCollection]; - // Default to the global default. - return (value ? (OWSSound)value.intValue : [self defaultNotificationSound]); -} - -+ (void)setGlobalNotificationSound:(OWSSound)sound -{ - [self.sharedManager setGlobalNotificationSound:sound]; -} - -- (void)setGlobalNotificationSound:(OWSSound)sound -{ - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) { - [self setGlobalNotificationSound:sound transaction:transaction]; - }]; -} - -+ (void)setGlobalNotificationSound:(OWSSound)sound transaction:(YapDatabaseReadWriteTransaction *)transaction -{ - [self.sharedManager setGlobalNotificationSound:sound transaction:transaction]; -} - -- (void)setGlobalNotificationSound:(OWSSound)sound transaction:(YapDatabaseReadWriteTransaction *)transaction -{ - // Fallback push notifications play a sound specified by the server, but we don't want to store this configuration - // on the server. Instead, we create a file with the same name as the default to be played when receiving - // a fallback notification. - NSString *dirPath = [[OWSFileSystem appLibraryDirectoryPath] stringByAppendingPathComponent:@"Sounds"]; - [OWSFileSystem ensureDirectoryExists:dirPath]; - - // This name is specified in the payload by the Signal Service when requesting fallback push notifications. - NSString *kDefaultNotificationSoundFilename = @"NewMessage.aifc"; - NSString *defaultSoundPath = [dirPath stringByAppendingPathComponent:kDefaultNotificationSoundFilename]; - - NSURL *_Nullable soundURL = [OWSSounds soundURLForSound:sound quiet:NO]; - - NSData *soundData = ^{ - if (soundURL) { - return [NSData dataWithContentsOfURL:soundURL]; - } else { - return [NSData new]; - } - }(); - - // Quick way to achieve an atomic "copy" operation that allows overwriting if the user has previously specified - // a default notification sound. - BOOL success = [soundData writeToFile:defaultSoundPath atomically:YES]; - - // The globally configured sound the user has configured is unprotected, so that we can still play the sound if the - // user hasn't authenticated after power-cycling their device. - [OWSFileSystem protectFileOrFolderAtPath:defaultSoundPath fileProtectionType:NSFileProtectionNone]; - - if (!success) { - return; - } - - [transaction setObject:@(sound) - forKey:kOWSSoundsStorageGlobalNotificationKey - inCollection:kOWSSoundsStorageNotificationCollection]; -} - -+ (OWSSound)notificationSoundForThread:(TSThread *)thread -{ - OWSSounds *instance = OWSSounds.sharedManager; - NSNumber *_Nullable value = - [instance.dbConnection objectForKey:thread.uniqueId inCollection:kOWSSoundsStorageNotificationCollection]; - // Default to the "global" notification sound, which in turn will default to the global default. - return (value ? (OWSSound)value.intValue : [self globalNotificationSound]); -} - -+ (void)setNotificationSound:(OWSSound)sound forThread:(TSThread *)thread -{ - OWSSounds *instance = OWSSounds.sharedManager; - [instance.dbConnection setObject:@(sound) - forKey:thread.uniqueId - inCollection:kOWSSoundsStorageNotificationCollection]; -} - -#pragma mark - AudioPlayer - -+ (BOOL)shouldAudioPlayerLoopForSound:(OWSSound)sound -{ - return (sound == OWSSound_CallConnecting || sound == OWSSound_CallOutboundRinging); -} - -+ (nullable OWSAudioPlayer *)audioPlayerForSound:(OWSSound)sound - audioBehavior:(OWSAudioBehavior)audioBehavior; -{ - NSURL *_Nullable soundURL = [OWSSounds soundURLForSound:sound quiet:NO]; - if (!soundURL) { - return nil; - } - OWSAudioPlayer *player = [[OWSAudioPlayer alloc] initWithMediaUrl:soundURL audioBehavior:audioBehavior]; - if ([self shouldAudioPlayerLoopForSound:sound]) { - player.isLooping = YES; - } - return player; -} - -@end diff --git a/SessionMessagingKit/Utilities/OWSSounds.swift b/SessionMessagingKit/Utilities/OWSSounds.swift deleted file mode 100644 index 97caad633..000000000 --- a/SessionMessagingKit/Utilities/OWSSounds.swift +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SignalCoreKit - -extension OWSSound { - - public func notificationSound(isQuiet: Bool) -> UNNotificationSound { - guard let filename = OWSSounds.filename(for: self, quiet: isQuiet) else { - owsFailDebug("filename was unexpectedly nil") - return UNNotificationSound.default - } - return UNNotificationSound(named: UNNotificationSoundName(rawValue: filename)) - } -} diff --git a/SessionMessagingKit/Utilities/Preferences.swift b/SessionMessagingKit/Utilities/Preferences.swift index 5309e0142..557f4198c 100644 --- a/SessionMessagingKit/Utilities/Preferences.swift +++ b/SessionMessagingKit/Utilities/Preferences.swift @@ -3,6 +3,7 @@ import Foundation import GRDB import SessionUtilitiesKit +import AudioToolbox public extension Setting.EnumKey { /// Controls how notifications should appear for the user (See `NotificationPreviewType` for the options) @@ -66,8 +67,15 @@ public enum Preferences { } public enum Sound: Int, Codable, DatabaseValueConvertible, EnumSetting { - static var defaultiOSIncomingRingtone: Sound = .opening - static var defaultNotificationSound: Sound = .note + public static var defaultiOSIncomingRingtone: Sound = .opening + public static var defaultNotificationSound: Sound = .note + + // Don't store too many sounds in memory (Most users will only use 1 or 2 sounds anyway) + private static let maxCachedSounds: Int = 4 + private static var cachedSystemSounds: Atomic<[String: (url: URL?, soundId: SystemSoundID)]> = Atomic([:]) + private static var cachedSystemSoundOrder: Atomic<[String]> = Atomic([]) + + // Values case `default` @@ -99,7 +107,7 @@ public enum Preferences { case messageSent = 4000 case none - static var notificationSounds: [Sound] { + public static var notificationSounds: [Sound] { return [ // None and Note (default) should be first. .none, @@ -118,5 +126,202 @@ public enum Preferences { .synth ] } + + var displayName: String { + // TODO: Should we localize these sound names? + switch self { + case .`default`: return "" + + // Notification Sounds + case .aurora: return "Aurora" + case .bamboo: return "Bamboo" + case .chord: return "Chord" + case .circles: return "Circles" + case .complete: return "Complete" + case .hello: return "Hello" + case .input: return "Input" + case .keys: return "Keys" + case .note: return "Note" + case .popcorn: return "Popcorn" + case .pulse: return "Pulse" + case .synth: return "Synth" + case .signalClassic: return "Signal Classic" + + // Ringtone Sounds + case .opening: return "Opening" + + // Calls + case .callConnecting: return "Call Connecting" + case .callOutboundRinging: return "Call Outboung Ringing" + case .callBusy: return "Call Busy" + case .callFailure: return "Call Failure" + + // Other + case .messageSent: return "Message Sent" + case .none: return "SOUNDS_NONE".localized() + } + } + + // MARK: - Functions + + public func filename(quiet: Bool = false) -> String? { + switch self { + case .`default`: return "" + + // Notification Sounds + case .aurora: return (quiet ? "aurora-quiet.aifc" : "aurora.aifc") + case .bamboo: return (quiet ? "bamboo-quiet.aifc" : "bamboo.aifc") + case .chord: return (quiet ? "chord-quiet.aifc" : "chord.aifc") + case .circles: return (quiet ? "circles-quiet.aifc" : "circles.aifc") + case .complete: return (quiet ? "complete-quiet.aifc" : "complete.aifc") + case .hello: return (quiet ? "hello-quiet.aifc" : "hello.aifc") + case .input: return (quiet ? "input-quiet.aifc" : "input.aifc") + case .keys: return (quiet ? "keys-quiet.aifc" : "keys.aifc") + case .note: return (quiet ? "note-quiet.aifc" : "note.aifc") + case .popcorn: return (quiet ? "popcorn-quiet.aifc" : "popcorn.aifc") + case .pulse: return (quiet ? "pulse-quiet.aifc" : "pulse.aifc") + case .synth: return (quiet ? "synth-quiet.aifc" : "synth.aifc") + case .signalClassic: return (quiet ? "classic-quiet.aifc" : "classic.aifc") + + // Ringtone Sounds + case .opening: return "Opening.m4r" + + // Calls + case .callConnecting: return "ringback_tone_ansi.caf" + case .callOutboundRinging: return "ringback_tone_ansi.caf" + case .callBusy: return "busy_tone_ansi.caf" + case .callFailure: return "end_call_tone_cept.caf" + + // Other + case .messageSent: return "message_sent.aiff" + case .none: return nil + } + } + + public func soundUrl(quiet: Bool = false) -> URL? { + guard let filename: String = filename(quiet: quiet) else { return nil } + + let url: URL = URL(fileURLWithPath: filename) + + return Bundle.main.url( + forResource: url.deletingPathExtension().absoluteString, + withExtension: url.pathExtension + ) + } + + public func notificationSound(isQuiet: Bool) -> UNNotificationSound { + guard let filename: String = filename(quiet: isQuiet) else { + SNLog("[Preferences.Sound] filename was unexpectedly nil") + return UNNotificationSound.default + } + + return UNNotificationSound(named: UNNotificationSoundName(rawValue: filename)) + } + + public static func systemSoundId(for sound: Sound, quiet: Bool) -> SystemSoundID { + let cacheKey: String = "\(sound.rawValue):\(quiet ? 1 : 0)" + + if let cachedSound: SystemSoundID = cachedSystemSounds.wrappedValue[cacheKey]?.soundId { + return cachedSound + } + + let systemSound: (url: URL?, soundId: SystemSoundID) = ( + url: sound.soundUrl(quiet: quiet), + soundId: SystemSoundID() + ) + + cachedSystemSounds.mutate { cache in + cachedSystemSoundOrder.mutate { order in + if order.count > Sound.maxCachedSounds { + cache.removeValue(forKey: order[0]) + order.remove(at: 0) + } + + order.append(cacheKey) + } + + cache[cacheKey] = systemSound + } + + return systemSound.soundId + } + + // MARK: - AudioPlayer + + public static func audioPlayer(for sound: Sound, behaviour: OWSAudioBehavior) -> OWSAudioPlayer? { + guard let soundUrl: URL = sound.soundUrl(quiet: false) else { return nil } + + let player = OWSAudioPlayer(mediaUrl: soundUrl, audioBehavior: behaviour) + + // These two cases should loop + if sound == .callConnecting || sound == .callOutboundRinging { + player.isLooping = true + } + + return player + } + } +} + +// MARK: - Objective C Support + +// FIXME: Remove this once the 'NotificationSettingsViewController' and 'OWSSoundSettingsViewController' have been refactored to Swift +@objc(SMKSound) +public class SMKSound: NSObject { + @objc public static var notificationSounds: [Int] = Preferences.Sound.notificationSounds.map { $0.rawValue } + + @objc public static func displayName(for sound: Int) -> String { + return (Preferences.Sound(rawValue: sound) ?? Preferences.Sound.default).displayName + } + + @objc public static func isNote(_ sound: Int) -> Bool { + return (sound == Preferences.Sound.note.rawValue) + } + + @objc public static func audioPlayer(for sound: Int, audioBehavior: OWSAudioBehavior) -> OWSAudioPlayer? { + guard let sound: Preferences.Sound = Preferences.Sound(rawValue: sound) else { return nil } + + return Preferences.Sound.audioPlayer(for: sound, behaviour: audioBehavior) + } + + @objc public static var defaultNotificationSound: Int { + GRDBStorage.shared[.defaultNotificationSound] + .defaulting(to: Preferences.Sound.defaultNotificationSound) + .rawValue + } + + @objc public static func setGlobalNotificationSound(_ sound: Int) { + guard let sound: Preferences.Sound = Preferences.Sound(rawValue: sound) else { return } + + GRDBStorage.shared.write { db in + db[.defaultNotificationSound] = sound + } + } + + @objc public static func notificationSound(for threadId: String?) -> Int { + guard let threadId: String = threadId else { return defaultNotificationSound } + + return (GRDBStorage.shared + .read { db in + try Preferences.Sound + .fetchOne( + db, + SessionThread + .select(SessionThread.Columns.notificationSound) + .filter(id: threadId) + ) + }? + .rawValue) + .defaulting(to: defaultNotificationSound) + } + + @objc public static func setNotificationSound(_ sound: Int, forThreadId threadId: String) { + guard let sound: Preferences.Sound = Preferences.Sound(rawValue: sound) else { return } + + GRDBStorage.shared.write { db in + try SessionThread + .filter(id: threadId) + .updateAll(db, SessionThread.Columns.notificationSound.set(to: sound)) + } } } diff --git a/SessionNotificationServiceExtension/NSENotificationPresenter.swift b/SessionNotificationServiceExtension/NSENotificationPresenter.swift index dbbe189e5..5a5343d56 100644 --- a/SessionNotificationServiceExtension/NSENotificationPresenter.swift +++ b/SessionNotificationServiceExtension/NSENotificationPresenter.swift @@ -74,7 +74,8 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { let notificationContent = UNMutableNotificationContent() notificationContent.userInfo = userInfo - notificationContent.sound = OWSSounds.notificationSound(forThreadId: thread.id) + notificationContent.sound = thread.notificationSound + .defaulting(to: db[.defaultNotificationSound] ?? Preferences.Sound.defaultNotificationSound) .notificationSound(isQuiet: false) // Badge Number diff --git a/SessionUtilitiesKit/Database/Models/Identity.swift b/SessionUtilitiesKit/Database/Models/Identity.swift index 480cf932f..3ebf1d214 100644 --- a/SessionUtilitiesKit/Database/Models/Identity.swift +++ b/SessionUtilitiesKit/Database/Models/Identity.swift @@ -70,89 +70,61 @@ public extension Identity { } static func userExists(_ db: Database? = nil) -> Bool { - let userExists: (Database) -> Bool = { db in - return ( - (try? Identity.fetchOne(db, id: .x25519PublicKey)) != nil && - (try? Identity.fetchOne(db, id: .x25519PrivateKey)) != nil - ) - } - - if let db: Database = db { - return userExists(db) - } - - return GRDBStorage.shared - .read { db -> Bool in userExists(db) } - .defaulting(to: false) + return (fetchUserKeyPair(db) != nil) } static func fetchUserPublicKey(_ db: Database? = nil) -> Data? { - if let db: Database = db { - return try? Identity.fetchOne(db, id: .x25519PublicKey)?.data + guard let db: Database = db else { + return GRDBStorage.shared.read { db in fetchUserPublicKey(db) } } - return GRDBStorage.shared.read { db -> Data? in - try Identity.fetchOne(db, id: .x25519PublicKey)?.data - } + return try? Identity.fetchOne(db, id: .x25519PublicKey)?.data } static func fetchUserPrivateKey(_ db: Database? = nil) -> Data? { - if let db: Database = db { - return try? Identity.fetchOne(db, id: .x25519PrivateKey)?.data + guard let db: Database = db else { + return GRDBStorage.shared.read { db in fetchUserPrivateKey(db) } } - return GRDBStorage.shared.read { db -> Data? in - try Identity.fetchOne(db, id: .x25519PrivateKey)?.data - } + return try? Identity.fetchOne(db, id: .x25519PrivateKey)?.data } static func fetchUserKeyPair(_ db: Database? = nil) -> Box.KeyPair? { - let fetchKeys: (Database) -> Box.KeyPair? = { db in - guard - let publicKey: Identity = try? Identity.fetchOne(db, id: .x25519PublicKey), - let privateKey: Identity = try? Identity.fetchOne(db, id: .x25519PrivateKey) - else { - return nil - } - - return Box.KeyPair( - publicKey: publicKey.data.bytes, - secretKey: privateKey.data.bytes - ) + guard let db: Database = db else { + return GRDBStorage.shared.read { db in fetchUserKeyPair(db) } } + guard + let publicKey: Data = fetchUserPublicKey(db), + let privateKey: Data = fetchUserPrivateKey(db) + else { return nil } - if let db: Database = db { - return fetchKeys(db) - } - - return GRDBStorage.shared.read { db -> Box.KeyPair? in - return fetchKeys(db) - } + return Box.KeyPair( + publicKey: publicKey.bytes, + secretKey: privateKey.bytes + ) } static func fetchUserEd25519KeyPair() -> Box.KeyPair? { return GRDBStorage.shared.read { db -> Box.KeyPair? in guard - let publicKey: Identity = try? Identity.fetchOne(db, id: .ed25519PublicKey), - let secretKey: Identity = try? Identity.fetchOne(db, id: .ed25519SecretKey) - else { - return nil - } + let publicKey: Data = try? Identity.fetchOne(db, id: .ed25519PublicKey)?.data, + let secretKey: Data = try? Identity.fetchOne(db, id: .ed25519SecretKey)?.data + else { return nil } return Box.KeyPair( - publicKey: publicKey.data.bytes, - secretKey: secretKey.data.bytes + publicKey: publicKey.bytes, + secretKey: secretKey.bytes ) } } static func fetchHexEncodedSeed() -> String? { return GRDBStorage.shared.read { db in - guard let value: Identity = try? Identity.fetchOne(db, id: .seed) else { + guard let data: Data = try? Identity.fetchOne(db, id: .seed)?.data else { return nil } - return value.data.toHexString() + return data.toHexString() } } diff --git a/SessionUtilitiesKit/Database/Models/Setting.swift b/SessionUtilitiesKit/Database/Models/Setting.swift index 4cf28c67a..01029f2a3 100644 --- a/SessionUtilitiesKit/Database/Models/Setting.swift +++ b/SessionUtilitiesKit/Database/Models/Setting.swift @@ -30,8 +30,14 @@ extension Setting { self.value = Data(bytes: &targetValue, count: MemoryLayout.size(ofValue: targetValue)) } - fileprivate func value(as type: T.Type) -> T { - return value.withUnsafeBytes { $0.load(as: T.self) } + fileprivate func value(as type: T.Type) -> T? { + // Note: The 'assumingMemoryBound' is essentially going to try to convert + // the memory into the provided type so can result in invalid data being + // returned if the type is incorrect. But it does seem safer than the 'load' + // method which crashed under certain circumstances (an `Int` value of 0) + return value.withUnsafeBytes { + $0.baseAddress?.assumingMemoryBound(to: T.self).pointee + } } } diff --git a/SessionUtilitiesKit/General/Array+Utilities.swift b/SessionUtilitiesKit/General/Array+Utilities.swift index 887bf9052..30575df00 100644 --- a/SessionUtilitiesKit/General/Array+Utilities.swift +++ b/SessionUtilitiesKit/General/Array+Utilities.swift @@ -23,6 +23,12 @@ public extension Array { return updatedArray } + func removing(index: Int) -> [Element] { + var updatedArray: [Element] = self + updatedArray.remove(at: index) + return updatedArray + } + mutating func popFirst() -> Element? { guard !self.isEmpty else { return nil } diff --git a/SessionUtilitiesKit/General/LRUCache.swift b/SessionUtilitiesKit/General/LRUCache.swift index 8e2dde882..184558373 100644 --- a/SessionUtilitiesKit/General/LRUCache.swift +++ b/SessionUtilitiesKit/General/LRUCache.swift @@ -2,32 +2,6 @@ // Copyright (c) 2019 Open Whisper Systems. All rights reserved. // -@objc -public class AnyLRUCache: NSObject { - - private let backingCache: LRUCache - - @objc - public init(maxSize: Int) { - backingCache = LRUCache(maxSize: maxSize) - } - - @objc - public func get(key: NSObject) -> NSObject? { - return self.backingCache.get(key: key) - } - - @objc - public func set(key: NSObject, value: NSObject) { - self.backingCache.set(key: key, value: value) - } - - @objc - public func clear() { - self.backingCache.clear() - } -} - // A simple LRU cache bounded by the number of entries. public class LRUCache { diff --git a/SignalUtilitiesKit/Utilities/AppSetup.m b/SignalUtilitiesKit/Utilities/AppSetup.m index 591b18346..a95fd0e95 100644 --- a/SignalUtilitiesKit/Utilities/AppSetup.m +++ b/SignalUtilitiesKit/Utilities/AppSetup.m @@ -10,7 +10,6 @@ #import #import #import -#import #import #import #import @@ -59,14 +58,12 @@ NS_ASSUME_NONNULL_BEGIN id typingIndicators = [[OWSTypingIndicatorsImpl alloc] init]; OWSAudioSession *audioSession = [OWSAudioSession new]; - OWSSounds *sounds = [[OWSSounds alloc] initWithPrimaryStorage:primaryStorage]; id proximityMonitoringManager = [OWSProximityMonitoringManagerImpl new]; OWSWindowManager *windowManager = [[OWSWindowManager alloc] initDefault]; [Environment setShared:[[Environment alloc] initWithAudioSession:audioSession preferences:preferences proximityMonitoringManager:proximityMonitoringManager - sounds:sounds windowManager:windowManager]]; // TODO: Add this back From 949b043867702c5bf471afeeb7552a25c0e401fb Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 26 Apr 2022 15:08:41 +1000 Subject: [PATCH 064/157] Updated the unit tests to build with a host app (needed due to the x86_64 build requirement...) Fixed the broken tests --- Session.xcodeproj/project.pbxproj | 44 +++++++++++ .../Open Groups/OpenGroupManager.swift | 16 ++++ .../Open Groups/OpenGroupManagerSpec.swift | 73 +++++++++++++++++-- .../Utilities/Optional+Utilities.swift | 23 ++++++ SharedTest/NimbleExtensions.swift | 41 ++++++++--- 5 files changed, 182 insertions(+), 15 deletions(-) create mode 100644 SessionUtilitiesKit/Utilities/Optional+Utilities.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index a4fdbed69..5f8e0474a 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -892,6 +892,7 @@ FDC438CD27BC641200C60D73 /* Set+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438CC27BC641200C60D73 /* Set+Utilities.swift */; }; FDC438CF27BCA45400C60D73 /* Server.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438CE27BCA45400C60D73 /* Server.swift */; }; FDCDB8E42817819600352A0C /* TSYapDatabaseObject.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA90255A57FD00E217F9 /* TSYapDatabaseObject.m */; }; + FDCDB8F12817ABE600352A0C /* Optional+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8F02817ABE600352A0C /* Optional+Utilities.swift */; }; FDFD645927F26C6800808CA1 /* Array+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D12553860800C340D1 /* Array+Utilities.swift */; }; FDFD645B27F26D4600808CA1 /* Data+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFD645A27F26D4600808CA1 /* Data+Utilities.swift */; }; FDFD645D27F273F300808CA1 /* MockGeneralCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */; }; @@ -1031,6 +1032,20 @@ remoteGlobalIDString = C3C2A6EF25539DE700C340D1; remoteInfo = SessionMessagingKit; }; + FDCDB8EB28179EAF00352A0C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D221A080169C9E5E00537ABF /* Project object */; + proxyType = 1; + remoteGlobalIDString = D221A088169C9E5E00537ABF; + remoteInfo = Session; + }; + FDCDB8ED28179EB200352A0C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D221A080169C9E5E00537ABF /* Project object */; + proxyType = 1; + remoteGlobalIDString = D221A088169C9E5E00537ABF; + remoteInfo = Session; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -2062,6 +2077,7 @@ 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 = ""; }; FDC438CE27BCA45400C60D73 /* Server.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.swift; sourceTree = ""; }; + FDCDB8F02817ABE600352A0C /* Optional+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Utilities.swift"; sourceTree = ""; }; FDFD645A27F26D4600808CA1 /* Data+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Data+Utilities.swift"; sourceTree = ""; }; FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockGeneralCache.swift; sourceTree = ""; }; FEDBAE1B98C49BBE8C87F575 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; sourceTree = ""; }; @@ -3622,6 +3638,7 @@ B8A582B9258C696200AFD84C /* Messaging */, B8A582AE258C65D000AFD84C /* Networking */, B8A582AD258C655E00AFD84C /* PromiseKit */, + FDCDB8EF2817ABCE00352A0C /* Utilities */, C3D9E43025676D3D0040E4F3 /* Configuration.swift */, ); path = SessionUtilitiesKit; @@ -4194,6 +4211,14 @@ path = Models; sourceTree = ""; }; + FDCDB8EF2817ABCE00352A0C /* Utilities */ = { + isa = PBXGroup; + children = ( + FDCDB8F02817ABE600352A0C /* Optional+Utilities.swift */, + ); + path = Utilities; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -4529,6 +4554,7 @@ ); dependencies = ( FD83B9B527CF200A005E1583 /* PBXTargetDependency */, + FDCDB8EE28179EB200352A0C /* PBXTargetDependency */, ); name = SessionUtilitiesKitTests; productName = SessionUtilitiesKitTests; @@ -4549,6 +4575,7 @@ ); dependencies = ( FDC4389427B9FFC700C60D73 /* PBXTargetDependency */, + FDCDB8EC28179EAF00352A0C /* PBXTargetDependency */, ); name = SessionMessagingKitTests; productName = SessionMessagingKitTests; @@ -4654,9 +4681,11 @@ }; FD83B9AE27CF200A005E1583 = { CreatedOnToolsVersion = 13.2.1; + TestTargetID = D221A088169C9E5E00537ABF; }; FDC4388D27B9FFC700C60D73 = { CreatedOnToolsVersion = 13.2.1; + TestTargetID = D221A088169C9E5E00537ABF; }; }; }; @@ -5332,6 +5361,7 @@ C3BBE0A82554D4DE0050F1E3 /* JSON.swift in Sources */, C352A36D2557858E00338F3E /* NSTimer+Proxying.m in Sources */, 7BAF54DA27ACD0E3003D12F8 /* UITableView+ReusableView.swift in Sources */, + FDCDB8F12817ABE600352A0C /* Optional+Utilities.swift in Sources */, C32C5A2D256DB849003C73A2 /* LKGroupUtilities.m in Sources */, 7B7CB192271508AD0079FF93 /* CallRingTonePlayer.swift in Sources */, C3C2ABD22553C6C900C340D1 /* Data+SecureRandom.swift in Sources */, @@ -5926,6 +5956,16 @@ target = C3C2A6EF25539DE700C340D1 /* SessionMessagingKit */; targetProxy = FDC4389327B9FFC700C60D73 /* PBXContainerItemProxy */; }; + FDCDB8EC28179EAF00352A0C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D221A088169C9E5E00537ABF /* Session */; + targetProxy = FDCDB8EB28179EAF00352A0C /* PBXContainerItemProxy */; + }; + FDCDB8EE28179EB200352A0C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D221A088169C9E5E00537ABF /* Session */; + targetProxy = FDCDB8ED28179EB200352A0C /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -7309,6 +7349,7 @@ SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Session.app/Session"; }; name = Debug; }; @@ -7373,6 +7414,7 @@ SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Session.app/Session"; VALIDATE_PRODUCT = YES; }; name = "App Store Release"; @@ -7415,6 +7457,7 @@ SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Session.app/Session"; }; name = Debug; }; @@ -7479,6 +7522,7 @@ SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Session.app/Session"; VALIDATE_PRODUCT = YES; }; name = "App Store Release"; diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 5dd854261..922ad2277 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -241,6 +241,7 @@ public final class OpenGroupManager: NSObject { publicKey maybePublicKey: String?, for roomToken: String, on server: String, + waitForImageToComplete: Bool = false, using transaction: YapDatabaseReadWriteTransaction, dependencies: OGMDependencies = OGMDependencies(), completion: (() -> ())? = nil @@ -336,10 +337,25 @@ public final class OpenGroupManager: NSObject { let thread = TSGroupThread.getOrCreateThread(with: initialModel, transaction: transaction) thread.groupModel.groupImage = UIImage(data: data) thread.save(with: transaction) + + if waitForImageToComplete { + completion?() + } + } + } + .catch { _ in + if waitForImageToComplete { + completion?() } } .retainUntilComplete() } + else if waitForImageToComplete { + completion?() + } + + // If we want to wait for the image to complete then don't call the completion here + guard !waitForImageToComplete else { return } // Finish completion?() diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift index 98e6ad7a1..d730e9138 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -1060,7 +1060,8 @@ class OpenGroupManagerSpec: QuickSpec { } it("does not stop the poller") { - mockOGMCache.when { $0.pollers }.thenReturn(["testserver": OpenGroupAPI.Poller(for: "testserver")]) + mockOGMCache.when { $0.pollers } + .thenReturn(["testserver": OpenGroupAPI.Poller(for: "testserver")]) openGroupManager .delete( @@ -1082,7 +1083,8 @@ class OpenGroupManagerSpec: QuickSpec { dependencies: dependencies ) - expect(mockStorage).toNot(call { $0.removeOpenGroupServer(name: any(), using: anyAny()) }) + expect(mockStorage) + .toNot(call { $0.removeOpenGroupServer(name: any(), using: anyAny()) }) } it("does not remove the open group public key") { @@ -1234,9 +1236,64 @@ class OpenGroupManagerSpec: QuickSpec { on: "testServer", using: testTransaction, dependencies: dependencies - ) { - didCallComplete = true - } + ) { didCallComplete = true } + + expect(didCallComplete) + .toEventually( + beTrue(), + timeout: .milliseconds(50) + ) + } + + it("calls the room image completion block when waiting but there is no image") { + var didCallComplete: Bool = false + + OpenGroupManager.handlePollInfo( + testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + waitForImageToComplete: true, + using: testTransaction, + dependencies: dependencies + ) { didCallComplete = true } + + expect(didCallComplete) + .toEventually( + beTrue(), + timeout: .milliseconds(50) + ) + } + + it("calls the room image completion block when waiting and there is an image") { + var didCallComplete: Bool = false + + mockStorage.when { $0.getOpenGroupImage(for: any(), on: any()) }.thenReturn(nil) + mockOGMCache.when { $0.groupImagePromises } + .thenReturn(["testServer.testRoom": Promise.value(Data())]) + mockStorage + .when { $0.getOpenGroup(for: any()) } + .thenReturn( + OpenGroup( + server: "testServer", + room: "testRoom", + publicKey: TestConstants.publicKey, + name: "Test", + groupDescription: nil, + imageID: "12", + infoUpdates: 10 + ) + ) + + OpenGroupManager.handlePollInfo( + testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + waitForImageToComplete: true, + using: testTransaction, + dependencies: dependencies + ) { didCallComplete = true } expect(didCallComplete) .toEventually( @@ -1629,6 +1686,7 @@ class OpenGroupManagerSpec: QuickSpec { publicKey: TestConstants.publicKey, for: "testRoom", on: "testServer", + waitForImageToComplete: true, using: testTransaction, dependencies: dependencies ) { didComplete = true } @@ -1700,6 +1758,7 @@ class OpenGroupManagerSpec: QuickSpec { publicKey: TestConstants.publicKey, for: "testRoom", on: "testServer", + waitForImageToComplete: true, using: testTransaction, dependencies: dependencies ) { didComplete = true } @@ -1806,6 +1865,7 @@ class OpenGroupManagerSpec: QuickSpec { publicKey: TestConstants.publicKey, for: "testRoom", on: "testServer", + waitForImageToComplete: true, using: testTransaction, dependencies: dependencies ) { didComplete = true } @@ -1852,6 +1912,7 @@ class OpenGroupManagerSpec: QuickSpec { publicKey: TestConstants.publicKey, for: "testRoom", on: "testServer", + waitForImageToComplete: true, using: testTransaction, dependencies: dependencies ) { didComplete = true } @@ -1923,6 +1984,7 @@ class OpenGroupManagerSpec: QuickSpec { publicKey: TestConstants.publicKey, for: "testRoom", on: "testServer", + waitForImageToComplete: true, using: testTransaction, dependencies: dependencies ) { didComplete = true } @@ -1991,6 +2053,7 @@ class OpenGroupManagerSpec: QuickSpec { publicKey: TestConstants.publicKey, for: "testRoom", on: "testServer", + waitForImageToComplete: true, using: testTransaction, dependencies: dependencies ) { didComplete = true } diff --git a/SessionUtilitiesKit/Utilities/Optional+Utilities.swift b/SessionUtilitiesKit/Utilities/Optional+Utilities.swift new file mode 100644 index 000000000..cd6374b96 --- /dev/null +++ b/SessionUtilitiesKit/Utilities/Optional+Utilities.swift @@ -0,0 +1,23 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension Optional { + public func map(_ transform: (Wrapped) throws -> U?) rethrows -> U? { + switch self { + case .some(let value): return try transform(value) + default: return nil + } + } + + public func asType(_ type: R.Type) -> R? { + switch self { + case .some(let value): return (value as? R) + default: return nil + } + } + + public func defaulting(to value: Wrapped) -> Wrapped { + return (self ?? value) + } +} diff --git a/SharedTest/NimbleExtensions.swift b/SharedTest/NimbleExtensions.swift index b18c5160b..d4f820ec9 100644 --- a/SharedTest/NimbleExtensions.swift +++ b/SharedTest/NimbleExtensions.swift @@ -2,6 +2,7 @@ import Foundation import Nimble +import SessionUtilitiesKit public enum CallAmount { case atLeast(times: Int) @@ -192,11 +193,21 @@ fileprivate func generateCallInfo(_ actualExpression: Expression, _ allFunctionsCalled = Array(validInstance.functionConsumer.calls.wrappedValue.keys) - let builder: MockFunctionBuilder = builderCreator(validInstance) - validInstance.functionConsumer.trackCalls = false - maybeFunction = try? builder.build() - desiredFunctionCalls = (validInstance.functionConsumer.calls.wrappedValue[maybeFunction?.name ?? ""] ?? []) - validInstance.functionConsumer.trackCalls = true + // Only check for the specific function calls if there was at least a single + // call (if there weren't any this will likely throw errors when attempting + // to build) + if !allFunctionsCalled.isEmpty { + let builder: MockFunctionBuilder = builderCreator(validInstance) + validInstance.functionConsumer.trackCalls = false + maybeFunction = try? builder.build() + desiredFunctionCalls = validInstance.functionConsumer.calls + .wrappedValue[maybeFunction?.name ?? ""] + .defaulting(to: []) + validInstance.functionConsumer.trackCalls = true + } + else { + desiredFunctionCalls = [] + } } catch { didError = true @@ -216,11 +227,21 @@ fileprivate func generateCallInfo(_ actualExpression: Expression, _ allFunctionsCalled = Array(validInstance.functionConsumer.calls.wrappedValue.keys) - let builder: MockExpectationBuilder = builderCreator(validInstance) - validInstance.functionConsumer.trackCalls = false - maybeFunction = try? builder.build() - desiredFunctionCalls = (validInstance.functionConsumer.calls.wrappedValue[maybeFunction?.name ?? ""] ?? []) - validInstance.functionConsumer.trackCalls = true + // Only check for the specific function calls if there was at least a single + // call (if there weren't any this will likely throw errors when attempting + // to build) + if !allFunctionsCalled.isEmpty { + let builder: MockExpectationBuilder = builderCreator(validInstance) + validInstance.functionConsumer.trackCalls = false + maybeFunction = try? builder.build() + desiredFunctionCalls = validInstance.functionConsumer.calls + .wrappedValue[maybeFunction?.name ?? ""] + .defaulting(to: []) + validInstance.functionConsumer.trackCalls = true + } + else { + desiredFunctionCalls = [] + } #endif return CallInfo( From 94742c80ec1285033a9722ba4363a3fca31e4485 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 26 Apr 2022 17:31:50 +1000 Subject: [PATCH 065/157] Further work on the JobRunner Fixed an issue where the hash retrieved when fetching messages from the service node might not be the latest one Updated the MessageReceiveJob to batch process messages (on failure only the failed messages will retry) --- Session/Utilities/BackgroundPoller.swift | 83 ++++++++++++---- .../Migrations/_003_YDBToGRDBMigration.swift | 8 +- .../Jobs/Types/MessageReceiveJob.swift | 99 ++++++++++++++----- .../_001_InitialSetupMigration.swift | 5 +- .../Migrations/_002_YDBToGRDBMigration.swift | 9 +- .../Models/SnodeReceivedMessageInfo.swift | 35 ++++++- 6 files changed, 183 insertions(+), 56 deletions(-) diff --git a/Session/Utilities/BackgroundPoller.swift b/Session/Utilities/BackgroundPoller.swift index bbdd9d9f8..64e2e36ce 100644 --- a/Session/Utilities/BackgroundPoller.swift +++ b/Session/Utilities/BackgroundPoller.swift @@ -45,33 +45,76 @@ public final class BackgroundPoller : NSObject { private static func getMessages(for publicKey: String) -> Promise { return SnodeAPI.getSwarm(for: publicKey).then(on: DispatchQueue.main) { swarm -> Promise in guard let snode = swarm.randomElement() else { throw SnodeAPI.Error.generic } + return attempt(maxRetryCount: 4, recoveringOn: DispatchQueue.main) { return SnodeAPI.getRawMessages(from: snode, associatedWith: publicKey).then(on: DispatchQueue.main) { rawResponse -> Promise in let messages: [SnodeReceivedMessage] = SnodeAPI.parseRawMessagesResponse(rawResponse, from: snode, associatedWith: publicKey) - let promises = messages.compactMap { message -> Promise? in - // Use a best attempt approach here; we don't want to fail the entire process - // if one of the messages failed to parse - guard - let envelope = SNProtoEnvelope.from(message), - let data = try? envelope.serializedData() - else { return nil } - - let job = MessageReceiveJob( - data: data, - serverHash: message.info.hash, - isBackgroundPoll: true - ) - return job.execute() - } - // Now that the MessageReceiveJob's have been created we can persist the received messages - if !messages.isEmpty { - GRDBStorage.shared.write { db in - messages.forEach { try? $0.info.save(db) } + guard !messages.isEmpty else { return Promise.value(()) } + + var jobsToRun: [Job] = [] + + GRDBStorage.shared.write { db in + var threadMessages: [String: [MessageReceiveJob.Details.MessageInfo]] = [:] + // TODO: Test this updated logic + messages.forEach { message in + guard let envelope = SNProtoEnvelope.from(message) else { return } + + // Extract the threadId and add that to the messageReceive job for + // multi-threading and garbage collection purposes + let threadId: String? = MessageReceiver.extractSenderPublicKey(db, from: envelope) + + do { + threadMessages[threadId ?? ""] = (threadMessages[threadId ?? ""] ?? []) + .appending( + MessageReceiveJob.Details.MessageInfo( + data: try envelope.serializedData(), + serverHash: message.info.hash + ) + ) + + // Persist the received message after the MessageReceiveJob is created + _ = try message.info.saved(db) + } + catch { + SNLog("Failed to deserialize envelope due to error: \(error).") + } } + + threadMessages + .forEach { threadId, threadMessages in + let maybeJob: Job? = Job( + variant: .messageReceive, + behaviour: .runOnce, + threadId: threadId, + details: MessageReceiveJob.Details( + messages: threadMessages, + isBackgroundPoll: false + ) + ) + + guard let job: Job = maybeJob else { return } + + JobRunner.add(db, job: job) + jobsToRun.append(job) + } } - return when(fulfilled: promises) // The promise returned by MessageReceiveJob never rejects + let promises = jobsToRun.compactMap { job -> Promise? in + let (promise, seal) = Promise.pending() + + // Note: In the background we just want jobs to fail silently + MessageReceiveJob.run( + job, + success: { _, _ in seal.fulfill(()) }, + failure: { _, _, _ in seal.fulfill(()) }, + deferred: { _ in seal.fulfill(()) } + ) + + return promise + } + + return when(fulfilled: promises) } } } diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index e14d89ff9..5fd482a8d 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -814,8 +814,12 @@ enum _003_YDBToGRDBMigration: Migration { nextRunTimestamp: 0, threadId: threadId, details: MessageReceiveJob.Details( - data: legacyJob.data, - serverHash: legacyJob.serverHash, + messages: [ + MessageReceiveJob.Details.MessageInfo( + data: legacyJob.data, + serverHash: legacyJob.serverHash + ) + ], isBackgroundPoll: legacyJob.isBackgroundPoll ) )?.inserted(db) diff --git a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift index 59027e7e6..f95d476d3 100644 --- a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift @@ -23,33 +23,66 @@ public enum MessageReceiveJob: JobExecutor { return } - var processingError: Error? + var updatedJob: Job = job + var leastSevereError: Error? GRDBStorage.shared.write { db in - do { - let isRetry: Bool = (job.failureCount > 0) - let (message, proto) = try MessageReceiver.parse( - db, - data: details.data, - isRetry: isRetry - ) - message.serverHash = details.serverHash - - try MessageReceiver.handle( - db, - message: message, - associatedWithProto: proto, - openGroupId: nil, - isBackgroundPoll: details.isBackgroundPoll - ) - } - catch { - processingError = error + var remainingMessagesToProcess: [Details.MessageInfo] = [] + + for messageInfo in details.messages { + do { + // Note: The main reason why the 'MessageReceiver.parse' can fail but then succeed + // later on is when we get a closed group message which is signed using a new key + // but haven't received the key yet (the key gets sent directly to the user rather + // than via the closed group so this is unfortunately a possible case) + let isRetry: Bool = (job.failureCount > 0) + let (message, proto) = try MessageReceiver.parse( + db, + data: messageInfo.data, + isRetry: isRetry + ) + message.serverHash = messageInfo.serverHash + + try MessageReceiver.handle( + db, + message: message, + associatedWithProto: proto, + openGroupId: nil, + isBackgroundPoll: details.isBackgroundPoll + ) + } + catch { + // We failed to process this message so add it to the list to re-process + remainingMessagesToProcess.append(messageInfo) + + // If the current message is a permanent failure then override it with the new error (we want + // to retry if there is a single non-permanent error) + switch leastSevereError { + case let error as MessageReceiverError where !error.isRetryable: + leastSevereError = error + + default: break + } + } } + + // If any messages failed to process then we want to update the job to only include + // those failed messages + updatedJob = try job + .with( + details: Details( + messages: remainingMessagesToProcess, + isBackgroundPoll: details.isBackgroundPoll + ) + ) + .defaulting(to: job) + .saved(db) + } + } // Handle the result - switch processingError { + switch leastSevereError { case let error as MessageReceiverError where !error.isRetryable: SNLog("Message receive job permanently failed due to error: \(error)") failure(job, error, true) @@ -68,8 +101,28 @@ public enum MessageReceiveJob: JobExecutor { extension MessageReceiveJob { public struct Details: Codable { - public let data: Data - public let serverHash: String? + public struct MessageInfo: Codable { + public let data: Data + public let serverHash: String? + + public init( + data: Data, + serverHash: String? + ) { + self.data = data + self.serverHash = serverHash + } + } + + public let messages: [MessageInfo] public let isBackgroundPoll: Bool + + public init( + messages: [MessageInfo], + isBackgroundPoll: Bool + ) { + self.messages = messages + self.isBackgroundPoll = isBackgroundPoll + } } } diff --git a/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift index 37523b470..8740d8191 100644 --- a/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -33,6 +33,9 @@ enum _001_InitialSetupMigration: Migration { } try db.create(table: SnodeReceivedMessageInfo.self) { t in + t.column(.id, .integer) + .notNull() + .primaryKey(autoincrement: true) t.column(.key, .text) .notNull() .indexed() @@ -41,7 +44,7 @@ enum _001_InitialSetupMigration: Migration { .notNull() .indexed() - t.primaryKey([.key, .hash]) + t.uniqueKey([.key, .hash]) } } } diff --git a/SessionSnodeKit/Database/Migrations/_002_YDBToGRDBMigration.swift b/SessionSnodeKit/Database/Migrations/_002_YDBToGRDBMigration.swift index 71bebacf4..444c5b03f 100644 --- a/SessionSnodeKit/Database/Migrations/_002_YDBToGRDBMigration.swift +++ b/SessionSnodeKit/Database/Migrations/_002_YDBToGRDBMigration.swift @@ -7,7 +7,6 @@ import SessionUtilitiesKit enum _002_YDBToGRDBMigration: Migration { static let identifier: String = "YDBToGRDBMigration" - // TODO: Autorelease pool??? static func migrate(_ db: Database) throws { // MARK: - OnionRequestPath, Snode Pool & Swarm @@ -126,20 +125,20 @@ enum _002_YDBToGRDBMigration: Migration { try autoreleasepool { try receivedMessageResults.forEach { key, hashes in try hashes.forEach { hash in - try SnodeReceivedMessageInfo( + _ = try SnodeReceivedMessageInfo( key: key, hash: hash, expirationDateMs: 0 - ).insert(db) + ).inserted(db) } } try lastMessageResults.forEach { key, data in - try SnodeReceivedMessageInfo( + _ = try SnodeReceivedMessageInfo( key: key, hash: data.hash, expirationDateMs: ((data.json["expirationDate"] as? Int64) ?? 0) - ).insert(db) + ).inserted(db) } } } diff --git a/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift b/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift index 7577ec8c0..8010df5d3 100644 --- a/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift +++ b/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift @@ -4,19 +4,39 @@ import Foundation import GRDB import SessionUtilitiesKit -public struct SnodeReceivedMessageInfo: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct SnodeReceivedMessageInfo: Codable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "snodeReceivedMessageInfo" } public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { + case id case key case hash case expirationDateMs } + /// The `id` value is auto incremented by the database, if the `Job` hasn't been inserted into + /// the database yet this value will be `nil` + public var id: Int64? = nil + + /// The key this message hash is associated to + /// + /// This will be a combination of {address}.{port}.{publicKey} for new rows and just the {publicKey} for legacy rows public let key: String + + /// The is the hash for the received message public let hash: String + + /// This is the timestamp (in milliseconds since epoch) when the message hash should expire + /// + /// **Note:** A value of `0` means this hash should not expire public let expirationDateMs: Int64 + + // MARK: - Custom Database Interaction + + public mutating func didInsert(with rowID: Int64, for column: String?) { + self.id = rowID + } } // MARK: - Convenience @@ -51,12 +71,17 @@ public extension SnodeReceivedMessageInfo { } } + /// This method fetches the last non-expired hash from the database for message retrieval + /// + /// **Note:** This method uses a `write` instead of a read because there is a single write queue for the database and it's very common for + /// this method to be called after the hash value has been updated but before the various `read` threads have been updated, resulting in a + /// pointless fetch for data the app has already received static func fetchLastNotExpired(for snode: Snode, associatedWith publicKey: String) -> SnodeReceivedMessageInfo? { - return GRDBStorage.shared.read { db in - try? SnodeReceivedMessageInfo + return GRDBStorage.shared.write { db in + try SnodeReceivedMessageInfo .filter(SnodeReceivedMessageInfo.Columns.key == key(for: snode, publicKey: publicKey)) - .order(SnodeReceivedMessageInfo.Columns.expirationDateMs) - .reversed() + .filter(SnodeReceivedMessageInfo.Columns.expirationDateMs <= (Date().timeIntervalSince1970 * 1000)) + .order(SnodeReceivedMessageInfo.Columns.id.desc) .fetchOne(db) } } From 3baeb981d9e2e1a5b3983a1f345344aacb77e664 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 27 Apr 2022 10:48:54 +1000 Subject: [PATCH 066/157] Further work on the JobRunner Moved the JobRunner into SessionUtilitiesKit so it can be used by SessionSnodeKit Exposed a 'sharedLokiProject' value on UserDefaults to remove the hard-coded group name used everywhere Added "blocking" job support for 'OnLaunch' and 'OnActive' jobs to the JobRunner (will retry until it succeeds) Added the UpdateProfilePicture and RetrieveDefaultOpenGroupRooms jobs --- ...Configuration.swift => Configuration.swift | 2 + Session.xcodeproj/project.pbxproj | 64 ++++++--- Session/Home/HomeVC.swift | 6 - Session/Meta/AppDelegate.swift | 8 +- Session/Meta/MainAppContext.m | 5 +- Session/Notifications/SyncPushTokensJob.swift | 10 +- .../_001_InitialSetupMigration.swift | 24 ---- .../Migrations/_002_SetupStandardJobs.swift | 24 ++-- .../Database/Models/Interaction.swift | 5 + .../Database/Models/SessionThread.swift | 11 ++ .../Jobs/Types/AttachmentDownloadJob.swift | 2 +- .../Jobs/Types/DisappearingMessagesJob.swift | 19 +-- .../Types/FailedAttachmentDownloadsJob.swift | 2 +- .../Jobs/Types/FailedMessagesJob.swift | 2 +- .../Jobs/Types/MessageReceiveJob.swift | 2 +- .../Jobs/Types/MessageSendJob.swift | 2 +- .../Jobs/Types/NotifyPushServerJob.swift | 2 +- .../RetrieveDefaultOpenGroupRoomsJob.swift | 30 ++++ .../Jobs/Types/SendReadReceiptsJob.swift | 2 +- .../Jobs/Types/UpdateProfilePictureJob.swift | 46 ++++++ .../Open Groups/OpenGroupAPIV2+ObjC.swift | 5 - .../MessageReceiver+Decryption.swift | 2 +- .../MessageReceiver+Handling.swift | 12 +- .../Sending & Receiving/MessageSender.swift | 5 +- .../Pollers/ClosedGroupPoller.swift | 32 +++-- .../Sending & Receiving/Pollers/Poller.swift | 35 +++-- .../NotificationServiceExtension.swift | 6 +- SessionSnodeKit/Configuration.swift | 7 +- .../Migrations/_002_SetupStandardJobs.swift | 20 +++ ...on.swift => _003_YDBToGRDBMigration.swift} | 3 +- SessionSnodeKit/GetSnodePoolJob.swift | 24 ++++ SessionSnodeKit/SnodeAPI.swift | 5 +- SessionUtilitiesKit/Configuration.swift | 5 +- .../_001_InitialSetupMigration.swift | 24 ++++ .../Migrations/_002_SetupStandardJobs.swift | 21 +++ ...on.swift => _003_YDBToGRDBMigration.swift} | 3 +- .../Database/Models/Job.swift | 113 +++++++++++---- .../General/SNUserDefaults.swift | 3 + .../JobRunner}/JobRunner.swift | 131 ++++++++++++++---- .../JobRunner}/JobRunnerError.swift | 0 40 files changed, 519 insertions(+), 205 deletions(-) rename SessionMessagingKit/Configuration.swift => Configuration.swift (89%) create mode 100644 SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift create mode 100644 SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift create mode 100644 SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift rename SessionSnodeKit/Database/Migrations/{_002_YDBToGRDBMigration.swift => _003_YDBToGRDBMigration.swift} (98%) create mode 100644 SessionSnodeKit/GetSnodePoolJob.swift create mode 100644 SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift rename SessionUtilitiesKit/Database/Migrations/{_002_YDBToGRDBMigration.swift => _003_YDBToGRDBMigration.swift} (96%) rename {SessionMessagingKit => SessionUtilitiesKit}/Database/Models/Job.swift (64%) rename {SessionMessagingKit/Jobs => SessionUtilitiesKit/JobRunner}/JobRunner.swift (80%) rename {SessionMessagingKit/Jobs => SessionUtilitiesKit/JobRunner}/JobRunnerError.swift (100%) diff --git a/SessionMessagingKit/Configuration.swift b/Configuration.swift similarity index 89% rename from SessionMessagingKit/Configuration.swift rename to Configuration.swift index cb62884f2..cf8f34141 100644 --- a/SessionMessagingKit/Configuration.swift +++ b/Configuration.swift @@ -33,6 +33,8 @@ public enum SNMessagingKit { // Just to make the external API nice JobRunner.add(executor: DisappearingMessagesJob.self, for: .disappearingMessages) JobRunner.add(executor: FailedMessagesJob.self, for: .failedMessages) JobRunner.add(executor: FailedAttachmentDownloadsJob.self, for: .failedAttachmentDownloads) + JobRunner.add(executor: UpdateProfilePictureJob.self, for: .updateProfilePicture) + JobRunner.add(executor: RetrieveDefaultOpenGroupRoomsJob.self, for: .retrieveDefaultOpenGroupRooms) JobRunner.add(executor: MessageSendJob.self, for: .messageSend) JobRunner.add(executor: MessageReceiveJob.self, for: .messageReceive) JobRunner.add(executor: NotifyPushServerJob.self, for: .notifyPushServer) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index aaea0791e..2a1143247 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -751,7 +751,7 @@ FD17D7A027F40CC800122BE0 /* _001_InitialSetupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79F27F40CC800122BE0 /* _001_InitialSetupMigration.swift */; }; FD17D7A127F40D2500122BE0 /* GRDBStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD28A4F527EAD44C00FF65E7 /* GRDBStorage.swift */; }; FD17D7A227F40F0500122BE0 /* _001_InitialSetupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79527F3E04600122BE0 /* _001_InitialSetupMigration.swift */; }; - FD17D7A427F40F8100122BE0 /* _002_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7A327F40F8100122BE0 /* _002_YDBToGRDBMigration.swift */; }; + FD17D7A427F40F8100122BE0 /* _003_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7A327F40F8100122BE0 /* _003_YDBToGRDBMigration.swift */; }; FD17D7A727F41AF000122BE0 /* SSKLegacyModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7A627F41AF000122BE0 /* SSKLegacyModels.swift */; }; FD17D7AA27F41BF500122BE0 /* SnodeSet.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7A927F41BF500122BE0 /* SnodeSet.swift */; }; FD17D7AE27F41C4300122BE0 /* SnodeReceivedMessageInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7AD27F41C4300122BE0 /* SnodeReceivedMessageInfo.swift */; }; @@ -772,7 +772,7 @@ FD17D7D827F658E200122BE0 /* SSKDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7D727F658E200122BE0 /* SSKDestination.swift */; }; FD17D7E127F67BD400122BE0 /* SnodeReceivedMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E027F67BD400122BE0 /* SnodeReceivedMessage.swift */; }; FD17D7E527F6A09900122BE0 /* Identity.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E427F6A09900122BE0 /* Identity.swift */; }; - FD17D7E727F6A16700122BE0 /* _002_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E627F6A16700122BE0 /* _002_YDBToGRDBMigration.swift */; }; + FD17D7E727F6A16700122BE0 /* _003_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E627F6A16700122BE0 /* _003_YDBToGRDBMigration.swift */; }; FD17D7EA27F6A1C600122BE0 /* SUKLegacyModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E927F6A1C600122BE0 /* SUKLegacyModels.swift */; }; FD28A4F227E990E800FF65E7 /* BlockingManagerRemovalMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD28A4F127E990E800FF65E7 /* BlockingManagerRemovalMigration.swift */; }; FD28A4F427EA79F800FF65E7 /* BlockListUIUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD28A4F327EA79F800FF65E7 /* BlockListUIUtils.swift */; }; @@ -781,6 +781,9 @@ FD5D200F27AA2B6000FEA984 /* MessageRequestResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */; }; FD5D201127AA331F00FEA984 /* ConfigurationMessage+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D201027AA331F00FEA984 /* ConfigurationMessage+Convenience.swift */; }; FD659AC027A7649600F12C02 /* MessageRequestsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD659ABF27A7649600F12C02 /* MessageRequestsViewController.swift */; }; + FD6A7A692818BE7300035AC1 /* RetrieveDefaultOpenGroupRoomsJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6A7A682818BE7300035AC1 /* RetrieveDefaultOpenGroupRoomsJob.swift */; }; + FD6A7A6B2818C17C00035AC1 /* UpdateProfilePictureJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6A7A6A2818C17C00035AC1 /* UpdateProfilePictureJob.swift */; }; + FD6A7A6D2818C61500035AC1 /* _002_SetupStandardJobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6A7A6C2818C61500035AC1 /* _002_SetupStandardJobs.swift */; }; FD705A8C278CDB5600F16121 /* SAEScreenLockViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A8B278CDB5600F16121 /* SAEScreenLockViewController.swift */; }; FD705A8E278CE29800F16121 /* String+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A8D278CE29800F16121 /* String+Localization.swift */; }; FD705A90278CEBBC00F16121 /* ShareAppExtensionContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A8F278CEBBC00F16121 /* ShareAppExtensionContext.swift */; }; @@ -790,6 +793,11 @@ FD859F0027C4691300510D0C /* MockDataGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EFF27C4691300510D0C /* MockDataGenerator.swift */; }; FD88BAD927A7439C00BBC442 /* MessageRequestsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */; }; FD88BADB27A750F200BBC442 /* MessageRequestsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */; }; + FD90040F2818AB6D00ABAAF6 /* GetSnodePoolJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD90040E2818AB6D00ABAAF6 /* GetSnodePoolJob.swift */; }; + FD9004122818ABDC00ABAAF6 /* Job.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B73F280402C4004C14C5 /* Job.swift */; }; + FD9004142818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9004132818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift */; }; + FD9004152818B46300ABAAF6 /* JobRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7432804EF1B004C14C5 /* JobRunner.swift */; }; + FD9004162818B46700ABAAF6 /* JobRunnerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE77F68280F9EDA002CFC5D /* JobRunnerError.swift */; }; FDA8EAFE280E8B78002B68E5 /* FailedMessagesJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA8EAFD280E8B78002B68E5 /* FailedMessagesJob.swift */; }; FDA8EB00280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA8EAFF280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift */; }; FDA8EB09280E90FB002B68E5 /* AppSetup.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF287255B6D85007E1867 /* AppSetup.m */; }; @@ -798,12 +806,9 @@ FDC4389E27BA2B8A00C60D73 /* SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */; }; FDCDB8DE2810F73B00352A0C /* Differentiable+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DD2810F73B00352A0C /* Differentiable+Utilities.swift */; }; FDCDB8E02811007F00352A0C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */; }; - FDE77F69280F9EDA002CFC5D /* JobRunnerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE77F68280F9EDA002CFC5D /* JobRunnerError.swift */; }; FDE77F6B280FEB28002CFC5D /* ControlMessageProcessRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */; }; FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */; }; - FDF0B740280402C4004C14C5 /* Job.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B73F280402C4004C14C5 /* Job.swift */; }; FDF0B7422804EA4F004C14C5 /* _002_SetupStandardJobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7412804EA4F004C14C5 /* _002_SetupStandardJobs.swift */; }; - FDF0B7442804EF1B004C14C5 /* JobRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7432804EF1B004C14C5 /* JobRunner.swift */; }; FDF0B7472804F0CE004C14C5 /* DisappearingMessagesJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7462804F0CE004C14C5 /* DisappearingMessagesJob.swift */; }; FDF0B74928060D13004C14C5 /* QuotedReplyModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B74828060D13004C14C5 /* QuotedReplyModel.swift */; }; FDF0B74B28061F7A004C14C5 /* InteractionAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B74A28061F7A004C14C5 /* InteractionAttachment.swift */; }; @@ -1815,7 +1820,7 @@ FD17D79827F40AB800122BE0 /* _003_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _003_YDBToGRDBMigration.swift; sourceTree = ""; }; FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMKLegacyModels.swift; sourceTree = ""; }; FD17D79F27F40CC800122BE0 /* _001_InitialSetupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _001_InitialSetupMigration.swift; sourceTree = ""; }; - FD17D7A327F40F8100122BE0 /* _002_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_YDBToGRDBMigration.swift; sourceTree = ""; }; + FD17D7A327F40F8100122BE0 /* _003_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _003_YDBToGRDBMigration.swift; sourceTree = ""; }; FD17D7A627F41AF000122BE0 /* SSKLegacyModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSKLegacyModels.swift; sourceTree = ""; }; FD17D7A927F41BF500122BE0 /* SnodeSet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnodeSet.swift; sourceTree = ""; }; FD17D7AD27F41C4300122BE0 /* SnodeReceivedMessageInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnodeReceivedMessageInfo.swift; sourceTree = ""; }; @@ -1836,7 +1841,7 @@ FD17D7D727F658E200122BE0 /* SSKDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSKDestination.swift; sourceTree = ""; }; FD17D7E027F67BD400122BE0 /* SnodeReceivedMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnodeReceivedMessage.swift; sourceTree = ""; }; FD17D7E427F6A09900122BE0 /* Identity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Identity.swift; sourceTree = ""; }; - FD17D7E627F6A16700122BE0 /* _002_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_YDBToGRDBMigration.swift; sourceTree = ""; }; + FD17D7E627F6A16700122BE0 /* _003_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _003_YDBToGRDBMigration.swift; sourceTree = ""; }; FD17D7E927F6A1C600122BE0 /* SUKLegacyModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SUKLegacyModels.swift; sourceTree = ""; }; FD28A4F127E990E800FF65E7 /* BlockingManagerRemovalMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockingManagerRemovalMigration.swift; sourceTree = ""; }; FD28A4F327EA79F800FF65E7 /* BlockListUIUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockListUIUtils.swift; sourceTree = ""; }; @@ -1846,6 +1851,9 @@ FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestResponse.swift; sourceTree = ""; }; FD5D201027AA331F00FEA984 /* ConfigurationMessage+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConfigurationMessage+Convenience.swift"; sourceTree = ""; }; FD659ABF27A7649600F12C02 /* MessageRequestsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsViewController.swift; sourceTree = ""; }; + FD6A7A682818BE7300035AC1 /* RetrieveDefaultOpenGroupRoomsJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetrieveDefaultOpenGroupRoomsJob.swift; sourceTree = ""; }; + FD6A7A6A2818C17C00035AC1 /* UpdateProfilePictureJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateProfilePictureJob.swift; sourceTree = ""; }; + FD6A7A6C2818C61500035AC1 /* _002_SetupStandardJobs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_SetupStandardJobs.swift; sourceTree = ""; }; FD705A8B278CDB5600F16121 /* SAEScreenLockViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SAEScreenLockViewController.swift; sourceTree = ""; }; FD705A8D278CE29800F16121 /* String+Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Localization.swift"; sourceTree = ""; }; FD705A8F278CEBBC00F16121 /* ShareAppExtensionContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAppExtensionContext.swift; sourceTree = ""; }; @@ -1855,6 +1863,8 @@ FD859EFF27C4691300510D0C /* MockDataGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDataGenerator.swift; sourceTree = ""; }; FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsCell.swift; sourceTree = ""; }; FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsMigration.swift; sourceTree = ""; }; + FD90040E2818AB6D00ABAAF6 /* GetSnodePoolJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetSnodePoolJob.swift; sourceTree = ""; }; + FD9004132818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_SetupStandardJobs.swift; sourceTree = ""; }; FD9039443F7CB729CF71350E /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig"; sourceTree = ""; }; FDA8EAFD280E8B78002B68E5 /* FailedMessagesJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedMessagesJob.swift; sourceTree = ""; }; FDA8EAFF280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedAttachmentDownloadsJob.swift; sourceTree = ""; }; @@ -2853,8 +2863,6 @@ isa = PBXGroup; children = ( FDF0B7452804F0A8004C14C5 /* Types */, - FDF0B7432804EF1B004C14C5 /* JobRunner.swift */, - FDE77F68280F9EDA002CFC5D /* JobRunnerError.swift */, C352A2F425574B4700338F3E /* LegacyJob.swift */, C352A3922557883D00338F3E /* JobDelegate.swift */, C352A3882557876500338F3E /* JobQueue.swift */, @@ -3272,6 +3280,7 @@ C3C2A5BA255385ED00C340D1 /* OnionRequestAPI.swift */, C3C2A5BB255385ED00C340D1 /* OnionRequestAPI+Encryption.swift */, C3C2A5BE255385EE00C340D1 /* SnodeAPI.swift */, + FD90040E2818AB6D00ABAAF6 /* GetSnodePoolJob.swift */, C3C2A5B6255385EC00C340D1 /* SnodeMessage.swift */, C3C2A5CD255385F300C340D1 /* Utilities */, ); @@ -3306,6 +3315,7 @@ B8A582AC258C653C00AFD84C /* Crypto */, B8A582AB258C64E800AFD84C /* Database */, B8A582B0258C66C900AFD84C /* General */, + FD9004102818ABB000ABAAF6 /* JobRunner */, B8A582AF258C665E00AFD84C /* Media */, B8A582B9258C696200AFD84C /* Messaging */, B8A582AE258C65D000AFD84C /* Networking */, @@ -3331,7 +3341,6 @@ C3C2A7802553AA6300C340D1 /* Protos */, C3C2A70A25539DF900C340D1 /* Meta */, C32C5BB9256DC7C4003C73A2 /* To Do */, - C3BBE0752554CDA60050F1E3 /* Configuration.swift */, C3BBE07F2554CDD70050F1E3 /* Storage.swift */, C32C5BCB256DC818003C73A2 /* Database */, C300A5BB2554AFFB00555489 /* Messages */, @@ -3520,6 +3529,7 @@ C33FD9AC255A548A00E217F9 /* SignalUtilitiesKit */, C331FF1C2558F9D300070591 /* SessionUIKit */, C3C2A6F125539DE700C340D1 /* SessionMessagingKit */, + C3BBE0752554CDA60050F1E3 /* Configuration.swift */, C3C2A5A0255385C100C340D1 /* SessionSnodeKit */, C3C2A67A255388CC00C340D1 /* SessionUtilitiesKit */, D221A08C169C9E5E00537ABF /* Frameworks */, @@ -3648,7 +3658,6 @@ FD09799A27FFC82D00936362 /* Quote.swift */, FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */, FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */, - FDF0B73F280402C4004C14C5 /* Job.swift */, ); path = Models; sourceTree = ""; @@ -3686,7 +3695,8 @@ isa = PBXGroup; children = ( FD17D79F27F40CC800122BE0 /* _001_InitialSetupMigration.swift */, - FD17D7A327F40F8100122BE0 /* _002_YDBToGRDBMigration.swift */, + FD6A7A6C2818C61500035AC1 /* _002_SetupStandardJobs.swift */, + FD17D7A327F40F8100122BE0 /* _003_YDBToGRDBMigration.swift */, ); path = Migrations; sourceTree = ""; @@ -3744,7 +3754,8 @@ isa = PBXGroup; children = ( FD17D7C927F546D900122BE0 /* _001_InitialSetupMigration.swift */, - FD17D7E627F6A16700122BE0 /* _002_YDBToGRDBMigration.swift */, + FD9004132818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift */, + FD17D7E627F6A16700122BE0 /* _003_YDBToGRDBMigration.swift */, ); path = Migrations; sourceTree = ""; @@ -3753,6 +3764,7 @@ isa = PBXGroup; children = ( FD17D7E427F6A09900122BE0 /* Identity.swift */, + FDF0B73F280402C4004C14C5 /* Job.swift */, FD17D7CC27F546FF00122BE0 /* Setting.swift */, ); path = Models; @@ -3801,12 +3813,23 @@ path = Views; sourceTree = ""; }; + FD9004102818ABB000ABAAF6 /* JobRunner */ = { + isa = PBXGroup; + children = ( + FDF0B7432804EF1B004C14C5 /* JobRunner.swift */, + FDE77F68280F9EDA002CFC5D /* JobRunnerError.swift */, + ); + path = JobRunner; + sourceTree = ""; + }; FDF0B7452804F0A8004C14C5 /* Types */ = { isa = PBXGroup; children = ( FDF0B7462804F0CE004C14C5 /* DisappearingMessagesJob.swift */, FDA8EAFD280E8B78002B68E5 /* FailedMessagesJob.swift */, FDA8EAFF280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift */, + FD6A7A6A2818C17C00035AC1 /* UpdateProfilePictureJob.swift */, + FD6A7A682818BE7300035AC1 /* RetrieveDefaultOpenGroupRoomsJob.swift */, C352A2FE25574B6300338F3E /* MessageSendJob.swift */, C352A31225574F5200338F3E /* MessageReceiveJob.swift */, C352A32E2557549C00338F3E /* NotifyPushServerJob.swift */, @@ -4792,7 +4815,7 @@ C3C2A5C7255385EE00C340D1 /* SnodeAPI.swift in Sources */, C3C2A5C6255385EE00C340D1 /* Notification+OnionRequestAPI.swift in Sources */, FD17D7AA27F41BF500122BE0 /* SnodeSet.swift in Sources */, - FD17D7A427F40F8100122BE0 /* _002_YDBToGRDBMigration.swift in Sources */, + FD17D7A427F40F8100122BE0 /* _003_YDBToGRDBMigration.swift in Sources */, C3C2A5DC2553860B00C340D1 /* Promise+Threading.swift in Sources */, C3C2A5C4255385EE00C340D1 /* OnionRequestAPI+Encryption.swift in Sources */, FD17D7D227F5797A00122BE0 /* SSKEndpoint.swift in Sources */, @@ -4802,9 +4825,11 @@ FD17D7A727F41AF000122BE0 /* SSKLegacyModels.swift in Sources */, C3C2A5C2255385EE00C340D1 /* Configuration.swift in Sources */, FD17D7D827F658E200122BE0 /* SSKDestination.swift in Sources */, + FD6A7A6D2818C61500035AC1 /* _002_SetupStandardJobs.swift in Sources */, FD17D7B327F51E5B00122BE0 /* SSKSetting.swift in Sources */, FD17D7AE27F41C4300122BE0 /* SnodeReceivedMessageInfo.swift in Sources */, C3C2A5C3255385EE00C340D1 /* OnionRequestAPI.swift in Sources */, + FD90040F2818AB6D00ABAAF6 /* GetSnodePoolJob.swift in Sources */, FD17D7D427F6584600122BE0 /* SSKError.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -4832,6 +4857,7 @@ B8856E09256F1676001CE70E /* UIDevice+featureSupport.swift in Sources */, B8856DEF256F161F001CE70E /* NSString+SSK.m in Sources */, FD09796727F6B0B600936362 /* Sodium+Conversion.swift in Sources */, + FD9004122818ABDC00ABAAF6 /* Job.swift in Sources */, FDF0B74D280664E9004C14C5 /* PersistableRecord+Utilities.swift in Sources */, FD09797927FAB7E800936362 /* ImageFormat.swift in Sources */, C32C5DC9256DD935003C73A2 /* ProxiedContentDownloader.swift in Sources */, @@ -4839,6 +4865,7 @@ C3D9E43125676D3D0040E4F3 /* Configuration.swift in Sources */, C32C5DD2256DD9E5003C73A2 /* LRUCache.swift in Sources */, FD705A94278D052B00F16121 /* UITableView+ReusableView.swift in Sources */, + FD9004152818B46300ABAAF6 /* JobRunner.swift in Sources */, C3A7211A2558BCA10043A11F /* DiffieHellman.swift in Sources */, C32C5FA1256DFED5003C73A2 /* NSArray+Functional.m in Sources */, C3A7225E2558C38D0043A11F /* Promise+Retaining.swift in Sources */, @@ -4886,16 +4913,18 @@ C32C5A48256DB8F0003C73A2 /* BuildConfiguration.swift in Sources */, FD17D7BF27F51F8200122BE0 /* ColumnExpressible.swift in Sources */, FD17D7E527F6A09900122BE0 /* Identity.swift in Sources */, + FD9004142818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift in Sources */, B87EF18126377A1D00124B3C /* Features.swift in Sources */, FD09797727FAB7A600936362 /* Data+Image.swift in Sources */, C300A60D2554B31900555489 /* Logging.swift in Sources */, B8FF8EA625C11FEF004D1F22 /* IPv4.swift in Sources */, C3D9E35525675EE10040E4F3 /* MIMETypeUtil.m in Sources */, FD705A8E278CE29800F16121 /* String+Localization.swift in Sources */, + FD9004162818B46700ABAAF6 /* JobRunnerError.swift in Sources */, FD09797227FAA2F500936362 /* Optional+Utilities.swift in Sources */, C3A7219A2558C1660043A11F /* AnyPromise+Conversion.swift in Sources */, C300A6322554B6D100555489 /* NSDate+Timestamp.mm in Sources */, - FD17D7E727F6A16700122BE0 /* _002_YDBToGRDBMigration.swift in Sources */, + FD17D7E727F6A16700122BE0 /* _003_YDBToGRDBMigration.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4909,7 +4938,6 @@ C3A3A156256E1B91004D228D /* ProtoUtils.m in Sources */, FD09799927FFC1A300936362 /* Attachment.swift in Sources */, C3471ECB2555356A00297E91 /* MessageSender+Encryption.swift in Sources */, - FDE77F69280F9EDA002CFC5D /* JobRunnerError.swift in Sources */, FDF0B74928060D13004C14C5 /* QuotedReplyModel.swift in Sources */, C352A32F2557549C00338F3E /* NotifyPushServerJob.swift in Sources */, 7B4C75CB26B37E0F0000AC89 /* UnsendRequest.swift in Sources */, @@ -4919,6 +4947,7 @@ FD5D201127AA331F00FEA984 /* ConfigurationMessage+Convenience.swift in Sources */, C32C5A13256DB7A5003C73A2 /* PushNotificationAPI.swift in Sources */, C32A026325A801AA000ED5D4 /* NSData+messagePadding.m in Sources */, + FD6A7A6B2818C17C00035AC1 /* UpdateProfilePictureJob.swift in Sources */, C352A3932557883D00338F3E /* JobDelegate.swift in Sources */, C32C5B84256DC54F003C73A2 /* SSKEnvironment.m in Sources */, C3A3A108256E1A5C004D228D /* OWSIncomingMessageFinder.m in Sources */, @@ -4937,7 +4966,6 @@ C32C5CA4256DD1DC003C73A2 /* TSAccountManager.m in Sources */, C352A3892557876500338F3E /* JobQueue.swift in Sources */, C3BBE0B52554F0E10050F1E3 /* ProofOfWork.swift in Sources */, - FDF0B740280402C4004C14C5 /* Job.swift in Sources */, C32C59C1256DB41F003C73A2 /* TSGroupThread.m in Sources */, C3A3A08F256E1728004D228D /* FullTextSearchFinder.swift in Sources */, FDF0B7472804F0CE004C14C5 /* DisappearingMessagesJob.swift in Sources */, @@ -4990,6 +5018,7 @@ C32C5E15256DDC78003C73A2 /* SSKPreferences.swift in Sources */, C32C5D9C256DD6DC003C73A2 /* OWSOutgoingReceiptManager.m in Sources */, C32C5C4F256DCC36003C73A2 /* Storage+OpenGroups.swift in Sources */, + FD6A7A692818BE7300035AC1 /* RetrieveDefaultOpenGroupRoomsJob.swift in Sources */, C3DA9C0725AE7396008F7C7E /* ConfigurationMessage.swift in Sources */, B8856CEE256F1054001CE70E /* OWSAudioPlayer.m in Sources */, FD5D200F27AA2B6000FEA984 /* MessageRequestResponse.swift in Sources */, @@ -5018,7 +5047,6 @@ C3A3A0EC256E1949004D228D /* OWSRecipientIdentity.m in Sources */, FDF0B74B28061F7A004C14C5 /* InteractionAttachment.swift in Sources */, B8F5F56525EC8453003BF8D4 /* Notification+Contacts.swift in Sources */, - FDF0B7442804EF1B004C14C5 /* JobRunner.swift in Sources */, C32C5AB2256DBE8F003C73A2 /* TSMessage.m in Sources */, FD09796E27FA6D0000936362 /* Contact.swift in Sources */, C3A3A0FE256E1A3C004D228D /* TSDatabaseSecondaryIndexes.m in Sources */, diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index f2750a931..5adcfecde 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -165,16 +165,10 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve } } - // Re-populate snode pool if needed - SnodeAPI.getSnodePool().retainUntilComplete() - // Onion request path countries cache DispatchQueue.global(qos: .utility).sync { let _ = IP2Country.shared.populateCacheIfNeeded() } - - // Get default open group rooms if needed - OpenGroupAPIV2.getDefaultRoomsIfNeeded() } override func viewWillAppear(_ animated: Bool) { diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index dcbc72f48..2683b0d5c 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -162,9 +162,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD func applicationDidBecomeActive(_ application: UIApplication) { guard !CurrentAppContext().isRunningTests else { return } - // FIXME: We should move this somewhere to prevent typos from breaking it - let sharedUserDefaults: UserDefaults? = UserDefaults(suiteName: "group.com.loki-project.loki-messenger") - sharedUserDefaults?[.isMainAppActive] = true + UserDefaults.sharedLokiProject?[.isMainAppActive] = true ensureRootViewController() adapt(appMode: AppModeManager.getAppModeOrSystemDefault()) @@ -186,8 +184,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD func applicationWillResignActive(_ application: UIApplication) { clearAllNotificationsAndRestoreBadgeCount() - let sharedUserDefaults: UserDefaults? = UserDefaults(suiteName: "group.com.loki-project.loki-messenger") - sharedUserDefaults?[.isMainAppActive] = false + UserDefaults.sharedLokiProject?[.isMainAppActive] = false DDLog.flushLog() } @@ -258,7 +255,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD enableBackgroundRefreshIfNecessary() JobRunner.appDidBecomeActive() - SnodeAPI.getSnodePool().retainUntilComplete() startPollersIfNeeded() if CurrentAppContext().isMainApp { diff --git a/Session/Meta/MainAppContext.m b/Session/Meta/MainAppContext.m index b5e82f87d..d1b703476 100644 --- a/Session/Meta/MainAppContext.m +++ b/Session/Meta/MainAppContext.m @@ -226,9 +226,8 @@ NSString *const ReportedApplicationStateDidChangeNotification = @"ReportedApplic - (void)setMainAppBadgeNumber:(NSInteger)value { [[UIApplication sharedApplication] setApplicationIconBadgeNumber:value]; - NSUserDefaults *sharedUserDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.com.loki-project.loki-messenger"]; - [sharedUserDefaults setInteger:value forKey:@"currentBadgeNumber"]; - [sharedUserDefaults synchronize]; + [[NSUserDefaults sharedLokiProject] setInteger:value forKey:@"currentBadgeNumber"]; + [[NSUserDefaults sharedLokiProject] synchronize]; } - (nullable UIViewController *)frontmostViewController diff --git a/Session/Notifications/SyncPushTokensJob.swift b/Session/Notifications/SyncPushTokensJob.swift index fabce3628..e6496f801 100644 --- a/Session/Notifications/SyncPushTokensJob.swift +++ b/Session/Notifications/SyncPushTokensJob.swift @@ -8,7 +8,7 @@ import SessionMessagingKit import SessionUtilitiesKit public enum SyncPushTokensJob: JobExecutor { - public static let maxFailureCount: UInt = 0 + public static let maxFailureCount: Int = -1 public static let requiresThreadId: Bool = false public static let requiresInteractionId: Bool = false @@ -18,12 +18,8 @@ public enum SyncPushTokensJob: JobExecutor { failure: @escaping (Job, Error?, Bool) -> (), deferred: @escaping (Job) -> () ) { - // Don't schedule run when inactive or not in main app - var isMainAppActive = false - if let sharedUserDefaults = UserDefaults(suiteName: "group.com.loki-project.loki-messenger") { - isMainAppActive = sharedUserDefaults[.isMainAppActive] - } - guard isMainAppActive else { + // Don't run when inactive or not in main app + guard (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) else { deferred(job) // Don't need to do anything if it's not the main app return } diff --git a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift index bb4cc6518..af8637bfa 100644 --- a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -278,29 +278,5 @@ enum _001_InitialSetupMigration: Migration { t.uniqueKey([.threadId, .sentTimestampMs, .serverHash, .openGroupMessageServerId]) } - - try db.create(table: Job.self) { t in - t.column(.id, .integer) - .notNull() - .primaryKey(autoincrement: true) - t.column(.failureCount, .integer) - .notNull() - .defaults(to: 0) - t.column(.variant, .integer) - .notNull() - .indexed() // Quicker querying - t.column(.behaviour, .integer).notNull() // TODO: Indexed??? - t.column(.nextRunTimestamp, .double) - .notNull() // TODO: Should this just be nullable??? (or do we want to fetch by this?) - .indexed() // Quicker querying - .defaults(to: 0) - t.column(.threadId, .text) - .indexed() // Quicker querying - .references(SessionThread.self, onDelete: .cascade) // Delete if thread deleted - t.column(.interactionId, .text) - .indexed() // Quicker querying - .references(Interaction.self, onDelete: .cascade) // Delete if interaction deleted - t.column(.details, .blob) - } } } diff --git a/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift index 000b36cf0..35dc37a4f 100644 --- a/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -17,32 +17,28 @@ enum _002_SetupStandardJobs: Migration { try autoreleasepool { // TODO: Add additional jobs from the AppDelegate _ = try Job( - failureCount: 0, variant: .disappearingMessages, - behaviour: .recurringOnLaunch, - nextRunTimestamp: 0 + behaviour: .recurringOnLaunchBlockingOncePerSession ).inserted(db) _ = try Job( - failureCount: 0, variant: .failedMessages, - behaviour: .recurringOnLaunch, - nextRunTimestamp: 0 + behaviour: .recurringOnLaunchBlocking ).inserted(db) _ = try Job( - failureCount: 0, variant: .failedAttachmentDownloads, - behaviour: .recurringOnLaunch, - nextRunTimestamp: 0 + behaviour: .recurringOnLaunchBlocking ).inserted(db) - // Note: This job exists in the 'Session' target but that doesn't have it's own migrations _ = try Job( - failureCount: 0, - variant: .syncPushTokens, - behaviour: .recurringOnLaunch, - nextRunTimestamp: 0 + variant: .updateProfilePicture, + behaviour: .recurringOnActive + ).inserted(db) + + _ = try Job( + variant: .retrieveDefaultOpenGroupRooms, + behaviour: .recurringOnActive ).inserted(db) } } diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index deb9d7c72..ad45f5482 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -334,6 +334,11 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu } } + // Delete any jobs associated to this interaction + try Job + .filter(Job.Columns.interactionId == id) + .deleteAll(db) + return try performDelete(db) } } diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index 068f41e29..82a3dd92b 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -120,6 +120,17 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, self.notificationSound = notificationSound self.mutedUntilTimestamp = mutedUntilTimestamp } + + // MARK: - Custom Database Interaction + + public func delete(_ db: Database) throws -> Bool { + // Delete any jobs associated to this thread + try Job + .filter(Job.Columns.threadId == id) + .deleteAll(db) + + return try performDelete(db) + } } // MARK: - GRDB Interactions diff --git a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift index 990b90273..1a238112f 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift @@ -7,7 +7,7 @@ import SessionSnodeKit import SignalCoreKit public enum AttachmentDownloadJob: JobExecutor { - public static var maxFailureCount: UInt = 10 + public static var maxFailureCount: Int = 10 public static var requiresThreadId: Bool = true public static let requiresInteractionId: Bool = true diff --git a/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift b/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift index acc59cab1..a54b52ab5 100644 --- a/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift +++ b/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift @@ -5,7 +5,7 @@ import GRDB import SessionUtilitiesKit public enum DisappearingMessagesJob: JobExecutor { - public static let maxFailureCount: UInt = 0 + public static let maxFailureCount: Int = -1 public static let requiresThreadId: Bool = false public static let requiresInteractionId: Bool = false @@ -25,8 +25,15 @@ public enum DisappearingMessagesJob: JobExecutor { .filter(sql: "(\(Interaction.Columns.expiresStartedAtMs) + (\(Interaction.Columns.expiresInSeconds) * 1000) <= \(timestampNowMs)") .deleteAll(db) - // Update the next run timestamp for the DisappearingMessagesJob + // Update the next run timestamp for the DisappearingMessagesJob (if the call + // to 'updateNextRunIfNeeded' returns 'nil' then it doesn't need to re-run so + // should have it's 'nextRunTimestamp' cleared) return updateNextRunIfNeeded(db) + .defaulting( + to: try job + .with(nextRunTimestamp: 0) + .saved(db) + ) } success(updatedJob ?? job, false) @@ -40,12 +47,8 @@ public enum DisappearingMessagesJob: JobExecutor { public extension DisappearingMessagesJob { @discardableResult static func updateNextRunIfNeeded(_ db: Database) -> Job? { - // Don't schedule run when inactive or not in main app - var isMainAppActive = false - if let sharedUserDefaults = UserDefaults(suiteName: "group.com.loki-project.loki-messenger") { - isMainAppActive = sharedUserDefaults[.isMainAppActive] - } - guard isMainAppActive else { return nil } + // Don't run when inactive or not in main app + guard (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) else { return nil } // If there is another expiring message then update the job to run 1 second after it's meant to expire let nextExpirationTimestampMs: Double? = try? Double diff --git a/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift b/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift index 2c10422da..de2b02743 100644 --- a/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift +++ b/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift @@ -6,7 +6,7 @@ import SignalCoreKit import SessionUtilitiesKit public enum FailedAttachmentDownloadsJob: JobExecutor { - public static let maxFailureCount: UInt = 0 + public static let maxFailureCount: Int = -1 public static let requiresThreadId: Bool = false public static let requiresInteractionId: Bool = false diff --git a/SessionMessagingKit/Jobs/Types/FailedMessagesJob.swift b/SessionMessagingKit/Jobs/Types/FailedMessagesJob.swift index 30104c176..b72e37e05 100644 --- a/SessionMessagingKit/Jobs/Types/FailedMessagesJob.swift +++ b/SessionMessagingKit/Jobs/Types/FailedMessagesJob.swift @@ -6,7 +6,7 @@ import SignalCoreKit import SessionUtilitiesKit public enum FailedMessagesJob: JobExecutor { - public static let maxFailureCount: UInt = 0 + public static let maxFailureCount: Int = -1 public static let requiresThreadId: Bool = false public static let requiresInteractionId: Bool = false diff --git a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift index f95d476d3..f0993cb8b 100644 --- a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift @@ -5,7 +5,7 @@ import PromiseKit import SessionUtilitiesKit public enum MessageReceiveJob: JobExecutor { - public static var maxFailureCount: UInt = 10 + public static var maxFailureCount: Int = 10 public static var requiresThreadId: Bool = true public static let requiresInteractionId: Bool = false diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift index 2f6dc5cc3..11744912c 100644 --- a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift @@ -7,7 +7,7 @@ import SessionUtilitiesKit import SessionSnodeKit public enum MessageSendJob: JobExecutor { - public static var maxFailureCount: UInt = 10 + public static var maxFailureCount: Int = 10 public static var requiresThreadId: Bool = true public static let requiresInteractionId: Bool = false // Some messages don't have interactions diff --git a/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift b/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift index 95f00bf27..4ade85d97 100644 --- a/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift +++ b/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift @@ -6,7 +6,7 @@ import SessionSnodeKit import SessionUtilitiesKit public enum NotifyPushServerJob: JobExecutor { - public static var maxFailureCount: UInt = 20 + public static var maxFailureCount: Int = 20 public static var requiresThreadId: Bool = false public static let requiresInteractionId: Bool = false diff --git a/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift b/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift new file mode 100644 index 000000000..d9a9b4c2c --- /dev/null +++ b/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift @@ -0,0 +1,30 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SignalCoreKit +import SessionUtilitiesKit + +public enum RetrieveDefaultOpenGroupRoomsJob: JobExecutor { + public static let maxFailureCount: Int = -1 + public static let requiresThreadId: Bool = false + public static let requiresInteractionId: Bool = false + + public static func run( + _ job: Job, + success: @escaping (Job, Bool) -> (), + failure: @escaping (Job, Error?, Bool) -> (), + deferred: @escaping (Job) -> () + ) { + // Don't run when inactive or not in main app + guard (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) else { + deferred(job) // Don't need to do anything if it's not the main app + return + } + + OpenGroupAPIV2.getDefaultRoomsIfNeeded() + .done { _ in success(job, false) } + .catch { error in failure(job, error, false) } + .retainUntilComplete() + } +} diff --git a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift index 9f6d8dfd1..b2f2d443a 100644 --- a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift +++ b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift @@ -6,7 +6,7 @@ import PromiseKit import SessionUtilitiesKit public enum SendReadReceiptsJob: JobExecutor { - public static let maxFailureCount: UInt = 0 + public static let maxFailureCount: Int = -1 public static let requiresThreadId: Bool = false public static let requiresInteractionId: Bool = false private static let minRunFrequency: TimeInterval = 3 diff --git a/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift b/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift new file mode 100644 index 000000000..60e8d39d3 --- /dev/null +++ b/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift @@ -0,0 +1,46 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SignalCoreKit +import SessionUtilitiesKit + +public enum UpdateProfilePictureJob: JobExecutor { + public static let maxFailureCount: Int = -1 + public static let requiresThreadId: Bool = false + public static let requiresInteractionId: Bool = false + + public static func run( + _ job: Job, + success: @escaping (Job, Bool) -> (), + failure: @escaping (Job, Error?, Bool) -> (), + deferred: @escaping (Job) -> () + ) { + // Don't run when inactive or not in main app + guard (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) else { + deferred(job) // Don't need to do anything if it's not the main app + return + } + + // Only re-upload the profile picture if enough time has passed since the last upload + guard + let lastProfilePictureUpload: Date = UserDefaults.standard[.lastProfilePictureUpload], + Date().timeIntervalSince(lastProfilePictureUpload) > (14 * 24 * 60 * 60) + else { + deferred(job) + return + } + + // Note: The user defaults flag is updated in ProfileManager + let profile: Profile = Profile.fetchOrCreateCurrentUser() + let profilePicture: UIImage? = ProfileManager.profileAvatar(id: profile.id) + + ProfileManager.updateLocal( + profileName: profile.name, + avatarImage: profilePicture, + requiredSync: true, + success: { success(job, false) }, + failure: { error in failure(job, error, false) } + ) + } +} diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPIV2+ObjC.swift b/SessionMessagingKit/Open Groups/OpenGroupAPIV2+ObjC.swift index 11686edc8..466db6303 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPIV2+ObjC.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPIV2+ObjC.swift @@ -11,9 +11,4 @@ extension OpenGroupAPIV2 { public static func objc_isUserModerator(_ publicKey: String, for room: String, on server: String) -> Bool { return isUserModerator(publicKey, for: room, on: server) } - - @objc(getDefaultRoomsIfNeeded) - public static func objc_getDefaultRoomsIfNeeded() { - getDefaultRoomsIfNeeded() - } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift index 5791f5e85..297f27a91 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift @@ -13,7 +13,7 @@ extension MessageReceiver { /// /// **Note:** This is a slightly optimised version of the `decryptWithSessionProtocol` function which just skips /// the validation (handled when the job actually runs) and doesn't throw - internal static func extractSenderPublicKey(_ db: Database, from envelope: SNProtoEnvelope) -> String? { + public static func extractSenderPublicKey(_ db: Database, from envelope: SNProtoEnvelope) -> String? { guard let ciphertext: Data = envelope.content, let userX25519KeyPair: Box.KeyPair = Identity.fetchUserKeyPair(db) diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index 7746af472..c70f5bd22 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -29,11 +29,7 @@ extension MessageReceiver { default: fatalError() } - var isMainAppActive = false - if let sharedUserDefaults = UserDefaults(suiteName: "group.com.loki-project.loki-messenger") { - isMainAppActive = sharedUserDefaults[.isMainAppActive] - } - guard isMainAppActive else { return } + guard (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) else { return } // Touch the thread to update the home screen preview let storage = SNMessagingKitConfiguration.shared.storage @@ -399,11 +395,7 @@ extension MessageReceiver { // Note: `message.sentTimestamp` is in ms let messageSentTimestamp: TimeInterval = TimeInterval((message.sentTimestamp ?? 0) / 1000) - - var isMainAppActive = false - if let sharedUserDefaults = UserDefaults(suiteName: "group.com.loki-project.loki-messenger") { - isMainAppActive = sharedUserDefaults[.isMainAppActive] - } + let isMainAppActive: Bool = (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) // Parse & persist attachments diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 8f2246a54..a16110c55 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -89,10 +89,7 @@ public final class MessageSender : NSObject { let (promise, seal) = Promise.pending() let userPublicKey: String = getUserHexEncodedPublicKey(db) - var isMainAppActive = false - if let sharedUserDefaults = UserDefaults(suiteName: "group.com.loki-project.loki-messenger") { - isMainAppActive = sharedUserDefaults[.isMainAppActive] - } + let isMainAppActive: Bool = (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) // Set the timestamp, sender and recipient if message.sentTimestamp == nil { // Visible messages will already have their sent timestamp set diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift index bcaba688b..c1f601365 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift @@ -124,31 +124,39 @@ public final class ClosedGroupPoller : NSObject { SNLog("Received \(messages.count) new message(s) in closed group with public key: \(groupPublicKey).") GRDBStorage.shared.write { db in + var jobDetailMessages: [MessageReceiveJob.Details.MessageInfo] = [] + messages.forEach { message in guard let envelope = SNProtoEnvelope.from(message) else { return } do { - JobRunner.add( - db, - job: Job( - variant: .messageReceive, - behaviour: .runOnce, - threadId: groupPublicKey, - details: MessageReceiveJob.Details( - data: try envelope.serializedData(), - serverHash: message.info.hash, - isBackgroundPoll: false - ) + jobDetailMessages.append( + MessageReceiveJob.Details.MessageInfo( + data: try envelope.serializedData(), + serverHash: message.info.hash ) ) // Persist the received message after the MessageReceiveJob is created - try message.info.save(db) + _ = try message.info.saved(db) } catch { SNLog("Failed to deserialize envelope due to error: \(error).") } } + + JobRunner.add( + db, + job: Job( + variant: .messageReceive, + behaviour: .runOnce, + threadId: groupPublicKey, + details: MessageReceiveJob.Details( + messages: jobDetailMessages, + isBackgroundPoll: false + ) + ) + ) } } } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift index 3ff6d224f..9012b6115 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift @@ -95,7 +95,9 @@ public final class Poller : NSObject { private func poll(_ snode: Snode, seal longTermSeal: Resolver) -> Promise { guard isPolling else { return Promise { $0.fulfill(()) } } + let userPublicKey = getUserHexEncodedPublicKey() + return SnodeAPI.getRawMessages(from: snode, associatedWith: userPublicKey) .then(on: Threading.pollerQueue) { [weak self] rawResponse -> Promise in guard let strongSelf = self, strongSelf.isPolling else { return Promise { $0.fulfill(()) } } @@ -106,6 +108,8 @@ public final class Poller : NSObject { SNLog("Received \(messages.count) new message(s).") GRDBStorage.shared.write { db in + var threadMessages: [String: [MessageReceiveJob.Details.MessageInfo]] = [:] + messages.forEach { message in guard let envelope = SNProtoEnvelope.from(message) else { return } @@ -117,27 +121,36 @@ public final class Poller : NSObject { } do { - JobRunner.add( - db, - job: Job( - variant: .messageReceive, - behaviour: .runOnce, - threadId: threadId, - details: MessageReceiveJob.Details( + threadMessages[threadId ?? ""] = (threadMessages[threadId ?? ""] ?? []) + .appending( + MessageReceiveJob.Details.MessageInfo( data: try envelope.serializedData(), - serverHash: message.info.hash, - isBackgroundPoll: false + serverHash: message.info.hash ) ) - ) // Persist the received message after the MessageReceiveJob is created - try message.info.save(db) + _ = try message.info.saved(db) } catch { SNLog("Failed to deserialize envelope due to error: \(error).") } } + + threadMessages.forEach { threadId, threadMessages in + JobRunner.add( + db, + job: Job( + variant: .messageReceive, + behaviour: .runOnce, + threadId: threadId, + details: MessageReceiveJob.Details( + messages: threadMessages, + isBackgroundPoll: false + ) + ) + ) + } } } diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index 0524b6a7f..a0cdf0845 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -19,11 +19,9 @@ public final class NotificationServiceExtension : UNNotificationServiceExtension self.notificationContent = request.content.mutableCopy() as? UNMutableNotificationContent // Abort if the main app is running - var isMainAppActive = false - if let sharedUserDefaults = UserDefaults(suiteName: "group.com.loki-project.loki-messenger") { - isMainAppActive = sharedUserDefaults.bool(forKey: "isMainAppActive") + guard !(UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) else { + return self.completeSilenty() } - guard !isMainAppActive else { return self.completeSilenty() } // Perform main setup DispatchQueue.main.sync { self.setUpIfNecessary() { } } diff --git a/SessionSnodeKit/Configuration.swift b/SessionSnodeKit/Configuration.swift index 7d4b0a333..24df07e8e 100644 --- a/SessionSnodeKit/Configuration.swift +++ b/SessionSnodeKit/Configuration.swift @@ -13,15 +13,18 @@ public enum SNSnodeKit { // Just to make the external API nice identifier: .snodeKit, migrations: [ [ - _001_InitialSetupMigration.self + _001_InitialSetupMigration.self, + _002_SetupStandardJobs.self ], [ - _002_YDBToGRDBMigration.self + _003_YDBToGRDBMigration.self ] ] ) } public static func configure() { + // Configure the job executors + JobRunner.add(executor: GetSnodePoolJob.self, for: .getSnodePool) } } diff --git a/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift new file mode 100644 index 000000000..5f7965c6b --- /dev/null +++ b/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -0,0 +1,20 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +/// This migration sets up the standard jobs, since we want these jobs to run before any "once-off" jobs we do this migration +/// before running the `YDBToGRDBMigration` +enum _002_SetupStandardJobs: Migration { + static let identifier: String = "SetupStandardJobs" + + static func migrate(_ db: Database) throws { + try autoreleasepool { + _ = try Job( + variant: .getSnodePool, + behaviour: .recurringOnActiveBlocking + ).inserted(db) + } + } +} diff --git a/SessionSnodeKit/Database/Migrations/_002_YDBToGRDBMigration.swift b/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift similarity index 98% rename from SessionSnodeKit/Database/Migrations/_002_YDBToGRDBMigration.swift rename to SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 444c5b03f..6f254aff0 100644 --- a/SessionSnodeKit/Database/Migrations/_002_YDBToGRDBMigration.swift +++ b/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -4,7 +4,7 @@ import Foundation import GRDB import SessionUtilitiesKit -enum _002_YDBToGRDBMigration: Migration { +enum _003_YDBToGRDBMigration: Migration { static let identifier: String = "YDBToGRDBMigration" static func migrate(_ db: Database) throws { @@ -103,6 +103,7 @@ enum _002_YDBToGRDBMigration: Migration { var lastMessageResults: [String: (hash: String, json: JSON)] = [:] var receivedMessageResults: [String: Set] = [:] + // TODO: Move into the top read block??? Storage.read { transaction in // Extract the received message hashes transaction.enumerateKeysAndObjects(inCollection: Legacy.receivedMessagesCollection) { key, object, _ in diff --git a/SessionSnodeKit/GetSnodePoolJob.swift b/SessionSnodeKit/GetSnodePoolJob.swift new file mode 100644 index 000000000..eeb9f7fe2 --- /dev/null +++ b/SessionSnodeKit/GetSnodePoolJob.swift @@ -0,0 +1,24 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SignalCoreKit +import SessionUtilitiesKit + +public enum GetSnodePoolJob: JobExecutor { + public static let maxFailureCount: Int = -1 + public static let requiresThreadId: Bool = false + public static let requiresInteractionId: Bool = false + + public static func run( + _ job: Job, + success: @escaping (Job, Bool) -> (), + failure: @escaping (Job, Error?, Bool) -> (), + deferred: @escaping (Job) -> () + ) { + SnodeAPI.getSnodePool() + .done { _ in success(job, false) } + .catch { error in failure(job, error, false) } + .retainUntilComplete() + } +} diff --git a/SessionSnodeKit/SnodeAPI.swift b/SessionSnodeKit/SnodeAPI.swift index fe2305425..b64ec0594 100644 --- a/SessionSnodeKit/SnodeAPI.swift +++ b/SessionSnodeKit/SnodeAPI.swift @@ -258,7 +258,9 @@ public final class SnodeAPI : NSObject { public static func getSnodePool() -> Promise> { loadSnodePoolIfNeeded() let now = Date() - let hasSnodePoolExpired = given(GRDBStorage.shared[.lastSnodePoolRefreshDate]) { now.timeIntervalSince($0) > 2 * 60 * 60 } ?? true + let hasSnodePoolExpired = given(GRDBStorage.shared[.lastSnodePoolRefreshDate]) { + now.timeIntervalSince($0) > 2 * 60 * 60 + }.defaulting(to: true) let snodePool = SnodeAPI.snodePool let hasInsufficientSnodes = (snodePool.count < minSnodePoolCount) @@ -441,6 +443,7 @@ public final class SnodeAPI : NSObject { // "pubkey_ed25519" : ed25519PublicKey, // "signature" : signature.toBase64()! ] + return invoke(.getMessages, on: snode, associatedWith: publicKey, parameters: parameters) } diff --git a/SessionUtilitiesKit/Configuration.swift b/SessionUtilitiesKit/Configuration.swift index cfc16ae73..c102292d2 100644 --- a/SessionUtilitiesKit/Configuration.swift +++ b/SessionUtilitiesKit/Configuration.swift @@ -18,11 +18,12 @@ public enum SNUtilitiesKit { // Just to make the external API nice identifier: .utilitiesKit, migrations: [ [ - // Intentionally including the '_002_YDBToGRDBMigration' in the first migration + // Intentionally including the '_003_YDBToGRDBMigration' in the first migration // set to ensure the 'Identity' data is migrated before any other migrations are // run (some need access to the users publicKey) _001_InitialSetupMigration.self, - _002_YDBToGRDBMigration.self + _002_SetupStandardJobs.self, + _003_YDBToGRDBMigration.self ] ] ) diff --git a/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift index b67b006d9..858ccd70a 100644 --- a/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -15,6 +15,30 @@ enum _001_InitialSetupMigration: Migration { t.column(.data, .blob).notNull() } + try db.create(table: Job.self) { t in + t.column(.id, .integer) + .notNull() + .primaryKey(autoincrement: true) + t.column(.failureCount, .integer) + .notNull() + .defaults(to: 0) + t.column(.variant, .integer) + .notNull() + .indexed() // Quicker querying + t.column(.behaviour, .integer) + .notNull() + .indexed() // Quicker querying + t.column(.nextRunTimestamp, .double) + .notNull() + .indexed() // Quicker querying + .defaults(to: 0) + t.column(.threadId, .text) + .indexed() // Quicker querying + t.column(.interactionId, .text) + .indexed() // Quicker querying + t.column(.details, .blob) + } + try db.create(table: Setting.self) { t in t.column(.key, .text) .notNull() diff --git a/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift new file mode 100644 index 000000000..473c9ff9a --- /dev/null +++ b/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -0,0 +1,21 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import Curve25519Kit + +/// This migration sets up the standard jobs, since we want these jobs to run before any "once-off" jobs we do this migration +/// before running the `YDBToGRDBMigration` +enum _002_SetupStandardJobs: Migration { + static let identifier: String = "SetupStandardJobs" + + static func migrate(_ db: Database) throws { + try autoreleasepool { + // Note: This job exists in the 'Session' target but that doesn't have it's own migrations + _ = try Job( + variant: .syncPushTokens, + behaviour: .recurringOnLaunch + ).inserted(db) + } + } +} diff --git a/SessionUtilitiesKit/Database/Migrations/_002_YDBToGRDBMigration.swift b/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift similarity index 96% rename from SessionUtilitiesKit/Database/Migrations/_002_YDBToGRDBMigration.swift rename to SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 61065617b..55de4d025 100644 --- a/SessionUtilitiesKit/Database/Migrations/_002_YDBToGRDBMigration.swift +++ b/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -3,7 +3,7 @@ import Foundation import GRDB -enum _002_YDBToGRDBMigration: Migration { +enum _003_YDBToGRDBMigration: Migration { static let identifier: String = "YDBToGRDBMigration" static func migrate(_ db: Database) throws { @@ -65,6 +65,7 @@ enum _002_YDBToGRDBMigration: Migration { throw GRDBStorageError.migrationFailed } + print("RAWR publicKey \(userX25519KeyPair.publicKey.toHexString())") try autoreleasepool { // Insert the data into GRDB try Identity( diff --git a/SessionMessagingKit/Database/Models/Job.swift b/SessionUtilitiesKit/Database/Models/Job.swift similarity index 64% rename from SessionMessagingKit/Database/Models/Job.swift rename to SessionUtilitiesKit/Database/Models/Job.swift index a6159af22..ae9410138 100644 --- a/SessionMessagingKit/Database/Models/Job.swift +++ b/SessionUtilitiesKit/Database/Models/Job.swift @@ -2,21 +2,9 @@ import Foundation import GRDB -import SessionUtilitiesKit -import SwiftProtobuf public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "job" } - internal static let threadForeignKey = ForeignKey( - [Columns.threadId], - to: [SessionThread.Columns.id] - ) - internal static let interactionForeignKey = ForeignKey( - [Columns.interactionId], - to: [Interaction.Columns.id] - ) - internal static let thread = hasOne(SessionThread.self, using: Job.threadForeignKey) - internal static let interaction = hasOne(Interaction.self, using: Job.interactionForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -35,13 +23,31 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer /// at the timestamp of the next disappearing message case disappearingMessages + /// This is a recurring job that ensures the app retrieves a service node pool on active + /// + /// **Note:** This is a blocking job so it will run before any other jobs and prevent them from + /// running until it's complete + case getSnodePool + + /// This is a recurring job that checks if the user needs to update their profile picture on launch, and if so + /// attempt to download the latest + case updateProfilePicture + + /// This is a recurring job that ensures the app fetches the default open group rooms on launch + case retrieveDefaultOpenGroupRooms /// This is a recurring job that runs on launch and flags any messages marked as 'sending' to /// be in their 'failed' state + /// + /// **Note:** This is a blocking job so it will run before any other jobs and prevent them from + /// running until it's complete case failedMessages = 1000 /// This is a recurring job that runs on launch and flags any attachments marked as 'uploading' to /// be in their 'failed' state + /// + /// **Note:** This is a blocking job so it will run before any other jobs and prevent them from + /// running until it's complete case failedAttachmentDownloads /// This is a recurring job that runs on return from background and registeres and uploads the @@ -84,11 +90,26 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer /// the future) in order to be run again case recurring - /// This job will run once each launch + /// This job will run once each launch and may run again during the same session if `nextRunTimestamp` + /// gets set case recurringOnLaunch - /// This job will run once each whenever the app becomes active (launch and return from background) + /// This job will run once each launch and may run again during the same session if `nextRunTimestamp` + /// gets set, it also must complete before any other jobs can run + case recurringOnLaunchBlocking + + /// This job will run once each launch and may run again during the same session if `nextRunTimestamp` + /// gets set, it also must complete before any other jobs can run + case recurringOnLaunchBlockingOncePerSession + + /// This job will run once each whenever the app becomes active (launch and return from background) and + /// may run again during the same session if `nextRunTimestamp` gets set case recurringOnActive + + /// This job will run once each whenever the app becomes active (launch and return from background) and + /// may run again during the same session if `nextRunTimestamp` gets set, it also must complete before + /// any other jobs can run + case recurringOnActiveBlocking } /// The `id` value is auto incremented by the database, if the `Job` hasn't been inserted into @@ -122,16 +143,6 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer /// JSON encoded data required for the job public let details: Data? - // MARK: - Relationships - - public var thread: QueryInterfaceRequest { - request(for: Job.thread) - } - - public var interaction: QueryInterfaceRequest { - request(for: Job.interaction) - } - // MARK: - Initialization fileprivate init( @@ -201,26 +212,72 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer } } +// MARK: - GRDB Interactions + +extension Job { + internal static func filterPendingJobs(excludeFutureJobs: Bool = true) -> QueryInterfaceRequest { + let query: QueryInterfaceRequest = Job + .filter( + // TODO: Should this include other behaviours? (what happens if one of the other types fails???? Just leave it until the next launch/active???) Set a 'failureCount' and use that to determine if it should run? (reset on success) + // Retrieve all 'runOnce' and 'recurring' jobs + [ + Job.Behaviour.runOnce, + Job.Behaviour.recurring + ].contains(Job.Columns.behaviour) || ( + // Retrieve any 'recurringOnLaunch' and 'recurringOnActive' jobs that have a + // 'nextRunTimestamp' + [ + Job.Behaviour.recurringOnLaunch, + Job.Behaviour.recurringOnLaunchBlocking, + Job.Behaviour.recurringOnActive, + Job.Behaviour.recurringOnActiveBlocking + ].contains(Job.Columns.behaviour) && + Job.Columns.nextRunTimestamp > 0 + ) + ) + .order(Job.Columns.nextRunTimestamp) + .order(Job.Columns.id) + + guard excludeFutureJobs else { + return query + } + + return query + .filter(Job.Columns.nextRunTimestamp <= Date().timeIntervalSince1970) + } +} + // MARK: - Convenience public extension Job { - internal func with( + var isBlocking: Bool { + switch self.behaviour { + case .recurringOnLaunchBlocking, + .recurringOnLaunchBlockingOncePerSession, + .recurringOnActiveBlocking: + return true + + default: return false + } + } + + func with( failureCount: UInt = 0, - nextRunTimestamp: TimeInterval? + nextRunTimestamp: TimeInterval ) -> Job { return Job( id: id, failureCount: failureCount, variant: variant, behaviour: behaviour, - nextRunTimestamp: (nextRunTimestamp ?? self.nextRunTimestamp), + nextRunTimestamp: nextRunTimestamp, threadId: threadId, interactionId: interactionId, details: details ) } - internal func with(details: T) -> Job? { + func with(details: T) -> Job? { guard let detailsData: Data = try? JSONEncoder().encode(details) else { return nil } return Job( diff --git a/SessionUtilitiesKit/General/SNUserDefaults.swift b/SessionUtilitiesKit/General/SNUserDefaults.swift index c2f361957..6e09a5711 100644 --- a/SessionUtilitiesKit/General/SNUserDefaults.swift +++ b/SessionUtilitiesKit/General/SNUserDefaults.swift @@ -34,6 +34,9 @@ public enum SNUserDefaults { } public extension UserDefaults { + @objc static var sharedLokiProject: UserDefaults? { + UserDefaults(suiteName: "group.com.loki-project.loki-messenger") + } subscript(bool: SNUserDefaults.Bool) -> Bool { get { return self.bool(forKey: bool.rawValue) } diff --git a/SessionMessagingKit/Jobs/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift similarity index 80% rename from SessionMessagingKit/Jobs/JobRunner.swift rename to SessionUtilitiesKit/JobRunner/JobRunner.swift index 0f9d154b3..39d6e896b 100644 --- a/SessionMessagingKit/Jobs/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -3,10 +3,12 @@ import Foundation import GRDB import SignalCoreKit -import SessionUtilitiesKit public protocol JobExecutor { - static var maxFailureCount: UInt { get } + /// The maximum number of times the job can fail before it fails permanently + /// + /// **Note:** A value of `-1` means it will retry indefinitely + static var maxFailureCount: Int { get } static var requiresThreadId: Bool { get } static var requiresInteractionId: Bool { get } @@ -57,8 +59,8 @@ public final class JobRunner { } } - // TODO: Could this be a bottleneck? (single serial queue to process all these jobs? Group by thread?) - // TODO: Multi-thread support + // TODO: Could this be a bottleneck? (single serial queue to process all these jobs? Group by thread?). + // TODO: Multi-thread support. private static let queueKey: DispatchSpecificKey = DispatchSpecificKey() private static let queueContext: String = "JobRunner" private static let internalQueue: DispatchQueue = { @@ -74,6 +76,7 @@ public final class JobRunner { private static var jobQueue: Atomic<[Job]> = Atomic([]) private static var jobsCurrentlyRunning: Atomic> = Atomic([]) + private static var perSessionJobsCompleted: Atomic> = Atomic([]) // MARK: - Configuration @@ -182,27 +185,64 @@ public final class JobRunner { .filter( [ Job.Behaviour.recurringOnLaunch, + Job.Behaviour.recurringOnLaunchBlocking, + Job.Behaviour.recurringOnLaunchBlockingOncePerSession, Job.Behaviour.runOnceNextLaunch ].contains(Job.Columns.behaviour) ) + .order(Job.Columns.id) .fetchAll(db) } guard let jobsToRun: [Job] = maybeJobsToRun else { return } - jobQueue.mutate { $0.append(contentsOf: jobsToRun) } + jobQueue.mutate { + // Insert any blocking jobs after any existing blocking jobs then add + // the remaining jobs to the end of the queue + let lastBlockingIndex = $0.lastIndex(where: { $0.isBlocking }) + .defaulting(to: $0.startIndex.advanced(by: -1)) + .advanced(by: 1) + + $0.insert( + contentsOf: jobsToRun.filter { $0.isBlocking }, + at: lastBlockingIndex + ) + $0.append( + contentsOf: jobsToRun.filter { !$0.isBlocking } + ) + } } public static func appDidBecomeActive() { let maybeJobsToRun: [Job]? = GRDBStorage.shared.read { db in try Job - .filter(Job.Columns.behaviour == Job.Behaviour.recurringOnActive) + .filter( + [ + Job.Behaviour.recurringOnActive, + Job.Behaviour.recurringOnActiveBlocking + ].contains(Job.Columns.behaviour) + ) + .order(Job.Columns.id) .fetchAll(db) } guard let jobsToRun: [Job] = maybeJobsToRun else { return } - jobQueue.mutate { $0.append(contentsOf: jobsToRun) } + jobQueue.mutate { + // Insert any blocking jobs after any existing blocking jobs then add + // the remaining jobs to the end of the queue + let lastBlockingIndex = $0.lastIndex(where: { $0.isBlocking }) + .defaulting(to: $0.startIndex.advanced(by: -1)) + .advanced(by: 1) + + $0.insert( + contentsOf: jobsToRun.filter { $0.isBlocking }, + at: lastBlockingIndex + ) + $0.append( + contentsOf: jobsToRun.filter { !$0.isBlocking } + ) + } // Start the job runner if needed if !isRunning.wrappedValue { @@ -228,21 +268,14 @@ public final class JobRunner { guard DispatchQueue.getSpecific(key: queueKey) == queueContext else { internalQueue.async { start() - } + }// TODO: Want to have multiple threads for this (attachment download should be separate - do we even use attachment upload anymore???) return } // Get any pending jobs let maybeJobsToRun: [Job]? = GRDBStorage.shared.read { db in - try Job - .filter( - [ - Job.Behaviour.runOnce, - Job.Behaviour.recurring - ].contains(Job.Columns.behaviour) - ) - .filter(Job.Columns.nextRunTimestamp <= Date().timeIntervalSince1970) - .order(Job.Columns.nextRunTimestamp) + try Job// TODO: Test this + .filterPendingJobs() .fetchAll(db) } @@ -300,6 +333,12 @@ public final class JobRunner { return } + // If the 'nextRunTimestamp' for the job is in the future then don't run it yet + guard nextJob.nextRunTimestamp <= Date().timeIntervalSince1970 else { + handleJobDeferred(nextJob) + return + } + // Update the state to indicate it's running // // Note: We need to store 'numJobsRemaining' in it's own variable because @@ -324,21 +363,17 @@ public final class JobRunner { try TimeInterval .fetchOne( db, - Job + Job// TODO: Test this works as expected + .filterPendingJobs(excludeFutureJobs: false) .select(Job.Columns.nextRunTimestamp) - .filter( - [ - Job.Behaviour.runOnce, - Job.Behaviour.recurring - ].contains(Job.Columns.behaviour) - ) - .order(Job.Columns.nextRunTimestamp) ) } + guard let nextJobTimestamp: TimeInterval = nextJobTimestamp else { return } // If the next job isn't scheduled in the future then just restart the JobRunner immediately let secondsUntilNextJob: TimeInterval = (nextJobTimestamp - Date().timeIntervalSince1970) + guard secondsUntilNextJob > 0 else { SNLog("[JobRunner] Restarting immediately for job scheduled \(Int(ceil(abs(secondsUntilNextJob)))) second\(Int(ceil(abs(secondsUntilNextJob))) == 1 ? "" : "s")) ago") @@ -378,6 +413,9 @@ public final class JobRunner { .saved(db) } + case .recurringOnLaunchBlockingOncePerSession: + perSessionJobsCompleted.mutate { $0 = $0.inserting(job.id) } + default: break } @@ -393,17 +431,48 @@ public final class JobRunner { guard GRDBStorage.shared.read({ db in try Job.exists(db, id: job.id ?? -1) }) == true else { SNLog("[JobRunner] \(job.variant) job canceled") jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) } - runNextJob() + + internalQueue.async { + runNextJob() + } return } + switch job.behaviour { + // If a "blocking" job failed then rerun it immediately + case .recurringOnLaunchBlocking, .recurringOnActiveBlocking: + SNLog("[JobRunner] blocking \(job.variant) job failed; retrying immediately") + jobQueue.mutate({ $0.insert(job, at: 0) }) + + internalQueue.async { + runNextJob() + } + return + + // For "blocking once per session" jobs only rerun it immediately if it hasn't already + // run this session + case .recurringOnLaunchBlockingOncePerSession: + guard !perSessionJobsCompleted.wrappedValue.contains(job.id ?? -1) else { break } + + SNLog("[JobRunner] blocking \(job.variant) job failed; retrying immediately") + perSessionJobsCompleted.mutate { $0 = $0.inserting(job.id) } + jobQueue.mutate({ $0.insert(job, at: 0) }) + + internalQueue.async { + runNextJob() + } + return + + default: break + } + GRDBStorage.shared.write { db in - // Check if the job has a 'maxFailureCount' (a value of '0' means it will always retry) - let maxFailureCount: UInt = (executorMap.wrappedValue[job.variant]?.maxFailureCount ?? 0) + // Get the max failure count for the job (a value of '-1' means it will retry indefinitely) + let maxFailureCount: Int = (executorMap.wrappedValue[job.variant]?.maxFailureCount ?? 0) guard !permanentFailure && - maxFailureCount > 0 && + maxFailureCount >= 0 && job.failureCount + 1 < maxFailureCount else { // If the job permanently failed or we have performed all of our retry attempts @@ -422,7 +491,9 @@ public final class JobRunner { } jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) } - runNextJob() + internalQueue.async { + runNextJob() + } } /// This function is called when a job neither succeeds or fails (this should only occur if the job has specific logic that makes it dependant diff --git a/SessionMessagingKit/Jobs/JobRunnerError.swift b/SessionUtilitiesKit/JobRunner/JobRunnerError.swift similarity index 100% rename from SessionMessagingKit/Jobs/JobRunnerError.swift rename to SessionUtilitiesKit/JobRunner/JobRunnerError.swift From 4eaa8c4d363d7a7c14e23067a40a0003c39e2254 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 29 Apr 2022 11:51:56 +1000 Subject: [PATCH 067/157] Updated Nimble to the latest version Fixed some flaky OpenGroupManager tests --- Podfile | 6 ++---- Podfile.lock | 15 +++++---------- .../Open Groups/OpenGroupManager.swift | 2 +- 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/Podfile b/Podfile index ce103bb8b..6a25544ad 100644 --- a/Podfile +++ b/Podfile @@ -60,8 +60,7 @@ abstract_target 'GlobalDependencies' do inherit! :complete pod 'Quick' - # FIXME: change this back to use the latest 'Nimble' once a version newer than 9.2.1 has been released - pod 'Nimble', :git => 'https://github.com/Quick/Nimble', :commit => 'cabe966' + pod 'Nimble' end end @@ -72,8 +71,7 @@ abstract_target 'GlobalDependencies' do inherit! :complete pod 'Quick' - # FIXME: change this back to use the latest 'Nimble' once a version newer than 9.2.1 has been released - pod 'Nimble', :git => 'https://github.com/Quick/Nimble', :commit => 'cabe966' + pod 'Nimble' end end end diff --git a/Podfile.lock b/Podfile.lock index 6b1fdad8d..2280ec0b4 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -25,7 +25,7 @@ PODS: - Mantle (2.1.0): - Mantle/extobjc (= 2.1.0) - Mantle/extobjc (2.1.0) - - Nimble (9.2.0) + - Nimble (10.0.0) - NVActivityIndicatorView (5.1.1): - NVActivityIndicatorView/Base (= 5.1.1) - NVActivityIndicatorView/Base (5.1.1) @@ -129,7 +129,7 @@ DEPENDENCIES: - Curve25519Kit (from `https://github.com/oxen-io/session-ios-curve-25519-kit.git`, branch `session-version`) - GoogleWebRTC - Mantle (from `https://github.com/signalapp/Mantle`, branch `signal-master`) - - Nimble (from `https://github.com/Quick/Nimble`, commit `cabe966`) + - Nimble - NVActivityIndicatorView - PromiseKit - PureLayout (~> 3.1.8) @@ -150,6 +150,7 @@ SPEC REPOS: - CocoaLumberjack - CryptoSwift - GoogleWebRTC + - Nimble - NVActivityIndicatorView - OpenSSL-Universal - PromiseKit @@ -169,9 +170,6 @@ EXTERNAL SOURCES: Mantle: :branch: signal-master :git: https://github.com/signalapp/Mantle - Nimble: - :commit: cabe966 - :git: https://github.com/Quick/Nimble SignalCoreKit: :branch: session-version :git: https://github.com/oxen-io/session-ios-core-kit @@ -191,9 +189,6 @@ CHECKOUT OPTIONS: Mantle: :commit: e7e46253bb01ce39525d90aa69ed9e85e758bfc4 :git: https://github.com/signalapp/Mantle - Nimble: - :commit: cabe966 - :git: https://github.com/Quick/Nimble SignalCoreKit: :commit: 4590c2737a2b5dc0ef4ace9f9019b581caccc1de :git: https://github.com/oxen-io/session-ios-core-kit @@ -214,7 +209,7 @@ SPEC CHECKSUMS: Curve25519Kit: e63f9859ede02438ae3defc5e1a87e09d1ec7ee6 GoogleWebRTC: b39a78c4f5cc6b0323415b9233db03a2faa7b0f0 Mantle: 2fa750afa478cd625a94230fbf1c13462f29395b - Nimble: 0526ae760c851747ff4a682f7646af07a0cc2013 + Nimble: 5316ef81a170ce87baf72dd961f22f89a602ff84 NVActivityIndicatorView: 1f6c5687f1171810aa27a3296814dc2d7dec3667 OpenSSL-Universal: e7311447fd2419f57420c79524b641537387eff2 PromiseKit: 3b2b6995e51a954c46dbc550ce3da44fbfb563c5 @@ -231,6 +226,6 @@ SPEC CHECKSUMS: YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331 ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: 715ef2f16aa5c6957ff91d8dd7240a2cfba46aa2 +PODFILE CHECKSUM: e4f78b5555c81d9dc1377a9462bacc8bd662684e COCOAPODS: 1.11.2 diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 922ad2277..9af7791ac 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -330,7 +330,7 @@ public final class OpenGroupManager: NSObject { // - Room image (if there is one and it's different from the existing one, or we don't have the existing one) if let imageId: UInt64 = UInt64(updatedOpenGroup.imageID ?? ""), (updatedModel.groupImage == nil || updatedOpenGroup.imageID != existingOpenGroup?.imageID) { OpenGroupManager.roomImage(imageId, for: roomToken, on: server, using: dependencies) - .done(on: DispatchQueue.global(qos: .userInitiated)) { data in + .done { data in dependencies.storage.write { transaction in // Update the thread let transaction = transaction as! YapDatabaseReadWriteTransaction From 32304ae5dd907e0515a2a7dba821831ca9a2e721 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 3 May 2022 17:14:56 +1000 Subject: [PATCH 068/157] Cleared out some of the legacy serialisation logic, further UI binding Refactored the SignalApp class to Swift Fixed a horizontal alignment issue in the ConversationTitleView Fixed an issue where expiration timer update messages weren't migrated or rendering correctly Fixed an issue where expiring messages weren't migrated correctly Fixed an issue where closed groups which had been left were causing migration failures (due to data incorrectly being assumed to be required) Shifted the Legacy Attachment types into the 'SMKLegacy' namespace Moved all of the NSCoding logic for the TSMessage --- Session.xcodeproj/project.pbxproj | 64 +- .../Conversations/ConversationViewAction.h | 8 - .../Conversations/ConversationViewModel.swift | 3 + .../Views & Modals/MessagesTableView.swift | 23 - Session/Home/HomeVC.swift | 3 +- Session/Meta/AppDelegate.swift | 4 +- Session/Meta/SessionApp.swift | 85 ++ Session/Meta/SignalApp.h | 60 - Session/Meta/SignalApp.m | 167 --- Session/Notifications/AppNotifications.swift | 10 +- ...otificationSettingsOptionsViewController.m | 1 - .../PrivacySettingsTableViewController.m | 3 +- Session/Settings/SettingsVC.swift | 38 +- Session/Shared/UserCell.swift | 6 +- .../LegacyDatabase/SMKLegacyModels.swift | 1227 +++++++++++++---- .../_001_InitialSetupMigration.swift | 5 +- .../Migrations/_003_YDBToGRDBMigration.swift | 426 +++++- .../Database/Models/Attachment.swift | 233 +++- .../Database/Models/ClosedGroup.swift | 14 +- .../Database/Models/Contact.swift | 7 + .../DisappearingMessageConfiguration.swift | 56 +- .../Database/Models/GroupMember.swift | 6 +- .../Database/Models/Interaction.swift | 205 ++- .../Models/InteractionAttachment.swift | 6 +- .../Database/Models/LinkPreview.swift | 5 +- .../Database/Models/OpenGroup.swift | 8 +- .../Database/Models/Profile.swift | 107 +- .../Database/Models/Quote.swift | 7 +- .../Database/Models/RecipientState.swift | 29 +- .../Database/Models/SessionThread.swift | 56 +- .../Database/SSKPreferences.swift | 10 +- .../Jobs/Types/AttachmentDownloadJob.swift | 7 +- .../Jobs/Types/DisappearingMessagesJob.swift | 6 +- .../Jobs/Types/MessageSendJob.swift | 44 +- .../Jobs/Types/SendReadReceiptsJob.swift | 32 +- .../Jobs/Types/UpdateProfilePictureJob.swift | 2 +- .../ClosedGroupControlMessage.swift | 228 ++- .../ConfigurationMessage+Convenience.swift | 6 +- .../ConfigurationMessage.swift | 247 ++-- .../Control Messages/ControlMessage.swift | 1 - .../DataExtractionNotification.swift | 55 +- .../ExpirationTimerUpdate.swift | 36 +- .../MessageRequestResponse.swift | 19 +- .../Control Messages/ReadReceipt.swift | 31 +- .../Control Messages/TypingIndicator.swift | 44 +- .../Control Messages/UnsendRequest.swift | 32 +- SessionMessagingKit/Messages/Message.swift | 73 +- .../Signal/TSIncomingMessage+Conversion.swift | 8 +- .../VisibleMessage+LinkPreview.swift | 46 +- .../VisibleMessage+OpenGroupInvitation.swift | 24 +- .../VisibleMessage+Profile.swift | 54 +- .../VisibleMessage+Quote.swift | 61 +- .../Visible Messages/VisibleMessage.swift | 122 +- .../MessageReceiver+Handling.swift | 20 +- .../Sending & Receiving/MessageReceiver.swift | 2 +- .../Sending & Receiving/MessageSender.swift | 18 +- .../Notification+MessageReceiver.swift | 2 + .../LegacyDatabase/SSKLegacyModels.swift | 43 +- .../Migrations/_003_YDBToGRDBMigration.swift | 6 + .../LegacyDatabase/SUKLegacyModels.swift | 12 +- .../Migrations/_003_YDBToGRDBMigration.swift | 6 + .../Database/Types/TypedTableAlias.swift | 14 + .../PersistableRecord+Utilities.swift | 2 +- .../QueryInterfaceRequest+Utilities.swift | 26 + .../Utilities/TableRecord+Utilities.swift | 10 + ...alization.swift => String+Localized.swift} | 0 .../General/UITableView+ReusableView.swift | 8 +- SessionUtilitiesKit/JobRunner/JobRunner.swift | 2 +- SessionUtilitiesKit/Media/OWSMediaUtils.swift | 2 + .../NSAttributedString+Utilities.swift | 57 + .../Utilities/Optional+Utilities.swift | 8 + .../Migrations/ContactsMigration.swift | 10 +- .../Profile Pictures/Identicon+ObjC.swift | 5 + 73 files changed, 2696 insertions(+), 1617 deletions(-) delete mode 100644 Session/Conversations/ConversationViewAction.h create mode 100644 Session/Conversations/ConversationViewModel.swift delete mode 100644 Session/Conversations/Views & Modals/MessagesTableView.swift create mode 100644 Session/Meta/SessionApp.swift delete mode 100644 Session/Meta/SignalApp.h delete mode 100644 Session/Meta/SignalApp.m create mode 100644 SessionUtilitiesKit/Database/Types/TypedTableAlias.swift create mode 100644 SessionUtilitiesKit/Database/Utilities/QueryInterfaceRequest+Utilities.swift create mode 100644 SessionUtilitiesKit/Database/Utilities/TableRecord+Utilities.swift rename SessionUtilitiesKit/General/{String+Localization.swift => String+Localized.swift} (100%) create mode 100644 SessionUtilitiesKit/Utilities/NSAttributedString+Utilities.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 2a1143247..cd0593208 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -21,7 +21,6 @@ 3430FE181F7751D4000EC51B /* GiphyAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3430FE171F7751D4000EC51B /* GiphyAPI.swift */; }; 34330AA31E79686200DF2FB9 /* OWSProgressView.m in Sources */ = {isa = PBXBuildFile; fileRef = 34330AA21E79686200DF2FB9 /* OWSProgressView.m */; }; 34386A54207D271D009F5D9C /* NeverClearView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34386A53207D271C009F5D9C /* NeverClearView.swift */; }; - 346129991FD1E4DA00532771 /* SignalApp.m in Sources */ = {isa = PBXBuildFile; fileRef = 346129971FD1E4D900532771 /* SignalApp.m */; }; 34661FB820C1C0D60056EDD6 /* message_sent.aiff in Resources */ = {isa = PBXBuildFile; fileRef = 34661FB720C1C0D60056EDD6 /* message_sent.aiff */; }; 346B66311F4E29B200E5122F /* CropScaleImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 346B66301F4E29B200E5122F /* CropScaleImageViewController.swift */; }; 3478504C1FD7496D007B8332 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B66DBF4919D5BBC8006EA940 /* Images.xcassets */; }; @@ -157,7 +156,6 @@ B821494F25D4E163009C0F2A /* BodyTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B821494E25D4E163009C0F2A /* BodyTextView.swift */; }; B82149B825D60393009C0F2A /* BlockedModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82149B725D60393009C0F2A /* BlockedModal.swift */; }; B82149C125D605C6009C0F2A /* InfoBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82149C025D605C6009C0F2A /* InfoBanner.swift */; }; - B8214A2B25D63EB9009C0F2A /* MessagesTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8214A2A25D63EB9009C0F2A /* MessagesTableView.swift */; }; B8269D2925C7A4B400488AB4 /* InputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8269D2825C7A4B400488AB4 /* InputView.swift */; }; B8269D3325C7A8C600488AB4 /* InputViewButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8269D3225C7A8C600488AB4 /* InputViewButton.swift */; }; B8269D3D25C7B34D00488AB4 /* InputTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8269D3C25C7B34D00488AB4 /* InputTextView.swift */; }; @@ -785,11 +783,12 @@ FD6A7A6B2818C17C00035AC1 /* UpdateProfilePictureJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6A7A6A2818C17C00035AC1 /* UpdateProfilePictureJob.swift */; }; FD6A7A6D2818C61500035AC1 /* _002_SetupStandardJobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6A7A6C2818C61500035AC1 /* _002_SetupStandardJobs.swift */; }; FD705A8C278CDB5600F16121 /* SAEScreenLockViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A8B278CDB5600F16121 /* SAEScreenLockViewController.swift */; }; - FD705A8E278CE29800F16121 /* String+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A8D278CE29800F16121 /* String+Localization.swift */; }; + FD705A8E278CE29800F16121 /* String+Localized.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A8D278CE29800F16121 /* String+Localized.swift */; }; FD705A90278CEBBC00F16121 /* ShareAppExtensionContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A8F278CEBBC00F16121 /* ShareAppExtensionContext.swift */; }; FD705A92278D051200F16121 /* ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A91278D051200F16121 /* ReusableView.swift */; }; FD705A94278D052B00F16121 /* UITableView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A93278D052B00F16121 /* UITableView+ReusableView.swift */; }; FD705A98278E9F4D00F16121 /* UIColor+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A97278E9F4D00F16121 /* UIColor+Extensions.swift */; }; + FD7162DB281B6C440060647B /* TypedTableAlias.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7162DA281B6C440060647B /* TypedTableAlias.swift */; }; FD859F0027C4691300510D0C /* MockDataGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EFF27C4691300510D0C /* MockDataGenerator.swift */; }; FD88BAD927A7439C00BBC442 /* MessageRequestsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */; }; FD88BADB27A750F200BBC442 /* MessageRequestsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */; }; @@ -820,6 +819,11 @@ FDF0B75A2807F3A3004C14C5 /* MessageSenderError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7592807F3A3004C14C5 /* MessageSenderError.swift */; }; FDF0B75C2807F41D004C14C5 /* MessageSender+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B75B2807F41D004C14C5 /* MessageSender+Convenience.swift */; }; FDF0B75E280AAF35004C14C5 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B75D280AAF35004C14C5 /* Preferences.swift */; }; + FDF222072818CECF000A4995 /* ConversationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF222062818CECF000A4995 /* ConversationViewModel.swift */; }; + FDF222092818D2B0000A4995 /* NSAttributedString+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF222082818D2B0000A4995 /* NSAttributedString+Utilities.swift */; }; + FDF2220B2818F38D000A4995 /* SessionApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF2220A2818F38D000A4995 /* SessionApp.swift */; }; + FDF2220F281B55E6000A4995 /* QueryInterfaceRequest+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF2220E281B55E6000A4995 /* QueryInterfaceRequest+Utilities.swift */; }; + FDF22211281B5E0B000A4995 /* TableRecord+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF22210281B5E0B000A4995 /* TableRecord+Utilities.swift */; }; FDFD645827EC1F4000808CA1 /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFD645727EC1F4000808CA1 /* Atomic.swift */; }; /* End PBXBuildFile section */ @@ -1010,8 +1014,6 @@ 34386A53207D271C009F5D9C /* NeverClearView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NeverClearView.swift; sourceTree = ""; }; 34480B371FD092A900BC14EF /* SignalShareExtension-Bridging-Header.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "SignalShareExtension-Bridging-Header.h"; sourceTree = ""; }; 34480B381FD092E300BC14EF /* SessionShareExtension-Prefix.pch */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "SessionShareExtension-Prefix.pch"; sourceTree = ""; }; - 346129971FD1E4D900532771 /* SignalApp.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SignalApp.m; sourceTree = ""; }; - 346129981FD1E4DA00532771 /* SignalApp.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SignalApp.h; sourceTree = ""; }; 34661FB720C1C0D60056EDD6 /* message_sent.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; name = message_sent.aiff; path = Session/Meta/AudioFiles/message_sent.aiff; sourceTree = SOURCE_ROOT; }; 346B66301F4E29B200E5122F /* CropScaleImageViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CropScaleImageViewController.swift; sourceTree = ""; }; 3488F9352191CC4000E524CC /* MediaView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaView.swift; sourceTree = ""; }; @@ -1206,7 +1208,6 @@ B821494E25D4E163009C0F2A /* BodyTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BodyTextView.swift; sourceTree = ""; }; B82149B725D60393009C0F2A /* BlockedModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedModal.swift; sourceTree = ""; }; B82149C025D605C6009C0F2A /* InfoBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoBanner.swift; sourceTree = ""; }; - B8214A2A25D63EB9009C0F2A /* MessagesTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesTableView.swift; sourceTree = ""; }; B8269D2825C7A4B400488AB4 /* InputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputView.swift; sourceTree = ""; }; B8269D3225C7A8C600488AB4 /* InputViewButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputViewButton.swift; sourceTree = ""; }; B8269D3C25C7B34D00488AB4 /* InputTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputTextView.swift; sourceTree = ""; }; @@ -1288,7 +1289,6 @@ B8D0A24F25E3678700C1835E /* LinkDeviceVC.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LinkDeviceVC.swift; sourceTree = ""; }; B8D0A25825E367AC00C1835E /* Notification+MessageReceiver.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "Notification+MessageReceiver.swift"; path = "SessionMessagingKit/Sending & Receiving/Notification+MessageReceiver.swift"; sourceTree = SOURCE_ROOT; }; B8D0A26825E4A2C200C1835E /* Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Onboarding.swift; sourceTree = ""; }; - B8D84E9325DF72AF005A043E /* ConversationViewAction.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ConversationViewAction.h; sourceTree = ""; }; B8D84EA225DF745A005A043E /* LinkPreviewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewState.swift; sourceTree = ""; }; B8D84ECE25E3108A005A043E /* ExpandingAttachmentsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandingAttachmentsButton.swift; sourceTree = ""; }; B8D8F1372566120F0092EF10 /* Storage+ClosedGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+ClosedGroups.swift"; sourceTree = ""; }; @@ -1855,11 +1855,12 @@ FD6A7A6A2818C17C00035AC1 /* UpdateProfilePictureJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateProfilePictureJob.swift; sourceTree = ""; }; FD6A7A6C2818C61500035AC1 /* _002_SetupStandardJobs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_SetupStandardJobs.swift; sourceTree = ""; }; FD705A8B278CDB5600F16121 /* SAEScreenLockViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SAEScreenLockViewController.swift; sourceTree = ""; }; - FD705A8D278CE29800F16121 /* String+Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Localization.swift"; sourceTree = ""; }; + FD705A8D278CE29800F16121 /* String+Localized.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Localized.swift"; sourceTree = ""; }; FD705A8F278CEBBC00F16121 /* ShareAppExtensionContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAppExtensionContext.swift; sourceTree = ""; }; FD705A91278D051200F16121 /* ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReusableView.swift; sourceTree = ""; }; FD705A93278D052B00F16121 /* UITableView+ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITableView+ReusableView.swift"; sourceTree = ""; }; FD705A97278E9F4D00F16121 /* UIColor+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Extensions.swift"; sourceTree = ""; }; + FD7162DA281B6C440060647B /* TypedTableAlias.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypedTableAlias.swift; sourceTree = ""; }; FD859EFF27C4691300510D0C /* MockDataGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDataGenerator.swift; sourceTree = ""; }; FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsCell.swift; sourceTree = ""; }; FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsMigration.swift; sourceTree = ""; }; @@ -1888,6 +1889,11 @@ 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 = ""; }; FDF0B75D280AAF35004C14C5 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; + FDF222062818CECF000A4995 /* ConversationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationViewModel.swift; sourceTree = ""; }; + FDF222082818D2B0000A4995 /* NSAttributedString+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+Utilities.swift"; sourceTree = ""; }; + FDF2220A2818F38D000A4995 /* SessionApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionApp.swift; sourceTree = ""; }; + FDF2220E281B55E6000A4995 /* QueryInterfaceRequest+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "QueryInterfaceRequest+Utilities.swift"; sourceTree = ""; }; + FDF22210281B5E0B000A4995 /* TableRecord+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TableRecord+Utilities.swift"; sourceTree = ""; }; FDFD645727EC1F4000808CA1 /* Atomic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = ""; }; FEDBAE1B98C49BBE8C87F575 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; sourceTree = ""; }; FF9BA33D021B115B1F5B4E46 /* Pods-SessionMessagingKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionMessagingKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionMessagingKit/Pods-SessionMessagingKit.app store release.xcconfig"; sourceTree = ""; }; @@ -2267,7 +2273,6 @@ B82149B725D60393009C0F2A /* BlockedModal.swift */, C374EEE125DA26740073A857 /* LinkPreviewModal.swift */, B82149C025D605C6009C0F2A /* InfoBanner.swift */, - B8214A2A25D63EB9009C0F2A /* MessagesTableView.swift */, C374EEEA25DA3CA70073A857 /* ConversationTitleView.swift */, B848A4C4269EAAA200617031 /* UserDetailsSheet.swift */, ); @@ -2277,21 +2282,21 @@ B835246C25C38AA20089A44F /* Conversations */ = { isa = PBXGroup; children = ( - B835246D25C38ABF0089A44F /* ConversationVC.swift */, - B8569AC225CB5D2900DBA3DB /* ConversationVC+Interaction.swift */, - 3496744E2076ACCE00080B5F /* LongTextViewController.swift */, - 4CC613352227A00400E21A3A /* ConversationSearch.swift */, - B8D84E9325DF72AF005A043E /* ConversationViewAction.h */, - 34D1F06F1F8678AA0066283D /* ConversationViewItem.h */, - 34D1F0701F8678AA0066283D /* ConversationViewItem.m */, - 341341ED2187467900192D59 /* ConversationViewModel.h */, - 341341EE2187467900192D59 /* ConversationViewModel.m */, - 34ABC0E321DD20C500ED9469 /* ConversationMessageMapping.swift */, B887C38125C7C79700E11DAE /* Input View */, B835247725C38D190089A44F /* Message Cells */, C328252E25CA54F70062D0A7 /* Context Menu */, B821493625D4D6A7009C0F2A /* Views & Modals */, C302094625DCDFD3001F572D /* Settings */, + FDF222062818CECF000A4995 /* ConversationViewModel.swift */, + B835246D25C38ABF0089A44F /* ConversationVC.swift */, + B8569AC225CB5D2900DBA3DB /* ConversationVC+Interaction.swift */, + 3496744E2076ACCE00080B5F /* LongTextViewController.swift */, + 4CC613352227A00400E21A3A /* ConversationSearch.swift */, + 34D1F06F1F8678AA0066283D /* ConversationViewItem.h */, + 34D1F0701F8678AA0066283D /* ConversationViewItem.m */, + 341341ED2187467900192D59 /* ConversationViewModel.h */, + 341341EE2187467900192D59 /* ConversationViewModel.m */, + 34ABC0E321DD20C500ED9469 /* ConversationMessageMapping.swift */, ); path = Conversations; sourceTree = ""; @@ -2433,7 +2438,7 @@ C33FDB6B255A580F00E217F9 /* SNUserDefaults.swift */, C33FDB3F255A580C00E217F9 /* String+SSK.swift */, C3C2AC2D2553CBEB00C340D1 /* String+Trimming.swift */, - FD705A8D278CE29800F16121 /* String+Localization.swift */, + FD705A8D278CE29800F16121 /* String+Localized.swift */, C38EF237255B6D65007E1867 /* UIDevice+featureSupport.swift */, C35D0DB425AE5F1200B6BF49 /* UIEdgeInsets.swift */, FD705A91278D051200F16121 /* ReusableView.swift */, @@ -3507,8 +3512,7 @@ C3CA3AA0255CDA7000F4C6D4 /* Mnemonic */, B67EBF5C19194AC60084CCFD /* Settings.bundle */, B657DDC91911A40500F45B0C /* Signal.entitlements */, - 346129981FD1E4DA00532771 /* SignalApp.h */, - 346129971FD1E4D900532771 /* SignalApp.m */, + FDF2220A2818F38D000A4995 /* SessionApp.swift */, 45B201741DAECBFD00C461E0 /* Signal-Bridging-Header.h */, D221A095169C9E5E00537ABF /* Session-Info.plist */, D221A09B169C9E5E00537ABF /* Session-Prefix.pch */, @@ -3630,11 +3634,12 @@ FD09796527F6B0A800936362 /* Utilities */ = { isa = PBXGroup; children = ( + FDA8EB0F280F8238002B68E5 /* Codable+Utilities.swift */, FD09796A27F6C67500936362 /* Failable.swift */, FD09797127FAA2F500936362 /* Optional+Utilities.swift */, FD09797C27FBDB2000936362 /* Notification+Utilities.swift */, + FDF222082818D2B0000A4995 /* NSAttributedString+Utilities.swift */, C3E7134E251C867C009649BB /* Sodium+Conversion.swift */, - FDA8EB0F280F8238002B68E5 /* Codable+Utilities.swift */, ); path = Utilities; sourceTree = ""; @@ -3734,6 +3739,7 @@ FD17D7B727F51ECA00122BE0 /* Migration.swift */, FD17D7B927F51F2100122BE0 /* TargetMigrations.swift */, FD17D7C027F5200100122BE0 /* TypedTableDefinition.swift */, + FD7162DA281B6C440060647B /* TypedTableAlias.swift */, ); path = Types; sourceTree = ""; @@ -3746,6 +3752,8 @@ FD17D7C627F5207C00122BE0 /* DatabaseMigrator+Utilities.swift */, FD17D7BC27F51F6900122BE0 /* GRDB+Notifications.swift */, FDF0B74C280664E9004C14C5 /* PersistableRecord+Utilities.swift */, + FDF22210281B5E0B000A4995 /* TableRecord+Utilities.swift */, + FDF2220E281B55E6000A4995 /* QueryInterfaceRequest+Utilities.swift */, ); path = Utilities; sourceTree = ""; @@ -4889,6 +4897,7 @@ B8AE75A425A6C6A6001A84D2 /* Data+Trimming.swift in Sources */, FD17D7C527F5206300122BE0 /* ColumnDefinition+Utilities.swift in Sources */, B8856DE6256F15F2001CE70E /* String+SSK.swift in Sources */, + FDF2220F281B55E6000A4995 /* QueryInterfaceRequest+Utilities.swift in Sources */, C3471ED42555386B00297E91 /* AESGCM.swift in Sources */, FD17D7B827F51ECA00122BE0 /* Migration.swift in Sources */, C32C5DDB256DD9FF003C73A2 /* ContentProxy.swift in Sources */, @@ -4903,9 +4912,11 @@ C3BBE0A72554D4DE0050F1E3 /* Promise+Retrying.swift in Sources */, B8856D7B256F14F4001CE70E /* UIView+OWS.m in Sources */, FD17D7B027F4225C00122BE0 /* Set+Utilities.swift in Sources */, + FDF22211281B5E0B000A4995 /* TableRecord+Utilities.swift in Sources */, B88FA7FB26114EA70049422F /* Hex.swift in Sources */, C35D0DB525AE5F1200B6BF49 /* UIEdgeInsets.swift in Sources */, C3D9E4F4256778AF0040E4F3 /* NSData+Image.m in Sources */, + FDF222092818D2B0000A4995 /* NSAttributedString+Utilities.swift in Sources */, C32C5E0C256DDAFA003C73A2 /* NSRegularExpression+SSK.swift in Sources */, C3BBE0A92554D4DE0050F1E3 /* HTTP.swift in Sources */, B8856D23256F116B001CE70E /* Weak.swift in Sources */, @@ -4919,9 +4930,10 @@ C300A60D2554B31900555489 /* Logging.swift in Sources */, B8FF8EA625C11FEF004D1F22 /* IPv4.swift in Sources */, C3D9E35525675EE10040E4F3 /* MIMETypeUtil.m in Sources */, - FD705A8E278CE29800F16121 /* String+Localization.swift in Sources */, + FD705A8E278CE29800F16121 /* String+Localized.swift in Sources */, FD9004162818B46700ABAAF6 /* JobRunnerError.swift in Sources */, FD09797227FAA2F500936362 /* Optional+Utilities.swift in Sources */, + FD7162DB281B6C440060647B /* TypedTableAlias.swift in Sources */, C3A7219A2558C1660043A11F /* AnyPromise+Conversion.swift in Sources */, C300A6322554B6D100555489 /* NSDate+Timestamp.mm in Sources */, FD17D7E727F6A16700122BE0 /* _003_YDBToGRDBMigration.swift in Sources */, @@ -5103,6 +5115,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + FDF2220B2818F38D000A4995 /* SessionApp.swift in Sources */, B8041AA725C90927003C2166 /* TypingIndicatorCell.swift in Sources */, B8CCF63723961D6D0091D419 /* NewDMVC.swift in Sources */, 452EC6DF205E9E30000E787C /* MediaGalleryViewController.swift in Sources */, @@ -5156,7 +5169,6 @@ 4CC613362227A00400E21A3A /* ConversationSearch.swift in Sources */, B82149B825D60393009C0F2A /* BlockedModal.swift in Sources */, B82B408C239A068800A248E7 /* RegisterVC.swift in Sources */, - 346129991FD1E4DA00532771 /* SignalApp.m in Sources */, 34BECE301F7ABCF800D7438D /* GifPickerLayout.swift in Sources */, C331FFFE2558FF3B00070591 /* ConversationCell.swift in Sources */, FD659AC027A7649600F12C02 /* MessageRequestsViewController.swift in Sources */, @@ -5167,7 +5179,6 @@ FD88BAD927A7439C00BBC442 /* MessageRequestsCell.swift in Sources */, 45A6DAD61EBBF85500893231 /* ReminderView.swift in Sources */, B82B408E239DC00D00A248E7 /* DisplayNameVC.swift in Sources */, - B8214A2B25D63EB9009C0F2A /* MessagesTableView.swift in Sources */, B835246E25C38ABF0089A44F /* ConversationVC.swift in Sources */, B8AF4BB426A5204600583500 /* SendSeedModal.swift in Sources */, B821494625D4D6FF009C0F2A /* URLModal.swift in Sources */, @@ -5203,6 +5214,7 @@ B8CCF6432397711F0091D419 /* SettingsVC.swift in Sources */, C354E75A23FE2A7600CE22E3 /* BaseVC.swift in Sources */, 45C0DC1B1E68FE9000E04C47 /* UIApplication+OWS.swift in Sources */, + FDF222072818CECF000A4995 /* ConversationViewModel.swift in Sources */, 4539B5861F79348F007141FF /* PushRegistrationManager.swift in Sources */, B8041A9525C8FA1D003C2166 /* MediaLoaderView.swift in Sources */, 45F32C232057297A00A300D5 /* MediaPageViewController.swift in Sources */, diff --git a/Session/Conversations/ConversationViewAction.h b/Session/Conversations/ConversationViewAction.h deleted file mode 100644 index 17d7cc29a..000000000 --- a/Session/Conversations/ConversationViewAction.h +++ /dev/null @@ -1,8 +0,0 @@ -@import Foundation; - -typedef NS_ENUM(NSUInteger, ConversationViewAction) { - ConversationViewActionNone, - ConversationViewActionCompose, - ConversationViewActionAudioCall, - ConversationViewActionVideoCall, -}; diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift new file mode 100644 index 000000000..0a8ab0dac --- /dev/null +++ b/Session/Conversations/ConversationViewModel.swift @@ -0,0 +1,3 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation diff --git a/Session/Conversations/Views & Modals/MessagesTableView.swift b/Session/Conversations/Views & Modals/MessagesTableView.swift deleted file mode 100644 index 033664ca8..000000000 --- a/Session/Conversations/Views & Modals/MessagesTableView.swift +++ /dev/null @@ -1,23 +0,0 @@ - -final class MessagesTableView : UITableView { - override init(frame: CGRect, style: UITableView.Style) { - super.init(frame: frame, style: style) - initialize() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - initialize() - } - - private func initialize() { - register(VisibleMessageCell.self, forCellReuseIdentifier: VisibleMessageCell.identifier) - register(InfoMessageCell.self, forCellReuseIdentifier: InfoMessageCell.identifier) - register(TypingIndicatorCell.self, forCellReuseIdentifier: TypingIndicatorCell.identifier) - separatorStyle = .none - backgroundColor = .clear - showsVerticalScrollIndicator = false - contentInsetAdjustmentBehavior = .never - keyboardDismissMode = .interactive - } -} diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 5adcfecde..f3ba0f89c 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -100,7 +100,8 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve _ = CurrentAppContext().isRTL // Preparation - SignalApp.shared().homeViewController = self + SessionApp.homeViewController.mutate { $0 = self } + // Gradient & nav bar setUpGradientBackground() if navigationController?.navigationBar != nil { diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 2683b0d5c..e589bfda6 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -306,7 +306,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD AppReadiness.runNowOrWhenAppDidBecomeReady { guard Identity.userExists() else { return } - SignalApp.shared().homeViewController?.createNewDM() + SessionApp.homeViewController.wrappedValue?.createNewDM() completionHandler(true) } } @@ -373,7 +373,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD stopPollers() let wasUnlinked: Bool = UserDefaults.standard[.wasUnlinked] - SignalApp.resetAppData { + SessionApp.resetAppData { // Resetting the data clears the old user defaults. We need to restore the unlink default. UserDefaults.standard[.wasUnlinked] = wasUnlinked } diff --git a/Session/Meta/SessionApp.swift b/Session/Meta/SessionApp.swift new file mode 100644 index 000000000..d23fe650b --- /dev/null +++ b/Session/Meta/SessionApp.swift @@ -0,0 +1,85 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit +import SessionMessagingKit + +public struct SessionApp { + static let homeViewController: Atomic = Atomic(nil) + + // MARK: - View Convenience Methods + + public static func presentConversation(for recipientId: String, action: ConversationViewModel.Action = .none, animated: Bool) { + let maybeThread: SessionThread? = GRDBStorage.shared.write { db in + SessionThread.fetchOrCreate(db, id: recipientId, variant: .contact) + } + + guard let thread: SessionThread = maybeThread else { return } + + self.presentConversation(for: thread, action: action, animated: animated) + } + + public static func presentConversation(for threadId: String, animated: Bool) { + guard let thread: SessionThread = GRDBStorage.shared.read({ db in try SessionThread.fetchOne(db, id: threadId) }) else { + SNLog("Unable to find thread with id:\(threadId)") + return + } + + self.presentConversation(for: thread, animated: animated) + } + + public static func presentConversation( + for thread: SessionThread, + action: ConversationViewModel.Action = .none, + focusInteractionId: Int64? = nil, + animated: Bool + ) { + guard Thread.isMainThread else { + DispatchQueue.main.async { + self.presentConversation( + for: thread, + action: action, + focusInteractionId: focusInteractionId, + animated: animated + ) + } + return + } + + homeViewController.wrappedValue?.show( + thread, + with: action, + highlightedInteractionId: focusInteractionId, // TODO: Confirm this + animated: animated + ) + } + + // MARK: - Functions + + public static func resetAppData(onReset: (() -> ())? = nil) { + // This _should_ be wiped out below. + Logger.error("") + DDLog.flushLog() + + OWSStorage.resetAllStorage() + OWSUserProfile.resetProfileStorage() + Environment.shared.preferences.clear() + AppEnvironment.shared.notificationPresenter.clearAllNotifications() + + onReset?() + exit(0) + } + + public static func showHomeView() { + guard Thread.isMainThread else { + DispatchQueue.main.async { + self.showHomeView() + } + return + } + + let homeViewController: HomeVC = HomeVC() + let navController: UINavigationController = UINavigationController(rootViewController: homeViewController) + (UIApplication.shared.delegate as? AppDelegate)?.window?.rootViewController = navController + } +} diff --git a/Session/Meta/SignalApp.h b/Session/Meta/SignalApp.h deleted file mode 100644 index 8583ed672..000000000 --- a/Session/Meta/SignalApp.h +++ /dev/null @@ -1,60 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "ConversationViewAction.h" - -NS_ASSUME_NONNULL_BEGIN - -@class AccountManager; -@class CallService; -@class CallUIAdapter; -@class HomeVC; -@class OWSMessageFetcherJob; -@class OWSNavigationController; -@class OutboundCallInitiator; -@class TSThread; - -@interface SignalApp : NSObject - -@property (nonatomic, nullable, weak) HomeVC *homeViewController; -@property (nonatomic, nullable, weak) OWSNavigationController *signUpFlowNavigationController; - -- (instancetype)init NS_UNAVAILABLE; - -+ (instancetype)sharedApp; - -- (void)setup; - -#pragma mark - Conversation Presentation - -- (void)presentConversationForRecipientId:(NSString *)recipientId animated:(BOOL)isAnimated; - -- (void)presentConversationForRecipientId:(NSString *)recipientId - action:(ConversationViewAction)action - animated:(BOOL)isAnimated; - -- (void)presentConversationForThreadId:(NSString *)threadId animated:(BOOL)isAnimated; - -- (void)presentConversationForThread:(TSThread *)thread animated:(BOOL)isAnimated; - -- (void)presentConversationForThread:(TSThread *)thread action:(ConversationViewAction)action animated:(BOOL)isAnimated; - -- (void)presentConversationForThread:(TSThread *)thread - action:(ConversationViewAction)action - focusMessageId:(nullable NSString *)focusMessageId - animated:(BOOL)isAnimated; - -- (void)presentConversationAndScrollToFirstUnreadMessageForThreadId:(NSString *)threadId animated:(BOOL)isAnimated; - -#pragma mark - Methods - -+ (void)resetAppData; -+ (void)resetAppData:(void (^__nullable)(void))onReset; - - -- (void)showHomeView; - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Meta/SignalApp.m b/Session/Meta/SignalApp.m deleted file mode 100644 index 7c1e40fcd..000000000 --- a/Session/Meta/SignalApp.m +++ /dev/null @@ -1,167 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "SignalApp.h" -#import "Session-Swift.h" -#import -#import -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@implementation SignalApp - -+ (instancetype)sharedApp -{ - static SignalApp *sharedApp = nil; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - sharedApp = [[self alloc] initDefault]; - }); - return sharedApp; -} - -- (instancetype)initDefault -{ - self = [super init]; - - if (!self) { - return self; - } - - OWSSingletonAssert(); - - return self; -} - -#pragma mark - Singletons - -- (void)setup { - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(didChangeCallLoggingPreference:) - name:OWSPreferencesCallLoggingDidChangeNotification - object:nil]; -} - -#pragma mark - View Convenience Methods - -- (void)presentConversationForRecipientId:(NSString *)recipientId animated:(BOOL)isAnimated -{ - [self presentConversationForRecipientId:recipientId action:ConversationViewActionNone animated:(BOOL)isAnimated]; -} - -- (void)presentConversationForRecipientId:(NSString *)recipientId - action:(ConversationViewAction)action - animated:(BOOL)isAnimated -{ - __block TSThread *thread = nil; - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) { - thread = [TSContactThread getOrCreateThreadWithContactSessionID:recipientId transaction:transaction]; - }]; - [self presentConversationForThread:thread action:action animated:(BOOL)isAnimated]; -} - -- (void)presentConversationForThreadId:(NSString *)threadId animated:(BOOL)isAnimated -{ - OWSAssertDebug(threadId.length > 0); - - TSThread *thread = [TSThread fetchObjectWithUniqueID:threadId]; - if (thread == nil) { - OWSFailDebug(@"unable to find thread with id: %@", threadId); - return; - } - - [self presentConversationForThread:thread animated:isAnimated]; -} - -- (void)presentConversationForThread:(TSThread *)thread animated:(BOOL)isAnimated -{ - [self presentConversationForThread:thread action:ConversationViewActionNone animated:isAnimated]; -} - -- (void)presentConversationForThread:(TSThread *)thread action:(ConversationViewAction)action animated:(BOOL)isAnimated -{ - [self presentConversationForThread:thread action:action focusMessageId:nil animated:isAnimated]; -} - -- (void)presentConversationForThread:(TSThread *)thread - action:(ConversationViewAction)action - focusMessageId:(nullable NSString *)focusMessageId - animated:(BOOL)isAnimated -{ - OWSAssertIsOnMainThread(); - - OWSLogInfo(@""); - - if (!thread) { - OWSFailDebug(@"Can't present nil thread."); - return; - } - - DispatchMainThreadSafe(^{ - [self.homeViewController show:thread with:action highlightedMessageID:focusMessageId animated:isAnimated]; - }); -} - -- (void)presentConversationAndScrollToFirstUnreadMessageForThreadId:(NSString *)threadId animated:(BOOL)isAnimated -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(threadId.length > 0); - - OWSLogInfo(@""); - - TSThread *thread = [TSThread fetchObjectWithUniqueID:threadId]; - if (thread == nil) { - OWSFailDebug(@"unable to find thread with id: %@", threadId); - return; - } - - DispatchMainThreadSafe(^{ - [self.homeViewController show:thread with:ConversationViewActionNone highlightedMessageID:nil animated:isAnimated]; - }); -} - -- (void)didChangeCallLoggingPreference:(NSNotification *)notitication -{ -// [AppEnvironment.shared.callService createCallUIAdapter]; -} - -#pragma mark - Methods - -+ (void)resetAppData -{ - [self resetAppData:nil]; -} - -+ (void)resetAppData:(void (^__nullable)(void))onReset { - // This _should_ be wiped out below. - OWSLogError(@""); - [DDLog flushLog]; - - [OWSStorage resetAllStorage]; - [OWSUserProfile resetProfileStorage]; - [Environment.shared.preferences clear]; - [AppEnvironment.shared.notificationPresenter clearAllNotifications]; - - if (onReset != nil) { onReset(); } - exit(0); -} - -- (void)showHomeView -{ - HomeVC *homeView = [HomeVC new]; - UINavigationController *navigationController = [[UINavigationController alloc] initWithRootViewController:homeView]; - AppDelegate *appDelegate = (AppDelegate *)[UIApplication sharedApplication].delegate; - appDelegate.window.rootViewController = navigationController; - OWSAssertDebug([navigationController.topViewController isKindOfClass:[HomeVC class]]); - - // Clear the signUpFlowNavigationController. - [self setSignUpFlowNavigationController:nil]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Notifications/AppNotifications.swift b/Session/Notifications/AppNotifications.swift index 40964537a..429fd4b1c 100644 --- a/Session/Notifications/AppNotifications.swift +++ b/Session/Notifications/AppNotifications.swift @@ -251,7 +251,7 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { DispatchQueue.main.async { notificationBody = MentionUtilities.highlightMentions( in: (notificationBody ?? ""), - threadId: thread.id + threadVariant: thread.variant ) let sound: Preferences.Sound? = self.requestSound(thread: thread) @@ -354,10 +354,6 @@ class NotificationActionHandler { // MARK: - Dependencies - var signalApp: SignalApp { - return SignalApp.shared() - } - var notificationPresenter: NotificationPresenter { return AppEnvironment.shared.notificationPresenter } @@ -421,12 +417,12 @@ class NotificationActionHandler { // can be visible to the user immediately upon opening the app, rather than having to watch // it animate in from the homescreen. let shouldAnimate = UIApplication.shared.applicationState == .active - signalApp.presentConversationAndScrollToFirstUnreadMessage(forThreadId: threadId, animated: shouldAnimate) + SessionApp.presentConversation(for: threadId, animated: shouldAnimate) return Promise.value(()) } func showHomeVC() -> Promise { - signalApp.showHomeView() + SessionApp.showHomeView() return Promise.value(()) } diff --git a/Session/Settings/NotificationSettingsOptionsViewController.m b/Session/Settings/NotificationSettingsOptionsViewController.m index ebb120b40..edc613765 100644 --- a/Session/Settings/NotificationSettingsOptionsViewController.m +++ b/Session/Settings/NotificationSettingsOptionsViewController.m @@ -4,7 +4,6 @@ #import "NotificationSettingsOptionsViewController.h" #import "Session-Swift.h" -#import "SignalApp.h" #import #import diff --git a/Session/Settings/PrivacySettingsTableViewController.m b/Session/Settings/PrivacySettingsTableViewController.m index ea6ac16ef..a6e99f674 100644 --- a/Session/Settings/PrivacySettingsTableViewController.m +++ b/Session/Settings/PrivacySettingsTableViewController.m @@ -13,7 +13,6 @@ #import #import #import -#import #import #import @@ -73,7 +72,7 @@ static NSString *const kSealedSenderInfoURL = @"https://signal.org/blog/sealed-s @"Label for the 'read receipts' setting.") accessibilityIdentifier:[NSString stringWithFormat:@"settings.privacy.%@", @"read_receipts"] isOnBlock:^{ - return [OWSReadReceiptManager.sharedManager areReadReceiptsEnabled]; + return [SSKPreferences areReadReceiptsEnabled]; } isEnabledBlock:^{ return YES; diff --git a/Session/Settings/SettingsVC.swift b/Session/Settings/SettingsVC.swift index cb64e2c5f..3ea7b2fe0 100644 --- a/Session/Settings/SettingsVC.swift +++ b/Session/Settings/SettingsVC.swift @@ -139,15 +139,23 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { setUpGradientBackground() setUpNavBarStyle() setNavBarTitle(NSLocalizedString("vc_settings_title", comment: "")) + // Navigation bar buttons updateNavigationBarButtons() + // Profile picture view + let profile: Profile = Profile.fetchOrCreateCurrentUser() let profilePictureTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(showEditProfilePictureUI)) profilePictureView.addGestureRecognizer(profilePictureTapGestureRecognizer) - profilePictureView.publicKey = getUserHexEncodedPublicKey() - profilePictureView.update() + profilePictureView + .update( + publicKey: profile.id, + profile: profile, + threadVariant: .contact + ) // Display name label - displayNameLabel.text = Profile.fetchOrCreateCurrentUser().name + displayNameLabel.text = profile.name + // Display name container let displayNameContainer = UIView() displayNameContainer.accessibilityLabel = "Edit display name text field" @@ -160,22 +168,27 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { displayNameTextField.alpha = 0 let displayNameContainerTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(showEditDisplayNameUI)) displayNameContainer.addGestureRecognizer(displayNameContainerTapGestureRecognizer) + // Header view let headerStackView = UIStackView(arrangedSubviews: [ profilePictureView, displayNameContainer ]) headerStackView.axis = .vertical headerStackView.spacing = Values.smallSpacing headerStackView.alignment = .center + // Separator let separator = Separator(title: NSLocalizedString("your_session_id", comment: "")) + // Share button let shareButton = Button(style: .regular, size: .medium) shareButton.setTitle(NSLocalizedString("share", comment: ""), for: UIControl.State.normal) shareButton.addTarget(self, action: #selector(sharePublicKey), for: UIControl.Event.touchUpInside) + // Button container let buttonContainer = UIStackView(arrangedSubviews: [ copyButton, shareButton ]) buttonContainer.axis = .horizontal buttonContainer.spacing = Values.mediumSpacing buttonContainer.distribution = .fillEqually + // Top stack view let topStackView = UIStackView(arrangedSubviews: [ headerStackView, separator, publicKeyLabel, buttonContainer ]) topStackView.axis = .vertical @@ -183,10 +196,12 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { topStackView.alignment = .fill topStackView.layoutMargins = UIEdgeInsets(top: 0, left: Values.largeSpacing, bottom: 0, right: Values.largeSpacing) topStackView.isLayoutMarginsRelativeArrangement = true + // Setting buttons stack view getSettingButtons().forEach { settingButtonOrSeparator in settingButtonsStackView.addArrangedSubview(settingButtonOrSeparator) } + // Oxen logo updateLogo() let logoContainer = UIView() @@ -194,6 +209,7 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { logoImageView.pin(.top, to: .top, of: logoContainer) logoContainer.pin(.bottom, to: .bottom, of: logoImageView) logoImageView.centerXAnchor.constraint(equalTo: logoContainer.centerXAnchor, constant: -2).isActive = true + // Main stack view let stackView = UIStackView(arrangedSubviews: [ topStackView, settingButtonsStackView, inviteButton, faqButton, surveyButton, supportButton, helpTranslateButton, logoContainer, versionLabel ]) stackView.axis = .vertical @@ -202,6 +218,7 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { stackView.layoutMargins = UIEdgeInsets(top: Values.mediumSpacing, left: 0, bottom: Values.mediumSpacing, right: 0) stackView.isLayoutMarginsRelativeArrangement = true stackView.set(.width, to: UIScreen.main.bounds.width) + // Scroll view let scrollView = UIScrollView() scrollView.showsVerticalScrollIndicator = false @@ -365,7 +382,7 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { profileName: (name ?? ""), avatarImage: profilePicture, requiredSync: true, - success: { + success: { updatedProfile in if displayNameToBeUploaded != nil { userDefaults[.lastDisplayNameUpdate] = Date() } @@ -378,11 +395,14 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { DispatchQueue.main.async { modalActivityIndicator.dismiss { - guard let self = self else { return } - self.profilePictureView.update() - self.displayNameLabel.text = name - self.profilePictureToBeUploaded = nil - self.displayNameToBeUploaded = nil + self?.profilePictureView.update( + publicKey: updatedProfile.id, + profile: updatedProfile, + threadVariant: .contact + ) + self?.displayNameLabel.text = name + self?.profilePictureToBeUploaded = nil + self?.displayNameToBeUploaded = nil } } }, diff --git a/Session/Shared/UserCell.swift b/Session/Shared/UserCell.swift index af665877f..dea13b9ce 100644 --- a/Session/Shared/UserCell.swift +++ b/Session/Shared/UserCell.swift @@ -78,10 +78,10 @@ final class UserCell : UITableViewCell { separator.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.bottom, UIView.HorizontalEdge.trailing ], to: contentView) } - // MARK: Updating + // MARK: - Updating + func update() { - profilePictureView.publicKey = publicKey - profilePictureView.update() + profilePictureView.update(for: publicKey) displayNameLabel.text = Profile.displayName(id: publicKey) switch accessory { diff --git a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift index 19a5895f4..693ccde4a 100644 --- a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift +++ b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift @@ -2,8 +2,12 @@ import Foundation import Mantle +import Sodium import YapDatabase import SignalCoreKit +import SessionUtilitiesKit + +public typealias SMKLegacy = Legacy public enum Legacy { // MARK: - Collections and Keys @@ -29,7 +33,7 @@ public enum Legacy { internal static let openGroupServerIdToUniqueIdLookupCollection = "SNOpenGroupServerIdToUniqueIdLookup" internal static let interactionCollection = "TSInteraction" - internal static let attachmentsCollection = "TSAttachements" + internal static let attachmentsCollection = "TSAttachements" // Note: This is how it was previously spelt internal static let outgoingReadReceiptManagerCollection = "kOutgoingReadReceiptManagerCollection" internal static let notifyPushServerJobCollection = "NotifyPNServerJobCollection" @@ -55,16 +59,890 @@ public enum Legacy { internal static let userDefaultsHasHiddenMessageRequests = "hasHiddenMessageRequests" - // MARK: - Types + // MARK: - Types (and NSCoding) - public typealias Contact = _LegacyContact - public typealias DisappearingMessagesConfiguration = _LegacyDisappearingMessagesConfiguration + @objc(SNContact) + public class Contact: NSObject, NSCoding { + @objc public let sessionID: String + @objc public var profilePictureURL: String? + @objc public var profilePictureFileName: String? + @objc public var profileEncryptionKey: OWSAES256Key? + @objc public var threadID: String? + @objc public var isTrusted = false + @objc public var isApproved = false + @objc public var isBlocked = false + @objc public var didApproveMe = false + @objc public var hasBeenBlocked = false + @objc public var name: String? + @objc public var nickname: String? + + // MARK: Coding + + public required init?(coder: NSCoder) { + guard let sessionID = coder.decodeObject(forKey: "sessionID") as! String? else { return nil } + self.sessionID = sessionID + isTrusted = coder.decodeBool(forKey: "isTrusted") + if let name = coder.decodeObject(forKey: "displayName") as! String? { self.name = name } + if let nickname = coder.decodeObject(forKey: "nickname") as! String? { self.nickname = nickname } + if let profilePictureURL = coder.decodeObject(forKey: "profilePictureURL") as! String? { self.profilePictureURL = profilePictureURL } + if let profilePictureFileName = coder.decodeObject(forKey: "profilePictureFileName") as! String? { self.profilePictureFileName = profilePictureFileName } + if let profileEncryptionKey = coder.decodeObject(forKey: "profilePictureEncryptionKey") as! OWSAES256Key? { self.profileEncryptionKey = profileEncryptionKey } + if let threadID = coder.decodeObject(forKey: "threadID") as! String? { self.threadID = threadID } + + let isBlockedFlag: Bool = coder.decodeBool(forKey: "isBlocked") + isApproved = coder.decodeBool(forKey: "isApproved") + isBlocked = isBlockedFlag + didApproveMe = coder.decodeBool(forKey: "didApproveMe") + hasBeenBlocked = (coder.decodeBool(forKey: "hasBeenBlocked") || isBlockedFlag) + } + + public func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") + } + } + + @objc(OWSDisappearingMessagesConfiguration) + internal class DisappearingMessagesConfiguration: MTLModel { + @objc public let uniqueId: String + @objc public var isEnabled: Bool + @objc public var durationSeconds: UInt32 + + // MARK: - NSCoder + + required init(coder: NSCoder) { + self.uniqueId = coder.decodeObject(forKey: "uniqueId") as! String + self.isEnabled = coder.decodeObject(forKey: "enabled") as! Bool + self.durationSeconds = coder.decodeObject(forKey: "durationSeconds") as! UInt32 + + // Intentionally not calling 'super.init(coder:) here + super.init() + } + + required init(dictionary dictionaryValue: [String : Any]!) throws { + fatalError("init(dictionary:) has not been implemented") + } + + override public func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") + } + } + + // MARK: - Visible/Control Message NSCoding + + /// Abstract base class for `VisibleMessage` and `ControlMessage`. + @objc(SNMessage) + internal class Message: NSObject, NSCoding { + internal var id: String? + internal var threadID: String? + internal var sentTimestamp: UInt64? + internal var receivedTimestamp: UInt64? + internal var recipient: String? + internal var sender: String? + internal var groupPublicKey: String? + internal var openGroupServerMessageID: UInt64? + internal var openGroupServerTimestamp: UInt64? + internal var serverHash: String? + + // MARK: NSCoding + + public required init?(coder: NSCoder) { + if let id = coder.decodeObject(forKey: "id") as! String? { self.id = id } + if let threadID = coder.decodeObject(forKey: "threadID") as! String? { self.threadID = threadID } + if let sentTimestamp = coder.decodeObject(forKey: "sentTimestamp") as! UInt64? { self.sentTimestamp = sentTimestamp } + if let receivedTimestamp = coder.decodeObject(forKey: "receivedTimestamp") as! UInt64? { self.receivedTimestamp = receivedTimestamp } + if let recipient = coder.decodeObject(forKey: "recipient") as! String? { self.recipient = recipient } + if let sender = coder.decodeObject(forKey: "sender") as! String? { self.sender = sender } + if let groupPublicKey = coder.decodeObject(forKey: "groupPublicKey") as! String? { self.groupPublicKey = groupPublicKey } + if let openGroupServerMessageID = coder.decodeObject(forKey: "openGroupServerMessageID") as! UInt64? { self.openGroupServerMessageID = openGroupServerMessageID } + if let openGroupServerTimestamp = coder.decodeObject(forKey: "openGroupServerTimestamp") as! UInt64? { self.openGroupServerTimestamp = openGroupServerTimestamp } + if let serverHash = coder.decodeObject(forKey: "serverHash") as! String? { self.serverHash = serverHash } + } + + public func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") + } + + // MARK: Non-Legacy Conversion + + internal func toNonLegacy(_ instance: SessionMessagingKit.Message? = nil) throws -> SessionMessagingKit.Message { + let result: SessionMessagingKit.Message = (instance ?? SessionMessagingKit.Message()) + result.id = self.id + result.threadId = self.threadID + result.sentTimestamp = self.sentTimestamp + result.receivedTimestamp = self.receivedTimestamp + result.recipient = self.recipient + result.sender = self.sender + result.groupPublicKey = self.groupPublicKey + result.openGroupServerMessageId = self.openGroupServerMessageID + result.openGroupServerTimestamp = self.openGroupServerTimestamp + result.serverHash = self.serverHash + + return result + } + } + + @objc(SNVisibleMessage) + internal final class VisibleMessage: Message { + internal var syncTarget: String? + internal var text: String? + internal var attachmentIDs: [String] = [] + internal var quote: Quote? + internal var linkPreview: LinkPreview? + internal var profile: Profile? + internal var openGroupInvitation: OpenGroupInvitation? + + // MARK: - NSCoding + + public required init?(coder: NSCoder) { + super.init(coder: coder) + + if let syncTarget = coder.decodeObject(forKey: "syncTarget") as! String? { self.syncTarget = syncTarget } + if let text = coder.decodeObject(forKey: "body") as! String? { self.text = text } + if let attachmentIDs = coder.decodeObject(forKey: "attachments") as! [String]? { self.attachmentIDs = attachmentIDs } + if let quote = coder.decodeObject(forKey: "quote") as! Quote? { self.quote = quote } + if let linkPreview = coder.decodeObject(forKey: "linkPreview") as! LinkPreview? { self.linkPreview = linkPreview } + if let profile = coder.decodeObject(forKey: "profile") as! Profile? { self.profile = profile } + if let openGroupInvitation = coder.decodeObject(forKey: "openGroupInvitation") as! OpenGroupInvitation? { self.openGroupInvitation = openGroupInvitation } + } + + public override func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") + } + + // MARK: Non-Legacy Conversion + + override internal func toNonLegacy(_ instance: SessionMessagingKit.Message? = nil) throws -> SessionMessagingKit.Message { + return try super.toNonLegacy( + SessionMessagingKit.VisibleMessage( + syncTarget: syncTarget, + text: text, + attachmentIds: attachmentIDs, + quote: quote?.toNonLegacy(), + linkPreview: linkPreview?.toNonLegacy(), + profile: profile?.toNonLegacy(), + openGroupInvitation: openGroupInvitation?.toNonLegacy() + ) + ) + } + } + + @objc(SNQuote) + internal class Quote: NSObject, NSCoding { + internal var timestamp: UInt64? + internal var publicKey: String? + internal var text: String? + internal var attachmentID: String? + + // MARK: - NSCoding + + public required init?(coder: NSCoder) { + if let timestamp = coder.decodeObject(forKey: "timestamp") as! UInt64? { self.timestamp = timestamp } + if let publicKey = coder.decodeObject(forKey: "authorId") as! String? { self.publicKey = publicKey } + if let text = coder.decodeObject(forKey: "body") as! String? { self.text = text } + if let attachmentID = coder.decodeObject(forKey: "attachmentID") as! String? { self.attachmentID = attachmentID } + } + + public func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") + } + + // MARK: Non-Legacy Conversion + + internal func toNonLegacy() -> SessionMessagingKit.VisibleMessage.Quote { + return SessionMessagingKit.VisibleMessage.Quote( + timestamp: (timestamp ?? 0), + publicKey: (publicKey ?? ""), + text: text, + attachmentId: attachmentID + ) + } + } + + @objc(SNLinkPreview) + internal class LinkPreview: NSObject, NSCoding { + internal var title: String? + internal var url: String? + internal var attachmentID: String? + + // MARK: - NSCoding + + public required init?(coder: NSCoder) { + if let title = coder.decodeObject(forKey: "title") as! String? { self.title = title } + if let url = coder.decodeObject(forKey: "urlString") as! String? { self.url = url } + if let attachmentID = coder.decodeObject(forKey: "attachmentID") as! String? { self.attachmentID = attachmentID } + } + + public func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") + } + + // MARK: Non-Legacy Conversion + + internal func toNonLegacy() -> SessionMessagingKit.VisibleMessage.LinkPreview { + return SessionMessagingKit.VisibleMessage.LinkPreview( + title: title, + url: (url ?? ""), + attachmentId: attachmentID + ) + } + } @objc(SNProfile) - public class Profile: NSObject, NSCoding { - public var displayName: String? - public var profileKey: Data? - public var profilePictureURL: String? + internal class Profile: NSObject, NSCoding { + internal var displayName: String? + internal var profileKey: Data? + internal var profilePictureURL: String? + + // MARK: - NSCoding + + public required init?(coder: NSCoder) { + if let displayName = coder.decodeObject(forKey: "displayName") as! String? { self.displayName = displayName } + if let profileKey = coder.decodeObject(forKey: "profileKey") as! Data? { self.profileKey = profileKey } + if let profilePictureURL = coder.decodeObject(forKey: "profilePictureURL") as! String? { self.profilePictureURL = profilePictureURL } + } + + public func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") + } + + // MARK: Non-Legacy Conversion + + internal func toNonLegacy() -> SessionMessagingKit.VisibleMessage.Profile { + return SessionMessagingKit.VisibleMessage.Profile( + displayName: (displayName ?? ""), + profileKey: profileKey, + profilePictureUrl: profilePictureURL + ) + } + } + + @objc(SNOpenGroupInvitation) + internal class OpenGroupInvitation: NSObject, NSCoding { + internal var name: String? + internal var url: String? + + // MARK: - NSCoding + + public required init?(coder: NSCoder) { + if let name = coder.decodeObject(forKey: "name") as! String? { self.name = name } + if let url = coder.decodeObject(forKey: "url") as! String? { self.url = url } + } + + public func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") + } + + // MARK: Non-Legacy Conversion + + internal func toNonLegacy() -> SessionMessagingKit.VisibleMessage.OpenGroupInvitation { + return SessionMessagingKit.VisibleMessage.OpenGroupInvitation( + name: (name ?? ""), + url: (url ?? "") + ) + } + } + + @objc(SNControlMessage) + internal class ControlMessage: Message {} + + @objc(SNReadReceipt) + internal final class ReadReceipt: ControlMessage { + internal var timestamps: [UInt64]? + + // MARK: - NSCoding + + public required init?(coder: NSCoder) { + super.init(coder: coder) + if let timestamps = coder.decodeObject(forKey: "messageTimestamps") as! [UInt64]? { self.timestamps = timestamps } + } + + public override func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") + } + + // MARK: Non-Legacy Conversion + + override internal func toNonLegacy(_ instance: SessionMessagingKit.Message? = nil) throws -> SessionMessagingKit.Message { + return try super.toNonLegacy( + SessionMessagingKit.ReadReceipt( + timestamps: (timestamps ?? []) + ) + ) + } + } + + @objc(SNTypingIndicator) + internal final class TypingIndicator: ControlMessage { + public var rawKind: Int? + + // MARK: - NSCoding + + public required init?(coder: NSCoder) { + super.init(coder: coder) + + self.rawKind = coder.decodeObject(forKey: "action") as! Int? + } + + public override func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") + } + + // MARK: Non-Legacy Conversion + + override internal func toNonLegacy(_ instance: SessionMessagingKit.Message? = nil) throws -> SessionMessagingKit.Message { + return try super.toNonLegacy( + SessionMessagingKit.TypingIndicator( + kind: SessionMessagingKit.TypingIndicator.Kind( + rawValue: (rawKind ?? SessionMessagingKit.TypingIndicator.Kind.stopped.rawValue) + ) + .defaulting(to: .stopped) + ) + ) + } + } + + @objc(SNClosedGroupControlMessage) + internal final class ClosedGroupControlMessage: ControlMessage { + internal var rawKind: String? + + internal var publicKey: Data? + internal var wrappers: [KeyPairWrapper]? + internal var name: String? + internal var encryptionKeyPair: SUKLegacy.KeyPair? + internal var members: [Data]? + internal var admins: [Data]? + internal var expirationTimer: UInt32 + + // MARK: - Key Pair Wrapper + + @objc(SNKeyPairWrapper) + internal final class KeyPairWrapper: NSObject, NSCoding { + internal var publicKey: String? + internal var encryptedKeyPair: Data? + + // MARK: - NSCoding + + public required init?(coder: NSCoder) { + if let publicKey = coder.decodeObject(forKey: "publicKey") as! String? { self.publicKey = publicKey } + if let encryptedKeyPair = coder.decodeObject(forKey: "encryptedKeyPair") as! Data? { self.encryptedKeyPair = encryptedKeyPair } + } + + public func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") + } + } + + // MARK: - NSCoding + + public required init?(coder: NSCoder) { + self.rawKind = coder.decodeObject(forKey: "kind") as? String + + self.publicKey = coder.decodeObject(forKey: "publicKey") as? Data + self.wrappers = coder.decodeObject(forKey: "wrappers") as? [KeyPairWrapper] + self.name = coder.decodeObject(forKey: "name") as? String + self.encryptionKeyPair = coder.decodeObject(forKey: "encryptionKeyPair") as? SUKLegacy.KeyPair + self.members = coder.decodeObject(forKey: "members") as? [Data] + self.admins = coder.decodeObject(forKey: "admins") as? [Data] + self.expirationTimer = (coder.decodeObject(forKey: "expirationTimer") as? UInt32 ?? 0) + + super.init(coder: coder) + } + + public override func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") + } + + // MARK: Non-Legacy Conversion + + override internal func toNonLegacy(_ instance: SessionMessagingKit.Message? = nil) throws -> SessionMessagingKit.Message { + return try super.toNonLegacy( + SessionMessagingKit.ClosedGroupControlMessage( + kind: try { + switch rawKind { + case "new": + guard + let publicKey: Data = self.publicKey, + let name: String = self.name, + let encryptionKeyPair: SUKLegacy.KeyPair = self.encryptionKeyPair, + let members: [Data] = self.members, + let admins: [Data] = self.admins + else { + SNLog("[Migration Error] Unable to decode Legacy ClosedGroupControlMessage") + throw GRDBStorageError.migrationFailed + } + + return .new( + publicKey: publicKey, + name: name, + encryptionKeyPair: Box.KeyPair( + publicKey: encryptionKeyPair.publicKey.bytes, + secretKey: encryptionKeyPair.privateKey.bytes + ), + members: members, + admins: admins, + expirationTimer: self.expirationTimer + ) + + case "encryptionKeyPair": + guard let wrappers: [KeyPairWrapper] = self.wrappers else { + SNLog("[Migration Error] Unable to decode Legacy ClosedGroupControlMessage") + throw GRDBStorageError.migrationFailed + } + + return .encryptionKeyPair( + publicKey: publicKey, + wrappers: try wrappers.map { wrapper in + guard + let publicKey: String = wrapper.publicKey, + let encryptedKeyPair: Data = wrapper.encryptedKeyPair + else { + SNLog("[Migration Error] Unable to decode Legacy ClosedGroupControlMessage") + throw GRDBStorageError.migrationFailed + } + + return SessionMessagingKit.ClosedGroupControlMessage.KeyPairWrapper( + publicKey: publicKey, + encryptedKeyPair: encryptedKeyPair + ) + } + ) + + case "nameChange": + guard let name: String = self.name else { + SNLog("[Migration Error] Unable to decode Legacy ClosedGroupControlMessage") + throw GRDBStorageError.migrationFailed + } + + return .nameChange( + name: name + ) + + case "membersAdded": + guard let members: [Data] = self.members else { + SNLog("[Migration Error] Unable to decode Legacy ClosedGroupControlMessage") + throw GRDBStorageError.migrationFailed + } + + return .membersAdded(members: members) + + case "membersRemoved": + guard let members: [Data] = self.members else { + SNLog("[Migration Error] Unable to decode Legacy ClosedGroupControlMessage") + throw GRDBStorageError.migrationFailed + } + + return .membersRemoved(members: members) + + case "memberLeft": return .memberLeft + case "encryptionKeyPairRequest": return .encryptionKeyPairRequest + default: throw GRDBStorageError.migrationFailed + } + }() + ) + ) + } + } + + @objc(SNDataExtractionNotification) + internal final class DataExtractionNotification: ControlMessage { + internal let rawKind: String? + internal let timestamp: UInt64? + + // MARK: - NSCoding + + public required init?(coder: NSCoder) { + self.rawKind = coder.decodeObject(forKey: "kind") as? String + self.timestamp = coder.decodeObject(forKey: "timestamp") as? UInt64 + + super.init(coder: coder) + } + + public override func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") + } + + // MARK: Non-Legacy Conversion + + override internal func toNonLegacy(_ instance: SessionMessagingKit.Message? = nil) throws -> SessionMessagingKit.Message { + return try super.toNonLegacy( + SessionMessagingKit.DataExtractionNotification( + kind: try { + switch rawKind { + case "screenshot": return .screenshot + case "mediaSaved": + guard let timestamp: UInt64 = self.timestamp else { + SNLog("[Migration Error] Unable to decode Legacy DataExtractionNotification") + throw GRDBStorageError.migrationFailed + } + + return .mediaSaved(timestamp: timestamp) + + default: throw GRDBStorageError.migrationFailed + } + }() + ) + ) + } + } + + @objc(SNExpirationTimerUpdate) + internal final class ExpirationTimerUpdate: ControlMessage { + internal var syncTarget: String? + internal var duration: UInt32? + + // MARK: - NSCoding + + public required init?(coder: NSCoder) { + super.init(coder: coder) + if let syncTarget = coder.decodeObject(forKey: "syncTarget") as! String? { self.syncTarget = syncTarget } + if let duration = coder.decodeObject(forKey: "durationSeconds") as! UInt32? { self.duration = duration } + } + + public override func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") + } + + // MARK: Non-Legacy Conversion + + override internal func toNonLegacy(_ instance: SessionMessagingKit.Message? = nil) throws -> SessionMessagingKit.Message { + return try super.toNonLegacy( + SessionMessagingKit.ExpirationTimerUpdate( + syncTarget: syncTarget, + duration: (duration ?? 0) + ) + ) + } + } + + @objc(SNConfigurationMessage) + internal final class ConfigurationMessage: ControlMessage { + internal var closedGroups: Set = [] + internal var openGroups: Set = [] + internal var displayName: String? + internal var profilePictureURL: String? + internal var profileKey: Data? + internal var contacts: Set = [] + + // MARK: - NSCoding + + public required init?(coder: NSCoder) { + super.init(coder: coder) + if let closedGroups = coder.decodeObject(forKey: "closedGroups") as! Set? { self.closedGroups = closedGroups } + if let openGroups = coder.decodeObject(forKey: "openGroups") as! Set? { self.openGroups = openGroups } + if let displayName = coder.decodeObject(forKey: "displayName") as! String? { self.displayName = displayName } + if let profilePictureURL = coder.decodeObject(forKey: "profilePictureURL") as! String? { self.profilePictureURL = profilePictureURL } + if let profileKey = coder.decodeObject(forKey: "profileKey") as! Data? { self.profileKey = profileKey } + if let contacts = coder.decodeObject(forKey: "contacts") as! Set? { self.contacts = contacts } + } + + public override func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") + } + + // MARK: Non-Legacy Conversion + + override internal func toNonLegacy(_ instance: SessionMessagingKit.Message? = nil) throws -> SessionMessagingKit.Message { + return try super.toNonLegacy( + SessionMessagingKit.ConfigurationMessage( + displayName: displayName, + profilePictureUrl: profilePictureURL, + profileKey: profileKey, + closedGroups: closedGroups + .map { $0.toNonLegacy() } + .asSet(), + openGroups: openGroups, + contacts: contacts + .map { $0.toNonLegacy() } + .asSet() + ) + ) + } + } + + @objc(CMClosedGroup) + internal final class CMClosedGroup: NSObject, NSCoding { + internal let publicKey: String + internal let name: String + internal let encryptionKeyPair: SUKLegacy.KeyPair + internal let members: Set + internal let admins: Set + internal let expirationTimer: UInt32 + + // MARK: - NSCoding + + public required init?(coder: NSCoder) { + guard + let publicKey = coder.decodeObject(forKey: "publicKey") as! String?, + let name = coder.decodeObject(forKey: "name") as! String?, + let encryptionKeyPair = coder.decodeObject(forKey: "encryptionKeyPair") as! SUKLegacy.KeyPair?, + let members = coder.decodeObject(forKey: "members") as! Set?, + let admins = coder.decodeObject(forKey: "admins") as! Set? + else { return nil } + + self.publicKey = publicKey + self.name = name + self.encryptionKeyPair = encryptionKeyPair + self.members = members + self.admins = admins + self.expirationTimer = (coder.decodeObject(forKey: "expirationTimer") as? UInt32 ?? 0) + } + + public func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") + } + + // MARK: Non-Legacy Conversion + + internal func toNonLegacy() -> SessionMessagingKit.ConfigurationMessage.CMClosedGroup { + return SessionMessagingKit.ConfigurationMessage.CMClosedGroup( + publicKey: publicKey, + name: name, + encryptionKeyPublicKey: encryptionKeyPair.publicKey, + encryptionKeySecretKey: encryptionKeyPair.privateKey, + members: members, + admins: admins, + expirationTimer: expirationTimer + ) + } + } + + @objc(SNConfigurationMessageContact) + internal final class CMContact: NSObject, NSCoding { + internal var publicKey: String? + internal var displayName: String? + internal var profilePictureURL: String? + internal var profileKey: Data? + + internal var hasIsApproved: Bool + internal var isApproved: Bool + internal var hasIsBlocked: Bool + internal var isBlocked: Bool + internal var hasDidApproveMe: Bool + internal var didApproveMe: Bool + + // MARK: - NSCoding + + public required init?(coder: NSCoder) { + guard + let publicKey = coder.decodeObject(forKey: "publicKey") as! String?, + let displayName = coder.decodeObject(forKey: "displayName") as! String? + else { return nil } + + self.publicKey = publicKey + self.displayName = displayName + self.profilePictureURL = coder.decodeObject(forKey: "profilePictureURL") as! String? + self.profileKey = coder.decodeObject(forKey: "profileKey") as! Data? + self.hasIsApproved = (coder.decodeObject(forKey: "hasIsApproved") as? Bool ?? false) + self.isApproved = (coder.decodeObject(forKey: "isApproved") as? Bool ?? false) + self.hasIsBlocked = (coder.decodeObject(forKey: "hasIsBlocked") as? Bool ?? false) + self.isBlocked = (coder.decodeObject(forKey: "isBlocked") as? Bool ?? false) + self.hasDidApproveMe = (coder.decodeObject(forKey: "hasDidApproveMe") as? Bool ?? false) + self.didApproveMe = (coder.decodeObject(forKey: "didApproveMe") as? Bool ?? false) + } + + public func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") + } + + // MARK: Non-Legacy Conversion + + internal func toNonLegacy() -> SessionMessagingKit.ConfigurationMessage.CMContact { + return SessionMessagingKit.ConfigurationMessage.CMContact( + publicKey: publicKey, + displayName: displayName, + profilePictureUrl: profilePictureURL, + profileKey: profileKey, + hasIsApproved: hasIsApproved, + isApproved: isApproved, + hasIsBlocked: hasIsBlocked, + isBlocked: isBlocked, + hasDidApproveMe: hasDidApproveMe, + didApproveMe: didApproveMe + ) + } + } + + @objc(SNUnsendRequest) + internal final class UnsendRequest: ControlMessage { + internal var timestamp: UInt64? + internal var author: String? + + // MARK: - NSCoding + + public required init?(coder: NSCoder) { + super.init(coder: coder) + + self.timestamp = coder.decodeObject(forKey: "timestamp") as? UInt64 + self.author = coder.decodeObject(forKey: "author") as? String + } + + public override func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") + } + + // MARK: Non-Legacy Conversion + + override internal func toNonLegacy(_ instance: SessionMessagingKit.Message? = nil) throws -> SessionMessagingKit.Message { + return try super.toNonLegacy( + SessionMessagingKit.UnsendRequest( + timestamp: (timestamp ?? 0), + author: (author ?? "") + ) + ) + } + } + + @objc(SNMessageRequestResponse) + internal final class MessageRequestResponse: ControlMessage { + internal var isApproved: Bool + + // MARK: - NSCoding + + public required init?(coder: NSCoder) { + self.isApproved = coder.decodeBool(forKey: "isApproved") + + super.init(coder: coder) + } + + public override func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") + } + + // MARK: Non-Legacy Conversion + + override internal func toNonLegacy(_ instance: SessionMessagingKit.Message? = nil) throws -> SessionMessagingKit.Message { + return try super.toNonLegacy( + SessionMessagingKit.MessageRequestResponse( + isApproved: isApproved + ) + ) + } + } + + // MARK: - Attachments + + @objc(TSAttachment) + internal class Attachment: NSObject, NSCoding { + @objc(TSAttachmentType) + public enum AttachmentType: Int { + case `default` + case voiceMessage + } + + @objc public var serverId: UInt64 + @objc public var encryptionKey: Data? + @objc public var contentType: String + @objc public var isDownloaded: Bool + @objc public var attachmentType: AttachmentType + @objc public var downloadURL: String + @objc public var byteCount: UInt32 + @objc public var sourceFilename: String? + @objc public var caption: String? + @objc public var albumMessageId: String? + + public var isImage: Bool { return MIMETypeUtil.isImage(contentType) } + public var isVideo: Bool { return MIMETypeUtil.isVideo(contentType) } + public var isAudio: Bool { return MIMETypeUtil.isAudio(contentType) } + public var isAnimated: Bool { return MIMETypeUtil.isAnimated(contentType) } + + public var isVisualMedia: Bool { isImage || isVideo || isAnimated } + + // MARK: - NSCoder + + public required init(coder: NSCoder) { + self.serverId = coder.decodeObject(forKey: "serverId") as! UInt64 + self.encryptionKey = coder.decodeObject(forKey: "encryptionKey") as? Data + self.contentType = coder.decodeObject(forKey: "contentType") as! String + self.isDownloaded = (coder.decodeObject(forKey: "isDownloaded") as? Bool == true) + self.attachmentType = AttachmentType( + rawValue: (coder.decodeObject(forKey: "attachmentType") as! NSNumber).intValue + ).defaulting(to: .default) + self.downloadURL = (coder.decodeObject(forKey: "downloadURL") as? String ?? "") + self.byteCount = coder.decodeObject(forKey: "byteCount") as! UInt32 + } + + public func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") + } + } + + @objc(TSAttachmentPointer) + internal class AttachmentPointer: Attachment { + @objc(TSAttachmentPointerState) + public enum State: Int { + case enqueued + case downloading + case failed + } + + @objc public var state: State + @objc public var mostRecentFailureLocalizedText: String? + @objc public var digest: Data? + @objc public var mediaSize: CGSize + @objc public var lazyRestoreFragmentId: String? + + // MARK: - NSCoder + + public required init(coder: NSCoder) { + self.state = State( + rawValue: coder.decodeObject(forKey: "state") as! Int + ).defaulting(to: .failed) + self.mostRecentFailureLocalizedText = coder.decodeObject(forKey: "mostRecentFailureLocalizedText") as? String + self.digest = coder.decodeObject(forKey: "digest") as? Data + self.mediaSize = coder.decodeObject(forKey: "mediaSize") as! CGSize + self.lazyRestoreFragmentId = coder.decodeObject(forKey: "lazyRestoreFragmentId") as? String + + super.init(coder: coder) + } + + override public func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") + } + } + + @objc(TSAttachmentStream) + internal class AttachmentStream: Attachment { + @objc public var digest: Data? + @objc public var isUploaded: Bool + @objc public var creationTimestamp: Date + @objc public var localRelativeFilePath: String? + @objc public var cachedImageWidth: NSNumber? + @objc public var cachedImageHeight: NSNumber? + @objc public var cachedAudioDurationSeconds: NSNumber? + @objc public var isValidImageCached: NSNumber? + @objc public var isValidVideoCached: NSNumber? + + public var isValidImage: Bool { return (isValidImageCached?.boolValue == true) } + public var isValidVideo: Bool { return (isValidVideoCached?.boolValue == true) } + + public var isValidVisualMedia: Bool { + if self.isImage && self.isValidImage { return true } + if self.isVideo && self.isValidVideo { return true } + if self.isAnimated && self.isValidImage { return true } + + return false + } + + // MARK: - NSCoder + + public required init(coder: NSCoder) { + self.digest = coder.decodeObject(forKey: "digest") as? Data + self.isUploaded = (coder.decodeObject(forKey: "isUploaded") as? Bool == true) + self.creationTimestamp = coder.decodeObject(forKey: "creationTimestamp") as! Date + self.localRelativeFilePath = coder.decodeObject(forKey: "localRelativeFilePath") as? String + self.cachedImageWidth = coder.decodeObject(forKey: "cachedImageWidth") as? NSNumber + self.cachedImageHeight = coder.decodeObject(forKey: "cachedImageHeight") as? NSNumber + self.cachedAudioDurationSeconds = coder.decodeObject(forKey: "cachedAudioDurationSeconds") as? NSNumber + self.isValidImageCached = coder.decodeObject(forKey: "isValidImageCached") as? NSNumber + self.isValidVideoCached = coder.decodeObject(forKey: "isValidVideoCached") as? NSNumber + + super.init(coder: coder) + } + + override public func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") + } + } @objc(NotifyPNServerJob) internal final class NotifyPNServerJob: NSObject, NSCoding { @@ -94,10 +972,7 @@ public enum Legacy { } public func encode(with coder: NSCoder) { - coder.encode(recipient, forKey: "recipient") - coder.encode(data, forKey: "data") - coder.encode(ttl, forKey: "ttl") - coder.encode(timestamp, forKey: "timestamp") + fatalError("encode(with:) should never be called for legacy types") } } @@ -119,9 +994,7 @@ public enum Legacy { } public func encode(with coder: NSCoder) { - coder.encode(message, forKey: "message") - coder.encode(id, forKey: "id") - coder.encode(failureCount, forKey: "failureCount") + fatalError("encode(with:) should never be called for legacy types") } } @@ -140,36 +1013,33 @@ public enum Legacy { public init?(coder: NSCoder) { guard let data = coder.decodeObject(forKey: "data") as! Data?, - let id = coder.decodeObject(forKey: "id") as! String?, - let isBackgroundPoll = coder.decodeObject(forKey: "isBackgroundPoll") as! Bool? + let id = coder.decodeObject(forKey: "id") as! String? else { return nil } self.data = data self.serverHash = coder.decodeObject(forKey: "serverHash") as! String? self.openGroupMessageServerID = coder.decodeObject(forKey: "openGroupMessageServerID") as! UInt64? self.openGroupID = coder.decodeObject(forKey: "openGroupID") as! String? - self.isBackgroundPoll = isBackgroundPoll + // Note: This behaviour is changed from the old code but the 'isBackgroundPoll' is only set + // when getting messages from the 'BackgroundPoller' class and since we likely want to process + // these new messages immediately it should be fine to do this (this value seemed to be missing + // in some cases which resulted in the 'Legacy.MessageReceiveJob' failing to parse) + self.isBackgroundPoll = ((coder.decodeObject(forKey: "isBackgroundPoll") as? Bool) ?? false) self.id = id self.failureCount = ((coder.decodeObject(forKey: "failureCount") as? UInt) ?? 0) } public func encode(with coder: NSCoder) { - coder.encode(data, forKey: "data") - coder.encode(serverHash, forKey: "serverHash") - coder.encode(openGroupMessageServerID, forKey: "openGroupMessageServerID") - coder.encode(openGroupID, forKey: "openGroupID") - coder.encode(isBackgroundPoll, forKey: "isBackgroundPoll") - coder.encode(id, forKey: "id") - coder.encode(failureCount, forKey: "failureCount") + fatalError("encode(with:) should never be called for legacy types") } } @objc(SNMessageSendJob) - public final class MessageSendJob: NSObject, NSCoding { - public let message: Message - public let destination: Message.Destination - public var id: String? - public var failureCount: UInt = 0 + internal final class MessageSendJob: NSObject, NSCoding { + internal let message: Message + internal let destination: SessionMessagingKit.Message.Destination + internal var id: String? + internal var failureCount: UInt = 0 // MARK: - Coding @@ -217,24 +1087,7 @@ public enum Legacy { } public func encode(with coder: NSCoder) { - coder.encode(message, forKey: "message") - - switch destination { - case .contact(let publicKey): - coder.encode("contact(\(publicKey))", forKey: "destination") - - case .closedGroup(let groupPublicKey): - coder.encode("closedGroup(\(groupPublicKey))", forKey: "destination") - - case .openGroup(let channel, let server): - coder.encode("openGroup(\(channel), \(server))", forKey: "destination") - - case .openGroupV2(let room, let server): - coder.encode("openGroupV2(\(room), \(server))", forKey: "destination") - } - - coder.encode(id, forKey: "id") - coder.encode(failureCount, forKey: "failureCount") + fatalError("encode(with:) should never be called for legacy types") } // MARK: - Convenience @@ -252,13 +1105,13 @@ public enum Legacy { } @objc(AttachmentUploadJob) - public final class AttachmentUploadJob: NSObject, NSCoding { - public let attachmentID: String - public let threadID: String - public let message: Message - public let messageSendJobID: String - public var id: String? - public var failureCount: UInt = 0 + internal final class AttachmentUploadJob: NSObject, NSCoding { + internal let attachmentID: String + internal let threadID: String + internal let message: Message + internal let messageSendJobID: String + internal var id: String? + internal var failureCount: UInt = 0 // MARK: - Coding @@ -280,12 +1133,7 @@ public enum Legacy { } public func encode(with coder: NSCoder) { - coder.encode(attachmentID, forKey: "attachmentID") - coder.encode(threadID, forKey: "threadID") - coder.encode(message, forKey: "message") - coder.encode(messageSendJobID, forKey: "messageSendJobID") - coder.encode(id, forKey: "id") - coder.encode(failureCount, forKey: "failureCount") + fatalError("encode(with:) should never be called for legacy types") } } @@ -317,235 +1165,34 @@ public enum Legacy { } public func encode(with coder: NSCoder) { - coder.encode(attachmentID, forKey: "attachmentID") - coder.encode(tsMessageID, forKey: "tsIncomingMessageID") - coder.encode(threadID, forKey: "threadID") - coder.encode(id, forKey: "id") - coder.encode(failureCount, forKey: "failureCount") - coder.encode(isDeferred, forKey: "isDeferred") + fatalError("encode(with:) should never be called for legacy types") + } + } + + public final class DisappearingConfigurationUpdateInfoMessage: TSInfoMessage { + // Note: Due to how Mantle works we need to set default values for these as the 'init(dictionary:)' + // method doesn't actually get values for them but the must be set before calling a super.init method + // so this allows us to work around the behaviour until 'init(coder:)' method completes it's super call + var createdByRemoteName: String? + var configurationDurationSeconds: UInt32 = 0 + var configurationIsEnabled: Bool = false + + // MARK: - Coding + + public required init(coder: NSCoder) { + super.init(coder: coder) + + self.createdByRemoteName = coder.decodeObject(forKey: "createdByRemoteName") as? String + self.configurationDurationSeconds = ((coder.decodeObject(forKey: "configurationDurationSeconds") as? UInt32) ?? 0) + self.configurationIsEnabled = ((coder.decodeObject(forKey: "configurationIsEnabled") as? Bool) ?? false) + } + + required init(dictionary dictionaryValue: [String : Any]!) throws { + try super.init(dictionary: dictionaryValue) + } + + public override func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") } } } - -@objc(SNJob) -public protocol _LegacyJob : NSCoding { - var id: String? { get set } - var failureCount: UInt { get set } - - static var collection: String { get } - static var maxFailureCount: UInt { get } - - func execute() -} - -// Note: Looks like Swift doesn't expose nested types well (in the `-Swift` header this was -// appearing with `SWIFT_CLASS_NAME("Contact")` which conflicts with the new type and has a -// different structure) as a result we cannot nest this cleanly -@objc(SNContact) -public class _LegacyContact: NSObject, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility - @objc public let sessionID: String - /// The URL from which to fetch the contact's profile picture. - @objc public var profilePictureURL: String? - /// The file name of the contact's profile picture on local storage. - @objc public var profilePictureFileName: String? - /// The key with which the profile is encrypted. - @objc public var profileEncryptionKey: OWSAES256Key? - /// The ID of the thread associated with this contact. - @objc public var threadID: String? - /// This flag is used to determine whether we should auto-download files sent by this contact. - @objc public var isTrusted = false - /// This flag is used to determine whether message requests from this contact are approved - @objc public var isApproved = false - /// This flag is used to determine whether message requests from this contact are blocked - @objc public var isBlocked = false { - didSet { - if isBlocked { - hasBeenBlocked = true - } - } - } - /// This flag is used to determine whether this contact has approved the current users message request - @objc public var didApproveMe = false - /// This flag is used to determine whether this contact has ever been blocked (will be included in the config message if so) - @objc public var hasBeenBlocked = false - - // MARK: Name - /// The name of the contact. Use this whenever you need the "real", underlying name of a user (e.g. when sending a message). - @objc public var name: String? - /// The contact's nickname, if the user set one. - @objc public var nickname: String? - /// The name to display in the UI. For local use only. - @objc public func displayName(for context: Context) -> String? { - if let nickname = nickname { return nickname } - switch context { - case .regular: return name - case .openGroup: - // In open groups, where it's more likely that multiple users have the same name, we display a bit of the Session ID after - // a user's display name for added context. - guard let name = name else { return nil } - let endIndex = sessionID.endIndex - let cutoffIndex = sessionID.index(endIndex, offsetBy: -8) - return "\(name) (...\(sessionID[cutoffIndex.. Bool { - guard let other = other as? _LegacyContact else { return false } - return sessionID == other.sessionID - } - - // MARK: Hashing - override public var hash: Int { // Override NSObject.hash and not Hashable.hashValue or Hashable.hash(into:) - return sessionID.hash - } - - // MARK: Description - override public var description: String { - nickname ?? name ?? sessionID - } - - // MARK: Convenience - @objc(contextForThread:) - public static func context(for thread: TSThread) -> Context { - return ((thread as? TSGroupThread)?.isOpenGroup == true) ? .openGroup : .regular - -@objc(OWSDisappearingMessagesConfiguration) -public class _LegacyDisappearingMessagesConfiguration: MTLModel { - public let uniqueId: String - @objc public var isEnabled: Bool - @objc public var durationSeconds: UInt32 - - @objc public var durationIndex: UInt32 = 0 - @objc public var durationString: String? - - var originalDictionaryValue: [String: Any]? - @objc public var isNewRecord: Bool = false - - @objc public static func defaultWith(_ threadId: String) -> Legacy.DisappearingMessagesConfiguration { - return Legacy.DisappearingMessagesConfiguration( - threadId: threadId, - enabled: false, - durationSeconds: (24 * 60 * 60) - ) - } - - public static func fetch(uniqueId: String, transaction: YapDatabaseReadTransaction? = nil) -> Legacy.DisappearingMessagesConfiguration? { - return nil - } - - @objc public static func fetchObject(uniqueId: String) -> Legacy.DisappearingMessagesConfiguration? { - return nil - } - - @objc public static func fetchOrBuildDefault(threadId: String, transaction: YapDatabaseReadTransaction) -> Legacy.DisappearingMessagesConfiguration? { - return defaultWith(threadId) - } - - @objc public static var validDurationsSeconds: [UInt32] = [] - - // MARK: - Initialization - - init(threadId: String, enabled: Bool, durationSeconds: UInt32) { - self.uniqueId = threadId - self.isEnabled = enabled - self.durationSeconds = durationSeconds - self.isNewRecord = true - - super.init() - } - - required init(coder: NSCoder) { - self.uniqueId = coder.decodeObject(forKey: "uniqueId") as! String - self.isEnabled = coder.decodeObject(forKey: "enabled") as! Bool - self.durationSeconds = coder.decodeObject(forKey: "durationSeconds") as! UInt32 - - // Intentionally not calling 'super.init(coder:) here - super.init() - } - - required init(dictionary dictionaryValue: [String : Any]!) throws { - fatalError("init(dictionary:) has not been implemented") - } - - // MARK: - Dirty Tracking - - @objc public override static func storageBehaviorForProperty(withKey propertyKey: String) -> MTLPropertyStorage { - // Don't persist transient properties - if - propertyKey == "TAG" || - propertyKey == "originalDictionaryValue" || - propertyKey == "newRecord" - { - return MTLPropertyStorageNone - } - - return super.storageBehaviorForProperty(withKey: propertyKey) - } - - @objc public var dictionaryValueDidChange: Bool { - return false - } - - @objc(saveWithTransaction:) - public func save(with transaction: YapDatabaseReadWriteTransaction) { - self.originalDictionaryValue = self.dictionaryValue - self.isNewRecord = false - } -} diff --git a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift index af8637bfa..67d6bbca2 100644 --- a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -194,7 +194,6 @@ enum _001_InitialSetupMigration: Migration { t.column(.recipientId, .text) .notNull() .indexed() // Quicker querying - .references(Profile.self) t.column(.state, .integer) .notNull() .indexed() // Quicker querying @@ -225,6 +224,10 @@ enum _001_InitialSetupMigration: Migration { t.column(.localRelativeFilePath, .text) t.column(.width, .integer) t.column(.height, .integer) + t.column(.duration, .double) + t.column(.isValid, .boolean) + .notNull() + .defaults(to: false) t.column(.encryptionKey, .blob) t.column(.digest, .blob) t.column(.caption, .text) diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 5fd482a8d..59e042b5c 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import AVKit import GRDB import Curve25519Kit import SessionUtilitiesKit @@ -13,16 +14,17 @@ enum _003_YDBToGRDBMigration: Migration { static func migrate(_ db: Database) throws { // MARK: - Process Contacts, Threads & Interactions - + print("RAWR [\(Date().timeIntervalSince1970)] - SessionMessagingKit migration - Start") var shouldFailMigration: Bool = false var contacts: Set = [] + var validProfileIds: Set = [] var contactThreadIds: Set = [] var legacyThreadIdToIdMap: [String: String] = [:] var threads: Set = [] var disappearingMessagesConfiguration: [String: Legacy.DisappearingMessagesConfiguration] = [:] - var closedGroupKeys: [String: [TimeInterval: SessionUtilitiesKit.Legacy.KeyPair]] = [:] + var closedGroupKeys: [String: [TimeInterval: SUKLegacy.KeyPair]] = [:] var closedGroupName: [String: String] = [:] var closedGroupFormation: [String: UInt64] = [:] var closedGroupModel: [String: TSGroupModel] = [:] @@ -36,18 +38,41 @@ enum _003_YDBToGRDBMigration: Migration { // var openGroupServerToUniqueIdLookup: [String: [String]] = [:] // TODO: Not needed???? var interactions: [String: [TSInteraction]] = [:] - var attachments: [String: TSAttachment] = [:] + var attachments: [String: Legacy.Attachment] = [:] + var processedAttachmentIds: Set = [] var outgoingReadReceiptsTimestampsMs: [String: Set] = [:] + // Map the Legacy types for the NSKeyedUnarchiver + NSKeyedUnarchiver.setClass( + Legacy.Contact.self, + forClassName: "SNContact" + ) + NSKeyedUnarchiver.setClass( + Legacy.Attachment.self, + forClassName: "TSAttachment" + ) + NSKeyedUnarchiver.setClass( + Legacy.AttachmentStream.self, + forClassName: "TSAttachmentStream" + ) + NSKeyedUnarchiver.setClass( + Legacy.AttachmentPointer.self, + forClassName: "TSAttachmentPointer" + ) + NSKeyedUnarchiver.setClass( + Legacy.DisappearingConfigurationUpdateInfoMessage.self, + forClassName: "OWSDisappearingConfigurationUpdateInfoMessage" + ) + Storage.read { transaction in // Process the Contacts transaction.enumerateRows(inCollection: Legacy.contactCollection) { _, object, _, _ in guard let contact = object as? Legacy.Contact else { return } contacts.insert(contact) + validProfileIds.insert(contact.sessionID) } print("RAWR [\(Date().timeIntervalSince1970)] - Process threads - Start") - let userClosedGroupPublicKeys: [String] = transaction.allKeys(inCollection: Legacy.closedGroupPublicKeyCollection) // Process the threads transaction.enumerateKeysAndObjects(inCollection: Legacy.threadCollection) { key, object, _ in @@ -66,7 +91,6 @@ enum _003_YDBToGRDBMigration: Migration { disappearingMessagesConfiguration[threadId] = transaction .object(forKey: threadId, inCollection: Legacy.disappearingMessagesCollection) .asType(Legacy.DisappearingMessagesConfiguration.self) - .defaulting(to: Legacy.DisappearingMessagesConfiguration.defaultWith(threadId)) // Process group-specific info guard let groupThread: TSGroupThread = thread as? TSGroupThread else { @@ -89,14 +113,6 @@ enum _003_YDBToGRDBMigration: Migration { shouldFailMigration = true return } - guard userClosedGroupPublicKeys.contains(publicKey) else { - // TODO: Determine if we want to remove this - SNLog("[Migration Error] Found unexpected invalid closed group public key") - shouldFailMigration = true - return - } - - let keyCollection: String = "\(Legacy.closedGroupKeyPairPrefix)\(publicKey)" legacyThreadIdToIdMap[threadId] = publicKey closedGroupName[threadId] = groupThread.name(with: transaction) @@ -107,10 +123,15 @@ enum _003_YDBToGRDBMigration: Migration { inCollection: Legacy.closedGroupZombieMembersCollection ) as? Set + // Note: If the user is no longer in a closed group then the group will still exist but the user + // won't have the closed group public key anymore + let keyCollection: String = "\(Legacy.closedGroupKeyPairPrefix)\(publicKey)" + transaction.enumerateKeysAndObjects(inCollection: keyCollection) { key, object, _ in - guard let timestamp: TimeInterval = TimeInterval(key), let keyPair: SessionUtilitiesKit.Legacy.KeyPair = object as? SessionUtilitiesKit.Legacy.KeyPair else { - return - } + guard + let timestamp: TimeInterval = TimeInterval(key), + let keyPair: SUKLegacy.KeyPair = object as? SUKLegacy.KeyPair + else { return } closedGroupKeys[threadId] = (closedGroupKeys[threadId] ?? [:]) .setting(timestamp, keyPair) @@ -153,7 +174,7 @@ enum _003_YDBToGRDBMigration: Migration { // Process attachments print("RAWR [\(Date().timeIntervalSince1970)] - Process attachments - Start") transaction.enumerateKeysAndObjects(inCollection: Legacy.attachmentsCollection) { key, object, _ in - guard let attachment: TSAttachment = object as? TSAttachment else { + guard let attachment: Legacy.Attachment = object as? Legacy.Attachment else { SNLog("[Migration Error] Unable to process attachment") shouldFailMigration = true return @@ -227,7 +248,6 @@ enum _003_YDBToGRDBMigration: Migration { var legacyInteractionToIdMap: [String: Int64] = [:] var legacyInteractionIdentifierToIdMap: [String: Int64] = [:] var legacyInteractionIdentifierToIdFallbackMap: [String: Int64] = [:] - var legacyAttachmentToIdMap: [String: String] = [:] func identifier( for threadId: String, @@ -296,7 +316,10 @@ enum _003_YDBToGRDBMigration: Migration { creationDateTimestamp: thread.creationDate.timeIntervalSince1970, shouldBeVisible: thread.shouldBeVisible, isPinned: thread.isPinned, - messageDraft: thread.messageDraft, + messageDraft: ((thread.messageDraft ?? "").isEmpty ? + nil : + thread.messageDraft + ), notificationMode: notificationMode, mutedUntilTimestamp: thread.mutedUntilDate?.timeIntervalSince1970 ).insert(db) @@ -309,11 +332,15 @@ enum _003_YDBToGRDBMigration: Migration { durationSeconds: TimeInterval(config.durationSeconds) ).insert(db) } + else { + try DisappearingMessagesConfiguration + .defaultWith(threadId) + .insert(db) + } // Closed Groups if (thread as? TSGroupThread)?.isClosedGroup == true { guard - let legacyKeys = closedGroupKeys[legacyThreadId], let name: String = closedGroupName[legacyThreadId], let groupModel: TSGroupModel = closedGroupModel[legacyThreadId], let formationTimestamp: UInt64 = closedGroupFormation[legacyThreadId] @@ -328,7 +355,10 @@ enum _003_YDBToGRDBMigration: Migration { formationTimestamp: TimeInterval(formationTimestamp) ).insert(db) - try legacyKeys.forEach { timestamp, legacyKeys in + // Note: If a user has left a closed group then they won't actually have any keys + // but they should still be able to browse the old messages so we do want to allow + // this case and migrate the rest of the info + try closedGroupKeys[legacyThreadId]?.forEach { timestamp, legacyKeys in try ClosedGroupKeyPair( threadId: threadId, publicKey: legacyKeys.publicKey, @@ -469,7 +499,6 @@ enum _003_YDBToGRDBMigration: Migration { recipientStateMap = [:] mostRecentFailureText = nil - case let outgoingMessage as TSOutgoingMessage: variant = .standardOutgoing authorId = currentUserPublicKey @@ -481,11 +510,32 @@ enum _003_YDBToGRDBMigration: Migration { mostRecentFailureText = outgoingMessage.mostRecentFailureText case let infoMessage as TSInfoMessage: + // Note: The legacy 'TSInfoMessage' didn't store the author id so there is no + // way to determine who actually triggered the info message authorId = currentUserPublicKey - body = ((infoMessage.body ?? "").isEmpty ? - infoMessage.customMessage : - infoMessage.body - ) + body = { + // Note: The 'DisappearingConfigurationUpdateInfoMessage' stored additional info and constructed + // a string at display time so we want to continue that behaviour + guard + infoMessage.messageType == .disappearingMessagesUpdate, + let updateMessage: Legacy.DisappearingConfigurationUpdateInfoMessage = infoMessage as? Legacy.DisappearingConfigurationUpdateInfoMessage, + let infoMessageData: Data = try? JSONEncoder().encode( + DisappearingMessagesConfiguration.MessageInfo( + senderName: updateMessage.createdByRemoteName, + isEnabled: updateMessage.configurationIsEnabled, + durationSeconds: TimeInterval(updateMessage.configurationDurationSeconds) + ) + ), + let infoMessageString: String = String(data: infoMessageData, encoding: .utf8) + else { + return ((infoMessage.body ?? "").isEmpty ? + infoMessage.customMessage : + infoMessage.body + ) + } + + return infoMessageString + }() wasRead = infoMessage.wasRead expiresInSeconds = nil // Info messages don't expire expiresStartedAtMs = nil // Info messages don't expire @@ -522,8 +572,15 @@ enum _003_YDBToGRDBMigration: Migration { timestampMs: Int64(legacyInteraction.timestamp), receivedAtTimestampMs: Int64(legacyInteraction.receivedAtTimestamp), wasRead: wasRead, - expiresInSeconds: expiresInSeconds.map { TimeInterval($0) }, - expiresStartedAtMs: expiresStartedAtMs.map { Double($0) }, + // For both of these '0' used to be equivalent to null + expiresInSeconds: ((expiresInSeconds ?? 0) > 0 ? + expiresInSeconds.map { TimeInterval($0) } : + nil + ), + expiresStartedAtMs: ((expiresStartedAtMs ?? 0) > 0 ? + expiresStartedAtMs.map { Double($0) } : + nil + ), linkPreviewUrl: linkPreview?.urlString, // Only a soft link so save to set openGroupServerMessageId: openGroupServerMessageId.map { Int64($0) }, openGroupWhisperMods: false, // TODO: This in SOGSV4 @@ -586,36 +643,75 @@ enum _003_YDBToGRDBMigration: Migration { // Handle any quote if let quotedMessage: TSQuotedMessage = quotedMessage { - let quoteAttachmentId: String? = quotedMessage.quotedAttachments - .compactMap { attachmentInfo in - if let attachmentId: String = attachmentInfo.attachmentId { - return attachmentId - } - else if let attachmentId: String = attachmentInfo.thumbnailAttachmentPointerId { - return attachmentId - } - // TODO: Looks like some of these might be busted??? - return attachmentInfo.thumbnailAttachmentStreamId + var quoteAttachmentId: String? = quotedMessage.quotedAttachments + .flatMap { attachmentInfo in + return [ + // Prioritise the thumbnail as it means we won't + // need to generate a new one + attachmentInfo.thumbnailAttachmentStreamId, + attachmentInfo.thumbnailAttachmentPointerId, + attachmentInfo.attachmentId + ] + .compactMap { $0 } } - .first { attachments[$0] != nil } + .first { attachmentId -> Bool in attachments[attachmentId] != nil } - guard quotedMessage.quotedAttachments.isEmpty || quoteAttachmentId != nil else { - // TODO: Is it possible to hit this case if a quoted attachment hasn't been downloaded? - SNLog("[Migration Error] Missing quote attachment") - throw GRDBStorageError.migrationFailed + // It looks like there can be cases where a quote can be quoting an + // interaction that isn't associated with a profile we know about (eg. + // if you join an open group and one of the first messages is a quote of + // an older message not cached to the device) - this will cause a foreign + // key constraint violation so in these cases just create an empty profile + if !validProfileIds.contains(quotedMessage.authorId) { + SNLog("[Migration Warning] Quote with unknown author found - Creating empty profile") + + // Note: Need to upsert here because it's possible multiple quotes + // will use the same invalid 'authorId' value resulting in a unique + // constraint violation + try Profile( + id: quotedMessage.authorId, + name: quotedMessage.authorId + ).save(db) + } + + // Note: It looks like there is a way for a quote to not have it's + // associated attachmentId so let's try our best to track down the + // original interaction and re-create the attachment link before + // falling back to having no attachment in the quote + if quoteAttachmentId == nil && !quotedMessage.quotedAttachments.isEmpty { + quoteAttachmentId = interactions[legacyThreadId]? + .first(where: { + $0.timestamp == quotedMessage.timestamp && + ( + // Outgoing messages don't store the 'authorId' so we + // need to compare against the 'currentUserPublicKey' + // for those or cast to a TSIncomingMessage otherwise + quotedMessage.authorId == currentUserPublicKey || + quotedMessage.authorId == ($0 as? TSIncomingMessage)?.authorId + ) + }) + .asType(TSMessage.self)? + .attachmentIds + .firstObject + .asType(String.self) + + SNLog([ + "[Migration Warning] Quote with invalid attachmentId found", + (quoteAttachmentId == nil ? + "Unable to reconcile, leaving attachment blank" : + "Original interaction found, using source attachment" + ) + ].joined(separator: " - ")) } // Setup the attachment and add it to the lookup (if it exists) let attachmentId: String? = try attachmentId( db, for: quoteAttachmentId, - attachments: attachments + isQuotedMessage: true, + attachments: attachments, + processedAttachmentIds: &processedAttachmentIds ) - if let quoteAttachmentId: String = quoteAttachmentId, let attachmentId: String = attachmentId { - legacyAttachmentToIdMap[quoteAttachmentId] = attachmentId - } - // Create the quote try Quote( interactionId: interactionId, @@ -642,13 +738,10 @@ enum _003_YDBToGRDBMigration: Migration { let attachmentId: String? = try attachmentId( db, for: linkPreview.imageAttachmentId, - attachments: attachments + attachments: attachments, + processedAttachmentIds: &processedAttachmentIds ) - if let legacyAttachmentId: String = linkPreview.imageAttachmentId, let attachmentId: String = attachmentId { - legacyAttachmentToIdMap[legacyAttachmentId] = attachmentId - } - // Note: It's possible for there to be duplicate values here so we use 'save' // instead of insert (ie. upsert) try LinkPreview( @@ -663,8 +756,13 @@ enum _003_YDBToGRDBMigration: Migration { // Handle any attachments try attachmentIds.forEach { legacyAttachmentId in - guard let attachmentId: String = try attachmentId(db, for: legacyAttachmentId, interactionVariant: variant, attachments: attachments) else { - // TODO: Is it possible to hit this case if an interaction hasn't been viewed? + guard let attachmentId: String = try attachmentId( + db, + for: legacyAttachmentId, + interactionVariant: variant, + attachments: attachments, + processedAttachmentIds: &processedAttachmentIds + ) else { SNLog("[Migration Error] Missing interaction attachment") throw GRDBStorageError.migrationFailed } @@ -674,13 +772,13 @@ enum _003_YDBToGRDBMigration: Migration { interactionId: interactionId, attachmentId: attachmentId ).insert(db) - - legacyAttachmentToIdMap[legacyAttachmentId] = attachmentId } } } } + print("RAWR [\(Date().timeIntervalSince1970)] - Process thread inserts - End") + // Clear out processed data (give the memory a change to be freed) contacts = [] @@ -706,6 +804,8 @@ enum _003_YDBToGRDBMigration: Migration { // MARK: - Process Legacy Jobs + print("RAWR [\(Date().timeIntervalSince1970)] - Process jobs - Start") + var notifyPushServerJobs: Set = [] var messageReceiveJobs: Set = [] var messageSendJobs: Set = [] @@ -737,6 +837,83 @@ enum _003_YDBToGRDBMigration: Migration { Legacy.AttachmentDownloadJob.self, forClassName: "SessionMessagingKit.AttachmentDownloadJob" ) + NSKeyedUnarchiver.setClass( + Legacy.Message.self, + forClassName: "SNMessage" + ) + NSKeyedUnarchiver.setClass( + Legacy.VisibleMessage.self, + forClassName: "SNVisibleMessage" + ) + NSKeyedUnarchiver.setClass( + Legacy.Quote.self, + forClassName: "SNQuote" + ) + NSKeyedUnarchiver.setClass( + Legacy.LinkPreview.self, + forClassName: "SessionServiceKit.OWSLinkPreview" // Very old legacy name + ) + NSKeyedUnarchiver.setClass( + Legacy.LinkPreview.self, + forClassName: "SNLinkPreview" + ) + NSKeyedUnarchiver.setClass( + Legacy.Profile.self, + forClassName: "SNProfile" + ) + NSKeyedUnarchiver.setClass( + Legacy.OpenGroupInvitation.self, + forClassName: "SNOpenGroupInvitation" + ) + NSKeyedUnarchiver.setClass( + Legacy.ControlMessage.self, + forClassName: "SNControlMessage" + ) + NSKeyedUnarchiver.setClass( + Legacy.ReadReceipt.self, + forClassName: "SNReadReceipt" + ) + NSKeyedUnarchiver.setClass( + Legacy.TypingIndicator.self, + forClassName: "SNTypingIndicator" + ) + NSKeyedUnarchiver.setClass( + Legacy.ClosedGroupControlMessage.self, + forClassName: "SessionMessagingKit.ClosedGroupControlMessage" + ) + NSKeyedUnarchiver.setClass( + Legacy.ClosedGroupControlMessage.KeyPairWrapper.self, + forClassName: "ClosedGroupControlMessage.SNKeyPairWrapper" + ) + NSKeyedUnarchiver.setClass( + Legacy.DataExtractionNotification.self, + forClassName: "SessionMessagingKit.DataExtractionNotification" + ) + NSKeyedUnarchiver.setClass( + Legacy.ExpirationTimerUpdate.self, + forClassName: "SNExpirationTimerUpdate" + ) + NSKeyedUnarchiver.setClass( + Legacy.ConfigurationMessage.self, + forClassName: "SNConfigurationMessage" + ) + NSKeyedUnarchiver.setClass( + Legacy.CMClosedGroup.self, + forClassName: "SNClosedGroup" + ) + NSKeyedUnarchiver.setClass( + Legacy.CMContact.self, + forClassName: "SNConfigurationMessage.SNConfigurationMessageContact" + ) + NSKeyedUnarchiver.setClass( + Legacy.UnsendRequest.self, + forClassName: "SNUnsendRequest" + ) + NSKeyedUnarchiver.setClass( + Legacy.MessageRequestResponse.self, + forClassName: "SNMessageRequestResponse" + ) + Storage.read { transaction in transaction.enumerateRows(inCollection: Legacy.notifyPushServerJobCollection) { _, object, _, _ in guard let job = object as? Legacy.NotifyPNServerJob else { return } @@ -764,8 +941,12 @@ enum _003_YDBToGRDBMigration: Migration { } } + print("RAWR [\(Date().timeIntervalSince1970)] - Process jobs - End") + // MARK: - Insert Jobs + print("RAWR [\(Date().timeIntervalSince1970)] - Process job inserts - Start") + // MARK: - --notifyPushServer try autoreleasepool { @@ -805,7 +986,18 @@ enum _003_YDBToGRDBMigration: Migration { return } - let threadId: String? = MessageReceiver.extractSenderPublicKey(db, from: envelope) + let threadId: String? + + switch envelope.type { + // For closed group messages the 'groupPublicKey' is stored in the + // 'envelope.source' value and that should be used for the 'threadId' + case .closedGroupMessage: + threadId = envelope.source + break + + default: + threadId = MessageReceiver.extractSenderPublicKey(db, from: envelope) + } _ = try Job( failureCount: legacyJob.failureCount, @@ -873,7 +1065,8 @@ enum _003_YDBToGRDBMigration: Migration { destination: legacyJob.destination, variant: { switch legacyJob.message { - case is ExpirationTimerUpdate: return .infoDisappearingMessagesUpdate + case is Legacy.ExpirationTimerUpdate: + return .infoDisappearingMessagesUpdate default: return nil } }(), @@ -895,7 +1088,7 @@ enum _003_YDBToGRDBMigration: Migration { // in these cases the 'interactionId' value will be nil interactionId: interactionId, destination: legacyJob.destination, - message: legacyJob.message + message: legacyJob.message.toNonLegacy() ) )?.inserted(db) @@ -936,7 +1129,7 @@ enum _003_YDBToGRDBMigration: Migration { SNLog("[Migration Error] attachmentDownload job unable to find interaction") throw GRDBStorageError.migrationFailed } - guard let attachmentId: String = legacyAttachmentToIdMap[legacyJob.attachmentID] else { + guard processedAttachmentIds.contains(legacyJob.attachmentID) else { SNLog("[Migration Error] attachmentDownload job unable to find attachment") throw GRDBStorageError.migrationFailed } @@ -949,7 +1142,7 @@ enum _003_YDBToGRDBMigration: Migration { threadId: legacyThreadIdToIdMap[legacyJob.threadID], interactionId: interactionId, details: AttachmentDownloadJob.Details( - attachmentId: attachmentId + attachmentId: legacyJob.attachmentID ) )?.inserted(db) } @@ -971,8 +1164,12 @@ enum _003_YDBToGRDBMigration: Migration { } } + print("RAWR [\(Date().timeIntervalSince1970)] - Process job inserts - End") + // MARK: - Process Preferences + print("RAWR [\(Date().timeIntervalSince1970)] - Process preferences inserts - Start") + var legacyPreferences: [String: Any] = [:] Storage.read { transaction in @@ -1027,24 +1224,39 @@ enum _003_YDBToGRDBMigration: Migration { db[.hasHiddenMessageRequests] = CurrentAppContext().appUserDefaults() .bool(forKey: Legacy.userDefaultsHasHiddenMessageRequests) - print("RAWR [\(Date().timeIntervalSince1970)] - Process thread inserts - End") + print("RAWR [\(Date().timeIntervalSince1970)] - Process preferences inserts - End") print("RAWR Done!!!") } // MARK: - Convenience - private static func attachmentId(_ db: Database, for legacyAttachmentId: String?, interactionVariant: Interaction.Variant? = nil, attachments: [String: TSAttachment]) throws -> String? { + private static func attachmentId( + _ db: Database, + for legacyAttachmentId: String?, + interactionVariant: Interaction.Variant? = nil, + isQuotedMessage: Bool = false, + attachments: [String: Legacy.Attachment], + processedAttachmentIds: inout Set + ) throws -> String? { guard let legacyAttachmentId: String = legacyAttachmentId else { return nil } - - guard let legacyAttachment: TSAttachment = attachments[legacyAttachmentId] else { - SNLog("[Migration Error] Missing attachment") - throw GRDBStorageError.migrationFailed + guard !processedAttachmentIds.contains(legacyAttachmentId) else { + guard isQuotedMessage else { + SNLog("[Migration Error] Attempted to process duplicate attachment") + throw GRDBStorageError.migrationFailed + } + + return legacyAttachmentId + } + + guard let legacyAttachment: Legacy.Attachment = attachments[legacyAttachmentId] else { + SNLog("[Migration Warning] Missing attachment - interaction will appear as blank") + return nil } let state: Attachment.State = { switch legacyAttachment { - case let stream as TSAttachmentStream: // Outgoing or already downloaded + case let stream as Legacy.AttachmentStream: // Outgoing or already downloaded switch interactionVariant { case .standardOutgoing: return (stream.isUploaded ? .uploaded : .pending) default: return .downloaded @@ -1056,28 +1268,90 @@ enum _003_YDBToGRDBMigration: Migration { }() let size: CGSize = { switch legacyAttachment { - case let stream as TSAttachmentStream: return stream.calculateImageSize() - case let pointer as TSAttachmentPointer: return pointer.mediaSize + case let stream as Legacy.AttachmentStream: + guard let originalFilePath: String = Attachment.originalFilePath(id: legacyAttachmentId, mimeType: stream.contentType, sourceFilename: stream.localRelativeFilePath) else { + return .zero + } + + return Attachment + .imageSize( + contentType: stream.contentType, + originalFilePath: originalFilePath + ) + .defaulting(to: .zero) + + case let pointer as Legacy.AttachmentPointer: return pointer.mediaSize default: return CGSize.zero } }() + let (isValid, duration): (Bool, TimeInterval?) = { + guard + let stream: Legacy.AttachmentStream = legacyAttachment as? Legacy.AttachmentStream, + let originalFilePath: String = Attachment.originalFilePath( + id: legacyAttachmentId, + mimeType: stream.contentType, + sourceFilename: stream.localRelativeFilePath + ) + else { + return (false, nil) + } + + if stream.isAudio { + if let cachedDuration: TimeInterval = stream.cachedAudioDurationSeconds?.doubleValue, cachedDuration > 0 { + return (true, cachedDuration) + } + + let (isValid, duration): (Bool, TimeInterval?) = Attachment.determineValidityAndDuration( + contentType: stream.contentType, + originalFilePath: originalFilePath + ) + + return (isValid, duration) + } + + if stream.isVideo { + let videoPlayer: AVPlayer = AVPlayer(url: URL(fileURLWithPath: originalFilePath)) + let duration: TimeInterval? = videoPlayer.currentItem + .map { item -> TimeInterval in + // Accorting to the CMTime docs "value/timescale = seconds" + (TimeInterval(item.duration.value) / TimeInterval(item.duration.timescale)) + } + + return ((duration ?? 0) > 0, duration) + } + + if stream.isVisualMedia { + return (stream.isValidVisualMedia, nil) + } + + return (true, nil) + }() - let attachment: Attachment = try Attachment( + + _ = try Attachment( + // Note: The legacy attachment object used a UUID string for it's id as well + // and saved files using these id's so just used the existing id so we don't + // need to bother renaming files as part of the migration + id: legacyAttachmentId, serverId: "\(legacyAttachment.serverId)", - variant: (legacyAttachment.isVoiceMessage ? .voiceMessage : .standard), + variant: (legacyAttachment.attachmentType == .voiceMessage ? .voiceMessage : .standard), state: state, contentType: legacyAttachment.contentType, byteCount: UInt(legacyAttachment.byteCount), - creationTimestamp: (legacyAttachment as? TSAttachmentStream)?.creationTimestamp.timeIntervalSince1970, + creationTimestamp: (legacyAttachment as? Legacy.AttachmentStream)?.creationTimestamp.timeIntervalSince1970, sourceFilename: legacyAttachment.sourceFilename, downloadUrl: legacyAttachment.downloadURL, width: (size == .zero ? nil : UInt(size.width)), height: (size == .zero ? nil : UInt(size.height)), + duration: duration, + isValid: isValid, encryptionKey: legacyAttachment.encryptionKey, - digest: (legacyAttachment as? TSAttachmentStream)?.digest, + digest: (legacyAttachment as? Legacy.AttachmentStream)?.digest, caption: legacyAttachment.caption ).inserted(db) - return attachment.id + processedAttachmentIds.insert(legacyAttachmentId) + + return legacyAttachmentId } } diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index b6ec24101..84e335c79 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -2,9 +2,13 @@ import Foundation import GRDB +import PromiseKit +import SignalCoreKit import SessionUtilitiesKit +import AVFAudio +import AVFoundation -public struct Attachment: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct Attachment: Codable, Identifiable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "attachment" } internal static let interactionAttachments = belongsTo(InteractionAttachment.self) fileprivate static let quote = belongsTo(Quote.self) @@ -24,6 +28,8 @@ public struct Attachment: Codable, Identifiable, FetchableRecord, PersistableRec case localRelativeFilePath case width case height + case duration + case isValid case encryptionKey case digest case caption @@ -44,7 +50,7 @@ public struct Attachment: Codable, Identifiable, FetchableRecord, PersistableRec } /// A unique identifier for the attachment - public let id: String = UUID().uuidString + public let id: String /// The id for the attachment returned by the server /// @@ -93,6 +99,12 @@ public struct Attachment: Codable, Identifiable, FetchableRecord, PersistableRec /// The height of the attachment, this will be `null` for non-visual attachment types public let height: UInt? + /// The number of seconds the attachment plays for (this will only be set for video and audio attachment types) + public let duration: TimeInterval? + + /// A flag indicating whether the attachment data downloaded is valid for it's content type + public let isValid: Bool + /// The key used to decrypt the attachment public let encryptionKey: Data? @@ -105,6 +117,7 @@ public struct Attachment: Codable, Identifiable, FetchableRecord, PersistableRec // MARK: - Initialization public init( + id: String = UUID().uuidString, serverId: String? = nil, variant: Variant, state: State = .pending, @@ -116,10 +129,13 @@ public struct Attachment: Codable, Identifiable, FetchableRecord, PersistableRec localRelativeFilePath: String? = nil, width: UInt? = nil, height: UInt? = nil, + duration: TimeInterval? = nil, + isValid: Bool = false, encryptionKey: Data? = nil, digest: Data? = nil, caption: String? = nil ) { + self.id = id self.serverId = serverId self.variant = variant self.state = state @@ -131,19 +147,21 @@ public struct Attachment: Codable, Identifiable, FetchableRecord, PersistableRec self.localRelativeFilePath = localRelativeFilePath self.width = width self.height = height + self.duration = duration + self.isValid = isValid self.encryptionKey = encryptionKey self.digest = digest self.caption = caption } + /// This initializer should only be used when converting from either a LinkPreview or a SignalAttachment to an Attachment (prior to upload) public init?( + id: String = UUID().uuidString, variant: Variant = .standard, contentType: String, dataSource: DataSource ) { - guard - let originalFilePath: String = Attachment.originalFilePath(id: self.id, mimeType: contentType, sourceFilename: nil) - else { + guard let originalFilePath: String = Attachment.originalFilePath(id: id, mimeType: contentType, sourceFilename: nil) else { return nil } guard dataSource.write(toPath: originalFilePath) else { return nil } @@ -152,7 +170,12 @@ public struct Attachment: Codable, Identifiable, FetchableRecord, PersistableRec contentType: contentType, originalFilePath: originalFilePath ) + let (isValid, duration): (Bool, TimeInterval?) = Attachment.determineValidityAndDuration( + contentType: contentType, + originalFilePath: originalFilePath + ) + self.id = id self.serverId = nil self.variant = variant self.state = .pending @@ -164,6 +187,8 @@ public struct Attachment: Codable, Identifiable, FetchableRecord, PersistableRec self.localRelativeFilePath = nil self.width = imageSize.map { UInt(floor($0.width)) } self.height = imageSize.map { UInt(floor($0.height)) } + self.duration = duration + self.isValid = isValid self.encryptionKey = nil self.digest = nil self.caption = nil @@ -223,7 +248,24 @@ public extension Attachment { encryptionKey: Data? = nil, digest: Data? = nil ) -> Attachment { + let (isValid, duration): (Bool, TimeInterval?) = { + switch (self.state, state) { + case (_, .downloaded): + return Attachment.determineValidityAndDuration( + contentType: contentType, + originalFilePath: originalFilePath + ) + + // Assume the data is already correct for "uploading" attachments (and don't override it) + case (.uploading, .failed), (.uploaded, .failed): return (self.isValid, self.duration) + case (_, .failed): return (false, nil) + + default: return (self.isValid, self.duration) + } + }() + return Attachment( + id: self.id, serverId: (serverId ?? self.serverId), variant: variant, state: (state ?? self.state), @@ -235,6 +277,8 @@ public extension Attachment { localRelativeFilePath: (localRelativeFilePath ?? self.localRelativeFilePath), width: width, height: height, + duration: duration, + isValid: isValid, encryptionKey: (encryptionKey ?? self.encryptionKey), digest: (digest ?? self.digest), caption: self.caption @@ -255,7 +299,8 @@ public extension Attachment { return (MIMETypeUtil.mimeType(forFileExtension: fileExtension) ?? OWSMimeTypeApplicationOctetStream) } - self.serverId = nil + self.id = UUID().uuidString + self.serverId = "\(proto.id)" self.variant = { let voiceMessageFlag: Int32 = SNProtoAttachmentPointer.SNProtoAttachmentPointerFlags .voiceMessage @@ -276,6 +321,8 @@ public extension Attachment { self.localRelativeFilePath = nil self.width = (proto.hasWidth && proto.width > 0 ? UInt(proto.width) : nil) self.height = (proto.hasHeight && proto.height > 0 ? UInt(proto.height) : nil) + self.duration = nil // Needs to be downloaded to be set + self.isValid = false // Needs to be downloaded to be set self.encryptionKey = proto.key self.digest = proto.digest self.caption = (proto.hasCaption ? proto.caption : nil) @@ -335,31 +382,56 @@ public extension Attachment { // MARK: - GRDB Interactions public extension Attachment { - static func fetchAllPendingAttachments(_ db: Database, for threadId: String) throws -> [Attachment] { - return try Attachment - .select(Attachment.Columns.allCases + [Interaction.Columns.id]) - .filter(Columns.variant == Variant.standard) - .filter(Columns.state == State.pending) - .joining( - optional: Attachment.interactionAttachments - .filter(Interaction.Columns.threadId == threadId) - ) - .joining( - optional: Attachment.quote - .joining( - required: Quote.interaction - .filter(Interaction.Columns.threadId == threadId) - ) - )//tmp.authorId - .joining( - optional: Attachment.linkPreview - .joining( - required: LinkPreview.interactions - .filter(Interaction.Columns.threadId == threadId) - ) - ) - .order(Interaction.Columns.id.desc) // Newest attachments first - .fetchAll(db) + struct DownloadInfo: FetchableRecord, Decodable { + public let attachmentId: String + public let interactionId: Int64 + } + + static func pendingAttachmentDownloadInfo(for authorId: String) -> SQLRequest { + let attachment: TypedTableAlias = TypedTableAlias() + let interaction: TypedTableAlias = TypedTableAlias() + let quote: TypedTableAlias = TypedTableAlias() + let interactionAttachment: TypedTableAlias = TypedTableAlias() + let linkPreview: TypedTableAlias = TypedTableAlias() + + // Note: In GRDB all joins need to run via their "association" system which doesn't support the type + // of query we have below (a required join based on one of 3 optional joins) so we have to construct + // the query manually + return """ + SELECT DISTINCT + \(attachment[.id]) AS attachmentId, + \(interaction[.id]) AS interactionId + + FROM \(Attachment.self) + + JOIN \(Interaction.self) ON + \(interaction[.authorId]) = \(SQL(sql: ":authorId", arguments: StatementArguments(["authorId": authorId]))) AND ( + \(interaction[.id]) = \(quote[.interactionId]) OR + \(interaction[.id]) = \(interactionAttachment[.interactionId]) OR + \(interaction[.linkPreviewUrl]) = \(linkPreview[.url]) + ) + + LEFT JOIN \(Quote.self) ON \(quote[.attachmentId]) = \(attachment[.id]) + LEFT JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id]) + LEFT JOIN \(LinkPreview.self) ON + \(linkPreview[.attachmentId]) = \(attachment[.id]) AND + \(linkPreview[.variant]) = \(SQL( + sql: ":variant", + arguments: StatementArguments(["variant": LinkPreview.Variant.standard]) + )) + + WHERE + \(attachment[.variant]) = \(SQL( + sql: ":attachmentVariant", + arguments: StatementArguments(["attachmentVariant": Attachment.Variant.standard]) + )) AND + \(attachment[.state]) = \(SQL( + sql: ":state", + arguments: StatementArguments(["state": Attachment.State.pending]) + )) + + ORDER BY interactionId DESC + """ } } @@ -370,11 +442,11 @@ public extension Attachment { private static let thumbnailDimensionMedium: UInt = 450 /// This size is large enough to render full screen - private static var thumbnailDimensionsLarge: CGFloat = { + private static var thumbnailDimensionLarge: UInt = { let screenSizePoints: CGSize = UIScreen.main.bounds.size - let minZoomFactor: CGFloat = 2 // TODO: Should this be screen scale? + let minZoomFactor: CGFloat = UIScreen.main.scale - return (max(screenSizePoints.width, screenSizePoints.height) * minZoomFactor) + return UInt(floor(max(screenSizePoints.width, screenSizePoints.height) * minZoomFactor)) }() private static var sharedDataAttachmentsDirPath: String = { @@ -404,7 +476,7 @@ public extension Attachment { ) } - static func imageSize(contentType: String, originalFilePath: String) -> CGSize? { + internal static func imageSize(contentType: String, originalFilePath: String) -> CGSize? { let isVideo: Bool = MIMETypeUtil.isVideo(contentType) let isImage: Bool = MIMETypeUtil.isImage(contentType) let isAnimated: Bool = MIMETypeUtil.isAnimated(contentType) @@ -423,15 +495,77 @@ public extension Attachment { static func videoStillImage(filePath: String) -> UIImage? { return try? OWSMediaUtils.thumbnail( forVideoAtPath: filePath, - maxDimension: Attachment.thumbnailDimensionsLarge + maxDimension: CGFloat(Attachment.thumbnailDimensionLarge) ) } + + internal static func determineValidityAndDuration(contentType: String, originalFilePath: String?) -> (isValid: Bool, duration: TimeInterval?) { + guard let originalFilePath: String = originalFilePath else { return (false, nil) } + + // Process audio attachments + if MIMETypeUtil.isAudio(contentType) { + do { + let audioPlayer: AVAudioPlayer = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: originalFilePath)) + + return ((audioPlayer.duration > 0), audioPlayer.duration) + } + catch { + switch (error as NSError).code { + case Int(kAudioFileInvalidFileError), Int(kAudioFileStreamError_InvalidFile): + // Ignore "invalid audio file" errors + return (false, nil) // TODO: Confirm this behaviour (previously returned 0) + + default: return (false, nil) + } + } + } + + // Process image attachments + if MIMETypeUtil.isImage(contentType) { + return ( + NSData.ows_isValidImage(atPath: originalFilePath, mimeType: contentType), + nil + ) + } + + // Process video attachments + if MIMETypeUtil.isVideo(contentType) { + let videoPlayer: AVPlayer = AVPlayer(url: URL(fileURLWithPath: originalFilePath)) + let durationSeconds: TimeInterval? = videoPlayer.currentItem + .map { item -> TimeInterval in + // Accorting to the CMTime docs "value/timescale = seconds" + (TimeInterval(item.duration.value) / TimeInterval(item.duration.timescale)) + } + + return ( + OWSMediaUtils.isValidVideo(path: originalFilePath), + durationSeconds + ) + } + + // Any other attachment types are valid and have no duration + return (true, nil) + } } // MARK: - Convenience extension Attachment { - var originalFilePath: String? { + public enum ThumbnailSize { + case small + case medium + case large + + var dimension: UInt { + switch self { + case .small: return Attachment.thumbnailDimensionSmall + case .medium: return Attachment.thumbnailDimensionMedium + case .large: return Attachment.thumbnailDimensionLarge + } + } + } + + public var originalFilePath: String? { return Attachment.originalFilePath( id: self.id, mimeType: self.contentType, @@ -453,18 +587,19 @@ extension Attachment { } guard isImage || isAnimated else { return nil } - guard NSData.ows_isValidImage(atPath: originalFilePath, mimeType: contentType) else { - return nil - } + guard isValid else { return nil } return UIImage(contentsOfFile: originalFilePath) } - var isImage: Bool { MIMETypeUtil.isImage(contentType) } - var isVideo: Bool { MIMETypeUtil.isVideo(contentType) } - var isAnimated: Bool { MIMETypeUtil.isAnimated(contentType) } + public var isImage: Bool { MIMETypeUtil.isImage(contentType) } + public var isVideo: Bool { MIMETypeUtil.isVideo(contentType) } + public var isAnimated: Bool { MIMETypeUtil.isAnimated(contentType) } + public var isAudio: Bool { MIMETypeUtil.isAudio(contentType) } - func readDataFromFile() throws -> Data? { + public var isVisualMedia: Bool { isImage || isVideo || isAnimated } + + public func readDataFromFile() throws -> Data? { guard let filePath: String = Attachment.originalFilePath(id: self.id, mimeType: self.contentType, sourceFilename: self.sourceFilename) else { return nil } @@ -514,14 +649,18 @@ extension Attachment { ) } - func thumbnailImageSmallSync() -> UIImage? { + public func thumbnail(size: ThumbnailSize, success: @escaping (UIImage) -> (), failure: @escaping () -> ()) { + loadThumbnail(with: size.dimension, success: success, failure: failure) + } + + func thumbnailSync(size: ThumbnailSize) -> UIImage? { guard isVideo || isImage || isAnimated else { return nil } let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) var image: UIImage? - loadThumbnail( - with: Attachment.thumbnailDimensionSmall, + thumbnail( + size: size, success: { loadedImage in image = loadedImage semaphore.signal() diff --git a/SessionMessagingKit/Database/Models/ClosedGroup.swift b/SessionMessagingKit/Database/Models/ClosedGroup.swift index 39fe9d249..60e3e7712 100644 --- a/SessionMessagingKit/Database/Models/ClosedGroup.swift +++ b/SessionMessagingKit/Database/Models/ClosedGroup.swift @@ -12,7 +12,7 @@ public struct ClosedGroup: Codable, Identifiable, FetchableRecord, PersistableRe ClosedGroupKeyPair.self, using: ClosedGroupKeyPair.closedGroupForeignKey ) - private static let members = hasMany(GroupMember.self, using: GroupMember.closedGroupForeignKey) + public static let members = hasMany(GroupMember.self, using: GroupMember.closedGroupForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -65,6 +65,18 @@ public struct ClosedGroup: Codable, Identifiable, FetchableRecord, PersistableRe .filter(GroupMember.Columns.role == GroupMember.Role.admin) } + // MARK: - Initialization + + public init( + threadId: String, + name: String, + formationTimestamp: TimeInterval + ) { + self.threadId = threadId + self.name = name + self.formationTimestamp = formationTimestamp + } + // MARK: - Custom Database Interaction public func delete(_ db: Database) throws -> Bool { diff --git a/SessionMessagingKit/Database/Models/Contact.swift b/SessionMessagingKit/Database/Models/Contact.swift index 2edf6b56b..26574da08 100644 --- a/SessionMessagingKit/Database/Models/Contact.swift +++ b/SessionMessagingKit/Database/Models/Contact.swift @@ -7,6 +7,7 @@ import SessionUtilitiesKit public struct Contact: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "contact" } internal static let threadForeignKey = ForeignKey([Columns.id], to: [SessionThread.Columns.id]) + public static let profile = hasOne(Profile.self, using: Profile.contactForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -37,6 +38,12 @@ public struct Contact: Codable, Identifiable, FetchableRecord, PersistableRecord /// This flag is used to determine whether this contact has ever been blocked (will be included in the config message if so) public let hasBeenBlocked: Bool + // MARK: - Relationships + + public var profile: QueryInterfaceRequest { + request(for: Contact.profile) + } + // MARK: - Initialization public init( diff --git a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift index 0bfb78415..13c30a7cd 100644 --- a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift +++ b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift @@ -57,6 +57,34 @@ public extension DisappearingMessagesConfiguration { // MARK: - Convenience public extension DisappearingMessagesConfiguration { + struct MessageInfo: Codable { + public let senderName: String? + public let isEnabled: Bool + public let durationSeconds: TimeInterval + + var previewText: String { + guard let senderName: String = senderName else { + // Changed by localNumber on this device or via synced transcript + guard isEnabled, durationSeconds > 0 else { return "YOU_DISABLED_DISAPPEARING_MESSAGES_CONFIGURATION".localized() } + + return String( + format: "YOU_UPDATED_DISAPPEARING_MESSAGES_CONFIGURATION".localized(), + NSString.formatDurationSeconds(UInt32(floor(durationSeconds)), useShortFormat: false) + ) + } + + guard isEnabled, durationSeconds > 0 else { + return String(format: "OTHER_DISABLED_DISAPPEARING_MESSAGES_CONFIGURATION".localized(), senderName) + } + + return String( + format: "OTHER_UPDATED_DISAPPEARING_MESSAGES_CONFIGURATION".localized(), + NSString.formatDurationSeconds(UInt32(floor(durationSeconds)), useShortFormat: false), + senderName + ) + } + } + var durationIndex: Int { return DisappearingMessagesConfiguration.validDurationsSeconds .firstIndex(of: durationSeconds) @@ -67,26 +95,16 @@ public extension DisappearingMessagesConfiguration { NSString.formatDurationSeconds(UInt32(durationSeconds), useShortFormat: false) } - func infoUpdateMessage(with senderName: String?) -> String { - guard let senderName: String = senderName else { - // Changed by localNumber on this device or via synced transcript - guard isEnabled, durationSeconds > 0 else { return "YOU_DISABLED_DISAPPEARING_MESSAGES_CONFIGURATION".localized() } - - return String( - format: "YOU_UPDATED_DISAPPEARING_MESSAGES_CONFIGURATION".localized(), - NSString.formatDurationSeconds(UInt32(floor(durationSeconds)), useShortFormat: false) - ) - } - - guard isEnabled, durationSeconds > 0 else { - return String(format: "OTHER_DISABLED_DISAPPEARING_MESSAGES_CONFIGURATION".localized(), senderName) - } - - return String( - format: "OTHER_UPDATED_DISAPPEARING_MESSAGES_CONFIGURATION".localized(), - NSString.formatDurationSeconds(UInt32(floor(durationSeconds)), useShortFormat: false), - senderName + func messageInfoString(with senderName: String?) -> String? { + let messageInfo: MessageInfo = DisappearingMessagesConfiguration.MessageInfo( + senderName: senderName, + isEnabled: isEnabled, + durationSeconds: durationSeconds ) + + guard let messageInfoData: Data = try? JSONEncoder().encode(messageInfo) else { return nil } + + return String(data: messageInfoData, encoding: .utf8) } } diff --git a/SessionMessagingKit/Database/Models/GroupMember.swift b/SessionMessagingKit/Database/Models/GroupMember.swift index c1a8bef50..f1f8b26f1 100644 --- a/SessionMessagingKit/Database/Models/GroupMember.swift +++ b/SessionMessagingKit/Database/Models/GroupMember.swift @@ -9,9 +9,9 @@ public struct GroupMember: Codable, FetchableRecord, PersistableRecord, TableRec internal static let openGroupForeignKey = ForeignKey([Columns.groupId], to: [OpenGroup.Columns.threadId]) internal static let closedGroupForeignKey = ForeignKey([Columns.groupId], to: [ClosedGroup.Columns.threadId]) internal static let profileForeignKey = ForeignKey([Columns.profileId], to: [Profile.Columns.id]) - private static let openGroup = belongsTo(OpenGroup.self, using: openGroupForeignKey) - private static let closedGroup = belongsTo(ClosedGroup.self, using: closedGroupForeignKey) - private static let profile = hasOne(Profile.self, using: profileForeignKey) + public static let openGroup = belongsTo(OpenGroup.self, using: openGroupForeignKey) + public static let closedGroup = belongsTo(ClosedGroup.self, using: closedGroupForeignKey) + public static let profile = hasOne(Profile.self, using: profileForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index ad45f5482..daaaf98b0 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -7,25 +7,31 @@ import SessionUtilitiesKit public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "interaction" } internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id]) - internal static let profileForeignKey = ForeignKey([Columns.authorId], to: [Profile.Columns.id]) internal static let linkPreviewForeignKey = ForeignKey( [Columns.linkPreviewUrl], to: [LinkPreview.Columns.url] ) internal static let thread = belongsTo(SessionThread.self, using: threadForeignKey) - private static let profile = hasOne(Profile.self, using: profileForeignKey) + public static let profile = hasOne(Profile.self, using: Profile.interactionForeignKey) internal static let interactionAttachments = hasMany( InteractionAttachment.self, using: InteractionAttachment.interactionForeignKey ) - internal static let attachments = hasMany( + public static let attachments = hasMany( Attachment.self, through: interactionAttachments, using: InteractionAttachment.attachment ) public static let quote = hasOne(Quote.self, using: Quote.interactionForeignKey) - internal static let linkPreview = hasOne(LinkPreview.self, using: LinkPreview.interactionForeignKey) - private static let recipientStates = hasMany(RecipientState.self, using: RecipientState.interactionForeignKey) + + /// Whenever using this `linkPreview` association make sure to filter the result using `.filter(literal: Interaction.linkPreviewFilterLiteral)` to ensure the correct LinkPreview is returned + public static let linkPreview = hasOne(LinkPreview.self, using: LinkPreview.interactionForeignKey) + public static let linkPreviewFilterLiteral: SQL = { + let interaction: TypedTableAlias = TypedTableAlias() + let linkPreview: TypedTableAlias = TypedTableAlias() + return "(ROUND((\(interaction[.timestampMs]) / 1000 / 100000) - 0.5) * 100000) = \(linkPreview[.timestamp])" + }() + public static let recipientStates = hasMany(RecipientState.self, using: RecipientState.interactionForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -67,6 +73,20 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu case infoMediaSavedNotification case infoMessageRequestAccepted = 4000 + + // MARK: - Convenience + + public var isInfoMessage: Bool { + switch self { + case .infoClosedGroupCreated, .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft, + .infoDisappearingMessagesUpdate, .infoScreenshotNotification, .infoMediaSavedNotification, + .infoMessageRequestAccepted: + return true + + case .standardIncoming, .standardOutgoing, .standardIncomingDeleted: + return false + } + } } /// The `id` value is auto incremented by the database, if the `Interaction` hasn't been inserted into @@ -83,6 +103,10 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu public let threadId: String /// The id of the user who sent the interaction, also used to expose the `profile` variable) + /// + /// **Note:** For any "info" messages this value will always be the current user public key (this is because these + /// messages are created locally based on control messages and the initiator of a control message doesn't always + /// get transmitted) public let authorId: String /// The type of interaction @@ -156,20 +180,10 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu } public var linkPreview: QueryInterfaceRequest { - let linkPreviewAlias: TableAlias = TableAlias() - - return LinkPreview - .aliased(linkPreviewAlias) - .joining( - required: LinkPreview.interactions - .filter(literal: [ - "(ROUND((\(Interaction.Columns.timestampMs) / 1000 / 100000) - 0.5) * 100000)", - "=", - "\(linkPreviewAlias[LinkPreview.Columns.timestamp])" - ].joined(separator: " ")) - .limit(1) // Avoid joining to multiple interactions - ) - .limit(1) // Avoid joining to multiple interactions + /// **Note:** This equation **MUST** match the `linkPreviewFilterLiteral` logic + let roundedTimestamp: Double = (round(((Double(timestampMs) / 1000) / 100000) - 0.5) * 100000) + return request(for: Interaction.linkPreview) + .filter(LinkPreview.Columns.timestamp == roundedTimestamp) } public var recipientStates: QueryInterfaceRequest { @@ -320,11 +334,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu .aliased(interactionAlias) .joining( required: Interaction.linkPreview - .filter(literal: [ - "(ROUND((\(interactionAlias[Columns.timestampMs]) / 1000 / 100000) - 0.5) * 100000)", - "=", - "\(LinkPreview.Columns.timestamp)" - ].joined(separator: " ")) + .filter(literal: Interaction.linkPreviewFilterLiteral) ) .fetchCount(db) let tmp = try linkPreview.fetchAll(db) @@ -449,7 +459,7 @@ public extension Interaction { scheduleJobs( interactionIds: try Int64.fetchAll( db, - interactionQuery.select(Interaction.Columns.id) + interactionQuery.select(.id) ) ) } @@ -538,10 +548,10 @@ public extension Interaction { ) } + /// Use the `Interaction.previewText` method directly where possible rather than this method as it + /// makes it's own database queries func previewText(_ db: Database) -> String { switch variant { - case .standardIncomingDeleted: return "" - case .standardIncoming, .standardOutgoing: struct AttachmentDescriptionInfo: Decodable, FetchableRecord { let id: String @@ -549,36 +559,19 @@ public extension Interaction { let contentType: String let sourceFilename: String? } - - var bodyDescription: String? - - if let body: String = self.body, !body.isEmpty { - bodyDescription = body - } - - if bodyDescription == nil { - struct AttachmentBodyInfo: Decodable, FetchableRecord { - let id: String - let variant: Attachment.Variant - let contentType: String - let sourceFilename: String? - } - + + var targetBody: String? = self.body + + if self.body == nil || self.body?.isEmpty == true { let maybeTextInfo: AttachmentDescriptionInfo? = try? AttachmentDescriptionInfo .fetchOne( db, attachments - .select( - Attachment.Columns.id, - Attachment.Columns.state, - Attachment.Columns.variant, - Attachment.Columns.contentType, - Attachment.Columns.sourceFilename - ) + .select(.id, .state, .variant, .contentType, .sourceFilename) .filter(Attachment.Columns.contentType == OWSMimeTypeOversizeTextMessage) .filter(Attachment.Columns.state == Attachment.State.downloaded) ) - + if let textInfo: AttachmentDescriptionInfo = maybeTextInfo, let filePath: String = Attachment.originalFilePath( @@ -589,20 +582,15 @@ public extension Interaction { let data: Data = try? Data(contentsOf: URL(fileURLWithPath: filePath)), let dataString: String = String(data: data, encoding: .utf8) { - bodyDescription = dataString.filterForDisplay + targetBody = dataString.filterForDisplay } } - + let attachmentDescription: String? = try? AttachmentDescriptionInfo .fetchOne( db, attachments - .select( - Attachment.Columns.id, - Attachment.Columns.variant, - Attachment.Columns.contentType, - Attachment.Columns.sourceFilename - ) + .select(.id, .variant, .contentType, .sourceFilename) .filter(Attachment.Columns.contentType != OWSMimeTypeOversizeTextMessage) ) .map { info -> String in @@ -612,6 +600,70 @@ public extension Interaction { sourceFilename: info.sourceFilename ) } + let isOpenGroupInvitation: Bool = (try? linkPreview + .filter(LinkPreview.Columns.variant == LinkPreview.Variant.openGroupInvitation) + .isNotEmpty(db)) + .defaulting(to: false) + + return Interaction.previewText( + variant: self.variant, + body: targetBody, + attachments: [], + customAttachmentDescription: attachmentDescription, + isOpenGroupInvitation: isOpenGroupInvitation + ) + + case .infoMediaSavedNotification, .infoScreenshotNotification: + // Note: This should only occur in 'contact' threads so the `threadId` + // is the contact id + return Interaction.previewText( + variant: self.variant, + body: self.body, + authorDisplayName: Profile.displayName(db, id: threadId), + attachments: [] + ) + + default: return Interaction.previewText( + variant: self.variant, + body: self.body, + attachments: [] + ) + } + } + + /// This menthod generates the preview text for a given transaction + static func previewText( + variant: Variant, + body: String?, + authorDisplayName: String = "", + attachments: [Attachment], + customAttachmentDescription: String? = nil, + isOpenGroupInvitation: Bool = false + ) -> String { + switch variant { + case .standardIncomingDeleted: return "" + + case .standardIncoming, .standardOutgoing: + var bodyDescription: String? + let attachmentDescription: String? = (customAttachmentDescription ?? attachments + .first(where: { $0.contentType != OWSMimeTypeOversizeTextMessage })? + .description + ) + + if let body: String = body, !body.isEmpty { + bodyDescription = body + } + else if + let textAttachment: Attachment = attachments.first(where: { attachment in + attachment.state == .downloaded && + attachment.contentType == OWSMimeTypeOversizeTextMessage + }), + let filePath: String = textAttachment.originalFilePath, + let data: Data = try? Data(contentsOf: URL(fileURLWithPath: filePath)), + let dataString: String = String(data: data, encoding: .utf8) + { + bodyDescription = dataString.filterForDisplay + } if let attachmentDescription: String = attachmentDescription, @@ -634,7 +686,7 @@ public extension Interaction { return attachmentDescription } - if let linkPreview: LinkPreview = try? linkPreview.fetchOne(db), linkPreview.variant == .openGroupInvitation { + if isOpenGroupInvitation { return "😎 Open group invitation" } @@ -642,19 +694,11 @@ public extension Interaction { return "" case .infoMediaSavedNotification: - // Note: This should only occur in 'contact' threads so the `threadId` - // is the contact id - let displayName: String = Profile.displayName(id: threadId) - // TODO: Use referencedAttachmentTimestamp to tell the user * which * media was saved - return String(format: "media_saved".localized(), displayName) + return String(format: "media_saved".localized(), authorDisplayName) case .infoScreenshotNotification: - // Note: This should only occur in 'contact' threads so the `threadId` - // is the contact id - let displayName: String = Profile.displayName(id: threadId) - - return String(format: "screenshot_taken".localized(), displayName) + return String(format: "screenshot_taken".localized(), authorDisplayName) case .infoClosedGroupCreated: return "GROUP_CREATED".localized() case .infoClosedGroupCurrentUserLeft: return "GROUP_YOU_LEFT".localized() @@ -662,8 +706,15 @@ public extension Interaction { case .infoMessageRequestAccepted: return (body ?? "MESSAGE_REQUESTS_ACCEPTED".localized()) case .infoDisappearingMessagesUpdate: - // TODO: We should do better here - return (body ?? "") + guard + let infoMessageData: Data = (body ?? "").data(using: .utf8), + let messageInfo: DisappearingMessagesConfiguration.MessageInfo = try? JSONDecoder().decode( + DisappearingMessagesConfiguration.MessageInfo.self, + from: infoMessageData + ) + else { return (body ?? "") } + + return messageInfo.previewText } } @@ -671,9 +722,17 @@ public extension Interaction { let states: [RecipientState.State] = try RecipientState.State .fetchAll( db, - recipientStates - .select(RecipientState.Columns.state) + recipientStates.select(.state) ) + + return Interaction.state(for: states) + } + + static func state(for states: [RecipientState.State]) -> RecipientState.State { + // If there are no states then assume this is a new interaction which hasn't been + // saved yet so has no states + guard !states.isEmpty else { return .sending } + var hasFailed: Bool = false for state in states { diff --git a/SessionMessagingKit/Database/Models/InteractionAttachment.swift b/SessionMessagingKit/Database/Models/InteractionAttachment.swift index b61007329..ae75a0631 100644 --- a/SessionMessagingKit/Database/Models/InteractionAttachment.swift +++ b/SessionMessagingKit/Database/Models/InteractionAttachment.swift @@ -7,7 +7,7 @@ import SessionUtilitiesKit public struct InteractionAttachment: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "interactionAttachment" } internal static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id]) - private static let attachmentForeignKey = ForeignKey([Columns.attachmentId], to: [Attachment.Columns.id]) + internal static let attachmentForeignKey = ForeignKey([Columns.attachmentId], to: [Attachment.Columns.id]) internal static let interaction = belongsTo(Interaction.self, using: interactionForeignKey) internal static let attachment = belongsTo(Attachment.self, using: attachmentForeignKey) @@ -36,11 +36,11 @@ public struct InteractionAttachment: Codable, FetchableRecord, PersistableRecord // If we have an Attachment then check if this is the only type that is referencing it // and delete the Attachment if so let quoteUses: Int? = try? Quote - .select(Quote.Columns.attachmentId) + .select(.attachmentId) .filter(Quote.Columns.attachmentId == attachmentId) .fetchCount(db) let linkPreviewUses: Int? = try? LinkPreview - .select(LinkPreview.Columns.attachmentId) + .select(.attachmentId) .filter(LinkPreview.Columns.attachmentId == attachmentId) .fetchCount(db) diff --git a/SessionMessagingKit/Database/Models/LinkPreview.swift b/SessionMessagingKit/Database/Models/LinkPreview.swift index 30c33d26e..54b4726e9 100644 --- a/SessionMessagingKit/Database/Models/LinkPreview.swift +++ b/SessionMessagingKit/Database/Models/LinkPreview.swift @@ -4,15 +4,14 @@ import Foundation import GRDB import SessionUtilitiesKit -public struct LinkPreview: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct LinkPreview: Codable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "linkPreview" } internal static let interactionForeignKey = ForeignKey( [Columns.url], to: [Interaction.Columns.linkPreviewUrl] ) - private static let attachmentForeignKey = ForeignKey([Columns.attachmentId], to: [Attachment.Columns.id]) internal static let interactions = hasMany(Interaction.self, using: Interaction.linkPreviewForeignKey) - internal static let attachment = hasOne(Attachment.self, using: attachmentForeignKey) + public static let attachment = hasOne(Attachment.self, using: Attachment.linkPreviewForeignKey) /// We want to cache url previews to the nearest 100,000 seconds (~28 hours - simpler than 86,400) to ensure the user isn't shown a preview that is too stale internal static let timstampResolution: Double = 100000 diff --git a/SessionMessagingKit/Database/Models/OpenGroup.swift b/SessionMessagingKit/Database/Models/OpenGroup.swift index acaa31c76..77d75f091 100644 --- a/SessionMessagingKit/Database/Models/OpenGroup.swift +++ b/SessionMessagingKit/Database/Models/OpenGroup.swift @@ -82,14 +82,14 @@ public struct OpenGroup: Codable, Identifiable, FetchableRecord, PersistableReco // MARK: - Initialization - init( + public init( server: String, room: String, publicKey: String, name: String, - groupDescription: String?, - imageId: Int?, - imageData: Data?, + groupDescription: String? = nil, + imageId: Int? = nil, + imageData: Data? = nil, userCount: Int, infoUpdates: Int ) { diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index cedb3b173..b404d166a 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -5,8 +5,11 @@ import GRDB import SignalCoreKit import SessionUtilitiesKit -public struct Profile: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible, CustomStringConvertible { +public struct Profile: Codable, Identifiable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible, CustomStringConvertible { public static var databaseTableName: String { "profile" } + internal static let interactionForeignKey = ForeignKey([Columns.id], to: [Interaction.Columns.authorId]) + internal static let contactForeignKey = ForeignKey([Columns.id], to: [Contact.Columns.id]) + public static let groupMembers = hasMany(GroupMember.self, using: GroupMember.profileForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -38,6 +41,24 @@ public struct Profile: Codable, Identifiable, FetchableRecord, PersistableRecord /// The key with which the profile is encrypted. public let profileEncryptionKey: OWSAES256Key? + // MARK: - Initialization + + public init( + id: String, + name: String, + nickname: String? = nil, + profilePictureUrl: String? = nil, + profilePictureFileName: String? = nil, + profileEncryptionKey: OWSAES256Key? = nil + ) { + self.id = id + self.name = name + self.nickname = nickname + self.profilePictureUrl = profilePictureUrl + self.profilePictureFileName = profilePictureFileName + self.profileEncryptionKey = profileEncryptionKey + } + // MARK: - Description public var description: String { @@ -177,7 +198,7 @@ public extension Profile { } } -// MARK: - Convenience +// MARK: - Mutation public extension Profile { func with( @@ -196,29 +217,6 @@ public extension Profile { profileEncryptionKey: (profileEncryptionKey ?? self.profileEncryptionKey) ) } - - // MARK: - Context - - @objc enum Context: Int { - case regular - case openGroup - } - - /// The name to display in the UI. For local use only. - func displayName(for context: Context = .regular) -> String { - if let nickname: String = nickname { return nickname } - - switch context { - case .regular: return name - - case .openGroup: - // In open groups, where it's more likely that multiple users have the same name, we display a bit of the Session ID after - // a user's display name for added context. - let endIndex = id.endIndex - let cutoffIndex = id.index(endIndex, offsetBy: -8) - return "\(name) (...\(id[cutoffIndex.. String { + switch thread.variant { + case .openGroup: return truncated(id: id, truncating: .start) + default: return truncated(id: id, truncating: .middle) + } + } + + /// A standardised mechanism for truncating a user id + static func truncated(id: String, truncating: Truncation = .start) -> String { + guard id.count > 8 else { return id } + + switch truncating { + case .start: return "...\(id.suffix(8))" + case .middle: return "\(id.prefix(4))...\(id.suffix(4))" + case .end: return "\(id.prefix(8))..." + } + } + + /// The name to display in the UI for a given thread variant + func displayName(for threadVariant: SessionThread.Variant) -> String { + return displayName( + for: (threadVariant == .openGroup ? .openGroup : .regular) + ) + } + + /// The name to display in the UI + func displayName(for context: Context = .regular) -> String { + if let nickname: String = nickname { return nickname } + + switch context { + case .regular: return name + + case .openGroup: + // In open groups, where it's more likely that multiple users have the same name, + // we display a bit of the Session ID after a user's display name for added context + return "\(name) (\(Profile.truncated(id: id, truncating: .start)))" + } + } +} + // MARK: - Objective-C Support @objc(SMKProfile) public class SMKProfile: NSObject { diff --git a/SessionMessagingKit/Database/Models/Quote.swift b/SessionMessagingKit/Database/Models/Quote.swift index d1d49b86a..3f1c27f86 100644 --- a/SessionMessagingKit/Database/Models/Quote.swift +++ b/SessionMessagingKit/Database/Models/Quote.swift @@ -4,19 +4,18 @@ import Foundation import GRDB import SessionUtilitiesKit -public struct Quote: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct Quote: Codable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "quote" } - internal static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id]) + public static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id]) internal static let originalInteractionForeignKey = ForeignKey( [Columns.timestampMs, Columns.authorId], to: [Interaction.Columns.timestampMs, Interaction.Columns.authorId] ) internal static let profileForeignKey = ForeignKey([Columns.authorId], to: [Profile.Columns.id]) - private static let attachmentForeignKey = ForeignKey([Columns.attachmentId], to: [Attachment.Columns.id]) internal static let interaction = belongsTo(Interaction.self, using: interactionForeignKey) private static let profile = hasOne(Profile.self, using: profileForeignKey) private static let quotedInteraction = hasOne(Interaction.self, using: originalInteractionForeignKey) - internal static let attachment = hasOne(Attachment.self, using: attachmentForeignKey) + public static let attachment = hasOne(Attachment.self, using: Attachment.quoteForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { diff --git a/SessionMessagingKit/Database/Models/RecipientState.swift b/SessionMessagingKit/Database/Models/RecipientState.swift index b35d5e743..e96da4c71 100644 --- a/SessionMessagingKit/Database/Models/RecipientState.swift +++ b/SessionMessagingKit/Database/Models/RecipientState.swift @@ -2,6 +2,7 @@ import Foundation import GRDB +import SignalCoreKit import SessionUtilitiesKit public struct RecipientState: Codable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { @@ -25,12 +26,38 @@ public struct RecipientState: Codable, Equatable, FetchableRecord, PersistableRe case sending case skipped case sent + + func message(hasAttachments: Bool, hasAtLeastOneReadReceipt: Bool) -> String { + switch self { + case .failed: return "MESSAGE_STATUS_FAILED".localized() + case .sending: + guard hasAttachments else { + return "MESSAGE_STATUS_SENDING".localized() + } + + return "MESSAGE_STATUS_UPLOADING".localized() + + case .sent: + guard hasAtLeastOneReadReceipt else { + return "MESSAGE_STATUS_SENT".localized() + } + + return "MESSAGE_STATUS_READ".localized() + + default: + owsFailDebug("Message has unexpected status: \(self).") + return "MESSAGE_STATUS_SENT".localized() + } + } } /// The id for the interaction this state belongs to public let interactionId: Int64 - /// The id for the recipient this state belongs to + /// The id for the recipient that has this state + /// + /// **Note:** For contact and closedGroup threads this can be used as a lookup for a contact/profile but in an + /// openGroup thread this will be the threadId so won’t resolve to a contact/profile public let recipientId: String /// The current state for the recipient diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index 82a3dd92b..2f9e95126 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -96,7 +96,6 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, request(for: SessionThread.interactions) } - // MARK: - Initialization public init( @@ -133,6 +132,26 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, } } +// MARK: - Mutation + +public extension SessionThread { + func with( + shouldBeVisible: Bool? = nil + ) -> SessionThread { + return SessionThread( + id: id, + variant: variant, + creationDateTimestamp: creationDateTimestamp, + shouldBeVisible: (shouldBeVisible ?? self.shouldBeVisible), + isPinned: isPinned, + messageDraft: messageDraft, + notificationMode: notificationMode, + notificationSound: notificationSound, + mutedUntilTimestamp: mutedUntilTimestamp + ) + } +} + // MARK: - GRDB Interactions public extension SessionThread { @@ -174,21 +193,36 @@ public extension SessionThread { func name(_ db: Database) -> String { switch variant { - case .contact: return Profile.displayName(db, id: id) + case .contact: + guard !isNoteToSelf(db) else { return name(isNoteToSelf: true) } + + return name( + displayName: Profile.displayName( + db, + id: id, + customFallback: Profile.truncated(id: id, truncating: .middle) + ) + ) case .closedGroup: - guard let name: String = try? String.fetchOne(db, closedGroup.select(ClosedGroup.Columns.name)), !name.isEmpty else { - return "Group" - } - - return name + return name(displayName: try? String.fetchOne(db, closedGroup.select(.name))) case .openGroup: - guard let name: String = try? String.fetchOne(db, openGroup.select(OpenGroup.Columns.name)), !name.isEmpty else { - return "Group" - } + return name(displayName: try? String.fetchOne(db, openGroup.select(.name))) + } + } + + func name(isNoteToSelf: Bool = false, displayName: String? = nil) -> String { + switch variant { + case .contact: + guard !isNoteToSelf else { return "Note to Self" } - return name + return displayName + .defaulting(to: "Anonymous", useDefaultIfEmpty: true) + + case .closedGroup, .openGroup: + return displayName + .defaulting(to: "Group", useDefaultIfEmpty: true) } } } diff --git a/SessionMessagingKit/Database/SSKPreferences.swift b/SessionMessagingKit/Database/SSKPreferences.swift index 297266f7b..8afbbf245 100644 --- a/SessionMessagingKit/Database/SSKPreferences.swift +++ b/SessionMessagingKit/Database/SSKPreferences.swift @@ -1,8 +1,7 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import SessionUtilitiesKit @objc public class SSKPreferences: NSObject { @@ -65,6 +64,11 @@ public extension SSKPreferences { GRDBStorage.shared.write { db in db[.preferencesAppSwitcherPreviewEnabled] = enabled } } + @objc(areReadReceiptsEnabled) + static func objc_areReadReceiptsEnabled() -> Bool { + return GRDBStorage.shared[.areReadReceiptsEnabled] + } + @objc(setAreReadReceiptsEnabled:) static func objc_setAreReadReceiptsEnabled(_ enabled: Bool) { GRDBStorage.shared.write { db in db[.areReadReceiptsEnabled] = enabled } diff --git a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift index 1a238112f..a706bf68d 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift @@ -28,7 +28,8 @@ public enum AttachmentDownloadJob: JobExecutor { return } guard attachment.state != .downloaded else { - // FIXME: It's not clear * how * this happens, but apparently we can get to this point from time to time with an already downloaded attachment. + // If there is a bug elsewhere in the code it's possible for an AttachmentDownloadJob to be created + // for an attachment that is already downloaded - if it is just succeed immediately success(job, false) return } @@ -133,6 +134,10 @@ public enum AttachmentDownloadJob: JobExecutor { extension AttachmentDownloadJob { public struct Details: Codable { public let attachmentId: String + + public init(attachmentId: String) { + self.attachmentId = attachmentId + } } public enum AttachmentDownloadError: LocalizedError { diff --git a/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift b/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift index a54b52ab5..41e9f4336 100644 --- a/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift +++ b/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift @@ -22,7 +22,7 @@ public enum DisappearingMessagesJob: JobExecutor { let updatedJob: Job? = GRDBStorage.shared.write { db in _ = try Interaction .filter(Interaction.Columns.expiresStartedAtMs != nil) - .filter(sql: "(\(Interaction.Columns.expiresStartedAtMs) + (\(Interaction.Columns.expiresInSeconds) * 1000) <= \(timestampNowMs)") + .filter((Interaction.Columns.expiresStartedAtMs + (Interaction.Columns.expiresInSeconds * 1000)) <= timestampNowMs) .deleteAll(db) // Update the next run timestamp for the DisappearingMessagesJob (if the call @@ -55,8 +55,8 @@ public extension DisappearingMessagesJob { .fetchOne( db, Interaction - .select(sql: "(\(Interaction.Columns.expiresStartedAtMs) + (\(Interaction.Columns.expiresInSeconds) * 1000)") - .order(sql: "(\(Interaction.Columns.expiresStartedAtMs) + (\(Interaction.Columns.expiresInSeconds) * 1000) asc") + .select(Interaction.Columns.expiresStartedAtMs + (Interaction.Columns.expiresInSeconds * 1000)) + .order((Interaction.Columns.expiresStartedAtMs + (Interaction.Columns.expiresInSeconds * 1000)).asc) ) guard let nextExpirationTimestampMs: Double = nextExpirationTimestampMs else { return nil } diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift index 11744912c..5f6b2ac79 100644 --- a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB import PromiseKit import SignalCoreKit import SessionUtilitiesKit @@ -36,46 +37,21 @@ public enum MessageSendJob: JobExecutor { return } - var shouldDeferJob: Bool = false + var shouldFailJob: Bool = false GRDBStorage.shared.read { db in // Fetch all associated attachments - let attachments: [Attachment] = try interaction.attachments.fetchAll(db) + let attachmentCount: Int = try interaction.attachments + .filter(Attachment.Columns.state == Attachment.State.pending) + .fetchCount(db) - // Create jobs for any pending attachment jobs and insert them into the - // queue before the current job (this will mean the current job will re-run - // after these inserted jobs complete) - let pendingAttachments: [Attachment] = attachments.filter { $0.state == .pending } - pendingAttachments - .forEach { attachment in - JobRunner.insert( - db, - job: Job( - variant: .attachmentUpload, - behaviour: .runOnce, - threadId: job.threadId, - details: AttachmentUploadJob.Details( - threadId: threadId, - attachmentId: attachment.id, - messageSendJobId: jobId - ) - ), - before: job - ) - } - - // If there were pending or uploading attachments then stop here (we want to - // upload them first and then re-run this send job - the 'JobRunner.insert' - // method will take care of this) - shouldDeferJob = ( - !pendingAttachments.isEmpty || - attachments.contains(where: { $0.state == .uploading }) - ) + shouldFailJob = (attachmentCount > 0) } - // Only continue if we don't want to defer the job - guard !shouldDeferJob else { - deferred(job) + // Cannot send messages with pending attachments (the app doesn't currently + // support deferred attachment uploads) + guard !shouldFailJob else { + failure(job, Attachment.UploadError.notUploaded, true) return } } diff --git a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift index b2f2d443a..d2f7bd871 100644 --- a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift +++ b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift @@ -49,13 +49,35 @@ public enum SendReadReceiptsJob: JobExecutor { // When we complete the 'SendReadReceiptsJob' we want to immediately schedule // another one for the same thread but with a 'nextRunTimestamp' set to the // 'minRunFrequency' value to throttle the read receipt requests - GRDBStorage.shared.write { db in - _ = try createOrUpdateIfNeeded(db, threadId: threadId, interactionIds: [])? - .with(nextRunTimestamp: (Date().timeIntervalSince1970 + minRunFrequency)) + var shouldFinishCurrentJob: Bool = false + let nextRunTimestamp: TimeInterval = (Date().timeIntervalSince1970 + minRunFrequency) + + let updatedJob: Job? = GRDBStorage.shared.write { db in + // If another 'sendReadReceipts' job was scheduled then update that one + // to run at 'nextRunTimestamp' and make the current job stop + if + let existingJob: Job = try? Job + .filter(Job.Columns.id != job.id) + .filter(Job.Columns.variant == Job.Variant.sendReadReceipts) + .filter(Job.Columns.threadId == threadId) + .fetchOne(db), + !JobRunner.isCurrentlyRunning(existingJob) + { + _ = try existingJob + .with(nextRunTimestamp: nextRunTimestamp) + .saved(db) + shouldFinishCurrentJob = true + return job + } + + return try job + .with(details: Details(destination: details.destination, timestampMsValues: [])) + .defaulting(to: job) + .with(nextRunTimestamp: nextRunTimestamp) .saved(db) } - success(job, false) + success(updatedJob ?? job, shouldFinishCurrentJob) } .catch { error in failure(job, error, false) } .retainUntilComplete() @@ -82,7 +104,7 @@ public extension SendReadReceiptsJob { let maybeTimestampMsValues: [Int64]? = try? Int64.fetchAll( db, Interaction - .select(Interaction.Columns.timestampMs) + .select(.timestampMs) .filter(interactionIds.contains(Interaction.Columns.id)) // Only `standardIncoming` incoming interactions should have read receipts sent .filter(Interaction.Columns.variant == Interaction.Variant.standardIncoming) diff --git a/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift b/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift index 60e8d39d3..2fa13fe01 100644 --- a/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift +++ b/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift @@ -39,7 +39,7 @@ public enum UpdateProfilePictureJob: JobExecutor { profileName: profile.name, avatarImage: profilePicture, requiredSync: true, - success: { success(job, false) }, + success: { _ in success(job, false) }, failure: { error in failure(job, error, false) } ) } diff --git a/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift b/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift index bf8462f49..3bcbc3232 100644 --- a/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift @@ -6,7 +6,7 @@ import Sodium import Curve25519Kit import SessionUtilitiesKit -public final class ClosedGroupControlMessage : ControlMessage { +public final class ClosedGroupControlMessage: ControlMessage { private enum CodingKeys: String, CodingKey { case kind } @@ -22,7 +22,8 @@ public final class ClosedGroupControlMessage : ControlMessage { public override var isSelfSendValid: Bool { true } - // MARK: Kind + // MARK: - Kind + public enum Kind: CustomStringConvertible, Codable { private enum CodingKeys: String, CodingKey { case description @@ -49,13 +50,13 @@ public final class ClosedGroupControlMessage : ControlMessage { public var description: String { switch self { - case .new: return "new" - case .encryptionKeyPair: return "encryptionKeyPair" - case .nameChange: return "nameChange" - case .membersAdded: return "membersAdded" - case .membersRemoved: return "membersRemoved" - case .memberLeft: return "memberLeft" - case .encryptionKeyPairRequest: return "encryptionKeyPairRequest" + case .new: return "new" + case .encryptionKeyPair: return "encryptionKeyPair" + case .nameChange: return "nameChange" + case .membersAdded: return "membersAdded" + case .membersRemoved: return "membersRemoved" + case .memberLeft: return "memberLeft" + case .encryptionKeyPairRequest: return "encryptionKeyPairRequest" } } @@ -150,9 +151,9 @@ public final class ClosedGroupControlMessage : ControlMessage { } } - // MARK: Key Pair Wrapper - @objc(SNKeyPairWrapper) - public final class KeyPairWrapper: NSObject, Codable, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility + // MARK: - Key Pair Wrapper + + public struct KeyPairWrapper: Codable { public var publicKey: String? public var encryptedKeyPair: Data? @@ -162,16 +163,8 @@ public final class ClosedGroupControlMessage : ControlMessage { self.publicKey = publicKey self.encryptedKeyPair = encryptedKeyPair } - - public required init?(coder: NSCoder) { - if let publicKey = coder.decodeObject(forKey: "publicKey") as! String? { self.publicKey = publicKey } - if let encryptedKeyPair = coder.decodeObject(forKey: "encryptedKeyPair") as! Data? { self.encryptedKeyPair = encryptedKeyPair } - } - - public func encode(with coder: NSCoder) { - coder.encode(publicKey, forKey: "publicKey") - coder.encode(encryptedKeyPair, forKey: "encryptedKeyPair") - } + + // MARK: - Proto Conversion public static func fromProto(_ proto: SNProtoDataMessageClosedGroupControlMessageKeyPairWrapper) -> KeyPairWrapper? { return KeyPairWrapper(publicKey: proto.publicKey.toHexString(), encryptedKeyPair: proto.encryptedKeyPair) @@ -189,97 +182,36 @@ public final class ClosedGroupControlMessage : ControlMessage { } } - // MARK: Initialization - public override init() { super.init() } + // MARK: - Initialization internal init(kind: Kind) { super.init() + self.kind = kind } - // MARK: Validation + // MARK: - Validation + public override var isValid: Bool { guard super.isValid, let kind = kind else { return false } + switch kind { - case .new(let publicKey, let name, let encryptionKeyPair, let members, let admins, let expirationTimer): - return !publicKey.isEmpty && !name.isEmpty && !encryptionKeyPair.publicKey.isEmpty - && !encryptionKeyPair.secretKey.isEmpty && !members.isEmpty && !admins.isEmpty - case .encryptionKeyPair: return true - case .nameChange(let name): return !name.isEmpty - case .membersAdded(let members): return !members.isEmpty - case .membersRemoved(let members): return !members.isEmpty - case .memberLeft: return true - case .encryptionKeyPairRequest: return true - } - } - - // MARK: Coding - public required init?(coder: NSCoder) { - super.init(coder: coder) - guard let rawKind = coder.decodeObject(forKey: "kind") as? String else { return nil } - switch rawKind { - case "new": - guard let publicKey = coder.decodeObject(forKey: "publicKey") as? Data, - let name = coder.decodeObject(forKey: "name") as? String, - let encryptionKeyPair = coder.decodeObject(forKey: "encryptionKeyPair") as? SessionUtilitiesKit.Legacy.KeyPair, - let members = coder.decodeObject(forKey: "members") as? [Data], - let admins = coder.decodeObject(forKey: "admins") as? [Data] else { return nil } - let expirationTimer = coder.decodeObject(forKey: "expirationTimer") as? UInt32 ?? 0 - let keyPair: Box.KeyPair = Box.KeyPair( - publicKey: encryptionKeyPair.publicKey.bytes, - secretKey: encryptionKeyPair.privateKey.bytes - ) - self.kind = .new(publicKey: publicKey, name: name, encryptionKeyPair: keyPair, members: members, admins: admins, expirationTimer: expirationTimer) - case "encryptionKeyPair": - let publicKey = coder.decodeObject(forKey: "publicKey") as? Data - guard let wrappers = coder.decodeObject(forKey: "wrappers") as? [KeyPairWrapper] else { return nil } - self.kind = .encryptionKeyPair(publicKey: publicKey, wrappers: wrappers) - case "nameChange": - guard let name = coder.decodeObject(forKey: "name") as? String else { return nil } - self.kind = .nameChange(name: name) - case "membersAdded": - guard let members = coder.decodeObject(forKey: "members") as? [Data] else { return nil } - self.kind = .membersAdded(members: members) - case "membersRemoved": - guard let members = coder.decodeObject(forKey: "members") as? [Data] else { return nil } - self.kind = .membersRemoved(members: members) - case "memberLeft": - self.kind = .memberLeft - case "encryptionKeyPairRequest": - self.kind = .encryptionKeyPairRequest - default: return nil - } - } - - public override func encode(with coder: NSCoder) { - super.encode(with: coder) - guard let kind = kind else { return } - switch kind { - case .new(let publicKey, let name, let encryptionKeyPair, let members, let admins, let expirationTimer): - coder.encode("new", forKey: "kind") - coder.encode(publicKey, forKey: "publicKey") - coder.encode(name, forKey: "name") - coder.encode(encryptionKeyPair, forKey: "encryptionKeyPair") - coder.encode(members, forKey: "members") - coder.encode(admins, forKey: "admins") - coder.encode(expirationTimer, forKey: "expirationTimer") - case .encryptionKeyPair(let publicKey, let wrappers): - coder.encode("encryptionKeyPair", forKey: "kind") - coder.encode(publicKey, forKey: "publicKey") - coder.encode(wrappers, forKey: "wrappers") - case .nameChange(let name): - coder.encode("nameChange", forKey: "kind") - coder.encode(name, forKey: "name") - case .membersAdded(let members): - coder.encode("membersAdded", forKey: "kind") - coder.encode(members, forKey: "members") - case .membersRemoved(let members): - coder.encode("membersRemoved", forKey: "kind") - coder.encode(members, forKey: "members") - case .memberLeft: - coder.encode("memberLeft", forKey: "kind") - case .encryptionKeyPairRequest: - coder.encode("encryptionKeyPairRequest", forKey: "kind") + case .new(let publicKey, let name, let encryptionKeyPair, let members, let admins, _): + return ( + !publicKey.isEmpty && + !name.isEmpty && + !encryptionKeyPair.publicKey.isEmpty && + !encryptionKeyPair.secretKey.isEmpty && + !members.isEmpty && + !admins.isEmpty + ) + + case .encryptionKeyPair: return true + case .nameChange(let name): return !name.isEmpty + case .membersAdded(let members): return !members.isEmpty + case .membersRemoved(let members): return !members.isEmpty + case .memberLeft: return true + case .encryptionKeyPairRequest: return true } } @@ -301,35 +233,64 @@ public final class ClosedGroupControlMessage : ControlMessage { try container.encode(kind, forKey: .kind) } - // MARK: Proto Conversion + // MARK: - Proto Conversion + public override class func fromProto(_ proto: SNProtoContent, sender: String) -> ClosedGroupControlMessage? { - guard let closedGroupControlMessageProto = proto.dataMessage?.closedGroupControlMessage else { return nil } - let kind: Kind - switch closedGroupControlMessageProto.type { - case .new: - guard let publicKey = closedGroupControlMessageProto.publicKey, let name = closedGroupControlMessageProto.name, - let encryptionKeyPairAsProto = closedGroupControlMessageProto.encryptionKeyPair else { return nil } - let expirationTimer = closedGroupControlMessageProto.expirationTimer - let encryptionKeyPair = Box.KeyPair(publicKey: encryptionKeyPairAsProto.publicKey.removing05PrefixIfNeeded().bytes, secretKey: encryptionKeyPairAsProto.privateKey.bytes) - kind = .new(publicKey: publicKey, name: name, encryptionKeyPair: encryptionKeyPair, - members: closedGroupControlMessageProto.members, admins: closedGroupControlMessageProto.admins, expirationTimer: expirationTimer) - case .encryptionKeyPair: - let publicKey = closedGroupControlMessageProto.publicKey - let wrappers = closedGroupControlMessageProto.wrappers.compactMap { KeyPairWrapper.fromProto($0) } - kind = .encryptionKeyPair(publicKey: publicKey, wrappers: wrappers) - case .nameChange: - guard let name = closedGroupControlMessageProto.name else { return nil } - kind = .nameChange(name: name) - case .membersAdded: - kind = .membersAdded(members: closedGroupControlMessageProto.members) - case .membersRemoved: - kind = .membersRemoved(members: closedGroupControlMessageProto.members) - case .memberLeft: - kind = .memberLeft - case .encryptionKeyPairRequest: - kind = .encryptionKeyPairRequest + guard let closedGroupControlMessageProto = proto.dataMessage?.closedGroupControlMessage else { + return nil + } + + switch closedGroupControlMessageProto.type { + case .new: + guard + let publicKey = closedGroupControlMessageProto.publicKey, + let name = closedGroupControlMessageProto.name, + let encryptionKeyPairAsProto = closedGroupControlMessageProto.encryptionKeyPair + else { return nil } + + return ClosedGroupControlMessage( + kind: .new( + publicKey: publicKey, + name: name, + encryptionKeyPair: Box.KeyPair( + publicKey: encryptionKeyPairAsProto.publicKey.removing05PrefixIfNeeded().bytes, + secretKey: encryptionKeyPairAsProto.privateKey.bytes + ), + members: closedGroupControlMessageProto.members, + admins: closedGroupControlMessageProto.admins, + expirationTimer: closedGroupControlMessageProto.expirationTimer + ) + ) + + case .encryptionKeyPair: + return ClosedGroupControlMessage( + kind: .encryptionKeyPair( + publicKey: closedGroupControlMessageProto.publicKey, + wrappers: closedGroupControlMessageProto.wrappers + .compactMap { KeyPairWrapper.fromProto($0) } + ) + ) + + case .nameChange: + guard let name = closedGroupControlMessageProto.name else { return nil } + + return ClosedGroupControlMessage(kind: .nameChange(name: name)) + + case .membersAdded: + return ClosedGroupControlMessage( + kind: .membersAdded(members: closedGroupControlMessageProto.members) + ) + + case .membersRemoved: + return ClosedGroupControlMessage( + kind: .membersRemoved(members: closedGroupControlMessageProto.members) + ) + + case .memberLeft: return ClosedGroupControlMessage(kind: .memberLeft) + + case .encryptionKeyPairRequest: + return ClosedGroupControlMessage(kind: .encryptionKeyPairRequest) } - return ClosedGroupControlMessage(kind: kind) } public override func toProto(_ db: Database) -> SNProtoContent? { @@ -387,8 +348,9 @@ public final class ClosedGroupControlMessage : ControlMessage { } } - // MARK: Description - public override var description: String { + // MARK: - Description + + public var description: String { """ ClosedGroupControlMessage( kind: \(kind?.description ?? "null") diff --git a/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift b/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift index 880398c0b..fac43e97a 100644 --- a/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift +++ b/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift @@ -12,7 +12,7 @@ extension ConfigurationMessage { let displayName: String = profile.name let profilePictureUrl: String? = profile.profilePictureUrl let profileKey: Data? = profile.profileEncryptionKey?.keyData - var closedGroups: Set = [] + var closedGroups: Set = [] var openGroups: Set = [] Storage.read { transaction in @@ -66,7 +66,7 @@ extension ConfigurationMessage { return CMContact( publicKey: contact.id, displayName: (profile?.name ?? contact.id), - profilePictureURL: profile?.profilePictureUrl, + profilePictureUrl: profile?.profilePictureUrl, profileKey: profile?.profileEncryptionKey?.keyData, hasIsApproved: true, isApproved: contact.isApproved, @@ -80,7 +80,7 @@ extension ConfigurationMessage { return ConfigurationMessage( displayName: displayName, - profilePictureURL: profilePictureUrl, + profilePictureUrl: profilePictureUrl, profileKey: profileKey, closedGroups: closedGroups, openGroups: openGroups, diff --git a/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage.swift b/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage.swift index 630c56220..9ffa5e6ce 100644 --- a/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage.swift @@ -1,63 +1,49 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Sodium import GRDB import Curve25519Kit import SessionUtilitiesKit -@objc(SNConfigurationMessage) -public final class ConfigurationMessage : ControlMessage { +public final class ConfigurationMessage: ControlMessage { private enum CodingKeys: String, CodingKey { case closedGroups case openGroups case displayName - case profilePictureURL + case profilePictureUrl case profileKey case contacts } - public var closedGroups: Set = [] + public var closedGroups: Set = [] public var openGroups: Set = [] public var displayName: String? - public var profilePictureURL: String? + public var profilePictureUrl: String? public var profileKey: Data? public var contacts: Set = [] public override var isSelfSendValid: Bool { true } - // MARK: Initialization - public override init() { super.init() } + // MARK: - Initialization - public init(displayName: String?, profilePictureURL: String?, profileKey: Data?, closedGroups: Set, openGroups: Set, contacts: Set) { + public init( + displayName: String?, + profilePictureUrl: String?, + profileKey: Data?, + closedGroups: Set, + openGroups: Set, + contacts: Set + ) { super.init() + self.displayName = displayName - self.profilePictureURL = profilePictureURL + self.profilePictureUrl = profilePictureUrl self.profileKey = profileKey self.closedGroups = closedGroups self.openGroups = openGroups self.contacts = contacts } - - // MARK: Coding - public required init?(coder: NSCoder) { - super.init(coder: coder) - if let closedGroups = coder.decodeObject(forKey: "closedGroups") as! Set? { self.closedGroups = closedGroups } - if let openGroups = coder.decodeObject(forKey: "openGroups") as! Set? { self.openGroups = openGroups } - if let displayName = coder.decodeObject(forKey: "displayName") as! String? { self.displayName = displayName } - if let profilePictureURL = coder.decodeObject(forKey: "profilePictureURL") as! String? { self.profilePictureURL = profilePictureURL } - if let profileKey = coder.decodeObject(forKey: "profileKey") as! Data? { self.profileKey = profileKey } - if let contacts = coder.decodeObject(forKey: "contacts") as! Set? { self.contacts = contacts } - } - - public override func encode(with coder: NSCoder) { - super.encode(with: coder) - coder.encode(closedGroups, forKey: "closedGroups") - coder.encode(openGroups, forKey: "openGroups") - coder.encode(displayName, forKey: "displayName") - coder.encode(profilePictureURL, forKey: "profilePictureURL") - coder.encode(profileKey, forKey: "profileKey") - coder.encode(contacts, forKey: "contacts") - } // MARK: - Codable @@ -66,10 +52,10 @@ public final class ConfigurationMessage : ControlMessage { let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) - closedGroups = ((try? container.decode(Set.self, forKey: .closedGroups)) ?? []) + closedGroups = ((try? container.decode(Set.self, forKey: .closedGroups)) ?? []) openGroups = ((try? container.decode(Set.self, forKey: .openGroups)) ?? []) displayName = try? container.decode(String.self, forKey: .displayName) - profilePictureURL = try? container.decode(String.self, forKey: .profilePictureURL) + profilePictureUrl = try? container.decode(String.self, forKey: .profilePictureUrl) profileKey = try? container.decode(Data.self, forKey: .profileKey) contacts = ((try? container.decode(Set.self, forKey: .contacts)) ?? []) } @@ -82,28 +68,36 @@ public final class ConfigurationMessage : ControlMessage { try container.encodeIfPresent(closedGroups, forKey: .closedGroups) try container.encodeIfPresent(openGroups, forKey: .openGroups) try container.encodeIfPresent(displayName, forKey: .displayName) - try container.encodeIfPresent(profilePictureURL, forKey: .profilePictureURL) + try container.encodeIfPresent(profilePictureUrl, forKey: .profilePictureUrl) try container.encodeIfPresent(profileKey, forKey: .profileKey) try container.encodeIfPresent(contacts, forKey: .contacts) } - // MARK: Proto Conversion + // MARK: - Proto Conversion + public override class func fromProto(_ proto: SNProtoContent, sender: String) -> ConfigurationMessage? { guard let configurationProto = proto.configurationMessage else { return nil } let displayName = configurationProto.displayName - let profilePictureURL = configurationProto.profilePicture + let profilePictureUrl = configurationProto.profilePicture let profileKey = configurationProto.profileKey - let closedGroups = Set(configurationProto.closedGroups.compactMap { ClosedGroup.fromProto($0) }) + let closedGroups = Set(configurationProto.closedGroups.compactMap { CMClosedGroup.fromProto($0) }) let openGroups = Set(configurationProto.openGroups) let contacts = Set(configurationProto.contacts.compactMap { CMContact.fromProto($0) }) - return ConfigurationMessage(displayName: displayName, profilePictureURL: profilePictureURL, profileKey: profileKey, - closedGroups: closedGroups, openGroups: openGroups, contacts: contacts) + + return ConfigurationMessage( + displayName: displayName, + profilePictureUrl: profilePictureUrl, + profileKey: profileKey, + closedGroups: closedGroups, + openGroups: openGroups, + contacts: contacts + ) } public override func toProto(_ db: Database) -> SNProtoContent? { let configurationProto = SNProtoConfigurationMessage.builder() if let displayName = displayName { configurationProto.setDisplayName(displayName) } - if let profilePictureURL = profilePictureURL { configurationProto.setProfilePicture(profilePictureURL) } + if let profilePictureUrl = profilePictureUrl { configurationProto.setProfilePicture(profilePictureUrl) } if let profileKey = profileKey { configurationProto.setProfileKey(profileKey) } configurationProto.setClosedGroups(closedGroups.compactMap { $0.toProto() }) configurationProto.setOpenGroups([String](openGroups)) @@ -118,14 +112,15 @@ public final class ConfigurationMessage : ControlMessage { } } - // MARK: Description - public override var description: String { + // MARK: - Description + + public var description: String { """ ConfigurationMessage( - closedGroups: \([ClosedGroup](closedGroups).prettifiedDescription), + closedGroups: \([CMClosedGroup](closedGroups).prettifiedDescription), openGroups: \([String](openGroups).prettifiedDescription), displayName: \(displayName ?? "null"), - profilePictureURL: \(profilePictureURL ?? "null"), + profilePictureUrl: \(profilePictureUrl ?? "null"), profileKey: \(profileKey?.toHexString() ?? "null"), contacts: \([CMContact](contacts).prettifiedDescription) ) @@ -133,11 +128,10 @@ public final class ConfigurationMessage : ControlMessage { } } -// MARK: Closed Group -extension ConfigurationMessage { +// MARK: - Closed Group - @objc(SNClosedGroup) - public final class ClosedGroup: NSObject, Codable, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility +extension ConfigurationMessage { + public struct CMClosedGroup: Codable, Hashable, CustomStringConvertible { private enum CodingKeys: String, CodingKey { case publicKey case name @@ -150,57 +144,43 @@ extension ConfigurationMessage { public let publicKey: String public let name: String - public let encryptionKeyPair: ECKeyPair + public let encryptionKeyPublicKey: Data + public let encryptionKeySecretKey: Data public let members: Set public let admins: Set public let expirationTimer: UInt32 public var isValid: Bool { !members.isEmpty && !admins.isEmpty } - - public init(publicKey: String, name: String, encryptionKeyPair: ECKeyPair, members: Set, admins: Set, expirationTimer: UInt32) { - self.publicKey = publicKey - self.name = name - self.encryptionKeyPair = encryptionKeyPair - self.members = members - self.admins = admins - self.expirationTimer = expirationTimer - } - - public required init?(coder: NSCoder) { - guard let publicKey = coder.decodeObject(forKey: "publicKey") as! String?, - let name = coder.decodeObject(forKey: "name") as! String?, - let encryptionKeyPair = coder.decodeObject(forKey: "encryptionKeyPair") as! ECKeyPair?, - let members = coder.decodeObject(forKey: "members") as! Set?, - let admins = coder.decodeObject(forKey: "admins") as! Set? else { return nil } - let expirationTimer = coder.decodeObject(forKey: "expirationTimer") as? UInt32 ?? 0 - self.publicKey = publicKey - self.name = name - self.encryptionKeyPair = encryptionKeyPair - self.members = members - self.admins = admins - self.expirationTimer = expirationTimer - } - - public func encode(with coder: NSCoder) { - coder.encode(publicKey, forKey: "publicKey") - coder.encode(name, forKey: "name") - coder.encode(encryptionKeyPair, forKey: "encryptionKeyPair") - coder.encode(members, forKey: "members") - coder.encode(admins, forKey: "admins") - coder.encode(expirationTimer, forKey: "expirationTimer") - } + // MARK: - Initialization + + public init( + publicKey: String, + name: String, + encryptionKeyPublicKey: Data, + encryptionKeySecretKey: Data, + members: Set, + admins: Set, + expirationTimer: UInt32 + ) { + self.publicKey = publicKey + self.name = name + self.encryptionKeyPublicKey = encryptionKeyPublicKey + self.encryptionKeySecretKey = encryptionKeySecretKey + self.members = members + self.admins = admins + self.expirationTimer = expirationTimer + } + // MARK: - Codable - public required init(from decoder: Decoder) throws { + public init(from decoder: Decoder) throws { let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) publicKey = try container.decode(String.self, forKey: .publicKey) name = try container.decode(String.self, forKey: .name) - encryptionKeyPair = try ECKeyPair( - publicKeyData: try container.decode(Data.self, forKey: .encryptionKeyPublicKey), - privateKeyData: try container.decode(Data.self, forKey: .encryptionKeySecretKey) - ) + encryptionKeyPublicKey = try container.decode(Data.self, forKey: .encryptionKeyPublicKey) + encryptionKeySecretKey = try container.decode(Data.self, forKey: .encryptionKeySecretKey) members = try container.decode(Set.self, forKey: .members) admins = try container.decode(Set.self, forKey: .admins) expirationTimer = try container.decode(UInt32.self, forKey: .expirationTimer) @@ -211,28 +191,33 @@ extension ConfigurationMessage { try container.encode(publicKey, forKey: .publicKey) try container.encode(name, forKey: .name) - try container.encode(encryptionKeyPair.publicKey, forKey: .encryptionKeyPublicKey) - try container.encode(encryptionKeyPair.privateKey, forKey: .encryptionKeySecretKey) + try container.encode(encryptionKeyPublicKey, forKey: .encryptionKeyPublicKey) + try container.encode(encryptionKeySecretKey, forKey: .encryptionKeySecretKey) try container.encode(members, forKey: .members) try container.encode(admins, forKey: .admins) try container.encode(expirationTimer, forKey: .expirationTimer) } - public static func fromProto(_ proto: SNProtoConfigurationMessageClosedGroup) -> ClosedGroup? { - guard let publicKey = proto.publicKey?.toHexString(), + public static func fromProto(_ proto: SNProtoConfigurationMessageClosedGroup) -> CMClosedGroup? { + guard + let publicKey = proto.publicKey?.toHexString(), let name = proto.name, - let encryptionKeyPairAsProto = proto.encryptionKeyPair else { return nil } - let encryptionKeyPair: ECKeyPair - do { - encryptionKeyPair = try ECKeyPair(publicKeyData: encryptionKeyPairAsProto.publicKey, privateKeyData: encryptionKeyPairAsProto.privateKey) - } catch { - SNLog("Couldn't construct closed group from proto: \(self).") - return nil - } + let encryptionKeyPairAsProto = proto.encryptionKeyPair + else { return nil } + let members = Set(proto.members.map { $0.toHexString() }) let admins = Set(proto.admins.map { $0.toHexString() }) let expirationTimer = proto.expirationTimer - let result = ClosedGroup(publicKey: publicKey, name: name, encryptionKeyPair: encryptionKeyPair, members: members, admins: admins, expirationTimer: expirationTimer) + let result = CMClosedGroup( + publicKey: publicKey, + name: name, + encryptionKeyPublicKey: encryptionKeyPairAsProto.publicKey, + encryptionKeySecretKey: encryptionKeyPairAsProto.privateKey, + members: members, + admins: admins, + expirationTimer: expirationTimer + ) + guard result.isValid else { return nil } return result } @@ -243,7 +228,10 @@ extension ConfigurationMessage { result.setPublicKey(Data(hex: publicKey)) result.setName(name) do { - let encryptionKeyPairAsProto = try SNProtoKeyPair.builder(publicKey: encryptionKeyPair.publicKey, privateKey: encryptionKeyPair.privateKey).build() + let encryptionKeyPairAsProto = try SNProtoKeyPair.builder( + publicKey: encryptionKeyPublicKey, + privateKey: encryptionKeySecretKey + ).build() result.setEncryptionKeyPair(encryptionKeyPairAsProto) } catch { SNLog("Couldn't construct closed group proto from: \(self).") @@ -260,19 +248,18 @@ extension ConfigurationMessage { } } - public override var description: String { name } + public var description: String { name } } } -// MARK: Contact -extension ConfigurationMessage { +// MARK: - Contact - @objc(SNConfigurationMessageContact) - public final class CMContact: NSObject, Codable, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility +extension ConfigurationMessage { + public struct CMContact: Codable, Hashable, CustomStringConvertible { private enum CodingKeys: String, CodingKey { case publicKey case displayName - case profilePictureURL + case profilePictureUrl case profileKey case hasIsApproved @@ -285,7 +272,7 @@ extension ConfigurationMessage { public var publicKey: String? public var displayName: String? - public var profilePictureURL: String? + public var profilePictureUrl: String? public var profileKey: Data? public var hasIsApproved: Bool @@ -298,9 +285,9 @@ extension ConfigurationMessage { public var isValid: Bool { publicKey != nil && displayName != nil } public init( - publicKey: String, - displayName: String, - profilePictureURL: String?, + publicKey: String?, + displayName: String?, + profilePictureUrl: String?, profileKey: Data?, hasIsApproved: Bool, isApproved: Bool, @@ -311,7 +298,7 @@ extension ConfigurationMessage { ) { self.publicKey = publicKey self.displayName = displayName - self.profilePictureURL = profilePictureURL + self.profilePictureUrl = profilePictureUrl self.profileKey = profileKey self.hasIsApproved = hasIsApproved self.isApproved = isApproved @@ -320,43 +307,15 @@ extension ConfigurationMessage { self.hasDidApproveMe = hasDidApproveMe self.didApproveMe = didApproveMe } - - public required init?(coder: NSCoder) { - guard let publicKey = coder.decodeObject(forKey: "publicKey") as! String?, - let displayName = coder.decodeObject(forKey: "displayName") as! String? else { return nil } - self.publicKey = publicKey - self.displayName = displayName - self.profilePictureURL = coder.decodeObject(forKey: "profilePictureURL") as! String? - self.profileKey = coder.decodeObject(forKey: "profileKey") as! Data? - self.hasIsApproved = (coder.decodeObject(forKey: "hasIsApproved") as? Bool ?? false) - self.isApproved = (coder.decodeObject(forKey: "isApproved") as? Bool ?? false) - self.hasIsBlocked = (coder.decodeObject(forKey: "hasIsBlocked") as? Bool ?? false) - self.isBlocked = (coder.decodeObject(forKey: "isBlocked") as? Bool ?? false) - self.hasDidApproveMe = (coder.decodeObject(forKey: "hasDidApproveMe") as? Bool ?? false) - self.didApproveMe = (coder.decodeObject(forKey: "didApproveMe") as? Bool ?? false) - } - - public func encode(with coder: NSCoder) { - coder.encode(publicKey, forKey: "publicKey") - coder.encode(displayName, forKey: "displayName") - coder.encode(profilePictureURL, forKey: "profilePictureURL") - coder.encode(profileKey, forKey: "profileKey") - coder.encode(hasIsApproved, forKey: "hasIsApproved") - coder.encode(isApproved, forKey: "isApproved") - coder.encode(hasIsBlocked, forKey: "hasIsBlocked") - coder.encode(isBlocked, forKey: "isBlocked") - coder.encode(hasDidApproveMe, forKey: "hasDidApproveMe") - coder.encode(didApproveMe, forKey: "didApproveMe") - } // MARK: - Codable - public required init(from decoder: Decoder) throws { + public init(from decoder: Decoder) throws { let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) publicKey = try? container.decode(String.self, forKey: .publicKey) displayName = try? container.decode(String.self, forKey: .displayName) - profilePictureURL = try? container.decode(String.self, forKey: .profilePictureURL) + profilePictureUrl = try? container.decode(String.self, forKey: .profilePictureUrl) profileKey = try? container.decode(Data.self, forKey: .profileKey) hasIsApproved = try container.decode(Bool.self, forKey: .hasIsApproved) @@ -371,7 +330,7 @@ extension ConfigurationMessage { let result: CMContact = CMContact( publicKey: proto.publicKey.toHexString(), displayName: proto.name, - profilePictureURL: proto.profilePicture, + profilePictureUrl: proto.profilePicture, profileKey: proto.profileKey, hasIsApproved: proto.hasIsApproved, isApproved: proto.isApproved, @@ -389,7 +348,7 @@ extension ConfigurationMessage { guard isValid else { return nil } guard let publicKey = publicKey, let displayName = displayName else { return nil } let result = SNProtoConfigurationMessageContact.builder(publicKey: Data(hex: publicKey), name: displayName) - if let profilePictureURL = profilePictureURL { result.setProfilePicture(profilePictureURL) } + if let profilePictureUrl = profilePictureUrl { result.setProfilePicture(profilePictureUrl) } if let profileKey = profileKey { result.setProfileKey(profileKey) } if hasIsApproved { result.setIsApproved(isApproved) } @@ -404,6 +363,6 @@ extension ConfigurationMessage { } } - public override var description: String { displayName ?? "" } + public var description: String { displayName ?? "" } } } diff --git a/SessionMessagingKit/Messages/Control Messages/ControlMessage.swift b/SessionMessagingKit/Messages/Control Messages/ControlMessage.swift index a9b476153..09504cfd8 100644 --- a/SessionMessagingKit/Messages/Control Messages/ControlMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/ControlMessage.swift @@ -2,5 +2,4 @@ import Foundation -@objc(SNControlMessage) public class ControlMessage: Message { } diff --git a/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift b/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift index 0e05bdad0..01557954d 100644 --- a/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift +++ b/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift @@ -11,59 +11,36 @@ public final class DataExtractionNotification : ControlMessage { public var kind: Kind? - // MARK: Kind + // MARK: - Kind + public enum Kind: CustomStringConvertible, Codable { case screenshot case mediaSaved(timestamp: UInt64) public var description: String { switch self { - case .screenshot: return "screenshot" - case .mediaSaved: return "mediaSaved" + case .screenshot: return "screenshot" + case .mediaSaved: return "mediaSaved" } } } - // MARK: Initialization - public override init() { super.init() } - + // MARK: - Initialization + internal init(kind: Kind) { super.init() + self.kind = kind } - // MARK: Validation + // MARK: - Validation + public override var isValid: Bool { guard super.isValid, let kind = kind else { return false } + switch kind { - case .screenshot: return true - case .mediaSaved(let timestamp): return timestamp > 0 - } - } - - // MARK: Coding - public required init?(coder: NSCoder) { - super.init(coder: coder) - guard let rawKind = coder.decodeObject(forKey: "kind") as? String else { return nil } - switch rawKind { - case "screenshot": - self.kind = .screenshot - case "mediaSaved": - guard let timestamp = coder.decodeObject(forKey: "timestamp") as? UInt64 else { return nil } - self.kind = .mediaSaved(timestamp: timestamp) - default: return nil - } - } - - public override func encode(with coder: NSCoder) { - super.encode(with: coder) - guard let kind = kind else { return } - switch kind { - case .screenshot: - coder.encode("screenshot", forKey: "kind") - case .mediaSaved(let timestamp): - coder.encode("mediaSaved", forKey: "kind") - coder.encode(timestamp, forKey: "timestamp") + case .screenshot: return true + case .mediaSaved(let timestamp): return timestamp > 0 } } @@ -85,7 +62,8 @@ public final class DataExtractionNotification : ControlMessage { try container.encodeIfPresent(kind, forKey: .kind) } - // MARK: Proto Conversion + // MARK: - Proto Conversion + public override class func fromProto(_ proto: SNProtoContent, sender: String) -> DataExtractionNotification? { guard let dataExtractionNotification = proto.dataExtractionNotification else { return nil } let kind: Kind @@ -121,8 +99,9 @@ public final class DataExtractionNotification : ControlMessage { } } - // MARK: Description - public override var description: String { + // MARK: - Description + + public var description: String { """ DataExtractionNotification( kind: \(kind?.description ?? "null") diff --git a/SessionMessagingKit/Messages/Control Messages/ExpirationTimerUpdate.swift b/SessionMessagingKit/Messages/Control Messages/ExpirationTimerUpdate.swift index 5426d8cf1..18379089d 100644 --- a/SessionMessagingKit/Messages/Control Messages/ExpirationTimerUpdate.swift +++ b/SessionMessagingKit/Messages/Control Messages/ExpirationTimerUpdate.swift @@ -4,8 +4,7 @@ import Foundation import GRDB import SessionUtilitiesKit -@objc(SNExpirationTimerUpdate) -public final class ExpirationTimerUpdate : ControlMessage { +public final class ExpirationTimerUpdate: ControlMessage { private enum CodingKeys: String, CodingKey { case syncTarget case duration @@ -19,33 +18,21 @@ public final class ExpirationTimerUpdate : ControlMessage { public override var isSelfSendValid: Bool { true } - // MARK: Initialization - public override init() { super.init() } + // MARK: - Initialization internal init(syncTarget: String?, duration: UInt32) { super.init() + self.syncTarget = syncTarget self.duration = duration } - // MARK: Validation + // MARK: - Validation + public override var isValid: Bool { guard super.isValid else { return false } return duration != nil } - - // MARK: Coding - public required init?(coder: NSCoder) { - super.init(coder: coder) - if let syncTarget = coder.decodeObject(forKey: "syncTarget") as! String? { self.syncTarget = syncTarget } - if let duration = coder.decodeObject(forKey: "durationSeconds") as! UInt32? { self.duration = duration } - } - - public override func encode(with coder: NSCoder) { - super.encode(with: coder) - coder.encode(syncTarget, forKey: "syncTarget") - coder.encode(duration, forKey: "durationSeconds") - } // MARK: - Codable @@ -67,7 +54,8 @@ public final class ExpirationTimerUpdate : ControlMessage { try container.encodeIfPresent(duration, forKey: .duration) } - // MARK: Proto Conversion + // MARK: - Proto Conversion + public override class func fromProto(_ proto: SNProtoContent, sender: String) -> ExpirationTimerUpdate? { guard let dataMessageProto = proto.dataMessage else { return nil } @@ -106,8 +94,9 @@ public final class ExpirationTimerUpdate : ControlMessage { } } - // MARK: Description - public override var description: String { + // MARK: - Description + + public var description: String { """ ExpirationTimerUpdate( syncTarget: \(syncTarget ?? "null"), @@ -115,9 +104,4 @@ public final class ExpirationTimerUpdate : ControlMessage { ) """ } - - // MARK: Convenience - @objc public func setDuration(_ duration: UInt32) { - self.duration = duration - } } diff --git a/SessionMessagingKit/Messages/Control Messages/MessageRequestResponse.swift b/SessionMessagingKit/Messages/Control Messages/MessageRequestResponse.swift index b508660c8..fda301631 100644 --- a/SessionMessagingKit/Messages/Control Messages/MessageRequestResponse.swift +++ b/SessionMessagingKit/Messages/Control Messages/MessageRequestResponse.swift @@ -4,7 +4,6 @@ import Foundation import GRDB import SessionUtilitiesKit -@objc(SNMessageRequestResponse) public final class MessageRequestResponse: ControlMessage { private enum CodingKeys: String, CodingKey { case isApproved @@ -20,22 +19,6 @@ public final class MessageRequestResponse: ControlMessage { super.init() } - // MARK: - Coding - - public required init?(coder: NSCoder) { - guard let isApproved: Bool = coder.decodeObject(forKey: "isApproved") as? Bool else { return nil } - - self.isApproved = isApproved - - super.init(coder: coder) - } - - public override func encode(with coder: NSCoder) { - super.encode(with: coder) - - coder.encode(isApproved, forKey: "isApproved") - } - // MARK: - Codable required init(from decoder: Decoder) throws { @@ -79,7 +62,7 @@ public final class MessageRequestResponse: ControlMessage { // MARK: - Description - public override var description: String { + public var description: String { """ MessageRequestResponse( isApproved: \(isApproved) diff --git a/SessionMessagingKit/Messages/Control Messages/ReadReceipt.swift b/SessionMessagingKit/Messages/Control Messages/ReadReceipt.swift index 686a58b74..9437e6503 100644 --- a/SessionMessagingKit/Messages/Control Messages/ReadReceipt.swift +++ b/SessionMessagingKit/Messages/Control Messages/ReadReceipt.swift @@ -4,39 +4,28 @@ import Foundation import GRDB import SessionUtilitiesKit -@objc(SNReadReceipt) -public final class ReadReceipt : ControlMessage { +public final class ReadReceipt: ControlMessage { private enum CodingKeys: String, CodingKey { case timestamps } - @objc public var timestamps: [UInt64]? + public var timestamps: [UInt64]? - // MARK: Initialization - public override init() { super.init() } + // MARK: - Initialization internal init(timestamps: [UInt64]) { super.init() + self.timestamps = timestamps } - // MARK: Validation + // MARK: - Validation + public override var isValid: Bool { guard super.isValid else { return false } if let timestamps = timestamps, !timestamps.isEmpty { return true } return false } - - // MARK: Coding - public required init?(coder: NSCoder) { - super.init(coder: coder) - if let timestamps = coder.decodeObject(forKey: "messageTimestamps") as! [UInt64]? { self.timestamps = timestamps } - } - - public override func encode(with coder: NSCoder) { - super.encode(with: coder) - coder.encode(timestamps, forKey: "messageTimestamps") - } // MARK: - Codable @@ -56,7 +45,8 @@ public final class ReadReceipt : ControlMessage { try container.encodeIfPresent(timestamps, forKey: .timestamps) } - // MARK: Proto Conversion + // MARK: - Proto Conversion + public override class func fromProto(_ proto: SNProtoContent, sender: String) -> ReadReceipt? { guard let receiptProto = proto.receiptMessage, receiptProto.type == .read else { return nil } let timestamps = receiptProto.timestamp @@ -81,8 +71,9 @@ public final class ReadReceipt : ControlMessage { } } - // MARK: Description - public override var description: String { + // MARK: - Description + + public var description: String { """ ReadReceipt( timestamps: \(timestamps?.description ?? "null") diff --git a/SessionMessagingKit/Messages/Control Messages/TypingIndicator.swift b/SessionMessagingKit/Messages/Control Messages/TypingIndicator.swift index d92881360..d5d9058d4 100644 --- a/SessionMessagingKit/Messages/Control Messages/TypingIndicator.swift +++ b/SessionMessagingKit/Messages/Control Messages/TypingIndicator.swift @@ -4,8 +4,7 @@ import Foundation import GRDB import SessionUtilitiesKit -@objc(SNTypingIndicator) -public final class TypingIndicator : ControlMessage { +public final class TypingIndicator: ControlMessage { private enum CodingKeys: String, CodingKey { case kind } @@ -14,56 +13,47 @@ public final class TypingIndicator : ControlMessage { public override var ttl: UInt64 { 20 * 1000 } - // MARK: Kind + // MARK: - Kind + public enum Kind: Int, Codable, CustomStringConvertible { case started, stopped static func fromProto(_ proto: SNProtoTypingMessage.SNProtoTypingMessageAction) -> Kind { switch proto { - case .started: return .started - case .stopped: return .stopped + case .started: return .started + case .stopped: return .stopped } } func toProto() -> SNProtoTypingMessage.SNProtoTypingMessageAction { switch self { - case .started: return .started - case .stopped: return .stopped + case .started: return .started + case .stopped: return .stopped } } public var description: String { switch self { - case .started: return "started" - case .stopped: return "stopped" + case .started: return "started" + case .stopped: return "stopped" } } } - // MARK: Validation + // MARK: - Validation + public override var isValid: Bool { guard super.isValid else { return false } return kind != nil } - // MARK: Initialization - public override init() { super.init() } + // MARK: - Initialization internal init(kind: Kind) { super.init() + self.kind = kind } - - // MARK: Coding - public required init?(coder: NSCoder) { - super.init(coder: coder) - if let rawKind = coder.decodeObject(forKey: "action") as! Int? { kind = Kind(rawValue: rawKind) } - } - - public override func encode(with coder: NSCoder) { - super.encode(with: coder) - coder.encode(kind?.rawValue, forKey: "action") - } // MARK: - Codable @@ -83,7 +73,8 @@ public final class TypingIndicator : ControlMessage { try container.encodeIfPresent(kind, forKey: .kind) } - // MARK: Proto Conversion + // MARK: - Proto Conversion + public override class func fromProto(_ proto: SNProtoContent, sender: String) -> TypingIndicator? { guard let typingIndicatorProto = proto.typingMessage else { return nil } let kind = Kind.fromProto(typingIndicatorProto.action) @@ -106,8 +97,9 @@ public final class TypingIndicator : ControlMessage { } } - // MARK: Description - public override var description: String { + // MARK: - Description + + public var description: String { """ TypingIndicator( kind: \(kind?.description ?? "null") diff --git a/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift b/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift index f784d2c9b..b1b982764 100644 --- a/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift +++ b/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift @@ -4,7 +4,6 @@ import Foundation import GRDB import SessionUtilitiesKit -@objc(SNUnsendRequest) public final class UnsendRequest: ControlMessage { private enum CodingKeys: String, CodingKey { case timestamp @@ -16,33 +15,22 @@ public final class UnsendRequest: ControlMessage { public override var isSelfSendValid: Bool { true } - // MARK: Validation + // MARK: - Validation + public override var isValid: Bool { guard super.isValid else { return false } + return timestamp != nil && author != nil } - // MARK: Initialization - public override init() { super.init() } - + // MARK: - Initialization + internal init(timestamp: UInt64, author: String) { super.init() + self.timestamp = timestamp self.author = author } - - // MARK: Coding - public required init?(coder: NSCoder) { - super.init(coder: coder) - if let timestamp = coder.decodeObject(forKey: "timestamp") as! UInt64? { self.timestamp = timestamp } - if let author = coder.decodeObject(forKey: "author") as! String? { self.author = author } - } - - public override func encode(with coder: NSCoder) { - super.encode(with: coder) - coder.encode(timestamp, forKey: "timestamp") - coder.encode(author, forKey: "author") - } // MARK: - Codable @@ -64,7 +52,8 @@ public final class UnsendRequest: ControlMessage { try container.encodeIfPresent(author, forKey: .author) } - // MARK: Proto Conversion + // MARK: - Proto Conversion + public override class func fromProto(_ proto: SNProtoContent, sender: String) -> UnsendRequest? { guard let unsendRequestProto = proto.unsendRequest else { return nil } let timestamp = unsendRequestProto.timestamp @@ -88,8 +77,9 @@ public final class UnsendRequest: ControlMessage { } } - // MARK: Description - public override var description: String { + // MARK: - Description + + public var description: String { """ UnsendRequest( timestamp: \(timestamp?.description ?? "null") diff --git a/SessionMessagingKit/Messages/Message.swift b/SessionMessagingKit/Messages/Message.swift index 7407e70bb..ee1398d78 100644 --- a/SessionMessagingKit/Messages/Message.swift +++ b/SessionMessagingKit/Messages/Message.swift @@ -4,59 +4,57 @@ import Foundation import GRDB /// Abstract base class for `VisibleMessage` and `ControlMessage`. -@objc(SNMessage) -public class Message: NSObject, Codable, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility +public class Message: Codable { public var id: String? - @objc public var threadID: String? + public var threadId: String? public var sentTimestamp: UInt64? public var receivedTimestamp: UInt64? public var recipient: String? public var sender: String? public var groupPublicKey: String? - public var openGroupServerMessageID: UInt64? + public var openGroupServerMessageId: UInt64? public var openGroupServerTimestamp: UInt64? public var serverHash: String? public var ttl: UInt64 { 14 * 24 * 60 * 60 * 1000 } public var isSelfSendValid: Bool { false } - public override init() { } - - // MARK: Validation + // MARK: - Validation + public var isValid: Bool { if let sentTimestamp = sentTimestamp { guard sentTimestamp > 0 else { return false } } if let receivedTimestamp = receivedTimestamp { guard receivedTimestamp > 0 else { return false } } return sender != nil && recipient != nil } - - // MARK: Coding - public required init?(coder: NSCoder) { - if let id = coder.decodeObject(forKey: "id") as! String? { self.id = id } - if let threadID = coder.decodeObject(forKey: "threadID") as! String? { self.threadID = threadID } - if let sentTimestamp = coder.decodeObject(forKey: "sentTimestamp") as! UInt64? { self.sentTimestamp = sentTimestamp } - if let receivedTimestamp = coder.decodeObject(forKey: "receivedTimestamp") as! UInt64? { self.receivedTimestamp = receivedTimestamp } - if let recipient = coder.decodeObject(forKey: "recipient") as! String? { self.recipient = recipient } - if let sender = coder.decodeObject(forKey: "sender") as! String? { self.sender = sender } - if let groupPublicKey = coder.decodeObject(forKey: "groupPublicKey") as! String? { self.groupPublicKey = groupPublicKey } - if let openGroupServerMessageID = coder.decodeObject(forKey: "openGroupServerMessageID") as! UInt64? { self.openGroupServerMessageID = openGroupServerMessageID } - if let openGroupServerTimestamp = coder.decodeObject(forKey: "openGroupServerTimestamp") as! UInt64? { self.openGroupServerTimestamp = openGroupServerTimestamp } - if let serverHash = coder.decodeObject(forKey: "serverHash") as! String? { self.serverHash = serverHash } + + // MARK: - Initialization + + public init( + id: String? = nil, + threadId: String? = nil, + sentTimestamp: UInt64? = nil, + receivedTimestamp: UInt64? = nil, + recipient: String? = nil, + sender: String? = nil, + groupPublicKey: String? = nil, + openGroupServerMessageId: UInt64? = nil, + openGroupServerTimestamp: UInt64? = nil, + serverHash: String? = nil + ) { + self.id = id + self.threadId = threadId + self.sentTimestamp = sentTimestamp + self.receivedTimestamp = receivedTimestamp + self.recipient = recipient + self.sender = sender + self.groupPublicKey = groupPublicKey + self.openGroupServerMessageId = openGroupServerMessageId + self.openGroupServerTimestamp = openGroupServerTimestamp + self.serverHash = serverHash } - public func encode(with coder: NSCoder) { - coder.encode(id, forKey: "id") - coder.encode(threadID, forKey: "threadID") - coder.encode(sentTimestamp, forKey: "sentTimestamp") - coder.encode(receivedTimestamp, forKey: "receivedTimestamp") - coder.encode(recipient, forKey: "recipient") - coder.encode(sender, forKey: "sender") - coder.encode(groupPublicKey, forKey: "groupPublicKey") - coder.encode(openGroupServerMessageID, forKey: "openGroupServerMessageID") - coder.encode(openGroupServerTimestamp, forKey: "openGroupServerTimestamp") - coder.encode(serverHash, forKey: "serverHash") - } - - // MARK: Proto Conversion + // MARK: - Proto Conversion + public class func fromProto(_ proto: SNProtoContent, sender: String) -> Self? { preconditionFailure("fromProto(_:sender:) is abstract and must be overridden.") } @@ -67,7 +65,7 @@ public class Message: NSObject, Codable, NSCoding { // NSObject/NSCoding conform public func setGroupContextIfNeeded(_ db: Database, on dataMessage: SNProtoDataMessage.SNProtoDataMessageBuilder) throws { guard - let threadId: String = threadID, + let threadId: String = threadId, let thread: SessionThread = try? SessionThread.fetchOne(db, id: threadId), thread.variant == .closedGroup, let legacyGroupId: Data = "\(Legacy.closedGroupIdPrefix)\(threadId)".data(using: .utf8) @@ -77,9 +75,4 @@ public class Message: NSObject, Codable, NSCoding { // NSObject/NSCoding conform let groupProto = SNProtoGroupContext.builder(id: legacyGroupId, type: .deliver) dataMessage.setGroup(try groupProto.build()) } - - // MARK: General - @objc public func setSentTimestamp(_ sentTimestamp: UInt64) { - self.sentTimestamp = sentTimestamp - } } diff --git a/SessionMessagingKit/Messages/Signal/TSIncomingMessage+Conversion.swift b/SessionMessagingKit/Messages/Signal/TSIncomingMessage+Conversion.swift index 6849fd50c..4284b0867 100644 --- a/SessionMessagingKit/Messages/Signal/TSIncomingMessage+Conversion.swift +++ b/SessionMessagingKit/Messages/Signal/TSIncomingMessage+Conversion.swift @@ -7,15 +7,15 @@ public extension TSIncomingMessage { Storage.read { transaction in expiration = thread.disappearingMessagesDuration(with: transaction) } - let openGroupServerMessageID = visibleMessage.openGroupServerMessageID ?? 0 - let isOpenGroupMessage = (openGroupServerMessageID != 0) + let openGroupServerMessageId = visibleMessage.openGroupServerMessageId ?? 0 + let isOpenGroupMessage = (openGroupServerMessageId != 0) let result = TSIncomingMessage( timestamp: visibleMessage.sentTimestamp!, in: thread, authorId: sender, sourceDeviceId: 1, messageBody: visibleMessage.text, - attachmentIds: visibleMessage.attachmentIDs, + attachmentIds: visibleMessage.attachmentIds, expiresInSeconds: !isOpenGroupMessage ? expiration : 0, // Ensure we don't ever expire open group messages quotedMessage: quotedMessage, linkPreview: linkPreview, @@ -24,7 +24,7 @@ public extension TSIncomingMessage { openGroupInvitationURL: visibleMessage.openGroupInvitation?.url, serverHash: visibleMessage.serverHash ) - result.openGroupServerMessageID = openGroupServerMessageID + result.openGroupServerMessageID = openGroupServerMessageId return result } } diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift index ae3183b1f..83020f501 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift @@ -5,37 +5,25 @@ import GRDB import SessionUtilitiesKit public extension VisibleMessage { + struct LinkPreview: Codable { + public let title: String? + public let url: String? + public let attachmentId: String? - @objc(SNLinkPreview) - class LinkPreview: NSObject, Codable, NSCoding { - public var title: String? - public var url: String? - public var attachmentID: String? + public var isValid: Bool { title != nil && url != nil && attachmentId != nil } - public var isValid: Bool { title != nil && url != nil && attachmentID != nil } - - internal init(title: String?, url: String, attachmentID: String?) { + internal init(title: String?, url: String, attachmentId: String?) { self.title = title self.url = url - self.attachmentID = attachmentID - } - - public required init?(coder: NSCoder) { - if let title = coder.decodeObject(forKey: "title") as! String? { self.title = title } - if let url = coder.decodeObject(forKey: "urlString") as! String? { self.url = url } - if let attachmentID = coder.decodeObject(forKey: "attachmentID") as! String? { self.attachmentID = attachmentID } - } - - public func encode(with coder: NSCoder) { - coder.encode(title, forKey: "title") - coder.encode(url, forKey: "urlString") - coder.encode(attachmentID, forKey: "attachmentID") + self.attachmentId = attachmentId } + + // MARK: - Proto Conversion public static func fromProto(_ proto: SNProtoDataMessagePreview) -> LinkPreview? { let title = proto.title let url = proto.url - return LinkPreview(title: title, url: url, attachmentID: nil) + return LinkPreview(title: title, url: url, attachmentId: nil) } public func toProto() -> SNProtoDataMessagePreview? { @@ -51,8 +39,9 @@ public extension VisibleMessage { if let title = title { linkPreviewProto.setTitle(title) } if - let attachmentID = attachmentID, - let attachment: SessionMessagingKit.Attachment = try? SessionMessagingKit.Attachment.fetchOne(db, id: attachmentID), + let attachmentId = attachmentId, + // TODO: try to ditch `SessionMessagingKit.` + let attachment: SessionMessagingKit.Attachment = try? SessionMessagingKit.Attachment.fetchOne(db, id: attachmentId), let attachmentProto = attachment.buildProto() { linkPreviewProto.setImage(attachmentProto) @@ -66,13 +55,14 @@ public extension VisibleMessage { } } - // MARK: Description - public override var description: String { + // MARK: - Description + + public var description: String { """ LinkPreview( title: \(title ?? "null"), url: \(url ?? "null"), - attachmentID: \(attachmentID ?? "null") + attachmentId: \(attachmentId ?? "null") ) """ } @@ -86,7 +76,7 @@ public extension VisibleMessage.LinkPreview { return VisibleMessage.LinkPreview( title: linkPreview.title, url: linkPreview.url, - attachmentID: linkPreview.attachmentId + attachmentId: linkPreview.attachmentId ) } } diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+OpenGroupInvitation.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+OpenGroupInvitation.swift index 034f61b3b..c1bdf29fe 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+OpenGroupInvitation.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+OpenGroupInvitation.swift @@ -5,27 +5,16 @@ import GRDB import SessionUtilitiesKit public extension VisibleMessage { + struct OpenGroupInvitation: Codable { + public let name: String? + public let url: String? - @objc(SNOpenGroupInvitation) - class OpenGroupInvitation: NSObject, Codable, NSCoding { - public var name: String? - public var url: String? - - @objc public init(name: String, url: String) { self.name = name self.url = url } - public required init?(coder: NSCoder) { - if let name = coder.decodeObject(forKey: "name") as! String? { self.name = name } - if let url = coder.decodeObject(forKey: "url") as! String? { self.url = url } - } - - public func encode(with coder: NSCoder) { - coder.encode(name, forKey: "name") - coder.encode(url, forKey: "url") - } + // MARK: - Proto Conversion public static func fromProto(_ proto: SNProtoDataMessageOpenGroupInvitation) -> OpenGroupInvitation? { let url = proto.url @@ -47,8 +36,9 @@ public extension VisibleMessage { } } - // MARK: Description - public override var description: String { + // MARK: - Description + + public var description: String { """ OpenGroupInvitation( name: \(name ?? "null"), diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift index 60a5caec8..64698772e 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift @@ -4,40 +4,30 @@ import Foundation import SessionUtilitiesKit public extension VisibleMessage { + struct Profile: Codable { + public let displayName: String? + public let profileKey: Data? + public let profilePictureUrl: String? - @objc(SNProfile) - class Profile: NSObject, Codable, NSCoding { - public var displayName: String? - public var profileKey: Data? - public var profilePictureURL: String? - - internal init(displayName: String, profileKey: Data? = nil, profilePictureURL: String? = nil) { + internal init(displayName: String, profileKey: Data? = nil, profilePictureUrl: String? = nil) { self.displayName = displayName self.profileKey = profileKey - self.profilePictureURL = profilePictureURL + self.profilePictureUrl = profilePictureUrl } - public required init?(coder: NSCoder) { - if let displayName = coder.decodeObject(forKey: "displayName") as! String? { self.displayName = displayName } - if let profileKey = coder.decodeObject(forKey: "profileKey") as! Data? { self.profileKey = profileKey } - if let profilePictureURL = coder.decodeObject(forKey: "profilePictureURL") as! String? { self.profilePictureURL = profilePictureURL } - } - - public func encode(with coder: NSCoder) { - coder.encode(displayName, forKey: "displayName") - coder.encode(profileKey, forKey: "profileKey") - coder.encode(profilePictureURL, forKey: "profilePictureURL") - } + // MARK: - Proto Conversion public static func fromProto(_ proto: SNProtoDataMessage) -> Profile? { - guard let profileProto = proto.profile, let displayName = profileProto.displayName else { return nil } - let profileKey = proto.profileKey - let profilePictureURL = profileProto.profilePicture - if let profileKey = profileKey, let profilePictureURL = profilePictureURL { - return Profile(displayName: displayName, profileKey: profileKey, profilePictureURL: profilePictureURL) - } else { - return Profile(displayName: displayName) - } + guard + let profileProto = proto.profile, + let displayName = profileProto.displayName + else { return nil } + + return Profile( + displayName: displayName, + profileKey: proto.profileKey, + profilePictureUrl: profileProto.profilePicture + ) } public func toProto() -> SNProtoDataMessage? { @@ -48,9 +38,10 @@ public extension VisibleMessage { let dataMessageProto = SNProtoDataMessage.builder() let profileProto = SNProtoDataMessageLokiProfile.builder() profileProto.setDisplayName(displayName) - if let profileKey = profileKey, let profilePictureURL = profilePictureURL { + + if let profileKey = profileKey, let profilePictureUrl = profilePictureUrl { dataMessageProto.setProfileKey(profileKey) - profileProto.setProfilePicture(profilePictureURL) + profileProto.setProfilePicture(profilePictureUrl) } do { dataMessageProto.setProfile(try profileProto.build()) @@ -62,12 +53,13 @@ public extension VisibleMessage { } // MARK: Description - public override var description: String { + + public var description: String { """ Profile( displayName: \(displayName ?? "null"), profileKey: \(profileKey?.description ?? "null"), - profilePictureURL: \(profilePictureURL ?? "null") + profilePictureUrl: \(profilePictureUrl ?? "null") ) """ } diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift index 21cbe0fb2..10f4276e7 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift @@ -6,43 +6,30 @@ import SessionUtilitiesKit public extension VisibleMessage { - @objc(SNQuote) - class Quote: NSObject, Codable, NSCoding { - public var timestamp: UInt64? - public var publicKey: String? - public var text: String? - public var attachmentID: String? + struct Quote: Codable { + public let timestamp: UInt64? + public let publicKey: String? + public let text: String? + public let attachmentId: String? public var isValid: Bool { timestamp != nil && publicKey != nil } - - public override init() { super.init() } - internal init(timestamp: UInt64, publicKey: String, text: String?, attachmentID: String?) { + // MARK: - Initialization + + internal init(timestamp: UInt64, publicKey: String, text: String?, attachmentId: String?) { self.timestamp = timestamp self.publicKey = publicKey self.text = text - self.attachmentID = attachmentID - } - - public required init?(coder: NSCoder) { - if let timestamp = coder.decodeObject(forKey: "timestamp") as! UInt64? { self.timestamp = timestamp } - if let publicKey = coder.decodeObject(forKey: "authorId") as! String? { self.publicKey = publicKey } - if let text = coder.decodeObject(forKey: "body") as! String? { self.text = text } - if let attachmentID = coder.decodeObject(forKey: "attachmentID") as! String? { self.attachmentID = attachmentID } - } - - public func encode(with coder: NSCoder) { - coder.encode(timestamp, forKey: "timestamp") - coder.encode(publicKey, forKey: "authorId") - coder.encode(text, forKey: "body") - coder.encode(attachmentID, forKey: "attachmentID") + self.attachmentId = attachmentId } + + // MARK: - Proto Conversion public static func fromProto(_ proto: SNProtoDataMessageQuote) -> Quote? { let timestamp = proto.id let publicKey = proto.author let text = proto.text - return Quote(timestamp: timestamp, publicKey: publicKey, text: text, attachmentID: nil) + return Quote(timestamp: timestamp, publicKey: publicKey, text: text, attachmentId: nil) } public func toProto() -> SNProtoDataMessageQuote? { @@ -66,9 +53,9 @@ public extension VisibleMessage { } private func addAttachmentsIfNeeded(_ db: Database, to quoteProto: SNProtoDataMessageQuote.SNProtoDataMessageQuoteBuilder) { - guard let attachmentID = attachmentID else { return } + guard let attachmentId = attachmentId else { return } guard - let attachment: SessionMessagingKit.Attachment = try? SessionMessagingKit.Attachment.fetchOne(db, id: attachmentID), + let attachment: SessionMessagingKit.Attachment = try? SessionMessagingKit.Attachment.fetchOne(db, id: attachmentId), attachment.state != .uploaded else { #if DEBUG @@ -91,14 +78,15 @@ public extension VisibleMessage { } } - // MARK: Description - public override var description: String { + // MARK: - Description + + public var description: String { """ Quote( timestamp: \(timestamp?.description ?? "null"), publicKey: \(publicKey ?? "null"), text: \(text ?? "null"), - attachmentID: \(attachmentID ?? "null") + attachmentId: \(attachmentId ?? "null") ) """ } @@ -109,12 +97,11 @@ public extension VisibleMessage { public extension VisibleMessage.Quote { static func from(_ db: Database, quote: Quote) -> VisibleMessage.Quote { - let result = VisibleMessage.Quote() - result.timestamp = UInt64(quote.timestampMs) - result.publicKey = quote.authorId - result.text = quote.body - result.attachmentID = quote.attachmentId - - return result + return VisibleMessage.Quote( + timestamp: UInt64(quote.timestampMs), + publicKey: quote.authorId, + text: quote.body, + attachmentId: quote.attachmentId + ) } } diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift index 7ac2758b9..fafa53719 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift @@ -4,12 +4,11 @@ import Foundation import GRDB import SessionUtilitiesKit -@objc(SNVisibleMessage) public final class VisibleMessage: Message { private enum CodingKeys: String, CodingKey { case syncTarget case text = "body" - case attachmentIDs = "attachments" + case attachmentIds = "attachments" case quote case linkPreview case profile @@ -20,65 +19,68 @@ public final class VisibleMessage: Message { /// /// - Note: `nil` if this isn't a sync message. public var syncTarget: String? - @objc public var text: String? - @objc public var attachmentIDs: [String] = [] - @objc public var quote: Quote? - @objc public var linkPreview: LinkPreview? - @objc public var contact: Legacy.Contact? - @objc public var profile: Profile? - @objc public var openGroupInvitation: OpenGroupInvitation? + public let text: String? + public var attachmentIds: [String] + public let quote: Quote? + public let linkPreview: LinkPreview? + public var profile: Profile? + public let openGroupInvitation: OpenGroupInvitation? public override var isSelfSendValid: Bool { true } - // MARK: Initialization - public override init() { super.init() } - - // MARK: Validation + // MARK: - Validation + public override var isValid: Bool { guard super.isValid else { return false } - if !attachmentIDs.isEmpty { return true } + if !attachmentIds.isEmpty { return true } if openGroupInvitation != nil { return true } if let text = text?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty { return true } return false } - - // MARK: Coding - public required init?(coder: NSCoder) { - super.init(coder: coder) - if let syncTarget = coder.decodeObject(forKey: "syncTarget") as! String? { self.syncTarget = syncTarget } - if let text = coder.decodeObject(forKey: "body") as! String? { self.text = text } - if let attachmentIDs = coder.decodeObject(forKey: "attachments") as! [String]? { self.attachmentIDs = attachmentIDs } - if let quote = coder.decodeObject(forKey: "quote") as! Quote? { self.quote = quote } - if let linkPreview = coder.decodeObject(forKey: "linkPreview") as! LinkPreview? { self.linkPreview = linkPreview } - if let profile = coder.decodeObject(forKey: "profile") as! Profile? { self.profile = profile } - if let openGroupInvitation = coder.decodeObject(forKey: "openGroupInvitation") as! OpenGroupInvitation? { self.openGroupInvitation = openGroupInvitation } - } - public override func encode(with coder: NSCoder) { - super.encode(with: coder) - coder.encode(syncTarget, forKey: "syncTarget") - coder.encode(text, forKey: "body") - coder.encode(attachmentIDs, forKey: "attachments") - coder.encode(quote, forKey: "quote") - coder.encode(linkPreview, forKey: "linkPreview") - coder.encode(profile, forKey: "profile") - coder.encode(openGroupInvitation, forKey: "openGroupInvitation") + // MARK: - Initialization + + public init( + sentTimestamp: UInt64? = nil, + recipient: String? = nil, + groupPublicKey: String? = nil, + syncTarget: String? = nil, + text: String?, + attachmentIds: [String] = [], + quote: Quote? = nil, + linkPreview: LinkPreview? = nil, + profile: Profile? = nil, + openGroupInvitation: OpenGroupInvitation? = nil + ) { + self.syncTarget = syncTarget + self.text = text + self.attachmentIds = attachmentIds + self.quote = quote + self.linkPreview = linkPreview + self.profile = profile + self.openGroupInvitation = openGroupInvitation + + super.init( + sentTimestamp: sentTimestamp, + recipient: recipient, + groupPublicKey: groupPublicKey + ) } // MARK: - Codable required init(from decoder: Decoder) throws { - try super.init(from: decoder) - let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) syncTarget = try? container.decode(String.self, forKey: .syncTarget) text = try? container.decode(String.self, forKey: .text) - attachmentIDs = ((try? container.decode([String].self, forKey: .attachmentIDs)) ?? []) + attachmentIds = ((try? container.decode([String].self, forKey: .attachmentIds)) ?? []) quote = try? container.decode(Quote.self, forKey: .quote) linkPreview = try? container.decode(LinkPreview.self, forKey: .linkPreview) profile = try? container.decode(Profile.self, forKey: .profile) openGroupInvitation = try? container.decode(OpenGroupInvitation.self, forKey: .openGroupInvitation) + + try super.init(from: decoder) } public override func encode(to encoder: Encoder) throws { @@ -88,32 +90,32 @@ public final class VisibleMessage: Message { try container.encodeIfPresent(syncTarget, forKey: .syncTarget) try container.encodeIfPresent(text, forKey: .text) - try container.encodeIfPresent(attachmentIDs, forKey: .attachmentIDs) + try container.encodeIfPresent(attachmentIds, forKey: .attachmentIds) try container.encodeIfPresent(quote, forKey: .quote) try container.encodeIfPresent(linkPreview, forKey: .linkPreview) try container.encodeIfPresent(profile, forKey: .profile) try container.encodeIfPresent(openGroupInvitation, forKey: .openGroupInvitation) } - // MARK: Proto Conversion + // MARK: - Proto Conversion + public override class func fromProto(_ proto: SNProtoContent, sender: String) -> VisibleMessage? { guard let dataMessage = proto.dataMessage else { return nil } - let result = VisibleMessage() - result.text = dataMessage.body - // Attachments are handled in MessageReceiver - if let quoteProto = dataMessage.quote, let quote = Quote.fromProto(quoteProto) { result.quote = quote } - if let linkPreviewProto = dataMessage.preview.first, let linkPreview = LinkPreview.fromProto(linkPreviewProto) { result.linkPreview = linkPreview } - // TODO: Contact - if let profile = Profile.fromProto(dataMessage) { result.profile = profile } - if let openGroupInvitationProto = dataMessage.openGroupInvitation, - let openGroupInvitation = OpenGroupInvitation.fromProto(openGroupInvitationProto) { result.openGroupInvitation = openGroupInvitation } - result.syncTarget = dataMessage.syncTarget - return result + + return VisibleMessage( + syncTarget: dataMessage.syncTarget, + text: dataMessage.body, + attachmentIds: [], // Attachments are handled in MessageReceiver + quote: dataMessage.quote.map { Quote.fromProto($0) }, + linkPreview: dataMessage.preview.first.map { LinkPreview.fromProto($0) }, + profile: Profile.fromProto(dataMessage), + openGroupInvitation: dataMessage.openGroupInvitation.map { OpenGroupInvitation.fromProto($0) } + ) } public override func toProto(_ db: Database) -> SNProtoContent? { let proto = SNProtoContent.builder() - var attachmentIDs = self.attachmentIDs + var attachmentIds = self.attachmentIds let dataMessage: SNProtoDataMessage.SNProtoDataMessageBuilder // Profile @@ -138,8 +140,8 @@ public final class VisibleMessage: Message { } // Link preview - if let linkPreviewAttachmentID = linkPreview?.attachmentID, let index = attachmentIDs.firstIndex(of: linkPreviewAttachmentID) { - attachmentIDs.remove(at: index) + if let linkPreviewAttachmentId = linkPreview?.attachmentId, let index = attachmentIds.firstIndex(of: linkPreviewAttachmentId) { + attachmentIds.remove(at: index) } if let linkPreview = linkPreview, let linkPreviewProto = linkPreview.toProto(db) { @@ -148,7 +150,7 @@ public final class VisibleMessage: Message { // Attachments - let attachments: [SessionMessagingKit.Attachment]? = try? SessionMessagingKit.Attachment.fetchAll(db, ids: self.attachmentIDs) + let attachments: [SessionMessagingKit.Attachment]? = try? SessionMessagingKit.Attachment.fetchAll(db, ids: self.attachmentIds) if !(attachments ?? []).allSatisfy({ $0.state == .uploaded }) { #if DEBUG @@ -158,8 +160,6 @@ public final class VisibleMessage: Message { let attachmentProtos = (attachments ?? []).compactMap { $0.buildProto() } dataMessage.setAttachments(attachmentProtos) - // TODO: Contact - // Open group invitation if let openGroupInvitation = openGroupInvitation, let openGroupInvitationProto = openGroupInvitation.toProto() { dataMessage.setOpenGroupInvitation(openGroupInvitationProto) } @@ -184,15 +184,15 @@ public final class VisibleMessage: Message { } } - // MARK: Description - public override var description: String { + // MARK: - Description + + public var description: String { """ VisibleMessage( text: \(text ?? "null"), - attachmentIDs: \(attachmentIDs), + attachmentIds: \(attachmentIds), quote: \(quote?.description ?? "null"), linkPreview: \(linkPreview?.description ?? "null"), - contact: \(contact?.description ?? "null"), profile: \(profile?.description ?? "null") "openGroupInvitation": \(openGroupInvitation?.description ?? "null") ) diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index c70f5bd22..644ab35ab 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -194,7 +194,7 @@ extension MessageReceiver { threadId: thread.id, authorId: sender, variant: .infoDisappearingMessagesUpdate, - body: config.infoUpdateMessage( + body: config.messageInfoString( with: (sender != getUserHexEncodedPublicKey(db) ? Profile.displayName(db, id: sender) : nil @@ -225,7 +225,7 @@ extension MessageReceiver { db, publicKey: userPublicKey, name: message.displayName, - profilePictureUrl: message.profilePictureURL, + profilePictureUrl: message.profilePictureUrl, profileKey: OWSAES256Key(data: message.profileKey), sentTimestamp: messageSentTimestamp ) @@ -248,7 +248,7 @@ extension MessageReceiver { try profile .with( name: contactInfo.displayName, - profilePictureUrl: .updateIf(contactInfo.profilePictureURL), + profilePictureUrl: .updateIf(contactInfo.profilePictureUrl), profileEncryptionKey: .updateIf( contactInfo.profileKey.map { OWSAES256Key(data: $0) } ) @@ -312,8 +312,8 @@ extension MessageReceiver { guard !existingClosedGroupsIds.contains(closedGroup.publicKey) else { return } let keyPair: Box.KeyPair = Box.KeyPair( - publicKey: closedGroup.encryptionKeyPair.publicKey.bytes, - secretKey: closedGroup.encryptionKeyPair.privateKey.bytes + publicKey: closedGroup.encryptionKeyPublicKey.bytes, + secretKey: closedGroup.encryptionKeySecretKey.bytes ) try handleNewClosedGroup( @@ -409,7 +409,7 @@ extension MessageReceiver { } try attachments.saveAll(db) - message.attachmentIDs = attachments.map { $0.id } + message.attachmentIds = attachments.map { $0.id } // Update profile if needed if let profile = message.profile { @@ -420,7 +420,7 @@ extension MessageReceiver { db, publicKey: sender, name: profile.displayName, - profilePictureUrl: profile.profilePictureURL, + profilePictureUrl: profile.profilePictureUrl, profileKey: contactProfileKey, sentTimestamp: messageSentTimestamp ) @@ -474,7 +474,7 @@ extension MessageReceiver { interaction = try existingInteraction .with( serverHash: message.serverHash, // Keep track of server hash - openGroupServerMessageId: message.openGroupServerMessageID.map { Int64($0) } + openGroupServerMessageId: message.openGroupServerMessageId.map { Int64($0) } ) .saved(db) @@ -494,7 +494,7 @@ extension MessageReceiver { body: message.text, timestampMs: Int64(messageSentTimestamp * 1000), // Note: Ensure we don't ever expire open group messages - expiresInSeconds: (disappearingMessagesConfiguration.isEnabled && message.openGroupServerMessageID == nil ? + expiresInSeconds: (disappearingMessagesConfiguration.isEnabled && message.openGroupServerMessageId == nil ? disappearingMessagesConfiguration.durationSeconds : nil ), @@ -502,9 +502,9 @@ extension MessageReceiver { // OpenGroupInvitations are stored as LinkPreview's in the database linkPreviewUrl: (message.linkPreview?.url ?? message.openGroupInvitation?.url), // Keep track of the open group server message ID ↔ message ID relationship - openGroupServerMessageId: message.openGroupServerMessageID.map { Int64($0) }, openGroupWhisperMods: false, // TODO: SOGSV4 openGroupWhisperTo: nil // TODO: SOGSV4 + openGroupServerMessageId: message.openGroupServerMessageId.map { Int64($0) }, ).inserted(db) guard let newInteractionId: Int64 = interaction.id else { throw GRDBStorageError.failedToSave } diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index 2fb678ac1..6235da943 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -147,7 +147,7 @@ public enum MessageReceiver { message.sentTimestamp = envelope.timestamp message.receivedTimestamp = UInt64((Date().timeIntervalSince1970) * 1000) message.groupPublicKey = groupPublicKey - message.openGroupServerMessageID = openGroupMessageServerId + message.openGroupServerMessageId = openGroupMessageServerId // Validate var isValid: Bool = message.isValid diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index a16110c55..94794f6d5 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -147,7 +147,11 @@ public final class MessageSender : NSObject { let profile: Profile = Profile.fetchOrCreateCurrentUser(db) if let profileKey: Data = profile.profileEncryptionKey?.keyData, let profilePictureUrl: String = profile.profilePictureUrl { - message.profile = VisibleMessage.Profile(displayName: profile.name, profileKey: profileKey, profilePictureURL: profilePictureUrl) + message.profile = VisibleMessage.Profile( + displayName: profile.name, + profileKey: profileKey, + profilePictureUrl: profilePictureUrl + ) } else { message.profile = VisibleMessage.Profile(displayName: profile.name) @@ -400,7 +404,7 @@ public final class MessageSender : NSObject { on: server ) .done(on: DispatchQueue.global(qos: .userInitiated)) { openGroupMessage in - message.openGroupServerMessageID = given(openGroupMessage.serverID) { UInt64($0) } + message.openGroupServerMessageId = given(openGroupMessage.serverID) { UInt64($0) } GRDBStorage.shared.write { db in try MessageSender.handleSuccessfulMessageSend( @@ -445,11 +449,11 @@ public final class MessageSender : NSObject { // Track the open group server message ID and update server timestamp (use server // timestamp for open group messages otherwise the quote messages may not be able // to be found by the timestamp on other devices - timestampMs: (message.openGroupServerMessageID == nil ? + timestampMs: (message.openGroupServerMessageId == nil ? nil : serverTimestampMs.map { Int64($0) } ), - openGroupServerMessageId: message.openGroupServerMessageID.map { Int64($0) } + openGroupServerMessageId: message.openGroupServerMessageId.map { Int64($0) } ).update(db) // Mark the message as sent @@ -489,14 +493,14 @@ public final class MessageSender : NSObject { } }(), sentTimestampMs: { - if message.openGroupServerMessageID != nil { + if message.openGroupServerMessageId != nil { return (serverTimestampMs.map { Int64($0) } ?? 0) } return (message.sentTimestamp.map { Int64($0) } ?? 0) }(), serverHash: (message.serverHash ?? ""), - openGroupMessageServerId: (message.openGroupServerMessageID.map { Int64($0) } ?? 0) + openGroupMessageServerId: (message.openGroupServerMessageId.map { Int64($0) } ?? 0) ).insert(db) } // Sync the message if: @@ -553,7 +557,7 @@ public final class MessageSender : NSObject { else if let sentTimestamp: Double = message.sentTimestamp.map({ Double($0) }) { // If we have a threadId then include that in the filter to make the request smaller if - let threadId: String = message.threadID, + let threadId: String = message.threadId, !threadId.isEmpty, let thread: SessionThread = try? SessionThread.fetchOne(db, id: threadId) { diff --git a/SessionMessagingKit/Sending & Receiving/Notification+MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/Notification+MessageReceiver.swift index 87cdcfd51..f369ac1ef 100644 --- a/SessionMessagingKit/Sending & Receiving/Notification+MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/Notification+MessageReceiver.swift @@ -2,9 +2,11 @@ public extension Notification.Name { static let initialConfigurationMessageReceived = Notification.Name("initialConfigurationMessageReceived") + static let incomingMessageMarkedAsRead = Notification.Name("incomingMessageMarkedAsRead") } @objc public extension NSNotification { @objc static let initialConfigurationMessageReceived = Notification.Name.initialConfigurationMessageReceived.rawValue as NSString + @objc static let incomingMessageMarkedAsRead = Notification.Name.incomingMessageMarkedAsRead.rawValue as NSString } diff --git a/SessionSnodeKit/Database/LegacyDatabase/SSKLegacyModels.swift b/SessionSnodeKit/Database/LegacyDatabase/SSKLegacyModels.swift index f29077117..cb271285b 100644 --- a/SessionSnodeKit/Database/LegacyDatabase/SSKLegacyModels.swift +++ b/SessionSnodeKit/Database/LegacyDatabase/SSKLegacyModels.swift @@ -18,53 +18,48 @@ public enum Legacy { public typealias LegacyOnionRequestAPIPath = [Snode] @objc(Snode) - public final class Snode: NSObject, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility + public final class Snode: NSObject, NSCoding { public let address: String public let port: UInt16 public let publicKeySet: KeySet - public var ip: String { - guard let range = address.range(of: "https://"), range.lowerBound == address.startIndex else { return address } - return String(address[range.upperBound.. Bool { guard let other = other as? Snode else { return false } + return address == other.address && port == other.port } - override public var hash: Int { // Override NSObject.hash and not Hashable.hashValue or Hashable.hash(into:) + override public var hash: Int { return address.hashValue ^ port.hashValue } } diff --git a/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 6f254aff0..bc760e87e 100644 --- a/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -15,6 +15,12 @@ enum _003_YDBToGRDBMigration: Migration { var snodeSetResult: [String: Set] = [:] var lastSnodePoolRefreshDate: Date? = nil + // Map the Legacy types for the NSKeyedUnarchiver + NSKeyedUnarchiver.setClass( + Legacy.Snode.self, + forClassName: "SessionSnodeKit.Snode" + ) + Storage.read { transaction in // Process the lastSnodePoolRefreshDate lastSnodePoolRefreshDate = transaction.object( diff --git a/SessionUtilitiesKit/Database/LegacyDatabase/SUKLegacyModels.swift b/SessionUtilitiesKit/Database/LegacyDatabase/SUKLegacyModels.swift index 0773d38d7..28fd3aaeb 100644 --- a/SessionUtilitiesKit/Database/LegacyDatabase/SUKLegacyModels.swift +++ b/SessionUtilitiesKit/Database/LegacyDatabase/SUKLegacyModels.swift @@ -2,6 +2,8 @@ import Foundation +public typealias SUKLegacy = Legacy + public enum Legacy { // MARK: - Collections and Keys @@ -14,7 +16,7 @@ public enum Legacy { internal static let identityKeyStoreIdentityKey = "TSStorageManagerIdentityKeyStoreIdentityKey" internal static let identityKeyStoreCollection = "TSStorageManagerIdentityKeyStoreCollection" - @objc(ECKeyPair) + @objc(LegacyKeyPair) public class KeyPair: NSObject, NSCoding { private static let keyLength: Int = 32 private static let publicKeyKey: String = "TSECKeyPairPublicKey" @@ -23,6 +25,14 @@ public enum Legacy { public let publicKey: Data public let privateKey: Data + public init( + publicKeyData: Data, + privateKeyData: Data + ) { + publicKey = publicKeyData + privateKey = privateKeyData + } + public required init?(coder: NSCoder) { var pubKeyLength: Int = 0 var privKeyLength: Int = 0 diff --git a/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 55de4d025..436ad7f85 100644 --- a/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -16,6 +16,12 @@ enum _003_YDBToGRDBMigration: Migration { var userEd25519PublicKeyHexString: String? var userX25519KeyPair: Legacy.KeyPair? + // Map the Legacy types for the NSKeyedUnarchiver + NSKeyedUnarchiver.setClass( + Legacy.KeyPair.self, + forClassName: "ECKeyPair" + ) + Storage.read { transaction in registeredNumber = transaction.object( forKey: Legacy.userAccountRegisteredNumberKey, diff --git a/SessionUtilitiesKit/Database/Types/TypedTableAlias.swift b/SessionUtilitiesKit/Database/Types/TypedTableAlias.swift new file mode 100644 index 000000000..9c526ec14 --- /dev/null +++ b/SessionUtilitiesKit/Database/Types/TypedTableAlias.swift @@ -0,0 +1,14 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +public class TypedTableAlias where T: TableRecord, T: ColumnExpressible { + let alias: TableAlias = TableAlias(name: T.databaseTableName) + + public init() {} + + public subscript(_ column: T.Columns) -> SQLExpression { + return alias[column.name] + } +} diff --git a/SessionUtilitiesKit/Database/Utilities/PersistableRecord+Utilities.swift b/SessionUtilitiesKit/Database/Utilities/PersistableRecord+Utilities.swift index de92207c1..3da9eba81 100644 --- a/SessionUtilitiesKit/Database/Utilities/PersistableRecord+Utilities.swift +++ b/SessionUtilitiesKit/Database/Utilities/PersistableRecord+Utilities.swift @@ -10,7 +10,7 @@ public extension Array where Element: PersistableRecord { } } - @discardableResult func saveAll(_ db: Database) throws { + func saveAll(_ db: Database) throws { try forEach { try $0.save(db) } } } diff --git a/SessionUtilitiesKit/Database/Utilities/QueryInterfaceRequest+Utilities.swift b/SessionUtilitiesKit/Database/Utilities/QueryInterfaceRequest+Utilities.swift new file mode 100644 index 000000000..fa9419022 --- /dev/null +++ b/SessionUtilitiesKit/Database/Utilities/QueryInterfaceRequest+Utilities.swift @@ -0,0 +1,26 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +public extension QueryInterfaceRequest { + /// Returns true if the request matches a row in the database. + /// + /// try Player.filter(Column("name") == "Arthur").isEmpty(db) + /// + /// - parameter db: A database connection. + /// - returns: Whether the request matches a row in the database. + func isNotEmpty(_ db: Database) throws -> Bool { + return ((try? SQLRequest("SELECT \(exists())").fetchOne(db)) ?? false) + } +} + +public extension QueryInterfaceRequest where RowDecoder: ColumnExpressible { + func select(_ selection: RowDecoder.Columns...) -> Self { + select(selection) + } + + func order(_ orderings: RowDecoder.Columns...) -> QueryInterfaceRequest { + order(orderings) + } +} diff --git a/SessionUtilitiesKit/Database/Utilities/TableRecord+Utilities.swift b/SessionUtilitiesKit/Database/Utilities/TableRecord+Utilities.swift new file mode 100644 index 000000000..b8c0be2b1 --- /dev/null +++ b/SessionUtilitiesKit/Database/Utilities/TableRecord+Utilities.swift @@ -0,0 +1,10 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +public extension TableRecord where Self: ColumnExpressible { + static func select(_ selection: Columns...) -> QueryInterfaceRequest { + return all().select(selection) + } +} diff --git a/SessionUtilitiesKit/General/String+Localization.swift b/SessionUtilitiesKit/General/String+Localized.swift similarity index 100% rename from SessionUtilitiesKit/General/String+Localization.swift rename to SessionUtilitiesKit/General/String+Localized.swift diff --git a/SessionUtilitiesKit/General/UITableView+ReusableView.swift b/SessionUtilitiesKit/General/UITableView+ReusableView.swift index 725faa6b4..48b8425fd 100644 --- a/SessionUtilitiesKit/General/UITableView+ReusableView.swift +++ b/SessionUtilitiesKit/General/UITableView+ReusableView.swift @@ -12,12 +12,16 @@ public extension UITableView { } func dequeue(type: T.Type, for indexPath: IndexPath) -> T where T: UITableViewCell { - let reuseIdentifier = T.defaultReuseIdentifier + // Note: We need to use `type.defaultReuseIdentifier` rather than `T.defaultReuseIdentifier` + // otherwise we may get a subclass rather than the actual type we specified + let reuseIdentifier = type.defaultReuseIdentifier return dequeueReusableCell(withIdentifier: reuseIdentifier, for: indexPath) as! T } func dequeueHeaderFooterView(type: T.Type) -> T where T: UITableViewHeaderFooterView { - let reuseIdentifier = T.defaultReuseIdentifier + // Note: We need to use `type.defaultReuseIdentifier` rather than `T.defaultReuseIdentifier` + // otherwise we may get a subclass rather than the actual type we specified + let reuseIdentifier = type.defaultReuseIdentifier return dequeueReusableHeaderFooterView(withIdentifier: reuseIdentifier) as! T } } diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index 39d6e896b..8947a0032 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -365,7 +365,7 @@ public final class JobRunner { db, Job// TODO: Test this works as expected .filterPendingJobs(excludeFutureJobs: false) - .select(Job.Columns.nextRunTimestamp) + .select(.nextRunTimestamp) ) } diff --git a/SessionUtilitiesKit/Media/OWSMediaUtils.swift b/SessionUtilitiesKit/Media/OWSMediaUtils.swift index fbab78183..42cd8e7ac 100644 --- a/SessionUtilitiesKit/Media/OWSMediaUtils.swift +++ b/SessionUtilitiesKit/Media/OWSMediaUtils.swift @@ -1,3 +1,5 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + import Foundation import AVFoundation diff --git a/SessionUtilitiesKit/Utilities/NSAttributedString+Utilities.swift b/SessionUtilitiesKit/Utilities/NSAttributedString+Utilities.swift new file mode 100644 index 000000000..bc80c9dcb --- /dev/null +++ b/SessionUtilitiesKit/Utilities/NSAttributedString+Utilities.swift @@ -0,0 +1,57 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension NSAttributedString { + static func with(_ attrStrings: [NSAttributedString]) -> NSAttributedString { + let mutableString: NSMutableAttributedString = NSMutableAttributedString() + + for attrString in attrStrings { + mutableString.append(attrString) + } + + return mutableString + } + + func appending(_ attrString: NSAttributedString) -> NSAttributedString { + let mutableString: NSMutableAttributedString = NSMutableAttributedString(attributedString: self) + mutableString.append(attrString) + + return mutableString + } + + func appending(string: String, attributes: [Key: Any]? = nil) -> NSAttributedString { + return appending(NSAttributedString(string: string, attributes: attributes)) + } + + // The actual Swift implementation of 'uppercased' is pretty nuts (see + // https://github.com/apple/swift/blob/main/stdlib/public/core/String.swift#L901) + // this approach is definitely less efficient but is much simpler and less likely to break + private enum CharacterCasing { + static let map: [UTF16.CodeUnit: String.UTF16View] = [ + "a": "A", "b": "B", "c": "C", "d": "D", "e": "E", "f": "F", "g": "G", + "h": "H", "i": "I", "j": "J", "k": "K", "l": "L", "m": "M", "n": "N", + "o": "O", "p": "P", "q": "Q", "r": "R", "s": "S", "t": "T", "u": "U", + "v": "V", "w": "W", "x": "X", "y": "Y", "z": "Z" + ] + .reduce(into: [:]) { prev, next in + prev[next.key.utf16.first ?? UTF16.CodeUnit()] = next.value.utf16 + } + } + + func uppercased() -> NSAttributedString { + let result = NSMutableAttributedString(attributedString: self) + let uppercasedCharacters = result.string.utf16.map { utf16Char in + // Try convert the individual utf16 character to it's uppercase variant + // or fallback to the original character + (CharacterCasing.map[utf16Char]?.first ?? utf16Char) + } + + result.replaceCharacters( + in: NSRange(location: 0, length: length), + with: String(utf16CodeUnits: uppercasedCharacters, count: length) + ) + + return result + } +} diff --git a/SessionUtilitiesKit/Utilities/Optional+Utilities.swift b/SessionUtilitiesKit/Utilities/Optional+Utilities.swift index cd6374b96..ede1f4e36 100644 --- a/SessionUtilitiesKit/Utilities/Optional+Utilities.swift +++ b/SessionUtilitiesKit/Utilities/Optional+Utilities.swift @@ -21,3 +21,11 @@ extension Optional { return (self ?? value) } } + +extension Optional where Wrapped == String { + public func defaulting(to value: Wrapped, useDefaultIfEmpty: Bool = false) -> Wrapped { + guard !useDefaultIfEmpty || self?.isEmpty != true else { return value } + + return (self ?? value) + } +} diff --git a/SignalUtilitiesKit/Database/Migrations/ContactsMigration.swift b/SignalUtilitiesKit/Database/Migrations/ContactsMigration.swift index 2e0a78b66..23e3534f4 100644 --- a/SignalUtilitiesKit/Database/Migrations/ContactsMigration.swift +++ b/SignalUtilitiesKit/Database/Migrations/ContactsMigration.swift @@ -16,24 +16,24 @@ public class ContactsMigration : OWSDatabaseMigration { } private func doMigrationAsync(completion: @escaping OWSDatabaseMigrationCompletion) { - var contacts: [SessionMessagingKit.Legacy.Contact] = [] + var contacts: [SMKLegacy.Contact] = [] TSContactThread.enumerateCollectionObjects { object, _ in guard let thread = object as? TSContactThread else { return } let sessionID = thread.contactSessionID() - var contact: SessionMessagingKit.Legacy.Contact? + var contact: SMKLegacy.Contact? Storage.read { transaction in - contact = transaction.object(forKey: sessionID, inCollection: Legacy.contactCollection) as? SessionMessagingKit.Legacy.Contact + contact = transaction.object(forKey: sessionID, inCollection: SMKLegacy.contactCollection) as? SMKLegacy.Contact } - if let contact: SessionMessagingKit.Legacy.Contact = contact { + if let contact: SMKLegacy.Contact = contact { contact.isTrusted = true contacts.append(contact) } } Storage.write(with: { transaction in contacts.forEach { contact in - transaction.setObject(contact, forKey: contact.sessionID, inCollection: Legacy.contactCollection) + transaction.setObject(contact, forKey: contact.sessionID, inCollection: SMKLegacy.contactCollection) } self.save(with: transaction) // Intentionally capture self }, completion: { diff --git a/SignalUtilitiesKit/Profile Pictures/Identicon+ObjC.swift b/SignalUtilitiesKit/Profile Pictures/Identicon+ObjC.swift index 81e2d664b..2e7914aca 100644 --- a/SignalUtilitiesKit/Profile Pictures/Identicon+ObjC.swift +++ b/SignalUtilitiesKit/Profile Pictures/Identicon+ObjC.swift @@ -4,10 +4,13 @@ public final class Identicon : NSObject { @objc public static func generatePlaceholderIcon(seed: String, text: String, size: CGFloat) -> UIImage { let icon = PlaceholderIcon(seed: seed) + var content = text + if content.count > 2 && content.hasPrefix("05") { content.removeFirst(2) } + let initials: String = content .split(separator: " ") .compactMap { word in word.first.map { String($0) } } @@ -19,8 +22,10 @@ public final class Identicon : NSObject { content.substring(to: 2).uppercased() ) ) + let rect = CGRect(origin: CGPoint.zero, size: layer.frame.size) let renderer = UIGraphicsImageRenderer(size: rect.size) + return renderer.image { layer.render(in: $0.cgContext) } } } From b541666ef0430a1ea39361e0baa6032e676e20d1 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 6 May 2022 12:44:26 +1000 Subject: [PATCH 069/157] Got the ability to send message working again and other tweaks Tested and fixed a couple of issues with the disappearingMessages job Added a simple dependency system for jobs (primarily for the AttachmentUploadJob, but will likely be others later) Setup the AttachmentUploadJob again (looks like there are cases which use it) Prevented a possible infinite job deferral loop from causing the app to crash (the loop is still technically possible but the app will continue to run now) Updated the interactions unique constraints based on testing and discussions around how the serverHash works Deleted the legacy ReadReceipt handling (now managed via the 'interaction.wasRead' flag and 'SendReadReceiptsJob') Deleted the unused SSKIncrementingIdFinder --- Configuration.swift | 1 + Session.xcodeproj/project.pbxproj | 42 +-- Session/Closed Groups/NewClosedGroupVC.swift | 5 +- .../ExpandingAttachmentsButton.swift | 2 +- .../Input View/InputTextView.swift | 15 +- .../Conversations/Input View/InputView.swift | 6 +- .../Input View/MentionSelectionView.swift | 10 +- .../VoiceMessageRecordingView.swift | 4 +- Session/DMs/NewDMVC.swift | 5 +- .../MessageRequestsViewModel.swift | 3 + .../_001_InitialSetupMigration.swift | 30 +- .../Migrations/_003_YDBToGRDBMigration.swift | 8 +- .../Database/Models/Contact.swift | 6 +- .../Database/Models/GroupMember.swift | 3 +- .../Database/Models/Interaction.swift | 60 ++-- .../Database/Models/Profile.swift | 36 +- .../Database/Models/SessionThread.swift | 71 +++- SessionMessagingKit/Database/TSDatabaseView.h | 10 - SessionMessagingKit/Database/TSDatabaseView.m | 39 --- .../Jobs/AttachmentUploadJob.swift | 160 --------- .../Jobs/Types/AttachmentDownloadJob.swift | 8 +- .../Jobs/Types/AttachmentUploadJob.swift | 72 ++++ .../Jobs/Types/DisappearingMessagesJob.swift | 19 +- .../Jobs/Types/MessageReceiveJob.swift | 51 +-- .../Jobs/Types/MessageSendJob.swift | 6 +- .../MessageRequestResponse.swift | 13 +- .../Messages/Signal/TSIncomingMessage.h | 6 +- .../Messages/Signal/TSIncomingMessage.m | 49 --- .../Messages/Signal/TSInfoMessage.h | 4 +- .../Messages/Signal/TSInfoMessage.m | 28 -- .../Messages/Signal/TSInteraction.m | 6 +- .../VisibleMessage+Profile.swift | 10 + .../Visible Messages/VisibleMessage.swift | 4 +- .../Meta/SessionMessagingKit.h | 3 - .../Open Groups/OpenGroupAPIV2.swift | 17 +- .../Open Groups/OpenGroupManagerV2.swift | 27 +- .../MessageReceiver+Handling.swift | 314 ++++++++--------- .../Sending & Receiving/MessageReceiver.swift | 2 +- .../MessageSender+ClosedGroups.swift | 4 - .../MessageSender+Convenience.swift | 4 +- .../Sending & Receiving/MessageSender.swift | 22 +- .../Pollers/ClosedGroupPoller.swift | 26 +- .../Pollers/OpenGroupPollerV2.swift | 27 +- .../Sending & Receiving/Pollers/Poller.swift | 23 +- .../Quotes/TSQuotedMessage+Conversion.swift | 13 +- .../Read Tracking/OWSOutgoingReceiptManager.h | 24 -- .../Read Tracking/OWSOutgoingReceiptManager.m | 229 ------------- .../Read Tracking/OWSReadReceiptManager.h | 60 ---- .../Read Tracking/OWSReadReceiptManager.m | 322 ------------------ .../Read Tracking/OWSReadTracking.h | 37 -- SessionMessagingKit/Threads/TSThread.h | 5 - SessionMessagingKit/Threads/TSThread.m | 55 --- .../Utilities/Preferences.swift | 2 +- .../Utilities/ProfileManager.swift | 30 +- .../Utilities/SSKIncrementingIdFinder.swift | 27 -- SessionShareExtension/ShareVC.swift | 2 - .../_001_InitialSetupMigration.swift | 6 +- .../Models/SnodeReceivedMessageInfo.swift | 10 +- SessionSnodeKit/SnodeAPI.swift | 48 ++- .../Database/GRDBStorage.swift | 11 +- .../_001_InitialSetupMigration.swift | 12 + SessionUtilitiesKit/Database/Models/Job.swift | 20 ++ .../Database/Models/JobDependencies.swift | 40 +++ .../Database/Types/TypedTableAlias.swift | 26 +- .../General/Array+Utilities.swift | 8 + SessionUtilitiesKit/JobRunner/JobRunner.swift | 81 ++++- 66 files changed, 845 insertions(+), 1484 deletions(-) create mode 100644 Session/Home/Message Requests/MessageRequestsViewModel.swift delete mode 100644 SessionMessagingKit/Jobs/AttachmentUploadJob.swift create mode 100644 SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift delete mode 100644 SessionMessagingKit/Sending & Receiving/Read Tracking/OWSOutgoingReceiptManager.h delete mode 100644 SessionMessagingKit/Sending & Receiving/Read Tracking/OWSOutgoingReceiptManager.m delete mode 100644 SessionMessagingKit/Sending & Receiving/Read Tracking/OWSReadReceiptManager.h delete mode 100644 SessionMessagingKit/Sending & Receiving/Read Tracking/OWSReadReceiptManager.m delete mode 100644 SessionMessagingKit/Sending & Receiving/Read Tracking/OWSReadTracking.h delete mode 100644 SessionMessagingKit/Utilities/SSKIncrementingIdFinder.swift create mode 100644 SessionUtilitiesKit/Database/Models/JobDependencies.swift diff --git a/Configuration.swift b/Configuration.swift index cf8f34141..9b7ca4acc 100644 --- a/Configuration.swift +++ b/Configuration.swift @@ -40,6 +40,7 @@ public enum SNMessagingKit { // Just to make the external API nice JobRunner.add(executor: NotifyPushServerJob.self, for: .notifyPushServer) JobRunner.add(executor: SendReadReceiptsJob.self, for: .sendReadReceipts) JobRunner.add(executor: AttachmentDownloadJob.self, for: .attachmentDownload) + JobRunner.add(executor: AttachmentUploadJob.self, for: .attachmentUpload) SNMessagingKitConfiguration.shared = SNMessagingKitConfiguration(storage: storage) } diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index cd0593208..ed3152ae5 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -317,7 +317,6 @@ C32C5AB3256DBE8F003C73A2 /* TSInteraction.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDAE6255A580400E217F9 /* TSInteraction.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5AB4256DBE8F003C73A2 /* TSOutgoingMessage.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB48255A580C00E217F9 /* TSOutgoingMessage.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5AB5256DBE8F003C73A2 /* TSOutgoingMessage+Conversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = B84072952565E9F50037CB17 /* TSOutgoingMessage+Conversion.swift */; }; - C32C5ADF256DBFAA003C73A2 /* OWSReadTracking.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDAE1255A580400E217F9 /* OWSReadTracking.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5AF8256DC051003C73A2 /* OWSDisappearingMessagesConfiguration.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBA4255A581400E217F9 /* OWSDisappearingMessagesConfiguration.m */; }; C32C5B0A256DC076003C73A2 /* OWSDisappearingMessagesConfiguration.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDAD9255A580300E217F9 /* OWSDisappearingMessagesConfiguration.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5B1C256DC19D003C73A2 /* TSQuotedMessage.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB83255A581100E217F9 /* TSQuotedMessage.m */; }; @@ -331,8 +330,6 @@ C32C5B8D256DC565003C73A2 /* SSKEnvironment.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB31255A580A00E217F9 /* SSKEnvironment.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5BBA256DC7E3003C73A2 /* ProfileManagerProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBB9255A581600E217F9 /* ProfileManagerProtocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5BCC256DC830003C73A2 /* Storage+ClosedGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8D8F1372566120F0092EF10 /* Storage+ClosedGroups.swift */; }; - C32C5BDD256DC88D003C73A2 /* OWSReadReceiptManager.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA71255A57FA00E217F9 /* OWSReadReceiptManager.m */; }; - C32C5BE6256DC891003C73A2 /* OWSReadReceiptManager.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB1D255A580900E217F9 /* OWSReadReceiptManager.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5BEF256DC8EE003C73A2 /* OWSDisappearingMessagesJob.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBDD255A581900E217F9 /* OWSDisappearingMessagesJob.m */; }; C32C5BF8256DC8F6003C73A2 /* OWSDisappearingMessagesJob.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDA80255A57FC00E217F9 /* OWSDisappearingMessagesJob.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5C3D256DCBAF003C73A2 /* AppReadiness.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB75255A581000E217F9 /* AppReadiness.m */; }; @@ -347,8 +344,6 @@ C32C5D23256DD4C0003C73A2 /* Mention.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA7E255A57FB00E217F9 /* Mention.swift */; }; C32C5D24256DD4C0003C73A2 /* MentionsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA81255A57FC00E217F9 /* MentionsManager.swift */; }; C32C5D83256DD5B6003C73A2 /* SSKKeychainStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBBC255A581600E217F9 /* SSKKeychainStorage.swift */; }; - C32C5D9C256DD6DC003C73A2 /* OWSOutgoingReceiptManager.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB6F255A580F00E217F9 /* OWSOutgoingReceiptManager.m */; }; - C32C5DA5256DD6E5003C73A2 /* OWSOutgoingReceiptManager.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDABD255A580100E217F9 /* OWSOutgoingReceiptManager.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5DBF256DD743003C73A2 /* ClosedGroupPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB34255A580B00E217F9 /* ClosedGroupPoller.swift */; }; C32C5DC0256DD743003C73A2 /* Poller.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB3A255A580B00E217F9 /* Poller.swift */; }; C32C5DC9256DD935003C73A2 /* ProxiedContentDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAF2255A580500E217F9 /* ProxiedContentDownloader.swift */; }; @@ -369,7 +364,6 @@ C32C5EE5256DF506003C73A2 /* YapDatabaseConnection+OWS.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB5F255A580E00E217F9 /* YapDatabaseConnection+OWS.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5EEE256DF54E003C73A2 /* TSDatabaseView.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB46255A580C00E217F9 /* TSDatabaseView.m */; }; C32C5EF7256DF567003C73A2 /* TSDatabaseView.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB2C255A580A00E217F9 /* TSDatabaseView.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C32C5F11256DF79A003C73A2 /* SSKIncrementingIdFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB32255A580A00E217F9 /* SSKIncrementingIdFinder.swift */; }; C32C5FA1256DFED5003C73A2 /* NSArray+Functional.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAB8255A580100E217F9 /* NSArray+Functional.m */; }; C32C5FAA256DFED9003C73A2 /* NSArray+Functional.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB5C255A580E00E217F9 /* NSArray+Functional.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5FBB256E0206003C73A2 /* OWSBackgroundTask.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDC1B255A581F00E217F9 /* OWSBackgroundTask.m */; }; @@ -744,6 +738,8 @@ FD09799727FFA84A00936362 /* RecipientState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799627FFA84900936362 /* RecipientState.swift */; }; FD09799927FFC1A300936362 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799827FFC1A300936362 /* Attachment.swift */; }; FD09799B27FFC82D00936362 /* Quote.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799A27FFC82D00936362 /* Quote.swift */; }; + FD09C5E2282212B3000CE219 /* JobDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E1282212B3000CE219 /* JobDependencies.swift */; }; + FD09C5E428237209000CE219 /* MessageRequestsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E328237209000CE219 /* MessageRequestsViewModel.swift */; }; FD17D79927F40AB800122BE0 /* _003_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79827F40AB800122BE0 /* _003_YDBToGRDBMigration.swift */; }; FD17D79C27F40B2E00122BE0 /* SMKLegacyModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */; }; FD17D7A027F40CC800122BE0 /* _001_InitialSetupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79F27F40CC800122BE0 /* _001_InitialSetupMigration.swift */; }; @@ -1352,7 +1348,6 @@ C33FDA6D255A57FA00E217F9 /* YapDatabase+Promise.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "YapDatabase+Promise.swift"; sourceTree = ""; }; C33FDA6F255A57FA00E217F9 /* ReachabilityManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReachabilityManager.swift; sourceTree = ""; }; C33FDA70255A57FA00E217F9 /* TSMessage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSMessage.h; sourceTree = ""; }; - C33FDA71255A57FA00E217F9 /* OWSReadReceiptManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSReadReceiptManager.m; sourceTree = ""; }; C33FDA72255A57FA00E217F9 /* OWSFailedAttachmentDownloadsJob.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSFailedAttachmentDownloadsJob.h; sourceTree = ""; }; C33FDA73255A57FA00E217F9 /* ECKeyPair+Hexadecimal.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ECKeyPair+Hexadecimal.swift"; sourceTree = ""; }; C33FDA79255A57FB00E217F9 /* TSGroupThread.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSGroupThread.h; sourceTree = ""; }; @@ -1379,7 +1374,6 @@ C33FDAB7255A580100E217F9 /* OWSFailedMessagesJob.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSFailedMessagesJob.m; sourceTree = ""; }; C33FDAB8255A580100E217F9 /* NSArray+Functional.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSArray+Functional.m"; sourceTree = ""; }; C33FDAB9255A580100E217F9 /* OWSStorage+Subclass.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "OWSStorage+Subclass.h"; sourceTree = ""; }; - C33FDABD255A580100E217F9 /* OWSOutgoingReceiptManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSOutgoingReceiptManager.h; sourceTree = ""; }; C33FDABE255A580100E217F9 /* TSConstants.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSConstants.m; sourceTree = ""; }; C33FDAC0255A580100E217F9 /* OWSIncomingMessageFinder.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSIncomingMessageFinder.h; sourceTree = ""; }; C33FDAC1255A580100E217F9 /* NSSet+Functional.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSSet+Functional.m"; sourceTree = ""; }; @@ -1395,7 +1389,6 @@ C33FDADD255A580400E217F9 /* TSInfoMessage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSInfoMessage.h; sourceTree = ""; }; C33FDADE255A580400E217F9 /* SwiftSingletons.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftSingletons.swift; sourceTree = ""; }; C33FDAE0255A580400E217F9 /* ByteParser.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ByteParser.m; sourceTree = ""; }; - C33FDAE1255A580400E217F9 /* OWSReadTracking.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSReadTracking.h; sourceTree = ""; }; C33FDAE4255A580400E217F9 /* TSAttachmentStream.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSAttachmentStream.h; sourceTree = ""; }; C33FDAE6255A580400E217F9 /* TSInteraction.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSInteraction.h; sourceTree = ""; }; C33FDAE8255A580500E217F9 /* OWSMessageUtils.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSMessageUtils.h; sourceTree = ""; }; @@ -1418,7 +1411,6 @@ C33FDB17255A580800E217F9 /* FunctionalUtil.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FunctionalUtil.m; sourceTree = ""; }; C33FDB19255A580900E217F9 /* GroupUtilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupUtilities.swift; sourceTree = ""; }; C33FDB1C255A580900E217F9 /* UIImage+OWS.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "UIImage+OWS.h"; sourceTree = ""; }; - C33FDB1D255A580900E217F9 /* OWSReadReceiptManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSReadReceiptManager.h; sourceTree = ""; }; C33FDB1E255A580900E217F9 /* OWSIncomingMessageFinder.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSIncomingMessageFinder.m; sourceTree = ""; }; C33FDB20255A580900E217F9 /* TSDatabaseSecondaryIndexes.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSDatabaseSecondaryIndexes.m; sourceTree = ""; }; C33FDB22255A580900E217F9 /* OWSMediaUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSMediaUtils.swift; sourceTree = ""; }; @@ -1426,7 +1418,6 @@ C33FDB29255A580A00E217F9 /* NSData+Image.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSData+Image.h"; sourceTree = ""; }; C33FDB2C255A580A00E217F9 /* TSDatabaseView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSDatabaseView.h; sourceTree = ""; }; C33FDB31255A580A00E217F9 /* SSKEnvironment.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SSKEnvironment.h; sourceTree = ""; }; - C33FDB32255A580A00E217F9 /* SSKIncrementingIdFinder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SSKIncrementingIdFinder.swift; sourceTree = ""; }; C33FDB34255A580B00E217F9 /* ClosedGroupPoller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ClosedGroupPoller.swift; sourceTree = ""; }; C33FDB36255A580B00E217F9 /* Storage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = ""; }; C33FDB38255A580B00E217F9 /* OWSBackgroundTask.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSBackgroundTask.h; sourceTree = ""; }; @@ -1454,7 +1445,6 @@ C33FDB69255A580F00E217F9 /* FeatureFlags.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeatureFlags.swift; sourceTree = ""; }; C33FDB6B255A580F00E217F9 /* SNUserDefaults.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SNUserDefaults.swift; sourceTree = ""; }; C33FDB6C255A580F00E217F9 /* NSNotificationCenter+OWS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSNotificationCenter+OWS.m"; sourceTree = ""; }; - C33FDB6F255A580F00E217F9 /* OWSOutgoingReceiptManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSOutgoingReceiptManager.m; sourceTree = ""; }; C33FDB71255A581000E217F9 /* OWSMediaGalleryFinder.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSMediaGalleryFinder.m; sourceTree = ""; }; C33FDB73255A581000E217F9 /* TSGroupModel.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSGroupModel.m; sourceTree = ""; }; C33FDB75255A581000E217F9 /* AppReadiness.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppReadiness.m; sourceTree = ""; }; @@ -1816,6 +1806,8 @@ FD09799627FFA84900936362 /* RecipientState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipientState.swift; sourceTree = ""; }; FD09799827FFC1A300936362 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; FD09799A27FFC82D00936362 /* Quote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Quote.swift; sourceTree = ""; }; + FD09C5E1282212B3000CE219 /* JobDependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobDependencies.swift; sourceTree = ""; }; + FD09C5E328237209000CE219 /* MessageRequestsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsViewModel.swift; sourceTree = ""; }; FD17D79527F3E04600122BE0 /* _001_InitialSetupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _001_InitialSetupMigration.swift; sourceTree = ""; }; FD17D79827F40AB800122BE0 /* _003_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _003_YDBToGRDBMigration.swift; sourceTree = ""; }; FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMKLegacyModels.swift; sourceTree = ""; }; @@ -2556,7 +2548,6 @@ C379DC6825672B5E0002D4EB /* Notifications */, C32C59F8256DB5A6003C73A2 /* Pollers */, C32C5B1B256DC160003C73A2 /* Quotes */, - C32C5ADE256DBF7F003C73A2 /* Read Tracking */, C32C5995256DAF85003C73A2 /* Typing Indicators */, B8D0A25825E367AC00C1835E /* Notification+MessageReceiver.swift */, C300A5F12554B09800555489 /* MessageSender.swift */, @@ -2676,18 +2667,6 @@ path = Signal; sourceTree = ""; }; - C32C5ADE256DBF7F003C73A2 /* Read Tracking */ = { - isa = PBXGroup; - children = ( - C33FDABD255A580100E217F9 /* OWSOutgoingReceiptManager.h */, - C33FDB6F255A580F00E217F9 /* OWSOutgoingReceiptManager.m */, - C33FDB1D255A580900E217F9 /* OWSReadReceiptManager.h */, - C33FDA71255A57FA00E217F9 /* OWSReadReceiptManager.m */, - C33FDAE1255A580400E217F9 /* OWSReadTracking.h */, - ); - path = "Read Tracking"; - sourceTree = ""; - }; C32C5B01256DC054003C73A2 /* Expiration */ = { isa = PBXGroup; children = ( @@ -2871,7 +2850,6 @@ C352A2F425574B4700338F3E /* LegacyJob.swift */, C352A3922557883D00338F3E /* JobDelegate.swift */, C352A3882557876500338F3E /* JobQueue.swift */, - C352A35A2557824E00338F3E /* AttachmentUploadJob.swift */, ); path = Jobs; sourceTree = ""; @@ -3261,7 +3239,6 @@ C33FDB31255A580A00E217F9 /* SSKEnvironment.h */, C33FDAF4255A580600E217F9 /* SSKEnvironment.m */, FDF0B7542807C4BB004C14C5 /* SSKEnvironment.swift */, - C33FDB32255A580A00E217F9 /* SSKIncrementingIdFinder.swift */, C3A3A170256E1D25004D228D /* SSKReachabilityManager.swift */, C3ECBF7A257056B700EA7FCE /* Threading.swift */, C35D76DA26606303009AA5FB /* ThreadUpdateBatcher.swift */, @@ -3773,6 +3750,7 @@ children = ( FD17D7E427F6A09900122BE0 /* Identity.swift */, FDF0B73F280402C4004C14C5 /* Job.swift */, + FD09C5E1282212B3000CE219 /* JobDependencies.swift */, FD17D7CC27F546FF00122BE0 /* Setting.swift */, ); path = Models; @@ -3808,6 +3786,7 @@ FD659ABE27A7648200F12C02 /* Message Requests */ = { isa = PBXGroup; children = ( + FD09C5E328237209000CE219 /* MessageRequestsViewModel.swift */, FD659ABF27A7649600F12C02 /* MessageRequestsViewController.swift */, ); path = "Message Requests"; @@ -3843,6 +3822,7 @@ C352A32E2557549C00338F3E /* NotifyPushServerJob.swift */, FDF0B74E28079E5E004C14C5 /* SendReadReceiptsJob.swift */, C352A348255781F400338F3E /* AttachmentDownloadJob.swift */, + C352A35A2557824E00338F3E /* AttachmentUploadJob.swift */, ); path = Types; sourceTree = ""; @@ -3972,14 +3952,11 @@ C32C5FC4256E0209003C73A2 /* OWSBackgroundTask.h in Headers */, C32C59C0256DB41F003C73A2 /* TSThread.h in Headers */, C32C5AB3256DBE8F003C73A2 /* TSInteraction.h in Headers */, - C32C5DA5256DD6E5003C73A2 /* OWSOutgoingReceiptManager.h in Headers */, C32C5B0A256DC076003C73A2 /* OWSDisappearingMessagesConfiguration.h in Headers */, - C32C5ADF256DBFAA003C73A2 /* OWSReadTracking.h in Headers */, C3D9E486256775D20040E4F3 /* TSAttachmentPointer.h in Headers */, C32C5EF7256DF567003C73A2 /* TSDatabaseView.h in Headers */, B8856ED7256F1EB4001CE70E /* OWSPreferences.h in Headers */, C32A026C25A801AF000ED5D4 /* NSData+messagePadding.h in Headers */, - C32C5BE6256DC891003C73A2 /* OWSReadReceiptManager.h in Headers */, C32C5EC3256DE133003C73A2 /* OWSQuotedReplyModel.h in Headers */, C32C5BF8256DC8F6003C73A2 /* OWSDisappearingMessagesJob.h in Headers */, C32C5AAA256DBE8F003C73A2 /* TSIncomingMessage.h in Headers */, @@ -4864,6 +4841,7 @@ C3C2ABD22553C6C900C340D1 /* Data+SecureRandom.swift in Sources */, B8856E09256F1676001CE70E /* UIDevice+featureSupport.swift in Sources */, B8856DEF256F161F001CE70E /* NSString+SSK.m in Sources */, + FD09C5E2282212B3000CE219 /* JobDependencies.swift in Sources */, FD09796727F6B0B600936362 /* Sodium+Conversion.swift in Sources */, FD9004122818ABDC00ABAAF6 /* Job.swift in Sources */, FDF0B74D280664E9004C14C5 /* PersistableRecord+Utilities.swift in Sources */, @@ -4964,7 +4942,6 @@ C32C5B84256DC54F003C73A2 /* SSKEnvironment.m in Sources */, C3A3A108256E1A5C004D228D /* OWSIncomingMessageFinder.m in Sources */, C352A31325574F5200338F3E /* MessageReceiveJob.swift in Sources */, - C32C5BDD256DC88D003C73A2 /* OWSReadReceiptManager.m in Sources */, C3D9E3BF25676AD70040E4F3 /* TSAttachmentStream.m in Sources */, C3C2A7562553A3AB00C340D1 /* VisibleMessage+Quote.swift in Sources */, C3227FF6260AAD66006EA627 /* OpenGroupMessageV2.swift in Sources */, @@ -5028,7 +5005,6 @@ C32C5AB5256DBE8F003C73A2 /* TSOutgoingMessage+Conversion.swift in Sources */, C32C5E5B256DDF45003C73A2 /* OWSStorage.m in Sources */, C32C5E15256DDC78003C73A2 /* SSKPreferences.swift in Sources */, - C32C5D9C256DD6DC003C73A2 /* OWSOutgoingReceiptManager.m in Sources */, C32C5C4F256DCC36003C73A2 /* Storage+OpenGroups.swift in Sources */, FD6A7A692818BE7300035AC1 /* RetrieveDefaultOpenGroupRoomsJob.swift in Sources */, C3DA9C0725AE7396008F7C7E /* ConfigurationMessage.swift in Sources */, @@ -5082,7 +5058,6 @@ FD09799B27FFC82D00936362 /* Quote.swift in Sources */, C3A3A18A256E2092004D228D /* SignalRecipient.m in Sources */, C3C2A74425539EB700C340D1 /* Message.swift in Sources */, - C32C5F11256DF79A003C73A2 /* SSKIncrementingIdFinder.swift in Sources */, FD09798527FD1A6500936362 /* ClosedGroupKeyPair.swift in Sources */, C32C5DBF256DD743003C73A2 /* ClosedGroupPoller.swift in Sources */, C32C5EEE256DF54E003C73A2 /* TSDatabaseView.m in Sources */, @@ -5252,6 +5227,7 @@ C35E8AAE2485E51D00ACB629 /* IP2Country.swift in Sources */, B835249B25C3AB650089A44F /* VisibleMessageCell.swift in Sources */, 340FC8AE204DAC8D007AEB0F /* OWSSoundSettingsViewController.m in Sources */, + FD09C5E428237209000CE219 /* MessageRequestsViewModel.swift in Sources */, B8D0A25025E3678700C1835E /* LinkDeviceVC.swift in Sources */, B894D0752339EDCF00B4D94D /* NukeDataModal.swift in Sources */, 7B4C75CD26BB92060000AC89 /* DeletedMessageView.swift in Sources */, diff --git a/Session/Closed Groups/NewClosedGroupVC.swift b/Session/Closed Groups/NewClosedGroupVC.swift index 2558f1975..9f075b825 100644 --- a/Session/Closed Groups/NewClosedGroupVC.swift +++ b/Session/Closed Groups/NewClosedGroupVC.swift @@ -183,7 +183,7 @@ final class NewClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegat } self?.presentingViewController?.dismiss(animated: true, completion: nil) - SignalApp.shared().presentConversation(for: thread, action: .compose, animated: false) + SessionApp.presentConversation(for: thread.id, action: .compose, animated: false) } promise.catch(on: DispatchQueue.main) { [weak self] _ in self?.dismiss(animated: true, completion: nil) // Dismiss the loader @@ -199,6 +199,7 @@ final class NewClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegat @objc private func createNewDM() { presentingViewController?.dismiss(animated: true, completion: nil) - SignalApp.shared().homeViewController!.createNewDM() + + SessionApp.homeViewController.wrappedValue?.createNewDM() } } diff --git a/Session/Conversations/Input View/ExpandingAttachmentsButton.swift b/Session/Conversations/Input View/ExpandingAttachmentsButton.swift index 18c155f5d..9fbad5a1d 100644 --- a/Session/Conversations/Input View/ExpandingAttachmentsButton.swift +++ b/Session/Conversations/Input View/ExpandingAttachmentsButton.swift @@ -143,7 +143,7 @@ final class ExpandingAttachmentsButton : UIView, InputViewButtonDelegate { } } -// MARK: Delegate +// MARK: - Delegate protocol ExpandingAttachmentsButtonDelegate: AnyObject { diff --git a/Session/Conversations/Input View/InputTextView.swift b/Session/Conversations/Input View/InputTextView.swift index 69c61da67..13d7cb3aa 100644 --- a/Session/Conversations/Input View/InputTextView.swift +++ b/Session/Conversations/Input View/InputTextView.swift @@ -4,7 +4,7 @@ public final class InputTextView : UITextView, UITextViewDelegate { private let maxWidth: CGFloat private lazy var heightConstraint = self.set(.height, to: minHeight) - public override var text: String! { didSet { handleTextChanged() } } + public override var text: String? { didSet { handleTextChanged() } } // MARK: UI Components private lazy var placeholderLabel: UILabel = { @@ -79,21 +79,26 @@ public final class InputTextView : UITextView, UITextViewDelegate { private func handleTextChanged() { defer { snDelegate?.inputTextViewDidChangeContent(self) } - placeholderLabel.isHidden = !text.isEmpty + + placeholderLabel.isHidden = !(text ?? "").isEmpty + let height = frame.height let size = sizeThatFits(CGSize(width: maxWidth, height: .greatestFiniteMagnitude)) + // `textView.contentSize` isn't accurate when restoring a multiline draft, so we set it here manually self.contentSize = size let newHeight = size.height.clamp(minHeight, maxHeight) + guard newHeight != height else { return } + heightConstraint.constant = newHeight snDelegate?.inputTextViewDidChangeSize(self) } } -// MARK: Delegate -protocol InputTextViewDelegate : AnyObject { - +// MARK: - InputTextViewDelegate + +protocol InputTextViewDelegate: AnyObject { func inputTextViewDidChangeSize(_ inputTextView: InputTextView) func inputTextViewDidChangeContent(_ inputTextView: InputTextView) func didPasteImageFromPasteboard(_ inputTextView: InputTextView, image: UIImage) diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift index 8f9c0ff26..f5386c631 100644 --- a/Session/Conversations/Input View/InputView.swift +++ b/Session/Conversations/Input View/InputView.swift @@ -399,13 +399,15 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, delegate?.handleMentionSelected(mention, from: view) } - // MARK: Convenience + // MARK: - Convenience + private func container(for button: InputViewButton) -> UIView { - let result = UIView() + let result: UIView = UIView() result.addSubview(button) result.set(.width, to: InputViewButton.expandedSize) result.set(.height, to: InputViewButton.expandedSize) button.center(in: result) + return result } } diff --git a/Session/Conversations/Input View/MentionSelectionView.swift b/Session/Conversations/Input View/MentionSelectionView.swift index 327f50ec7..0028aa86d 100644 --- a/Session/Conversations/Input View/MentionSelectionView.swift +++ b/Session/Conversations/Input View/MentionSelectionView.swift @@ -157,11 +157,12 @@ private extension MentionSelectionView { separator.pin(.bottom, to: .bottom, of: self) } - // MARK: Updating + // MARK: - Updating + private func update() { displayNameLabel.text = mentionCandidate.displayName - profilePictureView.publicKey = mentionCandidate.publicKey - profilePictureView.update() + profilePictureView.update(for: mentionCandidate.publicKey) + if let server = openGroupServer, let room = openGroupRoom { let isUserModerator = OpenGroupAPIV2.isUserModerator(mentionCandidate.publicKey, for: room, on: server) moderatorIconImageView.isHidden = !isUserModerator @@ -174,7 +175,6 @@ private extension MentionSelectionView { // MARK: - Delegate -protocol MentionSelectionViewDelegate : class { - +protocol MentionSelectionViewDelegate: AnyObject { func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView) } diff --git a/Session/Conversations/Input View/VoiceMessageRecordingView.swift b/Session/Conversations/Input View/VoiceMessageRecordingView.swift index 8bf858b83..5a3f2a71f 100644 --- a/Session/Conversations/Input View/VoiceMessageRecordingView.swift +++ b/Session/Conversations/Input View/VoiceMessageRecordingView.swift @@ -396,9 +396,9 @@ extension VoiceMessageRecordingView { } } -// MARK: Delegate -protocol VoiceMessageRecordingViewDelegate : class { +// MARK: - Delegate +protocol VoiceMessageRecordingViewDelegate: AnyObject { func startVoiceMessageRecording() func endVoiceMessageRecording() func cancelVoiceMessageRecording() diff --git a/Session/DMs/NewDMVC.swift b/Session/DMs/NewDMVC.swift index 036853adc..e532e7e83 100644 --- a/Session/DMs/NewDMVC.swift +++ b/Session/DMs/NewDMVC.swift @@ -1,7 +1,9 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import GRDB import Curve25519Kit +import SessionMessagingKit import SessionUtilitiesKit final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControllerDelegate, OWSQRScannerDelegate { @@ -174,7 +176,8 @@ final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControll private func startNewDM(with sessionID: String) { let thread = TSContactThread.getOrCreateThread(contactSessionID: sessionID) presentingViewController?.dismiss(animated: true, completion: nil) - SignalApp.shared().presentConversation(for: thread, action: .compose, animated: false) + + SessionApp.presentConversation(for: sessionId, action: .compose, animated: false) } } diff --git a/Session/Home/Message Requests/MessageRequestsViewModel.swift b/Session/Home/Message Requests/MessageRequestsViewModel.swift new file mode 100644 index 000000000..0a8ab0dac --- /dev/null +++ b/Session/Home/Message Requests/MessageRequestsViewModel.swift @@ -0,0 +1,3 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation diff --git a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift index 67d6bbca2..2e208ee71 100644 --- a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -164,25 +164,25 @@ enum _001_InitialSetupMigration: Migration { .defaults(to: false) t.column(.openGroupWhisperTo, .text) - /// Note: The below unique constraints are added to prevent messages being duplicated, we need - /// multiple constraints because `null` is not unique in SQLite which means any unique constraint - /// which contained a nullable column would not be seen as unique if the value is null (this is good to - /// avoid outgoing message from conflicting due to not having a `serverHash` but bad when different - /// columns are only unique in certain circumstances) - /// - /// The values have the following behaviours: + /// The below unique constraints are added to prevent messages being duplicated, we need + /// multiple constraints to handle the different situations which can result in duplicate messages, + /// the following describes the different cases where messages can be duplicated: /// /// Threads with variants: [`contact`, `closedGroup`]: - /// `threadId` - Unique per thread - /// `serverHash` - Unique per message for service-node-based messages - /// **Note:** Some InfoMessage's will have this intentionally left `null` - /// as we want to ignore any collisions and re-process them - /// `timestampMs` - Very low chance of collision (especially combined with other two) + /// "Sync" messages (messages we resend to the current to ensure it appears on all linked devices): + /// `threadId` - Unique per thread + /// `authorId` - Unique per user + /// `timestampMs` - Very low chance of collision (especially combined with other two) + /// + /// Standard messages: + /// `threadId` - Unique per thread + /// `serverHash` - Unique per message (deterministically generated) /// /// Threads with variants: [`openGroup`]: - /// `threadId` - Unique per thread - /// `openGroupServerMessageId` - Unique for VisibleMessage's on an OpenGroup server - t.uniqueKey([.threadId, .serverHash, .timestampMs]) + /// `threadId` - Unique per thread + /// `openGroupServerMessageId` - Unique for VisibleMessage's on an OpenGroup server + t.uniqueKey([.threadId, .authorId, .timestampMs]) + t.uniqueKey([.threadId, .serverHash]) t.uniqueKey([.threadId, .openGroupServerMessageId]) } diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 59e042b5c..e508a5262 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -1082,11 +1082,11 @@ enum _003_YDBToGRDBMigration: Migration { behaviour: .runOnce, nextRunTimestamp: 0, threadId: threadId, + // Note: There are some cases where there isn't a link between a + // 'MessageSendJob' and an interaction (eg. ConfigurationMessage), + // in these cases the 'interactionId' value will be nil + interactionId: interactionId, details: MessageSendJob.Details( - // Note: There are some cases where there isn't a link between a - // 'MessageSendJob' and an interaction (eg. ConfigurationMessage), - // in these cases the 'interactionId' value will be nil - interactionId: interactionId, destination: legacyJob.destination, message: legacyJob.message.toNonLegacy() ) diff --git a/SessionMessagingKit/Database/Models/Contact.swift b/SessionMessagingKit/Database/Models/Contact.swift index 26574da08..92e7a5159 100644 --- a/SessionMessagingKit/Database/Models/Contact.swift +++ b/SessionMessagingKit/Database/Models/Contact.swift @@ -4,7 +4,7 @@ import Foundation import GRDB import SessionUtilitiesKit -public struct Contact: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct Contact: Codable, Identifiable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "contact" } internal static let threadForeignKey = ForeignKey([Columns.id], to: [SessionThread.Columns.id]) public static let profile = hasOne(Profile.self, using: Profile.contactForeignKey) @@ -106,6 +106,10 @@ public extension Contact { // MARK: - GRDB Interactions public extension Contact { + /// Fetches or creates a Contact for the specified user + /// + /// **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) -> Contact { return ((try? fetchOne(db, id: id)) ?? Contact(id: id)) } diff --git a/SessionMessagingKit/Database/Models/GroupMember.swift b/SessionMessagingKit/Database/Models/GroupMember.swift index f1f8b26f1..b9024564b 100644 --- a/SessionMessagingKit/Database/Models/GroupMember.swift +++ b/SessionMessagingKit/Database/Models/GroupMember.swift @@ -8,10 +8,9 @@ public struct GroupMember: Codable, FetchableRecord, PersistableRecord, TableRec public static var databaseTableName: String { "groupMember" } internal static let openGroupForeignKey = ForeignKey([Columns.groupId], to: [OpenGroup.Columns.threadId]) internal static let closedGroupForeignKey = ForeignKey([Columns.groupId], to: [ClosedGroup.Columns.threadId]) - internal static let profileForeignKey = ForeignKey([Columns.profileId], to: [Profile.Columns.id]) public static let openGroup = belongsTo(OpenGroup.self, using: openGroupForeignKey) public static let closedGroup = belongsTo(ClosedGroup.self, using: closedGroupForeignKey) - public static let profile = hasOne(Profile.self, using: profileForeignKey) + public static let profile = hasOne(Profile.self, using: Profile.groupMemberForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index daaaf98b0..c18aaba83 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -11,7 +11,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu [Columns.linkPreviewUrl], to: [LinkPreview.Columns.url] ) - internal static let thread = belongsTo(SessionThread.self, using: threadForeignKey) + public static let thread = belongsTo(SessionThread.self, using: threadForeignKey) public static let profile = hasOne(Profile.self, using: Profile.interactionForeignKey) internal static let interactionAttachments = hasMany( InteractionAttachment.self, @@ -24,11 +24,13 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu ) public static let quote = hasOne(Quote.self, using: Quote.interactionForeignKey) - /// Whenever using this `linkPreview` association make sure to filter the result using `.filter(literal: Interaction.linkPreviewFilterLiteral)` to ensure the correct LinkPreview is returned + /// Whenever using this `linkPreview` association make sure to filter the result using + /// `.filter(literal: Interaction.linkPreviewFilterLiteral)` to ensure the correct LinkPreview is returned public static let linkPreview = hasOne(LinkPreview.self, using: LinkPreview.interactionForeignKey) public static let linkPreviewFilterLiteral: SQL = { let interaction: TypedTableAlias = TypedTableAlias() let linkPreview: TypedTableAlias = TypedTableAlias() + return "(ROUND((\(interaction[.timestampMs]) / 1000 / 100000) - 0.5) * 100000) = \(linkPreview[.timestamp])" }() public static let recipientStates = hasMany(RecipientState.self, using: RecipientState.interactionForeignKey) @@ -95,8 +97,11 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu /// The hash returned by the server when this message was created on the server /// - /// **Note:** This will only be populated for `standardIncoming`/`standardOutgoing` interactions - /// from either `contact` or `closedGroup` threads + /// **Notes:** + /// - This will only be populated for `standardIncoming`/`standardOutgoing` interactions from + /// either `contact` or `closedGroup` threads + /// - This value will differ for "sync" messages (messages we resend to the current to ensure it appears + /// on all linked devices) because the data in the message is slightly different public let serverHash: String? /// The id of the thread that this interaction belongs to (used to expose the `thread` variable) @@ -182,6 +187,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu public var linkPreview: QueryInterfaceRequest { /// **Note:** This equation **MUST** match the `linkPreviewFilterLiteral` logic let roundedTimestamp: Double = (round(((Double(timestampMs) / 1000) / 100000) - 0.5) * 100000) + return request(for: Interaction.linkPreview) .filter(LinkPreview.Columns.timestamp == roundedTimestamp) } @@ -388,33 +394,32 @@ public extension Interaction { // MARK: - GRDB Interactions public extension Interaction { - /// Immutable version of the `markAsRead(_:includingOlder:trySendReadReceipt:)` function - func markingAsRead(_ db: Database, includingOlder: Bool, trySendReadReceipt: Bool) throws -> Interaction { - var updatedInteraction: Interaction = self - try updatedInteraction.markAsRead(db, includingOlder: includingOlder, trySendReadReceipt: trySendReadReceipt) - - return updatedInteraction - } - /// This will update the `wasRead` state the the interaction /// /// - Parameters + /// - interactionId: The id of the specific interaction to mark as read + /// - threadId: The id of the thread the interaction belongs to /// - includingOlder: Setting this to `true` will updated the `wasRead` flag for all older interactions as well /// - trySendReadReceipt: Setting this to `true` will schedule a `ReadReceiptJob` - mutating func markAsRead(_ db: Database, includingOlder: Bool, trySendReadReceipt: Bool) throws { + static func markAsRead( + _ db: Database, + interactionId: Int64?, + threadId: String, + includingOlder: Bool, + trySendReadReceipt: Bool + ) throws { + guard let interactionId: Int64 = interactionId else { return } + // Once all of the below is done schedule the jobs func scheduleJobs(interactionIds: [Int64]) { // Add the 'DisappearingMessagesJob' if needed - this will update any expiring // messages `expiresStartedAtMs` values JobRunner.upsert( db, - job: Job( - variant: .disappearingMessages, - details: DisappearingMessagesJob.updateNextRunIfNeeded( - db, - interactionIds: interactionIds, - startedAtMs: (Date().timeIntervalSince1970 * 1000) - ) + job: DisappearingMessagesJob.updateNextRunIfNeeded( + db, + interactionIds: interactionIds, + startedAtMs: (Date().timeIntervalSince1970 * 1000) ) ) @@ -433,22 +438,17 @@ public extension Interaction { // If we aren't including older interactions then update and save the current one guard includingOlder else { - let updatedInteraction: Interaction = try self - .with(wasRead: true) - .saved(db) + _ = try Interaction + .filter(id: interactionId) + .updateAll(db, Columns.wasRead.set(to: true)) - guard let id: Int64 = updatedInteraction.id else { throw GRDBStorageError.objectNotFound } - - scheduleJobs(interactionIds: [id]) + scheduleJobs(interactionIds: [interactionId]) return } - // Need an id in order to continue - guard let id: Int64 = self.id else { throw GRDBStorageError.objectNotFound } - let interactionQuery = Interaction .filter(Columns.threadId == threadId) - .filter(Columns.id <= id) + .filter(Columns.id <= interactionId) // The `wasRead` flag doesn't apply to `standardOutgoing` or `standardIncomingDeleted` .filter(Columns.variant != Variant.standardOutgoing && Columns.variant != Variant.standardIncomingDeleted) diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index b404d166a..ddcd59711 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -9,7 +9,8 @@ public struct Profile: Codable, Identifiable, Equatable, FetchableRecord, Persis public static var databaseTableName: String { "profile" } internal static let interactionForeignKey = ForeignKey([Columns.id], to: [Interaction.Columns.authorId]) internal static let contactForeignKey = ForeignKey([Columns.id], to: [Contact.Columns.id]) - public static let groupMembers = hasMany(GroupMember.self, using: GroupMember.profileForeignKey) + internal static let groupMemberForeignKey = ForeignKey([Columns.id], to: [GroupMember.Columns.profileId]) + public static let groupMembers = hasMany(GroupMember.self, using: groupMemberForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -274,6 +275,10 @@ public extension Profile { ) } + /// Fetches or creates a Profile for the current user + /// + /// **Note:** This method intentionally does **not** save the newly created Profile, + /// it will need to be explicitly saved after calling static func fetchOrCreateCurrentUser() -> Profile { var userPublicKey: String = "" @@ -286,6 +291,10 @@ public extension Profile { return (exisingProfile ?? defaultFor(userPublicKey)) } + /// Fetches or creates a Profile for the current user + /// + /// **Note:** This method intentionally does **not** save the newly created Profile, + /// it will need to be explicitly saved after calling static func fetchOrCreateCurrentUser(_ db: Database) -> Profile { let userPublicKey: String = getUserHexEncodedPublicKey(db) @@ -295,6 +304,10 @@ public extension Profile { ) } + /// Fetches or creates a Profile for the specified user + /// + /// **Note:** This method intentionally does **not** save the newly created Profile, + /// it will need to be explicitly saved after calling static func fetchOrCreate(id: String) -> Profile { let exisingProfile: Profile? = GRDBStorage.shared.read { db in try Profile.fetchOne(db, id: id) @@ -303,6 +316,10 @@ public extension Profile { return (exisingProfile ?? defaultFor(id)) } + /// Fetches or creates a Profile for the specified user + /// + /// **Note:** This method intentionally does **not** save the newly created Profile, + /// it will need to be explicitly saved after calling static func fetchOrCreate(_ db: Database, id: String) -> Profile { return ( (try? Profile.fetchOne(db, id: id)) ?? @@ -357,8 +374,23 @@ public extension Profile { /// The name to display in the UI func displayName(for context: Context = .regular) -> String { + return Profile.displayName(for: context, id: id, name: name, nickname: nickname) + } + + static func displayName(for threadVariant: SessionThread.Variant, id: String, name: String?, nickname: String?) -> String { + return Profile.displayName( + for: (threadVariant == .openGroup ? .openGroup : .regular), + id: id, + name: name, + nickname: nickname + ) + } + + static func displayName(for context: Context, id: String, name: String?, nickname: String?) -> String { if let nickname: String = nickname { return nickname } + guard let name: String = name else { return Profile.truncated(id: id, truncating: .start) } + switch context { case .regular: return name @@ -366,7 +398,7 @@ public extension Profile { // In open groups, where it's more likely that multiple users have the same name, // we display a bit of the Session ID after a user's display name for added context return "\(name) (\(Profile.truncated(id: id, truncating: .start)))" - } + } } } diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index 2f9e95126..8896bef79 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -6,9 +6,9 @@ import SessionUtilitiesKit public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "thread" } - private static let contact = hasOne(Contact.self, using: Contact.threadForeignKey) - private static let closedGroup = hasOne(ClosedGroup.self, using: ClosedGroup.threadForeignKey) - private static let openGroup = hasOne(OpenGroup.self, using: OpenGroup.threadForeignKey) + public static let contact = hasOne(Contact.self, using: Contact.threadForeignKey) + public static let closedGroup = hasOne(ClosedGroup.self, using: ClosedGroup.threadForeignKey) + public static let openGroup = hasOne(OpenGroup.self, using: OpenGroup.threadForeignKey) private static let disappearingMessagesConfiguration = hasOne( DisappearingMessagesConfiguration.self, using: DisappearingMessagesConfiguration.threadForeignKey @@ -136,14 +136,15 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, public extension SessionThread { func with( - shouldBeVisible: Bool? = nil + shouldBeVisible: Bool? = nil, + isPinned: Bool? = nil ) -> SessionThread { return SessionThread( id: id, variant: variant, creationDateTimestamp: creationDateTimestamp, shouldBeVisible: (shouldBeVisible ?? self.shouldBeVisible), - isPinned: isPinned, + isPinned: (isPinned ?? self.isPinned), messageDraft: messageDraft, notificationMode: notificationMode, notificationSound: notificationSound, @@ -155,9 +156,18 @@ public extension SessionThread { // MARK: - GRDB Interactions public extension SessionThread { - /// The `variant` will be ignored if an existing thread is found - static func fetchOrCreate(_ db: Database, id: ID, variant: Variant) -> SessionThread { - return ((try? fetchOne(db, id: id)) ?? SessionThread(id: id, variant: variant)) + /// Fetches or creates a SessionThread with the specified id and variant + /// + /// **Notes:** + /// - The `variant` will be ignored if an existing thread is found + /// - This method **will** save the newly created SessionThread to the database + static func fetchOrCreate(_ db: Database, id: ID, variant: Variant) throws -> SessionThread { + guard let existingThread: SessionThread = try? fetchOne(db, id: id) else { + return try SessionThread(id: id, variant: variant) + .saved(db) + } + + return existingThread } static func messageRequestThreads(_ db: Database) -> QueryInterfaceRequest { @@ -184,6 +194,51 @@ public extension SessionThread { // MARK: - Convenience public extension SessionThread { + + /// This method can be used to create a query based on whether a thread is the note to self thread + static func isNoteToSelf(userPublicKey: String) -> SQLSpecificExpressible { + return ( + SessionThread.Columns.variant == SessionThread.Variant.contact && + SessionThread.Columns.id == userPublicKey + ) + } + + /// This method can be used to filter a thread query to only include messages requests + /// + /// **Note:** In order to use this filter you **MUST** have a `joining(required/optional:)` to the + /// `SessionThread.contact` association or it won't work + static func isMessageRequest(userPublicKey: String) -> SQLSpecificExpressible { + let contactAlias: TypedTableAlias = TypedTableAlias() + + return ( + SessionThread.Columns.shouldBeVisible == true && + SessionThread.Columns.variant == SessionThread.Variant.contact && + SessionThread.Columns.id != userPublicKey && // Note to self + ( + // Note: Doing a '!= true' check doesn't work properly so we need + // to explicitly do this + contactAlias[.isApproved] == nil || + contactAlias[.isApproved] == false + ) + ) + } + + /// This method can be used to filter a thread query to exclude messages requests + /// + /// **Note:** In order to use this filter you **MUST** have a `joining(required/optional:)` to the + /// `SessionThread.contact` association or it won't work + static func isNotMessageRequest(userPublicKey: String) -> SQLSpecificExpressible { + let contactAlias: TypedTableAlias = TypedTableAlias() + + return ( + SessionThread.Columns.shouldBeVisible == true && ( + SessionThread.Columns.variant != SessionThread.Variant.contact || + SessionThread.Columns.id == userPublicKey || // Note to self + contactAlias[.isApproved] == true + ) + ) + } + func isNoteToSelf(_ db: Database? = nil) -> Bool { return ( variant == .contact && diff --git a/SessionMessagingKit/Database/TSDatabaseView.h b/SessionMessagingKit/Database/TSDatabaseView.h index 817347368..f6244dba2 100644 --- a/SessionMessagingKit/Database/TSDatabaseView.h +++ b/SessionMessagingKit/Database/TSDatabaseView.h @@ -57,16 +57,6 @@ extern NSString *const TSLazyRestoreAttachmentsDatabaseViewExtensionName; + (void)asyncRegisterThreadOutgoingMessagesDatabaseView:(OWSStorage *)storage; -// Instances of OWSReadTracking for wasRead is NO and shouldAffectUnreadCounts is YES. -// -// Should be used for "unread message counts". -+ (void)asyncRegisterUnreadDatabaseView:(OWSStorage *)storage; - -// Should be used for "unread indicator". -// -// Instances of OWSReadTracking for wasRead is NO. -+ (void)asyncRegisterUnseenDatabaseView:(OWSStorage *)storage; - // Should be used for "mention indicator". // // Instances of OWSReadTracking for wasRead is NO and isUserMentioned is YES. diff --git a/SessionMessagingKit/Database/TSDatabaseView.m b/SessionMessagingKit/Database/TSDatabaseView.m index af8a31f81..f2beee9c9 100644 --- a/SessionMessagingKit/Database/TSDatabaseView.m +++ b/SessionMessagingKit/Database/TSDatabaseView.m @@ -3,7 +3,6 @@ // #import "TSDatabaseView.h" -#import "OWSReadTracking.h" #import "TSAttachment.h" #import "TSAttachmentPointer.h" #import "TSIncomingMessage.h" @@ -96,44 +95,6 @@ NSString *const TSLazyRestoreAttachmentsGroup = @"TSLazyRestoreAttachmentsGroup" [storage asyncRegisterExtension:view withName:viewName]; } -+ (void)asyncRegisterUnreadDatabaseView:(OWSStorage *)storage -{ - YapDatabaseViewGrouping *viewGrouping = [YapDatabaseViewGrouping withObjectBlock:^NSString *( - YapDatabaseReadTransaction *transaction, NSString *collection, NSString *key, id object) { - if ([object conformsToProtocol:@protocol(OWSReadTracking)]) { - id possiblyRead = (id)object; - if (!possiblyRead.wasRead && possiblyRead.shouldAffectUnreadCounts) { - return possiblyRead.uniqueThreadId; - } - } - return nil; - }]; - - [self registerMessageDatabaseViewWithName:TSUnreadDatabaseViewExtensionName - viewGrouping:viewGrouping - version:@"2" - storage:storage]; -} - -+ (void)asyncRegisterUnseenDatabaseView:(OWSStorage *)storage -{ - YapDatabaseViewGrouping *viewGrouping = [YapDatabaseViewGrouping withObjectBlock:^NSString *( - YapDatabaseReadTransaction *transaction, NSString *collection, NSString *key, id object) { - if ([object conformsToProtocol:@protocol(OWSReadTracking)]) { - id possiblyRead = (id)object; - if (!possiblyRead.wasRead) { - return possiblyRead.uniqueThreadId; - } - } - return nil; - }]; - - [self registerMessageDatabaseViewWithName:TSUnseenDatabaseViewExtensionName - viewGrouping:viewGrouping - version:@"2" - storage:storage]; -} - + (void)asyncRegisterUnreadMentionDatabaseView:(OWSStorage *)storage { YapDatabaseViewGrouping *viewGrouping = [YapDatabaseViewGrouping withObjectBlock:^NSString *( diff --git a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift deleted file mode 100644 index a6c9bfd7b..000000000 --- a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift +++ /dev/null @@ -1,160 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import PromiseKit -import SignalCoreKit -import SessionUtilitiesKit - -public final class AttachmentUploadJob : NSObject, Job, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility - public let attachmentID: String - public let threadID: String - public let message: Message - public let messageSendJobID: String - public var delegate: JobDelegate? - public var id: String? - public var failureCount: UInt = 0 - - public enum Error : LocalizedError { - case noAttachment - case encryptionFailed - - public var errorDescription: String? { - switch self { - case .noAttachment: return "No such attachment." - case .encryptionFailed: return "Couldn't encrypt file." - } - } - } - - // MARK: Settings - public class var collection: String { return "AttachmentUploadJobCollection" } - public static let maxFailureCount: UInt = 20 - - // MARK: Initialization - public init(attachmentID: String, threadID: String, message: Message, messageSendJobID: String) { - self.attachmentID = attachmentID - self.threadID = threadID - self.message = message - self.messageSendJobID = messageSendJobID - } - - // MARK: Coding - public init?(coder: NSCoder) { - guard let attachmentID = coder.decodeObject(forKey: "attachmentID") as! String?, - let threadID = coder.decodeObject(forKey: "threadID") as! String?, - let message = coder.decodeObject(forKey: "message") as! Message?, - let messageSendJobID = coder.decodeObject(forKey: "messageSendJobID") as! String?, - let id = coder.decodeObject(forKey: "id") as! String? else { return nil } - self.attachmentID = attachmentID - self.threadID = threadID - self.message = message - self.messageSendJobID = messageSendJobID - self.id = id - self.failureCount = coder.decodeObject(forKey: "failureCount") as! UInt? ?? 0 - } - - public func encode(with coder: NSCoder) { - coder.encode(attachmentID, forKey: "attachmentID") - coder.encode(threadID, forKey: "threadID") - coder.encode(message, forKey: "message") - coder.encode(messageSendJobID, forKey: "messageSendJobID") - coder.encode(id, forKey: "id") - coder.encode(failureCount, forKey: "failureCount") - } - - // MARK: Running - public func execute() { - if let id = id { - JobQueue.currentlyExecutingJobs.insert(id) - } - guard let stream = TSAttachment.fetch(uniqueId: attachmentID) as? TSAttachmentStream else { - return handleFailure(error: Error.noAttachment) - } - guard !stream.isUploaded else { return handleSuccess() } // Should never occur - let storage = SNMessagingKitConfiguration.shared.storage - if let v2OpenGroup = storage.getV2OpenGroup(for: threadID) { - AttachmentUploadJob.upload(stream, using: { data in return OpenGroupAPIV2.upload(data, to: v2OpenGroup.room, on: v2OpenGroup.server) }, encrypt: false, onSuccess: handleSuccess, onFailure: handleFailure) - } else { - AttachmentUploadJob.upload(stream, using: FileServerAPIV2.upload, encrypt: true, onSuccess: handleSuccess, onFailure: handleFailure) - } - } - - public static func upload(_ stream: TSAttachmentStream, using upload: (Data) -> Promise, encrypt: Bool, onSuccess: (() -> Void)?, onFailure: ((Swift.Error) -> Void)?) { - // Get the attachment - guard var data = try? stream.readDataFromFile() else { - SNLog("Couldn't read attachment from disk.") - onFailure?(Error.noAttachment); return - } - // Encrypt the attachment if needed - if encrypt { - var encryptionKey = NSData() - var digest = NSData() - guard let ciphertext = Cryptography.encryptAttachmentData(data, shouldPad: true, outKey: &encryptionKey, outDigest: &digest) else { - SNLog("Couldn't encrypt attachment.") - onFailure?(Error.encryptionFailed); return - } - stream.encryptionKey = encryptionKey as Data - stream.digest = digest as Data - data = ciphertext - } - // Check the file size - SNLog("File size: \(data.count) bytes.") - if Double(data.count) > Double(FileServerAPIV2.maxFileSize) / FileServerAPIV2.fileSizeORMultiplier { - onFailure?(FileServerAPIV2.Error.maxFileSizeExceeded); return - } - // Send the request - stream.isUploaded = false - stream.save() - upload(data).done(on: DispatchQueue.global(qos: .userInitiated)) { fileID in - let downloadURL = "\(FileServerAPIV2.server)/files/\(fileID)" - stream.serverId = fileID - stream.isUploaded = true - stream.downloadURL = downloadURL - stream.save() - onSuccess?() - }.catch { error in - onFailure?(error) - } - } - - private func handleSuccess() { - SNLog("Attachment uploaded successfully.") - delegate?.handleJobSucceeded(self) - SNMessagingKitConfiguration.shared.storage.resumeMessageSendJobIfNeeded(messageSendJobID) - Storage.shared.write(with: { transaction in - var message: TSMessage? - let transaction = transaction as! YapDatabaseReadWriteTransaction - TSDatabaseSecondaryIndexes.enumerateMessages(withTimestamp: self.message.sentTimestamp!, with: { _, key, _ in - message = TSMessage.fetch(uniqueId: key, transaction: transaction) - }, using: transaction) - if let message = message { - MessageInvalidator.invalidate(message, with: transaction) - } - }, completion: { }) - } - - private func handlePermanentFailure(error: Swift.Error) { - SNLog("Attachment upload failed permanently due to error: \(error).") - delegate?.handleJobFailedPermanently(self, with: error) - failAssociatedMessageSendJob(with: error) - } - - private func handleFailure(error: Swift.Error) { - SNLog("Attachment upload failed due to error: \(error).") - delegate?.handleJobFailed(self, with: error) - if failureCount + 1 == AttachmentUploadJob.maxFailureCount { - failAssociatedMessageSendJob(with: error) - } - } - - private func failAssociatedMessageSendJob(with error: Swift.Error) { - let storage = SNMessagingKitConfiguration.shared.storage - let messageSendJob = storage.getMessageSendJob(for: messageSendJobID) - storage.write(with: { transaction in // Intentionally capture self - MessageSender.handleFailedMessageSend(self.message, with: error, using: transaction) - if let messageSendJob = messageSendJob { - storage.markJobAsFailed(messageSendJob, using: transaction) - } - }, completion: { }) - } -} - diff --git a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift index a706bf68d..5fbcd74a5 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift @@ -27,9 +27,11 @@ public enum AttachmentDownloadJob: JobExecutor { failure(job, JobRunnerError.missingRequiredDetails, false) return } - guard attachment.state != .downloaded else { - // If there is a bug elsewhere in the code it's possible for an AttachmentDownloadJob to be created - // for an attachment that is already downloaded - if it is just succeed immediately + + // Due to the complex nature of jobs and how attachments can be reused it's possible for + // and AttachmentDownloadJob to get created for an attachment which has already been + // downloaded/uploaded so in those cases just succeed immediately + guard attachment.state != .downloaded && attachment.state != .uploaded else { success(job, false) return } diff --git a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift new file mode 100644 index 000000000..4e3b9a7d2 --- /dev/null +++ b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift @@ -0,0 +1,72 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import PromiseKit +import SignalCoreKit +import SessionUtilitiesKit + +public enum AttachmentUploadJob: JobExecutor { + public static var maxFailureCount: Int = 10 + public static var requiresThreadId: Bool = true + public static let requiresInteractionId: Bool = true + public static func run( + _ job: Job, + success: @escaping (Job, Bool) -> (), + failure: @escaping (Job, Error?, Bool) -> (), + deferred: @escaping (Job) -> () + ) { + guard + let threadId: String = job.threadId, + let detailsData: Data = job.details, + let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData), + let (attachment, openGroup): (Attachment, OpenGroup?) = GRDBStorage.shared.read({ db in + guard let attachment: Attachment = try Attachment.fetchOne(db, id: details.attachmentId) else { + return nil + } + + return (attachment, try OpenGroup.fetchOne(db, id: threadId)) + }) + else { + failure(job, JobRunnerError.missingRequiredDetails, false) + return + } + + attachment.upload( + using: { data in + if let openGroup: OpenGroup = openGroup { + return OpenGroupAPIV2.upload(data, to: openGroup.room, on: openGroup.server) + } + + return FileServerAPIV2.upload(data) + }, + encrypt: (openGroup == nil), + success: { success(job, false) }, + failure: { error in failure(job, error, false) } + ) + } +} + +// MARK: - AttachmentUploadJob.Details + +extension AttachmentUploadJob { + public struct Details: Codable { + public let attachmentId: String + + public init(attachmentId: String) { + self.attachmentId = attachmentId + } + } + + public enum AttachmentUploadError: LocalizedError { + case noAttachment + case encryptionFailed + + public var errorDescription: String? { + switch self { + case .noAttachment: return "No such attachment." + case .encryptionFailed: return "Couldn't encrypt file." + } + } + } +} diff --git a/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift b/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift index 41e9f4336..fb3b5ccd1 100644 --- a/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift +++ b/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift @@ -51,13 +51,13 @@ public extension DisappearingMessagesJob { guard (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) else { return nil } // If there is another expiring message then update the job to run 1 second after it's meant to expire - let nextExpirationTimestampMs: Double? = try? Double - .fetchOne( - db, - Interaction - .select(Interaction.Columns.expiresStartedAtMs + (Interaction.Columns.expiresInSeconds * 1000)) - .order((Interaction.Columns.expiresStartedAtMs + (Interaction.Columns.expiresInSeconds * 1000)).asc) - ) + let nextExpirationTimestampMs: Double? = try? Interaction + .filter(Interaction.Columns.expiresStartedAtMs != nil) + .filter(Interaction.Columns.expiresInSeconds != nil) + .select(Interaction.Columns.expiresStartedAtMs + (Interaction.Columns.expiresInSeconds * 1000)) + .order((Interaction.Columns.expiresStartedAtMs + (Interaction.Columns.expiresInSeconds * 1000)).asc) + .asRequest(of: Double.self) + .fetchOne(db) guard let nextExpirationTimestampMs: Double = nextExpirationTimestampMs else { return nil } @@ -72,7 +72,10 @@ public extension DisappearingMessagesJob { // Update the expiring messages expiresStartedAtMs value let changeCount: Int? = try? Interaction .filter(interactionIds.contains(Interaction.Columns.id)) - .filter(Interaction.Columns.expiresInSeconds != nil && Interaction.Columns.expiresStartedAtMs == nil) + .filter( + Interaction.Columns.expiresInSeconds != nil && + Interaction.Columns.expiresStartedAtMs == nil + ) .updateAll(db, Interaction.Columns.expiresStartedAtMs.set(to: startedAtMs)) // If there were no changes then none of the provided `interactionIds` are expiring messages diff --git a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift index f0993cb8b..1d1ad1b85 100644 --- a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB import PromiseKit import SessionUtilitiesKit @@ -31,10 +32,10 @@ public enum MessageReceiveJob: JobExecutor { for messageInfo in details.messages { do { - // Note: The main reason why the 'MessageReceiver.parse' can fail but then succeed - // later on is when we get a closed group message which is signed using a new key - // but haven't received the key yet (the key gets sent directly to the user rather - // than via the closed group so this is unfortunately a possible case) + // Note: It generally shouldn't be possible for 'MessageReceiver.parse' to fail + // the main situation where this can happen is when the jobs run out of order (eg. + // a closed group message encrypted with a new key gets processed before the key + // gets added - this shouldn't be as possible with the updated JobRunner) let isRetry: Bool = (job.failureCount > 0) let (message, proto) = try MessageReceiver.parse( db, @@ -52,17 +53,31 @@ public enum MessageReceiveJob: JobExecutor { ) } catch { - // We failed to process this message so add it to the list to re-process - remainingMessagesToProcess.append(messageInfo) - - // If the current message is a permanent failure then override it with the new error (we want - // to retry if there is a single non-permanent error) - switch leastSevereError { - case let error as MessageReceiverError where !error.isRetryable: - leastSevereError = error - + switch error { + // Note: This is the same as the 'MessageReceiverError.duplicateMessage' + // which is not retryable so just skip to the next message to process + case DatabaseError.SQLITE_CONSTRAINT_UNIQUE: + SNLog("MessageReceiveJob skipping duplicate message.") + continue + default: break } + + // If the current message is a permanent failure then override it with the + // new error (we want to retry if there is a single non-permanent error) + switch error { + case let receiverError as MessageReceiverError where !receiverError.isRetryable: + SNLog("MessageReceiveJob permanently failed message due to error: \(error)") + continue + + default: + SNLog("Couldn't receive message due to error: \(error)") + leastSevereError = error + + // We failed to process this message but it is a retryable error + // so add it to the list to re-process + remainingMessagesToProcess.append(messageInfo) + } } } @@ -79,20 +94,16 @@ public enum MessageReceiveJob: JobExecutor { .saved(db) } - } - // Handle the result switch leastSevereError { case let error as MessageReceiverError where !error.isRetryable: - SNLog("Message receive job permanently failed due to error: \(error)") - failure(job, error, true) + failure(updatedJob, error, true) case .some(let error): - SNLog("Couldn't receive message due to error: \(error)") - failure(job, error, true) + failure(updatedJob, error, false) case .none: - success(job, false) + success(updatedJob, false) } } } diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift index 5f6b2ac79..b15cecf72 100644 --- a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift @@ -19,7 +19,6 @@ public enum MessageSendJob: JobExecutor { deferred: @escaping (Job) -> () ) { guard - let jobId: Int64 = job.id, // Need the 'job.id' in order to execute a MessageSendJob let detailsData: Data = job.details, let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData) else { @@ -29,9 +28,8 @@ public enum MessageSendJob: JobExecutor { if details.message is VisibleMessage { guard - let interactionId: Int64 = details.interactionId, - let threadId: String = job.threadId, - let interaction: Interaction = GRDBStorage.shared.read({ db in try Interaction.fetchOne(db, id: interactionId) }) + let jobId: Int64 = job.id, + let interactionId: Int64 = job.interactionId else { failure(job, JobRunnerError.missingRequiredDetails, false) return diff --git a/SessionMessagingKit/Messages/Control Messages/MessageRequestResponse.swift b/SessionMessagingKit/Messages/Control Messages/MessageRequestResponse.swift index fda301631..8568b31b4 100644 --- a/SessionMessagingKit/Messages/Control Messages/MessageRequestResponse.swift +++ b/SessionMessagingKit/Messages/Control Messages/MessageRequestResponse.swift @@ -13,10 +13,15 @@ public final class MessageRequestResponse: ControlMessage { // MARK: - Initialization - public init(isApproved: Bool) { + public init( + isApproved: Bool, + sentTimestampMs: UInt64? = nil + ) { self.isApproved = isApproved - super.init() + super.init( + sentTimestamp: sentTimestampMs + ) } // MARK: - Codable @@ -42,9 +47,7 @@ public final class MessageRequestResponse: ControlMessage { public override class func fromProto(_ proto: SNProtoContent, sender: String) -> MessageRequestResponse? { guard let messageRequestResponseProto = proto.messageRequestResponse else { return nil } - let isApproved = messageRequestResponseProto.isApproved - - return MessageRequestResponse(isApproved: isApproved) + return MessageRequestResponse(isApproved: messageRequestResponseProto.isApproved) } public override func toProto(_ db: Database) -> SNProtoContent? { diff --git a/SessionMessagingKit/Messages/Signal/TSIncomingMessage.h b/SessionMessagingKit/Messages/Signal/TSIncomingMessage.h index c965ff51b..2c9595a39 100644 --- a/SessionMessagingKit/Messages/Signal/TSIncomingMessage.h +++ b/SessionMessagingKit/Messages/Signal/TSIncomingMessage.h @@ -2,7 +2,6 @@ // Copyright (c) 2019 Open Whisper Systems. All rights reserved. // -#import #import NS_ASSUME_NONNULL_BEGIN @@ -10,12 +9,11 @@ NS_ASSUME_NONNULL_BEGIN @class TSContactThread; @class TSGroupThread; -@interface TSIncomingMessage : TSMessage +@interface TSIncomingMessage : TSMessage +@property (nonatomic, getter=wasRead) BOOL read; @property (nonatomic, readonly) BOOL wasReceivedByUD; - @property (nonatomic, readonly) BOOL isUserMentioned; - @property (nonatomic, readonly, nullable) NSString *notificationIdentifier; - (instancetype)initMessageWithTimestamp:(uint64_t)timestamp diff --git a/SessionMessagingKit/Messages/Signal/TSIncomingMessage.m b/SessionMessagingKit/Messages/Signal/TSIncomingMessage.m index a6578df0c..a04fe2067 100644 --- a/SessionMessagingKit/Messages/Signal/TSIncomingMessage.m +++ b/SessionMessagingKit/Messages/Signal/TSIncomingMessage.m @@ -6,7 +6,6 @@ #import "NSNotificationCenter+OWS.h" #import "OWSDisappearingMessagesConfiguration.h" #import "OWSDisappearingMessagesJob.h" -#import "OWSReadReceiptManager.h" #import "TSAttachmentPointer.h" #import "TSContactThread.h" #import "TSDatabaseSecondaryIndexes.h" @@ -19,8 +18,6 @@ NS_ASSUME_NONNULL_BEGIN @interface TSIncomingMessage () -@property (nonatomic, getter=wasRead) BOOL read; - @end #pragma mark - @@ -133,52 +130,6 @@ NS_ASSUME_NONNULL_BEGIN [self saveWithTransaction:transaction]; } -#pragma mark - OWSReadTracking - -- (BOOL)shouldAffectUnreadCounts -{ - return YES; -} - -- (void)markAsReadNowWithTrySendReadReceipt:(BOOL)trySendReadReceipt - transaction:(YapDatabaseReadWriteTransaction *)transaction; -{ - [self markAsReadAtTimestamp:[NSDate millisecondTimestamp] - trySendReadReceipt:trySendReadReceipt - transaction:transaction]; -} - -- (void)markAsReadAtTimestamp:(uint64_t)readTimestamp - trySendReadReceipt:(BOOL)trySendReadReceipt - transaction:(YapDatabaseReadWriteTransaction *)transaction; -{ - if (_read && readTimestamp >= self.expireStartedAt) { - return; - } - // We just ignore all attachments download state here and mark all messages as read - // This is a workaround for a situation that some large attachments won't be downloaded - // and just stuck in a downloading state. In that case, the corresponding message won't - // be able to be marked as read. - - _read = YES; - [self saveWithTransaction:transaction]; - - [transaction addCompletionQueue:nil - completionBlock:^{ - [[NSNotificationCenter defaultCenter] - postNotificationNameAsync:kIncomingMessageMarkedAsReadNotification - object:self]; - }]; - - [[OWSDisappearingMessagesJob sharedJob] startAnyExpirationForMessage:self - expirationStartedAt:readTimestamp - transaction:transaction]; - - if (trySendReadReceipt) { - [OWSReadReceiptManager.sharedManager messageWasReadLocally:self]; - } -} - @end NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Messages/Signal/TSInfoMessage.h b/SessionMessagingKit/Messages/Signal/TSInfoMessage.h index 627afc492..550b40368 100644 --- a/SessionMessagingKit/Messages/Signal/TSInfoMessage.h +++ b/SessionMessagingKit/Messages/Signal/TSInfoMessage.h @@ -2,12 +2,11 @@ // Copyright (c) 2019 Open Whisper Systems. All rights reserved. // -#import #import NS_ASSUME_NONNULL_BEGIN -@interface TSInfoMessage : TSMessage +@interface TSInfoMessage : TSMessage typedef NS_ENUM(NSInteger, TSInfoMessageType) { TSInfoMessageTypeGroupCreated, @@ -19,6 +18,7 @@ typedef NS_ENUM(NSInteger, TSInfoMessageType) { TSInfoMessageTypeMessageRequestAccepted = 99 }; +@property (nonatomic, getter=wasRead) BOOL read; @property (atomic, readonly) TSInfoMessageType messageType; @property (atomic, readonly, nullable) NSString *customMessage; @property (atomic, readonly, nullable) NSString *unregisteredRecipientId; diff --git a/SessionMessagingKit/Messages/Signal/TSInfoMessage.m b/SessionMessagingKit/Messages/Signal/TSInfoMessage.m index 9d303a1bf..9c7f8908c 100644 --- a/SessionMessagingKit/Messages/Signal/TSInfoMessage.m +++ b/SessionMessagingKit/Messages/Signal/TSInfoMessage.m @@ -13,8 +13,6 @@ NSUInteger TSInfoMessageSchemaVersion = 1; @interface TSInfoMessage () -@property (nonatomic, getter=wasRead) BOOL read; - @property (nonatomic, readonly) NSUInteger infoMessageSchemaVersion; @end @@ -121,32 +119,6 @@ NSUInteger TSInfoMessageSchemaVersion = 1; return @"Unknown Info Message Type"; } -#pragma mark - OWSReadTracking - -- (BOOL)shouldAffectUnreadCounts -{ - return NO; -} - -- (uint64_t)expireStartedAt -{ - return 0; -} - -- (void)markAsReadAtTimestamp:(uint64_t)readTimestamp - trySendReadReceipt:(BOOL)trySendReadReceipt - transaction:(YapDatabaseReadWriteTransaction *)transaction -{ - if (_read) { - return; - } - - _read = YES; - [self saveWithTransaction:transaction]; - - // Ignore trySendReadReceipt, it doesn't apply to info messages. -} - @end NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Messages/Signal/TSInteraction.m b/SessionMessagingKit/Messages/Signal/TSInteraction.m index f3522712d..b7100e0a5 100644 --- a/SessionMessagingKit/Messages/Signal/TSInteraction.m +++ b/SessionMessagingKit/Messages/Signal/TSInteraction.m @@ -226,9 +226,9 @@ NSString *NSStringFromOWSInteractionType(OWSInteractionType value) if (!self.uniqueId) { self.uniqueId = [NSUUID new].UUIDString; } - if (self.sortId == 0) { - self.sortId = [SSKIncrementingIdFinder nextIdWithKey:[TSInteraction collection] transaction:transaction]; - } +// if (self.sortId == 0) { +// self.sortId = [SSKIncrementingIdFinder nextIdWithKey:[TSInteraction collection] transaction:transaction]; +// } [super saveWithTransaction:transaction]; diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift index 64698772e..ea3111e62 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift @@ -65,3 +65,13 @@ public extension VisibleMessage { } } } + +// MARK: - Conversion + +extension VisibleMessage.Profile { + init(profile: SessionMessagingKit.Profile) { + self.displayName = profile.name + self.profileKey = profile.profileEncryptionKey?.keyData + self.profilePictureUrl = profile.profilePictureUrl + } +} diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift index fafa53719..44bbbaa9b 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift @@ -131,8 +131,8 @@ public final class VisibleMessage: Message { // Quote - if let quotedAttachmentID = quote?.attachmentID, let index = attachmentIDs.firstIndex(of: quotedAttachmentID) { - attachmentIDs.remove(at: index) + if let quotedAttachmentId = quote?.attachmentId, let index = attachmentIds.firstIndex(of: quotedAttachmentId) { + attachmentIds.remove(at: index) } if let quote = quote, let quoteProto = quote.toProto(db) { diff --git a/SessionMessagingKit/Meta/SessionMessagingKit.h b/SessionMessagingKit/Meta/SessionMessagingKit.h index 207086f95..9e21c5f40 100644 --- a/SessionMessagingKit/Meta/SessionMessagingKit.h +++ b/SessionMessagingKit/Meta/SessionMessagingKit.h @@ -15,12 +15,9 @@ FOUNDATION_EXPORT const unsigned char SessionMessagingKitVersionString[]; #import #import #import -#import #import #import #import -#import -#import #import #import #import diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift b/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift index 02adda022..aef0592f8 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB import PromiseKit import Curve25519Kit import SessionSnodeKit @@ -153,9 +154,11 @@ public final class OpenGroupAPIV2 : NSObject { } } - public static func compactPoll(_ server: String) -> Promise<[CompactPollResponseBody]> { + public static func compactPoll(_ db: Database, server: String) throws -> Promise<[CompactPollResponseBody]> { let storage = SNMessagingKitConfiguration.shared.storage - let rooms = storage.getAllV2OpenGroups().values.filter { $0.server == server }.map { $0.room } + let groups: [OpenGroup] = try OpenGroup + .filter(OpenGroup.Columns.server == server) + .fetchAll(db) var body: [JSON] = [] var authTokenPromises: [String:Promise] = [:] let useMessageLimit = (hasPerformedInitialPoll[server] != true && timeSinceLastOpen > OpenGroupPollerV2.maxInactivityPeriod) @@ -164,13 +167,13 @@ public final class OpenGroupAPIV2 : NSObject { UserDefaults.standard[.lastOpen] = Date() hasUpdatedLastOpenDate = true } - for room in rooms { - authTokenPromises[room] = getAuthToken(for: room, on: server) - var json: JSON = [ "room_id" : room ] - if let lastMessageServerID = storage.getLastMessageServerID(for: room, on: server) { + for group in groups { + authTokenPromises[group.room] = getAuthToken(for: group.room, on: server) + var json: JSON = [ "room_id" : group.room ] + if let lastMessageServerID = storage.getLastMessageServerID(for: group.room, on: server) { json["from_message_server_id"] = useMessageLimit ? nil : lastMessageServerID } - if let lastDeletionServerID = storage.getLastDeletionServerID(for: room, on: server) { + if let lastDeletionServerID = storage.getLastDeletionServerID(for: group.room, on: server) { json["from_deletion_server_id"] = useMessageLimit ? nil : lastDeletionServerID } body.append(json) diff --git a/SessionMessagingKit/Open Groups/OpenGroupManagerV2.swift b/SessionMessagingKit/Open Groups/OpenGroupManagerV2.swift index 66547950d..94dc6ab56 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManagerV2.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManagerV2.swift @@ -1,4 +1,6 @@ +import GRDB import PromiseKit +import SessionUtilitiesKit @objc(SNOpenGroupManagerV2) public final class OpenGroupManagerV2 : NSObject { @@ -14,8 +16,15 @@ public final class OpenGroupManagerV2 : NSObject { @objc public func startPolling() { guard !isPolling else { return } isPolling = true - let servers = Set(Storage.shared.getAllV2OpenGroups().values.map { $0.server }) - servers.forEach { server in + + let servers: [String]? = GRDBStorage.shared.read { db in + try OpenGroup + .select(.server) + .distinct() + .asRequest(of: String.self) + .fetchAll(db) + } + servers?.forEach { server in if let poller = pollers[server] { poller.stop() } // Should never occur let poller = OpenGroupPollerV2(for: server) poller.startIfNeeded() @@ -134,15 +143,21 @@ public final class OpenGroupManagerV2 : NSObject { return promise } - public func delete(_ openGroup: OpenGroupV2, associatedWith thread: TSThread, using transaction: YapDatabaseReadWriteTransaction) { - let storage = SNMessagingKitConfiguration.shared.storage + public func delete(_ db: Database, openGroupId: String) { + guard let openGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: openGroupId) else { return } + // Stop the poller if needed - let openGroups = storage.getAllV2OpenGroups().values.filter { $0.server == openGroup.server } - if openGroups.count == 1 && openGroups.last == openGroup { + let numRooms: Int = (try? OpenGroup + .filter(OpenGroup.Columns.server == openGroup.server) + .fetchCount(db)) + .defaulting(to: 1) + + if numRooms == 1 { let poller = pollers[openGroup.server] poller?.stop() pollers[openGroup.server] = nil } + // Remove all data var messageIDs: Set = [] var messageTimestamps: Set = [] diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index 644ab35ab..5e7df44df 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -29,15 +29,54 @@ extension MessageReceiver { default: fatalError() } - guard (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) else { return } - - // Touch the thread to update the home screen preview - let storage = SNMessagingKitConfiguration.shared.storage - guard let threadID = storage.getOrCreateThread(for: message.sender!, groupPublicKey: message.groupPublicKey, openGroupID: openGroupID, using: transaction) else { return } - ThreadUpdateBatcher.shared.touch(threadID) + // When handling any non-typing indicator message we want to make sure the thread becomes + // visible (the only other spot this flag gets set is when sending messages) + switch message { + case is TypingIndicator: break + + default: + guard let threadInfo: (id: String, variant: SessionThread.Variant) = threadInfo(db, message: message, openGroupId: openGroupId) else { + return + } + + _ = try SessionThread + .fetchOrCreate(db, id: threadInfo.id, variant: threadInfo.variant) + .with(shouldBeVisible: true) + .saved(db) + } } - + // MARK: - Convenience + + private static func threadInfo(_ db: Database, message: Message, openGroupId: String?) -> (id: String, variant: SessionThread.Variant)? { + if let openGroupId: String = openGroupId { + // Note: We don't want to create a thread for an open group if it doesn't exist + if (try? SessionThread.exists(db, id: openGroupId)) != true { return nil } + + return (openGroupId, .openGroup) + } + + if let groupPublicKey: String = message.groupPublicKey { + // Note: We don't want to create a thread for a closed group if it doesn't exist + if (try? SessionThread.exists(db, id: groupPublicKey)) != true { return nil } + + return (groupPublicKey, .closedGroup) + } + + // Extract the 'syncTarget' value if there is one + let maybeSyncTarget: String? + + switch message { + case let message as VisibleMessage: maybeSyncTarget = message.syncTarget + case let message as ExpirationTimerUpdate: maybeSyncTarget = message.syncTarget + default: maybeSyncTarget = nil + } + + // Note: We don't want to create a thread for a closed group if it doesn't exist + guard let contactId: String = (maybeSyncTarget ?? message.sender) else { return nil } + + return (contactId, .contact) + } // MARK: - Read Receipts @@ -153,15 +192,9 @@ extension MessageReceiver { // MARK: - Expiration Timers private static func handleExpirationTimerUpdate(_ db: Database, message: ExpirationTimerUpdate) throws { - let targetId: String? = { - if let groupPublicKey: String = message.groupPublicKey { return groupPublicKey } - - return (message.syncTarget ?? message.sender) - }() - // Get the target thread guard - let targetId: String = targetId, + let targetId: String = threadInfo(db, message: message, openGroupId: nil)?.id, let sender: String = message.sender, let thread: SessionThread = try? SessionThread.fetchOne(db, id: targetId) else { return } @@ -213,7 +246,8 @@ extension MessageReceiver { SNLog("Configuration message received.") - // Note: `message.sentTimestamp` is in ms + // Note: `message.sentTimestamp` is in ms (convert to TimeInterval before converting to + // seconds to maintain the accuracy) let isInitialSync: Bool = (!UserDefaults.standard[.hasSyncedInitialConfiguration]) let messageSentTimestamp: TimeInterval = TimeInterval((message.sentTimestamp ?? 0) / 1000) let lastConfigTimestamp: TimeInterval = UserDefaults.standard[.lastConfigurationSync] @@ -255,27 +289,25 @@ extension MessageReceiver { ) .save(db) - // Note: We only update these values if the proto actually has values for them (this is to - // prevent an edge case where an old client could override the values with default values - // since they aren't included) - // - // Note: Since message requests has no reverse, the only case we need to process is a - // config message setting *isApproved* and *didApproveMe* to true. This may prevent some - // weird edge cases where a config message swapping *isApproved* and *didApproveMe* to - // false. + /// We only update these values if the proto actually has values for them (this is to prevent an + /// edge case where an old client could override the values with default values since they aren't included) + /// + /// **Note:** Since message requests have no reverse, we should only handle setting `isApproved` + /// and `didApproveMe` to `true`. This may prevent some weird edge cases where a config message + /// swapping `isApproved` and `didApproveMe` to `false` try contact .with( isApproved: (contactInfo.hasIsApproved && contactInfo.isApproved ? - .existing : - true + true : + .existing ), - isBlocked: (contactInfo.hasIsBlocked && contactInfo.isBlocked ? - .existing : - true + isBlocked: (contactInfo.hasIsBlocked ? + .update(contactInfo.isBlocked) : + .existing ), didApproveMe: (contactInfo.hasDidApproveMe && contactInfo.didApproveMe ? - .existing : - true + true : + .existing ) ) .save(db) @@ -290,7 +322,7 @@ extension MessageReceiver { let thread: SessionThread = try? SessionThread.fetchOne(db, id: sessionId), thread.isMessageRequest(db) { - try thread.delete(db) + _ = try thread.delete(db) } } } @@ -353,11 +385,20 @@ extension MessageReceiver { .filter(Interaction.Columns.authorId == author) .fetchOne(db) - guard let interaction: Interaction = maybeInteraction else { return } + guard + let interactionId: Int64 = maybeInteraction?.id, + let interaction: Interaction = maybeInteraction + else { return } // Mark incoming messages as read and remove any of their notifications if interaction.variant == .standardIncoming { - _ = try interaction.markingAsRead(db, includingOlder: false, trySendReadReceipt: false) + try Interaction.markAsRead( + db, + interactionId: interactionId, + threadId: interaction.threadId, + includingOlder: false, + trySendReadReceipt: false + ) UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: interaction.notificationIdentifiers) UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: interaction.notificationIdentifiers) @@ -393,24 +434,11 @@ extension MessageReceiver { throw MessageReceiverError.invalidMessage } - // Note: `message.sentTimestamp` is in ms - let messageSentTimestamp: TimeInterval = TimeInterval((message.sentTimestamp ?? 0) / 1000) + // Note: `message.sentTimestamp` is in ms (convert to TimeInterval before converting to + // seconds to maintain the accuracy) + let messageSentTimestamp: TimeInterval = (TimeInterval(message.sentTimestamp ?? 0) / 1000) let isMainAppActive: Bool = (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) - // Parse & persist attachments - - let attachments: [Attachment] = dataMessage.attachments - .compactMap { proto in - let attachment: Attachment = Attachment(proto: proto) - - // Attachments on received messages must have a 'downloadUrl' otherwise - // they are invalid and we can ignore them - return (attachment.downloadUrl != nil ? attachment : nil) - } - try attachments.saveAll(db) - - message.attachmentIds = attachments.map { $0.id } - // Update profile if needed if let profile = message.profile { var contactProfileKey: OWSAES256Key? = nil @@ -427,123 +455,100 @@ extension MessageReceiver { } // Get or create thread - let threadInfo: (id: String, variant: SessionThread.Variant)? = { - if let openGroupId: String = openGroupId { - // Note: We don't want to create a thread for an open group if it doesn't exist - if (try? SessionThread.exists(db, id: openGroupId)) != true { return nil } - - return (openGroupId, .openGroup) - } - - if let groupPublicKey: String = message.groupPublicKey { - // Note: We don't want to create a thread for a closed group if it doesn't exist - if (try? SessionThread.exists(db, id: groupPublicKey)) != true { return nil } - - return (groupPublicKey, .closedGroup) - } - - return ((message.syncTarget ?? sender), .contact) - }() - guard let threadInfo: (id: String, variant: SessionThread.Variant) = threadInfo else { + guard let threadInfo: (id: String, variant: SessionThread.Variant) = threadInfo(db, message: message, openGroupId: openGroupId) else { throw MessageReceiverError.noThread } - let thread: SessionThread = SessionThread.fetchOrCreate(db, id: threadInfo.id, variant: threadInfo.variant) - let interaction: Interaction - let interactionId: Int64 + let thread: SessionThread = try SessionThread + .fetchOrCreate(db, id: threadInfo.id, variant: threadInfo.variant) - do { - // Store the message variant so we can run variant-specific behaviours - let variant: Interaction.Variant = { - if sender == getUserHexEncodedPublicKey(db) { - return .standardOutgoing - } - - return .standardIncoming - }() - - // Check if there is an existing message with the same timestamp, variant and sender - let existingInteraction: Interaction? = try? thread.interactions - .filter(Interaction.Columns.timestampMs == (messageSentTimestamp * 1000)) - .filter(Interaction.Columns.variant == variant) - .filter(Interaction.Columns.authorId == sender) - .fetchOne(db) - - if let existingInteraction: Interaction = existingInteraction { - // These values might not have been set yet for outgoing interactions so update them - interaction = try existingInteraction - .with( - serverHash: message.serverHash, // Keep track of server hash - openGroupServerMessageId: message.openGroupServerMessageId.map { Int64($0) } - ) - .saved(db) - - guard let existingInteractionId: Int64 = interaction.id else { throw GRDBStorageError.failedToSave } - - interactionId = existingInteractionId + // Store the message variant so we can run variant-specific behaviours + let variant: Interaction.Variant = { + if sender == getUserHexEncodedPublicKey(db) { + return .standardOutgoing } - else { - let disappearingMessagesConfiguration: DisappearingMessagesConfiguration = (try? thread.disappearingMessagesConfiguration.fetchOne(db)) - .defaulting(to: DisappearingMessagesConfiguration.defaultWith(thread.id)) - - interaction = try Interaction( - serverHash: message.serverHash, // Keep track of server hash - threadId: thread.id, - authorId: sender, - variant: variant, - body: message.text, - timestampMs: Int64(messageSentTimestamp * 1000), - // Note: Ensure we don't ever expire open group messages - expiresInSeconds: (disappearingMessagesConfiguration.isEnabled && message.openGroupServerMessageId == nil ? - disappearingMessagesConfiguration.durationSeconds : - nil - ), - expiresStartedAtMs: nil, - // OpenGroupInvitations are stored as LinkPreview's in the database - linkPreviewUrl: (message.linkPreview?.url ?? message.openGroupInvitation?.url), - // Keep track of the open group server message ID ↔ message ID relationship - openGroupWhisperMods: false, // TODO: SOGSV4 - openGroupWhisperTo: nil // TODO: SOGSV4 - openGroupServerMessageId: message.openGroupServerMessageId.map { Int64($0) }, - ).inserted(db) - - guard let newInteractionId: Int64 = interaction.id else { throw GRDBStorageError.failedToSave } - - interactionId = newInteractionId - - // For newly created outgoing messages upsert the recipient states to sent - if variant == .standardOutgoing { - if let syncTarget: String = message.syncTarget { + + return .standardIncoming + }() + + // Retrieve the disappearing messages config to set the 'expiresInSeconds' value + // accoring to the config + let disappearingMessagesConfiguration: DisappearingMessagesConfiguration = (try? thread.disappearingMessagesConfiguration.fetchOne(db)) + .defaulting(to: DisappearingMessagesConfiguration.defaultWith(thread.id)) + + // Try to insert the interaction + // + // Note: There are now a number of unique constraints on the database which + // prevent the ability to insert duplicate interactions at a database level + // so we don't need to check for the existance of a message beforehand anymore + let interaction: Interaction = try Interaction( + serverHash: message.serverHash, // Keep track of server hash + threadId: thread.id, + authorId: sender, + variant: variant, + body: message.text, + timestampMs: Int64(messageSentTimestamp * 1000), + // Note: Ensure we don't ever expire open group messages + expiresInSeconds: (disappearingMessagesConfiguration.isEnabled && message.openGroupServerMessageId == nil ? + disappearingMessagesConfiguration.durationSeconds : + nil + ), + expiresStartedAtMs: nil, + // OpenGroupInvitations are stored as LinkPreview's in the database + linkPreviewUrl: (message.linkPreview?.url ?? message.openGroupInvitation?.url), + // Keep track of the open group server message ID ↔ message ID relationship + openGroupServerMessageId: message.openGroupServerMessageId.map { Int64($0) }, + openGroupWhisperMods: false, // TODO: SOGSV4 + openGroupWhisperTo: nil // TODO: SOGSV4 + ).inserted(db) + + guard let interactionId: Int64 = interaction.id else { throw GRDBStorageError.failedToSave } + + // For newly created outgoing messages upsert the recipient states to sent + if variant == .standardOutgoing { + if let syncTarget: String = message.syncTarget { + try RecipientState( + interactionId: interactionId, + recipientId: syncTarget, + state: .sent + ).save(db) + } + else if thread.variant == .closedGroup { + try GroupMember + .filter(GroupMember.Columns.groupId == thread.id) + .fetchAll(db) + .forEach { member in try RecipientState( interactionId: interactionId, - recipientId: syncTarget, + recipientId: member.profileId, state: .sent ).save(db) } - else if - let closedGroup: ClosedGroup = try? thread.closedGroup.fetchOne(db), - let members: [GroupMember] = try? closedGroup.members.fetchAll(db) - { - try members.forEach { member in - try RecipientState( - interactionId: interactionId, - recipientId: member.profileId, - state: .sent - ).save(db) - } - } - } } - + // For outgoing messages mark it and all older interactions as read - if variant == .standardOutgoing { - _ = try interaction.markingAsRead(db, includingOlder: true, trySendReadReceipt: true) - } + try Interaction.markAsRead( + db, + interactionId: interactionId, + threadId: thread.id, + includingOlder: true, + trySendReadReceipt: true + ) + } + + + // Parse & persist attachments + let attachments: [Attachment] = dataMessage.attachments + .compactMap { proto in + let attachment: Attachment = Attachment(proto: proto) - } - catch { - throw error - } + // Attachments on received messages must have a 'downloadUrl' otherwise + // they are invalid and we can ignore them + return (attachment.downloadUrl != nil ? attachment : nil) + } + try attachments.saveAll(db) + + message.attachmentIds = attachments.map { $0.id } // Persist quote if needed let quote: Quote? = try? Quote( @@ -618,7 +623,7 @@ extension MessageReceiver { } // Notify the user if needed - guard interaction.variant == .standardIncoming else { return interactionId } + guard variant == .standardIncoming else { return interactionId } // Use the same identifier for notifications when in backgroud polling to prevent spam SSKEnvironment.shared.notificationsManager.wrappedValue? @@ -766,7 +771,6 @@ extension MessageReceiver { let groupAlreadyExisted: Bool = ((try? SessionThread.exists(db, id: groupPublicKey)) ?? false) let thread: SessionThread = try SessionThread .fetchOrCreate(db, id: groupPublicKey, variant: .closedGroup) - .saved(db) let closedGroup: ClosedGroup = try ClosedGroup( threadId: groupPublicKey, name: name, diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index 6235da943..414cb9783 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -168,7 +168,7 @@ public enum MessageReceiver { // • The app performed a background poll or received a push notification // • This method was invoked and the received message timestamps table was updated // • Processing wasn't finished - // • The user doesn't see theO new closed group + // • The user doesn't see the new closed group case (_, _, .new): break // All `VisibleMessage` values will have an associated `Interaction` so just let diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift index c5c40756f..86cf35bd6 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift @@ -27,7 +27,6 @@ extension MessageSender { let thread: SessionThread = try SessionThread .fetchOrCreate(db, id: groupPublicKey, variant: .closedGroup) - .saved(db) try ClosedGroup( threadId: groupPublicKey, name: name, @@ -56,7 +55,6 @@ extension MessageSender { try members.forEach { memberId in let contactThread: SessionThread = try SessionThread .fetchOrCreate(db, id: memberId, variant: .contact) - .saved(db) // Sending this non-durably is okay because we show a loader to the user. If they // close the app while the loader is still showing, it's within expectation that @@ -349,7 +347,6 @@ extension MessageSender { // Send updates to the new members individually let thread: SessionThread = try SessionThread .fetchOrCreate(db, id: member, variant: .contact) - .saved(db) try MessageSender.send( db, @@ -625,7 +622,6 @@ extension MessageSender { let plaintext = try proto.serializedData() let thread: SessionThread = try SessionThread .fetchOrCreate(db, id: publicKey, variant: .contact) - .saved(db) let ciphertext = try MessageSender.encryptWithSessionProtocol(plaintext, for: publicKey) SNLog("Sending latest encryption key pair to: \(publicKey).") diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index af9fed03f..3e7993b10 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -33,8 +33,9 @@ extension MessageSender { db, job: Job( variant: .messageSend, + threadId: thread.id, + interactionId: interactionId, details: MessageSendJob.Details( - interactionId: interactionId, destination: try Message.Destination.from(db, thread: thread), message: message ) @@ -153,7 +154,6 @@ extension MessageSender { job: Job( variant: .messageSend, details: MessageSendJob.Details( - interactionId: nil, destination: destination, message: configurationMessage ) diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 94794f6d5..67142630e 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -369,6 +369,17 @@ public final class MessageSender : NSObject { handleFailure(db, with: .invalidMessage) return promise } + + // Attach the user's profile + message.profile = VisibleMessage.Profile( + profile: Profile.fetchOrCreateCurrentUser() + ) + + if (message.profile?.displayName ?? "").isEmpty { + handleFailure(db, with: .noUsername) + return promise + } + // Convert it to protobuf guard let proto = message.toProto(db) else { handleFailure(db, with: .protoConversionFailed) @@ -467,13 +478,10 @@ public final class MessageSender : NSObject { // Start the disappearing messages timer if needed JobRunner.upsert( db, - job: Job( - variant: .disappearingMessages, - details: DisappearingMessagesJob.updateNextRunIfNeeded( - db, - interaction: interaction, - startedAtMs: (Date().timeIntervalSince1970 * 1000) - ) + job: DisappearingMessagesJob.updateNextRunIfNeeded( + db, + interaction: interaction, + startedAtMs: (Date().timeIntervalSince1970 * 1000) ) ) } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift index c1f601365..4787cf460 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift @@ -1,5 +1,9 @@ -import SessionSnodeKit +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB import PromiseKit +import SessionSnodeKit @objc(LKClosedGroupPoller) public final class ClosedGroupPoller : NSObject { @@ -109,19 +113,15 @@ public final class ClosedGroupPoller : NSObject { return Promise(error: Error.pollingCanceled) } - return SnodeAPI.getRawMessages(from: snode, associatedWith: groupPublicKey) - .map2 { - let messages: [SnodeReceivedMessage] = SnodeAPI.parseRawMessagesResponse($0, from: snode, associatedWith: groupPublicKey) - - return (snode, messages) - } + return SnodeAPI.getMessages(from: snode, associatedWith: groupPublicKey) + .map2 { messages in (snode, messages) } } promise.done2 { [weak self] snode, messages in - guard let self = self, self.isPolling(for: groupPublicKey) else { return } + guard self?.isPolling(for: groupPublicKey) == true else { return } if !messages.isEmpty { - SNLog("Received \(messages.count) new message(s) in closed group with public key: \(groupPublicKey).") + SNLog("Received \(messages.count) message(s) in closed group with public key: \(groupPublicKey).") GRDBStorage.shared.write { db in var jobDetailMessages: [MessageReceiveJob.Details.MessageInfo] = [] @@ -141,7 +141,13 @@ public final class ClosedGroupPoller : NSObject { _ = try message.info.saved(db) } catch { - SNLog("Failed to deserialize envelope due to error: \(error).") + switch error { + // Ignore unique constraint violations here (they will be hanled in the MessageReceiveJob) + case .SQLITE_CONSTRAINT_UNIQUE: break + + default: + SNLog("Failed to deserialize envelope due to error: \(error).") + } } } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift index 38ba3d31d..343805487 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift @@ -1,4 +1,6 @@ +import GRDB import PromiseKit +import SessionUtilitiesKit @objc(SNOpenGroupPollerV2) public final class OpenGroupPollerV2 : NSObject { @@ -44,17 +46,20 @@ public final class OpenGroupPollerV2 : NSObject { let (promise, seal) = Promise.pending() promise.retainUntilComplete() Threading.pollerQueue.async { - OpenGroupAPIV2.compactPoll(self.server).done(on: OpenGroupAPIV2.workQueue) { [weak self] bodies in - guard let self = self else { return } - self.isPolling = false - bodies.forEach { self.handleCompactPollBody($0, isBackgroundPoll: isBackgroundPoll) } - SNLog("Open group polling finished for \(self.server).") - seal.fulfill(()) - }.catch(on: OpenGroupAPIV2.workQueue) { error in - SNLog("Open group polling failed due to error: \(error).") - self.isPolling = false - seal.fulfill(()) // The promise is just used to keep track of when we're done - } + GRDBStorage.shared + .read { db in try OpenGroupAPIV2.compactPoll(db, server: self.server) } + .done(on: OpenGroupAPIV2.workQueue) { [weak self] bodies in + guard let self = self else { return } + self.isPolling = false + bodies.forEach { self.handleCompactPollBody($0, isBackgroundPoll: isBackgroundPoll) } + SNLog("Open group polling finished for \(self.server).") + seal.fulfill(()) + } + .catch(on: OpenGroupAPIV2.workQueue) { error in + SNLog("Open group polling failed due to error: \(error).") + self.isPolling = false + seal.fulfill(()) // The promise is just used to keep track of when we're done + } } return promise } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift index 9012b6115..f64d0c2eb 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB import PromiseKit import Sodium import SessionSnodeKit @@ -98,14 +99,12 @@ public final class Poller : NSObject { let userPublicKey = getUserHexEncodedPublicKey() - return SnodeAPI.getRawMessages(from: snode, associatedWith: userPublicKey) - .then(on: Threading.pollerQueue) { [weak self] rawResponse -> Promise in - guard let strongSelf = self, strongSelf.isPolling else { return Promise { $0.fulfill(()) } } - - let messages: [SnodeReceivedMessage] = SnodeAPI.parseRawMessagesResponse(rawResponse, from: snode, associatedWith: userPublicKey) + return SnodeAPI.getMessages(from: snode, associatedWith: userPublicKey) + .then(on: Threading.pollerQueue) { [weak self] messages -> Promise in + guard self?.isPolling == true else { return Promise { $0.fulfill(()) } } if !messages.isEmpty { - SNLog("Received \(messages.count) new message(s).") + SNLog("Received \(messages.count) message(s).") GRDBStorage.shared.write { db in var threadMessages: [String: [MessageReceiveJob.Details.MessageInfo]] = [:] @@ -133,7 +132,13 @@ public final class Poller : NSObject { _ = try message.info.saved(db) } catch { - SNLog("Failed to deserialize envelope due to error: \(error).") + switch error { + // Ignore unique constraint violations here (they will be hanled in the MessageReceiveJob) + case .SQLITE_CONSTRAINT_UNIQUE: break + + default: + SNLog("Failed to deserialize envelope due to error: \(error).") + } } } @@ -154,9 +159,9 @@ public final class Poller : NSObject { } } - strongSelf.pollCount += 1 + self?.pollCount += 1 - guard strongSelf.pollCount < Poller.maxPollCount else { + guard (self?.pollCount ?? 0) < Poller.maxPollCount else { throw Error.pollLimitReached } diff --git a/SessionMessagingKit/Sending & Receiving/Quotes/TSQuotedMessage+Conversion.swift b/SessionMessagingKit/Sending & Receiving/Quotes/TSQuotedMessage+Conversion.swift index 9cdf974d1..f943ebb33 100644 --- a/SessionMessagingKit/Sending & Receiving/Quotes/TSQuotedMessage+Conversion.swift +++ b/SessionMessagingKit/Sending & Receiving/Quotes/TSQuotedMessage+Conversion.swift @@ -21,11 +21,12 @@ extension VisibleMessage.Quote { public static func from(_ quote: TSQuotedMessage?) -> VisibleMessage.Quote? { guard let quote = quote else { return nil } - let result = VisibleMessage.Quote() - result.timestamp = quote.timestamp - result.publicKey = quote.authorId - result.text = quote.body - result.attachmentID = quote.quotedAttachments.first?.attachmentId - return result + + return VisibleMessage.Quote( + timestamp: quote.timestamp, + publicKey: quote.authorId, + text: quote.body, + attachmentId: quote.quotedAttachments.first?.attachmentId + ) } } diff --git a/SessionMessagingKit/Sending & Receiving/Read Tracking/OWSOutgoingReceiptManager.h b/SessionMessagingKit/Sending & Receiving/Read Tracking/OWSOutgoingReceiptManager.h deleted file mode 100644 index f6784ee0b..000000000 --- a/SessionMessagingKit/Sending & Receiving/Read Tracking/OWSOutgoingReceiptManager.h +++ /dev/null @@ -1,24 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@class OWSPrimaryStorage; -@class SNProtoEnvelope; - -@interface OWSOutgoingReceiptManager : NSObject - -- (instancetype)init NS_UNAVAILABLE; -- (instancetype)initWithPrimaryStorage:(OWSPrimaryStorage *)primaryStorage NS_DESIGNATED_INITIALIZER; -+ (instancetype)sharedManager; - -- (void)enqueueDeliveryReceiptForEnvelope:(SNProtoEnvelope *)envelope; - -- (void)enqueueReadReceiptForEnvelope:(NSString *)messageAuthorId timestamp:(uint64_t)timestamp; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Sending & Receiving/Read Tracking/OWSOutgoingReceiptManager.m b/SessionMessagingKit/Sending & Receiving/Read Tracking/OWSOutgoingReceiptManager.m deleted file mode 100644 index b639e8008..000000000 --- a/SessionMessagingKit/Sending & Receiving/Read Tracking/OWSOutgoingReceiptManager.m +++ /dev/null @@ -1,229 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSOutgoingReceiptManager.h" -#import -#import "SSKEnvironment.h" -#import "AppReadiness.h" -#import "OWSPrimaryStorage.h" -#import "TSContactThread.h" -#import "TSYapDatabaseObject.h" -#import -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -NSString *const kOutgoingReadReceiptManagerCollection = @"kOutgoingReadReceiptManagerCollection"; - -@interface OWSOutgoingReceiptManager () - -@property (nonatomic, readonly) YapDatabaseConnection *dbConnection; - -@property (nonatomic) Reachability *reachability; - -// This property should only be accessed on the serialQueue. -@property (nonatomic) BOOL isProcessing; - -@end - -#pragma mark - - -@implementation OWSOutgoingReceiptManager - -+ (instancetype)sharedManager -{ - return SSKEnvironment.shared.outgoingReceiptManager; -} - -- (instancetype)initWithPrimaryStorage:(OWSPrimaryStorage *)primaryStorage -{ - self = [super init]; - - if (!self) { - return self; - } - - self.reachability = [Reachability reachabilityForInternetConnection]; - - _dbConnection = primaryStorage.newDatabaseConnection; - - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(reachabilityChanged) - name:kReachabilityChangedNotification - object:nil]; - - // Start processing. - [AppReadiness runNowOrWhenAppDidBecomeReady:^{ - [self process]; - }]; - - return self; -} - -- (void)dealloc -{ - [[NSNotificationCenter defaultCenter] removeObserver:self]; -} - -#pragma mark - - -- (dispatch_queue_t)serialQueue -{ - static dispatch_queue_t _serialQueue; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - _serialQueue = dispatch_queue_create("org.whispersystems.outgoingReceipts", DISPATCH_QUEUE_SERIAL); - }); - - return _serialQueue; -} - -// Schedules a processing pass, unless one is already scheduled. -- (void)process { - dispatch_async(self.serialQueue, ^{ - if (self.isProcessing) { - return; - } - - self.isProcessing = YES; - - if (!self.reachability.isReachable) { - // No network availability; abort. - self.isProcessing = NO; - return; - } - - NSMutableArray *sendPromises = [NSMutableArray array]; - [sendPromises addObjectsFromArray:[self sendReceipts]]; - - if (sendPromises.count < 1) { - // No work to do; abort. - self.isProcessing = NO; - return; - } - - AnyPromise *completionPromise = PMKJoin(sendPromises); - completionPromise.ensure(^() { - // Wait N seconds before conducting another pass. - // This allows time for a batch to accumulate. - // - // We want a value high enough to allow us to effectively de-duplicate - // receipts without being so high that we incur so much latency that - // the user notices. - const CGFloat kProcessingFrequencySeconds = 3.f; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kProcessingFrequencySeconds * NSEC_PER_SEC)), - self.serialQueue, - ^{ - self.isProcessing = NO; - - [self process]; - }); - }); - [completionPromise retainUntilComplete]; - }); -} - -- (NSArray *)sendReceipts { - NSString *collection = kOutgoingReadReceiptManagerCollection; - - NSMutableDictionary *> *queuedReceiptMap = [NSMutableDictionary new]; - [self.dbConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - [transaction enumerateKeysAndObjectsInCollection:collection - usingBlock:^(NSString *key, id object, BOOL *stop) { - NSString *recipientId = key; - NSSet *timestamps = object; - queuedReceiptMap[recipientId] = [timestamps copy]; - }]; - }]; - - NSMutableArray *sendPromises = [NSMutableArray array]; - - for (NSString *recipientId in queuedReceiptMap) { - NSSet *timestampsAsSet = queuedReceiptMap[recipientId]; - if (timestampsAsSet.count < 1) { - continue; - } - - TSThread *thread = [TSContactThread getOrCreateThreadWithContactSessionID:recipientId]; - - if (thread.isGroupThread) { // Don't send receipts in group threads - continue; - } - - SNReadReceipt *readReceipt = [SNReadReceipt new]; - NSMutableArray *timestamps = [NSMutableArray new]; - for (NSNumber *timestamp in timestampsAsSet) { - [timestamps addObject:timestamp]; - } - readReceipt.timestamps = timestamps; - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - AnyPromise *promise = [SNMessageSender sendNonDurably:readReceipt inThread:thread usingTransaction:transaction] - .thenOn(self.serialQueue, ^(id object) { - [self dequeueReceiptsWithRecipientId:recipientId timestamps:timestampsAsSet]; - }); - [sendPromises addObject:promise]; - }]; - } - - return [sendPromises copy]; -} - -- (void)enqueueReadReceiptForEnvelope:(NSString *)messageAuthorId timestamp:(uint64_t)timestamp { - [self enqueueReceiptWithRecipientId:messageAuthorId timestamp:timestamp]; -} - -- (void)enqueueReceiptWithRecipientId:(NSString *)recipientId timestamp:(uint64_t)timestamp { - if (recipientId.length < 1) { - return; - } - if (timestamp < 1) { - return; - } - dispatch_async(self.serialQueue, ^{ - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - NSSet *_Nullable oldTimestamps = [transaction objectForKey:recipientId inCollection:kOutgoingReadReceiptManagerCollection]; - NSMutableSet *newTimestamps - = (oldTimestamps ? [oldTimestamps mutableCopy] : [NSMutableSet new]); - [newTimestamps addObject:@(timestamp)]; - - [transaction setObject:newTimestamps forKey:recipientId inCollection:kOutgoingReadReceiptManagerCollection]; - }]; - - [self process]; - }); -} - -- (void)dequeueReceiptsWithRecipientId:(NSString *)recipientId timestamps:(NSSet *)timestamps { - if (recipientId.length < 1) { - return; - } - if (timestamps.count < 1) { - return; - } - dispatch_async(self.serialQueue, ^{ - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - NSSet *_Nullable oldTimestamps = [transaction objectForKey:recipientId inCollection:kOutgoingReadReceiptManagerCollection]; - NSMutableSet *newTimestamps - = (oldTimestamps ? [oldTimestamps mutableCopy] : [NSMutableSet new]); - [newTimestamps minusSet:timestamps]; - - if (newTimestamps.count > 0) { - [transaction setObject:newTimestamps forKey:recipientId inCollection:kOutgoingReadReceiptManagerCollection]; - } else { - [transaction removeObjectForKey:recipientId inCollection:kOutgoingReadReceiptManagerCollection]; - } - }]; - }); -} - -- (void)reachabilityChanged -{ - [self process]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Sending & Receiving/Read Tracking/OWSReadReceiptManager.h b/SessionMessagingKit/Sending & Receiving/Read Tracking/OWSReadReceiptManager.h deleted file mode 100644 index c2dfddec5..000000000 --- a/SessionMessagingKit/Sending & Receiving/Read Tracking/OWSReadReceiptManager.h +++ /dev/null @@ -1,60 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@class OWSPrimaryStorage; -@class SNProtoSyncMessageRead; -@class TSIncomingMessage; -@class TSOutgoingMessage; -@class TSThread; -@class YapDatabaseReadTransaction; -@class YapDatabaseReadWriteTransaction; - -extern NSString *const kIncomingMessageMarkedAsReadNotification; - -@interface OWSReadReceiptManager : NSObject - -- (instancetype)init NS_UNAVAILABLE; -- (instancetype)initWithPrimaryStorage:(OWSPrimaryStorage *)primaryStorage NS_DESIGNATED_INITIALIZER; -+ (instancetype)sharedManager; - -#pragma mark - Sender/Recipient Read Receipts - -// This method should be called when we receive a read receipt -// from a user to whom we have sent a message. -// -// This method can be called from any thread. -- (void)processReadReceiptsFromRecipientId:(NSString *)recipientId - sentTimestamps:(NSArray *)sentTimestamps - readTimestamp:(uint64_t)readTimestamp; - -#pragma mark - Locally Read - -// This method cues this manager: -// -// * ...to inform the sender that this message was read (if read receipts -// are enabled). -// * ...to inform the local user's other devices that this message was read. -// -// Both types of messages are deduplicated. -// -// This method can be called from any thread. -- (void)messageWasReadLocally:(TSIncomingMessage *)message; - -- (void)markAsReadLocallyBeforeSortId:(uint64_t)sortId thread:(TSThread *)thread trySendReadReceipt:(BOOL)trySendReadReceipt; - -#pragma mark - Settings - -- (void)prepareCachedValues; - -- (BOOL)areReadReceiptsEnabled; -- (BOOL)areReadReceiptsEnabledWithTransaction:(YapDatabaseReadTransaction *)transaction; -- (void)setAreReadReceiptsEnabled:(BOOL)value; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Sending & Receiving/Read Tracking/OWSReadReceiptManager.m b/SessionMessagingKit/Sending & Receiving/Read Tracking/OWSReadReceiptManager.m deleted file mode 100644 index 4517e2be4..000000000 --- a/SessionMessagingKit/Sending & Receiving/Read Tracking/OWSReadReceiptManager.m +++ /dev/null @@ -1,322 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "OWSReadReceiptManager.h" -#import "AppReadiness.h" -#import "OWSOutgoingReceiptManager.h" -#import "OWSPrimaryStorage.h" -#import "OWSStorage.h" -#import "SSKEnvironment.h" -#import "TSAccountManager.h" -#import "TSContactThread.h" -#import "TSOutgoingMessage.h" -#import "TSDatabaseView.h" -#import "TSIncomingMessage.h" -#import "YapDatabaseConnection+OWS.h" -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -NSString *const kIncomingMessageMarkedAsReadNotification = @"kIncomingMessageMarkedAsReadNotification"; - -@interface TSRecipientReadReceipt : TSYapDatabaseObject - -@property (nonatomic, readonly) uint64_t sentTimestamp; -// Map of "recipient id"-to-"read timestamp". -@property (nonatomic, readonly) NSDictionary *recipientMap; - -@end - -#pragma mark - - -@implementation TSRecipientReadReceipt - -+ (NSString *)collection -{ - return @"TSRecipientReadReceipt2"; -} - -- (instancetype)initWithSentTimestamp:(uint64_t)sentTimestamp -{ - self = [super initWithUniqueId:[TSRecipientReadReceipt uniqueIdForSentTimestamp:sentTimestamp]]; - - if (self) { - _sentTimestamp = sentTimestamp; - _recipientMap = [NSDictionary new]; - } - - return self; -} - -+ (NSString *)uniqueIdForSentTimestamp:(uint64_t)timestamp -{ - return [NSString stringWithFormat:@"%llu", timestamp]; -} - -- (void)addRecipientId:(NSString *)recipientId timestamp:(uint64_t)timestamp -{ - NSMutableDictionary *recipientMapCopy = [self.recipientMap mutableCopy]; - recipientMapCopy[recipientId] = @(timestamp); - _recipientMap = [recipientMapCopy copy]; -} - -+ (void)addRecipientId:(NSString *)recipientId - sentTimestamp:(uint64_t)sentTimestamp - readTimestamp:(uint64_t)readTimestamp - transaction:(YapDatabaseReadWriteTransaction *)transaction -{ - TSRecipientReadReceipt *_Nullable recipientReadReceipt = - [transaction objectForKey:[self uniqueIdForSentTimestamp:sentTimestamp] inCollection:[self collection]]; - if (!recipientReadReceipt) { - recipientReadReceipt = [[TSRecipientReadReceipt alloc] initWithSentTimestamp:sentTimestamp]; - } - [recipientReadReceipt addRecipientId:recipientId timestamp:readTimestamp]; - [recipientReadReceipt saveWithTransaction:transaction]; -} - -+ (nullable NSDictionary *)recipientMapForSentTimestamp:(uint64_t)sentTimestamp - transaction: - (YapDatabaseReadWriteTransaction *)transaction -{ - TSRecipientReadReceipt *_Nullable recipientReadReceipt = - [transaction objectForKey:[self uniqueIdForSentTimestamp:sentTimestamp] inCollection:[self collection]]; - return recipientReadReceipt.recipientMap; -} - -+ (void)removeRecipientIdsForTimestamp:(uint64_t)sentTimestamp - transaction:(YapDatabaseReadWriteTransaction *)transaction -{ - [transaction removeObjectForKey:[self uniqueIdForSentTimestamp:sentTimestamp] inCollection:[self collection]]; -} - -@end - -#pragma mark - - -NSString *const OWSReadReceiptManagerCollection = @"OWSReadReceiptManagerCollection"; -NSString *const OWSReadReceiptManagerAreReadReceiptsEnabled = @"areReadReceiptsEnabled"; - -@interface OWSReadReceiptManager () - -@property (nonatomic, readonly) YapDatabaseConnection *dbConnection; - -// A map of "thread unique id"-to-"read receipt" for read receipts that -// we will send to our linked devices. -// -// Should only be accessed while synchronized on the OWSReadReceiptManager. -// @property (nonatomic, readonly) NSMutableDictionary *toLinkedDevicesReadReceiptMap; - -// Should only be accessed while synchronized on the OWSReadReceiptManager. -@property (nonatomic) BOOL isProcessing; - -@property (atomic) NSNumber *areReadReceiptsEnabledCached; - -@end - -#pragma mark - - -@implementation OWSReadReceiptManager - -+ (instancetype)sharedManager -{ - return SSKEnvironment.shared.readReceiptManager; -} - -- (instancetype)initWithPrimaryStorage:(OWSPrimaryStorage *)primaryStorage -{ - self = [super init]; - - if (!self) { - return self; - } - - _dbConnection = primaryStorage.newDatabaseConnection; - - // Start processing. - [AppReadiness runNowOrWhenAppDidBecomeReady:^{ - [self scheduleProcessing]; - }]; - - return self; -} - -- (void)dealloc -{ - [[NSNotificationCenter defaultCenter] removeObserver:self]; -} - -#pragma mark - Dependencies - -- (OWSOutgoingReceiptManager *)outgoingReceiptManager -{ - return SSKEnvironment.shared.outgoingReceiptManager; -} - -#pragma mark - - -// Schedules a processing pass, unless one is already scheduled. -- (void)scheduleProcessing -{ - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - @synchronized(self) - { - if (self.isProcessing) { - return; - } - - self.isProcessing = YES; - - [self process]; - } - }); -} - -- (void)process -{ - -} - -#pragma mark - Mark as Read Locally - -- (void)markAsReadLocallyBeforeSortId:(uint64_t)sortId thread:(TSThread *)thread trySendReadReceipt:(BOOL)trySendReadReceipt -{ - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [self markAsReadBeforeSortId:sortId - thread:thread - readTimestamp:[NSDate millisecondTimestamp] - trySendReadReceipt:trySendReadReceipt - transaction:transaction]; - }]; -} - -- (void)messageWasReadLocally:(TSIncomingMessage *)message -{ - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - @synchronized(self) - { - NSString *messageAuthorId = message.authorId; - - if (message.thread.isGroupThread) { return; } // Don't send read receipts in group threads - - if ([self areReadReceiptsEnabled]) { - [self.outgoingReceiptManager enqueueReadReceiptForEnvelope:messageAuthorId timestamp:message.timestamp]; - } - - [self scheduleProcessing]; - } - }); -} - -#pragma mark - Read Receipts From Recipient - -- (void)processReadReceiptsFromRecipientId:(NSString *)recipientId - sentTimestamps:(NSArray *)sentTimestamps - readTimestamp:(uint64_t)readTimestamp -{ - if (![self areReadReceiptsEnabled]) { - return; - } - - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - for (NSNumber *nsSentTimestamp in sentTimestamps) { - UInt64 sentTimestamp = [nsSentTimestamp unsignedLongLongValue]; - - NSArray *messages - = (NSArray *)[TSInteraction interactionsWithTimestamp:sentTimestamp - ofClass:[TSOutgoingMessage class] - withTransaction:transaction]; - if (messages.count > 0) { - // TODO: We might also need to "mark as read by recipient" any older messages - // from us in that thread. Or maybe this state should hang on the thread? - for (TSOutgoingMessage *message in messages) { - [message updateWithReadRecipientId:recipientId - readTimestamp:readTimestamp - transaction:transaction]; - } - } else { - // Persist the read receipts so that we can apply them to outgoing messages - // that we learn about later through sync messages. - [TSRecipientReadReceipt addRecipientId:recipientId - sentTimestamp:sentTimestamp - readTimestamp:readTimestamp - transaction:transaction]; - } - } - }]; - }); -} - -#pragma mark - Mark As Read - -- (void)markAsReadBeforeSortId:(uint64_t)sortId - thread:(TSThread *)thread - readTimestamp:(uint64_t)readTimestamp - trySendReadReceipt:(BOOL)trySendReadReceipt - transaction:(YapDatabaseReadWriteTransaction *)transaction -{ - NSMutableArray> *newlyReadList = [NSMutableArray new]; - - [[TSDatabaseView unseenDatabaseViewExtension:transaction] - enumerateKeysAndObjectsInGroup:thread.uniqueId - usingBlock:^(NSString *collection, NSString *key, id object, NSUInteger index, BOOL *stop) { - if (![object conformsToProtocol:@protocol(OWSReadTracking)]) { - return; - } - id possiblyRead = (id)object; - if (possiblyRead.sortId > sortId) { - *stop = YES; - return; - } - - // Under normal circumstances !possiblyRead.read should always evaluate to true at this point, but - // there is a bug that can somehow cause it to be false leading to conversations permanently being - // stuck with "unread" messages. - - if (!possiblyRead.read) { - [newlyReadList addObject:possiblyRead]; - } - }]; - - if (newlyReadList.count < 1) { - return; - } - - for (id readItem in newlyReadList) { - [readItem markAsReadAtTimestamp:readTimestamp trySendReadReceipt:trySendReadReceipt transaction:transaction]; - } -} - -#pragma mark - Settings - -- (void)prepareCachedValues -{ - [self areReadReceiptsEnabled]; -} - -- (BOOL)areReadReceiptsEnabled -{ - // We don't need to worry about races around this cached value. - if (!self.areReadReceiptsEnabledCached) { - self.areReadReceiptsEnabledCached = @([self.dbConnection boolForKey:OWSReadReceiptManagerAreReadReceiptsEnabled - inCollection:OWSReadReceiptManagerCollection - defaultValue:NO]); - } - - return [self.areReadReceiptsEnabledCached boolValue]; -} - -- (void)setAreReadReceiptsEnabled:(BOOL)value -{ - [self.dbConnection setBool:value - forKey:OWSReadReceiptManagerAreReadReceiptsEnabled - inCollection:OWSReadReceiptManagerCollection]; - - self.areReadReceiptsEnabledCached = @(value); -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Sending & Receiving/Read Tracking/OWSReadTracking.h b/SessionMessagingKit/Sending & Receiving/Read Tracking/OWSReadTracking.h deleted file mode 100644 index b08071959..000000000 --- a/SessionMessagingKit/Sending & Receiving/Read Tracking/OWSReadTracking.h +++ /dev/null @@ -1,37 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@class YapDatabaseReadWriteTransaction; - -/** - * Some interactions track read/unread status. - * e.g. incoming messages and call notifications - */ -@protocol OWSReadTracking - -/** - * Has the local user seen the interaction? - */ -@property (nonatomic, readonly, getter=wasRead) BOOL read; - -@property (nonatomic, readonly) uint64_t expireStartedAt; -@property (nonatomic, readonly) uint64_t sortId; -@property (nonatomic, readonly) NSString *uniqueThreadId; - -- (BOOL)shouldAffectUnreadCounts; - -/** - * Used both for *responding* to a remote read receipt and in response to the local user's activity. - */ -- (void)markAsReadAtTimestamp:(uint64_t)readTimestamp - trySendReadReceipt:(BOOL)trySendReadReceipt - transaction:(YapDatabaseReadWriteTransaction *)transaction; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Threads/TSThread.h b/SessionMessagingKit/Threads/TSThread.h index 7968ca7ad..4cbf068f2 100644 --- a/SessionMessagingKit/Threads/TSThread.h +++ b/SessionMessagingKit/Threads/TSThread.h @@ -72,9 +72,6 @@ BOOL IsNoteToSelfEnabled(void); - (NSUInteger)numberOfInteractionsWithTransaction:(YapDatabaseReadTransaction *)transaction; -- (NSUInteger)unreadMessageCountWithTransaction:(YapDatabaseReadTransaction *)transaction - NS_SWIFT_NAME(unreadMessageCount(transaction:)); - /** * @return If there is any message mentioning current user in this thread. */ @@ -82,8 +79,6 @@ BOOL IsNoteToSelfEnabled(void); - (NSUInteger)unreadMentionMessageCountWithTransaction:(YapDatabaseReadTransaction *)transaction; -- (void)markAllAsReadWithTransaction:(YapDatabaseReadWriteTransaction *)transaction; - /** * Returns the string that will be displayed typically in a conversations view as a preview of the last message * received in this thread. diff --git a/SessionMessagingKit/Threads/TSThread.m b/SessionMessagingKit/Threads/TSThread.m index 5b178af25..0092e8f81 100644 --- a/SessionMessagingKit/Threads/TSThread.m +++ b/SessionMessagingKit/Threads/TSThread.m @@ -253,52 +253,6 @@ BOOL IsNoteToSelfEnabled(void) return [interactionsByThread numberOfItemsInGroup:self.uniqueId]; } -- (NSArray> *)unseenMessagesWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - NSMutableArray> *messages = [NSMutableArray new]; - [[TSDatabaseView unseenDatabaseViewExtension:transaction] - enumerateKeysAndObjectsInGroup:self.uniqueId - usingBlock:^(NSString *collection, NSString *key, id object, NSUInteger index, BOOL *stop) { - if (![object conformsToProtocol:@protocol(OWSReadTracking)]) { - return; - } - id unread = (id)object; - if (unread.read) { - NSLog(@"Found an already read message in the * unseen * messages list."); - return; - } - [messages addObject:unread]; - }]; - - return [messages copy]; -} - -- (NSUInteger)unreadMessageCountWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - YapDatabaseViewTransaction *unreadMessages = [transaction ext:TSUnreadDatabaseViewExtensionName]; - return [unreadMessages numberOfItemsInGroup:self.uniqueId]; - - - // FIXME: Why did we have to do as the following? -// __block NSUInteger count = 0; -// -// YapDatabaseViewTransaction *unreadMessages = [transaction ext:TSUnreadDatabaseViewExtensionName]; -// [unreadMessages enumerateKeysAndObjectsInGroup:self.uniqueId -// usingBlock:^(NSString *collection, NSString *key, id object, NSUInteger index, BOOL *stop) { -// if (![object conformsToProtocol:@protocol(OWSReadTracking)]) { -// return; -// } -// id unread = (id)object; -// if (unread.read) { -// NSLog(@"Found an already read message in the * unread * messages list."); -// return; -// } -// count += 1; -// }]; - -// return count; -} - - (NSUInteger)unreadMentionMessageCount { __block NSUInteger unreadMentionMessageCount; @@ -314,15 +268,6 @@ BOOL IsNoteToSelfEnabled(void) return [unreadMentions numberOfItemsInGroup:self.uniqueId]; } -- (void)markAllAsReadWithTransaction:(YapDatabaseReadWriteTransaction *)transaction -{ - for (id message in [self unseenMessagesWithTransaction:transaction]) { - [message markAsReadAtTimestamp:[NSDate ows_millisecondTimeStamp] trySendReadReceipt:YES transaction:transaction]; - } - - [super saveWithTransaction:transaction]; -} - - (nullable TSInteraction *)lastInteractionForInboxWithTransaction:(YapDatabaseReadTransaction *)transaction { __block NSUInteger missedCount = 0; diff --git a/SessionMessagingKit/Utilities/Preferences.swift b/SessionMessagingKit/Utilities/Preferences.swift index 557f4198c..09342dd6d 100644 --- a/SessionMessagingKit/Utilities/Preferences.swift +++ b/SessionMessagingKit/Utilities/Preferences.swift @@ -307,7 +307,7 @@ public class SMKSound: NSObject { .fetchOne( db, SessionThread - .select(SessionThread.Columns.notificationSound) + .select(.notificationSound) .filter(id: threadId) ) }? diff --git a/SessionMessagingKit/Utilities/ProfileManager.swift b/SessionMessagingKit/Utilities/ProfileManager.swift index 3ed78927b..550132d19 100644 --- a/SessionMessagingKit/Utilities/ProfileManager.swift +++ b/SessionMessagingKit/Utilities/ProfileManager.swift @@ -45,6 +45,10 @@ public struct ProfileManager { } guard let profile: Profile = try? Profile.fetchOne(db, id: id) else { return nil } + return profileAvatar(profile: profile) + } + + public static func profileAvatar(profile: Profile) -> UIImage? { if let profileFileName: String = profile.profilePictureFileName, !profileFileName.isEmpty { return loadProfileAvatar(for: profileFileName) } @@ -186,7 +190,7 @@ public struct ProfileManager { profileName: String, avatarImage: UIImage?, requiredSync: Bool, - success: (() -> ())? = nil, + success: ((Profile) -> ())? = nil, failure: ((Error) -> ())? = nil ) { DispatchQueue.global(qos: .default).async { @@ -205,7 +209,7 @@ public struct ProfileManager { "Updating local profile on service with no avatar." ) - try? existingProfile + let updatedProfile: Profile = try existingProfile .with( name: profileName, profilePictureUrl: nil, @@ -215,20 +219,20 @@ public struct ProfileManager { .existing ) ) - .save(db) + .saved(db) // Remove any cached avatar image value if let fileName: String = existingProfile.profilePictureFileName { profileAvatarCache.mutate { $0[fileName] = nil } } - }, - completion: { _, _ in + SNLog("Successfully updated service with profile.") DispatchQueue.main.async { - success?() + success?(updatedProfile) } - } + }, + completion: { _, _ in } ) return } @@ -302,7 +306,7 @@ public struct ProfileManager { GRDBStorage.shared.writeAsync( updates: { db in - try? Profile + let profile: Profile = try Profile .fetchOrCreateCurrentUser(db) .with( name: profileName, @@ -310,17 +314,17 @@ public struct ProfileManager { profilePictureFileName: .update(fileName), profileEncryptionKey: .update(newProfileKey) ) - .save(db) - }, - completion: { _, _ in + .saved(db) + // Update the cached avatar image value profileAvatarCache.mutate { $0[fileName] = avatarImage } DispatchQueue.main.async { SNLog("Successfully updated service with profile.") - success?() + success?(profile) } - } + }, + completion: { _, _ in } ) } .recover { error in diff --git a/SessionMessagingKit/Utilities/SSKIncrementingIdFinder.swift b/SessionMessagingKit/Utilities/SSKIncrementingIdFinder.swift deleted file mode 100644 index 84e6a33af..000000000 --- a/SessionMessagingKit/Utilities/SSKIncrementingIdFinder.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -import Foundation - -@objc -public class SSKIncrementingIdFinder: NSObject { - - @objc - public static let collectionName = "IncrementingIdCollection" - - @objc - public class func previousId(key: String, transaction: YapDatabaseReadTransaction) -> UInt64 { - let previousId: UInt64 = transaction.object(forKey: key, inCollection: collectionName) as? UInt64 ?? 0 - return previousId - } - - @objc - public class func nextId(key: String, transaction: YapDatabaseReadWriteTransaction) -> UInt64 { - let previousId: UInt64 = transaction.object(forKey: key, inCollection: collectionName) as? UInt64 ?? 0 - let nextId: UInt64 = previousId + 1 - - transaction.setObject(nextId, forKey: key, inCollection: collectionName) - return nextId - } -} diff --git a/SessionShareExtension/ShareVC.swift b/SessionShareExtension/ShareVC.swift index 1dbb279ad..4cd67274f 100644 --- a/SessionShareExtension/ShareVC.swift +++ b/SessionShareExtension/ShareVC.swift @@ -131,8 +131,6 @@ final class ShareVC : UINavigationController, ShareViewDelegate, AppModeManagerD // We don't need to use OWSMessageReceiver in the SAE. // We don't need to use OWSBatchMessageProcessor in the SAE. // We don't need to fetch the local profile in the SAE - - OWSReadReceiptManager.shared().prepareCachedValues() } override func viewDidLoad() { diff --git a/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift index 8740d8191..42cb1e425 100644 --- a/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -27,7 +27,7 @@ enum _001_InitialSetupMigration: Migration { [.address, .port], references: Snode.self, columns: [.address, .port], - onDelete: .cascade + onDelete: .cascade // Delete if Snode deleted ) t.primaryKey([.key, .nodeIndex]) } @@ -38,11 +38,11 @@ enum _001_InitialSetupMigration: Migration { .primaryKey(autoincrement: true) t.column(.key, .text) .notNull() - .indexed() + .indexed() // Quicker querying t.column(.hash, .text).notNull() t.column(.expirationDateMs, .integer) .notNull() - .indexed() + .indexed() // Quicker querying t.uniqueKey([.key, .hash]) } diff --git a/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift b/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift index 8010df5d3..78df09302 100644 --- a/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift +++ b/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift @@ -61,13 +61,13 @@ public extension SnodeReceivedMessageInfo { // MARK: - GRDB Interactions public extension SnodeReceivedMessageInfo { - static func pruneLastMessageHashInfoIfExpired(for snode: Snode, associatedWith publicKey: String) { - // Clear out the 'expirationDateMs' value for all expired (but non-0) message infos + static func pruneExpiredMessageHashInfo(for snode: Snode, associatedWith publicKey: String) { + // Delete any expired (but non-0) SnodeReceivedMessageInfo values associated to a specific node GRDBStorage.shared.write { db in try? SnodeReceivedMessageInfo .filter(SnodeReceivedMessageInfo.Columns.key == key(for: snode, publicKey: publicKey)) - .filter(SnodeReceivedMessageInfo.Columns.expirationDateMs > 0) - .updateAll(db, SnodeReceivedMessageInfo.Columns.expirationDateMs.set(to: 0)) + .filter(SnodeReceivedMessageInfo.Columns.expirationDateMs <= (Date().timeIntervalSince1970 * 1000)) + .deleteAll(db) } } @@ -80,7 +80,7 @@ public extension SnodeReceivedMessageInfo { return GRDBStorage.shared.write { db in try SnodeReceivedMessageInfo .filter(SnodeReceivedMessageInfo.Columns.key == key(for: snode, publicKey: publicKey)) - .filter(SnodeReceivedMessageInfo.Columns.expirationDateMs <= (Date().timeIntervalSince1970 * 1000)) + .filter(SnodeReceivedMessageInfo.Columns.expirationDateMs > (Date().timeIntervalSince1970 * 1000)) .order(SnodeReceivedMessageInfo.Columns.id.desc) .fetchOne(db) } diff --git a/SessionSnodeKit/SnodeAPI.swift b/SessionSnodeKit/SnodeAPI.swift index b64ec0594..f6df27b5a 100644 --- a/SessionSnodeKit/SnodeAPI.swift +++ b/SessionSnodeKit/SnodeAPI.swift @@ -411,13 +411,33 @@ public final class SnodeAPI : NSObject { } } - public static func getRawMessages(from snode: Snode, associatedWith publicKey: String) -> RawResponsePromise { - let (promise, seal) = RawResponsePromise.pending() + public static func getMessages(from snode: Snode, associatedWith publicKey: String) -> Promise<[SnodeReceivedMessage]> { + let (promise, seal) = Promise<[SnodeReceivedMessage]>.pending() Threading.workQueue.async { getMessagesInternal(from: snode, associatedWith: publicKey) - .done2 { seal.fulfill($0) } + .done2 { rawResponse in + guard + let json: JSON = rawResponse as? JSON, + let rawMessages: [JSON] = json["messages"] as? [JSON] + else { + seal.fulfill([]) + return + } + + let messages: [SnodeReceivedMessage] = rawMessages + .compactMap { rawMessage -> SnodeReceivedMessage? in + SnodeReceivedMessage( + snode: snode, + publicKey: publicKey, + rawMessage: rawMessage + ) + } + + seal.fulfill(messages) + } .catch2 { seal.reject($0) } } + return promise } @@ -428,7 +448,7 @@ public final class SnodeAPI : NSObject { // guard let userED25519KeyPair = storage.getUserED25519KeyPair() else { return Promise(error: Error.noKeyPair) } // Get last message hash - SnodeReceivedMessageInfo.pruneLastMessageHashInfoIfExpired(for: snode, associatedWith: publicKey) + SnodeReceivedMessageInfo.pruneExpiredMessageHashInfo(for: snode, associatedWith: publicKey) let lastHash = SnodeReceivedMessageInfo.fetchLastNotExpired(for: snode, associatedWith: publicKey)?.hash ?? "" // Construct signature // let timestamp = UInt64(Int64(NSDate.millisecondTimestamp()) + SnodeAPI.clockOffset) @@ -675,18 +695,16 @@ public final class SnodeAPI : NSObject { } private static func removeDuplicates(from rawMessages: [JSON], associatedWith publicKey: String) -> [JSON] { - var oldReceivedMessages: [SnodeReceivedMessageInfo] = [] - - GRDBStorage.shared.read { db in - oldReceivedMessages = oldReceivedMessages.appending( - contentsOf: try? SnodeReceivedMessageInfo - .filter(SnodeReceivedMessageInfo.Columns.key.like("%\(publicKey)")) + let oldMessageHashes: Set = GRDBStorage.shared + .read { db in + try SnodeReceivedMessageInfo + .select(.hash) + .filter(SnodeReceivedMessageInfo.Columns.key == publicKey) + .asRequest(of: String.self) .fetchAll(db) - ) - } - - let oldMessageHashes: Set = oldReceivedMessages.map { $0.hash }.asSet() - + } + .defaulting(to: []) + .asSet() return rawMessages .compactMap { rawMessage -> JSON? in guard let hash: String = rawMessage["hash"] as? String else { diff --git a/SessionUtilitiesKit/Database/GRDBStorage.swift b/SessionUtilitiesKit/Database/GRDBStorage.swift index d5e322cf0..a691e96d1 100644 --- a/SessionUtilitiesKit/Database/GRDBStorage.swift +++ b/SessionUtilitiesKit/Database/GRDBStorage.swift @@ -268,7 +268,16 @@ public final class GRDBStorage { // MARK: - Promise Extensions public extension GRDBStorage { - // FIXME: Would be good to replace this with Swift Combine + // FIXME: Would be good to replace these with Swift Combine + @discardableResult func read(_ value: (Database) throws -> Promise) -> Promise { + do { + return try dbPool.read(value) + } + catch { + return Promise(error: error) + } + } + @discardableResult func write(updates: (Database) throws -> Promise) -> Promise { do { return try dbPool.write(updates) diff --git a/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift index 858ccd70a..0f5a2d5ef 100644 --- a/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -39,6 +39,18 @@ enum _001_InitialSetupMigration: Migration { t.column(.details, .blob) } + try db.create(table: JobDependencies.self) { t in + t.column(.jobId, .integer) + .notNull() + .references(Job.self, onDelete: .cascade) // Delete if Job deleted + t.column(.dependantId, .integer) + .notNull() + .indexed() // Quicker querying + .references(Job.self, onDelete: .cascade) // Delete if Job deleted + + t.primaryKey([.jobId, .dependantId]) + } + try db.create(table: Setting.self) { t in t.column(.key, .text) .notNull() diff --git a/SessionUtilitiesKit/Database/Models/Job.swift b/SessionUtilitiesKit/Database/Models/Job.swift index ae9410138..875f56307 100644 --- a/SessionUtilitiesKit/Database/Models/Job.swift +++ b/SessionUtilitiesKit/Database/Models/Job.swift @@ -5,6 +5,8 @@ import GRDB public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "job" } + internal static let dependencyForeignKey = ForeignKey([Columns.id], to: [JobDependencies.Columns.dependantId]) + internal static let dependencies = hasMany(Job.self, using: dependencyForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -143,6 +145,14 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer /// JSON encoded data required for the job public let details: Data? + /// The other jobs which this job is dependant on + /// + /// **Note:** When completing a job the dependencies **MUST** be cleared before the job is + /// deleted or it will automatically delete any dependant jobs + public var dependencies: QueryInterfaceRequest { + request(for: Job.dependencies) + } + // MARK: - Initialization fileprivate init( @@ -191,6 +201,8 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer interactionId: Int64? = nil, details: T? ) { + precondition(T.self != Job.self, "[Job] Fatal error trying to create a Job with a Job as it's details") + guard let details: T = details, let detailsData: Data = try? JSONEncoder().encode(details) @@ -210,6 +222,14 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer public mutating func didInsert(with rowID: Int64, for column: String?) { self.id = rowID } + + public func delete(_ db: Database) throws -> Bool { + // Delete any dependencies + try dependencies + .deleteAll(db) + + return try performDelete(db) + } } // MARK: - GRDB Interactions diff --git a/SessionUtilitiesKit/Database/Models/JobDependencies.swift b/SessionUtilitiesKit/Database/Models/JobDependencies.swift new file mode 100644 index 000000000..fd98fa61f --- /dev/null +++ b/SessionUtilitiesKit/Database/Models/JobDependencies.swift @@ -0,0 +1,40 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +public struct JobDependencies: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "jobDependencies" } + internal static let jobForeignKey = ForeignKey([Columns.jobId], to: [Job.Columns.id]) + internal static let job = belongsTo(Job.self, using: jobForeignKey) + internal static let dependant = hasOne(Job.self, using: Job.dependencyForeignKey) + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case jobId + case dependantId + } + + public let jobId: Int64 + public let dependantId: Int64 + + // MARK: - Initialization + + public init( + jobId: Int64, + dependantId: Int64 + ) { + self.jobId = jobId + self.dependantId = dependantId + } + + // MARK: - Relationships + + public var job: QueryInterfaceRequest { + request(for: JobDependencies.job) + } + + public var dependant: QueryInterfaceRequest { + request(for: JobDependencies.dependant) + } +} diff --git a/SessionUtilitiesKit/Database/Types/TypedTableAlias.swift b/SessionUtilitiesKit/Database/Types/TypedTableAlias.swift index 9c526ec14..14dd0aacd 100644 --- a/SessionUtilitiesKit/Database/Types/TypedTableAlias.swift +++ b/SessionUtilitiesKit/Database/Types/TypedTableAlias.swift @@ -4,11 +4,35 @@ import Foundation import GRDB public class TypedTableAlias where T: TableRecord, T: ColumnExpressible { - let alias: TableAlias = TableAlias(name: T.databaseTableName) + public let alias: TableAlias = TableAlias(name: T.databaseTableName) public init() {} public subscript(_ column: T.Columns) -> SQLExpression { return alias[column.name] } + + /// **Warning:** For this to work you **MUST** call the '.aliased()' method when joining or it will + /// throw when trying to decode + public func allColumns() -> SQLSelection { + return alias[AllColumns().sqlSelection] + } +} + +extension QueryInterfaceRequest { + public func aliased(_ typedAlias: TypedTableAlias) -> Self { + return aliased(typedAlias.alias) + } +} + +extension Association { + public func aliased(_ typedAlias: TypedTableAlias) -> Self { + return aliased(typedAlias.alias) + } +} + +extension TableAlias { + public func allColumns() -> SQLSelection { + return self[AllColumns().sqlSelection] + } } diff --git a/SessionUtilitiesKit/General/Array+Utilities.swift b/SessionUtilitiesKit/General/Array+Utilities.swift index 30575df00..059dd9d3e 100644 --- a/SessionUtilitiesKit/General/Array+Utilities.swift +++ b/SessionUtilitiesKit/General/Array+Utilities.swift @@ -34,6 +34,14 @@ public extension Array { return self.removeFirst() } + + func inserting(_ other: Element?, at index: Int) -> [Element] { + guard let other: Element = other else { return self } + + var updatedArray: [Element] = self + updatedArray.insert(other, at: index) + return updatedArray + } } diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index 8947a0032..72e9877c6 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -147,11 +147,11 @@ public final class JobRunner { add(db, job: job, canStartJob: canStartJob) } - public static func insert(_ db: Database, job: Job?, before otherJob: Job) { + @discardableResult public static func insert(_ db: Database, job: Job?, before otherJob: Job) -> Job? { switch job?.behaviour { case .recurringOnActive, .recurringOnLaunch, .runOnceNextLaunch: SNLog("[JobRunner] Attempted to insert \(job.map { "\($0.variant)" } ?? "unknown") job before the current one even though it's behaviour is \(job.map { "\($0.behaviour)" } ?? "unknown")") - return + return nil default: break } @@ -159,7 +159,7 @@ public final class JobRunner { // Store the job into the database (getting an id for it) guard let updatedJob: Job = try? job?.inserted(db) else { SNLog("[JobRunner] Unable to add \(job.map { "\($0.variant)" } ?? "unknown") job") - return + return nil } // Insert the job before the current job (re-adding the current job to @@ -167,14 +167,15 @@ public final class JobRunner { // job will run and then the otherJob will run (or run again) once it's // done jobQueue.mutate { - if !$0.contains(otherJob) { - $0.insert(otherJob, at: 0) + guard let otherJobIndex: Int = $0.firstIndex(of: otherJob) else { + $0.insert(contentsOf: [updatedJob, otherJob], at: 0) + return } - guard let otherJobIndex: Int = $0.firstIndex(of: otherJob) else { return } - $0.insert(updatedJob, at: otherJobIndex) } + + return updatedJob } public static func appDidFinishLaunching() { @@ -339,6 +340,46 @@ public final class JobRunner { return } + // Check if the next job has any dependencies + let jobDependencies: [Job] = GRDBStorage.shared + .read { db in try nextJob.dependencies.fetchAll(db) } + .defaulting(to: []) + + guard jobDependencies.isEmpty else { + SNLog("[JobRunner] Found job with \(jobDependencies.count) dependencies, running those first") + + let jobDependencyIds: [Int64] = jobDependencies + .compactMap { $0.id } + let jobIdsNotInQueue: Set = jobDependencyIds + .asSet() + .subtracting(jobQueue.wrappedValue.compactMap { $0.id }) + + // If there are dependencies which aren't in the queue we should just append them + guard !jobIdsNotInQueue.isEmpty else { + jobQueue.mutate { queue in + queue.append( + contentsOf: jobDependencies + .filter { jobIdsNotInQueue.contains($0.id ?? -1) } + ) + queue.append(nextJob) + } + handleJobDeferred(nextJob) + return + } + + // Otherwise re-add the current job after it's dependencies + jobQueue.mutate { queue in + guard let lastDependencyIndex: Int = queue.lastIndex(where: { jobDependencyIds.contains($0.id ?? -1) }) else { + queue.append(nextJob) + return + } + + queue.insert(nextJob, at: lastDependencyIndex + 1) + } + handleJobDeferred(nextJob) + return + } + // Update the state to indicate it's running // // Note: We need to store 'numJobsRemaining' in it's own variable because @@ -363,7 +404,7 @@ public final class JobRunner { try TimeInterval .fetchOne( db, - Job// TODO: Test this works as expected + Job .filterPendingJobs(excludeFutureJobs: false) .select(.nextRunTimestamp) ) @@ -384,7 +425,7 @@ public final class JobRunner { } // Setup a trigger - SNLog("[JobRunner] Stopping until next job in \(Int(ceil(abs(secondsUntilNextJob))))) second\(Int(ceil(abs(secondsUntilNextJob))) == 1 ? "" : "s"))") + SNLog("[JobRunner] Stopping until next job in \(Int(ceil(abs(secondsUntilNextJob)))) second\(Int(ceil(abs(secondsUntilNextJob))) == 1 ? "" : "s"))") nextTrigger.mutate { $0 = Trigger.create(timestamp: nextJobTimestamp) } } @@ -395,12 +436,24 @@ public final class JobRunner { switch job.behaviour { case .runOnce, .runOnceNextLaunch: GRDBStorage.shared.write { db in - try job.delete(db) + // First remove any JobDependencies requiring this job to be completed (if + // we don't then the dependant jobs will automatically be deleted) + _ = try JobDependencies + .filter(JobDependencies.Columns.dependantId == job.id) + .deleteAll(db) + + _ = try job.delete(db) } case .recurring where shouldStop == true: GRDBStorage.shared.write { db in - try job.delete(db) + // First remove any JobDependencies requiring this job to be completed (if + // we don't then the dependant jobs will automatically be deleted) + _ = try JobDependencies + .filter(JobDependencies.Columns.dependantId == job.id) + .deleteAll(db) + + _ = try job.delete(db) } // For `recurring` jobs which have already run, they should automatically run again @@ -477,7 +530,7 @@ public final class JobRunner { else { // If the job permanently failed or we have performed all of our retry attempts // then delete the job (it'll probably never succeed) - try job.delete(db) + _ = try job.delete(db) return } @@ -500,7 +553,9 @@ public final class JobRunner { /// on other jobs, and it should automatically manage those dependencies) private static func handleJobDeferred(_ job: Job) { jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) } - runNextJob() + internalQueue.async { + runNextJob() + } } // MARK: - Convenience From f4ca2190308541bab4288f8f867b1387606190c1 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 6 May 2022 18:07:57 +1000 Subject: [PATCH 070/157] Re-added a bunch of functionality to the home and message requests screens Cleared out some more legacy code which has been refactored --- Session.xcodeproj/project.pbxproj | 60 --- .../Views & Modals/BodyTextView.swift | 11 +- .../ConversationTitleView.swift | 186 +++---- .../DownloadAttachmentModal.swift | 36 +- .../Views & Modals/ScrollToBottomButton.swift | 21 +- .../Views & Modals/UserDetailsSheet.swift | 26 +- Session/DMs/NewDMVC.swift | 9 +- Session/Home/HomeVC.swift | 276 +++++------ Session/Home/HomeViewModel.swift | 461 +++++++++++++++--- .../MessageRequestsViewController.swift | 448 +++++++---------- .../MessageRequestsViewModel.swift | 30 ++ .../GIFs/GifPickerViewController.swift | 21 +- .../SendMediaNavigationController.swift | 28 +- Session/Meta/AppDelegate.swift | 33 +- Session/Meta/SessionApp.swift | 18 +- Session/Meta/Signal-Bridging-Header.h | 1 - Session/Notifications/AppNotifications.swift | 46 +- Session/Notifications/SyncPushTokensJob.swift | 10 +- Session/Settings/QRCodeVC.swift | 13 +- Session/Shared/ConversationCell.swift | 101 ++-- Session/Utilities/AccountManager.swift | 10 +- Session/Utilities/BackgroundPoller.swift | 121 +++-- Session/Utilities/MentionUtilities.swift | 75 ++- Session/Utilities/MockDataGenerator.swift | 418 ++++++++-------- .../Database/Models/Attachment.swift | 221 ++++++++- .../Database/Models/Profile.swift | 23 +- .../Database/Models/SessionThread.swift | 42 +- .../Database/OWSPrimaryStorage.m | 2 - SessionMessagingKit/Jobs/JobDelegate.swift | 8 - SessionMessagingKit/Jobs/JobQueue.swift | 119 ----- .../Jobs/Types/MessageSendJob.swift | 89 +++- .../Messages/Signal/TSIncomingMessage.m | 2 - .../Messages/Signal/TSMessage.m | 1 - .../Visible Messages/VisibleMessage.swift | 53 +- .../Meta/SessionMessagingKit.h | 3 - .../Open Groups/OpenGroupAPIV2.swift | 14 +- ...sappearingConfigurationUpdateInfoMessage.h | 28 -- ...sappearingConfigurationUpdateInfoMessage.m | 92 ---- .../OWSDisappearingMessagesConfiguration.h | 35 -- .../OWSDisappearingMessagesConfiguration.m | 130 ----- .../Expiration/OWSDisappearingMessagesJob.h | 57 --- .../Expiration/OWSDisappearingMessagesJob.m | 371 -------------- .../MessageReceiver+Handling.swift | 159 ++++-- .../MessageSender+Convenience.swift | 52 +- .../Sending & Receiving/MessageSender.swift | 4 +- SessionMessagingKit/Threads/TSThread.m | 27 - .../Utilities/MessageInvalidator.swift | 3 + .../Utilities/OWSAudioPlayer.h | 2 +- SessionMessagingKit/Utilities/Threading.swift | 2 - .../OWSFailedAttachmentDownloadsJob.h | 31 -- .../OWSFailedAttachmentDownloadsJob.m | 142 ------ .../Messaging/OWSFailedMessagesJob.h | 31 -- .../Messaging/OWSFailedMessagesJob.m | 149 ------ SignalUtilitiesKit/Meta/SignalUtilitiesKit.h | 2 - .../Profile Pictures/ProfilePictureView.swift | 295 ++++++----- SignalUtilitiesKit/Utilities/AppSetup.m | 9 - SignalUtilitiesKit/Utilities/ThreadUtil.m | 1 - 57 files changed, 2031 insertions(+), 2627 deletions(-) delete mode 100644 SessionMessagingKit/Jobs/JobDelegate.swift delete mode 100644 SessionMessagingKit/Jobs/JobQueue.swift delete mode 100644 SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingConfigurationUpdateInfoMessage.h delete mode 100644 SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingConfigurationUpdateInfoMessage.m delete mode 100644 SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingMessagesConfiguration.h delete mode 100644 SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingMessagesConfiguration.m delete mode 100644 SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingMessagesJob.h delete mode 100644 SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingMessagesJob.m delete mode 100644 SignalUtilitiesKit/Messaging/OWSFailedAttachmentDownloadsJob.h delete mode 100644 SignalUtilitiesKit/Messaging/OWSFailedAttachmentDownloadsJob.m delete mode 100644 SignalUtilitiesKit/Messaging/OWSFailedMessagesJob.h delete mode 100644 SignalUtilitiesKit/Messaging/OWSFailedMessagesJob.m diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index ed3152ae5..32db73521 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -317,21 +317,15 @@ C32C5AB3256DBE8F003C73A2 /* TSInteraction.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDAE6255A580400E217F9 /* TSInteraction.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5AB4256DBE8F003C73A2 /* TSOutgoingMessage.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB48255A580C00E217F9 /* TSOutgoingMessage.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5AB5256DBE8F003C73A2 /* TSOutgoingMessage+Conversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = B84072952565E9F50037CB17 /* TSOutgoingMessage+Conversion.swift */; }; - C32C5AF8256DC051003C73A2 /* OWSDisappearingMessagesConfiguration.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBA4255A581400E217F9 /* OWSDisappearingMessagesConfiguration.m */; }; - C32C5B0A256DC076003C73A2 /* OWSDisappearingMessagesConfiguration.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDAD9255A580300E217F9 /* OWSDisappearingMessagesConfiguration.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5B1C256DC19D003C73A2 /* TSQuotedMessage.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB83255A581100E217F9 /* TSQuotedMessage.m */; }; C32C5B2D256DC1A1003C73A2 /* TSQuotedMessage.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDAD5255A580300E217F9 /* TSQuotedMessage.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5B3F256DC1DF003C73A2 /* TSQuotedMessage+Conversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = C32C5B3E256DC1DF003C73A2 /* TSQuotedMessage+Conversion.swift */; }; C32C5B48256DC211003C73A2 /* NSNotificationCenter+OWS.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB6C255A580F00E217F9 /* NSNotificationCenter+OWS.m */; }; C32C5B51256DC219003C73A2 /* NSNotificationCenter+OWS.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB3B255A580B00E217F9 /* NSNotificationCenter+OWS.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C32C5B62256DC333003C73A2 /* OWSDisappearingConfigurationUpdateInfoMessage.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA6B255A57FA00E217F9 /* OWSDisappearingConfigurationUpdateInfoMessage.m */; }; - C32C5B6B256DC357003C73A2 /* OWSDisappearingConfigurationUpdateInfoMessage.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDADA255A580400E217F9 /* OWSDisappearingConfigurationUpdateInfoMessage.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5B84256DC54F003C73A2 /* SSKEnvironment.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAF4255A580600E217F9 /* SSKEnvironment.m */; }; C32C5B8D256DC565003C73A2 /* SSKEnvironment.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB31255A580A00E217F9 /* SSKEnvironment.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5BBA256DC7E3003C73A2 /* ProfileManagerProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBB9255A581600E217F9 /* ProfileManagerProtocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5BCC256DC830003C73A2 /* Storage+ClosedGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8D8F1372566120F0092EF10 /* Storage+ClosedGroups.swift */; }; - C32C5BEF256DC8EE003C73A2 /* OWSDisappearingMessagesJob.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBDD255A581900E217F9 /* OWSDisappearingMessagesJob.m */; }; - C32C5BF8256DC8F6003C73A2 /* OWSDisappearingMessagesJob.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDA80255A57FC00E217F9 /* OWSDisappearingMessagesJob.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5C3D256DCBAF003C73A2 /* AppReadiness.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB75255A581000E217F9 /* AppReadiness.m */; }; C32C5C46256DCBB2003C73A2 /* AppReadiness.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB01255A580700E217F9 /* AppReadiness.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5C4F256DCC36003C73A2 /* Storage+OpenGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8D8F18825661BA50092EF10 /* Storage+OpenGroups.swift */; }; @@ -404,17 +398,14 @@ C33FD9C5255A54EF00E217F9 /* SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */; }; C33FDC27255A581F00E217F9 /* YapDatabase+Promise.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA6D255A57FA00E217F9 /* YapDatabase+Promise.swift */; }; C33FDC29255A581F00E217F9 /* ReachabilityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA6F255A57FA00E217F9 /* ReachabilityManager.swift */; }; - C33FDC2C255A581F00E217F9 /* OWSFailedAttachmentDownloadsJob.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDA72255A57FA00E217F9 /* OWSFailedAttachmentDownloadsJob.h */; settings = {ATTRIBUTES = (Public, ); }; }; C33FDC45255A581F00E217F9 /* AppVersion.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA8B255A57FD00E217F9 /* AppVersion.m */; }; C33FDC50255A582000E217F9 /* OWSDispatch.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDA96255A57FE00E217F9 /* OWSDispatch.h */; settings = {ATTRIBUTES = (Public, ); }; }; C33FDC53255A582000E217F9 /* OutageDetection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA99255A57FE00E217F9 /* OutageDetection.swift */; }; C33FDC58255A582000E217F9 /* ReverseDispatchQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA9E255A57FF00E217F9 /* ReverseDispatchQueue.swift */; }; C33FDC64255A582000E217F9 /* NSObject+Casting.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAAA255A580000E217F9 /* NSObject+Casting.m */; }; - C33FDC71255A582000E217F9 /* OWSFailedMessagesJob.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAB7255A580100E217F9 /* OWSFailedMessagesJob.m */; }; C33FDC78255A582000E217F9 /* TSConstants.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDABE255A580100E217F9 /* TSConstants.m */; }; C33FDC7B255A582000E217F9 /* NSSet+Functional.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAC1255A580100E217F9 /* NSSet+Functional.m */; }; C33FDC7D255A582000E217F9 /* OWSDispatch.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAC3255A580200E217F9 /* OWSDispatch.m */; }; - C33FDC95255A582000E217F9 /* OWSFailedMessagesJob.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDADB255A580400E217F9 /* OWSFailedMessagesJob.h */; settings = {ATTRIBUTES = (Public, ); }; }; C33FDC96255A582000E217F9 /* NSObject+Casting.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDADC255A580400E217F9 /* NSObject+Casting.h */; settings = {ATTRIBUTES = (Public, ); }; }; C33FDC98255A582000E217F9 /* SwiftSingletons.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDADE255A580400E217F9 /* SwiftSingletons.swift */; }; C33FDC9A255A582000E217F9 /* ByteParser.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAE0255A580400E217F9 /* ByteParser.m */; }; @@ -425,7 +416,6 @@ C33FDCFA255A582000E217F9 /* SignalIOSProto.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB40255A580C00E217F9 /* SignalIOSProto.swift */; }; C33FDD03255A582000E217F9 /* WeakTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB49255A580C00E217F9 /* WeakTimer.swift */; }; C33FDD06255A582000E217F9 /* AppVersion.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB4C255A580D00E217F9 /* AppVersion.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C33FDD13255A582000E217F9 /* OWSFailedAttachmentDownloadsJob.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB59255A580E00E217F9 /* OWSFailedAttachmentDownloadsJob.m */; }; C33FDD23255A582000E217F9 /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB69255A580F00E217F9 /* FeatureFlags.swift */; }; C33FDD32255A582000E217F9 /* OWSOperation.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB78255A581000E217F9 /* OWSOperation.m */; }; C33FDD3A255A582000E217F9 /* Notification+Loki.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB80255A581100E217F9 /* Notification+Loki.swift */; }; @@ -456,7 +446,6 @@ C3471F4C25553AB000297E91 /* MessageReceiver+Decryption.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3471F4B25553AB000297E91 /* MessageReceiver+Decryption.swift */; }; C34A977425A3E34A00852C71 /* ClosedGroupControlMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C34A977325A3E34A00852C71 /* ClosedGroupControlMessage.swift */; }; C34C8F7423A7830B00D82669 /* SpaceMono-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = C34C8F7323A7830A00D82669 /* SpaceMono-Bold.ttf */; }; - C352A2F525574B4700338F3E /* LegacyJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A2F425574B4700338F3E /* LegacyJob.swift */; }; C352A2FF25574B6300338F3E /* MessageSendJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A2FE25574B6300338F3E /* MessageSendJob.swift */; }; C352A30925574D8500338F3E /* Message+Destination.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A30825574D8400338F3E /* Message+Destination.swift */; }; C352A31325574F5200338F3E /* MessageReceiveJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A31225574F5200338F3E /* MessageReceiveJob.swift */; }; @@ -465,8 +454,6 @@ C352A35B2557824E00338F3E /* AttachmentUploadJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A35A2557824E00338F3E /* AttachmentUploadJob.swift */; }; C352A36D2557858E00338F3E /* NSTimer+Proxying.m in Sources */ = {isa = PBXBuildFile; fileRef = C352A36C2557858D00338F3E /* NSTimer+Proxying.m */; }; C352A3772557864000338F3E /* NSTimer+Proxying.h in Headers */ = {isa = PBXBuildFile; fileRef = C352A3762557859C00338F3E /* NSTimer+Proxying.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C352A3892557876500338F3E /* JobQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A3882557876500338F3E /* JobQueue.swift */; }; - C352A3932557883D00338F3E /* JobDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A3922557883D00338F3E /* JobDelegate.swift */; }; C352A3A62557B60D00338F3E /* TSRequest.m in Sources */ = {isa = PBXBuildFile; fileRef = C352A3A52557B60D00338F3E /* TSRequest.m */; }; C352A3B72557B6ED00338F3E /* TSRequest.h in Headers */ = {isa = PBXBuildFile; fileRef = C352A3A42557B5F000338F3E /* TSRequest.h */; settings = {ATTRIBUTES = (Public, ); }; }; C3548F0624456447009433A8 /* PNModeVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3548F0524456447009433A8 /* PNModeVC.swift */; }; @@ -1343,17 +1330,14 @@ C33FD9AE255A548A00E217F9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; C33FDA67255A57F900E217F9 /* OWSPrimaryStorage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSPrimaryStorage.h; sourceTree = ""; }; C33FDA69255A57F900E217F9 /* SSKPreferences.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SSKPreferences.swift; sourceTree = ""; }; - C33FDA6B255A57FA00E217F9 /* OWSDisappearingConfigurationUpdateInfoMessage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSDisappearingConfigurationUpdateInfoMessage.m; sourceTree = ""; }; C33FDA6C255A57FA00E217F9 /* ProtoUtils.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ProtoUtils.m; sourceTree = ""; }; C33FDA6D255A57FA00E217F9 /* YapDatabase+Promise.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "YapDatabase+Promise.swift"; sourceTree = ""; }; C33FDA6F255A57FA00E217F9 /* ReachabilityManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReachabilityManager.swift; sourceTree = ""; }; C33FDA70255A57FA00E217F9 /* TSMessage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSMessage.h; sourceTree = ""; }; - C33FDA72255A57FA00E217F9 /* OWSFailedAttachmentDownloadsJob.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSFailedAttachmentDownloadsJob.h; sourceTree = ""; }; C33FDA73255A57FA00E217F9 /* ECKeyPair+Hexadecimal.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ECKeyPair+Hexadecimal.swift"; sourceTree = ""; }; C33FDA79255A57FB00E217F9 /* TSGroupThread.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSGroupThread.h; sourceTree = ""; }; C33FDA7A255A57FB00E217F9 /* NSRegularExpression+SSK.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSRegularExpression+SSK.swift"; sourceTree = ""; }; C33FDA7E255A57FB00E217F9 /* Mention.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Mention.swift; sourceTree = ""; }; - C33FDA80255A57FC00E217F9 /* OWSDisappearingMessagesJob.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSDisappearingMessagesJob.h; sourceTree = ""; }; C33FDA81255A57FC00E217F9 /* MentionsManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MentionsManager.swift; sourceTree = ""; }; C33FDA86255A57FC00E217F9 /* OWSDisappearingMessagesFinder.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSDisappearingMessagesFinder.m; sourceTree = ""; }; C33FDA87255A57FC00E217F9 /* TypingIndicators.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingIndicators.swift; sourceTree = ""; }; @@ -1371,7 +1355,6 @@ C33FDAAA255A580000E217F9 /* NSObject+Casting.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSObject+Casting.m"; sourceTree = ""; }; C33FDAB1255A580000E217F9 /* OWSStorage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSStorage.m; sourceTree = ""; }; C33FDAB3255A580000E217F9 /* TSContactThread.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSContactThread.h; sourceTree = ""; }; - C33FDAB7255A580100E217F9 /* OWSFailedMessagesJob.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSFailedMessagesJob.m; sourceTree = ""; }; C33FDAB8255A580100E217F9 /* NSArray+Functional.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSArray+Functional.m"; sourceTree = ""; }; C33FDAB9255A580100E217F9 /* OWSStorage+Subclass.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "OWSStorage+Subclass.h"; sourceTree = ""; }; C33FDABE255A580100E217F9 /* TSConstants.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSConstants.m; sourceTree = ""; }; @@ -1382,9 +1365,6 @@ C33FDAC4255A580200E217F9 /* TSAttachmentStream.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSAttachmentStream.m; sourceTree = ""; }; C33FDAD3255A580300E217F9 /* TSThread.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSThread.h; sourceTree = ""; }; C33FDAD5255A580300E217F9 /* TSQuotedMessage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSQuotedMessage.h; sourceTree = ""; }; - C33FDAD9255A580300E217F9 /* OWSDisappearingMessagesConfiguration.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSDisappearingMessagesConfiguration.h; sourceTree = ""; }; - C33FDADA255A580400E217F9 /* OWSDisappearingConfigurationUpdateInfoMessage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSDisappearingConfigurationUpdateInfoMessage.h; sourceTree = ""; }; - C33FDADB255A580400E217F9 /* OWSFailedMessagesJob.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSFailedMessagesJob.h; sourceTree = ""; }; C33FDADC255A580400E217F9 /* NSObject+Casting.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSObject+Casting.h"; sourceTree = ""; }; C33FDADD255A580400E217F9 /* TSInfoMessage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSInfoMessage.h; sourceTree = ""; }; C33FDADE255A580400E217F9 /* SwiftSingletons.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftSingletons.swift; sourceTree = ""; }; @@ -1435,7 +1415,6 @@ C33FDB51255A580D00E217F9 /* NSUserDefaults+OWS.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSUserDefaults+OWS.h"; sourceTree = ""; }; C33FDB54255A580D00E217F9 /* DataSource.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DataSource.h; sourceTree = ""; }; C33FDB56255A580D00E217F9 /* TSOutgoingMessage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSOutgoingMessage.m; sourceTree = ""; }; - C33FDB59255A580E00E217F9 /* OWSFailedAttachmentDownloadsJob.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSFailedAttachmentDownloadsJob.m; sourceTree = ""; }; C33FDB5B255A580E00E217F9 /* YapDatabaseTransaction+OWS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "YapDatabaseTransaction+OWS.m"; sourceTree = ""; }; C33FDB5C255A580E00E217F9 /* NSArray+Functional.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSArray+Functional.h"; sourceTree = ""; }; C33FDB5F255A580E00E217F9 /* YapDatabaseConnection+OWS.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "YapDatabaseConnection+OWS.h"; sourceTree = ""; }; @@ -1464,7 +1443,6 @@ C33FDB9C255A581300E217F9 /* TSIncomingMessage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSIncomingMessage.h; sourceTree = ""; }; C33FDB9E255A581400E217F9 /* TSAttachmentPointer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSAttachmentPointer.m; sourceTree = ""; }; C33FDBA1255A581400E217F9 /* OWSOperation.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSOperation.h; sourceTree = ""; }; - C33FDBA4255A581400E217F9 /* OWSDisappearingMessagesConfiguration.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSDisappearingMessagesConfiguration.m; sourceTree = ""; }; C33FDBA8255A581500E217F9 /* OWSLinkPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSLinkPreview.swift; sourceTree = ""; }; C33FDBAB255A581500E217F9 /* OWSFileSystem.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSFileSystem.h; sourceTree = ""; }; C33FDBAE255A581500E217F9 /* SignalAccount.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SignalAccount.h; sourceTree = ""; }; @@ -1480,7 +1458,6 @@ C33FDBD3255A581800E217F9 /* OWSSignalAddress.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSSignalAddress.swift; sourceTree = ""; }; C33FDBD7255A581900E217F9 /* OWSMessageUtils.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSMessageUtils.m; sourceTree = ""; }; C33FDBD8255A581900E217F9 /* SignalIOS.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SignalIOS.pb.swift; sourceTree = ""; }; - C33FDBDD255A581900E217F9 /* OWSDisappearingMessagesJob.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSDisappearingMessagesJob.m; sourceTree = ""; }; C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PushNotificationAPI.swift; sourceTree = ""; }; C33FDBE1255A581A00E217F9 /* LKGroupUtilities.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = LKGroupUtilities.m; sourceTree = ""; }; C33FDBE9255A581A00E217F9 /* TSInteraction.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSInteraction.m; sourceTree = ""; }; @@ -1506,7 +1483,6 @@ C3471F4B25553AB000297E91 /* MessageReceiver+Decryption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+Decryption.swift"; sourceTree = ""; }; C34A977325A3E34A00852C71 /* ClosedGroupControlMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClosedGroupControlMessage.swift; sourceTree = ""; }; C34C8F7323A7830A00D82669 /* SpaceMono-Bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "SpaceMono-Bold.ttf"; sourceTree = ""; }; - C352A2F425574B4700338F3E /* LegacyJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyJob.swift; sourceTree = ""; }; C352A2FE25574B6300338F3E /* MessageSendJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSendJob.swift; sourceTree = ""; }; C352A30825574D8400338F3E /* Message+Destination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+Destination.swift"; sourceTree = ""; }; C352A31225574F5200338F3E /* MessageReceiveJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReceiveJob.swift; sourceTree = ""; }; @@ -1515,8 +1491,6 @@ C352A35A2557824E00338F3E /* AttachmentUploadJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentUploadJob.swift; sourceTree = ""; }; C352A36C2557858D00338F3E /* NSTimer+Proxying.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSTimer+Proxying.m"; sourceTree = ""; }; C352A3762557859C00338F3E /* NSTimer+Proxying.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSTimer+Proxying.h"; sourceTree = ""; }; - C352A3882557876500338F3E /* JobQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobQueue.swift; sourceTree = ""; }; - C352A3922557883D00338F3E /* JobDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobDelegate.swift; sourceTree = ""; }; C352A3A42557B5F000338F3E /* TSRequest.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = TSRequest.h; sourceTree = ""; }; C352A3A52557B60D00338F3E /* TSRequest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = TSRequest.m; sourceTree = ""; }; C353F8F8244809150011121A /* PNOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PNOptionView.swift; sourceTree = ""; }; @@ -2542,7 +2516,6 @@ FDF0B7562807F35E004C14C5 /* Errors */, C3D9E3B52567685D0040E4F3 /* Attachments */, B8F5F61925EDE4B0003BF8D4 /* Data Extraction */, - C32C5B01256DC054003C73A2 /* Expiration */, C32C5D22256DD496003C73A2 /* Link Previews */, C32C5D2D256DD4C4003C73A2 /* Mentions */, C379DC6825672B5E0002D4EB /* Notifications */, @@ -2667,19 +2640,6 @@ path = Signal; sourceTree = ""; }; - C32C5B01256DC054003C73A2 /* Expiration */ = { - isa = PBXGroup; - children = ( - C33FDADA255A580400E217F9 /* OWSDisappearingConfigurationUpdateInfoMessage.h */, - C33FDA6B255A57FA00E217F9 /* OWSDisappearingConfigurationUpdateInfoMessage.m */, - C33FDAD9255A580300E217F9 /* OWSDisappearingMessagesConfiguration.h */, - C33FDBA4255A581400E217F9 /* OWSDisappearingMessagesConfiguration.m */, - C33FDA80255A57FC00E217F9 /* OWSDisappearingMessagesJob.h */, - C33FDBDD255A581900E217F9 /* OWSDisappearingMessagesJob.m */, - ); - path = Expiration; - sourceTree = ""; - }; C32C5B1B256DC160003C73A2 /* Quotes */ = { isa = PBXGroup; children = ( @@ -2847,9 +2807,6 @@ isa = PBXGroup; children = ( FDF0B7452804F0A8004C14C5 /* Types */, - C352A2F425574B4700338F3E /* LegacyJob.swift */, - C352A3922557883D00338F3E /* JobDelegate.swift */, - C352A3882557876500338F3E /* JobQueue.swift */, ); path = Jobs; sourceTree = ""; @@ -3150,10 +3107,6 @@ FD28A4F327EA79F800FF65E7 /* BlockListUIUtils.swift */, C38EF2FC255B6DBD007E1867 /* ConversationStyle.swift */, C38EF3D4255B6DEE007E1867 /* DisappearingTimerConfigurationView.swift */, - C33FDA72255A57FA00E217F9 /* OWSFailedAttachmentDownloadsJob.h */, - C33FDB59255A580E00E217F9 /* OWSFailedAttachmentDownloadsJob.m */, - C33FDADB255A580400E217F9 /* OWSFailedMessagesJob.h */, - C33FDAB7255A580100E217F9 /* OWSFailedMessagesJob.m */, C38EF397255B6DD9007E1867 /* ThreadViewModel.swift */, C38EF2E4255B6DB9007E1867 /* FullTextSearcher.swift */, C38EF2E9255B6DBA007E1867 /* OWSUnreadIndicator.h */, @@ -3863,7 +3816,6 @@ C38EF24C255B6D67007E1867 /* NSAttributedString+OWS.h in Headers */, FDA8EB0A280E9103002B68E5 /* AppSetup.h in Headers */, C38EF32B255B6DBF007E1867 /* OWSFormat.h in Headers */, - C33FDC2C255A581F00E217F9 /* OWSFailedAttachmentDownloadsJob.h in Headers */, C38EF22A255B6D5D007E1867 /* AttachmentSharing.h in Headers */, C33FDDB8255A582000E217F9 /* NSSet+Functional.h in Headers */, C33FDDCC255A582000E217F9 /* TSConstants.h in Headers */, @@ -3873,7 +3825,6 @@ C38EF249255B6D67007E1867 /* UIColor+OWS.h in Headers */, C38EF3F0255B6DF7007E1867 /* ThreadViewHelper.h in Headers */, C38EF274255B6D7A007E1867 /* OWSResaveCollectionDBMigration.h in Headers */, - C33FDC95255A582000E217F9 /* OWSFailedMessagesJob.h in Headers */, C38EF277255B6D7A007E1867 /* OWSDatabaseMigration.h in Headers */, C38EF3F5255B6DF7007E1867 /* OWSTextField.h in Headers */, C38EF275255B6D7A007E1867 /* OWSDatabaseMigrationRunner.h in Headers */, @@ -3952,16 +3903,13 @@ C32C5FC4256E0209003C73A2 /* OWSBackgroundTask.h in Headers */, C32C59C0256DB41F003C73A2 /* TSThread.h in Headers */, C32C5AB3256DBE8F003C73A2 /* TSInteraction.h in Headers */, - C32C5B0A256DC076003C73A2 /* OWSDisappearingMessagesConfiguration.h in Headers */, C3D9E486256775D20040E4F3 /* TSAttachmentPointer.h in Headers */, C32C5EF7256DF567003C73A2 /* TSDatabaseView.h in Headers */, B8856ED7256F1EB4001CE70E /* OWSPreferences.h in Headers */, C32A026C25A801AF000ED5D4 /* NSData+messagePadding.h in Headers */, C32C5EC3256DE133003C73A2 /* OWSQuotedReplyModel.h in Headers */, - C32C5BF8256DC8F6003C73A2 /* OWSDisappearingMessagesJob.h in Headers */, C32C5AAA256DBE8F003C73A2 /* TSIncomingMessage.h in Headers */, B8856D72256F1421001CE70E /* OWSWindowManager.h in Headers */, - C32C5B6B256DC357003C73A2 /* OWSDisappearingConfigurationUpdateInfoMessage.h in Headers */, C32C5BBA256DC7E3003C73A2 /* ProfileManagerProtocol.h in Headers */, C3A3A193256E20D4004D228D /* SignalRecipient.h in Headers */, B8856CF7256F105E001CE70E /* OWSAudioPlayer.h in Headers */, @@ -4691,7 +4639,6 @@ C38EF3C1255B6DE7007E1867 /* ImageEditorBrushViewController.swift in Sources */, C33FDCC7255A582000E217F9 /* NSArray+OWS.m in Sources */, C38EF3EF255B6DF7007E1867 /* ThreadViewHelper.m in Sources */, - C33FDC71255A582000E217F9 /* OWSFailedMessagesJob.m in Sources */, C33FDD32255A582000E217F9 /* OWSOperation.m in Sources */, C3F0A530255C80BC007BE2A3 /* NoopNotificationsManager.swift in Sources */, C3D90A7A25773A93002C9DF5 /* Configuration.swift in Sources */, @@ -4726,7 +4673,6 @@ C38EF370255B6DCC007E1867 /* OWSNavigationController.m in Sources */, C38EF24E255B6D67007E1867 /* Collection+OWS.swift in Sources */, C33FDCFA255A582000E217F9 /* SignalIOSProto.swift in Sources */, - C33FDD13255A582000E217F9 /* OWSFailedAttachmentDownloadsJob.m in Sources */, C38EF24D255B6D67007E1867 /* UIView+OWS.swift in Sources */, C38EF38B255B6DD2007E1867 /* AttachmentPrepViewController.swift in Sources */, C33FDC7B255A582000E217F9 /* NSSet+Functional.m in Sources */, @@ -4938,7 +4884,6 @@ C32C5A13256DB7A5003C73A2 /* PushNotificationAPI.swift in Sources */, C32A026325A801AA000ED5D4 /* NSData+messagePadding.m in Sources */, FD6A7A6B2818C17C00035AC1 /* UpdateProfilePictureJob.swift in Sources */, - C352A3932557883D00338F3E /* JobDelegate.swift in Sources */, C32C5B84256DC54F003C73A2 /* SSKEnvironment.m in Sources */, C3A3A108256E1A5C004D228D /* OWSIncomingMessageFinder.m in Sources */, C352A31325574F5200338F3E /* MessageReceiveJob.swift in Sources */, @@ -4953,7 +4898,6 @@ FDA8EB00280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift in Sources */, FD09798927FD1C5A00936362 /* OpenGroup.swift in Sources */, C32C5CA4256DD1DC003C73A2 /* TSAccountManager.m in Sources */, - C352A3892557876500338F3E /* JobQueue.swift in Sources */, C3BBE0B52554F0E10050F1E3 /* ProofOfWork.swift in Sources */, C32C59C1256DB41F003C73A2 /* TSGroupThread.m in Sources */, C3A3A08F256E1728004D228D /* FullTextSearchFinder.swift in Sources */, @@ -5024,7 +4968,6 @@ B8D0A25925E367AC00C1835E /* Notification+MessageReceiver.swift in Sources */, C32C599E256DB02B003C73A2 /* TypingIndicators.swift in Sources */, C3DB66CC260AF1F3001EFC55 /* OpenGroupAPIV2+ObjC.swift in Sources */, - C32C5BEF256DC8EE003C73A2 /* OWSDisappearingMessagesJob.m in Sources */, C34A977425A3E34A00852C71 /* ClosedGroupControlMessage.swift in Sources */, FD09799527FE7B8E00936362 /* Interaction.swift in Sources */, B88FA7B826045D100049422F /* OpenGroupAPIV2.swift in Sources */, @@ -5075,10 +5018,7 @@ C300A5B22554AF9800555489 /* VisibleMessage+Profile.swift in Sources */, FD09798127FCFEE800936362 /* SessionThread.swift in Sources */, C32C5A75256DBBCF003C73A2 /* TSAttachmentPointer+Conversion.swift in Sources */, - C32C5AF8256DC051003C73A2 /* OWSDisappearingMessagesConfiguration.m in Sources */, C32C5EBA256DE130003C73A2 /* OWSQuotedReplyModel.m in Sources */, - C32C5B62256DC333003C73A2 /* OWSDisappearingConfigurationUpdateInfoMessage.m in Sources */, - C352A2F525574B4700338F3E /* LegacyJob.swift in Sources */, C32C59C4256DB41F003C73A2 /* TSContactThread.m in Sources */, FDF0B75E280AAF35004C14C5 /* Preferences.swift in Sources */, C32C5AB0256DBE8F003C73A2 /* TSOutgoingMessage.m in Sources */, diff --git a/Session/Conversations/Views & Modals/BodyTextView.swift b/Session/Conversations/Views & Modals/BodyTextView.swift index 3048db56d..271bd71d6 100644 --- a/Session/Conversations/Views & Modals/BodyTextView.swift +++ b/Session/Conversations/Views & Modals/BodyTextView.swift @@ -1,18 +1,21 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit // Requirements: // • Links should show up properly and be tappable. // • Text should * not * be selectable. // • The long press interaction that shows the context menu should still work. -final class BodyTextView : UITextView { - private let snDelegate: BodyTextViewDelegate +final class BodyTextView: UITextView { + private let snDelegate: BodyTextViewDelegate? override var selectedTextRange: UITextRange? { get { return nil } set { } } - init(snDelegate: BodyTextViewDelegate) { + init(snDelegate: BodyTextViewDelegate?) { self.snDelegate = snDelegate super.init(frame: CGRect.zero, textContainer: nil) setUpGestureRecognizers() @@ -35,7 +38,7 @@ final class BodyTextView : UITextView { } @objc private func handleLongPress() { - snDelegate.handleLongPress() + snDelegate?.handleLongPress() } @objc private func handleDoubleTap() { diff --git a/Session/Conversations/Views & Modals/ConversationTitleView.swift b/Session/Conversations/Views & Modals/ConversationTitleView.swift index 33784dd5e..4e319f6a7 100644 --- a/Session/Conversations/Views & Modals/ConversationTitleView.swift +++ b/Session/Conversations/Views & Modals/ConversationTitleView.swift @@ -1,138 +1,110 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -final class ConversationTitleView : UIView { - private let thread: TSThread - weak var delegate: ConversationTitleViewDelegate? +import UIKit +import SessionUIKit +import SessionMessagingKit +import SessionUtilitiesKit +final class ConversationTitleView: UIView { override var intrinsicContentSize: CGSize { return UIView.layoutFittingExpandedSize } - // MARK: UI Components + // MARK: - UI Components + private lazy var titleLabel: UILabel = { - let result = UILabel() + let result: UILabel = UILabel() result.textColor = Colors.text result.font = .boldSystemFont(ofSize: Values.mediumFontSize) result.lineBreakMode = .byTruncatingTail + return result }() private lazy var subtitleLabel: UILabel = { - let result = UILabel() + let result: UILabel = UILabel() result.textColor = Colors.text result.font = .systemFont(ofSize: 13) result.lineBreakMode = .byTruncatingTail + return result }() - // MARK: Lifecycle - init(thread: TSThread) { - self.thread = thread - super.init(frame: CGRect.zero) - initialize() - } - - override init(frame: CGRect) { - preconditionFailure("Use init(thread:) instead.") - } - - required init?(coder: NSCoder) { - preconditionFailure("Use init(coder:) instead.") - } - - private func initialize() { - let stackView = UIStackView(arrangedSubviews: [ titleLabel, subtitleLabel ]) + // MARK: - Initialization + + init() { + super.init(frame: .zero) + + let stackView: UIStackView = UIStackView(arrangedSubviews: [ titleLabel, subtitleLabel ]) stackView.axis = .vertical stackView.alignment = .center stackView.isLayoutMarginsRelativeArrangement = true - stackView.layoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 0) addSubview(stackView) + stackView.pin(to: self) - let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap)) - addGestureRecognizer(tapGestureRecognizer) - let notificationCenter = NotificationCenter.default - notificationCenter.addObserver(self, selector: #selector(update), name: Notification.Name.groupThreadUpdated, object: nil) - notificationCenter.addObserver(self, selector: #selector(update), name: Notification.Name.muteSettingUpdated, object: nil) - notificationCenter.addObserver(self, selector: #selector(update), name: Notification.Name.profileUpdated, object: nil) - update() + } + + required init?(coder: NSCoder) { + preconditionFailure("Use init() instead.") } - deinit { - NotificationCenter.default.removeObserver(self) - } - - // MARK: Updating - @objc private func update() { - DispatchQueue.main.async { - self.titleLabel.text = self.getTitle() - - let subtitle: NSAttributedString? = self.getSubtitle() - self.subtitleLabel.attributedText = subtitle - self.titleLabel.font = .boldSystemFont( - ofSize: (subtitle != nil ? - Values.mediumFontSize : - Values.veryLargeFontSize - ) - ) - } - } - - // MARK: General - private func getTitle() -> String { - if let thread = thread as? TSGroupThread { - return thread.groupModel.groupName! - } - else if thread.isNoteToSelf() { - return "Note to Self" - } - else { - let sessionID = (thread as! TSContactThread).contactSessionID() - let middleTruncatedHexKey: String = "\(sessionID.prefix(4))...\(sessionID.suffix(4))" - - return Profile.displayName(id: sessionID, customFallback: middleTruncatedHexKey) - } - } - - private func getSubtitle() -> NSAttributedString? { - let result = NSMutableAttributedString() - if thread.isMuted { - result.append(NSAttributedString(string: "\u{e067} ", attributes: [ .font : UIFont.ows_elegantIconsFont(10), .foregroundColor : Colors.text ])) - result.append(NSAttributedString(string: "Muted")) - return result - } else if let thread = self.thread as? TSGroupThread { - if thread.isOnlyNotifyingForMentions { - let imageAttachment = NSTextAttachment() - let color: UIColor = isDarkMode ? .white : .black - imageAttachment.image = UIImage(named: "NotifyMentions.png")?.asTintedImage(color: color) - imageAttachment.bounds = CGRect(x: 0, y: -2, width: Values.smallFontSize, height: Values.smallFontSize) - let imageAsString = NSAttributedString(attachment: imageAttachment) - result.append(imageAsString) - result.append(NSAttributedString(string: " " + NSLocalizedString("view_conversation_title_notify_for_mentions_only", comment: ""))) - return result - } else { - var userCount: UInt64? - switch thread.groupModel.groupType { - case .closedGroup: userCount = UInt64(thread.groupModel.groupMemberIds.count) - case .openGroup: - guard let openGroupV2 = Storage.shared.getV2OpenGroup(for: self.thread.uniqueId!) else { return nil } - userCount = Storage.shared.getUserCount(forV2OpenGroupWithID: openGroupV2.id) - default: break - } - if let userCount = userCount { - return NSAttributedString(string: "\(userCount) members") - } + // MARK: - Content + + public func update( + with name: String, + notificationMode: SessionThread.NotificationMode, + userCount: Int? + ) { + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in + self?.update(with: name, notificationMode: notificationMode, userCount: userCount) } + return } - return nil - } - - // MARK: Interaction - @objc private func handleTap() { - delegate?.handleTitleViewTapped() + + // Generate the subtitle + let subtitle: NSAttributedString? = { + switch notificationMode { + case .none: + return NSAttributedString( + string: "\u{e067} ", + attributes: [ + .font: UIFont.ows_elegantIconsFont(10), + .foregroundColor: Colors.text + ] + ) + .appending(string: "Muted") + + case .mentionsOnly: + // FIXME: This is going to have issues when swapping between light/dark mode + let imageAttachment = NSTextAttachment() + let color: UIColor = (isDarkMode ? .white : .black) + imageAttachment.image = UIImage(named: "NotifyMentions.png")?.asTintedImage(color: color) + imageAttachment.bounds = CGRect( + x: 0, + y: -2, + width: Values.smallFontSize, + height: Values.smallFontSize + ) + + return NSAttributedString(attachment: imageAttachment) + .appending(string: " ") + .appending(string: "view_conversation_title_notify_for_mentions_only".localized()) + + case .all: + guard let userCount: Int = userCount else { return nil } + + return NSAttributedString(string: "\(userCount) member\(userCount == 1 ? "" : "s")") + } + }() + + self.titleLabel.text = name + self.titleLabel.font = .boldSystemFont( + ofSize: (subtitle != nil ? + Values.mediumFontSize : + Values.veryLargeFontSize + ) + ) + self.subtitleLabel.attributedText = subtitle } } - -// MARK: Delegate -protocol ConversationTitleViewDelegate : AnyObject { - - func handleTitleViewTapped() -} diff --git a/Session/Conversations/Views & Modals/DownloadAttachmentModal.swift b/Session/Conversations/Views & Modals/DownloadAttachmentModal.swift index e73e91692..9312b49dc 100644 --- a/Session/Conversations/Views & Modals/DownloadAttachmentModal.swift +++ b/Session/Conversations/Views & Modals/DownloadAttachmentModal.swift @@ -6,44 +6,53 @@ import SessionUIKit import SessionUtilitiesKit import SessionMessagingKit -final class DownloadAttachmentModal : Modal { - private let viewItem: ConversationViewItem +final class DownloadAttachmentModal: Modal { + private let profile: Profile? + + // MARK: - Lifecycle - // MARK: Lifecycle - init(viewItem: ConversationViewItem) { - self.viewItem = viewItem + init(profile: Profile?) { + self.profile = profile + super.init(nibName: nil, bundle: nil) } - + override init(nibName: String?, bundle: Bundle?) { preconditionFailure("Use init(viewItem:) instead.") } - + required init?(coder: NSCoder) { preconditionFailure("Use init(viewItem:) instead.") } - + override func populateContentView() { - guard let publicKey = (viewItem.interaction as? TSIncomingMessage)?.authorId else { return } + guard let profile: Profile = profile else { return } + // Name - let name = Profile.displayName(for: publicKey) + let name: String = profile.displayName() + // Title let titleLabel = UILabel() titleLabel.textColor = Colors.text titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize) titleLabel.text = String(format: NSLocalizedString("modal_download_attachment_title", comment: ""), name) titleLabel.textAlignment = .center + // Message let messageLabel = UILabel() messageLabel.textColor = Colors.text messageLabel.font = .systemFont(ofSize: Values.smallFontSize) let message = String(format: NSLocalizedString("modal_download_attachment_explanation", comment: ""), name) let attributedMessage = NSMutableAttributedString(string: message) - attributedMessage.addAttributes([ .font : UIFont.boldSystemFont(ofSize: Values.smallFontSize) ], range: (message as NSString).range(of: name)) + attributedMessage.addAttributes( + [.font: UIFont.boldSystemFont(ofSize: Values.smallFontSize) ], + range: (message as NSString).range(of: name) + ) messageLabel.attributedText = attributedMessage messageLabel.numberOfLines = 0 messageLabel.lineBreakMode = .byWordWrapping messageLabel.textAlignment = .center + // Download button let downloadButton = UIButton() downloadButton.set(.height, to: Values.mediumButtonHeight) @@ -53,11 +62,13 @@ final class DownloadAttachmentModal : Modal { downloadButton.setTitleColor(Colors.text, for: UIControl.State.normal) downloadButton.setTitle(NSLocalizedString("modal_download_button_title", comment: ""), for: UIControl.State.normal) downloadButton.addTarget(self, action: #selector(trust), for: UIControl.Event.touchUpInside) + // Button stack view let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, downloadButton ]) buttonStackView.axis = .horizontal buttonStackView.spacing = Values.mediumSpacing buttonStackView.distribution = .fillEqually + // Main stack view let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ]) mainStackView.axis = .vertical @@ -68,8 +79,9 @@ final class DownloadAttachmentModal : Modal { contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing) contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing) } + + // MARK: - Interaction - // MARK: Interaction @objc private func trust() { guard let message = viewItem.interaction as? TSIncomingMessage else { return } diff --git a/Session/Conversations/Views & Modals/ScrollToBottomButton.swift b/Session/Conversations/Views & Modals/ScrollToBottomButton.swift index 8db7c02cf..871f51c64 100644 --- a/Session/Conversations/Views & Modals/ScrollToBottomButton.swift +++ b/Session/Conversations/Views & Modals/ScrollToBottomButton.swift @@ -1,12 +1,17 @@ - -final class ScrollToBottomButton : UIView { +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit + +final class ScrollToBottomButton: UIView { private weak var delegate: ScrollToBottomButtonDelegate? - // MARK: Settings + // MARK: - Settings + private static let size: CGFloat = 40 private static let iconSize: CGFloat = 16 - // MARK: Lifecycle + // MARK: - Lifecycle + init(delegate: ScrollToBottomButtonDelegate) { self.delegate = delegate super.init(frame: CGRect.zero) @@ -55,13 +60,15 @@ final class ScrollToBottomButton : UIView { addGestureRecognizer(tapGestureRecognizer) } - // MARK: Interaction + // MARK: - Interaction + @objc private func handleTap() { delegate?.handleScrollToBottomButtonTapped() } } -protocol ScrollToBottomButtonDelegate : class { - +// MARK: - ScrollToBottomButtonDelegate + +protocol ScrollToBottomButtonDelegate: AnyObject { func handleScrollToBottomButtonTapped() } diff --git a/Session/Conversations/Views & Modals/UserDetailsSheet.swift b/Session/Conversations/Views & Modals/UserDetailsSheet.swift index 3e5b1bad9..4867cd662 100644 --- a/Session/Conversations/Views & Modals/UserDetailsSheet.swift +++ b/Session/Conversations/Views & Modals/UserDetailsSheet.swift @@ -4,10 +4,11 @@ import UIKit import SessionMessagingKit final class UserDetailsSheet: Sheet { - private let sessionID: String + private let profile: Profile - init(for sessionID: String) { - self.sessionID = sessionID + init(for profile: Profile) { + self.profile = profile + super.init(nibName: nil, bundle: nil) } @@ -26,16 +27,21 @@ final class UserDetailsSheet: Sheet { profilePictureView.size = size profilePictureView.set(.width, to: size) profilePictureView.set(.height, to: size) - profilePictureView.publicKey = sessionID - profilePictureView.update() + profilePictureView.update( + publicKey: profile.id, + profile: profile, + threadVariant: .contact + ) + // Display name label let displayNameLabel = UILabel() - let displayName = Profile.displayName(id: sessionID) + let displayName = profile.displayName() displayNameLabel.text = displayName displayNameLabel.font = .boldSystemFont(ofSize: Values.largeFontSize) displayNameLabel.textColor = Colors.text displayNameLabel.numberOfLines = 1 displayNameLabel.lineBreakMode = .byTruncatingTail + // Session ID label let sessionIDLabel = UILabel() sessionIDLabel.textColor = Colors.text @@ -43,7 +49,8 @@ final class UserDetailsSheet: Sheet { sessionIDLabel.numberOfLines = 0 sessionIDLabel.lineBreakMode = .byCharWrapping sessionIDLabel.accessibilityLabel = "Session ID label" - sessionIDLabel.text = sessionID + sessionIDLabel.text = profile.id + // Session ID label container let sessionIDLabelContainer = UIView() sessionIDLabelContainer.addSubview(sessionIDLabel) @@ -51,23 +58,26 @@ final class UserDetailsSheet: Sheet { sessionIDLabelContainer.layer.cornerRadius = TextField.cornerRadius sessionIDLabelContainer.layer.borderWidth = 1 sessionIDLabelContainer.layer.borderColor = isLightMode ? UIColor.black.cgColor : UIColor.white.cgColor + // Copy button let copyButton = Button(style: .prominentOutline, size: .medium) copyButton.setTitle(NSLocalizedString("copy", comment: ""), for: UIControl.State.normal) copyButton.addTarget(self, action: #selector(copySessionID), for: UIControl.Event.touchUpInside) copyButton.set(.width, to: 160) + // Stack view let stackView = UIStackView(arrangedSubviews: [ profilePictureView, displayNameLabel, sessionIDLabelContainer, copyButton, UIView.vSpacer(Values.largeSpacing) ]) stackView.axis = .vertical stackView.spacing = Values.largeSpacing stackView.alignment = .center + // Constraints contentView.addSubview(stackView) stackView.pin(to: contentView, withInset: Values.largeSpacing) } @objc private func copySessionID() { - UIPasteboard.general.string = sessionID + UIPasteboard.general.string = profile.id presentingViewController?.dismiss(animated: true, completion: nil) } } diff --git a/Session/DMs/NewDMVC.swift b/Session/DMs/NewDMVC.swift index e532e7e83..a840b00b1 100644 --- a/Session/DMs/NewDMVC.swift +++ b/Session/DMs/NewDMVC.swift @@ -173,8 +173,13 @@ final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControll } } - private func startNewDM(with sessionID: String) { - let thread = TSContactThread.getOrCreateThread(contactSessionID: sessionID) + private func startNewDM(with sessionId: String) { + let maybeThread: SessionThread? = GRDBStorage.shared.write { db in + try SessionThread.fetchOrCreate(db, id: sessionId, variant: .contact) + } + + guard maybeThread != nil else { return } + presentingViewController?.dismiss(animated: true, completion: nil) SessionApp.presentConversation(for: sessionId, action: .compose, animated: false) diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index f3ba0f89c..8a1724e6f 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -8,6 +8,9 @@ import SessionUtilitiesKit import SignalUtilitiesKit final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConversationButtonSetDelegate, SeedReminderViewDelegate { + typealias Section = HomeViewModel.Section + typealias Item = HomeViewModel.Item + private let viewModel: HomeViewModel = HomeViewModel() private var dataChangeObservable: DatabaseCancellable? private var hasLoadedInitialData: Bool = false @@ -147,9 +150,17 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve newConversationButtonSet.pin(.bottom, to: .bottom, of: view, withInset: -Values.newConversationButtonBottomOffset) // Negative due to how the constraint is set up // Notifications - let notificationCenter = NotificationCenter.default - notificationCenter.addObserver(self, selector: #selector(applicationDidBecomeActive(_:)), name: UIApplication.didBecomeActiveNotification, object: nil) - notificationCenter.addObserver(self, selector: #selector(applicationDidResignActive(_:)), name: UIApplication.didEnterBackgroundNotification, object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationDidBecomeActive(_:)), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationDidResignActive(_:)), + name: UIApplication.didEnterBackgroundNotification, object: nil + ) notificationCenter.addObserver(self, selector: #selector(handleProfileDidChangeNotification(_:)), name: .otherUsersProfileDidChange, object: nil) notificationCenter.addObserver(self, selector: #selector(handleLocalProfileDidChangeNotification(_:)), name: .localProfileDidChange, object: nil) @@ -214,7 +225,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve ) } - private func handleUpdates(_ updatedViewData: [ArraySection]) { + private func handleUpdates(_ updatedViewData: [ArraySection]) { // 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 { @@ -240,7 +251,6 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve ) { [weak self] updatedData in self?.viewModel.updateData(updatedData) } - } @objc private func handleProfileDidChangeNotification(_ notification: Notification) { @@ -271,8 +281,11 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve let profilePictureView = ProfilePictureView() profilePictureView.accessibilityLabel = "Settings button" profilePictureView.size = profilePictureSize - profilePictureView.publicKey = getUserHexEncodedPublicKey() - profilePictureView.update() + profilePictureView.update( + publicKey: getUserHexEncodedPublicKey(), + profile: Profile.fetchOrCreateCurrentUser(), + threadVariant: .contact + ) profilePictureView.set(.width, to: profilePictureSize) profilePictureView.set(.height, to: profilePictureSize) @@ -326,15 +339,17 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - switch viewModel.viewData[indexPath.section].model { + let section: ArraySection = viewModel.viewData[indexPath.section] + + switch section.model { case .messageRequests: - let cell = tableView.dequeueReusableCell(withIdentifier: MessageRequestsCell.reuseIdentifier) as! MessageRequestsCell - cell.update(with: viewModel.viewData[indexPath.section].elements[indexPath.row].unreadCount) + let cell: MessageRequestsCell = tableView.dequeue(type: MessageRequestsCell.self, for: indexPath) + cell.update(with: section.elements[indexPath.row].unreadCount) return cell case .threads: - let cell = tableView.dequeueReusableCell(withIdentifier: ConversationCell.reuseIdentifier) as! ConversationCell - cell.update(with: viewModel.viewData[indexPath.section].elements[indexPath.row].threadViewModel) + let cell: ConversationCell = tableView.dequeue(type: ConversationCell.self, for: indexPath) + cell.update(with: section.elements[indexPath.row].threadInfo) return cell } } @@ -344,15 +359,16 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - switch indexPath.section { - case 0: + let section: ArraySection = viewModel.viewData[indexPath.section] + + switch section.model { + case .messageRequests: let viewController: MessageRequestsViewController = MessageRequestsViewController() self.navigationController?.pushViewController(viewController, animated: true) - return - default: - guard let thread = self.thread(at: indexPath.row) else { return } - show(thread, with: ConversationViewAction.none, highlightedMessageID: nil, animated: true) + case .threads: + let threadId: String = section.elements[indexPath.row].threadInfo.id + show(threadId, with: .none, highlightedInteractionId: nil, animated: true) } } @@ -361,7 +377,9 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve } func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? { - switch viewModel.viewData[indexPath.section].model { + let section: ArraySection = viewModel.viewData[indexPath.section] + + switch section.model { case .messageRequests: let hide = UITableViewRowAction(style: .destructive, title: NSLocalizedString("TXT_HIDE_TITLE", comment: "")) { [weak self] _, _ in GRDBStorage.shared.write { db in db[.hasHiddenMessageRequests] = true } @@ -375,95 +393,89 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve return [hide] - default: - guard let thread = self.thread(at: indexPath.row) else { return [] } - let delete = UITableViewRowAction(style: .destructive, title: NSLocalizedString("TXT_DELETE_TITLE", comment: "")) { [weak self] _, _ in - var message = NSLocalizedString("CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE", comment: "") - if let thread = thread as? TSGroupThread, thread.isClosedGroup, thread.groupModel.groupAdminIds.contains(getUserHexEncodedPublicKey()) { - message = NSLocalizedString("admin_group_leave_warning", comment: "") - } - let alert = UIAlertController(title: NSLocalizedString("CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE", comment: ""), message: message, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: NSLocalizedString("TXT_DELETE_TITLE", comment: ""), style: .destructive) { [weak self] _ in - self?.delete(thread) + case .threads: + let threadInfo: HomeViewModel.ThreadInfo = section.elements[indexPath.row].threadInfo + let delete: UITableViewRowAction = UITableViewRowAction( + style: .destructive, + title: "TXT_DELETE_TITLE".localized() + ) { [weak self] _, _ in + let message = (threadInfo.isGroupAdmin ? + "admin_group_leave_warning".localized() : + "CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE".localized() + ) + + let alert = UIAlertController( + title: "CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE".localized(), + message: message, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction( + title: "TXT_DELETE_TITLE".localized(), + style: .destructive + ) { _ in + GRDBStorage.shared.write { db in + switch threadInfo.variant { + case .closedGroup: + try MessageSender + .leave(db, groupPublicKey: threadInfo.id) + .retainUntilComplete() + + case .openGroup: + OpenGroupManagerV2.shared.delete(db, openGroupId: threadInfo.id) + + default: break + } + + _ = try SessionThread + .filter(id: threadInfo.id) + .deleteAll(db) + } }) - alert.addAction(UIAlertAction(title: NSLocalizedString("TXT_CANCEL_TITLE", comment: ""), style: .default) { _ in }) - guard let self = self else { return } - self.present(alert, animated: true, completion: nil) + alert.addAction(UIAlertAction( + title: "TXT_CANCEL_TITLE".localized(), + style: .default + )) + + self?.present(alert, animated: true, completion: nil) } delete.backgroundColor = Colors.destructive - - let isPinned = thread.isPinned - let pin = UITableViewRowAction(style: .normal, title: NSLocalizedString("PIN_BUTTON_TEXT", comment: "")) { [weak self] _, _ in - thread.isPinned = true - thread.save() - self?.threadViewModelCache.removeValue(forKey: thread.uniqueId!) - tableView.reloadRows(at: [ indexPath ], with: UITableView.RowAnimation.fade) - } - pin.backgroundColor = Colors.pathsBuilding - let unpin = UITableViewRowAction(style: .normal, title: NSLocalizedString("UNPIN_BUTTON_TEXT", comment: "")) { [weak self] _, _ in - thread.isPinned = false - thread.save() - self?.threadViewModelCache.removeValue(forKey: thread.uniqueId!) - tableView.reloadRows(at: [ indexPath ], with: UITableView.RowAnimation.fade) - } - unpin.backgroundColor = Colors.pathsBuilding - - if let thread = thread as? TSContactThread, !thread.isNoteToSelf() { - let publicKey = thread.contactSessionID() - let block = UITableViewRowAction(style: .normal, title: NSLocalizedString("BLOCK_LIST_BLOCK_BUTTON", comment: "")) { _, _ in - GRDBStorage.shared.writeAsync( - updates: { db in - try Contact - .fetchOrCreate(db, id: publicKey) - .with(isBlocked: true) - .save(db) - }, - completion: { db, result in - switch result { - case .success: - MessageSender.syncConfiguration(db, forceSyncNow: true) - .retainUntilComplete() - - DispatchQueue.main.async { - tableView.reloadRows(at: [ indexPath ], with: UITableView.RowAnimation.fade) - } - - default: break - } - } - ) + let pin: UITableViewRowAction = UITableViewRowAction( + style: .normal, + title: (threadInfo.isPinned ? + "PIN_BUTTON_TEXT".localized() : + "UNPIN_BUTTON_TEXT".localized() + ) + ) { _, _ in + GRDBStorage.shared.write { db in + try SessionThread + .filter(id: threadInfo.id) + .updateAll(db, SessionThread.Columns.isPinned.set(to: !threadInfo.isPinned)) } - block.backgroundColor = Colors.unimportant - let unblock = UITableViewRowAction(style: .normal, title: NSLocalizedString("BLOCK_LIST_UNBLOCK_BUTTON", comment: "")) { _, _ in - GRDBStorage.shared.writeAsync( - updates: { db in - try Contact - .fetchOrCreate(db, id: publicKey) - .with(isBlocked: false) - .save(db) - }, - completion: { db, result in - switch result { - case .success: - MessageSender.syncConfiguration(db, forceSyncNow: true) - .retainUntilComplete() - - DispatchQueue.main.async { - tableView.reloadRows(at: [ indexPath ], with: UITableView.RowAnimation.fade) - } - - default: break - } - } - ) + } + + guard threadInfo.variant == .contact && !threadInfo.isNoteToSelf else { + return [ delete, pin ] + } + + let block: UITableViewRowAction = UITableViewRowAction( + style: .normal, + title: (threadInfo.isBlocked ? + "BLOCK_LIST_UNBLOCK_BUTTON".localized() : + "BLOCK_LIST_BLOCK_BUTTON".localized() + ) + ) { _, _ in + GRDBStorage.shared.write { db in + try Contact + .filter(id: threadInfo.id) + .updateAll(db, Contact.Columns.isBlocked.set(to: !threadInfo.isBlocked)) + try MessageSender.syncConfiguration(db, forceSyncNow: true) + .retainUntilComplete() } - unblock.backgroundColor = Colors.unimportant - return [ delete, (thread.isBlocked() ? unblock : block), (isPinned ? unpin : pin) ] - } - else { - return [ delete, (isPinned ? unpin : pin) ] } + block.backgroundColor = Colors.unimportant + + return [ delete, block, pin ] } } @@ -475,33 +487,20 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve present(navigationController, animated: true, completion: nil) } - @objc func show(_ thread: TSThread, with action: ConversationViewAction, highlightedMessageID: String?, animated: Bool) { - DispatchMainThreadSafe { - if let presentedVC = self.presentedViewController { - presentedVC.dismiss(animated: false, completion: nil) - } - let conversationVC = ConversationVC(thread: thread) - self.navigationController?.setViewControllers([ self, conversationVC ], animated: true) + func show( + _ threadId: String, + with action: ConversationViewModel.Action, + highlightedInteractionId: Int64?, + animated: Bool + ) { + guard let conversationVC: ConversationVC = ConversationVC(threadId: threadId) else { + return } - } - - private func delete(_ thread: TSThread) { - let openGroupV2 = Storage.shared.getV2OpenGroup(for: thread.uniqueId!) - Storage.write { transaction in - Storage.shared.cancelPendingMessageSendJobs(for: thread.uniqueId!, using: transaction) - if let openGroupV2 = openGroupV2 { - OpenGroupManagerV2.shared.delete(openGroupV2, associatedWith: thread, using: transaction) - } else if let thread = thread as? TSGroupThread, thread.isClosedGroup == true { - let groupID = thread.groupModel.groupId - let groupPublicKey = LKGroupUtilities.getDecodedGroupID(groupID) - MessageSender.leave(groupPublicKey, using: transaction).retainUntilComplete() - thread.removeAllThreadInteractions(with: transaction) - thread.remove(with: transaction) - } else { - thread.removeAllThreadInteractions(with: transaction) - thread.remove(with: transaction) - } + + if let presentedVC = self.presentedViewController { + presentedVC.dismiss(animated: false, completion: nil) } + self.navigationController?.setViewControllers([ self, conversationVC ], animated: true) } @objc private func openSettings() { @@ -543,29 +542,4 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve let navigationController = OWSNavigationController(rootViewController: newClosedGroupVC) present(navigationController, animated: true, completion: nil) } - - // MARK: Convenience - private func thread(at index: Int) -> TSThread? { - var thread: TSThread? = nil - dbConnection.read { transaction in - // Note: Section needs to be '1' as we now have 'TSMessageRequests' as the 0th section - let ext = transaction.ext(TSThreadDatabaseViewExtensionName) as! YapDatabaseViewTransaction - thread = ext.object(atRow: UInt(index), inSection: 1, with: self.threads) as? TSThread - } - return thread - } - - private func threadViewModel(at index: Int) -> ThreadViewModel? { - guard let thread = thread(at: index) else { return nil } - if let cachedThreadViewModel = threadViewModelCache[thread.uniqueId!] { - return cachedThreadViewModel - } else { - var threadViewModel: ThreadViewModel? = nil - dbConnection.read { transaction in - threadViewModel = ThreadViewModel(thread: thread, transaction: transaction) - } - threadViewModelCache[thread.uniqueId!] = threadViewModel - return threadViewModel - } - } } diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index e67748908..3e3833fcb 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -11,105 +11,406 @@ public class HomeViewModel { case threads } + public struct ObservedInfo: Equatable { + let unreadMessageRequestCount: Int + let threadInfo: [ThreadInfo] + } + + public struct ThreadInfo: FetchableRecord, Decodable, Equatable, Differentiable { + public struct GroupMemberInfo: FetchableRecord, Decodable, Equatable { + public let profile: Profile + } + public struct InteractionInfo: FetchableRecord, Decodable, Equatable { + public struct AuthorInfo: FetchableRecord, Decodable, Equatable { + public let id: String + public let displayName: String + public let nickname: String? + } + + fileprivate static let timestampMsKey = CodingKeys.timestampMs.stringValue + fileprivate static let threadVariantKey = CodingKeys.threadVariant.stringValue + fileprivate static let authorInfoKey = CodingKeys.authorInfo.stringValue + fileprivate static let isOpenGroupInvitationKey = CodingKeys.isOpenGroupInvitation.stringValue + fileprivate static let recipientStatesKey = CodingKeys.recipientStates.stringValue + + public let id: Int64? + public let variant: Interaction.Variant + public let timestampMs: Double + + private let threadVariant: SessionThread.Variant + private let body: String? + private let attachments: [Attachment]? + private let authorId: String + private let authorInfo: AuthorInfo? + private let isOpenGroupInvitation: Bool + private let recipientStates: [RecipientState.State]? + + public var authorName: String { + return Profile.displayName( + for: threadVariant, + id: (authorInfo?.id ?? authorId), + name: authorInfo?.displayName, + nickname: authorInfo?.nickname, + customFallback: (threadVariant == .contact && variant == .standardIncoming ? + "Anonymous" : + nil + ) + ) + } + + public var text: String { + return Interaction.previewText( + variant: variant, + body: body, + authorDisplayName: authorName, + attachments: (attachments ?? []), + isOpenGroupInvitation: (isOpenGroupInvitation == true) + ) + } + + public var state: RecipientState.State { + return Interaction.state(for: (recipientStates ?? [])) + } + } + + fileprivate static let closedGroupNameKey = CodingKeys.closedGroupName.stringValue + fileprivate static let openGroupNameKey = CodingKeys.openGroupName.stringValue + fileprivate static let openGroupProfilePictureDataKey = CodingKeys.openGroupProfilePictureData.stringValue + fileprivate static let currentUserProfileKey = CodingKeys.currentUserProfile.stringValue + fileprivate static let contactProfileKey = CodingKeys.contactProfile.stringValue + fileprivate static let closedGroupAvatarProfilesKey = CodingKeys.closedGroupAvatarProfiles.stringValue + fileprivate static let contactIsBlockedKey = CodingKeys.contactIsBlocked.stringValue + fileprivate static let isNoteToSelfKey = CodingKeys.isNoteToSelf.stringValue + fileprivate static let currentUserIsClosedGroupAdminKey = CodingKeys.currentUserIsClosedGroupAdmin.stringValue + fileprivate static let threadUnreadCountKey = CodingKeys.threadUnreadCount.stringValue + fileprivate static let lastInteractionInfoKey = CodingKeys.lastInteractionInfo.stringValue + + public var differenceIdentifier: String { id } + + public let id: String + public let variant: SessionThread.Variant + private let creationDateTimestamp: TimeInterval + + public let closedGroupName: String? + public let openGroupName: String? + public let openGroupProfilePictureData: Data? + private let currentUserProfile: Profile + private let contactProfile: Profile? + private let closedGroupAvatarProfiles: [GroupMemberInfo]? + + public let notificationMode: SessionThread.NotificationMode + public let isPinned: Bool + + /// A flag indicating whether the contact is blocked (will be null for non-contact threads) + private let contactIsBlocked: Bool? + + public let isNoteToSelf: Bool + private let currentUserIsClosedGroupAdmin: Bool? + + private let threadUnreadCount: UInt? + public let unreadMentionCount: UInt = 0 // TODO: This + + public let lastInteractionInfo: InteractionInfo? + + public var displayName: String { + switch variant { + case .closedGroup: return (closedGroupName ?? "Unknown Group") + case .openGroup: return (openGroupName ?? "Unknown Group") + case .contact: + guard !isNoteToSelf else { return "NOTE_TO_SELF".localized() } + guard let profile: Profile = profile else { + return Profile.truncated(id: id, truncating: .middle) + } + + return (profile.nickname ?? profile.name) + } + } + + public var profile: Profile? { + switch variant { + case .contact: return contactProfile + case .openGroup: return nil + case .closedGroup: + // If there is only a single user in the group then we want to use the current user + // profile at the back + if closedGroupAvatarProfiles?.count == 1 { + return currentUserProfile + } + + return closedGroupAvatarProfiles?.first?.profile + } + } + + public var additionalProfile: Profile? { + switch variant { + case .closedGroup: return closedGroupAvatarProfiles?.last?.profile + default: return nil + } + } + + public var lastInteractionDate: Date { + guard let lastInteractionInfo: InteractionInfo = lastInteractionInfo else { + return Date(timeIntervalSince1970: creationDateTimestamp) + } + + return Date(timeIntervalSince1970: (lastInteractionInfo.timestampMs / 1000)) + } + + /// A flag indicating whether the thread is blocked (only contact threads can be blocked) + public var isBlocked: Bool { + return (contactIsBlocked == true) + } + + public var isGroupAdmin: Bool { + return (currentUserIsClosedGroupAdmin == true) + } + + public var unreadCount: UInt { + return (threadUnreadCount ?? 0) + } + + fileprivate init() { + self.id = "FALLBACK" + self.variant = .contact + self.creationDateTimestamp = 0 + self.closedGroupName = nil + self.openGroupName = nil + self.openGroupProfilePictureData = nil + self.currentUserProfile = Profile(id: "", name: "") + self.contactProfile = nil + self.closedGroupAvatarProfiles = nil + self.notificationMode = .none + self.isPinned = false + self.contactIsBlocked = nil + self.isNoteToSelf = false + self.currentUserIsClosedGroupAdmin = nil + self.threadUnreadCount = nil + self.lastInteractionInfo = nil + } + + // MARK: - Query + + public static func query(userPublicKey: String) -> QueryInterfaceRequest { + let thread: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() + let closedGroup: TypedTableAlias = TypedTableAlias() + let closedGroupMember: TypedTableAlias = TypedTableAlias() + let openGroup: TypedTableAlias = TypedTableAlias() + let unreadInteractions: TableAlias = TableAlias() + let lastInteraction: TableAlias = TableAlias() + let lastInteractionThread: TypedTableAlias = TypedTableAlias() + let linkPreview: TypedTableAlias = TypedTableAlias() + + let currentUserProfileExpression: CommonTableExpression = CommonTableExpression( + named: ThreadInfo.currentUserProfileKey, + request: Profile.filter(id: userPublicKey) + ) + let unreadInteractionExpression: CommonTableExpression = CommonTableExpression( + named: ThreadInfo.threadUnreadCountKey, + request: Interaction + .select( + count(Interaction.Columns.id).forKey(ThreadInfo.threadUnreadCountKey), + Interaction.Columns.threadId + ) + .filter(Interaction.Columns.wasRead == false) + .group(Interaction.Columns.threadId) + ) + let lastInteractionExpression: CommonTableExpression = CommonTableExpression( + named: ThreadInfo.lastInteractionInfoKey, + request: Interaction + .select( + Interaction.Columns.id, + Interaction.Columns.threadId, + Interaction.Columns.variant, + + // 'max()' to get the latest + max(Interaction.Columns.timestampMs).forKey(ThreadInfo.InteractionInfo.timestampMsKey), + + lastInteractionThread[.variant].forKey(ThreadInfo.InteractionInfo.threadVariantKey), + Interaction.Columns.body, + Interaction.Columns.authorId, + (linkPreview[.url] != nil).forKey(ThreadInfo.InteractionInfo.isOpenGroupInvitationKey) + ) + .joining(required: Interaction.thread.aliased(lastInteractionThread)) + .joining( + optional: Interaction.linkPreview + .filter(literal: Interaction.linkPreviewFilterLiteral) + .filter(LinkPreview.Columns.variant == LinkPreview.Variant.openGroupInvitation) + ) + .including(all: Interaction.attachments) + .including( + all: Interaction.recipientStates + .select(RecipientState.Columns.state) + .forKey(ThreadInfo.InteractionInfo.recipientStatesKey) + ) + .group(Interaction.Columns.threadId) // One interaction per thread + ) + return SessionThread + .select( + thread[.id], + thread[.variant], + thread[.creationDateTimestamp], + + closedGroup[.name].forKey(ThreadInfo.closedGroupNameKey), + openGroup[.name].forKey(ThreadInfo.openGroupNameKey), + openGroup[.imageData].forKey(ThreadInfo.openGroupProfilePictureDataKey), + + thread[.notificationMode], + thread[.isPinned], + contact[.isBlocked].forKey(ThreadInfo.contactIsBlockedKey), + SessionThread.isNoteToSelf(userPublicKey: userPublicKey).forKey(ThreadInfo.isNoteToSelfKey), + (closedGroupMember[.profileId] != nil).forKey(ThreadInfo.currentUserIsClosedGroupAdminKey), + + unreadInteractions[ThreadInfo.threadUnreadCountKey] + ) + .aliased(thread) + .joining( + optional: SessionThread.contact + .aliased(contact) + .including( + optional: Contact.profile + .forKey(ThreadInfo.contactProfileKey) + ) + ) + .joining( + optional: SessionThread.closedGroup + .aliased(closedGroup) + .including( + all: ClosedGroup.members + .filter(GroupMember.Columns.role == GroupMember.Role.standard) + .filter(GroupMember.Columns.profileId != userPublicKey) + .order(GroupMember.Columns.profileId) // Sort to provide a level of stability + .limit(2) + .including(required: GroupMember.profile) + .forKey(ThreadInfo.closedGroupAvatarProfilesKey) + ) + .joining( + optional: ClosedGroup.members + .aliased(closedGroupMember) + .filter(GroupMember.Columns.role == GroupMember.Role.admin) + .filter(GroupMember.Columns.profileId == userPublicKey) + ) + ) + .joining(optional: SessionThread.openGroup.aliased(openGroup)) + .with(currentUserProfileExpression) + .including(required: SessionThread.association(to: currentUserProfileExpression).forKey(ThreadInfo.currentUserProfileKey)) + .with(unreadInteractionExpression) + .joining( + optional: SessionThread + .association( + to: unreadInteractionExpression, + on: { thread, unreadGroup in + thread[SessionThread.Columns.id] == unreadGroup[Interaction.Columns.threadId] + } + ) + .aliased(unreadInteractions) + ) + .with(lastInteractionExpression) + .including( + optional: SessionThread + .association( + to: lastInteractionExpression, + on: { thread, lastInteraction in + thread[SessionThread.Columns.id] == lastInteraction[Interaction.Columns.threadId] + } + ) + .aliased(lastInteraction) + .forKey(ThreadInfo.lastInteractionInfoKey) + .including( + optional: lastInteractionExpression + .association( + to: CommonTableExpression( + named: Profile.databaseTableName, + request: Profile.select(.id, .name, .nickname) + ), + on: { lastInteraction, profile in + lastInteraction[Interaction.Columns.authorId] == profile[Profile.Columns.id] + } + ) + .forKey(ThreadInfo.InteractionInfo.authorInfoKey) + ) + ) + .order( + lastInteraction[Interaction.Columns.timestampMs].desc, + thread[.creationDateTimestamp].desc + ) + .asRequest(of: ThreadInfo.self) + } + } + + public struct Item: Equatable, Differentiable { public var differenceIdentifier: String { - return (threadViewModel?.thread.id ?? "\(unreadCount)") + return threadInfo.id } let unreadCount: Int - let threadViewModel: ThreadViewModel? + let threadInfo: ThreadInfo } /// This value is the current state of the view public private(set) var viewData: [ArraySection] = [] - /// This is all the data the HomeVC needs to populate itself, please see the following link for tips to help optimise + /// 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 - public lazy var observableViewData = ValueObservation.tracking { db -> [ArraySection] in - // If message requests are hidden then don't bother fetching the unread count - let unreadMessageRequestCount: Int = (db[.hasHiddenMessageRequests] ? - 0 : - try SessionThread - .messageRequestThreads(db) + /// + /// **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 + public lazy var observableViewData = ValueObservation + .trackingConstantRegion { db -> ObservedInfo in + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let unreadMessageRequestCount: Int = try SessionThread + .filter(SessionThread.isMessageRequest(userPublicKey: userPublicKey)) + .joining(optional: SessionThread.contact) .joining( required: SessionThread.interactions .filter(Interaction.Columns.wasRead == false) ) + .group(SessionThread.Columns.id) .fetchCount(db) - ) - - let userPublicKey: String = getUserHexEncodedPublicKey(db) - let threadViewModels = try SessionThread - .fetchAll(db) - .compactMap { thread -> ThreadViewModel? in - let lastInteraction: Interaction? = try thread - .interactions - .order(Interaction.Columns.id.desc) - .fetchOne(db) - - // Only show the 'Note to Self' thread if it has interactions - guard !thread.isNoteToSelf(db) || lastInteraction != nil else { return nil } - - let unreadMessageCount: Int = try thread - .interactions - .filter(Interaction.Columns.wasRead == false) - .fetchCount(db) - let quoteAlias: TableAlias = TableAlias() - let unreadMentionCount: Int = try thread - .interactions - .filter(Interaction.Columns.wasRead == false) - .joining( - optional: Interaction.quote - .aliased(quoteAlias)// TODO: Test that this works - ) + + return ObservedInfo( + unreadMessageRequestCount: (db[.hasHiddenMessageRequests] ? 0 : unreadMessageRequestCount), + threadInfo: try ThreadInfo + .query(userPublicKey: userPublicKey) + .filter(SessionThread.Columns.shouldBeVisible == true) + .filter(SessionThread.isNotMessageRequest(userPublicKey: userPublicKey)) .filter( - Interaction.Columns.body.like("%@\(userPublicKey)") || - quoteAlias[Quote.Columns.authorId] == userPublicKey + // Only show the Note to Self if it has a lastInteraction + SessionThread.Columns.id != userPublicKey || + SQL(stringLiteral: "\(ThreadInfo.lastInteractionInfoKey).id IS NOT NULL") ) - .fetchCount(db) - - return ThreadViewModel( - thread: thread, - name: thread.name(db), - unreadCount: UInt(unreadMessageCount), - unreadMentionCount: UInt(unreadMentionCount), - lastInteraction: lastInteraction, - lastInteractionDate: ( - lastInteraction.map { Date(timeIntervalSince1970: Double($0.timestampMs / 1000)) } ?? - Date(timeIntervalSince1970: thread.creationDateTimestamp) - ), - lastInteractionText: lastInteraction?.previewText(db), - lastInteractionState: try lastInteraction?.state(db) - ) - } - - return [ - ArraySection( - model: .messageRequests, - elements: [ - // If there are no unread message requests then hide the message request banner - (unreadMessageRequestCount == 0 ? - nil : - Item( - unreadCount: unreadMessageRequestCount, - threadViewModel: nil + .fetchAll(db) + ) + } + .removeDuplicates() + .map { observedInfo -> [ArraySection] in + return [ + ArraySection( + model: .messageRequests, + elements: [ + // If there are no unread message requests then hide the message request banner + (observedInfo.unreadMessageRequestCount == 0 ? + nil : + Item( + unreadCount: observedInfo.unreadMessageRequestCount, + threadInfo: ThreadInfo() // Won't be used + ) ) - ) - ].compactMap { $0 } - ), - ArraySection( - model: .threads, - elements: threadViewModels - .sorted(by: { lhs, rhs in lhs.lastInteractionDate > rhs.lastInteractionDate }) - .map { - Item( - unreadCount: Int($0.unreadCount), - threadViewModel: $0 - ) - } - ), - ] - } + ].compactMap { $0 } + ), + ArraySection( + model: .threads, + elements: observedInfo.threadInfo + .map { info in + Item( + unreadCount: Int(info.unreadCount), + threadInfo: info + ) + } + ), + ] + } // MARK: - Functions diff --git a/Session/Home/Message Requests/MessageRequestsViewController.swift b/Session/Home/Message Requests/MessageRequestsViewController.swift index 2f87f4942..8b324fb3f 100644 --- a/Session/Home/Message Requests/MessageRequestsViewController.swift +++ b/Session/Home/Message Requests/MessageRequestsViewController.swift @@ -2,49 +2,34 @@ import UIKit import GRDB +import DifferenceKit import SessionUIKit import SessionMessagingKit +import SignalUtilitiesKit -@objc class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDataSource { - private var threads: YapDatabaseViewMappings! = { - let result = YapDatabaseViewMappings(groups: [ TSMessageRequestGroup ], view: TSThreadDatabaseViewExtensionName) - result.setIsReversed(true, forGroup: TSMessageRequestGroup) - return result - }() - private var threadViewModelCache: [String: ThreadViewModel] = [:] // Thread ID to ThreadViewModel - private var tableViewTopConstraint: NSLayoutConstraint! - - private var messageRequestCount: UInt { - threads.numberOfItems(inGroup: TSMessageRequestGroup) - } - - private lazy var dbConnection: YapDatabaseConnection = { - let result = OWSPrimaryStorage.shared().newDatabaseConnection() - result.objectCacheLimit = 500 - - return result - }() + private let viewModel: MessageRequestsViewModel = MessageRequestsViewModel() + private var dataChangeObservable: DatabaseCancellable? + private var hasLoadedInitialData: Bool = false // MARK: - UI - + private lazy var tableView: UITableView = { let result: UITableView = UITableView() result.translatesAutoresizingMaskIntoConstraints = false result.backgroundColor = .clear result.separatorStyle = .none - result.register(MessageRequestsCell.self, forCellReuseIdentifier: MessageRequestsCell.reuseIdentifier) - result.register(ConversationCell.self, forCellReuseIdentifier: ConversationCell.reuseIdentifier) + result.register(view: ConversationCell.self) result.dataSource = self result.delegate = self - + let bottomInset = Values.newConversationButtonBottomOffset + NewConversationButtonSet.expandedButtonSize + Values.largeSpacing + NewConversationButtonSet.collapsedButtonSize result.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: bottomInset, right: 0) result.showsVerticalScrollIndicator = false - + return result }() - + private lazy var emptyStateLabel: UILabel = { let result: UILabel = UILabel() result.translatesAutoresizingMaskIntoConstraints = false @@ -55,19 +40,19 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat result.textAlignment = .center result.numberOfLines = 0 result.isHidden = true - + return result }() - + private lazy var fadeView: UIView = { let result: UIView = UIView() result.translatesAutoresizingMaskIntoConstraints = false result.isUserInteractionEnabled = false result.setGradient(Gradients.homeVCFade) - + return result }() - + private lazy var clearAllButton: Button = { let result: Button = Button(style: .destructiveOutline, size: .large) result.translatesAutoresizingMaskIntoConstraints = false @@ -79,17 +64,21 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat for: .highlighted ) result.addTarget(self, action: #selector(clearAllTapped), for: .touchUpInside) - + return result }() - + // MARK: - Lifecycle - + override func viewDidLoad() { super.viewDidLoad() - - ViewControllerUtilities.setUpDefaultSessionStyle(for: self, title: NSLocalizedString("MESSAGE_REQUESTS_TITLE", comment: ""), hasCustomBackButton: false) - + + ViewControllerUtilities.setUpDefaultSessionStyle( + for: self, + title: "MESSAGE_REQUESTS_TITLE".localized(), + hasCustomBackButton: false + ) + // Add the UI (MUST be done after the thread freeze so the 'tableView' creation and setting // the dataSource has the correct data) view.addSubview(tableView) @@ -97,58 +86,66 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat view.addSubview(fadeView) view.addSubview(clearAllButton) setupLayout() - + // Notifications NotificationCenter.default.addObserver( self, - selector: #selector(handleYapDatabaseModifiedNotification(_:)), - name: .YapDatabaseModified, - object: OWSPrimaryStorage.shared().dbNotificationObject - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(handleProfileDidChangeNotification(_:)), - name: Notification.Name.otherUsersProfileDidChange, + selector: #selector(applicationDidBecomeActive(_:)), + name: UIApplication.didBecomeActiveNotification, object: nil ) NotificationCenter.default.addObserver( self, - selector: #selector(handleBlockedContactsUpdatedNotification(_:)), - name: .blockedContactsUpdated, - object: nil + selector: #selector(applicationDidResignActive(_:)), + name: UIApplication.didEnterBackgroundNotification, object: nil ) - - reload() } - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - reload() + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + startObservingChanges() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + // Stop observing database changes + dataChangeObservable?.cancel() + } + + @objc func applicationDidBecomeActive(_ notification: Notification) { + startObservingChanges() + } + + @objc func applicationDidResignActive(_ notification: Notification) { + // Stop observing database changes + dataChangeObservable?.cancel() } deinit { NotificationCenter.default.removeObserver(self) } - + // MARK: - Layout - + private func setupLayout() { NSLayoutConstraint.activate([ tableView.topAnchor.constraint(equalTo: view.topAnchor, constant: Values.smallSpacing), tableView.leftAnchor.constraint(equalTo: view.leftAnchor), tableView.rightAnchor.constraint(equalTo: view.rightAnchor), tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - + emptyStateLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: Values.massiveSpacing), emptyStateLabel.leftAnchor.constraint(equalTo: view.leftAnchor, constant: Values.mediumSpacing), emptyStateLabel.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -Values.mediumSpacing), emptyStateLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), - + fadeView.topAnchor.constraint(equalTo: view.topAnchor, constant: (0.15 * view.bounds.height)), fadeView.leftAnchor.constraint(equalTo: view.leftAnchor), fadeView.rightAnchor.constraint(equalTo: view.rightAnchor), fadeView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - + clearAllButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), clearAllButton.bottomAnchor.constraint( equalTo: view.safeAreaLayoutGuide.bottomAnchor, @@ -160,274 +157,161 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat ]) } - // MARK: - UITableViewDataSource - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return Int(messageRequestCount) - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: ConversationCell.reuseIdentifier) as! ConversationCell - cell.threadViewModel = threadViewModel(at: indexPath.row) - return cell - } - // MARK: - Updating - private func reload() { - AssertIsOnMainThread() - dbConnection.beginLongLivedReadTransaction() // Jump to the latest commit - dbConnection.read { transaction in - self.threads.update(with: transaction) - } - threadViewModelCache.removeAll() - tableView.reloadData() - clearAllButton.isHidden = (messageRequestCount == 0) - emptyStateLabel.isHidden = (messageRequestCount != 0) + private func startObservingChanges() { + // Start observing for data changes + dataChangeObservable = GRDBStorage.shared.start( + viewModel.observableViewData, + onError: { error in + print("Update error \(error)!!!!") + }, + onChange: { [weak self] viewData in + // The defaul scheduler emits changes on the main thread + self?.handleUpdates(viewData) + } + ) } - @objc private func handleYapDatabaseModifiedNotification(_ yapDatabase: YapDatabase) { - // NOTE: This code is very finicky and crashes easily. Modify with care. - AssertIsOnMainThread() - - // If we don't capture `threads` here, a race condition can occur where the - // `thread.snapshotOfLastUpdate != firstSnapshot - 1` check below evaluates to - // `false`, but `threads` then changes between that check and the - // `ext.getSectionChanges(§ionChanges, rowChanges: &rowChanges, for: notifications, with: threads)` - // line. This causes `tableView.endUpdates()` to crash with an `NSInternalInconsistencyException`. - let threads = threads! - - // Create a stable state for the connection and jump to the latest commit - let notifications = dbConnection.beginLongLivedReadTransaction() - - guard !notifications.isEmpty else { return } - - let ext = dbConnection.ext(TSThreadDatabaseViewExtensionName) as! YapDatabaseViewConnection - let hasChanges = ext.hasChanges(forGroup: TSMessageRequestGroup, in: notifications) - - guard hasChanges else { return } - - if let firstChangeSet = notifications[0].userInfo { - let firstSnapshot = firstChangeSet[YapDatabaseSnapshotKey] as! UInt64 - - if threads.snapshotOfLastUpdate != firstSnapshot - 1 { - return reload() // The code below will crash if we try to process multiple commits at once - } + private func handleUpdates(_ updatedViewData: [HomeViewModel.ThreadInfo]) { + // 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 } - var sectionChanges = NSArray() - var rowChanges = NSArray() - ext.getSectionChanges(§ionChanges, rowChanges: &rowChanges, for: notifications, with: threads) + // Show the empty state if there is no data + clearAllButton.isHidden = updatedViewData.isEmpty + emptyStateLabel.isHidden = !updatedViewData.isEmpty - guard sectionChanges.count > 0 || rowChanges.count > 0 else { return } - - tableView.beginUpdates() - - rowChanges.forEach { rowChange in - let rowChange = rowChange as! YapDatabaseViewRowChange - let key = rowChange.collectionKey.key - threadViewModelCache[key] = nil - switch rowChange.type { - case .delete: tableView.deleteRows(at: [ rowChange.indexPath! ], with: UITableView.RowAnimation.automatic) - case .insert: tableView.insertRows(at: [ rowChange.newIndexPath! ], with: UITableView.RowAnimation.automatic) - case .update: tableView.reloadRows(at: [ rowChange.indexPath! ], with: UITableView.RowAnimation.automatic) - default: break - } + // 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) } - tableView.endUpdates() - - // HACK: Moves can have conflicts with the other 3 types of change. - // Just batch perform all the moves separately to prevent crashing. - // Since all the changes are from the original state to the final state, - // it will still be correct if we pick the moves out. - - tableView.beginUpdates() - - rowChanges.forEach { rowChange in - let rowChange = rowChange as! YapDatabaseViewRowChange - let key = rowChange.collectionKey.key - threadViewModelCache[key] = nil - - switch rowChange.type { - case .move: tableView.moveRow(at: rowChange.indexPath!, to: rowChange.newIndexPath!) - default: break - } - } - - tableView.endUpdates() - clearAllButton.isHidden = (messageRequestCount == 0) - emptyStateLabel.isHidden = (messageRequestCount != 0) - } - - @objc private func handleProfileDidChangeNotification(_ notification: Notification) { - tableView.reloadData() // TODO: Just reload the affected cell - } - - @objc private func handleBlockedContactsUpdatedNotification(_ notification: Notification) { - tableView.reloadData() // TODO: Just reload the affected cell } @objc override internal func handleAppModeChangedNotification(_ notification: Notification) { super.handleAppModeChangedNotification(notification) - + let gradient = Gradients.homeVCFade fadeView.setGradient(gradient) // Re-do the gradient tableView.reloadData() } + // MARK: - UITableViewDataSource + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return viewModel.viewData.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell: ConversationCell = tableView.dequeue(type: ConversationCell.self, for: indexPath) + cell.update(with: viewModel.viewData[indexPath.row]) + return cell + } + // MARK: - UITableViewDelegate func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) + + guard let conversationVC: ConversationVC = ConversationVC(threadId: viewModel.viewData[indexPath.row].id) else { + return + } - guard let thread = self.thread(at: indexPath.row) else { return } - - let conversationVC = ConversationVC(thread: thread) self.navigationController?.pushViewController(conversationVC, animated: true) } - + func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { return true } - + func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? { - guard let thread = self.thread(at: indexPath.row) else { return [] } - - let delete = UITableViewRowAction(style: .destructive, title: NSLocalizedString("TXT_DELETE_TITLE", comment: "")) { [weak self] _, _ in - self?.delete(thread) + let threadId: String = viewModel.viewData[indexPath.row].id + let delete = UITableViewRowAction( + style: .destructive, + title: "TXT_DELETE_TITLE".localized() + ) { [weak self] _, _ in + self?.delete(threadId) } delete.backgroundColor = Colors.destructive - + return [ delete ] } - - // MARK: - Interaction - - @objc private func clearAllTapped() { - let threadCount: Int = Int(messageRequestCount) - let threads: [TSThread] = (0.. TSThread? { - var thread: TSThread? = nil - - dbConnection.read { transaction in - let ext: YapDatabaseViewTransaction? = transaction.ext(TSThreadDatabaseViewExtensionName) as? YapDatabaseViewTransaction - thread = ext?.object(atRow: UInt(index), inSection: 0, with: self.threads) as? TSThread - } - - return thread - } - - private func threadViewModel(at index: Int) -> ThreadViewModel? { - guard let thread = thread(at: index), let uniqueId: String = thread.uniqueId else { return nil } - - if let cachedThreadViewModel = threadViewModelCache[uniqueId] { - return cachedThreadViewModel - } - else { - var threadViewModel: ThreadViewModel? = nil - dbConnection.read { transaction in - threadViewModel = ThreadViewModel(thread: thread, transaction: transaction) + private func delete(_ threadId: String) { + let alertVC: UIAlertController = UIAlertController( + title: "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON".localized(), + message: nil, + preferredStyle: .actionSheet + ) + alertVC.addAction(UIAlertAction( + title: "TXT_DELETE_TITLE".localized(), + style: .destructive + ) { _ in + GRDBStorage.shared.write { db in + _ = try SessionThread + .filter(id: threadId) + .deleteAll(db) + _ = try Contact + .fetchOrCreate(db, id: threadId) + .with( + isApproved: false, + isBlocked: true + ) + .saved(db) + + // Force a config sync + try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() } - threadViewModelCache[uniqueId] = threadViewModel - - return threadViewModel - } + }) + + alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil)) + self.present(alertVC, animated: true, completion: nil) } } diff --git a/Session/Home/Message Requests/MessageRequestsViewModel.swift b/Session/Home/Message Requests/MessageRequestsViewModel.swift index 0a8ab0dac..996ac79d8 100644 --- a/Session/Home/Message Requests/MessageRequestsViewModel.swift +++ b/Session/Home/Message Requests/MessageRequestsViewModel.swift @@ -1,3 +1,33 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB +import DifferenceKit +import SignalUtilitiesKit + +public class MessageRequestsViewModel { + /// This value is the current state of the view + public private(set) var viewData: [HomeViewModel.ThreadInfo] = [] + + /// 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 + public lazy var observableViewData = ValueObservation + .trackingConstantRegion { db -> [HomeViewModel.ThreadInfo] in + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + return try HomeViewModel.ThreadInfo + .query(userPublicKey: userPublicKey) + .filter(SessionThread.isMessageRequest(userPublicKey: userPublicKey)) + .fetchAll(db) + } + .removeDuplicates() + + // MARK: - Functions + + public func updateData(_ updatedData: [HomeViewModel.ThreadInfo]) { + self.viewData = updatedData + } +} diff --git a/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift b/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift index f667a47b7..1bc09d68d 100644 --- a/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift +++ b/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift @@ -8,11 +8,6 @@ import SignalUtilitiesKit import PromiseKit import SessionUIKit -@objc -protocol GifPickerViewControllerDelegate: class { - func gifPickerDidSelect(attachment: SignalAttachment) -} - class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollectionViewDataSource, UICollectionViewDelegate, GifPickerLayoutDelegate { // MARK: Properties @@ -31,11 +26,8 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect var lastQuery: String = "" - @objc public weak var delegate: GifPickerViewControllerDelegate? - let thread: TSThread - let searchBar: SearchBar let layout: GifPickerLayout let collectionView: UICollectionView @@ -51,17 +43,14 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect var progressiveSearchTimer: Timer? - // MARK: Initializers + // MARK: - Initialization @available(*, unavailable, message:"use other constructor instead.") required init?(coder aDecoder: NSCoder) { notImplemented() } - @objc - required init(thread: TSThread) { - self.thread = thread - + required init() { self.searchBar = SearchBar() self.layout = GifPickerLayout() self.collectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: self.layout) @@ -556,3 +545,9 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect layout.invalidateLayout() } } + +// MARK: - GifPickerViewControllerDelegate + +protocol GifPickerViewControllerDelegate: AnyObject { + func gifPickerDidSelect(attachment: SignalAttachment) +} diff --git a/Session/Media Viewing & Editing/SendMediaNavigationController.swift b/Session/Media Viewing & Editing/SendMediaNavigationController.swift index de975713e..798aeb712 100644 --- a/Session/Media Viewing & Editing/SendMediaNavigationController.swift +++ b/Session/Media Viewing & Editing/SendMediaNavigationController.swift @@ -1,21 +1,10 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -import Foundation +import UIKit import Photos import PromiseKit +import SignalUtilitiesKit -@objc -protocol SendMediaNavDelegate: AnyObject { - func sendMediaNavDidCancel(_ sendMediaNavigationController: SendMediaNavigationController) - func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [SignalAttachment], messageText: String?) - - func sendMediaNavInitialMessageText(_ sendMediaNavigationController: SendMediaNavigationController) -> String? - func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didChangeMessageText newMessageText: String?) -} - -@objc class SendMediaNavigationController: OWSNavigationController { // This is a sensitive constant, if you change it make sure to check @@ -56,7 +45,6 @@ class SendMediaNavigationController: OWSNavigationController { // MARK: - - @objc public weak var sendMediaNavDelegate: SendMediaNavDelegate? @objc @@ -680,3 +668,13 @@ private class DoneButton: UIView { delegate?.doneButtonWasTapped(self) } } + +// MARK: - SendMediaNavDelegate + +protocol SendMediaNavDelegate: AnyObject { + func sendMediaNavDidCancel(_ sendMediaNavigationController: SendMediaNavigationController) + func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [SignalAttachment], messageText: String?) + + func sendMediaNavInitialMessageText(_ sendMediaNavigationController: SendMediaNavigationController) -> String? + func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didChangeMessageText newMessageText: String?) +} diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index e589bfda6..235fedbb3 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB import PromiseKit import SessionMessagingKit import SessionUtilitiesKit @@ -24,12 +25,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD verifyDBKeysAvailableBeforeBackgroundLaunch() AppModeManager.configure(delegate: self) - - // OWSLinkPreview is now in SessionMessagingKit, so to still be able to deserialize them we - // need to tell NSKeyedUnarchiver about the changes. - // FIXME: Remove this once YapDatabase gets removed - NSKeyedUnarchiver.setClass(OWSLinkPreview.self, forClassName: "SessionServiceKit.OWSLinkPreview") - Cryptography.seedRandom() AppVersion.sharedInstance() // TODO: ??? @@ -44,7 +39,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD appSpecificSingletonBlock: { // Create AppEnvironment AppEnvironment.shared.setup() - SignalApp.shared().setup() }, migrationCompletion: { [weak self] successful, needsConfigSync in guard let strongSelf = self else { return } @@ -298,7 +292,30 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD private func clearAllNotificationsAndRestoreBadgeCount() { AppReadiness.runNowOrWhenAppDidBecomeReady { AppEnvironment.shared.notificationPresenter.clearAllNotifications() - OWSMessageUtils.sharedManager().updateApplicationBadgeCount() + + guard CurrentAppContext().isMainApp else { return } + + CurrentAppContext().setMainAppBadgeNumber( + GRDBStorage.shared + .read({ db in + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + // Don't increase the count for muted threads or message requests + return try Interaction + .filter(Interaction.Columns.wasRead == false) + .joining( + required: Interaction.thread + .joining(optional: SessionThread.contact) + .filter(SessionThread.Columns.notificationMode != SessionThread.NotificationMode.none) + .filter( + SessionThread.Columns.variant != SessionThread.Variant.contact || + !SessionThread.isMessageRequest(userPublicKey: userPublicKey) + ) + ) + .fetchCount(db) + }) + .defaulting(to: 0) + ) } } diff --git a/Session/Meta/SessionApp.swift b/Session/Meta/SessionApp.swift index d23fe650b..1e653f5c7 100644 --- a/Session/Meta/SessionApp.swift +++ b/Session/Meta/SessionApp.swift @@ -11,25 +11,25 @@ public struct SessionApp { public static func presentConversation(for recipientId: String, action: ConversationViewModel.Action = .none, animated: Bool) { let maybeThread: SessionThread? = GRDBStorage.shared.write { db in - SessionThread.fetchOrCreate(db, id: recipientId, variant: .contact) + try SessionThread.fetchOrCreate(db, id: recipientId, variant: .contact) } - guard let thread: SessionThread = maybeThread else { return } + guard maybeThread != nil else { return } - self.presentConversation(for: thread, action: action, animated: animated) + self.presentConversation(for: recipientId, action: action, animated: animated) } public static func presentConversation(for threadId: String, animated: Bool) { - guard let thread: SessionThread = GRDBStorage.shared.read({ db in try SessionThread.fetchOne(db, id: threadId) }) else { + guard GRDBStorage.shared.read({ db in try SessionThread.exists(db, id: threadId) }) == true else { SNLog("Unable to find thread with id:\(threadId)") return } - self.presentConversation(for: thread, animated: animated) + self.presentConversation(for: threadId, animated: animated) } public static func presentConversation( - for thread: SessionThread, + for threadId: String, action: ConversationViewModel.Action = .none, focusInteractionId: Int64? = nil, animated: Bool @@ -37,7 +37,7 @@ public struct SessionApp { guard Thread.isMainThread else { DispatchQueue.main.async { self.presentConversation( - for: thread, + for: threadId, action: action, focusInteractionId: focusInteractionId, animated: animated @@ -47,9 +47,9 @@ public struct SessionApp { } homeViewController.wrappedValue?.show( - thread, + threadId, with: action, - highlightedInteractionId: focusInteractionId, // TODO: Confirm this + highlightedInteractionId: focusInteractionId, animated: animated ) } diff --git a/Session/Meta/Signal-Bridging-Header.h b/Session/Meta/Signal-Bridging-Header.h index 0eb31c11d..a9cefec5e 100644 --- a/Session/Meta/Signal-Bridging-Header.h +++ b/Session/Meta/Signal-Bridging-Header.h @@ -28,7 +28,6 @@ #import "OWSWindowManager.h" #import "PrivacySettingsTableViewController.h" #import "OWSQRCodeScanningViewController.h" -#import "SignalApp.h" #import "MainAppContext.h" #import "UIViewController+Permissions.h" #import diff --git a/Session/Notifications/AppNotifications.swift b/Session/Notifications/AppNotifications.swift index 429fd4b1c..831264ad0 100644 --- a/Session/Notifications/AppNotifications.swift +++ b/Session/Notifications/AppNotifications.swift @@ -1,6 +1,4 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation import GRDB @@ -365,11 +363,11 @@ class NotificationActionHandler { // MARK: - func markAsRead(userInfo: [AnyHashable: Any]) throws -> Promise { - guard let threadId = userInfo[AppNotificationUserInfoKey.threadId] as? String else { + guard let threadId: String = userInfo[AppNotificationUserInfoKey.threadId] as? String else { throw NotificationError.failDebug("threadId was unexpectedly nil") } - guard let thread = TSThread.fetch(uniqueId: threadId) else { + guard let thread: SessionThread = GRDBStorage.shared.read({ db in try SessionThread.fetchOne(db, id: threadId) }) else { throw NotificationError.failDebug("unable to find thread with id: \(threadId)") } @@ -394,7 +392,13 @@ class NotificationActionHandler { timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) ).inserted(db) - _ = try interaction.markingAsRead(db, includingOlder: true, trySendReadReceipt: true) + try Interaction.markAsRead( + db, + interactionId: interaction.id, + threadId: thread.id, + includingOlder: true, + trySendReadReceipt: true + ) return MessageSender.sendNonDurably(db, interaction: interaction, in: thread) } @@ -426,10 +430,32 @@ class NotificationActionHandler { return Promise.value(()) } - private func markAsRead(thread: TSThread) -> Promise { - return Storage.write { transaction in - thread.markAllAsRead(with: transaction) - } + private func markAsRead(thread: SessionThread) -> Promise { + let (promise, seal) = Promise.pending() + + GRDBStorage.shared.writeAsync( + updates: { db in + try Interaction.markAsRead( + db, + interactionId: try thread.interactions + .select(.id) + .order(Interaction.Columns.timestampMs.desc) + .asRequest(of: Int64?.self) + .fetchOne(db), + threadId: thread.id, + includingOlder: true, + trySendReadReceipt: true + ) + }, + completion: { _, result in + switch result { + case .success: seal.fulfill(()) + case .failure(let error): seal.reject(error) + } + } + ) + + return promise } } diff --git a/Session/Notifications/SyncPushTokensJob.swift b/Session/Notifications/SyncPushTokensJob.swift index e6496f801..85e276feb 100644 --- a/Session/Notifications/SyncPushTokensJob.swift +++ b/Session/Notifications/SyncPushTokensJob.swift @@ -88,9 +88,13 @@ public enum SyncPushTokensJob: JobExecutor { } public static func run(uploadOnlyIfStale: Bool) { - guard let job: Job = Job(variant: .syncPushTokens, details: SyncPushTokensJob.Details(uploadOnlyIfStale: uploadOnlyIfStale)) else { - return - } + guard let job: Job = Job( + variant: .syncPushTokens, + details: SyncPushTokensJob.Details( + uploadOnlyIfStale: uploadOnlyIfStale + ) + ) + else { return } SyncPushTokensJob.run( job, diff --git a/Session/Settings/QRCodeVC.swift b/Session/Settings/QRCodeVC.swift index 41731b779..c54494be5 100644 --- a/Session/Settings/QRCodeVC.swift +++ b/Session/Settings/QRCodeVC.swift @@ -130,10 +130,17 @@ final class QRCodeVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControl let alert = UIAlertController(title: NSLocalizedString("invalid_session_id", comment: ""), message: NSLocalizedString("Please check the Session ID and try again.", comment: ""), preferredStyle: .alert) alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil)) presentAlert(alert) - } else { - let thread = TSContactThread.getOrCreateThread(contactSessionID: hexEncodedPublicKey) + } + else { + let maybeThread: SessionThread? = GRDBStorage.shared.write { db in + try SessionThread.fetchOrCreate(db, id: hexEncodedPublicKey, variant: .contact) + } + + guard maybeThread != nil else { return } + presentingViewController?.dismiss(animated: true, completion: nil) - SignalApp.shared().presentConversation(for: thread, action: .compose, animated: false) + + SessionApp.presentConversation(for: hexEncodedPublicKey, action: .compose, animated: false) } } } diff --git a/Session/Shared/ConversationCell.swift b/Session/Shared/ConversationCell.swift index b72e839a3..65d4a0275 100644 --- a/Session/Shared/ConversationCell.swift +++ b/Session/Shared/ConversationCell.swift @@ -201,30 +201,43 @@ final class ConversationCell : UITableViewCell { // MARK: - Content - public func update(with threadViewModel: ThreadViewModel?, isGlobalSearchResult: Bool = false) { - guard let threadViewModel: ThreadViewModel = threadViewModel else { return } + public func update(with threadInfo: HomeViewModel.ThreadInfo, isGlobalSearchResult: Bool = false) { guard !isGlobalSearchResult else { - updateForSearchResult(threadViewModel) + updateForSearchResult(threadInfo) return } - update(threadViewModel) + update(threadInfo) } // MARK: - Updating for search results - private func updateForSearchResult(_ threadViewModel: ThreadViewModel) { - GRDBStorage.shared.read { db in profilePictureView.update(db, thread: threadViewModel.thread) } + private func updateForSearchResult(_ threadInfo: HomeViewModel.ThreadInfo) { + profilePictureView.update( + publicKey: threadInfo.id, + profile: threadInfo.profile, + additionalProfile: threadInfo.additionalProfile, + threadVariant: threadInfo.variant, + openGroupProfilePicture: threadInfo.openGroupProfilePictureData.map { UIImage(data: $0) }, + useFallbackPicture: (threadInfo.variant == .openGroup && threadInfo.openGroupProfilePictureData == nil) + ) isPinnedIcon.isHidden = true unreadCountView.isHidden = true hasMentionView.isHidden = true } - public func configureForRecent(_ threadViewModel: ThreadViewModel) { - displayNameLabel.attributedText = NSMutableAttributedString(string: getDisplayName(for: threadViewModel.thread), attributes: [.foregroundColor:Colors.text]) + public func configureForRecent(_ threadInfo: HomeViewModel.ThreadInfo) { + displayNameLabel.attributedText = NSMutableAttributedString( + string: threadInfo.displayName, + attributes: [ .foregroundColor: Colors.text ] + ) bottomLabelStackView.isHidden = false - let snippet = String(format: NSLocalizedString("RECENT_SEARCH_LAST_MESSAGE_DATETIME", comment: ""), DateUtil.formatDate(forDisplay: threadViewModel.lastInteractionDate)) + + let snippet = String( + format: "RECENT_SEARCH_LAST_MESSAGE_DATETIME".localized(), + DateUtil.formatDate(forDisplay: threadInfo.lastInteractionDate) + ) snippetLabel.attributedText = NSMutableAttributedString(string: snippet, attributes: [.foregroundColor:Colors.text.withAlphaComponent(Values.lowOpacity)]) timestampLabel.isHidden = true } @@ -290,47 +303,51 @@ final class ConversationCell : UITableViewCell { // MARK: - Updating - private func update(_ threadViewModel: ThreadViewModel) { - let thread: SessionThread = threadViewModel.thread + private func update(_ threadInfo: HomeViewModel.ThreadInfo) { + backgroundColor = (threadInfo.isPinned ? Colors.cellPinned : Colors.cellBackground) - backgroundColor = (thread.isPinned ? Colors.cellPinned : Colors.cellBackground) - - if GRDBStorage.shared.read({ db in try thread.contact.fetchOne(db)?.isBlocked }) == true { + if threadInfo.isBlocked { accentLineView.backgroundColor = Colors.destructive accentLineView.alpha = 1 } else { accentLineView.backgroundColor = Colors.accent - accentLineView.alpha = (threadViewModel.unreadCount > 0 ? 1 : 0.0001) // Setting the alpha to exactly 0 causes an issue on iOS 12 + accentLineView.alpha = (threadInfo.unreadCount > 0 ? 1 : 0.0001) // Setting the alpha to exactly 0 causes an issue on iOS 12 } - isPinnedIcon.isHidden = !thread.isPinned - unreadCountView.isHidden = (threadViewModel.unreadCount <= 0) - unreadCountLabel.text = (threadViewModel.unreadCount < 10000 ? "\(threadViewModel.unreadCount)" : "9999+") + isPinnedIcon.isHidden = !threadInfo.isPinned + unreadCountView.isHidden = (threadInfo.unreadCount <= 0) + unreadCountLabel.text = (threadInfo.unreadCount < 10000 ? "\(threadInfo.unreadCount)" : "9999+") unreadCountLabel.font = .boldSystemFont( - ofSize: (threadViewModel.unreadCount < 10000 ? Values.verySmallFontSize : 8) + ofSize: (threadInfo.unreadCount < 10000 ? Values.verySmallFontSize : 8) ) hasMentionView.isHidden = !( - (threadViewModel.unreadMentionCount > 0) && - (thread.variant == .closedGroup || thread.variant == .openGroup) + (threadInfo.unreadMentionCount > 0) && + (threadInfo.variant == .closedGroup || threadInfo.variant == .openGroup) ) - GRDBStorage.shared.read { db in profilePictureView.update(db, thread: thread) } - displayNameLabel.text = getDisplayName(for: thread) - timestampLabel.text = DateUtil.formatDate(forDisplay: threadViewModel.lastInteractionDate) - // TODO: Add this back + profilePictureView.update( + publicKey: threadInfo.id, + profile: threadInfo.profile, + additionalProfile: threadInfo.additionalProfile, + threadVariant: threadInfo.variant, + openGroupProfilePicture: threadInfo.openGroupProfilePictureData.map { UIImage(data: $0) }, + useFallbackPicture: (threadInfo.variant == .openGroup && threadInfo.openGroupProfilePictureData == nil) + ) + displayNameLabel.text = threadInfo.displayName + timestampLabel.text = DateUtil.formatDate(forDisplay: threadInfo.lastInteractionDate) // if SSKEnvironment.shared.typingIndicators.typingRecipientId(forThread: thread) != nil { // snippetLabel.text = "" // typingIndicatorView.isHidden = false // typingIndicatorView.startAnimation() // } else { - snippetLabel.attributedText = getSnippet(threadViewModel: threadViewModel) + snippetLabel.attributedText = getSnippet(threadInfo: threadInfo) typingIndicatorView.isHidden = true typingIndicatorView.stopAnimation() // } statusIndicatorView.backgroundColor = nil - switch (threadViewModel.lastInteraction?.variant, threadViewModel.lastInteractionState) { + switch (threadInfo.lastInteractionInfo?.variant, threadInfo.lastInteractionInfo?.state) { case (.standardOutgoing, .sending): statusIndicatorView.image = #imageLiteral(resourceName: "CircleDotDotDot").withRenderingMode(.alwaysTemplate) statusIndicatorView.tintColor = Colors.text @@ -351,15 +368,6 @@ final class ConversationCell : UITableViewCell { } } - private func getAuthorName(thread: SessionThread, interaction: Interaction) -> String? { - switch (thread.variant, interaction.variant) { - case (.contact, .standardIncoming): - return Profile.displayName(id: interaction.authorId, customFallback: "Anonymous") - - default: return nil - } - } - private func getDisplayNameForSearch(_ sessionID: String) -> String { if threadViewModel.threadRecord.isNoteToSelf() { return NSLocalizedString("NOTE_TO_SELF", comment: "") @@ -389,10 +397,10 @@ final class ConversationCell : UITableViewCell { return Profile.displayName(id: hexEncodedPublicKey, customFallback: middleTruncatedHexKey) } - private func getSnippet(threadViewModel: ThreadViewModel) -> NSMutableAttributedString { + private func getSnippet(threadInfo: HomeViewModel.ThreadInfo) -> NSMutableAttributedString { let result = NSMutableAttributedString() - if (threadViewModel.thread.notificationMode == .none) { + if (threadInfo.notificationMode == .none) { result.append(NSAttributedString( string: "\u{e067} ", attributes: [ @@ -401,7 +409,7 @@ final class ConversationCell : UITableViewCell { ] )) } - else if threadViewModel.thread.notificationMode == .mentionsOnly { + else if threadInfo.notificationMode == .mentionsOnly { let imageAttachment = NSTextAttachment() imageAttachment.image = UIImage(named: "NotifyMentions.png")?.asTintedImage(color: Colors.unimportant) imageAttachment.bounds = CGRect(x: 0, y: -2, width: Values.smallFontSize, height: Values.smallFontSize) @@ -417,15 +425,14 @@ final class ConversationCell : UITableViewCell { )) } - let font: UIFont = (threadViewModel.unreadCount > 0 ? + let font: UIFont = (threadInfo.unreadCount > 0 ? .boldSystemFont(ofSize: Values.smallFontSize) : .systemFont(ofSize: Values.smallFontSize) ) if - (threadViewModel.thread.variant == .closedGroup || threadViewModel.thread.variant == .openGroup), - let lastInteraction: Interaction = threadViewModel.lastInteraction, - let authorName: String = getAuthorName(thread: threadViewModel.thread, interaction: lastInteraction) + (threadInfo.variant == .closedGroup || threadInfo.variant == .openGroup), + let authorName: String = threadInfo.lastInteractionInfo?.authorName { result.append(NSAttributedString( string: "\(authorName): ", @@ -436,10 +443,12 @@ final class ConversationCell : UITableViewCell { )) } - if let rawSnippet: String = threadViewModel.lastInteractionText { - let snippet = MentionUtilities.highlightMentions(in: rawSnippet, threadId: threadViewModel.thread.id) + if let rawSnippet: String = threadInfo.lastInteractionInfo?.text { result.append(NSAttributedString( - string: snippet, + string: MentionUtilities.highlightMentions( + in: rawSnippet, + threadVariant: threadInfo.variant + ), attributes: [ .font: font, .foregroundColor: Colors.text diff --git a/Session/Utilities/AccountManager.swift b/Session/Utilities/AccountManager.swift index fc083d442..3f08a5da0 100644 --- a/Session/Utilities/AccountManager.swift +++ b/Session/Utilities/AccountManager.swift @@ -86,9 +86,13 @@ public class AccountManager: NSObject { private func syncPushTokens() -> Promise { Logger.info("") - guard let job: Job = Job(variant: .syncPushTokens, details: SyncPushTokensJob.Details(uploadOnlyIfStale: false)) else { - return Promise(error: GRDBStorageError.decodingFailed) - } + guard let job: Job = Job( + variant: .syncPushTokens, + details: SyncPushTokensJob.Details( + uploadOnlyIfStale: false + ) + ) + else { return Promise(error: GRDBStorageError.decodingFailed) } let (promise, seal) = Promise.pending() diff --git a/Session/Utilities/BackgroundPoller.swift b/Session/Utilities/BackgroundPoller.swift index 64e2e36ce..a0c593f39 100644 --- a/Session/Utilities/BackgroundPoller.swift +++ b/Session/Utilities/BackgroundPoller.swift @@ -47,75 +47,74 @@ public final class BackgroundPoller : NSObject { guard let snode = swarm.randomElement() else { throw SnodeAPI.Error.generic } return attempt(maxRetryCount: 4, recoveringOn: DispatchQueue.main) { - return SnodeAPI.getRawMessages(from: snode, associatedWith: publicKey).then(on: DispatchQueue.main) { rawResponse -> Promise in - let messages: [SnodeReceivedMessage] = SnodeAPI.parseRawMessagesResponse(rawResponse, from: snode, associatedWith: publicKey) - - guard !messages.isEmpty else { return Promise.value(()) } - - var jobsToRun: [Job] = [] - - GRDBStorage.shared.write { db in - var threadMessages: [String: [MessageReceiveJob.Details.MessageInfo]] = [:] - // TODO: Test this updated logic - messages.forEach { message in - guard let envelope = SNProtoEnvelope.from(message) else { return } + return SnodeAPI.getMessages(from: snode, associatedWith: publicKey) + .then(on: DispatchQueue.main) { messages -> Promise in + guard !messages.isEmpty else { return Promise.value(()) } + + var jobsToRun: [Job] = [] + + GRDBStorage.shared.write { db in + var threadMessages: [String: [MessageReceiveJob.Details.MessageInfo]] = [:] - // Extract the threadId and add that to the messageReceive job for - // multi-threading and garbage collection purposes - let threadId: String? = MessageReceiver.extractSenderPublicKey(db, from: envelope) + messages.forEach { message in + guard let envelope = SNProtoEnvelope.from(message) else { return } + + // Extract the threadId and add that to the messageReceive job for + // multi-threading and garbage collection purposes + let threadId: String? = MessageReceiver.extractSenderPublicKey(db, from: envelope) + + do { + threadMessages[threadId ?? ""] = (threadMessages[threadId ?? ""] ?? []) + .appending( + MessageReceiveJob.Details.MessageInfo( + data: try envelope.serializedData(), + serverHash: message.info.hash + ) + ) + + // Persist the received message after the MessageReceiveJob is created + _ = try message.info.saved(db) + } + catch { + SNLog("Failed to deserialize envelope due to error: \(error).") + } + } - do { - threadMessages[threadId ?? ""] = (threadMessages[threadId ?? ""] ?? []) - .appending( - MessageReceiveJob.Details.MessageInfo( - data: try envelope.serializedData(), - serverHash: message.info.hash + threadMessages + .forEach { threadId, threadMessages in + let maybeJob: Job? = Job( + variant: .messageReceive, + behaviour: .runOnce, + threadId: threadId, + details: MessageReceiveJob.Details( + messages: threadMessages, + isBackgroundPoll: false ) ) - - // Persist the received message after the MessageReceiveJob is created - _ = try message.info.saved(db) - } - catch { - SNLog("Failed to deserialize envelope due to error: \(error).") - } + + guard let job: Job = maybeJob else { return } + + JobRunner.add(db, job: job) + jobsToRun.append(job) + } } - threadMessages - .forEach { threadId, threadMessages in - let maybeJob: Job? = Job( - variant: .messageReceive, - behaviour: .runOnce, - threadId: threadId, - details: MessageReceiveJob.Details( - messages: threadMessages, - isBackgroundPoll: false - ) - ) - - guard let job: Job = maybeJob else { return } - - JobRunner.add(db, job: job) - jobsToRun.append(job) - } - } - - let promises = jobsToRun.compactMap { job -> Promise? in - let (promise, seal) = Promise.pending() - - // Note: In the background we just want jobs to fail silently - MessageReceiveJob.run( - job, - success: { _, _ in seal.fulfill(()) }, - failure: { _, _, _ in seal.fulfill(()) }, - deferred: { _ in seal.fulfill(()) } - ) + let promises = jobsToRun.compactMap { job -> Promise? in + let (promise, seal) = Promise.pending() + + // Note: In the background we just want jobs to fail silently + MessageReceiveJob.run( + job, + success: { _, _ in seal.fulfill(()) }, + failure: { _, _, _ in seal.fulfill(()) }, + deferred: { _ in seal.fulfill(()) } + ) - return promise - } + return promise + } - return when(fulfilled: promises) - } + return when(fulfilled: promises) + } } } } diff --git a/Session/Utilities/MentionUtilities.swift b/Session/Utilities/MentionUtilities.swift index 7babfb128..038acf495 100644 --- a/Session/Utilities/MentionUtilities.swift +++ b/Session/Utilities/MentionUtilities.swift @@ -1,42 +1,69 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -@objc(LKMentionUtilities) -public final class MentionUtilities : NSObject { +import Foundation +import GRDB +import SessionUIKit +import SessionMessagingKit - override private init() { } - - @objc public static func highlightMentions(in string: String, threadId: String) -> String { +public enum MentionUtilities { + public static func highlightMentions(in string: String, threadVariant: SessionThread.Variant) -> String { return highlightMentions( in: string, + threadVariant: threadVariant, isOutgoingMessage: false, - threadId: threadId, attributes: [:] ).string // isOutgoingMessage and attributes are irrelevant } - @objc public static func highlightMentions(in string: String, isOutgoingMessage: Bool, threadId: String, attributes: [NSAttributedString.Key:Any]) -> NSAttributedString { - let openGroupV2 = Storage.shared.getV2OpenGroup(for: threadId) - var string = string - let regex = try! NSRegularExpression(pattern: "@[0-9a-fA-F]{66}", options: []) - var mentions: [(range: NSRange, publicKey: String)] = [] - var outerMatch = regex.firstMatch(in: string, options: .withoutAnchoringBounds, range: NSRange(location: 0, length: string.utf16.count)) - while let match = outerMatch { - let publicKey = String((string as NSString).substring(with: match.range).dropFirst()) // Drop the @ - let matchEnd: Int - if let displayName = Profile.displayNameNoFallback(id: publicKey, context: (openGroupV2 != nil ? .openGroup : .regular)) { - string = (string as NSString).replacingCharacters(in: match.range, with: "@\(displayName)") - mentions.append((range: NSRange(location: match.range.location, length: displayName.utf16.count + 1), publicKey: publicKey)) // + 1 to include the @ - matchEnd = match.range.location + displayName.utf16.count - } else { - matchEnd = match.range.location + match.range.length - } - outerMatch = regex.firstMatch(in: string, options: .withoutAnchoringBounds, range: NSRange(location: matchEnd, length: string.utf16.count - matchEnd)) + public static func highlightMentions( + in string: String, + threadVariant: SessionThread.Variant, + isOutgoingMessage: Bool, + attributes: [NSAttributedString.Key: Any] + ) -> NSAttributedString { + guard + let regex: NSRegularExpression = try? NSRegularExpression(pattern: "@[0-9a-fA-F]{66}", options: []) + else { + return NSAttributedString(string: string) } - let result = NSMutableAttributedString(string: string, attributes: attributes) + + var string = string + var lastMatchEnd: Int = 0 + var mentions: [(range: NSRange, publicKey: String)] = [] + let context: Profile.Context = (threadVariant == .openGroup ? .openGroup : .regular) + + while let match: NSTextCheckingResult = regex.firstMatch( + in: string, + options: .withoutAnchoringBounds, + range: NSRange(location: lastMatchEnd, length: string.utf16.count - lastMatchEnd) + ) { + guard let range: Range = Range(match.range, in: string) else { break } + + let publicKey: String = String(string[range].dropFirst()) // Drop the @ + + guard let displayName: String = Profile.displayNameNoFallback(id: publicKey, context: context) else { + lastMatchEnd = (match.range.location + match.range.length) + continue + } + + string = string.replacingCharacters(in: range, with: "@\(displayName)") + lastMatchEnd = (match.range.location + displayName.utf16.count) + + mentions.append(( + // + 1 to include the @ + range: NSRange(location: match.range.location, length: displayName.utf16.count + 1), + publicKey: publicKey + )) + } + + let result: NSMutableAttributedString = NSMutableAttributedString(string: string, attributes: attributes) mentions.forEach { mention in + // FIXME: This might break when swapping between themes let color = isOutgoingMessage ? (isLightMode ? .white : .black) : Colors.accent result.addAttribute(.foregroundColor, value: color, range: mention.range) result.addAttribute(.font, value: UIFont.boldSystemFont(ofSize: Values.smallFontSize), range: mention.range) } + return result } } diff --git a/Session/Utilities/MockDataGenerator.swift b/Session/Utilities/MockDataGenerator.swift index a3e98db18..0ce9a9ba6 100644 --- a/Session/Utilities/MockDataGenerator.swift +++ b/Session/Utilities/MockDataGenerator.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB import Sodium import Curve25519Kit import SessionMessagingKit @@ -72,15 +73,9 @@ enum MockDataGenerator { static var printProgress: Bool = true static var hasStartedGenerationThisRun: Bool = false - static func generateMockData() { + static func generateMockData(_ db: Database) { // Don't re-generate the mock data if it already exists - var existingMockDataThread: TSContactThread? - - Storage.read { transaction in - existingMockDataThread = TSContactThread.getWithContactSessionID("MockDatabaseThread", transaction: transaction) - } - - guard !hasStartedGenerationThisRun && existingMockDataThread == nil else { + guard !hasStartedGenerationThisRun && !(try! SessionThread.exists(db, id: "MockDatabaseThread")) else { hasStartedGenerationThisRun = true return } @@ -100,7 +95,7 @@ enum MockDataGenerator { let stringContent: [String] = "abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ 0123456789 ".map { String($0) } let wordContent: [String] = ["alias", "consequatur", "aut", "perferendis", "sit", "voluptatem", "accusantium", "doloremque", "aperiam", "eaque", "ipsa", "quae", "ab", "illo", "inventore", "veritatis", "et", "quasi", "architecto", "beatae", "vitae", "dicta", "sunt", "explicabo", "aspernatur", "aut", "odit", "aut", "fugit", "sed", "quia", "consequuntur", "magni", "dolores", "eos", "qui", "ratione", "voluptatem", "sequi", "nesciunt", "neque", "dolorem", "ipsum", "quia", "dolor", "sit", "amet", "consectetur", "adipisci", "velit", "sed", "quia", "non", "numquam", "eius", "modi", "tempora", "incidunt", "ut", "labore", "et", "dolore", "magnam", "aliquam", "quaerat", "voluptatem", "ut", "enim", "ad", "minima", "veniam", "quis", "nostrum", "exercitationem", "ullam", "corporis", "nemo", "enim", "ipsam", "voluptatem", "quia", "voluptas", "sit", "suscipit", "laboriosam", "nisi", "ut", "aliquid", "ex", "ea", "commodi", "consequatur", "quis", "autem", "vel", "eum", "iure", "reprehenderit", "qui", "in", "ea", "voluptate", "velit", "esse", "quam", "nihil", "molestiae", "et", "iusto", "odio", "dignissimos", "ducimus", "qui", "blanditiis", "praesentium", "laudantium", "totam", "rem", "voluptatum", "deleniti", "atque", "corrupti", "quos", "dolores", "et", "quas", "molestias", "excepturi", "sint", "occaecati", "cupiditate", "non", "provident", "sed", "ut", "perspiciatis", "unde", "omnis", "iste", "natus", "error", "similique", "sunt", "in", "culpa", "qui", "officia", "deserunt", "mollitia", "animi", "id", "est", "laborum", "et", "dolorum", "fuga", "et", "harum", "quidem", "rerum", "facilis", "est", "et", "expedita", "distinctio", "nam", "libero", "tempore", "cum", "soluta", "nobis", "est", "eligendi", "optio", "cumque", "nihil", "impedit", "quo", "porro", "quisquam", "est", "qui", "minus", "id", "quod", "maxime", "placeat", "facere", "possimus", "omnis", "voluptas", "assumenda", "est", "omnis", "dolor", "repellendus", "temporibus", "autem", "quibusdam", "et", "aut", "consequatur", "vel", "illum", "qui", "dolorem", "eum", "fugiat", "quo", "voluptas", "nulla", "pariatur", "at", "vero", "eos", "et", "accusamus", "officiis", "debitis", "aut", "rerum", "necessitatibus", "saepe", "eveniet", "ut", "et", "voluptates", "repudiandae", "sint", "et", "molestiae", "non", "recusandae", "itaque", "earum", "rerum", "hic", "tenetur", "a", "sapiente", "delectus", "ut", "aut", "reiciendis", "voluptatibus", "maiores", "doloribus", "asperiores", "repellat"] let timestampNow: TimeInterval = Date().timeIntervalSince1970 - let userSessionId: String = getUserHexEncodedPublicKey() + let userSessionId: String = getUserHexEncodedPublicKey(db) let logProgress: (String, String) -> () = { title, event in guard printProgress else { return } @@ -113,9 +108,7 @@ enum MockDataGenerator { logProgress("", "Start") // First create the thread used to indicate that the mock data has been generated - Storage.write { transaction in - _ = TSContactThread.getOrCreateThread(withContactSessionID: "MockDatabaseThread", transaction: transaction) - } + _ = try? SessionThread.fetchOrCreate(db, id: "MockDatabaseThread", variant: .contact) // MARK: - -- DM Thread @@ -129,62 +122,64 @@ enum MockDataGenerator { (0.. SQLRequest { + static func stateInfo(authorId: String, state: State? = nil) -> SQLRequest { let attachment: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() let quote: TypedTableAlias = TypedTableAlias() @@ -400,39 +403,80 @@ public extension Attachment { return """ SELECT DISTINCT \(attachment[.id]) AS attachmentId, - \(interaction[.id]) AS interactionId + \(interaction[.id]) AS interactionId, + \(attachment[.state]) AS state FROM \(Attachment.self) JOIN \(Interaction.self) ON - \(interaction[.authorId]) = \(SQL(sql: ":authorId", arguments: StatementArguments(["authorId": authorId]))) AND ( + \(SQL("\(interaction[.authorId]) = \(authorId)")) AND ( \(interaction[.id]) = \(quote[.interactionId]) OR \(interaction[.id]) = \(interactionAttachment[.interactionId]) OR - \(interaction[.linkPreviewUrl]) = \(linkPreview[.url]) + ( + \(interaction[.linkPreviewUrl]) = \(linkPreview[.url]) AND + /* Note: This equation MUST match the `linkPreviewFilterLiteral` logic in Interaction.swift */ + (ROUND((\(interaction[.timestampMs]) / 1000 / 100000) - 0.5) * 100000) = \(linkPreview[.timestamp]) + ) ) LEFT JOIN \(Quote.self) ON \(quote[.attachmentId]) = \(attachment[.id]) LEFT JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id]) LEFT JOIN \(LinkPreview.self) ON \(linkPreview[.attachmentId]) = \(attachment[.id]) AND - \(linkPreview[.variant]) = \(SQL( - sql: ":variant", - arguments: StatementArguments(["variant": LinkPreview.Variant.standard]) - )) + \(SQL("\(linkPreview[.variant]) = \(LinkPreview.Variant.standard)")) WHERE - \(attachment[.variant]) = \(SQL( - sql: ":attachmentVariant", - arguments: StatementArguments(["attachmentVariant": Attachment.Variant.standard]) - )) AND - \(attachment[.state]) = \(SQL( - sql: ":state", - arguments: StatementArguments(["state": Attachment.State.pending]) - )) + ( + \(SQL("\(state) IS NULL")) OR + \(SQL("\(attachment[.state]) = \(state)")) + ) ORDER BY interactionId DESC """ } + + static func stateInfo(interactionId: Int64, state: State? = nil) -> SQLRequest { + let attachment: TypedTableAlias = TypedTableAlias() + let interaction: TypedTableAlias = TypedTableAlias() + let quote: TypedTableAlias = TypedTableAlias() + let interactionAttachment: TypedTableAlias = TypedTableAlias() + let linkPreview: TypedTableAlias = TypedTableAlias() + + // Note: In GRDB all joins need to run via their "association" system which doesn't support the type + // of query we have below (a required join based on one of 3 optional joins) so we have to construct + // the query manually + return """ + SELECT DISTINCT + \(attachment[.id]) AS attachmentId, + \(interaction[.id]) AS interactionId, + \(attachment[.state]) AS state + + FROM \(Attachment.self) + + JOIN \(Interaction.self) ON + \(SQL("\(interaction[.id]) = \(interactionId)")) AND ( + \(interaction[.id]) = \(quote[.interactionId]) OR + \(interaction[.id]) = \(interactionAttachment[.interactionId]) OR + ( + \(interaction[.linkPreviewUrl]) = \(linkPreview[.url]) AND + /* Note: This equation MUST match the `linkPreviewFilterLiteral` logic in Interaction.swift */ + (ROUND((\(interaction[.timestampMs]) / 1000 / 100000) - 0.5) * 100000) = \(linkPreview[.timestamp]) + ) + ) + + LEFT JOIN \(Quote.self) ON \(quote[.attachmentId]) = \(attachment[.id]) + LEFT JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id]) + LEFT JOIN \(LinkPreview.self) ON + \(linkPreview[.attachmentId]) = \(attachment[.id]) AND + \(SQL("\(linkPreview[.variant]) = \(LinkPreview.Variant.standard)")) + + WHERE + ( + \(SQL("\(state) IS NULL")) OR + \(SQL("\(attachment[.state]) = \(state)")) + ) + """ + } } // MARK: - Convenience - Static @@ -686,3 +730,140 @@ extension Attachment { return true } } + +// MARK: - Upload + +extension Attachment { + internal enum UploadError: LocalizedError { + case invalidStartState + case noAttachment + case notUploaded + case encryptionFailed + + public var errorDescription: String? { + switch self { + case .invalidStartState: return "Cannot upload an attachment in this state." + case .noAttachment: return "No such attachment." + case .notUploaded: return "Attachment not uploaded." + case .encryptionFailed: return "Couldn't encrypt file." + } + } + } + + internal func upload( + using upload: (Data) -> Promise, + encrypt: Bool, + success: (() -> Void)?, + failure: ((Error) -> Void)? + ) { + guard state != .uploaded else { + SNLog("Attempted to upload an already uploaded/downloaded attachment.") + failure?(UploadError.invalidStartState) + return + } + + // Get the attachment + guard var data = try? readDataFromFile() else { + SNLog("Couldn't read attachment from disk.") + failure?(UploadError.noAttachment) + return + } + + // If the attachment is a downloaded attachment, check if it came from the server + // and if so just succeed immediately (no use re-uploading an attachment that is + // already present on the server) - or if we want it to be encrypted and it's not + // then encrypt it + // + // Note: The most common cases for this will be for LinkPreviews or Quotes + guard + state != .downloaded || + serverId == nil || + downloadUrl == nil || + !encrypt || + encryptionKey == nil || + digest == nil + else { + // Save the final upload info + let uploadedAttachment: Attachment? = GRDBStorage.shared.write { db in + try self + .with(state: .uploaded) + .saved(db) + } + + guard uploadedAttachment != nil else { + SNLog("Couldn't update attachmentUpload job.") + failure?(GRDBStorageError.failedToSave) + return + } + + success?() + return + } + + var processedAttachment: Attachment = self + + // Encrypt the attachment if needed + if encrypt { + var encryptionKey: NSData = NSData() + var digest: NSData = NSData() + + guard let ciphertext = Cryptography.encryptAttachmentData(data, shouldPad: true, outKey: &encryptionKey, outDigest: &digest) else { + SNLog("Couldn't encrypt attachment.") + failure?(UploadError.encryptionFailed) + return + } + + processedAttachment = processedAttachment.with( + encryptionKey: encryptionKey as Data, + digest: digest as Data + ) + data = ciphertext + } + + // Check the file size + SNLog("File size: \(data.count) bytes.") + if Double(data.count) > Double(FileServerAPIV2.maxFileSize) / FileServerAPIV2.fileSizeORMultiplier { + failure?(FileServerAPIV2.Error.maxFileSizeExceeded) + return + } + + // Update the attachment to the 'uploading' state + let updatedAttachment: Attachment? = GRDBStorage.shared.write { db in + try processedAttachment + .with(state: .uploading) + .saved(db) + } + + guard updatedAttachment != nil else { + SNLog("Couldn't update attachmentUpload job.") + failure?(GRDBStorageError.failedToSave) + return + } + + // Perform the upload + upload(data) + .done(on: DispatchQueue.global(qos: .userInitiated)) { fileId in + // Save the final upload info + let uploadedAttachment: Attachment? = GRDBStorage.shared.write { db in + try updatedAttachment? + .with( + serverId: "\(fileId)", + state: .uploaded, + downloadUrl: "\(FileServerAPIV2.server)/files/\(fileId)" + ) + .saved(db) + } + + guard uploadedAttachment != nil else { + SNLog("Couldn't update attachmentUpload job.") + failure?(GRDBStorageError.failedToSave) + return + } + + success?() + } + .catch { error in + failure?(error) + } + } +} diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index ddcd59711..196e5d91e 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -377,19 +377,34 @@ public extension Profile { return Profile.displayName(for: context, id: id, name: name, nickname: nickname) } - static func displayName(for threadVariant: SessionThread.Variant, id: String, name: String?, nickname: String?) -> String { + static func displayName( + for threadVariant: SessionThread.Variant, + id: String, + name: String?, + nickname: String?, + customFallback: String? = nil + ) -> String { return Profile.displayName( for: (threadVariant == .openGroup ? .openGroup : .regular), id: id, name: name, - nickname: nickname + nickname: nickname, + customFallback: customFallback ) } - static func displayName(for context: Context, id: String, name: String?, nickname: String?) -> String { + static func displayName( + for context: Context, + id: String, + name: String?, + nickname: String?, + customFallback: String? = nil + ) -> String { if let nickname: String = nickname { return nickname } - guard let name: String = name else { return Profile.truncated(id: id, truncating: .start) } + guard let name: String = name else { + return (customFallback ?? Profile.truncated(id: id, truncating: .start)) + } switch context { case .regular: return name diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index 8896bef79..fab84db2b 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -194,6 +194,27 @@ public extension SessionThread { // MARK: - Convenience public extension SessionThread { + static func displayName(userPublicKey: String) -> SQLSpecificExpressible { + let contactAlias: TypedTableAlias = TypedTableAlias() + + return ( + ( + ( + SessionThread.Columns.variant == SessionThread.Variant.closedGroup && + ClosedGroup.Columns.name + ) || ( + SessionThread.Columns.variant == SessionThread.Variant.openGroup && + OpenGroup.Columns.name + ) || ( + isNoteToSelf(userPublicKey: userPublicKey) + ) || ( + Profile.Columns.nickname || + Profile.Columns.name + //customFallback: Profile.truncated(id: thread.id, truncating: .middle) + ) + ) + ) + } /// This method can be used to create a query based on whether a thread is the note to self thread static func isNoteToSelf(userPublicKey: String) -> SQLSpecificExpressible { @@ -208,18 +229,19 @@ public extension SessionThread { /// **Note:** In order to use this filter you **MUST** have a `joining(required/optional:)` to the /// `SessionThread.contact` association or it won't work static func isMessageRequest(userPublicKey: String) -> SQLSpecificExpressible { + let threadAlias: TypedTableAlias = TypedTableAlias() let contactAlias: TypedTableAlias = TypedTableAlias() - return ( - SessionThread.Columns.shouldBeVisible == true && - SessionThread.Columns.variant == SessionThread.Variant.contact && - SessionThread.Columns.id != userPublicKey && // Note to self - ( - // Note: Doing a '!= true' check doesn't work properly so we need - // to explicitly do this - contactAlias[.isApproved] == nil || - contactAlias[.isApproved] == false - ) + return SQL( + """ + \(threadAlias[.shouldBeVisible]) = true AND + \(SQL("\(threadAlias[.variant]) = \(SessionThread.Variant.contact)")) AND + \(SQL("\(threadAlias[.id]) != \(userPublicKey)")) AND ( + /* Note: A '!= true' check doesn't work properly so we need to be explicit */ + \(contactAlias[.isApproved]) IS NULL OR + \(contactAlias[.isApproved]) = false + ) + """ ) } diff --git a/SessionMessagingKit/Database/OWSPrimaryStorage.m b/SessionMessagingKit/Database/OWSPrimaryStorage.m index 338058af9..d7b2b507d 100644 --- a/SessionMessagingKit/Database/OWSPrimaryStorage.m +++ b/SessionMessagingKit/Database/OWSPrimaryStorage.m @@ -168,11 +168,9 @@ void VerifyRegistrationsForPrimaryStorage(OWSStorage *storage) [TSDatabaseView asyncRegisterLegacyThreadInteractionsDatabaseView:self]; [TSDatabaseView asyncRegisterThreadInteractionsDatabaseView:self]; [TSDatabaseView asyncRegisterThreadDatabaseView:self]; - [TSDatabaseView asyncRegisterUnreadDatabaseView:self]; [self asyncRegisterExtension:[TSDatabaseSecondaryIndexes registerTimeStampIndex] withName:[TSDatabaseSecondaryIndexes registerTimeStampIndexExtensionName]]; - [TSDatabaseView asyncRegisterUnseenDatabaseView:self]; [TSDatabaseView asyncRegisterUnreadMentionDatabaseView:self]; [TSDatabaseView asyncRegisterThreadOutgoingMessagesDatabaseView:self]; diff --git a/SessionMessagingKit/Jobs/JobDelegate.swift b/SessionMessagingKit/Jobs/JobDelegate.swift deleted file mode 100644 index b45a5bf28..000000000 --- a/SessionMessagingKit/Jobs/JobDelegate.swift +++ /dev/null @@ -1,8 +0,0 @@ - -@objc(SNJobDelegate) -public protocol JobDelegate { - - func handleJobSucceeded(_ job: Job) - func handleJobFailed(_ job: Job, with error: Error) - func handleJobFailedPermanently(_ job: Job, with error: Error) -} diff --git a/SessionMessagingKit/Jobs/JobQueue.swift b/SessionMessagingKit/Jobs/JobQueue.swift deleted file mode 100644 index 159641522..000000000 --- a/SessionMessagingKit/Jobs/JobQueue.swift +++ /dev/null @@ -1,119 +0,0 @@ -import SessionUtilitiesKit - -@objc(SNJobQueue) -public final class JobQueue : NSObject, JobDelegate { - - private static var jobIDs: [UInt64:UInt64] = [:] - - internal static var currentlyExecutingJobs: Set = [] - - private let internalQueue: DispatchQueue = DispatchQueue(label:"executingJobQueue") - - @objc public static let shared = JobQueue() - - @objc public func add(_ job: Job, using transaction: Any) { - let transaction = transaction as! YapDatabaseReadWriteTransaction - addWithoutExecuting(job, using: transaction) - transaction.addCompletionQueue(Threading.jobQueue) { - job.execute() - } - } - - @objc public func addWithoutExecuting(_ job: Job, using transaction: Any) { - let timestamp = NSDate.millisecondTimestamp() - let count = JobQueue.jobIDs[timestamp] ?? 0 - // When adding multiple jobs in rapid succession, timestamps might not be good enough as a unique ID. To - // deal with this we keep track of the number of jobs with a given timestamp and that to the end of the - // timestamp to make it a unique ID. We can't use a random number because we do still want to keep track - // of the order in which the jobs were added. - let id = String(timestamp) + String(count) - job.id = id - JobQueue.jobIDs[timestamp] = count + 1 - SNMessagingKitConfiguration.shared.storage.persist(job, using: transaction) - job.delegate = self - } - - @objc public func resumePendingJobs() { - let allJobTypes: [Job.Type] = [ AttachmentDownloadJob.self, AttachmentUploadJob.self, MessageReceiveJob.self, MessageSendJob.self, NotifyPNServerJob.self ] - allJobTypes.forEach { type in - let allPendingJobs = SNMessagingKitConfiguration.shared.storage.getAllPendingJobs(of: type) - allPendingJobs.sorted(by: { $0.id! < $1.id! }).forEach { job in // Retry the oldest jobs first - guard !JobQueue.currentlyExecutingJobs.contains(job.id!) else { - return SNLog("Not resuming already executing job.") - } - SNLog("Resuming pending job of type: \(type).") - job.delegate = self - job.execute() - } - } - } - - public func handleJobSucceeded(_ job: Job) { - given(job.id) { removeExecutingJob($0) } - SNMessagingKitConfiguration.shared.storage.write(with: { transaction in - SNMessagingKitConfiguration.shared.storage.markJobAsSucceeded(job, using: transaction) - }, completion: { - // Do nothing - }) - } - - public func handleJobFailed(_ job: Job, with error: Error) { - given(job.id) { removeExecutingJob($0) } - job.failureCount += 1 - let storage = SNMessagingKitConfiguration.shared.storage - guard !storage.isJobCanceled(job) else { return SNLog("\(type(of: job)) canceled.") } - storage.write(with: { transaction in - storage.persist(job, using: transaction) - }, completion: { // Intentionally capture self - if job.failureCount == type(of: job).maxFailureCount { - storage.write(with: { transaction in - storage.markJobAsFailed(job, using: transaction) - }, completion: { - // Do nothing - }) - } else { - let retryInterval = self.getRetryInterval(for: job) - SNLog("\(type(of: job)) failed; scheduling retry (failure count is \(job.failureCount)).") - Timer.scheduledTimer(timeInterval: retryInterval, target: self, selector: #selector(self.retry(_:)), userInfo: job, repeats: false) - } - }) - } - - public func handleJobFailedPermanently(_ job: Job, with error: Error) { - given(job.id) { removeExecutingJob($0) } - job.failureCount += 1 - let storage = SNMessagingKitConfiguration.shared.storage - storage.write(with: { transaction in - storage.persist(job, using: transaction) - }, completion: { // Intentionally capture self - storage.write(with: { transaction in - storage.markJobAsFailed(job, using: transaction) - }, completion: { - // Do nothing - }) - }) - } - - private func removeExecutingJob(_ jobID: String) { - let _ = internalQueue.sync { JobQueue.currentlyExecutingJobs.remove(jobID) } - } - - private func getRetryInterval(for job: Job) -> TimeInterval { - // Arbitrary backoff factor... - // try 1 delay: 0.5s - // try 2 delay: 1s - // ... - // try 5 delay: 16s - // ... - // try 11 delay: 512s - let maxBackoff: Double = 10 * 60 // 10 minutes - return 0.25 * min(maxBackoff, pow(2, Double(job.failureCount))) - } - - @objc private func retry(_ timer: Timer) { - guard let job = timer.userInfo as? Job else { return } - SNLog("Retrying \(type(of: job)).") - job.delegate = self - job.execute() - } -} diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift index b15cecf72..a2b93f08c 100644 --- a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift @@ -35,23 +35,81 @@ public enum MessageSendJob: JobExecutor { return } - var shouldFailJob: Bool = false - - GRDBStorage.shared.read { db in - // Fetch all associated attachments - let attachmentCount: Int = try interaction.attachments - .filter(Attachment.Columns.state == Attachment.State.pending) - .fetchCount(db) + // Check if there are any attachments associated to this message, and if so + // upload them now + // + // Note: Normal attachments should be sent in a non-durable way but any + // attachments for LinkPreviews and Quotes will be processed through this mechanism + let attachmentState: (shouldFail: Bool, shouldDefer: Bool)? = GRDBStorage.shared.write { db in + let allAttachmentStateInfo: [Attachment.StateInfo] = try Attachment + .stateInfo(interactionId: interactionId) + .fetchAll(db) - shouldFailJob = (attachmentCount > 0) + // If there were failed attachments then this job should fail (can't send a + // message which has associated attachments if the attachments fail to upload) + guard !allAttachmentStateInfo.contains(where: { $0.state == .failed }) else { + return (true, false) + } + + // Create jobs for any pending attachment jobs and insert them into the + // queue before the current job (this will mean the current job will re-run + // after these inserted jobs complete) + // + // Note: If there are any 'downloaded' attachments then they also need to be + // uploaded (as a 'downloaded' attachment will be on the current users device + // but not on the message recipients device - both LinkPreview and Quote can + // have this case) + try allAttachmentStateInfo + .filter { $0.state == .pending || $0.state == .downloaded } + .compactMap { stateInfo in + JobRunner + .insert( + db, + job: Job( + variant: .attachmentUpload, + behaviour: .runOnce, + threadId: job.threadId, + interactionId: interactionId, + details: AttachmentUploadJob.Details( + attachmentId: stateInfo.attachmentId + ) + ), + before: job + )? + .id + } + .forEach { otherJobId in + // Create the dependency between the jobs + try JobDependencies( + jobId: jobId, + dependantId: otherJobId + ) + .insert(db) + } + + // If there were pending or uploading attachments then stop here (we want to + // upload them first and then re-run this send job - the 'JobRunner.insert' + // method will take care of this) + return ( + false, + allAttachmentStateInfo.contains(where: { $0.state != .uploaded }) + ) } - // Cannot send messages with pending attachments (the app doesn't currently - // support deferred attachment uploads) - guard !shouldFailJob else { + // Don't send messages with failed attachment uploads + // + // Note: If we have gotten to this point then any dependant attachment upload + // jobs will have permanently failed so this message send should also do so + guard attachmentState?.shouldFail == false else { failure(job, Attachment.UploadError.notUploaded, true) return } + + // Defer the job if we found incomplete uploads + guard attachmentState?.shouldDefer == false else { + deferred(job) + return + } } // Perform the actual message sending @@ -60,7 +118,7 @@ public enum MessageSendJob: JobExecutor { db, message: details.message, to: details.destination, - interactionId: details.interactionId + interactionId: job.interactionId ) } .done2 { _ in success(job, false) } @@ -79,7 +137,7 @@ public enum MessageSendJob: JobExecutor { if details.message is VisibleMessage { guard - let interactionId: Int64 = details.interactionId, + let interactionId: Int64 = job.interactionId, GRDBStorage.shared.read({ db in try Interaction.exists(db, id: interactionId) }) == true else { // The message has been deleted so permanently fail the job @@ -121,18 +179,15 @@ extension MessageSendJob { case message } - public let interactionId: Int64? public let destination: Message.Destination public let message: Message // MARK: - Initialization public init( - interactionId: Int64? = nil, destination: Message.Destination, message: Message ) { - self.interactionId = interactionId self.destination = destination self.message = message } @@ -157,7 +212,6 @@ extension MessageSendJob { } self = Details( - interactionId: try? container.decode(Int64.self, forKey: .interactionId), destination: try container.decode(Message.Destination.self, forKey: .destination), message: message ) @@ -176,7 +230,6 @@ extension MessageSendJob { throw GRDBStorageError.objectNotFound } - try container.encodeIfPresent(interactionId, forKey: .interactionId) try container.encode(destination, forKey: .destination) try container.encode(messageTypeString, forKey: .messageType) try container.encode(message, forKey: .message) diff --git a/SessionMessagingKit/Messages/Signal/TSIncomingMessage.m b/SessionMessagingKit/Messages/Signal/TSIncomingMessage.m index a04fe2067..8dfbc6ce3 100644 --- a/SessionMessagingKit/Messages/Signal/TSIncomingMessage.m +++ b/SessionMessagingKit/Messages/Signal/TSIncomingMessage.m @@ -4,8 +4,6 @@ #import "TSIncomingMessage.h" #import "NSNotificationCenter+OWS.h" -#import "OWSDisappearingMessagesConfiguration.h" -#import "OWSDisappearingMessagesJob.h" #import "TSAttachmentPointer.h" #import "TSContactThread.h" #import "TSDatabaseSecondaryIndexes.h" diff --git a/SessionMessagingKit/Messages/Signal/TSMessage.m b/SessionMessagingKit/Messages/Signal/TSMessage.m index e390a4257..575a3ffdb 100644 --- a/SessionMessagingKit/Messages/Signal/TSMessage.m +++ b/SessionMessagingKit/Messages/Signal/TSMessage.m @@ -5,7 +5,6 @@ #import "TSMessage.h" #import "AppContext.h" #import "MIMETypeUtil.h" -#import "OWSDisappearingMessagesConfiguration.h" #import "TSAttachment.h" #import "TSAttachmentStream.h" #import "TSQuotedMessage.h" diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift index 44bbbaa9b..8a998f620 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift @@ -204,32 +204,37 @@ public final class VisibleMessage: Message { public extension VisibleMessage { static func from(_ db: Database, interaction: Interaction) -> VisibleMessage { - let result = VisibleMessage() - result.sentTimestamp = UInt64(interaction.timestampMs) - result.recipient = (try? interaction.recipientStates.fetchOne(db))?.recipientId + let linkPreview: SessionMessagingKit.LinkPreview? = try? interaction.linkPreview.fetchOne(db) - if let thread: SessionThread = try? interaction.thread.fetchOne(db), thread.variant == .closedGroup { - result.groupPublicKey = thread.id - } - - result.text = interaction.body - result.attachmentIDs = ((try? interaction.attachments.fetchAll(db)) ?? []).map { $0.id } - result.quote = (try? interaction.quote.fetchOne(db)) - .map { VisibleMessage.Quote.from(db, quote: $0) } - - if let linkPreview: SessionMessagingKit.LinkPreview = try? interaction.linkPreview.fetchOne(db) { - switch linkPreview.variant { - case .standard: - result.linkPreview = VisibleMessage.LinkPreview.from(db, linkPreview: linkPreview) + return VisibleMessage( + sentTimestamp: UInt64(interaction.timestampMs), + recipient: (try? interaction.recipientStates.fetchOne(db))?.recipientId, + groupPublicKey: try? interaction.thread + .filter(SessionThread.Columns.variant == SessionThread.Variant.closedGroup) + .select(.id) + .asRequest(of: String.self) + .fetchOne(db), + syncTarget: nil, + text: interaction.body, + attachmentIds: ((try? interaction.attachments.fetchAll(db)) ?? []) + .map { $0.id }, + quote: (try? interaction.quote.fetchOne(db)) + .map { VisibleMessage.Quote.from(db, quote: $0) }, + linkPreview: linkPreview + .map { linkPreview in + guard linkPreview.variant == .standard else { return nil } - case .openGroupInvitation: - result.openGroupInvitation = VisibleMessage.OpenGroupInvitation.from( - db, - linkPreview: linkPreview - ) + return VisibleMessage.LinkPreview.from(db, linkPreview: linkPreview) + }, + profile: nil, // TODO: Confirm this + openGroupInvitation: linkPreview.map { linkPreview in + guard linkPreview.variant == .openGroupInvitation else { return nil } + + return VisibleMessage.OpenGroupInvitation.from( + db, + linkPreview: linkPreview + ) } - } - - return result + ) } } diff --git a/SessionMessagingKit/Meta/SessionMessagingKit.h b/SessionMessagingKit/Meta/SessionMessagingKit.h index 9e21c5f40..829e3a4d2 100644 --- a/SessionMessagingKit/Meta/SessionMessagingKit.h +++ b/SessionMessagingKit/Meta/SessionMessagingKit.h @@ -9,10 +9,7 @@ FOUNDATION_EXPORT const unsigned char SessionMessagingKitVersionString[]; #import #import #import -#import -#import #import -#import #import #import #import diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift b/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift index aef0592f8..e010f6021 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift @@ -168,7 +168,7 @@ public final class OpenGroupAPIV2 : NSObject { hasUpdatedLastOpenDate = true } for group in groups { - authTokenPromises[group.room] = getAuthToken(for: group.room, on: server) + authTokenPromises[group.room] = getAuthToken(db, for: group.room, on: server) var json: JSON = [ "room_id" : group.room ] if let lastMessageServerID = storage.getLastMessageServerID(for: group.room, on: server) { json["from_message_server_id"] = useMessageLimit ? nil : lastMessageServerID @@ -213,7 +213,11 @@ public final class OpenGroupAPIV2 : NSObject { } // MARK: Authorization - private static func getAuthToken(for room: String, on server: String) -> Promise { + private static func getAuthToken(_ db: Database? = nil, for room: String, on server: String) -> Promise { + guard let db: Database = db else { + return GRDBStorage.shared.read { db in getAuthToken(db, for: room, on: server) } + } + let storage = SNMessagingKitConfiguration.shared.storage if let authToken = storage.getAuthToken(for: room, on: server) { return Promise.value(authToken) @@ -221,7 +225,7 @@ public final class OpenGroupAPIV2 : NSObject { if let authTokenPromise = authTokenPromises.wrappedValue["\(server).\(room)"] { return authTokenPromise } else { - let promise = requestNewAuthToken(for: room, on: server) + let promise = requestNewAuthToken(db, for: room, on: server) .then(on: OpenGroupAPIV2.workQueue) { claimAuthToken($0, for: room, on: server) } .then(on: OpenGroupAPIV2.workQueue) { authToken -> Promise in let (promise, seal) = Promise.pending() @@ -243,9 +247,9 @@ public final class OpenGroupAPIV2 : NSObject { } } - public static func requestNewAuthToken(for room: String, on server: String) -> Promise { + public static func requestNewAuthToken(_ db: Database, for room: String, on server: String) -> Promise { SNLog("Requesting auth token for server: \(server).") - guard let userKeyPair = Identity.fetchUserKeyPair() else { return Promise(error: Error.generic) } + guard let userKeyPair = Identity.fetchUserKeyPair(db) else { return Promise(error: Error.generic) } let queryParameters = [ "public_key" : getUserHexEncodedPublicKey() ] let request = Request(verb: .get, room: room, server: server, endpoint: "auth_token_challenge", queryParameters: queryParameters, isAuthRequired: false) return send(request).map(on: OpenGroupAPIV2.workQueue) { json in diff --git a/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingConfigurationUpdateInfoMessage.h b/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingConfigurationUpdateInfoMessage.h deleted file mode 100644 index 347c2f80e..000000000 --- a/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingConfigurationUpdateInfoMessage.h +++ /dev/null @@ -1,28 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@class OWSDisappearingMessagesConfiguration; -@class TSThread; - -@interface OWSDisappearingConfigurationUpdateInfoMessage : TSInfoMessage - -@property (nonatomic, readonly) BOOL configurationIsEnabled; - -/** - * @param remoteName is nil when created by the local user - */ -// MJK TODO - can we remove sendertimestamp here -- (instancetype)initWithTimestamp:(uint64_t)timestamp - thread:(TSThread *)thread - configuration:(OWSDisappearingMessagesConfiguration *)configuration - createdByRemoteName:(nullable NSString *)remoteName - createdInExistingGroup:(BOOL)createdInExistingGroup; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingConfigurationUpdateInfoMessage.m b/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingConfigurationUpdateInfoMessage.m deleted file mode 100644 index 00fb99acc..000000000 --- a/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingConfigurationUpdateInfoMessage.m +++ /dev/null @@ -1,92 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSDisappearingConfigurationUpdateInfoMessage.h" -#import -#import - - -NS_ASSUME_NONNULL_BEGIN - -@interface OWSDisappearingConfigurationUpdateInfoMessage () - -@property (nonatomic, readonly, nullable) NSString *createdByRemoteName; -@property (nonatomic, readonly) BOOL createdInExistingGroup; -@property (nonatomic, readonly) uint32_t configurationDurationSeconds; - -@end - -@implementation OWSDisappearingConfigurationUpdateInfoMessage - -- (instancetype)initWithTimestamp:(uint64_t)timestamp - thread:(TSThread *)thread - configuration:(OWSDisappearingMessagesConfiguration *)configuration - createdByRemoteName:(nullable NSString *)remoteName - createdInExistingGroup:(BOOL)createdInExistingGroup -{ - self = [super initWithTimestamp:timestamp inThread:thread messageType:TSInfoMessageTypeDisappearingMessagesUpdate]; - if (!self) { - return self; - } - - _configurationIsEnabled = configuration.isEnabled; - _configurationDurationSeconds = configuration.durationSeconds; - - // At most one should be set - _createdByRemoteName = remoteName; - _createdInExistingGroup = createdInExistingGroup; - - return self; -} - -- (BOOL)shouldUseReceiptDateForSorting -{ - // Use the timestamp, not the "received at" timestamp to sort, - // since we're creating these interactions after the fact and back-dating them. - return NO; -} - --(NSString *)previewTextWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - if (self.createdInExistingGroup) { - NSString *infoFormat = NSLocalizedString(@"DISAPPEARING_MESSAGES_CONFIGURATION_GROUP_EXISTING_FORMAT", - @"Info Message when added to a group which has enabled disappearing messages. Embeds {{time amount}} " - @"before messages disappear, see the *_TIME_AMOUNT strings for context."); - - NSString *durationString = [NSString formatDurationSeconds:self.configurationDurationSeconds useShortFormat:NO]; - return [NSString stringWithFormat:infoFormat, durationString]; - } else if (self.createdByRemoteName) { - if (self.configurationIsEnabled && self.configurationDurationSeconds > 0) { - NSString *infoFormat = NSLocalizedString(@"OTHER_UPDATED_DISAPPEARING_MESSAGES_CONFIGURATION", - @"Info Message when {{other user}} updates message expiration to {{time amount}}, see the " - @"*_TIME_AMOUNT " - @"strings for context."); - - NSString *durationString = - [NSString formatDurationSeconds:self.configurationDurationSeconds useShortFormat:NO]; - return [NSString stringWithFormat:infoFormat, self.createdByRemoteName, durationString]; - } else { - NSString *infoFormat = NSLocalizedString(@"OTHER_DISABLED_DISAPPEARING_MESSAGES_CONFIGURATION", - @"Info Message when {{other user}} disables or doesn't support disappearing messages"); - return [NSString stringWithFormat:infoFormat, self.createdByRemoteName]; - } - } else { - // Changed by localNumber on this device or via synced transcript - if (self.configurationIsEnabled && self.configurationDurationSeconds > 0) { - NSString *infoFormat = NSLocalizedString(@"YOU_UPDATED_DISAPPEARING_MESSAGES_CONFIGURATION", - @"Info message embedding a {{time amount}}, see the *_TIME_AMOUNT strings for context."); - - NSString *durationString = - [NSString formatDurationSeconds:self.configurationDurationSeconds useShortFormat:NO]; - return [NSString stringWithFormat:infoFormat, durationString]; - } else { - return NSLocalizedString(@"YOU_DISABLED_DISAPPEARING_MESSAGES_CONFIGURATION", - @"Info Message when you disable disappearing messages"); - } - } -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingMessagesConfiguration.h b/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingMessagesConfiguration.h deleted file mode 100644 index 367b787d8..000000000 --- a/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingMessagesConfiguration.h +++ /dev/null @@ -1,35 +0,0 @@ -//// -//// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -//// -// -//#import -//#import -// -//NS_ASSUME_NONNULL_BEGIN -// -//#define OWSDisappearingMessagesConfigurationDefaultExpirationDuration kDayInterval -// -//@class YapDatabaseReadTransaction; -// -//@interface OWSDisappearingMessagesConfiguration : TSYapDatabaseObject -// -//- (instancetype)initDefaultWithThreadId:(NSString *)threadId; -// -//- (instancetype)initWithThreadId:(NSString *)threadId enabled:(BOOL)isEnabled durationSeconds:(uint32_t)seconds; -// -//@property (nonatomic, getter=isEnabled) BOOL enabled; -//@property (nonatomic) uint32_t durationSeconds; -//@property (nonatomic, readonly) NSUInteger durationIndex; -//@property (nonatomic, readonly) NSString *durationString; -//@property (nonatomic, readonly) BOOL dictionaryValueDidChange; -//@property (readonly, getter=isNewRecord) BOOL newRecord; -// -//+ (instancetype)fetchOrBuildDefaultWithThreadId:(NSString *)threadId -// transaction:(YapDatabaseReadTransaction *)transaction; -// -//+ (NSArray *)validDurationsSeconds; -//+ (uint32_t)maxDurationSeconds; -// -//@end -// -//NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingMessagesConfiguration.m b/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingMessagesConfiguration.m deleted file mode 100644 index 8d7f5b12f..000000000 --- a/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingMessagesConfiguration.m +++ /dev/null @@ -1,130 +0,0 @@ -//// -//// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -//// -// -//#import "OWSDisappearingMessagesConfiguration.h" -//#import -//#import -// -//NS_ASSUME_NONNULL_BEGIN -// -//@interface OWSDisappearingMessagesConfiguration () -// -//// Transient record lifecycle attributes. -//@property (atomic) NSDictionary *originalDictionaryValue; -//@property (atomic, getter=isNewRecord) BOOL newRecord; -// -//@end -// -//@implementation OWSDisappearingMessagesConfiguration -// -//- (instancetype)initDefaultWithThreadId:(NSString *)threadId -//{ -// return [self initWithThreadId:threadId -// enabled:NO -// durationSeconds:(NSTimeInterval)OWSDisappearingMessagesConfigurationDefaultExpirationDuration]; -//} -// -//- (nullable instancetype)initWithCoder:(NSCoder *)coder -//{ -// self = [super initWithCoder:coder]; -// -// _originalDictionaryValue = [self dictionaryValue]; -// _newRecord = NO; -// -// return self; -//} -// -//- (instancetype)initWithThreadId:(NSString *)threadId enabled:(BOOL)isEnabled durationSeconds:(uint32_t)seconds -//{ -// self = [super initWithUniqueId:threadId]; -// if (!self) { -// return self; -// } -// -// _enabled = isEnabled; -// _durationSeconds = seconds; -// _newRecord = YES; -// _originalDictionaryValue = self.dictionaryValue; -// -// return self; -//} -// -//+ (instancetype)fetchOrBuildDefaultWithThreadId:(NSString *)threadId -// transaction:(YapDatabaseReadTransaction *)transaction -//{ -// OWSDisappearingMessagesConfiguration *savedConfiguration = -// [self fetchObjectWithUniqueID:threadId transaction:transaction]; -// if (savedConfiguration) { -// return savedConfiguration; -// } else { -// return [[self alloc] initDefaultWithThreadId:threadId]; -// } -//} -// -//+ (NSArray *)validDurationsSeconds -//{ -// return @[ -// @(5 * kSecondInterval), -// @(10 * kSecondInterval), -// @(30 * kSecondInterval), -// @(1 * kMinuteInterval), -// @(5 * kMinuteInterval), -// @(30 * kMinuteInterval), -// @(1 * kHourInterval), -// @(6 * kHourInterval), -// @(12 * kHourInterval), -// @(24 * kHourInterval), -// @(1 * kWeekInterval) -// ]; -//} -// -//+ (uint32_t)maxDurationSeconds -//{ -// static uint32_t max; -// static dispatch_once_t onceToken; -// dispatch_once(&onceToken, ^{ -// max = [[self.validDurationsSeconds valueForKeyPath:@"@max.intValue"] unsignedIntValue]; -// }); -// -// return max; -//} -// -//- (NSUInteger)durationIndex -//{ -// return [[self.class validDurationsSeconds] indexOfObject:@(self.durationSeconds)]; -//} -// -//- (NSString *)durationString -//{ -// return [NSString formatDurationSeconds:self.durationSeconds useShortFormat:NO]; -//} -// -//#pragma mark - Dirty Tracking -// -//+ (MTLPropertyStorage)storageBehaviorForPropertyWithKey:(NSString *)propertyKey -//{ -// // Don't persist transient properties -// if ([propertyKey isEqualToString:@"originalDictionaryValue"] -// ||[propertyKey isEqualToString:@"newRecord"]) { -// return MTLPropertyStorageNone; -// } else { -// return [super storageBehaviorForPropertyWithKey:propertyKey]; -// } -//} -// -//- (BOOL)dictionaryValueDidChange -//{ -// return ![self.originalDictionaryValue isEqual:[self dictionaryValue]]; -//} -// -//- (void)saveWithTransaction:(YapDatabaseReadWriteTransaction *)transaction -//{ -// [super saveWithTransaction:transaction]; -// self.originalDictionaryValue = [self dictionaryValue]; -// self.newRecord = NO; -//} -// -//@end -// -//NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingMessagesJob.h b/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingMessagesJob.h deleted file mode 100644 index fa43294a4..000000000 --- a/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingMessagesJob.h +++ /dev/null @@ -1,57 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@class OWSPrimaryStorage; -@class TSMessage; -@class TSThread; -@class YapDatabaseReadWriteTransaction; - -@protocol ContactsManagerProtocol; - -@interface OWSDisappearingMessagesJob : NSObject - -+ (instancetype)sharedJob; - -- (instancetype)init NS_UNAVAILABLE; - -- (instancetype)initWithPrimaryStorage:(OWSPrimaryStorage *)primaryStorage NS_DESIGNATED_INITIALIZER; - -- (void)startAnyExpirationForMessage:(TSMessage *)message - expirationStartedAt:(uint64_t)expirationStartedAt - transaction:(YapDatabaseReadWriteTransaction *_Nonnull)transaction; - -/** - * Synchronize our disappearing messages settings with that of the given message. Useful so we can - * become eventually consistent with remote senders. - * - * @param duration - * Can be 0, indicating a non-expiring message, or greater, indicating an expiring message. We match the expiration - * timer of the message, including disabling expiring messages if the message is not an expiring message. - * - * @param remoteRecipientId - * nil for outgoing messages, otherwise the recipientId of the sender - * - * @param createdInExistingGroup - * YES when being added to a group which already has DM enabled, otherwise NO - */ -- (void)becomeConsistentWithDisappearingDuration:(uint32_t)duration - thread:(TSThread *)thread - createdByRemoteRecipientId:(nullable NSString *)remoteRecipientId - createdInExistingGroup:(BOOL)createdInExistingGroup - transaction:(YapDatabaseReadWriteTransaction *)transaction; - -// Clean up any messages that expired since last launch immediately -// and continue cleaning in the background. -- (void)startIfNecessary; - -- (void)cleanupMessagesWhichFailedToStartExpiringFromNow; -- (void)cleanupMessagesWhichFailedToStartExpiringWithTransaction:(YapDatabaseReadWriteTransaction *)transaction; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingMessagesJob.m b/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingMessagesJob.m deleted file mode 100644 index 0c39211c7..000000000 --- a/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingMessagesJob.m +++ /dev/null @@ -1,371 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSDisappearingMessagesJob.h" -#import "AppContext.h" -#import "AppReadiness.h" -#import "OWSBackgroundTask.h" -#import "OWSDisappearingConfigurationUpdateInfoMessage.h" -#import "OWSDisappearingMessagesConfiguration.h" -#import "OWSDisappearingMessagesFinder.h" -#import "OWSPrimaryStorage.h" -#import "SSKEnvironment.h" -#import "TSIncomingMessage.h" -#import "TSMessage.h" -#import "TSThread.h" -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -// Can we move to Signal-iOS? -@interface OWSDisappearingMessagesJob () - -@property (nonatomic, readonly) YapDatabaseConnection *databaseConnection; - -@property (nonatomic, readonly) OWSDisappearingMessagesFinder *disappearingMessagesFinder; - -+ (dispatch_queue_t)serialQueue; - -// These three properties should only be accessed on the main thread. -@property (nonatomic) BOOL hasStarted; -@property (nonatomic, nullable) NSTimer *nextDisappearanceTimer; -@property (nonatomic, nullable) NSDate *nextDisappearanceDate; -@property (nonatomic, nullable) NSTimer *fallbackTimer; - -@end - -void AssertIsOnDisappearingMessagesQueue() -{ -#ifdef DEBUG - if (@available(iOS 10.0, *)) { - dispatch_assert_queue(OWSDisappearingMessagesJob.serialQueue); - } -#endif -} - -#pragma mark - - -@implementation OWSDisappearingMessagesJob - -+ (instancetype)sharedJob -{ - return SSKEnvironment.shared.disappearingMessagesJob; -} - -- (instancetype)initWithPrimaryStorage:(OWSPrimaryStorage *)primaryStorage -{ - self = [super init]; - if (!self) { - return self; - } - - _databaseConnection = primaryStorage.newDatabaseConnection; - _disappearingMessagesFinder = [OWSDisappearingMessagesFinder new]; - - // suspenders in case a deletion schedule is missed. - NSTimeInterval kFallBackTimerInterval = 5 * kMinuteInterval; - [AppReadiness runNowOrWhenAppDidBecomeReady:^{ - if (CurrentAppContext().isMainApp) { - self.fallbackTimer = [NSTimer weakScheduledTimerWithTimeInterval:kFallBackTimerInterval - target:self - selector:@selector(fallbackTimerDidFire) - userInfo:nil - repeats:YES]; - } - }]; - - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(applicationDidBecomeActive:) - name:OWSApplicationDidBecomeActiveNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(applicationWillResignActive:) - name:OWSApplicationWillResignActiveNotification - object:nil]; - - return self; -} - -- (void)dealloc -{ - [[NSNotificationCenter defaultCenter] removeObserver:self]; -} - -+ (dispatch_queue_t)serialQueue -{ - static dispatch_queue_t queue = nil; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - queue = dispatch_queue_create("org.whispersystems.disappearing.messages", DISPATCH_QUEUE_SERIAL); - }); - return queue; -} - -#pragma mark - - -- (NSUInteger)deleteExpiredMessages -{ - AssertIsOnDisappearingMessagesQueue(); - - uint64_t now = [NSDate ows_millisecondTimeStamp]; - - OWSBackgroundTask *_Nullable backgroundTask = [OWSBackgroundTask backgroundTaskWithLabelStr:__PRETTY_FUNCTION__]; - - __block NSUInteger expirationCount = 0; - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) { - [self.disappearingMessagesFinder enumerateExpiredMessagesWithBlock:^(TSMessage *message) { - // sanity check - if (message.expiresAt > now) { - return; - } - - [message removeWithTransaction:transaction]; - expirationCount++; - } - transaction:transaction]; - }]; - - backgroundTask = nil; - return expirationCount; -} - -// deletes any expired messages and schedules the next run. -- (NSUInteger)runLoop -{ - AssertIsOnDisappearingMessagesQueue(); - - NSUInteger deletedCount = [self deleteExpiredMessages]; - - __block NSNumber *nextExpirationTimestampNumber; - [self.databaseConnection readWithBlock:^(YapDatabaseReadTransaction *_Nonnull transaction) { - nextExpirationTimestampNumber = - [self.disappearingMessagesFinder nextExpirationTimestampWithTransaction:transaction]; - }]; - - if (!nextExpirationTimestampNumber) { - return deletedCount; - } - - uint64_t nextExpirationAt = nextExpirationTimestampNumber.unsignedLongLongValue; - NSDate *nextEpirationDate = [NSDate ows_dateWithMillisecondsSince1970:nextExpirationAt]; - [self scheduleRunByDate:nextEpirationDate]; - - return deletedCount; -} - -- (void)startAnyExpirationForMessage:(TSMessage *)message - expirationStartedAt:(uint64_t)expirationStartedAt - transaction:(YapDatabaseReadWriteTransaction *_Nonnull)transaction -{ - if (!message.isExpiringMessage) { - return; - } - - // Don't clobber if multiple actions simultaneously triggered expiration. - if (message.expireStartedAt == 0 || message.expireStartedAt > expirationStartedAt) { - [message updateWithExpireStartedAt:expirationStartedAt transaction:transaction]; - } - - [transaction addCompletionQueue:nil - completionBlock:^{ - // Necessary that the async expiration run happens *after* the message is saved with it's new - // expiration configuration. - [self scheduleRunByDate:[NSDate ows_dateWithMillisecondsSince1970:message.expiresAt]]; - }]; -} - -#pragma mark - Apply Remote Configuration - -- (void)becomeConsistentWithDisappearingDuration:(uint32_t)duration - thread:(TSThread *)thread - createdByRemoteRecipientId:(nullable NSString *)remoteRecipientId - createdInExistingGroup:(BOOL)createdInExistingGroup - transaction:(YapDatabaseReadWriteTransaction *)transaction -{ - OWSBackgroundTask *_Nullable backgroundTask = [OWSBackgroundTask backgroundTaskWithLabelStr:__PRETTY_FUNCTION__]; - - NSString *_Nullable remoteContactName = nil; - if (remoteRecipientId) { - remoteContactName = [SMKProfile displayNameWithId:remoteRecipientId thread:thread]; - } - - // Become eventually consistent in the case that the remote changed their settings at the same time. - // Also in case remote doesn't support expiring messages - OWSDisappearingMessagesConfiguration *disappearingMessagesConfiguration = - [thread disappearingMessagesConfigurationWithTransaction:transaction]; - - if (duration == 0) { - disappearingMessagesConfiguration.isEnabled = NO; - } else { - disappearingMessagesConfiguration.isEnabled = YES; - disappearingMessagesConfiguration.durationSeconds = duration; - } - - if (!disappearingMessagesConfiguration.dictionaryValueDidChange) { - return; - } - - [disappearingMessagesConfiguration saveWithTransaction:transaction]; - - // MJK TODO - should be safe to remove this senderTimestamp - OWSDisappearingConfigurationUpdateInfoMessage *infoMessage = - [[OWSDisappearingConfigurationUpdateInfoMessage alloc] initWithTimestamp:[NSDate ows_millisecondTimeStamp] - thread:thread - configuration:disappearingMessagesConfiguration - createdByRemoteName:remoteContactName - createdInExistingGroup:createdInExistingGroup]; - [infoMessage saveWithTransaction:transaction]; - - backgroundTask = nil; -} - -#pragma mark - - -- (void)startIfNecessary -{ - dispatch_async(dispatch_get_main_queue(), ^{ - if (self.hasStarted) { - return; - } - self.hasStarted = YES; - - dispatch_async(OWSDisappearingMessagesJob.serialQueue, ^{ - // Theoretically this shouldn't be necessary, but there was a race condition when receiving a backlog - // of messages across timer changes which could cause a disappearing message's timer to never be started. - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) { - [self cleanupMessagesWhichFailedToStartExpiringWithTransaction:transaction]; - }]; - - [self runLoop]; - }); - }); -} - -- (NSDateFormatter *)dateFormatter -{ - static NSDateFormatter *dateFormatter; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - dateFormatter = [NSDateFormatter new]; - dateFormatter.dateStyle = NSDateFormatterNoStyle; - dateFormatter.timeStyle = kCFDateFormatterMediumStyle; - dateFormatter.locale = [NSLocale systemLocale]; - }); - - return dateFormatter; -} - -- (void)scheduleRunByDate:(NSDate *)date -{ - dispatch_async(dispatch_get_main_queue(), ^{ - if (!CurrentAppContext().isMainAppAndActive) { - // Don't schedule run when inactive or not in main app. - return; - } - - // Don't run more often than once per second. - const NSTimeInterval kMinDelaySeconds = 1.0; - NSTimeInterval delaySeconds = MAX(kMinDelaySeconds, date.timeIntervalSinceNow); - NSDate *newTimerScheduleDate = [NSDate dateWithTimeIntervalSinceNow:delaySeconds]; - if (self.nextDisappearanceDate && [self.nextDisappearanceDate isBeforeDate:newTimerScheduleDate]) { - return; - } - - // Update Schedule - [self resetNextDisappearanceTimer]; - self.nextDisappearanceDate = newTimerScheduleDate; - self.nextDisappearanceTimer = [NSTimer weakScheduledTimerWithTimeInterval:delaySeconds - target:self - selector:@selector(disappearanceTimerDidFire) - userInfo:nil - repeats:NO]; - }); -} - -- (void)disappearanceTimerDidFire -{ - if (!CurrentAppContext().isMainAppAndActive) { - // Don't schedule run when inactive or not in main app. - return; - } - - [self resetNextDisappearanceTimer]; - - dispatch_async(OWSDisappearingMessagesJob.serialQueue, ^{ - [self runLoop]; - }); -} - -- (void)fallbackTimerDidFire -{ - BOOL recentlyScheduledDisappearanceTimer = NO; - if (fabs(self.nextDisappearanceDate.timeIntervalSinceNow) < 1.0) { - recentlyScheduledDisappearanceTimer = YES; - } - - if (!CurrentAppContext().isMainAppAndActive) { - return; - } - - dispatch_async(OWSDisappearingMessagesJob.serialQueue, ^{ - NSUInteger deletedCount = [self runLoop]; - - // Normally deletions should happen via the disappearanceTimer, to make sure that they're prompt. - // So, if we're deleting something via this fallback timer, something may have gone wrong. The - // exception is if we're in close proximity to the disappearanceTimer, in which case a race condition - // is inevitable. - }); -} - -- (void)resetNextDisappearanceTimer -{ - [self.nextDisappearanceTimer invalidate]; - self.nextDisappearanceTimer = nil; - self.nextDisappearanceDate = nil; -} - -#pragma mark - Cleanup - -- (void)cleanupMessagesWhichFailedToStartExpiringFromNow -{ - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) { - [self.disappearingMessagesFinder - enumerateMessagesWhichFailedToStartExpiringWithBlock:^(TSMessage *_Nonnull message) { - [self startAnyExpirationForMessage:message expirationStartedAt:[NSDate millisecondTimestamp] transaction:transaction]; - } - transaction:transaction]; - }]; -} - -- (void)cleanupMessagesWhichFailedToStartExpiringWithTransaction:(YapDatabaseReadWriteTransaction *)transaction -{ - [self.disappearingMessagesFinder - enumerateMessagesWhichFailedToStartExpiringWithBlock:^(TSMessage *_Nonnull message) { - // We don't know when it was actually read, so assume it was read as soon as it was received. - uint64_t readTimeBestGuess = message.receivedAtTimestamp; - [self startAnyExpirationForMessage:message expirationStartedAt:readTimeBestGuess transaction:transaction]; - } - transaction:transaction]; -} - -#pragma mark - Notifications - -- (void)applicationDidBecomeActive:(NSNotification *)notification -{ - [AppReadiness runNowOrWhenAppDidBecomeReady:^{ - dispatch_async(OWSDisappearingMessagesJob.serialQueue, ^{ - [self runLoop]; - }); - }]; -} - -- (void)applicationWillResignActive:(NSNotification *)notification -{ - [self resetNextDisappearanceTimer]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index 5e7df44df..30f7ce242 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -439,7 +439,8 @@ extension MessageReceiver { let messageSentTimestamp: TimeInterval = (TimeInterval(message.sentTimestamp ?? 0) / 1000) let isMainAppActive: Bool = (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) - // Update profile if needed + // Update profile if needed (want to do this regarless of whether the message exists or + // not to ensure the profile info gets sync between a users devices at every chance) if let profile = message.profile { var contactProfileKey: OWSAES256Key? = nil if let profileKey = profile.profileKey { contactProfileKey = OWSAES256Key(data: profileKey) } @@ -481,61 +482,71 @@ extension MessageReceiver { // Note: There are now a number of unique constraints on the database which // prevent the ability to insert duplicate interactions at a database level // so we don't need to check for the existance of a message beforehand anymore - let interaction: Interaction = try Interaction( - serverHash: message.serverHash, // Keep track of server hash - threadId: thread.id, - authorId: sender, - variant: variant, - body: message.text, - timestampMs: Int64(messageSentTimestamp * 1000), - // Note: Ensure we don't ever expire open group messages - expiresInSeconds: (disappearingMessagesConfiguration.isEnabled && message.openGroupServerMessageId == nil ? - disappearingMessagesConfiguration.durationSeconds : - nil - ), - expiresStartedAtMs: nil, - // OpenGroupInvitations are stored as LinkPreview's in the database - linkPreviewUrl: (message.linkPreview?.url ?? message.openGroupInvitation?.url), - // Keep track of the open group server message ID ↔ message ID relationship - openGroupServerMessageId: message.openGroupServerMessageId.map { Int64($0) }, - openGroupWhisperMods: false, // TODO: SOGSV4 - openGroupWhisperTo: nil // TODO: SOGSV4 - ).inserted(db) + let interaction: Interaction + + do { + interaction = try Interaction( + serverHash: message.serverHash, // Keep track of server hash + threadId: thread.id, + authorId: sender, + variant: variant, + body: message.text, + timestampMs: Int64(messageSentTimestamp * 1000), + // Note: Ensure we don't ever expire open group messages + expiresInSeconds: (disappearingMessagesConfiguration.isEnabled && message.openGroupServerMessageId == nil ? + disappearingMessagesConfiguration.durationSeconds : + nil + ), + expiresStartedAtMs: nil, + // OpenGroupInvitations are stored as LinkPreview's in the database + linkPreviewUrl: (message.linkPreview?.url ?? message.openGroupInvitation?.url), + // Keep track of the open group server message ID ↔ message ID relationship + openGroupServerMessageId: message.openGroupServerMessageId.map { Int64($0) }, + openGroupWhisperMods: false, + openGroupWhisperTo: nil + ).inserted(db) + } + catch { + switch error { + case DatabaseError.SQLITE_CONSTRAINT_UNIQUE: + guard + variant == .standardOutgoing, + let existingInteractionId: Int64 = try? thread.interactions + .select(.id) + .filter(Interaction.Columns.timestampMs == (messageSentTimestamp * 1000)) + .filter(Interaction.Columns.variant == variant) + .filter(Interaction.Columns.authorId == sender) + .asRequest(of: Int64.self) + .fetchOne(db) + else { break } + + // If we receive an outgoing message that already exists in the database + // then we still need up update the recipient and read states for the + // message (even if we don't need to do anything else) + try updateRecipientAndReadStates( + db, + thread: thread, + interactionId: existingInteractionId, + variant: variant, + syncTarget: message.syncTarget + ) + + default: break + } + + throw error + } guard let interactionId: Int64 = interaction.id else { throw GRDBStorageError.failedToSave } - // For newly created outgoing messages upsert the recipient states to sent - if variant == .standardOutgoing { - if let syncTarget: String = message.syncTarget { - try RecipientState( - interactionId: interactionId, - recipientId: syncTarget, - state: .sent - ).save(db) - } - else if thread.variant == .closedGroup { - try GroupMember - .filter(GroupMember.Columns.groupId == thread.id) - .fetchAll(db) - .forEach { member in - try RecipientState( - interactionId: interactionId, - recipientId: member.profileId, - state: .sent - ).save(db) - } - } - - // For outgoing messages mark it and all older interactions as read - try Interaction.markAsRead( - db, - interactionId: interactionId, - threadId: thread.id, - includingOlder: true, - trySendReadReceipt: true - ) - } - + // Update and recipient and read states as needed + try updateRecipientAndReadStates( + db, + thread: thread, + interactionId: interactionId, + variant: variant, + syncTarget: message.syncTarget + ) // Parse & persist attachments let attachments: [Attachment] = dataMessage.attachments @@ -637,10 +648,50 @@ extension MessageReceiver { return interactionId } + private static func updateRecipientAndReadStates( + _ db: Database, + thread: SessionThread, + interactionId: Int64, + variant: Interaction.Variant, + syncTarget: String? + ) throws { + guard variant == .standardOutgoing else { return } + + if let syncTarget: String = syncTarget { + try RecipientState( + interactionId: interactionId, + recipientId: syncTarget, + state: .sent + ).save(db) + } + else if thread.variant == .closedGroup { + try GroupMember + .filter(GroupMember.Columns.groupId == thread.id) + .fetchAll(db) + .forEach { member in + try RecipientState( + interactionId: interactionId, + recipientId: member.profileId, + state: .sent + ).save(db) + } + } + + // For outgoing messages mark it and all older interactions as read + try Interaction.markAsRead( + db, + interactionId: interactionId, + threadId: thread.id, + includingOlder: true, + trySendReadReceipt: true + ) + } + // MARK: - Profile Updating private static func updateProfileIfNeeded( - _ db: Database, publicKey: String, + _ db: Database, + publicKey: String, name: String?, profilePictureUrl: String?, profileKey: OWSAES256Key?, diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index 3e7993b10..de137163a 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -49,38 +49,30 @@ extension MessageSender { return Promise(error: GRDBStorageError.objectNotSaved) } - let attachments: [Attachment]? = try? Attachment - .filter(Attachment.Columns.state == Attachment.State.pending) - .joining( - required: Attachment.interactionAttachments - .filter(InteractionAttachment.Columns.interactionId == interactionId) - ) - .fetchAll(db) - - let attachmentUploadPromises: [Promise] = (attachments ?? []) - .map { attachment in + let openGroup: OpenGroup? = try? thread.openGroup.fetchOne(db) + let attachmentStateInfo: [Attachment.StateInfo] = (try? Attachment + .stateInfo(interactionId: interactionId, state: .pending) + .fetchAll(db)) + .defaulting(to: []) + let attachmentUploadPromises: [Promise] = (try? Attachment + .filter(ids: attachmentStateInfo.map { $0.attachmentId }) + .fetchAll(db)) + .defaulting(to: []) + .map { attachment -> Promise in let (promise, seal) = Promise.pending() - if let openGroup: OpenGroup = try? thread.openGroup.fetchOne(db) { - AttachmentUploadJob.upload( - db, - attachment: attachment, - using: { data in OpenGroupAPIV2.upload(data, to: openGroup.room, on: openGroup.server) }, - encrypt: false, - success: { seal.fulfill(()) }, - failure: { seal.reject($0) } - ) - } - else { - AttachmentUploadJob.upload( - db, - attachment: attachment, - using: FileServerAPIV2.upload, - encrypt: true, - success: { seal.fulfill(()) }, - failure: { seal.reject($0) } - ) - } + attachment.upload( + using: { data in + if let openGroup: OpenGroup = openGroup { + return OpenGroupAPIV2.upload(data, to: openGroup.room, on: openGroup.server) + } + + return FileServerAPIV2.upload(data) + }, + encrypt: (openGroup == nil), + success: { seal.fulfill(()) }, + failure: { seal.reject($0) } + ) return promise } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 67142630e..d0e81d4f0 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -469,9 +469,7 @@ public final class MessageSender : NSObject { // Mark the message as sent try interaction.recipientStates - .fetchAll(db) - .map { $0.with(state: .sent) } - .saveAll(db) + .updateAll(db, RecipientState.Columns.state.set(to: RecipientState.State.sent)) NotificationCenter.default.post(name: .messageSentStatusDidChange, object: nil, userInfo: nil) diff --git a/SessionMessagingKit/Threads/TSThread.m b/SessionMessagingKit/Threads/TSThread.m index 0092e8f81..d9f4f9000 100644 --- a/SessionMessagingKit/Threads/TSThread.m +++ b/SessionMessagingKit/Threads/TSThread.m @@ -3,7 +3,6 @@ // #import "TSThread.h" -#import "OWSDisappearingMessagesConfiguration.h" #import #import #import @@ -29,13 +28,6 @@ BOOL IsNoteToSelfEnabled(void) @implementation TSThread -#pragma mark Dependencies - -- (TSAccountManager *)tsAccountManager -{ - return SSKEnvironment.shared.tsAccountManager; -} - #pragma mark Initialization + (NSString *)collection { @@ -340,25 +332,6 @@ BOOL IsNoteToSelfEnabled(void) } } -#pragma mark Disappearing Messages - -- (OWSDisappearingMessagesConfiguration *)disappearingMessagesConfigurationWithTransaction: - (YapDatabaseReadTransaction *)transaction -{ - return [OWSDisappearingMessagesConfiguration fetchOrBuildDefaultWithThreadId:self.uniqueId transaction:transaction]; -} - -- (uint32_t)disappearingMessagesDurationWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - OWSDisappearingMessagesConfiguration *config = [self disappearingMessagesConfigurationWithTransaction:transaction]; - - if (!config.isEnabled) { - return 0; - } else { - return config.durationSeconds; - } -} - #pragma mark Drafts - (NSString *)currentDraftWithTransaction:(YapDatabaseReadTransaction *)transaction { diff --git a/SessionMessagingKit/Utilities/MessageInvalidator.swift b/SessionMessagingKit/Utilities/MessageInvalidator.swift index 83619e091..734691fab 100644 --- a/SessionMessagingKit/Utilities/MessageInvalidator.swift +++ b/SessionMessagingKit/Utilities/MessageInvalidator.swift @@ -1,3 +1,6 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation /// A message is invalidated when it needs to be re-rendered in the UI. Examples of when this happens include: /// diff --git a/SessionMessagingKit/Utilities/OWSAudioPlayer.h b/SessionMessagingKit/Utilities/OWSAudioPlayer.h index 09d207ad1..41f555e67 100644 --- a/SessionMessagingKit/Utilities/OWSAudioPlayer.h +++ b/SessionMessagingKit/Utilities/OWSAudioPlayer.h @@ -15,7 +15,7 @@ typedef NS_ENUM(NSInteger, AudioPlaybackState) { AudioPlaybackState_Paused, }; -@protocol OWSAudioPlayerDelegate +@protocol OWSAudioPlayerDelegate - (AudioPlaybackState)audioPlaybackState; - (void)setAudioPlaybackState:(AudioPlaybackState)state; diff --git a/SessionMessagingKit/Utilities/Threading.swift b/SessionMessagingKit/Utilities/Threading.swift index 10db310b6..b7e1cab79 100644 --- a/SessionMessagingKit/Utilities/Threading.swift +++ b/SessionMessagingKit/Utilities/Threading.swift @@ -2,7 +2,5 @@ import Foundation internal enum Threading { - internal static let jobQueue = DispatchQueue(label: "SessionMessagingKit.jobQueue", qos: .userInitiated) - internal static let pollerQueue = DispatchQueue(label: "SessionMessagingKit.pollerQueue") } diff --git a/SignalUtilitiesKit/Messaging/OWSFailedAttachmentDownloadsJob.h b/SignalUtilitiesKit/Messaging/OWSFailedAttachmentDownloadsJob.h deleted file mode 100644 index f3fab6d30..000000000 --- a/SignalUtilitiesKit/Messaging/OWSFailedAttachmentDownloadsJob.h +++ /dev/null @@ -1,31 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@class OWSPrimaryStorage; -@class OWSStorage; - -@interface OWSFailedAttachmentDownloadsJob : NSObject - -- (instancetype)init NS_UNAVAILABLE; -- (instancetype)initWithPrimaryStorage:(OWSPrimaryStorage *)primaryStorage NS_DESIGNATED_INITIALIZER; - -- (void)run; - -+ (NSString *)databaseExtensionName; -+ (void)asyncRegisterDatabaseExtensionsWithPrimaryStorage:(OWSStorage *)storage; - -#ifdef DEBUG -/** - * Only use the sync version for testing, generally we'll want to register extensions async - */ -- (void)blockingRegisterDatabaseExtensions; -#endif - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Messaging/OWSFailedAttachmentDownloadsJob.m b/SignalUtilitiesKit/Messaging/OWSFailedAttachmentDownloadsJob.m deleted file mode 100644 index e653a69c1..000000000 --- a/SignalUtilitiesKit/Messaging/OWSFailedAttachmentDownloadsJob.m +++ /dev/null @@ -1,142 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSFailedAttachmentDownloadsJob.h" -#import "OWSPrimaryStorage.h" -#import "TSAttachmentPointer.h" -#import -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -static NSString *const OWSFailedAttachmentDownloadsJobAttachmentStateColumn = @"state"; -static NSString *const OWSFailedAttachmentDownloadsJobAttachmentStateIndex = @"index_attachment_downloads_on_state"; - -@interface OWSFailedAttachmentDownloadsJob () - -@property (nonatomic, readonly) OWSPrimaryStorage *primaryStorage; - -@end - -#pragma mark - - -@implementation OWSFailedAttachmentDownloadsJob - -- (instancetype)initWithPrimaryStorage:(OWSPrimaryStorage *)primaryStorage -{ - self = [super init]; - if (!self) { - return self; - } - - _primaryStorage = primaryStorage; - - return self; -} - -- (NSArray *)fetchAttemptingOutAttachmentIdsWithTransaction: - (YapDatabaseReadWriteTransaction *_Nonnull)transaction -{ - OWSAssertDebug(transaction); - - NSMutableArray *attachmentIds = [NSMutableArray new]; - - NSString *formattedString = [NSString stringWithFormat:@"WHERE %@ != %d", - OWSFailedAttachmentDownloadsJobAttachmentStateColumn, - (int)TSAttachmentPointerStateFailed]; - YapDatabaseQuery *query = [YapDatabaseQuery queryWithFormat:formattedString]; - [[transaction ext:OWSFailedAttachmentDownloadsJobAttachmentStateIndex] - enumerateKeysMatchingQuery:query - usingBlock:^void(NSString *collection, NSString *key, BOOL *stop) { - [attachmentIds addObject:key]; - }]; - - return [attachmentIds copy]; -} - -- (void)enumerateAttemptingOutAttachmentsWithBlock:(void (^_Nonnull)(TSAttachmentPointer *attachment))block - transaction:(YapDatabaseReadWriteTransaction *_Nonnull)transaction -{ - OWSAssertDebug(transaction); - - // Since we can't directly mutate the enumerated attachments, we store only their ids in hopes - // of saving a little memory and then enumerate the (larger) TSAttachment objects one at a time. - for (NSString *attachmentId in [self fetchAttemptingOutAttachmentIdsWithTransaction:transaction]) { - TSAttachmentPointer *_Nullable attachment = - [TSAttachmentPointer fetchObjectWithUniqueID:attachmentId transaction:transaction]; - if ([attachment isKindOfClass:[TSAttachmentPointer class]]) { - block(attachment); - } else { - OWSLogError(@"unexpected object: %@", attachment); - } - } -} - -- (void)run -{ - __block uint count = 0; - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) { - [self enumerateAttemptingOutAttachmentsWithBlock:^(TSAttachmentPointer *attachment) { - // sanity check - if (attachment.state != TSAttachmentPointerStateFailed) { - attachment.state = TSAttachmentPointerStateFailed; - [attachment saveWithTransaction:transaction]; - count++; - } - } - transaction:transaction]; - }]; - - OWSLogDebug(@"Marked %u attachments as unsent", count); -} - -#pragma mark - YapDatabaseExtension - -+ (YapDatabaseSecondaryIndex *)indexDatabaseExtension -{ - YapDatabaseSecondaryIndexSetup *setup = [YapDatabaseSecondaryIndexSetup new]; - [setup addColumn:OWSFailedAttachmentDownloadsJobAttachmentStateColumn - withType:YapDatabaseSecondaryIndexTypeInteger]; - - YapDatabaseSecondaryIndexHandler *handler = - [YapDatabaseSecondaryIndexHandler withObjectBlock:^(YapDatabaseReadTransaction *transaction, - NSMutableDictionary *dict, - NSString *collection, - NSString *key, - id object) { - if (![object isKindOfClass:[TSAttachmentPointer class]]) { - return; - } - TSAttachmentPointer *attachment = (TSAttachmentPointer *)object; - dict[OWSFailedAttachmentDownloadsJobAttachmentStateColumn] = @(attachment.state); - }]; - - return [[YapDatabaseSecondaryIndex alloc] initWithSetup:setup handler:handler versionTag:nil]; -} - -#ifdef DEBUG -// Useful for tests, don't use in app startup path because it's slow. -- (void)blockingRegisterDatabaseExtensions -{ - [self.primaryStorage registerExtension:[self.class indexDatabaseExtension] - withName:OWSFailedAttachmentDownloadsJobAttachmentStateIndex]; -} -#endif - -+ (NSString *)databaseExtensionName -{ - return OWSFailedAttachmentDownloadsJobAttachmentStateIndex; -} - -+ (void)asyncRegisterDatabaseExtensionsWithPrimaryStorage:(OWSStorage *)storage -{ - [storage asyncRegisterExtension:[self indexDatabaseExtension] - withName:OWSFailedAttachmentDownloadsJobAttachmentStateIndex]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Messaging/OWSFailedMessagesJob.h b/SignalUtilitiesKit/Messaging/OWSFailedMessagesJob.h deleted file mode 100644 index 7a5bd0d6a..000000000 --- a/SignalUtilitiesKit/Messaging/OWSFailedMessagesJob.h +++ /dev/null @@ -1,31 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@class OWSPrimaryStorage; -@class OWSStorage; - -@interface OWSFailedMessagesJob : NSObject - -- (instancetype)init NS_UNAVAILABLE; -- (instancetype)initWithPrimaryStorage:(OWSPrimaryStorage *)primaryStorage NS_DESIGNATED_INITIALIZER; - -- (void)run; - -+ (NSString *)databaseExtensionName; -+ (void)asyncRegisterDatabaseExtensionsWithPrimaryStorage:(OWSStorage *)storage; - -#ifdef DEBUG -/** - * Only use the sync version for testing, generally we'll want to register extensions async - */ -- (void)blockingRegisterDatabaseExtensions; -#endif - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Messaging/OWSFailedMessagesJob.m b/SignalUtilitiesKit/Messaging/OWSFailedMessagesJob.m deleted file mode 100644 index e5e972b6b..000000000 --- a/SignalUtilitiesKit/Messaging/OWSFailedMessagesJob.m +++ /dev/null @@ -1,149 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSFailedMessagesJob.h" -#import "OWSPrimaryStorage.h" -#import "TSMessage.h" -#import "TSOutgoingMessage.h" -#import -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -static NSString *const OWSFailedMessagesJobMessageStateColumn = @"message_state"; -static NSString *const OWSFailedMessagesJobMessageStateIndex = @"index_outoing_messages_on_message_state"; - -@interface OWSFailedMessagesJob () - -@property (nonatomic, readonly) OWSPrimaryStorage *primaryStorage; - -@end - -#pragma mark - - -@implementation OWSFailedMessagesJob - -- (instancetype)initWithPrimaryStorage:(OWSPrimaryStorage *)primaryStorage -{ - self = [super init]; - if (!self) { - return self; - } - - _primaryStorage = primaryStorage; - - return self; -} - -- (NSArray *)fetchAttemptingOutMessageIdsWithTransaction: - (YapDatabaseReadWriteTransaction *_Nonnull)transaction -{ - OWSAssertDebug(transaction); - - NSMutableArray *messageIds = [NSMutableArray new]; - - NSString *formattedString = [NSString - stringWithFormat:@"WHERE %@ == %d", OWSFailedMessagesJobMessageStateColumn, (int)TSOutgoingMessageStateSending]; - YapDatabaseQuery *query = [YapDatabaseQuery queryWithFormat:formattedString]; - [[transaction ext:OWSFailedMessagesJobMessageStateIndex] - enumerateKeysMatchingQuery:query - usingBlock:^void(NSString *collection, NSString *key, BOOL *stop) { - if (key == nil) { return; } - [messageIds addObject:key]; - }]; - - return [messageIds copy]; -} - -- (void)enumerateAttemptingOutMessagesWithBlock:(void (^_Nonnull)(TSOutgoingMessage *message))block - transaction:(YapDatabaseReadWriteTransaction *_Nonnull)transaction -{ - OWSAssertDebug(transaction); - - // Since we can't directly mutate the enumerated "attempting out" expired messages, we store only their ids in hopes - // of saving a little memory and then enumerate the (larger) TSMessage objects one at a time. - for (NSString *expiredMessageId in [self fetchAttemptingOutMessageIdsWithTransaction:transaction]) { - TSOutgoingMessage *_Nullable message = - [TSOutgoingMessage fetchObjectWithUniqueID:expiredMessageId transaction:transaction]; - if ([message isKindOfClass:[TSOutgoingMessage class]]) { - block(message); - } else { - OWSLogError(@"unexpected object: %@", message); - } - } -} - -- (void)run -{ - __block uint count = 0; - - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) { - [self enumerateAttemptingOutMessagesWithBlock:^(TSOutgoingMessage *message) { - // sanity check - OWSAssertDebug(message.messageState == TSOutgoingMessageStateSending); - if (message.messageState != TSOutgoingMessageStateSending) { - OWSLogError(@"Refusing to mark as unsent message with state: %d", (int)message.messageState); - return; - } - - OWSLogDebug(@"marking message as unsent: %@", message.uniqueId); - [message updateWithAllSendingRecipientsMarkedAsFailedWithTansaction:transaction]; - OWSAssertDebug(message.messageState == TSOutgoingMessageStateFailed); - - count++; - } - transaction:transaction]; - }]; - - OWSLogDebug(@"Marked %u messages as unsent", count); -} - -#pragma mark - YapDatabaseExtension - -+ (YapDatabaseSecondaryIndex *)indexDatabaseExtension -{ - YapDatabaseSecondaryIndexSetup *setup = [YapDatabaseSecondaryIndexSetup new]; - [setup addColumn:OWSFailedMessagesJobMessageStateColumn withType:YapDatabaseSecondaryIndexTypeInteger]; - - YapDatabaseSecondaryIndexHandler *handler = - [YapDatabaseSecondaryIndexHandler withObjectBlock:^(YapDatabaseReadTransaction *transaction, - NSMutableDictionary *dict, - NSString *collection, - NSString *key, - id object) { - if (![object isKindOfClass:[TSOutgoingMessage class]]) { - return; - } - TSOutgoingMessage *message = (TSOutgoingMessage *)object; - - dict[OWSFailedMessagesJobMessageStateColumn] = @(message.messageState); - }]; - - return [[YapDatabaseSecondaryIndex alloc] initWithSetup:setup handler:handler versionTag:nil]; -} - -#ifdef DEBUG -// Useful for tests, don't use in app startup path because it's slow. -- (void)blockingRegisterDatabaseExtensions -{ - [self.primaryStorage registerExtension:[self.class indexDatabaseExtension] - withName:OWSFailedMessagesJobMessageStateIndex]; -} -#endif - -+ (NSString *)databaseExtensionName -{ - return OWSFailedMessagesJobMessageStateIndex; -} - -+ (void)asyncRegisterDatabaseExtensionsWithPrimaryStorage:(OWSStorage *)storage -{ - [storage asyncRegisterExtension:[self indexDatabaseExtension] withName:OWSFailedMessagesJobMessageStateIndex]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h b/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h index aa3215500..f66c49737 100644 --- a/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h +++ b/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h @@ -24,8 +24,6 @@ FOUNDATION_EXPORT const unsigned char SignalUtilitiesKitVersionString[]; #import #import #import -#import -#import #import #import #import diff --git a/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift b/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift index 9220f1b35..4979eabca 100644 --- a/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift +++ b/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift @@ -7,23 +7,33 @@ import SessionMessagingKit @objc(LKProfilePictureView) public final class ProfilePictureView: UIView { + public static func closedGroupProfileQuery(threadId: String, userPublicKey: String) -> QueryInterfaceRequest { + return Profile + .filter(Profile.Columns.id != userPublicKey) + .joining( + required: Profile.groupMembers + .filter(GroupMember.Columns.groupId == threadId) + ) + .order(.id) + .limit(2) + } + private var hasTappableProfilePicture: Bool = false @objc public var size: CGFloat = 0 // Not an implicitly unwrapped optional due to Obj-C limitations - @objc public var useFallbackPicture = false - @objc public var publicKey: String! - @objc public var additionalPublicKey: String? - @objc public var openGroupProfilePicture: UIImage? + // Constraints private var imageViewWidthConstraint: NSLayoutConstraint! private var imageViewHeightConstraint: NSLayoutConstraint! private var additionalImageViewWidthConstraint: NSLayoutConstraint! private var additionalImageViewHeightConstraint: NSLayoutConstraint! - // MARK: Components + // MARK: - Components + private lazy var imageView = getImageView() private lazy var additionalImageView = getImageView() - // MARK: Lifecycle + // MARK: - Lifecycle + public override init(frame: CGRect) { super.init(frame: frame) setUpViewHierarchy() @@ -39,144 +49,209 @@ public final class ProfilePictureView: UIView { addSubview(imageView) imageView.pin(.leading, to: .leading, of: self) imageView.pin(.top, to: .top, of: self) + let imageViewSize = CGFloat(Values.mediumProfilePictureSize) imageViewWidthConstraint = imageView.set(.width, to: imageViewSize) imageViewHeightConstraint = imageView.set(.height, to: imageViewSize) + // Set up additional image view addSubview(additionalImageView) additionalImageView.pin(.trailing, to: .trailing, of: self) additionalImageView.pin(.bottom, to: .bottom, of: self) + let additionalImageViewSize = CGFloat(Values.smallProfilePictureSize) additionalImageViewWidthConstraint = additionalImageView.set(.width, to: additionalImageViewSize) additionalImageViewHeightConstraint = additionalImageView.set(.height, to: additionalImageViewSize) additionalImageView.layer.cornerRadius = additionalImageViewSize / 2 } - // MARK: Updating + // MARK: - Updating @objc(updateForContact:) - public func update(for publicKey: String) { // TODO: Confirm this is still used - GRDBStorage.shared.read { db in update(db, publicKey: publicKey) } - } + public func update(for publicKey: String?) { + guard let publicKey: String = publicKey else { return } - public func update(_ db: Database, publicKey: String) { - openGroupProfilePicture = nil - self.publicKey = publicKey - additionalPublicKey = nil - useFallbackPicture = false - update(db) - } - - public func update(_ db: Database, thread: SessionThread) { - openGroupProfilePicture = nil - - switch thread.variant { - case .contact: update(db, publicKey: thread.id) - - case .closedGroup: - let userPublicKey: String = getUserHexEncodedPublicKey(db) - var randomUsers: [String] = (try? thread.closedGroup - .fetchOne(db)? - .members - .fetchAll(db) - .map { $0.profileId } - .filter { $0 != userPublicKey } - .sorted()) // Sort to provide a level of stability - .defaulting(to: []) - - if randomUsers.count == 1 { - // Ensure the current user is at the back visually - randomUsers.insert(userPublicKey, at: 0) - } - - publicKey = (randomUsers.first ?? "") - additionalPublicKey = (randomUsers.count >= 2 ? randomUsers[1] : "") - useFallbackPicture = false - update(db) - - case .openGroup: - openGroupProfilePicture = (try? thread.openGroup - .fetchOne(db)? - .imageData) - .map { UIImage(data: $0) } - publicKey = "" - useFallbackPicture = (openGroupProfilePicture == nil) - hasTappableProfilePicture = (openGroupProfilePicture != nil) - update(db) + let profile: Profile? = GRDBStorage.shared.read { db in + try? Profile.fetchOne(db, id: publicKey) } - } - - @objc public func update() { // TODO: Confirm this is still used - GRDBStorage.shared.read { db in update(db) } + + update( + publicKey: publicKey, + profile: profile, + threadVariant: .contact + ) } - public func update(_ db: Database) { + @objc(updateForThreadId:) + public func update(forThreadId threadId: String?) { + guard + let threadId: String = threadId, + let (thread, profiles, imageData) = GRDBStorage.shared.read({ db -> (SessionThread, [Profile], Data?) in + guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { + throw GRDBStorageError.objectNotFound + } + + switch thread.variant { + case .contact: + return ( + thread, + [try? Profile.fetchOne(db, id: thread.id)].compactMap { $0 }, + nil + ) + + case .closedGroup: + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let randomUsers: [Profile] = (try? ProfilePictureView + .closedGroupProfileQuery(threadId: thread.id, userPublicKey: userPublicKey) + .fetchAll(db)) + .defaulting(to: []) + + // If there is only a single user in the group then insert the current user + // at the back + if randomUsers.count == 1 { + return ( + thread, + randomUsers.inserting( + Profile.fetchOrCreateCurrentUser(db), + at: 0 + ), + nil + ) + } + + return (thread, randomUsers, nil) + + case .openGroup: + return ( + thread, + [], + try? thread.openGroup + .select(OpenGroup.Columns.imageData) + .asRequest(of: Data.self) + .fetchOne(db) + ) + } + }) + else { return } + + update( + publicKey: (imageData != nil ? "" : thread.id), + profile: profiles.first, + additionalProfile: profiles.last, + threadVariant: thread.variant, + openGroupProfilePicture: imageData.map { UIImage(data: $0) }, + useFallbackPicture: (thread.variant == .openGroup && imageData == nil) + ) + } + + public func update( + publicKey: String = "", + profile: Profile? = nil, + additionalProfile: Profile? = nil, + threadVariant: SessionThread.Variant, + openGroupProfilePicture: UIImage? = nil, + useFallbackPicture: Bool = false + ) { AssertIsOnMainThread() - func getProfilePicture(of size: CGFloat, for publicKey: String) -> UIImage? { - guard !publicKey.isEmpty else { return nil } - - if let profilePicture: UIImage = ProfileManager.profileAvatar(db, id: publicKey) { - hasTappableProfilePicture = true - return profilePicture - } - - hasTappableProfilePicture = false - // TODO: Pass in context? - let displayName: String = Profile.displayName(db, id: publicKey) - return Identicon.generatePlaceholderIcon(seed: publicKey, text: displayName, size: size) - } - - let size: CGFloat - if let additionalPublicKey = additionalPublicKey, !useFallbackPicture, openGroupProfilePicture == nil { - if self.size == 40 { - size = 32 - } else if self.size == Values.largeProfilePictureSize { - size = 56 - } else { - size = Values.smallProfilePictureSize - } - - imageViewWidthConstraint.constant = size - imageViewHeightConstraint.constant = size - additionalImageViewWidthConstraint.constant = size - additionalImageViewHeightConstraint.constant = size - additionalImageView.isHidden = false - additionalImageView.image = getProfilePicture(of: size, for: additionalPublicKey) - } - else { - size = self.size - imageViewWidthConstraint.constant = size - imageViewHeightConstraint.constant = size - additionalImageView.isHidden = true - additionalImageView.image = nil - } - - guard publicKey != nil || openGroupProfilePicture != nil else { return } - - imageView.image = useFallbackPicture ? nil : (openGroupProfilePicture ?? getProfilePicture(of: size, for: publicKey)) - imageView.backgroundColor = useFallbackPicture ? UIColor(rgbHex: 0x353535) : Colors.unimportant - imageView.layer.cornerRadius = size / 2 - additionalImageView.layer.cornerRadius = size / 2 - imageView.contentMode = useFallbackPicture ? .center : .scaleAspectFit - - if useFallbackPicture { - switch size { + guard !useFallbackPicture else { + switch self.size { case Values.smallProfilePictureSize.. (image: UIImage, isTappable: Bool) { + if let profile: Profile = profile, let profilePicture: UIImage = ProfileManager.profileAvatar(profile: profile) { + return (profilePicture, true) + } + + return ( + Identicon.generatePlaceholderIcon( + seed: publicKey, + text: (profile?.displayName(for: threadVariant)) + .defaulting(to: publicKey), + size: size + ), + false + ) + } + + // Calulate the sizes (and set the additional image content + let targetSize: CGFloat + if let additionalProfile: Profile = additionalProfile, openGroupProfilePicture == nil { + if self.size == 40 { + targetSize = 32 + } + else if self.size == Values.largeProfilePictureSize { + targetSize = 56 + } + else { + targetSize = Values.smallProfilePictureSize + } + + imageViewWidthConstraint.constant = targetSize + imageViewHeightConstraint.constant = targetSize + additionalImageViewWidthConstraint.constant = targetSize + additionalImageViewHeightConstraint.constant = targetSize + additionalImageView.isHidden = false + additionalImageView.image = getProfilePicture( + of: targetSize, + for: additionalProfile.id, + profile: additionalProfile + ).image + } + else { + targetSize = self.size + imageViewWidthConstraint.constant = targetSize + imageViewHeightConstraint.constant = targetSize + additionalImageView.isHidden = true + additionalImageView.image = nil + } + + // Set the image + if let openGroupProfilePicture: UIImage = openGroupProfilePicture { + imageView.image = openGroupProfilePicture + hasTappableProfilePicture = true + } + else { + let (image, isTappable): (UIImage, Bool) = getProfilePicture( + of: targetSize, + for: publicKey, + profile: profile + ) + imageView.image = image + hasTappableProfilePicture = isTappable + } + + imageView.contentMode = .scaleAspectFit + imageView.backgroundColor = Colors.unimportant + imageView.layer.cornerRadius = (targetSize / 2) + additionalImageView.layer.cornerRadius = (targetSize / 2) } - // MARK: Convenience + // MARK: - Convenience + private func getImageView() -> UIImageView { let result = UIImageView() result.layer.masksToBounds = true result.backgroundColor = Colors.unimportant result.contentMode = .scaleAspectFit + return result } @objc public func getProfilePicture() -> UIImage? { - return hasTappableProfilePicture ? imageView.image : nil + return (hasTappableProfilePicture ? imageView.image : nil) } } diff --git a/SignalUtilitiesKit/Utilities/AppSetup.m b/SignalUtilitiesKit/Utilities/AppSetup.m index a95fd0e95..9a512565b 100644 --- a/SignalUtilitiesKit/Utilities/AppSetup.m +++ b/SignalUtilitiesKit/Utilities/AppSetup.m @@ -7,9 +7,6 @@ #import "VersionMigrations.h" #import #import -#import -#import -#import #import #import #import @@ -48,12 +45,6 @@ NS_ASSUME_NONNULL_BEGIN OWSPreferences *preferences = [OWSPreferences new]; TSAccountManager *tsAccountManager = [[TSAccountManager alloc] initWithPrimaryStorage:primaryStorage]; - OWSDisappearingMessagesJob *disappearingMessagesJob = - [[OWSDisappearingMessagesJob alloc] initWithPrimaryStorage:primaryStorage]; - OWSReadReceiptManager *readReceiptManager = - [[OWSReadReceiptManager alloc] initWithPrimaryStorage:primaryStorage]; - OWSOutgoingReceiptManager *outgoingReceiptManager = - [[OWSOutgoingReceiptManager alloc] initWithPrimaryStorage:primaryStorage]; id reachabilityManager = [SSKReachabilityManagerImpl new]; id typingIndicators = [[OWSTypingIndicatorsImpl alloc] init]; diff --git a/SignalUtilitiesKit/Utilities/ThreadUtil.m b/SignalUtilitiesKit/Utilities/ThreadUtil.m index c2b61eb2d..34ddf9398 100644 --- a/SignalUtilitiesKit/Utilities/ThreadUtil.m +++ b/SignalUtilitiesKit/Utilities/ThreadUtil.m @@ -7,7 +7,6 @@ #import "OWSUnreadIndicator.h" #import #import -#import #import #import #import From 0db74ce1e3d1d874a4ee469f7e42e32eb65867c9 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Sun, 8 May 2022 22:01:39 +1000 Subject: [PATCH 071/157] Working on the MediaGallery and ClosedGroup handling Fixed a couple of issues around the duplicate messages handling Fixed a few issues with ClosedGroup polling and ClosedGroup control message handling Started working through updating the MediaGallery --- Session.xcodeproj/project.pbxproj | 8 + Session/Closed Groups/EditClosedGroupVC.swift | 333 +++++--- Session/Closed Groups/NewClosedGroupVC.swift | 129 +-- .../ConversationVC+Interaction.swift | 332 +++++--- Session/Conversations/ConversationVC.swift | 774 ++++++++++-------- .../OWSConversationSettingsViewController.h | 2 +- .../OWSConversationSettingsViewController.m | 316 +++---- .../Settings/ProfilePictureVC.swift | 6 +- .../ConversationTitleView.swift | 64 +- Session/Home/HomeViewModel.swift | 33 +- .../MediaDetailViewController.swift | 3 + .../MediaGalleryViewModel.swift | 3 + .../PhotoCollectionPickerController.swift | 2 +- .../PhotoGridViewCell.swift | 28 +- .../PhotoLibrary.swift | 8 +- Session/Meta/SessionApp.swift | 24 +- Session/Notifications/AppNotifications.swift | 37 +- Session/Shared/ConversationCell.swift | 4 +- Session/Shared/UserCell.swift | 81 +- Session/Shared/UserSelectionVC.swift | 56 +- Session/Utilities/BackgroundPoller.swift | 3 +- .../LegacyDatabase/SMKLegacyModels.swift | 4 +- .../_001_InitialSetupMigration.swift | 18 +- .../Migrations/_003_YDBToGRDBMigration.swift | 58 +- .../Database/Models/Attachment.swift | 42 +- .../Database/Models/ClosedGroup.swift | 2 +- .../Database/Models/Contact.swift | 34 +- .../Models/ControlMessageProcessRecord.swift | 190 ++++- .../DisappearingMessageConfiguration.swift | 97 ++- .../Database/Models/GroupMember.swift | 36 + .../Database/Models/Interaction.swift | 2 +- .../Database/Models/OpenGroup.swift | 40 + .../Database/Models/Profile.swift | 45 +- .../Database/Models/SessionThread.swift | 87 +- .../Jobs/Types/MessageReceiveJob.swift | 6 +- .../DataExtractionNotification.swift | 2 +- .../MessageReceiver+Handling.swift | 37 +- .../Sending & Receiving/MessageReceiver.swift | 54 +- .../Sending & Receiving/MessageSender.swift | 42 +- .../Pollers/ClosedGroupPoller.swift | 112 ++- .../Sending & Receiving/Pollers/Poller.swift | 3 +- .../NSENotificationPresenter.swift | 6 +- .../NotificationServiceExtension.swift | 6 +- .../General/Set+Utilities.swift | 9 + .../AttachmentPrepViewController.swift | 2 +- .../OWSVideoPlayer.swift | 10 +- .../Messaging/BlockListUIUtils.swift | 14 +- ...ModalActivityIndicatorViewController.swift | 10 +- .../Shared Views/GalleryRailView.swift | 6 +- SignalUtilitiesKit/Utilities/UIView+OWS.swift | 39 +- 50 files changed, 2089 insertions(+), 1170 deletions(-) create mode 100644 Session/Media Viewing & Editing/MediaDetailViewController.swift create mode 100644 Session/Media Viewing & Editing/MediaGalleryViewModel.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 32db73521..eb189d562 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -727,6 +727,8 @@ FD09799B27FFC82D00936362 /* Quote.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799A27FFC82D00936362 /* Quote.swift */; }; FD09C5E2282212B3000CE219 /* JobDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E1282212B3000CE219 /* JobDependencies.swift */; }; FD09C5E428237209000CE219 /* MessageRequestsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E328237209000CE219 /* MessageRequestsViewModel.swift */; }; + FD09C5E628260FF9000CE219 /* MediaGalleryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E528260FF9000CE219 /* MediaGalleryViewModel.swift */; }; + FD09C5E828264937000CE219 /* MediaDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E728264937000CE219 /* MediaDetailViewController.swift */; }; FD17D79927F40AB800122BE0 /* _003_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79827F40AB800122BE0 /* _003_YDBToGRDBMigration.swift */; }; FD17D79C27F40B2E00122BE0 /* SMKLegacyModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */; }; FD17D7A027F40CC800122BE0 /* _001_InitialSetupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79F27F40CC800122BE0 /* _001_InitialSetupMigration.swift */; }; @@ -1782,6 +1784,8 @@ FD09799A27FFC82D00936362 /* Quote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Quote.swift; sourceTree = ""; }; FD09C5E1282212B3000CE219 /* JobDependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobDependencies.swift; sourceTree = ""; }; FD09C5E328237209000CE219 /* MessageRequestsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsViewModel.swift; sourceTree = ""; }; + FD09C5E528260FF9000CE219 /* MediaGalleryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGalleryViewModel.swift; sourceTree = ""; }; + FD09C5E728264937000CE219 /* MediaDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaDetailViewController.swift; sourceTree = ""; }; FD17D79527F3E04600122BE0 /* _001_InitialSetupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _001_InitialSetupMigration.swift; sourceTree = ""; }; FD17D79827F40AB800122BE0 /* _003_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _003_YDBToGRDBMigration.swift; sourceTree = ""; }; FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMKLegacyModels.swift; sourceTree = ""; }; @@ -2927,6 +2931,8 @@ 34969559219B605E00DCFE74 /* ImagePickerController.swift */, 45B9EE9A200E91FB005D2F2D /* MediaDetailViewController.h */, 45B9EE9B200E91FB005D2F2D /* MediaDetailViewController.m */, + FD09C5E728264937000CE219 /* MediaDetailViewController.swift */, + FD09C5E528260FF9000CE219 /* MediaGalleryViewModel.swift */, 452EC6DE205E9E30000E787C /* MediaGalleryViewController.swift */, 45F32C1D205718B000A300D5 /* MediaPageViewController.swift */, 454A84032059C787008B8C75 /* MediaTileViewController.swift */, @@ -5067,6 +5073,7 @@ 3496955D219B605E00DCFE74 /* PhotoCollectionPickerController.swift in Sources */, 34B0796D1FCF46B100E248C2 /* MainAppContext.m in Sources */, 34A8B3512190A40E00218A25 /* MediaAlbumView.swift in Sources */, + FD09C5E828264937000CE219 /* MediaDetailViewController.swift in Sources */, 7B7CB18B270591630079FF93 /* ShareLogsModal.swift in Sources */, 4C4AEC4520EC343B0020E72B /* DismissableTextField.swift in Sources */, 3496955E219B605E00DCFE74 /* PhotoLibrary.swift in Sources */, @@ -5140,6 +5147,7 @@ 34BECE2E1F7ABCE000D7438D /* GifPickerViewController.swift in Sources */, B84664F5235022F30083A1CD /* MentionUtilities.swift in Sources */, 34D1F0C01F8EC1760066283D /* MessageRecipientStatusUtils.swift in Sources */, + FD09C5E628260FF9000CE219 /* MediaGalleryViewModel.swift in Sources */, FD859F0027C4691300510D0C /* MockDataGenerator.swift in Sources */, C328250F25CA06020062D0A7 /* VoiceMessageView.swift in Sources */, B82B4090239DD75000A248E7 /* RestoreVC.swift in Sources */, diff --git a/Session/Closed Groups/EditClosedGroupVC.swift b/Session/Closed Groups/EditClosedGroupVC.swift index 3faf66b3a..56b7da6b4 100644 --- a/Session/Closed Groups/EditClosedGroupVC.swift +++ b/Session/Closed Groups/EditClosedGroupVC.swift @@ -1,66 +1,77 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import GRDB import PromiseKit +import SessionUIKit import SessionMessagingKit +import SignalUtilitiesKit @objc(SNEditClosedGroupVC) -final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegate { - private let thread: TSGroupThread - private var name = "" - private var zombies: Set = [] - private var membersAndZombies: [String] = [] { didSet { handleMembersChanged() } } +final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate { + private struct GroupMemberDisplayInfo: FetchableRecord, Decodable { + let profileId: String + let role: GroupMember.Role + let profile: Profile? + } + + private let threadId: String + private var originalName: String = "" + private var originalMembersAndZombieIds: Set = [] + private var name: String = "" + private var hasContactsToAdd: Bool = false + private var userPublicKey: String = "" + private var membersAndZombies: [GroupMemberDisplayInfo] = [] + private var adminIds: Set = [] private var isEditingGroupName = false { didSet { handleIsEditingGroupNameChanged() } } private var tableViewHeightConstraint: NSLayoutConstraint! - private lazy var groupPublicKey: String = { - let groupID = thread.groupModel.groupId - return LKGroupUtilities.getDecodedGroupID(groupID) - }() + // MARK: - Components - // MARK: Components private lazy var groupNameLabel: UILabel = { - let result = UILabel() + let result: UILabel = UILabel() result.textColor = Colors.text result.font = .boldSystemFont(ofSize: Values.veryLargeFontSize) result.lineBreakMode = .byTruncatingTail result.textAlignment = .center + return result }() private lazy var groupNameTextField: TextField = { - let result = TextField(placeholder: "Enter a group name", usesDefaultHeight: false) + let result: TextField = TextField(placeholder: "Enter a group name", usesDefaultHeight: false) result.textAlignment = .center + return result }() private lazy var addMembersButton: Button = { - let result = Button(style: .prominentOutline, size: .large) + let result: Button = Button(style: .prominentOutline, size: .large) result.setTitle("Add Members", for: UIControl.State.normal) result.addTarget(self, action: #selector(addMembers), for: UIControl.Event.touchUpInside) result.contentEdgeInsets = UIEdgeInsets(top: 0, leading: Values.mediumSpacing, bottom: 0, trailing: Values.mediumSpacing) + return result }() @objc private lazy var tableView: UITableView = { - let result = UITableView() + let result: UITableView = UITableView() result.dataSource = self result.delegate = self - result.register(UserCell.self, forCellReuseIdentifier: "UserCell") result.separatorStyle = .none result.backgroundColor = .clear result.isScrollEnabled = false + result.register(view: UserCell.self) + return result }() - // MARK: Lifecycle - @objc(initWithThreadID:) - init(with threadID: String) { - var thread: TSGroupThread! - Storage.read { transaction in - thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction)! - } - self.thread = thread + // MARK: - Lifecycle + + @objc(initWithThreadId:) + init(with threadId: String) { + self.threadId = threadId + super.init(nibName: nil, bundle: nil) } @@ -70,27 +81,61 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega override func viewDidLoad() { super.viewDidLoad() + setUpGradientBackground() setUpNavBarStyle() setNavBarTitle("Edit Group") + let backButton = UIBarButtonItem(title: "Back", style: .plain, target: nil, action: nil) backButton.tintColor = Colors.text navigationItem.backBarButtonItem = backButton - func getDisplayName(for publicKey: String) -> String { - return Profile.displayName(for: publicKey) + + let threadId: String = self.threadId + + GRDBStorage.shared.read { [weak self] db in + self?.userPublicKey = getUserHexEncodedPublicKey(db) + self?.name = try ClosedGroup + .select(.name) + .filter(id: threadId) + .asRequest(of: String.self) + .fetchOne(db) + .defaulting(to: "Group") + self?.originalName = (self?.name ?? "") + + let profileAlias: TypedTableAlias = TypedTableAlias() + let allGroupMembers: [GroupMemberDisplayInfo] = try GroupMember + .filter(GroupMember.Columns.groupId == threadId) + .including(optional: GroupMember.profile.aliased(profileAlias)) + .order( + (GroupMember.Columns.role == GroupMember.Role.zombie), // Non-zombies at the top + profileAlias[.nickname], + profileAlias[.name], + GroupMember.Columns.profileId + ) + .asRequest(of: GroupMemberDisplayInfo.self) + .fetchAll(db) + self?.membersAndZombies = allGroupMembers + .filter { $0.role == .standard || $0.role == .zombie } + self?.adminIds = allGroupMembers + .filter { $0.role == .admin } + .map { $0.profileId } + .asSet() + + let uniqueGroupMemberIds: Set = allGroupMembers + .map { $0.profileId } + .asSet() + self?.originalMembersAndZombieIds = uniqueGroupMemberIds + self?.hasContactsToAdd = ((try Profile.fetchCount(db) - uniqueGroupMemberIds.count) > 0) } + setUpViewHierarchy() - // Always show zombies at the bottom - zombies = Storage.shared.getZombieMembers(for: groupPublicKey) - membersAndZombies = GroupUtilities.getClosedGroupMembers(thread).sorted { getDisplayName(for: $0) < getDisplayName(for: $1) } - + zombies.sorted { getDisplayName(for: $0) < getDisplayName(for: $1) } updateNavigationBarButtons() - name = thread.groupModel.groupName! } private func setUpViewHierarchy() { // Group name container - groupNameLabel.text = thread.groupModel.groupName + groupNameLabel.text = name + let groupNameContainer = UIView() groupNameContainer.addSubview(groupNameLabel) groupNameLabel.pin(to: groupNameContainer) @@ -98,6 +143,7 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega groupNameTextField.pin(to: groupNameContainer) groupNameContainer.set(.height, to: 40) groupNameTextField.alpha = 0 + // Top container let topContainer = UIView() topContainer.addSubview(groupNameContainer) @@ -105,19 +151,21 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega topContainer.set(.height, to: 40) let topContainerTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(showEditGroupNameUI)) topContainer.addGestureRecognizer(topContainerTapGestureRecognizer) + // Members label let membersLabel = UILabel() membersLabel.textColor = Colors.text membersLabel.font = .systemFont(ofSize: Values.mediumFontSize) membersLabel.text = "Members" + // Add members button - let hasContactsToAdd = !Set(Contact.fetchAllIds()).subtracting(self.membersAndZombies).isEmpty - if (!hasContactsToAdd) { + if !self.hasContactsToAdd { addMembersButton.isUserInteractionEnabled = false let disabledColor = Colors.text.withAlphaComponent(Values.mediumOpacity) addMembersButton.layer.borderColor = disabledColor.cgColor addMembersButton.setTitleColor(disabledColor, for: UIControl.State.normal) } + // Middle stack view let middleStackView = UIStackView(arrangedSubviews: [ membersLabel, addMembersButton ]) middleStackView.axis = .horizontal @@ -125,8 +173,10 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega middleStackView.layoutMargins = UIEdgeInsets(top: Values.smallSpacing, leading: Values.mediumSpacing, bottom: Values.smallSpacing, trailing: Values.mediumSpacing) middleStackView.isLayoutMarginsRelativeArrangement = true middleStackView.set(.height, to: Values.largeButtonHeight + Values.smallSpacing * 2) + // Table view tableViewHeightConstraint = tableView.set(.height, to: 0) + // Main stack view let mainStackView = UIStackView(arrangedSubviews: [ UIView.vSpacer(Values.veryLargeSpacing), @@ -140,6 +190,7 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega mainStackView.axis = .vertical mainStackView.alignment = .fill mainStackView.set(.width, to: UIScreen.main.bounds.width) + // Scroll view let scrollView = UIScrollView() scrollView.showsVerticalScrollIndicator = false @@ -155,41 +206,48 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "UserCell") as! UserCell - let publicKey = membersAndZombies[indexPath.row] - cell.publicKey = publicKey - cell.isZombie = zombies.contains(publicKey) - let userPublicKey = getUserHexEncodedPublicKey() - let isCurrentUserAdmin = thread.groupModel.groupAdminIds.contains(userPublicKey) - cell.accessory = !isCurrentUserAdmin ? .lock : .none - cell.update() + let cell: UserCell = tableView.dequeue(type: UserCell.self, for: indexPath) + cell.update( + with: membersAndZombies[indexPath.row].profileId, + profile: membersAndZombies[indexPath.row].profile, + isZombie: (membersAndZombies[indexPath.row].role == .zombie), + accessory: (adminIds.contains(userPublicKey) ? + .none : + .lock + ) + ) + return cell } func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { - let userPublicKey = getUserHexEncodedPublicKey() - return thread.groupModel.groupAdminIds.contains(userPublicKey) + return adminIds.contains(userPublicKey) } func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? { - let publicKey = membersAndZombies[indexPath.row] + let profileId: String = self.membersAndZombies[indexPath.row].profileId + let removeAction = UITableViewRowAction(style: .destructive, title: "Remove") { [weak self] _, _ in - guard let self = self, let index = self.membersAndZombies.firstIndex(of: publicKey) else { return } - self.membersAndZombies.remove(at: index) + self?.adminIds.remove(profileId) + self?.membersAndZombies.remove(at: indexPath.row) } removeAction.backgroundColor = Colors.destructive + return [ removeAction ] } - // MARK: Updating + // MARK: - Updating + private func updateNavigationBarButtons() { if isEditingGroupName { let cancelButton = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(handleCancelGroupNameEditingButtonTapped)) cancelButton.tintColor = Colors.text navigationItem.leftBarButtonItem = cancelButton - } else { + } + else { navigationItem.leftBarButtonItem = nil } + let doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(handleDoneButtonTapped)) doneButton.tintColor = Colors.text navigationItem.rightBarButtonItem = doneButton @@ -199,21 +257,25 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega tableViewHeightConstraint.constant = CGFloat(membersAndZombies.count) * 67 tableView.reloadData() } - + private func handleIsEditingGroupNameChanged() { updateNavigationBarButtons() + UIView.animate(withDuration: 0.25) { self.groupNameLabel.alpha = self.isEditingGroupName ? 0 : 1 self.groupNameTextField.alpha = self.isEditingGroupName ? 1 : 0 } + if isEditingGroupName { groupNameTextField.becomeFirstResponder() - } else { + } + else { groupNameTextField.resignFirstResponder() } } - // MARK: Interaction + // MARK: - Interaction + @objc private func showEditGroupNameUI() { isEditingGroupName = true } @@ -225,92 +287,159 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega @objc private func handleDoneButtonTapped() { if isEditingGroupName { updateGroupName() - } else { + } + else { commitChanges() } } private func updateGroupName() { - let name = groupNameTextField.text!.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) - guard !name.isEmpty else { - return showError(title: NSLocalizedString("vc_create_closed_group_group_name_missing_error", comment: "")) + let updatedName: String = groupNameTextField.text + .defaulting(to: "") + .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + + guard !updatedName.isEmpty else { + return showError(title: "vc_create_closed_group_group_name_missing_error".lowercased()) } - guard name.count < 64 else { - return showError(title: NSLocalizedString("vc_create_closed_group_group_name_too_long_error", comment: "")) + guard updatedName.count < 64 else { + return showError(title: "vc_create_closed_group_group_name_too_long_error".localized()) } + isEditingGroupName = false - self.name = name - groupNameLabel.text = name + groupNameLabel.text = updatedName + self.name = updatedName } @objc private func addMembers() { let title = "Add Members" - let userSelectionVC = UserSelectionVC(with: title, excluding: Set(membersAndZombies)) { [weak self] selectedUsers in - guard let self = self else { return } - var members = self.membersAndZombies - members.append(contentsOf: selectedUsers) - func getDisplayName(for publicKey: String) -> String { - return Profile.displayName(for: publicKey) + + let userSelectionVC: UserSelectionVC = UserSelectionVC( + with: title, + excluding: membersAndZombies + .map { $0.profileId } + .asSet() + ) { [weak self] selectedUserIds in + GRDBStorage.shared.read { [weak self] db in + let profileAlias: TypedTableAlias = TypedTableAlias() + let selectedGroupMembers: [GroupMemberDisplayInfo] = try GroupMember + .filter(selectedUserIds.contains(GroupMember.Columns.profileId)) + .including(optional: GroupMember.profile.aliased(profileAlias)) + .asRequest(of: GroupMemberDisplayInfo.self) + .fetchAll(db) + + self?.membersAndZombies = (self?.membersAndZombies ?? []) + .appending(contentsOf: selectedGroupMembers) + .sorted(by: { lhs, rhs in + if lhs.role == .zombie && rhs.role != .zombie { + return false + } + else if lhs.role != .zombie && rhs.role == .zombie { + return true + } + + let lhsDisplayName: String = Profile.displayName( + for: .contact, + id: lhs.profileId, + name: lhs.profile?.name, + nickname: lhs.profile?.nickname + ) + let rhsDisplayName: String = Profile.displayName( + for: .contact, + id: rhs.profileId, + name: rhs.profile?.name, + nickname: rhs.profile?.nickname + ) + + return (lhsDisplayName < rhsDisplayName) + }) + .filter { $0.role == .standard || $0.role == .zombie } + + let uniqueGroupMemberIds: Set = (self?.membersAndZombies ?? []) + .map { $0.profileId } + .asSet() + .inserting(contentsOf: self?.adminIds) + self?.hasContactsToAdd = ((try Profile.fetchCount(db) - uniqueGroupMemberIds.count) > 0) } - self.membersAndZombies = members.sorted { getDisplayName(for: $0) < getDisplayName(for: $1) } - let hasContactsToAdd = !Set(Contact.fetchAllIds()).subtracting(self.membersAndZombies).isEmpty - self.addMembersButton.isUserInteractionEnabled = hasContactsToAdd - let color = hasContactsToAdd ? Colors.accent : Colors.text.withAlphaComponent(Values.mediumOpacity) - self.addMembersButton.layer.borderColor = color.cgColor - self.addMembersButton.setTitleColor(color, for: UIControl.State.normal) + + let color = (self?.hasContactsToAdd == true ? + Colors.accent : + Colors.text.withAlphaComponent(Values.mediumOpacity) + ) + self?.addMembersButton.isUserInteractionEnabled = (self?.hasContactsToAdd == true) + self?.addMembersButton.layer.borderColor = color.cgColor + self?.addMembersButton.setTitleColor(color, for: UIControl.State.normal) } - navigationController!.pushViewController(userSelectionVC, animated: true, completion: nil) + + navigationController?.pushViewController(userSelectionVC, animated: true, completion: nil) } private func commitChanges() { - let popToConversationVC: (EditClosedGroupVC) -> Void = { editVC in - if let conversationVC = editVC.navigationController!.viewControllers.first(where: { $0 is ConversationVC }) { - editVC.navigationController!.popToViewController(conversationVC, animated: true) - } else { - editVC.navigationController!.popViewController(animated: true) + let popToConversationVC: ((EditClosedGroupVC?) -> ()) = { editVC in + guard + let viewControllers: [UIViewController] = editVC?.navigationController?.viewControllers, + let conversationVC: ConversationVC = viewControllers.first(where: { $0 is ConversationVC }) as? ConversationVC + else { + editVC?.navigationController?.popViewController(animated: true) + return } + + editVC?.navigationController?.popToViewController(conversationVC, animated: true) } - let storage = SNMessagingKitConfiguration.shared.storage - let members = Set(self.membersAndZombies) - let name = self.name - let zombies = storage.getZombieMembers(for: groupPublicKey) - guard members != Set(thread.groupModel.groupMemberIds + zombies) || name != thread.groupModel.groupName else { + + let threadId: String = self.threadId + let updatedName: String = self.name + let userPublicKey: String = self.userPublicKey + let updatedMemberIds: Set = self.membersAndZombies + .map { $0.profileId } + .asSet() + + guard updatedMemberIds != self.originalMembersAndZombieIds || updatedName != self.originalName else { return popToConversationVC(self) } - if !members.contains(getUserHexEncodedPublicKey()) { - guard Set(thread.groupModel.groupMemberIds).subtracting([ getUserHexEncodedPublicKey() ]) == members else { - return showError(title: "Couldn't Update Group", message: "Can't leave while adding or removing other members.") + + if !updatedMemberIds.contains(userPublicKey) { + guard self.originalMembersAndZombieIds.removing(userPublicKey) == updatedMemberIds else { + return showError( + title: "Couldn't Update Group", + message: "Can't leave while adding or removing other members." + ) } } - guard members.count <= 100 else { - return showError(title: NSLocalizedString("vc_create_closed_group_too_many_group_members_error", comment: "")) + guard updatedMemberIds.count <= 100 else { + return showError(title: "vc_create_closed_group_too_many_group_members_error".localized()) } - var promise: Promise! - ModalActivityIndicatorViewController.present(fromViewController: navigationController!) { [groupPublicKey, weak self] _ in - Storage.write(with: { transaction in - if !members.contains(getUserHexEncodedPublicKey()) { - promise = MessageSender.leave(groupPublicKey, using: transaction) - } else { - promise = MessageSender.update(groupPublicKey, with: members, name: name, transaction: transaction) + + ModalActivityIndicatorViewController.present(fromViewController: navigationController) { _ in + GRDBStorage.shared + .write { db in + if !updatedMemberIds.contains(userPublicKey) { + return try MessageSender.leave(db, groupPublicKey: threadId) + } + + return try MessageSender.update( + db, + groupPublicKey: threadId, + with: updatedMemberIds, + name: updatedName + ) } - }, completion: { - let _ = promise.done(on: DispatchQueue.main) { - guard let self = self else { return } - self.dismiss(animated: true, completion: nil) // Dismiss the loader + .done(on: DispatchQueue.main) { [weak self] in + self?.dismiss(animated: true, completion: nil) // Dismiss the loader popToConversationVC(self) } - promise.catch(on: DispatchQueue.main) { error in + .catch(on: DispatchQueue.main) { [weak self] error in self?.dismiss(animated: true, completion: nil) // Dismiss the loader self?.showError(title: "Couldn't Update Group", message: error.localizedDescription) } - }) + .retainUntilComplete() } } - // MARK: Convenience + // MARK: - Convenience + private func showError(title: String, message: String = "") { let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil)) + alert.addAction(UIAlertAction(title: "BUTTON_OK".localized(), style: .default, handler: nil)) presentAlert(alert) } } diff --git a/Session/Closed Groups/NewClosedGroupVC.swift b/Session/Closed Groups/NewClosedGroupVC.swift index 9f075b825..3e9b60e43 100644 --- a/Session/Closed Groups/NewClosedGroupVC.swift +++ b/Session/Closed Groups/NewClosedGroupVC.swift @@ -1,15 +1,16 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import GRDB import PromiseKit +import SessionUIKit import SessionMessagingKit private protocol TableViewTouchDelegate { - func tableViewWasTouched(_ tableView: TableView) } -private final class TableView : UITableView { +private final class TableView: UITableView { var touchDelegate: TableViewTouchDelegate? override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { @@ -18,107 +19,127 @@ private final class TableView : UITableView { } } -final class NewClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegate, TableViewTouchDelegate, UITextFieldDelegate, UIScrollViewDelegate { - private let contacts = Contact.fetchAllIds() +final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate, TableViewTouchDelegate, UITextFieldDelegate, UIScrollViewDelegate { + private let contactProfiles: [Profile] = Profile.fetchAllContactProfiles(excludeCurrentUser: true) private var selectedContacts: Set = [] - // MARK: Components - private lazy var nameTextField = TextField(placeholder: NSLocalizedString("vc_create_closed_group_text_field_hint", comment: "")) + // MARK: - Components + + private lazy var nameTextField = TextField(placeholder: "vc_create_closed_group_text_field_hint".localized()) private lazy var tableView: TableView = { - let result = TableView() + let result: TableView = TableView() result.dataSource = self result.delegate = self result.touchDelegate = self - result.register(UserCell.self, forCellReuseIdentifier: "UserCell") result.separatorStyle = .none result.backgroundColor = .clear result.isScrollEnabled = false + result.register(view: UserCell.self) + return result }() - // MARK: Lifecycle + // MARK: - Lifecycle + override func viewDidLoad() { super.viewDidLoad() + setUpGradientBackground() setUpNavBarStyle() + let customTitleFontSize = Values.largeFontSize - setNavBarTitle(NSLocalizedString("vc_create_closed_group_title", comment: ""), customFontSize: customTitleFontSize) + setNavBarTitle("vc_create_closed_group_title".localized(), customFontSize: customTitleFontSize) + // Set up navigation bar buttons let closeButton = UIBarButtonItem(image: #imageLiteral(resourceName: "X"), style: .plain, target: self, action: #selector(close)) closeButton.tintColor = Colors.text navigationItem.leftBarButtonItem = closeButton + let doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(createClosedGroup)) doneButton.tintColor = Colors.text navigationItem.rightBarButtonItem = doneButton + // Set up content setUpViewHierarchy() } private func setUpViewHierarchy() { - if !contacts.isEmpty { - let mainStackView = UIStackView() - mainStackView.axis = .vertical - nameTextField.delegate = self - let nameTextFieldContainer = UIView() - nameTextFieldContainer.addSubview(nameTextField) - nameTextField.pin(.leading, to: .leading, of: nameTextFieldContainer, withInset: Values.largeSpacing) - nameTextField.pin(.top, to: .top, of: nameTextFieldContainer, withInset: Values.mediumSpacing) - nameTextFieldContainer.pin(.trailing, to: .trailing, of: nameTextField, withInset: Values.largeSpacing) - nameTextFieldContainer.pin(.bottom, to: .bottom, of: nameTextField, withInset: Values.largeSpacing) - mainStackView.addArrangedSubview(nameTextFieldContainer) - let separator = UIView() - separator.backgroundColor = Colors.separator - separator.set(.height, to: Values.separatorThickness) - mainStackView.addArrangedSubview(separator) - tableView.set(.height, to: CGFloat(contacts.count * 65)) // A cell is exactly 65 points high - tableView.set(.width, to: UIScreen.main.bounds.width) - mainStackView.addArrangedSubview(tableView) - let scrollView = UIScrollView(wrapping: mainStackView, withInsets: UIEdgeInsets.zero) - scrollView.showsVerticalScrollIndicator = false - scrollView.delegate = self - view.addSubview(scrollView) - scrollView.set(.width, to: UIScreen.main.bounds.width) - scrollView.pin(to: view) - } else { - let explanationLabel = UILabel() + guard !contactProfiles.isEmpty else { + let explanationLabel: UILabel = UILabel() explanationLabel.textColor = Colors.text explanationLabel.font = .systemFont(ofSize: Values.smallFontSize) explanationLabel.numberOfLines = 0 explanationLabel.lineBreakMode = .byWordWrapping explanationLabel.textAlignment = .center explanationLabel.text = NSLocalizedString("vc_create_closed_group_empty_state_message", comment: "") - let createNewPrivateChatButton = Button(style: .prominentOutline, size: .large) + + let createNewPrivateChatButton: Button = Button(style: .prominentOutline, size: .large) createNewPrivateChatButton.setTitle(NSLocalizedString("vc_create_closed_group_empty_state_button_title", comment: ""), for: UIControl.State.normal) createNewPrivateChatButton.addTarget(self, action: #selector(createNewDM), for: UIControl.Event.touchUpInside) createNewPrivateChatButton.set(.width, to: 196) - let stackView = UIStackView(arrangedSubviews: [ explanationLabel, createNewPrivateChatButton ]) + + let stackView: UIStackView = UIStackView(arrangedSubviews: [ explanationLabel, createNewPrivateChatButton ]) stackView.axis = .vertical stackView.spacing = Values.mediumSpacing stackView.alignment = .center view.addSubview(stackView) stackView.center(.horizontal, in: view) + let verticalCenteringConstraint = stackView.center(.vertical, in: view) verticalCenteringConstraint.constant = -16 // Makes things appear centered visually + return } + + let mainStackView: UIStackView = UIStackView() + mainStackView.axis = .vertical + nameTextField.delegate = self + + let nameTextFieldContainer: UIView = UIView() + nameTextFieldContainer.addSubview(nameTextField) + nameTextField.pin(.leading, to: .leading, of: nameTextFieldContainer, withInset: Values.largeSpacing) + nameTextField.pin(.top, to: .top, of: nameTextFieldContainer, withInset: Values.mediumSpacing) + nameTextFieldContainer.pin(.trailing, to: .trailing, of: nameTextField, withInset: Values.largeSpacing) + nameTextFieldContainer.pin(.bottom, to: .bottom, of: nameTextField, withInset: Values.largeSpacing) + mainStackView.addArrangedSubview(nameTextFieldContainer) + + let separator: UIView = UIView() + separator.backgroundColor = Colors.separator + separator.set(.height, to: Values.separatorThickness) + mainStackView.addArrangedSubview(separator) + tableView.set(.height, to: CGFloat(contactProfiles.count * 65)) // A cell is exactly 65 points high + tableView.set(.width, to: UIScreen.main.bounds.width) + mainStackView.addArrangedSubview(tableView) + + let scrollView: UIScrollView = UIScrollView(wrapping: mainStackView, withInsets: UIEdgeInsets.zero) + scrollView.showsVerticalScrollIndicator = false + scrollView.delegate = self + view.addSubview(scrollView) + + scrollView.set(.width, to: UIScreen.main.bounds.width) + scrollView.pin(to: view) } - // MARK: Table View Data Source + // MARK: - Table View Data Source + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return contacts.count + return contactProfiles.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "UserCell") as! UserCell - let publicKey = contacts[indexPath.row] - cell.publicKey = publicKey - let isSelected = selectedContacts.contains(publicKey) - cell.accessory = .tick(isSelected: isSelected) - cell.update() + let cell: UserCell = tableView.dequeue(type: UserCell.self, for: indexPath) + cell.update( + with: contactProfiles[indexPath.row].id, + profile: contactProfiles[indexPath.row], + isZombie: false, + accessory: .tick(isSelected: selectedContacts.contains(contactProfiles[indexPath.row].id)) + ) + return cell } - // MARK: Interaction + // MARK: - Interaction + func textFieldDidEndEditing(_ textField: UITextField) { crossfadeLabel.text = textField.text!.isEmpty ? NSLocalizedString("vc_create_closed_group_title", comment: "") : textField.text! } @@ -139,13 +160,15 @@ final class NewClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegat } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let publicKey = contacts[indexPath.row] - if !selectedContacts.contains(publicKey) { selectedContacts.insert(publicKey) } else { selectedContacts.remove(publicKey) } - guard let cell = tableView.cellForRow(at: indexPath) as? UserCell else { return } - let isSelected = selectedContacts.contains(publicKey) - cell.accessory = .tick(isSelected: isSelected) - cell.update() + if !selectedContacts.contains(contactProfiles[indexPath.row].id) { + selectedContacts.insert(contactProfiles[indexPath.row].id) + } + else { + selectedContacts.remove(contactProfiles[indexPath.row].id) + } + tableView.deselectRow(at: indexPath, animated: true) + tableView.reloadRows(at: [indexPath], with: .none) } @objc private func close() { diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index c97206312..14b5b5c9c 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -9,41 +9,46 @@ import GRDB import SessionUtilitiesKit import SignalUtilitiesKit -extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuActionDelegate, ScrollToBottomButtonDelegate, - SendMediaNavDelegate, UIDocumentPickerDelegate, AttachmentApprovalViewControllerDelegate, GifPickerViewControllerDelegate, - ConversationTitleViewDelegate { +extension ConversationVC: + InputViewDelegate, + MessageCellDelegate, + ScrollToBottomButtonDelegate, + SendMediaNavDelegate, + UIDocumentPickerDelegate, + AttachmentApprovalViewControllerDelegate, + GifPickerViewControllerDelegate +{ + @objc func handleTitleViewTapped() { + // Don't take the user to settings for unapproved threads + guard !viewModel.viewData.requiresApproval else { return } - func handleTitleViewTapped() { - // Don't take the user to settings for message requests - guard - let contactThread: TSContactThread = thread as? TSContactThread, - let contact: Contact = GRDBStorage.shared.read({ db in try Contact.fetchOne(db, id: contactThread.contactSessionID()) }), - contact.isApproved, - contact.didApproveMe - else { - return - } - openSettings() } - + @objc func openSettings() { - let settingsVC = OWSConversationSettingsViewController() - settingsVC.configure(with: thread, uiDatabaseConnection: OWSPrimaryStorage.shared().uiDatabaseConnection) + let settingsVC: OWSConversationSettingsViewController = OWSConversationSettingsViewController() + settingsVC.configure( + withThreadId: viewModel.viewData.thread.id, + threadName: viewModel.viewData.threadName, + isClosedGroup: (viewModel.viewData.thread.variant == .closedGroup), + isOpenGroup: (viewModel.viewData.thread.variant == .openGroup), + isNoteToSelf: viewModel.viewData.threadIsNoteToSelf + ) settingsVC.conversationSettingsViewDelegate = self - navigationController!.pushViewController(settingsVC, animated: true, completion: nil) + navigationController?.pushViewController(settingsVC, animated: true, completion: nil) } + + // MARK: - ScrollToBottomButtonDelegate func handleScrollToBottomButtonTapped() { // The table view's content size is calculated by the estimated height of cells, // so the result may be inaccurate before all the cells are loaded. Use this // to scroll to the last row instead. - let indexPath = IndexPath(row: viewItems.count - 1, section: 0) - unreadViewItems.removeAll() - messagesTableView.scrollToRow(at: indexPath, at: .top, animated: true) + scrollToBottom(isAnimated: true) } - // MARK: Blocking + // MARK: - Blocking + @objc func unblock() { guard let thread = thread as? TSContactThread else { return } let publicKey = thread.contactSessionID() @@ -75,26 +80,18 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc } func showBlockedModalIfNeeded() -> Bool { - guard let thread = thread as? TSContactThread, thread.isBlocked() else { return false } + guard viewModel.viewData.threadIsBlocked else { return false } - let blockedModal = BlockedModal(publicKey: thread.contactSessionID()) + let blockedModal = BlockedModal(publicKey: viewModel.viewData.thread.id) blockedModal.modalPresentationStyle = .overFullScreen blockedModal.modalTransitionStyle = .crossDissolve present(blockedModal, animated: true, completion: nil) + return true } - // MARK: Attachments - func didPasteImageFromPasteboard(_ image: UIImage) { - guard let imageData = image.jpegData(compressionQuality: 1.0) else { return } - let dataSource = DataSourceValue.dataSource(with: imageData, utiType: kUTTypeJPEG as String) - let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: kUTTypeJPEG as String, imageQuality: .medium) - - let approvalVC = AttachmentApprovalViewController.wrappedInNavController(attachments: [ attachment ], approvalDelegate: self) - approvalVC.modalPresentationStyle = .fullScreen - self.present(approvalVC, animated: true, completion: nil) - } - + // MARK: - SendMediaNavDelegate + func sendMediaNavDidCancel(_ sendMediaNavigationController: SendMediaNavigationController) { dismiss(animated: true, completion: nil) } @@ -111,14 +108,16 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc } func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didChangeMessageText newMessageText: String?) { - snInputView.text = newMessageText ?? "" + snInputView.text = (newMessageText ?? "") } + // MARK: - AttachmentApprovalViewControllerDelegate + func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], messageText: String?) { sendAttachments(attachments, with: messageText ?? "") { [weak self] in self?.dismiss(animated: true, completion: nil) } - + scrollToBottom(isAnimated: false) resetMentions() self.snInputView.text = "" @@ -142,6 +141,25 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc sendMediaNavController.sendMediaNavDelegate = self sendMediaNavController.modalPresentationStyle = .fullScreen present(sendMediaNavController, animated: true, completion: nil) + // MARK: - ExpandingAttachmentsButtonDelegate + + func handleGIFButtonTapped() { + let gifVC = GifPickerViewController() + gifVC.delegate = self + + let navController = OWSNavigationController(rootViewController: gifVC) + navController.modalPresentationStyle = .fullScreen + present(navController, animated: true) { } + } + + func handleDocumentButtonTapped() { + // UIDocumentPickerModeImport copies to a temp file within our container. + // It uses more memory than "open" but lets us avoid working with security scoped URLs. + let documentPickerVC = UIDocumentPickerViewController(documentTypes: [ kUTTypeItem as String ], in: UIDocumentPickerMode.import) + documentPickerVC.delegate = self + documentPickerVC.modalPresentationStyle = .fullScreen + SNAppearance.switchToDocumentPickerAppearance() + present(documentPickerVC, animated: true, completion: nil) } func handleLibraryButtonTapped() { @@ -155,14 +173,20 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc } } - func handleGIFButtonTapped() { - let gifVC = GifPickerViewController(thread: thread) - gifVC.delegate = self - let navController = OWSNavigationController(rootViewController: gifVC) - navController.modalPresentationStyle = .fullScreen - present(navController, animated: true) { } + func handleCameraButtonTapped() { + guard requestCameraPermissionIfNeeded() else { return } + requestMicrophonePermissionIfNeeded { } + if AVAudioSession.sharedInstance().recordPermission != .granted { + SNLog("Proceeding without microphone access. Any recorded video will be silent.") + } + let sendMediaNavController = SendMediaNavigationController.showingCameraFirst() + sendMediaNavController.sendMediaNavDelegate = self + sendMediaNavController.modalPresentationStyle = .fullScreen + present(sendMediaNavController, animated: true, completion: nil) } - + + // MARK: - GifPickerViewControllerDelegate + func gifPickerDidSelect(attachment: SignalAttachment) { showAttachmentApprovalDialog(for: [ attachment ]) } @@ -176,6 +200,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc SNAppearance.switchToDocumentPickerAppearance() present(documentPickerVC, animated: true, completion: nil) } + // MARK: - UIDocumentPickerDelegate func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { SNAppearance.switchToSessionAppearance() // Switch back to the correct appearance @@ -242,21 +267,24 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc }.retainUntilComplete() } } + + // MARK: - InputViewDelegate - // MARK: Message Sending + // MARK: --Message Sending func handleSendButtonTapped() { sendMessage() } func sendMessage(hasPermissionToSendSeed: Bool = false) { guard !showBlockedModalIfNeeded() else { return } - + let text = replaceMentions(in: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines)) - let thread = self.thread guard !text.isEmpty else { return } - - if text.contains(mnemonic) && !thread.isNoteToSelf() && !hasPermissionToSendSeed { + + let isNoteToSelf: Bool = GRDBStorage.shared.read { db in viewModel.viewData.thread.isNoteToSelf(db) } + .defaulting(to: false) + if text.contains(mnemonic) && !isNoteToSelf && !hasPermissionToSendSeed { // Warn the user if they're about to send their seed to someone let modal = SendSeedModal() modal.modalPresentationStyle = .overFullScreen @@ -264,118 +292,166 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc modal.proceed = { self.sendMessage(hasPermissionToSendSeed: true) } return present(modal, animated: true, completion: nil) } - - let sentTimestamp: UInt64 = NSDate.millisecondTimestamp() - let message: VisibleMessage = VisibleMessage() - message.sentTimestamp = sentTimestamp - message.text = text - message.quote = VisibleMessage.Quote.from(snInputView.quoteDraftInfo?.model) - + // Note: 'shouldBeVisible' is set to true the first time a thread is saved so we can // use it to determine if the user is creating a new thread and update the 'isApproved' // flags appropriately + let thread: SessionThread = viewModel.viewData.thread let oldThreadShouldBeVisible: Bool = thread.shouldBeVisible - let linkPreviewDraft = snInputView.linkPreviewInfo?.draft - let tsMessage = TSOutgoingMessage.from(message, associatedWith: thread) + let sentTimestampMs: Int64 = Int64(floor((Date().timeIntervalSince1970 * 1000))) + let linkPreviewDraft: OWSLinkPreviewDraft? = snInputView.linkPreviewInfo?.draft + let quoteModel: QuotedReplyModel? = snInputView.quoteDraftInfo?.model - let promise: Promise = self.approveMessageRequestIfNeeded( for: self.thread, + approveMessageRequestIfNeeded( + for: thread, isNewThread: !oldThreadShouldBeVisible, - timestamp: (sentTimestamp - 1) // Set 1ms earlier as this is used for sorting + timestampMs: (sentTimestampMs - 1) // Set 1ms earlier as this is used for sorting ) - .map { [weak self] _ in - self?.viewModel.appendUnsavedOutgoingTextMessage(tsMessage) - - Storage.write(with: { transaction in - message.linkPreview = VisibleMessage.LinkPreview.from(linkPreviewDraft, using: transaction) - }, completion: { [weak self] in - tsMessage.linkPreview = OWSLinkPreview.from(message.linkPreview) - - Storage.shared.write( - with: { transaction in - tsMessage.save(with: transaction as! YapDatabaseReadWriteTransaction) - }, - completion: { [weak self] in - // At this point the TSOutgoingMessage should have its link preview set, so we can scroll to the bottom knowing - // the height of the new message cell - self?.scrollToBottom(isAnimated: false) + .done { [weak self] _ in + GRDBStorage.shared.writeAsync( + updates: { db in + // Update the thread to be visible + _ = try SessionThread + .filter(id: thread.id) + .updateAll(db, SessionThread.Columns.shouldBeVisible.set(to: true)) + + // Create the interaction + let interaction: Interaction = try Interaction( + threadId: thread.id, + authorId: getUserHexEncodedPublicKey(db), + variant: .standardOutgoing, + body: text, + timestampMs: sentTimestampMs, + linkPreviewUrl: linkPreviewDraft?.urlString + ).inserted(db) + + // If there is a LinkPreview add it now + if let linkPreviewDraft: OWSLinkPreviewDraft = linkPreviewDraft { + var attachmentId: String? + + // If the LinkPreview has image data then create an attachment first + if let imageData: Data = linkPreviewDraft.jpegImageData { + attachmentId = try LinkPreview.saveAttachmentIfPossible( + db, + imageData: imageData, + mimeType: OWSMimeTypeImageJpeg + ) + } + + try LinkPreview( + url: linkPreviewDraft.urlString, + title: linkPreviewDraft.title, + attachmentId: attachmentId + ).insert(db) + } + + guard let interactionId: Int64 = interaction.id else { return } + + // If there is a Quote the insert it now + if let quoteModel: QuotedReplyModel = quoteModel { + try Quote( + interactionId: interactionId, + authorId: quoteModel.authorId, + timestampMs: quoteModel.timestampMs, + body: quoteModel.body, + attachmentId: quoteModel.attachment?.id + ).insert(db) + } + + try MessageSender.send( + db, + interaction: interaction, + with: [], + in: thread + ) + }, + completion: { [weak self] _, _ in + // At this point the Interaction should have its link preview set, so we can + // scroll to the bottom knowing the height of the new message cell + DispatchQueue.main.async { + self?.scrollToBottom(isAnimated: false) + self?.handleMessageSent() } - ) - - Storage.shared.write { transaction in - MessageSender.send(message, with: [], in: thread, using: transaction as! YapDatabaseReadWriteTransaction) } - - self?.handleMessageSent() - }) + ) } - - // Show an error indicating that approving the thread failed - promise.catch(on: DispatchQueue.main) { [weak self] _ in + .catch(on: DispatchQueue.main) { [weak self] _ in + // Show an error indicating that approving the thread failed let alert = UIAlertController(title: "Session", message: "An error occurred when trying to accept this message request", preferredStyle: .alert) alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) self?.present(alert, animated: true, completion: nil) } - - promise.retainUntilComplete() + .retainUntilComplete() } func sendAttachments(_ attachments: [SignalAttachment], with text: String, onComplete: (() -> ())? = nil) { guard !showBlockedModalIfNeeded() else { return } - + for attachment in attachments { if attachment.hasError { return showErrorAlert(for: attachment, onDismiss: onComplete) } } - let thread = self.thread - let sentTimestamp: UInt64 = NSDate.millisecondTimestamp() - let message = VisibleMessage() - message.sentTimestamp = sentTimestamp - message.text = replaceMentions(in: text) - + let text = replaceMentions(in: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines)) + // Note: 'shouldBeVisible' is set to true the first time a thread is saved so we can // use it to determine if the user is creating a new thread and update the 'isApproved' // flags appropriately + let thread: SessionThread = viewModel.viewData.thread let oldThreadShouldBeVisible: Bool = thread.shouldBeVisible - let tsMessage = TSOutgoingMessage.from(message, associatedWith: thread) - - let promise: Promise = self.approveMessageRequestIfNeeded( - for: self.thread, + let sentTimestampMs: Int64 = Int64(floor((Date().timeIntervalSince1970 * 1000))) + + approveMessageRequestIfNeeded( + for: thread, isNewThread: !oldThreadShouldBeVisible, - timestamp: (sentTimestamp - 1) // Set 1ms earlier as this is used for sorting + timestampMs: (sentTimestampMs - 1) // Set 1ms earlier as this is used for sorting ) - .map { [weak self] _ in - Storage.write( - with: { transaction in - tsMessage.save(with: transaction) - // The new message cell is inserted at this point, but the TSOutgoingMessage doesn't have its attachment yet + .done { [weak self] _ in + GRDBStorage.shared.writeAsync( + updates: { db in + // Update the thread to be visible + _ = try SessionThread + .filter(id: thread.id) + .updateAll(db, SessionThread.Columns.shouldBeVisible.set(to: true)) + + // Create the interaction + let interaction: Interaction = try Interaction( + threadId: thread.id, + authorId: getUserHexEncodedPublicKey(db), + variant: .standardOutgoing, + body: text, + timestampMs: sentTimestampMs + ).inserted(db) + + try MessageSender.send( + db, + interaction: interaction, + with: attachments, + in: thread + ) }, - completion: { [weak self] in - Storage.write(with: { transaction in - MessageSender.send(message, with: attachments, in: thread, using: transaction) - }, completion: { [weak self] in - // At this point the TSOutgoingMessage should have its attachments set, so we can scroll to the bottom knowing - // the height of the new message cell + completion: { [weak self] _, _ in + // At this point the Interaction should have its link preview set, so we can + // scroll to the bottom knowing the height of the new message cell + DispatchQueue.main.async { self?.scrollToBottom(isAnimated: false) - }) - self?.handleMessageSent() - - // Attachment successfully sent - dismiss the screen - onComplete?() + self?.handleMessageSent() + + // Attachment successfully sent - dismiss the screen + onComplete?() + } } ) } - - // Show an error indicating that approving the thread failed - promise.catch(on: DispatchQueue.main) { [weak self] _ in + .catch(on: DispatchQueue.main) { [weak self] _ in + // Show an error indicating that approving the thread failed let alert = UIAlertController(title: "Session", message: "An error occurred when trying to accept this message request", preferredStyle: .alert) alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) self?.present(alert, animated: true, completion: nil) } - - promise.retainUntilComplete() + .retainUntilComplete() } func handleMessageSent() { @@ -399,7 +475,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc self.markAllAsRead() if Environment.shared.preferences.soundInForeground() { - let soundID = OWSSounds.systemSoundID(for: .messageSent, quiet: true) + let soundID = Preferences.Sound.systemSoundId(for: .messageSent, quiet: true) AudioServicesPlaySystemSound(soundID) } SSKEnvironment.shared.typingIndicators.didSendOutgoingMessage(inThread: thread) @@ -467,6 +543,13 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc currentMentionStartIndex = nil mentions = [] } + + // MARK: --Attachments + + func didPasteImageFromPasteboard(_ image: UIImage) { + guard let imageData = image.jpegData(compressionQuality: 1.0) else { return } + let dataSource = DataSourceValue.dataSource(with: imageData, utiType: kUTTypeJPEG as String) + let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: kUTTypeJPEG as String, imageQuality: .medium) func replaceMentions(in text: String) -> String { var result = text @@ -475,8 +558,13 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc result = result.replacingCharacters(in: range, with: "@\(mention.publicKey)") } return result + let approvalVC = AttachmentApprovalViewController.wrappedInNavController(attachments: [ attachment ], approvalDelegate: self) + approvalVC.modalPresentationStyle = .fullScreen + self.present(approvalVC, animated: true, completion: nil) } + // MARK: --Mentions + func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView) { guard let currentMentionStartIndex = currentMentionStartIndex else { return } mentions.append(mention) diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index b627e4a40..1725b6cb7 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -1,39 +1,48 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import GRDB +import DifferenceKit import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit +import SignalUtilitiesKit // TODO: // • Slight paging glitch when scrolling up and loading more content // • Photo rounding (the small corners don't have the correct rounding) // • Remaining search glitchiness -final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversationSettingsViewDelegate, ConversationSearchControllerDelegate, UITableViewDataSource, UITableViewDelegate { - let thread: TSThread - let threadStartedAsMessageRequest: Bool - let focusedMessageID: String? // This is used for global search +final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, ConversationSearchControllerDelegate, UITableViewDataSource, UITableViewDelegate { + internal let viewModel: ConversationViewModel + private var dataChangeObservable: DatabaseCancellable? + private var hasLoadedInitialData: Bool = false + var focusedMessageIndexPath: IndexPath? var initialUnreadCount: UInt = 0 var unreadViewItems: [ConversationViewItem] = [] var scrollButtonBottomConstraint: NSLayoutConstraint? var scrollButtonMessageRequestsBottomConstraint: NSLayoutConstraint? var messageRequestsViewBotomConstraint: NSLayoutConstraint? + // Search var isShowingSearchUI = false var lastSearchedText: String? + // Audio playback & recording var audioPlayer: OWSAudioPlayer? var audioRecorder: AVAudioRecorder? var audioTimer: Timer? + // Context menu var contextMenuWindow: ContextMenuWindow? var contextMenuVC: ContextMenuVC? + // Mentions var oldText = "" var currentMentionStartIndex: String.Index? var mentions: [Mention] = [] + // Scrolling & paging var isUserScrolling = false var didFinishInitialLayout = false @@ -42,46 +51,44 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat var baselineKeyboardHeight: CGFloat = 0 var audioSession: OWSAudioSession { Environment.shared.audioSession } - var dbConnection: YapDatabaseConnection { OWSPrimaryStorage.shared().uiDatabaseConnection } - var viewItems: [ConversationViewItem] { viewModel.viewState.viewItems } override var canBecomeFirstResponder: Bool { true } - + override var inputAccessoryView: UIView? { - if let thread = thread as? TSGroupThread, thread.groupModel.groupType == .closedGroup && !thread.isCurrentUserMemberInGroup() { - return nil - } else { - return isShowingSearchUI ? searchController.resultsBar : snInputView - } + guard + viewModel.viewData.thread.variant != .closedGroup || + viewModel.viewData.isClosedGroupMember + else { return nil } + + return (isShowingSearchUI ? searchController.resultsBar : snInputView) } /// The height of the visible part of the table view, i.e. the distance from the navigation bar (where the table view's origin is) - /// to the top of the input view (`messagesTableView.adjustedContentInset.bottom`). + /// to the top of the input view (`tableView.adjustedContentInset.bottom`). var tableViewUnobscuredHeight: CGFloat { - let bottomInset = messagesTableView.adjustedContentInset.bottom - return messagesTableView.bounds.height - bottomInset + let bottomInset = tableView.adjustedContentInset.bottom + return tableView.bounds.height - bottomInset } /// The offset at which the table view is exactly scrolled to the bottom. var lastPageTop: CGFloat { - return messagesTableView.contentSize.height - tableViewUnobscuredHeight + return tableView.contentSize.height - tableViewUnobscuredHeight } - + var isCloseToBottom: Bool { - let margin = (self.lastPageTop - self.messagesTableView.contentOffset.y) + let margin = (self.lastPageTop - self.tableView.contentOffset.y) return margin <= ConversationVC.scrollToBottomMargin } - + lazy var mnemonic: String = { if let hexEncodedSeed: String = Identity.fetchHexEncodedSeed() { return Mnemonic.encode(hexEncodedString: hexEncodedSeed) } - + // Legacy account return Mnemonic.encode(hexEncodedString: Identity.fetchUserPrivateKey()!.toHexString()) }() - - lazy var viewModel = ConversationViewModel(thread: thread, focusMessageIdOnOpen: nil, delegate: self) - + + // FIXME: Would be good to create a Swift-based cache and replace this lazy var mediaCache: NSCache = { let result = NSCache() result.countLimit = 40 @@ -89,79 +96,88 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat }() lazy var recordVoiceMessageActivity = AudioActivity(audioDescription: "Voice message", behavior: .playAndRecord) - + lazy var searchController: ConversationSearchController = { - let result = ConversationSearchController(thread: thread) - result.delegate = self - if #available(iOS 13, *) { - result.uiSearchController.obscuresBackgroundDuringPresentation = false - } else { - result.uiSearchController.dimsBackgroundDuringPresentation = false - } - return result - }() - - // MARK: - UI - - private static let messageRequestButtonHeight: CGFloat = 34 - - lazy var titleView: ConversationTitleView = { - let result = ConversationTitleView(thread: thread) + let result: ConversationSearchController = ConversationSearchController() + result.uiSearchController.obscuresBackgroundDuringPresentation = false result.delegate = self + return result }() - lazy var messagesTableView: MessagesTableView = { - let result: MessagesTableView = MessagesTableView() - result.dataSource = self - result.delegate = self + // MARK: - UI + + private static let messageRequestButtonHeight: CGFloat = 34 + + lazy var titleView: ConversationTitleView = { + let result: ConversationTitleView = ConversationTitleView() + let tapGestureRecognizer = UITapGestureRecognizer( + target: self, + action: #selector(handleTitleViewTapped) + ) + result.addGestureRecognizer(tapGestureRecognizer) + + return result + }() + + lazy var tableView: UITableView = { + let result: UITableView = UITableView() + result.separatorStyle = .none + result.backgroundColor = .clear + result.showsVerticalScrollIndicator = false result.contentInsetAdjustmentBehavior = .never + result.keyboardDismissMode = .interactive result.contentInset = UIEdgeInsets( top: 0, leading: 0, bottom: Values.mediumSpacing, trailing: 0 ) + result.register(view: VisibleMessageCell.self) + result.register(view: InfoMessageCell.self) + result.register(view: TypingIndicatorCell.self) + result.dataSource = self + result.delegate = self + + return result + }() + + lazy var snInputView: InputView = InputView( + threadVariant: viewModel.viewData.thread.variant, + delegate: self + ) + + lazy var unreadCountView: UIView = { + let result: UIView = UIView() + result.backgroundColor = Colors.text.withAlphaComponent(Values.veryLowOpacity) + result.set(.width, greaterThanOrEqualTo: ConversationVC.unreadCountViewSize) + result.set(.height, to: ConversationVC.unreadCountViewSize) + result.layer.masksToBounds = true + result.layer.cornerRadius = (ConversationVC.unreadCountViewSize / 2) return result }() - - lazy var snInputView: InputView = InputView(delegate: self) - - lazy var unreadCountView: UIView = { - let result = UIView() - result.backgroundColor = Colors.text.withAlphaComponent(Values.veryLowOpacity) - let size = ConversationVC.unreadCountViewSize - result.set(.width, greaterThanOrEqualTo: size) - result.set(.height, to: size) - result.layer.masksToBounds = true - result.layer.cornerRadius = size / 2 - return result - }() - + lazy var unreadCountLabel: UILabel = { - let result = UILabel() + let result: UILabel = UILabel() result.font = .boldSystemFont(ofSize: Values.verySmallFontSize) result.textColor = Colors.text result.textAlignment = .center + return result }() - + lazy var blockedBanner: InfoBanner = { - let name: String - if let thread = thread as? TSContactThread { - name = Profile.displayName(for: thread.contactSessionID(), thread: thread) - } - else { - name = "Thread" - } - let message = "\(name) is blocked. Unblock them?" - let result = InfoBanner(message: message, backgroundColor: Colors.destructive) + let result: InfoBanner = InfoBanner( + message: viewModel.blockedBannerMessage, + backgroundColor: Colors.destructive + ) let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(unblock)) result.addGestureRecognizer(tapGestureRecognizer) + return result }() - + lazy var footerControlsStackView: UIStackView = { let result: UIStackView = UIStackView() result.translatesAutoresizingMaskIntoConstraints = false @@ -171,21 +187,21 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat result.spacing = 10 result.layoutMargins = UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20) result.isLayoutMarginsRelativeArrangement = true - + return result }() - - lazy var scrollButton = ScrollToBottomButton(delegate: self) - + + lazy var scrollButton: ScrollToBottomButton = ScrollToBottomButton(delegate: self) + lazy var messageRequestView: UIView = { let result: UIView = UIView() result.translatesAutoresizingMaskIntoConstraints = false - result.isHidden = !thread.isMessageRequest() + result.isHidden = !viewModel.viewData.threadIsMessageRequest result.setGradient(Gradients.defaultBackground) - + return result }() - + private let messageRequestDescriptionLabel: UILabel = { let result: UILabel = UILabel() result.translatesAutoresizingMaskIntoConstraints = false @@ -194,10 +210,10 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat result.textColor = Colors.sessionMessageRequestsInfoText result.textAlignment = .center result.numberOfLines = 2 - + return result }() - + private let messageRequestAcceptButton: UIButton = { let result: UIButton = UIButton() result.translatesAutoresizingMaskIntoConstraints = false @@ -220,15 +236,15 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat with: UITraitCollection(userInterfaceStyle: isDarkMode ? .dark : .light) ).cgColor } - + return Colors.sessionHeading.cgColor }() result.layer.borderWidth = 1 result.addTarget(self, action: #selector(acceptMessageRequest), for: .touchUpInside) - + return result }() - + private let messageRequestDeleteButton: UIButton = { let result: UIButton = UIButton() result.translatesAutoresizingMaskIntoConstraints = false @@ -251,16 +267,17 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat with: UITraitCollection(userInterfaceStyle: isDarkMode ? .dark : .light) ).cgColor } - + return Colors.destructive.cgColor }() result.layer.borderWidth = 1 result.addTarget(self, action: #selector(deleteMessageRequest), for: .touchUpInside) - + return result }() + + // MARK: - Settings - // MARK: Settings static let unreadCountViewSize: CGFloat = 20 /// The table view's bottom inset (content will have this distance to the bottom if the table view is fully scrolled down). static let bottomInset = Values.mediumSpacing @@ -272,47 +289,55 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat static let scrollButtonNoVisibilityThreshold: CGFloat = 20 /// Automatically scroll to the bottom of the conversation when sending a message if the scroll distance from the bottom is less than this number. static let scrollToBottomMargin: CGFloat = 60 + + // MARK: - Initialization - // MARK: Lifecycle - init(thread: TSThread, focusedMessageID: String? = nil) { - self.thread = thread - self.threadStartedAsMessageRequest = thread.isMessageRequest() - self.focusedMessageID = focusedMessageID - super.init(nibName: nil, bundle: nil) - Storage.read { transaction in - self.initialUnreadCount = self.thread.unreadMessageCount(transaction: transaction) + init?(threadId: String, focusedInteractionId: Int64? = nil) { + guard let viewModel: ConversationViewModel = ConversationViewModel(threadId: threadId, focusedInteractionId: focusedInteractionId) else { + return nil } - let clampedUnreadCount = min(self.initialUnreadCount, UInt(kConversationInitialMaxRangeSize), UInt(viewItems.endIndex)) - unreadViewItems = clampedUnreadCount != 0 ? [ConversationViewItem](viewItems[viewItems.endIndex - Int(clampedUnreadCount) ..< viewItems.endIndex]) : [] + + self.viewModel = viewModel + + super.init(nibName: nil, bundle: nil) } - + required init?(coder: NSCoder) { preconditionFailure("Use init(thread:) instead.") } + deinit { + NotificationCenter.default.removeObserver(self) + } + + // MARK: - Lifecycle + override func viewDidLoad() { super.viewDidLoad() + // Gradient setUpGradientBackground() + // Nav bar setUpNavBarStyle() navigationItem.titleView = titleView - updateNavBarButtons() + updateNavBarButtons(viewData: viewModel.viewData) + // Constraints - view.addSubview(messagesTableView) - messagesTableView.pin(to: view) - + view.addSubview(tableView) + tableView.pin(to: view) + // Blocked banner - addOrRemoveBlockedBanner() - + addOrRemoveBlockedBanner(threadIsBlocked: viewModel.viewData.threadIsBlocked) + // Message requests view & scroll to bottom view.addSubview(scrollButton) view.addSubview(messageRequestView) - + messageRequestView.addSubview(messageRequestDescriptionLabel) messageRequestView.addSubview(messageRequestAcceptButton) messageRequestView.addSubview(messageRequestDeleteButton) - + scrollButton.pin(.right, to: .right, of: view, withInset: -20) messageRequestView.pin(.left, to: .left, of: view) messageRequestView.pin(.right, to: .right, of: view) @@ -320,30 +345,30 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat self.scrollButtonBottomConstraint = scrollButton.pin(.bottom, to: .bottom, of: view, withInset: -16) self.scrollButtonBottomConstraint?.isActive = false // Note: Need to disable this to avoid a conflict with the other bottom constraint self.scrollButtonMessageRequestsBottomConstraint = scrollButton.pin(.bottom, to: .top, of: messageRequestView, withInset: -16) - self.scrollButtonMessageRequestsBottomConstraint?.isActive = thread.isMessageRequest() - self.scrollButtonBottomConstraint?.isActive = !thread.isMessageRequest() + self.scrollButtonMessageRequestsBottomConstraint?.isActive = viewModel.viewData.threadIsMessageRequest + self.scrollButtonBottomConstraint?.isActive = !viewModel.viewData.threadIsMessageRequest messageRequestDescriptionLabel.pin(.top, to: .top, of: messageRequestView, withInset: 10) messageRequestDescriptionLabel.pin(.left, to: .left, of: messageRequestView, withInset: 40) messageRequestDescriptionLabel.pin(.right, to: .right, of: messageRequestView, withInset: -40) - + messageRequestAcceptButton.pin(.top, to: .bottom, of: messageRequestDescriptionLabel, withInset: 20) messageRequestAcceptButton.pin(.left, to: .left, of: messageRequestView, withInset: 20) messageRequestAcceptButton.pin(.bottom, to: .bottom, of: messageRequestView) messageRequestAcceptButton.set(.height, to: ConversationVC.messageRequestButtonHeight) - + messageRequestAcceptButton.pin(.top, to: .bottom, of: messageRequestDescriptionLabel, withInset: 20) messageRequestAcceptButton.pin(.left, to: .left, of: messageRequestView, withInset: 20) messageRequestAcceptButton.pin(.bottom, to: .bottom, of: messageRequestView) messageRequestAcceptButton.set(.height, to: ConversationVC.messageRequestButtonHeight) - + messageRequestDeleteButton.pin(.top, to: .bottom, of: messageRequestDescriptionLabel, withInset: 20) messageRequestDeleteButton.pin(.left, to: .right, of: messageRequestAcceptButton, withInset: 20) messageRequestDeleteButton.pin(.right, to: .right, of: messageRequestView, withInset: -20) messageRequestDeleteButton.pin(.bottom, to: .bottom, of: messageRequestView) messageRequestDeleteButton.set(.width, to: .width, of: messageRequestAcceptButton) messageRequestDeleteButton.set(.height, to: ConversationVC.messageRequestButtonHeight) - + // Unread count view view.addSubview(unreadCountView) unreadCountView.addSubview(unreadCountLabel) @@ -353,169 +378,263 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat unreadCountView.pin(.trailing, to: .trailing, of: unreadCountLabel, withInset: 4) unreadCountView.centerYAnchor.constraint(equalTo: scrollButton.topAnchor).isActive = true unreadCountView.center(.horizontal, in: scrollButton) - updateUnreadCountView() - + updateUnreadCountView(unreadCount: viewModel.viewData.unreadCount) + // Notifications - let notificationCenter = NotificationCenter.default - notificationCenter.addObserver(self, selector: #selector(handleKeyboardWillChangeFrameNotification(_:)), name: UIResponder.keyboardWillChangeFrameNotification, object: nil) - notificationCenter.addObserver(self, selector: #selector(handleKeyboardWillHideNotification(_:)), name: UIResponder.keyboardWillHideNotification, object: nil) - notificationCenter.addObserver(self, selector: #selector(handleAudioDidFinishPlayingNotification(_:)), name: .SNAudioDidFinishPlaying, object: nil) - notificationCenter.addObserver(self, selector: #selector(addOrRemoveBlockedBanner), name: .contactBlockedStateChanged, object: nil) - notificationCenter.addObserver(self, selector: #selector(handleGroupUpdatedNotification), name: .groupThreadUpdated, object: nil) - notificationCenter.addObserver(self, selector: #selector(sendScreenshotNotificationIfNeeded), name: UIApplication.userDidTakeScreenshotNotification, object: nil) - notificationCenter.addObserver(self, selector: #selector(handleMessageSentStatusChanged), name: .messageSentStatusDidChange, object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationDidBecomeActive(_:)), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationDidResignActive(_:)), + name: UIApplication.didEnterBackgroundNotification, object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleKeyboardWillChangeFrameNotification(_:)), + name: UIResponder.keyboardWillChangeFrameNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleKeyboardWillHideNotification(_:)), + name: UIResponder.keyboardWillHideNotification, + object: nil + ) // Mentions - MentionsManager.populateUserPublicKeyCacheIfNeeded(for: thread.uniqueId!) + MentionsManager.populateUserPublicKeyCacheIfNeeded(for: viewModel.viewData.thread.id) + // Draft - var draft = "" - Storage.read { transaction in - draft = self.thread.currentDraft(with: transaction) - } - if !draft.isEmpty { + if let draft: String = viewModel.viewData.thread.messageDraft, !draft.isEmpty { snInputView.text = draft } + + // Update the input state + snInputView.setEnabledMessageTypes(viewModel.viewData.enabledMessageTypes, message: nil) + + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() - // Update the input state if this is a contact thread - if let contactThread: TSContactThread = thread as? TSContactThread { - let contact: Contact? = GRDBStorage.shared.read { db in try Contact.fetchOne(db, id: contactThread.contactSessionID()) } + guard !didFinishInitialLayout else { return } + + // Scroll to the last unread message if possible; otherwise scroll to the bottom. + // When the unread message count is more than the number of view items of a page, + // the screen will scroll to the bottom instead of the first unread message. + // unreadIndicatorIndex is calculated during loading of the viewItems, so it's + // supposed to be accurate. + DispatchQueue.main.async { + if let focusedInteractionId: Int64 = self.viewModel.focusedInteractionId { + self.scrollToInteraction(with: focusedInteractionId, isAnimated: false, highlighted: true) + } + else if let firstUnreadInteractionId: Int64 = self.viewModel.firstUnreadInteractionId { + self.scrollToInteraction(with: firstUnreadInteractionId, position: .top, isAnimated: false) + self.unreadCountView.alpha = self.scrollButton.alpha + } + else { + self.scrollToBottom(isAnimated: false) + } - // If the contact doesn't exist yet then it's a message request without the first message sent - // so only allow text-based messages - self.snInputView.setEnabledMessageTypes( - (thread.isNoteToSelf() || contact?.didApproveMe == true || thread.isMessageRequest() ? - .all : .textOnly - ), + self.scrollButton.alpha = self.getScrollButtonOpacity() + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + startObservingChanges() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + highlightFocusedMessageIfNeeded() + didFinishInitialLayout = true + viewModel.markAllAsRead() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + // Stop observing database changes + dataChangeObservable?.cancel() + viewModel.updateDraft(to: snInputView.text) + inputAccessoryView?.resignFirstResponder() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + mediaCache.removeAllObjects() + } + + @objc func applicationDidBecomeActive(_ notification: Notification) { + startObservingChanges() + } + + @objc func applicationDidResignActive(_ notification: Notification) { + // Stop observing database changes + dataChangeObservable?.cancel() + } + + // MARK: - Updating + + private func startObservingChanges() { + // Start observing for data changes + dataChangeObservable = GRDBStorage.shared.start( + viewModel.observableViewData, + onError: { error in + }, + onChange: { [weak self] viewData in + // The default scheduler emits changes on the main thread + self?.handleUpdates(viewData) + } + ) + } + + private func handleUpdates(_ updatedViewData: ConversationViewModel.ViewData, initialLoad: Bool = false) { + // 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, initialLoad: true) } + return + } + // Update general conversation UI + + if + initialLoad || + viewModel.viewData.threadName != updatedViewData.threadName || + viewModel.viewData.thread.mutedUntilTimestamp != updatedViewData.thread.mutedUntilTimestamp || + viewModel.viewData.thread.onlyNotifyForMentions != updatedViewData.thread.onlyNotifyForMentions || + viewModel.viewData.userCount != updatedViewData.userCount + { + titleView.update( + with: updatedViewData.threadName, + mutedUntilTimestamp: updatedViewData.thread.mutedUntilTimestamp, + onlyNotifyForMentions: updatedViewData.thread.onlyNotifyForMentions, + userCount: updatedViewData.userCount + ) + } + + if + initialLoad || + viewModel.viewData.requiresApproval != updatedViewData.requiresApproval || + viewModel.viewData.threadAvatarProfiles != updatedViewData.threadAvatarProfiles + { + updateNavBarButtons(viewData: updatedViewData) + } + + if initialLoad || viewModel.viewData.enabledMessageTypes != updatedViewData.enabledMessageTypes { + snInputView.setEnabledMessageTypes( + updatedViewData.enabledMessageTypes, message: nil ) } - // Update member count if this is a V2 open group - if let v2OpenGroup = Storage.shared.getV2OpenGroup(for: thread.uniqueId!) { - OpenGroupAPIV2.getMemberCount(for: v2OpenGroup.room, on: v2OpenGroup.server).retainUntilComplete() + if initialLoad || viewModel.viewData.threadIsBlocked != updatedViewData.threadIsBlocked { + addOrRemoveBlockedBanner(threadIsBlocked: updatedViewData.threadIsBlocked) } - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - if !didFinishInitialLayout { - // Scroll to the last unread message if possible; otherwise scroll to the bottom. - // When the unread message count is more than the number of view items of a page, - // the screen will scroll to the bottom instead of the first unread message. - // unreadIndicatorIndex is calculated during loading of the viewItems, so it's - // supposed to be accurate. - DispatchQueue.main.async { - if let focusedMessageID = self.focusedMessageID { - self.scrollToInteraction(with: focusedMessageID, isAnimated: false, highlighted: true) - } else { - let firstUnreadMessageIndex = self.viewModel.viewState.unreadIndicatorIndex?.intValue - ?? (self.viewItems.count - self.unreadViewItems.count) - if self.initialUnreadCount > 0, let viewItem = self.viewItems[ifValid: firstUnreadMessageIndex], let interactionID = viewItem.interaction.uniqueId { - self.scrollToInteraction(with: interactionID, position: .top, isAnimated: false) - self.unreadCountView.alpha = self.scrollButton.alpha - } else { - self.scrollToBottom(isAnimated: false) - } - } - self.scrollButton.alpha = self.getScrollButtonOpacity() - } - } - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - highlightFocusedMessageIfNeeded() - didFinishInitialLayout = true - markAllAsRead() - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - let text = snInputView.text - Storage.write { transaction in - self.thread.setDraft(text, transaction: transaction) - } - inputAccessoryView?.resignFirstResponder() - } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - mediaCache.removeAllObjects() - } - - deinit { - NotificationCenter.default.removeObserver(self) - } - - // MARK: Table View Data Source - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return viewItems.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let viewItem = viewItems[indexPath.row] - let cell = tableView.dequeueReusableCell(withIdentifier: MessageCell.getCellType(for: viewItem).identifier) as! MessageCell - cell.delegate = self - cell.thread = thread - cell.viewItem = viewItem - return cell - } - - // MARK: Updating - - func updateNavBarButtons() { - navigationItem.hidesBackButton = isShowingSearchUI + if initialLoad || viewModel.viewData.unreadCount != updatedViewData.unreadCount { + updateUnreadCountView(unreadCount: updatedViewData.unreadCount) + } + + // Reload the table content (animate changes after the first load) + let changeset = StagedChangeset(source: viewModel.viewData.items, target: updatedViewData.items) + tableView.reload( + using: StagedChangeset(source: viewModel.viewData.items, target: updatedViewData.items), + interrupt: { + return $0.changeCount > 100 + } // Prevent too many changes from causing performance issues + ) { [weak self] items in + self?.viewModel.updateData(updatedViewData.with(items: items)) + } + + // Scroll to the bottom if we just sent a message or are close enough + // to the bottom + + // Only if it was an insert + if + changeset.contains(where: { !$0.elementInserted.isEmpty }) && ( + updatedViewData.items.last?.interactionVariant == .standardOutgoing || + isCloseToBottom + ) + { + scrollToBottom(isAnimated: true) + } + + // Mark received messages as read + viewModel.markAllAsRead() + } + + func updateNavBarButtons(viewData: ConversationViewModel.ViewData) { + navigationItem.hidesBackButton = isShowingSearchUI + if isShowingSearchUI { navigationItem.leftBarButtonItem = nil navigationItem.rightBarButtonItems = [] } else { - if let contactThread: TSContactThread = thread as? TSContactThread { - // Don't show the settings button for message requests - if - let contact: Contact = GRDBStorage.shared.read({ db in try Contact.fetchOne(db, id: contactThread.contactSessionID()) }), - contact.isApproved, - contact.didApproveMe - { - let size = Values.verySmallProfilePictureSize + guard !viewData.requiresApproval else { + // Note: Adding an empty button because without it the title alignment is + // busted (Note: The size was taken from the layout inspector for the back + // button in Xcode + navigationItem.rightBarButtonItem = UIBarButtonItem( + customView: UIView( + frame: CGRect( + x: 0, + y: 0, + width: (44 - 16), // Width of the standard back button + height: 44 + ) + ) + ) + return + } + + switch viewData.thread.variant { + case .contact: let profilePictureView = ProfilePictureView() - profilePictureView.size = size - profilePictureView.update(for: thread) - profilePictureView.set(.width, to: size) - profilePictureView.set(.height, to: size) - + profilePictureView.size = Values.verySmallProfilePictureSize + profilePictureView.update( + publicKey: viewData.thread.id, // Contact thread uses the contactId + profile: viewData.threadAvatarProfiles.first, + threadVariant: viewData.thread.variant + ) + profilePictureView.set(.width, to: (44 - 16)) // Width of the standard back button + profilePictureView.set(.height, to: Values.verySmallProfilePictureSize) + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(openSettings)) profilePictureView.addGestureRecognizer(tapGestureRecognizer) - + let rightBarButtonItem: UIBarButtonItem = UIBarButtonItem(customView: profilePictureView) rightBarButtonItem.accessibilityLabel = "Settings button" rightBarButtonItem.isAccessibilityElement = true - + + navigationItem.rightBarButtonItem = rightBarButtonItem + + default: + let rightBarButtonItem: UIBarButtonItem = UIBarButtonItem(image: UIImage(named: "Gear"), style: .plain, target: self, action: #selector(openSettings)) + rightBarButtonItem.accessibilityLabel = "Settings button" + rightBarButtonItem.isAccessibilityElement = true + navigationItem.rightBarButtonItem = rightBarButtonItem - } - else { - // Note: Adding an empty button because without it the title alignment is busted (Note: The size was - // taken from the layout inspector for the back button in Xcode - navigationItem.rightBarButtonItem = UIBarButtonItem(customView: UIView(frame: CGRect(x: 0, y: 0, width: 37, height: 44))) - } - } - else { - let rightBarButtonItem: UIBarButtonItem = UIBarButtonItem(image: UIImage(named: "Gear"), style: .plain, target: self, action: #selector(openSettings)) - rightBarButtonItem.accessibilityLabel = "Settings button" - rightBarButtonItem.isAccessibilityElement = true - - navigationItem.rightBarButtonItem = rightBarButtonItem } } } private func highlightFocusedMessageIfNeeded() { - if let indexPath = focusedMessageIndexPath, let cell = messagesTableView.cellForRow(at: indexPath) as? VisibleMessageCell { + if let indexPath = focusedMessageIndexPath, let cell = tableView.cellForRow(at: indexPath) as? VisibleMessageCell { cell.highlight() focusedMessageIndexPath = nil } } - + @objc func handleKeyboardWillChangeFrameNotification(_ notification: Notification) { // Please refer to https://github.com/mapbox/mapbox-navigation-ios/issues/1600 // and https://stackoverflow.com/a/25260930 to better understand what we are @@ -525,42 +644,42 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat let curveValue: Int = ((userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int) ?? Int(UIView.AnimationOptions.curveEaseInOut.rawValue)) let options: UIView.AnimationOptions = UIView.AnimationOptions(rawValue: UInt(curveValue << 16)) let keyboardRect: CGRect = ((userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect) ?? CGRect.zero) - + // Calculate new positions (Need the ensure the 'messageRequestView' has been layed out as it's // needed for proper calculations, so force an initial layout if it doesn't have a size) var hasDoneLayout: Bool = true - + if messageRequestView.bounds.height <= CGFloat.leastNonzeroMagnitude { hasDoneLayout = false - + UIView.performWithoutAnimation { self.view.layoutIfNeeded() } } - + let keyboardTop = (UIScreen.main.bounds.height - keyboardRect.minY) let messageRequestsOffset: CGFloat = (messageRequestView.isHidden ? 0 : messageRequestView.bounds.height + 16) - let oldContentInset: UIEdgeInsets = messagesTableView.contentInset + let oldContentInset: UIEdgeInsets = tableView.contentInset let newContentInset: UIEdgeInsets = UIEdgeInsets( top: 0, leading: 0, bottom: (Values.mediumSpacing + keyboardTop + messageRequestsOffset), trailing: 0 ) - let newContentOffsetY: CGFloat = (messagesTableView.contentOffset.y + (newContentInset.bottom - oldContentInset.bottom)) + let newContentOffsetY: CGFloat = (tableView.contentOffset.y + (newContentInset.bottom - oldContentInset.bottom)) let changes = { [weak self] in self?.scrollButtonBottomConstraint?.constant = -(keyboardTop + 16) self?.messageRequestsViewBotomConstraint?.constant = -(keyboardTop + 16) - self?.messagesTableView.contentInset = newContentInset - self?.messagesTableView.contentOffset.y = newContentOffsetY - + self?.tableView.contentInset = newContentInset + self?.tableView.contentOffset.y = newContentOffsetY + let scrollButtonOpacity: CGFloat = (self?.getScrollButtonOpacity() ?? 0) self?.scrollButton.alpha = scrollButtonOpacity - + self?.view.setNeedsLayout() self?.view.layoutIfNeeded() } - + // Perform the changes (don't animate if the initial layout hasn't been completed) guard hasDoneLayout else { UIView.performWithoutAnimation { @@ -568,7 +687,7 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat } return } - + UIView.animate( withDuration: duration, delay: 0, @@ -577,7 +696,7 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat completion: nil ) } - + @objc func handleKeyboardWillHideNotification(_ notification: Notification) { // Please refer to https://github.com/mapbox/mapbox-navigation-ios/issues/1600 // and https://stackoverflow.com/a/25260930 to better understand what we are @@ -586,10 +705,10 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat let duration = ((userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval) ?? 0) let curveValue: Int = ((userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int) ?? Int(UIView.AnimationOptions.curveEaseInOut.rawValue)) let options: UIView.AnimationOptions = UIView.AnimationOptions(rawValue: UInt(curveValue << 16)) - + let keyboardRect: CGRect = ((userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect) ?? CGRect.zero) let keyboardTop = (UIScreen.main.bounds.height - keyboardRect.minY) - + UIView.animate( withDuration: duration, delay: 0, @@ -597,11 +716,11 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat animations: { [weak self] in self?.scrollButtonBottomConstraint?.constant = -(keyboardTop + 16) self?.messageRequestsViewBotomConstraint?.constant = -(keyboardTop + 16) - + let scrollButtonOpacity: CGFloat = (self?.getScrollButtonOpacity() ?? 0) self?.scrollButton.alpha = scrollButtonOpacity self?.unreadCountView.alpha = scrollButtonOpacity - + self?.view.setNeedsLayout() self?.view.layoutIfNeeded() }, @@ -702,49 +821,54 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat thread.reload() // Needed so that thread.isCurrentUserMemberInGroup() is up to date reloadInputViews() } - + @objc private func handleMessageSentStatusChanged() { DispatchQueue.main.async { - guard let indexPaths = self.messagesTableView.indexPathsForVisibleRows else { return } + guard let indexPaths = self.tableView.indexPathsForVisibleRows else { return } var indexPathsToReload: [IndexPath] = [] for indexPath in indexPaths { - guard let cell = self.messagesTableView.cellForRow(at: indexPath) as? VisibleMessageCell else { continue } - let isLast = (indexPath.item == (self.messagesTableView.numberOfRows(inSection: 0) - 1)) + guard let cell = self.tableView.cellForRow(at: indexPath) as? VisibleMessageCell else { continue } + let isLast = (indexPath.item == (self.tableView.numberOfRows(inSection: 0) - 1)) guard !isLast else { continue } if !cell.messageStatusImageView.isHidden { indexPathsToReload.append(indexPath) } } UIView.performWithoutAnimation { - self.messagesTableView.reloadRows(at: indexPathsToReload, with: .none) + self.tableView.reloadRows(at: indexPathsToReload, with: .none) } } } - + // MARK: - General - - @objc func addOrRemoveBlockedBanner() { - DispatchQueue.main.async { - guard let thread = self.thread as? TSContactThread, thread.isBlocked() else { - self.blockedBanner.removeFromSuperview() - return - } - - self.view.addSubview(self.blockedBanner) - self.blockedBanner.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.HorizontalEdge.right ], to: self.view) + + func addOrRemoveBlockedBanner(threadIsBlocked: Bool) { + guard threadIsBlocked else { + self.blockedBanner.removeFromSuperview() + return } + + self.view.addSubview(self.blockedBanner) + self.blockedBanner.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.HorizontalEdge.right ], to: self.view) + } + + // MARK: - UITableViewDataSource + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return viewModel.viewData.items.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let item: ConversationViewModel.Item = viewModel.viewData.items[indexPath.row] + let cell: MessageCell = tableView.dequeue(type: MessageCell.cellType(for: item), for: indexPath) + cell.update(with: item, mediaCache: mediaCache, lastSearchText: viewModel.viewData.lastSearchedText) + cell.delegate = self + + return cell } - func markAllAsRead() { - guard let lastSortID = viewItems.last?.interaction.sortId else { return } - OWSReadReceiptManager.shared().markAsReadLocally( - beforeSortId: lastSortID, - thread: thread, - trySendReadReceipt: !thread.isMessageRequest() - ) - SSKEnvironment.shared.disappearingMessagesJob.cleanupMessagesWhichFailedToStartExpiringFromNow() - } - + // MARK: - UITableViewDelegate + func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { return UITableView.automaticDimension } @@ -753,43 +877,39 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat return UITableView.automaticDimension } - func getMediaCache() -> NSCache { - return mediaCache - } - func scrollToBottom(isAnimated: Bool) { - guard !isUserScrolling && !viewItems.isEmpty else { return } - messagesTableView.scrollToRow(at: IndexPath(row: viewItems.count - 1, section: 0), at: .bottom, animated: isAnimated) + guard !isUserScrolling && !viewModel.viewData.items.isEmpty else { return } + + tableView.scrollToRow( + at: IndexPath( + row: viewModel.viewData.items.count - 1, + section: 0), + at: .bottom, + animated: isAnimated + ) } - + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { isUserScrolling = true } - + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { isUserScrolling = false } - + func scrollViewDidScroll(_ scrollView: UIScrollView) { scrollButton.alpha = getScrollButtonOpacity() unreadCountView.alpha = scrollButton.alpha autoLoadMoreIfNeeded() - updateUnreadCountView() } - - func updateUnreadCountView() { - let visibleViewItems = (messagesTableView.indexPathsForVisibleRows ?? []).map { viewItems[ifValid: $0.row] } - for visibleItem in visibleViewItems { - guard let index = unreadViewItems.firstIndex(where: { $0 === visibleItem }) else { continue } - unreadViewItems.remove(at: index) - } - let unreadCount = unreadViewItems.count - unreadCountLabel.text = unreadCount < 10000 ? "\(unreadCount)" : "9999+" - let fontSize = (unreadCount < 10000) ? Values.verySmallFontSize : 8 + + func updateUnreadCountView(unreadCount: Int) { + let fontSize: CGFloat = (unreadCount < 10000 ? Values.verySmallFontSize : 8) + unreadCountLabel.text = (unreadCount < 10000 ? "\(unreadCount)" : "9999+") unreadCountLabel.font = .boldSystemFont(ofSize: fontSize) unreadCountView.isHidden = (unreadCount == 0) } - + func autoLoadMoreIfNeeded() { let isMainAppAndActive = CurrentAppContext().isMainAppAndActive guard isMainAppAndActive && didFinishInitialLayout && viewModel.canLoadMoreItems() && !isLoadingMore @@ -797,18 +917,18 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat isLoadingMore = true viewModel.loadAnotherPageOfMessages() } - + func getScrollButtonOpacity() -> CGFloat { - let contentOffsetY = messagesTableView.contentOffset.y + let contentOffsetY = tableView.contentOffset.y let x = (lastPageTop - ConversationVC.bottomInset - contentOffsetY).clamp(0, .greatestFiniteMagnitude) let a = 1 / (ConversationVC.scrollButtonFullVisibilityThreshold - ConversationVC.scrollButtonNoVisibilityThreshold) return a * x } - + func groupWasUpdated(_ groupModel: TSGroupModel) { // Not currently in use } - + // MARK: Search func conversationSettingsDidRequestConversationSearch(_ conversationSettingsViewController: OWSConversationSettingsViewController) { showSearchUI() @@ -818,7 +938,7 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat } } } - + func popAllConversationSettingsViews(completion completionBlock: (() -> Void)? = nil) { if presentedViewController != nil { dismiss(animated: true) { @@ -828,15 +948,18 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat navigationController!.popToViewController(self, animated: true, completion: completionBlock) } } - + func showSearchUI() { isShowingSearchUI = true + // Search bar let searchBar = searchController.uiSearchController.searchBar searchBar.setUpSessionStyle() navigationItem.titleView = searchBar + // Nav bar buttons - updateNavBarButtons() + updateNavBarButtons(viewData: viewModel.viewData) + // Hack so that the ResultsBar stays on the screen when dismissing the search field // keyboard. // @@ -867,35 +990,36 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat let navBar = navigationController!.navigationBar as! OWSNavigationBar navBar.stubbedNextResponder = self } - + func hideSearchUI() { isShowingSearchUI = false navigationItem.titleView = titleView - updateNavBarButtons() + updateNavBarButtons(viewData: viewModel.viewData) + let navBar = navigationController!.navigationBar as! OWSNavigationBar navBar.stubbedNextResponder = nil becomeFirstResponder() reloadInputViews() } - + func didDismissSearchController(_ searchController: UISearchController) { hideSearchUI() } - + func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults resultSet: ConversationScreenSearchResultSet?) { lastSearchedText = resultSet?.searchText - messagesTableView.reloadRows(at: messagesTableView.indexPathsForVisibleRows ?? [], with: UITableView.RowAnimation.none) + tableView.reloadRows(at: tableView.indexPathsForVisibleRows ?? [], with: UITableView.RowAnimation.none) } - - func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectMessageId interactionID: String) { - scrollToInteraction(with: interactionID) + + func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectInteractionId interactionId: Int64) { + scrollToInteraction(with: interactionId) } - - func scrollToInteraction(with interactionID: String, position: UITableView.ScrollPosition = .middle, isAnimated: Bool = true, highlighted: Bool = false) { - guard let indexPath = viewModel.ensureLoadWindowContainsInteractionId(interactionID) else { return } - messagesTableView.scrollToRow(at: indexPath, at: position, animated: isAnimated) - if highlighted { - focusedMessageIndexPath = indexPath - } + + func scrollToInteraction( + with interactionId: Int64, + position: UITableView.ScrollPosition = .middle, + isAnimated: Bool = true, + highlighted: Bool = false + ) { } } diff --git a/Session/Conversations/Settings/OWSConversationSettingsViewController.h b/Session/Conversations/Settings/OWSConversationSettingsViewController.h index 7d7542008..a072027a1 100644 --- a/Session/Conversations/Settings/OWSConversationSettingsViewController.h +++ b/Session/Conversations/Settings/OWSConversationSettingsViewController.h @@ -17,7 +17,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic) BOOL showVerificationOnAppear; -- (void)configureWithThread:(TSThread *)thread uiDatabaseConnection:(YapDatabaseConnection *)uiDatabaseConnection; +- (void)configureWithThreadId:(NSString *)threadId threadName:(nullable NSString *)threadName isClosedGroup:(BOOL)isClosedGroup isOpenGroup:(BOOL)isOpenGroup isNoteToSelf:(BOOL)isNoteToSelf; @end diff --git a/Session/Conversations/Settings/OWSConversationSettingsViewController.m b/Session/Conversations/Settings/OWSConversationSettingsViewController.m index 2da6b6b56..0d7749665 100644 --- a/Session/Conversations/Settings/OWSConversationSettingsViewController.m +++ b/Session/Conversations/Settings/OWSConversationSettingsViewController.m @@ -11,11 +11,8 @@ #import #import #import -#import #import #import -#import -#import #import #import #import @@ -30,11 +27,20 @@ CGFloat kIconViewLength = 24; @interface OWSConversationSettingsViewController () -@property (nonatomic) TSThread *thread; +@property (nonatomic) NSString *threadId; +@property (nonatomic) NSString *threadName; +@property (nonatomic) BOOL isNoteToSelf; +@property (nonatomic) BOOL isClosedGroup; +@property (nonatomic) BOOL isOpenGroup; @property (nonatomic) YapDatabaseConnection *uiDatabaseConnection; @property (nonatomic, readonly) YapDatabaseConnection *editingDatabaseConnection; @property (nonatomic) NSArray *disappearingMessagesDurations; -@property (nonatomic) OWSDisappearingMessagesConfiguration *disappearingMessagesConfiguration; + +@property (nonatomic) BOOL originalIsDisappearingMessagesEnabled; +@property (nonatomic) NSInteger originalDisappearingMessagesDurationIndex; +@property (nonatomic) BOOL isDisappearingMessagesEnabled; +@property (nonatomic) NSInteger disappearingMessagesDurationIndex; + @property (nullable, nonatomic) MediaGallery *mediaGallery; @property (nonatomic, readonly) UIImageView *avatarView; @property (nonatomic, readonly) UILabel *disappearingMessagesDurationLabel; @@ -96,15 +102,6 @@ CGFloat kIconViewLength = 24; [[NSNotificationCenter defaultCenter] removeObserver:self]; } -#pragma mark - Dependencies - -- (TSAccountManager *)tsAccountManager -{ - OWSAssertDebug(SSKEnvironment.shared.tsAccountManager); - - return SSKEnvironment.shared.tsAccountManager; -} - #pragma mark - (void)observeNotifications @@ -120,46 +117,19 @@ CGFloat kIconViewLength = 24; return [OWSPrimaryStorage sharedManager].dbReadWriteConnection; } -- (nullable NSString *)threadName -{ - NSString *threadName = self.thread.name; - if ([self.thread isKindOfClass:TSContactThread.class]) { - TSContactThread *thread = (TSContactThread *)self.thread; - return [SMKProfile displayNameWithId:thread.contactSessionID customFallback: @"Anonymous"]; - } else if (threadName.length == 0 && [self isGroupThread]) { - threadName = [MessageStrings newGroupDefaultTitle]; +- (void)configureWithThreadId:(NSString *)threadId threadName:(nullable NSString *)threadName isClosedGroup:(BOOL)isClosedGroup isOpenGroup:(BOOL)isOpenGroup isNoteToSelf:(BOOL)isNoteToSelf { + self.threadId = threadId; + self.threadName = threadName; + self.isClosedGroup = isClosedGroup; + self.isOpenGroup = isOpenGroup; + self.isNoteToSelf = isNoteToSelf; + + if (!isClosedGroup && !isOpenGroup) { + self.threadName = [SMKProfile displayNameWithId:threadId customFallback:@"Anonymous"]; } - return threadName; -} - -- (BOOL)isGroupThread -{ - return [self.thread isKindOfClass:[TSGroupThread class]]; -} - -- (BOOL)isOpenGroup -{ - if ([self isGroupThread]) { - TSGroupThread *thread = (TSGroupThread *)self.thread; - return thread.isOpenGroup; + else { + self.threadName = (threadName ?: [MessageStrings newGroupDefaultTitle]); } - return false; -} - --(BOOL)isClosedGroup -{ - if (self.isGroupThread) { - TSGroupThread *thread = (TSGroupThread *)self.thread; - return thread.groupModel.groupType == closedGroup; - } - return false; -} - -- (void)configureWithThread:(TSThread *)thread uiDatabaseConnection:(YapDatabaseConnection *)uiDatabaseConnection -{ - OWSAssertDebug(thread); - self.thread = thread; - self.uiDatabaseConnection = uiDatabaseConnection; } #pragma mark - ContactEditingDelegate @@ -202,7 +172,7 @@ CGFloat kIconViewLength = 24; self.displayNameLabel.font = [UIFont boldSystemFontOfSize:LKValues.largeFontSize]; self.displayNameLabel.lineBreakMode = NSLineBreakByTruncatingTail; self.displayNameLabel.textAlignment = NSTextAlignmentCenter; - + self.displayNameTextField = [[SNTextField alloc] initWithPlaceholder:@"Enter a name" usesDefaultHeight:NO]; self.displayNameTextField.textAlignment = NSTextAlignmentCenter; self.displayNameTextField.accessibilityLabel = @"Edit name text field"; @@ -211,45 +181,42 @@ CGFloat kIconViewLength = 24; self.displayNameContainer = [UIView new]; self.displayNameContainer.accessibilityLabel = @"Edit name text field"; self.displayNameContainer.isAccessibilityElement = YES; - + [self.displayNameContainer autoSetDimension:ALDimensionHeight toSize:40]; [self.displayNameContainer addSubview:self.displayNameLabel]; [self.displayNameLabel autoPinToEdgesOfView:self.displayNameContainer]; [self.displayNameContainer addSubview:self.displayNameTextField]; [self.displayNameTextField autoPinToEdgesOfView:self.displayNameContainer]; - - if ([self.thread isKindOfClass:TSContactThread.class]) { + + if (!self.isClosedGroup && !self.isOpenGroup) { UITapGestureRecognizer *tapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(showEditNameUI)]; [self.displayNameContainer addGestureRecognizer:tapGestureRecognizer]; } - + self.tableView.estimatedRowHeight = 45; self.tableView.rowHeight = UITableViewAutomaticDimension; _disappearingMessagesDurationLabel = [UILabel new]; SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _disappearingMessagesDurationLabel); - self.disappearingMessagesDurations = [OWSDisappearingMessagesConfiguration validDurationsSeconds]; + self.disappearingMessagesDurations = [SMKDisappearingMessagesConfiguration validDurationsSeconds]; + self.isDisappearingMessagesEnabled = [SMKDisappearingMessagesConfiguration isEnabledFor: self.threadId]; + self.disappearingMessagesDurationIndex = [SMKDisappearingMessagesConfiguration durationIndexFor: self.threadId]; + self.originalIsDisappearingMessagesEnabled = self.isDisappearingMessagesEnabled; + self.originalDisappearingMessagesDurationIndex = self.disappearingMessagesDurationIndex; - self.disappearingMessagesConfiguration = - [OWSDisappearingMessagesConfiguration fetchObjectWithUniqueId:self.thread.uniqueId]; - - if (!self.disappearingMessagesConfiguration) { - self.disappearingMessagesConfiguration = [OWSDisappearingMessagesConfiguration defaultWith: self.thread.uniqueId]; - } - [self updateTableContents]; - + NSString *title; - if ([self.thread isKindOfClass:[TSContactThread class]]) { + if (!self.isClosedGroup && !self.isOpenGroup) { title = NSLocalizedString(@"Settings", @""); } else { title = NSLocalizedString(@"Group Settings", @""); } [LKViewControllerUtilities setUpDefaultSessionStyleForVC:self withTitle:title customBackButton:YES]; self.tableView.backgroundColor = UIColor.clearColor; - - if ([self.thread isKindOfClass:TSContactThread.class]) { + + if (!self.isClosedGroup && !self.isOpenGroup) { [self updateNavBarButtons]; } } @@ -259,8 +226,6 @@ CGFloat kIconViewLength = 24; OWSTableContents *contents = [OWSTableContents new]; contents.title = NSLocalizedString(@"CONVERSATION_SETTINGS", @"title for conversation settings screen"); - BOOL isNoteToSelf = self.thread.isNoteToSelf; - __weak OWSConversationSettingsViewController *weakSelf = self; OWSTableSection *section = [OWSTableSection new]; @@ -269,7 +234,7 @@ CGFloat kIconViewLength = 24; section.customHeaderHeight = @(UITableViewAutomaticDimension); // Copy Session ID - if ([self.thread isKindOfClass:TSContactThread.class]) { + if (!self.isClosedGroup && !self.isOpenGroup) { [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ return [weakSelf disclosureCellWithName:NSLocalizedString(@"vc_conversation_settings_copy_session_id_button_title", "") @@ -290,7 +255,7 @@ CGFloat kIconViewLength = 24; } actionBlock:^{ [weakSelf showMediaGallery]; }]]; - + // Invite button if (self.isOpenGroup) { [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ @@ -315,7 +280,7 @@ CGFloat kIconViewLength = 24; } actionBlock:^{ [weakSelf tappedConversationSearch]; }]]; - + // Disappearing messages if (![self isOpenGroup]) { [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ @@ -327,7 +292,7 @@ CGFloat kIconViewLength = 24; cell.selectionStyle = UITableViewCellSelectionStyleNone; NSString *iconName - = (strongSelf.disappearingMessagesConfiguration.isEnabled ? @"ic_timer" : @"ic_timer_disabled"); + = (strongSelf.isDisappearingMessagesEnabled ? @"ic_timer" : @"ic_timer_disabled"); UIImageView *iconView = [strongSelf viewForIconWithName:iconName]; UILabel *rowLabel = [UILabel new]; @@ -338,7 +303,7 @@ CGFloat kIconViewLength = 24; rowLabel.lineBreakMode = NSLineBreakByTruncatingTail; UISwitch *switchView = [UISwitch new]; - switchView.on = strongSelf.disappearingMessagesConfiguration.isEnabled; + switchView.on = strongSelf.isDisappearingMessagesEnabled; [switchView addTarget:strongSelf action:@selector(disappearingMessagesSwitchValueDidChange:) forControlEvents:UIControlEventValueChanged]; @@ -351,11 +316,10 @@ CGFloat kIconViewLength = 24; UILabel *subtitleLabel = [UILabel new]; NSString *displayName; - if (self.thread.isGroupThread) { + if (self.isClosedGroup || self.isOpenGroup) { displayName = @"the group"; } else { - TSContactThread *thread = (TSContactThread *)self.thread; - displayName = [SMKProfile displayNameWithId:thread.contactSessionID customFallback:@"anonymous"]; + displayName = [SMKProfile displayNameWithId:self.threadId customFallback:@"anonymous"]; } subtitleLabel.text = [NSString stringWithFormat:NSLocalizedString(@"When enabled, messages between you and %@ will disappear after they have been seen.", ""), displayName]; subtitleLabel.textColor = LKColors.text; @@ -375,7 +339,7 @@ CGFloat kIconViewLength = 24; return cell; } customRowHeight:UITableViewAutomaticDimension actionBlock:nil]]; - if (self.disappearingMessagesConfiguration.isEnabled) { + if (self.isDisappearingMessagesEnabled) { [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ UITableViewCell *cell = [OWSTableItem newCell]; OWSConversationSettingsViewController *strongSelf = weakSelf; @@ -405,7 +369,7 @@ CGFloat kIconViewLength = 24; slider.minimumValue = 0; slider.tintColor = LKColors.accent; slider.continuous = NO; - slider.value = strongSelf.disappearingMessagesConfiguration.durationIndex; + slider.value = strongSelf.disappearingMessagesDurationIndex; [slider addTarget:strongSelf action:@selector(durationSliderDidChange:) forControlEvents:UIControlEventValueChanged]; [cell.contentView addSubview:slider]; @@ -413,7 +377,7 @@ CGFloat kIconViewLength = 24; [slider autoPinEdge:ALEdgeLeading toEdge:ALEdgeLeading ofView:rowLabel]; [slider autoPinTrailingToSuperviewMargin]; [slider autoPinBottomToSuperviewMargin]; - + cell.userInteractionEnabled = !strongSelf.hasLeftGroup; cell.accessibilityIdentifier = ACCESSIBILITY_IDENTIFIER_WITH_NAME( @@ -428,11 +392,10 @@ CGFloat kIconViewLength = 24; // Closed group settings __block BOOL isUserMember = NO; - if (self.isGroupThread) { - NSString *userPublicKey = [SNGeneralUtilities getUserPublicKey]; - isUserMember = [(TSGroupThread *)self.thread isUserMemberInGroup:userPublicKey]; + if (self.isClosedGroup || self.isOpenGroup) { + isUserMember = [SMKGroupMember isCurrentUserMemberOf:self.threadId]; } - if (self.isGroupThread && self.isClosedGroup && isUserMember) { + if (self.isClosedGroup && isUserMember) { [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ UITableViewCell *cell = [weakSelf disclosureCellWithName:NSLocalizedString(@"EDIT_GROUP_ACTION", @"table cell label in conversation settings") @@ -455,8 +418,8 @@ CGFloat kIconViewLength = 24; [weakSelf didTapLeaveGroup]; }]]; } - - if (!isNoteToSelf) { + + if (!self.isNoteToSelf) { // Notification sound [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ UITableViewCell *cell = @@ -483,8 +446,8 @@ CGFloat kIconViewLength = 24; [cell.contentView addSubview:contentRow]; [contentRow autoPinEdgesToSuperviewMargins]; - OWSSound sound = [OWSSounds notificationSoundForThread:strongSelf.thread]; - cell.detailTextLabel.text = [OWSSounds displayNameForSound:sound]; + NSInteger sound = [SMKSound notificationSoundFor:strongSelf.threadId]; + cell.detailTextLabel.text = [SMKSound displayNameFor:sound]; cell.accessibilityIdentifier = ACCESSIBILITY_IDENTIFIER_WITH_NAME( OWSConversationSettingsViewController, @"notifications"); @@ -494,11 +457,11 @@ CGFloat kIconViewLength = 24; customRowHeight:UITableViewAutomaticDimension actionBlock:^{ OWSSoundSettingsViewController *vc = [OWSSoundSettingsViewController new]; - vc.thread = weakSelf.thread; + vc.threadId = weakSelf.threadId; [weakSelf.navigationController pushViewController:vc animated:YES]; }]]; - - if (self.isGroupThread) { + + if (self.isClosedGroup || self.isOpenGroup) { // Notification Settings [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ UITableViewCell *cell = [OWSTableItem newCell]; @@ -517,7 +480,7 @@ CGFloat kIconViewLength = 24; rowLabel.lineBreakMode = NSLineBreakByTruncatingTail; UISwitch *switchView = [UISwitch new]; - switchView.on = ((TSGroupThread *)strongSelf.thread).isOnlyNotifyingForMentions; + switchView.on = [SMKThread isOnlyNotifyingForMentions:strongSelf.threadId]; [switchView addTarget:strongSelf action:@selector(notifyForMentionsOnlySwitchValueDidChange:) forControlEvents:UIControlEventValueChanged]; @@ -547,7 +510,7 @@ CGFloat kIconViewLength = 24; return cell; } customRowHeight:UITableViewAutomaticDimension actionBlock:nil]]; } - + // Mute thread [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ OWSConversationSettingsViewController *strongSelf = weakSelf; @@ -560,7 +523,7 @@ CGFloat kIconViewLength = 24; cell.selectionStyle = UITableViewCellSelectionStyleNone; UISwitch *muteConversationSwitch = [UISwitch new]; - NSDate *mutedUntilDate = strongSelf.thread.mutedUntilDate; + NSDate *mutedUntilDate = [SMKThread mutedUntilDateFor:strongSelf.threadId]; NSDate *now = [NSDate date]; muteConversationSwitch.on = (mutedUntilDate != nil && [mutedUntilDate timeIntervalSinceDate:now] > 0); [muteConversationSwitch addTarget:strongSelf action:@selector(handleMuteSwitchToggled:) @@ -570,9 +533,9 @@ CGFloat kIconViewLength = 24; return cell; } actionBlock:nil]]; } - + // Block contact - if (!isNoteToSelf && [self.thread isKindOfClass:TSContactThread.class]) { + if (!self.isNoteToSelf && !self.isClosedGroup && !self.isOpenGroup) { [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ OWSConversationSettingsViewController *strongSelf = weakSelf; if (!strongSelf) { return [UITableViewCell new]; } @@ -584,7 +547,7 @@ CGFloat kIconViewLength = 24; cell.selectionStyle = UITableViewCellSelectionStyleNone; UISwitch *blockConversationSwitch = [UISwitch new]; - blockConversationSwitch.on = strongSelf.thread.isBlocked; + blockConversationSwitch.on = [SMKContact isBlockedFor:strongSelf.threadId]; [blockConversationSwitch addTarget:strongSelf action:@selector(blockConversationSwitchDidChange:) forControlEvents:UIControlEventValueChanged]; cell.accessoryView = blockConversationSwitch; @@ -671,36 +634,36 @@ CGFloat kIconViewLength = 24; [profilePictureView autoSetDimension:ALDimensionWidth toSize:size]; [profilePictureView autoSetDimension:ALDimensionHeight toSize:size]; [profilePictureView addGestureRecognizer:profilePictureTapGestureRecognizer]; - + self.displayNameLabel.text = (self.threadName != nil && self.threadName.length > 0) ? self.threadName : @"Anonymous"; - if ([self.thread isKindOfClass:TSContactThread.class]) { + if (!self.isClosedGroup && !self.isOpenGroup) { UITapGestureRecognizer *tapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(showEditNameUI)]; [self.displayNameContainer addGestureRecognizer:tapGestureRecognizer]; } - + UIStackView *stackView = [[UIStackView alloc] initWithArrangedSubviews:@[ profilePictureView, self.displayNameContainer ]]; stackView.axis = UILayoutConstraintAxisVertical; stackView.spacing = LKValues.mediumSpacing; - stackView.distribution = UIStackViewDistributionEqualCentering; + stackView.distribution = UIStackViewDistributionEqualCentering; stackView.alignment = UIStackViewAlignmentCenter; BOOL isSmallScreen = (UIScreen.mainScreen.bounds.size.height - 568) < 1; CGFloat horizontalSpacing = isSmallScreen ? LKValues.largeSpacing : LKValues.veryLargeSpacing; stackView.layoutMargins = UIEdgeInsetsMake(LKValues.mediumSpacing, horizontalSpacing, LKValues.mediumSpacing, horizontalSpacing); [stackView setLayoutMarginsRelativeArrangement:YES]; - if (!self.isGroupThread) { + if (!self.isClosedGroup && !self.isOpenGroup) { SRCopyableLabel *subtitleView = [SRCopyableLabel new]; subtitleView.textColor = LKColors.text; subtitleView.font = [LKFonts spaceMonoOfSize:LKValues.smallFontSize]; subtitleView.lineBreakMode = NSLineBreakByCharWrapping; subtitleView.numberOfLines = 2; - subtitleView.text = ((TSContactThread *)self.thread).contactSessionID; + subtitleView.text = self.threadId; subtitleView.textAlignment = NSTextAlignmentCenter; [stackView addArrangedSubview:subtitleView]; } - - [profilePictureView updateForThread:self.thread]; - + + [profilePictureView updateForThreadId:self.threadId]; + return stackView; } @@ -739,48 +702,41 @@ CGFloat kIconViewLength = 24; { [super viewWillDisappear:animated]; - if (self.disappearingMessagesConfiguration.isNewRecord && !self.disappearingMessagesConfiguration.isEnabled) { - // don't save defaults, else we'll unintentionally save the configuration and notify the contact. + // Do nothing if the values haven't changed (or if it's disabled and only the 'durationIndex' + // has changed as the 'durationIndex' value defaults to 1 hour when disabled) + if ( + self.isDisappearingMessagesEnabled == self.originalIsDisappearingMessagesEnabled && ( + !self.originalIsDisappearingMessagesEnabled || + self.disappearingMessagesDurationIndex == self.originalDisappearingMessagesDurationIndex + ) + ) { return; } - - if (self.disappearingMessagesConfiguration.dictionaryValueDidChange) { - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) { - [self.disappearingMessagesConfiguration saveWithTransaction:transaction]; - OWSDisappearingConfigurationUpdateInfoMessage *infoMessage = [[OWSDisappearingConfigurationUpdateInfoMessage alloc] - initWithTimestamp:[NSDate ows_millisecondTimeStamp] - thread:self.thread - configuration:self.disappearingMessagesConfiguration - createdByRemoteName:nil - createdInExistingGroup:NO]; - [infoMessage saveWithTransaction:transaction]; - - SNExpirationTimerUpdate *expirationTimerUpdate = [SNExpirationTimerUpdate new]; - BOOL isEnabled = self.disappearingMessagesConfiguration.isEnabled; - expirationTimerUpdate.duration = isEnabled ? self.disappearingMessagesConfiguration.durationSeconds : 0; - [SNMessageSender send:expirationTimerUpdate inThread:self.thread usingTransaction:transaction]; - }]; - } + + [SMKDisappearingMessagesConfiguration + update:self.threadId + isEnabled: self.isDisappearingMessagesEnabled + durationIndex: self.disappearingMessagesDurationIndex + ]; } #pragma mark - Actions - (void)editGroup { - SNEditClosedGroupVC *editClosedGroupVC = [[SNEditClosedGroupVC alloc] initWithThreadID:self.thread.uniqueId]; + SNEditClosedGroupVC *editClosedGroupVC = [[SNEditClosedGroupVC alloc] initWithThreadId:self.threadId]; [self.navigationController pushViewController:editClosedGroupVC animated:YES completion:nil]; } - (void)didTapLeaveGroup { - NSString *userPublicKey = [SNGeneralUtilities getUserPublicKey]; NSString *message; - if ([((TSGroupThread *)self.thread).groupModel.groupAdminIds containsObject:userPublicKey]) { + if ([SMKGroupMember isCurrentUserAdminOf:self.threadId]) { message = @"Because you are the creator of this group it will be deleted for everyone. This cannot be undone."; } else { message = NSLocalizedString(@"CONFIRM_LEAVE_GROUP_DESCRIPTION", @"Alert body"); } - + UIAlertController *alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"CONFIRM_LEAVE_GROUP_TITLE", @"Alert title") message:message @@ -801,9 +757,8 @@ CGFloat kIconViewLength = 24; - (BOOL)hasLeftGroup { - if (self.isGroupThread) { - TSGroupThread *groupThread = (TSGroupThread *)self.thread; - return !groupThread.isCurrentUserMemberInGroup; + if (self.isClosedGroup || self.isOpenGroup) { + return ![SMKGroupMember isCurrentUserMemberOf:self.threadId]; } return NO; @@ -834,13 +789,9 @@ CGFloat kIconViewLength = 24; { UISwitch *uiSwitch = (UISwitch *)sender; if (uiSwitch.isOn) { - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [self.thread updateWithMutedUntilDate:[NSDate distantFuture] transaction:transaction]; - }]; + [SMKThread updateWithMutedUntilDateTo:[NSDate distantFuture] forThreadId:self.threadId]; } else { - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [self.thread updateWithMutedUntilDate:nil transaction:transaction]; - }]; + [SMKThread updateWithMutedUntilDateTo:nil forThreadId:self.threadId]; } } @@ -849,13 +800,12 @@ CGFloat kIconViewLength = 24; if (![sender isKindOfClass:[UISwitch class]]) { OWSFailDebug(@"Unexpected sender for block user switch: %@", sender); } - if (![self.thread isKindOfClass:[TSContactThread class]]) { - OWSFailDebug(@"unexpected thread type: %@", self.thread.class); + if (self.isClosedGroup || self.isOpenGroup) { + OWSFailDebug(@"unexpected group thread"); } UISwitch *blockConversationSwitch = (UISwitch *)sender; - TSContactThread *contactThread = (TSContactThread *)self.thread; - BOOL isCurrentlyBlocked = contactThread.isBlocked; + BOOL isCurrentlyBlocked = [SMKContact isBlockedFor:self.threadId]; __weak OWSConversationSettingsViewController *weakSelf = self; if (blockConversationSwitch.isOn) { @@ -863,12 +813,12 @@ CGFloat kIconViewLength = 24; if (isCurrentlyBlocked) { return; } - [BlockListUIUtils showBlockThreadActionSheet:contactThread + [BlockListUIUtils showBlockThreadActionSheet:self.threadId from:self completionBlock:^(BOOL isBlocked) { // Update switch state if user cancels action. blockConversationSwitch.on = isBlocked; - + // If we successfully blocked then force a config sync if (isBlocked) { [SNMessageSender forceSyncConfigurationNow]; @@ -882,12 +832,12 @@ CGFloat kIconViewLength = 24; if (!isCurrentlyBlocked) { return; } - [BlockListUIUtils showUnblockThreadActionSheet:contactThread + [BlockListUIUtils showUnblockThreadActionSheet:self.threadId from:self completionBlock:^(BOOL isBlocked) { // Update switch state if user cancels action. blockConversationSwitch.on = isBlocked; - + // If we successfully unblocked then force a config sync if (!isBlocked) { [SNMessageSender forceSyncConfigurationNow]; @@ -900,7 +850,7 @@ CGFloat kIconViewLength = 24; - (void)toggleDisappearingMessages:(BOOL)flag { - self.disappearingMessagesConfiguration.isEnabled = flag; + self.isDisappearingMessagesEnabled = flag; [self updateTableContents]; } @@ -908,21 +858,23 @@ CGFloat kIconViewLength = 24; - (void)durationSliderDidChange:(UISlider *)slider { // snap the slider to a valid value - NSUInteger index = (NSUInteger)(slider.value + 0.5); + NSInteger index = (NSInteger)(slider.value + 0.5); [slider setValue:index animated:YES]; - NSNumber *numberOfSeconds = self.disappearingMessagesDurations[index]; - self.disappearingMessagesConfiguration.durationSeconds = [numberOfSeconds unsignedIntValue]; + self.disappearingMessagesDurationIndex = index; [self updateDisappearingMessagesDurationLabel]; } - (void)updateDisappearingMessagesDurationLabel { - if (self.disappearingMessagesConfiguration.isEnabled) { + if (self.isDisappearingMessagesEnabled) { NSString *keepForFormat = @"Disappear after %@"; - self.disappearingMessagesDurationLabel.text = - [NSString stringWithFormat:keepForFormat, self.disappearingMessagesConfiguration.durationString]; - } else { + self.disappearingMessagesDurationLabel.text = [NSString + stringWithFormat:keepForFormat, + [SMKDisappearingMessagesConfiguration durationStringFor: self.disappearingMessagesDurationIndex] + ]; + } + else { self.disappearingMessagesDurationLabel.text = NSLocalizedString(@"KEEP_MESSAGES_FOREVER", @"Slider label when disappearing messages is off"); } @@ -933,30 +885,16 @@ CGFloat kIconViewLength = 24; - (void)copySessionID { - UIPasteboard.generalPasteboard.string = ((TSContactThread *)self.thread).contactSessionID; + UIPasteboard.generalPasteboard.string = self.threadId; } - (void)inviteUsersToOpenGroup { - NSString *threadID = self.thread.uniqueId; - SNOpenGroupV2 *openGroup = [LKStorage.shared getV2OpenGroupForThreadID:threadID]; - NSString *url = [NSString stringWithFormat:@"%@/%@?public_key=%@", openGroup.server, openGroup.room, openGroup.publicKey]; + NSString *threadId = self.threadId; SNUserSelectionVC *userSelectionVC = [[SNUserSelectionVC alloc] initWithTitle:NSLocalizedString(@"vc_conversation_settings_invite_button_title", @"") excluding:[NSSet new] completion:^(NSSet *selectedUsers) { - for (NSString *user in selectedUsers) { - SNVisibleMessage *message = [SNVisibleMessage new]; - message.sentTimestamp = [NSDate millisecondTimestamp]; - message.openGroupInvitation = [[SNOpenGroupInvitation alloc] initWithName:openGroup.name url:url]; - TSContactThread *thread = [TSContactThread getOrCreateThreadWithContactSessionID:user]; - TSOutgoingMessage *tsMessage = [TSOutgoingMessage from:message associatedWith:thread]; - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [tsMessage saveWithTransaction:transaction]; - }]; - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [SNMessageSender send:message inThread:thread usingTransaction:transaction]; - }]; - } + [SMKOpenGroup inviteUsers:selectedUsers toOpenGroupFor:threadId]; }]; [self.navigationController pushViewController:userSelectionVC animated:YES]; } @@ -965,8 +903,7 @@ CGFloat kIconViewLength = 24; { OWSLogDebug(@""); - MediaGallery *mediaGallery = [[MediaGallery alloc] initWithThread:self.thread - options:MediaGalleryOptionSliderEnabled]; + MediaGallery *mediaGallery = [[MediaGallery alloc] initWithSliderEnabledForThreadId:self.threadId isClosedGroup: self.isClosedGroup isOpenGroup: self.isOpenGroup]; self.mediaGallery = mediaGallery; @@ -983,9 +920,8 @@ CGFloat kIconViewLength = 24; { UISwitch *uiSwitch = (UISwitch *)sender; BOOL isEnabled = uiSwitch.isOn; - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [(TSGroupThread *)self.thread setIsOnlyNotifyingForMentions:isEnabled withTransaction:transaction]; - }]; + + [SMKThread setIsOnlyNotifyingForMentions:self.threadId to:isEnabled]; } - (void)hideEditNameUI @@ -1001,9 +937,9 @@ CGFloat kIconViewLength = 24; - (void)setIsEditingDisplayName:(BOOL)isEditingDisplayName { _isEditingDisplayName = isEditingDisplayName; - + [self updateNavBarButtons]; - + [UIView animateWithDuration:0.25 animations:^{ self.displayNameLabel.alpha = self.isEditingDisplayName ? 0 : 1; self.displayNameTextField.alpha = self.isEditingDisplayName ? 1 : 0; @@ -1017,13 +953,10 @@ CGFloat kIconViewLength = 24; - (void)saveName { - if (![self.thread isKindOfClass:TSContactThread.class]) { return; } - NSString *sessionID = ((TSContactThread *)self.thread).contactSessionID; - SMKProfile *profile = [SMKProfile fetchOrCreateWithId:sessionID]; + if (self.isClosedGroup || self.isOpenGroup) { return; } + NSString *text = [self.displayNameTextField.text stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; - profile.nickname = text.length > 0 ? text : nil; - [SMKProfile saveProfile: profile]; - self.displayNameLabel.text = text.length > 0 ? text : profile.name; + self.displayNameLabel.text = [SMKProfile displayNameAfterSavingNickname:text forProfileId:self.threadId]; [self hideEditNameUI]; } @@ -1054,14 +987,13 @@ CGFloat kIconViewLength = 24; - (void)otherUsersProfileDidChange:(NSNotification *)notification { - OWSAssertIsOnMainThread(); - NSString *recipientId = notification.userInfo[NSNotification.profileRecipientIdKey]; OWSAssertDebug(recipientId.length > 0); - if (recipientId.length > 0 && [self.thread isKindOfClass:[TSContactThread class]] && - [((TSContactThread *)self.thread).contactSessionID isEqualToString:recipientId]) { - [self updateTableContents]; + if (recipientId.length > 0 && !self.isClosedGroup && !self.isOpenGroup && self.threadId == recipientId) { + DispatchMainThreadSafe(^{ + [self updateTableContents]; + }); } } diff --git a/Session/Conversations/Settings/ProfilePictureVC.swift b/Session/Conversations/Settings/ProfilePictureVC.swift index 98df010f9..44d693394 100644 --- a/Session/Conversations/Settings/ProfilePictureVC.swift +++ b/Session/Conversations/Settings/ProfilePictureVC.swift @@ -1,13 +1,17 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit /// Shown when the user taps a profile picture in the conversation settings. @objc(SNProfilePictureVC) -final class ProfilePictureVC : BaseVC { +final class ProfilePictureVC: BaseVC { private let image: UIImage private let snTitle: String @objc init(image: UIImage, title: String) { self.image = image self.snTitle = title + super.init(nibName: nil, bundle: nil) } diff --git a/Session/Conversations/Views & Modals/ConversationTitleView.swift b/Session/Conversations/Views & Modals/ConversationTitleView.swift index 4e319f6a7..56d182613 100644 --- a/Session/Conversations/Views & Modals/ConversationTitleView.swift +++ b/Session/Conversations/Views & Modals/ConversationTitleView.swift @@ -52,50 +52,48 @@ final class ConversationTitleView: UIView { public func update( with name: String, - notificationMode: SessionThread.NotificationMode, + mutedUntilTimestamp: TimeInterval?, + onlyNotifyForMentions: Bool, userCount: Int? ) { guard Thread.isMainThread else { DispatchQueue.main.async { [weak self] in - self?.update(with: name, notificationMode: notificationMode, userCount: userCount) + self?.update(with: name, mutedUntilTimestamp: mutedUntilTimestamp, onlyNotifyForMentions: onlyNotifyForMentions, userCount: userCount) } return } // Generate the subtitle let subtitle: NSAttributedString? = { - switch notificationMode { - case .none: - return NSAttributedString( - string: "\u{e067} ", - attributes: [ - .font: UIFont.ows_elegantIconsFont(10), - .foregroundColor: Colors.text - ] - ) - .appending(string: "Muted") - - case .mentionsOnly: - // FIXME: This is going to have issues when swapping between light/dark mode - let imageAttachment = NSTextAttachment() - let color: UIColor = (isDarkMode ? .white : .black) - imageAttachment.image = UIImage(named: "NotifyMentions.png")?.asTintedImage(color: color) - imageAttachment.bounds = CGRect( - x: 0, - y: -2, - width: Values.smallFontSize, - height: Values.smallFontSize - ) - - return NSAttributedString(attachment: imageAttachment) - .appending(string: " ") - .appending(string: "view_conversation_title_notify_for_mentions_only".localized()) - - case .all: - guard let userCount: Int = userCount else { return nil } - - return NSAttributedString(string: "\(userCount) member\(userCount == 1 ? "" : "s")") + guard Date().timeIntervalSince1970 > (mutedUntilTimestamp ?? 0) else { + return NSAttributedString( + string: "\u{e067} ", + attributes: [ + .font: UIFont.ows_elegantIconsFont(10), + .foregroundColor: Colors.text + ] + ) + .appending(string: "Muted") } + guard !onlyNotifyForMentions else { + // FIXME: This is going to have issues when swapping between light/dark mode + let imageAttachment = NSTextAttachment() + let color: UIColor = (isDarkMode ? .white : .black) + imageAttachment.image = UIImage(named: "NotifyMentions.png")?.asTintedImage(color: color) + imageAttachment.bounds = CGRect( + x: 0, + y: -2, + width: Values.smallFontSize, + height: Values.smallFontSize + ) + + return NSAttributedString(attachment: imageAttachment) + .appending(string: " ") + .appending(string: "view_conversation_title_notify_for_mentions_only".localized()) + } + guard let userCount: Int = userCount else { return nil } + + return NSAttributedString(string: "\(userCount) member\(userCount == 1 ? "" : "s")") }() self.titleLabel.text = name diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 3e3833fcb..5ddf938ca 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -98,7 +98,8 @@ public class HomeViewModel { private let contactProfile: Profile? private let closedGroupAvatarProfiles: [GroupMemberInfo]? - public let notificationMode: SessionThread.NotificationMode + public let mutedUntilTimestamp: TimeInterval? + public let onlyNotifyForMentions: Bool public let isPinned: Bool /// A flag indicating whether the contact is blocked (will be null for non-contact threads) @@ -113,17 +114,14 @@ public class HomeViewModel { public let lastInteractionInfo: InteractionInfo? public var displayName: String { - switch variant { - case .closedGroup: return (closedGroupName ?? "Unknown Group") - case .openGroup: return (openGroupName ?? "Unknown Group") - case .contact: - guard !isNoteToSelf else { return "NOTE_TO_SELF".localized() } - guard let profile: Profile = profile else { - return Profile.truncated(id: id, truncating: .middle) - } - - return (profile.nickname ?? profile.name) - } + return SessionThread.displayName( + threadId: id, + variant: variant, + closedGroupName: closedGroupName, + openGroupName: openGroupName, + isNoteToSelf: isNoteToSelf, + profile: contactProfile + ) } public var profile: Profile? { @@ -179,7 +177,8 @@ public class HomeViewModel { self.currentUserProfile = Profile(id: "", name: "") self.contactProfile = nil self.closedGroupAvatarProfiles = nil - self.notificationMode = .none + self.mutedUntilTimestamp = nil + self.onlyNotifyForMentions = false self.isPinned = false self.contactIsBlocked = nil self.isNoteToSelf = false @@ -255,7 +254,8 @@ public class HomeViewModel { openGroup[.name].forKey(ThreadInfo.openGroupNameKey), openGroup[.imageData].forKey(ThreadInfo.openGroupProfilePictureDataKey), - thread[.notificationMode], + thread[.mutedUntilTimestamp], + thread[.onlyNotifyForMentions], thread[.isPinned], contact[.isBlocked].forKey(ThreadInfo.contactIsBlockedKey), SessionThread.isNoteToSelf(userPublicKey: userPublicKey).forKey(ThreadInfo.isNoteToSelfKey), @@ -293,7 +293,10 @@ public class HomeViewModel { ) .joining(optional: SessionThread.openGroup.aliased(openGroup)) .with(currentUserProfileExpression) - .including(required: SessionThread.association(to: currentUserProfileExpression).forKey(ThreadInfo.currentUserProfileKey)) + .including( + required: SessionThread.association(to: currentUserProfileExpression) + .forKey(ThreadInfo.currentUserProfileKey) + ) .with(unreadInteractionExpression) .joining( optional: SessionThread diff --git a/Session/Media Viewing & Editing/MediaDetailViewController.swift b/Session/Media Viewing & Editing/MediaDetailViewController.swift new file mode 100644 index 000000000..0a8ab0dac --- /dev/null +++ b/Session/Media Viewing & Editing/MediaDetailViewController.swift @@ -0,0 +1,3 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation diff --git a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift new file mode 100644 index 000000000..0a8ab0dac --- /dev/null +++ b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift @@ -0,0 +1,3 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation diff --git a/Session/Media Viewing & Editing/PhotoCollectionPickerController.swift b/Session/Media Viewing & Editing/PhotoCollectionPickerController.swift index a386e51ff..2c6682307 100644 --- a/Session/Media Viewing & Editing/PhotoCollectionPickerController.swift +++ b/Session/Media Viewing & Editing/PhotoCollectionPickerController.swift @@ -102,7 +102,7 @@ class PhotoCollectionPickerController: OWSTableViewController, PhotoLibraryDeleg let photoMediaSize = PhotoMediaSize(thumbnailSize: CGSize(width: kImageSize, height: kImageSize)) if let assetItem = contents.lastAssetItem(photoMediaSize: photoMediaSize) { - imageView.image = assetItem.asyncThumbnail { [weak imageView] image in + assetItem.asyncThumbnail { [weak imageView] image in AssertIsOnMainThread() guard let imageView = imageView else { diff --git a/Session/Media Viewing & Editing/PhotoGridViewCell.swift b/Session/Media Viewing & Editing/PhotoGridViewCell.swift index 4b2470c1c..8d8ca2ad9 100644 --- a/Session/Media Viewing & Editing/PhotoGridViewCell.swift +++ b/Session/Media Viewing & Editing/PhotoGridViewCell.swift @@ -9,9 +9,10 @@ public enum PhotoGridItemType { case photo, animated, video } -public protocol PhotoGridItem: class { +public protocol PhotoGridItem: AnyObject { var type: PhotoGridItemType { get } - func asyncThumbnail(completion: @escaping (UIImage?) -> Void) -> UIImage? + + func asyncThumbnail(completion: @escaping (UIImage?) -> Void) } public class PhotoGridViewCell: UICollectionViewCell { @@ -119,28 +120,21 @@ public class PhotoGridViewCell: UICollectionViewCell { public func configure(item: PhotoGridItem) { self.item = item - self.image = item.asyncThumbnail { image in - guard let currentItem = self.item else { - return - } - - guard currentItem === item else { - return - } + item.asyncThumbnail { [weak self] image in + guard let currentItem = self?.item else { return } + guard currentItem === item else { return } if image == nil { Logger.debug("image == nil") } - self.image = image + + self?.image = image } switch item.type { - case .video: - self.contentTypeBadgeImage = PhotoGridViewCell.videoBadgeImage - case .animated: - self.contentTypeBadgeImage = PhotoGridViewCell.animatedBadgeImage - case .photo: - self.contentTypeBadgeImage = nil + case .video: self.contentTypeBadgeImage = PhotoGridViewCell.videoBadgeImage + case .animated: self.contentTypeBadgeImage = PhotoGridViewCell.animatedBadgeImage + case .photo: self.contentTypeBadgeImage = nil } } diff --git a/Session/Media Viewing & Editing/PhotoLibrary.swift b/Session/Media Viewing & Editing/PhotoLibrary.swift index 164c8b542..77c9f68be 100644 --- a/Session/Media Viewing & Editing/PhotoLibrary.swift +++ b/Session/Media Viewing & Editing/PhotoLibrary.swift @@ -7,7 +7,7 @@ import Photos import PromiseKit import CoreServices -protocol PhotoLibraryDelegate: class { +protocol PhotoLibraryDelegate: AnyObject { func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) } @@ -47,16 +47,13 @@ class PhotoPickerAssetItem: PhotoGridItem { return .photo } - func asyncThumbnail(completion: @escaping (UIImage?) -> Void) -> UIImage? { - var syncImageResult: UIImage? + func asyncThumbnail(completion: @escaping (UIImage?) -> Void) { var hasLoadedImage = false // Surprisingly, iOS will opportunistically run the completion block sync if the image is // already available. photoCollectionContents.requestThumbnail(for: self.asset, thumbnailSize: photoMediaSize.thumbnailSize) { image, _ in DispatchMainThreadSafe({ - syncImageResult = image - // Once we've _successfully_ completed (e.g. invoked the completion with // a non-nil image), don't invoke the completion again with a nil argument. if !hasLoadedImage || image != nil { @@ -68,7 +65,6 @@ class PhotoPickerAssetItem: PhotoGridItem { } }) } - return syncImageResult } } diff --git a/Session/Meta/SessionApp.swift b/Session/Meta/SessionApp.swift index 1e653f5c7..40258aaeb 100644 --- a/Session/Meta/SessionApp.swift +++ b/Session/Meta/SessionApp.swift @@ -9,29 +9,25 @@ public struct SessionApp { // MARK: - View Convenience Methods - public static func presentConversation(for recipientId: String, action: ConversationViewModel.Action = .none, animated: Bool) { + public static func presentConversation(for threadId: String, action: ConversationViewModel.Action = .none, animated: Bool) { let maybeThread: SessionThread? = GRDBStorage.shared.write { db in - try SessionThread.fetchOrCreate(db, id: recipientId, variant: .contact) + try SessionThread.fetchOrCreate(db, id: threadId, variant: .contact) } guard maybeThread != nil else { return } - self.presentConversation(for: recipientId, action: action, animated: animated) - } - - public static func presentConversation(for threadId: String, animated: Bool) { - guard GRDBStorage.shared.read({ db in try SessionThread.exists(db, id: threadId) }) == true else { - SNLog("Unable to find thread with id:\(threadId)") - return - } - - self.presentConversation(for: threadId, animated: animated) + self.presentConversation( + for: threadId, + action: action, + focusInteractionId: nil, + animated: animated + ) } public static func presentConversation( for threadId: String, - action: ConversationViewModel.Action = .none, - focusInteractionId: Int64? = nil, + action: ConversationViewModel.Action, + focusInteractionId: Int64?, animated: Bool ) { guard Thread.isMainThread else { diff --git a/Session/Notifications/AppNotifications.swift b/Session/Notifications/AppNotifications.swift index 831264ad0..b3e5706e1 100644 --- a/Session/Notifications/AppNotifications.swift +++ b/Session/Notifications/AppNotifications.swift @@ -154,7 +154,7 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { } public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, isBackgroundPoll: Bool) { - guard thread.notificationMode != .none else { return } + guard Date().timeIntervalSince1970 < (thread.mutedUntilTimestamp ?? 0) else { return } let isMessageRequest = thread.isMessageRequest(db) @@ -192,7 +192,7 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { // Don't fire the notification if the current user isn't mentioned // and isOnlyNotifyingForMentions is on. - if thread.notificationMode == .mentionsOnly && !interaction.isUserMentioned(db) { + if thread.onlyNotifyForMentions && !interaction.isUserMentioned(db) { return } @@ -213,9 +213,21 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { notificationTitle = (isMessageRequest ? "Session" : senderName) case .closedGroup, .openGroup: - let groupName: String = thread.name(db) + 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) + ) - notificationTitle = (isBackgroundPoll ? groupName: + notificationTitle = (isBackgroundPoll ? groupName : String( format: NotificationStrings.incomingGroupMessageTitleFormat, senderName, @@ -269,7 +281,22 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { switch previewType { case .noNameNoPreview: notificationTitle = nil - case .nameNoPreview, .namePreview: notificationTitle = thread.name(db) + case .nameNoPreview, .namePreview: + notificationTitle = 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) == true), + profile: try? Profile.fetchOne(db, id: thread.id) + ) + default: notificationTitle = nil } diff --git a/Session/Shared/ConversationCell.swift b/Session/Shared/ConversationCell.swift index 65d4a0275..8cad0e3d3 100644 --- a/Session/Shared/ConversationCell.swift +++ b/Session/Shared/ConversationCell.swift @@ -400,7 +400,7 @@ final class ConversationCell : UITableViewCell { private func getSnippet(threadInfo: HomeViewModel.ThreadInfo) -> NSMutableAttributedString { let result = NSMutableAttributedString() - if (threadInfo.notificationMode == .none) { + if Date().timeIntervalSince1970 < (threadInfo.mutedUntilTimestamp ?? 0) { result.append(NSAttributedString( string: "\u{e067} ", attributes: [ @@ -409,7 +409,7 @@ final class ConversationCell : UITableViewCell { ] )) } - else if threadInfo.notificationMode == .mentionsOnly { + else if threadInfo.onlyNotifyForMentions { let imageAttachment = NSTextAttachment() imageAttachment.image = UIImage(named: "NotifyMentions.png")?.asTintedImage(color: Colors.unimportant) imageAttachment.bounds = CGRect(x: 0, y: -2, width: Values.smallFontSize, height: Values.smallFontSize) diff --git a/Session/Shared/UserCell.swift b/Session/Shared/UserCell.swift index dea13b9ce..546899e50 100644 --- a/Session/Shared/UserCell.swift +++ b/Session/Shared/UserCell.swift @@ -1,45 +1,50 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + import UIKit +import SessionUIKit +import SignalUtilitiesKit -final class UserCell : UITableViewCell { - var accessory = Accessory.none - var publicKey = "" - var isZombie = false - - // MARK: Accessory +final class UserCell: UITableViewCell { + // MARK: - Accessory + enum Accessory { case none case lock case tick(isSelected: Bool) } - // MARK: Components - private lazy var profilePictureView = ProfilePictureView() + // MARK: - Components + + private lazy var profilePictureView: ProfilePictureView = ProfilePictureView() private lazy var displayNameLabel: UILabel = { - let result = UILabel() + let result: UILabel = UILabel() result.textColor = Colors.text result.font = .boldSystemFont(ofSize: Values.mediumFontSize) result.lineBreakMode = .byTruncatingTail + return result }() private lazy var accessoryImageView: UIImageView = { - let result = UIImageView() + let result: UIImageView = UIImageView() result.contentMode = .scaleAspectFit - let size: CGFloat = 24 - result.set(.width, to: size) - result.set(.height, to: size) + result.set(.width, to: 24) + result.set(.height, to: 24) + return result }() private lazy var separator: UIView = { - let result = UIView() + let result: UIView = UIView() result.backgroundColor = Colors.separator result.set(.height, to: Values.separatorThickness) + return result }() - // MARK: Initialization + // MARK: - Initialization + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) setUpViewHierarchy() @@ -53,19 +58,30 @@ final class UserCell : UITableViewCell { private func setUpViewHierarchy() { // Background color backgroundColor = Colors.cellBackground + // Highlight color let selectedBackgroundView = UIView() selectedBackgroundView.backgroundColor = .clear // Disabled for now self.selectedBackgroundView = selectedBackgroundView + // Profile picture image view let profilePictureViewSize = Values.smallProfilePictureSize profilePictureView.set(.width, to: profilePictureViewSize) profilePictureView.set(.height, to: profilePictureViewSize) profilePictureView.size = profilePictureViewSize + // Main stack view let spacer = UIView.hStretchingSpacer() spacer.widthAnchor.constraint(greaterThanOrEqualToConstant: Values.mediumSpacing).isActive = true - let stackView = UIStackView(arrangedSubviews: [ profilePictureView, UIView.hSpacer(Values.mediumSpacing), displayNameLabel, spacer, accessoryImageView ]) + let stackView = UIStackView( + arrangedSubviews: [ + profilePictureView, + UIView.hSpacer(Values.mediumSpacing), + displayNameLabel, + spacer, + accessoryImageView + ] + ) stackView.axis = .horizontal stackView.alignment = .center stackView.isLayoutMarginsRelativeArrangement = true @@ -73,16 +89,39 @@ final class UserCell : UITableViewCell { contentView.addSubview(stackView) stackView.pin(to: contentView) stackView.set(.width, to: UIScreen.main.bounds.width) + // Set up the separator contentView.addSubview(separator) - separator.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.bottom, UIView.HorizontalEdge.trailing ], to: contentView) + separator.pin( + [ + UIView.HorizontalEdge.leading, + UIView.VerticalEdge.bottom, + UIView.HorizontalEdge.trailing + ], + to: contentView + ) } // MARK: - Updating - func update() { - profilePictureView.update(for: publicKey) - displayNameLabel.text = Profile.displayName(id: publicKey) + func update( + with publicKey: String, + profile: Profile?, + isZombie: Bool, + accessory: Accessory + ) { + profilePictureView.update( + publicKey: publicKey, + profile: profile, + threadVariant: .contact + ) + + displayNameLabel.text = Profile.displayName( + for: .contact, + id: publicKey, + name: profile?.name, + nickname: profile?.nickname + ) switch accessory { case .none: accessoryImageView.isHidden = true @@ -99,7 +138,7 @@ final class UserCell : UITableViewCell { accessoryImageView.tintColor = Colors.text } - let alpha: CGFloat = isZombie ? 0.5 : 1 + let alpha: CGFloat = (isZombie ? 0.5 : 1) [ profilePictureView, displayNameLabel, accessoryImageView ].forEach { $0.alpha = alpha } } } diff --git a/Session/Shared/UserSelectionVC.swift b/Session/Shared/UserSelectionVC.swift index 16e4d2be4..4c025cd1a 100644 --- a/Session/Shared/UserSelectionVC.swift +++ b/Session/Shared/UserSelectionVC.swift @@ -4,32 +4,35 @@ import UIKit import SessionMessagingKit @objc(SNUserSelectionVC) -final class UserSelectionVC : BaseVC, UITableViewDataSource, UITableViewDelegate { +final class UserSelectionVC: BaseVC, UITableViewDataSource, UITableViewDelegate { private let navBarTitle: String private let usersToExclude: Set private let completion: (Set) -> Void private var selectedUsers: Set = [] - private lazy var users: [String] = { - var result = Contact.fetchAllIds() - result.removeAll { usersToExclude.contains($0) } - return result + private lazy var users: [Profile] = { + return Profile + .fetchAllContactProfiles() + .filter { usersToExclude.contains($0.id) } }() - // MARK: Components + // MARK: - Components + @objc private lazy var tableView: UITableView = { - let result = UITableView() + let result: UITableView = UITableView() result.dataSource = self result.delegate = self - result.register(UserCell.self, forCellReuseIdentifier: "UserCell") result.separatorStyle = .none result.backgroundColor = .clear result.showsVerticalScrollIndicator = false result.alwaysBounceVertical = false + result.register(view: UserCell.self) + return result }() - // MARK: Lifecycle + // MARK: - Lifecycle + @objc(initWithTitle:excluding:completion:) init(with title: String, excluding usersToExclude: Set, completion: @escaping (Set) -> Void) { self.navBarTitle = title @@ -51,29 +54,36 @@ final class UserSelectionVC : BaseVC, UITableViewDataSource, UITableViewDelegate tableView.pin(to: view) } - // MARK: Data + // MARK: - UITableViewDataSource + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return users.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "UserCell") as! UserCell - let publicKey = users[indexPath.row] - cell.publicKey = publicKey - let isSelected = selectedUsers.contains(publicKey) - cell.accessory = .tick(isSelected: isSelected) - cell.update() + let cell: UserCell = tableView.dequeue(type: UserCell.self, for: indexPath) + cell.update( + with: users[indexPath.row].id, + profile: users[indexPath.row], + isZombie: false, + accessory: .tick(isSelected: selectedUsers.contains(users[indexPath.row].id)) + ) + return cell } - // MARK: Interaction + // MARK: - Interaction + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let publicKey = users[indexPath.row] - if !selectedUsers.contains(publicKey) { selectedUsers.insert(publicKey) } else { selectedUsers.remove(publicKey) } - guard let cell = tableView.cellForRow(at: indexPath) as? UserCell else { return } - let isSelected = selectedUsers.contains(publicKey) - cell.accessory = .tick(isSelected: isSelected) - cell.update() + if !selectedUsers.contains(users[indexPath.row].id) { + selectedUsers.insert(users[indexPath.row].id) + } + else { + selectedUsers.remove(users[indexPath.row].id) + } + + tableView.deselectRow(at: indexPath, animated: true) + tableView.reloadRows(at: [indexPath], with: .none) } @objc private func handleDoneButtonTapped() { diff --git a/Session/Utilities/BackgroundPoller.swift b/Session/Utilities/BackgroundPoller.swift index a0c593f39..c9c11b4ed 100644 --- a/Session/Utilities/BackgroundPoller.swift +++ b/Session/Utilities/BackgroundPoller.swift @@ -68,7 +68,8 @@ public final class BackgroundPoller : NSObject { .appending( MessageReceiveJob.Details.MessageInfo( data: try envelope.serializedData(), - serverHash: message.info.hash + serverHash: message.info.hash, + serverExpirationTimestamp: (TimeInterval(message.info.expirationDateMs) / 1000) ) ) diff --git a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift index 693ccde4a..714c6c5de 100644 --- a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift +++ b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift @@ -35,7 +35,9 @@ public enum Legacy { internal static let interactionCollection = "TSInteraction" internal static let attachmentsCollection = "TSAttachements" // Note: This is how it was previously spelt internal static let outgoingReadReceiptManagerCollection = "kOutgoingReadReceiptManagerCollection" - + internal static let receivedMessageTimestampsCollection = "ReceivedMessageTimestampsCollection" + internal static let receivedMessageTimestampsKey = "receivedMessageTimestamps" + internal static let notifyPushServerJobCollection = "NotifyPNServerJobCollection" internal static let messageReceiveJobCollection = "MessageReceiveJobCollection" internal static let messageSendJobCollection = "MessageSendJobCollection" diff --git a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift index 2e208ee71..85da33703 100644 --- a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -49,11 +49,11 @@ enum _001_InitialSetupMigration: Migration { t.column(.shouldBeVisible, .boolean).notNull() t.column(.isPinned, .boolean).notNull() t.column(.messageDraft, .text) - t.column(.notificationMode, .integer) - .notNull() - .defaults(to: SessionThread.NotificationMode.all) t.column(.notificationSound, .integer) t.column(.mutedUntilTimestamp, .double) + t.column(.onlyNotifyForMentions, .boolean) + .notNull() + .defaults(to: false) } try db.create(table: DisappearingMessagesConfiguration.self) { t in @@ -274,12 +274,14 @@ enum _001_InitialSetupMigration: Migration { } try db.create(table: ControlMessageProcessRecord.self) { t in - t.column(.threadId, .text).notNull() - t.column(.sentTimestampMs, .integer).notNull() - t.column(.serverHash, .text).notNull() - t.column(.openGroupMessageServerId, .integer).notNull() + t.column(.threadId, .text) + .notNull() + .indexed() // Quicker querying + t.column(.variant, .integer).notNull() + t.column(.timestampMs, .integer).notNull() + t.column(.serverExpirationTimestamp, .double) - t.uniqueKey([.threadId, .sentTimestampMs, .serverHash, .openGroupMessageServerId]) + t.uniqueKey([.threadId, .variant, .timestampMs]) } } } diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index e508a5262..6ae96c531 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -41,6 +41,7 @@ enum _003_YDBToGRDBMigration: Migration { var attachments: [String: Legacy.Attachment] = [:] var processedAttachmentIds: Set = [] var outgoingReadReceiptsTimestampsMs: [String: Set] = [:] + var receivedMessageTimestamps: Set = [] // Map the Legacy types for the NSKeyedUnarchiver NSKeyedUnarchiver.setClass( @@ -192,6 +193,16 @@ enum _003_YDBToGRDBMigration: Migration { .union(timestampsMs) } + receivedMessageTimestamps = receivedMessageTimestamps.inserting( + contentsOf: transaction + .object( + forKey: Legacy.receivedMessageTimestampsKey, + inCollection: Legacy.receivedMessageTimestampsCollection + ) + .asType([UInt64].self) + .defaulting(to: []) + .asSet() + ) } // We can't properly throw within the 'enumerateKeysAndObjects' block so have to throw here @@ -292,21 +303,16 @@ enum _003_YDBToGRDBMigration: Migration { } let threadVariant: SessionThread.Variant - let notificationMode: SessionThread.NotificationMode + let onlyNotifyForMentions: Bool switch thread { case let groupThread as TSGroupThread: threadVariant = (groupThread.isOpenGroup ? .openGroup : .closedGroup) - notificationMode = (thread.isMuted ? .none : - (groupThread.isOnlyNotifyingForMentions ? - .mentionsOnly : - .all - ) - ) + onlyNotifyForMentions = groupThread.isOnlyNotifyingForMentions default: threadVariant = .contact - notificationMode = (thread.isMuted ? .none : .all) + onlyNotifyForMentions = false } try autoreleasepool { @@ -320,8 +326,8 @@ enum _003_YDBToGRDBMigration: Migration { nil : thread.messageDraft ), - notificationMode: notificationMode, - mutedUntilTimestamp: thread.mutedUntilDate?.timeIntervalSince1970 + mutedUntilTimestamp: thread.mutedUntilDate?.timeIntervalSince1970, + onlyNotifyForMentions: onlyNotifyForMentions ).insert(db) // Disappearing Messages Configuration @@ -564,7 +570,15 @@ enum _003_YDBToGRDBMigration: Migration { // Insert the data let interaction: Interaction = try Interaction( - serverHash: serverHash, + serverHash: { + switch variant { + // Don't store the 'serverHash' for these so sync messages + // are seen as duplicates + case .infoDisappearingMessagesUpdate: return nil + + default: return serverHash + } + }(), threadId: threadId, authorId: authorId, variant: variant, @@ -587,6 +601,17 @@ enum _003_YDBToGRDBMigration: Migration { openGroupWhisperTo: nil // TODO: This in SOGSV4 ).inserted(db) + // Insert a 'ControlMessageProcessRecord' if needed (for duplication prevention) + try ControlMessageProcessRecord( + threadId: threadId, + variant: variant, + timestampMs: Int64(legacyInteraction.timestamp) + )?.insert(db) + + // Remove timestamps we created records for (they will be protected by unique + // constraints so don't need legacy process records) + receivedMessageTimestamps.remove(legacyInteraction.timestamp) + guard let interactionId: Int64 = interaction.id else { // TODO: Is it possible the old database has duplicates which could hit this case? SNLog("[Migration Error] Failed to insert interaction") @@ -777,6 +802,13 @@ enum _003_YDBToGRDBMigration: Migration { } } + // Insert a 'ControlMessageProcessRecord' for any remaining 'receivedMessageTimestamp' + // entries as "legacy" + try ControlMessageProcessRecord.generateLegacyProcessRecords( + db, + receivedMessageTimestamps: receivedMessageTimestamps.map { Int64($0) } + ) + print("RAWR [\(Date().timeIntervalSince1970)] - Process thread inserts - End") // Clear out processed data (give the memory a change to be freed) @@ -801,6 +833,7 @@ enum _003_YDBToGRDBMigration: Migration { interactions = [:] attachments = [:] + receivedMessageTimestamps = [] // MARK: - Process Legacy Jobs @@ -1009,7 +1042,8 @@ enum _003_YDBToGRDBMigration: Migration { messages: [ MessageReceiveJob.Details.MessageInfo( data: legacyJob.data, - serverHash: legacyJob.serverHash + serverHash: legacyJob.serverHash, + serverExpirationTimestamp: (Date().timeIntervalSince1970 + ControlMessageProcessRecord.defaultExpirationSeconds) ) ], isBackgroundPoll: legacyJob.isBackgroundPoll diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index 64c8e86a3..32fe5a0ba 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -8,11 +8,16 @@ import SessionUtilitiesKit import AVFAudio import AVFoundation -public struct Attachment: Codable, Identifiable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "attachment" } public static let interactionAttachments = hasOne(InteractionAttachment.self) internal static let quoteForeignKey = ForeignKey([Columns.id], to: [Quote.Columns.attachmentId]) internal static let linkPreviewForeignKey = ForeignKey([Columns.id], to: [LinkPreview.Columns.attachmentId]) + public static let interaction = hasOne( + Interaction.self, + through: interactionAttachments, + using: InteractionAttachment.interaction + ) fileprivate static let quote = belongsTo(Quote.self, using: quoteForeignKey) fileprivate static let linkPreview = belongsTo(LinkPreview.self, using: linkPreviewForeignKey) @@ -195,6 +200,28 @@ public struct Attachment: Codable, Identifiable, Equatable, FetchableRecord, Per self.digest = nil self.caption = nil } + + // MARK: - Custom Database Interaction + + public func delete(_ db: Database) throws -> Bool { + // Delete all associated files + if FileManager.default.fileExists(atPath: thumbnailsDirPath) { + try? FileManager.default.removeItem(atPath: thumbnailsDirPath) + } + + if + let legacyThumbnailPath: String = legacyThumbnailPath, + FileManager.default.fileExists(atPath: legacyThumbnailPath) + { + try? FileManager.default.removeItem(atPath: legacyThumbnailPath) + } + + if let originalFilePath: String = originalFilePath { + try? FileManager.default.removeItem(atPath: originalFilePath) + } + + return try performDelete(db) + } } // MARK: - CustomStringConvertible @@ -623,6 +650,19 @@ extension Attachment { return "\(OWSFileSystem.cachesDirectoryPath())/\(id)-thumbnails" } + var legacyThumbnailPath: String? { + guard + let originalFilePath: String = originalFilePath, + (isImage || isVideo || isAnimated) + else { return nil } + + let fileUrl: URL = URL(fileURLWithPath: originalFilePath) + let filename: String = fileUrl.lastPathComponent.filenameWithoutExtension + let containingDir: String = fileUrl.deletingLastPathComponent().absoluteString + + return "\(containingDir)/\(filename)-signal-ios-thumbnail.jpg" + } + var originalImage: UIImage? { guard let originalFilePath: String = originalFilePath else { return nil } diff --git a/SessionMessagingKit/Database/Models/ClosedGroup.swift b/SessionMessagingKit/Database/Models/ClosedGroup.swift index 60e3e7712..d9c087310 100644 --- a/SessionMessagingKit/Database/Models/ClosedGroup.swift +++ b/SessionMessagingKit/Database/Models/ClosedGroup.swift @@ -8,7 +8,7 @@ public struct ClosedGroup: Codable, Identifiable, FetchableRecord, PersistableRe public static var databaseTableName: String { "closedGroup" } internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id]) private static let thread = belongsTo(SessionThread.self, using: threadForeignKey) - private static let keyPairs = hasMany( + internal static let keyPairs = hasMany( ClosedGroupKeyPair.self, using: ClosedGroupKeyPair.closedGroupForeignKey ) diff --git a/SessionMessagingKit/Database/Models/Contact.swift b/SessionMessagingKit/Database/Models/Contact.swift index 92e7a5159..6b047e354 100644 --- a/SessionMessagingKit/Database/Models/Contact.swift +++ b/SessionMessagingKit/Database/Models/Contact.swift @@ -113,28 +113,11 @@ public extension Contact { static func fetchOrCreate(_ db: Database, id: ID) -> Contact { return ((try? fetchOne(db, id: id)) ?? Contact(id: id)) } - - static func fetchAllIds() -> [String] { - return GRDBStorage.shared - .read { db in - let userPublicKey: String = getUserHexEncodedPublicKey(db) - let contacts: [Contact] = try Contact - .filter(Contact.Columns.id != userPublicKey) - .filter(Contact.Columns.didApproveMe == true) - .fetchAll(db) - let profiles: [Profile] = try Profile - .fetchAll(db, ids: contacts.map { $0.id }) - - // Sort the contacts by their displayName value - return profiles - .sorted(by: { lhs, rhs -> Bool in lhs.displayName() < rhs.displayName() }) - .map { $0.id } - } - .defaulting(to: []) - } } // MARK: - Objective-C Support + +// TODO: Remove this when possible @objc(SMKContact) public class SMKContact: NSObject { @objc let isApproved: Bool @@ -158,5 +141,16 @@ public class SMKContact: NSObject { didApproveMe: existingContact?.didApproveMe ?? false ) } + + @objc(isBlockedFor:) + public static func isBlocked(id: String) -> Bool { + return GRDBStorage.shared + .read { db in + try Contact + .select(.isBlocked) + .asRequest(of: Bool.self) + .fetchOne(db) + } + .defaulting(to: false) + } } - diff --git a/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift b/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift index ae7d02a23..d8780fbc2 100644 --- a/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift +++ b/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift @@ -4,20 +4,196 @@ import Foundation import GRDB import SessionUtilitiesKit +/// 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 sentTimestampMs - case serverHash - case openGroupMessageServerId + case timestampMs + case variant + case serverExpirationTimestamp } - public let threadId: String - public let sentTimestampMs: Int64 - public let serverHash: String - public let openGroupMessageServerId: Int64 + public enum Variant: Int, Codable, CaseIterable, DatabaseValueConvertible { + /// **Note:** This value should only be used for entries created from the initial migration, when inserting + /// new records it will check if there is an existing legacy record and if so it will attempt to create a "legacy" + /// version of the new record to try and trip the unique constraint + case legacyEntry = 0 + + case readReceipt = 1 + case typingIndicator = 2 + case closedGroupControlMessage = 3 + case dataExtractionNotification = 4 + case expirationTimerUpdate = 5 + case configurationMessage = 6 + case unsendRequest = 7 + case messageRequestResponse = 8 + } + /// 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?, + isRetry: Bool = false + ) { + // All `VisibleMessage` values will have an associated `Interaction` so just let + // the unique constraints on that table prevent duplicate messages + if message is VisibleMessage { return nil } + + // TODO: Need to allow duplicates for call messages + + // If the message failed to process and we are retrying then there will already + // be a `ControlMessageProcessRecord`, so return nil to prevent the insertion + // causing a unique constraint violation + if isRetry { return nil } + + // Allow '.new' closed group config message duplicates in this case to avoid + // the following situation: + // • The app performed a background poll or received a push notification + // • This method was invoked and the received message timestamps table was updated + // • Processing wasn't finished + // • The user doesn't see the new closed group + if case .new = (message as? ClosedGroupControlMessage)?.kind { 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 + self.threadId = threadId + self.variant = { + switch message { + case is ReadReceipt: return .readReceipt + case is TypingIndicator: return .typingIndicator + case is ClosedGroupControlMessage: return .closedGroupControlMessage + case is DataExtractionNotification: return .dataExtractionNotification + case is ExpirationTimerUpdate: return .expirationTimerUpdate + case is ConfigurationMessage: return .configurationMessage + case is UnsendRequest: return .unsendRequest + case is MessageRequestResponse: return .messageRequestResponse + default: preconditionFailure("[ControlMessageProcessRecord] Unsupported message type") + } + }() + self.timestampMs = Int64(message.sentTimestamp ?? 0) // Default to `0` if not set + self.serverExpirationTimestamp = serverExpirationTimestamp + } + + public func insert(_ db: Database) throws { + // If this isn't a legacy entry then check if there is a single entry and, if so, + // try to create a "legacy entry" version of this record to see if a unique constraint + // conflict occurs + if !threadId.isEmpty && variant != .legacyEntry { + let legacyEntry: ControlMessageProcessRecord? = try? ControlMessageProcessRecord + .filter(Columns.threadId == "") + .filter(Columns.variant == Variant.legacyEntry) + .fetchOne(db) + + if legacyEntry != nil { + try ControlMessageProcessRecord( + threadId: "", + variant: .legacyEntry, + timestampMs: timestampMs, + serverExpirationTimestamp: (legacyEntry?.serverExpirationTimestamp ?? 0) + ).insert(db) + } + } + + try performInsert(db) + } +} + +// MARK: - Migration Extensions + +internal extension ControlMessageProcessRecord { + init?( + threadId: String, + variant: Interaction.Variant, + timestampMs: Int64 + ) { + switch variant { + case .standardOutgoing, .standardIncoming, .standardIncomingDeleted, + .infoClosedGroupCreated: + return nil + + case .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft: + self.variant = .closedGroupControlMessage + + case .infoDisappearingMessagesUpdate: + self.variant = .expirationTimerUpdate + + case .infoScreenshotNotification, .infoMediaSavedNotification: + self.variant = .dataExtractionNotification + + case .infoMessageRequestAccepted: + self.variant = .messageRequestResponse + } + + self.threadId = threadId + self.timestampMs = timestampMs + self.serverExpirationTimestamp = (Date().timeIntervalSince1970 + ControlMessageProcessRecord.defaultExpirationSeconds) + } + + /// This method should only be used for records created during migration from the legacy + /// `receivedMessageTimestamps` collection which doesn't include thread or variant info + /// + /// In order to get around this but maintain the unique constraints on everything we create entries for each timestamp + /// for every thread and every timestamp (while this is wildly inefficient there is a garbage collection process which will + /// clean out these excessive entries after `defaultExpirationSeconds`) + static func generateLegacyProcessRecords(_ db: Database, receivedMessageTimestamps: [Int64]) throws { + let defaultExpirationTimestamp: TimeInterval = ( + Date().timeIntervalSince1970 + ControlMessageProcessRecord.defaultExpirationSeconds + ) + + try receivedMessageTimestamps.forEach { timestampMs in + try ControlMessageProcessRecord( + threadId: "", + variant: .legacyEntry, + timestampMs: timestampMs, + serverExpirationTimestamp: defaultExpirationTimestamp + ).insert(db) + } + } + + /// 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 13c30a7cd..b809c947d 100644 --- a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift +++ b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift @@ -85,12 +85,6 @@ public extension DisappearingMessagesConfiguration { } } - var durationIndex: Int { - return DisappearingMessagesConfiguration.validDurationsSeconds - .firstIndex(of: durationSeconds) - .defaulting(to: 0) - } - var durationString: String { NSString.formatDurationSeconds(UInt32(durationSeconds), useShortFormat: false) } @@ -133,7 +127,98 @@ extension DisappearingMessagesConfiguration { } // MARK: - Objective-C Support + +// TODO: Remove this when possible + @objc(SMKDisappearingMessagesConfiguration) public class SMKDisappearingMessagesConfiguration: NSObject { @objc public static var maxDurationSeconds: UInt = UInt(DisappearingMessagesConfiguration.maxDurationSeconds) + + @objc public static var validDurationsSeconds: [UInt] = DisappearingMessagesConfiguration + .validDurationsSeconds + .map { UInt($0) } + + @objc(isEnabledFor:) + public static func isEnabled(for threadId: String) -> Bool { + return GRDBStorage.shared + .read { db in + try DisappearingMessagesConfiguration + .select(.isEnabled) + .filter(id: threadId) + .asRequest(of: Bool.self) + .fetchOne(db) + } + .defaulting(to: false) + } + + @objc(durationIndexFor:) + public static func durationIndex(for threadId: String) -> Int { + let durationSeconds: TimeInterval = GRDBStorage.shared + .read { db in + try DisappearingMessagesConfiguration + .select(.durationSeconds) + .filter(id: threadId) + .asRequest(of: TimeInterval.self) + .fetchOne(db) + } + .defaulting(to: DisappearingMessagesConfiguration.defaultDuration) + + return DisappearingMessagesConfiguration.validDurationsSeconds + .firstIndex(of: durationSeconds) + .defaulting(to: 0) + } + + @objc(durationStringFor:) + public static func durationString(for index: Int) -> String { + let durationSeconds: TimeInterval = ( + index >= 0 && index < DisappearingMessagesConfiguration.validDurationsSeconds.count ? + DisappearingMessagesConfiguration.validDurationsSeconds[index] : + DisappearingMessagesConfiguration.validDurationsSeconds[0] + ) + + return NSString.formatDurationSeconds(UInt32(durationSeconds), useShortFormat: false) + } + + @objc(update:isEnabled:durationIndex:) + public static func update(_ threadId: String, isEnabled: Bool, durationIndex: Int) { + let durationSeconds: TimeInterval = ( + durationIndex >= 0 && durationIndex < DisappearingMessagesConfiguration.validDurationsSeconds.count ? + DisappearingMessagesConfiguration.validDurationsSeconds[durationIndex] : + DisappearingMessagesConfiguration.validDurationsSeconds[0] + ) + + GRDBStorage.shared.write { db in + guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { + return + } + + let config: DisappearingMessagesConfiguration = (try DisappearingMessagesConfiguration + .fetchOne(db, id: threadId)? + .with( + isEnabled: isEnabled, + durationSeconds: durationSeconds + ) + .saved(db)) + .defaulting(to: DisappearingMessagesConfiguration.defaultWith(threadId)) + + let interaction: Interaction = try Interaction( + threadId: threadId, + authorId: getUserHexEncodedPublicKey(db), + variant: .infoDisappearingMessagesUpdate, + body: config.messageInfoString(with: nil), + timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) + ) + .saved(db) + + try MessageSender.send( + db, + message: ExpirationTimerUpdate( + syncTarget: nil, + duration: UInt32(floor(durationSeconds)) + ), + interactionId: interaction.id, + in: thread + ) + } + } } diff --git a/SessionMessagingKit/Database/Models/GroupMember.swift b/SessionMessagingKit/Database/Models/GroupMember.swift index b9024564b..1387d9eda 100644 --- a/SessionMessagingKit/Database/Models/GroupMember.swift +++ b/SessionMessagingKit/Database/Models/GroupMember.swift @@ -56,3 +56,39 @@ public struct GroupMember: Codable, FetchableRecord, PersistableRecord, TableRec self.role = role } } + +// MARK: - Objective-C Support + +// FIXME: Remove when possible + +@objc(SMKGroupMember) +public class SMKGroupMember: NSObject { + @objc(isCurrentUserMemberOf:) + public static func isCurrentUserMember(of groupId: String) -> Bool { + return GRDBStorage.shared.read { db in + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let numEntries: Int = try GroupMember + .filter(GroupMember.Columns.groupId == groupId) + .filter(GroupMember.Columns.profileId == userPublicKey) + .fetchCount(db) + + return (numEntries > 0) + } + .defaulting(to: false) + } + + @objc(isCurrentUserAdminOf:) + public static func isCurrentUserAdmin(of groupId: String) -> Bool { + return GRDBStorage.shared.read { db in + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let numEntries: Int = try GroupMember + .filter(GroupMember.Columns.groupId == groupId) + .filter(GroupMember.Columns.profileId == userPublicKey) + .filter(GroupMember.Columns.role == GroupMember.Role.admin) + .fetchCount(db) + + return (numEntries > 0) + } + .defaulting(to: false) + } +} diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index c18aaba83..0f9371eea 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -13,7 +13,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu ) public static let thread = belongsTo(SessionThread.self, using: threadForeignKey) public static let profile = hasOne(Profile.self, using: Profile.interactionForeignKey) - internal static let interactionAttachments = hasMany( + public static let interactionAttachments = hasMany( InteractionAttachment.self, using: InteractionAttachment.interactionForeignKey ) diff --git a/SessionMessagingKit/Database/Models/OpenGroup.swift b/SessionMessagingKit/Database/Models/OpenGroup.swift index 77d75f091..73454bf00 100644 --- a/SessionMessagingKit/Database/Models/OpenGroup.swift +++ b/SessionMessagingKit/Database/Models/OpenGroup.swift @@ -121,5 +121,45 @@ public struct OpenGroup: Codable, Identifiable, FetchableRecord, PersistableReco public extension OpenGroup { static func idFor(room: String, server: String) -> String { return "\(server.lowercased()).\(room)" + +// MARK: - Objective-C Support + +// TODO: Remove this when possible + +@objc(SMKOpenGroup) +public class SMKOpenGroup: NSObject { + @objc(inviteUsers:toOpenGroupFor:) + public static func invite(selectedUsers: Set, threadId: String) { + GRDBStorage.shared.write { db in + guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { return } + + let urlString: String = "\(openGroup.server)/\(openGroup.room)?public_key=\(openGroup.publicKey)" + + try selectedUsers.forEach { userId in + let thread: SessionThread = try SessionThread.fetchOrCreate(db, id: userId, variant: .contact) + + try LinkPreview( + url: urlString, + variant: .openGroupInvitation, + title: openGroup.name + ) + .save(db) + + let interaction: Interaction = try Interaction( + threadId: threadId, + authorId: userId, + variant: .standardOutgoing, + timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)), + linkPreviewUrl: urlString + ) + .saved(db) + + try MessageSender.send( + db, + interaction: interaction, + in: thread + ) + } + } } } diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index 196e5d91e..a7f97da8f 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -10,6 +10,7 @@ public struct Profile: Codable, Identifiable, Equatable, FetchableRecord, Persis internal static let interactionForeignKey = ForeignKey([Columns.id], to: [Interaction.Columns.authorId]) internal static let contactForeignKey = ForeignKey([Columns.id], to: [Contact.Columns.id]) internal static let groupMemberForeignKey = ForeignKey([Columns.id], to: [GroupMember.Columns.profileId]) + internal static let contact = hasOne(Contact.self, using: contactForeignKey) public static let groupMembers = hasMany(GroupMember.self, using: groupMemberForeignKey) public typealias Columns = CodingKeys @@ -204,9 +205,9 @@ public extension Profile { public extension Profile { func with( name: String? = nil, - nickname: Updatable = .existing, - profilePictureUrl: Updatable = .existing, - profilePictureFileName: Updatable = .existing, + nickname: Updatable = .existing, + profilePictureUrl: Updatable = .existing, + profilePictureFileName: Updatable = .existing, profileEncryptionKey: Updatable = .existing ) -> Profile { return Profile( @@ -223,6 +224,22 @@ public extension Profile { // MARK: - GRDB Interactions public extension Profile { + static func fetchAllContactProfiles(excludeCurrentUser: Bool = true) -> [Profile] { + return GRDBStorage.shared + .read { db in + // Sort the contacts by their displayName value + return try Profile + .filter(Profile.Columns.id != (excludeCurrentUser ? "" : getUserHexEncodedPublicKey(db))) + .joining( + required: Profile.contact + .filter(Contact.Columns.didApproveMe == true) + ) + .fetchAll(db) + .sorted(by: { lhs, rhs -> Bool in lhs.displayName() < rhs.displayName() }) + } + .defaulting(to: []) + } + static func displayName(_ db: Database? = nil, id: ID, thread: SessionThread, customFallback: String? = nil) -> String { return displayName( db, @@ -402,8 +419,8 @@ public extension Profile { ) -> String { if let nickname: String = nickname { return nickname } - guard let name: String = name else { - return (customFallback ?? Profile.truncated(id: id, truncating: .start)) + guard let name: String = name, name != id else { + return (customFallback ?? Profile.truncated(id: id, truncating: .middle)) } switch context { @@ -418,6 +435,9 @@ public extension Profile { } // MARK: - Objective-C Support + +// FIXME: Remove when possible + @objc(SMKProfile) public class SMKProfile: NSObject { var id: String @@ -480,4 +500,19 @@ public class SMKProfile: NSObject { @objc public static var localProfileKey: OWSAES256Key? { Profile.fetchOrCreateCurrentUser().profileEncryptionKey } + + @objc(displayNameAfterSavingNickname:forProfileId:) + public static func displayNameAfterSaving(nickname: String?, for profileId: String) -> String { + return GRDBStorage.shared.write { db in + let profile: Profile = Profile.fetchOrCreate(id: profileId) + let targetNickname: String? = ((nickname ?? "").count > 0 ? nickname : nil) + + _ = try profile + .with(nickname: .update(targetNickname)) + .saved(db) + + return (targetNickname ?? profile.name) + } + .defaulting(to: "") + } } diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index fab84db2b..f411e030d 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -23,9 +23,9 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, case shouldBeVisible case isPinned case messageDraft - case notificationMode case notificationSound case mutedUntilTimestamp + case onlyNotifyForMentions } public enum Variant: Int, Codable, DatabaseValueConvertible { @@ -33,12 +33,6 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, case closedGroup case openGroup } - - public enum NotificationMode: Int, Codable, DatabaseValueConvertible { - case none - case all - case mentionsOnly // Only applicable to group threads - } /// Unique identifier for a thread (formerly known as uniqueId) /// @@ -63,9 +57,6 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, /// The value the user started entering into the input field before they left the conversation screen public let messageDraft: String? - /// The notification mode this thread is set to - public let notificationMode: NotificationMode - /// The sound which should be used when receiving a notification for this thread /// /// **Note:** If unset this will use the `Preferences.Sound.defaultNotificationSound` @@ -74,6 +65,9 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, /// Timestamp (seconds since epoch) for when this thread should stop being muted public let mutedUntilTimestamp: TimeInterval? + /// A flag indicating whether the thread should only notify for mentions + public let onlyNotifyForMentions: Bool + // MARK: - Relationships public var contact: QueryInterfaceRequest { @@ -105,9 +99,9 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, shouldBeVisible: Bool = false, isPinned: Bool = false, messageDraft: String? = nil, - notificationMode: NotificationMode = .all, notificationSound: Preferences.Sound? = nil, - mutedUntilTimestamp: TimeInterval? = nil + mutedUntilTimestamp: TimeInterval? = nil, + onlyNotifyForMentions: Bool = false ) { self.id = id self.variant = variant @@ -115,9 +109,9 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, self.shouldBeVisible = shouldBeVisible self.isPinned = isPinned self.messageDraft = messageDraft - self.notificationMode = notificationMode self.notificationSound = notificationSound self.mutedUntilTimestamp = mutedUntilTimestamp + self.onlyNotifyForMentions = onlyNotifyForMentions } // MARK: - Custom Database Interaction @@ -146,9 +140,9 @@ public extension SessionThread { shouldBeVisible: (shouldBeVisible ?? self.shouldBeVisible), isPinned: (isPinned ?? self.isPinned), messageDraft: messageDraft, - notificationMode: notificationMode, notificationSound: notificationSound, - mutedUntilTimestamp: mutedUntilTimestamp + mutedUntilTimestamp: mutedUntilTimestamp, + onlyNotifyForMentions: onlyNotifyForMentions ) } } @@ -303,3 +297,66 @@ public extension SessionThread { } } } + +// MARK: - Objective-C Support + +// FIXME: Remove when possible + +@objc(SMKThread) +public class SMKThread: NSObject { + @objc(isThreadMuted:) + public static func isThreadMuted(_ threadId: String) -> Bool { + return GRDBStorage.shared.read { db in + let mutedUntilTimestamp: TimeInterval? = try SessionThread + .select(SessionThread.Columns.mutedUntilTimestamp) + .filter(id: threadId) + .asRequest(of: TimeInterval?.self) + .fetchOne(db) + + return (mutedUntilTimestamp != nil) + } + .defaulting(to: false) + } + + @objc(isOnlyNotifyingForMentions:) + public static func isOnlyNotifyingForMentions(_ threadId: String) -> Bool { + return GRDBStorage.shared.read { db in + return try SessionThread + .select(SessionThread.Columns.onlyNotifyForMentions == true) + .filter(id: threadId) + .asRequest(of: Bool.self) + .fetchOne(db) + } + .defaulting(to: false) + } + + @objc(setIsOnlyNotifyingForMentions:to:) + public static func isOnlyNotifyingForMentions(_ threadId: String, isEnabled: Bool) { + GRDBStorage.shared.write { db in + try SessionThread + .filter(id: threadId) + .updateAll(db, SessionThread.Columns.onlyNotifyForMentions.set(to: isEnabled)) + } + } + + @objc(mutedUntilDateFor:) + public static func mutedUntilDateFor(_ threadId: String) -> Date? { + return GRDBStorage.shared.read { db in + return try SessionThread + .select(SessionThread.Columns.mutedUntilTimestamp) + .filter(id: threadId) + .asRequest(of: TimeInterval.self) + .fetchOne(db) + } + .map { Date(timeIntervalSince1970: $0) } + } + + @objc(updateWithMutedUntilDateTo:forThreadId:) + public static func updateWithMutedUntilDate(to date: Date?, threadId: String) { + GRDBStorage.shared.write { db in + try SessionThread + .filter(id: threadId) + .updateAll(db, SessionThread.Columns.mutedUntilTimestamp.set(to: date?.timeIntervalSince1970)) + } + } +} diff --git a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift index 1d1ad1b85..335bcb73c 100644 --- a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift @@ -40,6 +40,7 @@ public enum MessageReceiveJob: JobExecutor { let (message, proto) = try MessageReceiver.parse( db, data: messageInfo.data, + serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, isRetry: isRetry ) message.serverHash = messageInfo.serverHash @@ -115,13 +116,16 @@ extension MessageReceiveJob { public struct MessageInfo: Codable { public let data: Data public let serverHash: String? + public let serverExpirationTimestamp: TimeInterval? public init( data: Data, - serverHash: String? + serverHash: String?, + serverExpirationTimestamp: TimeInterval? ) { self.data = data self.serverHash = serverHash + self.serverExpirationTimestamp = serverExpirationTimestamp } } diff --git a/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift b/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift index 01557954d..2b57181a3 100644 --- a/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift +++ b/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift @@ -27,7 +27,7 @@ public final class DataExtractionNotification : ControlMessage { // MARK: - Initialization - internal init(kind: Kind) { + public init(kind: Kind) { super.init() self.kind = kind diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index 30f7ce242..5423294c6 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -177,9 +177,9 @@ extension MessageReceiver { else { return } _ = try Interaction( - serverHash: message.serverHash, // TODO: Test this? (make sure it won't break anything?) + serverHash: message.serverHash, threadId: thread.id, - authorId: sender, // TODO: Confirm this + authorId: sender, variant: { switch messageKind { case .screenshot: return .infoScreenshotNotification @@ -209,21 +209,16 @@ extension MessageReceiver { .defaulting(to: DisappearingMessagesConfiguration.defaultWith(thread.id)) .with( // If there is no duration then we should disable the expiration timer - isEnabled: (message.duration != nil), + isEnabled: ((message.duration ?? 0) > 0), durationSeconds: ( message.duration.map { TimeInterval($0) } ?? DisappearingMessagesConfiguration.defaultDuration ) ) - .saved(db) // Add an info message for the user - // - // Note: If it's a duplicate message (which the 'ExpirationTimerUpdate' frequently can be) - // then the write transaction will fail meaning the above config update won't be applied - // so we don't need to worry about order-of-execution) _ = try Interaction( - serverHash: message.serverHash, + serverHash: nil, // Intentionally null so sync messages are seen as duplicates threadId: thread.id, authorId: sender, variant: .infoDisappearingMessagesUpdate, @@ -235,6 +230,10 @@ extension MessageReceiver { ), timestampMs: Int64(message.sentTimestamp ?? 0) // Default to `0` if not set ).inserted(db) + + // Finally save the changes to the DisappearingMessagesConfiguration (If it's a duplicate + // then the interaction unique constraint will prevent the code from getting here) + try config.save(db) } // MARK: - Configuration Messages @@ -822,10 +821,12 @@ extension MessageReceiver { let groupAlreadyExisted: Bool = ((try? SessionThread.exists(db, id: groupPublicKey)) ?? false) let thread: SessionThread = try SessionThread .fetchOrCreate(db, id: groupPublicKey, variant: .closedGroup) + .with(shouldBeVisible: true) + .saved(db) let closedGroup: ClosedGroup = try ClosedGroup( threadId: groupPublicKey, name: name, - formationTimestamp: Date().timeIntervalSince1970 + formationTimestamp: (TimeInterval(messageSentTimestamp) / 1000) ).saved(db) // Clear the zombie list if the group wasn't active (ie. had no keys) @@ -845,7 +846,7 @@ extension MessageReceiver { threadId: thread.id, authorId: getUserHexEncodedPublicKey(db), variant: .infoClosedGroupCreated, - timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) + timestampMs: Int64(messageSentTimestamp) ).inserted(db) } @@ -862,8 +863,6 @@ extension MessageReceiver { ) .save(db) - // Add the group to the user's set of public keys to poll for - Storage.shared.addClosedGroupPublicKey(groupPublicKey, using: transaction) // Store the key pair try ClosedGroupKeyPair( threadId: groupPublicKey, @@ -952,7 +951,7 @@ extension MessageReceiver { guard name != closedGroup.name else { return } _ = try Interaction( - serverHash: message.serverHash, // TODO: Test this? (make sure it won't break anything?) + serverHash: message.serverHash, threadId: thread.id, authorId: sender, variant: .infoClosedGroupUpdated, @@ -1017,7 +1016,7 @@ extension MessageReceiver { guard members != Set(groupMembers.map { $0.profileId }) else { return } _ = try Interaction( - serverHash: message.serverHash, // TODO: Test this? (make sure it won't break anything?) + serverHash: message.serverHash, threadId: thread.id, authorId: sender, variant: .infoClosedGroupUpdated, @@ -1071,6 +1070,7 @@ extension MessageReceiver { _ = try closedGroup .keyPairs .deleteAll(db) + let _ = PushNotificationAPI.performOperation( .unsubscribe, for: id, @@ -1090,7 +1090,7 @@ extension MessageReceiver { guard members != Set(groupMembers.map { $0.profileId }) else { return } _ = try Interaction( - serverHash: message.serverHash, // TODO: Test this? (make sure it won't break anything?) + serverHash: message.serverHash, threadId: thread.id, authorId: sender, variant: (wasCurrentUserRemoved ? .infoClosedGroupCurrentUserLeft : .infoClosedGroupUpdated), @@ -1140,6 +1140,7 @@ extension MessageReceiver { _ = try closedGroup .keyPairs .deleteAll(db) + let _ = PushNotificationAPI.performOperation( .unsubscribe, for: id, @@ -1162,7 +1163,7 @@ extension MessageReceiver { guard updatedMemberIds != Set(members.map { $0.profileId }) else { return } _ = try Interaction( - serverHash: message.serverHash, // TODO: Test this? (make sure it won't break anything?) + serverHash: message.serverHash, threadId: thread.id, authorId: sender, variant: .infoClosedGroupUpdated, @@ -1272,7 +1273,7 @@ extension MessageReceiver { // Get the existing thead and notify the user if let thread: SessionThread = try? SessionThread.fetchOne(db, id: senderId) { _ = try Interaction( - serverHash: message.serverHash, // TODO: Test this? (make sure it won't break anything?) + serverHash: message.serverHash, threadId: thread.id, authorId: senderId, variant: .infoMessageRequestAccepted, diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index 414cb9783..d8615a5ea 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -11,6 +11,7 @@ public enum MessageReceiver { public static func parse( _ db: Database, data: Data, + serverExpirationTimestamp: TimeInterval?, openGroupId: String? = nil, openGroupMessageServerId: UInt64? = nil, isRetry: Bool = false @@ -159,47 +160,22 @@ public enum MessageReceiver { throw MessageReceiverError.invalidMessage } - // If the message failed to process the first time around we retry it later (if the error is retryable). In this case the timestamp - // will already be in the database but we don't want to treat the message as a duplicate. The isRetry flag is a simple workaround - // for this issue. - - switch (isRetry, message, (message as? ClosedGroupControlMessage)?.kind) { - // Allow duplicates in this case to avoid the following situation: - // • The app performed a background poll or received a push notification - // • This method was invoked and the received message timestamps table was updated - // • Processing wasn't finished - // • The user doesn't see the new closed group - case (_, _, .new): break + // Prevent ControlMessages from being handled multiple times if not supported + try ControlMessageProcessRecord( + threadId: { + if let groupPublicKey: String = groupPublicKey { return groupPublicKey } + if let openGroupId: String = openGroupId { return openGroupId } - // All `VisibleMessage` values will have an associated `Interaction` so just let - // the unique constraints on that table prevent duplicate messages - case is (Bool, VisibleMessage, ClosedGroupControlMessage.Kind?): break - - // If the message failed to process and we are retrying then there will already - // be a `ControlMessageProcessRecord`, so just allow this through - case (true, _, _): break - - default: - do { - try ControlMessageProcessRecord( - threadId: { - if let openGroupId: String = openGroupId { - return openGroupId - } - - if let groupPublicKey: String = groupPublicKey { - return groupPublicKey - } - - return sender - }(), - sentTimestampMs: Int64(envelope.timestamp), - serverHash: (message.serverHash ?? ""), - openGroupMessageServerId: (openGroupMessageServerId.map { Int64($0) } ?? 0) - ).insert(db) + switch message { + case let message as VisibleMessage: return (message.syncTarget ?? sender) + case let message as ExpirationTimerUpdate: return (message.syncTarget ?? sender) + default: return sender } - catch { throw MessageReceiverError.duplicateMessage } - } + }(), + message: message, + serverExpirationTimestamp: serverExpirationTimestamp, + isRetry: false + )?.insert(db) // Return return (message, proto) diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index d0e81d4f0..87c64f7bf 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -484,31 +484,23 @@ public final class MessageSender : NSObject { ) } - // Prevent the same ExpirationTimerUpdate to be handled twice - if message is ControlMessage { - try? ControlMessageProcessRecord( - threadId: { - switch destination { - case .contact(let publicKey): return publicKey - case .closedGroup(let groupPublicKey): return groupPublicKey - case .openGroupV2(let room, let server): - return OpenGroup.idFor(room: room, server: server) - - // FIXME: Remove support for V1 SOGS - case .openGroup: return getUserHexEncodedPublicKey(db) - } - }(), - sentTimestampMs: { - if message.openGroupServerMessageId != nil { - return (serverTimestampMs.map { Int64($0) } ?? 0) - } - - return (message.sentTimestamp.map { Int64($0) } ?? 0) - }(), - serverHash: (message.serverHash ?? ""), - openGroupMessageServerId: (message.openGroupServerMessageId.map { Int64($0) } ?? 0) - ).insert(db) - } + // Prevent ControlMessages from being handled multiple times if not supported + try? ControlMessageProcessRecord( + threadId: { + switch destination { + case .contact(let publicKey): return publicKey + case .closedGroup(let groupPublicKey): return groupPublicKey + case .openGroupV2(let room, let server): + return OpenGroup.idFor(room: room, server: server) + + case .openGroup(_, _): return "" // TODO: Remove this after merge + } + }(), + message: message, + serverExpirationTimestamp: (Date().timeIntervalSince1970 + ControlMessageProcessRecord.defaultExpirationSeconds), + isRetry: false + )?.insert(db) + // Sync the message if: // • it's a visible message or an expiration timer update // • the destination was a contact diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift index 4787cf460..7c441a95e 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift @@ -6,41 +6,57 @@ import PromiseKit import SessionSnodeKit @objc(LKClosedGroupPoller) -public final class ClosedGroupPoller : NSObject { - private var isPolling: [String:Bool] = [:] - private var timers: [String:Timer] = [:] - private let internalQueue: DispatchQueue = DispatchQueue(label:"isPollingQueue") +public final class ClosedGroupPoller: NSObject { + private var isPolling: [String: Bool] = [:] + private var timers: [String: Timer] = [:] + private let internalQueue: DispatchQueue = DispatchQueue(label: "isPollingQueue") - // MARK: Settings + // MARK: - Settings + private static let minPollInterval: Double = 2 private static let maxPollInterval: Double = 30 - // MARK: Error - private enum Error : LocalizedError { + // MARK: - Error + + private enum Error: LocalizedError { case insufficientSnodes case pollingCanceled internal var errorDescription: String? { switch self { - case .insufficientSnodes: return "No snodes left to poll." - case .pollingCanceled: return "Polling canceled." + case .insufficientSnodes: return "No snodes left to poll." + case .pollingCanceled: return "Polling canceled." } } } - // MARK: Initialization + // MARK: - Initialization + public static let shared = ClosedGroupPoller() private override init() { } - // MARK: Public API + // MARK: - Public API + @objc public func start() { #if DEBUG assert(Thread.current.isMainThread) // Timers don't do well on background queues #endif - let storage = SNMessagingKitConfiguration.shared.storage - let allGroupPublicKeys = storage.getUserClosedGroupPublicKeys() - allGroupPublicKeys.forEach { startPolling(for: $0) } + + // Fetch all closed groups (excluding any which have no key pairs as the user is + // no longer a member of those + GRDBStorage.shared + .read { db in + try ClosedGroup + .select(.threadId) + .joining(required: ClosedGroup.keyPairs) + .asRequest(of: String.self) + .fetchAll(db) + } + .defaulting(to: []) + .forEach { [weak self] groupPublicKey in + self?.startPolling(for: groupPublicKey) + } } public func startPolling(for groupPublicKey: String) { @@ -53,9 +69,17 @@ public final class ClosedGroupPoller : NSObject { } @objc public func stop() { - let storage = SNMessagingKitConfiguration.shared.storage - let allGroupPublicKeys = storage.getUserClosedGroupPublicKeys() - allGroupPublicKeys.forEach { stopPolling(for: $0) } + GRDBStorage.shared + .read { db in + try ClosedGroup + .select(.threadId) + .asRequest(of: String.self) + .fetchAll(db) + } + .defaulting(to: []) + .forEach { [weak self] groupPublicKey in + self?.stopPolling(for: groupPublicKey) + } } public func stopPolling(for groupPublicKey: String) { @@ -63,29 +87,48 @@ public final class ClosedGroupPoller : NSObject { timers[groupPublicKey]?.invalidate() } - // MARK: Private API + // MARK: - Private API + private func setUpPolling(for groupPublicKey: String) { Threading.pollerQueue.async { - self.poll(groupPublicKey).done(on: Threading.pollerQueue) { [weak self] _ in - self?.pollRecursively(groupPublicKey) - }.catch(on: Threading.pollerQueue) { [weak self] error in - // The error is logged in poll(_:) - self?.pollRecursively(groupPublicKey) - } + self.poll(groupPublicKey) + .done(on: Threading.pollerQueue) { [weak self] _ in + self?.pollRecursively(groupPublicKey) + } + .catch(on: Threading.pollerQueue) { [weak self] error in + // The error is logged in poll(_:) + self?.pollRecursively(groupPublicKey) + } } } private func pollRecursively(_ groupPublicKey: String) { - let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey) - guard isPolling(for: groupPublicKey), - let thread = TSGroupThread.fetch(uniqueId: TSGroupThread.threadId(fromGroupId: groupID)) else { return } + guard + isPolling(for: groupPublicKey), + let thread: SessionThread = GRDBStorage.shared.read({ db in try SessionThread.fetchOne(db, id: groupPublicKey) }) + else { return } + // Get the received date of the last message in the thread. If we don't have any messages yet, pick some - // reasonable fake time interval to use instead. - let lastMessageDate = - (thread.numberOfInteractions() > 0) ? thread.lastInteraction.receivedAtDate() : Date().addingTimeInterval(-5 * 60) - let timeSinceLastMessage = Date().timeIntervalSince(lastMessageDate) - let minPollInterval = ClosedGroupPoller.minPollInterval - let limit: Double = 12 * 60 * 60 + // reasonable fake time interval to use instead + + let lastMessageDate: Date = GRDBStorage.shared + .read { db in + try thread + .interactions + .select(.receivedAtTimestampMs) + .order(Interaction.Columns.timestampMs.desc) + .asRequest(of: Int64.self) + .fetchOne(db) + } + .map { receivedAtTimestampMs -> Date? in + guard receivedAtTimestampMs > 0 else { return nil } + + return Date(timeIntervalSince1970: (TimeInterval(receivedAtTimestampMs) / 1000)) + } + .defaulting(to: Date().addingTimeInterval(-5 * 60)) + let timeSinceLastMessage: TimeInterval = Date().timeIntervalSince(lastMessageDate) + let minPollInterval: Double = ClosedGroupPoller.minPollInterval + let limit: Double = (12 * 60 * 60) let a = (ClosedGroupPoller.maxPollInterval - minPollInterval) / limit let nextPollInterval = a * min(timeSinceLastMessage, limit) + minPollInterval SNLog("Next poll interval for closed group with public key: \(groupPublicKey) is \(nextPollInterval) s.") @@ -133,7 +176,8 @@ public final class ClosedGroupPoller : NSObject { jobDetailMessages.append( MessageReceiveJob.Details.MessageInfo( data: try envelope.serializedData(), - serverHash: message.info.hash + serverHash: message.info.hash, + serverExpirationTimestamp: (TimeInterval(message.info.expirationDateMs) / 1000) ) ) diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift index f64d0c2eb..a58ea941d 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift @@ -124,7 +124,8 @@ public final class Poller : NSObject { .appending( MessageReceiveJob.Details.MessageInfo( data: try envelope.serializedData(), - serverHash: message.info.hash + serverHash: message.info.hash, + serverExpirationTimestamp: (TimeInterval(message.info.expirationDateMs) / 1000) ) ) diff --git a/SessionNotificationServiceExtension/NSENotificationPresenter.swift b/SessionNotificationServiceExtension/NSENotificationPresenter.swift index 5a5343d56..55fe20d65 100644 --- a/SessionNotificationServiceExtension/NSENotificationPresenter.swift +++ b/SessionNotificationServiceExtension/NSENotificationPresenter.swift @@ -7,9 +7,9 @@ import SignalUtilitiesKit import SessionMessagingKit public class NSENotificationPresenter: NSObject, NotificationsProtocol { - + public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, isBackgroundPoll: Bool) { - guard thread.notificationMode != .none else { return } + guard Date().timeIntervalSince1970 < (thread.mutedUntilTimestamp ?? 0) else { return } let isMessageRequest = thread.isMessageRequest(db) @@ -48,7 +48,7 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { var notificationTitle = senderName if thread.variant == .closedGroup || thread.variant == .openGroup { - if thread.notificationMode == .mentionsOnly && !interaction.isUserMentioned(db) { + if thread.onlyNotifyForMentions && !interaction.isUserMentioned(db) { // Ignore PNs if the group is set to only notify for mentions return } diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index a0cdf0845..8d53f5262 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -45,7 +45,11 @@ public final class NotificationServiceExtension : UNNotificationServiceExtension // is added to notification center GRDBStorage.shared.write { db in do { - let (message, proto) = try MessageReceiver.parse(db, data: envelopeAsData) + let (message, proto) = try MessageReceiver.parse( + db, + data: envelopeAsData, + serverExpirationTimestamp: (Date().timeIntervalSince1970 + ControlMessageProcessRecord.defaultExpirationSeconds) + ) switch message { case let visibleMessage as VisibleMessage: let interactionId: Int64 = try MessageReceiver.handleVisibleMessage(db, message: visibleMessage, associatedWithProto: proto, openGroupId: nil, isBackgroundPoll: false) diff --git a/SessionUtilitiesKit/General/Set+Utilities.swift b/SessionUtilitiesKit/General/Set+Utilities.swift index 2bc8205ca..5fb2d416b 100644 --- a/SessionUtilitiesKit/General/Set+Utilities.swift +++ b/SessionUtilitiesKit/General/Set+Utilities.swift @@ -12,6 +12,15 @@ public extension Set { return updatedSet } + func inserting(contentsOf value: Set?) -> Set { + guard let value: Set = value else { return self } + + var updatedSet: Set = self + value.forEach { updatedSet.insert($0) } + + return updatedSet + } + func removing(_ value: Element?) -> Set { guard let value: Element = value else { return self } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift index 0b352f6af..0258dc0e3 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift @@ -330,7 +330,7 @@ public class AttachmentPrepViewController: OWSViewController, PlayerProgressBarD } } - @objc public func videoPlayerDidPlayToCompletion(_ videoPlayer: OWSVideoPlayer) { + public func videoPlayerDidPlayToCompletion(_ videoPlayer: OWSVideoPlayer) { UIView.animate(withDuration: 0.1) { [weak self] in self?.playVideoButton.alpha = 1.0 } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/OWSVideoPlayer.swift b/SignalUtilitiesKit/Media Viewing & Editing/OWSVideoPlayer.swift index 581b861cc..12f1e85f6 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/OWSVideoPlayer.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/OWSVideoPlayer.swift @@ -5,27 +5,21 @@ import Foundation import AVFoundation -@objc -public protocol OWSVideoPlayerDelegate: class { +public protocol OWSVideoPlayerDelegate: AnyObject { func videoPlayerDidPlayToCompletion(_ videoPlayer: OWSVideoPlayer) } -@objc -public class OWSVideoPlayer: NSObject { +public class OWSVideoPlayer { - @objc public let avPlayer: AVPlayer let audioActivity: AudioActivity - @objc public weak var delegate: OWSVideoPlayerDelegate? @objc public init(url: URL) { self.avPlayer = AVPlayer(url: url) self.audioActivity = AudioActivity(audioDescription: "[OWSVideoPlayer] url:\(url)", behavior: .playback) - super.init() - NotificationCenter.default.addObserver(self, selector: #selector(playerItemDidPlayToCompletion(_:)), name: NSNotification.Name.AVPlayerItemDidPlayToEndTime, diff --git a/SignalUtilitiesKit/Messaging/BlockListUIUtils.swift b/SignalUtilitiesKit/Messaging/BlockListUIUtils.swift index 1b3e53877..7f157918a 100644 --- a/SignalUtilitiesKit/Messaging/BlockListUIUtils.swift +++ b/SignalUtilitiesKit/Messaging/BlockListUIUtils.swift @@ -10,15 +10,15 @@ import SessionMessagingKit /// This method shows an alert to unblock a contact in a ContactThread and will update the `isBlocked` flag of the contact if the user decides to continue /// /// **Note:** Make sure to force a config sync in the `completionBlock` if the blocked state was successfully changed - @objc public static func showBlockThreadActionSheet(_ thread: TSContactThread, from viewController: UIViewController, completionBlock: ((Bool) -> ())? = nil) { + @objc public static func showBlockThreadActionSheet(_ threadId: String, from viewController: UIViewController, completionBlock: ((Bool) -> ())? = nil) { let userPublicKey = getUserHexEncodedPublicKey() - guard thread.contactSessionID() != userPublicKey else { + guard threadId != userPublicKey else { completionBlock?(false) return } - let displayName: String = Profile.displayName(id: thread.contactSessionID()) + let displayName: String = Profile.displayName(id: threadId) let actionSheet: UIAlertController = UIAlertController( title: String( format: "BLOCK_LIST_BLOCK_USER_TITLE_FORMAT".localized(), @@ -35,7 +35,7 @@ import SessionMessagingKit GRDBStorage.shared.writeAsync( updates: { db in try? Contact - .fetchOrCreate(db, id: thread.contactSessionID()) + .fetchOrCreate(db, id: threadId) .with(isBlocked: true) .save(db) }, @@ -68,8 +68,8 @@ import SessionMessagingKit /// This method shows an alert to unblock a contact in a ContactThread and will update the `isBlocked` flag of the contact if the user decides to continue /// /// **Note:** Make sure to force a config sync in the `completionBlock` if the blocked state was successfully changed - @objc public static func showUnblockThreadActionSheet(_ thread: TSContactThread, from viewController: UIViewController, completionBlock: ((Bool) -> ())? = nil) { - let displayName: String = Profile.displayName(id: thread.contactSessionID()) + @objc public static func showUnblockThreadActionSheet(_ threadId: String, from viewController: UIViewController, completionBlock: ((Bool) -> ())? = nil) { + let displayName: String = Profile.displayName(id: threadId) let actionSheet: UIAlertController = UIAlertController( title: String( format: "BLOCK_LIST_UNBLOCK_TITLE_FORMAT".localized(), @@ -86,7 +86,7 @@ import SessionMessagingKit GRDBStorage.shared.writeAsync( updates: { db in try? Contact - .fetchOrCreate(db, id: thread.contactSessionID()) + .fetchOrCreate(db, id: threadId) .with(isBlocked: false) .save(db) }, diff --git a/SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift b/SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift index a38915a40..868d5745c 100644 --- a/SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift +++ b/SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift @@ -41,8 +41,14 @@ public class ModalActivityIndicatorViewController: OWSViewController { } @objc - public class func present(fromViewController: UIViewController, canCancel: Bool = false, message: String? = nil, - backgroundBlock : @escaping (ModalActivityIndicatorViewController) -> Void) { + public class func present( + fromViewController: UIViewController?, + canCancel: Bool = false, + message: String? = nil, + backgroundBlock: @escaping (ModalActivityIndicatorViewController) -> Void + ) { + guard let fromViewController: UIViewController = fromViewController else { return } + AssertIsOnMainThread() let view = ModalActivityIndicatorViewController(canCancel: canCancel, message: message) diff --git a/SignalUtilitiesKit/Shared Views/GalleryRailView.swift b/SignalUtilitiesKit/Shared Views/GalleryRailView.swift index 013602477..b6f43c54b 100644 --- a/SignalUtilitiesKit/Shared Views/GalleryRailView.swift +++ b/SignalUtilitiesKit/Shared Views/GalleryRailView.swift @@ -5,15 +5,15 @@ import PromiseKit import SessionUIKit -public protocol GalleryRailItemProvider: class { +public protocol GalleryRailItemProvider: AnyObject { var railItems: [GalleryRailItem] { get } } -public protocol GalleryRailItem: class { +public protocol GalleryRailItem: AnyObject { func buildRailItemView() -> UIView } -protocol GalleryRailCellViewDelegate: class { +protocol GalleryRailCellViewDelegate: AnyObject { func didTapGalleryRailCellView(_ galleryRailCellView: GalleryRailCellView) } diff --git a/SignalUtilitiesKit/Utilities/UIView+OWS.swift b/SignalUtilitiesKit/Utilities/UIView+OWS.swift index 2e02932ef..2a8f2c736 100644 --- a/SignalUtilitiesKit/Utilities/UIView+OWS.swift +++ b/SignalUtilitiesKit/Utilities/UIView+OWS.swift @@ -132,26 +132,35 @@ public extension UIView { @objc public extension UIViewController { - public func presentAlert(_ alert: UIAlertController) { + func presentAlert(_ alert: UIAlertController) { self.presentAlert(alert, animated: true) } - public func presentAlert(_ alert: UIAlertController, animated: Bool) { - self.present(alert, - animated: animated, - completion: { - alert.applyAccessibilityIdentifiers() - }) + func presentAlert(_ alert: UIAlertController, animated: Bool) { + if !Thread.isMainThread { + DispatchQueue.main.async { [weak self] in + self?.presentAlert(alert, animated: animated) + } + return + } + + self.present(alert, animated: animated) { + alert.applyAccessibilityIdentifiers() + } } - public func presentAlert(_ alert: UIAlertController, completion: @escaping (() -> Void)) { - self.present(alert, - animated: true, - completion: { - alert.applyAccessibilityIdentifiers() - - completion() - }) + func presentAlert(_ alert: UIAlertController, completion: @escaping (() -> Void)) { + if !Thread.isMainThread { + DispatchQueue.main.async { [weak self] in + self?.presentAlert(alert, completion: completion) + } + return + } + + self.present(alert, animated: true) { + alert.applyAccessibilityIdentifiers() + completion() + } } } From 38bb6e79e2932c004cb1e4534a4fdc349881b0af Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 9 May 2022 09:33:23 +1000 Subject: [PATCH 072/157] Combined the Register/Unregister response objects in the PushNotificationAPI (they are identical) --- Session.xcodeproj/project.pbxproj | 12 ++++-------- .../Notifications/Models/UnregisterResponse.swift | 11 ----------- ...sponse.swift => UpdateRegistrationResponse.swift} | 2 +- .../Notifications/PushNotificationAPI.swift | 6 +++--- 4 files changed, 8 insertions(+), 23 deletions(-) delete mode 100644 SessionMessagingKit/Sending & Receiving/Notifications/Models/UnregisterResponse.swift rename SessionMessagingKit/Sending & Receiving/Notifications/Models/{RegisterResponse.swift => UpdateRegistrationResponse.swift} (80%) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 5f8e0474a..097c939e1 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -855,8 +855,7 @@ FDC4381527B329CE00C60D73 /* NonceGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381427B329CE00C60D73 /* NonceGenerator.swift */; }; FDC4381727B32EC700C60D73 /* Personalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381627B32EC700C60D73 /* Personalization.swift */; }; FDC4382027B36ADC00C60D73 /* SOGSEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381F27B36ADC00C60D73 /* SOGSEndpoint.swift */; }; - FDC4382F27B383AF00C60D73 /* UnregisterResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382E27B383AF00C60D73 /* UnregisterResponse.swift */; }; - FDC4383127B3841C00C60D73 /* RegisterResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4383027B3841C00C60D73 /* RegisterResponse.swift */; }; + FDC4382F27B383AF00C60D73 /* UpdateRegistrationResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382E27B383AF00C60D73 /* UpdateRegistrationResponse.swift */; }; FDC4383827B3863200C60D73 /* VersionResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4383727B3863200C60D73 /* VersionResponse.swift */; }; FDC4384C27B47F7700C60D73 /* OpenGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384B27B47F7700C60D73 /* OpenGroup.swift */; }; FDC4384F27B4804F00C60D73 /* Header.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384E27B4804F00C60D73 /* Header.swift */; }; @@ -2041,8 +2040,7 @@ FDC4381427B329CE00C60D73 /* NonceGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonceGenerator.swift; sourceTree = ""; }; FDC4381627B32EC700C60D73 /* Personalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Personalization.swift; sourceTree = ""; }; FDC4381F27B36ADC00C60D73 /* SOGSEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSEndpoint.swift; sourceTree = ""; }; - FDC4382E27B383AF00C60D73 /* UnregisterResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnregisterResponse.swift; sourceTree = ""; }; - FDC4383027B3841C00C60D73 /* RegisterResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterResponse.swift; sourceTree = ""; }; + FDC4382E27B383AF00C60D73 /* UpdateRegistrationResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateRegistrationResponse.swift; sourceTree = ""; }; FDC4383727B3863200C60D73 /* VersionResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionResponse.swift; sourceTree = ""; }; FDC4383D27B4708600C60D73 /* Atomic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = ""; }; FDC4384B27B47F7700C60D73 /* OpenGroup.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OpenGroup.swift; sourceTree = ""; }; @@ -4112,8 +4110,7 @@ FDC4382D27B383A600C60D73 /* Models */ = { isa = PBXGroup; children = ( - FDC4382E27B383AF00C60D73 /* UnregisterResponse.swift */, - FDC4383027B3841C00C60D73 /* RegisterResponse.swift */, + FDC4382E27B383AF00C60D73 /* UpdateRegistrationResponse.swift */, ); path = Models; sourceTree = ""; @@ -5493,7 +5490,7 @@ B8566C63256F55930045A0B9 /* OWSLinkPreview+Conversion.swift in Sources */, C32C5B3F256DC1DF003C73A2 /* TSQuotedMessage+Conversion.swift in Sources */, B8EB20EE2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift in Sources */, - FDC4382F27B383AF00C60D73 /* UnregisterResponse.swift in Sources */, + FDC4382F27B383AF00C60D73 /* UpdateRegistrationResponse.swift in Sources */, FDC4386327B4D94E00C60D73 /* SOGSMessage.swift in Sources */, C3C2A7712553A41E00C340D1 /* ControlMessage.swift in Sources */, C32C5D19256DD493003C73A2 /* OWSLinkPreview.swift in Sources */, @@ -5596,7 +5593,6 @@ C32C5C01256DC9A0003C73A2 /* OWSIdentityManager.m in Sources */, FDC438AA27BB12BB00C60D73 /* UserModeratorRequest.swift in Sources */, C32C59C4256DB41F003C73A2 /* TSContactThread.m in Sources */, - FDC4383127B3841C00C60D73 /* RegisterResponse.swift in Sources */, C32C5AB0256DBE8F003C73A2 /* TSOutgoingMessage.m in Sources */, B82A0C3826B9098200C1BCE3 /* MessageInvalidator.swift in Sources */, FDC4385D27B4C18900C60D73 /* Room.swift in Sources */, diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/UnregisterResponse.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/UnregisterResponse.swift deleted file mode 100644 index d14776e76..000000000 --- a/SessionMessagingKit/Sending & Receiving/Notifications/Models/UnregisterResponse.swift +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -extension PushNotificationAPI { - struct UnregisterResponse: Codable { - let body: String - let code: Int - let message: String? - } -} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/RegisterResponse.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/UpdateRegistrationResponse.swift similarity index 80% rename from SessionMessagingKit/Sending & Receiving/Notifications/Models/RegisterResponse.swift rename to SessionMessagingKit/Sending & Receiving/Notifications/Models/UpdateRegistrationResponse.swift index c1add2d2d..7d7cb788e 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/Models/RegisterResponse.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/UpdateRegistrationResponse.swift @@ -3,7 +3,7 @@ import Foundation extension PushNotificationAPI { - struct RegisterResponse: Codable { + struct UpdateRegistrationResponse: Codable { let body: String let code: Int let message: String? diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift index 743a15539..928e2768b 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift @@ -53,7 +53,7 @@ public final class PushNotificationAPI : NSObject { let promise: Promise = attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) { OnionRequestAPI.sendOnionRequest(request, to: server, using: .v2, with: serverPublicKey) .map2 { _, response in - guard let response: UnregisterResponse = try? response?.decoded(as: UnregisterResponse.self) else { + guard let response: UpdateRegistrationResponse = try? response?.decoded(as: UpdateRegistrationResponse.self) else { return SNLog("Couldn't unregister from push notifications.") } guard response.code != 0 else { @@ -102,7 +102,7 @@ public final class PushNotificationAPI : NSObject { let promise: Promise = attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) { OnionRequestAPI.sendOnionRequest(request, to: server, using: .v2, with: serverPublicKey) .map2 { _, response in - guard let response: RegisterResponse = try? response?.decoded(as: RegisterResponse.self) else { + guard let response: UpdateRegistrationResponse = try? response?.decoded(as: UpdateRegistrationResponse.self) else { return SNLog("Couldn't register device token.") } guard response.code != 0 else { @@ -151,7 +151,7 @@ public final class PushNotificationAPI : NSObject { let promise: Promise = attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) { OnionRequestAPI.sendOnionRequest(request, to: server, using: .v2, with: serverPublicKey) .map2 { _, response in - guard let response: RegisterResponse = try? response?.decoded(as: RegisterResponse.self) else { + guard let response: UpdateRegistrationResponse = try? response?.decoded(as: UpdateRegistrationResponse.self) else { return SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey).") } guard response.code != 0 else { From 333849c32ed5935ad494601ef423292885220926 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 9 May 2022 14:45:14 +1000 Subject: [PATCH 073/157] Logic for interactions with user mentions and closed group tweaks Added logic to flag interactions that mention the current user Fixed up closed group member handling --- Session/Closed Groups/EditClosedGroupVC.swift | 18 ++++-- .../OWSConversationSettingsViewController.m | 7 +- Session/Home/HomeVC.swift | 4 +- Session/Home/HomeViewModel.swift | 34 +++++++++- .../_001_InitialSetupMigration.swift | 4 ++ .../Migrations/_003_YDBToGRDBMigration.swift | 4 ++ .../Database/Models/Interaction.swift | 11 ++++ .../Database/Models/Profile.swift | 8 ++- .../Database/Models/SessionThread.swift | 44 +++++-------- .../MessageReceiver+Handling.swift | 64 +++++++++++++------ .../MessageSender+Convenience.swift | 2 + .../Pollers/ClosedGroupPoller.swift | 9 ++- .../NSENotificationPresenter.swift | 11 ++-- 13 files changed, 148 insertions(+), 72 deletions(-) diff --git a/Session/Closed Groups/EditClosedGroupVC.swift b/Session/Closed Groups/EditClosedGroupVC.swift index 56b7da6b4..a6c2e0731 100644 --- a/Session/Closed Groups/EditClosedGroupVC.swift +++ b/Session/Closed Groups/EditClosedGroupVC.swift @@ -130,6 +130,7 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat setUpViewHierarchy() updateNavigationBarButtons() + handleMembersChanged() } private func setUpViewHierarchy() { @@ -230,6 +231,7 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat let removeAction = UITableViewRowAction(style: .destructive, title: "Remove") { [weak self] _, _ in self?.adminIds.remove(profileId) self?.membersAndZombies.remove(at: indexPath.row) + self?.handleMembersChanged() } removeAction.backgroundColor = Colors.destructive @@ -320,13 +322,16 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat .asSet() ) { [weak self] selectedUserIds in GRDBStorage.shared.read { [weak self] db in - let profileAlias: TypedTableAlias = TypedTableAlias() - let selectedGroupMembers: [GroupMemberDisplayInfo] = try GroupMember - .filter(selectedUserIds.contains(GroupMember.Columns.profileId)) - .including(optional: GroupMember.profile.aliased(profileAlias)) - .asRequest(of: GroupMemberDisplayInfo.self) + let selectedGroupMembers: [GroupMemberDisplayInfo] = try Profile + .filter(selectedUserIds.contains(Profile.Columns.id)) .fetchAll(db) - + .map { profile in + GroupMemberDisplayInfo( + profileId: profile.id, + role: .standard, + profile: profile + ) + } self?.membersAndZombies = (self?.membersAndZombies ?? []) .appending(contentsOf: selectedGroupMembers) .sorted(by: { lhs, rhs in @@ -368,6 +373,7 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat self?.addMembersButton.isUserInteractionEnabled = (self?.hasContactsToAdd == true) self?.addMembersButton.layer.borderColor = color.cgColor self?.addMembersButton.setTitleColor(color, for: UIControl.State.normal) + self?.handleMembersChanged() } navigationController?.pushViewController(userSelectionVC, animated: true, completion: nil) diff --git a/Session/Conversations/Settings/OWSConversationSettingsViewController.m b/Session/Conversations/Settings/OWSConversationSettingsViewController.m index 0d7749665..d4d21a049 100644 --- a/Session/Conversations/Settings/OWSConversationSettingsViewController.m +++ b/Session/Conversations/Settings/OWSConversationSettingsViewController.m @@ -766,11 +766,8 @@ CGFloat kIconViewLength = 24; - (void)leaveGroup { - TSGroupThread *gThread = (TSGroupThread *)self.thread; - - if (gThread.isClosedGroup) { - NSString *groupPublicKey = [LKGroupUtilities getDecodedGroupID:gThread.groupModel.groupId]; - [[SNMessageSender leaveClosedGroupWithPublicKey:groupPublicKey] retainUntilComplete]; + if (self.isClosedGroup) { + [[SNMessageSender leaveClosedGroupWithPublicKey:self.threadId] retainUntilComplete]; } [self.navigationController popViewControllerAnimated:YES]; diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 8a1724e6f..ff816bf36 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -48,8 +48,8 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve right: 0 ) result.showsVerticalScrollIndicator = false - result.register(MessageRequestsCell.self, forCellReuseIdentifier: MessageRequestsCell.reuseIdentifier) - result.register(ConversationCell.self, forCellReuseIdentifier: ConversationCell.reuseIdentifier) + result.register(view: MessageRequestsCell.self) + result.register(view: ConversationCell.self) result.dataSource = self result.delegate = self diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 5ddf938ca..3fc9969e2 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -83,6 +83,7 @@ public class HomeViewModel { fileprivate static let isNoteToSelfKey = CodingKeys.isNoteToSelf.stringValue fileprivate static let currentUserIsClosedGroupAdminKey = CodingKeys.currentUserIsClosedGroupAdmin.stringValue fileprivate static let threadUnreadCountKey = CodingKeys.threadUnreadCount.stringValue + fileprivate static let threadUnreadMentionCountKey = CodingKeys.threadUnreadMentionCount.stringValue fileprivate static let lastInteractionInfoKey = CodingKeys.lastInteractionInfo.stringValue public var differenceIdentifier: String { id } @@ -109,7 +110,7 @@ public class HomeViewModel { private let currentUserIsClosedGroupAdmin: Bool? private let threadUnreadCount: UInt? - public let unreadMentionCount: UInt = 0 // TODO: This + private let threadUnreadMentionCount: UInt? public let lastInteractionInfo: InteractionInfo? @@ -167,6 +168,10 @@ public class HomeViewModel { return (threadUnreadCount ?? 0) } + public var unreadMentionCount: UInt { + return (threadUnreadMentionCount ?? 0) + } + fileprivate init() { self.id = "FALLBACK" self.variant = .contact @@ -184,6 +189,7 @@ public class HomeViewModel { self.isNoteToSelf = false self.currentUserIsClosedGroupAdmin = nil self.threadUnreadCount = nil + self.threadUnreadMentionCount = nil self.lastInteractionInfo = nil } @@ -196,6 +202,7 @@ public class HomeViewModel { let closedGroupMember: TypedTableAlias = TypedTableAlias() let openGroup: TypedTableAlias = TypedTableAlias() let unreadInteractions: TableAlias = TableAlias() + let unreadMentions: TableAlias = TableAlias() let lastInteraction: TableAlias = TableAlias() let lastInteractionThread: TypedTableAlias = TypedTableAlias() let linkPreview: TypedTableAlias = TypedTableAlias() @@ -214,6 +221,17 @@ public class HomeViewModel { .filter(Interaction.Columns.wasRead == false) .group(Interaction.Columns.threadId) ) + let unreadMentionsExpression: CommonTableExpression = CommonTableExpression( + named: ThreadInfo.threadUnreadMentionCountKey, + request: Interaction + .select( + count(Interaction.Columns.id).forKey(ThreadInfo.threadUnreadMentionCountKey), + Interaction.Columns.threadId + ) + .filter(Interaction.Columns.wasRead == false) + .filter(Interaction.Columns.hasMention == true) + .group(Interaction.Columns.threadId) + ) let lastInteractionExpression: CommonTableExpression = CommonTableExpression( named: ThreadInfo.lastInteractionInfoKey, request: Interaction @@ -261,7 +279,8 @@ public class HomeViewModel { SessionThread.isNoteToSelf(userPublicKey: userPublicKey).forKey(ThreadInfo.isNoteToSelfKey), (closedGroupMember[.profileId] != nil).forKey(ThreadInfo.currentUserIsClosedGroupAdminKey), - unreadInteractions[ThreadInfo.threadUnreadCountKey] + unreadInteractions[ThreadInfo.threadUnreadCountKey], + unreadMentions[ThreadInfo.threadUnreadMentionCountKey] ) .aliased(thread) .joining( @@ -308,6 +327,17 @@ public class HomeViewModel { ) .aliased(unreadInteractions) ) + .with(unreadMentionsExpression) + .joining( + optional: SessionThread + .association( + to: unreadMentionsExpression, + on: { thread, unreadMentions in + thread[SessionThread.Columns.id] == unreadMentions[Interaction.Columns.threadId] + } + ) + .aliased(unreadMentions) + ) .with(lastInteractionExpression) .including( optional: SessionThread diff --git a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift index 85da33703..532622989 100644 --- a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -153,6 +153,10 @@ enum _001_InitialSetupMigration: Migration { .notNull() .indexed() // Quicker querying .defaults(to: false) + t.column(.hasMention, .boolean) + .notNull() + .indexed() // Quicker querying + .defaults(to: false) t.column(.expiresInSeconds, .double) t.column(.expiresStartedAtMs, .double) t.column(.linkPreviewUrl, .text) diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 6ae96c531..d830e6982 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -586,6 +586,10 @@ enum _003_YDBToGRDBMigration: Migration { timestampMs: Int64(legacyInteraction.timestamp), receivedAtTimestampMs: Int64(legacyInteraction.receivedAtTimestamp), wasRead: wasRead, + hasMention: ( + body?.contains("@\(currentUserPublicKey)") == true || + quotedMessage?.authorId == currentUserPublicKey + ), // For both of these '0' used to be equivalent to null expiresInSeconds: ((expiresInSeconds ?? 0) > 0 ? expiresInSeconds.map { TimeInterval($0) } : diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 0f9371eea..74555c658 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -47,6 +47,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu case timestampMs case receivedAtTimestampMs case wasRead + case hasMention case expiresInSeconds case expiresStartedAtMs @@ -136,6 +137,9 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu /// **Note:** This flag is not applicable to standardOutgoing or standardIncomingDeleted interactions public let wasRead: Bool + /// A flag indicating whether the current user was mentioned in this interaction (or the associated quote) + public let hasMention: Bool + /// The number of seconds until this message should expire public let expiresInSeconds: TimeInterval? @@ -208,6 +212,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu timestampMs: Int64, receivedAtTimestampMs: Int64, wasRead: Bool, + hasMention: Bool, expiresInSeconds: TimeInterval?, expiresStartedAtMs: Double?, linkPreviewUrl: String?, @@ -224,6 +229,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu self.timestampMs = timestampMs self.receivedAtTimestampMs = receivedAtTimestampMs self.wasRead = wasRead + self.hasMention = hasMention self.expiresInSeconds = expiresInSeconds self.expiresStartedAtMs = expiresStartedAtMs self.linkPreviewUrl = linkPreviewUrl @@ -240,6 +246,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu body: String? = nil, timestampMs: Int64 = 0, wasRead: Bool = false, + hasMention: Bool = false, expiresInSeconds: TimeInterval? = nil, expiresStartedAtMs: Double? = nil, linkPreviewUrl: String? = nil, @@ -262,6 +269,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu } }() self.wasRead = wasRead + self.hasMention = hasMention self.expiresInSeconds = expiresInSeconds self.expiresStartedAtMs = expiresStartedAtMs self.linkPreviewUrl = linkPreviewUrl @@ -367,6 +375,7 @@ public extension Interaction { authorId: String? = nil, timestampMs: Int64? = nil, wasRead: Bool? = nil, + hasMention: Bool? = nil, expiresInSeconds: TimeInterval? = nil, expiresStartedAtMs: Double? = nil, openGroupServerMessageId: Int64? = nil @@ -381,6 +390,7 @@ public extension Interaction { timestampMs: (timestampMs ?? self.timestampMs), receivedAtTimestampMs: receivedAtTimestampMs, wasRead: (wasRead ?? self.wasRead), + hasMention: (hasMention ?? self.hasMention), expiresInSeconds: (expiresInSeconds ?? self.expiresInSeconds), expiresStartedAtMs: (expiresStartedAtMs ?? self.expiresStartedAtMs), linkPreviewUrl: linkPreviewUrl, @@ -524,6 +534,7 @@ public extension Interaction { timestampMs: timestampMs, receivedAtTimestampMs: receivedAtTimestampMs, wasRead: wasRead, + hasMention: hasMention, expiresInSeconds: expiresInSeconds, expiresStartedAtMs: expiresStartedAtMs, linkPreviewUrl: linkPreviewUrl, diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index a7f97da8f..c3721383e 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -224,14 +224,18 @@ public extension Profile { // MARK: - GRDB Interactions public extension Profile { - static func fetchAllContactProfiles(excludeCurrentUser: Bool = true) -> [Profile] { + static func fetchAllContactProfiles(excluding: Set = [], excludeCurrentUser: Bool = true) -> [Profile] { return GRDBStorage.shared .read { db in + let idsToExclude: Set = excluding + .inserting(excludeCurrentUser ? getUserHexEncodedPublicKey(db) : nil) + // Sort the contacts by their displayName value return try Profile - .filter(Profile.Columns.id != (excludeCurrentUser ? "" : getUserHexEncodedPublicKey(db))) + .filter(!idsToExclude.contains(Profile.Columns.id)) .joining( required: Profile.contact + .filter(Contact.Columns.isApproved == true) .filter(Contact.Columns.didApproveMe == true) ) .fetchAll(db) diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index f411e030d..6dba259b7 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -262,38 +262,24 @@ public extension SessionThread { ) } - func name(_ db: Database) -> String { + static func displayName( + threadId: String, + variant: Variant, + closedGroupName: String? = nil, + openGroupName: String? = nil, + isNoteToSelf: Bool = false, + profile: Profile? = nil + ) -> String { switch variant { + case .closedGroup: return (closedGroupName ?? "Unknown Group") + case .openGroup: return (openGroupName ?? "Unknown Group") case .contact: - guard !isNoteToSelf(db) else { return name(isNoteToSelf: true) } + guard !isNoteToSelf else { return "NOTE_TO_SELF".localized() } + guard let profile: Profile = profile else { + return Profile.truncated(id: threadId, truncating: .middle) + } - return name( - displayName: Profile.displayName( - db, - id: id, - customFallback: Profile.truncated(id: id, truncating: .middle) - ) - ) - - case .closedGroup: - return name(displayName: try? String.fetchOne(db, closedGroup.select(.name))) - - case .openGroup: - return name(displayName: try? String.fetchOne(db, openGroup.select(.name))) - } - } - - func name(isNoteToSelf: Bool = false, displayName: String? = nil) -> String { - switch variant { - case .contact: - guard !isNoteToSelf else { return "Note to Self" } - - return displayName - .defaulting(to: "Anonymous", useDefaultIfEmpty: true) - - case .closedGroup, .openGroup: - return displayName - .defaulting(to: "Group", useDefaultIfEmpty: true) + return profile.displayName() } } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index 5423294c6..1f6f9de5f 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -459,17 +459,14 @@ extension MessageReceiver { throw MessageReceiverError.noThread } + // Store the message variant so we can run variant-specific behaviours + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) let thread: SessionThread = try SessionThread .fetchOrCreate(db, id: threadInfo.id, variant: threadInfo.variant) - - // Store the message variant so we can run variant-specific behaviours - let variant: Interaction.Variant = { - if sender == getUserHexEncodedPublicKey(db) { - return .standardOutgoing - } - - return .standardIncoming - }() + let variant: Interaction.Variant = (sender == currentUserPublicKey ? + .standardOutgoing : + .standardIncoming + ) // Retrieve the disappearing messages config to set the 'expiresInSeconds' value // accoring to the config @@ -491,6 +488,10 @@ extension MessageReceiver { variant: variant, body: message.text, timestampMs: Int64(messageSentTimestamp * 1000), + hasMention: ( + message.text?.contains("@\(currentUserPublicKey)") == true || + dataMessage.quote?.author == currentUserPublicKey + ), // Note: Ensure we don't ever expire open group messages expiresInSeconds: (disappearingMessagesConfiguration.isEnabled && message.openGroupServerMessageId == nil ? disappearingMessagesConfiguration.durationSeconds : @@ -836,6 +837,23 @@ extension MessageReceiver { // Notify the user if !groupAlreadyExisted { + // Create the GroupMember records + try members.forEach { memberId in + try GroupMember( + groupId: groupPublicKey, + profileId: memberId, + role: .standard + ).save(db) + } + + try admins.forEach { adminId in + try GroupMember( + groupId: groupPublicKey, + profileId: adminId, + role: .admin + ).save(db) + } + // Note: We don't provide a `serverHash` in this case as we want to allow duplicates // to avoid the following situation: // • The app performed a background poll or received a push notification @@ -972,15 +990,19 @@ extension MessageReceiver { // Update the group let addedMembers: [String] = membersAsData.map { $0.toHexString() } - let members: Set = Set(groupMembers.map { $0.profileId }).union(addedMembers) + let currentMemberIds: Set = groupMembers.map { $0.profileId }.asSet() + let members: Set = currentMemberIds.union(addedMembers) - try addedMembers.forEach { memberId in - try GroupMember( - groupId: id, - profileId: memberId, - role: .standard - ).save(db) - } + // Create records for any new members + try addedMembers + .filter { !currentMemberIds.contains($0) } + .forEach { memberId in + try GroupMember( + groupId: id, + profileId: memberId, + role: .standard + ).insert(db) + } // Send the latest encryption key pair to the added members if the current user is // the admin of the group @@ -1148,11 +1170,17 @@ extension MessageReceiver { ) } else { + // Delete all old user roles and re-add them as a zombie + try closedGroup + .allMembers + .filter(GroupMember.Columns.profileId == sender) + .deleteAll(db) + try GroupMember( groupId: id, profileId: sender, role: .zombie - ).save(db) + ).insert(db) } // Update the group diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index de137163a..4fff3a2a0 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -22,6 +22,8 @@ extension MessageSender { } public static func send(_ db: Database, interaction: Interaction, in thread: SessionThread) throws { + // Only 'VisibleMessage' types can be sent via this method + guard interaction.variant == .standardOutgoing else { throw MessageSenderError.invalidMessage } guard let interactionId: Int64 = interaction.id else { throw GRDBStorageError.objectNotSaved } return try send(db, message: VisibleMessage.from(db, interaction: interaction), interactionId: interactionId, in: thread) diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift index 7c441a95e..a2b65c1f8 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift @@ -43,13 +43,16 @@ public final class ClosedGroupPoller: NSObject { assert(Thread.current.isMainThread) // Timers don't do well on background queues #endif - // Fetch all closed groups (excluding any which have no key pairs as the user is - // no longer a member of those + // Fetch all closed groups (excluding any don't contain the current user as a + // GroupMemeber as the user is no longer a member of those) GRDBStorage.shared .read { db in try ClosedGroup .select(.threadId) - .joining(required: ClosedGroup.keyPairs) + .joining( + required: ClosedGroup.members + .filter(GroupMember.Columns.profileId == getUserHexEncodedPublicKey(db)) + ) .asRequest(of: String.self) .fetchAll(db) } diff --git a/SessionNotificationServiceExtension/NSENotificationPresenter.swift b/SessionNotificationServiceExtension/NSENotificationPresenter.swift index 55fe20d65..88c964f9d 100644 --- a/SessionNotificationServiceExtension/NSENotificationPresenter.swift +++ b/SessionNotificationServiceExtension/NSENotificationPresenter.swift @@ -53,14 +53,15 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { return } - var groupName = thread.name(db) - if groupName.count < 1 { - groupName = MessageStrings.newGroupDefaultTitle - } notificationTitle = String( format: NotificationStrings.incomingGroupMessageTitleFormat, senderName, - groupName + SessionThread.displayName( + threadId: thread.id, + variant: thread.variant, + closedGroupName: (try? thread.closedGroup.fetchOne(db))?.name, + openGroupName: (try? thread.openGroup.fetchOne(db))?.name + ) ) } From 06eef997663d0d32f7876d4ef2750442d815ebf6 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 10 May 2022 17:42:15 +1000 Subject: [PATCH 074/157] Cleared out some legacy code, fixed a few bugs, got typing indicators and mentions working Got mentions working again Got typing indicators working again Got the notification sound and preview preferences working Fixed a few issues with attachment image loading Fixed an issue where enum settings weren't getting stored correctly --- Session.xcodeproj/project.pbxproj | 48 +- .../Context Menu/ContextMenuVC+Action.swift | 173 +++--- .../ContextMenuVC+ActionView.swift | 48 +- .../Context Menu/ContextMenuVC.swift | 122 +++-- .../ConversationVC+Interaction.swift | 144 ++--- Session/Conversations/ConversationVC.swift | 10 +- .../Conversations/Input View/InputView.swift | 35 +- Session/Home/HomeVC.swift | 4 +- Session/Home/HomeViewModel.swift | 7 + Session/Meta/AppDelegate.swift | 19 +- Session/Notifications/AppNotifications.swift | 14 +- Session/Open Groups/JoinOpenGroupVC.swift | 184 ++++--- ...otificationSettingsOptionsViewController.m | 27 +- .../NotificationSettingsViewController.m | 2 +- Session/Shared/ConversationCell.swift | 74 ++- Session/Shared/UserSelectionVC.swift | 3 +- .../_001_InitialSetupMigration.swift | 7 + .../Migrations/_003_YDBToGRDBMigration.swift | 45 +- .../Database/Models/Attachment.swift | 73 ++- .../Models/ControlMessageProcessRecord.swift | 3 +- .../Database/Models/SessionThread.swift | 15 + .../Models/ThreadTypingIndicator.swift | 30 + .../Jobs/Types/AttachmentDownloadJob.swift | 8 +- .../Jobs/Types/MessageSendJob.swift | 3 + SessionMessagingKit/Messages/Message.swift | 12 +- .../Open Groups/OpenGroupAPIV2.swift | 6 +- .../Mentions/Mention.swift | 15 - .../Mentions/MentionsManager.swift | 93 ---- .../MessageReceiver+Handling.swift | 134 ++--- .../MessageSender+ClosedGroups.swift | 31 +- .../MessageSender+Convenience.swift | 1 - .../Sending & Receiving/MessageSender.swift | 18 +- .../Quotes/QuotedReplyModel.swift | 26 +- .../Typing Indicators/TypingIndicators.swift | 518 +++++------------- .../Utilities/OWSPreferences.m | 12 - .../Utilities/Preferences.swift | 48 +- .../NSENotificationPresenter.swift | 27 +- .../Database/Models/Setting.swift | 2 +- .../PersistableRecord+Utilities.swift | 16 - .../Messaging/BlockListUIUtils.swift | 4 +- .../Messaging/OWSMessageUtils.h | 25 - .../Messaging/OWSMessageUtils.m | 120 ---- SignalUtilitiesKit/Meta/SignalUtilitiesKit.h | 3 - .../Profile Pictures/ProfilePictureView.swift | 17 +- SignalUtilitiesKit/To Do/ContactCellView.h | 31 -- SignalUtilitiesKit/To Do/ContactCellView.m | 295 ---------- .../To Do/ContactTableViewCell.h | 31 -- .../To Do/ContactTableViewCell.m | 116 ---- 48 files changed, 966 insertions(+), 1733 deletions(-) create mode 100644 SessionMessagingKit/Database/Models/ThreadTypingIndicator.swift delete mode 100644 SessionMessagingKit/Sending & Receiving/Mentions/Mention.swift delete mode 100644 SessionMessagingKit/Sending & Receiving/Mentions/MentionsManager.swift delete mode 100644 SessionUtilitiesKit/Database/Utilities/PersistableRecord+Utilities.swift delete mode 100644 SignalUtilitiesKit/Messaging/OWSMessageUtils.h delete mode 100644 SignalUtilitiesKit/Messaging/OWSMessageUtils.m delete mode 100644 SignalUtilitiesKit/To Do/ContactCellView.h delete mode 100644 SignalUtilitiesKit/To Do/ContactCellView.m delete mode 100644 SignalUtilitiesKit/To Do/ContactTableViewCell.h delete mode 100644 SignalUtilitiesKit/To Do/ContactTableViewCell.m diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index eb189d562..91722a794 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -335,8 +335,6 @@ C32C5CAD256DD1DF003C73A2 /* TSAccountManager.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB94255A581300E217F9 /* TSAccountManager.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5CF0256DD3E4003C73A2 /* Storage+Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3F0A5FD255C988A007BE2A3 /* Storage+Shared.swift */; }; C32C5D19256DD493003C73A2 /* OWSLinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBA8255A581500E217F9 /* OWSLinkPreview.swift */; }; - C32C5D23256DD4C0003C73A2 /* Mention.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA7E255A57FB00E217F9 /* Mention.swift */; }; - C32C5D24256DD4C0003C73A2 /* MentionsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA81255A57FC00E217F9 /* MentionsManager.swift */; }; C32C5D83256DD5B6003C73A2 /* SSKKeychainStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBBC255A581600E217F9 /* SSKKeychainStorage.swift */; }; C32C5DBF256DD743003C73A2 /* ClosedGroupPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB34255A580B00E217F9 /* ClosedGroupPoller.swift */; }; C32C5DC0256DD743003C73A2 /* Poller.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB3A255A580B00E217F9 /* Poller.swift */; }; @@ -409,7 +407,6 @@ C33FDC96255A582000E217F9 /* NSObject+Casting.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDADC255A580400E217F9 /* NSObject+Casting.h */; settings = {ATTRIBUTES = (Public, ); }; }; C33FDC98255A582000E217F9 /* SwiftSingletons.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDADE255A580400E217F9 /* SwiftSingletons.swift */; }; C33FDC9A255A582000E217F9 /* ByteParser.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAE0255A580400E217F9 /* ByteParser.m */; }; - C33FDCA2255A582000E217F9 /* OWSMessageUtils.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDAE8255A580500E217F9 /* OWSMessageUtils.h */; settings = {ATTRIBUTES = (Public, ); }; }; C33FDCC7255A582000E217F9 /* NSArray+OWS.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB0D255A580800E217F9 /* NSArray+OWS.m */; }; C33FDCD1255A582000E217F9 /* FunctionalUtil.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB17255A580800E217F9 /* FunctionalUtil.m */; }; C33FDCD3255A582000E217F9 /* GroupUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB19255A580900E217F9 /* GroupUtilities.swift */; }; @@ -427,7 +424,6 @@ C33FDD74255A582000E217F9 /* OWSPrimaryStorage+keyFromIntLong.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBBA255A581600E217F9 /* OWSPrimaryStorage+keyFromIntLong.h */; settings = {ATTRIBUTES = (Public, ); }; }; C33FDD7C255A582000E217F9 /* SSKAsserts.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBC2255A581700E217F9 /* SSKAsserts.h */; settings = {ATTRIBUTES = (Public, ); }; }; C33FDD8D255A582000E217F9 /* OWSSignalAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBD3255A581800E217F9 /* OWSSignalAddress.swift */; }; - C33FDD91255A582000E217F9 /* OWSMessageUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBD7255A581900E217F9 /* OWSMessageUtils.m */; }; C33FDD92255A582000E217F9 /* SignalIOS.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBD8255A581900E217F9 /* SignalIOS.pb.swift */; }; C33FDDB0255A582000E217F9 /* NSURLSessionDataTask+StatusCode.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBF6255A581C00E217F9 /* NSURLSessionDataTask+StatusCode.h */; settings = {ATTRIBUTES = (Public, ); }; }; C33FDDB2255A582000E217F9 /* NSArray+OWS.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBF8255A581C00E217F9 /* NSArray+OWS.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -557,7 +553,6 @@ C38EF3EF255B6DF7007E1867 /* ThreadViewHelper.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF3D1255B6DEE007E1867 /* ThreadViewHelper.m */; }; C38EF3F0255B6DF7007E1867 /* ThreadViewHelper.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF3D2255B6DEE007E1867 /* ThreadViewHelper.h */; settings = {ATTRIBUTES = (Public, ); }; }; C38EF3F2255B6DF7007E1867 /* DisappearingTimerConfigurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF3D4255B6DEE007E1867 /* DisappearingTimerConfigurationView.swift */; }; - C38EF3F4255B6DF7007E1867 /* ContactCellView.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF3D6255B6DEF007E1867 /* ContactCellView.m */; }; C38EF3F5255B6DF7007E1867 /* OWSTextField.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF3D7255B6DF0007E1867 /* OWSTextField.h */; settings = {ATTRIBUTES = (Public, ); }; }; C38EF3F6255B6DF7007E1867 /* OWSTextView.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF3D8255B6DF0007E1867 /* OWSTextView.h */; settings = {ATTRIBUTES = (Public, ); }; }; C38EF3F7255B6DF7007E1867 /* OWSNavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF3D9255B6DF1007E1867 /* OWSNavigationBar.swift */; }; @@ -570,11 +565,8 @@ C38EF400255B6DF7007E1867 /* GalleryRailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF3E2255B6DF3007E1867 /* GalleryRailView.swift */; }; C38EF401255B6DF7007E1867 /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF3E3255B6DF4007E1867 /* VideoPlayerView.swift */; }; C38EF402255B6DF7007E1867 /* CommonStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF3E4255B6DF4007E1867 /* CommonStrings.swift */; }; - C38EF403255B6DF7007E1867 /* ContactCellView.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF3E5255B6DF4007E1867 /* ContactCellView.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C38EF404255B6DF7007E1867 /* ContactTableViewCell.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF3E6255B6DF4007E1867 /* ContactTableViewCell.h */; settings = {ATTRIBUTES = (Public, ); }; }; C38EF405255B6DF7007E1867 /* OWSButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF3E7255B6DF5007E1867 /* OWSButton.swift */; }; C38EF407255B6DF7007E1867 /* Toast.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF3E9255B6DF6007E1867 /* Toast.swift */; }; - C38EF409255B6DF7007E1867 /* ContactTableViewCell.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF3EB255B6DF6007E1867 /* ContactTableViewCell.m */; }; C38EF40A255B6DF7007E1867 /* OWSFlatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF3EC255B6DF6007E1867 /* OWSFlatButton.swift */; }; C38EF40B255B6DF7007E1867 /* TappableStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF3ED255B6DF6007E1867 /* TappableStackView.swift */; }; C38EF40C255B6DF7007E1867 /* GradientView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF3EE255B6DF6007E1867 /* GradientView.swift */; }; @@ -729,6 +721,7 @@ FD09C5E428237209000CE219 /* MessageRequestsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E328237209000CE219 /* MessageRequestsViewModel.swift */; }; FD09C5E628260FF9000CE219 /* MediaGalleryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E528260FF9000CE219 /* MediaGalleryViewModel.swift */; }; FD09C5E828264937000CE219 /* MediaDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E728264937000CE219 /* MediaDetailViewController.swift */; }; + FD09C5EA282A1BB2000CE219 /* ThreadTypingIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E9282A1BB2000CE219 /* ThreadTypingIndicator.swift */; }; FD17D79927F40AB800122BE0 /* _003_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79827F40AB800122BE0 /* _003_YDBToGRDBMigration.swift */; }; FD17D79C27F40B2E00122BE0 /* SMKLegacyModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */; }; FD17D7A027F40CC800122BE0 /* _001_InitialSetupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79F27F40CC800122BE0 /* _001_InitialSetupMigration.swift */; }; @@ -796,7 +789,6 @@ FDF0B7472804F0CE004C14C5 /* DisappearingMessagesJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7462804F0CE004C14C5 /* DisappearingMessagesJob.swift */; }; FDF0B74928060D13004C14C5 /* QuotedReplyModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B74828060D13004C14C5 /* QuotedReplyModel.swift */; }; FDF0B74B28061F7A004C14C5 /* InteractionAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B74A28061F7A004C14C5 /* InteractionAttachment.swift */; }; - FDF0B74D280664E9004C14C5 /* PersistableRecord+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B74C280664E9004C14C5 /* PersistableRecord+Utilities.swift */; }; FDF0B74F28079E5E004C14C5 /* SendReadReceiptsJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B74E28079E5E004C14C5 /* SendReadReceiptsJob.swift */; }; FDF0B7512807BA56004C14C5 /* NotificationsProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7502807BA56004C14C5 /* NotificationsProtocol.swift */; }; FDF0B7552807C4BB004C14C5 /* SSKEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7542807C4BB004C14C5 /* SSKEnvironment.swift */; }; @@ -1339,8 +1331,6 @@ C33FDA73255A57FA00E217F9 /* ECKeyPair+Hexadecimal.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ECKeyPair+Hexadecimal.swift"; sourceTree = ""; }; C33FDA79255A57FB00E217F9 /* TSGroupThread.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSGroupThread.h; sourceTree = ""; }; C33FDA7A255A57FB00E217F9 /* NSRegularExpression+SSK.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSRegularExpression+SSK.swift"; sourceTree = ""; }; - C33FDA7E255A57FB00E217F9 /* Mention.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Mention.swift; sourceTree = ""; }; - C33FDA81255A57FC00E217F9 /* MentionsManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MentionsManager.swift; sourceTree = ""; }; C33FDA86255A57FC00E217F9 /* OWSDisappearingMessagesFinder.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSDisappearingMessagesFinder.m; sourceTree = ""; }; C33FDA87255A57FC00E217F9 /* TypingIndicators.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingIndicators.swift; sourceTree = ""; }; C33FDA88255A57FD00E217F9 /* YapDatabaseTransaction+OWS.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "YapDatabaseTransaction+OWS.h"; sourceTree = ""; }; @@ -1373,7 +1363,6 @@ C33FDAE0255A580400E217F9 /* ByteParser.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ByteParser.m; sourceTree = ""; }; C33FDAE4255A580400E217F9 /* TSAttachmentStream.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSAttachmentStream.h; sourceTree = ""; }; C33FDAE6255A580400E217F9 /* TSInteraction.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSInteraction.h; sourceTree = ""; }; - C33FDAE8255A580500E217F9 /* OWSMessageUtils.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSMessageUtils.h; sourceTree = ""; }; C33FDAEA255A580500E217F9 /* OWSBackupFragment.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSBackupFragment.h; sourceTree = ""; }; C33FDAEC255A580500E217F9 /* SignalRecipient.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SignalRecipient.h; sourceTree = ""; }; C33FDAEF255A580500E217F9 /* NSData+Image.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSData+Image.m"; sourceTree = ""; }; @@ -1458,7 +1447,6 @@ C33FDBC2255A581700E217F9 /* SSKAsserts.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SSKAsserts.h; sourceTree = ""; }; C33FDBCA255A581700E217F9 /* LKGroupUtilities.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = LKGroupUtilities.h; sourceTree = ""; }; C33FDBD3255A581800E217F9 /* OWSSignalAddress.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSSignalAddress.swift; sourceTree = ""; }; - C33FDBD7255A581900E217F9 /* OWSMessageUtils.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSMessageUtils.m; sourceTree = ""; }; C33FDBD8255A581900E217F9 /* SignalIOS.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SignalIOS.pb.swift; sourceTree = ""; }; C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PushNotificationAPI.swift; sourceTree = ""; }; C33FDBE1255A581A00E217F9 /* LKGroupUtilities.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = LKGroupUtilities.m; sourceTree = ""; }; @@ -1619,7 +1607,6 @@ C38EF3D1255B6DEE007E1867 /* ThreadViewHelper.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = ThreadViewHelper.m; path = SignalUtilitiesKit/Database/ThreadViewHelper.m; sourceTree = SOURCE_ROOT; }; C38EF3D2255B6DEE007E1867 /* ThreadViewHelper.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = ThreadViewHelper.h; path = SignalUtilitiesKit/Database/ThreadViewHelper.h; sourceTree = SOURCE_ROOT; }; C38EF3D4255B6DEE007E1867 /* DisappearingTimerConfigurationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = DisappearingTimerConfigurationView.swift; path = SignalUtilitiesKit/Messaging/DisappearingTimerConfigurationView.swift; sourceTree = SOURCE_ROOT; }; - C38EF3D6255B6DEF007E1867 /* ContactCellView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = ContactCellView.m; path = "SignalUtilitiesKit/To Do/ContactCellView.m"; sourceTree = SOURCE_ROOT; }; C38EF3D7255B6DF0007E1867 /* OWSTextField.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSTextField.h; path = "SignalUtilitiesKit/Shared Views/OWSTextField.h"; sourceTree = SOURCE_ROOT; }; C38EF3D8255B6DF0007E1867 /* OWSTextView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSTextView.h; path = "SignalUtilitiesKit/Shared Views/OWSTextView.h"; sourceTree = SOURCE_ROOT; }; C38EF3D9255B6DF1007E1867 /* OWSNavigationBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = OWSNavigationBar.swift; path = "SignalUtilitiesKit/Shared Views/OWSNavigationBar.swift"; sourceTree = SOURCE_ROOT; }; @@ -1632,11 +1619,8 @@ C38EF3E2255B6DF3007E1867 /* GalleryRailView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = GalleryRailView.swift; path = "SignalUtilitiesKit/Shared Views/GalleryRailView.swift"; sourceTree = SOURCE_ROOT; }; C38EF3E3255B6DF4007E1867 /* VideoPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = VideoPlayerView.swift; path = "SignalUtilitiesKit/Media Viewing & Editing/VideoPlayerView.swift"; sourceTree = SOURCE_ROOT; }; C38EF3E4255B6DF4007E1867 /* CommonStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = CommonStrings.swift; path = SignalUtilitiesKit/Utilities/CommonStrings.swift; sourceTree = SOURCE_ROOT; }; - C38EF3E5255B6DF4007E1867 /* ContactCellView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = ContactCellView.h; path = "SignalUtilitiesKit/To Do/ContactCellView.h"; sourceTree = SOURCE_ROOT; }; - C38EF3E6255B6DF4007E1867 /* ContactTableViewCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = ContactTableViewCell.h; path = "SignalUtilitiesKit/To Do/ContactTableViewCell.h"; sourceTree = SOURCE_ROOT; }; C38EF3E7255B6DF5007E1867 /* OWSButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = OWSButton.swift; path = "SignalUtilitiesKit/Shared Views/OWSButton.swift"; sourceTree = SOURCE_ROOT; }; C38EF3E9255B6DF6007E1867 /* Toast.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Toast.swift; path = "SignalUtilitiesKit/Shared Views/Toast.swift"; sourceTree = SOURCE_ROOT; }; - C38EF3EB255B6DF6007E1867 /* ContactTableViewCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = ContactTableViewCell.m; path = "SignalUtilitiesKit/To Do/ContactTableViewCell.m"; sourceTree = SOURCE_ROOT; }; C38EF3EC255B6DF6007E1867 /* OWSFlatButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = OWSFlatButton.swift; path = "SignalUtilitiesKit/Shared Views/OWSFlatButton.swift"; sourceTree = SOURCE_ROOT; }; C38EF3ED255B6DF6007E1867 /* TappableStackView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = TappableStackView.swift; path = "SignalUtilitiesKit/Shared Views/TappableStackView.swift"; sourceTree = SOURCE_ROOT; }; C38EF3EE255B6DF6007E1867 /* GradientView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = GradientView.swift; path = "SignalUtilitiesKit/Shared Views/GradientView.swift"; sourceTree = SOURCE_ROOT; }; @@ -1786,6 +1770,7 @@ FD09C5E328237209000CE219 /* MessageRequestsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsViewModel.swift; sourceTree = ""; }; FD09C5E528260FF9000CE219 /* MediaGalleryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGalleryViewModel.swift; sourceTree = ""; }; FD09C5E728264937000CE219 /* MediaDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaDetailViewController.swift; sourceTree = ""; }; + FD09C5E9282A1BB2000CE219 /* ThreadTypingIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadTypingIndicator.swift; sourceTree = ""; }; FD17D79527F3E04600122BE0 /* _001_InitialSetupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _001_InitialSetupMigration.swift; sourceTree = ""; }; FD17D79827F40AB800122BE0 /* _003_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _003_YDBToGRDBMigration.swift; sourceTree = ""; }; FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMKLegacyModels.swift; sourceTree = ""; }; @@ -1851,7 +1836,6 @@ FDF0B7462804F0CE004C14C5 /* DisappearingMessagesJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisappearingMessagesJob.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 = ""; }; - FDF0B74C280664E9004C14C5 /* PersistableRecord+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistableRecord+Utilities.swift"; sourceTree = ""; }; FDF0B74E28079E5E004C14C5 /* SendReadReceiptsJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendReadReceiptsJob.swift; sourceTree = ""; }; FDF0B7502807BA56004C14C5 /* NotificationsProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsProtocol.swift; sourceTree = ""; }; FDF0B7542807C4BB004C14C5 /* SSKEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSKEnvironment.swift; sourceTree = ""; }; @@ -2521,7 +2505,6 @@ C3D9E3B52567685D0040E4F3 /* Attachments */, B8F5F61925EDE4B0003BF8D4 /* Data Extraction */, C32C5D22256DD496003C73A2 /* Link Previews */, - C32C5D2D256DD4C4003C73A2 /* Mentions */, C379DC6825672B5E0002D4EB /* Notifications */, C32C59F8256DB5A6003C73A2 /* Pollers */, C32C5B1B256DC160003C73A2 /* Quotes */, @@ -2712,15 +2695,6 @@ path = "Link Previews"; sourceTree = ""; }; - C32C5D2D256DD4C4003C73A2 /* Mentions */ = { - isa = PBXGroup; - children = ( - C33FDA7E255A57FB00E217F9 /* Mention.swift */, - C33FDA81255A57FC00E217F9 /* MentionsManager.swift */, - ); - path = Mentions; - sourceTree = ""; - }; C331FF1C2558F9D300070591 /* SessionUIKit */ = { isa = PBXGroup; children = ( @@ -3097,10 +3071,6 @@ isa = PBXGroup; children = ( C33FDB19255A580900E217F9 /* GroupUtilities.swift */, - C38EF3E5255B6DF4007E1867 /* ContactCellView.h */, - C38EF3D6255B6DEF007E1867 /* ContactCellView.m */, - C38EF3E6255B6DF4007E1867 /* ContactTableViewCell.h */, - C38EF3EB255B6DF6007E1867 /* ContactTableViewCell.m */, C38EF2D2255B6DAF007E1867 /* OWSProfileManager.h */, C38EF2CF255B6DAE007E1867 /* OWSProfileManager.m */, ); @@ -3117,8 +3087,6 @@ C38EF2E4255B6DB9007E1867 /* FullTextSearcher.swift */, C38EF2E9255B6DBA007E1867 /* OWSUnreadIndicator.h */, C38EF2E3255B6DB9007E1867 /* OWSUnreadIndicator.m */, - C33FDAE8255A580500E217F9 /* OWSMessageUtils.h */, - C33FDBD7255A581900E217F9 /* OWSMessageUtils.m */, ); path = Messaging; sourceTree = ""; @@ -3598,6 +3566,7 @@ FD09799827FFC1A300936362 /* Attachment.swift */, FD09799A27FFC82D00936362 /* Quote.swift */, FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */, + FD09C5E9282A1BB2000CE219 /* ThreadTypingIndicator.swift */, FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */, ); path = Models; @@ -3687,7 +3656,6 @@ FD17D7C227F5204C00122BE0 /* Database+Utilities.swift */, FD17D7C627F5207C00122BE0 /* DatabaseMigrator+Utilities.swift */, FD17D7BC27F51F6900122BE0 /* GRDB+Notifications.swift */, - FDF0B74C280664E9004C14C5 /* PersistableRecord+Utilities.swift */, FDF22210281B5E0B000A4995 /* TableRecord+Utilities.swift */, FDF2220E281B55E6000A4995 /* QueryInterfaceRequest+Utilities.swift */, ); @@ -3838,11 +3806,9 @@ C33FDDD3255A582000E217F9 /* OWSQueues.h in Headers */, C33FDC96255A582000E217F9 /* NSObject+Casting.h in Headers */, C33FDDB3255A582000E217F9 /* OWSError.h in Headers */, - C38EF403255B6DF7007E1867 /* ContactCellView.h in Headers */, C33FDD68255A582000E217F9 /* SignalAccount.h in Headers */, C38EF35E255B6DCC007E1867 /* OWSViewController.h in Headers */, C37F5396255B95BD002AEA92 /* OWSAnyTouchGestureRecognizer.h in Headers */, - C38EF404255B6DF7007E1867 /* ContactTableViewCell.h in Headers */, C33FDDB2255A582000E217F9 /* NSArray+OWS.h in Headers */, C38EF2D7255B6DAF007E1867 /* OWSProfileManager.h in Headers */, C38EF367255B6DCC007E1867 /* OWSTableViewController.h in Headers */, @@ -3850,7 +3816,6 @@ C33FD9AF255A548A00E217F9 /* SignalUtilitiesKit.h in Headers */, C33FDC50255A582000E217F9 /* OWSDispatch.h in Headers */, C33FDD06255A582000E217F9 /* AppVersion.h in Headers */, - C33FDCA2255A582000E217F9 /* OWSMessageUtils.h in Headers */, C38EF28F255B6D86007E1867 /* VersionMigrations.h in Headers */, ); runOnlyForDeploymentPostprocessing = 0; @@ -4657,7 +4622,6 @@ C33FDC29255A581F00E217F9 /* ReachabilityManager.swift in Sources */, C38EF407255B6DF7007E1867 /* Toast.swift in Sources */, C38EF38C255B6DD2007E1867 /* ApprovalRailCellView.swift in Sources */, - C38EF409255B6DF7007E1867 /* ContactTableViewCell.m in Sources */, FD3C907327E8387300CD579F /* OpenGroupServerIdLookupMigration.swift in Sources */, C38EF32A255B6DBF007E1867 /* UIUtil.m in Sources */, C38EF2A6255B6D93007E1867 /* PlaceholderIcon.swift in Sources */, @@ -4705,7 +4669,6 @@ C33FDC9A255A582000E217F9 /* ByteParser.m in Sources */, C33FDDC0255A582000E217F9 /* SignalAccount.m in Sources */, C33FDC58255A582000E217F9 /* ReverseDispatchQueue.swift in Sources */, - C38EF3F4255B6DF7007E1867 /* ContactCellView.m in Sources */, C33FDC78255A582000E217F9 /* TSConstants.m in Sources */, C38EF324255B6DBF007E1867 /* Bench.swift in Sources */, FDCDB8DE2810F73B00352A0C /* Differentiable+Utilities.swift in Sources */, @@ -4721,7 +4684,6 @@ FD88BADB27A750F200BBC442 /* MessageRequestsMigration.swift in Sources */, C38EF326255B6DBF007E1867 /* ConversationStyle.swift in Sources */, C38EF3B8255B6DE7007E1867 /* ImageEditorTextViewController.swift in Sources */, - C33FDD91255A582000E217F9 /* OWSMessageUtils.m in Sources */, B8C2B332256376F000551B4D /* ThreadUtil.m in Sources */, C38EF40B255B6DF7007E1867 /* TappableStackView.swift in Sources */, C38EF31D255B6DBF007E1867 /* UIImage+OWS.swift in Sources */, @@ -4796,7 +4758,6 @@ FD09C5E2282212B3000CE219 /* JobDependencies.swift in Sources */, FD09796727F6B0B600936362 /* Sodium+Conversion.swift in Sources */, FD9004122818ABDC00ABAAF6 /* Job.swift in Sources */, - FDF0B74D280664E9004C14C5 /* PersistableRecord+Utilities.swift in Sources */, FD09797927FAB7E800936362 /* ImageFormat.swift in Sources */, C32C5DC9256DD935003C73A2 /* ProxiedContentDownloader.swift in Sources */, C3D9E4C02567767F0040E4F3 /* DataSource.m in Sources */, @@ -4937,7 +4898,6 @@ C3A71D0B2558989C0043A11F /* MessageWrapper.swift in Sources */, FDE77F6B280FEB28002CFC5D /* ControlMessageProcessRecord.swift in Sources */, B8F5F60325EDE16F003BF8D4 /* DataExtractionNotification.swift in Sources */, - C32C5D24256DD4C0003C73A2 /* MentionsManager.swift in Sources */, C3A71D1E25589AC30043A11F /* WebSocketProto.swift in Sources */, C3C2A7852553AAF300C340D1 /* SessionProtos.pb.swift in Sources */, B8566C63256F55930045A0B9 /* OWSLinkPreview+Conversion.swift in Sources */, @@ -5014,7 +4974,6 @@ C3A3A13C256E1B27004D228D /* OWSMediaGalleryFinder.m in Sources */, FD09797027FA6FF300936362 /* Profile.swift in Sources */, C32C5AAE256DBE8F003C73A2 /* TSInteraction.m in Sources */, - C32C5D23256DD4C0003C73A2 /* Mention.swift in Sources */, C32C5FD6256E0346003C73A2 /* Notification+Thread.swift in Sources */, FD09798B27FD1CFE00936362 /* Capability.swift in Sources */, C3BBE0C72554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift in Sources */, @@ -5026,6 +4985,7 @@ C32C5A75256DBBCF003C73A2 /* TSAttachmentPointer+Conversion.swift in Sources */, C32C5EBA256DE130003C73A2 /* OWSQuotedReplyModel.m in Sources */, C32C59C4256DB41F003C73A2 /* TSContactThread.m in Sources */, + FD09C5EA282A1BB2000CE219 /* ThreadTypingIndicator.swift in Sources */, FDF0B75E280AAF35004C14C5 /* Preferences.swift in Sources */, C32C5AB0256DBE8F003C73A2 /* TSOutgoingMessage.m in Sources */, B82A0C3826B9098200C1BCE3 /* MessageInvalidator.swift in Sources */, diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index 57d0168e6..b9f9c3372 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -1,98 +1,127 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit extension ContextMenuVC { - struct Action { - let icon: UIImage + let icon: UIImage? let title: String let work: () -> Void - static func reply(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action { - let title = NSLocalizedString("context_menu_reply", comment: "") - return Action(icon: UIImage(named: "ic_reply")!, title: title) { delegate?.reply(viewItem) } + static func reply(_ item: ConversationViewModel.Item, _ delegate: ContextMenuActionDelegate?) -> Action { + return Action( + icon: UIImage(named: "ic_reply"), + title: "context_menu_reply".localized() + ) { delegate?.reply(item) } } - static func copy(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action { - let title = NSLocalizedString("copy", comment: "") - return Action(icon: UIImage(named: "ic_copy")!, title: title) { delegate?.copy(viewItem) } + static func copy(_ item: ConversationViewModel.Item, _ delegate: ContextMenuActionDelegate?) -> Action { + return Action( + icon: UIImage(named: "ic_copy"), + title: "copy".localized() + ) { delegate?.copy(item) } } - static func copySessionID(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action { - let title = NSLocalizedString("vc_conversation_settings_copy_session_id_button_title", comment: "") - return Action(icon: UIImage(named: "ic_copy")!, title: title) { delegate?.copySessionID(viewItem) } + static func copySessionID(_ item: ConversationViewModel.Item, _ delegate: ContextMenuActionDelegate?) -> Action { + return Action( + icon: UIImage(named: "ic_copy"), + title: "vc_conversation_settings_copy_session_id_button_title".localized() + ) { delegate?.copySessionID(item) } } - static func delete(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action { - let title = NSLocalizedString("TXT_DELETE_TITLE", comment: "") - return Action(icon: UIImage(named: "ic_trash")!, title: title) { delegate?.delete(viewItem) } + static func delete(_ item: ConversationViewModel.Item, _ delegate: ContextMenuActionDelegate?) -> Action { + return Action( + icon: UIImage(named: "ic_trash"), + title: "TXT_DELETE_TITLE".localized() + ) { delegate?.delete(item) } } - static func save(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action { - let title = NSLocalizedString("context_menu_save", comment: "") - return Action(icon: UIImage(named: "ic_download")!, title: title) { delegate?.save(viewItem) } + static func save(_ item: ConversationViewModel.Item, _ delegate: ContextMenuActionDelegate?) -> Action { + return Action( + icon: UIImage(named: "ic_download"), + title: "context_menu_save".localized() + ) { delegate?.save(item) } } - static func ban(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action { - let title = NSLocalizedString("context_menu_ban_user", comment: "") - return Action(icon: UIImage(named: "ic_block")!, title: title) { delegate?.ban(viewItem) } + static func ban(_ item: ConversationViewModel.Item, _ delegate: ContextMenuActionDelegate?) -> Action { + return Action( + icon: UIImage(named: "ic_block"), + title: "context_menu_ban_user".localized() + ) { delegate?.ban(item) } } - static func banAndDeleteAllMessages(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action { - let title = NSLocalizedString("context_menu_ban_and_delete_all", comment: "") - return Action(icon: UIImage(named: "ic_block")!, title: title) { delegate?.banAndDeleteAllMessages(viewItem) } + static func banAndDeleteAllMessages(_ item: ConversationViewModel.Item, _ delegate: ContextMenuActionDelegate?) -> Action { + return Action( + icon: UIImage(named: "ic_block"), + title: "context_menu_ban_and_delete_all".localized() + ) { delegate?.banAndDeleteAllMessages(item) } } } - static func actions(for viewItem: ConversationViewItem, delegate: ContextMenuActionDelegate?) -> [Action] { - func isReplyingAllowed() -> Bool { - guard let message = viewItem.interaction as? TSOutgoingMessage else { return true } - switch message.messageState { - case .failed, .sending: return false - default: return true - } - } - switch viewItem.messageCellType { - case .textOnlyMessage: - var result: [Action] = [] - if isReplyingAllowed() { result.append(Action.reply(viewItem, delegate)) } - result.append(Action.copy(viewItem, delegate)) - let isGroup = viewItem.isGroupThread - if let message = viewItem.interaction as? TSIncomingMessage, isGroup, !message.isOpenGroupMessage { - result.append(Action.copySessionID(viewItem, delegate)) - } - if !isGroup || viewItem.userCanDeleteGroupMessage { result.append(Action.delete(viewItem, delegate)) } - if isGroup && viewItem.interaction is TSIncomingMessage && viewItem.userHasModerationPermission { - result.append(Action.ban(viewItem, delegate)) - result.append(Action.banAndDeleteAllMessages(viewItem, delegate)) - } - return result - case .mediaMessage, .audio, .genericAttachment: - var result: [Action] = [] - if isReplyingAllowed() { result.append(Action.reply(viewItem, delegate)) } - if viewItem.canCopyMedia() { result.append(Action.copy(viewItem, delegate)) } - if viewItem.canSaveMedia() { result.append(Action.save(viewItem, delegate)) } - let isGroup = viewItem.isGroupThread - if let message = viewItem.interaction as? TSIncomingMessage, isGroup, !message.isOpenGroupMessage { - result.append(Action.copySessionID(viewItem, delegate)) - } - if !isGroup || viewItem.userCanDeleteGroupMessage { result.append(Action.delete(viewItem, delegate)) } - if isGroup && viewItem.interaction is TSIncomingMessage && viewItem.userHasModerationPermission { - result.append(Action.ban(viewItem, delegate)) - result.append(Action.banAndDeleteAllMessages(viewItem, delegate)) - } - return result - default: return [] + static func actions(for item: ConversationViewModel.Item, currentUserIsOpenGroupModerator: Bool, delegate: ContextMenuActionDelegate?) -> [Action]? { + // No context items for info messages + guard item.interactionVariant == .standardOutgoing || item.interactionVariant == .standardIncoming else { + return nil } + + let canReply: Bool = ( + item.interactionVariant != .standardOutgoing || ( + item.state != .failed && + item.state != .sending + ) + ) + let canCopy: Bool = ( + item.cellType == .textOnlyMessage || ( + ( + item.cellType == .genericAttachment || + item.cellType == .mediaMessage + ) && + (item.attachments ?? []).count == 1 && + (item.attachments ?? []).first?.isVisualMedia == true && + (item.attachments ?? []).first?.isValid == true && ( + (item.attachments ?? []).first?.state == .downloaded || + (item.attachments ?? []).first?.state == .uploaded + ) + ) + ) + let canSave: Bool = ( + item.cellType != .textOnlyMessage && + canCopy + ) + let canCopySessionId: Bool = ( + item.interactionVariant == .standardIncoming && + item.threadVariant != .openGroup + ) + let canDelete: Bool = ( + item.threadVariant != .openGroup || + currentUserIsOpenGroupModerator + ) + let canBan: Bool = ( + item.threadVariant == .openGroup && + currentUserIsOpenGroupModerator + ) + + return [ + (canReply ? Action.reply(item, delegate) : nil), + (canCopy ? Action.copy(item, delegate) : nil), + (canSave ? Action.save(item, delegate) : nil), + (canCopySessionId ? Action.copySessionID(item, delegate) : nil), + (canDelete ? Action.delete(item, delegate) : nil), + (canBan ? Action.ban(item, delegate) : nil), + (canBan ? Action.banAndDeleteAllMessages(item, delegate) : nil) + ] + .compactMap { $0 } } } -// MARK: Delegate -protocol ContextMenuActionDelegate : AnyObject { - - func reply(_ viewItem: ConversationViewItem) - func copy(_ viewItem: ConversationViewItem) - func copySessionID(_ viewItem: ConversationViewItem) - func delete(_ viewItem: ConversationViewItem) - func save(_ viewItem: ConversationViewItem) - func ban(_ viewItem: ConversationViewItem) - func banAndDeleteAllMessages(_ viewItem: ConversationViewItem) +// MARK: - Delegate + +protocol ContextMenuActionDelegate { + func reply(_ item: ConversationViewModel.Item) + func copy(_ item: ConversationViewModel.Item) + func copySessionID(_ item: ConversationViewModel.Item) + func delete(_ item: ConversationViewModel.Item) + func save(_ item: ConversationViewModel.Item) + func ban(_ item: ConversationViewModel.Item) + func banAndDeleteAllMessages(_ item: ConversationViewModel.Item) } diff --git a/Session/Conversations/Context Menu/ContextMenuVC+ActionView.swift b/Session/Conversations/Context Menu/ContextMenuVC+ActionView.swift index 0f0e99ffc..21648bd31 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+ActionView.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+ActionView.swift @@ -1,19 +1,25 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SessionUIKit +import SessionUtilitiesKit extension ContextMenuVC { - - final class ActionView : UIView { - private let action: Action - private let dismiss: () -> Void - - // MARK: Settings + final class ActionView: UIView { private static let iconSize: CGFloat = 16 private static let iconImageViewSize: CGFloat = 24 - // MARK: Lifecycle + private let action: Action + private let dismiss: () -> Void + + // MARK: - Lifecycle + init(for action: Action, dismiss: @escaping () -> Void) { self.action = action self.dismiss = dismiss + super.init(frame: CGRect.zero) + setUpViewHierarchy() } @@ -28,32 +34,46 @@ extension ContextMenuVC { private func setUpViewHierarchy() { // Icon let iconSize = ActionView.iconSize - let iconImageView = UIImageView(image: action.icon.resizedImage(to: CGSize(width: iconSize, height: iconSize))!.withTint(Colors.text)) - let iconImageViewSize = ActionView.iconImageViewSize - iconImageView.set(.width, to: iconImageViewSize) - iconImageView.set(.height, to: iconImageViewSize) + let iconImageView: UIImageView = UIImageView( + image: action.icon? + .resizedImage(to: CGSize(width: iconSize, height: iconSize))? + .withRenderingMode(.alwaysTemplate) + ) + iconImageView.set(.width, to: ActionView.iconImageViewSize) + iconImageView.set(.height, to: ActionView.iconImageViewSize) iconImageView.contentMode = .center + iconImageView.tintColor = Colors.text + // Title let titleLabel = UILabel() titleLabel.text = action.title titleLabel.textColor = Colors.text titleLabel.font = .systemFont(ofSize: Values.mediumFontSize) + // Stack view - let stackView = UIStackView(arrangedSubviews: [ iconImageView, titleLabel ]) + let stackView: UIStackView = UIStackView(arrangedSubviews: [ iconImageView, titleLabel ]) stackView.axis = .horizontal stackView.spacing = Values.smallSpacing stackView.alignment = .center stackView.isLayoutMarginsRelativeArrangement = true + let smallSpacing = Values.smallSpacing - stackView.layoutMargins = UIEdgeInsets(top: smallSpacing, leading: smallSpacing, bottom: smallSpacing, trailing: Values.mediumSpacing) + stackView.layoutMargins = UIEdgeInsets( + top: smallSpacing, + leading: smallSpacing, + bottom: smallSpacing, + trailing: Values.mediumSpacing + ) addSubview(stackView) stackView.pin(to: self) + // Tap gesture recognizer let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap)) addGestureRecognizer(tapGestureRecognizer) } - // MARK: Interaction + // MARK: - Interaction + @objc private func handleTap() { action.work() dismiss() diff --git a/Session/Conversations/Context Menu/ContextMenuVC.swift b/Session/Conversations/Context Menu/ContextMenuVC.swift index 66cf13925..5f4d56cbf 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC.swift @@ -1,43 +1,59 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -final class ContextMenuVC : UIViewController { +import UIKit +import SessionUIKit + +final class ContextMenuVC: UIViewController { + private static let actionViewHeight: CGFloat = 40 + private static let menuCornerRadius: CGFloat = 8 + private let snapshot: UIView - private let viewItem: ConversationViewItem private let frame: CGRect + private let item: ConversationViewModel.Item + private let actions: [Action] private let dismiss: () -> Void - private weak var delegate: ContextMenuActionDelegate? - // MARK: UI Components - private lazy var blurView = UIVisualEffectView(effect: nil) + // MARK: - UI + + private lazy var blurView: UIVisualEffectView = UIVisualEffectView(effect: nil) private lazy var menuView: UIView = { - let result = UIView() + let result: UIView = UIView() result.layer.shadowColor = UIColor.black.cgColor result.layer.shadowOffset = CGSize.zero result.layer.shadowOpacity = 0.4 result.layer.shadowRadius = 4 + return result }() private lazy var timestampLabel: UILabel = { - let result = UILabel() - let date = viewItem.interaction.dateForUI() - result.text = DateUtil.formatDate(forDisplay: date) + let result: UILabel = UILabel() result.font = .systemFont(ofSize: Values.verySmallFontSize) - result.textColor = isLightMode ? .black : .white + result.textColor = (isLightMode ? .black : .white) + + if let dateForUI: Date = item.dateForUI { + result.text = DateUtil.formatDate(forDisplay: dateForUI) + } + return result }() - - // MARK: Settings - private static let actionViewHeight: CGFloat = 40 - private static let menuCornerRadius: CGFloat = 8 - // MARK: Lifecycle - init(snapshot: UIView, viewItem: ConversationViewItem, frame: CGRect, delegate: ContextMenuActionDelegate, dismiss: @escaping () -> Void) { + // MARK: - Initialization + + init( + snapshot: UIView, + frame: CGRect, + item: ConversationViewModel.Item, + actions: [Action], + dismiss: @escaping () -> Void + ) { self.snapshot = snapshot - self.viewItem = viewItem self.frame = frame - self.delegate = delegate + self.item = item + self.actions = actions self.dismiss = dismiss + super.init(nibName: nil, bundle: nil) } @@ -48,33 +64,42 @@ final class ContextMenuVC : UIViewController { required init?(coder: NSCoder) { preconditionFailure("Use init(coder:) instead.") } + + // MARK: - Lifecycle override func viewDidLoad() { super.viewDidLoad() + // Background color view.backgroundColor = .clear + // Blur view.addSubview(blurView) blurView.pin(to: view) + // Snapshot snapshot.layer.shadowColor = UIColor.black.cgColor snapshot.layer.shadowOffset = CGSize.zero snapshot.layer.shadowOpacity = 0.4 snapshot.layer.shadowRadius = 4 view.addSubview(snapshot) + snapshot.pin(.left, to: .left, of: view, withInset: frame.origin.x) snapshot.pin(.top, to: .top, of: view, withInset: frame.origin.y) snapshot.set(.width, to: frame.width) snapshot.set(.height, to: frame.height) + // Timestamp view.addSubview(timestampLabel) timestampLabel.center(.vertical, in: snapshot) - let isOutgoing = (viewItem.interaction.interactionType() == .outgoingMessage) - if isOutgoing { + + if item.interactionVariant == .standardOutgoing { timestampLabel.pin(.right, to: .left, of: snapshot, withInset: -Values.smallSpacing) - } else { + } + else { timestampLabel.pin(.left, to: .right, of: snapshot, withInset: Values.smallSpacing) } + // Menu let menuBackgroundView = UIView() menuBackgroundView.backgroundColor = Colors.receivedMessageBackground @@ -82,25 +107,33 @@ final class ContextMenuVC : UIViewController { menuBackgroundView.layer.masksToBounds = true menuView.addSubview(menuBackgroundView) menuBackgroundView.pin(to: menuView) - let actionViews = ContextMenuVC.actions(for: viewItem, delegate: delegate).map { ActionView(for: $0, dismiss: snDismiss) } - let menuStackView = UIStackView(arrangedSubviews: actionViews) + + let menuStackView = UIStackView( + arrangedSubviews: actions + .map { action -> ActionView in ActionView(for: action, dismiss: snDismiss) } + ) menuStackView.axis = .vertical menuView.addSubview(menuStackView) menuStackView.pin(to: menuView) view.addSubview(menuView) - let menuHeight = CGFloat(actionViews.count) * ContextMenuVC.actionViewHeight + + let menuHeight = (CGFloat(actions.count) * ContextMenuVC.actionViewHeight) let spacing = Values.smallSpacing let margin = max(UIApplication.shared.keyWindow!.safeAreaInsets.bottom, Values.mediumSpacing) + if frame.maxY + spacing + menuHeight > UIScreen.main.bounds.height - margin { menuView.pin(.bottom, to: .top, of: snapshot, withInset: -spacing) - } else { + } + else { menuView.pin(.top, to: .bottom, of: snapshot, withInset: spacing) } - switch viewItem.interaction.interactionType() { - case .outgoingMessage: menuView.pin(.right, to: .right, of: snapshot) - case .incomingMessage: menuView.pin(.left, to: .left, of: snapshot) - default: break // Should never occur + + switch item.interactionVariant { + case .standardOutgoing: menuView.pin(.right, to: .right, of: snapshot) + case .standardIncoming: menuView.pin(.left, to: .left, of: snapshot) + default: break // Should never occur } + // Tap gesture let mainTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap)) view.addGestureRecognizer(mainTapGestureRecognizer) @@ -108,30 +141,41 @@ final class ContextMenuVC : UIViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + UIView.animate(withDuration: 0.25) { self.blurView.effect = UIBlurEffect(style: .regular) self.menuView.alpha = 1 } } - // MARK: Updating + // MARK: - Layout + override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - menuView.layer.shadowPath = UIBezierPath(roundedRect: menuView.bounds, cornerRadius: ContextMenuVC.menuCornerRadius).cgPath + + menuView.layer.shadowPath = UIBezierPath( + roundedRect: menuView.bounds, + cornerRadius: ContextMenuVC.menuCornerRadius + ).cgPath } - // MARK: Interaction + // MARK: - Interaction + @objc private func handleTap() { snDismiss() } func snDismiss() { - UIView.animate(withDuration: 0.25, animations: { - self.blurView.effect = nil - self.menuView.alpha = 0 - self.timestampLabel.alpha = 0 - }, completion: { _ in - self.dismiss() - }) + UIView.animate( + withDuration: 0.25, + animations: { [weak self] in + self?.blurView.effect = nil + self?.menuView.alpha = 0 + self?.timestampLabel.alpha = 0 + }, + completion: { [weak self] _ in + self?.dismiss() + } + ) } } diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 14b5b5c9c..6433fdb1e 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -12,6 +12,7 @@ import SignalUtilitiesKit extension ConversationVC: InputViewDelegate, MessageCellDelegate, + ContextMenuActionDelegate, ScrollToBottomButtonDelegate, SendMediaNavDelegate, UIDocumentPickerDelegate, @@ -50,31 +51,23 @@ extension ConversationVC: // MARK: - Blocking @objc func unblock() { - guard let thread = thread as? TSContactThread else { return } - let publicKey = thread.contactSessionID() + guard self.viewModel.viewData.thread.variant == .contact else { return } + let publicKey: String = self.viewModel.viewData.thread.id + UIView.animate( withDuration: 0.25, animations: { self.blockedBanner.alpha = 0 }, completion: { _ in - GRDBStorage.shared.writeAsync( - updates: { db in - try Contact - .fetchOne(db, id: publicKey)? - .with(isBlocked: false) - .update(db) - }, - completion: { db, result in - switch result { - case .success: - MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() - - default: break - } - } - ) + GRDBStorage.shared.write { db in + try Contact + .filter(id: publicKey) + .updateAll(db, Contact.Columns.isBlocked.set(to: true)) + + try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() + } } ) } @@ -484,15 +477,6 @@ extension ConversationVC: } } - // MARK: Input View - func inputTextViewDidChangeContent(_ inputTextView: InputTextView) { - let newText = inputTextView.text ?? "" - if !newText.isEmpty { - SSKEnvironment.shared.typingIndicators.didStartTypingOutgoingInput(inThread: thread) - } - updateMentions(for: newText) - } - func showLinkPreviewSuggestionModal() { let linkPreviewModel = LinkPreviewModal() { [weak self] in self?.snInputView.autoGenerateLinkPreview() @@ -501,45 +485,82 @@ extension ConversationVC: linkPreviewModel.modalTransitionStyle = .crossDissolve present(linkPreviewModel, animated: true, completion: nil) } - - // MARK: Mentions - func updateMentions(for newText: String) { - if newText.count < oldText.count { - currentMentionStartIndex = nil - snInputView.hideMentionsUI() - mentions = mentions.filter { $0.isContained(in: newText) } + + func inputTextViewDidChangeContent(_ inputTextView: InputTextView) { + let newText: String = (inputTextView.text ?? "") + + if !newText.isEmpty { } + + updateMentions(for: newText) + } + + // MARK: --Attachments + + func didPasteImageFromPasteboard(_ image: UIImage) { + guard let imageData = image.jpegData(compressionQuality: 1.0) else { return } + let dataSource = DataSourceValue.dataSource(with: imageData, utiType: kUTTypeJPEG as String) + let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: kUTTypeJPEG as String, imageQuality: .medium) + + let approvalVC = AttachmentApprovalViewController.wrappedInNavController(attachments: [ attachment ], approvalDelegate: self) + approvalVC.modalPresentationStyle = .fullScreen + self.present(approvalVC, animated: true, completion: nil) + } + + // MARK: --Mentions + + func handleMentionSelected(_ mentionInfo: ConversationViewModel.MentionInfo, from view: MentionSelectionView) { + guard let currentMentionStartIndex = currentMentionStartIndex else { return } + + mentions.append(mentionInfo) + + let newText: String = snInputView.text.replacingCharacters( + in: currentMentionStartIndex..., + with: "@\(mentionInfo.profile.displayName(for: self.viewModel.viewData.thread.variant)) " + ) + + snInputView.text = newText + self.currentMentionStartIndex = nil + snInputView.hideMentionsUI() + + mentions = mentions.filter { mentionInfo -> Bool in + newText.contains(mentionInfo.profile.displayName(for: self.viewModel.viewData.thread.variant)) + } + } + + func updateMentions(for newText: String) { if !newText.isEmpty { let lastCharacterIndex = newText.index(before: newText.endIndex) let lastCharacter = newText[lastCharacterIndex] + // Check if there is whitespace before the '@' or the '@' is the first character let isCharacterBeforeLastWhiteSpaceOrStartOfLine: Bool if newText.count == 1 { isCharacterBeforeLastWhiteSpaceOrStartOfLine = true // Start of line - } else { + } + else { let characterBeforeLast = newText[newText.index(before: lastCharacterIndex)] isCharacterBeforeLastWhiteSpaceOrStartOfLine = characterBeforeLast.isWhitespace } + if lastCharacter == "@" && isCharacterBeforeLastWhiteSpaceOrStartOfLine { - let candidates = MentionsManager.getMentionCandidates(for: "", in: thread.uniqueId!) currentMentionStartIndex = lastCharacterIndex - snInputView.showMentionsUI(for: candidates, in: thread) - } else if lastCharacter.isWhitespace || lastCharacter == "@" { // the lastCharacter == "@" is to check for @@ + snInputView.showMentionsUI(for: self.viewModel.mentions()) + } + else if lastCharacter.isWhitespace || lastCharacter == "@" { // the lastCharacter == "@" is to check for @@ currentMentionStartIndex = nil snInputView.hideMentionsUI() - } else { + } + else { if let currentMentionStartIndex = currentMentionStartIndex { let query = String(newText[newText.index(after: currentMentionStartIndex)...]) // + 1 to get rid of the @ - let candidates = MentionsManager.getMentionCandidates(for: query, in: thread.uniqueId!) - snInputView.showMentionsUI(for: candidates, in: thread) + snInputView.showMentionsUI(for: self.viewModel.mentions(for: query)) } } } - oldText = newText } func resetMentions() { - oldText = "" currentMentionStartIndex = nil mentions = [] } @@ -554,33 +575,11 @@ extension ConversationVC: func replaceMentions(in text: String) -> String { var result = text for mention in mentions { - guard let range = result.range(of: "@\(mention.displayName)") else { continue } - result = result.replacingCharacters(in: range, with: "@\(mention.publicKey)") + guard let range = result.range(of: "@\(mention.profile.displayName(for: mention.threadVariant))") else { continue } + result = result.replacingCharacters(in: range, with: "@\(mention.profile.id)") } + return result - let approvalVC = AttachmentApprovalViewController.wrappedInNavController(attachments: [ attachment ], approvalDelegate: self) - approvalVC.modalPresentationStyle = .fullScreen - self.present(approvalVC, animated: true, completion: nil) - } - - // MARK: --Mentions - - func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView) { - guard let currentMentionStartIndex = currentMentionStartIndex else { return } - mentions.append(mention) - let oldText = snInputView.text - let newText = oldText.replacingCharacters(in: currentMentionStartIndex..., with: "@\(mention.displayName) ") - snInputView.text = newText - self.currentMentionStartIndex = nil - snInputView.hideMentionsUI() - self.oldText = newText - } - - func showInputAccessoryView() { - UIView.animate(withDuration: 0.25, animations: { - self.inputAccessoryView?.isHidden = false - self.inputAccessoryView?.alpha = 1 - }) } // MARK: View Item Interaction @@ -925,14 +924,15 @@ extension ConversationVC: present(joinOpenGroupModal, animated: true, completion: nil) } - func handleReplyButtonTapped(for viewItem: ConversationViewItem) { - reply(viewItem) + func handleReplyButtonTapped(for item: ConversationViewModel.Item) { + reply(item) } - func showUserDetails(for sessionID: String) { - let userDetailsSheet = UserDetailsSheet(for: sessionID) + func showUserDetails(for profile: Profile) { + let userDetailsSheet = UserDetailsSheet(for: profile) userDetailsSheet.modalPresentationStyle = .overFullScreen userDetailsSheet.modalTransitionStyle = .crossDissolve + present(userDetailsSheet, animated: true, completion: nil) } diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 1725b6cb7..02ffa1e63 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -39,9 +39,8 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers var contextMenuVC: ContextMenuVC? // Mentions - var oldText = "" var currentMentionStartIndex: String.Index? - var mentions: [Mention] = [] + var mentions: [ConversationViewModel.MentionInfo] = [] // Scrolling & paging var isUserScrolling = false @@ -321,6 +320,13 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers // Nav bar setUpNavBarStyle() navigationItem.titleView = titleView + + titleView.update( + with: viewModel.viewData.threadName, + mutedUntilTimestamp: viewModel.viewData.thread.mutedUntilTimestamp, + onlyNotifyForMentions: viewModel.viewData.thread.onlyNotifyForMentions, + userCount: viewModel.viewData.userCount + ) updateNavBarButtons(viewData: viewModel.viewData) // Constraints diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift index f5386c631..584884f40 100644 --- a/Session/Conversations/Input View/InputView.swift +++ b/Session/Conversations/Input View/InputView.swift @@ -1,8 +1,11 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + import UIKit import SessionUIKit +import SessionMessagingKit -final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, QuoteViewDelegate, LinkPreviewViewDelegate, MentionSelectionViewDelegate { - enum MessageTypes { +final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, MentionSelectionViewDelegate { + enum MessageTypes: Equatable { case all case textOnly case none @@ -29,23 +32,22 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, setEnabledMessageTypes(enabledMessageTypes, message: nil) } } - + override var intrinsicContentSize: CGSize { CGSize.zero } var lastSearchedText: String? { nil } - - // MARK: UI Components - + + // MARK: - UI + private var bottomStackView: UIStackView? private lazy var attachmentsButton = ExpandingAttachmentsButton(delegate: delegate) - + private lazy var voiceMessageButton: InputViewButton = { let result = InputViewButton(icon: #imageLiteral(resourceName: "Microphone"), delegate: self) result.accessibilityLabel = NSLocalizedString("VOICE_MESSAGE_TOO_SHORT_ALERT_TITLE", comment: "") result.accessibilityHint = NSLocalizedString("VOICE_MESSAGE_TOO_SHORT_ALERT_MESSAGE", comment: "") return result }() - - + private lazy var sendButton: InputViewButton = { let result = InputViewButton(icon: #imageLiteral(resourceName: "ArrowUp"), isSendButton: true, delegate: self) result.isHidden = true @@ -55,25 +57,28 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, private lazy var voiceMessageButtonContainer = container(for: voiceMessageButton) private lazy var mentionsView: MentionSelectionView = { - let result = MentionSelectionView() + let result: MentionSelectionView = MentionSelectionView() result.delegate = self + return result }() private lazy var mentionsViewContainer: UIView = { - let result = UIView() + let result: UIView = UIView() let backgroundView = UIView() - backgroundView.backgroundColor = isLightMode ? .white : .black + backgroundView.backgroundColor = (isLightMode ? .white : .black) backgroundView.alpha = Values.lowOpacity result.addSubview(backgroundView) backgroundView.pin(to: result) - let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) + + let blurView: UIVisualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) result.addSubview(blurView) blurView.pin(to: result) result.alpha = 0 + return result }() - + private lazy var inputTextView: InputTextView = { // HACK: When restoring a draft the input text view won't have a frame yet, and therefore it won't // be able to calculate what size it should be to accommodate the draft text. As a workaround, we @@ -83,7 +88,7 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, let maxWidth = UIScreen.main.bounds.width - 2 * InputViewButton.expandedSize - 2 * Values.smallSpacing - 2 * (Values.mediumSpacing - adjustment) return InputTextView(delegate: self, maxWidth: maxWidth) }() - + private lazy var disabledInputLabel: UILabel = { let label: UILabel = UILabel() label.translatesAutoresizingMaskIntoConstraints = false diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index ff816bf36..4e13dec3e 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -519,8 +519,8 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve } @objc func joinOpenGroup() { - let joinOpenGroupVC = JoinOpenGroupVC() - let navigationController = OWSNavigationController(rootViewController: joinOpenGroupVC) + let joinOpenGroupVC: JoinOpenGroupVC = JoinOpenGroupVC() + let navigationController: OWSNavigationController = OWSNavigationController(rootViewController: joinOpenGroupVC) present(navigationController, animated: true, completion: nil) } diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 3fc9969e2..3ffdf9c71 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -73,6 +73,7 @@ public class HomeViewModel { } } + fileprivate static let contactIsTypingKey = CodingKeys.contactIsTyping.stringValue fileprivate static let closedGroupNameKey = CodingKeys.closedGroupName.stringValue fileprivate static let openGroupNameKey = CodingKeys.openGroupName.stringValue fileprivate static let openGroupProfilePictureDataKey = CodingKeys.openGroupProfilePictureData.stringValue @@ -92,6 +93,7 @@ public class HomeViewModel { public let variant: SessionThread.Variant private let creationDateTimestamp: TimeInterval + public let contactIsTyping: Bool public let closedGroupName: String? public let openGroupName: String? public let openGroupProfilePictureData: Data? @@ -176,6 +178,7 @@ public class HomeViewModel { self.id = "FALLBACK" self.variant = .contact self.creationDateTimestamp = 0 + self.contactIsTyping = false self.closedGroupName = nil self.openGroupName = nil self.openGroupProfilePictureData = nil @@ -198,6 +201,7 @@ public class HomeViewModel { public static func query(userPublicKey: String) -> QueryInterfaceRequest { let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() + let typingIndicator: TypedTableAlias = TypedTableAlias() let closedGroup: TypedTableAlias = TypedTableAlias() let closedGroupMember: TypedTableAlias = TypedTableAlias() let openGroup: TypedTableAlias = TypedTableAlias() @@ -262,12 +266,14 @@ public class HomeViewModel { ) .group(Interaction.Columns.threadId) // One interaction per thread ) + return SessionThread .select( thread[.id], thread[.variant], thread[.creationDateTimestamp], + (typingIndicator[.threadId] != nil).forKey(ThreadInfo.contactIsTypingKey), closedGroup[.name].forKey(ThreadInfo.closedGroupNameKey), openGroup[.name].forKey(ThreadInfo.openGroupNameKey), openGroup[.imageData].forKey(ThreadInfo.openGroupProfilePictureDataKey), @@ -291,6 +297,7 @@ public class HomeViewModel { .forKey(ThreadInfo.contactProfileKey) ) ) + .joining(optional: SessionThread.typingIndicator.aliased(typingIndicator)) .joining( optional: SessionThread.closedGroup .aliased(closedGroup) diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 235fedbb3..e38c479e8 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -297,23 +297,34 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD CurrentAppContext().setMainAppBadgeNumber( GRDBStorage.shared - .read({ db in + .read { db in let userPublicKey: String = getUserHexEncodedPublicKey(db) + let thread: TypedTableAlias = TypedTableAlias() - // Don't increase the count for muted threads or message requests return try Interaction .filter(Interaction.Columns.wasRead == false) + .filter( + // Only count mentions if 'onlyNotifyForMentions' is set + thread[.onlyNotifyForMentions] == false || + Interaction.Columns.hasMention == true + ) .joining( required: Interaction.thread + .aliased(thread) .joining(optional: SessionThread.contact) - .filter(SessionThread.Columns.notificationMode != SessionThread.NotificationMode.none) .filter( + // Ignore muted threads + SessionThread.Columns.mutedUntilTimestamp == nil || + SessionThread.Columns.mutedUntilTimestamp < Date().timeIntervalSince1970 + ) + .filter( + // Ignore message request threads SessionThread.Columns.variant != SessionThread.Variant.contact || !SessionThread.isMessageRequest(userPublicKey: userPublicKey) ) ) .fetchCount(db) - }) + } .defaulting(to: 0) ) } diff --git a/Session/Notifications/AppNotifications.swift b/Session/Notifications/AppNotifications.swift index b3e5706e1..060badf94 100644 --- a/Session/Notifications/AppNotifications.swift +++ b/Session/Notifications/AppNotifications.swift @@ -124,10 +124,6 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { return Environment.shared.preferences } - var previewType: NotificationType { - return preferences.notificationPreviewType() - } - // MARK: - @objc @@ -278,10 +274,12 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { public func notifyForFailedSend(_ db: Database, in thread: SessionThread) { let notificationTitle: String? + let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType] + .defaulting(to: .nameAndPreview) switch previewType { case .noNameNoPreview: notificationTitle = nil - case .nameNoPreview, .namePreview: + case .nameNoPreview, .nameAndPreview: notificationTitle = SessionThread.displayName( threadId: thread.id, variant: thread.variant, @@ -296,8 +294,6 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { isNoteToSelf: (thread.isNoteToSelf(db) == true), profile: try? Profile.fetchOne(db, id: thread.id) ) - - default: notificationTitle = nil } let notificationBody = NotificationStrings.failedToSendBody @@ -411,12 +407,14 @@ class NotificationActionHandler { } let promise: Promise = GRDBStorage.shared.write { db in + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) let interaction: Interaction = try Interaction( threadId: thread.id, authorId: getUserHexEncodedPublicKey(db), variant: .standardOutgoing, body: replyText, - timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) + timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)), + hasMention: replyText.contains("@\(currentUserPublicKey)") ).inserted(db) try Interaction.markAsRead( diff --git a/Session/Open Groups/JoinOpenGroupVC.swift b/Session/Open Groups/JoinOpenGroupVC.swift index 1cde67788..d6a7d2289 100644 --- a/Session/Open Groups/JoinOpenGroupVC.swift +++ b/Session/Open Groups/JoinOpenGroupVC.swift @@ -1,73 +1,82 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import GRDB +import SessionUIKit +import SessionMessagingKit import SessionUtilitiesKit -final class JoinOpenGroupVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControllerDelegate, OWSQRScannerDelegate { +final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewControllerDelegate, OWSQRScannerDelegate { private let pageVC = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil) private var pages: [UIViewController] = [] private var isJoining = false private var targetVCIndex: Int? + + // MARK: - Components - // MARK: Components private lazy var tabBar: TabBar = { - let tabs = [ - TabBar.Tab(title: NSLocalizedString("vc_join_public_chat_enter_group_url_tab_title", comment: "")) { [weak self] in + let tabs: [TabBar.Tab] = [ + TabBar.Tab(title: "vc_join_public_chat_enter_group_url_tab_title".localized()) { [weak self] in guard let self = self else { return } self.pageVC.setViewControllers([ self.pages[0] ], direction: .forward, animated: false, completion: nil) }, - TabBar.Tab(title: NSLocalizedString("vc_join_public_chat_scan_qr_code_tab_title", comment: "")) { [weak self] in + TabBar.Tab(title: "vc_join_public_chat_scan_qr_code_tab_title".localized()) { [weak self] in guard let self = self else { return } self.pageVC.setViewControllers([ self.pages[1] ], direction: .forward, animated: false, completion: nil) } ] + return TabBar(tabs: tabs) }() - + private lazy var enterURLVC: EnterURLVC = { - let result = EnterURLVC() + let result: EnterURLVC = EnterURLVC() result.joinOpenGroupVC = self + return result }() - + private lazy var scanQRCodePlaceholderVC: ScanQRCodePlaceholderVC = { - let result = ScanQRCodePlaceholderVC() + let result: ScanQRCodePlaceholderVC = ScanQRCodePlaceholderVC() result.joinOpenGroupVC = self + return result }() - + private lazy var scanQRCodeWrapperVC: ScanQRCodeWrapperVC = { - let message = NSLocalizedString("vc_join_public_chat_scan_qr_code_explanation", comment: "") - let result = ScanQRCodeWrapperVC(message: message) + let result: ScanQRCodeWrapperVC = ScanQRCodeWrapperVC(message: "vc_join_public_chat_scan_qr_code_explanation".localized()) result.delegate = self + return result }() - - // MARK: Lifecycle + + // MARK: - Lifecycle override func viewDidLoad() { super.viewDidLoad() + setUpGradientBackground() setUpNavBarStyle() - setNavBarTitle(NSLocalizedString("vc_join_public_chat_title", comment: "")) - let navigationBar = navigationController!.navigationBar + setNavBarTitle("vc_join_public_chat_title".localized()) + // Navigation bar buttons + let navBarHeight: CGFloat = (navigationController?.navigationBar.height() ?? 0) let closeButton = UIBarButtonItem(image: #imageLiteral(resourceName: "X"), style: .plain, target: self, action: #selector(close)) closeButton.tintColor = Colors.text navigationItem.leftBarButtonItem = closeButton + // Page VC let hasCameraAccess = (AVCaptureDevice.authorizationStatus(for: .video) == .authorized) pages = [ enterURLVC, (hasCameraAccess ? scanQRCodeWrapperVC : scanQRCodePlaceholderVC) ] pageVC.dataSource = self pageVC.delegate = self pageVC.setViewControllers([ enterURLVC ], direction: .forward, animated: false, completion: nil) + // Tab bar view.addSubview(tabBar) tabBar.pin(.leading, to: .leading, of: view) - let tabBarInset: CGFloat - if #available(iOS 13, *) { - tabBarInset = navigationBar.height() - } else { - tabBarInset = 0 - } - tabBar.pin(.top, to: .top, of: view, withInset: tabBarInset) + tabBar.pin(.top, to: .top, of: view, withInset: navBarHeight) view.pin(.trailing, to: .trailing, of: tabBar) + // Page VC constraints let pageVCView = pageVC.view! view.addSubview(pageVCView) @@ -75,79 +84,85 @@ final class JoinOpenGroupVC : BaseVC, UIPageViewControllerDataSource, UIPageView pageVCView.pin(.top, to: .bottom, of: tabBar) view.pin(.trailing, to: .trailing, of: pageVCView) view.pin(.bottom, to: .bottom, of: pageVCView) + let screen = UIScreen.main.bounds + let height: CGFloat = ((navigationController?.view.bounds.height ?? 0) - navBarHeight - TabBar.snHeight) pageVCView.set(.width, to: screen.width) - let height: CGFloat - if #available(iOS 13, *) { - height = navigationController!.view.bounds.height - navigationBar.height() - TabBar.snHeight - } else { - let statusBarHeight = UIApplication.shared.statusBarFrame.height - height = navigationController!.view.bounds.height - navigationBar.height() - TabBar.snHeight - statusBarHeight - } pageVCView.set(.height, to: height) enterURLVC.constrainHeight(to: height) scanQRCodePlaceholderVC.constrainHeight(to: height) } + + // MARK: - General - // MARK: General func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { guard let index = pages.firstIndex(of: viewController), index != 0 else { return nil } + return pages[index - 1] } - + func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { guard let index = pages.firstIndex(of: viewController), index != (pages.count - 1) else { return nil } + return pages[index + 1] } - + fileprivate func handleCameraAccessGranted() { pages[1] = scanQRCodeWrapperVC pageVC.setViewControllers([ scanQRCodeWrapperVC ], direction: .forward, animated: false, completion: nil) } + + // MARK: - Updating - // MARK: Updating func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) { guard let targetVC = pendingViewControllers.first, let index = pages.firstIndex(of: targetVC) else { return } + targetVCIndex = index } - + func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating isFinished: Bool, previousViewControllers: [UIViewController], transitionCompleted isCompleted: Bool) { guard isCompleted, let index = targetVCIndex else { return } + tabBar.selectTab(at: index) } + + // MARK: - Interaction - // MARK: Interaction @objc private func close() { dismiss(animated: true, completion: nil) } - + func controller(_ controller: OWSQRCodeScanningViewController, didDetectQRCodeWith string: String) { joinOpenGroup(with: string) } - + fileprivate func joinOpenGroup(with string: String) { // A V2 open group URL will look like: + + + + // The host doesn't parse if no explicit scheme is provided - if let (room, server, publicKey) = OpenGroupManagerV2.parseV2OpenGroup(from: string) { - joinV2OpenGroup(room: room, server: server, publicKey: publicKey) - } else { - let title = NSLocalizedString("invalid_url", comment: "") - let message = "Please check the URL you entered and try again." - showError(title: title, message: message) + guard let (room, server, publicKey) = OpenGroupManagerV2.parseV2OpenGroup(from: string) else { + showError( + title: "invalid_url".localized(), + message: "Please check the URL you entered and try again." + ) + return } + + joinV2OpenGroup(room: room, server: server, publicKey: publicKey) } - + fileprivate func joinV2OpenGroup(room: String, server: String, publicKey: String) { - guard !isJoining else { return } + guard !isJoining, let navigationController: UINavigationController = navigationController else { return } + isJoining = true - ModalActivityIndicatorViewController.present(fromViewController: navigationController!, canCancel: false) { [weak self] _ in - Storage.shared.write { transaction in - OpenGroupManagerV2.shared.add(room: room, server: server, publicKey: publicKey, using: transaction) + + ModalActivityIndicatorViewController.present(fromViewController: navigationController, canCancel: false) { [weak self] _ in + GRDBStorage.shared + .write { db in OpenGroupManagerV2.shared.add(db, room: room, server: server, publicKey: publicKey) } .done(on: DispatchQueue.main) { [weak self] _ in GRDBStorage.shared.write { db in - MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...) + try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...) } - + self?.presentingViewController?.dismiss(animated: true, completion: nil) } .catch(on: DispatchQueue.main) { [weak self] error in @@ -157,22 +172,24 @@ final class JoinOpenGroupVC : BaseVC, UIPageViewControllerDataSource, UIPageView self?.isJoining = false self?.showError(title: title, message: message) } - } } } - - // MARK: Convenience + + // MARK: - Convenience + private func showError(title: String, message: String = "") { - let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil)) + let alert: UIAlertController = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "BUTTON_OK".localized(), style: .default, handler: nil)) + presentAlert(alert) } } -private final class EnterURLVC : UIViewController, UIGestureRecognizerDelegate, OpenGroupSuggestionGridDelegate { - weak var joinOpenGroupVC: JoinOpenGroupVC! +private final class EnterURLVC: UIViewController, UIGestureRecognizerDelegate, OpenGroupSuggestionGridDelegate { + weak var joinOpenGroupVC: JoinOpenGroupVC? + + // MARK: - UI - // MARK: Components private lazy var urlTextView: TextView = { let result = TextView(placeholder: NSLocalizedString("vc_enter_chat_url_text_field_hint", comment: "")) result.keyboardType = .URL @@ -180,18 +197,21 @@ private final class EnterURLVC : UIViewController, UIGestureRecognizerDelegate, result.autocorrectionType = .no return result }() - + private lazy var suggestionGrid: OpenGroupSuggestionGrid = { - let maxWidth = UIScreen.main.bounds.width - Values.largeSpacing * 2 - let result = OpenGroupSuggestionGrid(maxWidth: maxWidth) + let maxWidth: CGFloat = (UIScreen.main.bounds.width - Values.largeSpacing * 2) + let result: OpenGroupSuggestionGrid = OpenGroupSuggestionGrid(maxWidth: maxWidth) result.delegate = self + return result }() + + // MARK: - Lifecycle - // MARK: Lifecycle override func viewDidLoad() { // Remove background color view.backgroundColor = .clear + // Suggestion grid title label let suggestionGridTitleLabel = UILabel() suggestionGridTitleLabel.textColor = Colors.text @@ -199,16 +219,19 @@ private final class EnterURLVC : UIViewController, UIGestureRecognizerDelegate, suggestionGridTitleLabel.text = NSLocalizedString("vc_join_open_group_suggestions_title", comment: "") suggestionGridTitleLabel.numberOfLines = 0 suggestionGridTitleLabel.lineBreakMode = .byWordWrapping + // Next button let nextButton = Button(style: .prominentOutline, size: .large) nextButton.setTitle(NSLocalizedString("next", comment: ""), for: UIControl.State.normal) nextButton.addTarget(self, action: #selector(joinOpenGroup), for: UIControl.Event.touchUpInside) + let nextButtonContainer = UIView() nextButtonContainer.addSubview(nextButton) nextButton.pin(.leading, to: .leading, of: nextButtonContainer, withInset: 80) nextButton.pin(.top, to: .top, of: nextButtonContainer) nextButtonContainer.pin(.trailing, to: .trailing, of: nextButton, withInset: 80) nextButtonContainer.pin(.bottom, to: .bottom, of: nextButton) + // Stack view let stackView = UIStackView(arrangedSubviews: [ urlTextView, UIView.spacer(withHeight: Values.mediumSpacing), suggestionGridTitleLabel, UIView.spacer(withHeight: Values.mediumSpacing), suggestionGrid, UIView.vStretchingSpacer(), nextButtonContainer ]) @@ -218,45 +241,52 @@ private final class EnterURLVC : UIViewController, UIGestureRecognizerDelegate, stackView.isLayoutMarginsRelativeArrangement = true view.addSubview(stackView) stackView.pin(to: view) + // Constraints view.set(.width, to: UIScreen.main.bounds.width) + // Dismiss keyboard on tap let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard)) tapGestureRecognizer.delegate = self view.addGestureRecognizer(tapGestureRecognizer) } + + // MARK: - General - // MARK: General func constrainHeight(to height: CGFloat) { view.set(.height, to: height) } - + @objc private func dismissKeyboard() { urlTextView.resignFirstResponder() } + + // MARK: - Interaction - // MARK: Interaction func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { let location = gestureRecognizer.location(in: view) return !suggestionGrid.frame.contains(location) } - + func join(_ room: OpenGroupAPIV2.Info) { - joinOpenGroupVC.joinV2OpenGroup(room: room.id, server: OpenGroupAPIV2.defaultServer, publicKey: OpenGroupAPIV2.defaultServerPublicKey) + joinOpenGroupVC?.joinV2OpenGroup(room: room.id, server: OpenGroupAPIV2.defaultServer, publicKey: OpenGroupAPIV2.defaultServerPublicKey) } - + @objc private func joinOpenGroup() { let url = urlTextView.text?.trimmingCharacters(in: .whitespaces) ?? "" - joinOpenGroupVC.joinOpenGroup(with: url) + joinOpenGroupVC?.joinOpenGroup(with: url) } } -private final class ScanQRCodePlaceholderVC : UIViewController { - weak var joinOpenGroupVC: JoinOpenGroupVC! +private final class ScanQRCodePlaceholderVC: UIViewController { + weak var joinOpenGroupVC: JoinOpenGroupVC? + // MARK: - Lifecycle + override func viewDidLoad() { // Remove background color view.backgroundColor = .clear + // Explanation label let explanationLabel = UILabel() explanationLabel.textColor = Colors.text @@ -265,34 +295,38 @@ private final class ScanQRCodePlaceholderVC : UIViewController { explanationLabel.numberOfLines = 0 explanationLabel.textAlignment = .center explanationLabel.lineBreakMode = .byWordWrapping + // Call to action button let callToActionButton = UIButton() callToActionButton.titleLabel!.font = .boldSystemFont(ofSize: Values.mediumFontSize) callToActionButton.setTitleColor(Colors.accent, for: UIControl.State.normal) callToActionButton.setTitle(NSLocalizedString("vc_scan_qr_code_grant_camera_access_button_title", comment: ""), for: UIControl.State.normal) callToActionButton.addTarget(self, action: #selector(requestCameraAccess), for: UIControl.Event.touchUpInside) + // Stack view let stackView = UIStackView(arrangedSubviews: [ explanationLabel, callToActionButton ]) stackView.axis = .vertical stackView.spacing = Values.mediumSpacing stackView.alignment = .center + // Constraints view.set(.width, to: UIScreen.main.bounds.width) view.addSubview(stackView) stackView.pin(.leading, to: .leading, of: view, withInset: Values.massiveSpacing) view.pin(.trailing, to: .trailing, of: stackView, withInset: Values.massiveSpacing) + let verticalCenteringConstraint = stackView.center(.vertical, in: view) verticalCenteringConstraint.constant = -16 // Makes things appear centered visually } - + func constrainHeight(to height: CGFloat) { view.set(.height, to: height) } - + @objc private func requestCameraAccess() { ows_ask(forCameraPermissions: { [weak self] hasCameraAccess in if hasCameraAccess { - self?.joinOpenGroupVC.handleCameraAccessGranted() + self?.joinOpenGroupVC?.handleCameraAccessGranted() } else { // Do nothing } diff --git a/Session/Settings/NotificationSettingsOptionsViewController.m b/Session/Settings/NotificationSettingsOptionsViewController.m index edc613765..833ac0a14 100644 --- a/Session/Settings/NotificationSettingsOptionsViewController.m +++ b/Session/Settings/NotificationSettingsOptionsViewController.m @@ -30,27 +30,23 @@ OWSTableSection *section = [OWSTableSection new]; // section.footerTitle = NSLocalizedString(@"NOTIFICATIONS_FOOTER_WARNING", nil); - OWSPreferences *prefs = Environment.shared.preferences; - NotificationType selectedNotifType = [prefs notificationPreviewType]; - for (NSNumber *option in - @[ @(NotificationNamePreview), @(NotificationNameNoPreview), @(NotificationNoNameNoPreview) ]) { - NotificationType notificationType = (NotificationType)option.intValue; - + NSInteger selectedNotifType = [SMKPreferences notificationPreviewType]; + + for (NSNumber *option in [SMKPreferences notificationTypes]) { [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ UITableViewCell *cell = [OWSTableItem newCell]; cell.tintColor = LKColors.accent; - [[cell textLabel] setText:[prefs nameForNotificationPreviewType:notificationType]]; - if (selectedNotifType == notificationType) { + [[cell textLabel] setText:[SMKPreferences nameForNotificationPreviewType:option.intValue]]; + if (selectedNotifType == option.intValue) { cell.accessoryType = UITableViewCellAccessoryCheckmark; } - cell.accessibilityIdentifier - = ACCESSIBILITY_IDENTIFIER_WITH_NAME(NotificationSettingsOptionsViewController, - NSStringForNotificationType(notificationType)); + cell.accessibilityIdentifier = ACCESSIBILITY_IDENTIFIER_WITH_NAME(NotificationSettingsOptionsViewController, [SMKPreferences accessibilityIdentifierForNotificationPreviewType:option.intValue]); return cell; } actionBlock:^{ - [weakSelf setNotificationType:notificationType]; + [SMKPreferences setNotificationPreviewType: option.intValue]; + [weakSelf.navigationController popViewControllerAnimated:YES]; }]]; } [contents addSection:section]; @@ -58,11 +54,4 @@ self.contents = contents; } -- (void)setNotificationType:(NotificationType)notificationType -{ - [Environment.shared.preferences setNotificationPreviewType:notificationType]; - - [self.navigationController popViewControllerAnimated:YES]; -} - @end diff --git a/Session/Settings/NotificationSettingsViewController.m b/Session/Settings/NotificationSettingsViewController.m index ce0099053..af21657d4 100644 --- a/Session/Settings/NotificationSettingsViewController.m +++ b/Session/Settings/NotificationSettingsViewController.m @@ -93,7 +93,7 @@ [backgroundSection addItem:[OWSTableItem disclosureItemWithText:NSLocalizedString(@"NOTIFICATIONS_SHOW", nil) - detailText:[prefs nameForNotificationPreviewType:[prefs notificationPreviewType]] + detailText:[SMKPreferences nameForNotificationPreviewType:[SMKPreferences notificationPreviewType]] accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"options") actionBlock:^{ NotificationSettingsOptionsViewController *vc = diff --git a/Session/Shared/ConversationCell.swift b/Session/Shared/ConversationCell.swift index 8cad0e3d3..119d10806 100644 --- a/Session/Shared/ConversationCell.swift +++ b/Session/Shared/ConversationCell.swift @@ -4,112 +4,122 @@ import UIKit import SessionUIKit import SignalUtilitiesKit -final class ConversationCell : UITableViewCell { - static let reuseIdentifier = "ConversationCell" +final class ConversationCell: UITableViewCell { + // MARK: - UI + + private let accentLineView: UIView = UIView() - // MARK: UI Components - private let accentLineView = UIView() - - private lazy var profilePictureView = ProfilePictureView() + private lazy var profilePictureView: ProfilePictureView = ProfilePictureView() private lazy var displayNameLabel: UILabel = { - let result = UILabel() + let result: UILabel = UILabel() result.font = .boldSystemFont(ofSize: Values.mediumFontSize) result.textColor = Colors.text result.lineBreakMode = .byTruncatingTail + return result }() private lazy var unreadCountView: UIView = { - let result = UIView() + let result: UIView = UIView() result.backgroundColor = Colors.text.withAlphaComponent(Values.veryLowOpacity) let size = ConversationCell.unreadCountViewSize result.set(.width, greaterThanOrEqualTo: size) result.set(.height, to: size) result.layer.masksToBounds = true - result.layer.cornerRadius = size / 2 + result.layer.cornerRadius = (size / 2) + return result }() private lazy var unreadCountLabel: UILabel = { - let result = UILabel() + let result: UILabel = UILabel() result.font = .boldSystemFont(ofSize: Values.verySmallFontSize) result.textColor = Colors.text result.textAlignment = .center + return result }() private lazy var hasMentionView: UIView = { - let result = UIView() + let result: UIView = UIView() result.backgroundColor = Colors.accent let size = ConversationCell.unreadCountViewSize result.set(.width, to: size) result.set(.height, to: size) result.layer.masksToBounds = true - result.layer.cornerRadius = size / 2 + result.layer.cornerRadius = (size / 2) + return result }() private lazy var hasMentionLabel: UILabel = { - let result = UILabel() + let result: UILabel = UILabel() result.font = .boldSystemFont(ofSize: Values.verySmallFontSize) result.textColor = Colors.text result.text = "@" result.textAlignment = .center + return result }() private lazy var isPinnedIcon: UIImageView = { - let result = UIImageView(image: UIImage(named: "Pin")!.withRenderingMode(.alwaysTemplate)) + let result: UIImageView = UIImageView(image: UIImage(named: "Pin")?.withRenderingMode(.alwaysTemplate)) result.contentMode = .scaleAspectFit let size = ConversationCell.unreadCountViewSize result.set(.width, to: size) result.set(.height, to: size) result.tintColor = Colors.pinIcon result.layer.masksToBounds = true + return result }() private lazy var timestampLabel: UILabel = { - let result = UILabel() + let result: UILabel = UILabel() result.font = .systemFont(ofSize: Values.smallFontSize) result.textColor = Colors.text result.lineBreakMode = .byTruncatingTail result.alpha = Values.lowOpacity + return result }() private lazy var snippetLabel: UILabel = { - let result = UILabel() + let result: UILabel = UILabel() result.font = .systemFont(ofSize: Values.smallFontSize) result.textColor = Colors.text result.lineBreakMode = .byTruncatingTail + return result }() private lazy var typingIndicatorView = TypingIndicatorView() private lazy var statusIndicatorView: UIImageView = { - let result = UIImageView() + let result: UIImageView = UIImageView() result.contentMode = .scaleAspectFit - result.layer.cornerRadius = ConversationCell.statusIndicatorSize / 2 + result.layer.cornerRadius = (ConversationCell.statusIndicatorSize / 2) result.layer.masksToBounds = true + return result }() private lazy var topLabelStackView: UIStackView = { - let result = UIStackView() + let result: UIStackView = UIStackView() result.axis = .horizontal result.alignment = .center result.spacing = Values.smallSpacing / 2 // Effectively Values.smallSpacing because there'll be spacing before and after the invisible spacer + return result }() private lazy var bottomLabelStackView: UIStackView = { - let result = UIStackView() + let result: UIStackView = UIStackView() result.axis = .horizontal result.alignment = .center result.spacing = Values.smallSpacing / 2 // Effectively Values.smallSpacing because there'll be spacing before and after the invisible spacer + return result }() @@ -118,7 +128,8 @@ final class ConversationCell : UITableViewCell { public static let unreadCountViewSize: CGFloat = 20 private static let statusIndicatorSize: CGFloat = 14 - // MARK: Initialization + // MARK: - Initialization + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) setUpViewHierarchy() @@ -131,69 +142,88 @@ final class ConversationCell : UITableViewCell { private func setUpViewHierarchy() { let cellHeight: CGFloat = 68 + // Background color backgroundColor = Colors.cellBackground + // Highlight color let selectedBackgroundView = UIView() selectedBackgroundView.backgroundColor = Colors.cellSelected self.selectedBackgroundView = selectedBackgroundView + // Accent line view accentLineView.set(.width, to: Values.accentLineThickness) accentLineView.set(.height, to: cellHeight) + // Profile picture view let profilePictureViewSize = Values.mediumProfilePictureSize profilePictureView.set(.width, to: profilePictureViewSize) profilePictureView.set(.height, to: profilePictureViewSize) profilePictureView.size = profilePictureViewSize + // Unread count view unreadCountView.addSubview(unreadCountLabel) unreadCountLabel.pin([ VerticalEdge.top, VerticalEdge.bottom ], to: unreadCountView) unreadCountView.pin(.leading, to: .leading, of: unreadCountLabel, withInset: -4) unreadCountView.pin(.trailing, to: .trailing, of: unreadCountLabel, withInset: 4) + // Has mention view hasMentionView.addSubview(hasMentionLabel) hasMentionLabel.pin(to: hasMentionView) + // Label stack view let topLabelSpacer = UIView.hStretchingSpacer() [ displayNameLabel, isPinnedIcon, unreadCountView, hasMentionView, topLabelSpacer, timestampLabel ].forEach{ view in topLabelStackView.addArrangedSubview(view) } + let snippetLabelContainer = UIView() snippetLabelContainer.addSubview(snippetLabel) snippetLabelContainer.addSubview(typingIndicatorView) + let bottomLabelSpacer = UIView.hStretchingSpacer() [ snippetLabelContainer, bottomLabelSpacer, statusIndicatorView ].forEach{ view in bottomLabelStackView.addArrangedSubview(view) } + let labelContainerView = UIStackView(arrangedSubviews: [ topLabelStackView, bottomLabelStackView ]) labelContainerView.axis = .vertical labelContainerView.alignment = .leading labelContainerView.spacing = 6 labelContainerView.isUserInteractionEnabled = false + // Main stack view let stackView = UIStackView(arrangedSubviews: [ accentLineView, profilePictureView, labelContainerView ]) stackView.axis = .horizontal stackView.alignment = .center stackView.spacing = Values.mediumSpacing contentView.addSubview(stackView) + // Constraints accentLineView.pin(.top, to: .top, of: contentView) accentLineView.pin(.bottom, to: .bottom, of: contentView) timestampLabel.setContentCompressionResistancePriority(.required, for: NSLayoutConstraint.Axis.horizontal) + // HACK: The six lines below are part of a workaround for a weird layout bug topLabelStackView.set(.width, to: UIScreen.main.bounds.width - Values.accentLineThickness - profilePictureViewSize - 3 * Values.mediumSpacing) topLabelStackView.set(.height, to: 20) topLabelSpacer.set(.height, to: 20) + bottomLabelStackView.set(.width, to: UIScreen.main.bounds.width - Values.accentLineThickness - profilePictureViewSize - 3 * Values.mediumSpacing) bottomLabelStackView.set(.height, to: 18) bottomLabelSpacer.set(.height, to: 18) + statusIndicatorView.set(.width, to: ConversationCell.statusIndicatorSize) statusIndicatorView.set(.height, to: ConversationCell.statusIndicatorSize) + snippetLabel.pin(to: snippetLabelContainer) + typingIndicatorView.pin(.leading, to: .leading, of: snippetLabelContainer) typingIndicatorView.centerYAnchor.constraint(equalTo: snippetLabel.centerYAnchor).isActive = true + stackView.pin(.leading, to: .leading, of: contentView) stackView.pin(.top, to: .top, of: contentView) + // HACK: The two lines below are part of a workaround for a weird layout bug stackView.set(.width, to: UIScreen.main.bounds.width - Values.mediumSpacing) stackView.set(.height, to: cellHeight) @@ -286,7 +316,7 @@ final class ConversationCell : UITableViewCell { } private func getHighlightedSnippet(snippet: String, searchText: String, fontSize: CGFloat) -> NSMutableAttributedString { - guard snippet != NSLocalizedString("NOTE_TO_SELF", comment: "") else { + guard snippet != "NOTE_TO_SELF".localized() else { return NSMutableAttributedString(string: snippet, attributes: [.foregroundColor:Colors.text]) } diff --git a/Session/Shared/UserSelectionVC.swift b/Session/Shared/UserSelectionVC.swift index 4c025cd1a..a2946f565 100644 --- a/Session/Shared/UserSelectionVC.swift +++ b/Session/Shared/UserSelectionVC.swift @@ -12,8 +12,7 @@ final class UserSelectionVC: BaseVC, UITableViewDataSource, UITableViewDelegate private lazy var users: [Profile] = { return Profile - .fetchAllContactProfiles() - .filter { usersToExclude.contains($0.id) } + .fetchAllContactProfiles(excluding: usersToExclude) }() // MARK: - Components diff --git a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift index 532622989..3d3a7f3cb 100644 --- a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -287,5 +287,12 @@ enum _001_InitialSetupMigration: Migration { t.uniqueKey([.threadId, .variant, .timestampMs]) } + + try db.create(table: ThreadTypingIndicator.self) { t in + t.column(.threadId, .text) + .primaryKey() + .references(SessionThread.self, onDelete: .cascade) // Delete if thread deleted + t.column(.timestampMs, .integer).notNull() + } } } diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index d830e6982..46e615e79 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -1215,8 +1215,8 @@ enum _003_YDBToGRDBMigration: Migration { legacyPreferences[key] = object } - // Note: The 'int(forKey:inCollection:)' defaults to `0` which is an incorrect value for the notification - // sound so catch it and default + // Note: The 'int(forKey:inCollection:)' defaults to `0` which is an incorrect value + // for the notification sound so catch it and default let globalNotificationSoundValue: Int32 = transaction.int( forKey: Legacy.soundsGlobalNotificationKey, inCollection: Legacy.soundsStorageNotificationCollection @@ -1226,17 +1226,17 @@ enum _003_YDBToGRDBMigration: Migration { Preferences.Sound.defaultNotificationSound.rawValue ) - legacyPreferences[Legacy.readReceiptManagerAreReadReceiptsEnabled] = (transaction.bool( + legacyPreferences[Legacy.readReceiptManagerAreReadReceiptsEnabled] = transaction.bool( forKey: Legacy.readReceiptManagerAreReadReceiptsEnabled, inCollection: Legacy.readReceiptManagerCollection, defaultValue: false - ) ? 1 : 0) + ) - legacyPreferences[Legacy.typingIndicatorsEnabledKey] = (transaction.bool( + legacyPreferences[Legacy.typingIndicatorsEnabledKey] = transaction.bool( forKey: Legacy.typingIndicatorsEnabledKey, inCollection: Legacy.typingIndicatorsCollection, defaultValue: false - ) ? 1 : 0) + ) } db[.preferencesNotificationPreviewType] = Preferences.NotificationPreviewType(rawValue: legacyPreferences[Legacy.preferencesKeyNotificationPreviewType] as? Int ?? -1) @@ -1292,6 +1292,15 @@ enum _003_YDBToGRDBMigration: Migration { return nil } + let processedLocalRelativeFilePath: String? = (legacyAttachment as? Legacy.AttachmentStream)? + .localRelativeFilePath + .map { filePath -> String in + // The old 'localRelativeFilePath' seemed to have a leading forward slash (want + // to get rid of it so we can correctly use 'appendingPathComponent') + guard filePath.starts(with: "/") else { return filePath } + + return String(filePath.suffix(from: filePath.index(after: filePath.startIndex))) + } let state: Attachment.State = { switch legacyAttachment { case let stream as Legacy.AttachmentStream: // Outgoing or already downloaded @@ -1307,7 +1316,22 @@ enum _003_YDBToGRDBMigration: Migration { let size: CGSize = { switch legacyAttachment { case let stream as Legacy.AttachmentStream: - guard let originalFilePath: String = Attachment.originalFilePath(id: legacyAttachmentId, mimeType: stream.contentType, sourceFilename: stream.localRelativeFilePath) else { + // First try to get an image size using the 'localRelativeFilePath' value + if + let localRelativeFilePath: String = processedLocalRelativeFilePath, + let specificImageSize: CGSize = Attachment.imageSize( + contentType: stream.contentType, + originalFilePath: URL(fileURLWithPath: Attachment.attachmentsFolder) + .appendingPathComponent(localRelativeFilePath) + .path + ), + specificImageSize != .zero + { + return specificImageSize + } + + // Then fallback to trying to get the size from the 'originalFilePath' + guard let originalFilePath: String = Attachment.originalFilePath(id: legacyAttachmentId, mimeType: stream.contentType, sourceFilename: stream.sourceFilename) else { return .zero } @@ -1328,7 +1352,7 @@ enum _003_YDBToGRDBMigration: Migration { let originalFilePath: String = Attachment.originalFilePath( id: legacyAttachmentId, mimeType: stream.contentType, - sourceFilename: stream.localRelativeFilePath + sourceFilename: stream.sourceFilename ) else { return (false, nil) @@ -1341,6 +1365,7 @@ enum _003_YDBToGRDBMigration: Migration { let (isValid, duration): (Bool, TimeInterval?) = Attachment.determineValidityAndDuration( contentType: stream.contentType, + localRelativeFilePath: processedLocalRelativeFilePath, originalFilePath: originalFilePath ) @@ -1376,9 +1401,11 @@ enum _003_YDBToGRDBMigration: Migration { state: state, contentType: legacyAttachment.contentType, byteCount: UInt(legacyAttachment.byteCount), - creationTimestamp: (legacyAttachment as? Legacy.AttachmentStream)?.creationTimestamp.timeIntervalSince1970, + creationTimestamp: (legacyAttachment as? Legacy.AttachmentStream)? + .creationTimestamp.timeIntervalSince1970, sourceFilename: legacyAttachment.sourceFilename, downloadUrl: legacyAttachment.downloadURL, + localRelativeFilePath: processedLocalRelativeFilePath, width: (size == .zero ? nil : UInt(size.width)), height: (size == .zero ? nil : UInt(size.height)), duration: duration, diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index 32fe5a0ba..5450bf902 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -179,6 +179,7 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR ) let (isValid, duration): (Bool, TimeInterval?) = Attachment.determineValidityAndDuration( contentType: contentType, + localRelativeFilePath: nil, originalFilePath: originalFilePath ) @@ -191,7 +192,7 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR self.creationTimestamp = nil self.sourceFilename = nil self.downloadUrl = nil - self.localRelativeFilePath = nil + self.localRelativeFilePath = URL(fileURLWithPath: originalFilePath).lastPathComponent self.width = imageSize.map { UInt(floor($0.width)) } self.height = imageSize.map { UInt(floor($0.height)) } self.duration = duration @@ -282,6 +283,7 @@ public extension Attachment { case (_, .downloaded): return Attachment.determineValidityAndDuration( contentType: contentType, + localRelativeFilePath: localRelativeFilePath, originalFilePath: originalFilePath ) @@ -570,7 +572,11 @@ public extension Attachment { ) } - internal static func determineValidityAndDuration(contentType: String, originalFilePath: String?) -> (isValid: Bool, duration: TimeInterval?) { + internal static func determineValidityAndDuration( + contentType: String, + localRelativeFilePath: String?, + originalFilePath: String? + ) -> (isValid: Bool, duration: TimeInterval?) { guard let originalFilePath: String = originalFilePath else { return (false, nil) } // Process audio attachments @@ -593,8 +599,23 @@ public extension Attachment { // Process image attachments if MIMETypeUtil.isImage(contentType) { + let specificFilePathIsValid: Bool = ( + localRelativeFilePath != nil && + localRelativeFilePath.map { + NSData.ows_isValidImage( + atPath: URL(fileURLWithPath: Attachment.attachmentsFolder) + .appendingPathComponent($0) + .path, + mimeType: contentType + ) + } == true + ) + return ( - NSData.ows_isValidImage(atPath: originalFilePath, mimeType: contentType), + ( + specificFilePathIsValid || + NSData.ows_isValidImage(atPath: originalFilePath, mimeType: contentType) + ), nil ) } @@ -607,9 +628,22 @@ public extension Attachment { // Accorting to the CMTime docs "value/timescale = seconds" (TimeInterval(item.duration.value) / TimeInterval(item.duration.timescale)) } + let specificFilePathIsValid: Bool = ( + localRelativeFilePath != nil && + localRelativeFilePath.map { + OWSMediaUtils.isValidVideo( + path: URL(fileURLWithPath: Attachment.attachmentsFolder) + .appendingPathComponent($0) + .path + ) + } == true + ) return ( - OWSMediaUtils.isValidVideo(path: originalFilePath), + ( + specificFilePathIsValid || + OWSMediaUtils.isValidVideo(path: originalFilePath) + ), durationSeconds ) } @@ -637,6 +671,12 @@ extension Attachment { } public var originalFilePath: String? { + if let localRelativeFilePath: String = self.localRelativeFilePath { + return URL(fileURLWithPath: Attachment.attachmentsFolder) + .appendingPathComponent(localRelativeFilePath) + .path + } + return Attachment.originalFilePath( id: self.id, mimeType: self.contentType, @@ -658,7 +698,7 @@ extension Attachment { let fileUrl: URL = URL(fileURLWithPath: originalFilePath) let filename: String = fileUrl.lastPathComponent.filenameWithoutExtension - let containingDir: String = fileUrl.deletingLastPathComponent().absoluteString + let containingDir: String = fileUrl.deletingLastPathComponent().path return "\(containingDir)/\(filename)-signal-ios-thumbnail.jpg" } @@ -684,7 +724,7 @@ extension Attachment { public var isVisualMedia: Bool { isImage || isVideo || isAnimated } public func readDataFromFile() throws -> Data? { - guard let filePath: String = Attachment.originalFilePath(id: self.id, mimeType: self.contentType, sourceFilename: self.sourceFilename) else { + guard let filePath: String = self.originalFilePath else { return nil } @@ -737,27 +777,6 @@ extension Attachment { loadThumbnail(with: size.dimension, success: success, failure: failure) } - func thumbnailSync(size: ThumbnailSize) -> UIImage? { - guard isVideo || isImage || isAnimated else { return nil } - - let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) - var image: UIImage? - - thumbnail( - size: size, - success: { loadedImage in - image = loadedImage - semaphore.signal() - }, - failure: { semaphore.signal() } - ) - - // Wait up to 5 seconds for the thumbnail to be loaded - _ = semaphore.wait(timeout: .now() + .seconds(5)) - - return image - } - public func cloneAsThumbnail() -> Attachment { fatalError("TODO: Add this back") } diff --git a/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift b/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift index d8780fbc2..b2ed7024a 100644 --- a/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift +++ b/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift @@ -78,13 +78,14 @@ public struct ControlMessageProcessRecord: Codable, FetchableRecord, Persistable // causing a unique constraint violation if isRetry { return nil } - // Allow '.new' closed group config message duplicates in this case to avoid + // Allow '.new' and 'encryptionKeyPair' closed group control message duplicates to avoid // the following situation: // • The app performed a background poll or received a push notification // • This method was invoked and the received message timestamps table was updated // • Processing wasn't finished // • The user doesn't see the new closed group if case .new = (message as? ClosedGroupControlMessage)?.kind { return nil } + if case .encryptionKeyPair = (message as? ClosedGroupControlMessage)?.kind { 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 diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index 6dba259b7..f4595bdae 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -14,6 +14,10 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, using: DisappearingMessagesConfiguration.threadForeignKey ) public static let interactions = hasMany(Interaction.self, using: Interaction.threadForeignKey) + public static let typingIndicator = hasOne( + ThreadTypingIndicator.self, + using: ThreadTypingIndicator.threadForeignKey + ) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -90,6 +94,10 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, request(for: SessionThread.interactions) } + public var typingIndicator: QueryInterfaceRequest { + request(for: SessionThread.typingIndicator) + } + // MARK: - Initialization public init( @@ -122,6 +130,13 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, .filter(Job.Columns.threadId == id) .deleteAll(db) + // Delete any GroupMembers associated to this thread + if variant == .closedGroup || variant == .openGroup { + try GroupMember + .filter(GroupMember.Columns.groupId == id) + .deleteAll(db) + } + return try performDelete(db) } } diff --git a/SessionMessagingKit/Database/Models/ThreadTypingIndicator.swift b/SessionMessagingKit/Database/Models/ThreadTypingIndicator.swift new file mode 100644 index 000000000..bad5e96dd --- /dev/null +++ b/SessionMessagingKit/Database/Models/ThreadTypingIndicator.swift @@ -0,0 +1,30 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +/// This record is created for an incoming typing indicator message +/// +/// **Note:** Currently we only support typing indicator on contact thread (one-to-one), to support groups we would need +/// to change the structure of this table (since it’s primary key is the threadId) +public struct ThreadTypingIndicator: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "threadTypingIndicator" } + internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id]) + private static let thread = belongsTo(SessionThread.self, using: threadForeignKey) + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case threadId + case timestampMs + } + + public let threadId: String + public let timestampMs: Int64 + + // MARK: - Relationships + + public var thread: QueryInterfaceRequest { + request(for: ThreadTypingIndicator.thread) + } +} diff --git a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift index 5fbcd74a5..0b8cd22fd 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift @@ -45,7 +45,7 @@ public enum AttachmentDownloadJob: JobExecutor { } .defaulting(to: attachment) - let temporaryFilePath: URL = URL( + let temporaryFileUrl: URL = URL( fileURLWithPath: OWSTemporaryDirectoryAccessibleAfterFirstAuth() + UUID().uuidString ) let downloadPromise: Promise = { @@ -66,7 +66,7 @@ public enum AttachmentDownloadJob: JobExecutor { downloadPromise .then { data -> Promise in - try data.write(to: temporaryFilePath, options: .atomic) + try data.write(to: temporaryFileUrl, options: .atomic) let plaintext: Data = try { guard @@ -92,7 +92,7 @@ public enum AttachmentDownloadJob: JobExecutor { } .done { // Remove the temporary file - OWSFileSystem.deleteFile(temporaryFilePath.absoluteString) + OWSFileSystem.deleteFile(temporaryFileUrl.path) // Update the attachment state GRDBStorage.shared.write { db in @@ -109,7 +109,7 @@ public enum AttachmentDownloadJob: JobExecutor { success(job, false) } .catch { error in - OWSFileSystem.deleteFile(temporaryFilePath.absoluteString) + OWSFileSystem.deleteFile(temporaryFileUrl.path) switch error { case OnionRequestAPI.Error.httpRequestFailedAtDestination(let statusCode, _, _) where statusCode == 400: diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift index a2b93f08c..6783489e4 100644 --- a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift @@ -112,6 +112,9 @@ public enum MessageSendJob: JobExecutor { } } + // Add the threadId to the message if there isn't one set + details.message.threadId = (details.message.threadId ?? job.threadId) + // Perform the actual message sending GRDBStorage.shared.write { db -> Promise in try MessageSender.send( diff --git a/SessionMessagingKit/Messages/Message.swift b/SessionMessagingKit/Messages/Message.swift index ee1398d78..b76c4e05c 100644 --- a/SessionMessagingKit/Messages/Message.swift +++ b/SessionMessagingKit/Messages/Message.swift @@ -66,8 +66,7 @@ public class Message: Codable { public func setGroupContextIfNeeded(_ db: Database, on dataMessage: SNProtoDataMessage.SNProtoDataMessageBuilder) throws { guard let threadId: String = threadId, - let thread: SessionThread = try? SessionThread.fetchOne(db, id: threadId), - thread.variant == .closedGroup, + (try? ClosedGroup.exists(db, id: threadId)) == true, let legacyGroupId: Data = "\(Legacy.closedGroupIdPrefix)\(threadId)".data(using: .utf8) else { return } @@ -76,3 +75,12 @@ public class Message: Codable { dataMessage.setGroup(try groupProto.build()) } } + +// MARK: - Mutation + +internal extension Message { + func with(sentTimestamp: UInt64) -> Message { + self.sentTimestamp = sentTimestamp + return self + } +} diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift b/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift index e010f6021..f6f76bf2d 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift @@ -430,8 +430,10 @@ public final class OpenGroupAPIV2 : NSObject { return send(request).map(on: OpenGroupAPIV2.workQueue) { _ in } } - public static func isUserModerator(_ publicKey: String, for room: String, on server: String) -> Bool { - return moderators[server]?[room]?.contains(publicKey) ?? false + public static func isUserModerator(_ publicKey: String, for room: String?, on server: String?) -> Bool { + guard let room: String = room, let server: String = server else { return false } + + return (moderators[server]?[room]?.contains(publicKey) ?? false) } // MARK: General diff --git a/SessionMessagingKit/Sending & Receiving/Mentions/Mention.swift b/SessionMessagingKit/Sending & Receiving/Mentions/Mention.swift deleted file mode 100644 index c064a5122..000000000 --- a/SessionMessagingKit/Sending & Receiving/Mentions/Mention.swift +++ /dev/null @@ -1,15 +0,0 @@ - -@objc(LKMention) -public final class Mention : NSObject { - @objc public let publicKey: String - @objc public let displayName: String - - @objc public init(publicKey: String, displayName: String) { - self.publicKey = publicKey - self.displayName = displayName - } - - @objc public func isContained(in string: String) -> Bool { - return string.contains(displayName) - } -} diff --git a/SessionMessagingKit/Sending & Receiving/Mentions/MentionsManager.swift b/SessionMessagingKit/Sending & Receiving/Mentions/MentionsManager.swift deleted file mode 100644 index 87a21fe20..000000000 --- a/SessionMessagingKit/Sending & Receiving/Mentions/MentionsManager.swift +++ /dev/null @@ -1,93 +0,0 @@ -import PromiseKit - -@objc(LKMentionsManager) -public final class MentionsManager : NSObject { - - /// A mapping from thread ID to set of user hex encoded public keys. - /// - /// - Note: Should only be accessed from the main queue to avoid race conditions. - @objc public static var userPublicKeyCache: [String:Set] = [:] - - internal static var storage: OWSPrimaryStorage { OWSPrimaryStorage.shared() } - - // MARK: Settings - private static var userIDScanLimit: UInt = 512 - - // MARK: Initialization - private override init() { } - - // MARK: Implementation - @objc public static func cache(_ publicKey: String, for threadID: String) { - if let cache = userPublicKeyCache[threadID] { - userPublicKeyCache[threadID] = cache.union([ publicKey ]) - } else { - userPublicKeyCache[threadID] = [ publicKey ] - } - } - - @objc public static func getMentionCandidates(for query: String, in threadID: String) -> [Mention] { - // Prepare - guard let cache = userPublicKeyCache[threadID] else { return [] } - var candidates: [Mention] = [] - // Gather candidates - let openGroupV2 = Storage.shared.getV2OpenGroup(for: threadID) - storage.dbReadConnection.read { transaction in - candidates = cache.compactMap { publicKey in - guard let displayName: String = Profile.displayNameNoFallback(id: publicKey, context: (openGroupV2 != nil ? .openGroup : .regular)) else { - return nil - } - guard !displayName.hasPrefix("Anonymous") else { return nil } - return Mention(publicKey: publicKey, displayName: displayName) - } - } - candidates = candidates.filter { $0.publicKey != getUserHexEncodedPublicKey() } - // Sort alphabetically first - candidates.sort { $0.displayName < $1.displayName } - if query.count >= 2 { - // Filter out any non-matching candidates - candidates = candidates.filter { $0.displayName.lowercased().contains(query.lowercased()) } - // Sort based on where in the candidate the query occurs - candidates.sort { - $0.displayName.lowercased().range(of: query.lowercased())!.lowerBound < $1.displayName.lowercased().range(of: query.lowercased())!.lowerBound - } - } - // Return - return candidates - } - - @objc public static func populateUserPublicKeyCacheIfNeeded(for threadID: String, in transaction: YapDatabaseReadTransaction? = nil) { - var result: Set = [] - func populate(in transaction: YapDatabaseReadTransaction) { - guard let thread = TSThread.fetch(uniqueId: threadID, transaction: transaction) else { return } - if let groupThread = thread as? TSGroupThread, groupThread.groupModel.groupType == .closedGroup { - result = result.union(groupThread.groupModel.groupMemberIds).subtracting([ getUserHexEncodedPublicKey() ]) - } else { - let hasOnlyCurrentUser: Bool = ( - userPublicKeyCache[threadID]?.count == 1 && - userPublicKeyCache[threadID]?.first == getUserHexEncodedPublicKey() - ) - - guard userPublicKeyCache[threadID] == nil || ((thread as? TSGroupThread)?.groupModel.groupType == .openGroup && hasOnlyCurrentUser) else { - return - } - - let interactions = transaction.ext(TSMessageDatabaseViewExtensionName) as! YapDatabaseViewTransaction - interactions.enumerateKeysAndObjects(inGroup: threadID) { _, _, object, index, _ in - guard let message = object as? TSIncomingMessage, index < userIDScanLimit else { return } - result.insert(message.authorId) - } - } - result.insert(getUserHexEncodedPublicKey()) - } - if let transaction = transaction { - populate(in: transaction) - } else { - storage.dbReadConnection.read { transaction in - populate(in: transaction) - } - } - if !result.isEmpty { - userPublicKeyCache[threadID] = result - } - } -} diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index 1f6f9de5f..7b629b6fe 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -96,75 +96,28 @@ extension MessageReceiver { // MARK: - Typing Indicators private static func handleTypingIndicator(_ db: Database, message: TypingIndicator) throws { + guard + let senderPublicKey: String = message.sender, + let thread: SessionThread = try SessionThread.fetchOne(db, id: senderPublicKey) + else { return } + switch message.kind { - case .started: try showTypingIndicatorIfNeeded(db, for: message.sender) - case .stopped: try hideTypingIndicatorIfNeeded(db, for: message.sender) + case .started: + TypingIndicators.didStartTyping( + db, + in: thread, + direction: .incoming, + timestampMs: message.sentTimestamp.map { Int64($0) } + ) + + case .stopped: + TypingIndicators.didStopTyping(db, in: thread, direction: .incoming) default: SNLog("Unknown TypingIndicator Kind ignored") return } } - - private static func showTypingIndicatorIfNeeded(_ db: Database, for senderPublicKey: String?) throws { - guard let senderPublicKey: String = senderPublicKey else { return } - - var threadOrNil: TSContactThread? - Storage.read { transaction in - threadOrNil = TSContactThread.getWithContactSessionID(senderPublicKey, transaction: transaction) - } - guard let thread = threadOrNil else { return } - func showTypingIndicatorsIfNeeded() { - SSKEnvironment.shared.typingIndicators.didReceiveTypingStartedMessage(inThread: thread, recipientId: senderPublicKey, deviceId: 1) - } - if Thread.current.isMainThread { - showTypingIndicatorsIfNeeded() - } else { - DispatchQueue.main.async { - showTypingIndicatorsIfNeeded() - } - } - } - - private static func hideTypingIndicatorIfNeeded(_ db: Database, for senderPublicKey: String?) throws { - guard let senderPublicKey: String = senderPublicKey else { return } - - var threadOrNil: TSContactThread? - Storage.read { transaction in - threadOrNil = TSContactThread.getWithContactSessionID(senderPublicKey, transaction: transaction) - } - guard let thread = threadOrNil else { return } - func hideTypingIndicatorsIfNeeded() { - SSKEnvironment.shared.typingIndicators.didReceiveTypingStoppedMessage(inThread: thread, recipientId: senderPublicKey, deviceId: 1) - } - if Thread.current.isMainThread { - hideTypingIndicatorsIfNeeded() - } else { - DispatchQueue.main.async { - hideTypingIndicatorsIfNeeded() - } - } - } - - public static func cancelTypingIndicatorsIfNeeded(for senderPublicKey: String) { - var threadOrNil: TSContactThread? - Storage.read { transaction in - threadOrNil = TSContactThread.getWithContactSessionID(senderPublicKey, transaction: transaction) - } - guard let thread = threadOrNil else { return } - func cancelTypingIndicatorsIfNeeded() { - SSKEnvironment.shared.typingIndicators.didReceiveIncomingMessage(inThread: thread, recipientId: senderPublicKey, deviceId: 1) - } - if Thread.current.isMainThread { - cancelTypingIndicatorsIfNeeded() - } else { - DispatchQueue.main.async { - cancelTypingIndicatorsIfNeeded() - } - } - } - - // MARK: - Data Extraction Notification @@ -549,15 +502,17 @@ extension MessageReceiver { ) // Parse & persist attachments - let attachments: [Attachment] = dataMessage.attachments - .compactMap { proto in + let attachments: [Attachment] = try dataMessage.attachments + .compactMap { proto -> Attachment? in let attachment: Attachment = Attachment(proto: proto) // Attachments on received messages must have a 'downloadUrl' otherwise // they are invalid and we can ignore them return (attachment.downloadUrl != nil ? attachment : nil) } - try attachments.saveAll(db) + .map { attachment in + try attachment.saved(db) + } message.attachmentIds = attachments.map { $0.id } @@ -615,7 +570,7 @@ extension MessageReceiver { // Cancel any typing indicators if needed if isMainAppActive { - cancelTypingIndicatorsIfNeeded(for: message.sender!) + TypingIndicators.didStopTyping(db, in: thread, direction: .incoming) } // Update the contact's approval status of the current user if needed (if we are getting messages from @@ -976,7 +931,10 @@ extension MessageReceiver { body: ClosedGroupControlMessage.Kind .nameChange(name: name) .infoMessage(db, sender: sender), - timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) + timestampMs: ( + message.sentTimestamp.map { Int64($0) } ?? + Int64(floor(Date().timeIntervalSince1970 * 1000)) + ) ).inserted(db) } } @@ -992,7 +950,7 @@ extension MessageReceiver { let addedMembers: [String] = membersAsData.map { $0.toHexString() } let currentMemberIds: Set = groupMembers.map { $0.profileId }.asSet() let members: Set = currentMemberIds.union(addedMembers) - + // Create records for any new members try addedMembers .filter { !currentMemberIds.contains($0) } @@ -1025,14 +983,11 @@ extension MessageReceiver { } } - // Update zombie members in case the added members are zombies - let zombies: [GroupMember] = ((try? closedGroup.zombies.fetchAll(db)) ?? []) - - if !zombies.map { $0.profileId }.asSet().intersection(addedMembers).isEmpty { - try zombies - .filter { !addedMembers.contains($0.profileId) } - .deleteAll(db) - } + // Remove any 'zombie' versions of the added members (in case they were re-added) + _ = try closedGroup + .zombies + .filter(addedMembers.contains(GroupMember.Columns.profileId)) + .deleteAll(db) // Notify the user if needed guard members != Set(groupMembers.map { $0.profileId }) else { return } @@ -1050,7 +1005,10 @@ extension MessageReceiver { .map { Data(hex: $0) } ) .infoMessage(db, sender: sender), - timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) + timestampMs: ( + message.sentTimestamp.map { Int64($0) } ?? + Int64(floor(Date().timeIntervalSince1970 * 1000)) + ) ).inserted(db) } } @@ -1124,7 +1082,10 @@ extension MessageReceiver { .map { Data(hex: $0) } ) .infoMessage(db, sender: sender), - timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) + timestampMs: ( + message.sentTimestamp.map { Int64($0) } ?? + Int64(floor(Date().timeIntervalSince1970 * 1000)) + ) ).inserted(db) } } @@ -1159,6 +1120,14 @@ extension MessageReceiver { // Remove the group from the database and unsubscribe from PNs ClosedGroupPoller.shared.stopPolling(for: id) + try closedGroup + .members + .filter( + GroupMember.Columns.role == GroupMember.Role.standard || + GroupMember.Columns.role == GroupMember.Role.zombie + ) + .deleteAll(db) + _ = try closedGroup .keyPairs .deleteAll(db) @@ -1183,10 +1152,6 @@ extension MessageReceiver { ).insert(db) } - // Update the group - try membersToRemove - .deleteAll(db) - // Notify the user if needed guard updatedMemberIds != Set(members.map { $0.profileId }) else { return } @@ -1198,7 +1163,10 @@ extension MessageReceiver { body: ClosedGroupControlMessage.Kind .memberLeft .infoMessage(db, sender: sender), - timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) + timestampMs: ( + message.sentTimestamp.map { Int64($0) } ?? + Int64(floor(Date().timeIntervalSince1970 * 1000)) + ) ).inserted(db) } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift index 86cf35bd6..3016636b5 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift @@ -24,13 +24,13 @@ extension MessageSender { let membersAsData = members.map { Data(hex: $0) } let admins = [ userPublicKey ] let adminsAsData = admins.map { Data(hex: $0) } - + let formationTimestamp: TimeInterval = Date().timeIntervalSince1970 let thread: SessionThread = try SessionThread .fetchOrCreate(db, id: groupPublicKey, variant: .closedGroup) try ClosedGroup( threadId: groupPublicKey, name: name, - formationTimestamp: Date().timeIntervalSince1970 + formationTimestamp: formationTimestamp ).insert(db) try admins.forEach { adminId in @@ -74,6 +74,11 @@ extension MessageSender { admins: adminsAsData, expirationTimer: 0 ) + ) + .with( + // Note: We set this here to ensure the value matches the 'ClosedGroup' + // object we created + sentTimestamp: UInt64(floor(formationTimestamp * 1000)) ), interactionId: nil, in: contactThread @@ -501,23 +506,6 @@ extension MessageSender { } let userPublicKey: String = getUserHexEncodedPublicKey(db) - let isCurrentUserAdmin: Bool = allGroupMembers.contains(where: { - $0.role == .admin && $0.profileId == userPublicKey - }) - let membersToRemove: [GroupMember] = allGroupMembers - .filter { member in - member.role == .standard && ( - isCurrentUserAdmin || // If the admin leaves the group is disbanded - member.profileId == userPublicKey - ) - } - let adminsToRemove: [GroupMember] = allGroupMembers - .filter { member in - member.role == .admin && ( - isCurrentUserAdmin || // If the admin leaves the group is disbanded - member.profileId == userPublicKey - ) - } // Notify the user let interaction: Interaction = try Interaction( @@ -563,8 +551,9 @@ extension MessageSender { .map { _ in } // Update the group - try membersToRemove.deleteAll(db) - try adminsToRemove.deleteAll(db) + _ = try closedGroup + .allMembers + .deleteAll(db) // Return return promise diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index 4fff3a2a0..bfbf9a481 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -30,7 +30,6 @@ extension MessageSender { } public static func send(_ db: Database, message: Message, interactionId: Int64?, in thread: SessionThread) throws { - JobRunner.add( db, job: Job( diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 87c64f7bf..fc20a055b 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -93,8 +93,8 @@ public final class MessageSender : NSObject { // Set the timestamp, sender and recipient if message.sentTimestamp == nil { // Visible messages will already have their sent timestamp set - message.sentTimestamp = NSDate.millisecondTimestamp() } + message.sentTimestamp = UInt64(floor(Date().timeIntervalSince1970 * 1000)) let isSelfSend: Bool = (message.recipient == userPublicKey) message.sender = userPublicKey @@ -340,7 +340,7 @@ public final class MessageSender : NSObject { // Set the timestamp, sender and recipient if message.sentTimestamp == nil { // Visible messages will already have their sent timestamp set - message.sentTimestamp = NSDate.millisecondTimestamp() + message.sentTimestamp = UInt64(floor(Date().timeIntervalSince1970 * 1000)) } message.sender = getUserHexEncodedPublicKey() @@ -552,18 +552,8 @@ public final class MessageSender : NSObject { if let interactionId: Int64 = interactionId { return try Interaction.fetchOne(db, id: interactionId) } - else if let sentTimestamp: Double = message.sentTimestamp.map({ Double($0) }) { - // If we have a threadId then include that in the filter to make the request smaller - if - let threadId: String = message.threadId, - !threadId.isEmpty, - let thread: SessionThread = try? SessionThread.fetchOne(db, id: threadId) - { - return try thread.interactions - .filter(Interaction.Columns.timestampMs == sentTimestamp) - .fetchOne(db) - } - + + if let sentTimestamp: Double = message.sentTimestamp.map({ Double($0) }) { return try Interaction .filter(Interaction.Columns.timestampMs == sentTimestamp) .fetchOne(db) diff --git a/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift b/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift index 739b2e554..fc73325de 100644 --- a/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift +++ b/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift @@ -9,7 +9,6 @@ public struct QuotedReplyModel { public let timestampMs: Int64 public let body: String? public let attachment: Attachment? - public let thumbnailImage: UIImage? public let contentType: String? public let sourceFileName: String? public let thumbnailDownloadFailed: Bool @@ -22,7 +21,6 @@ public struct QuotedReplyModel { timestampMs: Int64, body: String?, attachment: Attachment?, - thumbnailImage: UIImage?, contentType: String?, sourceFileName: String?, thumbnailDownloadFailed: Bool @@ -32,23 +30,26 @@ public struct QuotedReplyModel { self.authorId = authorId self.timestampMs = timestampMs self.body = body - self.thumbnailImage = thumbnailImage self.contentType = contentType self.sourceFileName = sourceFileName self.thumbnailDownloadFailed = thumbnailDownloadFailed } public static func quotedReplyForSending( - _ db: Database, - interaction: Interaction, + threadId: String, + authorId: String, + variant: Interaction.Variant, + body: String?, + timestampMs: Int64, + attachments: [Attachment]?, linkPreview: LinkPreview? ) -> QuotedReplyModel? { - guard interaction.variant == .standardOutgoing || interaction.variant == .standardOutgoing else { + guard variant == .standardOutgoing || variant == .standardIncoming else { return nil } - var quotedText: String? = interaction.body - var quotedAttachment: Attachment? = try? interaction.attachments.fetchOne(db) + var quotedText: String? = body + var quotedAttachment: Attachment? = attachments?.first // If the attachment is "oversize text", try the quote as a reply to text, not as // a reply to an attachment @@ -57,7 +58,7 @@ public struct QuotedReplyModel { let attachment: Attachment = quotedAttachment, attachment.contentType == OWSMimeTypeOversizeTextMessage, ( - (interaction.variant == .standardIncoming && attachment.state == .downloaded) || + (variant == .standardIncoming && attachment.state == .downloaded) || attachment.state != .failed ), let originalFilePath: String = attachment.originalFilePath @@ -100,12 +101,11 @@ public struct QuotedReplyModel { } return QuotedReplyModel( - threadId: interaction.threadId, - authorId: interaction.authorId, - timestampMs: interaction.timestampMs, + threadId: threadId, + authorId: authorId, + timestampMs: timestampMs, body: (quotedText == nil && quotedAttachment == nil ? "" : quotedText), attachment: quotedAttachment, - thumbnailImage: quotedAttachment?.thumbnailImageSmallSync(), contentType: quotedAttachment?.contentType, sourceFileName: quotedAttachment?.sourceFilename, thumbnailDownloadFailed: false diff --git a/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift b/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift index c532e6231..5c2d83611 100644 --- a/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift +++ b/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift @@ -1,388 +1,166 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB +import SessionUtilitiesKit -@objc(OWSTypingIndicators) -public protocol TypingIndicators : AnyObject { - - @objc - func didStartTypingOutgoingInput(inThread thread: TSThread) - - @objc - func didStopTypingOutgoingInput(inThread thread: TSThread) - - @objc - func didSendOutgoingMessage(inThread thread: TSThread) - - @objc - func didReceiveTypingStartedMessage(inThread thread: TSThread, recipientId: String, deviceId: UInt) - - @objc - func didReceiveTypingStoppedMessage(inThread thread: TSThread, recipientId: String, deviceId: UInt) - - @objc - func didReceiveIncomingMessage(inThread thread: TSThread, recipientId: String, deviceId: UInt) - - // Returns the recipient id of the user who should currently be shown typing for a given thread. - // - // If no one is typing in that thread, returns nil. - // If multiple users are typing in that thread, returns the user to show. - // - // TODO: Use this method. - @objc - func typingRecipientId(forThread thread: TSThread) -> String? - - @objc - func setTypingIndicatorsEnabled(value: Bool) - - @objc - func areTypingIndicatorsEnabled() -> Bool -} - -// MARK: - - -@objc(OWSTypingIndicatorsImpl) -public class TypingIndicatorsImpl : NSObject, TypingIndicators { - - @objc - public static let typingIndicatorStateDidChange = Notification.Name("typingIndicatorStateDidChange") - - private let kDatabaseCollection = "TypingIndicators" - private let kDatabaseKey_TypingIndicatorsEnabled = "kDatabaseKey_TypingIndicatorsEnabled" - - private var _areTypingIndicatorsEnabled = false - - public override init() { - super.init() - - AppReadiness.runNowOrWhenAppWillBecomeReady { - self.setup() - } +public class TypingIndicators { + // MARK: - Direction + + public enum Direction { + case outgoing + case incoming } - - private func setup() { - _areTypingIndicatorsEnabled = OWSPrimaryStorage.shared().dbReadConnection.bool(forKey: kDatabaseKey_TypingIndicatorsEnabled, inCollection: kDatabaseCollection, defaultValue: false) - } - - // MARK: - - - @objc - public func setTypingIndicatorsEnabled(value: Bool) { - _areTypingIndicatorsEnabled = value - - OWSPrimaryStorage.shared().dbReadWriteConnection.setBool(value, forKey: kDatabaseKey_TypingIndicatorsEnabled, inCollection: kDatabaseCollection) - - NotificationCenter.default.postNotificationNameAsync(TypingIndicatorsImpl.typingIndicatorStateDidChange, object: nil) - } - - @objc - public func areTypingIndicatorsEnabled() -> Bool { - return _areTypingIndicatorsEnabled - } - - // MARK: - - - @objc - public func didStartTypingOutgoingInput(inThread thread: TSThread) { - guard let outgoingIndicators = ensureOutgoingIndicators(forThread: thread), !thread.isMessageRequest() else { - return - } - outgoingIndicators.didStartTypingOutgoingInput() - } - - @objc - public func didStopTypingOutgoingInput(inThread thread: TSThread) { - guard let outgoingIndicators = ensureOutgoingIndicators(forThread: thread), !thread.isMessageRequest() else { - return - } - outgoingIndicators.didStopTypingOutgoingInput() - } - - @objc - public func didSendOutgoingMessage(inThread thread: TSThread) { - guard let outgoingIndicators = ensureOutgoingIndicators(forThread: thread) else { - return - } - outgoingIndicators.didSendOutgoingMessage() - } - - @objc - public func didReceiveTypingStartedMessage(inThread thread: TSThread, recipientId: String, deviceId: UInt) { - let incomingIndicators = ensureIncomingIndicators(forThread: thread, recipientId: recipientId, deviceId: deviceId) - incomingIndicators.didReceiveTypingStartedMessage() - } - - @objc - public func didReceiveTypingStoppedMessage(inThread thread: TSThread, recipientId: String, deviceId: UInt) { - let incomingIndicators = ensureIncomingIndicators(forThread: thread, recipientId: recipientId, deviceId: deviceId) - incomingIndicators.didReceiveTypingStoppedMessage() - } - - @objc - public func didReceiveIncomingMessage(inThread thread: TSThread, recipientId: String, deviceId: UInt) { - let incomingIndicators = ensureIncomingIndicators(forThread: thread, recipientId: recipientId, deviceId: deviceId) - incomingIndicators.didReceiveIncomingMessage() - } - - @objc - public func typingRecipientId(forThread thread: TSThread) -> String? { - guard areTypingIndicatorsEnabled() else { - return nil - } - - var firstRecipientId: String? - var firstTimestamp: UInt64? - - let threadKey = incomingIndicatorsKey(forThread: thread) - guard let deviceMap = incomingIndicatorsMap[threadKey] else { - // No devices are typing in this thread. - return nil - } - for incomingIndicators in deviceMap.values { - guard incomingIndicators.isTyping else { - continue + + private class Indicator { + fileprivate let thread: SessionThread + fileprivate let direction: Direction + fileprivate let timestampMs: Int64 + + fileprivate var refreshTimer: Timer? + fileprivate var stopTimer: Timer? + + init?(thread: SessionThread, direction: Direction, timestampMs: Int64?) { + // The `typingIndicatorsEnabled` flag reflects the user-facing setting in the app + // preferences, if it's disabled we don't want to emit "typing indicator" messages + // or show typing indicators for other users + // + // We also don't want to show/send typing indicators for message requests + guard GRDBStorage.shared.read({ db in + ( + db[.typingIndicatorsEnabled] == true && + thread.isMessageRequest(db) == false + ) + }) == true else { + return nil } - guard let startedTypingTimestamp = incomingIndicators.startedTypingTimestamp else { - continue - } - if let firstTimestamp = firstTimestamp, - firstTimestamp < startedTypingTimestamp { - // More than one recipient/device is typing in this conversation; - // prefer the one that started typing first. - continue - } - firstRecipientId = incomingIndicators.recipientId - firstTimestamp = startedTypingTimestamp - } - return firstRecipientId - } - - // MARK: - - - // Map of thread id-to-OutgoingIndicators. - private var outgoingIndicatorsMap = [String: OutgoingIndicators]() - - private func ensureOutgoingIndicators(forThread thread: TSThread) -> OutgoingIndicators? { - guard let threadId = thread.uniqueId else { - return nil - } - if let outgoingIndicators = outgoingIndicatorsMap[threadId] { - return outgoingIndicators - } - let outgoingIndicators = OutgoingIndicators(delegate: self, thread: thread) - outgoingIndicatorsMap[threadId] = outgoingIndicators - return outgoingIndicators - } - - // The sender maintains two timers per chat: - // - // A sendPause timer - // A sendRefresh timer - private class OutgoingIndicators { - private weak var delegate: TypingIndicators? - private let thread: TSThread - private var sendPauseTimer: Timer? - private var sendRefreshTimer: Timer? - - init(delegate: TypingIndicators, thread: TSThread) { - self.delegate = delegate + + // Don't send typing indicators in group threads + guard thread.variant != .closedGroup && thread.variant != .openGroup else { return nil } + self.thread = thread + self.direction = direction + self.timestampMs = (timestampMs ?? Int64(floor(Date().timeIntervalSince1970 * 1000))) } - - // MARK: - - - func didStartTypingOutgoingInput() { - if sendRefreshTimer == nil { - // If the user types a character into the compose box, and the sendRefresh timer isn’t running: - - sendTypingMessageIfNecessary(forThread: thread, action: .started) - - sendRefreshTimer?.invalidate() - sendRefreshTimer = Timer.weakScheduledTimer(withTimeInterval: 10, - target: self, - selector: #selector(OutgoingIndicators.sendRefreshTimerDidFire), - userInfo: nil, - repeats: false) - } else { - // If the user types a character into the compose box, and the sendRefresh timer is running: + + fileprivate func starting(_ db: Database) -> Indicator { + let thread: SessionThread = self.thread + let direction: Direction = self.direction + let timestampMs: Int64 = self.timestampMs + + // Start the typing indicator + switch direction { + case .outgoing: + scheduleRefreshCallback(db, shouldSend: (refreshTimer == nil)) + + case .incoming: + try? ThreadTypingIndicator( + threadId: thread.id, + timestampMs: timestampMs + ) + .save(db) } - - sendPauseTimer?.invalidate() - sendPauseTimer = Timer.weakScheduledTimer(withTimeInterval: 3, - target: self, - selector: #selector(OutgoingIndicators.sendPauseTimerDidFire), - userInfo: nil, - repeats: false) - } - - func didStopTypingOutgoingInput() { - sendTypingMessageIfNecessary(forThread: thread, action: .stopped) - - sendRefreshTimer?.invalidate() - sendRefreshTimer = nil - - sendPauseTimer?.invalidate() - sendPauseTimer = nil - } - - @objc - func sendPauseTimerDidFire() { - sendTypingMessageIfNecessary(forThread: thread, action: .stopped) - - sendRefreshTimer?.invalidate() - sendRefreshTimer = nil - - sendPauseTimer?.invalidate() - sendPauseTimer = nil - } - - @objc - func sendRefreshTimerDidFire() { - sendTypingMessageIfNecessary(forThread: thread, action: .started) - - sendRefreshTimer?.invalidate() - sendRefreshTimer = Timer.weakScheduledTimer(withTimeInterval: 10, - target: self, - selector: #selector(sendRefreshTimerDidFire), - userInfo: nil, - repeats: false) - } - - func didSendOutgoingMessage() { - sendRefreshTimer?.invalidate() - sendRefreshTimer = nil - - sendPauseTimer?.invalidate() - sendPauseTimer = nil - } - - private func sendTypingMessageIfNecessary(forThread thread: TSThread, action: TypingIndicator.Kind) { - guard let delegate = delegate else { - return + + // Schedule the 'stopCallback' to cancel the typing indicator + stopTimer?.invalidate() + stopTimer = Timer.scheduledTimerOnMainThread( + withTimeInterval: (direction == .outgoing ? 3 : 5), + repeats: false + ) { [weak self] _ in + GRDBStorage.shared.write { db in + self?.stoping(db) + } } - // `areTypingIndicatorsEnabled` reflects the user-facing setting in the app preferences. - // If it's disabled we don't want to emit "typing indicator" messages - // or show typing indicators for other users. - guard delegate.areTypingIndicatorsEnabled() else { - return + + return self + } + + @discardableResult fileprivate func stoping(_ db: Database) -> Indicator? { + self.refreshTimer?.invalidate() + self.refreshTimer = nil + self.stopTimer?.invalidate() + self.stopTimer = nil + + switch direction { + case .outgoing: + try? MessageSender.send( + db, + message: TypingIndicator(kind: .stopped), + interactionId: nil, + in: thread + ) + + case .incoming: + _ = try? ThreadTypingIndicator + .filter(ThreadTypingIndicator.Columns.threadId == thread.id) + .deleteAll(db) } - - if thread.isGroupThread() { return } // Don't send typing indicators in group threads - - let typingIndicator = TypingIndicator() - typingIndicator.kind = action - SNMessagingKitConfiguration.shared.storage.write { transaction in - MessageSender.send(typingIndicator, in: thread, using: transaction as! YapDatabaseReadWriteTransaction) + + return nil + } + + private func scheduleRefreshCallback(_ db: Database, shouldSend: Bool = true) { + if shouldSend { + try? MessageSender.send( + db, + message: TypingIndicator(kind: .started), + interactionId: nil, + in: thread + ) } - } - } - - // MARK: - - - // Map of (thread id)-to-(recipient id and device id)-to-IncomingIndicators. - private var incomingIndicatorsMap = [String: [String: IncomingIndicators]]() - - private func incomingIndicatorsKey(forThread thread: TSThread) -> String { - return String(describing: thread.uniqueId) - } - - private func incomingIndicatorsKey(recipientId: String, deviceId: UInt) -> String { - return "\(recipientId) \(deviceId)" - } - - private func ensureIncomingIndicators(forThread thread: TSThread, recipientId: String, deviceId: UInt) -> IncomingIndicators { - let threadKey = incomingIndicatorsKey(forThread: thread) - let deviceKey = incomingIndicatorsKey(recipientId: recipientId, deviceId: deviceId) - guard let deviceMap = incomingIndicatorsMap[threadKey] else { - let incomingIndicators = IncomingIndicators(delegate: self, thread: thread, recipientId: recipientId, deviceId: deviceId) - incomingIndicatorsMap[threadKey] = [deviceKey: incomingIndicators] - return incomingIndicators - } - guard let incomingIndicators = deviceMap[deviceKey] else { - let incomingIndicators = IncomingIndicators(delegate: self, thread: thread, recipientId: recipientId, deviceId: deviceId) - var deviceMapCopy = deviceMap - deviceMapCopy[deviceKey] = incomingIndicators - incomingIndicatorsMap[threadKey] = deviceMapCopy - return incomingIndicators - } - return incomingIndicators - } - - // The receiver maintains one timer for each (sender, device) in a chat: - private class IncomingIndicators { - private weak var delegate: TypingIndicators? - private let thread: TSThread - fileprivate let recipientId: String - private let deviceId: UInt - private var displayTypingTimer: Timer? - fileprivate var startedTypingTimestamp: UInt64? - - var isTyping = false { - didSet { - let didChange = oldValue != isTyping - if didChange { - notifyIfNecessary() + + refreshTimer?.invalidate() + refreshTimer = Timer.scheduledTimerOnMainThread( + withTimeInterval: 10, + repeats: false + ) { [weak self] _ in + GRDBStorage.shared.write { db in + self?.scheduleRefreshCallback(db) } } } - - init(delegate: TypingIndicators, thread: TSThread, - recipientId: String, deviceId: UInt) { - self.delegate = delegate - self.thread = thread - self.recipientId = recipientId - self.deviceId = deviceId + } + + // MARK: - Variables + + public static let shared: TypingIndicators = TypingIndicators() + + private static var outgoing: Atomic<[String: Indicator]> = Atomic([:]) + private static var incoming: Atomic<[String: Indicator]> = Atomic([:]) + + // MARK: - Functions + + public static func didStartTyping(_ db: Database, in thread: SessionThread, direction: Direction, timestampMs: Int64?) { + switch direction { + case .outgoing: + let updatedIndicator: Indicator? = ( + outgoing.wrappedValue[thread.id] ?? + Indicator(thread: thread, direction: direction, timestampMs: timestampMs) + )?.starting(db) + + outgoing.mutate { $0[thread.id] = updatedIndicator } + + case .incoming: + let updatedIndicator: Indicator? = ( + incoming.wrappedValue[thread.id] ?? + Indicator(thread: thread, direction: direction, timestampMs: timestampMs) + )?.starting(db) + + incoming.mutate { $0[thread.id] = updatedIndicator } } - - func didReceiveTypingStartedMessage() { - displayTypingTimer?.invalidate() - displayTypingTimer = Timer.weakScheduledTimer(withTimeInterval: 5, - target: self, - selector: #selector(IncomingIndicators.displayTypingTimerDidFire), - userInfo: nil, - repeats: false) - if !isTyping { - startedTypingTimestamp = NSDate.ows_millisecondTimeStamp() - } - isTyping = true - } - - func didReceiveTypingStoppedMessage() { - clearTyping() - } - - @objc - func displayTypingTimerDidFire() { - clearTyping() - } - - func didReceiveIncomingMessage() { - clearTyping() - } - - private func clearTyping() { - displayTypingTimer?.invalidate() - displayTypingTimer = nil - startedTypingTimestamp = nil - isTyping = false - } - - private func notifyIfNecessary() { - guard let delegate = delegate else { - return - } - // `areTypingIndicatorsEnabled` reflects the user-facing setting in the app preferences. - // If it's disabled we don't want to emit "typing indicator" messages - // or show typing indicators for other users. - guard delegate.areTypingIndicatorsEnabled() else { - return - } - guard let threadId = thread.uniqueId else { - return - } - NotificationCenter.default.postNotificationNameAsync(TypingIndicatorsImpl.typingIndicatorStateDidChange, object: threadId) + } + + public static func didStopTyping(_ db: Database, in thread: SessionThread, direction: Direction) { + switch direction { + case .outgoing: + let updatedIndicator: Indicator? = outgoing.wrappedValue[thread.id]?.stoping(db) + + outgoing.mutate { $0[thread.id] = updatedIndicator } + + case .incoming: + let updatedIndicator: Indicator? = incoming.wrappedValue[thread.id]?.stoping(db) + + incoming.mutate { $0[thread.id] = updatedIndicator } } } } diff --git a/SessionMessagingKit/Utilities/OWSPreferences.m b/SessionMessagingKit/Utilities/OWSPreferences.m index 29def0003..c999bed4f 100644 --- a/SessionMessagingKit/Utilities/OWSPreferences.m +++ b/SessionMessagingKit/Utilities/OWSPreferences.m @@ -6,18 +6,6 @@ NS_ASSUME_NONNULL_BEGIN -NSString *NSStringForNotificationType(NotificationType value) -{ - switch (value) { - case NotificationNamePreview: - return @"NotificationNamePreview"; - case NotificationNameNoPreview: - return @"NotificationNameNoPreview"; - case NotificationNoNameNoPreview: - return @"NotificationNoNameNoPreview"; - } -} - NSString *const OWSPreferencesSignalDatabaseCollection = @"SignalPreferences"; NSString *const OWSPreferencesCallLoggingDidChangeNotification = @"OWSPreferencesCallLoggingDidChangeNotification"; NSString *const OWSPreferencesKeyScreenSecurity = @"Screen Security Key"; diff --git a/SessionMessagingKit/Utilities/Preferences.swift b/SessionMessagingKit/Utilities/Preferences.swift index 09342dd6d..197357b6b 100644 --- a/SessionMessagingKit/Utilities/Preferences.swift +++ b/SessionMessagingKit/Utilities/Preferences.swift @@ -43,7 +43,7 @@ public extension Setting.StringKey { } public enum Preferences { - public enum NotificationPreviewType: Int, EnumSetting { + public enum NotificationPreviewType: Int, CaseIterable, EnumSetting { /// Notifications should include both the sender name and a preview of the message content case nameAndPreview @@ -60,10 +60,6 @@ public enum Preferences { case .noNameNoPreview: return "NOTIFICATIONS_NONE".localized() } } - - var accessibilityIdentifier: String { - return "NotificationSettingsOptionsViewController.\(name)" - } } public enum Sound: Int, Codable, DatabaseValueConvertible, EnumSetting { @@ -265,7 +261,45 @@ public enum Preferences { // MARK: - Objective C Support -// FIXME: Remove this once the 'NotificationSettingsViewController' and 'OWSSoundSettingsViewController' have been refactored to Swift +// FIXME: Remove the below the 'NotificationSettingsViewController' and 'OWSSoundSettingsViewController' have been refactored to Swift + +@objc(SMKPreferences) +public class SMKPreferences: NSObject { + @objc public static let notificationTypes: [Int] = Preferences.NotificationPreviewType + .allCases + .map { $0.rawValue } + + @objc public static func nameForNotificationPreviewType(_ previewType: Int) -> String { + return Preferences.NotificationPreviewType(rawValue: previewType) + .defaulting(to: .nameAndPreview) + .name + } + + @objc public static func notificationPreviewType() -> Int { + return GRDBStorage.shared[.preferencesNotificationPreviewType] + .defaulting(to: Preferences.NotificationPreviewType.nameAndPreview) + .rawValue + } + + @objc public static func setNotificationPreviewType(_ previewType: Int) { + GRDBStorage.shared.write { db in + db[.preferencesNotificationPreviewType] = Preferences.NotificationPreviewType(rawValue: previewType) + .defaulting(to: .nameAndPreview) + } + } + + @objc public static func accessibilityIdentifierForNotificationPreviewType(_ previewType: Int) -> String { + let notificationPreviewType: Preferences.NotificationPreviewType = Preferences.NotificationPreviewType(rawValue: previewType) + .defaulting(to: .nameAndPreview) + + switch notificationPreviewType { + case .nameAndPreview: return "NotificationNamePreview" + case .nameNoPreview: return "NotificationNameNoPreview" + case .noNameNoPreview: return "NotificationNoNameNoPreview" + } + } +} + @objc(SMKSound) public class SMKSound: NSObject { @objc public static var notificationSounds: [Int] = Preferences.Sound.notificationSounds.map { $0.rawValue } @@ -285,7 +319,7 @@ public class SMKSound: NSObject { } @objc public static var defaultNotificationSound: Int { - GRDBStorage.shared[.defaultNotificationSound] + return GRDBStorage.shared[.defaultNotificationSound] .defaulting(to: Preferences.Sound.defaultNotificationSound) .rawValue } diff --git a/SessionNotificationServiceExtension/NSENotificationPresenter.swift b/SessionNotificationServiceExtension/NSENotificationPresenter.swift index 88c964f9d..909ba9d03 100644 --- a/SessionNotificationServiceExtension/NSENotificationPresenter.swift +++ b/SessionNotificationServiceExtension/NSENotificationPresenter.swift @@ -85,18 +85,21 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { CurrentAppContext().appUserDefaults().set(newBadgeNumber, forKey: "currentBadgeNumber") // Title & body - let notificationsPreference = Environment.shared.preferences!.notificationPreviewType() - switch notificationsPreference { - case .namePreview: - notificationContent.title = notificationTitle - notificationContent.body = snippet - case .nameNoPreview: - notificationContent.title = notificationTitle - notificationContent.body = NotificationStrings.incomingMessageBody - case .noNameNoPreview: - notificationContent.title = "Session" - notificationContent.body = NotificationStrings.incomingMessageBody - default: break + let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType] + .defaulting(to: .nameAndPreview) + + switch previewType { + case .nameAndPreview: + notificationContent.title = notificationTitle + notificationContent.body = snippet + + case .nameNoPreview: + notificationContent.title = notificationTitle + notificationContent.body = NotificationStrings.incomingMessageBody + + case .noNameNoPreview: + notificationContent.title = "Session" + notificationContent.body = NotificationStrings.incomingMessageBody } // If it's a message request then overwrite the body to be something generic (only show a notification diff --git a/SessionUtilitiesKit/Database/Models/Setting.swift b/SessionUtilitiesKit/Database/Models/Setting.swift index 01029f2a3..ca9b672cd 100644 --- a/SessionUtilitiesKit/Database/Models/Setting.swift +++ b/SessionUtilitiesKit/Database/Models/Setting.swift @@ -167,7 +167,7 @@ public extension Database { return T(rawValue: rawValue) } - set { self[key.rawValue] = Setting(key: key.rawValue, value: newValue) } + set { self[key.rawValue] = Setting(key: key.rawValue, value: newValue?.rawValue) } } /// Value will be stored as a timestamp in seconds since 1970 diff --git a/SessionUtilitiesKit/Database/Utilities/PersistableRecord+Utilities.swift b/SessionUtilitiesKit/Database/Utilities/PersistableRecord+Utilities.swift deleted file mode 100644 index 3da9eba81..000000000 --- a/SessionUtilitiesKit/Database/Utilities/PersistableRecord+Utilities.swift +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import GRDB - -public extension Array where Element: PersistableRecord { - @discardableResult func deleteAll(_ db: Database) throws -> Bool { - return try self.reduce(true) { prev, next in - try (prev && next.delete(db)) - } - } - - func saveAll(_ db: Database) throws { - try forEach { try $0.save(db) } - } -} diff --git a/SignalUtilitiesKit/Messaging/BlockListUIUtils.swift b/SignalUtilitiesKit/Messaging/BlockListUIUtils.swift index 7f157918a..5a432bb24 100644 --- a/SignalUtilitiesKit/Messaging/BlockListUIUtils.swift +++ b/SignalUtilitiesKit/Messaging/BlockListUIUtils.swift @@ -34,7 +34,7 @@ import SessionMessagingKit handler: { _ in GRDBStorage.shared.writeAsync( updates: { db in - try? Contact + try Contact .fetchOrCreate(db, id: threadId) .with(isBlocked: true) .save(db) @@ -85,7 +85,7 @@ import SessionMessagingKit handler: { _ in GRDBStorage.shared.writeAsync( updates: { db in - try? Contact + try Contact .fetchOrCreate(db, id: threadId) .with(isBlocked: false) .save(db) diff --git a/SignalUtilitiesKit/Messaging/OWSMessageUtils.h b/SignalUtilitiesKit/Messaging/OWSMessageUtils.h deleted file mode 100644 index ef693b8dc..000000000 --- a/SignalUtilitiesKit/Messaging/OWSMessageUtils.h +++ /dev/null @@ -1,25 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@class TSMessage; -@class TSThread; -@class YapDatabaseReadTransaction; - -@interface OWSMessageUtils : NSObject - -- (instancetype)init NS_UNAVAILABLE; -+ (instancetype)sharedManager; - -- (NSUInteger)unreadMessagesCount; -- (NSUInteger)unreadMessagesCountExcept:(TSThread *)thread; - -- (void)updateApplicationBadgeCount; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Messaging/OWSMessageUtils.m b/SignalUtilitiesKit/Messaging/OWSMessageUtils.m deleted file mode 100644 index 543971dbc..000000000 --- a/SignalUtilitiesKit/Messaging/OWSMessageUtils.m +++ /dev/null @@ -1,120 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSMessageUtils.h" -#import "AppContext.h" -#import "MIMETypeUtil.h" - -#import "OWSPrimaryStorage.h" -#import "TSAccountManager.h" -#import "TSAttachment.h" -#import "TSAttachmentStream.h" -#import "TSDatabaseView.h" -#import "TSIncomingMessage.h" -#import "TSMessage.h" -#import "TSOutgoingMessage.h" -#import "TSQuotedMessage.h" -#import "TSThread.h" -#import "UIImage+OWS.h" -#import -#import "SSKAsserts.h" -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface OWSMessageUtils () - -@property (nonatomic, readonly) YapDatabaseConnection *dbConnection; - -@end - -#pragma mark - - -@implementation OWSMessageUtils - -+ (instancetype)sharedManager -{ - static OWSMessageUtils *sharedMyManager = nil; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - sharedMyManager = [[self alloc] initDefault]; - }); - return sharedMyManager; -} - -- (instancetype)initDefault -{ - OWSPrimaryStorage *primaryStorage = [OWSPrimaryStorage sharedManager]; - - return [self initWithPrimaryStorage:primaryStorage]; -} - -- (instancetype)initWithPrimaryStorage:(OWSPrimaryStorage *)primaryStorage -{ - self = [super init]; - - if (!self) { - return self; - } - - _dbConnection = primaryStorage.newDatabaseConnection; - - OWSSingletonAssert(); - - return self; -} - -- (NSUInteger)unreadMessagesCount -{ - __block NSUInteger count = 0; - - [LKStorage readWithBlock:^(YapDatabaseReadTransaction *transaction) { - YapDatabaseViewTransaction *unreadMessages = [transaction ext:TSUnreadDatabaseViewExtensionName]; - NSArray *allGroups = [unreadMessages allGroups]; - // FIXME: Confusingly, `allGroups` includes contact threads as well - for (NSString *groupID in allGroups) { - TSThread *thread = [TSThread fetchObjectWithUniqueID:groupID transaction:transaction]; - - // Don't increase the count for muted threads or message requests - if (thread.isMuted || thread.isMessageRequest) { continue; } - - BOOL isGroupThread = thread.isGroupThread; - - // For groups that only notifiy for mentions - if (isGroupThread && ((TSGroupThread *)thread).isOnlyNotifyingForMentions) { - count += [thread unreadMentionMessageCountWithTransaction:transaction]; - } else { - count += [thread unreadMessageCountWithTransaction:transaction]; - } - } - }]; - - return count; -} - -- (NSUInteger)unreadMessagesCountExcept:(TSThread *)thread -{ - __block NSUInteger numberOfItems; - [self.dbConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - id databaseView = [transaction ext:TSUnreadDatabaseViewExtensionName]; - OWSAssertDebug(databaseView); - numberOfItems = ([databaseView numberOfItemsInAllGroups] - [databaseView numberOfItemsInGroup:thread.uniqueId]); - }]; - - return numberOfItems; -} - -- (void)updateApplicationBadgeCount -{ - if (!CurrentAppContext().isMainApp) { - return; - } - - NSUInteger numberOfItems = [self unreadMessagesCount]; - [CurrentAppContext() setMainAppBadgeNumber:numberOfItems]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h b/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h index f66c49737..7c9d6a42d 100644 --- a/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h +++ b/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h @@ -11,8 +11,6 @@ FOUNDATION_EXPORT const unsigned char SignalUtilitiesKitVersionString[]; #import #import #import -#import -#import #import #import #import @@ -25,7 +23,6 @@ FOUNDATION_EXPORT const unsigned char SignalUtilitiesKitVersionString[]; #import #import #import -#import #import #import #import diff --git a/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift b/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift index 4979eabca..7381468ad 100644 --- a/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift +++ b/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift @@ -65,22 +65,7 @@ public final class ProfilePictureView: UIView { additionalImageView.layer.cornerRadius = additionalImageViewSize / 2 } - // MARK: - Updating - @objc(updateForContact:) - public func update(for publicKey: String?) { - guard let publicKey: String = publicKey else { return } - - let profile: Profile? = GRDBStorage.shared.read { db in - try? Profile.fetchOne(db, id: publicKey) - } - - update( - publicKey: publicKey, - profile: profile, - threadVariant: .contact - ) - } - + // FIXME: Look to deprecate this and replace it with the pattern in HomeViewModel (screen should fetch only the required info) @objc(updateForThreadId:) public func update(forThreadId threadId: String?) { guard diff --git a/SignalUtilitiesKit/To Do/ContactCellView.h b/SignalUtilitiesKit/To Do/ContactCellView.h deleted file mode 100644 index bf1c77254..000000000 --- a/SignalUtilitiesKit/To Do/ContactCellView.h +++ /dev/null @@ -1,31 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -NS_ASSUME_NONNULL_BEGIN - -extern const CGFloat kContactCellAvatarTextMargin; - -@class TSThread; - -@interface ContactCellView : UIStackView - -@property (nonatomic, nullable) NSString *accessoryMessage; - -- (void)configureWithRecipientId:(NSString *)recipientId; - -- (void)configureWithThread:(TSThread *)thread; - -- (void)prepareForReuse; - -- (NSAttributedString *)verifiedSubtitle; - -- (void)setAttributedSubtitle:(nullable NSAttributedString *)attributedSubtitle; - -- (BOOL)hasAccessoryText; - -- (void)setAccessoryView:(UIView *)accessoryView; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/To Do/ContactCellView.m b/SignalUtilitiesKit/To Do/ContactCellView.m deleted file mode 100644 index 5f6140bdf..000000000 --- a/SignalUtilitiesKit/To Do/ContactCellView.m +++ /dev/null @@ -1,295 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "ContactCellView.h" -#import "UIFont+OWS.h" -#import "UIView+OWS.h" -#import -#import -#import -#import -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -const CGFloat kContactCellAvatarTextMargin = 12; - -@interface ContactCellView () - -@property (nonatomic) UILabel *nameLabel; -@property (nonatomic) UILabel *profileNameLabel; -@property (nonatomic) LKProfilePictureView *profilePictureView; -@property (nonatomic) UILabel *subtitleLabel; -@property (nonatomic) UILabel *accessoryLabel; -@property (nonatomic) UIStackView *nameContainerView; -@property (nonatomic) UIView *accessoryViewContainer; - -@property (nonatomic, nullable) TSThread *thread; -@property (nonatomic) NSString *recipientId; - -@end - -#pragma mark - - -@implementation ContactCellView - -- (instancetype)init -{ - if (self = [super init]) { - [self configure]; - } - return self; -} - -#pragma mark - Dependencies - -- (OWSPrimaryStorage *)primaryStorage -{ - OWSAssertDebug(SSKEnvironment.shared.primaryStorage); - - return SSKEnvironment.shared.primaryStorage; -} - -- (TSAccountManager *)tsAccountManager -{ - OWSAssertDebug(SSKEnvironment.shared.tsAccountManager); - - return SSKEnvironment.shared.tsAccountManager; -} - -#pragma mark - - -- (void)configure -{ - OWSAssertDebug(!self.nameLabel); - - self.layoutMargins = UIEdgeInsetsZero; - - _profilePictureView = [LKProfilePictureView new]; - CGFloat profilePictureSize = LKValues.mediumProfilePictureSize; - [self.profilePictureView autoSetDimension:ALDimensionWidth toSize:profilePictureSize]; - [self.profilePictureView autoSetDimension:ALDimensionHeight toSize:profilePictureSize]; - self.profilePictureView.size = profilePictureSize; - - self.nameLabel = [UILabel new]; - self.nameLabel.lineBreakMode = NSLineBreakByTruncatingTail; - - self.profileNameLabel = [UILabel new]; - self.profileNameLabel.lineBreakMode = NSLineBreakByTruncatingTail; - - self.subtitleLabel = [UILabel new]; - - self.accessoryLabel = [[UILabel alloc] init]; - self.accessoryLabel.textAlignment = NSTextAlignmentRight; - - self.accessoryViewContainer = [UIView containerView]; - - self.nameContainerView = [[UIStackView alloc] initWithArrangedSubviews:@[ - self.nameLabel, - self.profileNameLabel, - self.subtitleLabel, - ]]; - self.nameContainerView.axis = UILayoutConstraintAxisVertical; - - [self.nameContainerView setContentHuggingHorizontalLow]; - [self.accessoryViewContainer setContentHuggingHorizontalHigh]; - - self.axis = UILayoutConstraintAxisHorizontal; - self.spacing = LKValues.mediumSpacing; - self.alignment = UIStackViewAlignmentCenter; - [self addArrangedSubview:self.profilePictureView]; - [self addArrangedSubview:self.nameContainerView]; - [self addArrangedSubview:self.accessoryViewContainer]; - - [self configureFontsAndColors]; -} - -- (void)configureFontsAndColors -{ - self.nameLabel.font = [UIFont boldSystemFontOfSize:15]; - self.profileNameLabel.font = [UIFont ows_regularFontWithSize:11.f]; - self.subtitleLabel.font = [UIFont ows_regularFontWithSize:11.f]; - self.accessoryLabel.font = [UIFont ows_mediumFontWithSize:13.f]; - - self.nameLabel.textColor = LKColors.text; - self.profileNameLabel.textColor = LKColors.separator; - self.subtitleLabel.textColor = LKColors.separator; - self.accessoryLabel.textColor = [UIColor colorWithWhite:0.5f alpha:1.f]; -} - -- (void)configureWithRecipientId:(NSString *)recipientId -{ - OWSAssertDebug(recipientId.length > 0); - - // Update fonts to reflect changes to dynamic type. - [self configureFontsAndColors]; - - self.recipientId = recipientId; - - [self.primaryStorage.dbReadConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - self.thread = [TSContactThread getThreadWithContactSessionID:recipientId transaction:transaction]; - }]; - - BOOL isNoteToSelf = (IsNoteToSelfEnabled() && [recipientId isEqualToString:self.tsAccountManager.localNumber]); - if (isNoteToSelf) { - self.nameLabel.attributedText = [[NSAttributedString alloc] - initWithString:NSLocalizedString(@"NOTE_TO_SELF", @"Label for 1:1 conversation with yourself.") - attributes:@{ - NSFontAttributeName : self.nameLabel.font, - }]; - } else { - self.nameLabel.text = [SMKProfile displayNameWithId:recipientId thread:self.thread]; - } - - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(otherUsersProfileDidChange:) - name:NSNotification.otherUsersProfileDidChange - object:nil]; - [self updateProfileName]; - [self updateAvatar]; - - if (self.accessoryMessage) { - self.accessoryLabel.text = self.accessoryMessage; - [self setAccessoryView:self.accessoryLabel]; - } - - // Force layout, since imageView isn't being initally rendered on App Store optimized build. - [self layoutSubviews]; -} - -- (void)configureWithThread:(TSThread *)thread -{ - OWSAssertDebug(thread); - self.thread = thread; - - // Update fonts to reflect changes to dynamic type. - [self configureFontsAndColors]; - - NSString *threadName = thread.name; - if (threadName.length == 0 && [thread isKindOfClass:[TSGroupThread class]]) { - threadName = [MessageStrings newGroupDefaultTitle]; - } - - BOOL isNoteToSelf - = ([thread isKindOfClass:TSContactThread.class] && [((TSContactThread *)thread).contactSessionID isEqualToString:self.tsAccountManager.localNumber]); - if (isNoteToSelf) { - threadName = NSLocalizedString(@"NOTE_TO_SELF", @"Label for 1:1 conversation with yourself."); - } - - if ([thread isKindOfClass:[TSContactThread class]]) { - self.recipientId = ((TSContactThread *)thread).contactSessionID; - - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(otherUsersProfileDidChange:) - name:NSNotification.otherUsersProfileDidChange - object:nil]; - [self updateProfileName]; - } else { - self.nameLabel.text = thread.name; - [self.nameLabel setNeedsLayout]; - } - - [self updateAvatar]; - - if (self.accessoryMessage) { - self.accessoryLabel.text = self.accessoryMessage; - [self setAccessoryView:self.accessoryLabel]; - } - - // Force layout, since imageView isn't being initally rendered on App Store optimized build. - [self layoutSubviews]; -} - -- (void)updateAvatar -{ - [LKStorage readWithBlock:^(YapDatabaseReadTransaction *transaction) { - [LKMentionsManager populateUserPublicKeyCacheIfNeededFor:self.thread.uniqueId in:transaction]; // FIXME: This is a terrible place to do this - }]; - if (self.thread != nil) { - [self.profilePictureView updateForThread:self.thread]; - } else { - [self.profilePictureView updateForContact:self.recipientId]; - } -} - -- (void)updateProfileName -{ - self.nameLabel.text = [SMKProfile displayNameWithId:self.recipientId thread:self.thread]; - [self.nameLabel setNeedsLayout]; -} - -- (void)prepareForReuse -{ - [[NSNotificationCenter defaultCenter] removeObserver:self]; - - self.thread = nil; - self.accessoryMessage = nil; - self.nameLabel.text = nil; - self.subtitleLabel.text = nil; - self.profileNameLabel.text = nil; - self.accessoryLabel.text = nil; - for (UIView *subview in self.accessoryViewContainer.subviews) { - [subview removeFromSuperview]; - } -} - -- (void)otherUsersProfileDidChange:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - - NSString *recipientId = notification.userInfo[NSNotification.profileRecipientIdKey]; - OWSAssertDebug(recipientId.length > 0); - - if (recipientId.length > 0 && [self.recipientId isEqualToString:recipientId]) { - [self updateProfileName]; - [self updateAvatar]; - } -} - -- (NSAttributedString *)verifiedSubtitle -{ - NSMutableAttributedString *text = [NSMutableAttributedString new]; - // "checkmark" - [text appendAttributedString:[[NSAttributedString alloc] - initWithString:@"\uf00c " - attributes:@{ - NSFontAttributeName : - [UIFont ows_fontAwesomeFont:self.subtitleLabel.font.pointSize], - }]]; - [text appendAttributedString:[[NSAttributedString alloc] - initWithString:NSLocalizedString(@"PRIVACY_IDENTITY_IS_VERIFIED_BADGE", - @"Badge indicating that the user is verified.")]]; - return [text copy]; -} - -- (void)setAttributedSubtitle:(nullable NSAttributedString *)attributedSubtitle -{ - self.subtitleLabel.attributedText = attributedSubtitle; -} - -- (BOOL)hasAccessoryText -{ - return self.accessoryMessage.length > 0; -} - -- (void)setAccessoryView:(UIView *)accessoryView -{ - OWSAssertDebug(accessoryView); - OWSAssertDebug(self.accessoryViewContainer); - OWSAssertDebug(self.accessoryViewContainer.subviews.count < 1); - - [self.accessoryViewContainer addSubview:accessoryView]; - - // Trailing-align the accessory view. - [accessoryView autoPinEdgeToSuperviewMargin:ALEdgeTop]; - [accessoryView autoPinEdgeToSuperviewMargin:ALEdgeBottom]; - [accessoryView autoPinEdgeToSuperviewMargin:ALEdgeTrailing]; - [accessoryView autoPinEdgeToSuperviewMargin:ALEdgeLeading relation:NSLayoutRelationGreaterThanOrEqual]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/To Do/ContactTableViewCell.h b/SignalUtilitiesKit/To Do/ContactTableViewCell.h deleted file mode 100644 index 5183609a0..000000000 --- a/SignalUtilitiesKit/To Do/ContactTableViewCell.h +++ /dev/null @@ -1,31 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -NS_ASSUME_NONNULL_BEGIN - -@class TSThread; - -@interface ContactTableViewCell : UITableViewCell - -+ (NSString *)reuseIdentifier; - -- (void)configureWithRecipientId:(NSString *)recipientId; - -- (void)configureWithThread:(TSThread *)thread; - -// This method should be called _before_ the configure... methods. -- (void)setAccessoryMessage:(nullable NSString *)accessoryMessage; - -// This method should be called _after_ the configure... methods. -- (void)setAttributedSubtitle:(nullable NSAttributedString *)attributedSubtitle; - -- (NSAttributedString *)verifiedSubtitle; - -- (BOOL)hasAccessoryText; - -- (void)ows_setAccessoryView:(UIView *)accessoryView; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/To Do/ContactTableViewCell.m b/SignalUtilitiesKit/To Do/ContactTableViewCell.m deleted file mode 100644 index bd35c076c..000000000 --- a/SignalUtilitiesKit/To Do/ContactTableViewCell.m +++ /dev/null @@ -1,116 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "ContactTableViewCell.h" -#import "ContactCellView.h" -#import "OWSTableViewController.h" -#import "UIColor+OWS.h" -#import "UIFont+OWS.h" -#import "UIView+OWS.h" -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface ContactTableViewCell () - -@property (nonatomic) ContactCellView *cellView; - -@end - -#pragma mark - - -@implementation ContactTableViewCell - -- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(nullable NSString *)reuseIdentifier -{ - if (self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]) { - [self configure]; - } - return self; -} - -+ (NSString *)reuseIdentifier -{ - return NSStringFromClass(self.class); -} - -- (void)setAccessoryView:(nullable UIView *)accessoryView -{ - OWSFailDebug(@"use ows_setAccessoryView instead."); -} - -- (void)configure -{ - OWSAssertDebug(!self.cellView); - - self.preservesSuperviewLayoutMargins = YES; - self.contentView.preservesSuperviewLayoutMargins = YES; - - self.cellView = [ContactCellView new]; - [self.contentView addSubview:self.cellView]; - [self.cellView autoPinEdgesToSuperviewMargins]; - self.cellView.userInteractionEnabled = NO; -} - -- (void)configureWithRecipientId:(NSString *)recipientId -{ - [OWSTableItem configureCell:self]; - - [self.cellView configureWithRecipientId:recipientId]; - - // Force layout, since imageView isn't being initally rendered on App Store optimized build. - [self layoutSubviews]; -} - -- (void)configureWithThread:(TSThread *)thread -{ - OWSAssertDebug(thread); - - [OWSTableItem configureCell:self]; - - [self.cellView configureWithThread:thread]; - - // Force layout, since imageView isn't being initally rendered on App Store optimized build. - [self layoutSubviews]; -} - -- (void)setAccessoryMessage:(nullable NSString *)accessoryMessage -{ - OWSAssertDebug(self.cellView); - - self.cellView.accessoryMessage = accessoryMessage; -} - -- (NSAttributedString *)verifiedSubtitle -{ - return self.cellView.verifiedSubtitle; -} - -- (void)setAttributedSubtitle:(nullable NSAttributedString *)attributedSubtitle -{ - [self.cellView setAttributedSubtitle:attributedSubtitle]; -} - -- (void)prepareForReuse -{ - [super prepareForReuse]; - - [self.cellView prepareForReuse]; - - self.accessoryType = UITableViewCellAccessoryNone; -} - -- (BOOL)hasAccessoryText -{ - return [self.cellView hasAccessoryText]; -} - -- (void)ows_setAccessoryView:(UIView *)accessoryView -{ - return [self.cellView setAccessoryView:accessoryView]; -} - -@end - -NS_ASSUME_NONNULL_END From 5432f5582e2d602851f0c9ec73ac1d8522713095 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 11 May 2022 18:20:10 +1000 Subject: [PATCH 075/157] Fixed a number of minor bugs, started re-connecting chat bubble interactions Fixed an issue where if you left a closed group on one device you wouldn't leave it on another Renamed a few types to clean up the namespacing and code jumping Fixed a stack overflow issue which could occur in the JobRunner Fixed an issue where the DeletedMessageView could randomly have the wrong height Fixed an issue where you could interact with the hidden reply button on a cell Fixed an issue where tapping anywhere horizontally would trigger the cell tap (need to tap within the bubble) Disabled the ability to select text in messages (only works sometimes and is buggy) --- .../ConversationVC+Interaction.swift | 399 ++++++++++-------- Session/Conversations/ConversationVC.swift | 26 +- .../Input View/MentionSelectionView.swift | 120 ++++-- .../Content Views/DeletedMessageView.swift | 48 ++- .../Content Views/DocumentView.swift | 29 +- .../Content Views/LinkPreviewState.swift | 77 ++-- .../Content Views/LinkPreviewView.swift | 168 +++++--- .../Content Views/QuoteView.swift | 266 ++++++------ .../Content Views/VoiceMessageView.swift | 159 ++++--- .../Message Cells/InfoMessageCell.swift | 80 ++-- .../Message Cells/MessageCell.swift | 58 +-- .../Message Cells/TypingIndicatorCell.swift | 81 ++-- .../Views & Modals/BodyTextView.swift | 13 +- .../Views & Modals/JoinOpenGroupModal.swift | 15 +- .../Views & Modals/URLModal.swift | 21 +- .../LegacyDatabase/SMKLegacyModels.swift | 188 ++++----- .../Migrations/_003_YDBToGRDBMigration.swift | 114 ++--- .../VisibleMessage+Attachment.swift | 117 ++--- .../VisibleMessage+LinkPreview.swift | 23 +- .../VisibleMessage+OpenGroupInvitation.swift | 19 +- .../VisibleMessage+Profile.swift | 12 +- .../VisibleMessage+Quote.swift | 35 +- .../Visible Messages/VisibleMessage.swift | 44 +- .../Errors/AttachmentError.swift | 3 + .../MessageReceiver+Handling.swift | 12 +- .../MessageSender+ClosedGroups.swift | 9 +- .../Database/GRDBStorage.swift | 4 + SessionUtilitiesKit/JobRunner/JobRunner.swift | 4 +- 28 files changed, 1194 insertions(+), 950 deletions(-) create mode 100644 SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 6433fdb1e..36a28d0e2 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -124,16 +124,6 @@ extension ConversationVC: snInputView.text = newMessageText ?? "" } - func handleCameraButtonTapped() { - guard requestCameraPermissionIfNeeded() else { return } - requestMicrophonePermissionIfNeeded { } - if AVAudioSession.sharedInstance().recordPermission != .granted { - SNLog("Proceeding without microphone access. Any recorded video will be silent.") - } - let sendMediaNavController = SendMediaNavigationController.showingCameraFirst() - sendMediaNavController.sendMediaNavDelegate = self - sendMediaNavController.modalPresentationStyle = .fullScreen - present(sendMediaNavController, animated: true, completion: nil) // MARK: - ExpandingAttachmentsButtonDelegate func handleGIFButtonTapped() { @@ -168,13 +158,17 @@ extension ConversationVC: func handleCameraButtonTapped() { guard requestCameraPermissionIfNeeded() else { return } + requestMicrophonePermissionIfNeeded { } + if AVAudioSession.sharedInstance().recordPermission != .granted { SNLog("Proceeding without microphone access. Any recorded video will be silent.") } + let sendMediaNavController = SendMediaNavigationController.showingCameraFirst() sendMediaNavController.sendMediaNavDelegate = self sendMediaNavController.modalPresentationStyle = .fullScreen + present(sendMediaNavController, animated: true, completion: nil) } @@ -184,15 +178,6 @@ extension ConversationVC: showAttachmentApprovalDialog(for: [ attachment ]) } - func handleDocumentButtonTapped() { - // UIDocumentPickerModeImport copies to a temp file within our container. - // It uses more memory than "open" but lets us avoid working with security scoped URLs. - let documentPickerVC = UIDocumentPickerViewController(documentTypes: [ kUTTypeItem as String ], in: UIDocumentPickerMode.import) - documentPickerVC.delegate = self - documentPickerVC.modalPresentationStyle = .fullScreen - SNAppearance.switchToDocumentPickerAppearance() - present(documentPickerVC, animated: true, completion: nil) - } // MARK: - UIDocumentPickerDelegate func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { @@ -202,37 +187,47 @@ extension ConversationVC: func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { SNAppearance.switchToSessionAppearance() guard let url = urls.first else { return } // TODO: Handle multiple? + let urlResourceValues: URLResourceValues do { urlResourceValues = try url.resourceValues(forKeys: [ .typeIdentifierKey, .isDirectoryKey, .nameKey ]) - } catch { - let alert = UIAlertController(title: "Session", message: "An error occurred.", preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) - return present(alert, animated: true, completion: nil) } - let type = urlResourceValues.typeIdentifier ?? (kUTTypeData as String) - guard urlResourceValues.isDirectory != true else { - DispatchQueue.main.async { - let title = NSLocalizedString("ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_TITLE", comment: "") - let message = NSLocalizedString("ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_BODY", comment: "") - OWSAlerts.showAlert(title: title, message: message) + catch { + DispatchQueue.main.async { [weak self] in + let alert = UIAlertController(title: "Session", message: "An error occurred.", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) + + self?.present(alert, animated: true, completion: nil) } return } + + let type = urlResourceValues.typeIdentifier ?? (kUTTypeData as String) + guard urlResourceValues.isDirectory != true else { + DispatchQueue.main.async { + OWSAlerts.showAlert( + title: "ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_TITLE".localized(), + message: "ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_BODY".localized() + ) + } + return + } + let fileName = urlResourceValues.name ?? NSLocalizedString("ATTACHMENT_DEFAULT_FILENAME", comment: "") guard let dataSource = DataSourcePath.dataSource(with: url, shouldDeleteOnDeallocation: false) else { DispatchQueue.main.async { - let title = NSLocalizedString("ATTACHMENT_PICKER_DOCUMENTS_FAILED_ALERT_TITLE", comment: "") - OWSAlerts.showAlert(title: title) + OWSAlerts.showAlert(title: "ATTACHMENT_PICKER_DOCUMENTS_FAILED_ALERT_TITLE".localized()) } return } dataSource.sourceFilename = fileName + // Although we want to be able to send higher quality attachments through the document picker // it's more imporant that we ensure the sent format is one all clients can accept (e.g. *not* quicktime .mov) guard !SignalAttachment.isInvalidVideo(dataSource: dataSource, dataUTI: type) else { return showAttachmentApprovalDialogAfterProcessingVideo(at: url, with: fileName) } + // "Document picker" attachments _SHOULD NOT_ be resized let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: type, imageQuality: .original) showAttachmentApprovalDialog(for: [ attachment ]) @@ -247,23 +242,36 @@ extension ConversationVC: ModalActivityIndicatorViewController.present(fromViewController: self, canCancel: true, message: nil) { [weak self] modalActivityIndicator in let dataSource = DataSourcePath.dataSource(with: url, shouldDeleteOnDeallocation: false)! dataSource.sourceFilename = fileName - let compressionResult: SignalAttachment.VideoCompressionResult = SignalAttachment.compressVideoAsMp4(dataSource: dataSource, dataUTI: kUTTypeMPEG4 as String) - compressionResult.attachmentPromise.done { attachment in - guard !modalActivityIndicator.wasCancelled, let attachment = attachment as? SignalAttachment else { return } - modalActivityIndicator.dismiss { - if !attachment.hasError { + + SignalAttachment + .compressVideoAsMp4( + dataSource: dataSource, + dataUTI: kUTTypeMPEG4 as String + ) + .attachmentPromise + .done { attachment in + guard + !modalActivityIndicator.wasCancelled, + let attachment = attachment as? SignalAttachment + else { return } + + modalActivityIndicator.dismiss { + guard !attachment.hasError else { + self?.showErrorAlert(for: attachment, onDismiss: nil) + return + } + self?.showAttachmentApprovalDialog(for: [ attachment ]) - } else { - self?.showErrorAlert(for: attachment, onDismiss: nil) } } - }.retainUntilComplete() + .retainUntilComplete() } } // MARK: - InputViewDelegate // MARK: --Message Sending + func handleSendButtonTapped() { sendMessage() } @@ -275,9 +283,7 @@ extension ConversationVC: guard !text.isEmpty else { return } - let isNoteToSelf: Bool = GRDBStorage.shared.read { db in viewModel.viewData.thread.isNoteToSelf(db) } - .defaulting(to: false) - if text.contains(mnemonic) && !isNoteToSelf && !hasPermissionToSendSeed { + if text.contains(mnemonic) && !viewModel.viewData.threadIsNoteToSelf && !hasPermissionToSendSeed { // Warn the user if they're about to send their seed to someone let modal = SendSeedModal() modal.modalPresentationStyle = .overFullScreen @@ -310,17 +316,22 @@ extension ConversationVC: .updateAll(db, SessionThread.Columns.shouldBeVisible.set(to: true)) // Create the interaction + let userPublicKey: String = getUserHexEncodedPublicKey(db) let interaction: Interaction = try Interaction( threadId: thread.id, authorId: getUserHexEncodedPublicKey(db), variant: .standardOutgoing, body: text, timestampMs: sentTimestampMs, + hasMention: text.contains("@\(userPublicKey)"), linkPreviewUrl: linkPreviewDraft?.urlString ).inserted(db) - // If there is a LinkPreview add it now - if let linkPreviewDraft: OWSLinkPreviewDraft = linkPreviewDraft { + // If there is a LinkPreview and it doesn't match an existing one then add it now + if + let linkPreviewDraft: OWSLinkPreviewDraft = linkPreviewDraft, + (try? interaction.linkPreview.isEmpty(db)) == true + { var attachmentId: String? // If the LinkPreview has image data then create an attachment first @@ -360,12 +371,8 @@ extension ConversationVC: ) }, completion: { [weak self] _, _ in - // At this point the Interaction should have its link preview set, so we can - // scroll to the bottom knowing the height of the new message cell - DispatchQueue.main.async { - self?.scrollToBottom(isAnimated: false) - self?.handleMessageSent() - } + self?.viewModel.sentMessageBeforeUpdate = true + self?.handleMessageSent() } ) } @@ -410,12 +417,14 @@ extension ConversationVC: .updateAll(db, SessionThread.Columns.shouldBeVisible.set(to: true)) // Create the interaction + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) let interaction: Interaction = try Interaction( threadId: thread.id, authorId: getUserHexEncodedPublicKey(db), variant: .standardOutgoing, body: text, - timestampMs: sentTimestampMs + timestampMs: sentTimestampMs, + hasMention: text.contains("@\(currentUserPublicKey)") ).inserted(db) try MessageSender.send( @@ -426,13 +435,11 @@ extension ConversationVC: ) }, completion: { [weak self] _, _ in - // At this point the Interaction should have its link preview set, so we can - // scroll to the bottom knowing the height of the new message cell + self?.viewModel.sentMessageBeforeUpdate = true + self?.handleMessageSent() + + // Attachment successfully sent - dismiss the screen DispatchQueue.main.async { - self?.scrollToBottom(isAnimated: false) - self?.handleMessageSent() - - // Attachment successfully sent - dismiss the screen onComplete?() } } @@ -448,32 +455,26 @@ extension ConversationVC: } func handleMessageSent() { - resetMentions() - self.snInputView.text = "" - self.snInputView.quoteDraftInfo = nil - - // Update the input state if this is a contact thread - if let contactThread: TSContactThread = thread as? TSContactThread { - let contact: Contact? = GRDBStorage.shared.read { db in try Contact.fetchOne(db, id: contactThread.contactSessionID()) } - - // If the contact doesn't exist yet then it's a message request without the first message sent - // so only allow text-based messages - self.snInputView.setEnabledMessageTypes( - (thread.isNoteToSelf() || contact?.didApproveMe == true || thread.isMessageRequest() ? - .all : .textOnly - ), - message: nil - ) + DispatchQueue.main.async { [weak self] in + self?.snInputView.text = "" + self?.snInputView.quoteDraftInfo = nil } - self.markAllAsRead() + resetMentions() + if Environment.shared.preferences.soundInForeground() { let soundID = Preferences.Sound.systemSoundId(for: .messageSent, quiet: true) AudioServicesPlaySystemSound(soundID) } - SSKEnvironment.shared.typingIndicators.didSendOutgoingMessage(inThread: thread) - Storage.write { transaction in - self.thread.setDraft("", transaction: transaction) + + let thread: SessionThread = self.viewModel.viewData.thread + + GRDBStorage.shared.writeAsync { db in + TypingIndicators.didStopTyping(db, in: thread, direction: .outgoing) + + _ = try SessionThread + .filter(id: thread.id) + .updateAll(db, SessionThread.Columns.messageDraft.set(to: "")) } } @@ -490,6 +491,16 @@ extension ConversationVC: let newText: String = (inputTextView.text ?? "") if !newText.isEmpty { + let thread: SessionThread = self.viewModel.viewData.thread + + GRDBStorage.shared.writeAsync { db in + TypingIndicators.didStartTyping( + db, + in: thread, + direction: .outgoing, + timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) + ) + } } updateMentions(for: newText) @@ -499,11 +510,13 @@ extension ConversationVC: func didPasteImageFromPasteboard(_ image: UIImage) { guard let imageData = image.jpegData(compressionQuality: 1.0) else { return } + let dataSource = DataSourceValue.dataSource(with: imageData, utiType: kUTTypeJPEG as String) let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: kUTTypeJPEG as String, imageQuality: .medium) let approvalVC = AttachmentApprovalViewController.wrappedInNavController(attachments: [ attachment ], approvalDelegate: self) approvalVC.modalPresentationStyle = .fullScreen + self.present(approvalVC, animated: true, completion: nil) } @@ -529,33 +542,40 @@ extension ConversationVC: } func updateMentions(for newText: String) { - if !newText.isEmpty { - let lastCharacterIndex = newText.index(before: newText.endIndex) - let lastCharacter = newText[lastCharacterIndex] - - // Check if there is whitespace before the '@' or the '@' is the first character - let isCharacterBeforeLastWhiteSpaceOrStartOfLine: Bool - if newText.count == 1 { - isCharacterBeforeLastWhiteSpaceOrStartOfLine = true // Start of line - } - else { - let characterBeforeLast = newText[newText.index(before: lastCharacterIndex)] - isCharacterBeforeLastWhiteSpaceOrStartOfLine = characterBeforeLast.isWhitespace - } - - if lastCharacter == "@" && isCharacterBeforeLastWhiteSpaceOrStartOfLine { - currentMentionStartIndex = lastCharacterIndex - snInputView.showMentionsUI(for: self.viewModel.mentions()) - } - else if lastCharacter.isWhitespace || lastCharacter == "@" { // the lastCharacter == "@" is to check for @@ - currentMentionStartIndex = nil + guard !newText.isEmpty else { + if currentMentionStartIndex != nil { snInputView.hideMentionsUI() } - else { - if let currentMentionStartIndex = currentMentionStartIndex { - let query = String(newText[newText.index(after: currentMentionStartIndex)...]) // + 1 to get rid of the @ - snInputView.showMentionsUI(for: self.viewModel.mentions(for: query)) - } + + resetMentions() + return + } + + let lastCharacterIndex = newText.index(before: newText.endIndex) + let lastCharacter = newText[lastCharacterIndex] + + // Check if there is whitespace before the '@' or the '@' is the first character + let isCharacterBeforeLastWhiteSpaceOrStartOfLine: Bool + if newText.count == 1 { + isCharacterBeforeLastWhiteSpaceOrStartOfLine = true // Start of line + } + else { + let characterBeforeLast = newText[newText.index(before: lastCharacterIndex)] + isCharacterBeforeLastWhiteSpaceOrStartOfLine = characterBeforeLast.isWhitespace + } + + if lastCharacter == "@" && isCharacterBeforeLastWhiteSpaceOrStartOfLine { + currentMentionStartIndex = lastCharacterIndex + snInputView.showMentionsUI(for: self.viewModel.mentions()) + } + else if lastCharacter.isWhitespace || lastCharacter == "@" { // the lastCharacter == "@" is to check for @@ + currentMentionStartIndex = nil + snInputView.hideMentionsUI() + } + else { + if let currentMentionStartIndex = currentMentionStartIndex { + let query = String(newText[newText.index(after: currentMentionStartIndex)...]) // + 1 to get rid of the @ + snInputView.showMentionsUI(for: self.viewModel.mentions(for: query)) } } } @@ -564,13 +584,6 @@ extension ConversationVC: currentMentionStartIndex = nil mentions = [] } - - // MARK: --Attachments - - func didPasteImageFromPasteboard(_ image: UIImage) { - guard let imageData = image.jpegData(compressionQuality: 1.0) else { return } - let dataSource = DataSourceValue.dataSource(with: imageData, utiType: kUTTypeJPEG as String) - let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: kUTTypeJPEG as String, imageQuality: .medium) func replaceMentions(in text: String) -> String { var result = text @@ -582,56 +595,78 @@ extension ConversationVC: return result } - // MARK: View Item Interaction - func handleViewItemLongPressed(_ viewItem: ConversationViewItem) { - // Show the context menu if applicable - guard let index = viewItems.firstIndex(where: { $0 === viewItem }), - let cell = messagesTableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell, - let snapshot = cell.bubbleView.snapshotView(afterScreenUpdates: false), contextMenuWindow == nil, - !ContextMenuVC.actions(for: viewItem, delegate: self).isEmpty else { return } - UIImpactFeedbackGenerator(style: .heavy).impactOccurred() - let frame = cell.convert(cell.bubbleView.frame, to: UIApplication.shared.keyWindow!) - let window = ContextMenuWindow() - let contextMenuVC = ContextMenuVC(snapshot: snapshot, viewItem: viewItem, frame: frame, delegate: self) { [weak self] in - window.isHidden = true - guard let self = self else { return } - self.contextMenuVC = nil - self.contextMenuWindow = nil - self.scrollButton.alpha = 0 - UIView.animate(withDuration: 0.25) { - self.scrollButton.alpha = self.getScrollButtonOpacity() - self.unreadCountView.alpha = self.scrollButton.alpha - } - } - self.contextMenuVC = contextMenuVC - contextMenuWindow = window - window.rootViewController = contextMenuVC - window.makeKeyAndVisible() - window.backgroundColor = .clear + func showInputAccessoryView() { + UIView.animate(withDuration: 0.25, animations: { + self.inputAccessoryView?.isHidden = false + self.inputAccessoryView?.alpha = 1 + }) } - func handleViewItemTapped(_ viewItem: ConversationViewItem, gestureRecognizer: UITapGestureRecognizer) { - func confirmDownload() { - let modal = DownloadAttachmentModal(viewItem: viewItem) + // MARK: MessageCellDelegate + + func handleItemLongPressed(_ item: ConversationViewModel.Item) { + // Show the context menu if applicable + guard + let keyWindow: UIWindow = UIApplication.shared.keyWindow, + let index = viewModel.viewData.items.firstIndex(of: item), + let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell, + let snapshot = cell.bubbleView.snapshotView(afterScreenUpdates: false), + contextMenuWindow == nil, + let actions: [ContextMenuVC.Action] = ContextMenuVC.actions( + for: item, + currentUserIsOpenGroupModerator: OpenGroupAPIV2.isUserModerator( + self.viewModel.viewData.userPublicKey, + for: self.viewModel.viewData.openGroupRoom, + on: self.viewModel.viewData.openGroupServer + ), + delegate: self + ) + else { return } + + UIImpactFeedbackGenerator(style: .heavy).impactOccurred() + self.contextMenuWindow = ContextMenuWindow() + self.contextMenuVC = ContextMenuVC( + snapshot: snapshot, + frame: cell.convert(cell.bubbleView.frame, to: keyWindow), + item: item, + actions: actions + ) { [weak self] in + self?.contextMenuWindow?.isHidden = true + self?.contextMenuVC = nil + self?.contextMenuWindow = nil + self?.scrollButton.alpha = 0 + + UIView.animate(withDuration: 0.25) { + self?.scrollButton.alpha = (self?.getScrollButtonOpacity() ?? 0) + self?.unreadCountView.alpha = (self?.scrollButton.alpha ?? 0) + } + } + + self.contextMenuWindow?.backgroundColor = .clear + self.contextMenuWindow?.rootViewController = self.contextMenuVC + self.contextMenuWindow?.makeKeyAndVisible() + } + + func handleItemTapped(_ item: ConversationViewModel.Item, gestureRecognizer: UITapGestureRecognizer) { + guard item.interactionVariant != .standardOutgoing || item.state != .failed else { + // Show the failed message sheet + showFailedMessageSheet(for: item) + return + } + + // If it's an incoming media message and the thread isn't trusted then show the placeholder view + if item.cellType != .textOnlyMessage && item.interactionVariant == .standardIncoming && !item.isThreadTrusted { + let modal = DownloadAttachmentModal(profile: item.profile) modal.modalPresentationStyle = .overFullScreen modal.modalTransitionStyle = .crossDissolve + present(modal, animated: true, completion: nil) + return } - if let message = viewItem.interaction as? TSOutgoingMessage, message.messageState == .failed { - // Show the failed message sheet - showFailedMessageSheet(for: message) - } else { - switch viewItem.messageCellType { - case .audio: - if - viewItem.interaction is TSIncomingMessage, - let thread = self.thread as? TSContactThread, - let contact: Contact? = GRDBStorage.shared.read({ db in try Contact.fetchOne(db, id: thread.contactSessionID()) }), - contact?.isTrusted != true { - confirmDownload() - } else { - playOrPauseAudio(for: viewItem) - } + + switch item.cellType { + case .audio: viewModel.playOrPauseAudio(for: item) + case .mediaMessage: guard let index = viewItems.firstIndex(where: { $0 === viewItem }), let cell = messagesTableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell else { return } @@ -661,29 +696,29 @@ extension ConversationVC: gallery.presentDetailView(fromViewController: self, mediaAttachment: stream) } case .genericAttachment: + guard + let attachment: Attachment = item.attachments?.first, + let originalFilePath: String = attachment.originalFilePath + else { return } + + let fileUrl: URL = URL(fileURLWithPath: originalFilePath) + + // Open a preview of the document for text, pdf or microsoft files if - viewItem.interaction is TSIncomingMessage, - let thread = self.thread as? TSContactThread, - let contact: Contact? = GRDBStorage.shared.read({ db in try Contact.fetchOne(db, id: thread.contactSessionID()) }), - contact?.isTrusted != true { - confirmDownload() - } - else if ( - viewItem.attachmentStream?.isText == true || - viewItem.attachmentStream?.isMicrosoftDoc == true || - viewItem.attachmentStream?.contentType == OWSMimeTypeApplicationPdf - ), let filePathString: String = viewItem.attachmentStream?.originalFilePath { - let fileUrl: URL = URL(fileURLWithPath: filePathString) + attachment.isText || + attachment.isMicrosoftDoc || + attachment.contentType == OWSMimeTypeApplicationPdf + { + let interactionController: UIDocumentInteractionController = UIDocumentInteractionController(url: fileUrl) interactionController.delegate = self interactionController.presentPreview(animated: true) + return } - else { - // Open the document if possible - guard let url = viewItem.attachmentStream?.originalMediaURL else { return } - let shareVC = UIActivityViewController(activityItems: [ url ], applicationActivities: nil) - navigationController!.present(shareVC, animated: true, completion: nil) - } + + // Otherwise share the file + let shareVC = UIActivityViewController(activityItems: [ fileUrl ], applicationActivities: nil) + navigationController?.present(shareVC, animated: true, completion: nil) case .textOnlyMessage: if let reply = viewItem.quotedReply { // Scroll to the source of the reply @@ -698,12 +733,11 @@ extension ConversationVC: } } - func handleViewItemSwiped(_ viewItem: ConversationViewItem, state: SwipeState) { - switch state { - case .began: - messagesTableView.isScrollEnabled = false - case .ended, .cancelled: - messagesTableView.isScrollEnabled = true + func handleItemDoubleTapped(_ item: ConversationViewModel.Item) { + switch item.cellType { + // The user can double tap a voice message when it's playing to speed it up + case .audio: self.viewModel.speedUpAudio(for: item) + default: break } } @@ -742,16 +776,13 @@ extension ConversationVC: UIPasteboard.general.string = snodeAddress })) } + func handleItemSwiped(_ item: ConversationViewModel.Item, state: SwipeState) { + switch state { + case .began: tableView.isScrollEnabled = false + case .ended, .cancelled: tableView.isScrollEnabled = true } - present(sheet, animated: true, completion: nil) } - func handleViewItemDoubleTapped(_ viewItem: ConversationViewItem) { - switch viewItem.messageCellType { - case .audio: speedUpAudio(for: viewItem) // The user can double tap a voice message when it's playing to speed it up - default: break - } - } func showFullText(_ viewItem: ConversationViewItem) { let longMessageVC = LongTextViewController(viewItem: viewItem) diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 02ffa1e63..2a97a0e86 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -437,7 +437,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers if let focusedInteractionId: Int64 = self.viewModel.focusedInteractionId { self.scrollToInteraction(with: focusedInteractionId, isAnimated: false, highlighted: true) } - else if let firstUnreadInteractionId: Int64 = self.viewModel.firstUnreadInteractionId { + else if let firstUnreadInteractionId: Int64 = self.viewModel.viewData.firstUnreadInteractionId { self.scrollToInteraction(with: firstUnreadInteractionId, position: .top, isAnimated: false) self.unreadCountView.alpha = self.scrollButton.alpha } @@ -561,10 +561,8 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers self?.viewModel.updateData(updatedViewData.with(items: items)) } - // Scroll to the bottom if we just sent a message or are close enough + // Scroll to the bottom if we just inserted a message and are close enough // to the bottom - - // Only if it was an insert if changeset.contains(where: { !$0.elementInserted.isEmpty }) && ( updatedViewData.items.last?.interactionVariant == .standardOutgoing || @@ -576,6 +574,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers // Mark received messages as read viewModel.markAllAsRead() + viewModel.sentMessageBeforeUpdate = false } func updateNavBarButtons(viewData: ConversationViewModel.ViewData) { @@ -867,7 +866,21 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let item: ConversationViewModel.Item = viewModel.viewData.items[indexPath.row] let cell: MessageCell = tableView.dequeue(type: MessageCell.cellType(for: item), for: indexPath) - cell.update(with: item, mediaCache: mediaCache, lastSearchText: viewModel.viewData.lastSearchedText) + cell.update( + with: item, + mediaCache: mediaCache, + playbackInfo: viewModel.playbackInfo(for: item) { [weak self] updatedInfo, error in + DispatchQueue.main.async { + guard error == nil else { + OWSAlerts.showErrorAlert(message: "INVALID_AUDIO_FILE_ALERT_ERROR_MESSAGE".localized()) + return + } + + cell.dynamicUpdate(with: item, playbackInfo: updatedInfo) + } + }, + lastSearchText: viewModel.viewData.lastSearchedText + ) cell.delegate = self return cell @@ -935,7 +948,8 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers // Not currently in use } - // MARK: Search + // MARK: - Search + func conversationSettingsDidRequestConversationSearch(_ conversationSettingsViewController: OWSConversationSettingsViewController) { showSearchUI() popAllConversationSettingsViews { diff --git a/Session/Conversations/Input View/MentionSelectionView.swift b/Session/Conversations/Input View/MentionSelectionView.swift index 0028aa86d..dab6a6ccb 100644 --- a/Session/Conversations/Input View/MentionSelectionView.swift +++ b/Session/Conversations/Input View/MentionSelectionView.swift @@ -1,6 +1,12 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -final class MentionSelectionView : UIView, UITableViewDataSource, UITableViewDelegate { - var candidates: [Mention] = [] { +import UIKit +import SessionUIKit +import SessionUtilitiesKit +import SignalUtilitiesKit + +final class MentionSelectionView: UIView, UITableViewDataSource, UITableViewDelegate { + var candidates: [ConversationViewModel.MentionInfo] = [] { didSet { tableView.isScrollEnabled = (candidates.count > 4) tableView.reloadData() @@ -10,27 +16,37 @@ final class MentionSelectionView : UIView, UITableViewDataSource, UITableViewDel var openGroupChannel: UInt64? var openGroupRoom: String? weak var delegate: MentionSelectionViewDelegate? + + var contentOffset: CGPoint { + get { tableView.contentOffset } + set { tableView.contentOffset = newValue } + } - // MARK: Components - lazy var tableView: UITableView = { // TODO: Make this private - let result = UITableView() + // MARK: - Components + + private lazy var tableView: UITableView = { + let result: UITableView = UITableView() result.dataSource = self result.delegate = self - result.register(Cell.self, forCellReuseIdentifier: "Cell") result.separatorStyle = .none result.backgroundColor = .clear result.showsVerticalScrollIndicator = false + result.register(view: Cell.self) + return result }() - // MARK: Initialization + // MARK: - Initialization + override init(frame: CGRect) { super.init(frame: frame) + setUpViewHierarchy() } required init?(coder: NSCoder) { super.init(coder: coder) + setUpViewHierarchy() } @@ -38,43 +54,54 @@ final class MentionSelectionView : UIView, UITableViewDataSource, UITableViewDel // Table view addSubview(tableView) tableView.pin(to: self) + // Top separator - let topSeparator = UIView() + let topSeparator: UIView = UIView() topSeparator.backgroundColor = Colors.separator topSeparator.set(.height, to: Values.separatorThickness) addSubview(topSeparator) topSeparator.pin(.leading, to: .leading, of: self) topSeparator.pin(.top, to: .top, of: self) topSeparator.pin(.trailing, to: .trailing, of: self) + // Bottom separator - let bottomSeparator = UIView() + let bottomSeparator: UIView = UIView() bottomSeparator.backgroundColor = Colors.separator bottomSeparator.set(.height, to: Values.separatorThickness) addSubview(bottomSeparator) + bottomSeparator.pin(.leading, to: .leading, of: self) bottomSeparator.pin(.trailing, to: .trailing, of: self) bottomSeparator.pin(.bottom, to: .bottom, of: self) } - // MARK: Data + // MARK: - Data + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return candidates.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "Cell") as! Cell - let mentionCandidate = candidates[indexPath.row] - cell.mentionCandidate = mentionCandidate - cell.openGroupServer = openGroupServer - cell.openGroupChannel = openGroupChannel - cell.openGroupRoom = openGroupRoom - cell.separator.isHidden = (indexPath.row == (candidates.count - 1)) + let cell: Cell = tableView.dequeue(type: Cell.self, for: indexPath) + cell.update( + with: candidates[indexPath.row].profile, + threadVariant: candidates[indexPath.row].threadVariant, + isUserModerator: OpenGroupAPIV2.isUserModerator( + candidates[indexPath.row].profile.id, + for: (candidates[indexPath.row].openGroupRoom ?? ""), + on: (candidates[indexPath.row].openGroupServer ?? "") + ), + isLast: (indexPath.row == (candidates.count - 1)) + ) + return cell } - // MARK: Interaction + // MARK: - Interaction + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { let mentionCandidate = candidates[indexPath.row] + delegate?.handleMentionSelected(mentionCandidate, from: self) } } @@ -82,56 +109,59 @@ final class MentionSelectionView : UIView, UITableViewDataSource, UITableViewDel // MARK: - Cell private extension MentionSelectionView { + final class Cell: UITableViewCell { + // MARK: - UI + + private lazy var profilePictureView: ProfilePictureView = ProfilePictureView() - final class Cell : UITableViewCell { - var mentionCandidate = Mention(publicKey: "", displayName: "") { didSet { update() } } - var openGroupServer: String? - var openGroupChannel: UInt64? - var openGroupRoom: String? - - // MARK: Components - private lazy var profilePictureView = ProfilePictureView() - - private lazy var moderatorIconImageView = UIImageView(image: #imageLiteral(resourceName: "Crown")) + private lazy var moderatorIconImageView: UIImageView = UIImageView(image: #imageLiteral(resourceName: "Crown")) private lazy var displayNameLabel: UILabel = { - let result = UILabel() + let result: UILabel = UILabel() result.textColor = Colors.text result.font = .systemFont(ofSize: Values.smallFontSize) result.lineBreakMode = .byTruncatingTail + return result }() lazy var separator: UIView = { - let result = UIView() + let result: UIView = UIView() result.backgroundColor = Colors.separator result.set(.height, to: Values.separatorThickness) + return result }() - // MARK: Initialization + // MARK: - Initialization + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) + setUpViewHierarchy() } required init?(coder: NSCoder) { super.init(coder: coder) + setUpViewHierarchy() } private func setUpViewHierarchy() { // Cell background color backgroundColor = .clear + // Highlight color let selectedBackgroundView = UIView() selectedBackgroundView.backgroundColor = .clear self.selectedBackgroundView = selectedBackgroundView + // Profile picture image view let profilePictureViewSize = Values.smallProfilePictureSize profilePictureView.set(.width, to: profilePictureViewSize) profilePictureView.set(.height, to: profilePictureViewSize) profilePictureView.size = profilePictureViewSize + // Main stack view let mainStackView = UIStackView(arrangedSubviews: [ profilePictureView, displayNameLabel ]) mainStackView.axis = .horizontal @@ -144,12 +174,14 @@ private extension MentionSelectionView { contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.mediumSpacing) contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.smallSpacing) mainStackView.set(.width, to: UIScreen.main.bounds.width - 2 * Values.mediumSpacing) + // Moderator icon image view moderatorIconImageView.set(.width, to: 20) moderatorIconImageView.set(.height, to: 20) contentView.addSubview(moderatorIconImageView) moderatorIconImageView.pin(.trailing, to: .trailing, of: profilePictureView, withInset: 1) moderatorIconImageView.pin(.bottom, to: .bottom, of: profilePictureView, withInset: 4.5) + // Separator addSubview(separator) separator.pin(.leading, to: .leading, of: self) @@ -159,16 +191,20 @@ private extension MentionSelectionView { // MARK: - Updating - private func update() { - displayNameLabel.text = mentionCandidate.displayName - profilePictureView.update(for: mentionCandidate.publicKey) - - if let server = openGroupServer, let room = openGroupRoom { - let isUserModerator = OpenGroupAPIV2.isUserModerator(mentionCandidate.publicKey, for: room, on: server) - moderatorIconImageView.isHidden = !isUserModerator - } else { - moderatorIconImageView.isHidden = true - } + fileprivate func update( + with profile: Profile, + threadVariant: SessionThread.Variant, + isUserModerator: Bool, + isLast: Bool + ) { + displayNameLabel.text = profile.displayName(for: threadVariant) + profilePictureView.update( + publicKey: profile.id, + profile: profile, + threadVariant: threadVariant + ) + moderatorIconImageView.isHidden = !isUserModerator + separator.isHidden = isLast } } } @@ -176,5 +212,5 @@ private extension MentionSelectionView { // MARK: - Delegate protocol MentionSelectionViewDelegate: AnyObject { - func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView) + func handleMentionSelected(_ mention: ConversationViewModel.MentionInfo, from view: MentionSelectionView) } diff --git a/Session/Conversations/Message Cells/Content Views/DeletedMessageView.swift b/Session/Conversations/Message Cells/Content Views/DeletedMessageView.swift index 0e353972e..46b224bd2 100644 --- a/Session/Conversations/Message Cells/Content Views/DeletedMessageView.swift +++ b/Session/Conversations/Message Cells/Content Views/DeletedMessageView.swift @@ -1,43 +1,50 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -final class DeletedMessageView : UIView { - private let viewItem: ConversationViewItem - private let textColor: UIColor - - // MARK: Settings +import UIKit +import SignalUtilitiesKit +import SessionUtilitiesKit + +final class DeletedMessageView: UIView { private static let iconSize: CGFloat = 18 private static let iconImageViewSize: CGFloat = 30 - // MARK: Lifecycle - init(viewItem: ConversationViewItem, textColor: UIColor) { - self.viewItem = viewItem - self.textColor = textColor + // MARK: - Lifecycle + + init(textColor: UIColor) { super.init(frame: CGRect.zero) - setUpViewHierarchy() + + setUpViewHierarchy(textColor: textColor) } override init(frame: CGRect) { - preconditionFailure("Use init(viewItem:textColor:) instead.") + preconditionFailure("Use init(textColor:) instead.") } required init?(coder: NSCoder) { - preconditionFailure("Use init(viewItem:textColor:) instead.") + preconditionFailure("Use init(textColor:) instead.") } - private func setUpViewHierarchy() { + private func setUpViewHierarchy(textColor: UIColor) { // Image view - let iconSize = DeletedMessageView.iconSize - let icon = UIImage(named: "ic_trash")?.withTint(textColor)?.resizedImage(to: CGSize(width: iconSize, height: iconSize)) + let icon = UIImage(named: "ic_trash")? + .withTint(textColor)? + .resizedImage(to: CGSize( + width: DeletedMessageView.iconSize, + height: DeletedMessageView.iconSize + )) + let imageView = UIImageView(image: icon) imageView.contentMode = .center - let iconImageViewSize = DeletedMessageView.iconImageViewSize - imageView.set(.width, to: iconImageViewSize) - imageView.set(.height, to: iconImageViewSize) + imageView.set(.width, to: DeletedMessageView.iconImageViewSize) + imageView.set(.height, to: DeletedMessageView.iconImageViewSize) + // Body label let titleLabel = UILabel() titleLabel.lineBreakMode = .byTruncatingTail - titleLabel.text = NSLocalizedString("message_deleted", comment: "") + titleLabel.text = "message_deleted".localized() titleLabel.textColor = textColor titleLabel.font = .systemFont(ofSize: Values.smallFontSize) + // Stack view let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ]) stackView.axis = .horizontal @@ -45,7 +52,8 @@ final class DeletedMessageView : UIView { stackView.isLayoutMarginsRelativeArrangement = true stackView.layoutMargins = UIEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 6) addSubview(stackView) + stackView.pin(to: self, withInset: Values.smallSpacing) + stackView.set(.height, to: .height, of: imageView) } } - diff --git a/Session/Conversations/Message Cells/Content Views/DocumentView.swift b/Session/Conversations/Message Cells/Content Views/DocumentView.swift index 5f0717ae6..402ac6281 100644 --- a/Session/Conversations/Message Cells/Content Views/DocumentView.swift +++ b/Session/Conversations/Message Cells/Content Views/DocumentView.swift @@ -1,17 +1,18 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -final class DocumentView : UIView { - private let viewItem: ConversationViewItem - private let textColor: UIColor - - // MARK: Settings +import UIKit +import SessionUIKit +import SessionMessagingKit + +final class DocumentView: UIView { private static let iconImageViewSize: CGSize = CGSize(width: 31, height: 40) - // MARK: Lifecycle - init(viewItem: ConversationViewItem, textColor: UIColor) { - self.viewItem = viewItem - self.textColor = textColor + // MARK: - Lifecycle + + init(attachment: Attachment, textColor: UIColor) { super.init(frame: CGRect.zero) - setUpViewHierarchy() + + setUpViewHierarchy(attachment: attachment, textColor: textColor) } override init(frame: CGRect) { @@ -22,12 +23,12 @@ final class DocumentView : UIView { preconditionFailure("Use init(viewItem:textColor:) instead.") } - private func setUpViewHierarchy() { - guard let attachment = viewItem.attachmentStream ?? viewItem.attachmentPointer else { return } + private func setUpViewHierarchy(attachment: Attachment, textColor: UIColor) { // Image view - let icon = UIImage(named: "File")?.withTint(textColor) - let imageView = UIImageView(image: icon) + let imageView = UIImageView(image: UIImage(named: "File")?.withRenderingMode(.alwaysTemplate)) + imageView.tintColor = textColor imageView.contentMode = .center + let iconImageViewSize = DocumentView.iconImageViewSize imageView.set(.width, to: iconImageViewSize.width) imageView.set(.height, to: iconImageViewSize.height) diff --git a/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift b/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift index f41b626d4..9cf8c83b9 100644 --- a/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift +++ b/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift @@ -1,6 +1,7 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SessionMessagingKit extension CGPoint { @@ -125,22 +126,19 @@ public class LinkPreviewDraft: NSObject, LinkPreviewState { // MARK: - -@objc -public class LinkPreviewSent: NSObject, LinkPreviewState { - private let linkPreview: OWSLinkPreview - private let imageAttachment: TSAttachment? +public class LinkPreviewSent: LinkPreviewState { + private let linkPreview: LinkPreview + private let imageAttachment: Attachment? - @objc public var imageSize: CGSize { - guard let attachmentStream = imageAttachment as? TSAttachmentStream else { + guard let width: UInt = imageAttachment?.width, let height: UInt = imageAttachment?.height else { return CGSize.zero } - return attachmentStream.imageSize() + + return CGSize(width: CGFloat(width), height: CGFloat(height)) } - @objc - public required init(linkPreview: OWSLinkPreview, - imageAttachment: TSAttachment?) { + public required init(linkPreview: LinkPreview, imageAttachment: Attachment?) { self.linkPreview = linkPreview self.imageAttachment = imageAttachment } @@ -148,20 +146,17 @@ public class LinkPreviewSent: NSObject, LinkPreviewState { public func isLoaded() -> Bool { return true } - + public func urlString() -> String? { - guard let urlString = linkPreview.urlString else { - owsFailDebug("Missing url") - return nil - } - return urlString + return linkPreview.url } public func displayDomain() -> String? { - guard let displayDomain = linkPreview.displayDomain() else { + guard let displayDomain: String = URL(string: linkPreview.url)?.host else { Logger.error("Missing display domain") return nil } + return displayDomain } @@ -174,39 +169,37 @@ public class LinkPreviewSent: NSObject, LinkPreviewState { } public func imageState() -> LinkPreviewImageState { - guard linkPreview.imageAttachmentId != nil else { - return .none - } - guard let imageAttachment = imageAttachment else { + guard linkPreview.attachmentId != nil else { return .none } + guard let imageAttachment: Attachment = imageAttachment else { owsFailDebug("Missing imageAttachment.") return .none } - guard let attachmentStream = imageAttachment as? TSAttachmentStream else { - return .loading + + switch imageAttachment.state { + case .downloaded, .uploaded: + guard imageAttachment.isImage && imageAttachment.isValid else { + return .invalid + } + + return .loaded + + case .pending, .downloading, .uploading: return .loading + case .failed: return .invalid } - guard attachmentStream.isImage, - attachmentStream.isValidImage else { - return .invalid - } - return .loaded } public func image() -> UIImage? { - guard let attachmentStream = imageAttachment as? TSAttachmentStream else { + // Note: We don't check if the image is valid here because that can be confirmed + // in 'imageState' and it's a little inefficient + guard imageAttachment?.isImage == true else { return nil } + guard let imageData: Data = try? imageAttachment?.readDataFromFile() else { return nil } - guard attachmentStream.isImage, - attachmentStream.isValidImage else { - return nil - } - guard let imageFilepath = attachmentStream.originalFilePath else { - owsFailDebug("Attachment is missing file path.") - return nil - } - guard let image = UIImage(contentsOfFile: imageFilepath) else { - owsFailDebug("Could not load image: \(imageFilepath)") + guard let image = UIImage(data: imageData) else { + owsFailDebug("Could not load image: \(imageAttachment?.localRelativeFilePath ?? "unknown")") return nil } + return image } } diff --git a/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift b/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift index 0de2178b6..0003a1545 100644 --- a/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift +++ b/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift @@ -1,97 +1,104 @@ -import NVActivityIndicatorView +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -final class LinkPreviewView : UIView { - private let viewItem: ConversationViewItem? +import UIKit +import NVActivityIndicatorView +import SessionUIKit +import SessionMessagingKit + +final class LinkPreviewView: UIView { + private static let loaderSize: CGFloat = 24 + private static let cancelButtonSize: CGFloat = 45 + private let maxWidth: CGFloat - private let delegate: LinkPreviewViewDelegate - var linkPreviewState: LinkPreviewState? { didSet { update() } } + private let onCancel: (() -> ())? + + // MARK: - UI + private lazy var imageViewContainerWidthConstraint = imageView.set(.width, to: 100) private lazy var imageViewContainerHeightConstraint = imageView.set(.height, to: 100) - - private lazy var sentLinkPreviewTextColor: UIColor = { - let isOutgoing = (viewItem?.interaction.interactionType() == .outgoingMessage) - switch (isOutgoing, AppModeManager.shared.currentAppMode) { - case (false, .light): return .black - case (true, .light): return Colors.grey - default: return .white - } - }() - - // MARK: UI Components + private lazy var imageView: UIImageView = { - let result = UIImageView() + let result: UIImageView = UIImageView() result.contentMode = .scaleAspectFill + return result }() private lazy var imageViewContainer: UIView = { - let result = UIView() + let result: UIView = UIView() result.clipsToBounds = true + return result }() private lazy var loader: NVActivityIndicatorView = { - let color: UIColor = isLightMode ? .black : .white + // FIXME: This will have issues with theme transitions + let color: UIColor = (isLightMode ? .black : .white) + return NVActivityIndicatorView(frame: CGRect.zero, type: .circleStrokeSpin, color: color, padding: nil) }() private lazy var titleLabel: UILabel = { - let result = UILabel() + let result: UILabel = UILabel() result.font = .boldSystemFont(ofSize: Values.smallFontSize) result.numberOfLines = 0 + return result }() - private lazy var bodyTextViewContainer = UIView() + private lazy var bodyTextViewContainer: UIView = UIView() - private lazy var hStackViewContainer = UIView() + private lazy var hStackViewContainer: UIView = UIView() - private lazy var hStackView = UIStackView() + private lazy var hStackView: UIStackView = UIStackView() private lazy var cancelButton: UIButton = { - let result = UIButton(type: .custom) - let tint: UIColor = isLightMode ? .black : .white + // FIXME: This will have issues with theme transitions + let tint: UIColor = (isLightMode ? .black : .white) + let result: UIButton = UIButton(type: .custom) result.setImage(UIImage(named: "X")?.withTint(tint), for: UIControl.State.normal) + let cancelButtonSize = LinkPreviewView.cancelButtonSize result.set(.width, to: cancelButtonSize) result.set(.height, to: cancelButtonSize) result.addTarget(self, action: #selector(cancel), for: UIControl.Event.touchUpInside) + return result }() - + var bodyTextView: UITextView? - // MARK: Settings - private static let loaderSize: CGFloat = 24 - private static let cancelButtonSize: CGFloat = 45 - - // MARK: Lifecycle - init(for viewItem: ConversationViewItem?, maxWidth: CGFloat, delegate: LinkPreviewViewDelegate) { - self.viewItem = viewItem + // MARK: - Initialization + + init(maxWidth: CGFloat, onCancel: (() -> ())? = nil) { self.maxWidth = maxWidth - self.delegate = delegate + self.onCancel = onCancel + super.init(frame: CGRect.zero) + setUpViewHierarchy() } - + override init(frame: CGRect) { preconditionFailure("Use init(for:maxWidth:delegate:) instead.") } - + required init?(coder: NSCoder) { preconditionFailure("Use init(for:maxWidth:delegate:) instead.") } - + private func setUpViewHierarchy() { // Image view imageViewContainerWidthConstraint.isActive = true imageViewContainerHeightConstraint.isActive = true imageViewContainer.addSubview(imageView) imageView.pin(to: imageViewContainer) + // Title label let titleLabelContainer = UIView() titleLabelContainer.addSubview(titleLabel) titleLabel.pin(to: titleLabelContainer, withInset: Values.smallSpacing) + // Horizontal stack view hStackView.addArrangedSubview(imageViewContainer) hStackView.addArrangedSubview(titleLabelContainer) @@ -99,72 +106,105 @@ final class LinkPreviewView : UIView { hStackView.alignment = .center hStackViewContainer.addSubview(hStackView) hStackView.pin(to: hStackViewContainer) + // Vertical stack view let vStackView = UIStackView(arrangedSubviews: [ hStackViewContainer, bodyTextViewContainer ]) vStackView.axis = .vertical addSubview(vStackView) vStackView.pin(to: self) + // Loader addSubview(loader) + let loaderSize = LinkPreviewView.loaderSize loader.set(.width, to: loaderSize) loader.set(.height, to: loaderSize) loader.center(in: self) } - // MARK: Updating - private func update() { + // MARK: - Updating + + public func update( + with state: LinkPreviewState, + isOutgoing: Bool, + delegate: (UITextViewDelegate & BodyTextViewDelegate)? = nil, + item: ConversationViewModel.Item? = nil, + bodyLabelTextColor: UIColor? = nil, + lastSearchText: String? = nil + ) { cancelButton.removeFromSuperview() - guard let linkPreviewState = linkPreviewState else { return } - var image = linkPreviewState.image() - if image == nil && (linkPreviewState is LinkPreviewDraft || linkPreviewState is LinkPreviewSent) { + + var image: UIImage? = state.image() + let stateHasImage: Bool = (image != nil) + if image == nil && (state is LinkPreviewDraft || state is LinkPreviewSent) { image = UIImage(named: "Link")?.withTint(isLightMode ? .black : .white) } + // Image view - let imageViewContainerSize: CGFloat = (linkPreviewState is LinkPreviewSent) ? 100 : 80 + let imageViewContainerSize: CGFloat = (state is LinkPreviewSent ? 100 : 80) imageViewContainerWidthConstraint.constant = imageViewContainerSize imageViewContainerHeightConstraint.constant = imageViewContainerSize - imageViewContainer.layer.cornerRadius = (linkPreviewState is LinkPreviewSent) ? 0 : 8 - if linkPreviewState is LinkPreviewLoading { + imageViewContainer.layer.cornerRadius = (state is LinkPreviewSent ? 0 : 8) + + if state is LinkPreviewLoading { imageViewContainer.backgroundColor = .clear - } else { + } + else { imageViewContainer.backgroundColor = isDarkMode ? .black : UIColor.black.withAlphaComponent(0.06) } + imageView.image = image - imageView.contentMode = (linkPreviewState.image() == nil) ? .center : .scaleAspectFill + imageView.contentMode = (stateHasImage ? .scaleAspectFill : .center) + // Loader - loader.alpha = (image != nil) ? 0 : 1 + loader.alpha = (image != nil ? 0 : 1) if image != nil { loader.stopAnimating() } else { loader.startAnimating() } + // Title + let sentLinkPreviewTextColor: UIColor = { + switch (isOutgoing, AppModeManager.shared.currentAppMode) { + case (false, .light): return .black + case (true, .light): return Colors.grey + default: return .white + } + }() titleLabel.textColor = sentLinkPreviewTextColor - titleLabel.text = linkPreviewState.title() + titleLabel.text = state.title() + // Horizontal stack view - switch linkPreviewState { - case is LinkPreviewSent: hStackViewContainer.backgroundColor = isDarkMode ? .black : UIColor.black.withAlphaComponent(0.06) - default: hStackViewContainer.backgroundColor = nil + switch state { + case is LinkPreviewSent: + // FIXME: This will have issues with theme transitions + hStackViewContainer.backgroundColor = (isDarkMode ? .black : UIColor.black.withAlphaComponent(0.06)) + + default: + hStackViewContainer.backgroundColor = nil } + // Body text view bodyTextViewContainer.subviews.forEach { $0.removeFromSuperview() } - if let viewItem = viewItem { - let bodyTextView = VisibleMessageCell.getBodyTextView(for: viewItem, with: maxWidth, textColor: sentLinkPreviewTextColor, searchText: delegate.lastSearchedText, delegate: delegate) + + if let item: ConversationViewModel.Item = item { + let bodyTextView = VisibleMessageCell.getBodyTextView( + for: item, + with: maxWidth, + textColor: (bodyLabelTextColor ?? sentLinkPreviewTextColor), + searchText: lastSearchText, + delegate: delegate + ) self.bodyTextView = bodyTextView bodyTextViewContainer.addSubview(bodyTextView) bodyTextView.pin(to: bodyTextViewContainer, withInset: 12) } - if linkPreviewState is LinkPreviewDraft { + + if state is LinkPreviewDraft { hStackView.addArrangedSubview(cancelButton) } } - // MARK: Interaction + // MARK: - Interaction + @objc private func cancel() { - delegate.handleLinkPreviewCanceled() + onCancel?() } } - -// MARK: Delegate -protocol LinkPreviewViewDelegate : UITextViewDelegate & BodyTextViewDelegate { - var lastSearchedText: String? { get } - - func handleLinkPreviewCanceled() -} diff --git a/Session/Conversations/Message Cells/Content Views/QuoteView.swift b/Session/Conversations/Message Cells/Content Views/QuoteView.swift index 1fc2aef94..bfaf22592 100644 --- a/Session/Conversations/Message Cells/Content Views/QuoteView.swift +++ b/Session/Conversations/Message Cells/Content Views/QuoteView.swift @@ -4,102 +4,50 @@ import UIKit import SessionUIKit import SessionMessagingKit -final class QuoteView : UIView { - private let mode: Mode - private let thread: TSThread - private let direction: Direction - private let hInset: CGFloat - private let maxWidth: CGFloat - private let delegate: QuoteViewDelegate? - - private var maxBodyLabelHeight: CGFloat { - switch mode { - case .regular: return 60 - case .draft: return 40 - } - } - - private var attachments: [OWSAttachmentInfo] { - switch mode { - case .regular(let viewItem): return (viewItem.interaction as? TSMessage)?.quotedMessage!.quotedAttachments ?? [] - case .draft(let model): return given(model.attachmentStream) { [ OWSAttachmentInfo(attachmentStream: $0) ] } ?? [] - } - } - - private var thumbnail: UIImage? { - switch mode { - case .regular(let viewItem): return viewItem.quotedReply!.thumbnailImage - case .draft(let model): return model.thumbnailImage - } - } - - private var body: String? { - switch mode { - case .regular(let viewItem): return (viewItem.interaction as? TSMessage)?.quotedMessage!.body - case .draft(let model): return model.body - } - } - - private var authorID: String { - switch mode { - case .regular(let viewItem): return viewItem.quotedReply!.authorId - case .draft(let model): return model.authorId - } - } - - private var lineColor: UIColor { - switch (mode, AppModeManager.shared.currentAppMode) { - case (.regular, .light), (.draft, .light): return .black - case (.regular, .dark): return (direction == .outgoing) ? .black : Colors.accent - case (.draft, .dark): return Colors.accent - } - } - - private var textColor: UIColor { - if case .draft = mode { return Colors.text } - switch (direction, AppModeManager.shared.currentAppMode) { - case (.outgoing, .dark), (.incoming, .light): return .black - default: return .white - } - } - - // MARK: Mode - enum Mode { - case regular(ConversationViewItem) - case draft(OWSQuotedReplyModel) - } - - // MARK: Direction - enum Direction { case incoming, outgoing } - - // MARK: Settings +final class QuoteView: UIView { static let thumbnailSize: CGFloat = 48 static let iconSize: CGFloat = 24 static let labelStackViewSpacing: CGFloat = 2 static let labelStackViewVMargin: CGFloat = 4 static let cancelButtonSize: CGFloat = 33 - - // MARK: Lifecycle - init(for viewItem: ConversationViewItem, in thread: TSThread?, direction: Direction, hInset: CGFloat, maxWidth: CGFloat) { - self.mode = .regular(viewItem) - self.thread = thread ?? TSThread.fetch(uniqueId: viewItem.interaction.uniqueThreadId)! - self.maxWidth = maxWidth - self.direction = direction - self.hInset = hInset - self.delegate = nil - super.init(frame: CGRect.zero) - setUpViewHierarchy() + + enum Mode { + case regular + case draft } + enum Direction { case incoming, outgoing } + + // MARK: - Variables + + private let onCancel: (() -> ())? - init(for model: OWSQuotedReplyModel, direction: Direction, hInset: CGFloat, maxWidth: CGFloat, delegate: QuoteViewDelegate) { - self.mode = .draft(model) - self.thread = TSThread.fetch(uniqueId: model.threadId)! - self.maxWidth = maxWidth - self.direction = direction - self.hInset = hInset - self.delegate = delegate + // MARK: - Lifecycle + + init( + for mode: Mode, + authorId: String, + quotedText: String?, + threadVariant: SessionThread.Variant, + direction: Direction, + attachment: Attachment?, + hInset: CGFloat, + maxWidth: CGFloat, + onCancel: (() -> ())? = nil + ) { + self.onCancel = onCancel + super.init(frame: CGRect.zero) - setUpViewHierarchy() + + setUpViewHierarchy( + mode: mode, + authorId: authorId, + quotedText: quotedText, + threadVariant: threadVariant, + direction: direction, + attachment: attachment, + hInset: hInset, + maxWidth: maxWidth + ) } override init(frame: CGRect) { @@ -110,14 +58,22 @@ final class QuoteView : UIView { preconditionFailure("Use init(for:maxMessageWidth:) instead.") } - private func setUpViewHierarchy() { + private func setUpViewHierarchy( + mode: Mode, + authorId: String, + quotedText: String?, + threadVariant: SessionThread.Variant, + direction: Direction, + attachment: Attachment?, + hInset: CGFloat, + maxWidth: CGFloat + ) { // There's quite a bit of calculation going on here. It's a bit complex so don't make changes // if you don't need to. If you do then test: // • Quoted text in both private chats and group chats // • Quoted images and videos in both private chats and group chats // • Quoted voice messages and documents in both private chats and group chats // • All of the above in both dark mode and light mode - let hasAttachments = !attachments.isEmpty let thumbnailSize = QuoteView.thumbnailSize let iconSize = QuoteView.iconSize let labelStackViewSpacing = QuoteView.labelStackViewSpacing @@ -125,18 +81,23 @@ final class QuoteView : UIView { let smallSpacing = Values.smallSpacing let cancelButtonSize = QuoteView.cancelButtonSize var availableWidth: CGFloat + // Subtract smallSpacing twice; once for the spacing in between the stack view elements and // once for the trailing margin. - if !hasAttachments { + if attachment != nil { availableWidth = maxWidth - 2 * hInset - Values.accentLineThickness - 2 * smallSpacing - } else { + } + else { availableWidth = maxWidth - 2 * hInset - thumbnailSize - 2 * smallSpacing } + if case .draft = mode { availableWidth -= cancelButtonSize } + let availableSpace = CGSize(width: availableWidth, height: .greatestFiniteMagnitude) - var body = self.body + var body: String? = quotedText + // Main stack view let mainStackView = UIStackView(arrangedSubviews: []) mainStackView.axis = .horizontal @@ -144,48 +105,108 @@ final class QuoteView : UIView { mainStackView.isLayoutMarginsRelativeArrangement = true mainStackView.layoutMargins = UIEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: smallSpacing) mainStackView.alignment = .center + // Content view let contentView = UIView() addSubview(contentView) contentView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.VerticalEdge.bottom ], to: self) contentView.rightAnchor.constraint(lessThanOrEqualTo: self.rightAnchor).isActive = true + // Line view + let lineColor: UIColor = { + switch (mode, AppModeManager.shared.currentAppMode) { + case (.regular, .light), (.draft, .light): return .black + case (.regular, .dark): return (direction == .outgoing) ? .black : Colors.accent + case (.draft, .dark): return Colors.accent + } + }() let lineView = UIView() lineView.backgroundColor = lineColor lineView.set(.width, to: Values.accentLineThickness) - if !hasAttachments { - mainStackView.addArrangedSubview(lineView) - } else { - let isAudio = MIMETypeUtil.isAudio(attachments.first!.contentType ?? "") - let fallbackImageName = isAudio ? "attachment_audio" : "actionsheet_document_black" - let fallbackImage = UIImage(named: fallbackImageName)?.withTint(.white)?.resizedImage(to: CGSize(width: iconSize, height: iconSize)) - let imageView = UIImageView(image: thumbnail ?? fallbackImage) - imageView.contentMode = (thumbnail != nil) ? .scaleAspectFill : .center + + if let attachment: Attachment = attachment { + let isAudio: Bool = MIMETypeUtil.isAudio(attachment.contentType) + let fallbackImageName: String = (isAudio ? "attachment_audio" : "actionsheet_document_black") + let imageView: UIImageView = UIImageView( + image: UIImage(named: fallbackImageName)? + .withRenderingMode(.alwaysTemplate) + .resizedImage(to: CGSize(width: iconSize, height: iconSize)) + ) + + attachment.thumbnail( + size: .small, + success: { image in + DispatchQueue.main.async { + imageView.image = image + imageView.contentMode = .scaleAspectFill + } + }, + failure: {} + ) + + imageView.tintColor = .white + imageView.contentMode = .center imageView.backgroundColor = lineColor imageView.layer.cornerRadius = VisibleMessageCell.smallCornerRadius imageView.layer.masksToBounds = true imageView.set(.width, to: thumbnailSize) imageView.set(.height, to: thumbnailSize) mainStackView.addArrangedSubview(imageView) + if (body ?? "").isEmpty { - body = (thumbnail != nil) ? "Image" : (isAudio ? "Audio" : "Document") + body = (attachment.isImage ? + "Image" : + (isAudio ? "Audio" : "Document") + ) } } + else { + mainStackView.addArrangedSubview(lineView) + } + // Body label + let textColor: UIColor = { + guard mode != .draft else { return Colors.text } + + switch (direction, AppModeManager.shared.currentAppMode) { + case (.outgoing, .dark), (.incoming, .light): return .black + default: return .white + } + }() let bodyLabel = UILabel() bodyLabel.numberOfLines = 0 bodyLabel.lineBreakMode = .byTruncatingTail + let isOutgoing = (direction == .outgoing) bodyLabel.font = .systemFont(ofSize: Values.smallFontSize) - bodyLabel.attributedText = given(body) { MentionUtilities.highlightMentions(in: $0, isOutgoingMessage: isOutgoing, threadID: thread.uniqueId!, attributes: [:]) } ?? given(attachments.first?.contentType) { NSAttributedString(string: MIMETypeUtil.isAudio($0) ? "Audio" : "Document") } ?? NSAttributedString(string: "Document") + bodyLabel.attributedText = body + .map { + MentionUtilities.highlightMentions( + in: $0, + threadVariant: threadVariant, + isOutgoingMessage: isOutgoing, + attributes: [:] + ) + } + .defaulting( + to: attachment.map { + NSAttributedString(string: MIMETypeUtil.isAudio($0.contentType) ? "Audio" : "Document") + } + ) + .defaulting(to: NSAttributedString(string: "Document")) bodyLabel.textColor = textColor + let bodyLabelSize = bodyLabel.systemLayoutSizeFitting(availableSpace) + // Label stack view var authorLabelHeight: CGFloat? - if let groupThread = thread as? TSGroupThread { + if threadVariant == .openGroup || threadVariant == .closedGroup { let authorLabel = UILabel() authorLabel.lineBreakMode = .byTruncatingTail - authorLabel.text = Profile.displayName(for: authorID, thread: groupThread) + authorLabel.text = Profile.displayName( + id: authorId, + context: (threadVariant == .openGroup ? .openGroup : .regular) + ) authorLabel.textColor = textColor authorLabel.font = .boldSystemFont(ofSize: Values.smallFontSize) let authorLabelSize = authorLabel.systemLayoutSizeFitting(availableSpace) @@ -199,9 +220,11 @@ final class QuoteView : UIView { labelStackView.isLayoutMarginsRelativeArrangement = true labelStackView.layoutMargins = UIEdgeInsets(top: labelStackViewVMargin, left: 0, bottom: labelStackViewVMargin, right: 0) mainStackView.addArrangedSubview(labelStackView) - } else { + } + else { mainStackView.addArrangedSubview(bodyLabel) } + // Cancel button let cancelButton = UIButton(type: .custom) let tint: UIColor = isLightMode ? .black : .white @@ -209,41 +232,44 @@ final class QuoteView : UIView { cancelButton.set(.width, to: cancelButtonSize) cancelButton.set(.height, to: cancelButtonSize) cancelButton.addTarget(self, action: #selector(cancel), for: UIControl.Event.touchUpInside) + // Constraints contentView.addSubview(mainStackView) mainStackView.pin(to: contentView) - if !thread.isGroupThread() { + + if threadVariant != .openGroup && threadVariant != .closedGroup { bodyLabel.set(.width, to: bodyLabelSize.width) } - let bodyLabelHeight = bodyLabelSize.height.clamp(0, maxBodyLabelHeight) + + let bodyLabelHeight = bodyLabelSize.height.clamp(0, (mode == .regular ? 60 : 40)) let contentViewHeight: CGFloat - if hasAttachments { + + if attachment != nil { contentViewHeight = thumbnailSize + 8 // Add a small amount of spacing above and below the thumbnail bodyLabel.set(.height, to: 18) // Experimentally determined - } else { + } + else { if let authorLabelHeight = authorLabelHeight { // Group thread contentViewHeight = bodyLabelHeight + (authorLabelHeight + labelStackViewSpacing) + 2 * labelStackViewVMargin - } else { + } + else { contentViewHeight = bodyLabelHeight + 2 * smallSpacing } } + contentView.set(.height, to: contentViewHeight) lineView.set(.height, to: contentViewHeight - 8) // Add a small amount of spacing above and below the line - if case .draft = mode { + + if mode == .draft { addSubview(cancelButton) cancelButton.center(.vertical, in: self) cancelButton.pin(.right, to: .right, of: self) } } - // MARK: Interaction + // MARK: - Interaction + @objc private func cancel() { - delegate?.handleQuoteViewCancelButtonTapped() + onCancel?() } } - -// MARK: Delegate -protocol QuoteViewDelegate { - - func handleQuoteViewCancelButtonTapped() -} diff --git a/Session/Conversations/Message Cells/Content Views/VoiceMessageView.swift b/Session/Conversations/Message Cells/Content Views/VoiceMessageView.swift index 6a9ba9904..c96625dcb 100644 --- a/Session/Conversations/Message Cells/Content Views/VoiceMessageView.swift +++ b/Session/Conversations/Message Cells/Content Views/VoiceMessageView.swift @@ -1,78 +1,84 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit import NVActivityIndicatorView +import SessionUIKit +import SessionMessagingKit -@objc(SNVoiceMessageView) -public final class VoiceMessageView : UIView { - private let viewItem: ConversationViewItem - private var isShowingSpeedUpLabel = false - @objc var progress: Int = 0 { didSet { handleProgressChanged() } } - @objc var isPlaying = false { didSet { handleIsPlayingChanged() } } - +public final class VoiceMessageView: UIView { + private static let width: CGFloat = 160 + private static let toggleContainerSize: CGFloat = 20 + private static let inset = Values.smallSpacing + + // MARK: - UI + private lazy var progressViewRightConstraint = progressView.pin(.right, to: .right, of: self, withInset: -VoiceMessageView.width) - - private var attachment: TSAttachment? { viewItem.attachmentStream ?? viewItem.attachmentPointer } - private var duration: Int { Int(viewItem.audioDurationSeconds) } - - // MARK: UI Components + private lazy var progressView: UIView = { - let result = UIView() + let result: UIView = UIView() result.backgroundColor = UIColor.black.withAlphaComponent(0.2) + return result }() private lazy var toggleImageView: UIImageView = { - let result = UIImageView(image: UIImage(named: "Play")) + let result: UIImageView = UIImageView(image: UIImage(named: "Play")) + result.contentMode = .scaleAspectFit result.set(.width, to: 8) result.set(.height, to: 8) - result.contentMode = .scaleAspectFit + return result }() private lazy var loader: NVActivityIndicatorView = { - let result = NVActivityIndicatorView(frame: CGRect.zero, type: .circleStrokeSpin, color: Colors.text, padding: nil) + let result: NVActivityIndicatorView = NVActivityIndicatorView( + frame: .zero, + type: .circleStrokeSpin, + color: Colors.text, + padding: nil + ) result.set(.width, to: VoiceMessageView.toggleContainerSize + 2) result.set(.height, to: VoiceMessageView.toggleContainerSize + 2) + return result }() private lazy var countdownLabelContainer: UIView = { - let result = UIView() + let result: UIView = UIView() result.backgroundColor = .white result.layer.masksToBounds = true result.set(.height, to: VoiceMessageView.toggleContainerSize) result.set(.width, to: 44) + return result }() private lazy var countdownLabel: UILabel = { - let result = UILabel() + let result: UILabel = UILabel() result.textColor = .black result.font = .systemFont(ofSize: Values.smallFontSize) result.text = "0:00" + return result }() private lazy var speedUpLabel: UILabel = { - let result = UILabel() + let result: UILabel = UILabel() result.textColor = .black result.font = .systemFont(ofSize: Values.smallFontSize) result.alpha = 0 result.text = "1.5x" result.textAlignment = .center + return result }() - // MARK: Settings - private static let width: CGFloat = 160 - private static let toggleContainerSize: CGFloat = 20 - private static let inset = Values.smallSpacing - - // MARK: Lifecycle - init(viewItem: ConversationViewItem) { - self.viewItem = viewItem - self.progress = Int(viewItem.audioProgressSeconds) + // MARK: - Lifecycle + + init() { super.init(frame: CGRect.zero) + setUpViewHierarchy() - handleProgressChanged() } override init(frame: CGRect) { @@ -86,27 +92,33 @@ public final class VoiceMessageView : UIView { private func setUpViewHierarchy() { let toggleContainerSize = VoiceMessageView.toggleContainerSize let inset = VoiceMessageView.inset + // Width & height set(.width, to: VoiceMessageView.width) + // Toggle - let toggleContainer = UIView() + let toggleContainer: UIView = UIView() toggleContainer.backgroundColor = .white toggleContainer.set(.width, to: toggleContainerSize) toggleContainer.set(.height, to: toggleContainerSize) toggleContainer.addSubview(toggleImageView) toggleImageView.center(in: toggleContainer) - toggleContainer.layer.cornerRadius = toggleContainerSize / 2 + toggleContainer.layer.cornerRadius = (toggleContainerSize / 2) toggleContainer.layer.masksToBounds = true + // Line let lineView = UIView() lineView.backgroundColor = .white lineView.set(.height, to: 1) + // Countdown label countdownLabelContainer.addSubview(countdownLabel) countdownLabel.center(in: countdownLabelContainer) + // Speed up label countdownLabelContainer.addSubview(speedUpLabel) speedUpLabel.center(in: countdownLabelContainer) + // Constraints addSubview(progressView) progressView.pin(.left, to: .left, of: self) @@ -114,60 +126,73 @@ public final class VoiceMessageView : UIView { progressViewRightConstraint.isActive = true progressView.pin(.bottom, to: .bottom, of: self) addSubview(toggleContainer) + toggleContainer.pin(.left, to: .left, of: self, withInset: inset) toggleContainer.pin(.top, to: .top, of: self, withInset: inset) toggleContainer.pin(.bottom, to: .bottom, of: self, withInset: -inset) addSubview(lineView) + lineView.pin(.left, to: .right, of: toggleContainer) lineView.center(.vertical, in: self) addSubview(countdownLabelContainer) + countdownLabelContainer.pin(.left, to: .right, of: lineView) countdownLabelContainer.pin(.right, to: .right, of: self, withInset: -inset) countdownLabelContainer.center(.vertical, in: self) + addSubview(loader) loader.center(in: toggleContainer) } - // MARK: Updating public override func layoutSubviews() { super.layoutSubviews() - countdownLabelContainer.layer.cornerRadius = countdownLabelContainer.bounds.height / 2 + + countdownLabelContainer.layer.cornerRadius = (countdownLabelContainer.bounds.height / 2) } - private func handleIsPlayingChanged() { - toggleImageView.image = isPlaying ? UIImage(named: "Pause") : UIImage(named: "Play") - if !isPlaying { progress = 0 } - } - - private func handleProgressChanged() { - let isDownloaded = (attachment?.isDownloaded == true) - loader.isHidden = isDownloaded - if isDownloaded { loader.stopAnimating() } else if !loader.isAnimating { loader.startAnimating() } - guard isDownloaded else { return } - countdownLabel.text = OWSFormat.formatDurationSeconds(duration - progress) - guard viewItem.audioProgressSeconds > 0 && viewItem.audioDurationSeconds > 0 else { - return progressViewRightConstraint.constant = -VoiceMessageView.width - } - let fraction = viewItem.audioProgressSeconds / viewItem.audioDurationSeconds - progressViewRightConstraint.constant = -(VoiceMessageView.width * (1 - fraction)) - } - - func showSpeedUpLabel() { - guard !isShowingSpeedUpLabel else { return } - isShowingSpeedUpLabel = true - UIView.animate(withDuration: 0.25) { [weak self] in - guard let self = self else { return } - self.countdownLabel.alpha = 0 - self.speedUpLabel.alpha = 1 - } - Timer.scheduledTimer(withTimeInterval: 1.25, repeats: false) { [weak self] _ in - UIView.animate(withDuration: 0.25, animations: { - guard let self = self else { return } - self.countdownLabel.alpha = 1 - self.speedUpLabel.alpha = 0 - }, completion: { _ in - self?.isShowingSpeedUpLabel = false - }) + // MARK: - Updating + + public func update( + with attachment: Attachment, + isPlaying: Bool, + progress: TimeInterval, + playbackRate: Double, + oldPlaybackRate: Double + ) { + switch attachment.state { + case .downloaded, .uploaded: + loader.isHidden = true + loader.stopAnimating() + + toggleImageView.image = (isPlaying ? UIImage(named: "Pause") : UIImage(named: "Play")) + countdownLabel.text = OWSFormat.formatDurationSeconds(max(0, Int(floor(attachment.duration.defaulting(to: 0) - progress)))) + + guard let duration: TimeInterval = attachment.duration, duration > 0, progress > 0 else { + return progressViewRightConstraint.constant = -VoiceMessageView.width + } + + let fraction: Double = (progress / duration) + progressViewRightConstraint.constant = -(VoiceMessageView.width * (1 - fraction)) + + // If the playback rate changed then show the 'speedUpLabel' briefly + guard playbackRate > oldPlaybackRate else { return } + + UIView.animate(withDuration: 0.25) { [weak self] in + self?.countdownLabel.alpha = 0 + self?.speedUpLabel.alpha = 1 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1250)) { + UIView.animate(withDuration: 0.25) { [weak self] in + self?.countdownLabel.alpha = 1 + self?.speedUpLabel.alpha = 0 + } + } + + default: + if !loader.isAnimating { + loader.startAnimating() + } } } } diff --git a/Session/Conversations/Message Cells/InfoMessageCell.swift b/Session/Conversations/Message Cells/InfoMessageCell.swift index cb1ceb4ff..26476d18f 100644 --- a/Session/Conversations/Message Cells/InfoMessageCell.swift +++ b/Session/Conversations/Message Cells/InfoMessageCell.swift @@ -5,73 +5,87 @@ import SessionUIKit import SessionMessagingKit final class InfoMessageCell: MessageCell { + private static let iconSize: CGFloat = 16 + private static let inset = Values.mediumSpacing + + // MARK: - UI + private lazy var iconImageViewWidthConstraint = iconImageView.set(.width, to: InfoMessageCell.iconSize) private lazy var iconImageViewHeightConstraint = iconImageView.set(.height, to: InfoMessageCell.iconSize) - // MARK: UI Components - private lazy var iconImageView = UIImageView() - + private lazy var iconImageView: UIImageView = UIImageView() + private lazy var label: UILabel = { - let result = UILabel() + let result: UILabel = UILabel() result.numberOfLines = 0 result.lineBreakMode = .byWordWrapping result.font = .boldSystemFont(ofSize: Values.verySmallFontSize) result.textColor = Colors.text result.textAlignment = .center + return result }() - + private lazy var stackView: UIStackView = { - let result = UIStackView(arrangedSubviews: [ iconImageView, label ]) + let result: UIStackView = UIStackView(arrangedSubviews: [ iconImageView, label ]) result.axis = .vertical result.alignment = .center result.spacing = Values.smallSpacing + return result }() + + // MARK: - Lifecycle - // MARK: Settings - private static let iconSize: CGFloat = 16 - private static let inset = Values.mediumSpacing - - override class var identifier: String { "InfoMessageCell" } - - // MARK: Lifecycle override func setUpViewHierarchy() { super.setUpViewHierarchy() + iconImageViewWidthConstraint.isActive = true iconImageViewHeightConstraint.isActive = true addSubview(stackView) + stackView.pin(.left, to: .left, of: self, withInset: InfoMessageCell.inset) stackView.pin(.top, to: .top, of: self, withInset: InfoMessageCell.inset) stackView.pin(.right, to: .right, of: self, withInset: -InfoMessageCell.inset) stackView.pin(.bottom, to: .bottom, of: self, withInset: -InfoMessageCell.inset) } + + // MARK: - Updating - // MARK: Updating - override func update() { - guard let message = viewItem?.interaction as? TSInfoMessage else { return } - let icon: UIImage? - switch message.messageType { - case .disappearingMessagesUpdate: - var configuration: SessionMessagingKit.Legacy.DisappearingMessagesConfiguration? - Storage.read { transaction in - configuration = message.thread(with: transaction).disappearingMessagesConfiguration(with: transaction) - } - if let configuration = configuration { - icon = configuration.isEnabled ? UIImage(named: "ic_timer") : UIImage(named: "ic_timer_disabled") - } else { - icon = nil - } - case .mediaSavedNotification: icon = UIImage(named: "ic_download") - default: icon = nil + override func update(with item: ConversationViewModel.Item, mediaCache: NSCache, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) { + switch item.interactionVariant { + case .infoClosedGroupCreated, .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft, + .infoDisappearingMessagesUpdate, .infoScreenshotNotification, .infoMediaSavedNotification, + .infoMessageRequestAccepted: + break + + default: return // Ignore non-info variants } + + let icon: UIImage? = { + switch item.interactionVariant { + case .infoDisappearingMessagesUpdate: + return (item.threadHasDisappearingMessagesEnabled ? + UIImage(named: "ic_timer") : + UIImage(named: "ic_timer_disabled") + ) + + case .infoMediaSavedNotification: return UIImage(named: "ic_download") + + default: return nil + } + }() + if let icon = icon { iconImageView.image = icon.withTint(Colors.text) } + iconImageViewWidthConstraint.constant = (icon != nil) ? InfoMessageCell.iconSize : 0 iconImageViewHeightConstraint.constant = (icon != nil) ? InfoMessageCell.iconSize : 0 - Storage.read { transaction in - self.label.text = message.previewText(with: transaction) - } + + self.label.text = item.body + } + + override func dynamicUpdate(with item: ConversationViewModel.Item, playbackInfo: ConversationViewModel.PlaybackInfo?) { } } diff --git a/Session/Conversations/Message Cells/MessageCell.swift b/Session/Conversations/Message Cells/MessageCell.swift index 889e3c19d..f7675242d 100644 --- a/Session/Conversations/Message Cells/MessageCell.swift +++ b/Session/Conversations/Message Cells/MessageCell.swift @@ -1,3 +1,5 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + import UIKit import SessionMessagingKit @@ -7,59 +9,63 @@ public enum SwipeState { case cancelled } -class MessageCell : UITableViewCell { +class MessageCell: UITableViewCell { weak var delegate: MessageCellDelegate? - var thread: TSThread? { - didSet { - if viewItem != nil { update() } - } - } - var viewItem: ConversationViewItem? { - didSet { - if thread != nil { update() } - } - } + var item: ConversationViewModel.Item? + + // MARK: - Lifecycle - // MARK: Settings - class var identifier: String { preconditionFailure("Must be overridden by subclasses.") } - - // MARK: Lifecycle override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) + setUpViewHierarchy() setUpGestureRecognizers() } required init?(coder: NSCoder) { super.init(coder: coder) + setUpViewHierarchy() setUpGestureRecognizers() } func setUpViewHierarchy() { backgroundColor = .clear + let selectedBackgroundView = UIView() selectedBackgroundView.backgroundColor = .clear self.selectedBackgroundView = selectedBackgroundView } - + func setUpGestureRecognizers() { // To be overridden by subclasses } + + // MARK: - Updating - // MARK: Updating - func update() { + func update(with item: ConversationViewModel.Item, mediaCache: NSCache, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) { preconditionFailure("Must be overridden by subclasses.") } - // MARK: Convenience - static func getCellType(for viewItem: ConversationViewItem) -> MessageCell.Type { - switch viewItem.interaction { - case is TSIncomingMessage: fallthrough - case is TSOutgoingMessage: return VisibleMessageCell.self - case is TSInfoMessage: return InfoMessageCell.self - case is TypingIndicatorInteraction: return TypingIndicatorCell.self - default: preconditionFailure() + /// This is a cut-down version of the 'update' function which doesn't re-create the UI (it should be used for dynamically-updating content + /// like playing inline audio/video) + func dynamicUpdate(with item: ConversationViewModel.Item, playbackInfo: ConversationViewModel.PlaybackInfo?) { + preconditionFailure("Must be overridden by subclasses.") + } + + // MARK: - Convenience + + static func cellType(for item: ConversationViewModel.Item) -> MessageCell.Type { + guard item.cellType != .typingIndicator else { return TypingIndicatorCell.self } + + switch item.interactionVariant { + case .standardOutgoing, .standardIncoming, .standardIncomingDeleted: + return VisibleMessageCell.self + + case .infoClosedGroupCreated, .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft, + .infoDisappearingMessagesUpdate, .infoScreenshotNotification, .infoMediaSavedNotification, + .infoMessageRequestAccepted: + return InfoMessageCell.self } } } diff --git a/Session/Conversations/Message Cells/TypingIndicatorCell.swift b/Session/Conversations/Message Cells/TypingIndicatorCell.swift index 87be75649..3650fbf39 100644 --- a/Session/Conversations/Message Cells/TypingIndicatorCell.swift +++ b/Session/Conversations/Message Cells/TypingIndicatorCell.swift @@ -1,85 +1,94 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SessionUIKit +import SessionMessagingKit // Assumptions // • We'll never encounter an outgoing typing indicator. // • Typing indicators are only sent in contact threads. - -final class TypingIndicatorCell : MessageCell { - - private var positionInCluster: Position? { - guard let viewItem = viewItem else { return nil } - if viewItem.isFirstInCluster { return .top } - if viewItem.isLastInCluster { return .bottom } - return .middle - } +final class TypingIndicatorCell: MessageCell { + // MARK: - UI - private var isOnlyMessageInCluster: Bool { viewItem?.isFirstInCluster == true && viewItem?.isLastInCluster == true } - - // MARK: UI Components private lazy var bubbleView: UIView = { - let result = UIView() + let result: UIView = UIView() result.layer.cornerRadius = VisibleMessageCell.smallCornerRadius result.backgroundColor = Colors.receivedMessageBackground + return result }() - private let bubbleViewMaskLayer = CAShapeLayer() + private let bubbleViewMaskLayer: CAShapeLayer = CAShapeLayer() - private lazy var typingIndicatorView = TypingIndicatorView() + private lazy var typingIndicatorView: TypingIndicatorView = TypingIndicatorView() - // MARK: Settings - override class var identifier: String { "TypingIndicatorCell" } - - // MARK: Direction & Position - enum Position { case top, middle, bottom } - - // MARK: Lifecycle + // MARK: - Lifecycle + override func setUpViewHierarchy() { super.setUpViewHierarchy() + // Bubble view addSubview(bubbleView) bubbleView.pin(.left, to: .left, of: self, withInset: VisibleMessageCell.contactThreadHSpacing) bubbleView.pin(.top, to: .top, of: self, withInset: 1) + // Typing indicator view bubbleView.addSubview(typingIndicatorView) typingIndicatorView.pin(to: bubbleView, withInset: 12) } - // MARK: Updating - override func update() { - guard let viewItem = viewItem, viewItem.interaction is TypingIndicatorInteraction else { return } + // MARK: - Updating + + override func update(with item: ConversationViewModel.Item, mediaCache: NSCache, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) { + guard item.cellType == .typingIndicator else { return } + + self.item = item + // Bubble view updateBubbleViewCorners() + // Typing indicator view typingIndicatorView.startAnimation() } + + override func dynamicUpdate(with item: ConversationViewModel.Item, playbackInfo: ConversationViewModel.PlaybackInfo?) { + } override func layoutSubviews() { super.layoutSubviews() + updateBubbleViewCorners() } private func updateBubbleViewCorners() { - let maskPath = UIBezierPath(roundedRect: bubbleView.bounds, byRoundingCorners: getCornersToRound(), - cornerRadii: CGSize(width: VisibleMessageCell.largeCornerRadius, height: VisibleMessageCell.largeCornerRadius)) + let maskPath = UIBezierPath( + roundedRect: bubbleView.bounds, + byRoundingCorners: getCornersToRound(), + cornerRadii: CGSize( + width: VisibleMessageCell.largeCornerRadius, + height: VisibleMessageCell.largeCornerRadius) + ) + bubbleViewMaskLayer.path = maskPath.cgPath bubbleView.layer.mask = bubbleViewMaskLayer } override func prepareForReuse() { super.prepareForReuse() + typingIndicatorView.stopAnimation() } - // MARK: Convenience + // MARK: - Convenience + private func getCornersToRound() -> UIRectCorner { - guard !isOnlyMessageInCluster else { return .allCorners } - let result: UIRectCorner - switch positionInCluster { - case .top: result = [ .topLeft, .topRight, .bottomRight ] - case .middle: result = [ .topRight, .bottomRight ] - case .bottom: result = [ .topRight, .bottomRight, .bottomLeft ] - case nil: result = .allCorners + guard item?.isOnlyMessageInCluster == false else { return .allCorners } + + switch item?.positionInCluster { + case .top: return [ .topLeft, .topRight, .bottomRight ] + case .middle: return [ .topRight, .bottomRight ] + case .bottom: return [ .topRight, .bottomRight, .bottomLeft ] + case .none: return .allCorners } - return result } } diff --git a/Session/Conversations/Views & Modals/BodyTextView.swift b/Session/Conversations/Views & Modals/BodyTextView.swift index 271bd71d6..d329bd972 100644 --- a/Session/Conversations/Views & Modals/BodyTextView.swift +++ b/Session/Conversations/Views & Modals/BodyTextView.swift @@ -3,18 +3,13 @@ import UIKit // Requirements: -// • Links should show up properly and be tappable. -// • Text should * not * be selectable. -// • The long press interaction that shows the context menu should still work. - +// • Links should show up properly and be tappable +// • Text should * not * be selectable (this is handled via the 'textViewDidChangeSelection(_:)' +// delegate method) +// • The long press interaction that shows the context menu should still work final class BodyTextView: UITextView { private let snDelegate: BodyTextViewDelegate? - override var selectedTextRange: UITextRange? { - get { return nil } - set { } - } - init(snDelegate: BodyTextViewDelegate?) { self.snDelegate = snDelegate super.init(frame: CGRect.zero, textContainer: nil) diff --git a/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift b/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift index 5dbbebce9..15d959f50 100644 --- a/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift +++ b/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift @@ -9,10 +9,12 @@ final class JoinOpenGroupModal: Modal { private let name: String private let url: String - // MARK: Lifecycle - init(name: String, url: String) { - self.name = name + // MARK: - Lifecycle + + init(name: String?, url: String) { + self.name = (name ?? "Open Group") self.url = url + super.init(nibName: nil, bundle: nil) } @@ -31,6 +33,7 @@ final class JoinOpenGroupModal: Modal { titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize) titleLabel.text = "Join \(name)?" titleLabel.textAlignment = .center + // Message let messageLabel = UILabel() messageLabel.textColor = Colors.text @@ -42,6 +45,7 @@ final class JoinOpenGroupModal: Modal { messageLabel.numberOfLines = 0 messageLabel.lineBreakMode = .byWordWrapping messageLabel.textAlignment = .center + // Join button let joinButton = UIButton() joinButton.set(.height, to: Values.mediumButtonHeight) @@ -51,11 +55,13 @@ final class JoinOpenGroupModal: Modal { joinButton.setTitleColor(Colors.text, for: UIControl.State.normal) joinButton.setTitle("Join", for: UIControl.State.normal) joinButton.addTarget(self, action: #selector(joinOpenGroup), for: UIControl.Event.touchUpInside) + // Button stack view let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, joinButton ]) buttonStackView.axis = .horizontal buttonStackView.spacing = Values.mediumSpacing buttonStackView.distribution = .fillEqually + // Main stack view let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ]) mainStackView.axis = .vertical @@ -67,7 +73,8 @@ final class JoinOpenGroupModal: Modal { contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing) } - // MARK: Interaction + // MARK: - Interaction + @objc private func joinOpenGroup() { guard let presentingViewController: UIViewController = self.presentingViewController else { return } guard let (room, server, publicKey) = OpenGroupManagerV2.parseV2OpenGroup(from: url) else { diff --git a/Session/Conversations/Views & Modals/URLModal.swift b/Session/Conversations/Views & Modals/URLModal.swift index 3280b01bc..ea3170c3a 100644 --- a/Session/Conversations/Views & Modals/URLModal.swift +++ b/Session/Conversations/Views & Modals/URLModal.swift @@ -1,8 +1,13 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -final class URLModal : Modal { +import UIKit +import SessionUIKit + +final class URLModal: Modal { private let url: URL - // MARK: Lifecycle + // MARK: - Lifecycle + init(url: URL) { self.url = url super.init(nibName: nil, bundle: nil) @@ -23,6 +28,7 @@ final class URLModal : Modal { titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize) titleLabel.text = NSLocalizedString("modal_open_url_title", comment: "") titleLabel.textAlignment = .center + // Message let messageLabel = UILabel() messageLabel.textColor = Colors.text @@ -34,6 +40,7 @@ final class URLModal : Modal { messageLabel.numberOfLines = 0 messageLabel.lineBreakMode = .byWordWrapping messageLabel.textAlignment = .center + // Open button let openButton = UIButton() openButton.set(.height, to: Values.mediumButtonHeight) @@ -42,12 +49,14 @@ final class URLModal : Modal { openButton.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize) openButton.setTitleColor(Colors.text, for: UIControl.State.normal) openButton.setTitle(NSLocalizedString("modal_open_url_button_title", comment: ""), for: UIControl.State.normal) - openButton.addTarget(self, action: #selector(openURL), for: UIControl.Event.touchUpInside) + openButton.addTarget(self, action: #selector(openUrl), for: UIControl.Event.touchUpInside) + // Button stack view let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, openButton ]) buttonStackView.axis = .horizontal buttonStackView.spacing = Values.mediumSpacing buttonStackView.distribution = .fillEqually + // Main stack view let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ]) mainStackView.axis = .vertical @@ -59,9 +68,11 @@ final class URLModal : Modal { contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing) } - // MARK: Interaction - @objc private func openURL() { + // MARK: - Interaction + + @objc private func openUrl() { let url = self.url + presentingViewController?.dismiss(animated: true, completion: { UIApplication.shared.open(url, options: [:], completionHandler: nil) }) diff --git a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift index 714c6c5de..5b8ea6b1c 100644 --- a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift +++ b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift @@ -64,7 +64,7 @@ public enum Legacy { // MARK: - Types (and NSCoding) @objc(SNContact) - public class Contact: NSObject, NSCoding { + public class _Contact: NSObject, NSCoding { @objc public let sessionID: String @objc public var profilePictureURL: String? @objc public var profilePictureFileName: String? @@ -104,7 +104,7 @@ public enum Legacy { } @objc(OWSDisappearingMessagesConfiguration) - internal class DisappearingMessagesConfiguration: MTLModel { + internal class _DisappearingMessagesConfiguration: MTLModel { @objc public let uniqueId: String @objc public var isEnabled: Bool @objc public var durationSeconds: UInt32 @@ -133,7 +133,7 @@ public enum Legacy { /// Abstract base class for `VisibleMessage` and `ControlMessage`. @objc(SNMessage) - internal class Message: NSObject, NSCoding { + internal class _Message: NSObject, NSCoding { internal var id: String? internal var threadID: String? internal var sentTimestamp: UInt64? @@ -166,8 +166,8 @@ public enum Legacy { // MARK: Non-Legacy Conversion - internal func toNonLegacy(_ instance: SessionMessagingKit.Message? = nil) throws -> SessionMessagingKit.Message { - let result: SessionMessagingKit.Message = (instance ?? SessionMessagingKit.Message()) + internal func toNonLegacy(_ instance: Message? = nil) throws -> Message { + let result: Message = (instance ?? Message()) result.id = self.id result.threadId = self.threadID result.sentTimestamp = self.sentTimestamp @@ -184,14 +184,14 @@ public enum Legacy { } @objc(SNVisibleMessage) - internal final class VisibleMessage: Message { + internal final class _VisibleMessage: _Message { internal var syncTarget: String? internal var text: String? internal var attachmentIDs: [String] = [] - internal var quote: Quote? - internal var linkPreview: LinkPreview? - internal var profile: Profile? - internal var openGroupInvitation: OpenGroupInvitation? + internal var quote: _Quote? + internal var linkPreview: _LinkPreview? + internal var profile: _Profile? + internal var openGroupInvitation: _OpenGroupInvitation? // MARK: - NSCoding @@ -201,10 +201,10 @@ public enum Legacy { if let syncTarget = coder.decodeObject(forKey: "syncTarget") as! String? { self.syncTarget = syncTarget } if let text = coder.decodeObject(forKey: "body") as! String? { self.text = text } if let attachmentIDs = coder.decodeObject(forKey: "attachments") as! [String]? { self.attachmentIDs = attachmentIDs } - if let quote = coder.decodeObject(forKey: "quote") as! Quote? { self.quote = quote } - if let linkPreview = coder.decodeObject(forKey: "linkPreview") as! LinkPreview? { self.linkPreview = linkPreview } - if let profile = coder.decodeObject(forKey: "profile") as! Profile? { self.profile = profile } - if let openGroupInvitation = coder.decodeObject(forKey: "openGroupInvitation") as! OpenGroupInvitation? { self.openGroupInvitation = openGroupInvitation } + if let quote = coder.decodeObject(forKey: "quote") as! _Quote? { self.quote = quote } + if let linkPreview = coder.decodeObject(forKey: "linkPreview") as! _LinkPreview? { self.linkPreview = linkPreview } + if let profile = coder.decodeObject(forKey: "profile") as! _Profile? { self.profile = profile } + if let openGroupInvitation = coder.decodeObject(forKey: "openGroupInvitation") as! _OpenGroupInvitation? { self.openGroupInvitation = openGroupInvitation } } public override func encode(with coder: NSCoder) { @@ -213,9 +213,9 @@ public enum Legacy { // MARK: Non-Legacy Conversion - override internal func toNonLegacy(_ instance: SessionMessagingKit.Message? = nil) throws -> SessionMessagingKit.Message { + override internal func toNonLegacy(_ instance: Message? = nil) throws -> Message { return try super.toNonLegacy( - SessionMessagingKit.VisibleMessage( + VisibleMessage( syncTarget: syncTarget, text: text, attachmentIds: attachmentIDs, @@ -229,7 +229,7 @@ public enum Legacy { } @objc(SNQuote) - internal class Quote: NSObject, NSCoding { + internal class _Quote: NSObject, NSCoding { internal var timestamp: UInt64? internal var publicKey: String? internal var text: String? @@ -250,8 +250,8 @@ public enum Legacy { // MARK: Non-Legacy Conversion - internal func toNonLegacy() -> SessionMessagingKit.VisibleMessage.Quote { - return SessionMessagingKit.VisibleMessage.Quote( + internal func toNonLegacy() -> VisibleMessage.VMQuote { + return VisibleMessage.VMQuote( timestamp: (timestamp ?? 0), publicKey: (publicKey ?? ""), text: text, @@ -261,7 +261,7 @@ public enum Legacy { } @objc(SNLinkPreview) - internal class LinkPreview: NSObject, NSCoding { + internal class _LinkPreview: NSObject, NSCoding { internal var title: String? internal var url: String? internal var attachmentID: String? @@ -280,8 +280,8 @@ public enum Legacy { // MARK: Non-Legacy Conversion - internal func toNonLegacy() -> SessionMessagingKit.VisibleMessage.LinkPreview { - return SessionMessagingKit.VisibleMessage.LinkPreview( + internal func toNonLegacy() -> VisibleMessage.VMLinkPreview { + return VisibleMessage.VMLinkPreview( title: title, url: (url ?? ""), attachmentId: attachmentID @@ -290,7 +290,7 @@ public enum Legacy { } @objc(SNProfile) - internal class Profile: NSObject, NSCoding { + internal class _Profile: NSObject, NSCoding { internal var displayName: String? internal var profileKey: Data? internal var profilePictureURL: String? @@ -309,8 +309,8 @@ public enum Legacy { // MARK: Non-Legacy Conversion - internal func toNonLegacy() -> SessionMessagingKit.VisibleMessage.Profile { - return SessionMessagingKit.VisibleMessage.Profile( + internal func toNonLegacy() -> VisibleMessage.VMProfile { + return VisibleMessage.VMProfile( displayName: (displayName ?? ""), profileKey: profileKey, profilePictureUrl: profilePictureURL @@ -319,7 +319,7 @@ public enum Legacy { } @objc(SNOpenGroupInvitation) - internal class OpenGroupInvitation: NSObject, NSCoding { + internal class _OpenGroupInvitation: NSObject, NSCoding { internal var name: String? internal var url: String? @@ -336,8 +336,8 @@ public enum Legacy { // MARK: Non-Legacy Conversion - internal func toNonLegacy() -> SessionMessagingKit.VisibleMessage.OpenGroupInvitation { - return SessionMessagingKit.VisibleMessage.OpenGroupInvitation( + internal func toNonLegacy() -> VisibleMessage.VMOpenGroupInvitation { + return VisibleMessage.VMOpenGroupInvitation( name: (name ?? ""), url: (url ?? "") ) @@ -345,10 +345,10 @@ public enum Legacy { } @objc(SNControlMessage) - internal class ControlMessage: Message {} + internal class _ControlMessage: _Message {} @objc(SNReadReceipt) - internal final class ReadReceipt: ControlMessage { + internal final class _ReadReceipt: _ControlMessage { internal var timestamps: [UInt64]? // MARK: - NSCoding @@ -364,9 +364,9 @@ public enum Legacy { // MARK: Non-Legacy Conversion - override internal func toNonLegacy(_ instance: SessionMessagingKit.Message? = nil) throws -> SessionMessagingKit.Message { + override internal func toNonLegacy(_ instance: Message? = nil) throws -> Message { return try super.toNonLegacy( - SessionMessagingKit.ReadReceipt( + ReadReceipt( timestamps: (timestamps ?? []) ) ) @@ -374,7 +374,7 @@ public enum Legacy { } @objc(SNTypingIndicator) - internal final class TypingIndicator: ControlMessage { + internal final class _TypingIndicator: _ControlMessage { public var rawKind: Int? // MARK: - NSCoding @@ -391,11 +391,11 @@ public enum Legacy { // MARK: Non-Legacy Conversion - override internal func toNonLegacy(_ instance: SessionMessagingKit.Message? = nil) throws -> SessionMessagingKit.Message { + override internal func toNonLegacy(_ instance: Message? = nil) throws -> Message { return try super.toNonLegacy( - SessionMessagingKit.TypingIndicator( - kind: SessionMessagingKit.TypingIndicator.Kind( - rawValue: (rawKind ?? SessionMessagingKit.TypingIndicator.Kind.stopped.rawValue) + TypingIndicator( + kind: TypingIndicator.Kind( + rawValue: (rawKind ?? TypingIndicator.Kind.stopped.rawValue) ) .defaulting(to: .stopped) ) @@ -404,11 +404,11 @@ public enum Legacy { } @objc(SNClosedGroupControlMessage) - internal final class ClosedGroupControlMessage: ControlMessage { + internal final class _ClosedGroupControlMessage: _ControlMessage { internal var rawKind: String? internal var publicKey: Data? - internal var wrappers: [KeyPairWrapper]? + internal var wrappers: [_KeyPairWrapper]? internal var name: String? internal var encryptionKeyPair: SUKLegacy.KeyPair? internal var members: [Data]? @@ -418,7 +418,7 @@ public enum Legacy { // MARK: - Key Pair Wrapper @objc(SNKeyPairWrapper) - internal final class KeyPairWrapper: NSObject, NSCoding { + internal final class _KeyPairWrapper: NSObject, NSCoding { internal var publicKey: String? internal var encryptedKeyPair: Data? @@ -440,7 +440,7 @@ public enum Legacy { self.rawKind = coder.decodeObject(forKey: "kind") as? String self.publicKey = coder.decodeObject(forKey: "publicKey") as? Data - self.wrappers = coder.decodeObject(forKey: "wrappers") as? [KeyPairWrapper] + self.wrappers = coder.decodeObject(forKey: "wrappers") as? [_KeyPairWrapper] self.name = coder.decodeObject(forKey: "name") as? String self.encryptionKeyPair = coder.decodeObject(forKey: "encryptionKeyPair") as? SUKLegacy.KeyPair self.members = coder.decodeObject(forKey: "members") as? [Data] @@ -456,9 +456,9 @@ public enum Legacy { // MARK: Non-Legacy Conversion - override internal func toNonLegacy(_ instance: SessionMessagingKit.Message? = nil) throws -> SessionMessagingKit.Message { + override internal func toNonLegacy(_ instance: Message? = nil) throws -> Message { return try super.toNonLegacy( - SessionMessagingKit.ClosedGroupControlMessage( + ClosedGroupControlMessage( kind: try { switch rawKind { case "new": @@ -486,7 +486,7 @@ public enum Legacy { ) case "encryptionKeyPair": - guard let wrappers: [KeyPairWrapper] = self.wrappers else { + guard let wrappers: [_KeyPairWrapper] = self.wrappers else { SNLog("[Migration Error] Unable to decode Legacy ClosedGroupControlMessage") throw GRDBStorageError.migrationFailed } @@ -546,7 +546,7 @@ public enum Legacy { } @objc(SNDataExtractionNotification) - internal final class DataExtractionNotification: ControlMessage { + internal final class _DataExtractionNotification: _ControlMessage { internal let rawKind: String? internal let timestamp: UInt64? @@ -565,9 +565,9 @@ public enum Legacy { // MARK: Non-Legacy Conversion - override internal func toNonLegacy(_ instance: SessionMessagingKit.Message? = nil) throws -> SessionMessagingKit.Message { + override internal func toNonLegacy(_ instance: Message? = nil) throws -> Message { return try super.toNonLegacy( - SessionMessagingKit.DataExtractionNotification( + DataExtractionNotification( kind: try { switch rawKind { case "screenshot": return .screenshot @@ -588,7 +588,7 @@ public enum Legacy { } @objc(SNExpirationTimerUpdate) - internal final class ExpirationTimerUpdate: ControlMessage { + internal final class _ExpirationTimerUpdate: _ControlMessage { internal var syncTarget: String? internal var duration: UInt32? @@ -606,9 +606,9 @@ public enum Legacy { // MARK: Non-Legacy Conversion - override internal func toNonLegacy(_ instance: SessionMessagingKit.Message? = nil) throws -> SessionMessagingKit.Message { + override internal func toNonLegacy(_ instance: Message? = nil) throws -> Message { return try super.toNonLegacy( - SessionMessagingKit.ExpirationTimerUpdate( + ExpirationTimerUpdate( syncTarget: syncTarget, duration: (duration ?? 0) ) @@ -617,24 +617,24 @@ public enum Legacy { } @objc(SNConfigurationMessage) - internal final class ConfigurationMessage: ControlMessage { - internal var closedGroups: Set = [] + internal final class _ConfigurationMessage: _ControlMessage { + internal var closedGroups: Set<_CMClosedGroup> = [] internal var openGroups: Set = [] internal var displayName: String? internal var profilePictureURL: String? internal var profileKey: Data? - internal var contacts: Set = [] + internal var contacts: Set<_CMContact> = [] // MARK: - NSCoding public required init?(coder: NSCoder) { super.init(coder: coder) - if let closedGroups = coder.decodeObject(forKey: "closedGroups") as! Set? { self.closedGroups = closedGroups } + if let closedGroups = coder.decodeObject(forKey: "closedGroups") as! Set<_CMClosedGroup>? { self.closedGroups = closedGroups } if let openGroups = coder.decodeObject(forKey: "openGroups") as! Set? { self.openGroups = openGroups } if let displayName = coder.decodeObject(forKey: "displayName") as! String? { self.displayName = displayName } if let profilePictureURL = coder.decodeObject(forKey: "profilePictureURL") as! String? { self.profilePictureURL = profilePictureURL } if let profileKey = coder.decodeObject(forKey: "profileKey") as! Data? { self.profileKey = profileKey } - if let contacts = coder.decodeObject(forKey: "contacts") as! Set? { self.contacts = contacts } + if let contacts = coder.decodeObject(forKey: "contacts") as! Set<_CMContact>? { self.contacts = contacts } } public override func encode(with coder: NSCoder) { @@ -643,9 +643,9 @@ public enum Legacy { // MARK: Non-Legacy Conversion - override internal func toNonLegacy(_ instance: SessionMessagingKit.Message? = nil) throws -> SessionMessagingKit.Message { + override internal func toNonLegacy(_ instance: Message? = nil) throws -> Message { return try super.toNonLegacy( - SessionMessagingKit.ConfigurationMessage( + ConfigurationMessage( displayName: displayName, profilePictureUrl: profilePictureURL, profileKey: profileKey, @@ -662,7 +662,7 @@ public enum Legacy { } @objc(CMClosedGroup) - internal final class CMClosedGroup: NSObject, NSCoding { + internal final class _CMClosedGroup: NSObject, NSCoding { internal let publicKey: String internal let name: String internal let encryptionKeyPair: SUKLegacy.KeyPair @@ -695,8 +695,8 @@ public enum Legacy { // MARK: Non-Legacy Conversion - internal func toNonLegacy() -> SessionMessagingKit.ConfigurationMessage.CMClosedGroup { - return SessionMessagingKit.ConfigurationMessage.CMClosedGroup( + internal func toNonLegacy() -> ConfigurationMessage.CMClosedGroup { + return ConfigurationMessage.CMClosedGroup( publicKey: publicKey, name: name, encryptionKeyPublicKey: encryptionKeyPair.publicKey, @@ -709,7 +709,7 @@ public enum Legacy { } @objc(SNConfigurationMessageContact) - internal final class CMContact: NSObject, NSCoding { + internal final class _CMContact: NSObject, NSCoding { internal var publicKey: String? internal var displayName: String? internal var profilePictureURL: String? @@ -748,8 +748,8 @@ public enum Legacy { // MARK: Non-Legacy Conversion - internal func toNonLegacy() -> SessionMessagingKit.ConfigurationMessage.CMContact { - return SessionMessagingKit.ConfigurationMessage.CMContact( + internal func toNonLegacy() -> ConfigurationMessage.CMContact { + return ConfigurationMessage.CMContact( publicKey: publicKey, displayName: displayName, profilePictureUrl: profilePictureURL, @@ -765,7 +765,7 @@ public enum Legacy { } @objc(SNUnsendRequest) - internal final class UnsendRequest: ControlMessage { + internal final class _UnsendRequest: _ControlMessage { internal var timestamp: UInt64? internal var author: String? @@ -784,9 +784,9 @@ public enum Legacy { // MARK: Non-Legacy Conversion - override internal func toNonLegacy(_ instance: SessionMessagingKit.Message? = nil) throws -> SessionMessagingKit.Message { + override internal func toNonLegacy(_ instance: Message? = nil) throws -> Message { return try super.toNonLegacy( - SessionMessagingKit.UnsendRequest( + UnsendRequest( timestamp: (timestamp ?? 0), author: (author ?? "") ) @@ -795,7 +795,7 @@ public enum Legacy { } @objc(SNMessageRequestResponse) - internal final class MessageRequestResponse: ControlMessage { + internal final class _MessageRequestResponse: _ControlMessage { internal var isApproved: Bool // MARK: - NSCoding @@ -812,9 +812,9 @@ public enum Legacy { // MARK: Non-Legacy Conversion - override internal func toNonLegacy(_ instance: SessionMessagingKit.Message? = nil) throws -> SessionMessagingKit.Message { + override internal func toNonLegacy(_ instance: Message? = nil) throws -> Message { return try super.toNonLegacy( - SessionMessagingKit.MessageRequestResponse( + MessageRequestResponse( isApproved: isApproved ) ) @@ -824,9 +824,9 @@ public enum Legacy { // MARK: - Attachments @objc(TSAttachment) - internal class Attachment: NSObject, NSCoding { + internal class _Attachment: NSObject, NSCoding { @objc(TSAttachmentType) - public enum AttachmentType: Int { + public enum _AttachmentType: Int { case `default` case voiceMessage } @@ -835,7 +835,7 @@ public enum Legacy { @objc public var encryptionKey: Data? @objc public var contentType: String @objc public var isDownloaded: Bool - @objc public var attachmentType: AttachmentType + @objc public var attachmentType: _AttachmentType @objc public var downloadURL: String @objc public var byteCount: UInt32 @objc public var sourceFilename: String? @@ -856,7 +856,7 @@ public enum Legacy { self.encryptionKey = coder.decodeObject(forKey: "encryptionKey") as? Data self.contentType = coder.decodeObject(forKey: "contentType") as! String self.isDownloaded = (coder.decodeObject(forKey: "isDownloaded") as? Bool == true) - self.attachmentType = AttachmentType( + self.attachmentType = _AttachmentType( rawValue: (coder.decodeObject(forKey: "attachmentType") as! NSNumber).intValue ).defaulting(to: .default) self.downloadURL = (coder.decodeObject(forKey: "downloadURL") as? String ?? "") @@ -869,15 +869,15 @@ public enum Legacy { } @objc(TSAttachmentPointer) - internal class AttachmentPointer: Attachment { + internal class _AttachmentPointer: _Attachment { @objc(TSAttachmentPointerState) - public enum State: Int { + public enum _State: Int { case enqueued case downloading case failed } - @objc public var state: State + @objc public var state: _State @objc public var mostRecentFailureLocalizedText: String? @objc public var digest: Data? @objc public var mediaSize: CGSize @@ -886,7 +886,7 @@ public enum Legacy { // MARK: - NSCoder public required init(coder: NSCoder) { - self.state = State( + self.state = _State( rawValue: coder.decodeObject(forKey: "state") as! Int ).defaulting(to: .failed) self.mostRecentFailureLocalizedText = coder.decodeObject(forKey: "mostRecentFailureLocalizedText") as? String @@ -903,7 +903,7 @@ public enum Legacy { } @objc(TSAttachmentStream) - internal class AttachmentStream: Attachment { + internal class _AttachmentStream: _Attachment { @objc public var digest: Data? @objc public var isUploaded: Bool @objc public var creationTimestamp: Date @@ -947,9 +947,9 @@ public enum Legacy { } @objc(NotifyPNServerJob) - internal final class NotifyPNServerJob: NSObject, NSCoding { + internal final class _NotifyPNServerJob: NSObject, NSCoding { @objc(SnodeMessage) - internal final class SnodeMessage: NSObject, NSCoding { + internal final class _SnodeMessage: NSObject, NSCoding { public let recipient: String public let data: LosslessStringConvertible public let ttl: UInt64 @@ -978,7 +978,7 @@ public enum Legacy { } } - public let message: SnodeMessage + public let message: _SnodeMessage public var id: String? public var failureCount: UInt = 0 @@ -986,7 +986,7 @@ public enum Legacy { public init?(coder: NSCoder) { guard - let message = coder.decodeObject(forKey: "message") as! SnodeMessage?, + let message = coder.decodeObject(forKey: "message") as! _SnodeMessage?, let id = coder.decodeObject(forKey: "id") as! String? else { return nil } @@ -1001,7 +1001,7 @@ public enum Legacy { } @objc(MessageReceiveJob) - public final class MessageReceiveJob: NSObject, NSCoding { + public final class _MessageReceiveJob: NSObject, NSCoding { public let data: Data public let serverHash: String? public let openGroupMessageServerID: UInt64? @@ -1037,29 +1037,29 @@ public enum Legacy { } @objc(SNMessageSendJob) - internal final class MessageSendJob: NSObject, NSCoding { - internal let message: Message - internal let destination: SessionMessagingKit.Message.Destination + internal final class _MessageSendJob: NSObject, NSCoding { + internal let message: _Message + internal let destination: Message.Destination internal var id: String? internal var failureCount: UInt = 0 // MARK: - Coding public init?(coder: NSCoder) { - guard let message = coder.decodeObject(forKey: "message") as! Message?, + guard let message = coder.decodeObject(forKey: "message") as! _Message?, let rawDestination = coder.decodeObject(forKey: "destination") as! String?, let id = coder.decodeObject(forKey: "id") as! String? else { return nil } self.message = message - if let destString: String = MessageSendJob.process(rawDestination, type: "contact") { + if let destString: String = _MessageSendJob.process(rawDestination, type: "contact") { destination = .contact(publicKey: destString) } - else if let destString: String = MessageSendJob.process(rawDestination, type: "closedGroup") { + else if let destString: String = _MessageSendJob.process(rawDestination, type: "closedGroup") { destination = .closedGroup(groupPublicKey: destString) } - else if let destString: String = MessageSendJob.process(rawDestination, type: "openGroup") { + else if let destString: String = _MessageSendJob.process(rawDestination, type: "openGroup") { let components = destString .split(separator: ",") .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } @@ -1069,7 +1069,7 @@ public enum Legacy { let server = components[1] destination = .openGroup(channel: channel, server: server) } - else if let destString: String = MessageSendJob.process(rawDestination, type: "openGroupV2") { + else if let destString: String = _MessageSendJob.process(rawDestination, type: "openGroupV2") { let components = destString .split(separator: ",") .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } @@ -1107,7 +1107,7 @@ public enum Legacy { } @objc(AttachmentUploadJob) - internal final class AttachmentUploadJob: NSObject, NSCoding { + internal final class _AttachmentUploadJob: NSObject, NSCoding { internal let attachmentID: String internal let threadID: String internal let message: Message @@ -1140,7 +1140,7 @@ public enum Legacy { } @objc(AttachmentDownloadJob) - public final class AttachmentDownloadJob: NSObject, NSCoding { + public final class _AttachmentDownloadJob: NSObject, NSCoding { public let attachmentID: String public let tsMessageID: String public let threadID: String @@ -1171,7 +1171,7 @@ public enum Legacy { } } - public final class DisappearingConfigurationUpdateInfoMessage: TSInfoMessage { + public final class _DisappearingConfigurationUpdateInfoMessage: TSInfoMessage { // Note: Due to how Mantle works we need to set default values for these as the 'init(dictionary:)' // method doesn't actually get values for them but the must be set before calling a super.init method // so this allows us to work around the behaviour until 'init(coder:)' method completes it's super call diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 46e615e79..c089ce5ae 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -16,13 +16,13 @@ enum _003_YDBToGRDBMigration: Migration { // MARK: - Process Contacts, Threads & Interactions print("RAWR [\(Date().timeIntervalSince1970)] - SessionMessagingKit migration - Start") var shouldFailMigration: Bool = false - var contacts: Set = [] + var contacts: Set = [] var validProfileIds: Set = [] var contactThreadIds: Set = [] var legacyThreadIdToIdMap: [String: String] = [:] var threads: Set = [] - var disappearingMessagesConfiguration: [String: Legacy.DisappearingMessagesConfiguration] = [:] + var disappearingMessagesConfiguration: [String: Legacy._DisappearingMessagesConfiguration] = [:] var closedGroupKeys: [String: [TimeInterval: SUKLegacy.KeyPair]] = [:] var closedGroupName: [String: String] = [:] @@ -38,37 +38,37 @@ enum _003_YDBToGRDBMigration: Migration { // var openGroupServerToUniqueIdLookup: [String: [String]] = [:] // TODO: Not needed???? var interactions: [String: [TSInteraction]] = [:] - var attachments: [String: Legacy.Attachment] = [:] + var attachments: [String: Legacy._Attachment] = [:] var processedAttachmentIds: Set = [] var outgoingReadReceiptsTimestampsMs: [String: Set] = [:] var receivedMessageTimestamps: Set = [] // Map the Legacy types for the NSKeyedUnarchiver NSKeyedUnarchiver.setClass( - Legacy.Contact.self, + Legacy._Contact.self, forClassName: "SNContact" ) NSKeyedUnarchiver.setClass( - Legacy.Attachment.self, + Legacy._Attachment.self, forClassName: "TSAttachment" ) NSKeyedUnarchiver.setClass( - Legacy.AttachmentStream.self, + Legacy._AttachmentStream.self, forClassName: "TSAttachmentStream" ) NSKeyedUnarchiver.setClass( - Legacy.AttachmentPointer.self, + Legacy._AttachmentPointer.self, forClassName: "TSAttachmentPointer" ) NSKeyedUnarchiver.setClass( - Legacy.DisappearingConfigurationUpdateInfoMessage.self, + Legacy._DisappearingConfigurationUpdateInfoMessage.self, forClassName: "OWSDisappearingConfigurationUpdateInfoMessage" ) Storage.read { transaction in // Process the Contacts transaction.enumerateRows(inCollection: Legacy.contactCollection) { _, object, _, _ in - guard let contact = object as? Legacy.Contact else { return } + guard let contact = object as? Legacy._Contact else { return } contacts.insert(contact) validProfileIds.insert(contact.sessionID) } @@ -91,7 +91,7 @@ enum _003_YDBToGRDBMigration: Migration { // Get the disappearing messages config disappearingMessagesConfiguration[threadId] = transaction .object(forKey: threadId, inCollection: Legacy.disappearingMessagesCollection) - .asType(Legacy.DisappearingMessagesConfiguration.self) + .asType(Legacy._DisappearingMessagesConfiguration.self) // Process group-specific info guard let groupThread: TSGroupThread = thread as? TSGroupThread else { @@ -175,7 +175,7 @@ enum _003_YDBToGRDBMigration: Migration { // Process attachments print("RAWR [\(Date().timeIntervalSince1970)] - Process attachments - Start") transaction.enumerateKeysAndObjects(inCollection: Legacy.attachmentsCollection) { key, object, _ in - guard let attachment: Legacy.Attachment = object as? Legacy.Attachment else { + guard let attachment: Legacy._Attachment = object as? Legacy._Attachment else { SNLog("[Migration Error] Unable to process attachment") shouldFailMigration = true return @@ -331,7 +331,7 @@ enum _003_YDBToGRDBMigration: Migration { ).insert(db) // Disappearing Messages Configuration - if let config: Legacy.DisappearingMessagesConfiguration = disappearingMessagesConfiguration[threadId] { + if let config: Legacy._DisappearingMessagesConfiguration = disappearingMessagesConfiguration[threadId] { try DisappearingMessagesConfiguration( threadId: threadId, isEnabled: config.isEnabled, @@ -524,7 +524,7 @@ enum _003_YDBToGRDBMigration: Migration { // a string at display time so we want to continue that behaviour guard infoMessage.messageType == .disappearingMessagesUpdate, - let updateMessage: Legacy.DisappearingConfigurationUpdateInfoMessage = infoMessage as? Legacy.DisappearingConfigurationUpdateInfoMessage, + let updateMessage: Legacy._DisappearingConfigurationUpdateInfoMessage = infoMessage as? Legacy._DisappearingConfigurationUpdateInfoMessage, let infoMessageData: Data = try? JSONEncoder().encode( DisappearingMessagesConfiguration.MessageInfo( senderName: updateMessage.createdByRemoteName, @@ -843,127 +843,127 @@ enum _003_YDBToGRDBMigration: Migration { print("RAWR [\(Date().timeIntervalSince1970)] - Process jobs - Start") - var notifyPushServerJobs: Set = [] - var messageReceiveJobs: Set = [] - var messageSendJobs: Set = [] - var attachmentUploadJobs: Set = [] - var attachmentDownloadJobs: Set = [] + var notifyPushServerJobs: Set = [] + var messageReceiveJobs: Set = [] + var messageSendJobs: Set = [] + var attachmentUploadJobs: Set = [] + var attachmentDownloadJobs: Set = [] // Map the Legacy types for the NSKeyedUnarchiver NSKeyedUnarchiver.setClass( - Legacy.NotifyPNServerJob.self, + Legacy._NotifyPNServerJob.self, forClassName: "SessionMessagingKit.NotifyPNServerJob" ) NSKeyedUnarchiver.setClass( - Legacy.NotifyPNServerJob.SnodeMessage.self, + Legacy._NotifyPNServerJob._SnodeMessage.self, forClassName: "SessionSnodeKit.SnodeMessage" ) NSKeyedUnarchiver.setClass( - Legacy.MessageSendJob.self, + Legacy._MessageSendJob.self, forClassName: "SessionMessagingKit.SNMessageSendJob" ) NSKeyedUnarchiver.setClass( - Legacy.MessageReceiveJob.self, + Legacy._MessageReceiveJob.self, forClassName: "SessionMessagingKit.MessageReceiveJob" ) NSKeyedUnarchiver.setClass( - Legacy.AttachmentUploadJob.self, + Legacy._AttachmentUploadJob.self, forClassName: "SessionMessagingKit.AttachmentUploadJob" ) NSKeyedUnarchiver.setClass( - Legacy.AttachmentDownloadJob.self, + Legacy._AttachmentDownloadJob.self, forClassName: "SessionMessagingKit.AttachmentDownloadJob" ) NSKeyedUnarchiver.setClass( - Legacy.Message.self, + Legacy._Message.self, forClassName: "SNMessage" ) NSKeyedUnarchiver.setClass( - Legacy.VisibleMessage.self, + Legacy._VisibleMessage.self, forClassName: "SNVisibleMessage" ) NSKeyedUnarchiver.setClass( - Legacy.Quote.self, + Legacy._Quote.self, forClassName: "SNQuote" ) NSKeyedUnarchiver.setClass( - Legacy.LinkPreview.self, + Legacy._LinkPreview.self, forClassName: "SessionServiceKit.OWSLinkPreview" // Very old legacy name ) NSKeyedUnarchiver.setClass( - Legacy.LinkPreview.self, + Legacy._LinkPreview.self, forClassName: "SNLinkPreview" ) NSKeyedUnarchiver.setClass( - Legacy.Profile.self, + Legacy._Profile.self, forClassName: "SNProfile" ) NSKeyedUnarchiver.setClass( - Legacy.OpenGroupInvitation.self, + Legacy._OpenGroupInvitation.self, forClassName: "SNOpenGroupInvitation" ) NSKeyedUnarchiver.setClass( - Legacy.ControlMessage.self, + Legacy._ControlMessage.self, forClassName: "SNControlMessage" ) NSKeyedUnarchiver.setClass( - Legacy.ReadReceipt.self, + Legacy._ReadReceipt.self, forClassName: "SNReadReceipt" ) NSKeyedUnarchiver.setClass( - Legacy.TypingIndicator.self, + Legacy._TypingIndicator.self, forClassName: "SNTypingIndicator" ) NSKeyedUnarchiver.setClass( - Legacy.ClosedGroupControlMessage.self, + Legacy._ClosedGroupControlMessage.self, forClassName: "SessionMessagingKit.ClosedGroupControlMessage" ) NSKeyedUnarchiver.setClass( - Legacy.ClosedGroupControlMessage.KeyPairWrapper.self, + Legacy._ClosedGroupControlMessage._KeyPairWrapper.self, forClassName: "ClosedGroupControlMessage.SNKeyPairWrapper" ) NSKeyedUnarchiver.setClass( - Legacy.DataExtractionNotification.self, + Legacy._DataExtractionNotification.self, forClassName: "SessionMessagingKit.DataExtractionNotification" ) NSKeyedUnarchiver.setClass( - Legacy.ExpirationTimerUpdate.self, + Legacy._ExpirationTimerUpdate.self, forClassName: "SNExpirationTimerUpdate" ) NSKeyedUnarchiver.setClass( - Legacy.ConfigurationMessage.self, + Legacy._ConfigurationMessage.self, forClassName: "SNConfigurationMessage" ) NSKeyedUnarchiver.setClass( - Legacy.CMClosedGroup.self, + Legacy._CMClosedGroup.self, forClassName: "SNClosedGroup" ) NSKeyedUnarchiver.setClass( - Legacy.CMContact.self, + Legacy._CMContact.self, forClassName: "SNConfigurationMessage.SNConfigurationMessageContact" ) NSKeyedUnarchiver.setClass( - Legacy.UnsendRequest.self, + Legacy._UnsendRequest.self, forClassName: "SNUnsendRequest" ) NSKeyedUnarchiver.setClass( - Legacy.MessageRequestResponse.self, + Legacy._MessageRequestResponse.self, forClassName: "SNMessageRequestResponse" ) Storage.read { transaction in transaction.enumerateRows(inCollection: Legacy.notifyPushServerJobCollection) { _, object, _, _ in - guard let job = object as? Legacy.NotifyPNServerJob else { return } + guard let job = object as? Legacy._NotifyPNServerJob else { return } notifyPushServerJobs.insert(job) } transaction.enumerateRows(inCollection: Legacy.messageReceiveJobCollection) { _, object, _, _ in - guard let job = object as? Legacy.MessageReceiveJob else { return } + guard let job = object as? Legacy._MessageReceiveJob else { return } messageReceiveJobs.insert(job) } transaction.enumerateRows(inCollection: Legacy.messageSendJobCollection) { _, object, _, _ in - guard let job = object as? Legacy.MessageSendJob else { return } + guard let job = object as? Legacy._MessageSendJob else { return } messageSendJobs.insert(job) } @@ -973,7 +973,7 @@ enum _003_YDBToGRDBMigration: Migration { } transaction.enumerateRows(inCollection: Legacy.attachmentDownloadJobCollection) { _, object, _, _ in - guard let job = object as? Legacy.AttachmentDownloadJob else { return } + guard let job = object as? Legacy._AttachmentDownloadJob else { return } attachmentDownloadJobs.insert(job) } } @@ -1103,7 +1103,7 @@ enum _003_YDBToGRDBMigration: Migration { destination: legacyJob.destination, variant: { switch legacyJob.message { - case is Legacy.ExpirationTimerUpdate: + case is Legacy._ExpirationTimerUpdate: return .infoDisappearingMessagesUpdate default: return nil } @@ -1274,7 +1274,7 @@ enum _003_YDBToGRDBMigration: Migration { for legacyAttachmentId: String?, interactionVariant: Interaction.Variant? = nil, isQuotedMessage: Bool = false, - attachments: [String: Legacy.Attachment], + attachments: [String: Legacy._Attachment], processedAttachmentIds: inout Set ) throws -> String? { guard let legacyAttachmentId: String = legacyAttachmentId else { return nil } @@ -1287,12 +1287,12 @@ enum _003_YDBToGRDBMigration: Migration { return legacyAttachmentId } - guard let legacyAttachment: Legacy.Attachment = attachments[legacyAttachmentId] else { + guard let legacyAttachment: Legacy._Attachment = attachments[legacyAttachmentId] else { SNLog("[Migration Warning] Missing attachment - interaction will appear as blank") return nil } - let processedLocalRelativeFilePath: String? = (legacyAttachment as? Legacy.AttachmentStream)? + let processedLocalRelativeFilePath: String? = (legacyAttachment as? Legacy._AttachmentStream)? .localRelativeFilePath .map { filePath -> String in // The old 'localRelativeFilePath' seemed to have a leading forward slash (want @@ -1303,7 +1303,7 @@ enum _003_YDBToGRDBMigration: Migration { } let state: Attachment.State = { switch legacyAttachment { - case let stream as Legacy.AttachmentStream: // Outgoing or already downloaded + case let stream as Legacy._AttachmentStream: // Outgoing or already downloaded switch interactionVariant { case .standardOutgoing: return (stream.isUploaded ? .uploaded : .pending) default: return .downloaded @@ -1315,7 +1315,7 @@ enum _003_YDBToGRDBMigration: Migration { }() let size: CGSize = { switch legacyAttachment { - case let stream as Legacy.AttachmentStream: + case let stream as Legacy._AttachmentStream: // First try to get an image size using the 'localRelativeFilePath' value if let localRelativeFilePath: String = processedLocalRelativeFilePath, @@ -1342,13 +1342,13 @@ enum _003_YDBToGRDBMigration: Migration { ) .defaulting(to: .zero) - case let pointer as Legacy.AttachmentPointer: return pointer.mediaSize + case let pointer as Legacy._AttachmentPointer: return pointer.mediaSize default: return CGSize.zero } }() let (isValid, duration): (Bool, TimeInterval?) = { guard - let stream: Legacy.AttachmentStream = legacyAttachment as? Legacy.AttachmentStream, + let stream: Legacy._AttachmentStream = legacyAttachment as? Legacy._AttachmentStream, let originalFilePath: String = Attachment.originalFilePath( id: legacyAttachmentId, mimeType: stream.contentType, @@ -1401,7 +1401,7 @@ enum _003_YDBToGRDBMigration: Migration { state: state, contentType: legacyAttachment.contentType, byteCount: UInt(legacyAttachment.byteCount), - creationTimestamp: (legacyAttachment as? Legacy.AttachmentStream)? + creationTimestamp: (legacyAttachment as? Legacy._AttachmentStream)? .creationTimestamp.timeIntervalSince1970, sourceFilename: legacyAttachment.sourceFilename, downloadUrl: legacyAttachment.downloadURL, @@ -1411,7 +1411,7 @@ enum _003_YDBToGRDBMigration: Migration { duration: duration, isValid: isValid, encryptionKey: legacyAttachment.encryptionKey, - digest: (legacyAttachment as? Legacy.AttachmentStream)?.digest, + digest: (legacyAttachment as? Legacy._AttachmentStream)?.digest, caption: legacyAttachment.caption ).inserted(db) diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Attachment.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Attachment.swift index 8b2d9daef..ed16977e4 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Attachment.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Attachment.swift @@ -5,9 +5,11 @@ import CoreGraphics import SessionUtilitiesKit public extension VisibleMessage { - - @objc(SNAttachment) - class Attachment: NSObject, Codable, NSCoding { + class VMAttachment: Codable { + public enum Kind: String, Codable { + case voiceMessage, generic + } + public var fileName: String? public var contentType: String? public var key: Data? @@ -22,65 +24,66 @@ public extension VisibleMessage { // key and digest can be nil for open group attachments contentType != nil && kind != nil && size != nil && sizeInBytes != nil && url != nil } + + // MARK: - Initialization - public enum Kind: String, Codable { - case voiceMessage, generic + internal init( + fileName: String?, + contentType: String?, + key: Data?, + digest: Data?, + kind: Kind?, + caption: String?, + size: CGSize?, + sizeInBytes: UInt?, + url: String? + ) { + self.fileName = fileName + self.contentType = contentType + self.key = key + self.digest = digest + self.kind = kind + self.caption = caption + self.size = size + self.sizeInBytes = sizeInBytes + self.url = url } + + // MARK: - Proto Conversion - public override init() { super.init() } - - public required init?(coder: NSCoder) { - if let fileName = coder.decodeObject(forKey: "fileName") as! String? { self.fileName = fileName } - if let contentType = coder.decodeObject(forKey: "contentType") as! String? { self.contentType = contentType } - if let key = coder.decodeObject(forKey: "key") as! Data? { self.key = key } - if let digest = coder.decodeObject(forKey: "digest") as! Data? { self.digest = digest } - if let rawKind = coder.decodeObject(forKey: "kind") as! String? { self.kind = Kind(rawValue: rawKind) } - if let caption = coder.decodeObject(forKey: "caption") as! String? { self.caption = caption } - if let size = coder.decodeObject(forKey: "size") as! CGSize? { self.size = size } - if let sizeInBytes = coder.decodeObject(forKey: "sizeInBytes") as! UInt? { self.sizeInBytes = sizeInBytes } - if let url = coder.decodeObject(forKey: "url") as! String? { self.url = url } - } - - public func encode(with coder: NSCoder) { - coder.encode(fileName, forKey: "fileName") - coder.encode(contentType, forKey: "contentType") - coder.encode(key, forKey: "key") - coder.encode(digest, forKey: "digest") - coder.encode(kind?.rawValue, forKey: "kind") - coder.encode(caption, forKey: "caption") - coder.encode(size, forKey: "size") - coder.encode(sizeInBytes, forKey: "sizeInBytes") - coder.encode(url, forKey: "url") - } - - public static func fromProto(_ proto: SNProtoAttachmentPointer) -> Attachment? { - let result = Attachment() - result.fileName = proto.fileName + public static func fromProto(_ proto: SNProtoAttachmentPointer) -> VMAttachment? { func inferContentType() -> String { - guard let fileName = result.fileName, let fileExtension = URL(string: fileName)?.pathExtension else { return OWSMimeTypeApplicationOctetStream } - return MIMETypeUtil.mimeType(forFileExtension: fileExtension) ?? OWSMimeTypeApplicationOctetStream + guard + let fileName: String = proto.fileName, + let fileExtension: String = URL(string: fileName)?.pathExtension + else { return OWSMimeTypeApplicationOctetStream } + + return (MIMETypeUtil.mimeType(forFileExtension: fileExtension) ?? OWSMimeTypeApplicationOctetStream) } - result.contentType = proto.contentType ?? inferContentType() - result.key = proto.key - result.digest = proto.digest - let kind: VisibleMessage.Attachment.Kind - if proto.hasFlags && (proto.flags & UInt32(SNProtoAttachmentPointer.SNProtoAttachmentPointerFlags.voiceMessage.rawValue)) > 0 { - kind = .voiceMessage - } else { - kind = .generic - } - result.kind = kind - result.caption = proto.hasCaption ? proto.caption : nil - let size: CGSize - if proto.hasWidth && proto.width > 0 && proto.hasHeight && proto.height > 0 { - size = CGSize(width: Int(proto.width), height: Int(proto.height)) - } else { - size = CGSize.zero - } - result.size = size - result.sizeInBytes = proto.size > 0 ? UInt(proto.size) : nil - result.url = proto.url - return result + + return VMAttachment( + fileName: proto.fileName, + contentType: (proto.contentType ?? inferContentType()), + key: proto.key, + digest: proto.digest, + kind: { + if proto.hasFlags && (proto.flags & UInt32(SNProtoAttachmentPointer.SNProtoAttachmentPointerFlags.voiceMessage.rawValue)) > 0 { + return .voiceMessage + } + + return .generic + }(), + caption: (proto.hasCaption ? proto.caption : nil), + size: { + if proto.hasWidth && proto.width > 0 && proto.hasHeight && proto.height > 0 { + return CGSize(width: Int(proto.width), height: Int(proto.height)) + } + + return .zero + }(), + sizeInBytes: (proto.size > 0 ? UInt(proto.size) : nil), + url: proto.url + ) } public func toProto() -> SNProtoDataMessageQuote? { diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift index 83020f501..894024106 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift @@ -5,12 +5,14 @@ import GRDB import SessionUtilitiesKit public extension VisibleMessage { - struct LinkPreview: Codable { + struct VMLinkPreview: Codable { public let title: String? public let url: String? public let attachmentId: String? public var isValid: Bool { title != nil && url != nil && attachmentId != nil } + + // MARK: - Initialization internal init(title: String?, url: String, attachmentId: String?) { self.title = title @@ -20,10 +22,12 @@ public extension VisibleMessage { // MARK: - Proto Conversion - public static func fromProto(_ proto: SNProtoDataMessagePreview) -> LinkPreview? { - let title = proto.title - let url = proto.url - return LinkPreview(title: title, url: url, attachmentId: nil) + public static func fromProto(_ proto: SNProtoDataMessagePreview) -> VMLinkPreview? { + return VMLinkPreview( + title: proto.title, + url: proto.url, + attachmentId: nil + ) } public func toProto() -> SNProtoDataMessagePreview? { @@ -40,8 +44,7 @@ public extension VisibleMessage { if let attachmentId = attachmentId, - // TODO: try to ditch `SessionMessagingKit.` - let attachment: SessionMessagingKit.Attachment = try? SessionMessagingKit.Attachment.fetchOne(db, id: attachmentId), + let attachment: Attachment = try? Attachment.fetchOne(db, id: attachmentId), let attachmentProto = attachment.buildProto() { linkPreviewProto.setImage(attachmentProto) @@ -71,9 +74,9 @@ public extension VisibleMessage { // MARK: - Database Type Conversion -public extension VisibleMessage.LinkPreview { - static func from(_ db: Database, linkPreview: LinkPreview) -> VisibleMessage.LinkPreview { - return VisibleMessage.LinkPreview( +public extension VisibleMessage.VMLinkPreview { + static func from(_ db: Database, linkPreview: LinkPreview) -> VisibleMessage.VMLinkPreview { + return VisibleMessage.VMLinkPreview( title: linkPreview.title, url: linkPreview.url, attachmentId: linkPreview.attachmentId diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+OpenGroupInvitation.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+OpenGroupInvitation.swift index c1bdf29fe..87e92a555 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+OpenGroupInvitation.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+OpenGroupInvitation.swift @@ -5,9 +5,11 @@ import GRDB import SessionUtilitiesKit public extension VisibleMessage { - struct OpenGroupInvitation: Codable { + struct VMOpenGroupInvitation: Codable { public let name: String? public let url: String? + + // MARK: - Initialization public init(name: String, url: String) { self.name = name @@ -16,10 +18,11 @@ public extension VisibleMessage { // MARK: - Proto Conversion - public static func fromProto(_ proto: SNProtoDataMessageOpenGroupInvitation) -> OpenGroupInvitation? { - let url = proto.url - let name = proto.name - return OpenGroupInvitation(name: name, url: url) + public static func fromProto(_ proto: SNProtoDataMessageOpenGroupInvitation) -> VMOpenGroupInvitation? { + return VMOpenGroupInvitation( + name: proto.name, + url: proto.url + ) } public func toProto() -> SNProtoDataMessageOpenGroupInvitation? { @@ -51,11 +54,11 @@ public extension VisibleMessage { // MARK: - Database Type Conversion -public extension VisibleMessage.OpenGroupInvitation { - static func from(_ db: Database, linkPreview: LinkPreview) -> VisibleMessage.OpenGroupInvitation? { +public extension VisibleMessage.VMOpenGroupInvitation { + static func from(_ db: Database, linkPreview: LinkPreview) -> VisibleMessage.VMOpenGroupInvitation? { guard let name: String = linkPreview.title else { return nil } - return VisibleMessage.OpenGroupInvitation( + return VisibleMessage.VMOpenGroupInvitation( name: name, url: linkPreview.url ) diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift index ea3111e62..79ae5c07e 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift @@ -4,10 +4,12 @@ import Foundation import SessionUtilitiesKit public extension VisibleMessage { - struct Profile: Codable { + struct VMProfile: Codable { public let displayName: String? public let profileKey: Data? public let profilePictureUrl: String? + + // MARK: - Initialization internal init(displayName: String, profileKey: Data? = nil, profilePictureUrl: String? = nil) { self.displayName = displayName @@ -17,13 +19,13 @@ public extension VisibleMessage { // MARK: - Proto Conversion - public static func fromProto(_ proto: SNProtoDataMessage) -> Profile? { + public static func fromProto(_ proto: SNProtoDataMessage) -> VMProfile? { guard let profileProto = proto.profile, let displayName = profileProto.displayName else { return nil } - return Profile( + return VMProfile( displayName: displayName, profileKey: proto.profileKey, profilePictureUrl: profileProto.profilePicture @@ -68,8 +70,8 @@ public extension VisibleMessage { // MARK: - Conversion -extension VisibleMessage.Profile { - init(profile: SessionMessagingKit.Profile) { +extension VisibleMessage.VMProfile { + init(profile: Profile) { self.displayName = profile.name self.profileKey = profile.profileEncryptionKey?.keyData self.profilePictureUrl = profile.profilePictureUrl diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift index 10f4276e7..72713f35f 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift @@ -6,7 +6,7 @@ import SessionUtilitiesKit public extension VisibleMessage { - struct Quote: Codable { + struct VMQuote: Codable { public let timestamp: UInt64? public let publicKey: String? public let text: String? @@ -25,11 +25,13 @@ public extension VisibleMessage { // MARK: - Proto Conversion - public static func fromProto(_ proto: SNProtoDataMessageQuote) -> Quote? { - let timestamp = proto.id - let publicKey = proto.author - let text = proto.text - return Quote(timestamp: timestamp, publicKey: publicKey, text: text, attachmentId: nil) + public static func fromProto(_ proto: SNProtoDataMessageQuote) -> VMQuote? { + return VMQuote( + timestamp: proto.id, + publicKey: proto.author, + text: proto.text, + attachmentId: nil + ) } public func toProto() -> SNProtoDataMessageQuote? { @@ -55,8 +57,8 @@ public extension VisibleMessage { private func addAttachmentsIfNeeded(_ db: Database, to quoteProto: SNProtoDataMessageQuote.SNProtoDataMessageQuoteBuilder) { guard let attachmentId = attachmentId else { return } guard - let attachment: SessionMessagingKit.Attachment = try? SessionMessagingKit.Attachment.fetchOne(db, id: attachmentId), - attachment.state != .uploaded + let attachment: Attachment = try? Attachment.fetchOne(db, id: attachmentId), + attachment.state == .uploaded else { #if DEBUG preconditionFailure("Sending a message before all associated attachments have been uploaded.") @@ -95,13 +97,24 @@ public extension VisibleMessage { // MARK: - Database Type Conversion -public extension VisibleMessage.Quote { - static func from(_ db: Database, quote: Quote) -> VisibleMessage.Quote { - return VisibleMessage.Quote( +public extension VisibleMessage.VMQuote { + static func from(_ db: Database, quote: Quote) -> VisibleMessage.VMQuote { + return VisibleMessage.VMQuote( timestamp: UInt64(quote.timestampMs), publicKey: quote.authorId, text: quote.body, attachmentId: quote.attachmentId ) } + + static func from(_ quote: TSQuotedMessage?) -> VisibleMessage.VMQuote? { + guard let quote = quote else { return nil } + + return VisibleMessage.VMQuote( + timestamp: quote.timestamp, + publicKey: quote.authorId, + text: quote.body, + attachmentId: quote.quotedAttachments.first?.attachmentId + ) + } } diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift index 8a998f620..085596c5c 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift @@ -21,10 +21,10 @@ public final class VisibleMessage: Message { public var syncTarget: String? public let text: String? public var attachmentIds: [String] - public let quote: Quote? - public let linkPreview: LinkPreview? - public var profile: Profile? - public let openGroupInvitation: OpenGroupInvitation? + public let quote: VMQuote? + public let linkPreview: VMLinkPreview? + public var profile: VMProfile? + public let openGroupInvitation: VMOpenGroupInvitation? public override var isSelfSendValid: Bool { true } @@ -47,10 +47,10 @@ public final class VisibleMessage: Message { syncTarget: String? = nil, text: String?, attachmentIds: [String] = [], - quote: Quote? = nil, - linkPreview: LinkPreview? = nil, - profile: Profile? = nil, - openGroupInvitation: OpenGroupInvitation? = nil + quote: VMQuote? = nil, + linkPreview: VMLinkPreview? = nil, + profile: VMProfile? = nil, + openGroupInvitation: VMOpenGroupInvitation? = nil ) { self.syncTarget = syncTarget self.text = text @@ -75,10 +75,10 @@ public final class VisibleMessage: Message { syncTarget = try? container.decode(String.self, forKey: .syncTarget) text = try? container.decode(String.self, forKey: .text) attachmentIds = ((try? container.decode([String].self, forKey: .attachmentIds)) ?? []) - quote = try? container.decode(Quote.self, forKey: .quote) - linkPreview = try? container.decode(LinkPreview.self, forKey: .linkPreview) - profile = try? container.decode(Profile.self, forKey: .profile) - openGroupInvitation = try? container.decode(OpenGroupInvitation.self, forKey: .openGroupInvitation) + quote = try? container.decode(VMQuote.self, forKey: .quote) + linkPreview = try? container.decode(VMLinkPreview.self, forKey: .linkPreview) + profile = try? container.decode(VMProfile.self, forKey: .profile) + openGroupInvitation = try? container.decode(VMOpenGroupInvitation.self, forKey: .openGroupInvitation) try super.init(from: decoder) } @@ -106,10 +106,10 @@ public final class VisibleMessage: Message { syncTarget: dataMessage.syncTarget, text: dataMessage.body, attachmentIds: [], // Attachments are handled in MessageReceiver - quote: dataMessage.quote.map { Quote.fromProto($0) }, - linkPreview: dataMessage.preview.first.map { LinkPreview.fromProto($0) }, - profile: Profile.fromProto(dataMessage), - openGroupInvitation: dataMessage.openGroupInvitation.map { OpenGroupInvitation.fromProto($0) } + quote: dataMessage.quote.map { VMQuote.fromProto($0) }, + linkPreview: dataMessage.preview.first.map { VMLinkPreview.fromProto($0) }, + profile: VMProfile.fromProto(dataMessage), + openGroupInvitation: dataMessage.openGroupInvitation.map { VMOpenGroupInvitation.fromProto($0) } ) } @@ -150,7 +150,7 @@ public final class VisibleMessage: Message { // Attachments - let attachments: [SessionMessagingKit.Attachment]? = try? SessionMessagingKit.Attachment.fetchAll(db, ids: self.attachmentIds) + let attachments: [Attachment]? = try? Attachment.fetchAll(db, ids: self.attachmentIds) if !(attachments ?? []).allSatisfy({ $0.state == .uploaded }) { #if DEBUG @@ -204,7 +204,7 @@ public final class VisibleMessage: Message { public extension VisibleMessage { static func from(_ db: Database, interaction: Interaction) -> VisibleMessage { - let linkPreview: SessionMessagingKit.LinkPreview? = try? interaction.linkPreview.fetchOne(db) + let linkPreview: LinkPreview? = try? interaction.linkPreview.fetchOne(db) return VisibleMessage( sentTimestamp: UInt64(interaction.timestampMs), @@ -219,18 +219,18 @@ public extension VisibleMessage { attachmentIds: ((try? interaction.attachments.fetchAll(db)) ?? []) .map { $0.id }, quote: (try? interaction.quote.fetchOne(db)) - .map { VisibleMessage.Quote.from(db, quote: $0) }, + .map { VMQuote.from(db, quote: $0) }, linkPreview: linkPreview .map { linkPreview in guard linkPreview.variant == .standard else { return nil } - return VisibleMessage.LinkPreview.from(db, linkPreview: linkPreview) + return VMLinkPreview.from(db, linkPreview: linkPreview) }, - profile: nil, // TODO: Confirm this + profile: nil, // Don't attach the profile to avoid sending a legacy version (set in MessageSender) openGroupInvitation: linkPreview.map { linkPreview in guard linkPreview.variant == .openGroupInvitation else { return nil } - return VisibleMessage.OpenGroupInvitation.from( + return VMOpenGroupInvitation.from( db, linkPreview: linkPreview ) diff --git a/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift b/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift new file mode 100644 index 000000000..0a8ab0dac --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift @@ -0,0 +1,3 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index 7b629b6fe..0cd322bb8 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -1102,6 +1102,7 @@ extension MessageReceiver { return } + let userPublicKey: String = getUserHexEncodedPublicKey(db) let didAdminLeave: Bool = allGroupMembers.contains(where: { member in member.role == .admin && member.profileId == sender }) @@ -1116,16 +1117,13 @@ extension MessageReceiver { .asSet() .subtracting(membersToRemove.map { $0.profileId }) - if didAdminLeave { + + if didAdminLeave || sender == userPublicKey { // Remove the group from the database and unsubscribe from PNs ClosedGroupPoller.shared.stopPolling(for: id) try closedGroup - .members - .filter( - GroupMember.Columns.role == GroupMember.Role.standard || - GroupMember.Columns.role == GroupMember.Role.zombie - ) + .allMembers .deleteAll(db) _ = try closedGroup @@ -1135,7 +1133,7 @@ extension MessageReceiver { let _ = PushNotificationAPI.performOperation( .unsubscribe, for: id, - publicKey: getUserHexEncodedPublicKey(db) + publicKey: userPublicKey ) } else { diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift index 3016636b5..87076013a 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift @@ -501,9 +501,6 @@ extension MessageSender { guard let closedGroup: ClosedGroup = try? thread.closedGroup.fetchOne(db) else { return Promise(error: MessageSenderError.invalidClosedGroupUpdate) } - guard let allGroupMembers: [GroupMember] = try? closedGroup.allMembers.fetchAll(db) else { - return Promise(error: MessageSenderError.invalidClosedGroupUpdate) - } let userPublicKey: String = getUserHexEncodedPublicKey(db) @@ -533,10 +530,10 @@ extension MessageSender { in: thread ) .done { + // Remove the group from the database and unsubscribe from PNs + ClosedGroupPoller.shared.stopPolling(for: groupPublicKey) + GRDBStorage.shared.write { db in - // Remove the group from the database and unsubscribe from PNs - ClosedGroupPoller.shared.stopPolling(for: groupPublicKey) - _ = try closedGroup .keyPairs .deleteAll(db) diff --git a/SessionUtilitiesKit/Database/GRDBStorage.swift b/SessionUtilitiesKit/Database/GRDBStorage.swift index a691e96d1..f20c524ff 100644 --- a/SessionUtilitiesKit/Database/GRDBStorage.swift +++ b/SessionUtilitiesKit/Database/GRDBStorage.swift @@ -228,6 +228,10 @@ public final class GRDBStorage { return try? dbPool.write(updates) } + public func writeAsync(updates: @escaping (Database) throws -> T) { + writeAsync(updates: updates, completion: { _, _ in }) + } + public func writeAsync(updates: @escaping (Database) throws -> T, completion: @escaping (Database, Swift.Result) throws -> Void) { dbPool.asyncWrite( updates, diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index 72e9877c6..84ab69c7b 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -475,7 +475,9 @@ public final class JobRunner { // The job is removed from the queue before it runs so all we need to to is remove it // from the 'currentlyRunning' set and start the next one jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) } - runNextJob() + internalQueue.async { + runNextJob() + } } /// This function is called when a job fails, if it's wasn't a permanent failure then the 'failureCount' for the job will be incremented and it'll From 6b1fc0f552c308c046e31ccef703b891711c6958 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 12 May 2022 13:29:49 +1000 Subject: [PATCH 076/157] Fixed an issue where I had 'whisperTo' and 'whisperMods' acting in a mutually exclusive way --- SessionMessagingKit/Sending & Receiving/MessageSender.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index f9c12b057..9e959451f 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -324,7 +324,7 @@ public final class MessageSender : NSObject { server, room, whisperTo, - (whisperTo == nil && whisperMods ? "mods" : nil) + (whisperMods ? "mods" : nil) ] .compactMap { $0 } .joined(separator: ".") From 3f062c044cff37a268d6714deba7b20e44a8ab25 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 12 May 2022 17:28:27 +1000 Subject: [PATCH 077/157] Added back the majority of the ConversationVC interactions Removed some more legacy code Added back logic similar to the pre-processing de-duping logic (was resulting in "unsent" messages reappearing) Added a number of updated view files --- Session.xcodeproj/project.pbxproj | 16 +- .../Context Menu/ContextMenuVC+Action.swift | 11 +- .../ConversationVC+Interaction.swift | 954 ++++++++++-------- Session/Conversations/ConversationVC.swift | 13 +- .../Conversations/Input View/InputView.swift | 189 ++-- .../Content Views/DocumentView.swift | 6 +- .../OpenGroupInvitationView.swift | 53 +- .../DownloadAttachmentModal.swift | 45 +- .../GIFs/GifPickerViewController.swift | 4 +- .../Translations/de.lproj/Localizable.strings | 1 + .../Translations/en.lproj/Localizable.strings | 1 + .../Translations/es.lproj/Localizable.strings | 1 + .../Translations/fa.lproj/Localizable.strings | 1 + .../Translations/fi.lproj/Localizable.strings | 1 + .../Translations/fr.lproj/Localizable.strings | 1 + .../Translations/hi.lproj/Localizable.strings | 1 + .../Translations/hr.lproj/Localizable.strings | 1 + .../id-ID.lproj/Localizable.strings | 1 + .../Translations/it.lproj/Localizable.strings | 1 + .../Translations/ja.lproj/Localizable.strings | 1 + .../Translations/nl.lproj/Localizable.strings | 1 + .../Translations/pl.lproj/Localizable.strings | 1 + .../pt_BR.lproj/Localizable.strings | 1 + .../Translations/ru.lproj/Localizable.strings | 1 + .../Translations/si.lproj/Localizable.strings | 1 + .../Translations/sk.lproj/Localizable.strings | 1 + .../Translations/sv.lproj/Localizable.strings | 1 + .../Translations/th.lproj/Localizable.strings | 1 + .../vi-VN.lproj/Localizable.strings | 1 + .../zh-Hant.lproj/Localizable.strings | 1 + .../zh_CN.lproj/Localizable.strings | 1 + Session/Notifications/AppNotifications.swift | 6 +- Session/Onboarding/RestoreVC.swift | 1 + Session/Shared/ConversationCell.swift | 14 +- Session/Sheets & Modals/Modal.swift | 3 + .../Migrations/_003_YDBToGRDBMigration.swift | 5 +- .../Database/Models/Attachment.swift | 69 +- .../Database/Models/Contact.swift | 14 - .../Database/Models/Interaction.swift | 3 + .../Database/Models/LinkPreview.swift | 4 +- .../Database/Models/OpenGroup.swift | 6 +- .../Database/Notification+Contacts.swift | 2 - .../Jobs/Types/AttachmentUploadJob.swift | 12 - .../Jobs/Types/MessageReceiveJob.swift | 14 +- .../Jobs/Types/MessageSendJob.swift | 4 +- .../Jobs/Types/SendReadReceiptsJob.swift | 2 +- .../Control Messages/UnsendRequest.swift | 2 +- .../Visible Messages/VisibleMessage.swift | 7 +- ...ataExtractionNotificationInfoMessage.swift | 38 - .../Errors/AttachmentError.swift | 18 + .../MessageReceiver+Handling.swift | 40 +- .../Sending & Receiving/MessageReceiver.swift | 2 +- .../MessageSender+Convenience.swift | 33 +- .../Sending & Receiving/MessageSender.swift | 21 +- .../Pollers/ClosedGroupPoller.swift | 115 ++- .../Sending & Receiving/Pollers/Poller.swift | 120 ++- .../Threads/Notification+Thread.swift | 2 - .../Utilities/SSKEnvironment.swift | 5 +- .../_001_InitialSetupMigration.swift | 4 +- .../Migrations/_003_YDBToGRDBMigration.swift | 13 +- .../Models/SnodeReceivedMessageInfo.swift | 26 +- .../Models/SnodeReceivedMessage.swift | 7 +- .../BlockingManagerRemovalMigration.swift | 4 +- .../Migrations/ContactsMigration.swift | 8 +- .../Migrations/MessageRequestsMigration.swift | 8 +- 65 files changed, 1103 insertions(+), 842 deletions(-) delete mode 100644 SessionMessagingKit/Sending & Receiving/Data Extraction/DataExtractionNotificationInfoMessage.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 91722a794..7744b88ab 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -251,7 +251,6 @@ B8F5F56525EC8453003BF8D4 /* Notification+Contacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F56425EC8453003BF8D4 /* Notification+Contacts.swift */; }; B8F5F58325EC94A6003BF8D4 /* Collection+Subscripting.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F58225EC94A6003BF8D4 /* Collection+Subscripting.swift */; }; B8F5F60325EDE16F003BF8D4 /* DataExtractionNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F60225EDE16F003BF8D4 /* DataExtractionNotification.swift */; }; - B8F5F61B25EDE4BF003BF8D4 /* DataExtractionNotificationInfoMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F61A25EDE4BF003BF8D4 /* DataExtractionNotificationInfoMessage.swift */; }; B8F5F71A25F1B35C003BF8D4 /* MediaPlaceholderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F71925F1B35C003BF8D4 /* MediaPlaceholderView.swift */; }; B8F5F72325F1B4CA003BF8D4 /* DownloadAttachmentModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F72225F1B4CA003BF8D4 /* DownloadAttachmentModal.swift */; }; B8FF8DAE25C0D00F004D1F22 /* SessionMessagingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A6F025539DE700C340D1 /* SessionMessagingKit.framework */; }; @@ -722,6 +721,7 @@ FD09C5E628260FF9000CE219 /* MediaGalleryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E528260FF9000CE219 /* MediaGalleryViewModel.swift */; }; FD09C5E828264937000CE219 /* MediaDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E728264937000CE219 /* MediaDetailViewController.swift */; }; FD09C5EA282A1BB2000CE219 /* ThreadTypingIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E9282A1BB2000CE219 /* ThreadTypingIndicator.swift */; }; + FD09C5EC282B8F18000CE219 /* AttachmentError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5EB282B8F17000CE219 /* AttachmentError.swift */; }; FD17D79927F40AB800122BE0 /* _003_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79827F40AB800122BE0 /* _003_YDBToGRDBMigration.swift */; }; FD17D79C27F40B2E00122BE0 /* SMKLegacyModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */; }; FD17D7A027F40CC800122BE0 /* _001_InitialSetupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79F27F40CC800122BE0 /* _001_InitialSetupMigration.swift */; }; @@ -1278,7 +1278,6 @@ B8F5F56425EC8453003BF8D4 /* Notification+Contacts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Contacts.swift"; sourceTree = ""; }; B8F5F58225EC94A6003BF8D4 /* Collection+Subscripting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+Subscripting.swift"; sourceTree = ""; }; B8F5F60225EDE16F003BF8D4 /* DataExtractionNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataExtractionNotification.swift; sourceTree = ""; }; - B8F5F61A25EDE4BF003BF8D4 /* DataExtractionNotificationInfoMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataExtractionNotificationInfoMessage.swift; sourceTree = ""; }; B8F5F71925F1B35C003BF8D4 /* MediaPlaceholderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlaceholderView.swift; sourceTree = ""; }; B8F5F72225F1B4CA003BF8D4 /* DownloadAttachmentModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadAttachmentModal.swift; sourceTree = ""; }; B8FF8E6125C10DA5004D1F22 /* GeoLite2-Country-Blocks-IPv4 */ = {isa = PBXFileReference; lastKnownFileType = file.bplist; name = "GeoLite2-Country-Blocks-IPv4"; path = "Countries/GeoLite2-Country-Blocks-IPv4"; sourceTree = ""; }; @@ -1771,6 +1770,7 @@ FD09C5E528260FF9000CE219 /* MediaGalleryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGalleryViewModel.swift; sourceTree = ""; }; FD09C5E728264937000CE219 /* MediaDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaDetailViewController.swift; sourceTree = ""; }; FD09C5E9282A1BB2000CE219 /* ThreadTypingIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadTypingIndicator.swift; sourceTree = ""; }; + FD09C5EB282B8F17000CE219 /* AttachmentError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentError.swift; sourceTree = ""; }; FD17D79527F3E04600122BE0 /* _001_InitialSetupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _001_InitialSetupMigration.swift; sourceTree = ""; }; FD17D79827F40AB800122BE0 /* _003_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _003_YDBToGRDBMigration.swift; sourceTree = ""; }; FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMKLegacyModels.swift; sourceTree = ""; }; @@ -2439,14 +2439,6 @@ path = Shared; sourceTree = ""; }; - B8F5F61925EDE4B0003BF8D4 /* Data Extraction */ = { - isa = PBXGroup; - children = ( - B8F5F61A25EDE4BF003BF8D4 /* DataExtractionNotificationInfoMessage.swift */, - ); - path = "Data Extraction"; - sourceTree = ""; - }; B8FF8E6025C10D8B004D1F22 /* Countries */ = { isa = PBXGroup; children = ( @@ -2503,7 +2495,6 @@ children = ( FDF0B7562807F35E004C14C5 /* Errors */, C3D9E3B52567685D0040E4F3 /* Attachments */, - B8F5F61925EDE4B0003BF8D4 /* Data Extraction */, C32C5D22256DD496003C73A2 /* Link Previews */, C379DC6825672B5E0002D4EB /* Notifications */, C32C59F8256DB5A6003C73A2 /* Pollers */, @@ -3759,6 +3750,7 @@ children = ( FDF0B7572807F368004C14C5 /* MessageReceiverError.swift */, FDF0B7592807F3A3004C14C5 /* MessageSenderError.swift */, + FD09C5EB282B8F17000CE219 /* AttachmentError.swift */, ); path = Errors; sourceTree = ""; @@ -4877,7 +4869,6 @@ FDA8EAFE280E8B78002B68E5 /* FailedMessagesJob.swift in Sources */, C3BBE0802554CDD70050F1E3 /* Storage.swift in Sources */, C3DB66AC260ACA42001EFC55 /* OpenGroupManagerV2.swift in Sources */, - B8F5F61B25EDE4BF003BF8D4 /* DataExtractionNotificationInfoMessage.swift in Sources */, C379DCF4256735770002D4EB /* VisibleMessage+Attachment.swift in Sources */, FD09797F27FCFBFF00936362 /* OWSAES256Key+Utilities.swift in Sources */, FD09798327FD1A1500936362 /* ClosedGroup.swift in Sources */, @@ -4890,6 +4881,7 @@ FDF0B7552807C4BB004C14C5 /* SSKEnvironment.swift in Sources */, C32C59C3256DB41F003C73A2 /* TSGroupModel.m in Sources */, B8856ECE256F1E58001CE70E /* OWSPreferences.m in Sources */, + FD09C5EC282B8F18000CE219 /* AttachmentError.swift in Sources */, FD17D79927F40AB800122BE0 /* _003_YDBToGRDBMigration.swift in Sources */, C3C2A7842553AAF300C340D1 /* SNProto.swift in Sources */, FDF0B7512807BA56004C14C5 /* NotificationsProtocol.swift in Sources */, diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index b9f9c3372..40ea11916 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -85,8 +85,15 @@ extension ContextMenuVC { ) ) let canSave: Bool = ( - item.cellType != .textOnlyMessage && - canCopy + item.cellType == .mediaMessage && + (item.attachments ?? []) + .filter { attachment in + attachment.isValid && + attachment.isVisualMedia && ( + attachment.state == .downloaded || + attachment.state == .uploaded + ) + }.isEmpty == false ) let canCopySessionId: Bool = ( item.interactionVariant == .standardIncoming && diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 36a28d0e2..0ac633d1f 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -741,41 +741,6 @@ extension ConversationVC: } } - func showFailedMessageSheet(for tsMessage: TSOutgoingMessage) { - let thread = self.thread - let error = tsMessage.mostRecentFailureText - let sheet = UIAlertController(title: error, message: nil, preferredStyle: .actionSheet) - sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) - sheet.addAction(UIAlertAction(title: "Delete", style: .destructive, handler: { _ in - Storage.write { transaction in - tsMessage.remove(with: transaction) - Storage.shared.cancelPendingMessageSendJobIfNeeded(for: tsMessage.timestamp, using: transaction) - } - })) - sheet.addAction(UIAlertAction(title: "Resend", style: .default, handler: { _ in - let message = VisibleMessage.from(tsMessage) - Storage.write { transaction in - var attachments: [TSAttachmentStream] = [] - tsMessage.attachmentIds.forEach { attachmentID in - guard let attachmentID = attachmentID as? String else { return } - let attachment = TSAttachment.fetch(uniqueId: attachmentID, transaction: transaction) - guard let stream = attachment as? TSAttachmentStream else { return } - attachments.append(stream) - } - MessageSender.prep(attachments, for: message, using: transaction) - MessageSender.send(message, in: thread, using: transaction) - } - })) - // HACK: Extracting this info from the error string is pretty dodgy - let prefix = "HTTP request failed at destination (Service node " - if error.hasPrefix(prefix) { - let rest = error.substring(from: prefix.count) - if let index = rest.firstIndex(of: ")") { - let snodeAddress = String(rest[rest.startIndex.. UnsendRequest? { - if let message = viewItem.interaction as? TSMessage, - message.isOpenGroupMessage || message.serverHash == nil { return nil } - let unsendRequest = UnsendRequest() - switch viewItem.interaction.interactionType() { - case .incomingMessage: - if let incomingMessage = viewItem.interaction as? TSIncomingMessage { - unsendRequest.author = incomingMessage.authorId - } - case .outgoingMessage: unsendRequest.author = getUserHexEncodedPublicKey() - default: return nil // Should never occur - } - unsendRequest.timestamp = viewItem.interaction.timestamp - return unsendRequest - } - - func deleteLocally(_ viewItem: ConversationViewItem) { - viewItem.deleteLocallyAction() - if let unsendRequest = buildUnsendRequest(viewItem) { - SNMessagingKitConfiguration.shared.storage.write { transaction in - MessageSender.send(unsendRequest, to: .contact(publicKey: getUserHexEncodedPublicKey()), using: transaction).retainUntilComplete() - } - } - } - - func deleteForEveryone(_ viewItem: ConversationViewItem) { - viewItem.deleteLocallyAction() - viewItem.deleteRemotelyAction() - if let unsendRequest = buildUnsendRequest(viewItem) { - SNMessagingKitConfiguration.shared.storage.write { transaction in - MessageSender.send(unsendRequest, in: self.thread, using: transaction as! YapDatabaseReadWriteTransaction) - } - } - } - - func save(_ viewItem: ConversationViewItem) { - guard viewItem.canSaveMedia() else { return } - viewItem.saveMediaAction() - sendMediaSavedNotificationIfNeeded(for: viewItem) - } - - func ban(_ viewItem: ConversationViewItem) { - guard let message = viewItem.interaction as? TSIncomingMessage, message.isOpenGroupMessage else { return } - let explanation = "This will ban the selected user from this room. It won't ban them from other rooms." - let alert = UIAlertController(title: "Session", message: explanation, preferredStyle: .alert) - let threadID = thread.uniqueId! - alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in - let publicKey = message.authorId - guard let openGroupV2 = Storage.shared.getV2OpenGroup(for: threadID) else { return } - OpenGroupAPIV2.ban(publicKey, from: openGroupV2.room, on: openGroupV2.server).retainUntilComplete() - })) - alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: nil)) - present(alert, animated: true, completion: nil) - } - - func banAndDeleteAllMessages(_ viewItem: ConversationViewItem) { - guard let message = viewItem.interaction as? TSIncomingMessage, message.isOpenGroupMessage else { return } - let explanation = "This will ban the selected user from this room and delete all messages sent by them. It won't ban them from other rooms or delete the messages they sent there." - let alert = UIAlertController(title: "Session", message: explanation, preferredStyle: .alert) - let threadID = thread.uniqueId! - alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in - let publicKey = message.authorId - guard let openGroupV2 = Storage.shared.getV2OpenGroup(for: threadID) else { return } - OpenGroupAPIV2.banAndDeleteAllMessages(publicKey, from: openGroupV2.room, on: openGroupV2.server).retainUntilComplete() - })) - alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: nil)) - present(alert, animated: true, completion: nil) - } - - func handleQuoteViewCancelButtonTapped() { - snInputView.quoteDraftInfo = nil - } - - func openURL(_ url: URL) { // URLs can be unsafe, so always ask the user whether they want to open one - let title = NSLocalizedString("modal_open_url_title", comment: "") - let message = String(format: NSLocalizedString("modal_open_url_explanation", comment: ""), url.absoluteString) - let alertVC = UIAlertController.init(title: title, message: message, preferredStyle: .actionSheet) - let openAction = UIAlertAction.init(title: NSLocalizedString("modal_open_url_button_title", comment: ""), style: .default) { _ in + let alertVC = UIAlertController.init( + title: "modal_open_url_title".localized(), + message: String(format: "modal_open_url_explanation".localized(), url.absoluteString), + preferredStyle: .actionSheet + ) + alertVC.addAction(UIAlertAction.init(title: "modal_open_url_button_title".localized(), style: .default) { [weak self] _ in UIApplication.shared.open(url, options: [:], completionHandler: nil) - self.showInputAccessoryView() - } - alertVC.addAction(openAction) - let copyAction = UIAlertAction.init(title: NSLocalizedString("modal_copy_url_button_title", comment: ""), style: .default) { _ in + self?.showInputAccessoryView() + }) + alertVC.addAction(UIAlertAction.init(title: "modal_copy_url_button_title".localized(), style: .default) { [weak self] _ in UIPasteboard.general.string = url.absoluteString - self.showInputAccessoryView() - } - alertVC.addAction(copyAction) - let cancelAction = UIAlertAction.init(title: NSLocalizedString("cancel", comment: ""), style: .cancel) {_ in - self.showInputAccessoryView() - } - alertVC.addAction(cancelAction) + self?.showInputAccessoryView() + }) + alertVC.addAction(UIAlertAction.init(title: "cancel".localized(), style: .cancel) { [weak self] _ in + self?.showInputAccessoryView() + }) + self.presentAlert(alertVC) } - func joinOpenGroup(name: String, url: String) { - // Open groups can be unsafe, so always ask the user whether they want to join one - let joinOpenGroupModal = JoinOpenGroupModal(name: name, url: url) - joinOpenGroupModal.modalPresentationStyle = .overFullScreen - joinOpenGroupModal.modalTransitionStyle = .crossDissolve - present(joinOpenGroupModal, animated: true, completion: nil) - } - func handleReplyButtonTapped(for item: ConversationViewModel.Item) { reply(item) } @@ -966,93 +786,450 @@ extension ConversationVC: present(userDetailsSheet, animated: true, completion: nil) } - - // MARK: Voice Message Playback - @objc func handleAudioDidFinishPlayingNotification(_ notification: Notification) { - // Play the next voice message if there is one - guard let audioPlayer = audioPlayer, let viewItem = audioPlayer.owner as? ConversationViewItem, - let index = viewItems.firstIndex(where: { $0 === viewItem }), index < (viewItems.endIndex - 1) else { return } - let nextViewItem = viewItems[index + 1] - guard nextViewItem.messageCellType == .audio else { return } - playOrPauseAudio(for: nextViewItem) - } - func playOrPauseAudio(for viewItem: ConversationViewItem) { - guard let attachment = viewItem.attachmentStream else { return } - let fileManager = FileManager.default - guard let path = attachment.originalFilePath, fileManager.fileExists(atPath: path), - let url = attachment.originalMediaURL else { return } - if let audioPlayer = audioPlayer { - if let owner = audioPlayer.owner as? ConversationViewItem, owner === viewItem { - audioPlayer.playbackRate = 1 - audioPlayer.togglePlayState() - return - } else { - audioPlayer.stop() - self.audioPlayer = nil + // MARK: --action handling + + + func showFailedMessageSheet(for item: ConversationViewModel.Item) { + let sheet = UIAlertController(title: item.mostRecentFailureText, message: nil, preferredStyle: .actionSheet) + sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) + sheet.addAction(UIAlertAction(title: "Delete", style: .destructive, handler: { _ in + GRDBStorage.shared.writeAsync { db in + try Interaction + .filter(id: item.interactionId) + .deleteAll(db) + } + })) + sheet.addAction(UIAlertAction(title: "Resend", style: .default, handler: { _ in + GRDBStorage.shared.writeAsync { [weak self] db in + guard + let interaction: Interaction = try? Interaction.fetchOne(db, id: item.interactionId), + let thread: SessionThread = self?.viewModel.viewData.thread + else { return } + try MessageSender.send( + db, + interaction: interaction, + in: thread + ) + } + })) + + // HACK: Extracting this info from the error string is pretty dodgy + let prefix: String = "HTTP request failed at destination (Service node " + if let mostRecentFailureText: String = item.mostRecentFailureText, mostRecentFailureText.hasPrefix(prefix) { + let rest = mostRecentFailureText.substring(from: prefix.count) + + if let index = rest.firstIndex(of: ")") { + let snodeAddress = String(rest[rest.startIndex.., onComplete: (() -> ())?) { + // Show a loading indicator + let (promise, seal) = Promise.pending() + + ModalActivityIndicatorViewController.present(fromViewController: viewController, canCancel: false) { _ in + seal.fulfill(()) + } + + promise + .then { _ -> Promise in request } + .done { _ in + // Delete the interaction (and associated data) from the database + GRDBStorage.shared.writeAsync { db in + _ = try Interaction + .filter(id: item.interactionId) + .deleteAll(db) + } + } + .ensure { + DispatchQueue.main.async { [weak self] in + if self?.presentedViewController is ModalActivityIndicatorViewController { + self?.dismiss(animated: true, completion: nil) // Dismiss the loader + } + + onComplete?() + } + } + .retainUntilComplete() + } + + // How we delete the message differs depending on the type of thread + switch item.threadVariant { + // Handle open group messages the old way + case .openGroup: + // If it's an incoming message the user must have moderator status + let result: (openGroupServerMessageId: Int64?, openGroup: OpenGroup?)? = GRDBStorage.shared.read { db -> (Int64?, OpenGroup?) in + ( + try Interaction + .select(.openGroupServerMessageId) + .filter(id: item.interactionId) + .asRequest(of: Int64.self) + .fetchOne(db), + try OpenGroup.fetchOne(db, id: thread.id) + ) + } + + guard + let openGroup: OpenGroup = result?.openGroup, + let openGroupServerMessageId: Int64 = result?.openGroupServerMessageId, ( + item.interactionVariant != .standardIncoming || + OpenGroupAPIV2.isUserModerator(userPublicKey, for: openGroup.room, on: openGroup.server) + ) + else { return } + + // Delete the message from the open group + deleteRemotely( + from: self, + request: OpenGroupAPIV2.deleteMessage( + with: openGroupServerMessageId, + from: openGroup.room, + on: openGroup.server + ) + ) { [weak self] in + self?.showInputAccessoryView() + } + + case .contact, .closedGroup: + let serverHash: String? = GRDBStorage.shared.read { db -> String? in + try Interaction + .select(.serverHash) + .filter(id: item.interactionId) + .asRequest(of: String.self) + .fetchOne(db) + } + let unsendRequest: UnsendRequest = UnsendRequest( + timestamp: UInt64(item.timestampMs), + author: (item.interactionVariant == .standardOutgoing ? + userPublicKey : + item.authorId + ) + ) + + // For incoming interactions or interactions with no serverHash just delete them locally + guard item.interactionVariant == .standardOutgoing, let serverHash: String = serverHash else { + GRDBStorage.shared.writeAsync { db in + _ = try Interaction + .filter(id: item.interactionId) + .deleteAll(db) + + // No need to send the unsendRequest if there is no serverHash (ie. the message + // was outgoing but never got to the server) + guard serverHash != nil else { return } + + MessageSender + .send( + db, + message: unsendRequest, + threadId: thread.id, + interactionId: nil, + to: .contact(publicKey: userPublicKey) + ) + } + return + } + + let alertVC = UIAlertController.init(title: nil, message: nil, preferredStyle: .actionSheet) + alertVC.addAction(UIAlertAction(title: "delete_message_for_me".localized(), style: .destructive) { [weak self] _ in + GRDBStorage.shared.writeAsync { db in + _ = try Interaction + .filter(id: item.interactionId) + .deleteAll(db) + + MessageSender + .send( + db, + message: unsendRequest, + threadId: thread.id, + interactionId: nil, + to: .contact(publicKey: userPublicKey) + ) + } + self?.showInputAccessoryView() + }) + + alertVC.addAction(UIAlertAction( + title: (item.threadVariant == .closedGroup ? + "delete_message_for_everyone".localized() : + String(format: "delete_message_for_me_and_recipient".localized(), threadName) + ), + style: .destructive + ) { [weak self] _ in + deleteRemotely( + from: self, + request: SnodeAPI + .deleteMessage( + publicKey: thread.id, + serverHashes: [serverHash] + ) + .map { _ in () } + ) { [weak self] in + GRDBStorage.shared.writeAsync { db in + try MessageSender + .send( + db, + message: unsendRequest, + interactionId: nil, + in: thread + ) + } + + self?.showInputAccessoryView() + } + }) + + alertVC.addAction(UIAlertAction.init(title: "TXT_CANCEL_TITLE".localized(), style: .cancel) { [weak self] _ in + self?.showInputAccessoryView() + }) + + self.inputAccessoryView?.isHidden = true + self.inputAccessoryView?.alpha = 0 + self.presentAlert(alertVC) + } + } + + func save(_ item: ConversationViewModel.Item) { + guard item.cellType == .mediaMessage else { return } + + let mediaAttachments: [(Attachment, String)] = (item.attachments ?? []) + .filter { attachment in + attachment.isValid && + attachment.isVisualMedia && ( + attachment.state == .downloaded || + attachment.state == .uploaded + ) + } + .compactMap { attachment in + guard let originalFilePath: String = attachment.originalFilePath else { return nil } + + return (attachment, originalFilePath) + } + + guard !mediaAttachments.isEmpty else { return } + + mediaAttachments.forEach { attachment, originalFilePath in + PHPhotoLibrary.shared().performChanges( + { + if attachment.isImage || attachment.isAnimated { + PHAssetChangeRequest.creationRequestForAssetFromImage( + atFileURL: URL(fileURLWithPath: originalFilePath) + ) + } + else if attachment.isVideo { + PHAssetChangeRequest.creationRequestForAssetFromVideo( + atFileURL: URL(fileURLWithPath: originalFilePath) + ) + } + }, + completionHandler: { _, _ in } + ) + } + + // Send a 'media saved' notification if needed + guard self.viewModel.viewData.thread.variant == .contact, item.interactionVariant == .standardIncoming else { + return + } + + let thread: SessionThread = self.viewModel.viewData.thread + + GRDBStorage.shared.writeAsync { db in + try MessageSender.send( + db, + message: DataExtractionNotification( + kind: .mediaSaved(timestamp: UInt64(item.timestampMs)) + ), + interactionId: nil, + in: thread + ) + } + } + + func ban(_ item: ConversationViewModel.Item) { + guard item.threadVariant == .openGroup else { return } + + let threadId: String = self.viewModel.viewData.thread.id + let alert: UIAlertController = UIAlertController( + title: "Session", + message: "This will ban the selected user from this room. It won't ban them from other rooms.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in + guard let openGroup: OpenGroup = GRDBStorage.shared.read({ db in try OpenGroup.fetchOne(db, id: threadId) }) else { + return + } + + OpenGroupAPIV2 + .ban(item.authorId, from: openGroup.room, on: openGroup.server) + .retainUntilComplete() + })) + alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: nil)) + + present(alert, animated: true, completion: nil) + } + + func banAndDeleteAllMessages(_ item: ConversationViewModel.Item) { + guard item.threadVariant == .openGroup else { return } + + let threadId: String = self.viewModel.viewData.thread.id + let alert: UIAlertController = UIAlertController( + title: "Session", + message: "This will ban the selected user from this room and delete all messages sent by them. It won't ban them from other rooms or delete the messages they sent there.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in + guard let openGroup: OpenGroup = GRDBStorage.shared.read({ db in try OpenGroup.fetchOne(db, id: threadId) }) else { + return + } + + OpenGroupAPIV2 + .banAndDeleteAllMessages(item.authorId, from: openGroup.room, on: openGroup.server) + .retainUntilComplete() + })) + alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: nil)) + + present(alert, animated: true, completion: nil) + } + + // MARK: - VoiceMessageRecordingViewDelegate + func startVoiceMessageRecording() { // Request permission if needed requestMicrophonePermissionIfNeeded() { [weak self] in self?.cancelVoiceMessageRecording() } + // Keep screen on UIApplication.shared.isIdleTimerDisabled = false guard AVAudioSession.sharedInstance().recordPermission == .granted else { return } + // Cancel any current audio playback - audioPlayer?.stop() - audioPlayer = nil + self.viewModel.stopAudio() + // Create URL - let directory = OWSTemporaryDirectory() - let fileName = "\(NSDate.millisecondTimestamp()).m4a" - let path = (directory as NSString).appendingPathComponent(fileName) - let url = URL(fileURLWithPath: path) + let directory: String = OWSTemporaryDirectory() + let fileName: String = "\(Int64(floor(Date().timeIntervalSince1970 * 1000))).m4a" + let url: URL = URL(fileURLWithPath: directory).appendingPathComponent(fileName) + // Set up audio session let isConfigured = audioSession.startAudioActivity(recordVoiceMessageActivity) guard isConfigured else { return cancelVoiceMessageRecording() } + // Set up audio recorder - let settings: [String:NSNumber] = [ - AVFormatIDKey : NSNumber(value: kAudioFormatMPEG4AAC), - AVSampleRateKey : NSNumber(value: 44100), - AVNumberOfChannelsKey : NSNumber(value: 2), - AVEncoderBitRateKey : NSNumber(value: 128 * 1024) - ] let audioRecorder: AVAudioRecorder do { - audioRecorder = try AVAudioRecorder(url: url, settings: settings) + audioRecorder = try AVAudioRecorder( + url: url, + settings: [ + AVFormatIDKey: NSNumber(value: kAudioFormatMPEG4AAC), + AVSampleRateKey: NSNumber(value: 44100), + AVNumberOfChannelsKey: NSNumber(value: 2), + AVEncoderBitRateKey: NSNumber(value: 128 * 1024) + ] + ) audioRecorder.isMeteringEnabled = true self.audioRecorder = audioRecorder - } catch { + } + catch { SNLog("Couldn't start audio recording due to error: \(error).") return cancelVoiceMessageRecording() } + // Limit voice messages to a minute audioTimer = Timer.scheduledTimer(withTimeInterval: 180, repeats: false, block: { [weak self] _ in self?.snInputView.hideVoiceMessageUI() self?.endVoiceMessageRecording() }) + // Prepare audio recorder guard audioRecorder.prepareToRecord() else { SNLog("Couldn't prepare audio recorder.") return cancelVoiceMessageRecording() } + // Start recording guard audioRecorder.record() else { SNLog("Couldn't record audio.") @@ -1062,34 +1239,49 @@ extension ConversationVC: func endVoiceMessageRecording() { UIApplication.shared.isIdleTimerDisabled = true + // Hide the UI snInputView.hideVoiceMessageUI() + // Cancel the timer audioTimer?.invalidate() + // Check preconditions guard let audioRecorder = audioRecorder else { return } + // Get duration let duration = audioRecorder.currentTime + // Stop the recording stopVoiceMessageRecording() + // Check for user misunderstanding guard duration > 1 else { self.audioRecorder = nil - let title = NSLocalizedString("VOICE_MESSAGE_TOO_SHORT_ALERT_TITLE", comment: "") - let message = NSLocalizedString("VOICE_MESSAGE_TOO_SHORT_ALERT_MESSAGE", comment: "") - return OWSAlerts.showAlert(title: title, message: message) + + OWSAlerts.showAlert( + title: "VOICE_MESSAGE_TOO_SHORT_ALERT_TITLE".localized(), + message: "VOICE_MESSAGE_TOO_SHORT_ALERT_MESSAGE".localized() + ) + return } + // Get data let dataSourceOrNil = DataSourcePath.dataSource(with: audioRecorder.url, shouldDeleteOnDeallocation: true) self.audioRecorder = nil + guard let dataSource = dataSourceOrNil else { return SNLog("Couldn't load recorded data.") } + // Create attachment - let fileName = (NSLocalizedString("VOICE_MESSAGE_FILE_NAME", comment: "") as NSString).appendingPathExtension("m4a") + let fileName = ("VOICE_MESSAGE_FILE_NAME".localized() as NSString).appendingPathExtension("m4a") dataSource.sourceFilename = fileName + let attachment = SignalAttachment.voiceMessageAttachment(dataSource: dataSource, dataUTI: kUTTypeMPEG4Audio as String) + guard !attachment.hasError else { return showErrorAlert(for: attachment, onDismiss: nil) } + // Send attachment sendAttachments([ attachment ], with: "") } @@ -1106,59 +1298,43 @@ extension ConversationVC: audioSession.endAudioActivity(recordVoiceMessageActivity) } - // MARK: Data Extraction Notifications - @objc func sendScreenshotNotificationIfNeeded() { - /* - guard thread is TSContactThread else { return } - let message = DataExtractionNotification() - message.kind = .screenshot - Storage.write { transaction in - MessageSender.send(message, in: self.thread, using: transaction) - } - */ - } + // MARK: - Permissions - func sendMediaSavedNotificationIfNeeded(for viewItem: ConversationViewItem) { - guard thread is TSContactThread, viewItem.interaction.interactionType() == .incomingMessage else { return } - let message = DataExtractionNotification() - message.kind = .mediaSaved(timestamp: viewItem.interaction.timestamp) - Storage.write { transaction in - MessageSender.send(message, in: self.thread, using: transaction) - } - } - - // MARK: Requesting Permission func requestCameraPermissionIfNeeded() -> Bool { switch AVCaptureDevice.authorizationStatus(for: .video) { - case .authorized: return true - case .denied, .restricted: - let modal = PermissionMissingModal(permission: "camera") { } - modal.modalPresentationStyle = .overFullScreen - modal.modalTransitionStyle = .crossDissolve - present(modal, animated: true, completion: nil) - return false - case .notDetermined: - AVCaptureDevice.requestAccess(for: .video, completionHandler: { _ in }) - return false - default: return false + case .authorized: return true + case .denied, .restricted: + let modal = PermissionMissingModal(permission: "camera") { } + modal.modalPresentationStyle = .overFullScreen + modal.modalTransitionStyle = .crossDissolve + present(modal, animated: true, completion: nil) + return false + + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video, completionHandler: { _ in }) + return false + + default: return false } } func requestMicrophonePermissionIfNeeded(onNotGranted: @escaping () -> Void) { switch AVAudioSession.sharedInstance().recordPermission { - case .granted: break - case .denied: - onNotGranted() - let modal = PermissionMissingModal(permission: "microphone") { + case .granted: break + case .denied: onNotGranted() - } - modal.modalPresentationStyle = .overFullScreen - modal.modalTransitionStyle = .crossDissolve - present(modal, animated: true, completion: nil) - case .undetermined: - onNotGranted() - AVAudioSession.sharedInstance().requestRecordPermission { _ in } - default: break + let modal = PermissionMissingModal(permission: "microphone") { + onNotGranted() + } + modal.modalPresentationStyle = .overFullScreen + modal.modalTransitionStyle = .crossDissolve + present(modal, animated: true, completion: nil) + + case .undetermined: + onNotGranted() + AVAudioSession.sharedInstance().requestRecordPermission { _ in } + + default: break } } @@ -1201,25 +1377,29 @@ extension ConversationVC: } } } + switch authorizationStatus { - case .authorized, .limited: - onAuthorized() - case .denied, .restricted: - let modal = PermissionMissingModal(permission: "library") { } - modal.modalPresentationStyle = .overFullScreen - modal.modalTransitionStyle = .crossDissolve - present(modal, animated: true, completion: nil) - default: return + case .authorized, .limited: + onAuthorized() + + case .denied, .restricted: + let modal = PermissionMissingModal(permission: "library") { } + modal.modalPresentationStyle = .overFullScreen + modal.modalTransitionStyle = .crossDissolve + present(modal, animated: true, completion: nil) + + default: return } } // MARK: - Convenience - + func showErrorAlert(for attachment: SignalAttachment, onDismiss: (() -> ())?) { - let title = NSLocalizedString("ATTACHMENT_ERROR_ALERT_TITLE", comment: "") - let message = attachment.localizedErrorDescription ?? SignalAttachment.missingDataErrorMessage - - OWSAlerts.showAlert(title: title, message: message, buttonTitle: nil) { _ in + OWSAlerts.showAlert( + title: "ATTACHMENT_ERROR_ALERT_TITLE".localized(), + message: (attachment.localizedErrorDescription ?? SignalAttachment.missingDataErrorMessage), + buttonTitle: nil + ) { _ in onDismiss?() } } @@ -1236,52 +1416,52 @@ extension ConversationVC: UIDocumentInteractionControllerDelegate { // MARK: - Message Request Actions extension ConversationVC { - - fileprivate func approveMessageRequestIfNeeded(for thread: TSThread?, isNewThread: Bool, timestamp: Double) -> Promise { - guard let contactThread: TSContactThread = thread as? TSContactThread else { return Promise.value(()) } - + fileprivate func approveMessageRequestIfNeeded( + for thread: SessionThread?, + isNewThread: Bool, + timestampMs: Int64 + ) -> Promise { + guard let thread: SessionThread = thread, thread.variant == .contact else { return Promise.value(()) } + // If the contact doesn't exist then we should create it so we can store the 'isApproved' state // (it'll be updated with correct profile info if they accept the message request so this // shouldn't cause weird behaviours) - let sessionId: String = contactThread.contactSessionID() - guard - let contact: Contact = GRDBStorage.shared.read({ db in Contact.fetchOrCreate(db, id: sessionId) }), + let contact: Contact = GRDBStorage.shared.read({ db in Contact.fetchOrCreate(db, id: thread.id) }), !contact.isApproved else { return Promise.value(()) } - + return Promise.value(()) .then { [weak self] _ -> Promise in guard !isNewThread else { return Promise.value(()) } - guard let strongSelf = self else { return Promise(error: MessageSender.Error.noThread) } - + guard let strongSelf = self else { return Promise(error: MessageSenderError.noThread) } + // If we aren't creating a new thread (ie. sending a message request) then send a // messageRequestResponse back to the sender (this allows the sender to know that // they have been approved and can now use this contact in closed groups) let (promise, seal) = Promise.pending() let messageRequestResponse: MessageRequestResponse = MessageRequestResponse( - isApproved: true + isApproved: true, + sentTimestampMs: UInt64(timestampMs) ) - messageRequestResponse.sentTimestamp = timestamp - + // Show a loading indicator ModalActivityIndicatorViewController.present(fromViewController: strongSelf, canCancel: false) { _ in seal.fulfill(()) } - + return promise .then { _ -> Promise in - let (promise, seal) = Promise.pending() - Storage.writeSync { transaction in - MessageSender.sendNonDurably(messageRequestResponse, in: contactThread, using: transaction) - .done { seal.fulfill(()) } - .catch { _ in seal.fulfill(()) } // Fulfill even if this failed; the configuration in the swarm should be at most 2 days old - .retainUntilComplete() + GRDBStorage.shared.write { db in + try MessageSender.sendNonDurably( + db, + message: messageRequestResponse, + interactionId: nil, + in: thread + ) } - - return promise } .map { _ in if self?.presentedViewController is ModalActivityIndicatorViewController { @@ -1299,26 +1479,26 @@ extension ConversationVC { didApproveMe: .update(contact.didApproveMe || !isNewThread) ) .save(db) + + // Send a sync message with the details of the contact + try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() }, completion: { db, _ in - // Send a sync message with the details of the contact - MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() - // Hide the 'messageRequestView' since the request has been approved DispatchQueue.main.async { [weak self] in let messageRequestViewWasVisible: Bool = (self?.messageRequestView.isHidden == false) - + UIView.animate(withDuration: 0.3) { self?.messageRequestView.isHidden = true self?.scrollButtonMessageRequestsBottomConstraint?.isActive = false self?.scrollButtonBottomConstraint?.isActive = true - + // Update the table content inset and offset to account for // the dissapearance of the messageRequestsView if messageRequestViewWasVisible { let messageRequestsOffset: CGFloat = ((self?.messageRequestView.bounds.height ?? 0) + 16) - let oldContentInset: UIEdgeInsets = (self?.messagesTableView.contentInset ?? UIEdgeInsets.zero) - self?.messagesTableView.contentInset = UIEdgeInsets( + let oldContentInset: UIEdgeInsets = (self?.tableView.contentInset ?? UIEdgeInsets.zero) + self?.tableView.contentInset = UIEdgeInsets( top: 0, leading: 0, bottom: max(oldContentInset.bottom - messageRequestsOffset, 0), @@ -1327,9 +1507,6 @@ extension ConversationVC { } } - // Update UI - self?.updateNavBarButtons() - // Remove the 'MessageRequestsViewController' from the nav hierarchy if present if let viewControllers: [UIViewController] = self?.navigationController?.viewControllers, @@ -1345,80 +1522,67 @@ extension ConversationVC { ) } } - + @objc func acceptMessageRequest() { - let promise: Promise = self.approveMessageRequestIfNeeded( - for: self.thread, + self.approveMessageRequestIfNeeded( + for: self.viewModel.viewData.thread, isNewThread: false, - timestamp: NSDate.millisecondTimestamp() + timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) ) - - // Show an error indicating that approving the thread failed - promise.catch(on: DispatchQueue.main) { [weak self] _ in - let alert = UIAlertController(title: "Session", message: NSLocalizedString("MESSAGE_REQUESTS_APPROVAL_ERROR_MESSAGE", comment: ""), preferredStyle: .alert) - alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil)) + .catch(on: DispatchQueue.main) { [weak self] _ in + // Show an error indicating that approving the thread failed + let alert = UIAlertController( + title: "Session", + message: "MESSAGE_REQUESTS_APPROVAL_ERROR_MESSAGE".localized(), + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "BUTTON_OK".localized(), style: .default, handler: nil)) self?.present(alert, animated: true, completion: nil) } - - promise.retainUntilComplete() + .retainUntilComplete() } - + @objc func deleteMessageRequest() { - guard let uniqueId: String = thread.uniqueId else { return } + guard self.viewModel.viewData.thread.variant == .contact else { return } - let alertVC: UIAlertController = UIAlertController(title: NSLocalizedString("MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON", comment: ""), message: nil, preferredStyle: .actionSheet) - alertVC.addAction(UIAlertAction(title: NSLocalizedString("TXT_DELETE_TITLE", comment: ""), style: .destructive) { _ in + let threadId: String = self.viewModel.viewData.thread.id + let alertVC: UIAlertController = UIAlertController( + title: "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON".localized(), + message: nil, + preferredStyle: .actionSheet + ) + alertVC.addAction(UIAlertAction(title: "TXT_DELETE_TITLE".localized(), style: .destructive) { _ in // Delete the request GRDBStorage.shared.writeAsync( updates: { [weak self] db in // Update the contact - if let contactThread: TSContactThread = self?.thread as? TSContactThread { - let sessionId: String = contactThread.contactSessionID() - - // Stop observing the `BlockListDidChange` notification (we are about to pop the screen - // so showing the banner just looks buggy) - if let strongSelf = self { - NotificationCenter.default.removeObserver(strongSelf, name: .contactBlockedStateChanged, object: nil) - } - - try? Contact - .fetchOne(db, id: sessionId)? - .with( - isApproved: false, - isBlocked: true, - - // Note: We set this to true so the current user will be able to send a - // message to the person who originally sent them the message request in - // the future if they unblock them - didApproveMe: true - ) - .update(db) - } + try? Contact + .fetchOrCreate(db, id: threadId) + .with( + isApproved: false, + isBlocked: true, + + // Note: We set this to true so the current user will be able to send a + // message to the person who originally sent them the message request in + // the future if they unblock them + didApproveMe: true + ) + .saved(db) + + _ = try SessionThread + .filter(id: threadId) + .deleteAll(db) + + try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() }, completion: { db, _ in - Storage.write( - with: { [weak self] transaction in - // TODO: This should be above the contact updating - Storage.shared.cancelPendingMessageSendJobs(for: uniqueId, using: transaction) - - // Delete all thread content - self?.thread.removeAllThreadInteractions(with: transaction) - self?.thread.remove(with: transaction) - }, - completion: { [weak self] in - // Force a config sync and pop to the previous screen - // TODO: This might cause an "incorrect thread" crash - MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() - - DispatchQueue.main.async { - self?.navigationController?.popViewController(animated: true) - } - } - ) + DispatchQueue.main.async { [weak self] in + self?.navigationController?.popViewController(animated: true) + } } ) }) - alertVC.addAction(UIAlertAction(title: NSLocalizedString("TXT_CANCEL_TITLE", comment: ""), style: .cancel, handler: nil)) + alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil)) self.present(alertVC, animated: true, completion: nil) } } diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 2a97a0e86..b89ef4443 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -410,8 +410,6 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers name: UIResponder.keyboardWillHideNotification, object: nil ) - // Mentions - MentionsManager.populateUserPublicKeyCacheIfNeeded(for: viewModel.viewData.thread.id) // Draft if let draft: String = viewModel.viewData.thread.messageDraft, !draft.isEmpty { @@ -535,6 +533,10 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers updateNavBarButtons(viewData: updatedViewData) } + if viewModel.viewData.isClosedGroupMember != updatedViewData.isClosedGroupMember { + reloadInputViews() + } + if initialLoad || viewModel.viewData.enabledMessageTypes != updatedViewData.enabledMessageTypes { snInputView.setEnabledMessageTypes( updatedViewData.enabledMessageTypes, @@ -821,11 +823,6 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers func conversationViewModelDidReset() { // Not currently in use } - - @objc private func handleGroupUpdatedNotification() { - thread.reload() // Needed so that thread.isCurrentUserMemberInGroup() is up to date - reloadInputViews() - } @objc private func handleMessageSentStatusChanged() { DispatchQueue.main.async { @@ -869,7 +866,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers cell.update( with: item, mediaCache: mediaCache, - playbackInfo: viewModel.playbackInfo(for: item) { [weak self] updatedInfo, error in + playbackInfo: viewModel.playbackInfo(for: item) { updatedInfo, error in DispatchQueue.main.async { guard error == nil else { OWSAlerts.showErrorAlert(message: "INVALID_AUDIO_FILE_ALERT_ERROR_MESSAGE".localized()) diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift index 584884f40..6d9034cc4 100644 --- a/Session/Conversations/Input View/InputView.swift +++ b/Session/Conversations/Input View/InputView.swift @@ -11,22 +11,32 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M case none } + // MARK: - Variables + + private static let linkPreviewViewInset: CGFloat = 6 + + private let threadVariant: SessionThread.Variant private weak var delegate: InputViewDelegate? - var quoteDraftInfo: (model: OWSQuotedReplyModel, isOutgoing: Bool)? { didSet { handleQuoteDraftChanged() } } + + var quoteDraftInfo: (model: QuotedReplyModel, isOutgoing: Bool)? { didSet { handleQuoteDraftChanged() } } var linkPreviewInfo: (url: String, draft: OWSLinkPreviewDraft?)? private var voiceMessageRecordingView: VoiceMessageRecordingView? private lazy var mentionsViewHeightConstraint = mentionsView.set(.height, to: 0) private lazy var linkPreviewView: LinkPreviewView = { - let maxWidth = self.additionalContentContainer.bounds.width - InputView.linkPreviewViewInset - return LinkPreviewView(for: nil, maxWidth: maxWidth, delegate: self) + let maxWidth: CGFloat = (self.additionalContentContainer.bounds.width - InputView.linkPreviewViewInset) + + return LinkPreviewView(maxWidth: maxWidth) { [weak self] in + self?.linkPreviewInfo = nil + self?.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } + } }() var text: String { - get { inputTextView.text } + get { inputTextView.text ?? "" } set { inputTextView.text = newValue } } - + var enabledMessageTypes: MessageTypes = .all { didSet { setEnabledMessageTypes(enabledMessageTypes, message: nil) @@ -96,71 +106,78 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M label.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity) label.textAlignment = .center label.alpha = 0 - + return label }() private lazy var additionalContentContainer = UIView() - // MARK: Settings - private static let linkPreviewViewInset: CGFloat = 6 + // MARK: - Initialization - // MARK: Lifecycle - init(delegate: InputViewDelegate) { + init(threadVariant: SessionThread.Variant, delegate: InputViewDelegate) { + self.threadVariant = threadVariant self.delegate = delegate + super.init(frame: CGRect.zero) + setUpViewHierarchy() } - + override init(frame: CGRect) { preconditionFailure("Use init(delegate:) instead.") } - + required init?(coder: NSCoder) { preconditionFailure("Use init(delegate:) instead.") } - + private func setUpViewHierarchy() { autoresizingMask = .flexibleHeight + // Background & blur let backgroundView = UIView() backgroundView.backgroundColor = isLightMode ? .white : .black backgroundView.alpha = Values.lowOpacity addSubview(backgroundView) backgroundView.pin(to: self) + let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) addSubview(blurView) blurView.pin(to: self) + // Separator let separator = UIView() separator.backgroundColor = Colors.text.withAlphaComponent(0.2) separator.set(.height, to: 1 / UIScreen.main.scale) addSubview(separator) separator.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.top, UIView.HorizontalEdge.trailing ], to: self) + // Bottom stack view let bottomStackView = UIStackView(arrangedSubviews: [ attachmentsButton, inputTextView, container(for: sendButton) ]) bottomStackView.axis = .horizontal bottomStackView.spacing = Values.smallSpacing bottomStackView.alignment = .center self.bottomStackView = bottomStackView + // Main stack view let mainStackView = UIStackView(arrangedSubviews: [ additionalContentContainer, bottomStackView ]) mainStackView.axis = .vertical mainStackView.isLayoutMarginsRelativeArrangement = true + let adjustment = (InputViewButton.expandedSize - InputViewButton.size) / 2 mainStackView.layoutMargins = UIEdgeInsets(top: 2, leading: Values.mediumSpacing - adjustment, bottom: 2, trailing: Values.mediumSpacing - adjustment) addSubview(mainStackView) mainStackView.pin(.top, to: .bottom, of: separator) mainStackView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: self) mainStackView.pin(.bottom, to: .bottom, of: self) - + addSubview(disabledInputLabel) - + disabledInputLabel.pin(.top, to: .top, of: mainStackView) disabledInputLabel.pin(.left, to: .left, of: mainStackView) disabledInputLabel.pin(.right, to: .right, of: mainStackView) disabledInputLabel.set(.height, to: InputViewButton.expandedSize) - + // Mentions insertSubview(mentionsViewContainer, belowSubview: mainStackView) mentionsViewContainer.pin([ UIView.HorizontalEdge.left, UIView.HorizontalEdge.right ], to: self) @@ -168,12 +185,14 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M mentionsViewContainer.addSubview(mentionsView) mentionsView.pin(to: mentionsViewContainer) mentionsViewHeightConstraint.isActive = true + // Voice message button addSubview(voiceMessageButtonContainer) voiceMessageButtonContainer.center(in: sendButton) } + + // MARK: - Updating - // MARK: Updating func inputTextViewDidChangeSize(_ inputTextView: InputTextView) { invalidateIntrinsicContentSize() } @@ -185,7 +204,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M autoGenerateLinkPreviewIfPossible() delegate?.inputTextViewDidChangeContent(inputTextView) } - + func didPasteImageFromPasteboard(_ inputTextView: InputTextView, image: UIImage) { delegate?.didPasteImageFromPasteboard(image) } @@ -193,15 +212,29 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M // We want to show either a link preview or a quote draft, but never both at the same time. When trying to // generate a link preview, wait until we're sure that we'll be able to build a link preview from the given // URL before removing the quote draft. - + private func handleQuoteDraftChanged() { additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } linkPreviewInfo = nil + guard let quoteDraftInfo = quoteDraftInfo else { return } - let direction: QuoteView.Direction = quoteDraftInfo.isOutgoing ? .outgoing : .incoming + let hInset: CGFloat = 6 // Slight visual adjustment let maxWidth = additionalContentContainer.bounds.width - let quoteView = QuoteView(for: quoteDraftInfo.model, direction: direction, hInset: hInset, maxWidth: maxWidth, delegate: self) + + let quoteView: QuoteView = QuoteView( + for: .draft, + authorId: quoteDraftInfo.model.authorId, + quotedText: quoteDraftInfo.model.body, + threadVariant: threadVariant, + direction: (quoteDraftInfo.isOutgoing ? .outgoing : .incoming), + attachment: quoteDraftInfo.model.attachment, + hInset: hInset, + maxWidth: maxWidth + ) { [weak self] in + self?.quoteDraftInfo = nil + } + additionalContentContainer.addSubview(quoteView) quoteView.pin(.left, to: .left, of: additionalContentContainer, withInset: hInset) quoteView.pin(.top, to: .top, of: additionalContentContainer, withInset: 12) @@ -212,7 +245,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M private func autoGenerateLinkPreviewIfPossible() { // Don't allow link previews on 'none' or 'textOnly' input guard enabledMessageTypes == .all else { return } - + // Suggest that the user enable link previews if they haven't already and we haven't // told them about link previews yet let text = inputTextView.text! @@ -234,42 +267,51 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M guard let linkPreviewURL = OWSLinkPreview.previewUrl(forRawBodyText: text, selectedRange: inputTextView.selectedRange) else { return } + // Guard against obsolete updates guard linkPreviewURL != self.linkPreviewInfo?.url else { return } + // Clear content container additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } quoteDraftInfo = nil + // Set the state to loading linkPreviewInfo = (url: linkPreviewURL, draft: nil) - linkPreviewView.linkPreviewState = LinkPreviewLoading() + linkPreviewView.update(with: LinkPreviewLoading(), isOutgoing: false) + // Add the link preview view additionalContentContainer.addSubview(linkPreviewView) linkPreviewView.pin(.left, to: .left, of: additionalContentContainer, withInset: InputView.linkPreviewViewInset) linkPreviewView.pin(.top, to: .top, of: additionalContentContainer, withInset: 10) linkPreviewView.pin(.right, to: .right, of: additionalContentContainer) linkPreviewView.pin(.bottom, to: .bottom, of: additionalContentContainer, withInset: -4) + // Build the link preview - OWSLinkPreview.tryToBuildPreviewInfo(previewUrl: linkPreviewURL).done { [weak self] draft in - guard let self = self else { return } - guard self.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete - self.linkPreviewInfo = (url: linkPreviewURL, draft: draft) - self.linkPreviewView.linkPreviewState = LinkPreviewDraft(linkPreviewDraft: draft) - }.catch { _ in - guard self.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete - self.linkPreviewInfo = nil - self.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } - }.retainUntilComplete() + OWSLinkPreview.tryToBuildPreviewInfo(previewUrl: linkPreviewURL) + .done { [weak self] draft in + guard self?.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete + + self?.linkPreviewInfo = (url: linkPreviewURL, draft: draft) + self?.linkPreviewView.update(with: LinkPreviewDraft(linkPreviewDraft: draft), isOutgoing: false) + } + .catch { [weak self] _ in + guard self?.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete + + self?.linkPreviewInfo = nil + self?.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } + } + .retainUntilComplete() } - + func setEnabledMessageTypes(_ messageTypes: MessageTypes, message: String?) { guard enabledMessageTypes != messageTypes else { return } - + enabledMessageTypes = messageTypes disabledInputLabel.text = (message ?? "") - + attachmentsButton.isUserInteractionEnabled = (messageTypes == .all) voiceMessageButton.isUserInteractionEnabled = (messageTypes == .all) - + UIView.animate(withDuration: 0.3) { [weak self] in self?.bottomStackView?.alpha = (messageTypes != .none ? 1 : 0) self?.attachmentsButton.alpha = (messageTypes == .all ? @@ -283,35 +325,40 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M self?.disabledInputLabel.alpha = (messageTypes != .none ? 0 : 1) } } + + // MARK: - Interaction - // MARK: Interaction override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { // Needed so that the user can tap the buttons when the expanding attachments button is expanded let buttonContainers = [ attachmentsButton.mainButton, attachmentsButton.cameraButton, attachmentsButton.libraryButton, attachmentsButton.documentButton, attachmentsButton.gifButton ] - let buttonContainer = buttonContainers.first { $0.superview!.convert($0.frame, to: self).contains(point) } - if let buttonContainer = buttonContainer { + + if let buttonContainer: InputViewButton = buttonContainers.first(where: { $0.superview?.convert($0.frame, to: self).contains(point) == true }) { return buttonContainer - } else { - return super.hitTest(point, with: event) } + + return super.hitTest(point, with: event) } - + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { let buttonContainers = [ attachmentsButton.gifButtonContainer, attachmentsButton.documentButtonContainer, attachmentsButton.libraryButtonContainer, attachmentsButton.cameraButtonContainer, attachmentsButton.mainButtonContainer ] - let isPointInsideAttachmentsButton = buttonContainers.contains { $0.superview!.convert($0.frame, to: self).contains(point) } + let isPointInsideAttachmentsButton = buttonContainers + .contains { $0.superview!.convert($0.frame, to: self).contains(point) } + if isPointInsideAttachmentsButton { // Needed so that the user can tap the buttons when the expanding attachments button is expanded return true - } else if mentionsViewContainer.frame.contains(point) { + } + + if mentionsViewContainer.frame.contains(point) { // Needed so that the user can tap mentions return true - } else { - return super.point(inside: point, with: event) } + + return super.point(inside: point, with: event) } - + func handleInputViewButtonTapped(_ inputViewButton: InputViewButton) { if inputViewButton == sendButton { delegate?.handleSendButtonTapped() } } @@ -334,10 +381,6 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M voiceMessageRecordingView.handleLongPressEnded(at: location) } - func handleQuoteViewCancelButtonTapped() { - delegate?.handleQuoteViewCancelButtonTapped() - } - override func resignFirstResponder() -> Bool { inputTextView.resignFirstResponder() } @@ -346,11 +389,6 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M // Not relevant in this case } - func handleLinkPreviewCanceled() { - linkPreviewInfo = nil - additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } - } - @objc private func showVoiceMessageUI() { voiceMessageRecordingView?.removeFromSuperview() let voiceMessageButtonFrame = voiceMessageButton.superview!.convert(voiceMessageButton.frame, to: self) @@ -378,30 +416,32 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M } func hideMentionsUI() { - UIView.animate(withDuration: 0.25, animations: { - self.mentionsViewContainer.alpha = 0 - }, completion: { _ in - self.mentionsViewHeightConstraint.constant = 0 - self.mentionsView.tableView.contentOffset = CGPoint.zero - }) + UIView.animate( + withDuration: 0.25, + animations: { [weak self] in + self?.mentionsViewContainer.alpha = 0 + }, + completion: { [weak self] _ in + self?.mentionsViewHeightConstraint.constant = 0 + self?.mentionsView.contentOffset = CGPoint.zero + } + ) } - func showMentionsUI(for candidates: [Mention], in thread: TSThread) { - if let openGroupV2 = Storage.shared.getV2OpenGroup(for: thread.uniqueId!) { - mentionsView.openGroupServer = openGroupV2.server - mentionsView.openGroupRoom = openGroupV2.room - } + func showMentionsUI(for candidates: [ConversationViewModel.MentionInfo]) { mentionsView.candidates = candidates - let mentionCellHeight = Values.smallProfilePictureSize + 2 * Values.smallSpacing + + let mentionCellHeight = (Values.smallProfilePictureSize + 2 * Values.smallSpacing) mentionsViewHeightConstraint.constant = CGFloat(min(3, candidates.count)) * mentionCellHeight layoutIfNeeded() + UIView.animate(withDuration: 0.25) { self.mentionsViewContainer.alpha = 1 } } - func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView) { - delegate?.handleMentionSelected(mention, from: view) + func handleMentionSelected(_ mentionInfo: ConversationViewModel.MentionInfo, from view: MentionSelectionView) { + delegate?.handleMentionSelected(mentionInfo, from: view) } // MARK: - Convenience @@ -417,13 +457,12 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M } } -// MARK: Delegate -protocol InputViewDelegate : AnyObject, ExpandingAttachmentsButtonDelegate, VoiceMessageRecordingViewDelegate { +// MARK: - Delegate +protocol InputViewDelegate: ExpandingAttachmentsButtonDelegate, VoiceMessageRecordingViewDelegate { func showLinkPreviewSuggestionModal() func handleSendButtonTapped() - func handleQuoteViewCancelButtonTapped() func inputTextViewDidChangeContent(_ inputTextView: InputTextView) - func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView) + func handleMentionSelected(_ mentionInfo: ConversationViewModel.MentionInfo, from view: MentionSelectionView) func didPasteImageFromPasteboard(_ image: UIImage) } diff --git a/Session/Conversations/Message Cells/Content Views/DocumentView.swift b/Session/Conversations/Message Cells/Content Views/DocumentView.swift index 402ac6281..d24974df6 100644 --- a/Session/Conversations/Message Cells/Content Views/DocumentView.swift +++ b/Session/Conversations/Message Cells/Content Views/DocumentView.swift @@ -32,21 +32,25 @@ final class DocumentView: UIView { let iconImageViewSize = DocumentView.iconImageViewSize imageView.set(.width, to: iconImageViewSize.width) imageView.set(.height, to: iconImageViewSize.height) + // Body label let titleLabel = UILabel() titleLabel.lineBreakMode = .byTruncatingTail - titleLabel.text = attachment.sourceFilename ?? "File" + titleLabel.text = (attachment.sourceFilename ?? "File") titleLabel.textColor = textColor titleLabel.font = .systemFont(ofSize: Values.smallFontSize, weight: .light) + // Size label let sizeLabel = UILabel() sizeLabel.lineBreakMode = .byTruncatingTail sizeLabel.text = OWSFormat.formatFileSize(UInt(attachment.byteCount)) sizeLabel.textColor = textColor sizeLabel.font = .systemFont(ofSize: Values.verySmallFontSize) + // Label stack view let labelStackView = UIStackView(arrangedSubviews: [ titleLabel, sizeLabel ]) labelStackView.axis = .vertical + // Stack view let stackView = UIStackView(arrangedSubviews: [ imageView, labelStackView ]) stackView.axis = .horizontal diff --git a/Session/Conversations/Message Cells/Content Views/OpenGroupInvitationView.swift b/Session/Conversations/Message Cells/Content Views/OpenGroupInvitationView.swift index 7a1065044..98a653d61 100644 --- a/Session/Conversations/Message Cells/Content Views/OpenGroupInvitationView.swift +++ b/Session/Conversations/Message Cells/Content Views/OpenGroupInvitationView.swift @@ -1,30 +1,24 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -final class OpenGroupInvitationView : UIView { - private let name: String - private let rawURL: String - private let textColor: UIColor - private let isOutgoing: Bool - - private lazy var url: String = { - if let range = rawURL.range(of: "?public_key=") { - return String(rawURL[.. recentThreshold } @@ -364,7 +364,7 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { return false } - mostRecentNotifications.append(now) + mostRecentNotifications.append(nowMs) return true } } diff --git a/Session/Onboarding/RestoreVC.swift b/Session/Onboarding/RestoreVC.swift index 762b1e5c1..87589c81a 100644 --- a/Session/Onboarding/RestoreVC.swift +++ b/Session/Onboarding/RestoreVC.swift @@ -13,6 +13,7 @@ final class RestoreVC: BaseVC { // MARK: Components private lazy var mnemonicTextView: TextView = { let result = TextView(placeholder: NSLocalizedString("vc_restore_seed_text_field_hint", comment: "")) + result.autocapitalizationType = .none result.layer.borderColor = Colors.text.cgColor result.accessibilityLabel = "Recovery phrase text view" return result diff --git a/Session/Shared/ConversationCell.swift b/Session/Shared/ConversationCell.swift index 119d10806..998d227b6 100644 --- a/Session/Shared/ConversationCell.swift +++ b/Session/Shared/ConversationCell.swift @@ -365,15 +365,17 @@ final class ConversationCell: UITableViewCell { ) displayNameLabel.text = threadInfo.displayName timestampLabel.text = DateUtil.formatDate(forDisplay: threadInfo.lastInteractionDate) -// if SSKEnvironment.shared.typingIndicators.typingRecipientId(forThread: thread) != nil { -// snippetLabel.text = "" -// typingIndicatorView.isHidden = false -// typingIndicatorView.startAnimation() -// } else { + + if threadInfo.contactIsTyping { + snippetLabel.text = "" + typingIndicatorView.isHidden = false + typingIndicatorView.startAnimation() + } + else { snippetLabel.attributedText = getSnippet(threadInfo: threadInfo) typingIndicatorView.isHidden = true typingIndicatorView.stopAnimation() -// } + } statusIndicatorView.backgroundColor = nil diff --git a/Session/Sheets & Modals/Modal.swift b/Session/Sheets & Modals/Modal.swift index 91c6bcd14..7a4859814 100644 --- a/Session/Sheets & Modals/Modal.swift +++ b/Session/Sheets & Modals/Modal.swift @@ -1,4 +1,7 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + import UIKit +import SessionUIKit @objc(LKModal) class Modal: BaseVC, UIGestureRecognizerDelegate { diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index c089ce5ae..04f345630 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -293,7 +293,8 @@ enum _003_YDBToGRDBMigration: Migration { .joined(separator: "-") } - try threads.forEach { thread in + // Sort by id just so we can make the migration process more determinstic + try threads.sorted(by: { lhs, rhs in (lhs.uniqueId ?? "") < (rhs.uniqueId ?? "") }).forEach { thread in guard let legacyThreadId: String = thread.uniqueId, let threadId: String = legacyThreadIdToIdMap[legacyThreadId] @@ -423,7 +424,7 @@ enum _003_YDBToGRDBMigration: Migration { let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) try interactions[legacyThreadId]? - .sorted(by: { lhs, rhs in lhs.sortId < rhs.sortId }) // Maintain sort order + .sorted(by: { lhs, rhs in lhs.timestamp < rhs.timestamp }) // Maintain sort order .forEach { legacyInteraction in let serverHash: String? let variant: Interaction.Variant diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index 5450bf902..444008f2e 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -579,10 +579,17 @@ public extension Attachment { ) -> (isValid: Bool, duration: TimeInterval?) { guard let originalFilePath: String = originalFilePath else { return (false, nil) } + let constructedFilePath: String? = localRelativeFilePath.map { + URL(fileURLWithPath: Attachment.attachmentsFolder) + .appendingPathComponent($0) + .path + } + let targetPath: String = (constructedFilePath ?? originalFilePath) + // Process audio attachments if MIMETypeUtil.isAudio(contentType) { do { - let audioPlayer: AVAudioPlayer = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: originalFilePath)) + let audioPlayer: AVAudioPlayer = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: targetPath)) return ((audioPlayer.duration > 0), audioPlayer.duration) } @@ -590,7 +597,7 @@ public extension Attachment { switch (error as NSError).code { case Int(kAudioFileInvalidFileError), Int(kAudioFileStreamError_InvalidFile): // Ignore "invalid audio file" errors - return (false, nil) // TODO: Confirm this behaviour (previously returned 0) + return (false, nil) default: return (false, nil) } @@ -599,51 +606,23 @@ public extension Attachment { // Process image attachments if MIMETypeUtil.isImage(contentType) { - let specificFilePathIsValid: Bool = ( - localRelativeFilePath != nil && - localRelativeFilePath.map { - NSData.ows_isValidImage( - atPath: URL(fileURLWithPath: Attachment.attachmentsFolder) - .appendingPathComponent($0) - .path, - mimeType: contentType - ) - } == true - ) - return ( - ( - specificFilePathIsValid || - NSData.ows_isValidImage(atPath: originalFilePath, mimeType: contentType) - ), + NSData.ows_isValidImage(atPath: targetPath, mimeType: contentType), nil ) } // Process video attachments if MIMETypeUtil.isVideo(contentType) { - let videoPlayer: AVPlayer = AVPlayer(url: URL(fileURLWithPath: originalFilePath)) + let videoPlayer: AVPlayer = AVPlayer(url: URL(fileURLWithPath: targetPath)) let durationSeconds: TimeInterval? = videoPlayer.currentItem .map { item -> TimeInterval in // Accorting to the CMTime docs "value/timescale = seconds" (TimeInterval(item.duration.value) / TimeInterval(item.duration.timescale)) } - let specificFilePathIsValid: Bool = ( - localRelativeFilePath != nil && - localRelativeFilePath.map { - OWSMediaUtils.isValidVideo( - path: URL(fileURLWithPath: Attachment.attachmentsFolder) - .appendingPathComponent($0) - .path - ) - } == true - ) return ( - ( - specificFilePathIsValid || - OWSMediaUtils.isValidVideo(path: originalFilePath) - ), + OWSMediaUtils.isValidVideo(path: targetPath), durationSeconds ) } @@ -720,6 +699,8 @@ extension Attachment { public var isVideo: Bool { MIMETypeUtil.isVideo(contentType) } public var isAnimated: Bool { MIMETypeUtil.isAnimated(contentType) } public var isAudio: Bool { MIMETypeUtil.isAudio(contentType) } + public var isText: Bool { MIMETypeUtil.isText(contentType) } + public var isMicrosoftDoc: Bool { MIMETypeUtil.isMicrosoftDoc(contentType) } public var isVisualMedia: Bool { isImage || isVideo || isAnimated } @@ -793,22 +774,6 @@ extension Attachment { // MARK: - Upload extension Attachment { - internal enum UploadError: LocalizedError { - case invalidStartState - case noAttachment - case notUploaded - case encryptionFailed - - public var errorDescription: String? { - switch self { - case .invalidStartState: return "Cannot upload an attachment in this state." - case .noAttachment: return "No such attachment." - case .notUploaded: return "Attachment not uploaded." - case .encryptionFailed: return "Couldn't encrypt file." - } - } - } - internal func upload( using upload: (Data) -> Promise, encrypt: Bool, @@ -817,14 +782,14 @@ extension Attachment { ) { guard state != .uploaded else { SNLog("Attempted to upload an already uploaded/downloaded attachment.") - failure?(UploadError.invalidStartState) + failure?(AttachmentError.invalidStartState) return } // Get the attachment guard var data = try? readDataFromFile() else { SNLog("Couldn't read attachment from disk.") - failure?(UploadError.noAttachment) + failure?(AttachmentError.noAttachment) return } @@ -868,7 +833,7 @@ extension Attachment { guard let ciphertext = Cryptography.encryptAttachmentData(data, shouldPad: true, outKey: &encryptionKey, outDigest: &digest) else { SNLog("Couldn't encrypt attachment.") - failure?(UploadError.encryptionFailed) + failure?(AttachmentError.encryptionFailed) return } diff --git a/SessionMessagingKit/Database/Models/Contact.swift b/SessionMessagingKit/Database/Models/Contact.swift index 6b047e354..dca421b0a 100644 --- a/SessionMessagingKit/Database/Models/Contact.swift +++ b/SessionMessagingKit/Database/Models/Contact.swift @@ -64,20 +64,6 @@ public struct Contact: Codable, Identifiable, Equatable, FetchableRecord, Persis self.didApproveMe = didApproveMe self.hasBeenBlocked = (isBlocked || hasBeenBlocked) } - - // MARK: - PersistableRecord - - public func save(_ db: Database) throws { - let oldContact: Contact? = try? Contact.fetchOne(db, id: id) - - try performSave(db) - - db.afterNextTransactionCommit { db in - if isBlocked != oldContact?.isBlocked { - NotificationCenter.default.post(name: .contactBlockedStateChanged, object: id) - } - } - } } // MARK: - Convenience diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 74555c658..b3a42eca8 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -474,6 +474,9 @@ public extension Interaction { ) } + /// This method flags sent messages as read for the specified recipients + /// + /// **Note:** This method won't update the 'wasRead' flag (it will be updated via the above method) static func markAsRead(_ db: Database, recipientId: String, timestampMsValues: [Double], readTimestampMs: Double) throws { guard db[.areReadReceiptsEnabled] == true else { return } diff --git a/SessionMessagingKit/Database/Models/LinkPreview.swift b/SessionMessagingKit/Database/Models/LinkPreview.swift index 54b4726e9..953b9b9bc 100644 --- a/SessionMessagingKit/Database/Models/LinkPreview.swift +++ b/SessionMessagingKit/Database/Models/LinkPreview.swift @@ -95,7 +95,7 @@ public struct LinkPreview: Codable, Equatable, FetchableRecord, PersistableRecor // MARK: - Protobuf public extension LinkPreview { - init?(_ db: Database, proto: SNProtoDataMessage, body: String?) throws { + init?(_ db: Database, proto: SNProtoDataMessage, body: String?, sentTimestampMs: TimeInterval) throws { guard OWSLinkPreview.featureEnabled else { throw LinkPreviewError.noPreview } guard let previewProto = proto.preview.first else { throw LinkPreviewError.noPreview } guard proto.attachments.count < 1 else { throw LinkPreviewError.invalidInput } @@ -107,7 +107,7 @@ public extension LinkPreview { } // Try to get an existing link preview first - let timestamp: TimeInterval = LinkPreview.timestampFor(sentTimestampMs: Double(proto.timestamp)) + let timestamp: TimeInterval = LinkPreview.timestampFor(sentTimestampMs: sentTimestampMs) let maybeLinkPreview: LinkPreview? = try? LinkPreview .filter(LinkPreview.Columns.url == previewProto.url) .filter(LinkPreview.Columns.timestamp == LinkPreview.timestampFor( diff --git a/SessionMessagingKit/Database/Models/OpenGroup.swift b/SessionMessagingKit/Database/Models/OpenGroup.swift index 73454bf00..ddb12ab03 100644 --- a/SessionMessagingKit/Database/Models/OpenGroup.swift +++ b/SessionMessagingKit/Database/Models/OpenGroup.swift @@ -129,9 +129,9 @@ public extension OpenGroup { @objc(SMKOpenGroup) public class SMKOpenGroup: NSObject { @objc(inviteUsers:toOpenGroupFor:) - public static func invite(selectedUsers: Set, threadId: String) { + public static func invite(selectedUsers: Set, openGroupThreadId: String) { GRDBStorage.shared.write { db in - guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { return } + guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: openGroupThreadId) else { return } let urlString: String = "\(openGroup.server)/\(openGroup.room)?public_key=\(openGroup.publicKey)" @@ -146,7 +146,7 @@ public class SMKOpenGroup: NSObject { .save(db) let interaction: Interaction = try Interaction( - threadId: threadId, + threadId: thread.id, authorId: userId, variant: .standardOutgoing, timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)), diff --git a/SessionMessagingKit/Database/Notification+Contacts.swift b/SessionMessagingKit/Database/Notification+Contacts.swift index 2b96b37ba..857d88cb4 100644 --- a/SessionMessagingKit/Database/Notification+Contacts.swift +++ b/SessionMessagingKit/Database/Notification+Contacts.swift @@ -7,7 +7,6 @@ public extension Notification.Name { static let profileUpdated = Notification.Name("profileUpdated") static let localProfileDidChange = Notification.Name("localProfileDidChange") static let otherUsersProfileDidChange = Notification.Name("otherUsersProfileDidChange") - static let contactBlockedStateChanged = Notification.Name("contactBlockedStateChanged") } @objc public extension NSNotification { @@ -15,7 +14,6 @@ public extension Notification.Name { @objc static let profileUpdated = Notification.Name.profileUpdated.rawValue as NSString @objc static let localProfileDidChange = Notification.Name.localProfileDidChange.rawValue as NSString @objc static let otherUsersProfileDidChange = Notification.Name.otherUsersProfileDidChange.rawValue as NSString - @objc static let contactBlockedStateChanged = Notification.Name.contactBlockedStateChanged.rawValue as NSString } extension Notification.Key { diff --git a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift index 4e3b9a7d2..57fc04a9e 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift @@ -57,16 +57,4 @@ extension AttachmentUploadJob { self.attachmentId = attachmentId } } - - public enum AttachmentUploadError: LocalizedError { - case noAttachment - case encryptionFailed - - public var errorDescription: String? { - switch self { - case .noAttachment: return "No such attachment." - case .encryptionFailed: return "Couldn't encrypt file." - } - } - } } diff --git a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift index 335bcb73c..fc8076d96 100644 --- a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift @@ -56,10 +56,11 @@ public enum MessageReceiveJob: JobExecutor { catch { switch error { // Note: This is the same as the 'MessageReceiverError.duplicateMessage' - // which is not retryable so just skip to the next message to process - case DatabaseError.SQLITE_CONSTRAINT_UNIQUE: - SNLog("MessageReceiveJob skipping duplicate message.") - continue + // which is not retryable so just skip to the next message to process (no + // longer logging this because all de-duping happens here now rather than + // when parsing as it did previously - this change results in excessive + // logging which isn't useful) + case DatabaseError.SQLITE_CONSTRAINT_UNIQUE: continue default: break } @@ -67,6 +68,11 @@ public enum MessageReceiveJob: JobExecutor { // If the current message is a permanent failure then override it with the // new error (we want to retry if there is a single non-permanent error) switch error { + // Ignore self-send errors (they will be permanently failed but no need + // to log since we are going to have a lot of the due to the change to the + // de-duping logic) + case MessageReceiverError.selfSend: continue + case let receiverError as MessageReceiverError where !receiverError.isRetryable: SNLog("MessageReceiveJob permanently failed message due to error: \(error)") continue diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift index 6783489e4..c2066c47f 100644 --- a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift @@ -101,7 +101,7 @@ public enum MessageSendJob: JobExecutor { // Note: If we have gotten to this point then any dependant attachment upload // jobs will have permanently failed so this message send should also do so guard attachmentState?.shouldFail == false else { - failure(job, Attachment.UploadError.notUploaded, true) + failure(job, AttachmentError.notUploaded, true) return } @@ -117,7 +117,7 @@ public enum MessageSendJob: JobExecutor { // Perform the actual message sending GRDBStorage.shared.write { db -> Promise in - try MessageSender.send( + try MessageSender.sendImmediate( db, message: details.message, to: details.destination, diff --git a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift index d2f7bd871..c1194a920 100644 --- a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift +++ b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift @@ -36,7 +36,7 @@ public enum SendReadReceiptsJob: JobExecutor { GRDBStorage.shared .write { db in - try MessageSender.send( + try MessageSender.sendImmediate( db, message: ReadReceipt( timestamps: details.timestampMsValues.map { UInt64($0) } diff --git a/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift b/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift index b1b982764..100edbefe 100644 --- a/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift +++ b/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift @@ -25,7 +25,7 @@ public final class UnsendRequest: ControlMessage { // MARK: - Initialization - internal init(timestamp: UInt64, author: String) { + public init(timestamp: UInt64, author: String) { super.init() self.timestamp = timestamp diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift index 085596c5c..698baa732 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift @@ -161,7 +161,12 @@ public final class VisibleMessage: Message { dataMessage.setAttachments(attachmentProtos) // Open group invitation - if let openGroupInvitation = openGroupInvitation, let openGroupInvitationProto = openGroupInvitation.toProto() { dataMessage.setOpenGroupInvitation(openGroupInvitationProto) } + if + let openGroupInvitation = openGroupInvitation, + let openGroupInvitationProto = openGroupInvitation.toProto() + { + dataMessage.setOpenGroupInvitation(openGroupInvitationProto) + } // Group context do { diff --git a/SessionMessagingKit/Sending & Receiving/Data Extraction/DataExtractionNotificationInfoMessage.swift b/SessionMessagingKit/Sending & Receiving/Data Extraction/DataExtractionNotificationInfoMessage.swift deleted file mode 100644 index 2e0ce41cf..000000000 --- a/SessionMessagingKit/Sending & Receiving/Data Extraction/DataExtractionNotificationInfoMessage.swift +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import GRDB -import SessionUtilitiesKit - -@objc(SNDataExtractionNotificationInfoMessage) -final class DataExtractionNotificationInfoMessage : TSInfoMessage { - - init(type: TSInfoMessageType, sentTimestamp: UInt64, thread: TSThread, referencedAttachmentTimestamp: UInt64?) { - super.init(timestamp: sentTimestamp, in: thread, messageType: type) - } - - required init(coder: NSCoder) { - super.init(coder: coder) - } - - required init(dictionary dictionaryValue: [String: Any]!) throws { - try super.init(dictionary: dictionaryValue) - } - - override func previewText(with transaction: YapDatabaseReadTransaction) -> String { - guard let thread = thread as? TSContactThread else { return "" } // Should never occur - - let displayName = Profile.displayName(for: thread.contactSessionID()) - - switch messageType { - case .screenshotNotification: - return String(format: NSLocalizedString("screenshot_taken", comment: ""), displayName) - - case .mediaSavedNotification: - // TODO: Use referencedAttachmentTimestamp to tell the user * which * media was saved - return String(format: NSLocalizedString("meida_saved", comment: ""), displayName) - - default: preconditionFailure() - } - } -} diff --git a/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift b/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift index 0a8ab0dac..5f708431e 100644 --- a/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift +++ b/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift @@ -1,3 +1,21 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation + +public enum AttachmentError: LocalizedError { + case invalidStartState + case noAttachment + case notUploaded + case invalidData + case encryptionFailed + + public var errorDescription: String? { + switch self { + case .invalidStartState: return "Cannot upload an attachment in this state." + case .noAttachment: return "No such attachment." + case .notUploaded: return "Attachment not uploaded." + case .invalidData: return "Invalid attachment data." + case .encryptionFailed: return "Couldn't encrypt file." + } + } +} diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index 0cd322bb8..522ec5184 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -356,20 +356,21 @@ extension MessageReceiver { UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: interaction.notificationIdentifiers) } - if author == message.sender { - if let serverHash: String = interaction.serverHash { - SnodeAPI.deleteMessage(publicKey: author, serverHashes: [serverHash]).retainUntilComplete() - } - - _ = try interaction - .markingAsDeleted() - .saved(db) - - _ = try interaction.attachments - .deleteAll(db) + if author == message.sender, let serverHash: String = interaction.serverHash { + SnodeAPI.deleteMessage(publicKey: author, serverHashes: [serverHash]).retainUntilComplete() } - else { - _ = try interaction.delete(db) + + switch (interaction.variant, (author == message.sender)) { + case (.standardOutgoing, _), (_, false): + _ = try interaction.delete(db) + + case (_, true): + _ = try interaction + .markingAsDeleted() + .saved(db) + + _ = try interaction.attachments + .deleteAll(db) } } @@ -511,7 +512,15 @@ extension MessageReceiver { return (attachment.downloadUrl != nil ? attachment : nil) } .map { attachment in - try attachment.saved(db) + let savedAttachment: Attachment = try attachment.saved(db) + + // Link the attachment to the interaction and add to the id lookup + try InteractionAttachment( + interactionId: interactionId, + attachmentId: savedAttachment.id + ).insert(db) + + return savedAttachment } message.attachmentIds = attachments.map { $0.id } @@ -528,7 +537,8 @@ extension MessageReceiver { let linkPreview: LinkPreview? = try? LinkPreview( db, proto: dataMessage, - body: message.text + body: message.text, + sentTimestampMs: (messageSentTimestamp * 1000) )?.saved(db) // Open group invitations are stored as LinkPreview values so create one if needed diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index d8615a5ea..312448d0a 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -35,7 +35,7 @@ public enum MessageReceiver { else { switch envelope.type { case .sessionMessage: - guard let userX25519KeyPair: Box.KeyPair = Identity.fetchUserKeyPair() else { + guard let userX25519KeyPair: Box.KeyPair = Identity.fetchUserKeyPair(db) else { throw MessageReceiverError.noUserX25519KeyPair } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index bfbf9a481..8bbb9a081 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -13,11 +13,12 @@ extension MessageSender { guard let interactionId: Int64 = interaction.id else { throw GRDBStorageError.objectNotSaved } // TODO: Is the 'prep' method needed anymore? // prep(db, attachments, for: message) - try send( + send( db, message: VisibleMessage.from(db, interaction: interaction), + threadId: thread.id, interactionId: interactionId, - in: thread + to: try Message.Destination.from(db, thread: thread) ) } @@ -26,18 +27,34 @@ extension MessageSender { guard interaction.variant == .standardOutgoing else { throw MessageSenderError.invalidMessage } guard let interactionId: Int64 = interaction.id else { throw GRDBStorageError.objectNotSaved } - return try send(db, message: VisibleMessage.from(db, interaction: interaction), interactionId: interactionId, in: thread) + send( + db, + message: VisibleMessage.from(db, interaction: interaction), + threadId: thread.id, + interactionId: interactionId, + to: try Message.Destination.from(db, thread: thread) + ) } public static func send(_ db: Database, message: Message, interactionId: Int64?, in thread: SessionThread) throws { + send( + db, + message: message, + threadId: thread.id, + interactionId: interactionId, + to: try Message.Destination.from(db, thread: thread) + ) + } + + public static func send(_ db: Database, message: Message, threadId: String?, interactionId: Int64?, to destination: Message.Destination) { JobRunner.add( db, job: Job( variant: .messageSend, - threadId: thread.id, + threadId: threadId, interactionId: interactionId, details: MessageSendJob.Details( - destination: try Message.Destination.from(db, thread: thread), + destination: destination, message: message ) ) @@ -98,7 +115,7 @@ extension MessageSender { public static func sendNonDurably(_ db: Database, message: Message, interactionId: Int64?, in thread: SessionThread) throws -> Promise { - return try MessageSender.send( + return try MessageSender.sendImmediate( db, message: message, to: try Message.Destination.from(db, thread: thread), @@ -110,7 +127,7 @@ extension MessageSender { } public static func sendNonDurably(_ db: Database, message: Message, interactionId: Int64?, to destination: Message.Destination) throws -> Promise { - return try MessageSender.send( + return try MessageSender.sendImmediate( db, message: message, to: destination, @@ -136,7 +153,7 @@ extension MessageSender { if forceSyncNow { try MessageSender - .send(db, message: configurationMessage, to: destination, interactionId: nil) + .sendImmediate(db, message: configurationMessage, to: destination, interactionId: nil) .done { seal.fulfill(()) } .catch { _ in seal.reject(GRDBStorageError.generic) } .retainUntilComplete() diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index fc20a055b..51a059a4d 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -67,7 +67,7 @@ public final class MessageSender : NSObject { // MARK: - Convenience - public static func send(_ db: Database, message: Message, to destination: Message.Destination, interactionId: Int64?) throws -> Promise { + public static func sendImmediate(_ db: Database, message: Message, to destination: Message.Destination, interactionId: Int64?) throws -> Promise { switch destination { case .contact(_), .closedGroup(_): return try sendToSnodeDestination(db, message: message, to: destination, interactionId: interactionId) @@ -88,15 +88,13 @@ public final class MessageSender : NSObject { ) throws -> Promise { let (promise, seal) = Promise.pending() let userPublicKey: String = getUserHexEncodedPublicKey(db) - let isMainAppActive: Bool = (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) // Set the timestamp, sender and recipient - if message.sentTimestamp == nil { // Visible messages will already have their sent timestamp set - } - message.sentTimestamp = UInt64(floor(Date().timeIntervalSince1970 * 1000)) - - let isSelfSend: Bool = (message.recipient == userPublicKey) + message.sentTimestamp = ( + message.sentTimestamp ?? // Visible messages will already have their sent timestamp set + UInt64(floor(Date().timeIntervalSince1970 * 1000)) + ) message.sender = userPublicKey message.recipient = { switch destination { @@ -123,6 +121,7 @@ public final class MessageSender : NSObject { // • a sync message // • a closed group control message of type `new` // • an unsend request + let isSelfSend: Bool = (message.recipient == userPublicKey) let isNewClosedGroupControlMessage: Bool = { switch (message as? ClosedGroupControlMessage)?.kind { case .new: return true @@ -147,14 +146,14 @@ public final class MessageSender : NSObject { let profile: Profile = Profile.fetchOrCreateCurrentUser(db) if let profileKey: Data = profile.profileEncryptionKey?.keyData, let profilePictureUrl: String = profile.profilePictureUrl { - message.profile = VisibleMessage.Profile( + message.profile = VisibleMessage.VMProfile( displayName: profile.name, profileKey: profileKey, profilePictureUrl: profilePictureUrl ) } else { - message.profile = VisibleMessage.Profile(displayName: profile.name) + message.profile = VisibleMessage.VMProfile(displayName: profile.name) } } @@ -371,7 +370,7 @@ public final class MessageSender : NSObject { } // Attach the user's profile - message.profile = VisibleMessage.Profile( + message.profile = VisibleMessage.VMProfile( profile: Profile.fetchOrCreateCurrentUser() ) @@ -471,8 +470,6 @@ public final class MessageSender : NSObject { try interaction.recipientStates .updateAll(db, RecipientState.Columns.state.set(to: RecipientState.State.sent)) - NotificationCenter.default.post(name: .messageSentStatusDidChange, object: nil, userInfo: nil) - // Start the disappearing messages timer if needed JobRunner.upsert( db, diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift index a2b65c1f8..07d594dae 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift @@ -7,7 +7,7 @@ import SessionSnodeKit @objc(LKClosedGroupPoller) public final class ClosedGroupPoller: NSObject { - private var isPolling: [String: Bool] = [:] + private var isPolling: Atomic<[String: Bool]> = Atomic([:]) private var timers: [String: Timer] = [:] private let internalQueue: DispatchQueue = DispatchQueue(label: "isPollingQueue") @@ -63,11 +63,12 @@ public final class ClosedGroupPoller: NSObject { } public func startPolling(for groupPublicKey: String) { - guard !isPolling(for: groupPublicKey) else { return } + guard isPolling.wrappedValue[groupPublicKey] != true else { return } + // Might be a race condition that the setUpPolling finishes too soon, // and the timer is not created, if we mark the group as is polling // after setUpPolling. So the poller may not work, thus misses messages. - internalQueue.sync{ isPolling[groupPublicKey] = true } + isPolling.mutate { $0[groupPublicKey] = true } setUpPolling(for: groupPublicKey) } @@ -86,7 +87,7 @@ public final class ClosedGroupPoller: NSObject { } public func stopPolling(for groupPublicKey: String) { - internalQueue.sync{ isPolling[groupPublicKey] = false } + isPolling.mutate { $0[groupPublicKey] = false } timers[groupPublicKey]?.invalidate() } @@ -107,7 +108,7 @@ public final class ClosedGroupPoller: NSObject { private func pollRecursively(_ groupPublicKey: String) { guard - isPolling(for: groupPublicKey), + isPolling.wrappedValue[groupPublicKey] == true, let thread: SessionThread = GRDBStorage.shared.read({ db in try SessionThread.fetchOne(db, id: groupPublicKey) }) else { return } @@ -149,78 +150,82 @@ public final class ClosedGroupPoller: NSObject { } private func poll(_ groupPublicKey: String) -> Promise { - guard isPolling(for: groupPublicKey) else { return Promise.value(()) } + guard isPolling.wrappedValue[groupPublicKey] == true else { return Promise.value(()) } - let promise = SnodeAPI.getSwarm(for: groupPublicKey) + let promise: Promise = SnodeAPI.getSwarm(for: groupPublicKey) .then2 { [weak self] swarm -> Promise<(Snode, [SnodeReceivedMessage])> in // randomElement() uses the system's default random generator, which is cryptographically secure guard let snode = swarm.randomElement() else { return Promise(error: Error.insufficientSnodes) } - guard let self = self, self.isPolling(for: groupPublicKey) else { + guard self?.isPolling.wrappedValue[groupPublicKey] == true else { return Promise(error: Error.pollingCanceled) } return SnodeAPI.getMessages(from: snode, associatedWith: groupPublicKey) .map2 { messages in (snode, messages) } } - - promise.done2 { [weak self] snode, messages in - guard self?.isPolling(for: groupPublicKey) == true else { return } - - if !messages.isEmpty { - SNLog("Received \(messages.count) message(s) in closed group with public key: \(groupPublicKey).") + .done2 { [weak self] snode, messages in + guard self?.isPolling.wrappedValue[groupPublicKey] == true else { return } - GRDBStorage.shared.write { db in - var jobDetailMessages: [MessageReceiveJob.Details.MessageInfo] = [] + if !messages.isEmpty { + var messageCount: Int = 0 - messages.forEach { message in - guard let envelope = SNProtoEnvelope.from(message) else { return } + GRDBStorage.shared.write { db in + var jobDetailMessages: [MessageReceiveJob.Details.MessageInfo] = [] - do { - jobDetailMessages.append( - MessageReceiveJob.Details.MessageInfo( - data: try envelope.serializedData(), - serverHash: message.info.hash, - serverExpirationTimestamp: (TimeInterval(message.info.expirationDateMs) / 1000) - ) - ) + messages.forEach { message in + guard let envelope = SNProtoEnvelope.from(message) else { return } - // Persist the received message after the MessageReceiveJob is created - _ = try message.info.saved(db) - } - catch { - switch error { - // Ignore unique constraint violations here (they will be hanled in the MessageReceiveJob) - case .SQLITE_CONSTRAINT_UNIQUE: break - - default: - SNLog("Failed to deserialize envelope due to error: \(error).") + do { + let serialisedData: Data = try envelope.serializedData() + _ = try message.info.inserted(db) + + // Ignore hashes for messages we have previously handled + guard try SnodeReceivedMessageInfo.filter(SnodeReceivedMessageInfo.Columns.hash == message.info.hash).fetchCount(db) == 1 else { + throw MessageReceiverError.duplicateMessage + } + + jobDetailMessages.append( + MessageReceiveJob.Details.MessageInfo( + data: serialisedData, + serverHash: message.info.hash, + serverExpirationTimestamp: (TimeInterval(message.info.expirationDateMs) / 1000) + ) + ) + } + catch { + switch error { + // Ignore duplicate messages + case .SQLITE_CONSTRAINT_UNIQUE, MessageReceiverError.duplicateMessage: break + default: SNLog("Failed to deserialize envelope due to error: \(error).") + } } } - } - - JobRunner.add( - db, - job: Job( - variant: .messageReceive, - behaviour: .runOnce, - threadId: groupPublicKey, - details: MessageReceiveJob.Details( - messages: jobDetailMessages, - isBackgroundPoll: false + + messageCount = jobDetailMessages.count + + JobRunner.add( + db, + job: Job( + variant: .messageReceive, + behaviour: .runOnce, + threadId: groupPublicKey, + details: MessageReceiveJob.Details( + messages: jobDetailMessages, + isBackgroundPoll: false + ) ) ) - ) + } + + SNLog("Received \(messageCount) message(s) in closed group with public key: \(groupPublicKey).") } } - } + .map { _ in } + promise.catch2 { error in SNLog("Polling failed for closed group with public key: \(groupPublicKey) due to error: \(error).") } - return promise.map { _ in } - } - - // MARK: Convenience - private func isPolling(for groupPublicKey: String) -> Bool { - return internalQueue.sync{ isPolling[groupPublicKey] ?? false } + + return promise } } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift index a58ea941d..6ef9cb70e 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift @@ -9,11 +9,12 @@ import SessionSnodeKit @objc(LKPoller) public final class Poller : NSObject { private let storage = OWSPrimaryStorage.shared() - private var isPolling = false + private var isPolling: Atomic = Atomic(false) private var usedSnodes = Set() private var pollCount = 0 - // MARK: Settings + // MARK: - Settings + private static let pollInterval: TimeInterval = 1.5 private static let retryInterval: TimeInterval = 0.25 /// After polling a given snode this many times we always switch to a new one. @@ -22,89 +23,103 @@ public final class Poller : NSObject { /// it isn't actually getting messages from other snodes. private static let maxPollCount: UInt = 6 - // MARK: Error + // MARK: - Error + private enum Error : LocalizedError { case pollLimitReached var localizedDescription: String { switch self { - case .pollLimitReached: return "Poll limit reached for current snode." + case .pollLimitReached: return "Poll limit reached for current snode." } } } - // MARK: Public API + // MARK: - Public API + @objc public func startIfNeeded() { - guard !isPolling else { return } + guard !isPolling.wrappedValue else { return } + SNLog("Started polling.") - isPolling = true + isPolling.mutate { $0 = true } setUpPolling() } @objc public func stop() { SNLog("Stopped polling.") - isPolling = false + isPolling.mutate { $0 = false } usedSnodes.removeAll() } - // MARK: Private API + // MARK: - Private API + private func setUpPolling() { - guard isPolling else { return } - Threading.pollerQueue.async { - let _ = SnodeAPI.getSwarm(for: getUserHexEncodedPublicKey()).then(on: Threading.pollerQueue) { [weak self] _ -> Promise in - guard let strongSelf = self else { return Promise { $0.fulfill(()) } } - strongSelf.usedSnodes.removeAll() - let (promise, seal) = Promise.pending() - strongSelf.pollNextSnode(seal: seal) - return promise - }.ensure(on: Threading.pollerQueue) { [weak self] in // Timers don't do well on background queues - guard let strongSelf = self, strongSelf.isPolling else { return } - Timer.scheduledTimerOnMainThread(withTimeInterval: Poller.retryInterval, repeats: false) { _ in - guard let strongSelf = self else { return } - strongSelf.setUpPolling() - } - } - } + guard isPolling.wrappedValue else { return } + Threading.pollerQueue.async { + let _ = SnodeAPI.getSwarm(for: getUserHexEncodedPublicKey()) + .then(on: Threading.pollerQueue) { [weak self] _ -> Promise in + let (promise, seal) = Promise.pending() + + self?.usedSnodes.removeAll() + self?.pollNextSnode(seal: seal) + + return promise + } + .ensure(on: Threading.pollerQueue) { [weak self] in // Timers don't do well on background queues + guard self?.isPolling.wrappedValue == true else { return } + + Timer.scheduledTimerOnMainThread(withTimeInterval: Poller.retryInterval, repeats: false) { _ in + self?.setUpPolling() + } + } + } } private func pollNextSnode(seal: Resolver) { let userPublicKey = getUserHexEncodedPublicKey() let swarm = SnodeAPI.swarmCache[userPublicKey] ?? [] let unusedSnodes = swarm.subtracting(usedSnodes) - if !unusedSnodes.isEmpty { - // randomElement() uses the system's default random generator, which is cryptographically secure - let nextSnode = unusedSnodes.randomElement()! - usedSnodes.insert(nextSnode) - poll(nextSnode, seal: seal).done2 { + + guard !unusedSnodes.isEmpty else { + seal.fulfill(()) + return + } + + // randomElement() uses the system's default random generator, which is cryptographically secure + let nextSnode = unusedSnodes.randomElement()! + usedSnodes.insert(nextSnode) + + poll(nextSnode, seal: seal) + .done2 { seal.fulfill(()) - }.catch2 { [weak self] error in + } + .catch2 { [weak self] error in if let error = error as? Error, error == .pollLimitReached { self?.pollCount = 0 - } else { + } + else { SNLog("Polling \(nextSnode) failed; dropping it and switching to next snode.") SnodeAPI.dropSnodeFromSwarmIfNeeded(nextSnode, publicKey: userPublicKey) } + Threading.pollerQueue.async { self?.pollNextSnode(seal: seal) } } - } else { - seal.fulfill(()) - } } private func poll(_ snode: Snode, seal longTermSeal: Resolver) -> Promise { - guard isPolling else { return Promise { $0.fulfill(()) } } + guard isPolling.wrappedValue else { return Promise { $0.fulfill(()) } } let userPublicKey = getUserHexEncodedPublicKey() return SnodeAPI.getMessages(from: snode, associatedWith: userPublicKey) .then(on: Threading.pollerQueue) { [weak self] messages -> Promise in - guard self?.isPolling == true else { return Promise { $0.fulfill(()) } } + guard self?.isPolling.wrappedValue == true else { return Promise { $0.fulfill(()) } } if !messages.isEmpty { - SNLog("Received \(messages.count) message(s).") + var messageCount: Int = 0 GRDBStorage.shared.write { db in var threadMessages: [String: [MessageReceiveJob.Details.MessageInfo]] = [:] @@ -117,25 +132,33 @@ public final class Poller : NSObject { let threadId: String? = MessageReceiver.extractSenderPublicKey(db, from: envelope) if threadId == nil { + // TODO: I assume a configuration message doesn't need a 'threadId' (confirm this and set the 'requiresThreadId' requirement accordingly) + // TODO: Does the configuration message come through here???? + print("RAWR WHAT CASES LETS THIS BE NIL????") } do { + let serialisedData: Data = try envelope.serializedData() + _ = try message.info.inserted(db) + + // Ignore hashes for messages we have previously handled + guard try SnodeReceivedMessageInfo.filter(SnodeReceivedMessageInfo.Columns.hash == message.info.hash).fetchCount(db) == 1 else { + throw MessageReceiverError.duplicateMessage + } + threadMessages[threadId ?? ""] = (threadMessages[threadId ?? ""] ?? []) .appending( MessageReceiveJob.Details.MessageInfo( - data: try envelope.serializedData(), + data: serialisedData, serverHash: message.info.hash, serverExpirationTimestamp: (TimeInterval(message.info.expirationDateMs) / 1000) ) ) - - // Persist the received message after the MessageReceiveJob is created - _ = try message.info.saved(db) } catch { switch error { - // Ignore unique constraint violations here (they will be hanled in the MessageReceiveJob) - case .SQLITE_CONSTRAINT_UNIQUE: break + // Ignore duplicate messages + case .SQLITE_CONSTRAINT_UNIQUE, MessageReceiverError.duplicateMessage: break default: SNLog("Failed to deserialize envelope due to error: \(error).") @@ -143,6 +166,10 @@ public final class Poller : NSObject { } } + messageCount = threadMessages + .values + .reduce(into: 0) { prev, next in prev += next.count } + threadMessages.forEach { threadId, threadMessages in JobRunner.add( db, @@ -158,6 +185,8 @@ public final class Poller : NSObject { ) } } + + SNLog("Received \(messageCount) message(s).") } self?.pollCount += 1 @@ -167,7 +196,8 @@ public final class Poller : NSObject { } return withDelay(Poller.pollInterval, completionQueue: Threading.pollerQueue) { - guard let strongSelf = self, strongSelf.isPolling else { return Promise { $0.fulfill(()) } } + guard let strongSelf = self, strongSelf.isPolling.wrappedValue else { return Promise { $0.fulfill(()) } } + return strongSelf.poll(snode, seal: longTermSeal) } } diff --git a/SessionMessagingKit/Threads/Notification+Thread.swift b/SessionMessagingKit/Threads/Notification+Thread.swift index 4b61f8f1b..77b74d0ef 100644 --- a/SessionMessagingKit/Threads/Notification+Thread.swift +++ b/SessionMessagingKit/Threads/Notification+Thread.swift @@ -3,12 +3,10 @@ public extension Notification.Name { static let groupThreadUpdated = Notification.Name("groupThreadUpdated") static let muteSettingUpdated = Notification.Name("muteSettingUpdated") - static let messageSentStatusDidChange = Notification.Name("messageSentStatusDidChange") } @objc public extension NSNotification { @objc static let groupThreadUpdated = Notification.Name.groupThreadUpdated.rawValue as NSString @objc static let muteSettingUpdated = Notification.Name.muteSettingUpdated.rawValue as NSString - @objc static let messageSentStatusDidChange = Notification.Name.messageSentStatusDidChange.rawValue as NSString } diff --git a/SessionMessagingKit/Utilities/SSKEnvironment.swift b/SessionMessagingKit/Utilities/SSKEnvironment.swift index f599d7347..89a0912da 100644 --- a/SessionMessagingKit/Utilities/SSKEnvironment.swift +++ b/SessionMessagingKit/Utilities/SSKEnvironment.swift @@ -8,7 +8,6 @@ public class SSKEnvironment: NSObject { @objc public let primaryStorage: OWSPrimaryStorage public let tsAccountManager: TSAccountManager public let reachabilityManager: SSKReachabilityManager - @objc public let typingIndicators: TypingIndicators // Note: This property is configured after Environment is created. public let notificationsManager: Atomic = Atomic(nil) @@ -29,13 +28,11 @@ public class SSKEnvironment: NSObject { @objc public init( primaryStorage: OWSPrimaryStorage, tsAccountManager: TSAccountManager, - reachabilityManager: SSKReachabilityManager, - typingIndicators: TypingIndicators + reachabilityManager: SSKReachabilityManager ) { self.primaryStorage = primaryStorage self.tsAccountManager = tsAccountManager self.reachabilityManager = reachabilityManager - self.typingIndicators = typingIndicators self.objectReadWriteConnection = primaryStorage.newDatabaseConnection() self.sessionStoreDBConnection = primaryStorage.newDatabaseConnection() diff --git a/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift index 42cb1e425..7ee5082d7 100644 --- a/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -39,7 +39,9 @@ enum _001_InitialSetupMigration: Migration { t.column(.key, .text) .notNull() .indexed() // Quicker querying - t.column(.hash, .text).notNull() + t.column(.hash, .text) + .notNull() + .indexed() // Quicker querying t.column(.expirationDateMs, .integer) .notNull() .indexed() // Quicker querying diff --git a/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift index bc760e87e..09cf59825 100644 --- a/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -122,8 +122,8 @@ enum _003_YDBToGRDBMigration: Migration { guard let lastMessageJson = object as? JSON else { return } guard let lastMessageHash: String = lastMessageJson["hash"] as? String else { return } - // Note: We remove the value from 'receivedMessageResults' as we don't want to default it's - // expiration value to 0 + // Note: We remove the value from 'receivedMessageResults' as we want to try and use + // it's actual 'expirationDate' value lastMessageResults[key] = (lastMessageHash, lastMessageJson) receivedMessageResults[key] = receivedMessageResults[key]?.removing(lastMessageHash) } @@ -135,16 +135,21 @@ enum _003_YDBToGRDBMigration: Migration { _ = try SnodeReceivedMessageInfo( key: key, hash: hash, - expirationDateMs: 0 + expirationDateMs: SnodeReceivedMessage.defaultExpirationSeconds ).inserted(db) } } try lastMessageResults.forEach { key, data in + let expirationDateMs: Int64 = ((data.json["expirationDate"] as? Int64) ?? 0) + _ = try SnodeReceivedMessageInfo( key: key, hash: data.hash, - expirationDateMs: ((data.json["expirationDate"] as? Int64) ?? 0) + expirationDateMs: (expirationDateMs > 0 ? + expirationDateMs : + SnodeReceivedMessage.defaultExpirationSeconds + ) ).inserted(db) } } diff --git a/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift b/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift index 78df09302..4d1b8cf96 100644 --- a/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift +++ b/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift @@ -29,7 +29,8 @@ public struct SnodeReceivedMessageInfo: Codable, FetchableRecord, MutablePersist /// This is the timestamp (in milliseconds since epoch) when the message hash should expire /// - /// **Note:** A value of `0` means this hash should not expire + /// **Note:** If no value exists this will default to 15 days from now (since the service node caches messages for + /// 14 days) public let expirationDateMs: Int64 // MARK: - Custom Database Interaction @@ -62,9 +63,17 @@ public extension SnodeReceivedMessageInfo { public extension SnodeReceivedMessageInfo { static func pruneExpiredMessageHashInfo(for snode: Snode, associatedWith publicKey: String) { - // Delete any expired (but non-0) SnodeReceivedMessageInfo values associated to a specific node + // Delete any expired SnodeReceivedMessageInfo values associated to a specific node GRDBStorage.shared.write { db in - try? SnodeReceivedMessageInfo + // Only prune the hashes if new hashes exist for this Snode (if they don't then we don't want + // to clear out the legacy hashes) + let hasNonLegacyHash: Bool = try SnodeReceivedMessageInfo + .filter(SnodeReceivedMessageInfo.Columns.key == key(for: snode, publicKey: publicKey)) + .isNotEmpty(db) + + guard hasNonLegacyHash else { return } + + try SnodeReceivedMessageInfo .filter(SnodeReceivedMessageInfo.Columns.key == key(for: snode, publicKey: publicKey)) .filter(SnodeReceivedMessageInfo.Columns.expirationDateMs <= (Date().timeIntervalSince1970 * 1000)) .deleteAll(db) @@ -78,11 +87,20 @@ public extension SnodeReceivedMessageInfo { /// pointless fetch for data the app has already received static func fetchLastNotExpired(for snode: Snode, associatedWith publicKey: String) -> SnodeReceivedMessageInfo? { return GRDBStorage.shared.write { db in - try SnodeReceivedMessageInfo + let nonLegacyHash: SnodeReceivedMessageInfo? = try SnodeReceivedMessageInfo .filter(SnodeReceivedMessageInfo.Columns.key == key(for: snode, publicKey: publicKey)) .filter(SnodeReceivedMessageInfo.Columns.expirationDateMs > (Date().timeIntervalSince1970 * 1000)) .order(SnodeReceivedMessageInfo.Columns.id.desc) .fetchOne(db) + + // If we have a non-legacy hash then return it immediately (legacy hashes had a different + // 'key' structure) + if nonLegacyHash != nil { return nonLegacyHash } + + return try SnodeReceivedMessageInfo + .filter(SnodeReceivedMessageInfo.Columns.key == publicKey) + .order(SnodeReceivedMessageInfo.Columns.id.desc) + .fetchOne(db) } } } diff --git a/SessionSnodeKit/Models/SnodeReceivedMessage.swift b/SessionSnodeKit/Models/SnodeReceivedMessage.swift index 36bd94cc9..85d1b0d9e 100644 --- a/SessionSnodeKit/Models/SnodeReceivedMessage.swift +++ b/SessionSnodeKit/Models/SnodeReceivedMessage.swift @@ -4,6 +4,10 @@ import Foundation 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 let info: SnodeReceivedMessageInfo public let data: Data @@ -18,11 +22,12 @@ public struct SnodeReceivedMessage: CustomDebugStringConvertible { return nil } + let expirationDateMs: Int64? = (rawMessage["expiration"] as? Int64) self.info = SnodeReceivedMessageInfo( snode: snode, publicKey: publicKey, hash: hash, - expirationDateMs: rawMessage["expiration"] as? Int64 + expirationDateMs: (expirationDateMs ?? SnodeReceivedMessage.defaultExpirationSeconds) ) self.data = data } diff --git a/SignalUtilitiesKit/Database/Migrations/BlockingManagerRemovalMigration.swift b/SignalUtilitiesKit/Database/Migrations/BlockingManagerRemovalMigration.swift index e643e3633..676eb1252 100644 --- a/SignalUtilitiesKit/Database/Migrations/BlockingManagerRemovalMigration.swift +++ b/SignalUtilitiesKit/Database/Migrations/BlockingManagerRemovalMigration.swift @@ -28,10 +28,10 @@ public class BlockingManagerRemovalMigration: OWSDatabaseMigration { Storage.write( with: { transaction in - var result: Set = [] + var result: Set = [] transaction.enumerateRows(inCollection: Legacy.contactCollection) { _, object, _, _ in - guard let contact = object as? SessionMessagingKit.Legacy.Contact else { return } + guard let contact = object as? SessionMessagingKit.Legacy._Contact else { return } result.insert(contact) } diff --git a/SignalUtilitiesKit/Database/Migrations/ContactsMigration.swift b/SignalUtilitiesKit/Database/Migrations/ContactsMigration.swift index 23e3534f4..f0cbead75 100644 --- a/SignalUtilitiesKit/Database/Migrations/ContactsMigration.swift +++ b/SignalUtilitiesKit/Database/Migrations/ContactsMigration.swift @@ -16,17 +16,17 @@ public class ContactsMigration : OWSDatabaseMigration { } private func doMigrationAsync(completion: @escaping OWSDatabaseMigrationCompletion) { - var contacts: [SMKLegacy.Contact] = [] + var contacts: [SMKLegacy._Contact] = [] TSContactThread.enumerateCollectionObjects { object, _ in guard let thread = object as? TSContactThread else { return } let sessionID = thread.contactSessionID() - var contact: SMKLegacy.Contact? + var contact: SMKLegacy._Contact? Storage.read { transaction in - contact = transaction.object(forKey: sessionID, inCollection: SMKLegacy.contactCollection) as? SMKLegacy.Contact + contact = transaction.object(forKey: sessionID, inCollection: SMKLegacy.contactCollection) as? SMKLegacy._Contact } - if let contact: SMKLegacy.Contact = contact { + if let contact: SMKLegacy._Contact = contact { contact.isTrusted = true contacts.append(contact) } diff --git a/SignalUtilitiesKit/Database/Migrations/MessageRequestsMigration.swift b/SignalUtilitiesKit/Database/Migrations/MessageRequestsMigration.swift index aeb57e122..6593880a3 100644 --- a/SignalUtilitiesKit/Database/Migrations/MessageRequestsMigration.swift +++ b/SignalUtilitiesKit/Database/Migrations/MessageRequestsMigration.swift @@ -16,7 +16,7 @@ public class MessageRequestsMigration : OWSDatabaseMigration { } private func doMigrationAsync(completion: @escaping OWSDatabaseMigrationCompletion) { - var contacts: Set = Set() + var contacts: Set = Set() var threads: [TSThread] = [] TSThread.enumerateCollectionObjects { object, _ in @@ -26,7 +26,7 @@ public class MessageRequestsMigration : OWSDatabaseMigration { if let contactThread: TSContactThread = thread as? TSContactThread { let sessionId: String = contactThread.contactSessionID() - if let contact: SessionMessagingKit.Legacy.Contact = transaction.object(forKey: sessionId, inCollection: Legacy.contactCollection) as? SessionMessagingKit.Legacy.Contact { + if let contact: SMKLegacy._Contact = transaction.object(forKey: sessionId, inCollection: Legacy.contactCollection) as? SMKLegacy._Contact { contact.isApproved = true contact.didApproveMe = true contacts.insert(contact) @@ -36,7 +36,7 @@ public class MessageRequestsMigration : OWSDatabaseMigration { let groupAdmins: [String] = groupThread.groupModel.groupAdminIds groupAdmins.forEach { sessionId in - if let contact: SessionMessagingKit.Legacy.Contact = transaction.object(forKey: sessionId, inCollection: Legacy.contactCollection) as? SessionMessagingKit.Legacy.Contact { + if let contact: SMKLegacy._Contact = transaction.object(forKey: sessionId, inCollection: Legacy.contactCollection) as? SMKLegacy._Contact { contact.isApproved = true contact.didApproveMe = true contacts.insert(contact) @@ -51,7 +51,7 @@ public class MessageRequestsMigration : OWSDatabaseMigration { let userPublicKey: String = getUserHexEncodedPublicKey() Storage.read { transaction in - if let user = transaction.object(forKey: userPublicKey, inCollection: Legacy.contactCollection) as? SessionMessagingKit.Legacy.Contact { + if let user = transaction.object(forKey: userPublicKey, inCollection: Legacy.contactCollection) as? SMKLegacy._Contact { user.isApproved = true user.didApproveMe = true contacts.insert(user) From 8f120c43804eb63a22a71295947318a0cb535bec Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 13 May 2022 18:07:24 +1000 Subject: [PATCH 078/157] Started re-adding media gallery interactions (in progress) Fixed up quote attachment sending and retrieval Validated attachment sending and retrieving is working correctly Re-added the AttachmentUploadJob migration --- Session.xcodeproj/project.pbxproj | 36 +- .../ConversationVC+Interaction.swift | 180 +++- .../Content Views/MediaAlbumView.swift | 359 +++---- .../Content Views/MediaPlaceholderView.swift | 63 +- .../Content Views/MediaView.swift | 400 ++++---- .../Content Views/QuoteView.swift | 2 +- .../Message Cells/VisibleMessageCell.swift | 875 +++++++++++------- .../OWSConversationSettingsViewController.m | 12 +- .../ImagePickerController.swift | 8 +- .../MediaGalleryViewModel.swift | 55 ++ .../MediaTileViewController.swift | 14 +- .../PhotoGridViewCell.swift | 7 +- .../MediaDismissAnimationController.swift | 234 +++++ .../Transitions/MediaInteractiveDismiss.swift | 108 +++ .../MediaPresentationContext.swift | 50 + .../MediaZoomAnimationController.swift | 189 ++++ .../Utilities/UINavigationBar+Utilities.swift | 44 + .../LegacyDatabase/SMKLegacyModels.swift | 4 +- .../_001_InitialSetupMigration.swift | 3 + .../Migrations/_003_YDBToGRDBMigration.swift | 33 +- .../Database/Models/Attachment.swift | 127 ++- .../Models/InteractionAttachment.swift | 2 +- .../Database/Models/LinkPreview.swift | 2 +- .../Database/Models/Quote.swift | 39 +- .../Jobs/Types/AttachmentDownloadJob.swift | 2 +- .../Jobs/Types/AttachmentUploadJob.swift | 12 +- .../Jobs/Types/MessageSendJob.swift | 1 + .../Signal/TypingIndicatorInteraction.swift | 48 - .../MessageSender+ClosedGroups.swift | 9 - .../MessageSender+Convenience.swift | 13 +- .../Sending & Receiving/MessageSender.swift | 98 +- .../Quotes/QuotedReplyModel.swift | 13 + SessionUtilitiesKit/Database/Models/Job.swift | 12 +- .../General/ReusableView.swift | 1 + .../UICollectionView+ReusableView.swift | 27 + SessionUtilitiesKit/JobRunner/JobRunner.swift | 27 +- .../Shared Views/GalleryRailView.swift | 4 +- 37 files changed, 2091 insertions(+), 1022 deletions(-) create mode 100644 Session/Media Viewing & Editing/Transitions/MediaDismissAnimationController.swift create mode 100644 Session/Media Viewing & Editing/Transitions/MediaInteractiveDismiss.swift create mode 100644 Session/Media Viewing & Editing/Transitions/MediaPresentationContext.swift create mode 100644 Session/Media Viewing & Editing/Transitions/MediaZoomAnimationController.swift create mode 100644 Session/Utilities/UINavigationBar+Utilities.swift delete mode 100644 SessionMessagingKit/Messages/Signal/TypingIndicatorInteraction.swift create mode 100644 SessionUtilitiesKit/General/UICollectionView+ReusableView.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 7744b88ab..81cd9273a 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -214,7 +214,6 @@ B8856ED7256F1EB4001CE70E /* OWSPreferences.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF2F1255B6DBB007E1867 /* OWSPreferences.h */; settings = {ATTRIBUTES = (Public, ); }; }; B886B4A72398B23E00211ABE /* QRCodeVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B886B4A62398B23E00211ABE /* QRCodeVC.swift */; }; B886B4A92398BA1500211ABE /* QRCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = B886B4A82398BA1500211ABE /* QRCode.swift */; }; - B88A1AC725C90A4700E6D421 /* TypingIndicatorInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34B6A904218B4C90007C4606 /* TypingIndicatorInteraction.swift */; }; B88FA7B826045D100049422F /* OpenGroupAPIV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88FA7B726045D100049422F /* OpenGroupAPIV2.swift */; }; B88FA7F2260C3EB10049422F /* OpenGroupSuggestionGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88FA7F1260C3EB10049422F /* OpenGroupSuggestionGrid.swift */; }; B88FA7FB26114EA70049422F /* Hex.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88FA7FA26114EA70049422F /* Hex.swift */; }; @@ -750,6 +749,7 @@ FD17D7E527F6A09900122BE0 /* Identity.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E427F6A09900122BE0 /* Identity.swift */; }; FD17D7E727F6A16700122BE0 /* _003_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E627F6A16700122BE0 /* _003_YDBToGRDBMigration.swift */; }; FD17D7EA27F6A1C600122BE0 /* SUKLegacyModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E927F6A1C600122BE0 /* SUKLegacyModels.swift */; }; + FD1C98E4282E3C5B00B76F9E /* UINavigationBar+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */; }; FD28A4F227E990E800FF65E7 /* BlockingManagerRemovalMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD28A4F127E990E800FF65E7 /* BlockingManagerRemovalMigration.swift */; }; FD28A4F427EA79F800FF65E7 /* BlockListUIUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD28A4F327EA79F800FF65E7 /* BlockListUIUtils.swift */; }; FD3C907327E8387300CD579F /* OpenGroupServerIdLookupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907227E8387300CD579F /* OpenGroupServerIdLookupMigration.swift */; }; @@ -784,6 +784,7 @@ FDCDB8DE2810F73B00352A0C /* Differentiable+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DD2810F73B00352A0C /* Differentiable+Utilities.swift */; }; FDCDB8E02811007F00352A0C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */; }; FDE77F6B280FEB28002CFC5D /* ControlMessageProcessRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */; }; + FDED2E3C282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDED2E3B282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift */; }; FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */; }; FDF0B7422804EA4F004C14C5 /* _002_SetupStandardJobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7412804EA4F004C14C5 /* _002_SetupStandardJobs.swift */; }; FDF0B7472804F0CE004C14C5 /* DisappearingMessagesJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7462804F0CE004C14C5 /* DisappearingMessagesJob.swift */; }; @@ -802,6 +803,10 @@ FDF2220F281B55E6000A4995 /* QueryInterfaceRequest+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF2220E281B55E6000A4995 /* QueryInterfaceRequest+Utilities.swift */; }; FDF22211281B5E0B000A4995 /* TableRecord+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF22210281B5E0B000A4995 /* TableRecord+Utilities.swift */; }; FDFD645827EC1F4000808CA1 /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFD645727EC1F4000808CA1 /* Atomic.swift */; }; + FDFDE124282D04F20098B17F /* MediaDismissAnimationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFDE123282D04F20098B17F /* MediaDismissAnimationController.swift */; }; + FDFDE126282D05380098B17F /* MediaInteractiveDismiss.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFDE125282D05380098B17F /* MediaInteractiveDismiss.swift */; }; + FDFDE128282D05530098B17F /* MediaPresentationContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFDE127282D05530098B17F /* MediaPresentationContext.swift */; }; + FDFDE12A282D056B0098B17F /* MediaZoomAnimationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFDE129282D056B0098B17F /* MediaZoomAnimationController.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1006,7 +1011,6 @@ 34B0796C1FCF46B000E248C2 /* MainAppContext.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MainAppContext.h; sourceTree = ""; }; 34B0796E1FD07B1E00E248C2 /* SignalShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SignalShareExtension.entitlements; sourceTree = ""; }; 34B6A902218B3F62007C4606 /* TypingIndicatorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingIndicatorView.swift; sourceTree = ""; }; - 34B6A904218B4C90007C4606 /* TypingIndicatorInteraction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingIndicatorInteraction.swift; sourceTree = ""; }; 34BECE2D1F7ABCE000D7438D /* GifPickerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GifPickerViewController.swift; sourceTree = ""; }; 34BECE2F1F7ABCF800D7438D /* GifPickerLayout.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GifPickerLayout.swift; sourceTree = ""; }; 34C3C78C20409F320000134C /* Opening.m4r */ = {isa = PBXFileReference; lastKnownFileType = file; path = Opening.m4r; sourceTree = ""; }; @@ -1798,6 +1802,7 @@ FD17D7E427F6A09900122BE0 /* Identity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Identity.swift; sourceTree = ""; }; FD17D7E627F6A16700122BE0 /* _003_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _003_YDBToGRDBMigration.swift; sourceTree = ""; }; FD17D7E927F6A1C600122BE0 /* SUKLegacyModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SUKLegacyModels.swift; sourceTree = ""; }; + FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationBar+Utilities.swift"; sourceTree = ""; }; FD28A4F127E990E800FF65E7 /* BlockingManagerRemovalMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockingManagerRemovalMigration.swift; sourceTree = ""; }; FD28A4F327EA79F800FF65E7 /* BlockListUIUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockListUIUtils.swift; sourceTree = ""; }; FD28A4F527EAD44C00FF65E7 /* GRDBStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GRDBStorage.swift; sourceTree = ""; }; @@ -1829,6 +1834,7 @@ FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.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 = ""; }; + FDED2E3B282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UICollectionView+ReusableView.swift"; sourceTree = ""; }; FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreview.swift; sourceTree = ""; }; FDF0B73F280402C4004C14C5 /* Job.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Job.swift; sourceTree = ""; }; FDF0B7412804EA4F004C14C5 /* _002_SetupStandardJobs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_SetupStandardJobs.swift; sourceTree = ""; }; @@ -1849,6 +1855,10 @@ FDF2220E281B55E6000A4995 /* QueryInterfaceRequest+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "QueryInterfaceRequest+Utilities.swift"; sourceTree = ""; }; FDF22210281B5E0B000A4995 /* TableRecord+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TableRecord+Utilities.swift"; sourceTree = ""; }; FDFD645727EC1F4000808CA1 /* Atomic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = ""; }; + FDFDE123282D04F20098B17F /* MediaDismissAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaDismissAnimationController.swift; sourceTree = ""; }; + FDFDE125282D05380098B17F /* MediaInteractiveDismiss.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaInteractiveDismiss.swift; sourceTree = ""; }; + FDFDE127282D05530098B17F /* MediaPresentationContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPresentationContext.swift; sourceTree = ""; }; + FDFDE129282D056B0098B17F /* MediaZoomAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaZoomAnimationController.swift; sourceTree = ""; }; FEDBAE1B98C49BBE8C87F575 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; sourceTree = ""; }; FF9BA33D021B115B1F5B4E46 /* Pods-SessionMessagingKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionMessagingKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionMessagingKit/Pods-SessionMessagingKit.app store release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -2080,6 +2090,7 @@ B886B4A82398BA1500211ABE /* QRCode.swift */, B8783E9D23EB948D00404FB8 /* UILabel+Interaction.swift */, B83F2B87240CB75A000A54AB /* UIImage+Scaling.swift */, + FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */, C31A6C59247F214E001123EF /* UIView+Glow.swift */, C3548F0724456AB6009433A8 /* UIView+Wrapping.swift */, FD859EFF27C4691300510D0C /* MockDataGenerator.swift */, @@ -2396,6 +2407,7 @@ C38EF237255B6D65007E1867 /* UIDevice+featureSupport.swift */, C35D0DB425AE5F1200B6BF49 /* UIEdgeInsets.swift */, FD705A91278D051200F16121 /* ReusableView.swift */, + FDED2E3B282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift */, FD705A93278D052B00F16121 /* UITableView+ReusableView.swift */, C38EF23D255B6D66007E1867 /* UIView+OWS.h */, C38EF23E255B6D66007E1867 /* UIView+OWS.m */, @@ -2613,7 +2625,6 @@ C33FDB48255A580C00E217F9 /* TSOutgoingMessage.h */, C33FDB56255A580D00E217F9 /* TSOutgoingMessage.m */, B84072952565E9F50037CB17 /* TSOutgoingMessage+Conversion.swift */, - 34B6A904218B4C90007C4606 /* TypingIndicatorInteraction.swift */, ); path = Signal; sourceTree = ""; @@ -2890,6 +2901,7 @@ C36096BA25AD1B14008B62B2 /* Media Viewing & Editing */ = { isa = PBXGroup; children = ( + FDFDE122282D04E30098B17F /* Transitions */, C36096B925AD1ACF008B62B2 /* GIFs */, 34E3E5671EC4B19400495BAC /* AudioProgressView.swift */, 346B66301F4E29B200E5122F /* CropScaleImageViewController.swift */, @@ -3755,6 +3767,17 @@ path = Errors; sourceTree = ""; }; + FDFDE122282D04E30098B17F /* Transitions */ = { + isa = PBXGroup; + children = ( + FDFDE123282D04F20098B17F /* MediaDismissAnimationController.swift */, + FDFDE125282D05380098B17F /* MediaInteractiveDismiss.swift */, + FDFDE127282D05530098B17F /* MediaPresentationContext.swift */, + FDFDE129282D056B0098B17F /* MediaZoomAnimationController.swift */, + ); + path = Transitions; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -4749,6 +4772,7 @@ B8856DEF256F161F001CE70E /* NSString+SSK.m in Sources */, FD09C5E2282212B3000CE219 /* JobDependencies.swift in Sources */, FD09796727F6B0B600936362 /* Sodium+Conversion.swift in Sources */, + FDED2E3C282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift in Sources */, FD9004122818ABDC00ABAAF6 /* Job.swift in Sources */, FD09797927FAB7E800936362 /* ImageFormat.swift in Sources */, C32C5DC9256DD935003C73A2 /* ProxiedContentDownloader.swift in Sources */, @@ -4945,7 +4969,6 @@ C352A349255781F400338F3E /* AttachmentDownloadJob.swift in Sources */, C352A30925574D8500338F3E /* Message+Destination.swift in Sources */, C300A5E72554B07300555489 /* ExpirationTimerUpdate.swift in Sources */, - B88A1AC725C90A4700E6D421 /* TypingIndicatorInteraction.swift in Sources */, C3D9E3C025676AD70040E4F3 /* TSAttachment.m in Sources */, C32C598A256D0664003C73A2 /* SNProtoEnvelope+Conversion.swift in Sources */, C352A2FF25574B6300338F3E /* MessageSendJob.swift in Sources */, @@ -4991,6 +5014,7 @@ FDF2220B2818F38D000A4995 /* SessionApp.swift in Sources */, B8041AA725C90927003C2166 /* TypingIndicatorCell.swift in Sources */, B8CCF63723961D6D0091D419 /* NewDMVC.swift in Sources */, + FDFDE12A282D056B0098B17F /* MediaZoomAnimationController.swift in Sources */, 452EC6DF205E9E30000E787C /* MediaGalleryViewController.swift in Sources */, 4C1885D2218F8E1C00B67051 /* PhotoGridViewCell.swift in Sources */, 34D1F0501F7D45A60066283D /* GifPickerCell.swift in Sources */, @@ -5043,9 +5067,12 @@ 4CC613362227A00400E21A3A /* ConversationSearch.swift in Sources */, B82149B825D60393009C0F2A /* BlockedModal.swift in Sources */, B82B408C239A068800A248E7 /* RegisterVC.swift in Sources */, + FDFDE126282D05380098B17F /* MediaInteractiveDismiss.swift in Sources */, 34BECE301F7ABCF800D7438D /* GifPickerLayout.swift in Sources */, C331FFFE2558FF3B00070591 /* ConversationCell.swift in Sources */, + FDFDE128282D05530098B17F /* MediaPresentationContext.swift in Sources */, FD659AC027A7649600F12C02 /* MessageRequestsViewController.swift in Sources */, + FDFDE124282D04F20098B17F /* MediaDismissAnimationController.swift in Sources */, B8F5F72325F1B4CA003BF8D4 /* DownloadAttachmentModal.swift in Sources */, C3DFFAC623E96F0D0058DAF8 /* Sheet.swift in Sources */, B8D84EA325DF745A005A043E /* LinkPreviewState.swift in Sources */, @@ -5134,6 +5161,7 @@ B897621C25D201F7004F83B2 /* ScrollToBottomButton.swift in Sources */, 346B66311F4E29B200E5122F /* CropScaleImageViewController.swift in Sources */, 45E5A6991F61E6DE001E4A8A /* MarqueeLabel.swift in Sources */, + FD1C98E4282E3C5B00B76F9E /* UINavigationBar+Utilities.swift in Sources */, C302093E25DCBF08001F572D /* MentionSelectionView.swift in Sources */, C328251F25CA3A900062D0A7 /* QuoteView.swift in Sources */, B8EB20F02640F7F000773E52 /* OpenGroupInvitationView.swift in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 0ac633d1f..3d37ebcf7 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -301,7 +301,6 @@ extension ConversationVC: let linkPreviewDraft: OWSLinkPreviewDraft? = snInputView.linkPreviewInfo?.draft let quoteModel: QuotedReplyModel? = snInputView.quoteDraftInfo?.model - for: self.thread, approveMessageRequestIfNeeded( for: thread, isNewThread: !oldThreadShouldBeVisible, @@ -332,21 +331,14 @@ extension ConversationVC: let linkPreviewDraft: OWSLinkPreviewDraft = linkPreviewDraft, (try? interaction.linkPreview.isEmpty(db)) == true { - var attachmentId: String? - - // If the LinkPreview has image data then create an attachment first - if let imageData: Data = linkPreviewDraft.jpegImageData { - attachmentId = try LinkPreview.saveAttachmentIfPossible( - db, - imageData: imageData, - mimeType: OWSMimeTypeImageJpeg - ) - } - try LinkPreview( url: linkPreviewDraft.urlString, title: linkPreviewDraft.title, - attachmentId: attachmentId + attachmentId: LinkPreview.saveAttachmentIfPossible( + db, + imageData: linkPreviewDraft.jpegImageData, + mimeType: OWSMimeTypeImageJpeg + ) ).insert(db) } @@ -359,14 +351,13 @@ extension ConversationVC: authorId: quoteModel.authorId, timestampMs: quoteModel.timestampMs, body: quoteModel.body, - attachmentId: quoteModel.attachment?.id + attachmentId: quoteModel.generateAttachmentThumbnailIfNeeded(db) ).insert(db) } try MessageSender.send( db, interaction: interaction, - with: [], in: thread ) }, @@ -417,14 +408,14 @@ extension ConversationVC: .updateAll(db, SessionThread.Columns.shouldBeVisible.set(to: true)) // Create the interaction - let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) + let userPublicKey: String = getUserHexEncodedPublicKey(db) let interaction: Interaction = try Interaction( threadId: thread.id, authorId: getUserHexEncodedPublicKey(db), variant: .standardOutgoing, body: text, timestampMs: sentTimestampMs, - hasMention: text.contains("@\(currentUserPublicKey)") + hasMention: text.contains("@\(userPublicKey)") ).inserted(db) try MessageSender.send( @@ -668,33 +659,41 @@ extension ConversationVC: case .audio: viewModel.playOrPauseAudio(for: item) case .mediaMessage: - guard let index = viewItems.firstIndex(where: { $0 === viewItem }), - let cell = messagesTableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell else { return } - if - viewItem.interaction is TSIncomingMessage, - let thread = self.thread as? TSContactThread, - let contact: Contact? = GRDBStorage.shared.read({ db in try Contact.fetchOne(db, id: thread.contactSessionID()) }), - contact?.isTrusted != true { - confirmDownload() - } else { - guard let albumView = cell.albumView else { return } - let locationInCell = gestureRecognizer.location(in: cell) - // Figure out which of the media views was tapped - let locationInAlbumView = cell.convert(locationInCell, to: albumView) - guard let mediaView = albumView.mediaView(forLocation: locationInAlbumView) else { return } - if albumView.isMoreItemsView(mediaView: mediaView) && viewItem.mediaAlbumHasFailedAttachment() { + guard + let index = self.viewModel.viewData.items.firstIndex(where: { $0.interactionId == item.interactionId }), + let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell, + let albumView: MediaAlbumView = cell.albumView + else { return } + + let locationInCell: CGPoint = gestureRecognizer.location(in: cell) + + // Figure out which of the media views was tapped + let locationInAlbumView: CGPoint = cell.convert(locationInCell, to: albumView) + guard let mediaView = albumView.mediaView(forLocation: locationInAlbumView) else { return } + + + switch mediaView.attachment.state { + case .pending, .downloading, .uploading: // TODO: Tapped a failed incoming attachment - } - let attachment = mediaView.attachment - if let pointer = attachment as? TSAttachmentPointer { - if pointer.state == .failed { - // TODO: Tapped a failed incoming attachment + break + + case .failed: + // TODO: Tapped a failed incoming attachment + break + + default: + let viewController: UIViewController? = MediaGalleryViewModel.createDetailViewController( + for: self.viewModel.viewData.thread.id, + item: item, + selectedAttachmentId: mediaView.attachment.id, + options: [ .sliderEnabled, .showAllMediaButton ] + ) + + if let viewController: UIViewController = viewController { + self.present(viewController, animated: true, completion: nil) } - } - guard let stream = attachment as? TSAttachmentStream else { return } - let gallery = MediaGallery(thread: thread, options: [ .sliderEnabled, .showAllMediaButton ]) - gallery.presentDetailView(fromViewController: self, mediaAttachment: stream) } + case .genericAttachment: guard let attachment: Attachment = item.attachments?.first, @@ -1554,9 +1553,9 @@ extension ConversationVC { alertVC.addAction(UIAlertAction(title: "TXT_DELETE_TITLE".localized(), style: .destructive) { _ in // Delete the request GRDBStorage.shared.writeAsync( - updates: { [weak self] db in + updates: { db in // Update the contact - try? Contact + _ = try Contact .fetchOrCreate(db, id: threadId) .with( isApproved: false, @@ -1586,3 +1585,98 @@ extension ConversationVC { self.present(alertVC, animated: true, completion: nil) } } + +// MARK: - MediaPresentationContextProvider + +extension ConversationVC: MediaPresentationContextProvider { + func mediaPresentationContext(mediaItem: Media, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? { + guard case let .gallery(galleryItem) = mediaItem else { return nil } + // Note: According to Apple's docs the 'indexPathsForVisibleRows' method returns an + // unsorted array which means we can't use it to determine the desired 'visibleCell' + // we are after, due to this we will need to iterate all of the visible cells to find + // the one we want + let maybeMessageCell: VisibleMessageCell? = tableView.visibleCells + .first { cell -> Bool in + ((cell as? VisibleMessageCell)? + .albumView? + .itemViews + .contains(where: { mediaView in + mediaView.attachment.id == galleryItem.attachment.id + })) + .defaulting(to: false) + } + .map { $0 as? VisibleMessageCell } + let maybeTargetView: MediaView? = maybeMessageCell? + .albumView? + .itemViews + .first(where: { $0.attachment.id == galleryItem.attachment.id }) + + guard + let messageCell: VisibleMessageCell = maybeMessageCell, + let targetView: MediaView = maybeTargetView, + let mediaSuperview: UIView = targetView.superview + else { return nil } + + let cornerRadius: CGFloat + let cornerMask: CACornerMask + let presentationFrame = coordinateSpace.convert(targetView.frame, from: mediaSuperview) + + if messageCell.bubbleView.bounds == targetView.bounds { + cornerRadius = messageCell.bubbleView.layer.cornerRadius + cornerMask = messageCell.bubbleView.layer.maskedCorners + } + else { + // If the frames don't match then assume it's either multiple images or there is a caption + // and determine which corners need to be rounded + cornerRadius = messageCell.bubbleView.layer.cornerRadius + + var newCornerMask = CACornerMask() + let cellMaskedCorners: CACornerMask = messageCell.bubbleView.layer.maskedCorners + + if + cellMaskedCorners.contains(.layerMinXMinYCorner) && + targetView.frame.minX < CGFloat.leastNonzeroMagnitude && + targetView.frame.minY < CGFloat.leastNonzeroMagnitude + { + newCornerMask.insert(.layerMinXMinYCorner) + } + + if + cellMaskedCorners.contains(.layerMaxXMinYCorner) && + abs(targetView.frame.maxX - messageCell.bubbleView.bounds.width) < CGFloat.leastNonzeroMagnitude && + targetView.frame.minY < CGFloat.leastNonzeroMagnitude + { + newCornerMask.insert(.layerMaxXMinYCorner) + } + + if + cellMaskedCorners.contains(.layerMinXMaxYCorner) && + targetView.frame.minX < CGFloat.leastNonzeroMagnitude && + abs(targetView.frame.maxY - messageCell.bubbleView.bounds.height) < CGFloat.leastNonzeroMagnitude + { + newCornerMask.insert(.layerMinXMaxYCorner) + } + + if + cellMaskedCorners.contains(.layerMaxXMaxYCorner) && + abs(targetView.frame.maxX - messageCell.bubbleView.bounds.width) < CGFloat.leastNonzeroMagnitude && + abs(targetView.frame.maxY - messageCell.bubbleView.bounds.height) < CGFloat.leastNonzeroMagnitude + { + newCornerMask.insert(.layerMaxXMaxYCorner) + } + + cornerMask = newCornerMask + } + + return MediaPresentationContext( + mediaView: targetView, + presentationFrame: presentationFrame, + cornerRadius: cornerRadius, + cornerMask: cornerMask + ) + } + + func snapshotOverlayView(in coordinateSpace: UICoordinateSpace) -> (UIView, CGRect)? { + return self.navigationController?.navigationBar.generateSnapshot(in: coordinateSpace) + } +} diff --git a/Session/Conversations/Message Cells/Content Views/MediaAlbumView.swift b/Session/Conversations/Message Cells/Content Views/MediaAlbumView.swift index 8fdd9f266..5600fa62b 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaAlbumView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaAlbumView.swift @@ -1,17 +1,11 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -import Foundation +import UIKit +import SessionMessagingKit -@objc(OWSMediaAlbumView) public class MediaAlbumView: UIStackView { - private let items: [ConversationMediaAlbumItem] - - @objc + private let items: [Attachment] public let itemViews: [MediaView] - - @objc public var moreItemsView: MediaView? private static let kSpacingPts: CGFloat = 2 @@ -22,19 +16,22 @@ public class MediaAlbumView: UIStackView { notImplemented() } - @objc - public required init(mediaCache: NSCache, - items: [ConversationMediaAlbumItem], - isOutgoing: Bool, - maxMessageWidth: CGFloat) { + public required init( + mediaCache: NSCache, + items: [Attachment], + isOutgoing: Bool, + maxMessageWidth: CGFloat + ) { self.items = items - self.itemViews = MediaAlbumView.itemsToDisplay(forItems: items).map { - let result = MediaView(mediaCache: mediaCache, - attachment: $0.attachment, - isOutgoing: isOutgoing, - maxMessageWidth: maxMessageWidth) - return result - } + self.itemViews = MediaAlbumView.itemsToDisplay(forItems: items) + .map { + MediaView( + mediaCache: mediaCache, + attachment: $0, + isOutgoing: isOutgoing, + maxMessageWidth: maxMessageWidth + ) + } super.init(frame: .zero) @@ -46,110 +43,137 @@ public class MediaAlbumView: UIStackView { private func createContents(maxMessageWidth: CGFloat) { switch itemViews.count { - case 0: - owsFailDebug("No item views.") - return - case 1: - // X - guard let itemView = itemViews.first else { - owsFailDebug("Missing item view.") - return - } - addSubview(itemView) - itemView.autoPinEdgesToSuperviewEdges() - case 2: - // X X - // side-by-side. - let imageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2 - autoSet(viewSize: imageSize, ofViews: itemViews) - for itemView in itemViews { - addArrangedSubview(itemView) - } - self.axis = .horizontal - self.spacing = MediaAlbumView.kSpacingPts - case 3: - // x - // X x - // Big on left, 2 small on right. - let smallImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts * 2) / 3 - let bigImageSize = smallImageSize * 2 + MediaAlbumView.kSpacingPts - - guard let leftItemView = itemViews.first else { - owsFailDebug("Missing view") - return - } - autoSet(viewSize: bigImageSize, ofViews: [leftItemView]) - addArrangedSubview(leftItemView) - - let rightViews = Array(itemViews[1..<3]) - addArrangedSubview(newRow(rowViews: rightViews, - axis: .vertical, - viewSize: smallImageSize)) - self.axis = .horizontal - self.spacing = MediaAlbumView.kSpacingPts - case 4: - // X X - // X X - // Square - let imageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2 - - let topViews = Array(itemViews[0..<2]) - addArrangedSubview(newRow(rowViews: topViews, - axis: .horizontal, - viewSize: imageSize)) - - let bottomViews = Array(itemViews[2..<4]) - addArrangedSubview(newRow(rowViews: bottomViews, - axis: .horizontal, - viewSize: imageSize)) - - self.axis = .vertical - self.spacing = MediaAlbumView.kSpacingPts - default: - // X X - // xxx - // 2 big on top, 3 small on bottom. - let bigImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2 - let smallImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts * 2) / 3 - - let topViews = Array(itemViews[0..<2]) - addArrangedSubview(newRow(rowViews: topViews, - axis: .horizontal, - viewSize: bigImageSize)) - - let bottomViews = Array(itemViews[2..<5]) - addArrangedSubview(newRow(rowViews: bottomViews, - axis: .horizontal, - viewSize: smallImageSize)) - - self.axis = .vertical - self.spacing = MediaAlbumView.kSpacingPts - - if items.count > MediaAlbumView.kMaxItems { - guard let lastView = bottomViews.last else { - owsFailDebug("Missing lastView") + case 0: return owsFailDebug("No item views.") + + case 1: + // X + guard let itemView = itemViews.first else { + owsFailDebug("Missing item view.") return } + addSubview(itemView) + itemView.autoPinEdgesToSuperviewEdges() + + case 2: + // X X + // side-by-side. + let imageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2 + autoSet(viewSize: imageSize, ofViews: itemViews) + for itemView in itemViews { + addArrangedSubview(itemView) + } + self.axis = .horizontal + self.distribution = .fillEqually + self.spacing = MediaAlbumView.kSpacingPts + + case 3: + // x + // X x + // Big on left, 2 small on right. + let smallImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts * 2) / 3 + let bigImageSize = smallImageSize * 2 + MediaAlbumView.kSpacingPts - moreItemsView = lastView + guard let leftItemView = itemViews.first else { + owsFailDebug("Missing view") + return + } + autoSet(viewSize: bigImageSize, ofViews: [leftItemView]) + addArrangedSubview(leftItemView) - let tintView = UIView() - tintView.backgroundColor = UIColor(white: 0, alpha: 0.4) - lastView.addSubview(tintView) - tintView.autoPinEdgesToSuperviewEdges() + let rightViews = Array(itemViews[1..<3]) + addArrangedSubview( + newRow( + rowViews: rightViews, + axis: .vertical, + viewSize: smallImageSize + ) + ) + self.axis = .horizontal + self.spacing = MediaAlbumView.kSpacingPts + + case 4: + // X X + // X X + // Square + let imageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2 - let moreCount = max(1, items.count - MediaAlbumView.kMaxItems) - let moreCountText = OWSFormat.formatInt(Int32(moreCount)) - let moreText = String(format: NSLocalizedString("MEDIA_GALLERY_MORE_ITEMS_FORMAT", - comment: "Format for the 'more items' indicator for media galleries. Embeds {{the number of additional items}}."), moreCountText) - let moreLabel = UILabel() - moreLabel.text = moreText - moreLabel.textColor = UIColor.ows_white - // We don't want to use dynamic text here. - moreLabel.font = UIFont.systemFont(ofSize: 24) - lastView.addSubview(moreLabel) - moreLabel.autoCenterInSuperview() - } + let topViews = Array(itemViews[0..<2]) + addArrangedSubview( + newRow( + rowViews: topViews, + axis: .horizontal, + viewSize: imageSize + ) + ) + + let bottomViews = Array(itemViews[2..<4]) + addArrangedSubview( + newRow( + rowViews: bottomViews, + axis: .horizontal, + viewSize: imageSize + ) + ) + + self.axis = .vertical + self.spacing = MediaAlbumView.kSpacingPts + + default: + // X X + // xxx + // 2 big on top, 3 small on bottom. + let bigImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2 + let smallImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts * 2) / 3 + + let topViews = Array(itemViews[0..<2]) + addArrangedSubview( + newRow( + rowViews: topViews, + axis: .horizontal, + viewSize: bigImageSize + ) + ) + + let bottomViews = Array(itemViews[2..<5]) + addArrangedSubview( + newRow( + rowViews: bottomViews, + axis: .horizontal, + viewSize: smallImageSize + ) + ) + + self.axis = .vertical + self.spacing = MediaAlbumView.kSpacingPts + + if items.count > MediaAlbumView.kMaxItems { + guard let lastView = bottomViews.last else { + owsFailDebug("Missing lastView") + return + } + + moreItemsView = lastView + + let tintView = UIView() + tintView.backgroundColor = UIColor(white: 0, alpha: 0.4) + lastView.addSubview(tintView) + tintView.autoPinEdgesToSuperviewEdges() + + let moreCount = max(1, items.count - MediaAlbumView.kMaxItems) + let moreCountText = OWSFormat.formatInt(Int32(moreCount)) + let moreText = String( + // Format for the 'more items' indicator for media galleries. Embeds {{the number of additional items}}. + format: "MEDIA_GALLERY_MORE_ITEMS_FORMAT".localized(), + moreCountText + ) + let moreLabel = UILabel() + moreLabel.text = moreText + moreLabel.textColor = UIColor.ows_white + // We don't want to use dynamic text here. + moreLabel.font = UIFont.systemFont(ofSize: 24) + lastView.addSubview(moreLabel) + moreLabel.autoCenterInSuperview() + } } for itemView in itemViews { @@ -181,43 +205,47 @@ public class MediaAlbumView: UIStackView { } } - private func autoSet(viewSize: CGFloat, - ofViews views: [MediaView]) { + private func autoSet( + viewSize: CGFloat, + ofViews views: [MediaView] + ) { for itemView in views { itemView.autoSetDimensions(to: CGSize(width: viewSize, height: viewSize)) } } - private func newRow(rowViews: [MediaView], - axis: NSLayoutConstraint.Axis, - viewSize: CGFloat) -> UIStackView { + private func newRow( + rowViews: [MediaView], + axis: NSLayoutConstraint.Axis, + viewSize: CGFloat + ) -> UIStackView { autoSet(viewSize: viewSize, ofViews: rowViews) return newRow(rowViews: rowViews, axis: axis) } - private func newRow(rowViews: [MediaView], - axis: NSLayoutConstraint.Axis) -> UIStackView { + private func newRow( + rowViews: [MediaView], + axis: NSLayoutConstraint.Axis + ) -> UIStackView { let stackView = UIStackView(arrangedSubviews: rowViews) stackView.axis = axis stackView.spacing = MediaAlbumView.kSpacingPts return stackView } - @objc public func loadMedia() { for itemView in itemViews { itemView.loadMedia() } } - @objc public func unloadMedia() { for itemView in itemViews { itemView.unloadMedia() } } - private class func itemsToDisplay(forItems items: [ConversationMediaAlbumItem]) -> [ConversationMediaAlbumItem] { + private class func itemsToDisplay(forItems items: [Attachment]) -> [Attachment] { // TODO: Unless design changes, we want to display // items which are still downloading and invalid // items. @@ -228,43 +256,47 @@ public class MediaAlbumView: UIStackView { return validItems } - @objc - public class func layoutSize(forMaxMessageWidth maxMessageWidth: CGFloat, - items: [ConversationMediaAlbumItem]) -> CGSize { + public class func layoutSize( + forMaxMessageWidth maxMessageWidth: CGFloat, + items: [Attachment] + ) -> CGSize { let itemCount = itemsToDisplay(forItems: items).count + switch itemCount { - case 0, 1, 4: - // X - // - // or - // - // XX - // XX - // Square - return CGSize(width: maxMessageWidth, height: maxMessageWidth) - case 2: - // X X - // side-by-side. - let imageSize = (maxMessageWidth - kSpacingPts) / 2 - return CGSize(width: maxMessageWidth, height: imageSize) - case 3: - // x - // X x - // Big on left, 2 small on right. - let smallImageSize = (maxMessageWidth - kSpacingPts * 2) / 3 - let bigImageSize = smallImageSize * 2 + kSpacingPts - return CGSize(width: maxMessageWidth, height: bigImageSize) - default: - // X X - // xxx - // 2 big on top, 3 small on bottom. - let bigImageSize = (maxMessageWidth - kSpacingPts) / 2 - let smallImageSize = (maxMessageWidth - kSpacingPts * 2) / 3 - return CGSize(width: maxMessageWidth, height: bigImageSize + smallImageSize + kSpacingPts) + case 0, 1, 4: + // X + // + // or + // + // XX + // XX + // Square + return CGSize(width: maxMessageWidth, height: maxMessageWidth) + + case 2: + // X X + // side-by-side. + let imageSize = (maxMessageWidth - kSpacingPts) / 2 + return CGSize(width: maxMessageWidth, height: imageSize) + + case 3: + // x + // X x + // Big on left, 2 small on right. + let smallImageSize = (maxMessageWidth - kSpacingPts * 2) / 3 + let bigImageSize = smallImageSize * 2 + kSpacingPts + return CGSize(width: maxMessageWidth, height: bigImageSize) + + default: + // X X + // xxx + // 2 big on top, 3 small on bottom. + let bigImageSize = (maxMessageWidth - kSpacingPts) / 2 + let smallImageSize = (maxMessageWidth - kSpacingPts * 2) / 3 + return CGSize(width: maxMessageWidth, height: bigImageSize + smallImageSize + kSpacingPts) } } - @objc public func mediaView(forLocation location: CGPoint) -> MediaView? { var bestMediaView: MediaView? var bestDistance: CGFloat = 0 @@ -280,7 +312,6 @@ public class MediaAlbumView: UIStackView { return bestMediaView } - @objc public func isMoreItemsView(mediaView: MediaView) -> Bool { return moreItemsView == mediaView } diff --git a/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift b/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift index 5c4f586af..4f65a24d5 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift @@ -1,18 +1,18 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -final class MediaPlaceholderView : UIView { - private let viewItem: ConversationViewItem - private let textColor: UIColor - - // MARK: Settings +import UIKit +import SessionMessagingKit + +final class MediaPlaceholderView: UIView { private static let iconSize: CGFloat = 24 private static let iconImageViewSize: CGFloat = 40 - // MARK: Lifecycle - init(viewItem: ConversationViewItem, textColor: UIColor) { - self.viewItem = viewItem - self.textColor = textColor + // MARK: - Lifecycle + + init(item: ConversationViewModel.Item, textColor: UIColor) { super.init(frame: CGRect.zero) - setUpViewHierarchy() + + setUpViewHierarchy(item: item, textColor: textColor) } override init(frame: CGRect) { @@ -23,32 +23,47 @@ final class MediaPlaceholderView : UIView { preconditionFailure("Use init(viewItem:textColor:) instead.") } - private func setUpViewHierarchy() { + private func setUpViewHierarchy( + item: ConversationViewModel.Item, + textColor: UIColor + ) { let (iconName, attachmentDescription): (String, String) = { - guard let message = viewItem.interaction as? TSIncomingMessage else { return ("actionsheet_document_black", "file") } // Should never occur - var attachments: [TSAttachment] = [] - Storage.read { transaction in - attachments = message.attachments(with: transaction) + guard + item.interactionVariant == .standardIncoming, + let attachment: Attachment = item.attachments?.first + else { + return ("actionsheet_document_black", "file") // Should never occur } - guard let contentType = attachments.first?.contentType else { return ("actionsheet_document_black", "file") } // Should never occur - if MIMETypeUtil.isAudio(contentType) { return ("attachment_audio", "audio") } - if MIMETypeUtil.isImage(contentType) || MIMETypeUtil.isVideo(contentType) { return ("actionsheet_camera_roll_black", "media") } + + if attachment.isAudio { return ("attachment_audio", "audio") } + if attachment.isImage || attachment.isVideo { return ("actionsheet_camera_roll_black", "media") } + return ("actionsheet_document_black", "file") }() + // Image view - let iconSize = MediaPlaceholderView.iconSize - let icon = UIImage(named: iconName)?.withTint(textColor)?.resizedImage(to: CGSize(width: iconSize, height: iconSize)) - let imageView = UIImageView(image: icon) + let imageView = UIImageView( + image: UIImage(named: iconName)? + .withRenderingMode(.alwaysTemplate) + .resizedImage( + to: CGSize( + width: MediaPlaceholderView.iconSize, + height: MediaPlaceholderView.iconSize + ) + ) + ) + imageView.tintColor = textColor imageView.contentMode = .center - let iconImageViewSize = MediaPlaceholderView.iconImageViewSize - imageView.set(.width, to: iconImageViewSize) - imageView.set(.height, to: iconImageViewSize) + imageView.set(.width, to: MediaPlaceholderView.iconImageViewSize) + imageView.set(.height, to: MediaPlaceholderView.iconImageViewSize) + // Body label let titleLabel = UILabel() titleLabel.lineBreakMode = .byTruncatingTail titleLabel.text = "Tap to download \(attachmentDescription)" titleLabel.textColor = textColor titleLabel.font = .systemFont(ofSize: Values.mediumFontSize) + // Stack view let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ]) stackView.axis = .horizontal diff --git a/Session/Conversations/Message Cells/Content Views/MediaView.swift b/Session/Conversations/Message Cells/Content Views/MediaView.swift index 15ff3b413..72350d966 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaView.swift @@ -1,13 +1,13 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -import Foundation +import UIKit +import YYImage import SessionUIKit +import SessionMessagingKit -@objc(OWSMediaView) public class MediaView: UIView { - + static let contentMode: UIView.ContentMode = .scaleAspectFill + private enum MediaError { case missing case invalid @@ -17,8 +17,7 @@ public class MediaView: UIView { // MARK: - private let mediaCache: NSCache - @objc - public let attachment: TSAttachment + public let attachment: Attachment private let isOutgoing: Bool private let maxMessageWidth: CGFloat private var loadBlock: (() -> Void)? @@ -42,50 +41,16 @@ public class MediaView: UIView { case failed } - // Thread-safe access to load state. - // - // We use a "box" class so that we can capture a reference - // to this box (rather than self) and a) safely access - // if off the main thread b) not prevent deallocation of - // self. - private class ThreadSafeLoadState { - private var value: LoadState - - required init(_ value: LoadState) { - self.value = value - } - - func get() -> LoadState { - objc_sync_enter(self) - let valueCopy = value - objc_sync_exit(self) - return valueCopy - } - - func set(_ newValue: LoadState) { - objc_sync_enter(self) - value = newValue - objc_sync_exit(self) - } - } - private let threadSafeLoadState = ThreadSafeLoadState(.unloaded) - // Convenience accessors. - private var loadState: LoadState { - get { - return threadSafeLoadState.get() - } - set { - threadSafeLoadState.set(newValue) - } - } + private let loadState: Atomic = Atomic(.unloaded) // MARK: - Initializers - @objc - public required init(mediaCache: NSCache, - attachment: TSAttachment, - isOutgoing: Bool, - maxMessageWidth: CGFloat) { + public required init( + mediaCache: NSCache, + attachment: Attachment, + isOutgoing: Bool, + maxMessageWidth: CGFloat + ) { self.mediaCache = mediaCache self.attachment = attachment self.isOutgoing = isOutgoing @@ -105,9 +70,7 @@ public class MediaView: UIView { } deinit { - AssertIsOnMainThread() - - loadState = .unloaded + loadState.mutate { $0 = .unloaded } } // MARK: - @@ -115,41 +78,41 @@ public class MediaView: UIView { private func createContents() { AssertIsOnMainThread() - guard let attachmentStream = attachment as? TSAttachmentStream else { + guard attachment.state == .uploaded || attachment.state == .downloaded else { addDownloadProgressIfNecessary() return } - guard !isFailedDownload else { + guard attachment.state != .failed else { configure(forError: .failed) return } - if attachmentStream.isAnimated { - configureForAnimatedImage(attachmentStream: attachmentStream) - } else if attachmentStream.isImage { - configureForStillImage(attachmentStream: attachmentStream) - } else if attachmentStream.isVideo { - configureForVideo(attachmentStream: attachmentStream) - } else { + + if attachment.isAnimated { + configureForAnimatedImage(attachment: attachment) + } + else if attachment.isImage { + configureForStillImage(attachment: attachment) + } + else if attachment.isVideo { + configureForVideo(attachment: attachment) + } + else { owsFailDebug("Attachment has unexpected type.") configure(forError: .invalid) } } private func addDownloadProgressIfNecessary() { - guard !isFailedDownload else { + guard attachment.state != .failed else { configure(forError: .failed) return } - guard let attachmentPointer = attachment as? TSAttachmentPointer else { - owsFailDebug("Attachment has unexpected type.") - configure(forError: .invalid) - return - } - guard attachmentPointer.pointerType == .incoming else { + guard attachment.state != .uploading && attachment.state != .uploaded else { // TODO: Show "restoring" indicator and possibly progress. configure(forError: .missing) return } + backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05) let loader = MediaLoaderView() addSubview(loader) @@ -158,23 +121,20 @@ public class MediaView: UIView { private func addUploadProgressIfNecessary(_ subview: UIView) -> Bool { guard isOutgoing else { return false } - guard let attachmentStream = attachment as? TSAttachmentStream else { return false } - guard !attachmentStream.isUploaded else { return false } + guard attachment.state != .uploaded else { return false } + let loader = MediaLoaderView() addSubview(loader) loader.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.bottom, UIView.HorizontalEdge.right ], to: self) + return true } - private func configureForAnimatedImage(attachmentStream: TSAttachmentStream) { - guard let cacheKey = attachmentStream.uniqueId else { - owsFailDebug("Attachment stream missing unique ID.") - return - } - let animatedImageView = YYAnimatedImageView() + private func configureForAnimatedImage(attachment: Attachment) { + let animatedImageView: YYAnimatedImageView = YYAnimatedImageView() // We need to specify a contentMode since the size of the image // might not match the aspect ratio of the view. - animatedImageView.contentMode = .scaleAspectFill + animatedImageView.contentMode = MediaView.contentMode // Use trilinear filters for better scaling quality at // some performance cost. animatedImageView.layer.minificationFilter = .trilinear @@ -187,36 +147,37 @@ public class MediaView: UIView { loadBlock = { [weak self] in AssertIsOnMainThread() - guard let strongSelf = self else { - return - } + guard let strongSelf = self else { return } if animatedImageView.image != nil { owsFailDebug("Unexpectedly already loaded.") return } - strongSelf.tryToLoadMedia(loadMediaBlock: { () -> AnyObject? in - guard attachmentStream.isValidImage else { - Logger.warn("Ignoring invalid attachment.") - return nil - } - guard let filePath = attachmentStream.originalFilePath else { - owsFailDebug("Attachment stream missing original file path.") - return nil - } - let animatedImage = YYImage(contentsOfFile: filePath) - return animatedImage - }, - applyMediaBlock: { (media) in - AssertIsOnMainThread() - - guard let image = media as? YYImage else { - owsFailDebug("Media has unexpected type: \(type(of: media))") - return - } - animatedImageView.image = image - }, - cacheKey: cacheKey) + strongSelf.tryToLoadMedia( + loadMediaBlock: { applyMediaBlock in + guard attachment.isValid else { + Logger.warn("Ignoring invalid attachment.") + return + } + guard let filePath: String = attachment.originalFilePath else { + owsFailDebug("Attachment stream missing original file path.") + return + } + + applyMediaBlock(YYImage(contentsOfFile: filePath)) + }, + applyMediaBlock: { media in + AssertIsOnMainThread() + + guard let image: YYImage = media as? YYImage else { + owsFailDebug("Media has unexpected type: \(type(of: media))") + return + } + + animatedImageView.image = image + }, + cacheKey: attachment.id + ) } unloadBlock = { AssertIsOnMainThread() @@ -225,15 +186,11 @@ public class MediaView: UIView { } } - private func configureForStillImage(attachmentStream: TSAttachmentStream) { - guard let cacheKey = attachmentStream.uniqueId else { - owsFailDebug("Attachment stream missing unique ID.") - return - } + private func configureForStillImage(attachment: Attachment) { let stillImageView = UIImageView() // We need to specify a contentMode since the size of the image // might not match the aspect ratio of the view. - stillImageView.contentMode = .scaleAspectFill + stillImageView.contentMode = MediaView.contentMode // Use trilinear filters for better scaling quality at // some performance cost. stillImageView.layer.minificationFilter = .trilinear @@ -242,6 +199,7 @@ public class MediaView: UIView { addSubview(stillImageView) stillImageView.autoPinEdgesToSuperviewEdges() _ = addUploadProgressIfNecessary(stillImageView) + loadBlock = { [weak self] in AssertIsOnMainThread() @@ -249,29 +207,31 @@ public class MediaView: UIView { owsFailDebug("Unexpectedly already loaded.") return } - self?.tryToLoadMedia(loadMediaBlock: { () -> AnyObject? in - guard attachmentStream.isValidImage else { - Logger.warn("Ignoring invalid attachment.") - return nil - } - return attachmentStream.thumbnailImageLarge(success: { (image) in + self?.tryToLoadMedia( + loadMediaBlock: { applyMediaBlock in + guard attachment.isValid else { + Logger.warn("Ignoring invalid attachment.") + return + } + + attachment.thumbnail( + size: .large, + success: { image, _ in applyMediaBlock(image) }, + failure: { Logger.error("Could not load thumbnail") } + ) + }, + applyMediaBlock: { media in AssertIsOnMainThread() - + + guard let image: UIImage = media as? UIImage else { + owsFailDebug("Media has unexpected type: \(type(of: media))") + return + } + stillImageView.image = image - }, failure: { - Logger.error("Could not load thumbnail") - }) - }, - applyMediaBlock: { (media) in - AssertIsOnMainThread() - - guard let image = media as? UIImage else { - owsFailDebug("Media has unexpected type: \(type(of: media))") - return - } - stillImageView.image = image - }, - cacheKey: cacheKey) + }, + cacheKey: attachment.id + ) } unloadBlock = { AssertIsOnMainThread() @@ -280,15 +240,11 @@ public class MediaView: UIView { } } - private func configureForVideo(attachmentStream: TSAttachmentStream) { - guard let cacheKey = attachmentStream.uniqueId else { - owsFailDebug("Attachment stream missing unique ID.") - return - } + private func configureForVideo(attachment: Attachment) { let stillImageView = UIImageView() // We need to specify a contentMode since the size of the image // might not match the aspect ratio of the view. - stillImageView.contentMode = .scaleAspectFill + stillImageView.contentMode = MediaView.contentMode // Use trilinear filters for better scaling quality at // some performance cost. stillImageView.layer.minificationFilter = .trilinear @@ -314,29 +270,31 @@ public class MediaView: UIView { owsFailDebug("Unexpectedly already loaded.") return } - self?.tryToLoadMedia(loadMediaBlock: { () -> AnyObject? in - guard attachmentStream.isValidVideo else { - Logger.warn("Ignoring invalid attachment.") - return nil - } - return attachmentStream.thumbnailImageMedium(success: { (image) in + self?.tryToLoadMedia( + loadMediaBlock: { applyMediaBlock in + guard attachment.isValid else { + Logger.warn("Ignoring invalid attachment.") + return + } + + attachment.thumbnail( + size: .medium, + success: { image, _ in applyMediaBlock(image) }, + failure: { Logger.error("Could not load thumbnail") } + ) + }, + applyMediaBlock: { media in AssertIsOnMainThread() + guard let image: UIImage = media as? UIImage else { + owsFailDebug("Media has unexpected type: \(type(of: media))") + return + } + stillImageView.image = image - }, failure: { - Logger.error("Could not load thumbnail") - }) - }, - applyMediaBlock: { (media) in - AssertIsOnMainThread() - - guard let image = media as? UIImage else { - owsFailDebug("Media has unexpected type: \(type(of: media))") - return - } - stillImageView.image = image - }, - cacheKey: cacheKey) + }, + cacheKey: attachment.id + ) } unloadBlock = { AssertIsOnMainThread() @@ -345,101 +303,89 @@ public class MediaView: UIView { } } - private var isFailedDownload: Bool { - guard let attachmentPointer = attachment as? TSAttachmentPointer else { - return false - } - return attachmentPointer.state == .failed - } - private func configure(forError error: MediaError) { - backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05) let icon: UIImage + switch error { - case .failed: - guard let asset = UIImage(named: "media_retry") else { - owsFailDebug("Missing image") - return - } - icon = asset - case .invalid: - guard let asset = UIImage(named: "media_invalid") else { - owsFailDebug("Missing image") - return - } - icon = asset - case .missing: - return + case .failed: + guard let asset = UIImage(named: "media_retry") else { + owsFailDebug("Missing image") + return + } + icon = asset + + case .invalid: + guard let asset = UIImage(named: "media_invalid") else { + owsFailDebug("Missing image") + return + } + icon = asset + + case .missing: return } + + backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05) + let iconView = UIImageView(image: icon.withRenderingMode(.alwaysTemplate)) iconView.tintColor = Colors.text.withAlphaComponent(Values.mediumOpacity) addSubview(iconView) iconView.autoCenterInSuperview() } - private func tryToLoadMedia(loadMediaBlock: @escaping () -> AnyObject?, - applyMediaBlock: @escaping (AnyObject) -> Void, - cacheKey: String) { - AssertIsOnMainThread() - + private func tryToLoadMedia( + loadMediaBlock: @escaping (@escaping (AnyObject?) -> Void) -> Void, + applyMediaBlock: @escaping (AnyObject) -> Void, + cacheKey: String + ) { // It's critical that we update loadState once // our load attempt is complete. - let loadCompletion: (AnyObject?) -> Void = { [weak self] (possibleMedia) in - AssertIsOnMainThread() - - guard let strongSelf = self else { - return - } - guard strongSelf.loadState == .loading else { + let loadCompletion: (AnyObject?) -> Void = { [weak self] possibleMedia in + guard self?.loadState.wrappedValue == .loading else { Logger.verbose("Skipping obsolete load.") return } - guard let media = possibleMedia else { - strongSelf.loadState = .failed + guard let media: AnyObject = possibleMedia else { + self?.loadState.mutate { $0 = .failed } // TODO: // [self showAttachmentErrorViewWithMediaView:mediaView]; return } - + applyMediaBlock(media) - - strongSelf.loadState = .loaded + + self?.mediaCache.setObject(media, forKey: cacheKey as NSString) + self?.loadState.mutate { $0 = .loaded } } - guard loadState == .loading else { + guard loadState.wrappedValue == .loading else { owsFailDebug("Unexpected load state: \(loadState)") return } - let mediaCache = self.mediaCache - if let media = mediaCache.object(forKey: cacheKey as NSString) { + if let media: AnyObject = self.mediaCache.object(forKey: cacheKey as NSString) { Logger.verbose("media cache hit") + + guard !Thread.isMainThread else { + DispatchQueue.main.async { + loadMediaBlock(loadCompletion) + } + return + } + loadCompletion(media) return } Logger.verbose("media cache miss") - let threadSafeLoadState = self.threadSafeLoadState - MediaView.loadQueue.async { - guard threadSafeLoadState.get() == .loading else { + MediaView.loadQueue.async { [weak self] in + guard self?.loadState.wrappedValue == .loading else { Logger.verbose("Skipping obsolete load.") return } - guard let media = loadMediaBlock() else { - Logger.error("Failed to load media.") - - DispatchQueue.main.async { - loadCompletion(nil) - } - return - } - DispatchQueue.main.async { - mediaCache.setObject(media, forKey: cacheKey as NSString) - - loadCompletion(media) + loadMediaBlock(loadCompletion) } } } @@ -459,32 +405,18 @@ public class MediaView: UIView { // "skip rate" of obsolete loads. private static let loadQueue = ReverseDispatchQueue(label: "org.signal.asyncMediaLoadQueue") - @objc public func loadMedia() { - AssertIsOnMainThread() - - switch loadState { - case .unloaded: - loadState = .loading - - guard let loadBlock = loadBlock else { - return - } - loadBlock() - case .loading, .loaded, .failed: - break + switch loadState.wrappedValue { + case .unloaded: + loadState.mutate { $0 = .loading } + loadBlock?() + + case .loading, .loaded, .failed: break } } - @objc public func unloadMedia() { - AssertIsOnMainThread() - - loadState = .unloaded - - guard let unloadBlock = unloadBlock else { - return - } - unloadBlock() + loadState.mutate { $0 = .unloaded } + unloadBlock?() } } diff --git a/Session/Conversations/Message Cells/Content Views/QuoteView.swift b/Session/Conversations/Message Cells/Content Views/QuoteView.swift index bfaf22592..4338ebfa8 100644 --- a/Session/Conversations/Message Cells/Content Views/QuoteView.swift +++ b/Session/Conversations/Message Cells/Content Views/QuoteView.swift @@ -135,7 +135,7 @@ final class QuoteView: UIView { attachment.thumbnail( size: .small, - success: { image in + success: { image, _ in DispatchQueue.main.async { imageView.image = image imageView.contentMode = .scaleAspectFill diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index f1933ddef..7ea44dc82 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -1,10 +1,19 @@ -import SessionUtilitiesKit +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { +import UIKit +import SignalUtilitiesKit +import SessionUtilitiesKit +import SessionMessagingKit + +final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDelegate { private var unloadContent: (() -> Void)? private var previousX: CGFloat = 0 + var albumView: MediaAlbumView? var bodyTextView: UITextView? + var voiceMessageView: VoiceMessageView? + var audioStateChanged: ((TimeInterval, Bool) -> ())? + // Constraints private lazy var headerViewTopConstraint = headerView.pin(.top, to: .top, of: self, withInset: 1) private lazy var authorLabelHeightConstraint = authorLabel.set(.height, to: 0) @@ -29,59 +38,37 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { var lastSearchedText: String? { delegate?.lastSearchedText } - private var positionInCluster: Position? { - guard let viewItem = viewItem else { return nil } - if viewItem.isFirstInCluster { return .top } - if viewItem.isLastInCluster { return .bottom } - return .middle - } + // MARK: - UI Components - private var isOnlyMessageInCluster: Bool { viewItem?.isFirstInCluster == true && viewItem?.isLastInCluster == true } - - private var direction: Direction { - guard let message = viewItem?.interaction as? TSMessage else { preconditionFailure() } - switch message { - case is TSIncomingMessage: return .incoming - case is TSOutgoingMessage: return .outgoing - default: preconditionFailure() - } - } - - private var shouldInsetHeader: Bool { - guard let viewItem = viewItem else { preconditionFailure() } - return (positionInCluster == .top || isOnlyMessageInCluster) && !viewItem.wasPreviousItemInfoMessage - } - - // MARK: UI Components private lazy var profilePictureView: ProfilePictureView = { - let result = ProfilePictureView() - let size = Values.verySmallProfilePictureSize - result.set(.height, to: size) - result.size = size + let result: ProfilePictureView = ProfilePictureView() + result.set(.height, to: Values.verySmallProfilePictureSize) + result.size = Values.verySmallProfilePictureSize + return result }() - + private lazy var moderatorIconImageView = UIImageView(image: #imageLiteral(resourceName: "Crown")) - + lazy var bubbleView: UIView = { let result = UIView() result.layer.cornerRadius = VisibleMessageCell.largeCornerRadius result.set(.width, greaterThanOrEqualTo: VisibleMessageCell.largeCornerRadius * 2) return result }() - + private let bubbleViewMaskLayer = CAShapeLayer() - + private lazy var headerView = UIView() - + private lazy var authorLabel: UILabel = { let result = UILabel() result.font = .boldSystemFont(ofSize: Values.smallFontSize) return result }() - + private lazy var snContentView = UIView() - + internal lazy var messageStatusImageView: UIImageView = { let result = UIImageView() result.contentMode = .scaleAspectFit @@ -89,7 +76,7 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { result.layer.masksToBounds = true return result }() - + private lazy var replyButton: UIView = { let result = UIView() let size = VisibleMessageCell.replyButtonSize + 8 @@ -102,7 +89,7 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { result.alpha = 0 return result }() - + private lazy var replyIconImageView: UIImageView = { let result = UIImageView() let size = VisibleMessageCell.replyButtonSize @@ -111,10 +98,11 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { result.image = UIImage(named: "ic_reply")!.withTint(Colors.text) return result }() + + private lazy var timerView: OWSMessageTimerView = OWSMessageTimerView() + + // MARK: - Settings - private lazy var timerView = OWSMessageTimerView() - - // MARK: Settings private static let messageStatusImageViewSize: CGFloat = 16 private static let authorLabelBottomSpacing: CGFloat = 4 private static let groupThreadHSpacing: CGFloat = 12 @@ -126,57 +114,56 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { static let smallCornerRadius: CGFloat = 4 static let largeCornerRadius: CGFloat = 18 static let contactThreadHSpacing = Values.mediumSpacing - + static var gutterSize: CGFloat { groupThreadHSpacing + profilePictureSize + groupThreadHSpacing } - - private var bodyLabelTextColor: UIColor { - switch (direction, AppModeManager.shared.currentAppMode) { - case (.outgoing, .dark), (.incoming, .light): return .black - case (.outgoing, .light): return Colors.grey - default: return .white - } - } - - override class var identifier: String { "VisibleMessageCell" } - + // MARK: Direction & Position - enum Direction { case incoming, outgoing } - enum Position { case top, middle, bottom } - // MARK: Lifecycle + enum Direction { case incoming, outgoing } + + // MARK: - Lifecycle + override func setUpViewHierarchy() { super.setUpViewHierarchy() + // Header view addSubview(headerView) headerViewTopConstraint.isActive = true headerView.pin([ UIView.HorizontalEdge.left, UIView.HorizontalEdge.right ], to: self) + // Author label addSubview(authorLabel) authorLabelHeightConstraint.isActive = true authorLabel.pin(.top, to: .bottom, of: headerView) + // Profile picture view addSubview(profilePictureView) profilePictureViewLeftConstraint.isActive = true profilePictureViewWidthConstraint.isActive = true profilePictureView.pin(.bottom, to: .bottom, of: self, withInset: -1) + // Moderator icon image view moderatorIconImageView.set(.width, to: 20) moderatorIconImageView.set(.height, to: 20) addSubview(moderatorIconImageView) moderatorIconImageView.pin(.trailing, to: .trailing, of: profilePictureView, withInset: 1) moderatorIconImageView.pin(.bottom, to: .bottom, of: profilePictureView, withInset: 4.5) + // Bubble view addSubview(bubbleView) bubbleViewLeftConstraint1.isActive = true bubbleViewTopConstraint.isActive = true bubbleViewRightConstraint1.isActive = true + // Timer view addSubview(timerView) timerView.center(.vertical, in: bubbleView) timerViewOutgoingMessageConstraint.isActive = true + // Content view bubbleView.addSubview(snContentView) snContentView.pin(to: bubbleView) + // Message status image view addSubview(messageStatusImageView) messageStatusImageViewTopConstraint.isActive = true @@ -184,286 +171,425 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { messageStatusImageView.pin(.bottom, to: .bottom, of: self, withInset: -1) messageStatusImageViewWidthConstraint.isActive = true messageStatusImageViewHeightConstraint.isActive = true + // Reply button addSubview(replyButton) replyButton.addSubview(replyIconImageView) replyIconImageView.center(in: replyButton) replyButton.pin(.left, to: .right, of: bubbleView, withInset: Values.smallSpacing) replyButton.center(.vertical, in: bubbleView) + // Remaining constraints authorLabel.pin(.left, to: .left, of: bubbleView, withInset: VisibleMessageCell.authorLabelInset) } - + override func setUpGestureRecognizers() { let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress)) addGestureRecognizer(longPressRecognizer) + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap)) tapGestureRecognizer.numberOfTapsRequired = 1 addGestureRecognizer(tapGestureRecognizer) + let doubleTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap)) doubleTapGestureRecognizer.numberOfTapsRequired = 2 addGestureRecognizer(doubleTapGestureRecognizer) tapGestureRecognizer.require(toFail: doubleTapGestureRecognizer) } + + // MARK: - Updating - // MARK: Updating - override func update() { - guard let viewItem = viewItem, let message = viewItem.interaction as? TSMessage else { return } - let isGroupThread = viewItem.isGroupThread + override func update( + with item: ConversationViewModel.Item, + mediaCache: NSCache, + playbackInfo: ConversationViewModel.PlaybackInfo?, + lastSearchText: String? + ) { + self.item = item + + let isGroupThread: Bool = (item.threadVariant == .openGroup || item.threadVariant == .closedGroup) + let shouldInsetHeader: Bool = ( + item.previousInteractionVariant?.isInfoMessage != true && + ( + item.positionInCluster == .top || + item.isOnlyMessageInCluster + ) + ) + // Profile picture view - profilePictureViewLeftConstraint.constant = isGroupThread ? VisibleMessageCell.groupThreadHSpacing : 0 - profilePictureViewWidthConstraint.constant = isGroupThread ? VisibleMessageCell.profilePictureSize : 0 - let senderSessionID = (message as? TSIncomingMessage)?.authorId - profilePictureView.isHidden = !VisibleMessageCell.shouldShowProfilePicture(for: viewItem) - if let senderSessionID = senderSessionID { - profilePictureView.update(for: senderSessionID) - } - if let senderSessionID = senderSessionID, message.isOpenGroupMessage { - if let openGroupV2 = Storage.shared.getV2OpenGroup(for: message.uniqueThreadId) { - let isUserModerator = OpenGroupAPIV2.isUserModerator(senderSessionID, for: openGroupV2.room, on: openGroupV2.server) - moderatorIconImageView.isHidden = !isUserModerator || profilePictureView.isHidden - } else { - moderatorIconImageView.isHidden = true - } - } else { - moderatorIconImageView.isHidden = true - } + profilePictureViewLeftConstraint.constant = (isGroupThread ? VisibleMessageCell.groupThreadHSpacing : 0) + profilePictureViewWidthConstraint.constant = (isGroupThread ? VisibleMessageCell.profilePictureSize : 0) + profilePictureView.isHidden = (!item.shouldShowProfile || item.profile == nil) + profilePictureView.update( + publicKey: item.authorId, + profile: item.profile, + threadVariant: item.threadVariant + ) + moderatorIconImageView.isHidden = !item.isSenderOpenGroupModerator + // Bubble view - bubbleViewLeftConstraint1.isActive = (direction == .incoming) - bubbleViewLeftConstraint1.constant = isGroupThread ? VisibleMessageCell.groupThreadHSpacing : VisibleMessageCell.contactThreadHSpacing - bubbleViewLeftConstraint2.isActive = (direction == .outgoing) - bubbleViewTopConstraint.constant = (viewItem.senderName == nil) ? 0 : VisibleMessageCell.authorLabelBottomSpacing - bubbleViewRightConstraint1.isActive = (direction == .outgoing) - bubbleViewRightConstraint2.isActive = (direction == .incoming) - bubbleView.backgroundColor = (direction == .incoming) ? Colors.receivedMessageBackground : Colors.sentMessageBackground + bubbleViewLeftConstraint1.isActive = ( + item.interactionVariant == .standardIncoming || + item.interactionVariant == .standardIncomingDeleted + ) + bubbleViewLeftConstraint1.constant = (isGroupThread ? VisibleMessageCell.groupThreadHSpacing : VisibleMessageCell.contactThreadHSpacing) + bubbleViewLeftConstraint2.isActive = (item.interactionVariant == .standardOutgoing) + bubbleViewTopConstraint.constant = (item.senderName == nil ? 0 : VisibleMessageCell.authorLabelBottomSpacing) + bubbleViewRightConstraint1.isActive = (item.interactionVariant == .standardOutgoing) + bubbleViewRightConstraint2.isActive = ( + item.interactionVariant == .standardIncoming || + item.interactionVariant == .standardIncomingDeleted + ) + bubbleView.backgroundColor = (( + item.interactionVariant == .standardIncoming || + item.interactionVariant == .standardIncomingDeleted + ) ? Colors.receivedMessageBackground : Colors.sentMessageBackground) updateBubbleViewCorners() + // Content view - populateContentView(for: viewItem, message: message) + populateContentView(for: item, mediaCache: mediaCache, playbackInfo: playbackInfo, lastSearchText: lastSearchText) + // Date break - headerViewTopConstraint.constant = shouldInsetHeader ? Values.mediumSpacing : 1 + headerViewTopConstraint.constant = (shouldInsetHeader ? Values.mediumSpacing : 1) headerView.subviews.forEach { $0.removeFromSuperview() } - if viewItem.shouldShowDate { - populateHeader(for: viewItem) - } + populateHeader(for: item, shouldInsetHeader: shouldInsetHeader) + // Author label authorLabel.textColor = Colors.text - authorLabel.isHidden = (viewItem.senderName == nil) - authorLabel.text = viewItem.senderName?.string // Will only be set if it should be shown - let authorLabelAvailableWidth = VisibleMessageCell.getMaxWidth(for: viewItem) - 2 * VisibleMessageCell.authorLabelInset + authorLabel.isHidden = (item.senderName == nil) + authorLabel.text = item.senderName + + let authorLabelAvailableWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: item) - 2 * VisibleMessageCell.authorLabelInset) let authorLabelAvailableSpace = CGSize(width: authorLabelAvailableWidth, height: .greatestFiniteMagnitude) let authorLabelSize = authorLabel.sizeThatFits(authorLabelAvailableSpace) - authorLabelHeightConstraint.constant = (viewItem.senderName != nil) ? authorLabelSize.height : 0 + authorLabelHeightConstraint.constant = (item.senderName != nil ? authorLabelSize.height : 0) + // Message status image view - let (image, tintColor, backgroundColor) = getMessageStatusImage(for: message) + let (image, tintColor, backgroundColor) = getMessageStatusImage(for: item) messageStatusImageView.image = image messageStatusImageView.tintColor = tintColor messageStatusImageView.backgroundColor = backgroundColor - if let message = message as? TSOutgoingMessage { - messageStatusImageView.isHidden = (message.messageState == .sent && thread?.lastInteraction != message) - } else { - messageStatusImageView.isHidden = true - } - messageStatusImageViewTopConstraint.constant = (messageStatusImageView.isHidden) ? 0 : 5 - [ messageStatusImageViewWidthConstraint, messageStatusImageViewHeightConstraint ].forEach { - $0.constant = (messageStatusImageView.isHidden) ? 0 : VisibleMessageCell.messageStatusImageViewSize - } + messageStatusImageView.isHidden = ( + item.interactionVariant != .standardOutgoing || ( + item.state == .sent && + item.isLastInteraction + ) + ) + messageStatusImageViewTopConstraint.constant = (messageStatusImageView.isHidden ? 0 : 5) + [ messageStatusImageViewWidthConstraint, messageStatusImageViewHeightConstraint ] + .forEach { + $0.constant = (messageStatusImageView.isHidden ? 0 : VisibleMessageCell.messageStatusImageViewSize) + } + // Timer - if viewItem.isExpiringMessage { - let expirationTimestamp = message.expiresAt - let expiresInSeconds = message.expiresInSeconds - timerView.configure(withExpirationTimestamp: expirationTimestamp, initialDurationSeconds: expiresInSeconds, tintColor: Colors.text) + if + item.isExpiringMessage, + let expiresStartedAtMs: Double = item.expiresStartedAtMs, + let expiresInSeconds: TimeInterval = item.expiresInSeconds + { + let expirationTimestampMs: Double = (expiresStartedAtMs + (expiresInSeconds * 1000)) + + timerView.configure( + withExpirationTimestamp: UInt64(floor(expirationTimestampMs)), + initialDurationSeconds: UInt32(floor(expiresInSeconds)), + tintColor: Colors.text + ) } - timerView.isHidden = !viewItem.isExpiringMessage - timerViewOutgoingMessageConstraint.isActive = (direction == .outgoing) - timerViewIncomingMessageConstraint.isActive = (direction == .incoming) + + timerView.isHidden = !item.isExpiringMessage + timerViewOutgoingMessageConstraint.isActive = (item.interactionVariant == .standardOutgoing) + timerViewIncomingMessageConstraint.isActive = ( + item.interactionVariant == .standardIncoming || + item.interactionVariant == .standardIncomingDeleted + ) + // Swipe to reply - if (message.isDeleted) { + if item.interactionVariant == .standardIncomingDeleted { removeGestureRecognizer(panGestureRecognizer) - } else { + } + else { addGestureRecognizer(panGestureRecognizer) } } - - private func populateHeader(for viewItem: ConversationViewItem) { - guard viewItem.shouldShowDate else { return } - let dateBreakLabel = UILabel() + + private func populateHeader(for item: ConversationViewModel.Item, shouldInsetHeader: Bool) { + guard let date: Date = item.dateForUI else { return } + + let dateBreakLabel: UILabel = UILabel() dateBreakLabel.font = .boldSystemFont(ofSize: Values.verySmallFontSize) dateBreakLabel.textColor = Colors.text dateBreakLabel.textAlignment = .center - let date = viewItem.interaction.dateForUI() - let description = DateUtil.formatDate(forDisplay: date) + + let description: String = DateUtil.formatDate(forDisplay: date) dateBreakLabel.text = description headerView.addSubview(dateBreakLabel) dateBreakLabel.pin(.top, to: .top, of: headerView, withInset: Values.smallSpacing) - let additionalBottomInset = shouldInsetHeader ? Values.mediumSpacing : 1 + + let additionalBottomInset = (shouldInsetHeader ? Values.mediumSpacing : 1) headerView.pin(.bottom, to: .bottom, of: dateBreakLabel, withInset: Values.smallSpacing + additionalBottomInset) dateBreakLabel.center(.horizontal, in: headerView) - let availableWidth = VisibleMessageCell.getMaxWidth(for: viewItem) + + let availableWidth = VisibleMessageCell.getMaxWidth(for: item) let availableSpace = CGSize(width: availableWidth, height: .greatestFiniteMagnitude) let dateBreakLabelSize = dateBreakLabel.sizeThatFits(availableSpace) dateBreakLabel.set(.height, to: dateBreakLabelSize.height) } - - private func populateContentView(for viewItem: ConversationViewItem, message: TSMessage) { + + private func populateContentView( + for item: ConversationViewModel.Item, + mediaCache: NSCache, + playbackInfo: ConversationViewModel.PlaybackInfo?, + lastSearchText: String? + ) { + let bodyLabelTextColor: UIColor = { + let direction: Direction = (item.interactionVariant == .standardOutgoing ? + .outgoing : + .incoming + ) + + switch (direction, AppModeManager.shared.currentAppMode) { + case (.outgoing, .dark), (.incoming, .light): return .black + case (.outgoing, .light): return Colors.grey + default: return .white + } + }() + snContentView.subviews.forEach { $0.removeFromSuperview() } - func showMediaPlaceholder() { - let mediaPlaceholderView = MediaPlaceholderView(viewItem: viewItem, textColor: bodyLabelTextColor) - snContentView.addSubview(mediaPlaceholderView) - mediaPlaceholderView.pin(to: snContentView) - } albumView = nil bodyTextView = nil - let isOutgoing = (viewItem.interaction.interactionType() == .outgoingMessage) - switch viewItem.messageCellType { - case .textOnlyMessage: - let inset: CGFloat = 12 - let maxWidth = VisibleMessageCell.getMaxWidth(for: viewItem) - 2 * inset - if let linkPreview = viewItem.linkPreview { - let linkPreviewView = LinkPreviewView(for: viewItem, maxWidth: maxWidth, delegate: self) - linkPreviewView.linkPreviewState = LinkPreviewSent(linkPreview: linkPreview, imageAttachment: viewItem.linkPreviewAttachment) - snContentView.addSubview(linkPreviewView) - linkPreviewView.pin(to: snContentView) - linkPreviewView.layer.mask = bubbleViewMaskLayer - self.bodyTextView = linkPreviewView.bodyTextView - } else if let openGroupInvitationName = message.openGroupInvitationName, let openGroupInvitationURL = message.openGroupInvitationURL { - let openGroupInvitationView = OpenGroupInvitationView(name: openGroupInvitationName, url: openGroupInvitationURL, textColor: bodyLabelTextColor, isOutgoing: isOutgoing) - snContentView.addSubview(openGroupInvitationView) - openGroupInvitationView.pin(to: snContentView) - openGroupInvitationView.layer.mask = bubbleViewMaskLayer - } else { - // Stack view - let stackView = UIStackView(arrangedSubviews: []) - stackView.axis = .vertical - stackView.spacing = 2 - // Quote view - if viewItem.quotedReply != nil { - let direction: QuoteView.Direction = isOutgoing ? .outgoing : .incoming - let hInset: CGFloat = 2 - let quoteView = QuoteView(for: viewItem, in: thread, direction: direction, hInset: hInset, maxWidth: maxWidth) - let quoteViewContainer = UIView(wrapping: quoteView, withInsets: UIEdgeInsets(top: 0, leading: hInset, bottom: 0, trailing: hInset)) - stackView.addArrangedSubview(quoteViewContainer) + + // Handle the deleted state first (it's much simpler than the others) + guard item.interactionVariant != .standardIncomingDeleted else { + let deletedMessageView: DeletedMessageView = DeletedMessageView(textColor: bodyLabelTextColor) + snContentView.addSubview(deletedMessageView) + deletedMessageView.pin(to: snContentView) + return + } + + // If it's an incoming media message and the thread isn't trusted then show the placeholder view + if item.cellType != .textOnlyMessage && item.interactionVariant == .standardIncoming && !item.isThreadTrusted { + let mediaPlaceholderView = MediaPlaceholderView(item: item, textColor: bodyLabelTextColor) + snContentView.addSubview(mediaPlaceholderView) + mediaPlaceholderView.pin(to: snContentView) + return + } + + switch item.cellType { + case .typingIndicator: break + + case .textOnlyMessage: + let inset: CGFloat = 12 + let maxWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: item) - 2 * inset) + + if let linkPreview: LinkPreview = item.linkPreview { + switch linkPreview.variant { + case .standard: + let linkPreviewView: LinkPreviewView = LinkPreviewView(maxWidth: maxWidth) + linkPreviewView.update( + with: LinkPreviewSent( + linkPreview: linkPreview, + imageAttachment: item.attachments?.first + ), + isOutgoing: (item.interactionVariant == .standardOutgoing), + delegate: self, + item: item, + bodyLabelTextColor: bodyLabelTextColor, + lastSearchText: lastSearchText + ) + snContentView.addSubview(linkPreviewView) + linkPreviewView.pin(to: snContentView) + linkPreviewView.layer.mask = bubbleViewMaskLayer + self.bodyTextView = linkPreviewView.bodyTextView + + case .openGroupInvitation: + let openGroupInvitationView: OpenGroupInvitationView = OpenGroupInvitationView( + name: (linkPreview.title ?? ""), + url: linkPreview.url, + textColor: bodyLabelTextColor, + isOutgoing: (item.interactionVariant == .standardOutgoing) + ) + + snContentView.addSubview(openGroupInvitationView) + openGroupInvitationView.pin(to: snContentView) + openGroupInvitationView.layer.mask = bubbleViewMaskLayer + } } - // Body text view - let bodyTextView = VisibleMessageCell.getBodyTextView(for: viewItem, with: maxWidth, textColor: bodyLabelTextColor, searchText: delegate?.lastSearchedText, delegate: self) - self.bodyTextView = bodyTextView - stackView.addArrangedSubview(bodyTextView) - // Constraints - snContentView.addSubview(stackView) - stackView.pin(to: snContentView, withInset: inset) - } - case .mediaMessage: - if - viewItem.interaction is TSIncomingMessage, - let thread = thread as? TSContactThread, - let contact: Contact? = GRDBStorage.shared.read({ db in try Contact.fetchOne(db, id: thread.contactSessionID()) }), - contact?.isTrusted != true { - showMediaPlaceholder() - } - else { - guard let cache = delegate?.getMediaCache() else { preconditionFailure() } + else { + // Stack view + let stackView = UIStackView(arrangedSubviews: []) + stackView.axis = .vertical + stackView.spacing = 2 + + // Quote view + if let quote: Quote = item.quote { + let hInset: CGFloat = 2 + let quoteView: QuoteView = QuoteView( + for: .regular, + authorId: quote.authorId, + quotedText: quote.body, + threadVariant: item.threadVariant, + direction: (item.interactionVariant == .standardOutgoing ? + .outgoing : + .incoming + ), + attachment: item.attachments?.first, + hInset: hInset, + maxWidth: maxWidth + ) + let quoteViewContainer = UIView(wrapping: quoteView, withInsets: UIEdgeInsets(top: 0, leading: hInset, bottom: 0, trailing: hInset)) + stackView.addArrangedSubview(quoteViewContainer) + } + + // Body text view + let bodyTextView = VisibleMessageCell.getBodyTextView(for: item, with: maxWidth, textColor: bodyLabelTextColor, searchText: lastSearchText, delegate: self) + self.bodyTextView = bodyTextView + stackView.addArrangedSubview(bodyTextView) + + // Constraints + snContentView.addSubview(stackView) + stackView.pin(to: snContentView, withInset: inset) + } + + case .mediaMessage: // Stack view let stackView = UIStackView(arrangedSubviews: []) stackView.axis = .vertical stackView.spacing = Values.smallSpacing + // Album view - let maxMessageWidth = VisibleMessageCell.getMaxWidth(for: viewItem) - let albumView = MediaAlbumView(mediaCache: cache, items: viewItem.mediaAlbumItems!, isOutgoing: isOutgoing, maxMessageWidth: maxMessageWidth) + let maxMessageWidth: CGFloat = VisibleMessageCell.getMaxWidth(for: item) + let albumView = MediaAlbumView( + mediaCache: mediaCache, + items: (item.attachments? + .filter { $0.isVisualMedia }) + .defaulting(to: []), + isOutgoing: (item.interactionVariant == .standardOutgoing), + maxMessageWidth: maxMessageWidth + ) self.albumView = albumView - let size = getSize(for: viewItem) + let size = getSize(for: item) albumView.set(.width, to: size.width) albumView.set(.height, to: size.height) albumView.loadMedia() albumView.layer.mask = bubbleViewMaskLayer stackView.addArrangedSubview(albumView) + // Body text view - if let message = viewItem.interaction as? TSMessage, let body = message.body, body.count > 0 { + if let body: String = item.body, !body.isEmpty { let inset: CGFloat = 12 let maxWidth = size.width - 2 * inset - let bodyTextView = VisibleMessageCell.getBodyTextView(for: viewItem, with: maxWidth, textColor: bodyLabelTextColor, searchText: delegate?.lastSearchedText, delegate: self) + let bodyTextView = VisibleMessageCell.getBodyTextView(for: item, with: maxWidth, textColor: bodyLabelTextColor, searchText: lastSearchText, delegate: self) self.bodyTextView = bodyTextView stackView.addArrangedSubview(UIView(wrapping: bodyTextView, withInsets: UIEdgeInsets(top: 0, left: inset, bottom: inset, right: inset))) } unloadContent = { albumView.unloadMedia() } + // Constraints snContentView.addSubview(stackView) stackView.pin(to: snContentView) - } - case .audio: - if - viewItem.interaction is TSIncomingMessage, - let thread = thread as? TSContactThread, - let contact: Contact? = GRDBStorage.shared.read({ db in try Contact.fetchOne(db, id: thread.contactSessionID()) }), - contact?.isTrusted != true { - showMediaPlaceholder() - } - else { - let voiceMessageView = VoiceMessageView(viewItem: viewItem) + + case .audio: + guard let attachment: Attachment = item.attachments?.first(where: { $0.isAudio }) else { return } + + let voiceMessageView: VoiceMessageView = VoiceMessageView() + voiceMessageView.update( + with: attachment, + isPlaying: (playbackInfo?.state == .playing), + progress: (playbackInfo?.progress ?? 0), + playbackRate: (playbackInfo?.playbackRate ?? 1), + oldPlaybackRate: (playbackInfo?.oldPlaybackRate ?? 1) + ) + snContentView.addSubview(voiceMessageView) voiceMessageView.pin(to: snContentView) voiceMessageView.layer.mask = bubbleViewMaskLayer - viewItem.lastAudioMessageView = voiceMessageView - } - case .genericAttachment: - if - viewItem.interaction is TSIncomingMessage, - let thread = thread as? TSContactThread, - let contact: Contact? = GRDBStorage.shared.read({ db in try Contact.fetchOne(db, id: thread.contactSessionID()) }), - contact?.isTrusted != true { - showMediaPlaceholder() - } - else { + self.voiceMessageView = voiceMessageView + + case .genericAttachment: + guard let attachment: Attachment = item.attachments?.first else { preconditionFailure() } + let inset: CGFloat = 12 - let maxWidth = VisibleMessageCell.getMaxWidth(for: viewItem) - 2 * inset + let maxWidth = (VisibleMessageCell.getMaxWidth(for: item) - 2 * inset) + // Stack view let stackView = UIStackView(arrangedSubviews: []) stackView.axis = .vertical stackView.spacing = Values.smallSpacing + // Document view - let documentView = DocumentView(viewItem: viewItem, textColor: bodyLabelTextColor) + let documentView = DocumentView(attachment: attachment, textColor: bodyLabelTextColor) stackView.addArrangedSubview(documentView) + // Body text view - if let message = viewItem.interaction as? TSMessage, let body = message.body, body.count > 0, - let delegate = delegate { // delegate should always be set at this point - let bodyTextView = VisibleMessageCell.getBodyTextView(for: viewItem, with: maxWidth, textColor: bodyLabelTextColor, searchText: delegate.lastSearchedText, delegate: self) + if let body: String = item.body, !body.isEmpty { // delegate should always be set at this point + let bodyTextView = VisibleMessageCell.getBodyTextView(for: item, with: maxWidth, textColor: bodyLabelTextColor, searchText: lastSearchText, delegate: self) self.bodyTextView = bodyTextView stackView.addArrangedSubview(bodyTextView) } + // Constraints snContentView.addSubview(stackView) stackView.pin(to: snContentView, withInset: inset) - } - case .deletedMessage: - let deletedMessageView = DeletedMessageView(viewItem: viewItem, textColor: bodyLabelTextColor) - snContentView.addSubview(deletedMessageView) - deletedMessageView.pin(to: snContentView) - default: return } } - + override func layoutSubviews() { super.layoutSubviews() updateBubbleViewCorners() } - + private func updateBubbleViewCorners() { - let cornersToRound = getCornersToRound() - let maskPath = UIBezierPath(roundedRect: bubbleView.bounds, byRoundingCorners: cornersToRound, - cornerRadii: CGSize(width: VisibleMessageCell.largeCornerRadius, height: VisibleMessageCell.largeCornerRadius)) + let cornersToRound: UIRectCorner = getCornersToRound() + let maskPath: UIBezierPath = UIBezierPath( + roundedRect: bubbleView.bounds, + byRoundingCorners: cornersToRound, + cornerRadii: CGSize( + width: VisibleMessageCell.largeCornerRadius, + height: VisibleMessageCell.largeCornerRadius + ) + ) + bubbleViewMaskLayer.path = maskPath.cgPath bubbleView.layer.cornerRadius = VisibleMessageCell.largeCornerRadius bubbleView.layer.maskedCorners = getCornerMask(from: cornersToRound) } + override func dynamicUpdate(with item: ConversationViewModel.Item, playbackInfo: ConversationViewModel.PlaybackInfo?) { + guard item.interactionVariant != .standardIncomingDeleted else { return } + + // If it's an incoming media message and the thread isn't trusted then show the placeholder view + if item.cellType != .textOnlyMessage && item.interactionVariant == .standardIncoming && !item.isThreadTrusted { + return + } + + switch item.cellType { + case .audio: + guard let attachment: Attachment = item.attachments?.first(where: { $0.isAudio }) else { return } + + self.voiceMessageView?.update( + with: attachment, + isPlaying: (playbackInfo?.state == .playing), + progress: (playbackInfo?.progress ?? 0), + playbackRate: (playbackInfo?.playbackRate ?? 1), + oldPlaybackRate: (playbackInfo?.oldPlaybackRate ?? 1) + ) + + default: break + } + } + override func prepareForReuse() { super.prepareForReuse() + unloadContent?() let viewsToMove = [ bubbleView, profilePictureView, replyButton, timerView, messageStatusImageView ] viewsToMove.forEach { $0.transform = .identity } replyButton.alpha = 0 timerView.prepareForReuse() } + + // MARK: - Interaction - // MARK: Interaction override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if let bodyTextView = bodyTextView { let pointInBodyTextViewCoordinates = convert(point, to: bodyTextView) @@ -473,99 +599,120 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { } return super.hitTest(point, with: event) } - + override func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { return true // Needed for the pan gesture recognizer to work with the table view's pan gesture recognizer } - + override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { if gestureRecognizer == panGestureRecognizer { let v = panGestureRecognizer.velocity(in: self) // Only allow swipes to the left; allowing swipes to the right gets in the way of the default // iOS swipe to go back gesture guard v.x < 0 else { return false } - return abs(v.x) > abs(v.y) // It has to be more horizontal than vertical - } else { - return true + return abs(v.x) > abs(v.y) // It has to be more horizontal than vertical } + + return true } - + func highlight() { - let shawdowColour = isLightMode ? UIColor.black.cgColor : Colors.accent.cgColor - let opacity : Float = isLightMode ? 0.5 : 1 + // FIXME: This will have issues with themes + let shawdowColour = (isLightMode ? UIColor.black.cgColor : Colors.accent.cgColor) + let opacity: Float = (isLightMode ? 0.5 : 1) bubbleView.setShadow(radius: 10, opacity: opacity, offset: .zero, color: shawdowColour) + DispatchQueue.main.async { UIView.animate(withDuration: 1.6) { self.bubbleView.setShadow(radius: 0, opacity: 0, offset: .zero, color: UIColor.clear.cgColor) } } } - + @objc func handleLongPress() { - guard let viewItem = viewItem else { return } - delegate?.handleViewItemLongPressed(viewItem) + guard let item: ConversationViewModel.Item = self.item else { return } + + delegate?.handleItemLongPressed(item) } @objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) { - guard let viewItem = viewItem else { return } + guard let item: ConversationViewModel.Item = self.item else { return } + let location = gestureRecognizer.location(in: self) - if profilePictureView.frame.contains(location) && VisibleMessageCell.shouldShowProfilePicture(for: viewItem) { - guard let message = viewItem.interaction as? TSIncomingMessage else { return } - guard !message.isOpenGroupMessage else { return } // Do not show user details to prevent spam - delegate?.showUserDetails(for: message.authorId) - } else if replyButton.frame.contains(location) { + + if profilePictureView.frame.contains(location), let profile: Profile = item.profile, item.threadVariant != .openGroup { + delegate?.showUserDetails(for: profile) + } + else if replyButton.alpha > 0 && replyButton.frame.contains(location) { UIImpactFeedbackGenerator(style: .heavy).impactOccurred() reply() - } else { - delegate?.handleViewItemTapped(viewItem, gestureRecognizer: gestureRecognizer) + } + else if bubbleView.frame.contains(location) { + delegate?.handleItemTapped(item, gestureRecognizer: gestureRecognizer) } } @objc private func handleDoubleTap() { - guard let viewItem = viewItem else { return } - delegate?.handleViewItemDoubleTapped(viewItem) + guard let item: ConversationViewModel.Item = self.item else { return } + + delegate?.handleItemDoubleTapped(item) } - + @objc private func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) { - guard let viewItem = viewItem else { return } - let viewsToMove = [ bubbleView, profilePictureView, replyButton, timerView, messageStatusImageView ] + guard let item: ConversationViewModel.Item = self.item else { return } + + let viewsToMove: [UIView] = [ + bubbleView, profilePictureView, replyButton, timerView, messageStatusImageView + ] let translationX = gestureRecognizer.translation(in: self).x.clamp(-CGFloat.greatestFiniteMagnitude, 0) + switch gestureRecognizer.state { - case .began: - delegate?.handleViewItemSwiped(viewItem, state: .began) - case .changed: - // The idea here is to asymptotically approach a maximum drag distance - let damping: CGFloat = 20 - let sign: CGFloat = -1 - let x = (damping * (sqrt(abs(translationX)) / sqrt(damping))) * sign - viewsToMove.forEach { $0.transform = CGAffineTransform(translationX: x, y: 0) } - if timerView.isHidden { - replyButton.alpha = abs(translationX) / VisibleMessageCell.maxBubbleTranslationX - } else { - replyButton.alpha = 0 // Always hide the reply button if the timer view is showing, otherwise they can overlap - } - if abs(translationX) > VisibleMessageCell.swipeToReplyThreshold && abs(previousX) < VisibleMessageCell.swipeToReplyThreshold { - UIImpactFeedbackGenerator(style: .heavy).impactOccurred() // Let the user know when they've hit the swipe to reply threshold - } - previousX = translationX - case .ended, .cancelled: - if abs(translationX) > VisibleMessageCell.swipeToReplyThreshold { - delegate?.handleViewItemSwiped(viewItem, state: .ended) - reply() - } else { - delegate?.handleViewItemSwiped(viewItem, state: .cancelled) - resetReply() - } - default: break + case .began: delegate?.handleItemSwiped(item, state: .began) + + case .changed: + // The idea here is to asymptotically approach a maximum drag distance + let damping: CGFloat = 20 + let sign: CGFloat = -1 + let x = (damping * (sqrt(abs(translationX)) / sqrt(damping))) * sign + viewsToMove.forEach { $0.transform = CGAffineTransform(translationX: x, y: 0) } + if timerView.isHidden { + replyButton.alpha = abs(translationX) / VisibleMessageCell.maxBubbleTranslationX + } else { + replyButton.alpha = 0 // Always hide the reply button if the timer view is showing, otherwise they can overlap + } + if abs(translationX) > VisibleMessageCell.swipeToReplyThreshold && abs(previousX) < VisibleMessageCell.swipeToReplyThreshold { + UIImpactFeedbackGenerator(style: .heavy).impactOccurred() // Let the user know when they've hit the swipe to reply threshold + } + previousX = translationX + + case .ended, .cancelled: + if abs(translationX) > VisibleMessageCell.swipeToReplyThreshold { + delegate?.handleItemSwiped(item, state: .ended) + reply() + } + else { + delegate?.handleItemSwiped(item, state: .cancelled) + resetReply() + } + + default: break } } - - func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { - delegate?.openURL(URL) + + func textView(_ textView: UITextView, shouldInteractWith url: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { + delegate?.openUrl(url.absoluteString) return false } + func textViewDidChangeSelection(_ textView: UITextView) { + // Note: We can't just set 'isSelectable' to false otherwise the link detection/selection + // stops working (do a null check to avoid an infinite loop on older iOS versions) + if textView.selectedTextRange != nil { + textView.selectedTextRange = nil + } + } + private func resetReply() { let viewsToMove = [ bubbleView, profilePictureView, replyButton, timerView, messageStatusImageView ] UIView.animate(withDuration: 0.25) { @@ -573,47 +720,46 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { self.replyButton.alpha = 0 } } - + private func reply() { - guard let viewItem = viewItem else { return } + guard let item: ConversationViewModel.Item = self.item else { return } + resetReply() - delegate?.handleReplyButtonTapped(for: viewItem) + delegate?.handleReplyButtonTapped(for: item) } - func handleLinkPreviewCanceled() { - // Not relevant in this case - } + // MARK: - Convenience - // MARK: Convenience private func getCornersToRound() -> UIRectCorner { - guard !isOnlyMessageInCluster else { return .allCorners } - let result: UIRectCorner - switch (positionInCluster, direction) { - case (.top, .outgoing): result = [ .bottomLeft, .topLeft, .topRight ] - case (.middle, .outgoing): result = [ .bottomLeft, .topLeft ] - case (.bottom, .outgoing): result = [ .bottomRight, .bottomLeft, .topLeft ] - case (.top, .incoming): result = [ .topLeft, .topRight, .bottomRight ] - case (.middle, .incoming): result = [ .topRight, .bottomRight ] - case (.bottom, .incoming): result = [ .topRight, .bottomRight, .bottomLeft ] - case (nil, _): result = .allCorners + guard item?.isOnlyMessageInCluster == false else { return .allCorners } + + let direction: Direction = (item?.interactionVariant == .standardOutgoing ? .outgoing : .incoming) + + switch (item?.positionInCluster, direction) { + case (.top, .outgoing): return [ .bottomLeft, .topLeft, .topRight ] + case (.middle, .outgoing): return [ .bottomLeft, .topLeft ] + case (.bottom, .outgoing): return [ .bottomRight, .bottomLeft, .topLeft ] + case (.top, .incoming): return [ .topLeft, .topRight, .bottomRight ] + case (.middle, .incoming): return [ .topRight, .bottomRight ] + case (.bottom, .incoming): return [ .topRight, .bottomRight, .bottomLeft ] + case (.none, _): return .allCorners } - return result } private func getCornerMask(from rectCorner: UIRectCorner) -> CACornerMask { - var cornerMask = CACornerMask() - if rectCorner.contains(.allCorners) { - cornerMask = [ .layerMaxXMinYCorner, .layerMinXMinYCorner, .layerMaxXMaxYCorner, .layerMinXMaxYCorner] - } else { - if rectCorner.contains(.topRight) { cornerMask.insert(.layerMaxXMinYCorner) } - if rectCorner.contains(.topLeft) { cornerMask.insert(.layerMinXMinYCorner) } - if rectCorner.contains(.bottomRight) { cornerMask.insert(.layerMaxXMaxYCorner) } - if rectCorner.contains(.bottomLeft) { cornerMask.insert(.layerMinXMaxYCorner) } + guard !rectCorner.contains(.allCorners) else { + return [ .layerMaxXMinYCorner, .layerMinXMinYCorner, .layerMaxXMaxYCorner, .layerMinXMaxYCorner] } + + var cornerMask = CACornerMask() + if rectCorner.contains(.topRight) { cornerMask.insert(.layerMaxXMinYCorner) } + if rectCorner.contains(.topLeft) { cornerMask.insert(.layerMinXMinYCorner) } + if rectCorner.contains(.bottomRight) { cornerMask.insert(.layerMaxXMaxYCorner) } + if rectCorner.contains(.bottomLeft) { cornerMask.insert(.layerMinXMaxYCorner) } return cornerMask } - - private static func getFontSize(for viewItem: ConversationViewItem) -> CGFloat { + + private static func getFontSize(for item: ConversationViewModel.Item) -> CGFloat { let baselineFontSize = Values.mediumFontSize switch viewItem.displayableBodyText?.jumbomojiCount { case 1: return baselineFontSize + 30 @@ -622,104 +768,125 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { default: return baselineFontSize } } - - private func getMessageStatusImage(for message: TSMessage) -> (image: UIImage?, tintColor: UIColor?, backgroundColor: UIColor?) { - guard let message = message as? TSOutgoingMessage else { return (nil, nil, nil) } - + + private func getMessageStatusImage(for item: ConversationViewModel.Item) -> (image: UIImage?, tintColor: UIColor?, backgroundColor: UIColor?) { + guard item.interactionVariant == .standardOutgoing else { return (nil, nil, nil) } + let image: UIImage var tintColor: UIColor? = nil var backgroundColor: UIColor? = nil - let status = MessageRecipientStatusUtils.recipientStatus(outgoingMessage: message) - switch status { - case .uploading, .sending: + switch (item.state, item.hasAtLeastOneReadReceipt) { + case (.sending, _): image = #imageLiteral(resourceName: "CircleDotDotDot").withRenderingMode(.alwaysTemplate) tintColor = Colors.text - - case .sent, .skipped, .delivered: + + case (.sent, false), (.skipped, _): image = #imageLiteral(resourceName: "CircleCheck").withRenderingMode(.alwaysTemplate) tintColor = Colors.text - case .read: + case (.sent, true): image = isLightMode ? #imageLiteral(resourceName: "FilledCircleCheckLightMode") : #imageLiteral(resourceName: "FilledCircleCheckDarkMode") backgroundColor = isLightMode ? .black : .white - case .failed: + case (.failed, _): image = #imageLiteral(resourceName: "message_status_failed").withRenderingMode(.alwaysTemplate) tintColor = Colors.destructive } - + return (image, tintColor, backgroundColor) } - - private func getSize(for viewItem: ConversationViewItem) -> CGSize { - guard let albumItems = viewItem.mediaAlbumItems else { preconditionFailure() } - let maxMessageWidth = VisibleMessageCell.getMaxWidth(for: viewItem) - let defaultSize = MediaAlbumView.layoutSize(forMaxMessageWidth: maxMessageWidth, items: albumItems) - guard albumItems.count == 1 else { return defaultSize } + + private func getSize(for item: ConversationViewModel.Item) -> CGSize { + guard let mediaAttachments: [Attachment] = item.attachments?.filter({ $0.isVisualMedia }) else { preconditionFailure() } + + let maxMessageWidth = VisibleMessageCell.getMaxWidth(for: item) + let defaultSize = MediaAlbumView.layoutSize(forMaxMessageWidth: maxMessageWidth, items: mediaAttachments) + + guard + let firstAttachment: Attachment = mediaAttachments.first, + var width: CGFloat = firstAttachment.width.map({ CGFloat($0) }), + var height: CGFloat = firstAttachment.height.map({ CGFloat($0) }), + mediaAttachments.count == 1, + width > 0, + height > 0 + else { return defaultSize } + // Honor the content aspect ratio for single media - let albumItem = albumItems.first! - let size = albumItem.mediaSize - guard size.width > 0 && size.height > 0 else { return defaultSize } + let size: CGSize = CGSize(width: width, height: height) var aspectRatio = (size.width / size.height) // Clamp the aspect ratio so that very thin/wide content still looks alright let minAspectRatio: CGFloat = 0.35 let maxAspectRatio = 1 / minAspectRatio - aspectRatio = aspectRatio.clamp(minAspectRatio, maxAspectRatio) let maxSize = CGSize(width: maxMessageWidth, height: maxMessageWidth) - var width: CGFloat - var height: CGFloat + aspectRatio = aspectRatio.clamp(minAspectRatio, maxAspectRatio) + if aspectRatio > 1 { width = maxSize.width height = width / aspectRatio - } else { + } + else { height = maxSize.height width = height * aspectRatio } + // Don't blow up small images unnecessarily let minSize: CGFloat = 150 let shortSourceDimension = min(size.width, size.height) let shortDestinationDimension = min(width, height) + if shortDestinationDimension > minSize && shortDestinationDimension > shortSourceDimension { let factor = minSize / shortDestinationDimension width *= factor; height *= factor } + return CGSize(width: width, height: height) } - static func getMaxWidth(for viewItem: ConversationViewItem) -> CGFloat { - let screen = UIScreen.main.bounds - switch viewItem.interaction.interactionType() { - case .outgoingMessage: return screen.width - contactThreadHSpacing - gutterSize - case .incomingMessage: - let isGroupThread = viewItem.isGroupThread - let leftGutterSize = isGroupThread ? gutterSize : contactThreadHSpacing - return screen.width - leftGutterSize - gutterSize - default: preconditionFailure() + static func getMaxWidth(for item: ConversationViewModel.Item) -> CGFloat { + let screen: CGRect = UIScreen.main.bounds + + switch item.interactionVariant { + case .standardOutgoing: return (screen.width - contactThreadHSpacing - gutterSize) + case .standardIncoming, .standardIncomingDeleted: + let isGroupThread = (item.threadVariant == .openGroup || item.threadVariant == .closedGroup) + let leftGutterSize = (isGroupThread ? gutterSize : contactThreadHSpacing) + + return (screen.width - leftGutterSize - gutterSize) + + default: preconditionFailure() } } - private static func shouldShowProfilePicture(for viewItem: ConversationViewItem) -> Bool { - guard let message = viewItem.interaction as? TSMessage else { preconditionFailure() } - let isGroupThread = viewItem.isGroupThread - let senderSessionID = (message as? TSIncomingMessage)?.authorId - return isGroupThread && viewItem.shouldShowSenderProfilePicture && senderSessionID != nil - } - - static func getBodyTextView(for viewItem: ConversationViewItem, with availableWidth: CGFloat, textColor: UIColor, searchText: String?, delegate: UITextViewDelegate & BodyTextViewDelegate) -> UITextView { + static func getBodyTextView( + for item: ConversationViewModel.Item, + with availableWidth: CGFloat, + textColor: UIColor, + searchText: String?, + delegate: (UITextViewDelegate & BodyTextViewDelegate)? + ) -> UITextView { // Take care of: // • Highlighting mentions // • Linkification // • Highlighting search results - guard let message = viewItem.interaction as? TSMessage else { preconditionFailure() } - let isOutgoing = (message.interactionType() == .outgoingMessage) - let result = BodyTextView(snDelegate: delegate) + // + // Note: We can't just set 'isSelectable' to false otherwise the link detection/selection + // stops working + let isOutgoing: Bool = (item.interactionVariant == .standardOutgoing) + let result: BodyTextView = BodyTextView(snDelegate: delegate) result.isEditable = false - let attributes: [NSAttributedString.Key:Any] = [ - .foregroundColor : textColor, - .font : UIFont.systemFont(ofSize: getFontSize(for: viewItem)) - ] - let attributedText = NSMutableAttributedString(attributedString: MentionUtilities.highlightMentions(in: message.body ?? "", isOutgoingMessage: isOutgoing, threadID: viewItem.interaction.uniqueThreadId, attributes: attributes)) + + let attributedText: NSMutableAttributedString = NSMutableAttributedString( + attributedString: MentionUtilities.highlightMentions( + in: (item.body ?? ""), + threadVariant: item.threadVariant, + isOutgoingMessage: isOutgoing, + attributes: [ + .foregroundColor : textColor, + .font : UIFont.systemFont(ofSize: getFontSize(for: item)) + ] + ) + ) if let searchText = searchText, searchText.count >= ConversationSearchController.kMinimumSearchTextLength { let normalizedSearchText = FullTextSearchFinder.normalize(text: searchText) do { @@ -734,6 +901,7 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { // Do nothing } } + result.attributedText = attributedText result.dataDetectorTypes = .link result.backgroundColor = .clear @@ -744,10 +912,15 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { result.isScrollEnabled = false result.isUserInteractionEnabled = true result.delegate = delegate - result.linkTextAttributes = [ .foregroundColor : textColor, .underlineStyle : NSUnderlineStyle.single.rawValue ] + result.linkTextAttributes = [ + .foregroundColor: textColor, + .underlineStyle: NSUnderlineStyle.single.rawValue + ] + let availableSpace = CGSize(width: availableWidth, height: .greatestFiniteMagnitude) let size = result.sizeThatFits(availableSpace) result.set(.height, to: size.height) + return result } } diff --git a/Session/Conversations/Settings/OWSConversationSettingsViewController.m b/Session/Conversations/Settings/OWSConversationSettingsViewController.m index d4d21a049..079bf4979 100644 --- a/Session/Conversations/Settings/OWSConversationSettingsViewController.m +++ b/Session/Conversations/Settings/OWSConversationSettingsViewController.m @@ -767,7 +767,7 @@ CGFloat kIconViewLength = 24; - (void)leaveGroup { if (self.isClosedGroup) { - [[SNMessageSender leaveClosedGroupWithPublicKey:self.threadId] retainUntilComplete]; + [[SMKMessageSender leaveClosedGroupWithPublicKey:self.threadId] retainUntilComplete]; } [self.navigationController popViewControllerAnimated:YES]; @@ -818,7 +818,7 @@ CGFloat kIconViewLength = 24; // If we successfully blocked then force a config sync if (isBlocked) { - [SNMessageSender forceSyncConfigurationNow]; + [SMKMessageSender forceSyncConfigurationNow]; } [weakSelf updateTableContents]; @@ -837,7 +837,7 @@ CGFloat kIconViewLength = 24; // If we successfully unblocked then force a config sync if (!isBlocked) { - [SNMessageSender forceSyncConfigurationNow]; + [SMKMessageSender forceSyncConfigurationNow]; } [weakSelf updateTableContents]; @@ -900,12 +900,8 @@ CGFloat kIconViewLength = 24; { OWSLogDebug(@""); - MediaGallery *mediaGallery = [[MediaGallery alloc] initWithSliderEnabledForThreadId:self.threadId isClosedGroup: self.isClosedGroup isOpenGroup: self.isOpenGroup]; - - self.mediaGallery = mediaGallery; - OWSAssertDebug([self.navigationController isKindOfClass:[OWSNavigationController class]]); - [mediaGallery pushTileViewFromNavController:(OWSNavigationController *)self.navigationController]; + [SNMediaGallery pushTileViewWithSliderEnabledForThreadId:self.threadId isClosedGroup:self.isClosedGroup isOpenGroup:self.isOpenGroup fromNavController:(OWSNavigationController *)self.navigationController]; } - (void)tappedConversationSearch diff --git a/Session/Media Viewing & Editing/ImagePickerController.swift b/Session/Media Viewing & Editing/ImagePickerController.swift index 36f514d2a..45d316bc1 100644 --- a/Session/Media Viewing & Editing/ImagePickerController.swift +++ b/Session/Media Viewing & Editing/ImagePickerController.swift @@ -54,7 +54,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat return } - collectionView.register(PhotoGridViewCell.self, forCellWithReuseIdentifier: PhotoGridViewCell.reuseIdentifier) + collectionView.register(view: PhotoGridViewCell.self) // ensure images at the end of the list can be scrolled above the bottom buttons let bottomButtonInset = -1 * SendMediaNavigationController.bottomButtonsCenterOffset + SendMediaNavigationController.bottomButtonWidth / 2 + 16 @@ -543,11 +543,9 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat return UICollectionViewCell(forAutoLayout: ()) } - guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PhotoGridViewCell.reuseIdentifier, for: indexPath) as? PhotoGridViewCell else { - owsFail("cell was unexpectedly nil") - } - + let cell: PhotoGridViewCell = collectionView.dequeue(type: PhotoGridViewCell.self, for: indexPath) cell.loadingColor = UIColor(white: 0.2, alpha: 1) + let assetItem = photoCollectionContents.assetItem(at: indexPath.item, photoMediaSize: photoMediaSize) cell.configure(item: assetItem) diff --git a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift index 0a8ab0dac..bbd855ed3 100644 --- a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift +++ b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift @@ -1,3 +1,58 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB +import DifferenceKit +import SignalUtilitiesKit + +public class MediaGalleryViewModel { + public let threadId: String + public let threadVariant: SessionThread.Variant + private let item: ConversationViewModel.Item? + + // MARK: - Initialization + + init( + threadId: String, + threadVariant: SessionThread.Variant, + item: ConversationViewModel.Item? = nil + ) { + self.threadId = threadId + self.threadVariant = threadVariant + self.item = item + } + } + + public static func createTileViewController(threadId: String, isClosedGroup: Bool, isOpenGroup: Bool) -> MediaTileViewController { + return MediaTileViewController( + viewModel: MediaGalleryViewModel( + threadId: threadId, + threadVariant: { + if isClosedGroup { return .closedGroup } + if isOpenGroup { return .openGroup } + + return .contact + }() + ) + ) + } +} + +// MARK: - Objective-C Support + +// FIXME: Remove when we can + +@objc(SNMediaGallery) +public class SNMediaGallery: NSObject { + @objc(pushTileViewWithSliderEnabledForThreadId:isClosedGroup:isOpenGroup:fromNavController:) + static func pushTileView(threadId: String, isClosedGroup: Bool, isOpenGroup: Bool, fromNavController: OWSNavigationController) { + fromNavController.pushViewController( + MediaGalleryViewModel.createTileViewController( + threadId: threadId, + isClosedGroup: isClosedGroup, + isOpenGroup: isOpenGroup + ), + animated: true + ) + } +} diff --git a/Session/Media Viewing & Editing/MediaTileViewController.swift b/Session/Media Viewing & Editing/MediaTileViewController.swift index 6958a2c34..6585813e2 100644 --- a/Session/Media Viewing & Editing/MediaTileViewController.swift +++ b/Session/Media Viewing & Editing/MediaTileViewController.swift @@ -1,13 +1,13 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -import Foundation -import SessionUIKit import UIKit +import GRDB +import DifferenceKit +import SessionUIKit +import SignalUtilitiesKit -public protocol MediaTileViewControllerDelegate: class { - func mediaTileViewController(_ viewController: MediaTileViewController, didTapView tappedView: UIView, mediaGalleryItem: MediaGalleryItem) +public protocol MediaTileViewControllerDelegate: AnyObject { + func mediaTileViewController(_ viewController: MediaTileViewController, didTapView tappedView: UIView, mediaGalleryItem: MediaGalleryViewModel.Item) } public class MediaTileViewController: UICollectionViewController, MediaGalleryDataSourceDelegate, UICollectionViewDelegateFlowLayout { diff --git a/Session/Media Viewing & Editing/PhotoGridViewCell.swift b/Session/Media Viewing & Editing/PhotoGridViewCell.swift index 8d8ca2ad9..aa79acea2 100644 --- a/Session/Media Viewing & Editing/PhotoGridViewCell.swift +++ b/Session/Media Viewing & Editing/PhotoGridViewCell.swift @@ -16,9 +16,6 @@ public protocol PhotoGridItem: AnyObject { } public class PhotoGridViewCell: UICollectionViewCell { - - static let reuseIdentifier = "PhotoGridViewCell" - public let imageView: UIImageView private let contentTypeBadgeView: UIImageView @@ -128,7 +125,9 @@ public class PhotoGridViewCell: UICollectionViewCell { Logger.debug("image == nil") } - self?.image = image + DispatchQueue.main.async { + self?.image = image + } } switch item.type { diff --git a/Session/Media Viewing & Editing/Transitions/MediaDismissAnimationController.swift b/Session/Media Viewing & Editing/Transitions/MediaDismissAnimationController.swift new file mode 100644 index 000000000..c4416648e --- /dev/null +++ b/Session/Media Viewing & Editing/Transitions/MediaDismissAnimationController.swift @@ -0,0 +1,234 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import PromiseKit + +class MediaDismissAnimationController: NSObject { + private let mediaItem: Media + public let interactionController: MediaInteractiveDismiss? + + var fromView: UIView? + var transitionView: UIView? + var fromTransitionalOverlayView: UIView? + var toTransitionalOverlayView: UIView? + var fromMediaFrame: CGRect? + var pendingCompletion: (() -> ())? + + init(galleryItem: MediaGalleryViewModel.Item, interactionController: MediaInteractiveDismiss? = nil) { + self.mediaItem = .gallery(galleryItem) + self.interactionController = interactionController + } + + init(image: UIImage, interactionController: MediaInteractiveDismiss? = nil) { + self.mediaItem = .image(image) + self.interactionController = interactionController + } +} + +extension MediaDismissAnimationController: UIViewControllerAnimatedTransitioning { + func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { + return 0.3 + } + + func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + let containerView = transitionContext.containerView + let fromContextProvider: MediaPresentationContextProvider + let toContextProvider: MediaPresentationContextProvider + + guard let fromVC: UIViewController = transitionContext.viewController(forKey: .from) else { + transitionContext.completeTransition(false) + return + } + guard let toVC: UIViewController = transitionContext.viewController(forKey: .to) else { + transitionContext.completeTransition(false) + return + } + + switch fromVC { + case let contextProvider as MediaPresentationContextProvider: + fromContextProvider = contextProvider + + case let navController as UINavigationController: + guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else { + transitionContext.completeTransition(false) + return + } + + fromContextProvider = contextProvider + + default: + transitionContext.completeTransition(false) + return + } + + switch toVC { + case let contextProvider as MediaPresentationContextProvider: + toContextProvider = contextProvider + + case let navController as UINavigationController: + guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else { + transitionContext.completeTransition(false) + return + } + + toContextProvider = contextProvider + + default: + transitionContext.completeTransition(false) + return + } + + guard let fromMediaContext: MediaPresentationContext = fromContextProvider.mediaPresentationContext(mediaItem: mediaItem, in: containerView) else { + transitionContext.completeTransition(false) + return + } + + guard let presentationImage: UIImage = mediaItem.image else { + transitionContext.completeTransition(true) + return + } + + // fromView will be nil if doing a presentation, in which case we don't want to add the view - + // it will automatically be added to the view hierarchy, in front of the VC we're presenting from + if let fromView: UIView = transitionContext.view(forKey: .from) { + self.fromView = fromView + containerView.addSubview(fromView) + } + + // toView will be nil if doing a modal dismiss, in which case we don't want to add the view - + // it's already in the view hierarchy, behind the VC we're dismissing. + if let toView: UIView = transitionContext.view(forKey: .to) { + containerView.insertSubview(toView, at: 0) + } + + let toMediaContext: MediaPresentationContext? = toContextProvider.mediaPresentationContext(mediaItem: mediaItem, in: containerView) + let duration: CGFloat = transitionDuration(using: transitionContext) + + fromMediaContext.mediaView.alpha = 0.0 + toMediaContext?.mediaView.alpha = 0.0 + + let transitionView = UIImageView(image: presentationImage) + transitionView.frame = fromMediaContext.presentationFrame + transitionView.contentMode = MediaView.contentMode + transitionView.layer.masksToBounds = true + transitionView.layer.cornerRadius = fromMediaContext.cornerRadius + transitionView.layer.maskedCorners = (toMediaContext?.cornerMask ?? fromMediaContext.cornerMask) + containerView.addSubview(transitionView) + + // Add any UI elements which should appear above the media view + self.fromTransitionalOverlayView = { + guard let (overlayView, overlayViewFrame) = fromContextProvider.snapshotOverlayView(in: containerView) else { + return nil + } + + overlayView.frame = overlayViewFrame + containerView.addSubview(overlayView) + + return overlayView + }() + self.toTransitionalOverlayView = { [weak self] in + guard let (overlayView, overlayViewFrame) = toContextProvider.snapshotOverlayView(in: containerView) else { + return nil + } + + // Only fade in the 'toTransitionalOverlayView' if it's bigger than the origin + // one (makes it look cleaner as you don't get the crossfade effect) + if (self?.fromTransitionalOverlayView?.frame.size.height ?? 0) > overlayViewFrame.height { + overlayView.alpha = 0 + } + + overlayView.frame = overlayViewFrame + + if let fromTransitionalOverlayView = self?.fromTransitionalOverlayView { + containerView.insertSubview(overlayView, belowSubview: fromTransitionalOverlayView) + } + else { + containerView.addSubview(overlayView) + } + + return overlayView + }() + + self.transitionView = transitionView + self.fromMediaFrame = transitionView.frame + + self.pendingCompletion = { + let destinationFromAlpha: CGFloat + let destinationFrame: CGRect + let destinationCornerRadius: CGFloat + + if transitionContext.transitionWasCancelled { + destinationFromAlpha = 1 + destinationFrame = fromMediaContext.presentationFrame + destinationCornerRadius = fromMediaContext.cornerRadius + } + else if let toMediaContext: MediaPresentationContext = toMediaContext { + destinationFromAlpha = 0 + destinationFrame = toMediaContext.presentationFrame + destinationCornerRadius = toMediaContext.cornerRadius + } + else { + // `toMediaContext` can be nil if the target item is scrolled off of the + // contextProvider's screen, so we synthesize a context to dismiss the item + // off screen + destinationFromAlpha = 0 + destinationFrame = fromMediaContext.presentationFrame + .offsetBy(dx: 0, dy: (containerView.bounds.height * 2)) + destinationCornerRadius = fromMediaContext.cornerRadius + } + + UIView.animate( + withDuration: duration, + delay: 0, + options: [.beginFromCurrentState, .curveEaseInOut], + animations: { [weak self] in + self?.fromTransitionalOverlayView?.alpha = destinationFromAlpha + self?.fromView?.alpha = destinationFromAlpha + self?.toTransitionalOverlayView?.alpha = (1.0 - destinationFromAlpha) + transitionView.frame = destinationFrame + transitionView.layer.cornerRadius = destinationCornerRadius + }, + completion: { [weak self] _ in + self?.fromView?.alpha = 1 + fromMediaContext.mediaView.alpha = 1 + toMediaContext?.mediaView.alpha = 1 + transitionView.removeFromSuperview() + self?.fromTransitionalOverlayView?.removeFromSuperview() + self?.toTransitionalOverlayView?.removeFromSuperview() + + if transitionContext.transitionWasCancelled { + // the "to" view will be nil if we're doing a modal dismiss, in which case + // we wouldn't want to remove the toView. + transitionContext.view(forKey: .to)?.removeFromSuperview() + } + else { + transitionContext.view(forKey: .from)?.removeFromSuperview() + } + + transitionContext.completeTransition(!transitionContext.transitionWasCancelled) + } + ) + } + + // The interactive transition will call the 'pendingCompletion' when it completes so don't call it here + guard !transitionContext.isInteractive else { return } + + self.pendingCompletion?() + self.pendingCompletion = nil + } +} + +extension MediaDismissAnimationController: InteractiveDismissDelegate { + func interactiveDismissUpdate(_ interactiveDismiss: UIPercentDrivenInteractiveTransition, didChangeTouchOffset offset: CGPoint) { + guard let transitionView: UIView = transitionView else { return } // Transition hasn't started yet + guard let fromMediaFrame: CGRect = fromMediaFrame else { return } + + fromView?.alpha = (1.0 - interactiveDismiss.percentComplete) + transitionView.center = fromMediaFrame.offsetBy(dx: offset.x, dy: offset.y).center + } + + func interactiveDismissDidFinish(_ interactiveDismiss: UIPercentDrivenInteractiveTransition) { + self.pendingCompletion?() + self.pendingCompletion = nil + } +} diff --git a/Session/Media Viewing & Editing/Transitions/MediaInteractiveDismiss.swift b/Session/Media Viewing & Editing/Transitions/MediaInteractiveDismiss.swift new file mode 100644 index 000000000..3445a3b2f --- /dev/null +++ b/Session/Media Viewing & Editing/Transitions/MediaInteractiveDismiss.swift @@ -0,0 +1,108 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit + +// MARK: - InteractivelyDismissableViewController + +protocol InteractivelyDismissableViewController: UIViewController { + func performInteractiveDismissal(animated: Bool) +} + +// MARK: - InteractiveDismissDelegate + +protocol InteractiveDismissDelegate: AnyObject { + func interactiveDismissUpdate(_ interactiveDismiss: UIPercentDrivenInteractiveTransition, didChangeTouchOffset offset: CGPoint) + func interactiveDismissDidFinish(_ interactiveDismiss: UIPercentDrivenInteractiveTransition) +} + +// MARK: - MediaInteractiveDismiss + +class MediaInteractiveDismiss: UIPercentDrivenInteractiveTransition { + var interactionInProgress = false + + weak var interactiveDismissDelegate: InteractiveDismissDelegate? + private weak var targetViewController: InteractivelyDismissableViewController? + + init(targetViewController: InteractivelyDismissableViewController) { + super.init() + + self.targetViewController = targetViewController + } + + public func addGestureRecognizer(to view: UIView) { + let gesture: DirectionalPanGestureRecognizer = DirectionalPanGestureRecognizer(direction: .vertical, target: self, action: #selector(handleGesture(_:))) + + // Allow panning with trackpad + if #available(iOS 13.4, *) { gesture.allowedScrollTypesMask = .continuous } + + view.addGestureRecognizer(gesture) + } + + // MARK: - Private + + private var fastEnoughToCompleteTransition = false + private var farEnoughToCompleteTransition = false + + private var shouldCompleteTransition: Bool { + if farEnoughToCompleteTransition { return true } + if fastEnoughToCompleteTransition { return true } + + return false + } + + @objc private func handleGesture(_ gestureRecognizer: UIScreenEdgePanGestureRecognizer) { + guard let coordinateSpace = gestureRecognizer.view?.superview else { return } + + if case .began = gestureRecognizer.state { + gestureRecognizer.setTranslation(.zero, in: coordinateSpace) + } + + let totalDistance: CGFloat = 100 + let velocityThreshold: CGFloat = 500 + + switch gestureRecognizer.state { + case .began: + interactionInProgress = true + targetViewController?.performInteractiveDismissal(animated: true) + + case .changed: + let velocity = abs(gestureRecognizer.velocity(in: coordinateSpace).y) + if velocity > velocityThreshold { + fastEnoughToCompleteTransition = true + } + + let offset = gestureRecognizer.translation(in: coordinateSpace) + let progress = abs(offset.y) / totalDistance + // `farEnoughToCompleteTransition` is cancelable if the user reverses direction + farEnoughToCompleteTransition = progress >= 0.5 + update(progress) + + interactiveDismissDelegate?.interactiveDismissUpdate(self, didChangeTouchOffset: offset) + + case .cancelled: + interactiveDismissDelegate?.interactiveDismissDidFinish(self) + cancel() + + interactionInProgress = false + farEnoughToCompleteTransition = false + fastEnoughToCompleteTransition = false + + case .ended: + if shouldCompleteTransition { + finish() + } + else { + cancel() + } + + interactiveDismissDelegate?.interactiveDismissDidFinish(self) + + interactionInProgress = false + farEnoughToCompleteTransition = false + fastEnoughToCompleteTransition = false + + default: + break + } + } +} diff --git a/Session/Media Viewing & Editing/Transitions/MediaPresentationContext.swift b/Session/Media Viewing & Editing/Transitions/MediaPresentationContext.swift new file mode 100644 index 000000000..e37ea56c2 --- /dev/null +++ b/Session/Media Viewing & Editing/Transitions/MediaPresentationContext.swift @@ -0,0 +1,50 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +enum Media { + case gallery(MediaGalleryViewModel.Item) + case image(UIImage) + + var image: UIImage? { + switch self { + case let .gallery(item): + guard let originalFilePath: String = item.attachment.originalFilePath else { return nil } + + return UIImage(contentsOfFile: originalFilePath) + + case let .image(image): return image + } + } +} + +struct MediaPresentationContext { + let mediaView: UIView + let presentationFrame: CGRect + let cornerRadius: CGFloat + let cornerMask: CACornerMask +} + +// There are two kinds of AnimationControllers that interact with the media detail view. Both +// appear to transition the media view from one VC to it's corresponding location in the +// destination VC. +// +// MediaPresentationContextProvider is either a target or destination VC which can provide the +// details necessary to facilite this animation. +// +// First, the MediaZoomAnimationController is non-interactive. We use it whenever we're going to +// show the Media detail pager. +// +// We can get there several ways: +// From conversation settings, this can be a push or a pop from the tileView. +// From conversationView/MessageDetails this can be a modal present or a pop from the tile view. +// +// The other animation controller, the MediaDismissAnimationController is used when we're going to +// stop showing the media pager. This can be a pop to the tile view, or a modal dismiss. +protocol MediaPresentationContextProvider { + func mediaPresentationContext(mediaItem: Media, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? + + // The transitionView will be presented below this view. + // If nil, the transitionView will be presented above all + func snapshotOverlayView(in coordinateSpace: UICoordinateSpace) -> (UIView, CGRect)? +} diff --git a/Session/Media Viewing & Editing/Transitions/MediaZoomAnimationController.swift b/Session/Media Viewing & Editing/Transitions/MediaZoomAnimationController.swift new file mode 100644 index 000000000..4f99d1842 --- /dev/null +++ b/Session/Media Viewing & Editing/Transitions/MediaZoomAnimationController.swift @@ -0,0 +1,189 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit + +class MediaZoomAnimationController: NSObject { + private let mediaItem: Media + + init(image: UIImage) { + mediaItem = .image(image) + } + + init(galleryItem: MediaGalleryViewModel.Item) { + mediaItem = .gallery(galleryItem) + } +} + +extension MediaZoomAnimationController: UIViewControllerAnimatedTransitioning { + func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { + return 0.4 + } + + func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + let containerView = transitionContext.containerView + let fromContextProvider: MediaPresentationContextProvider + let toContextProvider: MediaPresentationContextProvider + + guard let fromVC: UIViewController = transitionContext.viewController(forKey: .from) else { + transitionContext.completeTransition(false) + return + } + guard let toVC: UIViewController = transitionContext.viewController(forKey: .to) else { + transitionContext.completeTransition(false) + return + } + + switch fromVC { + case let contextProvider as MediaPresentationContextProvider: + fromContextProvider = contextProvider + + case let navController as UINavigationController: + guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else { + transitionContext.completeTransition(false) + return + } + + fromContextProvider = contextProvider + + default: + transitionContext.completeTransition(false) + return + } + + switch toVC { + case let contextProvider as MediaPresentationContextProvider: + toContextProvider = contextProvider + + case let navController as UINavigationController: + guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else { + transitionContext.completeTransition(false) + return + } + + toContextProvider = contextProvider + + default: + transitionContext.completeTransition(false) + return + } + + // 'view(forKey: .to)' will be nil when using this transition for a modal dismiss, in which + // case we want to use the 'toVC.view' but need to ensure we add it back to it's original + // parent afterwards so we don't break the view hierarchy + // + // Note: We *MUST* call 'layoutIfNeeded' prior to 'toContextProvider.mediaPresentationContext' + // as the 'toContextProvider.mediaPresentationContext' is dependant on it having the correct + // positioning (and the navBar sizing isn't correct until after layout) + let toView: UIView = (transitionContext.view(forKey: .to) ?? toVC.view) + let oldToViewSuperview: UIView? = toView.superview + toView.layoutIfNeeded() + + guard let fromMediaContext: MediaPresentationContext = fromContextProvider.mediaPresentationContext(mediaItem: mediaItem, in: containerView) else { + transitionContext.completeTransition(false) + return + } + + guard let toMediaContext: MediaPresentationContext = toContextProvider.mediaPresentationContext(mediaItem: mediaItem, in: containerView) else { + transitionContext.completeTransition(false) + return + } + + guard let presentationImage: UIImage = mediaItem.image else { + transitionContext.completeTransition(true) + return + } + + let duration: CGFloat = transitionDuration(using: transitionContext) + + fromMediaContext.mediaView.alpha = 0 + toMediaContext.mediaView.alpha = 0 + + let fromSnapshotView: UIView = (fromVC.view.snapshotView(afterScreenUpdates: false) ?? UIView()) + containerView.addSubview(fromSnapshotView) + + toView.frame = containerView.bounds + toView.alpha = 0 + containerView.addSubview(toView) + + let transitionView = UIImageView(image: presentationImage) + transitionView.frame = fromMediaContext.presentationFrame + transitionView.contentMode = MediaView.contentMode + transitionView.layer.masksToBounds = true + transitionView.layer.cornerRadius = fromMediaContext.cornerRadius + transitionView.layer.maskedCorners = fromMediaContext.cornerMask + containerView.addSubview(transitionView) + + let overshootPercentage: CGFloat = 0.15 + let overshootFrame: CGRect = CGRect( + x: (toMediaContext.presentationFrame.minX + ((toMediaContext.presentationFrame.minX - fromMediaContext.presentationFrame.minX) * overshootPercentage)), + y: (toMediaContext.presentationFrame.minY + ((toMediaContext.presentationFrame.minY - fromMediaContext.presentationFrame.minY) * overshootPercentage)), + width: (toMediaContext.presentationFrame.width + ((toMediaContext.presentationFrame.width - fromMediaContext.presentationFrame.width) * overshootPercentage)), + height: (toMediaContext.presentationFrame.height + ((toMediaContext.presentationFrame.height - fromMediaContext.presentationFrame.height) * overshootPercentage)) + ) + + // Add any UI elements which should appear above the media view + let fromTransitionalOverlayView: UIView? = { + guard let (overlayView, overlayViewFrame) = fromContextProvider.snapshotOverlayView(in: containerView) else { + return nil + } + + overlayView.frame = overlayViewFrame + containerView.addSubview(overlayView) + + return overlayView + }() + let toTransitionalOverlayView: UIView? = { + guard let (overlayView, overlayViewFrame) = toContextProvider.snapshotOverlayView(in: containerView) else { + return nil + } + + overlayView.alpha = 0 + overlayView.frame = overlayViewFrame + containerView.addSubview(overlayView) + + return overlayView + }() + + UIView.animate( + withDuration: (duration / 2), + delay: 0, + options: .curveEaseOut, + animations: { + // Only fade out the 'fromTransitionalOverlayView' if it's bigger than the destination + // one (makes it look cleaner as you don't get the crossfade effect) + if (fromTransitionalOverlayView?.frame.size.height ?? 0) > (toTransitionalOverlayView?.frame.size.height ?? 0) { + fromTransitionalOverlayView?.alpha = 0 + } + + toView.alpha = 1 + toTransitionalOverlayView?.alpha = 1 + transitionView.frame = overshootFrame + transitionView.layer.cornerRadius = toMediaContext.cornerRadius + }, + completion: { _ in + UIView.animate( + withDuration: (duration / 2), + delay: 0, + options: .curveEaseInOut, + animations: { + transitionView.frame = toMediaContext.presentationFrame + }, + completion: { _ in + transitionView.removeFromSuperview() + fromSnapshotView.removeFromSuperview() + fromTransitionalOverlayView?.removeFromSuperview() + toTransitionalOverlayView?.removeFromSuperview() + + toMediaContext.mediaView.alpha = 1 + fromMediaContext.mediaView.alpha = 1 + + // Need to ensure we add the 'toView' back to it's old superview if it had one + oldToViewSuperview?.addSubview(toView) + + transitionContext.completeTransition(!transitionContext.transitionWasCancelled) + } + ) + } + ) + } +} diff --git a/Session/Utilities/UINavigationBar+Utilities.swift b/Session/Utilities/UINavigationBar+Utilities.swift new file mode 100644 index 000000000..09ba0a8bf --- /dev/null +++ b/Session/Utilities/UINavigationBar+Utilities.swift @@ -0,0 +1,44 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit + +extension UINavigationBar { + func generateSnapshot(in coordinateSpace: UICoordinateSpace) -> (UIView, CGRect)? { + let scale = UIScreen.main.scale + + guard let navBarSuperview: UIView = superview else { return nil } + + UIGraphicsBeginImageContextWithOptions(layer.frame.size, false, scale) + + guard let context: CGContext = UIGraphicsGetCurrentContext() else { + UIGraphicsEndImageContext() + return nil + } + + layer.render(in: context) + + guard let image: UIImage = UIGraphicsGetImageFromCurrentImageContext() else { + UIGraphicsEndImageContext() + return nil + } + UIGraphicsEndImageContext() + + let snapshotView: UIView = UIView( + frame: CGRect( + x: 0, + y: 0, + width: bounds.width, + height: frame.maxY + ) + ) + snapshotView.backgroundColor = backgroundColor + + let imageView: UIImageView = UIImageView(image: image) + imageView.frame = frame + snapshotView.addSubview(imageView) + + let presentationFrame = coordinateSpace.convert(snapshotView.frame, from: navBarSuperview) + + return (snapshotView, presentationFrame) + } +} diff --git a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift index 5b8ea6b1c..0e03a9035 100644 --- a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift +++ b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift @@ -1110,7 +1110,7 @@ public enum Legacy { internal final class _AttachmentUploadJob: NSObject, NSCoding { internal let attachmentID: String internal let threadID: String - internal let message: Message + internal let message: _Message internal let messageSendJobID: String internal var id: String? internal var failureCount: UInt = 0 @@ -1121,7 +1121,7 @@ public enum Legacy { guard let attachmentID = coder.decodeObject(forKey: "attachmentID") as! String?, let threadID = coder.decodeObject(forKey: "threadID") as! String?, - let message = coder.decodeObject(forKey: "message") as! Message?, + let message = coder.decodeObject(forKey: "message") as! _Message?, let messageSendJobID = coder.decodeObject(forKey: "messageSendJobID") as! String?, let id = coder.decodeObject(forKey: "id") as! String? else { return nil } diff --git a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift index 3d3a7f3cb..b2d4a0f98 100644 --- a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -229,6 +229,9 @@ enum _001_InitialSetupMigration: Migration { t.column(.width, .integer) t.column(.height, .integer) t.column(.duration, .double) + t.column(.isVisualMedia, .boolean) + .notNull() + .defaults(to: false) t.column(.isValid, .boolean) .notNull() .defaults(to: false) diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 04f345630..ea638d918 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -969,7 +969,7 @@ enum _003_YDBToGRDBMigration: Migration { } transaction.enumerateRows(inCollection: Legacy.attachmentUploadJobCollection) { _, object, _, _ in - guard let job = object as? Legacy.AttachmentUploadJob else { return } + guard let job = object as? Legacy._AttachmentUploadJob else { return } attachmentUploadJobs.insert(job) } @@ -1059,7 +1059,7 @@ enum _003_YDBToGRDBMigration: Migration { // MARK: - --messageSend - var messageSendJobIdMap: [String: Int64] = [:] + var messageSendJobLegacyMap: [String: Job] = [:] try autoreleasepool { try messageSendJobs.forEach { legacyJob in @@ -1132,31 +1132,42 @@ enum _003_YDBToGRDBMigration: Migration { )?.inserted(db) if let oldId: String = legacyJob.id, let newId: Int64 = job?.id { - messageSendJobIdMap[oldId] = newId + messageSendJobLegacyMap[oldId] = job } } } // MARK: - --attachmentUpload - + try autoreleasepool { try attachmentUploadJobs.forEach { legacyJob in - guard let sendJobId: Int64 = messageSendJobIdMap[legacyJob.messageSendJobID] else { + guard let sendJob: Job = messageSendJobLegacyMap[legacyJob.messageSendJobID], let sendJobId: Int64 = sendJob.id else { SNLog("[Migration Error] attachmentUpload job missing associated MessageSendJob") throw GRDBStorageError.migrationFailed } - - _ = try Job( + + let uploadJob: Job? = try Job( failureCount: legacyJob.failureCount, variant: .attachmentUpload, behaviour: .runOnce, - nextRunTimestamp: 0, + threadId: legacyJob.threadID, + interactionId: sendJob.interactionId, details: AttachmentUploadJob.Details( - threadId: legacyJob.threadID, - attachmentId: legacyJob.attachmentID, - messageSendJobId: sendJobId + messageSendJobId: sendJobId, + attachmentId: legacyJob.attachmentID ) )?.inserted(db) + + // Add the dependency to the relevant MessageSendJob + guard let uploadJobId: Int64 = uploadJob?.id else { + SNLog("[Migration Error] attachmentUpload job was not created") + throw GRDBStorageError.migrationFailed + } + + try JobDependencies( + jobId: sendJobId, + dependantId: uploadJobId + ).insert(db) } } diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index 444008f2e..6fa2c67d3 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -10,7 +10,7 @@ import AVFoundation public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "attachment" } - public static let interactionAttachments = hasOne(InteractionAttachment.self) + internal static let interactionAttachments = hasOne(InteractionAttachment.self) internal static let quoteForeignKey = ForeignKey([Columns.id], to: [Quote.Columns.attachmentId]) internal static let linkPreviewForeignKey = ForeignKey([Columns.id], to: [LinkPreview.Columns.attachmentId]) public static let interaction = hasOne( @@ -36,6 +36,7 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR case width case height case duration + case isVisualMedia case isValid case encryptionKey case digest @@ -109,6 +110,9 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR /// The number of seconds the attachment plays for (this will only be set for video and audio attachment types) public let duration: TimeInterval? + /// A flag indicating whether the attachment data is visual media + public let isVisualMedia: Bool + /// A flag indicating whether the attachment data downloaded is valid for it's content type public let isValid: Bool @@ -137,6 +141,7 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR width: UInt? = nil, height: UInt? = nil, duration: TimeInterval? = nil, + isVisualMedia: Bool? = nil, isValid: Bool = false, encryptionKey: Data? = nil, digest: Data? = nil, @@ -155,6 +160,11 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR self.width = width self.height = height self.duration = duration + self.isVisualMedia = (isVisualMedia ?? ( + MIMETypeUtil.isImage(contentType) || + MIMETypeUtil.isVideo(contentType) || + MIMETypeUtil.isAnimated(contentType) + )) self.isValid = isValid self.encryptionKey = encryptionKey self.digest = digest @@ -166,9 +176,11 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR id: String = UUID().uuidString, variant: Variant = .standard, contentType: String, - dataSource: DataSource + dataSource: DataSource, + sourceFilename: String? = nil, + caption: String? = nil ) { - guard let originalFilePath: String = Attachment.originalFilePath(id: id, mimeType: contentType, sourceFilename: nil) else { + guard let originalFilePath: String = Attachment.originalFilePath(id: id, mimeType: contentType, sourceFilename: sourceFilename) else { return nil } guard dataSource.write(toPath: originalFilePath) else { return nil } @@ -190,16 +202,21 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR self.contentType = contentType self.byteCount = dataSource.dataLength() self.creationTimestamp = nil - self.sourceFilename = nil + self.sourceFilename = sourceFilename self.downloadUrl = nil self.localRelativeFilePath = URL(fileURLWithPath: originalFilePath).lastPathComponent self.width = imageSize.map { UInt(floor($0.width)) } self.height = imageSize.map { UInt(floor($0.height)) } self.duration = duration + self.isVisualMedia = ( + MIMETypeUtil.isImage(contentType) || + MIMETypeUtil.isVideo(contentType) || + MIMETypeUtil.isAnimated(contentType) + ) self.isValid = isValid self.encryptionKey = nil self.digest = nil - self.caption = nil + self.caption = caption } // MARK: - Custom Database Interaction @@ -309,6 +326,7 @@ public extension Attachment { width: width, height: height, duration: duration, + isVisualMedia: isVisualMedia, isValid: isValid, encryptionKey: (encryptionKey ?? self.encryptionKey), digest: (digest ?? self.digest), @@ -353,6 +371,11 @@ public extension Attachment { self.width = (proto.hasWidth && proto.width > 0 ? UInt(proto.width) : nil) self.height = (proto.hasHeight && proto.height > 0 ? UInt(proto.height) : nil) self.duration = nil // Needs to be downloaded to be set + self.isVisualMedia = ( + MIMETypeUtil.isImage(contentType) || + MIMETypeUtil.isVideo(contentType) || + MIMETypeUtil.isAnimated(contentType) + ) self.isValid = false // Needs to be downloaded to be set self.encryptionKey = proto.key self.digest = proto.digest @@ -702,8 +725,6 @@ extension Attachment { public var isText: Bool { MIMETypeUtil.isText(contentType) } public var isMicrosoftDoc: Bool { MIMETypeUtil.isMicrosoftDoc(contentType) } - public var isVisualMedia: Bool { isImage || isVideo || isAnimated } - public func readDataFromFile() throws -> Data? { guard let filePath: String = self.originalFilePath else { return nil @@ -716,7 +737,7 @@ extension Attachment { return "\(thumbnailsDirPath)/thumbnail-\(dimensions).jpg" } - private func loadThumbnail(with dimensions: UInt, success: @escaping (UIImage) -> (), failure: @escaping () -> ()) { + private func loadThumbnail(with dimensions: UInt, success: @escaping (UIImage, () throws -> Data) -> (), failure: @escaping () -> ()) { guard let width: UInt = self.width, let height: UInt = self.height, width > 1, height > 1 else { failure() return @@ -730,43 +751,113 @@ extension Attachment { return } - success(image) + success( + image, + { + guard let originalFilePath: String = originalFilePath else { throw AttachmentError.invalidData } + + return try Data(contentsOf: URL(fileURLWithPath: originalFilePath)) + } + ) return } let thumbnailPath = thumbnailPath(for: dimensions) if FileManager.default.fileExists(atPath: thumbnailPath) { - guard let image: UIImage = UIImage(contentsOfFile: thumbnailPath) else { + guard + let data: Data = try? Data(contentsOf: URL(fileURLWithPath: thumbnailPath)), + let image: UIImage = UIImage(data: data) + else { failure() return } - success(image) + success(image, { data }) return } OWSThumbnailService.shared.ensureThumbnail( for: self, dimensions: dimensions, - success: { loadedThumbnail in success(loadedThumbnail.image) }, + success: { loadedThumbnail in success(loadedThumbnail.image, loadedThumbnail.dataSourceBlock) }, failure: { _ in failure() } ) } - public func thumbnail(size: ThumbnailSize, success: @escaping (UIImage) -> (), failure: @escaping () -> ()) { + public func thumbnail(size: ThumbnailSize, success: @escaping (UIImage, () throws -> Data) -> (), failure: @escaping () -> ()) { loadThumbnail(with: size.dimension, success: success, failure: failure) } - public func cloneAsThumbnail() -> Attachment { - fatalError("TODO: Add this back") + public func cloneAsThumbnail() -> Attachment? { + let cloneId: String = UUID().uuidString + let thumbnailName: String = "quoted-thumbnail-\(sourceFilename ?? "null")" + + guard + self.isValid, + self.isVisualMedia, + let thumbnailPath: String = Attachment.originalFilePath( + id: cloneId, + mimeType: OWSMimeTypeImageJpeg, + sourceFilename: thumbnailName + ) + else { return nil } + + // Try generate the thumbnail + var thumbnailData: Data? + let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) + + self.thumbnail( + size: .small, + success: { _, dataSourceBlock in + thumbnailData = try? dataSourceBlock() + semaphore.signal() + }, + failure: { semaphore.signal() } + ) + + // Wait up to 0.5 seconds + _ = semaphore.wait(timeout: .now() + .milliseconds(500)) + + guard let thumbnailData: Data = thumbnailData else { return nil } + + // Write the quoted thumbnail to disk + do { try thumbnailData.write(to: URL(fileURLWithPath: thumbnailPath)) } + catch { return nil } + + // Need to retrieve the size of the thumbnail as it maintains it's aspect ratio + let thumbnailSize: CGSize = Attachment + .imageSize( + contentType: OWSMimeTypeImageJpeg, + originalFilePath: thumbnailPath + ) + .defaulting( + to: CGSize( + width: Int(ThumbnailSize.small.dimension), + height: Int(ThumbnailSize.small.dimension) + ) + ) + + // Copy the thumbnail to a new attachment + return Attachment( + id: cloneId, + variant: .standard, + contentType: OWSMimeTypeImageJpeg, + byteCount: UInt(thumbnailData.count), + sourceFilename: thumbnailName, + localRelativeFilePath: thumbnailPath + .substring(from: (Attachment.attachmentsFolder.count + 1)), // Leading forward slash + width: UInt(thumbnailSize.width), + height: UInt(thumbnailSize.height), + isValid: true + ) } public func write(data: Data) throws -> Bool { guard let originalFilePath: String = originalFilePath else { return false } try data.write(to: URL(fileURLWithPath: originalFilePath)) - + return true } } @@ -873,6 +964,10 @@ extension Attachment { .with( serverId: "\(fileId)", state: .uploaded, + creationTimestamp: ( + updatedAttachment?.creationTimestamp ?? + Date().timeIntervalSince1970 + ), downloadUrl: "\(FileServerAPIV2.server)/files/\(fileId)" ) .saved(db) diff --git a/SessionMessagingKit/Database/Models/InteractionAttachment.swift b/SessionMessagingKit/Database/Models/InteractionAttachment.swift index ae75a0631..50f0dab0c 100644 --- a/SessionMessagingKit/Database/Models/InteractionAttachment.swift +++ b/SessionMessagingKit/Database/Models/InteractionAttachment.swift @@ -8,7 +8,7 @@ public struct InteractionAttachment: Codable, FetchableRecord, PersistableRecord public static var databaseTableName: String { "interactionAttachment" } internal static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id]) internal static let attachmentForeignKey = ForeignKey([Columns.attachmentId], to: [Attachment.Columns.id]) - internal static let interaction = belongsTo(Interaction.self, using: interactionForeignKey) + public static let interaction = belongsTo(Interaction.self, using: interactionForeignKey) internal static let attachment = belongsTo(Attachment.self, using: attachmentForeignKey) public typealias Columns = CodingKeys diff --git a/SessionMessagingKit/Database/Models/LinkPreview.swift b/SessionMessagingKit/Database/Models/LinkPreview.swift index 953b9b9bc..76782c689 100644 --- a/SessionMessagingKit/Database/Models/LinkPreview.swift +++ b/SessionMessagingKit/Database/Models/LinkPreview.swift @@ -154,7 +154,7 @@ public extension LinkPreview { return (floor(sentTimestampMs / 1000 / LinkPreview.timstampResolution) * LinkPreview.timstampResolution) } - @discardableResult static func saveAttachmentIfPossible(_ db: Database, imageData: Data?, mimeType: String) throws -> String? { + static func saveAttachmentIfPossible(_ db: Database, imageData: Data?, mimeType: String) throws -> String? { guard let imageData: Data = imageData, !imageData.isEmpty else { return nil } guard let fileExtension: String = MIMETypeUtil.fileExtension(forMIMEType: mimeType) else { return nil } diff --git a/SessionMessagingKit/Database/Models/Quote.swift b/SessionMessagingKit/Database/Models/Quote.swift index 3f1c27f86..0fd8b4b71 100644 --- a/SessionMessagingKit/Database/Models/Quote.swift +++ b/SessionMessagingKit/Database/Models/Quote.swift @@ -106,6 +106,7 @@ public extension Quote { quote.id != 0, !quote.author.isEmpty else { return nil } + self.interactionId = interactionId self.timestampMs = Int64(quote.id) self.authorId = quote.author @@ -128,27 +129,24 @@ public extension Quote { } // We only use the first attachment - if let attachment = proto.attachments.first { - let thumbnailAttachment: Attachment - - // We prefer deriving any thumbnail locally rather than fetching one from the network - if let quotedInteraction: Interaction = quotedInteraction { - if let attachment: Attachment = try? quotedInteraction.attachments.fetchOne(db) { - thumbnailAttachment = attachment.cloneAsThumbnail() + if let attachment = quote.attachments.first(where: { $0.thumbnail != nil })?.thumbnail { + self.attachmentId = try quotedInteraction + .map { quotedInteraction -> Attachment? in + // If the quotedInteraction has an attachment then try clone it + if let attachment: Attachment = try? quotedInteraction.attachments.fetchOne(db) { + return attachment.cloneAsThumbnail() + } + + // Otherwise if the quotedInteraction has a link preview, try clone that + return try? quotedInteraction.linkPreview + .fetchOne(db)? + .attachment + .fetchOne(db)? + .cloneAsThumbnail() } - else if let linkPreviewAttachment: Attachment = try? quotedInteraction.linkPreview.fetchOne(db)?.attachment.fetchOne(db) { - thumbnailAttachment = linkPreviewAttachment.cloneAsThumbnail() - } - else { - thumbnailAttachment = Attachment(proto: attachment) - } - } - else { - thumbnailAttachment = Attachment(proto: attachment) - } - - try thumbnailAttachment.save(db) - self.attachmentId = thumbnailAttachment.id + .defaulting(to: Attachment(proto: attachment)) + .inserted(db) + .id } else { self.attachmentId = nil @@ -158,6 +156,5 @@ public extension Quote { if self.body == nil && self.attachmentId == nil { return nil } - } } diff --git a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift index 0b8cd22fd..72efd9ce4 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift @@ -101,7 +101,7 @@ public enum AttachmentDownloadJob: JobExecutor { state: .downloaded, creationTimestamp: Date().timeIntervalSince1970, localRelativeFilePath: attachment.originalFilePath? - .substring(from: Attachment.attachmentsFolder.count) + .substring(from: (Attachment.attachmentsFolder.count + 1)) // Leading forward slash ) .save(db) } diff --git a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift index 57fc04a9e..827512b06 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift @@ -10,6 +10,7 @@ public enum AttachmentUploadJob: JobExecutor { public static var maxFailureCount: Int = 10 public static var requiresThreadId: Bool = true public static let requiresInteractionId: Bool = true + public static func run( _ job: Job, success: @escaping (Job, Bool) -> (), @@ -51,9 +52,18 @@ public enum AttachmentUploadJob: JobExecutor { extension AttachmentUploadJob { public struct Details: Codable { + /// This is the id for the messageSend job this attachmentUpload job is associated to, the value isn't used for any of + /// the logic but we want to mandate that the attachmentUpload job can only be used alongside a messageSend job + /// + /// **Note:** If we do decide to remove this the `_003_YDBToGRDBMigration` will need to be updated as it + /// fails if this connection can't be made + public let messageSendJobId: Int64 + + /// The id of the `Attachment` to upload public let attachmentId: String - public init(attachmentId: String) { + public init(messageSendJobId: Int64, attachmentId: String) { + self.messageSendJobId = messageSendJobId self.attachmentId = attachmentId } } diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift index c2066c47f..502ce51b1 100644 --- a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift @@ -71,6 +71,7 @@ public enum MessageSendJob: JobExecutor { threadId: job.threadId, interactionId: interactionId, details: AttachmentUploadJob.Details( + messageSendJobId: jobId, attachmentId: stateInfo.attachmentId ) ), diff --git a/SessionMessagingKit/Messages/Signal/TypingIndicatorInteraction.swift b/SessionMessagingKit/Messages/Signal/TypingIndicatorInteraction.swift deleted file mode 100644 index 6bb271d05..000000000 --- a/SessionMessagingKit/Messages/Signal/TypingIndicatorInteraction.swift +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SignalCoreKit - -@objc(OWSTypingIndicatorInteraction) -public class TypingIndicatorInteraction: TSInteraction { - @objc - public static let TypingIndicatorId = "TypingIndicator" - - @objc - public override func isDynamicInteraction() -> Bool { - return true - } - - @objc - public override func interactionType() -> OWSInteractionType { - return .typingIndicator - } - - @available(*, unavailable, message:"use other constructor instead.") - @objc - public required init(coder aDecoder: NSCoder) { - notImplemented() - } - - @available(*, unavailable, message:"use other constructor instead.") - @objc - public required init(dictionary dictionaryValue: [String: Any]!) throws { - notImplemented() - } - - @objc - public let recipientId: String - - @objc - public init(thread: TSThread, timestamp: UInt64, recipientId: String) { - self.recipientId = recipientId - - super.init(interactionWithUniqueId: TypingIndicatorInteraction.TypingIndicatorId, - timestamp: timestamp, in: thread) - } - - @objc - public override func save(with transaction: YapDatabaseReadWriteTransaction) { - owsFailDebug("The transient interaction should not be saved in the database.") - } -} diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift index 87076013a..fa42ccc75 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift @@ -476,15 +476,6 @@ extension MessageSender { return promise } - @objc(leaveClosedGroupWithPublicKey:) - public static func objc_leave(_ groupPublicKey: String) -> AnyPromise { - let promise = GRDBStorage.shared.write { db in - try leave(db, groupPublicKey: groupPublicKey) - } - - return AnyPromise.from(promise) - } - /// Leave the group with the given `groupPublicKey`. If the current user is the admin, the group is disbanded entirely. If the /// user is a regular member they'll be marked as a "zombie" member by the other users in the group (upon receiving the leave /// message). The admin can then truly remove them later. diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index 8bbb9a081..c58632fed 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -11,8 +11,8 @@ extension MessageSender { public static func send(_ db: Database, interaction: Interaction, with attachments: [SignalAttachment], in thread: SessionThread) throws { guard let interactionId: Int64 = interaction.id else { throw GRDBStorageError.objectNotSaved } - // TODO: Is the 'prep' method needed anymore? -// prep(db, attachments, for: message) + + try prep(db, signalAttachments: attachments, for: interactionId) send( db, message: VisibleMessage.from(db, interaction: interaction), @@ -175,12 +175,3 @@ extension MessageSender { return promise } } - -extension MessageSender { - @objc(forceSyncConfigurationNow) - public static func objc_forceSyncConfigurationNow() { - GRDBStorage.shared.write { db in - try syncConfiguration(db, forceSyncNow: true).retainUntilComplete() - } - } -} diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 51a059a4d..271ef3bfb 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -6,63 +6,36 @@ import PromiseKit import SessionSnodeKit import SessionUtilitiesKit -@objc(SNMessageSender) -public final class MessageSender : NSObject { - // MARK: Initialization - private override init() { } - +public final class MessageSender { // MARK: - Preparation public static func prep( _ db: Database, signalAttachments: [SignalAttachment], - for message: VisibleMessage - ) { - guard let tsMessage = TSOutgoingMessage.find(withTimestamp: message.sentTimestamp!) else { - #if DEBUG - preconditionFailure() - #else - return - #endif + for interactionId: Int64 + ) throws { + try signalAttachments.forEach { signalAttachment in + let maybeAttachment: Attachment? = Attachment( + variant: (signalAttachment.isVoiceMessage ? + .voiceMessage : + .standard + ), + contentType: signalAttachment.mimeType, + dataSource: signalAttachment.dataSource, + sourceFilename: signalAttachment.sourceFilename, + caption: signalAttachment.captionText + ) + + guard let attachment: Attachment = maybeAttachment else { return } + + let interactionAttachment: InteractionAttachment = InteractionAttachment( + interactionId: interactionId, + attachmentId: attachment.id + ) + + try attachment.insert(db) + try interactionAttachment.insert(db) } - var attachments: [TSAttachmentStream] = [] - signalAttachments.forEach { - let attachment = TSAttachmentStream(contentType: $0.mimeType, byteCount: UInt32($0.dataLength), sourceFilename: $0.sourceFilename, - caption: $0.captionText, albumMessageId: tsMessage.uniqueId!) - attachment.attachmentType = $0.isVoiceMessage ? .voiceMessage : .default - attachments.append(attachment) - attachment.write($0.dataSource) - attachment.save(with: transaction) - } - prep(attachments, for: message, using: transaction) - } - - @objc(prep:forMessage:usingTransaction:) - public static func prep(_ attachmentStreams: [TSAttachmentStream], for message: VisibleMessage, using transaction: YapDatabaseReadWriteTransaction) { - guard let tsMessage = TSOutgoingMessage.find(withTimestamp: message.sentTimestamp!) else { - #if DEBUG - preconditionFailure() - #else - return - #endif - } - var attachments = attachmentStreams - // The line below locally generates a thumbnail for the quoted attachment. It just needs to happen at some point during the - // message sending process. - tsMessage.quotedMessage?.createThumbnailAttachmentsIfNecessary(with: transaction) - var linkPreviewAttachmentID: String? - if let id = tsMessage.linkPreview?.imageAttachmentId, - let attachment = TSAttachment.fetch(uniqueId: id, transaction: transaction) as? TSAttachmentStream { - linkPreviewAttachmentID = id - attachments.append(attachment) - } - // Anything added to message.attachmentIDs will be uploaded by an UploadAttachmentJob. Any attachment IDs added to tsMessage will - // make it render as an attachment (not what we want in the case of a link preview or quoted attachment). - message.attachmentIDs = attachments.map { $0.uniqueId! } - tsMessage.attachmentIds.removeAllObjects() - tsMessage.attachmentIds.addObjects(from: message.attachmentIDs) - if let id = linkPreviewAttachmentID { tsMessage.attachmentIds.remove(id) } - tsMessage.save(with: transaction) } // MARK: - Convenience @@ -559,3 +532,26 @@ public final class MessageSender : NSObject { return nil } } + +// MARK: - Objective-C Support + +// FIXME: Remove when possible + +@objc(SMKMessageSender) +public class SMKMessageSender: NSObject { + @objc(leaveClosedGroupWithPublicKey:) + public static func objc_leave(_ groupPublicKey: String) -> AnyPromise { + let promise = GRDBStorage.shared.write { db in + try MessageSender.leave(db, groupPublicKey: groupPublicKey) + } + + return AnyPromise.from(promise) + } + + @objc(forceSyncConfigurationNow) + public static func objc_forceSyncConfigurationNow() { + GRDBStorage.shared.write { db in + try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() + } + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift b/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift index fc73325de..a1c8cbec4 100644 --- a/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift +++ b/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift @@ -112,3 +112,16 @@ public struct QuotedReplyModel { ) } } + +// MARK: - Convenience + +public extension QuotedReplyModel { + func generateAttachmentThumbnailIfNeeded(_ db: Database) throws -> String? { + guard let sourceAttachment: Attachment = self.attachment else { return nil } + + return try sourceAttachment + .cloneAsThumbnail()? + .inserted(db) + .id + } +} diff --git a/SessionUtilitiesKit/Database/Models/Job.swift b/SessionUtilitiesKit/Database/Models/Job.swift index 875f56307..c652105a9 100644 --- a/SessionUtilitiesKit/Database/Models/Job.swift +++ b/SessionUtilitiesKit/Database/Models/Job.swift @@ -6,7 +6,9 @@ import GRDB public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "job" } internal static let dependencyForeignKey = ForeignKey([Columns.id], to: [JobDependencies.Columns.dependantId]) + internal static let dependantJobForeignKey = ForeignKey([Columns.id], to: [JobDependencies.Columns.jobId]) internal static let dependencies = hasMany(Job.self, using: dependencyForeignKey) + internal static let dependantJobs = hasMany(Job.self, using: dependencyForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -153,6 +155,14 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer request(for: Job.dependencies) } + /// The other jobs which depend on this job + /// + /// **Note:** When completing a job the dependencies **MUST** be cleared before the job is + /// deleted or it will automatically delete any dependant jobs + public var dependantJobs: QueryInterfaceRequest { + request(for: Job.dependantJobs) + } + // MARK: - Initialization fileprivate init( @@ -225,7 +235,7 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer public func delete(_ db: Database) throws -> Bool { // Delete any dependencies - try dependencies + try dependantJobs .deleteAll(db) return try performDelete(db) diff --git a/SessionUtilitiesKit/General/ReusableView.swift b/SessionUtilitiesKit/General/ReusableView.swift index 4a33f2e65..032b624c6 100644 --- a/SessionUtilitiesKit/General/ReusableView.swift +++ b/SessionUtilitiesKit/General/ReusableView.swift @@ -12,5 +12,6 @@ public extension ReusableView where Self: UIView { } } +extension UICollectionReusableView: ReusableView {} extension UITableViewCell: ReusableView {} extension UITableViewHeaderFooterView: ReusableView {} diff --git a/SessionUtilitiesKit/General/UICollectionView+ReusableView.swift b/SessionUtilitiesKit/General/UICollectionView+ReusableView.swift new file mode 100644 index 000000000..fbbc7cd33 --- /dev/null +++ b/SessionUtilitiesKit/General/UICollectionView+ReusableView.swift @@ -0,0 +1,27 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit + +public extension UICollectionView { + func register(view: View.Type) where View: UICollectionViewCell { + register(view.self, forCellWithReuseIdentifier: view.defaultReuseIdentifier) + } + + func register(view: View.Type, ofKind kind: String) where View: UICollectionReusableView { + register(view.self, forSupplementaryViewOfKind: kind, withReuseIdentifier: view.defaultReuseIdentifier) + } + + func dequeue(type: T.Type, for indexPath: IndexPath) -> T where T: UICollectionViewCell { + // Note: We need to use `type.defaultReuseIdentifier` rather than `T.defaultReuseIdentifier` + // otherwise we may get a subclass rather than the actual type we specified + let reuseIdentifier = type.defaultReuseIdentifier + return dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as! T + } + + func dequeue(type: T.Type, ofKind kind: String, for indexPath: IndexPath) -> T where T: UICollectionReusableView { + // Note: We need to use `type.defaultReuseIdentifier` rather than `T.defaultReuseIdentifier` + // otherwise we may get a subclass rather than the actual type we specified + let reuseIdentifier = type.defaultReuseIdentifier + return dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: reuseIdentifier, for: indexPath) as! T + } +} diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index 84ab69c7b..c3298d133 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -524,6 +524,7 @@ public final class JobRunner { GRDBStorage.shared.write { db in // Get the max failure count for the job (a value of '-1' means it will retry indefinitely) let maxFailureCount: Int = (executorMap.wrappedValue[job.variant]?.maxFailureCount ?? 0) + let nextRunTimestamp: TimeInterval = (Date().timeIntervalSince1970 + getRetryInterval(for: job)) guard !permanentFailure && @@ -537,12 +538,36 @@ public final class JobRunner { } SNLog("[JobRunner] \(job.variant) job failed; scheduling retry (failure count is \(job.failureCount + 1))") + _ = try job .with( failureCount: (job.failureCount + 1), - nextRunTimestamp: (Date().timeIntervalSince1970 + getRetryInterval(for: job)) + nextRunTimestamp: nextRunTimestamp ) .saved(db) + + // Update the failureCount and nextRunTimestamp on dependant jobs as well (update the + // 'nextRunTimestamp' value to be 1ms later so when the queue gets regenerated it'll + // come after the dependency) + try job.dependantJobs + .updateAll( + db, + Job.Columns.failureCount.set(to: job.failureCount), + Job.Columns.nextRunTimestamp.set(to: (nextRunTimestamp + (1 / 1000))) + ) + + let dependantJobIds: [Int64] = try job.dependantJobs + .select(.id) + .asRequest(of: Int64.self) + .fetchAll(db) + + // Remove the dependant jobs from the queue (so we don't get stuck in a loop of trying + // to run dependecies indefinitely + if !dependantJobIds.isEmpty { + jobQueue.mutate { queue in + queue = queue.filter { !dependantJobIds.contains($0.id ?? -1) } + } + } } jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) } diff --git a/SignalUtilitiesKit/Shared Views/GalleryRailView.swift b/SignalUtilitiesKit/Shared Views/GalleryRailView.swift index b6f43c54b..94dabe8ee 100644 --- a/SignalUtilitiesKit/Shared Views/GalleryRailView.swift +++ b/SignalUtilitiesKit/Shared Views/GalleryRailView.swift @@ -5,11 +5,11 @@ import PromiseKit import SessionUIKit -public protocol GalleryRailItemProvider: AnyObject { +public protocol GalleryRailItemProvider { var railItems: [GalleryRailItem] { get } } -public protocol GalleryRailItem: AnyObject { +public protocol GalleryRailItem { func buildRailItemView() -> UIView } From 5bcc124388181fa5bcbeeb3eb722426ff8b9507a Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Sun, 15 May 2022 14:39:21 +1000 Subject: [PATCH 079/157] Updated the SessionShareExtension to work with GRDB Updated to the latest version of GRDB Fixed an issue with db reentrant behaviour with the Attachment upload function Finished up the updated 'sendNonDurability' functions --- Podfile | 1 + Podfile.lock | 6 +- Session.xcodeproj/project.pbxproj | 4 + .../ConversationVC+Interaction.swift | 25 +- Session/Home/HomeVC.swift | 10 +- Session/Notifications/AppNotifications.swift | 6 +- .../Database/Models/Attachment.swift | 19 +- .../Database/Models/Interaction.swift | 57 ++++ .../Models/InteractionAttachment.swift | 10 + .../Jobs/Types/AttachmentUploadJob.swift | 27 +- .../ClosedGroupControlMessage.swift | 8 +- .../MessageSender+ClosedGroups.swift | 6 +- .../MessageSender+Convenience.swift | 153 ++++++----- SessionShareExtension/ShareVC.swift | 7 + .../SimplifiedConversationCell.swift | 54 ++-- SessionShareExtension/ThreadPickerVC.swift | 253 +++++++++++------- .../ThreadPickerViewModel.swift | 192 +++++++++++++ SessionUtilitiesKit/JobRunner/JobRunner.swift | 6 +- .../AttachmentApprovalViewController.swift | 10 +- ...ModalActivityIndicatorViewController.swift | 9 +- SignalUtilitiesKit/Utilities/AppSetup.m | 4 +- .../Utilities/UIViewController+OWS.m | 35 +-- 22 files changed, 619 insertions(+), 283 deletions(-) create mode 100644 SessionShareExtension/ThreadPickerViewModel.swift diff --git a/Podfile b/Podfile index 72e34dc1c..8e9c238cc 100644 --- a/Podfile +++ b/Podfile @@ -42,6 +42,7 @@ abstract_target 'GlobalDependencies' do target 'SessionShareExtension' do pod 'NVActivityIndicatorView' + pod 'DifferenceKit' end target 'SignalUtilitiesKit' do diff --git a/Podfile.lock b/Podfile.lock index 81d738229..aa728eb01 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -27,7 +27,7 @@ PODS: - DifferenceKit/Core (1.2.0) - DifferenceKit/UIKitExtension (1.2.0): - DifferenceKit/Core - - GRDB.swift/SQLCipher (5.23.0): + - GRDB.swift/SQLCipher (5.24.0): - SQLCipher (>= 3.4.0) - Mantle (2.1.0): - Mantle/extobjc (= 2.1.0) @@ -203,7 +203,7 @@ SPEC CHECKSUMS: CryptoSwift: a532e74ed010f8c95f611d00b8bbae42e9fe7c17 Curve25519Kit: e63f9859ede02438ae3defc5e1a87e09d1ec7ee6 DifferenceKit: 5659c430bb7fe45876fa32ce5cba5d6167f0c805 - GRDB.swift: e4a950fe99d113ea5d24571d49eaae0062303c14 + GRDB.swift: 7ecc8799aaa97cf1fbbcfa9d75821aa920cb713f Mantle: 2fa750afa478cd625a94230fbf1c13462f29395b NVActivityIndicatorView: 1f6c5687f1171810aa27a3296814dc2d7dec3667 OpenSSL-Universal: e7311447fd2419f57420c79524b641537387eff2 @@ -219,6 +219,6 @@ SPEC CHECKSUMS: YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331 ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: 50ae96076a7cd581c63b3276679615844c88ac44 +PODFILE CHECKSUM: bd0e75b0b6e37b30d8414efed2a5a98635e1a1a6 COCOAPODS: 1.11.2 diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 81cd9273a..7cc89689a 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -752,6 +752,7 @@ FD1C98E4282E3C5B00B76F9E /* UINavigationBar+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */; }; FD28A4F227E990E800FF65E7 /* BlockingManagerRemovalMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD28A4F127E990E800FF65E7 /* BlockingManagerRemovalMigration.swift */; }; FD28A4F427EA79F800FF65E7 /* BlockListUIUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD28A4F327EA79F800FF65E7 /* BlockListUIUtils.swift */; }; + FD3AABE928306BBD00E5099A /* ThreadPickerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */; }; FD3C907327E8387300CD579F /* OpenGroupServerIdLookupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907227E8387300CD579F /* OpenGroupServerIdLookupMigration.swift */; }; FD3C907527E83AC200CD579F /* OpenGroupServerIdLookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */; }; FD5D200F27AA2B6000FEA984 /* MessageRequestResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */; }; @@ -1806,6 +1807,7 @@ FD28A4F127E990E800FF65E7 /* BlockingManagerRemovalMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockingManagerRemovalMigration.swift; sourceTree = ""; }; FD28A4F327EA79F800FF65E7 /* BlockListUIUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockListUIUtils.swift; sourceTree = ""; }; FD28A4F527EAD44C00FF65E7 /* GRDBStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GRDBStorage.swift; sourceTree = ""; }; + FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadPickerViewModel.swift; sourceTree = ""; }; FD3C907227E8387300CD579F /* OpenGroupServerIdLookupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookupMigration.swift; sourceTree = ""; }; FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookup.swift; sourceTree = ""; }; FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestResponse.swift; sourceTree = ""; }; @@ -2055,6 +2057,7 @@ FD705A8B278CDB5600F16121 /* SAEScreenLockViewController.swift */, FD705A8F278CEBBC00F16121 /* ShareAppExtensionContext.swift */, C3ADC66026426688005F1414 /* ShareVC.swift */, + FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */, B817AD9B26436F73009DF825 /* ThreadPickerVC.swift */, B817AD9926436593009DF825 /* SimplifiedConversationCell.swift */, ); @@ -4546,6 +4549,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + FD3AABE928306BBD00E5099A /* ThreadPickerViewModel.swift in Sources */, B817AD9A26436593009DF825 /* SimplifiedConversationCell.swift in Sources */, C3ADC66126426688005F1414 /* ShareVC.swift in Sources */, FD705A90278CEBBC00F16121 /* ShareAppExtensionContext.swift in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 3d37ebcf7..86147e117 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -89,7 +89,7 @@ extension ConversationVC: dismiss(animated: true, completion: nil) } - func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [SignalAttachment], messageText: String?) { + func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?) { sendAttachments(attachments, with: messageText ?? "") resetMentions() self.snInputView.text = "" @@ -106,7 +106,7 @@ extension ConversationVC: // MARK: - AttachmentApprovalViewControllerDelegate - func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], messageText: String?) { + func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?) { sendAttachments(attachments, with: messageText ?? "") { [weak self] in self?.dismiss(animated: true, completion: nil) } @@ -146,9 +146,13 @@ extension ConversationVC: } func handleLibraryButtonTapped() { + let threadId: String = self.viewModel.viewData.thread.id + requestLibraryPermissionIfNeeded { [weak self] in DispatchQueue.main.async { - let sendMediaNavController = SendMediaNavigationController.showingMediaLibraryFirst() + let sendMediaNavController = SendMediaNavigationController.showingMediaLibraryFirst( + threadId: threadId + ) sendMediaNavController.sendMediaNavDelegate = self sendMediaNavController.modalPresentationStyle = .fullScreen self?.present(sendMediaNavController, animated: true, completion: nil) @@ -165,7 +169,7 @@ extension ConversationVC: SNLog("Proceeding without microphone access. Any recorded video will be silent.") } - let sendMediaNavController = SendMediaNavigationController.showingCameraFirst() + let sendMediaNavController = SendMediaNavigationController.showingCameraFirst(threadId: self.viewModel.viewData.thread.id) sendMediaNavController.sendMediaNavDelegate = self sendMediaNavController.modalPresentationStyle = .fullScreen @@ -234,7 +238,12 @@ extension ConversationVC: } func showAttachmentApprovalDialog(for attachments: [SignalAttachment]) { - let navController = AttachmentApprovalViewController.wrappedInNavController(attachments: attachments, approvalDelegate: self) + let navController = AttachmentApprovalViewController.wrappedInNavController( + threadId: self.viewModel.viewData.thread.id, + attachments: attachments, + approvalDelegate: self + ) + present(navController, animated: true, completion: nil) } @@ -505,7 +514,11 @@ extension ConversationVC: let dataSource = DataSourceValue.dataSource(with: imageData, utiType: kUTTypeJPEG as String) let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: kUTTypeJPEG as String, imageQuality: .medium) - let approvalVC = AttachmentApprovalViewController.wrappedInNavController(attachments: [ attachment ], approvalDelegate: self) + let approvalVC = AttachmentApprovalViewController.wrappedInNavController( + threadId: self.viewModel.viewData.thread.id, + attachments: [ attachment ], + approvalDelegate: self + ) approvalVC.modalPresentationStyle = .fullScreen self.present(approvalVC, animated: true, completion: nil) diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 4e13dec3e..30d51278a 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -15,6 +15,12 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve private var dataChangeObservable: DatabaseCancellable? private var hasLoadedInitialData: Bool = false + // MARK: - Intialization + + deinit { + NotificationCenter.default.removeObserver(self) + } + // MARK: - UI private var tableViewTopConstraint: NSLayoutConstraint! @@ -205,10 +211,6 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve dataChangeObservable?.cancel() } - deinit { - NotificationCenter.default.removeObserver(self) - } - // MARK: - Updating private func startObservingChanges() { diff --git a/Session/Notifications/AppNotifications.swift b/Session/Notifications/AppNotifications.swift index 06ae9bc2e..7bf09b3d7 100644 --- a/Session/Notifications/AppNotifications.swift +++ b/Session/Notifications/AppNotifications.swift @@ -425,7 +425,11 @@ class NotificationActionHandler { trySendReadReceipt: true ) - return MessageSender.sendNonDurably(db, interaction: interaction, in: thread) + return try MessageSender.sendNonDurably( + db, + interaction: interaction, + in: thread + ) } promise.catch { [weak self] error in diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index 6fa2c67d3..d526532ab 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -563,7 +563,7 @@ public extension Attachment { return attachmentsFolder }() - internal static func originalFilePath(id: String, mimeType: String, sourceFilename: String?) -> String? { + public static func originalFilePath(id: String, mimeType: String, sourceFilename: String?) -> String? { return MIMETypeUtil.filePath( forAttachment: id, ofMIMEType: mimeType, @@ -866,6 +866,7 @@ extension Attachment { extension Attachment { internal func upload( + _ db: Database, using upload: (Data) -> Promise, encrypt: Bool, success: (() -> Void)?, @@ -899,11 +900,9 @@ extension Attachment { digest == nil else { // Save the final upload info - let uploadedAttachment: Attachment? = GRDBStorage.shared.write { db in - try self - .with(state: .uploaded) - .saved(db) - } + let uploadedAttachment: Attachment? = try? self + .with(state: .uploaded) + .saved(db) guard uploadedAttachment != nil else { SNLog("Couldn't update attachmentUpload job.") @@ -943,11 +942,9 @@ extension Attachment { } // Update the attachment to the 'uploading' state - let updatedAttachment: Attachment? = GRDBStorage.shared.write { db in - try processedAttachment - .with(state: .uploading) - .saved(db) - } + let updatedAttachment: Attachment? = try? processedAttachment + .with(state: .uploading) + .saved(db) guard updatedAttachment != nil else { SNLog("Couldn't update attachmentUpload job.") diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index b3a42eca8..01c2267fd 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -404,6 +404,63 @@ public extension Interaction { // MARK: - GRDB Interactions public extension Interaction { + static func lastInteractionTimestamp(timestampMsKey: String) -> CommonTableExpression { + return CommonTableExpression( + named: "lastInteraction", + request: Interaction + .select( + Interaction.Columns.threadId, + + // 'max()' to get the latest + max(Interaction.Columns.timestampMs).forKey(timestampMsKey) + ) + .joining(required: Interaction.thread) + .group(Interaction.Columns.threadId) // One interaction per thread + ) + } + + static func lastInteraction( + lastInteractionKey: String, + timestampMsKey: String, + threadVariantKey: String, + isOpenGroupInvitationKey: String, + recipientStatesKey: String + ) -> CommonTableExpression { + let thread: TypedTableAlias = TypedTableAlias() + let linkPreview: TypedTableAlias = TypedTableAlias() + + return CommonTableExpression( + named: lastInteractionKey, + request: Interaction + .select( + Interaction.Columns.id, + Interaction.Columns.threadId, + Interaction.Columns.variant, + + // 'max()' to get the latest + max(Interaction.Columns.timestampMs).forKey(timestampMsKey), + + thread[.variant].forKey(threadVariantKey), + Interaction.Columns.body, + Interaction.Columns.authorId, + (linkPreview[.url] != nil).forKey(isOpenGroupInvitationKey) + ) + .joining(required: Interaction.thread.aliased(thread)) + .joining( + optional: Interaction.linkPreview + .filter(literal: Interaction.linkPreviewFilterLiteral) + .filter(LinkPreview.Columns.variant == LinkPreview.Variant.openGroupInvitation) + ) + .including(all: Interaction.attachments) + .including( + all: Interaction.recipientStates + .select(RecipientState.Columns.state) + .forKey(recipientStatesKey) + ) + .group(Interaction.Columns.threadId) // One interaction per thread + ) + } + /// This will update the `wasRead` state the the interaction /// /// - Parameters diff --git a/SessionMessagingKit/Database/Models/InteractionAttachment.swift b/SessionMessagingKit/Database/Models/InteractionAttachment.swift index 50f0dab0c..394df2e07 100644 --- a/SessionMessagingKit/Database/Models/InteractionAttachment.swift +++ b/SessionMessagingKit/Database/Models/InteractionAttachment.swift @@ -30,6 +30,16 @@ public struct InteractionAttachment: Codable, FetchableRecord, PersistableRecord request(for: InteractionAttachment.attachment) } + // MARK: - Initialization + + public init( + interactionId: Int64, + attachmentId: String + ) { + self.interactionId = interactionId + self.attachmentId = attachmentId + } + // MARK: - Custom Database Interaction public func delete(_ db: Database) throws -> Bool { diff --git a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift index 827512b06..f9965ba89 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift @@ -33,18 +33,21 @@ public enum AttachmentUploadJob: JobExecutor { return } - attachment.upload( - using: { data in - if let openGroup: OpenGroup = openGroup { - return OpenGroupAPIV2.upload(data, to: openGroup.room, on: openGroup.server) - } - - return FileServerAPIV2.upload(data) - }, - encrypt: (openGroup == nil), - success: { success(job, false) }, - failure: { error in failure(job, error, false) } - ) + GRDBStorage.shared.writeAsync { db in + attachment.upload( + db, + using: { data in + if let openGroup: OpenGroup = openGroup { + return OpenGroupAPIV2.upload(data, to: openGroup.room, on: openGroup.server) + } + + return FileServerAPIV2.upload(data) + }, + encrypt: (openGroup == nil), + success: { success(job, false) }, + failure: { error in failure(job, error, false) } + ) + } } } diff --git a/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift b/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift index 3bcbc3232..1be74cf07 100644 --- a/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift @@ -15,8 +15,8 @@ public final class ClosedGroupControlMessage: ControlMessage { public override var ttl: UInt64 { switch kind { - case .encryptionKeyPair: return 14 * 24 * 60 * 60 * 1000 - default: return 14 * 24 * 60 * 60 * 1000 + case .encryptionKeyPair: return 14 * 24 * 60 * 60 * 1000 + default: return 14 * 24 * 60 * 60 * 1000 } } @@ -184,8 +184,8 @@ public final class ClosedGroupControlMessage: ControlMessage { // MARK: - Initialization - internal init(kind: Kind) { - super.init() + internal init(kind: Kind, sentTimestampMs: UInt64? = nil) { + super.init(sentTimestamp: sentTimestampMs) self.kind = kind } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift index fa42ccc75..3215309a7 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift @@ -73,12 +73,10 @@ extension MessageSender { members: membersAsData, admins: adminsAsData, expirationTimer: 0 - ) - ) - .with( + ), // Note: We set this here to ensure the value matches the 'ClosedGroup' // object we created - sentTimestamp: UInt64(floor(formationTimestamp * 1000)) + sentTimestampMs: UInt64(floor(formationTimestamp * 1000)) ), interactionId: nil, in: contactThread diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index c58632fed..716d74ba5 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -6,8 +6,8 @@ import PromiseKit import SessionUtilitiesKit extension MessageSender { - - // MARK: Durable + + // MARK: - Durable public static func send(_ db: Database, interaction: Interaction, with attachments: [SignalAttachment], in thread: SessionThread) throws { guard let interactionId: Int64 = interaction.id else { throw GRDBStorageError.objectNotSaved } @@ -61,78 +61,111 @@ extension MessageSender { ) } + // MARK: - Non-Durable - public static func sendNonDurably(_ db: Database, interaction: Interaction, in thread: SessionThread) -> Promise { - guard let interactionId: Int64 = interaction.id else { - return Promise(error: GRDBStorageError.objectNotSaved) - } + public static func sendNonDurably(_ db: Database, interaction: Interaction, with attachments: [SignalAttachment], in thread: SessionThread) throws -> Promise { + guard let interactionId: Int64 = interaction.id else { return Promise(error: GRDBStorageError.objectNotSaved) } - let openGroup: OpenGroup? = try? thread.openGroup.fetchOne(db) - let attachmentStateInfo: [Attachment.StateInfo] = (try? Attachment - .stateInfo(interactionId: interactionId, state: .pending) - .fetchAll(db)) - .defaulting(to: []) - let attachmentUploadPromises: [Promise] = (try? Attachment - .filter(ids: attachmentStateInfo.map { $0.attachmentId }) - .fetchAll(db)) - .defaulting(to: []) - .map { attachment -> Promise in - let (promise, seal) = Promise.pending() - - attachment.upload( - using: { data in - if let openGroup: OpenGroup = openGroup { - return OpenGroupAPIV2.upload(data, to: openGroup.room, on: openGroup.server) - } - - return FileServerAPIV2.upload(data) - }, - encrypt: (openGroup == nil), - success: { seal.fulfill(()) }, - failure: { seal.reject($0) } - ) - - return promise - } + try prep(db, signalAttachments: attachments, for: interactionId) - return when(resolved: attachmentUploadPromises) - .then(on: DispatchQueue.global(qos: .userInitiated)) { results -> Promise in - let errors = results - .compactMap { result -> Swift.Error? in - if case .rejected(let error) = result { return error } - - return nil - } - - if let error = errors.first { return Promise(error: error) } - - return sendNonDurably(db, interaction: interaction, in: thread) - } + return sendNonDurably( + db, + message: VisibleMessage.from(db, interaction: interaction), + interactionId: interactionId, + to: try Message.Destination.from(db, thread: thread) + ) } - public static func sendNonDurably(_ db: Database, _ message: VisibleMessage, with attachmentIds: [String], in thread: TSThread) -> Promise { + + public static func sendNonDurably(_ db: Database, interaction: Interaction, in thread: SessionThread) throws -> Promise { + // Only 'VisibleMessage' types can be sent via this method + guard interaction.variant == .standardOutgoing else { throw MessageSenderError.invalidMessage } + guard let interactionId: Int64 = interaction.id else { throw GRDBStorageError.objectNotSaved } + + return sendNonDurably( + db, + message: VisibleMessage.from(db, interaction: interaction), + interactionId: interactionId, + to: try Message.Destination.from(db, thread: thread) + ) } - public static func sendNonDurably(_ db: Database, message: Message, interactionId: Int64?, in thread: SessionThread) throws -> Promise { - return try MessageSender.sendImmediate( + return sendNonDurably( db, message: message, - to: try Message.Destination.from(db, thread: thread), - interactionId: interactionId + interactionId: interactionId, + to: try Message.Destination.from(db, thread: thread) ) } - public static func sendNonDurably(_ message: VisibleMessage, with attachments: [SignalAttachment], in thread: TSThread) -> Promise { - } + public static func sendNonDurably(_ db: Database, message: Message, interactionId: Int64?, to destination: Message.Destination) -> Promise { + var attachmentUploadPromises: [Promise] = [Promise.value(())] + + // If we have an interactionId then check if it has any attachments and process them first + if let interactionId: Int64 = interactionId { + let threadId: String = { + switch destination { + case .contact(let publicKey): return publicKey + case .closedGroup(let groupPublicKey): return groupPublicKey + case .openGroupV2(let room, let server): + return OpenGroup.idFor(room: room, server: server) + + case .openGroup: return "" + } + }() + let openGroup: OpenGroup? = try? OpenGroup.fetchOne(db, id: threadId) + let attachmentStateInfo: [Attachment.StateInfo] = (try? Attachment + .stateInfo(interactionId: interactionId, state: .pending) + .fetchAll(db)) + .defaulting(to: []) + + attachmentUploadPromises = (try? Attachment + .filter(ids: attachmentStateInfo.map { $0.attachmentId }) + .fetchAll(db)) + .defaulting(to: []) + .map { attachment -> Promise in + let (promise, seal) = Promise.pending() - public static func sendNonDurably(_ db: Database, message: Message, interactionId: Int64?, to destination: Message.Destination) throws -> Promise { - return try MessageSender.sendImmediate( - db, - message: message, - to: destination, - interactionId: interactionId - ) + attachment.upload( + db, + using: { data in + if let openGroup: OpenGroup = openGroup { + return OpenGroupAPIV2.upload(data, to: openGroup.room, on: openGroup.server) + } + + return FileServerAPIV2.upload(data) + }, + encrypt: (openGroup == nil), + success: { seal.fulfill(()) }, + failure: { seal.reject($0) } + ) + + return promise + } + } + + // Once the attachments are processed then send the message + return when(resolved: attachmentUploadPromises) + .then { results -> Promise in + let errors: [Error] = results + .compactMap { result -> Error? in + if case .rejected(let error) = result { return error } + + return nil + } + + if let error: Error = errors.first { return Promise(error: error) } + + return GRDBStorage.shared.write { db in + try MessageSender.sendImmediate( + db, + message: message, + to: destination, + interactionId: interactionId + ) + } + } } /// This method requires the `db` value to be passed in because if it's called within a `writeAsync` completion block diff --git a/SessionShareExtension/ShareVC.swift b/SessionShareExtension/ShareVC.swift index 4cd67274f..f637b9418 100644 --- a/SessionShareExtension/ShareVC.swift +++ b/SessionShareExtension/ShareVC.swift @@ -225,6 +225,13 @@ final class ShareVC : UINavigationController, ShareViewDelegate, AppModeManagerD } func shareViewFailed(error: Error) { + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in + self?.shareViewFailed(error: error) + } + return + } + let alert = UIAlertController(title: "Session", message: error.localizedDescription, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "BUTTON_OK".localized(), style: .default, handler: { _ in self.extensionContext!.cancelRequest(withError: error) diff --git a/SessionShareExtension/SimplifiedConversationCell.swift b/SessionShareExtension/SimplifiedConversationCell.swift index 386b2da83..ad0eddfc9 100644 --- a/SessionShareExtension/SimplifiedConversationCell.swift +++ b/SessionShareExtension/SimplifiedConversationCell.swift @@ -4,9 +4,7 @@ import UIKit import SessionUIKit import SessionMessagingKit -final class SimplifiedConversationCell : UITableViewCell { - var threadViewModel: ThreadViewModel! { didSet { update() } } - +final class SimplifiedConversationCell: UITableViewCell { // MARK: - Initialization override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { @@ -80,45 +78,25 @@ final class SimplifiedConversationCell : UITableViewCell { accentLineView.set(.width, to: Values.accentLineThickness) accentLineView.set(.height, to: 68) - let profilePictureViewSize = Values.mediumProfilePictureSize - profilePictureView.set(.width, to: profilePictureViewSize) - profilePictureView.set(.height, to: profilePictureViewSize) - profilePictureView.size = profilePictureViewSize + profilePictureView.set(.width, to: Values.mediumProfilePictureSize) + profilePictureView.set(.height, to: Values.mediumProfilePictureSize) + profilePictureView.size = Values.mediumProfilePictureSize stackView.pin(to: self) } - // MARK: - Content + // MARK: - Updating - private func update() { - AssertIsOnMainThread() - - guard let thread = threadViewModel?.thread else { return } - - accentLineView.alpha = (thread.isBlocked() ? 1 : 0) - profilePictureView.update(for: thread) - displayNameLabel.text = getDisplayName() - } - - private func getDisplayName() -> String { - if threadViewModel.thread.variant == .closedGroup || threadViewModel.thread.variant == .openGroup { - if threadViewModel.name.isEmpty { - // TODO: Localization - return "Unknown Group" - } - - return threadViewModel.name - } - - if threadViewModel.threadRecord.isNoteToSelf() { - return "NOTE_TO_SELF".localized() - } - - guard threadViewModel.thread.variant == .contact else { - // TODO: Localization - return "Unknown" - } - - return Profile.displayName(id: threadViewModel.thread.id) + public func update(with item: ThreadPickerViewModel.Item, currentUserProfile: Profile) { + accentLineView.alpha = (item.isBlocked ? 1 : 0) + profilePictureView.update( + publicKey: item.id, + profile: item.profile(currentUserProfile: currentUserProfile), + additionalProfile: item.additionalProfile, + threadVariant: item.variant, + openGroupProfilePicture: item.openGroupProfilePictureData.map { UIImage(data: $0) }, + useFallbackPicture: (item.variant == .openGroup && item.openGroupProfilePictureData == nil) + ) + displayNameLabel.text = item.displayName(currentUserProfile: currentUserProfile) } } diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index 91a193a7e..f4798cde6 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -1,24 +1,25 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + import UIKit -import SignalUtilitiesKit +import GRDB +import PromiseKit +import DifferenceKit import SessionUIKit +import SignalUtilitiesKit import SessionMessagingKit -import SessionUtilitiesKit final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableViewDelegate, AttachmentApprovalViewControllerDelegate { - private var threads: YapDatabaseViewMappings! - private var threadViewModelCache: [String: ThreadViewModel] = [:] // Thread ID to ThreadViewModel - private var selectedThread: TSThread? + private let viewModel: ThreadPickerViewModel = ThreadPickerViewModel() + private var dataChangeObservable: DatabaseCancellable? + private var hasLoadedInitialData: Bool = false + var shareVC: ShareVC? - private var threadCount: UInt { - threads.numberOfItems(inGroup: TSShareExtensionGroup) - } + // MARK: - Intialization - private lazy var dbConnection: YapDatabaseConnection = { - let result = OWSPrimaryStorage.shared().newDatabaseConnection() - result.objectCacheLimit = 500 - return result - }() + deinit { + NotificationCenter.default.removeObserver(self) + } // MARK: - UI @@ -63,14 +64,6 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView view.backgroundColor = .clear view.setGradient(Gradients.defaultBackground) - // Threads - dbConnection.beginLongLivedReadTransaction() // Freeze the connection for use on the main thread (this gives us a stable data source that doesn't change until we tell it to) - threads = YapDatabaseViewMappings(groups: [ TSShareExtensionGroup ], view: TSThreadShareExtensionDatabaseViewExtensionName) // The extension should be registered at this point - threads.setIsReversed(true, forGroup: TSShareExtensionGroup) - dbConnection.read { transaction in - self.threads.update(with: transaction) // Perform the initial update - } - // Title navigationItem.titleView = titleLabel @@ -80,8 +73,41 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView view.addSubview(fadeView) setupLayout() - // Reload - reload() + + // Notifications + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationDidBecomeActive(_:)), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationDidResignActive(_:)), + name: UIApplication.didEnterBackgroundNotification, object: nil + ) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + startObservingChanges() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + // Stop observing database changes + dataChangeObservable?.cancel() + } + + @objc func applicationDidBecomeActive(_ notification: Notification) { + startObservingChanges() + } + + @objc func applicationDidResignActive(_ notification: Notification) { + // Stop observing database changes + dataChangeObservable?.cancel() } private func setupNavBar() { @@ -112,55 +138,83 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView fadeView.pin(.bottom, to: .bottom, of: view) } - // MARK: Table View Data Source + // MARK: - Updating + + private func startObservingChanges() { + // Start observing for data changes + dataChangeObservable = GRDBStorage.shared.start( + viewModel.observableViewData, + onError: { _ in }, + onChange: { [weak self] viewData in + // The defaul scheduler emits changes on the main thread + self?.handleUpdates(viewData) + } + ) + } + + private func handleUpdates(_ updatedViewData: ThreadPickerViewModel.ViewData) { + // 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.items, target: updatedViewData.items), + with: .automatic, + interrupt: { $0.changeCount > 100 } // Prevent too many changes from causing performance issues + ) { [weak self] updatedData in + self?.viewModel.updateData( + ThreadPickerViewModel.ViewData( + currentUserProfile: updatedViewData.currentUserProfile, + items: updatedData + ) + ) + } + } + + // MARK: - UITableViewDataSource + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return Int(threadCount) + return self.viewModel.viewData.items.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell: SimplifiedConversationCell = tableView.dequeue(type: SimplifiedConversationCell.self, for: indexPath) - cell.threadViewModel = threadViewModel(at: indexPath.row) + cell.update( + with: self.viewModel.viewData.items[indexPath.row], + currentUserProfile: self.viewModel.viewData.currentUserProfile + ) return cell } - // MARK: - Updating - - private func reload() { - AssertIsOnMainThread() - dbConnection.beginLongLivedReadTransaction() // Jump to the latest commit - dbConnection.read { transaction in - self.threads.update(with: transaction) - } - threadViewModelCache.removeAll() - tableView.reloadData() - } - // MARK: - Interaction func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - guard let thread = self.thread(at: indexPath.row), let attachments = ShareVC.attachmentPrepPromise?.value else { - return - } + guard let attachments: [SignalAttachment] = ShareVC.attachmentPrepPromise?.value else { return } - self.selectedThread = thread - - let approvalVC = AttachmentApprovalViewController.wrappedInNavController(attachments: attachments, approvalDelegate: self) - navigationController!.present(approvalVC, animated: true, completion: nil) + let approvalVC: OWSNavigationController = AttachmentApprovalViewController.wrappedInNavController( + threadId: self.viewModel.viewData.items[indexPath.row].id, + attachments: attachments, + approvalDelegate: self + ) + self.navigationController?.present(approvalVC, animated: true, completion: nil) } - func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], messageText: String?) { + func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?) { // Sharing a URL or plain text will populate the 'messageText' field so in those // cases we should ignore the attachments let isSharingUrl: Bool = (attachments.count == 1 && attachments[0].isUrl) let isSharingText: Bool = (attachments.count == 1 && attachments[0].isText) let finalAttachments: [SignalAttachment] = (isSharingUrl || isSharingText ? [] : attachments) - - let message = VisibleMessage() - message.sentTimestamp = NSDate.millisecondTimestamp() - message.text = (isSharingUrl && (messageText?.isEmpty == true || attachments[0].linkPreviewDraft == nil) ? + let body: String? = ( + isSharingUrl && (messageText?.isEmpty == true || attachments[0].linkPreviewDraft == nil) ? ( (messageText?.isEmpty == true || (attachments[0].text() == messageText) ? attachments[0].text() : @@ -169,35 +223,55 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView ) : messageText ) - - let tsMessage = TSOutgoingMessage.from(message, associatedWith: selectedThread!) - Storage.write( - with: { transaction in - if isSharingUrl { - message.linkPreview = VisibleMessage.LinkPreview.from( - attachments[0].linkPreviewDraft, - using: transaction - ) - } - else { - tsMessage.save(with: transaction) - } - }, - completion: { - if isSharingUrl { - tsMessage.linkPreview = OWSLinkPreview.from(message.linkPreview) - - Storage.write { transaction in - tsMessage.save(with: transaction) - } - } - } - ) - shareVC!.dismiss(animated: true, completion: nil) + shareVC?.dismiss(animated: true, completion: nil) ModalActivityIndicatorViewController.present(fromViewController: shareVC!, canCancel: false, message: "vc_share_sending_message".localized()) { activityIndicator in - MessageSender.sendNonDurably(message, with: finalAttachments, in: self.selectedThread!) + GRDBStorage.shared + .write { [weak self] db -> Promise in + guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { + activityIndicator.dismiss { } + self?.shareVC?.shareViewFailed(error: MessageSenderError.noThread) + return Promise(error: MessageSenderError.noThread) + } + + // Create the interaction + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let interaction: Interaction = try Interaction( + threadId: threadId, + authorId: getUserHexEncodedPublicKey(db), + variant: .standardOutgoing, + body: body, + timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)), + hasMention: (body?.contains("@\(userPublicKey)") == true), + linkPreviewUrl: (isSharingUrl ? attachments.first?.linkPreviewDraft?.urlString : nil) + ).inserted(db) + + // If the user is sharing a Url, there is a LinkPreview and it doesn't match an existing + // one then add it now + if + isSharingUrl, + let linkPreviewDraft: OWSLinkPreviewDraft = attachments.first?.linkPreviewDraft, + (try? interaction.linkPreview.isEmpty(db)) == true + { + try LinkPreview( + url: linkPreviewDraft.urlString, + title: linkPreviewDraft.title, + attachmentId: LinkPreview.saveAttachmentIfPossible( + db, + imageData: linkPreviewDraft.jpegImageData, + mimeType: OWSMimeTypeImageJpeg + ) + ).insert(db) + } + + return try MessageSender.sendNonDurably( + db, + interaction: interaction, + with: finalAttachments, + in: thread + ) + } .done { [weak self] _ in activityIndicator.dismiss { } self?.shareVC?.shareViewWasCompleted() @@ -216,31 +290,4 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didChangeMessageText newMessageText: String?) { // Do nothing } - - // MARK: - Convenience - - private func thread(at index: Int) -> TSThread? { - var thread: TSThread? = nil - dbConnection.read { transaction in - let ext = transaction.ext(TSThreadShareExtensionDatabaseViewExtensionName) as! YapDatabaseViewTransaction - thread = ext.object(atRow: UInt(index), inSection: 0, with: self.threads) as! TSThread? - } - return thread - } - - private func threadViewModel(at index: Int) -> ThreadViewModel? { - guard let thread = thread(at: index) else { return nil } - - if let cachedThreadViewModel = threadViewModelCache[thread.uniqueId!] { - return cachedThreadViewModel - } - else { - var threadViewModel: ThreadViewModel? = nil - dbConnection.read { transaction in - threadViewModel = ThreadViewModel(thread: thread, transaction: transaction) - } - threadViewModelCache[thread.uniqueId!] = threadViewModel - return threadViewModel - } - } } diff --git a/SessionShareExtension/ThreadPickerViewModel.swift b/SessionShareExtension/ThreadPickerViewModel.swift new file mode 100644 index 000000000..6885dcd81 --- /dev/null +++ b/SessionShareExtension/ThreadPickerViewModel.swift @@ -0,0 +1,192 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import DifferenceKit +import SignalUtilitiesKit +import SessionMessagingKit + +public class ThreadPickerViewModel { + // MARK: - Initialization + + init() { + viewData = ViewData( + currentUserProfile: Profile.fetchOrCreateCurrentUser(), + items: [] + ) + } + + public struct Item: FetchableRecord, Decodable, Equatable, Differentiable { + public struct GroupMemberInfo: FetchableRecord, Decodable, Equatable { + public let profile: Profile + } + + fileprivate static let closedGroupNameKey = CodingKeys.closedGroupName.stringValue + fileprivate static let openGroupNameKey = CodingKeys.openGroupName.stringValue + fileprivate static let openGroupProfilePictureDataKey = CodingKeys.openGroupProfilePictureData.stringValue + fileprivate static let contactProfileKey = CodingKeys.contactProfile.stringValue + fileprivate static let closedGroupAvatarProfilesKey = CodingKeys.closedGroupAvatarProfiles.stringValue + fileprivate static let contactIsBlockedKey = CodingKeys.contactIsBlocked.stringValue + fileprivate static let isNoteToSelfKey = CodingKeys.isNoteToSelf.stringValue + + public var differenceIdentifier: String { id } + + public let id: String + public let variant: SessionThread.Variant + + public let closedGroupName: String? + public let openGroupName: String? + public let openGroupProfilePictureData: Data? + private let contactProfile: Profile? + private let closedGroupAvatarProfiles: [GroupMemberInfo]? + + /// A flag indicating whether the contact is blocked (will be null for non-contact threads) + private let contactIsBlocked: Bool? + public let isNoteToSelf: Bool + + public func displayName(currentUserProfile: Profile) -> String { + return SessionThread.displayName( + threadId: id, + variant: variant, + closedGroupName: closedGroupName, + openGroupName: openGroupName, + isNoteToSelf: isNoteToSelf, + profile: contactProfile + ) + } + + public func profile(currentUserProfile: Profile) -> Profile? { + switch variant { + case .contact: return contactProfile + case .openGroup: return nil + case .closedGroup: + // If there is only a single user in the group then we want to use the current user + // profile at the back + if closedGroupAvatarProfiles?.count == 1 { + return currentUserProfile + } + + return closedGroupAvatarProfiles?.first?.profile + } + } + + public var additionalProfile: Profile? { + switch variant { + case .closedGroup: return closedGroupAvatarProfiles?.last?.profile + default: return nil + } + } + + /// A flag indicating whether the thread is blocked (only contact threads can be blocked) + public var isBlocked: Bool { + return (contactIsBlocked == true) + } + + // MARK: - Query + + public static func query(userPublicKey: String) -> QueryInterfaceRequest { + let thread: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() + let closedGroup: TypedTableAlias = TypedTableAlias() + let openGroup: TypedTableAlias = TypedTableAlias() + let lastInteraction: TableAlias = TableAlias() + + let lastInteractionTimestampExpression: CommonTableExpression = Interaction.lastInteractionTimestamp( + timestampMsKey: Interaction.Columns.timestampMs.stringValue + ) + // FIXME: Exclude unwritable opengroups + return SessionThread + .select( + thread[.id], + thread[.variant], + thread[.creationDateTimestamp], + + closedGroup[.name].forKey(Item.closedGroupNameKey), + openGroup[.name].forKey(Item.openGroupNameKey), + openGroup[.imageData].forKey(Item.openGroupProfilePictureDataKey), + + contact[.isBlocked].forKey(Item.contactIsBlockedKey), + SessionThread.isNoteToSelf(userPublicKey: userPublicKey).forKey(Item.isNoteToSelfKey) + ) + .filter(SessionThread.Columns.shouldBeVisible == true) + .filter(SessionThread.isNotMessageRequest(userPublicKey: userPublicKey)) + .filter( + // Only show the Note to Self if it has an interaction + SessionThread.Columns.id != userPublicKey || + lastInteraction[Interaction.Columns.timestampMs] != nil + ) + .aliased(thread) + .joining( + optional: SessionThread.contact + .aliased(contact) + .including( + optional: Contact.profile + .forKey(Item.contactProfileKey) + ) + ) + .joining( + optional: SessionThread.closedGroup + .aliased(closedGroup) + .including( + all: ClosedGroup.members + .filter(GroupMember.Columns.role == GroupMember.Role.standard) + .filter(GroupMember.Columns.profileId != userPublicKey) + .order(GroupMember.Columns.profileId) // Sort to provide a level of stability + .limit(2) + .including(required: GroupMember.profile) + .forKey(Item.closedGroupAvatarProfilesKey) + ) + ) + .joining(optional: SessionThread.openGroup.aliased(openGroup)) + .with(lastInteractionTimestampExpression) + .including( + optional: SessionThread + .association( + to: lastInteractionTimestampExpression, + on: { thread, lastInteraction in + thread[SessionThread.Columns.id] == lastInteraction[Interaction.Columns.threadId] + } + ) + .aliased(lastInteraction) + ) + .order( + ( + lastInteraction[Interaction.Columns.timestampMs] ?? + (thread[.creationDateTimestamp] * 1000) + ).desc + ) + .asRequest(of: Item.self) + } + } + + public struct ViewData: Equatable { + let currentUserProfile: Profile + let items: [Item] + } + + /// This value is the current state of the view + public private(set) var viewData: ViewData + + /// 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 + public lazy var observableViewData = ValueObservation + .trackingConstantRegion { db -> ViewData in + let currentUserProfile: Profile = Profile.fetchOrCreateCurrentUser(db) + return ViewData( + currentUserProfile: Profile.fetchOrCreateCurrentUser(db), + items: try Item + .query(userPublicKey: currentUserProfile.id) + .fetchAll(db) + ) + } + .removeDuplicates() + + // MARK: - Functions + + public func updateData(_ updatedData: ViewData) { + self.viewData = updatedData + } +} diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index c3298d133..df07cc562 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -503,7 +503,7 @@ public final class JobRunner { runNextJob() } return - + // For "blocking once per session" jobs only rerun it immediately if it hasn't already // run this session case .recurringOnLaunchBlockingOncePerSession: @@ -517,7 +517,7 @@ public final class JobRunner { runNextJob() } return - + default: break } @@ -531,6 +531,8 @@ public final class JobRunner { maxFailureCount >= 0 && job.failureCount + 1 < maxFailureCount else { + SNLog("[JobRunner] \(job.variant) failed permanently\(maxFailureCount >= 0 ? "; too many retries" : "")") + // If the job permanently failed or we have performed all of our retry attempts // then delete the job (it'll probably never succeed) _ = try job.delete(db) diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift index 04ce2726a..027a6f877 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift @@ -14,6 +14,7 @@ public protocol AttachmentApprovalViewControllerDelegate: AnyObject { func attachmentApproval( _ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], + forThreadId threadId: String, messageText: String? ) @@ -54,6 +55,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC // MARK: - Properties private let mode: Mode + private let threadId: String private let isAddMoreVisible: Bool public weak var approvalDelegate: AttachmentApprovalViewControllerDelegate? @@ -123,10 +125,12 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC @objc required public init( mode: Mode, + threadId: String, attachments: [SignalAttachment] ) { assert(attachments.count > 0) self.mode = mode + self.threadId = threadId let attachmentItems = attachments.map { SignalAttachmentItem(attachment: $0 )} self.isAddMoreVisible = (mode == .sharedNavigation) @@ -154,12 +158,12 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC NotificationCenter.default.removeObserver(self) } - @objc public class func wrappedInNavController( + threadId: String, attachments: [SignalAttachment], approvalDelegate: AttachmentApprovalViewControllerDelegate ) -> OWSNavigationController { - let vc = AttachmentApprovalViewController(mode: .modal, attachments: attachments) + let vc = AttachmentApprovalViewController(mode: .modal, threadId: threadId, attachments: attachments) vc.approvalDelegate = approvalDelegate let navController = OWSNavigationController(rootViewController: vc) @@ -760,7 +764,7 @@ extension AttachmentApprovalViewController: AttachmentTextToolbarDelegate { attachmentTextToolbar.isUserInteractionEnabled = false attachmentTextToolbar.isHidden = true - approvalDelegate?.attachmentApproval(self, didApproveAttachments: attachments, messageText: attachmentTextToolbar.messageText) + approvalDelegate?.attachmentApproval(self, didApproveAttachments: attachments, forThreadId: threadId, messageText: attachmentTextToolbar.messageText) } func attachmentTextToolbarDidChange(_ attachmentTextToolbar: AttachmentTextToolbar) { diff --git a/SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift b/SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift index 868d5745c..4f1c818b0 100644 --- a/SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift +++ b/SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift @@ -63,8 +63,13 @@ public class ModalActivityIndicatorViewController: OWSViewController { } @objc - public func dismiss(completion : @escaping () -> Void) { - AssertIsOnMainThread() + public func dismiss(completion: @escaping () -> Void) { + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in + self?.dismiss(completion: completion) + } + return + } if !wasDimissed { // Only dismiss once. diff --git a/SignalUtilitiesKit/Utilities/AppSetup.m b/SignalUtilitiesKit/Utilities/AppSetup.m index 9a512565b..129400905 100644 --- a/SignalUtilitiesKit/Utilities/AppSetup.m +++ b/SignalUtilitiesKit/Utilities/AppSetup.m @@ -46,7 +46,6 @@ NS_ASSUME_NONNULL_BEGIN TSAccountManager *tsAccountManager = [[TSAccountManager alloc] initWithPrimaryStorage:primaryStorage]; id reachabilityManager = [SSKReachabilityManagerImpl new]; - id typingIndicators = [[OWSTypingIndicatorsImpl alloc] init]; OWSAudioSession *audioSession = [OWSAudioSession new]; id proximityMonitoringManager = [OWSProximityMonitoringManagerImpl new]; @@ -61,8 +60,7 @@ NS_ASSUME_NONNULL_BEGIN // TODO: Refactor this file to Swift [SSKEnvironment setShared:[[SSKEnvironment alloc] initWithPrimaryStorage:primaryStorage tsAccountManager:tsAccountManager - reachabilityManager:reachabilityManager - typingIndicators:typingIndicators]]; + reachabilityManager:reachabilityManager]]; // [SSKEnvironment setShared:[[SSKEnvironment alloc] initWithPrimaryStorage:primaryStorage // tsAccountManager:tsAccountManager // disappearingMessagesJob:disappearingMessagesJob diff --git a/SignalUtilitiesKit/Utilities/UIViewController+OWS.m b/SignalUtilitiesKit/Utilities/UIViewController+OWS.m index bb20c7a7d..ea3a05715 100644 --- a/SignalUtilitiesKit/Utilities/UIViewController+OWS.m +++ b/SignalUtilitiesKit/Utilities/UIViewController+OWS.m @@ -9,6 +9,7 @@ #import "UIView+OWS.h" #import "UIViewController+OWS.h" #import +#import #import @@ -83,7 +84,7 @@ NS_ASSUME_NONNULL_BEGIN const CGFloat kExtraRightPadding = isRTL ? -0 : +10; // Extra hit area above/below - const CGFloat kExtraHeightPadding = 4; + const CGFloat kExtraHeightPadding = 8; // Matching the default backbutton placement is tricky. // We can't just adjust the imageEdgeInsets on a UIBarButtonItem directly, @@ -91,39 +92,19 @@ NS_ASSUME_NONNULL_BEGIN // in a UIBarButtonItem. [backButton addTarget:target action:selector forControlEvents:UIControlEventTouchUpInside]; - UIImage *backImage = [[UIImage imageNamed:(isRTL ? @"NavBarBackRTL" : @"NavBarBack")] - imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; + UIImageConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:22 weight:UIImageSymbolWeightMedium]; + UIImage *backImage = [[UIImage systemImageNamed:@"chevron.backward" withConfiguration:config] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; OWSAssertDebug(backImage); [backButton setImage:backImage forState:UIControlStateNormal]; - backButton.tintColor = UIColor.lokiGreen; + backButton.tintColor = LKColors.text; backButton.contentHorizontalAlignment = UIControlContentHorizontalAlignmentLeft; + backButton.imageEdgeInsets = UIEdgeInsetsMake(0, kExtraLeftPadding, 0, 0); - // Default back button is 1.5 pixel lower than our extracted image. - const CGFloat kTopInsetPadding = 1.5; - backButton.imageEdgeInsets = UIEdgeInsetsMake(kTopInsetPadding, kExtraLeftPadding, 0, 0); - - CGRect buttonFrame - = CGRectMake(0, 0, backImage.size.width + kExtraRightPadding, backImage.size.height + kExtraHeightPadding); + CGRect buttonFrame = CGRectMake(0, 0, backImage.size.width + kExtraRightPadding, backImage.size.height + kExtraHeightPadding); backButton.frame = buttonFrame; - // In iOS 11.1 beta, the hot area of custom bar button items is _only_ - // the bounds of the custom view, making them very hard to hit. - // - // TODO: Remove this hack if the bug is fixed in iOS 11.1 by the time - // it goes to production (or in a later release), - // since it has two negative side effects: 1) the layout of the - // back button isn't consistent with the iOS default back buttons - // 2) we can't add the unread count badge to the back button - // with this hack. - return [[UIBarButtonItem alloc] initWithImage:backImage - style:UIBarButtonItemStylePlain - target:target - action:selector]; - - UIBarButtonItem *backItem = - [[UIBarButtonItem alloc] initWithCustomView:backButton - accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"back")]; + UIBarButtonItem *backItem = [[UIBarButtonItem alloc] initWithCustomView:backButton accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"back")]; backItem.width = buttonFrame.size.width; return backItem; From a6c7e252a734faad0d1f2bc3fd60207c962e8878 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 17 May 2022 17:47:56 +1000 Subject: [PATCH 080/157] Added global search back Removed the logic for 'oversizedText' (not sent by either iOS or Android and not handled at all by desktop) Updated the HomeViewModel (and ConversationCell) to use the same query model as Global Search Added an 'albumIndex' property to the InteractionAttachment so we can enforce a correct order (apparently SQLite doesn't do this by default) Updated the YDB to GRDB migration to avoid creating GroupMembers if the current user isn't a member of a ClosedGroup (be consistent with the running logic) Updated the attachment description logic to be consistent throughout Cleaned up the Interaction preview generation logic --- Session.xcodeproj/project.pbxproj | 12 + .../Conversations/ConversationSearch.swift | 95 +- .../GlobalSearch/EmptySearchResultCell.swift | 6 +- .../GlobalSearchViewController.swift | 309 +++-- Session/Home/HomeVC.swift | 46 +- Session/Home/HomeViewModel.swift | 420 +------ .../MessageRequestsViewController.swift | 8 +- .../MessageRequestsViewModel.swift | 11 +- Session/Shared/ConversationCell.swift | 380 +++--- .../Models/ConversationCellViewModel.swift | 1080 +++++++++++++++++ .../_001_InitialSetupMigration.swift | 38 + .../Migrations/_003_YDBToGRDBMigration.swift | 56 +- .../Database/Models/Attachment.swift | 78 +- .../Database/Models/Interaction.swift | 102 +- .../Models/InteractionAttachment.swift | 4 + .../Attachments/SignalAttachment.swift | 31 +- .../MessageReceiver+Handling.swift | 25 +- .../Sending & Receiving/MessageSender.swift | 3 +- .../Quotes/QuotedReplyModel.swift | 65 +- .../Database/GRDBStorage.swift | 2 + .../Database/Types/ColumnExpressible.swift | 8 + .../Utilities/Database+Utilities.swift | 4 + .../Utilities/TableRecord+Utilities.swift | 2 + SessionUtilitiesKit/Media/DataSource.h | 2 - SessionUtilitiesKit/Media/DataSource.m | 10 - SessionUtilitiesKit/Media/MIMETypeUtil.h | 1 - SessionUtilitiesKit/Media/MIMETypeUtil.m | 8 - .../AttachmentCaptionToolbar.swift | 69 +- .../AttachmentTextToolbar.swift | 69 +- .../MediaMessageView.swift | 4 +- .../Utilities/FeatureFlags.swift | 11 - 31 files changed, 1803 insertions(+), 1156 deletions(-) create mode 100644 Session/Shared/Models/ConversationCellViewModel.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 7cc89689a..003138f0d 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -755,6 +755,7 @@ FD3AABE928306BBD00E5099A /* ThreadPickerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */; }; FD3C907327E8387300CD579F /* OpenGroupServerIdLookupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907227E8387300CD579F /* OpenGroupServerIdLookupMigration.swift */; }; FD3C907527E83AC200CD579F /* OpenGroupServerIdLookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */; }; + FD4B200C283367410034334B /* ConversationCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4B200B283367410034334B /* ConversationCellViewModel.swift */; }; FD5D200F27AA2B6000FEA984 /* MessageRequestResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */; }; FD5D201127AA331F00FEA984 /* ConfigurationMessage+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D201027AA331F00FEA984 /* ConfigurationMessage+Convenience.swift */; }; FD659AC027A7649600F12C02 /* MessageRequestsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD659ABF27A7649600F12C02 /* MessageRequestsViewController.swift */; }; @@ -1810,6 +1811,7 @@ FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadPickerViewModel.swift; sourceTree = ""; }; FD3C907227E8387300CD579F /* OpenGroupServerIdLookupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookupMigration.swift; sourceTree = ""; }; FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookup.swift; sourceTree = ""; }; + FD4B200B283367410034334B /* ConversationCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationCellViewModel.swift; sourceTree = ""; }; FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestResponse.swift; sourceTree = ""; }; FD5D201027AA331F00FEA984 /* ConfigurationMessage+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConfigurationMessage+Convenience.swift"; sourceTree = ""; }; FD659ABF27A7649600F12C02 /* MessageRequestsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsViewController.swift; sourceTree = ""; }; @@ -2431,6 +2433,7 @@ B8CCF63B239757C10091D419 /* Shared */ = { isa = PBXGroup; children = ( + FD4B200A283367350034334B /* Models */, 4CA46F4B219CCC630038ABDE /* CaptionView.swift */, 4C4AEC4420EC343B0020E72B /* DismissableTextField.swift */, 45E5A6981F61E6DD001E4A8A /* MarqueeLabel.swift */, @@ -3716,6 +3719,14 @@ path = LegacyDatabase; sourceTree = ""; }; + FD4B200A283367350034334B /* Models */ = { + isa = PBXGroup; + children = ( + FD4B200B283367410034334B /* ConversationCellViewModel.swift */, + ); + path = Models; + sourceTree = ""; + }; FD659ABE27A7648200F12C02 /* Message Requests */ = { isa = PBXGroup; children = ( @@ -5036,6 +5047,7 @@ B82B408A2399EC0600A248E7 /* FakeChatView.swift in Sources */, B82B40882399EB0E00A248E7 /* LandingVC.swift in Sources */, EF764C351DB67CC5000D9A87 /* UIViewController+Permissions.m in Sources */, + FD4B200C283367410034334B /* ConversationCellViewModel.swift in Sources */, 45CD81EF1DC030E7004C9430 /* SyncPushTokensJob.swift in Sources */, B83524A525C3BA4B0089A44F /* InfoMessageCell.swift in Sources */, B84A89BC25DE328A0040017D /* ProfilePictureVC.swift in Sources */, diff --git a/Session/Conversations/ConversationSearch.swift b/Session/Conversations/ConversationSearch.swift index 7b7ed47b1..24ecf4f91 100644 --- a/Session/Conversations/ConversationSearch.swift +++ b/Session/Conversations/ConversationSearch.swift @@ -1,67 +1,32 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -import Foundation +import UIKit +import SignalUtilitiesKit -@objc -public protocol ConversationSearchControllerDelegate: UISearchControllerDelegate { - - @objc - func conversationSearchController(_ conversationSearchController: ConversationSearchController, - didUpdateSearchResults resultSet: ConversationScreenSearchResultSet?) - - @objc - func conversationSearchController(_ conversationSearchController: ConversationSearchController, - didSelectMessageId: String) -} - -@objc -public class ConversationSearchController : NSObject { - - @objc +public class ConversationSearchController: NSObject { public static let kMinimumSearchTextLength: UInt = 2 - @objc - public let uiSearchController = UISearchController(searchResultsController: nil) - - @objc public weak var delegate: ConversationSearchControllerDelegate? - - let thread: TSThread - - @objc + public let uiSearchController: UISearchController = UISearchController(searchResultsController: nil) public let resultsBar: SearchResultsBar = SearchResultsBar() // MARK: Initializer - @objc - required public init(thread: TSThread) { - self.thread = thread + override public init() { super.init() - + resultsBar.resultsBarDelegate = self uiSearchController.delegate = self uiSearchController.searchResultsUpdater = self uiSearchController.hidesNavigationBarDuringPresentation = false - if #available(iOS 13, *) { - // Do nothing - } else { - uiSearchController.dimsBackgroundDuringPresentation = false - } uiSearchController.searchBar.inputAccessoryView = resultsBar } - - // MARK: Dependencies - - var dbReadConnection: YapDatabaseConnection { - return OWSPrimaryStorage.shared().dbReadConnection - } } -extension ConversationSearchController : UISearchControllerDelegate { - +// MARK: - UISearchControllerDelegate + +extension ConversationSearchController: UISearchControllerDelegate { public func didPresentSearchController(_ searchController: UISearchController) { Logger.verbose("") delegate?.didPresentSearchController?(searchController) @@ -73,8 +38,9 @@ extension ConversationSearchController : UISearchControllerDelegate { } } -extension ConversationSearchController : UISearchResultsUpdating { - +// MARK: - UISearchResultsUpdating + +extension ConversationSearchController: UISearchResultsUpdating { var dbSearcher: FullTextSearcher { return FullTextSearcher.shared } @@ -111,29 +77,33 @@ extension ConversationSearchController : UISearchResultsUpdating { } } -extension ConversationSearchController : SearchResultsBarDelegate { - - func searchResultsBar(_ searchResultsBar: SearchResultsBar, - setCurrentIndex currentIndex: Int, - resultSet: ConversationScreenSearchResultSet) { +// MARK: - SearchResultsBarDelegate + +extension ConversationSearchController: SearchResultsBarDelegate { + func searchResultsBar( + _ searchResultsBar: SearchResultsBar, + setCurrentIndex currentIndex: Int, + resultSet: ConversationScreenSearchResultSet + ) { guard let searchResult = resultSet.messages[safe: currentIndex] else { owsFailDebug("messageId was unexpectedly nil") return } BenchEventStart(title: "Conversation Search Nav", eventId: "Conversation Search Nav: \(searchResult.messageId)") - self.delegate?.conversationSearchController(self, didSelectMessageId: searchResult.messageId) + self.delegate?.conversationSearchController(self, didSelectInteractionId: searchResult.messageId) } } -protocol SearchResultsBarDelegate : AnyObject { - - func searchResultsBar(_ searchResultsBar: SearchResultsBar, - setCurrentIndex currentIndex: Int, - resultSet: ConversationScreenSearchResultSet) +protocol SearchResultsBarDelegate: AnyObject { + func searchResultsBar( + _ searchResultsBar: SearchResultsBar, + setCurrentIndex currentIndex: Int, + resultSet: ConversationScreenSearchResultSet + ) } -public final class SearchResultsBar : UIView { +public final class SearchResultsBar: UIView { private var resultSet: ConversationScreenSearchResultSet? var currentIndex: Int? weak var resultsBarDelegate: SearchResultsBarDelegate? @@ -313,3 +283,10 @@ public final class SearchResultsBar : UIView { } } } + +// MARK: - ConversationSearchControllerDelegate + +public protocol ConversationSearchControllerDelegate: UISearchControllerDelegate { + func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults resultSet: ConversationScreenSearchResultSet?) + func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectInteractionId: Int64) +} diff --git a/Session/Home/GlobalSearch/EmptySearchResultCell.swift b/Session/Home/GlobalSearch/EmptySearchResultCell.swift index 45402292a..21d56bf7d 100644 --- a/Session/Home/GlobalSearch/EmptySearchResultCell.swift +++ b/Session/Home/GlobalSearch/EmptySearchResultCell.swift @@ -1,10 +1,12 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. +import UIKit +import PureLayout +import SessionUIKit +import SessionUtilitiesKit import NVActivityIndicatorView class EmptySearchResultCell: UITableViewCell { - static let reuseIdentifier = "EmptySearchResultCell" - private lazy var messageLabel: UILabel = { let result = UILabel() result.textAlignment = .center diff --git a/Session/Home/GlobalSearch/GlobalSearchViewController.swift b/Session/Home/GlobalSearch/GlobalSearchViewController.swift index 9d1f35dba..c83b1a8ef 100644 --- a/Session/Home/GlobalSearch/GlobalSearchViewController.swift +++ b/Session/Home/GlobalSearch/GlobalSearchViewController.swift @@ -1,10 +1,21 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -@objc +import UIKit +import GRDB +import DifferenceKit +import SessionUIKit +import SessionMessagingKit +import SessionUtilitiesKit +import SignalUtilitiesKit + class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSource { + private struct SearchResultSet { + let contactsAndGroups: [ConversationCell.ViewModel] + let messages: [ConversationCell.ViewModel] + } let isRecentSearchResultsEnabled = false - + @objc public var searchText = "" { didSet { AssertIsOnMainThread() @@ -12,55 +23,54 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo refreshSearchResults() } } - var recentSearchResults: [String] = Array(Storage.shared.getRecentSearchResults().reversed()) var defaultSearchResults: HomeScreenSearchResultSet = HomeScreenSearchResultSet.noteToSelfOnly - var searchResultSet: HomeScreenSearchResultSet = HomeScreenSearchResultSet.empty + + var searchResultSet: [ArraySection] = [] + private var termForCurrentSearchResultSet: String = "" + + private var lastSearchText: String? var searcher: FullTextSearcher { return FullTextSearcher.shared } var isLoading = false - enum SearchSection: Int { + enum SearchSection: Int, Differentiable { case noResults - case contacts + case contactsAndGroups case messages - case recent } - - // MARK: UI Components - + + // MARK: - UI Components + internal lazy var searchBar: SearchBar = { - let result = SearchBar() + let result: SearchBar = SearchBar() result.tintColor = Colors.text result.delegate = self result.showsCancelButton = true return result }() - + internal lazy var tableView: UITableView = { - let result = UITableView(frame: .zero, style: .grouped) + let result: UITableView = UITableView(frame: .zero, style: .grouped) result.rowHeight = UITableView.automaticDimension result.estimatedRowHeight = 60 result.separatorStyle = .none result.keyboardDismissMode = .onDrag - result.register(EmptySearchResultCell.self, forCellReuseIdentifier: EmptySearchResultCell.reuseIdentifier) - result.register(ConversationCell.self, forCellReuseIdentifier: ConversationCell.reuseIdentifier) + result.register(view: EmptySearchResultCell.self) + result.register(view: ConversationCell.self) result.showsVerticalScrollIndicator = false + return result }() - - // MARK: Dependencies - var dbReadConnection: YapDatabaseConnection { - return OWSPrimaryStorage.shared().dbReadConnection - } + // MARK: - View Lifecycle - // MARK: View Lifecycle public override func viewDidLoad() { super.viewDidLoad() - setUpGradientBackground() + setUpGradientBackground() + tableView.dataSource = self tableView.delegate = self view.addSubview(tableView) @@ -72,22 +82,22 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo navigationItem.hidesBackButton = true setupNavigationBar() } - + public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) searchBar.becomeFirstResponder() } - + public override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) searchBar.resignFirstResponder() } - + private func setupNavigationBar() { // This is a workaround for a UI issue that the navigation bar can be a bit higher if // the search bar is put directly to be the titleView. And this can cause the tableView // in home screen doing a weird scrolling when going back to home screen. - let searchBarContainer = UIView() + let searchBarContainer: UIView = UIView() searchBarContainer.layoutMargins = UIEdgeInsets.zero searchBar.sizeToFit() searchBar.layoutMargins = UIEdgeInsets.zero @@ -97,23 +107,22 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo searchBar.autoPinEdgesToSuperviewMargins() navigationItem.titleView = searchBarContainer } - + private func reloadTableData() { tableView.reloadData() } - - // MARK: Update Search Results + + // MARK: - Update Search Results var refreshTimer: Timer? - + private func refreshSearchResults() { refreshTimer?.invalidate() refreshTimer = WeakTimer.scheduledTimer(timeInterval: 0.1, target: self, userInfo: nil, repeats: false) { [weak self] _ in - guard let self = self else { return } - self.updateSearchResults(searchText: self.searchText) + self?.updateSearchResults(searchText: (self?.searchText ?? "")) } } - + private func updateSearchResults(searchText rawSearchText: String) { let searchText = rawSearchText.stripped @@ -127,52 +136,73 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo lastSearchText = searchText - var searchResults: HomeScreenSearchResultSet? - self.dbReadConnection.asyncRead({[weak self] transaction in - guard let self = self else { return } - self.isLoading = true - // The max search result count is set according to the keyword length. This is just a workaround for performance issue. - // The longer and more accurate the keyword is, the less search results should there be. - searchResults = self.searcher.searchForHomeScreen(searchText: searchText, maxSearchResults: 500, transaction: transaction) - }, completionBlock: { [weak self] in - AssertIsOnMainThread() - guard let self = self, let results = searchResults, self.lastSearchText == searchText else { return } - self.searchResultSet = results - self.isLoading = false - self.reloadTableData() - self.refreshTimer = nil - }) + GRDBStorage.shared + .read { db -> Result in + do { + let contactsAndGroupsResults: [ConversationCell.ViewModel] = try ConversationCell.ViewModel + .contactsAndGroupsQuery( + userPublicKey: getUserHexEncodedPublicKey(db), + pattern: try ConversationCell.ViewModel.pattern(db, searchTerm: searchText), + searchTerm: searchText + ) + .fetchAll(db) + + let messageResults: [ConversationCell.ViewModel] = try ConversationCell.ViewModel + .messagesQuery( + userPublicKey: getUserHexEncodedPublicKey(db), + pattern: try ConversationCell.ViewModel.pattern(db, searchTerm: searchText) + ) + .fetchAll(db) + + return .success(SearchResultSet( + contactsAndGroups: contactsAndGroupsResults, + messages: messageResults + )) + } + catch { + return .failure(error) + } + } + .map { [weak self] result in + switch result { + case .success(let resultSet): + self?.termForCurrentSearchResultSet = searchText + self?.searchResultSet = [ + ArraySection(model: .contactsAndGroups, elements: resultSet.contactsAndGroups), + ArraySection(model: .messages, elements: resultSet.messages) + ] + self?.isLoading = false + self?.reloadTableData() + self?.refreshTimer = nil + + + case .failure: break + } + } } - - // MARK: Interaction - @objc func clearRecentSearchResults() { - recentSearchResults = [] - tableView.reloadSections([ SearchSection.recent.rawValue ], with: .top) - Storage.shared.clearRecentSearchResults() - } - } // MARK: - UISearchBarDelegate + extension GlobalSearchViewController: UISearchBarDelegate { public func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { self.updateSearchText() } - + public func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { self.updateSearchText() } - + public func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { self.updateSearchText() } - + public func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { searchBar.text = nil searchBar.resignFirstResponder() self.navigationController?.popViewController(animated: true) } - + func updateSearchText() { guard let searchText = searchBar.text?.ows_stripped() else { return } self.searchText = searchText @@ -180,36 +210,29 @@ extension GlobalSearchViewController: UISearchBarDelegate { } // MARK: - UITableViewDelegate & UITableViewDataSource + extension GlobalSearchViewController { - - // MARK: UITableViewDelegate - + + // MARK: - UITableViewDelegate + public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: false) + guard let searchSection = SearchSection(rawValue: indexPath.section) else { return } + switch searchSection { - case .noResults: - SNLog("shouldn't be able to tap 'no results' section") - case .contacts: - let sectionResults = searchResultSet.conversations - guard let searchResult = sectionResults[safe: indexPath.row] else { return } - show(searchResult.thread.threadRecord, highlightedMessageID: nil, animated: true) - case .messages: - let sectionResults = searchResultSet.messages - guard let searchResult = sectionResults[safe: indexPath.row] else { return } - show(searchResult.thread.threadRecord, highlightedMessageID: searchResult.message?.uniqueId, animated: true) - case .recent: - guard let threadId = recentSearchResults[safe: indexPath.row], let thread = TSThread.fetch(uniqueId: threadId) else { return } - show(thread, highlightedMessageID: nil, animated: true, isFromRecent: true) + case .noResults: + SNLog("shouldn't be able to tap 'no results' section") + + case .contactsAndGroups: + break + + case .messages: + break } } - + private func show(_ thread: TSThread, highlightedMessageID: String?, animated: Bool, isFromRecent: Bool = false) { - if let threadId = thread.uniqueId { - recentSearchResults = Array(Storage.shared.addSearchResults(threadID: threadId).reversed()) - } - - DispatchMainThreadSafe { if let presentedVC = self.presentedViewController { presentedVC.dismiss(animated: false, completion: nil) } @@ -221,12 +244,12 @@ extension GlobalSearchViewController { } } - // MARK: UITableViewDataSource - + // MARK: - UITableViewDataSource + public func numberOfSections(in tableView: UITableView) -> Int { - return 4 + return self.searchResultSet.count } - + public func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { UIView() } @@ -239,80 +262,40 @@ extension GlobalSearchViewController { guard nil != self.tableView(tableView, titleForHeaderInSection: section) else { return .leastNonzeroMagnitude } + return UITableView.automaticDimension } - + public func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - guard let searchSection = SearchSection(rawValue: section) else { return nil } - - guard let title = self.tableView(tableView, titleForHeaderInSection: section) else { + guard let title: String = self.tableView(tableView, titleForHeaderInSection: section) else { return UIView() } - + let titleLabel = UILabel() titleLabel.text = title titleLabel.textColor = Colors.text titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize) - + let container = UIView() container.backgroundColor = Colors.cellBackground container.layoutMargins = UIEdgeInsets(top: Values.smallSpacing, left: Values.mediumSpacing, bottom: Values.smallSpacing, right: Values.mediumSpacing) container.addSubview(titleLabel) titleLabel.autoPinEdgesToSuperviewMargins() - - if searchSection == .recent { - let clearButton = UIButton() - clearButton.setTitle("Clear", for: .normal) - clearButton.setTitleColor(Colors.text, for: UIControl.State.normal) - clearButton.titleLabel!.font = .boldSystemFont(ofSize: Values.smallFontSize) - clearButton.addTarget(self, action: #selector(clearRecentSearchResults), for: .touchUpInside) - container.addSubview(clearButton) - clearButton.autoPinTrailingToSuperviewMargin() - clearButton.autoVCenterInSuperview() - } return container } public func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - guard let searchSection = SearchSection(rawValue: section) else { return nil } - - switch searchSection { - case .noResults: - return nil - case .contacts: - if searchResultSet.conversations.count > 0 { - return NSLocalizedString("SEARCH_SECTION_CONTACTS", comment: "") - } else { - return nil - } - case .messages: - if searchResultSet.messages.count > 0 { - return NSLocalizedString("SEARCH_SECTION_MESSAGES", comment: "") - } else { - return nil - } - case .recent: - if recentSearchResults.count > 0 && searchText.isEmpty && isRecentSearchResultsEnabled { - return NSLocalizedString("SEARCH_SECTION_RECENT", comment: "") - } else { - return nil - } + let section: ArraySection = self.searchResultSet[section] + switch section.model { + case .noResults: return nil + case .contactsAndGroups: return (section.elements.isEmpty ? nil : "SEARCH_SECTION_CONTACTS".localized()) + case .messages: return (section.elements.isEmpty ? nil : "SEARCH_SECTION_MESSAGES".localized()) } } public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - guard let searchSection = SearchSection(rawValue: section) else { return 0 } - switch searchSection { - case .noResults: - return (searchText.count > 0 && searchResultSet.isEmpty) ? 1 : 0 - case .contacts: - return searchResultSet.conversations.count - case .messages: - return searchResultSet.messages.count - case .recent: - return searchText.isEmpty && isRecentSearchResultsEnabled ? recentSearchResults.count : 0 - } + return self.searchResultSet[section].elements.count } public func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { @@ -320,41 +303,23 @@ extension GlobalSearchViewController { } public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - - guard let searchSection = SearchSection(rawValue: indexPath.section) else { - return UITableViewCell() - } - - switch searchSection { - case .noResults: - guard let cell = tableView.dequeueReusableCell(withIdentifier: EmptySearchResultCell.reuseIdentifier) as? EmptySearchResultCell, indexPath.row == 0 else { return UITableViewCell() } - cell.configure(isLoading: isLoading) - return cell - case .contacts: - let sectionResults = searchResultSet.conversations - let cell = tableView.dequeueReusableCell(withIdentifier: ConversationCell.reuseIdentifier) as! ConversationCell - cell.isShowingGlobalSearchResult = true - let searchResult = sectionResults[safe: indexPath.row] - cell.threadViewModel = searchResult?.thread - cell.configure(snippet: searchResult?.snippet, searchText: searchResultSet.searchText) - return cell - case .messages: - let sectionResults = searchResultSet.messages - let cell = tableView.dequeueReusableCell(withIdentifier: ConversationCell.reuseIdentifier) as! ConversationCell - cell.isShowingGlobalSearchResult = true - let searchResult = sectionResults[safe: indexPath.row] - cell.threadViewModel = searchResult?.thread - cell.configure(snippet: searchResult?.snippet, searchText: searchResultSet.searchText, message: searchResult?.message) - return cell - case .recent: - let cell = tableView.dequeueReusableCell(withIdentifier: ConversationCell.reuseIdentifier) as! ConversationCell - cell.isShowingGlobalSearchResult = true - dbReadConnection.read { transaction in - guard let threadId = self.recentSearchResults[safe: indexPath.row], let thread = TSThread.fetch(uniqueId: threadId, transaction: transaction) else { return } - cell.threadViewModel = ThreadViewModel(thread: thread, transaction: transaction) - } - cell.configureForRecent() - return cell + let section: ArraySection = self.searchResultSet[indexPath.section] + + switch section.model { + case .noResults: + let cell: EmptySearchResultCell = tableView.dequeue(type: EmptySearchResultCell.self, for: indexPath) + cell.configure(isLoading: isLoading) + return cell + + case .contactsAndGroups: + let cell: ConversationCell = tableView.dequeue(type: ConversationCell.self, for: indexPath) + cell.updateForContactAndGroupSearchResult(with: section.elements[indexPath.row], searchText: self.termForCurrentSearchResultSet) + return cell + + case .messages: + let cell: ConversationCell = tableView.dequeue(type: ConversationCell.self, for: indexPath) + cell.updateForMessageSearchResult(with: section.elements[indexPath.row], searchText: self.termForCurrentSearchResultSet) + return cell } } } diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 30d51278a..332eccbd5 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -9,7 +9,7 @@ import SignalUtilitiesKit final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConversationButtonSetDelegate, SeedReminderViewDelegate { typealias Section = HomeViewModel.Section - typealias Item = HomeViewModel.Item + typealias Item = ConversationCell.ViewModel private let viewModel: HomeViewModel = HomeViewModel() private var dataChangeObservable: DatabaseCancellable? @@ -346,12 +346,12 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve switch section.model { case .messageRequests: let cell: MessageRequestsCell = tableView.dequeue(type: MessageRequestsCell.self, for: indexPath) - cell.update(with: section.elements[indexPath.row].unreadCount) + cell.update(with: Int(section.elements[indexPath.row].threadUnreadCount ?? 0)) return cell case .threads: let cell: ConversationCell = tableView.dequeue(type: ConversationCell.self, for: indexPath) - cell.update(with: section.elements[indexPath.row].threadInfo) + cell.update(with: section.elements[indexPath.row]) return cell } } @@ -369,7 +369,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve self.navigationController?.pushViewController(viewController, animated: true) case .threads: - let threadId: String = section.elements[indexPath.row].threadInfo.id + let threadId: String = section.elements[indexPath.row].threadId show(threadId, with: .none, highlightedInteractionId: nil, animated: true) } } @@ -396,12 +396,12 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve return [hide] case .threads: - let threadInfo: HomeViewModel.ThreadInfo = section.elements[indexPath.row].threadInfo + let cellViewModel: ConversationCell.ViewModel = section.elements[indexPath.row] let delete: UITableViewRowAction = UITableViewRowAction( style: .destructive, title: "TXT_DELETE_TITLE".localized() ) { [weak self] _, _ in - let message = (threadInfo.isGroupAdmin ? + let message = (cellViewModel.currentUserIsClosedGroupAdmin == true ? "admin_group_leave_warning".localized() : "CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE".localized() ) @@ -416,20 +416,20 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve style: .destructive ) { _ in GRDBStorage.shared.write { db in - switch threadInfo.variant { + switch cellViewModel.threadVariant { case .closedGroup: try MessageSender - .leave(db, groupPublicKey: threadInfo.id) + .leave(db, groupPublicKey: cellViewModel.threadId) .retainUntilComplete() case .openGroup: - OpenGroupManagerV2.shared.delete(db, openGroupId: threadInfo.id) + OpenGroupManagerV2.shared.delete(db, openGroupId: cellViewModel.threadId) default: break } _ = try SessionThread - .filter(id: threadInfo.id) + .filter(id: cellViewModel.threadId) .deleteAll(db) } }) @@ -444,33 +444,41 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve let pin: UITableViewRowAction = UITableViewRowAction( style: .normal, - title: (threadInfo.isPinned ? - "PIN_BUTTON_TEXT".localized() : - "UNPIN_BUTTON_TEXT".localized() + title: (cellViewModel.threadIsPinned ? + "UNPIN_BUTTON_TEXT".localized() : + "PIN_BUTTON_TEXT".localized() ) ) { _, _ in GRDBStorage.shared.write { db in try SessionThread - .filter(id: threadInfo.id) - .updateAll(db, SessionThread.Columns.isPinned.set(to: !threadInfo.isPinned)) + .filter(id: cellViewModel.threadId) + .updateAll(db, SessionThread.Columns.isPinned.set(to: !cellViewModel.threadIsPinned)) } } - guard threadInfo.variant == .contact && !threadInfo.isNoteToSelf else { + guard cellViewModel.threadVariant == .contact && !cellViewModel.threadIsNoteToSelf else { return [ delete, pin ] } let block: UITableViewRowAction = UITableViewRowAction( style: .normal, - title: (threadInfo.isBlocked ? + title: (cellViewModel.threadIsBlocked == true ? "BLOCK_LIST_UNBLOCK_BUTTON".localized() : "BLOCK_LIST_BLOCK_BUTTON".localized() ) ) { _, _ in GRDBStorage.shared.write { db in try Contact - .filter(id: threadInfo.id) - .updateAll(db, Contact.Columns.isBlocked.set(to: !threadInfo.isBlocked)) + .filter(id: cellViewModel.threadId) + .updateAll( + db, + Contact.Columns.isBlocked.set( + to: (cellViewModel.threadIsBlocked == false ? + true: + false + ) + ) + ) try MessageSender.syncConfiguration(db, forceSyncNow: true) .retainUntilComplete() } diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 3ffdf9c71..0ad4fd760 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -11,385 +11,8 @@ public class HomeViewModel { case threads } - public struct ObservedInfo: Equatable { - let unreadMessageRequestCount: Int - let threadInfo: [ThreadInfo] - } - - public struct ThreadInfo: FetchableRecord, Decodable, Equatable, Differentiable { - public struct GroupMemberInfo: FetchableRecord, Decodable, Equatable { - public let profile: Profile - } - public struct InteractionInfo: FetchableRecord, Decodable, Equatable { - public struct AuthorInfo: FetchableRecord, Decodable, Equatable { - public let id: String - public let displayName: String - public let nickname: String? - } - - fileprivate static let timestampMsKey = CodingKeys.timestampMs.stringValue - fileprivate static let threadVariantKey = CodingKeys.threadVariant.stringValue - fileprivate static let authorInfoKey = CodingKeys.authorInfo.stringValue - fileprivate static let isOpenGroupInvitationKey = CodingKeys.isOpenGroupInvitation.stringValue - fileprivate static let recipientStatesKey = CodingKeys.recipientStates.stringValue - - public let id: Int64? - public let variant: Interaction.Variant - public let timestampMs: Double - - private let threadVariant: SessionThread.Variant - private let body: String? - private let attachments: [Attachment]? - private let authorId: String - private let authorInfo: AuthorInfo? - private let isOpenGroupInvitation: Bool - private let recipientStates: [RecipientState.State]? - - public var authorName: String { - return Profile.displayName( - for: threadVariant, - id: (authorInfo?.id ?? authorId), - name: authorInfo?.displayName, - nickname: authorInfo?.nickname, - customFallback: (threadVariant == .contact && variant == .standardIncoming ? - "Anonymous" : - nil - ) - ) - } - - public var text: String { - return Interaction.previewText( - variant: variant, - body: body, - authorDisplayName: authorName, - attachments: (attachments ?? []), - isOpenGroupInvitation: (isOpenGroupInvitation == true) - ) - } - - public var state: RecipientState.State { - return Interaction.state(for: (recipientStates ?? [])) - } - } - - fileprivate static let contactIsTypingKey = CodingKeys.contactIsTyping.stringValue - fileprivate static let closedGroupNameKey = CodingKeys.closedGroupName.stringValue - fileprivate static let openGroupNameKey = CodingKeys.openGroupName.stringValue - fileprivate static let openGroupProfilePictureDataKey = CodingKeys.openGroupProfilePictureData.stringValue - fileprivate static let currentUserProfileKey = CodingKeys.currentUserProfile.stringValue - fileprivate static let contactProfileKey = CodingKeys.contactProfile.stringValue - fileprivate static let closedGroupAvatarProfilesKey = CodingKeys.closedGroupAvatarProfiles.stringValue - fileprivate static let contactIsBlockedKey = CodingKeys.contactIsBlocked.stringValue - fileprivate static let isNoteToSelfKey = CodingKeys.isNoteToSelf.stringValue - fileprivate static let currentUserIsClosedGroupAdminKey = CodingKeys.currentUserIsClosedGroupAdmin.stringValue - fileprivate static let threadUnreadCountKey = CodingKeys.threadUnreadCount.stringValue - fileprivate static let threadUnreadMentionCountKey = CodingKeys.threadUnreadMentionCount.stringValue - fileprivate static let lastInteractionInfoKey = CodingKeys.lastInteractionInfo.stringValue - - public var differenceIdentifier: String { id } - - public let id: String - public let variant: SessionThread.Variant - private let creationDateTimestamp: TimeInterval - - public let contactIsTyping: Bool - public let closedGroupName: String? - public let openGroupName: String? - public let openGroupProfilePictureData: Data? - private let currentUserProfile: Profile - private let contactProfile: Profile? - private let closedGroupAvatarProfiles: [GroupMemberInfo]? - - public let mutedUntilTimestamp: TimeInterval? - public let onlyNotifyForMentions: Bool - public let isPinned: Bool - - /// A flag indicating whether the contact is blocked (will be null for non-contact threads) - private let contactIsBlocked: Bool? - - public let isNoteToSelf: Bool - private let currentUserIsClosedGroupAdmin: Bool? - - private let threadUnreadCount: UInt? - private let threadUnreadMentionCount: UInt? - - public let lastInteractionInfo: InteractionInfo? - - public var displayName: String { - return SessionThread.displayName( - threadId: id, - variant: variant, - closedGroupName: closedGroupName, - openGroupName: openGroupName, - isNoteToSelf: isNoteToSelf, - profile: contactProfile - ) - } - - public var profile: Profile? { - switch variant { - case .contact: return contactProfile - case .openGroup: return nil - case .closedGroup: - // If there is only a single user in the group then we want to use the current user - // profile at the back - if closedGroupAvatarProfiles?.count == 1 { - return currentUserProfile - } - - return closedGroupAvatarProfiles?.first?.profile - } - } - - public var additionalProfile: Profile? { - switch variant { - case .closedGroup: return closedGroupAvatarProfiles?.last?.profile - default: return nil - } - } - - public var lastInteractionDate: Date { - guard let lastInteractionInfo: InteractionInfo = lastInteractionInfo else { - return Date(timeIntervalSince1970: creationDateTimestamp) - } - - return Date(timeIntervalSince1970: (lastInteractionInfo.timestampMs / 1000)) - } - - /// A flag indicating whether the thread is blocked (only contact threads can be blocked) - public var isBlocked: Bool { - return (contactIsBlocked == true) - } - - public var isGroupAdmin: Bool { - return (currentUserIsClosedGroupAdmin == true) - } - - public var unreadCount: UInt { - return (threadUnreadCount ?? 0) - } - - public var unreadMentionCount: UInt { - return (threadUnreadMentionCount ?? 0) - } - - fileprivate init() { - self.id = "FALLBACK" - self.variant = .contact - self.creationDateTimestamp = 0 - self.contactIsTyping = false - self.closedGroupName = nil - self.openGroupName = nil - self.openGroupProfilePictureData = nil - self.currentUserProfile = Profile(id: "", name: "") - self.contactProfile = nil - self.closedGroupAvatarProfiles = nil - self.mutedUntilTimestamp = nil - self.onlyNotifyForMentions = false - self.isPinned = false - self.contactIsBlocked = nil - self.isNoteToSelf = false - self.currentUserIsClosedGroupAdmin = nil - self.threadUnreadCount = nil - self.threadUnreadMentionCount = nil - self.lastInteractionInfo = nil - } - - // MARK: - Query - - public static func query(userPublicKey: String) -> QueryInterfaceRequest { - let thread: TypedTableAlias = TypedTableAlias() - let contact: TypedTableAlias = TypedTableAlias() - let typingIndicator: TypedTableAlias = TypedTableAlias() - let closedGroup: TypedTableAlias = TypedTableAlias() - let closedGroupMember: TypedTableAlias = TypedTableAlias() - let openGroup: TypedTableAlias = TypedTableAlias() - let unreadInteractions: TableAlias = TableAlias() - let unreadMentions: TableAlias = TableAlias() - let lastInteraction: TableAlias = TableAlias() - let lastInteractionThread: TypedTableAlias = TypedTableAlias() - let linkPreview: TypedTableAlias = TypedTableAlias() - - let currentUserProfileExpression: CommonTableExpression = CommonTableExpression( - named: ThreadInfo.currentUserProfileKey, - request: Profile.filter(id: userPublicKey) - ) - let unreadInteractionExpression: CommonTableExpression = CommonTableExpression( - named: ThreadInfo.threadUnreadCountKey, - request: Interaction - .select( - count(Interaction.Columns.id).forKey(ThreadInfo.threadUnreadCountKey), - Interaction.Columns.threadId - ) - .filter(Interaction.Columns.wasRead == false) - .group(Interaction.Columns.threadId) - ) - let unreadMentionsExpression: CommonTableExpression = CommonTableExpression( - named: ThreadInfo.threadUnreadMentionCountKey, - request: Interaction - .select( - count(Interaction.Columns.id).forKey(ThreadInfo.threadUnreadMentionCountKey), - Interaction.Columns.threadId - ) - .filter(Interaction.Columns.wasRead == false) - .filter(Interaction.Columns.hasMention == true) - .group(Interaction.Columns.threadId) - ) - let lastInteractionExpression: CommonTableExpression = CommonTableExpression( - named: ThreadInfo.lastInteractionInfoKey, - request: Interaction - .select( - Interaction.Columns.id, - Interaction.Columns.threadId, - Interaction.Columns.variant, - - // 'max()' to get the latest - max(Interaction.Columns.timestampMs).forKey(ThreadInfo.InteractionInfo.timestampMsKey), - - lastInteractionThread[.variant].forKey(ThreadInfo.InteractionInfo.threadVariantKey), - Interaction.Columns.body, - Interaction.Columns.authorId, - (linkPreview[.url] != nil).forKey(ThreadInfo.InteractionInfo.isOpenGroupInvitationKey) - ) - .joining(required: Interaction.thread.aliased(lastInteractionThread)) - .joining( - optional: Interaction.linkPreview - .filter(literal: Interaction.linkPreviewFilterLiteral) - .filter(LinkPreview.Columns.variant == LinkPreview.Variant.openGroupInvitation) - ) - .including(all: Interaction.attachments) - .including( - all: Interaction.recipientStates - .select(RecipientState.Columns.state) - .forKey(ThreadInfo.InteractionInfo.recipientStatesKey) - ) - .group(Interaction.Columns.threadId) // One interaction per thread - ) - - return SessionThread - .select( - thread[.id], - thread[.variant], - thread[.creationDateTimestamp], - - (typingIndicator[.threadId] != nil).forKey(ThreadInfo.contactIsTypingKey), - closedGroup[.name].forKey(ThreadInfo.closedGroupNameKey), - openGroup[.name].forKey(ThreadInfo.openGroupNameKey), - openGroup[.imageData].forKey(ThreadInfo.openGroupProfilePictureDataKey), - - thread[.mutedUntilTimestamp], - thread[.onlyNotifyForMentions], - thread[.isPinned], - contact[.isBlocked].forKey(ThreadInfo.contactIsBlockedKey), - SessionThread.isNoteToSelf(userPublicKey: userPublicKey).forKey(ThreadInfo.isNoteToSelfKey), - (closedGroupMember[.profileId] != nil).forKey(ThreadInfo.currentUserIsClosedGroupAdminKey), - - unreadInteractions[ThreadInfo.threadUnreadCountKey], - unreadMentions[ThreadInfo.threadUnreadMentionCountKey] - ) - .aliased(thread) - .joining( - optional: SessionThread.contact - .aliased(contact) - .including( - optional: Contact.profile - .forKey(ThreadInfo.contactProfileKey) - ) - ) - .joining(optional: SessionThread.typingIndicator.aliased(typingIndicator)) - .joining( - optional: SessionThread.closedGroup - .aliased(closedGroup) - .including( - all: ClosedGroup.members - .filter(GroupMember.Columns.role == GroupMember.Role.standard) - .filter(GroupMember.Columns.profileId != userPublicKey) - .order(GroupMember.Columns.profileId) // Sort to provide a level of stability - .limit(2) - .including(required: GroupMember.profile) - .forKey(ThreadInfo.closedGroupAvatarProfilesKey) - ) - .joining( - optional: ClosedGroup.members - .aliased(closedGroupMember) - .filter(GroupMember.Columns.role == GroupMember.Role.admin) - .filter(GroupMember.Columns.profileId == userPublicKey) - ) - ) - .joining(optional: SessionThread.openGroup.aliased(openGroup)) - .with(currentUserProfileExpression) - .including( - required: SessionThread.association(to: currentUserProfileExpression) - .forKey(ThreadInfo.currentUserProfileKey) - ) - .with(unreadInteractionExpression) - .joining( - optional: SessionThread - .association( - to: unreadInteractionExpression, - on: { thread, unreadGroup in - thread[SessionThread.Columns.id] == unreadGroup[Interaction.Columns.threadId] - } - ) - .aliased(unreadInteractions) - ) - .with(unreadMentionsExpression) - .joining( - optional: SessionThread - .association( - to: unreadMentionsExpression, - on: { thread, unreadMentions in - thread[SessionThread.Columns.id] == unreadMentions[Interaction.Columns.threadId] - } - ) - .aliased(unreadMentions) - ) - .with(lastInteractionExpression) - .including( - optional: SessionThread - .association( - to: lastInteractionExpression, - on: { thread, lastInteraction in - thread[SessionThread.Columns.id] == lastInteraction[Interaction.Columns.threadId] - } - ) - .aliased(lastInteraction) - .forKey(ThreadInfo.lastInteractionInfoKey) - .including( - optional: lastInteractionExpression - .association( - to: CommonTableExpression( - named: Profile.databaseTableName, - request: Profile.select(.id, .name, .nickname) - ), - on: { lastInteraction, profile in - lastInteraction[Interaction.Columns.authorId] == profile[Profile.Columns.id] - } - ) - .forKey(ThreadInfo.InteractionInfo.authorInfoKey) - ) - ) - .order( - lastInteraction[Interaction.Columns.timestampMs].desc, - thread[.creationDateTimestamp].desc - ) - .asRequest(of: ThreadInfo.self) - } - } - - - public struct Item: Equatable, Differentiable { - public var differenceIdentifier: String { - return threadInfo.id - } - - let unreadCount: Int - let threadInfo: ThreadInfo - } - /// This value is the current state of the view - public private(set) var viewData: [ArraySection] = [] + public private(set) var viewData: [ArraySection] = [] /// 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 @@ -397,7 +20,7 @@ public class HomeViewModel { /// **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 public lazy var observableViewData = ValueObservation - .trackingConstantRegion { db -> ObservedInfo in + .trackingConstantRegion { db -> [ArraySection] in let userPublicKey: String = getUserHexEncodedPublicKey(db) let unreadMessageRequestCount: Int = try SessionThread .filter(SessionThread.isMessageRequest(userPublicKey: userPublicKey)) @@ -408,53 +31,34 @@ public class HomeViewModel { ) .group(SessionThread.Columns.id) .fetchCount(db) + let finalUnreadMessageRequestCount: Int = (db[.hasHiddenMessageRequests] ? 0 : unreadMessageRequestCount) - return ObservedInfo( - unreadMessageRequestCount: (db[.hasHiddenMessageRequests] ? 0 : unreadMessageRequestCount), - threadInfo: try ThreadInfo - .query(userPublicKey: userPublicKey) - .filter(SessionThread.Columns.shouldBeVisible == true) - .filter(SessionThread.isNotMessageRequest(userPublicKey: userPublicKey)) - .filter( - // Only show the Note to Self if it has a lastInteraction - SessionThread.Columns.id != userPublicKey || - SQL(stringLiteral: "\(ThreadInfo.lastInteractionInfoKey).id IS NOT NULL") - ) - .fetchAll(db) - ) - } - .removeDuplicates() - .map { observedInfo -> [ArraySection] in return [ ArraySection( model: .messageRequests, elements: [ // If there are no unread message requests then hide the message request banner - (observedInfo.unreadMessageRequestCount == 0 ? + (finalUnreadMessageRequestCount == 0 ? nil : - Item( - unreadCount: observedInfo.unreadMessageRequestCount, - threadInfo: ThreadInfo() // Won't be used + ConversationCell.ViewModel( + unreadCount: UInt(finalUnreadMessageRequestCount) ) ) ].compactMap { $0 } ), ArraySection( model: .threads, - elements: observedInfo.threadInfo - .map { info in - Item( - unreadCount: Int(info.unreadCount), - threadInfo: info - ) - } - ), + elements: try ConversationCell.ViewModel + .homeQuery(userPublicKey: userPublicKey) + .fetchAll(db) + ) ] } + .removeDuplicates() // MARK: - Functions - public func updateData(_ updatedData: [ArraySection]) { + public func updateData(_ updatedData: [ArraySection]) { self.viewData = updatedData } } diff --git a/Session/Home/Message Requests/MessageRequestsViewController.swift b/Session/Home/Message Requests/MessageRequestsViewController.swift index 8b324fb3f..e5bf0aab8 100644 --- a/Session/Home/Message Requests/MessageRequestsViewController.swift +++ b/Session/Home/Message Requests/MessageRequestsViewController.swift @@ -173,7 +173,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat ) } - private func handleUpdates(_ updatedViewData: [HomeViewModel.ThreadInfo]) { + private func handleUpdates(_ updatedViewData: [ConversationCell.ViewModel]) { // 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 { @@ -221,7 +221,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - guard let conversationVC: ConversationVC = ConversationVC(threadId: viewModel.viewData[indexPath.row].id) else { + guard let conversationVC: ConversationVC = ConversationVC(threadId: viewModel.viewData[indexPath.row].threadId) else { return } @@ -233,7 +233,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat } func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? { - let threadId: String = viewModel.viewData[indexPath.row].id + let threadId: String = viewModel.viewData[indexPath.row].threadId let delete = UITableViewRowAction( style: .destructive, title: "TXT_DELETE_TITLE".localized() @@ -250,7 +250,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat @objc private func clearAllTapped() { guard !viewModel.viewData.isEmpty else { return } - let threadIds: [String] = viewModel.viewData.map { $0.id } + let threadIds: [String] = viewModel.viewData.map { $0.threadId } let alertVC: UIAlertController = UIAlertController( title: "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE".localized(), message: nil, diff --git a/Session/Home/Message Requests/MessageRequestsViewModel.swift b/Session/Home/Message Requests/MessageRequestsViewModel.swift index 996ac79d8..56f845f16 100644 --- a/Session/Home/Message Requests/MessageRequestsViewModel.swift +++ b/Session/Home/Message Requests/MessageRequestsViewModel.swift @@ -7,7 +7,7 @@ import SignalUtilitiesKit public class MessageRequestsViewModel { /// This value is the current state of the view - public private(set) var viewData: [HomeViewModel.ThreadInfo] = [] + public private(set) var viewData: [ConversationCell.ViewModel] = [] /// 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 @@ -15,19 +15,18 @@ public class MessageRequestsViewModel { /// **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 public lazy var observableViewData = ValueObservation - .trackingConstantRegion { db -> [HomeViewModel.ThreadInfo] in + .trackingConstantRegion { db -> [ConversationCell.ViewModel] in let userPublicKey: String = getUserHexEncodedPublicKey(db) - return try HomeViewModel.ThreadInfo - .query(userPublicKey: userPublicKey) - .filter(SessionThread.isMessageRequest(userPublicKey: userPublicKey)) + return try ConversationCell.ViewModel + .messageRequestsQuery(userPublicKey: userPublicKey) .fetchAll(db) } .removeDuplicates() // MARK: - Functions - public func updateData(_ updatedData: [HomeViewModel.ThreadInfo]) { + public func updateData(_ updatedData: [ConversationCell.ViewModel]) { self.viewData = updatedData } } diff --git a/Session/Shared/ConversationCell.swift b/Session/Shared/ConversationCell.swift index 998d227b6..821b130fb 100644 --- a/Session/Shared/ConversationCell.swift +++ b/Session/Shared/ConversationCell.swift @@ -4,7 +4,7 @@ import UIKit import SessionUIKit import SignalUtilitiesKit -final class ConversationCell: UITableViewCell { +public final class ConversationCell: UITableViewCell { // MARK: - UI private let accentLineView: UIView = UIView() @@ -231,155 +231,125 @@ final class ConversationCell: UITableViewCell { // MARK: - Content - public func update(with threadInfo: HomeViewModel.ThreadInfo, isGlobalSearchResult: Bool = false) { - guard !isGlobalSearchResult else { - updateForSearchResult(threadInfo) - return - } - - update(threadInfo) - } - - // MARK: - Updating for search results + // MARK: --Search Results - private func updateForSearchResult(_ threadInfo: HomeViewModel.ThreadInfo) { + public func updateForMessageSearchResult(with cellViewModel: ViewModel, searchText: String) { profilePictureView.update( - publicKey: threadInfo.id, - profile: threadInfo.profile, - additionalProfile: threadInfo.additionalProfile, - threadVariant: threadInfo.variant, - openGroupProfilePicture: threadInfo.openGroupProfilePictureData.map { UIImage(data: $0) }, - useFallbackPicture: (threadInfo.variant == .openGroup && threadInfo.openGroupProfilePictureData == nil) + publicKey: cellViewModel.threadId, + profile: cellViewModel.profile, + additionalProfile: cellViewModel.additionalProfile, + threadVariant: cellViewModel.threadVariant, + openGroupProfilePicture: cellViewModel.openGroupProfilePictureData.map { UIImage(data: $0) }, + useFallbackPicture: (cellViewModel.threadVariant == .openGroup && cellViewModel.openGroupProfilePictureData == nil) ) isPinnedIcon.isHidden = true unreadCountView.isHidden = true hasMentionView.isHidden = true - } - - public func configureForRecent(_ threadInfo: HomeViewModel.ThreadInfo) { displayNameLabel.attributedText = NSMutableAttributedString( - string: threadInfo.displayName, - attributes: [ .foregroundColor: Colors.text ] + string: cellViewModel.displayName, + attributes: [ .foregroundColor: Colors.text] ) + timestampLabel.isHidden = false + timestampLabel.text = DateUtil.formatDate(forDisplay: cellViewModel.lastInteractionDate) bottomLabelStackView.isHidden = false - - let snippet = String( - format: "RECENT_SEARCH_LAST_MESSAGE_DATETIME".localized(), - DateUtil.formatDate(forDisplay: threadInfo.lastInteractionDate) + snippetLabel.attributedText = getHighlightedSnippet( + content: (cellViewModel.interactionBody ?? ""), + authorName: (cellViewModel.authorId != cellViewModel.currentUserPublicKey ? + cellViewModel.authorName(for: .contact) : + nil + ), + searchText: searchText.lowercased(), + fontSize: Values.smallFontSize ) - snippetLabel.attributedText = NSMutableAttributedString(string: snippet, attributes: [.foregroundColor:Colors.text.withAlphaComponent(Values.lowOpacity)]) - timestampLabel.isHidden = true } - - public func configure(snippet: String?, searchText: String, message: TSMessage? = nil) { - let normalizedSearchText = searchText.lowercased() - if let messageTimestamp = message?.timestamp, let snippet = snippet { - // Message - let messageDate = NSDate.ows_date(withMillisecondsSince1970: messageTimestamp) - displayNameLabel.attributedText = NSMutableAttributedString(string: getDisplayName(), attributes: [.foregroundColor:Colors.text]) - timestampLabel.isHidden = false - timestampLabel.text = DateUtil.formatDate(forDisplay: messageDate) - bottomLabelStackView.isHidden = false - var rawSnippet = snippet - if let message = message, let name = getMessageAuthorName(message: message) { - rawSnippet = "\(name): \(snippet)" - } - snippetLabel.attributedText = getHighlightedSnippet(snippet: rawSnippet, searchText: normalizedSearchText, fontSize: Values.smallFontSize) - } else { - // Contact - if threadViewModel.isGroupThread, let thread = threadViewModel.threadRecord as? TSGroupThread { - displayNameLabel.attributedText = getHighlightedSnippet(snippet: getDisplayName(), searchText: normalizedSearchText, fontSize: Values.mediumFontSize) - var rawSnippet: String = "" - thread.groupModel.groupMemberIds.forEach { id in - if let displayName = Profile.displayNameNoFallback(for: id, thread: thread) { - if !rawSnippet.isEmpty { - rawSnippet += ", \(displayName)" - } - if displayName.lowercased().contains(normalizedSearchText) { - rawSnippet = displayName - } - } - } - if rawSnippet.isEmpty { - bottomLabelStackView.isHidden = true - } else { - bottomLabelStackView.isHidden = false - snippetLabel.attributedText = getHighlightedSnippet(snippet: rawSnippet, searchText: normalizedSearchText, fontSize: Values.smallFontSize) - } - } else { - displayNameLabel.attributedText = getHighlightedSnippet(snippet: getDisplayNameForSearch(threadViewModel.contactSessionID!), searchText: normalizedSearchText, fontSize: Values.mediumFontSize) - bottomLabelStackView.isHidden = true - } - timestampLabel.isHidden = true - } - } - - private func getHighlightedSnippet(snippet: String, searchText: String, fontSize: CGFloat) -> NSMutableAttributedString { - guard snippet != "NOTE_TO_SELF".localized() else { - return NSMutableAttributedString(string: snippet, attributes: [.foregroundColor:Colors.text]) - } - - let result = NSMutableAttributedString(string: snippet, attributes: [.foregroundColor:Colors.text.withAlphaComponent(Values.lowOpacity)]) - let normalizedSnippet = snippet.lowercased() as NSString - - guard normalizedSnippet.contains(searchText) else { return result } - - let range = normalizedSnippet.range(of: searchText) - result.addAttribute(.foregroundColor, value: Colors.text, range: range) - result.addAttribute(.font, value: UIFont.boldSystemFont(ofSize: fontSize), range: range) - return result - } - - // MARK: - Updating - private func update(_ threadInfo: HomeViewModel.ThreadInfo) { - backgroundColor = (threadInfo.isPinned ? Colors.cellPinned : Colors.cellBackground) + public func updateForContactAndGroupSearchResult(with cellViewModel: ViewModel, searchText: String) { + profilePictureView.update( + publicKey: cellViewModel.threadId, + profile: cellViewModel.profile, + additionalProfile: cellViewModel.additionalProfile, + threadVariant: cellViewModel.threadVariant, + openGroupProfilePicture: cellViewModel.openGroupProfilePictureData.map { UIImage(data: $0) }, + useFallbackPicture: (cellViewModel.threadVariant == .openGroup && cellViewModel.openGroupProfilePictureData == nil) + ) - if threadInfo.isBlocked { + isPinnedIcon.isHidden = true + unreadCountView.isHidden = true + hasMentionView.isHidden = true + timestampLabel.isHidden = true + displayNameLabel.attributedText = getHighlightedSnippet( + content: cellViewModel.displayName, + searchText: searchText.lowercased(), + fontSize: Values.mediumFontSize + ) + + switch cellViewModel.threadVariant { + case .contact, .openGroup: bottomLabelStackView.isHidden = true + + case .closedGroup: + bottomLabelStackView.isHidden = (cellViewModel.threadMemberNames ?? "").isEmpty + snippetLabel.attributedText = getHighlightedSnippet( + content: (cellViewModel.threadMemberNames ?? ""), + searchText: searchText.lowercased(), + fontSize: Values.smallFontSize + ) + } + } + + // MARK: --Standard + + public func update(with cellViewModel: ViewModel) { + let unreadCount: UInt = (cellViewModel.threadUnreadCount ?? 0) + backgroundColor = (cellViewModel.threadIsPinned ? Colors.cellPinned : Colors.cellBackground) + + if cellViewModel.threadIsBlocked == true { accentLineView.backgroundColor = Colors.destructive accentLineView.alpha = 1 } else { accentLineView.backgroundColor = Colors.accent - accentLineView.alpha = (threadInfo.unreadCount > 0 ? 1 : 0.0001) // Setting the alpha to exactly 0 causes an issue on iOS 12 + accentLineView.alpha = (unreadCount > 0 ? 1 : 0.0001) // Setting the alpha to exactly 0 causes an issue on iOS 12 } - isPinnedIcon.isHidden = !threadInfo.isPinned - unreadCountView.isHidden = (threadInfo.unreadCount <= 0) - unreadCountLabel.text = (threadInfo.unreadCount < 10000 ? "\(threadInfo.unreadCount)" : "9999+") + isPinnedIcon.isHidden = !cellViewModel.threadIsPinned + unreadCountView.isHidden = (unreadCount <= 0) + unreadCountLabel.text = (unreadCount < 10000 ? "\(unreadCount)" : "9999+") unreadCountLabel.font = .boldSystemFont( - ofSize: (threadInfo.unreadCount < 10000 ? Values.verySmallFontSize : 8) + ofSize: (unreadCount < 10000 ? Values.verySmallFontSize : 8) ) hasMentionView.isHidden = !( - (threadInfo.unreadMentionCount > 0) && - (threadInfo.variant == .closedGroup || threadInfo.variant == .openGroup) + ((cellViewModel.threadUnreadMentionCount ?? 0) > 0) && + (cellViewModel.threadVariant == .closedGroup || cellViewModel.threadVariant == .openGroup) ) profilePictureView.update( - publicKey: threadInfo.id, - profile: threadInfo.profile, - additionalProfile: threadInfo.additionalProfile, - threadVariant: threadInfo.variant, - openGroupProfilePicture: threadInfo.openGroupProfilePictureData.map { UIImage(data: $0) }, - useFallbackPicture: (threadInfo.variant == .openGroup && threadInfo.openGroupProfilePictureData == nil) + publicKey: cellViewModel.threadId, + profile: cellViewModel.profile, + additionalProfile: cellViewModel.additionalProfile, + threadVariant: cellViewModel.threadVariant, + openGroupProfilePicture: cellViewModel.openGroupProfilePictureData.map { UIImage(data: $0) }, + useFallbackPicture: ( + cellViewModel.threadVariant == .openGroup && + cellViewModel.openGroupProfilePictureData == nil + ) ) - displayNameLabel.text = threadInfo.displayName - timestampLabel.text = DateUtil.formatDate(forDisplay: threadInfo.lastInteractionDate) + displayNameLabel.text = cellViewModel.displayName + timestampLabel.text = DateUtil.formatDate(forDisplay: cellViewModel.lastInteractionDate) - if threadInfo.contactIsTyping { + if cellViewModel.threadContactIsTyping == true { snippetLabel.text = "" typingIndicatorView.isHidden = false typingIndicatorView.startAnimation() } else { - snippetLabel.attributedText = getSnippet(threadInfo: threadInfo) + snippetLabel.attributedText = getSnippet(cellViewModel: cellViewModel) typingIndicatorView.isHidden = true typingIndicatorView.stopAnimation() } statusIndicatorView.backgroundColor = nil - switch (threadInfo.lastInteractionInfo?.variant, threadInfo.lastInteractionInfo?.state) { + switch (cellViewModel.interactionVariant, cellViewModel.interactionState) { case (.standardOutgoing, .sending): statusIndicatorView.image = #imageLiteral(resourceName: "CircleDotDotDot").withRenderingMode(.alwaysTemplate) statusIndicatorView.tintColor = Colors.text @@ -399,40 +369,13 @@ final class ConversationCell: UITableViewCell { statusIndicatorView.isHidden = false } } + + // MARK: - Snippet generation - private func getDisplayNameForSearch(_ sessionID: String) -> String { - if threadViewModel.threadRecord.isNoteToSelf() { - return NSLocalizedString("NOTE_TO_SELF", comment: "") - } - - return [ - Profile.displayName(id: sessionID), - Profile.fetchOrCreate(id: sessionID).nickname.map { "(\($0)" } - ] - .compactMap { $0 } - .joined(separator: " ") - } - - private func getDisplayName(for thread: SessionThread) -> String { - if thread.variant == .closedGroup || thread.variant == .openGroup { - return GRDBStorage.shared.read({ db in thread.name(db) }) - .defaulting(to: "Unknown Group") - } - - if GRDBStorage.shared.read({ db in thread.isNoteToSelf(db) }) == true { - return "NOTE_TO_SELF".localized() - } - - let hexEncodedPublicKey: String = thread.id - let middleTruncatedHexKey: String = "\(hexEncodedPublicKey.prefix(4))...\(hexEncodedPublicKey.suffix(4))" - - return Profile.displayName(id: hexEncodedPublicKey, customFallback: middleTruncatedHexKey) - } - - private func getSnippet(threadInfo: HomeViewModel.ThreadInfo) -> NSMutableAttributedString { + private func getSnippet(cellViewModel: ViewModel) -> NSMutableAttributedString { let result = NSMutableAttributedString() - if Date().timeIntervalSince1970 < (threadInfo.mutedUntilTimestamp ?? 0) { + if Date().timeIntervalSince1970 < (cellViewModel.threadMutedUntilTimestamp ?? 0) { result.append(NSAttributedString( string: "\u{e067} ", attributes: [ @@ -441,7 +384,7 @@ final class ConversationCell: UITableViewCell { ] )) } - else if threadInfo.onlyNotifyForMentions { + else if cellViewModel.threadOnlyNotifyForMentions == true { let imageAttachment = NSTextAttachment() imageAttachment.image = UIImage(named: "NotifyMentions.png")?.asTintedImage(color: Colors.unimportant) imageAttachment.bounds = CGRect(x: 0, y: -2, width: Values.smallFontSize, height: Values.smallFontSize) @@ -457,15 +400,14 @@ final class ConversationCell: UITableViewCell { )) } - let font: UIFont = (threadInfo.unreadCount > 0 ? + let font: UIFont = ((cellViewModel.threadUnreadCount ?? 0) > 0 ? .boldSystemFont(ofSize: Values.smallFontSize) : .systemFont(ofSize: Values.smallFontSize) ) - if - (threadInfo.variant == .closedGroup || threadInfo.variant == .openGroup), - let authorName: String = threadInfo.lastInteractionInfo?.authorName - { + if cellViewModel.threadVariant == .closedGroup || cellViewModel.threadVariant == .openGroup { + let authorName: String = cellViewModel.authorName(for: cellViewModel.threadVariant) + result.append(NSAttributedString( string: "\(authorName): ", attributes: [ @@ -474,20 +416,138 @@ final class ConversationCell: UITableViewCell { ] )) } - - if let rawSnippet: String = threadInfo.lastInteractionInfo?.text { - result.append(NSAttributedString( - string: MentionUtilities.highlightMentions( - in: rawSnippet, - threadVariant: threadInfo.variant + + result.append(NSAttributedString( + string: MentionUtilities.highlightMentions( + in: Interaction.previewText( + variant: (cellViewModel.interactionVariant ?? .standardIncoming), + body: cellViewModel.interactionBody, + authorDisplayName: cellViewModel.authorName(for: cellViewModel.threadVariant), + attachmentDescriptionInfo: cellViewModel.interactionAttachmentDescriptionInfo, + attachmentCount: cellViewModel.interactionAttachmentCount, + isOpenGroupInvitation: (cellViewModel.interactionIsOpenGroupInvitation == true) ), - attributes: [ - .font: font, - .foregroundColor: Colors.text - ] - )) - } + threadVariant: cellViewModel.threadVariant + ), + attributes: [ + .font: font, + .foregroundColor: Colors.text + ] + )) return result } + + private func getHighlightedSnippet( + content: String, + authorName: String? = nil, + searchText: String, + fontSize: CGFloat + ) -> NSAttributedString { + guard !content.isEmpty, content != "NOTE_TO_SELF".localized() else { + return NSMutableAttributedString( + string: (authorName != nil && authorName?.isEmpty != true ? + "\(authorName ?? ""): \(content)" : + content + ), + attributes: [ .foregroundColor: Colors.text ] + ) + } + + // Replace mentions in the content + // + // Note: The 'threadVariant' is used for profile context but in the search results + // we don't want to include the truncated id as part of the name so we exclude it + let mentionReplacedContent: String = MentionUtilities.highlightMentions( + in: content, + threadVariant: .contact + ) + let result: NSMutableAttributedString = NSMutableAttributedString( + string: mentionReplacedContent, + attributes: [ + .foregroundColor: Colors.text + .withAlphaComponent(Values.lowOpacity) + ] + ) + + // Bold each part of the searh term which matched + let normalizedSnippet: String = mentionReplacedContent.lowercased() + var firstMatchRange: Range? + + ConversationCell.ViewModel.searchTermParts(searchText) + .map { part -> String in + guard part.hasPrefix("\"") && part.hasSuffix("\"") else { return part } + + return String(part[part.index(after: part.startIndex).. = normalizedSnippet.range(of: part.lowercased()) + else { return } + + // Store the range of the first match so we can focus it in the content displayed + if firstMatchRange == nil { + firstMatchRange = range + } + + let legacyRange: NSRange = NSRange(range, in: normalizedSnippet) + result.addAttribute(.foregroundColor, value: Colors.text, range: legacyRange) + result.addAttribute(.font, value: UIFont.boldSystemFont(ofSize: fontSize), range: legacyRange) + } + + // We then want to truncate the content so the first metching term is visible + let startOfSnippet: String.Index = ( + firstMatchRange.map { + max( + mentionReplacedContent.startIndex, + mentionReplacedContent + .index( + $0.lowerBound, + offsetBy: -10, + limitedBy: mentionReplacedContent.startIndex + ) + .defaulting(to: mentionReplacedContent.startIndex) + ) + } ?? + mentionReplacedContent.startIndex + ) + + // This method determines if the content is probably too long and returns the truncated or untruncated + // content accordingly + func truncatingIfNeeded(approxWidth: CGFloat, content: NSAttributedString) -> NSAttributedString { + let approxFullWidth: CGFloat = (approxWidth + profilePictureView.size + (Values.mediumSpacing * 3)) + + guard ((bounds.width - approxFullWidth) < 0) else { return content } + + return content.attributedSubstring( + from: NSRange(startOfSnippet.. NSAttributedString? in + guard !authorName.isEmpty else { return nil } + + let authorPrefix: NSAttributedString = NSAttributedString( + string: "\(authorName): ...", + attributes: [ .foregroundColor: Colors.text ] + ) + + return authorPrefix + .appending( + truncatingIfNeeded( + approxWidth: (authorPrefix.size().width + result.size().width), + content: result + ) + ) + } + .defaulting( + to: truncatingIfNeeded( + approxWidth: result.size().width, + content: result + ) + ) + } } diff --git a/Session/Shared/Models/ConversationCellViewModel.swift b/Session/Shared/Models/ConversationCellViewModel.swift new file mode 100644 index 000000000..8ea63d1de --- /dev/null +++ b/Session/Shared/Models/ConversationCellViewModel.swift @@ -0,0 +1,1080 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import DifferenceKit +import SessionMessagingKit + +fileprivate typealias ViewModel = ConversationCell.ViewModel + +extension ConversationCell { + /// This type is used to populate the `ConversationCell` in the `HomeVC`, `MessageRequestsViewController` and the + /// `GlobalSearchViewController`, it has a number of query methods which can be used to retrieve the relevant data for each + /// screen in a single location in an attempt to avoid spreading out _almost_ duplicated code in multiple places + /// + /// **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 + public struct ViewModel: FetchableRecord, Decodable, Equatable, Hashable, Differentiable { + public static let threadIdKey: SQL = SQL(stringLiteral: CodingKeys.threadId.stringValue) + public static let threadVariantKey: SQL = SQL(stringLiteral: CodingKeys.threadVariant.stringValue) + public static let threadCreationDateTimestampKey: SQL = SQL(stringLiteral: CodingKeys.threadCreationDateTimestamp.stringValue) + public static let threadMemberNamesKey: SQL = SQL(stringLiteral: CodingKeys.threadMemberNames.stringValue) + public static let threadIsNoteToSelfKey: SQL = SQL(stringLiteral: CodingKeys.threadIsNoteToSelf.stringValue) + public static let threadIsPinnedKey: SQL = SQL(stringLiteral: CodingKeys.threadIsPinned.stringValue) + public static let threadIsBlockedKey: SQL = SQL(stringLiteral: CodingKeys.threadIsBlocked.stringValue) + public static let threadMutedUntilTimestampKey: SQL = SQL(stringLiteral: CodingKeys.threadMutedUntilTimestamp.stringValue) + public static let threadOnlyNotifyForMentionsKey: SQL = SQL(stringLiteral: CodingKeys.threadOnlyNotifyForMentions.stringValue) + public static let threadContactIsTypingKey: SQL = SQL(stringLiteral: CodingKeys.threadContactIsTyping.stringValue) + public static let threadUnreadCountKey: SQL = SQL(stringLiteral: CodingKeys.threadUnreadCount.stringValue) + public static let threadUnreadMentionCountKey: SQL = SQL(stringLiteral: CodingKeys.threadUnreadMentionCount.stringValue) + public static let contactProfileKey: SQL = SQL(stringLiteral: CodingKeys.contactProfile.stringValue) + public static let closedGroupNameKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupName.stringValue) + public static let currentUserIsClosedGroupAdminKey: SQL = SQL(stringLiteral: CodingKeys.currentUserIsClosedGroupAdmin.stringValue) + public static let closedGroupProfileFrontKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupProfileFront.stringValue) + public static let closedGroupProfileBackKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupProfileBack.stringValue) + public static let closedGroupProfileBackFallbackKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupProfileBackFallback.stringValue) + public static let openGroupNameKey: SQL = SQL(stringLiteral: CodingKeys.openGroupName.stringValue) + public static let openGroupProfilePictureDataKey: SQL = SQL(stringLiteral: CodingKeys.openGroupProfilePictureData.stringValue) + public static let interactionIdKey: SQL = SQL(stringLiteral: CodingKeys.interactionId.stringValue) + public static let interactionVariantKey: SQL = SQL(stringLiteral: CodingKeys.interactionVariant.stringValue) + public static let interactionTimestampMsKey: SQL = SQL(stringLiteral: CodingKeys.interactionTimestampMs.stringValue) + public static let interactionBodyKey: SQL = SQL(stringLiteral: CodingKeys.interactionBody.stringValue) + public static let interactionIsOpenGroupInvitationKey: SQL = SQL(stringLiteral: CodingKeys.interactionIsOpenGroupInvitation.stringValue) + public static let interactionAttachmentDescriptionInfoKey: SQL = SQL(stringLiteral: CodingKeys.interactionAttachmentDescriptionInfo.stringValue) + public static let interactionAttachmentCountKey: SQL = SQL(stringLiteral: CodingKeys.interactionAttachmentCount.stringValue) + public static let authorNameInternalKey: SQL = SQL(stringLiteral: CodingKeys.authorNameInternal.stringValue) + public static let currentUserPublicKeyKey: SQL = SQL(stringLiteral: CodingKeys.currentUserPublicKey.stringValue) + + public static let threadUnreadCountString: String = CodingKeys.threadUnreadCount.stringValue + public static let threadUnreadMentionCountString: String = CodingKeys.threadUnreadMentionCount.stringValue + public static let contactProfileString: String = CodingKeys.contactProfile.stringValue + public static let closedGroupProfileFrontString: String = CodingKeys.closedGroupProfileFront.stringValue + public static let closedGroupProfileBackString: String = CodingKeys.closedGroupProfileBack.stringValue + public static let closedGroupProfileBackFallbackString: String = CodingKeys.closedGroupProfileBackFallback.stringValue + public static let interactionAttachmentDescriptionInfoString: String = CodingKeys.interactionAttachmentDescriptionInfo.stringValue + + public var differenceIdentifier: ViewModel { self } + + public let threadId: String + public let threadVariant: SessionThread.Variant + private let threadCreationDateTimestamp: TimeInterval + public let threadMemberNames: String? + + public let threadIsNoteToSelf: Bool + public let threadIsPinned: Bool + public var threadIsBlocked: Bool? + public let threadMutedUntilTimestamp: TimeInterval? + public let threadOnlyNotifyForMentions: Bool? + + public let threadContactIsTyping: Bool? + public let threadUnreadCount: UInt? + public let threadUnreadMentionCount: UInt? + + // Thread display info + + private let contactProfile: Profile? + private let closedGroupProfileFront: Profile? + private let closedGroupProfileBack: Profile? + private let closedGroupProfileBackFallback: Profile? + public let closedGroupName: String? + public let currentUserIsClosedGroupAdmin: Bool? + public let openGroupName: String? + public let openGroupProfilePictureData: Data? + + // Interaction display info + + public let interactionId: Int64? + public let interactionVariant: Interaction.Variant? + private let interactionTimestampMs: Int64? + public let interactionBody: String? + public let interactionState: RecipientState.State? + public let interactionIsOpenGroupInvitation: Bool? + public let interactionAttachmentDescriptionInfo: Attachment.DescriptionInfo? + public let interactionAttachmentCount: Int? + + public let authorId: String? + private let authorNameInternal: String? + public let currentUserPublicKey: String + + // UI specific logic + + public var displayName: String { + return SessionThread.displayName( + threadId: threadId, + variant: threadVariant, + closedGroupName: closedGroupName, + openGroupName: openGroupName, + isNoteToSelf: threadIsNoteToSelf, + profile: profile + ) + } + + public var profile: Profile? { + switch threadVariant { + case .contact: return contactProfile + case .closedGroup: return (closedGroupProfileBack ?? closedGroupProfileBackFallback) + case .openGroup: return nil + } + } + + public var additionalProfile: Profile? { + switch threadVariant { + case .closedGroup: return closedGroupProfileFront + default: return nil + } + } + + public var lastInteractionDate: Date { + guard let interactionTimestampMs: Int64 = interactionTimestampMs else { + return Date(timeIntervalSince1970: threadCreationDateTimestamp) + } + + return Date(timeIntervalSince1970: (TimeInterval(interactionTimestampMs) / 1000)) + } + + /// This function returns the profile name formatted for the specific type of thread provided + /// + /// **Note:** The 'threadVariant' parameter is used for profile context but in the search results we actually want this + /// to always behave as the `contact` variant which is why this needs to be a function instead of just using the provided + /// parameter + public func authorName(for threadVariant: SessionThread.Variant) -> String { + return Profile.displayName( + for: threadVariant, + id: (authorId ?? threadId), + name: authorNameInternal, + nickname: nil, // Folded into 'authorName' within the Query + customFallback: (threadVariant == .contact ? + "Anonymous" : + nil + ) + ) + } + } +} + +// MARK: - Convenience Initialization + +public extension ConversationCell.ViewModel { + // Note: This init method is only used for the message requests cell on the home screen so we can avoid having + init(unreadCount: UInt) { + self.threadId = "UNREAD_MESSAGE_REQUEST_THREADS" + self.threadVariant = .contact + self.threadCreationDateTimestamp = 0 + self.threadMemberNames = nil + + self.threadIsNoteToSelf = false + self.threadIsPinned = false + self.threadIsBlocked = nil + self.threadMutedUntilTimestamp = nil + self.threadOnlyNotifyForMentions = nil + + self.threadContactIsTyping = nil + self.threadUnreadCount = unreadCount + self.threadUnreadMentionCount = nil + + // Thread display info + + self.contactProfile = nil + self.closedGroupProfileFront = nil + self.closedGroupProfileBack = nil + self.closedGroupProfileBackFallback = nil + self.closedGroupName = nil + self.currentUserIsClosedGroupAdmin = nil + self.openGroupName = nil + self.openGroupProfilePictureData = nil + + // Interaction display info + + self.interactionId = nil + self.interactionVariant = nil + self.interactionTimestampMs = nil + self.interactionBody = nil + self.interactionState = nil + self.interactionIsOpenGroupInvitation = nil + self.interactionAttachmentDescriptionInfo = nil + self.interactionAttachmentCount = nil + + self.authorId = nil + self.authorNameInternal = nil + self.currentUserPublicKey = getUserHexEncodedPublicKey() + } +} + +// MARK: - HomeVC & MessageRequestsViewController + +public extension ConversationCell.ViewModel { + private static func baseQuery( + userPublicKey: String, + filters: SQL, + ordering: SQL + ) -> AdaptedFetchRequest> { + let thread: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() + let typingIndicator: TypedTableAlias = TypedTableAlias() + let closedGroup: TypedTableAlias = TypedTableAlias() + let groupMember: TypedTableAlias = TypedTableAlias() + let openGroup: TypedTableAlias = TypedTableAlias() + let interaction: TypedTableAlias = TypedTableAlias() + let linkPreview: TypedTableAlias = TypedTableAlias() + let interactionAttachment: TypedTableAlias = TypedTableAlias() + let recipientState: TypedTableAlias = TypedTableAlias() + + let unreadCountTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.threadUnreadCountString)_table") + let unreadMentionCountTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.threadUnreadMentionCountString)_table") + let interactionThreadIdLiteral: SQL = SQL(stringLiteral: Interaction.Columns.threadId.name) + let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) + let profileNicknameColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.nickname.name) + let profileNameColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.name.name) + let authorProfileLiteral: SQL = SQL(stringLiteral: "authorProfile") + let attachmentIdColumnLiteral: SQL = SQL(stringLiteral: Attachment.Columns.id.name) + let firstInteractionAttachmentLiteral: SQL = SQL(stringLiteral: "firstInteractionAttachmentLiteral") + let interactionAttachmentAttachmentIdColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.attachmentId.name) + let interactionAttachmentAlbumIndexColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.albumIndex.name) + let interactionStateTableLiteral: SQL = SQL(stringLiteral: "interactionState") + let interactionStateInteractionIdColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.interactionId.name) + let interactionStateStateColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.state.name) + + /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before + /// the `ViewModel.closedGroupProfileFrontKey` entry below otherwise the query will fail to + /// parse and might throw + /// + /// Explicitly set default values for the fields ignored for search results + let numColumnsBeforeProfiles: Int = 11 + let numColumnsBetweenProfilesAndAttachmentInfo: Int = 10 + let request: SQLRequest = """ + SELECT + \(thread[.id]) AS \(ViewModel.threadIdKey), + \(thread[.variant]) AS \(ViewModel.threadVariantKey), + \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), + + (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.threadIsNoteToSelfKey), + \(thread[.isPinned]) AS \(ViewModel.threadIsPinnedKey), + \(contact[.isBlocked]) AS \(ViewModel.threadIsBlockedKey), + \(thread[.mutedUntilTimestamp]) AS \(ViewModel.threadMutedUntilTimestampKey), + \(thread[.onlyNotifyForMentions]) AS \(ViewModel.threadOnlyNotifyForMentionsKey), + + (\(typingIndicator[.threadId]) IS NOT NULL) AS \(ViewModel.threadContactIsTypingKey), + \(unreadCountTableLiteral).\(ViewModel.threadUnreadCountKey) AS \(ViewModel.threadUnreadCountKey), + \(unreadMentionCountTableLiteral).\(ViewModel.threadUnreadMentionCountKey) AS \(ViewModel.threadUnreadMentionCountKey), + + \(ViewModel.contactProfileKey).*, + \(ViewModel.closedGroupProfileFrontKey).*, + \(ViewModel.closedGroupProfileBackKey).*, + \(ViewModel.closedGroupProfileBackFallbackKey).*, + \(closedGroup[.name]) AS \(ViewModel.closedGroupNameKey), + (\(groupMember[.profileId]) IS NOT NULL) AS \(ViewModel.currentUserIsClosedGroupAdminKey), + \(openGroup[.name]) AS \(ViewModel.openGroupNameKey), + \(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey), + + \(interaction[.id]) AS \(ViewModel.interactionIdKey), + \(interaction[.variant]) AS \(ViewModel.interactionVariantKey), + \(interaction[.timestampMs]) AS \(ViewModel.interactionTimestampMsKey), + \(interaction[.body]) AS \(ViewModel.interactionBodyKey), + -- Default to 'sending' assuming non-processed interaction when null + IFNULL(\(interactionStateTableLiteral).\(interactionStateStateColumnLiteral), \(SQL("\(RecipientState.State.sending)"))) AS \(interactionStateTableLiteral), + (\(linkPreview[.url]) IS NOT NULL) AS \(ViewModel.interactionIsOpenGroupInvitationKey), + \(ViewModel.interactionAttachmentDescriptionInfoKey).*, + COUNT(\(interactionAttachment[.interactionId])) AS \(ViewModel.interactionAttachmentCountKey), + + \(interaction[.authorId]), + IFNULL(\(authorProfileLiteral).\(profileNicknameColumnLiteral), \(authorProfileLiteral).\(profileNameColumnLiteral)) AS \(ViewModel.authorNameInternalKey), + \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) + + FROM \(SessionThread.self) + LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) + LEFT JOIN \(ThreadTypingIndicator.self) ON \(typingIndicator[.threadId]) = \(thread[.id]) + LEFT JOIN ( + SELECT *, MAX(\(interaction[.timestampMs])) + FROM \(Interaction.self) + GROUP BY \(interaction[.threadId]) + ) AS \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id]) + LEFT JOIN ( + SELECT + \(interaction[.threadId]), + COUNT(*) AS \(ViewModel.threadUnreadCountKey) + FROM \(Interaction.self) + WHERE \(interaction[.wasRead]) = false + GROUP BY \(interaction[.threadId]) + ) AS \(unreadCountTableLiteral) ON \(unreadCountTableLiteral).\(interactionThreadIdLiteral) = \(thread[.id]) + LEFT JOIN ( + SELECT + \(interaction[.threadId]), + COUNT(*) AS \(ViewModel.threadUnreadMentionCountKey) + FROM \(Interaction.self) + WHERE ( + \(interaction[.wasRead]) = false AND + \(interaction[.hasMention]) = true + ) + GROUP BY \(interaction[.threadId]) + ) AS \(unreadMentionCountTableLiteral) ON \(unreadMentionCountTableLiteral).\(interactionThreadIdLiteral) = \(thread[.id]) + + LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) + LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) + LEFT JOIN \(GroupMember.self) ON ( + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.admin)")) AND + \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(SQL("\(groupMember[.profileId]) = \(userPublicKey)")) + ) + LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(interaction[.threadId]) + + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON ( + \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( + SELECT MIN(\(groupMember[.profileId])) + FROM \(GroupMember.self) + WHERE ( + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND + \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(SQL("\(groupMember[.profileId]) != \(userPublicKey)")) + ) + ) + ) + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON ( + \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) != \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) AND + \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) = ( + SELECT MAX(\(groupMember[.profileId])) + FROM \(GroupMember.self) + WHERE ( + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND + \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(SQL("\(groupMember[.profileId]) != \(userPublicKey)")) + ) + ) + ) + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON ( + \(closedGroup[.threadId]) IS NOT NULL AND + \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) IS NULL AND + \(ViewModel.closedGroupProfileBackFallbackKey).\(profileIdColumnLiteral) = \(SQL("\(userPublicKey)")) + ) + + LEFT JOIN \(Profile.self) AS \(authorProfileLiteral) ON \(authorProfileLiteral).\(profileIdColumnLiteral) = \(interaction[.authorId]) + LEFT JOIN \(LinkPreview.self) ON ( + \(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND + \(SQL("\(linkPreview[.variant]) = \(LinkPreview.Variant.openGroupInvitation)")) AND + \(Interaction.linkPreviewFilterLiteral) + ) + LEFT JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.interactionId]) = \(interaction[.id]) + LEFT JOIN \(InteractionAttachment.self) AS \(firstInteractionAttachmentLiteral) ON ( + \(firstInteractionAttachmentLiteral).\(interactionAttachmentAlbumIndexColumnLiteral) = 0 AND + \(firstInteractionAttachmentLiteral).\(interactionAttachmentAttachmentIdColumnLiteral) = \(interaction[.id]) + ) + LEFT JOIN \(Attachment.self) AS \(ViewModel.interactionAttachmentDescriptionInfoKey) ON \(ViewModel.interactionAttachmentDescriptionInfoKey).\(attachmentIdColumnLiteral) = \(firstInteractionAttachmentLiteral).\(interactionAttachmentAttachmentIdColumnLiteral) + LEFT JOIN ( + SELECT + \(recipientState[.interactionId]), + \(recipientState[.state]) + FROM \(RecipientState.self) + JOIN \(Interaction.self) ON \(interaction[.id]) = \(recipientState[.interactionId]) + WHERE \(SQL("\(recipientState[.state]) != \(RecipientState.State.skipped)")) -- Ignore 'skipped' + ORDER BY + -- If there is a single 'sending' then should be 'sending', otherwise if there is a single + -- 'failed' and there is no 'sending' then it should be 'failed' + \(SQL("\(recipientState[.state]) = \(RecipientState.State.sending)")) DESC, + \(SQL("\(recipientState[.state]) = \(RecipientState.State.failed)")) DESC + ) AS \(interactionStateTableLiteral) ON \(interactionStateTableLiteral).\(interactionStateInteractionIdColumnLiteral) = \(interaction[.id]) + + WHERE ( + \(filters) + ) + + GROUP BY \(thread[.id]) + ORDER BY \(ordering) + """ + return request.adapted { db in + let adapters = try splittingRowAdapters(columnCounts: [ + numColumnsBeforeProfiles, + Profile.numberOfSelectedColumns(db), + Profile.numberOfSelectedColumns(db), + Profile.numberOfSelectedColumns(db), + Profile.numberOfSelectedColumns(db), + numColumnsBetweenProfilesAndAttachmentInfo, + Attachment.DescriptionInfo.numberOfSelectedColumns() + ]) + + return ScopeAdapter([ + ViewModel.contactProfileString: adapters[1], + ViewModel.closedGroupProfileFrontString: adapters[2], + ViewModel.closedGroupProfileBackString: adapters[3], + ViewModel.closedGroupProfileBackFallbackString: adapters[4], + ViewModel.interactionAttachmentDescriptionInfoString: adapters[6] + ]) + } + } + + static func homeQuery(userPublicKey: String) -> AdaptedFetchRequest> { + let thread: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() + let interaction: TypedTableAlias = TypedTableAlias() + + return baseQuery( + userPublicKey: userPublicKey, + filters: """ + \(thread[.shouldBeVisible]) = true AND ( + -- Is not a message request + \(SQL("\(thread[.variant]) != \(SessionThread.Variant.contact)")) OR + \(SQL("\(thread[.id]) = \(userPublicKey)")) OR + \(contact[.isApproved]) = true + ) AND ( + -- Only show the 'Note to Self' thread if it has an interaction + \(SQL("\(thread[.id]) != \(userPublicKey)")) OR + \(interaction[.id]) IS NOT NULL + ) + """, + ordering: """ + \(thread[.isPinned]) DESC, IFNULL(\(interaction[.timestampMs]), \(thread[.creationDateTimestamp])) DESC + """ + ) + } + + static func messageRequestsQuery(userPublicKey: String) -> AdaptedFetchRequest> { + let thread: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() + let interaction: TypedTableAlias = TypedTableAlias() + + return baseQuery( + userPublicKey: userPublicKey, + filters: """ + \(thread[.shouldBeVisible]) = true AND ( + -- Is a message request + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND + \(SQL("\(thread[.id]) != \(userPublicKey)")) AND ( + -- A '!= true' check doesn't work properly so we need to be explicit + \(contact[.isApproved]) IS NULL OR + \(contact[.isApproved]) = false + ) + ) + """, + ordering: """ + IFNULL(\(interaction[.timestampMs]), \(thread[.creationDateTimestamp])) DESC + """ + ) + } +} + +// MARK: - Search Queries + +public extension ConversationCell.ViewModel { + static func searchTermParts(_ searchTerm: String) -> [String] { + /// Process the search term in order to extract the parts of the search pattern we want + /// + /// Step 1 - Keep any "quoted" sections as stand-alone search + /// Step 2 - Separate any words outside of quotes + /// Step 3 - Join the different search term parts with 'OR" (include results for each individual term) + /// Step 4 - Append a wild-card character to the final word + return searchTerm + .split(separator: "\"") + .enumerated() + .flatMap { index, value -> [String] in + guard index % 2 == 1 else { + return String(value) + .split(separator: " ") + .map { String($0) } + } + + return ["\"\(value)\""] + } + .filter { !$0.isEmpty } + } + + static func pattern(_ db: Database, searchTerm: String) throws -> FTS5Pattern { + // Note: FTS doesn't support both prefix/suffix wild cards so don't bother trying to + // add a prefix one + let rawPattern: String = searchTermParts(searchTerm) + .joined(separator: " OR ") + .appending("*") + + /// There are cases where creating a pattern can fail, we want to try and recover from those cases + /// by failling back to simpler patterns if needed + let maybePattern: FTS5Pattern? = (try? db.makeFTS5Pattern(rawPattern: rawPattern, forTable: Interaction.self)) + .defaulting( + to: (try? db.makeFTS5Pattern(rawPattern: searchTerm, forTable: Interaction.self)) + .defaulting(to: FTS5Pattern(matchingAnyTokenIn: searchTerm)) + ) + + guard let pattern: FTS5Pattern = maybePattern else { throw GRDBStorageError.invalidSearchPattern } + + return pattern + } + + static func messagesQuery(userPublicKey: String, pattern: FTS5Pattern) -> AdaptedFetchRequest> { + let interaction: TypedTableAlias = TypedTableAlias() + let thread: TypedTableAlias = TypedTableAlias() + let closedGroup: TypedTableAlias = TypedTableAlias() + let groupMember: TypedTableAlias = TypedTableAlias() + let openGroup: TypedTableAlias = TypedTableAlias() + let profile: TypedTableAlias = TypedTableAlias() + let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) + let interactionLiteral: SQL = SQL(stringLiteral: Interaction.databaseTableName) + let interactionFullTextSearch: SQL = SQL(stringLiteral: Interaction.fullTextSearchTableName) + + /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before + /// the `ViewModel.closedGroupProfileFrontKey` entry below otherwise the query will fail to + /// parse and might throw + /// + /// Explicitly set default values for the fields ignored for search results + let numColumnsBeforeProfiles: Int = 11 + let request: SQLRequest = """ + SELECT + \(thread[.id]) AS \(ViewModel.threadIdKey), + \(thread[.variant]) AS \(ViewModel.threadVariantKey), + \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), + + (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.threadIsNoteToSelfKey), + \(thread[.isPinned]) AS \(ViewModel.threadIsPinnedKey), + + \(ViewModel.contactProfileKey).*, + \(ViewModel.closedGroupProfileFrontKey).*, + \(ViewModel.closedGroupProfileBackKey).*, + \(ViewModel.closedGroupProfileBackFallbackKey).*, + \(closedGroup[.name]) AS \(ViewModel.closedGroupNameKey), + \(openGroup[.name]) AS \(ViewModel.openGroupNameKey), + \(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey), + + \(interaction[.id]) AS \(ViewModel.interactionIdKey), + \(interaction[.variant]) AS \(ViewModel.interactionVariantKey), + \(interaction[.timestampMs]) AS \(ViewModel.interactionTimestampMsKey), + \(interaction[.body]) AS \(ViewModel.interactionBodyKey), + + \(interaction[.authorId]), + IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.authorNameInternalKey), + \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) + + FROM \(Interaction.self) + JOIN \(interactionFullTextSearch) ON ( + \(interactionFullTextSearch).rowid = \(interactionLiteral).rowid AND + \(interactionFullTextSearch).\(SQL(stringLiteral: Interaction.Columns.body.name)) MATCH \(pattern) + ) + JOIN \(SessionThread.self) ON \(thread[.id]) = \(interaction[.threadId]) + JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId]) + LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(interaction[.threadId]) + LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(interaction[.threadId]) + LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(interaction[.threadId]) + + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON ( + \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( + SELECT MIN(\(groupMember[.profileId])) + FROM \(GroupMember.self) + WHERE ( + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND + \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(groupMember[.profileId]) != \(userPublicKey) + ) + ) + ) + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON ( + \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) != \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) AND + \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) = ( + SELECT MAX(\(groupMember[.profileId])) + FROM \(GroupMember.self) + WHERE ( + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND + \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(groupMember[.profileId]) != \(userPublicKey) + ) + ) + ) + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON ( + \(closedGroup[.threadId]) IS NOT NULL AND + \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) IS NULL AND + \(ViewModel.closedGroupProfileBackFallbackKey).\(profileIdColumnLiteral) = \(userPublicKey) + ) + + ORDER BY rank + """ + + return request.adapted { db in + let adapters = try splittingRowAdapters(columnCounts: [ + numColumnsBeforeProfiles, + Profile.numberOfSelectedColumns(db), + Profile.numberOfSelectedColumns(db), + Profile.numberOfSelectedColumns(db), + Profile.numberOfSelectedColumns(db) + ]) + + return ScopeAdapter([ + ViewModel.contactProfileString: adapters[1], + ViewModel.closedGroupProfileFrontString: adapters[2], + ViewModel.closedGroupProfileBackString: adapters[3], + ViewModel.closedGroupProfileBackFallbackString: adapters[4] + ]) + } + } + + /// This method does an FTS search against threads and their contacts to find any which contain the pattern + /// + /// **Note:** Unfortunately the FTS search only allows for a single pattern match per query which means we + /// need to combine the results of **all** of the following potential matches as unioned queries: + /// - Contact thread contact nickname + /// - Contact thread contact name + /// - Closed group name + /// - Closed group member nickname + /// - Closed group member name + /// - Open group name + /// - "Note to self" text match + static func contactsAndGroupsQuery(userPublicKey: String, pattern: FTS5Pattern, searchTerm: String) -> AdaptedFetchRequest> { + let thread: TypedTableAlias = TypedTableAlias() + let closedGroup: TypedTableAlias = TypedTableAlias() + let groupMember: TypedTableAlias = TypedTableAlias() + let openGroup: TypedTableAlias = TypedTableAlias() + let profile: TypedTableAlias = TypedTableAlias() + let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) + let profileNicknameColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.nickname.name) + let profileNameColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.name.name) + + let profileFullTextSearch: SQL = SQL(stringLiteral: Profile.fullTextSearchTableName) + let closedGroupNameColumnLiteral: SQL = SQL(stringLiteral: ClosedGroup.Columns.name.name) + let closedGroupLiteral: SQL = SQL(stringLiteral: ClosedGroup.databaseTableName) + let closedGroupFullTextSearch: SQL = SQL(stringLiteral: ClosedGroup.fullTextSearchTableName) + let openGroupNameColumnLiteral: SQL = SQL(stringLiteral: OpenGroup.Columns.name.name) + let openGroupLiteral: SQL = SQL(stringLiteral: OpenGroup.databaseTableName) + let openGroupFullTextSearch: SQL = SQL(stringLiteral: OpenGroup.fullTextSearchTableName) + let groupMemberInfoLiteral: SQL = SQL(stringLiteral: "groupMemberInfo") + let groupMemberGroupIdColumnLiteral: SQL = SQL(stringLiteral: GroupMember.Columns.groupId.name) + let groupMemberProfileLiteral: SQL = SQL(stringLiteral: "groupMemberProfile") + let noteToSelfLiteral: SQL = SQL(stringLiteral: "NOTE_TO_SELF".localized().lowercased()) + let searchTermLiteral: SQL = SQL(stringLiteral: searchTerm.lowercased()) + + /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before + /// the `ViewModel.closedGroupProfileFrontKey` entry below otherwise the query will fail to + /// parse and might throw + /// + /// We use `IFNULL(rank, 100)` because the custom `Note to Self` like comparison will get a null + /// `rank` value which ends up as the first result, by defaulting to `100` it will always be ranked last compared + /// to any relevance-based results + let numColumnsBeforeProfiles: Int = 7 + var sqlQuery: SQL = "" + let selectQuery: SQL = """ + SELECT + IFNULL(rank, 100) AS rank, + + \(thread[.id]) AS \(ViewModel.threadIdKey), + \(thread[.variant]) AS \(ViewModel.threadVariantKey), + \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), + \(groupMemberInfoLiteral).\(ViewModel.threadMemberNamesKey), + + (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.threadIsNoteToSelfKey), + \(thread[.isPinned]) AS \(ViewModel.threadIsPinnedKey), + + \(ViewModel.contactProfileKey).*, + \(ViewModel.closedGroupProfileFrontKey).*, + \(ViewModel.closedGroupProfileBackKey).*, + \(ViewModel.closedGroupProfileBackFallbackKey).*, + \(closedGroup[.name]) AS \(ViewModel.closedGroupNameKey), + \(openGroup[.name]) AS \(ViewModel.openGroupNameKey), + \(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey), + + \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) + + FROM \(SessionThread.self) + + """ + + // Contact thread nickname searching (ignoring note to self - handled separately) + sqlQuery += selectQuery + sqlQuery += """ + JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) + JOIN \(profileFullTextSearch) ON ( + \(profileFullTextSearch).rowid = \(ViewModel.contactProfileKey).rowid AND + \(profileFullTextSearch).\(profileNicknameColumnLiteral) MATCH \(pattern) + ) + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON false + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON false + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON false + LEFT JOIN \(ClosedGroup.self) ON false + LEFT JOIN \(OpenGroup.self) ON false + LEFT JOIN ( + SELECT + \(groupMember[.groupId]), + '' AS \(ViewModel.threadMemberNamesKey) + FROM \(GroupMember.self) + ) AS \(groupMemberInfoLiteral) ON false + + WHERE + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND + \(SQL("\(thread[.id]) != \(userPublicKey)")) + GROUP BY \(thread[.id]) + """ + + // Contact thread name searching (ignoring note to self - handled separately) + sqlQuery += """ + + UNION ALL + + """ + sqlQuery += selectQuery + sqlQuery += """ + JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) + JOIN \(profileFullTextSearch) ON ( + \(profileFullTextSearch).rowid = \(ViewModel.contactProfileKey).rowid AND + \(profileFullTextSearch).\(profileNameColumnLiteral) MATCH \(pattern) + ) + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON false + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON false + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON false + LEFT JOIN \(ClosedGroup.self) ON false + LEFT JOIN \(OpenGroup.self) ON false + LEFT JOIN ( + SELECT + \(groupMember[.groupId]), + '' AS \(ViewModel.threadMemberNamesKey) + FROM \(GroupMember.self) + ) AS \(groupMemberInfoLiteral) ON false + + WHERE + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND + \(SQL("\(thread[.id]) != \(userPublicKey)")) + GROUP BY \(thread[.id]) + """ + + // Closed group thread name searching + sqlQuery += """ + + UNION ALL + + """ + sqlQuery += selectQuery + sqlQuery += """ + JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) + JOIN \(GroupMember.self) ON ( + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND + \(groupMember[.groupId]) = \(thread[.id]) + ) + JOIN \(closedGroupFullTextSearch) ON ( + \(closedGroupFullTextSearch).rowid = \(closedGroupLiteral).rowid AND + \(closedGroupFullTextSearch).\(closedGroupNameColumnLiteral) MATCH \(pattern) + ) + LEFT JOIN ( + SELECT + \(groupMember[.groupId]), + GROUP_CONCAT(IFNULL(\(profile[.nickname]), \(profile[.name])), ', ') AS \(ViewModel.threadMemberNamesKey) + FROM \(GroupMember.self) + JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) + WHERE \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) + GROUP BY \(groupMember[.groupId]) + ) AS \(groupMemberInfoLiteral) ON \(groupMemberInfoLiteral).\(groupMemberGroupIdColumnLiteral) = \(closedGroup[.threadId]) + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON ( + \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( + SELECT MIN(\(groupMember[.profileId])) + FROM \(GroupMember.self) + WHERE ( + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND + \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(groupMember[.profileId]) != \(userPublicKey) + ) + ) + ) + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON ( + \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) != \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) AND + \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) = ( + SELECT MAX(\(groupMember[.profileId])) + FROM \(GroupMember.self) + WHERE ( + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND + \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(groupMember[.profileId]) != \(userPublicKey) + ) + ) + ) + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON ( + \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) IS NULL AND + \(ViewModel.closedGroupProfileBackFallbackKey).\(profileIdColumnLiteral) = \(userPublicKey) + ) + + LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON false + LEFT JOIN \(OpenGroup.self) ON false + + WHERE \(SQL("\(thread[.variant]) = \(SessionThread.Variant.closedGroup)")) + GROUP BY \(thread[.id]) + """ + + // Closed group member nickname searching + sqlQuery += """ + + UNION ALL + + """ + sqlQuery += selectQuery + sqlQuery += """ + JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) + JOIN \(GroupMember.self) ON ( + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND + \(groupMember[.groupId]) = \(thread[.id]) + ) + JOIN \(Profile.self) AS \(groupMemberProfileLiteral) ON \(groupMemberProfileLiteral).\(profileIdColumnLiteral) = \(groupMember[.profileId]) + JOIN \(profileFullTextSearch) ON ( + \(profileFullTextSearch).rowid = \(groupMemberProfileLiteral).rowid AND + \(profileFullTextSearch).\(profileNicknameColumnLiteral) MATCH \(pattern) + ) + LEFT JOIN ( + SELECT + \(groupMember[.groupId]), + GROUP_CONCAT(IFNULL(\(profile[.nickname]), \(profile[.name])), ', ') AS \(ViewModel.threadMemberNamesKey) + FROM \(GroupMember.self) + JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) + WHERE \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) + GROUP BY \(groupMember[.groupId]) + ) AS \(groupMemberInfoLiteral) ON \(groupMemberInfoLiteral).\(groupMemberGroupIdColumnLiteral) = \(closedGroup[.threadId]) + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON ( + \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( + SELECT MIN(\(groupMember[.profileId])) + FROM \(GroupMember.self) + WHERE ( + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND + \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(groupMember[.profileId]) != \(userPublicKey) + ) + ) + ) + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON ( + \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) != \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) AND + \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) = ( + SELECT MAX(\(groupMember[.profileId])) + FROM \(GroupMember.self) + WHERE ( + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND + \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(groupMember[.profileId]) != \(userPublicKey) + ) + ) + ) + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON ( + \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) IS NULL AND + \(ViewModel.closedGroupProfileBackFallbackKey).\(profileIdColumnLiteral) = \(userPublicKey) + ) + + LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON false + LEFT JOIN \(OpenGroup.self) ON false + + WHERE \(SQL("\(thread[.variant]) = \(SessionThread.Variant.closedGroup)")) + GROUP BY \(thread[.id]) + """ + + // Closed group member name searching + sqlQuery += """ + + UNION ALL + + """ + sqlQuery += selectQuery + sqlQuery += """ + JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) + JOIN \(GroupMember.self) ON ( + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND + \(groupMember[.groupId]) = \(thread[.id]) + ) + JOIN \(Profile.self) AS \(groupMemberProfileLiteral) ON \(groupMemberProfileLiteral).\(profileIdColumnLiteral) = \(groupMember[.profileId]) + JOIN \(profileFullTextSearch) ON ( + \(profileFullTextSearch).rowid = \(groupMemberProfileLiteral).rowid AND + \(profileFullTextSearch).\(profileNameColumnLiteral) MATCH \(pattern) + ) + LEFT JOIN ( + SELECT + \(groupMember[.groupId]), + GROUP_CONCAT(IFNULL(\(profile[.nickname]), \(profile[.name])), ', ') AS \(ViewModel.threadMemberNamesKey) + FROM \(GroupMember.self) + JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) + WHERE \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) + GROUP BY \(groupMember[.groupId]) + ) AS \(groupMemberInfoLiteral) ON \(groupMemberInfoLiteral).\(groupMemberGroupIdColumnLiteral) = \(closedGroup[.threadId]) + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON ( + \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( + SELECT MIN(\(groupMember[.profileId])) + FROM \(GroupMember.self) + WHERE ( + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND + \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(groupMember[.profileId]) != \(userPublicKey) + ) + ) + ) + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON ( + \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) != \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) AND + \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) = ( + SELECT MAX(\(groupMember[.profileId])) + FROM \(GroupMember.self) + WHERE ( + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND + \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(groupMember[.profileId]) != \(userPublicKey) + ) + ) + ) + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON ( + \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) IS NULL AND + \(ViewModel.closedGroupProfileBackFallbackKey).\(profileIdColumnLiteral) = \(userPublicKey) + ) + + LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON false + LEFT JOIN \(OpenGroup.self) ON false + + WHERE \(SQL("\(thread[.variant]) = \(SessionThread.Variant.closedGroup)")) + GROUP BY \(thread[.id]) + """ + + // Open group thread name searching + sqlQuery += """ + + UNION ALL + + """ + sqlQuery += selectQuery + sqlQuery += """ + JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) + JOIN \(openGroupFullTextSearch) ON ( + \(openGroupFullTextSearch).rowid = \(openGroupLiteral).rowid AND + \(openGroupFullTextSearch).\(openGroupNameColumnLiteral) MATCH \(pattern) + ) + LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON false + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON false + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON false + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON false + LEFT JOIN \(ClosedGroup.self) ON false + LEFT JOIN ( + SELECT + \(groupMember[.groupId]), + '' AS \(ViewModel.threadMemberNamesKey) + FROM \(GroupMember.self) + ) AS \(groupMemberInfoLiteral) ON false + + WHERE + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.openGroup)")) AND + \(SQL("\(thread[.id]) != \(userPublicKey)")) + GROUP BY \(thread[.id]) + """ + + // Note to self thread searching for 'Note to Self' (need to join an FTS table to + // ensure there is a 'rank' column) + sqlQuery += """ + + UNION ALL + + """ + sqlQuery += selectQuery + sqlQuery += """ + JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) + LEFT JOIN \(profileFullTextSearch) ON false + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON false + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON false + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON false + LEFT JOIN \(OpenGroup.self) ON false + LEFT JOIN \(ClosedGroup.self) ON false + LEFT JOIN ( + SELECT + \(groupMember[.groupId]), + '' AS \(ViewModel.threadMemberNamesKey) + FROM \(GroupMember.self) + ) AS \(groupMemberInfoLiteral) ON false + + WHERE + \(SQL("\(thread[.id]) = \(userPublicKey)")) AND + '\(noteToSelfLiteral)' LIKE '%\(searchTermLiteral)%' + """ + + // Note to self thread nickname searching + sqlQuery += """ + + UNION ALL + + """ + sqlQuery += selectQuery + sqlQuery += """ + JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) + JOIN \(profileFullTextSearch) ON ( + \(profileFullTextSearch).rowid = \(ViewModel.contactProfileKey).rowid AND + \(profileFullTextSearch).\(profileNicknameColumnLiteral) MATCH \(pattern) + ) + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON false + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON false + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON false + LEFT JOIN \(ClosedGroup.self) ON false + LEFT JOIN \(OpenGroup.self) ON false + LEFT JOIN ( + SELECT + \(groupMember[.groupId]), + '' AS \(ViewModel.threadMemberNamesKey) + FROM \(GroupMember.self) + ) AS \(groupMemberInfoLiteral) ON false + + WHERE \(SQL("\(thread[.id]) = \(userPublicKey)")) + """ + + // Note to self thread name searching + sqlQuery += """ + + UNION ALL + + """ + sqlQuery += selectQuery + sqlQuery += """ + JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) + JOIN \(profileFullTextSearch) ON ( + \(profileFullTextSearch).rowid = \(ViewModel.contactProfileKey).rowid AND + \(profileFullTextSearch).\(profileNameColumnLiteral) MATCH \(pattern) + ) + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON false + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON false + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON false + LEFT JOIN \(ClosedGroup.self) ON false + LEFT JOIN \(OpenGroup.self) ON false + LEFT JOIN ( + SELECT + \(groupMember[.groupId]), + '' AS \(ViewModel.threadMemberNamesKey) + FROM \(GroupMember.self) + ) AS \(groupMemberInfoLiteral) ON false + + WHERE \(SQL("\(thread[.id]) = \(userPublicKey)")) + """ + + // Group everything by 'threadId' (the same thread can be found in multiple queries due + // to seaerching both nickname and name), then order everything by 'rank' (relevance) + // first, 'Note to Self' second (want it to appear at the bottom of threads unless it + // has relevance) adn then try to group and sort based on thread type and names + let finalQuery: SQL = """ + SELECT * + FROM ( + \(sqlQuery) + ) + + GROUP BY \(ViewModel.threadIdKey) + ORDER BY + rank, + \(ViewModel.threadIsNoteToSelfKey), + \(ViewModel.closedGroupNameKey), + \(ViewModel.openGroupNameKey), + \(ViewModel.threadIdKey) + """ + + // Construct the actual request + let request: SQLRequest = SQLRequest( + literal: finalQuery, + adapter: RenameColumnAdapter { column in + // Note: The query automatically adds a suffix to the various profile columns + // to make them easier to distinguish (ie. 'id' -> 'id:1') - this breaks the + // decoding so we need to strip the information after the colon + guard column.contains(":") else { return column } + + return String(column.split(separator: ":")[0]) + }, + cached: false + ) + + // Add adapters which will group the various 'Profile' columns so they can be decoded + // as instances of 'Profile' types + return request.adapted { db in + let adapters = try splittingRowAdapters(columnCounts: [ + numColumnsBeforeProfiles, + Profile.numberOfSelectedColumns(db), + Profile.numberOfSelectedColumns(db), + Profile.numberOfSelectedColumns(db), + Profile.numberOfSelectedColumns(db) + ]) + + return ScopeAdapter([ + ViewModel.contactProfileString: adapters[1], + ViewModel.closedGroupProfileFrontString: adapters[2], + ViewModel.closedGroupProfileBackString: adapters[3], + ViewModel.closedGroupProfileBackFallbackString: adapters[4] + ]) + } + } +} diff --git a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift index b2d4a0f98..70c68de09 100644 --- a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -8,6 +8,10 @@ enum _001_InitialSetupMigration: Migration { static let identifier: String = "initialSetup" static func migrate(_ db: Database) throws { + // Define the tokenizer to be used in all the FTS tables + // https://github.com/groue/GRDB.swift/blob/master/Documentation/FullTextSearch.md#fts5-tokenizers + let fullTextSearchTokenizer: FTS5TokenizerDescriptor = .porter(wrapping: .unicode61()) + try db.create(table: Contact.self) { t in t.column(.id, .text) .notNull() @@ -40,6 +44,15 @@ enum _001_InitialSetupMigration: Migration { t.column(.profileEncryptionKey, .blob) } + /// Create a full-text search table synchronized with the Profile table + try db.create(virtualTable: Profile.fullTextSearchTableName, using: FTS5()) { t in + t.synchronize(withTable: Profile.databaseTableName) + t.tokenizer = fullTextSearchTokenizer + + t.column(Profile.Columns.nickname.name) + t.column(Profile.Columns.name.name) + } + try db.create(table: SessionThread.self) { t in t.column(.id, .text) .notNull() @@ -78,6 +91,14 @@ enum _001_InitialSetupMigration: Migration { t.column(.formationTimestamp, .double).notNull() } + /// Create a full-text search table synchronized with the ClosedGroup table + try db.create(virtualTable: ClosedGroup.fullTextSearchTableName, using: FTS5()) { t in + t.synchronize(withTable: ClosedGroup.databaseTableName) + t.tokenizer = fullTextSearchTokenizer + + t.column(ClosedGroup.Columns.name.name) + } + try db.create(table: ClosedGroupKeyPair.self) { t in t.column(.threadId, .text) .notNull() @@ -108,6 +129,14 @@ enum _001_InitialSetupMigration: Migration { t.column(.infoUpdates, .integer).notNull() } + /// Create a full-text search table synchronized with the OpenGroup table + try db.create(virtualTable: OpenGroup.fullTextSearchTableName, using: FTS5()) { t in + t.synchronize(withTable: OpenGroup.databaseTableName) + t.tokenizer = fullTextSearchTokenizer + + t.column(OpenGroup.Columns.name.name) + } + try db.create(table: Capability.self) { t in t.column(.openGroupId, .text) .notNull() @@ -190,6 +219,14 @@ enum _001_InitialSetupMigration: Migration { t.uniqueKey([.threadId, .openGroupServerMessageId]) } + /// Create a full-text search table synchronized with the Interaction table + try db.create(virtualTable: Interaction.fullTextSearchTableName, using: FTS5()) { t in + t.synchronize(withTable: Interaction.databaseTableName) + t.tokenizer = fullTextSearchTokenizer + + t.column(Interaction.Columns.body.name) + } + try db.create(table: RecipientState.self) { t in t.column(.interactionId, .integer) .notNull() @@ -241,6 +278,7 @@ enum _001_InitialSetupMigration: Migration { } try db.create(table: InteractionAttachment.self) { t in + t.column(.albumIndex, .integer).notNull() t.column(.interactionId, .integer) .notNull() .indexed() // Quicker querying diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index ea638d918..6fabd0501 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -210,6 +210,8 @@ enum _003_YDBToGRDBMigration: Migration { // Insert the data into GRDB + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) + // MARK: - Insert Contacts try autoreleasepool { @@ -374,28 +376,33 @@ enum _003_YDBToGRDBMigration: Migration { ).insert(db) } - try groupModel.groupMemberIds.forEach { memberId in - try GroupMember( - groupId: threadId, - profileId: memberId, - role: .standard - ).insert(db) - } - - try groupModel.groupAdminIds.forEach { adminId in - try GroupMember( - groupId: threadId, - profileId: adminId, - role: .admin - ).insert(db) - } - - try (closedGroupZombieMemberIds[legacyThreadId] ?? []).forEach { zombieId in - try GroupMember( - groupId: threadId, - profileId: zombieId, - role: .zombie - ).insert(db) + // Only create the 'GroupMember' models if the current user is actually a member + // of the group (if the user has left the group or been removed from it we now + // delete all of these records so want this to behave the same way) + if groupModel.groupMemberIds.contains(currentUserPublicKey) { + try groupModel.groupMemberIds.forEach { memberId in + try GroupMember( + groupId: threadId, + profileId: memberId, + role: .standard + ).insert(db) + } + + try groupModel.groupAdminIds.forEach { adminId in + try GroupMember( + groupId: threadId, + profileId: adminId, + role: .admin + ).insert(db) + } + + try (closedGroupZombieMemberIds[legacyThreadId] ?? []).forEach { zombieId in + try GroupMember( + groupId: threadId, + profileId: zombieId, + role: .zombie + ).insert(db) + } } } @@ -421,8 +428,6 @@ enum _003_YDBToGRDBMigration: Migration { } try autoreleasepool { - let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) - try interactions[legacyThreadId]? .sorted(by: { lhs, rhs in lhs.timestamp < rhs.timestamp }) // Maintain sort order .forEach { legacyInteraction in @@ -785,7 +790,7 @@ enum _003_YDBToGRDBMigration: Migration { // Handle any attachments - try attachmentIds.forEach { legacyAttachmentId in + try attachmentIds.enumerated().forEach { index, legacyAttachmentId in guard let attachmentId: String = try attachmentId( db, for: legacyAttachmentId, @@ -799,6 +804,7 @@ enum _003_YDBToGRDBMigration: Migration { // Link the attachment to the interaction and add to the id lookup try InteractionAttachment( + albumIndex: index, interactionId: interactionId, attachmentId: attachmentId ).insert(db) diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index d526532ab..1187a43af 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -245,16 +245,55 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR // MARK: - CustomStringConvertible extension Attachment: CustomStringConvertible { - public static func description(for variant: Variant, contentType: String, sourceFilename: String?) -> String { - if MIMETypeUtil.isAudio(contentType) { + public struct DescriptionInfo: FetchableRecord, Decodable, Equatable, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case variant + case contentType + case sourceFilename + } + + let variant: Attachment.Variant + let contentType: String + let sourceFilename: String? + + public init( + variant: Attachment.Variant, + contentType: String, + sourceFilename: String? + ) { + self.variant = variant + self.contentType = contentType + self.sourceFilename = sourceFilename + } + } + + public static func description(for descriptionInfo: DescriptionInfo?, count: Int?) -> String? { + guard let descriptionInfo: DescriptionInfo = descriptionInfo else { + return nil + } + + return description(for: descriptionInfo, count: (count ?? 1)) + } + + public static func description(for descriptionInfo: DescriptionInfo, count: Int) -> String { + // We only support multi-attachment sending of images so we can just default to the image attachment + // if there were multiple attachments + guard count == 1 else { return "\("ATTACHMENT".localized()) \(emoji(for: OWSMimeTypeImageJpeg))" } + + if MIMETypeUtil.isAudio(descriptionInfo.contentType) { // a missing filename is the legacy way to determine if an audio attachment is // a voice note vs. other arbitrary audio attachments. - if variant == .voiceMessage || sourceFilename == nil || (sourceFilename?.count ?? 0) == 0 { + if + descriptionInfo.variant == .voiceMessage || + descriptionInfo.sourceFilename == nil || + (descriptionInfo.sourceFilename?.count ?? 0) == 0 + { return "🎙️ \("ATTACHMENT_TYPE_VOICE_MESSAGE".localized())" } } - return "\("ATTACHMENT".localized()) \(emoji(for: contentType))" + return "\("ATTACHMENT".localized()) \(emoji(for: descriptionInfo.contentType))" } public static func emoji(for contentType: String) -> String { @@ -276,17 +315,20 @@ extension Attachment: CustomStringConvertible { public var description: String { return Attachment.description( - for: variant, - contentType: contentType, - sourceFilename: sourceFilename + for: DescriptionInfo( + variant: variant, + contentType: contentType, + sourceFilename: sourceFilename + ), + count: 1 ) } } // MARK: - Mutation -public extension Attachment { - func with( +extension Attachment { + public func with( serverId: String? = nil, state: State? = nil, creationTimestamp: TimeInterval? = nil, @@ -337,8 +379,8 @@ public extension Attachment { // MARK: - Protobuf -public extension Attachment { - init(proto: SNProtoAttachmentPointer) { +extension Attachment { + public init(proto: SNProtoAttachmentPointer) { func inferContentType(from filename: String?) -> String { guard let fileName: String = filename, @@ -382,7 +424,7 @@ public extension Attachment { self.caption = (proto.hasCaption ? proto.caption : nil) } - func buildProto() -> SNProtoAttachmentPointer? { + public func buildProto() -> SNProtoAttachmentPointer? { guard let serverId: UInt64 = UInt64(self.serverId ?? "") else { return nil } let builder = SNProtoAttachmentPointer.builder(id: serverId) @@ -435,14 +477,14 @@ public extension Attachment { // MARK: - GRDB Interactions -public extension Attachment { - struct StateInfo: FetchableRecord, Decodable { +extension Attachment { + public struct StateInfo: FetchableRecord, Decodable { public let attachmentId: String public let interactionId: Int64 public let state: Attachment.State } - static func stateInfo(authorId: String, state: State? = nil) -> SQLRequest { + public static func stateInfo(authorId: String, state: State? = nil) -> SQLRequest { let attachment: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() let quote: TypedTableAlias = TypedTableAlias() @@ -487,7 +529,7 @@ public extension Attachment { """ } - static func stateInfo(interactionId: Int64, state: State? = nil) -> SQLRequest { + public static func stateInfo(interactionId: Int64, state: State? = nil) -> SQLRequest { let attachment: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() let quote: TypedTableAlias = TypedTableAlias() @@ -533,7 +575,7 @@ public extension Attachment { // MARK: - Convenience - Static -public extension Attachment { +extension Attachment { private static let thumbnailDimensionSmall: UInt = 200 private static let thumbnailDimensionMedium: UInt = 450 @@ -588,7 +630,7 @@ public extension Attachment { return NSData.imageSize(forFilePath: originalFilePath, mimeType: contentType) } - static func videoStillImage(filePath: String) -> UIImage? { + public static func videoStillImage(filePath: String) -> UIImage? { return try? OWSMediaUtils.thumbnail( forVideoAtPath: filePath, maxDimension: CGFloat(Attachment.thumbnailDimensionLarge) diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 01c2267fd..98b18e56a 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -631,57 +631,19 @@ public extension Interaction { let sourceFilename: String? } - var targetBody: String? = self.body - - if self.body == nil || self.body?.isEmpty == true { - let maybeTextInfo: AttachmentDescriptionInfo? = try? AttachmentDescriptionInfo - .fetchOne( - db, - attachments - .select(.id, .state, .variant, .contentType, .sourceFilename) - .filter(Attachment.Columns.contentType == OWSMimeTypeOversizeTextMessage) - .filter(Attachment.Columns.state == Attachment.State.downloaded) - ) - - if - let textInfo: AttachmentDescriptionInfo = maybeTextInfo, - let filePath: String = Attachment.originalFilePath( - id: textInfo.id, - mimeType: textInfo.contentType, - sourceFilename: textInfo.sourceFilename - ), - let data: Data = try? Data(contentsOf: URL(fileURLWithPath: filePath)), - let dataString: String = String(data: data, encoding: .utf8) - { - targetBody = dataString.filterForDisplay - } - } - - let attachmentDescription: String? = try? AttachmentDescriptionInfo - .fetchOne( - db, - attachments - .select(.id, .variant, .contentType, .sourceFilename) - .filter(Attachment.Columns.contentType != OWSMimeTypeOversizeTextMessage) - ) - .map { info -> String in - Attachment.description( - for: info.variant, - contentType: info.contentType, - sourceFilename: info.sourceFilename - ) - } - let isOpenGroupInvitation: Bool = (try? linkPreview - .filter(LinkPreview.Columns.variant == LinkPreview.Variant.openGroupInvitation) - .isNotEmpty(db)) - .defaulting(to: false) return Interaction.previewText( variant: self.variant, - body: targetBody, - attachments: [], - customAttachmentDescription: attachmentDescription, - isOpenGroupInvitation: isOpenGroupInvitation + body: self.body, + attachmentDescriptionInfo: try? attachments + .select(.variant, .contentType, .sourceFilename) + .asRequest(of: Attachment.DescriptionInfo.self) + .fetchOne(db), + attachmentCount: try? attachments.fetchCount(db), + isOpenGroupInvitation: (try? linkPreview + .filter(LinkPreview.Columns.variant == LinkPreview.Variant.openGroupInvitation) + .isNotEmpty(db)) + .defaulting(to: false) ) case .infoMediaSavedNotification, .infoScreenshotNotification: @@ -690,14 +652,12 @@ public extension Interaction { return Interaction.previewText( variant: self.variant, body: self.body, - authorDisplayName: Profile.displayName(db, id: threadId), - attachments: [] + authorDisplayName: Profile.displayName(db, id: threadId) ) default: return Interaction.previewText( variant: self.variant, - body: self.body, - attachments: [] + body: self.body ) } } @@ -707,50 +667,34 @@ public extension Interaction { variant: Variant, body: String?, authorDisplayName: String = "", - attachments: [Attachment], - customAttachmentDescription: String? = nil, + attachmentDescriptionInfo: Attachment.DescriptionInfo? = nil, + attachmentCount: Int? = nil, isOpenGroupInvitation: Bool = false ) -> String { switch variant { case .standardIncomingDeleted: return "" case .standardIncoming, .standardOutgoing: - var bodyDescription: String? - let attachmentDescription: String? = (customAttachmentDescription ?? attachments - .first(where: { $0.contentType != OWSMimeTypeOversizeTextMessage })? - .description + let attachmentDescription: String? = Attachment.description( + for: attachmentDescriptionInfo, + count: attachmentCount ) - if let body: String = body, !body.isEmpty { - bodyDescription = body - } - else if - let textAttachment: Attachment = attachments.first(where: { attachment in - attachment.state == .downloaded && - attachment.contentType == OWSMimeTypeOversizeTextMessage - }), - let filePath: String = textAttachment.originalFilePath, - let data: Data = try? Data(contentsOf: URL(fileURLWithPath: filePath)), - let dataString: String = String(data: data, encoding: .utf8) - { - bodyDescription = dataString.filterForDisplay - } - if let attachmentDescription: String = attachmentDescription, - let bodyDescription: String = bodyDescription, + let body: String = body, !attachmentDescription.isEmpty, - !bodyDescription.isEmpty + !body.isEmpty { if CurrentAppContext().isRTL { - return "\(bodyDescription): \(attachmentDescription)" + return "\(body): \(attachmentDescription)" } - return "\(attachmentDescription): \(bodyDescription)" + return "\(attachmentDescription): \(body)" } - if let bodyDescription: String = bodyDescription, !bodyDescription.isEmpty { - return bodyDescription + if let body: String = body, !body.isEmpty { + return body } if let attachmentDescription: String = attachmentDescription, !attachmentDescription.isEmpty { diff --git a/SessionMessagingKit/Database/Models/InteractionAttachment.swift b/SessionMessagingKit/Database/Models/InteractionAttachment.swift index 394df2e07..879ac0f45 100644 --- a/SessionMessagingKit/Database/Models/InteractionAttachment.swift +++ b/SessionMessagingKit/Database/Models/InteractionAttachment.swift @@ -13,10 +13,12 @@ public struct InteractionAttachment: Codable, FetchableRecord, PersistableRecord public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { + case albumIndex case interactionId case attachmentId } + public let albumIndex: Int public let interactionId: Int64 public let attachmentId: String @@ -33,9 +35,11 @@ public struct InteractionAttachment: Codable, FetchableRecord, PersistableRecord // MARK: - Initialization public init( + albumIndex: Int, interactionId: Int64, attachmentId: String ) { + self.albumIndex = albumIndex self.interactionId = interactionId self.attachmentId = attachmentId } diff --git a/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift b/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift index 0087c3663..47c687751 100644 --- a/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift +++ b/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift @@ -331,9 +331,6 @@ public class SignalAttachment: NSObject { } } } - if isOversizeText { - return OWSMimeTypeOversizeTextMessage - } if dataUTI == kUnknownTestAttachmentUTI { return OWSMimeTypeUnknownForTests } @@ -375,9 +372,6 @@ public class SignalAttachment: NSObject { return fileExtension.filterFilename() } } - if isOversizeText { - return kOversizeTextAttachmentFileExtension - } if dataUTI == kUnknownTestAttachmentUTI { return "unknown" } @@ -455,18 +449,11 @@ public class SignalAttachment: NSObject { return SignalAttachment.audioUTISet.contains(dataUTI) } - @objc - public var isOversizeText: Bool { - return dataUTI == kOversizeTextAttachmentUTI - } - @objc public var isText: Bool { return ( - isConvertibleToTextMessage && ( - UTTypeConformsTo(dataUTI as CFString, kUTTypeText) || - isOversizeText - ) + isConvertibleToTextMessage && + UTTypeConformsTo(dataUTI as CFString, kUTTypeText) ) } @@ -1056,20 +1043,6 @@ public class SignalAttachment: NSObject { maxFileSize: kMaxFileSizeAudio) } - // MARK: Oversize Text Attachments - - // Factory method for oversize text attachments. - // - // NOTE: The attachment returned by this method may not be valid. - // Check the attachment's error property. - private class func oversizeTextAttachment(text: String?) -> SignalAttachment { - let dataSource = DataSourceValue.dataSource(withOversizeText: text) - return newAttachment(dataSource: dataSource, - dataUTI: kOversizeTextAttachmentUTI, - validUTISet: nil, - maxFileSize: kMaxFileSizeGeneric) - } - // MARK: Generic Attachments // Factory method for generic attachments. diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index 522ec5184..1dab9ab3f 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -511,11 +511,13 @@ extension MessageReceiver { // they are invalid and we can ignore them return (attachment.downloadUrl != nil ? attachment : nil) } - .map { attachment in + .enumerated() + .map { index, attachment in let savedAttachment: Attachment = try attachment.saved(db) // Link the attachment to the interaction and add to the id lookup try InteractionAttachment( + albumIndex: index, interactionId: interactionId, attachmentId: savedAttachment.id ).insert(db) @@ -1057,6 +1059,10 @@ extension MessageReceiver { if wasCurrentUserRemoved { ClosedGroupPoller.shared.stopPolling(for: id) + try closedGroup + .allMembers + .deleteAll(db) + _ = try closedGroup .keyPairs .deleteAll(db) @@ -1067,14 +1073,15 @@ extension MessageReceiver { publicKey: userPublicKey ) } - - // Remove the member from the group and it's zombies - try closedGroup.members - .filter(removedMembers.contains(GroupMember.Columns.profileId)) - .deleteAll(db) - try closedGroup.zombies - .filter(removedMembers.contains(GroupMember.Columns.profileId)) - .deleteAll(db) + else { + // Remove the member from the group and it's zombies + try closedGroup.members + .filter(removedMembers.contains(GroupMember.Columns.profileId)) + .deleteAll(db) + try closedGroup.zombies + .filter(removedMembers.contains(GroupMember.Columns.profileId)) + .deleteAll(db) + } // Notify the user if needed guard members != Set(groupMembers.map { $0.profileId }) else { return } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 271ef3bfb..eb6672145 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -14,7 +14,7 @@ public final class MessageSender { signalAttachments: [SignalAttachment], for interactionId: Int64 ) throws { - try signalAttachments.forEach { signalAttachment in + try signalAttachments.enumerated().forEach { index, signalAttachment in let maybeAttachment: Attachment? = Attachment( variant: (signalAttachment.isVoiceMessage ? .voiceMessage : @@ -29,6 +29,7 @@ public final class MessageSender { guard let attachment: Attachment = maybeAttachment else { return } let interactionAttachment: InteractionAttachment = InteractionAttachment( + albumIndex: index, interactionId: interactionId, attachmentId: attachment.id ) diff --git a/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift b/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift index a1c8cbec4..07cb06126 100644 --- a/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift +++ b/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift @@ -44,70 +44,17 @@ public struct QuotedReplyModel { attachments: [Attachment]?, linkPreview: LinkPreview? ) -> QuotedReplyModel? { - guard variant == .standardOutgoing || variant == .standardIncoming else { - return nil - } - - var quotedText: String? = body - var quotedAttachment: Attachment? = attachments?.first - - // If the attachment is "oversize text", try the quote as a reply to text, not as - // a reply to an attachment - if - quotedText?.isEmpty == true, - let attachment: Attachment = quotedAttachment, - attachment.contentType == OWSMimeTypeOversizeTextMessage, - ( - (variant == .standardIncoming && attachment.state == .downloaded) || - attachment.state != .failed - ), - let originalFilePath: String = attachment.originalFilePath - { - quotedText = "" - - if - let textData: Data = try? Data(contentsOf: URL(fileURLWithPath: originalFilePath)), - let oversizeText: String = String(data: textData, encoding: .utf8) - { - // The attachment is going to be sent as text instead - quotedAttachment = nil - - // We don't need to include the entire text body of the message, just - // enough to render a snippet. kOversizeTextMessageSizeThreshold is our - // limit on how long text should be in protos since they'll be stored in - // the database. We apply this constant here for the same reasons. - // - // First, truncate to the rough max characters - var truncatedText: String = oversizeText.substring(to: Int(Interaction.oversizeTextMessageSizeThreshold - 1)) - - // But kOversizeTextMessageSizeThreshold is in _bytes_, not characters, - // so we need to continue to trim the string until it fits. - while truncatedText.lengthOfBytes(using: .utf8) >= Interaction.oversizeTextMessageSizeThreshold { - // A very coarse binary search by halving is acceptable, since - // kOversizeTextMessageSizeThreshold is much longer than our target - // length of "three short lines of text on any device we might - // display this on. - // - // The search will always converge since in the worst case (namely - // a single character which in utf-8 is >= 1024 bytes) the loop will - // exit when the string is empty. - truncatedText = truncatedText.substring(to: truncatedText.count / 2) - } - - if truncatedText.lengthOfBytes(using: .utf8) < Interaction.oversizeTextMessageSizeThreshold { - quotedText = truncatedText - } - } - } + guard variant == .standardOutgoing || variant == .standardIncoming else { return nil } + guard (body != nil && body?.isEmpty == false) || attachments?.isEmpty == false else { return nil } return QuotedReplyModel( threadId: threadId, authorId: authorId, timestampMs: timestampMs, - body: (quotedText == nil && quotedAttachment == nil ? "" : quotedText), - attachment: quotedAttachment, - contentType: quotedAttachment?.contentType, - sourceFileName: quotedAttachment?.sourceFilename, + body: body, + attachment: attachments?.first, + contentType: attachments?.first?.contentType, + sourceFileName: attachments?.first?.sourceFilename, thumbnailDownloadFailed: false ) } diff --git a/SessionUtilitiesKit/Database/GRDBStorage.swift b/SessionUtilitiesKit/Database/GRDBStorage.swift index f20c524ff..7ed3bdc0c 100644 --- a/SessionUtilitiesKit/Database/GRDBStorage.swift +++ b/SessionUtilitiesKit/Database/GRDBStorage.swift @@ -14,6 +14,8 @@ public enum GRDBStorageError: Error { // TODO: Rename to `StorageError` case failedToSave case objectNotFound case objectNotSaved + + case invalidSearchPattern } // TODO: Protocol for storage (just need to have 'read' and 'write' methods and mock 'Database'? diff --git a/SessionUtilitiesKit/Database/Types/ColumnExpressible.swift b/SessionUtilitiesKit/Database/Types/ColumnExpressible.swift index 434af47e4..032d1d193 100644 --- a/SessionUtilitiesKit/Database/Types/ColumnExpressible.swift +++ b/SessionUtilitiesKit/Database/Types/ColumnExpressible.swift @@ -6,3 +6,11 @@ import GRDB public protocol ColumnExpressible { associatedtype Columns: ColumnExpression } + +public extension ColumnExpressible where Columns: CaseIterable { + /// Note: Where possible the `TableRecord.numberOfSelectedColumns(_:)` function should be used instead as + /// it has proper validation + static func numberOfSelectedColumns() -> Int { + return Self.Columns.allCases.count + } +} diff --git a/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift b/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift index 7ab658bb5..810a862b6 100644 --- a/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift +++ b/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift @@ -15,4 +15,8 @@ public extension Database { try body(typedDefinition) } } + + public func makeFTS5Pattern(rawPattern: String, forTable table: T.Type) throws -> FTS5Pattern where T: TableRecord, T: ColumnExpressible { + return try makeFTS5Pattern(rawPattern: rawPattern, forTable: table.databaseTableName) + } } diff --git a/SessionUtilitiesKit/Database/Utilities/TableRecord+Utilities.swift b/SessionUtilitiesKit/Database/Utilities/TableRecord+Utilities.swift index b8c0be2b1..79b934153 100644 --- a/SessionUtilitiesKit/Database/Utilities/TableRecord+Utilities.swift +++ b/SessionUtilitiesKit/Database/Utilities/TableRecord+Utilities.swift @@ -4,6 +4,8 @@ import Foundation import GRDB public extension TableRecord where Self: ColumnExpressible { + static var fullTextSearchTableName: String { "\(self.databaseTableName)_fts" } + static func select(_ selection: Columns...) -> QueryInterfaceRequest { return all().select(selection) } diff --git a/SessionUtilitiesKit/Media/DataSource.h b/SessionUtilitiesKit/Media/DataSource.h index a999ed7ff..ecab61588 100755 --- a/SessionUtilitiesKit/Media/DataSource.h +++ b/SessionUtilitiesKit/Media/DataSource.h @@ -41,8 +41,6 @@ NS_ASSUME_NONNULL_BEGIN + (nullable DataSource *)dataSourceWithData:(NSData *)data utiType:(NSString *)utiType; -+ (nullable DataSource *)dataSourceWithOversizeText:(NSString *_Nullable)text; - + (DataSource *)dataSourceWithSyncMessageData:(NSData *)data; + (DataSource *)emptyDataSource; diff --git a/SessionUtilitiesKit/Media/DataSource.m b/SessionUtilitiesKit/Media/DataSource.m index 43b22c895..be8e33999 100755 --- a/SessionUtilitiesKit/Media/DataSource.m +++ b/SessionUtilitiesKit/Media/DataSource.m @@ -134,16 +134,6 @@ NS_ASSUME_NONNULL_BEGIN return [self dataSourceWithData:data fileExtension:fileExtension]; } -+ (nullable DataSource *)dataSourceWithOversizeText:(NSString *_Nullable)text -{ - if (!text) { - return nil; - } - - NSData *data = [text.filterStringForDisplay dataUsingEncoding:NSUTF8StringEncoding]; - return [self dataSourceWithData:data fileExtension:kOversizeTextAttachmentFileExtension]; -} - + (DataSource *)dataSourceWithSyncMessageData:(NSData *)data { return [self dataSourceWithData:data fileExtension:kSyncMessageFileExtension]; diff --git a/SessionUtilitiesKit/Media/MIMETypeUtil.h b/SessionUtilitiesKit/Media/MIMETypeUtil.h index 6b09c6437..611895499 100644 --- a/SessionUtilitiesKit/Media/MIMETypeUtil.h +++ b/SessionUtilitiesKit/Media/MIMETypeUtil.h @@ -12,7 +12,6 @@ extern NSString *const OWSMimeTypeImageTiff1; extern NSString *const OWSMimeTypeImageTiff2; extern NSString *const OWSMimeTypeImageBmp1; extern NSString *const OWSMimeTypeImageBmp2; -extern NSString *const OWSMimeTypeOversizeTextMessage; extern NSString *const OWSMimeTypeUnknownForTests; extern NSString *const kOversizeTextAttachmentUTI; diff --git a/SessionUtilitiesKit/Media/MIMETypeUtil.m b/SessionUtilitiesKit/Media/MIMETypeUtil.m index d1aa2ce6c..ce72daa98 100644 --- a/SessionUtilitiesKit/Media/MIMETypeUtil.m +++ b/SessionUtilitiesKit/Media/MIMETypeUtil.m @@ -19,12 +19,10 @@ NSString *const OWSMimeTypeImageTiff1 = @"image/tiff"; NSString *const OWSMimeTypeImageTiff2 = @"image/x-tiff"; NSString *const OWSMimeTypeImageBmp1 = @"image/bmp"; NSString *const OWSMimeTypeImageBmp2 = @"image/x-windows-bmp"; -NSString *const OWSMimeTypeOversizeTextMessage = @"text/x-signal-plain"; NSString *const OWSMimeTypeUnknownForTests = @"unknown/mimetype"; NSString *const OWSMimeTypeApplicationZip = @"application/zip"; NSString *const OWSMimeTypeApplicationPdf = @"application/pdf"; -NSString *const kOversizeTextAttachmentUTI = @"org.whispersystems.oversize-text-attachment"; NSString *const kOversizeTextAttachmentFileExtension = @"txt"; NSString *const kUnknownTestAttachmentUTI = @"org.whispersystems.unknown"; NSString *const kSyncMessageFileExtension = @"bin"; @@ -409,12 +407,6 @@ NSString *const kSyncMessageFileExtension = @"bin"; return [MIMETypeUtil filePathForAnimated:uniqueId ofMIMEType:contentType inFolder:folder]; } else if ([self isBinaryData:contentType]) { return [MIMETypeUtil filePathForBinaryData:uniqueId ofMIMEType:contentType inFolder:folder]; - } else if ([contentType isEqualToString:OWSMimeTypeOversizeTextMessage]) { - // We need to use a ".txt" file extension since this file extension is used - // by UIActivityViewController to determine which kinds of sharing are - // appropriate for this text. - // be used outside the app. - return [self filePathForData:uniqueId withFileExtension:@"txt" inFolder:folder]; } else if ([contentType isEqualToString:OWSMimeTypeUnknownForTests]) { // This file extension is arbitrary - it should never be exposed to the user or // be used outside the app. diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentCaptionToolbar.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentCaptionToolbar.swift index 3419bef56..f319ff2f1 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentCaptionToolbar.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentCaptionToolbar.swift @@ -146,50 +146,47 @@ class AttachmentCaptionToolbar: UIView, UITextViewDelegate { } public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { + let existingText: String = textView.text ?? "" + let proposedText: String = (existingText as NSString).replacingCharacters(in: range, with: text) - if !FeatureFlags.sendingMediaWithOversizeText { - let existingText: String = textView.text ?? "" - let proposedText: String = (existingText as NSString).replacingCharacters(in: range, with: text) + // Don't complicate things by mixing media attachments with oversize text attachments + guard proposedText.utf8.count < kOversizeTextMessageSizeThreshold else { + Logger.debug("long text was truncated") + self.lengthLimitLabel.isHidden = false - // Don't complicate things by mixing media attachments with oversize text attachments - guard proposedText.utf8.count < kOversizeTextMessageSizeThreshold else { - Logger.debug("long text was truncated") - self.lengthLimitLabel.isHidden = false + // `range` represents the section of the existing text we will replace. We can re-use that space. + // Range is in units of NSStrings's standard UTF-16 characters. Since some of those chars could be + // represented as single bytes in utf-8, while others may be 8 or more, the only way to be sure is + // to just measure the utf8 encoded bytes of the replaced substring. + let bytesAfterDelete: Int = (existingText as NSString).replacingCharacters(in: range, with: "").utf8.count - // `range` represents the section of the existing text we will replace. We can re-use that space. - // Range is in units of NSStrings's standard UTF-16 characters. Since some of those chars could be - // represented as single bytes in utf-8, while others may be 8 or more, the only way to be sure is - // to just measure the utf8 encoded bytes of the replaced substring. - let bytesAfterDelete: Int = (existingText as NSString).replacingCharacters(in: range, with: "").utf8.count - - // Accept as much of the input as we can - let byteBudget: Int = Int(kOversizeTextMessageSizeThreshold) - bytesAfterDelete - if byteBudget >= 0, let acceptableNewText = text.truncated(toByteCount: UInt(byteBudget)) { - textView.text = (existingText as NSString).replacingCharacters(in: range, with: acceptableNewText) - } - - return false + // Accept as much of the input as we can + let byteBudget: Int = Int(kOversizeTextMessageSizeThreshold) - bytesAfterDelete + if byteBudget >= 0, let acceptableNewText = text.truncated(toByteCount: UInt(byteBudget)) { + textView.text = (existingText as NSString).replacingCharacters(in: range, with: acceptableNewText) } - self.lengthLimitLabel.isHidden = true - // After verifying the byte-length is sufficiently small, verify the character count is within bounds. - guard proposedText.count < kMaxCaptionCharacterCount else { - Logger.debug("hit attachment message body character count limit") + return false + } + self.lengthLimitLabel.isHidden = true - self.lengthLimitLabel.isHidden = false + // After verifying the byte-length is sufficiently small, verify the character count is within bounds. + guard proposedText.count < kMaxCaptionCharacterCount else { + Logger.debug("hit attachment message body character count limit") - // `range` represents the section of the existing text we will replace. We can re-use that space. - let charsAfterDelete: Int = (existingText as NSString).replacingCharacters(in: range, with: "").count + self.lengthLimitLabel.isHidden = false - // Accept as much of the input as we can - let charBudget: Int = Int(kMaxCaptionCharacterCount) - charsAfterDelete - if charBudget >= 0 { - let acceptableNewText = String(text.prefix(charBudget)) - textView.text = (existingText as NSString).replacingCharacters(in: range, with: acceptableNewText) - } + // `range` represents the section of the existing text we will replace. We can re-use that space. + let charsAfterDelete: Int = (existingText as NSString).replacingCharacters(in: range, with: "").count - return false + // Accept as much of the input as we can + let charBudget: Int = Int(kMaxCaptionCharacterCount) - charsAfterDelete + if charBudget >= 0 { + let acceptableNewText = String(text.prefix(charBudget)) + textView.text = (existingText as NSString).replacingCharacters(in: range, with: acceptableNewText) } + + return false } // Though we can wrap the text, we don't want to encourage multline captions, plus a "done" button @@ -197,9 +194,9 @@ class AttachmentCaptionToolbar: UIView, UITextViewDelegate { if text == "\n" { attachmentCaptionToolbarDelegate?.attachmentCaptionToolbarDidComplete() return false - } else { - return true } + + return true } // MARK: - Helpers diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift index 2907d0c64..f5dcf5963 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift @@ -216,50 +216,47 @@ class AttachmentTextToolbar: UIView, UITextViewDelegate { } public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { + let existingText: String = textView.text ?? "" + let proposedText: String = (existingText as NSString).replacingCharacters(in: range, with: text) - if !FeatureFlags.sendingMediaWithOversizeText { - let existingText: String = textView.text ?? "" - let proposedText: String = (existingText as NSString).replacingCharacters(in: range, with: text) + // Don't complicate things by mixing media attachments with oversize text attachments + guard proposedText.utf8.count < kOversizeTextMessageSizeThreshold else { + Logger.debug("long text was truncated") + self.lengthLimitLabel.isHidden = false - // Don't complicate things by mixing media attachments with oversize text attachments - guard proposedText.utf8.count < kOversizeTextMessageSizeThreshold else { - Logger.debug("long text was truncated") - self.lengthLimitLabel.isHidden = false + // `range` represents the section of the existing text we will replace. We can re-use that space. + // Range is in units of NSStrings's standard UTF-16 characters. Since some of those chars could be + // represented as single bytes in utf-8, while others may be 8 or more, the only way to be sure is + // to just measure the utf8 encoded bytes of the replaced substring. + let bytesAfterDelete: Int = (existingText as NSString).replacingCharacters(in: range, with: "").utf8.count - // `range` represents the section of the existing text we will replace. We can re-use that space. - // Range is in units of NSStrings's standard UTF-16 characters. Since some of those chars could be - // represented as single bytes in utf-8, while others may be 8 or more, the only way to be sure is - // to just measure the utf8 encoded bytes of the replaced substring. - let bytesAfterDelete: Int = (existingText as NSString).replacingCharacters(in: range, with: "").utf8.count - - // Accept as much of the input as we can - let byteBudget: Int = Int(kOversizeTextMessageSizeThreshold) - bytesAfterDelete - if byteBudget >= 0, let acceptableNewText = text.truncated(toByteCount: UInt(byteBudget)) { - textView.text = (existingText as NSString).replacingCharacters(in: range, with: acceptableNewText) - } - - return false + // Accept as much of the input as we can + let byteBudget: Int = Int(kOversizeTextMessageSizeThreshold) - bytesAfterDelete + if byteBudget >= 0, let acceptableNewText = text.truncated(toByteCount: UInt(byteBudget)) { + textView.text = (existingText as NSString).replacingCharacters(in: range, with: acceptableNewText) } - self.lengthLimitLabel.isHidden = true - // After verifying the byte-length is sufficiently small, verify the character count is within bounds. - guard proposedText.count < kMaxMessageBodyCharacterCount else { - Logger.debug("hit attachment message body character count limit") + return false + } + self.lengthLimitLabel.isHidden = true - self.lengthLimitLabel.isHidden = false + // After verifying the byte-length is sufficiently small, verify the character count is within bounds. + guard proposedText.count < kMaxMessageBodyCharacterCount else { + Logger.debug("hit attachment message body character count limit") - // `range` represents the section of the existing text we will replace. We can re-use that space. - let charsAfterDelete: Int = (existingText as NSString).replacingCharacters(in: range, with: "").count + self.lengthLimitLabel.isHidden = false - // Accept as much of the input as we can - let charBudget: Int = Int(kMaxMessageBodyCharacterCount) - charsAfterDelete - if charBudget >= 0 { - let acceptableNewText = String(text.prefix(charBudget)) - textView.text = (existingText as NSString).replacingCharacters(in: range, with: acceptableNewText) - } + // `range` represents the section of the existing text we will replace. We can re-use that space. + let charsAfterDelete: Int = (existingText as NSString).replacingCharacters(in: range, with: "").count - return false + // Accept as much of the input as we can + let charBudget: Int = Int(kMaxMessageBodyCharacterCount) - charsAfterDelete + if charBudget >= 0 { + let acceptableNewText = String(text.prefix(charBudget)) + textView.text = (existingText as NSString).replacingCharacters(in: range, with: acceptableNewText) } + + return false } // Though we can wrap the text, we don't want to encourage multline captions, plus a "done" button @@ -267,9 +264,9 @@ class AttachmentTextToolbar: UIView, UITextViewDelegate { if text == "\n" { textView.resignFirstResponder() return false - } else { - return true } + + return true } public func textViewDidBeginEditing(_ textView: UITextView) { diff --git a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift index 51e6dc4de..c431e3c22 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift @@ -344,7 +344,7 @@ public class MediaMessageView: UIView, OWSAudioPlayerDelegate { private func setupViews() { // Plain text will just be put in the 'message' input so do nothing - guard !attachment.isText && !attachment.isOversizeText else { return } + guard !attachment.isText else { return } // Setup the view hierarchy addSubview(stackView) @@ -411,7 +411,7 @@ public class MediaMessageView: UIView, OWSAudioPlayerDelegate { private func setupLayout() { // Plain text will just be put in the 'message' input so do nothing - guard !attachment.isText && !attachment.isOversizeText else { return } + guard !attachment.isText else { return } // Sizing calculations let clampedRatio: CGFloat = { diff --git a/SignalUtilitiesKit/Utilities/FeatureFlags.swift b/SignalUtilitiesKit/Utilities/FeatureFlags.swift index 0d78affd1..db4f018a1 100644 --- a/SignalUtilitiesKit/Utilities/FeatureFlags.swift +++ b/SignalUtilitiesKit/Utilities/FeatureFlags.swift @@ -14,17 +14,6 @@ public class FeatureFlags: NSObject { return false } - /// iOS has long supported sending oversized text as a sidecar attachment. The other clients - /// simply displayed it as a text attachment. As part of the new cross-client long-text feature, - /// we want to be able to display long text with attachments as well. Existing iOS clients - /// won't properly display this, so we'll need to wait a while for rollout. - /// The stakes aren't __too__ high, because legacy clients won't lose data - they just won't - /// see the media attached to a long text message until they update their client. - @objc - public static var sendingMediaWithOversizeText: Bool { - return false - } - @objc public static var useCustomPhotoCapture: Bool { return true From aabf656d894e1760446809e9e54a4d63b801be60 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 20 May 2022 17:58:39 +1000 Subject: [PATCH 081/157] Finished off the MediaGallery logic Updated the config message generation for GRDB Migrated more preferences into GRDB Added paging to the MediaTileViewController and sorted out the various animations/transitions Fixed an issue where the 'recipientState' for the 'baseQuery' on the ConversationCell.ViewModel wasn't grouping correctly Fixed an issue where the MediaZoomAnimationController could fail if the contextual info wasn't available Fixed an issue where the MediaZoomAnimationController bounce looked odd when returning to the detail screen from the tile screen Fixed an issue where the MediaZoomAnimationController didn't work for videos Fixed a bug where the YDB to GRDB migration wasn't properly handling video files Fixed a number of minor UI bugs with the GalleryRailView Deleted a bunch of legacy code --- Configuration.swift | 1 + Session.xcodeproj/project.pbxproj | 78 +- .../ConversationVC+Interaction.swift | 48 +- Session/Conversations/ConversationVC.swift | 43 +- .../Conversations/Input View/InputView.swift | 15 +- .../Content Views/QuoteView.swift | 2 +- .../OWSConversationSettingsViewController.m | 2 - .../InsetLockableTableView.swift | 35 + .../Views & Modals/LinkPreviewModal.swift | 38 +- .../MediaDetailViewController.h | 54 - .../MediaDetailViewController.m | 501 ------- .../MediaDetailViewController.swift | 426 +++++- .../MediaGalleryNavigationController.swift | 84 ++ .../MediaGalleryViewController.swift | 903 ----------- .../MediaGalleryViewModel.swift | 862 ++++++++++- .../MediaPageViewController.swift | 904 ++++++----- .../MediaTileViewController.swift | 1318 +++++++++-------- .../SendMediaNavigationController.swift | 40 +- .../MediaDismissAnimationController.swift | 16 +- .../Transitions/MediaInteractiveDismiss.swift | 20 +- .../MediaPresentationContext.swift | 3 + .../MediaZoomAnimationController.swift | 78 +- Session/Meta/MainAppContext.m | 1 - Session/Meta/Signal-Bridging-Header.h | 8 - Session/Notifications/AppNotifications.swift | 11 +- ...otificationSettingsOptionsViewController.m | 1 - .../NotificationSettingsViewController.m | 8 +- .../PrivacySettingsTableViewController.m | 16 +- .../Models/ConversationCellViewModel.swift | 44 +- Session/Shared/OWSScreenLockUI.m | 4 +- Session/Utilities/AvatarViewHelper.h | 1 - .../Utilities/DifferenceKit+Utilities.swift | 10 + Session/Utilities/MentionUtilities.swift | 3 +- .../LegacyDatabase/SMKLegacyModels.swift | 11 +- .../Migrations/_003_YDBToGRDBMigration.swift | 46 +- .../Database/Models/Attachment.swift | 46 +- .../Database/Models/Profile.swift | 122 +- .../Database/Models/SessionThread.swift | 6 + .../Database/OWSPrimaryStorage.m | 2 - .../Database/SSKPreferences.swift | 86 -- .../Jobs/Types/AttachmentDownloadJob.swift | 196 +-- .../Jobs/Types/GarbageCollectionJob.swift | 50 + .../Jobs/Types/MessageSendJob.swift | 140 -- .../ConfigurationMessage+Convenience.swift | 85 +- .../DataExtractionNotification.swift | 4 +- .../Messages/Signal/TSOutgoingMessage.h | 1 - .../Messages/Signal/TSOutgoingMessage.m | 3 - .../Meta/SessionMessagingKit.h | 3 - .../Link Previews/OWSLinkPreview.swift | 19 +- .../Sending & Receiving/Pollers/Poller.swift | 6 +- SessionMessagingKit/Threads/TSThread.h | 1 - .../To Do/ProfileManagerProtocol.h | 30 - SessionMessagingKit/To Do/SignalRecipient.h | 50 - SessionMessagingKit/To Do/SignalRecipient.m | 217 --- SessionMessagingKit/To Do/TSAccountManager.m | 1 - .../Utilities/MessageInvalidator.swift | 30 - .../Utilities/OWSAudioPlayer.h | 2 +- .../Utilities/OWSAudioPlayer.m | 2 +- .../Utilities/OWSMediaGalleryFinder.h | 54 - .../Utilities/OWSMediaGalleryFinder.m | 209 --- .../Utilities/OWSPreferences.h | 24 - .../Utilities/OWSPreferences.m | 123 -- .../Utilities/Preferences.swift | 71 +- SessionMessagingKit/Utilities/ProtoUtils.h | 24 - SessionMessagingKit/Utilities/ProtoUtils.m | 50 - .../Utilities/YapDatabaseTransaction+OWS.h | 1 + .../Utilities/YapDatabaseTransaction+OWS.m | 6 + .../NSENotificationPresenter.swift | 2 +- .../Database/GRDBStorage.swift | 4 + SessionUtilitiesKit/Database/Models/Job.swift | 4 + .../General/Array+Utilities.swift | 6 + .../AttachmentItemCollection.swift | 4 + SignalUtilitiesKit/Meta/SignalUtilitiesKit.h | 2 - .../Screen Lock/OWSScreenLock.swift | 66 +- .../Shared Views/GalleryRailView.swift | 329 ++-- SignalUtilitiesKit/To Do/OWSProfileManager.h | 4 +- SignalUtilitiesKit/Utilities/OWSError.h | 2 - SignalUtilitiesKit/Utilities/OWSError.m | 7 - SignalUtilitiesKit/Utilities/SignalAccount.h | 44 - SignalUtilitiesKit/Utilities/SignalAccount.m | 51 - .../Utilities/VersionMigrations.m | 1 - 81 files changed, 3413 insertions(+), 4412 deletions(-) create mode 100644 Session/Conversations/Views & Modals/InsetLockableTableView.swift delete mode 100644 Session/Media Viewing & Editing/MediaDetailViewController.h delete mode 100644 Session/Media Viewing & Editing/MediaDetailViewController.m create mode 100644 Session/Media Viewing & Editing/MediaGalleryNavigationController.swift delete mode 100644 Session/Media Viewing & Editing/MediaGalleryViewController.swift create mode 100644 Session/Utilities/DifferenceKit+Utilities.swift delete mode 100644 SessionMessagingKit/Database/SSKPreferences.swift create mode 100644 SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift delete mode 100644 SessionMessagingKit/To Do/ProfileManagerProtocol.h delete mode 100644 SessionMessagingKit/To Do/SignalRecipient.h delete mode 100644 SessionMessagingKit/To Do/SignalRecipient.m delete mode 100644 SessionMessagingKit/Utilities/MessageInvalidator.swift delete mode 100644 SessionMessagingKit/Utilities/OWSMediaGalleryFinder.h delete mode 100644 SessionMessagingKit/Utilities/OWSMediaGalleryFinder.m delete mode 100644 SessionMessagingKit/Utilities/ProtoUtils.h delete mode 100644 SessionMessagingKit/Utilities/ProtoUtils.m delete mode 100644 SignalUtilitiesKit/Utilities/SignalAccount.h delete mode 100644 SignalUtilitiesKit/Utilities/SignalAccount.m diff --git a/Configuration.swift b/Configuration.swift index 9b7ca4acc..791f38f7c 100644 --- a/Configuration.swift +++ b/Configuration.swift @@ -35,6 +35,7 @@ public enum SNMessagingKit { // Just to make the external API nice JobRunner.add(executor: FailedAttachmentDownloadsJob.self, for: .failedAttachmentDownloads) JobRunner.add(executor: UpdateProfilePictureJob.self, for: .updateProfilePicture) JobRunner.add(executor: RetrieveDefaultOpenGroupRoomsJob.self, for: .retrieveDefaultOpenGroupRooms) + JobRunner.add(executor: GarbageCollectionJob.self, for: .garbageCollection) JobRunner.add(executor: MessageSendJob.self, for: .messageSend) JobRunner.add(executor: MessageReceiveJob.self, for: .messageReceive) JobRunner.add(executor: NotifyPushServerJob.self, for: .notifyPushServer) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 003138f0d..23b9c3d8b 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -59,7 +59,6 @@ 451A13B11E13DED2000A50FD /* AppNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 451A13B01E13DED2000A50FD /* AppNotifications.swift */; }; 4520D8D51D417D8E00123472 /* Photos.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4520D8D41D417D8E00123472 /* Photos.framework */; }; 4521C3C01F59F3BA00B4C582 /* TextFieldHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4521C3BF1F59F3BA00B4C582 /* TextFieldHelper.swift */; }; - 452EC6DF205E9E30000E787C /* MediaGalleryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 452EC6DE205E9E30000E787C /* MediaGalleryViewController.swift */; }; 4535186E1FC635DD00210559 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 4535186C1FC635DD00210559 /* MainInterface.storyboard */; }; 453518721FC635DD00210559 /* SessionShareExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 453518681FC635DD00210559 /* SessionShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 4539B5861F79348F007141FF /* PushRegistrationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4539B5851F79348F007141FF /* PushRegistrationManager.swift */; }; @@ -101,7 +100,6 @@ 45CB2FA81CB7146C00E1B343 /* Launch Screen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 45CB2FA71CB7146C00E1B343 /* Launch Screen.storyboard */; }; 45CD81EF1DC030E7004C9430 /* SyncPushTokensJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45CD81EE1DC030E7004C9430 /* SyncPushTokensJob.swift */; }; 45E5A6991F61E6DE001E4A8A /* MarqueeLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45E5A6981F61E6DD001E4A8A /* MarqueeLabel.swift */; }; - 45F32C222057297A00A300D5 /* MediaDetailViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 45B9EE9B200E91FB005D2F2D /* MediaDetailViewController.m */; }; 45F32C232057297A00A300D5 /* MediaPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45F32C1D205718B000A300D5 /* MediaPageViewController.swift */; }; 4C090A1B210FD9C7001FD7F9 /* HapticFeedback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C090A1A210FD9C7001FD7F9 /* HapticFeedback.swift */; }; 4C1885D2218F8E1C00B67051 /* PhotoGridViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C1885D1218F8E1C00B67051 /* PhotoGridViewCell.swift */; }; @@ -159,7 +157,6 @@ B8269D2925C7A4B400488AB4 /* InputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8269D2825C7A4B400488AB4 /* InputView.swift */; }; B8269D3325C7A8C600488AB4 /* InputViewButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8269D3225C7A8C600488AB4 /* InputViewButton.swift */; }; B8269D3D25C7B34D00488AB4 /* InputTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8269D3C25C7B34D00488AB4 /* InputTextView.swift */; }; - B82A0C3826B9098200C1BCE3 /* MessageInvalidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82A0C3726B9098200C1BCE3 /* MessageInvalidator.swift */; }; B82B40882399EB0E00A248E7 /* LandingVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82B40872399EB0E00A248E7 /* LandingVC.swift */; }; B82B408A2399EC0600A248E7 /* FakeChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82B40892399EC0600A248E7 /* FakeChatView.swift */; }; B82B408C239A068800A248E7 /* RegisterVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82B408B239A068800A248E7 /* RegisterVC.swift */; }; @@ -322,7 +319,6 @@ C32C5B51256DC219003C73A2 /* NSNotificationCenter+OWS.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB3B255A580B00E217F9 /* NSNotificationCenter+OWS.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5B84256DC54F003C73A2 /* SSKEnvironment.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAF4255A580600E217F9 /* SSKEnvironment.m */; }; C32C5B8D256DC565003C73A2 /* SSKEnvironment.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB31255A580A00E217F9 /* SSKEnvironment.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C32C5BBA256DC7E3003C73A2 /* ProfileManagerProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBB9255A581600E217F9 /* ProfileManagerProtocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5BCC256DC830003C73A2 /* Storage+ClosedGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8D8F1372566120F0092EF10 /* Storage+ClosedGroups.swift */; }; C32C5C3D256DCBAF003C73A2 /* AppReadiness.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB75255A581000E217F9 /* AppReadiness.m */; }; C32C5C46256DCBB2003C73A2 /* AppReadiness.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB01255A580700E217F9 /* AppReadiness.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -340,7 +336,6 @@ C32C5DD2256DD9E5003C73A2 /* LRUCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAFD255A580600E217F9 /* LRUCache.swift */; }; C32C5DDB256DD9FF003C73A2 /* ContentProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB68255A580F00E217F9 /* ContentProxy.swift */; }; C32C5E0C256DDAFA003C73A2 /* NSRegularExpression+SSK.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA7A255A57FB00E217F9 /* NSRegularExpression+SSK.swift */; }; - C32C5E15256DDC78003C73A2 /* SSKPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA69255A57F900E217F9 /* SSKPreferences.swift */; }; C32C5E5B256DDF45003C73A2 /* OWSStorage.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAB1255A580000E217F9 /* OWSStorage.m */; }; C32C5E64256DDFD6003C73A2 /* OWSStorage.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDAFE255A580600E217F9 /* OWSStorage.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5E75256DE020003C73A2 /* YapDatabaseTransaction+OWS.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB5B255A580E00E217F9 /* YapDatabaseTransaction+OWS.m */; }; @@ -417,7 +412,6 @@ C33FDD49255A582000E217F9 /* ParamParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB8F255A581200E217F9 /* ParamParser.swift */; }; C33FDD53255A582000E217F9 /* OWSPrimaryStorage+keyFromIntLong.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB99255A581300E217F9 /* OWSPrimaryStorage+keyFromIntLong.m */; }; C33FDD5B255A582000E217F9 /* OWSOperation.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBA1255A581400E217F9 /* OWSOperation.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C33FDD68255A582000E217F9 /* SignalAccount.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBAE255A581500E217F9 /* SignalAccount.h */; settings = {ATTRIBUTES = (Public, ); }; }; C33FDD6E255A582000E217F9 /* NSURLSessionDataTask+StatusCode.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBB4255A581600E217F9 /* NSURLSessionDataTask+StatusCode.m */; }; C33FDD74255A582000E217F9 /* OWSPrimaryStorage+keyFromIntLong.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBBA255A581600E217F9 /* OWSPrimaryStorage+keyFromIntLong.h */; settings = {ATTRIBUTES = (Public, ); }; }; C33FDD7C255A582000E217F9 /* SSKAsserts.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBC2255A581700E217F9 /* SSKAsserts.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -428,7 +422,6 @@ C33FDDB3255A582000E217F9 /* OWSError.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBF9255A581C00E217F9 /* OWSError.h */; settings = {ATTRIBUTES = (Public, ); }; }; C33FDDB8255A582000E217F9 /* NSSet+Functional.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBFE255A581C00E217F9 /* NSSet+Functional.h */; settings = {ATTRIBUTES = (Public, ); }; }; C33FDDBD255A582000E217F9 /* ByteParser.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDC03255A581D00E217F9 /* ByteParser.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C33FDDC0255A582000E217F9 /* SignalAccount.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDC06255A581D00E217F9 /* SignalAccount.m */; }; C33FDDC5255A582000E217F9 /* OWSError.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDC0B255A581D00E217F9 /* OWSError.m */; }; C33FDDCC255A582000E217F9 /* TSConstants.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDC12255A581E00E217F9 /* TSConstants.h */; settings = {ATTRIBUTES = (Public, ); }; }; C33FDDD0255A582000E217F9 /* FunctionalUtil.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDC16255A581E00E217F9 /* FunctionalUtil.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -581,13 +574,7 @@ C3A3A111256E1A93004D228D /* OWSIncomingMessageFinder.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDAC0255A580100E217F9 /* OWSIncomingMessageFinder.h */; settings = {ATTRIBUTES = (Public, ); }; }; C3A3A122256E1A97004D228D /* OWSDisappearingMessagesFinder.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDC05255A581D00E217F9 /* OWSDisappearingMessagesFinder.h */; settings = {ATTRIBUTES = (Public, ); }; }; C3A3A12B256E1AD5004D228D /* TSDatabaseSecondaryIndexes.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB25255A580900E217F9 /* TSDatabaseSecondaryIndexes.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C3A3A13C256E1B27004D228D /* OWSMediaGalleryFinder.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB71255A581000E217F9 /* OWSMediaGalleryFinder.m */; }; - C3A3A145256E1B49004D228D /* OWSMediaGalleryFinder.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB67255A580F00E217F9 /* OWSMediaGalleryFinder.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C3A3A156256E1B91004D228D /* ProtoUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA6C255A57FA00E217F9 /* ProtoUtils.m */; }; - C3A3A15F256E1BB4004D228D /* ProtoUtils.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB91255A581200E217F9 /* ProtoUtils.h */; settings = {ATTRIBUTES = (Public, ); }; }; C3A3A171256E1D25004D228D /* SSKReachabilityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A3A170256E1D25004D228D /* SSKReachabilityManager.swift */; }; - C3A3A18A256E2092004D228D /* SignalRecipient.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBB7255A581600E217F9 /* SignalRecipient.m */; }; - C3A3A193256E20D4004D228D /* SignalRecipient.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDAEC255A580500E217F9 /* SignalRecipient.h */; settings = {ATTRIBUTES = (Public, ); }; }; C3A71D0B2558989C0043A11F /* MessageWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A71D0A2558989C0043A11F /* MessageWrapper.swift */; }; C3A71D1E25589AC30043A11F /* WebSocketProto.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A71D1C25589AC30043A11F /* WebSocketProto.swift */; }; C3A71D1F25589AC30043A11F /* WebSocketResources.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A71D1D25589AC30043A11F /* WebSocketResources.pb.swift */; }; @@ -756,6 +743,7 @@ FD3C907327E8387300CD579F /* OpenGroupServerIdLookupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907227E8387300CD579F /* OpenGroupServerIdLookupMigration.swift */; }; FD3C907527E83AC200CD579F /* OpenGroupServerIdLookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */; }; FD4B200C283367410034334B /* ConversationCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4B200B283367410034334B /* ConversationCellViewModel.swift */; }; + FD4B200E283492210034334B /* InsetLockableTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4B200D283492210034334B /* InsetLockableTableView.swift */; }; FD5D200F27AA2B6000FEA984 /* MessageRequestResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */; }; FD5D201127AA331F00FEA984 /* ConfigurationMessage+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D201027AA331F00FEA984 /* ConfigurationMessage+Convenience.swift */; }; FD659AC027A7649600F12C02 /* MessageRequestsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD659ABF27A7649600F12C02 /* MessageRequestsViewController.swift */; }; @@ -785,6 +773,9 @@ FDC4389E27BA2B8A00C60D73 /* SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */; }; FDCDB8DE2810F73B00352A0C /* Differentiable+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DD2810F73B00352A0C /* Differentiable+Utilities.swift */; }; FDCDB8E02811007F00352A0C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */; }; + FDD2506E283711D600198BDA /* DifferenceKit+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */; }; + FDD250702837199200198BDA /* GarbageCollectionJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD2506F2837199200198BDA /* GarbageCollectionJob.swift */; }; + FDD250722837234B00198BDA /* MediaGalleryNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD250712837234B00198BDA /* MediaGalleryNavigationController.swift */; }; FDE77F6B280FEB28002CFC5D /* ControlMessageProcessRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */; }; FDED2E3C282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDED2E3B282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift */; }; FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */; }; @@ -1047,7 +1038,6 @@ 451A13B01E13DED2000A50FD /* AppNotifications.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = AppNotifications.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; 4520D8D41D417D8E00123472 /* Photos.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Photos.framework; path = System/Library/Frameworks/Photos.framework; sourceTree = SDKROOT; }; 4521C3BF1F59F3BA00B4C582 /* TextFieldHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextFieldHelper.swift; sourceTree = ""; }; - 452EC6DE205E9E30000E787C /* MediaGalleryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGalleryViewController.swift; sourceTree = ""; }; 453518681FC635DD00210559 /* SessionShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SessionShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 4535186D1FC635DD00210559 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = ""; }; 4535186F1FC635DD00210559 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -1085,8 +1075,6 @@ 45B74A702044AAB500CD42F8 /* circles-quiet.aifc */ = {isa = PBXFileReference; lastKnownFileType = file; path = "circles-quiet.aifc"; sourceTree = ""; }; 45B74A722044AAB600CD42F8 /* synth.aifc */ = {isa = PBXFileReference; lastKnownFileType = file; path = synth.aifc; sourceTree = ""; }; 45B74A732044AAB600CD42F8 /* input-quiet.aifc */ = {isa = PBXFileReference; lastKnownFileType = file; path = "input-quiet.aifc"; sourceTree = ""; }; - 45B9EE9A200E91FB005D2F2D /* MediaDetailViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MediaDetailViewController.h; sourceTree = ""; }; - 45B9EE9B200E91FB005D2F2D /* MediaDetailViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MediaDetailViewController.m; sourceTree = ""; }; 45BD60811DE9547E00A8F436 /* Contacts.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Contacts.framework; path = System/Library/Frameworks/Contacts.framework; sourceTree = SDKROOT; }; 45C0DC1A1E68FE9000E04C47 /* UIApplication+OWS.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIApplication+OWS.swift"; sourceTree = ""; }; 45C0DC1D1E69011F00E04C47 /* UIStoryboard+OWS.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIStoryboard+OWS.swift"; sourceTree = ""; }; @@ -1194,7 +1182,6 @@ B8269D2825C7A4B400488AB4 /* InputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputView.swift; sourceTree = ""; }; B8269D3225C7A8C600488AB4 /* InputViewButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputViewButton.swift; sourceTree = ""; }; B8269D3C25C7B34D00488AB4 /* InputTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputTextView.swift; sourceTree = ""; }; - B82A0C3726B9098200C1BCE3 /* MessageInvalidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageInvalidator.swift; sourceTree = ""; }; B82B40872399EB0E00A248E7 /* LandingVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandingVC.swift; sourceTree = ""; }; B82B40892399EC0600A248E7 /* FakeChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeChatView.swift; sourceTree = ""; }; B82B408B239A068800A248E7 /* RegisterVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterVC.swift; sourceTree = ""; }; @@ -1328,8 +1315,6 @@ C33FD9AD255A548A00E217F9 /* SignalUtilitiesKit.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SignalUtilitiesKit.h; sourceTree = ""; }; C33FD9AE255A548A00E217F9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; C33FDA67255A57F900E217F9 /* OWSPrimaryStorage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSPrimaryStorage.h; sourceTree = ""; }; - C33FDA69255A57F900E217F9 /* SSKPreferences.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SSKPreferences.swift; sourceTree = ""; }; - C33FDA6C255A57FA00E217F9 /* ProtoUtils.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ProtoUtils.m; sourceTree = ""; }; C33FDA6D255A57FA00E217F9 /* YapDatabase+Promise.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "YapDatabase+Promise.swift"; sourceTree = ""; }; C33FDA6F255A57FA00E217F9 /* ReachabilityManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReachabilityManager.swift; sourceTree = ""; }; C33FDA70255A57FA00E217F9 /* TSMessage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSMessage.h; sourceTree = ""; }; @@ -1369,7 +1354,6 @@ C33FDAE4255A580400E217F9 /* TSAttachmentStream.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSAttachmentStream.h; sourceTree = ""; }; C33FDAE6255A580400E217F9 /* TSInteraction.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSInteraction.h; sourceTree = ""; }; C33FDAEA255A580500E217F9 /* OWSBackupFragment.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSBackupFragment.h; sourceTree = ""; }; - C33FDAEC255A580500E217F9 /* SignalRecipient.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SignalRecipient.h; sourceTree = ""; }; C33FDAEF255A580500E217F9 /* NSData+Image.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSData+Image.m"; sourceTree = ""; }; C33FDAF1255A580500E217F9 /* OWSThumbnailService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSThumbnailService.swift; sourceTree = ""; }; C33FDAF2255A580500E217F9 /* ProxiedContentDownloader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProxiedContentDownloader.swift; sourceTree = ""; }; @@ -1415,12 +1399,10 @@ C33FDB5C255A580E00E217F9 /* NSArray+Functional.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSArray+Functional.h"; sourceTree = ""; }; C33FDB5F255A580E00E217F9 /* YapDatabaseConnection+OWS.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "YapDatabaseConnection+OWS.h"; sourceTree = ""; }; C33FDB60255A580E00E217F9 /* TSMessage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSMessage.m; sourceTree = ""; }; - C33FDB67255A580F00E217F9 /* OWSMediaGalleryFinder.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSMediaGalleryFinder.h; sourceTree = ""; }; C33FDB68255A580F00E217F9 /* ContentProxy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentProxy.swift; sourceTree = ""; }; C33FDB69255A580F00E217F9 /* FeatureFlags.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeatureFlags.swift; sourceTree = ""; }; C33FDB6B255A580F00E217F9 /* SNUserDefaults.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SNUserDefaults.swift; sourceTree = ""; }; C33FDB6C255A580F00E217F9 /* NSNotificationCenter+OWS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSNotificationCenter+OWS.m"; sourceTree = ""; }; - C33FDB71255A581000E217F9 /* OWSMediaGalleryFinder.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSMediaGalleryFinder.m; sourceTree = ""; }; C33FDB73255A581000E217F9 /* TSGroupModel.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSGroupModel.m; sourceTree = ""; }; C33FDB75255A581000E217F9 /* AppReadiness.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppReadiness.m; sourceTree = ""; }; C33FDB77255A581000E217F9 /* NSUserDefaults+OWS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSUserDefaults+OWS.m"; sourceTree = ""; }; @@ -1433,7 +1415,6 @@ C33FDB88255A581200E217F9 /* TSAccountManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSAccountManager.m; sourceTree = ""; }; C33FDB8A255A581200E217F9 /* AppContext.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppContext.h; sourceTree = ""; }; C33FDB8F255A581200E217F9 /* ParamParser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ParamParser.swift; sourceTree = ""; }; - C33FDB91255A581200E217F9 /* ProtoUtils.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ProtoUtils.h; sourceTree = ""; }; C33FDB94255A581300E217F9 /* TSAccountManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSAccountManager.h; sourceTree = ""; }; C33FDB99255A581300E217F9 /* OWSPrimaryStorage+keyFromIntLong.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "OWSPrimaryStorage+keyFromIntLong.m"; sourceTree = ""; }; C33FDB9C255A581300E217F9 /* TSIncomingMessage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSIncomingMessage.h; sourceTree = ""; }; @@ -1441,12 +1422,9 @@ C33FDBA1255A581400E217F9 /* OWSOperation.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSOperation.h; sourceTree = ""; }; C33FDBA8255A581500E217F9 /* OWSLinkPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSLinkPreview.swift; sourceTree = ""; }; C33FDBAB255A581500E217F9 /* OWSFileSystem.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSFileSystem.h; sourceTree = ""; }; - C33FDBAE255A581500E217F9 /* SignalAccount.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SignalAccount.h; sourceTree = ""; }; C33FDBB4255A581600E217F9 /* NSURLSessionDataTask+StatusCode.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSURLSessionDataTask+StatusCode.m"; sourceTree = ""; }; C33FDBB6255A581600E217F9 /* DataSource.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DataSource.m; sourceTree = ""; }; - C33FDBB7255A581600E217F9 /* SignalRecipient.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SignalRecipient.m; sourceTree = ""; }; C33FDBB8255A581600E217F9 /* TSThread.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSThread.m; sourceTree = ""; }; - C33FDBB9255A581600E217F9 /* ProfileManagerProtocol.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ProfileManagerProtocol.h; sourceTree = ""; }; C33FDBBA255A581600E217F9 /* OWSPrimaryStorage+keyFromIntLong.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "OWSPrimaryStorage+keyFromIntLong.h"; sourceTree = ""; }; C33FDBBC255A581600E217F9 /* SSKKeychainStorage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SSKKeychainStorage.swift; sourceTree = ""; }; C33FDBC2255A581700E217F9 /* SSKAsserts.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SSKAsserts.h; sourceTree = ""; }; @@ -1465,7 +1443,6 @@ C33FDC02255A581D00E217F9 /* OWSPrimaryStorage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSPrimaryStorage.m; sourceTree = ""; }; C33FDC03255A581D00E217F9 /* ByteParser.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ByteParser.h; sourceTree = ""; }; C33FDC05255A581D00E217F9 /* OWSDisappearingMessagesFinder.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSDisappearingMessagesFinder.h; sourceTree = ""; }; - C33FDC06255A581D00E217F9 /* SignalAccount.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SignalAccount.m; sourceTree = ""; }; C33FDC0B255A581D00E217F9 /* OWSError.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSError.m; sourceTree = ""; }; C33FDC0C255A581E00E217F9 /* TSInfoMessage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSInfoMessage.m; sourceTree = ""; }; C33FDC12255A581E00E217F9 /* TSConstants.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSConstants.h; sourceTree = ""; }; @@ -1812,6 +1789,7 @@ FD3C907227E8387300CD579F /* OpenGroupServerIdLookupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookupMigration.swift; sourceTree = ""; }; FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookup.swift; sourceTree = ""; }; FD4B200B283367410034334B /* ConversationCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationCellViewModel.swift; sourceTree = ""; }; + FD4B200D283492210034334B /* InsetLockableTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsetLockableTableView.swift; sourceTree = ""; }; FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestResponse.swift; sourceTree = ""; }; FD5D201027AA331F00FEA984 /* ConfigurationMessage+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConfigurationMessage+Convenience.swift"; sourceTree = ""; }; FD659ABF27A7649600F12C02 /* MessageRequestsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsViewController.swift; sourceTree = ""; }; @@ -1836,6 +1814,9 @@ FDA8EB0F280F8238002B68E5 /* Codable+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Codable+Utilities.swift"; sourceTree = ""; }; FDCDB8DD2810F73B00352A0C /* Differentiable+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Differentiable+Utilities.swift"; sourceTree = ""; }; FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; + FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DifferenceKit+Utilities.swift"; sourceTree = ""; }; + FDD2506F2837199200198BDA /* GarbageCollectionJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarbageCollectionJob.swift; sourceTree = ""; }; + FDD250712837234B00198BDA /* MediaGalleryNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGalleryNavigationController.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 = ""; }; FDED2E3B282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UICollectionView+ReusableView.swift"; sourceTree = ""; }; @@ -2076,6 +2057,7 @@ 4CC1ECFA211A553000CC13BE /* AppUpdateNag.swift */, B90418E4183E9DD40038554A /* DateUtil.h */, B90418E5183E9DD40038554A /* DateUtil.m */, + FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */, 4C21D5D5223A9DC500EF8A77 /* UIAlerts+iOS9.m */, 45C0DC1A1E68FE9000E04C47 /* UIApplication+OWS.swift */, 45C0DC1D1E69011F00E04C47 /* UIStoryboard+OWS.swift */, @@ -2245,6 +2227,7 @@ B82149C025D605C6009C0F2A /* InfoBanner.swift */, C374EEEA25DA3CA70073A857 /* ConversationTitleView.swift */, B848A4C4269EAAA200617031 /* UserDetailsSheet.swift */, + FD4B200D283492210034334B /* InsetLockableTableView.swift */, ); path = "Views & Modals"; sourceTree = ""; @@ -2656,9 +2639,6 @@ C33FDBEC255A581B00E217F9 /* OWSRecipientIdentity.m */, C38EF2D3255B6DAF007E1867 /* OWSUserProfile.h */, C38EF2D1255B6DAF007E1867 /* OWSUserProfile.m */, - C33FDBB9255A581600E217F9 /* ProfileManagerProtocol.h */, - C33FDAEC255A580500E217F9 /* SignalRecipient.h */, - C33FDBB7255A581600E217F9 /* SignalRecipient.m */, C33FDB94255A581300E217F9 /* TSAccountManager.h */, C33FDB88255A581200E217F9 /* TSAccountManager.m */, ); @@ -2684,7 +2664,6 @@ B8D8F19225661BF80092EF10 /* Storage+Messaging.swift */, B8D8F18825661BA50092EF10 /* Storage+OpenGroups.swift */, C3F0A5FD255C988A007BE2A3 /* Storage+Shared.swift */, - C33FDA69255A57F900E217F9 /* SSKPreferences.swift */, C33FDB25255A580900E217F9 /* TSDatabaseSecondaryIndexes.h */, C33FDB20255A580900E217F9 /* TSDatabaseSecondaryIndexes.m */, C33FDB2C255A580A00E217F9 /* TSDatabaseView.h */, @@ -2909,16 +2888,14 @@ children = ( FDFDE122282D04E30098B17F /* Transitions */, C36096B925AD1ACF008B62B2 /* GIFs */, + FD09C5E728264937000CE219 /* MediaDetailViewController.swift */, + FD09C5E528260FF9000CE219 /* MediaGalleryViewModel.swift */, + FDD250712837234B00198BDA /* MediaGalleryNavigationController.swift */, + 45F32C1D205718B000A300D5 /* MediaPageViewController.swift */, + 454A84032059C787008B8C75 /* MediaTileViewController.swift */, 34E3E5671EC4B19400495BAC /* AudioProgressView.swift */, 346B66301F4E29B200E5122F /* CropScaleImageViewController.swift */, 34969559219B605E00DCFE74 /* ImagePickerController.swift */, - 45B9EE9A200E91FB005D2F2D /* MediaDetailViewController.h */, - 45B9EE9B200E91FB005D2F2D /* MediaDetailViewController.m */, - FD09C5E728264937000CE219 /* MediaDetailViewController.swift */, - FD09C5E528260FF9000CE219 /* MediaGalleryViewModel.swift */, - 452EC6DE205E9E30000E787C /* MediaGalleryViewController.swift */, - 45F32C1D205718B000A300D5 /* MediaPageViewController.swift */, - 454A84032059C787008B8C75 /* MediaTileViewController.swift */, 34A6C27F21E503E600B5B12E /* OWSImagePickerController.swift */, 3496955A219B605E00DCFE74 /* PhotoCollectionPickerController.swift */, 3496955B219B605E00DCFE74 /* PhotoLibrary.swift */, @@ -3145,7 +3122,6 @@ C37F5402255BA9ED002AEA92 /* Environment.m */, C3BBE0C62554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift */, C33FDB7F255A581100E217F9 /* FullTextSearchFinder.swift */, - B82A0C3726B9098200C1BCE3 /* MessageInvalidator.swift */, C3A71D0A2558989C0043A11F /* MessageWrapper.swift */, C3A71D4E25589FF30043A11F /* NSData+messagePadding.h */, C3A71D4825589FF20043A11F /* NSData+messagePadding.m */, @@ -3159,8 +3135,6 @@ C33FDA86255A57FC00E217F9 /* OWSDisappearingMessagesFinder.m */, C33FDAC0255A580100E217F9 /* OWSIncomingMessageFinder.h */, C33FDB1E255A580900E217F9 /* OWSIncomingMessageFinder.m */, - C33FDB67255A580F00E217F9 /* OWSMediaGalleryFinder.h */, - C33FDB71255A581000E217F9 /* OWSMediaGalleryFinder.m */, C38EF2F1255B6DBB007E1867 /* OWSPreferences.h */, C38EF308255B6DBE007E1867 /* OWSPreferences.m */, FDF0B75D280AAF35004C14C5 /* Preferences.swift */, @@ -3169,8 +3143,6 @@ FD09797327FAB3E200936362 /* ProfileManager.swift */, C38EF2EC255B6DBA007E1867 /* ProximityMonitoringManager.swift */, C3BBE0B42554F0E10050F1E3 /* ProofOfWork.swift */, - C33FDB91255A581200E217F9 /* ProtoUtils.h */, - C33FDA6C255A57FA00E217F9 /* ProtoUtils.m */, C38EEF09255B49A8007E1867 /* SNProtoEnvelope+Conversion.swift */, C33FDB31255A580A00E217F9 /* SSKEnvironment.h */, C33FDAF4255A580600E217F9 /* SSKEnvironment.m */, @@ -3344,8 +3316,6 @@ C33FDC19255A581F00E217F9 /* OWSQueues.h */, C33FDBD3255A581800E217F9 /* OWSSignalAddress.swift */, C33FDA6F255A57FA00E217F9 /* ReachabilityManager.swift */, - C33FDBAE255A581500E217F9 /* SignalAccount.h */, - C33FDC06255A581D00E217F9 /* SignalAccount.m */, C33FDBD8255A581900E217F9 /* SignalIOS.pb.swift */, C33FDB40255A580C00E217F9 /* SignalIOSProto.swift */, C33FDC12255A581E00E217F9 /* TSConstants.h */, @@ -3761,6 +3731,7 @@ FDA8EAFF280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift */, FD6A7A6A2818C17C00035AC1 /* UpdateProfilePictureJob.swift */, FD6A7A682818BE7300035AC1 /* RetrieveDefaultOpenGroupRoomsJob.swift */, + FDD2506F2837199200198BDA /* GarbageCollectionJob.swift */, C352A2FE25574B6300338F3E /* MessageSendJob.swift */, C352A31225574F5200338F3E /* MessageReceiveJob.swift */, C352A32E2557549C00338F3E /* NotifyPushServerJob.swift */, @@ -3835,7 +3806,6 @@ C33FDDD3255A582000E217F9 /* OWSQueues.h in Headers */, C33FDC96255A582000E217F9 /* NSObject+Casting.h in Headers */, C33FDDB3255A582000E217F9 /* OWSError.h in Headers */, - C33FDD68255A582000E217F9 /* SignalAccount.h in Headers */, C38EF35E255B6DCC007E1867 /* OWSViewController.h in Headers */, C37F5396255B95BD002AEA92 /* OWSAnyTouchGestureRecognizer.h in Headers */, C33FDDB2255A582000E217F9 /* NSArray+OWS.h in Headers */, @@ -3910,8 +3880,6 @@ C32C5EC3256DE133003C73A2 /* OWSQuotedReplyModel.h in Headers */, C32C5AAA256DBE8F003C73A2 /* TSIncomingMessage.h in Headers */, B8856D72256F1421001CE70E /* OWSWindowManager.h in Headers */, - C32C5BBA256DC7E3003C73A2 /* ProfileManagerProtocol.h in Headers */, - C3A3A193256E20D4004D228D /* SignalRecipient.h in Headers */, B8856CF7256F105E001CE70E /* OWSAudioPlayer.h in Headers */, B8856D3D256F11B2001CE70E /* Environment.h in Headers */, C32C5E7E256DE023003C73A2 /* YapDatabaseTransaction+OWS.h in Headers */, @@ -3922,8 +3890,6 @@ C32C5EA0256DE0D6003C73A2 /* OWSPrimaryStorage.h in Headers */, C3A3A111256E1A93004D228D /* OWSIncomingMessageFinder.h in Headers */, C32C5AAC256DBE8F003C73A2 /* TSInfoMessage.h in Headers */, - C3A3A15F256E1BB4004D228D /* ProtoUtils.h in Headers */, - C3A3A145256E1B49004D228D /* OWSMediaGalleryFinder.h in Headers */, B8856E33256F18D5001CE70E /* OWSStorage+Subclass.h in Headers */, C32C5E64256DDFD6003C73A2 /* OWSStorage.h in Headers */, ); @@ -4697,7 +4663,6 @@ C38EF3BD255B6DE7007E1867 /* ImageEditorTransform.swift in Sources */, C38EF24F255B6D67007E1867 /* UIColor+OWS.m in Sources */, C33FDC9A255A582000E217F9 /* ByteParser.m in Sources */, - C33FDDC0255A582000E217F9 /* SignalAccount.m in Sources */, C33FDC58255A582000E217F9 /* ReverseDispatchQueue.swift in Sources */, C33FDC78255A582000E217F9 /* TSConstants.m in Sources */, C38EF324255B6DBF007E1867 /* Bench.swift in Sources */, @@ -4869,7 +4834,6 @@ B8856D08256F10F1001CE70E /* DeviceSleepManager.swift in Sources */, C3471F4C25553AB000297E91 /* MessageReceiver+Decryption.swift in Sources */, C300A5D32554B05A00555489 /* TypingIndicator.swift in Sources */, - C3A3A156256E1B91004D228D /* ProtoUtils.m in Sources */, FD09799927FFC1A300936362 /* Attachment.swift in Sources */, C3471ECB2555356A00297E91 /* MessageSender+Encryption.swift in Sources */, FDF0B74928060D13004C14C5 /* QuotedReplyModel.swift in Sources */, @@ -4880,6 +4844,7 @@ C32C5AAD256DBE8F003C73A2 /* TSInfoMessage.m in Sources */, FD5D201127AA331F00FEA984 /* ConfigurationMessage+Convenience.swift in Sources */, C32C5A13256DB7A5003C73A2 /* PushNotificationAPI.swift in Sources */, + FDD250702837199200198BDA /* GarbageCollectionJob.swift in Sources */, C32A026325A801AA000ED5D4 /* NSData+messagePadding.m in Sources */, FD6A7A6B2818C17C00035AC1 /* UpdateProfilePictureJob.swift in Sources */, C32C5B84256DC54F003C73A2 /* SSKEnvironment.m in Sources */, @@ -4945,7 +4910,6 @@ FDF0B7582807F368004C14C5 /* MessageReceiverError.swift in Sources */, C32C5AB5256DBE8F003C73A2 /* TSOutgoingMessage+Conversion.swift in Sources */, C32C5E5B256DDF45003C73A2 /* OWSStorage.m in Sources */, - C32C5E15256DDC78003C73A2 /* SSKPreferences.swift in Sources */, C32C5C4F256DCC36003C73A2 /* Storage+OpenGroups.swift in Sources */, FD6A7A692818BE7300035AC1 /* RetrieveDefaultOpenGroupRoomsJob.swift in Sources */, C3DA9C0725AE7396008F7C7E /* ConfigurationMessage.swift in Sources */, @@ -4995,13 +4959,11 @@ C32C5C3D256DCBAF003C73A2 /* AppReadiness.m in Sources */, B87EF17126367CF800124B3C /* FileServerAPIV2.swift in Sources */, FD09799B27FFC82D00936362 /* Quote.swift in Sources */, - C3A3A18A256E2092004D228D /* SignalRecipient.m in Sources */, C3C2A74425539EB700C340D1 /* Message.swift in Sources */, FD09798527FD1A6500936362 /* ClosedGroupKeyPair.swift in Sources */, C32C5DBF256DD743003C73A2 /* ClosedGroupPoller.swift in Sources */, C32C5EEE256DF54E003C73A2 /* TSDatabaseView.m in Sources */, C352A35B2557824E00338F3E /* AttachmentUploadJob.swift in Sources */, - C3A3A13C256E1B27004D228D /* OWSMediaGalleryFinder.m in Sources */, FD09797027FA6FF300936362 /* Profile.swift in Sources */, C32C5AAE256DBE8F003C73A2 /* TSInteraction.m in Sources */, C32C5FD6256E0346003C73A2 /* Notification+Thread.swift in Sources */, @@ -5018,7 +4980,6 @@ FD09C5EA282A1BB2000CE219 /* ThreadTypingIndicator.swift in Sources */, FDF0B75E280AAF35004C14C5 /* Preferences.swift in Sources */, C32C5AB0256DBE8F003C73A2 /* TSOutgoingMessage.m in Sources */, - B82A0C3826B9098200C1BCE3 /* MessageInvalidator.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5030,7 +4991,6 @@ B8041AA725C90927003C2166 /* TypingIndicatorCell.swift in Sources */, B8CCF63723961D6D0091D419 /* NewDMVC.swift in Sources */, FDFDE12A282D056B0098B17F /* MediaZoomAnimationController.swift in Sources */, - 452EC6DF205E9E30000E787C /* MediaGalleryViewController.swift in Sources */, 4C1885D2218F8E1C00B67051 /* PhotoGridViewCell.swift in Sources */, 34D1F0501F7D45A60066283D /* GifPickerCell.swift in Sources */, C3E5C2FA251DBABB0040DFFC /* EditClosedGroupVC.swift in Sources */, @@ -5119,7 +5079,6 @@ 4C586926224FAB83003FD070 /* AVAudioSession+OWS.m in Sources */, C331FFF42558FF0300070591 /* PNOptionView.swift in Sources */, 4C4AE6A1224AF35700D4AF6F /* SendMediaNavigationController.swift in Sources */, - 45F32C222057297A00A300D5 /* MediaDetailViewController.m in Sources */, B82149C125D605C6009C0F2A /* InfoBanner.swift in Sources */, C3DAB3242480CB2B00725F25 /* SRCopyableLabel.swift in Sources */, B8CCF63F23975CFB0091D419 /* JoinOpenGroupVC.swift in Sources */, @@ -5152,6 +5111,7 @@ C31D1DE32521718E005D4DA8 /* UserSelectionVC.swift in Sources */, 34A6C28021E503E700B5B12E /* OWSImagePickerController.swift in Sources */, C31A6C5C247F2CF3001123EF /* CGRect+Utilities.swift in Sources */, + FD4B200E283492210034334B /* InsetLockableTableView.swift in Sources */, 4C21D5D6223A9DC500EF8A77 /* UIAlerts+iOS9.m in Sources */, B8269D3325C7A8C600488AB4 /* InputViewButton.swift in Sources */, B8269D3D25C7B34D00488AB4 /* InputTextView.swift in Sources */, @@ -5174,6 +5134,8 @@ B8D0A25025E3678700C1835E /* LinkDeviceVC.swift in Sources */, B894D0752339EDCF00B4D94D /* NukeDataModal.swift in Sources */, 7B4C75CD26BB92060000AC89 /* DeletedMessageView.swift in Sources */, + FDD250722837234B00198BDA /* MediaGalleryNavigationController.swift in Sources */, + FDD2506E283711D600198BDA /* DifferenceKit+Utilities.swift in Sources */, B897621C25D201F7004F83B2 /* ScrollToBottomButton.swift in Sources */, 346B66311F4E29B200E5122F /* CropScaleImageViewController.swift in Sources */, 45E5A6991F61E6DE001E4A8A /* MarqueeLabel.swift in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 86147e117..f61412cfa 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -462,7 +462,7 @@ extension ConversationVC: resetMentions() - if Environment.shared.preferences.soundInForeground() { + if GRDBStorage.shared[.playNotificationSoundInForeground] { let soundID = Preferences.Sound.systemSoundId(for: .messageSent, quiet: true) AudioServicesPlaySystemSound(soundID) } @@ -697,13 +697,31 @@ extension ConversationVC: default: let viewController: UIViewController? = MediaGalleryViewModel.createDetailViewController( for: self.viewModel.viewData.thread.id, - item: item, + threadVariant: self.viewModel.viewData.thread.variant, + interactionId: item.interactionId, selectedAttachmentId: mediaView.attachment.id, options: [ .sliderEnabled, .showAllMediaButton ] ) if let viewController: UIViewController = viewController { - self.present(viewController, animated: true, completion: nil) + /// Delay becoming the first responder to make the return transition a little nicer (allows + /// for the footer on the detail view to slide out rather than instantly vanish) + self.delayFirstResponder = true + + /// Dismiss the input before starting the presentation to make everything look smoother + self.resignFirstResponder() + + /// Delay the actual presentation to give the 'resignFirstResponder' call the chance to complete + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) { [weak self] in + /// Lock the contentOffset of the tableView so the transition doesn't look buggy + self?.tableView.lockContentOffset = true + + self?.present(viewController, animated: true) { [weak self] in + // Unlock the contentOffset so everything will be in the right + // place when we return + self?.tableView.lockContentOffset = false + } + } } } @@ -1527,7 +1545,7 @@ extension ConversationVC { { var newViewControllers = viewControllers newViewControllers.remove(at: messageRequestsIndex) - self?.navigationController?.setViewControllers(newViewControllers, animated: false) + self?.navigationController?.viewControllers = newViewControllers } } } @@ -1604,6 +1622,7 @@ extension ConversationVC { extension ConversationVC: MediaPresentationContextProvider { func mediaPresentationContext(mediaItem: Media, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? { guard case let .gallery(galleryItem) = mediaItem else { return nil } + // Note: According to Apple's docs the 'indexPathsForVisibleRows' method returns an // unsorted array which means we can't use it to determine the desired 'visibleCell' // we are after, due to this we will need to iterate all of the visible cells to find @@ -1632,7 +1651,8 @@ extension ConversationVC: MediaPresentationContextProvider { let cornerRadius: CGFloat let cornerMask: CACornerMask - let presentationFrame = coordinateSpace.convert(targetView.frame, from: mediaSuperview) + let presentationFrame: CGRect = coordinateSpace.convert(targetView.frame, from: mediaSuperview) + let frameInBubble: CGRect = messageCell.bubbleView.convert(targetView.frame, from: mediaSuperview) if messageCell.bubbleView.bounds == targetView.bounds { cornerRadius = messageCell.bubbleView.layer.cornerRadius @@ -1648,39 +1668,39 @@ extension ConversationVC: MediaPresentationContextProvider { if cellMaskedCorners.contains(.layerMinXMinYCorner) && - targetView.frame.minX < CGFloat.leastNonzeroMagnitude && - targetView.frame.minY < CGFloat.leastNonzeroMagnitude + frameInBubble.minX < CGFloat.leastNonzeroMagnitude && + frameInBubble.minY < CGFloat.leastNonzeroMagnitude { newCornerMask.insert(.layerMinXMinYCorner) } if cellMaskedCorners.contains(.layerMaxXMinYCorner) && - abs(targetView.frame.maxX - messageCell.bubbleView.bounds.width) < CGFloat.leastNonzeroMagnitude && - targetView.frame.minY < CGFloat.leastNonzeroMagnitude + abs(frameInBubble.maxX - messageCell.bubbleView.bounds.width) < CGFloat.leastNonzeroMagnitude && + frameInBubble.minY < CGFloat.leastNonzeroMagnitude { newCornerMask.insert(.layerMaxXMinYCorner) } if cellMaskedCorners.contains(.layerMinXMaxYCorner) && - targetView.frame.minX < CGFloat.leastNonzeroMagnitude && - abs(targetView.frame.maxY - messageCell.bubbleView.bounds.height) < CGFloat.leastNonzeroMagnitude + frameInBubble.minX < CGFloat.leastNonzeroMagnitude && + abs(frameInBubble.maxY - messageCell.bubbleView.bounds.height) < CGFloat.leastNonzeroMagnitude { newCornerMask.insert(.layerMinXMaxYCorner) } if cellMaskedCorners.contains(.layerMaxXMaxYCorner) && - abs(targetView.frame.maxX - messageCell.bubbleView.bounds.width) < CGFloat.leastNonzeroMagnitude && - abs(targetView.frame.maxY - messageCell.bubbleView.bounds.height) < CGFloat.leastNonzeroMagnitude + abs(frameInBubble.maxX - messageCell.bubbleView.bounds.width) < CGFloat.leastNonzeroMagnitude && + abs(frameInBubble.maxY - messageCell.bubbleView.bounds.height) < CGFloat.leastNonzeroMagnitude { newCornerMask.insert(.layerMaxXMaxYCorner) } cornerMask = newCornerMask } - + return MediaPresentationContext( mediaView: targetView, presentationFrame: presentationFrame, diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index b89ef4443..bac286a98 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -18,6 +18,10 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers private var dataChangeObservable: DatabaseCancellable? private var hasLoadedInitialData: Bool = false + /// This flag indicates whether the data has been reloaded after a disappearance (it defaults to true as it will never + /// have disappeared before) + private var hasReloadedDataAfterDisappearance: Bool = true + var focusedMessageIndexPath: IndexPath? var initialUnreadCount: UInt = 0 var unreadViewItems: [ConversationViewItem] = [] @@ -50,7 +54,11 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers var baselineKeyboardHeight: CGFloat = 0 var audioSession: OWSAudioSession { Environment.shared.audioSession } - override var canBecomeFirstResponder: Bool { true } + + /// This flag is used to temporarily prevent the ConversationVC from becoming the first responder (primarily used with + /// custom transitions from preventing them from being buggy + var delayFirstResponder: Bool = false + override var canBecomeFirstResponder: Bool { !delayFirstResponder } override var inputAccessoryView: UIView? { guard @@ -119,8 +127,8 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers return result }() - lazy var tableView: UITableView = { - let result: UITableView = UITableView() + lazy var tableView: InsetLockableTableView = { + let result: InsetLockableTableView = InsetLockableTableView() result.separatorStyle = .none result.backgroundColor = .clear result.showsVerticalScrollIndicator = false @@ -213,7 +221,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers return result }() - private let messageRequestAcceptButton: UIButton = { + private lazy var messageRequestAcceptButton: UIButton = { let result: UIButton = UIButton() result.translatesAutoresizingMaskIntoConstraints = false result.clipsToBounds = true @@ -244,7 +252,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers return result }() - private let messageRequestDeleteButton: UIButton = { + private lazy var messageRequestDeleteButton: UIButton = { let result: UIButton = UIButton() result.translatesAutoresizingMaskIntoConstraints = false result.clipsToBounds = true @@ -459,6 +467,14 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers highlightFocusedMessageIfNeeded() didFinishInitialLayout = true viewModel.markAllAsRead() + + if delayFirstResponder { + delayFirstResponder = false + + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) { [weak self] in + self?.becomeFirstResponder() + } + } } override func viewWillDisappear(_ animated: Bool) { @@ -474,6 +490,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers super.viewDidDisappear(animated) mediaCache.removeAllObjects() + hasReloadedDataAfterDisappearance = false } @objc func applicationDidBecomeActive(_ notification: Notification) { @@ -501,10 +518,11 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers } private func handleUpdates(_ updatedViewData: ConversationViewModel.ViewData, initialLoad: Bool = false) { - // 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 { + // Ensure the first load or a load when returning from a child screen runs without animations (if + // we don't do this the cells will animate in from a frame of CGRect.zero or have a buggy transition) + guard hasLoadedInitialData && hasReloadedDataAfterDisappearance else { hasLoadedInitialData = true + hasReloadedDataAfterDisappearance = true UIView.performWithoutAnimation { handleUpdates(updatedViewData, initialLoad: true) } return } @@ -556,6 +574,12 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers let changeset = StagedChangeset(source: viewModel.viewData.items, target: updatedViewData.items) tableView.reload( using: StagedChangeset(source: viewModel.viewData.items, target: updatedViewData.items), + deleteSectionsAnimation: .bottom, + insertSectionsAnimation: .bottom, + reloadSectionsAnimation: .none, + deleteRowsAnimation: .bottom, + insertRowsAnimation: .bottom, + reloadRowsAnimation: .none, interrupt: { return $0.changeCount > 100 } // Prevent too many changes from causing performance issues @@ -635,6 +659,9 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers } } + + // MARK: Notifications + private func highlightFocusedMessageIfNeeded() { if let indexPath = focusedMessageIndexPath, let cell = tableView.cellForRow(at: indexPath) as? VisibleMessageCell { cell.highlight() diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift index 6d9034cc4..2e153f00d 100644 --- a/Session/Conversations/Input View/InputView.swift +++ b/Session/Conversations/Input View/InputView.swift @@ -249,15 +249,20 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M // Suggest that the user enable link previews if they haven't already and we haven't // told them about link previews yet let text = inputTextView.text! - let userDefaults = UserDefaults.standard - if !OWSLinkPreview.allPreviewUrls(forMessageBodyText: text).isEmpty && !SSKPreferences.areLinkPreviewsEnabled - && !userDefaults[.hasSeenLinkPreviewSuggestion] { + let areLinkPreviewsEnabled: Bool = GRDBStorage.shared[.areLinkPreviewsEnabled] + + if + !OWSLinkPreview.allPreviewUrls(forMessageBodyText: text).isEmpty && + !areLinkPreviewsEnabled && + !UserDefaults.standard[.hasSeenLinkPreviewSuggestion] + { delegate?.showLinkPreviewSuggestionModal() - userDefaults[.hasSeenLinkPreviewSuggestion] = true + UserDefaults.standard[.hasSeenLinkPreviewSuggestion] = true return } // Check that link previews are enabled - guard SSKPreferences.areLinkPreviewsEnabled else { return } + guard areLinkPreviewsEnabled else { return } + // Proceed autoGenerateLinkPreview() } diff --git a/Session/Conversations/Message Cells/Content Views/QuoteView.swift b/Session/Conversations/Message Cells/Content Views/QuoteView.swift index 4338ebfa8..ddeb9ae33 100644 --- a/Session/Conversations/Message Cells/Content Views/QuoteView.swift +++ b/Session/Conversations/Message Cells/Content Views/QuoteView.swift @@ -205,7 +205,7 @@ final class QuoteView: UIView { authorLabel.lineBreakMode = .byTruncatingTail authorLabel.text = Profile.displayName( id: authorId, - context: (threadVariant == .openGroup ? .openGroup : .regular) + threadVariant: threadVariant ) authorLabel.textColor = textColor authorLabel.font = .boldSystemFont(ofSize: Values.smallFontSize) diff --git a/Session/Conversations/Settings/OWSConversationSettingsViewController.m b/Session/Conversations/Settings/OWSConversationSettingsViewController.m index 079bf4979..f2bc3be9e 100644 --- a/Session/Conversations/Settings/OWSConversationSettingsViewController.m +++ b/Session/Conversations/Settings/OWSConversationSettingsViewController.m @@ -10,7 +10,6 @@ #import #import #import -#import #import #import #import @@ -41,7 +40,6 @@ CGFloat kIconViewLength = 24; @property (nonatomic) BOOL isDisappearingMessagesEnabled; @property (nonatomic) NSInteger disappearingMessagesDurationIndex; -@property (nullable, nonatomic) MediaGallery *mediaGallery; @property (nonatomic, readonly) UIImageView *avatarView; @property (nonatomic, readonly) UILabel *disappearingMessagesDurationLabel; @property (nonatomic) UILabel *displayNameLabel; diff --git a/Session/Conversations/Views & Modals/InsetLockableTableView.swift b/Session/Conversations/Views & Modals/InsetLockableTableView.swift new file mode 100644 index 000000000..b724eeb82 --- /dev/null +++ b/Session/Conversations/Views & Modals/InsetLockableTableView.swift @@ -0,0 +1,35 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit + +/// This custom UITableView allows us to lock the contentOffset to a specific value - it's current used to prevent +/// the ConversationVC first responder resignation from making the MediaGalleryDetailViewController transition +/// from looking buggy (ie. the table scrolls down with the resignation during the transition) +public class InsetLockableTableView: UITableView { + public var lockContentOffset: Bool = false { + didSet { + guard !lockContentOffset else { return } + + self.contentOffset = newOffset + } + } + public var oldOffset: CGPoint = .zero + public var newOffset: CGPoint = .zero + + public override func layoutSubviews() { + newOffset = self.contentOffset + + guard !lockContentOffset else { + self.contentOffset = CGPoint( + x: newOffset.x, + y: oldOffset.y + ) + super.layoutSubviews() + return + } + + super.layoutSubviews() + + oldOffset = self.contentOffset + } +} diff --git a/Session/Conversations/Views & Modals/LinkPreviewModal.swift b/Session/Conversations/Views & Modals/LinkPreviewModal.swift index b8bf44dd5..118ab01a5 100644 --- a/Session/Conversations/Views & Modals/LinkPreviewModal.swift +++ b/Session/Conversations/Views & Modals/LinkPreviewModal.swift @@ -1,8 +1,15 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -final class LinkPreviewModal : Modal { +import UIKit +import GRDB +import SessionUIKit +import SessionMessagingKit + +final class LinkPreviewModal: Modal { private let onLinkPreviewsEnabled: () -> Void - // MARK: Lifecycle + // MARK: - Lifecycle + init(onLinkPreviewsEnabled: @escaping () -> Void) { self.onLinkPreviewsEnabled = onLinkPreviewsEnabled super.init(nibName: nil, bundle: nil) @@ -18,22 +25,23 @@ final class LinkPreviewModal : Modal { override func populateContentView() { // Title - let titleLabel = UILabel() + let titleLabel: UILabel = UILabel() titleLabel.textColor = Colors.text titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize) - titleLabel.text = NSLocalizedString("modal_link_previews_title", comment: "") + titleLabel.text = "modal_link_previews_title".localized() titleLabel.textAlignment = .center + // Message - let messageLabel = UILabel() + let messageLabel: UILabel = UILabel() messageLabel.textColor = Colors.text messageLabel.font = .systemFont(ofSize: Values.smallFontSize) - let message = NSLocalizedString("modal_link_previews_explanation", comment: "") - messageLabel.text = message + messageLabel.text = "modal_link_previews_explanation".localized() messageLabel.numberOfLines = 0 messageLabel.lineBreakMode = .byWordWrapping messageLabel.textAlignment = .center + // Enable button - let enableButton = UIButton() + let enableButton: UIButton = UIButton() enableButton.set(.height, to: Values.mediumButtonHeight) enableButton.layer.cornerRadius = Modal.buttonCornerRadius enableButton.backgroundColor = Colors.buttonBackground @@ -41,13 +49,15 @@ final class LinkPreviewModal : Modal { enableButton.setTitleColor(Colors.text, for: UIControl.State.normal) enableButton.setTitle(NSLocalizedString("modal_link_previews_button_title", comment: ""), for: UIControl.State.normal) enableButton.addTarget(self, action: #selector(enable), for: UIControl.Event.touchUpInside) + // Button stack view - let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, enableButton ]) + let buttonStackView: UIStackView = UIStackView(arrangedSubviews: [ cancelButton, enableButton ]) buttonStackView.axis = .horizontal buttonStackView.spacing = Values.mediumSpacing buttonStackView.distribution = .fillEqually + // Main stack view - let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ]) + let mainStackView: UIStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ]) mainStackView.axis = .vertical mainStackView.spacing = Values.largeSpacing contentView.addSubview(mainStackView) @@ -57,9 +67,13 @@ final class LinkPreviewModal : Modal { contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing) } - // MARK: Interaction + // MARK: - Interaction + @objc private func enable() { - SSKPreferences.areLinkPreviewsEnabled = true + GRDBStorage.shared.writeAsync { db in + db[.areLinkPreviewsEnabled] = true + } + presentingViewController?.dismiss(animated: true, completion: nil) onLinkPreviewsEnabled() } diff --git a/Session/Media Viewing & Editing/MediaDetailViewController.h b/Session/Media Viewing & Editing/MediaDetailViewController.h deleted file mode 100644 index 106b20aa6..000000000 --- a/Session/Media Viewing & Editing/MediaDetailViewController.h +++ /dev/null @@ -1,54 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@protocol ConversationViewItem; - -@class GalleryItemBox; -@class MediaDetailViewController; -@class TSAttachment; - -typedef NS_OPTIONS(NSInteger, MediaGalleryOption) { - MediaGalleryOptionSliderEnabled = 1 << 0, - MediaGalleryOptionShowAllMediaButton = 1 << 1 -}; - -@protocol MediaDetailViewControllerDelegate - -- (void)mediaDetailViewController:(MediaDetailViewController *)mediaDetailViewController - requestDeleteAttachment:(TSAttachment *)attachment; - -- (void)mediaDetailViewController:(MediaDetailViewController *)mediaDetailViewController - isPlayingVideo:(BOOL)isPlayingVideo; - -- (void)mediaDetailViewControllerDidTapMedia:(MediaDetailViewController *)mediaDetailViewController; - -@end - -@interface MediaDetailViewController : OWSViewController - -@property (nonatomic, weak) id delegate; -@property (nonatomic, readonly) GalleryItemBox *galleryItemBox; - -// If viewItem is non-null, long press will show a menu controller. -- (instancetype)initWithGalleryItemBox:(GalleryItemBox *)galleryItemBox - viewItem:(nullable id)viewItem; -#pragma mark - Actions - -- (void)didPressPlayBarButton:(id)sender; -- (void)didPressPauseBarButton:(id)sender; -- (void)playVideo; - -// Stops playback and rewinds -- (void)stopAnyVideo; - -- (void)setShouldHideToolbars:(BOOL)shouldHideToolbars; -- (void)zoomOutAnimated:(BOOL)isAnimated; - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Media Viewing & Editing/MediaDetailViewController.m b/Session/Media Viewing & Editing/MediaDetailViewController.m deleted file mode 100644 index ece98e334..000000000 --- a/Session/Media Viewing & Editing/MediaDetailViewController.m +++ /dev/null @@ -1,501 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "MediaDetailViewController.h" -#import "AttachmentSharing.h" -#import "ConversationViewItem.h" -#import "Session-Swift.h" -#import "TSAttachmentStream.h" -#import "TSInteraction.h" -#import "UIColor+OWS.h" -#import "UIUtil.h" -#import "UIView+OWS.h" -#import -#import -#import -#import -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -#pragma mark - - -@interface MediaDetailViewController () - -@property (nonatomic) UIScrollView *scrollView; -@property (nonatomic) UIView *mediaView; -@property (nonatomic) UIView *presentationView; -@property (nonatomic) UIView *replacingView; -@property (nonatomic) UIButton *shareButton; - -@property (nonatomic) TSAttachmentStream *attachmentStream; -@property (nonatomic, nullable) id viewItem; -@property (nonatomic, nullable) UIImage *image; - -@property (nonatomic, nullable) OWSVideoPlayer *videoPlayer; -@property (nonatomic, nullable) UIButton *playVideoButton; -@property (nonatomic, nullable) PlayerProgressBar *videoProgressBar; -@property (nonatomic, nullable) UIBarButtonItem *videoPlayBarButton; -@property (nonatomic, nullable) UIBarButtonItem *videoPauseBarButton; - -@property (nonatomic, nullable) NSArray *presentationViewConstraints; -@property (nonatomic, nullable) NSLayoutConstraint *mediaViewBottomConstraint; -@property (nonatomic, nullable) NSLayoutConstraint *mediaViewLeadingConstraint; -@property (nonatomic, nullable) NSLayoutConstraint *mediaViewTopConstraint; -@property (nonatomic, nullable) NSLayoutConstraint *mediaViewTrailingConstraint; - -@end - -#pragma mark - - -@implementation MediaDetailViewController - -- (void)dealloc -{ - [self stopAnyVideo]; -} - -- (instancetype)initWithGalleryItemBox:(GalleryItemBox *)galleryItemBox - viewItem:(nullable id)viewItem -{ - self = [super initWithNibName:nil bundle:nil]; - if (!self) { - return self; - } - - _galleryItemBox = galleryItemBox; - _viewItem = viewItem; - - // We cache the image data in case the attachment stream is deleted. - __weak MediaDetailViewController *weakSelf = self; - _image = [galleryItemBox.attachmentStream - thumbnailImageLargeWithSuccess:^(UIImage *image) { - weakSelf.image = image; - [weakSelf updateContents]; - [weakSelf updateMinZoomScale]; - } - failure:^{ - OWSLogWarn(@"Could not load media."); - }]; - - return self; -} - -- (TSAttachmentStream *)attachmentStream -{ - return self.galleryItemBox.attachmentStream; -} - -- (BOOL)isAnimated -{ - return self.attachmentStream.isAnimated; -} - -- (BOOL)isVideo -{ - return self.attachmentStream.isVideo; -} - -- (void)viewDidLoad -{ - [super viewDidLoad]; - - self.view.backgroundColor = LKColors.navigationBarBackground; - - [self updateContents]; - - // Loki: Set navigation bar background color - UINavigationBar *navigationBar = self.navigationController.navigationBar; - [navigationBar setBackgroundImage:[UIImage new] forBarMetrics:UIBarMetricsDefault]; - navigationBar.shadowImage = [UIImage new]; - [navigationBar setTranslucent:NO]; - navigationBar.barTintColor = LKColors.navigationBarBackground; -} - -- (void)viewWillAppear:(BOOL)animated -{ - [super viewWillAppear:animated]; - [self resetMediaFrame]; -} - -- (void)viewDidLayoutSubviews -{ - [super viewDidLayoutSubviews]; - - [self updateMinZoomScale]; - [self centerMediaViewConstraints]; -} - -- (void)updateMinZoomScale -{ - if (!self.image) { - self.scrollView.minimumZoomScale = 1.f; - self.scrollView.maximumZoomScale = 1.f; - self.scrollView.zoomScale = 1.f; - return; - } - - CGSize viewSize = self.scrollView.bounds.size; - UIImage *image = self.image; - OWSAssertDebug(image); - - if (image.size.width == 0 || image.size.height == 0) { - OWSFailDebug(@"Invalid image dimensions. %@", NSStringFromCGSize(image.size)); - return; - } - - CGFloat scaleWidth = viewSize.width / image.size.width; - CGFloat scaleHeight = viewSize.height / image.size.height; - CGFloat minScale = MIN(scaleWidth, scaleHeight); - - if (minScale != self.scrollView.minimumZoomScale) { - self.scrollView.minimumZoomScale = minScale; - self.scrollView.maximumZoomScale = minScale * 8; - self.scrollView.zoomScale = minScale; - } -} - -- (void)zoomOutAnimated:(BOOL)isAnimated -{ - if (self.scrollView.zoomScale != self.scrollView.minimumZoomScale) { - [self.scrollView setZoomScale:self.scrollView.minimumZoomScale animated:isAnimated]; - } -} - -#pragma mark - Initializers - -- (void)updateContents -{ - [self.mediaView removeFromSuperview]; - [self.scrollView removeFromSuperview]; - [self.playVideoButton removeFromSuperview]; - [self.videoProgressBar removeFromSuperview]; - - UIScrollView *scrollView = [UIScrollView new]; - [self.view addSubview:scrollView]; - self.scrollView = scrollView; - scrollView.delegate = self; - - scrollView.showsVerticalScrollIndicator = NO; - scrollView.showsHorizontalScrollIndicator = NO; - scrollView.decelerationRate = UIScrollViewDecelerationRateFast; - - if (@available(iOS 11.0, *)) { - [scrollView contentInsetAdjustmentBehavior]; - } else { - self.automaticallyAdjustsScrollViewInsets = NO; - } - - [scrollView ows_autoPinToSuperviewEdges]; - - if (self.isAnimated) { - if (self.attachmentStream.isValidImage) { - YYImage *animatedGif = [YYImage imageWithContentsOfFile:self.attachmentStream.originalFilePath]; - YYAnimatedImageView *animatedView = [YYAnimatedImageView new]; - animatedView.image = animatedGif; - self.mediaView = animatedView; - } else { - self.mediaView = [UIView new]; - self.mediaView.backgroundColor = LKColors.unimportant; - } - } else if (!self.image) { - // Still loading thumbnail. - self.mediaView = [UIView new]; - self.mediaView.backgroundColor = LKColors.unimportant; - } else if (self.isVideo) { - if (self.attachmentStream.isValidVideo) { - self.mediaView = [self buildVideoPlayerView]; - } else { - self.mediaView = [UIView new]; - self.mediaView.backgroundColor = LKColors.unimportant; - } - } else { - // Present the static image using standard UIImageView - UIImageView *imageView = [[UIImageView alloc] initWithImage:self.image]; - self.mediaView = imageView; - } - - OWSAssertDebug(self.mediaView); - - // We add these gestures to mediaView rather than - // the root view so that interacting with the video player - // progres bar doesn't trigger any of these gestures. - [self addGestureRecognizersToView:self.mediaView]; - - [scrollView addSubview:self.mediaView]; - self.mediaViewLeadingConstraint = [self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeLeading]; - self.mediaViewTopConstraint = [self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeTop]; - self.mediaViewTrailingConstraint = [self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeTrailing]; - self.mediaViewBottomConstraint = [self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeBottom]; - - self.mediaView.contentMode = UIViewContentModeScaleAspectFit; - self.mediaView.userInteractionEnabled = YES; - self.mediaView.clipsToBounds = YES; - self.mediaView.layer.allowsEdgeAntialiasing = YES; - self.mediaView.translatesAutoresizingMaskIntoConstraints = NO; - - // Use trilinear filters for better scaling quality at - // some performance cost. - self.mediaView.layer.minificationFilter = kCAFilterTrilinear; - self.mediaView.layer.magnificationFilter = kCAFilterTrilinear; - - if (self.isVideo) { - PlayerProgressBar *videoProgressBar = [PlayerProgressBar new]; - videoProgressBar.delegate = self; - videoProgressBar.player = self.videoPlayer.avPlayer; - - // We hide the progress bar until either: - // 1. Video completes playing - // 2. User taps the screen - videoProgressBar.hidden = YES; - - self.videoProgressBar = videoProgressBar; - [self.view addSubview:videoProgressBar]; - [videoProgressBar autoPinWidthToSuperview]; - [videoProgressBar autoPinEdgeToSuperviewSafeArea:ALEdgeTop]; - CGFloat kVideoProgressBarHeight = 44; - [videoProgressBar autoSetDimension:ALDimensionHeight toSize:kVideoProgressBarHeight]; - - UIButton *playVideoButton = [UIButton new]; - self.playVideoButton = playVideoButton; - - [playVideoButton addTarget:self action:@selector(playVideo) forControlEvents:UIControlEventTouchUpInside]; - - UIImage *playImage = [UIImage imageNamed:@"CirclePlay"]; - [playVideoButton setBackgroundImage:playImage forState:UIControlStateNormal]; - playVideoButton.contentMode = UIViewContentModeScaleAspectFill; - - [self.view addSubview:playVideoButton]; - - CGFloat playVideoButtonWidth = 72.f; - [playVideoButton autoSetDimensionsToSize:CGSizeMake(playVideoButtonWidth, playVideoButtonWidth)]; - [playVideoButton autoCenterInSuperview]; - } -} - -- (UIView *)buildVideoPlayerView -{ - NSURL *_Nullable attachmentUrl = self.attachmentStream.originalMediaURL; - - NSFileManager *fileManager = [NSFileManager defaultManager]; - if (![fileManager fileExistsAtPath:[attachmentUrl path]]) { - OWSFailDebug(@"Missing video file"); - } - - OWSVideoPlayer *player = [[OWSVideoPlayer alloc] initWithUrl:attachmentUrl]; - [player seekToTime:kCMTimeZero]; - player.delegate = self; - self.videoPlayer = player; - - VideoPlayerView *playerView = [VideoPlayerView new]; - playerView.player = player.avPlayer; - - [NSLayoutConstraint autoSetPriority:UILayoutPriorityDefaultLow - forConstraints:^{ - [playerView autoSetDimensionsToSize:self.image.size]; - }]; - - return playerView; -} - -- (void)setShouldHideToolbars:(BOOL)shouldHideToolbars -{ - self.videoProgressBar.hidden = shouldHideToolbars; -} - -- (void)addGestureRecognizersToView:(UIView *)view -{ - UITapGestureRecognizer *doubleTap = - [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(didDoubleTapImage:)]; - doubleTap.numberOfTapsRequired = 2; - [view addGestureRecognizer:doubleTap]; - - UITapGestureRecognizer *singleTap = - [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(didSingleTapImage:)]; - [singleTap requireGestureRecognizerToFail:doubleTap]; - [view addGestureRecognizer:singleTap]; -} - -#pragma mark - Gesture Recognizers - -- (void)didSingleTapImage:(UITapGestureRecognizer *)gesture -{ - [self.delegate mediaDetailViewControllerDidTapMedia:self]; -} - -- (void)didDoubleTapImage:(UITapGestureRecognizer *)gesture -{ - OWSLogVerbose(@"did double tap image."); - if (self.scrollView.zoomScale == self.scrollView.minimumZoomScale) { - CGFloat kDoubleTapZoomScale = 2; - - CGFloat zoomWidth = self.scrollView.width / kDoubleTapZoomScale; - CGFloat zoomHeight = self.scrollView.height / kDoubleTapZoomScale; - - // center zoom rect around tapLocation - CGPoint tapLocation = [gesture locationInView:self.scrollView]; - CGFloat zoomX = MAX(0, tapLocation.x - zoomWidth / 2); - CGFloat zoomY = MAX(0, tapLocation.y - zoomHeight / 2); - - CGRect zoomRect = CGRectMake(zoomX, zoomY, zoomWidth, zoomHeight); - - CGRect translatedRect = [self.mediaView convertRect:zoomRect fromView:self.scrollView]; - - [self.scrollView zoomToRect:translatedRect animated:YES]; - } else { - // If already zoomed in at all, zoom out all the way. - [self zoomOutAnimated:YES]; - } -} - -- (void)didPressPlayBarButton:(id)sender -{ - OWSAssertDebug(self.isVideo); - OWSAssertDebug(self.videoPlayer); - [self playVideo]; -} - -- (void)didPressPauseBarButton:(id)sender -{ - OWSAssertDebug(self.isVideo); - OWSAssertDebug(self.videoPlayer); - [self pauseVideo]; -} - -#pragma mark - UIScrollViewDelegate - -- (nullable UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView -{ - return self.mediaView; -} - -- (void)centerMediaViewConstraints -{ - OWSAssertDebug(self.scrollView); - - CGSize scrollViewSize = self.scrollView.bounds.size; - CGSize imageViewSize = self.mediaView.frame.size; - - CGFloat yOffset = MAX(0, (scrollViewSize.height - imageViewSize.height) / 2); - self.mediaViewTopConstraint.constant = yOffset; - self.mediaViewBottomConstraint.constant = yOffset; - - CGFloat xOffset = MAX(0, (scrollViewSize.width - imageViewSize.width) / 2); - self.mediaViewLeadingConstraint.constant = xOffset; - self.mediaViewTrailingConstraint.constant = xOffset; -} - -- (void)scrollViewDidZoom:(UIScrollView *)scrollView -{ - [self centerMediaViewConstraints]; - [self.view layoutIfNeeded]; -} - -- (void)resetMediaFrame -{ - // HACK: Setting the frame to itself *seems* like it should be a no-op, but - // it ensures the content is drawn at the right frame. In particular I was - // reproducibly seeing some images squished (they were EXIF rotated, maybe - // related). similar to this report: - // https://stackoverflow.com/questions/27961884/swift-uiimageview-stretched-aspect - [self.view layoutIfNeeded]; - self.mediaView.frame = self.mediaView.frame; -} - -#pragma mark - Video Playback - -- (void)playVideo -{ - OWSAssertDebug(self.videoPlayer); - - self.playVideoButton.hidden = YES; - - [self.videoPlayer play]; - - [self.delegate mediaDetailViewController:self isPlayingVideo:YES]; -} - -- (void)pauseVideo -{ - OWSAssertDebug(self.isVideo); - OWSAssertDebug(self.videoPlayer); - - [self.videoPlayer pause]; - - [self.delegate mediaDetailViewController:self isPlayingVideo:NO]; -} - -- (void)stopAnyVideo -{ - if (self.isVideo) { - [self stopVideo]; - } -} - -- (void)stopVideo -{ - OWSAssertDebug(self.isVideo); - OWSAssertDebug(self.videoPlayer); - - [self.videoPlayer stop]; - - self.playVideoButton.hidden = NO; - - [self.delegate mediaDetailViewController:self isPlayingVideo:NO]; -} - -#pragma mark - OWSVideoPlayer - -- (void)videoPlayerDidPlayToCompletion:(OWSVideoPlayer *)videoPlayer -{ - OWSAssertDebug(self.isVideo); - OWSAssertDebug(self.videoPlayer); - OWSLogVerbose(@""); - - [self stopVideo]; -} - -#pragma mark - PlayerProgressBarDelegate - -- (void)playerProgressBarDidStartScrubbing:(PlayerProgressBar *)playerProgressBar -{ - OWSAssertDebug(self.videoPlayer); - [self.videoPlayer pause]; -} - -- (void)playerProgressBar:(PlayerProgressBar *)playerProgressBar scrubbedToTime:(CMTime)time -{ - OWSAssertDebug(self.videoPlayer); - [self.videoPlayer seekToTime:time]; -} - -- (void)playerProgressBar:(PlayerProgressBar *)playerProgressBar - didFinishScrubbingAtTime:(CMTime)time - shouldResumePlayback:(BOOL)shouldResumePlayback -{ - OWSAssertDebug(self.videoPlayer); - [self.videoPlayer seekToTime:time]; - - if (shouldResumePlayback) { - [self.videoPlayer play]; - } -} - -#pragma mark - Saving images to Camera Roll - -- (void)image:(UIImage *)image didFinishSavingWithError:(NSError *)error contextInfo:(void *)contextInfo -{ - if (error) { - OWSLogWarn(@"There was a problem saving <%@> to camera roll.", error.localizedDescription); - } -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Media Viewing & Editing/MediaDetailViewController.swift b/Session/Media Viewing & Editing/MediaDetailViewController.swift index 0a8ab0dac..b83035af2 100644 --- a/Session/Media Viewing & Editing/MediaDetailViewController.swift +++ b/Session/Media Viewing & Editing/MediaDetailViewController.swift @@ -1,3 +1,427 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -import Foundation +import UIKit +import YYImage +import SessionUIKit +import SignalUtilitiesKit +import SessionMessagingKit + +public enum MediaGalleryOption { + case sliderEnabled + case showAllMediaButton +} + +class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVideoPlayerDelegate, PlayerProgressBarDelegate { + public let galleryItem: MediaGalleryViewModel.Item + public weak var delegate: MediaDetailViewControllerDelegate? + private var image: UIImage? + + // MARK: - UI + + private var mediaViewBottomConstraint: NSLayoutConstraint? + private var mediaViewLeadingConstraint: NSLayoutConstraint? + private var mediaViewTopConstraint: NSLayoutConstraint? + private var mediaViewTrailingConstraint: NSLayoutConstraint? + + private lazy var scrollView: UIScrollView = { + let result: UIScrollView = UIScrollView() + result.showsVerticalScrollIndicator = false + result.showsHorizontalScrollIndicator = false + result.contentInsetAdjustmentBehavior = .never + result.decelerationRate = .fast + result.delegate = self + + return result + }() + + public var mediaView: UIView = UIView() + private var playVideoButton: UIButton = UIButton() + private var videoProgressBar: PlayerProgressBar = PlayerProgressBar() + private var videoPlayer: OWSVideoPlayer? + + // MARK: - Initialization + + init( + galleryItem: MediaGalleryViewModel.Item, + delegate: MediaDetailViewControllerDelegate? = nil + ) { + self.galleryItem = galleryItem + self.delegate = delegate + + super.init(nibName: nil, bundle: nil) + + // We cache the image data in case the attachment stream is deleted. + galleryItem.attachment.thumbnail( + size: .large, + success: { [weak self] image, _ in + self?.image = image + + // Only reload the content if the view has already loaded (if it + // hasn't then it'll load with the image immediately) + if self?.isViewLoaded == true { + self?.updateContents() + self?.updateMinZoomScale() + } + }, + failure: { + SNLog("Could not load media.") + } + ) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.stopAnyVideo() + } + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + self.view.backgroundColor = Colors.navigationBarBackground + + self.view.addSubview(scrollView) + scrollView.pin(to: self.view) + + self.updateContents() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + self.resetMediaFrame() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + if mediaView is YYAnimatedImageView { + // Add a slight delay before starting the gif animation to prevent it from looking + // buggy due to the custom transition + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) { [weak self] in + (self?.mediaView as? YYAnimatedImageView)?.startAnimating() + } + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + self.updateMinZoomScale() + self.centerMediaViewConstraints() + } + + // MARK: - Functions + + private func updateMinZoomScale() { + guard let image: UIImage = image else { + self.scrollView.minimumZoomScale = 1 + self.scrollView.maximumZoomScale = 1 + self.scrollView.zoomScale = 1 + return + } + + let viewSize: CGSize = self.scrollView.bounds.size + + guard image.size.width > 0 && image.size.height > 0 else { + SNLog("Invalid image dimensions (\(image.size.width), \(image.size.height))") + return; + } + + let scaleWidth: CGFloat = (viewSize.width / image.size.width) + let scaleHeight: CGFloat = (viewSize.height / image.size.height) + let minScale: CGFloat = min(scaleWidth, scaleHeight) + + if minScale != self.scrollView.minimumZoomScale { + self.scrollView.minimumZoomScale = minScale + self.scrollView.maximumZoomScale = (minScale * 8) + self.scrollView.zoomScale = minScale + } + } + + public func zoomOut(animated: Bool) { + if self.scrollView.zoomScale != self.scrollView.minimumZoomScale { + self.scrollView.setZoomScale(self.scrollView.minimumZoomScale, animated: animated) + } + } + + // MARK: - Content + + private func updateContents() { + self.mediaView.removeFromSuperview() + self.playVideoButton.removeFromSuperview() + self.videoProgressBar.removeFromSuperview() + + // TODO: COnfirm this + scrollView.zoomScale = 1 + + if self.galleryItem.attachment.isAnimated { + if self.galleryItem.attachment.isValid, let originalFilePath: String = self.galleryItem.attachment.originalFilePath { + let animatedView: YYAnimatedImageView = YYAnimatedImageView() + animatedView.autoPlayAnimatedImage = false + animatedView.image = YYImage(contentsOfFile: originalFilePath) + self.mediaView = animatedView + } + else { + self.mediaView = UIView() + self.mediaView.backgroundColor = Colors.unimportant + } + } + else if self.image == nil { + // Still loading thumbnail. + self.mediaView = UIView() + self.mediaView.backgroundColor = Colors.unimportant + } + else if self.galleryItem.attachment.isVideo { + if self.galleryItem.attachment.isValid { + self.mediaView = self.buildVideoPlayerView() + } + else { + self.mediaView = UIView() + self.mediaView.backgroundColor = Colors.unimportant + } + } + else { + // Present the static image using standard UIImageView + self.mediaView = UIImageView(image: self.image) + } + + // We add these gestures to mediaView rather than + // the root view so that interacting with the video player + // progres bar doesn't trigger any of these gestures. + self.addGestureRecognizers(to: self.mediaView) + self.scrollView.addSubview(self.mediaView) + + self.mediaViewLeadingConstraint = self.mediaView.pin(.leading, to: .leading, of: self.scrollView) + self.mediaViewTopConstraint = self.mediaView.pin(.top, to: .top, of: self.scrollView) + self.mediaViewTrailingConstraint = self.mediaView.pin(.trailing, to: .trailing, of: self.scrollView) + self.mediaViewBottomConstraint = self.mediaView.pin(.bottom, to: .bottom, of: self.scrollView) + + self.mediaView.contentMode = .scaleAspectFit + self.mediaView.isUserInteractionEnabled = true + self.mediaView.clipsToBounds = true + self.mediaView.layer.allowsEdgeAntialiasing = true + self.mediaView.translatesAutoresizingMaskIntoConstraints = false + + // Use trilinear filters for better scaling quality at + // some performance cost. + self.mediaView.layer.minificationFilter = .trilinear + self.mediaView.layer.magnificationFilter = .trilinear + + if self.galleryItem.attachment.isVideo { + self.videoProgressBar = PlayerProgressBar() + self.videoProgressBar.delegate = self + self.videoProgressBar.player = self.videoPlayer?.avPlayer + + // We hide the progress bar until either: + // 1. Video completes playing + // 2. User taps the screen + self.videoProgressBar.isHidden = false + + self.view.addSubview(self.videoProgressBar) + + self.videoProgressBar.autoPinWidthToSuperview() + self.videoProgressBar.autoPinEdge(toSuperviewSafeArea: .top) + self.videoProgressBar.autoSetDimension(.height, toSize: 44) + + self.playVideoButton = UIButton() + self.playVideoButton.contentMode = .scaleAspectFill + self.playVideoButton.setBackgroundImage(UIImage(named: "CirclePlay"), for: .normal) + self.playVideoButton.addTarget(self, action: #selector(playVideo), for: .touchUpInside) + self.view.addSubview(self.playVideoButton) + + self.playVideoButton.set(.width, to: 72) + self.playVideoButton.set(.height, to: 72) + self.playVideoButton.center(in: self.view) + } + } + + private func buildVideoPlayerView() -> UIView { + guard + let originalFilePath: String = self.galleryItem.attachment.originalFilePath, + FileManager.default.fileExists(atPath: originalFilePath) + else { + owsFailDebug("Missing video file") + return UIView() + } + + self.videoPlayer = OWSVideoPlayer(url: URL(fileURLWithPath: originalFilePath)) + self.videoPlayer?.seek(to: .zero) + self.videoPlayer?.delegate = self + + let imageSize: CGSize = (self.image?.size ?? .zero) + let playerView: VideoPlayerView = VideoPlayerView() + playerView.player = self.videoPlayer?.avPlayer + + NSLayoutConstraint.autoSetPriority(.defaultLow) { + playerView.autoSetDimensions(to: imageSize) + } + + return playerView + } + + public func setShouldHideToolbars(_ shouldHideToolbars: Bool) { + self.videoProgressBar.isHidden = shouldHideToolbars + } + + private func addGestureRecognizers(to view: UIView) { + let doubleTap: UITapGestureRecognizer = UITapGestureRecognizer( + target: self, + action: #selector(didDoubleTapImage(_:)) + ) + doubleTap.numberOfTapsRequired = 2 + view.addGestureRecognizer(doubleTap) + + let singleTap: UITapGestureRecognizer = UITapGestureRecognizer( + target: self, + action: #selector(didSingleTapImage(_:)) + ) + singleTap.require(toFail: doubleTap) + view.addGestureRecognizer(singleTap) + } + + // MARK: - Gesture Recognizers + + @objc private func didSingleTapImage(_ gesture: UITapGestureRecognizer) { + self.delegate?.mediaDetailViewControllerDidTapMedia(self) + } + + @objc private func didDoubleTapImage(_ gesture: UITapGestureRecognizer) { + guard self.scrollView.zoomScale == self.scrollView.minimumZoomScale else { + // If already zoomed in at all, zoom out all the way. + self.zoomOut(animated: true) + return + } + + let doubleTapZoomScale: CGFloat = 2 + let zoomWidth: CGFloat = (self.scrollView.bounds.width / doubleTapZoomScale) + let zoomHeight: CGFloat = (self.scrollView.bounds.height / doubleTapZoomScale) + + // Center zoom rect around tapLocation + let tapLocation: CGPoint = gesture.location(in: self.scrollView) + let zoomX: CGFloat = max(0, tapLocation.x - zoomWidth / 2) + let zoomY: CGFloat = max(0, tapLocation.y - zoomHeight / 2) + let zoomRect: CGRect = CGRect(x: zoomX, y: zoomY, width: zoomWidth, height: zoomHeight) + let translatedRect: CGRect = self.mediaView.convert(zoomRect, to: self.scrollView) + + self.scrollView.zoom(to: translatedRect, animated: true) + } + + @objc public func didPressPlayBarButton() { + self.playVideo() + } + + @objc public func didPressPauseBarButton() { + self.pauseVideo() + } + + // MARK: - UIScrollViewDelegate + + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + return self.mediaView + } + + private func centerMediaViewConstraints() { + let scrollViewSize: CGSize = self.scrollView.bounds.size + let imageViewSize: CGSize = self.mediaView.frame.size + + // We want to modify the yOffset so the content remains centered on the screen (we can do this + // by subtracting half the parentViewController's y position) + // + // Note: Due to weird partial-pixel value rendering behaviours we need to round the inset either + // up or down depending on which direction the partial-pixel would end up rounded to make it + // align correctly + let halfHeightDiff: CGFloat = ((self.scrollView.bounds.size.height - self.mediaView.frame.size.height) / 2) + let shouldRoundUp: Bool = (round(halfHeightDiff) - halfHeightDiff > 0) + + let yOffset: CGFloat = ( + round((scrollViewSize.height - imageViewSize.height) / 2) - + (shouldRoundUp ? + ceil((self.parent?.view.frame.origin.y ?? 0) / 2) : + floor((self.parent?.view.frame.origin.y ?? 0) / 2) + ) + ) + + self.mediaViewTopConstraint?.constant = yOffset + self.mediaViewBottomConstraint?.constant = yOffset + + let xOffset: CGFloat = max(0, (scrollViewSize.width - imageViewSize.width) / 2) + self.mediaViewLeadingConstraint?.constant = xOffset + self.mediaViewTrailingConstraint?.constant = xOffset + } + + func scrollViewDidZoom(_ scrollView: UIScrollView) { + self.centerMediaViewConstraints() + self.view.layoutIfNeeded() + } + + private func resetMediaFrame() { + // HACK: Setting the frame to itself *seems* like it should be a no-op, but + // it ensures the content is drawn at the right frame. In particular I was + // reproducibly seeing some images squished (they were EXIF rotated, maybe + // related). similar to this report: + // https://stackoverflow.com/questions/27961884/swift-uiimageview-stretched-aspect + self.view.layoutIfNeeded() + self.mediaView.frame = self.mediaView.frame + } + + // MARK: - Video Playback + + @objc public func playVideo() { + self.playVideoButton.isHidden = true + self.videoPlayer?.play() + self.delegate?.mediaDetailViewController(self, isPlayingVideo: true) + } + + private func pauseVideo() { + self.videoPlayer?.pause() + self.delegate?.mediaDetailViewController(self, isPlayingVideo: false) + } + + public func stopAnyVideo() { + guard self.galleryItem.attachment.isVideo else { return } + + self.stopVideo() + } + + private func stopVideo() { + self.videoPlayer?.stop() + self.playVideoButton.isHidden = false + self.delegate?.mediaDetailViewController(self, isPlayingVideo: false) + } + + // MARK: - OWSVideoPlayerDelegate + + func videoPlayerDidPlayToCompletion(_ videoPlayer: OWSVideoPlayer) { + self.stopVideo() + } + + // MARK: - PlayerProgressBarDelegate + + func playerProgressBarDidStartScrubbing(_ playerProgressBar: PlayerProgressBar) { + self.videoPlayer?.pause() + } + + func playerProgressBar(_ playerProgressBar: PlayerProgressBar, scrubbedToTime time: CMTime) { + self.videoPlayer?.seek(to: time) + } + + func playerProgressBar(_ playerProgressBar: PlayerProgressBar, didFinishScrubbingAtTime time: CMTime, shouldResumePlayback: Bool) { + self.videoPlayer?.seek(to: time) + + if shouldResumePlayback { + self.videoPlayer?.play() + } + } +} + +// MARK: - MediaDetailViewControllerDelegate + +protocol MediaDetailViewControllerDelegate: AnyObject { + func mediaDetailViewController(_ mediaDetailViewController: MediaDetailViewController, isPlayingVideo: Bool) + func mediaDetailViewControllerDidTapMedia(_ mediaDetailViewController: MediaDetailViewController) +} diff --git a/Session/Media Viewing & Editing/MediaGalleryNavigationController.swift b/Session/Media Viewing & Editing/MediaGalleryNavigationController.swift new file mode 100644 index 000000000..48c25e056 --- /dev/null +++ b/Session/Media Viewing & Editing/MediaGalleryNavigationController.swift @@ -0,0 +1,84 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SignalUtilitiesKit +import SessionUIKit + +class MediaGalleryNavigationController: OWSNavigationController { + // HACK: Though we don't have an input accessory view, the VC we are presented above (ConversationVC) does. + // If the app is backgrounded and then foregrounded, when OWSWindowManager calls mainWindow.makeKeyAndVisible + // the ConversationVC's inputAccessoryView will appear *above* us unless we'd previously become first responder. + override public var canBecomeFirstResponder: Bool { + return true + } + + // MARK: - UI + + private lazy var backgroundView: UIView = { + let result: UIView = UIView() + result.backgroundColor = Colors.navigationBarBackground + + return result + }() + + // MARK: - View Lifecycle + + override var preferredStatusBarStyle: UIStatusBarStyle { + return (isLightMode ? .default : .lightContent) + } + + override func viewDidLoad() { + super.viewDidLoad() + + guard let navigationBar = self.navigationBar as? OWSNavigationBar else { + owsFailDebug("navigationBar had unexpected class: \(self.navigationBar)") + return + } + + view.backgroundColor = Colors.navigationBarBackground + + navigationBar.setBackgroundImage(UIImage(), for: UIBarMetrics.default) + navigationBar.shadowImage = UIImage() + navigationBar.isTranslucent = false + navigationBar.barTintColor = Colors.navigationBarBackground + + // Insert a view to ensure the nav bar colour goes to the top of the screen + relayoutBackgroundView() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + // If the user's device is already rotated, try to respect that by rotating to landscape now + UIViewController.attemptRotationToDeviceOrientation() + } + + // MARK: - Orientation + + public override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + return .allButUpsideDown + } + + // MARK: - Functions + + private func relayoutBackgroundView() { + guard !backgroundView.isHidden else { + backgroundView.removeFromSuperview() + return + } + + view.insertSubview(backgroundView, belowSubview: navigationBar) + + backgroundView.pin(.top, to: .top, of: view) + backgroundView.pin(.left, to: .left, of: navigationBar) + backgroundView.pin(.right, to: .right, of: navigationBar) + backgroundView.pin(.bottom, to: .bottom, of: navigationBar) + } + + override func setNavigationBarHidden(_ hidden: Bool, animated: Bool) { + super.setNavigationBarHidden(hidden, animated: animated) + + backgroundView.isHidden = hidden + relayoutBackgroundView() + } +} diff --git a/Session/Media Viewing & Editing/MediaGalleryViewController.swift b/Session/Media Viewing & Editing/MediaGalleryViewController.swift deleted file mode 100644 index dfe8413d5..000000000 --- a/Session/Media Viewing & Editing/MediaGalleryViewController.swift +++ /dev/null @@ -1,903 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -import Foundation - -public enum GalleryDirection { - case before, after, around -} - -class MediaGalleryAlbum { - - private var originalItems: [MediaGalleryItem] - var items: [MediaGalleryItem] { - get { - guard let mediaGalleryDataSource = self.mediaGalleryDataSource else { - owsFailDebug("mediaGalleryDataSource was unexpectedly nil") - return originalItems - } - - return originalItems.filter { !mediaGalleryDataSource.deletedGalleryItems.contains($0) } - } - } - - weak var mediaGalleryDataSource: MediaGalleryDataSource? - - init(items: [MediaGalleryItem]) { - self.originalItems = items - } - - func add(item: MediaGalleryItem) { - guard !originalItems.contains(item) else { - return - } - - originalItems.append(item) - originalItems.sort { (lhs, rhs) -> Bool in - return lhs.albumIndex < rhs.albumIndex - } - } -} - -public class MediaGalleryItem: Equatable, Hashable { - let message: TSMessage - let attachmentStream: TSAttachmentStream - let galleryDate: GalleryDate - let captionForDisplay: String? - let albumIndex: Int - var album: MediaGalleryAlbum? - let orderingKey: MediaGalleryItemOrderingKey - - init(message: TSMessage, attachmentStream: TSAttachmentStream) { - self.message = message - self.attachmentStream = attachmentStream - self.captionForDisplay = attachmentStream.caption?.filterForDisplay - self.galleryDate = GalleryDate(message: message) - self.albumIndex = message.attachmentIds.index(of: attachmentStream.uniqueId!) - self.orderingKey = MediaGalleryItemOrderingKey(messageSortKey: message.sortId, attachmentSortKey: albumIndex) - } - - var isVideo: Bool { - return attachmentStream.isVideo - } - - var isAnimated: Bool { - return attachmentStream.isAnimated - } - - var isImage: Bool { - return attachmentStream.isImage - } - - var imageSize: CGSize { - return attachmentStream.imageSize() - } - - public typealias AsyncThumbnailBlock = (UIImage) -> Void - func thumbnailImage(async:@escaping AsyncThumbnailBlock) -> UIImage? { - return attachmentStream.thumbnailImageSmall(success: async, failure: {}) - } - - // MARK: Equatable - - public static func == (lhs: MediaGalleryItem, rhs: MediaGalleryItem) -> Bool { - return lhs.attachmentStream.uniqueId == rhs.attachmentStream.uniqueId - } - - // MARK: Hashable - - public var hashValue: Int { - return attachmentStream.uniqueId?.hashValue ?? attachmentStream.hashValue - } - - // MARK: Sorting - - struct MediaGalleryItemOrderingKey: Comparable { - let messageSortKey: UInt64 - let attachmentSortKey: Int - - // MARK: Comparable - - static func < (lhs: MediaGalleryItem.MediaGalleryItemOrderingKey, rhs: MediaGalleryItem.MediaGalleryItemOrderingKey) -> Bool { - if lhs.messageSortKey < rhs.messageSortKey { - return true - } - - if lhs.messageSortKey == rhs.messageSortKey { - if lhs.attachmentSortKey < rhs.attachmentSortKey { - return true - } - } - - return false - } - } -} - -public struct GalleryDate: Hashable, Comparable, Equatable { - let year: Int - let month: Int - - init(message: TSMessage) { - let date = message.dateForUI() - - self.year = Calendar.current.component(.year, from: date) - self.month = Calendar.current.component(.month, from: date) - } - - init(year: Int, month: Int) { - assert(month >= 1 && month <= 12) - - self.year = year - self.month = month - } - - private var isThisMonth: Bool { - let now = Date() - let year = Calendar.current.component(.year, from: now) - let month = Calendar.current.component(.month, from: now) - let thisMonth = GalleryDate(year: year, month: month) - - return self == thisMonth - } - - public var date: Date { - var components = DateComponents() - components.month = self.month - components.year = self.year - - return Calendar.current.date(from: components)! - } - - private var isThisYear: Bool { - let now = Date() - let thisYear = Calendar.current.component(.year, from: now) - - return self.year == thisYear - } - - static let thisYearFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "MMMM" - - return formatter - }() - - static let olderFormatter: DateFormatter = { - let formatter = DateFormatter() - - // FIXME localize for RTL, or is there a built in way to do this? - formatter.dateFormat = "MMMM yyyy" - - return formatter - }() - - var localizedString: String { - if isThisMonth { - return NSLocalizedString("MEDIA_GALLERY_THIS_MONTH_HEADER", comment: "Section header in media gallery collection view") - } else if isThisYear { - return type(of: self).thisYearFormatter.string(from: self.date) - } else { - return type(of: self).olderFormatter.string(from: self.date) - } - } - - // MARK: Hashable - - public var hashValue: Int { - return month.hashValue ^ year.hashValue - } - - // MARK: Comparable - - public static func < (lhs: GalleryDate, rhs: GalleryDate) -> Bool { - if lhs.year != rhs.year { - return lhs.year < rhs.year - } else if lhs.month != rhs.month { - return lhs.month < rhs.month - } else { - return false - } - } - - // MARK: Equatable - - public static func == (lhs: GalleryDate, rhs: GalleryDate) -> Bool { - return lhs.month == rhs.month && lhs.year == rhs.year - } -} - -protocol MediaGalleryDataSource: class { - var hasFetchedOldest: Bool { get } - var hasFetchedMostRecent: Bool { get } - - var galleryItems: [MediaGalleryItem] { get } - var galleryItemCount: Int { get } - - var sections: [GalleryDate: [MediaGalleryItem]] { get } - var sectionDates: [GalleryDate] { get } - - var deletedAttachments: Set { get } - var deletedGalleryItems: Set { get } - - func ensureGalleryItemsLoaded(_ direction: GalleryDirection, item: MediaGalleryItem, amount: UInt, completion: ((IndexSet, [IndexPath]) -> Void)?) - - func galleryItem(before currentItem: MediaGalleryItem) -> MediaGalleryItem? - func galleryItem(after currentItem: MediaGalleryItem) -> MediaGalleryItem? - - func showAllMedia(focusedItem: MediaGalleryItem) - func dismissMediaDetailViewController(_ mediaDetailViewController: MediaPageViewController, animated isAnimated: Bool, completion: (() -> Void)?) - - func delete(items: [MediaGalleryItem], initiatedBy: AnyObject) -} - -protocol MediaGalleryDataSourceDelegate: class { - func mediaGalleryDataSource(_ mediaGalleryDataSource: MediaGalleryDataSource, willDelete items: [MediaGalleryItem], initiatedBy: AnyObject) - func mediaGalleryDataSource(_ mediaGalleryDataSource: MediaGalleryDataSource, deletedSections: IndexSet, deletedItems: [IndexPath]) -} - -class MediaGalleryNavigationController: OWSNavigationController { - - var retainUntilDismissed: MediaGallery? - - // HACK: Though we don't have an input accessory view, the VC we are presented above (ConversationVC) does. - // If the app is backgrounded and then foregrounded, when OWSWindowManager calls mainWindow.makeKeyAndVisible - // the ConversationVC's inputAccessoryView will appear *above* us unless we'd previously become first responder. - override public var canBecomeFirstResponder: Bool { - Logger.debug("") - return true - } - - // MARK: View Lifecycle - - override var preferredStatusBarStyle: UIStatusBarStyle { - return isLightMode ? .default : .lightContent - } - - override func viewDidLoad() { - super.viewDidLoad() - - guard let navigationBar = self.navigationBar as? OWSNavigationBar else { - owsFailDebug("navigationBar had unexpected class: \(self.navigationBar)") - return - } - - view.backgroundColor = Colors.navigationBarBackground - - navigationBar.setBackgroundImage(UIImage(), for: UIBarMetrics.default) - navigationBar.shadowImage = UIImage() - navigationBar.isTranslucent = false - navigationBar.barTintColor = Colors.navigationBarBackground - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - // If the user's device is already rotated, try to respect that by rotating to landscape now - UIViewController.attemptRotationToDeviceOrientation() - } - - // MARK: Orientation - - public override var supportedInterfaceOrientations: UIInterfaceOrientationMask { - return .allButUpsideDown - } -} - -@objc -class MediaGallery: NSObject, MediaGalleryDataSource, MediaTileViewControllerDelegate { - - @objc - weak public var navigationController: MediaGalleryNavigationController! - - var deletedAttachments: Set = Set() - var deletedGalleryItems: Set = Set() - - private var pageViewController: MediaPageViewController? - - private var uiDatabaseConnection: YapDatabaseConnection { - return OWSPrimaryStorage.shared().uiDatabaseConnection - } - - private let editingDatabaseConnection: YapDatabaseConnection - private let mediaGalleryFinder: OWSMediaGalleryFinder - - private var initialDetailItem: MediaGalleryItem? - private let thread: TSThread - private let options: MediaGalleryOption - - // we start with a small range size for quick loading. - private let fetchRangeSize: UInt = 10 - - deinit { - Logger.debug("") - } - - @objc - init(thread: TSThread, options: MediaGalleryOption = []) { - self.thread = thread - - self.editingDatabaseConnection = OWSPrimaryStorage.shared().newDatabaseConnection() - - self.options = options - self.mediaGalleryFinder = OWSMediaGalleryFinder(thread: thread) - super.init() - - NotificationCenter.default.addObserver(self, - selector: #selector(uiDatabaseDidUpdate), - name: .OWSUIDatabaseConnectionDidUpdate, - object: OWSPrimaryStorage.shared().dbNotificationObject) - } - - // MARK: Present/Dismiss - - private var currentItem: MediaGalleryItem { - return self.pageViewController!.currentItem - } - - @objc - public func presentDetailView(fromViewController: UIViewController, mediaAttachment: TSAttachment) { - var galleryItem: MediaGalleryItem? - uiDatabaseConnection.read { transaction in - galleryItem = self.buildGalleryItem(attachment: mediaAttachment, transaction: transaction) - } - - guard let initialDetailItem = galleryItem else { - return - } - - presentDetailView(fromViewController: fromViewController, initialDetailItem: initialDetailItem) - } - - public func presentDetailView(fromViewController: UIViewController, initialDetailItem: MediaGalleryItem) { - // For a speedy load, we only fetch a few items on either side of - // the initial message - ensureGalleryItemsLoaded(.around, item: initialDetailItem, amount: 10) - - // We lazily load media into the gallery, but with large albums, we want to be sure - // we load all the media required to render the album's media rail. - ensureAlbumEntirelyLoaded(galleryItem: initialDetailItem) - - self.initialDetailItem = initialDetailItem - - let pageViewController = MediaPageViewController(initialItem: initialDetailItem, mediaGalleryDataSource: self, uiDatabaseConnection: self.uiDatabaseConnection, options: self.options) - self.addDataSourceDelegate(pageViewController) - - self.pageViewController = pageViewController - - let navController = MediaGalleryNavigationController() - self.navigationController = navController - navController.retainUntilDismissed = self - - navigationController.setViewControllers([pageViewController], animated: false) - - navigationController.modalPresentationStyle = .fullScreen - navigationController.modalTransitionStyle = .crossDissolve - - fromViewController.present(navigationController, animated: true, completion: nil) - } - - // If we're using a navigationController other than self to present the views - // e.g. the conversation settings view controller - var fromNavController: OWSNavigationController? - - @objc - func pushTileView(fromNavController: OWSNavigationController) { - var mostRecentItem: MediaGalleryItem? - self.uiDatabaseConnection.read { transaction in - if let attachment = self.mediaGalleryFinder.mostRecentMediaAttachment(transaction: transaction) { - mostRecentItem = self.buildGalleryItem(attachment: attachment, transaction: transaction) - } - } - - if let mostRecentItem = mostRecentItem { - mediaTileViewController.focusedItem = mostRecentItem - ensureGalleryItemsLoaded(.around, item: mostRecentItem, amount: 100) - } - self.fromNavController = fromNavController - fromNavController.pushViewController(mediaTileViewController, animated: true) - } - - func showAllMedia(focusedItem: MediaGalleryItem) { - // TODO fancy animation - zoom media item into it's tile in the all media grid - ensureGalleryItemsLoaded(.around, item: focusedItem, amount: 100) - - if let fromNavController = self.fromNavController { - // If from conversation settings view, we've already pushed - fromNavController.popViewController(animated: true) - } else { - // If from conversation view - mediaTileViewController.focusedItem = focusedItem - navigationController.pushViewController(mediaTileViewController, animated: true) - } - } - - // MARK: MediaTileViewControllerDelegate - - func mediaTileViewController(_ viewController: MediaTileViewController, didTapView tappedView: UIView, mediaGalleryItem: MediaGalleryItem) { - if self.fromNavController != nil { - // If we got to the gallery via conversation settings, present the detail view - // on top of the tile view - // - // == ViewController Schematic == - // - // [DetailView] <--, - // [TileView] -----' - // [ConversationSettingsView] - // [ConversationView] - // - - self.presentDetailView(fromViewController: mediaTileViewController, initialDetailItem: mediaGalleryItem) - } else { - // If we got to the gallery via the conversation view, pop the tile view - // to return to the detail view - // - // == ViewController Schematic == - // - // [TileView] -----, - // [DetailView] <--' - // [ConversationView] - // - - guard let pageViewController = self.pageViewController else { - owsFailDebug("pageViewController was unexpectedly nil") - self.navigationController.dismiss(animated: true) - - return - } - - pageViewController.setCurrentItem(mediaGalleryItem, direction: .forward, animated: false) - pageViewController.willBePresentedAgain() - - // TODO fancy zoom animation - self.navigationController.popViewController(animated: true) - } - } - - public func dismissMediaDetailViewController(_ mediaPageViewController: MediaPageViewController, animated isAnimated: Bool, completion completionParam: (() -> Void)?) { - - guard let presentingViewController = self.navigationController.presentingViewController else { - owsFailDebug("presentingController was unexpectedly nil") - return - } - - let completion = { - completionParam?() - UIApplication.shared.isStatusBarHidden = false - presentingViewController.setNeedsStatusBarAppearanceUpdate() - } - - navigationController.view.isUserInteractionEnabled = false - - presentingViewController.dismiss(animated: true, completion: completion) - } - - // MARK: - Database Notifications - - @objc - func uiDatabaseDidUpdate(notification: Notification) { - guard let notifications = notification.userInfo?[OWSUIDatabaseConnectionNotificationsKey] as? [Notification] else { - owsFailDebug("notifications was unexpectedly nil") - return - } - - guard mediaGalleryFinder.hasMediaChanges(in: notifications, dbConnection: uiDatabaseConnection) else { - Logger.verbose("no changes for thread: \(thread)") - return - } - - let rowChanges = extractRowChanges(notifications: notifications) - assert(rowChanges.count > 0) - - process(rowChanges: rowChanges) - } - - func extractRowChanges(notifications: [Notification]) -> [YapDatabaseViewRowChange] { - return notifications.flatMap { notification -> [YapDatabaseViewRowChange] in - guard let userInfo = notification.userInfo else { - owsFailDebug("userInfo was unexpectedly nil") - return [] - } - - guard let extensionChanges = userInfo["extensions"] as? [AnyHashable: Any] else { - owsFailDebug("extensionChanges was unexpectedly nil") - return [] - } - - guard let galleryData = extensionChanges[OWSMediaGalleryFinder.databaseExtensionName()] as? [AnyHashable: Any] else { - owsFailDebug("galleryData was unexpectedly nil") - return [] - } - - guard let galleryChanges = galleryData["changes"] as? [Any] else { - owsFailDebug("gallerlyChanges was unexpectedly nil") - return [] - } - - return galleryChanges.compactMap { $0 as? YapDatabaseViewRowChange } - } - } - - func process(rowChanges: [YapDatabaseViewRowChange]) { - let deleteChanges = rowChanges.filter { $0.type == .delete } - - let deletedItems: [MediaGalleryItem] = deleteChanges.compactMap { (deleteChange: YapDatabaseViewRowChange) -> MediaGalleryItem? in - guard let deletedItem = self.galleryItems.first(where: { galleryItem in - galleryItem.attachmentStream.uniqueId == deleteChange.collectionKey.key - }) else { - Logger.debug("deletedItem was never loaded - no need to remove.") - return nil - } - - return deletedItem - } - - self.delete(items: deletedItems, initiatedBy: self) - } - - // MARK: - MediaGalleryDataSource - - lazy var mediaTileViewController: MediaTileViewController = { - let vc = MediaTileViewController(mediaGalleryDataSource: self, uiDatabaseConnection: self.uiDatabaseConnection) - vc.delegate = self - - self.addDataSourceDelegate(vc) - - return vc - }() - - var galleryItems: [MediaGalleryItem] = [] - var sections: [GalleryDate: [MediaGalleryItem]] = [:] - var sectionDates: [GalleryDate] = [] - var hasFetchedOldest = false - var hasFetchedMostRecent = false - - func buildGalleryItem(attachment: TSAttachment, transaction: YapDatabaseReadTransaction) -> MediaGalleryItem? { - guard let attachmentStream = attachment as? TSAttachmentStream else { - return nil - } - - guard let message = attachmentStream.fetchAlbumMessage(with: transaction) else { - return nil - } - - let galleryItem = MediaGalleryItem(message: message, attachmentStream: attachmentStream) - galleryItem.album = getAlbum(item: galleryItem) - - return galleryItem - } - - func ensureAlbumEntirelyLoaded(galleryItem: MediaGalleryItem) { - ensureGalleryItemsLoaded(.before, item: galleryItem, amount: UInt(galleryItem.albumIndex)) - - let followingCount = galleryItem.message.attachmentIds.count - 1 - galleryItem.albumIndex - guard followingCount >= 0 else { - return - } - ensureGalleryItemsLoaded(.after, item: galleryItem, amount: UInt(followingCount)) - } - - var galleryAlbums: [String: MediaGalleryAlbum] = [:] - func getAlbum(item: MediaGalleryItem) -> MediaGalleryAlbum? { - guard let albumMessageId = item.attachmentStream.albumMessageId else { - return nil - } - - guard let existingAlbum = galleryAlbums[albumMessageId] else { - let newAlbum = MediaGalleryAlbum(items: [item]) - galleryAlbums[albumMessageId] = newAlbum - newAlbum.mediaGalleryDataSource = self - return newAlbum - } - - existingAlbum.add(item: item) - return existingAlbum - } - - // Range instead of indexSet since it's contiguous? - var fetchedIndexSet = IndexSet() { - didSet { - Logger.debug("\(oldValue) -> \(fetchedIndexSet)") - } - } - - enum MediaGalleryError: Error { - case itemNoLongerExists - } - - func ensureGalleryItemsLoaded(_ direction: GalleryDirection, item: MediaGalleryItem, amount: UInt, completion: ((IndexSet, [IndexPath]) -> Void)? = nil ) { - - var galleryItems: [MediaGalleryItem] = self.galleryItems - var sections: [GalleryDate: [MediaGalleryItem]] = self.sections - var sectionDates: [GalleryDate] = self.sectionDates - - var newGalleryItems: [MediaGalleryItem] = [] - var newDates: [GalleryDate] = [] - - do { - try Bench(title: "fetching gallery items") { - try self.uiDatabaseConnection.read { transaction in - guard let index = self.mediaGalleryFinder.mediaIndex(attachment: item.attachmentStream, transaction: transaction) else { - throw MediaGalleryError.itemNoLongerExists - } - let initialIndex: Int = index.intValue - let mediaCount: Int = Int(self.mediaGalleryFinder.mediaCount(transaction: transaction)) - - let requestRange: Range = { () -> Range in - let range: Range = { () -> Range in - switch direction { - case .around: - // To keep it simple, this isn't exactly *amount* sized if `message` window overlaps the end or - // beginning of the view. Still, we have sufficient buffer to fetch more as the user swipes. - let start: Int = initialIndex - Int(amount) / 2 - let end: Int = initialIndex + Int(amount) / 2 + 1 - - return start.. (requestSet.count / 2) - // ...but we always fulfill even small requests if we're getting just the tail end of a gallery. - let isFetchingEdgeOfGallery = (self.fetchedIndexSet.count - unfetchedSet.count) < requestSet.count - - guard isSubstantialRequest || isFetchingEdgeOfGallery else { - Logger.debug("ignoring small fetch request: \(unfetchedSet.count)") - return - } - - Logger.debug("fetching set: \(unfetchedSet)") - let nsRange: NSRange = NSRange(location: unfetchedSet.min()!, length: unfetchedSet.count) - self.mediaGalleryFinder.enumerateMediaAttachments(range: nsRange, transaction: transaction) { (attachment: TSAttachment) in - - guard !self.deletedAttachments.contains(attachment) else { - Logger.debug("skipping \(attachment) which has been deleted.") - return - } - - guard let item: MediaGalleryItem = self.buildGalleryItem(attachment: attachment, transaction: transaction) else { - owsFailDebug("unexpectedly failed to buildGalleryItem") - return - } - - let date = item.galleryDate - - galleryItems.append(item) - if sections[date] != nil { - sections[date]!.append(item) - - // so we can update collectionView - newGalleryItems.append(item) - } else { - sectionDates.append(date) - sections[date] = [item] - - // so we can update collectionView - newDates.append(date) - newGalleryItems.append(item) - } - } - - self.fetchedIndexSet = self.fetchedIndexSet.union(unfetchedSet) - self.hasFetchedOldest = self.fetchedIndexSet.min() == 0 - self.hasFetchedMostRecent = self.fetchedIndexSet.max() == mediaCount - 1 - } - } - } catch MediaGalleryError.itemNoLongerExists { - Logger.debug("Ignoring reload, since item no longer exists.") - return - } catch { - owsFailDebug("unexpected error: \(error)") - return - } - - // TODO only sort if changed - var sortedSections: [GalleryDate: [MediaGalleryItem]] = [:] - - Bench(title: "sorting gallery items") { - galleryItems.sort { lhs, rhs -> Bool in - return lhs.orderingKey < rhs.orderingKey - } - sectionDates.sort() - - for (date, galleryItems) in sections { - sortedSections[date] = galleryItems.sorted { lhs, rhs -> Bool in - return lhs.orderingKey < rhs.orderingKey - } - } - } - - self.galleryItems = galleryItems - self.sections = sortedSections - self.sectionDates = sectionDates - - if let completionBlock = completion { - Bench(title: "calculating changes for collectionView") { - // FIXME can we avoid this index offset? - let dateIndices = newDates.map { sectionDates.firstIndex(of: $0)! + 1 } - let addedSections: IndexSet = IndexSet(dateIndices) - - let addedItems: [IndexPath] = newGalleryItems.map { galleryItem in - let sectionIdx = sectionDates.firstIndex(of: galleryItem.galleryDate)! - let section = sections[galleryItem.galleryDate]! - let itemIdx = section.firstIndex(of: galleryItem)! - - // FIXME can we avoid this index offset? - return IndexPath(item: itemIdx, section: sectionIdx + 1) - } - - completionBlock(addedSections, addedItems) - } - } - } - - var dataSourceDelegates: [Weak] = [] - func addDataSourceDelegate(_ dataSourceDelegate: MediaGalleryDataSourceDelegate) { - dataSourceDelegates.append(Weak(value: dataSourceDelegate)) - } - - func delete(items: [MediaGalleryItem], initiatedBy: AnyObject) { - AssertIsOnMainThread() - - Logger.info("with items: \(items.map { ($0.attachmentStream, $0.message.timestamp) })") - - deletedGalleryItems.formUnion(items) - dataSourceDelegates.forEach { $0.value?.mediaGalleryDataSource(self, willDelete: items, initiatedBy: initiatedBy) } - - for item in items { - self.deletedAttachments.insert(item.attachmentStream) - } - - self.editingDatabaseConnection.asyncReadWrite { transaction in - for item in items { - let message = item.message - let attachment = item.attachmentStream - message.removeAttachment(attachment, transaction: transaction) - if message.attachmentIds.count == 0 { - Logger.debug("removing message after removing last media attachment") - message.remove(with: transaction) - } - } - } - - var deletedSections: IndexSet = IndexSet() - var deletedIndexPaths: [IndexPath] = [] - let originalSections = self.sections - let originalSectionDates = self.sectionDates - - for item in items { - guard let itemIndex = galleryItems.firstIndex(of: item) else { - owsFailDebug("removing unknown item.") - return - } - - self.galleryItems.remove(at: itemIndex) - - guard let sectionIndex = sectionDates.firstIndex(where: { $0 == item.galleryDate }) else { - owsFailDebug("item with unknown date.") - return - } - - guard var sectionItems = self.sections[item.galleryDate] else { - owsFailDebug("item with unknown section") - return - } - - guard let sectionRowIndex = sectionItems.firstIndex(of: item) else { - owsFailDebug("item with unknown sectionRowIndex") - return - } - - // We need to calculate the index of the deleted item with respect to it's original position. - guard let originalSectionIndex = originalSectionDates.firstIndex(where: { $0 == item.galleryDate }) else { - owsFailDebug("item with unknown date.") - return - } - - guard let originalSectionItems = originalSections[item.galleryDate] else { - owsFailDebug("item with unknown section") - return - } - - guard let originalSectionRowIndex = originalSectionItems.firstIndex(of: item) else { - owsFailDebug("item with unknown sectionRowIndex") - return - } - - if sectionItems == [item] { - // Last item in section. Delete section. - self.sections[item.galleryDate] = nil - self.sectionDates.remove(at: sectionIndex) - - deletedSections.insert(originalSectionIndex + 1) - deletedIndexPaths.append(IndexPath(row: originalSectionRowIndex, section: originalSectionIndex + 1)) - } else { - sectionItems.remove(at: sectionRowIndex) - self.sections[item.galleryDate] = sectionItems - - deletedIndexPaths.append(IndexPath(row: originalSectionRowIndex, section: originalSectionIndex + 1)) - } - } - - dataSourceDelegates.forEach { $0.value?.mediaGalleryDataSource(self, deletedSections: deletedSections, deletedItems: deletedIndexPaths) } - } - - let kGallerySwipeLoadBatchSize: UInt = 5 - - internal func galleryItem(after currentItem: MediaGalleryItem) -> MediaGalleryItem? { - Logger.debug("") - - self.ensureGalleryItemsLoaded(.after, item: currentItem, amount: kGallerySwipeLoadBatchSize) - - guard let currentIndex = galleryItems.firstIndex(of: currentItem) else { - owsFailDebug("currentIndex was unexpectedly nil") - return nil - } - - let index: Int = galleryItems.index(after: currentIndex) - guard let nextItem = galleryItems[safe: index] else { - // already at last item - return nil - } - - guard !deletedGalleryItems.contains(nextItem) else { - Logger.debug("nextItem was deleted - Recursing.") - return galleryItem(after: nextItem) - } - - return nextItem - } - - internal func galleryItem(before currentItem: MediaGalleryItem) -> MediaGalleryItem? { - Logger.debug("") - - self.ensureGalleryItemsLoaded(.before, item: currentItem, amount: kGallerySwipeLoadBatchSize) - - guard let currentIndex = galleryItems.firstIndex(of: currentItem) else { - owsFailDebug("currentIndex was unexpectedly nil") - return nil - } - - let index: Int = galleryItems.index(before: currentIndex) - guard let previousItem = galleryItems[safe: index] else { - // already at first item - return nil - } - - guard !deletedGalleryItems.contains(previousItem) else { - Logger.debug("previousItem was deleted - Recursing.") - return galleryItem(before: previousItem) - } - - return previousItem - } - - var galleryItemCount: Int { - var count: UInt = 0 - self.uiDatabaseConnection.read { (transaction: YapDatabaseReadTransaction) in - count = self.mediaGalleryFinder.mediaCount(transaction: transaction) - } - return Int(count) - deletedAttachments.count - } -} diff --git a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift index bbd855ed3..7d9c17f02 100644 --- a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift +++ b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift @@ -5,35 +5,860 @@ import GRDB import DifferenceKit import SignalUtilitiesKit -public class MediaGalleryViewModel { +public class MediaGalleryViewModel: TransactionObserver { public let threadId: String public let threadVariant: SessionThread.Variant - private let item: ConversationViewModel.Item? + private var focusedAttachmentId: String? + public private(set) var focusedIndexPath: IndexPath? + + /// This value is the current state of an album view + private var cachedInteractionIdBefore: Atomic<[Int64: Int64]> = Atomic([:]) + private var cachedInteractionIdAfter: Atomic<[Int64: Int64]> = Atomic([:]) + + public var interactionIdBefore: [Int64: Int64] { cachedInteractionIdBefore.wrappedValue } + public var interactionIdAfter: [Int64: Int64] { cachedInteractionIdAfter.wrappedValue } + public private(set) var albumData: [Int64: [Item]] = [:] + + /// This value is the current state of a gallery view + public private(set) var galleryData: [SectionModel] = [] + + // MARK: - Paging + + public struct PageInfo { + public enum Target: Equatable { + case before + case around(id: String) + case after + } + + let pageSize: Int + let pageOffset: Int + let currentCount: Int + let totalCount: Int + + // MARK: - Initizliation + + init( + pageSize: Int, + pageOffset: Int = 0, + currentCount: Int = 0, + totalCount: Int = 0 + ) { + self.pageSize = pageSize + self.pageOffset = pageOffset + self.currentCount = currentCount + self.totalCount = totalCount + } + } + + private var isFetchingMoreItems: Atomic = Atomic(false) + private var pageInfo: Atomic + + // Gallery observing + + private let updatedRows: Atomic> = Atomic([]) + public var onGalleryChange: (([SectionModel], PageInfo) -> ())? // MARK: - Initialization init( threadId: String, threadVariant: SessionThread.Variant, - item: ConversationViewModel.Item? = nil + pageSize: Int = 1, + focusedAttachmentId: String? = nil ) { self.threadId = threadId self.threadVariant = threadVariant - self.item = item - } + self.pageInfo = Atomic(PageInfo(pageSize: pageSize)) + self.focusedAttachmentId = focusedAttachmentId } - public static func createTileViewController(threadId: String, isClosedGroup: Bool, isOpenGroup: Bool) -> MediaTileViewController { - return MediaTileViewController( - viewModel: MediaGalleryViewModel( - threadId: threadId, - threadVariant: { - if isClosedGroup { return .closedGroup } - if isOpenGroup { return .openGroup } + // MARK: - Data + + public struct GalleryDate: Differentiable, Equatable, Comparable, Hashable { + private static let thisYearFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "MMMM" - return .contact - }() + return formatter + }() + + private static let olderFormatter: DateFormatter = { + // FIXME: localize for RTL, or is there a built in way to do this? + let formatter = DateFormatter() + formatter.dateFormat = "MMMM yyyy" + + return formatter + }() + + let year: Int + let month: Int + + private var date: Date? { + var components = DateComponents() + components.month = self.month + components.year = self.year + + return Calendar.current.date(from: components) + } + + var localizedString: String { + let isSameMonth: Bool = (self.month == Calendar.current.component(.month, from: Date())) + let isCurrentYear: Bool = (self.year == Calendar.current.component(.year, from: Date())) + let galleryDate: Date = (self.date ?? Date()) + + switch (isSameMonth, isCurrentYear) { + case (true, true): return "MEDIA_GALLERY_THIS_MONTH_HEADER".localized() + case (false, true): return GalleryDate.thisYearFormatter.string(from: galleryDate) + default: return GalleryDate.olderFormatter.string(from: galleryDate) + } + } + + // MARK: - --Initialization + + init(messageDate: Date) { + self.year = Calendar.current.component(.year, from: messageDate) + self.month = Calendar.current.component(.month, from: messageDate) + } + + // MARK: - --Comparable + + public static func < (lhs: GalleryDate, rhs: GalleryDate) -> Bool { + switch ((lhs.year != rhs.year), (lhs.month != rhs.month)) { + case (true, _): return lhs.year < rhs.year + case (_, true): return lhs.month < rhs.month + default: return false + } + } + } + + public typealias SectionModel = ArraySection + + public enum Section: Differentiable, Equatable, Comparable, Hashable { + case emptyGallery + case loadNewer + case galleryMonth(date: GalleryDate) + case loadOlder + } + + public struct Item: FetchableRecord, Decodable, Differentiable, Equatable, Hashable, Comparable { + fileprivate static let interactionIdKey: String = CodingKeys.interactionId.stringValue + fileprivate static let interactionVariantKey: String = CodingKeys.interactionVariant.stringValue + fileprivate static let interactionAuthorIdKey: String = CodingKeys.interactionAuthorId.stringValue + fileprivate static let interactionTimestampMsKey: String = CodingKeys.interactionTimestampMs.stringValue + fileprivate static let attachmentRowIdKey: String = CodingKeys.attachmentRowId.stringValue + fileprivate static let attachmentAlbumIndexKey: String = CodingKeys.attachmentAlbumIndex.stringValue + + public var differenceIdentifier: String { + return attachment.id + } + + let interactionId: Int64 + let interactionVariant: Interaction.Variant + let interactionAuthorId: String + let interactionTimestampMs: Int64 + + let attachmentRowId: Int64 + let attachmentAlbumIndex: Int + let attachment: Attachment + + var galleryDate: GalleryDate { + GalleryDate( + messageDate: Date(timeIntervalSince1970: (Double(interactionTimestampMs) / 1000)) ) + } + + var isVideo: Bool { attachment.isVideo } + var isAnimated: Bool { attachment.isAnimated } + var isImage: Bool { attachment.isImage } + + var imageSize: CGSize { + guard let width: UInt = attachment.width, let height: UInt = attachment.height else { + return .zero + } + + return CGSize(width: Int(width), height: Int(height)) + } + + var captionForDisplay: String? { attachment.caption?.filterForDisplay } + + // MARK: - Comparable + + public static func < (lhs: Item, rhs: Item) -> Bool { + if lhs.interactionTimestampMs == rhs.interactionTimestampMs { + return (lhs.attachmentAlbumIndex < rhs.attachmentAlbumIndex) + } + + return (lhs.interactionTimestampMs < rhs.interactionTimestampMs) + } + + // MARK: - Query + + private static let baseQueryFilterSQL: SQL = { + let attachment: TypedTableAlias = TypedTableAlias() + + return SQL("\(attachment[.isVisualMedia]) = true AND \(attachment[.isValid]) = true") + }() + + private static let galleryQueryOrderSQL: SQL = { + let interaction: TypedTableAlias = TypedTableAlias() + let interactionAttachment: TypedTableAlias = TypedTableAlias() + + /// **Note:** This **MUST** match the desired sort behaviour for the screen otherwise paging will be + /// very broken + return SQL("\(interaction[.timestampMs].desc), \(interactionAttachment[.albumIndex])") + }() + + /// Retrieve the index that the attachment with the given `attachmentId` will have in the gallery + fileprivate static func galleryIndex(for attachmentId: String) -> SQLRequest { + let attachment: TypedTableAlias = TypedTableAlias() + let interaction: TypedTableAlias = TypedTableAlias() + let interactionAttachment: TypedTableAlias = TypedTableAlias() + + return """ + SELECT + (gallery.galleryIndex - 1) AS galleryIndex -- Converting from 1-Indexed to 0-indexed + FROM ( + SELECT + \(attachment[.id]) AS id, + ROW_NUMBER() OVER (ORDER BY \(galleryQueryOrderSQL)) AS galleryIndex + FROM \(Attachment.self) + JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id]) + JOIN \(Interaction.self) ON \(interaction[.id]) = \(interactionAttachment[.interactionId]) + WHERE \(baseQueryFilterSQL) + ) AS gallery + WHERE \(SQL("gallery.id = \(attachmentId)")) + """ + } + + /// Retrieve the indexes the given attachment row will have in the gallery + fileprivate static func galleryIndexes(for rowIds: Set, threadId: String) -> SQLRequest { + let attachment: TypedTableAlias = TypedTableAlias() + let interaction: TypedTableAlias = TypedTableAlias() + let interactionAttachment: TypedTableAlias = TypedTableAlias() + + return """ + SELECT + (gallery.galleryIndex - 1) AS galleryIndex -- Converting from 1-Indexed to 0-indexed + FROM ( + SELECT + \(attachment.alias[Column.rowID]) AS rowid, + ROW_NUMBER() OVER (ORDER BY \(galleryQueryOrderSQL)) AS galleryIndex + FROM \(Attachment.self) + JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id]) + JOIN \(Interaction.self) ON ( + \(interaction[.id]) = \(interactionAttachment[.interactionId]) AND + \(SQL("\(interaction[.threadId]) = \(threadId)")) + ) + WHERE \(baseQueryFilterSQL) + ) AS gallery + WHERE \(SQL("gallery.rowid IN \(rowIds)")) + """ + } + + private static let baseQuery: QueryInterfaceRequest = { + let attachment: TypedTableAlias = TypedTableAlias() + let interaction: TypedTableAlias = TypedTableAlias() + let interactionAttachment: TypedTableAlias = TypedTableAlias() + + return Attachment + .select( + interaction[.id].forKey(Item.interactionIdKey), + interaction[.variant].forKey(Item.interactionVariantKey), + interaction[.authorId].forKey(Item.interactionAuthorIdKey), + interaction[.timestampMs].forKey(Item.interactionTimestampMsKey), + + attachment.alias[Column.rowID].forKey(Item.attachmentRowIdKey), + interactionAttachment[.albumIndex].forKey(Item.attachmentAlbumIndexKey), + attachment.allColumns() + ) + .aliased(attachment) + .filter(literal: baseQueryFilterSQL) + .joining( + required: Attachment.interactionAttachments + .aliased(interactionAttachment) + .joining( + required: InteractionAttachment.interaction + .aliased(interaction) + ) + ) + .asRequest(of: Item.self) + }() + + fileprivate static let albumQuery: QueryInterfaceRequest = { + let interactionAttachment: TypedTableAlias = TypedTableAlias() + + return Item.baseQuery.order(interactionAttachment[.albumIndex]) + }() + + fileprivate static let galleryQuery: QueryInterfaceRequest = { + return Item.baseQuery + .order(literal: galleryQueryOrderSQL) + }() + + fileprivate static let galleryQueryReversed: QueryInterfaceRequest = { + let interaction: TypedTableAlias = TypedTableAlias() + let interactionAttachment: TypedTableAlias = TypedTableAlias() + + /// **Note:** This **MUST** always result in the same data as `galleryQuery` but in the opposite order + return Item.baseQuery + .order(interaction[.timestampMs], interactionAttachment[.albumIndex].desc) + }() + + func thumbnailImage(async: @escaping (UIImage) -> ()) { + attachment.thumbnail(size: .small, success: { image, _ in async(image) }, failure: {}) + } + } + + // MARK: - Album + + /// 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 + public typealias AlbumObservation = ValueObservation>> + public lazy var observableAlbumData: AlbumObservation = buildAlbumObservation(for: nil) + + private func buildAlbumObservation(for interactionId: Int64?) -> AlbumObservation { + return ValueObservation + .trackingConstantRegion { db -> [Item] in + guard let interactionId: Int64 = interactionId else { return [] } + + let interaction: TypedTableAlias = TypedTableAlias() + + return try Item.albumQuery + .filter(interaction[.id] == interactionId) + .fetchAll(db) + } + .removeDuplicates() + } + + + // MARK: - Gallery + + /// This function is used to load a gallery page using the provided `limitInfo`, if a `focusedAttachmentId` is provided then + /// the `limitInfo.offset` value will be ignored and it will retrieve `limitInfo.limit` values positioning the focussed item + /// as closed to the middle as possible prioritising retrieving `limitInfo.limit` items total + /// + /// **Note:** The `focusedAttachmentId` should only be provided during the first call, subsequent calls should solely provide + /// the `limitInfo` so content can be added before and after the initial page + private func loadGalleryPage( + _ target: PageInfo.Target, + currentPageInfo: PageInfo + ) -> (items: [Item], updatedPageInfo: PageInfo) { + return GRDBStorage.shared + .read { db in + let interaction: TypedTableAlias = TypedTableAlias() + let totalCount: Int = try Item.galleryQuery + .filter(interaction[.threadId] == threadId) + .fetchCount(db) + let queryOffset: Int = { + switch target { + case .before: + return max(0, (currentPageInfo.pageOffset - currentPageInfo.pageSize)) + + case .around(let targetId): + // If we want to focus on a specific item then we need to find it's index in + // the queried data + guard let targetIndex: Int = try? Int.fetchOne(db, Item.galleryIndex(for: targetId)) else { + // If we couldn't find the targetId then just load the page after the current one + return (currentPageInfo.pageOffset + currentPageInfo.pageSize) + } + + // If the focused item is within the first half of the page then we still want + // to retrieve a full page so calculate the offset needed to do so + let halfPageSize: Int = Int(floor(Double(currentPageInfo.pageSize) / 2)) + + // If the focused item is within the first or last half page then just + // start from the start/end of the content + guard targetIndex > halfPageSize else { return 0 } + guard targetIndex < (totalCount - halfPageSize) else { + return (totalCount - currentPageInfo.pageSize) + } + + return (targetIndex - halfPageSize) + + case .after: + return (currentPageInfo.pageOffset + currentPageInfo.currentCount) + } + }() + + let items: [Item] = try Item.galleryQuery + .filter(interaction[.threadId] == threadId) + .limit(currentPageInfo.pageSize, offset: queryOffset) + .fetchAll(db) + let updatedLimitInfo: PageInfo = PageInfo( + pageSize: currentPageInfo.pageSize, + pageOffset: (target != .after ? + queryOffset : + currentPageInfo.pageOffset + ), + currentCount: (currentPageInfo.currentCount + items.count), + totalCount: totalCount + ) + + return (items, updatedLimitInfo) + } + .defaulting(to: ([], currentPageInfo)) + } + + private func addingSystemSections(to data: [SectionModel], for pageInfo: PageInfo) -> [SectionModel] { + // Remove and re-add the custom sections as needed + return [ + (data.isEmpty ? [SectionModel(section: .emptyGallery)] : []), + (!data.isEmpty && pageInfo.pageOffset > 0 ? [SectionModel(section: .loadNewer)] : []), + data.filter { section -> Bool in + switch section.model { + case .galleryMonth: return true + case .emptyGallery, .loadOlder, .loadNewer: return false + } + }, + (!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ? + [SectionModel(section: .loadOlder)] : + [] + ) + ] + .flatMap { $0 } + } + + private func updatedGalleryData( + with existingData: [SectionModel], + dataToUpsert: [Item], + pageInfoToUpdate: PageInfo + ) -> (sections: [SectionModel], pageInfo: PageInfo) { + guard !dataToUpsert.isEmpty else { return (existingData, pageInfoToUpdate) } + + let updatedGalleryData: (sections: [SectionModel], pageInfo: PageInfo) = updatedGalleryData( + with: self.galleryData, + dataToUpsert: (dataToUpsert, pageInfoToUpdate) + ) + let existingDataCount: Int = existingData + .map { $0.elements.count } + .reduce(0, +) + let updatedGalleryDataCount: Int = updatedGalleryData.sections + .map { $0.elements.count } + .reduce(0, +) + let gallerySizeDiff: Int = (updatedGalleryDataCount - existingDataCount) + let updatedPageInfo: PageInfo = PageInfo( + pageSize: pageInfoToUpdate.pageSize, + pageOffset: pageInfoToUpdate.pageOffset, + currentCount: (pageInfoToUpdate.currentCount + gallerySizeDiff), + totalCount: (pageInfoToUpdate.totalCount + gallerySizeDiff) + ) + + // Add the "system" sections, sort the sections and return the result + return ( + self.addingSystemSections(to: updatedGalleryData.sections, for: updatedPageInfo) + .sorted { lhs, rhs -> Bool in (lhs.model > rhs.model) }, + updatedPageInfo + ) + } + + private func updatedGalleryData( + with existingData: [SectionModel], + dataToUpsert: (items: [Item], updatedPageInfo: PageInfo) + ) -> (sections: [SectionModel], pageInfo: PageInfo) { + var updatedGalleryData: [SectionModel] = existingData + + dataToUpsert + .items + .grouped(by: \.galleryDate) + .forEach { key, items in + guard let existingIndex = galleryData.firstIndex(where: { $0.model == .galleryMonth(date: key) }) else { + // Insert a new section + updatedGalleryData.append( + ArraySection( + model: .galleryMonth(date: key), + elements: items + .sorted() + .reversed() + ) + ) + return + } + + // Filter out collisions, replacing them with the updated values and insert + // and new values + let itemRowIds: Set = items.map { $0.attachmentRowId }.asSet() + + updatedGalleryData[existingIndex] = ArraySection( + model: .galleryMonth(date: key), + elements: updatedGalleryData[existingIndex].elements + .filter { !itemRowIds.contains($0.attachmentRowId) } + .appending(contentsOf: items) + .sorted() + .reversed() + ) + } + + // Add the "system" sections, sort the sections and return the result + return ( + self.addingSystemSections(to: updatedGalleryData, for: dataToUpsert.updatedPageInfo) + .sorted { lhs, rhs -> Bool in (lhs.model > rhs.model) }, + dataToUpsert.updatedPageInfo + ) + } + + // MARK: - TransactionObserver + + private struct TrackedChange: Equatable, Hashable { + let kind: DatabaseEvent.Kind + let rowId: Int64 + } + + public func observes(eventsOfKind eventKind: DatabaseEventKind) -> Bool { + switch eventKind { + case .delete(let tableName): return (tableName == Attachment.databaseTableName) + case .update(let tableName, let columnNames): + /// **Warning:** This filtering allows us to ignore all changes to attachments except + /// for the 'isValid' column, unfortunately calling the `with()` function on an attachment + /// does result in this column being seen as updated (even if the value doesn't change) so + /// we need to be careful where we set it to avoid unnecessarily triggering updates + return ( + tableName == Attachment.databaseTableName && + columnNames.contains(Attachment.Columns.isValid.name) + ) + + // We can ignore 'insert' events as we only care about valid attachments + case .insert: return false + } + } + + public func databaseDidChange(with event: DatabaseEvent) { + // This will get called for whenever an Attachment's 'isValid' column is + // updated (ie. an attachment finished uploading/downloading), unfortunately + // we won't know if the attachment is actually relevant yet as it could be for + // another thread or it might not be a media attachment + let trackedChange: TrackedChange = TrackedChange( + kind: event.kind, + rowId: event.rowID + ) + updatedRows.mutate { $0.insert(trackedChange) } + } + + // Note: We will process all updates which come through this method even if + // 'onGalleryChange' is null because if the UI stops observing and then starts again + // later we don't want them to have missed out on changes which happened while they + // weren't subscribed (and doing a full re-query seems painful...) + public func databaseDidCommit(_ db: Database) { + var committedUpdatedRows: Set = [] + self.updatedRows.mutate { updatedRows in + committedUpdatedRows = updatedRows + updatedRows.removeAll() + } + + // Note: This method will be called regardless of whether there were actually changes + // in the areas we are observing so we want to early-out if there aren't any relevant + // updated rows + guard !committedUpdatedRows.isEmpty else { return } + + var updatedPageInfo: PageInfo = self.pageInfo.wrappedValue + let attachmentRowIdsToQuery: Set = committedUpdatedRows + .filter { $0.kind != .delete } + .map { $0.rowId } + .asSet() + let attachmentRowIdsToDelete: Set = committedUpdatedRows + .filter { $0.kind == .delete } + .map { $0.rowId } + .asSet() + let oldGalleryDataCount: Int = self.galleryData + .map { $0.elements.count } + .reduce(0, +) + var galleryDataWithDeletions: [SectionModel] = self.galleryData + + // First remove any items which have been deleted + if !attachmentRowIdsToDelete.isEmpty { + galleryDataWithDeletions = galleryDataWithDeletions + .map { section -> SectionModel in + ArraySection( + model: section.model, + elements: section.elements + .filter { item -> Bool in !attachmentRowIdsToDelete.contains(item.attachmentRowId) } + ) + } + .filter { section -> Bool in !section.elements.isEmpty } + let updatedGalleryDataCount: Int = galleryDataWithDeletions + .map { $0.elements.count } + .reduce(0, +) + + // Make sure there were actually changes + if updatedGalleryDataCount != oldGalleryDataCount { + let gallerySizeDiff: Int = (updatedGalleryDataCount - oldGalleryDataCount) + + updatedPageInfo = PageInfo( + pageSize: updatedPageInfo.pageSize, + pageOffset: updatedPageInfo.pageOffset, + currentCount: (updatedPageInfo.currentCount + gallerySizeDiff), + totalCount: (updatedPageInfo.totalCount + gallerySizeDiff) + ) + } + } + + /// Store the 'deletions-only' update logic in a block as there are a number of places we will fallback to this logic + let sendDeletionsOnlyUpdateIfNeeded: () -> () = { + guard !attachmentRowIdsToDelete.isEmpty else { return } + + DispatchQueue.main.async { [weak self] in + self?.onGalleryChange?(galleryDataWithDeletions, updatedPageInfo) + } + } + + // If there are no inserted/updated rows then trigger the update callback and stop here + guard !attachmentRowIdsToQuery.isEmpty else { + sendDeletionsOnlyUpdateIfNeeded() + return + } + + // Fetch the indexes of the rowIds so we can determine whether they should be added to the screen + let interaction: TypedTableAlias = TypedTableAlias() + let itemIndexes: [Int] = (try? Item.galleryIndexes(for: attachmentRowIdsToQuery, threadId: self.threadId) + .fetchAll(db)) + .defaulting(to: []) + + // Determine if the indexes for the row ids should be displayed on the screen and remove any + // which shouldn't - values less than 'currentCount' or if there is at least one value less than + // 'currentCount' and the indexes are sequential (ie. more than the current loaded content was + // added at once) + let itemsAreSequential: Bool = (itemIndexes.map { $0 - 1 }.dropFirst() == itemIndexes.dropLast()) + let validAttachmentRowIds: Set = (itemsAreSequential && itemIndexes.contains(where: { $0 < updatedPageInfo.currentCount }) ? + attachmentRowIdsToQuery : + zip(itemIndexes, attachmentRowIdsToQuery) + .filter { index, _ -> Bool in index < updatedPageInfo.currentCount } + .map { _, rowId -> Int64 in rowId } + .asSet() + ) + + // If there are no valid attachment row ids then stop here + guard !validAttachmentRowIds.isEmpty else { + sendDeletionsOnlyUpdateIfNeeded() + return + } + + // Fetch the inserted/updated rows + let updatedItems: [Item] = (try? Item.galleryQuery + .filter(validAttachmentRowIds.contains(Column.rowID)) + .filter(interaction[.threadId] == self.threadId) + .fetchAll(db)) + .defaulting(to: []) + + // If the inserted/updated rows we irrelevant (eg. associated to another thread, a quote or a link + // preview) then trigger the update callback (if there were deletions) and stop here + guard !updatedItems.isEmpty else { + sendDeletionsOnlyUpdateIfNeeded() + return + } + + // Process the upserted data + let updatedGalleryData: (sections: [SectionModel], pageInfo: PageInfo) = updatedGalleryData( + with: galleryDataWithDeletions, + dataToUpsert: updatedItems, + pageInfoToUpdate: updatedPageInfo + ) + + DispatchQueue.main.async { [weak self] in + self?.onGalleryChange?(updatedGalleryData.sections, updatedGalleryData.pageInfo) + } + } + + public func databaseDidRollback(_ db: Database) {} + + // MARK: - Functions + + @discardableResult public func loadAndCacheAlbumData(for interactionId: Int64) -> [Item] { + typealias AlbumInfo = (albumData: [Item], interactionIdBefore: Int64?, interactionIdAfter: Int64?) + + // Note: It's possible we already have cached album data for this interaction + // but to avoid displaying stale data we re-fetch from the database anyway + let maybeAlbumInfo: AlbumInfo? = GRDBStorage.shared + .read { db -> AlbumInfo in + let interaction: TypedTableAlias = TypedTableAlias() + let newAlbumData: [Item] = try Item.albumQuery + .filter(interaction[.id] == interactionId) + .fetchAll(db) + + guard let albumTimestampMs: Int64 = newAlbumData.first?.interactionTimestampMs else { + return (newAlbumData, nil, nil) + } + + let itemBefore: Item? = try Item.galleryQueryReversed + .filter(interaction[.timestampMs] > albumTimestampMs) + .fetchOne(db) + let itemAfter: Item? = try Item.galleryQuery + .filter(interaction[.timestampMs] < albumTimestampMs) + .fetchOne(db) + + return (newAlbumData, itemBefore?.interactionId, itemAfter?.interactionId) + } + + guard let newAlbumInfo: AlbumInfo = maybeAlbumInfo else { return [] } + + // Cache the album info for the new interactionId + self.updateAlbumData(newAlbumInfo.albumData, for: interactionId) + self.cachedInteractionIdBefore.mutate { $0[interactionId] = newAlbumInfo.interactionIdBefore } + self.cachedInteractionIdAfter.mutate { $0[interactionId] = newAlbumInfo.interactionIdAfter } + + return newAlbumInfo.albumData + } + + public func replaceAlbumObservation(toObservationFor interactionId: Int64) { + self.observableAlbumData = self.buildAlbumObservation(for: interactionId) + } + + public func updateAlbumData(_ updatedData: [Item], for interactionId: Int64) { + self.albumData[interactionId] = updatedData + } + + public func updateGalleryData(_ updatedData: [SectionModel], pageInfo: PageInfo) { + self.galleryData = updatedData + self.pageInfo.mutate { $0 = pageInfo } + + // If we have a focused attachment id then we need to make sure the 'focusedIndexPath' + // is updated to be accurate + if let focusedAttachmentId: String = focusedAttachmentId { + self.focusedIndexPath = nil + + for (section, sectionData) in updatedData.enumerated() { + for (index, item) in sectionData.elements.enumerated() { + if item.attachment.id == focusedAttachmentId { + self.focusedIndexPath = IndexPath(item: index, section: section) + break + } + } + + if self.focusedIndexPath != nil { break } + } + } + } + + public func loadNewerGalleryItems() { + // Only allow on 'load older' fetch at a time + guard !isFetchingMoreItems.wrappedValue else { return } + + // Prevent more fetching until we have completed adding the page + isFetchingMoreItems.mutate { $0 = true } + + // Load the page before the current data (newer items) then merge and sort + // with the current data + let updatedGalleryData: (sections: [SectionModel], pageInfo: PageInfo) = updatedGalleryData( + with: galleryData, + dataToUpsert: loadGalleryPage( + .before, + currentPageInfo: pageInfo.wrappedValue + ) + ) + + DispatchQueue.main.async { [weak self] in + self?.onGalleryChange?(updatedGalleryData.sections, updatedGalleryData.pageInfo) + self?.isFetchingMoreItems.mutate { $0 = false } + } + } + + public func loadOlderGalleryItems() { + // Only allow on 'load older' fetch at a time + guard !isFetchingMoreItems.wrappedValue else { return } + + // Prevent more fetching until we have completed adding the page + isFetchingMoreItems.mutate { $0 = true } + + // Load the page after the current data (older items) then merge and sort + // with the current data + let updatedGalleryData: (sections: [SectionModel], pageInfo: PageInfo) = updatedGalleryData( + with: galleryData, + dataToUpsert: loadGalleryPage( + .after, + currentPageInfo: pageInfo.wrappedValue + ) + ) + + DispatchQueue.main.async { [weak self] in + self?.onGalleryChange?(updatedGalleryData.sections, updatedGalleryData.pageInfo) + self?.isFetchingMoreItems.mutate { $0 = false } + } + } + + public func updateFocusedItem(attachmentId: String, indexPath: IndexPath) { + // Note: We need to set both of these as the 'focusedIndexPath' is usually + // derived and if the data changes it will be regenerated using the + // 'focusedAttachmentId' value + self.focusedAttachmentId = attachmentId + self.focusedIndexPath = indexPath + } + + // MARK: - Creation Functions + + public static func createDetailViewController( + for threadId: String, + threadVariant: SessionThread.Variant, + interactionId: Int64, + selectedAttachmentId: String, + options: [MediaGalleryOption] + ) -> UIViewController? { + // Load the data for the album immediately (needed before pushing to the screen so + // transitions work nicely) + let viewModel: MediaGalleryViewModel = MediaGalleryViewModel( + threadId: threadId, + threadVariant: threadVariant + ) + viewModel.loadAndCacheAlbumData(for: interactionId) + viewModel.replaceAlbumObservation(toObservationFor: interactionId) + + guard + !viewModel.albumData.isEmpty, + let initialItem: Item = viewModel.albumData[interactionId]?.first(where: { item -> Bool in + item.attachment.id == selectedAttachmentId + }) + else { return nil } + + let pageViewController: MediaPageViewController = MediaPageViewController( + viewModel: viewModel, + initialItem: initialItem, + options: options + ) + let navController: MediaGalleryNavigationController = MediaGalleryNavigationController() + navController.viewControllers = [pageViewController] + navController.modalPresentationStyle = .fullScreen + navController.transitioningDelegate = pageViewController + + return navController + } + + public static func createTileViewController( + threadId: String, + threadVariant: SessionThread.Variant, + focusedAttachmentId: String? + ) -> MediaTileViewController { + let viewModel: MediaGalleryViewModel = MediaGalleryViewModel( + threadId: threadId, + threadVariant: threadVariant, + pageSize: MediaTileViewController.itemPageSize, + focusedAttachmentId: focusedAttachmentId + ) + + // Load the data for the album immediately (needed before pushing to the screen so + // transitions work nicely) + let pageTarget: PageInfo.Target = { + // If we don't have a `focusedAttachmentId` then default to `.before` (it'll query + // from a `0` offset + guard let targetId: String = focusedAttachmentId else { return .before } + + return .around(id: targetId) + }() + let initialGalleryData: (sections: [SectionModel], pageInfo: PageInfo) = viewModel.updatedGalleryData( + with: [], + dataToUpsert: viewModel.loadGalleryPage( + pageTarget, + currentPageInfo: PageInfo(pageSize: MediaTileViewController.itemPageSize) + ) + ) + + viewModel.updateGalleryData( + initialGalleryData.sections, + pageInfo: initialGalleryData.pageInfo + ) + + return MediaTileViewController( + viewModel: viewModel ) } } @@ -49,8 +874,13 @@ public class SNMediaGallery: NSObject { fromNavController.pushViewController( MediaGalleryViewModel.createTileViewController( threadId: threadId, - isClosedGroup: isClosedGroup, - isOpenGroup: isOpenGroup + threadVariant: { + if isClosedGroup { return .closedGroup } + if isOpenGroup { return .openGroup } + + return .contact + }(), + focusedAttachmentId: nil ), animated: true ) diff --git a/Session/Media Viewing & Editing/MediaPageViewController.swift b/Session/Media Viewing & Editing/MediaPageViewController.swift index c8a92d48e..566d0e0ec 100644 --- a/Session/Media Viewing & Editing/MediaPageViewController.swift +++ b/Session/Media Viewing & Editing/MediaPageViewController.swift @@ -1,55 +1,33 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import GRDB import PromiseKit import SessionUIKit +import SessionMessagingKit +import SignalUtilitiesKit -// Objc wrapper for the MediaGalleryItem struct -@objc -public class GalleryItemBox: NSObject { - public let value: MediaGalleryItem - - init(_ value: MediaGalleryItem) { - self.value = value +class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate, MediaDetailViewControllerDelegate, InteractivelyDismissableViewController { + class DynamicallySizedView: UIView { + override var intrinsicContentSize: CGSize { CGSize.zero } } - - @objc - public var attachmentStream: TSAttachmentStream { - return value.attachmentStream - } -} - -private class Box { - var value: A - init(_ val: A) { - self.value = val - } -} - -fileprivate extension MediaDetailViewController { - fileprivate var galleryItem: MediaGalleryItem { - return self.galleryItemBox.value - } -} - -class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate, MediaDetailViewControllerDelegate, MediaGalleryDataSourceDelegate { - - private weak var mediaGalleryDataSource: MediaGalleryDataSource? - - private var cachedPages: [MediaGalleryItem: MediaDetailViewController] = [:] - private var initialPage: MediaDetailViewController! - + + fileprivate var mediaInteractiveDismiss: MediaInteractiveDismiss? + + public let viewModel: MediaGalleryViewModel + private var dataChangeObservable: DatabaseCancellable? + private var initialPage: MediaDetailViewController + private var cachedPages: [Int64: [MediaGalleryViewModel.Item: MediaDetailViewController]] = [:] + public var currentViewController: MediaDetailViewController { return viewControllers!.first as! MediaDetailViewController } - public var currentItem: MediaGalleryItem! { - return currentViewController.galleryItemBox.value + public var currentItem: MediaGalleryViewModel.Item { + return currentViewController.galleryItem } - public func setCurrentItem(_ item: MediaGalleryItem, direction: UIPageViewController.NavigationDirection, animated isAnimated: Bool) { + public func setCurrentItem(_ item: MediaGalleryViewModel.Item, direction: UIPageViewController.NavigationDirection, animated isAnimated: Bool) { guard let galleryPage = self.buildGalleryPage(galleryItem: item) else { owsFailDebug("unexpectedly unable to build new gallery page") return @@ -59,36 +37,34 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou updateCaption(item: item) setViewControllers([galleryPage], direction: direction, animated: isAnimated) updateFooterBarButtonItems(isPlayingVideo: false) - updateMediaRail() + updateMediaRail(item: item) } - private let uiDatabaseConnection: YapDatabaseConnection - private let showAllMediaButton: Bool private let sliderEnabled: Bool - init(initialItem: MediaGalleryItem, mediaGalleryDataSource: MediaGalleryDataSource, uiDatabaseConnection: YapDatabaseConnection, options: MediaGalleryOption) { - assert(uiDatabaseConnection.isInLongLivedReadTransaction()) - self.uiDatabaseConnection = uiDatabaseConnection + init( + viewModel: MediaGalleryViewModel, + initialItem: MediaGalleryViewModel.Item, + options: [MediaGalleryOption] + ) { + self.viewModel = viewModel self.showAllMediaButton = options.contains(.showAllMediaButton) self.sliderEnabled = options.contains(.sliderEnabled) - self.mediaGalleryDataSource = mediaGalleryDataSource - - let kSpacingBetweenItems: CGFloat = 20 - - let options: [UIPageViewController.OptionsKey: Any] = [.interPageSpacing: kSpacingBetweenItems] - super.init(transitionStyle: .scroll, - navigationOrientation: .horizontal, - options: options) + self.initialPage = MediaDetailViewController(galleryItem: initialItem) + super.init( + transitionStyle: .scroll, + navigationOrientation: .horizontal, + options: [ .interPageSpacing: 20 ] + ) + + self.cachedPages[initialItem.interactionId] = [initialItem: self.initialPage] + self.initialPage.delegate = self self.dataSource = self self.delegate = self - - guard let initialPage = self.buildGalleryPage(galleryItem: initialItem) else { - owsFailDebug("unexpectedly unable to build initial gallery item") - return - } - self.initialPage = initialPage + self.modalPresentationStyle = .overFullScreen + self.transitioningDelegate = self self.setViewControllers([initialPage], direction: .forward, animated: false, completion: nil) } @@ -102,10 +78,31 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou } // MARK: - Subview + + private var hasAppeared: Bool = false + override var canBecomeFirstResponder: Bool { hasAppeared } - // MARK: Bottom Bar + override var inputAccessoryView: UIView? { + return bottomContainer + } + + // MARK: - Bottom Bar + var bottomContainer: UIView! - var footerBar: UIToolbar! + + var footerBar: UIToolbar = { + let result: UIToolbar = UIToolbar() + result.clipsToBounds = true // hide 1px top-border + result.tintColor = Colors.text + result.barTintColor = Colors.navigationBarBackground + result.setBackgroundImage(UIImage(), forToolbarPosition: .any, barMetrics: UIBarMetrics.default) + result.setShadowImage(UIImage(), forToolbarPosition: .any) + result.isTranslucent = false + result.backgroundColor = Colors.navigationBarBackground + + return result + }() + let captionContainerView: CaptionContainerView = CaptionContainerView() var galleryRailView: GalleryRailView = GalleryRailView() @@ -118,11 +115,8 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou // Navigation - // Note: using a custom leftBarButtonItem breaks the interactive pop gesture, but we don't want to be able - // to swipe to go back in the pager view anyway, instead swiping back should show the next page. let backButton = OWSViewController.createOWSBackButton(withTarget: self, selector: #selector(didPressDismissButton)) self.navigationItem.leftBarButtonItem = backButton - self.navigationItem.titleView = portraitHeaderView if showAllMediaButton { @@ -134,11 +128,18 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou // The alternative would be that content would shift when the navbars hide. self.extendedLayoutIncludesOpaqueBars = true self.automaticallyAdjustsScrollViewInsets = false + + // Disable the interactivePopGestureRecognizer as we want to be able to swipe between + // different pages + self.navigationController?.interactivePopGestureRecognizer?.isEnabled = false + self.mediaInteractiveDismiss = MediaInteractiveDismiss(targetViewController: self) + self.mediaInteractiveDismiss?.addGestureRecognizer(to: view) // Get reference to paged content which lives in a scrollView created by the superclass // We show/hide this content during presentation for view in self.view.subviews { if let pagerScrollView = view as? UIScrollView { + pagerScrollView.contentInsetAdjustmentBehavior = .never self.pagerScrollView = pagerScrollView } } @@ -154,54 +155,97 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou // Views pagerScrollView.backgroundColor = Colors.navigationBarBackground - + view.backgroundColor = Colors.navigationBarBackground captionContainerView.delegate = self updateCaptionContainerVisibility() + galleryRailView.isHidden = true galleryRailView.delegate = self galleryRailView.autoSetDimension(.height, toSize: 72) + footerBar.autoSetDimension(.height, toSize: 44) - let footerBar = self.makeClearToolbar() - self.footerBar = footerBar - footerBar.tintColor = Colors.text - footerBar.setBackgroundImage(UIImage(), forToolbarPosition: .any, barMetrics: UIBarMetrics.default) - footerBar.setShadowImage(UIImage(), forToolbarPosition: .any) - footerBar.isTranslucent = false - footerBar.barTintColor = Colors.navigationBarBackground - - let bottomContainer = UIView() - self.bottomContainer = bottomContainer + let bottomContainer: DynamicallySizedView = DynamicallySizedView() + bottomContainer.clipsToBounds = true + bottomContainer.autoresizingMask = .flexibleHeight bottomContainer.backgroundColor = Colors.navigationBarBackground + self.bottomContainer = bottomContainer let bottomStack = UIStackView(arrangedSubviews: [captionContainerView, galleryRailView, footerBar]) bottomStack.axis = .vertical + bottomStack.isLayoutMarginsRelativeArrangement = true bottomContainer.addSubview(bottomStack) bottomStack.autoPinEdgesToSuperviewEdges() - - self.view.addSubview(bottomContainer) - bottomContainer.autoPinWidthToSuperview() - bottomContainer.autoPinEdge(.bottom, to: .bottom, of: view) - footerBar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor).isActive = true - footerBar.autoSetDimension(.height, toSize: 44) - - updateTitle() + + let galleryRailBlockingView: UIView = UIView() + galleryRailBlockingView.backgroundColor = Colors.navigationBarBackground + bottomStack.addSubview(galleryRailBlockingView) + galleryRailBlockingView.pin(.top, to: .bottom, of: footerBar) + galleryRailBlockingView.pin(.left, to: .left, of: bottomStack) + galleryRailBlockingView.pin(.right, to: .right, of: bottomStack) + galleryRailBlockingView.pin(.bottom, to: .bottom, of: bottomStack) + + updateTitle(item: currentItem) updateCaption(item: currentItem) - updateMediaRail() - updateFooterBarButtonItems(isPlayingVideo: true) + updateMediaRail(item: currentItem) + updateFooterBarButtonItems(isPlayingVideo: false) // Gestures let verticalSwipe = UISwipeGestureRecognizer(target: self, action: #selector(didSwipeView)) verticalSwipe.direction = [.up, .down] view.addGestureRecognizer(verticalSwipe) - + let navigationBar = navigationController!.navigationBar navigationBar.setBackgroundImage(UIImage(), for: UIBarMetrics.default) navigationBar.shadowImage = UIImage() navigationBar.isTranslucent = false navigationBar.barTintColor = Colors.navigationBarBackground + + // Notifications + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationDidBecomeActive(_:)), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationDidResignActive(_:)), + name: UIApplication.didEnterBackgroundNotification, object: nil + ) + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + startObservingChanges() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + hasAppeared = true + becomeFirstResponder() + } + + public override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + // Stop observing database changes + dataChangeObservable?.cancel() + + resignFirstResponder() + } + + @objc func applicationDidBecomeActive(_ notification: Notification) { + startObservingChanges() + } + + @objc func applicationDidResignActive(_ notification: Notification) { + // Stop observing database changes + dataChangeObservable?.cancel() } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { @@ -248,25 +292,12 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou } } - private func makeClearToolbar() -> UIToolbar { - let toolbar = UIToolbar() - - toolbar.backgroundColor = Colors.navigationBarBackground - - // hide 1px top-border - toolbar.clipsToBounds = true - - return toolbar - } - private var shouldHideToolbars: Bool = false { didSet { - if (oldValue == shouldHideToolbars) { - return - } + guard oldValue != shouldHideToolbars else { return } - // Hiding the status bar affects the positioning of the navbar. We don't want to show that in an animation, it's - // better to just have everythign "flit" in/out. + // Hiding the status bar affects the positioning of the navbar. We don't want to show + // that in an animation, it's better to just have everythign "flit" in/out UIApplication.shared.setStatusBarHidden(shouldHideToolbars, with: .none) self.navigationController?.setNavigationBarHidden(shouldHideToolbars, animated: false) @@ -280,16 +311,24 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou // MARK: Bar Buttons lazy var shareBarButton: UIBarButtonItem = { - let shareBarButton = UIBarButtonItem(barButtonSystemItem: .action, target: self, action: #selector(didPressShare)) + let shareBarButton = UIBarButtonItem( + barButtonSystemItem: .action, + target: self, + action: #selector(didPressShare) + ) shareBarButton.tintColor = Colors.text + return shareBarButton }() lazy var deleteBarButton: UIBarButtonItem = { - let deleteBarButton = UIBarButtonItem(barButtonSystemItem: .trash, - target: self, - action: #selector(didPressDelete)) + let deleteBarButton = UIBarButtonItem( + barButtonSystemItem: .trash, + target: self, + action: #selector(didPressDelete) + ) deleteBarButton.tintColor = Colors.text + return deleteBarButton }() @@ -298,78 +337,160 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou } lazy var videoPlayBarButton: UIBarButtonItem = { - let videoPlayBarButton = UIBarButtonItem(barButtonSystemItem: .play, target: self, action: #selector(didPressPlayBarButton)) + let videoPlayBarButton = UIBarButtonItem( + barButtonSystemItem: .play, + target: self, + action: #selector(didPressPlayBarButton) + ) videoPlayBarButton.tintColor = Colors.text + return videoPlayBarButton }() lazy var videoPauseBarButton: UIBarButtonItem = { - let videoPauseBarButton = UIBarButtonItem(barButtonSystemItem: .pause, target: self, action: - #selector(didPressPauseBarButton)) + let videoPauseBarButton = UIBarButtonItem( + barButtonSystemItem: .pause, + target: self, + action: #selector(didPressPauseBarButton) + ) videoPauseBarButton.tintColor = Colors.text + return videoPauseBarButton }() private func updateFooterBarButtonItems(isPlayingVideo: Bool) { - // TODO do we still need this? seems like a vestige - // from when media detail view was used for attachment approval - if self.footerBar == nil { - owsFailDebug("No footer bar visible.") - return - } - - var toolbarItems: [UIBarButtonItem] = [ - shareBarButton, - buildFlexibleSpace() - ] - - if (self.currentItem.isVideo) { - toolbarItems += [ - isPlayingVideo ? self.videoPauseBarButton : self.videoPlayBarButton, - buildFlexibleSpace() - ] - } - - toolbarItems.append(deleteBarButton) - - self.footerBar.setItems(toolbarItems, animated: false) + self.footerBar.setItems( + [ + shareBarButton, + buildFlexibleSpace(), + (self.currentItem.isVideo && isPlayingVideo ? self.videoPauseBarButton : nil), + (self.currentItem.isVideo && !isPlayingVideo ? self.videoPlayBarButton : nil), + (self.currentItem.isVideo ? buildFlexibleSpace() : nil), + deleteBarButton + ].compactMap { $0 }, + animated: false + ) } - func updateMediaRail() { - guard let currentItem = self.currentItem else { - owsFailDebug("currentItem was unexpectedly nil") + func updateMediaRail(item: MediaGalleryViewModel.Item) { + galleryRailView.configureCellViews( + album: (self.viewModel.albumData[item.interactionId] ?? []), + focusedItem: currentItem, + cellViewBuilder: { _ in return GalleryRailCellView() } + ) + } + + // MARK: - Updating + + private func startObservingChanges() { + // Start observing for data changes + dataChangeObservable = GRDBStorage.shared.start( + viewModel.observableAlbumData, + onError: { error in + }, + onChange: { [weak self] albumData in + // The defaul scheduler emits changes on the main thread + self?.handleUpdates(albumData) + } + ) + } + + private func handleUpdates(_ updatedViewData: [MediaGalleryViewModel.Item]) { + // Determine if we swapped albums (if so we don't need to do anything else) + guard updatedViewData.contains(where: { $0.interactionId == currentItem.interactionId }) else { + if let updatedInteractionId: Int64 = updatedViewData.first?.interactionId { + self.viewModel.updateAlbumData(updatedViewData, for: updatedInteractionId) + } return } - - galleryRailView.configureCellViews(itemProvider: currentItem.album, - focusedItem: currentItem, - cellViewBuilder: { _ in return GalleryRailCellView() }) + + // Clear the cached pages that no longer match + let interactionId: Int64 = currentItem.interactionId + let updatedCachedPages: [MediaGalleryViewModel.Item: MediaDetailViewController] = cachedPages[interactionId] + .defaulting(to: [:]) + .filter { key, _ -> Bool in updatedViewData.contains(key) } + + // If there are no more items in the album then dismiss the screen + guard + !updatedViewData.isEmpty, + let oldIndex: Int = self.viewModel.albumData[interactionId]?.firstIndex(of: currentItem) + else { + self.dismissSelf(animated: true) + return + } + + // Update the caches + self.viewModel.updateAlbumData(updatedViewData, for: interactionId) + self.cachedPages[interactionId] = updatedCachedPages + + // If the current item is still available then do nothing else + guard updatedCachedPages[currentItem] == nil else { return } + + // If the current item was modified within the current update then reload it (just in case) + if let updatedCurrentItem: MediaGalleryViewModel.Item = updatedViewData.first(where: { item in item.attachment.id == currentItem.attachment.id }) { + setCurrentItem(updatedCurrentItem, direction: .forward, animated: false) + return + } + + // Determine the next index (if it's less than 0 then pop the screen) + let nextIndex: Int = min(oldIndex, (updatedViewData.count - 1)) + + guard nextIndex >= 0 else { + self.dismissSelf(animated: true) + return + } + + self.setCurrentItem( + updatedViewData[nextIndex], + direction: (nextIndex < oldIndex ? + .reverse : + .forward + ), + animated: true + ) } - // MARK: Actions - - @objc - public func didPressAllMediaButton(sender: Any) { - Logger.debug("") + // MARK: - Actions + @objc public func didPressAllMediaButton(sender: Any) { currentViewController.stopAnyVideo() - - guard let mediaGalleryDataSource = self.mediaGalleryDataSource else { - owsFailDebug("mediaGalleryDataSource was unexpectedly nil") + + // If the screen wasn't presented or it was presented from a location which isn't the + // MediaTileViewController then just pop/dismiss the screen + guard + let presentingNavController: UINavigationController = (self.presentingViewController as? UINavigationController), + !(presentingNavController.viewControllers.last is MediaTileViewController) + else { + guard self.navigationController?.viewControllers.count == 1 else { + self.navigationController?.popViewController(animated: true) + return + } + + self.dismiss(animated: true) return } - mediaGalleryDataSource.showAllMedia(focusedItem: currentItem) + + // Otherwise if we came via the conversation screen we need to push a new + // instance of MediaTileViewController + let tileViewController: MediaTileViewController = MediaGalleryViewModel.createTileViewController( + threadId: self.viewModel.threadId, + threadVariant: self.viewModel.threadVariant, + focusedAttachmentId: currentItem.attachment.id + ) + + let navController: MediaGalleryNavigationController = MediaGalleryNavigationController() + navController.viewControllers = [tileViewController] + navController.modalPresentationStyle = .overFullScreen + navController.transitioningDelegate = tileViewController + + self.navigationController?.present(navController, animated: true) } - @objc - public func didSwipeView(sender: Any) { - Logger.debug("") - + @objc public func didSwipeView(sender: Any) { self.dismissSelf(animated: true) } - @objc - public func didPressDismissButton(_ sender: Any) { + @objc public func didPressDismissButton(_ sender: Any) { dismissSelf(animated: true) } @@ -379,38 +500,68 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou owsFailDebug("currentViewController was unexpectedly nil") return } - - let attachmentStream = currentViewController.galleryItem.attachmentStream - - AttachmentSharing.showShareUI(forAttachment: attachmentStream) { activityType in - guard let activityType = activityType, activityType == .saveToCameraRoll, - let tsMessage = currentViewController.galleryItem.message as? TSIncomingMessage, let thread = tsMessage.thread as? TSContactThread else { return } - let message = DataExtractionNotification() - message.kind = .mediaSaved(timestamp: tsMessage.timestamp) - Storage.write { transaction in - MessageSender.send(message, in: thread, using: transaction) - } + guard let originalFilePath: String = currentViewController.galleryItem.attachment.originalFilePath else { + return + } + + AttachmentSharing.showShareUI(for: URL(fileURLWithPath: originalFilePath)) { activityType in + guard + let activityType = activityType, + activityType == .saveToCameraRoll, + currentViewController.galleryItem.interactionVariant == .standardIncoming, + self.viewModel.threadVariant == .contact + else { return } + GRDBStorage.shared.write { db in + + guard let thread: SessionThread = try SessionThread.fetchOne(db, id: self.viewModel.threadId) else { + return + } + + try MessageSender.send( + db, + message: DataExtractionNotification( + kind: .mediaSaved( + timestamp: UInt64(currentViewController.galleryItem.interactionTimestampMs) + ) + ), + interactionId: nil, // Show no interaction for the current user + in: thread + ) + } } } - @objc - public func didPressDelete(_ sender: Any) { - guard let currentViewController = self.viewControllers?[0] as? MediaDetailViewController else { - owsFailDebug("currentViewController was unexpectedly nil") - return - } - - guard let mediaGalleryDataSource = self.mediaGalleryDataSource else { - owsFailDebug("mediaGalleryDataSource was unexpectedly nil") - return - } - - let actionSheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) - let deleteAction = UIAlertAction(title: NSLocalizedString("delete_message_for_me", comment: ""), - style: .destructive) { _ in - let deletedItem = currentViewController.galleryItem - mediaGalleryDataSource.delete(items: [deletedItem], initiatedBy: self) + @objc public func didPressDelete(_ sender: Any) { + let itemToDelete: MediaGalleryViewModel.Item = self.currentItem + let actionSheet: UIAlertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + let deleteAction = UIAlertAction( + title: "delete_message_for_me".localized(), + style: .destructive + ) { _ in + GRDBStorage.shared.writeAsync { db in + _ = try Attachment + .filter(id: itemToDelete.attachment.id) + .deleteAll(db) + + // Add the garbage collection job to delete orphaned attachment files + JobRunner.add( + db, + job: Job( + variant: .garbageCollection, + behaviour: .runOnce, + details: GarbageCollectionJob.Details( + typesToCollect: [.orphanedAttachmentFiles] + ) + ) + ) + + // Delete any interactions which had all of their attachments removed + _ = try Interaction + .filter(id: itemToDelete.interactionId) + .having(Interaction.interactionAttachments.isEmpty) + .deleteAll(db) + } } actionSheet.addAction(OWSAlerts.cancelAction) actionSheet.addAction(deleteAction) @@ -418,59 +569,24 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou self.presentAlert(actionSheet) } - // MARK: MediaGalleryDataSourceDelegate + // MARK: - Video interaction - func mediaGalleryDataSource(_ mediaGalleryDataSource: MediaGalleryDataSource, willDelete items: [MediaGalleryItem], initiatedBy: AnyObject) { - Logger.debug("") - - guard let currentItem = self.currentItem else { - owsFailDebug("currentItem was unexpectedly nil") + @objc public func didPressPlayBarButton() { + guard let currentViewController = self.viewControllers?.first as? MediaDetailViewController else { + SNLog("currentViewController was unexpectedly nil") return } - - guard items.contains(currentItem) else { - Logger.debug("irrelevant item") - return - } - - // If we setCurrentItem with (animated: true) while this VC is in the background, then - // the next/previous cache isn't expired, and we're able to swipe back to the just-deleted vc. - // So to get the correct behavior, we should only animate these transitions when this - // vc is in the foreground - let isAnimated = initiatedBy === self - - if !self.sliderEnabled { - // In message details, which doesn't use the slider, so don't swap pages. - } else if let nextItem = mediaGalleryDataSource.galleryItem(after: currentItem) { - self.setCurrentItem(nextItem, direction: .forward, animated: isAnimated) - } else if let previousItem = mediaGalleryDataSource.galleryItem(before: currentItem) { - self.setCurrentItem(previousItem, direction: .reverse, animated: isAnimated) - } else { - // else we deleted the last piece of media, return to the conversation view - self.dismissSelf(animated: true) - } + + currentViewController.didPressPlayBarButton() } - func mediaGalleryDataSource(_ mediaGalleryDataSource: MediaGalleryDataSource, deletedSections: IndexSet, deletedItems: [IndexPath]) { - // no-op - } - - @objc - public func didPressPlayBarButton(_ sender: Any) { - guard let currentViewController = self.viewControllers?[0] as? MediaDetailViewController else { - owsFailDebug("currentViewController was unexpectedly nil") + @objc public func didPressPauseBarButton() { + guard let currentViewController = self.viewControllers?.first as? MediaDetailViewController else { + SNLog("currentViewController was unexpectedly nil") return } - currentViewController.didPressPlayBarButton(sender) - } - - @objc - public func didPressPauseBarButton(_ sender: Any) { - guard let currentViewController = self.viewControllers?[0] as? MediaDetailViewController else { - owsFailDebug("currentViewController was unexpectedly nil") - return - } - currentViewController.didPressPauseBarButton(sender) + + currentViewController.didPressPauseBarButton() } // MARK: UIPageViewControllerDelegate @@ -518,8 +634,8 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou captionContainerView.completePagerTransition() } - updateTitle() - updateMediaRail() + updateTitle(item: currentItem) + updateMediaRail(item: currentItem) previousPage.zoomOut(animated: false) previousPage.stopAnyVideo() updateFooterBarButtonItems(isPlayingVideo: false) @@ -532,139 +648,129 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou // MARK: UIPageViewControllerDataSource public func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { - Logger.debug("") - - guard let previousDetailViewController = viewController as? MediaDetailViewController else { - owsFailDebug("unexpected viewController: \(viewController)") + guard let mediaViewController: MediaDetailViewController = viewController as? MediaDetailViewController else { return nil } - - guard let mediaGalleryDataSource = self.mediaGalleryDataSource else { - owsFailDebug("mediaGalleryDataSource was unexpectedly nil") + + // First check if there is another item in the current album + let interactionId: Int64 = mediaViewController.galleryItem.interactionId + + if + let currentAlbum: [MediaGalleryViewModel.Item] = self.viewModel.albumData[interactionId], + let index: Int = currentAlbum.firstIndex(of: mediaViewController.galleryItem), + index > 0, + let previousPage: MediaDetailViewController = buildGalleryPage(galleryItem: currentAlbum[index - 1]) + { + return previousPage + } + + // Then check if there is an interaction before the current album interaction + guard let interactionIdAfter: Int64 = self.viewModel.interactionIdAfter[interactionId] else { return nil } + + // Cache and retrieve the new album items + let newAlbumItems: [MediaGalleryViewModel.Item] = viewModel.loadAndCacheAlbumData(for: interactionIdAfter) + + guard + !newAlbumItems.isEmpty, + let previousPage: MediaDetailViewController = buildGalleryPage( + galleryItem: newAlbumItems[newAlbumItems.count - 1] + ) + else { + // Invalid state, restart the observer + startObservingChanges() return nil } - - let previousItem = previousDetailViewController.galleryItem - guard let nextItem: MediaGalleryItem = mediaGalleryDataSource.galleryItem(before: previousItem) else { - return nil - } - - guard let nextPage: MediaDetailViewController = buildGalleryPage(galleryItem: nextItem) else { - return nil - } - - return nextPage + + // Swap out the database observer + dataChangeObservable?.cancel() + viewModel.replaceAlbumObservation(toObservationFor: interactionIdAfter) + startObservingChanges() + + return previousPage } public func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { - Logger.debug("") - - guard let previousDetailViewController = viewController as? MediaDetailViewController else { - owsFailDebug("unexpected viewController: \(viewController)") + guard let mediaViewController: MediaDetailViewController = viewController as? MediaDetailViewController else { return nil } + + // First check if there is another item in the current album + let interactionId: Int64 = mediaViewController.galleryItem.interactionId + + if + let currentAlbum: [MediaGalleryViewModel.Item] = self.viewModel.albumData[interactionId], + let index: Int = currentAlbum.firstIndex(of: mediaViewController.galleryItem), + index < (currentAlbum.count - 1), + let nextPage: MediaDetailViewController = buildGalleryPage(galleryItem: currentAlbum[index + 1]) + { + return nextPage + } + + // Then check if there is an interaction before the current album interaction + guard let interactionIdBefore: Int64 = self.viewModel.interactionIdBefore[interactionId] else { return nil } - guard let mediaGalleryDataSource = self.mediaGalleryDataSource else { - owsFailDebug("mediaGalleryDataSource was unexpectedly nil") + // Cache and retrieve the new album items + let newAlbumItems: [MediaGalleryViewModel.Item] = viewModel.loadAndCacheAlbumData(for: interactionIdBefore) + + guard + !newAlbumItems.isEmpty, + let nextPage: MediaDetailViewController = buildGalleryPage(galleryItem: newAlbumItems[0]) + else { + // Invalid state, restart the observer + startObservingChanges() return nil } - - let previousItem = previousDetailViewController.galleryItem - guard let nextItem = mediaGalleryDataSource.galleryItem(after: previousItem) else { - // no more pages - return nil - } - - guard let nextPage: MediaDetailViewController = buildGalleryPage(galleryItem: nextItem) else { - return nil - } - + + // Swap out the database observer + dataChangeObservable?.cancel() + viewModel.replaceAlbumObservation(toObservationFor: interactionIdBefore) + startObservingChanges() + return nextPage } - private func buildGalleryPage(galleryItem: MediaGalleryItem) -> MediaDetailViewController? { - - if let cachedPage = cachedPages[galleryItem] { - Logger.debug("cache hit.") + private func buildGalleryPage(galleryItem: MediaGalleryViewModel.Item) -> MediaDetailViewController? { + if let cachedPage: MediaDetailViewController = cachedPages[galleryItem.interactionId]?[galleryItem] { return cachedPage } - - Logger.debug("cache miss.") - var fetchedItem: ConversationViewItem? - self.uiDatabaseConnection.read { transaction in - let message = galleryItem.message - let thread = message.thread(with: transaction) - fetchedItem = ConversationInteractionViewItem(interaction: message, - isGroupThread: thread.isGroupThread(), - transaction: transaction) - } - - guard let viewItem = fetchedItem else { - owsFailDebug("viewItem was unexpectedly nil") - return nil - } - - let viewController = MediaDetailViewController(galleryItemBox: GalleryItemBox(galleryItem), viewItem: viewItem) - viewController.delegate = self - - cachedPages[galleryItem] = viewController - return viewController + + cachedPages[galleryItem.interactionId] = (cachedPages[galleryItem.interactionId] ?? [:]) + .setting(galleryItem, MediaDetailViewController(galleryItem: galleryItem, delegate: self)) + + return cachedPages[galleryItem.interactionId]?[galleryItem] } public func dismissSelf(animated isAnimated: Bool, completion: (() -> Void)? = nil) { + // If we have presented a MediaTileViewController from this screen then it will continue + // to observe media changes and if all the items in the album this screen is showing are + // deleted it will attempt to auto-dismiss + guard self.presentedViewController == nil else { return } + // Swapping mediaView for presentationView will be perceptible if we're not zoomed out all the way. // currentVC currentViewController.zoomOut(animated: true) currentViewController.stopAnyVideo() - guard let mediaGalleryDataSource = self.mediaGalleryDataSource else { - owsFailDebug("mediaGalleryDataSource was unexpectedly nil") - self.presentingViewController?.dismiss(animated: true) - - return - } - - if IsLandscapeOrientationEnabled() { - mediaGalleryDataSource.dismissMediaDetailViewController(self, - animated: isAnimated, - completion: completion) - } else { - mediaGalleryDataSource.dismissMediaDetailViewController(self, animated: isAnimated) { + self.navigationController?.view.isUserInteractionEnabled = false + self.navigationController?.dismiss(animated: true, completion: { [weak self] in + if !IsLandscapeOrientationEnabled() { UIDevice.current.ows_setOrientation(.portrait) - completion?() } - } + + UIApplication.shared.isStatusBarHidden = false + self?.navigationController?.presentingViewController?.setNeedsStatusBarAppearanceUpdate() + completion?() + }) } // MARK: MediaDetailViewControllerDelegate - @objc public func mediaDetailViewControllerDidTapMedia(_ mediaDetailViewController: MediaDetailViewController) { Logger.debug("") self.shouldHideToolbars = !self.shouldHideToolbars } - public func mediaDetailViewController(_ mediaDetailViewController: MediaDetailViewController, requestDelete attachment: TSAttachment) { - guard let mediaGalleryDataSource = self.mediaGalleryDataSource else { - owsFailDebug("mediaGalleryDataSource was unexpectedly nil") - self.presentingViewController?.dismiss(animated: true) - - return - } - - guard let galleryItem = self.mediaGalleryDataSource?.galleryItems.first(where: { $0.attachmentStream == attachment }) else { - owsFailDebug("galleryItem was unexpectedly nil") - self.presentingViewController?.dismiss(animated: true) - - return - } - - dismissSelf(animated: true) { - mediaGalleryDataSource.delete(items: [galleryItem], initiatedBy: self) - } - } - public func mediaDetailViewController(_ mediaDetailViewController: MediaDetailViewController, isPlayingVideo: Bool) { guard mediaDetailViewController == currentViewController else { Logger.verbose("ignoring stale delegate.") @@ -675,19 +781,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou self.updateFooterBarButtonItems(isPlayingVideo: isPlayingVideo) } - // MARK: Dynamic Header - - private func senderName(message: TSMessage) -> String { - switch message { - case let incomingMessage as TSIncomingMessage: - return Profile.displayName(for: incomingMessage.authorId, thread: incomingMessage.thread) - case is TSOutgoingMessage: - return NSLocalizedString("MEDIA_GALLERY_SENDER_NAME_YOU", comment: "Short sender label for media sent by you") - default: - owsFailDebug("Unknown message type: \(type(of: message))") - return "" - } - } + // MARK: - Dynamic Header private lazy var dateFormatter: DateFormatter = { let formatter = DateFormatter() @@ -743,24 +837,40 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou return containerView }() - private func updateTitle() { - guard let currentItem = self.currentItem else { - owsFailDebug("currentItem was unexpectedly nil") - return - } - updateTitle(item: currentItem) - } - - private func updateCaption(item: MediaGalleryItem) { + private func updateCaption(item: MediaGalleryViewModel.Item) { captionContainerView.currentText = item.captionForDisplay } - private func updateTitle(item: MediaGalleryItem) { - let name = senderName(message: item.message) + private func updateTitle(item: MediaGalleryViewModel.Item) { + let targetItem: MediaGalleryViewModel.Item = item + let threadVariant: SessionThread.Variant = self.viewModel.threadVariant + + let name: String = { + switch targetItem.interactionVariant { + case .standardIncoming: + return GRDBStorage.shared + .read { db in + Profile.displayName( + db, + id: targetItem.interactionAuthorId, + threadVariant: threadVariant + ) + } + .defaulting(to: Profile.truncated(id: targetItem.interactionAuthorId, truncating: .middle)) + + case .standardOutgoing: + return "MEDIA_GALLERY_SENDER_NAME_YOU".localized() //"Short sender label for media sent by you" + + default: + owsFailDebug("Unsupported message variant: \(targetItem.interactionVariant)") + return "" + } + }() + portraitHeaderNameLabel.text = name // use sent date - let date = Date(timeIntervalSince1970: Double(item.message.timestamp) / 1000) + let date = Date(timeIntervalSince1970: (Double(targetItem.interactionTimestampMs) / 1000)) let formattedDate = dateFormatter.string(from: date) portraitHeaderDateLabel.text = formattedDate @@ -774,7 +884,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou } else { // Size the titleView to be large enough to fit the widest label, // but no larger. If we go for a "full width" label, our title view - // will not be centered (since the left and right bar buttons have different widths) + // will not be centered (since the left and right bar buttons have different widths) portraitHeaderNameLabel.sizeToFit() portraitHeaderDateLabel.sizeToFit() let width = max(portraitHeaderNameLabel.frame.width, portraitHeaderDateLabel.frame.width) @@ -783,9 +893,15 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou portraitHeaderView.frame = headerFrame } } + + // MARK: - InteractivelyDismissableViewController + + func performInteractiveDismissal(animated: Bool) { + dismissSelf(animated: true) + } } -extension MediaGalleryItem: GalleryRailItem { +extension MediaGalleryViewModel.Item: GalleryRailItem { public func buildRailItemView() -> UIView { let imageView = UIImageView() imageView.contentMode = .scaleAspectFill @@ -799,30 +915,32 @@ extension MediaGalleryItem: GalleryRailItem { public func getRailImage() -> Guarantee { return Guarantee { fulfill in - if let image = self.thumbnailImage(async: { fulfill($0) }) { - fulfill(image) - } + self.thumbnailImage(async: { image in fulfill(image) }) } } -} - -extension MediaGalleryAlbum: GalleryRailItemProvider { - var railItems: [GalleryRailItem] { - return self.items + + public func isEqual(to other: GalleryRailItem?) -> Bool { + guard let otherItem: MediaGalleryViewModel.Item = other as? MediaGalleryViewModel.Item else { return false } + + return (self == otherItem) } } extension MediaPageViewController: GalleryRailViewDelegate { func galleryRailView(_ galleryRailView: GalleryRailView, didTapItem imageRailItem: GalleryRailItem) { - guard let targetItem = imageRailItem as? MediaGalleryItem else { + guard let targetItem = imageRailItem as? MediaGalleryViewModel.Item else { owsFailDebug("unexpected imageRailItem: \(imageRailItem)") return } - let direction: UIPageViewController.NavigationDirection - direction = currentItem.albumIndex < targetItem.albumIndex ? .forward : .reverse - - self.setCurrentItem(targetItem, direction: direction, animated: true) + self.setCurrentItem( + targetItem, + direction: (currentItem.attachmentAlbumIndex < targetItem.attachmentAlbumIndex ? + .forward : + .reverse + ), + animated: true + ) } } @@ -848,3 +966,57 @@ extension MediaPageViewController: CaptionContainerViewDelegate { captionContainerView.isHidden = true } } + +// MARK: - UIViewControllerTransitioningDelegate + +extension MediaPageViewController: UIViewControllerTransitioningDelegate { + public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? { + guard self == presented || self.navigationController == presented else { return nil } + + return MediaZoomAnimationController(galleryItem: currentItem) + } + + public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { + guard self == dismissed || self.navigationController == dismissed else { return nil } + guard !self.viewModel.albumData.isEmpty else { return nil } + + let animationController = MediaDismissAnimationController(galleryItem: currentItem, interactionController: mediaInteractiveDismiss) + mediaInteractiveDismiss?.interactiveDismissDelegate = animationController + + return animationController + } + + public func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { + guard let animator = animator as? MediaDismissAnimationController, + let interactionController = animator.interactionController, + interactionController.interactionInProgress + else { + return nil + } + + return interactionController + } +} + +// MARK: - MediaPresentationContextProvider + +extension MediaPageViewController: MediaPresentationContextProvider { + func mediaPresentationContext(mediaItem: Media, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? { + let mediaView = currentViewController.mediaView + + guard let mediaSuperview: UIView = mediaView.superview else { return nil } + + let presentationFrame = coordinateSpace.convert(mediaView.frame, from: mediaSuperview) + + return MediaPresentationContext( + mediaView: mediaView, + presentationFrame: presentationFrame, + cornerRadius: 0, + cornerMask: CACornerMask() + ) + } + + func snapshotOverlayView(in coordinateSpace: UICoordinateSpace) -> (UIView, CGRect)? { + return self.navigationController?.navigationBar.generateSnapshot(in: coordinateSpace) + } +} diff --git a/Session/Media Viewing & Editing/MediaTileViewController.swift b/Session/Media Viewing & Editing/MediaTileViewController.swift index 6585813e2..049938c62 100644 --- a/Session/Media Viewing & Editing/MediaTileViewController.swift +++ b/Session/Media Viewing & Editing/MediaTileViewController.swift @@ -1,531 +1,631 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import QuartzCore import GRDB import DifferenceKit import SessionUIKit import SignalUtilitiesKit -public protocol MediaTileViewControllerDelegate: AnyObject { - func mediaTileViewController(_ viewController: MediaTileViewController, didTapView tappedView: UIView, mediaGalleryItem: MediaGalleryViewModel.Item) -} - -public class MediaTileViewController: UICollectionViewController, MediaGalleryDataSourceDelegate, UICollectionViewDelegateFlowLayout { - - private weak var mediaGalleryDataSource: MediaGalleryDataSource? - - private var galleryItems: [GalleryDate: [MediaGalleryItem]] { - guard let mediaGalleryDataSource = self.mediaGalleryDataSource else { - owsFailDebug("mediaGalleryDataSource was unexpectedly nil") - return [:] +public class MediaTileViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout { + + /// This should be larger than one screen size so we don't have to call it multiple times in rapid succession, but not + /// so large that loading get's really chopping + static let itemPageSize: Int = Int(11 * itemsPerPortraitRow) + static let itemsPerPortraitRow: CGFloat = 4 + static let interItemSpacing: CGFloat = 2 + static let footerBarHeight: CGFloat = 40 + static let loadMoreHeaderHeight: CGFloat = 100 + static let autoLoadNextPageDelay: DispatchTimeInterval = .milliseconds(400) + + private let viewModel: MediaGalleryViewModel + private var hasLoadedInitialData: Bool = false + private var currentTargetOffset: CGPoint? + + var isInBatchSelectMode = false { + didSet { + collectionView.allowsMultipleSelection = isInBatchSelectMode + updateSelectButton(updatedData: self.viewModel.galleryData, inBatchSelectMode: isInBatchSelectMode) + updateDeleteButton() } - return mediaGalleryDataSource.sections } + + // MARK: - Initialization - private var galleryDates: [GalleryDate] { - guard let mediaGalleryDataSource = self.mediaGalleryDataSource else { - owsFailDebug("mediaGalleryDataSource was unexpectedly nil") - return [] - } - return mediaGalleryDataSource.sectionDates - } - public var focusedItem: MediaGalleryItem? + init(viewModel: MediaGalleryViewModel) { + self.viewModel = viewModel + + // Start observing database changes + GRDBStorage.shared.addObserver(viewModel) - private let uiDatabaseConnection: YapDatabaseConnection - - public weak var delegate: MediaTileViewControllerDelegate? - - deinit { - Logger.debug("deinit") - } - - fileprivate let mediaTileViewLayout: MediaTileViewLayout - - init(mediaGalleryDataSource: MediaGalleryDataSource, uiDatabaseConnection: YapDatabaseConnection) { - - self.mediaGalleryDataSource = mediaGalleryDataSource - assert(uiDatabaseConnection.isInLongLivedReadTransaction()) - self.uiDatabaseConnection = uiDatabaseConnection - - let layout: MediaTileViewLayout = type(of: self).buildLayout() - self.mediaTileViewLayout = layout - super.init(collectionViewLayout: layout) + super.init(nibName: nil, bundle: nil) } required public init?(coder aDecoder: NSCoder) { notImplemented() } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // MARK: - UI + + override public var supportedInterfaceOrientations: UIInterfaceOrientationMask { + return .allButUpsideDown + } + + var footerBarBottomConstraint: NSLayoutConstraint? - // MARK: Subviews + fileprivate lazy var mediaTileViewLayout: MediaTileViewLayout = { + let result: MediaTileViewLayout = MediaTileViewLayout() + result.sectionInsetReference = .fromSafeArea + result.minimumInteritemSpacing = MediaTileViewController.interItemSpacing + result.minimumLineSpacing = MediaTileViewController.interItemSpacing + result.sectionHeadersPinToVisibleBounds = true + + return result + }() + + lazy var collectionView: UICollectionView = { + let result: UICollectionView = UICollectionView(frame: .zero, collectionViewLayout: mediaTileViewLayout) + result.translatesAutoresizingMaskIntoConstraints = false + result.backgroundColor = Colors.navigationBarBackground + result.delegate = self + result.dataSource = self + result.register(view: PhotoGridViewCell.self) + result.register(view: MediaGallerySectionHeader.self, ofKind: UICollectionView.elementKindSectionHeader) + result.register(view: MediaGalleryStaticHeader.self, ofKind: UICollectionView.elementKindSectionHeader) + + // Feels a bit weird to have content smashed all the way to the bottom edge. + result.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 20, right: 0) + + return result + }() lazy var footerBar: UIToolbar = { - let footerBar = UIToolbar() - let footerItems = [ - UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil), - deleteButton, - UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) - ] - footerBar.setItems(footerItems, animated: false) + let result: UIToolbar = UIToolbar() + result.setItems( + [ + UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil), + deleteButton, + UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) + ], + animated: false + ) - footerBar.barTintColor = Colors.navigationBarBackground - footerBar.tintColor = Colors.text + result.barTintColor = Colors.navigationBarBackground + result.tintColor = Colors.text - return footerBar + return result }() lazy var deleteButton: UIBarButtonItem = { - let deleteButton = UIBarButtonItem(barButtonSystemItem: .trash, - target: self, - action: #selector(didPressDelete)) - deleteButton.tintColor = Colors.text + let result: UIBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .trash, + target: self, + action: #selector(didPressDelete) + ) + result.tintColor = Colors.text - return deleteButton + return result }() - // MARK: View Lifecycle Overrides + // MARK: - Lifecycle override public func viewDidLoad() { super.viewDidLoad() + + // Add a custom back button if this is the only view controller + if self.navigationController?.viewControllers.first == self { + let backButton = OWSViewController.createOWSBackButton(withTarget: self, selector: #selector(didPressDismissButton)) + self.navigationItem.leftBarButtonItem = backButton + } - ViewControllerUtilities.setUpDefaultSessionStyle(for: self, title: MediaStrings.allMedia, hasCustomBackButton: false) + ViewControllerUtilities.setUpDefaultSessionStyle( + for: self, + title: MediaStrings.allMedia, + hasCustomBackButton: false + ) - guard let collectionView = self.collectionView else { - owsFailDebug("collectionView was unexpectedly nil") - return - } - - collectionView.backgroundColor = Colors.navigationBarBackground - - collectionView.register(PhotoGridViewCell.self, forCellWithReuseIdentifier: PhotoGridViewCell.reuseIdentifier) - collectionView.register(MediaGallerySectionHeader.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: MediaGallerySectionHeader.reuseIdentifier) - collectionView.register(MediaGalleryStaticHeader.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: MediaGalleryStaticHeader.reuseIdentifier) - - collectionView.delegate = self - - // feels a bit weird to have content smashed all the way to the bottom edge. - collectionView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 20, right: 0) - - self.view.addSubview(self.footerBar) + view.addSubview(self.collectionView) + collectionView.autoPin(toEdgesOf: view) + + view.addSubview(self.footerBar) footerBar.autoPinWidthToSuperview() - footerBar.autoSetDimension(.height, toSize: kFooterBarHeight) - self.footerBarBottomConstraint = footerBar.autoPinEdge(toSuperviewEdge: .bottom, withInset: -kFooterBarHeight) + footerBar.autoSetDimension(.height, toSize: MediaTileViewController.footerBarHeight) + self.footerBarBottomConstraint = footerBar.autoPinEdge(toSuperviewEdge: .bottom, withInset: -MediaTileViewController.footerBarHeight) - updateSelectButton() + self.updateSelectButton(updatedData: self.viewModel.galleryData, inBatchSelectMode: false) self.mediaTileViewLayout.invalidateLayout() + + // Notifications + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationDidBecomeActive(_:)), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationDidResignActive(_:)), + name: UIApplication.didEnterBackgroundNotification, object: nil + ) } - - private func indexPath(galleryItem: MediaGalleryItem) -> IndexPath? { - guard let sectionIdx = galleryDates.firstIndex(of: galleryItem.galleryDate) else { - return nil - } - guard let rowIdx = galleryItems[galleryItem.galleryDate]!.firstIndex(of: galleryItem) else { - return nil - } - - return IndexPath(row: rowIdx, section: sectionIdx + 1) - } - - override public func viewWillAppear(_ animated: Bool) { + + public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - - guard let focusedItem = self.focusedItem else { - return - } - - guard let indexPath = self.indexPath(galleryItem: focusedItem) else { - owsFailDebug("unexpectedly unable to find indexPath for focusedItem: \(focusedItem)") - return - } - - Logger.debug("scrolling to focused item at indexPath: \(indexPath)") - self.view.layoutIfNeeded() - self.collectionView?.scrollToItem(at: indexPath, at: .centeredVertically, animated: false) - self.autoLoadMoreIfNecessary() + + startObservingChanges() + triggerInitialDataLoadIfNeeded() + } + + public override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + // Stop observing database changes + self.viewModel.onGalleryChange = nil + } + + @objc func applicationDidBecomeActive(_ notification: Notification) { + startObservingChanges() + } + + @objc func applicationDidResignActive(_ notification: Notification) { + // Stop observing database changes + self.viewModel.onGalleryChange = nil } - override public func viewWillTransition(to size: CGSize, - with coordinator: UIViewControllerTransitionCoordinator) { + override public func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { self.mediaTileViewLayout.invalidateLayout() } public override func viewWillLayoutSubviews() { super.viewWillLayoutSubviews() + self.updateLayout() } - - // MARK: Orientation - - override public var supportedInterfaceOrientations: UIInterfaceOrientationMask { - return .allButUpsideDown - } - - // MARK: UICollectionViewDelegate - - override public func scrollViewDidScroll(_ scrollView: UIScrollView) { - self.autoLoadMoreIfNecessary() - } - - override public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { - self.isUserScrolling = true - } - - override public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { - self.isUserScrolling = false - } - - override public func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { - - Logger.debug("") - - guard galleryDates.count > 0 else { - return false - } - - switch indexPath.section { - case kLoadOlderSectionIdx, loadNewerSectionIdx: - return false - default: - return true - } - } - - override public func collectionView(_ collectionView: UICollectionView, shouldDeselectItemAt indexPath: IndexPath) -> Bool { - - Logger.debug("") - - guard galleryDates.count > 0 else { - return false - } - - switch indexPath.section { - case kLoadOlderSectionIdx, loadNewerSectionIdx: - return false - default: - return true - } - } - - public override func collectionView(_ collectionView: UICollectionView, shouldHighlightItemAt indexPath: IndexPath) -> Bool { - - Logger.debug("") - - guard galleryDates.count > 0 else { - return false - } - - switch indexPath.section { - case kLoadOlderSectionIdx, loadNewerSectionIdx: - return false - default: - return true - } - } - - override public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - Logger.debug("") - - guard let gridCell = self.collectionView(collectionView, cellForItemAt: indexPath) as? PhotoGridViewCell else { - owsFailDebug("galleryCell was unexpectedly nil") + + // MARK: - Updating + + private func triggerInitialDataLoadIfNeeded() { + // Ensure this hasn't run before and that we have data (The 'galleryData' will always + // contain something as the 'empty' state is a section within 'galleryData') + guard !self.hasLoadedInitialData && !self.viewModel.galleryData.isEmpty else { return } + + // If we have a focused item then we want to scroll to it + guard let focusedIndexPath: IndexPath = self.viewModel.focusedIndexPath else { + self.hasLoadedInitialData = true return } - - guard let galleryItem = (gridCell.item as? GalleryGridCellItem)?.galleryItem else { - owsFailDebug("galleryItem was unexpectedly nil") + + Logger.debug("scrolling to focused item at indexPath: \(focusedIndexPath)") + self.view.layoutIfNeeded() + self.collectionView.scrollToItem(at: focusedIndexPath, at: .centeredVertically, animated: false) + + // Note: If we have a 'focusedIndexPath' then we want to leave this until last so we can avoid + // triggering page loads due to default content offsets + self.hasLoadedInitialData = true + + // Now that the data has loaded we need to check if either of the "load more" sections are + // visible and trigger them if so + // + // Note: We do it this way as we want to trigger the load behaviour for the first section + // if it has one before trying to trigger the load behaviour for the last section + self.autoLoadNextPageIfNeeded() + } + + private func autoLoadNextPageIfNeeded() { + DispatchQueue.main.asyncAfter(deadline: .now() + MediaTileViewController.autoLoadNextPageDelay) { [weak self] in + let sortedVisibleIndexPaths: [IndexPath] = (self?.collectionView + .indexPathsForVisibleSupplementaryElements(ofKind: UICollectionView.elementKindSectionHeader)) + .defaulting(to: []) + .sorted() + + for headerIndexPath in sortedVisibleIndexPaths { + switch self?.viewModel.galleryData[safe: headerIndexPath.section]?.model { + case .loadNewer: + self?.viewModel.loadNewerGalleryItems() + return + + case .loadOlder: + self?.viewModel.loadOlderGalleryItems() + return + + default: continue + } + } + } + } + + private func startObservingChanges() { + // Start observing for data changes (will callback on the main thread) + self.viewModel.onGalleryChange = { [weak self] updatedGalleryData, pageInfo in + self?.handleUpdates(updatedGalleryData, pageInfo: pageInfo) + } + } + + private func handleUpdates(_ updatedGalleryData: [MediaGalleryViewModel.SectionModel], pageInfo: MediaGalleryViewModel.PageInfo) { + // 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 { + UIView.performWithoutAnimation { + handleUpdates(updatedGalleryData, pageInfo: pageInfo) + triggerInitialDataLoadIfNeeded() + } return } + + // Determine if we are inserting content at the top of the collectionView + let isInsertingAtTop: Bool = { + let oldFirstSectionIsLoadMore: Bool = ( + self.viewModel.galleryData[safe: 0]?.model == .loadNewer || + self.viewModel.galleryData[safe: 0]?.model == .loadOlder + ) + let oldTargetSectionIndex: Int = (oldFirstSectionIsLoadMore ? 1 : 0) + + guard + let newTargetSectionIndex = updatedGalleryData + .firstIndex(where: { $0.model == self.viewModel.galleryData[safe: oldTargetSectionIndex]?.model }), + let oldFirstItem: MediaGalleryViewModel.Item = self.viewModel.galleryData[safe: oldTargetSectionIndex]?.elements.first, + let newFirstItemIndex = updatedGalleryData[safe: newTargetSectionIndex]?.elements.firstIndex(of: oldFirstItem) + else { return false } + + return (newTargetSectionIndex > oldTargetSectionIndex || newFirstItemIndex > 0) + }() + + // We want to maintain the same content offset between the updates if content was added to + // the top, the mediaTileViewLayout will adjust content offset to compensate for the change + // in content height so that the same content is visible after the update + // + // Using the `CollectionViewLayout.prepare` approach (rather than calling setContentOffset + // in the batchUpdate completion block) avoids a distinct flicker (we also have to + // disable animations for this to avoid buggy animations) + CATransaction.begin() + + if isInsertingAtTop { CATransaction.setDisableActions(true) } + + self.mediaTileViewLayout.isInsertingCellsToTop = isInsertingAtTop + self.mediaTileViewLayout.contentSizeBeforeInsertingToTop = self.collectionView.contentSize + self.collectionView.reload( + using: StagedChangeset(source: self.viewModel.galleryData, target: updatedGalleryData), + interrupt: { $0.changeCount > MediaTileViewController.itemPageSize } + ) { [weak self] updatedData in + self?.viewModel.updateGalleryData(updatedData, pageInfo: pageInfo) + } + + CATransaction.setCompletionBlock { [weak self] in + // Need to manually reset these here as the 'reload' method above can actually trigger + // multiple updates (eg. inserting sections and then items) + self?.mediaTileViewLayout.isInsertingCellsToTop = false + self?.mediaTileViewLayout.contentSizeBeforeInsertingToTop = nil + + // If one of the "load more" sections is still visible once the animation completes then + // trigger another "load more" (after a small delay to minimize animation bugginess) + self?.autoLoadNextPageIfNeeded() + } + CATransaction.commit() + + // Update the select button (should be hidden if there is no data) + self.updateSelectButton(updatedData: updatedGalleryData, inBatchSelectMode: isInBatchSelectMode) + } + + // MARK: - Interactions + + @objc public func didPressDismissButton() { + let presentedNavController: UINavigationController? = (self.presentingViewController as? UINavigationController) + let mediaPageViewController: MediaPageViewController? = ( + (presentedNavController?.viewControllers.last as? MediaPageViewController) ?? + (self.presentingViewController as? MediaPageViewController) + ) + + // If the album was presented from a 'MediaPageViewController' and it has no more data (ie. + // all album items had been deleted) then dismiss to the screen before that one + guard mediaPageViewController?.viewModel.albumData.isEmpty != true else { + presentedNavController?.presentingViewController?.dismiss(animated: true, completion: nil) + return + } + + dismiss(animated: true, completion: nil) + } + + // MARK: - UIScrollViewDelegate + + public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + self.currentTargetOffset = nil + } + + public func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { + self.currentTargetOffset = targetContentOffset.pointee + } + + // MARK: - UICollectionViewDataSource - if isInBatchSelectMode { + public func numberOfSections(in collectionView: UICollectionView) -> Int { + return self.viewModel.galleryData.count + } + + public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + let section: MediaGalleryViewModel.SectionModel = self.viewModel.galleryData[section] + + return section.elements.count + } + + public func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { + let section: MediaGalleryViewModel.SectionModel = self.viewModel.galleryData[indexPath.section] + + switch section.model { + case .emptyGallery, .loadOlder, .loadNewer: + let sectionHeader: MediaGalleryStaticHeader = collectionView.dequeue(type: MediaGalleryStaticHeader.self, ofKind: kind, for: indexPath) + sectionHeader.configure( + title: { + switch section.model { + case .emptyGallery: return "GALLERY_TILES_EMPTY_GALLERY".localized() + case .loadOlder: return "GALLERY_TILES_LOADING_OLDER_LABEL".localized() + case .loadNewer: return "GALLERY_TILES_LOADING_MORE_RECENT_LABEL".localized() + case .galleryMonth: return "" // Impossible case + } + }() + ) + + return sectionHeader + + case .galleryMonth(let date): + let sectionHeader: MediaGallerySectionHeader = collectionView.dequeue(type: MediaGallerySectionHeader.self, ofKind: kind, for: indexPath) + sectionHeader.configure( + title: date.localizedString + ) + + return sectionHeader + } + } + + public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let section: MediaGalleryViewModel.SectionModel = self.viewModel.galleryData[indexPath.section] + let cell: PhotoGridViewCell = collectionView.dequeue(type: PhotoGridViewCell.self, for: indexPath) + cell.configure( + item: GalleryGridCellItem( + galleryItem: section.elements[indexPath.row] + ) + ) + + return cell + } + + public func collectionView(_ collectionView: UICollectionView, willDisplaySupplementaryView view: UICollectionReusableView, forElementKind elementKind: String, at indexPath: IndexPath) { + // Want to ensure the initial content load has completed before we try to load any more data + guard self.hasLoadedInitialData else { return } + + let section: MediaGalleryViewModel.SectionModel = self.viewModel.galleryData[indexPath.section] + let fastEndScrollingThen: ((@escaping () -> ()) -> ()) = { callback in + let endOffset: CGPoint + + if let currentTargetOffset: CGPoint = self.currentTargetOffset { + endOffset = currentTargetOffset + } + else { + let currentVelocity: CGPoint = collectionView.panGestureRecognizer.velocity(in: collectionView) + + endOffset = CGPoint( + x: collectionView.contentOffset.x, + y: collectionView.contentOffset.y - (currentVelocity.y / 100) + ) + } + + guard endOffset != collectionView.contentOffset else { + return callback() + } + + UIView.animate( + withDuration: 0.1, + delay: 0, + options: .curveEaseOut, + animations: { + collectionView.setContentOffset(endOffset, animated: false) + }, + completion: { _ in + callback() + } + ) + } + + switch section.model { + case .loadOlder: fastEndScrollingThen { self.viewModel.loadOlderGalleryItems() } + case .loadNewer: fastEndScrollingThen { self.viewModel.loadNewerGalleryItems() } + + case .emptyGallery, .galleryMonth: break + } + } + + // MARK: - UICollectionViewDelegate + + public func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { + let section: MediaGalleryViewModel.Section = self.viewModel.galleryData[indexPath.section].model + + switch section { + case .emptyGallery, .loadOlder, .loadNewer: return false + case .galleryMonth: return true + } + } + + public func collectionView(_ collectionView: UICollectionView, shouldDeselectItemAt indexPath: IndexPath) -> Bool { + let section: MediaGalleryViewModel.Section = self.viewModel.galleryData[indexPath.section].model + + switch section { + case .emptyGallery, .loadOlder, .loadNewer: return false + case .galleryMonth: return true + } + } + + public func collectionView(_ collectionView: UICollectionView, shouldHighlightItemAt indexPath: IndexPath) -> Bool { + let section: MediaGalleryViewModel.Section = self.viewModel.galleryData[indexPath.section].model + + switch section { + case .emptyGallery, .loadOlder, .loadNewer: return false + case .galleryMonth: return true + } + } + + public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + let section: MediaGalleryViewModel.SectionModel = self.viewModel.galleryData[indexPath.section] + + switch section.model { + case .emptyGallery, .loadOlder, .loadNewer: return + case .galleryMonth: break + } + + guard !isInBatchSelectMode else { updateDeleteButton() - } else { - collectionView.deselectItem(at: indexPath, animated: true) - self.delegate?.mediaTileViewController(self, didTapView: gridCell.imageView, mediaGalleryItem: galleryItem) + return } + + collectionView.deselectItem(at: indexPath, animated: true) + + let galleryItem: MediaGalleryViewModel.Item = section.elements[indexPath.row] + + // First check if this screen was presented + guard let presentingViewController: UIViewController = self.presentingViewController else { + // If we got to the gallery via conversation settings, present the detail view + // on top of the tile view + // + // == ViewController Schematic == + // + // [DetailView] <--, + // [TileView] -----' + // [ConversationSettingsView] + // [ConversationView] + // + guard + let viewControllers: [UIViewController] = self.navigationController?.viewControllers, + viewControllers.count > 1, + viewControllers[viewControllers.count - 2] is OWSConversationSettingsViewController + else { return } + + let detailViewController: UIViewController? = MediaGalleryViewModel.createDetailViewController( + for: self.viewModel.threadId, + threadVariant: self.viewModel.threadVariant, + interactionId: galleryItem.interactionId, + selectedAttachmentId: galleryItem.attachment.id, + options: [ .sliderEnabled ] + ) + + guard let detailViewController: UIViewController = detailViewController else { return } + + self.present(detailViewController, animated: true) + return + } + + // Check if we were presented via the 'MediaPageViewController' + guard let existingDetailPageView: MediaPageViewController = (presentingViewController as? UINavigationController)?.viewControllers.first as? MediaPageViewController else { + self.navigationController?.dismiss(animated: true) + return + } + + // If we got to the gallery via the conversation view, pop the tile view + // to return to the detail view + // + // == ViewController Schematic == + // + // [TileView] -----, + // [DetailView] <--' + // [ConversationView] + // + existingDetailPageView.setCurrentItem(galleryItem, direction: .forward, animated: false) + existingDetailPageView.willBePresentedAgain() + self.viewModel.updateFocusedItem(attachmentId: galleryItem.attachment.id, indexPath: indexPath) + self.navigationController?.dismiss(animated: true) } - public override func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) { - Logger.debug("") - + public func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) { if isInBatchSelectMode { updateDeleteButton() } } - private var isUserScrolling: Bool = false { - didSet { - autoLoadMoreIfNecessary() - } - } - - // MARK: UICollectionViewDataSource - - override public func numberOfSections(in collectionView: UICollectionView) -> Int { - guard galleryDates.count > 0 else { - // empty gallery - return 1 - } - - // One for each galleryDate plus a "loading older" and "loading newer" section - return galleryItems.keys.count + 2 - } - - override public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection sectionIdx: Int) -> Int { - - guard galleryDates.count > 0 else { - // empty gallery - return 0 - } - - if sectionIdx == kLoadOlderSectionIdx { - // load older - return 0 - } - - if sectionIdx == loadNewerSectionIdx { - // load more recent - return 0 - } - - guard let sectionDate = self.galleryDates[safe: sectionIdx - 1] else { - owsFailDebug("unknown section: \(sectionIdx)") - return 0 - } - - guard let section = self.galleryItems[sectionDate] else { - owsFailDebug("no section for date: \(sectionDate)") - return 0 - } - - return section.count - } - - override public func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { - - let defaultView = UICollectionReusableView() - - guard galleryDates.count > 0 else { - guard let sectionHeader = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: MediaGalleryStaticHeader.reuseIdentifier, for: indexPath) as? MediaGalleryStaticHeader else { - - owsFailDebug("unable to build section header for kLoadOlderSectionIdx") - return defaultView - } - let title = NSLocalizedString("GALLERY_TILES_EMPTY_GALLERY", comment: "Label indicating media gallery is empty") - sectionHeader.configure(title: title) - return sectionHeader - } - - if (kind == UICollectionView.elementKindSectionHeader) { - switch indexPath.section { - case kLoadOlderSectionIdx: - guard let sectionHeader = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: MediaGalleryStaticHeader.reuseIdentifier, for: indexPath) as? MediaGalleryStaticHeader else { - - owsFailDebug("unable to build section header for kLoadOlderSectionIdx") - return defaultView - } - let title = NSLocalizedString("GALLERY_TILES_LOADING_OLDER_LABEL", comment: "Label indicating loading is in progress") - sectionHeader.configure(title: title) - return sectionHeader - case loadNewerSectionIdx: - guard let sectionHeader = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: MediaGalleryStaticHeader.reuseIdentifier, for: indexPath) as? MediaGalleryStaticHeader else { - - owsFailDebug("unable to build section header for kLoadOlderSectionIdx") - return defaultView - } - let title = NSLocalizedString("GALLERY_TILES_LOADING_MORE_RECENT_LABEL", comment: "Label indicating loading is in progress") - sectionHeader.configure(title: title) - return sectionHeader - default: - guard let sectionHeader = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: MediaGallerySectionHeader.reuseIdentifier, for: indexPath) as? MediaGallerySectionHeader else { - owsFailDebug("unable to build section header for indexPath: \(indexPath)") - return defaultView - } - guard let date = self.galleryDates[safe: indexPath.section - 1] else { - owsFailDebug("unknown section for indexPath: \(indexPath)") - return defaultView - } - - sectionHeader.configure(title: date.localizedString) - return sectionHeader - } - } - - return defaultView - } - - override public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - Logger.debug("indexPath: \(indexPath)") - - let defaultCell = UICollectionViewCell() - - guard galleryDates.count > 0 else { - owsFailDebug("unexpected cell for loadNewerSectionIdx") - return defaultCell - } - - switch indexPath.section { - case kLoadOlderSectionIdx: - owsFailDebug("unexpected cell for kLoadOlderSectionIdx") - return defaultCell - case loadNewerSectionIdx: - owsFailDebug("unexpected cell for loadNewerSectionIdx") - return defaultCell - default: - guard let galleryItem = galleryItem(at: indexPath) else { - owsFailDebug("no message for path: \(indexPath)") - return defaultCell - } - - guard let cell = self.collectionView?.dequeueReusableCell(withReuseIdentifier: PhotoGridViewCell.reuseIdentifier, for: indexPath) as? PhotoGridViewCell else { - owsFailDebug("unexpected cell for indexPath: \(indexPath)") - return defaultCell - } - - let gridCellItem = GalleryGridCellItem(galleryItem: galleryItem) - cell.configure(item: gridCellItem) - - return cell - } - } - - func galleryItem(at indexPath: IndexPath) -> MediaGalleryItem? { - guard let sectionDate = self.galleryDates[safe: indexPath.section - 1] else { - owsFailDebug("unknown section: \(indexPath.section)") - return nil - } - - guard let sectionItems = self.galleryItems[sectionDate] else { - owsFailDebug("no section for date: \(sectionDate)") - return nil - } - - guard let galleryItem = sectionItems[safe: indexPath.row] else { - owsFailDebug("no message for row: \(indexPath.row)") - return nil - } - - return galleryItem - } - - // MARK: UICollectionViewDelegateFlowLayout - - static let kInterItemSpacing: CGFloat = 2 - private class func buildLayout() -> MediaTileViewLayout { - let layout = MediaTileViewLayout() - - if #available(iOS 11, *) { - layout.sectionInsetReference = .fromSafeArea - } - layout.minimumInteritemSpacing = kInterItemSpacing - layout.minimumLineSpacing = kInterItemSpacing - layout.sectionHeadersPinToVisibleBounds = true - - return layout - } - + // MARK: - UICollectionViewDelegateFlowLayout + func updateLayout() { - let containerWidth: CGFloat - if #available(iOS 11.0, *) { - containerWidth = self.view.safeAreaLayoutGuide.layoutFrame.size.width - } else { - containerWidth = self.view.frame.size.width - } - - let kItemsPerPortraitRow = 4 - let screenWidth = min(UIScreen.main.bounds.width, UIScreen.main.bounds.height) - let approxItemWidth = screenWidth / CGFloat(kItemsPerPortraitRow) - - let itemCount = round(containerWidth / approxItemWidth) - let spaceWidth = (itemCount + 1) * type(of: self).kInterItemSpacing - let availableWidth = containerWidth - spaceWidth - + let screenWidth: CGFloat = min(UIScreen.main.bounds.width, UIScreen.main.bounds.height) + let approxItemWidth: CGFloat = (screenWidth / MediaTileViewController.itemsPerPortraitRow) + let itemSectionInsets: UIEdgeInsets = self.collectionView( + collectionView, + layout: mediaTileViewLayout, + insetForSectionAt: 1 + ) + let widthInset: CGFloat = (itemSectionInsets.left + itemSectionInsets.right) + let containerWidth: CGFloat = (collectionView.frame.width > CGFloat.leastNonzeroMagnitude ? + collectionView.frame.width : + view.bounds.width + ) + let collectionViewWidth: CGFloat = (containerWidth - widthInset) + let itemCount: CGFloat = round(collectionViewWidth / approxItemWidth) + let spaceWidth: CGFloat = ((itemCount - 1) * MediaTileViewController.interItemSpacing) + let availableWidth: CGFloat = (collectionViewWidth - spaceWidth) + let itemWidth = floor(availableWidth / CGFloat(itemCount)) let newItemSize = CGSize(width: itemWidth, height: itemWidth) - if (newItemSize != mediaTileViewLayout.itemSize) { + if newItemSize != mediaTileViewLayout.itemSize { mediaTileViewLayout.itemSize = newItemSize mediaTileViewLayout.invalidateLayout() } } + + public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets { + return .zero + } - public func collectionView(_ collectionView: UICollectionView, - layout collectionViewLayout: UICollectionViewLayout, - referenceSizeForHeaderInSection section: Int) -> CGSize { - - let kMonthHeaderSize: CGSize = CGSize(width: 0, height: 50) - let kStaticHeaderSize: CGSize = CGSize(width: 0, height: 100) - - guard galleryDates.count > 0 else { - return kStaticHeaderSize - } - - guard let mediaGalleryDataSource = self.mediaGalleryDataSource else { - owsFailDebug("mediaGalleryDataSource was unexpectedly nil") - return CGSize.zero - } - - switch section { - case kLoadOlderSectionIdx: - // Show "loading older..." iff there is still older data to be fetched - return mediaGalleryDataSource.hasFetchedOldest ? CGSize.zero : kStaticHeaderSize - case loadNewerSectionIdx: - // Show "loading newer..." iff there is still more recent data to be fetched - return mediaGalleryDataSource.hasFetchedMostRecent ? CGSize.zero : kStaticHeaderSize - default: - return kMonthHeaderSize + public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize { + let section: MediaGalleryViewModel.SectionModel = self.viewModel.galleryData[section] + + switch section.model { + case .emptyGallery, .loadOlder, .loadNewer: + return CGSize(width: 0, height: MediaTileViewController.loadMoreHeaderHeight) + + case .galleryMonth: return CGSize(width: 0, height: 50) } } // MARK: Batch Selection - var isInBatchSelectMode = false { - didSet { - collectionView!.allowsMultipleSelection = isInBatchSelectMode - updateSelectButton() - updateDeleteButton() - } + func updateDeleteButton() { + self.deleteButton.isEnabled = ((collectionView.indexPathsForSelectedItems?.count ?? 0) > 0) } - func updateDeleteButton() { - guard let collectionView = self.collectionView else { - owsFailDebug("collectionView was unexpectedly nil") + func updateSelectButton(updatedData: [MediaGalleryViewModel.SectionModel], inBatchSelectMode: Bool) { + guard !updatedData.isEmpty else { + self.navigationItem.rightBarButtonItem = nil return } - - if let count = collectionView.indexPathsForSelectedItems?.count, count > 0 { - self.deleteButton.isEnabled = true - } else { - self.deleteButton.isEnabled = false + + if inBatchSelectMode { + self.navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .cancel, + target: self, + action: #selector(didCancelSelect) + ) + } + else { + self.navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "BUTTON_SELECT".localized(), + style: .plain, + target: self, + action: #selector(didTapSelect) + ) } } - func updateSelectButton() { - if isInBatchSelectMode { - self.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(didCancelSelect)) - } else { - self.navigationItem.rightBarButtonItem = UIBarButtonItem(title: NSLocalizedString("BUTTON_SELECT", comment: "Button text to enable batch selection mode"), - style: .plain, - target: self, - action: #selector(didTapSelect)) - } - } - - @objc - func didTapSelect(_ sender: Any) { + @objc func didTapSelect(_ sender: Any) { isInBatchSelectMode = true - guard let collectionView = self.collectionView else { - owsFailDebug("collectionView was unexpectedly nil") - return - } - // show toolbar - UIView.animate(withDuration: 0.1, delay: 0, options: .curveEaseInOut, animations: { - NSLayoutConstraint.deactivate([self.footerBarBottomConstraint]) - self.footerBarBottomConstraint = self.footerBar.autoPinEdge(toSuperviewSafeArea: .bottom) + UIView.animate(withDuration: 0.1, delay: 0, options: .curveEaseInOut, animations: { [weak self] in + self?.footerBarBottomConstraint?.isActive = false + self?.footerBarBottomConstraint = self?.footerBar.autoPinEdge(toSuperviewSafeArea: .bottom) + self?.footerBar.superview?.layoutIfNeeded() - self.footerBar.superview?.layoutIfNeeded() - - // ensure toolbar doesn't cover bottom row. - collectionView.contentInset.bottom += self.kFooterBarHeight + // Ensure toolbar doesn't cover bottom row. + self?.collectionView.contentInset.bottom += MediaTileViewController.footerBarHeight }, completion: nil) // disabled until at least one item is selected @@ -536,68 +636,79 @@ public class MediaTileViewController: UICollectionViewController, MediaGalleryDa self.navigationItem.hidesBackButton = true } - @objc - func didCancelSelect(_ sender: Any) { + @objc func didCancelSelect(_ sender: Any) { endSelectMode() } func endSelectMode() { isInBatchSelectMode = false - guard let collectionView = self.collectionView else { - owsFailDebug("collectionView was unexpectedly nil") - return - } - // hide toolbar - UIView.animate(withDuration: 0.1, delay: 0, options: .curveEaseInOut, animations: { - NSLayoutConstraint.deactivate([self.footerBarBottomConstraint]) - self.footerBarBottomConstraint = self.footerBar.autoPinEdge(toSuperviewEdge: .bottom, withInset: -self.kFooterBarHeight) - self.footerBar.superview?.layoutIfNeeded() + UIView.animate(withDuration: 0.1, delay: 0, options: .curveEaseInOut, animations: { [weak self] in + self?.footerBarBottomConstraint?.isActive = false + self?.footerBarBottomConstraint = self?.footerBar.autoPinEdge(toSuperviewEdge: .bottom, withInset: -MediaTileViewController.footerBarHeight) + self?.footerBar.superview?.layoutIfNeeded() - // undo "ensure toolbar doesn't cover bottom row." - collectionView.contentInset.bottom -= self.kFooterBarHeight + // Undo "Ensure toolbar doesn't cover bottom row." + self?.collectionView.contentInset.bottom -= MediaTileViewController.footerBarHeight }, completion: nil) self.navigationItem.hidesBackButton = false - // deselect any selected + // Deselect any selected collectionView.indexPathsForSelectedItems?.forEach { collectionView.deselectItem(at: $0, animated: false)} } - @objc - func didPressDelete(_ sender: Any) { - Logger.debug("") - - guard let collectionView = self.collectionView else { - owsFailDebug("collectionView was unexpectedly nil") - return - } - + @objc func didPressDelete(_ sender: Any) { guard let indexPaths = collectionView.indexPathsForSelectedItems else { owsFailDebug("indexPaths was unexpectedly nil") return } - let items: [MediaGalleryItem] = indexPaths.compactMap { return self.galleryItem(at: $0) } - - guard let mediaGalleryDataSource = self.mediaGalleryDataSource else { - owsFailDebug("mediaGalleryDataSource was unexpectedly nil") - return + let items: [MediaGalleryViewModel.Item] = indexPaths.map { + self.viewModel.galleryData[$0.section].elements[$0.item] } - let confirmationTitle: String = { if indexPaths.count == 1 { - return NSLocalizedString("MEDIA_GALLERY_DELETE_SINGLE_MESSAGE", comment: "Confirmation button text to delete selected media message from the gallery") - } else { - let format = NSLocalizedString("MEDIA_GALLERY_DELETE_MULTIPLE_MESSAGES_FORMAT", comment: "Confirmation button text to delete selected media from the gallery, embeds {{number of messages}}") - return String(format: format, indexPaths.count) + return "MEDIA_GALLERY_DELETE_SINGLE_MESSAGE".localized() } + + return String( + format: "MEDIA_GALLERY_DELETE_MULTIPLE_MESSAGES_FORMAT".localized(), + indexPaths.count + ) }() - let deleteAction = UIAlertAction(title: confirmationTitle, style: .destructive) { _ in - mediaGalleryDataSource.delete(items: items, initiatedBy: self) - self.endSelectMode() + let deleteAction = UIAlertAction(title: confirmationTitle, style: .destructive) { [weak self] _ in + GRDBStorage.shared.writeAsync { db in + let interactionIds: Set = items + .map { $0.interactionId } + .asSet() + + _ = try Attachment + .filter(ids: items.map { $0.attachment.id }) + .deleteAll(db) + + // Add the garbage collection job to delete orphaned attachment files + JobRunner.add( + db, + job: Job( + variant: .garbageCollection, + behaviour: .runOnce, + details: GarbageCollectionJob.Details( + typesToCollect: [.orphanedAttachmentFiles] + ) + ) + ) + + // Delete any interactions which had all of their attachments removed + _ = try Interaction + .filter(ids: interactionIds) + .having(Interaction.interactionAttachments.isEmpty) + .deleteAll(db) + } + + self?.endSelectMode() } let actionSheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) @@ -606,168 +717,6 @@ public class MediaTileViewController: UICollectionViewController, MediaGalleryDa presentAlert(actionSheet) } - - var footerBarBottomConstraint: NSLayoutConstraint! - let kFooterBarHeight: CGFloat = 40 - - // MARK: MediaGalleryDataSourceDelegate - - func mediaGalleryDataSource(_ mediaGalleryDataSource: MediaGalleryDataSource, willDelete items: [MediaGalleryItem], initiatedBy: AnyObject) { - Logger.debug("") - - guard let collectionView = self.collectionView else { - owsFailDebug("collectionView was unexpectedly nil") - return - } - - // We've got to lay out the collectionView before any changes are made to the date source - // otherwise we'll fail when we try to remove the deleted sections/rows - collectionView.layoutIfNeeded() - } - - func mediaGalleryDataSource(_ mediaGalleryDataSource: MediaGalleryDataSource, deletedSections: IndexSet, deletedItems: [IndexPath]) { - Logger.debug("with deletedSections: \(deletedSections) deletedItems: \(deletedItems)") - - guard let collectionView = self.collectionView else { - owsFailDebug("collectionView was unexpectedly nil") - return - } - - guard mediaGalleryDataSource.galleryItemCount > 0 else { - // Show Empty - self.collectionView?.reloadData() - return - } - - collectionView.performBatchUpdates({ - collectionView.deleteSections(deletedSections) - collectionView.deleteItems(at: deletedItems) - }) - } - - // MARK: Lazy Loading - - // This should be substantially larger than one screen size so we don't have to call it - // multiple times in a rapid succession, but not so large that loading get's really chopping - let kMediaTileViewLoadBatchSize: UInt = 40 - var oldestLoadedItem: MediaGalleryItem? { - guard let oldestDate = galleryDates.first else { - return nil - } - - return galleryItems[oldestDate]?.first - } - - var mostRecentLoadedItem: MediaGalleryItem? { - guard let mostRecentDate = galleryDates.last else { - return nil - } - - return galleryItems[mostRecentDate]?.last - } - - var isFetchingMoreData: Bool = false - - let kLoadOlderSectionIdx = 0 - var loadNewerSectionIdx: Int { - return galleryDates.count + 1 - } - - public func autoLoadMoreIfNecessary() { - let kEdgeThreshold: CGFloat = 800 - - if (self.isUserScrolling) { - return - } - - guard let collectionView = self.collectionView else { - owsFailDebug("collectionView was unexpectedly nil") - return - } - - guard let mediaGalleryDataSource = self.mediaGalleryDataSource else { - owsFailDebug("mediaGalleryDataSource was unexpectedly nil") - return - } - - let contentOffsetY = collectionView.contentOffset.y - let oldContentHeight = collectionView.contentSize.height - - if contentOffsetY < kEdgeThreshold { - // Near the top, load older content - - guard let oldestLoadedItem = self.oldestLoadedItem else { - Logger.debug("no oldest item") - return - } - - guard !mediaGalleryDataSource.hasFetchedOldest else { - return - } - - guard !isFetchingMoreData else { - Logger.debug("already fetching more data") - return - } - isFetchingMoreData = true - - CATransaction.begin() - CATransaction.setDisableActions(true) - - // mediaTileViewLayout will adjust content offset to compensate for the change in content height so that - // the same content is visible after the update. I considered doing something like setContentOffset in the - // batchUpdate completion block, but it caused a distinct flicker, which I was able to avoid with the - // `CollectionViewLayout.prepare` based approach. - mediaTileViewLayout.isInsertingCellsToTop = true - mediaTileViewLayout.contentSizeBeforeInsertingToTop = collectionView.contentSize - collectionView.performBatchUpdates({ - mediaGalleryDataSource.ensureGalleryItemsLoaded(.before, item: oldestLoadedItem, amount: self.kMediaTileViewLoadBatchSize) { addedSections, addedItems in - Logger.debug("insertingSections: \(addedSections) items: \(addedItems)") - - collectionView.insertSections(addedSections) - collectionView.insertItems(at: addedItems) - } - }, completion: { finished in - Logger.debug("performBatchUpdates finished: \(finished)") - self.isFetchingMoreData = false - CATransaction.commit() - }) - - } else if oldContentHeight - contentOffsetY < kEdgeThreshold { - // Near the bottom, load newer content - - guard let mostRecentLoadedItem = self.mostRecentLoadedItem else { - Logger.debug("no mostRecent item") - return - } - - guard !mediaGalleryDataSource.hasFetchedMostRecent else { - return - } - - guard !isFetchingMoreData else { - Logger.debug("already fetching more data") - return - } - isFetchingMoreData = true - - CATransaction.begin() - CATransaction.setDisableActions(true) - UIView.performWithoutAnimation { - collectionView.performBatchUpdates({ - mediaGalleryDataSource.ensureGalleryItemsLoaded(.after, item: mostRecentLoadedItem, amount: self.kMediaTileViewLoadBatchSize) { addedSections, addedItems in - Logger.debug("insertingSections: \(addedSections), items: \(addedItems)") - collectionView.insertSections(addedSections) - collectionView.insertItems(at: addedItems) - } - }, completion: { finished in - Logger.debug("performBatchUpdates finished: \(finished)") - self.isFetchingMoreData = false - CATransaction.commit() - }) - } - } - } } // MARK: - Private Helper Classes @@ -776,7 +725,6 @@ public class MediaTileViewController: UICollectionViewController, MediaGalleryDa // into the top of a collectionView. There are multiple ways to solve this problem, but this // is the only one which avoided a perceptible flicker. private class MediaTileViewLayout: UICollectionViewFlowLayout { - fileprivate var isInsertingCellsToTop: Bool = false fileprivate var contentSizeBeforeInsertingToTop: CGSize? @@ -789,9 +737,10 @@ private class MediaTileViewLayout: UICollectionViewFlowLayout { let contentOffsetY = collectionView.contentOffset.y + (newContentSize.height - oldContentSize.height) let newOffset = CGPoint(x: collectionView.contentOffset.x, y: contentOffsetY) collectionView.setContentOffset(newOffset, animated: false) + + // Update the content size in case there is a subsequent update + contentSizeBeforeInsertingToTop = newContentSize } - contentSizeBeforeInsertingToTop = nil - isInsertingCellsToTop = false } } } @@ -894,23 +843,90 @@ private class MediaGalleryStaticHeader: UICollectionViewCell { } class GalleryGridCellItem: PhotoGridItem { - let galleryItem: MediaGalleryItem + let galleryItem: MediaGalleryViewModel.Item - init(galleryItem: MediaGalleryItem) { + init(galleryItem: MediaGalleryViewModel.Item) { self.galleryItem = galleryItem } var type: PhotoGridItemType { if galleryItem.isVideo { return .video - } else if galleryItem.isAnimated { - return .animated - } else { - return .photo } + + if galleryItem.isAnimated { + return .animated + } + + return .photo } - func asyncThumbnail(completion: @escaping (UIImage?) -> Void) -> UIImage? { - return galleryItem.thumbnailImage(async: completion) + func asyncThumbnail(completion: @escaping (UIImage?) -> Void) { + galleryItem.thumbnailImage(async: completion) + } +} + +// MARK: - UIViewControllerTransitioningDelegate + +extension MediaTileViewController: UIViewControllerTransitioningDelegate { + public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? { + guard self == presented || self.navigationController == presented else { return nil } + guard let focusedIndexPath: IndexPath = self.viewModel.focusedIndexPath else { return nil } + + return MediaDismissAnimationController( + galleryItem: self.viewModel.galleryData[focusedIndexPath.section].elements[focusedIndexPath.item] + ) + } + + public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { + guard self == dismissed || self.navigationController == dismissed else { return nil } + guard let focusedIndexPath: IndexPath = self.viewModel.focusedIndexPath else { return nil } + + return MediaZoomAnimationController( + galleryItem: self.viewModel.galleryData[focusedIndexPath.section].elements[focusedIndexPath.item], + shouldBounce: false + ) + } +} + +// MARK: - MediaPresentationContextProvider + +extension MediaTileViewController: MediaPresentationContextProvider { + func mediaPresentationContext(mediaItem: Media, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? { + guard case let .gallery(galleryItem) = mediaItem else { return nil } + + // Note: According to Apple's docs the 'indexPathsForVisibleRows' method returns an + // unsorted array which means we can't use it to determine the desired 'visibleCell' + // we are after, due to this we will need to iterate all of the visible cells to find + // the one we want + let maybeGridCell: PhotoGridViewCell? = collectionView.visibleCells + .first { cell -> Bool in + guard + let cell: PhotoGridViewCell = cell as? PhotoGridViewCell, + let item: GalleryGridCellItem = cell.item as? GalleryGridCellItem, + item.galleryItem.attachment.id == galleryItem.attachment.id + else { return false } + + return true + } + .map { $0 as? PhotoGridViewCell } + + guard + let gridCell: PhotoGridViewCell = maybeGridCell, + let mediaSuperview: UIView = gridCell.imageView.superview + else { return nil } + + let presentationFrame: CGRect = coordinateSpace.convert(gridCell.imageView.frame, from: mediaSuperview) + + return MediaPresentationContext( + mediaView: gridCell.imageView, + presentationFrame: presentationFrame, + cornerRadius: 0, + cornerMask: CACornerMask() + ) + } + + func snapshotOverlayView(in coordinateSpace: UICoordinateSpace) -> (UIView, CGRect)? { + return self.navigationController?.navigationBar.generateSnapshot(in: coordinateSpace) } } diff --git a/Session/Media Viewing & Editing/SendMediaNavigationController.swift b/Session/Media Viewing & Editing/SendMediaNavigationController.swift index 798aeb712..e0211fab3 100644 --- a/Session/Media Viewing & Editing/SendMediaNavigationController.swift +++ b/Session/Media Viewing & Editing/SendMediaNavigationController.swift @@ -10,7 +10,21 @@ class SendMediaNavigationController: OWSNavigationController { // This is a sensitive constant, if you change it make sure to check // on iPhone5, 6, 6+, X, layouts. static let bottomButtonsCenterOffset: CGFloat = -50 - + + private let threadId: String + + // MARK: - Initialization + + init(threadId: String) { + self.threadId = threadId + + super.init() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + // MARK: - Overrides override var prefersStatusBarHidden: Bool { return true } @@ -48,17 +62,17 @@ class SendMediaNavigationController: OWSNavigationController { public weak var sendMediaNavDelegate: SendMediaNavDelegate? @objc - public class func showingCameraFirst() -> SendMediaNavigationController { - let navController = SendMediaNavigationController() - navController.setViewControllers([navController.captureViewController], animated: false) + public class func showingCameraFirst(threadId: String) -> SendMediaNavigationController { + let navController = SendMediaNavigationController(threadId: threadId) + navController.viewControllers = [navController.captureViewController] return navController } @objc - public class func showingMediaLibraryFirst() -> SendMediaNavigationController { - let navController = SendMediaNavigationController() - navController.setViewControllers([navController.mediaLibraryViewController], animated: false) + public class func showingMediaLibraryFirst(threadId: String) -> SendMediaNavigationController { + let navController = SendMediaNavigationController(threadId: threadId) + navController.viewControllers = [navController.mediaLibraryViewController] return navController } @@ -218,7 +232,11 @@ class SendMediaNavigationController: OWSNavigationController { return } - let approvalViewController = AttachmentApprovalViewController(mode: .sharedNavigation, attachments: self.attachments) + let approvalViewController = AttachmentApprovalViewController( + mode: .sharedNavigation, + threadId: self.threadId, + attachments: self.attachments + ) approvalViewController.approvalDelegate = self approvalViewController.messageText = sendMediaNavDelegate.sendMediaNavInitialMessageText(self) @@ -429,8 +447,8 @@ extension SendMediaNavigationController: AttachmentApprovalViewControllerDelegat attachmentDraftCollection.remove(attachment: attachment) } - func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], messageText: String?) { - sendMediaNavDelegate?.sendMediaNav(self, didApproveAttachments: attachments, messageText: messageText) + func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?) { + sendMediaNavDelegate?.sendMediaNav(self, didApproveAttachments: attachments, forThreadId: threadId, messageText: messageText) } func attachmentApprovalDidCancel(_ attachmentApproval: AttachmentApprovalViewController) { @@ -673,7 +691,7 @@ private class DoneButton: UIView { protocol SendMediaNavDelegate: AnyObject { func sendMediaNavDidCancel(_ sendMediaNavigationController: SendMediaNavigationController) - func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [SignalAttachment], messageText: String?) + func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?) func sendMediaNavInitialMessageText(_ sendMediaNavigationController: SendMediaNavigationController) -> String? func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didChangeMessageText newMessageText: String?) diff --git a/Session/Media Viewing & Editing/Transitions/MediaDismissAnimationController.swift b/Session/Media Viewing & Editing/Transitions/MediaDismissAnimationController.swift index c4416648e..8a3c8a8db 100644 --- a/Session/Media Viewing & Editing/Transitions/MediaDismissAnimationController.swift +++ b/Session/Media Viewing & Editing/Transitions/MediaDismissAnimationController.swift @@ -63,6 +63,7 @@ extension MediaDismissAnimationController: UIViewControllerAnimatedTransitioning switch toVC { case let contextProvider as MediaPresentationContextProvider: + toVC.view.layoutIfNeeded() toContextProvider = contextProvider case let navController as UINavigationController: @@ -71,6 +72,7 @@ extension MediaDismissAnimationController: UIViewControllerAnimatedTransitioning return } + toVC.view.layoutIfNeeded() toContextProvider = contextProvider default: @@ -104,8 +106,8 @@ extension MediaDismissAnimationController: UIViewControllerAnimatedTransitioning let toMediaContext: MediaPresentationContext? = toContextProvider.mediaPresentationContext(mediaItem: mediaItem, in: containerView) let duration: CGFloat = transitionDuration(using: transitionContext) - fromMediaContext.mediaView.alpha = 0.0 - toMediaContext?.mediaView.alpha = 0.0 + fromMediaContext.mediaView.alpha = 0 + toMediaContext?.mediaView.alpha = 0 let transitionView = UIImageView(image: presentationImage) transitionView.frame = fromMediaContext.presentationFrame @@ -197,12 +199,20 @@ extension MediaDismissAnimationController: UIViewControllerAnimatedTransitioning self?.toTransitionalOverlayView?.removeFromSuperview() if transitionContext.transitionWasCancelled { - // the "to" view will be nil if we're doing a modal dismiss, in which case + // The "to" view will be nil if we're doing a modal dismiss, in which case // we wouldn't want to remove the toView. transitionContext.view(forKey: .to)?.removeFromSuperview() + + // Note: We shouldn't need to do this but for some reason it's not + // automatically getting re-enabled so we manually enable it + transitionContext.view(forKey: .from)?.isUserInteractionEnabled = true } else { transitionContext.view(forKey: .from)?.removeFromSuperview() + + // Note: We shouldn't need to do this but for some reason it's not + // automatically getting re-enabled so we manually enable it + transitionContext.view(forKey: .to)?.isUserInteractionEnabled = true } transitionContext.completeTransition(!transitionContext.transitionWasCancelled) diff --git a/Session/Media Viewing & Editing/Transitions/MediaInteractiveDismiss.swift b/Session/Media Viewing & Editing/Transitions/MediaInteractiveDismiss.swift index 3445a3b2f..bd213e0b9 100644 --- a/Session/Media Viewing & Editing/Transitions/MediaInteractiveDismiss.swift +++ b/Session/Media Viewing & Editing/Transitions/MediaInteractiveDismiss.swift @@ -42,6 +42,8 @@ class MediaInteractiveDismiss: UIPercentDrivenInteractiveTransition { private var fastEnoughToCompleteTransition = false private var farEnoughToCompleteTransition = false + private var lastProgress: CGFloat = 0 + private var lastIncreasedProgress: CGFloat = 0 private var shouldCompleteTransition: Bool { if farEnoughToCompleteTransition { return true } @@ -73,9 +75,21 @@ class MediaInteractiveDismiss: UIPercentDrivenInteractiveTransition { let offset = gestureRecognizer.translation(in: coordinateSpace) let progress = abs(offset.y) / totalDistance + // `farEnoughToCompleteTransition` is cancelable if the user reverses direction - farEnoughToCompleteTransition = progress >= 0.5 + farEnoughToCompleteTransition = (progress >= 0.5) + + // If the user has reverted enough progress then we want to reset the velocity + // flag (don't want the user to start quickly, slowly drag it back end end up + // dismissing the screen) + if (lastIncreasedProgress - progress) > 0.2 || progress < 0.05 { + fastEnoughToCompleteTransition = false + } + update(progress) + + lastIncreasedProgress = (progress > lastProgress ? progress : lastIncreasedProgress) + lastProgress = progress interactiveDismissDelegate?.interactiveDismissUpdate(self, didChangeTouchOffset: offset) @@ -86,6 +100,8 @@ class MediaInteractiveDismiss: UIPercentDrivenInteractiveTransition { interactionInProgress = false farEnoughToCompleteTransition = false fastEnoughToCompleteTransition = false + lastIncreasedProgress = 0 + lastProgress = 0 case .ended: if shouldCompleteTransition { @@ -100,6 +116,8 @@ class MediaInteractiveDismiss: UIPercentDrivenInteractiveTransition { interactionInProgress = false farEnoughToCompleteTransition = false fastEnoughToCompleteTransition = false + lastIncreasedProgress = 0 + lastProgress = 0 default: break diff --git a/Session/Media Viewing & Editing/Transitions/MediaPresentationContext.swift b/Session/Media Viewing & Editing/Transitions/MediaPresentationContext.swift index e37ea56c2..93ca9b5f9 100644 --- a/Session/Media Viewing & Editing/Transitions/MediaPresentationContext.swift +++ b/Session/Media Viewing & Editing/Transitions/MediaPresentationContext.swift @@ -9,6 +9,9 @@ enum Media { var image: UIImage? { switch self { case let .gallery(item): + // For videos attempt to load a large thumbnail, for other items just try to load + // the source file directly + guard !item.isVideo else { return item.attachment.existingThumbnail(size: .large) } guard let originalFilePath: String = item.attachment.originalFilePath else { return nil } return UIImage(contentsOfFile: originalFilePath) diff --git a/Session/Media Viewing & Editing/Transitions/MediaZoomAnimationController.swift b/Session/Media Viewing & Editing/Transitions/MediaZoomAnimationController.swift index 4f99d1842..83efc9a24 100644 --- a/Session/Media Viewing & Editing/Transitions/MediaZoomAnimationController.swift +++ b/Session/Media Viewing & Editing/Transitions/MediaZoomAnimationController.swift @@ -4,13 +4,11 @@ import UIKit class MediaZoomAnimationController: NSObject { private let mediaItem: Media + private let shouldBounce: Bool - init(image: UIImage) { - mediaItem = .image(image) - } - - init(galleryItem: MediaGalleryViewModel.Item) { - mediaItem = .gallery(galleryItem) + init(galleryItem: MediaGalleryViewModel.Item, shouldBounce: Bool = true) { + self.mediaItem = .gallery(galleryItem) + self.shouldBounce = shouldBounce } } @@ -75,50 +73,70 @@ extension MediaZoomAnimationController: UIViewControllerAnimatedTransitioning { // as the 'toContextProvider.mediaPresentationContext' is dependant on it having the correct // positioning (and the navBar sizing isn't correct until after layout) let toView: UIView = (transitionContext.view(forKey: .to) ?? toVC.view) + let duration: CGFloat = transitionDuration(using: transitionContext) let oldToViewSuperview: UIView? = toView.superview toView.layoutIfNeeded() + + // If we can't retrieve the contextual info we need to perform the proper zoom animation then + // just fade the destination in (otherwise the user would get stuck on a blank screen) + guard + let fromMediaContext: MediaPresentationContext = fromContextProvider.mediaPresentationContext(mediaItem: mediaItem, in: containerView), + let toMediaContext: MediaPresentationContext = toContextProvider.mediaPresentationContext(mediaItem: mediaItem, in: containerView), + let presentationImage: UIImage = mediaItem.image + else { + + toView.frame = containerView.bounds + toView.alpha = 0 + containerView.addSubview(toView) + + UIView.animate( + withDuration: (duration / 2), + delay: 0, + options: .curveEaseInOut, + animations: { + toView.alpha = 1 + }, + completion: { _ in + // Need to ensure we add the 'toView' back to it's old superview if it had one + oldToViewSuperview?.addSubview(toView) - guard let fromMediaContext: MediaPresentationContext = fromContextProvider.mediaPresentationContext(mediaItem: mediaItem, in: containerView) else { - transitionContext.completeTransition(false) + transitionContext.completeTransition(!transitionContext.transitionWasCancelled) + } + ) return } - guard let toMediaContext: MediaPresentationContext = toContextProvider.mediaPresentationContext(mediaItem: mediaItem, in: containerView) else { - transitionContext.completeTransition(false) - return - } - - guard let presentationImage: UIImage = mediaItem.image else { - transitionContext.completeTransition(true) - return - } - - let duration: CGFloat = transitionDuration(using: transitionContext) - fromMediaContext.mediaView.alpha = 0 toMediaContext.mediaView.alpha = 0 - let fromSnapshotView: UIView = (fromVC.view.snapshotView(afterScreenUpdates: false) ?? UIView()) - containerView.addSubview(fromSnapshotView) - toView.frame = containerView.bounds toView.alpha = 0 containerView.addSubview(toView) - - let transitionView = UIImageView(image: presentationImage) + + let transitionView: UIImageView = UIImageView(image: presentationImage) transitionView.frame = fromMediaContext.presentationFrame transitionView.contentMode = MediaView.contentMode transitionView.layer.masksToBounds = true transitionView.layer.cornerRadius = fromMediaContext.cornerRadius transitionView.layer.maskedCorners = fromMediaContext.cornerMask containerView.addSubview(transitionView) + + // Note: We need to do this after adding the 'transitionView' and insert it at the back + // otherwise the screen can flicker since we have 'afterScreenUpdates: true' (if we use + // 'afterScreenUpdates: false' then the 'fromMediaContext.mediaView' won't be hidden + // during the transition) + let fromSnapshotView: UIView = (fromVC.view.snapshotView(afterScreenUpdates: true) ?? UIView()) + containerView.insertSubview(fromSnapshotView, at: 0) let overshootPercentage: CGFloat = 0.15 - let overshootFrame: CGRect = CGRect( - x: (toMediaContext.presentationFrame.minX + ((toMediaContext.presentationFrame.minX - fromMediaContext.presentationFrame.minX) * overshootPercentage)), - y: (toMediaContext.presentationFrame.minY + ((toMediaContext.presentationFrame.minY - fromMediaContext.presentationFrame.minY) * overshootPercentage)), - width: (toMediaContext.presentationFrame.width + ((toMediaContext.presentationFrame.width - fromMediaContext.presentationFrame.width) * overshootPercentage)), - height: (toMediaContext.presentationFrame.height + ((toMediaContext.presentationFrame.height - fromMediaContext.presentationFrame.height) * overshootPercentage)) + let overshootFrame: CGRect = (self.shouldBounce ? + CGRect( + x: (toMediaContext.presentationFrame.minX + ((toMediaContext.presentationFrame.minX - fromMediaContext.presentationFrame.minX) * overshootPercentage)), + y: (toMediaContext.presentationFrame.minY + ((toMediaContext.presentationFrame.minY - fromMediaContext.presentationFrame.minY) * overshootPercentage)), + width: (toMediaContext.presentationFrame.width + ((toMediaContext.presentationFrame.width - fromMediaContext.presentationFrame.width) * overshootPercentage)), + height: (toMediaContext.presentationFrame.height + ((toMediaContext.presentationFrame.height - fromMediaContext.presentationFrame.height) * overshootPercentage)) + ) : + toMediaContext.presentationFrame ) // Add any UI elements which should appear above the media view diff --git a/Session/Meta/MainAppContext.m b/Session/Meta/MainAppContext.m index d1b703476..1a1cd6039 100644 --- a/Session/Meta/MainAppContext.m +++ b/Session/Meta/MainAppContext.m @@ -6,7 +6,6 @@ #import "Session-Swift.h" #import #import -#import #import NS_ASSUME_NONNULL_BEGIN diff --git a/Session/Meta/Signal-Bridging-Header.h b/Session/Meta/Signal-Bridging-Header.h index a9cefec5e..d77037259 100644 --- a/Session/Meta/Signal-Bridging-Header.h +++ b/Session/Meta/Signal-Bridging-Header.h @@ -9,12 +9,9 @@ // Separate iOS Frameworks from other imports. #import "AvatarViewHelper.h" #import "AVAudioSession+OWS.h" -#import "ContactCellView.h" -#import "ContactTableViewCell.h" #import "ConversationViewItem.h" #import "ConversationViewModel.h" #import "DateUtil.h" -#import "MediaDetailViewController.h" #import "NotificationSettingsViewController.h" #import "OWSAnyTouchGestureRecognizer.h" #import "OWSAudioPlayer.h" @@ -39,12 +36,10 @@ #import #import #import -#import #import #import #import #import -#import #import #import #import @@ -62,10 +57,7 @@ #import #import #import -#import #import -#import -#import #import #import #import diff --git a/Session/Notifications/AppNotifications.swift b/Session/Notifications/AppNotifications.swift index 7bf09b3d7..ad67e2247 100644 --- a/Session/Notifications/AppNotifications.swift +++ b/Session/Notifications/AppNotifications.swift @@ -195,7 +195,7 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { let notificationTitle: String? var notificationBody: String? - let senderName = Profile.displayName(db, id: interaction.authorId, thread: thread) + let senderName = Profile.displayName(db, id: interaction.authorId, threadVariant: thread.variant) let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType] .defaulting(to: .nameAndPreview) @@ -347,13 +347,8 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { private func checkIfShouldPlaySound() -> Bool { AssertIsOnMainThread() - guard UIApplication.shared.applicationState == .active else { - return true - } - - guard preferences.soundInForeground() else { - return false - } + guard UIApplication.shared.applicationState == .active else { return true } + guard GRDBStorage.shared[.playNotificationSoundInForeground] else { return false } let nowMs: UInt64 = UInt64(floor(Date().timeIntervalSince1970 * 1000)) let recentThreshold = nowMs - UInt64(kAudioNotificationsThrottleInterval * Double(kSecondInMs)) diff --git a/Session/Settings/NotificationSettingsOptionsViewController.m b/Session/Settings/NotificationSettingsOptionsViewController.m index 833ac0a14..e772ec061 100644 --- a/Session/Settings/NotificationSettingsOptionsViewController.m +++ b/Session/Settings/NotificationSettingsOptionsViewController.m @@ -4,7 +4,6 @@ #import "NotificationSettingsOptionsViewController.h" #import "Session-Swift.h" -#import #import @implementation NotificationSettingsOptionsViewController diff --git a/Session/Settings/NotificationSettingsViewController.m b/Session/Settings/NotificationSettingsViewController.m index af21657d4..17825bef4 100644 --- a/Session/Settings/NotificationSettingsViewController.m +++ b/Session/Settings/NotificationSettingsViewController.m @@ -7,8 +7,6 @@ #import "NotificationSettingsViewController.h" #import "NotificationSettingsOptionsViewController.h" #import "OWSSoundSettingsViewController.h" -#import -#import #import #import #import "Session-Swift.h" @@ -40,8 +38,6 @@ __weak NotificationSettingsViewController *weakSelf = self; - OWSPreferences *prefs = Environment.shared.preferences; - OWSTableSection *strategySection = [OWSTableSection new]; strategySection.headerTitle = NSLocalizedString(@"preferences_notifications_strategy_category_title", @""); [strategySection addItem:[OWSTableItem switchItemWithText:NSLocalizedString(@"vc_notification_settings_notification_mode_title", @"") @@ -79,7 +75,7 @@ [soundsSection addItem:[OWSTableItem switchItemWithText:inAppSoundsLabelText accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"in_app_sounds") isOnBlock:^{ - return [prefs soundInForeground]; + return [SMKPreferences playNotificationSoundInForeground]; } isEnabledBlock:^{ return YES; @@ -111,7 +107,7 @@ - (void)didToggleSoundNotificationsSwitch:(UISwitch *)sender { - [Environment.shared.preferences setSoundInForeground:sender.on]; + [SMKPreferences setPlayNotificationSoundInForeground:sender.on]; } - (void)didToggleAPNsSwitch:(UISwitch *)sender diff --git a/Session/Settings/PrivacySettingsTableViewController.m b/Session/Settings/PrivacySettingsTableViewController.m index a6e99f674..fe7ea6cf2 100644 --- a/Session/Settings/PrivacySettingsTableViewController.m +++ b/Session/Settings/PrivacySettingsTableViewController.m @@ -72,7 +72,7 @@ static NSString *const kSealedSenderInfoURL = @"https://signal.org/blog/sealed-s @"Label for the 'read receipts' setting.") accessibilityIdentifier:[NSString stringWithFormat:@"settings.privacy.%@", @"read_receipts"] isOnBlock:^{ - return [SSKPreferences areReadReceiptsEnabled]; + return [SMKPreferences areReadReceiptsEnabled]; } isEnabledBlock:^{ return YES; @@ -90,7 +90,7 @@ static NSString *const kSealedSenderInfoURL = @"https://signal.org/blog/sealed-s @"Label for the 'typing indicators' setting.") accessibilityIdentifier:[NSString stringWithFormat:@"settings.privacy.%@", @"typing_indicators"] isOnBlock:^{ - return [SSKPreferences areTypingIndicatorsEnabled]; + return [SMKPreferences areTypingIndicatorsEnabled]; } isEnabledBlock:^{ return YES; @@ -143,7 +143,7 @@ static NSString *const kSealedSenderInfoURL = @"https://signal.org/blog/sealed-s addItem:[OWSTableItem switchItemWithText:NSLocalizedString(@"Disable Preview in App Switcher", @"") accessibilityIdentifier:[NSString stringWithFormat:@"settings.privacy.%@", @"screen_security"] isOnBlock:^{ - return [Environment.shared.preferences screenSecurityIsEnabled]; + return [SMKPreferences isScreenSecurityEnabled]; } isEnabledBlock:^{ return YES; @@ -168,7 +168,7 @@ static NSString *const kSealedSenderInfoURL = @"https://signal.org/blog/sealed-s @"Setting for enabling & disabling link previews.") accessibilityIdentifier:[NSString stringWithFormat:@"settings.privacy.%@", @"link_previews"] isOnBlock:^{ - return [SSKPreferences areLinkPreviewsEnabled]; + return [SMKPreferences areLinkPreviewsEnabled]; } isEnabledBlock:^{ return YES; @@ -219,28 +219,28 @@ static NSString *const kSealedSenderInfoURL = @"https://signal.org/blog/sealed-s { BOOL enabled = sender.isOn; OWSLogInfo(@"toggled screen security: %@", enabled ? @"ON" : @"OFF"); - [SSKPreferences setScreenSecurity:enabled]; + [SMKPreferences setScreenSecurity:enabled]; } - (void)didToggleReadReceiptsSwitch:(UISwitch *)sender { BOOL enabled = sender.isOn; OWSLogInfo(@"toggled areReadReceiptsEnabled: %@", enabled ? @"ON" : @"OFF"); - [SSKPreferences setAreReadReceiptsEnabled:enabled]; + [SMKPreferences setAreReadReceiptsEnabled:enabled]; } - (void)didToggleTypingIndicatorsSwitch:(UISwitch *)sender { BOOL enabled = sender.isOn; OWSLogInfo(@"toggled areTypingIndicatorsEnabled: %@", enabled ? @"ON" : @"OFF"); - [SSKPreferences setTypingIndicatorsEnabled:enabled]; + [SMKPreferences setTypingIndicatorsEnabled:enabled]; } - (void)didToggleLinkPreviewsEnabled:(UISwitch *)sender { BOOL enabled = sender.isOn; OWSLogInfo(@"toggled to: %@", (enabled ? @"ON" : @"OFF")); - SSKPreferences.areLinkPreviewsEnabled = enabled; + [SMKPreferences setLinkPreviewsEnabled:enabled]; } - (void)isScreenLockEnabledDidChange:(UISwitch *)sender diff --git a/Session/Shared/Models/ConversationCellViewModel.swift b/Session/Shared/Models/ConversationCellViewModel.swift index 8ea63d1de..e242c532b 100644 --- a/Session/Shared/Models/ConversationCellViewModel.swift +++ b/Session/Shared/Models/ConversationCellViewModel.swift @@ -217,6 +217,7 @@ public extension ConversationCell.ViewModel { let interaction: TypedTableAlias = TypedTableAlias() let linkPreview: TypedTableAlias = TypedTableAlias() let interactionAttachment: TypedTableAlias = TypedTableAlias() + let attachment: TypedTableAlias = TypedTableAlias() let recipientState: TypedTableAlias = TypedTableAlias() let unreadCountTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.threadUnreadCountString)_table") @@ -227,8 +228,9 @@ public extension ConversationCell.ViewModel { let profileNameColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.name.name) let authorProfileLiteral: SQL = SQL(stringLiteral: "authorProfile") let attachmentIdColumnLiteral: SQL = SQL(stringLiteral: Attachment.Columns.id.name) - let firstInteractionAttachmentLiteral: SQL = SQL(stringLiteral: "firstInteractionAttachmentLiteral") + let firstInteractionAttachmentLiteral: SQL = SQL(stringLiteral: "firstInteractionAttachment") let interactionAttachmentAttachmentIdColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.attachmentId.name) + let interactionAttachmentInteractionIdColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.interactionId.name) let interactionAttachmentAlbumIndexColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.albumIndex.name) let interactionStateTableLiteral: SQL = SQL(stringLiteral: "interactionState") let interactionStateInteractionIdColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.interactionId.name) @@ -355,21 +357,31 @@ public extension ConversationCell.ViewModel { LEFT JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.interactionId]) = \(interaction[.id]) LEFT JOIN \(InteractionAttachment.self) AS \(firstInteractionAttachmentLiteral) ON ( \(firstInteractionAttachmentLiteral).\(interactionAttachmentAlbumIndexColumnLiteral) = 0 AND - \(firstInteractionAttachmentLiteral).\(interactionAttachmentAttachmentIdColumnLiteral) = \(interaction[.id]) + \(firstInteractionAttachmentLiteral).\(interactionAttachmentInteractionIdColumnLiteral) = \(interaction[.id]) ) - LEFT JOIN \(Attachment.self) AS \(ViewModel.interactionAttachmentDescriptionInfoKey) ON \(ViewModel.interactionAttachmentDescriptionInfoKey).\(attachmentIdColumnLiteral) = \(firstInteractionAttachmentLiteral).\(interactionAttachmentAttachmentIdColumnLiteral) LEFT JOIN ( SELECT - \(recipientState[.interactionId]), - \(recipientState[.state]) - FROM \(RecipientState.self) - JOIN \(Interaction.self) ON \(interaction[.id]) = \(recipientState[.interactionId]) - WHERE \(SQL("\(recipientState[.state]) != \(RecipientState.State.skipped)")) -- Ignore 'skipped' - ORDER BY - -- If there is a single 'sending' then should be 'sending', otherwise if there is a single - -- 'failed' and there is no 'sending' then it should be 'failed' - \(SQL("\(recipientState[.state]) = \(RecipientState.State.sending)")) DESC, - \(SQL("\(recipientState[.state]) = \(RecipientState.State.failed)")) DESC + \(attachment[.id]), + \(attachment[.variant]), + \(attachment[.contentType]), + \(attachment[.sourceFilename]) + FROM \(Attachment.self) + ) AS \(ViewModel.interactionAttachmentDescriptionInfoKey) ON \(ViewModel.interactionAttachmentDescriptionInfoKey).\(attachmentIdColumnLiteral) = \(firstInteractionAttachmentLiteral).\(interactionAttachmentAttachmentIdColumnLiteral) + LEFT JOIN ( + SELECT * FROM ( + SELECT + \(recipientState[.interactionId]), + \(recipientState[.state]) + FROM \(RecipientState.self) + JOIN \(Interaction.self) ON \(interaction[.id]) = \(recipientState[.interactionId]) + WHERE \(SQL("\(recipientState[.state]) != \(RecipientState.State.skipped)")) -- Ignore 'skipped' + ORDER BY + -- If there is a single 'sending' then should be 'sending', otherwise if there is a single + -- 'failed' and there is no 'sending' then it should be 'failed' + \(SQL("\(recipientState[.state]) = \(RecipientState.State.sending)")) DESC, + \(SQL("\(recipientState[.state]) = \(RecipientState.State.failed)")) DESC + ) AS \(interactionStateTableLiteral) + GROUP BY \(interactionStateTableLiteral).\(interactionStateInteractionIdColumnLiteral) ) AS \(interactionStateTableLiteral) ON \(interactionStateTableLiteral).\(interactionStateInteractionIdColumnLiteral) = \(interaction[.id]) WHERE ( @@ -578,7 +590,7 @@ public extension ConversationCell.ViewModel { \(ViewModel.closedGroupProfileBackFallbackKey).\(profileIdColumnLiteral) = \(userPublicKey) ) - ORDER BY rank + ORDER BY \(Column.rank) """ return request.adapted { db in @@ -644,7 +656,7 @@ public extension ConversationCell.ViewModel { var sqlQuery: SQL = "" let selectQuery: SQL = """ SELECT - IFNULL(rank, 100) AS rank, + IFNULL(\(Column.rank), 100) AS \(Column.rank), \(thread[.id]) AS \(ViewModel.threadIdKey), \(thread[.variant]) AS \(ViewModel.threadVariantKey), @@ -1037,7 +1049,7 @@ public extension ConversationCell.ViewModel { GROUP BY \(ViewModel.threadIdKey) ORDER BY - rank, + \(Column.rank), \(ViewModel.threadIsNoteToSelfKey), \(ViewModel.closedGroupNameKey), \(ViewModel.openGroupNameKey), diff --git a/Session/Shared/OWSScreenLockUI.m b/Session/Shared/OWSScreenLockUI.m index e7cb44121..0e8055ed2 100644 --- a/Session/Shared/OWSScreenLockUI.m +++ b/Session/Shared/OWSScreenLockUI.m @@ -351,8 +351,8 @@ NS_ASSUME_NONNULL_BEGIN if (Environment.shared.isRequestingPermission) { return ScreenLockUIStateNone; } - - if (Environment.shared.preferences.screenSecurityIsEnabled) { + + if ([SMKPreferences isScreenSecurityEnabled]) { OWSLogVerbose(@"desiredUIState: screen protection 4."); return ScreenLockUIStateScreenProtection; } else { diff --git a/Session/Utilities/AvatarViewHelper.h b/Session/Utilities/AvatarViewHelper.h index 82ecc282d..dcd86c983 100644 --- a/Session/Utilities/AvatarViewHelper.h +++ b/Session/Utilities/AvatarViewHelper.h @@ -8,7 +8,6 @@ NS_ASSUME_NONNULL_BEGIN @class AvatarViewHelper; @class OWSContactsManager; -@class SignalAccount; @class TSThread; @protocol AvatarViewHelperDelegate diff --git a/Session/Utilities/DifferenceKit+Utilities.swift b/Session/Utilities/DifferenceKit+Utilities.swift new file mode 100644 index 000000000..23f6517d3 --- /dev/null +++ b/Session/Utilities/DifferenceKit+Utilities.swift @@ -0,0 +1,10 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import DifferenceKit + +public extension ArraySection { + init(section: Model, elements: [Element] = []) { + self.init(model: section, elements: elements) + } +} diff --git a/Session/Utilities/MentionUtilities.swift b/Session/Utilities/MentionUtilities.swift index 038acf495..104deaaea 100644 --- a/Session/Utilities/MentionUtilities.swift +++ b/Session/Utilities/MentionUtilities.swift @@ -30,7 +30,6 @@ public enum MentionUtilities { var string = string var lastMatchEnd: Int = 0 var mentions: [(range: NSRange, publicKey: String)] = [] - let context: Profile.Context = (threadVariant == .openGroup ? .openGroup : .regular) while let match: NSTextCheckingResult = regex.firstMatch( in: string, @@ -41,7 +40,7 @@ public enum MentionUtilities { let publicKey: String = String(string[range].dropFirst()) // Drop the @ - guard let displayName: String = Profile.displayNameNoFallback(id: publicKey, context: context) else { + guard let displayName: String = Profile.displayNameNoFallback(id: publicKey, threadVariant: threadVariant) else { lastMatchEnd = (match.range.location + match.range.length) continue } diff --git a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift index 0e03a9035..1a72c9bfd 100644 --- a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift +++ b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift @@ -44,11 +44,16 @@ public enum Legacy { internal static let attachmentUploadJobCollection = "AttachmentUploadJobCollection" internal static let attachmentDownloadJobCollection = "AttachmentDownloadJobCollection" + // Preferences + internal static let preferencesCollection = "SignalPreferences" - internal static let preferencesKeyNotificationPreviewType = "preferencesKeyNotificationPreviewType" internal static let preferencesKeyScreenSecurityDisabled = "Screen Security Key" internal static let preferencesKeyLastRecordedPushToken = "LastRecordedPushToken" internal static let preferencesKeyLastRecordedVoipToken = "LastRecordedVoipToken" + internal static let preferencesKeyAreLinkPreviewsEnabled = "areLinkPreviewsEnabled" + internal static let preferencesKeyNotificationPreviewType = "preferencesKeyNotificationPreviewType" + internal static let preferencesKeyNotificationSoundInForeground = "NotificationSoundInForeground" + internal static let preferencesKeyHasSavedThreadKey = "hasSavedThread" internal static let readReceiptManagerCollection = "OWSReadReceiptManagerCollection" internal static let readReceiptManagerAreReadReceiptsEnabled = "areReadReceiptsEnabled" @@ -56,6 +61,10 @@ public enum Legacy { internal static let typingIndicatorsCollection = "TypingIndicators" internal static let typingIndicatorsEnabledKey = "kDatabaseKey_TypingIndicatorsEnabled" + internal static let screenLockCollection = "OWSScreenLock_Collection" + internal static let screenLockIsScreenLockEnabledKey = "OWSScreenLock_Key_IsScreenLockEnabled" + internal static let screenLockScreenLockTimeoutSecondsKey = "OWSScreenLock_Key_ScreenLockTimeoutSeconds" + internal static let soundsStorageNotificationCollection = "kOWSSoundsStorageNotificationCollection" internal static let soundsGlobalNotificationKey = "kOWSSoundsStorageGlobalNotificationKey" diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 6fabd0501..601e6fb26 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -1255,12 +1255,25 @@ enum _003_YDBToGRDBMigration: Migration { inCollection: Legacy.typingIndicatorsCollection, defaultValue: false ) + + legacyPreferences[Legacy.screenLockIsScreenLockEnabledKey] = transaction.bool( + forKey: Legacy.screenLockIsScreenLockEnabledKey, + inCollection: Legacy.screenLockCollection, + defaultValue: false + ) + + legacyPreferences[Legacy.screenLockScreenLockTimeoutSecondsKey] = transaction.double( + forKey: Legacy.screenLockScreenLockTimeoutSecondsKey, + inCollection: Legacy.screenLockCollection, + defaultValue: (15 * 60) + ) } - db[.preferencesNotificationPreviewType] = Preferences.NotificationPreviewType(rawValue: legacyPreferences[Legacy.preferencesKeyNotificationPreviewType] as? Int ?? -1) - .defaulting(to: .nameAndPreview) db[.defaultNotificationSound] = Preferences.Sound(rawValue: legacyPreferences[Legacy.soundsGlobalNotificationKey] as? Int ?? -1) .defaulting(to: Preferences.Sound.defaultNotificationSound) + db[.playNotificationSoundInForeground] = (legacyPreferences[Legacy.preferencesKeyNotificationSoundInForeground] as? Bool == true) + db[.preferencesNotificationPreviewType] = Preferences.NotificationPreviewType(rawValue: legacyPreferences[Legacy.preferencesKeyNotificationPreviewType] as? Int ?? -1) + .defaulting(to: .nameAndPreview) if let lastPushToken: String = legacyPreferences[Legacy.preferencesKeyLastRecordedPushToken] as? String { db[.lastRecordedPushToken] = lastPushToken @@ -1270,15 +1283,19 @@ enum _003_YDBToGRDBMigration: Migration { db[.lastRecordedVoipToken] = lastVoipToken } - // Note: The 'preferencesKeyScreenSecurityDisabled' value previously controlled whether the setting - // was disabled, this has been inverted to 'preferencesAppSwitcherPreviewEnabled' so it can default + // Note: The 'preferencesKeyScreenSecurityDisabled' value previously controlled whether the + // setting was disabled, this has been inverted to 'appSwitcherPreviewEnabled' so it can default // to 'false' (as most Bool values do) - db[.preferencesAppSwitcherPreviewEnabled] = (legacyPreferences[Legacy.preferencesKeyScreenSecurityDisabled] as? Bool == false) db[.areReadReceiptsEnabled] = (legacyPreferences[Legacy.readReceiptManagerAreReadReceiptsEnabled] as? Bool == true) db[.typingIndicatorsEnabled] = (legacyPreferences[Legacy.typingIndicatorsEnabledKey] as? Bool == true) - + db[.isScreenLockEnabled] = (legacyPreferences[Legacy.screenLockIsScreenLockEnabledKey] as? Bool == true) + db[.screenLockTimeoutSeconds] = (legacyPreferences[Legacy.screenLockScreenLockTimeoutSecondsKey] as? Double) + .defaulting(to: (15 * 60)) + db[.appSwitcherPreviewEnabled] = (legacyPreferences[Legacy.preferencesKeyScreenSecurityDisabled] as? Bool == false) + db[.areLinkPreviewsEnabled] = (legacyPreferences[Legacy.preferencesKeyAreLinkPreviewsEnabled] as? Bool == true) db[.hasHiddenMessageRequests] = CurrentAppContext().appUserDefaults() .bool(forKey: Legacy.userDefaultsHasHiddenMessageRequests) + db[.hasSavedThreadKey] = (legacyPreferences[Legacy.preferencesKeyHasSavedThreadKey] as? Bool == true) print("RAWR [\(Date().timeIntervalSince1970)] - Process preferences inserts - End") @@ -1381,24 +1398,23 @@ enum _003_YDBToGRDBMigration: Migration { return (true, cachedDuration) } - let (isValid, duration): (Bool, TimeInterval?) = Attachment.determineValidityAndDuration( + let attachmentVailidityInfo = Attachment.determineValidityAndDuration( contentType: stream.contentType, localRelativeFilePath: processedLocalRelativeFilePath, originalFilePath: originalFilePath ) - return (isValid, duration) + return (attachmentVailidityInfo.isValid, attachmentVailidityInfo.duration) } if stream.isVideo { - let videoPlayer: AVPlayer = AVPlayer(url: URL(fileURLWithPath: originalFilePath)) - let duration: TimeInterval? = videoPlayer.currentItem - .map { item -> TimeInterval in - // Accorting to the CMTime docs "value/timescale = seconds" - (TimeInterval(item.duration.value) / TimeInterval(item.duration.timescale)) - } + let attachmentVailidityInfo = Attachment.determineValidityAndDuration( + contentType: stream.contentType, + localRelativeFilePath: processedLocalRelativeFilePath, + originalFilePath: originalFilePath + ) - return ((duration ?? 0) > 0, duration) + return (attachmentVailidityInfo.isValid, attachmentVailidityInfo.duration) } if stream.isVisualMedia { diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index 1187a43af..7a482e9f8 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -10,9 +10,9 @@ import AVFoundation public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "attachment" } - internal static let interactionAttachments = hasOne(InteractionAttachment.self) internal static let quoteForeignKey = ForeignKey([Columns.id], to: [Quote.Columns.attachmentId]) internal static let linkPreviewForeignKey = ForeignKey([Columns.id], to: [LinkPreview.Columns.attachmentId]) + public static let interactionAttachments = hasOne(InteractionAttachment.self) public static let interaction = hasOne( Interaction.self, through: interactionAttachments, @@ -245,23 +245,27 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR // MARK: - CustomStringConvertible extension Attachment: CustomStringConvertible { - public struct DescriptionInfo: FetchableRecord, Decodable, Equatable, ColumnExpressible { + public struct DescriptionInfo: FetchableRecord, Decodable, Equatable, Hashable, ColumnExpressible { public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case id case variant case contentType case sourceFilename } + let id: String let variant: Attachment.Variant let contentType: String let sourceFilename: String? public init( + id: String, variant: Attachment.Variant, contentType: String, sourceFilename: String? ) { + self.id = id self.variant = variant self.contentType = contentType self.sourceFilename = sourceFilename @@ -279,8 +283,7 @@ extension Attachment: CustomStringConvertible { public static func description(for descriptionInfo: DescriptionInfo, count: Int) -> String { // We only support multi-attachment sending of images so we can just default to the image attachment // if there were multiple attachments - guard count == 1 else { return "\("ATTACHMENT".localized()) \(emoji(for: OWSMimeTypeImageJpeg))" } - + guard count == 1 else { return "\(emoji(for: OWSMimeTypeImageJpeg)) \("ATTACHMENT".localized())" } if MIMETypeUtil.isAudio(descriptionInfo.contentType) { // a missing filename is the legacy way to determine if an audio attachment is // a voice note vs. other arbitrary audio attachments. @@ -293,7 +296,7 @@ extension Attachment: CustomStringConvertible { } } - return "\("ATTACHMENT".localized()) \(emoji(for: descriptionInfo.contentType))" + return "\(emoji(for: descriptionInfo.contentType)) \("ATTACHMENT".localized())" } public static func emoji(for contentType: String) -> String { @@ -316,6 +319,7 @@ extension Attachment: CustomStringConvertible { public var description: String { return Attachment.description( for: DescriptionInfo( + id: id, variant: variant, contentType: contentType, sourceFilename: sourceFilename @@ -679,12 +683,11 @@ extension Attachment { // Process video attachments if MIMETypeUtil.isVideo(contentType) { - let videoPlayer: AVPlayer = AVPlayer(url: URL(fileURLWithPath: targetPath)) - let durationSeconds: TimeInterval? = videoPlayer.currentItem - .map { item -> TimeInterval in - // Accorting to the CMTime docs "value/timescale = seconds" - (TimeInterval(item.duration.value) / TimeInterval(item.duration.timescale)) - } + let asset: AVURLAsset = AVURLAsset(url: URL(fileURLWithPath: targetPath), options: nil) + let durationSeconds: TimeInterval = ( + // According to the CMTime docs "value/timescale = seconds" + TimeInterval(asset.duration.value) / TimeInterval(asset.duration.timescale) + ) return ( OWSMediaUtils.isValidVideo(path: targetPath), @@ -831,6 +834,27 @@ extension Attachment { loadThumbnail(with: size.dimension, success: success, failure: failure) } + public func existingThumbnail(size: ThumbnailSize) -> UIImage? { + var existingImage: UIImage? + + let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) + loadThumbnail( + with: size.dimension, + success: { image, _ in + existingImage = image + semaphore.signal() + }, + failure: { semaphore.signal() } + ) + + // We don't really want to wait at all so having a tiny timeout here will give the + // 'loadThumbnail' call the change to return a result for an existing thumbnail but + // not a new one + _ = semaphore.wait(timeout: .now() + .milliseconds(10)) + + return existingImage + } + public func cloneAsThumbnail() -> Attachment? { let cloneId: String = UUID().uuidString let thumbnailName: String = "quoted-thumbnail-\(sourceFilename ?? "null")" diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index c3721383e..a217ae85b 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -5,7 +5,7 @@ import GRDB import SignalCoreKit import SessionUtilitiesKit -public struct Profile: Codable, Identifiable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible, CustomStringConvertible { +public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible, CustomStringConvertible { public static var databaseTableName: String { "profile" } internal static let interactionForeignKey = ForeignKey([Columns.id], to: [Interaction.Columns.authorId]) internal static let contactForeignKey = ForeignKey([Columns.id], to: [Contact.Columns.id]) @@ -244,43 +244,26 @@ public extension Profile { .defaulting(to: []) } - static func displayName(_ db: Database? = nil, id: ID, thread: SessionThread, customFallback: String? = nil) -> String { - return displayName( - db, - id: id, - context: (thread.variant == .openGroup ? .openGroup : .regular), - customFallback: customFallback - ) - } - - static func displayName(_ db: Database? = nil, id: ID, context: Context = .regular, customFallback: String? = nil) -> String { + static func displayName(_ db: Database? = nil, id: ID, threadVariant: SessionThread.Variant = .contact, customFallback: String? = nil) -> String { guard let db: Database = db else { return GRDBStorage.shared - .read { db in displayName(db, id: id, context: context, customFallback: customFallback) } + .read { db in displayName(db, id: id, threadVariant: threadVariant, customFallback: customFallback) } .defaulting(to: (customFallback ?? id)) } let existingDisplayName: String? = (try? Profile.fetchOne(db, id: id))? - .displayName(for: context) + .displayName(for: threadVariant) return (existingDisplayName ?? (customFallback ?? id)) } - static func displayNameNoFallback(_ db: Database? = nil, id: ID, thread: SessionThread) -> String? { - return displayName( - db, - id: id, - context: (thread.variant == .openGroup ? .openGroup : .regular) - ) - } - - static func displayNameNoFallback(_ db: Database? = nil, id: ID, context: Context = .regular) -> String? { + static func displayNameNoFallback(_ db: Database? = nil, id: ID, threadVariant: SessionThread.Variant = .contact) -> String? { guard let db: Database = db else { - return GRDBStorage.shared.read { db in displayNameNoFallback(db, id: id, context: context) } + return GRDBStorage.shared.read { db in displayNameNoFallback(db, id: id, threadVariant: threadVariant) } } return (try? Profile.fetchOne(db, id: id))? - .displayName(for: context) + .displayName(for: threadVariant) } // MARK: - Fetch or Create @@ -352,13 +335,6 @@ public extension Profile { // MARK: - Convenience public extension Profile { - // MARK: - Context - - @objc enum Context: Int { - case regular - case openGroup - } - // MARK: - Truncation enum Truncation { @@ -387,15 +363,8 @@ public extension Profile { } /// The name to display in the UI for a given thread variant - func displayName(for threadVariant: SessionThread.Variant) -> String { - return displayName( - for: (threadVariant == .openGroup ? .openGroup : .regular) - ) - } - - /// The name to display in the UI - func displayName(for context: Context = .regular) -> String { - return Profile.displayName(for: context, id: id, name: name, nickname: nickname) + func displayName(for threadVariant: SessionThread.Variant = .contact) -> String { + return Profile.displayName(for: threadVariant, id: id, name: name, nickname: nickname) } static func displayName( @@ -404,22 +373,6 @@ public extension Profile { name: String?, nickname: String?, customFallback: String? = nil - ) -> String { - return Profile.displayName( - for: (threadVariant == .openGroup ? .openGroup : .regular), - id: id, - name: name, - nickname: nickname, - customFallback: customFallback - ) - } - - static func displayName( - for context: Context, - id: String, - name: String?, - nickname: String?, - customFallback: String? = nil ) -> String { if let nickname: String = nickname { return nickname } @@ -427,8 +380,8 @@ public extension Profile { return (customFallback ?? Profile.truncated(id: id, truncating: .middle)) } - switch context { - case .regular: return name + switch threadVariant { + case .contact, .closedGroup: return name case .openGroup: // In open groups, where it's more likely that multiple users have the same name, @@ -444,43 +397,6 @@ public extension Profile { @objc(SMKProfile) public class SMKProfile: NSObject { - var id: String - @objc var name: String - @objc var nickname: String? - - init(id: String, name: String, nickname: String?) { - self.id = id - self.name = name - self.nickname = nickname - } - - @objc public static func fetchCurrentUserName() -> String { - let existingProfile: Profile? = GRDBStorage.shared.read { db in - Profile.fetchOrCreateCurrentUser(db) - } - - return (existingProfile?.name ?? "") - } - - @objc public static func fetchOrCreate(id: String) -> SMKProfile { - let profile: Profile = Profile.fetchOrCreate(id: id) - - return SMKProfile( - id: id, - name: profile.name, - nickname: profile.nickname - ) - } - - @objc public static func saveProfile(_ profile: SMKProfile) { - GRDBStorage.shared.write { db in - try? Profile - .fetchOrCreate(db, id: profile.id) - .with(nickname: .updateTo(profile.nickname)) - .save(db) - } - } - @objc public static func displayName(id: String) -> String { return Profile.displayName(id: id) } @@ -489,22 +405,6 @@ public class SMKProfile: NSObject { return Profile.displayName(id: id, customFallback: customFallback) } - @objc public static func displayName(id: String, context: Profile.Context = .regular) -> String { - let existingProfile: Profile? = GRDBStorage.shared.read { db in - Profile.fetchOrCreateCurrentUser(db) - } - - return (existingProfile?.name ?? id) - } - - public static func displayName(id: String, thread: SessionThread) -> String { - return Profile.displayName(id: id, thread: thread) - } - - @objc public static var localProfileKey: OWSAES256Key? { - Profile.fetchOrCreateCurrentUser().profileEncryptionKey - } - @objc(displayNameAfterSavingNickname:forProfileId:) public static func displayNameAfterSaving(nickname: String?, for profileId: String) -> String { return GRDBStorage.shared.write { db in diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index f4595bdae..d0605cb5a 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -124,6 +124,12 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, // MARK: - Custom Database Interaction + public func insert(_ db: Database) throws { + try performInsert(db) + + db[.hasSavedThreadKey] = true + } + public func delete(_ db: Database) throws -> Bool { // Delete any jobs associated to this thread try Job diff --git a/SessionMessagingKit/Database/OWSPrimaryStorage.m b/SessionMessagingKit/Database/OWSPrimaryStorage.m index d7b2b507d..c44b6dd7e 100644 --- a/SessionMessagingKit/Database/OWSPrimaryStorage.m +++ b/SessionMessagingKit/Database/OWSPrimaryStorage.m @@ -7,7 +7,6 @@ #import "OWSDisappearingMessagesFinder.h" #import "OWSFileSystem.h" #import "OWSIncomingMessageFinder.h" -#import "OWSMediaGalleryFinder.h" #import #import "OWSStorage.h" #import "OWSStorage+Subclass.h" @@ -177,7 +176,6 @@ void VerifyRegistrationsForPrimaryStorage(OWSStorage *storage) [FullTextSearchFinder asyncRegisterDatabaseExtensionWithStorage:self]; [OWSIncomingMessageFinder asyncRegisterExtensionWithPrimaryStorage:self]; [OWSDisappearingMessagesFinder asyncRegisterDatabaseExtensions:self]; - [OWSMediaGalleryFinder asyncRegisterDatabaseExtensionsWithPrimaryStorage:self]; [TSDatabaseView asyncRegisterLazyRestoreAttachmentsDatabaseView:self]; [self.database diff --git a/SessionMessagingKit/Database/SSKPreferences.swift b/SessionMessagingKit/Database/SSKPreferences.swift deleted file mode 100644 index 8afbbf245..000000000 --- a/SessionMessagingKit/Database/SSKPreferences.swift +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionUtilitiesKit - -@objc -public class SSKPreferences: NSObject { - // Never instantiate this class. - private override init() {} - - private static let collection = "SSKPreferences" - - // MARK: - - - private static let areLinkPreviewsEnabledKey = "areLinkPreviewsEnabled" - - @objc - public static var areLinkPreviewsEnabled: Bool { - get { - return getBool(key: areLinkPreviewsEnabledKey, defaultValue: false) - } - set { - setBool(newValue, key: areLinkPreviewsEnabledKey) - } - } - - // MARK: - - - private static let hasSavedThreadKey = "hasSavedThread" - - @objc - public static var hasSavedThread: Bool { - get { - return getBool(key: hasSavedThreadKey) - } - set { - setBool(newValue, key: hasSavedThreadKey) - } - } - - @objc - public class func setHasSavedThread(value: Bool, transaction: YapDatabaseReadWriteTransaction) { - transaction.setBool(value, - forKey: hasSavedThreadKey, - inCollection: collection) - } - - // MARK: - - - private class func getBool(key: String, defaultValue: Bool = false) -> Bool { - return OWSPrimaryStorage.dbReadConnection().bool(forKey: key, inCollection: collection, defaultValue: defaultValue) - } - - private class func setBool(_ value: Bool, key: String) { - OWSPrimaryStorage.dbReadWriteConnection().setBool(value, forKey: key, inCollection: collection) - } -} - -// MARK: - Objective C Support - -public extension SSKPreferences { - @objc(setScreenSecurity:) - static func objc_setScreenSecurity(_ enabled: Bool) { - GRDBStorage.shared.write { db in db[.preferencesAppSwitcherPreviewEnabled] = enabled } - } - - @objc(areReadReceiptsEnabled) - static func objc_areReadReceiptsEnabled() -> Bool { - return GRDBStorage.shared[.areReadReceiptsEnabled] - } - - @objc(setAreReadReceiptsEnabled:) - static func objc_setAreReadReceiptsEnabled(_ enabled: Bool) { - GRDBStorage.shared.write { db in db[.areReadReceiptsEnabled] = enabled } - } - - @objc(setTypingIndicatorsEnabled:) - static func objc_setTypingIndicatorsEnabled(_ enabled: Bool) { - GRDBStorage.shared.write { db in db[.typingIndicatorsEnabled] = enabled } - } - - @objc(areTypingIndicatorsEnabled) - static func objc_areTypingIndicatorsEnabled() -> Bool { - return (GRDBStorage.shared.read { db in db[.typingIndicatorsEnabled] } == true) - } -} diff --git a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift index 72efd9ce4..e55e6bc05 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift @@ -21,7 +21,7 @@ public enum AttachmentDownloadJob: JobExecutor { let threadId: String = job.threadId, let detailsData: Data = job.details, let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData), - var attachment: Attachment = GRDBStorage.shared + let attachment: Attachment = GRDBStorage.shared .read({ db in try Attachment.fetchOne(db, id: details.attachmentId) }) else { failure(job, JobRunnerError.missingRequiredDetails, false) @@ -36,14 +36,12 @@ public enum AttachmentDownloadJob: JobExecutor { return } - // Update to the 'downloading' state - attachment = GRDBStorage.shared - .write { db in - try attachment - .with(state: .downloading) - .saved(db) - } - .defaulting(to: attachment) + // Update to the 'downloading' state (no need to update the 'attachment' instance) + GRDBStorage.shared.write { db in + try Attachment + .filter(id: attachment.id) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.downloading)) + } let temporaryFileUrl: URL = URL( fileURLWithPath: OWSTemporaryDirectoryAccessibleAfterFirstAuth() + UUID().uuidString @@ -94,16 +92,19 @@ public enum AttachmentDownloadJob: JobExecutor { // Remove the temporary file OWSFileSystem.deleteFile(temporaryFileUrl.path) - // Update the attachment state + /// Update the attachment state + /// + /// **Note:** We **MUST** use the `'with()` function here as it will update the + /// `isValid` and `duration` values based on the downloaded data and the state GRDBStorage.shared.write { db in - try attachment + _ = try attachment .with( state: .downloaded, creationTimestamp: Date().timeIntervalSince1970, localRelativeFilePath: attachment.originalFilePath? .substring(from: (Attachment.attachmentsFolder.count + 1)) // Leading forward slash ) - .save(db) + .saved(db) } success(job, false) @@ -113,12 +114,15 @@ public enum AttachmentDownloadJob: JobExecutor { switch error { case OnionRequestAPI.Error.httpRequestFailedAtDestination(let statusCode, _, _) where statusCode == 400: - // Otherwise, the attachment will show a state of downloading forever, - // and the message won't be able to be marked as read + /// Otherwise, the attachment will show a state of downloading forever, and the message + /// won't be able to be marked as read + /// + /// **Note:** We **MUST** use the `'with()` function here as it will update the + /// `isValid` and `duration` values based on the downloaded data and the state GRDBStorage.shared.write { db in - try attachment + _ = try attachment .with(state: .failed) - .save(db) + .saved(db) } // This usually indicates a file that has expired on the server, so there's no need to retry @@ -154,163 +158,3 @@ extension AttachmentDownloadJob { } } } -// TODO: MessageInvalidator.invalidate(tsMessage, with: transaction) - -// public let attachmentID: String -// public let tsMessageID: String -// public let threadID: String -// public var delegate: JobDelegate? -// public var id: String? -// public var failureCount: UInt = 0 -// public var isDeferred = false -// -// public enum Error : LocalizedError { -// case noAttachment -// case invalidURL -// -// public var errorDescription: String? { -// switch self { -// case .noAttachment: return "No such attachment." -// case .invalidURL: return "Invalid file URL." -// } -// } -// } -// -// // MARK: Settings -// public class var collection: String { return "AttachmentDownloadJobCollection" } -// public static let maxFailureCount: UInt = 20 -// -// // MARK: Initialization -// public init(attachmentID: String, tsMessageID: String, threadID: String) { -// self.attachmentID = attachmentID -// self.tsMessageID = tsMessageID -// self.threadID = threadID -// } -// -// // MARK: Coding -// public init?(coder: NSCoder) { -// guard let attachmentID = coder.decodeObject(forKey: "attachmentID") as! String?, -// let tsMessageID = coder.decodeObject(forKey: "tsIncomingMessageID") as! String?, -// let threadID = coder.decodeObject(forKey: "threadID") as! String?, -// let id = coder.decodeObject(forKey: "id") as! String? else { return nil } -// self.attachmentID = attachmentID -// self.tsMessageID = tsMessageID -// self.threadID = threadID -// self.id = id -// self.failureCount = coder.decodeObject(forKey: "failureCount") as! UInt? ?? 0 -// self.isDeferred = coder.decodeBool(forKey: "isDeferred") -// } -// -// public func encode(with coder: NSCoder) { -// coder.encode(attachmentID, forKey: "attachmentID") -// coder.encode(tsMessageID, forKey: "tsIncomingMessageID") -// coder.encode(threadID, forKey: "threadID") -// coder.encode(id, forKey: "id") -// coder.encode(failureCount, forKey: "failureCount") -// coder.encode(isDeferred, forKey: "isDeferred") -// } -// -// // MARK: Running -// public func execute() { -// if let id = id { -// JobQueue.currentlyExecutingJobs.insert(id) -// } -// guard !isDeferred else { return } -// if TSAttachment.fetch(uniqueId: attachmentID) is TSAttachmentStream { -// // FIXME: It's not clear * how * this happens, but apparently we can get to this point -// // from time to time with an already downloaded attachment. -// return handleSuccess() -// } -// guard let pointer = TSAttachment.fetch(uniqueId: attachmentID) as? TSAttachmentPointer else { -// return handleFailure(error: Error.noAttachment) -// } -// let storage = SNMessagingKitConfiguration.shared.storage -// storage.write(with: { transaction in -// storage.setAttachmentState(to: .downloading, for: pointer, associatedWith: self.tsMessageID, using: transaction) -// }, completion: { }) -// let temporaryFilePath = URL(fileURLWithPath: OWSTemporaryDirectoryAccessibleAfterFirstAuth() + UUID().uuidString) -// let handleFailure: (Swift.Error) -> Void = { error in // Intentionally capture self -// OWSFileSystem.deleteFile(temporaryFilePath.absoluteString) -// if let error = error as? Error, case .noAttachment = error { -// storage.write(with: { transaction in -// storage.setAttachmentState(to: .failed, for: pointer, associatedWith: self.tsMessageID, using: transaction) -// }, completion: { }) -// self.handlePermanentFailure(error: error) -// } else if let error = error as? OnionRequestAPI.Error, case .httpRequestFailedAtDestination(let statusCode, _, _) = error, -// statusCode == 400 { -// // Otherwise, the attachment will show a state of downloading forever, -// // and the message won't be able to be marked as read. -// storage.write(with: { transaction in -// storage.setAttachmentState(to: .failed, for: pointer, associatedWith: self.tsMessageID, using: transaction) -// }, completion: { }) -// // This usually indicates a file that has expired on the server, so there's no need to retry. -// self.handlePermanentFailure(error: error) -// } else { -// self.handleFailure(error: error) -// } -// } -// if let tsMessage = TSMessage.fetch(uniqueId: tsMessageID), let v2OpenGroup = storage.getV2OpenGroup(for: tsMessage.uniqueThreadId) { -// guard let fileAsString = pointer.downloadURL.split(separator: "/").last, let file = UInt64(fileAsString) else { -// return handleFailure(Error.invalidURL) -// } -// OpenGroupAPIV2.download(file, from: v2OpenGroup.room, on: v2OpenGroup.server).done(on: DispatchQueue.global(qos: .userInitiated)) { data in -// self.handleDownloadedAttachment(data: data, temporaryFilePath: temporaryFilePath, pointer: pointer, failureHandler: handleFailure) -// }.catch(on: DispatchQueue.global()) { error in -// handleFailure(error) -// } -// } else { -// guard let fileAsString = pointer.downloadURL.split(separator: "/").last, let file = UInt64(fileAsString) else { -// return handleFailure(Error.invalidURL) -// } -// let useOldServer = pointer.downloadURL.contains(FileServerAPIV2.oldServer) -// FileServerAPIV2.download(file, useOldServer: useOldServer).done(on: DispatchQueue.global(qos: .userInitiated)) { data in -// self.handleDownloadedAttachment(data: data, temporaryFilePath: temporaryFilePath, pointer: pointer, failureHandler: handleFailure) -// }.catch(on: DispatchQueue.global()) { error in -// handleFailure(error) -// } -// } -// } -// -// private func handleDownloadedAttachment(data: Data, temporaryFilePath: URL, pointer: TSAttachmentPointer, failureHandler: (Swift.Error) -> Void) { -// let storage = SNMessagingKitConfiguration.shared.storage -// do { -// try data.write(to: temporaryFilePath, options: .atomic) -// } catch { -// return failureHandler(error) -// } -// let plaintext: Data -// if let key = pointer.encryptionKey, let digest = pointer.digest, key.count > 0 && digest.count > 0 { -// do { -// plaintext = try Cryptography.decryptAttachment(data, withKey: key, digest: digest, unpaddedSize: pointer.byteCount) -// } catch { -// return failureHandler(error) -// } -// } else { -// plaintext = data // Open group attachments are unencrypted -// } -// let stream = TSAttachmentStream(pointer: pointer) -// do { -// try stream.write(plaintext) -// } catch { -// return failureHandler(error) -// } -// OWSFileSystem.deleteFile(temporaryFilePath.absoluteString) -// storage.write(with: { transaction in -// storage.persist(stream, associatedWith: self.tsMessageID, using: transaction) -// }, completion: { -// self.handleSuccess() -// }) -// } -// -// private func handleSuccess() { -// delegate?.handleJobSucceeded(self) -// } -// -// private func handlePermanentFailure(error: Swift.Error) { -// delegate?.handleJobFailedPermanently(self, with: error) -// } -// -// private func handleFailure(error: Swift.Error) { -// delegate?.handleJobFailed(self, with: error) -// } -//} diff --git a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift new file mode 100644 index 000000000..c9c7a34dc --- /dev/null +++ b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift @@ -0,0 +1,50 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import PromiseKit +import SignalCoreKit +import SessionUtilitiesKit +import SessionSnodeKit + +public enum GarbageCollectionJob: JobExecutor { + public static var maxFailureCount: Int = 10 + public static var requiresThreadId: Bool = true + public static let requiresInteractionId: Bool = false // Some messages don't have interactions + + public static func run( + _ job: Job, + success: @escaping (Job, Bool) -> (), + failure: @escaping (Job, Error?, Bool) -> (), + deferred: @escaping (Job) -> () + ) { + guard + let detailsData: Data = job.details, + let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData) + else { + failure(job, JobRunnerError.missingRequiredDetails, false) + return + } + + failure(job, JobRunnerError.missingRequiredDetails, true) + } +} + +// MARK: - GarbageCollectionJob.Details + +extension GarbageCollectionJob { + public enum Types: Codable, CaseIterable { + case oldOpenGroupMessages + case expiredControlMessageProcessRecords + case threadTypingIndicators + case orphanedAttachmentFiles + } + + public struct Details: Codable { + public let typesToCollect: [Types] + + public init(typesToCollect: [Types] = Types.allCases) { + self.typesToCollect = typesToCollect + } + } +} diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift index 502ce51b1..b2cbece63 100644 --- a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift @@ -240,143 +240,3 @@ extension MessageSendJob { } } } - -// public let message: Message -// public let destination: Message.Destination -// public var delegate: JobDelegate? -// public var id: String? -// public var failureCount: UInt = 0 -// -// // MARK: Settings -// public class var collection: String { return "MessageSendJobCollection" } -// public static let maxFailureCount: UInt = 10 -// -// // MARK: Initialization -// @objc public convenience init(message: Message, publicKey: String) { self.init(message: message, destination: .contact(publicKey: publicKey)) } -// @objc public convenience init(message: Message, groupPublicKey: String) { self.init(message: message, destination: .closedGroup(groupPublicKey: groupPublicKey)) } -// -// public init(message: Message, destination: Message.Destination) { -// self.message = message -// self.destination = destination -// } -// -// // MARK: Coding -// public init?(coder: NSCoder) { -// guard let message = coder.decodeObject(forKey: "message") as! Message?, -// var rawDestination = coder.decodeObject(forKey: "destination") as! String?, -// let id = coder.decodeObject(forKey: "id") as! String? else { return nil } -// self.message = message -// if rawDestination.removePrefix("contact(") { -// guard rawDestination.removeSuffix(")") else { return nil } -// let publicKey = rawDestination -// destination = .contact(publicKey: publicKey) -// } else if rawDestination.removePrefix("closedGroup(") { -// guard rawDestination.removeSuffix(")") else { return nil } -// let groupPublicKey = rawDestination -// destination = .closedGroup(groupPublicKey: groupPublicKey) -// } else if rawDestination.removePrefix("openGroup(") { -// guard rawDestination.removeSuffix(")") else { return nil } -// let components = rawDestination.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } -// guard components.count == 2, let channel = UInt64(components[0]) else { return nil } -// let server = components[1] -// destination = .openGroup(channel: channel, server: server) -// } else if rawDestination.removePrefix("openGroupV2(") { -// guard rawDestination.removeSuffix(")") else { return nil } -// let components = rawDestination.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } -// guard components.count == 2 else { return nil } -// let room = components[0] -// let server = components[1] -// destination = .openGroupV2(room: room, server: server) -// } else { -// return nil -// } -// self.id = id -// self.failureCount = coder.decodeObject(forKey: "failureCount") as! UInt? ?? 0 -// } -// -// public func encode(with coder: NSCoder) { -// coder.encode(message, forKey: "message") -// switch destination { -// case .contact(let publicKey): coder.encode("contact(\(publicKey))", forKey: "destination") -// case .closedGroup(let groupPublicKey): coder.encode("closedGroup(\(groupPublicKey))", forKey: "destination") -// case .openGroup(let channel, let server): coder.encode("openGroup(\(channel), \(server))", forKey: "destination") -// case .openGroupV2(let room, let server): coder.encode("openGroupV2(\(room), \(server))", forKey: "destination") -// } -// coder.encode(id, forKey: "id") -// coder.encode(failureCount, forKey: "failureCount") -// } -// -// // MARK: Running -// public func execute() { -// if let id = id { -// JobQueue.currentlyExecutingJobs.insert(id) -// } -// let storage = SNMessagingKitConfiguration.shared.storage -// if let message = message as? VisibleMessage { -// guard TSOutgoingMessage.find(withTimestamp: message.sentTimestamp!) != nil else { return } // The message has been deleted -// let attachments = message.attachmentIDs.compactMap { TSAttachment.fetch(uniqueId: $0) as? TSAttachmentStream } -// let attachmentsToUpload = attachments.filter { !$0.isUploaded } -// attachmentsToUpload.forEach { attachment in -// if storage.getAttachmentUploadJob(for: attachment.uniqueId!) != nil { -// // Wait for it to finish -// } else { -// let job = AttachmentUploadJob(attachmentID: attachment.uniqueId!, threadID: message.threadID!, message: message, messageSendJobID: id!) -// storage.write(with: { transaction in -// JobQueue.shared.add(job, using: transaction) -// }, completion: { }) -// } -// } -// if !attachmentsToUpload.isEmpty { return } // Wait for all attachments to upload before continuing -// } -// storage.write(with: { transaction in // Intentionally capture self -// MessageSender.send(self.message, to: self.destination, using: transaction).done(on: DispatchQueue.global(qos: .userInitiated)) { -// self.handleSuccess() -// }.catch(on: DispatchQueue.global(qos: .userInitiated)) { error in -// SNLog("Couldn't send message due to error: \(error).") -// if let error = error as? MessageSender.Error, !error.isRetryable { -// self.handlePermanentFailure(error: error) -// } else if let error = error as? OnionRequestAPI.Error, case .httpRequestFailedAtDestination(let statusCode, _, _) = error, -// statusCode == 429 { // Rate limited -// self.handlePermanentFailure(error: error) -// } else { -// self.handleFailure(error: error) -// } -// } -// }, completion: { }) -// } -// -// private func handleSuccess() { -// delegate?.handleJobSucceeded(self) -// } -// -// private func handlePermanentFailure(error: Error) { -// delegate?.handleJobFailedPermanently(self, with: error) -// } -// -// private func handleFailure(error: Error) { -// SNLog("Failed to send \(type(of: message)).") -// if let message = message as? VisibleMessage { -// guard TSOutgoingMessage.find(withTimestamp: message.sentTimestamp!) != nil else { return } // The message has been deleted -// } -// delegate?.handleJobFailed(self, with: error) -// } -//} -// -//// MARK: Convenience -//private extension String { -// -// @discardableResult -// mutating func removePrefix(_ prefix: T) -> Bool { -// guard hasPrefix(prefix) else { return false } -// removeFirst(prefix.count) -// return true -// } -// -// @discardableResult -// mutating func removeSuffix(_ suffix: T) -> Bool { -// guard hasSuffix(suffix) else { return false } -// removeLast(suffix.count) -// return true -// } -//} -// diff --git a/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift b/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift index fac43e97a..012cea8a5 100644 --- a/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift +++ b/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift @@ -7,58 +7,43 @@ import SessionUtilitiesKit extension ConfigurationMessage { public static func getCurrent(_ db: Database) throws -> ConfigurationMessage { - let profile: Profile = Profile.fetchOrCreateCurrentUser(db) - - let displayName: String = profile.name - let profilePictureUrl: String? = profile.profilePictureUrl - let profileKey: Data? = profile.profileEncryptionKey?.keyData - var closedGroups: Set = [] - var openGroups: Set = [] - - Storage.read { transaction in - TSGroupThread.enumerateCollectionObjects(with: transaction) { object, _ in - guard let thread = object as? TSGroupThread else { return } - - switch thread.groupModel.groupType { - case .closedGroup: - guard thread.isCurrentUserMemberInGroup() else { return } - - let groupID = thread.groupModel.groupId - let groupPublicKey = LKGroupUtilities.getDecodedGroupID(groupID) - - guard - Storage.shared.isClosedGroup(groupPublicKey, using: transaction), - let encryptionKeyPair = Storage.shared.getLatestClosedGroupEncryptionKeyPair(for: groupPublicKey, using: transaction) - else { - return - } - - let closedGroup = ClosedGroup( - publicKey: groupPublicKey, - name: (thread.groupModel.groupName ?? ""), - encryptionKeyPair: encryptionKeyPair, - members: Set(thread.groupModel.groupMemberIds), - admins: Set(thread.groupModel.groupAdminIds), - expirationTimer: thread.disappearingMessagesDuration(with: transaction) - ) - closedGroups.insert(closedGroup) - - case .openGroup: - if let threadId: String = thread.uniqueId, let v2OpenGroup = Storage.shared.getV2OpenGroup(for: threadId) { - openGroups.insert("\(v2OpenGroup.server)/\(v2OpenGroup.room)?public_key=\(v2OpenGroup.publicKey)") - } - - default: break + let currentUserProfile: Profile = Profile.fetchOrCreateCurrentUser(db) + let displayName: String = currentUserProfile.name + let profilePictureUrl: String? = currentUserProfile.profilePictureUrl + let profileKey: Data? = currentUserProfile.profileEncryptionKey?.keyData + let closedGroups: Set = try ClosedGroup.fetchAll(db) + .compactMap { closedGroup -> CMClosedGroup? in + guard let latestKeyPair: ClosedGroupKeyPair = try closedGroup.fetchLatestKeyPair(db) else { + return nil } - } - } - - let currentUserPublicKey: String = getUserHexEncodedPublicKey() - - let contacts: Set = try Contact.fetchAll(db) - .compactMap { contact -> CMContact? in - guard contact.id != currentUserPublicKey else { return nil } + return CMClosedGroup( + publicKey: closedGroup.publicKey, + name: closedGroup.name, + encryptionKeyPublicKey: latestKeyPair.publicKey, + encryptionKeySecretKey: latestKeyPair.secretKey, + members: try closedGroup.members + .select(GroupMember.Columns.profileId) + .asRequest(of: String.self) + .fetchSet(db), + admins: try closedGroup.admins + .select(GroupMember.Columns.profileId) + .asRequest(of: String.self) + .fetchSet(db), + expirationTimer: (try? DisappearingMessagesConfiguration + .fetchOne(db, id: closedGroup.threadId) + .map { ($0.isEnabled ? UInt32($0.durationSeconds) : 0) }) + .defaulting(to: 0) + ) + } + .asSet() + let openGroups: Set = try OpenGroup.fetchAll(db) + .map { "\($0.server)/\($0.room)?public_key=\($0.publicKey)" } + .asSet() + let contacts: Set = try Contact + .filter(Contact.Columns.id != currentUserProfile.id) + .fetchAll(db) + .map { contact -> CMContact in // Can just default the 'hasX' values to true as they will be set to this // when converting to proto anyway let profile: Profile? = try? Profile.fetchOne(db, id: contact.id) diff --git a/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift b/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift index 2b57181a3..d54549df1 100644 --- a/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift +++ b/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift @@ -4,7 +4,7 @@ import Foundation import GRDB import SessionUtilitiesKit -public final class DataExtractionNotification : ControlMessage { +public final class DataExtractionNotification: ControlMessage { private enum CodingKeys: String, CodingKey { case kind } @@ -15,7 +15,7 @@ public final class DataExtractionNotification : ControlMessage { public enum Kind: CustomStringConvertible, Codable { case screenshot - case mediaSaved(timestamp: UInt64) + case mediaSaved(timestamp: UInt64) // Note: The 'timestamp' should the original message timestamp public var description: String { switch self { diff --git a/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.h b/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.h index 5a671c9a8..d43793d82 100644 --- a/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.h +++ b/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.h @@ -55,7 +55,6 @@ typedef NS_ENUM(NSInteger, TSGroupMetaMessage) { @class SNProtoContentBuilder; @class SNProtoDataMessage; @class SNProtoDataMessageBuilder; -@class SignalRecipient; @interface TSOutgoingMessageRecipientState : MTLModel diff --git a/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.m b/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.m index aaf1f33d6..3470fb821 100644 --- a/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.m +++ b/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.m @@ -7,10 +7,7 @@ #import "TSOutgoingMessage.h" #import "TSDatabaseSecondaryIndexes.h" #import "OWSPrimaryStorage.h" -#import "ProfileManagerProtocol.h" -#import "ProtoUtils.h" #import "SSKEnvironment.h" -#import "SignalRecipient.h" #import "TSAccountManager.h" #import "TSAttachmentStream.h" #import "TSContactThread.h" diff --git a/SessionMessagingKit/Meta/SessionMessagingKit.h b/SessionMessagingKit/Meta/SessionMessagingKit.h index 829e3a4d2..8a69caee9 100644 --- a/SessionMessagingKit/Meta/SessionMessagingKit.h +++ b/SessionMessagingKit/Meta/SessionMessagingKit.h @@ -11,7 +11,6 @@ FOUNDATION_EXPORT const unsigned char SessionMessagingKitVersionString[]; #import #import #import -#import #import #import #import @@ -20,8 +19,6 @@ FOUNDATION_EXPORT const unsigned char SessionMessagingKitVersionString[]; #import #import #import -#import -#import #import #import #import diff --git a/SessionMessagingKit/Sending & Receiving/Link Previews/OWSLinkPreview.swift b/SessionMessagingKit/Sending & Receiving/Link Previews/OWSLinkPreview.swift index 1c4f8ddfb..c98118ebb 100644 --- a/SessionMessagingKit/Sending & Receiving/Link Previews/OWSLinkPreview.swift +++ b/SessionMessagingKit/Sending & Receiving/Link Previews/OWSLinkPreview.swift @@ -1,9 +1,11 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import AFNetworking +import GRDB import Foundation import PromiseKit import SignalCoreKit +import SessionUtilitiesKit @objc public enum LinkPreviewError: Int, Error { @@ -329,7 +331,7 @@ public class OWSLinkPreview: MTLModel { return nil } - guard SSKPreferences.areLinkPreviewsEnabled else { + guard GRDBStorage.shared[.areLinkPreviewsEnabled] else { return nil } @@ -425,28 +427,19 @@ public class OWSLinkPreview: MTLModel { // Exit early if link previews are not enabled in order to avoid // tainting the cache. - guard OWSLinkPreview.featureEnabled else { - return - } - guard SSKPreferences.areLinkPreviewsEnabled else { - return - } + guard OWSLinkPreview.featureEnabled else { return } + guard GRDBStorage.shared[.areLinkPreviewsEnabled] else { return } serialQueue.sync { linkPreviewDraftCache = linkPreviewDraft } } - @objc - public class func tryToBuildPreviewInfoObjc(previewUrl: String?) -> AnyPromise { - return AnyPromise(tryToBuildPreviewInfo(previewUrl: previewUrl)) - } - public class func tryToBuildPreviewInfo(previewUrl: String?) -> Promise { guard OWSLinkPreview.featureEnabled else { return Promise(error: LinkPreviewError.featureDisabled) } - guard SSKPreferences.areLinkPreviewsEnabled else { + guard GRDBStorage.shared[.areLinkPreviewsEnabled] else { return Promise(error: LinkPreviewError.featureDisabled) } guard let previewUrl = previewUrl else { diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift index 6ef9cb70e..8d85ab987 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift @@ -186,7 +186,7 @@ public final class Poller : NSObject { } } - SNLog("Received \(messageCount) message(s).") + SNLog("Received \(messageCount) new message\(messageCount == 1 ? "" : "s") (\(messages.count - messageCount) duplicates)") } self?.pollCount += 1 @@ -196,7 +196,9 @@ public final class Poller : NSObject { } return withDelay(Poller.pollInterval, completionQueue: Threading.pollerQueue) { - guard let strongSelf = self, strongSelf.isPolling.wrappedValue else { return Promise { $0.fulfill(()) } } + guard let strongSelf = self, strongSelf.isPolling.wrappedValue else { + return Promise { $0.fulfill(()) } + } return strongSelf.poll(snode, seal: longTermSeal) } diff --git a/SessionMessagingKit/Threads/TSThread.h b/SessionMessagingKit/Threads/TSThread.h index 4cbf068f2..f1c660a22 100644 --- a/SessionMessagingKit/Threads/TSThread.h +++ b/SessionMessagingKit/Threads/TSThread.h @@ -8,7 +8,6 @@ NS_ASSUME_NONNULL_BEGIN BOOL IsNoteToSelfEnabled(void); -@class OWSDisappearingMessagesConfiguration; @class TSInteraction; /** diff --git a/SessionMessagingKit/To Do/ProfileManagerProtocol.h b/SessionMessagingKit/To Do/ProfileManagerProtocol.h deleted file mode 100644 index 21d52e304..000000000 --- a/SessionMessagingKit/To Do/ProfileManagerProtocol.h +++ /dev/null @@ -1,30 +0,0 @@ -//// -//// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -//// -// -//@class OWSAES256Key; -//@class TSThread; -//@class YapDatabaseReadWriteTransaction; -//@class SNContact; -// -//NS_ASSUME_NONNULL_BEGIN -// -//@protocol ProfileManagerProtocol -// -//#pragma mark - Local Profile -// -//- (void)updateServiceWithProfileName:(nullable NSString *)localProfileName avatarURL:(nullable NSString *)avatarURL; -// -//#pragma mark - Other User's Profiles -// -//- (nullable NSData *)profileKeyDataForRecipientId:(NSString *)recipientId; -//- (void)setProfileKeyData:(NSData *)profileKeyData forRecipientId:(NSString *)recipientId; -//- (void)setProfileKeyData:(NSData *)profileKeyData forRecipientId:(NSString *)recipientId avatarURL:(nullable NSString *)avatarURL; -// -//#pragma mark - Other -// -//- (void)downloadAvatarForUserProfile:(SNContact *)userProfile; -// -//@end -// -//NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/To Do/SignalRecipient.h b/SessionMessagingKit/To Do/SignalRecipient.h deleted file mode 100644 index 430698ae3..000000000 --- a/SessionMessagingKit/To Do/SignalRecipient.h +++ /dev/null @@ -1,50 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -// SignalRecipient serves two purposes: -// -// a) It serves as a cache of "known" Signal accounts. When the service indicates -// that an account exists, we make sure that an instance of SignalRecipient exists -// for that recipient id (using mark as registered) and has at least one device. -// When the service indicates that an account does not exist, we remove any devices -// from that SignalRecipient - but do not remove it from the database. -// Note that SignalRecipients without any devices are not considered registered. -//// b) We hang the "known device list" for known signal accounts on this entity. -@interface SignalRecipient : TSYapDatabaseObject - -@property (nonatomic, readonly) NSOrderedSet *devices; - -- (instancetype)init NS_UNAVAILABLE; - -+ (nullable instancetype)registeredRecipientForRecipientId:(NSString *)recipientId - mustHaveDevices:(BOOL)mustHaveDevices - transaction:(YapDatabaseReadTransaction *)transaction; -+ (instancetype)getOrBuildUnsavedRecipientForRecipientId:(NSString *)recipientId - transaction:(YapDatabaseReadTransaction *)transaction; - -- (void)updateRegisteredRecipientWithDevicesToAdd:(nullable NSArray *)devicesToAdd - devicesToRemove:(nullable NSArray *)devicesToRemove - transaction:(YapDatabaseReadWriteTransaction *)transaction; - -- (NSString *)recipientId; - -- (NSComparisonResult)compare:(SignalRecipient *)other; - -+ (BOOL)isRegisteredRecipient:(NSString *)recipientId transaction:(YapDatabaseReadTransaction *)transaction; - -+ (SignalRecipient *)markRecipientAsRegisteredAndGet:(NSString *)recipientId - transaction:(YapDatabaseReadWriteTransaction *)transaction; -+ (void)markRecipientAsRegistered:(NSString *)recipientId - deviceId:(UInt32)deviceId - transaction:(YapDatabaseReadWriteTransaction *)transaction; - -+ (void)markRecipientAsUnregistered:(NSString *)recipientId transaction:(YapDatabaseReadWriteTransaction *)transaction; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/To Do/SignalRecipient.m b/SessionMessagingKit/To Do/SignalRecipient.m deleted file mode 100644 index 51f5196c1..000000000 --- a/SessionMessagingKit/To Do/SignalRecipient.m +++ /dev/null @@ -1,217 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "SignalRecipient.h" -#import "ProfileManagerProtocol.h" -#import "SSKEnvironment.h" -#import "TSAccountManager.h" -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface SignalRecipient () - -@property (nonatomic) NSOrderedSet *devices; - -@end - -#pragma mark - - -@implementation SignalRecipient - -#pragma mark - Dependencies - -- (id)profileManager -{ - return SSKEnvironment.shared.profileManager; -} - -- (TSAccountManager *)tsAccountManager -{ - return SSKEnvironment.shared.tsAccountManager; -} - -#pragma mark - - -+ (instancetype)getOrBuildUnsavedRecipientForRecipientId:(NSString *)recipientId - transaction:(YapDatabaseReadTransaction *)transaction -{ - SignalRecipient *_Nullable recipient = - [self registeredRecipientForRecipientId:recipientId mustHaveDevices:NO transaction:transaction]; - if (!recipient) { - recipient = [[self alloc] initWithTextSecureIdentifier:recipientId]; - } - return recipient; -} - -- (instancetype)initWithTextSecureIdentifier:(NSString *)textSecureIdentifier -{ - self = [super initWithUniqueId:textSecureIdentifier]; - if (!self) { - return self; - } - - _devices = [NSOrderedSet orderedSetWithObject:@(1)]; - - return self; -} - -- (nullable instancetype)initWithCoder:(NSCoder *)coder -{ - self = [super initWithCoder:coder]; - if (!self) { - return self; - } - - if (_devices == nil) { - _devices = [NSOrderedSet new]; - } - - // Since we use device count to determine whether a user is registered or not, - // ensure the local user always has at least *this* device. - if (![_devices containsObject:@(1)]) { - if ([self.uniqueId isEqualToString:self.tsAccountManager.localNumber]) { - [self addDevices:[NSSet setWithObject:@(1)]]; - } - } - - return self; -} - -+ (nullable instancetype)registeredRecipientForRecipientId:(NSString *)recipientId - mustHaveDevices:(BOOL)mustHaveDevices - transaction:(YapDatabaseReadTransaction *)transaction -{ - SignalRecipient *_Nullable signalRecipient = [self fetchObjectWithUniqueID:recipientId transaction:transaction]; - if (mustHaveDevices && signalRecipient.devices.count < 1) { - return nil; - } - return signalRecipient; -} - -- (void)addDevices:(NSSet *)devices -{ - NSMutableOrderedSet *updatedDevices = [self.devices mutableCopy]; - [updatedDevices unionSet:devices]; - self.devices = [updatedDevices copy]; -} - -- (void)removeDevices:(NSSet *)devices -{ - NSMutableOrderedSet *updatedDevices = [self.devices mutableCopy]; - [updatedDevices minusSet:devices]; - self.devices = [updatedDevices copy]; -} - -- (void)updateRegisteredRecipientWithDevicesToAdd:(nullable NSArray *)devicesToAdd - devicesToRemove:(nullable NSArray *)devicesToRemove - transaction:(YapDatabaseReadWriteTransaction *)transaction { - // Add before we remove, since removeDevicesFromRecipient:... - // can markRecipientAsUnregistered:... if the recipient has - // no devices left. - if (devicesToAdd.count > 0) { - [self addDevicesToRegisteredRecipient:[NSSet setWithArray:devicesToAdd] transaction:transaction]; - } - if (devicesToRemove.count > 0) { - [self removeDevicesFromRecipient:[NSSet setWithArray:devicesToRemove] transaction:transaction]; - } -} - -- (void)addDevicesToRegisteredRecipient:(NSSet *)devices transaction:(YapDatabaseReadWriteTransaction *)transaction -{ - [self reloadWithTransaction:transaction]; - [self addDevices:devices]; - [self saveWithTransaction_internal:transaction]; -} - -- (void)removeDevicesFromRecipient:(NSSet *)devices transaction:(YapDatabaseReadWriteTransaction *)transaction -{ - [self reloadWithTransaction:transaction ignoreMissing:YES]; - [self removeDevices:devices]; - [self saveWithTransaction_internal:transaction]; -} - -- (NSString *)recipientId -{ - return self.uniqueId; -} - -- (NSComparisonResult)compare:(SignalRecipient *)other -{ - return [self.recipientId compare:other.recipientId]; -} - -- (void)removeWithTransaction:(YapDatabaseReadWriteTransaction *)transaction -{ - // We need to distinguish between "users we know to be unregistered" and - // "users whose registration status is unknown". The former correspond to - // instances of SignalRecipient with no devices. The latter do not - // correspond to an instance of SignalRecipient in the database (although - // they may correspond to an "unsaved" instance of SignalRecipient built - // by getOrBuildUnsavedRecipientForRecipientId. - - [super removeWithTransaction:transaction]; -} - -- (void)saveWithTransaction:(YapDatabaseReadWriteTransaction *)transaction -{ - // We only want to mutate the persisted SignalRecipients in the database - // using other methods of this class, e.g. markRecipientAsRegistered... - // to create, addDevices and removeDevices to mutate. We're trying to - // be strict about using persisted SignalRecipients as a cache to - // reflect "last known registration status". Forcing our codebase to - // use those methods helps ensure that we update the cache deliberately. - - [self saveWithTransaction_internal:transaction]; -} - -- (void)saveWithTransaction_internal:(YapDatabaseReadWriteTransaction *)transaction -{ - [super saveWithTransaction:transaction]; -} - -+ (BOOL)isRegisteredRecipient:(NSString *)recipientId transaction:(YapDatabaseReadTransaction *)transaction -{ - return nil != [self registeredRecipientForRecipientId:recipientId mustHaveDevices:YES transaction:transaction]; -} - -+ (SignalRecipient *)markRecipientAsRegisteredAndGet:(NSString *)recipientId - transaction:(YapDatabaseReadWriteTransaction *)transaction -{ - SignalRecipient *_Nullable instance = - [self registeredRecipientForRecipientId:recipientId mustHaveDevices:YES transaction:transaction]; - - if (!instance) { - - instance = [[self alloc] initWithTextSecureIdentifier:recipientId]; - [instance saveWithTransaction_internal:transaction]; - } - return instance; -} - -+ (void)markRecipientAsRegistered:(NSString *)recipientId - deviceId:(UInt32)deviceId - transaction:(YapDatabaseReadWriteTransaction *)transaction -{ - SignalRecipient *recipient = [self markRecipientAsRegisteredAndGet:recipientId transaction:transaction]; - if (![recipient.devices containsObject:@(deviceId)]) { - - [recipient addDevices:[NSSet setWithObject:@(deviceId)]]; - [recipient saveWithTransaction_internal:transaction]; - } -} - -+ (void)markRecipientAsUnregistered:(NSString *)recipientId transaction:(YapDatabaseReadWriteTransaction *)transaction -{ - SignalRecipient *instance = [self getOrBuildUnsavedRecipientForRecipientId:recipientId - transaction:transaction]; - if (instance.devices.count > 0) { - [instance removeDevices:instance.devices.set]; - } - [instance saveWithTransaction_internal:transaction]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/To Do/TSAccountManager.m b/SessionMessagingKit/To Do/TSAccountManager.m index eed562917..c74e356c0 100644 --- a/SessionMessagingKit/To Do/TSAccountManager.m +++ b/SessionMessagingKit/To Do/TSAccountManager.m @@ -6,7 +6,6 @@ #import "AppContext.h" #import "AppReadiness.h" #import "NSNotificationCenter+OWS.h" -#import "ProfileManagerProtocol.h" #import "SSKEnvironment.h" #import "YapDatabaseConnection+OWS.h" #import "YapDatabaseTransaction+OWS.h" diff --git a/SessionMessagingKit/Utilities/MessageInvalidator.swift b/SessionMessagingKit/Utilities/MessageInvalidator.swift deleted file mode 100644 index 734691fab..000000000 --- a/SessionMessagingKit/Utilities/MessageInvalidator.swift +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -/// A message is invalidated when it needs to be re-rendered in the UI. Examples of when this happens include: -/// -/// • When the sent or read status of a message is updated. -/// • When an attachment is uploaded or downloaded. -@objc public final class MessageInvalidator : NSObject { - private static var invalidatedMessages: Set = [] - - @objc public static let shared = MessageInvalidator() - - private override init() { } - - @objc public static func invalidate(_ message: TSMessage, with transaction: YapDatabaseReadWriteTransaction) { - guard let id = message.uniqueId else { return } - invalidatedMessages.insert(id) - message.touch(with: transaction) - } - - @objc public static func isInvalidated(_ message: TSMessage) -> Bool { - guard let id = message.uniqueId else { return false } - return invalidatedMessages.contains(id) - } - - @objc public static func markAsUpdated(_ id: String) { - invalidatedMessages.remove(id) - } -} diff --git a/SessionMessagingKit/Utilities/OWSAudioPlayer.h b/SessionMessagingKit/Utilities/OWSAudioPlayer.h index 41f555e67..63d74acf0 100644 --- a/SessionMessagingKit/Utilities/OWSAudioPlayer.h +++ b/SessionMessagingKit/Utilities/OWSAudioPlayer.h @@ -46,7 +46,7 @@ typedef NS_ENUM(NSUInteger, OWSAudioBehavior) { @property (nonatomic) NSTimeInterval duration; - (instancetype)initWithMediaUrl:(NSURL *)mediaUrl audioBehavior:(OWSAudioBehavior)audioBehavior; -- (instancetype)initWithMediaUrl:(NSURL *)mediaUrl audioBehavior:(OWSAudioBehavior)audioBehavior delegate:(id)delegate; +- (instancetype)initWithMediaUrl:(NSURL *)mediaUrl audioBehavior:(OWSAudioBehavior)audioBehavior delegate:(nullable id)delegate; - (void)play; - (void)setCurrentTime:(NSTimeInterval)currentTime; - (void)pause; diff --git a/SessionMessagingKit/Utilities/OWSAudioPlayer.m b/SessionMessagingKit/Utilities/OWSAudioPlayer.m index 124c2483e..5039c5de9 100644 --- a/SessionMessagingKit/Utilities/OWSAudioPlayer.m +++ b/SessionMessagingKit/Utilities/OWSAudioPlayer.m @@ -61,7 +61,7 @@ NS_ASSUME_NONNULL_BEGIN - (instancetype)initWithMediaUrl:(NSURL *)mediaUrl audioBehavior:(OWSAudioBehavior)audioBehavior - delegate:(id)delegate + delegate:(nullable id)delegate { self = [super init]; if (!self) { diff --git a/SessionMessagingKit/Utilities/OWSMediaGalleryFinder.h b/SessionMessagingKit/Utilities/OWSMediaGalleryFinder.h deleted file mode 100644 index bdcc32e17..000000000 --- a/SessionMessagingKit/Utilities/OWSMediaGalleryFinder.h +++ /dev/null @@ -1,54 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@class OWSStorage; -@class TSAttachment; -@class TSThread; -@class YapDatabaseAutoViewTransaction; -@class YapDatabaseConnection; -@class YapDatabaseReadTransaction; -@class YapDatabaseViewRowChange; - -@interface OWSMediaGalleryFinder : NSObject - -- (instancetype)init NS_UNAVAILABLE; - -- (instancetype)initWithThread:(TSThread *)thread NS_DESIGNATED_INITIALIZER; - -// How many media items a thread has -- (NSUInteger)mediaCountWithTransaction:(YapDatabaseReadTransaction *)transaction NS_SWIFT_NAME(mediaCount(transaction:)); - -// The ordinal position of an attachment within a thread's media gallery -- (nullable NSNumber *)mediaIndexForAttachment:(TSAttachment *)attachment - transaction:(YapDatabaseReadTransaction *)transaction - NS_SWIFT_NAME(mediaIndex(attachment:transaction:)); - -- (nullable TSAttachment *)oldestMediaAttachmentWithTransaction:(YapDatabaseReadTransaction *)transaction - NS_SWIFT_NAME(oldestMediaAttachment(transaction:)); -- (nullable TSAttachment *)mostRecentMediaAttachmentWithTransaction:(YapDatabaseReadTransaction *)transaction - NS_SWIFT_NAME(mostRecentMediaAttachment(transaction:)); - -- (void)enumerateMediaAttachmentsWithRange:(NSRange)range - transaction:(YapDatabaseReadTransaction *)transaction - block:(void (^)(TSAttachment *))attachmentBlock - NS_SWIFT_NAME(enumerateMediaAttachments(range:transaction:block:)); - -- (BOOL)hasMediaChangesInNotifications:(NSArray *)notifications - dbConnection:(YapDatabaseConnection *)dbConnection; - -#pragma mark - Extension registration - -@property (nonatomic, readonly) NSString *mediaGroup; -- (YapDatabaseAutoViewTransaction *)galleryExtensionWithTransaction:(YapDatabaseReadTransaction *)transaction - NS_SWIFT_NAME(galleryExtension(transaction:)); -+ (NSString *)databaseExtensionName; -+ (void)asyncRegisterDatabaseExtensionsWithPrimaryStorage:(OWSStorage *)storage; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Utilities/OWSMediaGalleryFinder.m b/SessionMessagingKit/Utilities/OWSMediaGalleryFinder.m deleted file mode 100644 index 26670ab6a..000000000 --- a/SessionMessagingKit/Utilities/OWSMediaGalleryFinder.m +++ /dev/null @@ -1,209 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "OWSMediaGalleryFinder.h" -#import "OWSStorage.h" -#import "TSAttachmentStream.h" -#import "TSMessage.h" -#import "TSThread.h" -#import -#import -#import -#import -#import "TSAttachment.h" - -NS_ASSUME_NONNULL_BEGIN - -static NSString *const OWSMediaGalleryFinderExtensionName = @"OWSMediaGalleryFinderExtensionName"; - -@interface OWSMediaGalleryFinder () - -@property (nonatomic, readonly) TSThread *thread; - -@end - -@implementation OWSMediaGalleryFinder - -- (instancetype)initWithThread:(TSThread *)thread -{ - self = [super init]; - if (!self) { - return self; - } - - _thread = thread; - - return self; -} - -#pragma mark - Public Finder Methods - -- (NSUInteger)mediaCountWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - return [[self galleryExtensionWithTransaction:transaction] numberOfItemsInGroup:self.mediaGroup]; -} - -- (nullable NSNumber *)mediaIndexForAttachment:(TSAttachment *)attachment - transaction:(YapDatabaseReadTransaction *)transaction -{ - NSString *groupId; - NSUInteger index; - - BOOL wasFound = [[self galleryExtensionWithTransaction:transaction] getGroup:&groupId - index:&index - forKey:attachment.uniqueId - inCollection:[TSAttachment collection]]; - - if (!wasFound) { - return nil; - } - - return @(index); -} - -- (nullable TSAttachment *)oldestMediaAttachmentWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - return [[self galleryExtensionWithTransaction:transaction] firstObjectInGroup:self.mediaGroup]; -} - -- (nullable TSAttachment *)mostRecentMediaAttachmentWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - return [[self galleryExtensionWithTransaction:transaction] lastObjectInGroup:self.mediaGroup]; -} - -- (void)enumerateMediaAttachmentsWithRange:(NSRange)range - transaction:(YapDatabaseReadTransaction *)transaction - block:(void (^)(TSAttachment *))attachmentBlock -{ - - [[self galleryExtensionWithTransaction:transaction] - enumerateKeysAndObjectsInGroup:self.mediaGroup - withOptions:0 - range:range - usingBlock:^(NSString *_Nonnull collection, - NSString *_Nonnull key, - id _Nonnull object, - NSUInteger index, - BOOL *_Nonnull stop) { - attachmentBlock((TSAttachment *)object); - }]; -} - -- (BOOL)hasMediaChangesInNotifications:(NSArray *)notifications - dbConnection:(YapDatabaseConnection *)dbConnection -{ - YapDatabaseAutoViewConnection *extConnection = [dbConnection ext:OWSMediaGalleryFinderExtensionName]; - - return [extConnection hasChangesForGroup:self.mediaGroup inNotifications:notifications]; -} - -#pragma mark - Util - -- (YapDatabaseAutoViewTransaction *)galleryExtensionWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - YapDatabaseAutoViewTransaction *extension = [transaction extension:OWSMediaGalleryFinderExtensionName]; - - return extension; -} - -+ (NSString *)mediaGroupWithThreadId:(NSString *)threadId -{ - return [NSString stringWithFormat:@"%@-media", threadId]; -} - -- (NSString *)mediaGroup -{ - return [[self class] mediaGroupWithThreadId:self.thread.uniqueId]; -} - -#pragma mark - Extension registration - -+ (NSString *)databaseExtensionName -{ - return OWSMediaGalleryFinderExtensionName; -} - -+ (void)asyncRegisterDatabaseExtensionsWithPrimaryStorage:(OWSStorage *)storage -{ - [storage asyncRegisterExtension:[self mediaGalleryDatabaseExtension] - withName:OWSMediaGalleryFinderExtensionName]; -} - -+ (YapDatabaseAutoView *)mediaGalleryDatabaseExtension -{ - YapDatabaseViewSorting *sorting = - [YapDatabaseViewSorting withObjectBlock:^NSComparisonResult(YapDatabaseReadTransaction *_Nonnull transaction, - NSString *_Nonnull group, - NSString *_Nonnull collection1, - NSString *_Nonnull key1, - id _Nonnull object1, - NSString *_Nonnull collection2, - NSString *_Nonnull key2, - id _Nonnull object2) { - if (![object1 isKindOfClass:[TSAttachment class]]) { - return NSOrderedSame; - } - TSAttachment *attachment1 = (TSAttachment *)object1; - - if (![object2 isKindOfClass:[TSAttachment class]]) { - return NSOrderedSame; - } - TSAttachment *attachment2 = (TSAttachment *)object2; - - TSMessage *_Nullable message1 = [attachment1 fetchAlbumMessageWithTransaction:transaction]; - TSMessage *_Nullable message2 = [attachment2 fetchAlbumMessageWithTransaction:transaction]; - if (message1 == nil || message2 == nil) { - return NSOrderedSame; - } - - if ([message1.uniqueId isEqualToString:message2.uniqueId]) { - NSUInteger index1 = [message1.attachmentIds indexOfObject:attachment1.uniqueId]; - NSUInteger index2 = [message1.attachmentIds indexOfObject:attachment2.uniqueId]; - - if (index1 == NSNotFound || index2 == NSNotFound) { - return NSOrderedSame; - } - return [@(index1) compare:@(index2)]; - } else { - return [message1 compareForSorting:message2]; - } - }]; - - YapDatabaseViewGrouping *grouping = - [YapDatabaseViewGrouping withObjectBlock:^NSString *_Nullable(YapDatabaseReadTransaction *_Nonnull transaction, - NSString *_Nonnull collection, - NSString *_Nonnull key, - id _Nonnull object) { - // Don't include nil or not yet downloaded attachments. - if (![object isKindOfClass:[TSAttachmentStream class]]) { - return nil; - } - - TSAttachmentStream *attachment = (TSAttachmentStream *)object; - if (attachment.albumMessageId == nil) { - return nil; - } - - if (!attachment.isValidVisualMedia) { - return nil; - } - - TSMessage *message = [attachment fetchAlbumMessageWithTransaction:transaction]; - if (message == nil) { - return nil; - } - - return [self mediaGroupWithThreadId:message.uniqueThreadId]; - }]; - - YapDatabaseViewOptions *options = [YapDatabaseViewOptions new]; - options.allowedCollections = - [[YapWhitelistBlacklist alloc] initWithWhitelist:[NSSet setWithObject:TSAttachment.collection]]; - - return [[YapDatabaseAutoView alloc] initWithGrouping:grouping sorting:sorting versionTag:@"4" options:options]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Utilities/OWSPreferences.h b/SessionMessagingKit/Utilities/OWSPreferences.h index 8fdafd1b1..412c5f61d 100644 --- a/SessionMessagingKit/Utilities/OWSPreferences.h +++ b/SessionMessagingKit/Utilities/OWSPreferences.h @@ -44,20 +44,6 @@ extern NSString *const OWSPreferencesCallLoggingDidChangeNotification; - (BOOL)hasSentAMessage; - (void)setHasSentAMessage:(BOOL)enabled; -+ (BOOL)isLoggingEnabled; -+ (void)setIsLoggingEnabled:(BOOL)flag; - -- (BOOL)screenSecurityIsEnabled; -- (void)setScreenSecurity:(BOOL)flag; - -- (NotificationType)notificationPreviewType; -- (NotificationType)notificationPreviewTypeWithTransaction:(YapDatabaseReadTransaction *)transaction; -- (void)setNotificationPreviewType:(NotificationType)type; -- (NSString *)nameForNotificationPreviewType:(NotificationType)notificationType; - -- (BOOL)soundInForeground; -- (void)setSoundInForeground:(BOOL)enabled; - - (BOOL)hasDeclinedNoContactsView; - (void)setHasDeclinedNoContactsView:(BOOL)value; @@ -95,16 +81,6 @@ extern NSString *const OWSPreferencesCallLoggingDidChangeNotification; - (BOOL)doCallsHideIPAddress; - (void)setDoCallsHideIPAddress:(BOOL)flag; -#pragma mark - Push Tokens - -- (void)setPushToken:(NSString *)value; -- (nullable NSString *)getPushToken; - -- (void)setVoipToken:(NSString *)value; -- (nullable NSString *)getVoipToken; - -- (void)unsetRecordedAPNSTokens; - @end NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Utilities/OWSPreferences.m b/SessionMessagingKit/Utilities/OWSPreferences.m index c999bed4f..cd666e3d2 100644 --- a/SessionMessagingKit/Utilities/OWSPreferences.m +++ b/SessionMessagingKit/Utilities/OWSPreferences.m @@ -8,13 +8,7 @@ NS_ASSUME_NONNULL_BEGIN NSString *const OWSPreferencesSignalDatabaseCollection = @"SignalPreferences"; NSString *const OWSPreferencesCallLoggingDidChangeNotification = @"OWSPreferencesCallLoggingDidChangeNotification"; -NSString *const OWSPreferencesKeyScreenSecurity = @"Screen Security Key"; -NSString *const OWSPreferencesKeyEnableDebugLog = @"Debugging Log Enabled Key"; -NSString *const OWSPreferencesKeyNotificationPreviewType = @"Notification Preview Type Key"; NSString *const OWSPreferencesKeyHasSentAMessage = @"User has sent a message"; -NSString *const OWSPreferencesKeyPlaySoundInForeground = @"NotificationSoundInForeground"; -NSString *const OWSPreferencesKeyLastRecordedPushToken = @"LastRecordedPushToken"; -NSString *const OWSPreferencesKeyLastRecordedVoipToken = @"LastRecordedVoipToken"; NSString *const OWSPreferencesKeyCallKitEnabled = @"CallKitEnabled"; NSString *const OWSPreferencesKeyCallKitPrivacyEnabled = @"CallKitPrivacyEnabled"; NSString *const OWSPreferencesKeyCallsHideIPAddress = @"CallsHideIPAddress"; @@ -92,17 +86,6 @@ NSString *const OWSPreferencesKeySystemCallLogEnabled = @"OWSPreferencesKeySyste [NSUserDefaults.appUserDefaults synchronize]; } -- (BOOL)screenSecurityIsEnabled -{ - NSNumber *preference = [self tryGetValueForKey:OWSPreferencesKeyScreenSecurity]; - return preference ? [preference boolValue] : YES; -} - -- (void)setScreenSecurity:(BOOL)flag -{ - [self setValueForKey:OWSPreferencesKeyScreenSecurity toValue:@(flag)]; -} - - (BOOL)hasSentAMessage { NSNumber *preference = [self tryGetValueForKey:OWSPreferencesKeyHasSentAMessage]; @@ -113,26 +96,6 @@ NSString *const OWSPreferencesKeySystemCallLogEnabled = @"OWSPreferencesKeySyste } } -+ (BOOL)isLoggingEnabled -{ - NSNumber *preference = [NSUserDefaults.appUserDefaults objectForKey:OWSPreferencesKeyEnableDebugLog]; - - if (preference) { - return [preference boolValue]; - } else { - return YES; - } -} - -+ (void)setIsLoggingEnabled:(BOOL)flag -{ - // Logging preferences are stored in UserDefaults instead of the database, so that we can (optionally) start - // logging before the database is initialized. This is important because sometimes there are problems *with* the - // database initialization, and without logging it would be hard to track down. - [NSUserDefaults.appUserDefaults setObject:@(flag) forKey:OWSPreferencesKeyEnableDebugLog]; - [NSUserDefaults.appUserDefaults synchronize]; -} - - (void)setHasSentAMessage:(BOOL)enabled { [self setValueForKey:OWSPreferencesKeyHasSentAMessage toValue:@(enabled)]; @@ -335,92 +298,6 @@ NSString *const OWSPreferencesKeySystemCallLogEnabled = @"OWSPreferencesKeySyste [self setValueForKey:OWSPreferencesKeyCallsHideIPAddress toValue:@(flag)]; } -#pragma mark Notification Preferences - -- (BOOL)soundInForeground -{ - NSNumber *preference = [self tryGetValueForKey:OWSPreferencesKeyPlaySoundInForeground]; - if (preference) { - return [preference boolValue]; - } else { - return YES; - } -} - -- (void)setSoundInForeground:(BOOL)enabled -{ - [self setValueForKey:OWSPreferencesKeyPlaySoundInForeground toValue:@(enabled)]; -} - -- (void)setNotificationPreviewType:(NotificationType)type -{ - [self setValueForKey:OWSPreferencesKeyNotificationPreviewType toValue:@(type)]; -} - -- (NotificationType)notificationPreviewType -{ - NSNumber *preference = [self tryGetValueForKey:OWSPreferencesKeyNotificationPreviewType]; - - if (preference) { - return [preference unsignedIntegerValue]; - } else { - return NotificationNamePreview; - } -} - -- (NotificationType)notificationPreviewTypeWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - NSNumber *preference = [self tryGetValueForKey:OWSPreferencesKeyNotificationPreviewType transaction:transaction]; - - if (preference) { - return [preference unsignedIntegerValue]; - } else { - return NotificationNamePreview; - } -} - -- (NSString *)nameForNotificationPreviewType:(NotificationType)notificationType -{ - switch (notificationType) { - case NotificationNamePreview: - return NSLocalizedString(@"NOTIFICATIONS_SENDER_AND_MESSAGE", nil); - case NotificationNameNoPreview: - return NSLocalizedString(@"NOTIFICATIONS_SENDER_ONLY", nil); - case NotificationNoNameNoPreview: - return NSLocalizedString(@"NOTIFICATIONS_NONE", nil); - default: - return @""; - } -} - -#pragma mark - Push Tokens - -- (void)setPushToken:(NSString *)value -{ - [self setValueForKey:OWSPreferencesKeyLastRecordedPushToken toValue:value]; -} - -- (nullable NSString *)getPushToken -{ - return [self tryGetValueForKey:OWSPreferencesKeyLastRecordedPushToken]; -} - -- (void)setVoipToken:(NSString *)value -{ - [self setValueForKey:OWSPreferencesKeyLastRecordedVoipToken toValue:value]; -} - -- (nullable NSString *)getVoipToken -{ - return [self tryGetValueForKey:OWSPreferencesKeyLastRecordedVoipToken]; -} - -- (void)unsetRecordedAPNSTokens -{ - [self setValueForKey:OWSPreferencesKeyLastRecordedPushToken toValue:nil]; - [self setValueForKey:OWSPreferencesKeyLastRecordedVoipToken toValue:nil]; -} - @end NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Utilities/Preferences.swift b/SessionMessagingKit/Utilities/Preferences.swift index 197357b6b..f37f048ae 100644 --- a/SessionMessagingKit/Utilities/Preferences.swift +++ b/SessionMessagingKit/Utilities/Preferences.swift @@ -18,7 +18,7 @@ public extension Setting.BoolKey { /// /// **Note:** In the legacy setting this flag controlled whether the preview was "disabled" (and defaulted to /// true), by inverting this flag we can default it to false as is standard for Bool values - static let preferencesAppSwitcherPreviewEnabled: Setting.BoolKey = "preferencesAppSwitcherPreviewEnabled" + static let appSwitcherPreviewEnabled: Setting.BoolKey = "appSwitcherPreviewEnabled" /// Controls whether typing indicators are enabled /// @@ -30,8 +30,22 @@ public extension Setting.BoolKey { /// **Note:** Only works if both participants in a "contact" thread have this setting enabled static let typingIndicatorsEnabled: Setting.BoolKey = "typingIndicatorsEnabled" + /// Controls whether the device will automatically lock the screen + static let isScreenLockEnabled: Setting.BoolKey = "isScreenLockEnabled" + + /// Controls whether Link Previews (image & title URL metadata) will be downloaded when the user enters a URL + /// + /// **Note:** Link Previews are only enabled for HTTPS urls + static let areLinkPreviewsEnabled: Setting.BoolKey = "areLinkPreviewsEnabled" + /// Controls whether the message requests item has been hidden on the home screen static let hasHiddenMessageRequests: Setting.BoolKey = "hasHiddenMessageRequests" + + /// Controls whether the notification sound should play while the app is in the foreground + static let playNotificationSoundInForeground: Setting.BoolKey = "playNotificationSoundInForeground" + + /// A flag indicating whether the user has ever saved a thread + static let hasSavedThreadKey: Setting.BoolKey = "hasSavedThread" } public extension Setting.StringKey { @@ -42,6 +56,11 @@ public extension Setting.StringKey { static let lastRecordedVoipToken: Setting.StringKey = "lastRecordedVoipToken" } +public extension Setting.DoubleKey { + /// The duration of the timeout for screen lock in seconds + static let screenLockTimeoutSeconds: Setting.DoubleKey = "screenLockTimeoutSeconds" +} + public enum Preferences { public enum NotificationPreviewType: Int, CaseIterable, EnumSetting { /// Notifications should include both the sender name and a preview of the message content @@ -298,6 +317,56 @@ public class SMKPreferences: NSObject { case .noNameNoPreview: return "NotificationNoNameNoPreview" } } + + @objc(setPlayNotificationSoundInForeground:) + static func objc_setPlayNotificationSoundInForeground(_ enabled: Bool) { + GRDBStorage.shared.write { db in db[.playNotificationSoundInForeground] = enabled } + } + + @objc(playNotificationSoundInForeground) + static func objc_playNotificationSoundInForeground() -> Bool { + return GRDBStorage.shared[.playNotificationSoundInForeground] + } + + @objc(setScreenSecurity:) + static func objc_setScreenSecurity(_ enabled: Bool) { + GRDBStorage.shared.write { db in db[.appSwitcherPreviewEnabled] = enabled } + } + + @objc(isScreenSecurityEnabled) + static func objc_isScreenSecurityEnabled() -> Bool { + return GRDBStorage.shared[.appSwitcherPreviewEnabled] + } + + @objc(setAreReadReceiptsEnabled:) + static func objc_setAreReadReceiptsEnabled(_ enabled: Bool) { + GRDBStorage.shared.write { db in db[.areReadReceiptsEnabled] = enabled } + } + + @objc(areReadReceiptsEnabled) + static func objc_areReadReceiptsEnabled() -> Bool { + return GRDBStorage.shared[.areReadReceiptsEnabled] + } + + @objc(setTypingIndicatorsEnabled:) + static func objc_setTypingIndicatorsEnabled(_ enabled: Bool) { + GRDBStorage.shared.write { db in db[.typingIndicatorsEnabled] = enabled } + } + + @objc(areTypingIndicatorsEnabled) + static func objc_areTypingIndicatorsEnabled() -> Bool { + return GRDBStorage.shared[.typingIndicatorsEnabled] + } + + @objc(setLinkPreviewsEnabled:) + static func objc_setLinkPreviewsEnabled(_ enabled: Bool) { + GRDBStorage.shared.write { db in db[.areLinkPreviewsEnabled] = enabled } + } + + @objc(areLinkPreviewsEnabled) + static func objc_areLinkPreviewsEnabled() -> Bool { + return GRDBStorage.shared[.areLinkPreviewsEnabled] + } } @objc(SMKSound) diff --git a/SessionMessagingKit/Utilities/ProtoUtils.h b/SessionMessagingKit/Utilities/ProtoUtils.h deleted file mode 100644 index cac16bed8..000000000 --- a/SessionMessagingKit/Utilities/ProtoUtils.h +++ /dev/null @@ -1,24 +0,0 @@ -//// -//// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -//// -// -//#import -// -//NS_ASSUME_NONNULL_BEGIN -// -//@class SNProtoDataMessageBuilder; -//@class TSThread; -// -//@interface ProtoUtils : NSObject -// -//- (instancetype)init NS_UNAVAILABLE; -// -//+ (void)addLocalProfileKeyIfNecessary:(TSThread *)thread -// recipientId:(NSString *_Nullable)recipientId -// dataMessageBuilder:(SNProtoDataMessageBuilder *)dataMessageBuilder; -// -//+ (void)addLocalProfileKeyToDataMessageBuilder:(SNProtoDataMessageBuilder *)dataMessageBuilder; -// -//@end -// -//NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Utilities/ProtoUtils.m b/SessionMessagingKit/Utilities/ProtoUtils.m deleted file mode 100644 index 57bb38b11..000000000 --- a/SessionMessagingKit/Utilities/ProtoUtils.m +++ /dev/null @@ -1,50 +0,0 @@ -//// -//// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -//// -// -//#import "ProtoUtils.h" -//#import "ProfileManagerProtocol.h" -//#import "SSKEnvironment.h" -//#import "TSThread.h" -//#import -//#import -// -//NS_ASSUME_NONNULL_BEGIN -// -//@implementation ProtoUtils -// -//#pragma mark - Dependencies -// -////+ (id)profileManager { -//// return SSKEnvironment.shared.profileManager; -////} -// -////+ (OWSAES256Key *)localProfileKey -////{ -//// return [[LKStorage.shared getUser] profileEncryptionKey]; -////} -// -//#pragma mark - -// -//+ (BOOL)shouldMessageHaveLocalProfileKey:(TSThread *)thread recipientId:(NSString *_Nullable)recipientId -//{ -// return YES; -//} -// -//+ (void)addLocalProfileKeyIfNecessary:(TSThread *)thread -// recipientId:(NSString *_Nullable)recipientId -// dataMessageBuilder:(SNProtoDataMessageBuilder *)dataMessageBuilder -//{ -// if ([self shouldMessageHaveLocalProfileKey:thread recipientId:recipientId]) { -// [dataMessageBuilder setProfileKey:[SMKProfile localProfileKey].keyData]; -// } -//} -// -//+ (void)addLocalProfileKeyToDataMessageBuilder:(SNProtoDataMessageBuilder *)dataMessageBuilder -//{ -// [dataMessageBuilder setProfileKey:[SMKProfile localProfileKey].keyData]; -//} -// -//@end -// -//NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Utilities/YapDatabaseTransaction+OWS.h b/SessionMessagingKit/Utilities/YapDatabaseTransaction+OWS.h index 03722a874..056a20aa5 100644 --- a/SessionMessagingKit/Utilities/YapDatabaseTransaction+OWS.h +++ b/SessionMessagingKit/Utilities/YapDatabaseTransaction+OWS.h @@ -14,6 +14,7 @@ NS_ASSUME_NONNULL_BEGIN @interface YapDatabaseReadTransaction (OWS) - (BOOL)boolForKey:(NSString *)key inCollection:(NSString *)collection defaultValue:(BOOL)defaultValue; +- (double)doubleForKey:(NSString *)key inCollection:(NSString *)collection defaultValue:(double)defaultValue; - (int)intForKey:(NSString *)key inCollection:(NSString *)collection; - (nullable NSDate *)dateForKey:(NSString *)key inCollection:(NSString *)collection; - (nullable NSDictionary *)dictionaryForKey:(NSString *)key inCollection:(NSString *)collection; diff --git a/SessionMessagingKit/Utilities/YapDatabaseTransaction+OWS.m b/SessionMessagingKit/Utilities/YapDatabaseTransaction+OWS.m index c95a67a1a..c102ee6e3 100644 --- a/SessionMessagingKit/Utilities/YapDatabaseTransaction+OWS.m +++ b/SessionMessagingKit/Utilities/YapDatabaseTransaction+OWS.m @@ -30,6 +30,12 @@ NS_ASSUME_NONNULL_BEGIN return value ? [value boolValue] : defaultValue; } +- (double)doubleForKey:(NSString *)key inCollection:(NSString *)collection defaultValue:(double)defaultValue +{ + NSNumber *_Nullable value = [self objectForKey:key inCollection:collection ofExpectedType:[NSNumber class]]; + return value ? [value doubleValue] : defaultValue; +} + - (nullable NSData *)dataForKey:(NSString *)key inCollection:(NSString *)collection { return [self objectForKey:key inCollection:collection ofExpectedType:[NSData class]]; diff --git a/SessionNotificationServiceExtension/NSENotificationPresenter.swift b/SessionNotificationServiceExtension/NSENotificationPresenter.swift index 909ba9d03..cdc20df0c 100644 --- a/SessionNotificationServiceExtension/NSENotificationPresenter.swift +++ b/SessionNotificationServiceExtension/NSENotificationPresenter.swift @@ -43,7 +43,7 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { return } - let senderName = Profile.displayName(db, id: senderPublicKey, thread: thread) + let senderName = Profile.displayName(db, id: senderPublicKey, threadVariant: thread.variant) var notificationTitle = senderName diff --git a/SessionUtilitiesKit/Database/GRDBStorage.swift b/SessionUtilitiesKit/Database/GRDBStorage.swift index 7ed3bdc0c..8c859e727 100644 --- a/SessionUtilitiesKit/Database/GRDBStorage.swift +++ b/SessionUtilitiesKit/Database/GRDBStorage.swift @@ -269,6 +269,10 @@ public final class GRDBStorage { onChange: onChange ) } + + public func addObserver(_ observer: TransactionObserver) { + dbPool.add(transactionObserver: observer) + } } // MARK: - Promise Extensions diff --git a/SessionUtilitiesKit/Database/Models/Job.swift b/SessionUtilitiesKit/Database/Models/Job.swift index c652105a9..15adf0a34 100644 --- a/SessionUtilitiesKit/Database/Models/Job.swift +++ b/SessionUtilitiesKit/Database/Models/Job.swift @@ -40,6 +40,10 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer /// This is a recurring job that ensures the app fetches the default open group rooms on launch case retrieveDefaultOpenGroupRooms + /// This is a recurring job that removes expired and orphaned data, it runs on launch and can also be triggered + /// as 'runOnce' to avoid waiting until the next launch to clear data + case garbageCollection + /// This is a recurring job that runs on launch and flags any messages marked as 'sending' to /// be in their 'failed' state /// diff --git a/SessionUtilitiesKit/General/Array+Utilities.swift b/SessionUtilitiesKit/General/Array+Utilities.swift index 059dd9d3e..ff9bd11fc 100644 --- a/SessionUtilitiesKit/General/Array+Utilities.swift +++ b/SessionUtilitiesKit/General/Array+Utilities.swift @@ -1,3 +1,4 @@ +import UIKit public extension Array where Element : CustomStringConvertible { @@ -44,6 +45,11 @@ public extension Array { } } +public extension Array { + func grouped(by keyForValue: (Element) throws -> Key) -> [Key: [Element]] { + return ((try? Dictionary(grouping: self, by: keyForValue)) ?? [:]) + } +} public extension Array where Element: Hashable { func asSet() -> Set { diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift index f03ff9472..3a3370820 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift @@ -19,6 +19,10 @@ class AddMoreRailItem: GalleryRailItem { return view } + + func isEqual(to other: GalleryRailItem?) -> Bool { + return (other is AddMoreRailItem) + } } class SignalAttachmentItem: Hashable { diff --git a/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h b/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h index 7c9d6a42d..3df762e34 100644 --- a/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h +++ b/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h @@ -26,7 +26,6 @@ FOUNDATION_EXPORT const unsigned char SignalUtilitiesKitVersionString[]; #import #import #import -#import #import #import #import @@ -35,7 +34,6 @@ FOUNDATION_EXPORT const unsigned char SignalUtilitiesKitVersionString[]; #import #import #import -#import #import #import #import diff --git a/SignalUtilitiesKit/Screen Lock/OWSScreenLock.swift b/SignalUtilitiesKit/Screen Lock/OWSScreenLock.swift index b6c0002c5..e04a24e83 100644 --- a/SignalUtilitiesKit/Screen Lock/OWSScreenLock.swift +++ b/SignalUtilitiesKit/Screen Lock/OWSScreenLock.swift @@ -1,20 +1,21 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB import LocalAuthentication +import SessionMessagingKit +// FIXME: Refactor this once the 'PrivacySettingsTableViewController' and 'OWSScreenLockUI' have been refactored @objc public class OWSScreenLock: NSObject { public enum OWSScreenLockOutcome { case success case cancel - case failure(error:String) - case unexpectedFailure(error:String) + case failure(error: String) + case unexpectedFailure(error: String) } - @objc public let screenLockTimeoutDefault = 15 * kMinuteInterval + @objc public let screenLockTimeoutDefault = (15 * kMinuteInterval) @objc public let screenLockTimeouts = [ 1 * kMinuteInterval, 5 * kMinuteInterval, @@ -26,22 +27,12 @@ import LocalAuthentication @objc public static let ScreenLockDidChange = Notification.Name("ScreenLockDidChange") - let primaryStorage: OWSPrimaryStorage - let dbConnection: YapDatabaseConnection - - private let OWSScreenLock_Collection = "OWSScreenLock_Collection" - private let OWSScreenLock_Key_IsScreenLockEnabled = "OWSScreenLock_Key_IsScreenLockEnabled" - private let OWSScreenLock_Key_ScreenLockTimeoutSeconds = "OWSScreenLock_Key_ScreenLockTimeoutSeconds" - // MARK: - Singleton class @objc(sharedManager) public static let shared = OWSScreenLock() private override init() { - self.primaryStorage = OWSPrimaryStorage.shared() - self.dbConnection = self.primaryStorage.newDatabaseConnection() - super.init() SwiftSingletons.register(self) @@ -50,44 +41,31 @@ import LocalAuthentication // MARK: - Properties @objc public func isScreenLockEnabled() -> Bool { - AssertIsOnMainThread() - - if !OWSStorage.isStorageReady() { - owsFailDebug("accessed screen lock state before storage is ready.") - return false - } - - return self.dbConnection.bool(forKey: OWSScreenLock_Key_IsScreenLockEnabled, inCollection: OWSScreenLock_Collection, defaultValue: false) + return GRDBStorage.shared[.isScreenLockEnabled] } @objc public func setIsScreenLockEnabled(_ value: Bool) { - AssertIsOnMainThread() - assert(OWSStorage.isStorageReady()) - - self.dbConnection.setBool(value, forKey: OWSScreenLock_Key_IsScreenLockEnabled, inCollection: OWSScreenLock_Collection) - - NotificationCenter.default.postNotificationNameAsync(OWSScreenLock.ScreenLockDidChange, object: nil) + GRDBStorage.shared.writeAsync( + updates: { db in db[.isScreenLockEnabled] = value }, + completion: { _, _ in + NotificationCenter.default.postNotificationNameAsync(OWSScreenLock.ScreenLockDidChange, object: nil) + } + ) } @objc public func screenLockTimeout() -> TimeInterval { - AssertIsOnMainThread() - - if !OWSStorage.isStorageReady() { - owsFailDebug("accessed screen lock state before storage is ready.") - return 0 - } - - return self.dbConnection.double(forKey: OWSScreenLock_Key_ScreenLockTimeoutSeconds, inCollection: OWSScreenLock_Collection, defaultValue: screenLockTimeoutDefault) + return GRDBStorage.shared[.screenLockTimeoutSeconds] + .defaulting(to: screenLockTimeoutDefault) } @objc public func setScreenLockTimeout(_ value: TimeInterval) { - AssertIsOnMainThread() - assert(OWSStorage.isStorageReady()) - - self.dbConnection.setDouble(value, forKey: OWSScreenLock_Key_ScreenLockTimeoutSeconds, inCollection: OWSScreenLock_Collection) - - NotificationCenter.default.postNotificationNameAsync(OWSScreenLock.ScreenLockDidChange, object: nil) + GRDBStorage.shared.writeAsync( + updates: { db in db[.screenLockTimeoutSeconds] = value }, + completion: { _, _ in + NotificationCenter.default.postNotificationNameAsync(OWSScreenLock.ScreenLockDidChange, object: nil) + } + ) } // MARK: - Methods diff --git a/SignalUtilitiesKit/Shared Views/GalleryRailView.swift b/SignalUtilitiesKit/Shared Views/GalleryRailView.swift index 94dabe8ee..74d001b1a 100644 --- a/SignalUtilitiesKit/Shared Views/GalleryRailView.swift +++ b/SignalUtilitiesKit/Shared Views/GalleryRailView.swift @@ -1,25 +1,42 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. +import UIKit import PromiseKit import SessionUIKit -public protocol GalleryRailItemProvider { - var railItems: [GalleryRailItem] { get } -} +// MARK: - GalleryRailItem public protocol GalleryRailItem { func buildRailItemView() -> UIView + func isEqual(to other: GalleryRailItem?) -> Bool } +// MARK: - GalleryRailCellViewDelegate + protocol GalleryRailCellViewDelegate: AnyObject { func didTapGalleryRailCellView(_ galleryRailCellView: GalleryRailCellView) } -public class GalleryRailCellView: UIView { +// MARK: - GalleryRailCellView - weak var delegate: GalleryRailCellViewDelegate? +public class GalleryRailCellView: UIView { + public let cellBorderWidth: CGFloat = 3 + public var item: GalleryRailItem? + fileprivate weak var delegate: GalleryRailCellViewDelegate? + + private(set) var isSelected: Bool = false + + // MARK: - UI + + let contentContainer: UIView = { + let view = UIView() + view.autoPinToSquareAspectRatio() + view.clipsToBounds = true + + return view + }() + + // MARK: - Initialization override init(frame: CGRect) { super.init(frame: frame) @@ -38,16 +55,14 @@ public class GalleryRailCellView: UIView { fatalError("init(coder:) has not been implemented") } - // MARK: Actions + // MARK: - Actions @objc func didTap(sender: UITapGestureRecognizer) { self.delegate?.didTapGalleryRailCellView(self) } - // MARK: - - var item: GalleryRailItem? + // MARK: Content func configure(item: GalleryRailItem, delegate: GalleryRailCellViewDelegate) { self.item = item @@ -62,11 +77,7 @@ public class GalleryRailCellView: UIView { itemView.autoPinEdgesToSuperviewEdges() } - // MARK: Selected - - private(set) var isSelected: Bool = false - - public let cellBorderWidth: CGFloat = 3 + // MARK: - Selected func setIsSelected(_ isSelected: Bool) { self.isSelected = isSelected @@ -81,134 +92,189 @@ public class GalleryRailCellView: UIView { contentContainer.layer.borderWidth = 0 } } - - // MARK: Subview Helpers - - let contentContainer: UIView = { - let view = UIView() - view.autoPinToSquareAspectRatio() - view.clipsToBounds = true - - return view - }() } -public protocol GalleryRailViewDelegate: class { +// MARK: - GalleryRailViewDelegate + +public protocol GalleryRailViewDelegate: AnyObject { func galleryRailView(_ galleryRailView: GalleryRailView, didTapItem imageRailItem: GalleryRailItem) } +// MARK: - GalleryRailView + public class GalleryRailView: UIView, GalleryRailCellViewDelegate { + public enum ScrollFocusMode { + case keepCentered + case keepWithinBounds + } - public weak var delegate: GalleryRailViewDelegate? - + public var scrollFocusMode: ScrollFocusMode = .keepCentered public var cellViews: [GalleryRailCellView] = [] + public weak var delegate: GalleryRailViewDelegate? + + private var album: [GalleryRailItem]? + private var oldSize: CGSize = .zero var cellViewItems: [GalleryRailItem] { get { return cellViews.compactMap { $0.item } } } - // MARK: Initializers + // MARK: - Initializers override init(frame: CGRect) { super.init(frame: frame) + clipsToBounds = false + addSubview(scrollView) - scrollView.clipsToBounds = false - scrollView.layoutMargins = .zero scrollView.autoPinEdgesToSuperviewMargins() + + scrollView.addSubview(stackView) + stackView.autoPinEdgesToSuperviewEdges() + stackView.autoMatch(.height, to: .height, of: scrollView) } public required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + // MARK: - UI + + private let scrollView: UIScrollView = { + let result: UIScrollView = UIScrollView() + result.clipsToBounds = false + result.layoutMargins = .zero + result.isScrollEnabled = true + + return result + }() + + private let stackView: UIStackView = { + let result: UIStackView = UIStackView() + result.clipsToBounds = false + result.axis = .horizontal + result.spacing = 0 + + return result + }() - // MARK: Public + // MARK: - Public - public func configureCellViews(itemProvider: GalleryRailItemProvider?, focusedItem: GalleryRailItem?, cellViewBuilder: (GalleryRailItem) -> GalleryRailCellView) { + public func configureCellViews(album: [GalleryRailItem], focusedItem: GalleryRailItem?, cellViewBuilder: (GalleryRailItem) -> GalleryRailCellView) { let animationDuration: TimeInterval = 0.2 + let zippedItems = zip(album, self.cellViewItems) - guard let itemProvider = itemProvider else { - UIView.animate(withDuration: animationDuration) { - self.isHidden = true - } - self.cellViews = [] - return - } - - let areRailItemsIdentical = { (lhs: [GalleryRailItem], rhs: [GalleryRailItem]) -> Bool in - guard lhs.count == rhs.count else { - return false - } - for (index, element) in lhs.enumerated() { - guard element === rhs[index] else { - return false - } - } - return true - } - - if itemProvider === self.itemProvider, areRailItemsIdentical(itemProvider.railItems, self.cellViewItems) { + // Check if the album has changed + guard + album.count != self.cellViewItems.count || + zippedItems.contains(where: { lhs, rhs in !lhs.isEqual(to: rhs) }) + else { UIView.animate(withDuration: animationDuration) { self.updateFocusedItem(focusedItem) self.layoutIfNeeded() } - } - - self.itemProvider = itemProvider - - guard itemProvider.railItems.count > 1 else { - let cellViews = scrollView.subviews - - UIView.animate(withDuration: animationDuration, - animations: { - cellViews.forEach { $0.isHidden = true } - self.isHidden = true - }, - completion: { _ in cellViews.forEach { $0.removeFromSuperview() } }) - self.cellViews = [] return } - scrollView.subviews.forEach { $0.removeFromSuperview() } + // If so update to the new album + self.album = album - UIView.animate(withDuration: animationDuration) { - self.isHidden = false + // Check if there are multiple items in the album (if not then just slide it away) + guard album.count > 1 else { + let oldFrame: CGRect = self.stackView.frame + + UIView.animate( + withDuration: animationDuration, + animations: { [weak self] in + self?.stackView.frame = oldFrame.offsetBy( + dx: 0, + dy: oldFrame.height + ) + }, + completion: { [weak self] _ in + self?.stackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + self?.stackView.frame = oldFrame + self?.isHidden = true + self?.cellViews = [] + } + ) + return } + + // Otherwise slide it away, recreate it and then slide it back + var oldFrame: CGRect = self.stackView.frame + let newCellViews: [GalleryRailCellView] = buildCellViews( + items: album, + cellViewBuilder: cellViewBuilder + ) - let cellViews = buildCellViews(items: itemProvider.railItems, cellViewBuilder: cellViewBuilder) - self.cellViews = cellViews - let stackView = UIStackView(arrangedSubviews: cellViews) - stackView.axis = .horizontal - stackView.spacing = 0 - stackView.clipsToBounds = false - - scrollView.addSubview(stackView) - stackView.autoPinEdgesToSuperviewEdges() - stackView.autoMatch(.height, to: .height, of: scrollView) - - updateFocusedItem(focusedItem) + UIView.animate( + withDuration: (animationDuration / 2), + delay: 0, + options: .curveEaseIn, + animations: { [weak self] in + self?.stackView.frame = oldFrame.offsetBy( + dx: 0, + dy: oldFrame.height + ) + }, + completion: { [weak self] _ in + self?.stackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + newCellViews.forEach { cellView in + self?.stackView.addArrangedSubview(cellView) + } + self?.cellViews = newCellViews + + // Update the UI (need to re-offset it as the position gets reset during + // during these changes) + UIView.performWithoutAnimation { + self?.updateFocusedItem(focusedItem) + self?.isHidden = false + + oldFrame = (self?.stackView.frame) + .defaulting(to: oldFrame) + self?.stackView.frame = oldFrame.offsetBy( + dx: 0, + dy: oldFrame.height + ) + } + + UIView.animate( + withDuration: (animationDuration / 2), + delay: 0, + options: .curveEaseOut, + animations: { [weak self] in + self?.stackView.frame = oldFrame + }, + completion: nil + ) + } + ) } - // MARK: GalleryRailCellViewDelegate + // MARK: - GalleryRailCellViewDelegate func didTapGalleryRailCellView(_ galleryRailCellView: GalleryRailCellView) { - guard let item = galleryRailCellView.item else { - owsFailDebug("item was unexpectedly nil") - return - } + guard let item = galleryRailCellView.item else { return } delegate?.galleryRailView(self, didTapItem: item) } - // MARK: Subview Helpers - - private var itemProvider: GalleryRailItemProvider? - - private let scrollView: UIScrollView = { - let scrollView = UIScrollView() - scrollView.isScrollEnabled = true - return scrollView - }() + // MARK: - Subview Helpers + + public override func layoutSubviews() { + super.layoutSubviews() + + guard self.bounds.size != self.oldSize else { return } + + self.oldSize = self.bounds.size + + // If the bounds of the biew changed then update the focused item to ensure the + // alignment isn't broken + if let focusedItem: GalleryRailItem = self.cellViews.first(where: { $0.isSelected })?.item { + self.updateFocusedItem(focusedItem) + } + } private func buildCellViews(items: [GalleryRailItem], cellViewBuilder: (GalleryRailItem) -> GalleryRailCellView) -> [GalleryRailCellView] { return items.map { item in @@ -218,49 +284,42 @@ public class GalleryRailView: UIView, GalleryRailCellViewDelegate { } } - enum ScrollFocusMode { - case keepCentered, keepWithinBounds - } - var scrollFocusMode: ScrollFocusMode = .keepCentered func updateFocusedItem(_ focusedItem: GalleryRailItem?) { - var selectedCellView: GalleryRailCellView? - cellViews.forEach { cellView in - if cellView.item === focusedItem { - assert(selectedCellView == nil) - selectedCellView = cellView - cellView.setIsSelected(true) - } else { - cellView.setIsSelected(false) - } - } + let selectedCellView: GalleryRailCellView? = cellViews.first(where: { cellView -> Bool in + (cellView.item?.isEqual(to: focusedItem) == true) + }) + + cellViews.forEach { $0.setIsSelected(false) } + selectedCellView?.setIsSelected(true) self.layoutIfNeeded() + switch scrollFocusMode { - case .keepCentered: - guard let selectedCell = selectedCellView else { - owsFailDebug("selectedCell was unexpectedly nil") - return - } + case .keepCentered: + guard + let selectedCell: UIView = selectedCellView, + let selectedCellSuperview: UIView = selectedCell.superview + else { return } - let cellViewCenter = selectedCell.superview!.convert(selectedCell.center, to: scrollView) - let additionalInset = scrollView.center.x - cellViewCenter.x + let cellViewCenter: CGPoint = selectedCellSuperview.convert(selectedCell.center, to: scrollView) + let additionalInset: CGFloat = ((scrollView.frame.width / 2) - cellViewCenter.x) + + var inset: UIEdgeInsets = scrollView.contentInset + inset.left = additionalInset + scrollView.contentInset = inset - var inset = scrollView.contentInset - inset.left = additionalInset - scrollView.contentInset = inset + var offset: CGPoint = scrollView.contentOffset + offset.x = -additionalInset + scrollView.contentOffset = offset + + case .keepWithinBounds: + guard + let selectedCell: UIView = selectedCellView, + let selectedCellSuperview: UIView = selectedCell.superview + else { return } - var offset = scrollView.contentOffset - offset.x = -additionalInset - scrollView.contentOffset = offset - case .keepWithinBounds: - guard let selectedCell = selectedCellView else { - owsFailDebug("selectedCell was unexpectedly nil") - return - } - - let cellFrame = selectedCell.superview!.convert(selectedCell.frame, to: scrollView) - - scrollView.scrollRectToVisible(cellFrame, animated: true) + let cellFrame: CGRect = selectedCellSuperview.convert(selectedCell.frame, to: scrollView) + scrollView.scrollRectToVisible(cellFrame, animated: true) } } } diff --git a/SignalUtilitiesKit/To Do/OWSProfileManager.h b/SignalUtilitiesKit/To Do/OWSProfileManager.h index 75249838e..8f95bed3f 100644 --- a/SignalUtilitiesKit/To Do/OWSProfileManager.h +++ b/SignalUtilitiesKit/To Do/OWSProfileManager.h @@ -2,8 +2,6 @@ //// Copyright (c) 2018 Open Whisper Systems. All rights reserved. //// // -//#import -// //NS_ASSUME_NONNULL_BEGIN // //extern const NSUInteger kOWSProfileManager_NameDataLength; @@ -17,7 +15,7 @@ //@class YapDatabaseReadWriteTransaction; // //// This class can be safely accessed and used from any thread. -//@interface OWSProfileManager : NSObject +//@interface OWSProfileManager : NSObject // //- (instancetype)init NS_UNAVAILABLE; // diff --git a/SignalUtilitiesKit/Utilities/OWSError.h b/SignalUtilitiesKit/Utilities/OWSError.h index 79f763369..e4772fc17 100644 --- a/SignalUtilitiesKit/Utilities/OWSError.h +++ b/SignalUtilitiesKit/Utilities/OWSError.h @@ -25,7 +25,6 @@ typedef NS_ENUM(NSInteger, OWSErrorCode) { OWSErrorCodeSignalServiceFailure = 1001, OWSErrorCodeSignalServiceRateLimited = 1010, OWSErrorCodeUserError = 2001, - OWSErrorCodeNoSuchSignalRecipient = 777404, OWSErrorCodeMessageSendDisabledDueToPreKeyUpdateFailures = 777405, OWSErrorCodeMessageSendFailedToBlockList = 777406, OWSErrorCodeMessageSendNoValidRecipients = 777407, @@ -62,7 +61,6 @@ extern NSError *OWSErrorWithCodeDescription(OWSErrorCode code, NSString *descrip extern NSError *OWSErrorMakeUntrustedIdentityError(NSString *description, NSString *recipientId); extern NSError *OWSErrorMakeUnableToProcessServerResponseError(void); extern NSError *OWSErrorMakeFailedToSendOutgoingMessageError(void); -extern NSError *OWSErrorMakeNoSuchSignalRecipientError(void); extern NSError *OWSErrorMakeAssertionError(NSString *description); extern NSError *OWSErrorMakeMessageSendDisabledDueToPreKeyUpdateFailuresError(void); extern NSError *OWSErrorMakeMessageSendFailedDueToBlockListError(void); diff --git a/SignalUtilitiesKit/Utilities/OWSError.m b/SignalUtilitiesKit/Utilities/OWSError.m index b089f235d..2577bb11a 100644 --- a/SignalUtilitiesKit/Utilities/OWSError.m +++ b/SignalUtilitiesKit/Utilities/OWSError.m @@ -28,13 +28,6 @@ NSError *OWSErrorMakeFailedToSendOutgoingMessageError() NSLocalizedString(@"ERROR_DESCRIPTION_CLIENT_SENDING_FAILURE", @"Generic notice when message failed to send.")); } -NSError *OWSErrorMakeNoSuchSignalRecipientError() -{ - return OWSErrorWithCodeDescription(OWSErrorCodeNoSuchSignalRecipient, - NSLocalizedString( - @"ERROR_DESCRIPTION_UNREGISTERED_RECIPIENT", @"Error message when attempting to send message")); -} - NSError *OWSErrorMakeAssertionError(NSString *description) { OWSCFailDebug(@"Assertion failed: %@", description); diff --git a/SignalUtilitiesKit/Utilities/SignalAccount.h b/SignalUtilitiesKit/Utilities/SignalAccount.h deleted file mode 100644 index bbf29282d..000000000 --- a/SignalUtilitiesKit/Utilities/SignalAccount.h +++ /dev/null @@ -1,44 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@class Contact; -@class SignalRecipient; -@class YapDatabaseReadTransaction; - -// This class represents a single valid Signal account. -// -// * Contacts with multiple signal accounts will correspond to -// multiple instances of SignalAccount. -// * For non-contacts, the contact property will be nil. -@interface SignalAccount : TSYapDatabaseObject - -// An E164 value identifying the signal account. -// -// This is the key property of this class and it -// will always be non-null. -@property (nonatomic, readonly) NSString *recipientId; - -// This property is optional and will not be set for -// non-contact account. -@property (nonatomic, nullable) Contact *contact; - -@property (nonatomic) BOOL hasMultipleAccountContact; - -// For contacts with more than one signal account, -// this is a label for the account. -@property (nonatomic) NSString *multipleAccountLabelText; - -- (instancetype)init NS_UNAVAILABLE; - -- (instancetype)initWithSignalRecipient:(SignalRecipient *)signalRecipient; - -- (instancetype)initWithRecipientId:(NSString *)recipientId; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/SignalAccount.m b/SignalUtilitiesKit/Utilities/SignalAccount.m deleted file mode 100644 index 28c9ce906..000000000 --- a/SignalUtilitiesKit/Utilities/SignalAccount.m +++ /dev/null @@ -1,51 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "SignalAccount.h" - -#import "NSString+SSK.h" -#import "OWSPrimaryStorage.h" -#import "SignalRecipient.h" - -NS_ASSUME_NONNULL_BEGIN - -@interface SignalAccount () - -@property (nonatomic) NSString *recipientId; - -@end - -#pragma mark - - -@implementation SignalAccount - -- (instancetype)initWithSignalRecipient:(SignalRecipient *)signalRecipient -{ - OWSAssertDebug(signalRecipient); - return [self initWithRecipientId:signalRecipient.recipientId]; -} - -- (instancetype)initWithRecipientId:(NSString *)recipientId -{ - if (self = [super init]) { - OWSAssertDebug(recipientId.length > 0); - - _recipientId = recipientId; - } - return self; -} - -- (nullable NSString *)uniqueId -{ - return _recipientId; -} - -- (NSString *)multipleAccountLabelText -{ - return _multipleAccountLabelText.filterStringForDisplay; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/VersionMigrations.m b/SignalUtilitiesKit/Utilities/VersionMigrations.m index 70a121039..e0d0787b5 100644 --- a/SignalUtilitiesKit/Utilities/VersionMigrations.m +++ b/SignalUtilitiesKit/Utilities/VersionMigrations.m @@ -125,7 +125,6 @@ NS_ASSUME_NONNULL_BEGIN [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) { [transaction removeAllObjectsInCollection:@"TSRecipient"]; }]; - OWSLogInfo(@"Removed all TSRecipient records - will be replaced by SignalRecipients at next address sync."); } else { OWSLogError(@"Failed to remove bloom filter cache with error: %@", deleteError.localizedDescription); } From 9ada8b84e0dabee6baaaca987c89f03513940357 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Sat, 21 May 2022 12:25:10 +1000 Subject: [PATCH 082/157] Removed a bunch of legacy code Renamed the 'Legacy' classes to have their library prefix (avoid confusion) Removed the legacy Objective C Thread code (pulled only the NSCoding stuff into the 'SMKLegacy' type) --- Session.xcodeproj/project.pbxproj | 68 +--- .../OWSConversationSettingsViewController.m | 2 - .../OWSConversationSettingsViewDelegate.h | 1 - Session/Meta/AppDelegate.swift | 7 +- Session/Meta/Signal-Bridging-Header.h | 3 - .../PrivacySettingsTableViewController.m | 2 +- Session/Utilities/AvatarViewHelper.h | 1 - Session/Utilities/AvatarViewHelper.m | 3 - ...{SMKLegacyModels.swift => SMKLegacy.swift} | 201 +++++++--- .../Migrations/_003_YDBToGRDBMigration.swift | 356 +++++++++-------- .../Database/Models/SessionThread.swift | 9 +- .../Database/OWSPrimaryStorage.m | 1 - SessionMessagingKit/Database/TSDatabaseView.m | 1 - SessionMessagingKit/Messages/Message.swift | 2 +- .../Messages/Signal/TSIncomingMessage.m | 2 - .../Messages/Signal/TSInteraction.m | 2 - .../Messages/Signal/TSMessage.m | 2 - .../Messages/Signal/TSOutgoingMessage.m | 2 - .../Meta/SessionMessagingKit.h | 5 - .../Pollers/ClosedGroupPoller.swift | 2 +- .../Quotes/OWSQuotedReplyModel.m | 1 - .../Quotes/TSQuotedMessage.m | 1 - .../Threads/Notification+Thread.swift | 12 - SessionMessagingKit/Threads/TSContactThread.h | 31 -- SessionMessagingKit/Threads/TSContactThread.m | 127 ------ SessionMessagingKit/Threads/TSGroupModel.h | 43 -- SessionMessagingKit/Threads/TSGroupModel.m | 159 -------- SessionMessagingKit/Threads/TSGroupThread.h | 63 --- SessionMessagingKit/Threads/TSGroupThread.m | 283 ------------- SessionMessagingKit/Threads/TSThread.h | 137 ------- SessionMessagingKit/Threads/TSThread.m | 375 ------------------ .../Utilities/Preferences.swift | 5 +- ...{SSKLegacyModels.swift => SSKLegacy.swift} | 2 +- .../Migrations/_003_YDBToGRDBMigration.swift | 38 +- ...{SUKLegacyModels.swift => SUKLegacy.swift} | 4 +- .../Migrations/_003_YDBToGRDBMigration.swift | 28 +- .../Database/Models/Identity.swift | 14 - .../BlockingManagerRemovalMigration.swift | 15 +- .../Migrations/ContactsMigration.swift | 40 +- .../Migrations/MessageRequestsMigration.swift | 58 ++- .../OpenGroupServerIdLookupMigration.swift | 35 +- SignalUtilitiesKit/Utilities/ThreadUtil.m | 2 - .../Utilities/VersionMigrations.m | 2 - 43 files changed, 514 insertions(+), 1633 deletions(-) rename SessionMessagingKit/Database/LegacyDatabase/{SMKLegacyModels.swift => SMKLegacy.swift} (89%) delete mode 100644 SessionMessagingKit/Threads/Notification+Thread.swift delete mode 100644 SessionMessagingKit/Threads/TSContactThread.h delete mode 100644 SessionMessagingKit/Threads/TSContactThread.m delete mode 100644 SessionMessagingKit/Threads/TSGroupModel.h delete mode 100644 SessionMessagingKit/Threads/TSGroupModel.m delete mode 100644 SessionMessagingKit/Threads/TSGroupThread.h delete mode 100644 SessionMessagingKit/Threads/TSGroupThread.m delete mode 100644 SessionMessagingKit/Threads/TSThread.h delete mode 100644 SessionMessagingKit/Threads/TSThread.m rename SessionSnodeKit/Database/LegacyDatabase/{SSKLegacyModels.swift => SSKLegacy.swift} (99%) rename SessionUtilitiesKit/Database/LegacyDatabase/{SUKLegacyModels.swift => SUKLegacy.swift} (97%) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 23b9c3d8b..2f634819d 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -283,14 +283,6 @@ C32A026C25A801AF000ED5D4 /* NSData+messagePadding.h in Headers */ = {isa = PBXBuildFile; fileRef = C3A71D4E25589FF30043A11F /* NSData+messagePadding.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C598A256D0664003C73A2 /* SNProtoEnvelope+Conversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EEF09255B49A8007E1867 /* SNProtoEnvelope+Conversion.swift */; }; C32C599E256DB02B003C73A2 /* TypingIndicators.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA87255A57FC00E217F9 /* TypingIndicators.swift */; }; - C32C59C0256DB41F003C73A2 /* TSThread.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDAD3255A580300E217F9 /* TSThread.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C32C59C1256DB41F003C73A2 /* TSGroupThread.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDC01255A581C00E217F9 /* TSGroupThread.m */; }; - C32C59C2256DB41F003C73A2 /* TSContactThread.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDAB3255A580000E217F9 /* TSContactThread.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C32C59C3256DB41F003C73A2 /* TSGroupModel.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB73255A581000E217F9 /* TSGroupModel.m */; }; - C32C59C4256DB41F003C73A2 /* TSContactThread.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAF9255A580600E217F9 /* TSContactThread.m */; }; - C32C59C5256DB41F003C73A2 /* TSGroupModel.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB0A255A580700E217F9 /* TSGroupModel.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C32C59C6256DB41F003C73A2 /* TSGroupThread.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDA79255A57FB00E217F9 /* TSGroupThread.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C32C59C7256DB41F003C73A2 /* TSThread.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBB8255A581600E217F9 /* TSThread.m */; }; C32C5A13256DB7A5003C73A2 /* PushNotificationAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */; }; C32C5A24256DB7DB003C73A2 /* SNUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB6B255A580F00E217F9 /* SNUserDefaults.swift */; }; C32C5A2D256DB849003C73A2 /* LKGroupUtilities.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBE1255A581A00E217F9 /* LKGroupUtilities.m */; }; @@ -353,7 +345,6 @@ C32C5FAA256DFED9003C73A2 /* NSArray+Functional.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB5C255A580E00E217F9 /* NSArray+Functional.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5FBB256E0206003C73A2 /* OWSBackgroundTask.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDC1B255A581F00E217F9 /* OWSBackgroundTask.m */; }; C32C5FC4256E0209003C73A2 /* OWSBackgroundTask.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB38255A580B00E217F9 /* OWSBackgroundTask.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C32C5FD6256E0346003C73A2 /* Notification+Thread.swift in Sources */ = {isa = PBXBuildFile; fileRef = C32C5FD5256E0346003C73A2 /* Notification+Thread.swift */; }; C32C600F256E07F5003C73A2 /* NSUserDefaults+OWS.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB77255A581000E217F9 /* NSUserDefaults+OWS.m */; }; C32C6018256E07F9003C73A2 /* NSUserDefaults+OWS.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB51255A580D00E217F9 /* NSUserDefaults+OWS.h */; settings = {ATTRIBUTES = (Public, ); }; }; C33100082558FF6D00070591 /* NewConversationButtonSet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83F2B85240C7B8F000A54AB /* NewConversationButtonSet.swift */; }; @@ -709,12 +700,12 @@ FD09C5EA282A1BB2000CE219 /* ThreadTypingIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E9282A1BB2000CE219 /* ThreadTypingIndicator.swift */; }; FD09C5EC282B8F18000CE219 /* AttachmentError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5EB282B8F17000CE219 /* AttachmentError.swift */; }; FD17D79927F40AB800122BE0 /* _003_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79827F40AB800122BE0 /* _003_YDBToGRDBMigration.swift */; }; - FD17D79C27F40B2E00122BE0 /* SMKLegacyModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */; }; + FD17D79C27F40B2E00122BE0 /* SMKLegacy.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79B27F40B2E00122BE0 /* SMKLegacy.swift */; }; FD17D7A027F40CC800122BE0 /* _001_InitialSetupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79F27F40CC800122BE0 /* _001_InitialSetupMigration.swift */; }; FD17D7A127F40D2500122BE0 /* GRDBStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD28A4F527EAD44C00FF65E7 /* GRDBStorage.swift */; }; FD17D7A227F40F0500122BE0 /* _001_InitialSetupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79527F3E04600122BE0 /* _001_InitialSetupMigration.swift */; }; FD17D7A427F40F8100122BE0 /* _003_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7A327F40F8100122BE0 /* _003_YDBToGRDBMigration.swift */; }; - FD17D7A727F41AF000122BE0 /* SSKLegacyModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7A627F41AF000122BE0 /* SSKLegacyModels.swift */; }; + FD17D7A727F41AF000122BE0 /* SSKLegacy.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7A627F41AF000122BE0 /* SSKLegacy.swift */; }; FD17D7AA27F41BF500122BE0 /* SnodeSet.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7A927F41BF500122BE0 /* SnodeSet.swift */; }; FD17D7AE27F41C4300122BE0 /* SnodeReceivedMessageInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7AD27F41C4300122BE0 /* SnodeReceivedMessageInfo.swift */; }; FD17D7B027F4225C00122BE0 /* Set+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7AF27F4225C00122BE0 /* Set+Utilities.swift */; }; @@ -735,7 +726,7 @@ FD17D7E127F67BD400122BE0 /* SnodeReceivedMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E027F67BD400122BE0 /* SnodeReceivedMessage.swift */; }; FD17D7E527F6A09900122BE0 /* Identity.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E427F6A09900122BE0 /* Identity.swift */; }; FD17D7E727F6A16700122BE0 /* _003_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E627F6A16700122BE0 /* _003_YDBToGRDBMigration.swift */; }; - FD17D7EA27F6A1C600122BE0 /* SUKLegacyModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E927F6A1C600122BE0 /* SUKLegacyModels.swift */; }; + FD17D7EA27F6A1C600122BE0 /* SUKLegacy.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E927F6A1C600122BE0 /* SUKLegacy.swift */; }; FD1C98E4282E3C5B00B76F9E /* UINavigationBar+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */; }; FD28A4F227E990E800FF65E7 /* BlockingManagerRemovalMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD28A4F127E990E800FF65E7 /* BlockingManagerRemovalMigration.swift */; }; FD28A4F427EA79F800FF65E7 /* BlockListUIUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD28A4F327EA79F800FF65E7 /* BlockListUIUtils.swift */; }; @@ -1304,7 +1295,6 @@ C328255125CA64470062D0A7 /* ContextMenuVC+ActionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContextMenuVC+ActionView.swift"; sourceTree = ""; }; C32C5A87256DBCF9003C73A2 /* MessageReceiver+Handling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+Handling.swift"; sourceTree = ""; }; C32C5B3E256DC1DF003C73A2 /* TSQuotedMessage+Conversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TSQuotedMessage+Conversion.swift"; sourceTree = ""; }; - C32C5FD5256E0346003C73A2 /* Notification+Thread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Thread.swift"; sourceTree = ""; }; C33100132558FFC200070591 /* UIImage+Tinting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Tinting.swift"; sourceTree = ""; }; C33100272559000A00070591 /* UIView+Rendering.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Rendering.swift"; sourceTree = ""; }; C3310032255900A400070591 /* Notification+AppMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+AppMode.swift"; sourceTree = ""; }; @@ -1319,7 +1309,6 @@ C33FDA6F255A57FA00E217F9 /* ReachabilityManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReachabilityManager.swift; sourceTree = ""; }; C33FDA70255A57FA00E217F9 /* TSMessage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSMessage.h; sourceTree = ""; }; C33FDA73255A57FA00E217F9 /* ECKeyPair+Hexadecimal.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ECKeyPair+Hexadecimal.swift"; sourceTree = ""; }; - C33FDA79255A57FB00E217F9 /* TSGroupThread.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSGroupThread.h; sourceTree = ""; }; C33FDA7A255A57FB00E217F9 /* NSRegularExpression+SSK.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSRegularExpression+SSK.swift"; sourceTree = ""; }; C33FDA86255A57FC00E217F9 /* OWSDisappearingMessagesFinder.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSDisappearingMessagesFinder.m; sourceTree = ""; }; C33FDA87255A57FC00E217F9 /* TypingIndicators.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingIndicators.swift; sourceTree = ""; }; @@ -1336,7 +1325,6 @@ C33FDAA8255A57FF00E217F9 /* BuildConfiguration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BuildConfiguration.swift; sourceTree = ""; }; C33FDAAA255A580000E217F9 /* NSObject+Casting.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSObject+Casting.m"; sourceTree = ""; }; C33FDAB1255A580000E217F9 /* OWSStorage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSStorage.m; sourceTree = ""; }; - C33FDAB3255A580000E217F9 /* TSContactThread.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSContactThread.h; sourceTree = ""; }; C33FDAB8255A580100E217F9 /* NSArray+Functional.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSArray+Functional.m"; sourceTree = ""; }; C33FDAB9255A580100E217F9 /* OWSStorage+Subclass.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "OWSStorage+Subclass.h"; sourceTree = ""; }; C33FDABE255A580100E217F9 /* TSConstants.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSConstants.m; sourceTree = ""; }; @@ -1345,7 +1333,6 @@ C33FDAC2255A580200E217F9 /* TSAttachment.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSAttachment.m; sourceTree = ""; }; C33FDAC3255A580200E217F9 /* OWSDispatch.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSDispatch.m; sourceTree = ""; }; C33FDAC4255A580200E217F9 /* TSAttachmentStream.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSAttachmentStream.m; sourceTree = ""; }; - C33FDAD3255A580300E217F9 /* TSThread.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSThread.h; sourceTree = ""; }; C33FDAD5255A580300E217F9 /* TSQuotedMessage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSQuotedMessage.h; sourceTree = ""; }; C33FDADC255A580400E217F9 /* NSObject+Casting.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSObject+Casting.h"; sourceTree = ""; }; C33FDADD255A580400E217F9 /* TSInfoMessage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSInfoMessage.h; sourceTree = ""; }; @@ -1358,13 +1345,11 @@ C33FDAF1255A580500E217F9 /* OWSThumbnailService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSThumbnailService.swift; sourceTree = ""; }; C33FDAF2255A580500E217F9 /* ProxiedContentDownloader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProxiedContentDownloader.swift; sourceTree = ""; }; C33FDAF4255A580600E217F9 /* SSKEnvironment.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SSKEnvironment.m; sourceTree = ""; }; - C33FDAF9255A580600E217F9 /* TSContactThread.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSContactThread.m; sourceTree = ""; }; C33FDAFC255A580600E217F9 /* MIMETypeUtil.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MIMETypeUtil.h; sourceTree = ""; }; C33FDAFD255A580600E217F9 /* LRUCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LRUCache.swift; sourceTree = ""; }; C33FDAFE255A580600E217F9 /* OWSStorage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSStorage.h; sourceTree = ""; }; C33FDB01255A580700E217F9 /* AppReadiness.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppReadiness.h; sourceTree = ""; }; C33FDB07255A580700E217F9 /* OWSBackupFragment.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSBackupFragment.m; sourceTree = ""; }; - C33FDB0A255A580700E217F9 /* TSGroupModel.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSGroupModel.h; sourceTree = ""; }; C33FDB0D255A580800E217F9 /* NSArray+OWS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSArray+OWS.m"; sourceTree = ""; }; C33FDB12255A580800E217F9 /* NSString+SSK.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSString+SSK.h"; sourceTree = ""; }; C33FDB14255A580800E217F9 /* OWSMath.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSMath.h; sourceTree = ""; }; @@ -1403,7 +1388,6 @@ C33FDB69255A580F00E217F9 /* FeatureFlags.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeatureFlags.swift; sourceTree = ""; }; C33FDB6B255A580F00E217F9 /* SNUserDefaults.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SNUserDefaults.swift; sourceTree = ""; }; C33FDB6C255A580F00E217F9 /* NSNotificationCenter+OWS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSNotificationCenter+OWS.m"; sourceTree = ""; }; - C33FDB73255A581000E217F9 /* TSGroupModel.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSGroupModel.m; sourceTree = ""; }; C33FDB75255A581000E217F9 /* AppReadiness.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppReadiness.m; sourceTree = ""; }; C33FDB77255A581000E217F9 /* NSUserDefaults+OWS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSUserDefaults+OWS.m"; sourceTree = ""; }; C33FDB78255A581000E217F9 /* OWSOperation.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSOperation.m; sourceTree = ""; }; @@ -1424,7 +1408,6 @@ C33FDBAB255A581500E217F9 /* OWSFileSystem.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSFileSystem.h; sourceTree = ""; }; C33FDBB4255A581600E217F9 /* NSURLSessionDataTask+StatusCode.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSURLSessionDataTask+StatusCode.m"; sourceTree = ""; }; C33FDBB6255A581600E217F9 /* DataSource.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DataSource.m; sourceTree = ""; }; - C33FDBB8255A581600E217F9 /* TSThread.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSThread.m; sourceTree = ""; }; C33FDBBA255A581600E217F9 /* OWSPrimaryStorage+keyFromIntLong.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "OWSPrimaryStorage+keyFromIntLong.h"; sourceTree = ""; }; C33FDBBC255A581600E217F9 /* SSKKeychainStorage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SSKKeychainStorage.swift; sourceTree = ""; }; C33FDBC2255A581700E217F9 /* SSKAsserts.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SSKAsserts.h; sourceTree = ""; }; @@ -1439,7 +1422,6 @@ C33FDBF8255A581C00E217F9 /* NSArray+OWS.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSArray+OWS.h"; sourceTree = ""; }; C33FDBF9255A581C00E217F9 /* OWSError.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSError.h; sourceTree = ""; }; C33FDBFE255A581C00E217F9 /* NSSet+Functional.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSSet+Functional.h"; sourceTree = ""; }; - C33FDC01255A581C00E217F9 /* TSGroupThread.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSGroupThread.m; sourceTree = ""; }; C33FDC02255A581D00E217F9 /* OWSPrimaryStorage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSPrimaryStorage.m; sourceTree = ""; }; C33FDC03255A581D00E217F9 /* ByteParser.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ByteParser.h; sourceTree = ""; }; C33FDC05255A581D00E217F9 /* OWSDisappearingMessagesFinder.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSDisappearingMessagesFinder.h; sourceTree = ""; }; @@ -1756,10 +1738,10 @@ FD09C5EB282B8F17000CE219 /* AttachmentError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentError.swift; sourceTree = ""; }; FD17D79527F3E04600122BE0 /* _001_InitialSetupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _001_InitialSetupMigration.swift; sourceTree = ""; }; FD17D79827F40AB800122BE0 /* _003_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _003_YDBToGRDBMigration.swift; sourceTree = ""; }; - FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMKLegacyModels.swift; sourceTree = ""; }; + FD17D79B27F40B2E00122BE0 /* SMKLegacy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMKLegacy.swift; sourceTree = ""; }; FD17D79F27F40CC800122BE0 /* _001_InitialSetupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _001_InitialSetupMigration.swift; sourceTree = ""; }; FD17D7A327F40F8100122BE0 /* _003_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _003_YDBToGRDBMigration.swift; sourceTree = ""; }; - FD17D7A627F41AF000122BE0 /* SSKLegacyModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSKLegacyModels.swift; sourceTree = ""; }; + FD17D7A627F41AF000122BE0 /* SSKLegacy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSKLegacy.swift; sourceTree = ""; }; FD17D7A927F41BF500122BE0 /* SnodeSet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnodeSet.swift; sourceTree = ""; }; FD17D7AD27F41C4300122BE0 /* SnodeReceivedMessageInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnodeReceivedMessageInfo.swift; sourceTree = ""; }; FD17D7AF27F4225C00122BE0 /* Set+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Set+Utilities.swift"; sourceTree = ""; }; @@ -1780,7 +1762,7 @@ FD17D7E027F67BD400122BE0 /* SnodeReceivedMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnodeReceivedMessage.swift; sourceTree = ""; }; FD17D7E427F6A09900122BE0 /* Identity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Identity.swift; sourceTree = ""; }; FD17D7E627F6A16700122BE0 /* _003_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _003_YDBToGRDBMigration.swift; sourceTree = ""; }; - FD17D7E927F6A1C600122BE0 /* SUKLegacyModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SUKLegacyModels.swift; sourceTree = ""; }; + FD17D7E927F6A1C600122BE0 /* SUKLegacy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SUKLegacy.swift; sourceTree = ""; }; FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationBar+Utilities.swift"; sourceTree = ""; }; FD28A4F127E990E800FF65E7 /* BlockingManagerRemovalMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockingManagerRemovalMigration.swift; sourceTree = ""; }; FD28A4F327EA79F800FF65E7 /* BlockListUIUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockListUIUtils.swift; sourceTree = ""; }; @@ -2573,22 +2555,6 @@ path = "Typing Indicators"; sourceTree = ""; }; - C32C59AF256DB31A003C73A2 /* Threads */ = { - isa = PBXGroup; - children = ( - C32C5FD5256E0346003C73A2 /* Notification+Thread.swift */, - C33FDAB3255A580000E217F9 /* TSContactThread.h */, - C33FDAF9255A580600E217F9 /* TSContactThread.m */, - C33FDB0A255A580700E217F9 /* TSGroupModel.h */, - C33FDB73255A581000E217F9 /* TSGroupModel.m */, - C33FDA79255A57FB00E217F9 /* TSGroupThread.h */, - C33FDC01255A581C00E217F9 /* TSGroupThread.m */, - C33FDAD3255A580300E217F9 /* TSThread.h */, - C33FDBB8255A581600E217F9 /* TSThread.m */, - ); - path = Threads; - sourceTree = ""; - }; C32C59F8256DB5A6003C73A2 /* Pollers */ = { isa = PBXGroup; children = ( @@ -3234,7 +3200,6 @@ C3BBE07F2554CDD70050F1E3 /* Storage.swift */, C32C5BCB256DC818003C73A2 /* Database */, C300A5BB2554AFFB00555489 /* Messages */, - C32C59AF256DB31A003C73A2 /* Threads */, C300A5F02554B08500555489 /* Sending & Receiving */, C352A2F325574B3300338F3E /* Jobs */, C3A7215C2558C0AC0043A11F /* File Server */, @@ -3564,7 +3529,7 @@ FD17D79A27F40ADA00122BE0 /* LegacyDatabase */ = { isa = PBXGroup; children = ( - FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */, + FD17D79B27F40B2E00122BE0 /* SMKLegacy.swift */, ); path = LegacyDatabase; sourceTree = ""; @@ -3593,7 +3558,7 @@ FD17D7A527F41ADE00122BE0 /* LegacyDatabase */ = { isa = PBXGroup; children = ( - FD17D7A627F41AF000122BE0 /* SSKLegacyModels.swift */, + FD17D7A627F41AF000122BE0 /* SSKLegacy.swift */, ); path = LegacyDatabase; sourceTree = ""; @@ -3684,7 +3649,7 @@ FD17D7E827F6A1B800122BE0 /* LegacyDatabase */ = { isa = PBXGroup; children = ( - FD17D7E927F6A1C600122BE0 /* SUKLegacyModels.swift */, + FD17D7E927F6A1C600122BE0 /* SUKLegacy.swift */, ); path = LegacyDatabase; sourceTree = ""; @@ -3860,18 +3825,14 @@ C32C5EE5256DF506003C73A2 /* YapDatabaseConnection+OWS.h in Headers */, C32C5AAF256DBE8F003C73A2 /* TSMessage.h in Headers */, C32C5B8D256DC565003C73A2 /* SSKEnvironment.h in Headers */, - C32C59C6256DB41F003C73A2 /* TSGroupThread.h in Headers */, C3C2A6F425539DE700C340D1 /* SessionMessagingKit.h in Headers */, - C32C59C2256DB41F003C73A2 /* TSContactThread.h in Headers */, C32C5B2D256DC1A1003C73A2 /* TSQuotedMessage.h in Headers */, - C32C59C5256DB41F003C73A2 /* TSGroupModel.h in Headers */, C32C5C46256DCBB2003C73A2 /* AppReadiness.h in Headers */, C3D9E487256775D20040E4F3 /* TSAttachmentStream.h in Headers */, B8856CB1256F0F47001CE70E /* OWSBackupFragment.h in Headers */, C3A3A122256E1A97004D228D /* OWSDisappearingMessagesFinder.h in Headers */, C3A3A12B256E1AD5004D228D /* TSDatabaseSecondaryIndexes.h in Headers */, C32C5FC4256E0209003C73A2 /* OWSBackgroundTask.h in Headers */, - C32C59C0256DB41F003C73A2 /* TSThread.h in Headers */, C32C5AB3256DBE8F003C73A2 /* TSInteraction.h in Headers */, C3D9E486256775D20040E4F3 /* TSAttachmentPointer.h in Headers */, C32C5EF7256DF567003C73A2 /* TSDatabaseView.h in Headers */, @@ -4716,7 +4677,7 @@ C3C2A5DE2553860B00C340D1 /* String+Trimming.swift in Sources */, C3C2A5DB2553860B00C340D1 /* Promise+Hashing.swift in Sources */, C3C2A5E42553860B00C340D1 /* Data+Utilities.swift in Sources */, - FD17D7A727F41AF000122BE0 /* SSKLegacyModels.swift in Sources */, + FD17D7A727F41AF000122BE0 /* SSKLegacy.swift in Sources */, C3C2A5C2255385EE00C340D1 /* Configuration.swift in Sources */, FD17D7D827F658E200122BE0 /* SSKDestination.swift in Sources */, FD6A7A6D2818C61500035AC1 /* _002_SetupStandardJobs.swift in Sources */, @@ -4742,7 +4703,7 @@ C3D9E39B256763C20040E4F3 /* AppContext.m in Sources */, C3BBE0A82554D4DE0050F1E3 /* JSON.swift in Sources */, FD17D7C127F5200100122BE0 /* TypedTableDefinition.swift in Sources */, - FD17D7EA27F6A1C600122BE0 /* SUKLegacyModels.swift in Sources */, + FD17D7EA27F6A1C600122BE0 /* SUKLegacy.swift in Sources */, FDA8EB10280F8238002B68E5 /* Codable+Utilities.swift in Sources */, C352A36D2557858E00338F3E /* NSTimer+Proxying.m in Sources */, FD09797B27FBB25900936362 /* Updatable.swift in Sources */, @@ -4862,7 +4823,6 @@ FD09798927FD1C5A00936362 /* OpenGroup.swift in Sources */, C32C5CA4256DD1DC003C73A2 /* TSAccountManager.m in Sources */, C3BBE0B52554F0E10050F1E3 /* ProofOfWork.swift in Sources */, - C32C59C1256DB41F003C73A2 /* TSGroupThread.m in Sources */, C3A3A08F256E1728004D228D /* FullTextSearchFinder.swift in Sources */, FDF0B7472804F0CE004C14C5 /* DisappearingMessagesJob.swift in Sources */, B8856D1A256F114D001CE70E /* ProximityMonitoringManager.swift in Sources */, @@ -4883,7 +4843,6 @@ FD09798727FD1B7800936362 /* GroupMember.swift in Sources */, FD09799127FD499200936362 /* BoxKeyPair+Utilities.swift in Sources */, FDF0B7552807C4BB004C14C5 /* SSKEnvironment.swift in Sources */, - C32C59C3256DB41F003C73A2 /* TSGroupModel.m in Sources */, B8856ECE256F1E58001CE70E /* OWSPreferences.m in Sources */, FD09C5EC282B8F18000CE219 /* AttachmentError.swift in Sources */, FD17D79927F40AB800122BE0 /* _003_YDBToGRDBMigration.swift in Sources */, @@ -4966,17 +4925,14 @@ C352A35B2557824E00338F3E /* AttachmentUploadJob.swift in Sources */, FD09797027FA6FF300936362 /* Profile.swift in Sources */, C32C5AAE256DBE8F003C73A2 /* TSInteraction.m in Sources */, - C32C5FD6256E0346003C73A2 /* Notification+Thread.swift in Sources */, FD09798B27FD1CFE00936362 /* Capability.swift in Sources */, C3BBE0C72554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift in Sources */, C32C5C88256DD0D2003C73A2 /* Storage+Messaging.swift in Sources */, - FD17D79C27F40B2E00122BE0 /* SMKLegacyModels.swift in Sources */, - C32C59C7256DB41F003C73A2 /* TSThread.m in Sources */, + FD17D79C27F40B2E00122BE0 /* SMKLegacy.swift in Sources */, C300A5B22554AF9800555489 /* VisibleMessage+Profile.swift in Sources */, FD09798127FCFEE800936362 /* SessionThread.swift in Sources */, C32C5A75256DBBCF003C73A2 /* TSAttachmentPointer+Conversion.swift in Sources */, C32C5EBA256DE130003C73A2 /* OWSQuotedReplyModel.m in Sources */, - C32C59C4256DB41F003C73A2 /* TSContactThread.m in Sources */, FD09C5EA282A1BB2000CE219 /* ThreadTypingIndicator.swift in Sources */, FDF0B75E280AAF35004C14C5 /* Preferences.swift in Sources */, C32C5AB0256DBE8F003C73A2 /* TSOutgoingMessage.m in Sources */, diff --git a/Session/Conversations/Settings/OWSConversationSettingsViewController.m b/Session/Conversations/Settings/OWSConversationSettingsViewController.m index f2bc3be9e..b4c818942 100644 --- a/Session/Conversations/Settings/OWSConversationSettingsViewController.m +++ b/Session/Conversations/Settings/OWSConversationSettingsViewController.m @@ -13,9 +13,7 @@ #import #import #import -#import #import -#import @import ContactsUI; @import PromiseKit; diff --git a/Session/Conversations/Settings/OWSConversationSettingsViewDelegate.h b/Session/Conversations/Settings/OWSConversationSettingsViewDelegate.h index 6e421cfa4..b3e432abc 100644 --- a/Session/Conversations/Settings/OWSConversationSettingsViewDelegate.h +++ b/Session/Conversations/Settings/OWSConversationSettingsViewDelegate.h @@ -9,7 +9,6 @@ NS_ASSUME_NONNULL_BEGIN @protocol OWSConversationSettingsViewDelegate -- (void)groupWasUpdated:(TSGroupModel *)groupModel; - (void)conversationSettingsDidRequestConversationSearch:(OWSConversationSettingsViewController *)conversationSettingsViewController; - (void)popAllConversationSettingsViewsWithCompletion:(void (^_Nullable)(void))completionBlock; diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index e38c479e8..47d2e3805 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -395,8 +395,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD PushNotificationAPI.unregister(data).retainUntilComplete() } - ThreadUtil.deleteAllContent() - Identity.clearAll() + GRDBStorage.shared.write { db in + _ = try SessionThread.deleteAll(db) + _ = try Identity.deleteAll(db) + } + SnodeAPI.clearSnodePool() stopPollers() diff --git a/Session/Meta/Signal-Bridging-Header.h b/Session/Meta/Signal-Bridging-Header.h index d77037259..28e533d98 100644 --- a/Session/Meta/Signal-Bridging-Header.h +++ b/Session/Meta/Signal-Bridging-Header.h @@ -62,12 +62,9 @@ #import #import #import -#import -#import #import #import #import -#import #import #import #import diff --git a/Session/Settings/PrivacySettingsTableViewController.m b/Session/Settings/PrivacySettingsTableViewController.m index fe7ea6cf2..b0bd17de9 100644 --- a/Session/Settings/PrivacySettingsTableViewController.m +++ b/Session/Settings/PrivacySettingsTableViewController.m @@ -212,7 +212,7 @@ static NSString *const kSealedSenderInfoURL = @"https://signal.org/blog/sealed-s - (void)deleteThreadsAndMessages { - [ThreadUtil deleteAllContent]; + [SMKThread deleteAll]; } - (void)didToggleScreenSecuritySwitch:(UISwitch *)sender diff --git a/Session/Utilities/AvatarViewHelper.h b/Session/Utilities/AvatarViewHelper.h index dcd86c983..e6a0e8e06 100644 --- a/Session/Utilities/AvatarViewHelper.h +++ b/Session/Utilities/AvatarViewHelper.h @@ -8,7 +8,6 @@ NS_ASSUME_NONNULL_BEGIN @class AvatarViewHelper; @class OWSContactsManager; -@class TSThread; @protocol AvatarViewHelperDelegate diff --git a/Session/Utilities/AvatarViewHelper.m b/Session/Utilities/AvatarViewHelper.m index 178f0a05f..db7d36938 100644 --- a/Session/Utilities/AvatarViewHelper.m +++ b/Session/Utilities/AvatarViewHelper.m @@ -9,9 +9,6 @@ #import -#import -#import -#import #import NS_ASSUME_NONNULL_BEGIN diff --git a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift similarity index 89% rename from SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift rename to SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift index 1a72c9bfd..eb2a5970c 100644 --- a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift +++ b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift @@ -7,9 +7,7 @@ import YapDatabase import SignalCoreKit import SessionUtilitiesKit -public typealias SMKLegacy = Legacy - -public enum Legacy { +public enum SMKLegacy { // MARK: - Collections and Keys internal static let contactThreadPrefix = "c" @@ -18,7 +16,7 @@ public enum Legacy { internal static let closedGroupKeyPairPrefix = "SNClosedGroupEncryptionKeyPairCollection-" public static let contactCollection = "LokiContactCollection" - internal static let threadCollection = "TSThread" + public static let threadCollection = "TSThread" internal static let disappearingMessagesCollection = "OWSDisappearingMessagesConfiguration" internal static let closedGroupPublicKeyCollection = "SNClosedGroupPublicKeyCollection" @@ -32,6 +30,7 @@ public enum Legacy { internal static let openGroupLastDeletionServerIDCollection = "SNLastDeletionServerIDCollection" internal static let openGroupServerIdToUniqueIdLookupCollection = "SNOpenGroupServerIdToUniqueIdLookup" + public static let messageDatabaseViewExtensionName = "TSMessageDatabaseViewExtensionName_Monotonic" internal static let interactionCollection = "TSInteraction" internal static let attachmentsCollection = "TSAttachements" // Note: This is how it was previously spelt internal static let outgoingReadReceiptManagerCollection = "kOutgoingReadReceiptManagerCollection" @@ -54,6 +53,7 @@ public enum Legacy { internal static let preferencesKeyNotificationPreviewType = "preferencesKeyNotificationPreviewType" internal static let preferencesKeyNotificationSoundInForeground = "NotificationSoundInForeground" internal static let preferencesKeyHasSavedThreadKey = "hasSavedThread" + internal static let preferencesKeyHasSentAMessageKey = "User has sent a message" internal static let readReceiptManagerCollection = "OWSReadReceiptManagerCollection" internal static let readReceiptManagerAreReadReceiptsEnabled = "areReadReceiptsEnabled" @@ -74,18 +74,18 @@ public enum Legacy { @objc(SNContact) public class _Contact: NSObject, NSCoding { - @objc public let sessionID: String - @objc public var profilePictureURL: String? - @objc public var profilePictureFileName: String? - @objc public var profileEncryptionKey: OWSAES256Key? - @objc public var threadID: String? - @objc public var isTrusted = false - @objc public var isApproved = false - @objc public var isBlocked = false - @objc public var didApproveMe = false - @objc public var hasBeenBlocked = false - @objc public var name: String? - @objc public var nickname: String? + public let sessionID: String + public var profilePictureURL: String? + public var profilePictureFileName: String? + public var profileEncryptionKey: OWSAES256Key? + public var threadID: String? + public var isTrusted = false + public var isApproved = false + public var isBlocked = false + public var didApproveMe = false + public var hasBeenBlocked = false + public var name: String? + public var nickname: String? // MARK: Coding @@ -114,9 +114,9 @@ public enum Legacy { @objc(OWSDisappearingMessagesConfiguration) internal class _DisappearingMessagesConfiguration: MTLModel { - @objc public let uniqueId: String - @objc public var isEnabled: Bool - @objc public var durationSeconds: UInt32 + public let uniqueId: String + public var isEnabled: Bool + public var durationSeconds: UInt32 // MARK: - NSCoder @@ -829,27 +829,139 @@ public enum Legacy { ) } } + + // MARK: - Threads + + @objc(TSThread) + public class _Thread: NSObject, NSCoding { + public var uniqueId: String + public var creationDate: Date + public var shouldBeVisible: Bool + public var isPinned: Bool + public var mutedUntilDate: Date? + public var messageDraft: String? + + // MARK: - Convenience + + open var isClosedGroup: Bool { false } + open var isOpenGroup: Bool { false } + + // MARK: - NSCoder + + public required init(coder: NSCoder) { + self.uniqueId = coder.decodeObject(forKey: "uniqueId") as! String + self.creationDate = coder.decodeObject(forKey: "creationDate") as! Date + + // Legacy version of 'shouldBeVisible' + if let hasEverHadMessage: Bool = (coder.decodeObject(forKey: "hasEverHadMessage") as? NSNumber)?.boolValue { + self.shouldBeVisible = hasEverHadMessage + } + else { + self.shouldBeVisible = ((coder.decodeObject(forKey: "shouldBeVisible") as? NSNumber)? + .boolValue) + .defaulting(to: false) + } + + self.isPinned = ((coder.decodeObject(forKey: "isPinned") as? NSNumber)? + .boolValue) + .defaulting(to: false) + self.mutedUntilDate = coder.decodeObject(forKey: "mutedUntilDate") as? Date + self.messageDraft = coder.decodeObject(forKey: "messageDraft") as? String // TODO: Test this + } + + public func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") + } + } + + @objc(TSContactThread) + public class _ContactThread: _Thread { + // MARK: - NSCoder + + public required init(coder: NSCoder) { + super.init(coder: coder) + } + + // MARK: - Functions + + internal static func threadId(from sessionId: String) -> String { + return "\(SMKLegacy.contactThreadPrefix)\(sessionId)" + } + + public static func contactSessionId(fromThreadId threadId: String) -> String { + return String(threadId.substring(from: SMKLegacy.contactThreadPrefix.count)) + } + } + + @objc(TSGroupThread) + public class _GroupThread: _Thread { + public var groupModel: _GroupModel + public var isOnlyNotifyingForMentions: Bool + + // MARK: - Convenience + + public override var isClosedGroup: Bool { (groupModel.groupType == .closedGroup) } + public override var isOpenGroup: Bool { (groupModel.groupType == .openGroup) } + + // MARK: - NSCoder + + public required init(coder: NSCoder) { + self.groupModel = coder.decodeObject(forKey: "groupModel") as! _GroupModel + self.isOnlyNotifyingForMentions = ((coder.decodeObject(forKey: "isOnlyNotifyingForMentions") as? NSNumber)? + .boolValue) + .defaulting(to: false) + + super.init(coder: coder) + } + } + + @objc(TSGroupModel) + public class _GroupModel: NSObject, NSCoding { + public enum _GroupType: Int { + case closedGroup + case openGroup + } + + public var groupId: Data + public var groupType: _GroupType + public var groupName: String? + public var groupMemberIds: [String] + public var groupAdminIds: [String] + + // MARK: - NSCoder + + public required init(coder: NSCoder) { + self.groupId = coder.decodeObject(forKey: "groupId") as! Data + self.groupType = _GroupType(rawValue: coder.decodeObject(forKey: "groupType") as! Int)! + self.groupName = ((coder.decodeObject(forKey: "groupName") as? String) ?? "Group") + self.groupMemberIds = coder.decodeObject(forKey: "groupMemberIds") as! [String] + self.groupAdminIds = coder.decodeObject(forKey: "groupAdminIds") as! [String] + } + + public func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") + } + } // MARK: - Attachments @objc(TSAttachment) internal class _Attachment: NSObject, NSCoding { - @objc(TSAttachmentType) public enum _AttachmentType: Int { case `default` case voiceMessage } - @objc public var serverId: UInt64 - @objc public var encryptionKey: Data? - @objc public var contentType: String - @objc public var isDownloaded: Bool - @objc public var attachmentType: _AttachmentType - @objc public var downloadURL: String - @objc public var byteCount: UInt32 - @objc public var sourceFilename: String? - @objc public var caption: String? - @objc public var albumMessageId: String? + public var serverId: UInt64 + public var encryptionKey: Data? + public var contentType: String + public var isDownloaded: Bool + public var attachmentType: _AttachmentType + public var downloadURL: String + public var byteCount: UInt32 + public var sourceFilename: String? + public var caption: String? + public var albumMessageId: String? public var isImage: Bool { return MIMETypeUtil.isImage(contentType) } public var isVideo: Bool { return MIMETypeUtil.isVideo(contentType) } @@ -879,18 +991,17 @@ public enum Legacy { @objc(TSAttachmentPointer) internal class _AttachmentPointer: _Attachment { - @objc(TSAttachmentPointerState) public enum _State: Int { case enqueued case downloading case failed } - @objc public var state: _State - @objc public var mostRecentFailureLocalizedText: String? - @objc public var digest: Data? - @objc public var mediaSize: CGSize - @objc public var lazyRestoreFragmentId: String? + public var state: _State + public var mostRecentFailureLocalizedText: String? + public var digest: Data? + public var mediaSize: CGSize + public var lazyRestoreFragmentId: String? // MARK: - NSCoder @@ -913,15 +1024,15 @@ public enum Legacy { @objc(TSAttachmentStream) internal class _AttachmentStream: _Attachment { - @objc public var digest: Data? - @objc public var isUploaded: Bool - @objc public var creationTimestamp: Date - @objc public var localRelativeFilePath: String? - @objc public var cachedImageWidth: NSNumber? - @objc public var cachedImageHeight: NSNumber? - @objc public var cachedAudioDurationSeconds: NSNumber? - @objc public var isValidImageCached: NSNumber? - @objc public var isValidVideoCached: NSNumber? + public var digest: Data? + public var isUploaded: Bool + public var creationTimestamp: Date + public var localRelativeFilePath: String? + public var cachedImageWidth: NSNumber? + public var cachedImageHeight: NSNumber? + public var cachedAudioDurationSeconds: NSNumber? + public var isValidImageCached: NSNumber? + public var isValidVideoCached: NSNumber? public var isValidImage: Bool { return (isValidImageCached?.boolValue == true) } public var isValidVideo: Bool { return (isValidVideoCached?.boolValue == true) } diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 601e6fb26..b8268d45d 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -16,18 +16,18 @@ enum _003_YDBToGRDBMigration: Migration { // MARK: - Process Contacts, Threads & Interactions print("RAWR [\(Date().timeIntervalSince1970)] - SessionMessagingKit migration - Start") var shouldFailMigration: Bool = false - var contacts: Set = [] + var contacts: Set = [] var validProfileIds: Set = [] var contactThreadIds: Set = [] var legacyThreadIdToIdMap: [String: String] = [:] - var threads: Set = [] - var disappearingMessagesConfiguration: [String: Legacy._DisappearingMessagesConfiguration] = [:] + var legacyThreads: Set = [] + var disappearingMessagesConfiguration: [String: SMKLegacy._DisappearingMessagesConfiguration] = [:] var closedGroupKeys: [String: [TimeInterval: SUKLegacy.KeyPair]] = [:] var closedGroupName: [String: String] = [:] var closedGroupFormation: [String: UInt64] = [:] - var closedGroupModel: [String: TSGroupModel] = [:] + var closedGroupModel: [String: SMKLegacy._GroupModel] = [:] var closedGroupZombieMemberIds: [String: Set] = [:] var openGroupInfo: [String: OpenGroupV2] = [:] @@ -38,37 +38,53 @@ enum _003_YDBToGRDBMigration: Migration { // var openGroupServerToUniqueIdLookup: [String: [String]] = [:] // TODO: Not needed???? var interactions: [String: [TSInteraction]] = [:] - var attachments: [String: Legacy._Attachment] = [:] + var attachments: [String: SMKLegacy._Attachment] = [:] var processedAttachmentIds: Set = [] var outgoingReadReceiptsTimestampsMs: [String: Set] = [:] var receivedMessageTimestamps: Set = [] // Map the Legacy types for the NSKeyedUnarchiver NSKeyedUnarchiver.setClass( - Legacy._Contact.self, + SMKLegacy._Thread.self, + forClassName: "TSThread" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._ContactThread.self, + forClassName: "TSContactThread" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._GroupThread.self, + forClassName: "TSGroupThread" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._GroupModel.self, + forClassName: "TSGroupModel" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._Contact.self, forClassName: "SNContact" ) NSKeyedUnarchiver.setClass( - Legacy._Attachment.self, + SMKLegacy._Attachment.self, forClassName: "TSAttachment" ) NSKeyedUnarchiver.setClass( - Legacy._AttachmentStream.self, + SMKLegacy._AttachmentStream.self, forClassName: "TSAttachmentStream" ) NSKeyedUnarchiver.setClass( - Legacy._AttachmentPointer.self, + SMKLegacy._AttachmentPointer.self, forClassName: "TSAttachmentPointer" ) NSKeyedUnarchiver.setClass( - Legacy._DisappearingConfigurationUpdateInfoMessage.self, + SMKLegacy._DisappearingConfigurationUpdateInfoMessage.self, forClassName: "OWSDisappearingConfigurationUpdateInfoMessage" ) Storage.read { transaction in // Process the Contacts - transaction.enumerateRows(inCollection: Legacy.contactCollection) { _, object, _, _ in - guard let contact = object as? Legacy._Contact else { return } + transaction.enumerateRows(inCollection: SMKLegacy.contactCollection) { _, object, _, _ in + guard let contact = object as? SMKLegacy._Contact else { return } contacts.insert(contact) validProfileIds.insert(contact.sessionID) } @@ -76,26 +92,27 @@ enum _003_YDBToGRDBMigration: Migration { print("RAWR [\(Date().timeIntervalSince1970)] - Process threads - Start") // Process the threads - transaction.enumerateKeysAndObjects(inCollection: Legacy.threadCollection) { key, object, _ in - guard let thread: TSThread = object as? TSThread else { return } - guard let threadId: String = thread.uniqueId else { return } + transaction.enumerateKeysAndObjects(inCollection: SMKLegacy.threadCollection) { key, object, _ in + guard let thread: SMKLegacy._Thread = object as? SMKLegacy._Thread else { return } - threads.insert(thread) + legacyThreads.insert(thread) // Want to exclude threads which aren't visible (ie. threads which we started // but the user never ended up sending a message) - if key.starts(with: Legacy.contactThreadPrefix) && thread.shouldBeVisible { + if key.starts(with: SMKLegacy.contactThreadPrefix) && thread.shouldBeVisible { contactThreadIds.insert(key) } // Get the disappearing messages config - disappearingMessagesConfiguration[threadId] = transaction - .object(forKey: threadId, inCollection: Legacy.disappearingMessagesCollection) - .asType(Legacy._DisappearingMessagesConfiguration.self) + disappearingMessagesConfiguration[thread.uniqueId] = transaction + .object(forKey: thread.uniqueId, inCollection: SMKLegacy.disappearingMessagesCollection) + .asType(SMKLegacy._DisappearingMessagesConfiguration.self) // Process group-specific info - guard let groupThread: TSGroupThread = thread as? TSGroupThread else { - legacyThreadIdToIdMap[threadId] = threadId.substring(from: Legacy.contactThreadPrefix.count) + guard let groupThread: SMKLegacy._GroupThread = thread as? SMKLegacy._GroupThread else { + legacyThreadIdToIdMap[thread.uniqueId] = thread.uniqueId.substring( + from: SMKLegacy.contactThreadPrefix.count + ) return } @@ -104,7 +121,7 @@ enum _003_YDBToGRDBMigration: Migration { // really need the unnecessary complexity so process the key and extract // the publicKey from it // `g{base64String(Data(__textsecure_group__!{publicKey}))} - let base64GroupId: String = String(threadId.suffix(from: threadId.index(after: threadId.startIndex))) + let base64GroupId: String = String(thread.uniqueId.suffix(from: thread.uniqueId.index(after: thread.uniqueId.startIndex))) guard let groupIdData: Data = Data(base64Encoded: base64GroupId), let groupId: String = String(data: groupIdData, encoding: .utf8), @@ -115,18 +132,18 @@ enum _003_YDBToGRDBMigration: Migration { return } - legacyThreadIdToIdMap[threadId] = publicKey - closedGroupName[threadId] = groupThread.name(with: transaction) - closedGroupModel[threadId] = groupThread.groupModel - closedGroupFormation[threadId] = ((transaction.object(forKey: publicKey, inCollection: Legacy.closedGroupFormationTimestampCollection) as? UInt64) ?? 0) - closedGroupZombieMemberIds[threadId] = transaction.object( + legacyThreadIdToIdMap[thread.uniqueId] = publicKey + closedGroupName[thread.uniqueId] = groupThread.groupModel.groupName + closedGroupModel[thread.uniqueId] = groupThread.groupModel + closedGroupFormation[thread.uniqueId] = ((transaction.object(forKey: publicKey, inCollection: SMKLegacy.closedGroupFormationTimestampCollection) as? UInt64) ?? 0) + closedGroupZombieMemberIds[thread.uniqueId] = transaction.object( forKey: publicKey, - inCollection: Legacy.closedGroupZombieMembersCollection + inCollection: SMKLegacy.closedGroupZombieMembersCollection ) as? Set // Note: If the user is no longer in a closed group then the group will still exist but the user // won't have the closed group public key anymore - let keyCollection: String = "\(Legacy.closedGroupKeyPairPrefix)\(publicKey)" + let keyCollection: String = "\(SMKLegacy.closedGroupKeyPairPrefix)\(publicKey)" transaction.enumerateKeysAndObjects(inCollection: keyCollection) { key, object, _ in guard @@ -134,33 +151,33 @@ enum _003_YDBToGRDBMigration: Migration { let keyPair: SUKLegacy.KeyPair = object as? SUKLegacy.KeyPair else { return } - closedGroupKeys[threadId] = (closedGroupKeys[threadId] ?? [:]) + closedGroupKeys[thread.uniqueId] = (closedGroupKeys[thread.uniqueId] ?? [:]) .setting(timestamp, keyPair) } } else if groupThread.isOpenGroup { - guard let openGroup: OpenGroupV2 = transaction.object(forKey: threadId, inCollection: Legacy.openGroupCollection) as? OpenGroupV2 else { + guard let openGroup: OpenGroupV2 = transaction.object(forKey: thread.uniqueId, inCollection: SMKLegacy.openGroupCollection) as? OpenGroupV2 else { SNLog("[Migration Error] Unable to find open group info") shouldFailMigration = true return } - legacyThreadIdToIdMap[threadId] = OpenGroup.idFor( + legacyThreadIdToIdMap[thread.uniqueId] = OpenGroup.idFor( room: openGroup.room, server: openGroup.server ) - openGroupInfo[threadId] = openGroup - openGroupUserCount[threadId] = ((transaction.object(forKey: openGroup.id, inCollection: Legacy.openGroupUserCountCollection) as? Int) ?? 0) - openGroupImage[threadId] = transaction.object(forKey: openGroup.id, inCollection: Legacy.openGroupImageCollection) as? Data - openGroupLastMessageServerId[threadId] = transaction.object(forKey: openGroup.id, inCollection: Legacy.openGroupLastMessageServerIDCollection) as? Int64 - openGroupLastDeletionServerId[threadId] = transaction.object(forKey: openGroup.id, inCollection: Legacy.openGroupLastDeletionServerIDCollection) as? Int64 + openGroupInfo[thread.uniqueId] = openGroup + openGroupUserCount[thread.uniqueId] = ((transaction.object(forKey: openGroup.id, inCollection: SMKLegacy.openGroupUserCountCollection) as? Int) ?? 0) + openGroupImage[thread.uniqueId] = transaction.object(forKey: openGroup.id, inCollection: SMKLegacy.openGroupImageCollection) as? Data + openGroupLastMessageServerId[thread.uniqueId] = transaction.object(forKey: openGroup.id, inCollection: SMKLegacy.openGroupLastMessageServerIDCollection) as? Int64 + openGroupLastDeletionServerId[thread.uniqueId] = transaction.object(forKey: openGroup.id, inCollection: SMKLegacy.openGroupLastDeletionServerIDCollection) as? Int64 } } print("RAWR [\(Date().timeIntervalSince1970)] - Process threads - End") // Process interactions print("RAWR [\(Date().timeIntervalSince1970)] - Process interactions - Start") - transaction.enumerateKeysAndObjects(inCollection: Legacy.interactionCollection) { _, object, _ in + transaction.enumerateKeysAndObjects(inCollection: SMKLegacy.interactionCollection) { _, object, _ in guard let interaction: TSInteraction = object as? TSInteraction else { SNLog("[Migration Error] Unable to process interaction") shouldFailMigration = true @@ -174,8 +191,8 @@ enum _003_YDBToGRDBMigration: Migration { // Process attachments print("RAWR [\(Date().timeIntervalSince1970)] - Process attachments - Start") - transaction.enumerateKeysAndObjects(inCollection: Legacy.attachmentsCollection) { key, object, _ in - guard let attachment: Legacy._Attachment = object as? Legacy._Attachment else { + transaction.enumerateKeysAndObjects(inCollection: SMKLegacy.attachmentsCollection) { key, object, _ in + guard let attachment: SMKLegacy._Attachment = object as? SMKLegacy._Attachment else { SNLog("[Migration Error] Unable to process attachment") shouldFailMigration = true return @@ -186,7 +203,7 @@ enum _003_YDBToGRDBMigration: Migration { print("RAWR [\(Date().timeIntervalSince1970)] - Process attachments - End") // Process read receipts - transaction.enumerateKeysAndObjects(inCollection: Legacy.outgoingReadReceiptManagerCollection) { key, object, _ in + transaction.enumerateKeysAndObjects(inCollection: SMKLegacy.outgoingReadReceiptManagerCollection) { key, object, _ in guard let timestampsMs: Set = object as? Set else { return } outgoingReadReceiptsTimestampsMs[key] = (outgoingReadReceiptsTimestampsMs[key] ?? Set()) @@ -196,8 +213,8 @@ enum _003_YDBToGRDBMigration: Migration { receivedMessageTimestamps = receivedMessageTimestamps.inserting( contentsOf: transaction .object( - forKey: Legacy.receivedMessageTimestampsKey, - inCollection: Legacy.receivedMessageTimestampsCollection + forKey: SMKLegacy.receivedMessageTimestampsKey, + inCollection: SMKLegacy.receivedMessageTimestampsCollection ) .asType([UInt64].self) .defaulting(to: []) @@ -217,19 +234,18 @@ enum _003_YDBToGRDBMigration: Migration { try autoreleasepool { let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) - try contacts.forEach { contact in - let isCurrentUser: Bool = (contact.sessionID == currentUserPublicKey) - let contactThreadId: String = TSContactThread.threadID(fromContactSessionID: contact.sessionID) + try contacts.forEach { legacyContact in + let isCurrentUser: Bool = (legacyContact.sessionID == currentUserPublicKey) + let contactThreadId: String = SMKLegacy._ContactThread.threadId(from: legacyContact.sessionID) - // TODO: Contact 'hasOne' profile??? // Create the "Profile" for the legacy contact try Profile( - id: contact.sessionID, - name: (contact.name ?? contact.sessionID), - nickname: contact.nickname, - profilePictureUrl: contact.profilePictureURL, - profilePictureFileName: contact.profilePictureFileName, - profileEncryptionKey: contact.profileEncryptionKey + id: legacyContact.sessionID, + name: (legacyContact.name ?? legacyContact.sessionID), + nickname: legacyContact.nickname, + profilePictureUrl: legacyContact.profilePictureURL, + profilePictureFileName: legacyContact.profilePictureFileName, + profileEncryptionKey: legacyContact.profileEncryptionKey ).insert(db) // Determine if this contact is a "real" contact (don't want to create contacts for @@ -237,19 +253,19 @@ enum _003_YDBToGRDBMigration: Migration { if isCurrentUser || contactThreadIds.contains(contactThreadId) || - contact.isApproved || - contact.didApproveMe || - contact.isBlocked || - contact.hasBeenBlocked { + legacyContact.isApproved || + legacyContact.didApproveMe || + legacyContact.isBlocked || + legacyContact.hasBeenBlocked { // Create the contact // TODO: Closed group admins??? try Contact( - id: contact.sessionID, - isTrusted: (isCurrentUser || contact.isTrusted), - isApproved: (isCurrentUser || contact.isApproved), - isBlocked: (!isCurrentUser && contact.isBlocked), - didApproveMe: (isCurrentUser || contact.didApproveMe), - hasBeenBlocked: (!isCurrentUser && (contact.hasBeenBlocked || contact.isBlocked)) + id: legacyContact.sessionID, + isTrusted: (isCurrentUser || legacyContact.isTrusted), + isApproved: (isCurrentUser || legacyContact.isApproved), + isBlocked: (!isCurrentUser && legacyContact.isBlocked), + didApproveMe: (isCurrentUser || legacyContact.didApproveMe), + hasBeenBlocked: (!isCurrentUser && (legacyContact.hasBeenBlocked || legacyContact.isBlocked)) ).insert(db) } } @@ -296,11 +312,8 @@ enum _003_YDBToGRDBMigration: Migration { } // Sort by id just so we can make the migration process more determinstic - try threads.sorted(by: { lhs, rhs in (lhs.uniqueId ?? "") < (rhs.uniqueId ?? "") }).forEach { thread in - guard - let legacyThreadId: String = thread.uniqueId, - let threadId: String = legacyThreadIdToIdMap[legacyThreadId] - else { + try legacyThreads.sorted(by: { lhs, rhs in lhs.uniqueId < rhs.uniqueId }).forEach { legacyThread in + guard let threadId: String = legacyThreadIdToIdMap[legacyThread.uniqueId] else { SNLog("[Migration Error] Unable to migrate thread with no id mapping") throw GRDBStorageError.migrationFailed } @@ -308,8 +321,8 @@ enum _003_YDBToGRDBMigration: Migration { let threadVariant: SessionThread.Variant let onlyNotifyForMentions: Bool - switch thread { - case let groupThread as TSGroupThread: + switch legacyThread { + case let groupThread as SMKLegacy._GroupThread: threadVariant = (groupThread.isOpenGroup ? .openGroup : .closedGroup) onlyNotifyForMentions = groupThread.isOnlyNotifyingForMentions @@ -322,19 +335,19 @@ enum _003_YDBToGRDBMigration: Migration { try SessionThread( id: threadId, variant: threadVariant, - creationDateTimestamp: thread.creationDate.timeIntervalSince1970, - shouldBeVisible: thread.shouldBeVisible, - isPinned: thread.isPinned, - messageDraft: ((thread.messageDraft ?? "").isEmpty ? + creationDateTimestamp: legacyThread.creationDate.timeIntervalSince1970, + shouldBeVisible: legacyThread.shouldBeVisible, + isPinned: legacyThread.isPinned, + messageDraft: ((legacyThread.messageDraft ?? "").isEmpty ? nil : - thread.messageDraft + legacyThread.messageDraft ), - mutedUntilTimestamp: thread.mutedUntilDate?.timeIntervalSince1970, + mutedUntilTimestamp: legacyThread.mutedUntilDate?.timeIntervalSince1970, onlyNotifyForMentions: onlyNotifyForMentions ).insert(db) // Disappearing Messages Configuration - if let config: Legacy._DisappearingMessagesConfiguration = disappearingMessagesConfiguration[threadId] { + if let config: SMKLegacy._DisappearingMessagesConfiguration = disappearingMessagesConfiguration[threadId] { try DisappearingMessagesConfiguration( threadId: threadId, isEnabled: config.isEnabled, @@ -348,11 +361,11 @@ enum _003_YDBToGRDBMigration: Migration { } // Closed Groups - if (thread as? TSGroupThread)?.isClosedGroup == true { + if legacyThread.isClosedGroup { guard - let name: String = closedGroupName[legacyThreadId], - let groupModel: TSGroupModel = closedGroupModel[legacyThreadId], - let formationTimestamp: UInt64 = closedGroupFormation[legacyThreadId] + let name: String = closedGroupName[legacyThread.uniqueId], + let groupModel: SMKLegacy._GroupModel = closedGroupModel[legacyThread.uniqueId], + let formationTimestamp: UInt64 = closedGroupFormation[legacyThread.uniqueId] else { SNLog("[Migration Error] Closed group missing required data") throw GRDBStorageError.migrationFailed @@ -367,7 +380,7 @@ enum _003_YDBToGRDBMigration: Migration { // Note: If a user has left a closed group then they won't actually have any keys // but they should still be able to browse the old messages so we do want to allow // this case and migrate the rest of the info - try closedGroupKeys[legacyThreadId]?.forEach { timestamp, legacyKeys in + try closedGroupKeys[legacyThread.uniqueId]?.forEach { timestamp, legacyKeys in try ClosedGroupKeyPair( threadId: threadId, publicKey: legacyKeys.publicKey, @@ -396,7 +409,7 @@ enum _003_YDBToGRDBMigration: Migration { ).insert(db) } - try (closedGroupZombieMemberIds[legacyThreadId] ?? []).forEach { zombieId in + try (closedGroupZombieMemberIds[legacyThread.uniqueId] ?? []).forEach { zombieId in try GroupMember( groupId: threadId, profileId: zombieId, @@ -407,8 +420,8 @@ enum _003_YDBToGRDBMigration: Migration { } // Open Groups - if (thread as? TSGroupThread)?.isOpenGroup == true { - guard let openGroup: OpenGroupV2 = openGroupInfo[legacyThreadId] else { + if legacyThread.isOpenGroup { + guard let openGroup: OpenGroupV2 = openGroupInfo[legacyThread.uniqueId] else { SNLog("[Migration Error] Open group missing required data") throw GRDBStorageError.migrationFailed } @@ -420,15 +433,15 @@ enum _003_YDBToGRDBMigration: Migration { name: openGroup.name, groupDescription: nil, // TODO: Add with SOGS V4. imageId: nil, // TODO: Add with SOGS V4. - imageData: openGroupImage[legacyThreadId], - userCount: (openGroupUserCount[legacyThreadId] ?? 0), // Will be updated next poll + imageData: openGroupImage[legacyThread.uniqueId], + userCount: (openGroupUserCount[legacyThread.uniqueId] ?? 0), // Will be updated next poll infoUpdates: 0 // TODO: Add with SOGS V4. ).insert(db) } } try autoreleasepool { - try interactions[legacyThreadId]? + try interactions[legacyThread.uniqueId]? .sorted(by: { lhs, rhs in lhs.timestamp < rhs.timestamp }) // Maintain sort order .forEach { legacyInteraction in let serverHash: String? @@ -530,7 +543,7 @@ enum _003_YDBToGRDBMigration: Migration { // a string at display time so we want to continue that behaviour guard infoMessage.messageType == .disappearingMessagesUpdate, - let updateMessage: Legacy._DisappearingConfigurationUpdateInfoMessage = infoMessage as? Legacy._DisappearingConfigurationUpdateInfoMessage, + let updateMessage: SMKLegacy._DisappearingConfigurationUpdateInfoMessage = infoMessage as? SMKLegacy._DisappearingConfigurationUpdateInfoMessage, let infoMessageData: Data = try? JSONEncoder().encode( DisappearingMessagesConfiguration.MessageInfo( senderName: updateMessage.createdByRemoteName, @@ -713,7 +726,7 @@ enum _003_YDBToGRDBMigration: Migration { // original interaction and re-create the attachment link before // falling back to having no attachment in the quote if quoteAttachmentId == nil && !quotedMessage.quotedAttachments.isEmpty { - quoteAttachmentId = interactions[legacyThreadId]? + quoteAttachmentId = interactions[legacyThread.uniqueId]? .first(where: { $0.timestamp == quotedMessage.timestamp && ( @@ -827,7 +840,7 @@ enum _003_YDBToGRDBMigration: Migration { contacts = [] contactThreadIds = [] - threads = [] + legacyThreads = [] disappearingMessagesConfiguration = [:] closedGroupKeys = [:] @@ -850,137 +863,137 @@ enum _003_YDBToGRDBMigration: Migration { print("RAWR [\(Date().timeIntervalSince1970)] - Process jobs - Start") - var notifyPushServerJobs: Set = [] - var messageReceiveJobs: Set = [] - var messageSendJobs: Set = [] - var attachmentUploadJobs: Set = [] - var attachmentDownloadJobs: Set = [] + var notifyPushServerJobs: Set = [] + var messageReceiveJobs: Set = [] + var messageSendJobs: Set = [] + var attachmentUploadJobs: Set = [] + var attachmentDownloadJobs: Set = [] // Map the Legacy types for the NSKeyedUnarchiver NSKeyedUnarchiver.setClass( - Legacy._NotifyPNServerJob.self, + SMKLegacy._NotifyPNServerJob.self, forClassName: "SessionMessagingKit.NotifyPNServerJob" ) NSKeyedUnarchiver.setClass( - Legacy._NotifyPNServerJob._SnodeMessage.self, + SMKLegacy._NotifyPNServerJob._SnodeMessage.self, forClassName: "SessionSnodeKit.SnodeMessage" ) NSKeyedUnarchiver.setClass( - Legacy._MessageSendJob.self, + SMKLegacy._MessageSendJob.self, forClassName: "SessionMessagingKit.SNMessageSendJob" ) NSKeyedUnarchiver.setClass( - Legacy._MessageReceiveJob.self, + SMKLegacy._MessageReceiveJob.self, forClassName: "SessionMessagingKit.MessageReceiveJob" ) NSKeyedUnarchiver.setClass( - Legacy._AttachmentUploadJob.self, + SMKLegacy._AttachmentUploadJob.self, forClassName: "SessionMessagingKit.AttachmentUploadJob" ) NSKeyedUnarchiver.setClass( - Legacy._AttachmentDownloadJob.self, + SMKLegacy._AttachmentDownloadJob.self, forClassName: "SessionMessagingKit.AttachmentDownloadJob" ) NSKeyedUnarchiver.setClass( - Legacy._Message.self, + SMKLegacy._Message.self, forClassName: "SNMessage" ) NSKeyedUnarchiver.setClass( - Legacy._VisibleMessage.self, + SMKLegacy._VisibleMessage.self, forClassName: "SNVisibleMessage" ) NSKeyedUnarchiver.setClass( - Legacy._Quote.self, + SMKLegacy._Quote.self, forClassName: "SNQuote" ) NSKeyedUnarchiver.setClass( - Legacy._LinkPreview.self, + SMKLegacy._LinkPreview.self, forClassName: "SessionServiceKit.OWSLinkPreview" // Very old legacy name ) NSKeyedUnarchiver.setClass( - Legacy._LinkPreview.self, + SMKLegacy._LinkPreview.self, forClassName: "SNLinkPreview" ) NSKeyedUnarchiver.setClass( - Legacy._Profile.self, + SMKLegacy._Profile.self, forClassName: "SNProfile" ) NSKeyedUnarchiver.setClass( - Legacy._OpenGroupInvitation.self, + SMKLegacy._OpenGroupInvitation.self, forClassName: "SNOpenGroupInvitation" ) NSKeyedUnarchiver.setClass( - Legacy._ControlMessage.self, + SMKLegacy._ControlMessage.self, forClassName: "SNControlMessage" ) NSKeyedUnarchiver.setClass( - Legacy._ReadReceipt.self, + SMKLegacy._ReadReceipt.self, forClassName: "SNReadReceipt" ) NSKeyedUnarchiver.setClass( - Legacy._TypingIndicator.self, + SMKLegacy._TypingIndicator.self, forClassName: "SNTypingIndicator" ) NSKeyedUnarchiver.setClass( - Legacy._ClosedGroupControlMessage.self, + SMKLegacy._ClosedGroupControlMessage.self, forClassName: "SessionMessagingKit.ClosedGroupControlMessage" ) NSKeyedUnarchiver.setClass( - Legacy._ClosedGroupControlMessage._KeyPairWrapper.self, + SMKLegacy._ClosedGroupControlMessage._KeyPairWrapper.self, forClassName: "ClosedGroupControlMessage.SNKeyPairWrapper" ) NSKeyedUnarchiver.setClass( - Legacy._DataExtractionNotification.self, + SMKLegacy._DataExtractionNotification.self, forClassName: "SessionMessagingKit.DataExtractionNotification" ) NSKeyedUnarchiver.setClass( - Legacy._ExpirationTimerUpdate.self, + SMKLegacy._ExpirationTimerUpdate.self, forClassName: "SNExpirationTimerUpdate" ) NSKeyedUnarchiver.setClass( - Legacy._ConfigurationMessage.self, + SMKLegacy._ConfigurationMessage.self, forClassName: "SNConfigurationMessage" ) NSKeyedUnarchiver.setClass( - Legacy._CMClosedGroup.self, + SMKLegacy._CMClosedGroup.self, forClassName: "SNClosedGroup" ) NSKeyedUnarchiver.setClass( - Legacy._CMContact.self, + SMKLegacy._CMContact.self, forClassName: "SNConfigurationMessage.SNConfigurationMessageContact" ) NSKeyedUnarchiver.setClass( - Legacy._UnsendRequest.self, + SMKLegacy._UnsendRequest.self, forClassName: "SNUnsendRequest" ) NSKeyedUnarchiver.setClass( - Legacy._MessageRequestResponse.self, + SMKLegacy._MessageRequestResponse.self, forClassName: "SNMessageRequestResponse" ) Storage.read { transaction in - transaction.enumerateRows(inCollection: Legacy.notifyPushServerJobCollection) { _, object, _, _ in - guard let job = object as? Legacy._NotifyPNServerJob else { return } + transaction.enumerateRows(inCollection: SMKLegacy.notifyPushServerJobCollection) { _, object, _, _ in + guard let job = object as? SMKLegacy._NotifyPNServerJob else { return } notifyPushServerJobs.insert(job) } - transaction.enumerateRows(inCollection: Legacy.messageReceiveJobCollection) { _, object, _, _ in - guard let job = object as? Legacy._MessageReceiveJob else { return } + transaction.enumerateRows(inCollection: SMKLegacy.messageReceiveJobCollection) { _, object, _, _ in + guard let job = object as? SMKLegacy._MessageReceiveJob else { return } messageReceiveJobs.insert(job) } - transaction.enumerateRows(inCollection: Legacy.messageSendJobCollection) { _, object, _, _ in - guard let job = object as? Legacy._MessageSendJob else { return } + transaction.enumerateRows(inCollection: SMKLegacy.messageSendJobCollection) { _, object, _, _ in + guard let job = object as? SMKLegacy._MessageSendJob else { return } messageSendJobs.insert(job) } - transaction.enumerateRows(inCollection: Legacy.attachmentUploadJobCollection) { _, object, _, _ in - guard let job = object as? Legacy._AttachmentUploadJob else { return } + transaction.enumerateRows(inCollection: SMKLegacy.attachmentUploadJobCollection) { _, object, _, _ in + guard let job = object as? SMKLegacy._AttachmentUploadJob else { return } attachmentUploadJobs.insert(job) } - transaction.enumerateRows(inCollection: Legacy.attachmentDownloadJobCollection) { _, object, _, _ in - guard let job = object as? Legacy._AttachmentDownloadJob else { return } + transaction.enumerateRows(inCollection: SMKLegacy.attachmentDownloadJobCollection) { _, object, _, _ in + guard let job = object as? SMKLegacy._AttachmentDownloadJob else { return } attachmentDownloadJobs.insert(job) } } @@ -1110,7 +1123,7 @@ enum _003_YDBToGRDBMigration: Migration { destination: legacyJob.destination, variant: { switch legacyJob.message { - case is Legacy._ExpirationTimerUpdate: + case is SMKLegacy._ExpirationTimerUpdate: return .infoDisappearingMessagesUpdate default: return nil } @@ -1229,73 +1242,74 @@ enum _003_YDBToGRDBMigration: Migration { var legacyPreferences: [String: Any] = [:] Storage.read { transaction in - transaction.enumerateKeysAndObjects(inCollection: Legacy.preferencesCollection) { key, object, _ in + transaction.enumerateKeysAndObjects(inCollection: SMKLegacy.preferencesCollection) { key, object, _ in legacyPreferences[key] = object } // Note: The 'int(forKey:inCollection:)' defaults to `0` which is an incorrect value // for the notification sound so catch it and default let globalNotificationSoundValue: Int32 = transaction.int( - forKey: Legacy.soundsGlobalNotificationKey, - inCollection: Legacy.soundsStorageNotificationCollection + forKey: SMKLegacy.soundsGlobalNotificationKey, + inCollection: SMKLegacy.soundsStorageNotificationCollection ) - legacyPreferences[Legacy.soundsGlobalNotificationKey] = (globalNotificationSoundValue > 0 ? + legacyPreferences[SMKLegacy.soundsGlobalNotificationKey] = (globalNotificationSoundValue > 0 ? Int(globalNotificationSoundValue) : Preferences.Sound.defaultNotificationSound.rawValue ) - legacyPreferences[Legacy.readReceiptManagerAreReadReceiptsEnabled] = transaction.bool( - forKey: Legacy.readReceiptManagerAreReadReceiptsEnabled, - inCollection: Legacy.readReceiptManagerCollection, + legacyPreferences[SMKLegacy.readReceiptManagerAreReadReceiptsEnabled] = transaction.bool( + forKey: SMKLegacy.readReceiptManagerAreReadReceiptsEnabled, + inCollection: SMKLegacy.readReceiptManagerCollection, defaultValue: false ) - legacyPreferences[Legacy.typingIndicatorsEnabledKey] = transaction.bool( - forKey: Legacy.typingIndicatorsEnabledKey, - inCollection: Legacy.typingIndicatorsCollection, + legacyPreferences[SMKLegacy.typingIndicatorsEnabledKey] = transaction.bool( + forKey: SMKLegacy.typingIndicatorsEnabledKey, + inCollection: SMKLegacy.typingIndicatorsCollection, defaultValue: false ) - legacyPreferences[Legacy.screenLockIsScreenLockEnabledKey] = transaction.bool( - forKey: Legacy.screenLockIsScreenLockEnabledKey, - inCollection: Legacy.screenLockCollection, + legacyPreferences[SMKLegacy.screenLockIsScreenLockEnabledKey] = transaction.bool( + forKey: SMKLegacy.screenLockIsScreenLockEnabledKey, + inCollection: SMKLegacy.screenLockCollection, defaultValue: false ) - legacyPreferences[Legacy.screenLockScreenLockTimeoutSecondsKey] = transaction.double( - forKey: Legacy.screenLockScreenLockTimeoutSecondsKey, - inCollection: Legacy.screenLockCollection, + legacyPreferences[SMKLegacy.screenLockScreenLockTimeoutSecondsKey] = transaction.double( + forKey: SMKLegacy.screenLockScreenLockTimeoutSecondsKey, + inCollection: SMKLegacy.screenLockCollection, defaultValue: (15 * 60) ) } - db[.defaultNotificationSound] = Preferences.Sound(rawValue: legacyPreferences[Legacy.soundsGlobalNotificationKey] as? Int ?? -1) + db[.defaultNotificationSound] = Preferences.Sound(rawValue: legacyPreferences[SMKLegacy.soundsGlobalNotificationKey] as? Int ?? -1) .defaulting(to: Preferences.Sound.defaultNotificationSound) - db[.playNotificationSoundInForeground] = (legacyPreferences[Legacy.preferencesKeyNotificationSoundInForeground] as? Bool == true) - db[.preferencesNotificationPreviewType] = Preferences.NotificationPreviewType(rawValue: legacyPreferences[Legacy.preferencesKeyNotificationPreviewType] as? Int ?? -1) + db[.playNotificationSoundInForeground] = (legacyPreferences[SMKLegacy.preferencesKeyNotificationSoundInForeground] as? Bool == true) + db[.preferencesNotificationPreviewType] = Preferences.NotificationPreviewType(rawValue: legacyPreferences[SMKLegacy.preferencesKeyNotificationPreviewType] as? Int ?? -1) .defaulting(to: .nameAndPreview) - if let lastPushToken: String = legacyPreferences[Legacy.preferencesKeyLastRecordedPushToken] as? String { + if let lastPushToken: String = legacyPreferences[SMKLegacy.preferencesKeyLastRecordedPushToken] as? String { db[.lastRecordedPushToken] = lastPushToken } - if let lastVoipToken: String = legacyPreferences[Legacy.preferencesKeyLastRecordedVoipToken] as? String { + if let lastVoipToken: String = legacyPreferences[SMKLegacy.preferencesKeyLastRecordedVoipToken] as? String { db[.lastRecordedVoipToken] = lastVoipToken } // Note: The 'preferencesKeyScreenSecurityDisabled' value previously controlled whether the // setting was disabled, this has been inverted to 'appSwitcherPreviewEnabled' so it can default // to 'false' (as most Bool values do) - db[.areReadReceiptsEnabled] = (legacyPreferences[Legacy.readReceiptManagerAreReadReceiptsEnabled] as? Bool == true) - db[.typingIndicatorsEnabled] = (legacyPreferences[Legacy.typingIndicatorsEnabledKey] as? Bool == true) - db[.isScreenLockEnabled] = (legacyPreferences[Legacy.screenLockIsScreenLockEnabledKey] as? Bool == true) - db[.screenLockTimeoutSeconds] = (legacyPreferences[Legacy.screenLockScreenLockTimeoutSecondsKey] as? Double) + db[.areReadReceiptsEnabled] = (legacyPreferences[SMKLegacy.readReceiptManagerAreReadReceiptsEnabled] as? Bool == true) + db[.typingIndicatorsEnabled] = (legacyPreferences[SMKLegacy.typingIndicatorsEnabledKey] as? Bool == true) + db[.isScreenLockEnabled] = (legacyPreferences[SMKLegacy.screenLockIsScreenLockEnabledKey] as? Bool == true) + db[.screenLockTimeoutSeconds] = (legacyPreferences[SMKLegacy.screenLockScreenLockTimeoutSecondsKey] as? Double) .defaulting(to: (15 * 60)) - db[.appSwitcherPreviewEnabled] = (legacyPreferences[Legacy.preferencesKeyScreenSecurityDisabled] as? Bool == false) - db[.areLinkPreviewsEnabled] = (legacyPreferences[Legacy.preferencesKeyAreLinkPreviewsEnabled] as? Bool == true) + db[.appSwitcherPreviewEnabled] = (legacyPreferences[SMKLegacy.preferencesKeyScreenSecurityDisabled] as? Bool == false) + db[.areLinkPreviewsEnabled] = (legacyPreferences[SMKLegacy.preferencesKeyAreLinkPreviewsEnabled] as? Bool == true) db[.hasHiddenMessageRequests] = CurrentAppContext().appUserDefaults() - .bool(forKey: Legacy.userDefaultsHasHiddenMessageRequests) - db[.hasSavedThreadKey] = (legacyPreferences[Legacy.preferencesKeyHasSavedThreadKey] as? Bool == true) + .bool(forKey: SMKLegacy.userDefaultsHasHiddenMessageRequests) + db[.hasSavedThread] = (legacyPreferences[SMKLegacy.preferencesKeyHasSavedThreadKey] as? Bool == true) + db[.hasSentAMessage] = (legacyPreferences[SMKLegacy.preferencesKeyHasSentAMessageKey] as? Bool == true) print("RAWR [\(Date().timeIntervalSince1970)] - Process preferences inserts - End") @@ -1309,7 +1323,7 @@ enum _003_YDBToGRDBMigration: Migration { for legacyAttachmentId: String?, interactionVariant: Interaction.Variant? = nil, isQuotedMessage: Bool = false, - attachments: [String: Legacy._Attachment], + attachments: [String: SMKLegacy._Attachment], processedAttachmentIds: inout Set ) throws -> String? { guard let legacyAttachmentId: String = legacyAttachmentId else { return nil } @@ -1322,12 +1336,12 @@ enum _003_YDBToGRDBMigration: Migration { return legacyAttachmentId } - guard let legacyAttachment: Legacy._Attachment = attachments[legacyAttachmentId] else { + guard let legacyAttachment: SMKLegacy._Attachment = attachments[legacyAttachmentId] else { SNLog("[Migration Warning] Missing attachment - interaction will appear as blank") return nil } - let processedLocalRelativeFilePath: String? = (legacyAttachment as? Legacy._AttachmentStream)? + let processedLocalRelativeFilePath: String? = (legacyAttachment as? SMKLegacy._AttachmentStream)? .localRelativeFilePath .map { filePath -> String in // The old 'localRelativeFilePath' seemed to have a leading forward slash (want @@ -1338,7 +1352,7 @@ enum _003_YDBToGRDBMigration: Migration { } let state: Attachment.State = { switch legacyAttachment { - case let stream as Legacy._AttachmentStream: // Outgoing or already downloaded + case let stream as SMKLegacy._AttachmentStream: // Outgoing or already downloaded switch interactionVariant { case .standardOutgoing: return (stream.isUploaded ? .uploaded : .pending) default: return .downloaded @@ -1350,7 +1364,7 @@ enum _003_YDBToGRDBMigration: Migration { }() let size: CGSize = { switch legacyAttachment { - case let stream as Legacy._AttachmentStream: + case let stream as SMKLegacy._AttachmentStream: // First try to get an image size using the 'localRelativeFilePath' value if let localRelativeFilePath: String = processedLocalRelativeFilePath, @@ -1377,13 +1391,13 @@ enum _003_YDBToGRDBMigration: Migration { ) .defaulting(to: .zero) - case let pointer as Legacy._AttachmentPointer: return pointer.mediaSize + case let pointer as SMKLegacy._AttachmentPointer: return pointer.mediaSize default: return CGSize.zero } }() let (isValid, duration): (Bool, TimeInterval?) = { guard - let stream: Legacy._AttachmentStream = legacyAttachment as? Legacy._AttachmentStream, + let stream: SMKLegacy._AttachmentStream = legacyAttachment as? SMKLegacy._AttachmentStream, let originalFilePath: String = Attachment.originalFilePath( id: legacyAttachmentId, mimeType: stream.contentType, @@ -1435,7 +1449,7 @@ enum _003_YDBToGRDBMigration: Migration { state: state, contentType: legacyAttachment.contentType, byteCount: UInt(legacyAttachment.byteCount), - creationTimestamp: (legacyAttachment as? Legacy._AttachmentStream)? + creationTimestamp: (legacyAttachment as? SMKLegacy._AttachmentStream)? .creationTimestamp.timeIntervalSince1970, sourceFilename: legacyAttachment.sourceFilename, downloadUrl: legacyAttachment.downloadURL, @@ -1445,7 +1459,7 @@ enum _003_YDBToGRDBMigration: Migration { duration: duration, isValid: isValid, encryptionKey: legacyAttachment.encryptionKey, - digest: (legacyAttachment as? Legacy._AttachmentStream)?.digest, + digest: (legacyAttachment as? SMKLegacy._AttachmentStream)?.digest, caption: legacyAttachment.caption ).inserted(db) diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index d0605cb5a..2fd51a151 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -127,7 +127,7 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, public func insert(_ db: Database) throws { try performInsert(db) - db[.hasSavedThreadKey] = true + db[.hasSavedThread] = true } public func delete(_ db: Database) throws -> Bool { @@ -311,6 +311,13 @@ public extension SessionThread { @objc(SMKThread) public class SMKThread: NSObject { + @objc(deleteAll) + public static func deleteAll() { + GRDBStorage.shared.writeAsync { db in + _ = try SessionThread.deleteAll(db) + } + } + @objc(isThreadMuted:) public static func isThreadMuted(_ threadId: String) -> Bool { return GRDBStorage.shared.read { db in diff --git a/SessionMessagingKit/Database/OWSPrimaryStorage.m b/SessionMessagingKit/Database/OWSPrimaryStorage.m index c44b6dd7e..df54ae71e 100644 --- a/SessionMessagingKit/Database/OWSPrimaryStorage.m +++ b/SessionMessagingKit/Database/OWSPrimaryStorage.m @@ -4,7 +4,6 @@ #import "OWSPrimaryStorage.h" #import "AppContext.h" -#import "OWSDisappearingMessagesFinder.h" #import "OWSFileSystem.h" #import "OWSIncomingMessageFinder.h" #import diff --git a/SessionMessagingKit/Database/TSDatabaseView.m b/SessionMessagingKit/Database/TSDatabaseView.m index f2beee9c9..6e733cbc9 100644 --- a/SessionMessagingKit/Database/TSDatabaseView.m +++ b/SessionMessagingKit/Database/TSDatabaseView.m @@ -7,7 +7,6 @@ #import "TSAttachmentPointer.h" #import "TSIncomingMessage.h" #import "TSOutgoingMessage.h" -#import "TSThread.h" #import #import #import diff --git a/SessionMessagingKit/Messages/Message.swift b/SessionMessagingKit/Messages/Message.swift index b76c4e05c..aeb747893 100644 --- a/SessionMessagingKit/Messages/Message.swift +++ b/SessionMessagingKit/Messages/Message.swift @@ -67,7 +67,7 @@ public class Message: Codable { guard let threadId: String = threadId, (try? ClosedGroup.exists(db, id: threadId)) == true, - let legacyGroupId: Data = "\(Legacy.closedGroupIdPrefix)\(threadId)".data(using: .utf8) + let legacyGroupId: Data = "\(SMKLegacy.closedGroupIdPrefix)\(threadId)".data(using: .utf8) else { return } // Android needs a group context or it'll interpret the message as a one-to-one message diff --git a/SessionMessagingKit/Messages/Signal/TSIncomingMessage.m b/SessionMessagingKit/Messages/Signal/TSIncomingMessage.m index 8dfbc6ce3..7731e86c2 100644 --- a/SessionMessagingKit/Messages/Signal/TSIncomingMessage.m +++ b/SessionMessagingKit/Messages/Signal/TSIncomingMessage.m @@ -5,9 +5,7 @@ #import "TSIncomingMessage.h" #import "NSNotificationCenter+OWS.h" #import "TSAttachmentPointer.h" -#import "TSContactThread.h" #import "TSDatabaseSecondaryIndexes.h" -#import "TSGroupThread.h" #import #import #import diff --git a/SessionMessagingKit/Messages/Signal/TSInteraction.m b/SessionMessagingKit/Messages/Signal/TSInteraction.m index b7100e0a5..7eb2eca62 100644 --- a/SessionMessagingKit/Messages/Signal/TSInteraction.m +++ b/SessionMessagingKit/Messages/Signal/TSInteraction.m @@ -4,8 +4,6 @@ #import "TSInteraction.h" #import "TSDatabaseSecondaryIndexes.h" -#import "TSThread.h" -#import "TSGroupThread.h" #import #import diff --git a/SessionMessagingKit/Messages/Signal/TSMessage.m b/SessionMessagingKit/Messages/Signal/TSMessage.m index 575a3ffdb..bd22479e1 100644 --- a/SessionMessagingKit/Messages/Signal/TSMessage.m +++ b/SessionMessagingKit/Messages/Signal/TSMessage.m @@ -8,12 +8,10 @@ #import "TSAttachment.h" #import "TSAttachmentStream.h" #import "TSQuotedMessage.h" -#import "TSThread.h" #import #import #import #import -#import "TSContactThread.h" #import NS_ASSUME_NONNULL_BEGIN diff --git a/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.m b/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.m index 3470fb821..a8f6cc1cb 100644 --- a/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.m +++ b/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.m @@ -10,8 +10,6 @@ #import "SSKEnvironment.h" #import "TSAccountManager.h" #import "TSAttachmentStream.h" -#import "TSContactThread.h" -#import "TSGroupThread.h" #import "TSQuotedMessage.h" #import #import diff --git a/SessionMessagingKit/Meta/SessionMessagingKit.h b/SessionMessagingKit/Meta/SessionMessagingKit.h index 8a69caee9..9d306116d 100644 --- a/SessionMessagingKit/Meta/SessionMessagingKit.h +++ b/SessionMessagingKit/Meta/SessionMessagingKit.h @@ -9,7 +9,6 @@ FOUNDATION_EXPORT const unsigned char SessionMessagingKitVersionString[]; #import #import #import -#import #import #import #import @@ -24,16 +23,12 @@ FOUNDATION_EXPORT const unsigned char SessionMessagingKitVersionString[]; #import #import #import -#import #import #import -#import -#import #import #import #import #import #import -#import #import #import diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift index 07d594dae..56ba23f92 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift @@ -217,7 +217,7 @@ public final class ClosedGroupPoller: NSObject { ) } - SNLog("Received \(messageCount) message(s) in closed group with public key: \(groupPublicKey).") + SNLog("Received \(messageCount) new message\(messageCount == 1 ? "" : "s") in closed group with public key: \(groupPublicKey) (\(messages.count - messageCount) duplicates)") } } .map { _ in } diff --git a/SessionMessagingKit/Sending & Receiving/Quotes/OWSQuotedReplyModel.m b/SessionMessagingKit/Sending & Receiving/Quotes/OWSQuotedReplyModel.m index 59dada4d7..705caf663 100644 --- a/SessionMessagingKit/Sending & Receiving/Quotes/OWSQuotedReplyModel.m +++ b/SessionMessagingKit/Sending & Receiving/Quotes/OWSQuotedReplyModel.m @@ -12,7 +12,6 @@ #import #import #import -#import NS_ASSUME_NONNULL_BEGIN diff --git a/SessionMessagingKit/Sending & Receiving/Quotes/TSQuotedMessage.m b/SessionMessagingKit/Sending & Receiving/Quotes/TSQuotedMessage.m index d69b971e0..576ccda30 100644 --- a/SessionMessagingKit/Sending & Receiving/Quotes/TSQuotedMessage.m +++ b/SessionMessagingKit/Sending & Receiving/Quotes/TSQuotedMessage.m @@ -10,7 +10,6 @@ #import "TSIncomingMessage.h" #import "TSInteraction.h" #import "TSOutgoingMessage.h" -#import "TSThread.h" #import #import diff --git a/SessionMessagingKit/Threads/Notification+Thread.swift b/SessionMessagingKit/Threads/Notification+Thread.swift deleted file mode 100644 index 77b74d0ef..000000000 --- a/SessionMessagingKit/Threads/Notification+Thread.swift +++ /dev/null @@ -1,12 +0,0 @@ - -public extension Notification.Name { - - static let groupThreadUpdated = Notification.Name("groupThreadUpdated") - static let muteSettingUpdated = Notification.Name("muteSettingUpdated") -} - -@objc public extension NSNotification { - - @objc static let groupThreadUpdated = Notification.Name.groupThreadUpdated.rawValue as NSString - @objc static let muteSettingUpdated = Notification.Name.muteSettingUpdated.rawValue as NSString -} diff --git a/SessionMessagingKit/Threads/TSContactThread.h b/SessionMessagingKit/Threads/TSContactThread.h deleted file mode 100644 index f40a7b98c..000000000 --- a/SessionMessagingKit/Threads/TSContactThread.h +++ /dev/null @@ -1,31 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -extern NSString *const TSContactThreadPrefix; - -@interface TSContactThread : TSThread - -- (instancetype)initWithContactSessionID:(NSString *)contactSessionID; - -+ (instancetype)getOrCreateThreadWithContactSessionID:(NSString *)contactSessionID NS_SWIFT_NAME(getOrCreateThread(contactSessionID:)); - -+ (instancetype)getOrCreateThreadWithContactSessionID:(NSString *)contactSessionID - transaction:(YapDatabaseReadWriteTransaction *)transaction; - -// Unlike getOrCreateThreadWithContactSessionID, this will _NOT_ create a thread if one does not already exist. -+ (nullable instancetype)getThreadWithContactSessionID:(NSString *)contactSessionID transaction:(YapDatabaseReadTransaction *)transaction; - -- (NSString *)contactSessionID; - -+ (NSString *)contactSessionIDFromThreadID:(NSString *)threadId; - -+ (NSString *)threadIDFromContactSessionID:(NSString *)contactSessionID; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Threads/TSContactThread.m b/SessionMessagingKit/Threads/TSContactThread.m deleted file mode 100644 index 4eb4ead40..000000000 --- a/SessionMessagingKit/Threads/TSContactThread.m +++ /dev/null @@ -1,127 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "TSContactThread.h" -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -NSString *const TSContactThreadPrefix = @"c"; - -@implementation TSContactThread - -- (instancetype)initWithContactSessionID:(NSString *)contactSessionID { - NSString *uniqueIdentifier = [[self class] threadIDFromContactSessionID:contactSessionID]; - - self = [super initWithUniqueId:uniqueIdentifier]; - - return self; -} - -+ (instancetype)getOrCreateThreadWithContactSessionID:(NSString *)contactSessionID - transaction:(YapDatabaseReadWriteTransaction *)transaction { - TSContactThread *thread = - [self fetchObjectWithUniqueID:[self threadIDFromContactSessionID:contactSessionID] transaction:transaction]; - - if (!thread) { - thread = [[TSContactThread alloc] initWithContactSessionID:contactSessionID]; - [thread saveWithTransaction:transaction]; - } - - return thread; -} - -+ (instancetype)getOrCreateThreadWithContactSessionID:(NSString *)contactSessionID -{ - __block TSContactThread *thread; - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - thread = [self getOrCreateThreadWithContactSessionID:contactSessionID transaction:transaction]; - }]; - - return thread; -} - -+ (nullable instancetype)getThreadWithContactSessionID:(NSString *)contactSessionID transaction:(YapDatabaseReadTransaction *)transaction; -{ - return [TSContactThread fetchObjectWithUniqueID:[self threadIDFromContactSessionID:contactSessionID] transaction:transaction]; -} - -- (NSString *)contactSessionID { - return [[self class] contactSessionIDFromThreadID:self.uniqueId]; -} - -- (NSArray *)recipientIdentifiers -{ - return @[ self.contactSessionID ]; -} - -- (BOOL)isMessageRequest { - NSString *sessionID = self.contactSessionID; - SMKContact *contact = [SMKContact fetchOrCreateWithId: sessionID]; - - return ( - self.shouldBeVisible && - !self.isNoteToSelf && ( - contact == nil || - !contact.isApproved - ) - ); -} - -- (BOOL)isMessageRequestUsingTransaction:(YapDatabaseReadTransaction *)transaction { - NSString *sessionID = self.contactSessionID; - SMKContact *contact = [SMKContact fetchOrCreateWithId: sessionID]; - - return ( - self.shouldBeVisible && - !self.isNoteToSelf && ( - contact == nil || - !contact.isApproved - ) - ); -} - -- (BOOL)isBlocked { - NSString *sessionID = self.contactSessionID; - SMKContact *contact = [SMKContact fetchOrCreateWithId: sessionID]; - - return (contact.isBlocked == YES); -} - -- (BOOL)isBlockedUsingTransaction:(YapDatabaseReadTransaction *)transaction { - NSString *sessionID = self.contactSessionID; - SMKContact *contact = [SMKContact fetchOrCreateWithId: sessionID]; - - return (contact.isBlocked == YES); -} - -- (BOOL)isGroupThread -{ - return NO; -} - -- (NSString *)name -{ - NSString *sessionID = self.contactSessionID; - return [SMKProfile displayNameWithId:sessionID]; -} - -- (NSString *)nameWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - NSString *sessionID = self.contactSessionID; - return [SMKProfile displayNameWithId:sessionID]; -} - -+ (NSString *)threadIDFromContactSessionID:(NSString *)contactSessionID { - return [TSContactThreadPrefix stringByAppendingString:contactSessionID]; -} - -+ (NSString *)contactSessionIDFromThreadID:(NSString *)threadId { - return [threadId substringWithRange:NSMakeRange(1, threadId.length - 1)]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Threads/TSGroupModel.h b/SessionMessagingKit/Threads/TSGroupModel.h deleted file mode 100644 index f05879470..000000000 --- a/SessionMessagingKit/Threads/TSGroupModel.h +++ /dev/null @@ -1,43 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -typedef NS_ENUM(NSInteger, GroupType) { - closedGroup = 0, - openGroup = 1, -}; - -extern const int32_t kGroupIdLength; - -@interface TSGroupModel : TSYapDatabaseObject - -@property (nonatomic) NSArray *groupMemberIds; -@property (nonatomic) NSArray *groupAdminIds; -@property (nullable, readonly, nonatomic) NSString *groupName; -@property (readonly, nonatomic) NSData *groupId; -@property (nonatomic) GroupType groupType; - -#if TARGET_OS_IOS -@property (nullable, nonatomic, strong) UIImage *groupImage; - -- (instancetype)initWithTitle:(nullable NSString *)title - memberIds:(NSArray *)memberIds - image:(nullable UIImage *)image - groupId:(NSData *)groupId - groupType:(GroupType)groupType - adminIds:(NSArray *)adminIds; - -- (BOOL)isEqual:(id)other; -- (BOOL)isEqualToGroupModel:(TSGroupModel *)model; -- (NSString *)getInfoStringAboutUpdateTo:(TSGroupModel *)model; - -#endif - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Threads/TSGroupModel.m b/SessionMessagingKit/Threads/TSGroupModel.m deleted file mode 100644 index 8af3d8b52..000000000 --- a/SessionMessagingKit/Threads/TSGroupModel.m +++ /dev/null @@ -1,159 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "TSGroupModel.h" -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -const int32_t kGroupIdLength = 16; - -@interface TSGroupModel () - -@property (nullable, nonatomic) NSString *groupName; - -@end - -#pragma mark - - -@implementation TSGroupModel - -- (nullable NSString *)groupName -{ - return _groupName.filterStringForDisplay; -} - -#if TARGET_OS_IOS -- (instancetype)initWithTitle:(nullable NSString *)title - memberIds:(NSArray *)memberIds - image:(nullable UIImage *)image - groupId:(NSData *)groupId - groupType:(GroupType)groupType - adminIds:(NSArray *)adminIds -{ - _groupName = title; - _groupMemberIds = [memberIds copy]; - _groupImage = image; - _groupType = groupType; - _groupId = groupId; - _groupAdminIds = [adminIds copy]; - - return self; -} - -- (nullable instancetype)initWithCoder:(NSCoder *)coder -{ - self = [super initWithCoder:coder]; - if (!self) { - return self; - } - - // Occasionally seeing this as nil in legacy data, - // which causes crashes. - if (_groupMemberIds == nil) { - _groupMemberIds = [NSArray new]; - } - - if (_groupAdminIds == nil) { - _groupAdminIds = [NSArray new]; - } - - return self; -} - -- (BOOL)isEqual:(id)other { - if (other == self) { - return YES; - } - if (!other || ![other isKindOfClass:[self class]]) { - return NO; - } - return [self isEqualToGroupModel:other]; -} - -- (BOOL)isEqualToGroupModel:(TSGroupModel *)other { - if (self == other) - return YES; - if (![_groupId isEqualToData:other.groupId]) { - return NO; - } - if (![_groupName isEqual:other.groupName]) { - return NO; - } - if (!(_groupImage != nil && other.groupImage != nil && - [UIImagePNGRepresentation(_groupImage) isEqualToData:UIImagePNGRepresentation(other.groupImage)])) { - return NO; - } - if (_groupType != other.groupType) { - return NO; - } - NSMutableArray *compareMyGroupMemberIds = [NSMutableArray arrayWithArray:_groupMemberIds]; - [compareMyGroupMemberIds removeObjectsInArray:other.groupMemberIds]; - if ([compareMyGroupMemberIds count] > 0) { - return NO; - } - return YES; -} - -- (NSString *)getInfoStringAboutUpdateTo:(TSGroupModel *)newModel { - // This is only invoked for group * changes *, i.e. not when a group is created. - NSString *userPublicKey = [SNGeneralUtilities getUserPublicKey]; - NSString *updatedGroupInfoString = @""; - if (self == newModel) { - return NSLocalizedString(@"GROUP_UPDATED", @""); - } - // Name change - if (![_groupName isEqual:newModel.groupName]) { - updatedGroupInfoString = [updatedGroupInfoString stringByAppendingString:[NSString stringWithFormat:NSLocalizedString(@"GROUP_TITLE_CHANGED", @""), newModel.groupName]]; - } - // Added & removed members - NSSet *oldMembers = [NSSet setWithArray:_groupMemberIds]; - NSSet *newMembers = [NSSet setWithArray:newModel.groupMemberIds]; - - NSMutableSet *addedMembers = newMembers.mutableCopy; - [addedMembers minusSet:oldMembers]; - - NSMutableSet *removedMembers = oldMembers.mutableCopy; - [removedMembers minusSet:newMembers]; - - NSMutableSet *removedMembersMinusSelf = removedMembers.mutableCopy; - [removedMembersMinusSelf minusSet:[NSSet setWithObject:userPublicKey]]; - - if (removedMembersMinusSelf.count > 0) { - NSArray *removedMemberNames = [removedMembers.allObjects map:^NSString *(NSString *publicKey) { - return [SMKProfile displayNameWithId:publicKey]; - }]; - NSString *format = removedMembers.count > 1 ? NSLocalizedString(@"GROUP_MEMBERS_REMOVED", @"") : NSLocalizedString(@"GROUP_MEMBER_REMOVED", @""); - updatedGroupInfoString = [updatedGroupInfoString - stringByAppendingString:[NSString - stringWithFormat: format, - [removedMemberNames componentsJoinedByString:@", "]]]; - } - - if (addedMembers.count > 0) { - NSArray *addedMemberNames = [[addedMembers allObjects] map:^NSString*(NSString* publicKey) { - return [SMKProfile displayNameWithId:publicKey]; - }]; - updatedGroupInfoString = [updatedGroupInfoString - stringByAppendingString:[NSString - stringWithFormat:NSLocalizedString(@"GROUP_MEMBER_JOINED", @""), - [addedMemberNames componentsJoinedByString:@", "]]]; - } - - if ([removedMembers containsObject:userPublicKey]) { - updatedGroupInfoString = [updatedGroupInfoString stringByAppendingString:NSLocalizedString(@"YOU_WERE_REMOVED", @"")]; - } - // Return - if ([updatedGroupInfoString length] == 0) { - updatedGroupInfoString = NSLocalizedString(@"GROUP_UPDATED", @""); - } - return updatedGroupInfoString; -} - -#endif - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Threads/TSGroupThread.h b/SessionMessagingKit/Threads/TSGroupThread.h deleted file mode 100644 index 1ed89a978..000000000 --- a/SessionMessagingKit/Threads/TSGroupThread.h +++ /dev/null @@ -1,63 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@class TSAttachmentStream; -@class YapDatabaseReadWriteTransaction; - -extern NSString *const TSGroupThreadAvatarChangedNotification; -extern NSString *const TSGroupThread_NotificationKey_UniqueId; - -@interface TSGroupThread : TSThread - -@property (nonatomic, strong) TSGroupModel *groupModel; -@property (nonatomic, readonly) BOOL isOpenGroup; -@property (nonatomic, readonly) BOOL isClosedGroup; -@property (nonatomic) BOOL isOnlyNotifyingForMentions; - -+ (instancetype)getOrCreateThreadWithGroupModel:(TSGroupModel *)groupModel; -+ (instancetype)getOrCreateThreadWithGroupModel:(TSGroupModel *)groupModel - transaction:(YapDatabaseReadWriteTransaction *)transaction; - -+ (instancetype)getOrCreateThreadWithGroupId:(NSData *)groupId - groupType:(GroupType) groupType; -+ (instancetype)getOrCreateThreadWithGroupId:(NSData *)groupId - groupType:(GroupType) groupType - transaction:(YapDatabaseReadWriteTransaction *)transaction; - -+ (nullable instancetype)threadWithGroupId:(NSData *)groupId transaction:(YapDatabaseReadTransaction *)transaction - NS_SWIFT_NAME(fetch(groupId:transaction:)); - -+ (NSString *)threadIdFromGroupId:(NSData *)groupId; - -+ (NSString *)defaultGroupName; - -- (BOOL)isCurrentUserMemberInGroup; -- (BOOL)isUserMemberInGroup:(NSString *)publicKey; -- (BOOL)isUserAdminInGroup:(NSString *)publicKey; - -// all group threads containing recipient as a member -+ (NSArray *)groupThreadsWithRecipientId:(NSString *)recipientId - transaction:(YapDatabaseReadWriteTransaction *)transaction; - -- (void)setGroupModel:(TSGroupModel *)newGroupModel withTransaction:(YapDatabaseReadWriteTransaction *)transaction; -- (void)setIsOnlyNotifyingForMentions:(BOOL)isOnlyNotifyingForMentions withTransaction:(YapDatabaseReadWriteTransaction *)transaction; -- (void)leaveGroupWithSneakyTransaction; -- (void)leaveGroupWithTransaction:(YapDatabaseReadWriteTransaction *)transaction; - -#pragma mark - Avatar - -- (void)updateAvatarWithAttachmentStream:(TSAttachmentStream *)attachmentStream; -- (void)updateAvatarWithAttachmentStream:(TSAttachmentStream *)attachmentStream - transaction:(YapDatabaseReadWriteTransaction *)transaction; - -- (void)fireAvatarChangedNotification; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Threads/TSGroupThread.m b/SessionMessagingKit/Threads/TSGroupThread.m deleted file mode 100644 index 61b2e706e..000000000 --- a/SessionMessagingKit/Threads/TSGroupThread.m +++ /dev/null @@ -1,283 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "TSGroupThread.h" -#import "TSAttachmentStream.h" -#import -#import -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -NSString *const TSGroupThreadAvatarChangedNotification = @"TSGroupThreadAvatarChangedNotification"; -NSString *const TSGroupThread_NotificationKey_UniqueId = @"TSGroupThread_NotificationKey_UniqueId"; - -@implementation TSGroupThread - -#define TSGroupThreadPrefix @"g" - -- (instancetype)initWithGroupModel:(TSGroupModel *)groupModel -{ - NSString *uniqueIdentifier = [[self class] threadIdFromGroupId:groupModel.groupId]; - self = [super initWithUniqueId:uniqueIdentifier]; - - if (!self) { - return self; - } - - _groupModel = groupModel; - - return self; -} - -- (instancetype)initWithGroupId:(NSData *)groupId groupType:(GroupType)groupType -{ - NSString *localNumber = [TSAccountManager localNumber]; - - TSGroupModel *groupModel = [[TSGroupModel alloc] initWithTitle:nil - memberIds:@[ localNumber ] - image:nil - groupId:groupId - groupType:groupType - adminIds:@[ localNumber ]]; - - self = [self initWithGroupModel:groupModel]; - - if (!self) { - return self; - } - - return self; -} - -+ (nullable instancetype)threadWithGroupId:(NSData *)groupId transaction:(YapDatabaseReadTransaction *)transaction -{ - return [self fetchObjectWithUniqueID:[self threadIdFromGroupId:groupId] transaction:transaction]; -} - -+ (instancetype)getOrCreateThreadWithGroupId:(NSData *)groupId - groupType:(GroupType)groupType - transaction:(YapDatabaseReadWriteTransaction *)transaction -{ - TSGroupThread *thread = [self fetchObjectWithUniqueID:[self threadIdFromGroupId:groupId] transaction:transaction]; - - if (!thread) { - thread = [[self alloc] initWithGroupId:groupId groupType:groupType]; - [thread saveWithTransaction:transaction]; - } - - return thread; -} - -+ (instancetype)getOrCreateThreadWithGroupId:(NSData *)groupId groupType:(GroupType)groupType -{ - __block TSGroupThread *thread; - - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - thread = [self getOrCreateThreadWithGroupId:groupId groupType:groupType transaction:transaction]; - }]; - - return thread; -} - -+ (instancetype)getOrCreateThreadWithGroupModel:(TSGroupModel *)groupModel - transaction:(YapDatabaseReadWriteTransaction *)transaction { - TSGroupThread *thread = - [self fetchObjectWithUniqueID:[self threadIdFromGroupId:groupModel.groupId] transaction:transaction]; - - if (!thread) { - thread = [[TSGroupThread alloc] initWithGroupModel:groupModel]; - [thread saveWithTransaction:transaction]; - } - - return thread; -} - -+ (instancetype)getOrCreateThreadWithGroupModel:(TSGroupModel *)groupModel -{ - __block TSGroupThread *thread; - - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - thread = [self getOrCreateThreadWithGroupModel:groupModel transaction:transaction]; - }]; - - return thread; -} - -+ (NSString *)threadIdFromGroupId:(NSData *)groupId -{ - return [TSGroupThreadPrefix stringByAppendingString:[[LKGroupUtilities getDecodedGroupIDAsData:groupId] base64EncodedString]]; -} - -+ (NSData *)groupIdFromThreadId:(NSString *)threadId -{ - return [NSData dataFromBase64String:[threadId substringWithRange:NSMakeRange(1, threadId.length - 1)]]; -} - -- (NSArray *)recipientIdentifiers -{ - if (self.isClosedGroup) { - NSMutableArray *groupMemberIds = [self.groupModel.groupMemberIds mutableCopy]; - if (groupMemberIds == nil) { return @[]; } - [groupMemberIds removeObject:TSAccountManager.localNumber]; - return [groupMemberIds copy]; - } else { - return @[ [LKGroupUtilities getDecodedGroupID:self.groupModel.groupId] ]; - } -} - -// @returns all threads to which the recipient is a member. -// -// @note If this becomes a hotspot we can extract into a YapDB View. -// As is, the number of groups should be small (dozens, *maybe* hundreds), and we only enumerate them upon SN changes. -+ (NSArray *)groupThreadsWithRecipientId:(NSString *)recipientId - transaction:(YapDatabaseReadWriteTransaction *)transaction -{ - NSMutableArray *groupThreads = [NSMutableArray new]; - - [self enumerateCollectionObjectsWithTransaction:transaction usingBlock:^(id obj, BOOL *stop) { - if ([obj isKindOfClass:[TSGroupThread class]]) { - TSGroupThread *groupThread = (TSGroupThread *)obj; - if ([groupThread.groupModel.groupMemberIds containsObject:recipientId]) { - [groupThreads addObject:groupThread]; - } - } - }]; - - return [groupThreads copy]; -} - -- (BOOL)isGroupThread -{ - return true; -} - -- (BOOL)isClosedGroup -{ - return (self.groupModel.groupType == closedGroup); -} - -- (BOOL)isOpenGroup -{ - return (self.groupModel.groupType == openGroup); -} - -- (BOOL)isCurrentUserMemberInGroup -{ - NSString *userPublicKey = [SNGeneralUtilities getUserPublicKey]; - return [self isUserMemberInGroup:userPublicKey]; -} - -- (BOOL)isUserMemberInGroup:(NSString *)publicKey -{ - if (publicKey == nil) { return NO; } - return [self.groupModel.groupMemberIds containsObject:publicKey]; -} - -- (BOOL)isUserAdminInGroup:(NSString *)publicKey -{ - if (publicKey == nil) { return NO; } - return [self.groupModel.groupAdminIds containsObject:publicKey]; -} - -- (NSString *)name -{ - // TODO sometimes groupName is set to the empty string. I'm hesitent to change - // the semantics here until we have time to thouroughly test the fallout. - // Instead, see the `groupNameOrDefault` which is appropriate for use when displaying - // text corresponding to a group. - return self.groupModel.groupName ?: self.class.defaultGroupName; -} - -- (NSString *)nameWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - return [self name]; -} - -+ (NSString *)defaultGroupName -{ - return @"Group"; -} - -- (void)setGroupModel:(TSGroupModel *)newGroupModel withTransaction:(YapDatabaseReadWriteTransaction *)transaction -{ - self.groupModel = newGroupModel; - - [self saveWithTransaction:transaction]; - - [transaction addCompletionQueue:dispatch_get_main_queue() completionBlock:^{ - [NSNotificationCenter.defaultCenter postNotificationName:NSNotification.groupThreadUpdated object:self.uniqueId]; - }]; -} - -- (void)setIsOnlyNotifyingForMentions:(BOOL)isOnlyNotifyingForMentions withTransaction:(YapDatabaseReadWriteTransaction *)transaction -{ - self.isOnlyNotifyingForMentions = isOnlyNotifyingForMentions; - - [self saveWithTransaction:transaction]; - - [transaction addCompletionQueue:dispatch_get_main_queue() completionBlock:^{ - [NSNotificationCenter.defaultCenter postNotificationName:NSNotification.groupThreadUpdated object:self.uniqueId]; - }]; -} - -- (void)leaveGroupWithSneakyTransaction -{ - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) { - [self leaveGroupWithTransaction:transaction]; - }]; -} - -- (void)leaveGroupWithTransaction:(YapDatabaseReadWriteTransaction *)transaction -{ - NSMutableSet *newGroupMemberIDs = [NSMutableSet setWithArray:self.groupModel.groupMemberIds]; - NSString *userPublicKey = TSAccountManager.localNumber; - if (userPublicKey == nil) { return; } - [newGroupMemberIDs removeObject:userPublicKey]; - self.groupModel.groupMemberIds = newGroupMemberIDs.allObjects; - [self saveWithTransaction:transaction]; - [transaction addCompletionQueue:dispatch_get_main_queue() completionBlock:^{ - [NSNotificationCenter.defaultCenter postNotificationName:NSNotification.groupThreadUpdated object:self.uniqueId]; - }]; -} - -#pragma mark - Avatar - -- (void)updateAvatarWithAttachmentStream:(TSAttachmentStream *)attachmentStream -{ - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [self updateAvatarWithAttachmentStream:attachmentStream transaction:transaction]; - }]; -} - -- (void)updateAvatarWithAttachmentStream:(TSAttachmentStream *)attachmentStream - transaction:(YapDatabaseReadWriteTransaction *)transaction -{ - self.groupModel.groupImage = [attachmentStream thumbnailImageSmallSync]; - [self saveWithTransaction:transaction]; - - [transaction addCompletionQueue:nil - completionBlock:^{ - [self fireAvatarChangedNotification]; - }]; - - // Avatars are stored directly in the database, so there's no need - // to keep the attachment around after assigning the image. - [attachmentStream removeWithTransaction:transaction]; -} - -- (void)fireAvatarChangedNotification -{ - NSDictionary *userInfo = @{ TSGroupThread_NotificationKey_UniqueId : self.uniqueId }; - - [[NSNotificationCenter defaultCenter] postNotificationName:TSGroupThreadAvatarChangedNotification - object:self.uniqueId - userInfo:userInfo]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Threads/TSThread.h b/SessionMessagingKit/Threads/TSThread.h deleted file mode 100644 index f1c660a22..000000000 --- a/SessionMessagingKit/Threads/TSThread.h +++ /dev/null @@ -1,137 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -BOOL IsNoteToSelfEnabled(void); - -@class TSInteraction; - -/** - * TSThread is the superclass of TSContactThread and TSGroupThread - */ -@interface TSThread : TSYapDatabaseObject - -@property (nonatomic) BOOL isPinned; -@property (nonatomic) BOOL shouldBeVisible; -@property (nonatomic, readonly) NSDate *creationDate; -@property (nonatomic, readonly, nullable) NSDate *lastInteractionDate; -@property (nonatomic, readonly) TSInteraction *lastInteraction; -@property (atomic, readonly) BOOL isMuted; -@property (nonatomic, copy, nullable) NSString *messageDraft; -@property (atomic, readonly, nullable) NSDate *mutedUntilDate; - -/** - * Whether the object is a group thread or not. - * - * @return YES if is a group thread, NO otherwise. - */ -- (BOOL)isGroupThread; - -/** - * Returns the name of the thread. - * - * @return The name of the thread. - */ -- (NSString *)name; - -- (NSString *)nameWithTransaction:(YapDatabaseReadTransaction *)transaction; - -/** - * @returns recipientId for each recipient in the thread - */ -@property (nonatomic, readonly) NSArray *recipientIdentifiers; - -- (BOOL)isNoteToSelf; - -/** - * Whether the thread is a message request. - * - * @return YES if the combination of thread and contact approval means this thread should appear in the message requests section, NO otherwise. - */ -- (BOOL)isMessageRequest; -- (BOOL)isMessageRequestUsingTransaction:(YapDatabaseReadTransaction *)transaction; - -- (BOOL)isBlocked; -- (BOOL)isBlockedUsingTransaction:(YapDatabaseReadTransaction *)transaction; - -#pragma mark Interactions - -- (void)enumerateInteractionsWithTransaction:(YapDatabaseReadTransaction *)transaction usingBlock:(void (^)(TSInteraction *interaction, BOOL *stop))block; - -- (void)enumerateInteractionsUsingBlock:(void (^)(TSInteraction *interaction))block; - -/** - * @return The number of interactions in this thread. - */ -- (NSUInteger)numberOfInteractions; - -- (NSUInteger)numberOfInteractionsWithTransaction:(YapDatabaseReadTransaction *)transaction; - -/** - * @return If there is any message mentioning current user in this thread. - */ -- (NSUInteger)unreadMentionMessageCount; - -- (NSUInteger)unreadMentionMessageCountWithTransaction:(YapDatabaseReadTransaction *)transaction; - -/** - * Returns the string that will be displayed typically in a conversations view as a preview of the last message - * received in this thread. - * - * @return Thread preview string. - */ -- (NSString *)lastMessageTextWithTransaction:(YapDatabaseReadTransaction *)transaction - NS_SWIFT_NAME(lastMessageText(transaction:)); - -- (nullable TSInteraction *)lastInteractionForInboxWithTransaction:(YapDatabaseReadTransaction *)transaction - NS_SWIFT_NAME(lastInteractionForInbox(transaction:)); - -/** - * Updates the thread's caches of the latest interaction. - * - * @param lastMessage Latest Interaction to take into consideration. - * @param transaction Database transaction. - */ -- (void)updateWithLastMessage:(TSInteraction *)lastMessage transaction:(YapDatabaseReadWriteTransaction *)transaction; - -- (void)removeAllThreadInteractionsWithTransaction:(YapDatabaseReadWriteTransaction *)transaction; - -- (TSInteraction *)getLastInteractionWithTransaction:(YapDatabaseReadTransaction *)transaction; - -#pragma mark Disappearing Messages - -- (OWSDisappearingMessagesConfiguration *)disappearingMessagesConfigurationWithTransaction: - (YapDatabaseReadTransaction *)transaction; - -- (uint32_t)disappearingMessagesDurationWithTransaction:(YapDatabaseReadTransaction *)transaction; - -#pragma mark Drafts - -/** - * Returns the last known draft for that thread. Always returns a string. Empty string if nil. - * - * @param transaction Database transaction. - * - * @return Last known draft for that thread. - */ -- (NSString *)currentDraftWithTransaction:(YapDatabaseReadTransaction *)transaction; - -/** - * Sets the draft of a thread. Typically called when leaving a conversation view. - * - * @param draftString Draft string to be saved. - * @param transaction Database transaction. - */ -- (void)setDraft:(NSString *)draftString transaction:(YapDatabaseReadWriteTransaction *)transaction; - -#pragma mark Muting - -- (void)updateWithMutedUntilDate:(NSDate * _Nullable)mutedUntilDate transaction:(YapDatabaseReadWriteTransaction *)transaction; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Threads/TSThread.m b/SessionMessagingKit/Threads/TSThread.m deleted file mode 100644 index d9f4f9000..000000000 --- a/SessionMessagingKit/Threads/TSThread.m +++ /dev/null @@ -1,375 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "TSThread.h" -#import -#import -#import -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -BOOL IsNoteToSelfEnabled(void) -{ - return YES; -} - -@interface TSThread () - -@property (nonatomic) NSDate *creationDate; -@property (nonatomic, nullable) NSDate *lastInteractionDate; -@property (nonatomic, nullable) NSNumber *archivedAsOfMessageSortId; -@property (atomic, nullable) NSDate *mutedUntilDate; - -@end - -@implementation TSThread - -#pragma mark Initialization - -+ (NSString *)collection { - return @"TSThread"; -} - -- (instancetype)initWithUniqueId:(NSString *_Nullable)uniqueId -{ - self = [super initWithUniqueId:uniqueId]; - - if (self) { - _creationDate = [NSDate date]; - _messageDraft = nil; - } - - return self; -} - -- (nullable instancetype)initWithCoder:(NSCoder *)coder -{ - self = [super initWithCoder:coder]; - if (!self) { - return self; - } - - // renamed `hasEverHadMessage` -> `shouldBeVisible` - if (!_shouldBeVisible) { - NSNumber *_Nullable legacy_hasEverHadMessage = [coder decodeObjectForKey:@"hasEverHadMessage"]; - - if (legacy_hasEverHadMessage != nil) { - _shouldBeVisible = legacy_hasEverHadMessage.boolValue; - } - } - - NSDate *_Nullable lastMessageDate = [coder decodeObjectOfClass:NSDate.class forKey:@"lastMessageDate"]; - NSDate *_Nullable archivalDate = [coder decodeObjectOfClass:NSDate.class forKey:@"archivalDate"]; - - return self; -} - -- (void)saveWithTransaction:(YapDatabaseReadWriteTransaction *)transaction -{ - [super saveWithTransaction:transaction]; - - [SSKPreferences setHasSavedThreadWithValue:YES transaction:transaction]; -} - -- (void)removeWithTransaction:(YapDatabaseReadWriteTransaction *)transaction -{ - [self removeAllThreadInteractionsWithTransaction:transaction]; - - [super removeWithTransaction:transaction]; -} - -- (void)removeAllThreadInteractionsWithTransaction:(YapDatabaseReadWriteTransaction *)transaction -{ - // We can't safely delete interactions while enumerating them, so - // we collect and delete separately. - // - // We don't want to instantiate the interactions when collecting them - // or when deleting them. - NSMutableArray *interactionIds = [NSMutableArray new]; - YapDatabaseViewTransaction *interactionsByThread = [transaction ext:TSMessageDatabaseViewExtensionName]; - __block BOOL didDetectCorruption = NO; - [interactionsByThread enumerateKeysInGroup:self.uniqueId - usingBlock:^(NSString *collection, NSString *key, NSUInteger index, BOOL *stop) { - if (![key isKindOfClass:[NSString class]] || key.length < 1) { - didDetectCorruption = YES; - return; - } - [interactionIds addObject:key]; - }]; - - if (didDetectCorruption) { - [OWSPrimaryStorage incrementVersionOfDatabaseExtension:TSMessageDatabaseViewExtensionName]; - } - - for (NSString *interactionId in interactionIds) { - // We need to fetch each interaction, since [TSInteraction removeWithTransaction:] does important work. - TSInteraction *_Nullable interaction = - [TSInteraction fetchObjectWithUniqueID:interactionId transaction:transaction]; - if (!interaction) { - continue; - } - [interaction removeWithTransaction:transaction]; - } -} - -- (BOOL)isNoteToSelf -{ - if (!IsNoteToSelfEnabled()) { return NO; } - if (![self isKindOfClass:TSContactThread.class]) { return NO; } - return [self.contactSessionID isEqual:[SNGeneralUtilities getUserPublicKey]]; -} - -// Override in ContactThread -- (BOOL)isMessageRequest { - return NO; -} - -// Override in ContactThread -- (BOOL)isMessageRequestUsingTransaction:(YapDatabaseReadTransaction *)transaction { - return NO; -} - -// Override in ContactThread -- (BOOL)isBlocked { - return NO; -} - -// Override in ContactThread -- (BOOL)isBlockedUsingTransaction:(YapDatabaseReadTransaction *)transaction { - return NO; -} - -#pragma mark To be subclassed. - -- (BOOL)isGroupThread { - return NO; -} - -// Override in ContactThread -- (nullable NSString *)contactSessionID -{ - return nil; -} - -- (NSString *)name { - return nil; -} - -- (NSString *)nameWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - return nil; -} - -- (NSArray *)recipientIdentifiers -{ - return @[]; -} - -#pragma mark Interactions - -/** - * Iterate over this thread's interactions - */ -- (void)enumerateInteractionsWithTransaction:(YapDatabaseReadTransaction *)transaction - usingBlock:(void (^)(TSInteraction *interaction, BOOL *stop))block -{ - YapDatabaseViewTransaction *interactionsByThread = [transaction ext:TSMessageDatabaseViewExtensionName]; - [interactionsByThread - enumerateKeysAndObjectsInGroup:self.uniqueId - usingBlock:^(NSString *collection, NSString *key, id object, NSUInteger index, BOOL *stop) { - TSInteraction *interaction = object; - block(interaction, stop); - }]; -} - -/** - * Enumerates all the threads interactions. Note this will explode if you try to create a transaction in the block. - * If you need a transaction, use the sister method: `enumerateInteractionsWithTransaction:usingBlock` - */ -- (void)enumerateInteractionsUsingBlock:(void (^)(TSInteraction *interaction))block -{ - [self.dbReadWriteConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - [self enumerateInteractionsWithTransaction:transaction - usingBlock:^( - TSInteraction *interaction, BOOL *stop) { - - block(interaction); - }]; - }]; -} - -- (TSInteraction *)lastInteraction -{ - __block TSInteraction *interaction; - [self.dbReadConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - interaction = [self getLastInteractionWithTransaction:transaction]; - }]; - return interaction; -} - -- (TSInteraction *)getLastInteractionWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - YapDatabaseViewTransaction *interactions = [transaction ext:TSMessageDatabaseViewExtensionName]; - return [interactions lastObjectInGroup:self.uniqueId]; -} - -/** - * Useful for tests and debugging. In production use an enumeration method. - */ -- (NSArray *)allInteractions -{ - NSMutableArray *interactions = [NSMutableArray new]; - [self enumerateInteractionsUsingBlock:^(TSInteraction *interaction) { - [interactions addObject:interaction]; - }]; - - return [interactions copy]; -} - -- (NSUInteger)numberOfInteractions -{ - __block NSUInteger count; - [[self dbReadConnection] readWithBlock:^(YapDatabaseReadTransaction *transaction) { - count = [self numberOfInteractionsWithTransaction:transaction]; - }]; - return count; -} - -- (NSUInteger)numberOfInteractionsWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - YapDatabaseViewTransaction *interactionsByThread = [transaction ext:TSMessageDatabaseViewExtensionName]; - return [interactionsByThread numberOfItemsInGroup:self.uniqueId]; -} - -- (NSUInteger)unreadMentionMessageCount -{ - __block NSUInteger unreadMentionMessageCount; - [[self dbReadConnection] readWithBlock:^(YapDatabaseReadTransaction *transaction) { - unreadMentionMessageCount = [self unreadMentionMessageCountWithTransaction:transaction]; - }]; - return unreadMentionMessageCount; -} - -- (NSUInteger)unreadMentionMessageCountWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - YapDatabaseViewTransaction *unreadMentions = [transaction ext:TSUnreadMentionDatabaseViewExtensionName]; - return [unreadMentions numberOfItemsInGroup:self.uniqueId]; -} - -- (nullable TSInteraction *)lastInteractionForInboxWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - __block NSUInteger missedCount = 0; - __block TSInteraction *last = nil; - [[transaction ext:TSMessageDatabaseViewExtensionName] - enumerateKeysAndObjectsInGroup:self.uniqueId - withOptions:NSEnumerationReverse - usingBlock:^(NSString *collection, NSString *key, id object, NSUInteger index, BOOL *stop) { - missedCount++; - TSInteraction *interaction = (TSInteraction *)object; - - if ([TSThread shouldInteractionAppearInInbox:interaction]) { - last = interaction; - - // For long ignored threads, with lots of SN changes this can get really slow. - // I see this in development because I have a lot of long forgotten threads with - // members who's test devices are constantly reinstalled. We could add a - // purpose-built DB view, but I think in the real world this is rare to be a - // hotspot. - - *stop = YES; - } - }]; - return last; -} - -- (NSString *)lastMessageTextWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - TSInteraction *interaction = [self lastInteractionForInboxWithTransaction:transaction]; - if ([interaction conformsToProtocol:@protocol(OWSPreviewText)]) { - id previewable = (id)interaction; - return [previewable previewTextWithTransaction:transaction].filterStringForDisplay; - } else { - return @""; - } -} - -// Returns YES IFF the interaction should show up in the inbox as the last message. -+ (BOOL)shouldInteractionAppearInInbox:(TSInteraction *)interaction -{ - if (interaction.isDynamicInteraction) { - return NO; - } - - if ([interaction isKindOfClass:[TSMessage class]]) { - TSMessage *message = (TSMessage *)interaction; - if (message.isDeleted) { - return NO; - } - } - - return YES; -} - -- (void)updateWithLastMessage:(TSInteraction *)lastMessage transaction:(YapDatabaseReadWriteTransaction *)transaction { - if (![self.class shouldInteractionAppearInInbox:lastMessage]) { - return; - } - - if ([_lastInteractionDate compare: lastMessage.receivedAtDate] == NSOrderedAscending) { - _lastInteractionDate = lastMessage.receivedAtDate; - [super saveWithTransaction:transaction]; - } - - if (!self.shouldBeVisible) { - self.shouldBeVisible = YES; - [self saveWithTransaction:transaction]; - } else { - [self touchWithTransaction:transaction]; - } -} - -#pragma mark Drafts - -- (NSString *)currentDraftWithTransaction:(YapDatabaseReadTransaction *)transaction { - TSThread *thread = [TSThread fetchObjectWithUniqueID:self.uniqueId transaction:transaction]; - if (thread.messageDraft) { - return thread.messageDraft; - } else { - return @""; - } -} - -- (void)setDraft:(NSString *)draftString transaction:(YapDatabaseReadWriteTransaction *)transaction { - TSThread *thread = [TSThread fetchObjectWithUniqueID:self.uniqueId transaction:transaction]; - thread.messageDraft = draftString; - [thread saveWithTransaction:transaction]; -} - -#pragma mark Muting - -- (BOOL)isMuted -{ - NSDate *mutedUntilDate = self.mutedUntilDate; - NSDate *now = [NSDate date]; - return (mutedUntilDate != nil && [mutedUntilDate timeIntervalSinceDate:now] > 0); -} - -- (void)updateWithMutedUntilDate:(NSDate * _Nullable)mutedUntilDate transaction:(YapDatabaseReadWriteTransaction *)transaction -{ - [self applyChangeToSelfAndLatestCopy:transaction - changeBlock:^(TSThread *thread) { - [thread setMutedUntilDate:mutedUntilDate]; - }]; - - [transaction addCompletionQueue:dispatch_get_main_queue() completionBlock:^{ - [NSNotificationCenter.defaultCenter postNotificationName:NSNotification.muteSettingUpdated object:self.uniqueId]; - }]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Utilities/Preferences.swift b/SessionMessagingKit/Utilities/Preferences.swift index f37f048ae..022c1e4c6 100644 --- a/SessionMessagingKit/Utilities/Preferences.swift +++ b/SessionMessagingKit/Utilities/Preferences.swift @@ -45,7 +45,10 @@ public extension Setting.BoolKey { static let playNotificationSoundInForeground: Setting.BoolKey = "playNotificationSoundInForeground" /// A flag indicating whether the user has ever saved a thread - static let hasSavedThreadKey: Setting.BoolKey = "hasSavedThread" + static let hasSavedThread: Setting.BoolKey = "hasSavedThread" + + /// A flag indicating whether the user has ever send a message + static let hasSentAMessage: Setting.BoolKey = "hasSentAMessageKey" } public extension Setting.StringKey { diff --git a/SessionSnodeKit/Database/LegacyDatabase/SSKLegacyModels.swift b/SessionSnodeKit/Database/LegacyDatabase/SSKLegacy.swift similarity index 99% rename from SessionSnodeKit/Database/LegacyDatabase/SSKLegacyModels.swift rename to SessionSnodeKit/Database/LegacyDatabase/SSKLegacy.swift index cb271285b..375634444 100644 --- a/SessionSnodeKit/Database/LegacyDatabase/SSKLegacyModels.swift +++ b/SessionSnodeKit/Database/LegacyDatabase/SSKLegacy.swift @@ -2,7 +2,7 @@ import Foundation -public enum Legacy { +public enum SSKLegacy { // MARK: - Collections and Keys internal static let swarmCollectionPrefix = "LokiSwarmCollection-" diff --git a/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 09cf59825..a29fde245 100644 --- a/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -11,28 +11,28 @@ enum _003_YDBToGRDBMigration: Migration { // MARK: - OnionRequestPath, Snode Pool & Swarm // Note: Want to exclude the Snode's we already added from the 'onionRequestPathResult' - var snodeResult: Set = [] - var snodeSetResult: [String: Set] = [:] + var snodeResult: Set = [] + var snodeSetResult: [String: Set] = [:] var lastSnodePoolRefreshDate: Date? = nil // Map the Legacy types for the NSKeyedUnarchiver NSKeyedUnarchiver.setClass( - Legacy.Snode.self, + SSKLegacy.Snode.self, forClassName: "SessionSnodeKit.Snode" ) Storage.read { transaction in // Process the lastSnodePoolRefreshDate lastSnodePoolRefreshDate = transaction.object( - forKey: Legacy.lastSnodePoolRefreshDateKey, - inCollection: Legacy.lastSnodePoolRefreshDateCollection + forKey: SSKLegacy.lastSnodePoolRefreshDateKey, + inCollection: SSKLegacy.lastSnodePoolRefreshDateCollection ) as? Date // Process the OnionRequestPaths if - let path0Snode0 = transaction.object(forKey: "0-0", inCollection: Legacy.onionRequestPathCollection) as? Legacy.Snode, - let path0Snode1 = transaction.object(forKey: "0-1", inCollection: Legacy.onionRequestPathCollection) as? Legacy.Snode, - let path0Snode2 = transaction.object(forKey: "0-2", inCollection: Legacy.onionRequestPathCollection) as? Legacy.Snode + let path0Snode0 = transaction.object(forKey: "0-0", inCollection: SSKLegacy.onionRequestPathCollection) as? SSKLegacy.Snode, + let path0Snode1 = transaction.object(forKey: "0-1", inCollection: SSKLegacy.onionRequestPathCollection) as? SSKLegacy.Snode, + let path0Snode2 = transaction.object(forKey: "0-2", inCollection: SSKLegacy.onionRequestPathCollection) as? SSKLegacy.Snode { snodeResult.insert(path0Snode0) snodeResult.insert(path0Snode1) @@ -40,9 +40,9 @@ enum _003_YDBToGRDBMigration: Migration { snodeSetResult["\(SnodeSet.onionRequestPathPrefix)0"] = [ path0Snode0, path0Snode1, path0Snode2 ] if - let path1Snode0 = transaction.object(forKey: "1-0", inCollection: Legacy.onionRequestPathCollection) as? Legacy.Snode, - let path1Snode1 = transaction.object(forKey: "1-1", inCollection: Legacy.onionRequestPathCollection) as? Legacy.Snode, - let path1Snode2 = transaction.object(forKey: "1-2", inCollection: Legacy.onionRequestPathCollection) as? Legacy.Snode + let path1Snode0 = transaction.object(forKey: "1-0", inCollection: SSKLegacy.onionRequestPathCollection) as? SSKLegacy.Snode, + let path1Snode1 = transaction.object(forKey: "1-1", inCollection: SSKLegacy.onionRequestPathCollection) as? SSKLegacy.Snode, + let path1Snode2 = transaction.object(forKey: "1-2", inCollection: SSKLegacy.onionRequestPathCollection) as? SSKLegacy.Snode { snodeResult.insert(path1Snode0) snodeResult.insert(path1Snode1) @@ -52,8 +52,8 @@ enum _003_YDBToGRDBMigration: Migration { } // Process the SnodePool - transaction.enumerateKeysAndObjects(inCollection: Legacy.snodePoolCollection) { _, object, _ in - guard let snode = object as? Legacy.Snode else { return } + transaction.enumerateKeysAndObjects(inCollection: SSKLegacy.snodePoolCollection) { _, object, _ in + guard let snode = object as? SSKLegacy.Snode else { return } snodeResult.insert(snode) } @@ -61,16 +61,16 @@ enum _003_YDBToGRDBMigration: Migration { var swarmCollections: Set = [] transaction.enumerateCollections { collectionName, _ in - if collectionName.starts(with: Legacy.swarmCollectionPrefix) { - swarmCollections.insert(collectionName.substring(from: Legacy.swarmCollectionPrefix.count)) + if collectionName.starts(with: SSKLegacy.swarmCollectionPrefix) { + swarmCollections.insert(collectionName.substring(from: SSKLegacy.swarmCollectionPrefix.count)) } } for swarmCollection in swarmCollections { - let collection: String = "\(Legacy.swarmCollectionPrefix)\(swarmCollection)" + let collection: String = "\(SSKLegacy.swarmCollectionPrefix)\(swarmCollection)" transaction.enumerateKeysAndObjects(inCollection: collection) { _, object, _ in - guard let snode = object as? Legacy.Snode else { return } + guard let snode = object as? SSKLegacy.Snode else { return } snodeResult.insert(snode) snodeSetResult[swarmCollection] = (snodeSetResult[swarmCollection] ?? Set()).inserting(snode) } @@ -112,13 +112,13 @@ enum _003_YDBToGRDBMigration: Migration { // TODO: Move into the top read block??? Storage.read { transaction in // Extract the received message hashes - transaction.enumerateKeysAndObjects(inCollection: Legacy.receivedMessagesCollection) { key, object, _ in + transaction.enumerateKeysAndObjects(inCollection: SSKLegacy.receivedMessagesCollection) { key, object, _ in guard let hashSet = object as? Set else { return } receivedMessageResults[key] = hashSet } // Retrieve the last message info - transaction.enumerateKeysAndObjects(inCollection: Legacy.lastMessageHashCollection) { key, object, _ in + transaction.enumerateKeysAndObjects(inCollection: SSKLegacy.lastMessageHashCollection) { key, object, _ in guard let lastMessageJson = object as? JSON else { return } guard let lastMessageHash: String = lastMessageJson["hash"] as? String else { return } diff --git a/SessionUtilitiesKit/Database/LegacyDatabase/SUKLegacyModels.swift b/SessionUtilitiesKit/Database/LegacyDatabase/SUKLegacy.swift similarity index 97% rename from SessionUtilitiesKit/Database/LegacyDatabase/SUKLegacyModels.swift rename to SessionUtilitiesKit/Database/LegacyDatabase/SUKLegacy.swift index 28fd3aaeb..1bd4f7da4 100644 --- a/SessionUtilitiesKit/Database/LegacyDatabase/SUKLegacyModels.swift +++ b/SessionUtilitiesKit/Database/LegacyDatabase/SUKLegacy.swift @@ -2,9 +2,7 @@ import Foundation -public typealias SUKLegacy = Legacy - -public enum Legacy { +public enum SUKLegacy { // MARK: - Collections and Keys internal static let userAccountRegisteredNumberKey = "TSStorageRegisteredNumberKey" diff --git a/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 436ad7f85..3f8cc5b32 100644 --- a/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -14,42 +14,42 @@ enum _003_YDBToGRDBMigration: Migration { var seedHexString: String? var userEd25519SecretKeyHexString: String? var userEd25519PublicKeyHexString: String? - var userX25519KeyPair: Legacy.KeyPair? + var userX25519KeyPair: SUKLegacy.KeyPair? // Map the Legacy types for the NSKeyedUnarchiver NSKeyedUnarchiver.setClass( - Legacy.KeyPair.self, + SUKLegacy.KeyPair.self, forClassName: "ECKeyPair" ) Storage.read { transaction in registeredNumber = transaction.object( - forKey: Legacy.userAccountRegisteredNumberKey, - inCollection: Legacy.userAccountCollection + forKey: SUKLegacy.userAccountRegisteredNumberKey, + inCollection: SUKLegacy.userAccountCollection ) as? String // Note: The 'seed', 'ed25519SecretKey' and 'ed25519PublicKey' were // all previously stored as hex strings, so we need to convert them // to data before we store them in the new database seedHexString = transaction.object( - forKey: Legacy.identityKeyStoreSeedKey, - inCollection: Legacy.identityKeyStoreCollection + forKey: SUKLegacy.identityKeyStoreSeedKey, + inCollection: SUKLegacy.identityKeyStoreCollection ) as? String userEd25519SecretKeyHexString = transaction.object( - forKey: Legacy.identityKeyStoreEd25519SecretKey, - inCollection: Legacy.identityKeyStoreCollection + forKey: SUKLegacy.identityKeyStoreEd25519SecretKey, + inCollection: SUKLegacy.identityKeyStoreCollection ) as? String userEd25519PublicKeyHexString = transaction.object( - forKey: Legacy.identityKeyStoreEd25519PublicKey, - inCollection: Legacy.identityKeyStoreCollection + forKey: SUKLegacy.identityKeyStoreEd25519PublicKey, + inCollection: SUKLegacy.identityKeyStoreCollection ) as? String userX25519KeyPair = transaction.object( - forKey: Legacy.identityKeyStoreIdentityKey, - inCollection: Legacy.identityKeyStoreCollection - ) as? Legacy.KeyPair + forKey: SUKLegacy.identityKeyStoreIdentityKey, + inCollection: SUKLegacy.identityKeyStoreCollection + ) as? SUKLegacy.KeyPair } // No need to continue if the user isn't registered @@ -60,7 +60,7 @@ enum _003_YDBToGRDBMigration: Migration { let seedHexString: String = seedHexString, let userEd25519SecretKeyHexString: String = userEd25519SecretKeyHexString, let userEd25519PublicKeyHexString: String = userEd25519PublicKeyHexString, - let userX25519KeyPair: Legacy.KeyPair = userX25519KeyPair + let userX25519KeyPair: SUKLegacy.KeyPair = userX25519KeyPair else { // If this is a fresh install then we would have created all of the Identity // values directly within the 'Identity' table so this is actually a valid diff --git a/SessionUtilitiesKit/Database/Models/Identity.swift b/SessionUtilitiesKit/Database/Models/Identity.swift index 3ebf1d214..577f55bfc 100644 --- a/SessionUtilitiesKit/Database/Models/Identity.swift +++ b/SessionUtilitiesKit/Database/Models/Identity.swift @@ -127,18 +127,4 @@ public extension Identity { return data.toHexString() } } - - static func clearAll() { - GRDBStorage.shared.write { db in - try Identity.deleteAll(db) - } - } -} - -@objc(SUKIdentity) -public class objc_Identity: NSObject { - @objc(clearAll) - public static func objc_clearAll() { - Identity.clearAll() - } } diff --git a/SignalUtilitiesKit/Database/Migrations/BlockingManagerRemovalMigration.swift b/SignalUtilitiesKit/Database/Migrations/BlockingManagerRemovalMigration.swift index 676eb1252..1e2ead55e 100644 --- a/SignalUtilitiesKit/Database/Migrations/BlockingManagerRemovalMigration.swift +++ b/SignalUtilitiesKit/Database/Migrations/BlockingManagerRemovalMigration.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import YapDatabase import SessionMessagingKit @objc(SNBlockingManagerRemovalMigration) @@ -19,6 +20,12 @@ public class BlockingManagerRemovalMigration: OWSDatabaseMigration { let kOWSBlockingManager_BlockListCollection: String = "kOWSBlockingManager_BlockedPhoneNumbersCollection" let kOWSBlockingManager_BlockedPhoneNumbersKey: String = "kOWSBlockingManager_BlockedPhoneNumbersKey" + // Note: These will be done in the YDB to GRDB migration but have added it here to be safe + NSKeyedUnarchiver.setClass( + SMKLegacy._Contact.self, + forClassName: "SNContact" + ) + let dbConnection: YapDatabaseConnection = primaryStorage.newDatabaseConnection() let blockedSessionIds: Set = Set(dbConnection.object( @@ -28,10 +35,10 @@ public class BlockingManagerRemovalMigration: OWSDatabaseMigration { Storage.write( with: { transaction in - var result: Set = [] + var result: Set = [] - transaction.enumerateRows(inCollection: Legacy.contactCollection) { _, object, _, _ in - guard let contact = object as? SessionMessagingKit.Legacy._Contact else { return } + transaction.enumerateRows(inCollection: SMKLegacy.contactCollection) { _, object, _, _ in + guard let contact = object as? SMKLegacy._Contact else { return } result.insert(contact) } @@ -39,7 +46,7 @@ public class BlockingManagerRemovalMigration: OWSDatabaseMigration { .filter { contact -> Bool in blockedSessionIds.contains(contact.sessionID) } .forEach { contact in contact.isBlocked = true - transaction.setObject(contact, forKey: contact.sessionID, inCollection: Legacy.contactCollection) + transaction.setObject(contact, forKey: contact.sessionID, inCollection: SMKLegacy.contactCollection) } // Now that the values have been migrated we can clear out the old collection diff --git a/SignalUtilitiesKit/Database/Migrations/ContactsMigration.swift b/SignalUtilitiesKit/Database/Migrations/ContactsMigration.swift index f0cbead75..b2290d10b 100644 --- a/SignalUtilitiesKit/Database/Migrations/ContactsMigration.swift +++ b/SignalUtilitiesKit/Database/Migrations/ContactsMigration.swift @@ -1,10 +1,11 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import YapDatabase import SessionMessagingKit @objc(SNContactsMigration) -public class ContactsMigration : OWSDatabaseMigration { +public class ContactsMigration: OWSDatabaseMigration { @objc class func migrationId() -> String { @@ -17,20 +18,33 @@ public class ContactsMigration : OWSDatabaseMigration { private func doMigrationAsync(completion: @escaping OWSDatabaseMigrationCompletion) { var contacts: [SMKLegacy._Contact] = [] - TSContactThread.enumerateCollectionObjects { object, _ in - guard let thread = object as? TSContactThread else { return } - let sessionID = thread.contactSessionID() - var contact: SMKLegacy._Contact? - - Storage.read { transaction in - contact = transaction.object(forKey: sessionID, inCollection: SMKLegacy.contactCollection) as? SMKLegacy._Contact - } - - if let contact: SMKLegacy._Contact = contact { - contact.isTrusted = true - contacts.append(contact) + + // Note: These will be done in the YDB to GRDB migration but have added it here to be safe + NSKeyedUnarchiver.setClass( + SMKLegacy._Thread.self, + forClassName: "TSThread" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._ContactThread.self, + forClassName: "TSContactThread" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._Contact.self, + forClassName: "SNContact" + ) + + Storage.read { transaction in + transaction.enumerateKeysAndObjects(inCollection: SMKLegacy.threadCollection) { _, object, _ in + guard let thread = object as? SMKLegacy._ContactThread else { return } + + let sessionId: String = SMKLegacy._ContactThread.contactSessionId(fromThreadId: thread.uniqueId) + let contact: SMKLegacy._Contact? = transaction.object(forKey: sessionId, inCollection: SMKLegacy.contactCollection) as? SMKLegacy._Contact + + contact?.isTrusted = true + contacts = contacts.appending(contact) } } + Storage.write(with: { transaction in contacts.forEach { contact in transaction.setObject(contact, forKey: contact.sessionID, inCollection: SMKLegacy.contactCollection) diff --git a/SignalUtilitiesKit/Database/Migrations/MessageRequestsMigration.swift b/SignalUtilitiesKit/Database/Migrations/MessageRequestsMigration.swift index 6593880a3..92223ee70 100644 --- a/SignalUtilitiesKit/Database/Migrations/MessageRequestsMigration.swift +++ b/SignalUtilitiesKit/Database/Migrations/MessageRequestsMigration.swift @@ -1,10 +1,11 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import YapDatabase import SessionMessagingKit @objc(SNMessageRequestsMigration) -public class MessageRequestsMigration : OWSDatabaseMigration { +public class MessageRequestsMigration: OWSDatabaseMigration { @objc class func migrationId() -> String { @@ -16,42 +17,61 @@ public class MessageRequestsMigration : OWSDatabaseMigration { } private func doMigrationAsync(completion: @escaping OWSDatabaseMigrationCompletion) { + let userPublicKey: String = getUserHexEncodedPublicKey() var contacts: Set = Set() - var threads: [TSThread] = [] + var threads: [SMKLegacy._Thread] = [] + + // Note: These will be done in the YDB to GRDB migration but have added it here to be safe + NSKeyedUnarchiver.setClass( + SMKLegacy._Thread.self, + forClassName: "TSThread" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._ContactThread.self, + forClassName: "TSContactThread" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._GroupThread.self, + forClassName: "TSGroupThread" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._GroupModel.self, + forClassName: "TSGroupModel" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._Contact.self, + forClassName: "SNContact" + ) - TSThread.enumerateCollectionObjects { object, _ in - guard let thread: TSThread = object as? TSThread else { return } + Storage.read { transaction in + transaction.enumerateKeysAndObjects(inCollection: SMKLegacy.threadCollection) { _, object, _ in + guard let thread: SMKLegacy._Thread = object as? SMKLegacy._Thread else { return } - Storage.read { transaction in - if let contactThread: TSContactThread = thread as? TSContactThread { - let sessionId: String = contactThread.contactSessionID() + if thread is SMKLegacy._ContactThread { + let sessionId: String = SMKLegacy._ContactThread.contactSessionId(fromThreadId: thread.uniqueId) - if let contact: SMKLegacy._Contact = transaction.object(forKey: sessionId, inCollection: Legacy.contactCollection) as? SMKLegacy._Contact { + if let contact: SMKLegacy._Contact = transaction.object(forKey: sessionId, inCollection: SMKLegacy.contactCollection) as? SMKLegacy._Contact { contact.isApproved = true contact.didApproveMe = true contacts.insert(contact) } } - else if let groupThread: TSGroupThread = thread as? TSGroupThread, groupThread.isClosedGroup { + else if let groupThread: SMKLegacy._GroupThread = thread as? SMKLegacy._GroupThread, groupThread.isClosedGroup { let groupAdmins: [String] = groupThread.groupModel.groupAdminIds groupAdmins.forEach { sessionId in - if let contact: SMKLegacy._Contact = transaction.object(forKey: sessionId, inCollection: Legacy.contactCollection) as? SMKLegacy._Contact { + if let contact: SMKLegacy._Contact = transaction.object(forKey: sessionId, inCollection: SMKLegacy.contactCollection) as? SMKLegacy._Contact { contact.isApproved = true contact.didApproveMe = true contacts.insert(contact) } } } + + threads.append(thread) } - threads.append(thread) - } - - let userPublicKey: String = getUserHexEncodedPublicKey() - - Storage.read { transaction in - if let user = transaction.object(forKey: userPublicKey, inCollection: Legacy.contactCollection) as? SMKLegacy._Contact { + if let user = transaction.object(forKey: userPublicKey, inCollection: SMKLegacy.contactCollection) as? SMKLegacy._Contact { user.isApproved = true user.didApproveMe = true contacts.insert(user) @@ -60,10 +80,10 @@ public class MessageRequestsMigration : OWSDatabaseMigration { Storage.write(with: { transaction in contacts.forEach { contact in - transaction.setObject(contact, forKey: contact.sessionID, inCollection: Legacy.contactCollection) + transaction.setObject(contact, forKey: contact.sessionID, inCollection: SMKLegacy.contactCollection) } threads.forEach { thread in - thread.save(with: transaction) + transaction.setObject(thread, forKey: thread.uniqueId, inCollection: SMKLegacy.threadCollection) } self.save(with: transaction) // Intentionally capture self }, completion: { diff --git a/SignalUtilitiesKit/Database/Migrations/OpenGroupServerIdLookupMigration.swift b/SignalUtilitiesKit/Database/Migrations/OpenGroupServerIdLookupMigration.swift index 1765e463c..54f7f0e83 100644 --- a/SignalUtilitiesKit/Database/Migrations/OpenGroupServerIdLookupMigration.swift +++ b/SignalUtilitiesKit/Database/Migrations/OpenGroupServerIdLookupMigration.swift @@ -1,6 +1,8 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import YapDatabase +import SessionMessagingKit @objc(SNOpenGroupServerIdLookupMigration) public class OpenGroupServerIdLookupMigration: OWSDatabaseMigration { @@ -16,14 +18,35 @@ public class OpenGroupServerIdLookupMigration: OWSDatabaseMigration { private func doMigrationAsync(completion: @escaping OWSDatabaseMigrationCompletion) { var lookups: [OpenGroupServerIdLookup] = [] + // Note: These will be done in the YDB to GRDB migration but have added it here to be safe + NSKeyedUnarchiver.setClass( + SMKLegacy._Thread.self, + forClassName: "TSThread" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._ContactThread.self, + forClassName: "TSContactThread" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._GroupThread.self, + forClassName: "TSGroupThread" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._GroupModel.self, + forClassName: "TSGroupModel" + ) + // TODO: Add, SMKLegacy._OpenGroup, SMKLegacy._TSMessage (and related) + Storage.write(with: { transaction in - TSGroupThread.enumerateCollectionObjects(with: transaction) { object, _ in - guard let thread: TSGroupThread = object as? TSGroupThread else { return } - guard let threadId: String = thread.uniqueId else { return } - guard let openGroup: OpenGroupV2 = Storage.shared.getV2OpenGroup(for: threadId) else { return } + transaction.enumerateKeysAndObjects(inCollection: SMKLegacy.threadCollection) { _, object, _ in + guard let thread = object as? SMKLegacy._GroupThread else { return } + guard let openGroup: OpenGroupV2 = Storage.shared.getV2OpenGroup(for: thread.uniqueId) else { return } + guard let interactionsByThread: YapDatabaseViewTransaction = transaction.ext(SMKLegacy.messageDatabaseViewExtensionName) as? YapDatabaseViewTransaction else { + return + } - thread.enumerateInteractions(with: transaction) { interaction, _ in - guard let tsMessage: TSMessage = interaction as? TSMessage else { return } + interactionsByThread.enumerateKeysAndObjects(inGroup: thread.uniqueId) { _, _, object, _, _ in + guard let tsMessage: TSMessage = object as? TSMessage else { return } guard let tsMessageId: String = tsMessage.uniqueId else { return } lookups.append( diff --git a/SignalUtilitiesKit/Utilities/ThreadUtil.m b/SignalUtilitiesKit/Utilities/ThreadUtil.m index 34ddf9398..702d16870 100644 --- a/SignalUtilitiesKit/Utilities/ThreadUtil.m +++ b/SignalUtilitiesKit/Utilities/ThreadUtil.m @@ -8,11 +8,9 @@ #import #import #import -#import #import #import #import -#import #import diff --git a/SignalUtilitiesKit/Utilities/VersionMigrations.m b/SignalUtilitiesKit/Utilities/VersionMigrations.m index e0d0787b5..6aca6986b 100644 --- a/SignalUtilitiesKit/Utilities/VersionMigrations.m +++ b/SignalUtilitiesKit/Utilities/VersionMigrations.m @@ -8,8 +8,6 @@ #import #import #import -#import -#import #import #import From 5de8d9c7a80e07b1429691347fe0846a3c32f8d4 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 23 May 2022 09:49:04 +1000 Subject: [PATCH 083/157] Removed a bunch of legacy database types Removed the old OWSProfileManager and OWSUserProfile (refactored) Removed all the old TSInteraction/etc. types (replaced with new database types) Removed the old Quote models (refactored) Removed the old Attachment models (refactored) Removed the old recipient identity (unused) Deleted a number of other unused files --- Session.xcodeproj/project.pbxproj | 212 +---- .../ConversationVC+Interaction.swift | 13 +- Session/Conversations/ConversationVC.swift | 4 - Session/Conversations/ConversationViewItem.h | 9 - .../Conversations/Input View/InputView.swift | 12 +- .../LongTextViewController.swift | 170 ---- .../Content Views/LinkPreviewState.swift | 311 +++---- .../Content Views/LinkPreviewView.swift | 16 +- .../Message Cells/VisibleMessageCell.swift | 2 +- .../OWSConversationSettingsViewController.m | 1 - Session/Meta/SessionApp.swift | 2 +- Session/Meta/Signal-Bridging-Header.h | 8 - .../PrivacySettingsTableViewController.m | 1 - Session/Settings/SettingsVC.swift | 30 +- .../Database/LegacyDatabase/SMKLegacy.swift | 498 ++++++++--- .../Migrations/_003_YDBToGRDBMigration.swift | 179 +++- .../Database/Models/Attachment.swift | 4 +- .../Database/Models/LinkPreview.swift | 367 +++++++- .../Database/Models/Profile.swift | 3 +- SessionMessagingKit/Database/OWSStorage.m | 1 - .../Database/TSDatabaseSecondaryIndexes.m | 1 - SessionMessagingKit/Database/TSDatabaseView.m | 4 - .../Jobs/Types/UpdateProfilePictureJob.swift | 2 +- .../Signal/TSIncomingMessage+Conversion.swift | 30 - .../Messages/Signal/TSIncomingMessage.h | 94 -- .../Messages/Signal/TSIncomingMessage.m | 131 --- .../Messages/Signal/TSInfoMessage.h | 61 -- .../Messages/Signal/TSInfoMessage.m | 124 --- .../Messages/Signal/TSInteraction.h | 84 -- .../Messages/Signal/TSInteraction.m | 272 ------ .../Messages/Signal/TSMessage.h | 90 -- .../Messages/Signal/TSMessage.m | 443 ---------- .../Signal/TSOutgoingMessage+Conversion.swift | 60 -- .../Messages/Signal/TSOutgoingMessage.h | 228 ----- .../Messages/Signal/TSOutgoingMessage.m | 576 ------------- .../VisibleMessage+Quote.swift | 11 - .../Meta/SessionMessagingKit.h | 11 - .../Attachments/SignalAttachment.swift | 120 ++- .../Attachments/TSAttachment.h | 105 --- .../Attachments/TSAttachment.m | 280 ------ .../TSAttachmentPointer+Conversion.swift | 24 - .../Attachments/TSAttachmentPointer.h | 72 -- .../Attachments/TSAttachmentPointer.m | 206 ----- .../Attachments/TSAttachmentStream.h | 105 --- .../Attachments/TSAttachmentStream.m | 816 ------------------ .../Link Previews/LinkPreviewDraft.swift | 27 + .../Link Previews/LinkPreviewError.swift | 14 + .../OWSLinkPreview+Conversion.swift | 27 - .../Link Previews/OWSLinkPreview.swift | 715 --------------- .../OWSQuotedReplyModel+Conversion.swift | 14 - .../Quotes/OWSQuotedReplyModel.h | 57 -- .../Quotes/OWSQuotedReplyModel.m | 235 ----- .../Quotes/TSQuotedMessage+Conversion.swift | 32 - .../Quotes/TSQuotedMessage.h | 108 --- .../Quotes/TSQuotedMessage.m | 339 -------- .../To Do/OWSRecipientIdentity.h | 47 - .../To Do/OWSRecipientIdentity.m | 115 --- SessionMessagingKit/To Do/OWSUserProfile.h | 25 - SessionMessagingKit/To Do/OWSUserProfile.m | 86 -- .../Utilities/OWSAudioPlayer.m | 1 - .../Utilities/OWSPreferences.h | 1 - .../Utilities/OWSPreferences.m | 16 - .../Utilities/ProfileManager.swift | 167 ++-- .../Utilities/ProfileManagerError.swift | 21 + SessionShareExtension/ThreadPickerVC.swift | 9 +- .../OWSPrimaryStorage+keyFromIntLong.h | 15 - .../OWSPrimaryStorage+keyFromIntLong.m | 18 - .../Database/ThreadViewHelper.h | 30 - .../Database/ThreadViewHelper.m | 220 ----- .../MediaMessageView.swift | 7 +- .../MessageApprovalViewController.swift | 209 ----- .../Messaging/ConversationStyle.swift | 240 ------ .../DisappearingTimerConfigurationView.swift | 116 --- .../Messaging/OWSUnreadIndicator.h | 40 - .../Messaging/OWSUnreadIndicator.m | 49 -- SignalUtilitiesKit/Meta/SignalUtilitiesKit.h | 4 - SignalUtilitiesKit/To Do/GroupUtilities.swift | 23 - SignalUtilitiesKit/To Do/OWSProfileManager.h | 63 -- SignalUtilitiesKit/To Do/OWSProfileManager.m | 736 ---------------- SignalUtilitiesKit/Utilities/ThreadUtil.h | 81 -- SignalUtilitiesKit/Utilities/ThreadUtil.m | 343 -------- 81 files changed, 1277 insertions(+), 8766 deletions(-) delete mode 100644 Session/Conversations/LongTextViewController.swift delete mode 100644 SessionMessagingKit/Messages/Signal/TSIncomingMessage+Conversion.swift delete mode 100644 SessionMessagingKit/Messages/Signal/TSIncomingMessage.h delete mode 100644 SessionMessagingKit/Messages/Signal/TSIncomingMessage.m delete mode 100644 SessionMessagingKit/Messages/Signal/TSInfoMessage.h delete mode 100644 SessionMessagingKit/Messages/Signal/TSInfoMessage.m delete mode 100644 SessionMessagingKit/Messages/Signal/TSInteraction.h delete mode 100644 SessionMessagingKit/Messages/Signal/TSInteraction.m delete mode 100644 SessionMessagingKit/Messages/Signal/TSMessage.h delete mode 100644 SessionMessagingKit/Messages/Signal/TSMessage.m delete mode 100644 SessionMessagingKit/Messages/Signal/TSOutgoingMessage+Conversion.swift delete mode 100644 SessionMessagingKit/Messages/Signal/TSOutgoingMessage.h delete mode 100644 SessionMessagingKit/Messages/Signal/TSOutgoingMessage.m delete mode 100644 SessionMessagingKit/Sending & Receiving/Attachments/TSAttachment.h delete mode 100644 SessionMessagingKit/Sending & Receiving/Attachments/TSAttachment.m delete mode 100644 SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentPointer+Conversion.swift delete mode 100644 SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentPointer.h delete mode 100644 SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentPointer.m delete mode 100644 SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentStream.h delete mode 100644 SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentStream.m create mode 100644 SessionMessagingKit/Sending & Receiving/Link Previews/LinkPreviewDraft.swift create mode 100644 SessionMessagingKit/Sending & Receiving/Link Previews/LinkPreviewError.swift delete mode 100644 SessionMessagingKit/Sending & Receiving/Link Previews/OWSLinkPreview+Conversion.swift delete mode 100644 SessionMessagingKit/Sending & Receiving/Link Previews/OWSLinkPreview.swift delete mode 100644 SessionMessagingKit/Sending & Receiving/Quotes/OWSQuotedReplyModel+Conversion.swift delete mode 100644 SessionMessagingKit/Sending & Receiving/Quotes/OWSQuotedReplyModel.h delete mode 100644 SessionMessagingKit/Sending & Receiving/Quotes/OWSQuotedReplyModel.m delete mode 100644 SessionMessagingKit/Sending & Receiving/Quotes/TSQuotedMessage+Conversion.swift delete mode 100644 SessionMessagingKit/Sending & Receiving/Quotes/TSQuotedMessage.h delete mode 100644 SessionMessagingKit/Sending & Receiving/Quotes/TSQuotedMessage.m delete mode 100644 SessionMessagingKit/To Do/OWSRecipientIdentity.h delete mode 100644 SessionMessagingKit/To Do/OWSRecipientIdentity.m delete mode 100644 SessionMessagingKit/To Do/OWSUserProfile.h delete mode 100644 SessionMessagingKit/To Do/OWSUserProfile.m create mode 100644 SessionMessagingKit/Utilities/ProfileManagerError.swift delete mode 100644 SignalUtilitiesKit/Database/OWSPrimaryStorage+keyFromIntLong.h delete mode 100644 SignalUtilitiesKit/Database/OWSPrimaryStorage+keyFromIntLong.m delete mode 100644 SignalUtilitiesKit/Database/ThreadViewHelper.h delete mode 100644 SignalUtilitiesKit/Database/ThreadViewHelper.m delete mode 100644 SignalUtilitiesKit/Media Viewing & Editing/MessageApprovalViewController.swift delete mode 100644 SignalUtilitiesKit/Messaging/ConversationStyle.swift delete mode 100644 SignalUtilitiesKit/Messaging/DisappearingTimerConfigurationView.swift delete mode 100644 SignalUtilitiesKit/Messaging/OWSUnreadIndicator.h delete mode 100644 SignalUtilitiesKit/Messaging/OWSUnreadIndicator.m delete mode 100644 SignalUtilitiesKit/To Do/GroupUtilities.swift delete mode 100644 SignalUtilitiesKit/To Do/OWSProfileManager.h delete mode 100644 SignalUtilitiesKit/To Do/OWSProfileManager.m delete mode 100644 SignalUtilitiesKit/Utilities/ThreadUtil.h delete mode 100644 SignalUtilitiesKit/Utilities/ThreadUtil.m diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 2f634819d..494261eb4 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -26,7 +26,6 @@ 3478504C1FD7496D007B8332 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B66DBF4919D5BBC8006EA940 /* Images.xcassets */; }; 347850551FD749C0007B8332 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = B6F509951AA53F760068F56A /* Localizable.strings */; }; 3488F9362191CC4000E524CC /* MediaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3488F9352191CC4000E524CC /* MediaView.swift */; }; - 3496744F2076ACD000080B5F /* LongTextViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3496744E2076ACCE00080B5F /* LongTextViewController.swift */; }; 3496955C219B605E00DCFE74 /* ImagePickerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34969559219B605E00DCFE74 /* ImagePickerController.swift */; }; 3496955D219B605E00DCFE74 /* PhotoCollectionPickerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3496955A219B605E00DCFE74 /* PhotoCollectionPickerController.swift */; }; 3496955E219B605E00DCFE74 /* PhotoLibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3496955B219B605E00DCFE74 /* PhotoLibrary.swift */; }; @@ -174,9 +173,6 @@ B85357BF23A1AE0800AAF6CD /* SeedReminderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B85357BE23A1AE0800AAF6CD /* SeedReminderView.swift */; }; B85357C323A1BD1200AAF6CD /* SeedVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B85357C223A1BD1200AAF6CD /* SeedVC.swift */; }; B8544E3323D50E4900299F14 /* SNAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8544E3223D50E4900299F14 /* SNAppearance.swift */; }; - B8566C63256F55930045A0B9 /* OWSLinkPreview+Conversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8566C62256F55930045A0B9 /* OWSLinkPreview+Conversion.swift */; }; - B8566C6C256F60F50045A0B9 /* OWSUserProfile.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2D1255B6DAF007E1867 /* OWSUserProfile.m */; }; - B8566C7D256F62030045A0B9 /* OWSUserProfile.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF2D3255B6DAF007E1867 /* OWSUserProfile.h */; settings = {ATTRIBUTES = (Public, ); }; }; B8569AC325CB5D2900DBA3DB /* ConversationVC+Interaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8569AC225CB5D2900DBA3DB /* ConversationVC+Interaction.swift */; }; B8569AE325CBB19A00DBA3DB /* DocumentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8569AE225CBB19A00DBA3DB /* DocumentView.swift */; }; B86BD08423399ACF000F5AE3 /* Modal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B86BD08323399ACF000F5AE3 /* Modal.swift */; }; @@ -224,8 +220,6 @@ B8BB82A5238F627000BA5194 /* HomeVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8BB82A4238F627000BA5194 /* HomeVC.swift */; }; B8BC00C0257D90E30032E807 /* General.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8BC00BF257D90E30032E807 /* General.swift */; }; B8C2B2C82563685C00551B4D /* CircleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8C2B2C72563685C00551B4D /* CircleView.swift */; }; - B8C2B332256376F000551B4D /* ThreadUtil.m in Sources */ = {isa = PBXBuildFile; fileRef = B8C2B331256376F000551B4D /* ThreadUtil.m */; }; - B8C2B3442563782400551B4D /* ThreadUtil.h in Headers */ = {isa = PBXBuildFile; fileRef = B8C2B33B2563770800551B4D /* ThreadUtil.h */; settings = {ATTRIBUTES = (Public, ); }; }; B8CCF6352396005F0091D419 /* SpaceMono-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = B8CCF6342396005F0091D419 /* SpaceMono-Regular.ttf */; }; B8CCF63723961D6D0091D419 /* NewDMVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8CCF63623961D6D0091D419 /* NewDMVC.swift */; }; B8CCF63F23975CFB0091D419 /* JoinOpenGroupVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8CCF63E23975CFB0091D419 /* JoinOpenGroupVC.swift */; }; @@ -289,24 +283,8 @@ C32C5A36256DB856003C73A2 /* LKGroupUtilities.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBCA255A581700E217F9 /* LKGroupUtilities.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5A47256DB8F0003C73A2 /* ECKeyPair+Hexadecimal.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA73255A57FA00E217F9 /* ECKeyPair+Hexadecimal.swift */; }; C32C5A48256DB8F0003C73A2 /* BuildConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAA8255A57FF00E217F9 /* BuildConfiguration.swift */; }; - C32C5A75256DBBCF003C73A2 /* TSAttachmentPointer+Conversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = C379DCFD25673DBC0002D4EB /* TSAttachmentPointer+Conversion.swift */; }; C32C5A76256DBBCF003C73A2 /* SignalAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF224255B6D5D007E1867 /* SignalAttachment.swift */; }; C32C5A88256DBCF9003C73A2 /* MessageReceiver+Handling.swift in Sources */ = {isa = PBXBuildFile; fileRef = C32C5A87256DBCF9003C73A2 /* MessageReceiver+Handling.swift */; }; - C32C5AAA256DBE8F003C73A2 /* TSIncomingMessage.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB9C255A581300E217F9 /* TSIncomingMessage.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C32C5AAB256DBE8F003C73A2 /* TSIncomingMessage+Conversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3B7845C25649DA600ADB2E7 /* TSIncomingMessage+Conversion.swift */; }; - C32C5AAC256DBE8F003C73A2 /* TSInfoMessage.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDADD255A580400E217F9 /* TSInfoMessage.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C32C5AAD256DBE8F003C73A2 /* TSInfoMessage.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDC0C255A581E00E217F9 /* TSInfoMessage.m */; }; - C32C5AAE256DBE8F003C73A2 /* TSInteraction.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBE9255A581A00E217F9 /* TSInteraction.m */; }; - C32C5AAF256DBE8F003C73A2 /* TSMessage.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDA70255A57FA00E217F9 /* TSMessage.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C32C5AB0256DBE8F003C73A2 /* TSOutgoingMessage.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB56255A580D00E217F9 /* TSOutgoingMessage.m */; }; - C32C5AB1256DBE8F003C73A2 /* TSIncomingMessage.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA97255A57FE00E217F9 /* TSIncomingMessage.m */; }; - C32C5AB2256DBE8F003C73A2 /* TSMessage.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB60255A580E00E217F9 /* TSMessage.m */; }; - C32C5AB3256DBE8F003C73A2 /* TSInteraction.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDAE6255A580400E217F9 /* TSInteraction.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C32C5AB4256DBE8F003C73A2 /* TSOutgoingMessage.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB48255A580C00E217F9 /* TSOutgoingMessage.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C32C5AB5256DBE8F003C73A2 /* TSOutgoingMessage+Conversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = B84072952565E9F50037CB17 /* TSOutgoingMessage+Conversion.swift */; }; - C32C5B1C256DC19D003C73A2 /* TSQuotedMessage.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB83255A581100E217F9 /* TSQuotedMessage.m */; }; - C32C5B2D256DC1A1003C73A2 /* TSQuotedMessage.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDAD5255A580300E217F9 /* TSQuotedMessage.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C32C5B3F256DC1DF003C73A2 /* TSQuotedMessage+Conversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = C32C5B3E256DC1DF003C73A2 /* TSQuotedMessage+Conversion.swift */; }; C32C5B48256DC211003C73A2 /* NSNotificationCenter+OWS.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB6C255A580F00E217F9 /* NSNotificationCenter+OWS.m */; }; C32C5B51256DC219003C73A2 /* NSNotificationCenter+OWS.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB3B255A580B00E217F9 /* NSNotificationCenter+OWS.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5B84256DC54F003C73A2 /* SSKEnvironment.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAF4255A580600E217F9 /* SSKEnvironment.m */; }; @@ -320,7 +298,7 @@ C32C5CA4256DD1DC003C73A2 /* TSAccountManager.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB88255A581200E217F9 /* TSAccountManager.m */; }; C32C5CAD256DD1DF003C73A2 /* TSAccountManager.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB94255A581300E217F9 /* TSAccountManager.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5CF0256DD3E4003C73A2 /* Storage+Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3F0A5FD255C988A007BE2A3 /* Storage+Shared.swift */; }; - C32C5D19256DD493003C73A2 /* OWSLinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBA8255A581500E217F9 /* OWSLinkPreview.swift */; }; + C32C5D19256DD493003C73A2 /* LinkPreviewDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBA8255A581500E217F9 /* LinkPreviewDraft.swift */; }; C32C5D83256DD5B6003C73A2 /* SSKKeychainStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBBC255A581600E217F9 /* SSKKeychainStorage.swift */; }; C32C5DBF256DD743003C73A2 /* ClosedGroupPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB34255A580B00E217F9 /* ClosedGroupPoller.swift */; }; C32C5DC0256DD743003C73A2 /* Poller.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB3A255A580B00E217F9 /* Poller.swift */; }; @@ -334,9 +312,6 @@ C32C5E7E256DE023003C73A2 /* YapDatabaseTransaction+OWS.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDA88255A57FD00E217F9 /* YapDatabaseTransaction+OWS.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5E97256DE0CB003C73A2 /* OWSPrimaryStorage.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDC02255A581D00E217F9 /* OWSPrimaryStorage.m */; }; C32C5EA0256DE0D6003C73A2 /* OWSPrimaryStorage.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDA67255A57F900E217F9 /* OWSPrimaryStorage.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C32C5EB9256DE130003C73A2 /* OWSQuotedReplyModel+Conversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = B840729F2565F1670037CB17 /* OWSQuotedReplyModel+Conversion.swift */; }; - C32C5EBA256DE130003C73A2 /* OWSQuotedReplyModel.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF39A255B6DD9007E1867 /* OWSQuotedReplyModel.m */; }; - C32C5EC3256DE133003C73A2 /* OWSQuotedReplyModel.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF398255B6DD9007E1867 /* OWSQuotedReplyModel.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5EDC256DF501003C73A2 /* YapDatabaseConnection+OWS.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB43255A580C00E217F9 /* YapDatabaseConnection+OWS.m */; }; C32C5EE5256DF506003C73A2 /* YapDatabaseConnection+OWS.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB5F255A580E00E217F9 /* YapDatabaseConnection+OWS.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5EEE256DF54E003C73A2 /* TSDatabaseView.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB46255A580C00E217F9 /* TSDatabaseView.m */; }; @@ -393,7 +368,6 @@ C33FDC9A255A582000E217F9 /* ByteParser.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAE0255A580400E217F9 /* ByteParser.m */; }; C33FDCC7255A582000E217F9 /* NSArray+OWS.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB0D255A580800E217F9 /* NSArray+OWS.m */; }; C33FDCD1255A582000E217F9 /* FunctionalUtil.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB17255A580800E217F9 /* FunctionalUtil.m */; }; - C33FDCD3255A582000E217F9 /* GroupUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB19255A580900E217F9 /* GroupUtilities.swift */; }; C33FDCFA255A582000E217F9 /* SignalIOSProto.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB40255A580C00E217F9 /* SignalIOSProto.swift */; }; C33FDD03255A582000E217F9 /* WeakTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB49255A580C00E217F9 /* WeakTimer.swift */; }; C33FDD06255A582000E217F9 /* AppVersion.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB4C255A580D00E217F9 /* AppVersion.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -401,10 +375,8 @@ C33FDD32255A582000E217F9 /* OWSOperation.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB78255A581000E217F9 /* OWSOperation.m */; }; C33FDD3A255A582000E217F9 /* Notification+Loki.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB80255A581100E217F9 /* Notification+Loki.swift */; }; C33FDD49255A582000E217F9 /* ParamParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB8F255A581200E217F9 /* ParamParser.swift */; }; - C33FDD53255A582000E217F9 /* OWSPrimaryStorage+keyFromIntLong.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB99255A581300E217F9 /* OWSPrimaryStorage+keyFromIntLong.m */; }; C33FDD5B255A582000E217F9 /* OWSOperation.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBA1255A581400E217F9 /* OWSOperation.h */; settings = {ATTRIBUTES = (Public, ); }; }; C33FDD6E255A582000E217F9 /* NSURLSessionDataTask+StatusCode.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBB4255A581600E217F9 /* NSURLSessionDataTask+StatusCode.m */; }; - C33FDD74255A582000E217F9 /* OWSPrimaryStorage+keyFromIntLong.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBBA255A581600E217F9 /* OWSPrimaryStorage+keyFromIntLong.h */; settings = {ATTRIBUTES = (Public, ); }; }; C33FDD7C255A582000E217F9 /* SSKAsserts.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBC2255A581700E217F9 /* SSKAsserts.h */; settings = {ATTRIBUTES = (Public, ); }; }; C33FDD8D255A582000E217F9 /* OWSSignalAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBD3255A581800E217F9 /* OWSSignalAddress.swift */; }; C33FDD92255A582000E217F9 /* SignalIOS.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBD8255A581900E217F9 /* SignalIOS.pb.swift */; }; @@ -476,18 +448,13 @@ C38EF2A7255B6D93007E1867 /* ProfilePictureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2A4255B6D93007E1867 /* ProfilePictureView.swift */; }; C38EF2B3255B6D9C007E1867 /* UIViewController+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2B1255B6D9C007E1867 /* UIViewController+Utilities.swift */; }; C38EF2B4255B6D9C007E1867 /* UIView+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2B2255B6D9C007E1867 /* UIView+Utilities.swift */; }; - C38EF2D4255B6DAF007E1867 /* OWSProfileManager.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2CF255B6DAE007E1867 /* OWSProfileManager.m */; }; - C38EF2D7255B6DAF007E1867 /* OWSProfileManager.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF2D2255B6DAF007E1867 /* OWSProfileManager.h */; settings = {ATTRIBUTES = (Public, ); }; }; C38EF30C255B6DBF007E1867 /* OWSScreenLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2E2255B6DB9007E1867 /* OWSScreenLock.swift */; }; - C38EF30D255B6DBF007E1867 /* OWSUnreadIndicator.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2E3255B6DB9007E1867 /* OWSUnreadIndicator.m */; }; C38EF30E255B6DBF007E1867 /* FullTextSearcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2E4255B6DB9007E1867 /* FullTextSearcher.swift */; }; - C38EF313255B6DBF007E1867 /* OWSUnreadIndicator.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF2E9255B6DBA007E1867 /* OWSUnreadIndicator.h */; settings = {ATTRIBUTES = (Public, ); }; }; C38EF317255B6DBF007E1867 /* DisplayableText.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2ED255B6DBB007E1867 /* DisplayableText.swift */; }; C38EF31A255B6DBF007E1867 /* OWSAnyTouchGestureRecognizer.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2F0255B6DBB007E1867 /* OWSAnyTouchGestureRecognizer.m */; }; C38EF31C255B6DBF007E1867 /* Searcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2F2255B6DBC007E1867 /* Searcher.swift */; }; C38EF31D255B6DBF007E1867 /* UIImage+OWS.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2F3255B6DBC007E1867 /* UIImage+OWS.swift */; }; C38EF324255B6DBF007E1867 /* Bench.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2FA255B6DBD007E1867 /* Bench.swift */; }; - C38EF326255B6DBF007E1867 /* ConversationStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2FC255B6DBD007E1867 /* ConversationStyle.swift */; }; C38EF32A255B6DBF007E1867 /* UIUtil.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF300255B6DBD007E1867 /* UIUtil.m */; }; C38EF32B255B6DBF007E1867 /* OWSFormat.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF301255B6DBD007E1867 /* OWSFormat.h */; settings = {ATTRIBUTES = (Public, ); }; }; C38EF32E255B6DBF007E1867 /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF304255B6DBE007E1867 /* ImageCache.swift */; }; @@ -504,7 +471,6 @@ C38EF36B255B6DCC007E1867 /* ScreenLockViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF351255B6DC9007E1867 /* ScreenLockViewController.m */; }; C38EF36F255B6DCC007E1867 /* OWSViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF355255B6DCB007E1867 /* OWSViewController.m */; }; C38EF370255B6DCC007E1867 /* OWSNavigationController.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF356255B6DCB007E1867 /* OWSNavigationController.m */; }; - C38EF371255B6DCC007E1867 /* MessageApprovalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF357255B6DCC007E1867 /* MessageApprovalViewController.swift */; }; C38EF372255B6DCC007E1867 /* MediaMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF358255B6DCC007E1867 /* MediaMessageView.swift */; }; C38EF385255B6DD2007E1867 /* AttachmentTextToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF37C255B6DCF007E1867 /* AttachmentTextToolbar.swift */; }; C38EF386255B6DD2007E1867 /* AttachmentApprovalInputAccessoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF37D255B6DCF007E1867 /* AttachmentApprovalInputAccessoryView.swift */; }; @@ -532,9 +498,6 @@ C38EF3C5255B6DE7007E1867 /* OWSViewController+ImageEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF3B5255B6DE6007E1867 /* OWSViewController+ImageEditor.swift */; }; C38EF3C6255B6DE7007E1867 /* ImageEditorModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF3B6255B6DE6007E1867 /* ImageEditorModel.swift */; }; C38EF3C7255B6DE7007E1867 /* ImageEditorCanvasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF3B7255B6DE6007E1867 /* ImageEditorCanvasView.swift */; }; - C38EF3EF255B6DF7007E1867 /* ThreadViewHelper.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF3D1255B6DEE007E1867 /* ThreadViewHelper.m */; }; - C38EF3F0255B6DF7007E1867 /* ThreadViewHelper.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF3D2255B6DEE007E1867 /* ThreadViewHelper.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C38EF3F2255B6DF7007E1867 /* DisappearingTimerConfigurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF3D4255B6DEE007E1867 /* DisappearingTimerConfigurationView.swift */; }; C38EF3F5255B6DF7007E1867 /* OWSTextField.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF3D7255B6DF0007E1867 /* OWSTextField.h */; settings = {ATTRIBUTES = (Public, ); }; }; C38EF3F6255B6DF7007E1867 /* OWSTextView.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF3D8255B6DF0007E1867 /* OWSTextView.h */; settings = {ATTRIBUTES = (Public, ); }; }; C38EF3F7255B6DF7007E1867 /* OWSNavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF3D9255B6DF1007E1867 /* OWSNavigationBar.swift */; }; @@ -557,8 +520,6 @@ C3A01E06261D24C400290BEB /* storage-seed-1.der in Resources */ = {isa = PBXBuildFile; fileRef = C3A01E03261D24C400290BEB /* storage-seed-1.der */; }; C3A01E07261D24C400290BEB /* storage-seed-3.der in Resources */ = {isa = PBXBuildFile; fileRef = C3A01E04261D24C400290BEB /* storage-seed-3.der */; }; C3A3A08F256E1728004D228D /* FullTextSearchFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB7F255A581100E217F9 /* FullTextSearchFinder.swift */; }; - C3A3A0EC256E1949004D228D /* OWSRecipientIdentity.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBEC255A581B00E217F9 /* OWSRecipientIdentity.m */; }; - C3A3A0F5256E194C004D228D /* OWSRecipientIdentity.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDAA0255A57FF00E217F9 /* OWSRecipientIdentity.h */; settings = {ATTRIBUTES = (Public, ); }; }; C3A3A0FE256E1A3C004D228D /* TSDatabaseSecondaryIndexes.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB20255A580900E217F9 /* TSDatabaseSecondaryIndexes.m */; }; C3A3A107256E1A5C004D228D /* OWSDisappearingMessagesFinder.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA86255A57FC00E217F9 /* OWSDisappearingMessagesFinder.m */; }; C3A3A108256E1A5C004D228D /* OWSIncomingMessageFinder.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB1E255A580900E217F9 /* OWSIncomingMessageFinder.m */; }; @@ -628,17 +589,11 @@ C3D9E38A256760390040E4F3 /* OWSFileSystem.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBAB255A581500E217F9 /* OWSFileSystem.h */; settings = {ATTRIBUTES = (Public, ); }; }; C3D9E39B256763C20040E4F3 /* AppContext.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB85255A581100E217F9 /* AppContext.m */; }; C3D9E3A4256763DE0040E4F3 /* AppContext.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB8A255A581200E217F9 /* AppContext.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C3D9E3BE25676AD70040E4F3 /* TSAttachmentPointer.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB9E255A581400E217F9 /* TSAttachmentPointer.m */; }; - C3D9E3BF25676AD70040E4F3 /* TSAttachmentStream.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAC4255A580200E217F9 /* TSAttachmentStream.m */; }; - C3D9E3C025676AD70040E4F3 /* TSAttachment.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAC2255A580200E217F9 /* TSAttachment.m */; }; C3D9E3C925676AF30040E4F3 /* TSYapDatabaseObject.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA90255A57FD00E217F9 /* TSYapDatabaseObject.m */; }; C3D9E3FA25676BCE0040E4F3 /* TSYapDatabaseObject.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDAA1255A57FF00E217F9 /* TSYapDatabaseObject.h */; settings = {ATTRIBUTES = (Public, ); }; }; C3D9E41525676C320040E4F3 /* Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB36255A580B00E217F9 /* Storage.swift */; }; C3D9E41F25676C870040E4F3 /* OWSPrimaryStorageProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3D9E41E25676C870040E4F3 /* OWSPrimaryStorageProtocol.swift */; }; C3D9E43125676D3D0040E4F3 /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3D9E43025676D3D0040E4F3 /* Configuration.swift */; }; - C3D9E485256775D20040E4F3 /* TSAttachment.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDC15255A581E00E217F9 /* TSAttachment.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C3D9E486256775D20040E4F3 /* TSAttachmentPointer.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDC18255A581F00E217F9 /* TSAttachmentPointer.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C3D9E487256775D20040E4F3 /* TSAttachmentStream.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDAE4255A580400E217F9 /* TSAttachmentStream.h */; settings = {ATTRIBUTES = (Public, ); }; }; C3D9E4C02567767F0040E4F3 /* DataSource.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBB6255A581600E217F9 /* DataSource.m */; }; C3D9E4D12567777D0040E4F3 /* OWSMediaUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB22255A580900E217F9 /* OWSMediaUtils.swift */; }; C3D9E4DA256778410040E4F3 /* UIImage+OWS.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB81255A581100E217F9 /* UIImage+OWS.m */; }; @@ -761,6 +716,8 @@ FDA8EB09280E90FB002B68E5 /* AppSetup.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF287255B6D85007E1867 /* AppSetup.m */; }; FDA8EB0A280E9103002B68E5 /* AppSetup.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF284255B6D84007E1867 /* AppSetup.h */; settings = {ATTRIBUTES = (Public, ); }; }; FDA8EB10280F8238002B68E5 /* Codable+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA8EB0F280F8238002B68E5 /* Codable+Utilities.swift */; }; + FDB4BBC72838B91E00B7C95D /* LinkPreviewError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB4BBC62838B91E00B7C95D /* LinkPreviewError.swift */; }; + FDB4BBC92839BEF000B7C95D /* ProfileManagerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB4BBC82839BEF000B7C95D /* ProfileManagerError.swift */; }; FDC4389E27BA2B8A00C60D73 /* SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */; }; FDCDB8DE2810F73B00352A0C /* Differentiable+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DD2810F73B00352A0C /* Differentiable+Utilities.swift */; }; FDCDB8E02811007F00352A0C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */; }; @@ -983,7 +940,6 @@ 34661FB720C1C0D60056EDD6 /* message_sent.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; name = message_sent.aiff; path = Session/Meta/AudioFiles/message_sent.aiff; sourceTree = SOURCE_ROOT; }; 346B66301F4E29B200E5122F /* CropScaleImageViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CropScaleImageViewController.swift; sourceTree = ""; }; 3488F9352191CC4000E524CC /* MediaView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaView.swift; sourceTree = ""; }; - 3496744E2076ACCE00080B5F /* LongTextViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LongTextViewController.swift; sourceTree = ""; }; 34969559219B605E00DCFE74 /* ImagePickerController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImagePickerController.swift; sourceTree = ""; }; 3496955A219B605E00DCFE74 /* PhotoCollectionPickerController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoCollectionPickerController.swift; sourceTree = ""; }; 3496955B219B605E00DCFE74 /* PhotoLibrary.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoLibrary.swift; sourceTree = ""; }; @@ -1185,8 +1141,6 @@ B83524A425C3BA4B0089A44F /* InfoMessageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoMessageCell.swift; sourceTree = ""; }; B83F2B85240C7B8F000A54AB /* NewConversationButtonSet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewConversationButtonSet.swift; sourceTree = ""; }; B83F2B87240CB75A000A54AB /* UIImage+Scaling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Scaling.swift"; sourceTree = ""; }; - B84072952565E9F50037CB17 /* TSOutgoingMessage+Conversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TSOutgoingMessage+Conversion.swift"; sourceTree = ""; }; - B840729F2565F1670037CB17 /* OWSQuotedReplyModel+Conversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OWSQuotedReplyModel+Conversion.swift"; sourceTree = ""; }; B84664F4235022F30083A1CD /* MentionUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionUtilities.swift; sourceTree = ""; }; B847570023D568EB00759540 /* SignalServiceKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = SignalServiceKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; B848A4C4269EAAA200617031 /* UserDetailsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDetailsSheet.swift; sourceTree = ""; }; @@ -1196,7 +1150,6 @@ B85357C223A1BD1200AAF6CD /* SeedVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeedVC.swift; sourceTree = ""; }; B8544E3023D16CA500299F14 /* DeviceUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceUtilities.swift; sourceTree = ""; }; B8544E3223D50E4900299F14 /* SNAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SNAppearance.swift; sourceTree = ""; }; - B8566C62256F55930045A0B9 /* OWSLinkPreview+Conversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OWSLinkPreview+Conversion.swift"; sourceTree = ""; }; B8569AC225CB5D2900DBA3DB /* ConversationVC+Interaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConversationVC+Interaction.swift"; sourceTree = ""; }; B8569AE225CBB19A00DBA3DB /* DocumentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentView.swift; sourceTree = ""; }; B86BD08323399ACF000F5AE3 /* Modal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Modal.swift; sourceTree = ""; }; @@ -1238,8 +1191,6 @@ B8BB82BD2394D4CE00BA5194 /* Fonts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fonts.swift; sourceTree = ""; }; B8BC00BF257D90E30032E807 /* General.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = General.swift; sourceTree = ""; }; B8C2B2C72563685C00551B4D /* CircleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircleView.swift; sourceTree = ""; }; - B8C2B331256376F000551B4D /* ThreadUtil.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ThreadUtil.m; sourceTree = ""; }; - B8C2B33B2563770800551B4D /* ThreadUtil.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ThreadUtil.h; sourceTree = ""; }; B8C9689023FA1401005F64E0 /* AppMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMode.swift; sourceTree = ""; }; B8CCF6342396005F0091D419 /* SpaceMono-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "SpaceMono-Regular.ttf"; sourceTree = ""; }; B8CCF63623961D6D0091D419 /* NewDMVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewDMVC.swift; sourceTree = ""; }; @@ -1294,7 +1245,6 @@ C328254825CA60E60062D0A7 /* ContextMenuVC+Action.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContextMenuVC+Action.swift"; sourceTree = ""; }; C328255125CA64470062D0A7 /* ContextMenuVC+ActionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContextMenuVC+ActionView.swift"; sourceTree = ""; }; C32C5A87256DBCF9003C73A2 /* MessageReceiver+Handling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+Handling.swift"; sourceTree = ""; }; - C32C5B3E256DC1DF003C73A2 /* TSQuotedMessage+Conversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TSQuotedMessage+Conversion.swift"; sourceTree = ""; }; C33100132558FFC200070591 /* UIImage+Tinting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Tinting.swift"; sourceTree = ""; }; C33100272559000A00070591 /* UIView+Rendering.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Rendering.swift"; sourceTree = ""; }; C3310032255900A400070591 /* Notification+AppMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+AppMode.swift"; sourceTree = ""; }; @@ -1307,7 +1257,6 @@ C33FDA67255A57F900E217F9 /* OWSPrimaryStorage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSPrimaryStorage.h; sourceTree = ""; }; C33FDA6D255A57FA00E217F9 /* YapDatabase+Promise.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "YapDatabase+Promise.swift"; sourceTree = ""; }; C33FDA6F255A57FA00E217F9 /* ReachabilityManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReachabilityManager.swift; sourceTree = ""; }; - C33FDA70255A57FA00E217F9 /* TSMessage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSMessage.h; sourceTree = ""; }; C33FDA73255A57FA00E217F9 /* ECKeyPair+Hexadecimal.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ECKeyPair+Hexadecimal.swift"; sourceTree = ""; }; C33FDA7A255A57FB00E217F9 /* NSRegularExpression+SSK.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSRegularExpression+SSK.swift"; sourceTree = ""; }; C33FDA86255A57FC00E217F9 /* OWSDisappearingMessagesFinder.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSDisappearingMessagesFinder.m; sourceTree = ""; }; @@ -1317,10 +1266,8 @@ C33FDA8E255A57FD00E217F9 /* OWSFileSystem.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSFileSystem.m; sourceTree = ""; }; C33FDA90255A57FD00E217F9 /* TSYapDatabaseObject.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSYapDatabaseObject.m; sourceTree = ""; }; C33FDA96255A57FE00E217F9 /* OWSDispatch.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSDispatch.h; sourceTree = ""; }; - C33FDA97255A57FE00E217F9 /* TSIncomingMessage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSIncomingMessage.m; sourceTree = ""; }; C33FDA99255A57FE00E217F9 /* OutageDetection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutageDetection.swift; sourceTree = ""; }; C33FDA9E255A57FF00E217F9 /* ReverseDispatchQueue.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReverseDispatchQueue.swift; sourceTree = ""; }; - C33FDAA0255A57FF00E217F9 /* OWSRecipientIdentity.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSRecipientIdentity.h; sourceTree = ""; }; C33FDAA1255A57FF00E217F9 /* TSYapDatabaseObject.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSYapDatabaseObject.h; sourceTree = ""; }; C33FDAA8255A57FF00E217F9 /* BuildConfiguration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BuildConfiguration.swift; sourceTree = ""; }; C33FDAAA255A580000E217F9 /* NSObject+Casting.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSObject+Casting.m"; sourceTree = ""; }; @@ -1330,16 +1277,10 @@ C33FDABE255A580100E217F9 /* TSConstants.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSConstants.m; sourceTree = ""; }; C33FDAC0255A580100E217F9 /* OWSIncomingMessageFinder.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSIncomingMessageFinder.h; sourceTree = ""; }; C33FDAC1255A580100E217F9 /* NSSet+Functional.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSSet+Functional.m"; sourceTree = ""; }; - C33FDAC2255A580200E217F9 /* TSAttachment.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSAttachment.m; sourceTree = ""; }; C33FDAC3255A580200E217F9 /* OWSDispatch.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSDispatch.m; sourceTree = ""; }; - C33FDAC4255A580200E217F9 /* TSAttachmentStream.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSAttachmentStream.m; sourceTree = ""; }; - C33FDAD5255A580300E217F9 /* TSQuotedMessage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSQuotedMessage.h; sourceTree = ""; }; C33FDADC255A580400E217F9 /* NSObject+Casting.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSObject+Casting.h"; sourceTree = ""; }; - C33FDADD255A580400E217F9 /* TSInfoMessage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSInfoMessage.h; sourceTree = ""; }; C33FDADE255A580400E217F9 /* SwiftSingletons.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftSingletons.swift; sourceTree = ""; }; C33FDAE0255A580400E217F9 /* ByteParser.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ByteParser.m; sourceTree = ""; }; - C33FDAE4255A580400E217F9 /* TSAttachmentStream.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSAttachmentStream.h; sourceTree = ""; }; - C33FDAE6255A580400E217F9 /* TSInteraction.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSInteraction.h; sourceTree = ""; }; C33FDAEA255A580500E217F9 /* OWSBackupFragment.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSBackupFragment.h; sourceTree = ""; }; C33FDAEF255A580500E217F9 /* NSData+Image.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSData+Image.m"; sourceTree = ""; }; C33FDAF1255A580500E217F9 /* OWSThumbnailService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSThumbnailService.swift; sourceTree = ""; }; @@ -1354,7 +1295,6 @@ C33FDB12255A580800E217F9 /* NSString+SSK.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSString+SSK.h"; sourceTree = ""; }; C33FDB14255A580800E217F9 /* OWSMath.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSMath.h; sourceTree = ""; }; C33FDB17255A580800E217F9 /* FunctionalUtil.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FunctionalUtil.m; sourceTree = ""; }; - C33FDB19255A580900E217F9 /* GroupUtilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupUtilities.swift; sourceTree = ""; }; C33FDB1C255A580900E217F9 /* UIImage+OWS.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "UIImage+OWS.h"; sourceTree = ""; }; C33FDB1E255A580900E217F9 /* OWSIncomingMessageFinder.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSIncomingMessageFinder.m; sourceTree = ""; }; C33FDB20255A580900E217F9 /* TSDatabaseSecondaryIndexes.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSDatabaseSecondaryIndexes.m; sourceTree = ""; }; @@ -1374,16 +1314,13 @@ C33FDB43255A580C00E217F9 /* YapDatabaseConnection+OWS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "YapDatabaseConnection+OWS.m"; sourceTree = ""; }; C33FDB45255A580C00E217F9 /* NSString+SSK.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSString+SSK.m"; sourceTree = ""; }; C33FDB46255A580C00E217F9 /* TSDatabaseView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSDatabaseView.m; sourceTree = ""; }; - C33FDB48255A580C00E217F9 /* TSOutgoingMessage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSOutgoingMessage.h; sourceTree = ""; }; C33FDB49255A580C00E217F9 /* WeakTimer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WeakTimer.swift; sourceTree = ""; }; C33FDB4C255A580D00E217F9 /* AppVersion.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppVersion.h; sourceTree = ""; }; C33FDB51255A580D00E217F9 /* NSUserDefaults+OWS.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSUserDefaults+OWS.h"; sourceTree = ""; }; C33FDB54255A580D00E217F9 /* DataSource.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DataSource.h; sourceTree = ""; }; - C33FDB56255A580D00E217F9 /* TSOutgoingMessage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSOutgoingMessage.m; sourceTree = ""; }; C33FDB5B255A580E00E217F9 /* YapDatabaseTransaction+OWS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "YapDatabaseTransaction+OWS.m"; sourceTree = ""; }; C33FDB5C255A580E00E217F9 /* NSArray+Functional.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSArray+Functional.h"; sourceTree = ""; }; C33FDB5F255A580E00E217F9 /* YapDatabaseConnection+OWS.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "YapDatabaseConnection+OWS.h"; sourceTree = ""; }; - C33FDB60255A580E00E217F9 /* TSMessage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSMessage.m; sourceTree = ""; }; C33FDB68255A580F00E217F9 /* ContentProxy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentProxy.swift; sourceTree = ""; }; C33FDB69255A580F00E217F9 /* FeatureFlags.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeatureFlags.swift; sourceTree = ""; }; C33FDB6B255A580F00E217F9 /* SNUserDefaults.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SNUserDefaults.swift; sourceTree = ""; }; @@ -1394,21 +1331,16 @@ C33FDB7F255A581100E217F9 /* FullTextSearchFinder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FullTextSearchFinder.swift; sourceTree = ""; }; C33FDB80255A581100E217F9 /* Notification+Loki.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Notification+Loki.swift"; sourceTree = ""; }; C33FDB81255A581100E217F9 /* UIImage+OWS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "UIImage+OWS.m"; sourceTree = ""; }; - C33FDB83255A581100E217F9 /* TSQuotedMessage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSQuotedMessage.m; sourceTree = ""; }; C33FDB85255A581100E217F9 /* AppContext.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppContext.m; sourceTree = ""; }; C33FDB88255A581200E217F9 /* TSAccountManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSAccountManager.m; sourceTree = ""; }; C33FDB8A255A581200E217F9 /* AppContext.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppContext.h; sourceTree = ""; }; C33FDB8F255A581200E217F9 /* ParamParser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ParamParser.swift; sourceTree = ""; }; C33FDB94255A581300E217F9 /* TSAccountManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSAccountManager.h; sourceTree = ""; }; - C33FDB99255A581300E217F9 /* OWSPrimaryStorage+keyFromIntLong.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "OWSPrimaryStorage+keyFromIntLong.m"; sourceTree = ""; }; - C33FDB9C255A581300E217F9 /* TSIncomingMessage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSIncomingMessage.h; sourceTree = ""; }; - C33FDB9E255A581400E217F9 /* TSAttachmentPointer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSAttachmentPointer.m; sourceTree = ""; }; C33FDBA1255A581400E217F9 /* OWSOperation.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSOperation.h; sourceTree = ""; }; - C33FDBA8255A581500E217F9 /* OWSLinkPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSLinkPreview.swift; sourceTree = ""; }; + C33FDBA8255A581500E217F9 /* LinkPreviewDraft.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LinkPreviewDraft.swift; sourceTree = ""; }; C33FDBAB255A581500E217F9 /* OWSFileSystem.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSFileSystem.h; sourceTree = ""; }; C33FDBB4255A581600E217F9 /* NSURLSessionDataTask+StatusCode.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSURLSessionDataTask+StatusCode.m"; sourceTree = ""; }; C33FDBB6255A581600E217F9 /* DataSource.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DataSource.m; sourceTree = ""; }; - C33FDBBA255A581600E217F9 /* OWSPrimaryStorage+keyFromIntLong.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "OWSPrimaryStorage+keyFromIntLong.h"; sourceTree = ""; }; C33FDBBC255A581600E217F9 /* SSKKeychainStorage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SSKKeychainStorage.swift; sourceTree = ""; }; C33FDBC2255A581700E217F9 /* SSKAsserts.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SSKAsserts.h; sourceTree = ""; }; C33FDBCA255A581700E217F9 /* LKGroupUtilities.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = LKGroupUtilities.h; sourceTree = ""; }; @@ -1416,8 +1348,6 @@ C33FDBD8255A581900E217F9 /* SignalIOS.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SignalIOS.pb.swift; sourceTree = ""; }; C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PushNotificationAPI.swift; sourceTree = ""; }; C33FDBE1255A581A00E217F9 /* LKGroupUtilities.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = LKGroupUtilities.m; sourceTree = ""; }; - C33FDBE9255A581A00E217F9 /* TSInteraction.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSInteraction.m; sourceTree = ""; }; - C33FDBEC255A581B00E217F9 /* OWSRecipientIdentity.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSRecipientIdentity.m; sourceTree = ""; }; C33FDBF6255A581C00E217F9 /* NSURLSessionDataTask+StatusCode.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSURLSessionDataTask+StatusCode.h"; sourceTree = ""; }; C33FDBF8255A581C00E217F9 /* NSArray+OWS.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSArray+OWS.h"; sourceTree = ""; }; C33FDBF9255A581C00E217F9 /* OWSError.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSError.h; sourceTree = ""; }; @@ -1426,11 +1356,8 @@ C33FDC03255A581D00E217F9 /* ByteParser.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ByteParser.h; sourceTree = ""; }; C33FDC05255A581D00E217F9 /* OWSDisappearingMessagesFinder.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSDisappearingMessagesFinder.h; sourceTree = ""; }; C33FDC0B255A581D00E217F9 /* OWSError.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSError.m; sourceTree = ""; }; - C33FDC0C255A581E00E217F9 /* TSInfoMessage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSInfoMessage.m; sourceTree = ""; }; C33FDC12255A581E00E217F9 /* TSConstants.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSConstants.h; sourceTree = ""; }; - C33FDC15255A581E00E217F9 /* TSAttachment.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSAttachment.h; sourceTree = ""; }; C33FDC16255A581E00E217F9 /* FunctionalUtil.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FunctionalUtil.h; sourceTree = ""; }; - C33FDC18255A581F00E217F9 /* TSAttachmentPointer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSAttachmentPointer.h; sourceTree = ""; }; C33FDC19255A581F00E217F9 /* OWSQueues.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSQueues.h; sourceTree = ""; }; C33FDC1B255A581F00E217F9 /* OWSBackgroundTask.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSBackgroundTask.m; sourceTree = ""; }; C3471ECA2555356A00297E91 /* MessageSender+Encryption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageSender+Encryption.swift"; sourceTree = ""; }; @@ -1459,7 +1386,6 @@ C374EEEA25DA3CA70073A857 /* ConversationTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationTitleView.swift; sourceTree = ""; }; C374EEF325DB31D40073A857 /* VoiceMessageRecordingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRecordingView.swift; sourceTree = ""; }; C379DCF3256735770002D4EB /* VisibleMessage+Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VisibleMessage+Attachment.swift"; sourceTree = ""; }; - C379DCFD25673DBC0002D4EB /* TSAttachmentPointer+Conversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TSAttachmentPointer+Conversion.swift"; sourceTree = ""; }; C37F53E8255BA9BB002AEA92 /* Environment.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Environment.h; sourceTree = ""; }; C37F5402255BA9ED002AEA92 /* Environment.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = Environment.m; sourceTree = ""; }; C38D5E8C2575011E00B6A65C /* MessageSender+ClosedGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageSender+ClosedGroups.swift"; sourceTree = ""; }; @@ -1498,14 +1424,8 @@ C38EF2A4255B6D93007E1867 /* ProfilePictureView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ProfilePictureView.swift; path = "SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift"; sourceTree = SOURCE_ROOT; }; C38EF2B1255B6D9C007E1867 /* UIViewController+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "UIViewController+Utilities.swift"; path = "SignalUtilitiesKit/Utilities/UIViewController+Utilities.swift"; sourceTree = SOURCE_ROOT; }; C38EF2B2255B6D9C007E1867 /* UIView+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "UIView+Utilities.swift"; path = "SignalUtilitiesKit/Utilities/UIView+Utilities.swift"; sourceTree = SOURCE_ROOT; }; - C38EF2CF255B6DAE007E1867 /* OWSProfileManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = OWSProfileManager.m; path = "SignalUtilitiesKit/To Do/OWSProfileManager.m"; sourceTree = SOURCE_ROOT; }; - C38EF2D1255B6DAF007E1867 /* OWSUserProfile.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = OWSUserProfile.m; path = "SessionMessagingKit/To Do/OWSUserProfile.m"; sourceTree = SOURCE_ROOT; }; - C38EF2D2255B6DAF007E1867 /* OWSProfileManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSProfileManager.h; path = "SignalUtilitiesKit/To Do/OWSProfileManager.h"; sourceTree = SOURCE_ROOT; }; - C38EF2D3255B6DAF007E1867 /* OWSUserProfile.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSUserProfile.h; path = "SessionMessagingKit/To Do/OWSUserProfile.h"; sourceTree = SOURCE_ROOT; }; C38EF2E2255B6DB9007E1867 /* OWSScreenLock.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = OWSScreenLock.swift; path = "SignalUtilitiesKit/Screen Lock/OWSScreenLock.swift"; sourceTree = SOURCE_ROOT; }; - C38EF2E3255B6DB9007E1867 /* OWSUnreadIndicator.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = OWSUnreadIndicator.m; path = SignalUtilitiesKit/Messaging/OWSUnreadIndicator.m; sourceTree = SOURCE_ROOT; }; C38EF2E4255B6DB9007E1867 /* FullTextSearcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = FullTextSearcher.swift; path = SignalUtilitiesKit/Messaging/FullTextSearcher.swift; sourceTree = SOURCE_ROOT; }; - C38EF2E9255B6DBA007E1867 /* OWSUnreadIndicator.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSUnreadIndicator.h; path = SignalUtilitiesKit/Messaging/OWSUnreadIndicator.h; sourceTree = SOURCE_ROOT; }; C38EF2EC255B6DBA007E1867 /* ProximityMonitoringManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ProximityMonitoringManager.swift; path = SessionMessagingKit/Utilities/ProximityMonitoringManager.swift; sourceTree = SOURCE_ROOT; }; C38EF2ED255B6DBB007E1867 /* DisplayableText.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = DisplayableText.swift; path = SignalUtilitiesKit/Utilities/DisplayableText.swift; sourceTree = SOURCE_ROOT; }; C38EF2EF255B6DBB007E1867 /* Weak.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Weak.swift; path = SessionUtilitiesKit/General/Weak.swift; sourceTree = SOURCE_ROOT; }; @@ -1517,7 +1437,6 @@ C38EF2F7255B6DBC007E1867 /* OWSAudioPlayer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = OWSAudioPlayer.m; path = SessionMessagingKit/Utilities/OWSAudioPlayer.m; sourceTree = SOURCE_ROOT; }; C38EF2FA255B6DBD007E1867 /* Bench.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Bench.swift; path = SignalUtilitiesKit/Utilities/Bench.swift; sourceTree = SOURCE_ROOT; }; C38EF2FB255B6DBD007E1867 /* OWSWindowManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSWindowManager.h; path = SessionMessagingKit/Utilities/OWSWindowManager.h; sourceTree = SOURCE_ROOT; }; - C38EF2FC255B6DBD007E1867 /* ConversationStyle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ConversationStyle.swift; path = SignalUtilitiesKit/Messaging/ConversationStyle.swift; sourceTree = SOURCE_ROOT; }; C38EF300255B6DBD007E1867 /* UIUtil.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = UIUtil.m; path = SignalUtilitiesKit/Utilities/UIUtil.m; sourceTree = SOURCE_ROOT; }; C38EF301255B6DBD007E1867 /* OWSFormat.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSFormat.h; path = SignalUtilitiesKit/Utilities/OWSFormat.h; sourceTree = SOURCE_ROOT; }; C38EF302255B6DBE007E1867 /* OWSAnyTouchGestureRecognizer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSAnyTouchGestureRecognizer.h; path = SignalUtilitiesKit/Utilities/OWSAnyTouchGestureRecognizer.h; sourceTree = SOURCE_ROOT; }; @@ -1538,7 +1457,6 @@ C38EF351255B6DC9007E1867 /* ScreenLockViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = ScreenLockViewController.m; path = "SignalUtilitiesKit/Screen Lock/ScreenLockViewController.m"; sourceTree = SOURCE_ROOT; }; C38EF355255B6DCB007E1867 /* OWSViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = OWSViewController.m; path = "SignalUtilitiesKit/Shared View Controllers/OWSViewController.m"; sourceTree = SOURCE_ROOT; }; C38EF356255B6DCB007E1867 /* OWSNavigationController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = OWSNavigationController.m; path = "SignalUtilitiesKit/Shared View Controllers/OWSNavigationController.m"; sourceTree = SOURCE_ROOT; }; - C38EF357255B6DCC007E1867 /* MessageApprovalViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = MessageApprovalViewController.swift; path = "SignalUtilitiesKit/Media Viewing & Editing/MessageApprovalViewController.swift"; sourceTree = SOURCE_ROOT; }; C38EF358255B6DCC007E1867 /* MediaMessageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = MediaMessageView.swift; path = "SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift"; sourceTree = SOURCE_ROOT; }; C38EF37C255B6DCF007E1867 /* AttachmentTextToolbar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = AttachmentTextToolbar.swift; path = "SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift"; sourceTree = SOURCE_ROOT; }; C38EF37D255B6DCF007E1867 /* AttachmentApprovalInputAccessoryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = AttachmentApprovalInputAccessoryView.swift; path = "SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalInputAccessoryView.swift"; sourceTree = SOURCE_ROOT; }; @@ -1550,8 +1468,6 @@ C38EF383255B6DD1007E1867 /* ApprovalRailCellView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ApprovalRailCellView.swift; path = "SignalUtilitiesKit/Shared Views/ApprovalRailCellView.swift"; sourceTree = SOURCE_ROOT; }; C38EF384255B6DD2007E1867 /* AttachmentCaptionViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = AttachmentCaptionViewController.swift; path = "SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentCaptionViewController.swift"; sourceTree = SOURCE_ROOT; }; C38EF397255B6DD9007E1867 /* ThreadViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ThreadViewModel.swift; path = SignalUtilitiesKit/Messaging/ThreadViewModel.swift; sourceTree = SOURCE_ROOT; }; - C38EF398255B6DD9007E1867 /* OWSQuotedReplyModel.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSQuotedReplyModel.h; path = "SessionMessagingKit/Sending & Receiving/Quotes/OWSQuotedReplyModel.h"; sourceTree = SOURCE_ROOT; }; - C38EF39A255B6DD9007E1867 /* OWSQuotedReplyModel.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = OWSQuotedReplyModel.m; path = "SessionMessagingKit/Sending & Receiving/Quotes/OWSQuotedReplyModel.m"; sourceTree = SOURCE_ROOT; }; C38EF3A8255B6DE4007E1867 /* ImageEditorTextViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ImageEditorTextViewController.swift; path = "SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorTextViewController.swift"; sourceTree = SOURCE_ROOT; }; C38EF3A9255B6DE4007E1867 /* ImageEditorPinchGestureRecognizer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ImageEditorPinchGestureRecognizer.swift; path = "SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorPinchGestureRecognizer.swift"; sourceTree = SOURCE_ROOT; }; C38EF3AA255B6DE4007E1867 /* ImageEditorItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ImageEditorItem.swift; path = "SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorItem.swift"; sourceTree = SOURCE_ROOT; }; @@ -1568,9 +1484,6 @@ C38EF3B5255B6DE6007E1867 /* OWSViewController+ImageEditor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "OWSViewController+ImageEditor.swift"; path = "SignalUtilitiesKit/Media Viewing & Editing/OWSViewController+ImageEditor.swift"; sourceTree = SOURCE_ROOT; }; C38EF3B6255B6DE6007E1867 /* ImageEditorModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ImageEditorModel.swift; path = "SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift"; sourceTree = SOURCE_ROOT; }; C38EF3B7255B6DE6007E1867 /* ImageEditorCanvasView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ImageEditorCanvasView.swift; path = "SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCanvasView.swift"; sourceTree = SOURCE_ROOT; }; - C38EF3D1255B6DEE007E1867 /* ThreadViewHelper.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = ThreadViewHelper.m; path = SignalUtilitiesKit/Database/ThreadViewHelper.m; sourceTree = SOURCE_ROOT; }; - C38EF3D2255B6DEE007E1867 /* ThreadViewHelper.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = ThreadViewHelper.h; path = SignalUtilitiesKit/Database/ThreadViewHelper.h; sourceTree = SOURCE_ROOT; }; - C38EF3D4255B6DEE007E1867 /* DisappearingTimerConfigurationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = DisappearingTimerConfigurationView.swift; path = SignalUtilitiesKit/Messaging/DisappearingTimerConfigurationView.swift; sourceTree = SOURCE_ROOT; }; C38EF3D7255B6DF0007E1867 /* OWSTextField.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSTextField.h; path = "SignalUtilitiesKit/Shared Views/OWSTextField.h"; sourceTree = SOURCE_ROOT; }; C38EF3D8255B6DF0007E1867 /* OWSTextView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSTextView.h; path = "SignalUtilitiesKit/Shared Views/OWSTextView.h"; sourceTree = SOURCE_ROOT; }; C38EF3D9255B6DF1007E1867 /* OWSNavigationBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = OWSNavigationBar.swift; path = "SignalUtilitiesKit/Shared Views/OWSNavigationBar.swift"; sourceTree = SOURCE_ROOT; }; @@ -1613,7 +1526,6 @@ C3AAFFF125AE99710089E6DD /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; C3ADC66026426688005F1414 /* ShareVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareVC.swift; sourceTree = ""; }; C3AECBEA24EF5244005743DE /* fa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fa; path = fa.lproj/Localizable.strings; sourceTree = ""; }; - C3B7845C25649DA600ADB2E7 /* TSIncomingMessage+Conversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TSIncomingMessage+Conversion.swift"; sourceTree = ""; }; C3BBE0752554CDA60050F1E3 /* Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = ""; }; C3BBE07F2554CDD70050F1E3 /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = ""; }; C3BBE0B42554F0E10050F1E3 /* ProofOfWork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProofOfWork.swift; sourceTree = ""; }; @@ -1794,6 +1706,8 @@ FDA8EAFD280E8B78002B68E5 /* FailedMessagesJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedMessagesJob.swift; sourceTree = ""; }; FDA8EAFF280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedAttachmentDownloadsJob.swift; sourceTree = ""; }; FDA8EB0F280F8238002B68E5 /* Codable+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Codable+Utilities.swift"; sourceTree = ""; }; + FDB4BBC62838B91E00B7C95D /* LinkPreviewError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewError.swift; sourceTree = ""; }; + FDB4BBC82839BEF000B7C95D /* ProfileManagerError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileManagerError.swift; sourceTree = ""; }; FDCDB8DD2810F73B00352A0C /* Differentiable+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Differentiable+Utilities.swift"; sourceTree = ""; }; FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DifferenceKit+Utilities.swift"; sourceTree = ""; }; @@ -2225,7 +2139,6 @@ FDF222062818CECF000A4995 /* ConversationViewModel.swift */, B835246D25C38ABF0089A44F /* ConversationVC.swift */, B8569AC225CB5D2900DBA3DB /* ConversationVC+Interaction.swift */, - 3496744E2076ACCE00080B5F /* LongTextViewController.swift */, 4CC613352227A00400E21A3A /* ConversationSearch.swift */, 34D1F06F1F8678AA0066283D /* ConversationViewItem.h */, 34D1F0701F8678AA0066283D /* ConversationViewItem.m */, @@ -2436,7 +2349,6 @@ children = ( C3C2A74325539EB700C340D1 /* Message.swift */, C352A30825574D8400338F3E /* Message+Destination.swift */, - C32C5A99256DBDC1003C73A2 /* Signal */, C300A5C62554B02D00555489 /* Visible Messages */, C300A5C72554B03900555489 /* Control Messages */, ); @@ -2565,35 +2477,10 @@ path = Pollers; sourceTree = ""; }; - C32C5A99256DBDC1003C73A2 /* Signal */ = { - isa = PBXGroup; - children = ( - C33FDB9C255A581300E217F9 /* TSIncomingMessage.h */, - C33FDA97255A57FE00E217F9 /* TSIncomingMessage.m */, - C3B7845C25649DA600ADB2E7 /* TSIncomingMessage+Conversion.swift */, - C33FDADD255A580400E217F9 /* TSInfoMessage.h */, - C33FDC0C255A581E00E217F9 /* TSInfoMessage.m */, - C33FDAE6255A580400E217F9 /* TSInteraction.h */, - C33FDBE9255A581A00E217F9 /* TSInteraction.m */, - C33FDA70255A57FA00E217F9 /* TSMessage.h */, - C33FDB60255A580E00E217F9 /* TSMessage.m */, - C33FDB48255A580C00E217F9 /* TSOutgoingMessage.h */, - C33FDB56255A580D00E217F9 /* TSOutgoingMessage.m */, - B84072952565E9F50037CB17 /* TSOutgoingMessage+Conversion.swift */, - ); - path = Signal; - sourceTree = ""; - }; C32C5B1B256DC160003C73A2 /* Quotes */ = { isa = PBXGroup; children = ( - C38EF398255B6DD9007E1867 /* OWSQuotedReplyModel.h */, - C38EF39A255B6DD9007E1867 /* OWSQuotedReplyModel.m */, FDF0B74828060D13004C14C5 /* QuotedReplyModel.swift */, - B840729F2565F1670037CB17 /* OWSQuotedReplyModel+Conversion.swift */, - C33FDAD5255A580300E217F9 /* TSQuotedMessage.h */, - C33FDB83255A581100E217F9 /* TSQuotedMessage.m */, - C32C5B3E256DC1DF003C73A2 /* TSQuotedMessage+Conversion.swift */, ); path = Quotes; sourceTree = ""; @@ -2601,10 +2488,6 @@ C32C5BB9256DC7C4003C73A2 /* To Do */ = { isa = PBXGroup; children = ( - C33FDAA0255A57FF00E217F9 /* OWSRecipientIdentity.h */, - C33FDBEC255A581B00E217F9 /* OWSRecipientIdentity.m */, - C38EF2D3255B6DAF007E1867 /* OWSUserProfile.h */, - C38EF2D1255B6DAF007E1867 /* OWSUserProfile.m */, C33FDB94255A581300E217F9 /* TSAccountManager.h */, C33FDB88255A581200E217F9 /* TSAccountManager.m */, ); @@ -2642,8 +2525,8 @@ isa = PBXGroup; children = ( B8B320B6258C30D70020074B /* HTMLMetadata.swift */, - C33FDBA8255A581500E217F9 /* OWSLinkPreview.swift */, - B8566C62256F55930045A0B9 /* OWSLinkPreview+Conversion.swift */, + FDB4BBC62838B91E00B7C95D /* LinkPreviewError.swift */, + C33FDBA8255A581500E217F9 /* LinkPreviewDraft.swift */, ); path = "Link Previews"; sourceTree = ""; @@ -2718,7 +2601,6 @@ C36096EE25AD21BC008B62B2 /* Screen Lock */, C3851CD225624B060061EEB0 /* Shared Views */, C360970125AD22D3008B62B2 /* Shared View Controllers */, - C3851CE3256250FA0061EEB0 /* To Do */, C3CA3B11255CF17200F4C6D4 /* Utilities */, ); path = SignalUtilitiesKit; @@ -2892,7 +2774,6 @@ C38EF225255B6D5D007E1867 /* AttachmentSharing.h */, C38EF223255B6D5D007E1867 /* AttachmentSharing.m */, C38EF358255B6DCC007E1867 /* MediaMessageView.swift */, - C38EF357255B6DCC007E1867 /* MessageApprovalViewController.swift */, C38EF227255B6D5D007E1867 /* OWSVideoPlayer.swift */, C38EF3B5255B6DE6007E1867 /* OWSViewController+ImageEditor.swift */, C38EF3E3255B6DF4007E1867 /* VideoPlayerView.swift */, @@ -3019,26 +2900,12 @@ path = "Shared Views"; sourceTree = ""; }; - C3851CE3256250FA0061EEB0 /* To Do */ = { - isa = PBXGroup; - children = ( - C33FDB19255A580900E217F9 /* GroupUtilities.swift */, - C38EF2D2255B6DAF007E1867 /* OWSProfileManager.h */, - C38EF2CF255B6DAE007E1867 /* OWSProfileManager.m */, - ); - path = "To Do"; - sourceTree = ""; - }; C38BBA0D255E321C0041B9A3 /* Messaging */ = { isa = PBXGroup; children = ( FD28A4F327EA79F800FF65E7 /* BlockListUIUtils.swift */, - C38EF2FC255B6DBD007E1867 /* ConversationStyle.swift */, - C38EF3D4255B6DEE007E1867 /* DisappearingTimerConfigurationView.swift */, C38EF397255B6DD9007E1867 /* ThreadViewModel.swift */, C38EF2E4255B6DB9007E1867 /* FullTextSearcher.swift */, - C38EF2E9255B6DBA007E1867 /* OWSUnreadIndicator.h */, - C38EF2E3255B6DB9007E1867 /* OWSUnreadIndicator.m */, ); path = Messaging; sourceTree = ""; @@ -3047,10 +2914,6 @@ isa = PBXGroup; children = ( C379DCE82567330E0002D4EB /* Migrations */, - C33FDBBA255A581600E217F9 /* OWSPrimaryStorage+keyFromIntLong.h */, - C33FDB99255A581300E217F9 /* OWSPrimaryStorage+keyFromIntLong.m */, - C38EF3D2255B6DEE007E1867 /* ThreadViewHelper.h */, - C38EF3D1255B6DEE007E1867 /* ThreadViewHelper.m */, C33FDA6D255A57FA00E217F9 /* YapDatabase+Promise.swift */, ); path = Database; @@ -3107,6 +2970,7 @@ C38EF2FB255B6DBD007E1867 /* OWSWindowManager.h */, C38EF306255B6DBE007E1867 /* OWSWindowManager.m */, FD09797327FAB3E200936362 /* ProfileManager.swift */, + FDB4BBC82839BEF000B7C95D /* ProfileManagerError.swift */, C38EF2EC255B6DBA007E1867 /* ProximityMonitoringManager.swift */, C3BBE0B42554F0E10050F1E3 /* ProofOfWork.swift */, C38EEF09255B49A8007E1867 /* SNProtoEnvelope+Conversion.swift */, @@ -3289,8 +3153,6 @@ C38EF3E4255B6DF4007E1867 /* CommonStrings.swift */, C38EF304255B6DBE007E1867 /* ImageCache.swift */, C38EF2F2255B6DBC007E1867 /* Searcher.swift */, - B8C2B33B2563770800551B4D /* ThreadUtil.h */, - B8C2B331256376F000551B4D /* ThreadUtil.m */, B8856D5F256F129B001CE70E /* OWSAlerts.swift */, C3F0A52F255C80BC007BE2A3 /* NoopNotificationsManager.swift */, C38EF283255B6D84007E1867 /* VersionMigrations.h */, @@ -3334,13 +3196,6 @@ children = ( C33FDAF1255A580500E217F9 /* OWSThumbnailService.swift */, C38EF224255B6D5D007E1867 /* SignalAttachment.swift */, - C33FDC15255A581E00E217F9 /* TSAttachment.h */, - C33FDAC2255A580200E217F9 /* TSAttachment.m */, - C33FDC18255A581F00E217F9 /* TSAttachmentPointer.h */, - C33FDB9E255A581400E217F9 /* TSAttachmentPointer.m */, - C379DCFD25673DBC0002D4EB /* TSAttachmentPointer+Conversion.swift */, - C33FDAE4255A580400E217F9 /* TSAttachmentStream.h */, - C33FDAC4255A580200E217F9 /* TSAttachmentStream.m */, ); path = Attachments; sourceTree = ""; @@ -3743,13 +3598,10 @@ isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( - C33FDD74255A582000E217F9 /* OWSPrimaryStorage+keyFromIntLong.h in Headers */, C33FDDB0255A582000E217F9 /* NSURLSessionDataTask+StatusCode.h in Headers */, C33FDDD0255A582000E217F9 /* FunctionalUtil.h in Headers */, - B8C2B3442563782400551B4D /* ThreadUtil.h in Headers */, C38EF334255B6DBF007E1867 /* UIUtil.h in Headers */, C33FDD5B255A582000E217F9 /* OWSOperation.h in Headers */, - C38EF313255B6DBF007E1867 /* OWSUnreadIndicator.h in Headers */, C33FDD7C255A582000E217F9 /* SSKAsserts.h in Headers */, C38EF3F6255B6DF7007E1867 /* OWSTextView.h in Headers */, C38EF24C255B6D67007E1867 /* NSAttributedString+OWS.h in Headers */, @@ -3762,7 +3614,6 @@ C38EF243255B6D67007E1867 /* UIViewController+OWS.h in Headers */, C38EF35D255B6DCC007E1867 /* OWSNavigationController.h in Headers */, C38EF249255B6D67007E1867 /* UIColor+OWS.h in Headers */, - C38EF3F0255B6DF7007E1867 /* ThreadViewHelper.h in Headers */, C38EF274255B6D7A007E1867 /* OWSResaveCollectionDBMigration.h in Headers */, C38EF277255B6D7A007E1867 /* OWSDatabaseMigration.h in Headers */, C38EF3F5255B6DF7007E1867 /* OWSTextField.h in Headers */, @@ -3774,7 +3625,6 @@ C38EF35E255B6DCC007E1867 /* OWSViewController.h in Headers */, C37F5396255B95BD002AEA92 /* OWSAnyTouchGestureRecognizer.h in Headers */, C33FDDB2255A582000E217F9 /* NSArray+OWS.h in Headers */, - C38EF2D7255B6DAF007E1867 /* OWSProfileManager.h in Headers */, C38EF367255B6DCC007E1867 /* OWSTableViewController.h in Headers */, C38EF246255B6D67007E1867 /* UIFont+OWS.h in Headers */, C33FD9AF255A548A00E217F9 /* SignalUtilitiesKit.h in Headers */, @@ -3821,36 +3671,24 @@ isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( - C3D9E485256775D20040E4F3 /* TSAttachment.h in Headers */, C32C5EE5256DF506003C73A2 /* YapDatabaseConnection+OWS.h in Headers */, - C32C5AAF256DBE8F003C73A2 /* TSMessage.h in Headers */, C32C5B8D256DC565003C73A2 /* SSKEnvironment.h in Headers */, C3C2A6F425539DE700C340D1 /* SessionMessagingKit.h in Headers */, - C32C5B2D256DC1A1003C73A2 /* TSQuotedMessage.h in Headers */, C32C5C46256DCBB2003C73A2 /* AppReadiness.h in Headers */, - C3D9E487256775D20040E4F3 /* TSAttachmentStream.h in Headers */, B8856CB1256F0F47001CE70E /* OWSBackupFragment.h in Headers */, C3A3A122256E1A97004D228D /* OWSDisappearingMessagesFinder.h in Headers */, C3A3A12B256E1AD5004D228D /* TSDatabaseSecondaryIndexes.h in Headers */, C32C5FC4256E0209003C73A2 /* OWSBackgroundTask.h in Headers */, - C32C5AB3256DBE8F003C73A2 /* TSInteraction.h in Headers */, - C3D9E486256775D20040E4F3 /* TSAttachmentPointer.h in Headers */, C32C5EF7256DF567003C73A2 /* TSDatabaseView.h in Headers */, B8856ED7256F1EB4001CE70E /* OWSPreferences.h in Headers */, C32A026C25A801AF000ED5D4 /* NSData+messagePadding.h in Headers */, - C32C5EC3256DE133003C73A2 /* OWSQuotedReplyModel.h in Headers */, - C32C5AAA256DBE8F003C73A2 /* TSIncomingMessage.h in Headers */, B8856D72256F1421001CE70E /* OWSWindowManager.h in Headers */, B8856CF7256F105E001CE70E /* OWSAudioPlayer.h in Headers */, B8856D3D256F11B2001CE70E /* Environment.h in Headers */, C32C5E7E256DE023003C73A2 /* YapDatabaseTransaction+OWS.h in Headers */, C32C5CAD256DD1DF003C73A2 /* TSAccountManager.h in Headers */, - B8566C7D256F62030045A0B9 /* OWSUserProfile.h in Headers */, - C3A3A0F5256E194C004D228D /* OWSRecipientIdentity.h in Headers */, - C32C5AB4256DBE8F003C73A2 /* TSOutgoingMessage.h in Headers */, C32C5EA0256DE0D6003C73A2 /* OWSPrimaryStorage.h in Headers */, C3A3A111256E1A93004D228D /* OWSIncomingMessageFinder.h in Headers */, - C32C5AAC256DBE8F003C73A2 /* TSInfoMessage.h in Headers */, B8856E33256F18D5001CE70E /* OWSStorage+Subclass.h in Headers */, C32C5E64256DDFD6003C73A2 /* OWSStorage.h in Headers */, ); @@ -4533,7 +4371,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - C38EF30D255B6DBF007E1867 /* OWSUnreadIndicator.m in Sources */, C38EF3FD255B6DF7007E1867 /* OWSTextView.m in Sources */, C38EF3C6255B6DE7007E1867 /* ImageEditorModel.swift in Sources */, C38EF317255B6DBF007E1867 /* DisplayableText.swift in Sources */, @@ -4566,14 +4403,12 @@ C38EF402255B6DF7007E1867 /* CommonStrings.swift in Sources */, C38EF3C1255B6DE7007E1867 /* ImageEditorBrushViewController.swift in Sources */, C33FDCC7255A582000E217F9 /* NSArray+OWS.m in Sources */, - C38EF3EF255B6DF7007E1867 /* ThreadViewHelper.m in Sources */, C33FDD32255A582000E217F9 /* OWSOperation.m in Sources */, C3F0A530255C80BC007BE2A3 /* NoopNotificationsManager.swift in Sources */, C3D90A7A25773A93002C9DF5 /* Configuration.swift in Sources */, C33FDD8D255A582000E217F9 /* OWSSignalAddress.swift in Sources */, C33FDC64255A582000E217F9 /* NSObject+Casting.m in Sources */, C38EF388255B6DD2007E1867 /* AttachmentApprovalViewController.swift in Sources */, - C33FDD53255A582000E217F9 /* OWSPrimaryStorage+keyFromIntLong.m in Sources */, C38EF2A7255B6D93007E1867 /* ProfilePictureView.swift in Sources */, C38EF22C255B6D5D007E1867 /* OWSVideoPlayer.swift in Sources */, C33FDC29255A581F00E217F9 /* ReachabilityManager.swift in Sources */, @@ -4582,7 +4417,6 @@ FD3C907327E8387300CD579F /* OpenGroupServerIdLookupMigration.swift in Sources */, C38EF32A255B6DBF007E1867 /* UIUtil.m in Sources */, C38EF2A6255B6D93007E1867 /* PlaceholderIcon.swift in Sources */, - C38EF371255B6DCC007E1867 /* MessageApprovalViewController.swift in Sources */, C33FDD92255A582000E217F9 /* SignalIOS.pb.swift in Sources */, C33FDC45255A581F00E217F9 /* AppVersion.m in Sources */, C38EF3C7255B6DE7007E1867 /* ImageEditorCanvasView.swift in Sources */, @@ -4591,7 +4425,6 @@ C38EF32F255B6DBF007E1867 /* OWSFormat.m in Sources */, C38EF3BA255B6DE7007E1867 /* ImageEditorItem.swift in Sources */, C38EF3F7255B6DF7007E1867 /* OWSNavigationBar.swift in Sources */, - C38EF2D4255B6DAF007E1867 /* OWSProfileManager.m in Sources */, C38EF248255B6D67007E1867 /* UIViewController+OWS.m in Sources */, C38EF272255B6D7A007E1867 /* OWSResaveCollectionDBMigration.m in Sources */, C33FDD3A255A582000E217F9 /* Notification+Loki.swift in Sources */, @@ -4629,18 +4462,14 @@ C38EF324255B6DBF007E1867 /* Bench.swift in Sources */, FDCDB8DE2810F73B00352A0C /* Differentiable+Utilities.swift in Sources */, C38EF292255B6D86007E1867 /* VersionMigrations.m in Sources */, - C38EF3F2255B6DF7007E1867 /* DisappearingTimerConfigurationView.swift in Sources */, C38EF3F9255B6DF7007E1867 /* OWSLayerView.swift in Sources */, C33FDD03255A582000E217F9 /* WeakTimer.swift in Sources */, B8B3204E258C15C80020074B /* ContactsMigration.swift in Sources */, C38EF3B9255B6DE7007E1867 /* ImageEditorPinchGestureRecognizer.swift in Sources */, C33FDC98255A582000E217F9 /* SwiftSingletons.swift in Sources */, C33FDC27255A581F00E217F9 /* YapDatabase+Promise.swift in Sources */, - C33FDCD3255A582000E217F9 /* GroupUtilities.swift in Sources */, FD88BADB27A750F200BBC442 /* MessageRequestsMigration.swift in Sources */, - C38EF326255B6DBF007E1867 /* ConversationStyle.swift in Sources */, C38EF3B8255B6DE7007E1867 /* ImageEditorTextViewController.swift in Sources */, - B8C2B332256376F000551B4D /* ThreadUtil.m in Sources */, C38EF40B255B6DF7007E1867 /* TappableStackView.swift in Sources */, C38EF31D255B6DBF007E1867 /* UIImage+OWS.swift in Sources */, C38EF359255B6DCC007E1867 /* SheetViewController.swift in Sources */, @@ -4802,7 +4631,6 @@ 7B4C75CB26B37E0F0000AC89 /* UnsendRequest.swift in Sources */, C300A5F22554B09800555489 /* MessageSender.swift in Sources */, C3C2A74D2553A39700C340D1 /* VisibleMessage.swift in Sources */, - C32C5AAD256DBE8F003C73A2 /* TSInfoMessage.m in Sources */, FD5D201127AA331F00FEA984 /* ConfigurationMessage+Convenience.swift in Sources */, C32C5A13256DB7A5003C73A2 /* PushNotificationAPI.swift in Sources */, FDD250702837199200198BDA /* GarbageCollectionJob.swift in Sources */, @@ -4811,7 +4639,6 @@ C32C5B84256DC54F003C73A2 /* SSKEnvironment.m in Sources */, C3A3A108256E1A5C004D228D /* OWSIncomingMessageFinder.m in Sources */, C352A31325574F5200338F3E /* MessageReceiveJob.swift in Sources */, - C3D9E3BF25676AD70040E4F3 /* TSAttachmentStream.m in Sources */, C3C2A7562553A3AB00C340D1 /* VisibleMessage+Quote.swift in Sources */, C3227FF6260AAD66006EA627 /* OpenGroupMessageV2.swift in Sources */, C32C5C89256DD0D2003C73A2 /* Storage+Jobs.swift in Sources */, @@ -4835,13 +4662,14 @@ C3DB66AC260ACA42001EFC55 /* OpenGroupManagerV2.swift in Sources */, C379DCF4256735770002D4EB /* VisibleMessage+Attachment.swift in Sources */, FD09797F27FCFBFF00936362 /* OWSAES256Key+Utilities.swift in Sources */, + FDB4BBC72838B91E00B7C95D /* LinkPreviewError.swift in Sources */, FD09798327FD1A1500936362 /* ClosedGroup.swift in Sources */, B8856D34256F1192001CE70E /* Environment.m in Sources */, B8B320B7258C30D70020074B /* HTMLMetadata.swift in Sources */, - C32C5AB1256DBE8F003C73A2 /* TSIncomingMessage.m in Sources */, C3A3A107256E1A5C004D228D /* OWSDisappearingMessagesFinder.m in Sources */, FD09798727FD1B7800936362 /* GroupMember.swift in Sources */, FD09799127FD499200936362 /* BoxKeyPair+Utilities.swift in Sources */, + FDB4BBC92839BEF000B7C95D /* ProfileManagerError.swift in Sources */, FDF0B7552807C4BB004C14C5 /* SSKEnvironment.swift in Sources */, B8856ECE256F1E58001CE70E /* OWSPreferences.m in Sources */, FD09C5EC282B8F18000CE219 /* AttachmentError.swift in Sources */, @@ -4855,19 +4683,16 @@ B8F5F60325EDE16F003BF8D4 /* DataExtractionNotification.swift in Sources */, C3A71D1E25589AC30043A11F /* WebSocketProto.swift in Sources */, C3C2A7852553AAF300C340D1 /* SessionProtos.pb.swift in Sources */, - B8566C63256F55930045A0B9 /* OWSLinkPreview+Conversion.swift in Sources */, - C32C5B3F256DC1DF003C73A2 /* TSQuotedMessage+Conversion.swift in Sources */, FDF0B74F28079E5E004C14C5 /* SendReadReceiptsJob.swift in Sources */, FDF0B7422804EA4F004C14C5 /* _002_SetupStandardJobs.swift in Sources */, B8EB20EE2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift in Sources */, FD17D7A227F40F0500122BE0 /* _001_InitialSetupMigration.swift in Sources */, C3C2A7712553A41E00C340D1 /* ControlMessage.swift in Sources */, - C32C5D19256DD493003C73A2 /* OWSLinkPreview.swift in Sources */, + C32C5D19256DD493003C73A2 /* LinkPreviewDraft.swift in Sources */, FD3C907527E83AC200CD579F /* OpenGroupServerIdLookup.swift in Sources */, C32C5CF0256DD3E4003C73A2 /* Storage+Shared.swift in Sources */, C300A5BD2554B00D00555489 /* ReadReceipt.swift in Sources */, FDF0B7582807F368004C14C5 /* MessageReceiverError.swift in Sources */, - C32C5AB5256DBE8F003C73A2 /* TSOutgoingMessage+Conversion.swift in Sources */, C32C5E5B256DDF45003C73A2 /* OWSStorage.m in Sources */, C32C5C4F256DCC36003C73A2 /* Storage+OpenGroups.swift in Sources */, FD6A7A692818BE7300035AC1 /* RetrieveDefaultOpenGroupRoomsJob.swift in Sources */, @@ -4880,11 +4705,8 @@ C3BBE0762554CDA60050F1E3 /* Configuration.swift in Sources */, C35D76DB26606304009AA5FB /* ThreadUpdateBatcher.swift in Sources */, B8856D69256F141F001CE70E /* OWSWindowManager.m in Sources */, - C3D9E3BE25676AD70040E4F3 /* TSAttachmentPointer.m in Sources */, C3ECBF7B257056B700EA7FCE /* Threading.swift in Sources */, - C32C5AAB256DBE8F003C73A2 /* TSIncomingMessage+Conversion.swift in Sources */, C32C5A88256DBCF9003C73A2 /* MessageReceiver+Handling.swift in Sources */, - B8566C6C256F60F50045A0B9 /* OWSUserProfile.m in Sources */, B8D0A25925E367AC00C1835E /* Notification+MessageReceiver.swift in Sources */, C32C599E256DB02B003C73A2 /* TypingIndicators.swift in Sources */, C3DB66CC260AF1F3001EFC55 /* OpenGroupAPIV2+ObjC.swift in Sources */, @@ -4892,22 +4714,17 @@ FD09799527FE7B8E00936362 /* Interaction.swift in Sources */, B88FA7B826045D100049422F /* OpenGroupAPIV2.swift in Sources */, C32C5E97256DE0CB003C73A2 /* OWSPrimaryStorage.m in Sources */, - C32C5EB9256DE130003C73A2 /* OWSQuotedReplyModel+Conversion.swift in Sources */, C3A71D1F25589AC30043A11F /* WebSocketResources.pb.swift in Sources */, C32C5BCC256DC830003C73A2 /* Storage+ClosedGroups.swift in Sources */, - C3A3A0EC256E1949004D228D /* OWSRecipientIdentity.m in Sources */, FDF0B74B28061F7A004C14C5 /* InteractionAttachment.swift in Sources */, B8F5F56525EC8453003BF8D4 /* Notification+Contacts.swift in Sources */, - C32C5AB2256DBE8F003C73A2 /* TSMessage.m in Sources */, FD09796E27FA6D0000936362 /* Contact.swift in Sources */, C3A3A0FE256E1A3C004D228D /* TSDatabaseSecondaryIndexes.m in Sources */, C38D5E8D2575011E00B6A65C /* MessageSender+ClosedGroups.swift in Sources */, - C32C5B1C256DC19D003C73A2 /* TSQuotedMessage.m in Sources */, C3DB6695260AC923001EFC55 /* OpenGroupV2.swift in Sources */, C352A349255781F400338F3E /* AttachmentDownloadJob.swift in Sources */, C352A30925574D8500338F3E /* Message+Destination.swift in Sources */, C300A5E72554B07300555489 /* ExpirationTimerUpdate.swift in Sources */, - C3D9E3C025676AD70040E4F3 /* TSAttachment.m in Sources */, C32C598A256D0664003C73A2 /* SNProtoEnvelope+Conversion.swift in Sources */, C352A2FF25574B6300338F3E /* MessageSendJob.swift in Sources */, B8856D11256F112A001CE70E /* OWSAudioSession.swift in Sources */, @@ -4924,18 +4741,14 @@ C32C5EEE256DF54E003C73A2 /* TSDatabaseView.m in Sources */, C352A35B2557824E00338F3E /* AttachmentUploadJob.swift in Sources */, FD09797027FA6FF300936362 /* Profile.swift in Sources */, - C32C5AAE256DBE8F003C73A2 /* TSInteraction.m in Sources */, FD09798B27FD1CFE00936362 /* Capability.swift in Sources */, C3BBE0C72554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift in Sources */, C32C5C88256DD0D2003C73A2 /* Storage+Messaging.swift in Sources */, FD17D79C27F40B2E00122BE0 /* SMKLegacy.swift in Sources */, C300A5B22554AF9800555489 /* VisibleMessage+Profile.swift in Sources */, FD09798127FCFEE800936362 /* SessionThread.swift in Sources */, - C32C5A75256DBBCF003C73A2 /* TSAttachmentPointer+Conversion.swift in Sources */, - C32C5EBA256DE130003C73A2 /* OWSQuotedReplyModel.m in Sources */, FD09C5EA282A1BB2000CE219 /* ThreadTypingIndicator.swift in Sources */, FDF0B75E280AAF35004C14C5 /* Preferences.swift in Sources */, - C32C5AB0256DBE8F003C73A2 /* TSOutgoingMessage.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4975,7 +4788,6 @@ 3430FE181F7751D4000EC51B /* GiphyAPI.swift in Sources */, 340FC8AA204DAC8D007AEB0F /* NotificationSettingsViewController.m in Sources */, 4C090A1B210FD9C7001FD7F9 /* HapticFeedback.swift in Sources */, - 3496744F2076ACD000080B5F /* LongTextViewController.swift in Sources */, 34F308A21ECB469700BB7697 /* OWSBezierPathView.m in Sources */, B886B4A92398BA1500211ABE /* QRCode.swift in Sources */, 3496955D219B605E00DCFE74 /* PhotoCollectionPickerController.swift in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index f61412cfa..24cbe4b1f 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -123,6 +123,12 @@ extension ConversationVC: func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didChangeMessageText newMessageText: String?) { snInputView.text = newMessageText ?? "" } + + func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didRemoveAttachment attachment: SignalAttachment) { + } + + func attachmentApprovalDidTapAddMore(_ attachmentApproval: AttachmentApprovalViewController) { + } // MARK: - ExpandingAttachmentsButtonDelegate @@ -307,7 +313,7 @@ extension ConversationVC: let thread: SessionThread = viewModel.viewData.thread let oldThreadShouldBeVisible: Bool = thread.shouldBeVisible let sentTimestampMs: Int64 = Int64(floor((Date().timeIntervalSince1970 * 1000))) - let linkPreviewDraft: OWSLinkPreviewDraft? = snInputView.linkPreviewInfo?.draft + let linkPreviewDraft: LinkPreviewDraft? = snInputView.linkPreviewInfo?.draft let quoteModel: QuotedReplyModel? = snInputView.quoteDraftInfo?.model approveMessageRequestIfNeeded( @@ -337,7 +343,7 @@ extension ConversationVC: // If there is a LinkPreview and it doesn't match an existing one then add it now if - let linkPreviewDraft: OWSLinkPreviewDraft = linkPreviewDraft, + let linkPreviewDraft: LinkPreviewDraft = linkPreviewDraft, (try? interaction.linkPreview.isEmpty(db)) == true { try LinkPreview( @@ -777,9 +783,6 @@ extension ConversationVC: case .ended, .cancelled: tableView.isScrollEnabled = true } } - - func showFullText(_ item: ConversationViewModel.Item) { - } func openUrl(_ urlString: String) { guard let url: URL = URL(string: urlString) else { return } diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index bac286a98..fe79c2843 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -968,10 +968,6 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers return a * x } - func groupWasUpdated(_ groupModel: TSGroupModel) { - // Not currently in use - } - // MARK: - Search func conversationSettingsDidRequestConversationSearch(_ conversationSettingsViewController: OWSConversationSettingsViewController) { diff --git a/Session/Conversations/ConversationViewItem.h b/Session/Conversations/ConversationViewItem.h index 8aeaccd01..c5479f1e2 100644 --- a/Session/Conversations/ConversationViewItem.h +++ b/Session/Conversations/ConversationViewItem.h @@ -25,15 +25,6 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType); @class ContactShareViewModel; @class ConversationViewCell; @class DisplayableText; -@class SNVoiceMessageView; -@class OWSLinkPreview; -@class OWSQuotedReplyModel; -@class OWSUnreadIndicator; -@class TSAttachment; -@class TSAttachmentPointer; -@class TSAttachmentStream; -@class TSInteraction; -@class TSThread; @class YapDatabaseReadTransaction; @interface ConversationMediaAlbumItem : NSObject diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift index 2e153f00d..3c28bfdcf 100644 --- a/Session/Conversations/Input View/InputView.swift +++ b/Session/Conversations/Input View/InputView.swift @@ -19,7 +19,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M private weak var delegate: InputViewDelegate? var quoteDraftInfo: (model: QuotedReplyModel, isOutgoing: Bool)? { didSet { handleQuoteDraftChanged() } } - var linkPreviewInfo: (url: String, draft: OWSLinkPreviewDraft?)? + var linkPreviewInfo: (url: String, draft: LinkPreviewDraft?)? private var voiceMessageRecordingView: VoiceMessageRecordingView? private lazy var mentionsViewHeightConstraint = mentionsView.set(.height, to: 0) @@ -252,7 +252,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M let areLinkPreviewsEnabled: Bool = GRDBStorage.shared[.areLinkPreviewsEnabled] if - !OWSLinkPreview.allPreviewUrls(forMessageBodyText: text).isEmpty && + !LinkPreview.allPreviewUrls(forMessageBodyText: text).isEmpty && !areLinkPreviewsEnabled && !UserDefaults.standard[.hasSeenLinkPreviewSuggestion] { @@ -269,7 +269,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M func autoGenerateLinkPreview() { // Check that a valid URL is present - guard let linkPreviewURL = OWSLinkPreview.previewUrl(forRawBodyText: text, selectedRange: inputTextView.selectedRange) else { + guard let linkPreviewURL = LinkPreview.previewUrl(for: text, selectedRange: inputTextView.selectedRange) else { return } @@ -282,7 +282,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M // Set the state to loading linkPreviewInfo = (url: linkPreviewURL, draft: nil) - linkPreviewView.update(with: LinkPreviewLoading(), isOutgoing: false) + linkPreviewView.update(with: LinkPreview.LoadingState(), isOutgoing: false) // Add the link preview view additionalContentContainer.addSubview(linkPreviewView) @@ -292,12 +292,12 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M linkPreviewView.pin(.bottom, to: .bottom, of: additionalContentContainer, withInset: -4) // Build the link preview - OWSLinkPreview.tryToBuildPreviewInfo(previewUrl: linkPreviewURL) + LinkPreview.tryToBuildPreviewInfo(previewUrl: linkPreviewURL) .done { [weak self] draft in guard self?.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete self?.linkPreviewInfo = (url: linkPreviewURL, draft: draft) - self?.linkPreviewView.update(with: LinkPreviewDraft(linkPreviewDraft: draft), isOutgoing: false) + self?.linkPreviewView.update(with: LinkPreview.DraftState(linkPreviewDraft: draft), isOutgoing: false) } .catch { [weak self] _ in guard self?.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete diff --git a/Session/Conversations/LongTextViewController.swift b/Session/Conversations/LongTextViewController.swift deleted file mode 100644 index ecb9ac5bf..000000000 --- a/Session/Conversations/LongTextViewController.swift +++ /dev/null @@ -1,170 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -import Foundation -import SignalUtilitiesKit - -@objc -public protocol LongTextViewDelegate { - @objc - func longTextViewMessageWasDeleted(_ longTextViewController: LongTextViewController) -} - -@objc -public class LongTextViewController: OWSViewController { - - // MARK: - Dependencies - - var uiDatabaseConnection: YapDatabaseConnection { - return OWSPrimaryStorage.shared().uiDatabaseConnection - } - - // MARK: - Properties - - @objc - weak var delegate: LongTextViewDelegate? - - let viewItem: ConversationViewItem - - var messageTextView: UITextView! - - var displayableText: DisplayableText? { - return viewItem.displayableBodyText - } - - var fullText: String { - return displayableText?.fullText ?? "" - } - - // MARK: Initializers - - @available(*, unavailable, message:"use other constructor instead.") - public required init?(coder aDecoder: NSCoder) { - notImplemented() - } - - @objc - public required init(viewItem: ConversationViewItem) { - self.viewItem = viewItem - super.init(nibName: nil, bundle: nil) - } - - // MARK: View Lifecycle - - public override func viewDidLoad() { - super.viewDidLoad() - - ViewControllerUtilities.setUpDefaultSessionStyle(for: self, title: NSLocalizedString("LONG_TEXT_VIEW_TITLE", comment: ""), hasCustomBackButton: false) - - createViews() - - self.messageTextView.contentOffset = CGPoint(x: 0, y: self.messageTextView.contentInset.top) - - NotificationCenter.default.addObserver(self, - selector: #selector(uiDatabaseDidUpdate), - name: .OWSUIDatabaseConnectionDidUpdate, - object: OWSPrimaryStorage.shared().dbNotificationObject) - } - - // MARK: - DB - - @objc internal func uiDatabaseDidUpdate(notification: NSNotification) { - AssertIsOnMainThread() - - guard let notifications = notification.userInfo?[OWSUIDatabaseConnectionNotificationsKey] as? [Notification] else { - owsFailDebug("notifications was unexpectedly nil") - return - } - - guard let uniqueId = self.viewItem.interaction.uniqueId else { - Logger.error("Message is missing uniqueId.") - return - } - - guard self.uiDatabaseConnection.hasChange(forKey: uniqueId, - inCollection: TSInteraction.collection(), - in: notifications) else { - Logger.debug("No relevant changes.") - return - } - - do { - try uiDatabaseConnection.read { transaction in - guard TSInteraction.fetch(uniqueId: uniqueId, transaction: transaction) != nil else { - Logger.error("Message was deleted") - throw LongTextViewError.messageWasDeleted - } - } - } catch LongTextViewError.messageWasDeleted { - DispatchQueue.main.async { - self.delegate?.longTextViewMessageWasDeleted(self) - } - } catch { - owsFailDebug("unexpected error: \(error)") - - } - } - - enum LongTextViewError: Error { - case messageWasDeleted - } - - // MARK: - Create Views - - private func createViews() { - view.backgroundColor = Colors.navigationBarBackground - - let messageTextView = OWSTextView() - self.messageTextView = messageTextView - messageTextView.font = .systemFont(ofSize: Values.smallFontSize) - messageTextView.backgroundColor = .clear - messageTextView.isOpaque = true - messageTextView.isEditable = false - messageTextView.isSelectable = true - messageTextView.isScrollEnabled = true - messageTextView.showsHorizontalScrollIndicator = false - messageTextView.showsVerticalScrollIndicator = true - messageTextView.isUserInteractionEnabled = true - messageTextView.textColor = Colors.text - messageTextView.contentInset = UIEdgeInsets(top: Values.mediumSpacing, leading: 0, bottom: 0, trailing: 0) - if let displayableText = displayableText { - messageTextView.text = fullText - messageTextView.ensureShouldLinkifyText(displayableText.shouldAllowLinkification) - } else { - owsFailDebug("displayableText was unexpectedly nil") - messageTextView.text = "" - } - - let linkTextAttributes: [NSAttributedString.Key: Any] = [ - NSAttributedString.Key.foregroundColor: Colors.text, - NSAttributedString.Key.underlineColor: Colors.text, - NSAttributedString.Key.underlineStyle: NSUnderlineStyle.single.rawValue - ] - messageTextView.linkTextAttributes = linkTextAttributes - - view.addSubview(messageTextView) - messageTextView.autoPinEdge(toSuperviewEdge: .top) - messageTextView.autoPinEdge(toSuperviewEdge: .leading) - messageTextView.autoPinEdge(toSuperviewEdge: .trailing) - messageTextView.textContainerInset = UIEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16) - - let footer = UIToolbar() - view.addSubview(footer) - footer.autoPinWidthToSuperview() - footer.autoPinEdge(.top, to: .bottom, of: messageTextView) - footer.autoPinEdge(toSuperviewSafeArea: .bottom) - - footer.items = [ - UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil), - UIBarButtonItem(barButtonSystemItem: .action, target: self, action: #selector(shareButtonPressed)), - UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) - ] - } - - // MARK: - Actions - - @objc func shareButtonPressed() { - AttachmentSharing.showShareUI(forText: fullText) - } -} diff --git a/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift b/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift index 9cf8c83b9..0b2482946 100644 --- a/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift +++ b/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift @@ -3,211 +3,136 @@ import UIKit import SessionMessagingKit -extension CGPoint { - - public func offsetBy(dx: CGFloat) -> CGPoint { - return CGPoint(x: x + dx, y: y) - } - - public func offsetBy(dy: CGFloat) -> CGPoint { - return CGPoint(x: x, y: y + dy) - } +protocol LinkPreviewState { + var isLoaded: Bool { get } + var urlString: String? { get } + var title: String? { get } + var imageState: LinkPreview.ImageState { get } + var image: UIImage? { get } } -// MARK: - - -@objc -public enum LinkPreviewImageState: Int { - case none - case loading - case loaded - case invalid -} - -// MARK: - - -@objc -public protocol LinkPreviewState { - func isLoaded() -> Bool - func urlString() -> String? - func displayDomain() -> String? - func title() -> String? - func imageState() -> LinkPreviewImageState - func image() -> UIImage? -} - -// MARK: - - -@objc -public class LinkPreviewLoading: NSObject, LinkPreviewState { - - override init() { - } - - public func isLoaded() -> Bool { - return false - } - - public func urlString() -> String? { - return nil - } - - public func displayDomain() -> String? { - return nil - } - - public func title() -> String? { - return nil - } - - public func imageState() -> LinkPreviewImageState { - return .none - } - - public func image() -> UIImage? { - return nil - } -} - -// MARK: - - -@objc -public class LinkPreviewDraft: NSObject, LinkPreviewState { - private let linkPreviewDraft: OWSLinkPreviewDraft - - @objc - public required init(linkPreviewDraft: OWSLinkPreviewDraft) { - self.linkPreviewDraft = linkPreviewDraft - } - - public func isLoaded() -> Bool { - return true - } - - public func urlString() -> String? { - return linkPreviewDraft.urlString - } - - public func displayDomain() -> String? { - guard let displayDomain = linkPreviewDraft.displayDomain() else { - owsFailDebug("Missing display domain") - return nil - } - return displayDomain - } - - public func title() -> String? { - guard let value = linkPreviewDraft.title, - value.count > 0 else { - return nil - } - return value - } - - public func imageState() -> LinkPreviewImageState { - if linkPreviewDraft.jpegImageData != nil { - return .loaded - } else { - return .none - } - } - - public func image() -> UIImage? { - guard let jpegImageData = linkPreviewDraft.jpegImageData else { - return nil - } - guard let image = UIImage(data: jpegImageData) else { - owsFailDebug("Could not load image: \(jpegImageData.count)") - return nil - } - return image - } -} - -// MARK: - - -public class LinkPreviewSent: LinkPreviewState { - private let linkPreview: LinkPreview - private let imageAttachment: Attachment? - - public var imageSize: CGSize { - guard let width: UInt = imageAttachment?.width, let height: UInt = imageAttachment?.height else { - return CGSize.zero - } - - return CGSize(width: CGFloat(width), height: CGFloat(height)) - } - - public required init(linkPreview: LinkPreview, imageAttachment: Attachment?) { - self.linkPreview = linkPreview - self.imageAttachment = imageAttachment - } - - public func isLoaded() -> Bool { - return true +public extension LinkPreview { + enum ImageState: Int { + case none + case loading + case loaded + case invalid } - public func urlString() -> String? { - return linkPreview.url + // MARK: LoadingState + + struct LoadingState: LinkPreviewState { + var isLoaded: Bool { false } + var urlString: String? { nil } + var title: String? { nil } + var imageState: LinkPreview.ImageState { .none } + var image: UIImage? { nil } } + + // MARK: DraftState + + struct DraftState: LinkPreviewState { + var isLoaded: Bool { true } + var urlString: String? { linkPreviewDraft.urlString } - public func displayDomain() -> String? { - guard let displayDomain: String = URL(string: linkPreview.url)?.host else { - Logger.error("Missing display domain") - return nil + var title: String? { + guard let value = linkPreviewDraft.title, value.count > 0 else { return nil } + + return value } - return displayDomain - } - - public func title() -> String? { - guard let value = linkPreview.title, - value.count > 0 else { - return nil - } - return value - } - - public func imageState() -> LinkPreviewImageState { - guard linkPreview.attachmentId != nil else { return .none } - guard let imageAttachment: Attachment = imageAttachment else { - owsFailDebug("Missing imageAttachment.") + var imageState: LinkPreview.ImageState { + if linkPreviewDraft.jpegImageData != nil { return .loaded } + return .none } - switch imageAttachment.state { - case .downloaded, .uploaded: - guard imageAttachment.isImage && imageAttachment.isValid else { - return .invalid - } - - return .loaded - - case .pending, .downloading, .uploading: return .loading - case .failed: return .invalid - } - } - - public func image() -> UIImage? { - // Note: We don't check if the image is valid here because that can be confirmed - // in 'imageState' and it's a little inefficient - guard imageAttachment?.isImage == true else { return nil } - guard let imageData: Data = try? imageAttachment?.readDataFromFile() else { - return nil - } - guard let image = UIImage(data: imageData) else { - owsFailDebug("Could not load image: \(imageAttachment?.localRelativeFilePath ?? "unknown")") - return nil + var image: UIImage? { + guard let jpegImageData = linkPreviewDraft.jpegImageData else { return nil } + guard let image = UIImage(data: jpegImageData) else { + owsFailDebug("Could not load image: \(jpegImageData.count)") + return nil + } + + return image } - return image + // MARK: - Type Specific + + private let linkPreviewDraft: LinkPreviewDraft + + // MARK: - Initialization + + init(linkPreviewDraft: LinkPreviewDraft) { + self.linkPreviewDraft = linkPreviewDraft + } + } + + // MARK: SentState + + struct SentState: LinkPreviewState { + var isLoaded: Bool { true } + var urlString: String? { linkPreview.url } + + var title: String? { + guard let value = linkPreview.title, value.count > 0 else { return nil } + + return value + } + + var imageState: LinkPreview.ImageState { + guard linkPreview.attachmentId != nil else { return .none } + guard let imageAttachment: Attachment = imageAttachment else { + owsFailDebug("Missing imageAttachment.") + return .none + } + + switch imageAttachment.state { + case .downloaded, .uploaded: + guard imageAttachment.isImage && imageAttachment.isValid else { + return .invalid + } + + return .loaded + + case .pending, .downloading, .uploading: return .loading + case .failed: return .invalid + } + } + + var image: UIImage? { + // Note: We don't check if the image is valid here because that can be confirmed + // in 'imageState' and it's a little inefficient + guard imageAttachment?.isImage == true else { return nil } + guard let imageData: Data = try? imageAttachment?.readDataFromFile() else { + return nil + } + guard let image = UIImage(data: imageData) else { + owsFailDebug("Could not load image: \(imageAttachment?.localRelativeFilePath ?? "unknown")") + return nil + } + + return image + } + + // MARK: - Type Specific + + private let linkPreview: LinkPreview + private let imageAttachment: Attachment? + + public var imageSize: CGSize { + guard let width: UInt = imageAttachment?.width, let height: UInt = imageAttachment?.height else { + return CGSize.zero + } + + return CGSize(width: CGFloat(width), height: CGFloat(height)) + } + + // MARK: - Initialization + + init(linkPreview: LinkPreview, imageAttachment: Attachment?) { + self.linkPreview = linkPreview + self.imageAttachment = imageAttachment + } } } - -// MARK: - - -@objc -public protocol LinkPreviewViewDraftDelegate { - func linkPreviewCanCancel() -> Bool - func linkPreviewDidCancel() -} diff --git a/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift b/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift index 0003a1545..204cef5f3 100644 --- a/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift +++ b/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift @@ -134,19 +134,19 @@ final class LinkPreviewView: UIView { ) { cancelButton.removeFromSuperview() - var image: UIImage? = state.image() + var image: UIImage? = state.image let stateHasImage: Bool = (image != nil) - if image == nil && (state is LinkPreviewDraft || state is LinkPreviewSent) { + if image == nil && (state is LinkPreview.DraftState || state is LinkPreview.SentState) { image = UIImage(named: "Link")?.withTint(isLightMode ? .black : .white) } // Image view - let imageViewContainerSize: CGFloat = (state is LinkPreviewSent ? 100 : 80) + let imageViewContainerSize: CGFloat = (state is LinkPreview.SentState ? 100 : 80) imageViewContainerWidthConstraint.constant = imageViewContainerSize imageViewContainerHeightConstraint.constant = imageViewContainerSize - imageViewContainer.layer.cornerRadius = (state is LinkPreviewSent ? 0 : 8) + imageViewContainer.layer.cornerRadius = (state is LinkPreview.SentState ? 0 : 8) - if state is LinkPreviewLoading { + if state is LinkPreview.LoadingState { imageViewContainer.backgroundColor = .clear } else { @@ -169,11 +169,11 @@ final class LinkPreviewView: UIView { } }() titleLabel.textColor = sentLinkPreviewTextColor - titleLabel.text = state.title() + titleLabel.text = state.title // Horizontal stack view switch state { - case is LinkPreviewSent: + case is LinkPreview.SentState: // FIXME: This will have issues with theme transitions hStackViewContainer.backgroundColor = (isDarkMode ? .black : UIColor.black.withAlphaComponent(0.06)) @@ -197,7 +197,7 @@ final class LinkPreviewView: UIView { bodyTextView.pin(to: bodyTextViewContainer, withInset: 12) } - if state is LinkPreviewDraft { + if state is LinkPreview.DraftState { hStackView.addArrangedSubview(cancelButton) } } diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 7ea44dc82..cdbfade3c 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -386,7 +386,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel case .standard: let linkPreviewView: LinkPreviewView = LinkPreviewView(maxWidth: maxWidth) linkPreviewView.update( - with: LinkPreviewSent( + with: LinkPreview.SentState( linkPreview: linkPreview, imageAttachment: item.attachments?.first ), diff --git a/Session/Conversations/Settings/OWSConversationSettingsViewController.m b/Session/Conversations/Settings/OWSConversationSettingsViewController.m index b4c818942..d53be144d 100644 --- a/Session/Conversations/Settings/OWSConversationSettingsViewController.m +++ b/Session/Conversations/Settings/OWSConversationSettingsViewController.m @@ -13,7 +13,6 @@ #import #import #import -#import @import ContactsUI; @import PromiseKit; diff --git a/Session/Meta/SessionApp.swift b/Session/Meta/SessionApp.swift index 40258aaeb..1c600081f 100644 --- a/Session/Meta/SessionApp.swift +++ b/Session/Meta/SessionApp.swift @@ -58,7 +58,7 @@ public struct SessionApp { DDLog.flushLog() OWSStorage.resetAllStorage() - OWSUserProfile.resetProfileStorage() + ProfileManager.resetProfileStorage() Environment.shared.preferences.clear() AppEnvironment.shared.notificationPresenter.clearAllNotifications() diff --git a/Session/Meta/Signal-Bridging-Header.h b/Session/Meta/Signal-Bridging-Header.h index 28e533d98..f5f701a8b 100644 --- a/Session/Meta/Signal-Bridging-Header.h +++ b/Session/Meta/Signal-Bridging-Header.h @@ -40,7 +40,6 @@ #import #import #import -#import #import #import #import @@ -57,14 +56,7 @@ #import #import #import -#import #import -#import -#import -#import -#import -#import -#import #import #import #import diff --git a/Session/Settings/PrivacySettingsTableViewController.m b/Session/Settings/PrivacySettingsTableViewController.m index b0bd17de9..153ba68ab 100644 --- a/Session/Settings/PrivacySettingsTableViewController.m +++ b/Session/Settings/PrivacySettingsTableViewController.m @@ -12,7 +12,6 @@ #import #import #import -#import #import #import diff --git a/Session/Settings/SettingsVC.swift b/Session/Settings/SettingsVC.swift index 3ea7b2fe0..5bd46eb08 100644 --- a/Session/Settings/SettingsVC.swift +++ b/Session/Settings/SettingsVC.swift @@ -382,27 +382,29 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { profileName: (name ?? ""), avatarImage: profilePicture, requiredSync: true, - success: { updatedProfile in + success: { db, updatedProfile in if displayNameToBeUploaded != nil { userDefaults[.lastDisplayNameUpdate] = Date() } if profilePictureToBeUploaded != nil { userDefaults[.lastProfilePictureUpdate] = Date() } - GRDBStorage.shared.write { db in - try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() - } - DispatchQueue.main.async { - modalActivityIndicator.dismiss { - self?.profilePictureView.update( - publicKey: updatedProfile.id, - profile: updatedProfile, - threadVariant: .contact - ) - self?.displayNameLabel.text = name - self?.profilePictureToBeUploaded = nil - self?.displayNameToBeUploaded = nil + try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() + + // Wait for the database transaction to complete before updating the UI + db.afterNextTransactionCommit { _ in + DispatchQueue.main.async { + modalActivityIndicator.dismiss { + self?.profilePictureView.update( + publicKey: updatedProfile.id, + profile: updatedProfile, + threadVariant: .contact + ) + self?.displayNameLabel.text = name + self?.profilePictureToBeUploaded = nil + self?.displayNameToBeUploaded = nil + } } } }, diff --git a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift index eb2a5970c..be2b6194b 100644 --- a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift +++ b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift @@ -1,7 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import Mantle import Sodium import YapDatabase import SignalCoreKit @@ -15,6 +14,8 @@ public enum SMKLegacy { internal static let closedGroupIdPrefix = "__textsecure_group__!" internal static let closedGroupKeyPairPrefix = "SNClosedGroupEncryptionKeyPairCollection-" + internal static let databaseMigrationCollection = "OWSDatabaseMigration" + public static let contactCollection = "LokiContactCollection" public static let threadCollection = "TSThread" internal static let disappearingMessagesCollection = "OWSDisappearingMessagesConfiguration" @@ -43,6 +44,9 @@ public enum SMKLegacy { internal static let attachmentUploadJobCollection = "AttachmentUploadJobCollection" internal static let attachmentDownloadJobCollection = "AttachmentDownloadJobCollection" + internal static let blockListCollection: String = "kOWSBlockingManager_BlockedPhoneNumbersCollection" + internal static let blockedPhoneNumbersKey: String = "kOWSBlockingManager_BlockedPhoneNumbersKey" + // Preferences internal static let preferencesCollection = "SignalPreferences" @@ -70,7 +74,16 @@ public enum SMKLegacy { internal static let userDefaultsHasHiddenMessageRequests = "hasHiddenMessageRequests" - // MARK: - Types (and NSCoding) + // MARK: - DatabaseMigration + + public enum _DBMigration: String { + case contactsMigration = "001" // Handled during contact migration + case messageRequestsMigration = "002" // Handled during contact migration + case openGroupServerIdLookupMigration = "003" // Ignored (creates a lookup table, replaced with an index) + case blockingManagerRemovalMigration = "004" // Handled during contact migration + } + + // MARK: - Contact @objc(SNContact) public class _Contact: NSObject, NSCoding { @@ -112,33 +125,7 @@ public enum SMKLegacy { } } - @objc(OWSDisappearingMessagesConfiguration) - internal class _DisappearingMessagesConfiguration: MTLModel { - public let uniqueId: String - public var isEnabled: Bool - public var durationSeconds: UInt32 - - // MARK: - NSCoder - - required init(coder: NSCoder) { - self.uniqueId = coder.decodeObject(forKey: "uniqueId") as! String - self.isEnabled = coder.decodeObject(forKey: "enabled") as! Bool - self.durationSeconds = coder.decodeObject(forKey: "durationSeconds") as! UInt32 - - // Intentionally not calling 'super.init(coder:) here - super.init() - } - - required init(dictionary dictionaryValue: [String : Any]!) throws { - fatalError("init(dictionary:) has not been implemented") - } - - override public func encode(with coder: NSCoder) { - fatalError("encode(with:) should never be called for legacy types") - } - } - - // MARK: - Visible/Control Message NSCoding + // MARK: - Message /// Abstract base class for `VisibleMessage` and `ControlMessage`. @objc(SNMessage) @@ -191,6 +178,8 @@ public enum SMKLegacy { return result } } + + // MARK: - Visible Message @objc(SNVisibleMessage) internal final class _VisibleMessage: _Message { @@ -202,7 +191,7 @@ public enum SMKLegacy { internal var profile: _Profile? internal var openGroupInvitation: _OpenGroupInvitation? - // MARK: - NSCoding + // MARK: NSCoding public required init?(coder: NSCoder) { super.init(coder: coder) @@ -237,6 +226,8 @@ public enum SMKLegacy { } } + // MARK: - Quote + @objc(SNQuote) internal class _Quote: NSObject, NSCoding { internal var timestamp: UInt64? @@ -244,7 +235,7 @@ public enum SMKLegacy { internal var text: String? internal var attachmentID: String? - // MARK: - NSCoding + // MARK: NSCoding public required init?(coder: NSCoder) { if let timestamp = coder.decodeObject(forKey: "timestamp") as! UInt64? { self.timestamp = timestamp } @@ -269,13 +260,15 @@ public enum SMKLegacy { } } + // MARK: - Link Preview + @objc(SNLinkPreview) internal class _LinkPreview: NSObject, NSCoding { internal var title: String? internal var url: String? internal var attachmentID: String? - // MARK: - NSCoding + // MARK: NSCoding public required init?(coder: NSCoder) { if let title = coder.decodeObject(forKey: "title") as! String? { self.title = title } @@ -298,13 +291,15 @@ public enum SMKLegacy { } } + // MARK: - Profile + @objc(SNProfile) internal class _Profile: NSObject, NSCoding { internal var displayName: String? internal var profileKey: Data? internal var profilePictureURL: String? - // MARK: - NSCoding + // MARK: NSCoding public required init?(coder: NSCoder) { if let displayName = coder.decodeObject(forKey: "displayName") as! String? { self.displayName = displayName } @@ -327,12 +322,14 @@ public enum SMKLegacy { } } + // MARK: - Open Group Invitation + @objc(SNOpenGroupInvitation) internal class _OpenGroupInvitation: NSObject, NSCoding { internal var name: String? internal var url: String? - // MARK: - NSCoding + // MARK: NSCoding public required init?(coder: NSCoder) { if let name = coder.decodeObject(forKey: "name") as! String? { self.name = name } @@ -353,14 +350,18 @@ public enum SMKLegacy { } } + // MARK: - Control Message + @objc(SNControlMessage) internal class _ControlMessage: _Message {} + // MARK: - Read Receipt + @objc(SNReadReceipt) internal final class _ReadReceipt: _ControlMessage { internal var timestamps: [UInt64]? - // MARK: - NSCoding + // MARK: NSCoding public required init?(coder: NSCoder) { super.init(coder: coder) @@ -382,11 +383,13 @@ public enum SMKLegacy { } } + // MARK: - Typing Indicator + @objc(SNTypingIndicator) internal final class _TypingIndicator: _ControlMessage { public var rawKind: Int? - // MARK: - NSCoding + // MARK: NSCoding public required init?(coder: NSCoder) { super.init(coder: coder) @@ -411,6 +414,8 @@ public enum SMKLegacy { ) } } + + // MARK: - Closed Group Control Message @objc(SNClosedGroupControlMessage) internal final class _ClosedGroupControlMessage: _ControlMessage { @@ -424,14 +429,14 @@ public enum SMKLegacy { internal var admins: [Data]? internal var expirationTimer: UInt32 - // MARK: - Key Pair Wrapper + // MARK: Key Pair Wrapper @objc(SNKeyPairWrapper) internal final class _KeyPairWrapper: NSObject, NSCoding { internal var publicKey: String? internal var encryptedKeyPair: Data? - // MARK: - NSCoding + // MARK: NSCoding public required init?(coder: NSCoder) { if let publicKey = coder.decodeObject(forKey: "publicKey") as! String? { self.publicKey = publicKey } @@ -443,7 +448,7 @@ public enum SMKLegacy { } } - // MARK: - NSCoding + // MARK: NSCoding public required init?(coder: NSCoder) { self.rawKind = coder.decodeObject(forKey: "kind") as? String @@ -554,12 +559,14 @@ public enum SMKLegacy { } } + // MARK: - Data Extraction Notification + @objc(SNDataExtractionNotification) internal final class _DataExtractionNotification: _ControlMessage { internal let rawKind: String? internal let timestamp: UInt64? - // MARK: - NSCoding + // MARK: NSCoding public required init?(coder: NSCoder) { self.rawKind = coder.decodeObject(forKey: "kind") as? String @@ -596,12 +603,14 @@ public enum SMKLegacy { } } + // MARK: - Expiration Timer Update + @objc(SNExpirationTimerUpdate) internal final class _ExpirationTimerUpdate: _ControlMessage { internal var syncTarget: String? internal var duration: UInt32? - // MARK: - NSCoding + // MARK: NSCoding public required init?(coder: NSCoder) { super.init(coder: coder) @@ -625,6 +634,8 @@ public enum SMKLegacy { } } + // MARK: - Configuration Message + @objc(SNConfigurationMessage) internal final class _ConfigurationMessage: _ControlMessage { internal var closedGroups: Set<_CMClosedGroup> = [] @@ -634,7 +645,7 @@ public enum SMKLegacy { internal var profileKey: Data? internal var contacts: Set<_CMContact> = [] - // MARK: - NSCoding + // MARK: NSCoding public required init?(coder: NSCoder) { super.init(coder: coder) @@ -669,6 +680,8 @@ public enum SMKLegacy { ) } } + + // MARK: - Config Message Closed Group @objc(CMClosedGroup) internal final class _CMClosedGroup: NSObject, NSCoding { @@ -679,7 +692,7 @@ public enum SMKLegacy { internal let admins: Set internal let expirationTimer: UInt32 - // MARK: - NSCoding + // MARK: NSCoding public required init?(coder: NSCoder) { guard @@ -716,6 +729,8 @@ public enum SMKLegacy { ) } } + + // MARK: - Config Message Contact @objc(SNConfigurationMessageContact) internal final class _CMContact: NSObject, NSCoding { @@ -731,7 +746,7 @@ public enum SMKLegacy { internal var hasDidApproveMe: Bool internal var didApproveMe: Bool - // MARK: - NSCoding + // MARK: NSCoding public required init?(coder: NSCoder) { guard @@ -773,12 +788,14 @@ public enum SMKLegacy { } } + // MARK: - Unsend Request + @objc(SNUnsendRequest) internal final class _UnsendRequest: _ControlMessage { internal var timestamp: UInt64? internal var author: String? - // MARK: - NSCoding + // MARK: NSCoding public required init?(coder: NSCoder) { super.init(coder: coder) @@ -803,11 +820,13 @@ public enum SMKLegacy { } } + // MARK: - Message Request Response + @objc(SNMessageRequestResponse) internal final class _MessageRequestResponse: _ControlMessage { internal var isApproved: Bool - // MARK: - NSCoding + // MARK: NSCoding public required init?(coder: NSCoder) { self.isApproved = coder.decodeBool(forKey: "isApproved") @@ -830,7 +849,7 @@ public enum SMKLegacy { } } - // MARK: - Threads + // MARK: - Thread @objc(TSThread) public class _Thread: NSObject, NSCoding { @@ -841,32 +860,30 @@ public enum SMKLegacy { public var mutedUntilDate: Date? public var messageDraft: String? - // MARK: - Convenience + // MARK: Convenience open var isClosedGroup: Bool { false } open var isOpenGroup: Bool { false } - // MARK: - NSCoder + // MARK: NSCoder public required init(coder: NSCoder) { self.uniqueId = coder.decodeObject(forKey: "uniqueId") as! String self.creationDate = coder.decodeObject(forKey: "creationDate") as! Date // Legacy version of 'shouldBeVisible' - if let hasEverHadMessage: Bool = (coder.decodeObject(forKey: "hasEverHadMessage") as? NSNumber)?.boolValue { + if let hasEverHadMessage: Bool = (coder.decodeObject(forKey: "hasEverHadMessage") as? Bool) { self.shouldBeVisible = hasEverHadMessage } else { - self.shouldBeVisible = ((coder.decodeObject(forKey: "shouldBeVisible") as? NSNumber)? - .boolValue) + self.shouldBeVisible = (coder.decodeObject(forKey: "shouldBeVisible") as? Bool) .defaulting(to: false) } - self.isPinned = ((coder.decodeObject(forKey: "isPinned") as? NSNumber)? - .boolValue) + self.isPinned = (coder.decodeObject(forKey: "isPinned") as? Bool) .defaulting(to: false) self.mutedUntilDate = coder.decodeObject(forKey: "mutedUntilDate") as? Date - self.messageDraft = coder.decodeObject(forKey: "messageDraft") as? String // TODO: Test this + self.messageDraft = coder.decodeObject(forKey: "messageDraft") as? String } public func encode(with coder: NSCoder) { @@ -874,15 +891,17 @@ public enum SMKLegacy { } } + // MARK: - Contact Thread + @objc(TSContactThread) public class _ContactThread: _Thread { - // MARK: - NSCoder + // MARK: NSCoder public required init(coder: NSCoder) { super.init(coder: coder) } - // MARK: - Functions + // MARK: Functions internal static func threadId(from sessionId: String) -> String { return "\(SMKLegacy.contactThreadPrefix)\(sessionId)" @@ -893,28 +912,31 @@ public enum SMKLegacy { } } + // MARK: - Group Thread + @objc(TSGroupThread) public class _GroupThread: _Thread { public var groupModel: _GroupModel public var isOnlyNotifyingForMentions: Bool - // MARK: - Convenience + // MARK: Convenience public override var isClosedGroup: Bool { (groupModel.groupType == .closedGroup) } public override var isOpenGroup: Bool { (groupModel.groupType == .openGroup) } - // MARK: - NSCoder + // MARK: NSCoder public required init(coder: NSCoder) { self.groupModel = coder.decodeObject(forKey: "groupModel") as! _GroupModel - self.isOnlyNotifyingForMentions = ((coder.decodeObject(forKey: "isOnlyNotifyingForMentions") as? NSNumber)? - .boolValue) + self.isOnlyNotifyingForMentions = (coder.decodeObject(forKey: "isOnlyNotifyingForMentions") as? Bool) .defaulting(to: false) super.init(coder: coder) } } + // MARK: - Group Model + @objc(TSGroupModel) public class _GroupModel: NSObject, NSCoding { public enum _GroupType: Int { @@ -928,7 +950,7 @@ public enum SMKLegacy { public var groupMemberIds: [String] public var groupAdminIds: [String] - // MARK: - NSCoder + // MARK: NSCoder public required init(coder: NSCoder) { self.groupId = coder.decodeObject(forKey: "groupId") as! Data @@ -942,6 +964,298 @@ public enum SMKLegacy { fatalError("encode(with:) should never be called for legacy types") } } + + // MARK: - Disappearing Messages Config + + @objc(OWSDisappearingMessagesConfiguration) + internal class _DisappearingMessagesConfiguration: NSObject, NSCoding { + public let uniqueId: String + public var isEnabled: Bool + public var durationSeconds: UInt32 + + // MARK: NSCoder + + required init(coder: NSCoder) { + self.uniqueId = coder.decodeObject(forKey: "uniqueId") as! String + self.isEnabled = coder.decodeObject(forKey: "enabled") as! Bool + self.durationSeconds = coder.decodeObject(forKey: "durationSeconds") as! UInt32 + } + + public func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") + } + } + + // MARK: - Interaction + + @objc(TSInteraction) + public class _DBInteraction: NSObject, NSCoding { + public var uniqueId: String + public var uniqueThreadId: String + public var sortId: UInt64 + public var timestamp: UInt64 + public var receivedAtTimestamp: UInt64 + + // MARK: NSCoder + + public required init(coder: NSCoder) { + self.uniqueId = coder.decodeObject(forKey: "uniqueId") as! String + self.uniqueThreadId = coder.decodeObject(forKey: "uniqueThreadId") as! String + self.sortId = coder.decodeObject(forKey: "sortId") as! UInt64 + self.timestamp = coder.decodeObject(forKey: "timestamp") as! UInt64 + self.receivedAtTimestamp = coder.decodeObject(forKey: "receivedAtTimestamp") as! UInt64 + } + + public func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") + } + } + + // MARK: - Message + + @objc(TSMessage) + public class _DBMessage: _DBInteraction { + public var body: String? + public var attachmentIds: [String] + public var expiresInSeconds: UInt32 + public var expireStartedAt: UInt64 + public var expiresAt: UInt64 + public var quotedMessage: _DBQuotedMessage? + public var linkPreview: _DBLinkPreview? + public var openGroupServerMessageID: UInt64 + public var openGroupInvitationName: String? + public var openGroupInvitationURL: String? + public var serverHash: String? + public var isDeleted: Bool + + // MARK: NSCoder + + public required init(coder: NSCoder) { + self.body = coder.decodeObject(forKey: "body") as? String + // Note: 'attachments' was a legacy name for this key (schema version 2) + self.attachmentIds = (coder.decodeObject(forKey: "attachments") as? [String]) + .defaulting(to: coder.decodeObject(forKey: "attachmentIds") as! [String]) + self.expiresInSeconds = coder.decodeObject(forKey: "expiresInSeconds") as! UInt32 + self.expireStartedAt = coder.decodeObject(forKey: "expireStartedAt") as! UInt64 + self.expiresAt = coder.decodeObject(forKey: "expiresAt") as! UInt64 + self.quotedMessage = coder.decodeObject(forKey: "quotedMessage") as? _DBQuotedMessage + self.linkPreview = coder.decodeObject(forKey: "linkPreview") as? _DBLinkPreview + self.openGroupServerMessageID = coder.decodeObject(forKey: "openGroupServerMessageID") as! UInt64 + self.openGroupInvitationName = coder.decodeObject(forKey: "openGroupInvitationName") as? String + self.openGroupInvitationURL = coder.decodeObject(forKey: "openGroupInvitationURL") as? String + self.serverHash = coder.decodeObject(forKey: "serverHash") as? String + self.isDeleted = (coder.decodeObject(forKey: "isDeleted") as? Bool) + .defaulting(to: false) + + super.init(coder: coder) + } + } + + // MARK: - Quoted Message + + @objc(TSQuotedMessage) + public class _DBQuotedMessage: NSObject, NSCoding { + @objc(OWSAttachmentInfo) + public class _DBAttachmentInfo: NSObject, NSCoding { + public var contentType: String? + public var sourceFilename: String? + public var attachmentId: String? + public var thumbnailAttachmentStreamId: String? + public var thumbnailAttachmentPointerId: String? + + // MARK: NSCoder + + public required init(coder: NSCoder) { + self.contentType = coder.decodeObject(forKey: "contentType") as? String + self.sourceFilename = coder.decodeObject(forKey: "sourceFilename") as? String + self.attachmentId = coder.decodeObject(forKey: "attachmentId") as? String + self.thumbnailAttachmentStreamId = coder.decodeObject(forKey: "thumbnailAttachmentStreamId") as? String + self.thumbnailAttachmentPointerId = coder.decodeObject(forKey: "thumbnailAttachmentPointerId") as? String + } + + public func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") + } + } + + public var timestamp: UInt64 + public var authorId: String + public var body: String? + public var quotedAttachments: [_DBAttachmentInfo] + + // MARK: NSCoder + + public required init(coder: NSCoder) { + self.timestamp = coder.decodeObject(forKey: "timestamp") as! UInt64 + self.authorId = coder.decodeObject(forKey: "authorId") as! String + self.body = coder.decodeObject(forKey: "body") as? String + self.quotedAttachments = coder.decodeObject(forKey: "quotedAttachments") as! [_DBAttachmentInfo] + } + + public func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") + } + } + + // MARK: - Link Preview + + @objc(OWSLinkPreview) + public class _DBLinkPreview: NSObject, NSCoding { + public var urlString: String? + public var title: String? + public var imageAttachmentId: String? + + internal init( + urlString: String?, + title: String?, + imageAttachmentId: String? + ) { + self.urlString = urlString + self.title = title + self.imageAttachmentId = imageAttachmentId + } + + // MARK: NSCoder + + public required init(coder: NSCoder) { + self.urlString = coder.decodeObject(forKey: "urlString") as? String + self.title = coder.decodeObject(forKey: "title") as? String + self.imageAttachmentId = coder.decodeObject(forKey: "imageAttachmentId") as? String + } + + public func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") + } + } + + // MARK: - Incoming Message + + @objc(TSIncomingMessage) + public class _DBIncomingMessage: _DBMessage { + public var authorId: String + public var sourceDeviceId: UInt32 + public var wasRead: Bool + public var wasReceivedByUD: Bool + public var notificationIdentifier: String? + + // MARK: NSCoder + + public required init(coder: NSCoder) { + self.authorId = coder.decodeObject(forKey: "authorId") as! String + self.sourceDeviceId = coder.decodeObject(forKey: "sourceDeviceId") as! UInt32 + self.wasRead = (coder.decodeObject(forKey: "read") as? Bool) // Note: 'read' is the correct key + .defaulting(to: false) + self.wasReceivedByUD = (coder.decodeObject(forKey: "wasReceivedByUD") as? Bool) + .defaulting(to: false) + self.notificationIdentifier = coder.decodeObject(forKey: "notificationIdentifier") as? String + + super.init(coder: coder) + } + } + + // MARK: - Outgoing Message + + @objc(TSOutgoingMessage) + public class _DBOutgoingMessage: _DBMessage { + public var recipientStateMap: [String: _DBOutgoingMessageRecipientState]? + public var hasSyncedTranscript: Bool + public var customMessage: String? + public var mostRecentFailureText: String? + public var isVoiceMessage: Bool + public var attachmentFilenameMap: [String: String] + + // MARK: NSCoder + + public required init(coder: NSCoder) { + self.recipientStateMap = coder.decodeObject(forKey: "recipientStateMap") as? [String: _DBOutgoingMessageRecipientState] + self.hasSyncedTranscript = (coder.decodeObject(forKey: "hasSyncedTranscript") as? Bool) + .defaulting(to: false) + self.customMessage = coder.decodeObject(forKey: "customMessage") as? String + self.mostRecentFailureText = coder.decodeObject(forKey: "mostRecentFailureText") as? String + self.isVoiceMessage = (coder.decodeObject(forKey: "isVoiceMessage") as? Bool) + .defaulting(to: false) + self.attachmentFilenameMap = coder.decodeObject(forKey: "attachmentFilenameMap") as! [String: String] + + super.init(coder: coder) + } + } + + // MARK: - Outgoing Message Recipient State + + @objc(TSOutgoingMessageRecipientState) + public class _DBOutgoingMessageRecipientState: NSObject, NSCoding { + public enum _RecipientState: Int { + case failed = 0 + case sending + case skipped + case sent + } + + public var state: _RecipientState + public var readTimestamp: Int64? + + // MARK: NSCoder + + public required init(coder: NSCoder) { + self.state = _RecipientState(rawValue: (coder.decodeObject(forKey: "state") as! NSNumber).intValue)! + self.readTimestamp = coder.decodeObject(forKey: "readTimestamp") as? Int64 + } + + public func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") + } + } + + // MARK: - Info Message + + @objc(TSInfoMessage) + public class _DBInfoMessage: _DBMessage { + public enum _InfoMessageType: Int { + case groupCreated + case groupUpdated + case groupCurrentUserLeft + case disappearingMessagesUpdate + case screenshotNotification + case mediaSavedNotification + case messageRequestAccepted = 99 + } + + public var wasRead: Bool + public var messageType: _InfoMessageType + public var customMessage: String? + + // MARK: NSCoder + + public required init(coder: NSCoder) { + self.wasRead = (coder.decodeObject(forKey: "read") as? Bool) // Note: 'read' is the correct key + .defaulting(to: false) + self.messageType = _InfoMessageType(rawValue: (coder.decodeObject(forKey: "messageType") as! NSNumber).intValue)! + self.customMessage = coder.decodeObject(forKey: "customMessage") as? String + + super.init(coder: coder) + } + } + + // MARK: - Disappearing Config Update Info Message + + public final class _DisappearingConfigurationUpdateInfoMessage: _DBInfoMessage { + // Note: Due to how Mantle works we need to set default values for these as the 'init(dictionary:)' + // method doesn't actually get values for them but the must be set before calling a super.init method + // so this allows us to work around the behaviour until 'init(coder:)' method completes it's super call + var createdByRemoteName: String? + var configurationDurationSeconds: UInt32 = 0 + var configurationIsEnabled: Bool = false + + // MARK: Coding + + public required init(coder: NSCoder) { + self.createdByRemoteName = coder.decodeObject(forKey: "createdByRemoteName") as? String + self.configurationDurationSeconds = ((coder.decodeObject(forKey: "configurationDurationSeconds") as? UInt32) ?? 0) + self.configurationIsEnabled = ((coder.decodeObject(forKey: "configurationIsEnabled") as? Bool) ?? false) + + super.init(coder: coder) + } + } // MARK: - Attachments @@ -970,7 +1284,7 @@ public enum SMKLegacy { public var isVisualMedia: Bool { isImage || isVideo || isAnimated } - // MARK: - NSCoder + // MARK: NSCoder public required init(coder: NSCoder) { self.serverId = coder.decodeObject(forKey: "serverId") as! UInt64 @@ -982,6 +1296,9 @@ public enum SMKLegacy { ).defaulting(to: .default) self.downloadURL = (coder.decodeObject(forKey: "downloadURL") as? String ?? "") self.byteCount = coder.decodeObject(forKey: "byteCount") as! UInt32 + self.sourceFilename = coder.decodeObject(forKey: "sourceFilename") as? String + self.caption = coder.decodeObject(forKey: "caption") as? String + self.albumMessageId = coder.decodeObject(forKey: "albumMessageId") as? String } public func encode(with coder: NSCoder) { @@ -1003,7 +1320,7 @@ public enum SMKLegacy { public var mediaSize: CGSize public var lazyRestoreFragmentId: String? - // MARK: - NSCoder + // MARK: NSCoder public required init(coder: NSCoder) { self.state = _State( @@ -1045,7 +1362,7 @@ public enum SMKLegacy { return false } - // MARK: - NSCoder + // MARK: NSCoder public required init(coder: NSCoder) { self.digest = coder.decodeObject(forKey: "digest") as? Data @@ -1065,6 +1382,8 @@ public enum SMKLegacy { fatalError("encode(with:) should never be called for legacy types") } } + + // MARK: - Notify Push Server Job @objc(NotifyPNServerJob) internal final class _NotifyPNServerJob: NSObject, NSCoding { @@ -1075,7 +1394,7 @@ public enum SMKLegacy { public let ttl: UInt64 public let timestamp: UInt64 // Milliseconds - // MARK: - Coding + // MARK: Coding public init?(coder: NSCoder) { guard @@ -1102,7 +1421,7 @@ public enum SMKLegacy { public var id: String? public var failureCount: UInt = 0 - // MARK: - Coding + // MARK: Coding public init?(coder: NSCoder) { guard @@ -1119,6 +1438,8 @@ public enum SMKLegacy { fatalError("encode(with:) should never be called for legacy types") } } + + // MARK: - Message Receive Job @objc(MessageReceiveJob) public final class _MessageReceiveJob: NSObject, NSCoding { @@ -1130,7 +1451,7 @@ public enum SMKLegacy { public var id: String? public var failureCount: UInt = 0 - // MARK: - Coding + // MARK: Coding public init?(coder: NSCoder) { guard @@ -1155,6 +1476,8 @@ public enum SMKLegacy { fatalError("encode(with:) should never be called for legacy types") } } + + // MARK: - Message Send Job @objc(SNMessageSendJob) internal final class _MessageSendJob: NSObject, NSCoding { @@ -1163,7 +1486,7 @@ public enum SMKLegacy { internal var id: String? internal var failureCount: UInt = 0 - // MARK: - Coding + // MARK: Coding public init?(coder: NSCoder) { guard let message = coder.decodeObject(forKey: "message") as! _Message?, @@ -1212,7 +1535,7 @@ public enum SMKLegacy { fatalError("encode(with:) should never be called for legacy types") } - // MARK: - Convenience + // MARK: Convenience private static func process(_ value: String, type: String) -> String? { guard value.hasPrefix("\(type)(") else { return nil } @@ -1226,6 +1549,8 @@ public enum SMKLegacy { } } + // MARK: - Attachment Upload Job + @objc(AttachmentUploadJob) internal final class _AttachmentUploadJob: NSObject, NSCoding { internal let attachmentID: String @@ -1235,7 +1560,7 @@ public enum SMKLegacy { internal var id: String? internal var failureCount: UInt = 0 - // MARK: - Coding + // MARK: Coding public init?(coder: NSCoder) { guard @@ -1259,6 +1584,8 @@ public enum SMKLegacy { } } + // MARK: - Attachment Download Job + @objc(AttachmentDownloadJob) public final class _AttachmentDownloadJob: NSObject, NSCoding { public let attachmentID: String @@ -1268,7 +1595,7 @@ public enum SMKLegacy { public var failureCount: UInt = 0 public var isDeferred = false - // MARK: - Coding + // MARK: Coding public init?(coder: NSCoder) { guard @@ -1290,31 +1617,4 @@ public enum SMKLegacy { fatalError("encode(with:) should never be called for legacy types") } } - - public final class _DisappearingConfigurationUpdateInfoMessage: TSInfoMessage { - // Note: Due to how Mantle works we need to set default values for these as the 'init(dictionary:)' - // method doesn't actually get values for them but the must be set before calling a super.init method - // so this allows us to work around the behaviour until 'init(coder:)' method completes it's super call - var createdByRemoteName: String? - var configurationDurationSeconds: UInt32 = 0 - var configurationIsEnabled: Bool = false - - // MARK: - Coding - - public required init(coder: NSCoder) { - super.init(coder: coder) - - self.createdByRemoteName = coder.decodeObject(forKey: "createdByRemoteName") as? String - self.configurationDurationSeconds = ((coder.decodeObject(forKey: "configurationDurationSeconds") as? UInt32) ?? 0) - self.configurationIsEnabled = ((coder.decodeObject(forKey: "configurationIsEnabled") as? Bool) ?? false) - } - - required init(dictionary dictionaryValue: [String : Any]!) throws { - try super.init(dictionary: dictionaryValue) - } - - public override func encode(with coder: NSCoder) { - fatalError("encode(with:) should never be called for legacy types") - } - } } diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index b8268d45d..6a9edb48a 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -16,7 +16,9 @@ enum _003_YDBToGRDBMigration: Migration { // MARK: - Process Contacts, Threads & Interactions print("RAWR [\(Date().timeIntervalSince1970)] - SessionMessagingKit migration - Start") var shouldFailMigration: Bool = false + var legacyMigrations: Set = [] var contacts: Set = [] + var legacyBlockedSessionIds: Set = [] var validProfileIds: Set = [] var contactThreadIds: Set = [] @@ -35,9 +37,8 @@ enum _003_YDBToGRDBMigration: Migration { var openGroupImage: [String: Data] = [:] var openGroupLastMessageServerId: [String: Int64] = [:] // Optional var openGroupLastDeletionServerId: [String: Int64] = [:] // Optional -// var openGroupServerToUniqueIdLookup: [String: [String]] = [:] // TODO: Not needed???? - var interactions: [String: [TSInteraction]] = [:] + var interactions: [String: [SMKLegacy._DBInteraction]] = [:] var attachments: [String: SMKLegacy._Attachment] = [:] var processedAttachmentIds: Set = [] var outgoingReadReceiptsTimestampsMs: [String: Set] = [:] @@ -64,6 +65,50 @@ enum _003_YDBToGRDBMigration: Migration { SMKLegacy._Contact.self, forClassName: "SNContact" ) + NSKeyedUnarchiver.setClass( + SMKLegacy._DBInteraction.self, + forClassName: "TSInteraction" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._DBMessage.self, + forClassName: "TSMessage" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._DBQuotedMessage.self, + forClassName: "TSQuotedMessage" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._DBQuotedMessage._DBAttachmentInfo.self, + forClassName: "OWSAttachmentInfo" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._DBLinkPreview.self, + forClassName: "SessionServiceKit.OWSLinkPreview" // Very old legacy name + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._DBLinkPreview.self, + forClassName: "SessionMessagingKit.OWSLinkPreview" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._DBIncomingMessage.self, + forClassName: "TSIncomingMessage" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._DBOutgoingMessage.self, + forClassName: "TSOutgoingMessage" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._DBOutgoingMessageRecipientState.self, + forClassName: "TSOutgoingMessageRecipientState" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._DBInfoMessage.self, + forClassName: "TSInfoMessage" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._DisappearingConfigurationUpdateInfoMessage.self, + forClassName: "OWSDisappearingConfigurationUpdateInfoMessage" + ) NSKeyedUnarchiver.setClass( SMKLegacy._Attachment.self, forClassName: "TSAttachment" @@ -76,18 +121,33 @@ enum _003_YDBToGRDBMigration: Migration { SMKLegacy._AttachmentPointer.self, forClassName: "TSAttachmentPointer" ) - NSKeyedUnarchiver.setClass( - SMKLegacy._DisappearingConfigurationUpdateInfoMessage.self, - forClassName: "OWSDisappearingConfigurationUpdateInfoMessage" - ) Storage.read { transaction in + // Process the migrations (we don't want to bother running the old migrations as it would be + // a waste of time, rather we include the logic from the old migrations in here and make the + // same changes if the migration hasn't already run) + transaction.enumerateRows(inCollection: SMKLegacy.databaseMigrationCollection) { key, _, _, _ in + guard let legacyMigration: SMKLegacy._DBMigration = SMKLegacy._DBMigration(rawValue: key) else { + SNLog("[Migration Error] Found unknown migration") + shouldFailMigration = true + return + } + + legacyMigrations.insert(legacyMigration) + } + // Process the Contacts transaction.enumerateRows(inCollection: SMKLegacy.contactCollection) { _, object, _, _ in guard let contact = object as? SMKLegacy._Contact else { return } contacts.insert(contact) validProfileIds.insert(contact.sessionID) } + + // Process legacy blocked contacts + legacyBlockedSessionIds = Set(transaction.object( + forKey: SMKLegacy.blockedPhoneNumbersKey, + inCollection: SMKLegacy.blockListCollection + ) as? [String] ?? []) print("RAWR [\(Date().timeIntervalSince1970)] - Process threads - Start") @@ -178,7 +238,7 @@ enum _003_YDBToGRDBMigration: Migration { // Process interactions print("RAWR [\(Date().timeIntervalSince1970)] - Process interactions - Start") transaction.enumerateKeysAndObjects(inCollection: SMKLegacy.interactionCollection) { _, object, _ in - guard let interaction: TSInteraction = object as? TSInteraction else { + guard let interaction: SMKLegacy._DBInteraction = object as? SMKLegacy._DBInteraction else { SNLog("[Migration Error] Unable to process interaction") shouldFailMigration = true return @@ -248,6 +308,21 @@ enum _003_YDBToGRDBMigration: Migration { profileEncryptionKey: legacyContact.profileEncryptionKey ).insert(db) + /// **Note:** The blow "shouldForce" flags are here to allow us to avoid having to run legacy migrations they + /// replicate the behaviour of a number of the migrations and perform the changes if the migrations had never run + + /// `ContactsMigration` - Marked all existing contacts as trusted + let shouldForceTrustContact: Bool = (!legacyMigrations.contains(.contactsMigration)) + + /// `MessageRequestsMigration` - Marked all existing contacts as isApproved and didApproveMe + let shouldForceApproveContact: Bool = (!legacyMigrations.contains(.messageRequestsMigration)) + + /// `BlockingManagerRemovalMigration` - Removed the old blocking manager and updated contacts isBlocked flag accordingly + let shouldForceBlockContact: Bool = ( + !legacyMigrations.contains(.messageRequestsMigration) && + legacyBlockedSessionIds.contains(legacyContact.sessionID) + ) + // Determine if this contact is a "real" contact (don't want to create contacts for // every user in the new structure but still want profiles for every user) if @@ -256,15 +331,36 @@ enum _003_YDBToGRDBMigration: Migration { legacyContact.isApproved || legacyContact.didApproveMe || legacyContact.isBlocked || - legacyContact.hasBeenBlocked { + legacyContact.hasBeenBlocked || + shouldForceTrustContact || + shouldForceApproveContact || + shouldForceBlockContact + { // Create the contact // TODO: Closed group admins??? try Contact( id: legacyContact.sessionID, - isTrusted: (isCurrentUser || legacyContact.isTrusted), - isApproved: (isCurrentUser || legacyContact.isApproved), - isBlocked: (!isCurrentUser && legacyContact.isBlocked), - didApproveMe: (isCurrentUser || legacyContact.didApproveMe), + isTrusted: ( + isCurrentUser || + legacyContact.isTrusted || + shouldForceTrustContact + ), + isApproved: ( + isCurrentUser || + legacyContact.isApproved || + shouldForceApproveContact + ), + isBlocked: ( + !isCurrentUser && ( + legacyContact.isBlocked || + shouldForceBlockContact + ) + ), + didApproveMe: ( + isCurrentUser || + legacyContact.didApproveMe || + shouldForceApproveContact + ), hasBeenBlocked: (!isCurrentUser && (legacyContact.hasBeenBlocked || legacyContact.isBlocked)) ).insert(db) } @@ -452,15 +548,15 @@ enum _003_YDBToGRDBMigration: Migration { let expiresInSeconds: UInt32? let expiresStartedAtMs: UInt64? let openGroupServerMessageId: UInt64? - let recipientStateMap: [String: TSOutgoingMessageRecipientState]? + let recipientStateMap: [String: SMKLegacy._DBOutgoingMessageRecipientState]? let mostRecentFailureText: String? - let quotedMessage: TSQuotedMessage? - let linkPreview: OWSLinkPreview? + let quotedMessage: SMKLegacy._DBQuotedMessage? + let linkPreview: SMKLegacy._DBLinkPreview? let linkPreviewVariant: LinkPreview.Variant var attachmentIds: [String] - // Handle the common 'TSMessage' values first - if let legacyMessage: TSMessage = legacyInteraction as? TSMessage { + // Handle the common 'SMKLegacy._DBMessage' values first + if let legacyMessage: SMKLegacy._DBMessage = legacyInteraction as? SMKLegacy._DBMessage { serverHash = legacyMessage.serverHash // The legacy code only considered '!= 0' ids as valid so set those @@ -475,7 +571,7 @@ enum _003_YDBToGRDBMigration: Migration { // Convert the 'OpenGroupInvitation' into a LinkPreview if let openGroupInvitationName: String = legacyMessage.openGroupInvitationName, let openGroupInvitationUrl: String = legacyMessage.openGroupInvitationURL { linkPreviewVariant = .openGroupInvitation - linkPreview = OWSLinkPreview( + linkPreview = SMKLegacy._DBLinkPreview( urlString: openGroupInvitationUrl, title: openGroupInvitationName, imageAttachmentId: nil @@ -489,14 +585,7 @@ enum _003_YDBToGRDBMigration: Migration { // Attachments for deleted messages won't exist attachmentIds = (legacyMessage.isDeleted ? [] : - try legacyMessage.attachmentIds.map { legacyId in - guard let attachmentId: String = legacyId as? String else { - SNLog("[Migration Error] Unable to process attachment id") - throw GRDBStorageError.migrationFailed - } - - return attachmentId - } + legacyMessage.attachmentIds ) } else { @@ -510,7 +599,7 @@ enum _003_YDBToGRDBMigration: Migration { // Then handle the behaviours for each message type switch legacyInteraction { - case let incomingMessage as TSIncomingMessage: + case let incomingMessage as SMKLegacy._DBIncomingMessage: // Note: We want to distinguish deleted messages from normal ones variant = (incomingMessage.isDeleted ? .standardIncomingDeleted : @@ -524,7 +613,7 @@ enum _003_YDBToGRDBMigration: Migration { recipientStateMap = [:] mostRecentFailureText = nil - case let outgoingMessage as TSOutgoingMessage: + case let outgoingMessage as SMKLegacy._DBOutgoingMessage: variant = .standardOutgoing authorId = currentUserPublicKey body = outgoingMessage.body @@ -534,7 +623,7 @@ enum _003_YDBToGRDBMigration: Migration { recipientStateMap = outgoingMessage.recipientStateMap mostRecentFailureText = outgoingMessage.mostRecentFailureText - case let infoMessage as TSInfoMessage: + case let infoMessage as SMKLegacy._DBInfoMessage: // Note: The legacy 'TSInfoMessage' didn't store the author id so there is no // way to determine who actually triggered the info message authorId = currentUserPublicKey @@ -645,7 +734,11 @@ enum _003_YDBToGRDBMigration: Migration { let legacyIdentifier: String = identifier( for: threadId, sentTimestamp: legacyInteraction.timestamp, - recipients: ((legacyInteraction as? TSOutgoingMessage)?.recipientIds() ?? []), + recipients: ((legacyInteraction as? SMKLegacy._DBOutgoingMessage)? + .recipientStateMap? + .keys + .map { $0 }) + .defaulting(to: []), destination: (threadVariant == .contact ? .contact(publicKey: threadId) : nil), variant: variant, useFallback: false @@ -653,13 +746,17 @@ enum _003_YDBToGRDBMigration: Migration { let legacyIdentifierFallback: String = identifier( for: threadId, sentTimestamp: legacyInteraction.timestamp, - recipients: ((legacyInteraction as? TSOutgoingMessage)?.recipientIds() ?? []), + recipients: ((legacyInteraction as? SMKLegacy._DBOutgoingMessage)? + .recipientStateMap? + .keys + .map { $0 }) + .defaulting(to: []), destination: (threadVariant == .contact ? .contact(publicKey: threadId) : nil), variant: variant, useFallback: true ) - legacyInteractionToIdMap[legacyInteraction.uniqueId ?? ""] = interactionId + legacyInteractionToIdMap[legacyInteraction.uniqueId] = interactionId legacyInteractionIdentifierToIdMap[legacyIdentifier] = interactionId legacyInteractionIdentifierToIdFallbackMap[legacyIdentifierFallback] = interactionId @@ -680,7 +777,7 @@ enum _003_YDBToGRDBMigration: Migration { @unknown default: throw GRDBStorageError.migrationFailed } }(), - readTimestampMs: legacyState.readTimestamp?.int64Value, + readTimestampMs: legacyState.readTimestamp, mostRecentFailureText: (legacyState.state == .failed ? mostRecentFailureText : nil @@ -690,7 +787,7 @@ enum _003_YDBToGRDBMigration: Migration { // Handle any quote - if let quotedMessage: TSQuotedMessage = quotedMessage { + if let quotedMessage: SMKLegacy._DBQuotedMessage = quotedMessage { var quoteAttachmentId: String? = quotedMessage.quotedAttachments .flatMap { attachmentInfo in return [ @@ -734,13 +831,12 @@ enum _003_YDBToGRDBMigration: Migration { // need to compare against the 'currentUserPublicKey' // for those or cast to a TSIncomingMessage otherwise quotedMessage.authorId == currentUserPublicKey || - quotedMessage.authorId == ($0 as? TSIncomingMessage)?.authorId + quotedMessage.authorId == ($0 as? SMKLegacy._DBIncomingMessage)?.authorId ) }) - .asType(TSMessage.self)? + .asType(SMKLegacy._DBMessage.self)? .attachmentIds - .firstObject - .asType(String.self) + .first SNLog([ "[Migration Warning] Quote with invalid attachmentId found", @@ -772,7 +868,7 @@ enum _003_YDBToGRDBMigration: Migration { // Handle any LinkPreview - if let linkPreview: OWSLinkPreview = linkPreview, let urlString: String = linkPreview.urlString { + if let linkPreview: SMKLegacy._DBLinkPreview = linkPreview, let urlString: String = linkPreview.urlString { // Note: The `legacyInteraction.timestamp` value is in milliseconds let timestamp: TimeInterval = LinkPreview.timestampFor(sentTimestampMs: Double(legacyInteraction.timestamp)) @@ -838,6 +934,7 @@ enum _003_YDBToGRDBMigration: Migration { // Clear out processed data (give the memory a change to be freed) contacts = [] + legacyBlockedSessionIds = [] contactThreadIds = [] legacyThreads = [] @@ -906,10 +1003,6 @@ enum _003_YDBToGRDBMigration: Migration { SMKLegacy._Quote.self, forClassName: "SNQuote" ) - NSKeyedUnarchiver.setClass( - SMKLegacy._LinkPreview.self, - forClassName: "SessionServiceKit.OWSLinkPreview" // Very old legacy name - ) NSKeyedUnarchiver.setClass( SMKLegacy._LinkPreview.self, forClassName: "SNLinkPreview" diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index 7a482e9f8..c62341fd7 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -592,7 +592,9 @@ extension Attachment { }() private static var sharedDataAttachmentsDirPath: String = { - OWSFileSystem.appSharedDataDirectoryPath().appending("/Attachments") + URL(fileURLWithPath: OWSFileSystem.appSharedDataDirectoryPath()) + .appendingPathComponent("Attachments") + .path }() internal static var attachmentsFolder: String = { diff --git a/SessionMessagingKit/Database/Models/LinkPreview.swift b/SessionMessagingKit/Database/Models/LinkPreview.swift index 76782c689..ca6a2d439 100644 --- a/SessionMessagingKit/Database/Models/LinkPreview.swift +++ b/SessionMessagingKit/Database/Models/LinkPreview.swift @@ -2,6 +2,9 @@ import Foundation import GRDB +import PromiseKit +import AFNetworking +import SignalCoreKit import SessionUtilitiesKit public struct LinkPreview: Codable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { @@ -96,7 +99,6 @@ public struct LinkPreview: Codable, Equatable, FetchableRecord, PersistableRecor public extension LinkPreview { init?(_ db: Database, proto: SNProtoDataMessage, body: String?, sentTimestampMs: TimeInterval) throws { - guard OWSLinkPreview.featureEnabled else { throw LinkPreviewError.noPreview } guard let previewProto = proto.preview.first else { throw LinkPreviewError.noPreview } guard proto.attachments.count < 1 else { throw LinkPreviewError.invalidInput } guard URL(string: previewProto.url) != nil else { throw LinkPreviewError.invalidInput } @@ -232,4 +234,367 @@ public extension LinkPreview { return result.filterStringForDisplay() } + + // MARK: - Text Parsing + + private static var previewUrlCache: Atomic> = Atomic(NSCache()) + + static func previewUrl(for body: String?, selectedRange: NSRange? = nil) -> String? { + guard GRDBStorage.shared[.areLinkPreviewsEnabled] else { return nil } + guard let body: String = body else { return nil } + + if let cachedUrl = previewUrlCache.wrappedValue.object(forKey: body as NSString) as String? { + guard cachedUrl.count > 0 else { + return nil + } + + return cachedUrl + } + + let previewUrlMatches: [URLMatchResult] = allPreviewUrlMatches(forMessageBodyText: body) + + guard let urlMatch: URLMatchResult = previewUrlMatches.first else { + // Use empty string to indicate "no preview URL" in the cache. + previewUrlCache.mutate { $0.setObject("", forKey: body as NSString) } + return nil + } + + if let selectedRange: NSRange = selectedRange { + let cursorAtEndOfMatch: Bool = ( + (urlMatch.matchRange.location + urlMatch.matchRange.length) == selectedRange.location + ) + + if selectedRange.location != body.count, (urlMatch.matchRange.intersection(selectedRange) != nil || cursorAtEndOfMatch) { + // we don't want to cache the result here, as we want to fetch the link preview + // if the user moves the cursor. + return nil + } + } + + previewUrlCache.mutate { $0.setObject(urlMatch.urlString as NSString, forKey: body as NSString) } + + return urlMatch.urlString + } +} + +// MARK: - Drafts + +public extension LinkPreview { + private struct Contents { + public var title: String? + public var imageUrl: String? + + public init(title: String?, imageUrl: String? = nil) { + self.title = title + self.imageUrl = imageUrl + } + } + + private static let serialQueue = DispatchQueue(label: "org.signal.linkPreview") + + // This cache should only be accessed on serialQueue. + // + // We should only maintain a "cache" of the last known draft. + private static var linkPreviewDraftCache: LinkPreviewDraft? + + // Twitter doesn't return OpenGraph tags to Signal + // `curl -A Signal "https://twitter.com/signalapp/status/1280166087577997312?s=20"` + // If this ever changes, we can switch back to our default User-Agent + private static let userAgentString = "WhatsApp" + + private static func cachedLinkPreview(forPreviewUrl previewUrl: String) -> LinkPreviewDraft? { + return serialQueue.sync { + guard let linkPreviewDraft = linkPreviewDraftCache, + linkPreviewDraft.urlString == previewUrl else { + return nil + } + return linkPreviewDraft + } + } + + private static func setCachedLinkPreview(_ linkPreviewDraft: LinkPreviewDraft, forPreviewUrl previewUrl: String) { + assert(previewUrl == linkPreviewDraft.urlString) + + // Exit early if link previews are not enabled in order to avoid + // tainting the cache. + guard GRDBStorage.shared[.areLinkPreviewsEnabled] else { return } + + serialQueue.sync { + linkPreviewDraftCache = linkPreviewDraft + } + } + + static func tryToBuildPreviewInfo(previewUrl: String?) -> Promise { + guard GRDBStorage.shared[.areLinkPreviewsEnabled] else { + return Promise(error: LinkPreviewError.featureDisabled) + } + guard let previewUrl: String = previewUrl else { + return Promise(error: LinkPreviewError.invalidInput) + } + + if let cachedInfo = cachedLinkPreview(forPreviewUrl: previewUrl) { + return Promise.value(cachedInfo) + } + + return downloadLink(url: previewUrl) + .then(on: DispatchQueue.global()) { data, response -> Promise in + return parseLinkDataAndBuildDraft(linkData: data, response: response, linkUrlString: previewUrl) + } + .then(on: DispatchQueue.global()) { linkPreviewDraft -> Promise in + guard linkPreviewDraft.isValid() else { throw LinkPreviewError.noPreview } + + setCachedLinkPreview(linkPreviewDraft, forPreviewUrl: previewUrl) + + return Promise.value(linkPreviewDraft) + } + } + + private static func downloadLink(url urlString: String, remainingRetries: UInt = 3) -> Promise<(Data, URLResponse)> { + Logger.verbose("url: \(urlString)") + + // let sessionConfiguration = ContentProxy.sessionConfiguration() // Loki: Signal's proxy appears to have been banned by YouTube + let sessionConfiguration = URLSessionConfiguration.ephemeral + + // Don't use any caching to protect privacy of these requests. + sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData + sessionConfiguration.urlCache = nil + + // FIXME: Refactor to stop using AFHTTPRequest + let sessionManager = AFHTTPSessionManager(baseURL: nil, + sessionConfiguration: sessionConfiguration) + sessionManager.requestSerializer = AFHTTPRequestSerializer() + sessionManager.responseSerializer = AFHTTPResponseSerializer() + + guard ContentProxy.configureSessionManager(sessionManager: sessionManager, forUrl: urlString) else { + return Promise(error: LinkPreviewError.assertionFailure) + } + + sessionManager.requestSerializer.setValue(self.userAgentString, forHTTPHeaderField: "User-Agent") + + let (promise, resolver) = Promise<(Data, URLResponse)>.pending() + sessionManager.get( + urlString, + parameters: [String: AnyObject](), + headers: nil, + progress: nil, + success: { task, value in + guard let response = task.response as? HTTPURLResponse else { + resolver.reject(LinkPreviewError.assertionFailure) + return + } + if let contentType = response.allHeaderFields["Content-Type"] as? String { + guard contentType.lowercased().hasPrefix("text/") else { + resolver.reject(LinkPreviewError.invalidContent) + return + } + } + guard let data = value as? Data else { + resolver.reject(LinkPreviewError.assertionFailure) + return + } + guard data.count > 0 else { + resolver.reject(LinkPreviewError.invalidContent) + return + } + + resolver.fulfill((data, response)) + }, + failure: { _, error in + guard isRetryable(error: error) else { + resolver.reject(LinkPreviewError.couldNotDownload) + return + } + + guard remainingRetries > 0 else { + resolver.reject(LinkPreviewError.couldNotDownload) + return + } + + LinkPreview.downloadLink( + url: urlString, + remainingRetries: (remainingRetries - 1) + ) + .done(on: DispatchQueue.global()) { (data, response) in + resolver.fulfill((data, response)) + } + .catch(on: DispatchQueue.global()) { (error) in + resolver.reject(error) + } + .retainUntilComplete() + } + ) + + return promise + } + + private static func parseLinkDataAndBuildDraft(linkData: Data, response: URLResponse, linkUrlString: String) -> Promise { + do { + let contents = try parse(linkData: linkData, response: response) + + let title = contents.title + guard let imageUrl = contents.imageUrl else { + return Promise.value(LinkPreviewDraft(urlString: linkUrlString, title: title)) + } + + guard URL(string: imageUrl) != nil else { + return Promise.value(LinkPreviewDraft(urlString: linkUrlString, title: title)) + } + guard let imageFileExtension = fileExtension(forImageUrl: imageUrl) else { + return Promise.value(LinkPreviewDraft(urlString: linkUrlString, title: title)) + } + guard let imageMimeType = mimetype(forImageFileExtension: imageFileExtension) else { + return Promise.value(LinkPreviewDraft(urlString: linkUrlString, title: title)) + } + + return downloadImage(url: imageUrl, imageMimeType: imageMimeType) + .map(on: DispatchQueue.global()) { (imageData: Data) -> LinkPreviewDraft in + // We always recompress images to Jpeg + LinkPreviewDraft(urlString: linkUrlString, title: title, jpegImageData: imageData) + } + .recover(on: DispatchQueue.global()) { _ -> Promise in + Promise.value(LinkPreviewDraft(urlString: linkUrlString, title: title)) + } + } catch { + return Promise(error: error) + } + } + + private static func parse(linkData: Data, response: URLResponse) throws -> Contents { + guard let linkText = String(data: linkData, urlResponse: response) else { + print("Could not parse link text.") + throw LinkPreviewError.invalidInput + } + + let content = HTMLMetadata.construct(parsing: linkText) + + var title: String? + let rawTitle = content.ogTitle ?? content.titleTag + if + let decodedTitle: String = decodeHTMLEntities(inString: rawTitle ?? ""), + let normalizedTitle: String = LinkPreview.normalizeTitle(title: decodedTitle), + normalizedTitle.count > 0 + { + title = normalizedTitle + } + + Logger.verbose("title: \(String(describing: title))") + + guard let rawImageUrlString = content.ogImageUrlString ?? content.faviconUrlString else { + return Contents(title: title) + } + guard let imageUrlString = decodeHTMLEntities(inString: rawImageUrlString)?.ows_stripped() else { + return Contents(title: title) + } + + return Contents(title: title, imageUrl: imageUrlString) + } + + private static func downloadImage(url urlString: String, imageMimeType: String) -> Promise { + guard let url = URL(string: urlString) else { return Promise(error: LinkPreviewError.invalidInput) } + guard let assetDescription = ProxiedContentAssetDescription(url: url as NSURL) else { + return Promise(error: LinkPreviewError.invalidInput) + } + + let (promise, resolver) = Promise.pending() + DispatchQueue.main.async { + _ = ProxiedContentDownloader.defaultDownloader.requestAsset( + assetDescription: assetDescription, + priority: .high, + success: { _, asset in + resolver.fulfill(asset) + }, + failure: { _ in + resolver.reject(LinkPreviewError.couldNotDownload) + }, + shouldIgnoreSignalProxy: true + ) + } + + return promise.then(on: DispatchQueue.global()) { (asset: ProxiedContentAsset) -> Promise in + do { + let imageSize = NSData.imageSize(forFilePath: asset.filePath, mimeType: imageMimeType) + + guard imageSize.width > 0, imageSize.height > 0 else { + return Promise(error: LinkPreviewError.invalidContent) + } + + let data = try Data(contentsOf: URL(fileURLWithPath: asset.filePath)) + + guard let srcImage = UIImage(data: data) else { + return Promise(error: LinkPreviewError.invalidContent) + } + + // Loki: If it's a GIF then ensure its validity and don't download it as a JPG + if (imageMimeType == OWSMimeTypeImageGif && NSData(data: data).ows_isValidImage(withMimeType: OWSMimeTypeImageGif)) { return Promise.value(data) } + + let maxImageSize: CGFloat = 1024 + let shouldResize = imageSize.width > maxImageSize || imageSize.height > maxImageSize + + guard shouldResize else { + guard let dstData = srcImage.jpegData(compressionQuality: 0.8) else { + return Promise(error: LinkPreviewError.invalidContent) + } + + return Promise.value(dstData) + } + + guard let dstImage = srcImage.resized(withMaxDimensionPoints: maxImageSize) else { + return Promise(error: LinkPreviewError.invalidContent) + } + guard let dstData = dstImage.jpegData(compressionQuality: 0.8) else { + return Promise(error: LinkPreviewError.invalidContent) + } + + return Promise.value(dstData) + } + catch { + return Promise(error: LinkPreviewError.assertionFailure) + } + } + } + + private static func isRetryable(error: Error) -> Bool { + if (error as NSError).domain == kCFErrorDomainCFNetwork as String { + // Network failures are retried. + return true + } + + return false + } + + private static func fileExtension(forImageUrl urlString: String) -> String? { + guard let imageUrl = URL(string: urlString) else { return nil } + + let imageFilename = imageUrl.lastPathComponent + let imageFileExtension = (imageFilename as NSString).pathExtension.lowercased() + + guard imageFileExtension.count > 0 else { + // TODO: For those links don't have a file extension, we should figure out a way to know the image mime type + return "png" + } + + return imageFileExtension + } + + private static func mimetype(forImageFileExtension imageFileExtension: String) -> String? { + guard imageFileExtension.count > 0 else { return nil } + guard let imageMimeType = MIMETypeUtil.mimeType(forFileExtension: imageFileExtension) else { return nil } + + return imageMimeType + } + + private static func decodeHTMLEntities(inString value: String) -> String? { + guard let data = value.data(using: .utf8) else { return nil } + + let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [ + .documentType: NSAttributedString.DocumentType.html, + .characterEncoding: String.Encoding.utf8.rawValue + ] + + guard let attributedString = try? NSAttributedString(data: data, options: options, documentAttributes: nil) else { + return nil + } + + return attributedString.string + } } diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index a217ae85b..42f7ab694 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -83,7 +83,8 @@ public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableReco db.afterNextTransactionCommit { db in // Delete old profile picture if needed if let oldProfilePictureFileName: String = oldProfile?.profilePictureFileName, oldProfilePictureFileName != profilePictureFileName { - let path: String = OWSUserProfile.profileAvatarFilepath(withFilename: oldProfilePictureFileName) + let path: String = ProfileManager.profileAvatarFilepath(filename: oldProfilePictureFileName) + DispatchQueue.global(qos: .default).async { OWSFileSystem.deleteFileIfExists(path) } diff --git a/SessionMessagingKit/Database/OWSStorage.m b/SessionMessagingKit/Database/OWSStorage.m index 01d4e9924..b3f07ce8e 100644 --- a/SessionMessagingKit/Database/OWSStorage.m +++ b/SessionMessagingKit/Database/OWSStorage.m @@ -8,7 +8,6 @@ #import "OWSFileSystem.h" #import "OWSPrimaryStorage.h" #import "TSYapDatabaseObject.h" -#import "TSAttachmentStream.h" #import #import #import diff --git a/SessionMessagingKit/Database/TSDatabaseSecondaryIndexes.m b/SessionMessagingKit/Database/TSDatabaseSecondaryIndexes.m index d31c677d9..893a8e9ea 100644 --- a/SessionMessagingKit/Database/TSDatabaseSecondaryIndexes.m +++ b/SessionMessagingKit/Database/TSDatabaseSecondaryIndexes.m @@ -4,7 +4,6 @@ #import "TSDatabaseSecondaryIndexes.h" #import "OWSStorage.h" -#import "TSInteraction.h" NS_ASSUME_NONNULL_BEGIN diff --git a/SessionMessagingKit/Database/TSDatabaseView.m b/SessionMessagingKit/Database/TSDatabaseView.m index 6e733cbc9..9051d0a18 100644 --- a/SessionMessagingKit/Database/TSDatabaseView.m +++ b/SessionMessagingKit/Database/TSDatabaseView.m @@ -3,10 +3,6 @@ // #import "TSDatabaseView.h" -#import "TSAttachment.h" -#import "TSAttachmentPointer.h" -#import "TSIncomingMessage.h" -#import "TSOutgoingMessage.h" #import #import #import diff --git a/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift b/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift index 2fa13fe01..884429958 100644 --- a/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift +++ b/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift @@ -39,7 +39,7 @@ public enum UpdateProfilePictureJob: JobExecutor { profileName: profile.name, avatarImage: profilePicture, requiredSync: true, - success: { _ in success(job, false) }, + success: { _, _ in success(job, false) }, failure: { error in failure(job, error, false) } ) } diff --git a/SessionMessagingKit/Messages/Signal/TSIncomingMessage+Conversion.swift b/SessionMessagingKit/Messages/Signal/TSIncomingMessage+Conversion.swift deleted file mode 100644 index 4284b0867..000000000 --- a/SessionMessagingKit/Messages/Signal/TSIncomingMessage+Conversion.swift +++ /dev/null @@ -1,30 +0,0 @@ - -public extension TSIncomingMessage { - - static func from(_ visibleMessage: VisibleMessage, quotedMessage: TSQuotedMessage?, linkPreview: OWSLinkPreview?, associatedWith thread: TSThread) -> TSIncomingMessage { - let sender = visibleMessage.sender! - var expiration: UInt32 = 0 - Storage.read { transaction in - expiration = thread.disappearingMessagesDuration(with: transaction) - } - let openGroupServerMessageId = visibleMessage.openGroupServerMessageId ?? 0 - let isOpenGroupMessage = (openGroupServerMessageId != 0) - let result = TSIncomingMessage( - timestamp: visibleMessage.sentTimestamp!, - in: thread, - authorId: sender, - sourceDeviceId: 1, - messageBody: visibleMessage.text, - attachmentIds: visibleMessage.attachmentIds, - expiresInSeconds: !isOpenGroupMessage ? expiration : 0, // Ensure we don't ever expire open group messages - quotedMessage: quotedMessage, - linkPreview: linkPreview, - wasReceivedByUD: true, - openGroupInvitationName: visibleMessage.openGroupInvitation?.name, - openGroupInvitationURL: visibleMessage.openGroupInvitation?.url, - serverHash: visibleMessage.serverHash - ) - result.openGroupServerMessageID = openGroupServerMessageId - return result - } -} diff --git a/SessionMessagingKit/Messages/Signal/TSIncomingMessage.h b/SessionMessagingKit/Messages/Signal/TSIncomingMessage.h deleted file mode 100644 index 2c9595a39..000000000 --- a/SessionMessagingKit/Messages/Signal/TSIncomingMessage.h +++ /dev/null @@ -1,94 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@class TSContactThread; -@class TSGroupThread; - -@interface TSIncomingMessage : TSMessage - -@property (nonatomic, getter=wasRead) BOOL read; -@property (nonatomic, readonly) BOOL wasReceivedByUD; -@property (nonatomic, readonly) BOOL isUserMentioned; -@property (nonatomic, readonly, nullable) NSString *notificationIdentifier; - -- (instancetype)initMessageWithTimestamp:(uint64_t)timestamp - inThread:(nullable TSThread *)thread - messageBody:(nullable NSString *)body - attachmentIds:(NSArray *)attachmentIds - expiresInSeconds:(uint32_t)expiresInSeconds - expireStartedAt:(uint64_t)expireStartedAt - quotedMessage:(nullable TSQuotedMessage *)quotedMessage - contactShare:(nullable OWSContact *)contactShare - linkPreview:(nullable OWSLinkPreview *)linkPreview NS_UNAVAILABLE; - -/** - * Inits an incoming group message that expires. - * - * @param timestamp - * When the message was created in milliseconds since epoch - * @param thread - * Thread to which the message belongs - * @param authorId - * Signal ID (i.e. e164) of the user who sent the message - * @param sourceDeviceId - * Numeric ID of the device used to send the message. Used to detect duplicate messages. - * @param body - * Body of the message - * @param attachmentIds - * The uniqueIds for the message's attachments, possibly an empty list. - * @param expiresInSeconds - * Seconds from when the message is read until it is deleted. - * @param quotedMessage - * If this message is a quoted reply to another message, contains data about that message. - * - * @return initiated incoming group message - */ -- (instancetype)initWithTimestamp:(uint64_t)timestamp - inThread:(TSThread *)thread - authorId:(NSString *)authorId - sourceDeviceId:(uint32_t)sourceDeviceId - messageBody:(nullable NSString *)body - attachmentIds:(NSArray *)attachmentIds - expiresInSeconds:(uint32_t)expiresInSeconds - quotedMessage:(nullable TSQuotedMessage *)quotedMessage - linkPreview:(nullable OWSLinkPreview *)linkPreview - wasReceivedByUD:(BOOL)wasReceivedByUD - openGroupInvitationName:(nullable NSString *)openGroupInvitationName - openGroupInvitationURL:(nullable NSString *)openGroupInvitationURL - serverHash:(nullable NSString*)serverHash NS_DESIGNATED_INITIALIZER; - -- (instancetype)initWithCoder:(NSCoder *)coder NS_DESIGNATED_INITIALIZER; - -/* - * Find a message matching the senderId and timestamp, if any. - * - * @param authorId - * Signal ID (i.e. e164) of the user who sent the message - * @params timestamp - * When the message was created in milliseconds since epoch - * - */ -+ (nullable instancetype)findMessageWithAuthorId:(NSString *)authorId - timestamp:(uint64_t)timestamp - transaction:(YapDatabaseReadWriteTransaction *)transaction; - -// This will be 0 for messages created before we were tracking sourceDeviceId -@property (nonatomic, readonly) UInt32 sourceDeviceId; - -@property (nonatomic, readonly) NSString *authorId; - -// convenience method for expiring a message which was just read -- (void)markAsReadNowWithTrySendReadReceipt:(BOOL)trySendReadReceipt - transaction:(YapDatabaseReadWriteTransaction *)transaction; - -- (void)setNotificationIdentifier:(NSString * _Nullable)notificationIdentifier - transaction:(YapDatabaseReadWriteTransaction *)transaction; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Messages/Signal/TSIncomingMessage.m b/SessionMessagingKit/Messages/Signal/TSIncomingMessage.m deleted file mode 100644 index 7731e86c2..000000000 --- a/SessionMessagingKit/Messages/Signal/TSIncomingMessage.m +++ /dev/null @@ -1,131 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "TSIncomingMessage.h" -#import "NSNotificationCenter+OWS.h" -#import "TSAttachmentPointer.h" -#import "TSDatabaseSecondaryIndexes.h" -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface TSIncomingMessage () - -@end - -#pragma mark - - -@implementation TSIncomingMessage - -- (instancetype)initWithCoder:(NSCoder *)coder -{ - self = [super initWithCoder:coder]; - if (!self) { - return self; - } - - if (_authorId == nil) { - _authorId = [TSContactThread contactSessionIDFromThreadID:self.uniqueThreadId]; - } - - return self; -} - -- (instancetype)initWithTimestamp:(uint64_t)timestamp - inThread:(TSThread *)thread - authorId:(NSString *)authorId - sourceDeviceId:(uint32_t)sourceDeviceId - messageBody:(nullable NSString *)body - attachmentIds:(NSArray *)attachmentIds - expiresInSeconds:(uint32_t)expiresInSeconds - quotedMessage:(nullable TSQuotedMessage *)quotedMessage - linkPreview:(nullable OWSLinkPreview *)linkPreview - wasReceivedByUD:(BOOL)wasReceivedByUD - openGroupInvitationName:(nullable NSString *)openGroupInvitationName - openGroupInvitationURL:(nullable NSString *)openGroupInvitationURL - serverHash:(nullable NSString *)serverHash -{ - self = [super initMessageWithTimestamp:timestamp - inThread:thread - messageBody:body - attachmentIds:attachmentIds - expiresInSeconds:expiresInSeconds - expireStartedAt:0 - quotedMessage:quotedMessage - linkPreview:linkPreview - openGroupInvitationName:openGroupInvitationName - openGroupInvitationURL:openGroupInvitationURL - serverHash:serverHash]; - - if (!self) { - return self; - } - - _authorId = authorId; - _sourceDeviceId = sourceDeviceId; - _read = NO; - _wasReceivedByUD = wasReceivedByUD; - _notificationIdentifier = nil; - - return self; -} - -+ (nullable instancetype)findMessageWithAuthorId:(NSString *)authorId - timestamp:(uint64_t)timestamp - transaction:(YapDatabaseReadWriteTransaction *)transaction -{ - __block TSIncomingMessage *foundMessage; - // In theory we could build a new secondaryIndex for (authorId,timestamp), but in practice there should - // be *very* few (millisecond) timestamps with multiple authors. - [TSDatabaseSecondaryIndexes - enumerateMessagesWithTimestamp:timestamp - withBlock:^(NSString *collection, NSString *key, BOOL *stop) { - TSInteraction *interaction = - [TSInteraction fetchObjectWithUniqueID:key transaction:transaction]; - if ([interaction isKindOfClass:[TSIncomingMessage class]]) { - TSIncomingMessage *message = (TSIncomingMessage *)interaction; - if ([message.authorId isEqualToString:authorId]) { - foundMessage = message; - } - } - } - usingTransaction:transaction]; - - return foundMessage; -} - -- (OWSInteractionType)interactionType -{ - return OWSInteractionType_IncomingMessage; -} - -- (BOOL)shouldStartExpireTimerWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - for (NSString *attachmentId in self.attachmentIds) { - TSAttachment *_Nullable attachment = - [TSAttachment fetchObjectWithUniqueID:attachmentId transaction:transaction]; - if ([attachment isKindOfClass:[TSAttachmentPointer class]]) { - return NO; - } - } - return self.isExpiringMessage; -} - -- (BOOL)isUserMentioned -{ - NSString *userPublicKey = [SNGeneralUtilities getUserPublicKey]; - return (self.body != nil && [self.body containsString:[NSString stringWithFormat:@"@%@", userPublicKey]]) || (self.quotedMessage != nil && [self.quotedMessage.authorId isEqualToString:userPublicKey]); -} - -- (void)setNotificationIdentifier:(NSString * _Nullable)notificationIdentifier transaction:(nonnull YapDatabaseReadWriteTransaction *)transaction -{ - _notificationIdentifier = notificationIdentifier; - [self saveWithTransaction:transaction]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Messages/Signal/TSInfoMessage.h b/SessionMessagingKit/Messages/Signal/TSInfoMessage.h deleted file mode 100644 index 550b40368..000000000 --- a/SessionMessagingKit/Messages/Signal/TSInfoMessage.h +++ /dev/null @@ -1,61 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface TSInfoMessage : TSMessage - -typedef NS_ENUM(NSInteger, TSInfoMessageType) { - TSInfoMessageTypeGroupCreated, - TSInfoMessageTypeGroupUpdated, - TSInfoMessageTypeGroupCurrentUserLeft, - TSInfoMessageTypeDisappearingMessagesUpdate, - TSInfoMessageTypeScreenshotNotification, - TSInfoMessageTypeMediaSavedNotification, - TSInfoMessageTypeMessageRequestAccepted = 99 -}; - -@property (nonatomic, getter=wasRead) BOOL read; -@property (atomic, readonly) TSInfoMessageType messageType; -@property (atomic, readonly, nullable) NSString *customMessage; -@property (atomic, readonly, nullable) NSString *unregisteredRecipientId; - -- (instancetype)initMessageWithTimestamp:(uint64_t)timestamp - inThread:(nullable TSThread *)thread - messageBody:(nullable NSString *)body - attachmentIds:(NSArray *)attachmentIds - expiresInSeconds:(uint32_t)expiresInSeconds - expireStartedAt:(uint64_t)expireStartedAt - quotedMessage:(nullable TSQuotedMessage *)quotedMessage - contactShare:(nullable OWSContact *)contact - linkPreview:(nullable OWSLinkPreview *)linkPreview NS_UNAVAILABLE; - -- (instancetype)initWithCoder:(NSCoder *)coder NS_DESIGNATED_INITIALIZER; - -- (instancetype)initWithTimestamp:(uint64_t)timestamp - inThread:(TSThread *)contact - messageType:(TSInfoMessageType)infoMessage NS_DESIGNATED_INITIALIZER; - -- (instancetype)initWithTimestamp:(uint64_t)timestamp - inThread:(TSThread *)thread - messageType:(TSInfoMessageType)infoMessage - customMessage:(NSString *)customMessage; - -- (instancetype)initWithTimestamp:(uint64_t)timestamp - inThread:(TSThread *)thread - messageType:(TSInfoMessageType)infoMessage - unregisteredRecipientId:(NSString *)unregisteredRecipientId; - -- (instancetype)initWithTimestamp:(uint64_t)timestamp - inThread:(nullable TSThread *)thread - messageBody:(nullable NSString *)body - attachmentIds:(NSArray *)attachmentIds - expiresInSeconds:(uint32_t)expiresInSeconds - expireStartedAt:(uint64_t)expireStartedAt NS_UNAVAILABLE; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Messages/Signal/TSInfoMessage.m b/SessionMessagingKit/Messages/Signal/TSInfoMessage.m deleted file mode 100644 index 9c7f8908c..000000000 --- a/SessionMessagingKit/Messages/Signal/TSInfoMessage.m +++ /dev/null @@ -1,124 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "TSInfoMessage.h" -#import "SSKEnvironment.h" -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -NSUInteger TSInfoMessageSchemaVersion = 1; - -@interface TSInfoMessage () - -@property (nonatomic, readonly) NSUInteger infoMessageSchemaVersion; - -@end - -#pragma mark - - -@implementation TSInfoMessage - -- (instancetype)initWithCoder:(NSCoder *)coder -{ - self = [super initWithCoder:coder]; - if (!self) { - return self; - } - - if (self.infoMessageSchemaVersion < 1) { - _read = YES; - } - - _infoMessageSchemaVersion = TSInfoMessageSchemaVersion; - - if (self.isDynamicInteraction) { - self.read = YES; - } - - return self; -} - -- (instancetype)initWithTimestamp:(uint64_t)timestamp - inThread:(TSThread *)thread - messageType:(TSInfoMessageType)infoMessage -{ - // MJK TODO - remove senderTimestamp - self = [super initMessageWithTimestamp:timestamp - inThread:thread - messageBody:nil - attachmentIds:@[] - expiresInSeconds:0 - expireStartedAt:0 - quotedMessage:nil - linkPreview:nil - openGroupInvitationName:nil - openGroupInvitationURL:nil - serverHash:nil]; - - if (!self) { - return self; - } - - _messageType = infoMessage; - _infoMessageSchemaVersion = TSInfoMessageSchemaVersion; - - if (self.isDynamicInteraction) { - self.read = YES; - } - - return self; -} - -- (instancetype)initWithTimestamp:(uint64_t)timestamp - inThread:(TSThread *)thread - messageType:(TSInfoMessageType)infoMessage - customMessage:(NSString *)customMessage -{ - self = [self initWithTimestamp:timestamp inThread:thread messageType:infoMessage]; - if (self) { - _customMessage = customMessage; - } - return self; -} - -- (instancetype)initWithTimestamp:(uint64_t)timestamp - inThread:(TSThread *)thread - messageType:(TSInfoMessageType)infoMessage - unregisteredRecipientId:(NSString *)unregisteredRecipientId -{ - self = [self initWithTimestamp:timestamp inThread:thread messageType:infoMessage]; - if (self) { - _unregisteredRecipientId = unregisteredRecipientId; - } - return self; -} - -- (OWSInteractionType)interactionType -{ - return OWSInteractionType_Info; -} - -- (NSString *)previewTextWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - switch (_messageType) { - case TSInfoMessageTypeGroupCreated: - return NSLocalizedString(@"GROUP_CREATED", @""); - case TSInfoMessageTypeGroupCurrentUserLeft: - return NSLocalizedString(@"GROUP_YOU_LEFT", @""); - case TSInfoMessageTypeGroupUpdated: - return _customMessage != nil ? _customMessage : NSLocalizedString(@"GROUP_UPDATED", @""); - case TSInfoMessageTypeMessageRequestAccepted: - return NSLocalizedString(@"MESSAGE_REQUESTS_ACCEPTED", @""); - default: - break; - } - - return @"Unknown Info Message Type"; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Messages/Signal/TSInteraction.h b/SessionMessagingKit/Messages/Signal/TSInteraction.h deleted file mode 100644 index e6b77faf3..000000000 --- a/SessionMessagingKit/Messages/Signal/TSInteraction.h +++ /dev/null @@ -1,84 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@class TSThread; - -typedef NS_ENUM(NSInteger, OWSInteractionType) { - OWSInteractionType_Unknown, - OWSInteractionType_IncomingMessage, - OWSInteractionType_OutgoingMessage, - OWSInteractionType_Call, - OWSInteractionType_Info, - OWSInteractionType_Offer, - OWSInteractionType_TypingIndicator, -}; - -NSString *NSStringFromOWSInteractionType(OWSInteractionType value); - -@protocol OWSPreviewText - -- (NSString *)previewTextWithTransaction:(YapDatabaseReadTransaction *)transaction; - -@end - -@interface TSInteraction : TSYapDatabaseObject - -- (instancetype)initInteractionWithUniqueId:(NSString *)uniqueId - timestamp:(uint64_t)timestamp - inThread:(TSThread *)thread; -- (instancetype)initInteractionWithTimestamp:(uint64_t)timestamp inThread:(TSThread *)thread; - -@property (nonatomic, readonly) NSString *uniqueThreadId; -@property (nonatomic, readonly) TSThread *thread; -@property (nonatomic, readonly) uint64_t timestamp; -@property (nonatomic, readonly) uint64_t sortId; -@property (nonatomic, readonly) uint64_t receivedAtTimestamp; - -- (NSDate *)dateForUI; - -- (NSDate *)receivedAtDate; - -- (OWSInteractionType)interactionType; - -- (TSThread *)threadWithTransaction:(YapDatabaseReadTransaction *)transaction; - -/** - * When an interaction is updated, it often affects the UI for it's containing thread. Touching it's thread will notify - * any observers so they can redraw any related UI. - */ -- (void)touchThreadWithTransaction:(YapDatabaseReadWriteTransaction *)transaction; - -#pragma mark Utility Method - -+ (NSArray *)interactionsWithTimestamp:(uint64_t)timestamp - ofClass:(Class)clazz - withTransaction:(YapDatabaseReadTransaction *)transaction; - -+ (NSArray *)interactionsWithTimestamp:(uint64_t)timestamp - filter:(BOOL (^_Nonnull)(TSInteraction *))filter - withTransaction:(YapDatabaseReadTransaction *)transaction; - -- (uint64_t)timestampForLegacySorting; -- (NSComparisonResult)compareForSorting:(TSInteraction *)other; - -// "Dynamic" interactions are not messages or static events (like -// info messages, error messages, etc.). They are interactions -// created, updated and deleted by the views. -// -// These include block offers, "add to contact" offers, -// unseen message indicators, etc. -- (BOOL)isDynamicInteraction; - -- (void)saveNextSortIdWithTransaction:(YapDatabaseReadWriteTransaction *)transaction - NS_SWIFT_NAME(saveNextSortId(transaction:)); - -- (void)updateTimestamp:(uint64_t)timestamp; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Messages/Signal/TSInteraction.m b/SessionMessagingKit/Messages/Signal/TSInteraction.m deleted file mode 100644 index 7eb2eca62..000000000 --- a/SessionMessagingKit/Messages/Signal/TSInteraction.m +++ /dev/null @@ -1,272 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "TSInteraction.h" -#import "TSDatabaseSecondaryIndexes.h" -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -NSString *NSStringFromOWSInteractionType(OWSInteractionType value) -{ - switch (value) { - case OWSInteractionType_Unknown: - return @"OWSInteractionType_Unknown"; - case OWSInteractionType_IncomingMessage: - return @"OWSInteractionType_IncomingMessage"; - case OWSInteractionType_OutgoingMessage: - return @"OWSInteractionType_OutgoingMessage"; - case OWSInteractionType_Call: - return @"OWSInteractionType_Call"; - case OWSInteractionType_Info: - return @"OWSInteractionType_Info"; - case OWSInteractionType_Offer: - return @"OWSInteractionType_Offer"; - case OWSInteractionType_TypingIndicator: - return @"OWSInteractionType_TypingIndicator"; - } -} - -@interface TSInteraction () - -@property (nonatomic) uint64_t sortId; - -@end - -@implementation TSInteraction - -@synthesize timestamp = _timestamp; - -+ (NSArray *)interactionsWithTimestamp:(uint64_t)timestamp - ofClass:(Class)clazz - withTransaction:(YapDatabaseReadTransaction *)transaction -{ - // Accept any interaction. - return [self interactionsWithTimestamp:timestamp - filter:^(TSInteraction *interaction) { - return [interaction isKindOfClass:clazz]; - } - withTransaction:transaction]; -} - -+ (NSArray *)interactionsWithTimestamp:(uint64_t)timestamp - filter:(BOOL (^_Nonnull)(TSInteraction *))filter - withTransaction:(YapDatabaseReadTransaction *)transaction -{ - NSMutableArray *interactions = [NSMutableArray new]; - - [TSDatabaseSecondaryIndexes - enumerateMessagesWithTimestamp:timestamp - withBlock:^(NSString *collection, NSString *key, BOOL *stop) { - TSInteraction *interaction = - [TSInteraction fetchObjectWithUniqueID:key transaction:transaction]; - if (!filter(interaction)) { - return; - } - [interactions addObject:interaction]; - } - usingTransaction:transaction]; - - return [interactions copy]; -} - -+ (NSString *)collection { - return @"TSInteraction"; -} - -- (instancetype)initInteractionWithUniqueId:(NSString *)uniqueId - timestamp:(uint64_t)timestamp - inThread:(TSThread *)thread -{ - self = [super initWithUniqueId:uniqueId]; - - if (!self) { - return self; - } - - _timestamp = timestamp; - _uniqueThreadId = thread.uniqueId; - - return self; -} - -- (instancetype)initInteractionWithTimestamp:(uint64_t)timestamp inThread:(TSThread *)thread -{ - self = [super initWithUniqueId:[[NSUUID UUID] UUIDString]]; - - if (!self) { - return self; - } - - _timestamp = timestamp; - _uniqueThreadId = thread.uniqueId; - _receivedAtTimestamp = [NSDate ows_millisecondTimeStamp]; - - return self; -} - -- (nullable instancetype)initWithCoder:(NSCoder *)coder -{ - self = [super initWithCoder:coder]; - if (!self) { - return nil; - } - - // Previously the receivedAtTimestamp field lived on TSMessage, but we've moved it up - // to the TSInteraction superclass. - if (_receivedAtTimestamp == 0) { - // Upgrade from the older "TSMessage.receivedAtDate" and "TSMessage.receivedAt" properties if - // necessary. - NSDate *receivedAtDate = [coder decodeObjectForKey:@"receivedAtDate"]; - if (!receivedAtDate) { - receivedAtDate = [coder decodeObjectForKey:@"receivedAt"]; - } - - if (receivedAtDate) { - _receivedAtTimestamp = [NSDate ows_millisecondsSince1970ForDate:receivedAtDate]; - } - - // For TSInteractions which are not TSMessage's, the timestamp *is* the receivedAtTimestamp - if (_receivedAtTimestamp == 0) { - _receivedAtTimestamp = _timestamp; - } - } - - return self; -} - -#pragma mark Thread - -- (TSThread *)thread -{ - return [TSThread fetchObjectWithUniqueID:self.uniqueThreadId]; -} - -- (TSThread *)threadWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - return [TSThread fetchObjectWithUniqueID:self.uniqueThreadId transaction:transaction]; -} - -- (void)touchThreadWithTransaction:(YapDatabaseReadWriteTransaction *)transaction -{ - [transaction touchObjectForKey:self.uniqueThreadId inCollection:[TSThread collection]]; -} - -- (void)applyChangeToSelfAndLatestCopy:(YapDatabaseReadWriteTransaction *)transaction - changeBlock:(void (^)(id))changeBlock -{ - [super applyChangeToSelfAndLatestCopy:transaction changeBlock:changeBlock]; - [self touchThreadWithTransaction:transaction]; -} - -#pragma mark Date operations - -- (uint64_t)timestampForLegacySorting -{ - return self.timestamp; -} - -- (NSDate *)dateForUI -{ - return [NSDate ows_dateWithMillisecondsSince1970:self.timestamp]; -} - -- (NSDate *)receivedAtDate -{ - // This is only used for sorting threads - return [NSDate ows_dateWithMillisecondsSince1970:self.receivedAtTimestamp]; -} - -- (NSComparisonResult)compareForSorting:(TSInteraction *)other -{ - uint64_t sortId1; - uint64_t sortId2; - - // In open groups messages should be sorted by server timestamp. `sortId` represents the order in which messages - // were processed. Since in the open group poller we sort messages by their server timestamp, sorting by `sortId` is - // effectively the same as sorting by server timestamp. - // sortId == serverTimestamp (the sent timestamp) for open group messages. - // sortId == timestamp (the sent timestamp) for one-to-one and closed group messages. - sortId1 = self.sortId; - sortId2 = other.sortId; - - if (sortId1 > sortId2) { - return NSOrderedDescending; - } else if (sortId1 < sortId2) { - return NSOrderedAscending; - } else { - return NSOrderedSame; - } -} - -- (OWSInteractionType)interactionType -{ - return OWSInteractionType_Unknown; -} - -- (NSString *)description -{ - return [NSString stringWithFormat:@"%@ in thread: %@ timestamp: %lu", - [super description], - self.uniqueThreadId, - (unsigned long)self.timestamp]; -} - -- (uint64_t)sortId -{ - return self.timestamp; -} - -- (void)saveWithTransaction:(YapDatabaseReadWriteTransaction *)transaction -{ - if (!self.uniqueId) { - self.uniqueId = [NSUUID new].UUIDString; - } -// if (self.sortId == 0) { -// self.sortId = [SSKIncrementingIdFinder nextIdWithKey:[TSInteraction collection] transaction:transaction]; -// } - - [super saveWithTransaction:transaction]; - - TSThread *fetchedThread = [self threadWithTransaction:transaction]; - - [fetchedThread updateWithLastMessage:self transaction:transaction]; -} - -- (void)removeWithTransaction:(YapDatabaseReadWriteTransaction *)transaction -{ - [super removeWithTransaction:transaction]; - - [self touchThreadWithTransaction:transaction]; -} - -- (BOOL)isDynamicInteraction -{ - return NO; -} - -#pragma mark - sorting migration - -- (void)saveNextSortIdWithTransaction:(YapDatabaseReadWriteTransaction *)transaction -{ - if (self.sortId != 0) { - // This could happen if something else in our startup process saved the interaction - // e.g. another migration ran. - // During the migration, since we're enumerating the interactions in the proper order, - // we want to ignore any previously assigned sortId - self.sortId = 0; - } - [self saveWithTransaction:transaction]; -} - -- (void)updateTimestamp:(uint64_t)timestamp -{ - _timestamp = timestamp; -} - - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Messages/Signal/TSMessage.h b/SessionMessagingKit/Messages/Signal/TSMessage.h deleted file mode 100644 index ff581bb16..000000000 --- a/SessionMessagingKit/Messages/Signal/TSMessage.h +++ /dev/null @@ -1,90 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -typedef NS_ENUM(NSUInteger, TSMessageDirection) { - TSMessageDirectionIncoming, - TSMessageDirectionOutgoing -}; - -/** - * Abstract message class. - */ - -@class OWSContact; -@class OWSLinkPreview; -@class TSAttachment; -@class TSAttachmentStream; -@class TSQuotedMessage; -@class YapDatabaseReadWriteTransaction; - -extern const NSUInteger kOversizeTextMessageSizeThreshold; - -@interface TSMessage : TSInteraction - -@property (nonatomic, readonly) NSMutableArray *attachmentIds; -@property (nonatomic, readonly, nullable) NSString *body; -@property (nonatomic, readonly) uint32_t expiresInSeconds; -@property (nonatomic, readonly) uint64_t expireStartedAt; -@property (nonatomic, readonly) uint64_t expiresAt; -@property (nonatomic, readonly) BOOL isExpiringMessage; -@property (nonatomic, readonly, nullable) TSQuotedMessage *quotedMessage; -@property (nonatomic, nullable) OWSLinkPreview *linkPreview; -@property (nonatomic) uint64_t openGroupServerMessageID; -@property (nonatomic, readonly) BOOL isOpenGroupMessage; -@property (nonatomic, readonly, nullable) NSString *openGroupInvitationName; -@property (nonatomic, readonly, nullable) NSString *openGroupInvitationURL; -@property (nonatomic, nullable) NSString *serverHash; -@property (nonatomic) BOOL isDeleted; - -- (instancetype)initInteractionWithTimestamp:(uint64_t)timestamp inThread:(TSThread *)thread NS_UNAVAILABLE; - -- (instancetype)initMessageWithTimestamp:(uint64_t)timestamp - inThread:(nullable TSThread *)thread - messageBody:(nullable NSString *)body - attachmentIds:(NSArray *)attachmentIds - expiresInSeconds:(uint32_t)expiresInSeconds - expireStartedAt:(uint64_t)expireStartedAt - quotedMessage:(nullable TSQuotedMessage *)quotedMessage - linkPreview:(nullable OWSLinkPreview *)linkPreview - openGroupInvitationName:(nullable NSString *)openGroupInvitationName - openGroupInvitationURL:(nullable NSString *)openGroupInvitationURL - serverHash:(nullable NSString *)serverHash NS_DESIGNATED_INITIALIZER; - -- (nullable instancetype)initWithCoder:(NSCoder *)coder NS_DESIGNATED_INITIALIZER; - -- (BOOL)hasAttachments; -- (NSArray *)attachmentsWithTransaction:(YapDatabaseReadTransaction *)transaction; -- (NSArray *)mediaAttachmentsWithTransaction:(YapDatabaseReadTransaction *)transaction; -- (nullable TSAttachment *)oversizeTextAttachmentWithTransaction:(YapDatabaseReadTransaction *)transaction; -- (void)addAttachmentWithID:(NSString *)attachmentID in:(YapDatabaseReadWriteTransaction *)transaction; - -- (void)removeAttachment:(TSAttachment *)attachment - transaction:(YapDatabaseReadWriteTransaction *)transaction NS_SWIFT_NAME(removeAttachment(_:transaction:)); - -// Returns ids for all attachments, including message ("body") attachments, -// quoted reply thumbnails, contact share avatars, link preview images, etc. -- (NSArray *)allAttachmentIds; - -- (void)setQuotedMessageThumbnailAttachmentStream:(TSAttachmentStream *)attachmentStream; - -- (nullable NSString *)oversizeTextWithTransaction:(YapDatabaseReadTransaction *)transaction; -- (nullable NSString *)bodyTextWithTransaction:(YapDatabaseReadTransaction *)transaction; - -- (BOOL)shouldStartExpireTimerWithTransaction:(YapDatabaseReadTransaction *)transaction; - -#pragma mark - Update With... Methods - -- (void)updateWithExpireStartedAt:(uint64_t)expireStartedAt transaction:(YapDatabaseReadWriteTransaction *)transaction; - -- (void)updateWithLinkPreview:(OWSLinkPreview *)linkPreview transaction:(YapDatabaseReadWriteTransaction *)transaction; - -- (void)updateForDeletionWithTransaction:(YapDatabaseReadWriteTransaction *)transaction; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Messages/Signal/TSMessage.m b/SessionMessagingKit/Messages/Signal/TSMessage.m deleted file mode 100644 index bd22479e1..000000000 --- a/SessionMessagingKit/Messages/Signal/TSMessage.m +++ /dev/null @@ -1,443 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "TSMessage.h" -#import "AppContext.h" -#import "MIMETypeUtil.h" -#import "TSAttachment.h" -#import "TSAttachmentStream.h" -#import "TSQuotedMessage.h" -#import -#import -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -static const NSUInteger OWSMessageSchemaVersion = 4; -const NSUInteger kOversizeTextMessageSizeThreshold = 2 * 1024; - -#pragma mark - - -@interface TSMessage () - -@property (nonatomic, nullable) NSString *body; -@property (nonatomic) uint32_t expiresInSeconds; -@property (nonatomic) uint64_t expireStartedAt; - -/** - * The version of the model class's schema last used to serialize this model. Use this to manage data migrations during - * object de/serialization. - * - * e.g. - * - * - (id)initWithCoder:(NSCoder *)coder - * { - * self = [super initWithCoder:coder]; - * if (!self) { return self; } - * if (_schemaVersion < 2) { - * _newName = [coder decodeObjectForKey:@"oldName"] - * } - * ... - * _schemaVersion = 2; - * } - */ -@property (nonatomic, readonly) NSUInteger schemaVersion; - -@end - -#pragma mark - - -@implementation TSMessage - -- (instancetype)initMessageWithTimestamp:(uint64_t)timestamp - inThread:(nullable TSThread *)thread - messageBody:(nullable NSString *)body - attachmentIds:(NSArray *)attachmentIds - expiresInSeconds:(uint32_t)expiresInSeconds - expireStartedAt:(uint64_t)expireStartedAt - quotedMessage:(nullable TSQuotedMessage *)quotedMessage - linkPreview:(nullable OWSLinkPreview *)linkPreview - openGroupInvitationName:(nullable NSString *)openGroupInvitationName - openGroupInvitationURL:(nullable NSString *)openGroupInvitationURL - serverHash:(nullable NSString *)serverHash -{ - self = [super initInteractionWithTimestamp:timestamp inThread:thread]; - - if (!self) { - return self; - } - - _schemaVersion = OWSMessageSchemaVersion; - - _body = body; - _attachmentIds = attachmentIds ? [attachmentIds mutableCopy] : [NSMutableArray new]; - _expiresInSeconds = expiresInSeconds; - _expireStartedAt = expireStartedAt; - [self updateExpiresAt]; - _quotedMessage = quotedMessage; - _linkPreview = linkPreview; - _openGroupServerMessageID = 0; - _openGroupInvitationName = openGroupInvitationName; - _openGroupInvitationURL = openGroupInvitationURL; - _serverHash = serverHash; - _isDeleted = false; - - return self; -} - -- (nullable instancetype)initWithCoder:(NSCoder *)coder -{ - self = [super initWithCoder:coder]; - if (!self) { - return self; - } - - if (_schemaVersion < 2) { - // renamed _attachments to _attachmentIds - if (!_attachmentIds) { - _attachmentIds = [coder decodeObjectForKey:@"attachments"]; - } - } - - if (_schemaVersion < 3) { - _expiresInSeconds = 0; - _expireStartedAt = 0; - _expiresAt = 0; - } - - if (_schemaVersion < 4) { - // Wipe out the body field on these legacy attachment messages. - // - // Explantion: Historically, a message sent from iOS could be an attachment XOR a text message, - // but now we support sending an attachment+caption as a single message. - // - // Other clients have supported sending attachment+caption in a single message for a long time. - // So the way we used to handle receiving them was to make it look like they'd sent two messages: - // first the attachment+caption (we'd ignore this caption when rendering), followed by a separate - // message with just the caption (which we'd render as a simple independent text message), for - // which we'd offset the timestamp by a little bit to get the desired ordering. - // - // Now that we can properly render an attachment+caption message together, these legacy "dummy" text - // messages are not only unnecessary, but worse, would be rendered redundantly. For safety, rather - // than building the logic to try to find and delete the redundant "dummy" text messages which users - // have been seeing and interacting with, we delete the body field from the attachment message, - // which iOS users have never seen directly. - if (_attachmentIds.count > 0) { - _body = nil; - } - } - - if (!_attachmentIds) { - _attachmentIds = [NSMutableArray new]; - } - - _schemaVersion = OWSMessageSchemaVersion; - - return self; -} - -- (void)setExpiresInSeconds:(uint32_t)expiresInSeconds -{ - uint32_t maxExpirationDuration = [SMKDisappearingMessagesConfiguration maxDurationSeconds]; - - _expiresInSeconds = MIN(expiresInSeconds, maxExpirationDuration); - [self updateExpiresAt]; -} - -- (void)setExpireStartedAt:(uint64_t)expireStartedAt -{ - if (_expireStartedAt != 0 && _expireStartedAt < expireStartedAt) { - return; - } - - uint64_t now = [NSDate ows_millisecondTimeStamp]; - - _expireStartedAt = MIN(now, expireStartedAt); - [self updateExpiresAt]; -} - -- (BOOL)shouldStartExpireTimerWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - return self.isExpiringMessage; -} - -// TODO a downloaded media doesn't start counting until download is complete. -- (void)updateExpiresAt -{ - if (_expiresInSeconds > 0 && _expireStartedAt > 0) { - _expiresAt = _expireStartedAt + _expiresInSeconds * 1000; - } else { - _expiresAt = 0; - } -} - -- (BOOL)hasAttachments -{ - return self.attachmentIds ? (self.attachmentIds.count > 0) : NO; -} - -- (NSArray *)allAttachmentIds -{ - NSMutableArray *result = [NSMutableArray new]; - if (self.attachmentIds.count > 0) { - [result addObjectsFromArray:self.attachmentIds]; - } - - if (self.quotedMessage) { - [result addObjectsFromArray:self.quotedMessage.thumbnailAttachmentStreamIds]; - } - - if (self.linkPreview.imageAttachmentId) { - [result addObject:self.linkPreview.imageAttachmentId]; - } - - return [result copy]; -} - -- (NSArray *)attachmentsWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - NSMutableArray *attachments = [NSMutableArray new]; - for (NSString *attachmentId in self.attachmentIds) { - TSAttachment *_Nullable attachment = - [TSAttachment fetchObjectWithUniqueID:attachmentId transaction:transaction]; - if (attachment) { - [attachments addObject:attachment]; - } - } - return [attachments copy]; -} - -- (NSArray *)attachmentsWithTransaction:(YapDatabaseReadTransaction *)transaction - contentType:(NSString *)contentType -{ - NSArray *attachments = [self attachmentsWithTransaction:transaction]; - return [attachments filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(TSAttachment *evaluatedObject, - NSDictionary *_Nullable bindings) { - return [evaluatedObject.contentType isEqualToString:contentType]; - }]]; -} - -- (NSArray *)attachmentsWithTransaction:(YapDatabaseReadTransaction *)transaction - exceptContentType:(NSString *)contentType -{ - NSArray *attachments = [self attachmentsWithTransaction:transaction]; - return [attachments filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(TSAttachment *evaluatedObject, - NSDictionary *_Nullable bindings) { - return ![evaluatedObject.contentType isEqualToString:contentType]; - }]]; -} - -- (void)removeAttachment:(TSAttachment *)attachment transaction:(YapDatabaseReadWriteTransaction *)transaction; -{ - [attachment removeWithTransaction:transaction]; - - [self.attachmentIds removeObject:attachment.uniqueId]; - - [self saveWithTransaction:transaction]; -} - -- (void)addAttachmentWithID:(NSString *)attachmentID in:(YapDatabaseReadWriteTransaction *)transaction { - if (!self.attachmentIds) { return; } - [self.attachmentIds addObject:attachmentID]; - [self saveWithTransaction:transaction]; -} - -- (NSString *)debugDescription -{ - if ([self hasAttachments] && self.body.length > 0) { - NSString *attachmentId = self.attachmentIds[0]; - return [NSString - stringWithFormat:@"Media Message with attachmentId: %@ and caption: '%@'", attachmentId, self.body]; - } else if ([self hasAttachments]) { - NSString *attachmentId = self.attachmentIds[0]; - return [NSString stringWithFormat:@"Media Message with attachmentId: %@", attachmentId]; - } else { - return [NSString stringWithFormat:@"%@ with body: %@", [self class], self.body]; - } -} - -- (nullable TSAttachment *)oversizeTextAttachmentWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - return [self attachmentsWithTransaction:transaction contentType:OWSMimeTypeOversizeTextMessage].firstObject; -} - -- (NSArray *)mediaAttachmentsWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - return [self attachmentsWithTransaction:transaction exceptContentType:OWSMimeTypeOversizeTextMessage]; -} - -- (nullable NSString *)oversizeTextWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - TSAttachment *_Nullable attachment = [self oversizeTextAttachmentWithTransaction:transaction]; - if (!attachment) { - return nil; - } - - if (![attachment isKindOfClass:TSAttachmentStream.class]) { - return nil; - } - - TSAttachmentStream *attachmentStream = (TSAttachmentStream *)attachment; - - NSData *_Nullable data = [NSData dataWithContentsOfFile:attachmentStream.originalFilePath]; - if (!data) { - return nil; - } - NSString *_Nullable text = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; - if (!text) { - return nil; - } - return text.filterStringForDisplay; -} - -- (nullable NSString *)bodyTextWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - NSString *_Nullable oversizeText = [self oversizeTextWithTransaction:transaction]; - if (oversizeText) { - return oversizeText; - } - - if (self.body.length > 0) { - return self.body.filterStringForDisplay; - } - - return nil; -} - -// TODO: This method contains view-specific logic and probably belongs in NotificationsManager, not in SSK. -- (NSString *)previewTextWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - NSString *_Nullable bodyDescription = nil; - if (self.body.length > 0) { - bodyDescription = self.body; - } - - if (bodyDescription == nil) { - TSAttachment *_Nullable oversizeTextAttachment = [self oversizeTextAttachmentWithTransaction:transaction]; - if ([oversizeTextAttachment isKindOfClass:[TSAttachmentStream class]]) { - TSAttachmentStream *oversizeTextAttachmentStream = (TSAttachmentStream *)oversizeTextAttachment; - NSData *_Nullable data = [NSData dataWithContentsOfFile:oversizeTextAttachmentStream.originalFilePath]; - if (data) { - NSString *_Nullable text = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; - if (text) { - bodyDescription = text.filterStringForDisplay; - } - } - } - } - - NSString *_Nullable attachmentDescription = nil; - TSAttachment *_Nullable mediaAttachment = [self mediaAttachmentsWithTransaction:transaction].firstObject; - if (mediaAttachment != nil) { - attachmentDescription = mediaAttachment.description; - } - - if (attachmentDescription.length > 0 && bodyDescription.length > 0) { - // Attachment with caption. - if ([CurrentAppContext() isRTL]) { - return [[bodyDescription stringByAppendingString:@": "] stringByAppendingString:attachmentDescription]; - } else { - return [[attachmentDescription stringByAppendingString:@": "] stringByAppendingString:bodyDescription]; - } - } else if (bodyDescription.length > 0) { - return bodyDescription; - } else if (attachmentDescription.length > 0) { - return attachmentDescription; - } else if (self.openGroupInvitationName != nil) { - return @"😎 Open group invitation"; - } else { - // TODO: We should do better here. - return @""; - } -} - -- (void)removeWithTransaction:(YapDatabaseReadWriteTransaction *)transaction -{ - [super removeWithTransaction:transaction]; - - for (NSString *attachmentId in self.allAttachmentIds) { - // We need to fetch each attachment, since [TSAttachment removeWithTransaction:] does important work. - TSAttachment *_Nullable attachment = - [TSAttachment fetchObjectWithUniqueID:attachmentId transaction:transaction]; - if (!attachment) { - continue; - } - [attachment removeWithTransaction:transaction]; - }; -} - -- (BOOL)isExpiringMessage -{ - return self.expiresInSeconds > 0; -} - -- (uint64_t)timestampForLegacySorting -{ - if ([self shouldUseReceiptDateForSorting] && self.receivedAtTimestamp > 0) { - return self.receivedAtTimestamp; - } else { - return self.timestamp; - } -} - -- (BOOL)shouldUseReceiptDateForSorting -{ - return YES; -} - -- (nullable NSString *)body -{ - return _body.filterStringForDisplay; -} - -- (void)setQuotedMessageThumbnailAttachmentStream:(TSAttachmentStream *)attachmentStream -{ - [self.quotedMessage setThumbnailAttachmentStream:attachmentStream]; -} - -- (BOOL)isOpenGroupMessage -{ - return (self.openGroupServerMessageID != 0); -} - -#pragma mark - Update With... Methods - -- (void)updateWithExpireStartedAt:(uint64_t)expireStartedAt transaction:(YapDatabaseReadWriteTransaction *)transaction -{ - [self applyChangeToSelfAndLatestCopy:transaction - changeBlock:^(TSMessage *message) { - [message setExpireStartedAt:expireStartedAt]; - }]; -} - -- (void)updateWithLinkPreview:(OWSLinkPreview *)linkPreview transaction:(YapDatabaseReadWriteTransaction *)transaction -{ - [self applyChangeToSelfAndLatestCopy:transaction - changeBlock:^(TSMessage *message) { - [message setLinkPreview:linkPreview]; - }]; -} - -- (void)updateForDeletionWithTransaction:(YapDatabaseReadWriteTransaction *)transaction -{ - [self applyChangeToSelfAndLatestCopy:transaction - changeBlock:^(TSMessage *message) { - [message setBody:nil]; - [message setServerHash:nil]; - for (NSString *attachmentId in message.attachmentIds) { - TSAttachment *_Nullable attachment = - [TSAttachment fetchObjectWithUniqueID:attachmentId transaction:transaction]; - if (attachment) { - [attachment removeWithTransaction:transaction]; - } - } - [message setIsDeleted:true]; - }]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Messages/Signal/TSOutgoingMessage+Conversion.swift b/SessionMessagingKit/Messages/Signal/TSOutgoingMessage+Conversion.swift deleted file mode 100644 index 8cea810d1..000000000 --- a/SessionMessagingKit/Messages/Signal/TSOutgoingMessage+Conversion.swift +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionUtilitiesKit - -@objc public extension TSOutgoingMessage { - - @objc(from:associatedWith:) - static func from(_ visibleMessage: VisibleMessage, associatedWith thread: TSThread) -> TSOutgoingMessage { - return from(visibleMessage, associatedWith: thread, using: nil) - } - - static func from(_ visibleMessage: VisibleMessage, associatedWith thread: TSThread, using transaction: YapDatabaseReadWriteTransaction? = nil) -> TSOutgoingMessage { - var expiration: UInt32 = 0 - let disappearingMessagesConfigurationOrNil: Legacy.DisappearingMessagesConfiguration? - if let transaction = transaction { - disappearingMessagesConfigurationOrNil = Legacy.DisappearingMessagesConfiguration.fetch(uniqueId: thread.uniqueId!, transaction: transaction) - } else { - disappearingMessagesConfigurationOrNil = Legacy.DisappearingMessagesConfiguration.fetch(uniqueId: thread.uniqueId!) - } - if let disappearingMessagesConfiguration = disappearingMessagesConfigurationOrNil { - expiration = disappearingMessagesConfiguration.isEnabled ? disappearingMessagesConfiguration.durationSeconds : 0 - } - return TSOutgoingMessage( - outgoingMessageWithTimestamp: visibleMessage.sentTimestamp!, - in: thread, - messageBody: visibleMessage.text, - attachmentIds: NSMutableArray(array: visibleMessage.attachmentIDs), - expiresInSeconds: expiration, - expireStartedAt: 0, - isVoiceMessage: false, - groupMetaMessage: .unspecified, - quotedMessage: TSQuotedMessage.from(visibleMessage.quote), - linkPreview: OWSLinkPreview.from(visibleMessage.linkPreview), - openGroupInvitationName: visibleMessage.openGroupInvitation?.name, - openGroupInvitationURL: visibleMessage.openGroupInvitation?.url, - serverHash: visibleMessage.serverHash - ) - } -} - -@objc public extension VisibleMessage { - - @objc(from:) - static func from(_ tsMessage: TSOutgoingMessage) -> VisibleMessage { - let result = VisibleMessage() - result.threadID = tsMessage.uniqueThreadId - result.sentTimestamp = tsMessage.timestamp - result.recipient = tsMessage.recipientIds().first - if let thread = tsMessage.thread as? TSGroupThread, thread.isClosedGroup { - let groupID = thread.groupModel.groupId - result.groupPublicKey = LKGroupUtilities.getDecodedGroupID(groupID) - } - result.text = tsMessage.body - result.attachmentIDs = tsMessage.attachmentIds.compactMap { $0 as? String } - result.quote = VisibleMessage.Quote.from(tsMessage.quotedMessage) - result.linkPreview = VisibleMessage.LinkPreview.from(tsMessage.linkPreview) - return result - } -} diff --git a/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.h b/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.h deleted file mode 100644 index d43793d82..000000000 --- a/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.h +++ /dev/null @@ -1,228 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -// Feature flag. -// -// TODO: Remove. -BOOL AreRecipientUpdatesEnabled(void); - -typedef NS_ENUM(NSInteger, TSOutgoingMessageState) { - // The message is either: - // a) Enqueued for sending. - // b) Waiting on attachment upload(s). - // c) Being sent to the service. - TSOutgoingMessageStateSending, - // The failure state. - TSOutgoingMessageStateFailed, - // The message has been sent to the service. - TSOutgoingMessageStateSent, -}; - -NSString *NSStringForOutgoingMessageState(TSOutgoingMessageState value); - -typedef NS_ENUM(NSInteger, OWSOutgoingMessageRecipientState) { - // Message could not be sent to recipient. - OWSOutgoingMessageRecipientStateFailed = 0, - // Message is being sent to the recipient (enqueued, uploading or sending). - OWSOutgoingMessageRecipientStateSending, - // The message was not sent because the recipient is not valid. - // For example, this recipient may have left the group. - OWSOutgoingMessageRecipientStateSkipped, - // The message has been sent to the service. It may also have been delivered or read. - OWSOutgoingMessageRecipientStateSent, - - OWSOutgoingMessageRecipientStateMin = OWSOutgoingMessageRecipientStateFailed, - OWSOutgoingMessageRecipientStateMax = OWSOutgoingMessageRecipientStateSent, -}; - -NSString *NSStringForOutgoingMessageRecipientState(OWSOutgoingMessageRecipientState value); - -typedef NS_ENUM(NSInteger, TSGroupMetaMessage) { - TSGroupMetaMessageUnspecified, - TSGroupMetaMessageNew, - TSGroupMetaMessageUpdate, - TSGroupMetaMessageDeliver, - TSGroupMetaMessageQuit, - TSGroupMetaMessageRequestInfo, -}; - -@class SNProtoAttachmentPointer; -@class SNProtoContentBuilder; -@class SNProtoDataMessage; -@class SNProtoDataMessageBuilder; - -@interface TSOutgoingMessageRecipientState : MTLModel - -@property (atomic, readonly) OWSOutgoingMessageRecipientState state; -// This property should only be set if state == .sent. -@property (atomic, nullable, readonly) NSNumber *deliveryTimestamp; -// This property should only be set if state == .sent. -@property (atomic, nullable, readonly) NSNumber *readTimestamp; - -@property (atomic, readonly) BOOL wasSentByUD; - -@end - -#pragma mark - - -@interface TSOutgoingMessage : TSMessage - -@property (atomic, nullable) NSDictionary *recipientStateMap; - -- (instancetype)initMessageWithTimestamp:(uint64_t)timestamp - inThread:(nullable TSThread *)thread - messageBody:(nullable NSString *)body - attachmentIds:(NSArray *)attachmentIds - expiresInSeconds:(uint32_t)expiresInSeconds - expireStartedAt:(uint64_t)expireStartedAt - quotedMessage:(nullable TSQuotedMessage *)quotedMessage - linkPreview:(nullable OWSLinkPreview *)linkPreview NS_UNAVAILABLE; - -// MJK TODO - Can we remove the sender timestamp param? -- (instancetype)initOutgoingMessageWithTimestamp:(uint64_t)timestamp - inThread:(nullable TSThread *)thread - messageBody:(nullable NSString *)body - attachmentIds:(NSMutableArray *)attachmentIds - expiresInSeconds:(uint32_t)expiresInSeconds - expireStartedAt:(uint64_t)expireStartedAt - isVoiceMessage:(BOOL)isVoiceMessage - groupMetaMessage:(TSGroupMetaMessage)groupMetaMessage - quotedMessage:(nullable TSQuotedMessage *)quotedMessage - linkPreview:(nullable OWSLinkPreview *)linkPreview - openGroupInvitationName:(nullable NSString *)openGroupInvitationName - openGroupInvitationURL:(nullable NSString *)openGroupInvitationURL - serverHash:(nullable NSString *)serverHash NS_DESIGNATED_INITIALIZER; - -- (nullable instancetype)initWithCoder:(NSCoder *)coder NS_DESIGNATED_INITIALIZER; - -+ (instancetype)outgoingMessageInThread:(nullable TSThread *)thread - messageBody:(nullable NSString *)body - attachmentId:(nullable NSString *)attachmentId; - -+ (instancetype)outgoingMessageInThread:(nullable TSThread *)thread - messageBody:(nullable NSString *)body - attachmentId:(nullable NSString *)attachmentId - expiresInSeconds:(uint32_t)expiresInSeconds; - -+ (instancetype)outgoingMessageInThread:(nullable TSThread *)thread - messageBody:(nullable NSString *)body - attachmentId:(nullable NSString *)attachmentId - expiresInSeconds:(uint32_t)expiresInSeconds - quotedMessage:(nullable TSQuotedMessage *)quotedMessage - linkPreview:(nullable OWSLinkPreview *)linkPreview; - -+ (instancetype)outgoingMessageInThread:(nullable TSThread *)thread - groupMetaMessage:(TSGroupMetaMessage)groupMetaMessage - expiresInSeconds:(uint32_t)expiresInSeconds; - -@property (readonly) TSOutgoingMessageState messageState; -@property (readonly) BOOL wasDeliveredToAnyRecipient; -@property (readonly) BOOL wasSentToAnyRecipient; - -@property (atomic, readonly) BOOL hasSyncedTranscript; -@property (atomic, readonly) NSString *customMessage; -@property (atomic, readonly) NSString *mostRecentFailureText; -// A map of attachment id-to-"source" filename. -@property (nonatomic, readonly) NSMutableDictionary *attachmentFilenameMap; - -@property (atomic, readonly) TSGroupMetaMessage groupMetaMessage; - -@property (nonatomic, readonly) BOOL isVoiceMessage; - -+ (nullable instancetype)findMessageWithTimestamp:(uint64_t)timestamp; - -- (BOOL)shouldBeSaved; - -// All recipients of this message. -- (NSArray *)recipientIds; - -// All recipients of this message who we are currently trying to send to (queued, uploading or during send). -- (NSArray *)sendingRecipientIds; - -// All recipients of this message to whom it has been sent (and possibly delivered or read). -- (NSArray *)sentRecipientIds; - -// All recipients of this message to whom it has been sent and delivered (and possibly read). -- (NSArray *)deliveredRecipientIds; - -// All recipients of this message to whom it has been sent, delivered and read. -- (NSArray *)readRecipientIds; - -// Number of recipients of this message to whom it has been sent. -- (NSUInteger)sentRecipientsCount; - -- (nullable TSOutgoingMessageRecipientState *)recipientStateForRecipientId:(NSString *)recipientId; - -#pragma mark - Update With... Methods - -- (void)updateOpenGroupServerID:(uint64_t)openGroupServerID - serverTimeStamp:(uint64_t)timestamp; - -// This method is used to record a successful send to one recipient. -- (void)updateWithSentRecipient:(NSString *)recipientId - wasSentByUD:(BOOL)wasSentByUD - transaction:(YapDatabaseReadWriteTransaction *)transaction; - -// This method is used to record a skipped send to one recipient. -- (void)updateWithSkippedRecipient:(NSString *)recipientId transaction:(YapDatabaseReadWriteTransaction *)transaction; - -// On app launch, all "sending" recipients should be marked as "failed". -- (void)updateWithAllSendingRecipientsMarkedAsFailedWithTansaction:(YapDatabaseReadWriteTransaction *)transaction; - -// When we start a message send, all "failed" recipients should be marked as "sending". -- (void)updateWithMarkingAllUnsentRecipientsAsSendingWithTransaction:(YapDatabaseReadWriteTransaction *)transaction; - -// This method is used to forge the message state for fake messages. -// -// NOTE: This method should only be used by Debug UI, etc. -- (void)updateWithFakeMessageState:(TSOutgoingMessageState)messageState - transaction:(YapDatabaseReadWriteTransaction *)transaction; - -// This method is used to record a failed send to all "sending" recipients. -- (void)updateWithSendingError:(NSError *)error - transaction:(YapDatabaseReadWriteTransaction *)transaction - NS_SWIFT_NAME(update(sendingError:transaction:)); - -- (void)updateWithHasSyncedTranscript:(BOOL)hasSyncedTranscript - transaction:(YapDatabaseReadWriteTransaction *)transaction; -- (void)updateWithCustomMessage:(NSString *)customMessage transaction:(YapDatabaseReadWriteTransaction *)transaction; -- (void)updateWithCustomMessage:(NSString *)customMessage; - -// This method is used to record a successful delivery to one recipient. -// -// deliveryTimestamp is an optional parameter, since legacy -// delivery receipts don't have a "delivery timestamp". Those -// messages repurpose the "timestamp" field to indicate when the -// corresponding message was originally sent. -- (void)updateWithDeliveredRecipient:(NSString *)recipientId - deliveryTimestamp:(NSNumber *_Nullable)deliveryTimestamp - transaction:(YapDatabaseReadWriteTransaction *)transaction; - -- (void)updateWithWasSentFromLinkedDeviceWithUDRecipientIds:(nullable NSArray *)udRecipientIds - nonUdRecipientIds:(nullable NSArray *)nonUdRecipientIds - isSentUpdate:(BOOL)isSentUpdate - transaction:(YapDatabaseReadWriteTransaction *)transaction; - -// This method is used to rewrite the recipient list with a single recipient. -// It is used to reply to a "group info request", which should only be -// delivered to the requestor. -- (void)updateWithSendingToSingleGroupRecipient:(NSString *)singleGroupRecipient - transaction:(YapDatabaseReadWriteTransaction *)transaction; - -// This method is used to record a successful "read" by one recipient. -- (void)updateWithReadRecipientId:(NSString *)recipientId - readTimestamp:(uint64_t)readTimestamp - transaction:(YapDatabaseReadWriteTransaction *)transaction; - -- (nullable NSNumber *)firstRecipientReadTimestamp; - -- (NSString *)statusDescription; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.m b/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.m deleted file mode 100644 index a8f6cc1cb..000000000 --- a/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.m +++ /dev/null @@ -1,576 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -@import Foundation; - -#import "TSOutgoingMessage.h" -#import "TSDatabaseSecondaryIndexes.h" -#import "OWSPrimaryStorage.h" -#import "SSKEnvironment.h" -#import "TSAccountManager.h" -#import "TSAttachmentStream.h" -#import "TSQuotedMessage.h" -#import -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -BOOL AreRecipientUpdatesEnabled(void) -{ - return NO; -} - -NSString *const kTSOutgoingMessageSentRecipientAll = @"kTSOutgoingMessageSentRecipientAll"; - -NSString *NSStringForOutgoingMessageState(TSOutgoingMessageState value) -{ - switch (value) { - case TSOutgoingMessageStateSending: - return @"TSOutgoingMessageStateSending"; - case TSOutgoingMessageStateFailed: - return @"TSOutgoingMessageStateFailed"; - case TSOutgoingMessageStateSent: - return @"TSOutgoingMessageStateSent"; - } -} - -NSString *NSStringForOutgoingMessageRecipientState(OWSOutgoingMessageRecipientState value) -{ - switch (value) { - case OWSOutgoingMessageRecipientStateFailed: - return @"OWSOutgoingMessageRecipientStateFailed"; - case OWSOutgoingMessageRecipientStateSending: - return @"OWSOutgoingMessageRecipientStateSending"; - case OWSOutgoingMessageRecipientStateSkipped: - return @"OWSOutgoingMessageRecipientStateSkipped"; - case OWSOutgoingMessageRecipientStateSent: - return @"OWSOutgoingMessageRecipientStateSent"; - } -} - -@interface TSOutgoingMessageRecipientState () - -@property (atomic) OWSOutgoingMessageRecipientState state; -@property (atomic, nullable) NSNumber *deliveryTimestamp; -@property (atomic, nullable) NSNumber *readTimestamp; -@property (atomic) BOOL wasSentByUD; - -@end - -#pragma mark - - -@implementation TSOutgoingMessageRecipientState - -@end - -#pragma mark - - -@interface TSOutgoingMessage () - -@property (atomic) BOOL hasSyncedTranscript; -@property (atomic) NSString *customMessage; -@property (atomic) NSString *mostRecentFailureText; -@property (atomic) TSGroupMetaMessage groupMetaMessage; - -@end - -#pragma mark - - -@implementation TSOutgoingMessage - -- (nullable instancetype)initWithCoder:(NSCoder *)coder -{ - self = [super initWithCoder:coder]; - - if (self) { - if (!_attachmentFilenameMap) { - _attachmentFilenameMap = [NSMutableDictionary new]; - } - } - - return self; -} - -+ (YapDatabaseConnection *)dbMigrationConnection -{ - return SSKEnvironment.shared.migrationDBConnection; -} - -+ (instancetype)outgoingMessageInThread:(nullable TSThread *)thread - messageBody:(nullable NSString *)body - attachmentId:(nullable NSString *)attachmentId -{ - return [self outgoingMessageInThread:thread - messageBody:body - attachmentId:attachmentId - expiresInSeconds:0 - quotedMessage:nil - linkPreview:nil]; -} - -+ (instancetype)outgoingMessageInThread:(nullable TSThread *)thread - messageBody:(nullable NSString *)body - attachmentId:(nullable NSString *)attachmentId - expiresInSeconds:(uint32_t)expiresInSeconds -{ - return [self outgoingMessageInThread:thread - messageBody:body - attachmentId:attachmentId - expiresInSeconds:expiresInSeconds - quotedMessage:nil - linkPreview:nil]; -} - -+ (instancetype)outgoingMessageInThread:(nullable TSThread *)thread - messageBody:(nullable NSString *)body - attachmentId:(nullable NSString *)attachmentId - expiresInSeconds:(uint32_t)expiresInSeconds - quotedMessage:(nullable TSQuotedMessage *)quotedMessage - linkPreview:(nullable OWSLinkPreview *)linkPreview -{ - NSMutableArray *attachmentIds = [NSMutableArray new]; - if (attachmentId) { - [attachmentIds addObject:attachmentId]; - } - - // MJK TODO remove SenderTimestamp? - return [[TSOutgoingMessage alloc] initOutgoingMessageWithTimestamp:[NSDate ows_millisecondTimeStamp] - inThread:thread - messageBody:body - attachmentIds:attachmentIds - expiresInSeconds:expiresInSeconds - expireStartedAt:0 - isVoiceMessage:NO - groupMetaMessage:TSGroupMetaMessageUnspecified - quotedMessage:quotedMessage - linkPreview:linkPreview - openGroupInvitationName:nil - openGroupInvitationURL:nil - serverHash:nil]; -} - -+ (instancetype)outgoingMessageInThread:(nullable TSThread *)thread - groupMetaMessage:(TSGroupMetaMessage)groupMetaMessage - expiresInSeconds:(uint32_t)expiresInSeconds; -{ - // MJK TODO remove SenderTimestamp? - return [[TSOutgoingMessage alloc] initOutgoingMessageWithTimestamp:[NSDate ows_millisecondTimeStamp] - inThread:thread - messageBody:nil - attachmentIds:[NSMutableArray new] - expiresInSeconds:expiresInSeconds - expireStartedAt:0 - isVoiceMessage:NO - groupMetaMessage:groupMetaMessage - quotedMessage:nil - linkPreview:nil - openGroupInvitationName:nil - openGroupInvitationURL:nil - serverHash:nil]; -} - -- (instancetype)initOutgoingMessageWithTimestamp:(uint64_t)timestamp - inThread:(nullable TSThread *)thread - messageBody:(nullable NSString *)body - attachmentIds:(NSMutableArray *)attachmentIds - expiresInSeconds:(uint32_t)expiresInSeconds - expireStartedAt:(uint64_t)expireStartedAt - isVoiceMessage:(BOOL)isVoiceMessage - groupMetaMessage:(TSGroupMetaMessage)groupMetaMessage - quotedMessage:(nullable TSQuotedMessage *)quotedMessage - linkPreview:(nullable OWSLinkPreview *)linkPreview - openGroupInvitationName:(nullable NSString *)openGroupInvitationName - openGroupInvitationURL:(nullable NSString *)openGroupInvitationURL - serverHash:(nullable NSString *)serverHash -{ - self = [super initMessageWithTimestamp:timestamp - inThread:thread - messageBody:body - attachmentIds:attachmentIds - expiresInSeconds:expiresInSeconds - expireStartedAt:expireStartedAt - quotedMessage:quotedMessage - linkPreview:linkPreview - openGroupInvitationName:openGroupInvitationName - openGroupInvitationURL:openGroupInvitationURL - serverHash:serverHash]; - if (!self) { - return self; - } - - _hasSyncedTranscript = NO; - - if ([thread isKindOfClass:TSGroupThread.class]) { - // Unless specified, we assume group messages are "Delivery" i.e. normal messages. - if (groupMetaMessage == TSGroupMetaMessageUnspecified) { - _groupMetaMessage = TSGroupMetaMessageDeliver; - } else { - _groupMetaMessage = groupMetaMessage; - } - } else { - // Specifying a group meta message only makes sense for Group threads - _groupMetaMessage = TSGroupMetaMessageUnspecified; - } - - _isVoiceMessage = isVoiceMessage; - - _attachmentFilenameMap = [NSMutableDictionary new]; - - // New outgoing messages should immediately determine their - // recipient list from current thread state. - NSMutableDictionary *recipientStateMap = [NSMutableDictionary new]; - NSArray *recipientIds = [thread recipientIdentifiers]; - for (NSString *recipientId in recipientIds) { - TSOutgoingMessageRecipientState *recipientState = [TSOutgoingMessageRecipientState new]; - recipientState.state = OWSOutgoingMessageRecipientStateSending; - recipientStateMap[recipientId] = recipientState; - } - self.recipientStateMap = [recipientStateMap copy]; - - return self; -} - -- (void)dealloc -{ - [self removeTemporaryAttachments]; -} - -// Each message has the responsibility for eagerly cleaning up its attachments. -// Normally this is done in [TSMessage removeWithTransaction], but that doesn't -// apply for "transient", unsaved messages (i.e. shouldBeSaved == NO). These -// messages should clean up their attachments upon deallocation. -- (void)removeTemporaryAttachments -{ - if (self.shouldBeSaved) { - // Message is not transient; no need to clean up attachments. - return; - } - NSArray *_Nullable attachmentIds = self.attachmentIds; - if (attachmentIds.count < 1) { - return; - } - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - for (NSString *attachmentId in attachmentIds) { - // We need to fetch each attachment, since [TSAttachment removeWithTransaction:] does important work. - TSAttachment *_Nullable attachment = - [TSAttachment fetchObjectWithUniqueID:attachmentId transaction:transaction]; - if (!attachment) { - continue; - } - [attachment removeWithTransaction:transaction]; - }; - }]; -} - -#pragma mark - - -- (TSOutgoingMessageState)messageState -{ - return [TSOutgoingMessage messageStateForRecipientStates:self.recipientStateMap.allValues]; -} - -- (BOOL)wasDeliveredToAnyRecipient -{ - return [self deliveredRecipientIds].count > 0; -} - -- (BOOL)wasSentToAnyRecipient -{ - return [self sentRecipientIds].count > 0; -} - -+ (TSOutgoingMessageState)messageStateForRecipientStates:(NSArray *)recipientStates -{ - // If there are any "sending" recipients, consider this message "sending". - BOOL hasFailed = NO; - for (TSOutgoingMessageRecipientState *recipientState in recipientStates) { - if (recipientState.state == OWSOutgoingMessageRecipientStateSending) { - return TSOutgoingMessageStateSending; - } else if (recipientState.state == OWSOutgoingMessageRecipientStateFailed) { - hasFailed = YES; - } - } - - // If there are any "failed" recipients, consider this message "failed". - if (hasFailed) { - return TSOutgoingMessageStateFailed; - } - - // Otherwise, consider the message "sent". - // - // NOTE: This includes messages with no recipients. - return TSOutgoingMessageStateSent; -} - -- (BOOL)shouldBeSaved -{ - if (self.groupMetaMessage == TSGroupMetaMessageDeliver || self.groupMetaMessage == TSGroupMetaMessageUnspecified) { - return YES; - } - - return NO; -} - -- (void)saveWithTransaction:(YapDatabaseReadWriteTransaction *)transaction -{ - if (!self.shouldBeSaved) { - // There's no need to save this message, since it's not displayed to the user. - // - // Should we find a need to save this in the future, we need to exclude any non-serializable properties. - return; - } - - [super saveWithTransaction:transaction]; -} - -- (BOOL)shouldStartExpireTimerWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - // It's not clear if we should wait until _all_ recipients have reached "sent or later" - // (which could never occur if one group member is unregistered) or only wait until - // the first recipient has reached "sent or later" (which could cause partially delivered - // messages to expire). For now, we'll do the latter. - // - // TODO: Revisit this decision. - - if (!self.isExpiringMessage) { - return NO; - } else if (self.messageState == TSOutgoingMessageStateSent) { - return YES; - } else { - if (self.expireStartedAt > 0) { - // Our initial migration to populate the recipient state map was incomplete. It's since been - // addressed, but it's possible there are edge cases where a previously sent message would - // no longer be considered sent. - // So here we take extra care not to stop any expiration that had previously started. - // This can also happen under normal cirumstances with an outgoing group message. - return YES; - } - - return NO; - } -} - -+ (nullable instancetype)findMessageWithTimestamp:(uint64_t)timestamp -{ - __block TSOutgoingMessage *result; - [LKStorage readWithBlock:^(YapDatabaseReadTransaction *transaction) { - [TSDatabaseSecondaryIndexes enumerateMessagesWithTimestamp:timestamp withBlock:^(NSString *collection, NSString *key, BOOL *stop) { - TSInteraction *interaction = [TSInteraction fetchObjectWithUniqueID:key transaction:transaction]; - if ([interaction isKindOfClass:[TSOutgoingMessage class]]) { - result = (TSOutgoingMessage *)interaction; - } - } usingTransaction:transaction]; - }]; - return result; -} - -- (OWSInteractionType)interactionType -{ - return OWSInteractionType_OutgoingMessage; -} - -- (NSArray *)recipientIds -{ - return self.recipientStateMap.allKeys; -} - -- (NSArray *)sendingRecipientIds -{ - NSMutableArray *result = [NSMutableArray new]; - for (NSString *recipientId in self.recipientStateMap) { - TSOutgoingMessageRecipientState *recipientState = self.recipientStateMap[recipientId]; - if (recipientState.state == OWSOutgoingMessageRecipientStateSending) { - [result addObject:recipientId]; - } - } - return result; -} - -- (NSArray *)sentRecipientIds -{ - NSMutableArray *result = [NSMutableArray new]; - for (NSString *recipientId in self.recipientStateMap) { - TSOutgoingMessageRecipientState *recipientState = self.recipientStateMap[recipientId]; - if (recipientState.state == OWSOutgoingMessageRecipientStateSent) { - [result addObject:recipientId]; - } - } - return result; -} - -- (NSArray *)deliveredRecipientIds -{ - NSMutableArray *result = [NSMutableArray new]; - for (NSString *recipientId in self.recipientStateMap) { - TSOutgoingMessageRecipientState *recipientState = self.recipientStateMap[recipientId]; - if (recipientState.deliveryTimestamp != nil) { - [result addObject:recipientId]; - } - } - return result; -} - -- (NSArray *)readRecipientIds -{ - NSMutableArray *result = [NSMutableArray new]; - for (NSString *recipientId in self.recipientStateMap) { - TSOutgoingMessageRecipientState *recipientState = self.recipientStateMap[recipientId]; - if (recipientState.readTimestamp != nil) { - [result addObject:recipientId]; - } - } - return result; -} - -- (NSUInteger)sentRecipientsCount -{ - return [self.recipientStateMap.allValues - filteredArrayUsingPredicate:[NSPredicate - predicateWithBlock:^BOOL(TSOutgoingMessageRecipientState *recipientState, - NSDictionary *_Nullable bindings) { - return recipientState.state == OWSOutgoingMessageRecipientStateSent; - }]] - .count; -} - -- (nullable TSOutgoingMessageRecipientState *)recipientStateForRecipientId:(NSString *)recipientId -{ - TSOutgoingMessageRecipientState *_Nullable result = self.recipientStateMap[recipientId]; - return [result copy]; -} - -#pragma mark - Update With... Methods - -- (void)updateOpenGroupServerID:(uint64_t)openGroupServerID serverTimeStamp:(uint64_t)timestamp -{ - self.openGroupServerMessageID = openGroupServerID; - [super updateTimestamp:timestamp]; -} - -- (void)updateWithSendingError:(NSError *)error transaction:(YapDatabaseReadWriteTransaction *)transaction -{ - [self applyChangeToSelfAndLatestCopy:transaction - changeBlock:^(TSOutgoingMessage *message) { - // Mark any "sending" recipients as "failed." - for (TSOutgoingMessageRecipientState *recipientState in message.recipientStateMap.allValues) { - if (recipientState.state == OWSOutgoingMessageRecipientStateSending) { - recipientState.state = OWSOutgoingMessageRecipientStateFailed; - } - } - [message setMostRecentFailureText:error.localizedDescription]; - }]; -} - -- (void)updateWithAllSendingRecipientsMarkedAsFailedWithTansaction:(YapDatabaseReadWriteTransaction *)transaction -{ - [self applyChangeToSelfAndLatestCopy:transaction - changeBlock:^(TSOutgoingMessage *message) { - // Mark any "sending" recipients as "failed." - for (TSOutgoingMessageRecipientState *recipientState in message.recipientStateMap - .allValues) { - if (recipientState.state == OWSOutgoingMessageRecipientStateSending) { - recipientState.state = OWSOutgoingMessageRecipientStateFailed; - } - } - }]; -} - -- (void)updateWithMarkingAllUnsentRecipientsAsSendingWithTransaction:(YapDatabaseReadWriteTransaction *)transaction -{ - [self applyChangeToSelfAndLatestCopy:transaction - changeBlock:^(TSOutgoingMessage *message) { - // Mark any "sending" recipients as "failed." - for (TSOutgoingMessageRecipientState *recipientState in message.recipientStateMap - .allValues) { - if (recipientState.state == OWSOutgoingMessageRecipientStateFailed) { - recipientState.state = OWSOutgoingMessageRecipientStateSending; - } - } - }]; -} - -- (void)updateWithCustomMessage:(NSString *)customMessage transaction:(YapDatabaseReadWriteTransaction *)transaction -{ - [self applyChangeToSelfAndLatestCopy:transaction - changeBlock:^(TSOutgoingMessage *message) { - [message setCustomMessage:customMessage]; - }]; -} - -- (void)updateWithCustomMessage:(NSString *)customMessage -{ - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [self updateWithCustomMessage:customMessage transaction:transaction]; - }]; -} - -- (void)updateWithSentRecipient:(NSString *)recipientId - wasSentByUD:(BOOL)wasSentByUD - transaction:(YapDatabaseReadWriteTransaction *)transaction { - [self applyChangeToSelfAndLatestCopy:transaction - changeBlock:^(TSOutgoingMessage *message) { - TSOutgoingMessageRecipientState *_Nullable recipientState - = message.recipientStateMap[recipientId]; - if (!recipientState) { return; } - recipientState.state = OWSOutgoingMessageRecipientStateSent; - recipientState.wasSentByUD = wasSentByUD; - }]; -} - -- (void)updateWithSkippedRecipient:(NSString *)recipientId transaction:(YapDatabaseReadWriteTransaction *)transaction -{ - [self applyChangeToSelfAndLatestCopy:transaction - changeBlock:^(TSOutgoingMessage *message) { - TSOutgoingMessageRecipientState *_Nullable recipientState - = message.recipientStateMap[recipientId]; - if (!recipientState) { return; } - recipientState.state = OWSOutgoingMessageRecipientStateSkipped; - }]; -} - -- (void)updateWithDeliveredRecipient:(NSString *)recipientId - deliveryTimestamp:(NSNumber *_Nullable)deliveryTimestamp - transaction:(YapDatabaseReadWriteTransaction *)transaction -{ - // If delivery notification doesn't include timestamp, use "now" as an estimate. - if (!deliveryTimestamp) { - deliveryTimestamp = @([NSDate ows_millisecondTimeStamp]); - } - - [self applyChangeToSelfAndLatestCopy:transaction - changeBlock:^(TSOutgoingMessage *message) { - TSOutgoingMessageRecipientState *_Nullable recipientState - = message.recipientStateMap[recipientId]; - if (!recipientState) { return; } - recipientState.state = OWSOutgoingMessageRecipientStateSent; - recipientState.deliveryTimestamp = deliveryTimestamp; - }]; -} - -- (void)updateWithReadRecipientId:(NSString *)recipientId - readTimestamp:(uint64_t)readTimestamp - transaction:(YapDatabaseReadWriteTransaction *)transaction -{ - [self applyChangeToSelfAndLatestCopy:transaction - changeBlock:^(TSOutgoingMessage *message) { - TSOutgoingMessageRecipientState *_Nullable recipientState = message.recipientStateMap[recipientId]; - if (!recipientState) { return; } - recipientState.state = OWSOutgoingMessageRecipientStateSent; - recipientState.readTimestamp = @(readTimestamp); - }]; -} - -#pragma mark - Delete - -- (void)updateForDeletionWithTransaction:(YapDatabaseReadWriteTransaction *)transaction -{ - [super updateForDeletionWithTransaction:transaction]; - [self removeWithTransaction:transaction]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift index 72713f35f..fdd50732c 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift @@ -106,15 +106,4 @@ public extension VisibleMessage.VMQuote { attachmentId: quote.attachmentId ) } - - static func from(_ quote: TSQuotedMessage?) -> VisibleMessage.VMQuote? { - guard let quote = quote else { return nil } - - return VisibleMessage.VMQuote( - timestamp: quote.timestamp, - publicKey: quote.authorId, - text: quote.body, - attachmentId: quote.quotedAttachments.first?.attachmentId - ) - } } diff --git a/SessionMessagingKit/Meta/SessionMessagingKit.h b/SessionMessagingKit/Meta/SessionMessagingKit.h index 9d306116d..12dc61212 100644 --- a/SessionMessagingKit/Meta/SessionMessagingKit.h +++ b/SessionMessagingKit/Meta/SessionMessagingKit.h @@ -12,23 +12,12 @@ FOUNDATION_EXPORT const unsigned char SessionMessagingKitVersionString[]; #import #import #import -#import -#import #import #import -#import #import #import #import -#import -#import -#import #import #import -#import -#import -#import -#import -#import #import #import diff --git a/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift b/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift index 47c687751..20e033815 100644 --- a/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift +++ b/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift @@ -7,6 +7,7 @@ import MobileCoreServices import PromiseKit import AVFoundation +import SessionUtilitiesKit public enum SignalAttachmentError: Error { case missingData @@ -104,62 +105,30 @@ public enum TSImageQuality: UInt { // [SignalAttachment hasError] will be true for non-valid attachments. // // TODO: Perhaps do conversion off the main thread? -@objc -public class SignalAttachment: NSObject { +public class SignalAttachment: Equatable, Hashable { // MARK: Properties - @objc public let dataSource: DataSource - - @objc public var captionText: String? + public var linkPreviewDraft: LinkPreviewDraft? - @objc - public var linkPreviewDraft: OWSLinkPreviewDraft? - - @objc - public var data: Data { - return dataSource.data() - } - - @objc - public var dataLength: UInt { - return dataSource.dataLength() - } - - @objc - public var dataUrl: URL? { - return dataSource.dataUrl() - } - - @objc - public var sourceFilename: String? { - return dataSource.sourceFilename?.filterFilename() - } - - @objc - public var isValidImage: Bool { - return dataSource.isValidImage() - } - - @objc - public var isValidVideo: Bool { - return dataSource.isValidVideo() - } + public var data: Data { return dataSource.data() } + public var dataLength: UInt { return dataSource.dataLength() } + public var dataUrl: URL? { return dataSource.dataUrl() } + public var sourceFilename: String? { return dataSource.sourceFilename?.filterFilename() } + public var isValidImage: Bool { return dataSource.isValidImage() } + public var isValidVideo: Bool { return dataSource.isValidVideo() } // This flag should be set for text attachments that can be sent as text messages. - @objc public var isConvertibleToTextMessage = false // This flag should be set for attachments that can be sent as contact shares. - @objc public var isConvertibleToContactShare = false // Attachment types are identified using UTIs. // // See: https://developer.apple.com/library/content/documentation/Miscellaneous/Reference/UTIRef/Articles/System-DeclaredUniformTypeIdentifiers.html - @objc public let dataUTI: String public var error: SignalAttachmentError? { @@ -174,7 +143,6 @@ public class SignalAttachment: NSObject { private var cachedImage: UIImage? private var cachedVideoPreview: UIImage? - @objc private(set) public var isVoiceMessage = false // MARK: Constants @@ -187,28 +155,21 @@ public class SignalAttachment: NSObject { // MARK: - @objc public static let maxAttachmentsAllowed: Int = 32 // MARK: Constructor // This method should not be called directly; use the factory // methods instead. - @objc private init(dataSource: DataSource, dataUTI: String) { self.dataSource = dataSource self.dataUTI = dataUTI - super.init() } // MARK: Methods - @objc - public var hasError: Bool { - return error != nil - } + public var hasError: Bool { return error != nil } - @objc public var errorName: String? { guard let error = error else { // This method should only be called if there is an error. @@ -218,7 +179,6 @@ public class SignalAttachment: NSObject { return "\(error)" } - @objc public var localizedErrorDescription: String? { guard let error = self.error else { // This method should only be called if there is an error. @@ -231,30 +191,31 @@ public class SignalAttachment: NSObject { return "\(errorDescription)" } - @objc public class var missingDataErrorMessage: String { guard let errorDescription = SignalAttachmentError.missingData.errorDescription else { return "" } + return errorDescription } - @objc public func staticThumbnail() -> UIImage? { if isAnimatedImage { return image() - } else if isImage { + } + else if isImage { return image() - } else if isVideo { + } + else if isVideo { return videoPreview() - } else if isAudio { - return nil - } else { + } + else if isAudio { return nil } + + return nil } - @objc public func image() -> UIImage? { if let cachedImage = cachedImage { return cachedImage @@ -262,11 +223,11 @@ public class SignalAttachment: NSObject { guard let image = UIImage(data: dataSource.data()) else { return nil } + cachedImage = image return image } - @objc public func videoPreview() -> UIImage? { if let cachedVideoPreview = cachedVideoPreview { return cachedVideoPreview @@ -296,7 +257,6 @@ public class SignalAttachment: NSObject { } } - @objc public func text() -> String? { guard let text = String(data: dataSource.data(), encoding: .utf8) else { return nil @@ -307,7 +267,6 @@ public class SignalAttachment: NSObject { // Returns the MIME type for this attachment or nil if no MIME type // can be identified. - @objc public var mimeType: String { if isVoiceMessage { // Legacy iOS clients don't handle "audio/mp4" files correctly; @@ -342,7 +301,6 @@ public class SignalAttachment: NSObject { // Use the filename if known. If not, e.g. if the attachment was copy/pasted, we'll generate a filename // like: "signal-2017-04-24-095918.zip" - @objc public var filenameOrDefault: String { if let filename = sourceFilename { return filename.filterFilename() @@ -364,7 +322,6 @@ public class SignalAttachment: NSObject { // Returns the file extension for this attachment or nil if no file extension // can be identified. - @objc public var fileExtension: String? { if let filename = sourceFilename { let fileExtension = (filename as NSString).pathExtension @@ -516,7 +473,6 @@ public class SignalAttachment: NSObject { // // NOTE: The attachment returned by this method may not be valid. // Check the attachment's error property. - @objc public class func attachmentFromPasteboard() -> SignalAttachment? { guard UIPasteboard.general.numberOfItems >= 1 else { return nil @@ -583,7 +539,6 @@ public class SignalAttachment: NSObject { // // NOTE: The attachment returned by this method may not be valid. // Check the attachment's error property. - @objc private class func imageAttachment(dataSource: DataSource?, dataUTI: String, imageQuality: TSImageQuality) -> SignalAttachment { assert(dataUTI.count > 0) assert(dataSource != nil) @@ -671,7 +626,6 @@ public class SignalAttachment: NSObject { // // NOTE: The attachment returned by this method may nil or not be valid. // Check the attachment's error property. - @objc public class func imageAttachment(image: UIImage?, dataUTI: String, filename: String?, imageQuality: TSImageQuality) -> SignalAttachment { assert(dataUTI.count > 0) @@ -1058,7 +1012,6 @@ public class SignalAttachment: NSObject { // MARK: Voice Messages - @objc public class func voiceMessageAttachment(dataSource: DataSource?, dataUTI: String) -> SignalAttachment { let attachment = audioAttachment(dataSource: dataSource, dataUTI: dataUTI) attachment.isVoiceMessage = true @@ -1071,7 +1024,6 @@ public class SignalAttachment: NSObject { // // NOTE: The attachment returned by this method may not be valid. // Check the attachment's error property. - @objc public class func attachment(dataSource: DataSource?, dataUTI: String) -> SignalAttachment { return attachment(dataSource: dataSource, dataUTI: dataUTI, imageQuality: .original) } @@ -1080,7 +1032,6 @@ public class SignalAttachment: NSObject { // // NOTE: The attachment returned by this method may not be valid. // Check the attachment's error property. - @objc public class func attachment(dataSource: DataSource?, dataUTI: String, imageQuality: TSImageQuality) -> SignalAttachment { if inputImageUTISet.contains(dataUTI) { return imageAttachment(dataSource: dataSource, dataUTI: dataUTI, imageQuality: imageQuality) @@ -1093,7 +1044,6 @@ public class SignalAttachment: NSObject { } } - @objc public class func empty() -> SignalAttachment { return SignalAttachment.attachment(dataSource: DataSourceValue.emptyDataSource(), dataUTI: kUTTypeContent as String, @@ -1138,4 +1088,34 @@ public class SignalAttachment: NSObject { // Attachment is valid return attachment } + + // MARK: - Equatable + + public static func == (lhs: SignalAttachment, rhs: SignalAttachment) -> Bool { + return ( + lhs.dataSource == rhs.dataSource && + lhs.dataUTI == rhs.dataUTI && + lhs.captionText == rhs.captionText && + lhs.linkPreviewDraft == rhs.linkPreviewDraft && + lhs.isConvertibleToTextMessage == rhs.isConvertibleToTextMessage && + lhs.isConvertibleToContactShare == rhs.isConvertibleToContactShare && + lhs.cachedImage == rhs.cachedImage && + lhs.cachedVideoPreview == rhs.cachedVideoPreview && + lhs.isVoiceMessage == rhs.isVoiceMessage + ) + } + + // MARK: - Hashable + + public func hash(into hasher: inout Hasher) { + dataSource.hash(into: &hasher) + dataUTI.hash(into: &hasher) + captionText.hash(into: &hasher) + linkPreviewDraft.hash(into: &hasher) + isConvertibleToTextMessage.hash(into: &hasher) + isConvertibleToContactShare.hash(into: &hasher) + cachedImage.hash(into: &hasher) + cachedVideoPreview.hash(into: &hasher) + isVoiceMessage.hash(into: &hasher) + } } diff --git a/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachment.h b/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachment.h deleted file mode 100644 index d8f0d8936..000000000 --- a/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachment.h +++ /dev/null @@ -1,105 +0,0 @@ -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@class TSAttachmentPointer; - -typedef NS_ENUM(NSUInteger, TSAttachmentType) { - TSAttachmentTypeDefault = 0, - TSAttachmentTypeVoiceMessage = 1, -}; - -@interface TSAttachment : TSYapDatabaseObject { - -@protected - NSString *_contentType; -} - -// TSAttachment is a base class for TSAttachmentPointer (a yet-to-be-downloaded -// incoming attachment) and TSAttachmentStream (an outgoing or already-downloaded -// incoming attachment). -// -// The attachmentSchemaVersion and serverId properties only apply to -// TSAttachmentPointer, which can be distinguished by the isDownloaded -// property. -@property (atomic, readwrite) UInt64 serverId; -@property (atomic, readwrite, nullable) NSData *encryptionKey; -@property (nonatomic, readonly) NSString *contentType; -@property (atomic, readwrite) BOOL isDownloaded; -@property (nonatomic) TSAttachmentType attachmentType; -@property (nonatomic) NSString *downloadURL; - -// Though now required, may incorrectly be 0 on legacy attachments. -@property (nonatomic, readonly) UInt32 byteCount; - -// Represents the "source" filename sent or received in the protos, -// not the filename on disk. -@property (nonatomic, readonly, nullable) NSString *sourceFilename; - -#pragma mark - Media Album - -@property (nonatomic, readonly, nullable) NSString *caption; -@property (nonatomic, nullable) NSString *albumMessageId; - -#pragma mark - - -// This constructor is used for new instances of TSAttachmentPointer, -// i.e. undownloaded incoming attachments. -- (instancetype)initWithServerId:(UInt64)serverId - encryptionKey:(nullable NSData *)encryptionKey - byteCount:(UInt32)byteCount - contentType:(NSString *)contentType - sourceFilename:(nullable NSString *)sourceFilename - caption:(nullable NSString *)caption - albumMessageId:(nullable NSString *)albumMessageId; - -// This constructor is used for new instances of TSAttachmentPointer, -// i.e. undownloaded restoring attachments. -- (instancetype)initForRestoreWithUniqueId:(NSString *)uniqueId - contentType:(NSString *)contentType - sourceFilename:(nullable NSString *)sourceFilename - caption:(nullable NSString *)caption - albumMessageId:(nullable NSString *)albumMessageId; - -// This constructor is used for new instances of TSAttachmentStream -// that represent new, un-uploaded outgoing attachments. -- (instancetype)initWithContentType:(NSString *)contentType - byteCount:(UInt32)byteCount - sourceFilename:(nullable NSString *)sourceFilename - caption:(nullable NSString *)caption - albumMessageId:(nullable NSString *)albumMessageId; - -// This constructor is used for new instances of TSAttachmentStream -// that represent downloaded incoming attachments. -- (instancetype)initWithPointer:(TSAttachmentPointer *)pointer; - -- (nullable instancetype)initWithCoder:(NSCoder *)coder; - -- (void)upgradeFromAttachmentSchemaVersion:(NSUInteger)attachmentSchemaVersion; - -@property (nonatomic, readonly) BOOL isAnimated; -@property (nonatomic, readonly) BOOL isImage; -@property (nonatomic, readonly) BOOL isVideo; -@property (nonatomic, readonly) BOOL isAudio; -@property (nonatomic, readonly) BOOL isVoiceMessage; -@property (nonatomic, readonly) BOOL isVisualMedia; -@property (nonatomic, readonly) BOOL isText; -@property (nonatomic, readonly) BOOL isMicrosoftDoc; -@property (nonatomic, readonly) BOOL isOversizeText; - -+ (NSString *)emojiForMimeType:(NSString *)contentType; - -#pragma mark - Media Album - -- (nullable TSMessage *)fetchAlbumMessageWithTransaction:(YapDatabaseReadTransaction *)transaction; - -// `migrateAlbumMessageId` is only used in the migration to the new multi-attachment message scheme, -// and shouldn't be used as a general purpose setter. Instead, `albumMessageId` should be passed as -// an initializer param. -- (void)migrateAlbumMessageId:(NSString *)albumMesssageId; - - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachment.m b/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachment.m deleted file mode 100644 index 3d92cdca8..000000000 --- a/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachment.m +++ /dev/null @@ -1,280 +0,0 @@ -#import "TSAttachment.h" -#import "MIMETypeUtil.h" -#import "TSAttachmentPointer.h" -#import - -#if TARGET_OS_IPHONE -#import - -#else -#import - -#endif - -NS_ASSUME_NONNULL_BEGIN - -NSUInteger const TSAttachmentSchemaVersion = 4; - -@interface TSAttachment () - -@property (nonatomic, readonly) NSUInteger attachmentSchemaVersion; -@property (nonatomic, nullable) NSString *sourceFilename; -@property (nonatomic) NSString *contentType; - -@end - -@implementation TSAttachment - -// This constructor is used for new instances of TSAttachmentPointer, -// i.e. undownloaded incoming attachments. -- (instancetype)initWithServerId:(UInt64)serverId - encryptionKey:(nullable NSData *)encryptionKey - byteCount:(UInt32)byteCount - contentType:(NSString *)contentType - sourceFilename:(nullable NSString *)sourceFilename - caption:(nullable NSString *)caption - albumMessageId:(nullable NSString *)albumMessageId -{ - if (contentType.length < 1) { - contentType = OWSMimeTypeApplicationOctetStream; - } - - self = [super init]; - if (!self) { - return self; - } - - _serverId = serverId; - _encryptionKey = encryptionKey; - _byteCount = byteCount; - _contentType = contentType; - _sourceFilename = sourceFilename; - _caption = caption; - _albumMessageId = albumMessageId; - - _attachmentSchemaVersion = TSAttachmentSchemaVersion; - - return self; -} - -// This constructor is used for new instances of TSAttachmentPointer, -// i.e. undownloaded restoring attachments. -- (instancetype)initForRestoreWithUniqueId:(NSString *)uniqueId - contentType:(NSString *)contentType - sourceFilename:(nullable NSString *)sourceFilename - caption:(nullable NSString *)caption - albumMessageId:(nullable NSString *)albumMessageId -{ - if (contentType.length < 1) { - contentType = OWSMimeTypeApplicationOctetStream; - } - - // If saved, this AttachmentPointer would replace the AttachmentStream in the attachments collection. - // However we only use this AttachmentPointer should only be used during the export process so it - // won't be saved until we restore the backup (when there will be no AttachmentStream to replace). - self = [super initWithUniqueId:uniqueId]; - if (!self) { - return self; - } - - _contentType = contentType; - _sourceFilename = sourceFilename; - _caption = caption; - _albumMessageId = albumMessageId; - - _attachmentSchemaVersion = TSAttachmentSchemaVersion; - - return self; -} - -// This constructor is used for new instances of TSAttachmentStream -// that represent new, un-uploaded outgoing attachments. -- (instancetype)initWithContentType:(NSString *)contentType - byteCount:(UInt32)byteCount - sourceFilename:(nullable NSString *)sourceFilename - caption:(nullable NSString *)caption - albumMessageId:(nullable NSString *)albumMessageId -{ - if (contentType.length < 1) { - contentType = OWSMimeTypeApplicationOctetStream; - } - - self = [super init]; - if (!self) { - return self; - } - - _contentType = contentType; - _byteCount = byteCount; - _sourceFilename = sourceFilename; - _caption = caption; - _albumMessageId = albumMessageId; - - _attachmentSchemaVersion = TSAttachmentSchemaVersion; - - return self; -} - -// This constructor is used for new instances of TSAttachmentStream -// that represent downloaded incoming attachments. -- (instancetype)initWithPointer:(TSAttachmentPointer *)pointer -{ - // Once saved, this AttachmentStream will replace the AttachmentPointer in the attachments collection. - self = [super initWithUniqueId:pointer.uniqueId]; - if (!self) { - return self; - } - - _serverId = pointer.serverId; - _encryptionKey = pointer.encryptionKey; - _byteCount = pointer.byteCount; - _sourceFilename = pointer.sourceFilename; - NSString *contentType = pointer.contentType; - if (contentType.length < 1) { - contentType = OWSMimeTypeApplicationOctetStream; - } - _contentType = contentType; - _caption = pointer.caption; - _albumMessageId = pointer.albumMessageId; - - _attachmentSchemaVersion = TSAttachmentSchemaVersion; - - return self; -} - -- (nullable instancetype)initWithCoder:(NSCoder *)coder -{ - self = [super initWithCoder:coder]; - if (!self) { - return self; - } - - if (_attachmentSchemaVersion < TSAttachmentSchemaVersion) { - [self upgradeFromAttachmentSchemaVersion:_attachmentSchemaVersion]; - _attachmentSchemaVersion = TSAttachmentSchemaVersion; - } - - if (!_sourceFilename) { - // renamed _filename to _sourceFilename - _sourceFilename = [coder decodeObjectForKey:@"filename"]; - } - - if (_contentType.length < 1) { - _contentType = OWSMimeTypeApplicationOctetStream; - } - - return self; -} - -- (void)upgradeFromAttachmentSchemaVersion:(NSUInteger)attachmentSchemaVersion -{ - // This method is overridden by the base classes TSAttachmentPointer and - // TSAttachmentStream. -} - -+ (NSString *)collection { - return @"TSAttachements"; -} - -- (NSString *)description { - NSString *attachmentString = NSLocalizedString(@"ATTACHMENT", nil); - - if ([MIMETypeUtil isAudio:self.contentType]) { - // a missing filename is the legacy way to determine if an audio attachment is - // a voice note vs. other arbitrary audio attachments. - if (self.isVoiceMessage || !self.sourceFilename || self.sourceFilename.length == 0) { - attachmentString = NSLocalizedString(@"ATTACHMENT_TYPE_VOICE_MESSAGE", - @"Short text label for a voice message attachment, used for thread preview and on the lock screen"); - return [NSString stringWithFormat:@"🎙️ %@", attachmentString]; - } - } - - return [NSString stringWithFormat:@"%@ %@", [TSAttachment emojiForMimeType:self.contentType], attachmentString]; -} - -+ (NSString *)emojiForMimeType:(NSString *)contentType -{ - if ([MIMETypeUtil isImage:contentType]) { - return @"📷"; - } else if ([MIMETypeUtil isVideo:contentType]) { - return @"🎥"; - } else if ([MIMETypeUtil isAudio:contentType]) { - return @"🎧"; - } else if ([MIMETypeUtil isAnimated:contentType]) { - return @"🎡"; - } else { - return @"📎"; - } -} - -- (BOOL)isImage -{ - return [MIMETypeUtil isImage:self.contentType]; -} - -- (BOOL)isVideo -{ - return [MIMETypeUtil isVideo:self.contentType]; -} - -- (BOOL)isAudio -{ - return [MIMETypeUtil isAudio:self.contentType]; -} - -- (BOOL)isAnimated -{ - return [MIMETypeUtil isAnimated:self.contentType]; -} - -- (BOOL)isVoiceMessage -{ - return self.attachmentType == TSAttachmentTypeVoiceMessage; -} - -- (BOOL)isVisualMedia -{ - return [MIMETypeUtil isVisualMedia:self.contentType]; -} - -- (BOOL)isText { - return [MIMETypeUtil isText:self.contentType]; -} - -- (BOOL)isMicrosoftDoc { - return [MIMETypeUtil isMicrosoftDoc:self.contentType]; -} - -- (BOOL)isOversizeText -{ - return [self.contentType isEqualToString:OWSMimeTypeOversizeTextMessage]; -} - -- (nullable NSString *)sourceFilename -{ - return _sourceFilename.filterFilename; -} - -- (NSString *)contentType -{ - return _contentType.filterFilename; -} - -#pragma mark - Media Album - -- (nullable TSMessage *)fetchAlbumMessageWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - if (self.albumMessageId == nil) { - return nil; - } - return [TSMessage fetchObjectWithUniqueID:self.albumMessageId transaction:transaction]; -} - -- (void)migrateAlbumMessageId:(NSString *)albumMesssageId -{ - self.albumMessageId = albumMesssageId; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentPointer+Conversion.swift b/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentPointer+Conversion.swift deleted file mode 100644 index 41fa7aa8e..000000000 --- a/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentPointer+Conversion.swift +++ /dev/null @@ -1,24 +0,0 @@ - -extension TSAttachmentPointer { - - public static func from(_ attachment: VisibleMessage.Attachment) -> TSAttachmentPointer { - let kind: TSAttachmentType - switch attachment.kind! { - case .generic: kind = .default - case .voiceMessage: kind = .voiceMessage - } - let result = TSAttachmentPointer( - serverId: 0, - key: attachment.key, - digest: attachment.digest, - byteCount: UInt32(attachment.sizeInBytes!), - contentType: attachment.contentType!, - sourceFilename: attachment.fileName, - caption: attachment.caption, - albumMessageId: nil, - attachmentType: kind, - mediaSize: attachment.size!) - result.downloadURL = attachment.url! - return result - } -} diff --git a/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentPointer.h b/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentPointer.h deleted file mode 100644 index f72a8178f..000000000 --- a/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentPointer.h +++ /dev/null @@ -1,72 +0,0 @@ -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@class OWSBackupFragment; -@class SNProtoAttachmentPointer; -@class TSAttachmentStream; -@class TSMessage; - -typedef NS_ENUM(NSUInteger, TSAttachmentPointerType) { - TSAttachmentPointerTypeUnknown = 0, - TSAttachmentPointerTypeIncoming = 1, - TSAttachmentPointerTypeRestoring = 2, -}; - -typedef NS_ENUM(NSUInteger, TSAttachmentPointerState) { - TSAttachmentPointerStateEnqueued = 0, - TSAttachmentPointerStateDownloading = 1, - TSAttachmentPointerStateFailed = 2, -}; - -/** - * A TSAttachmentPointer is a yet-to-be-downloaded attachment. - */ -@interface TSAttachmentPointer : TSAttachment - -@property (nonatomic) TSAttachmentPointerType pointerType; -@property (atomic) TSAttachmentPointerState state; -@property (nullable, atomic) NSString *mostRecentFailureLocalizedText; - -// Though now required, `digest` may be null for pre-existing records or from -// messages received from other clients -@property (nullable, nonatomic, readonly) NSData *digest; - -@property (nonatomic, readonly) CGSize mediaSize; - -// Optional property. Only set for attachments which need "lazy backup restore." -@property (nonatomic, nullable) NSString *lazyRestoreFragmentId; - -- (nullable instancetype)initWithCoder:(NSCoder *)coder NS_DESIGNATED_INITIALIZER; - -- (instancetype)initWithServerId:(UInt64)serverId - key:(nullable NSData *)key - digest:(nullable NSData *)digest - byteCount:(UInt32)byteCount - contentType:(NSString *)contentType - sourceFilename:(nullable NSString *)sourceFilename - caption:(nullable NSString *)caption - albumMessageId:(nullable NSString *)albumMessageId - attachmentType:(TSAttachmentType)attachmentType - mediaSize:(CGSize)mediaSize NS_DESIGNATED_INITIALIZER; - -- (instancetype)initForRestoreWithAttachmentStream:(TSAttachmentStream *)attachmentStream NS_DESIGNATED_INITIALIZER; - -+ (nullable TSAttachmentPointer *)attachmentPointerFromProto:(SNProtoAttachmentPointer *)attachmentProto - albumMessage:(nullable TSMessage *)message; - -+ (NSArray *)attachmentPointersFromProtos: - (NSArray *)attachmentProtos - albumMessage:(TSMessage *)message; - -// Non-nil for attachments which need "lazy backup restore." -- (nullable OWSBackupFragment *)lazyRestoreFragment; - -// Marks attachment as needing "lazy backup restore." -- (void)markForLazyRestoreWithFragment:(OWSBackupFragment *)lazyRestoreFragment - transaction:(YapDatabaseReadWriteTransaction *)transaction; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentPointer.m b/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentPointer.m deleted file mode 100644 index 6a675fa64..000000000 --- a/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentPointer.m +++ /dev/null @@ -1,206 +0,0 @@ -#import "TSAttachmentPointer.h" -#import "TSAttachmentStream.h" -#import -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface TSAttachmentStream (TSAttachmentPointer) - -- (CGSize)cachedMediaSize; - -@end - -#pragma mark - - -@implementation TSAttachmentPointer - -- (nullable instancetype)initWithCoder:(NSCoder *)coder -{ - self = [super initWithCoder:coder]; - if (!self) { - return self; - } - - // A TSAttachmentPointer is a yet-to-be-downloaded attachment. - // If this is an old TSAttachmentPointer from another session, - // we know that it failed to complete before the session completed. - if (![coder containsValueForKey:@"state"]) { - _state = TSAttachmentPointerStateFailed; - } - - if (_pointerType == TSAttachmentPointerTypeUnknown) { - _pointerType = TSAttachmentPointerTypeIncoming; - } - - return self; -} - -- (instancetype)initWithServerId:(UInt64)serverId - key:(nullable NSData *)key - digest:(nullable NSData *)digest - byteCount:(UInt32)byteCount - contentType:(NSString *)contentType - sourceFilename:(nullable NSString *)sourceFilename - caption:(nullable NSString *)caption - albumMessageId:(nullable NSString *)albumMessageId - attachmentType:(TSAttachmentType)attachmentType - mediaSize:(CGSize)mediaSize -{ - self = [super initWithServerId:serverId - encryptionKey:key - byteCount:byteCount - contentType:contentType - sourceFilename:sourceFilename - caption:caption - albumMessageId:albumMessageId]; - if (!self) { - return self; - } - - _digest = digest; - _state = TSAttachmentPointerStateEnqueued; - self.attachmentType = attachmentType; - _pointerType = TSAttachmentPointerTypeIncoming; - _mediaSize = mediaSize; - - return self; -} - -- (instancetype)initForRestoreWithAttachmentStream:(TSAttachmentStream *)attachmentStream -{ - self = [super initForRestoreWithUniqueId:attachmentStream.uniqueId - contentType:attachmentStream.contentType - sourceFilename:attachmentStream.sourceFilename - caption:attachmentStream.caption - albumMessageId:attachmentStream.albumMessageId]; - if (!self) { - return self; - } - - _state = TSAttachmentPointerStateEnqueued; - self.attachmentType = attachmentStream.attachmentType; - _pointerType = TSAttachmentPointerTypeRestoring; - _mediaSize = (attachmentStream.shouldHaveImageSize ? attachmentStream.cachedMediaSize : CGSizeZero); - - return self; -} - -+ (nullable TSAttachmentPointer *)attachmentPointerFromProto:(SNProtoAttachmentPointer *)attachmentProto - albumMessage:(nullable TSMessage *)albumMessage -{ - if (attachmentProto.id < 1) { - return nil; - } - - NSString *_Nullable fileName = attachmentProto.fileName; - NSString *_Nullable contentType = attachmentProto.contentType; - if (contentType.length < 1) { - // Content type might not set if the sending client can't - // infer a MIME type from the file extension. - NSString *_Nullable fileExtension = [fileName pathExtension].lowercaseString; - if (fileExtension.length > 0) { - contentType = [MIMETypeUtil mimeTypeForFileExtension:fileExtension]; - } - if (contentType.length < 1) { - contentType = OWSMimeTypeApplicationOctetStream; - } - } - - // digest will be empty for old clients. - NSData *_Nullable digest = attachmentProto.hasDigest ? attachmentProto.digest : nil; - - TSAttachmentType attachmentType = TSAttachmentTypeDefault; - if ([attachmentProto hasFlags]) { - UInt32 flags = attachmentProto.flags; - if ((flags & (UInt32)SNProtoAttachmentPointerFlagsVoiceMessage) > 0) { - attachmentType = TSAttachmentTypeVoiceMessage; - } - } - NSString *_Nullable caption; - if (attachmentProto.hasCaption) { - caption = attachmentProto.caption; - } - - CGSize mediaSize = CGSizeZero; - if (attachmentProto.hasWidth && attachmentProto.hasHeight && attachmentProto.width > 0 - && attachmentProto.height > 0) { - mediaSize = CGSizeMake(attachmentProto.width, attachmentProto.height); - } - - TSAttachmentPointer *pointer = [[TSAttachmentPointer alloc] initWithServerId:attachmentProto.id - key:attachmentProto.key - digest:digest - byteCount:attachmentProto.size - contentType:contentType - sourceFilename:fileName - caption:caption - albumMessageId:0 - attachmentType:attachmentType - mediaSize:mediaSize]; - pointer.downloadURL = attachmentProto.url; - - return pointer; -} - -+ (NSArray *)attachmentPointersFromProtos:(NSArray *)attachmentProtos - albumMessage:(TSMessage *)albumMessage -{ - NSMutableArray *attachmentPointers = [NSMutableArray new]; - for (SNProtoAttachmentPointer *attachmentProto in attachmentProtos) { - TSAttachmentPointer *_Nullable attachmentPointer = - [self attachmentPointerFromProto:attachmentProto albumMessage:albumMessage]; - if (attachmentPointer) { - [attachmentPointers addObject:attachmentPointer]; - } - } - return [attachmentPointers copy]; -} - -- (BOOL)isDecimalNumberText:(NSString *)text -{ - return [text componentsSeparatedByCharactersInSet:[NSCharacterSet decimalDigitCharacterSet]].count == 1; -} - -- (void)upgradeFromAttachmentSchemaVersion:(NSUInteger)attachmentSchemaVersion -{ - // Legacy instances of TSAttachmentPointer apparently used the serverId as their - // uniqueId. - if (attachmentSchemaVersion < 2 && self.serverId == 0) { - if ([self isDecimalNumberText:self.uniqueId]) { - // For legacy instances, try to parse the serverId from the uniqueId. - self.serverId = (UInt64)[self.uniqueId integerValue]; - } - } -} - -#pragma mark - Backups - -- (nullable OWSBackupFragment *)lazyRestoreFragment -{ - if (!self.lazyRestoreFragmentId) { - return nil; - } - OWSBackupFragment *_Nullable backupFragment = - [OWSBackupFragment fetchObjectWithUniqueID:self.lazyRestoreFragmentId]; - return backupFragment; -} - -- (void)markForLazyRestoreWithFragment:(OWSBackupFragment *)lazyRestoreFragment - transaction:(YapDatabaseReadWriteTransaction *)transaction -{ - if (!lazyRestoreFragment.uniqueId) { - // If metadata hasn't been saved yet, save now. - [lazyRestoreFragment saveWithTransaction:transaction]; - } - [self applyChangeToSelfAndLatestCopy:transaction - changeBlock:^(TSAttachmentPointer *attachment) { - [attachment setLazyRestoreFragmentId:lazyRestoreFragment.uniqueId]; - }]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentStream.h b/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentStream.h deleted file mode 100644 index 3625ebcf2..000000000 --- a/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentStream.h +++ /dev/null @@ -1,105 +0,0 @@ -#import -#import - -#if TARGET_OS_IPHONE -#import - -#endif - -NS_ASSUME_NONNULL_BEGIN - -@class SNProtoAttachmentPointer; -@class TSAttachmentPointer; -@class YapDatabaseReadWriteTransaction; - -typedef void (^OWSThumbnailSuccess)(UIImage *image); -typedef void (^OWSThumbnailFailure)(void); - -@interface TSAttachmentStream : TSAttachment - -- (instancetype)init NS_UNAVAILABLE; -- (instancetype)initWithContentType:(NSString *)contentType - byteCount:(UInt32)byteCount - sourceFilename:(nullable NSString *)sourceFilename - caption:(nullable NSString *)caption - albumMessageId:(nullable NSString *)albumMessageId NS_DESIGNATED_INITIALIZER; - -- (instancetype)initWithPointer:(TSAttachmentPointer *)pointer NS_DESIGNATED_INITIALIZER; -- (nullable instancetype)initWithCoder:(NSCoder *)coder NS_DESIGNATED_INITIALIZER; - -// Though now required, `digest` may be null for pre-existing records or from -// messages received from other clients -@property (nullable, nonatomic) NSData *digest; - -// This only applies for attachments being uploaded. -@property (atomic) BOOL isUploaded; - -@property (nonatomic, readonly) NSDate *creationTimestamp; - -#if TARGET_OS_IPHONE -- (nullable NSData *)validStillImageData; -#endif - -@property (nonatomic, readonly, nullable) UIImage *originalImage; -@property (nonatomic, readonly, nullable) NSString *originalFilePath; -@property (nonatomic, readonly, nullable) NSURL *originalMediaURL; - -- (NSArray *)allThumbnailPaths; - -+ (BOOL)hasThumbnailForMimeType:(NSString *)contentType; - -- (nullable NSData *)readDataFromFileAndReturnError:(NSError **)error; -- (BOOL)writeData:(NSData *)data error:(NSError **)error; -- (BOOL)writeDataSource:(DataSource *)dataSource; - -+ (void)deleteAttachments; - -+ (NSString *)attachmentsFolder; -+ (NSString *)legacyAttachmentsDirPath; -+ (NSString *)sharedDataAttachmentsDirPath; - -- (BOOL)shouldHaveImageSize; -- (CGSize)imageSize; -- (CGSize)calculateImageSize; - -- (CGFloat)audioDurationSeconds; - -+ (nullable NSError *)migrateToSharedData; - -#pragma mark - Thumbnails - -// On cache hit, the thumbnail will be returned synchronously and completion will never be invoked. -// On cache miss, nil will be returned and success will be invoked if thumbnail can be generated; -// otherwise failure will be invoked. -// -// success and failure are invoked async on main. -- (nullable UIImage *)thumbnailImageWithSizeHint:(CGSize)sizeHint - success:(OWSThumbnailSuccess)success - failure:(OWSThumbnailFailure)failure; -- (nullable UIImage *)thumbnailImageSmallWithSuccess:(OWSThumbnailSuccess)success failure:(OWSThumbnailFailure)failure; -- (nullable UIImage *)thumbnailImageMediumWithSuccess:(OWSThumbnailSuccess)success failure:(OWSThumbnailFailure)failure; -- (nullable UIImage *)thumbnailImageLargeWithSuccess:(OWSThumbnailSuccess)success failure:(OWSThumbnailFailure)failure; -- (nullable UIImage *)thumbnailImageSmallSync; - -// This method should only be invoked by OWSThumbnailService. -- (NSString *)pathForThumbnailDimensionPoints:(NSUInteger)thumbnailDimensionPoints; - -#pragma mark - Validation - -@property (nonatomic, readonly) BOOL isValidImage; -@property (nonatomic, readonly) BOOL isValidVideo; -@property (nonatomic, readonly) BOOL isValidVisualMedia; - -#pragma mark - Update With... Methods - -- (nullable TSAttachmentStream *)cloneAsThumbnail; - -#pragma mark - Protobuf - -+ (nullable SNProtoAttachmentPointer *)buildProtoForAttachmentId:(nullable NSString *)attachmentId; - -- (nullable SNProtoAttachmentPointer *)buildProto; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentStream.m b/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentStream.m deleted file mode 100644 index 11daf4eeb..000000000 --- a/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentStream.m +++ /dev/null @@ -1,816 +0,0 @@ -#import "TSAttachmentStream.h" -#import "NSData+Image.h" -#import "TSAttachmentPointer.h" -#import -#import -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -const NSUInteger kThumbnailDimensionPointsSmall = 200; -const NSUInteger kThumbnailDimensionPointsMedium = 450; -// This size is large enough to render full screen. -const NSUInteger ThumbnailDimensionPointsLarge() -{ - CGSize screenSizePoints = UIScreen.mainScreen.bounds.size; - const CGFloat kMinZoomFactor = 2.f; - return (NSUInteger)MAX(screenSizePoints.width, screenSizePoints.height) * kMinZoomFactor; -} - -typedef void (^OWSLoadedThumbnailSuccess)(OWSLoadedThumbnail *loadedThumbnail); - -@interface TSAttachmentStream () - -// We only want to generate the file path for this attachment once, so that -// changes in the file path generation logic don't break existing attachments. -@property (nullable, nonatomic) NSString *localRelativeFilePath; - -// These properties should only be accessed while synchronized on self. -@property (nullable, nonatomic) NSNumber *cachedImageWidth; -@property (nullable, nonatomic) NSNumber *cachedImageHeight; - -// This property should only be accessed on the main thread. -@property (nullable, nonatomic) NSNumber *cachedAudioDurationSeconds; - -@property (atomic, nullable) NSNumber *isValidImageCached; -@property (atomic, nullable) NSNumber *isValidVideoCached; - -@end - -#pragma mark - - -@implementation TSAttachmentStream - -- (instancetype)initWithContentType:(NSString *)contentType - byteCount:(UInt32)byteCount - sourceFilename:(nullable NSString *)sourceFilename - caption:(nullable NSString *)caption - albumMessageId:(nullable NSString *)albumMessageId -{ - self = [super initWithContentType:contentType - byteCount:byteCount - sourceFilename:sourceFilename - caption:caption - albumMessageId:albumMessageId]; - if (!self) { - return self; - } - - self.isDownloaded = YES; - // TSAttachmentStream doesn't have any "incoming vs. outgoing" - // state, but this constructor is used only for new outgoing - // attachments which haven't been uploaded yet. - _isUploaded = NO; - _creationTimestamp = [NSDate new]; - - [self ensureFilePath]; - - return self; -} - -- (instancetype)initWithPointer:(TSAttachmentPointer *)pointer -{ - // Once saved, this AttachmentStream will replace the AttachmentPointer in the attachments collection. - self = [super initWithPointer:pointer]; - if (!self) { - return self; - } - - _contentType = pointer.contentType; - self.isDownloaded = YES; - // TSAttachmentStream doesn't have any "incoming vs. outgoing" - // state, but this constructor is used only for new incoming - // attachments which don't need to be uploaded. - _isUploaded = YES; - self.attachmentType = pointer.attachmentType; - _creationTimestamp = [NSDate new]; - - [self ensureFilePath]; - - return self; -} - -- (nullable instancetype)initWithCoder:(NSCoder *)coder -{ - self = [super initWithCoder:coder]; - if (!self) { - return self; - } - - // OWS105AttachmentFilePaths will ensure the file path is saved if necessary. - [self ensureFilePath]; - - // OWS105AttachmentFilePaths will ensure the creation timestamp is saved if necessary. - if (!_creationTimestamp) { - _creationTimestamp = [NSDate new]; - } - - return self; -} - -- (void)upgradeFromAttachmentSchemaVersion:(NSUInteger)attachmentSchemaVersion -{ - [super upgradeFromAttachmentSchemaVersion:attachmentSchemaVersion]; - - if (attachmentSchemaVersion < 3) { - // We want to treat any legacy TSAttachmentStream as though - // they have already been uploaded. If it needs to be reuploaded, - // the OWSUploadingService will update this progress when the - // upload begins. - self.isUploaded = YES; - } - - if (attachmentSchemaVersion < 4) { - // Legacy image sizes don't correctly reflect image orientation. - @synchronized(self) - { - self.cachedImageWidth = nil; - self.cachedImageHeight = nil; - } - } -} - -- (void)ensureFilePath -{ - if (self.localRelativeFilePath) { - return; - } - - NSString *attachmentsFolder = [[self class] attachmentsFolder]; - NSString *filePath = [MIMETypeUtil filePathForAttachment:self.uniqueId - ofMIMEType:self.contentType - sourceFilename:self.sourceFilename - inFolder:attachmentsFolder]; - if (!filePath) { - return; - } - if (![filePath hasPrefix:attachmentsFolder]) { - return; - } - NSString *localRelativeFilePath = [filePath substringFromIndex:attachmentsFolder.length]; - if (localRelativeFilePath.length < 1) { - return; - } - - self.localRelativeFilePath = localRelativeFilePath; -} - -#pragma mark - File Management - -- (nullable NSData *)readDataFromFileAndReturnError:(NSError **)error -{ - *error = nil; - NSString *_Nullable filePath = self.originalFilePath; - if (!filePath) { - return nil; - } - return [NSData dataWithContentsOfFile:filePath options:0 error:error]; -} - -- (BOOL)writeData:(NSData *)data error:(NSError **)error -{ - *error = nil; - NSString *_Nullable filePath = self.originalFilePath; - if (!filePath) { - return NO; - } - return [data writeToFile:filePath options:0 error:error]; -} - -- (BOOL)writeDataSource:(DataSource *)dataSource -{ - NSString *_Nullable filePath = self.originalFilePath; - if (!filePath) { - return NO; - } - return [dataSource writeToPath:filePath]; -} - -+ (NSString *)legacyAttachmentsDirPath -{ - return [[OWSFileSystem appDocumentDirectoryPath] stringByAppendingPathComponent:@"Attachments"]; -} - -+ (NSString *)sharedDataAttachmentsDirPath -{ - return [[OWSFileSystem appSharedDataDirectoryPath] stringByAppendingPathComponent:@"Attachments"]; -} - -+ (nullable NSError *)migrateToSharedData -{ - return [OWSFileSystem moveAppFilePath:self.legacyAttachmentsDirPath - sharedDataFilePath:self.sharedDataAttachmentsDirPath]; -} - -+ (NSString *)attachmentsFolder -{ - static NSString *attachmentsFolder = nil; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - attachmentsFolder = TSAttachmentStream.sharedDataAttachmentsDirPath; - - [OWSFileSystem ensureDirectoryExists:attachmentsFolder]; - }); - return attachmentsFolder; -} - -- (nullable NSString *)originalFilePath -{ - if (!self.localRelativeFilePath) { - return nil; - } - - return [[[self class] attachmentsFolder] stringByAppendingPathComponent:self.localRelativeFilePath]; -} - -- (nullable NSString *)legacyThumbnailPath -{ - NSString *filePath = self.originalFilePath; - if (!filePath) { - return nil; - } - - if (!self.isImage && !self.isVideo && !self.isAnimated) { - return nil; - } - - NSString *filename = filePath.lastPathComponent.stringByDeletingPathExtension; - NSString *containingDir = filePath.stringByDeletingLastPathComponent; - NSString *newFilename = [filename stringByAppendingString:@"-signal-ios-thumbnail"]; - - return [[containingDir stringByAppendingPathComponent:newFilename] stringByAppendingPathExtension:@"jpg"]; -} - -- (NSString *)thumbnailsDirPath -{ - if (!self.localRelativeFilePath) { - return nil; - } - - // Thumbnails are written to the caches directory, so that iOS can - // remove them if necessary. - NSString *dirName = [NSString stringWithFormat:@"%@-thumbnails", self.uniqueId]; - return [OWSFileSystem.cachesDirectoryPath stringByAppendingPathComponent:dirName]; -} - -- (NSString *)pathForThumbnailDimensionPoints:(NSUInteger)thumbnailDimensionPoints -{ - NSString *filename = [NSString stringWithFormat:@"thumbnail-%lu.jpg", (unsigned long)thumbnailDimensionPoints]; - return [self.thumbnailsDirPath stringByAppendingPathComponent:filename]; -} - -- (nullable NSURL *)originalMediaURL -{ - NSString *_Nullable filePath = self.originalFilePath; - if (!filePath) { - return nil; - } - return [NSURL fileURLWithPath:filePath]; -} - -- (void)removeFileWithTransaction:(YapDatabaseReadWriteTransaction *)transaction -{ - NSError *error; - - NSString *thumbnailsDirPath = self.thumbnailsDirPath; - if ([[NSFileManager defaultManager] fileExistsAtPath:thumbnailsDirPath]) { - [[NSFileManager defaultManager] removeItemAtPath:thumbnailsDirPath error:&error]; - } - - NSString *_Nullable legacyThumbnailPath = self.legacyThumbnailPath; - if (legacyThumbnailPath) { - [[NSFileManager defaultManager] removeItemAtPath:legacyThumbnailPath error:&error]; - } - - NSString *_Nullable filePath = self.originalFilePath; - if (!filePath) { - return; - } - [[NSFileManager defaultManager] removeItemAtPath:filePath error:&error]; -} - -- (void)removeWithTransaction:(YapDatabaseReadWriteTransaction *)transaction -{ - [super removeWithTransaction:transaction]; - [self removeFileWithTransaction:transaction]; -} - -- (BOOL)isValidVisualMedia -{ - if (self.isImage && self.isValidImage) { - return YES; - } - - if (self.isVideo && self.isValidVideo) { - return YES; - } - - if (self.isAnimated && self.isValidImage) { - return YES; - } - - return NO; -} - -#pragma mark - Image Validation - -- (BOOL)isValidImage -{ - BOOL result; - BOOL didUpdateCache = NO; - @synchronized(self) { - if (!self.isValidImageCached) { - self.isValidImageCached = @([NSData ows_isValidImageAtPath:self.originalFilePath - mimeType:self.contentType]); - didUpdateCache = YES; - } - result = self.isValidImageCached.boolValue; - } - - if (didUpdateCache) { - [self applyChangeAsyncToLatestCopyWithChangeBlock:^(TSAttachmentStream *latestInstance) { - latestInstance.isValidImageCached = @(result); - }]; - } - - return result; -} - -- (BOOL)isValidVideo -{ - BOOL result; - BOOL didUpdateCache = NO; - @synchronized(self) { - if (!self.isValidVideoCached) { - self.isValidVideoCached = @([OWSMediaUtils isValidVideoWithPath:self.originalFilePath]); - didUpdateCache = YES; - } - result = self.isValidVideoCached.boolValue; - } - - if (didUpdateCache) { - [self applyChangeAsyncToLatestCopyWithChangeBlock:^(TSAttachmentStream *latestInstance) { - latestInstance.isValidVideoCached = @(result); - }]; - } - - return result; -} - -#pragma mark - - -- (nullable UIImage *)originalImage -{ - if ([self isVideo]) { - return [self videoStillImage]; - } else if ([self isImage] || [self isAnimated]) { - NSURL *_Nullable mediaUrl = self.originalMediaURL; - if (!mediaUrl) { - return nil; - } - if (![self isValidImage]) { - return nil; - } - return [[UIImage alloc] initWithContentsOfFile:self.originalFilePath]; - } else { - return nil; - } -} - -- (nullable NSData *)validStillImageData -{ - if ([self isVideo]) { - return nil; - } - if ([self isAnimated]) { - return nil; - } - - if (![NSData ows_isValidImageAtPath:self.originalFilePath mimeType:self.contentType]) { - return nil; - } - - return [NSData dataWithContentsOfFile:self.originalFilePath]; -} - -+ (BOOL)hasThumbnailForMimeType:(NSString *)contentType -{ - return ([MIMETypeUtil isVideo:contentType] || [MIMETypeUtil isImage:contentType] || - [MIMETypeUtil isAnimated:contentType]); -} - -- (nullable UIImage *)videoStillImage -{ - NSError *error; - UIImage *_Nullable image = [OWSMediaUtils thumbnailForVideoAtPath:self.originalFilePath - maxDimension:ThumbnailDimensionPointsLarge() - error:&error]; - if (error || !image) { - return nil; - } - return image; -} - -+ (void)deleteAttachments -{ - NSError *error; - NSFileManager *fileManager = [NSFileManager defaultManager]; - - NSURL *fileURL = [NSURL fileURLWithPath:self.attachmentsFolder]; - NSArray *contents = - [fileManager contentsOfDirectoryAtURL:fileURL includingPropertiesForKeys:nil options:0 error:&error]; - - if (error) { - return; - } - - for (NSURL *url in contents) { - [fileManager removeItemAtURL:url error:&error]; - } -} - -- (CGSize)calculateImageSize -{ - if ([self isVideo]) { - if (![self isValidVideo]) { - return CGSizeZero; - } - return [self videoStillImage].size; - } else if ([self isImage] || [self isAnimated]) { - // imageSizeForFilePath checks validity. - return [NSData imageSizeForFilePath:self.originalFilePath mimeType:self.contentType]; - } else { - return CGSizeZero; - } -} - -- (BOOL)shouldHaveImageSize -{ - return ([self isVideo] || [self isImage] || [self isAnimated]); -} - -- (CGSize)imageSize -{ - // Avoid crash in dev mode - // OWSAssertDebug(self.shouldHaveImageSize); - - @synchronized(self) - { - if (self.cachedImageWidth && self.cachedImageHeight) { - return CGSizeMake(self.cachedImageWidth.floatValue, self.cachedImageHeight.floatValue); - } - - CGSize imageSize = [self calculateImageSize]; - if (imageSize.width <= 0 || imageSize.height <= 0) { - return CGSizeZero; - } - self.cachedImageWidth = @(imageSize.width); - self.cachedImageHeight = @(imageSize.height); - - [self applyChangeAsyncToLatestCopyWithChangeBlock:^(TSAttachmentStream *latestInstance) { - latestInstance.cachedImageWidth = @(imageSize.width); - latestInstance.cachedImageHeight = @(imageSize.height); - }]; - - return imageSize; - } -} - -- (CGSize)cachedMediaSize -{ - @synchronized(self) { - if (self.cachedImageWidth && self.cachedImageHeight) { - return CGSizeMake(self.cachedImageWidth.floatValue, self.cachedImageHeight.floatValue); - } else { - return CGSizeZero; - } - } -} - -#pragma mark - Update With... - -- (void)applyChangeAsyncToLatestCopyWithChangeBlock:(void (^)(TSAttachmentStream *))changeBlock -{ - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - NSString *collection = [TSAttachmentStream collection]; - TSAttachmentStream *latestInstance = [transaction objectForKey:self.uniqueId inCollection:collection]; - if (!latestInstance) { - // This attachment has either not yet been saved or has been deleted; do nothing. - // This isn't an error per se, but these race conditions should be - // _very_ rare. - // - // An exception is incoming group avatar updates which we don't ever save. - } else if (![latestInstance isKindOfClass:[TSAttachmentStream class]]) { - // Shouldn't occur - } else { - changeBlock(latestInstance); - - [latestInstance saveWithTransaction:transaction]; - } - }]; -} - -#pragma mark - - -- (CGFloat)calculateAudioDurationSeconds -{ - NSError *error; - AVAudioPlayer *audioPlayer = [[AVAudioPlayer alloc] initWithContentsOfURL:self.originalMediaURL error:&error]; - if (error && [error.domain isEqualToString:NSOSStatusErrorDomain] - && (error.code == kAudioFileInvalidFileError || error.code == kAudioFileStreamError_InvalidFile)) { - // Ignore "invalid audio file" errors. - return 0.f; - } - [audioPlayer prepareToPlay]; - if (!error) { - return (CGFloat)[audioPlayer duration]; - } else { - return 0; - } -} - -- (CGFloat)audioDurationSeconds -{ - if (self.cachedAudioDurationSeconds) { - return self.cachedAudioDurationSeconds.floatValue; - } - - CGFloat audioDurationSeconds = [self calculateAudioDurationSeconds]; - self.cachedAudioDurationSeconds = @(audioDurationSeconds); - - [self applyChangeAsyncToLatestCopyWithChangeBlock:^(TSAttachmentStream *latestInstance) { - latestInstance.cachedAudioDurationSeconds = @(audioDurationSeconds); - }]; - - return audioDurationSeconds; -} - -#pragma mark - Thumbnails - -- (nullable UIImage *)thumbnailImageWithSizeHint:(CGSize)sizeHint - success:(OWSThumbnailSuccess)success - failure:(OWSThumbnailFailure)failure -{ - CGFloat maxDimensionHint = MAX(sizeHint.width, sizeHint.height); - NSUInteger thumbnailDimensionPoints; - if (maxDimensionHint <= kThumbnailDimensionPointsSmall) { - thumbnailDimensionPoints = kThumbnailDimensionPointsSmall; - } else if (maxDimensionHint <= kThumbnailDimensionPointsMedium) { - thumbnailDimensionPoints = kThumbnailDimensionPointsMedium; - } else { - thumbnailDimensionPoints = ThumbnailDimensionPointsLarge(); - } - - return [self thumbnailImageWithThumbnailDimensionPoints:thumbnailDimensionPoints success:success failure:failure]; -} - -- (nullable UIImage *)thumbnailImageSmallWithSuccess:(OWSThumbnailSuccess)success failure:(OWSThumbnailFailure)failure -{ - return [self thumbnailImageWithThumbnailDimensionPoints:kThumbnailDimensionPointsSmall - success:success - failure:failure]; -} - -- (nullable UIImage *)thumbnailImageMediumWithSuccess:(OWSThumbnailSuccess)success failure:(OWSThumbnailFailure)failure -{ - return [self thumbnailImageWithThumbnailDimensionPoints:kThumbnailDimensionPointsMedium - success:success - failure:failure]; -} - -- (nullable UIImage *)thumbnailImageLargeWithSuccess:(OWSThumbnailSuccess)success failure:(OWSThumbnailFailure)failure -{ - return [self thumbnailImageWithThumbnailDimensionPoints:ThumbnailDimensionPointsLarge() - success:success - failure:failure]; -} - -- (nullable UIImage *)thumbnailImageWithThumbnailDimensionPoints:(NSUInteger)thumbnailDimensionPoints - success:(OWSThumbnailSuccess)success - failure:(OWSThumbnailFailure)failure -{ - OWSLoadedThumbnail *_Nullable loadedThumbnail; - loadedThumbnail = [self loadedThumbnailWithThumbnailDimensionPoints:thumbnailDimensionPoints - success:^(OWSLoadedThumbnail *thumbnail) { - DispatchMainThreadSafe(^{ - success(thumbnail.image); - }); - } - failure:^{ - DispatchMainThreadSafe(^{ - failure(); - }); - }]; - return loadedThumbnail.image; -} - -- (nullable OWSLoadedThumbnail *)loadedThumbnailWithThumbnailDimensionPoints:(NSUInteger)thumbnailDimensionPoints - success:(OWSLoadedThumbnailSuccess)success - failure:(OWSThumbnailFailure)failure -{ - CGSize originalSize = self.imageSize; - if (originalSize.width < 1 || originalSize.height < 1) { - // Any time we return nil from this method we have to call the failure handler - // or else the caller waits for an async thumbnail - failure(); - return nil; - } - if (originalSize.width <= thumbnailDimensionPoints || originalSize.height <= thumbnailDimensionPoints) { - // There's no point in generating a thumbnail if the original is smaller than the - // thumbnail size. - return [[OWSLoadedThumbnail alloc] initWithImage:self.originalImage filePath:self.originalFilePath]; - } - - NSString *thumbnailPath = [self pathForThumbnailDimensionPoints:thumbnailDimensionPoints]; - if ([[NSFileManager defaultManager] fileExistsAtPath:thumbnailPath]) { - UIImage *_Nullable image = [UIImage imageWithContentsOfFile:thumbnailPath]; - if (!image) { - // Any time we return nil from this method we have to call the failure handler - // or else the caller waits for an async thumbnail - failure(); - return nil; - } - return [[OWSLoadedThumbnail alloc] initWithImage:image filePath:thumbnailPath]; - } - - [OWSThumbnailService.shared ensureThumbnailForAttachment:self - thumbnailDimensionPoints:thumbnailDimensionPoints - success:success - failure:^(NSError *error) { - failure(); - }]; - return nil; -} - -- (nullable OWSLoadedThumbnail *)loadedThumbnailSmallSync -{ - dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); - - __block OWSLoadedThumbnail *_Nullable asyncLoadedThumbnail = nil; - OWSLoadedThumbnail *_Nullable syncLoadedThumbnail = nil; - syncLoadedThumbnail = [self loadedThumbnailWithThumbnailDimensionPoints:kThumbnailDimensionPointsSmall - success:^(OWSLoadedThumbnail *thumbnail) { - @synchronized(self) { - asyncLoadedThumbnail = thumbnail; - } - dispatch_semaphore_signal(semaphore); - } - failure:^{ - dispatch_semaphore_signal(semaphore); - }]; - - if (syncLoadedThumbnail) { - return syncLoadedThumbnail; - } - - // Wait up to N seconds. - dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC))); - @synchronized(self) { - return asyncLoadedThumbnail; - } -} - -- (nullable UIImage *)thumbnailImageSmallSync -{ - OWSLoadedThumbnail *_Nullable loadedThumbnail = [self loadedThumbnailSmallSync]; - if (!loadedThumbnail) { - return nil; - } - return loadedThumbnail.image; -} - -- (nullable NSData *)thumbnailDataSmallSync -{ - OWSLoadedThumbnail *_Nullable loadedThumbnail = [self loadedThumbnailSmallSync]; - if (!loadedThumbnail) { - return nil; - } - NSError *error; - NSData *_Nullable data = [loadedThumbnail dataAndReturnError:&error]; - if (error || !data) { - return nil; - } - return data; -} - -- (NSArray *)allThumbnailPaths -{ - NSMutableArray *result = [NSMutableArray new]; - - NSString *thumbnailsDirPath = self.thumbnailsDirPath; - if ([[NSFileManager defaultManager] fileExistsAtPath:thumbnailsDirPath]) { - NSError *error; - NSArray *_Nullable fileNames = - [[NSFileManager defaultManager] contentsOfDirectoryAtPath:thumbnailsDirPath error:&error]; - if (error || !fileNames) { - // Do nothing - } else { - for (NSString *fileName in fileNames) { - NSString *filePath = [thumbnailsDirPath stringByAppendingPathComponent:fileName]; - [result addObject:filePath]; - } - } - } - - NSString *_Nullable legacyThumbnailPath = self.legacyThumbnailPath; - if (legacyThumbnailPath && [[NSFileManager defaultManager] fileExistsAtPath:legacyThumbnailPath]) { - [result addObject:legacyThumbnailPath]; - } - - return result; -} - -#pragma mark - Update With... Methods - -- (nullable TSAttachmentStream *)cloneAsThumbnail -{ - if (!self.isValidVisualMedia) { - return nil; - } - - NSData *_Nullable thumbnailData = self.thumbnailDataSmallSync; - // Only some media types have thumbnails - if (!thumbnailData) { - return nil; - } - - // Copy the thumbnail to a new attachment. - NSString *thumbnailName = [NSString stringWithFormat:@"quoted-thumbnail-%@", self.sourceFilename]; - TSAttachmentStream *thumbnailAttachment = - [[TSAttachmentStream alloc] initWithContentType:OWSMimeTypeImageJpeg - byteCount:(uint32_t)thumbnailData.length - sourceFilename:thumbnailName - caption:nil - albumMessageId:nil]; - - NSError *error; - BOOL success = [thumbnailAttachment writeData:thumbnailData error:&error]; - if (!success || error) { - return nil; - } - - return thumbnailAttachment; -} - -// MARK: Protobuf serialization - -+ (nullable SNProtoAttachmentPointer *)buildProtoForAttachmentId:(nullable NSString *)attachmentId -{ - // TODO we should past in a transaction, rather than sneakily generate one in `fetch...` to make sure we're - // getting a consistent view in the message sending process. A brief glance shows it touches quite a bit of code, - // but should be straight forward. - TSAttachment *attachment = [TSAttachmentStream fetchObjectWithUniqueID:attachmentId]; - if (![attachment isKindOfClass:[TSAttachmentStream class]]) { - return nil; - } - - TSAttachmentStream *attachmentStream = (TSAttachmentStream *)attachment; - return [attachmentStream buildProto]; -} - - -- (nullable SNProtoAttachmentPointer *)buildProto -{ - SNProtoAttachmentPointerBuilder *builder = [SNProtoAttachmentPointer builderWithId:self.serverId]; - - builder.contentType = self.contentType; - - if (self.sourceFilename.length > 0) { - builder.fileName = self.sourceFilename; - } - if (self.caption.length > 0) { - builder.caption = self.caption; - } - - builder.size = self.byteCount; - builder.key = self.encryptionKey; - builder.digest = self.digest; - builder.flags = self.isVoiceMessage ? SNProtoAttachmentPointerFlagsVoiceMessage : 0; - - if (self.shouldHaveImageSize) { - CGSize imageSize = self.imageSize; - if (imageSize.width < NSIntegerMax && imageSize.height < NSIntegerMax) { - NSInteger imageWidth = (NSInteger)round(imageSize.width); - NSInteger imageHeight = (NSInteger)round(imageSize.height); - if (imageWidth > 0 && imageHeight > 0) { - builder.width = (UInt32)imageWidth; - builder.height = (UInt32)imageHeight; - } - } - } - - builder.url = self.downloadURL; - - NSError *error; - SNProtoAttachmentPointer *_Nullable attachmentProto = [builder buildAndReturnError:&error]; - if (error || !attachmentProto) { - return nil; - } - return attachmentProto; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Sending & Receiving/Link Previews/LinkPreviewDraft.swift b/SessionMessagingKit/Sending & Receiving/Link Previews/LinkPreviewDraft.swift new file mode 100644 index 000000000..92b540875 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Link Previews/LinkPreviewDraft.swift @@ -0,0 +1,27 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public struct LinkPreviewDraft: Equatable, Hashable { + public var urlString: String + public var title: String? + public var jpegImageData: Data? + + public init(urlString: String, title: String?, jpegImageData: Data? = nil) { + self.urlString = urlString + self.title = title + self.jpegImageData = jpegImageData + } + + public func isValid() -> Bool { + var hasTitle = false + + if let titleValue = title { + hasTitle = titleValue.count > 0 + } + + let hasImage = jpegImageData != nil + + return (hasTitle || hasImage) + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Link Previews/LinkPreviewError.swift b/SessionMessagingKit/Sending & Receiving/Link Previews/LinkPreviewError.swift new file mode 100644 index 000000000..145541a8c --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Link Previews/LinkPreviewError.swift @@ -0,0 +1,14 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum LinkPreviewError: Int, Error { + case invalidInput + case noPreview + case assertionFailure + case couldNotDownload + case featureDisabled + case invalidContent + case invalidMediaContent + case attachmentFailedToSave +} diff --git a/SessionMessagingKit/Sending & Receiving/Link Previews/OWSLinkPreview+Conversion.swift b/SessionMessagingKit/Sending & Receiving/Link Previews/OWSLinkPreview+Conversion.swift deleted file mode 100644 index 5bb5c3c7f..000000000 --- a/SessionMessagingKit/Sending & Receiving/Link Previews/OWSLinkPreview+Conversion.swift +++ /dev/null @@ -1,27 +0,0 @@ - -extension OWSLinkPreview { - - @objc public static func from(_ linkPreview: VisibleMessage.LinkPreview?) -> OWSLinkPreview? { - guard let linkPreview = linkPreview else { return nil } - return OWSLinkPreview(urlString: linkPreview.url!, title: linkPreview.title, imageAttachmentId: linkPreview.attachmentID) - } -} - -extension VisibleMessage.LinkPreview { - - public static func from(_ linkPreview: OWSLinkPreview?) -> VisibleMessage.LinkPreview? { - guard let linkPreview = linkPreview else { return nil } - return VisibleMessage.LinkPreview(title: linkPreview.title, url: linkPreview.urlString!, attachmentID: linkPreview.imageAttachmentId) - } - - @objc(from:using:) - public static func from(_ linkPreview: OWSLinkPreviewDraft?, using transaction: YapDatabaseReadWriteTransaction) -> VisibleMessage.LinkPreview? { - guard let linkPreview = linkPreview else { return nil } - do { - let linkPreview = try OWSLinkPreview.buildValidatedLinkPreview(fromInfo: linkPreview, transaction: transaction) - return VisibleMessage.LinkPreview(title: linkPreview.title, url: linkPreview.urlString!, attachmentID: linkPreview.imageAttachmentId) - } catch { - return nil - } - } -} diff --git a/SessionMessagingKit/Sending & Receiving/Link Previews/OWSLinkPreview.swift b/SessionMessagingKit/Sending & Receiving/Link Previews/OWSLinkPreview.swift deleted file mode 100644 index c98118ebb..000000000 --- a/SessionMessagingKit/Sending & Receiving/Link Previews/OWSLinkPreview.swift +++ /dev/null @@ -1,715 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import AFNetworking -import GRDB -import Foundation -import PromiseKit -import SignalCoreKit -import SessionUtilitiesKit - -@objc -public enum LinkPreviewError: Int, Error { - case invalidInput - case noPreview - case assertionFailure - case couldNotDownload - case featureDisabled - case invalidContent - case invalidMediaContent - case attachmentFailedToSave -} - -// MARK: - OWSLinkPreviewDraft - -public class OWSLinkPreviewContents: NSObject { - @objc - public var title: String? - - @objc - public var imageUrl: String? - - public init(title: String?, imageUrl: String? = nil) { - self.title = title - self.imageUrl = imageUrl - - super.init() - } -} - -// This contains the info for a link preview "draft". -public class OWSLinkPreviewDraft: NSObject { - @objc - public var urlString: String - - @objc - public var title: String? - - @objc - public var jpegImageData: Data? - - public init(urlString: String, title: String?, jpegImageData: Data? = nil) { - self.urlString = urlString - self.title = title - self.jpegImageData = jpegImageData - - super.init() - } - - fileprivate func isValid() -> Bool { - var hasTitle = false - if let titleValue = title { - hasTitle = titleValue.count > 0 - } - let hasImage = jpegImageData != nil - return hasTitle || hasImage - } - - @objc - public func displayDomain() -> String? { - return OWSLinkPreview.displayDomain(forUrl: urlString) - } -} - -// MARK: - OWSLinkPreview - -@objc -public class OWSLinkPreview: MTLModel { - @objc - public static let featureEnabled = true - - @objc - public var urlString: String? - - @objc - public var title: String? - - @objc - public var imageAttachmentId: String? - - // Whether this preview can be rendered as an attachment - @objc - public var isDirectAttachment: Bool = false - - @objc - public init(urlString: String, title: String?, imageAttachmentId: String?, isDirectAttachment: Bool = false) { - self.urlString = urlString - self.title = title - self.imageAttachmentId = imageAttachmentId - self.isDirectAttachment = isDirectAttachment - - super.init() - } - - @objc - public override init() { - super.init() - } - - @objc - public required init!(coder: NSCoder) { - super.init(coder: coder) - } - - @objc - public required init(dictionary dictionaryValue: [String: Any]!) throws { - try super.init(dictionary: dictionaryValue) - } - - @objc - public class func isNoPreviewError(_ error: Error) -> Bool { - guard let error = error as? LinkPreviewError else { - return false - } - return error == .noPreview - } - - @objc - public class func isInvalidContentError(_ error: Error) -> Bool { - guard let error = error as? LinkPreviewError else { return false } - return error == .invalidContent - } - - @objc - public class func buildValidatedLinkPreview(dataMessage: SNProtoDataMessage, - body: String?, - transaction: YapDatabaseReadWriteTransaction) throws -> OWSLinkPreview { - guard OWSLinkPreview.featureEnabled else { - throw LinkPreviewError.noPreview - } - guard let previewProto = dataMessage.preview.first else { - throw LinkPreviewError.noPreview - } - guard dataMessage.attachments.count < 1 else { - throw LinkPreviewError.invalidInput - } - let urlString = previewProto.url - - guard URL(string: urlString) != nil else { - throw LinkPreviewError.invalidInput - } - - guard let body = body else { - throw LinkPreviewError.invalidInput - } - let previewUrls = allPreviewUrls(forMessageBodyText: body) - guard previewUrls.contains(urlString) else { - throw LinkPreviewError.invalidInput - } - - guard isValidLinkUrl(urlString) else { - throw LinkPreviewError.invalidInput - } - - var title: String? - if let rawTitle = previewProto.title { - let normalizedTitle = OWSLinkPreview.normalizeTitle(title: rawTitle) - if normalizedTitle.count > 0 { - title = normalizedTitle - } - } - - var imageAttachmentId: String? - if let imageProto = previewProto.image { - if let imageAttachmentPointer = TSAttachmentPointer(fromProto: imageProto, albumMessage: nil) { - imageAttachmentPointer.save(with: transaction) - imageAttachmentId = imageAttachmentPointer.uniqueId - } else { - throw LinkPreviewError.invalidInput - } - } - - let linkPreview = OWSLinkPreview(urlString: urlString, title: title, imageAttachmentId: imageAttachmentId) - - guard linkPreview.isValid() else { - throw LinkPreviewError.invalidInput - } - - return linkPreview - } - - @objc - public class func buildValidatedLinkPreview(fromInfo info: OWSLinkPreviewDraft, - transaction: YapDatabaseReadWriteTransaction) throws -> OWSLinkPreview { - guard OWSLinkPreview.featureEnabled else { - throw LinkPreviewError.noPreview - } - guard SSKPreferences.areLinkPreviewsEnabled else { - throw LinkPreviewError.noPreview - } - let imageAttachmentId = OWSLinkPreview.saveAttachmentIfPossible(jpegImageData: info.jpegImageData, - transaction: transaction) - - let linkPreview = OWSLinkPreview(urlString: info.urlString, title: info.title, imageAttachmentId: imageAttachmentId) - - guard linkPreview.isValid() else { - throw LinkPreviewError.invalidInput - } - - return linkPreview - } - - private class func saveAttachmentIfPossible(jpegImageData: Data?, - transaction: YapDatabaseReadWriteTransaction) -> String? { - return saveAttachmentIfPossible(imageData: jpegImageData, mimeType: OWSMimeTypeImageJpeg, transaction: transaction); - } - - private class func saveAttachmentIfPossible(imageData: Data?, mimeType: String, transaction: YapDatabaseReadWriteTransaction) -> String? { - guard let imageData = imageData else { return nil } - - let fileSize = imageData.count - guard fileSize > 0 else { - return nil - } - - guard let fileExtension = fileExtension(forMimeType: mimeType) else { return nil } - let filePath = OWSFileSystem.temporaryFilePath(withFileExtension: fileExtension) - do { - try imageData.write(to: NSURL.fileURL(withPath: filePath), options: .atomicWrite) - } catch { - return nil - } - - guard let dataSource = DataSourcePath.dataSource(withFilePath: filePath, shouldDeleteOnDeallocation: true) else { - return nil - } - let attachment = TSAttachmentStream(contentType: mimeType, byteCount: UInt32(fileSize), sourceFilename: nil, caption: nil, albumMessageId: nil) - guard attachment.write(dataSource) else { - return nil - } - attachment.save(with: transaction) - - return attachment.uniqueId - } - - private func isValid() -> Bool { - var hasTitle = false - if let titleValue = title { - hasTitle = titleValue.count > 0 - } - let hasImage = imageAttachmentId != nil - return hasTitle || hasImage - } - - @objc - public func removeAttachment(transaction: YapDatabaseReadWriteTransaction) { - guard let imageAttachmentId = imageAttachmentId else { - return - } - guard let attachment = TSAttachment.fetch(uniqueId: imageAttachmentId, transaction: transaction) else { - return - } - attachment.remove(with: transaction) - } - - private class func normalizeTitle(title: String) -> String { - var result = title - // Truncate title after 2 lines of text. - let maxLineCount = 2 - var components = result.components(separatedBy: .newlines) - if components.count > maxLineCount { - components = Array(components[0.. maxCharacterCount { - let endIndex = result.index(result.startIndex, offsetBy: maxCharacterCount) - result = String(result[.. String? { - return OWSLinkPreview.displayDomain(forUrl: urlString) - } - - @objc - public class func displayDomain(forUrl urlString: String?) -> String? { - guard let urlString = urlString else { - return nil - } - guard let url = URL(string: urlString) else { - return nil - } - return url.host - } - - @objc - public class func isValidLinkUrl(_ urlString: String) -> Bool { - return URL(string: urlString) != nil - } - - @objc - public class func isValidMediaUrl(_ urlString: String) -> Bool { - return URL(string: urlString) != nil - } - - // MARK: - Serial Queue - - private static let serialQueue = DispatchQueue(label: "org.signal.linkPreview") - - // MARK: - Text Parsing - - // This cache should only be accessed on main thread. - private static var previewUrlCache: NSCache = NSCache() - - @objc - public class func previewUrl(forRawBodyText body: String?, selectedRange: NSRange) -> String? { - return previewUrl(forMessageBodyText: body, selectedRange: selectedRange) - } - - @objc - public class func previewURL(forRawBodyText body: String?) -> String? { - return previewUrl(forMessageBodyText: body, selectedRange: nil) - } - - public class func previewUrl(forMessageBodyText body: String?, selectedRange: NSRange?) -> String? { - - // Exit early if link previews are not enabled in order to avoid - // tainting the cache. - guard OWSLinkPreview.featureEnabled else { - return nil - } - - guard GRDBStorage.shared[.areLinkPreviewsEnabled] else { - return nil - } - - guard let body = body else { - return nil - } - - if let cachedUrl = previewUrlCache.object(forKey: body as NSString) as String? { - guard cachedUrl.count > 0 else { - return nil - } - return cachedUrl - } - let previewUrlMatches = allPreviewUrlMatches(forMessageBodyText: body) - guard let urlMatch = previewUrlMatches.first else { - // Use empty string to indicate "no preview URL" in the cache. - previewUrlCache.setObject("", forKey: body as NSString) - return nil - } - - if let selectedRange = selectedRange { - let cursorAtEndOfMatch = urlMatch.matchRange.location + urlMatch.matchRange.length == selectedRange.location - if selectedRange.location != body.count, - (urlMatch.matchRange.intersection(selectedRange) != nil || cursorAtEndOfMatch) { - // we don't want to cache the result here, as we want to fetch the link preview - // if the user moves the cursor. - return nil - } - } - - previewUrlCache.setObject(urlMatch.urlString as NSString, forKey: body as NSString) - return urlMatch.urlString - } - - struct URLMatchResult { - let urlString: String - let matchRange: NSRange - } - - public class func allPreviewUrls(forMessageBodyText body: String) -> [String] { - return allPreviewUrlMatches(forMessageBodyText: body).map { $0.urlString } - } - - class func allPreviewUrlMatches(forMessageBodyText body: String) -> [URLMatchResult] { - let detector: NSDataDetector - do { - detector = try NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) - } catch { - return [] - } - - var urlMatches: [URLMatchResult] = [] - let matches = detector.matches(in: body, options: [], range: NSRange(location: 0, length: body.count)) - for match in matches { - guard let matchURL = match.url else { continue } - - // If the URL entered didn't have a scheme it will default to 'http', we want to catch this and - // set the scheme to 'https' instead as we don't load previews for 'http' so this will result - // in more previews actually getting loaded without forcing the user to enter 'https://' before - // every URL they enter - let urlString: String = (matchURL.absoluteString == "http://\(body)" ? - "https://\(body)" : - matchURL.absoluteString - ) - if isValidLinkUrl(urlString) { - let matchResult = URLMatchResult(urlString: urlString, matchRange: match.range) - urlMatches.append(matchResult) - } - } - return urlMatches - } - - // MARK: - Preview Construction - - // This cache should only be accessed on serialQueue. - // - // We should only maintain a "cache" of the last known draft. - private static var linkPreviewDraftCache: OWSLinkPreviewDraft? - - private class func cachedLinkPreview(forPreviewUrl previewUrl: String) -> OWSLinkPreviewDraft? { - return serialQueue.sync { - guard let linkPreviewDraft = linkPreviewDraftCache, - linkPreviewDraft.urlString == previewUrl else { - return nil - } - return linkPreviewDraft - } - } - - private class func setCachedLinkPreview(_ linkPreviewDraft: OWSLinkPreviewDraft, - forPreviewUrl previewUrl: String) { - assert(previewUrl == linkPreviewDraft.urlString) - - // Exit early if link previews are not enabled in order to avoid - // tainting the cache. - guard OWSLinkPreview.featureEnabled else { return } - guard GRDBStorage.shared[.areLinkPreviewsEnabled] else { return } - - serialQueue.sync { - linkPreviewDraftCache = linkPreviewDraft - } - } - - public class func tryToBuildPreviewInfo(previewUrl: String?) -> Promise { - guard OWSLinkPreview.featureEnabled else { - return Promise(error: LinkPreviewError.featureDisabled) - } - guard GRDBStorage.shared[.areLinkPreviewsEnabled] else { - return Promise(error: LinkPreviewError.featureDisabled) - } - guard let previewUrl = previewUrl else { - return Promise(error: LinkPreviewError.invalidInput) - } - if let cachedInfo = cachedLinkPreview(forPreviewUrl: previewUrl) { - return Promise.value(cachedInfo) - } - return downloadLink(url: previewUrl) - .then(on: DispatchQueue.global()) { (data, response) -> Promise in - return parseLinkDataAndBuildDraft(linkData: data, response: response, linkUrlString: previewUrl) - }.then(on: DispatchQueue.global()) { (linkPreviewDraft) -> Promise in - guard linkPreviewDraft.isValid() else { - throw LinkPreviewError.noPreview - } - setCachedLinkPreview(linkPreviewDraft, forPreviewUrl: previewUrl) - - return Promise.value(linkPreviewDraft) - } - } - - // Twitter doesn't return OpenGraph tags to Signal - // `curl -A Signal "https://twitter.com/signalapp/status/1280166087577997312?s=20"` - // If this ever changes, we can switch back to our default User-Agent - private static let userAgentString = "WhatsApp" - - class func downloadLink(url urlString: String, - remainingRetries: UInt = 3) -> Promise<(Data, URLResponse)> { - - Logger.verbose("url: \(urlString)") - - // let sessionConfiguration = ContentProxy.sessionConfiguration() // Loki: Signal's proxy appears to have been banned by YouTube - let sessionConfiguration = URLSessionConfiguration.ephemeral - - // Don't use any caching to protect privacy of these requests. - sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData - sessionConfiguration.urlCache = nil - - let sessionManager = AFHTTPSessionManager(baseURL: nil, - sessionConfiguration: sessionConfiguration) - sessionManager.requestSerializer = AFHTTPRequestSerializer() - sessionManager.responseSerializer = AFHTTPResponseSerializer() - - guard ContentProxy.configureSessionManager(sessionManager: sessionManager, forUrl: urlString) else { - return Promise(error: LinkPreviewError.assertionFailure) - } - - sessionManager.requestSerializer.setValue(self.userAgentString, forHTTPHeaderField: "User-Agent") - - let (promise, resolver) = Promise<(Data, URLResponse)>.pending() - sessionManager.get(urlString, - parameters: [String: AnyObject](), - headers: nil, - progress: nil, - success: { task, value in - - guard let response = task.response as? HTTPURLResponse else { - resolver.reject(LinkPreviewError.assertionFailure) - return - } - if let contentType = response.allHeaderFields["Content-Type"] as? String { - guard contentType.lowercased().hasPrefix("text/") else { - resolver.reject(LinkPreviewError.invalidContent) - return - } - } - guard let data = value as? Data else { - resolver.reject(LinkPreviewError.assertionFailure) - return - } - guard data.count > 0 else { - resolver.reject(LinkPreviewError.invalidContent) - return - } - resolver.fulfill((data, response)) - }, - failure: { _, error in - guard isRetryable(error: error) else { - resolver.reject(LinkPreviewError.couldNotDownload) - return - } - - guard remainingRetries > 0 else { - resolver.reject(LinkPreviewError.couldNotDownload) - return - } - OWSLinkPreview.downloadLink(url: urlString, remainingRetries: remainingRetries - 1) - .done(on: DispatchQueue.global()) { (data, response) in - resolver.fulfill((data, response)) - }.catch(on: DispatchQueue.global()) { (error) in - resolver.reject(error) - }.retainUntilComplete() - }) - return promise - } - - private class func downloadImage(url urlString: String, imageMimeType: String) -> Promise { - guard let url = URL(string: urlString) else { - return Promise(error: LinkPreviewError.invalidInput) - } - - guard let assetDescription = ProxiedContentAssetDescription(url: url as NSURL) else { - return Promise(error: LinkPreviewError.invalidInput) - } - let (promise, resolver) = Promise.pending() - DispatchQueue.main.async { - _ = ProxiedContentDownloader.defaultDownloader.requestAsset(assetDescription: assetDescription, - priority: .high, - success: { (_, asset) in - resolver.fulfill(asset) - }, failure: { (_) in - resolver.reject(LinkPreviewError.couldNotDownload) - }, shouldIgnoreSignalProxy: true) - } - return promise.then(on: DispatchQueue.global()) { (asset: ProxiedContentAsset) -> Promise in - do { - let imageSize = NSData.imageSize(forFilePath: asset.filePath, mimeType: imageMimeType) - guard imageSize.width > 0, imageSize.height > 0 else { - return Promise(error: LinkPreviewError.invalidContent) - } - let data = try Data(contentsOf: URL(fileURLWithPath: asset.filePath)) - - guard let srcImage = UIImage(data: data) else { - return Promise(error: LinkPreviewError.invalidContent) - } - - // Loki: If it's a GIF then ensure its validity and don't download it as a JPG - if (imageMimeType == OWSMimeTypeImageGif && NSData(data: data).ows_isValidImage(withMimeType: OWSMimeTypeImageGif)) { return Promise.value(data) } - - let maxImageSize: CGFloat = 1024 - let shouldResize = imageSize.width > maxImageSize || imageSize.height > maxImageSize - guard shouldResize else { - guard let dstData = srcImage.jpegData(compressionQuality: 0.8) else { - return Promise(error: LinkPreviewError.invalidContent) - } - return Promise.value(dstData) - } - - guard let dstImage = srcImage.resized(withMaxDimensionPoints: maxImageSize) else { - return Promise(error: LinkPreviewError.invalidContent) - } - guard let dstData = dstImage.jpegData(compressionQuality: 0.8) else { - return Promise(error: LinkPreviewError.invalidContent) - } - return Promise.value(dstData) - } catch { - return Promise(error: LinkPreviewError.assertionFailure) - } - } - } - - private class func isRetryable(error: Error) -> Bool { - let nsError = error as NSError - if nsError.domain == kCFErrorDomainCFNetwork as String { - // Network failures are retried. - return true - } - return false - } - - class func parseLinkDataAndBuildDraft(linkData: Data, - response: URLResponse, - linkUrlString: String) -> Promise { - do { - let contents = try parse(linkData: linkData, response: response) - - let title = contents.title - guard let imageUrl = contents.imageUrl else { - return Promise.value(OWSLinkPreviewDraft(urlString: linkUrlString, title: title)) - } - - guard isValidMediaUrl(imageUrl) else { - return Promise.value(OWSLinkPreviewDraft(urlString: linkUrlString, title: title)) - } - guard let imageFileExtension = fileExtension(forImageUrl: imageUrl) else { - return Promise.value(OWSLinkPreviewDraft(urlString: linkUrlString, title: title)) - } - guard let imageMimeType = mimetype(forImageFileExtension: imageFileExtension) else { - return Promise.value(OWSLinkPreviewDraft(urlString: linkUrlString, title: title)) - } - - return downloadImage(url: imageUrl, imageMimeType: imageMimeType) - .map(on: DispatchQueue.global()) { (imageData: Data) -> OWSLinkPreviewDraft in - // We always recompress images to Jpeg. - let linkPreviewDraft = OWSLinkPreviewDraft(urlString: linkUrlString, title: title, jpegImageData: imageData) - return linkPreviewDraft - } - .recover(on: DispatchQueue.global()) { (_) -> Promise in - return Promise.value(OWSLinkPreviewDraft(urlString: linkUrlString, title: title)) - } - } catch { - return Promise(error: error) - } - } - - class func parse(linkData: Data, response: URLResponse) throws -> OWSLinkPreviewContents { - guard let linkText = String(data: linkData, urlResponse: response) else { - print("Could not parse link text.") - throw LinkPreviewError.invalidInput - } - - let content = HTMLMetadata.construct(parsing: linkText) - - var title: String? - let rawTitle = content.ogTitle ?? content.titleTag - if let decodedTitle = decodeHTMLEntities(inString: rawTitle ?? "") { - let normalizedTitle = OWSLinkPreview.normalizeTitle(title: decodedTitle) - if normalizedTitle.count > 0 { - title = normalizedTitle - } - } - - Logger.verbose("title: \(String(describing: title))") - - guard let rawImageUrlString = content.ogImageUrlString ?? content.faviconUrlString else { - return OWSLinkPreviewContents(title: title) - } - guard let imageUrlString = decodeHTMLEntities(inString: rawImageUrlString)?.ows_stripped() else { - return OWSLinkPreviewContents(title: title) - } - - return OWSLinkPreviewContents(title: title, imageUrl: imageUrlString) - } - - class func fileExtension(forImageUrl urlString: String) -> String? { - guard let imageUrl = URL(string: urlString) else { - return nil - } - let imageFilename = imageUrl.lastPathComponent - let imageFileExtension = (imageFilename as NSString).pathExtension.lowercased() - guard imageFileExtension.count > 0 else { - // TODO: For those links don't have a file extension, we should figure out a way to know the image mime type - return "png" - } - return imageFileExtension - } - - class func fileExtension(forMimeType mimeType: String) -> String? { - switch mimeType { - case OWSMimeTypeImageGif: return "gif" - case OWSMimeTypeImagePng: return "png" - case OWSMimeTypeImageJpeg: return "jpg" - default: return nil - } - } - - class func mimetype(forImageFileExtension imageFileExtension: String) -> String? { - guard imageFileExtension.count > 0 else { - return nil - } - guard let imageMimeType = MIMETypeUtil.mimeType(forFileExtension: imageFileExtension) else { - return nil - } - return imageMimeType - } - - private class func decodeHTMLEntities(inString value: String) -> String? { - guard let data = value.data(using: .utf8) else { - return nil - } - - let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [ - NSAttributedString.DocumentReadingOptionKey.documentType: NSAttributedString.DocumentType.html, - NSAttributedString.DocumentReadingOptionKey.characterEncoding: String.Encoding.utf8.rawValue - ] - - guard let attributedString = try? NSAttributedString(data: data, options: options, documentAttributes: nil) else { - return nil - } - - return attributedString.string - } -} diff --git a/SessionMessagingKit/Sending & Receiving/Quotes/OWSQuotedReplyModel+Conversion.swift b/SessionMessagingKit/Sending & Receiving/Quotes/OWSQuotedReplyModel+Conversion.swift deleted file mode 100644 index 5a1ef63cd..000000000 --- a/SessionMessagingKit/Sending & Receiving/Quotes/OWSQuotedReplyModel+Conversion.swift +++ /dev/null @@ -1,14 +0,0 @@ - -extension VisibleMessage.Quote { - - @objc(from:) - public static func from(_ quote: OWSQuotedReplyModel?) -> VisibleMessage.Quote? { - guard let quote = quote else { return nil } - let result = VisibleMessage.Quote() - result.timestamp = quote.timestamp - result.publicKey = quote.authorId - result.text = quote.body - result.attachmentID = quote.attachmentStream?.uniqueId - return result - } -} diff --git a/SessionMessagingKit/Sending & Receiving/Quotes/OWSQuotedReplyModel.h b/SessionMessagingKit/Sending & Receiving/Quotes/OWSQuotedReplyModel.h deleted file mode 100644 index 7425533fa..000000000 --- a/SessionMessagingKit/Sending & Receiving/Quotes/OWSQuotedReplyModel.h +++ /dev/null @@ -1,57 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@protocol ConversationViewItem; - -@class TSAttachmentPointer; -@class TSAttachmentStream; -@class TSMessage; -@class YapDatabaseReadTransaction; - -// View model which has already fetched any attachments. -@interface OWSQuotedReplyModel : NSObject - -@property (nonatomic, readonly) uint64_t timestamp; -@property (nonatomic, readonly) NSString *authorId; -@property (nonatomic, readonly, nullable) TSAttachmentStream *attachmentStream; -@property (nonatomic, readonly, nullable) TSAttachmentPointer *thumbnailAttachmentPointer; -@property (nonatomic, readonly) BOOL thumbnailDownloadFailed; -@property (nonatomic, readonly) NSString *threadId; - -// This property should be set IFF we are quoting a text message -// or attachment with caption. -@property (nullable, nonatomic, readonly) NSString *body; -@property (nonatomic, readonly) BOOL isRemotelySourced; - -#pragma mark - Attachments - -// This is a MIME type. -// -// This property should be set IFF we are quoting an attachment message. -@property (nonatomic, readonly, nullable) NSString *contentType; -@property (nonatomic, readonly, nullable) NSString *sourceFilename; -@property (nonatomic, readonly, nullable) UIImage *thumbnailImage; - -- (instancetype)init NS_UNAVAILABLE; - -// Used for persisted quoted replies, both incoming and outgoing. -+ (instancetype)quotedReplyWithQuotedMessage:(TSQuotedMessage *)quotedMessage - threadId:(NSString *)threadId - transaction:(YapDatabaseReadTransaction *)transaction; - -// Builds a not-yet-sent QuotedReplyModel -+ (nullable instancetype)quotedReplyForSendingWithConversationViewItem:(id)conversationItem - threadId:(NSString *)threadId - transaction:(YapDatabaseReadTransaction *)transaction; - -- (TSQuotedMessage *)buildQuotedMessageForSending; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Sending & Receiving/Quotes/OWSQuotedReplyModel.m b/SessionMessagingKit/Sending & Receiving/Quotes/OWSQuotedReplyModel.m deleted file mode 100644 index 705caf663..000000000 --- a/SessionMessagingKit/Sending & Receiving/Quotes/OWSQuotedReplyModel.m +++ /dev/null @@ -1,235 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "OWSQuotedReplyModel.h" -#import "ConversationViewItem.h" -#import -#import -#import -#import -#import -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface OWSQuotedReplyModel () - -@property (nonatomic, readonly) TSQuotedMessageContentSource bodySource; - -- (instancetype)initWithTimestamp:(uint64_t)timestamp - authorId:(NSString *)authorId - body:(nullable NSString *)body - bodySource:(TSQuotedMessageContentSource)bodySource - thumbnailImage:(nullable UIImage *)thumbnailImage - contentType:(nullable NSString *)contentType - sourceFilename:(nullable NSString *)sourceFilename - attachmentStream:(nullable TSAttachmentStream *)attachmentStream - thumbnailAttachmentPointer:(nullable TSAttachmentPointer *)thumbnailAttachmentPointer - thumbnailDownloadFailed:(BOOL)thumbnailDownloadFailed - threadId:(NSString *)threadId NS_DESIGNATED_INITIALIZER; - -@end - -// View Model which has already fetched any thumbnail attachment. -@implementation OWSQuotedReplyModel - -#pragma mark - Initializers - -- (instancetype)initWithTimestamp:(uint64_t)timestamp - authorId:(NSString *)authorId - body:(nullable NSString *)body - bodySource:(TSQuotedMessageContentSource)bodySource - thumbnailImage:(nullable UIImage *)thumbnailImage - contentType:(nullable NSString *)contentType - sourceFilename:(nullable NSString *)sourceFilename - attachmentStream:(nullable TSAttachmentStream *)attachmentStream - thumbnailAttachmentPointer:(nullable TSAttachmentPointer *)thumbnailAttachmentPointer - thumbnailDownloadFailed:(BOOL)thumbnailDownloadFailed - threadId:(NSString *)threadId -{ - self = [super init]; - if (!self) { - return self; - } - - _timestamp = timestamp; - _authorId = authorId; - _body = body; - _bodySource = bodySource; - _thumbnailImage = thumbnailImage; - _contentType = contentType; - _sourceFilename = sourceFilename; - _attachmentStream = attachmentStream; - _thumbnailAttachmentPointer = thumbnailAttachmentPointer; - _thumbnailDownloadFailed = thumbnailDownloadFailed; - _threadId = threadId; - - return self; -} - -#pragma mark - Factory Methods - -+ (instancetype)quotedReplyWithQuotedMessage:(TSQuotedMessage *)quotedMessage - threadId:(NSString *)threadId - transaction:(YapDatabaseReadTransaction *)transaction -{ - OWSAttachmentInfo *attachmentInfo = quotedMessage.quotedAttachments.firstObject; - - BOOL thumbnailDownloadFailed = NO; - UIImage *_Nullable thumbnailImage; - TSAttachmentPointer *attachmentPointer; - if (attachmentInfo.thumbnailAttachmentStreamId) { - TSAttachment *attachment = - [TSAttachment fetchObjectWithUniqueID:attachmentInfo.thumbnailAttachmentStreamId transaction:transaction]; - - TSAttachmentStream *attachmentStream; - if ([attachment isKindOfClass:[TSAttachmentStream class]]) { - attachmentStream = (TSAttachmentStream *)attachment; - thumbnailImage = attachmentStream.thumbnailImageSmallSync; - } - } else if (attachmentInfo.thumbnailAttachmentPointerId) { - // download failed, or hasn't completed yet. - TSAttachment *attachment = - [TSAttachment fetchObjectWithUniqueID:attachmentInfo.thumbnailAttachmentPointerId transaction:transaction]; - - if ([attachment isKindOfClass:[TSAttachmentPointer class]]) { - attachmentPointer = (TSAttachmentPointer *)attachment; - if (attachmentPointer.state == TSAttachmentPointerStateFailed) { - thumbnailDownloadFailed = YES; - } - } - } - - return [[self alloc] initWithTimestamp:quotedMessage.timestamp - authorId:quotedMessage.authorId - body:quotedMessage.body - bodySource:quotedMessage.bodySource - thumbnailImage:thumbnailImage - contentType:attachmentInfo.contentType - sourceFilename:attachmentInfo.sourceFilename - attachmentStream:nil - thumbnailAttachmentPointer:attachmentPointer - thumbnailDownloadFailed:thumbnailDownloadFailed - threadId:threadId]; -} - -+ (nullable instancetype)quotedReplyForSendingWithConversationViewItem:(id)conversationItem - threadId:(NSString *)threadId - transaction:(YapDatabaseReadTransaction *)transaction; -{ - TSMessage *message = (TSMessage *)conversationItem.interaction; - if (![message isKindOfClass:[TSMessage class]]) { - return nil; - } - - uint64_t timestamp = message.timestamp; - - NSString *_Nullable authorId = ^{ - if ([message isKindOfClass:[TSOutgoingMessage class]]) { - return [TSAccountManager localNumber]; - } else if ([message isKindOfClass:[TSIncomingMessage class]]) { - return [(TSIncomingMessage *)message authorId]; - } else { - return (NSString * _Nullable) nil; - } - }(); - - NSString *_Nullable quotedText = message.body; - BOOL hasText = quotedText.length > 0; - - TSAttachment *_Nullable attachment = [message attachmentsWithTransaction:transaction].firstObject; - TSAttachmentStream *quotedAttachment; - if (attachment && [attachment isKindOfClass:[TSAttachmentStream class]]) { - - TSAttachmentStream *attachmentStream = (TSAttachmentStream *)attachment; - - // If the attachment is "oversize text", try the quote as a reply to text, not as - // a reply to an attachment. - if (!hasText && [OWSMimeTypeOversizeTextMessage isEqualToString:attachment.contentType]) { - hasText = YES; - quotedText = @""; - - NSData *_Nullable oversizeTextData = [NSData dataWithContentsOfFile:attachmentStream.originalFilePath]; - if (oversizeTextData) { - // We don't need to include the entire text body of the message, just - // enough to render a snippet. kOversizeTextMessageSizeThreshold is our - // limit on how long text should be in protos since they'll be stored in - // the database. We apply this constant here for the same reasons. - NSString *_Nullable oversizeText = - [[NSString alloc] initWithData:oversizeTextData encoding:NSUTF8StringEncoding]; - // First, truncate to the rough max characters. - NSString *_Nullable truncatedText = - [oversizeText substringToIndex:kOversizeTextMessageSizeThreshold - 1]; - // But kOversizeTextMessageSizeThreshold is in _bytes_, not characters, - // so we need to continue to trim the string until it fits. - while (truncatedText && truncatedText.length > 0 && - [truncatedText dataUsingEncoding:NSUTF8StringEncoding].length - >= kOversizeTextMessageSizeThreshold) { - // A very coarse binary search by halving is acceptable, since - // kOversizeTextMessageSizeThreshold is much longer than our target - // length of "three short lines of text on any device we might - // display this on. - // - // The search will always converge since in the worst case (namely - // a single character which in utf-8 is >= 1024 bytes) the loop will - // exit when the string is empty. - truncatedText = [truncatedText substringToIndex:truncatedText.length / 2]; - } - if ([truncatedText dataUsingEncoding:NSUTF8StringEncoding].length < kOversizeTextMessageSizeThreshold) { - quotedText = truncatedText; - } - } - } else { - quotedAttachment = attachmentStream; - } - } - - if (!quotedAttachment && conversationItem.linkPreview && conversationItem.linkPreviewAttachment && - [conversationItem.linkPreviewAttachment isKindOfClass:[TSAttachmentStream class]]) { - - quotedAttachment = (TSAttachmentStream *)conversationItem.linkPreviewAttachment; - } - - BOOL hasAttachment = quotedAttachment != nil; - if (!hasText && !hasAttachment) { - quotedText = @""; - hasText = YES; - } - - return [[self alloc] initWithTimestamp:timestamp - authorId:authorId - body:quotedText - bodySource:TSQuotedMessageContentSourceLocal - thumbnailImage:quotedAttachment.thumbnailImageSmallSync - contentType:quotedAttachment.contentType - sourceFilename:quotedAttachment.sourceFilename - attachmentStream:quotedAttachment - thumbnailAttachmentPointer:nil - thumbnailDownloadFailed:NO - threadId:threadId]; -} - -#pragma mark - Instance Methods - -- (TSQuotedMessage *)buildQuotedMessageForSending -{ - NSArray *attachments = self.attachmentStream ? @[ self.attachmentStream ] : @[]; - - // Legit usage of senderTimestamp to reference existing message - return [[TSQuotedMessage alloc] initWithTimestamp:self.timestamp - authorId:self.authorId - body:self.body - quotedAttachmentsForSending:attachments]; -} - -- (BOOL)isRemotelySourced -{ - return self.bodySource == TSQuotedMessageContentSourceRemote; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Sending & Receiving/Quotes/TSQuotedMessage+Conversion.swift b/SessionMessagingKit/Sending & Receiving/Quotes/TSQuotedMessage+Conversion.swift deleted file mode 100644 index f943ebb33..000000000 --- a/SessionMessagingKit/Sending & Receiving/Quotes/TSQuotedMessage+Conversion.swift +++ /dev/null @@ -1,32 +0,0 @@ - -extension TSQuotedMessage { - - /// To be used for outgoing messages only. - public static func from(_ quote: VisibleMessage.Quote?) -> TSQuotedMessage? { - guard let quote = quote else { return nil } - var attachments: [TSAttachment] = [] - if let attachmentID = quote.attachmentID, let attachment = TSAttachment.fetch(uniqueId: attachmentID) { - attachments.append(attachment) - } - return TSQuotedMessage( - timestamp: quote.timestamp!, - authorId: quote.publicKey!, - body: quote.text, - quotedAttachmentsForSending: attachments - ) - } -} - -extension VisibleMessage.Quote { - - public static func from(_ quote: TSQuotedMessage?) -> VisibleMessage.Quote? { - guard let quote = quote else { return nil } - - return VisibleMessage.Quote( - timestamp: quote.timestamp, - publicKey: quote.authorId, - text: quote.body, - attachmentId: quote.quotedAttachments.first?.attachmentId - ) - } -} diff --git a/SessionMessagingKit/Sending & Receiving/Quotes/TSQuotedMessage.h b/SessionMessagingKit/Sending & Receiving/Quotes/TSQuotedMessage.h deleted file mode 100644 index bf18aaddb..000000000 --- a/SessionMessagingKit/Sending & Receiving/Quotes/TSQuotedMessage.h +++ /dev/null @@ -1,108 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@class SNProtoDataMessage; -@class TSAttachment; -@class TSAttachmentStream; -@class TSQuotedMessage; -@class TSThread; -@class YapDatabaseReadWriteTransaction; - -@interface OWSAttachmentInfo : MTLModel - -@property (nonatomic, readonly, nullable) NSString *contentType; -@property (nonatomic, readonly, nullable) NSString *sourceFilename; - -// This is only set when sending a new attachment so we have a way -// to reference the original attachment when generating a thumbnail. -// We don't want to do this until the message is saved, when the user sends -// the message so as not to end up with an orphaned file. -@property (nonatomic, readonly, nullable) NSString *attachmentId; - -// References a yet-to-be downloaded thumbnail file -@property (atomic, nullable) NSString *thumbnailAttachmentPointerId; - -// References an already downloaded or locally generated thumbnail file -@property (atomic, nullable) NSString *thumbnailAttachmentStreamId; - -- (instancetype)init NS_UNAVAILABLE; - -- (instancetype)initWithAttachmentId:(nullable NSString *)attachmentId - contentType:(NSString *)contentType - sourceFilename:(NSString *)sourceFilename NS_DESIGNATED_INITIALIZER; - -- (instancetype)initWithAttachmentStream:(TSAttachmentStream *)attachmentStream; - -@end - -typedef NS_ENUM(NSUInteger, TSQuotedMessageContentSource) { - TSQuotedMessageContentSourceUnknown, - TSQuotedMessageContentSourceLocal, - TSQuotedMessageContentSourceRemote -}; - -@interface TSQuotedMessage : MTLModel - -@property (nonatomic, readonly) uint64_t timestamp; -@property (nonatomic, readonly) NSString *authorId; -@property (nonatomic, readonly) TSQuotedMessageContentSource bodySource; - -// This property should be set IFF we are quoting a text message -// or attachment with caption. -@property (nullable, nonatomic, readonly) NSString *body; - -#pragma mark - Attachments - -// This is a MIME type. -// -// This property should be set IFF we are quoting an attachment message. -- (nullable NSString *)contentType; -- (nullable NSString *)sourceFilename; - -// References a yet-to-be downloaded thumbnail file -- (nullable NSString *)thumbnailAttachmentPointerId; - -// References an already downloaded or locally generated thumbnail file -- (nullable NSString *)thumbnailAttachmentStreamId; -- (void)setThumbnailAttachmentStream:(TSAttachment *)thumbnailAttachmentStream; - -// currently only used by orphan attachment cleaner -- (NSArray *)thumbnailAttachmentStreamIds; - -@property (atomic, readonly) NSArray *quotedAttachments; - -// Before sending, persist a thumbnail attachment derived from the quoted attachment -- (NSArray *)createThumbnailAttachmentsIfNecessaryWithTransaction: - (YapDatabaseReadWriteTransaction *)transaction; - -- (instancetype)init NS_UNAVAILABLE; - -// used when receiving quoted messages -- (instancetype)initWithTimestamp:(uint64_t)timestamp - authorId:(NSString *)authorId - body:(NSString *_Nullable)body - bodySource:(TSQuotedMessageContentSource)bodySource - receivedQuotedAttachmentInfos:(NSArray *)attachmentInfos; - -// used when sending quoted messages -- (instancetype)initWithTimestamp:(uint64_t)timestamp - authorId:(NSString *)authorId - body:(NSString *_Nullable)body - quotedAttachmentsForSending:(NSArray *)attachments; - - -+ (nullable instancetype)quotedMessageForDataMessage:(SNProtoDataMessage *)dataMessage - thread:(TSThread *)thread - transaction:(YapDatabaseReadWriteTransaction *)transaction; - -@end - -#pragma mark - - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Sending & Receiving/Quotes/TSQuotedMessage.m b/SessionMessagingKit/Sending & Receiving/Quotes/TSQuotedMessage.m deleted file mode 100644 index 576ccda30..000000000 --- a/SessionMessagingKit/Sending & Receiving/Quotes/TSQuotedMessage.m +++ /dev/null @@ -1,339 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "TSQuotedMessage.h" -#import "TSAccountManager.h" -#import "TSAttachment.h" -#import "TSAttachmentPointer.h" -#import "TSAttachmentStream.h" -#import "TSIncomingMessage.h" -#import "TSInteraction.h" -#import "TSOutgoingMessage.h" -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@implementation OWSAttachmentInfo - -- (instancetype)initWithAttachmentStream:(TSAttachmentStream *)attachmentStream; -{ - return [self initWithAttachmentId:attachmentStream.uniqueId - contentType:attachmentStream.contentType - sourceFilename:attachmentStream.sourceFilename]; -} - -- (instancetype)initWithAttachmentId:(nullable NSString *)attachmentId - contentType:(NSString *)contentType - sourceFilename:(NSString *)sourceFilename -{ - self = [super init]; - if (!self) { - return self; - } - - _attachmentId = attachmentId; - _contentType = contentType; - _sourceFilename = sourceFilename; - - return self; -} - -@end - -@interface TSQuotedMessage () - -@property (atomic) NSArray *quotedAttachments; -@property (atomic) NSArray *quotedAttachmentsForSending; - -@end - -@implementation TSQuotedMessage - -- (instancetype)initWithTimestamp:(uint64_t)timestamp - authorId:(NSString *)authorId - body:(NSString *_Nullable)body - bodySource:(TSQuotedMessageContentSource)bodySource - receivedQuotedAttachmentInfos:(NSArray *)attachmentInfos -{ - self = [super init]; - if (!self) { - return nil; - } - - _timestamp = timestamp; - _authorId = authorId; - _body = body; - _bodySource = bodySource; - _quotedAttachments = attachmentInfos; - - return self; -} - -- (instancetype)initWithTimestamp:(uint64_t)timestamp - authorId:(NSString *)authorId - body:(NSString *_Nullable)body - quotedAttachmentsForSending:(NSArray *)attachments -{ - self = [super init]; - if (!self) { - return nil; - } - - _timestamp = timestamp; - _authorId = authorId; - _body = body; - _bodySource = TSQuotedMessageContentSourceLocal; - - NSMutableArray *attachmentInfos = [NSMutableArray new]; - for (TSAttachmentStream *attachmentStream in attachments) { - [attachmentInfos addObject:[[OWSAttachmentInfo alloc] initWithAttachmentStream:attachmentStream]]; - } - _quotedAttachments = [attachmentInfos copy]; - - return self; -} - -+ (TSQuotedMessage *_Nullable)quotedMessageForDataMessage:(SNProtoDataMessage *)dataMessage - thread:(TSThread *)thread - transaction:(YapDatabaseReadWriteTransaction *)transaction -{ - if (!dataMessage.quote) { - return nil; - } - - SNProtoDataMessageQuote *quoteProto = [dataMessage quote]; - - if (quoteProto.id == 0) { - return nil; - } - uint64_t timestamp = [quoteProto id]; - - if (quoteProto.author.length == 0) { - return nil; - } - // TODO: We could verify that this is a valid e164 value. - NSString *authorId = [quoteProto author]; - - NSString *_Nullable body = nil; - BOOL hasAttachment = NO; - TSQuotedMessageContentSource bodySource = TSQuotedMessageContentSourceUnknown; - - // Prefer to generate the text snippet locally if available. - TSMessage *_Nullable quotedMessage = [self findQuotedMessageWithTimestamp:timestamp - threadId:thread.uniqueId - authorId:authorId - transaction:transaction]; - - if (quotedMessage) { - bodySource = TSQuotedMessageContentSourceLocal; - - NSString *localText = [quotedMessage bodyTextWithTransaction:transaction]; - if (localText.length > 0) { - body = localText; - } - } - - if (body.length == 0) { - if (quoteProto.text.length > 0) { - bodySource = TSQuotedMessageContentSourceRemote; - body = quoteProto.text; - } - } - - NSMutableArray *attachmentInfos = [NSMutableArray new]; - for (SNProtoDataMessageQuoteQuotedAttachment *quotedAttachment in quoteProto.attachments) { - hasAttachment = YES; - OWSAttachmentInfo *attachmentInfo = [[OWSAttachmentInfo alloc] initWithAttachmentId:nil - contentType:quotedAttachment.contentType - sourceFilename:quotedAttachment.fileName]; - - // We prefer deriving any thumbnail locally rather than fetching one from the network. - TSAttachmentStream *_Nullable localThumbnail = - [self tryToDeriveLocalThumbnailWithTimestamp:timestamp - threadId:thread.uniqueId - authorId:authorId - contentType:quotedAttachment.contentType - transaction:transaction]; - - if (localThumbnail) { - [localThumbnail saveWithTransaction:transaction]; - - attachmentInfo.thumbnailAttachmentStreamId = localThumbnail.uniqueId; - } else if (quotedAttachment.thumbnail) { - SNProtoAttachmentPointer *thumbnailAttachmentProto = quotedAttachment.thumbnail; - TSAttachmentPointer *_Nullable thumbnailPointer = - [TSAttachmentPointer attachmentPointerFromProto:thumbnailAttachmentProto albumMessage:nil]; - if (thumbnailPointer) { - [thumbnailPointer saveWithTransaction:transaction]; - - attachmentInfo.thumbnailAttachmentPointerId = thumbnailPointer.uniqueId; - } - } - - [attachmentInfos addObject:attachmentInfo]; - - // For now, only support a single quoted attachment. - break; - } - - if (body.length == 0 && !hasAttachment) { - return nil; - } - - // Legit usage of senderTimestamp - this class references the message it is quoting by it's sender timestamp - return [[TSQuotedMessage alloc] initWithTimestamp:timestamp - authorId:authorId - body:body - bodySource:bodySource - receivedQuotedAttachmentInfos:attachmentInfos]; -} - -+ (nullable TSAttachmentStream *)tryToDeriveLocalThumbnailWithTimestamp:(uint64_t)timestamp - threadId:(NSString *)threadId - authorId:(NSString *)authorId - contentType:(NSString *)contentType - transaction:(YapDatabaseReadWriteTransaction *)transaction -{ - TSMessage *_Nullable quotedMessage = - [self findQuotedMessageWithTimestamp:timestamp threadId:threadId authorId:authorId transaction:transaction]; - if (!quotedMessage) { - return nil; - } - - TSAttachment *_Nullable attachmentToQuote = nil; - if (quotedMessage.attachmentIds.count > 0) { - attachmentToQuote = [quotedMessage attachmentsWithTransaction:transaction].firstObject; - } else if (quotedMessage.linkPreview && quotedMessage.linkPreview.imageAttachmentId.length > 0) { - attachmentToQuote = - [TSAttachment fetchObjectWithUniqueID:quotedMessage.linkPreview.imageAttachmentId transaction:transaction]; - } - if (![attachmentToQuote isKindOfClass:[TSAttachmentStream class]]) { - return nil; - } - if (![TSAttachmentStream hasThumbnailForMimeType:contentType]) { - return nil; - } - TSAttachmentStream *sourceStream = (TSAttachmentStream *)attachmentToQuote; - return [sourceStream cloneAsThumbnail]; -} - -+ (nullable TSMessage *)findQuotedMessageWithTimestamp:(uint64_t)timestamp - threadId:(NSString *)threadId - authorId:(NSString *)authorId - transaction:(YapDatabaseReadWriteTransaction *)transaction -{ - if (timestamp <= 0) { - return nil; - } - if (threadId.length <= 0) { - return nil; - } - if (authorId.length <= 0) { - return nil; - } - - for (TSMessage *message in - [TSInteraction interactionsWithTimestamp:timestamp ofClass:TSMessage.class withTransaction:transaction]) { - if (![message.uniqueThreadId isEqualToString:threadId]) { - continue; - } - if ([message isKindOfClass:[TSIncomingMessage class]]) { - TSIncomingMessage *incomingMessage = (TSIncomingMessage *)message; - if (![authorId isEqual:incomingMessage.authorId]) { - continue; - } - } else if ([message isKindOfClass:[TSOutgoingMessage class]]) { - if (![authorId isEqual:[TSAccountManager localNumber]]) { - continue; - } - } - - return message; - } - return nil; -} - -#pragma mark - Attachment (not necessarily with a thumbnail) - -- (nullable OWSAttachmentInfo *)firstAttachmentInfo -{ - return self.quotedAttachments.firstObject; -} - -- (nullable NSString *)contentType -{ - OWSAttachmentInfo *firstAttachment = self.firstAttachmentInfo; - - return firstAttachment.contentType; -} - -- (nullable NSString *)sourceFilename -{ - OWSAttachmentInfo *firstAttachment = self.firstAttachmentInfo; - - return firstAttachment.sourceFilename; -} - -- (nullable NSString *)thumbnailAttachmentPointerId -{ - OWSAttachmentInfo *firstAttachment = self.firstAttachmentInfo; - - return firstAttachment.thumbnailAttachmentPointerId; -} - -- (nullable NSString *)thumbnailAttachmentStreamId -{ - OWSAttachmentInfo *firstAttachment = self.firstAttachmentInfo; - - return firstAttachment.thumbnailAttachmentStreamId; -} - -- (void)setThumbnailAttachmentStream:(TSAttachmentStream *)attachmentStream -{ - OWSAttachmentInfo *firstAttachment = self.firstAttachmentInfo; - firstAttachment.thumbnailAttachmentStreamId = attachmentStream.uniqueId; -} - -- (NSArray *)thumbnailAttachmentStreamIds -{ - NSMutableArray *streamIds = [NSMutableArray new]; - for (OWSAttachmentInfo *info in self.quotedAttachments) { - if (info.thumbnailAttachmentStreamId) { - [streamIds addObject:info.thumbnailAttachmentStreamId]; - } - } - - return [streamIds copy]; -} - -// Before sending, persist a thumbnail attachment derived from the quoted attachment -- (NSArray *)createThumbnailAttachmentsIfNecessaryWithTransaction: - (YapDatabaseReadWriteTransaction *)transaction -{ - NSMutableArray *thumbnailAttachments = [NSMutableArray new]; - - for (OWSAttachmentInfo *info in self.quotedAttachments) { - TSAttachment *attachment = [TSAttachment fetchObjectWithUniqueID:info.attachmentId transaction:transaction]; - if (![attachment isKindOfClass:[TSAttachmentStream class]]) { - continue; - } - TSAttachmentStream *sourceStream = (TSAttachmentStream *)attachment; - - TSAttachmentStream *_Nullable thumbnailStream = [sourceStream cloneAsThumbnail]; - if (!thumbnailStream) { - continue; - } - - [thumbnailStream saveWithTransaction:transaction]; - info.thumbnailAttachmentStreamId = thumbnailStream.uniqueId; - [thumbnailAttachments addObject:thumbnailStream]; - } - - return [thumbnailAttachments copy]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/To Do/OWSRecipientIdentity.h b/SessionMessagingKit/To Do/OWSRecipientIdentity.h deleted file mode 100644 index d2023cf35..000000000 --- a/SessionMessagingKit/To Do/OWSRecipientIdentity.h +++ /dev/null @@ -1,47 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -typedef NS_ENUM(NSUInteger, OWSVerificationState) { - OWSVerificationStateDefault, - OWSVerificationStateVerified, - OWSVerificationStateNoLongerVerified, -}; - -@class SNProtoVerified; - -NSString *OWSVerificationStateToString(OWSVerificationState verificationState); - -@interface OWSRecipientIdentity : TSYapDatabaseObject - -@property (nonatomic, readonly) NSString *recipientId; -@property (nonatomic, readonly) NSData *identityKey; -@property (nonatomic, readonly) NSDate *createdAt; -@property (nonatomic, readonly) BOOL isFirstKnownKey; - -#pragma mark - Verification State - -@property (atomic, readonly) OWSVerificationState verificationState; - -- (void)updateWithVerificationState:(OWSVerificationState)verificationState - transaction:(YapDatabaseReadWriteTransaction *)transaction; - -#pragma mark - Initializers - -- (instancetype)initWithUniqueId:(NSString *_Nullable)uniqueId NS_UNAVAILABLE; - -- (instancetype)initWithCoder:(NSCoder *)coder NS_DESIGNATED_INITIALIZER; - -- (instancetype)initWithRecipientId:(NSString *)recipientId - identityKey:(NSData *)identityKey - isFirstKnownKey:(BOOL)isFirstKnownKey - createdAt:(NSDate *)createdAt - verificationState:(OWSVerificationState)verificationState NS_DESIGNATED_INITIALIZER; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/To Do/OWSRecipientIdentity.m b/SessionMessagingKit/To Do/OWSRecipientIdentity.m deleted file mode 100644 index d402c6a91..000000000 --- a/SessionMessagingKit/To Do/OWSRecipientIdentity.m +++ /dev/null @@ -1,115 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSRecipientIdentity.h" -#import "OWSPrimaryStorage.h" -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -NSString *OWSVerificationStateToString(OWSVerificationState verificationState) -{ - switch (verificationState) { - case OWSVerificationStateDefault: - return @"OWSVerificationStateDefault"; - case OWSVerificationStateVerified: - return @"OWSVerificationStateVerified"; - case OWSVerificationStateNoLongerVerified: - return @"OWSVerificationStateNoLongerVerified"; - } -} - -@interface OWSRecipientIdentity () - -@property (atomic) OWSVerificationState verificationState; - -@end - -/** - * Record for a recipients identity key and some meta data around it used to make trust decisions. - * - * NOTE: Instances of this class MUST only be retrieved/persisted via it's internal `dbConnection`, - * which makes some special accomodations to enforce consistency. - */ -@implementation OWSRecipientIdentity - -- (instancetype)initWithCoder:(NSCoder *)coder -{ - self = [super initWithCoder:coder]; - - if (self) { - if (![coder decodeObjectForKey:@"verificationState"]) { - _verificationState = OWSVerificationStateDefault; - } - } - - return self; -} - -- (instancetype)initWithRecipientId:(NSString *)recipientId - identityKey:(NSData *)identityKey - isFirstKnownKey:(BOOL)isFirstKnownKey - createdAt:(NSDate *)createdAt - verificationState:(OWSVerificationState)verificationState -{ - self = [super initWithUniqueId:recipientId]; - if (!self) { - return self; - } - - _recipientId = recipientId; - _identityKey = identityKey; - _isFirstKnownKey = isFirstKnownKey; - _createdAt = createdAt; - _verificationState = verificationState; - - return self; -} - -- (void)updateWithVerificationState:(OWSVerificationState)verificationState - transaction:(YapDatabaseReadWriteTransaction *)transaction -{ - // Ensure changes are persisted without clobbering any work done on another thread or instance. - [self updateWithChangeBlock:^(OWSRecipientIdentity *_Nonnull obj) { - obj.verificationState = verificationState; - } - transaction:transaction]; -} - -- (void)updateWithChangeBlock:(void (^)(OWSRecipientIdentity *obj))changeBlock - transaction:(YapDatabaseReadWriteTransaction *)transaction -{ - changeBlock(self); - - OWSRecipientIdentity *latest = [[self class] fetchObjectWithUniqueID:self.uniqueId transaction:transaction]; - if (latest == nil) { - [self saveWithTransaction:transaction]; - return; - } - - changeBlock(latest); - [latest saveWithTransaction:transaction]; -} - -- (void)updateWithChangeBlock:(void (^)(OWSRecipientIdentity *obj))changeBlock -{ - changeBlock(self); - - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) { - OWSRecipientIdentity *latest = [[self class] fetchObjectWithUniqueID:self.uniqueId transaction:transaction]; - if (latest == nil) { - [self saveWithTransaction:transaction]; - return; - } - - changeBlock(latest); - [latest saveWithTransaction:transaction]; - }]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/To Do/OWSUserProfile.h b/SessionMessagingKit/To Do/OWSUserProfile.h deleted file mode 100644 index ddc23c062..000000000 --- a/SessionMessagingKit/To Do/OWSUserProfile.h +++ /dev/null @@ -1,25 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -//extern NSString *const kNSNotificationName_LocalProfileDidChange; -//extern NSString *const kNSNotificationName_OtherUsersProfileDidChange; -//extern NSString *const kNSNotificationKey_ProfileRecipientId; - -@interface OWSUserProfile : TSYapDatabaseObject - -+ (NSString *)profileAvatarFilepathWithFilename:(NSString *)filename; -+ (nullable NSError *)migrateToSharedData; -+ (NSString *)legacyProfileAvatarsDirPath; -+ (NSString *)sharedDataProfileAvatarsDirPath; -+ (NSString *)profileAvatarsDirPath; -+ (void)resetProfileStorage; -//+ (NSSet *)allProfileAvatarFilePaths; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/To Do/OWSUserProfile.m b/SessionMessagingKit/To Do/OWSUserProfile.m deleted file mode 100644 index 80088ffe1..000000000 --- a/SessionMessagingKit/To Do/OWSUserProfile.m +++ /dev/null @@ -1,86 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSUserProfile.h" -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface OWSUserProfile () - -@end - -@implementation OWSUserProfile - -+ (NSString *)profileAvatarFilepathWithFilename:(NSString *)filename -{ - if (filename.length <= 0) { return @""; }; - - return [self.profileAvatarsDirPath stringByAppendingPathComponent:filename]; -} - -+ (NSString *)legacyProfileAvatarsDirPath -{ - return [[OWSFileSystem appDocumentDirectoryPath] stringByAppendingPathComponent:@"ProfileAvatars"]; -} - -+ (NSString *)sharedDataProfileAvatarsDirPath -{ - return [[OWSFileSystem appSharedDataDirectoryPath] stringByAppendingPathComponent:@"ProfileAvatars"]; -} - -+ (nullable NSError *)migrateToSharedData -{ - return [OWSFileSystem moveAppFilePath:self.legacyProfileAvatarsDirPath - sharedDataFilePath:self.sharedDataProfileAvatarsDirPath]; -} - -+ (NSString *)profileAvatarsDirPath -{ - static NSString *profileAvatarsDirPath = nil; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - profileAvatarsDirPath = self.sharedDataProfileAvatarsDirPath; - - [OWSFileSystem ensureDirectoryExists:profileAvatarsDirPath]; - }); - return profileAvatarsDirPath; -} - -+ (void)resetProfileStorage -{ - NSError *error; - [[NSFileManager defaultManager] removeItemAtPath:[self profileAvatarsDirPath] error:&error]; -} - -//+ (NSSet *)allProfileAvatarFilePaths -//{ -// NSString *profileAvatarsDirPath = self.profileAvatarsDirPath; -// NSMutableSet *profileAvatarFilePaths = [NSMutableSet new]; -// -// NSSet *allContacts = [LKStorage.shared getAllContacts]; -// -// for (SNContact *contact in allContacts) { -// if (contact.profilePictureFileName == nil) { continue; } -// NSString *filePath = [profileAvatarsDirPath stringByAppendingPathComponent:contact.profilePictureFileName]; -// [profileAvatarFilePaths addObject:filePath]; -// } -// -// return [profileAvatarFilePaths copy]; -//} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Utilities/OWSAudioPlayer.m b/SessionMessagingKit/Utilities/OWSAudioPlayer.m index 5039c5de9..f59af6416 100644 --- a/SessionMessagingKit/Utilities/OWSAudioPlayer.m +++ b/SessionMessagingKit/Utilities/OWSAudioPlayer.m @@ -3,7 +3,6 @@ // #import "OWSAudioPlayer.h" -#import "TSAttachmentStream.h" #import #import #import diff --git a/SessionMessagingKit/Utilities/OWSPreferences.h b/SessionMessagingKit/Utilities/OWSPreferences.h index 412c5f61d..48a95067b 100644 --- a/SessionMessagingKit/Utilities/OWSPreferences.h +++ b/SessionMessagingKit/Utilities/OWSPreferences.h @@ -23,7 +23,6 @@ NSString *NSStringForNotificationType(NotificationType value); // Used when migrating logging to NSUserDefaults. extern NSString *const OWSPreferencesSignalDatabaseCollection; -extern NSString *const OWSPreferencesKeyEnableDebugLog; extern NSString *const OWSPreferencesCallLoggingDidChangeNotification; @class YapDatabaseReadWriteTransaction; diff --git a/SessionMessagingKit/Utilities/OWSPreferences.m b/SessionMessagingKit/Utilities/OWSPreferences.m index cd666e3d2..3aa051865 100644 --- a/SessionMessagingKit/Utilities/OWSPreferences.m +++ b/SessionMessagingKit/Utilities/OWSPreferences.m @@ -8,7 +8,6 @@ NS_ASSUME_NONNULL_BEGIN NSString *const OWSPreferencesSignalDatabaseCollection = @"SignalPreferences"; NSString *const OWSPreferencesCallLoggingDidChangeNotification = @"OWSPreferencesCallLoggingDidChangeNotification"; -NSString *const OWSPreferencesKeyHasSentAMessage = @"User has sent a message"; NSString *const OWSPreferencesKeyCallKitEnabled = @"CallKitEnabled"; NSString *const OWSPreferencesKeyCallKitPrivacyEnabled = @"CallKitPrivacyEnabled"; NSString *const OWSPreferencesKeyCallsHideIPAddress = @"CallsHideIPAddress"; @@ -86,21 +85,6 @@ NSString *const OWSPreferencesKeySystemCallLogEnabled = @"OWSPreferencesKeySyste [NSUserDefaults.appUserDefaults synchronize]; } -- (BOOL)hasSentAMessage -{ - NSNumber *preference = [self tryGetValueForKey:OWSPreferencesKeyHasSentAMessage]; - if (preference) { - return [preference boolValue]; - } else { - return NO; - } -} - -- (void)setHasSentAMessage:(BOOL)enabled -{ - [self setValueForKey:OWSPreferencesKeyHasSentAMessage toValue:@(enabled)]; -} - - (BOOL)hasDeclinedNoContactsView { NSNumber *preference = [self tryGetValueForKey:OWSPreferencesKeyHasDeclinedNoContactsView]; diff --git a/SessionMessagingKit/Utilities/ProfileManager.swift b/SessionMessagingKit/Utilities/ProfileManager.swift index 550132d19..f74e5711a 100644 --- a/SessionMessagingKit/Utilities/ProfileManager.swift +++ b/SessionMessagingKit/Utilities/ProfileManager.swift @@ -7,24 +7,6 @@ import SignalCoreKit import SessionUtilitiesKit public struct ProfileManager { - public enum Error: LocalizedError { - case avatarImageTooLarge - case avatarWriteFailed - case avatarEncryptionFailed - case avatarUploadFailed - case avatarUploadMaxFileSizeExceeded - - var localizedDescription: String { - switch self { - case .avatarImageTooLarge: return "Avatar image too large." - case .avatarWriteFailed: return "Avatar write failed." - case .avatarEncryptionFailed: return "Avatar encryption failed." - case .avatarUploadFailed: return "Avatar upload failed." - case .avatarUploadMaxFileSizeExceeded: return "Maximum file size exceeded." - } - } - } - // The max bytes for a user's profile name, encoded in UTF8. // Before encrypting and submitting we NULL pad the name data to this length. private static let nameDataLength: UInt = 26 @@ -79,7 +61,7 @@ public struct ProfileManager { } private static func loadProfileData(with fileName: String) -> Data? { - let filePath: String = OWSUserProfile.profileAvatarFilepath(withFilename: fileName) + let filePath: String = ProfileManager.profileAvatarFilepath(filename: fileName) return try? Data(contentsOf: URL(fileURLWithPath: filePath)) } @@ -98,6 +80,33 @@ public struct ProfileManager { return Cryptography.decryptAESGCMProfileData(encryptedData: data, key: key) } + // MARK: - File Paths + + private static let sharedDataProfileAvatarsDirPath: String = { + URL(fileURLWithPath: OWSFileSystem.appSharedDataDirectoryPath()) + .appendingPathComponent("ProfileAvatars") + .path + }() + + private static let profileAvatarsDirPath: String = { + let path: String = ProfileManager.sharedDataProfileAvatarsDirPath + OWSFileSystem.ensureDirectoryExists(path) + + return path + }() + + public static func profileAvatarFilepath(filename: String) -> String { + guard !filename.isEmpty else { return "" } + + return URL(fileURLWithPath: OWSFileSystem.appSharedDataDirectoryPath()) + .appendingPathComponent(filename) + .path + } + + public static func resetProfileStorage() { + try? FileManager.default.removeItem(atPath: ProfileManager.profileAvatarsDirPath) + } + // MARK: - Other Users' Profiles public static func downloadAvatar(for profile: Profile, funcName: String = #function) { @@ -121,7 +130,7 @@ public struct ProfileManager { } let fileName: String = UUID().uuidString.appendingFileExtension("jpg") - let filePath: String = OWSUserProfile.profileAvatarFilepath(withFilename: fileName) + let filePath: String = ProfileManager.profileAvatarFilepath(filename: fileName) var backgroundTask: OWSBackgroundTask? = OWSBackgroundTask(label: funcName) DispatchQueue.global(qos: .default).async { @@ -190,8 +199,8 @@ public struct ProfileManager { profileName: String, avatarImage: UIImage?, requiredSync: Bool, - success: ((Profile) -> ())? = nil, - failure: ((Error) -> ())? = nil + success: ((Database, Profile) throws -> ())? = nil, + failure: ((ProfileManagerError) -> ())? = nil ) { DispatchQueue.global(qos: .default).async { // If the profile avatar was updated or removed then encrypt with a new profile key @@ -200,40 +209,35 @@ public struct ProfileManager { guard let avatarImage: UIImage = avatarImage else { // If we have no image then we need to make sure to remove it from the profile - GRDBStorage.shared.writeAsync( - updates: { db in - let existingProfile: Profile = Profile.fetchOrCreateCurrentUser(db) - - OWSLogger.verbose(existingProfile.profilePictureUrl != nil ? - "Updating local profile on service with cleared avatar." : - "Updating local profile on service with no avatar." - ) - - let updatedProfile: Profile = try existingProfile - .with( - name: profileName, - profilePictureUrl: nil, - profilePictureFileName: nil, - profileEncryptionKey: (existingProfile.profilePictureUrl != nil ? - .update(newProfileKey) : - .existing - ) + GRDBStorage.shared.writeAsync { db in + let existingProfile: Profile = Profile.fetchOrCreateCurrentUser(db) + + OWSLogger.verbose(existingProfile.profilePictureUrl != nil ? + "Updating local profile on service with cleared avatar." : + "Updating local profile on service with no avatar." + ) + + let updatedProfile: Profile = try existingProfile + .with( + name: profileName, + profilePictureUrl: nil, + profilePictureFileName: nil, + profileEncryptionKey: (existingProfile.profilePictureUrl != nil ? + .update(newProfileKey) : + .existing ) - .saved(db) - - // Remove any cached avatar image value - if let fileName: String = existingProfile.profilePictureFileName { - profileAvatarCache.mutate { $0[fileName] = nil } - } - - SNLog("Successfully updated service with profile.") - - DispatchQueue.main.async { - success?(updatedProfile) - } - }, - completion: { _, _ in } - ) + ) + .saved(db) + + // Remove any cached avatar image value + if let fileName: String = existingProfile.profilePictureFileName { + profileAvatarCache.mutate { $0[fileName] = nil } + } + + SNLog("Successfully updated service with profile.") + + try success?(db, updatedProfile) + } return } @@ -276,7 +280,7 @@ public struct ProfileManager { } let fileName: String = UUID().uuidString.appendingFileExtension("jpg") - let filePath: String = OWSUserProfile.profileAvatarFilepath(withFilename: fileName) + let filePath: String = ProfileManager.profileAvatarFilepath(filename: fileName) // Write the avatar to disk do { try data.write(to: URL(fileURLWithPath: filePath), options: [.atomic]) } @@ -304,28 +308,23 @@ public struct ProfileManager { let downloadUrl: String = "\(FileServerAPIV2.server)/files/\(fileId)" UserDefaults.standard[.lastProfilePictureUpload] = Date() - GRDBStorage.shared.writeAsync( - updates: { db in - let profile: Profile = try Profile - .fetchOrCreateCurrentUser(db) - .with( - name: profileName, - profilePictureUrl: .update(downloadUrl), - profilePictureFileName: .update(fileName), - profileEncryptionKey: .update(newProfileKey) - ) - .saved(db) - - // Update the cached avatar image value - profileAvatarCache.mutate { $0[fileName] = avatarImage } - - DispatchQueue.main.async { - SNLog("Successfully updated service with profile.") - success?(profile) - } - }, - completion: { _, _ in } - ) + GRDBStorage.shared.writeAsync { db in + let profile: Profile = try Profile + .fetchOrCreateCurrentUser(db) + .with( + name: profileName, + profilePictureUrl: .update(downloadUrl), + profilePictureFileName: .update(fileName), + profileEncryptionKey: .update(newProfileKey) + ) + .saved(db) + + // Update the cached avatar image value + profileAvatarCache.mutate { $0[fileName] = avatarImage } + + SNLog("Successfully updated service with profile.") + try success?(db, profile) + } } .recover { error in DispatchQueue.main.async { @@ -342,15 +341,3 @@ public struct ProfileManager { } } } - -// MARK: - Objective-C Support -@objc(SMKProfileManager) -public class SMKProfileManager: NSObject { - @objc public static func profileAvatar(recipientId: String) -> UIImage? { - return ProfileManager.profileAvatar(id: recipientId) - } - - @objc public static func updateLocal(profileName: String, avatarImage: UIImage?, requiresSync: Bool) { - ProfileManager.updateLocal(profileName: profileName, avatarImage: avatarImage, requiredSync: requiresSync) - } -} diff --git a/SessionMessagingKit/Utilities/ProfileManagerError.swift b/SessionMessagingKit/Utilities/ProfileManagerError.swift new file mode 100644 index 000000000..1be60fbad --- /dev/null +++ b/SessionMessagingKit/Utilities/ProfileManagerError.swift @@ -0,0 +1,21 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum ProfileManagerError: LocalizedError { + case avatarImageTooLarge + case avatarWriteFailed + case avatarEncryptionFailed + case avatarUploadFailed + case avatarUploadMaxFileSizeExceeded + + var localizedDescription: String { + switch self { + case .avatarImageTooLarge: return "Avatar image too large." + case .avatarWriteFailed: return "Avatar write failed." + case .avatarEncryptionFailed: return "Avatar encryption failed." + case .avatarUploadFailed: return "Avatar upload failed." + case .avatarUploadMaxFileSizeExceeded: return "Maximum file size exceeded." + } + } +} diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index f4798cde6..88400b6bc 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -251,7 +251,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView // one then add it now if isSharingUrl, - let linkPreviewDraft: OWSLinkPreviewDraft = attachments.first?.linkPreviewDraft, + let linkPreviewDraft: LinkPreviewDraft = attachments.first?.linkPreviewDraft, (try? interaction.linkPreview.isEmpty(db)) == true { try LinkPreview( @@ -288,6 +288,11 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView } func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didChangeMessageText newMessageText: String?) { - // Do nothing + } + + func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didRemoveAttachment attachment: SignalAttachment) { + } + + func attachmentApprovalDidTapAddMore(_ attachmentApproval: AttachmentApprovalViewController) { } } diff --git a/SignalUtilitiesKit/Database/OWSPrimaryStorage+keyFromIntLong.h b/SignalUtilitiesKit/Database/OWSPrimaryStorage+keyFromIntLong.h deleted file mode 100644 index bb84530c0..000000000 --- a/SignalUtilitiesKit/Database/OWSPrimaryStorage+keyFromIntLong.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface OWSPrimaryStorage (keyFromIntLong) - -- (NSString *)keyFromInt:(int)integer; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Database/OWSPrimaryStorage+keyFromIntLong.m b/SignalUtilitiesKit/Database/OWSPrimaryStorage+keyFromIntLong.m deleted file mode 100644 index 428897fd5..000000000 --- a/SignalUtilitiesKit/Database/OWSPrimaryStorage+keyFromIntLong.m +++ /dev/null @@ -1,18 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "OWSPrimaryStorage+keyFromIntLong.h" - -NS_ASSUME_NONNULL_BEGIN - -@implementation OWSPrimaryStorage (keyFromIntLong) - -- (NSString *)keyFromInt:(int)integer -{ - return [[NSNumber numberWithInteger:integer] stringValue]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Database/ThreadViewHelper.h b/SignalUtilitiesKit/Database/ThreadViewHelper.h deleted file mode 100644 index 9f6f9c11c..000000000 --- a/SignalUtilitiesKit/Database/ThreadViewHelper.h +++ /dev/null @@ -1,30 +0,0 @@ -//// -//// Copyright (c) 2017 Open Whisper Systems. All rights reserved. -//// -// -//NS_ASSUME_NONNULL_BEGIN -// -//@protocol ThreadViewHelperDelegate -// -//- (void)threadListDidChange; -// -//@end -// -//#pragma mark - -// -//@class TSThread; -// -//// A helper class for views that want to present the list of threads -//// that show up in home view, and in the same order. -//// -//// It observes changes to the threads & their ordering and informs -//// its delegate when they happen. -//@interface ThreadViewHelper : NSObject -// -//@property (nonatomic, weak) id delegate; -// -//@property (nonatomic, readonly) NSMutableArray *threads; -// -//@end -// -//NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Database/ThreadViewHelper.m b/SignalUtilitiesKit/Database/ThreadViewHelper.m deleted file mode 100644 index 1ab0d1699..000000000 --- a/SignalUtilitiesKit/Database/ThreadViewHelper.m +++ /dev/null @@ -1,220 +0,0 @@ -//// -//// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -//// -// -//#import "ThreadViewHelper.h" -//#import -//#import -//#import -//#import -//#import -//#import -//#import -//#import -//#import -// -//NS_ASSUME_NONNULL_BEGIN -// -//@interface ThreadViewHelper () -// -//@property (nonatomic) YapDatabaseConnection *uiDatabaseConnection; -//@property (nonatomic) YapDatabaseViewMappings *threadMappings; -//@property (nonatomic) BOOL shouldObserveDBModifications; -// -//@end -// -//#pragma mark - -// -//@implementation ThreadViewHelper -// -//- (instancetype)init -//{ -// self = [super init]; -// if (!self) { -// return self; -// } -// -// [self initializeMapping]; -// -// return self; -//} -// -//- (void)dealloc -//{ -// [[NSNotificationCenter defaultCenter] removeObserver:self]; -//} -// -//- (void)initializeMapping -//{ -// OWSAssertIsOnMainThread(); -// -// NSString *grouping = TSInboxGroup; -// -// self.threadMappings = -// [[YapDatabaseViewMappings alloc] initWithGroups:@[ grouping ] view:TSThreadDatabaseViewExtensionName]; -// [self.threadMappings setIsReversed:YES forGroup:grouping]; -// -// self.uiDatabaseConnection = [OWSPrimaryStorage.sharedManager newDatabaseConnection]; -// [self.uiDatabaseConnection beginLongLivedReadTransaction]; -// -// [[NSNotificationCenter defaultCenter] addObserver:self -// selector:@selector(applicationDidBecomeActive:) -// name:OWSApplicationDidBecomeActiveNotification -// object:nil]; -// [[NSNotificationCenter defaultCenter] addObserver:self -// selector:@selector(applicationWillResignActive:) -// name:OWSApplicationWillResignActiveNotification -// object:nil]; -// -// [self updateShouldObserveDBModifications]; -//} -// -//- (void)applicationDidBecomeActive:(NSNotification *)notification -//{ -// [self updateShouldObserveDBModifications]; -//} -// -//- (void)applicationWillResignActive:(NSNotification *)notification -//{ -// [self updateShouldObserveDBModifications]; -//} -// -//- (void)updateShouldObserveDBModifications -//{ -// self.shouldObserveDBModifications = CurrentAppContext().isAppForegroundAndActive; -//} -// -//// Don't observe database change notifications when the app is in the background. -//// -//// Instead, rebuild model state when app enters foreground. -//- (void)setShouldObserveDBModifications:(BOOL)shouldObserveDBModifications -//{ -// if (_shouldObserveDBModifications == shouldObserveDBModifications) { -// return; -// } -// -// _shouldObserveDBModifications = shouldObserveDBModifications; -// -// if (shouldObserveDBModifications) { -// [self.uiDatabaseConnection beginLongLivedReadTransaction]; -// [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { -// [self.threadMappings updateWithTransaction:transaction]; -// }]; -// [self updateThreads]; -// [self.delegate threadListDidChange]; -// -// [[NSNotificationCenter defaultCenter] addObserver:self -// selector:@selector(yapDatabaseModified:) -// name:YapDatabaseModifiedNotification -// object:OWSPrimaryStorage.sharedManager.dbNotificationObject]; -// [[NSNotificationCenter defaultCenter] addObserver:self -// selector:@selector(yapDatabaseModifiedExternally:) -// name:YapDatabaseModifiedExternallyNotification -// object:nil]; -// } else { -// [[NSNotificationCenter defaultCenter] removeObserver:self -// name:YapDatabaseModifiedNotification -// object:OWSPrimaryStorage.sharedManager.dbNotificationObject]; -// [[NSNotificationCenter defaultCenter] removeObserver:self -// name:YapDatabaseModifiedExternallyNotification -// object:nil]; -// } -//} -// -//#pragma mark - Database -// -//- (YapDatabaseConnection *)uiDatabaseConnection -//{ -// OWSAssertIsOnMainThread(); -// -// return _uiDatabaseConnection; -//} -// -//- (void)yapDatabaseModifiedExternally:(NSNotification *)notification -//{ -// OWSAssertIsOnMainThread(); -// -// OWSLogVerbose(@""); -// -// if (self.shouldObserveDBModifications) { -// // External database modifications can't be converted into incremental updates, -// // so rebuild everything. This is expensive and usually isn't necessary, but -// // there's no alternative. -// // -// // We don't need to do this if we're not observing db modifications since we'll -// // do it when we resume. -// [self.uiDatabaseConnection beginLongLivedReadTransaction]; -// [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { -// [self.threadMappings updateWithTransaction:transaction]; -// }]; -// -// [self updateThreads]; -// [self.delegate threadListDidChange]; -// } -//} -// -//- (void)yapDatabaseModified:(NSNotification *)notification -//{ -// OWSAssertIsOnMainThread(); -// -// OWSLogVerbose(@""); -// -// NSArray *notifications = [self.uiDatabaseConnection beginLongLivedReadTransaction]; -// -// if (! -// [[self.uiDatabaseConnection ext:TSMessageDatabaseViewExtensionName] hasChangesForNotifications:notifications]) { -// [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { -// [self.threadMappings updateWithTransaction:transaction]; -// }]; -// return; -// } -// -// NSArray *sectionChanges = nil; -// NSArray *rowChanges = nil; -// [[self.uiDatabaseConnection ext:TSThreadDatabaseViewExtensionName] getSectionChanges:§ionChanges -// rowChanges:&rowChanges -// forNotifications:notifications -// withMappings:self.threadMappings]; -// -// if (sectionChanges.count == 0 && rowChanges.count == 0) { -// // Ignore irrelevant modifications. -// return; -// } -// -// [self updateThreads]; -// -// [self.delegate threadListDidChange]; -//} -// -//- (void)updateThreads -//{ -// OWSAssertIsOnMainThread(); -// -// NSMutableArray *threads = [NSMutableArray new]; -// [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { -// NSUInteger numberOfSections = [self.threadMappings numberOfSections]; -// OWSAssertDebug(numberOfSections == 1); -// for (NSUInteger section = 0; section < numberOfSections; section++) { -// NSUInteger numberOfItems = [self.threadMappings numberOfItemsInSection:section]; -// for (NSUInteger item = 0; item < numberOfItems; item++) { -// TSThread *thread = [[transaction extension:TSThreadDatabaseViewExtensionName] -// objectAtIndexPath:[NSIndexPath indexPathForItem:(NSInteger)item inSection:(NSInteger)section] -// withMappings:self.threadMappings]; -// if (!thread.shouldBeVisible) { continue; } -// if ([thread isKindOfClass:TSContactThread.class]) { -// NSString *publicKey = ((TSContactThread *)thread).contactSessionID; -// if ([[LKStorage.shared getContactWithSessionID:publicKey] name] == nil) { continue; } -// [threads addObject:thread]; -// } else { -// [threads addObject:thread]; -// } -// } -// } -// }]; -// -// _threads = [threads copy]; -//} -// -//@end -// -//NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift index c431e3c22..52b17af03 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift @@ -7,6 +7,7 @@ import MediaPlayer import YYImage import NVActivityIndicatorView import SessionUIKit +import SessionMessagingKit public protocol MediaMessageViewAudioDelegate: AnyObject { func progressChanged(_ progressSeconds: CGFloat, durationSeconds: CGFloat) @@ -85,7 +86,7 @@ public class MediaMessageView: UIView, OWSAudioPlayerDelegate { return image }() - private var linkPreviewInfo: (url: String, draft: OWSLinkPreviewDraft?)? + private var linkPreviewInfo: (url: String, draft: LinkPreviewDraft?)? // MARK: Initializers @@ -103,7 +104,7 @@ public class MediaMessageView: UIView, OWSAudioPlayerDelegate { self.mode = mode // Set the linkPreviewUrl if it's a url - if attachment.isUrl, let linkPreviewURL: String = OWSLinkPreview.previewURL(forRawBodyText: attachment.text()) { + if attachment.isUrl, let linkPreviewURL: String = LinkPreview.previewUrl(for: attachment.text()) { self.linkPreviewInfo = (url: linkPreviewURL, draft: nil) } @@ -543,7 +544,7 @@ public class MediaMessageView: UIView, OWSAudioPlayerDelegate { private func loadLinkPreview(linkPreviewURL: String) { loadingView.startAnimating() - OWSLinkPreview.tryToBuildPreviewInfo(previewUrl: linkPreviewURL) + LinkPreview.tryToBuildPreviewInfo(previewUrl: linkPreviewURL) .done { [weak self] draft in // TODO: Look at refactoring this behaviour to consolidate attachment mutations self?.attachment.linkPreviewDraft = draft diff --git a/SignalUtilitiesKit/Media Viewing & Editing/MessageApprovalViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/MessageApprovalViewController.swift deleted file mode 100644 index e57317abc..000000000 --- a/SignalUtilitiesKit/Media Viewing & Editing/MessageApprovalViewController.swift +++ /dev/null @@ -1,209 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -import Foundation -import SessionUIKit - -@objc -public protocol MessageApprovalViewControllerDelegate: class { - func messageApproval(_ messageApproval: MessageApprovalViewController, didApproveMessage messageText: String) - func messageApprovalDidCancel(_ messageApproval: MessageApprovalViewController) -} - -@objc -public class MessageApprovalViewController: OWSViewController, UITextViewDelegate { - - weak var delegate: MessageApprovalViewControllerDelegate? - - // MARK: Properties - - let thread: TSThread - let initialMessageText: String - - private(set) var textView: UITextView! - private var sendButton: UIBarButtonItem! - - // MARK: Initializers - - @available(*, unavailable, message:"use attachment: constructor instead.") - required public init?(coder aDecoder: NSCoder) { - notImplemented() - } - - @objc - required public init(messageText: String, thread: TSThread, delegate: MessageApprovalViewControllerDelegate) { - self.initialMessageText = messageText - self.thread = thread - self.delegate = delegate - - super.init(nibName: nil, bundle: nil) - } - - // MARK: View Lifecycle - - override public func viewDidLoad() { - super.viewDidLoad() - - self.navigationItem.title = NSLocalizedString("MESSAGE_APPROVAL_DIALOG_TITLE", - comment: "Title for the 'message approval' dialog.") - - self.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .stop, target: self, action: #selector(cancelPressed)) - sendButton = UIBarButtonItem(title: MessageStrings.sendButton, - style: .plain, - target: self, - action: #selector(sendPressed)) - self.navigationItem.rightBarButtonItem = sendButton - } - - private func updateSendButton() { - sendButton.isEnabled = textView.text.count > 0 - } - - override public func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - updateSendButton() - } - - // MARK: - Create Views - - public override func loadView() { - - self.view = UIView.container() - self.view.backgroundColor = Colors.navigationBarBackground - - // Recipient Row - let recipientRow = createRecipientRow() - view.addSubview(recipientRow) - recipientRow.autoPinEdge(toSuperviewSafeArea: .leading) - recipientRow.autoPinEdge(toSuperviewSafeArea: .trailing) - recipientRow.autoPinEdge(.bottom, to: .bottom, of: view) - - // Text View - textView = OWSTextView() - textView.delegate = self - textView.backgroundColor = Colors.navigationBarBackground - textView.textColor = Colors.text - textView.font = UIFont.ows_dynamicTypeBody - textView.text = self.initialMessageText - textView.contentInset = UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0) - textView.textContainerInset = UIEdgeInsets(top: 10.0, left: 10.0, bottom: 10.0, right: 10.0) - view.addSubview(textView) - textView.autoPinEdge(toSuperviewSafeArea: .leading) - textView.autoPinEdge(toSuperviewSafeArea: .trailing) - textView.autoPinEdge(.top, to: .bottom, of: recipientRow) - textView.autoPinEdge(.bottom, to: .bottom, of: view) - } - - private func createRecipientRow() -> UIView { - let recipientRow = UIView.container() - recipientRow.backgroundColor = UIColor.lokiDarkestGray() - - // Hairline borders should be 1 pixel, not 1 point. - let borderThickness = 1.0 / UIScreen.main.scale - let borderColor = UIColor(white: 0.5, alpha: 1) - - let topBorder = UIView.container() - topBorder.backgroundColor = borderColor - recipientRow.addSubview(topBorder) - topBorder.autoPinWidthToSuperview() - topBorder.autoPinTopToSuperviewMargin() - topBorder.autoSetDimension(.height, toSize: borderThickness) - - let bottomBorder = UIView.container() - bottomBorder.backgroundColor = borderColor - recipientRow.addSubview(bottomBorder) - bottomBorder.autoPinWidthToSuperview() - bottomBorder.autoPinBottomToSuperviewMargin() - bottomBorder.autoSetDimension(.height, toSize: borderThickness) - - let font = UIFont.ows_regularFont(withSize: ScaleFromIPhone5To7Plus(14.0, 18.0)) - let hSpacing = CGFloat(10) - let hMargin = CGFloat(15) - let vSpacing = CGFloat(5) - let vMargin = CGFloat(10) - - let toLabel = UILabel() - toLabel.text = NSLocalizedString("MESSAGE_APPROVAL_RECIPIENT_LABEL", - comment: "Label for the recipient name in the 'message approval' dialog.") - toLabel.textColor = Colors.separator - toLabel.font = font - recipientRow.addSubview(toLabel) - - let nameLabel = UILabel() - nameLabel.textColor = Colors.text - nameLabel.font = font - nameLabel.lineBreakMode = .byTruncatingTail - recipientRow.addSubview(nameLabel) - - toLabel.autoPinLeadingToSuperviewMargin(withInset: hMargin) - toLabel.setContentHuggingHorizontalHigh() - toLabel.setCompressionResistanceHorizontalHigh() - toLabel.autoAlignAxis(.horizontal, toSameAxisOf: nameLabel) - - nameLabel.autoPinLeading(toTrailingEdgeOf: toLabel, offset: hSpacing) - nameLabel.autoPinTrailingToSuperviewMargin(withInset: hMargin) - nameLabel.setContentHuggingHorizontalLow() - nameLabel.setCompressionResistanceHorizontalLow() - nameLabel.autoPinTopToSuperviewMargin(withInset: vMargin) - - if let groupThread = self.thread as? TSGroupThread { - let groupName = (groupThread.name().count > 0 - ? groupThread.name() - : MessageStrings.newGroupDefaultTitle) - - nameLabel.text = groupName - nameLabel.autoPinBottomToSuperviewMargin(withInset: vMargin) - - return recipientRow - } - guard let contactThread = self.thread as? TSContactThread else { - owsFailDebug("Unexpected thread type") - return recipientRow - } - - nameLabel.text = Profile.displayName(id: contactThread.contactSessionID()) - nameLabel.textColor = Colors.text - - if let profileName = self.profileName(contactThread: contactThread) { - // If there's a profile name worth showing, add it as a second line below the name. - let profileNameLabel = UILabel() - profileNameLabel.textColor = Colors.separator - profileNameLabel.font = font - profileNameLabel.text = profileName - profileNameLabel.lineBreakMode = .byTruncatingTail - recipientRow.addSubview(profileNameLabel) - profileNameLabel.autoPinEdge(.top, to: .bottom, of: nameLabel, withOffset: vSpacing) - profileNameLabel.autoPinLeading(toTrailingEdgeOf: toLabel, offset: hSpacing) - profileNameLabel.autoPinTrailingToSuperviewMargin(withInset: hMargin) - profileNameLabel.setContentHuggingHorizontalLow() - profileNameLabel.setCompressionResistanceHorizontalLow() - profileNameLabel.autoPinBottomToSuperviewMargin(withInset: vMargin) - } else { - nameLabel.autoPinBottomToSuperviewMargin(withInset: vMargin) - } - - return recipientRow - } - - private func profileName(contactThread: TSContactThread) -> String? { - return Profile.displayName(id: contactThread.contactSessionID()) - } - - // MARK: - Event Handlers - - @objc func cancelPressed(sender: UIButton) { - delegate?.messageApprovalDidCancel(self) - } - - @objc func sendPressed(sender: UIButton) { - delegate?.messageApproval(self, didApproveMessage: self.textView.text) - } - - // MARK: - UITextViewDelegate - - public func textViewDidChange(_ textView: UITextView) { - updateSendButton() - } -} diff --git a/SignalUtilitiesKit/Messaging/ConversationStyle.swift b/SignalUtilitiesKit/Messaging/ConversationStyle.swift deleted file mode 100644 index d64113a20..000000000 --- a/SignalUtilitiesKit/Messaging/ConversationStyle.swift +++ /dev/null @@ -1,240 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -import Foundation -import SessionUIKit - -@objc -public class ConversationStyle: NSObject { - - private let thread: TSThread - - // The width of the collection view. - @objc public var viewWidth: CGFloat = 0 { - didSet { - AssertIsOnMainThread() - - updateProperties() - } - } - - @objc public let contentMarginTop: CGFloat = Values.largeSpacing - @objc public let contentMarginBottom: CGFloat = Values.largeSpacing - - @objc public var gutterLeading: CGFloat = 0 - @objc public var gutterTrailing: CGFloat = 0 - - @objc public var headerGutterLeading: CGFloat = Values.veryLargeSpacing - @objc public var headerGutterTrailing: CGFloat = Values.veryLargeSpacing - - // These are the gutters used by "full width" views - // like "contact offer" and "info message". - @objc public var fullWidthGutterLeading: CGFloat = 0 - @objc public var fullWidthGutterTrailing: CGFloat = 0 - - @objc public var errorGutterTrailing: CGFloat = 0 - - @objc public var contentWidth: CGFloat { - return viewWidth - (gutterLeading + gutterTrailing) - } - - @objc public var fullWidthContentWidth: CGFloat { - return viewWidth - (fullWidthGutterLeading + fullWidthGutterTrailing) - } - - @objc public var headerViewContentWidth: CGFloat { - return viewWidth - (headerGutterLeading + headerGutterTrailing) - } - - @objc public var maxMessageWidth: CGFloat = 0 - - @objc public var textInsetTop: CGFloat = 0 - @objc public var textInsetBottom: CGFloat = 0 - @objc public var textInsetHorizontal: CGFloat = 0 - - // We want to align "group sender" avatars with the v-center of the - // "last line" of the message body text - or where it would be for - // non-text content. - // - // This is the distance from that v-center to the bottom of the - // message bubble. - @objc public var lastTextLineAxis: CGFloat = 0 - - @objc - public required init(thread: TSThread) { - - self.thread = thread - - super.init() - - updateProperties() - - NotificationCenter.default.addObserver(self, - selector: #selector(uiContentSizeCategoryDidChange), - name: UIContentSizeCategory.didChangeNotification, - object: nil) - } - - deinit { - NotificationCenter.default.removeObserver(self) - } - - @objc func uiContentSizeCategoryDidChange() { - AssertIsOnMainThread() - - updateProperties() - } - - // MARK: - - - @objc - public func updateProperties() { - gutterLeading = thread.isGroupThread() ? (12 + Values.smallProfilePictureSize + 12) : Values.mediumSpacing - gutterTrailing = Values.mediumSpacing - fullWidthGutterLeading = Values.mediumSpacing - fullWidthGutterTrailing = Values.mediumSpacing - headerGutterLeading = Values.mediumSpacing - headerGutterTrailing = Values.mediumSpacing - errorGutterTrailing = Values.mediumSpacing - - if thread is TSGroupThread { - maxMessageWidth = floor(contentWidth) - } else { - maxMessageWidth = floor(contentWidth - 32) - } - - let messageTextFont = UIFont.systemFont(ofSize: Values.smallFontSize) - - let baseFontOffset: CGFloat = 12 - - // Don't include the distance from the "cap height" to the top of the UILabel - // in the top margin. - textInsetTop = max(0, round(baseFontOffset - (messageTextFont.ascender - messageTextFont.capHeight))) - // Don't include the distance from the "baseline" to the bottom of the UILabel - // (e.g. the descender) in the top margin. Note that UIFont.descender is a - // negative value. - textInsetBottom = max(0, round(baseFontOffset - abs(messageTextFont.descender))) - - textInsetHorizontal = 12 - - lastTextLineAxis = CGFloat(round(baseFontOffset + messageTextFont.capHeight * 0.5)) - } - - // MARK: Colors - - @objc - private static var defaultBubbleColorIncoming: UIColor { - return Colors.receivedMessageBackground - } - - @objc - public let bubbleColorOutgoingFailed = Colors.sentMessageBackground - - @objc - public let bubbleColorOutgoingSending = Colors.sentMessageBackground - - @objc - public let bubbleColorOutgoingSent = Colors.sentMessageBackground - - @objc - public let dateBreakTextColor = UIColor.ows_gray60 - - @objc - public func bubbleColor(message: TSMessage) -> UIColor { - if message is TSIncomingMessage { - return ConversationStyle.defaultBubbleColorIncoming - } else if let outgoingMessage = message as? TSOutgoingMessage { - switch outgoingMessage.messageState { - case .failed: - return bubbleColorOutgoingFailed - case .sending: - return bubbleColorOutgoingSending - default: - return bubbleColorOutgoingSent - } - } else { - owsFailDebug("Unexpected message type: \(message)") - return bubbleColorOutgoingSent - } - } - - @objc - public func bubbleColor(isIncoming: Bool) -> UIColor { - if isIncoming { - return ConversationStyle.defaultBubbleColorIncoming - } else { - return self.bubbleColorOutgoingSent - } - } - - @objc - public static var bubbleTextColorIncoming: UIColor { - return Colors.text - } - - @objc - public static var bubbleTextColorOutgoing: UIColor { - return Colors.text - } - - @objc - public func bubbleTextColor(message: TSMessage) -> UIColor { - if message is TSIncomingMessage { - return ConversationStyle.bubbleTextColorIncoming - } else if message is TSOutgoingMessage { - return ConversationStyle.bubbleTextColorOutgoing - } else { - owsFailDebug("Unexpected message type: \(message)") - return ConversationStyle.bubbleTextColorOutgoing - } - } - - @objc - public func bubbleTextColor(isIncoming: Bool) -> UIColor { - if isIncoming { - return ConversationStyle.bubbleTextColorIncoming - } else { - return ConversationStyle.bubbleTextColorOutgoing - } - } - - @objc - public func bubbleSecondaryTextColor(isIncoming: Bool) -> UIColor { - return bubbleTextColor(isIncoming: isIncoming).withAlphaComponent(Values.mediumOpacity) - } - - @objc - public func quotedReplyBubbleColor(isIncoming: Bool) -> UIColor { - if isIncoming { - return Colors.sentMessageBackground - } else { - return Colors.receivedMessageBackground - } - } - - @objc - public func quotedReplyStripeColor(isIncoming: Bool) -> UIColor { - return isLightMode ? UIColor(hex: 0x272726) : Colors.accent - } - - @objc - public func quotingSelfHighlightColor() -> UIColor { - return UIColor.init(rgbHex: 0xB5B5B5) - } - - @objc - public func quotedReplyAuthorColor() -> UIColor { - return Colors.text - } - - @objc - public func quotedReplyTextColor() -> UIColor { - return Colors.text - } - - @objc - public func quotedReplyAttachmentColor() -> UIColor { - return Colors.text - } -} diff --git a/SignalUtilitiesKit/Messaging/DisappearingTimerConfigurationView.swift b/SignalUtilitiesKit/Messaging/DisappearingTimerConfigurationView.swift deleted file mode 100644 index ec5f9d86d..000000000 --- a/SignalUtilitiesKit/Messaging/DisappearingTimerConfigurationView.swift +++ /dev/null @@ -1,116 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -import Foundation - -@objc -public protocol DisappearingTimerConfigurationViewDelegate: class { - func disappearingTimerConfigurationViewWasTapped(_ disappearingTimerView: DisappearingTimerConfigurationView) -} - -// DisappearingTimerConfigurationView shows a timer icon and a short label showing the duration -// of disappearing messages for a thread. -// -// If you assign a delegate, it behaves like a button. -@objc -public class DisappearingTimerConfigurationView: UIView { - - @objc - public weak var delegate: DisappearingTimerConfigurationViewDelegate? { - didSet { - // gesture recognizer is only enabled when a delegate is assigned. - // This lets us use this view as either an interactive button - // or as a non-interactive status indicator - pressGesture.isEnabled = delegate != nil - } - } - - private let imageView: UIImageView - private let label: UILabel - private var pressGesture: UILongPressGestureRecognizer! - - public required init?(coder aDecoder: NSCoder) { - notImplemented() - } - - @objc - public init(durationSeconds: UInt32) { - self.imageView = UIImageView(image: #imageLiteral(resourceName: "ic_timer")) - imageView.contentMode = .scaleAspectFit - - self.label = UILabel() - label.text = NSString.formatDurationSeconds(durationSeconds, useShortFormat: true) - label.font = UIFont.systemFont(ofSize: 10) - label.textAlignment = .center - label.minimumScaleFactor = 0.5 - - super.init(frame: CGRect.zero) - - applyTintColor(self.tintColor) - - // Gesture, simulating button touch up inside - let gesture = UILongPressGestureRecognizer(target: self, action: #selector(pressHandler)) - gesture.minimumPressDuration = 0 - self.pressGesture = gesture - self.addGestureRecognizer(pressGesture) - - // disable gesture recognizer until a delegate is assigned - // this lets us use the UI as either an interactive button - // or as a non-interactive status indicator - pressGesture.isEnabled = false - - // Accessibility - self.accessibilityLabel = NSLocalizedString("DISAPPEARING_MESSAGES_LABEL", comment: "Accessibility label for disappearing messages") - let hintFormatString = NSLocalizedString("DISAPPEARING_MESSAGES_HINT", comment: "Accessibility hint that contains current timeout information") - let durationString = NSString.formatDurationSeconds(durationSeconds, useShortFormat: false) - self.accessibilityHint = String(format: hintFormatString, durationString) - - // Layout - self.addSubview(imageView) - self.addSubview(label) - - let kHorizontalPadding: CGFloat = 4 - let kVerticalPadding: CGFloat = 6 - imageView.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets(top: kVerticalPadding, left: kHorizontalPadding, bottom: 0, right: kHorizontalPadding), excludingEdge: .bottom) - label.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets(top: 0, left: kHorizontalPadding, bottom: kVerticalPadding, right: kHorizontalPadding), excludingEdge: .top) - label.autoPinEdge(.top, to: .bottom, of: imageView) - } - - @objc - func pressHandler(_ gestureRecognizer: UILongPressGestureRecognizer) { - Logger.verbose("") - - // handle touch down and touch up events separately - if gestureRecognizer.state == .began { - applyTintColor(UIColor.gray) - } else if gestureRecognizer.state == .ended { - applyTintColor(self.tintColor) - - let location = gestureRecognizer.location(in: self) - let isTouchUpInside = self.bounds.contains(location) - - if (isTouchUpInside) { - // Similar to a UIButton's touch-up-inside - self.delegate?.disappearingTimerConfigurationViewWasTapped(self) - } else { - // Similar to a UIButton's touch-up-outside - - // cancel gesture - gestureRecognizer.isEnabled = false - gestureRecognizer.isEnabled = true - } - } - } - - override public var tintColor: UIColor! { - didSet { - applyTintColor(tintColor) - } - } - - private func applyTintColor(_ color: UIColor) { - imageView.tintColor = color - label.textColor = color - } -} diff --git a/SignalUtilitiesKit/Messaging/OWSUnreadIndicator.h b/SignalUtilitiesKit/Messaging/OWSUnreadIndicator.h deleted file mode 100644 index ee6f7b37e..000000000 --- a/SignalUtilitiesKit/Messaging/OWSUnreadIndicator.h +++ /dev/null @@ -1,40 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -NS_ASSUME_NONNULL_BEGIN - -@interface OWSUnreadIndicator : NSObject - -@property (nonatomic, readonly) BOOL hasMoreUnseenMessages; - -@property (nonatomic, readonly) NSUInteger missingUnseenSafetyNumberChangeCount; - -// The sortId of the oldest unseen message. -// -// Once we enter messages view, we mark all messages read, so we need -// a snapshot of what the first unread message was when we entered the -// view so that we can call ensureDynamicInteractionsForThread:... -// repeatedly. The unread indicator should continue to show up until -// it has been cleared, at which point hideUnreadMessagesIndicator is -// YES in ensureDynamicInteractionsForThread:... -@property (nonatomic, readonly) uint64_t firstUnseenSortId; - -// The index of the unseen indicator, counting from the _end_ of the conversation -// history. -// -// This is used by MessageViewController to increase the -// range size of the mappings (the load window of the conversation) -// to include the unread indicator. -@property (nonatomic, readonly) NSInteger unreadIndicatorPosition; - -- (instancetype)init NS_UNAVAILABLE; - -- (instancetype)initWithFirstUnseenSortId:(uint64_t)firstUnseenSortId - hasMoreUnseenMessages:(BOOL)hasMoreUnseenMessages - missingUnseenSafetyNumberChangeCount:(NSUInteger)missingUnseenSafetyNumberChangeCount - unreadIndicatorPosition:(NSInteger)unreadIndicatorPosition NS_DESIGNATED_INITIALIZER; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Messaging/OWSUnreadIndicator.m b/SignalUtilitiesKit/Messaging/OWSUnreadIndicator.m deleted file mode 100644 index 3cc384df3..000000000 --- a/SignalUtilitiesKit/Messaging/OWSUnreadIndicator.m +++ /dev/null @@ -1,49 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSUnreadIndicator.h" - -NS_ASSUME_NONNULL_BEGIN - -@implementation OWSUnreadIndicator - -- (instancetype)initWithFirstUnseenSortId:(uint64_t)firstUnseenSortId - hasMoreUnseenMessages:(BOOL)hasMoreUnseenMessages - missingUnseenSafetyNumberChangeCount:(NSUInteger)missingUnseenSafetyNumberChangeCount - unreadIndicatorPosition:(NSInteger)unreadIndicatorPosition -{ - self = [super init]; - - if (!self) { - return self; - } - - _firstUnseenSortId = firstUnseenSortId; - _hasMoreUnseenMessages = hasMoreUnseenMessages; - _missingUnseenSafetyNumberChangeCount = missingUnseenSafetyNumberChangeCount; - _unreadIndicatorPosition = unreadIndicatorPosition; - - return self; -} - -- (BOOL)isEqual:(id)object -{ - if (self == object) { - return YES; - } - - if (![object isKindOfClass:[OWSUnreadIndicator class]]) { - return NO; - } - - OWSUnreadIndicator *other = object; - return (self.firstUnseenSortId == other.firstUnseenSortId - && self.hasMoreUnseenMessages == other.hasMoreUnseenMessages - && self.missingUnseenSafetyNumberChangeCount == other.missingUnseenSafetyNumberChangeCount - && self.unreadIndicatorPosition == other.unreadIndicatorPosition); -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h b/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h index 3df762e34..4637fbae9 100644 --- a/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h +++ b/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h @@ -25,17 +25,13 @@ FOUNDATION_EXPORT const unsigned char SignalUtilitiesKitVersionString[]; #import #import #import -#import #import -#import #import #import #import -#import #import #import #import -#import #import #import #import diff --git a/SignalUtilitiesKit/To Do/GroupUtilities.swift b/SignalUtilitiesKit/To Do/GroupUtilities.swift deleted file mode 100644 index 51f6640a5..000000000 --- a/SignalUtilitiesKit/To Do/GroupUtilities.swift +++ /dev/null @@ -1,23 +0,0 @@ - -public enum GroupUtilities { - - public static func getClosedGroupMembers(_ closedGroup: TSGroupThread) -> [String] { - var result: [String]! - OWSPrimaryStorage.shared().dbReadConnection.read { transaction in - result = getClosedGroupMembers(closedGroup, with: transaction) - } - return result - } - - public static func getClosedGroupMembers(_ closedGroup: TSGroupThread, with transaction: YapDatabaseReadTransaction) -> [String] { - return closedGroup.groupModel.groupMemberIds - } - - public static func getClosedGroupMemberCount(_ closedGroup: TSGroupThread) -> Int { - return getClosedGroupMembers(closedGroup).count - } - - public static func getClosedGroupMemberCount(_ closedGroup: TSGroupThread, with transaction: YapDatabaseReadTransaction) -> Int { - return getClosedGroupMembers(closedGroup, with: transaction).count - } -} diff --git a/SignalUtilitiesKit/To Do/OWSProfileManager.h b/SignalUtilitiesKit/To Do/OWSProfileManager.h deleted file mode 100644 index 8f95bed3f..000000000 --- a/SignalUtilitiesKit/To Do/OWSProfileManager.h +++ /dev/null @@ -1,63 +0,0 @@ -//// -//// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -//// -// -//NS_ASSUME_NONNULL_BEGIN -// -//extern const NSUInteger kOWSProfileManager_NameDataLength; -//extern const NSUInteger kOWSProfileManager_MaxAvatarDiameter; -// -//@class OWSAES256Key; -//@class OWSMessageSender; -//@class OWSPrimaryStorage; -//@class TSNetworkManager; -//@class TSThread; -//@class YapDatabaseReadWriteTransaction; -// -//// This class can be safely accessed and used from any thread. -//@interface OWSProfileManager : NSObject -// -//- (instancetype)init NS_UNAVAILABLE; -// -//- (instancetype)initWithPrimaryStorage:(OWSPrimaryStorage *)primaryStorage; -// -//+ (instancetype)sharedManager; -// -//#pragma mark - Local Profile -// -//// localUserProfileExists is true if there is _ANY_ local profile. -//- (BOOL)localProfileExists; -//// hasLocalProfile is true if there is a local profile with a name or avatar. -//- (BOOL)hasLocalProfile; -// -//// This method is used to update the "local profile" state on the client -//// and the service. Client state is only updated if service state is -//// successfully updated. -//// -//// This method should only be called from the main thread. -//- (void)updateLocalProfileName:(nullable NSString *)profileName -// avatarImage:(nullable UIImage *)avatarImage -// success:(void (^)(void))successBlock -// failure:(void (^)(NSError *))failureBlock -// requiresSync:(BOOL)requiresSync; -// -//- (BOOL)isProfileNameTooLong:(nullable NSString *)profileName; -// -//- (void)regenerateLocalProfile; -// -//#pragma mark - Other Users' Profiles -// -//- (nullable UIImage *)profileAvatarForRecipientId:(NSString *)recipientId; -//- (nullable NSData *)profileAvatarDataForRecipientId:(NSString *)recipientId; -// -//- (void)updateProfileForRecipientId:(NSString *)recipientId -// profileNameEncrypted:(nullable NSData *)profileNameEncrypted -// avatarUrlPath:(nullable NSString *)avatarUrlPath; -// -//#pragma mark - Other -// -//- (void)downloadAvatarForUserProfile:(SNContact *)contact; -// -//@end -// -//NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/To Do/OWSProfileManager.m b/SignalUtilitiesKit/To Do/OWSProfileManager.m deleted file mode 100644 index fe3233d3c..000000000 --- a/SignalUtilitiesKit/To Do/OWSProfileManager.m +++ /dev/null @@ -1,736 +0,0 @@ -//// -//// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -//// -// -//#import "OWSProfileManager.h" -//#import "Environment.h" -//#import "OWSUserProfile.h" -//#import -//#import -//#import "UIUtil.h" -//#import -//#import -//#import -//#import -//#import -//#import -//#import -//#import -//#import -//#import -//#import -//#import -//#import -//#import -// -//NS_ASSUME_NONNULL_BEGIN -// -//// The max bytes for a user's profile name, encoded in UTF8. -//// Before encrypting and submitting we NULL pad the name data to this length. -//const NSUInteger kOWSProfileManager_NameDataLength = 26; -//const NSUInteger kOWSProfileManager_MaxAvatarDiameter = 640; -// -//typedef void (^ProfileManagerFailureBlock)(NSError *error); -// -//@interface OWSProfileManager () -// -//@property (nonatomic, readonly) YapDatabaseConnection *dbConnection; -// -//// This property can be accessed on any thread, while synchronized on self. -//@property (atomic, readonly) NSCache *profileAvatarImageCache; -// -//// This property can be accessed on any thread, while synchronized on self. -//@property (atomic, readonly) NSMutableSet *currentAvatarDownloads; -// -//@end -// -//#pragma mark - -// -//// Access to most state should happen while synchronized on the profile manager. -//// Writes should happen off the main thread, wherever possible. -//@implementation OWSProfileManager -// -//+ (instancetype)sharedManager -//{ -// return SSKEnvironment.shared.profileManager; -//} -// -//- (instancetype)initWithPrimaryStorage:(OWSPrimaryStorage *)primaryStorage -//{ -// self = [super init]; -// -// if (!self) { -// return self; -// } -// -// OWSAssertIsOnMainThread(); -// OWSAssertDebug(primaryStorage); -// -// _dbConnection = primaryStorage.newDatabaseConnection; -// -// _profileAvatarImageCache = [NSCache new]; -// _currentAvatarDownloads = [NSMutableSet new]; -// -// OWSSingletonAssert(); -// -// return self; -//} -// -//- (void)dealloc -//{ -// [[NSNotificationCenter defaultCenter] removeObserver:self]; -//} -// -//#pragma mark - Dependencies -// -//- (TSAccountManager *)tsAccountManager -//{ -// return TSAccountManager.sharedInstance; -//} -// -//- (OWSIdentityManager *)identityManager -//{ -// return SSKEnvironment.shared.identityManager; -//} -// -//- (void)updateLocalProfileName:(nullable NSString *)profileName -// avatarImage:(nullable UIImage *)avatarImage -// success:(void (^)(void))successBlockParameter -// failure:(void (^)(NSError *))failureBlockParameter -// requiresSync:(BOOL)requiresSync -//{ -// OWSAssertDebug(successBlockParameter); -// OWSAssertDebug(failureBlockParameter); -// -// // Ensure that the success and failure blocks are called on the main thread. -// void (^failureBlock)(NSError *) = ^(NSError *error) { -// OWSLogError(@"Updating service with profile failed."); -// -// dispatch_async(dispatch_get_main_queue(), ^{ -// failureBlockParameter(error); -// }); -// }; -// void (^successBlock)(void) = ^{ -// OWSLogInfo(@"Successfully updated service with profile."); -// -// dispatch_async(dispatch_get_main_queue(), ^{ -// successBlockParameter(); -// }); -// }; -// -// // The final steps are to: -// // -// // * Try to update the service. -// // * Update client state on success. -// void (^tryToUpdateService)(NSString *_Nullable, NSString *_Nullable) = ^( -// NSString *_Nullable avatarUrlPath, NSString *_Nullable avatarFileName) { -// [self updateServiceWithProfileName:profileName -// avatarUrl:avatarUrlPath -// success:^{ -// SNContact *userProfile = [LKStorage.shared getUser]; -// OWSAssertDebug(userProfile); -// -// userProfile.name = profileName; -// userProfile.profilePictureURL = avatarUrlPath; -// userProfile.profilePictureFileName = avatarFileName; -// -// [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { -// [LKStorage.shared setContact:userProfile usingTransaction:transaction]; -// } completion:^{ -// if (avatarFileName != nil) { -// [self updateProfileAvatarCache:avatarImage filename:avatarFileName]; -// } -// -// successBlock(); -// }]; -// } -// failure:^(NSError *error) { -// failureBlock(error); -// }]; -// }; -// -// SNContact *userProfile = [LKStorage.shared getUser]; -// OWSAssertDebug(userProfile); -// -// if (avatarImage) { -// // If we have a new avatar image, we must first: -// // -// // * Encode it to JPEG. -// // * Write it to disk. -// // * Encrypt it -// // * Upload it to asset service -// // * Send asset service info to Signal Service -// OWSLogVerbose(@"Updating local profile on service with new avatar."); -// [self writeAvatarToDisk:avatarImage -// success:^(NSData *data, NSString *fileName) { -// [self uploadAvatarToService:data -// success:^(NSString *_Nullable avatarUrlPath) { -// tryToUpdateService(avatarUrlPath, fileName); -// } -// failure:^(NSError *error) { -// failureBlock(error); -// }]; -// } -// failure:^(NSError *error) { -// failureBlock(error); -// }]; -// } else if (userProfile.profilePictureURL) { -// OWSLogVerbose(@"Updating local profile on service with cleared avatar."); -// [self uploadAvatarToService:nil -// success:^(NSString *_Nullable avatarUrlPath) { -// tryToUpdateService(nil, nil); -// } -// failure:^(NSError *error) { -// failureBlock(error); -// }]; -// } else { -// OWSLogVerbose(@"Updating local profile on service with no avatar."); -// tryToUpdateService(nil, nil); -// } -//} -// -//- (void)writeAvatarToDisk:(UIImage *)avatar -// success:(void (^)(NSData *data, NSString *fileName))successBlock -// failure:(ProfileManagerFailureBlock)failureBlock { -// OWSAssertDebug(avatar); -// OWSAssertDebug(successBlock); -// OWSAssertDebug(failureBlock); -// -// dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ -// if (avatar) { -// NSData *data = [self processedImageDataForRawAvatar:avatar]; -// OWSAssertDebug(data); -// if (data) { -// NSString *fileName = [self generateAvatarFilename]; -// NSString *filePath = [OWSUserProfile profileAvatarFilepathWithFilename:fileName]; -// BOOL success = [data writeToFile:filePath atomically:YES]; -// OWSAssertDebug(success); -// if (success) { -// return successBlock(data, fileName); -// } -// } -// } -// failureBlock(OWSErrorWithCodeDescription(OWSErrorCodeAvatarWriteFailed, @"Avatar write failed.")); -// }); -//} -// -//- (NSData *)processedImageDataForRawAvatar:(UIImage *)image -//{ -// NSUInteger kMaxAvatarBytes = 5 * 1000 * 1000; -// -// if (image.size.width != kOWSProfileManager_MaxAvatarDiameter -// || image.size.height != kOWSProfileManager_MaxAvatarDiameter) { -// // To help ensure the user is being shown the same cropping of their avatar as -// // everyone else will see, we want to be sure that the image was resized before this point. -// OWSFailDebug(@"Avatar image should have been resized before trying to upload"); -// image = [image resizedImageToFillPixelSize:CGSizeMake(kOWSProfileManager_MaxAvatarDiameter, -// kOWSProfileManager_MaxAvatarDiameter)]; -// } -// -// NSData *_Nullable data = UIImageJPEGRepresentation(image, 0.95f); -// if (data.length > kMaxAvatarBytes) { -// // Our avatar dimensions are so small that it's incredibly unlikely we wouldn't be able to fit our profile -// // photo. e.g. generating pure noise at our resolution compresses to ~200k. -// OWSFailDebug(@"Suprised to find profile avatar was too large. Was it scaled properly? image: %@", image); -// } -// -// return data; -//} -// -//// If avatarData is nil, we are clearing the avatar. -//- (void)uploadAvatarToService:(NSData *_Nullable)avatarData -// success:(void (^)(NSString *_Nullable avatarUrlPath))successBlock -// failure:(ProfileManagerFailureBlock)failureBlock { -// OWSAssertDebug(successBlock); -// OWSAssertDebug(failureBlock); -// OWSAssertDebug(avatarData == nil || avatarData.length > 0); -// -// dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ -// // We always want to encrypt a profile with a new profile key -// // This ensures that other users know that our profile picture was updated -// OWSAES256Key *newProfileKey = [OWSAES256Key generateRandomKey]; -// -// if (avatarData) { -// NSData *encryptedAvatarData = [self encryptProfileData:avatarData profileKey:newProfileKey]; -// OWSAssertDebug(encryptedAvatarData.length > 0); -// -// AnyPromise *promise = [SNFileServerAPIV2 upload:encryptedAvatarData]; -// -// [promise.thenOn(dispatch_get_main_queue(), ^(NSString *fileID) { -// NSString *downloadURL = [NSString stringWithFormat:@"%@/files/%@", SNFileServerAPIV2.server, fileID]; -// [NSUserDefaults.standardUserDefaults setObject:[NSDate new] forKey:@"lastProfilePictureUpload"]; -// -// SNContact *user = [LKStorage.shared getUser]; -// user.profileEncryptionKey = newProfileKey; -// [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { -// [LKStorage.shared setContact:user usingTransaction:transaction]; -// } completion:^{ -// successBlock(downloadURL); -// }]; -// }) -// .catchOn(dispatch_get_main_queue(), ^(id result) { -// // There appears to be a bug in PromiseKit that sometimes causes catchOn -// // to be invoked with the fulfilled promise's value as the error. The below -// // is a quick and dirty workaround. -// if ([result isKindOfClass:NSString.class]) { -// SNContact *user = [LKStorage.shared getUser]; -// user.profileEncryptionKey = newProfileKey; -// [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { -// [LKStorage.shared setContact:user usingTransaction:transaction]; -// } completion:^{ -// successBlock(result); -// }]; -// } else { -// failureBlock(result); -// } -// }) retainUntilComplete]; -// } else { -// // Update our profile key and set the url to nil if avatar data is nil -// SNContact *user = [LKStorage.shared getUser]; -// user.profileEncryptionKey = newProfileKey; -// user.profilePictureURL = nil; -// user.profilePictureFileName = nil; -// [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { -// [LKStorage.shared setContact:user usingTransaction:transaction]; -// } completion:^{ -// successBlock(nil); -// }]; -// } -// }); -//} -// -//- (void)updateServiceWithProfileName:(nullable NSString *)localProfileName -// avatarUrl:(nullable NSString *)avatarURL -// success:(void (^)(void))successBlock -// failure:(ProfileManagerFailureBlock)failureBlock { -// successBlock(); -//} -// -//- (void)updateServiceWithProfileName:(nullable NSString *)localProfileName avatarURL:(nullable NSString *)avatarURL { -// [self updateServiceWithProfileName:localProfileName avatarUrl:avatarURL success:^{} failure:^(NSError * _Nonnull error) {}]; -//} -// -//#pragma mark - Profile Key Rotation -// -//- (nullable NSString *)groupKeyForGroupId:(NSData *)groupId { -// NSString *groupIdKey = [groupId hexadecimalString]; -// return groupIdKey; -//} -// -//- (nullable NSData *)groupIdForGroupKey:(NSString *)groupKey { -// NSMutableData *groupId = [NSMutableData new]; -// -// if (groupKey.length % 2 != 0) { -// OWSFailDebug(@"Group key has unexpected length: %@ (%lu)", groupKey, (unsigned long)groupKey.length); -// return nil; -// } -// for (NSUInteger i = 0; i + 2 <= groupKey.length; i += 2) { -// NSString *_Nullable byteString = [groupKey substringWithRange:NSMakeRange(i, 2)]; -// if (!byteString) { -// OWSFailDebug(@"Couldn't slice group key."); -// return nil; -// } -// unsigned byteValue; -// if (![[NSScanner scannerWithString:byteString] scanHexInt:&byteValue]) { -// OWSFailDebug(@"Couldn't parse hex byte: %@.", byteString); -// return nil; -// } -// if (byteValue > 0xff) { -// OWSFailDebug(@"Invalid hex byte: %@ (%d).", byteString, byteValue); -// return nil; -// } -// uint8_t byte = (uint8_t)(0xff & byteValue); -// [groupId appendBytes:&byte length:1]; -// } -// return [groupId copy]; -//} -// -//- (void)regenerateLocalProfile -//{ -// NSString *userPublicKey = [SNGeneralUtilities getUserPublicKey]; -// SNContact *contact = [LKStorage.shared getContactWithSessionID:userPublicKey]; -// contact.profileEncryptionKey = [OWSAES256Key generateRandomKey]; -// contact.profilePictureURL = nil; -// contact.profilePictureFileName = nil; -// [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { -// [LKStorage.shared setContact:contact usingTransaction:transaction]; -// } completion:^{ -// [[self.tsAccountManager updateAccountAttributes] retainUntilComplete]; -// }]; -//} -// -//#pragma mark - Other Users' Profiles -// -//- (void)setProfileKeyData:(NSData *)profileKeyData forRecipientId:(NSString *)recipientId avatarURL:(nullable NSString *)avatarURL -//{ -// dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ -// OWSAES256Key *_Nullable profileKey = [OWSAES256Key keyWithData:profileKeyData]; -// if (profileKey == nil) { -// OWSFailDebug(@"Failed to make profile key for key data"); -// return; -// } -// -// SNContact *contact = [LKStorage.shared getContactWithSessionID:recipientId]; -// -// OWSAssertDebug(contact); -// if (contact.profileEncryptionKey != nil && [contact.profileEncryptionKey.keyData isEqual:profileKey.keyData]) { -// // Ignore redundant update. -// return; -// } -// -// contact.profileEncryptionKey = profileKey; -// contact.profilePictureURL = nil; -// contact.profilePictureFileName = nil; -// -// [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { -// [LKStorage.shared setContact:contact usingTransaction:transaction]; -// } completion:^{ -// contact.profilePictureURL = avatarURL; -// [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { -// [LKStorage.shared setContact:contact usingTransaction:transaction]; -// } completion:^{ -// [self downloadAvatarForUserProfile:contact]; -// }]; -// }]; -// }); -//} -// -//- (void)setProfileKeyData:(NSData *)profileKeyData forRecipientId:(NSString *)recipientId -//{ -// [self setProfileKeyData:profileKeyData forRecipientId:recipientId avatarURL:nil]; -//} -// -//- (nullable NSData *)profileKeyDataForRecipientId:(NSString *)recipientId -//{ -// return [self profileKeyForRecipientId:recipientId].keyData; -//} -// -//- (nullable OWSAES256Key *)profileKeyForRecipientId:(NSString *)recipientId -//{ -// OWSAssertDebug(recipientId.length > 0); -// -// SNContact *contact = [LKStorage.shared getContactWithSessionID:recipientId]; -// OWSAssertDebug(contact); -// -// return contact.profileEncryptionKey; -//} -// -//- (nullable UIImage *)profileAvatarForRecipientId:(NSString *)recipientId -//{ -// OWSAssertDebug(recipientId.length > 0); -// -// SNContact *contact = [LKStorage.shared getContactWithSessionID:recipientId]; -// -// if (contact.profilePictureFileName != nil && contact.profilePictureFileName.length > 0) { -// return [self loadProfileAvatarWithFilename:contact.profilePictureFileName]; -// } -// -// if (contact.profilePictureURL != nil && contact.profilePictureURL.length > 0) { -// [self downloadAvatarForUserProfile:contact]; -// } -// -// return nil; -//} -// -//- (nullable NSData *)profileAvatarDataForRecipientId:(NSString *)recipientId -//{ -// OWSAssertDebug(recipientId.length > 0); -// -// SNContact *contact = [LKStorage.shared getContactWithSessionID:recipientId]; -// -// if (contact.profilePictureFileName != nil && contact.profilePictureFileName.length > 0) { -// return [self loadProfileDataWithFilename:contact.profilePictureFileName]; -// } -// -// return nil; -//} -// -//- (NSString *)generateAvatarFilename -//{ -// return [[NSUUID UUID].UUIDString stringByAppendingPathExtension:@"jpg"]; -//} -// -//- (void)downloadAvatarForUserProfile:(SNContact *)contact -//{ -// OWSAssertDebug(contact); -// -// __block OWSBackgroundTask *backgroundTask = [OWSBackgroundTask backgroundTaskWithLabelStr:__PRETTY_FUNCTION__]; -// -// dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ -// BOOL hasProfilePictureURL = (contact.profilePictureURL != nil && contact.profilePictureURL.length > 0); -// if (!hasProfilePictureURL) { -// OWSLogDebug(@"Skipping downloading avatar for %@ because url is not set", contact.sessionID); -// return; -// } -// NSString *_Nullable avatarUrlPathAtStart = contact.profilePictureURL; -// -// BOOL hasProfileEncryptionKey = (contact.profileEncryptionKey != nil && contact.profileEncryptionKey.keyData.length > 0); -// if (!hasProfileEncryptionKey || !hasProfilePictureURL) { -// return; -// } -// -// OWSAES256Key *profileKeyAtStart = contact.profileEncryptionKey; -// -// NSString *fileName = [self generateAvatarFilename]; -// NSString *filePath = [OWSUserProfile profileAvatarFilepathWithFilename:fileName]; -// -// @synchronized(self.currentAvatarDownloads) -// { -// if ([self.currentAvatarDownloads containsObject:contact.sessionID]) { -// // Download already in flight; ignore. -// return; -// } -// [self.currentAvatarDownloads addObject:contact.sessionID]; -// } -// -// OWSLogVerbose(@"downloading profile avatar: %@", contact.sessionID); -// -// NSString *profilePictureURL = contact.profilePictureURL; -// -// NSString *file = [profilePictureURL lastPathComponent]; -// BOOL useOldServer = [profilePictureURL containsString:SNFileServerAPIV2.oldServer]; -// AnyPromise *promise = [SNFileServerAPIV2 download:file useOldServer:useOldServer]; -// -// [promise.then(^(NSData *data) { -// @synchronized(self.currentAvatarDownloads) -// { -// [self.currentAvatarDownloads removeObject:contact.sessionID]; -// } -// NSData *_Nullable encryptedData = data; -// NSData *_Nullable decryptedData = [self decryptProfileData:encryptedData profileKey:profileKeyAtStart]; -// UIImage *_Nullable image = nil; -// if (decryptedData) { -// BOOL success = [decryptedData writeToFile:filePath atomically:YES]; -// if (success) { -// image = [UIImage imageWithContentsOfFile:filePath]; -// } -// } -// -// SNContact *latestContact = [LKStorage.shared getContactWithSessionID:contact.sessionID]; -// -// BOOL hasProfileEncryptionKey = (latestContact.profileEncryptionKey != nil -// && latestContact.profileEncryptionKey.keyData.length > 0); -// if (!hasProfileEncryptionKey || ![latestContact.profileEncryptionKey isEqual:contact.profileEncryptionKey]) { -// OWSLogWarn(@"Ignoring avatar download for obsolete user profile."); -// } else if (![avatarUrlPathAtStart isEqualToString:latestContact.profilePictureURL]) { -// OWSLogInfo(@"avatar url has changed during download"); -// if (latestContact.profilePictureURL != nil && latestContact.profilePictureURL.length > 0) { -// [self downloadAvatarForUserProfile:latestContact]; -// } -// } else if (!encryptedData) { -// OWSLogError(@"avatar encrypted data for %@ could not be read.", contact.sessionID); -// } else if (!decryptedData) { -// OWSLogError(@"avatar data for %@ could not be decrypted.", contact.sessionID); -// } else if (!image) { -// OWSLogError(@"avatar image for %@ could not be loaded.", contact.sessionID); -// } else { -// latestContact.profilePictureFileName = fileName; -// [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { -// [LKStorage.shared setContact:latestContact usingTransaction:transaction]; -// }]; -// [self updateProfileAvatarCache:image filename:fileName]; -// } -// -// OWSAssertDebug(backgroundTask); -// backgroundTask = nil; -// }) retainUntilComplete]; -// }); -//} -// -//- (void)updateProfileForRecipientId:(NSString *)recipientId -// profileNameEncrypted:(nullable NSData *)profileNameEncrypted -// avatarUrlPath:(nullable NSString *)avatarUrlPath -//{ -// OWSAssertDebug(recipientId.length > 0); -// -// OWSLogDebug(@"update profile for: %@ name: %@ avatar: %@", recipientId, profileNameEncrypted, avatarUrlPath); -// -// // Ensure decryption, etc. off main thread. -// dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ -// SNContact *contact = [LKStorage.shared getContactWithSessionID:recipientId]; -// -// if (!contact.profileEncryptionKey) { return; } -// -// NSString *_Nullable profileName = -// [self decryptProfileNameData:profileNameEncrypted profileKey:contact.profileEncryptionKey]; -// -// contact.name = profileName; -// contact.profilePictureURL = avatarUrlPath; -// -// [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { -// [LKStorage.shared setContact:contact usingTransaction:transaction]; -// }]; -// -// // Whenever we change avatarUrlPath, OWSUserProfile clears avatarFileName. -// // So if avatarUrlPath is set and avatarFileName is not set, we should to -// // download this avatar. downloadAvatarForUserProfile will de-bounce -// // downloads. -// BOOL hasProfilePictureURL = (contact.profilePictureURL != nil && contact.profilePictureURL.length > 0); -// BOOL hasProfilePictureFileName = (contact.profilePictureFileName != nil && contact.profilePictureFileName.length > 0); -// if (hasProfilePictureURL && !hasProfilePictureFileName) { -// [self downloadAvatarForUserProfile:contact]; -// } -// }); -//} -// -//- (BOOL)isNullableDataEqual:(NSData *_Nullable)left toData:(NSData *_Nullable)right -//{ -// if (left == nil && right == nil) { -// return YES; -// } else if (left == nil || right == nil) { -// return YES; -// } else { -// return [left isEqual:right]; -// } -//} -// -//- (BOOL)isNullableStringEqual:(NSString *_Nullable)left toString:(NSString *_Nullable)right -//{ -// if (left == nil && right == nil) { -// return YES; -// } else if (left == nil || right == nil) { -// return YES; -// } else { -// return [left isEqualToString:right]; -// } -//} -// -//#pragma mark - Profile Encryption -// -//- (nullable NSData *)encryptProfileData:(nullable NSData *)encryptedData profileKey:(OWSAES256Key *)profileKey -//{ -// OWSAssertDebug(profileKey.keyData.length == kAES256_KeyByteLength); -// -// if (!encryptedData) { -// return nil; -// } -// -// return [Cryptography encryptAESGCMWithProfileData:encryptedData key:profileKey]; -//} -// -//- (nullable NSData *)decryptProfileData:(nullable NSData *)encryptedData profileKey:(OWSAES256Key *)profileKey -//{ -// OWSAssertDebug(profileKey.keyData.length == kAES256_KeyByteLength); -// -// if (!encryptedData) { -// return nil; -// } -// -// return [Cryptography decryptAESGCMWithProfileData:encryptedData key:profileKey]; -//} -// -//- (nullable NSString *)decryptProfileNameData:(nullable NSData *)encryptedData profileKey:(OWSAES256Key *)profileKey -//{ -// OWSAssertDebug(profileKey.keyData.length == kAES256_KeyByteLength); -// -// NSData *_Nullable decryptedData = [self decryptProfileData:encryptedData profileKey:profileKey]; -// if (decryptedData.length < 1) { -// return nil; -// } -// -// -// // Unpad profile name. -// NSUInteger unpaddedLength = 0; -// const char *bytes = decryptedData.bytes; -// -// // Work through the bytes until we encounter our first -// // padding byte (our padding scheme is NULL bytes) -// for (NSUInteger i = 0; i < decryptedData.length; i++) { -// if (bytes[i] == 0x00) { -// break; -// } -// unpaddedLength = i + 1; -// } -// -// NSData *unpaddedData = [decryptedData subdataWithRange:NSMakeRange(0, unpaddedLength)]; -// -// return [[NSString alloc] initWithData:unpaddedData encoding:NSUTF8StringEncoding]; -//} -// -//- (nullable NSData *)encryptProfileData:(nullable NSData *)data -//{ -// OWSAES256Key *localProfileKey = [LKStorage.shared getUser].profileEncryptionKey; -// -// return [self encryptProfileData:data profileKey:localProfileKey]; -//} -// -//- (BOOL)isProfileNameTooLong:(nullable NSString *)profileName -//{ -// OWSAssertIsOnMainThread(); -// -// NSData *nameData = [profileName dataUsingEncoding:NSUTF8StringEncoding]; -// return nameData.length > kOWSProfileManager_NameDataLength; -//} -// -//- (nullable NSData *)encryptProfileNameWithUnpaddedName:(NSString *)name -//{ -// NSData *nameData = [name dataUsingEncoding:NSUTF8StringEncoding]; -// if (nameData.length > kOWSProfileManager_NameDataLength) { -// OWSFailDebug(@"name data is too long with length:%lu", (unsigned long)nameData.length); -// return nil; -// } -// -// NSUInteger paddingByteCount = kOWSProfileManager_NameDataLength - nameData.length; -// -// NSMutableData *paddedNameData = [nameData mutableCopy]; -// // Since we want all encrypted profile names to be the same length on the server, we use `increaseLengthBy` -// // to pad out any remaining length with 0 bytes. -// [paddedNameData increaseLengthBy:paddingByteCount]; -// OWSAssertDebug(paddedNameData.length == kOWSProfileManager_NameDataLength); -// -// OWSAES256Key *localProfileKey = [LKStorage.shared getUser].profileEncryptionKey; -// -// return [self encryptProfileData:[paddedNameData copy] profileKey:localProfileKey]; -//} -// -//#pragma mark - Avatar Disk Cache -// -//- (nullable NSData *)loadProfileDataWithFilename:(NSString *)filename -//{ -// if (filename.length <= 0) { return nil; }; -// -// NSString *filePath = [OWSUserProfile profileAvatarFilepathWithFilename:filename]; -// return [NSData dataWithContentsOfFile:filePath]; -//} -// -//- (nullable UIImage *)loadProfileAvatarWithFilename:(NSString *)filename -//{ -// if (filename.length == 0) { -// return nil; -// } -// -// UIImage *_Nullable image = nil; -// @synchronized(self.profileAvatarImageCache) -// { -// image = [self.profileAvatarImageCache objectForKey:filename]; -// } -// if (image) { -// return image; -// } -// -// NSData *data = [self loadProfileDataWithFilename:filename]; -// if (![data ows_isValidImage]) { -// return nil; -// } -// image = [UIImage imageWithData:data]; -// [self updateProfileAvatarCache:image filename:filename]; -// return image; -//} -// -//- (void)updateProfileAvatarCache:(nullable UIImage *)image filename:(NSString *)filename -//{ -// if (filename.length <= 0) { return; }; -// -// @synchronized(self.profileAvatarImageCache) -// { -// if (image) { -// [self.profileAvatarImageCache setObject:image forKey:filename]; -// } else { -// [self.profileAvatarImageCache removeObjectForKey:filename]; -// } -// } -//} -// -//@end -// -//NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/ThreadUtil.h b/SignalUtilitiesKit/Utilities/ThreadUtil.h deleted file mode 100644 index ddc936b94..000000000 --- a/SignalUtilitiesKit/Utilities/ThreadUtil.h +++ /dev/null @@ -1,81 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -NS_ASSUME_NONNULL_BEGIN - -@class OWSLinkPreviewDraft; -@class OWSQuotedReplyModel; -@class OWSUnreadIndicator; -@class SignalAttachment; -@class TSContactThread; -@class TSGroupThread; -@class TSInteraction; -@class TSOutgoingMessage; -@class TSThread; -@class YapDatabaseConnection; -@class YapDatabaseReadTransaction; -@class YapDatabaseReadWriteTransaction; - -@interface ThreadDynamicInteractions : NSObject - -// Represents the "reverse index" of the focus message, if any. -// The "reverse index" is the distance of this interaction from -// the last interaction in the thread. Therefore the last interaction -// will have a "reverse index" of zero. -// -// We use "reverse indices" because (among other uses) we use this to -// determine the initial load window size. -@property (nonatomic, nullable, readonly) NSNumber *focusMessagePosition; - -@property (nonatomic, nullable, readonly) OWSUnreadIndicator *unreadIndicator; - -- (void)clearUnreadIndicatorState; - -@end - -#pragma mark - - -@interface ThreadUtil : NSObject - -#pragma mark - dynamic interactions - -// This method will create and/or remove any offers and indicators -// necessary for this thread. This includes: -// -// * Block offers. -// * "Add to contacts" offers. -// * Unread indicators. -// -// Parameters: -// -// * hideUnreadMessagesIndicator: If YES, the "unread indicator" has -// been cleared and should not be shown. -// * firstUnseenInteractionTimestamp: A snapshot of unseen message state -// when we entered the conversation view. See comments on -// ThreadOffersAndIndicators. -// * maxRangeSize: Loading a lot of messages in conversation view is -// slow and unwieldy. This number represents the maximum current -// size of the "load window" in that view. The unread indicator should -// always be inserted within that window. -+ (ThreadDynamicInteractions *)ensureDynamicInteractionsForThread:(TSThread *)thread - dbConnection:(YapDatabaseConnection *)dbConnection - hideUnreadMessagesIndicator:(BOOL)hideUnreadMessagesIndicator - lastUnreadIndicator:(nullable OWSUnreadIndicator *)lastUnreadIndicator - focusMessageId:(nullable NSString *)focusMessageId - maxRangeSize:(int)maxRangeSize; - -#pragma mark - Delete Content - -+ (void)deleteAllContent; - -#pragma mark - Find Content - -+ (nullable TSInteraction *)findInteractionInThreadByTimestamp:(uint64_t)timestamp - authorId:(NSString *)authorId - threadUniqueId:(NSString *)threadUniqueId - transaction:(YapDatabaseReadTransaction *)transaction; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/ThreadUtil.m b/SignalUtilitiesKit/Utilities/ThreadUtil.m deleted file mode 100644 index 702d16870..000000000 --- a/SignalUtilitiesKit/Utilities/ThreadUtil.m +++ /dev/null @@ -1,343 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "ThreadUtil.h" -#import "OWSQuotedReplyModel.h" -#import "OWSUnreadIndicator.h" -#import -#import -#import -#import -#import -#import -#import - - -NS_ASSUME_NONNULL_BEGIN - -@interface ThreadDynamicInteractions () - -@property (nonatomic, nullable) NSNumber *focusMessagePosition; - -@property (nonatomic, nullable) OWSUnreadIndicator *unreadIndicator; - -@end - -#pragma mark - - -@implementation ThreadDynamicInteractions - -- (void)clearUnreadIndicatorState -{ - self.unreadIndicator = nil; -} - -- (BOOL)isEqual:(id)object -{ - if (self == object) { - return YES; - } - - if (![object isKindOfClass:[ThreadDynamicInteractions class]]) { - return NO; - } - - ThreadDynamicInteractions *other = (ThreadDynamicInteractions *)object; - return ([NSObject isNullableObject:self.focusMessagePosition equalTo:other.focusMessagePosition] && - [NSObject isNullableObject:self.unreadIndicator equalTo:other.unreadIndicator]); -} - -@end - -@implementation ThreadUtil - -#pragma mark - Dependencies - -+ (YapDatabaseConnection *)dbConnection -{ - return SSKEnvironment.shared.primaryStorage.dbReadWriteConnection; -} - -#pragma mark - Dynamic Interactions - -+ (ThreadDynamicInteractions *)ensureDynamicInteractionsForThread:(TSThread *)thread - dbConnection:(YapDatabaseConnection *)dbConnection - hideUnreadMessagesIndicator:(BOOL)hideUnreadMessagesIndicator - lastUnreadIndicator:(nullable OWSUnreadIndicator *)lastUnreadIndicator - focusMessageId:(nullable NSString *)focusMessageId - maxRangeSize:(int)maxRangeSize -{ - OWSAssertDebug(thread); - OWSAssertDebug(dbConnection); - OWSAssertDebug(maxRangeSize > 0); - - ThreadDynamicInteractions *result = [ThreadDynamicInteractions new]; - - [dbConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - // Determine if there are "unread" messages in this conversation. - // If we've been passed a firstUnseenInteractionTimestampParameter, - // just use that value in order to preserve continuity of the - // unread messages indicator after all messages in the conversation - // have been marked as read. - // - // IFF this variable is non-null, there are unseen messages in the thread. - NSNumber *_Nullable firstUnseenSortId = nil; - if (lastUnreadIndicator) { - firstUnseenSortId = @(lastUnreadIndicator.firstUnseenSortId); - } else { - TSInteraction *_Nullable firstUnseenInteraction = - [[TSDatabaseView unseenDatabaseViewExtension:transaction] firstObjectInGroup:thread.uniqueId]; - if (firstUnseenInteraction && firstUnseenInteraction.sortId != NULL) { - firstUnseenSortId = @(firstUnseenInteraction.sortId); - } - } - - [self ensureUnreadIndicator:result - thread:thread - transaction:transaction - maxRangeSize:maxRangeSize - nonBlockingSafetyNumberChanges:@[] - hideUnreadMessagesIndicator:hideUnreadMessagesIndicator - firstUnseenSortId:firstUnseenSortId]; - - // Determine the position of the focus message _after_ performing any mutations - // around dynamic interactions. - if (focusMessageId != nil) { - result.focusMessagePosition = - [self focusMessagePositionForThread:thread transaction:transaction focusMessageId:focusMessageId]; - } - }]; - - return result; -} - -+ (void)ensureUnreadIndicator:(ThreadDynamicInteractions *)dynamicInteractions - thread:(TSThread *)thread - transaction:(YapDatabaseReadTransaction *)transaction - maxRangeSize:(int)maxRangeSize - nonBlockingSafetyNumberChanges:(NSArray *)nonBlockingSafetyNumberChanges - hideUnreadMessagesIndicator:(BOOL)hideUnreadMessagesIndicator - firstUnseenSortId:(nullable NSNumber *)firstUnseenSortId -{ - OWSAssertDebug(dynamicInteractions); - OWSAssertDebug(thread); - OWSAssertDebug(transaction); - OWSAssertDebug(nonBlockingSafetyNumberChanges); - - if (hideUnreadMessagesIndicator) { - return; - } - if (!firstUnseenSortId) { - // If there are no unseen interactions, don't show an unread indicator. - return; - } - - YapDatabaseViewTransaction *threadMessagesTransaction = [transaction ext:TSMessageDatabaseViewExtensionName]; - OWSAssertDebug([threadMessagesTransaction isKindOfClass:[YapDatabaseViewTransaction class]]); - - // Determine unread indicator position, if necessary. - // - // Enumerate in reverse to count the number of messages - // after the unseen messages indicator. Not all of - // them are unnecessarily unread, but we need to tell - // the messages view the position of the unread indicator, - // so that it can widen its "load window" to always show - // the unread indicator. - __block long visibleUnseenMessageCount = 0; - __block TSInteraction *interactionAfterUnreadIndicator = nil; - __block BOOL hasMoreUnseenMessages = NO; - [threadMessagesTransaction - enumerateKeysAndObjectsInGroup:thread.uniqueId - withOptions:NSEnumerationReverse - usingBlock:^(NSString *collection, NSString *key, id object, NSUInteger index, BOOL *stop) { - if (![object isKindOfClass:[TSInteraction class]]) { - OWSFailDebug(@"Expected a TSInteraction: %@", [object class]); - return; - } - - TSInteraction *interaction = (TSInteraction *)object; - - if (interaction.isDynamicInteraction) { - // Ignore dynamic interactions, if any. - return; - } - - if (interaction.sortId < firstUnseenSortId.unsignedLongLongValue) { - // By default we want the unread indicator to appear just before - // the first unread message. - *stop = YES; - return; - } - - visibleUnseenMessageCount++; - - interactionAfterUnreadIndicator = interaction; - - if (visibleUnseenMessageCount + 1 >= maxRangeSize) { - // If there are more unseen messages than can be displayed in the - // messages view, show the unread indicator at the top of the - // displayed messages. - *stop = YES; - hasMoreUnseenMessages = YES; - } - }]; - - if (!interactionAfterUnreadIndicator) { - // If we can't find an interaction after the unread indicator, - // don't show it. All unread messages may have been deleted or - // expired. - return; - } - OWSAssertDebug(visibleUnseenMessageCount > 0); - - NSInteger unreadIndicatorPosition = visibleUnseenMessageCount; - - dynamicInteractions.unreadIndicator = - [[OWSUnreadIndicator alloc] initWithFirstUnseenSortId:firstUnseenSortId.unsignedLongLongValue - hasMoreUnseenMessages:hasMoreUnseenMessages - missingUnseenSafetyNumberChangeCount:nonBlockingSafetyNumberChanges.count - unreadIndicatorPosition:unreadIndicatorPosition]; - OWSLogInfo(@"Creating Unread Indicator: %llu", dynamicInteractions.unreadIndicator.firstUnseenSortId); -} - -+ (nullable NSNumber *)focusMessagePositionForThread:(TSThread *)thread - transaction:(YapDatabaseReadTransaction *)transaction - focusMessageId:(NSString *)focusMessageId -{ - OWSAssertDebug(thread); - OWSAssertDebug(transaction); - OWSAssertDebug(focusMessageId); - - YapDatabaseViewTransaction *databaseView = [transaction ext:TSMessageDatabaseViewExtensionName]; - - NSString *_Nullable group = nil; - NSUInteger index; - BOOL success = - [databaseView getGroup:&group index:&index forKey:focusMessageId inCollection:TSInteraction.collection]; - if (!success) { - // This might happen if the focus message has disappeared - // before this view could appear. - OWSFailDebug(@"failed to find focus message index."); - return nil; - } - if (![group isEqualToString:thread.uniqueId]) { - OWSFailDebug(@"focus message has invalid group."); - return nil; - } - NSUInteger count = [databaseView numberOfItemsInGroup:thread.uniqueId]; - if (index >= count) { - OWSFailDebug(@"focus message has invalid index."); - return nil; - } - NSUInteger position = (count - index) - 1; - return @(position); -} - -#pragma mark - Delete Content - -+ (void)deleteAllContent -{ - OWSLogInfo(@""); - - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [self removeAllObjectsInCollection:[TSThread collection] - class:[TSThread class] - transaction:transaction]; - [self removeAllObjectsInCollection:[TSInteraction collection] - class:[TSInteraction class] - transaction:transaction]; - [self removeAllObjectsInCollection:[TSAttachment collection] - class:[TSAttachment class] - transaction:transaction]; - @try { - [self removeAllObjectsInCollection:[SignalRecipient collection] - class:[SignalRecipient class] - transaction:transaction]; - } @catch (NSException *exception) { - // Do nothing - } - }]; - [TSAttachmentStream deleteAttachments]; -} - -+ (void)removeAllObjectsInCollection:(NSString *)collection - class:(Class) class - transaction:(YapDatabaseReadWriteTransaction *)transaction { - OWSAssertDebug(collection.length > 0); - OWSAssertDebug(class); - OWSAssertDebug(transaction); - - NSArray *_Nullable uniqueIds = [transaction allKeysInCollection:collection]; - if (!uniqueIds) { - OWSFailDebug(@"couldn't load uniqueIds for collection: %@.", collection); - return; - } - OWSLogInfo(@"Deleting %lu objects from: %@", (unsigned long)uniqueIds.count, collection); - NSUInteger count = 0; - for (NSString *uniqueId in uniqueIds) { - // We need to fetch each object, since [TSYapDatabaseObject removeWithTransaction:] sometimes does important - // work. - TSYapDatabaseObject *_Nullable object = [class fetchObjectWithUniqueID:uniqueId transaction:transaction]; - if (!object) { - OWSFailDebug(@"couldn't load object for deletion: %@.", collection); - continue; - } - [object removeWithTransaction:transaction]; - count++; - }; - OWSLogInfo(@"Deleted %lu/%lu objects from: %@", (unsigned long)count, (unsigned long)uniqueIds.count, collection); -} - -#pragma mark - Find Content - -+ (nullable TSInteraction *)findInteractionInThreadByTimestamp:(uint64_t)timestamp - authorId:(NSString *)authorId - threadUniqueId:(NSString *)threadUniqueId - transaction:(YapDatabaseReadTransaction *)transaction -{ - OWSAssertDebug(timestamp > 0); - OWSAssertDebug(authorId.length > 0); - - NSString *localNumber = [TSAccountManager localNumber]; - if (localNumber.length < 1) { - OWSFailDebug(@"missing long number."); - return nil; - } - - NSArray *interactions = - [TSInteraction interactionsWithTimestamp:timestamp - filter:^(TSInteraction *interaction) { - NSString *_Nullable messageAuthorId = nil; - if ([interaction isKindOfClass:[TSIncomingMessage class]]) { - TSIncomingMessage *incomingMessage = (TSIncomingMessage *)interaction; - messageAuthorId = incomingMessage.authorId; - } else if ([interaction isKindOfClass:[TSOutgoingMessage class]]) { - messageAuthorId = localNumber; - } - if (messageAuthorId.length < 1) { - return NO; - } - - if (![authorId isEqualToString:messageAuthorId]) { - return NO; - } - if (![interaction.uniqueThreadId isEqualToString:threadUniqueId]) { - return NO; - } - return YES; - } - withTransaction:transaction]; - if (interactions.count < 1) { - return nil; - } - if (interactions.count > 1) { - // In case of collision, take the first. - OWSLogError(@"more than one matching interaction in thread."); - } - return interactions.firstObject; -} - -@end - -NS_ASSUME_NONNULL_END From 49dd341b6da15ad450b2423b8c0ccfa0fc09b8e3 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 23 May 2022 12:23:43 +1000 Subject: [PATCH 084/157] Removed some more legacy code which has been refactored --- Session.xcodeproj/project.pbxproj | 48 -- Session/Conversations/ConversationViewItem.m | 3 - Session/Meta/AppDelegate.swift | 29 +- Session/Meta/AppEnvironment.swift | 4 - Session/Meta/Signal-Bridging-Header.h | 1 - Session/Notifications/SyncPushTokensJob.swift | 59 ++- Session/Onboarding/LinkDeviceVC.swift | 4 +- Session/Onboarding/Onboarding.swift | 2 +- Session/Onboarding/PNModeVC.swift | 4 +- Session/Utilities/AccountManager.swift | 130 ----- .../DisappearingMessageConfiguration.swift | 2 +- .../Database/OWSPrimaryStorage.m | 4 - .../Meta/SessionMessagingKit.h | 3 - SessionMessagingKit/To Do/TSAccountManager.h | 167 ------ SessionMessagingKit/To Do/TSAccountManager.m | 499 ------------------ .../Utilities/OWSDisappearingMessagesFinder.h | 47 -- .../Utilities/OWSDisappearingMessagesFinder.m | 241 --------- .../Utilities/OWSIncomingMessageFinder.h | 30 -- .../Utilities/OWSIncomingMessageFinder.m | 144 ----- .../Utilities/OWSPreferences.h | 1 - .../Utilities/SSKEnvironment.h | 80 --- .../Utilities/SSKEnvironment.m | 135 ----- .../Utilities/SSKEnvironment.swift | 3 - .../Utilities/ThreadUpdateBatcher.swift | 31 -- .../SignalShareExtension-Bridging-Header.h | 1 - .../Database/Models/Identity.swift | 25 + .../AttachmentApprovalViewController.swift | 40 +- .../AttachmentItemCollection.swift | 1 + .../AttachmentSharing.m | 1 - SignalUtilitiesKit/Utilities/AppSetup.m | 11 - .../Utilities/Notification+Loki.swift | 4 +- .../Utilities/VersionMigrations.m | 16 +- 32 files changed, 114 insertions(+), 1656 deletions(-) delete mode 100644 Session/Utilities/AccountManager.swift delete mode 100644 SessionMessagingKit/To Do/TSAccountManager.h delete mode 100644 SessionMessagingKit/To Do/TSAccountManager.m delete mode 100644 SessionMessagingKit/Utilities/OWSDisappearingMessagesFinder.h delete mode 100644 SessionMessagingKit/Utilities/OWSDisappearingMessagesFinder.m delete mode 100644 SessionMessagingKit/Utilities/OWSIncomingMessageFinder.h delete mode 100644 SessionMessagingKit/Utilities/OWSIncomingMessageFinder.m delete mode 100644 SessionMessagingKit/Utilities/SSKEnvironment.h delete mode 100644 SessionMessagingKit/Utilities/SSKEnvironment.m delete mode 100644 SessionMessagingKit/Utilities/ThreadUpdateBatcher.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 494261eb4..718cb0e62 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -54,7 +54,6 @@ 4503F1BE20470A5B00CEE724 /* classic-quiet.aifc in Resources */ = {isa = PBXBuildFile; fileRef = 4503F1BB20470A5B00CEE724 /* classic-quiet.aifc */; }; 4503F1BF20470A5B00CEE724 /* classic.aifc in Resources */ = {isa = PBXBuildFile; fileRef = 4503F1BC20470A5B00CEE724 /* classic.aifc */; }; 450DF2091E0DD2C6003D14BE /* UserNotificationsAdaptee.swift in Sources */ = {isa = PBXBuildFile; fileRef = 450DF2081E0DD2C6003D14BE /* UserNotificationsAdaptee.swift */; }; - 451166C01FD86B98000739BA /* AccountManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 451166BF1FD86B98000739BA /* AccountManager.swift */; }; 451A13B11E13DED2000A50FD /* AppNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 451A13B01E13DED2000A50FD /* AppNotifications.swift */; }; 4520D8D51D417D8E00123472 /* Photos.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4520D8D41D417D8E00123472 /* Photos.framework */; }; 4521C3C01F59F3BA00B4C582 /* TextFieldHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4521C3BF1F59F3BA00B4C582 /* TextFieldHelper.swift */; }; @@ -287,16 +286,12 @@ C32C5A88256DBCF9003C73A2 /* MessageReceiver+Handling.swift in Sources */ = {isa = PBXBuildFile; fileRef = C32C5A87256DBCF9003C73A2 /* MessageReceiver+Handling.swift */; }; C32C5B48256DC211003C73A2 /* NSNotificationCenter+OWS.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB6C255A580F00E217F9 /* NSNotificationCenter+OWS.m */; }; C32C5B51256DC219003C73A2 /* NSNotificationCenter+OWS.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB3B255A580B00E217F9 /* NSNotificationCenter+OWS.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C32C5B84256DC54F003C73A2 /* SSKEnvironment.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAF4255A580600E217F9 /* SSKEnvironment.m */; }; - C32C5B8D256DC565003C73A2 /* SSKEnvironment.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB31255A580A00E217F9 /* SSKEnvironment.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5BCC256DC830003C73A2 /* Storage+ClosedGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8D8F1372566120F0092EF10 /* Storage+ClosedGroups.swift */; }; C32C5C3D256DCBAF003C73A2 /* AppReadiness.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB75255A581000E217F9 /* AppReadiness.m */; }; C32C5C46256DCBB2003C73A2 /* AppReadiness.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB01255A580700E217F9 /* AppReadiness.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5C4F256DCC36003C73A2 /* Storage+OpenGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8D8F18825661BA50092EF10 /* Storage+OpenGroups.swift */; }; C32C5C88256DD0D2003C73A2 /* Storage+Messaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8D8F19225661BF80092EF10 /* Storage+Messaging.swift */; }; C32C5C89256DD0D2003C73A2 /* Storage+Jobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8D8F17625661AFA0092EF10 /* Storage+Jobs.swift */; }; - C32C5CA4256DD1DC003C73A2 /* TSAccountManager.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB88255A581200E217F9 /* TSAccountManager.m */; }; - C32C5CAD256DD1DF003C73A2 /* TSAccountManager.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB94255A581300E217F9 /* TSAccountManager.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5CF0256DD3E4003C73A2 /* Storage+Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3F0A5FD255C988A007BE2A3 /* Storage+Shared.swift */; }; C32C5D19256DD493003C73A2 /* LinkPreviewDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBA8255A581500E217F9 /* LinkPreviewDraft.swift */; }; C32C5D83256DD5B6003C73A2 /* SSKKeychainStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBBC255A581600E217F9 /* SSKKeychainStorage.swift */; }; @@ -410,7 +405,6 @@ C3548F0824456AB6009433A8 /* UIView+Wrapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3548F0724456AB6009433A8 /* UIView+Wrapping.swift */; }; C354E75A23FE2A7600CE22E3 /* BaseVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C354E75923FE2A7600CE22E3 /* BaseVC.swift */; }; C35D0DB525AE5F1200B6BF49 /* UIEdgeInsets.swift in Sources */ = {isa = PBXBuildFile; fileRef = C35D0DB425AE5F1200B6BF49 /* UIEdgeInsets.swift */; }; - C35D76DB26606304009AA5FB /* ThreadUpdateBatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C35D76DA26606303009AA5FB /* ThreadUpdateBatcher.swift */; }; C35E8AAE2485E51D00ACB629 /* IP2Country.swift in Sources */ = {isa = PBXBuildFile; fileRef = C35E8AAD2485E51D00ACB629 /* IP2Country.swift */; }; C374EEE225DA26740073A857 /* LinkPreviewModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = C374EEE125DA26740073A857 /* LinkPreviewModal.swift */; }; C374EEEB25DA3CA70073A857 /* ConversationTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C374EEEA25DA3CA70073A857 /* ConversationTitleView.swift */; }; @@ -521,10 +515,6 @@ C3A01E07261D24C400290BEB /* storage-seed-3.der in Resources */ = {isa = PBXBuildFile; fileRef = C3A01E04261D24C400290BEB /* storage-seed-3.der */; }; C3A3A08F256E1728004D228D /* FullTextSearchFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB7F255A581100E217F9 /* FullTextSearchFinder.swift */; }; C3A3A0FE256E1A3C004D228D /* TSDatabaseSecondaryIndexes.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB20255A580900E217F9 /* TSDatabaseSecondaryIndexes.m */; }; - C3A3A107256E1A5C004D228D /* OWSDisappearingMessagesFinder.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA86255A57FC00E217F9 /* OWSDisappearingMessagesFinder.m */; }; - C3A3A108256E1A5C004D228D /* OWSIncomingMessageFinder.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB1E255A580900E217F9 /* OWSIncomingMessageFinder.m */; }; - C3A3A111256E1A93004D228D /* OWSIncomingMessageFinder.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDAC0255A580100E217F9 /* OWSIncomingMessageFinder.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C3A3A122256E1A97004D228D /* OWSDisappearingMessagesFinder.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDC05255A581D00E217F9 /* OWSDisappearingMessagesFinder.h */; settings = {ATTRIBUTES = (Public, ); }; }; C3A3A12B256E1AD5004D228D /* TSDatabaseSecondaryIndexes.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB25255A580900E217F9 /* TSDatabaseSecondaryIndexes.h */; settings = {ATTRIBUTES = (Public, ); }; }; C3A3A171256E1D25004D228D /* SSKReachabilityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A3A170256E1D25004D228D /* SSKReachabilityManager.swift */; }; C3A71D0B2558989C0043A11F /* MessageWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A71D0A2558989C0043A11F /* MessageWrapper.swift */; }; @@ -981,7 +971,6 @@ 4503F1BC20470A5B00CEE724 /* classic.aifc */ = {isa = PBXFileReference; lastKnownFileType = file; path = classic.aifc; sourceTree = ""; }; 4509E7991DD653700025A59F /* WebRTC.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebRTC.framework; path = ThirdParty/WebRTC/Build/WebRTC.framework; sourceTree = ""; }; 450DF2081E0DD2C6003D14BE /* UserNotificationsAdaptee.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = UserNotificationsAdaptee.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; - 451166BF1FD86B98000739BA /* AccountManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountManager.swift; sourceTree = ""; }; 451A13B01E13DED2000A50FD /* AppNotifications.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = AppNotifications.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; 4520D8D41D417D8E00123472 /* Photos.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Photos.framework; path = System/Library/Frameworks/Photos.framework; sourceTree = SDKROOT; }; 4521C3BF1F59F3BA00B4C582 /* TextFieldHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextFieldHelper.swift; sourceTree = ""; }; @@ -1259,7 +1248,6 @@ C33FDA6F255A57FA00E217F9 /* ReachabilityManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReachabilityManager.swift; sourceTree = ""; }; C33FDA73255A57FA00E217F9 /* ECKeyPair+Hexadecimal.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ECKeyPair+Hexadecimal.swift"; sourceTree = ""; }; C33FDA7A255A57FB00E217F9 /* NSRegularExpression+SSK.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSRegularExpression+SSK.swift"; sourceTree = ""; }; - C33FDA86255A57FC00E217F9 /* OWSDisappearingMessagesFinder.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSDisappearingMessagesFinder.m; sourceTree = ""; }; C33FDA87255A57FC00E217F9 /* TypingIndicators.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingIndicators.swift; sourceTree = ""; }; C33FDA88255A57FD00E217F9 /* YapDatabaseTransaction+OWS.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "YapDatabaseTransaction+OWS.h"; sourceTree = ""; }; C33FDA8B255A57FD00E217F9 /* AppVersion.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppVersion.m; sourceTree = ""; }; @@ -1275,7 +1263,6 @@ C33FDAB8255A580100E217F9 /* NSArray+Functional.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSArray+Functional.m"; sourceTree = ""; }; C33FDAB9255A580100E217F9 /* OWSStorage+Subclass.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "OWSStorage+Subclass.h"; sourceTree = ""; }; C33FDABE255A580100E217F9 /* TSConstants.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSConstants.m; sourceTree = ""; }; - C33FDAC0255A580100E217F9 /* OWSIncomingMessageFinder.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSIncomingMessageFinder.h; sourceTree = ""; }; C33FDAC1255A580100E217F9 /* NSSet+Functional.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSSet+Functional.m"; sourceTree = ""; }; C33FDAC3255A580200E217F9 /* OWSDispatch.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSDispatch.m; sourceTree = ""; }; C33FDADC255A580400E217F9 /* NSObject+Casting.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSObject+Casting.h"; sourceTree = ""; }; @@ -1285,7 +1272,6 @@ C33FDAEF255A580500E217F9 /* NSData+Image.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSData+Image.m"; sourceTree = ""; }; C33FDAF1255A580500E217F9 /* OWSThumbnailService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSThumbnailService.swift; sourceTree = ""; }; C33FDAF2255A580500E217F9 /* ProxiedContentDownloader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProxiedContentDownloader.swift; sourceTree = ""; }; - C33FDAF4255A580600E217F9 /* SSKEnvironment.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SSKEnvironment.m; sourceTree = ""; }; C33FDAFC255A580600E217F9 /* MIMETypeUtil.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MIMETypeUtil.h; sourceTree = ""; }; C33FDAFD255A580600E217F9 /* LRUCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LRUCache.swift; sourceTree = ""; }; C33FDAFE255A580600E217F9 /* OWSStorage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSStorage.h; sourceTree = ""; }; @@ -1296,13 +1282,11 @@ C33FDB14255A580800E217F9 /* OWSMath.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSMath.h; sourceTree = ""; }; C33FDB17255A580800E217F9 /* FunctionalUtil.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FunctionalUtil.m; sourceTree = ""; }; C33FDB1C255A580900E217F9 /* UIImage+OWS.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "UIImage+OWS.h"; sourceTree = ""; }; - C33FDB1E255A580900E217F9 /* OWSIncomingMessageFinder.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSIncomingMessageFinder.m; sourceTree = ""; }; C33FDB20255A580900E217F9 /* TSDatabaseSecondaryIndexes.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSDatabaseSecondaryIndexes.m; sourceTree = ""; }; C33FDB22255A580900E217F9 /* OWSMediaUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSMediaUtils.swift; sourceTree = ""; }; C33FDB25255A580900E217F9 /* TSDatabaseSecondaryIndexes.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSDatabaseSecondaryIndexes.h; sourceTree = ""; }; C33FDB29255A580A00E217F9 /* NSData+Image.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSData+Image.h"; sourceTree = ""; }; C33FDB2C255A580A00E217F9 /* TSDatabaseView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSDatabaseView.h; sourceTree = ""; }; - C33FDB31255A580A00E217F9 /* SSKEnvironment.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SSKEnvironment.h; sourceTree = ""; }; C33FDB34255A580B00E217F9 /* ClosedGroupPoller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ClosedGroupPoller.swift; sourceTree = ""; }; C33FDB36255A580B00E217F9 /* Storage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = ""; }; C33FDB38255A580B00E217F9 /* OWSBackgroundTask.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSBackgroundTask.h; sourceTree = ""; }; @@ -1332,10 +1316,8 @@ C33FDB80255A581100E217F9 /* Notification+Loki.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Notification+Loki.swift"; sourceTree = ""; }; C33FDB81255A581100E217F9 /* UIImage+OWS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "UIImage+OWS.m"; sourceTree = ""; }; C33FDB85255A581100E217F9 /* AppContext.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppContext.m; sourceTree = ""; }; - C33FDB88255A581200E217F9 /* TSAccountManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSAccountManager.m; sourceTree = ""; }; C33FDB8A255A581200E217F9 /* AppContext.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppContext.h; sourceTree = ""; }; C33FDB8F255A581200E217F9 /* ParamParser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ParamParser.swift; sourceTree = ""; }; - C33FDB94255A581300E217F9 /* TSAccountManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSAccountManager.h; sourceTree = ""; }; C33FDBA1255A581400E217F9 /* OWSOperation.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSOperation.h; sourceTree = ""; }; C33FDBA8255A581500E217F9 /* LinkPreviewDraft.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LinkPreviewDraft.swift; sourceTree = ""; }; C33FDBAB255A581500E217F9 /* OWSFileSystem.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSFileSystem.h; sourceTree = ""; }; @@ -1354,7 +1336,6 @@ C33FDBFE255A581C00E217F9 /* NSSet+Functional.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSSet+Functional.h"; sourceTree = ""; }; C33FDC02255A581D00E217F9 /* OWSPrimaryStorage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSPrimaryStorage.m; sourceTree = ""; }; C33FDC03255A581D00E217F9 /* ByteParser.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ByteParser.h; sourceTree = ""; }; - C33FDC05255A581D00E217F9 /* OWSDisappearingMessagesFinder.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSDisappearingMessagesFinder.h; sourceTree = ""; }; C33FDC0B255A581D00E217F9 /* OWSError.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSError.m; sourceTree = ""; }; C33FDC12255A581E00E217F9 /* TSConstants.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSConstants.h; sourceTree = ""; }; C33FDC16255A581E00E217F9 /* FunctionalUtil.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FunctionalUtil.h; sourceTree = ""; }; @@ -1379,7 +1360,6 @@ C3548F0724456AB6009433A8 /* UIView+Wrapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Wrapping.swift"; sourceTree = ""; }; C354E75923FE2A7600CE22E3 /* BaseVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseVC.swift; sourceTree = ""; }; C35D0DB425AE5F1200B6BF49 /* UIEdgeInsets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIEdgeInsets.swift; sourceTree = ""; }; - C35D76DA26606303009AA5FB /* ThreadUpdateBatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadUpdateBatcher.swift; sourceTree = ""; }; C35E8AA22485C72300ACB629 /* SwiftCSV.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftCSV.framework; path = ThirdParty/Carthage/Build/iOS/SwiftCSV.framework; sourceTree = ""; }; C35E8AAD2485E51D00ACB629 /* IP2Country.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IP2Country.swift; sourceTree = ""; }; C374EEE125DA26740073A857 /* LinkPreviewModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewModal.swift; sourceTree = ""; }; @@ -1946,7 +1926,6 @@ 76EB03C118170B33006006FC /* Utilities */ = { isa = PBXGroup; children = ( - 451166BF1FD86B98000739BA /* AccountManager.swift */, 4C090A1A210FD9C7001FD7F9 /* HapticFeedback.swift */, 34D5CCA71EAE3D30005515DB /* AvatarViewHelper.h */, 34D5CCA81EAE3D30005515DB /* AvatarViewHelper.m */, @@ -2485,15 +2464,6 @@ path = Quotes; sourceTree = ""; }; - C32C5BB9256DC7C4003C73A2 /* To Do */ = { - isa = PBXGroup; - children = ( - C33FDB94255A581300E217F9 /* TSAccountManager.h */, - C33FDB88255A581200E217F9 /* TSAccountManager.m */, - ); - path = "To Do"; - sourceTree = ""; - }; C32C5BCB256DC818003C73A2 /* Database */ = { isa = PBXGroup; children = ( @@ -2960,10 +2930,6 @@ C38EF281255B6D84007E1867 /* OWSAudioSession.swift */, C33FDB38255A580B00E217F9 /* OWSBackgroundTask.h */, C33FDC1B255A581F00E217F9 /* OWSBackgroundTask.m */, - C33FDC05255A581D00E217F9 /* OWSDisappearingMessagesFinder.h */, - C33FDA86255A57FC00E217F9 /* OWSDisappearingMessagesFinder.m */, - C33FDAC0255A580100E217F9 /* OWSIncomingMessageFinder.h */, - C33FDB1E255A580900E217F9 /* OWSIncomingMessageFinder.m */, C38EF2F1255B6DBB007E1867 /* OWSPreferences.h */, C38EF308255B6DBE007E1867 /* OWSPreferences.m */, FDF0B75D280AAF35004C14C5 /* Preferences.swift */, @@ -2974,12 +2940,9 @@ C38EF2EC255B6DBA007E1867 /* ProximityMonitoringManager.swift */, C3BBE0B42554F0E10050F1E3 /* ProofOfWork.swift */, C38EEF09255B49A8007E1867 /* SNProtoEnvelope+Conversion.swift */, - C33FDB31255A580A00E217F9 /* SSKEnvironment.h */, - C33FDAF4255A580600E217F9 /* SSKEnvironment.m */, FDF0B7542807C4BB004C14C5 /* SSKEnvironment.swift */, C3A3A170256E1D25004D228D /* SSKReachabilityManager.swift */, C3ECBF7A257056B700EA7FCE /* Threading.swift */, - C35D76DA26606303009AA5FB /* ThreadUpdateBatcher.swift */, C33FDB5F255A580E00E217F9 /* YapDatabaseConnection+OWS.h */, C33FDB43255A580C00E217F9 /* YapDatabaseConnection+OWS.m */, C33FDA88255A57FD00E217F9 /* YapDatabaseTransaction+OWS.h */, @@ -3060,7 +3023,6 @@ children = ( C3C2A7802553AA6300C340D1 /* Protos */, C3C2A70A25539DF900C340D1 /* Meta */, - C32C5BB9256DC7C4003C73A2 /* To Do */, C3BBE07F2554CDD70050F1E3 /* Storage.swift */, C32C5BCB256DC818003C73A2 /* Database */, C300A5BB2554AFFB00555489 /* Messages */, @@ -3672,11 +3634,9 @@ buildActionMask = 2147483647; files = ( C32C5EE5256DF506003C73A2 /* YapDatabaseConnection+OWS.h in Headers */, - C32C5B8D256DC565003C73A2 /* SSKEnvironment.h in Headers */, C3C2A6F425539DE700C340D1 /* SessionMessagingKit.h in Headers */, C32C5C46256DCBB2003C73A2 /* AppReadiness.h in Headers */, B8856CB1256F0F47001CE70E /* OWSBackupFragment.h in Headers */, - C3A3A122256E1A97004D228D /* OWSDisappearingMessagesFinder.h in Headers */, C3A3A12B256E1AD5004D228D /* TSDatabaseSecondaryIndexes.h in Headers */, C32C5FC4256E0209003C73A2 /* OWSBackgroundTask.h in Headers */, C32C5EF7256DF567003C73A2 /* TSDatabaseView.h in Headers */, @@ -3686,9 +3646,7 @@ B8856CF7256F105E001CE70E /* OWSAudioPlayer.h in Headers */, B8856D3D256F11B2001CE70E /* Environment.h in Headers */, C32C5E7E256DE023003C73A2 /* YapDatabaseTransaction+OWS.h in Headers */, - C32C5CAD256DD1DF003C73A2 /* TSAccountManager.h in Headers */, C32C5EA0256DE0D6003C73A2 /* OWSPrimaryStorage.h in Headers */, - C3A3A111256E1A93004D228D /* OWSIncomingMessageFinder.h in Headers */, B8856E33256F18D5001CE70E /* OWSStorage+Subclass.h in Headers */, C32C5E64256DDFD6003C73A2 /* OWSStorage.h in Headers */, ); @@ -4636,8 +4594,6 @@ FDD250702837199200198BDA /* GarbageCollectionJob.swift in Sources */, C32A026325A801AA000ED5D4 /* NSData+messagePadding.m in Sources */, FD6A7A6B2818C17C00035AC1 /* UpdateProfilePictureJob.swift in Sources */, - C32C5B84256DC54F003C73A2 /* SSKEnvironment.m in Sources */, - C3A3A108256E1A5C004D228D /* OWSIncomingMessageFinder.m in Sources */, C352A31325574F5200338F3E /* MessageReceiveJob.swift in Sources */, C3C2A7562553A3AB00C340D1 /* VisibleMessage+Quote.swift in Sources */, C3227FF6260AAD66006EA627 /* OpenGroupMessageV2.swift in Sources */, @@ -4648,7 +4604,6 @@ C32C5A76256DBBCF003C73A2 /* SignalAttachment.swift in Sources */, FDA8EB00280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift in Sources */, FD09798927FD1C5A00936362 /* OpenGroup.swift in Sources */, - C32C5CA4256DD1DC003C73A2 /* TSAccountManager.m in Sources */, C3BBE0B52554F0E10050F1E3 /* ProofOfWork.swift in Sources */, C3A3A08F256E1728004D228D /* FullTextSearchFinder.swift in Sources */, FDF0B7472804F0CE004C14C5 /* DisappearingMessagesJob.swift in Sources */, @@ -4666,7 +4621,6 @@ FD09798327FD1A1500936362 /* ClosedGroup.swift in Sources */, B8856D34256F1192001CE70E /* Environment.m in Sources */, B8B320B7258C30D70020074B /* HTMLMetadata.swift in Sources */, - C3A3A107256E1A5C004D228D /* OWSDisappearingMessagesFinder.m in Sources */, FD09798727FD1B7800936362 /* GroupMember.swift in Sources */, FD09799127FD499200936362 /* BoxKeyPair+Utilities.swift in Sources */, FDB4BBC92839BEF000B7C95D /* ProfileManagerError.swift in Sources */, @@ -4703,7 +4657,6 @@ FDF0B75A2807F3A3004C14C5 /* MessageSenderError.swift in Sources */, C32C5EDC256DF501003C73A2 /* YapDatabaseConnection+OWS.m in Sources */, C3BBE0762554CDA60050F1E3 /* Configuration.swift in Sources */, - C35D76DB26606304009AA5FB /* ThreadUpdateBatcher.swift in Sources */, B8856D69256F141F001CE70E /* OWSWindowManager.m in Sources */, C3ECBF7B257056B700EA7FCE /* Threading.swift in Sources */, C32C5A88256DBCF9003C73A2 /* MessageReceiver+Handling.swift in Sources */, @@ -4782,7 +4735,6 @@ B84A89BC25DE328A0040017D /* ProfilePictureVC.swift in Sources */, 34386A54207D271D009F5D9C /* NeverClearView.swift in Sources */, FDCDB8E02811007F00352A0C /* HomeViewModel.swift in Sources */, - 451166C01FD86B98000739BA /* AccountManager.swift in Sources */, C374EEF425DB31D40073A857 /* VoiceMessageRecordingView.swift in Sources */, B83F2B88240CB75A000A54AB /* UIImage+Scaling.swift in Sources */, 3430FE181F7751D4000EC51B /* GiphyAPI.swift in Sources */, diff --git a/Session/Conversations/ConversationViewItem.m b/Session/Conversations/ConversationViewItem.m index 5be802798..102803dfc 100644 --- a/Session/Conversations/ConversationViewItem.m +++ b/Session/Conversations/ConversationViewItem.m @@ -6,11 +6,8 @@ #import "ConversationViewItem.h" #import "Session-Swift.h" #import "AnyPromise.h" -#import #import #import -#import -#import #import NS_ASSUME_NONNULL_BEGIN diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 47d2e3805..c8bfee916 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -67,24 +67,19 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD // Setup the UI self?.ensureRootViewController() - - // Every time the user upgrades to a new version: - // - // * Update account attributes. - // * Sync configuration. if Identity.userExists() { - // TODO: This -// AppVersion *appVersion = AppVersion.sharedInstance; -// if (appVersion.lastAppVersion.length > 0 -// && ![appVersion.lastAppVersion isEqualToString:appVersion.currentAppVersion]) { -// [[self.tsAccountManager updateAccountAttributes] retainUntilComplete]; -// } - } - - // If we need a config sync then trigger it now - if (needsConfigSync) { - GRDBStorage.shared.write { db in - try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() + let appVersion: AppVersion = AppVersion.sharedInstance() + + // If the device needs to sync config or the user updated to a new version + if + needsConfigSync || ( // TODO: 'needsConfigSync' logic for migrations + (appVersion.lastAppVersion?.count ?? 0) > 0 && + appVersion.lastAppVersion != appVersion.currentAppVersion + ) + { + GRDBStorage.shared.write { db in + try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() + } } } } diff --git a/Session/Meta/AppEnvironment.swift b/Session/Meta/AppEnvironment.swift index f6226f557..f2fa40af4 100644 --- a/Session/Meta/AppEnvironment.swift +++ b/Session/Meta/AppEnvironment.swift @@ -25,9 +25,6 @@ import SignalUtilitiesKit } } - @objc - public var accountManager: AccountManager - @objc public var notificationPresenter: NotificationPresenter @@ -47,7 +44,6 @@ import SignalUtilitiesKit } private override init() { - self.accountManager = AccountManager() self.notificationPresenter = NotificationPresenter() self.pushRegistrationManager = PushRegistrationManager() self._userNotificationActionHandler = UserNotificationActionHandler() diff --git a/Session/Meta/Signal-Bridging-Header.h b/Session/Meta/Signal-Bridging-Header.h index f5f701a8b..42da6bcad 100644 --- a/Session/Meta/Signal-Bridging-Header.h +++ b/Session/Meta/Signal-Bridging-Header.h @@ -56,7 +56,6 @@ #import #import #import -#import #import #import #import diff --git a/Session/Notifications/SyncPushTokensJob.swift b/Session/Notifications/SyncPushTokensJob.swift index 85e276feb..fbcfd8657 100644 --- a/Session/Notifications/SyncPushTokensJob.swift +++ b/Session/Notifications/SyncPushTokensJob.swift @@ -50,8 +50,8 @@ public enum SyncPushTokensJob: JobExecutor { PushRegistrationManager.shared.requestPushTokens() .then { (pushToken: String, voipToken: String) -> Promise in - let lastPushToken: String? = GRDBStorage.shared.read { db in db[.lastRecordedPushToken] } - let lastVoipToken: String? = GRDBStorage.shared.read { db in db[.lastRecordedVoipToken] } + let lastPushToken: String? = GRDBStorage.shared[.lastRecordedPushToken] + let lastVoipToken: String? = GRDBStorage.shared[.lastRecordedVoipToken] let shouldUploadTokens: Bool = ( !uploadOnlyIfStale || ( lastPushToken != pushToken || @@ -64,14 +64,13 @@ public enum SyncPushTokensJob: JobExecutor { let (promise, seal) = Promise.pending() - SSKEnvironment.shared.tsAccountManager - .registerForPushNotifications( - pushToken: pushToken, - voipToken: voipToken, - isForcedUpdate: shouldUploadTokens, - success: { seal.fulfill(()) }, - failure: seal.reject - ) + SyncPushTokensJob.registerForPushNotifications( + pushToken: pushToken, + voipToken: voipToken, + isForcedUpdate: shouldUploadTokens, + success: { seal.fulfill(()) }, + failure: seal.reject + ) return promise .done { _ in @@ -119,6 +118,46 @@ private func redact(_ string: String) -> String { return OWSIsDebugBuild() ? string : "[ READACTED \(string.prefix(2))...\(string.suffix(2)) ]" } +extension SyncPushTokensJob { + fileprivate static func registerForPushNotifications( + pushToken: String, + voipToken: String, + isForcedUpdate: Bool, + success: @escaping () -> (), + failure: @escaping (Error) -> (), + remainingRetries: Int = 3 + ) { + let isUsingFullAPNs: Bool = UserDefaults.standard[.isUsingFullAPNs] + let pushTokenAsData = Data(hex: pushToken) + let promise: Promise = (isUsingFullAPNs ? + PushNotificationAPI.register( + with: pushTokenAsData, + publicKey: getUserHexEncodedPublicKey(), + isForcedUpdate: isForcedUpdate + ) : + PushNotificationAPI.unregister(pushTokenAsData) + ) + + promise + .done { success() } + .catch { error in + guard remainingRetries == 0 else { + SyncPushTokensJob.registerForPushNotifications( + pushToken: pushToken, + voipToken: voipToken, + isForcedUpdate: isForcedUpdate, + success: success, + failure: failure, + remainingRetries: (remainingRetries - 1) + ) + return + } + + failure(error) + } + } +} + // MARK: - Objective C Support @objc(OWSSyncPushTokensJob) diff --git a/Session/Onboarding/LinkDeviceVC.swift b/Session/Onboarding/LinkDeviceVC.swift index 9c5edaeae..14d4a4f3b 100644 --- a/Session/Onboarding/LinkDeviceVC.swift +++ b/Session/Onboarding/LinkDeviceVC.swift @@ -136,7 +136,7 @@ final class LinkDeviceVC : BaseVC, UIPageViewControllerDataSource, UIPageViewCon } let (ed25519KeyPair, x25519KeyPair) = try! Identity.generate(from: seed) Onboarding.Flow.link.preregister(with: seed, ed25519KeyPair: ed25519KeyPair, x25519KeyPair: x25519KeyPair) - TSAccountManager.sharedInstance().didRegister() + Identity.didRegister() NotificationCenter.default.addObserver(self, selector: #selector(handleInitialConfigurationMessageReceived), name: .initialConfigurationMessageReceived, object: nil) ModalActivityIndicatorViewController.present(fromViewController: navigationController!) { [weak self] modal in self?.activityIndicatorModal = modal @@ -144,8 +144,6 @@ final class LinkDeviceVC : BaseVC, UIPageViewControllerDataSource, UIPageViewCon } @objc private func handleInitialConfigurationMessageReceived(_ notification: Notification) { - TSAccountManager.sharedInstance().phoneNumberAwaitingVerification = getUserHexEncodedPublicKey() - DispatchQueue.main.async { self.navigationController!.dismiss(animated: true) { let pnModeVC = PNModeVC() diff --git a/Session/Onboarding/Onboarding.swift b/Session/Onboarding/Onboarding.swift index f8772252f..aad20b279 100644 --- a/Session/Onboarding/Onboarding.swift +++ b/Session/Onboarding/Onboarding.swift @@ -16,7 +16,7 @@ enum Onboarding { let userDefaults = UserDefaults.standard Identity.store(seed: seed, ed25519KeyPair: ed25519KeyPair, x25519KeyPair: x25519KeyPair) let x25519PublicKey = x25519KeyPair.hexEncodedPublicKey - TSAccountManager.sharedInstance().phoneNumberAwaitingVerification = x25519PublicKey + GRDBStorage.shared.write { db in try Contact(id: x25519PublicKey) .with( diff --git a/Session/Onboarding/PNModeVC.swift b/Session/Onboarding/PNModeVC.swift index 371701e02..833520bbe 100644 --- a/Session/Onboarding/PNModeVC.swift +++ b/Session/Onboarding/PNModeVC.swift @@ -1,4 +1,6 @@ +import UIKit import PromiseKit +import SessionMessagingKit final class PNModeVC : BaseVC, OptionViewDelegate { @@ -94,7 +96,7 @@ final class PNModeVC : BaseVC, OptionViewDelegate { return present(alert, animated: true, completion: nil) } UserDefaults.standard[.isUsingFullAPNs] = (selectedOptionView == apnsOptionView) - TSAccountManager.sharedInstance().didRegister() + Identity.didRegister() let homeVC = HomeVC() navigationController!.setViewControllers([ homeVC ], animated: true) diff --git a/Session/Utilities/AccountManager.swift b/Session/Utilities/AccountManager.swift deleted file mode 100644 index 3f08a5da0..000000000 --- a/Session/Utilities/AccountManager.swift +++ /dev/null @@ -1,130 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -import Foundation -import PromiseKit -import SignalUtilitiesKit - -/** - * Signal is actually two services - textSecure for messages and red phone (for calls). - * AccountManager delegates to both. - */ -@objc -public class AccountManager: NSObject { - - // MARK: - Dependencies - - private var preferences: OWSPreferences { - return Environment.shared.preferences - } - - private var tsAccountManager: TSAccountManager { - return TSAccountManager.sharedInstance() - } - - // MARK: - - - @objc - public override init() { - super.init() - - SwiftSingletons.register(self) - } - - // MARK: registration - - @objc func registerObjc(verificationCode: String, - pin: String?) -> AnyPromise { - return AnyPromise(register(verificationCode: verificationCode, pin: pin)) - } - - func register(verificationCode: String, - pin: String?) -> Promise { - guard verificationCode.count > 0 else { - let error = OWSErrorWithCodeDescription(.userError, - NSLocalizedString("REGISTRATION_ERROR_BLANK_VERIFICATION_CODE", - comment: "alert body during registration")) - return Promise(error: error) - } - - Logger.debug("registering with signal server") - let registrationPromise: Promise = firstly { - return self.registerForTextSecure(verificationCode: verificationCode, pin: pin) - }.then { _ -> Promise in - return self.syncPushTokens().recover { (error) -> Promise in - switch error { - case PushRegistrationError.pushNotSupported(let description): - // This can happen with: - // - simulators, none of which support receiving push notifications - // - on iOS11 devices which have disabled "Allow Notifications" and disabled "Enable Background Refresh" in the system settings. - Logger.info("Recovered push registration error. Registering for manual message fetcher because push not supported: \(description)") - return self.enableManualMessageFetching() - default: - throw error - } - } - }.done { (_) -> Void in - self.completeRegistration() - } - - registrationPromise.retainUntilComplete() - - return registrationPromise - } - - private func registerForTextSecure(verificationCode: String, - pin: String?) -> Promise { - return Promise { resolver in - tsAccountManager.verifyAccount(withCode: verificationCode, - pin: pin, - success: { resolver.fulfill(()) }, - failure: resolver.reject) - } - } - - private func syncPushTokens() -> Promise { - Logger.info("") - - guard let job: Job = Job( - variant: .syncPushTokens, - details: SyncPushTokensJob.Details( - uploadOnlyIfStale: false - ) - ) - else { return Promise(error: GRDBStorageError.decodingFailed) } - - let (promise, seal) = Promise.pending() - - SyncPushTokensJob.run( - job, - success: { _, _ in seal.fulfill(()) }, - failure: { _, error, _ in seal.reject(error ?? GRDBStorageError.generic) }, - deferred: { _ in seal.reject(GRDBStorageError.generic) } - ) - - return promise - } - - private func completeRegistration() { - Logger.info("") - tsAccountManager.didRegister() - } - - // MARK: Message Delivery - - func updatePushTokens(pushToken: String, voipToken: String, isForcedUpdate: Bool) -> Promise { - return Promise { resolver in - tsAccountManager.registerForPushNotifications(pushToken: pushToken, - voipToken: voipToken, - isForcedUpdate: isForcedUpdate, - success: { resolver.fulfill(()) }, - failure: resolver.reject) - } - } - - func enableManualMessageFetching() -> Promise { - let anyPromise = tsAccountManager.setIsManualMessageFetchEnabled(true) - return Promise(anyPromise).asVoid() - } -} diff --git a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift index b809c947d..042b6190f 100644 --- a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift +++ b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift @@ -64,7 +64,7 @@ public extension DisappearingMessagesConfiguration { var previewText: String { guard let senderName: String = senderName else { - // Changed by localNumber on this device or via synced transcript + // Changed by this device or via synced transcript guard isEnabled, durationSeconds > 0 else { return "YOU_DISABLED_DISAPPEARING_MESSAGES_CONFIGURATION".localized() } return String( diff --git a/SessionMessagingKit/Database/OWSPrimaryStorage.m b/SessionMessagingKit/Database/OWSPrimaryStorage.m index df54ae71e..fd1cc007e 100644 --- a/SessionMessagingKit/Database/OWSPrimaryStorage.m +++ b/SessionMessagingKit/Database/OWSPrimaryStorage.m @@ -5,11 +5,9 @@ #import "OWSPrimaryStorage.h" #import "AppContext.h" #import "OWSFileSystem.h" -#import "OWSIncomingMessageFinder.h" #import #import "OWSStorage.h" #import "OWSStorage+Subclass.h" -#import "SSKEnvironment.h" #import "TSDatabaseSecondaryIndexes.h" #import "TSDatabaseView.h" #import @@ -173,8 +171,6 @@ void VerifyRegistrationsForPrimaryStorage(OWSStorage *storage) [TSDatabaseView asyncRegisterThreadOutgoingMessagesDatabaseView:self]; [FullTextSearchFinder asyncRegisterDatabaseExtensionWithStorage:self]; - [OWSIncomingMessageFinder asyncRegisterExtensionWithPrimaryStorage:self]; - [OWSDisappearingMessagesFinder asyncRegisterDatabaseExtensions:self]; [TSDatabaseView asyncRegisterLazyRestoreAttachmentsDatabaseView:self]; [self.database diff --git a/SessionMessagingKit/Meta/SessionMessagingKit.h b/SessionMessagingKit/Meta/SessionMessagingKit.h index 12dc61212..262399fb4 100644 --- a/SessionMessagingKit/Meta/SessionMessagingKit.h +++ b/SessionMessagingKit/Meta/SessionMessagingKit.h @@ -9,14 +9,11 @@ FOUNDATION_EXPORT const unsigned char SessionMessagingKitVersionString[]; #import #import #import -#import #import #import #import #import #import -#import -#import #import #import #import diff --git a/SessionMessagingKit/To Do/TSAccountManager.h b/SessionMessagingKit/To Do/TSAccountManager.h deleted file mode 100644 index 9c6013bf9..000000000 --- a/SessionMessagingKit/To Do/TSAccountManager.h +++ /dev/null @@ -1,167 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -extern NSString *const TSRegistrationErrorDomain; -extern NSString *const TSRegistrationErrorUserInfoHTTPStatus; -extern NSString *const RegistrationStateDidChangeNotification; -extern NSString *const kNSNotificationName_LocalNumberDidChange; - -@class AnyPromise; -@class OWSPrimaryStorage; -@class TSNetworkManager; -@class YapDatabaseReadTransaction; -@class YapDatabaseReadWriteTransaction; - -typedef NS_ENUM(NSUInteger, OWSRegistrationState) { - OWSRegistrationState_Unregistered, - OWSRegistrationState_PendingBackupRestore, - OWSRegistrationState_Registered, - OWSRegistrationState_Deregistered, - OWSRegistrationState_Reregistering, -}; - -@interface TSAccountManager : NSObject - -@property (nonatomic, nullable) NSString *phoneNumberAwaitingVerification; - -#pragma mark - Initializers - -- (instancetype)init NS_UNAVAILABLE; - -- (instancetype)initWithPrimaryStorage:(OWSPrimaryStorage *)primaryStorage NS_DESIGNATED_INITIALIZER; - -+ (instancetype)sharedInstance; - -- (OWSRegistrationState)registrationState; - -/** - * Returns if a user is registered or not - * - * @return registered or not - */ -- (BOOL)isRegistered; -- (BOOL)isRegisteredAndReady; - -/** - * Returns current phone number for this device, which may not yet have been registered. - * - * @return E164 formatted phone number - */ -+ (nullable NSString *)localNumber; -- (nullable NSString *)localNumber; - -// A variant of localNumber that never opens a "sneaky" transaction. -- (nullable NSString *)storedOrCachedLocalNumber:(YapDatabaseReadTransaction *)transaction; - -/** - * Symmetric key that's used to encrypt message payloads from the server, - * - * @return signaling key - */ -+ (nullable NSString *)signalingKey; -- (nullable NSString *)signalingKey; - -/** - * The server auth token allows the Signal client to connect to the Signal server - * - * @return server authentication token - */ -+ (nullable NSString *)serverAuthToken; -- (nullable NSString *)serverAuthToken; - -/** - * The registration ID is unique to an installation of TextSecure, it allows to know if the app was reinstalled - * - * @return registrationID; - */ - -+ (uint32_t)getOrGenerateRegistrationId:(YapDatabaseReadWriteTransaction *)transaction; -- (uint32_t)getOrGenerateRegistrationId; -- (uint32_t)getOrGenerateRegistrationId:(YapDatabaseReadWriteTransaction *)transaction; - -#pragma mark - Register with phone number - -- (void)registerWithPhoneNumber:(NSString *)phoneNumber - captchaToken:(nullable NSString *)captchaToken - success:(void (^)(void))successBlock - failure:(void (^)(NSError *error))failureBlock - smsVerification:(BOOL)isSMS; - -- (void)rerequestSMSWithCaptchaToken:(nullable NSString *)captchaToken - success:(void (^)(void))successBlock - failure:(void (^)(NSError *error))failureBlock; - -- (void)rerequestVoiceWithCaptchaToken:(nullable NSString *)captchaToken - success:(void (^)(void))successBlock - failure:(void (^)(NSError *error))failureBlock; - -- (void)verifyAccountWithCode:(NSString *)verificationCode - pin:(nullable NSString *)pin - success:(void (^)(void))successBlock - failure:(void (^)(NSError *error))failureBlock; - -// Called once registration is complete - meaning the following have succeeded: -// - obtained signal server credentials -// - uploaded pre-keys -// - uploaded push tokens -- (void)didRegister; - -#if TARGET_OS_IPHONE - -/** - * Register's the device's push notification token with the server - * - * @param pushToken Apple's Push Token - */ -- (void)registerForPushNotificationsWithPushToken:(NSString *)pushToken - voipToken:(NSString *)voipToken - isForcedUpdate:(BOOL)isForcedUpdate - success:(void (^)(void))successHandler - failure:(void (^)(NSError *error))failureHandler - NS_SWIFT_NAME(registerForPushNotifications(pushToken:voipToken:isForcedUpdate:success:failure:)); - -#endif - -+ (void)unregisterTextSecureWithSuccess:(void (^)(void))success failure:(void (^)(NSError *error))failureBlock; - -#pragma mark - De-Registration - -// De-registration reflects whether or not the "last known contact" -// with the service was: -// -// * A 403 from the service, indicating de-registration. -// * A successful auth'd request _or_ websocket connection indicating -// valid registration. -- (BOOL)isDeregistered; -- (void)setIsDeregistered:(BOOL)isDeregistered; - -#pragma mark - Re-registration - -// Re-registration is the process of re-registering _with the same phone number_. - -// Returns YES on success. -- (nullable NSString *)reregisterationPhoneNumber; -- (BOOL)isReregistering; - -#pragma mark - Manual Message Fetch - -- (BOOL)isManualMessageFetchEnabled; -- (AnyPromise *)setIsManualMessageFetchEnabled:(BOOL)value __attribute__((warn_unused_result)); - -#ifdef DEBUG -- (void)registerForTestsWithLocalNumber:(NSString *)localNumber; -#endif - -- (AnyPromise *)updateAccountAttributes __attribute__((warn_unused_result)); - -// This should only be used during the registration process. -- (AnyPromise *)performUpdateAccountAttributes __attribute__((warn_unused_result)); - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/To Do/TSAccountManager.m b/SessionMessagingKit/To Do/TSAccountManager.m deleted file mode 100644 index c74e356c0..000000000 --- a/SessionMessagingKit/To Do/TSAccountManager.m +++ /dev/null @@ -1,499 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "TSAccountManager.h" -#import "AppContext.h" -#import "AppReadiness.h" -#import "NSNotificationCenter+OWS.h" -#import "SSKEnvironment.h" -#import "YapDatabaseConnection+OWS.h" -#import "YapDatabaseTransaction+OWS.h" -#import -#import -#import -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -NSString *const TSRegistrationErrorDomain = @"TSRegistrationErrorDomain"; -NSString *const TSRegistrationErrorUserInfoHTTPStatus = @"TSHTTPStatus"; -NSString *const RegistrationStateDidChangeNotification = @"RegistrationStateDidChangeNotification"; -NSString *const kNSNotificationName_LocalNumberDidChange = @"kNSNotificationName_LocalNumberDidChange"; - -NSString *const TSAccountManager_RegisteredNumberKey = @"TSStorageRegisteredNumberKey"; -NSString *const TSAccountManager_IsDeregisteredKey = @"TSAccountManager_IsDeregisteredKey"; -NSString *const TSAccountManager_ReregisteringPhoneNumberKey = @"TSAccountManager_ReregisteringPhoneNumberKey"; -NSString *const TSAccountManager_LocalRegistrationIdKey = @"TSStorageLocalRegistrationId"; -NSString *const TSAccountManager_HasPendingRestoreDecisionKey = @"TSAccountManager_HasPendingRestoreDecisionKey"; - -NSString *const TSAccountManager_UserAccountCollection = @"TSStorageUserAccountCollection"; -NSString *const TSAccountManager_ServerAuthToken = @"TSStorageServerAuthToken"; -NSString *const TSAccountManager_ServerSignalingKey = @"TSStorageServerSignalingKey"; -NSString *const TSAccountManager_ManualMessageFetchKey = @"TSAccountManager_ManualMessageFetchKey"; -NSString *const TSAccountManager_NeedsAccountAttributesUpdateKey = @"TSAccountManager_NeedsAccountAttributesUpdateKey"; - -@interface TSAccountManager () - -@property (atomic, readonly) BOOL isRegistered; - -@property (nonatomic, nullable) NSString *cachedLocalNumber; -@property (nonatomic, readonly) YapDatabaseConnection *dbConnection; - -@property (nonatomic, nullable) NSNumber *cachedIsDeregistered; - -@property (nonatomic) Reachability *reachability; - -@end - -#pragma mark - - -@implementation TSAccountManager - -@synthesize isRegistered = _isRegistered; - -- (instancetype)initWithPrimaryStorage:(OWSPrimaryStorage *)primaryStorage -{ - self = [super init]; - if (!self) { - return self; - } - - _dbConnection = [primaryStorage newDatabaseConnection]; - self.reachability = [Reachability reachabilityForInternetConnection]; - - if (!CurrentAppContext().isMainApp) { - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(yapDatabaseModifiedExternally:) - name:YapDatabaseModifiedExternallyNotification - object:nil]; - } - - [AppReadiness runNowOrWhenAppDidBecomeReady:^{ - [[self updateAccountAttributesIfNecessary] retainUntilComplete]; - }]; - - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(reachabilityChanged) - name:kReachabilityChangedNotification - object:nil]; - - return self; -} - -- (void)dealloc -{ - [[NSNotificationCenter defaultCenter] removeObserver:self]; -} - -+ (instancetype)sharedInstance -{ - return SSKEnvironment.shared.tsAccountManager; -} - -#pragma mark - Dependencies - -- (id)profileManager { - return SSKEnvironment.shared.profileManager; -} - -#pragma mark - - -- (void)setPhoneNumberAwaitingVerification:(NSString *_Nullable)phoneNumberAwaitingVerification -{ - _phoneNumberAwaitingVerification = phoneNumberAwaitingVerification; - - [[NSNotificationCenter defaultCenter] postNotificationNameAsync:kNSNotificationName_LocalNumberDidChange - object:nil - userInfo:nil]; -} - -- (OWSRegistrationState)registrationState -{ - if (!self.isRegistered) { - return OWSRegistrationState_Unregistered; - } else if (self.isDeregistered) { - if (self.isReregistering) { - return OWSRegistrationState_Reregistering; - } else { - return OWSRegistrationState_Deregistered; - } - } else if (self.isDeregistered) { - return OWSRegistrationState_PendingBackupRestore; - } else { - return OWSRegistrationState_Registered; - } -} - -- (BOOL)isRegistered -{ - @synchronized (self) { - if (_isRegistered) { - return YES; - } else { - // Cache this once it's true since it's called alot, involves a dbLookup, and once set - it doesn't change. - _isRegistered = [self storedLocalNumber] != nil; - } - return _isRegistered; - } -} - -- (BOOL)isRegisteredAndReady -{ - return self.registrationState == OWSRegistrationState_Registered; -} - -- (void)didRegister -{ - NSString *phoneNumber = self.phoneNumberAwaitingVerification; - - [self storeLocalNumber:phoneNumber]; - - // Warm these cached values. - [self isRegistered]; - [self localNumber]; - [self isDeregistered]; - - [self postRegistrationStateDidChangeNotification]; -} - -+ (nullable NSString *)localNumber -{ - return [[self sharedInstance] localNumber]; -} - -- (nullable NSString *)localNumber -{ - NSString *awaitingVerif = self.phoneNumberAwaitingVerification; - if (awaitingVerif) { - return awaitingVerif; - } - - // Cache this since we access this a lot, and once set it will not change. - @synchronized(self) - { - if (self.cachedLocalNumber == nil) { - self.cachedLocalNumber = self.storedLocalNumber; - } - } - - return self.cachedLocalNumber; -} - -- (nullable NSString *)storedLocalNumber -{ - @synchronized (self) { - return [self.dbConnection stringForKey:TSAccountManager_RegisteredNumberKey - inCollection:TSAccountManager_UserAccountCollection]; - } -} - -- (nullable NSString *)storedOrCachedLocalNumber:(YapDatabaseReadTransaction *)transaction -{ - @synchronized(self) { - if (self.cachedLocalNumber) { - return self.cachedLocalNumber; - } - } - - return [transaction stringForKey:TSAccountManager_RegisteredNumberKey - inCollection:TSAccountManager_UserAccountCollection]; -} - -- (void)storeLocalNumber:(NSString *)localNumber -{ - @synchronized (self) { - [self.dbConnection setObject:localNumber - forKey:TSAccountManager_RegisteredNumberKey - inCollection:TSAccountManager_UserAccountCollection]; - - [self.dbConnection removeObjectForKey:TSAccountManager_ReregisteringPhoneNumberKey - inCollection:TSAccountManager_UserAccountCollection]; - - self.phoneNumberAwaitingVerification = nil; - - self.cachedLocalNumber = localNumber; - } -} - -+ (uint32_t)getOrGenerateRegistrationId:(YapDatabaseReadWriteTransaction *)transaction -{ - return [[self sharedInstance] getOrGenerateRegistrationId:transaction]; -} - -- (uint32_t)getOrGenerateRegistrationId -{ - __block uint32_t result; - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) { - result = [self getOrGenerateRegistrationId:transaction]; - }]; - return result; -} - -- (uint32_t)getOrGenerateRegistrationId:(YapDatabaseReadWriteTransaction *)transaction -{ - // Unlike other methods in this class, there's no need for a `@synchronized` block - // here, since we're already in a write transaction, and all writes occur on a serial queue. - // - // Since other code in this class which uses @synchronized(self) also needs to open write - // transaction, using @synchronized(self) here, inside of a WriteTransaction risks deadlock. - uint32_t registrationID = [[transaction objectForKey:TSAccountManager_LocalRegistrationIdKey - inCollection:TSAccountManager_UserAccountCollection] unsignedIntValue]; - - if (registrationID == 0) { - registrationID = (uint32_t)arc4random_uniform(16380) + 1; - - [transaction setObject:[NSNumber numberWithUnsignedInteger:registrationID] - forKey:TSAccountManager_LocalRegistrationIdKey - inCollection:TSAccountManager_UserAccountCollection]; - } - return registrationID; -} - -- (void)registerForPushNotificationsWithPushToken:(NSString *)pushToken - voipToken:(NSString *)voipToken - isForcedUpdate:(BOOL)isForcedUpdate - success:(void (^)(void))successHandler - failure:(void (^)(NSError *))failureHandler -{ - [self registerForPushNotificationsWithPushToken:pushToken - voipToken:voipToken - isForcedUpdate:isForcedUpdate - success:successHandler - failure:failureHandler - remainingRetries:3]; -} - -- (void)registerForPushNotificationsWithPushToken:(NSString *)pushToken - voipToken:(NSString *)voipToken - isForcedUpdate:(BOOL)isForcedUpdate - success:(void (^)(void))successHandler - failure:(void (^)(NSError *))failureHandler - remainingRetries:(int)remainingRetries -{ - BOOL isUsingFullAPNs = [NSUserDefaults.standardUserDefaults boolForKey:@"isUsingFullAPNs"]; - NSData *pushTokenAsData = [NSData dataFromHexString:pushToken]; - AnyPromise *promise = isUsingFullAPNs ? [LKPushNotificationAPI registerWithToken:pushTokenAsData hexEncodedPublicKey:self.localNumber isForcedUpdate:isForcedUpdate] - : [LKPushNotificationAPI unregisterToken:pushTokenAsData]; - promise - .then(^() { - successHandler(); - }) - .catch(^(NSError *error) { - if (remainingRetries > 0) { - [self registerForPushNotificationsWithPushToken:pushToken voipToken:voipToken isForcedUpdate:isForcedUpdate success:successHandler failure:failureHandler - remainingRetries:remainingRetries - 1]; - } else { - failureHandler(error); - } - }); -} - -- (void)rerequestSMSWithCaptchaToken:(nullable NSString *)captchaToken - success:(void (^)(void))successBlock - failure:(void (^)(NSError *error))failureBlock -{ - // TODO: Can we remove phoneNumberAwaitingVerification? - NSString *number = self.phoneNumberAwaitingVerification; - - [self registerWithPhoneNumber:number - captchaToken:captchaToken - success:successBlock - failure:failureBlock - smsVerification:YES]; -} - -- (void)rerequestVoiceWithCaptchaToken:(nullable NSString *)captchaToken - success:(void (^)(void))successBlock - failure:(void (^)(NSError *error))failureBlock -{ - NSString *number = self.phoneNumberAwaitingVerification; - - [self registerWithPhoneNumber:number - captchaToken:captchaToken - success:successBlock - failure:failureBlock - smsVerification:NO]; -} - -#pragma mark Server keying material - -+ (NSString *)generateNewAccountAuthenticationToken { - NSData *authToken = [Randomness generateRandomBytes:16]; - NSString *authTokenPrint = [[NSData dataWithData:authToken] hexadecimalString]; - return authTokenPrint; -} - -+ (nullable NSString *)signalingKey -{ - return [[self sharedInstance] signalingKey]; -} - -- (nullable NSString *)signalingKey -{ - return [self.dbConnection stringForKey:TSAccountManager_ServerSignalingKey - inCollection:TSAccountManager_UserAccountCollection]; -} - -+ (nullable NSString *)serverAuthToken -{ - return [[self sharedInstance] serverAuthToken]; -} - -- (nullable NSString *)serverAuthToken -{ - return [self.dbConnection stringForKey:TSAccountManager_ServerAuthToken - inCollection:TSAccountManager_UserAccountCollection]; -} - -- (void)storeServerAuthToken:(NSString *)authToken -{ - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [transaction setObject:authToken - forKey:TSAccountManager_ServerAuthToken - inCollection:TSAccountManager_UserAccountCollection]; - }]; -} - -- (void)yapDatabaseModifiedExternally:(NSNotification *)notification -{ - // Any database write by the main app might reflect a deregistration, - // so clear the cached "is registered" state. This will significantly - // erode the value of this cache in the SAE. - @synchronized(self) - { - _isRegistered = NO; - } -} - -#pragma mark - De-Registration - -- (BOOL)isDeregistered -{ - // Cache this since we access this a lot, and once set it will not change. - @synchronized(self) { - if (self.cachedIsDeregistered == nil) { - self.cachedIsDeregistered = @([self.dbConnection boolForKey:TSAccountManager_IsDeregisteredKey - inCollection:TSAccountManager_UserAccountCollection - defaultValue:NO]); - } - - return self.cachedIsDeregistered.boolValue; - } -} - -- (void)setIsDeregistered:(BOOL)isDeregistered -{ - @synchronized(self) { - if (self.cachedIsDeregistered && self.cachedIsDeregistered.boolValue == isDeregistered) { - return; - } - - self.cachedIsDeregistered = @(isDeregistered); - } - - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [transaction setObject:@(isDeregistered) - forKey:TSAccountManager_IsDeregisteredKey - inCollection:TSAccountManager_UserAccountCollection]; - }]; - - [self postRegistrationStateDidChangeNotification]; -} - -- (nullable NSString *)reregisterationPhoneNumber -{ - NSString *_Nullable result = [self.dbConnection stringForKey:TSAccountManager_ReregisteringPhoneNumberKey - inCollection:TSAccountManager_UserAccountCollection]; - return result; -} - -- (BOOL)isReregistering -{ - return nil != - [self.dbConnection stringForKey:TSAccountManager_ReregisteringPhoneNumberKey - inCollection:TSAccountManager_UserAccountCollection]; -} - -- (BOOL)isManualMessageFetchEnabled -{ - return [self.dbConnection boolForKey:TSAccountManager_ManualMessageFetchKey - inCollection:TSAccountManager_UserAccountCollection - defaultValue:NO]; -} - -- (AnyPromise *)setIsManualMessageFetchEnabled:(BOOL)value { - [self.dbConnection setBool:value - forKey:TSAccountManager_ManualMessageFetchKey - inCollection:TSAccountManager_UserAccountCollection]; - - // Try to update the account attributes to reflect this change. - return [self updateAccountAttributes]; -} - -- (void)registerForTestsWithLocalNumber:(NSString *)localNumber -{ - [self storeLocalNumber:localNumber]; -} - -#pragma mark - Account Attributes - -- (AnyPromise *)updateAccountAttributes { - // Enqueue a "account attribute update", recording the "request time". - [self.dbConnection setObject:[NSDate new] - forKey:TSAccountManager_NeedsAccountAttributesUpdateKey - inCollection:TSAccountManager_UserAccountCollection]; - - return [self updateAccountAttributesIfNecessary]; -} - -- (AnyPromise *)updateAccountAttributesIfNecessary { - if (!self.isRegistered) { - return [AnyPromise promiseWithValue:@(1)]; - } - - return [AnyPromise promiseWithValue:@(1)]; - - NSDate *_Nullable updateRequestDate = - [self.dbConnection objectForKey:TSAccountManager_NeedsAccountAttributesUpdateKey - inCollection:TSAccountManager_UserAccountCollection]; - if (!updateRequestDate) { - return [AnyPromise promiseWithValue:@(1)]; - } - AnyPromise *promise = [self performUpdateAccountAttributes]; - promise = promise.then(^(id value) { - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - // Clear the update request unless a new update has been requested - // while this update was in flight. - NSDate *_Nullable latestUpdateRequestDate = - [transaction objectForKey:TSAccountManager_NeedsAccountAttributesUpdateKey - inCollection:TSAccountManager_UserAccountCollection]; - if (latestUpdateRequestDate && [latestUpdateRequestDate isEqual:updateRequestDate]) { - [transaction removeObjectForKey:TSAccountManager_NeedsAccountAttributesUpdateKey - inCollection:TSAccountManager_UserAccountCollection]; - } - }]; - }); - return promise; -} - -- (void)reachabilityChanged { - [AppReadiness runNowOrWhenAppDidBecomeReady:^{ - [[self updateAccountAttributesIfNecessary] retainUntilComplete]; - }]; -} - -#pragma mark - Notifications - -- (void)postRegistrationStateDidChangeNotification -{ - [[NSNotificationCenter defaultCenter] postNotificationNameAsync:RegistrationStateDidChangeNotification - object:nil - userInfo:nil]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Utilities/OWSDisappearingMessagesFinder.h b/SessionMessagingKit/Utilities/OWSDisappearingMessagesFinder.h deleted file mode 100644 index 5ba154856..000000000 --- a/SessionMessagingKit/Utilities/OWSDisappearingMessagesFinder.h +++ /dev/null @@ -1,47 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@class OWSPrimaryStorage; -@class OWSStorage; -@class TSMessage; -@class TSThread; -@class YapDatabaseReadTransaction; - -@interface OWSDisappearingMessagesFinder : NSObject - -- (void)enumerateExpiredMessagesWithBlock:(void (^_Nonnull)(TSMessage *message))block - transaction:(YapDatabaseReadTransaction *)transaction; - -- (void)enumerateUnstartedExpiringMessagesInThread:(TSThread *)thread - block:(void (^_Nonnull)(TSMessage *message))block - transaction:(YapDatabaseReadTransaction *)transaction; - -- (void)enumerateMessagesWhichFailedToStartExpiringWithBlock:(void (^_Nonnull)(TSMessage *message))block - transaction:(YapDatabaseReadTransaction *)transaction; - -/** - * @return - * uint64_t millisecond timestamp wrapped in a number. Retrieve with `unsignedLongLongvalue`. - * or nil if there are no upcoming expired messages - */ -- (nullable NSNumber *)nextExpirationTimestampWithTransaction:(YapDatabaseReadTransaction *_Nonnull)transaction; - -+ (NSString *)databaseExtensionName; - -+ (void)asyncRegisterDatabaseExtensions:(OWSStorage *)storage; - -#ifdef DEBUG -/** - * Only use the sync version for testing, generally we'll want to register extensions async - */ -+ (void)blockingRegisterDatabaseExtensions:(OWSStorage *)storage; -#endif - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Utilities/OWSDisappearingMessagesFinder.m b/SessionMessagingKit/Utilities/OWSDisappearingMessagesFinder.m deleted file mode 100644 index bd58d4abc..000000000 --- a/SessionMessagingKit/Utilities/OWSDisappearingMessagesFinder.m +++ /dev/null @@ -1,241 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSDisappearingMessagesFinder.h" -#import "OWSPrimaryStorage.h" -#import "TSIncomingMessage.h" -#import "TSMessage.h" -#import "TSOutgoingMessage.h" -#import "TSThread.h" -#import -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -static NSString *const OWSDisappearingMessageFinderThreadIdColumn = @"thread_id"; -static NSString *const OWSDisappearingMessageFinderExpiresAtColumn = @"expires_at"; -static NSString *const OWSDisappearingMessageFinderExpiresAtIndex = @"index_messages_on_expires_at_and_thread_id_v2"; - -@implementation OWSDisappearingMessagesFinder - -- (NSArray *)fetchUnstartedExpiringMessageIdsInThread:(TSThread *)thread - transaction:(YapDatabaseReadTransaction *_Nonnull)transaction -{ - NSMutableArray *messageIds = [NSMutableArray new]; - NSString *formattedString = [NSString stringWithFormat:@"WHERE %@ = 0 AND %@ = \"%@\"", - OWSDisappearingMessageFinderExpiresAtColumn, - OWSDisappearingMessageFinderThreadIdColumn, - thread.uniqueId]; - - YapDatabaseQuery *query = [YapDatabaseQuery queryWithFormat:formattedString]; - [[transaction ext:OWSDisappearingMessageFinderExpiresAtIndex] - enumerateKeysMatchingQuery:query - usingBlock:^void(NSString *collection, NSString *key, BOOL *stop) { - [messageIds addObject:key]; - }]; - - return [messageIds copy]; -} - -- (NSArray *)fetchMessageIdsWhichFailedToStartExpiring:(YapDatabaseReadTransaction *_Nonnull)transaction -{ - NSMutableArray *messageIds = [NSMutableArray new]; - NSString *formattedString = - [NSString stringWithFormat:@"WHERE %@ = 0", OWSDisappearingMessageFinderExpiresAtColumn]; - - YapDatabaseQuery *query = [YapDatabaseQuery queryWithFormat:formattedString]; - [[transaction ext:OWSDisappearingMessageFinderExpiresAtIndex] - enumerateKeysAndObjectsMatchingQuery:query - usingBlock:^void(NSString *collection, NSString *key, id object, BOOL *stop) { - if (![object isKindOfClass:[TSMessage class]]) { - return; - } - - TSMessage *message = (TSMessage *)object; - if ([message shouldStartExpireTimerWithTransaction:transaction]) { - if ([message isKindOfClass:[TSIncomingMessage class]]) { - TSIncomingMessage *incomingMessage = (TSIncomingMessage *)message; - if (!incomingMessage.wasRead) { - return; - } - } - [messageIds addObject:key]; - } - }]; - - return [messageIds copy]; -} - -- (NSArray *)fetchExpiredMessageIdsWithTransaction:(YapDatabaseReadTransaction *_Nonnull)transaction -{ - NSMutableArray *messageIds = [NSMutableArray new]; - - uint64_t now = [NSDate ows_millisecondTimeStamp]; - // When (expiresAt == 0) the message SHOULD NOT expire. Careful ;) - NSString *formattedString = [NSString stringWithFormat:@"WHERE %@ > 0 AND %@ <= %lld", - OWSDisappearingMessageFinderExpiresAtColumn, - OWSDisappearingMessageFinderExpiresAtColumn, - now]; - YapDatabaseQuery *query = [YapDatabaseQuery queryWithFormat:formattedString]; - [[transaction ext:OWSDisappearingMessageFinderExpiresAtIndex] - enumerateKeysMatchingQuery:query - usingBlock:^void(NSString *collection, NSString *key, BOOL *stop) { - [messageIds addObject:key]; - }]; - - return [messageIds copy]; -} - -- (nullable NSNumber *)nextExpirationTimestampWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - NSString *formattedString = [NSString stringWithFormat:@"WHERE %@ > 0 ORDER BY %@ ASC", - OWSDisappearingMessageFinderExpiresAtColumn, - OWSDisappearingMessageFinderExpiresAtColumn]; - YapDatabaseQuery *query = [YapDatabaseQuery queryWithFormat:formattedString]; - - __block TSMessage *firstMessage; - [[transaction ext:OWSDisappearingMessageFinderExpiresAtIndex] - enumerateKeysAndObjectsMatchingQuery:query - usingBlock:^void(NSString *collection, NSString *key, id object, BOOL *stop) { - firstMessage = (TSMessage *)object; - *stop = YES; - }]; - - if (firstMessage && firstMessage.expiresAt > 0) { - return [NSNumber numberWithUnsignedLongLong:firstMessage.expiresAt]; - } - - return nil; -} - -- (void)enumerateUnstartedExpiringMessagesInThread:(TSThread *)thread - block:(void (^_Nonnull)(TSMessage *message))block - transaction:(YapDatabaseReadTransaction *)transaction -{ - for (NSString *expiringMessageId in - [self fetchUnstartedExpiringMessageIdsInThread:thread transaction:transaction]) { - TSMessage *_Nullable message = [TSMessage fetchObjectWithUniqueID:expiringMessageId transaction:transaction]; - if ([message isKindOfClass:[TSMessage class]]) { - block(message); - } - } -} - -- (void)enumerateMessagesWhichFailedToStartExpiringWithBlock:(void (^_Nonnull)(TSMessage *message))block - transaction:(YapDatabaseReadTransaction *)transaction -{ - for (NSString *expiringMessageId in [self fetchMessageIdsWhichFailedToStartExpiring:transaction]) { - - TSMessage *_Nullable message = [TSMessage fetchObjectWithUniqueID:expiringMessageId transaction:transaction]; - if (![message isKindOfClass:[TSMessage class]]) { - continue; - } - - if (![message shouldStartExpireTimerWithTransaction:transaction]) { - continue; - } - - block(message); - } -} - -/** - * Don't use this in production. Useful for testing. - * We don't want to instantiate potentially many messages at once. - */ -- (NSArray *)fetchUnstartedExpiringMessagesInThread:(TSThread *)thread - transaction:(YapDatabaseReadTransaction *)transaction -{ - NSMutableArray *messages = [NSMutableArray new]; - [self enumerateUnstartedExpiringMessagesInThread:thread - block:^(TSMessage *message) { - [messages addObject:message]; - } - transaction:transaction]; - - return [messages copy]; -} - - -- (void)enumerateExpiredMessagesWithBlock:(void (^_Nonnull)(TSMessage *message))block - transaction:(YapDatabaseReadTransaction *)transaction -{ - // Since we can't directly mutate the enumerated expired messages, we store only their ids in hopes of saving a - // little memory and then enumerate the (larger) TSMessage objects one at a time. - for (NSString *expiredMessageId in [self fetchExpiredMessageIdsWithTransaction:transaction]) { - TSMessage *_Nullable message = [TSMessage fetchObjectWithUniqueID:expiredMessageId transaction:transaction]; - if ([message isKindOfClass:[TSMessage class]]) { - block(message); - } - } -} - -/** - * Don't use this in production. Useful for testing. - * We don't want to instantiate potentially many messages at once. - */ -- (NSArray *)fetchExpiredMessagesWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - NSMutableArray *messages = [NSMutableArray new]; - [self enumerateExpiredMessagesWithBlock:^(TSMessage *message) { - [messages addObject:message]; - } - transaction:transaction]; - - return [messages copy]; -} - -#pragma mark - YapDatabaseExtension - -+ (YapDatabaseSecondaryIndex *)indexDatabaseExtension -{ - YapDatabaseSecondaryIndexSetup *setup = [YapDatabaseSecondaryIndexSetup new]; - [setup addColumn:OWSDisappearingMessageFinderExpiresAtColumn withType:YapDatabaseSecondaryIndexTypeInteger]; - [setup addColumn:OWSDisappearingMessageFinderThreadIdColumn withType:YapDatabaseSecondaryIndexTypeText]; - - YapDatabaseSecondaryIndexHandler *handler = - [YapDatabaseSecondaryIndexHandler withObjectBlock:^(YapDatabaseReadTransaction *transaction, - NSMutableDictionary *dict, - NSString *collection, - NSString *key, - id object) { - if (![object isKindOfClass:[TSMessage class]]) { - return; - } - TSMessage *message = (TSMessage *)object; - - if (![message shouldStartExpireTimerWithTransaction:transaction]) { - return; - } - - dict[OWSDisappearingMessageFinderExpiresAtColumn] = @(message.expiresAt); - dict[OWSDisappearingMessageFinderThreadIdColumn] = message.uniqueThreadId; - }]; - - return [[YapDatabaseSecondaryIndex alloc] initWithSetup:setup handler:handler versionTag:@"1"]; -} - -#ifdef DEBUG -// Useful for tests, don't use in app startup path because it's slow. -+ (void)blockingRegisterDatabaseExtensions:(OWSStorage *)storage -{ - [storage registerExtension:[self indexDatabaseExtension] withName:OWSDisappearingMessageFinderExpiresAtIndex]; -} -#endif - -+ (NSString *)databaseExtensionName -{ - return OWSDisappearingMessageFinderExpiresAtIndex; -} - -+ (void)asyncRegisterDatabaseExtensions:(OWSStorage *)storage -{ - [storage asyncRegisterExtension:[self indexDatabaseExtension] withName:OWSDisappearingMessageFinderExpiresAtIndex]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Utilities/OWSIncomingMessageFinder.h b/SessionMessagingKit/Utilities/OWSIncomingMessageFinder.h deleted file mode 100644 index 4a1fe8e41..000000000 --- a/SessionMessagingKit/Utilities/OWSIncomingMessageFinder.h +++ /dev/null @@ -1,30 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@class OWSPrimaryStorage; -@class OWSStorage; -@class YapDatabaseReadTransaction; - -@interface OWSIncomingMessageFinder : NSObject - -- (instancetype)initWithPrimaryStorage:(OWSPrimaryStorage *)primaryStorage NS_DESIGNATED_INITIALIZER; - -+ (NSString *)databaseExtensionName; -+ (void)asyncRegisterExtensionWithPrimaryStorage:(OWSStorage *)storage; - -/** - * Detects existance of a duplicate incoming message. - */ -- (BOOL)existsMessageWithTimestamp:(uint64_t)timestamp - sourceId:(NSString *)sourceId - sourceDeviceId:(uint32_t)sourceDeviceId - transaction:(YapDatabaseReadTransaction *)transaction; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Utilities/OWSIncomingMessageFinder.m b/SessionMessagingKit/Utilities/OWSIncomingMessageFinder.m deleted file mode 100644 index f51d944a8..000000000 --- a/SessionMessagingKit/Utilities/OWSIncomingMessageFinder.m +++ /dev/null @@ -1,144 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSIncomingMessageFinder.h" -#import "OWSPrimaryStorage.h" -#import "TSIncomingMessage.h" -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -NSString *const OWSIncomingMessageFinderExtensionName = @"OWSIncomingMessageFinderExtensionName"; - -NSString *const OWSIncomingMessageFinderColumnTimestamp = @"OWSIncomingMessageFinderColumnTimestamp"; -NSString *const OWSIncomingMessageFinderColumnSourceId = @"OWSIncomingMessageFinderColumnSourceId"; -NSString *const OWSIncomingMessageFinderColumnSourceDeviceId = @"OWSIncomingMessageFinderColumnSourceDeviceId"; - -@interface OWSIncomingMessageFinder () - -@property (nonatomic, readonly) OWSPrimaryStorage *primaryStorage; -@property (nonatomic, readonly) YapDatabaseConnection *dbConnection; - -@end - -@implementation OWSIncomingMessageFinder - -@synthesize dbConnection = _dbConnection; - -#pragma mark - init - -- (instancetype)init -{ - return [self initWithPrimaryStorage:[OWSPrimaryStorage sharedManager]]; -} - -- (instancetype)initWithPrimaryStorage:(OWSPrimaryStorage *)primaryStorage -{ - self = [super init]; - if (!self) { - return self; - } - - _primaryStorage = primaryStorage; - - return self; -} - -#pragma mark - properties - -- (YapDatabaseConnection *)dbConnection -{ - @synchronized(self) { - if (!_dbConnection) { - _dbConnection = [self.primaryStorage newDatabaseConnection]; - } - } - return _dbConnection; -} - -#pragma mark - YAP integration - -+ (YapDatabaseSecondaryIndex *)indexExtension -{ - YapDatabaseSecondaryIndexSetup *setup = [YapDatabaseSecondaryIndexSetup new]; - - [setup addColumn:OWSIncomingMessageFinderColumnTimestamp withType:YapDatabaseSecondaryIndexTypeInteger]; - [setup addColumn:OWSIncomingMessageFinderColumnSourceId withType:YapDatabaseSecondaryIndexTypeText]; - [setup addColumn:OWSIncomingMessageFinderColumnSourceDeviceId withType:YapDatabaseSecondaryIndexTypeInteger]; - - YapDatabaseSecondaryIndexWithObjectBlock block = ^(YapDatabaseReadTransaction *transaction, - NSMutableDictionary *dict, - NSString *collection, - NSString *key, - id object) { - if ([object isKindOfClass:[TSIncomingMessage class]]) { - TSIncomingMessage *incomingMessage = (TSIncomingMessage *)object; - - // On new messages authorId should be set on all incoming messages, but there was a time when authorId was - // only set on incoming group messages. - NSObject *authorIdOrNull = incomingMessage.authorId ? incomingMessage.authorId : [NSNull null]; - [dict setObject:@(incomingMessage.timestamp) forKey:OWSIncomingMessageFinderColumnTimestamp]; - [dict setObject:authorIdOrNull forKey:OWSIncomingMessageFinderColumnSourceId]; - [dict setObject:@(incomingMessage.sourceDeviceId) forKey:OWSIncomingMessageFinderColumnSourceDeviceId]; - } - }; - - YapDatabaseSecondaryIndexHandler *handler = [YapDatabaseSecondaryIndexHandler withObjectBlock:block]; - - return [[YapDatabaseSecondaryIndex alloc] initWithSetup:setup handler:handler versionTag:nil]; -} - -+ (NSString *)databaseExtensionName -{ - return OWSIncomingMessageFinderExtensionName; -} - -+ (void)asyncRegisterExtensionWithPrimaryStorage:(OWSStorage *)storage -{ - [storage asyncRegisterExtension:self.indexExtension withName:OWSIncomingMessageFinderExtensionName]; -} - -#ifdef DEBUG -// We should not normally hit this, as we should have prefer registering async, but it is useful for testing. -- (void)registerExtension -{ - [self.primaryStorage registerExtension:self.class.indexExtension withName:OWSIncomingMessageFinderExtensionName]; -} -#endif - -#pragma mark - instance methods - -- (BOOL)existsMessageWithTimestamp:(uint64_t)timestamp - sourceId:(NSString *)sourceId - sourceDeviceId:(uint32_t)sourceDeviceId - transaction:(YapDatabaseReadTransaction *)transaction -{ -#ifdef DEBUG - if (![self.primaryStorage registeredExtension:OWSIncomingMessageFinderExtensionName]) { - - // we should be initializing this at startup rather than have an unexpectedly slow lazy setup at random. - [self registerExtension]; - } -#endif - - NSString *queryFormat = [NSString stringWithFormat:@"WHERE %@ = ? AND %@ = ? AND %@ = ?", - OWSIncomingMessageFinderColumnTimestamp, - OWSIncomingMessageFinderColumnSourceId, - OWSIncomingMessageFinderColumnSourceDeviceId]; - // YapDatabaseQuery params must be objects - YapDatabaseQuery *query = [YapDatabaseQuery queryWithFormat:queryFormat, @(timestamp), sourceId, @(sourceDeviceId)]; - - NSUInteger count; - BOOL success = [[transaction ext:OWSIncomingMessageFinderExtensionName] getNumberOfRows:&count matchingQuery:query]; - if (!success) { - return NO; - } - - return count > 0; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Utilities/OWSPreferences.h b/SessionMessagingKit/Utilities/OWSPreferences.h index 48a95067b..895d13d75 100644 --- a/SessionMessagingKit/Utilities/OWSPreferences.h +++ b/SessionMessagingKit/Utilities/OWSPreferences.h @@ -3,7 +3,6 @@ // #import -#import #import #import #import diff --git a/SessionMessagingKit/Utilities/SSKEnvironment.h b/SessionMessagingKit/Utilities/SSKEnvironment.h deleted file mode 100644 index 63751c3d5..000000000 --- a/SessionMessagingKit/Utilities/SSKEnvironment.h +++ /dev/null @@ -1,80 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@class ContactDiscoveryService; -@class ContactsUpdater; -@class OWS2FAManager; -@class OWSAttachmentDownloads; -@class OWSBatchMessageProcessor; -@class OWSDisappearingMessagesJob; -@class OWSIdentityManager; -@class OWSMessageDecrypter; -@class OWSMessageManager; -@class OWSMessageReceiver; -@class OWSMessageSender; -@class OWSOutgoingReceiptManager; -@class OWSPrimaryStorage; -@class OWSReadReceiptManager; -@class SSKMessageSenderJobQueue; -@class TSAccountManager; -@class TSSocketManager; -@class YapDatabaseConnection; - -@protocol ContactsManagerProtocol; -@protocol NotificationsProtocol; -@protocol OWSCallMessageHandler; -@protocol ProfileManagerProtocol; -@protocol OWSUDManager; -@protocol SSKReachabilityManager; -@protocol OWSSyncManagerProtocol; -@protocol OWSTypingIndicators; - -@interface SSKEnvironment : NSObject - -- (instancetype)initWithPrimaryStorage:(OWSPrimaryStorage *)primaryStorage - tsAccountManager:(TSAccountManager *)tsAccountManager - disappearingMessagesJob:(OWSDisappearingMessagesJob *)disappearingMessagesJob - readReceiptManager:(OWSReadReceiptManager *)readReceiptManager - outgoingReceiptManager:(OWSOutgoingReceiptManager *)outgoingReceiptManager - reachabilityManager:(id)reachabilityManager - typingIndicators:(id)typingIndicators NS_DESIGNATED_INITIALIZER; - -- (instancetype)init NS_UNAVAILABLE; - -@property (nonatomic, readonly, class) SSKEnvironment *shared; - -+ (void)setShared:(SSKEnvironment *)env; - -#ifdef DEBUG -// Should only be called by tests. -+ (void)clearSharedForTests; -#endif - -@property (nonatomic, readonly) id profileManager; -@property (nonatomic, readonly) OWSPrimaryStorage *primaryStorage; -@property (nonatomic, readonly) OWSIdentityManager *identityManager; -@property (nonatomic, readonly) TSAccountManager *tsAccountManager; -@property (nonatomic, readonly) OWSDisappearingMessagesJob *disappearingMessagesJob; -@property (nonatomic, readonly) OWSReadReceiptManager *readReceiptManager; -@property (nonatomic, readonly) OWSOutgoingReceiptManager *outgoingReceiptManager; -@property (nonatomic, readonly) id reachabilityManager; -@property (nonatomic, readonly) id typingIndicators; - -// This property is configured after Environment is created. -@property (atomic, nullable) id notificationsManager; - -@property (atomic, readonly) YapDatabaseConnection *objectReadWriteConnection; -@property (atomic, readonly) YapDatabaseConnection *sessionStoreDBConnection; -@property (atomic, readonly) YapDatabaseConnection *migrationDBConnection; -@property (atomic, readonly) YapDatabaseConnection *analyticsDBConnection; - -- (BOOL)isComplete; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Utilities/SSKEnvironment.m b/SessionMessagingKit/Utilities/SSKEnvironment.m deleted file mode 100644 index 75f0ead69..000000000 --- a/SessionMessagingKit/Utilities/SSKEnvironment.m +++ /dev/null @@ -1,135 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "SSKEnvironment.h" -#import "AppContext.h" -#import "OWSPrimaryStorage.h" - -NS_ASSUME_NONNULL_BEGIN - -static SSKEnvironment *sharedSSKEnvironment; - -@interface SSKEnvironment () - -@property (nonatomic) OWSPrimaryStorage *primaryStorage; -@property (nonatomic) TSAccountManager *tsAccountManager; -@property (nonatomic) OWSDisappearingMessagesJob *disappearingMessagesJob; -@property (nonatomic) OWSReadReceiptManager *readReceiptManager; -@property (nonatomic) OWSOutgoingReceiptManager *outgoingReceiptManager; -@property (nonatomic) id reachabilityManager; -@property (nonatomic) id typingIndicators; - -@end - -#pragma mark - - -@implementation SSKEnvironment - -@synthesize notificationsManager = _notificationsManager; -@synthesize objectReadWriteConnection = _objectReadWriteConnection; -@synthesize sessionStoreDBConnection = _sessionStoreDBConnection; -@synthesize migrationDBConnection = _migrationDBConnection; -@synthesize analyticsDBConnection = _analyticsDBConnection; - -- (instancetype)initWithPrimaryStorage:(OWSPrimaryStorage *)primaryStorage - tsAccountManager:(TSAccountManager *)tsAccountManager - disappearingMessagesJob:(OWSDisappearingMessagesJob *)disappearingMessagesJob - readReceiptManager:(OWSReadReceiptManager *)readReceiptManager - outgoingReceiptManager:(OWSOutgoingReceiptManager *)outgoingReceiptManager - reachabilityManager:(id)reachabilityManager - typingIndicators:(id)typingIndicators -{ - self = [super init]; - - if (!self) { - return self; - } - - _primaryStorage = primaryStorage; - _tsAccountManager = tsAccountManager; - _disappearingMessagesJob = disappearingMessagesJob; - _readReceiptManager = readReceiptManager; - _outgoingReceiptManager = outgoingReceiptManager; - _reachabilityManager = reachabilityManager; - _typingIndicators = typingIndicators; - - return self; -} - -+ (instancetype)shared -{ - return sharedSSKEnvironment; -} - -+ (void)setShared:(SSKEnvironment *)env -{ - sharedSSKEnvironment = env; -} - -+ (void)clearSharedForTests -{ - sharedSSKEnvironment = nil; -} - -#pragma mark - Mutable Accessors - -- (nullable id)notificationsManager -{ - @synchronized(self) { - return _notificationsManager; - } -} - -- (void)setNotificationsManager:(nullable id)notificationsManager -{ - @synchronized(self) { - _notificationsManager = notificationsManager; - } -} - -- (BOOL)isComplete -{ - return self.notificationsManager != nil; -} - -- (YapDatabaseConnection *)objectReadWriteConnection -{ - @synchronized(self) { - if (!_objectReadWriteConnection) { - _objectReadWriteConnection = self.primaryStorage.newDatabaseConnection; - } - return _objectReadWriteConnection; - } -} - -- (YapDatabaseConnection *)sessionStoreDBConnection { - @synchronized(self) { - if (!_sessionStoreDBConnection) { - _sessionStoreDBConnection = self.primaryStorage.newDatabaseConnection; - } - return _sessionStoreDBConnection; - } -} - -- (YapDatabaseConnection *)migrationDBConnection { - @synchronized(self) { - if (!_migrationDBConnection) { - _migrationDBConnection = self.primaryStorage.newDatabaseConnection; - } - return _migrationDBConnection; - } -} - -- (YapDatabaseConnection *)analyticsDBConnection { - @synchronized(self) { - if (!_analyticsDBConnection) { - _analyticsDBConnection = self.primaryStorage.newDatabaseConnection; - } - return _analyticsDBConnection; - } -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Utilities/SSKEnvironment.swift b/SessionMessagingKit/Utilities/SSKEnvironment.swift index 89a0912da..9338ccab1 100644 --- a/SessionMessagingKit/Utilities/SSKEnvironment.swift +++ b/SessionMessagingKit/Utilities/SSKEnvironment.swift @@ -6,7 +6,6 @@ import SessionUtilitiesKit @objc public class SSKEnvironment: NSObject { @objc public let primaryStorage: OWSPrimaryStorage - public let tsAccountManager: TSAccountManager public let reachabilityManager: SSKReachabilityManager // Note: This property is configured after Environment is created. @@ -27,11 +26,9 @@ public class SSKEnvironment: NSObject { @objc public init( primaryStorage: OWSPrimaryStorage, - tsAccountManager: TSAccountManager, reachabilityManager: SSKReachabilityManager ) { self.primaryStorage = primaryStorage - self.tsAccountManager = tsAccountManager self.reachabilityManager = reachabilityManager self.objectReadWriteConnection = primaryStorage.newDatabaseConnection() diff --git a/SessionMessagingKit/Utilities/ThreadUpdateBatcher.swift b/SessionMessagingKit/Utilities/ThreadUpdateBatcher.swift deleted file mode 100644 index 3eebcbda9..000000000 --- a/SessionMessagingKit/Utilities/ThreadUpdateBatcher.swift +++ /dev/null @@ -1,31 +0,0 @@ - -final class ThreadUpdateBatcher { - private var threadIDs: Set = [] - - private lazy var timer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in self?.touch() } - - static let shared = ThreadUpdateBatcher() - - private init() { - DispatchQueue.main.async { - SessionUtilitiesKit.touch(self.timer) - } - } - - deinit { timer.invalidate() } - - func touch(_ threadID: String) { - threadIDs.insert(threadID) - } - - @objc private func touch() { - let threadIDs = self.threadIDs - self.threadIDs.removeAll() - Storage.write { transaction in - for threadID in threadIDs { - guard let thread = TSThread.fetch(uniqueId: threadID, transaction: transaction) else { return } - thread.touch(with: transaction) - } - } - } -} diff --git a/SessionShareExtension/Meta/SignalShareExtension-Bridging-Header.h b/SessionShareExtension/Meta/SignalShareExtension-Bridging-Header.h index 29c93b1b9..282ed4347 100644 --- a/SessionShareExtension/Meta/SignalShareExtension-Bridging-Header.h +++ b/SessionShareExtension/Meta/SignalShareExtension-Bridging-Header.h @@ -19,4 +19,3 @@ #import #import #import -#import diff --git a/SessionUtilitiesKit/Database/Models/Identity.swift b/SessionUtilitiesKit/Database/Models/Identity.swift index 577f55bfc..2bd3cc2ea 100644 --- a/SessionUtilitiesKit/Database/Models/Identity.swift +++ b/SessionUtilitiesKit/Database/Models/Identity.swift @@ -128,3 +128,28 @@ public extension Identity { } } } + +// MARK: - Convenience + +public extension Notification.Name { + static let registrationStateDidChange = Notification.Name("registrationStateDidChange") +} + +public extension Identity { + static func didRegister() { + NotificationCenter.default.post(name: .registrationStateDidChange, object: nil, userInfo: nil) + } +} + +// MARK: - Objective-C Support + +// TODO: Remove this when possible +@objc(SUKIdentity) +public class SUKIdentity: NSObject { + @objc(userExists) + public static func userExists() -> Bool { + return GRDBStorage.shared + .read { db in Identity.userExists(db) } + .defaulting(to: false) + } +} diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift index 027a6f877..d478a0165 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift @@ -8,8 +8,8 @@ import MediaPlayer import PromiseKit import SessionUIKit import CoreServices +import SessionMessagingKit -@objc public protocol AttachmentApprovalViewControllerDelegate: AnyObject { func attachmentApproval( _ attachmentApproval: AttachmentApprovalViewController, @@ -25,14 +25,12 @@ public protocol AttachmentApprovalViewControllerDelegate: AnyObject { didChangeMessageText newMessageText: String? ) - @objc - optional func attachmentApproval( + func attachmentApproval( _ attachmentApproval: AttachmentApprovalViewController, didRemoveAttachment attachment: SignalAttachment ) - @objc - optional func attachmentApprovalDidTapAddMore(_ attachmentApproval: AttachmentApprovalViewController) + func attachmentApprovalDidTapAddMore(_ attachmentApproval: AttachmentApprovalViewController) } // MARK: - @@ -45,9 +43,8 @@ public enum AttachmentApprovalViewControllerMode: UInt { // MARK: - -@objc public class AttachmentApprovalViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate { - @objc public enum Mode: UInt { + public enum Mode: UInt { case modal case sharedNavigation } @@ -122,7 +119,6 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC notImplemented() } - @objc required public init( mode: Mode, threadId: String, @@ -248,7 +244,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC // If the first item is just text, or is a URL and LinkPreviews are disabled // then just fill the 'message' box with it - if firstItem.attachment.isText || (firstItem.attachment.isUrl && OWSLinkPreview.previewURL(forRawBodyText: firstItem.attachment.text()) == nil) { + if firstItem.attachment.isText || (firstItem.attachment.isUrl && LinkPreview.previewUrl(for: firstItem.attachment.text()) == nil) { bottomToolView.attachmentTextToolbar.messageText = firstItem.attachment.text() } @@ -607,9 +603,13 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC return GalleryRailCellView() } } - + galleryRailView.configureCellViews( - itemProvider: attachmentItemCollection, + album: (attachmentItemCollection.attachmentItems as [GalleryRailItem]) + .appending(attachmentItemCollection.isAddMoreVisible ? + AddMoreRailItem() : + nil + ), focusedItem: currentItem, cellViewBuilder: cellViewBuilder ) @@ -797,17 +797,11 @@ extension SignalAttachmentItem: GalleryRailItem { return imageView } -} - -// MARK: - - -extension AttachmentItemCollection: GalleryRailItemProvider { - var railItems: [GalleryRailItem] { - if isAddMoreVisible { - return self.attachmentItems + [AddMoreRailItem()] - } else { - return self.attachmentItems - } + + func isEqual(to other: GalleryRailItem?) -> Bool { + guard let otherAttachmentItem: SignalAttachmentItem = other as? SignalAttachmentItem else { return false } + + return (self.attachment == otherAttachmentItem.attachment) } } @@ -816,7 +810,7 @@ extension AttachmentItemCollection: GalleryRailItemProvider { extension AttachmentApprovalViewController: GalleryRailViewDelegate { public func galleryRailView(_ galleryRailView: GalleryRailView, didTapItem imageRailItem: GalleryRailItem) { if imageRailItem is AddMoreRailItem { - self.approvalDelegate?.attachmentApprovalDidTapAddMore?(self) + self.approvalDelegate?.attachmentApprovalDidTapAddMore(self) return } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift index 3a3370820..dcb63474f 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift @@ -4,6 +4,7 @@ import Foundation import PromiseKit +import SessionMessagingKit class AddMoreRailItem: GalleryRailItem { func buildRailItemView() -> UIView { diff --git a/SignalUtilitiesKit/Media Viewing & Editing/AttachmentSharing.m b/SignalUtilitiesKit/Media Viewing & Editing/AttachmentSharing.m index 770342426..5df014e9b 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/AttachmentSharing.m +++ b/SignalUtilitiesKit/Media Viewing & Editing/AttachmentSharing.m @@ -5,7 +5,6 @@ #import "AttachmentSharing.h" #import "UIUtil.h" #import -#import NS_ASSUME_NONNULL_BEGIN diff --git a/SignalUtilitiesKit/Utilities/AppSetup.m b/SignalUtilitiesKit/Utilities/AppSetup.m index 129400905..8c2e33da3 100644 --- a/SignalUtilitiesKit/Utilities/AppSetup.m +++ b/SignalUtilitiesKit/Utilities/AppSetup.m @@ -8,7 +8,6 @@ #import #import #import -#import #import NS_ASSUME_NONNULL_BEGIN @@ -44,7 +43,6 @@ NS_ASSUME_NONNULL_BEGIN OWSPreferences *preferences = [OWSPreferences new]; - TSAccountManager *tsAccountManager = [[TSAccountManager alloc] initWithPrimaryStorage:primaryStorage]; id reachabilityManager = [SSKReachabilityManagerImpl new]; OWSAudioSession *audioSession = [OWSAudioSession new]; @@ -59,15 +57,7 @@ NS_ASSUME_NONNULL_BEGIN // TODO: Add this back // TODO: Refactor this file to Swift [SSKEnvironment setShared:[[SSKEnvironment alloc] initWithPrimaryStorage:primaryStorage - tsAccountManager:tsAccountManager reachabilityManager:reachabilityManager]]; -// [SSKEnvironment setShared:[[SSKEnvironment alloc] initWithPrimaryStorage:primaryStorage -// tsAccountManager:tsAccountManager -// disappearingMessagesJob:disappearingMessagesJob -// readReceiptManager:readReceiptManager -// outgoingReceiptManager:outgoingReceiptManager -// reachabilityManager:reachabilityManager -// typingIndicators:typingIndicators]]; appSpecificSingletonBlock(); @@ -77,7 +67,6 @@ NS_ASSUME_NONNULL_BEGIN [SNConfiguration performMainSetup]; // Must happen before the performUpdateCheck call below // Register renamed classes. - [NSKeyedUnarchiver setClass:[OWSUserProfile class] forClassName:[OWSUserProfile collection]]; [NSKeyedUnarchiver setClass:[OWSDatabaseMigration class] forClassName:[OWSDatabaseMigration collection]]; [OWSStorage registerExtensionsWithMigrationBlock:^() { diff --git a/SignalUtilitiesKit/Utilities/Notification+Loki.swift b/SignalUtilitiesKit/Utilities/Notification+Loki.swift index 705ed8b87..6383bc077 100644 --- a/SignalUtilitiesKit/Utilities/Notification+Loki.swift +++ b/SignalUtilitiesKit/Utilities/Notification+Loki.swift @@ -1,9 +1,8 @@ -import UIKit +import Foundation public extension Notification.Name { // State changes - static let registrationStateDidChange = Notification.Name("registrationStateDidChange") static let blockedContactsUpdated = Notification.Name("blockedContactsUpdated") static let contactOnlineStatusChanged = Notification.Name("contactOnlineStatusChanged") static let threadDeleted = Notification.Name("threadDeleted") @@ -17,7 +16,6 @@ public extension Notification.Name { @objc public extension NSNotification { // State changes - @objc static let registrationStateDidChange = Notification.Name.registrationStateDidChange.rawValue as NSString @objc static let blockedContactsUpdated = Notification.Name.blockedContactsUpdated.rawValue as NSString @objc static let contactOnlineStatusChanged = Notification.Name.contactOnlineStatusChanged.rawValue as NSString @objc static let threadDeleted = Notification.Name.threadDeleted.rawValue as NSString diff --git a/SignalUtilitiesKit/Utilities/VersionMigrations.m b/SignalUtilitiesKit/Utilities/VersionMigrations.m index 6aca6986b..f7d7c0a26 100644 --- a/SignalUtilitiesKit/Utilities/VersionMigrations.m +++ b/SignalUtilitiesKit/Utilities/VersionMigrations.m @@ -7,7 +7,6 @@ #import #import #import -#import #import #import @@ -18,15 +17,6 @@ NS_ASSUME_NONNULL_BEGIN @implementation VersionMigrations -#pragma mark - Dependencies - -+ (TSAccountManager *)tsAccountManager -{ - OWSAssertDebug(SSKEnvironment.shared.tsAccountManager); - - return SSKEnvironment.shared.tsAccountManager; -} - #pragma mark - Utility methods + (void)performUpdateCheckWithCompletion:(VersionMigrationCompletion)completion @@ -56,12 +46,12 @@ NS_ASSUME_NONNULL_BEGIN }); return; } - - if ([self isVersion:previousVersion atLeast:@"2.0.0" andLessThan:@"2.1.70"] && [self.tsAccountManager isRegistered]) { + + if ([self isVersion:previousVersion atLeast:@"2.0.0" andLessThan:@"2.1.70"] && [SUKIdentity userExists]) { [self clearVideoCache]; } - if ([self isVersion:previousVersion atLeast:@"2.0.0" andLessThan:@"2.3.0"] && [self.tsAccountManager isRegistered]) { + if ([self isVersion:previousVersion atLeast:@"2.0.0" andLessThan:@"2.3.0"] && [SUKIdentity userExists]) { [self clearBloomFilterCache]; } From c500d4c6cadfb238f4850e6b94d10ae3bca7fe8c Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 23 May 2022 17:16:14 +1000 Subject: [PATCH 085/157] Fixed a few bugs, resolved a number of TODOs and deleted more unused code Fixed a couple of bugs with search term highlighting (updated the logic to make the highlighted content follow similar logic to what terms would have actually matched) Fixed a bug where info messages in search results weren't rendering correctly Shifted some duplicate query code for global search into variables Fixed a small bug where sending attachments could incorrectly result in the mentions UI being visible Fixed a bug where quote content was appearing incorrectly Consolidated the ShareExtension Item and the ConversationCell.ViewModel into one type (with a more-limited query) to remove duplicate code Added back a missing asset (deleted a long time ago) --- Podfile | 1 + Podfile.lock | 2 +- Session.xcodeproj/project.pbxproj | 84 +-- .../ConversationVC+Interaction.swift | 13 +- Session/Conversations/ConversationVC.swift | 4 +- .../Content Views/LinkPreviewState.swift | 4 +- .../Content Views/MediaView.swift | 8 +- .../DownloadAttachmentModal.swift | 2 +- .../GlobalSearchViewController.swift | 6 +- Session/Home/HomeVC.swift | 11 +- .../MessageRequestsViewController.swift | 11 +- Session/Home/Views/MessageRequestsCell.swift | 6 +- .../MediaPageViewController.swift | 16 +- .../x-24.imageset/Contents.json | 23 + .../Images.xcassets/x-24.imageset/x-24.png | Bin 0 -> 875 bytes .../Images.xcassets/x-24.imageset/x-24@2x.png | Bin 0 -> 1195 bytes .../Images.xcassets/x-24.imageset/x-24@3x.png | Bin 0 -> 573 bytes Session/Meta/Signal-Bridging-Header.h | 1 - .../Translations/de.lproj/Localizable.strings | 6 +- .../Translations/en.lproj/Localizable.strings | 8 +- .../Translations/es.lproj/Localizable.strings | 2 +- .../Translations/fa.lproj/Localizable.strings | 4 +- .../Translations/fi.lproj/Localizable.strings | 8 +- .../Translations/fr.lproj/Localizable.strings | 4 +- .../Translations/hi.lproj/Localizable.strings | 8 +- .../Translations/hr.lproj/Localizable.strings | 8 +- .../id-ID.lproj/Localizable.strings | 4 +- .../Translations/it.lproj/Localizable.strings | 4 +- .../Translations/ja.lproj/Localizable.strings | 4 +- .../Translations/nl.lproj/Localizable.strings | 8 +- .../Translations/pl.lproj/Localizable.strings | 4 +- .../pt_BR.lproj/Localizable.strings | 4 +- .../Translations/ru.lproj/Localizable.strings | 4 +- .../Translations/si.lproj/Localizable.strings | 8 +- .../Translations/sk.lproj/Localizable.strings | 8 +- .../Translations/sv.lproj/Localizable.strings | 8 +- .../Translations/th.lproj/Localizable.strings | 8 +- .../vi-VN.lproj/Localizable.strings | 8 +- .../zh-Hant.lproj/Localizable.strings | 8 +- Session/Settings/ShareLogsModal.swift | 11 +- Session/Shared/ConversationCell.swift | 553 ----------------- Session/Shared/FullConversationCell.swift | 570 ++++++++++++++++++ Session/Utilities/BackgroundPoller.swift | 20 +- Session/Utilities/ContactUtilities.swift | 36 -- .../MessageRecipientStatusUtils.swift | 169 ------ .../Database/LegacyDatabase/SMKLegacy.swift | 1 + .../Migrations/_003_YDBToGRDBMigration.swift | 27 +- .../Database/Models/Attachment.swift | 34 +- .../Database/Models/Interaction.swift | 77 +-- .../Database/Models/Profile.swift | 8 +- .../Database/Models/Quote.swift | 18 +- .../Database/Storage+ClosedGroups.swift | 117 ---- .../Database/Storage+Jobs.swift | 117 ---- .../Database/Storage+Messaging.swift | 115 ---- .../Jobs/Types/AttachmentDownloadJob.swift | 8 +- .../Types/FailedAttachmentDownloadsJob.swift | 2 +- .../Jobs/Types/GarbageCollectionJob.swift | 1 + .../Jobs/Types/MessageSendJob.swift | 4 +- .../Attachments/OWSThumbnailService.swift | 177 ------ .../Attachments/ThumbnailService.swift | 174 ++++++ .../MessageReceiver+Handling.swift | 2 +- .../MessageSender+Convenience.swift | 2 +- .../Notifications/PushNotificationAPI.swift | 37 +- .../ConversationCellViewModel.swift | 408 +++++++------ .../Utilities/ProfileManager.swift | 2 +- .../Utilities/ProofOfWork.swift | 36 -- SessionShareExtension/ShareVC.swift | 8 +- .../SimplifiedConversationCell.swift | 18 +- SessionShareExtension/ThreadPickerVC.swift | 20 +- .../ThreadPickerViewModel.swift | 175 +----- SessionSnodeKit/OnionRequestAPI.swift | 2 +- .../General/NSDate+Timestamp.h | 11 - .../General/NSDate+Timestamp.mm | 16 - .../General/String+Localized.swift | 13 - .../General/String+Utilities.swift | 31 + SessionUtilitiesKit/Media/DataSource.h | 2 + SessionUtilitiesKit/Media/DataSource.m | 10 + SessionUtilitiesKit/Media/MIMETypeUtil.h | 2 +- SessionUtilitiesKit/Media/MIMETypeUtil.m | 2 +- .../Meta/SessionUtilitiesKit.h | 1 - ...AttachmentApprovalInputAccessoryView.swift | 10 +- .../AttachmentApprovalViewController.swift | 29 +- .../AttachmentSharing.h | 33 - .../AttachmentSharing.m | 119 ---- .../Messaging/ThreadViewModel.swift | 37 -- SignalUtilitiesKit/Meta/SignalUtilitiesKit.h | 1 - .../Shared Views/ApprovalRailCellView.swift | 2 +- 87 files changed, 1342 insertions(+), 2246 deletions(-) create mode 100644 Session/Meta/Images.xcassets/x-24.imageset/Contents.json create mode 100644 Session/Meta/Images.xcassets/x-24.imageset/x-24.png create mode 100644 Session/Meta/Images.xcassets/x-24.imageset/x-24@2x.png create mode 100644 Session/Meta/Images.xcassets/x-24.imageset/x-24@3x.png delete mode 100644 Session/Shared/ConversationCell.swift create mode 100644 Session/Shared/FullConversationCell.swift delete mode 100644 Session/Utilities/ContactUtilities.swift delete mode 100644 Session/Utilities/MessageRecipientStatusUtils.swift delete mode 100644 SessionMessagingKit/Database/Storage+ClosedGroups.swift delete mode 100644 SessionMessagingKit/Database/Storage+Jobs.swift delete mode 100644 SessionMessagingKit/Database/Storage+Messaging.swift delete mode 100644 SessionMessagingKit/Sending & Receiving/Attachments/OWSThumbnailService.swift create mode 100644 SessionMessagingKit/Sending & Receiving/Attachments/ThumbnailService.swift rename {Session/Shared/Models => SessionMessagingKit/Shared Models}/ConversationCellViewModel.swift (88%) delete mode 100644 SessionMessagingKit/Utilities/ProofOfWork.swift delete mode 100644 SessionUtilitiesKit/General/NSDate+Timestamp.h delete mode 100644 SessionUtilitiesKit/General/NSDate+Timestamp.mm delete mode 100644 SessionUtilitiesKit/General/String+Localized.swift create mode 100644 SessionUtilitiesKit/General/String+Utilities.swift delete mode 100644 SignalUtilitiesKit/Media Viewing & Editing/AttachmentSharing.h delete mode 100644 SignalUtilitiesKit/Media Viewing & Editing/AttachmentSharing.m delete mode 100644 SignalUtilitiesKit/Messaging/ThreadViewModel.swift diff --git a/Podfile b/Podfile index 8e9c238cc..cfd179fd7 100644 --- a/Podfile +++ b/Podfile @@ -58,6 +58,7 @@ abstract_target 'GlobalDependencies' do pod 'Reachability' pod 'SAMKeychain' pod 'SwiftProtobuf', '~> 1.5.0' + pod 'DifferenceKit' end target 'SessionUtilitiesKit' do diff --git a/Podfile.lock b/Podfile.lock index aa728eb01..f4f18dfca 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -219,6 +219,6 @@ SPEC CHECKSUMS: YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331 ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: bd0e75b0b6e37b30d8414efed2a5a98635e1a1a6 +PODFILE CHECKSUM: 9715c163fab54d487be0c32357d6d1729aa96a7b COCOAPODS: 1.11.2 diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 718cb0e62..961263e2e 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -45,7 +45,6 @@ 34D1F0501F7D45A60066283D /* GifPickerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D1F04F1F7D45A60066283D /* GifPickerCell.swift */; }; 34D1F0521F7E8EA30066283D /* GiphyDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D1F0511F7E8EA30066283D /* GiphyDownloader.swift */; }; 34D1F0871F8678AA0066283D /* ConversationViewItem.m in Sources */ = {isa = PBXBuildFile; fileRef = 34D1F0701F8678AA0066283D /* ConversationViewItem.m */; }; - 34D1F0C01F8EC1760066283D /* MessageRecipientStatusUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D1F0BF1F8EC1760066283D /* MessageRecipientStatusUtils.swift */; }; 34D2CCDA2062E7D000CB1A14 /* OWSScreenLockUI.m in Sources */ = {isa = PBXBuildFile; fileRef = 34D2CCD92062E7D000CB1A14 /* OWSScreenLockUI.m */; }; 34D5CCA91EAE3D30005515DB /* AvatarViewHelper.m in Sources */ = {isa = PBXBuildFile; fileRef = 34D5CCA81EAE3D30005515DB /* AvatarViewHelper.m */; }; 34D99CE4217509C2000AFB39 /* AppEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D99CE3217509C1000AFB39 /* AppEnvironment.swift */; }; @@ -257,13 +256,10 @@ C300A5F22554B09800555489 /* MessageSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5F12554B09800555489 /* MessageSender.swift */; }; C300A5FC2554B0A000555489 /* MessageReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5FB2554B0A000555489 /* MessageReceiver.swift */; }; C300A60D2554B31900555489 /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5CE2553860700C340D1 /* Logging.swift */; }; - C300A6322554B6D100555489 /* NSDate+Timestamp.mm in Sources */ = {isa = PBXBuildFile; fileRef = C300A6312554B6D100555489 /* NSDate+Timestamp.mm */; }; - C300A63B2554B72200555489 /* NSDate+Timestamp.h in Headers */ = {isa = PBXBuildFile; fileRef = C300A6302554B68200555489 /* NSDate+Timestamp.h */; settings = {ATTRIBUTES = (Public, ); }; }; C302093E25DCBF08001F572D /* MentionSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C302093D25DCBF07001F572D /* MentionSelectionView.swift */; }; C31A6C5A247F214E001123EF /* UIView+Glow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31A6C59247F214E001123EF /* UIView+Glow.swift */; }; C31A6C5C247F2CF3001123EF /* CGRect+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31A6C5B247F2CF3001123EF /* CGRect+Utilities.swift */; }; C31D1DE32521718E005D4DA8 /* UserSelectionVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31D1DE22521718E005D4DA8 /* UserSelectionVC.swift */; }; - C31D1DE9252172D4005D4DA8 /* ContactUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31D1DE8252172D4005D4DA8 /* ContactUtilities.swift */; }; C3227FF6260AAD66006EA627 /* OpenGroupMessageV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3227FF5260AAD66006EA627 /* OpenGroupMessageV2.swift */; }; C32824D325C9F9790062D0A7 /* SessionSnodeKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A59F255385C100C340D1 /* SessionSnodeKit.framework */; }; C328250F25CA06020062D0A7 /* VoiceMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C328250E25CA06020062D0A7 /* VoiceMessageView.swift */; }; @@ -286,12 +282,9 @@ C32C5A88256DBCF9003C73A2 /* MessageReceiver+Handling.swift in Sources */ = {isa = PBXBuildFile; fileRef = C32C5A87256DBCF9003C73A2 /* MessageReceiver+Handling.swift */; }; C32C5B48256DC211003C73A2 /* NSNotificationCenter+OWS.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB6C255A580F00E217F9 /* NSNotificationCenter+OWS.m */; }; C32C5B51256DC219003C73A2 /* NSNotificationCenter+OWS.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB3B255A580B00E217F9 /* NSNotificationCenter+OWS.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C32C5BCC256DC830003C73A2 /* Storage+ClosedGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8D8F1372566120F0092EF10 /* Storage+ClosedGroups.swift */; }; C32C5C3D256DCBAF003C73A2 /* AppReadiness.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB75255A581000E217F9 /* AppReadiness.m */; }; C32C5C46256DCBB2003C73A2 /* AppReadiness.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB01255A580700E217F9 /* AppReadiness.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5C4F256DCC36003C73A2 /* Storage+OpenGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8D8F18825661BA50092EF10 /* Storage+OpenGroups.swift */; }; - C32C5C88256DD0D2003C73A2 /* Storage+Messaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8D8F19225661BF80092EF10 /* Storage+Messaging.swift */; }; - C32C5C89256DD0D2003C73A2 /* Storage+Jobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8D8F17625661AFA0092EF10 /* Storage+Jobs.swift */; }; C32C5CF0256DD3E4003C73A2 /* Storage+Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3F0A5FD255C988A007BE2A3 /* Storage+Shared.swift */; }; C32C5D19256DD493003C73A2 /* LinkPreviewDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBA8255A581500E217F9 /* LinkPreviewDraft.swift */; }; C32C5D83256DD5B6003C73A2 /* SSKKeychainStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBBC255A581600E217F9 /* SSKKeychainStorage.swift */; }; @@ -341,7 +334,7 @@ C331FFE92558FB0000070591 /* Separator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8BB82B82394911B00BA5194 /* Separator.swift */; }; C331FFF32558FF0300070591 /* PathStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B879D44A247E1D9200DB3608 /* PathStatusView.swift */; }; C331FFF42558FF0300070591 /* PNOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C353F8F8244809150011121A /* PNOptionView.swift */; }; - C331FFFE2558FF3B00070591 /* ConversationCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8BB82AA238F669C00BA5194 /* ConversationCell.swift */; }; + C331FFFE2558FF3B00070591 /* FullConversationCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8BB82AA238F669C00BA5194 /* FullConversationCell.swift */; }; C33FD4E9255A149100E217F9 /* Colors.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C39DD28724F3318C008590FC /* Colors.xcassets */; }; C33FD9AF255A548A00E217F9 /* SignalUtilitiesKit.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FD9AD255A548A00E217F9 /* SignalUtilitiesKit.h */; settings = {ATTRIBUTES = (Public, ); }; }; C33FD9B3255A548A00E217F9 /* SignalUtilitiesKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = C33FD9AB255A548A00E217F9 /* SignalUtilitiesKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -415,8 +408,6 @@ C37F54DC255BB84A002AEA92 /* SessionSnodeKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A59F255385C100C340D1 /* SessionSnodeKit.framework */; }; C38D5E8D2575011E00B6A65C /* MessageSender+ClosedGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38D5E8C2575011E00B6A65C /* MessageSender+ClosedGroups.swift */; }; C38EF00C255B61CC007E1867 /* SignalUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C33FD9AB255A548A00E217F9 /* SignalUtilitiesKit.framework */; }; - C38EF228255B6D5D007E1867 /* AttachmentSharing.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF223255B6D5D007E1867 /* AttachmentSharing.m */; }; - C38EF22A255B6D5D007E1867 /* AttachmentSharing.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF225255B6D5D007E1867 /* AttachmentSharing.h */; settings = {ATTRIBUTES = (Public, ); }; }; C38EF22B255B6D5D007E1867 /* ShareViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF226255B6D5D007E1867 /* ShareViewDelegate.swift */; }; C38EF22C255B6D5D007E1867 /* OWSVideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF227255B6D5D007E1867 /* OWSVideoPlayer.swift */; }; C38EF243255B6D67007E1867 /* UIViewController+OWS.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF236255B6D65007E1867 /* UIViewController+OWS.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -475,7 +466,6 @@ C38EF38B255B6DD2007E1867 /* AttachmentPrepViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF382255B6DD1007E1867 /* AttachmentPrepViewController.swift */; }; C38EF38C255B6DD2007E1867 /* ApprovalRailCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF383255B6DD1007E1867 /* ApprovalRailCellView.swift */; }; C38EF38D255B6DD2007E1867 /* AttachmentCaptionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF384255B6DD2007E1867 /* AttachmentCaptionViewController.swift */; }; - C38EF39B255B6DDA007E1867 /* ThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF397255B6DD9007E1867 /* ThreadViewModel.swift */; }; C38EF3B8255B6DE7007E1867 /* ImageEditorTextViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF3A8255B6DE4007E1867 /* ImageEditorTextViewController.swift */; }; C38EF3B9255B6DE7007E1867 /* ImageEditorPinchGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF3A9255B6DE4007E1867 /* ImageEditorPinchGestureRecognizer.swift */; }; C38EF3BA255B6DE7007E1867 /* ImageEditorItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF3AA255B6DE4007E1867 /* ImageEditorItem.swift */; }; @@ -534,7 +524,6 @@ C3BBE0A82554D4DE0050F1E3 /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D92553860B00C340D1 /* JSON.swift */; }; C3BBE0A92554D4DE0050F1E3 /* HTTP.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5BC255385EE00C340D1 /* HTTP.swift */; }; C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Description.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D52553860A00C340D1 /* Dictionary+Description.swift */; }; - C3BBE0B52554F0E10050F1E3 /* ProofOfWork.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3BBE0B42554F0E10050F1E3 /* ProofOfWork.swift */; }; C3BBE0C72554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3BBE0C62554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift */; }; C3C2A5A3255385C100C340D1 /* SessionSnodeKit.h in Headers */ = {isa = PBXBuildFile; fileRef = C3C2A5A1255385C100C340D1 /* SessionSnodeKit.h */; settings = {ATTRIBUTES = (Public, ); }; }; C3C2A5A7255385C100C340D1 /* SessionSnodeKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A59F255385C100C340D1 /* SessionSnodeKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -591,7 +580,7 @@ C3D9E4F4256778AF0040E4F3 /* NSData+Image.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAEF255A580500E217F9 /* NSData+Image.m */; }; C3D9E4FD256778E30040E4F3 /* NSData+Image.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB29255A580A00E217F9 /* NSData+Image.h */; settings = {ATTRIBUTES = (Public, ); }; }; C3D9E50E25677A510040E4F3 /* DataSource.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB54255A580D00E217F9 /* DataSource.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C3D9E52725677DF20040E4F3 /* OWSThumbnailService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAF1255A580500E217F9 /* OWSThumbnailService.swift */; }; + C3D9E52725677DF20040E4F3 /* ThumbnailService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAF1255A580500E217F9 /* ThumbnailService.swift */; }; C3DA9C0725AE7396008F7C7E /* ConfigurationMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DA9C0625AE7396008F7C7E /* ConfigurationMessage.swift */; }; C3DAB3242480CB2B00725F25 /* SRCopyableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DAB3232480CB2A00725F25 /* SRCopyableLabel.swift */; }; C3DB6695260AC923001EFC55 /* OpenGroupV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DB6694260AC923001EFC55 /* OpenGroupV2.swift */; }; @@ -678,7 +667,7 @@ FD3AABE928306BBD00E5099A /* ThreadPickerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */; }; FD3C907327E8387300CD579F /* OpenGroupServerIdLookupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907227E8387300CD579F /* OpenGroupServerIdLookupMigration.swift */; }; FD3C907527E83AC200CD579F /* OpenGroupServerIdLookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */; }; - FD4B200C283367410034334B /* ConversationCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4B200B283367410034334B /* ConversationCellViewModel.swift */; }; + FD3E0C84283B5835002A425C /* ConversationCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3E0C83283B5835002A425C /* ConversationCellViewModel.swift */; }; FD4B200E283492210034334B /* InsetLockableTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4B200D283492210034334B /* InsetLockableTableView.swift */; }; FD5D200F27AA2B6000FEA984 /* MessageRequestResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */; }; FD5D201127AA331F00FEA984 /* ConfigurationMessage+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D201027AA331F00FEA984 /* ConfigurationMessage+Convenience.swift */; }; @@ -687,7 +676,7 @@ FD6A7A6B2818C17C00035AC1 /* UpdateProfilePictureJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6A7A6A2818C17C00035AC1 /* UpdateProfilePictureJob.swift */; }; FD6A7A6D2818C61500035AC1 /* _002_SetupStandardJobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6A7A6C2818C61500035AC1 /* _002_SetupStandardJobs.swift */; }; FD705A8C278CDB5600F16121 /* SAEScreenLockViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A8B278CDB5600F16121 /* SAEScreenLockViewController.swift */; }; - FD705A8E278CE29800F16121 /* String+Localized.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A8D278CE29800F16121 /* String+Localized.swift */; }; + FD705A8E278CE29800F16121 /* String+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A8D278CE29800F16121 /* String+Utilities.swift */; }; FD705A90278CEBBC00F16121 /* ShareAppExtensionContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A8F278CEBBC00F16121 /* ShareAppExtensionContext.swift */; }; FD705A92278D051200F16121 /* ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A91278D051200F16121 /* ReusableView.swift */; }; FD705A94278D052B00F16121 /* UITableView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A93278D052B00F16121 /* UITableView+ReusableView.swift */; }; @@ -952,7 +941,6 @@ 34D1F0511F7E8EA30066283D /* GiphyDownloader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GiphyDownloader.swift; sourceTree = ""; }; 34D1F06F1F8678AA0066283D /* ConversationViewItem.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ConversationViewItem.h; sourceTree = ""; }; 34D1F0701F8678AA0066283D /* ConversationViewItem.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ConversationViewItem.m; sourceTree = ""; }; - 34D1F0BF1F8EC1760066283D /* MessageRecipientStatusUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageRecipientStatusUtils.swift; sourceTree = ""; }; 34D2CCD82062E7D000CB1A14 /* OWSScreenLockUI.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSScreenLockUI.h; sourceTree = ""; }; 34D2CCD92062E7D000CB1A14 /* OWSScreenLockUI.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSScreenLockUI.m; sourceTree = ""; }; 34D5CCA71EAE3D30005515DB /* AvatarViewHelper.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AvatarViewHelper.h; sourceTree = ""; }; @@ -1173,7 +1161,7 @@ B8BB82A1238F356100BA5194 /* Values.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Values.swift; sourceTree = ""; }; B8BB82A4238F627000BA5194 /* HomeVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeVC.swift; sourceTree = ""; }; B8BB82A8238F62FB00BA5194 /* Gradients.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Gradients.swift; sourceTree = ""; }; - B8BB82AA238F669C00BA5194 /* ConversationCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationCell.swift; sourceTree = ""; }; + B8BB82AA238F669C00BA5194 /* FullConversationCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullConversationCell.swift; sourceTree = ""; }; B8BB82B02390C37000BA5194 /* SearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBar.swift; sourceTree = ""; }; B8BB82B423947F2D00BA5194 /* TextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextField.swift; sourceTree = ""; }; B8BB82B82394911B00BA5194 /* Separator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Separator.swift; sourceTree = ""; }; @@ -1192,10 +1180,7 @@ B8D0A26825E4A2C200C1835E /* Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Onboarding.swift; sourceTree = ""; }; B8D84EA225DF745A005A043E /* LinkPreviewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewState.swift; sourceTree = ""; }; B8D84ECE25E3108A005A043E /* ExpandingAttachmentsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandingAttachmentsButton.swift; sourceTree = ""; }; - B8D8F1372566120F0092EF10 /* Storage+ClosedGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+ClosedGroups.swift"; sourceTree = ""; }; - B8D8F17625661AFA0092EF10 /* Storage+Jobs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+Jobs.swift"; sourceTree = ""; }; B8D8F18825661BA50092EF10 /* Storage+OpenGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+OpenGroups.swift"; sourceTree = ""; }; - B8D8F19225661BF80092EF10 /* Storage+Messaging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+Messaging.swift"; sourceTree = ""; }; B8EB20E6263F7E4B00773E52 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = ""; }; B8EB20ED2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VisibleMessage+OpenGroupInvitation.swift"; sourceTree = ""; }; B8EB20EF2640F7F000773E52 /* OpenGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupInvitationView.swift; sourceTree = ""; }; @@ -1218,14 +1203,11 @@ C300A5E62554B07300555489 /* ExpirationTimerUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpirationTimerUpdate.swift; sourceTree = ""; }; C300A5F12554B09800555489 /* MessageSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSender.swift; sourceTree = ""; }; C300A5FB2554B0A000555489 /* MessageReceiver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReceiver.swift; sourceTree = ""; }; - C300A6302554B68200555489 /* NSDate+Timestamp.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSDate+Timestamp.h"; sourceTree = ""; }; - C300A6312554B6D100555489 /* NSDate+Timestamp.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = "NSDate+Timestamp.mm"; sourceTree = ""; }; C302093D25DCBF07001F572D /* MentionSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionSelectionView.swift; sourceTree = ""; }; C31A6C59247F214E001123EF /* UIView+Glow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Glow.swift"; sourceTree = ""; }; C31A6C5B247F2CF3001123EF /* CGRect+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGRect+Utilities.swift"; sourceTree = ""; }; C31D1DDC25217014005D4DA8 /* UserCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCell.swift; sourceTree = ""; }; C31D1DE22521718E005D4DA8 /* UserSelectionVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSelectionVC.swift; sourceTree = ""; }; - C31D1DE8252172D4005D4DA8 /* ContactUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactUtilities.swift; sourceTree = ""; }; C3227FF5260AAD66006EA627 /* OpenGroupMessageV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupMessageV2.swift; sourceTree = ""; }; C328250E25CA06020062D0A7 /* VoiceMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageView.swift; sourceTree = ""; }; C328251E25CA3A900062D0A7 /* QuoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuoteView.swift; sourceTree = ""; }; @@ -1270,7 +1252,7 @@ C33FDAE0255A580400E217F9 /* ByteParser.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ByteParser.m; sourceTree = ""; }; C33FDAEA255A580500E217F9 /* OWSBackupFragment.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSBackupFragment.h; sourceTree = ""; }; C33FDAEF255A580500E217F9 /* NSData+Image.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSData+Image.m"; sourceTree = ""; }; - C33FDAF1255A580500E217F9 /* OWSThumbnailService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSThumbnailService.swift; sourceTree = ""; }; + C33FDAF1255A580500E217F9 /* ThumbnailService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThumbnailService.swift; sourceTree = ""; }; C33FDAF2255A580500E217F9 /* ProxiedContentDownloader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProxiedContentDownloader.swift; sourceTree = ""; }; C33FDAFC255A580600E217F9 /* MIMETypeUtil.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MIMETypeUtil.h; sourceTree = ""; }; C33FDAFD255A580600E217F9 /* LRUCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LRUCache.swift; sourceTree = ""; }; @@ -1370,9 +1352,7 @@ C37F5402255BA9ED002AEA92 /* Environment.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = Environment.m; sourceTree = ""; }; C38D5E8C2575011E00B6A65C /* MessageSender+ClosedGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageSender+ClosedGroups.swift"; sourceTree = ""; }; C38EEF09255B49A8007E1867 /* SNProtoEnvelope+Conversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SNProtoEnvelope+Conversion.swift"; sourceTree = ""; }; - C38EF223255B6D5D007E1867 /* AttachmentSharing.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = AttachmentSharing.m; path = "SignalUtilitiesKit/Media Viewing & Editing/AttachmentSharing.m"; sourceTree = SOURCE_ROOT; }; C38EF224255B6D5D007E1867 /* SignalAttachment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SignalAttachment.swift; path = "SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift"; sourceTree = SOURCE_ROOT; }; - C38EF225255B6D5D007E1867 /* AttachmentSharing.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AttachmentSharing.h; path = "SignalUtilitiesKit/Media Viewing & Editing/AttachmentSharing.h"; sourceTree = SOURCE_ROOT; }; C38EF226255B6D5D007E1867 /* ShareViewDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ShareViewDelegate.swift; path = SignalUtilitiesKit/Utilities/ShareViewDelegate.swift; sourceTree = SOURCE_ROOT; }; C38EF227255B6D5D007E1867 /* OWSVideoPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = OWSVideoPlayer.swift; path = "SignalUtilitiesKit/Media Viewing & Editing/OWSVideoPlayer.swift"; sourceTree = SOURCE_ROOT; }; C38EF236255B6D65007E1867 /* UIViewController+OWS.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = "UIViewController+OWS.h"; path = "SignalUtilitiesKit/Utilities/UIViewController+OWS.h"; sourceTree = SOURCE_ROOT; }; @@ -1447,7 +1427,6 @@ C38EF382255B6DD1007E1867 /* AttachmentPrepViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = AttachmentPrepViewController.swift; path = "SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift"; sourceTree = SOURCE_ROOT; }; C38EF383255B6DD1007E1867 /* ApprovalRailCellView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ApprovalRailCellView.swift; path = "SignalUtilitiesKit/Shared Views/ApprovalRailCellView.swift"; sourceTree = SOURCE_ROOT; }; C38EF384255B6DD2007E1867 /* AttachmentCaptionViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = AttachmentCaptionViewController.swift; path = "SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentCaptionViewController.swift"; sourceTree = SOURCE_ROOT; }; - C38EF397255B6DD9007E1867 /* ThreadViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ThreadViewModel.swift; path = SignalUtilitiesKit/Messaging/ThreadViewModel.swift; sourceTree = SOURCE_ROOT; }; C38EF3A8255B6DE4007E1867 /* ImageEditorTextViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ImageEditorTextViewController.swift; path = "SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorTextViewController.swift"; sourceTree = SOURCE_ROOT; }; C38EF3A9255B6DE4007E1867 /* ImageEditorPinchGestureRecognizer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ImageEditorPinchGestureRecognizer.swift; path = "SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorPinchGestureRecognizer.swift"; sourceTree = SOURCE_ROOT; }; C38EF3AA255B6DE4007E1867 /* ImageEditorItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ImageEditorItem.swift; path = "SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorItem.swift"; sourceTree = SOURCE_ROOT; }; @@ -1508,7 +1487,6 @@ C3AECBEA24EF5244005743DE /* fa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fa; path = fa.lproj/Localizable.strings; sourceTree = ""; }; C3BBE0752554CDA60050F1E3 /* Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = ""; }; C3BBE07F2554CDD70050F1E3 /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = ""; }; - C3BBE0B42554F0E10050F1E3 /* ProofOfWork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProofOfWork.swift; sourceTree = ""; }; C3BBE0C62554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FixedWidthInteger+BigEndian.swift"; sourceTree = ""; }; C3C2A59F255385C100C340D1 /* SessionSnodeKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SessionSnodeKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C3C2A5A1255385C100C340D1 /* SessionSnodeKit.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SessionSnodeKit.h; sourceTree = ""; }; @@ -1662,7 +1640,7 @@ FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadPickerViewModel.swift; sourceTree = ""; }; FD3C907227E8387300CD579F /* OpenGroupServerIdLookupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookupMigration.swift; sourceTree = ""; }; FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookup.swift; sourceTree = ""; }; - FD4B200B283367410034334B /* ConversationCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationCellViewModel.swift; sourceTree = ""; }; + FD3E0C83283B5835002A425C /* ConversationCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationCellViewModel.swift; sourceTree = ""; }; FD4B200D283492210034334B /* InsetLockableTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsetLockableTableView.swift; sourceTree = ""; }; FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestResponse.swift; sourceTree = ""; }; FD5D201027AA331F00FEA984 /* ConfigurationMessage+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConfigurationMessage+Convenience.swift"; sourceTree = ""; }; @@ -1671,7 +1649,7 @@ FD6A7A6A2818C17C00035AC1 /* UpdateProfilePictureJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateProfilePictureJob.swift; sourceTree = ""; }; FD6A7A6C2818C61500035AC1 /* _002_SetupStandardJobs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_SetupStandardJobs.swift; sourceTree = ""; }; FD705A8B278CDB5600F16121 /* SAEScreenLockViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SAEScreenLockViewController.swift; sourceTree = ""; }; - FD705A8D278CE29800F16121 /* String+Localized.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Localized.swift"; sourceTree = ""; }; + FD705A8D278CE29800F16121 /* String+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Utilities.swift"; sourceTree = ""; }; FD705A8F278CEBBC00F16121 /* ShareAppExtensionContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAppExtensionContext.swift; sourceTree = ""; }; FD705A91278D051200F16121 /* ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReusableView.swift; sourceTree = ""; }; FD705A93278D052B00F16121 /* UITableView+ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITableView+ReusableView.swift"; sourceTree = ""; }; @@ -1942,11 +1920,9 @@ 4C586924224FAB83003FD070 /* AVAudioSession+OWS.h */, 4C586925224FAB83003FD070 /* AVAudioSession+OWS.m */, 4521C3BF1F59F3BA00B4C582 /* TextFieldHelper.swift */, - 34D1F0BF1F8EC1760066283D /* MessageRecipientStatusUtils.swift */, B8544E3223D50E4900299F14 /* SNAppearance.swift */, C3D0972A2510499C00F6E3E4 /* BackgroundPoller.swift */, C31A6C5B247F2CF3001123EF /* CGRect+Utilities.swift */, - C31D1DE8252172D4005D4DA8 /* ContactUtilities.swift */, C35E8AAD2485E51D00ACB629 /* IP2Country.swift */, B84664F4235022F30083A1CD /* MentionUtilities.swift */, B886B4A82398BA1500211ABE /* QRCode.swift */, @@ -2248,8 +2224,6 @@ C33FDAFD255A580600E217F9 /* LRUCache.swift */, C33FDB5C255A580E00E217F9 /* NSArray+Functional.h */, C33FDAB8255A580100E217F9 /* NSArray+Functional.m */, - C300A6302554B68200555489 /* NSDate+Timestamp.h */, - C300A6312554B6D100555489 /* NSDate+Timestamp.mm */, C33FDB3B255A580B00E217F9 /* NSNotificationCenter+OWS.h */, C33FDB6C255A580F00E217F9 /* NSNotificationCenter+OWS.m */, C33FDA7A255A57FB00E217F9 /* NSRegularExpression+SSK.swift */, @@ -2265,7 +2239,7 @@ C33FDB6B255A580F00E217F9 /* SNUserDefaults.swift */, C33FDB3F255A580C00E217F9 /* String+SSK.swift */, C3C2AC2D2553CBEB00C340D1 /* String+Trimming.swift */, - FD705A8D278CE29800F16121 /* String+Localized.swift */, + FD705A8D278CE29800F16121 /* String+Utilities.swift */, C38EF237255B6D65007E1867 /* UIDevice+featureSupport.swift */, C35D0DB425AE5F1200B6BF49 /* UIEdgeInsets.swift */, FD705A91278D051200F16121 /* ReusableView.swift */, @@ -2301,7 +2275,7 @@ 34330AA21E79686200DF2FB9 /* OWSProgressView.m */, 45A6DAD51EBBF85500893231 /* ReminderView.swift */, C354E75923FE2A7600CE22E3 /* BaseVC.swift */, - B8BB82AA238F669C00BA5194 /* ConversationCell.swift */, + B8BB82AA238F669C00BA5194 /* FullConversationCell.swift */, 4542DF53208D40AC007B4E76 /* LoadingViewController.swift */, 340FC888204DAC8C007AEB0F /* OWSQRCodeScanningViewController.h */, 340FC896204DAC8C007AEB0F /* OWSQRCodeScanningViewController.m */, @@ -2478,9 +2452,6 @@ C33FDAFE255A580600E217F9 /* OWSStorage.h */, C33FDAB1255A580000E217F9 /* OWSStorage.m */, C33FDAB9255A580100E217F9 /* OWSStorage+Subclass.h */, - B8D8F1372566120F0092EF10 /* Storage+ClosedGroups.swift */, - B8D8F17625661AFA0092EF10 /* Storage+Jobs.swift */, - B8D8F19225661BF80092EF10 /* Storage+Messaging.swift */, B8D8F18825661BA50092EF10 /* Storage+OpenGroups.swift */, C3F0A5FD255C988A007BE2A3 /* Storage+Shared.swift */, C33FDB25255A580900E217F9 /* TSDatabaseSecondaryIndexes.h */, @@ -2741,8 +2712,6 @@ children = ( C379DCEA2567334F0002D4EB /* Attachment Approval */, C379DCE9256733390002D4EB /* Image Editing */, - C38EF225255B6D5D007E1867 /* AttachmentSharing.h */, - C38EF223255B6D5D007E1867 /* AttachmentSharing.m */, C38EF358255B6DCC007E1867 /* MediaMessageView.swift */, C38EF227255B6D5D007E1867 /* OWSVideoPlayer.swift */, C38EF3B5255B6DE6007E1867 /* OWSViewController+ImageEditor.swift */, @@ -2874,7 +2843,6 @@ isa = PBXGroup; children = ( FD28A4F327EA79F800FF65E7 /* BlockListUIUtils.swift */, - C38EF397255B6DD9007E1867 /* ThreadViewModel.swift */, C38EF2E4255B6DB9007E1867 /* FullTextSearcher.swift */, ); path = Messaging; @@ -2938,7 +2906,6 @@ FD09797327FAB3E200936362 /* ProfileManager.swift */, FDB4BBC82839BEF000B7C95D /* ProfileManagerError.swift */, C38EF2EC255B6DBA007E1867 /* ProximityMonitoringManager.swift */, - C3BBE0B42554F0E10050F1E3 /* ProofOfWork.swift */, C38EEF09255B49A8007E1867 /* SNProtoEnvelope+Conversion.swift */, FDF0B7542807C4BB004C14C5 /* SSKEnvironment.swift */, C3A3A170256E1D25004D228D /* SSKReachabilityManager.swift */, @@ -3030,6 +2997,7 @@ C352A2F325574B3300338F3E /* Jobs */, C3A7215C2558C0AC0043A11F /* File Server */, C3A721332558BDDF0043A11F /* Open Groups */, + FD3E0C82283B581F002A425C /* Shared Models */, C3BBE0B32554F0D30050F1E3 /* Utilities */, ); path = SessionMessagingKit; @@ -3156,7 +3124,7 @@ C3D9E3B52567685D0040E4F3 /* Attachments */ = { isa = PBXGroup; children = ( - C33FDAF1255A580500E217F9 /* OWSThumbnailService.swift */, + C33FDAF1255A580500E217F9 /* ThumbnailService.swift */, C38EF224255B6D5D007E1867 /* SignalAttachment.swift */, ); path = Attachments; @@ -3471,10 +3439,17 @@ path = LegacyDatabase; sourceTree = ""; }; + FD3E0C82283B581F002A425C /* Shared Models */ = { + isa = PBXGroup; + children = ( + FD3E0C83283B5835002A425C /* ConversationCellViewModel.swift */, + ); + path = "Shared Models"; + sourceTree = ""; + }; FD4B200A283367350034334B /* Models */ = { isa = PBXGroup; children = ( - FD4B200B283367410034334B /* ConversationCellViewModel.swift */, ); path = Models; sourceTree = ""; @@ -3569,7 +3544,6 @@ C38EF24C255B6D67007E1867 /* NSAttributedString+OWS.h in Headers */, FDA8EB0A280E9103002B68E5 /* AppSetup.h in Headers */, C38EF32B255B6DBF007E1867 /* OWSFormat.h in Headers */, - C38EF22A255B6D5D007E1867 /* AttachmentSharing.h in Headers */, C33FDDB8255A582000E217F9 /* NSSet+Functional.h in Headers */, C33FDDCC255A582000E217F9 /* TSConstants.h in Headers */, C33FDDBD255A582000E217F9 /* ByteParser.h in Headers */, @@ -3618,7 +3592,6 @@ C3D9E50E25677A510040E4F3 /* DataSource.h in Headers */, B8856DF8256F1633001CE70E /* NSString+SSK.h in Headers */, C3D9E4FD256778E30040E4F3 /* NSData+Image.h in Headers */, - C300A63B2554B72200555489 /* NSDate+Timestamp.h in Headers */, C3D9E4E3256778720040E4F3 /* UIImage+OWS.h in Headers */, B8856E1A256F1700001CE70E /* OWSMath.h in Headers */, C352A3772557864000338F3E /* NSTimer+Proxying.h in Headers */, @@ -4338,7 +4311,6 @@ C38EF247255B6D67007E1867 /* NSAttributedString+OWS.m in Sources */, C33FDD49255A582000E217F9 /* ParamParser.swift in Sources */, C38EF3C5255B6DE7007E1867 /* OWSViewController+ImageEditor.swift in Sources */, - C38EF39B255B6DDA007E1867 /* ThreadViewModel.swift in Sources */, FDA8EB09280E90FB002B68E5 /* AppSetup.m in Sources */, C38EF2A5255B6D93007E1867 /* Identicon+ObjC.swift in Sources */, C38EF273255B6D7A007E1867 /* OWSDatabaseMigrationRunner.m in Sources */, @@ -4403,7 +4375,6 @@ C38EF3BF255B6DE7007E1867 /* ImageEditorView.swift in Sources */, C38EF365255B6DCC007E1867 /* OWSTableViewController.m in Sources */, C38EF36B255B6DCC007E1867 /* ScreenLockViewController.m in Sources */, - C38EF228255B6D5D007E1867 /* AttachmentSharing.m in Sources */, C38EF40C255B6DF7007E1867 /* GradientView.swift in Sources */, C38EF30E255B6DBF007E1867 /* FullTextSearcher.swift in Sources */, C38EF3FA255B6DF7007E1867 /* DirectionalPanGestureRecognizer.swift in Sources */, @@ -4565,12 +4536,11 @@ C300A60D2554B31900555489 /* Logging.swift in Sources */, B8FF8EA625C11FEF004D1F22 /* IPv4.swift in Sources */, C3D9E35525675EE10040E4F3 /* MIMETypeUtil.m in Sources */, - FD705A8E278CE29800F16121 /* String+Localized.swift in Sources */, + FD705A8E278CE29800F16121 /* String+Utilities.swift in Sources */, FD9004162818B46700ABAAF6 /* JobRunnerError.swift in Sources */, FD09797227FAA2F500936362 /* Optional+Utilities.swift in Sources */, FD7162DB281B6C440060647B /* TypedTableAlias.swift in Sources */, C3A7219A2558C1660043A11F /* AnyPromise+Conversion.swift in Sources */, - C300A6322554B6D100555489 /* NSDate+Timestamp.mm in Sources */, FD17D7E727F6A16700122BE0 /* _003_YDBToGRDBMigration.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -4597,18 +4567,16 @@ C352A31325574F5200338F3E /* MessageReceiveJob.swift in Sources */, C3C2A7562553A3AB00C340D1 /* VisibleMessage+Quote.swift in Sources */, C3227FF6260AAD66006EA627 /* OpenGroupMessageV2.swift in Sources */, - C32C5C89256DD0D2003C73A2 /* Storage+Jobs.swift in Sources */, FDF0B75C2807F41D004C14C5 /* MessageSender+Convenience.swift in Sources */, FD09799727FFA84A00936362 /* RecipientState.swift in Sources */, C300A5FC2554B0A000555489 /* MessageReceiver.swift in Sources */, C32C5A76256DBBCF003C73A2 /* SignalAttachment.swift in Sources */, FDA8EB00280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift in Sources */, FD09798927FD1C5A00936362 /* OpenGroup.swift in Sources */, - C3BBE0B52554F0E10050F1E3 /* ProofOfWork.swift in Sources */, C3A3A08F256E1728004D228D /* FullTextSearchFinder.swift in Sources */, FDF0B7472804F0CE004C14C5 /* DisappearingMessagesJob.swift in Sources */, B8856D1A256F114D001CE70E /* ProximityMonitoringManager.swift in Sources */, - C3D9E52725677DF20040E4F3 /* OWSThumbnailService.swift in Sources */, + C3D9E52725677DF20040E4F3 /* ThumbnailService.swift in Sources */, C32C5E75256DE020003C73A2 /* YapDatabaseTransaction+OWS.m in Sources */, FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */, FD09797527FAB64300936362 /* ProfileManager.swift in Sources */, @@ -4626,6 +4594,7 @@ FDB4BBC92839BEF000B7C95D /* ProfileManagerError.swift in Sources */, FDF0B7552807C4BB004C14C5 /* SSKEnvironment.swift in Sources */, B8856ECE256F1E58001CE70E /* OWSPreferences.m in Sources */, + FD3E0C84283B5835002A425C /* ConversationCellViewModel.swift in Sources */, FD09C5EC282B8F18000CE219 /* AttachmentError.swift in Sources */, FD17D79927F40AB800122BE0 /* _003_YDBToGRDBMigration.swift in Sources */, C3C2A7842553AAF300C340D1 /* SNProto.swift in Sources */, @@ -4668,7 +4637,6 @@ B88FA7B826045D100049422F /* OpenGroupAPIV2.swift in Sources */, C32C5E97256DE0CB003C73A2 /* OWSPrimaryStorage.m in Sources */, C3A71D1F25589AC30043A11F /* WebSocketResources.pb.swift in Sources */, - C32C5BCC256DC830003C73A2 /* Storage+ClosedGroups.swift in Sources */, FDF0B74B28061F7A004C14C5 /* InteractionAttachment.swift in Sources */, B8F5F56525EC8453003BF8D4 /* Notification+Contacts.swift in Sources */, FD09796E27FA6D0000936362 /* Contact.swift in Sources */, @@ -4696,7 +4664,6 @@ FD09797027FA6FF300936362 /* Profile.swift in Sources */, FD09798B27FD1CFE00936362 /* Capability.swift in Sources */, C3BBE0C72554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift in Sources */, - C32C5C88256DD0D2003C73A2 /* Storage+Messaging.swift in Sources */, FD17D79C27F40B2E00122BE0 /* SMKLegacy.swift in Sources */, C300A5B22554AF9800555489 /* VisibleMessage+Profile.swift in Sources */, FD09798127FCFEE800936362 /* SessionThread.swift in Sources */, @@ -4729,7 +4696,6 @@ B82B408A2399EC0600A248E7 /* FakeChatView.swift in Sources */, B82B40882399EB0E00A248E7 /* LandingVC.swift in Sources */, EF764C351DB67CC5000D9A87 /* UIViewController+Permissions.m in Sources */, - FD4B200C283367410034334B /* ConversationCellViewModel.swift in Sources */, 45CD81EF1DC030E7004C9430 /* SyncPushTokensJob.swift in Sources */, B83524A525C3BA4B0089A44F /* InfoMessageCell.swift in Sources */, B84A89BC25DE328A0040017D /* ProfilePictureVC.swift in Sources */, @@ -4765,7 +4731,7 @@ B82B408C239A068800A248E7 /* RegisterVC.swift in Sources */, FDFDE126282D05380098B17F /* MediaInteractiveDismiss.swift in Sources */, 34BECE301F7ABCF800D7438D /* GifPickerLayout.swift in Sources */, - C331FFFE2558FF3B00070591 /* ConversationCell.swift in Sources */, + C331FFFE2558FF3B00070591 /* FullConversationCell.swift in Sources */, FDFDE128282D05530098B17F /* MediaPresentationContext.swift in Sources */, FD659AC027A7649600F12C02 /* MessageRequestsViewController.swift in Sources */, FDFDE124282D04F20098B17F /* MediaDismissAnimationController.swift in Sources */, @@ -4820,7 +4786,6 @@ 340FC8B7204DAC8D007AEB0F /* OWSConversationSettingsViewController.m in Sources */, 34BECE2E1F7ABCE000D7438D /* GifPickerViewController.swift in Sources */, B84664F5235022F30083A1CD /* MentionUtilities.swift in Sources */, - 34D1F0C01F8EC1760066283D /* MessageRecipientStatusUtils.swift in Sources */, FD09C5E628260FF9000CE219 /* MediaGalleryViewModel.swift in Sources */, FD859F0027C4691300510D0C /* MockDataGenerator.swift in Sources */, C328250F25CA06020062D0A7 /* VoiceMessageView.swift in Sources */, @@ -4841,7 +4806,6 @@ C3AAFFF225AE99710089E6DD /* AppDelegate.swift in Sources */, B8BB82A5238F627000BA5194 /* HomeVC.swift in Sources */, C31A6C5A247F214E001123EF /* UIView+Glow.swift in Sources */, - C31D1DE9252172D4005D4DA8 /* ContactUtilities.swift in Sources */, 4521C3C01F59F3BA00B4C582 /* TextFieldHelper.swift in Sources */, 340FC8AC204DAC8D007AEB0F /* PrivacySettingsTableViewController.m in Sources */, B8569AE325CBB19A00DBA3DB /* DocumentView.swift in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 24cbe4b1f..f6b9cad6b 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -91,8 +91,8 @@ extension ConversationVC: func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?) { sendAttachments(attachments, with: messageText ?? "") - resetMentions() self.snInputView.text = "" + resetMentions() dismiss(animated: true) { } } @@ -112,8 +112,8 @@ extension ConversationVC: } scrollToBottom(isAnimated: false) - resetMentions() self.snInputView.text = "" + resetMentions() } func attachmentApprovalDidCancel(_ attachmentApproval: AttachmentApprovalViewController) { @@ -464,9 +464,9 @@ extension ConversationVC: DispatchQueue.main.async { [weak self] in self?.snInputView.text = "" self?.snInputView.quoteDraftInfo = nil + + self?.resetMentions() } - - resetMentions() if GRDBStorage.shared[.playNotificationSoundInForeground] { let soundID = Preferences.Sound.systemSoundId(for: .messageSent, quiet: true) @@ -692,11 +692,11 @@ extension ConversationVC: switch mediaView.attachment.state { - case .pending, .downloading, .uploading: + case .pendingDownload, .downloading, .uploading: // TODO: Tapped a failed incoming attachment break - case .failed: + case .failedDownload: // TODO: Tapped a failed incoming attachment break @@ -755,6 +755,7 @@ extension ConversationVC: // Otherwise share the file let shareVC = UIActivityViewController(activityItems: [ fileUrl ], applicationActivities: nil) navigationController?.present(shareVC, animated: true, completion: nil) + case .textOnlyMessage: if let reply = viewItem.quotedReply { // Scroll to the source of the reply diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index fe79c2843..37250395b 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -436,9 +436,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers // Scroll to the last unread message if possible; otherwise scroll to the bottom. // When the unread message count is more than the number of view items of a page, - // the screen will scroll to the bottom instead of the first unread message. - // unreadIndicatorIndex is calculated during loading of the viewItems, so it's - // supposed to be accurate. + // the screen will scroll to the bottom instead of the first unread message DispatchQueue.main.async { if let focusedInteractionId: Int64 = self.viewModel.focusedInteractionId { self.scrollToInteraction(with: focusedInteractionId, isAnimated: false, highlighted: true) diff --git a/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift b/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift index 0b2482946..6559d4078 100644 --- a/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift +++ b/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift @@ -95,8 +95,8 @@ public extension LinkPreview { return .loaded - case .pending, .downloading, .uploading: return .loading - case .failed: return .invalid + case .pendingDownload, .downloading, .uploading: return .loading + case .failedDownload: return .invalid } } diff --git a/Session/Conversations/Message Cells/Content Views/MediaView.swift b/Session/Conversations/Message Cells/Content Views/MediaView.swift index 72350d966..7185a076b 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaView.swift @@ -78,11 +78,11 @@ public class MediaView: UIView { private func createContents() { AssertIsOnMainThread() - guard attachment.state == .uploaded || attachment.state == .downloaded else { + guard attachment.state != .pendingDownload && attachment.state != .downloading else { addDownloadProgressIfNecessary() return } - guard attachment.state != .failed else { + guard attachment.state != .failedDownload else { configure(forError: .failed) return } @@ -101,9 +101,9 @@ public class MediaView: UIView { configure(forError: .invalid) } } - + private func addDownloadProgressIfNecessary() { - guard attachment.state != .failed else { + guard attachment.state != .failedDownload else { configure(forError: .failed) return } diff --git a/Session/Conversations/Views & Modals/DownloadAttachmentModal.swift b/Session/Conversations/Views & Modals/DownloadAttachmentModal.swift index 927df942b..50b8c7187 100644 --- a/Session/Conversations/Views & Modals/DownloadAttachmentModal.swift +++ b/Session/Conversations/Views & Modals/DownloadAttachmentModal.swift @@ -93,7 +93,7 @@ final class DownloadAttachmentModal: Modal { // Start downloading any pending attachments for this contact (UI will automatically be // updated due to the database observation) try Attachment - .stateInfo(authorId: profileId, state: .pending) + .stateInfo(authorId: profileId, state: .pendingDownload) .fetchAll(db) .forEach { attachmentDownloadInfo in JobRunner.add( diff --git a/Session/Home/GlobalSearch/GlobalSearchViewController.swift b/Session/Home/GlobalSearch/GlobalSearchViewController.swift index c83b1a8ef..63d7f9f41 100644 --- a/Session/Home/GlobalSearch/GlobalSearchViewController.swift +++ b/Session/Home/GlobalSearch/GlobalSearchViewController.swift @@ -58,7 +58,7 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo result.separatorStyle = .none result.keyboardDismissMode = .onDrag result.register(view: EmptySearchResultCell.self) - result.register(view: ConversationCell.self) + result.register(view: ConversationCell.Full.self) result.showsVerticalScrollIndicator = false return result @@ -312,12 +312,12 @@ extension GlobalSearchViewController { return cell case .contactsAndGroups: - let cell: ConversationCell = tableView.dequeue(type: ConversationCell.self, for: indexPath) + let cell: ConversationCell.Full = tableView.dequeue(type: ConversationCell.Full.self, for: indexPath) cell.updateForContactAndGroupSearchResult(with: section.elements[indexPath.row], searchText: self.termForCurrentSearchResultSet) return cell case .messages: - let cell: ConversationCell = tableView.dequeue(type: ConversationCell.self, for: indexPath) + let cell: ConversationCell.Full = tableView.dequeue(type: ConversationCell.Full.self, for: indexPath) cell.updateForMessageSearchResult(with: section.elements[indexPath.row], searchText: self.termForCurrentSearchResultSet) return cell } diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 332eccbd5..d7a4a20ce 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -55,7 +55,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve ) result.showsVerticalScrollIndicator = false result.register(view: MessageRequestsCell.self) - result.register(view: ConversationCell.self) + result.register(view: ConversationCell.Full.self) result.dataSource = self result.delegate = self @@ -245,7 +245,12 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve // Reload the table content (animate changes after the first load) tableView.reload( using: StagedChangeset(source: viewModel.viewData, target: updatedViewData), - with: .automatic, + deleteSectionsAnimation: .none, + insertSectionsAnimation: .none, + reloadSectionsAnimation: .none, + deleteRowsAnimation: .bottom, + insertRowsAnimation: .top, + reloadRowsAnimation: .none, interrupt: { print("Interrupt change check: \($0.changeCount)") return $0.changeCount > 100 @@ -350,7 +355,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve return cell case .threads: - let cell: ConversationCell = tableView.dequeue(type: ConversationCell.self, for: indexPath) + let cell: ConversationCell.Full = tableView.dequeue(type: ConversationCell.Full.self, for: indexPath) cell.update(with: section.elements[indexPath.row]) return cell } diff --git a/Session/Home/Message Requests/MessageRequestsViewController.swift b/Session/Home/Message Requests/MessageRequestsViewController.swift index e5bf0aab8..2446cb518 100644 --- a/Session/Home/Message Requests/MessageRequestsViewController.swift +++ b/Session/Home/Message Requests/MessageRequestsViewController.swift @@ -19,7 +19,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat result.translatesAutoresizingMaskIntoConstraints = false result.backgroundColor = .clear result.separatorStyle = .none - result.register(view: ConversationCell.self) + result.register(view: ConversationCell.Full.self) result.dataSource = self result.delegate = self @@ -189,7 +189,12 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat // Reload the table content (animate changes after the first load) tableView.reload( using: StagedChangeset(source: viewModel.viewData, target: updatedViewData), - with: .automatic, + deleteSectionsAnimation: .none, + insertSectionsAnimation: .none, + reloadSectionsAnimation: .none, + deleteRowsAnimation: .bottom, + insertRowsAnimation: .top, + reloadRowsAnimation: .none, interrupt: { $0.changeCount > 100 } // Prevent too many changes from causing performance issues ) { [weak self] updatedData in self?.viewModel.updateData(updatedData) @@ -211,7 +216,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell: ConversationCell = tableView.dequeue(type: ConversationCell.self, for: indexPath) + let cell: ConversationCell.Full = tableView.dequeue(type: ConversationCell.Full.self, for: indexPath) cell.update(with: viewModel.viewData[indexPath.row]) return cell } diff --git a/Session/Home/Views/MessageRequestsCell.swift b/Session/Home/Views/MessageRequestsCell.swift index 54002f721..c2807e1d6 100644 --- a/Session/Home/Views/MessageRequestsCell.swift +++ b/Session/Home/Views/MessageRequestsCell.swift @@ -60,7 +60,7 @@ class MessageRequestsCell: UITableViewCell { result.translatesAutoresizingMaskIntoConstraints = false result.clipsToBounds = true result.backgroundColor = Colors.text.withAlphaComponent(Values.veryLowOpacity) - result.layer.cornerRadius = (ConversationCell.unreadCountViewSize / 2) + result.layer.cornerRadius = (ConversationCell.Full.unreadCountViewSize / 2) return result }() @@ -115,8 +115,8 @@ class MessageRequestsCell: UITableViewCell { unreadCountView.leftAnchor.constraint(equalTo: titleLabel.rightAnchor, constant: (Values.smallSpacing / 2)), unreadCountView.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), - unreadCountView.widthAnchor.constraint(equalToConstant: ConversationCell.unreadCountViewSize), - unreadCountView.heightAnchor.constraint(equalToConstant: ConversationCell.unreadCountViewSize), + unreadCountView.widthAnchor.constraint(equalToConstant: ConversationCell.Full.unreadCountViewSize), + unreadCountView.heightAnchor.constraint(equalToConstant: ConversationCell.Full.unreadCountViewSize), unreadCountLabel.topAnchor.constraint(equalTo: unreadCountView.topAnchor), unreadCountLabel.leftAnchor.constraint(equalTo: unreadCountView.leftAnchor), diff --git a/Session/Media Viewing & Editing/MediaPageViewController.swift b/Session/Media Viewing & Editing/MediaPageViewController.swift index 566d0e0ec..61bddbf34 100644 --- a/Session/Media Viewing & Editing/MediaPageViewController.swift +++ b/Session/Media Viewing & Editing/MediaPageViewController.swift @@ -504,7 +504,19 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou return } - AttachmentSharing.showShareUI(for: URL(fileURLWithPath: originalFilePath)) { activityType in + let shareVC = UIActivityViewController(activityItems: [ URL(fileURLWithPath: originalFilePath) ], applicationActivities: nil) + if UIDevice.current.isIPad { + shareVC.excludedActivityTypes = [] + shareVC.popoverPresentationController?.permittedArrowDirections = [] + shareVC.popoverPresentationController?.sourceView = self.view + shareVC.popoverPresentationController?.sourceRect = self.view.bounds + } + shareVC.completionWithItemsHandler = { activityType, completed, returnedItems, activityError in + if let activityError = activityError { + SNLog("Failed to share with activityError: \(activityError)") + } else if completed { + SNLog("Did share with activityType: \(activityType.debugDescription)") + } guard let activityType = activityType, activityType == .saveToCameraRoll, @@ -513,7 +525,6 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou else { return } GRDBStorage.shared.write { db in - guard let thread: SessionThread = try SessionThread.fetchOne(db, id: self.viewModel.threadId) else { return } @@ -530,6 +541,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou ) } } + self.present(shareVC, animated: true, completion: nil) } @objc public func didPressDelete(_ sender: Any) { diff --git a/Session/Meta/Images.xcassets/x-24.imageset/Contents.json b/Session/Meta/Images.xcassets/x-24.imageset/Contents.json new file mode 100644 index 000000000..925ecb5d0 --- /dev/null +++ b/Session/Meta/Images.xcassets/x-24.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "x-24.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "x-24@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "x-24@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Session/Meta/Images.xcassets/x-24.imageset/x-24.png b/Session/Meta/Images.xcassets/x-24.imageset/x-24.png new file mode 100644 index 0000000000000000000000000000000000000000..277cd6b6908c16c69e9b00b0df6da29a1154a0d2 GIT binary patch literal 875 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjjKx9jP7LeL$-D$|T2doC(|mmy zw18|523AHP24;{FAY@>aVqgWc85q16rQz%#Mh&PMCI*J~Oa>OHnkXO*0ylsds2fT% zFJMHNxPS?+T4Dh+f(_EPo=3n7NO2Z;L>4nJC|ZCpqw6%o1fXe=nIRD+5xzcF$@#f@ zi7EL>sd^Q;1t47vHWgMtW^QUpqC!P(PF}H9g{=};g%ywu64qBz04piUwpEJo4N!2- zFG^J~(=*UBP_pAvP*AWbN=dT{a&d!d2l8x{GD=Dctn~HE%ggo3jrH=2()A53EiLs8 zjP#9+bb%^#i!1X=5-W7`ij^UTz|3(;Elw`VEGWs$&r<-Io0ybeT4JlD1hNPYAnq*5 zOhed|R}A$Q(1ZFQ8GS=N1AVyJK&>_)Q7iwV%v7MwAoJ}EZNMr~#Gv-r=z}araty?$ zU{Rn~?YM08;lXCdB^mdS9T>>co-U3d9=u1VX7e>0@HF3a-hDrBTm9|a?Yl0s=P4Wz z$e8MN`^LuGAr9C7E{yGdp4q&)&BA-Ei4P>2SNbGmp8TVFk|idk>#B^vxzvit}{!b6Mw<&;$Totr_?L literal 0 HcmV?d00001 diff --git a/Session/Meta/Images.xcassets/x-24.imageset/x-24@2x.png b/Session/Meta/Images.xcassets/x-24.imageset/x-24@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..bdadc93db3d6ec90f3205391d60827ee4fe4a786 GIT binary patch literal 1195 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTC#^NA%Cx&(BWL^R}EvXTnX}-P; zT0k}j11qBt12aeo5Hc`IF|dN!3=Ce3(r|VVqXtwB69YqgCIbspO%#v@fg3;!)D5MX z7ce4BT)+fZZLokD!3JrY#>e>@NO2Z;L>4nJC|ZCpqw6%o1fXe=nIRD+5xzcF$@#f@ zi7EL>sd^Q;1t47vHWgMtW^QUpqC!P(PF}H9g{=};g%ywu64qBz04piUwpEJo4N!2- zFG^J~(=*UBP_pAvP*AWbN=dT{a&d!d2l8x{GD=Dctn~HE%ggo3jrH=2()A53EiLs8 zjP#9+bb%^#i!1X=5-W7`ij^UTz|3(;Elw`VEGWs$&r<-Io0ybeT4JlD1hNPYAnq*5 zOhed|R}A$Q(1ZFQ8GS=N1AVyJK&>_)Q7iwV%v7MwAoJ}EZNMr~#Gv-r=z}araty?$ zU{Rn~?YM08;lXCdB^mdSoq>TV+SA1`B!l(s%-ea*1_CaZyMO)f|Jmb}yMJxy?+fol z{C2q<`LU@@X`|3a!`Cx!8ZtWhEm!7a=VPBHvtu($ZnitedhvI^_jZ4b*%yDL+CI%b z>bJ=^No^)ApEl$r8yjiz%RUE_p z$KR54`vQ7+TUlp35)j{Wi9!F?q$MhuoKKq??j8SjHL9cFblWUuoy6|L#a_(I4Q`2X zdWtCI)$|-tIw05;$>O8Zpy+etuxei4VogR<+ZOHO1A*F%?JjZM-`ysfhtAk|{D>-3 z%&CSwOAMATcif+1+fgM|TzH~fiL+Yb(bju!+y&gF3WGCVIdnI0FbCb*)PI#DQ`06o z!ZWFJ!K^0lEg{dJaS1mT1z)e+uPS)bgIcCKyH@+%ydHP+Qf#n0xtDDkjZa&^}Kx!j?l zEIx-gEO+k??zo&dJwECEpRc8H+KwBv{&~4POqj4cs=LAR`QOsGrYY623%;4}p7SWG zZ^Ip@gCPfF7rO~OD&l&lwa?YV_u;~$E*u$#3s%nXc*LRH)%DU*BGY5jjCn4$Ju^j` ze=L1%Q#twMOzShDhAV%wRLAOW11V7J_w)fg1WSjr@pY_32 z-O*AHTxatxJ*>`^ur@wu&TB2VIgix#s2!W6o3Lu$_s>15>Sl5+&T}lzM;LBdf9rd9 zBbR!G-hKTR$xEykc!Cb@5zTMCl6>mnfik{73`gpD!tSj%ItR+Jp00i_>zopr0A#AF A2mk;8 literal 0 HcmV?d00001 diff --git a/Session/Meta/Images.xcassets/x-24.imageset/x-24@3x.png b/Session/Meta/Images.xcassets/x-24.imageset/x-24@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..cbb9bb263d56a3b51b69b48ace2bb48ad7eb0b3a GIT binary patch literal 573 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1|&n@ZgvM!oCO|{#S9FJ<{->y95KI&fr0V8 zr;B4q#hkZu8aodg2(X05J6in|3!3Hasj2z!^2zcA?g#mgso%Y+c4=;c__PV4ZqF49 zRlNO9SvF_AUVYPPud72F#|Z&ONhJkmmXwYL9>EEUOhzS~0bZG&ns;5C1ExlKpPjx+ zJGj@F?Sn=;;}6CH>*_xl)1|IQot}OC<>$4ny7>>DNGiO2@I-rp%LSu`)a!v69=sO+ zDr#cjvmIQp>6ZL{q^VZy^hrgSzU7eubkbjY5&$a7ew{4&Q(T=h@ zmC8JoaiyBL#fK=182>N(ejdpvYGyt^^=k8kn1>DC=eONqkln*Kb=&giM%%d;Ocu`! z_c+WbSrFHoJxPAatp^>|mP>V6MuwibE!$Oj*vBw`r`9vy>Z38=0?)KMAC0LB zJ9C!1Yv*5AL-PR5XJR%-?;Ke<<936nd)sC1NgPr082$J?lrm=WZ9_y@ literal 0 HcmV?d00001 diff --git a/Session/Meta/Signal-Bridging-Header.h b/Session/Meta/Signal-Bridging-Header.h index 42da6bcad..ab957879b 100644 --- a/Session/Meta/Signal-Bridging-Header.h +++ b/Session/Meta/Signal-Bridging-Header.h @@ -35,7 +35,6 @@ #import #import #import -#import #import #import #import diff --git a/Session/Meta/Translations/de.lproj/Localizable.strings b/Session/Meta/Translations/de.lproj/Localizable.strings index 212e051f3..9d8adff9c 100644 --- a/Session/Meta/Translations/de.lproj/Localizable.strings +++ b/Session/Meta/Translations/de.lproj/Localizable.strings @@ -205,13 +205,13 @@ /* No comment provided by engineer. */ "GROUP_CREATED" = "Gruppe erstellt."; /* No comment provided by engineer. */ -"GROUP_MEMBER_JOINED" = " %@ ist der Gruppe beigetreten."; +"GROUP_MEMBER_JOINED" = "%@ ist der Gruppe beigetreten."; /* No comment provided by engineer. */ "GROUP_MEMBER_LEFT" = "%@ hat die Gruppe verlassen."; /* No comment provided by engineer. */ -"GROUP_MEMBER_REMOVED" = " %@ wurde aus der Gruppe entfernt. "; +"GROUP_MEMBER_REMOVED" = "%@ wurde aus der Gruppe entfernt. "; /* No comment provided by engineer. */ -"GROUP_MEMBERS_REMOVED" = " %@ wurden aus der Gruppe entfernt. "; +"GROUP_MEMBERS_REMOVED" = "%@ wurden aus der Gruppe entfernt. "; /* No comment provided by engineer. */ "GROUP_TITLE_CHANGED" = "Gruppenname lautet jetzt »%@«. "; /* No comment provided by engineer. */ diff --git a/Session/Meta/Translations/en.lproj/Localizable.strings b/Session/Meta/Translations/en.lproj/Localizable.strings index 7bd0d5596..ca173d7f7 100644 --- a/Session/Meta/Translations/en.lproj/Localizable.strings +++ b/Session/Meta/Translations/en.lproj/Localizable.strings @@ -205,13 +205,13 @@ /* No comment provided by engineer. */ "GROUP_CREATED" = "Group created"; /* No comment provided by engineer. */ -"GROUP_MEMBER_JOINED" = " %@ joined the group. "; +"GROUP_MEMBER_JOINED" = "%@ joined the group. "; /* No comment provided by engineer. */ -"GROUP_MEMBER_LEFT" = " %@ left the group. "; +"GROUP_MEMBER_LEFT" = "%@ left the group. "; /* No comment provided by engineer. */ -"GROUP_MEMBER_REMOVED" = " %@ was removed from the group. "; +"GROUP_MEMBER_REMOVED" = "%@ was removed from the group. "; /* No comment provided by engineer. */ -"GROUP_MEMBERS_REMOVED" = " %@ were removed from the group. "; +"GROUP_MEMBERS_REMOVED" = "%@ were removed from the group. "; /* No comment provided by engineer. */ "GROUP_TITLE_CHANGED" = "Title is now '%@'. "; /* No comment provided by engineer. */ diff --git a/Session/Meta/Translations/es.lproj/Localizable.strings b/Session/Meta/Translations/es.lproj/Localizable.strings index f8fea9e21..6ba0ab4f5 100644 --- a/Session/Meta/Translations/es.lproj/Localizable.strings +++ b/Session/Meta/Translations/es.lproj/Localizable.strings @@ -211,7 +211,7 @@ /* No comment provided by engineer. */ "GROUP_MEMBER_REMOVED" = " Fue eliminado del grupo. "; /* No comment provided by engineer. */ -"GROUP_MEMBERS_REMOVED" = " %@ fue eliminado del grupo. "; +"GROUP_MEMBERS_REMOVED" = "%@ fue eliminado del grupo. "; /* No comment provided by engineer. */ "GROUP_TITLE_CHANGED" = "El grupo se llama ahora «%@»."; /* No comment provided by engineer. */ diff --git a/Session/Meta/Translations/fa.lproj/Localizable.strings b/Session/Meta/Translations/fa.lproj/Localizable.strings index bfb89b840..710e5f4e7 100644 --- a/Session/Meta/Translations/fa.lproj/Localizable.strings +++ b/Session/Meta/Translations/fa.lproj/Localizable.strings @@ -209,9 +209,9 @@ /* No comment provided by engineer. */ "GROUP_MEMBER_LEFT" = "%@ از گروه خارج شد."; /* No comment provided by engineer. */ -"GROUP_MEMBER_REMOVED" = " %@ از گروه حذف شد. "; +"GROUP_MEMBER_REMOVED" = "%@ از گروه حذف شد. "; /* No comment provided by engineer. */ -"GROUP_MEMBERS_REMOVED" = " %@ از گروه حذف شدند. "; +"GROUP_MEMBERS_REMOVED" = "%@ از گروه حذف شدند. "; /* No comment provided by engineer. */ "GROUP_TITLE_CHANGED" = "عنوان ، هم‌اکنون '%@' است."; /* No comment provided by engineer. */ diff --git a/Session/Meta/Translations/fi.lproj/Localizable.strings b/Session/Meta/Translations/fi.lproj/Localizable.strings index b8c2baa27..c50297985 100644 --- a/Session/Meta/Translations/fi.lproj/Localizable.strings +++ b/Session/Meta/Translations/fi.lproj/Localizable.strings @@ -205,13 +205,13 @@ /* No comment provided by engineer. */ "GROUP_CREATED" = "Ryhmä luotu"; /* No comment provided by engineer. */ -"GROUP_MEMBER_JOINED" = " %@ liittyi ryhmään. "; +"GROUP_MEMBER_JOINED" = "%@ liittyi ryhmään. "; /* No comment provided by engineer. */ -"GROUP_MEMBER_LEFT" = " %@ poistui ryhmästä. "; +"GROUP_MEMBER_LEFT" = "%@ poistui ryhmästä. "; /* No comment provided by engineer. */ -"GROUP_MEMBER_REMOVED" = " %@ poistettiin ryhmästä. "; +"GROUP_MEMBER_REMOVED" = "%@ poistettiin ryhmästä. "; /* No comment provided by engineer. */ -"GROUP_MEMBERS_REMOVED" = " %@ poistettiin ryhmästä. "; +"GROUP_MEMBERS_REMOVED" = "%@ poistettiin ryhmästä. "; /* No comment provided by engineer. */ "GROUP_TITLE_CHANGED" = "Ryhmän nimi on nyt ”%@”. "; /* No comment provided by engineer. */ diff --git a/Session/Meta/Translations/fr.lproj/Localizable.strings b/Session/Meta/Translations/fr.lproj/Localizable.strings index 316b47828..bd4752b8b 100644 --- a/Session/Meta/Translations/fr.lproj/Localizable.strings +++ b/Session/Meta/Translations/fr.lproj/Localizable.strings @@ -209,9 +209,9 @@ /* No comment provided by engineer. */ "GROUP_MEMBER_LEFT" = "%@ a quitté le groupe."; /* No comment provided by engineer. */ -"GROUP_MEMBER_REMOVED" = " %@ a été retiré du groupe. "; +"GROUP_MEMBER_REMOVED" = "%@ a été retiré du groupe. "; /* No comment provided by engineer. */ -"GROUP_MEMBERS_REMOVED" = " %@ ont été retirés du groupe. "; +"GROUP_MEMBERS_REMOVED" = "%@ ont été retirés du groupe. "; /* No comment provided by engineer. */ "GROUP_TITLE_CHANGED" = "Le titre est maintenant « %@ »."; /* No comment provided by engineer. */ diff --git a/Session/Meta/Translations/hi.lproj/Localizable.strings b/Session/Meta/Translations/hi.lproj/Localizable.strings index b5a16e850..babc35e71 100644 --- a/Session/Meta/Translations/hi.lproj/Localizable.strings +++ b/Session/Meta/Translations/hi.lproj/Localizable.strings @@ -205,13 +205,13 @@ /* No comment provided by engineer. */ "GROUP_CREATED" = "Group created"; /* No comment provided by engineer. */ -"GROUP_MEMBER_JOINED" = " %@ joined the group. "; +"GROUP_MEMBER_JOINED" = "%@ joined the group. "; /* No comment provided by engineer. */ -"GROUP_MEMBER_LEFT" = " %@ left the group. "; +"GROUP_MEMBER_LEFT" = "%@ left the group. "; /* No comment provided by engineer. */ -"GROUP_MEMBER_REMOVED" = " %@ was removed from the group. "; +"GROUP_MEMBER_REMOVED" = "%@ was removed from the group. "; /* No comment provided by engineer. */ -"GROUP_MEMBERS_REMOVED" = " %@ were removed from the group. "; +"GROUP_MEMBERS_REMOVED" = "%@ were removed from the group. "; /* No comment provided by engineer. */ "GROUP_TITLE_CHANGED" = "Title is now '%@'. "; /* No comment provided by engineer. */ diff --git a/Session/Meta/Translations/hr.lproj/Localizable.strings b/Session/Meta/Translations/hr.lproj/Localizable.strings index f8f1d62e0..5705b0b19 100644 --- a/Session/Meta/Translations/hr.lproj/Localizable.strings +++ b/Session/Meta/Translations/hr.lproj/Localizable.strings @@ -205,13 +205,13 @@ /* No comment provided by engineer. */ "GROUP_CREATED" = "Kreirana Grupa"; /* No comment provided by engineer. */ -"GROUP_MEMBER_JOINED" = " %@ se pridružio grupi. "; +"GROUP_MEMBER_JOINED" = "%@ se pridružio grupi. "; /* No comment provided by engineer. */ -"GROUP_MEMBER_LEFT" = " %@ je napustio grupu. "; +"GROUP_MEMBER_LEFT" = "%@ je napustio grupu. "; /* No comment provided by engineer. */ -"GROUP_MEMBER_REMOVED" = " %@ je uklonjen iz grupe. "; +"GROUP_MEMBER_REMOVED" = "%@ je uklonjen iz grupe. "; /* No comment provided by engineer. */ -"GROUP_MEMBERS_REMOVED" = " %@ su uklonjeni iz grupe. "; +"GROUP_MEMBERS_REMOVED" = "%@ su uklonjeni iz grupe. "; /* No comment provided by engineer. */ "GROUP_TITLE_CHANGED" = "Naslov je sada %@. "; /* No comment provided by engineer. */ diff --git a/Session/Meta/Translations/id-ID.lproj/Localizable.strings b/Session/Meta/Translations/id-ID.lproj/Localizable.strings index 99b6fb43b..f6c91ddf2 100644 --- a/Session/Meta/Translations/id-ID.lproj/Localizable.strings +++ b/Session/Meta/Translations/id-ID.lproj/Localizable.strings @@ -209,9 +209,9 @@ /* No comment provided by engineer. */ "GROUP_MEMBER_LEFT" = "%@ keluar dari group."; /* No comment provided by engineer. */ -"GROUP_MEMBER_REMOVED" = " %@ telah dihapus dari grup. "; +"GROUP_MEMBER_REMOVED" = "%@ telah dihapus dari grup. "; /* No comment provided by engineer. */ -"GROUP_MEMBERS_REMOVED" = " %@ telah dihapus dari grup. "; +"GROUP_MEMBERS_REMOVED" = "%@ telah dihapus dari grup. "; /* No comment provided by engineer. */ "GROUP_TITLE_CHANGED" = "Topik baru saat ini '%@'."; /* No comment provided by engineer. */ diff --git a/Session/Meta/Translations/it.lproj/Localizable.strings b/Session/Meta/Translations/it.lproj/Localizable.strings index 85dadfe24..f73ace460 100644 --- a/Session/Meta/Translations/it.lproj/Localizable.strings +++ b/Session/Meta/Translations/it.lproj/Localizable.strings @@ -209,9 +209,9 @@ /* No comment provided by engineer. */ "GROUP_MEMBER_LEFT" = "%@ ha lasciato il gruppo."; /* No comment provided by engineer. */ -"GROUP_MEMBER_REMOVED" = " %@ è stato rimosso dal gruppo. "; +"GROUP_MEMBER_REMOVED" = "%@ è stato rimosso dal gruppo. "; /* No comment provided by engineer. */ -"GROUP_MEMBERS_REMOVED" = " %@ sono stati rimossi dal gruppo. "; +"GROUP_MEMBERS_REMOVED" = "%@ sono stati rimossi dal gruppo. "; /* No comment provided by engineer. */ "GROUP_TITLE_CHANGED" = "Il nuovo titolo è '%@'"; /* No comment provided by engineer. */ diff --git a/Session/Meta/Translations/ja.lproj/Localizable.strings b/Session/Meta/Translations/ja.lproj/Localizable.strings index 1df2e5d14..1717a4e37 100644 --- a/Session/Meta/Translations/ja.lproj/Localizable.strings +++ b/Session/Meta/Translations/ja.lproj/Localizable.strings @@ -209,9 +209,9 @@ /* No comment provided by engineer. */ "GROUP_MEMBER_LEFT" = "%@がグループを離れました"; /* No comment provided by engineer. */ -"GROUP_MEMBER_REMOVED" = " %@ はグループから削除されました。 "; +"GROUP_MEMBER_REMOVED" = "%@ はグループから削除されました。 "; /* No comment provided by engineer. */ -"GROUP_MEMBERS_REMOVED" = " %@ はグループから削除されました。 "; +"GROUP_MEMBERS_REMOVED" = "%@ はグループから削除されました。 "; /* No comment provided by engineer. */ "GROUP_TITLE_CHANGED" = "タイトルが「%@」に変更されました"; /* No comment provided by engineer. */ diff --git a/Session/Meta/Translations/nl.lproj/Localizable.strings b/Session/Meta/Translations/nl.lproj/Localizable.strings index d1a62522c..22ae40e3e 100644 --- a/Session/Meta/Translations/nl.lproj/Localizable.strings +++ b/Session/Meta/Translations/nl.lproj/Localizable.strings @@ -205,13 +205,13 @@ /* No comment provided by engineer. */ "GROUP_CREATED" = "Groep aangemaakt"; /* No comment provided by engineer. */ -"GROUP_MEMBER_JOINED" = " %@ is toegevoegd aan de groep. "; +"GROUP_MEMBER_JOINED" = "%@ is toegevoegd aan de groep. "; /* No comment provided by engineer. */ -"GROUP_MEMBER_LEFT" = " %@ heeft de groep verlaten. "; +"GROUP_MEMBER_LEFT" = "%@ heeft de groep verlaten. "; /* No comment provided by engineer. */ -"GROUP_MEMBER_REMOVED" = " %@ is verwijderd uit de groep. "; +"GROUP_MEMBER_REMOVED" = "%@ is verwijderd uit de groep. "; /* No comment provided by engineer. */ -"GROUP_MEMBERS_REMOVED" = " %@ zijn uit de groep verwijderd. "; +"GROUP_MEMBERS_REMOVED" = "%@ zijn uit de groep verwijderd. "; /* No comment provided by engineer. */ "GROUP_TITLE_CHANGED" = "Titel is nu '%@'. "; /* No comment provided by engineer. */ diff --git a/Session/Meta/Translations/pl.lproj/Localizable.strings b/Session/Meta/Translations/pl.lproj/Localizable.strings index fa8c724a6..9ee0cb99e 100644 --- a/Session/Meta/Translations/pl.lproj/Localizable.strings +++ b/Session/Meta/Translations/pl.lproj/Localizable.strings @@ -209,9 +209,9 @@ /* No comment provided by engineer. */ "GROUP_MEMBER_LEFT" = "%@ opuścił(a) grupę."; /* No comment provided by engineer. */ -"GROUP_MEMBER_REMOVED" = " %@ został usunięty z grupy. "; +"GROUP_MEMBER_REMOVED" = "%@ został usunięty z grupy. "; /* No comment provided by engineer. */ -"GROUP_MEMBERS_REMOVED" = " %@ zostali usunięci z grupy. "; +"GROUP_MEMBERS_REMOVED" = "%@ zostali usunięci z grupy. "; /* No comment provided by engineer. */ "GROUP_TITLE_CHANGED" = "Nowy tytuł to '%@'. "; /* No comment provided by engineer. */ diff --git a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings index ecbcf3d97..c74587594 100644 --- a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings +++ b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings @@ -209,9 +209,9 @@ /* No comment provided by engineer. */ "GROUP_MEMBER_LEFT" = "%@ saiu do grupo."; /* No comment provided by engineer. */ -"GROUP_MEMBER_REMOVED" = " %@ foi removido do grupo. "; +"GROUP_MEMBER_REMOVED" = "%@ foi removido do grupo. "; /* No comment provided by engineer. */ -"GROUP_MEMBERS_REMOVED" = " %@ foram removidos do grupo. "; +"GROUP_MEMBERS_REMOVED" = "%@ foram removidos do grupo. "; /* No comment provided by engineer. */ "GROUP_TITLE_CHANGED" = "O título agora é '%@'."; /* No comment provided by engineer. */ diff --git a/Session/Meta/Translations/ru.lproj/Localizable.strings b/Session/Meta/Translations/ru.lproj/Localizable.strings index 4376cec2e..16f8f85e2 100644 --- a/Session/Meta/Translations/ru.lproj/Localizable.strings +++ b/Session/Meta/Translations/ru.lproj/Localizable.strings @@ -209,9 +209,9 @@ /* No comment provided by engineer. */ "GROUP_MEMBER_LEFT" = "%@ покинул группу."; /* No comment provided by engineer. */ -"GROUP_MEMBER_REMOVED" = " %@ был удален из группы. "; +"GROUP_MEMBER_REMOVED" = "%@ был удален из группы. "; /* No comment provided by engineer. */ -"GROUP_MEMBERS_REMOVED" = " %@ были удалены из группы. "; +"GROUP_MEMBERS_REMOVED" = "%@ были удалены из группы. "; /* No comment provided by engineer. */ "GROUP_TITLE_CHANGED" = "Название изменено на «%@»."; /* No comment provided by engineer. */ diff --git a/Session/Meta/Translations/si.lproj/Localizable.strings b/Session/Meta/Translations/si.lproj/Localizable.strings index da6341fe9..6e6dbc59f 100644 --- a/Session/Meta/Translations/si.lproj/Localizable.strings +++ b/Session/Meta/Translations/si.lproj/Localizable.strings @@ -205,13 +205,13 @@ /* No comment provided by engineer. */ "GROUP_CREATED" = "Group created"; /* No comment provided by engineer. */ -"GROUP_MEMBER_JOINED" = " %@ joined the group. "; +"GROUP_MEMBER_JOINED" = "%@ joined the group. "; /* No comment provided by engineer. */ -"GROUP_MEMBER_LEFT" = " %@ left the group. "; +"GROUP_MEMBER_LEFT" = "%@ left the group. "; /* No comment provided by engineer. */ -"GROUP_MEMBER_REMOVED" = " %@ was removed from the group. "; +"GROUP_MEMBER_REMOVED" = "%@ was removed from the group. "; /* No comment provided by engineer. */ -"GROUP_MEMBERS_REMOVED" = " %@ were removed from the group. "; +"GROUP_MEMBERS_REMOVED" = "%@ were removed from the group. "; /* No comment provided by engineer. */ "GROUP_TITLE_CHANGED" = "Title is now '%@'. "; /* No comment provided by engineer. */ diff --git a/Session/Meta/Translations/sk.lproj/Localizable.strings b/Session/Meta/Translations/sk.lproj/Localizable.strings index ca28d4992..49fae9bf2 100644 --- a/Session/Meta/Translations/sk.lproj/Localizable.strings +++ b/Session/Meta/Translations/sk.lproj/Localizable.strings @@ -205,13 +205,13 @@ /* No comment provided by engineer. */ "GROUP_CREATED" = "Skupina vytvorená"; /* No comment provided by engineer. */ -"GROUP_MEMBER_JOINED" = " %@ sa pripojil/a ku skupine. "; +"GROUP_MEMBER_JOINED" = "%@ sa pripojil/a ku skupine. "; /* No comment provided by engineer. */ -"GROUP_MEMBER_LEFT" = " %@ opustil/a skupinu. "; +"GROUP_MEMBER_LEFT" = "%@ opustil/a skupinu. "; /* No comment provided by engineer. */ -"GROUP_MEMBER_REMOVED" = " %@ bol/a odstránený/á zo skupiny. "; +"GROUP_MEMBER_REMOVED" = "%@ bol/a odstránený/á zo skupiny. "; /* No comment provided by engineer. */ -"GROUP_MEMBERS_REMOVED" = " %@ boli odstránení zo skupiny. "; +"GROUP_MEMBERS_REMOVED" = "%@ boli odstránení zo skupiny. "; /* No comment provided by engineer. */ "GROUP_TITLE_CHANGED" = "Názov je teraz '%@'. "; /* No comment provided by engineer. */ diff --git a/Session/Meta/Translations/sv.lproj/Localizable.strings b/Session/Meta/Translations/sv.lproj/Localizable.strings index 7c43781f2..7bc60b944 100644 --- a/Session/Meta/Translations/sv.lproj/Localizable.strings +++ b/Session/Meta/Translations/sv.lproj/Localizable.strings @@ -205,13 +205,13 @@ /* No comment provided by engineer. */ "GROUP_CREATED" = "Grupp skapad"; /* No comment provided by engineer. */ -"GROUP_MEMBER_JOINED" = " %@ gick med i gruppen. "; +"GROUP_MEMBER_JOINED" = "%@ gick med i gruppen. "; /* No comment provided by engineer. */ -"GROUP_MEMBER_LEFT" = " %@ lämnade gruppen. "; +"GROUP_MEMBER_LEFT" = "%@ lämnade gruppen. "; /* No comment provided by engineer. */ -"GROUP_MEMBER_REMOVED" = " %@ togs bort från gruppen. "; +"GROUP_MEMBER_REMOVED" = "%@ togs bort från gruppen. "; /* No comment provided by engineer. */ -"GROUP_MEMBERS_REMOVED" = " %@ togs bort från gruppen. "; +"GROUP_MEMBERS_REMOVED" = "%@ togs bort från gruppen. "; /* No comment provided by engineer. */ "GROUP_TITLE_CHANGED" = "Titeln är nu '%@'. "; /* No comment provided by engineer. */ diff --git a/Session/Meta/Translations/th.lproj/Localizable.strings b/Session/Meta/Translations/th.lproj/Localizable.strings index 5db23ddf5..9095870a8 100644 --- a/Session/Meta/Translations/th.lproj/Localizable.strings +++ b/Session/Meta/Translations/th.lproj/Localizable.strings @@ -205,13 +205,13 @@ /* No comment provided by engineer. */ "GROUP_CREATED" = "สร้างกลุ่มแล้ว"; /* No comment provided by engineer. */ -"GROUP_MEMBER_JOINED" = " %@ ได้เข้าร่วมกลุ่ม"; +"GROUP_MEMBER_JOINED" = "%@ ได้เข้าร่วมกลุ่ม"; /* No comment provided by engineer. */ -"GROUP_MEMBER_LEFT" = " %@ ออกจากกลุ่ม "; +"GROUP_MEMBER_LEFT" = "%@ ออกจากกลุ่ม "; /* No comment provided by engineer. */ -"GROUP_MEMBER_REMOVED" = " %@ ถูกลบออกจากกลุ่ม "; +"GROUP_MEMBER_REMOVED" = "%@ ถูกลบออกจากกลุ่ม "; /* No comment provided by engineer. */ -"GROUP_MEMBERS_REMOVED" = " %@ ถูกลบออกจากกลุ่ม "; +"GROUP_MEMBERS_REMOVED" = "%@ ถูกลบออกจากกลุ่ม "; /* No comment provided by engineer. */ "GROUP_TITLE_CHANGED" = "ชื่อเรื่องเปลี่ยนเป็น %@ แล้ว "; /* No comment provided by engineer. */ diff --git a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings index b3bf3ae22..6b4204a04 100644 --- a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings +++ b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings @@ -205,13 +205,13 @@ /* No comment provided by engineer. */ "GROUP_CREATED" = "Group created"; /* No comment provided by engineer. */ -"GROUP_MEMBER_JOINED" = " %@ joined the group. "; +"GROUP_MEMBER_JOINED" = "%@ joined the group. "; /* No comment provided by engineer. */ -"GROUP_MEMBER_LEFT" = " %@ left the group. "; +"GROUP_MEMBER_LEFT" = "%@ left the group. "; /* No comment provided by engineer. */ -"GROUP_MEMBER_REMOVED" = " %@ was removed from the group. "; +"GROUP_MEMBER_REMOVED" = "%@ was removed from the group. "; /* No comment provided by engineer. */ -"GROUP_MEMBERS_REMOVED" = " %@ were removed from the group. "; +"GROUP_MEMBERS_REMOVED" = "%@ were removed from the group. "; /* No comment provided by engineer. */ "GROUP_TITLE_CHANGED" = "Title is now '%@'. "; /* No comment provided by engineer. */ diff --git a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings index fcaa2cc96..5f1146f43 100644 --- a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings @@ -205,13 +205,13 @@ /* No comment provided by engineer. */ "GROUP_CREATED" = "已創立群組"; /* No comment provided by engineer. */ -"GROUP_MEMBER_JOINED" = " %@ 已加入群組。 "; +"GROUP_MEMBER_JOINED" = "%@ 已加入群組。 "; /* No comment provided by engineer. */ -"GROUP_MEMBER_LEFT" = " %@ 已離開群組。 "; +"GROUP_MEMBER_LEFT" = "%@ 已離開群組。 "; /* No comment provided by engineer. */ -"GROUP_MEMBER_REMOVED" = " %@ 被踢出群組。 "; +"GROUP_MEMBER_REMOVED" = "%@ 被踢出群組。 "; /* No comment provided by engineer. */ -"GROUP_MEMBERS_REMOVED" = " %@ 已被群組踢出 "; +"GROUP_MEMBERS_REMOVED" = "%@ 已被群組踢出 "; /* No comment provided by engineer. */ "GROUP_TITLE_CHANGED" = "標題已更改為 ‘%@‘ "; /* No comment provided by engineer. */ diff --git a/Session/Settings/ShareLogsModal.swift b/Session/Settings/ShareLogsModal.swift index 10f3f799e..0a93acff1 100644 --- a/Session/Settings/ShareLogsModal.swift +++ b/Session/Settings/ShareLogsModal.swift @@ -64,7 +64,16 @@ final class ShareLogsModal : Modal { if let latestLogFilePath = logFilePaths.first { let latestLogFileURL = URL(fileURLWithPath: latestLogFilePath) self.dismiss(animated: true, completion: { - AttachmentSharing.showShareUI(for: latestLogFileURL) + if let vc = CurrentAppContext().frontmostViewController() { + let shareVC = UIActivityViewController(activityItems: [ latestLogFileURL ], applicationActivities: nil) + if UIDevice.current.isIPad { + shareVC.excludedActivityTypes = [] + shareVC.popoverPresentationController?.permittedArrowDirections = [] + shareVC.popoverPresentationController?.sourceView = vc.view + shareVC.popoverPresentationController?.sourceRect = vc.view.bounds + } + vc.present(shareVC, animated: true, completion: nil) + } }) } } diff --git a/Session/Shared/ConversationCell.swift b/Session/Shared/ConversationCell.swift deleted file mode 100644 index 821b130fb..000000000 --- a/Session/Shared/ConversationCell.swift +++ /dev/null @@ -1,553 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import UIKit -import SessionUIKit -import SignalUtilitiesKit - -public final class ConversationCell: UITableViewCell { - // MARK: - UI - - private let accentLineView: UIView = UIView() - - private lazy var profilePictureView: ProfilePictureView = ProfilePictureView() - - private lazy var displayNameLabel: UILabel = { - let result: UILabel = UILabel() - result.font = .boldSystemFont(ofSize: Values.mediumFontSize) - result.textColor = Colors.text - result.lineBreakMode = .byTruncatingTail - - return result - }() - - private lazy var unreadCountView: UIView = { - let result: UIView = UIView() - result.backgroundColor = Colors.text.withAlphaComponent(Values.veryLowOpacity) - let size = ConversationCell.unreadCountViewSize - result.set(.width, greaterThanOrEqualTo: size) - result.set(.height, to: size) - result.layer.masksToBounds = true - result.layer.cornerRadius = (size / 2) - - return result - }() - - private lazy var unreadCountLabel: UILabel = { - let result: UILabel = UILabel() - result.font = .boldSystemFont(ofSize: Values.verySmallFontSize) - result.textColor = Colors.text - result.textAlignment = .center - - return result - }() - - private lazy var hasMentionView: UIView = { - let result: UIView = UIView() - result.backgroundColor = Colors.accent - let size = ConversationCell.unreadCountViewSize - result.set(.width, to: size) - result.set(.height, to: size) - result.layer.masksToBounds = true - result.layer.cornerRadius = (size / 2) - - return result - }() - - private lazy var hasMentionLabel: UILabel = { - let result: UILabel = UILabel() - result.font = .boldSystemFont(ofSize: Values.verySmallFontSize) - result.textColor = Colors.text - result.text = "@" - result.textAlignment = .center - - return result - }() - - private lazy var isPinnedIcon: UIImageView = { - let result: UIImageView = UIImageView(image: UIImage(named: "Pin")?.withRenderingMode(.alwaysTemplate)) - result.contentMode = .scaleAspectFit - let size = ConversationCell.unreadCountViewSize - result.set(.width, to: size) - result.set(.height, to: size) - result.tintColor = Colors.pinIcon - result.layer.masksToBounds = true - - return result - }() - - private lazy var timestampLabel: UILabel = { - let result: UILabel = UILabel() - result.font = .systemFont(ofSize: Values.smallFontSize) - result.textColor = Colors.text - result.lineBreakMode = .byTruncatingTail - result.alpha = Values.lowOpacity - - return result - }() - - private lazy var snippetLabel: UILabel = { - let result: UILabel = UILabel() - result.font = .systemFont(ofSize: Values.smallFontSize) - result.textColor = Colors.text - result.lineBreakMode = .byTruncatingTail - - return result - }() - - private lazy var typingIndicatorView = TypingIndicatorView() - - private lazy var statusIndicatorView: UIImageView = { - let result: UIImageView = UIImageView() - result.contentMode = .scaleAspectFit - result.layer.cornerRadius = (ConversationCell.statusIndicatorSize / 2) - result.layer.masksToBounds = true - - return result - }() - - private lazy var topLabelStackView: UIStackView = { - let result: UIStackView = UIStackView() - result.axis = .horizontal - result.alignment = .center - result.spacing = Values.smallSpacing / 2 // Effectively Values.smallSpacing because there'll be spacing before and after the invisible spacer - - return result - }() - - private lazy var bottomLabelStackView: UIStackView = { - let result: UIStackView = UIStackView() - result.axis = .horizontal - result.alignment = .center - result.spacing = Values.smallSpacing / 2 // Effectively Values.smallSpacing because there'll be spacing before and after the invisible spacer - - return result - }() - - // MARK: Settings - - public static let unreadCountViewSize: CGFloat = 20 - private static let statusIndicatorSize: CGFloat = 14 - - // MARK: - Initialization - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - setUpViewHierarchy() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - setUpViewHierarchy() - } - - private func setUpViewHierarchy() { - let cellHeight: CGFloat = 68 - - // Background color - backgroundColor = Colors.cellBackground - - // Highlight color - let selectedBackgroundView = UIView() - selectedBackgroundView.backgroundColor = Colors.cellSelected - self.selectedBackgroundView = selectedBackgroundView - - // Accent line view - accentLineView.set(.width, to: Values.accentLineThickness) - accentLineView.set(.height, to: cellHeight) - - // Profile picture view - let profilePictureViewSize = Values.mediumProfilePictureSize - profilePictureView.set(.width, to: profilePictureViewSize) - profilePictureView.set(.height, to: profilePictureViewSize) - profilePictureView.size = profilePictureViewSize - - // Unread count view - unreadCountView.addSubview(unreadCountLabel) - unreadCountLabel.pin([ VerticalEdge.top, VerticalEdge.bottom ], to: unreadCountView) - unreadCountView.pin(.leading, to: .leading, of: unreadCountLabel, withInset: -4) - unreadCountView.pin(.trailing, to: .trailing, of: unreadCountLabel, withInset: 4) - - // Has mention view - hasMentionView.addSubview(hasMentionLabel) - hasMentionLabel.pin(to: hasMentionView) - - // Label stack view - let topLabelSpacer = UIView.hStretchingSpacer() - [ displayNameLabel, isPinnedIcon, unreadCountView, hasMentionView, topLabelSpacer, timestampLabel ].forEach{ view in - topLabelStackView.addArrangedSubview(view) - } - - let snippetLabelContainer = UIView() - snippetLabelContainer.addSubview(snippetLabel) - snippetLabelContainer.addSubview(typingIndicatorView) - - let bottomLabelSpacer = UIView.hStretchingSpacer() - [ snippetLabelContainer, bottomLabelSpacer, statusIndicatorView ].forEach{ view in - bottomLabelStackView.addArrangedSubview(view) - } - - let labelContainerView = UIStackView(arrangedSubviews: [ topLabelStackView, bottomLabelStackView ]) - labelContainerView.axis = .vertical - labelContainerView.alignment = .leading - labelContainerView.spacing = 6 - labelContainerView.isUserInteractionEnabled = false - - // Main stack view - let stackView = UIStackView(arrangedSubviews: [ accentLineView, profilePictureView, labelContainerView ]) - stackView.axis = .horizontal - stackView.alignment = .center - stackView.spacing = Values.mediumSpacing - contentView.addSubview(stackView) - - // Constraints - accentLineView.pin(.top, to: .top, of: contentView) - accentLineView.pin(.bottom, to: .bottom, of: contentView) - timestampLabel.setContentCompressionResistancePriority(.required, for: NSLayoutConstraint.Axis.horizontal) - - // HACK: The six lines below are part of a workaround for a weird layout bug - topLabelStackView.set(.width, to: UIScreen.main.bounds.width - Values.accentLineThickness - profilePictureViewSize - 3 * Values.mediumSpacing) - topLabelStackView.set(.height, to: 20) - topLabelSpacer.set(.height, to: 20) - - bottomLabelStackView.set(.width, to: UIScreen.main.bounds.width - Values.accentLineThickness - profilePictureViewSize - 3 * Values.mediumSpacing) - bottomLabelStackView.set(.height, to: 18) - bottomLabelSpacer.set(.height, to: 18) - - statusIndicatorView.set(.width, to: ConversationCell.statusIndicatorSize) - statusIndicatorView.set(.height, to: ConversationCell.statusIndicatorSize) - - snippetLabel.pin(to: snippetLabelContainer) - - typingIndicatorView.pin(.leading, to: .leading, of: snippetLabelContainer) - typingIndicatorView.centerYAnchor.constraint(equalTo: snippetLabel.centerYAnchor).isActive = true - - stackView.pin(.leading, to: .leading, of: contentView) - stackView.pin(.top, to: .top, of: contentView) - - // HACK: The two lines below are part of a workaround for a weird layout bug - stackView.set(.width, to: UIScreen.main.bounds.width - Values.mediumSpacing) - stackView.set(.height, to: cellHeight) - } - - // MARK: - Content - - // MARK: --Search Results - - public func updateForMessageSearchResult(with cellViewModel: ViewModel, searchText: String) { - profilePictureView.update( - publicKey: cellViewModel.threadId, - profile: cellViewModel.profile, - additionalProfile: cellViewModel.additionalProfile, - threadVariant: cellViewModel.threadVariant, - openGroupProfilePicture: cellViewModel.openGroupProfilePictureData.map { UIImage(data: $0) }, - useFallbackPicture: (cellViewModel.threadVariant == .openGroup && cellViewModel.openGroupProfilePictureData == nil) - ) - - isPinnedIcon.isHidden = true - unreadCountView.isHidden = true - hasMentionView.isHidden = true - displayNameLabel.attributedText = NSMutableAttributedString( - string: cellViewModel.displayName, - attributes: [ .foregroundColor: Colors.text] - ) - timestampLabel.isHidden = false - timestampLabel.text = DateUtil.formatDate(forDisplay: cellViewModel.lastInteractionDate) - bottomLabelStackView.isHidden = false - snippetLabel.attributedText = getHighlightedSnippet( - content: (cellViewModel.interactionBody ?? ""), - authorName: (cellViewModel.authorId != cellViewModel.currentUserPublicKey ? - cellViewModel.authorName(for: .contact) : - nil - ), - searchText: searchText.lowercased(), - fontSize: Values.smallFontSize - ) - } - - public func updateForContactAndGroupSearchResult(with cellViewModel: ViewModel, searchText: String) { - profilePictureView.update( - publicKey: cellViewModel.threadId, - profile: cellViewModel.profile, - additionalProfile: cellViewModel.additionalProfile, - threadVariant: cellViewModel.threadVariant, - openGroupProfilePicture: cellViewModel.openGroupProfilePictureData.map { UIImage(data: $0) }, - useFallbackPicture: (cellViewModel.threadVariant == .openGroup && cellViewModel.openGroupProfilePictureData == nil) - ) - - isPinnedIcon.isHidden = true - unreadCountView.isHidden = true - hasMentionView.isHidden = true - timestampLabel.isHidden = true - displayNameLabel.attributedText = getHighlightedSnippet( - content: cellViewModel.displayName, - searchText: searchText.lowercased(), - fontSize: Values.mediumFontSize - ) - - switch cellViewModel.threadVariant { - case .contact, .openGroup: bottomLabelStackView.isHidden = true - - case .closedGroup: - bottomLabelStackView.isHidden = (cellViewModel.threadMemberNames ?? "").isEmpty - snippetLabel.attributedText = getHighlightedSnippet( - content: (cellViewModel.threadMemberNames ?? ""), - searchText: searchText.lowercased(), - fontSize: Values.smallFontSize - ) - } - } - - // MARK: --Standard - - public func update(with cellViewModel: ViewModel) { - let unreadCount: UInt = (cellViewModel.threadUnreadCount ?? 0) - backgroundColor = (cellViewModel.threadIsPinned ? Colors.cellPinned : Colors.cellBackground) - - if cellViewModel.threadIsBlocked == true { - accentLineView.backgroundColor = Colors.destructive - accentLineView.alpha = 1 - } - else { - accentLineView.backgroundColor = Colors.accent - accentLineView.alpha = (unreadCount > 0 ? 1 : 0.0001) // Setting the alpha to exactly 0 causes an issue on iOS 12 - } - - isPinnedIcon.isHidden = !cellViewModel.threadIsPinned - unreadCountView.isHidden = (unreadCount <= 0) - unreadCountLabel.text = (unreadCount < 10000 ? "\(unreadCount)" : "9999+") - unreadCountLabel.font = .boldSystemFont( - ofSize: (unreadCount < 10000 ? Values.verySmallFontSize : 8) - ) - hasMentionView.isHidden = !( - ((cellViewModel.threadUnreadMentionCount ?? 0) > 0) && - (cellViewModel.threadVariant == .closedGroup || cellViewModel.threadVariant == .openGroup) - ) - profilePictureView.update( - publicKey: cellViewModel.threadId, - profile: cellViewModel.profile, - additionalProfile: cellViewModel.additionalProfile, - threadVariant: cellViewModel.threadVariant, - openGroupProfilePicture: cellViewModel.openGroupProfilePictureData.map { UIImage(data: $0) }, - useFallbackPicture: ( - cellViewModel.threadVariant == .openGroup && - cellViewModel.openGroupProfilePictureData == nil - ) - ) - displayNameLabel.text = cellViewModel.displayName - timestampLabel.text = DateUtil.formatDate(forDisplay: cellViewModel.lastInteractionDate) - - if cellViewModel.threadContactIsTyping == true { - snippetLabel.text = "" - typingIndicatorView.isHidden = false - typingIndicatorView.startAnimation() - } - else { - snippetLabel.attributedText = getSnippet(cellViewModel: cellViewModel) - typingIndicatorView.isHidden = true - typingIndicatorView.stopAnimation() - } - - statusIndicatorView.backgroundColor = nil - - switch (cellViewModel.interactionVariant, cellViewModel.interactionState) { - case (.standardOutgoing, .sending): - statusIndicatorView.image = #imageLiteral(resourceName: "CircleDotDotDot").withRenderingMode(.alwaysTemplate) - statusIndicatorView.tintColor = Colors.text - statusIndicatorView.isHidden = false - - case (.standardOutgoing, .sent): - statusIndicatorView.image = #imageLiteral(resourceName: "CircleCheck").withRenderingMode(.alwaysTemplate) - statusIndicatorView.tintColor = Colors.text - statusIndicatorView.isHidden = false - - case (.standardOutgoing, .failed): - statusIndicatorView.image = #imageLiteral(resourceName: "message_status_failed").withRenderingMode(.alwaysTemplate) - statusIndicatorView.tintColor = Colors.destructive - statusIndicatorView.isHidden = false - - default: - statusIndicatorView.isHidden = false - } - } - - // MARK: - Snippet generation - - private func getSnippet(cellViewModel: ViewModel) -> NSMutableAttributedString { - let result = NSMutableAttributedString() - - if Date().timeIntervalSince1970 < (cellViewModel.threadMutedUntilTimestamp ?? 0) { - result.append(NSAttributedString( - string: "\u{e067} ", - attributes: [ - .font: UIFont.ows_elegantIconsFont(10), - .foregroundColor :Colors.unimportant - ] - )) - } - else if cellViewModel.threadOnlyNotifyForMentions == true { - let imageAttachment = NSTextAttachment() - imageAttachment.image = UIImage(named: "NotifyMentions.png")?.asTintedImage(color: Colors.unimportant) - imageAttachment.bounds = CGRect(x: 0, y: -2, width: Values.smallFontSize, height: Values.smallFontSize) - - let imageString = NSAttributedString(attachment: imageAttachment) - result.append(imageString) - result.append(NSAttributedString( - string: " ", - attributes: [ - .font: UIFont.ows_elegantIconsFont(10), - .foregroundColor: Colors.unimportant - ] - )) - } - - let font: UIFont = ((cellViewModel.threadUnreadCount ?? 0) > 0 ? - .boldSystemFont(ofSize: Values.smallFontSize) : - .systemFont(ofSize: Values.smallFontSize) - ) - - if cellViewModel.threadVariant == .closedGroup || cellViewModel.threadVariant == .openGroup { - let authorName: String = cellViewModel.authorName(for: cellViewModel.threadVariant) - - result.append(NSAttributedString( - string: "\(authorName): ", - attributes: [ - .font: font, - .foregroundColor: Colors.text - ] - )) - } - - result.append(NSAttributedString( - string: MentionUtilities.highlightMentions( - in: Interaction.previewText( - variant: (cellViewModel.interactionVariant ?? .standardIncoming), - body: cellViewModel.interactionBody, - authorDisplayName: cellViewModel.authorName(for: cellViewModel.threadVariant), - attachmentDescriptionInfo: cellViewModel.interactionAttachmentDescriptionInfo, - attachmentCount: cellViewModel.interactionAttachmentCount, - isOpenGroupInvitation: (cellViewModel.interactionIsOpenGroupInvitation == true) - ), - threadVariant: cellViewModel.threadVariant - ), - attributes: [ - .font: font, - .foregroundColor: Colors.text - ] - )) - - return result - } - - private func getHighlightedSnippet( - content: String, - authorName: String? = nil, - searchText: String, - fontSize: CGFloat - ) -> NSAttributedString { - guard !content.isEmpty, content != "NOTE_TO_SELF".localized() else { - return NSMutableAttributedString( - string: (authorName != nil && authorName?.isEmpty != true ? - "\(authorName ?? ""): \(content)" : - content - ), - attributes: [ .foregroundColor: Colors.text ] - ) - } - - // Replace mentions in the content - // - // Note: The 'threadVariant' is used for profile context but in the search results - // we don't want to include the truncated id as part of the name so we exclude it - let mentionReplacedContent: String = MentionUtilities.highlightMentions( - in: content, - threadVariant: .contact - ) - let result: NSMutableAttributedString = NSMutableAttributedString( - string: mentionReplacedContent, - attributes: [ - .foregroundColor: Colors.text - .withAlphaComponent(Values.lowOpacity) - ] - ) - - // Bold each part of the searh term which matched - let normalizedSnippet: String = mentionReplacedContent.lowercased() - var firstMatchRange: Range? - - ConversationCell.ViewModel.searchTermParts(searchText) - .map { part -> String in - guard part.hasPrefix("\"") && part.hasSuffix("\"") else { return part } - - return String(part[part.index(after: part.startIndex).. = normalizedSnippet.range(of: part.lowercased()) - else { return } - - // Store the range of the first match so we can focus it in the content displayed - if firstMatchRange == nil { - firstMatchRange = range - } - - let legacyRange: NSRange = NSRange(range, in: normalizedSnippet) - result.addAttribute(.foregroundColor, value: Colors.text, range: legacyRange) - result.addAttribute(.font, value: UIFont.boldSystemFont(ofSize: fontSize), range: legacyRange) - } - - // We then want to truncate the content so the first metching term is visible - let startOfSnippet: String.Index = ( - firstMatchRange.map { - max( - mentionReplacedContent.startIndex, - mentionReplacedContent - .index( - $0.lowerBound, - offsetBy: -10, - limitedBy: mentionReplacedContent.startIndex - ) - .defaulting(to: mentionReplacedContent.startIndex) - ) - } ?? - mentionReplacedContent.startIndex - ) - - // This method determines if the content is probably too long and returns the truncated or untruncated - // content accordingly - func truncatingIfNeeded(approxWidth: CGFloat, content: NSAttributedString) -> NSAttributedString { - let approxFullWidth: CGFloat = (approxWidth + profilePictureView.size + (Values.mediumSpacing * 3)) - - guard ((bounds.width - approxFullWidth) < 0) else { return content } - - return content.attributedSubstring( - from: NSRange(startOfSnippet.. NSAttributedString? in - guard !authorName.isEmpty else { return nil } - - let authorPrefix: NSAttributedString = NSAttributedString( - string: "\(authorName): ...", - attributes: [ .foregroundColor: Colors.text ] - ) - - return authorPrefix - .appending( - truncatingIfNeeded( - approxWidth: (authorPrefix.size().width + result.size().width), - content: result - ) - ) - } - .defaulting( - to: truncatingIfNeeded( - approxWidth: result.size().width, - content: result - ) - ) - } -} diff --git a/Session/Shared/FullConversationCell.swift b/Session/Shared/FullConversationCell.swift new file mode 100644 index 000000000..a906c9320 --- /dev/null +++ b/Session/Shared/FullConversationCell.swift @@ -0,0 +1,570 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SessionUIKit +import SignalUtilitiesKit +import SessionMessagingKit + +public extension ConversationCell { + public final class Full: UITableViewCell { + // MARK: - UI + + private let accentLineView: UIView = UIView() + + private lazy var profilePictureView: ProfilePictureView = ProfilePictureView() + + private lazy var displayNameLabel: UILabel = { + let result: UILabel = UILabel() + result.font = .boldSystemFont(ofSize: Values.mediumFontSize) + result.textColor = Colors.text + result.lineBreakMode = .byTruncatingTail + + return result + }() + + private lazy var unreadCountView: UIView = { + let result: UIView = UIView() + result.backgroundColor = Colors.text.withAlphaComponent(Values.veryLowOpacity) + let size = ConversationCell.Full.unreadCountViewSize + result.set(.width, greaterThanOrEqualTo: size) + result.set(.height, to: size) + result.layer.masksToBounds = true + result.layer.cornerRadius = (size / 2) + + return result + }() + + private lazy var unreadCountLabel: UILabel = { + let result: UILabel = UILabel() + result.font = .boldSystemFont(ofSize: Values.verySmallFontSize) + result.textColor = Colors.text + result.textAlignment = .center + + return result + }() + + private lazy var hasMentionView: UIView = { + let result: UIView = UIView() + result.backgroundColor = Colors.accent + let size = ConversationCell.Full.unreadCountViewSize + result.set(.width, to: size) + result.set(.height, to: size) + result.layer.masksToBounds = true + result.layer.cornerRadius = (size / 2) + + return result + }() + + private lazy var hasMentionLabel: UILabel = { + let result: UILabel = UILabel() + result.font = .boldSystemFont(ofSize: Values.verySmallFontSize) + result.textColor = Colors.text + result.text = "@" + result.textAlignment = .center + + return result + }() + + private lazy var isPinnedIcon: UIImageView = { + let result: UIImageView = UIImageView(image: UIImage(named: "Pin")?.withRenderingMode(.alwaysTemplate)) + result.contentMode = .scaleAspectFit + let size = ConversationCell.Full.unreadCountViewSize + result.set(.width, to: size) + result.set(.height, to: size) + result.tintColor = Colors.pinIcon + result.layer.masksToBounds = true + + return result + }() + + private lazy var timestampLabel: UILabel = { + let result: UILabel = UILabel() + result.font = .systemFont(ofSize: Values.smallFontSize) + result.textColor = Colors.text + result.lineBreakMode = .byTruncatingTail + result.alpha = Values.lowOpacity + + return result + }() + + private lazy var snippetLabel: UILabel = { + let result: UILabel = UILabel() + result.font = .systemFont(ofSize: Values.smallFontSize) + result.textColor = Colors.text + result.lineBreakMode = .byTruncatingTail + + return result + }() + + private lazy var typingIndicatorView = TypingIndicatorView() + + private lazy var statusIndicatorView: UIImageView = { + let result: UIImageView = UIImageView() + result.contentMode = .scaleAspectFit + result.layer.cornerRadius = (ConversationCell.Full.statusIndicatorSize / 2) + result.layer.masksToBounds = true + + return result + }() + + private lazy var topLabelStackView: UIStackView = { + let result: UIStackView = UIStackView() + result.axis = .horizontal + result.alignment = .center + result.spacing = Values.smallSpacing / 2 // Effectively Values.smallSpacing because there'll be spacing before and after the invisible spacer + + return result + }() + + private lazy var bottomLabelStackView: UIStackView = { + let result: UIStackView = UIStackView() + result.axis = .horizontal + result.alignment = .center + result.spacing = Values.smallSpacing / 2 // Effectively Values.smallSpacing because there'll be spacing before and after the invisible spacer + + return result + }() + + // MARK: Settings + + public static let unreadCountViewSize: CGFloat = 20 + private static let statusIndicatorSize: CGFloat = 14 + + // MARK: - Initialization + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setUpViewHierarchy() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setUpViewHierarchy() + } + + private func setUpViewHierarchy() { + let cellHeight: CGFloat = 68 + + // Background color + backgroundColor = Colors.cellBackground + + // Highlight color + let selectedBackgroundView = UIView() + selectedBackgroundView.backgroundColor = Colors.cellSelected + self.selectedBackgroundView = selectedBackgroundView + + // Accent line view + accentLineView.set(.width, to: Values.accentLineThickness) + accentLineView.set(.height, to: cellHeight) + + // Profile picture view + let profilePictureViewSize = Values.mediumProfilePictureSize + profilePictureView.set(.width, to: profilePictureViewSize) + profilePictureView.set(.height, to: profilePictureViewSize) + profilePictureView.size = profilePictureViewSize + + // Unread count view + unreadCountView.addSubview(unreadCountLabel) + unreadCountLabel.pin([ VerticalEdge.top, VerticalEdge.bottom ], to: unreadCountView) + unreadCountView.pin(.leading, to: .leading, of: unreadCountLabel, withInset: -4) + unreadCountView.pin(.trailing, to: .trailing, of: unreadCountLabel, withInset: 4) + + // Has mention view + hasMentionView.addSubview(hasMentionLabel) + hasMentionLabel.pin(to: hasMentionView) + + // Label stack view + let topLabelSpacer = UIView.hStretchingSpacer() + [ displayNameLabel, isPinnedIcon, unreadCountView, hasMentionView, topLabelSpacer, timestampLabel ].forEach{ view in + topLabelStackView.addArrangedSubview(view) + } + + let snippetLabelContainer = UIView() + snippetLabelContainer.addSubview(snippetLabel) + snippetLabelContainer.addSubview(typingIndicatorView) + + let bottomLabelSpacer = UIView.hStretchingSpacer() + [ snippetLabelContainer, bottomLabelSpacer, statusIndicatorView ].forEach{ view in + bottomLabelStackView.addArrangedSubview(view) + } + + let labelContainerView = UIStackView(arrangedSubviews: [ topLabelStackView, bottomLabelStackView ]) + labelContainerView.axis = .vertical + labelContainerView.alignment = .leading + labelContainerView.spacing = 6 + labelContainerView.isUserInteractionEnabled = false + + // Main stack view + let stackView = UIStackView(arrangedSubviews: [ accentLineView, profilePictureView, labelContainerView ]) + stackView.axis = .horizontal + stackView.alignment = .center + stackView.spacing = Values.mediumSpacing + contentView.addSubview(stackView) + + // Constraints + accentLineView.pin(.top, to: .top, of: contentView) + accentLineView.pin(.bottom, to: .bottom, of: contentView) + timestampLabel.setContentCompressionResistancePriority(.required, for: NSLayoutConstraint.Axis.horizontal) + + // HACK: The six lines below are part of a workaround for a weird layout bug + topLabelStackView.set(.width, to: UIScreen.main.bounds.width - Values.accentLineThickness - profilePictureViewSize - 3 * Values.mediumSpacing) + topLabelStackView.set(.height, to: 20) + topLabelSpacer.set(.height, to: 20) + + bottomLabelStackView.set(.width, to: UIScreen.main.bounds.width - Values.accentLineThickness - profilePictureViewSize - 3 * Values.mediumSpacing) + bottomLabelStackView.set(.height, to: 18) + bottomLabelSpacer.set(.height, to: 18) + + statusIndicatorView.set(.width, to: ConversationCell.Full.statusIndicatorSize) + statusIndicatorView.set(.height, to: ConversationCell.Full.statusIndicatorSize) + + snippetLabel.pin(to: snippetLabelContainer) + + typingIndicatorView.pin(.leading, to: .leading, of: snippetLabelContainer) + typingIndicatorView.centerYAnchor.constraint(equalTo: snippetLabel.centerYAnchor).isActive = true + + stackView.pin(.leading, to: .leading, of: contentView) + stackView.pin(.top, to: .top, of: contentView) + + // HACK: The two lines below are part of a workaround for a weird layout bug + stackView.set(.width, to: UIScreen.main.bounds.width - Values.mediumSpacing) + stackView.set(.height, to: cellHeight) + } + + // MARK: - Content + + // MARK: --Search Results + + public func updateForMessageSearchResult(with cellViewModel: ViewModel, searchText: String) { + profilePictureView.update( + publicKey: cellViewModel.threadId, + profile: cellViewModel.profile, + additionalProfile: cellViewModel.additionalProfile, + threadVariant: cellViewModel.threadVariant, + openGroupProfilePicture: cellViewModel.openGroupProfilePictureData.map { UIImage(data: $0) }, + useFallbackPicture: (cellViewModel.threadVariant == .openGroup && cellViewModel.openGroupProfilePictureData == nil) + ) + + isPinnedIcon.isHidden = true + unreadCountView.isHidden = true + hasMentionView.isHidden = true + displayNameLabel.attributedText = NSMutableAttributedString( + string: cellViewModel.displayName, + attributes: [ .foregroundColor: Colors.text] + ) + timestampLabel.isHidden = false + timestampLabel.text = DateUtil.formatDate(forDisplay: cellViewModel.lastInteractionDate) + bottomLabelStackView.isHidden = false + snippetLabel.attributedText = getHighlightedSnippet( + content: Interaction.previewText( + variant: (cellViewModel.interactionVariant ?? .standardIncoming), + body: cellViewModel.interactionBody, + authorDisplayName: cellViewModel.authorName(for: .contact), + attachmentDescriptionInfo: cellViewModel.interactionAttachmentDescriptionInfo, + attachmentCount: cellViewModel.interactionAttachmentCount, + isOpenGroupInvitation: (cellViewModel.interactionIsOpenGroupInvitation == true) + ), + authorName: (cellViewModel.authorId != cellViewModel.currentUserPublicKey ? + cellViewModel.authorName(for: .contact) : + nil + ), + searchText: searchText.lowercased(), + fontSize: Values.smallFontSize + ) + } + + public func updateForContactAndGroupSearchResult(with cellViewModel: ViewModel, searchText: String) { + profilePictureView.update( + publicKey: cellViewModel.threadId, + profile: cellViewModel.profile, + additionalProfile: cellViewModel.additionalProfile, + threadVariant: cellViewModel.threadVariant, + openGroupProfilePicture: cellViewModel.openGroupProfilePictureData.map { UIImage(data: $0) }, + useFallbackPicture: (cellViewModel.threadVariant == .openGroup && cellViewModel.openGroupProfilePictureData == nil) + ) + + isPinnedIcon.isHidden = true + unreadCountView.isHidden = true + hasMentionView.isHidden = true + timestampLabel.isHidden = true + displayNameLabel.attributedText = getHighlightedSnippet( + content: cellViewModel.displayName, + searchText: searchText.lowercased(), + fontSize: Values.mediumFontSize + ) + + switch cellViewModel.threadVariant { + case .contact, .openGroup: bottomLabelStackView.isHidden = true + + case .closedGroup: + bottomLabelStackView.isHidden = (cellViewModel.threadMemberNames ?? "").isEmpty + snippetLabel.attributedText = getHighlightedSnippet( + content: (cellViewModel.threadMemberNames ?? ""), + searchText: searchText.lowercased(), + fontSize: Values.smallFontSize + ) + } + } + + // MARK: --Standard + + public func update(with cellViewModel: ViewModel) { + let unreadCount: UInt = (cellViewModel.threadUnreadCount ?? 0) + backgroundColor = (cellViewModel.threadIsPinned ? Colors.cellPinned : Colors.cellBackground) + + if cellViewModel.threadIsBlocked == true { + accentLineView.backgroundColor = Colors.destructive + accentLineView.alpha = 1 + } + else { + accentLineView.backgroundColor = Colors.accent + accentLineView.alpha = (unreadCount > 0 ? 1 : 0.0001) // Setting the alpha to exactly 0 causes an issue on iOS 12 + } + + isPinnedIcon.isHidden = !cellViewModel.threadIsPinned + unreadCountView.isHidden = (unreadCount <= 0) + unreadCountLabel.text = (unreadCount < 10000 ? "\(unreadCount)" : "9999+") + unreadCountLabel.font = .boldSystemFont( + ofSize: (unreadCount < 10000 ? Values.verySmallFontSize : 8) + ) + hasMentionView.isHidden = !( + ((cellViewModel.threadUnreadMentionCount ?? 0) > 0) && + (cellViewModel.threadVariant == .closedGroup || cellViewModel.threadVariant == .openGroup) + ) + profilePictureView.update( + publicKey: cellViewModel.threadId, + profile: cellViewModel.profile, + additionalProfile: cellViewModel.additionalProfile, + threadVariant: cellViewModel.threadVariant, + openGroupProfilePicture: cellViewModel.openGroupProfilePictureData.map { UIImage(data: $0) }, + useFallbackPicture: ( + cellViewModel.threadVariant == .openGroup && + cellViewModel.openGroupProfilePictureData == nil + ) + ) + displayNameLabel.text = cellViewModel.displayName + timestampLabel.text = DateUtil.formatDate(forDisplay: cellViewModel.lastInteractionDate) + + if cellViewModel.threadContactIsTyping == true { + snippetLabel.text = "" + typingIndicatorView.isHidden = false + typingIndicatorView.startAnimation() + } + else { + snippetLabel.attributedText = getSnippet(cellViewModel: cellViewModel) + typingIndicatorView.isHidden = true + typingIndicatorView.stopAnimation() + } + + statusIndicatorView.backgroundColor = nil + + switch (cellViewModel.interactionVariant, cellViewModel.interactionState) { + case (.standardOutgoing, .sending): + statusIndicatorView.image = #imageLiteral(resourceName: "CircleDotDotDot").withRenderingMode(.alwaysTemplate) + statusIndicatorView.tintColor = Colors.text + statusIndicatorView.isHidden = false + + case (.standardOutgoing, .sent): + statusIndicatorView.image = #imageLiteral(resourceName: "CircleCheck").withRenderingMode(.alwaysTemplate) + statusIndicatorView.tintColor = Colors.text + statusIndicatorView.isHidden = false + + case (.standardOutgoing, .failed): + statusIndicatorView.image = #imageLiteral(resourceName: "message_status_failed").withRenderingMode(.alwaysTemplate) + statusIndicatorView.tintColor = Colors.destructive + statusIndicatorView.isHidden = false + + default: + statusIndicatorView.isHidden = false + } + } + + // MARK: - Snippet generation + + private func getSnippet(cellViewModel: ViewModel) -> NSMutableAttributedString { + let result = NSMutableAttributedString() + + if Date().timeIntervalSince1970 < (cellViewModel.threadMutedUntilTimestamp ?? 0) { + result.append(NSAttributedString( + string: "\u{e067} ", + attributes: [ + .font: UIFont.ows_elegantIconsFont(10), + .foregroundColor :Colors.unimportant + ] + )) + } + else if cellViewModel.threadOnlyNotifyForMentions == true { + let imageAttachment = NSTextAttachment() + imageAttachment.image = UIImage(named: "NotifyMentions.png")?.asTintedImage(color: Colors.unimportant) + imageAttachment.bounds = CGRect(x: 0, y: -2, width: Values.smallFontSize, height: Values.smallFontSize) + + let imageString = NSAttributedString(attachment: imageAttachment) + result.append(imageString) + result.append(NSAttributedString( + string: " ", + attributes: [ + .font: UIFont.ows_elegantIconsFont(10), + .foregroundColor: Colors.unimportant + ] + )) + } + + let font: UIFont = ((cellViewModel.threadUnreadCount ?? 0) > 0 ? + .boldSystemFont(ofSize: Values.smallFontSize) : + .systemFont(ofSize: Values.smallFontSize) + ) + + if cellViewModel.threadVariant == .closedGroup || cellViewModel.threadVariant == .openGroup { + let authorName: String = cellViewModel.authorName(for: cellViewModel.threadVariant) + + result.append(NSAttributedString( + string: "\(authorName): ", + attributes: [ + .font: font, + .foregroundColor: Colors.text + ] + )) + } + + result.append(NSAttributedString( + string: MentionUtilities.highlightMentions( + in: Interaction.previewText( + variant: (cellViewModel.interactionVariant ?? .standardIncoming), + body: cellViewModel.interactionBody, + authorDisplayName: cellViewModel.authorName(for: cellViewModel.threadVariant), + attachmentDescriptionInfo: cellViewModel.interactionAttachmentDescriptionInfo, + attachmentCount: cellViewModel.interactionAttachmentCount, + isOpenGroupInvitation: (cellViewModel.interactionIsOpenGroupInvitation == true) + ), + threadVariant: cellViewModel.threadVariant + ), + attributes: [ + .font: font, + .foregroundColor: Colors.text + ] + )) + + return result + } + + private func getHighlightedSnippet( + content: String, + authorName: String? = nil, + searchText: String, + fontSize: CGFloat + ) -> NSAttributedString { + guard !content.isEmpty, content != "NOTE_TO_SELF".localized() else { + return NSMutableAttributedString( + string: (authorName != nil && authorName?.isEmpty != true ? + "\(authorName ?? ""): \(content)" : + content + ), + attributes: [ .foregroundColor: Colors.text ] + ) + } + + // Replace mentions in the content + // + // Note: The 'threadVariant' is used for profile context but in the search results + // we don't want to include the truncated id as part of the name so we exclude it + let mentionReplacedContent: String = MentionUtilities.highlightMentions( + in: content, + threadVariant: .contact + ) + let result: NSMutableAttributedString = NSMutableAttributedString( + string: mentionReplacedContent, + attributes: [ + .foregroundColor: Colors.text + .withAlphaComponent(Values.lowOpacity) + ] + ) + + // Bold each part of the searh term which matched + let normalizedSnippet: String = mentionReplacedContent.lowercased() + var firstMatchRange: Range? + + ConversationCell.ViewModel.searchTermParts(searchText) + .map { part -> String in + guard part.hasPrefix("\"") && part.hasSuffix("\"") else { return part } + + return String(part[part.index(after: part.startIndex).. NSAttributedString { + let approxFullWidth: CGFloat = (approxWidth + profilePictureView.size + (Values.mediumSpacing * 3)) + + guard ((bounds.width - approxFullWidth) < 0) else { return content } + + return content.attributedSubstring( + from: NSRange(startOfSnippet.. NSAttributedString? in + guard !authorName.isEmpty else { return nil } + + let authorPrefix: NSAttributedString = NSAttributedString( + string: "\(authorName): ...", + attributes: [ .foregroundColor: Colors.text ] + ) + + return authorPrefix + .appending( + truncatingIfNeeded( + approxWidth: (authorPrefix.size().width + result.size().width), + content: result + ) + ) + } + .defaulting( + to: truncatingIfNeeded( + approxWidth: result.size().width, + content: result + ) + ) + } + } +} diff --git a/Session/Utilities/BackgroundPoller.swift b/Session/Utilities/BackgroundPoller.swift index c9c11b4ed..4f0f0e329 100644 --- a/Session/Utilities/BackgroundPoller.swift +++ b/Session/Utilities/BackgroundPoller.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB import PromiseKit import SessionSnodeKit import SessionMessagingKit @@ -38,8 +39,23 @@ public final class BackgroundPoller : NSObject { } private static func pollForClosedGroupMessages() -> [Promise] { - let publicKeys = Storage.shared.getUserClosedGroupPublicKeys() - return publicKeys.map { getMessages(for: $0) } + // Fetch all closed groups (excluding any don't contain the current user as a + // GroupMemeber as the user is no longer a member of those) + return GRDBStorage.shared + .read { db in + try ClosedGroup + .select(.threadId) + .joining( + required: ClosedGroup.members + .filter(GroupMember.Columns.profileId == getUserHexEncodedPublicKey(db)) + ) + .asRequest(of: String.self) + .fetchAll(db) + } + .defaulting(to: []) + .map { groupPublicKey in + getMessages(for: groupPublicKey) + } } private static func getMessages(for publicKey: String) -> Promise { diff --git a/Session/Utilities/ContactUtilities.swift b/Session/Utilities/ContactUtilities.swift deleted file mode 100644 index a1ef5db88..000000000 --- a/Session/Utilities/ContactUtilities.swift +++ /dev/null @@ -1,36 +0,0 @@ - -enum ContactUtilities { - - static func getAllContacts() -> [String] { - // Collect all contacts - var result: [String] = [] - Storage.read { transaction in - // FIXME: If a user deletes a contact thread they will no longer appear in this list (ie. won't be an option for closed group conversations) - TSContactThread.enumerateCollectionObjects(with: transaction) { object, _ in - guard - let thread: TSContactThread = object as? TSContactThread, - thread.shouldBeVisible, - Storage.shared.getContact( - with: thread.contactSessionID(), - using: transaction - )?.didApproveMe == true - else { - return - } - - result.append(thread.contactSessionID()) - } - } - func getDisplayName(for publicKey: String) -> String { - return Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey - } - - // Remove the current user - if let index = result.firstIndex(of: getUserHexEncodedPublicKey()) { - result.remove(at: index) - } - - // Sort alphabetically - return result.sorted { getDisplayName(for: $0) < getDisplayName(for: $1) } - } -} diff --git a/Session/Utilities/MessageRecipientStatusUtils.swift b/Session/Utilities/MessageRecipientStatusUtils.swift deleted file mode 100644 index 52ac383ff..000000000 --- a/Session/Utilities/MessageRecipientStatusUtils.swift +++ /dev/null @@ -1,169 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -import Foundation -import SignalUtilitiesKit -import SignalUtilitiesKit - -@objc public enum MessageReceiptStatus: Int { - case uploading - case sending - case sent - case delivered - case read - case failed - case skipped -} - -@objc -public class MessageRecipientStatusUtils: NSObject { - // MARK: Initializers - - @available(*, unavailable, message:"do not instantiate this class.") - private override init() { - } - - // This method is per-recipient. - @objc - public class func recipientStatus(outgoingMessage: TSOutgoingMessage, - recipientState: TSOutgoingMessageRecipientState) -> MessageReceiptStatus { - let (messageReceiptStatus, _, _) = recipientStatusAndStatusMessage(outgoingMessage: outgoingMessage, - recipientState: recipientState) - return messageReceiptStatus - } - - // This method is per-recipient. - @objc - public class func shortStatusMessage(outgoingMessage: TSOutgoingMessage, - recipientState: TSOutgoingMessageRecipientState) -> String { - let (_, shortStatusMessage, _) = recipientStatusAndStatusMessage(outgoingMessage: outgoingMessage, - recipientState: recipientState) - return shortStatusMessage - } - - // This method is per-recipient. - @objc - public class func longStatusMessage(outgoingMessage: TSOutgoingMessage, - recipientState: TSOutgoingMessageRecipientState) -> String { - let (_, _, longStatusMessage) = recipientStatusAndStatusMessage(outgoingMessage: outgoingMessage, - recipientState: recipientState) - return longStatusMessage - } - - // This method is per-recipient. - class func recipientStatusAndStatusMessage(outgoingMessage: TSOutgoingMessage, - recipientState: TSOutgoingMessageRecipientState) -> (status: MessageReceiptStatus, shortStatusMessage: String, longStatusMessage: String) { - - switch recipientState.state { - case .failed: - let shortStatusMessage = NSLocalizedString("MESSAGE_STATUS_FAILED_SHORT", comment: "status message for failed messages") - let longStatusMessage = NSLocalizedString("MESSAGE_STATUS_FAILED", comment: "status message for failed messages") - return (status:.failed, shortStatusMessage:shortStatusMessage, longStatusMessage:longStatusMessage) - case .sending: - if outgoingMessage.hasAttachments() { - assert(outgoingMessage.messageState == .sending) - - let statusMessage = NSLocalizedString("MESSAGE_STATUS_UPLOADING", - comment: "status message while attachment is uploading") - return (status:.uploading, shortStatusMessage:statusMessage, longStatusMessage:statusMessage) - } else { - assert(outgoingMessage.messageState == .sending) - - let statusMessage = NSLocalizedString("MESSAGE_STATUS_SENDING", - comment: "message status while message is sending.") - return (status:.sending, shortStatusMessage:statusMessage, longStatusMessage:statusMessage) - } - case .sent: - if let readTimestamp = recipientState.readTimestamp { - let timestampString = DateUtil.formatPastTimestampRelativeToNow(readTimestamp.uint64Value) - let shortStatusMessage = timestampString - let longStatusMessage = NSLocalizedString("MESSAGE_STATUS_READ", comment: "status message for read messages").rtlSafeAppend(" ") - .rtlSafeAppend(timestampString) - return (status:.read, shortStatusMessage:shortStatusMessage, longStatusMessage:longStatusMessage) - } - if let deliveryTimestamp = recipientState.deliveryTimestamp { - let timestampString = DateUtil.formatPastTimestampRelativeToNow(deliveryTimestamp.uint64Value) - let shortStatusMessage = timestampString - let longStatusMessage = NSLocalizedString("MESSAGE_STATUS_DELIVERED", - comment: "message status for message delivered to their recipient.").rtlSafeAppend(" ") - .rtlSafeAppend(timestampString) - return (status:.delivered, shortStatusMessage:shortStatusMessage, longStatusMessage:longStatusMessage) - } - let statusMessage = - NSLocalizedString("MESSAGE_STATUS_SENT", - comment: "status message for sent messages") - return (status:.sent, shortStatusMessage:statusMessage, longStatusMessage:statusMessage) - case .skipped: - let statusMessage = NSLocalizedString("MESSAGE_STATUS_RECIPIENT_SKIPPED", - comment: "message status if message delivery to a recipient is skipped. We skip delivering group messages to users who have left the group or unregistered their Signal account.") - return (status:.skipped, shortStatusMessage:statusMessage, longStatusMessage:statusMessage) - } - } - - // This method is per-message. - internal class func receiptStatusAndMessage(outgoingMessage: TSOutgoingMessage) -> (status: MessageReceiptStatus, message: String) { - - switch outgoingMessage.messageState { - case .failed: - // Use the "long" version of this message here. - return (.failed, NSLocalizedString("MESSAGE_STATUS_FAILED", comment: "status message for failed messages")) - case .sending: - if outgoingMessage.hasAttachments() { - return (.uploading, NSLocalizedString("MESSAGE_STATUS_UPLOADING", - comment: "status message while attachment is uploading")) - } else { - return (.sending, NSLocalizedString("MESSAGE_STATUS_SENDING", - comment: "message status while message is sending.")) - } - case .sent: - if outgoingMessage.readRecipientIds().count > 0 { - return (.read, NSLocalizedString("MESSAGE_STATUS_READ", comment: "status message for read messages")) - } - if outgoingMessage.wasDeliveredToAnyRecipient { - return (.delivered, NSLocalizedString("MESSAGE_STATUS_DELIVERED", - comment: "message status for message delivered to their recipient.")) - } - return (.sent, NSLocalizedString("MESSAGE_STATUS_SENT", - comment: "status message for sent messages")) - default: - owsFailDebug("Message has unexpected status: \(outgoingMessage.messageState).") - return (.sent, NSLocalizedString("MESSAGE_STATUS_SENT", - comment: "status message for sent messages")) - } - } - - // This method is per-message. - @objc - public class func receiptMessage(outgoingMessage: TSOutgoingMessage) -> String { - let (_, message ) = receiptStatusAndMessage(outgoingMessage: outgoingMessage) - return message - } - - // This method is per-message. - @objc - public class func recipientStatus(outgoingMessage: TSOutgoingMessage) -> MessageReceiptStatus { - let (status, _ ) = receiptStatusAndMessage(outgoingMessage: outgoingMessage) - return status - } - - @objc - public class func description(forMessageReceiptStatus value: MessageReceiptStatus) -> String { - switch(value) { - case .read: - return "read" - case .uploading: - return "uploading" - case .delivered: - return "delivered" - case .sent: - return "sent" - case .sending: - return "sending" - case .failed: - return "failed" - case .skipped: - return "skipped" - } - } -} diff --git a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift index be2b6194b..948c40449 100644 --- a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift +++ b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift @@ -50,6 +50,7 @@ public enum SMKLegacy { // Preferences internal static let preferencesCollection = "SignalPreferences" + internal static let additionalPreferencesCollection = "SSKPreferences" internal static let preferencesKeyScreenSecurityDisabled = "Screen Security Key" internal static let preferencesKeyLastRecordedPushToken = "LastRecordedPushToken" internal static let preferencesKeyLastRecordedVoipToken = "LastRecordedVoipToken" diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 6a9edb48a..ce84fbd95 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -774,7 +774,6 @@ enum _003_YDBToGRDBMigration: Migration { case .sending: return .sending case .skipped: return .skipped case .sent: return .sent - @unknown default: throw GRDBStorageError.migrationFailed } }(), readTimestampMs: legacyState.readTimestamp, @@ -1227,6 +1226,20 @@ enum _003_YDBToGRDBMigration: Migration { return legacyInteractionIdentifierToIdFallbackMap[fallbackIdentifier] }() + // Don't botther adding any 'MessageSend' jobs VisibleMessages which don't have associated + // interactions + switch legacyJob.message { + case is SMKLegacy._VisibleMessage: + guard interactionId != nil else { + SNLog("[Migration Warning] Unable to find associated interaction to messageSend job, ignoring.") + return + } + + break + + default: break + } + let job: Job? = try Job( failureCount: legacyJob.failureCount, variant: .messageSend, @@ -1243,7 +1256,7 @@ enum _003_YDBToGRDBMigration: Migration { ) )?.inserted(db) - if let oldId: String = legacyJob.id, let newId: Int64 = job?.id { + if let oldId: String = legacyJob.id { messageSendJobLegacyMap[oldId] = job } } @@ -1339,6 +1352,10 @@ enum _003_YDBToGRDBMigration: Migration { legacyPreferences[key] = object } + transaction.enumerateKeysAndObjects(inCollection: SMKLegacy.additionalPreferencesCollection) { key, object, _ in + legacyPreferences[key] = object + } + // Note: The 'int(forKey:inCollection:)' defaults to `0` which is an incorrect value // for the notification sound so catch it and default let globalNotificationSoundValue: Int32 = transaction.int( @@ -1447,12 +1464,12 @@ enum _003_YDBToGRDBMigration: Migration { switch legacyAttachment { case let stream as SMKLegacy._AttachmentStream: // Outgoing or already downloaded switch interactionVariant { - case .standardOutgoing: return (stream.isUploaded ? .uploaded : .pending) + case .standardOutgoing: return (stream.isUploaded ? .uploaded : .uploading) default: return .downloaded } - // All other cases can just be set to 'pending' - default: return .pending + // All other cases can just be set to 'pendingDownload' + default: return .pendingDownload } }() let size: CGSize = { diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index c62341fd7..3d22369bb 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -49,12 +49,12 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR } public enum State: Int, Codable, DatabaseValueConvertible { - case pending + case failedDownload + case pendingDownload case downloading case downloaded case uploading case uploaded - case failed } /// A unique identifier for the attachment @@ -131,7 +131,7 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR id: String = UUID().uuidString, serverId: String? = nil, variant: Variant, - state: State = .pending, + state: State = .pendingDownload, contentType: String, byteCount: UInt, creationTimestamp: TimeInterval? = nil, @@ -198,13 +198,13 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR self.id = id self.serverId = nil self.variant = variant - self.state = .pending + self.state = .uploading self.contentType = contentType self.byteCount = dataSource.dataLength() self.creationTimestamp = nil self.sourceFilename = sourceFilename self.downloadUrl = nil - self.localRelativeFilePath = URL(fileURLWithPath: originalFilePath).lastPathComponent + self.localRelativeFilePath = Attachment.localRelativeFilePath(from: originalFilePath) self.width = imageSize.map { UInt(floor($0.width)) } self.height = imageSize.map { UInt(floor($0.height)) } self.duration = duration @@ -351,8 +351,8 @@ extension Attachment { ) // Assume the data is already correct for "uploading" attachments (and don't override it) - case (.uploading, .failed), (.uploaded, .failed): return (self.isValid, self.duration) - case (_, .failed): return (false, nil) + case (.uploading, .failedDownload), (.uploaded, .failedDownload): return (self.isValid, self.duration) + case (_, .failedDownload): return (false, nil) default: return (self.isValid, self.duration) } @@ -407,7 +407,7 @@ extension Attachment { return .voiceMessage }() - self.state = .pending + self.state = .pendingDownload self.contentType = (proto.contentType ?? inferContentType(from: proto.fileName)) self.byteCount = UInt(proto.size) self.creationTimestamp = nil @@ -620,6 +620,13 @@ extension Attachment { ) } + public static func localRelativeFilePath(from originalFilePath: String?) -> String? { + guard let originalFilePath: String = originalFilePath else { return nil } + + return originalFilePath + .substring(from: (Attachment.attachmentsFolder.count + 1)) // Leading forward slash + } + internal static func imageSize(contentType: String, originalFilePath: String) -> CGSize? { let isVideo: Bool = MIMETypeUtil.isVideo(contentType) let isImage: Bool = MIMETypeUtil.isImage(contentType) @@ -824,7 +831,7 @@ extension Attachment { return } - OWSThumbnailService.shared.ensureThumbnail( + ThumbnailService.shared.ensureThumbnail( for: self, dimensions: dimensions, success: { loadedThumbnail in success(loadedThumbnail.image, loadedThumbnail.dataSourceBlock) }, @@ -913,8 +920,7 @@ extension Attachment { contentType: OWSMimeTypeImageJpeg, byteCount: UInt(thumbnailData.count), sourceFilename: thumbnailName, - localRelativeFilePath: thumbnailPath - .substring(from: (Attachment.attachmentsFolder.count + 1)), // Leading forward slash + localRelativeFilePath: Attachment.localRelativeFilePath(from: thumbnailPath), width: UInt(thumbnailSize.width), height: UInt(thumbnailSize.height), isValid: true @@ -940,9 +946,11 @@ extension Attachment { success: (() -> Void)?, failure: ((Error) -> Void)? ) { + // This can occur if an AttachmnetUploadJob was explicitly created for a message + // dependant on the attachment being uploaded (in this case the attachment has + // already been uploaded so just succeed) guard state != .uploaded else { - SNLog("Attempted to upload an already uploaded/downloaded attachment.") - failure?(AttachmentError.invalidStartState) + success?() return } diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 98b18e56a..e37a43bcf 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -123,7 +123,10 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu /// When the interaction was created in milliseconds since epoch /// - /// **Note:** This value will be `0` if it hasn't been set yet + /// **Notes:** + /// - This value will be `0` if it hasn't been set yet + /// - The code sorts messages using this value + /// - This value will ber overwritten by the `serverTimestamp` for open group messages public let timestampMs: Int64 /// When the interaction was received in milliseconds since epoch @@ -181,7 +184,10 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu /// **LinkPreview:** The thumbnails associated to the `LinkPreview` /// **Other:** The files directly attached to the interaction public var attachments: QueryInterfaceRequest { - request(for: Interaction.attachments) + let interactionAttachment: TypedTableAlias = TypedTableAlias() + + return request(for: Interaction.attachments) + .order(interactionAttachment[.albumIndex]) } public var quote: QueryInterfaceRequest { @@ -404,63 +410,6 @@ public extension Interaction { // MARK: - GRDB Interactions public extension Interaction { - static func lastInteractionTimestamp(timestampMsKey: String) -> CommonTableExpression { - return CommonTableExpression( - named: "lastInteraction", - request: Interaction - .select( - Interaction.Columns.threadId, - - // 'max()' to get the latest - max(Interaction.Columns.timestampMs).forKey(timestampMsKey) - ) - .joining(required: Interaction.thread) - .group(Interaction.Columns.threadId) // One interaction per thread - ) - } - - static func lastInteraction( - lastInteractionKey: String, - timestampMsKey: String, - threadVariantKey: String, - isOpenGroupInvitationKey: String, - recipientStatesKey: String - ) -> CommonTableExpression { - let thread: TypedTableAlias = TypedTableAlias() - let linkPreview: TypedTableAlias = TypedTableAlias() - - return CommonTableExpression( - named: lastInteractionKey, - request: Interaction - .select( - Interaction.Columns.id, - Interaction.Columns.threadId, - Interaction.Columns.variant, - - // 'max()' to get the latest - max(Interaction.Columns.timestampMs).forKey(timestampMsKey), - - thread[.variant].forKey(threadVariantKey), - Interaction.Columns.body, - Interaction.Columns.authorId, - (linkPreview[.url] != nil).forKey(isOpenGroupInvitationKey) - ) - .joining(required: Interaction.thread.aliased(thread)) - .joining( - optional: Interaction.linkPreview - .filter(literal: Interaction.linkPreviewFilterLiteral) - .filter(LinkPreview.Columns.variant == LinkPreview.Variant.openGroupInvitation) - ) - .including(all: Interaction.attachments) - .including( - all: Interaction.recipientStates - .select(RecipientState.Columns.state) - .forKey(recipientStatesKey) - ) - .group(Interaction.Columns.threadId) // One interaction per thread - ) - } - /// This will update the `wasRead` state the the interaction /// /// - Parameters @@ -624,19 +573,11 @@ public extension Interaction { func previewText(_ db: Database) -> String { switch variant { case .standardIncoming, .standardOutgoing: - struct AttachmentDescriptionInfo: Decodable, FetchableRecord { - let id: String - let variant: Attachment.Variant - let contentType: String - let sourceFilename: String? - } - - return Interaction.previewText( variant: self.variant, body: self.body, attachmentDescriptionInfo: try? attachments - .select(.variant, .contentType, .sourceFilename) + .select(.id, .variant, .contentType, .sourceFilename) .asRequest(of: Attachment.DescriptionInfo.self) .fetchOne(db), attachmentCount: try? attachments.fetchCount(db), diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index 42f7ab694..1967ca1bc 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -17,10 +17,10 @@ public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableReco public enum CodingKeys: String, CodingKey, ColumnExpression { case id - case name = "displayName" + case name case nickname - case profilePictureUrl = "profilePictureURL" + case profilePictureUrl case profilePictureFileName case profileEncryptionKey } @@ -66,9 +66,9 @@ public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableReco public var description: String { """ Profile( - displayName: \(name), + name: \(name), profileKey: \(profileEncryptionKey?.keyData.description ?? "null"), - profilePictureURL: \(profilePictureUrl ?? "null") + profilePictureUrl: \(profilePictureUrl ?? "null") ) """ } diff --git a/SessionMessagingKit/Database/Models/Quote.swift b/SessionMessagingKit/Database/Models/Quote.swift index 0fd8b4b71..9da8cd81c 100644 --- a/SessionMessagingKit/Database/Models/Quote.swift +++ b/SessionMessagingKit/Database/Models/Quote.swift @@ -102,26 +102,26 @@ public struct Quote: Codable, Equatable, FetchableRecord, PersistableRecord, Tab public extension Quote { init?(_ db: Database, proto: SNProtoDataMessage, interactionId: Int64, thread: SessionThread) throws { guard - let quote = proto.quote, - quote.id != 0, - !quote.author.isEmpty + let quoteProto = proto.quote, + quoteProto.id != 0, + !quoteProto.author.isEmpty else { return nil } self.interactionId = interactionId - self.timestampMs = Int64(quote.id) - self.authorId = quote.author + self.timestampMs = Int64(quoteProto.id) + self.authorId = quoteProto.author // Prefer to generate the text snippet locally if available. let quotedInteraction: Interaction? = try? thread .interactions - .filter(Interaction.Columns.authorId == quote.author) - .filter(Interaction.Columns.timestampMs == Double(quote.id)) + .filter(Interaction.Columns.authorId == quoteProto.author) + .filter(Interaction.Columns.timestampMs == Double(quoteProto.id)) .fetchOne(db) if let quotedInteraction: Interaction = quotedInteraction, quotedInteraction.body?.isEmpty == false { self.body = quotedInteraction.body } - else if let body: String = proto.body, !body.isEmpty { + else if let body: String = quoteProto.text, !body.isEmpty { self.body = body } else { @@ -129,7 +129,7 @@ public extension Quote { } // We only use the first attachment - if let attachment = quote.attachments.first(where: { $0.thumbnail != nil })?.thumbnail { + if let attachment = quoteProto.attachments.first(where: { $0.thumbnail != nil })?.thumbnail { self.attachmentId = try quotedInteraction .map { quotedInteraction -> Attachment? in // If the quotedInteraction has an attachment then try clone it diff --git a/SessionMessagingKit/Database/Storage+ClosedGroups.swift b/SessionMessagingKit/Database/Storage+ClosedGroups.swift deleted file mode 100644 index 901105af6..000000000 --- a/SessionMessagingKit/Database/Storage+ClosedGroups.swift +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Sodium -import Curve25519Kit - -extension Storage { - - private static func getClosedGroupEncryptionKeyPairCollection(for groupPublicKey: String) -> String { - return "SNClosedGroupEncryptionKeyPairCollection-\(groupPublicKey)" - } - - private static let closedGroupPublicKeyCollection = "SNClosedGroupPublicKeyCollection" - private static let closedGroupFormationTimestampCollection = "SNClosedGroupFormationTimestampCollection" - private static let closedGroupZombieMembersCollection = "SNClosedGroupZombieMembersCollection" - - public func getClosedGroupEncryptionKeyPairs(for groupPublicKey: String) -> [Box.KeyPair] { - var result: [ECKeyPair] = [] - Storage.read { transaction in - result = self.getClosedGroupEncryptionKeyPairs(for: groupPublicKey, using: transaction) - } - return result - .map { keyPair -> Box.KeyPair in - Box.KeyPair( - publicKey: keyPair.publicKey.bytes, - secretKey: keyPair.privateKey.bytes - ) - } - } - - public func getClosedGroupEncryptionKeyPairs(for groupPublicKey: String, using transaction: YapDatabaseReadTransaction) -> [ECKeyPair] { - let collection = Storage.getClosedGroupEncryptionKeyPairCollection(for: groupPublicKey) - var timestampsAndKeyPairs: [(timestamp: Double, keyPair: ECKeyPair)] = [] - transaction.enumerateKeysAndObjects(inCollection: collection) { key, object, _ in - guard let timestamp = Double(key), let keyPair = object as? ECKeyPair else { return } - timestampsAndKeyPairs.append((timestamp, keyPair)) - } - return timestampsAndKeyPairs.sorted { $0.timestamp < $1.timestamp }.map { $0.keyPair } - } - - public func getLatestClosedGroupEncryptionKeyPair(for groupPublicKey: String) -> Box.KeyPair? { - return getClosedGroupEncryptionKeyPairs(for: groupPublicKey).last - } - - public func getLatestClosedGroupEncryptionKeyPair(for groupPublicKey: String, using transaction: YapDatabaseReadTransaction) -> ECKeyPair? { - return getClosedGroupEncryptionKeyPairs(for: groupPublicKey, using: transaction).last - } - - public func addClosedGroupEncryptionKeyPair(_ keyPair: Box.KeyPair, for groupPublicKey: String, using transaction: Any) { - let ecKeyPair: ECKeyPair = try! ECKeyPair( - publicKeyData: Data(keyPair.publicKey), - privateKeyData: Data(keyPair.secretKey) - ) - let collection = Storage.getClosedGroupEncryptionKeyPairCollection(for: groupPublicKey) - let timestamp = String(Date().timeIntervalSince1970) - (transaction as! YapDatabaseReadWriteTransaction).setObject(ecKeyPair, forKey: timestamp, inCollection: collection) - } - - public func removeAllClosedGroupEncryptionKeyPairs(for groupPublicKey: String, using transaction: Any) { - let collection = Storage.getClosedGroupEncryptionKeyPairCollection(for: groupPublicKey) - (transaction as! YapDatabaseReadWriteTransaction).removeAllObjects(inCollection: collection) - } - - public func getUserClosedGroupPublicKeys() -> Set { - var result: Set = [] - Storage.read { transaction in - result = self.getUserClosedGroupPublicKeys(using: transaction) - } - return result - } - - public func getUserClosedGroupPublicKeys(using transaction: YapDatabaseReadTransaction) -> Set { - return Set(transaction.allKeys(inCollection: Storage.closedGroupPublicKeyCollection)) - } - - public func addClosedGroupPublicKey(_ groupPublicKey: String, using transaction: Any) { - (transaction as! YapDatabaseReadWriteTransaction).setObject(groupPublicKey, forKey: groupPublicKey, inCollection: Storage.closedGroupPublicKeyCollection) - } - - public func removeClosedGroupPublicKey(_ groupPublicKey: String, using transaction: Any) { - (transaction as! YapDatabaseReadWriteTransaction).removeObject(forKey: groupPublicKey, inCollection: Storage.closedGroupPublicKeyCollection) - } - - public func getClosedGroupFormationTimestamp(for groupPublicKey: String) -> UInt64? { - var result: UInt64? - Storage.read { transaction in - result = transaction.object(forKey: groupPublicKey, inCollection: Storage.closedGroupFormationTimestampCollection) as? UInt64 - } - return result - } - - public func setClosedGroupFormationTimestamp(to timestamp: UInt64, for groupPublicKey: String, using transaction: Any) { - (transaction as! YapDatabaseReadWriteTransaction).setObject(timestamp, forKey: groupPublicKey, inCollection: Storage.closedGroupFormationTimestampCollection) - } - - public func getZombieMembers(for groupPublicKey: String) -> Set { - var result: Set = [] - Storage.read { transaction in - if let zombies = transaction.object(forKey: groupPublicKey, inCollection: Storage.closedGroupZombieMembersCollection) as? Set { - result = zombies - } - } - return result - } - - public func setZombieMembers(for groupPublicKey: String, to zombies: Set, using transaction: Any) { - (transaction as! YapDatabaseReadWriteTransaction).setObject(zombies, forKey: groupPublicKey, inCollection: Storage.closedGroupZombieMembersCollection) - } - - public func isClosedGroup(_ publicKey: String) -> Bool { - getUserClosedGroupPublicKeys().contains(publicKey) - } - - public func isClosedGroup(_ publicKey: String, using transaction: YapDatabaseReadTransaction) -> Bool { - getUserClosedGroupPublicKeys(using: transaction).contains(publicKey) - } -} diff --git a/SessionMessagingKit/Database/Storage+Jobs.swift b/SessionMessagingKit/Database/Storage+Jobs.swift deleted file mode 100644 index fe3f31615..000000000 --- a/SessionMessagingKit/Database/Storage+Jobs.swift +++ /dev/null @@ -1,117 +0,0 @@ - -extension Storage { - - public func persist(_ job: Job, using transaction: Any) { - (transaction as! YapDatabaseReadWriteTransaction).setObject(job, forKey: job.id!, inCollection: type(of: job).collection) - } - - public func markJobAsSucceeded(_ job: Job, using transaction: Any) { - (transaction as! YapDatabaseReadWriteTransaction).removeObject(forKey: job.id!, inCollection: type(of: job).collection) - } - - public func markJobAsFailed(_ job: Job, using transaction: Any) { - (transaction as! YapDatabaseReadWriteTransaction).removeObject(forKey: job.id!, inCollection: type(of: job).collection) - } - - public func getAllPendingJobs(of type: Job.Type) -> [Job] { - var result: [Job] = [] - Storage.read { transaction in - transaction.enumerateRows(inCollection: type.collection) { key, object, _, x in - guard let job = object as? Job else { return } - result.append(job) - } - } - return result - } - - public func cancelAllPendingJobs(of type: Job.Type, using transaction: YapDatabaseReadWriteTransaction) { - transaction.removeAllObjects(inCollection: type.collection) - } - - @objc(cancelPendingMessageSendJobIfNeededForMessage:using:) - public func cancelPendingMessageSendJobIfNeeded(for tsMessageTimestamp: UInt64, using transaction: YapDatabaseReadWriteTransaction) { - var attachmentUploadJobKeys: [String] = [] - transaction.enumerateRows(inCollection: AttachmentUploadJob.collection) { key, object, _, _ in - guard let job = object as? AttachmentUploadJob, job.message.sentTimestamp == tsMessageTimestamp else { return } - attachmentUploadJobKeys.append(key) - } - var messageSendJobKeys: [String] = [] - transaction.enumerateRows(inCollection: MessageSendJob.collection) { key, object, _, _ in - guard let job = object as? MessageSendJob, job.message.sentTimestamp == tsMessageTimestamp else { return } - messageSendJobKeys.append(key) - } - transaction.removeObjects(forKeys: attachmentUploadJobKeys, inCollection: AttachmentUploadJob.collection) - transaction.removeObjects(forKeys: messageSendJobKeys, inCollection: MessageSendJob.collection) - } - - @objc public func cancelPendingMessageSendJobs(for threadID: String, using transaction: YapDatabaseReadWriteTransaction) { - var attachmentUploadJobKeys: [String] = [] - transaction.enumerateRows(inCollection: AttachmentUploadJob.collection) { key, object, _, _ in - guard let job = object as? AttachmentUploadJob, job.threadID == threadID else { return } - attachmentUploadJobKeys.append(key) - } - var messageSendJobKeys: [String] = [] - transaction.enumerateRows(inCollection: MessageSendJob.collection) { key, object, _, _ in - guard let job = object as? MessageSendJob, job.message.threadID == threadID else { return } - messageSendJobKeys.append(key) - } - transaction.removeObjects(forKeys: attachmentUploadJobKeys, inCollection: AttachmentUploadJob.collection) - transaction.removeObjects(forKeys: messageSendJobKeys, inCollection: MessageSendJob.collection) - } - - public func getAttachmentUploadJob(for attachmentID: String) -> AttachmentUploadJob? { - var result: [AttachmentUploadJob] = [] - Storage.read { transaction in - transaction.enumerateRows(inCollection: AttachmentUploadJob.collection) { _, object, _, _ in - guard let job = object as? AttachmentUploadJob, job.attachmentID == attachmentID else { return } - result.append(job) - } - } - #if DEBUG - assert(result.isEmpty || result.count == 1) - #endif - return result.first - } - - public func getAttachmentDownloadJobs(for threadID: String) -> [AttachmentDownloadJob] { - var result: [AttachmentDownloadJob] = [] - Storage.read { transaction in - transaction.enumerateRows(inCollection: AttachmentDownloadJob.collection) { _, object, _, _ in - guard let job = object as? AttachmentDownloadJob, job.threadID == threadID else { return } - result.append(job) - } - } - return result - } - - public func resumeAttachmentDownloadJobsIfNeeded(for threadID: String) { - let jobs = getAttachmentDownloadJobs(for: threadID) - jobs.forEach { job in - job.delegate = JobQueue.shared - job.isDeferred = false - job.execute() - } - } - - public func getMessageSendJob(for messageSendJobID: String) -> MessageSendJob? { - var result: MessageSendJob? - Storage.read { transaction in - result = transaction.object(forKey: messageSendJobID, inCollection: MessageSendJob.collection) as? MessageSendJob - } - return result - } - - public func resumeMessageSendJobIfNeeded(_ messageSendJobID: String) { - guard let job = getMessageSendJob(for: messageSendJobID) else { return } - job.delegate = JobQueue.shared - job.execute() - } - - public func isJobCanceled(_ job: Job) -> Bool { - var result = true - Storage.read { transaction in - result = !transaction.hasObject(forKey: job.id!, inCollection: type(of: job).collection) - } - return result - } -} diff --git a/SessionMessagingKit/Database/Storage+Messaging.swift b/SessionMessagingKit/Database/Storage+Messaging.swift deleted file mode 100644 index 0baff2ef0..000000000 --- a/SessionMessagingKit/Database/Storage+Messaging.swift +++ /dev/null @@ -1,115 +0,0 @@ -import PromiseKit - -extension Storage { - - /// Returns the ID of the thread. - public func getOrCreateThread(for publicKey: String, groupPublicKey: String?, openGroupID: String?, using transaction: Any) -> String? { - let transaction = transaction as! YapDatabaseReadWriteTransaction - var threadOrNil: TSThread? - if let openGroupID = openGroupID { - if let threadID = Storage.shared.v2GetThreadID(for: openGroupID), - let thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction) { - threadOrNil = thread - } - } else if let groupPublicKey = groupPublicKey { - guard Storage.shared.isClosedGroup(groupPublicKey) else { return nil } - let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey) - threadOrNil = TSGroupThread.fetch(uniqueId: TSGroupThread.threadId(fromGroupId: groupID), transaction: transaction) - } else { - threadOrNil = TSContactThread.getOrCreateThread(withContactSessionID: publicKey, transaction: transaction) - } - return threadOrNil?.uniqueId - } - - /// Returns the ID of the `TSIncomingMessage` that was constructed. - public func persist(_ message: VisibleMessage, quotedMessage: TSQuotedMessage?, linkPreview: OWSLinkPreview?, groupPublicKey: String?, openGroupID: String?, using transaction: Any) -> String? { - let transaction = transaction as! YapDatabaseReadWriteTransaction - guard let threadID = getOrCreateThread(for: message.syncTarget ?? message.sender!, groupPublicKey: groupPublicKey, openGroupID: openGroupID, using: transaction), - let thread = TSThread.fetch(uniqueId: threadID, transaction: transaction) else { return nil } - let tsMessage: TSMessage - if message.sender == getUserHexEncodedPublicKey() { - if TSOutgoingMessage.find(withTimestamp: message.sentTimestamp!) != nil { return nil } - let tsOutgoingMessage = TSOutgoingMessage.from(message, associatedWith: thread, using: transaction) - var recipients: [String] = [] - if let syncTarget = message.syncTarget { - recipients.append(syncTarget) - } else if let thread = thread as? TSGroupThread { - if thread.isClosedGroup { recipients = thread.groupModel.groupMemberIds } - else { recipients.append(LKGroupUtilities.getDecodedGroupID(thread.groupModel.groupId)) } - } - recipients.forEach { recipient in - tsOutgoingMessage.update(withSentRecipient: recipient, wasSentByUD: true, transaction: transaction) - } - tsMessage = tsOutgoingMessage - } else { - if TSIncomingMessage.find(withAuthorId: message.sender!, timestamp: message.sentTimestamp!, transaction: transaction) != nil { return nil } - tsMessage = TSIncomingMessage.from(message, quotedMessage: quotedMessage, linkPreview: linkPreview, associatedWith: thread) - } - tsMessage.save(with: transaction) - tsMessage.attachments(with: transaction).forEach { attachment in - attachment.albumMessageId = tsMessage.uniqueId! - attachment.save(with: transaction) - } - return tsMessage.uniqueId! - } - - /// Returns the IDs of the saved attachments. - public func persist(_ attachments: [VisibleMessage.Attachment], using transaction: Any) -> [String] { - return attachments.map { attachment in - let tsAttachment = TSAttachmentPointer.from(attachment) - tsAttachment.save(with: transaction as! YapDatabaseReadWriteTransaction) - return tsAttachment.uniqueId! - } - } - - /// Also touches the associated message. - public func setAttachmentState(to state: TSAttachmentPointerState, for pointer: TSAttachmentPointer, associatedWith tsMessageID: String, using transaction: Any) { - let transaction = transaction as! YapDatabaseReadWriteTransaction - // Workaround for some YapDatabase funkiness where pointer at this point can actually be a TSAttachmentStream - guard pointer.responds(to: #selector(setter: TSAttachmentPointer.state)) else { return } - pointer.state = state - pointer.save(with: transaction) - guard let tsMessage = TSMessage.fetch(uniqueId: tsMessageID, transaction: transaction) else { return } - MessageInvalidator.invalidate(tsMessage, with: transaction) - } - - /// Also touches the associated message. - public func persist(_ stream: TSAttachmentStream, associatedWith tsMessageID: String, using transaction: Any) { - let transaction = transaction as! YapDatabaseReadWriteTransaction - stream.save(with: transaction) - guard let tsMessage = TSMessage.fetch(uniqueId: tsMessageID, transaction: transaction) else { return } - MessageInvalidator.invalidate(tsMessage, with: transaction) - } - - private static let receivedMessageTimestampsCollection = "ReceivedMessageTimestampsCollection" - - public func getReceivedMessageTimestamps(using transaction: Any) -> [UInt64] { - var result: [UInt64] = [] - let transaction = transaction as! YapDatabaseReadWriteTransaction - transaction.enumerateRows(inCollection: Storage.receivedMessageTimestampsCollection) { _, object, _, _ in - guard let timestamps = object as? [UInt64] else { return } - result = timestamps - } - return result - } - - public func removeReceivedMessageTimestamps(_ timestamps: Set, using transaction: Any) { - var receivedMessageTimestamps = getReceivedMessageTimestamps(using: transaction) - timestamps.forEach { timestamp in - guard let index = receivedMessageTimestamps.firstIndex(of: timestamp) else { return } - receivedMessageTimestamps.remove(at: index) - } - let transaction = transaction as! YapDatabaseReadWriteTransaction - transaction.setObject(receivedMessageTimestamps, forKey: "receivedMessageTimestamps", inCollection: Storage.receivedMessageTimestampsCollection) - } - - public func addReceivedMessageTimestamp(_ timestamp: UInt64, using transaction: Any) { - var receivedMessageTimestamps = getReceivedMessageTimestamps(using: transaction) - // TODO: Do we need to sort the timestamps here? - if receivedMessageTimestamps.count > 1000 { receivedMessageTimestamps.remove(at: 0) } // Limit the size of the collection to 1000 - receivedMessageTimestamps.append(timestamp) - let transaction = transaction as! YapDatabaseReadWriteTransaction - transaction.setObject(receivedMessageTimestamps, forKey: "receivedMessageTimestamps", inCollection: Storage.receivedMessageTimestampsCollection) - } -} - diff --git a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift index e55e6bc05..edb7a62be 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift @@ -101,8 +101,10 @@ public enum AttachmentDownloadJob: JobExecutor { .with( state: .downloaded, creationTimestamp: Date().timeIntervalSince1970, - localRelativeFilePath: attachment.originalFilePath? - .substring(from: (Attachment.attachmentsFolder.count + 1)) // Leading forward slash + localRelativeFilePath: ( + attachment.localRelativeFilePath ?? + Attachment.localRelativeFilePath(from: attachment.originalFilePath) + ) ) .saved(db) } @@ -121,7 +123,7 @@ public enum AttachmentDownloadJob: JobExecutor { /// `isValid` and `duration` values based on the downloaded data and the state GRDBStorage.shared.write { db in _ = try attachment - .with(state: .failed) + .with(state: .failedDownload) .saved(db) } diff --git a/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift b/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift index de2b02743..ff4b0703e 100644 --- a/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift +++ b/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift @@ -20,7 +20,7 @@ public enum FailedAttachmentDownloadsJob: JobExecutor { GRDBStorage.shared.write { db in let changeCount: Int = try Attachment .filter(Attachment.Columns.state == Attachment.State.downloading) - .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failed)) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedDownload)) Logger.debug("Marked \(changeCount) attachments as failed") } diff --git a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift index c9c7a34dc..6db386f4e 100644 --- a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift +++ b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift @@ -38,6 +38,7 @@ extension GarbageCollectionJob { case expiredControlMessageProcessRecords case threadTypingIndicators case orphanedAttachmentFiles + case orphanedProfileAvatars } public struct Details: Codable { diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift index b2cbece63..f39421bec 100644 --- a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift @@ -47,7 +47,7 @@ public enum MessageSendJob: JobExecutor { // If there were failed attachments then this job should fail (can't send a // message which has associated attachments if the attachments fail to upload) - guard !allAttachmentStateInfo.contains(where: { $0.state == .failed }) else { + guard !allAttachmentStateInfo.contains(where: { $0.state == .failedDownload }) else { return (true, false) } @@ -60,7 +60,7 @@ public enum MessageSendJob: JobExecutor { // but not on the message recipients device - both LinkPreview and Quote can // have this case) try allAttachmentStateInfo - .filter { $0.state == .pending || $0.state == .downloaded } + .filter { $0.state == .uploading || $0.state == .downloaded } .compactMap { stateInfo in JobRunner .insert( diff --git a/SessionMessagingKit/Sending & Receiving/Attachments/OWSThumbnailService.swift b/SessionMessagingKit/Sending & Receiving/Attachments/OWSThumbnailService.swift deleted file mode 100644 index 7bd9c4928..000000000 --- a/SessionMessagingKit/Sending & Receiving/Attachments/OWSThumbnailService.swift +++ /dev/null @@ -1,177 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -import Foundation -import AVFoundation - -public enum OWSThumbnailError: Error { - case failure(description: String) - case assertionFailure(description: String) - case externalError(description: String, underlyingError:Error) -} - -@objc public class OWSLoadedThumbnail: NSObject { - public typealias DataSourceBlock = () throws -> Data - - @objc - public let image: UIImage - let dataSourceBlock: DataSourceBlock - - @objc - public init(image: UIImage, filePath: String) { - self.image = image - self.dataSourceBlock = { - return try Data(contentsOf: URL(fileURLWithPath: filePath)) - } - } - - @objc - public init(image: UIImage, data: Data) { - self.image = image - self.dataSourceBlock = { - return data - } - } - - @objc - public func data() throws -> Data { - return try dataSourceBlock() - } -} - -private struct OWSThumbnailRequest { - public typealias SuccessBlock = (OWSLoadedThumbnail) -> Void - public typealias FailureBlock = (Error) -> Void - - let attachment: Attachment - let dimensions: UInt - let success: SuccessBlock - let failure: FailureBlock -} - -@objc public class OWSThumbnailService: NSObject { - - // MARK: - Singleton class - - @objc(shared) - public static let shared = OWSThumbnailService() - - public typealias SuccessBlock = (OWSLoadedThumbnail) -> Void - public typealias FailureBlock = (Error) -> Void - - private let serialQueue = DispatchQueue(label: "OWSThumbnailService") - - // This property should only be accessed on the serialQueue. - // - // We want to process requests in _reverse_ order in which they - // arrive so that we prioritize the most recent view state. - private var thumbnailRequestStack = [OWSThumbnailRequest]() - - private func canThumbnailAttachment(attachment: Attachment) -> Bool { - return attachment.isImage || attachment.isAnimated || attachment.isVideo - } - - // success and failure will be called async _off_ the main thread. - @objc - public func ensureThumbnail(forAttachment attachment: TSAttachmentStream, - thumbnailDimensionPoints: UInt, - success: @escaping SuccessBlock, - failure: @escaping FailureBlock) { - serialQueue.async { - let thumbnailRequest = OWSThumbnailRequest(attachment: attachment, thumbnailDimensionPoints: thumbnailDimensionPoints, success: success, failure: failure) - self.thumbnailRequestStack.append(thumbnailRequest) - - public func ensureThumbnail( - for attachment: Attachment, - dimensions: UInt, - success: @escaping SuccessBlock, - failure: @escaping FailureBlock - ) { - serialQueue.async { - self.thumbnailRequestStack.append( - OWSThumbnailRequest( - attachment: attachment, - dimensions: dimensions, - success: success, - failure: failure - ) - ) - - self.processNextRequestSync() - } - } - - private func processNextRequestAsync() { - serialQueue.async { - self.processNextRequestSync() - } - } - - // This should only be called on the serialQueue. - private func processNextRequestSync() { - guard let thumbnailRequest = thumbnailRequestStack.popLast() else { - return - } - - do { - let loadedThumbnail = try process(thumbnailRequest: thumbnailRequest) - DispatchQueue.global().async { - thumbnailRequest.success(loadedThumbnail) - } - } catch { - DispatchQueue.global().async { - thumbnailRequest.failure(error) - } - } - } - - // This should only be called on the serialQueue. - // - // It should be safe to assume that an attachment will never end up with two thumbnails of - // the same size since: - // - // * Thumbnails are only added by this method. - // * This method checks for an existing thumbnail using the same connection. - // * This method is performed on the serial queue. - private func process(thumbnailRequest: OWSThumbnailRequest) throws -> OWSLoadedThumbnail { - let attachment = thumbnailRequest.attachment - guard canThumbnailAttachment(attachment: attachment) else { - throw OWSThumbnailError.failure(description: "Cannot thumbnail attachment.") - } - let thumbnailPath = attachment.thumbnailPath(for: thumbnailRequest.dimensions) - if FileManager.default.fileExists(atPath: thumbnailPath) { - guard let image = UIImage(contentsOfFile: thumbnailPath) else { - throw OWSThumbnailError.failure(description: "Could not load thumbnail.") - } - return OWSLoadedThumbnail(image: image, filePath: thumbnailPath) - } - - let thumbnailDirPath = (thumbnailPath as NSString).deletingLastPathComponent - guard OWSFileSystem.ensureDirectoryExists(thumbnailDirPath) else { - throw OWSThumbnailError.failure(description: "Could not create attachment's thumbnail directory.") - } - guard let originalFilePath = attachment.originalFilePath else { - throw OWSThumbnailError.failure(description: "Missing original file path.") - } - let maxDimension = CGFloat(thumbnailRequest.dimensions) - let thumbnailImage: UIImage - if attachment.isImage || attachment.isAnimated { - thumbnailImage = try OWSMediaUtils.thumbnail(forImageAtPath: originalFilePath, maxDimension: maxDimension) - } else if attachment.isVideo { - thumbnailImage = try OWSMediaUtils.thumbnail(forVideoAtPath: originalFilePath, maxDimension: maxDimension) - } else { - throw OWSThumbnailError.assertionFailure(description: "Invalid attachment type.") - } - guard let thumbnailData = thumbnailImage.jpegData(compressionQuality: 0.85) else { - throw OWSThumbnailError.failure(description: "Could not convert thumbnail to JPEG.") - } - do { - try thumbnailData.write(to: URL(fileURLWithPath: thumbnailPath, isDirectory: false), options: .atomic) - } catch let error as NSError { - throw OWSThumbnailError.externalError(description: "File write failed: \(thumbnailPath), \(error)", underlyingError: error) - } - OWSFileSystem.protectFileOrFolder(atPath: thumbnailPath) - return OWSLoadedThumbnail(image: thumbnailImage, data: thumbnailData) - } -} diff --git a/SessionMessagingKit/Sending & Receiving/Attachments/ThumbnailService.swift b/SessionMessagingKit/Sending & Receiving/Attachments/ThumbnailService.swift new file mode 100644 index 000000000..c8c66f149 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Attachments/ThumbnailService.swift @@ -0,0 +1,174 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import AVFoundation +import SessionUtilitiesKit + +public class ThumbnailService { + // MARK: - Singleton class + + public static let shared: ThumbnailService = ThumbnailService() + + public typealias SuccessBlock = (LoadedThumbnail) -> Void + public typealias FailureBlock = (Error) -> Void + + private let serialQueue = DispatchQueue(label: "ThumbnailService") + + // This property should only be accessed on the serialQueue. + // + // We want to process requests in _reverse_ order in which they + // arrive so that we prioritize the most recent view state. + private var requestStack = [Request]() + + private func canThumbnailAttachment(attachment: Attachment) -> Bool { + return attachment.isImage || attachment.isAnimated || attachment.isVideo + } + + public func ensureThumbnail( + for attachment: Attachment, + dimensions: UInt, + success: @escaping SuccessBlock, + failure: @escaping FailureBlock + ) { + serialQueue.async { + self.requestStack.append( + Request( + attachment: attachment, + dimensions: dimensions, + success: success, + failure: failure + ) + ) + + self.processNextRequestSync() + } + } + + private func processNextRequestAsync() { + serialQueue.async { + self.processNextRequestSync() + } + } + + // This should only be called on the serialQueue. + private func processNextRequestSync() { + guard let thumbnailRequest = requestStack.popLast() else { return } + + do { + let loadedThumbnail = try process(thumbnailRequest: thumbnailRequest) + DispatchQueue.global().async { + thumbnailRequest.success(loadedThumbnail) + } + } + catch { + DispatchQueue.global().async { + thumbnailRequest.failure(error) + } + } + } + + // This should only be called on the serialQueue. + // + // It should be safe to assume that an attachment will never end up with two thumbnails of + // the same size since: + // + // * Thumbnails are only added by this method. + // * This method checks for an existing thumbnail using the same connection. + // * This method is performed on the serial queue. + private func process(thumbnailRequest: Request) throws -> LoadedThumbnail { + let attachment = thumbnailRequest.attachment + + guard canThumbnailAttachment(attachment: attachment) else { + throw ThumbnailError.failure(description: "Cannot thumbnail attachment.") + } + + let thumbnailPath = attachment.thumbnailPath(for: thumbnailRequest.dimensions) + + if FileManager.default.fileExists(atPath: thumbnailPath) { + guard let image = UIImage(contentsOfFile: thumbnailPath) else { + throw ThumbnailError.failure(description: "Could not load thumbnail.") + } + return LoadedThumbnail(image: image, filePath: thumbnailPath) + } + + let thumbnailDirPath = (thumbnailPath as NSString).deletingLastPathComponent + + guard OWSFileSystem.ensureDirectoryExists(thumbnailDirPath) else { + throw ThumbnailError.failure(description: "Could not create attachment's thumbnail directory.") + } + guard let originalFilePath = attachment.originalFilePath else { + throw ThumbnailError.failure(description: "Missing original file path.") + } + + let maxDimension = CGFloat(thumbnailRequest.dimensions) + let thumbnailImage: UIImage + + if attachment.isImage || attachment.isAnimated { + thumbnailImage = try OWSMediaUtils.thumbnail(forImageAtPath: originalFilePath, maxDimension: maxDimension) + } + else if attachment.isVideo { + thumbnailImage = try OWSMediaUtils.thumbnail(forVideoAtPath: originalFilePath, maxDimension: maxDimension) + } + else { + throw ThumbnailError.assertionFailure(description: "Invalid attachment type.") + } + + guard let thumbnailData = thumbnailImage.jpegData(compressionQuality: 0.85) else { + throw ThumbnailError.failure(description: "Could not convert thumbnail to JPEG.") + } + + do { + try thumbnailData.write(to: URL(fileURLWithPath: thumbnailPath, isDirectory: false), options: .atomic) + } + catch let error as NSError { + throw ThumbnailError.externalError(description: "File write failed: \(thumbnailPath), \(error)", underlyingError: error) + } + + OWSFileSystem.protectFileOrFolder(atPath: thumbnailPath) + + return LoadedThumbnail(image: thumbnailImage, data: thumbnailData) + } +} + +public extension ThumbnailService { + enum ThumbnailError: Error { + case failure(description: String) + case assertionFailure(description: String) + case externalError(description: String, underlyingError: Error) + } + + struct LoadedThumbnail { + public typealias DataSourceBlock = () throws -> Data + + public let image: UIImage + public let dataSourceBlock: DataSourceBlock + + public init(image: UIImage, filePath: String) { + self.image = image + self.dataSourceBlock = { + return try Data(contentsOf: URL(fileURLWithPath: filePath)) + } + } + + public init(image: UIImage, data: Data) { + self.image = image + self.dataSourceBlock = { + return data + } + } + + public func data() throws -> Data { + return try dataSourceBlock() + } + } + + private struct Request { + public typealias SuccessBlock = (LoadedThumbnail) -> Void + public typealias FailureBlock = (Error) -> Void + + let attachment: Attachment + let dimensions: UInt + let success: SuccessBlock + let failure: FailureBlock + } +} diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index 1dab9ab3f..6e092a15b 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -392,7 +392,7 @@ extension MessageReceiver { let messageSentTimestamp: TimeInterval = (TimeInterval(message.sentTimestamp ?? 0) / 1000) let isMainAppActive: Bool = (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) - // Update profile if needed (want to do this regarless of whether the message exists or + // Update profile if needed (want to do this regardless of whether the message exists or // not to ensure the profile info gets sync between a users devices at every chance) if let profile = message.profile { var contactProfileKey: OWSAES256Key? = nil diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index 716d74ba5..9325c1362 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -116,7 +116,7 @@ extension MessageSender { }() let openGroup: OpenGroup? = try? OpenGroup.fetchOne(db, id: threadId) let attachmentStateInfo: [Attachment.StateInfo] = (try? Attachment - .stateInfo(interactionId: interactionId, state: .pending) + .stateInfo(interactionId: interactionId, state: .uploading) .fetchAll(db)) .defaulting(to: []) diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift index 8fccb96ec..9e07f583c 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift @@ -1,5 +1,8 @@ -import SessionSnodeKit +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import GRDB import PromiseKit +import SessionSnodeKit @objc(LKPushNotificationAPI) public final class PushNotificationAPI : NSObject { @@ -44,10 +47,20 @@ public final class PushNotificationAPI : NSObject { promise.catch2 { error in SNLog("Couldn't unregister from push notifications.") } - // Unsubscribe from all closed groups - Storage.shared.getUserClosedGroupPublicKeys().forEach { closedGroupPublicKey in - performOperation(.unsubscribe, for: closedGroupPublicKey, publicKey: getUserHexEncodedPublicKey()) + + // Unsubscribe from all closed groups (including ones the user is no longer a member of, just in case) + GRDBStorage.shared.read { db in + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + try ClosedGroup + .select(.threadId) + .asRequest(of: String.self) + .fetchAll(db) + .forEach { closedGroupPublicKey in + performOperation(.unsubscribe, for: closedGroupPublicKey, publicKey: userPublicKey) + } } + return promise } @@ -86,10 +99,22 @@ public final class PushNotificationAPI : NSObject { promise.catch2 { error in SNLog("Couldn't register device token.") } + // Subscribe to all closed groups - Storage.shared.getUserClosedGroupPublicKeys().forEach { closedGroupPublicKey in - performOperation(.subscribe, for: closedGroupPublicKey, publicKey: publicKey) + GRDBStorage.shared.read { db in + try ClosedGroup + .select(.threadId) + .joining( + required: ClosedGroup.members + .filter(GroupMember.Columns.profileId == getUserHexEncodedPublicKey(db)) + ) + .asRequest(of: String.self) + .fetchAll(db) + .forEach { closedGroupPublicKey in + performOperation(.subscribe, for: closedGroupPublicKey, publicKey: publicKey) + } } + return promise } diff --git a/Session/Shared/Models/ConversationCellViewModel.swift b/SessionMessagingKit/Shared Models/ConversationCellViewModel.swift similarity index 88% rename from Session/Shared/Models/ConversationCellViewModel.swift rename to SessionMessagingKit/Shared Models/ConversationCellViewModel.swift index e242c532b..4126a2ffb 100644 --- a/Session/Shared/Models/ConversationCellViewModel.swift +++ b/SessionMessagingKit/Shared Models/ConversationCellViewModel.swift @@ -3,10 +3,13 @@ import Foundation import GRDB import DifferenceKit -import SessionMessagingKit fileprivate typealias ViewModel = ConversationCell.ViewModel +public enum ConversationCell {} + +// MARK: - ViewModel + extension ConversationCell { /// This type is used to populate the `ConversationCell` in the `HomeVC`, `MessageRequestsViewController` and the /// `GlobalSearchViewController`, it has a number of query methods which can be used to retrieve the relevant data for each @@ -53,7 +56,7 @@ extension ConversationCell { public static let closedGroupProfileBackFallbackString: String = CodingKeys.closedGroupProfileBackFallback.stringValue public static let interactionAttachmentDescriptionInfoString: String = CodingKeys.interactionAttachmentDescriptionInfo.stringValue - public var differenceIdentifier: ViewModel { self } + public var differenceIdentifier: ViewModel { self } // TODO: Confirm this does what we want (ie. update on any data change) public let threadId: String public let threadVariant: SessionThread.Variant @@ -243,6 +246,7 @@ public extension ConversationCell.ViewModel { /// Explicitly set default values for the fields ignored for search results let numColumnsBeforeProfiles: Int = 11 let numColumnsBetweenProfilesAndAttachmentInfo: Int = 10 + // TODO: Some testing around the subqueries in the joins to see if they impact performance ('Simulator1' device takes ~125ms to complete this query) let request: SQLRequest = """ SELECT \(thread[.id]) AS \(ViewModel.threadIdKey), @@ -391,6 +395,7 @@ public extension ConversationCell.ViewModel { GROUP BY \(thread[.id]) ORDER BY \(ordering) """ + return request.adapted { db in let adapters = try splittingRowAdapters(columnCounts: [ numColumnsBeforeProfiles, @@ -523,7 +528,7 @@ public extension ConversationCell.ViewModel { /// parse and might throw /// /// Explicitly set default values for the fields ignored for search results - let numColumnsBeforeProfiles: Int = 11 + let numColumnsBeforeProfiles: Int = 5 let request: SQLRequest = """ SELECT \(thread[.id]) AS \(ViewModel.threadIdKey), @@ -680,14 +685,9 @@ public extension ConversationCell.ViewModel { """ - // Contact thread nickname searching (ignoring note to self - handled separately) - sqlQuery += selectQuery - sqlQuery += """ + // MARK: --Contact Threads + let contactQueryCommonJoinFilterGroup: SQL = """ JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) - JOIN \(profileFullTextSearch) ON ( - \(profileFullTextSearch).rowid = \(ViewModel.contactProfileKey).rowid AND - \(profileFullTextSearch).\(profileNicknameColumnLiteral) MATCH \(pattern) - ) LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON false LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON false LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON false @@ -706,6 +706,16 @@ public extension ConversationCell.ViewModel { GROUP BY \(thread[.id]) """ + // Contact thread nickname searching (ignoring note to self - handled separately) + sqlQuery += selectQuery + sqlQuery += """ + JOIN \(profileFullTextSearch) ON ( + \(profileFullTextSearch).rowid = \(ViewModel.contactProfileKey).rowid AND + \(profileFullTextSearch).\(profileNicknameColumnLiteral) MATCH \(pattern) + ) + """ + sqlQuery += contactQueryCommonJoinFilterGroup + // Contact thread name searching (ignoring note to self - handled separately) sqlQuery += """ @@ -714,26 +724,61 @@ public extension ConversationCell.ViewModel { """ sqlQuery += selectQuery sqlQuery += """ - JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) JOIN \(profileFullTextSearch) ON ( \(profileFullTextSearch).rowid = \(ViewModel.contactProfileKey).rowid AND \(profileFullTextSearch).\(profileNameColumnLiteral) MATCH \(pattern) ) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON false - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON false - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON false - LEFT JOIN \(ClosedGroup.self) ON false - LEFT JOIN \(OpenGroup.self) ON false + """ + sqlQuery += contactQueryCommonJoinFilterGroup + + // MARK: --Closed Group Threads + let closedGroupQueryCommonJoinFilterGroup: SQL = """ + JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) + JOIN \(GroupMember.self) ON ( + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND + \(groupMember[.groupId]) = \(thread[.id]) + ) LEFT JOIN ( SELECT \(groupMember[.groupId]), - '' AS \(ViewModel.threadMemberNamesKey) + GROUP_CONCAT(IFNULL(\(profile[.nickname]), \(profile[.name])), ', ') AS \(ViewModel.threadMemberNamesKey) FROM \(GroupMember.self) - ) AS \(groupMemberInfoLiteral) ON false + JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) + WHERE \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) + GROUP BY \(groupMember[.groupId]) + ) AS \(groupMemberInfoLiteral) ON \(groupMemberInfoLiteral).\(groupMemberGroupIdColumnLiteral) = \(closedGroup[.threadId]) + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON ( + \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( + SELECT MIN(\(groupMember[.profileId])) + FROM \(GroupMember.self) + WHERE ( + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND + \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(groupMember[.profileId]) != \(userPublicKey) + ) + ) + ) + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON ( + \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) != \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) AND + \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) = ( + SELECT MAX(\(groupMember[.profileId])) + FROM \(GroupMember.self) + WHERE ( + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND + \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(groupMember[.profileId]) != \(userPublicKey) + ) + ) + ) + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON ( + \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) IS NULL AND + \(ViewModel.closedGroupProfileBackFallbackKey).\(profileIdColumnLiteral) = \(userPublicKey) + ) - WHERE - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND - \(SQL("\(thread[.id]) != \(userPublicKey)")) + LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON false + LEFT JOIN \(OpenGroup.self) ON false + + WHERE \(SQL("\(thread[.variant]) = \(SessionThread.Variant.closedGroup)")) GROUP BY \(thread[.id]) """ @@ -745,58 +790,12 @@ public extension ConversationCell.ViewModel { """ sqlQuery += selectQuery sqlQuery += """ - JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) - JOIN \(GroupMember.self) ON ( - \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND - \(groupMember[.groupId]) = \(thread[.id]) - ) JOIN \(closedGroupFullTextSearch) ON ( \(closedGroupFullTextSearch).rowid = \(closedGroupLiteral).rowid AND \(closedGroupFullTextSearch).\(closedGroupNameColumnLiteral) MATCH \(pattern) ) - LEFT JOIN ( - SELECT - \(groupMember[.groupId]), - GROUP_CONCAT(IFNULL(\(profile[.nickname]), \(profile[.name])), ', ') AS \(ViewModel.threadMemberNamesKey) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) - GROUP BY \(groupMember[.groupId]) - ) AS \(groupMemberInfoLiteral) ON \(groupMemberInfoLiteral).\(groupMemberGroupIdColumnLiteral) = \(closedGroup[.threadId]) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON ( - \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( - SELECT MIN(\(groupMember[.profileId])) - FROM \(GroupMember.self) - WHERE ( - \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(groupMember[.profileId]) != \(userPublicKey) - ) - ) - ) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON ( - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) != \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) AND - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) = ( - SELECT MAX(\(groupMember[.profileId])) - FROM \(GroupMember.self) - WHERE ( - \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(groupMember[.profileId]) != \(userPublicKey) - ) - ) - ) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON ( - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) IS NULL AND - \(ViewModel.closedGroupProfileBackFallbackKey).\(profileIdColumnLiteral) = \(userPublicKey) - ) - - LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON false - LEFT JOIN \(OpenGroup.self) ON false - - WHERE \(SQL("\(thread[.variant]) = \(SessionThread.Variant.closedGroup)")) - GROUP BY \(thread[.id]) """ + sqlQuery += closedGroupQueryCommonJoinFilterGroup // Closed group member nickname searching sqlQuery += """ @@ -806,59 +805,13 @@ public extension ConversationCell.ViewModel { """ sqlQuery += selectQuery sqlQuery += """ - JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) - JOIN \(GroupMember.self) ON ( - \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND - \(groupMember[.groupId]) = \(thread[.id]) - ) JOIN \(Profile.self) AS \(groupMemberProfileLiteral) ON \(groupMemberProfileLiteral).\(profileIdColumnLiteral) = \(groupMember[.profileId]) JOIN \(profileFullTextSearch) ON ( \(profileFullTextSearch).rowid = \(groupMemberProfileLiteral).rowid AND \(profileFullTextSearch).\(profileNicknameColumnLiteral) MATCH \(pattern) ) - LEFT JOIN ( - SELECT - \(groupMember[.groupId]), - GROUP_CONCAT(IFNULL(\(profile[.nickname]), \(profile[.name])), ', ') AS \(ViewModel.threadMemberNamesKey) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) - GROUP BY \(groupMember[.groupId]) - ) AS \(groupMemberInfoLiteral) ON \(groupMemberInfoLiteral).\(groupMemberGroupIdColumnLiteral) = \(closedGroup[.threadId]) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON ( - \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( - SELECT MIN(\(groupMember[.profileId])) - FROM \(GroupMember.self) - WHERE ( - \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(groupMember[.profileId]) != \(userPublicKey) - ) - ) - ) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON ( - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) != \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) AND - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) = ( - SELECT MAX(\(groupMember[.profileId])) - FROM \(GroupMember.self) - WHERE ( - \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(groupMember[.profileId]) != \(userPublicKey) - ) - ) - ) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON ( - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) IS NULL AND - \(ViewModel.closedGroupProfileBackFallbackKey).\(profileIdColumnLiteral) = \(userPublicKey) - ) - - LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON false - LEFT JOIN \(OpenGroup.self) ON false - - WHERE \(SQL("\(thread[.variant]) = \(SessionThread.Variant.closedGroup)")) - GROUP BY \(thread[.id]) """ + sqlQuery += closedGroupQueryCommonJoinFilterGroup // Closed group member name searching sqlQuery += """ @@ -868,60 +821,15 @@ public extension ConversationCell.ViewModel { """ sqlQuery += selectQuery sqlQuery += """ - JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) - JOIN \(GroupMember.self) ON ( - \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND - \(groupMember[.groupId]) = \(thread[.id]) - ) JOIN \(Profile.self) AS \(groupMemberProfileLiteral) ON \(groupMemberProfileLiteral).\(profileIdColumnLiteral) = \(groupMember[.profileId]) JOIN \(profileFullTextSearch) ON ( \(profileFullTextSearch).rowid = \(groupMemberProfileLiteral).rowid AND \(profileFullTextSearch).\(profileNameColumnLiteral) MATCH \(pattern) ) - LEFT JOIN ( - SELECT - \(groupMember[.groupId]), - GROUP_CONCAT(IFNULL(\(profile[.nickname]), \(profile[.name])), ', ') AS \(ViewModel.threadMemberNamesKey) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) - GROUP BY \(groupMember[.groupId]) - ) AS \(groupMemberInfoLiteral) ON \(groupMemberInfoLiteral).\(groupMemberGroupIdColumnLiteral) = \(closedGroup[.threadId]) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON ( - \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( - SELECT MIN(\(groupMember[.profileId])) - FROM \(GroupMember.self) - WHERE ( - \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(groupMember[.profileId]) != \(userPublicKey) - ) - ) - ) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON ( - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) != \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) AND - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) = ( - SELECT MAX(\(groupMember[.profileId])) - FROM \(GroupMember.self) - WHERE ( - \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(groupMember[.profileId]) != \(userPublicKey) - ) - ) - ) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON ( - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) IS NULL AND - \(ViewModel.closedGroupProfileBackFallbackKey).\(profileIdColumnLiteral) = \(userPublicKey) - ) - - LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON false - LEFT JOIN \(OpenGroup.self) ON false - - WHERE \(SQL("\(thread[.variant]) = \(SessionThread.Variant.closedGroup)")) - GROUP BY \(thread[.id]) """ + sqlQuery += closedGroupQueryCommonJoinFilterGroup + // MARK: --Open Group Threads // Open group thread name searching sqlQuery += """ @@ -953,17 +861,9 @@ public extension ConversationCell.ViewModel { GROUP BY \(thread[.id]) """ - // Note to self thread searching for 'Note to Self' (need to join an FTS table to - // ensure there is a 'rank' column) - sqlQuery += """ - - UNION ALL - - """ - sqlQuery += selectQuery - sqlQuery += """ + // MARK: --Note to Self Thread + let noteToSelfQueryCommonJoins: SQL = """ JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) - LEFT JOIN \(profileFullTextSearch) ON false LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON false LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON false LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON false @@ -975,6 +875,22 @@ public extension ConversationCell.ViewModel { '' AS \(ViewModel.threadMemberNamesKey) FROM \(GroupMember.self) ) AS \(groupMemberInfoLiteral) ON false + """ + + // Note to self thread searching for 'Note to Self' (need to join an FTS table to + // ensure there is a 'rank' column) + sqlQuery += """ + + UNION ALL + + """ + sqlQuery += selectQuery + sqlQuery += """ + + LEFT JOIN \(profileFullTextSearch) ON false + """ + sqlQuery += noteToSelfQueryCommonJoins + sqlQuery += """ WHERE \(SQL("\(thread[.id]) = \(userPublicKey)")) AND @@ -989,22 +905,14 @@ public extension ConversationCell.ViewModel { """ sqlQuery += selectQuery sqlQuery += """ - JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) + JOIN \(profileFullTextSearch) ON ( \(profileFullTextSearch).rowid = \(ViewModel.contactProfileKey).rowid AND \(profileFullTextSearch).\(profileNicknameColumnLiteral) MATCH \(pattern) ) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON false - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON false - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON false - LEFT JOIN \(ClosedGroup.self) ON false - LEFT JOIN \(OpenGroup.self) ON false - LEFT JOIN ( - SELECT - \(groupMember[.groupId]), - '' AS \(ViewModel.threadMemberNamesKey) - FROM \(GroupMember.self) - ) AS \(groupMemberInfoLiteral) ON false + """ + sqlQuery += noteToSelfQueryCommonJoins + sqlQuery += """ WHERE \(SQL("\(thread[.id]) = \(userPublicKey)")) """ @@ -1017,22 +925,14 @@ public extension ConversationCell.ViewModel { """ sqlQuery += selectQuery sqlQuery += """ - JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) + JOIN \(profileFullTextSearch) ON ( \(profileFullTextSearch).rowid = \(ViewModel.contactProfileKey).rowid AND \(profileFullTextSearch).\(profileNameColumnLiteral) MATCH \(pattern) ) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON false - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON false - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON false - LEFT JOIN \(ClosedGroup.self) ON false - LEFT JOIN \(OpenGroup.self) ON false - LEFT JOIN ( - SELECT - \(groupMember[.groupId]), - '' AS \(ViewModel.threadMemberNamesKey) - FROM \(GroupMember.self) - ) AS \(groupMemberInfoLiteral) ON false + """ + sqlQuery += noteToSelfQueryCommonJoins + sqlQuery += """ WHERE \(SQL("\(thread[.id]) = \(userPublicKey)")) """ @@ -1090,3 +990,119 @@ public extension ConversationCell.ViewModel { } } } + +// MARK: - Share Extension + +public extension ConversationCell.ViewModel { + static func shareQuery(userPublicKey: String) -> AdaptedFetchRequest> { + let thread: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() + let closedGroup: TypedTableAlias = TypedTableAlias() + let groupMember: TypedTableAlias = TypedTableAlias() + let openGroup: TypedTableAlias = TypedTableAlias() + let interaction: TypedTableAlias = TypedTableAlias() + + let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) + + /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before + /// the `ViewModel.closedGroupProfileFrontKey` entry below otherwise the query will fail to + /// parse and might throw + /// + /// Explicitly set default values for the fields ignored for search results + let numColumnsBeforeProfiles: Int = 6 + + let request: SQLRequest = """ + SELECT + \(thread[.id]) AS \(ViewModel.threadIdKey), + \(thread[.variant]) AS \(ViewModel.threadVariantKey), + \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), + + (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.threadIsNoteToSelfKey), + \(thread[.isPinned]) AS \(ViewModel.threadIsPinnedKey), + \(contact[.isBlocked]) AS \(ViewModel.threadIsBlockedKey), + + \(ViewModel.contactProfileKey).*, + \(ViewModel.closedGroupProfileFrontKey).*, + \(ViewModel.closedGroupProfileBackKey).*, + \(ViewModel.closedGroupProfileBackFallbackKey).*, + \(closedGroup[.name]) AS \(ViewModel.closedGroupNameKey), + \(openGroup[.name]) AS \(ViewModel.openGroupNameKey), + \(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey), + + \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) + + FROM \(SessionThread.self) + LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) + LEFT JOIN ( + SELECT *, MAX(\(interaction[.timestampMs])) + FROM \(Interaction.self) + GROUP BY \(interaction[.threadId]) + ) AS \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id]) + LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) + LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) + LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(interaction[.threadId]) + + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON ( + \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( + SELECT MIN(\(groupMember[.profileId])) + FROM \(GroupMember.self) + WHERE ( + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND + \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(SQL("\(groupMember[.profileId]) != \(userPublicKey)")) + ) + ) + ) + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON ( + \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) != \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) AND + \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) = ( + SELECT MAX(\(groupMember[.profileId])) + FROM \(GroupMember.self) + WHERE ( + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND + \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(SQL("\(groupMember[.profileId]) != \(userPublicKey)")) + ) + ) + ) + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON ( + \(closedGroup[.threadId]) IS NOT NULL AND + \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) IS NULL AND + \(ViewModel.closedGroupProfileBackFallbackKey).\(profileIdColumnLiteral) = \(SQL("\(userPublicKey)")) + ) + + WHERE ( + \(thread[.shouldBeVisible]) = true AND ( + -- Is not a message request + \(SQL("\(thread[.variant]) != \(SessionThread.Variant.contact)")) OR + \(SQL("\(thread[.id]) = \(userPublicKey)")) OR + \(contact[.isApproved]) = true + ) AND ( + -- Only show the 'Note to Self' thread if it has an interaction + \(SQL("\(thread[.id]) != \(userPublicKey)")) OR + \(interaction[.id]) IS NOT NULL + ) + ) + + GROUP BY \(thread[.id]) + ORDER BY IFNULL(\(interaction[.timestampMs]), \(thread[.creationDateTimestamp])) DESC + """ + + return request.adapted { db in + let adapters = try splittingRowAdapters(columnCounts: [ + numColumnsBeforeProfiles, + Profile.numberOfSelectedColumns(db), + Profile.numberOfSelectedColumns(db), + Profile.numberOfSelectedColumns(db), + Profile.numberOfSelectedColumns(db) + ]) + + return ScopeAdapter([ + ViewModel.contactProfileString: adapters[1], + ViewModel.closedGroupProfileFrontString: adapters[2], + ViewModel.closedGroupProfileBackString: adapters[3], + ViewModel.closedGroupProfileBackFallbackString: adapters[4] + ]) + } + } +} diff --git a/SessionMessagingKit/Utilities/ProfileManager.swift b/SessionMessagingKit/Utilities/ProfileManager.swift index f74e5711a..edcdb3f52 100644 --- a/SessionMessagingKit/Utilities/ProfileManager.swift +++ b/SessionMessagingKit/Utilities/ProfileManager.swift @@ -98,7 +98,7 @@ public struct ProfileManager { public static func profileAvatarFilepath(filename: String) -> String { guard !filename.isEmpty else { return "" } - return URL(fileURLWithPath: OWSFileSystem.appSharedDataDirectoryPath()) + return URL(fileURLWithPath: sharedDataProfileAvatarsDirPath) .appendingPathComponent(filename) .path } diff --git a/SessionMessagingKit/Utilities/ProofOfWork.swift b/SessionMessagingKit/Utilities/ProofOfWork.swift deleted file mode 100644 index 4cb500c97..000000000 --- a/SessionMessagingKit/Utilities/ProofOfWork.swift +++ /dev/null @@ -1,36 +0,0 @@ -import SessionSnodeKit -import SessionUtilitiesKit - -enum ProofOfWork { - - /// A modified version of [Bitmessage's Proof of Work Implementation](https://bitmessage.org/wiki/Proof_of_work). - static func calculate(ttl: UInt64, publicKey: String, data: String) -> (timestamp: UInt64, base64EncodedNonce: String)? { - let nonceSize = MemoryLayout.size - // Get millisecond timestamp - let timestamp = NSDate.millisecondTimestamp() - // Construct payload - let payloadAsString = String(timestamp) + String(ttl) + publicKey + data - let payload = payloadAsString.bytes - // Calculate target - let numerator = UInt64.max - let difficulty = UInt64(1) - let totalSize = UInt64(payload.count + nonceSize) - let ttlInSeconds = ttl / 1000 - let denominator = difficulty * (totalSize + (ttlInSeconds * totalSize) / UInt64(UInt16.max)) - let target = numerator / denominator - // Calculate proof of work - var value = UInt64.max - let payloadHash = payload.sha512() - var nonce = UInt64(0) - while value > target { - nonce = nonce &+ 1 - let hash = (nonce.bigEndianBytes + payloadHash).sha512() - guard let newValue = UInt64(fromBigEndianBytes: [UInt8](hash[0.. DataSource? { if utiType == (kUTTypeURL as String) { - // Share URLs as oversize text messages whose text content is the URL. - // - // NOTE: SharingThreadPickerViewController will try to unpack them - // and send them as normal text messages if possible. - let urlString = url.absoluteString - return DataSourceValue.dataSource(withOversizeText: urlString) + // Share URLs as text messages whose text content is the URL + return DataSourceValue.dataSource(withText: url.absoluteString) } else if UTTypeConformsTo(utiType as CFString, kUTTypeText) { // Share text as oversize text messages. diff --git a/SessionShareExtension/SimplifiedConversationCell.swift b/SessionShareExtension/SimplifiedConversationCell.swift index ad0eddfc9..c1dc7cca8 100644 --- a/SessionShareExtension/SimplifiedConversationCell.swift +++ b/SessionShareExtension/SimplifiedConversationCell.swift @@ -87,16 +87,16 @@ final class SimplifiedConversationCell: UITableViewCell { // MARK: - Updating - public func update(with item: ThreadPickerViewModel.Item, currentUserProfile: Profile) { - accentLineView.alpha = (item.isBlocked ? 1 : 0) + public func update(with cellViewModel: ConversationCell.ViewModel) { + accentLineView.alpha = (cellViewModel.threadIsBlocked == true ? 1 : 0) profilePictureView.update( - publicKey: item.id, - profile: item.profile(currentUserProfile: currentUserProfile), - additionalProfile: item.additionalProfile, - threadVariant: item.variant, - openGroupProfilePicture: item.openGroupProfilePictureData.map { UIImage(data: $0) }, - useFallbackPicture: (item.variant == .openGroup && item.openGroupProfilePictureData == nil) + publicKey: cellViewModel.threadId, + profile: cellViewModel.profile, + additionalProfile: cellViewModel.additionalProfile, + threadVariant: cellViewModel.threadVariant, + openGroupProfilePicture: cellViewModel.openGroupProfilePictureData.map { UIImage(data: $0) }, + useFallbackPicture: (cellViewModel.threadVariant == .openGroup && cellViewModel.openGroupProfilePictureData == nil) ) - displayNameLabel.text = item.displayName(currentUserProfile: currentUserProfile) + displayNameLabel.text = cellViewModel.displayName } } diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index 88400b6bc..aee2717be 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -152,7 +152,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView ) } - private func handleUpdates(_ updatedViewData: ThreadPickerViewModel.ViewData) { + private func handleUpdates(_ updatedViewData: [ConversationCell.ViewModel]) { // 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 { @@ -163,31 +163,23 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView // Reload the table content (animate changes after the first load) tableView.reload( - using: StagedChangeset(source: viewModel.viewData.items, target: updatedViewData.items), + 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( - ThreadPickerViewModel.ViewData( - currentUserProfile: updatedViewData.currentUserProfile, - items: updatedData - ) - ) + self?.viewModel.updateData(updatedData) } } // MARK: - UITableViewDataSource func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return self.viewModel.viewData.items.count + return self.viewModel.viewData.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell: SimplifiedConversationCell = tableView.dequeue(type: SimplifiedConversationCell.self, for: indexPath) - cell.update( - with: self.viewModel.viewData.items[indexPath.row], - currentUserProfile: self.viewModel.viewData.currentUserProfile - ) + cell.update(with: self.viewModel.viewData[indexPath.row]) return cell } @@ -200,7 +192,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView guard let attachments: [SignalAttachment] = ShareVC.attachmentPrepPromise?.value else { return } let approvalVC: OWSNavigationController = AttachmentApprovalViewController.wrappedInNavController( - threadId: self.viewModel.viewData.items[indexPath.row].id, + threadId: self.viewModel.viewData[indexPath.row].threadId, attachments: attachments, approvalDelegate: self ) diff --git a/SessionShareExtension/ThreadPickerViewModel.swift b/SessionShareExtension/ThreadPickerViewModel.swift index 6885dcd81..120171882 100644 --- a/SessionShareExtension/ThreadPickerViewModel.swift +++ b/SessionShareExtension/ThreadPickerViewModel.swift @@ -7,165 +7,8 @@ import SignalUtilitiesKit import SessionMessagingKit public class ThreadPickerViewModel { - // MARK: - Initialization - - init() { - viewData = ViewData( - currentUserProfile: Profile.fetchOrCreateCurrentUser(), - items: [] - ) - } - - public struct Item: FetchableRecord, Decodable, Equatable, Differentiable { - public struct GroupMemberInfo: FetchableRecord, Decodable, Equatable { - public let profile: Profile - } - - fileprivate static let closedGroupNameKey = CodingKeys.closedGroupName.stringValue - fileprivate static let openGroupNameKey = CodingKeys.openGroupName.stringValue - fileprivate static let openGroupProfilePictureDataKey = CodingKeys.openGroupProfilePictureData.stringValue - fileprivate static let contactProfileKey = CodingKeys.contactProfile.stringValue - fileprivate static let closedGroupAvatarProfilesKey = CodingKeys.closedGroupAvatarProfiles.stringValue - fileprivate static let contactIsBlockedKey = CodingKeys.contactIsBlocked.stringValue - fileprivate static let isNoteToSelfKey = CodingKeys.isNoteToSelf.stringValue - - public var differenceIdentifier: String { id } - - public let id: String - public let variant: SessionThread.Variant - - public let closedGroupName: String? - public let openGroupName: String? - public let openGroupProfilePictureData: Data? - private let contactProfile: Profile? - private let closedGroupAvatarProfiles: [GroupMemberInfo]? - - /// A flag indicating whether the contact is blocked (will be null for non-contact threads) - private let contactIsBlocked: Bool? - public let isNoteToSelf: Bool - - public func displayName(currentUserProfile: Profile) -> String { - return SessionThread.displayName( - threadId: id, - variant: variant, - closedGroupName: closedGroupName, - openGroupName: openGroupName, - isNoteToSelf: isNoteToSelf, - profile: contactProfile - ) - } - - public func profile(currentUserProfile: Profile) -> Profile? { - switch variant { - case .contact: return contactProfile - case .openGroup: return nil - case .closedGroup: - // If there is only a single user in the group then we want to use the current user - // profile at the back - if closedGroupAvatarProfiles?.count == 1 { - return currentUserProfile - } - - return closedGroupAvatarProfiles?.first?.profile - } - } - - public var additionalProfile: Profile? { - switch variant { - case .closedGroup: return closedGroupAvatarProfiles?.last?.profile - default: return nil - } - } - - /// A flag indicating whether the thread is blocked (only contact threads can be blocked) - public var isBlocked: Bool { - return (contactIsBlocked == true) - } - - // MARK: - Query - - public static func query(userPublicKey: String) -> QueryInterfaceRequest { - let thread: TypedTableAlias = TypedTableAlias() - let contact: TypedTableAlias = TypedTableAlias() - let closedGroup: TypedTableAlias = TypedTableAlias() - let openGroup: TypedTableAlias = TypedTableAlias() - let lastInteraction: TableAlias = TableAlias() - - let lastInteractionTimestampExpression: CommonTableExpression = Interaction.lastInteractionTimestamp( - timestampMsKey: Interaction.Columns.timestampMs.stringValue - ) - // FIXME: Exclude unwritable opengroups - return SessionThread - .select( - thread[.id], - thread[.variant], - thread[.creationDateTimestamp], - - closedGroup[.name].forKey(Item.closedGroupNameKey), - openGroup[.name].forKey(Item.openGroupNameKey), - openGroup[.imageData].forKey(Item.openGroupProfilePictureDataKey), - - contact[.isBlocked].forKey(Item.contactIsBlockedKey), - SessionThread.isNoteToSelf(userPublicKey: userPublicKey).forKey(Item.isNoteToSelfKey) - ) - .filter(SessionThread.Columns.shouldBeVisible == true) - .filter(SessionThread.isNotMessageRequest(userPublicKey: userPublicKey)) - .filter( - // Only show the Note to Self if it has an interaction - SessionThread.Columns.id != userPublicKey || - lastInteraction[Interaction.Columns.timestampMs] != nil - ) - .aliased(thread) - .joining( - optional: SessionThread.contact - .aliased(contact) - .including( - optional: Contact.profile - .forKey(Item.contactProfileKey) - ) - ) - .joining( - optional: SessionThread.closedGroup - .aliased(closedGroup) - .including( - all: ClosedGroup.members - .filter(GroupMember.Columns.role == GroupMember.Role.standard) - .filter(GroupMember.Columns.profileId != userPublicKey) - .order(GroupMember.Columns.profileId) // Sort to provide a level of stability - .limit(2) - .including(required: GroupMember.profile) - .forKey(Item.closedGroupAvatarProfilesKey) - ) - ) - .joining(optional: SessionThread.openGroup.aliased(openGroup)) - .with(lastInteractionTimestampExpression) - .including( - optional: SessionThread - .association( - to: lastInteractionTimestampExpression, - on: { thread, lastInteraction in - thread[SessionThread.Columns.id] == lastInteraction[Interaction.Columns.threadId] - } - ) - .aliased(lastInteraction) - ) - .order( - ( - lastInteraction[Interaction.Columns.timestampMs] ?? - (thread[.creationDateTimestamp] * 1000) - ).desc - ) - .asRequest(of: Item.self) - } - } - - public struct ViewData: Equatable { - let currentUserProfile: Profile - let items: [Item] - } - /// This value is the current state of the view - public private(set) var viewData: ViewData + public private(set) var viewData: [ConversationCell.ViewModel] = [] /// 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 @@ -173,20 +16,18 @@ public class ThreadPickerViewModel { /// **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 public lazy var observableViewData = ValueObservation - .trackingConstantRegion { db -> ViewData in - let currentUserProfile: Profile = Profile.fetchOrCreateCurrentUser(db) - return ViewData( - currentUserProfile: Profile.fetchOrCreateCurrentUser(db), - items: try Item - .query(userPublicKey: currentUserProfile.id) - .fetchAll(db) - ) + .trackingConstantRegion { db -> [ConversationCell.ViewModel] in + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + return try ConversationCell.ViewModel + .shareQuery(userPublicKey: userPublicKey) + .fetchAll(db) } .removeDuplicates() // MARK: - Functions - public func updateData(_ updatedData: ViewData) { + public func updateData(_ updatedData: [ConversationCell.ViewModel]) { self.viewData = updatedData } } diff --git a/SessionSnodeKit/OnionRequestAPI.swift b/SessionSnodeKit/OnionRequestAPI.swift index 932dadbae..d6f8871fb 100644 --- a/SessionSnodeKit/OnionRequestAPI.swift +++ b/SessionSnodeKit/OnionRequestAPI.swift @@ -432,7 +432,7 @@ public enum OnionRequestAPI { guard let bodyAsData = bodyAsString.data(using: .utf8), let body = try JSONSerialization.jsonObject(with: bodyAsData, options: [ .fragmentsAllowed ]) as? JSON else { return seal.reject(HTTP.Error.invalidJSON) } if let timestamp = body["t"] as? Int64 { - let offset = timestamp - Int64(NSDate.millisecondTimestamp()) + let offset = timestamp - Int64(floor(Date().timeIntervalSince1970 * 1000)) SnodeAPI.clockOffset = offset } guard 200...299 ~= statusCode else { diff --git a/SessionUtilitiesKit/General/NSDate+Timestamp.h b/SessionUtilitiesKit/General/NSDate+Timestamp.h deleted file mode 100644 index 87dc05235..000000000 --- a/SessionUtilitiesKit/General/NSDate+Timestamp.h +++ /dev/null @@ -1,11 +0,0 @@ -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface NSDate (Session) - -+ (uint64_t)millisecondTimestamp; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionUtilitiesKit/General/NSDate+Timestamp.mm b/SessionUtilitiesKit/General/NSDate+Timestamp.mm deleted file mode 100644 index 375b62a1c..000000000 --- a/SessionUtilitiesKit/General/NSDate+Timestamp.mm +++ /dev/null @@ -1,16 +0,0 @@ -#import "NSDate+Timestamp.h" -#import - -NS_ASSUME_NONNULL_BEGIN - -@implementation NSDate (Session) - -+ (uint64_t)millisecondTimestamp -{ - return (uint64_t)(std::chrono::system_clock::now().time_since_epoch() / std::chrono::milliseconds(1)); -} - -@end - -NS_ASSUME_NONNULL_END - diff --git a/SessionUtilitiesKit/General/String+Localized.swift b/SessionUtilitiesKit/General/String+Localized.swift deleted file mode 100644 index 2468d1d25..000000000 --- a/SessionUtilitiesKit/General/String+Localized.swift +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import SignalCoreKit - -public extension String { - func localized() -> String { - // If the localized string matches the key provided then the localisation failed - let localizedString = NSLocalizedString(self, comment: "") - owsAssertDebug(localizedString != self, "Key \"\(self)\" is not set in Localizable.strings") - - return localizedString - } -} diff --git a/SessionUtilitiesKit/General/String+Utilities.swift b/SessionUtilitiesKit/General/String+Utilities.swift new file mode 100644 index 000000000..9b5777417 --- /dev/null +++ b/SessionUtilitiesKit/General/String+Utilities.swift @@ -0,0 +1,31 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import SignalCoreKit + +public extension String { + func localized() -> String { + // If the localized string matches the key provided then the localisation failed + let localizedString = NSLocalizedString(self, comment: "") + owsAssertDebug(localizedString != self, "Key \"\(self)\" is not set in Localizable.strings") + + return localizedString + } + + func ranges(of substring: String, options: CompareOptions = [], locale: Locale? = nil) -> [Range] { + var ranges: [Range] = [] + + while + (ranges.last.map({ $0.upperBound < self.endIndex }) ?? true), + let range = self.range( + of: substring, + options: options, + range: (ranges.last?.upperBound ?? self.startIndex).. #import #import -#import #import #import #import diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalInputAccessoryView.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalInputAccessoryView.swift index 824a9008a..1a71f3808 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalInputAccessoryView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalInputAccessoryView.swift @@ -6,7 +6,7 @@ import Foundation import UIKit import SessionUIKit -protocol AttachmentApprovalInputAccessoryViewDelegate: class { +protocol AttachmentApprovalInputAccessoryViewDelegate: AnyObject { func attachmentApprovalInputUpdateMediaRail() func attachmentApprovalInputStartEditingCaptions() func attachmentApprovalInputStopEditingCaptions() @@ -88,6 +88,14 @@ class AttachmentApprovalInputAccessoryView: UIView { // the layout if you hide the keyboard in the simulator (or if the // user uses an external keyboard). stackView.autoPinEdge(toSuperviewMargin: .bottom) + + let galleryRailBlockingView: UIView = UIView() + galleryRailBlockingView.backgroundColor = backgroundView.backgroundColor + stackView.addSubview(galleryRailBlockingView) + galleryRailBlockingView.pin(.top, to: .bottom, of: attachmentTextToolbar) + galleryRailBlockingView.pin(.left, to: .left, of: stackView) + galleryRailBlockingView.pin(.right, to: .right, of: stackView) + galleryRailBlockingView.pin(.bottom, to: .bottom, of: stackView) } // MARK: - Events diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift index d478a0165..552e6569c 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift @@ -436,7 +436,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC // MARK: - View Helpers func remove(attachmentItem: SignalAttachmentItem) { - if attachmentItem == currentItem { + if attachmentItem.isEqual(to: currentItem) { if let nextItem = attachmentItemCollection.itemAfter(item: attachmentItem) { setCurrentItem(nextItem, direction: .forward, animated: true) } @@ -449,30 +449,9 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC } } - guard let cell = galleryRailView.cellViews.first(where: { $0.item === attachmentItem }) else { - owsFailDebug("cell was unexpectedly nil") - return - } - - UIView.animate( - withDuration: 0.2, - animations: { - // shrink stack view item until it disappears - cell.isHidden = true - - // simultaneously fade out - cell.alpha = 0 - }, - completion: { [weak self] _ in - self?.attachmentItemCollection.remove(item: attachmentItem) - - if let strongSelf: AttachmentApprovalViewController = self { - self?.approvalDelegate?.attachmentApproval?(strongSelf, didRemoveAttachment: attachmentItem.attachment) - } - - self?.updateMediaRail() - } - ) + self.attachmentItemCollection.remove(item: attachmentItem) + self.approvalDelegate?.attachmentApproval(self, didRemoveAttachment: attachmentItem.attachment) + self.updateMediaRail() } // MARK: - UIPageViewControllerDelegate diff --git a/SignalUtilitiesKit/Media Viewing & Editing/AttachmentSharing.h b/SignalUtilitiesKit/Media Viewing & Editing/AttachmentSharing.h deleted file mode 100644 index e3454b5e1..000000000 --- a/SignalUtilitiesKit/Media Viewing & Editing/AttachmentSharing.h +++ /dev/null @@ -1,33 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -NS_ASSUME_NONNULL_BEGIN - -@class TSAttachmentStream; - -typedef void (^AttachmentSharingCompletion)(UIActivityType __nullable activityType); - -@interface AttachmentSharing : NSObject - -+ (void)showShareUIForAttachments:(NSArray *)attachmentStreams - completion:(nullable AttachmentSharingCompletion)completion; - -+ (void)showShareUIForAttachment:(TSAttachmentStream *)stream; -+ (void)showShareUIForAttachment:(TSAttachmentStream *)stream completion:(nullable AttachmentSharingCompletion)completion; - -+ (void)showShareUIForURL:(NSURL *)url; -+ (void)showShareUIForURL:(NSURL *)url completion:(nullable AttachmentSharingCompletion)completion; - -+ (void)showShareUIForURLs:(NSArray *)urls completion:(nullable AttachmentSharingCompletion)completion; - -+ (void)showShareUIForText:(NSString *)text; -+ (void)showShareUIForText:(NSString *)text completion:(nullable AttachmentSharingCompletion)completion; - -#ifdef DEBUG -+ (void)showShareUIForUIImage:(UIImage *)image; -#endif - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Media Viewing & Editing/AttachmentSharing.m b/SignalUtilitiesKit/Media Viewing & Editing/AttachmentSharing.m deleted file mode 100644 index 5df014e9b..000000000 --- a/SignalUtilitiesKit/Media Viewing & Editing/AttachmentSharing.m +++ /dev/null @@ -1,119 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "AttachmentSharing.h" -#import "UIUtil.h" -#import - -NS_ASSUME_NONNULL_BEGIN - -@implementation AttachmentSharing - -+ (void)showShareUIForAttachments:(NSArray *)attachmentStreams - completion:(nullable AttachmentSharingCompletion)completion -{ - OWSAssertDebug(attachmentStreams.count > 0); - - NSMutableArray *urls = [NSMutableArray new]; - for (TSAttachmentStream *attachmentStream in attachmentStreams) { - [urls addObject:attachmentStream.originalMediaURL]; - } - - [AttachmentSharing showShareUIForActivityItems:urls completion:completion]; -} - -+ (void)showShareUIForAttachment:(TSAttachmentStream *)stream -{ - OWSAssertDebug(stream); - - [self showShareUIForAttachment:stream completion:nil]; -} - -+ (void)showShareUIForAttachment:(TSAttachmentStream *)stream completion:(nullable AttachmentSharingCompletion)completion -{ - OWSAssertDebug(stream); - - [self showShareUIForURL:stream.originalMediaURL completion:completion]; -} - -+ (void)showShareUIForURL:(NSURL *)url -{ - [self showShareUIForURL:url completion:nil]; -} - -+ (void)showShareUIForURL:(NSURL *)url completion:(nullable AttachmentSharingCompletion)completion -{ - OWSAssertDebug(url); - - [AttachmentSharing showShareUIForActivityItems:@[ url ] - completion:completion]; -} - -+ (void)showShareUIForURLs:(NSArray *)urls completion:(nullable AttachmentSharingCompletion)completion -{ - OWSAssertDebug(urls.count > 0); - - [AttachmentSharing showShareUIForActivityItems:urls - completion:completion]; -} - -+ (void)showShareUIForText:(NSString *)text -{ - [self showShareUIForText:text completion:nil]; -} - -+ (void)showShareUIForText:(NSString *)text completion:(nullable AttachmentSharingCompletion)completion -{ - OWSAssertDebug(text); - - [AttachmentSharing showShareUIForActivityItems:@[ text, ] - completion:completion]; -} - -#ifdef DEBUG -+ (void)showShareUIForUIImage:(UIImage *)image -{ - OWSAssertDebug(image); - - [AttachmentSharing showShareUIForActivityItems:@[ image, ] - completion:nil]; -} -#endif - -+ (void)showShareUIForActivityItems:(NSArray *)activityItems completion:(nullable AttachmentSharingCompletion)completion -{ - OWSAssertDebug(activityItems); - - DispatchMainThreadSafe(^{ - UIActivityViewController *activityViewController = - [[UIActivityViewController alloc] initWithActivityItems:activityItems applicationActivities:@[]]; - - [activityViewController setCompletionWithItemsHandler:^(UIActivityType __nullable activityType, - BOOL completed, - NSArray *__nullable returnedItems, - NSError *__nullable activityError) { - - if (activityError) { - OWSLogInfo(@"Failed to share with activityError: %@", activityError); - } else if (completed) { - OWSLogInfo(@"Did share with activityType: %@", activityType); - } - - if (completion) { - DispatchMainThreadSafe(^{ completion(activityType); }); - } - }]; - - UIViewController *fromViewController = CurrentAppContext().frontmostViewController; - while (fromViewController.presentedViewController) { - fromViewController = fromViewController.presentedViewController; - } - OWSAssertDebug(fromViewController); - [fromViewController presentViewController:activityViewController animated:YES completion:nil]; - }); -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Messaging/ThreadViewModel.swift b/SignalUtilitiesKit/Messaging/ThreadViewModel.swift deleted file mode 100644 index 0135063e4..000000000 --- a/SignalUtilitiesKit/Messaging/ThreadViewModel.swift +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionMessagingKit - -public struct ThreadViewModel: Equatable { - public let thread: SessionThread - public let name: String - public let unreadCount: UInt - public let unreadMentionCount: UInt - - public let lastInteraction: Interaction? - public let lastInteractionDate: Date - public let lastInteractionText: String? - public let lastInteractionState: RecipientState.State? - - public init( - thread: SessionThread, - name: String, - unreadCount: UInt, - unreadMentionCount: UInt, - lastInteraction: Interaction?, - lastInteractionDate: Date, - lastInteractionText: String?, - lastInteractionState: RecipientState.State? - ) { - self.thread = thread - self.name = name - self.unreadCount = unreadCount - self.unreadMentionCount = unreadMentionCount - - self.lastInteraction = lastInteraction - self.lastInteractionDate = lastInteractionDate - self.lastInteractionText = lastInteractionText - self.lastInteractionState = lastInteractionState - } -} diff --git a/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h b/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h index 4637fbae9..e127a8286 100644 --- a/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h +++ b/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h @@ -9,7 +9,6 @@ FOUNDATION_EXPORT const unsigned char SignalUtilitiesKitVersionString[]; #import #import -#import #import #import #import diff --git a/SignalUtilitiesKit/Shared Views/ApprovalRailCellView.swift b/SignalUtilitiesKit/Shared Views/ApprovalRailCellView.swift index d937c7381..ed7458919 100644 --- a/SignalUtilitiesKit/Shared Views/ApprovalRailCellView.swift +++ b/SignalUtilitiesKit/Shared Views/ApprovalRailCellView.swift @@ -5,7 +5,7 @@ import Foundation import UIKit -protocol ApprovalRailCellViewDelegate: class { +protocol ApprovalRailCellViewDelegate: AnyObject { func approvalRailCellView(_ approvalRailCellView: ApprovalRailCellView, didRemoveItem attachmentItem: SignalAttachmentItem) func canRemoveApprovalRailCellView(_ approvalRailCellView: ApprovalRailCellView) -> Bool } From cfb8f1615aa542a8fa58a40ac48d31688e5e4ca0 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 23 May 2022 17:27:24 +1000 Subject: [PATCH 086/157] Migrated a couple more preferences --- Session/Meta/AppDelegate.swift | 36 +++++++++---------- .../Database/LegacyDatabase/SMKLegacy.swift | 1 + .../Migrations/_003_YDBToGRDBMigration.swift | 1 + .../Utilities/OWSPreferences.h | 6 ---- .../Utilities/OWSPreferences.m | 31 ---------------- .../Utilities/Preferences.swift | 3 ++ .../NotificationServiceExtension.swift | 2 +- 7 files changed, 23 insertions(+), 57 deletions(-) diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index c8bfee916..97c2c0957 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -58,30 +58,28 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD Environment.shared.audioSession.setup() SSKEnvironment.shared.reachabilityManager.setup() - if !Environment.shared.preferences.hasGeneratedThumbnails() { - - // Disable the SAE until the main app has successfully completed launch process - // at least once in the post-SAE world. - OWSPreferences.setIsReadyForAppExtensions() - - // Setup the UI - self?.ensureRootViewController() - - if Identity.userExists() { - let appVersion: AppVersion = AppVersion.sharedInstance() + GRDBStorage.shared.writeAsync { db in + // Disable the SAE until the main app has successfully completed launch process + // at least once in the post-SAE world. + db[.isReadyForAppExtensions] = true - // If the device needs to sync config or the user updated to a new version - if - needsConfigSync || ( // TODO: 'needsConfigSync' logic for migrations - (appVersion.lastAppVersion?.count ?? 0) > 0 && - appVersion.lastAppVersion != appVersion.currentAppVersion - ) - { - GRDBStorage.shared.write { db in + if Identity.userExists(db) { + let appVersion: AppVersion = AppVersion.sharedInstance() + + // If the device needs to sync config or the user updated to a new version + if + needsConfigSync || ( + (appVersion.lastAppVersion?.count ?? 0) > 0 && + appVersion.lastAppVersion != appVersion.currentAppVersion + ) + { try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() } } } + + // Setup the UI + self?.ensureRootViewController() } ) diff --git a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift index 948c40449..06d6b7816 100644 --- a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift +++ b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift @@ -59,6 +59,7 @@ public enum SMKLegacy { internal static let preferencesKeyNotificationSoundInForeground = "NotificationSoundInForeground" internal static let preferencesKeyHasSavedThreadKey = "hasSavedThread" internal static let preferencesKeyHasSentAMessageKey = "User has sent a message" + internal static let preferencesKeyIsReadyForAppExtensions = "isReadyForAppExtensions_5" internal static let readReceiptManagerCollection = "OWSReadReceiptManagerCollection" internal static let readReceiptManagerAreReadReceiptsEnabled = "areReadReceiptsEnabled" diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index ce84fbd95..6f323bb51 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -1420,6 +1420,7 @@ enum _003_YDBToGRDBMigration: Migration { .bool(forKey: SMKLegacy.userDefaultsHasHiddenMessageRequests) db[.hasSavedThread] = (legacyPreferences[SMKLegacy.preferencesKeyHasSavedThreadKey] as? Bool == true) db[.hasSentAMessage] = (legacyPreferences[SMKLegacy.preferencesKeyHasSentAMessageKey] as? Bool == true) + db[.isReadyForAppExtensions] = CurrentAppContext().appUserDefaults().bool(forKey: SMKLegacy.preferencesKeyIsReadyForAppExtensions) print("RAWR [\(Date().timeIntervalSince1970)] - Process preferences inserts - End") diff --git a/SessionMessagingKit/Utilities/OWSPreferences.h b/SessionMessagingKit/Utilities/OWSPreferences.h index 895d13d75..39904bd44 100644 --- a/SessionMessagingKit/Utilities/OWSPreferences.h +++ b/SessionMessagingKit/Utilities/OWSPreferences.h @@ -36,9 +36,6 @@ extern NSString *const OWSPreferencesCallLoggingDidChangeNotification; #pragma mark - Specific Preferences -+ (BOOL)isReadyForAppExtensions; -+ (void)setIsReadyForAppExtensions; - - (BOOL)hasSentAMessage; - (void)setHasSentAMessage:(BOOL)enabled; @@ -48,9 +45,6 @@ extern NSString *const OWSPreferencesCallLoggingDidChangeNotification; - (void)setIOSUpgradeNagDate:(NSDate *)value; - (nullable NSDate *)iOSUpgradeNagDate; -- (BOOL)hasGeneratedThumbnails; -- (void)setHasGeneratedThumbnails:(BOOL)value; - - (BOOL)shouldShowUnidentifiedDeliveryIndicators; - (void)setShouldShowUnidentifiedDeliveryIndicators:(BOOL)value; diff --git a/SessionMessagingKit/Utilities/OWSPreferences.m b/SessionMessagingKit/Utilities/OWSPreferences.m index 3aa051865..80779d80d 100644 --- a/SessionMessagingKit/Utilities/OWSPreferences.m +++ b/SessionMessagingKit/Utilities/OWSPreferences.m @@ -12,11 +12,9 @@ NSString *const OWSPreferencesKeyCallKitEnabled = @"CallKitEnabled"; NSString *const OWSPreferencesKeyCallKitPrivacyEnabled = @"CallKitPrivacyEnabled"; NSString *const OWSPreferencesKeyCallsHideIPAddress = @"CallsHideIPAddress"; NSString *const OWSPreferencesKeyHasDeclinedNoContactsView = @"hasDeclinedNoContactsView"; -NSString *const OWSPreferencesKeyHasGeneratedThumbnails = @"OWSPreferencesKeyHasGeneratedThumbnails"; NSString *const OWSPreferencesKeyShouldShowUnidentifiedDeliveryIndicators = @"OWSPreferencesKeyShouldShowUnidentifiedDeliveryIndicators"; NSString *const OWSPreferencesKeyIOSUpgradeNagDate = @"iOSUpgradeNagDate"; -NSString *const OWSPreferencesKey_IsReadyForAppExtensions = @"isReadyForAppExtensions_5"; NSString *const OWSPreferencesKeySystemCallLogEnabled = @"OWSPreferencesKeySystemCallLogEnabled"; @implementation OWSPreferences @@ -68,23 +66,6 @@ NSString *const OWSPreferencesKeySystemCallLogEnabled = @"OWSPreferencesKeySyste #pragma mark - Specific Preferences -+ (BOOL)isReadyForAppExtensions -{ - NSNumber *preference = [NSUserDefaults.appUserDefaults objectForKey:OWSPreferencesKey_IsReadyForAppExtensions]; - - if (preference) { - return [preference boolValue]; - } else { - return NO; - } -} - -+ (void)setIsReadyForAppExtensions -{ - [NSUserDefaults.appUserDefaults setObject:@(YES) forKey:OWSPreferencesKey_IsReadyForAppExtensions]; - [NSUserDefaults.appUserDefaults synchronize]; -} - - (BOOL)hasDeclinedNoContactsView { NSNumber *preference = [self tryGetValueForKey:OWSPreferencesKeyHasDeclinedNoContactsView]; @@ -97,18 +78,6 @@ NSString *const OWSPreferencesKeySystemCallLogEnabled = @"OWSPreferencesKeySyste [self setValueForKey:OWSPreferencesKeyHasDeclinedNoContactsView toValue:@(value)]; } -- (BOOL)hasGeneratedThumbnails -{ - NSNumber *preference = [self tryGetValueForKey:OWSPreferencesKeyHasGeneratedThumbnails]; - // Default to NO. - return preference ? [preference boolValue] : NO; -} - -- (void)setHasGeneratedThumbnails:(BOOL)value -{ - [self setValueForKey:OWSPreferencesKeyHasGeneratedThumbnails toValue:@(value)]; -} - - (void)setIOSUpgradeNagDate:(NSDate *)value { [self setValueForKey:OWSPreferencesKeyIOSUpgradeNagDate toValue:value]; diff --git a/SessionMessagingKit/Utilities/Preferences.swift b/SessionMessagingKit/Utilities/Preferences.swift index 022c1e4c6..913b76f92 100644 --- a/SessionMessagingKit/Utilities/Preferences.swift +++ b/SessionMessagingKit/Utilities/Preferences.swift @@ -49,6 +49,9 @@ public extension Setting.BoolKey { /// A flag indicating whether the user has ever send a message static let hasSentAMessage: Setting.BoolKey = "hasSentAMessageKey" + + /// A flag indicating whether the app is ready for app extensions to run + static let isReadyForAppExtensions: Setting.BoolKey = "isReadyForAppExtensions" } public extension Setting.StringKey { diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index 8d53f5262..6c3c60b47 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -112,7 +112,7 @@ public final class NotificationServiceExtension : UNNotificationServiceExtension // 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 OWSPreferences.isReadyForAppExtensions() else { return completeSilenty() } + guard GRDBStorage.shared[.isReadyForAppExtensions] else { return completeSilenty() } AppSetup.setupEnvironment( appSpecificSingletonBlock: { From 19cd9d13c5d024b1b08725c2fba80f948611f92e Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 25 May 2022 18:48:04 +1000 Subject: [PATCH 087/157] Cleaned up the ConversationVC query and started plugging in paging Created a generic PagedDatabaseObserver (common logic for conversation & gallery paged database queries and observation) Updated the MediaGallery to use the PagedDatabaseObserver Split the interaction and thread data queries for the conversationVC --- Session.xcodeproj/project.pbxproj | 30 +- .../Context Menu/ContextMenuVC+Action.swift | 94 +- .../Context Menu/ContextMenuVC.swift | 12 +- .../ConversationVC+Interaction.swift | 278 +++-- Session/Conversations/ConversationVC.swift | 161 +-- .../Conversations/ConversationViewModel.swift | 510 ++++++++ .../Conversations/Input View/InputView.swift | 10 +- .../Content Views/LinkPreviewView.swift | 6 +- .../Content Views/MediaPlaceholderView.swift | 10 +- .../Message Cells/InfoMessageCell.swift | 21 +- .../Message Cells/MessageCell.swift | 36 +- .../Models/MessageCellViewModel.swift | 593 +++++++++ .../Message Cells/TypingIndicatorCell.swift | 12 +- .../Message Cells/VisibleMessageCell.swift | 242 ++-- .../MediaGalleryViewModel.swift | 774 ++++-------- .../MediaTileViewController.swift | 26 +- .../Database/Models/Interaction.swift | 4 +- .../Models/InteractionAttachment.swift | 2 +- .../Database/Models/LinkPreview.swift | 4 +- .../Database/Models/Profile.swift | 6 +- .../Database/Models/Quote.swift | 2 +- .../Database/Models/RecipientState.swift | 29 +- .../Database/Models/SessionThread.swift | 2 +- .../MessageReceiver+Handling.swift | 8 +- .../Quotes/QuotedReplyModel.swift | 10 +- .../Typing Indicators/TypingIndicators.swift | 77 +- .../ConversationCellViewModel.swift | 225 +++- .../Shared Models/MessageInputTypes.swift | 9 + .../Database/GRDBStorage.swift | 4 +- .../Types/PagedDatabaseObserver.swift | 1058 +++++++++++++++++ .../Utilities/Database+Utilities.swift | 2 +- ...ption.swift => Dictionary+Utilities.swift} | 0 32 files changed, 3201 insertions(+), 1056 deletions(-) create mode 100644 Session/Conversations/Message Cells/Models/MessageCellViewModel.swift create mode 100644 SessionMessagingKit/Shared Models/MessageInputTypes.swift create mode 100644 SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift rename SessionUtilitiesKit/General/{Dictionary+Description.swift => Dictionary+Utilities.swift} (100%) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 961263e2e..71ecea48f 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -523,7 +523,7 @@ C3BBE0A72554D4DE0050F1E3 /* Promise+Retrying.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D62553860B00C340D1 /* Promise+Retrying.swift */; }; C3BBE0A82554D4DE0050F1E3 /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D92553860B00C340D1 /* JSON.swift */; }; C3BBE0A92554D4DE0050F1E3 /* HTTP.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5BC255385EE00C340D1 /* HTTP.swift */; }; - C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Description.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D52553860A00C340D1 /* Dictionary+Description.swift */; }; + C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D52553860A00C340D1 /* Dictionary+Utilities.swift */; }; C3BBE0C72554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3BBE0C62554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift */; }; C3C2A5A3255385C100C340D1 /* SessionSnodeKit.h in Headers */ = {isa = PBXBuildFile; fileRef = C3C2A5A1255385C100C340D1 /* SessionSnodeKit.h */; settings = {ATTRIBUTES = (Public, ); }; }; C3C2A5A7255385C100C340D1 /* SessionSnodeKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A59F255385C100C340D1 /* SessionSnodeKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -682,6 +682,9 @@ FD705A94278D052B00F16121 /* UITableView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A93278D052B00F16121 /* UITableView+ReusableView.swift */; }; FD705A98278E9F4D00F16121 /* UIColor+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A97278E9F4D00F16121 /* UIColor+Extensions.swift */; }; FD7162DB281B6C440060647B /* TypedTableAlias.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7162DA281B6C440060647B /* TypedTableAlias.swift */; }; + FD848B87283B844B000E298B /* MessageCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B86283B844B000E298B /* MessageCellViewModel.swift */; }; + FD848B8B283DC509000E298B /* PagedDatabaseObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B8A283DC509000E298B /* PagedDatabaseObserver.swift */; }; + FD848B8D283E0B26000E298B /* MessageInputTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B8C283E0B26000E298B /* MessageInputTypes.swift */; }; FD859F0027C4691300510D0C /* MockDataGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EFF27C4691300510D0C /* MockDataGenerator.swift */; }; FD88BAD927A7439C00BBC442 /* MessageRequestsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */; }; FD88BADB27A750F200BBC442 /* MessageRequestsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */; }; @@ -1506,7 +1509,7 @@ C3C2A5D22553860900C340D1 /* String+Trimming.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Trimming.swift"; sourceTree = ""; }; C3C2A5D32553860900C340D1 /* Promise+Delaying.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Promise+Delaying.swift"; sourceTree = ""; }; C3C2A5D42553860A00C340D1 /* Threading.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Threading.swift; sourceTree = ""; }; - C3C2A5D52553860A00C340D1 /* Dictionary+Description.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Dictionary+Description.swift"; sourceTree = ""; }; + C3C2A5D52553860A00C340D1 /* Dictionary+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Dictionary+Utilities.swift"; sourceTree = ""; }; C3C2A5D62553860B00C340D1 /* Promise+Retrying.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Promise+Retrying.swift"; sourceTree = ""; }; C3C2A5D72553860B00C340D1 /* AESGCM.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AESGCM.swift; sourceTree = ""; }; C3C2A5D82553860B00C340D1 /* Data+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Data+Utilities.swift"; sourceTree = ""; }; @@ -1655,6 +1658,9 @@ FD705A93278D052B00F16121 /* UITableView+ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITableView+ReusableView.swift"; sourceTree = ""; }; FD705A97278E9F4D00F16121 /* UIColor+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Extensions.swift"; sourceTree = ""; }; FD7162DA281B6C440060647B /* TypedTableAlias.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypedTableAlias.swift; sourceTree = ""; }; + FD848B86283B844B000E298B /* MessageCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCellViewModel.swift; sourceTree = ""; }; + FD848B8A283DC509000E298B /* PagedDatabaseObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagedDatabaseObserver.swift; sourceTree = ""; }; + FD848B8C283E0B26000E298B /* MessageInputTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageInputTypes.swift; sourceTree = ""; }; FD859EFF27C4691300510D0C /* MockDataGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDataGenerator.swift; sourceTree = ""; }; FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsCell.swift; sourceTree = ""; }; FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsMigration.swift; sourceTree = ""; }; @@ -2107,11 +2113,12 @@ B835247725C38D190089A44F /* Message Cells */ = { isa = PBXGroup; children = ( + FD848B85283B8438000E298B /* Models */, + B8041A7325C8F758003C2166 /* Content Views */, B835247825C38D880089A44F /* MessageCell.swift */, B835249A25C3AB650089A44F /* VisibleMessageCell.swift */, B83524A425C3BA4B0089A44F /* InfoMessageCell.swift */, B8041AA625C90927003C2166 /* TypingIndicatorCell.swift */, - B8041A7325C8F758003C2166 /* Content Views */, ); path = "Message Cells"; sourceTree = ""; @@ -2217,7 +2224,7 @@ C33FDAA8255A57FF00E217F9 /* BuildConfiguration.swift */, B8F5F58225EC94A6003BF8D4 /* Collection+Subscripting.swift */, B8AE75A325A6C6A6001A84D2 /* Data+Trimming.swift */, - C3C2A5D52553860A00C340D1 /* Dictionary+Description.swift */, + C3C2A5D52553860A00C340D1 /* Dictionary+Utilities.swift */, B87EF18026377A1D00124B3C /* Features.swift */, B8BC00BF257D90E30032E807 /* General.swift */, C3C2A5CE2553860700C340D1 /* Logging.swift */, @@ -3374,6 +3381,7 @@ FD17D7B927F51F2100122BE0 /* TargetMigrations.swift */, FD17D7C027F5200100122BE0 /* TypedTableDefinition.swift */, FD7162DA281B6C440060647B /* TypedTableAlias.swift */, + FD848B8A283DC509000E298B /* PagedDatabaseObserver.swift */, ); path = Types; sourceTree = ""; @@ -3443,6 +3451,7 @@ isa = PBXGroup; children = ( FD3E0C83283B5835002A425C /* ConversationCellViewModel.swift */, + FD848B8C283E0B26000E298B /* MessageInputTypes.swift */, ); path = "Shared Models"; sourceTree = ""; @@ -3463,6 +3472,14 @@ path = "Message Requests"; sourceTree = ""; }; + FD848B85283B8438000E298B /* Models */ = { + isa = PBXGroup; + children = ( + FD848B86283B844B000E298B /* MessageCellViewModel.swift */, + ); + path = Models; + sourceTree = ""; + }; FD88BAD727A7438E00BBC442 /* Views */ = { isa = PBXGroup; children = ( @@ -4467,6 +4484,7 @@ FD09797B27FBB25900936362 /* Updatable.swift in Sources */, C32C5A2D256DB849003C73A2 /* LKGroupUtilities.m in Sources */, C3C2ABD22553C6C900C340D1 /* Data+SecureRandom.swift in Sources */, + FD848B8B283DC509000E298B /* PagedDatabaseObserver.swift in Sources */, B8856E09256F1676001CE70E /* UIDevice+featureSupport.swift in Sources */, B8856DEF256F161F001CE70E /* NSString+SSK.m in Sources */, FD09C5E2282212B3000CE219 /* JobDependencies.swift in Sources */, @@ -4486,7 +4504,7 @@ FD17D7BD27F51F6900122BE0 /* GRDB+Notifications.swift in Sources */, FD17D7CA27F546D900122BE0 /* _001_InitialSetupMigration.swift in Sources */, C3D9E4D12567777D0040E4F3 /* OWSMediaUtils.swift in Sources */, - C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Description.swift in Sources */, + C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Utilities.swift in Sources */, C3D9E4DA256778410040E4F3 /* UIImage+OWS.m in Sources */, C32C600F256E07F5003C73A2 /* NSUserDefaults+OWS.m in Sources */, C3D9E35E25675F640040E4F3 /* OWSFileSystem.m in Sources */, @@ -4652,6 +4670,7 @@ C3DB66C3260ACCE6001EFC55 /* OpenGroupPollerV2.swift in Sources */, C3C2A75F2553A3C500C340D1 /* VisibleMessage+LinkPreview.swift in Sources */, C32C5FBB256E0206003C73A2 /* OWSBackgroundTask.m in Sources */, + FD848B8D283E0B26000E298B /* MessageInputTypes.swift in Sources */, B8856CA8256F0F42001CE70E /* OWSBackupFragment.m in Sources */, C32C5C3D256DCBAF003C73A2 /* AppReadiness.m in Sources */, B87EF17126367CF800124B3C /* FileServerAPIV2.swift in Sources */, @@ -4682,6 +4701,7 @@ FDFDE12A282D056B0098B17F /* MediaZoomAnimationController.swift in Sources */, 4C1885D2218F8E1C00B67051 /* PhotoGridViewCell.swift in Sources */, 34D1F0501F7D45A60066283D /* GifPickerCell.swift in Sources */, + FD848B87283B844B000E298B /* MessageCellViewModel.swift in Sources */, C3E5C2FA251DBABB0040DFFC /* EditClosedGroupVC.swift in Sources */, B8783E9E23EB948D00404FB8 /* UILabel+Interaction.swift in Sources */, B893063F2383961A005EAA8E /* ScanQRCodeWrapperVC.swift in Sources */, diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index 40ea11916..63ba37af9 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -8,85 +8,85 @@ extension ContextMenuVC { let title: String let work: () -> Void - static func reply(_ item: ConversationViewModel.Item, _ delegate: ContextMenuActionDelegate?) -> Action { + static func reply(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_reply"), title: "context_menu_reply".localized() - ) { delegate?.reply(item) } + ) { delegate?.reply(cellViewModel) } } - static func copy(_ item: ConversationViewModel.Item, _ delegate: ContextMenuActionDelegate?) -> Action { + static func copy(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_copy"), title: "copy".localized() - ) { delegate?.copy(item) } + ) { delegate?.copy(cellViewModel) } } - static func copySessionID(_ item: ConversationViewModel.Item, _ delegate: ContextMenuActionDelegate?) -> Action { + static func copySessionID(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_copy"), title: "vc_conversation_settings_copy_session_id_button_title".localized() - ) { delegate?.copySessionID(item) } + ) { delegate?.copySessionID(cellViewModel) } } - static func delete(_ item: ConversationViewModel.Item, _ delegate: ContextMenuActionDelegate?) -> Action { + static func delete(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_trash"), title: "TXT_DELETE_TITLE".localized() - ) { delegate?.delete(item) } + ) { delegate?.delete(cellViewModel) } } - static func save(_ item: ConversationViewModel.Item, _ delegate: ContextMenuActionDelegate?) -> Action { + static func save(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_download"), title: "context_menu_save".localized() - ) { delegate?.save(item) } + ) { delegate?.save(cellViewModel) } } - static func ban(_ item: ConversationViewModel.Item, _ delegate: ContextMenuActionDelegate?) -> Action { + static func ban(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_block"), title: "context_menu_ban_user".localized() - ) { delegate?.ban(item) } + ) { delegate?.ban(cellViewModel) } } - static func banAndDeleteAllMessages(_ item: ConversationViewModel.Item, _ delegate: ContextMenuActionDelegate?) -> Action { + static func banAndDeleteAllMessages(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_block"), title: "context_menu_ban_and_delete_all".localized() - ) { delegate?.banAndDeleteAllMessages(item) } + ) { delegate?.banAndDeleteAllMessages(cellViewModel) } } } - static func actions(for item: ConversationViewModel.Item, currentUserIsOpenGroupModerator: Bool, delegate: ContextMenuActionDelegate?) -> [Action]? { + static func actions(for cellViewModel: MessageCell.ViewModel, currentUserIsOpenGroupModerator: Bool, delegate: ContextMenuActionDelegate?) -> [Action]? { // No context items for info messages - guard item.interactionVariant == .standardOutgoing || item.interactionVariant == .standardIncoming else { + guard cellViewModel.variant == .standardOutgoing || cellViewModel.variant == .standardIncoming else { return nil } let canReply: Bool = ( - item.interactionVariant != .standardOutgoing || ( - item.state != .failed && - item.state != .sending + cellViewModel.variant != .standardOutgoing || ( + cellViewModel.state != .failed && + cellViewModel.state != .sending ) ) let canCopy: Bool = ( - item.cellType == .textOnlyMessage || ( + cellViewModel.cellType == .textOnlyMessage || ( ( - item.cellType == .genericAttachment || - item.cellType == .mediaMessage + cellViewModel.cellType == .genericAttachment || + cellViewModel.cellType == .mediaMessage ) && - (item.attachments ?? []).count == 1 && - (item.attachments ?? []).first?.isVisualMedia == true && - (item.attachments ?? []).first?.isValid == true && ( - (item.attachments ?? []).first?.state == .downloaded || - (item.attachments ?? []).first?.state == .uploaded + (cellViewModel.attachments ?? []).count == 1 && + (cellViewModel.attachments ?? []).first?.isVisualMedia == true && + (cellViewModel.attachments ?? []).first?.isValid == true && ( + (cellViewModel.attachments ?? []).first?.state == .downloaded || + (cellViewModel.attachments ?? []).first?.state == .uploaded ) ) ) let canSave: Bool = ( - item.cellType == .mediaMessage && - (item.attachments ?? []) + cellViewModel.cellType == .mediaMessage && + (cellViewModel.attachments ?? []) .filter { attachment in attachment.isValid && attachment.isVisualMedia && ( @@ -96,26 +96,26 @@ extension ContextMenuVC { }.isEmpty == false ) let canCopySessionId: Bool = ( - item.interactionVariant == .standardIncoming && - item.threadVariant != .openGroup + cellViewModel.variant == .standardIncoming && + cellViewModel.threadVariant != .openGroup ) let canDelete: Bool = ( - item.threadVariant != .openGroup || + cellViewModel.threadVariant != .openGroup || currentUserIsOpenGroupModerator ) let canBan: Bool = ( - item.threadVariant == .openGroup && + cellViewModel.threadVariant == .openGroup && currentUserIsOpenGroupModerator ) return [ - (canReply ? Action.reply(item, delegate) : nil), - (canCopy ? Action.copy(item, delegate) : nil), - (canSave ? Action.save(item, delegate) : nil), - (canCopySessionId ? Action.copySessionID(item, delegate) : nil), - (canDelete ? Action.delete(item, delegate) : nil), - (canBan ? Action.ban(item, delegate) : nil), - (canBan ? Action.banAndDeleteAllMessages(item, delegate) : nil) + (canReply ? Action.reply(cellViewModel, delegate) : nil), + (canCopy ? Action.copy(cellViewModel, delegate) : nil), + (canSave ? Action.save(cellViewModel, delegate) : nil), + (canCopySessionId ? Action.copySessionID(cellViewModel, delegate) : nil), + (canDelete ? Action.delete(cellViewModel, delegate) : nil), + (canBan ? Action.ban(cellViewModel, delegate) : nil), + (canBan ? Action.banAndDeleteAllMessages(cellViewModel, delegate) : nil) ] .compactMap { $0 } } @@ -124,11 +124,11 @@ extension ContextMenuVC { // MARK: - Delegate protocol ContextMenuActionDelegate { - func reply(_ item: ConversationViewModel.Item) - func copy(_ item: ConversationViewModel.Item) - func copySessionID(_ item: ConversationViewModel.Item) - func delete(_ item: ConversationViewModel.Item) - func save(_ item: ConversationViewModel.Item) - func ban(_ item: ConversationViewModel.Item) - func banAndDeleteAllMessages(_ item: ConversationViewModel.Item) + func reply(_ cellViewModel: MessageCell.ViewModel) + func copy(_ cellViewModel: MessageCell.ViewModel) + func copySessionID(_ cellViewModel: MessageCell.ViewModel) + func delete(_ cellViewModel: MessageCell.ViewModel) + func save(_ cellViewModel: MessageCell.ViewModel) + func ban(_ cellViewModel: MessageCell.ViewModel) + func banAndDeleteAllMessages(_ cellViewModel: MessageCell.ViewModel) } diff --git a/Session/Conversations/Context Menu/ContextMenuVC.swift b/Session/Conversations/Context Menu/ContextMenuVC.swift index 5f4d56cbf..6cf1b0bc5 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC.swift @@ -9,7 +9,7 @@ final class ContextMenuVC: UIViewController { private let snapshot: UIView private let frame: CGRect - private let item: ConversationViewModel.Item + private let cellViewModel: MessageCell.ViewModel private let actions: [Action] private let dismiss: () -> Void @@ -32,7 +32,7 @@ final class ContextMenuVC: UIViewController { result.font = .systemFont(ofSize: Values.verySmallFontSize) result.textColor = (isLightMode ? .black : .white) - if let dateForUI: Date = item.dateForUI { + if let dateForUI: Date = cellViewModel.dateForUI { result.text = DateUtil.formatDate(forDisplay: dateForUI) } @@ -44,13 +44,13 @@ final class ContextMenuVC: UIViewController { init( snapshot: UIView, frame: CGRect, - item: ConversationViewModel.Item, + cellViewModel: MessageCell.ViewModel, actions: [Action], dismiss: @escaping () -> Void ) { self.snapshot = snapshot self.frame = frame - self.item = item + self.cellViewModel = cellViewModel self.actions = actions self.dismiss = dismiss @@ -93,7 +93,7 @@ final class ContextMenuVC: UIViewController { view.addSubview(timestampLabel) timestampLabel.center(.vertical, in: snapshot) - if item.interactionVariant == .standardOutgoing { + if cellViewModel.variant == .standardOutgoing { timestampLabel.pin(.right, to: .left, of: snapshot, withInset: -Values.smallSpacing) } else { @@ -128,7 +128,7 @@ final class ContextMenuVC: UIViewController { menuView.pin(.top, to: .bottom, of: snapshot, withInset: spacing) } - switch item.interactionVariant { + switch cellViewModel.variant { case .standardOutgoing: menuView.pin(.right, to: .right, of: snapshot) case .standardIncoming: menuView.pin(.left, to: .left, of: snapshot) default: break // Should never occur diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index f6b9cad6b..755ba8991 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -21,7 +21,7 @@ extension ConversationVC: { @objc func handleTitleViewTapped() { // Don't take the user to settings for unapproved threads - guard !viewModel.viewData.requiresApproval else { return } + guard viewModel.threadData.threadRequiresApproval == false else { return } openSettings() } @@ -29,11 +29,11 @@ extension ConversationVC: @objc func openSettings() { let settingsVC: OWSConversationSettingsViewController = OWSConversationSettingsViewController() settingsVC.configure( - withThreadId: viewModel.viewData.thread.id, - threadName: viewModel.viewData.threadName, - isClosedGroup: (viewModel.viewData.thread.variant == .closedGroup), - isOpenGroup: (viewModel.viewData.thread.variant == .openGroup), - isNoteToSelf: viewModel.viewData.threadIsNoteToSelf + withThreadId: viewModel.threadData.threadId, + threadName: viewModel.threadData.displayName, + isClosedGroup: (viewModel.threadData.threadVariant == .closedGroup), + isOpenGroup: (viewModel.threadData.threadVariant == .openGroup), + isNoteToSelf: viewModel.threadData.threadIsNoteToSelf ) settingsVC.conversationSettingsViewDelegate = self navigationController?.pushViewController(settingsVC, animated: true, completion: nil) @@ -51,9 +51,9 @@ extension ConversationVC: // MARK: - Blocking @objc func unblock() { - guard self.viewModel.viewData.thread.variant == .contact else { return } + guard self.viewModel.threadData.threadVariant == .contact else { return } - let publicKey: String = self.viewModel.viewData.thread.id + let publicKey: String = self.viewModel.threadData.threadId UIView.animate( withDuration: 0.25, @@ -73,9 +73,9 @@ extension ConversationVC: } func showBlockedModalIfNeeded() -> Bool { - guard viewModel.viewData.threadIsBlocked else { return false } + guard viewModel.threadData.threadIsBlocked == true else { return false } - let blockedModal = BlockedModal(publicKey: viewModel.viewData.thread.id) + let blockedModal = BlockedModal(publicKey: viewModel.threadData.threadId) blockedModal.modalPresentationStyle = .overFullScreen blockedModal.modalTransitionStyle = .crossDissolve present(blockedModal, animated: true, completion: nil) @@ -152,7 +152,7 @@ extension ConversationVC: } func handleLibraryButtonTapped() { - let threadId: String = self.viewModel.viewData.thread.id + let threadId: String = self.viewModel.threadData.threadId requestLibraryPermissionIfNeeded { [weak self] in DispatchQueue.main.async { @@ -175,7 +175,7 @@ extension ConversationVC: SNLog("Proceeding without microphone access. Any recorded video will be silent.") } - let sendMediaNavController = SendMediaNavigationController.showingCameraFirst(threadId: self.viewModel.viewData.thread.id) + let sendMediaNavController = SendMediaNavigationController.showingCameraFirst(threadId: self.viewModel.threadData.threadId) sendMediaNavController.sendMediaNavDelegate = self sendMediaNavController.modalPresentationStyle = .fullScreen @@ -245,7 +245,7 @@ extension ConversationVC: func showAttachmentApprovalDialog(for attachments: [SignalAttachment]) { let navController = AttachmentApprovalViewController.wrappedInNavController( - threadId: self.viewModel.viewData.thread.id, + threadId: self.viewModel.threadData.threadId, attachments: attachments, approvalDelegate: self ) @@ -298,7 +298,7 @@ extension ConversationVC: guard !text.isEmpty else { return } - if text.contains(mnemonic) && !viewModel.viewData.threadIsNoteToSelf && !hasPermissionToSendSeed { + if text.contains(mnemonic) && !viewModel.threadData.threadIsNoteToSelf && !hasPermissionToSendSeed { // Warn the user if they're about to send their seed to someone let modal = SendSeedModal() modal.modalPresentationStyle = .overFullScreen @@ -310,29 +310,34 @@ extension ConversationVC: // Note: 'shouldBeVisible' is set to true the first time a thread is saved so we can // use it to determine if the user is creating a new thread and update the 'isApproved' // flags appropriately - let thread: SessionThread = viewModel.viewData.thread - let oldThreadShouldBeVisible: Bool = thread.shouldBeVisible + let threadId: String = self.viewModel.threadData.threadId + let oldThreadShouldBeVisible: Bool = (self.viewModel.threadData.threadShouldBeVisible == true) let sentTimestampMs: Int64 = Int64(floor((Date().timeIntervalSince1970 * 1000))) let linkPreviewDraft: LinkPreviewDraft? = snInputView.linkPreviewInfo?.draft let quoteModel: QuotedReplyModel? = snInputView.quoteDraftInfo?.model approveMessageRequestIfNeeded( - for: thread, + for: threadId, + threadVariant: self.viewModel.threadData.threadVariant, isNewThread: !oldThreadShouldBeVisible, timestampMs: (sentTimestampMs - 1) // Set 1ms earlier as this is used for sorting ) .done { [weak self] _ in GRDBStorage.shared.writeAsync( updates: { db in + guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { + return + } + // Update the thread to be visible _ = try SessionThread - .filter(id: thread.id) + .filter(id: threadId) .updateAll(db, SessionThread.Columns.shouldBeVisible.set(to: true)) // Create the interaction let userPublicKey: String = getUserHexEncodedPublicKey(db) let interaction: Interaction = try Interaction( - threadId: thread.id, + threadId: threadId, authorId: getUserHexEncodedPublicKey(db), variant: .standardOutgoing, body: text, @@ -405,27 +410,32 @@ extension ConversationVC: // Note: 'shouldBeVisible' is set to true the first time a thread is saved so we can // use it to determine if the user is creating a new thread and update the 'isApproved' // flags appropriately - let thread: SessionThread = viewModel.viewData.thread - let oldThreadShouldBeVisible: Bool = thread.shouldBeVisible + let threadId: String = self.viewModel.threadData.threadId + let oldThreadShouldBeVisible: Bool = (self.viewModel.threadData.threadShouldBeVisible == true) let sentTimestampMs: Int64 = Int64(floor((Date().timeIntervalSince1970 * 1000))) approveMessageRequestIfNeeded( - for: thread, + for: threadId, + threadVariant: self.viewModel.threadData.threadVariant, isNewThread: !oldThreadShouldBeVisible, timestampMs: (sentTimestampMs - 1) // Set 1ms earlier as this is used for sorting ) .done { [weak self] _ in GRDBStorage.shared.writeAsync( updates: { db in + guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { + return + } + // Update the thread to be visible _ = try SessionThread - .filter(id: thread.id) + .filter(id: threadId) .updateAll(db, SessionThread.Columns.shouldBeVisible.set(to: true)) // Create the interaction let userPublicKey: String = getUserHexEncodedPublicKey(db) let interaction: Interaction = try Interaction( - threadId: thread.id, + threadId: threadId, authorId: getUserHexEncodedPublicKey(db), variant: .standardOutgoing, body: text, @@ -473,13 +483,13 @@ extension ConversationVC: AudioServicesPlaySystemSound(soundID) } - let thread: SessionThread = self.viewModel.viewData.thread + let threadId: String = self.viewModel.threadData.threadId GRDBStorage.shared.writeAsync { db in - TypingIndicators.didStopTyping(db, in: thread, direction: .outgoing) + TypingIndicators.didStopTyping(db, threadId: threadId, direction: .outgoing) _ = try SessionThread - .filter(id: thread.id) + .filter(id: threadId) .updateAll(db, SessionThread.Columns.messageDraft.set(to: "")) } } @@ -497,12 +507,16 @@ extension ConversationVC: let newText: String = (inputTextView.text ?? "") if !newText.isEmpty { - let thread: SessionThread = self.viewModel.viewData.thread + let threadId: String = self.viewModel.threadData.threadId + let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant + let threadIsMessageRequest: Bool = (self.viewModel.threadData.threadIsMessageRequest == true) GRDBStorage.shared.writeAsync { db in TypingIndicators.didStartTyping( db, - in: thread, + threadId: threadId, + threadVariant: threadVariant, + threadIsMessageRequest: threadIsMessageRequest, direction: .outgoing, timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) ) @@ -521,7 +535,7 @@ extension ConversationVC: let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: kUTTypeJPEG as String, imageQuality: .medium) let approvalVC = AttachmentApprovalViewController.wrappedInNavController( - threadId: self.viewModel.viewData.thread.id, + threadId: self.viewModel.threadData.threadId, attachments: [ attachment ], approvalDelegate: self ) @@ -539,7 +553,7 @@ extension ConversationVC: let newText: String = snInputView.text.replacingCharacters( in: currentMentionStartIndex..., - with: "@\(mentionInfo.profile.displayName(for: self.viewModel.viewData.thread.variant)) " + with: "@\(mentionInfo.profile.displayName(for: self.viewModel.threadData.threadVariant)) " ) snInputView.text = newText @@ -547,7 +561,7 @@ extension ConversationVC: snInputView.hideMentionsUI() mentions = mentions.filter { mentionInfo -> Bool in - newText.contains(mentionInfo.profile.displayName(for: self.viewModel.viewData.thread.variant)) + newText.contains(mentionInfo.profile.displayName(for: self.viewModel.threadData.threadVariant)) } } @@ -614,20 +628,20 @@ extension ConversationVC: // MARK: MessageCellDelegate - func handleItemLongPressed(_ item: ConversationViewModel.Item) { + func handleItemLongPressed(_ cellViewModel: MessageCell.ViewModel) { // Show the context menu if applicable guard let keyWindow: UIWindow = UIApplication.shared.keyWindow, - let index = viewModel.viewData.items.firstIndex(of: item), + let index = viewModel.interactionData.firstIndex(of: cellViewModel), let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell, let snapshot = cell.bubbleView.snapshotView(afterScreenUpdates: false), contextMenuWindow == nil, let actions: [ContextMenuVC.Action] = ContextMenuVC.actions( - for: item, + for: cellViewModel, currentUserIsOpenGroupModerator: OpenGroupAPIV2.isUserModerator( - self.viewModel.viewData.userPublicKey, - for: self.viewModel.viewData.openGroupRoom, - on: self.viewModel.viewData.openGroupServer + self.viewModel.threadData.currentUserPublicKey, + for: self.viewModel.threadData.openGroupRoom, + on: self.viewModel.threadData.openGroupServer ), delegate: self ) @@ -638,7 +652,7 @@ extension ConversationVC: self.contextMenuVC = ContextMenuVC( snapshot: snapshot, frame: cell.convert(cell.bubbleView.frame, to: keyWindow), - item: item, + cellViewModel: cellViewModel, actions: actions ) { [weak self] in self?.contextMenuWindow?.isHidden = true @@ -657,16 +671,16 @@ extension ConversationVC: self.contextMenuWindow?.makeKeyAndVisible() } - func handleItemTapped(_ item: ConversationViewModel.Item, gestureRecognizer: UITapGestureRecognizer) { - guard item.interactionVariant != .standardOutgoing || item.state != .failed else { + func handleItemTapped(_ cellViewModel: MessageCell.ViewModel, gestureRecognizer: UITapGestureRecognizer) { + guard cellViewModel.variant != .standardOutgoing || cellViewModel.state != .failed else { // Show the failed message sheet - showFailedMessageSheet(for: item) + showFailedMessageSheet(for: cellViewModel) return } // If it's an incoming media message and the thread isn't trusted then show the placeholder view - if item.cellType != .textOnlyMessage && item.interactionVariant == .standardIncoming && !item.isThreadTrusted { - let modal = DownloadAttachmentModal(profile: item.profile) + if cellViewModel.cellType != .textOnlyMessage && cellViewModel.variant == .standardIncoming && !cellViewModel.threadIsTrusted { + let modal = DownloadAttachmentModal(profile: cellViewModel.profile) modal.modalPresentationStyle = .overFullScreen modal.modalTransitionStyle = .crossDissolve @@ -674,12 +688,12 @@ extension ConversationVC: return } - switch item.cellType { - case .audio: viewModel.playOrPauseAudio(for: item) + switch cellViewModel.cellType { + case .audio: viewModel.playOrPauseAudio(for: cellViewModel) case .mediaMessage: guard - let index = self.viewModel.viewData.items.firstIndex(where: { $0.interactionId == item.interactionId }), + let index = self.viewModel.interactionData.firstIndex(where: { $0.id == cellViewModel.id }), let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell, let albumView: MediaAlbumView = cell.albumView else { return } @@ -702,9 +716,9 @@ extension ConversationVC: default: let viewController: UIViewController? = MediaGalleryViewModel.createDetailViewController( - for: self.viewModel.viewData.thread.id, - threadVariant: self.viewModel.viewData.thread.variant, - interactionId: item.interactionId, + for: self.viewModel.threadData.threadId, + threadVariant: self.viewModel.threadData.threadVariant, + interactionId: cellViewModel.id, selectedAttachmentId: mediaView.attachment.id, options: [ .sliderEnabled, .showAllMediaButton ] ) @@ -718,7 +732,7 @@ extension ConversationVC: self.resignFirstResponder() /// Delay the actual presentation to give the 'resignFirstResponder' call the chance to complete - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) { [weak self] in /// Lock the contentOffset of the tableView so the transition doesn't look buggy self?.tableView.lockContentOffset = true @@ -733,7 +747,7 @@ extension ConversationVC: case .genericAttachment: guard - let attachment: Attachment = item.attachments?.first, + let attachment: Attachment = cellViewModel.attachments?.first, let originalFilePath: String = attachment.originalFilePath else { return } @@ -766,19 +780,18 @@ extension ConversationVC: joinOpenGroup(name: name, url: url) } default: break - } } } - func handleItemDoubleTapped(_ item: ConversationViewModel.Item) { - switch item.cellType { + func handleItemDoubleTapped(_ cellViewModel: MessageCell.ViewModel) { + switch cellViewModel.cellType { // The user can double tap a voice message when it's playing to speed it up - case .audio: self.viewModel.speedUpAudio(for: item) + case .audio: self.viewModel.speedUpAudio(for: cellViewModel) default: break } } - func handleItemSwiped(_ item: ConversationViewModel.Item, state: SwipeState) { + func handleItemSwiped(_ cellViewModel: MessageCell.ViewModel, state: SwipeState) { switch state { case .began: tableView.isScrollEnabled = false case .ended, .cancelled: tableView.isScrollEnabled = true @@ -809,8 +822,8 @@ extension ConversationVC: self.presentAlert(alertVC) } - func handleReplyButtonTapped(for item: ConversationViewModel.Item) { - reply(item) + func handleReplyButtonTapped(for cellViewModel: MessageCell.ViewModel) { + reply(cellViewModel) } func showUserDetails(for profile: Profile) { @@ -824,21 +837,22 @@ extension ConversationVC: // MARK: --action handling - func showFailedMessageSheet(for item: ConversationViewModel.Item) { - let sheet = UIAlertController(title: item.mostRecentFailureText, message: nil, preferredStyle: .actionSheet) + func showFailedMessageSheet(for cellViewModel: MessageCell.ViewModel) { + let sheet = UIAlertController(title: cellViewModel.mostRecentFailureText, message: nil, preferredStyle: .actionSheet) sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) sheet.addAction(UIAlertAction(title: "Delete", style: .destructive, handler: { _ in GRDBStorage.shared.writeAsync { db in try Interaction - .filter(id: item.interactionId) + .filter(id: cellViewModel.id) .deleteAll(db) } })) sheet.addAction(UIAlertAction(title: "Resend", style: .default, handler: { _ in GRDBStorage.shared.writeAsync { [weak self] db in guard - let interaction: Interaction = try? Interaction.fetchOne(db, id: item.interactionId), - let thread: SessionThread = self?.viewModel.viewData.thread + let threadId: String = self?.viewModel.threadData.threadId, + let interaction: Interaction = try? Interaction.fetchOne(db, id: cellViewModel.id), + let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { return } try MessageSender.send( db, @@ -850,7 +864,7 @@ extension ConversationVC: // HACK: Extracting this info from the error string is pretty dodgy let prefix: String = "HTTP request failed at destination (Service node " - if let mostRecentFailureText: String = item.mostRecentFailureText, mostRecentFailureText.hasPrefix(prefix) { + if let mostRecentFailureText: String = cellViewModel.mostRecentFailureText, mostRecentFailureText.hasPrefix(prefix) { let rest = mostRecentFailureText.substring(from: prefix.count) if let index = rest.firstIndex(of: ")") { @@ -876,37 +890,37 @@ extension ConversationVC: // MARK: - ContextMenuActionDelegate - func reply(_ item: ConversationViewModel.Item) { + func reply(_ cellViewModel: MessageCell.ViewModel) { let maybeQuoteDraft: QuotedReplyModel? = QuotedReplyModel.quotedReplyForSending( - threadId: self.viewModel.viewData.thread.id, - authorId: item.authorId, - variant: item.interactionVariant, - body: item.body, - timestampMs: item.timestampMs, - attachments: item.attachments, - linkPreview: item.linkPreview + threadId: self.viewModel.threadData.threadId, + authorId: cellViewModel.authorId, + variant: cellViewModel.variant, + body: cellViewModel.body, + timestampMs: cellViewModel.timestampMs, + attachments: cellViewModel.attachments, + linkPreviewAttachment: cellViewModel.linkPreviewAttachment ) guard let quoteDraft: QuotedReplyModel = maybeQuoteDraft else { return } snInputView.quoteDraftInfo = ( model: quoteDraft, - isOutgoing: (item.interactionVariant == .standardOutgoing) + isOutgoing: (cellViewModel.variant == .standardOutgoing) ) snInputView.becomeFirstResponder() } - func copy(_ item: ConversationViewModel.Item) { - switch item.cellType { + func copy(_ cellViewModel: MessageCell.ViewModel) { + switch cellViewModel.cellType { case .typingIndicator: break case .textOnlyMessage: - UIPasteboard.general.string = item.body + UIPasteboard.general.string = cellViewModel.body case .audio, .genericAttachment, .mediaMessage: guard - item.attachments?.count == 1, - let attachment: Attachment = item.attachments?.first, + cellViewModel.attachments?.count == 1, + let attachment: Attachment = cellViewModel.attachments?.first, attachment.isValid, ( attachment.state == .downloaded || @@ -921,22 +935,22 @@ extension ConversationVC: } } - func copySessionID(_ item: ConversationViewModel.Item) { - guard item.interactionVariant == .standardIncoming || item.interactionVariant == .standardIncomingDeleted else { + func copySessionID(_ cellViewModel: MessageCell.ViewModel) { + guard cellViewModel.variant == .standardIncoming || cellViewModel.variant == .standardIncomingDeleted else { return } - UIPasteboard.general.string = item.authorId + UIPasteboard.general.string = cellViewModel.authorId } - func delete(_ item: ConversationViewModel.Item) { + func delete(_ cellViewModel: MessageCell.ViewModel) { // Only allow deletion on incoming and outgoing messages - guard item.interactionVariant == .standardIncoming || item.interactionVariant == .standardOutgoing else { + guard cellViewModel.variant == .standardIncoming || cellViewModel.variant == .standardOutgoing else { return } - let thread: SessionThread = self.viewModel.viewData.thread - let threadName: String = self.viewModel.viewData.threadName + let threadId: String = self.viewModel.threadData.threadId + let threadName: String = self.viewModel.threadData.displayName let userPublicKey: String = getUserHexEncodedPublicKey() // Remote deletion logic @@ -954,7 +968,7 @@ extension ConversationVC: // Delete the interaction (and associated data) from the database GRDBStorage.shared.writeAsync { db in _ = try Interaction - .filter(id: item.interactionId) + .filter(id: cellViewModel.id) .deleteAll(db) } } @@ -971,7 +985,7 @@ extension ConversationVC: } // How we delete the message differs depending on the type of thread - switch item.threadVariant { + switch cellViewModel.threadVariant { // Handle open group messages the old way case .openGroup: // If it's an incoming message the user must have moderator status @@ -979,17 +993,17 @@ extension ConversationVC: ( try Interaction .select(.openGroupServerMessageId) - .filter(id: item.interactionId) + .filter(id: cellViewModel.id) .asRequest(of: Int64.self) .fetchOne(db), - try OpenGroup.fetchOne(db, id: thread.id) + try OpenGroup.fetchOne(db, id: threadId) ) } guard let openGroup: OpenGroup = result?.openGroup, let openGroupServerMessageId: Int64 = result?.openGroupServerMessageId, ( - item.interactionVariant != .standardIncoming || + cellViewModel.variant != .standardIncoming || OpenGroupAPIV2.isUserModerator(userPublicKey, for: openGroup.room, on: openGroup.server) ) else { return } @@ -1010,23 +1024,23 @@ extension ConversationVC: let serverHash: String? = GRDBStorage.shared.read { db -> String? in try Interaction .select(.serverHash) - .filter(id: item.interactionId) + .filter(id: cellViewModel.id) .asRequest(of: String.self) .fetchOne(db) } let unsendRequest: UnsendRequest = UnsendRequest( - timestamp: UInt64(item.timestampMs), - author: (item.interactionVariant == .standardOutgoing ? + timestamp: UInt64(cellViewModel.timestampMs), + author: (cellViewModel.variant == .standardOutgoing ? userPublicKey : - item.authorId + cellViewModel.authorId ) ) // For incoming interactions or interactions with no serverHash just delete them locally - guard item.interactionVariant == .standardOutgoing, let serverHash: String = serverHash else { + guard cellViewModel.variant == .standardOutgoing, let serverHash: String = serverHash else { GRDBStorage.shared.writeAsync { db in _ = try Interaction - .filter(id: item.interactionId) + .filter(id: cellViewModel.id) .deleteAll(db) // No need to send the unsendRequest if there is no serverHash (ie. the message @@ -1037,7 +1051,7 @@ extension ConversationVC: .send( db, message: unsendRequest, - threadId: thread.id, + threadId: threadId, interactionId: nil, to: .contact(publicKey: userPublicKey) ) @@ -1049,14 +1063,14 @@ extension ConversationVC: alertVC.addAction(UIAlertAction(title: "delete_message_for_me".localized(), style: .destructive) { [weak self] _ in GRDBStorage.shared.writeAsync { db in _ = try Interaction - .filter(id: item.interactionId) + .filter(id: cellViewModel.id) .deleteAll(db) MessageSender .send( db, message: unsendRequest, - threadId: thread.id, + threadId: threadId, interactionId: nil, to: .contact(publicKey: userPublicKey) ) @@ -1065,7 +1079,7 @@ extension ConversationVC: }) alertVC.addAction(UIAlertAction( - title: (item.threadVariant == .closedGroup ? + title: (cellViewModel.threadVariant == .closedGroup ? "delete_message_for_everyone".localized() : String(format: "delete_message_for_me_and_recipient".localized(), threadName) ), @@ -1075,12 +1089,16 @@ extension ConversationVC: from: self, request: SnodeAPI .deleteMessage( - publicKey: thread.id, + publicKey: threadId, serverHashes: [serverHash] ) .map { _ in () } ) { [weak self] in GRDBStorage.shared.writeAsync { db in + guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { + return + } + try MessageSender .send( db, @@ -1104,10 +1122,10 @@ extension ConversationVC: } } - func save(_ item: ConversationViewModel.Item) { - guard item.cellType == .mediaMessage else { return } + func save(_ cellViewModel: MessageCell.ViewModel) { + guard cellViewModel.cellType == .mediaMessage else { return } - let mediaAttachments: [(Attachment, String)] = (item.attachments ?? []) + let mediaAttachments: [(Attachment, String)] = (cellViewModel.attachments ?? []) .filter { attachment in attachment.isValid && attachment.isVisualMedia && ( @@ -1142,17 +1160,19 @@ extension ConversationVC: } // Send a 'media saved' notification if needed - guard self.viewModel.viewData.thread.variant == .contact, item.interactionVariant == .standardIncoming else { + guard self.viewModel.threadData.threadVariant == .contact, cellViewModel.variant == .standardIncoming else { return } - let thread: SessionThread = self.viewModel.viewData.thread + let threadId: String = self.viewModel.threadData.threadId GRDBStorage.shared.writeAsync { db in + guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { return } + try MessageSender.send( db, message: DataExtractionNotification( - kind: .mediaSaved(timestamp: UInt64(item.timestampMs)) + kind: .mediaSaved(timestamp: UInt64(cellViewModel.timestampMs)) ), interactionId: nil, in: thread @@ -1160,10 +1180,10 @@ extension ConversationVC: } } - func ban(_ item: ConversationViewModel.Item) { - guard item.threadVariant == .openGroup else { return } + func ban(_ cellViewModel: MessageCell.ViewModel) { + guard cellViewModel.threadVariant == .openGroup else { return } - let threadId: String = self.viewModel.viewData.thread.id + let threadId: String = self.viewModel.threadData.threadId let alert: UIAlertController = UIAlertController( title: "Session", message: "This will ban the selected user from this room. It won't ban them from other rooms.", @@ -1175,7 +1195,7 @@ extension ConversationVC: } OpenGroupAPIV2 - .ban(item.authorId, from: openGroup.room, on: openGroup.server) + .ban(cellViewModel.authorId, from: openGroup.room, on: openGroup.server) .retainUntilComplete() })) alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: nil)) @@ -1183,10 +1203,10 @@ extension ConversationVC: present(alert, animated: true, completion: nil) } - func banAndDeleteAllMessages(_ item: ConversationViewModel.Item) { - guard item.threadVariant == .openGroup else { return } + func banAndDeleteAllMessages(_ cellViewModel: MessageCell.ViewModel) { + guard cellViewModel.threadVariant == .openGroup else { return } - let threadId: String = self.viewModel.viewData.thread.id + let threadId: String = self.viewModel.threadData.threadId let alert: UIAlertController = UIAlertController( title: "Session", message: "This will ban the selected user from this room and delete all messages sent by them. It won't ban them from other rooms or delete the messages they sent there.", @@ -1198,7 +1218,7 @@ extension ConversationVC: } OpenGroupAPIV2 - .banAndDeleteAllMessages(item.authorId, from: openGroup.room, on: openGroup.server) + .banAndDeleteAllMessages(cellViewModel.authorId, from: openGroup.room, on: openGroup.server) .retainUntilComplete() })) alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: nil)) @@ -1451,18 +1471,25 @@ extension ConversationVC: UIDocumentInteractionControllerDelegate { extension ConversationVC { fileprivate func approveMessageRequestIfNeeded( - for thread: SessionThread?, + for threadId: String, + threadVariant: SessionThread.Variant, isNewThread: Bool, timestampMs: Int64 ) -> Promise { - guard let thread: SessionThread = thread, thread.variant == .contact else { return Promise.value(()) } + guard threadVariant == .contact else { return Promise.value(()) } // If the contact doesn't exist then we should create it so we can store the 'isApproved' state // (it'll be updated with correct profile info if they accept the message request so this // shouldn't cause weird behaviours) guard - let contact: Contact = GRDBStorage.shared.read({ db in Contact.fetchOrCreate(db, id: thread.id) }), - !contact.isApproved + let approvalData: (contact: Contact, thread: SessionThread?) = GRDBStorage.shared.read({ db in + return ( + Contact.fetchOrCreate(db, id: threadId), + try SessionThread.fetchOne(db, id: threadId) + ) + }), + let thread: SessionThread = approvalData.thread, + !approvalData.contact.isApproved else { return Promise.value(()) } @@ -1507,10 +1534,10 @@ extension ConversationVC { // Default 'didApproveMe' to true for the person approving the message request GRDBStorage.shared.writeAsync( updates: { db in - try contact + try approvalData.contact .with( isApproved: true, - didApproveMe: .update(contact.didApproveMe || !isNewThread) + didApproveMe: .update(approvalData.contact.didApproveMe || !isNewThread) ) .save(db) @@ -1559,7 +1586,8 @@ extension ConversationVC { @objc func acceptMessageRequest() { self.approveMessageRequestIfNeeded( - for: self.viewModel.viewData.thread, + for: self.viewModel.threadData.threadId, + threadVariant: self.viewModel.threadData.threadVariant, isNewThread: false, timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) ) @@ -1577,9 +1605,9 @@ extension ConversationVC { } @objc func deleteMessageRequest() { - guard self.viewModel.viewData.thread.variant == .contact else { return } + guard self.viewModel.threadData.threadVariant == .contact else { return } - let threadId: String = self.viewModel.viewData.thread.id + let threadId: String = self.viewModel.threadData.threadId let alertVC: UIAlertController = UIAlertController( title: "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON".localized(), message: nil, diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 37250395b..69c5b293e 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -62,8 +62,8 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers override var inputAccessoryView: UIView? { guard - viewModel.viewData.thread.variant != .closedGroup || - viewModel.viewData.isClosedGroupMember + viewModel.threadData.threadVariant != .closedGroup || + viewModel.threadData.currentUserIsClosedGroupMember == true else { return nil } return (isShowingSearchUI ? searchController.resultsBar : snInputView) @@ -150,7 +150,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers }() lazy var snInputView: InputView = InputView( - threadVariant: viewModel.viewData.thread.variant, + threadVariant: self.viewModel.threadData.threadVariant, delegate: self ) @@ -176,7 +176,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers lazy var blockedBanner: InfoBanner = { let result: InfoBanner = InfoBanner( - message: viewModel.blockedBannerMessage, + message: self.viewModel.blockedBannerMessage, backgroundColor: Colors.destructive ) let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(unblock)) @@ -203,7 +203,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers lazy var messageRequestView: UIView = { let result: UIView = UIView() result.translatesAutoresizingMaskIntoConstraints = false - result.isHidden = !viewModel.viewData.threadIsMessageRequest + result.isHidden = (self.viewModel.threadData.threadIsMessageRequest == false) result.setGradient(Gradients.defaultBackground) return result @@ -330,19 +330,19 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers navigationItem.titleView = titleView titleView.update( - with: viewModel.viewData.threadName, - mutedUntilTimestamp: viewModel.viewData.thread.mutedUntilTimestamp, - onlyNotifyForMentions: viewModel.viewData.thread.onlyNotifyForMentions, - userCount: viewModel.viewData.userCount + with: viewModel.threadData.displayName, + mutedUntilTimestamp: viewModel.threadData.threadMutedUntilTimestamp, + onlyNotifyForMentions: (viewModel.threadData.threadOnlyNotifyForMentions == true), + userCount: viewModel.threadData.userCount ) - updateNavBarButtons(viewData: viewModel.viewData) + updateNavBarButtons(threadData: viewModel.threadData) // Constraints view.addSubview(tableView) tableView.pin(to: view) // Blocked banner - addOrRemoveBlockedBanner(threadIsBlocked: viewModel.viewData.threadIsBlocked) + addOrRemoveBlockedBanner(threadIsBlocked: (viewModel.threadData.threadIsBlocked == true)) // Message requests view & scroll to bottom view.addSubview(scrollButton) @@ -359,8 +359,8 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers self.scrollButtonBottomConstraint = scrollButton.pin(.bottom, to: .bottom, of: view, withInset: -16) self.scrollButtonBottomConstraint?.isActive = false // Note: Need to disable this to avoid a conflict with the other bottom constraint self.scrollButtonMessageRequestsBottomConstraint = scrollButton.pin(.bottom, to: .top, of: messageRequestView, withInset: -16) - self.scrollButtonMessageRequestsBottomConstraint?.isActive = viewModel.viewData.threadIsMessageRequest - self.scrollButtonBottomConstraint?.isActive = !viewModel.viewData.threadIsMessageRequest + self.scrollButtonMessageRequestsBottomConstraint?.isActive = (viewModel.threadData.threadIsMessageRequest == true) + self.scrollButtonBottomConstraint?.isActive = (viewModel.threadData.threadIsMessageRequest == false) messageRequestDescriptionLabel.pin(.top, to: .top, of: messageRequestView, withInset: 10) messageRequestDescriptionLabel.pin(.left, to: .left, of: messageRequestView, withInset: 40) @@ -392,7 +392,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers unreadCountView.pin(.trailing, to: .trailing, of: unreadCountLabel, withInset: 4) unreadCountView.centerYAnchor.constraint(equalTo: scrollButton.topAnchor).isActive = true unreadCountView.center(.horizontal, in: scrollButton) - updateUnreadCountView(unreadCount: viewModel.viewData.unreadCount) + updateUnreadCountView(unreadCount: viewModel.threadData.threadUnreadCount) // Notifications NotificationCenter.default.addObserver( @@ -420,12 +420,12 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers ) // Draft - if let draft: String = viewModel.viewData.thread.messageDraft, !draft.isEmpty { + if let draft: String = viewModel.threadData.threadMessageDraft, !draft.isEmpty { snInputView.text = draft } // Update the input state - snInputView.setEnabledMessageTypes(viewModel.viewData.enabledMessageTypes, message: nil) + snInputView.setEnabledMessageTypes(viewModel.threadData.enabledMessageTypes, message: nil) } @@ -441,7 +441,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers if let focusedInteractionId: Int64 = self.viewModel.focusedInteractionId { self.scrollToInteraction(with: focusedInteractionId, isAnimated: false, highlighted: true) } - else if let firstUnreadInteractionId: Int64 = self.viewModel.viewData.firstUnreadInteractionId { + else if let firstUnreadInteractionId: Int64 = self.viewModel.threadData.threadFirstUnreadInteractionId { self.scrollToInteraction(with: firstUnreadInteractionId, position: .top, isAnimated: false) self.unreadCountView.alpha = self.scrollButton.alpha } @@ -478,8 +478,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - // Stop observing database changes - dataChangeObservable?.cancel() + stopObservingChanges() viewModel.updateDraft(to: snInputView.text) inputAccessoryView?.resignFirstResponder() } @@ -496,8 +495,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers } @objc func applicationDidResignActive(_ notification: Notification) { - // Stop observing database changes - dataChangeObservable?.cancel() + stopObservingChanges() } // MARK: - Updating @@ -505,84 +503,104 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers private func startObservingChanges() { // Start observing for data changes dataChangeObservable = GRDBStorage.shared.start( - viewModel.observableViewData, - onError: { error in - }, - onChange: { [weak self] viewData in + viewModel.observableThreadData, + onError: { _ in }, + onChange: { [weak self] maybeThreadData in + guard let threadData: ConversationCell.ViewModel = maybeThreadData else { return } + // The default scheduler emits changes on the main thread - self?.handleUpdates(viewData) + self?.handleThreadUpdates(threadData) } ) + + self.viewModel.onInteractionChange = { [weak self] updatedInteractionData in + self?.handleInteractionUpdates(updatedInteractionData) + } } - private func handleUpdates(_ updatedViewData: ConversationViewModel.ViewData, initialLoad: Bool = false) { + private func stopObservingChanges() { + // Stop observing database changes + dataChangeObservable?.cancel() + self.viewModel.onInteractionChange = nil + } + + private func handleThreadUpdates(_ updatedThreadData: ConversationCell.ViewModel, initialLoad: Bool = false) { // Ensure the first load or a load when returning from a child screen runs without animations (if // we don't do this the cells will animate in from a frame of CGRect.zero or have a buggy transition) guard hasLoadedInitialData && hasReloadedDataAfterDisappearance else { hasLoadedInitialData = true hasReloadedDataAfterDisappearance = true - UIView.performWithoutAnimation { handleUpdates(updatedViewData, initialLoad: true) } + UIView.performWithoutAnimation { handleThreadUpdates(updatedThreadData, initialLoad: true) } return } // Update general conversation UI if initialLoad || - viewModel.viewData.threadName != updatedViewData.threadName || - viewModel.viewData.thread.mutedUntilTimestamp != updatedViewData.thread.mutedUntilTimestamp || - viewModel.viewData.thread.onlyNotifyForMentions != updatedViewData.thread.onlyNotifyForMentions || - viewModel.viewData.userCount != updatedViewData.userCount + viewModel.threadData.displayName != updatedThreadData.displayName || + viewModel.threadData.threadMutedUntilTimestamp != updatedThreadData.threadMutedUntilTimestamp || + viewModel.threadData.threadOnlyNotifyForMentions != updatedThreadData.threadOnlyNotifyForMentions || + viewModel.threadData.userCount != updatedThreadData.userCount { titleView.update( - with: updatedViewData.threadName, - mutedUntilTimestamp: updatedViewData.thread.mutedUntilTimestamp, - onlyNotifyForMentions: updatedViewData.thread.onlyNotifyForMentions, - userCount: updatedViewData.userCount + with: updatedThreadData.displayName, + mutedUntilTimestamp: updatedThreadData.threadMutedUntilTimestamp, + onlyNotifyForMentions: (updatedThreadData.threadOnlyNotifyForMentions == true), + userCount: updatedThreadData.userCount ) } if initialLoad || - viewModel.viewData.requiresApproval != updatedViewData.requiresApproval || - viewModel.viewData.threadAvatarProfiles != updatedViewData.threadAvatarProfiles + viewModel.threadData.threadRequiresApproval != updatedThreadData.threadRequiresApproval || + viewModel.threadData.profile != updatedThreadData.profile { - updateNavBarButtons(viewData: updatedViewData) + updateNavBarButtons(threadData: updatedThreadData) } - if viewModel.viewData.isClosedGroupMember != updatedViewData.isClosedGroupMember { + if viewModel.threadData.currentUserIsClosedGroupMember != updatedThreadData.currentUserIsClosedGroupMember { reloadInputViews() } - if initialLoad || viewModel.viewData.enabledMessageTypes != updatedViewData.enabledMessageTypes { + if initialLoad || viewModel.threadData.enabledMessageTypes != updatedThreadData.enabledMessageTypes { snInputView.setEnabledMessageTypes( - updatedViewData.enabledMessageTypes, + updatedThreadData.enabledMessageTypes, message: nil ) } - if initialLoad || viewModel.viewData.threadIsBlocked != updatedViewData.threadIsBlocked { - addOrRemoveBlockedBanner(threadIsBlocked: updatedViewData.threadIsBlocked) + if initialLoad || viewModel.threadData.threadIsBlocked != updatedThreadData.threadIsBlocked { + addOrRemoveBlockedBanner(threadIsBlocked: (updatedThreadData.threadIsBlocked == true)) } - if initialLoad || viewModel.viewData.unreadCount != updatedViewData.unreadCount { - updateUnreadCountView(unreadCount: updatedViewData.unreadCount) + if initialLoad || viewModel.threadData.threadUnreadCount != updatedThreadData.threadUnreadCount { + updateUnreadCountView(unreadCount: updatedThreadData.threadUnreadCount) + } + } + + private func handleInteractionUpdates(_ updatedViewData: [MessageCell.ViewModel], initialLoad: Bool = false) { + // Ensure the first load or a load when returning from a child screen runs without animations (if + // we don't do this the cells will animate in from a frame of CGRect.zero or have a buggy transition) + guard hasLoadedInitialData && hasReloadedDataAfterDisappearance else { + hasLoadedInitialData = true + hasReloadedDataAfterDisappearance = true + UIView.performWithoutAnimation { handleInteractionUpdates(updatedViewData, initialLoad: true) } + return } // Reload the table content (animate changes after the first load) - let changeset = StagedChangeset(source: viewModel.viewData.items, target: updatedViewData.items) + let changeset = StagedChangeset(source: viewModel.interactionData, target: updatedViewData) tableView.reload( - using: StagedChangeset(source: viewModel.viewData.items, target: updatedViewData.items), + using: StagedChangeset(source: viewModel.interactionData, target: updatedViewData), deleteSectionsAnimation: .bottom, insertSectionsAnimation: .bottom, reloadSectionsAnimation: .none, deleteRowsAnimation: .bottom, insertRowsAnimation: .bottom, reloadRowsAnimation: .none, - interrupt: { - return $0.changeCount > 100 - } // Prevent too many changes from causing performance issues - ) { [weak self] items in - self?.viewModel.updateData(updatedViewData.with(items: items)) + interrupt: { $0.changeCount > ConversationViewModel.pageSize } + ) { [weak self] updatedData in + self?.viewModel.updateInteractionData(updatedData) } // Scroll to the bottom if we just inserted a message and are close enough @@ -601,7 +619,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers viewModel.sentMessageBeforeUpdate = false } - func updateNavBarButtons(viewData: ConversationViewModel.ViewData) { + func updateNavBarButtons(threadData: ConversationCell.ViewModel) { navigationItem.hidesBackButton = isShowingSearchUI if isShowingSearchUI { @@ -609,7 +627,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers navigationItem.rightBarButtonItems = [] } else { - guard !viewData.requiresApproval else { + guard threadData.threadRequiresApproval == false else { // Note: Adding an empty button because without it the title alignment is // busted (Note: The size was taken from the layout inspector for the back // button in Xcode @@ -626,14 +644,14 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers return } - switch viewData.thread.variant { + switch threadData.threadVariant { case .contact: let profilePictureView = ProfilePictureView() profilePictureView.size = Values.verySmallProfilePictureSize profilePictureView.update( - publicKey: viewData.thread.id, // Contact thread uses the contactId - profile: viewData.threadAvatarProfiles.first, - threadVariant: viewData.thread.variant + publicKey: threadData.threadId, // Contact thread uses the contactId + profile: threadData.profile, + threadVariant: threadData.threadVariant ) profilePictureView.set(.width, to: (44 - 16)) // Width of the standard back button profilePictureView.set(.height, to: Values.verySmallProfilePictureSize) @@ -882,26 +900,26 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers // MARK: - UITableViewDataSource func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return viewModel.viewData.items.count + return viewModel.interactionData.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let item: ConversationViewModel.Item = viewModel.viewData.items[indexPath.row] - let cell: MessageCell = tableView.dequeue(type: MessageCell.cellType(for: item), for: indexPath) + let cellViewModel: MessageCell.ViewModel = viewModel.interactionData[indexPath.row] + let cell: MessageCell = tableView.dequeue(type: MessageCell.cellType(for: cellViewModel), for: indexPath) cell.update( - with: item, + with: cellViewModel, mediaCache: mediaCache, - playbackInfo: viewModel.playbackInfo(for: item) { updatedInfo, error in + playbackInfo: viewModel.playbackInfo(for: cellViewModel) { updatedInfo, error in DispatchQueue.main.async { guard error == nil else { OWSAlerts.showErrorAlert(message: "INVALID_AUDIO_FILE_ALERT_ERROR_MESSAGE".localized()) return } - cell.dynamicUpdate(with: item, playbackInfo: updatedInfo) + cell.dynamicUpdate(with: cellViewModel, playbackInfo: updatedInfo) } }, - lastSearchText: viewModel.viewData.lastSearchedText + lastSearchText: viewModel.lastSearchedText ) cell.delegate = self @@ -919,11 +937,11 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers } func scrollToBottom(isAnimated: Bool) { - guard !isUserScrolling && !viewModel.viewData.items.isEmpty else { return } + guard !isUserScrolling && !viewModel.interactionData.isEmpty else { return } tableView.scrollToRow( at: IndexPath( - row: viewModel.viewData.items.count - 1, + row: viewModel.interactionData.count - 1, section: 0), at: .bottom, animated: isAnimated @@ -944,7 +962,8 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers autoLoadMoreIfNeeded() } - func updateUnreadCountView(unreadCount: Int) { + func updateUnreadCountView(unreadCount: UInt?) { + let unreadCount: Int = Int(unreadCount ?? 0) let fontSize: CGFloat = (unreadCount < 10000 ? Values.verySmallFontSize : 8) unreadCountLabel.text = (unreadCount < 10000 ? "\(unreadCount)" : "9999+") unreadCountLabel.font = .boldSystemFont(ofSize: fontSize) @@ -996,7 +1015,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers navigationItem.titleView = searchBar // Nav bar buttons - updateNavBarButtons(viewData: viewModel.viewData) + updateNavBarButtons(threadData: self.viewModel.threadData) // Hack so that the ResultsBar stays on the screen when dismissing the search field // keyboard. @@ -1032,7 +1051,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers func hideSearchUI() { isShowingSearchUI = false navigationItem.titleView = titleView - updateNavBarButtons(viewData: viewModel.viewData) + updateNavBarButtons(threadData: self.viewModel.threadData) let navBar = navigationController!.navigationBar as! OWSNavigationBar navBar.stubbedNextResponder = nil diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 0a8ab0dac..7ef7ef60b 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -1,3 +1,513 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB +import DifferenceKit +import SessionMessagingKit +import SessionUtilitiesKit + +public class ConversationViewModel: OWSAudioPlayerDelegate { + public enum Action { + case none + case compose + case audioCall + case videoCall + } + + public static let pageSize: Int = 50 + + // MARK: - Initialization + + init?(threadId: String, focusedInteractionId: Int64?) { + let maybeThreadData: ConversationCell.ViewModel? = GRDBStorage.shared.read { db in + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + return try ConversationCell.ViewModel + .conversationQuery( + threadId: threadId, + userPublicKey: userPublicKey + ) + .fetchOne(db) + } + + guard let threadData: ConversationCell.ViewModel = maybeThreadData else { return nil } + + self.threadId = threadId + self.threadData = threadData + self.focusedInteractionId = focusedInteractionId // TODO: This + self.pagedDataObserver = nil + var hasSavedIntialUpdate: Bool = false + self.pagedDataObserver = PagedDatabaseObserver( + pagedTable: Interaction.self, + pageSize: ConversationViewModel.pageSize, + idColumn: .id, + initialFocusedId: nil, + observedChanges: [ + PagedData.ObservedChanges( + table: Interaction.self, + columns: Interaction.Columns + .allCases + .filter { $0 != .wasRead } + ) + ], + filterSQL: MessageCell.ViewModel.filterSQL(threadId: threadId), + orderSQL: MessageCell.ViewModel.orderSQL, + dataQuery: MessageCell.ViewModel.baseQuery( + orderSQL: MessageCell.ViewModel.orderSQL, + baseFilterSQL: MessageCell.ViewModel.filterSQL(threadId: threadId) + ), + associatedRecords: [ + AssociatedRecord( + trackedAgainst: Attachment.self, + observedChanges: [ + PagedData.ObservedChanges( + table: Attachment.self, + columns: [.state] + ) + ], + dataQuery: MessageCell.AttachmentInteractionInfo.baseQuery, + joinToPagedType: MessageCell.AttachmentInteractionInfo.joinToViewModelQuerySQL, + associateData: MessageCell.AttachmentInteractionInfo.createAssociateDataClosure() + ) + ], + onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in + guard let updatedInteractionData: [MessageCell.ViewModel] = self?.process(data: updatedData, for: updatedPageInfo) else { + return + } + + // If we haven't stored the data for the initial fetch then do so now (no need + // to call 'onInteractionsChange' in this case as it will always be null) + guard hasSavedIntialUpdate else { + self?.updateInteractionData(updatedInteractionData) + hasSavedIntialUpdate = true + return + } + + self?.onInteractionChange?(updatedInteractionData) + } + ) + } + + // MARK: - Variables + + private let threadId: String + public var sentMessageBeforeUpdate: Bool = false + public var lastSearchedText: String? + public let focusedInteractionId: Int64? // Note: This is used for global search + + public lazy var blockedBannerMessage: String = { + switch self.threadData.threadVariant { + case .contact: + let name: String = Profile.displayName( + id: self.threadData.threadId, + threadVariant: self.threadData.threadVariant + ) + + return "\(name) is blocked. Unblock them?" + + default: return "Thread is blocked. Unblock it?" + } + }() + + // MARK: - Thread Data + + /// This value is the current state of the view + public private(set) var threadData: ConversationCell.ViewModel + + public lazy var observableThreadData = ValueObservation + .trackingConstantRegion { [threadId = self.threadId] db -> ConversationCell.ViewModel? in + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + return try ConversationCell.ViewModel + .conversationQuery(threadId: threadId, userPublicKey: userPublicKey) + .fetchOne(db) + } + .removeDuplicates() + + public func updateThreadData(_ updatedData: ConversationCell.ViewModel) { + self.threadData = updatedData + } + + // MARK: - Interaction Data + + public private(set) var interactionData: [MessageCell.ViewModel] = [] + public private(set) var pagedDataObserver: PagedDatabaseObserver? + public var onInteractionChange: (([MessageCell.ViewModel]) -> ())? + + private func process(data: [MessageCell.ViewModel], for pageInfo: PagedData.PageInfo) -> [MessageCell.ViewModel] { + let sortedData: [MessageCell.ViewModel] = data + .sorted { lhs, rhs -> Bool in lhs.timestampMs < rhs.timestampMs } + + return sortedData + .enumerated() + .map { index, cellViewModel -> MessageCell.ViewModel in + cellViewModel.withClusteringChanges( + prevModel: (index > 0 ? sortedData[index - 1] : nil), + nextModel: (index < (sortedData.count - 2) ? sortedData[index + 1] : nil), + isLast: ( + index == (sortedData.count - 1) && + pageInfo.currentCount == pageInfo.totalCount + ) + ) + } + } + + public func updateInteractionData(_ updatedData: [MessageCell.ViewModel]) { + self.interactionData = updatedData + } + + // MARK: - Mentions + + public struct MentionInfo: FetchableRecord, Decodable { + fileprivate static let threadVariantKey = CodingKeys.threadVariant.stringValue + fileprivate static let openGroupRoomKey = CodingKeys.openGroupRoom.stringValue + fileprivate static let openGroupServerKey = CodingKeys.openGroupServer.stringValue + + let profile: Profile + let threadVariant: SessionThread.Variant + let openGroupRoom: String? + let openGroupServer: String? + } + + public func mentions(for query: String = "") -> [MentionInfo] { + let threadData: ConversationCell.ViewModel = self.threadData + + let results: [MentionInfo] = GRDBStorage.shared + .read { db -> [MentionInfo] in + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + switch threadData.threadVariant { + case .contact: + guard userPublicKey != threadData.threadId else { return [] } + + return [Profile.fetchOrCreate(db, id: threadData.threadId)] + .map { profile in + MentionInfo( + profile: profile, + threadVariant: threadData.threadVariant, + openGroupRoom: nil, + openGroupServer: nil + ) + } + .filter { + query.count < 2 || + $0.profile.displayName(for: $0.threadVariant).contains(query) + } + + case .closedGroup: + let profile: TypedTableAlias = TypedTableAlias() + + return try GroupMember + .select( + profile.allColumns(), + SQL("\(threadData.threadVariant)").forKey(MentionInfo.threadVariantKey) + ) + .filter(GroupMember.Columns.groupId == threadData.threadId) + .filter(GroupMember.Columns.profileId != userPublicKey) + .filter(GroupMember.Columns.role == GroupMember.Role.standard) + .joining( + required: GroupMember.profile + .aliased(profile) + // Note: LIKE is case-insensitive in SQLite + .filter( + query.count < 2 || ( + profile[.nickname] != nil && + profile[.nickname].like("%\(query)%") + ) || ( + profile[.nickname] == nil && + profile[.name].like("%\(query)%") + ) + ) + ) + .asRequest(of: MentionInfo.self) + .fetchAll(db) + + case .openGroup: + let profile: TypedTableAlias = TypedTableAlias() + + return try Interaction + .select( + profile.allColumns(), + SQL("\(threadData.threadVariant)").forKey(MentionInfo.threadVariantKey), + SQL("\(threadData.openGroupRoom)").forKey(MentionInfo.openGroupRoomKey), + SQL("\(threadData.openGroupServer)").forKey(MentionInfo.openGroupServerKey) + ) + .distinct() + .group(Interaction.Columns.authorId) + .filter(Interaction.Columns.threadId == threadData.threadId) + .filter(Interaction.Columns.authorId != userPublicKey) + .joining( + required: Interaction.profile + .aliased(profile) + // Note: LIKE is case-insensitive in SQLite + .filter( + query.count < 2 || ( + profile[.nickname] != nil && + profile[.nickname].like("%\(query)%") + ) || ( + profile[.nickname] == nil && + profile[.name].like("%\(query)%") + ) + ) + ) + .order(Interaction.Columns.timestampMs.desc) + .limit(20) + .asRequest(of: MentionInfo.self) + .fetchAll(db) + } + } + .defaulting(to: []) + + guard query.count >= 2 else { + return results.sorted { lhs, rhs -> Bool in + lhs.profile.displayName(for: lhs.threadVariant) < rhs.profile.displayName(for: rhs.threadVariant) + } + } + + return results + .sorted { lhs, rhs -> Bool in + let maybeLhsRange = lhs.profile.displayName(for: lhs.threadVariant).lowercased().range(of: query.lowercased()) + let maybeRhsRange = rhs.profile.displayName(for: rhs.threadVariant).lowercased().range(of: query.lowercased()) + + guard let lhsRange: Range = maybeLhsRange, let rhsRange: Range = maybeRhsRange else { + return true + } + + return (lhsRange.lowerBound < rhsRange.lowerBound) + } + } + + // MARK: - Functions + + public func updateDraft(to draft: String) { + GRDBStorage.shared.write { db in + try SessionThread + .filter(id: self.threadId) + .updateAll(db, SessionThread.Columns.messageDraft.set(to: draft)) + } + } + + public func markAllAsRead() { + guard let lastInteractionId: Int64 = self.interactionData.last?.id else { return } + + GRDBStorage.shared.write { db in + try Interaction.markAsRead( + db, + interactionId: lastInteractionId, + threadId: self.threadData.threadId, + includingOlder: true, + trySendReadReceipt: (self.threadData.threadIsMessageRequest == false) + ) + } + } + + // MARK: - Audio Playback + + public struct PlaybackInfo { + let state: AudioPlaybackState + let progress: TimeInterval + let playbackRate: Double + let oldPlaybackRate: Double + let updateCallback: (PlaybackInfo?, Error?) -> () + + public func with( + state: AudioPlaybackState? = nil, + progress: TimeInterval? = nil, + playbackRate: Double? = nil, + updateCallback: ((PlaybackInfo?, Error?) -> ())? = nil + ) -> PlaybackInfo { + return PlaybackInfo( + state: (state ?? self.state), + progress: (progress ?? self.progress), + playbackRate: (playbackRate ?? self.playbackRate), + oldPlaybackRate: self.playbackRate, + updateCallback: (updateCallback ?? self.updateCallback) + ) + } + } + + private var audioPlayer: Atomic = Atomic(nil) + private var currentPlayingInteraction: Atomic = Atomic(nil) + private var playbackInfo: Atomic<[Int64: PlaybackInfo]> = Atomic([:]) + + public func playbackInfo(for viewModel: MessageCell.ViewModel, updateCallback: ((PlaybackInfo?, Error?) -> ())? = nil) -> PlaybackInfo? { + // Use the existing info if it already exists (update it's callback if provided as that means + // the cell was reloaded) + if let currentPlaybackInfo: PlaybackInfo = playbackInfo.wrappedValue[viewModel.id] { + let updatedPlaybackInfo: PlaybackInfo = currentPlaybackInfo + .with(updateCallback: updateCallback) + + playbackInfo.mutate { $0[viewModel.id] = updatedPlaybackInfo } + + return updatedPlaybackInfo + } + + // Validate the item is a valid audio item + guard + let updateCallback: ((PlaybackInfo?, Error?) -> ()) = updateCallback, + let attachment: Attachment = viewModel.attachments?.first, + attachment.isAudio, + attachment.isValid, + let originalFilePath: String = attachment.originalFilePath, + FileManager.default.fileExists(atPath: originalFilePath) + else { return nil } + + // Create the info with the update callback + let newPlaybackInfo: PlaybackInfo = PlaybackInfo( + state: .stopped, + progress: 0, + playbackRate: 1, + oldPlaybackRate: 1, + updateCallback: updateCallback + ) + + // Cache the info + playbackInfo.mutate { $0[viewModel.id] = newPlaybackInfo } + + return newPlaybackInfo + } + + public func playOrPauseAudio(for viewModel: MessageCell.ViewModel) { + guard + let attachment: Attachment = viewModel.attachments?.first, + let originalFilePath: String = attachment.originalFilePath, + FileManager.default.fileExists(atPath: originalFilePath) + else { return } + + // If the user interacted with the currently playing item + guard currentPlayingInteraction.wrappedValue != viewModel.id else { + let currentPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[viewModel.id] + let updatedPlaybackInfo: PlaybackInfo? = currentPlaybackInfo? + .with( + state: (currentPlaybackInfo?.state != .playing ? .playing : .paused), + playbackRate: 1 + ) + + audioPlayer.wrappedValue?.playbackRate = 1 + + switch currentPlaybackInfo?.state { + case .playing: audioPlayer.wrappedValue?.pause() + default: audioPlayer.wrappedValue?.play() + } + + // Update the state and then update the UI with the updated state + playbackInfo.mutate { $0[viewModel.id] = updatedPlaybackInfo } + updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil) + return + } + + // First stop any existing audio + audioPlayer.wrappedValue?.stop() + + // Then setup the state for the new audio + currentPlayingInteraction.mutate { $0 = viewModel.id } + + audioPlayer.mutate { [weak self] player in + let audioPlayer: OWSAudioPlayer = OWSAudioPlayer( + mediaUrl: URL(fileURLWithPath: originalFilePath), + audioBehavior: .audioMessagePlayback, + delegate: self + ) + audioPlayer.play() + audioPlayer.setCurrentTime(playbackInfo.wrappedValue[viewModel.id]?.progress ?? 0) + player = audioPlayer + } + } + + public func speedUpAudio(for viewModel: MessageCell.ViewModel) { + // If we aren't playing the specified item then just start playing it + guard viewModel.id == currentPlayingInteraction.wrappedValue else { + playOrPauseAudio(for: viewModel) + return + } + + let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[viewModel.id]? + .with(playbackRate: 1.5) + + // Speed up the audio player + audioPlayer.wrappedValue?.playbackRate = 1.5 + + playbackInfo.mutate { $0[viewModel.id] = updatedPlaybackInfo } + updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil) + } + + public func stopAudio() { + audioPlayer.wrappedValue?.stop() + + currentPlayingInteraction.mutate { $0 = nil } + audioPlayer.mutate { $0 = nil } + } + + // MARK: - OWSAudioPlayerDelegate + + public func audioPlaybackState() -> AudioPlaybackState { + guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return .stopped } + + return (playbackInfo.wrappedValue[interactionId]?.state ?? .stopped) + } + + public func setAudioPlaybackState(_ state: AudioPlaybackState) { + guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return } + + let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[interactionId]? + .with(state: state) + + playbackInfo.mutate { $0[interactionId] = updatedPlaybackInfo } + updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil) + } + + public func setAudioProgress(_ progress: CGFloat, duration: CGFloat) { + guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return } + + let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[interactionId]? + .with(progress: TimeInterval(progress)) + + playbackInfo.mutate { $0[interactionId] = updatedPlaybackInfo } + updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil) + } + + public func audioPlayerDidFinishPlaying(_ player: OWSAudioPlayer, successfully: Bool) { + guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return } + guard successfully else { return } + + let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[interactionId]? + .with( + state: .stopped, + progress: 0, + playbackRate: 1 + ) + + // Safe the changes and send one final update to the UI + playbackInfo.mutate { $0[interactionId] = updatedPlaybackInfo } + updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil) + + // Clear out the currently playing record + currentPlayingInteraction.mutate { $0 = nil } + audioPlayer.mutate { $0 = nil } + + // If the next interaction is another voice message then autoplay it + guard + let currentIndex: Int = self.interactionData.firstIndex(where: { $0.id == interactionId }), + currentIndex < (self.interactionData.count - 1), + self.interactionData[currentIndex + 1].cellType == .audio + else { return } + + let nextItem: MessageCell.ViewModel = self.interactionData[currentIndex + 1] + playOrPauseAudio(for: nextItem) + } + + public func showInvalidAudioFileAlert() { + guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return } + + let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[interactionId]? + .with( + state: .stopped, + progress: 0, + playbackRate: 1 + ) + + currentPlayingInteraction.mutate { $0 = nil } + playbackInfo.mutate { $0[interactionId] = updatedPlaybackInfo } + updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, AttachmentError.invalidData) + } +} diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift index 3c28bfdcf..9504bdb63 100644 --- a/Session/Conversations/Input View/InputView.swift +++ b/Session/Conversations/Input View/InputView.swift @@ -5,12 +5,6 @@ import SessionUIKit import SessionMessagingKit final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, MentionSelectionViewDelegate { - enum MessageTypes: Equatable { - case all - case textOnly - case none - } - // MARK: - Variables private static let linkPreviewViewInset: CGFloat = 6 @@ -37,7 +31,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M set { inputTextView.text = newValue } } - var enabledMessageTypes: MessageTypes = .all { + var enabledMessageTypes: MessageInputTypes = .all { didSet { setEnabledMessageTypes(enabledMessageTypes, message: nil) } @@ -308,7 +302,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M .retainUntilComplete() } - func setEnabledMessageTypes(_ messageTypes: MessageTypes, message: String?) { + func setEnabledMessageTypes(_ messageTypes: MessageInputTypes, message: String?) { guard enabledMessageTypes != messageTypes else { return } enabledMessageTypes = messageTypes diff --git a/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift b/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift index 204cef5f3..076d49d94 100644 --- a/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift +++ b/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift @@ -128,7 +128,7 @@ final class LinkPreviewView: UIView { with state: LinkPreviewState, isOutgoing: Bool, delegate: (UITextViewDelegate & BodyTextViewDelegate)? = nil, - item: ConversationViewModel.Item? = nil, + cellViewModel: MessageCell.ViewModel? = nil, bodyLabelTextColor: UIColor? = nil, lastSearchText: String? = nil ) { @@ -184,9 +184,9 @@ final class LinkPreviewView: UIView { // Body text view bodyTextViewContainer.subviews.forEach { $0.removeFromSuperview() } - if let item: ConversationViewModel.Item = item { + if let cellViewModel: MessageCell.ViewModel = cellViewModel { let bodyTextView = VisibleMessageCell.getBodyTextView( - for: item, + for: cellViewModel, with: maxWidth, textColor: (bodyLabelTextColor ?? sentLinkPreviewTextColor), searchText: lastSearchText, diff --git a/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift b/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift index 4f65a24d5..f25b33bf6 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift @@ -9,10 +9,10 @@ final class MediaPlaceholderView: UIView { // MARK: - Lifecycle - init(item: ConversationViewModel.Item, textColor: UIColor) { + init(cellViewModel: MessageCell.ViewModel, textColor: UIColor) { super.init(frame: CGRect.zero) - setUpViewHierarchy(item: item, textColor: textColor) + setUpViewHierarchy(cellViewModel: cellViewModel, textColor: textColor) } override init(frame: CGRect) { @@ -24,13 +24,13 @@ final class MediaPlaceholderView: UIView { } private func setUpViewHierarchy( - item: ConversationViewModel.Item, + cellViewModel: MessageCell.ViewModel, textColor: UIColor ) { let (iconName, attachmentDescription): (String, String) = { guard - item.interactionVariant == .standardIncoming, - let attachment: Attachment = item.attachments?.first + cellViewModel.variant == .standardIncoming, + let attachment: Attachment = cellViewModel.attachments?.first else { return ("actionsheet_document_black", "file") // Should never occur } diff --git a/Session/Conversations/Message Cells/InfoMessageCell.swift b/Session/Conversations/Message Cells/InfoMessageCell.swift index 26476d18f..a8dfb6ae0 100644 --- a/Session/Conversations/Message Cells/InfoMessageCell.swift +++ b/Session/Conversations/Message Cells/InfoMessageCell.swift @@ -52,20 +52,15 @@ final class InfoMessageCell: MessageCell { // MARK: - Updating - override func update(with item: ConversationViewModel.Item, mediaCache: NSCache, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) { - switch item.interactionVariant { - case .infoClosedGroupCreated, .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft, - .infoDisappearingMessagesUpdate, .infoScreenshotNotification, .infoMediaSavedNotification, - .infoMessageRequestAccepted: - break - - default: return // Ignore non-info variants - } + override func update(with cellViewModel: MessageCell.ViewModel, mediaCache: NSCache, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) { + guard cellViewModel.variant.isInfoMessage else { return } + + self.viewModel = cellViewModel let icon: UIImage? = { - switch item.interactionVariant { + switch cellViewModel.variant { case .infoDisappearingMessagesUpdate: - return (item.threadHasDisappearingMessagesEnabled ? + return (cellViewModel.threadHasDisappearingMessagesEnabled ? UIImage(named: "ic_timer") : UIImage(named: "ic_timer_disabled") ) @@ -83,9 +78,9 @@ final class InfoMessageCell: MessageCell { iconImageViewWidthConstraint.constant = (icon != nil) ? InfoMessageCell.iconSize : 0 iconImageViewHeightConstraint.constant = (icon != nil) ? InfoMessageCell.iconSize : 0 - self.label.text = item.body + self.label.text = cellViewModel.body } - override func dynamicUpdate(with item: ConversationViewModel.Item, playbackInfo: ConversationViewModel.PlaybackInfo?) { + override func dynamicUpdate(with cellViewModel: MessageCell.ViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) { } } diff --git a/Session/Conversations/Message Cells/MessageCell.swift b/Session/Conversations/Message Cells/MessageCell.swift index f7675242d..809ae0f14 100644 --- a/Session/Conversations/Message Cells/MessageCell.swift +++ b/Session/Conversations/Message Cells/MessageCell.swift @@ -9,9 +9,9 @@ public enum SwipeState { case cancelled } -class MessageCell: UITableViewCell { +public class MessageCell: UITableViewCell { weak var delegate: MessageCellDelegate? - var item: ConversationViewModel.Item? + var viewModel: MessageCell.ViewModel? // MARK: - Lifecycle @@ -43,22 +43,22 @@ class MessageCell: UITableViewCell { // MARK: - Updating - func update(with item: ConversationViewModel.Item, mediaCache: NSCache, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) { + func update(with cellViewModel: MessageCell.ViewModel, mediaCache: NSCache, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) { preconditionFailure("Must be overridden by subclasses.") } /// This is a cut-down version of the 'update' function which doesn't re-create the UI (it should be used for dynamically-updating content /// like playing inline audio/video) - func dynamicUpdate(with item: ConversationViewModel.Item, playbackInfo: ConversationViewModel.PlaybackInfo?) { + func dynamicUpdate(with cellViewModel: MessageCell.ViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) { preconditionFailure("Must be overridden by subclasses.") } // MARK: - Convenience - static func cellType(for item: ConversationViewModel.Item) -> MessageCell.Type { - guard item.cellType != .typingIndicator else { return TypingIndicatorCell.self } + static func cellType(for viewModel: MessageCell.ViewModel) -> MessageCell.Type { + guard viewModel.cellType != .typingIndicator else { return TypingIndicatorCell.self } - switch item.interactionVariant { + switch viewModel.variant { case .standardOutgoing, .standardIncoming, .standardIncomingDeleted: return VisibleMessageCell.self @@ -70,16 +70,14 @@ class MessageCell: UITableViewCell { } } -protocol MessageCellDelegate : AnyObject { - var lastSearchedText: String? { get } - - func getMediaCache() -> NSCache - func handleViewItemLongPressed(_ viewItem: ConversationViewItem) - func handleViewItemTapped(_ viewItem: ConversationViewItem, gestureRecognizer: UITapGestureRecognizer) - func handleViewItemDoubleTapped(_ viewItem: ConversationViewItem) - func handleViewItemSwiped(_ viewItem: ConversationViewItem, state: SwipeState) - func showFullText(_ viewItem: ConversationViewItem) - func openURL(_ url: URL) - func handleReplyButtonTapped(for viewItem: ConversationViewItem) - func showUserDetails(for sessionID: String) +// MARK: - MessageCellDelegate + +protocol MessageCellDelegate: AnyObject { + func handleItemLongPressed(_ cellViewModel: MessageCell.ViewModel) + func handleItemTapped(_ cellViewModel: MessageCell.ViewModel, gestureRecognizer: UITapGestureRecognizer) + func handleItemDoubleTapped(_ cellViewModel: MessageCell.ViewModel) + func handleItemSwiped(_ cellViewModel: MessageCell.ViewModel, state: SwipeState) + func openUrl(_ urlString: String) + func handleReplyButtonTapped(for cellViewModel: MessageCell.ViewModel) + func showUserDetails(for profile: Profile) } diff --git a/Session/Conversations/Message Cells/Models/MessageCellViewModel.swift b/Session/Conversations/Message Cells/Models/MessageCellViewModel.swift new file mode 100644 index 000000000..adbcd40c0 --- /dev/null +++ b/Session/Conversations/Message Cells/Models/MessageCellViewModel.swift @@ -0,0 +1,593 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import DifferenceKit +import SessionUtilitiesKit +import SessionMessagingKit + +fileprivate typealias ViewModel = MessageCell.ViewModel +fileprivate typealias AttachmentInteractionInfo = MessageCell.AttachmentInteractionInfo + +extension MessageCell { + public struct ViewModel: FetchableRecordWithRowId, Decodable, Equatable, Hashable, Identifiable, Differentiable { + public static let threadVariantKey: SQL = SQL(stringLiteral: CodingKeys.threadVariant.stringValue) + public static let threadIsTrustedKey: SQL = SQL(stringLiteral: CodingKeys.threadIsTrusted.stringValue) + public static let threadHasDisappearingMessagesEnabledKey: SQL = SQL(stringLiteral: CodingKeys.threadHasDisappearingMessagesEnabled.stringValue) + public static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue) + public static let authorNameInternalKey: SQL = SQL(stringLiteral: CodingKeys.authorNameInternal.stringValue) + public static let stateKey: SQL = SQL(stringLiteral: CodingKeys.state.stringValue) + public static let hasAtLeastOneReadReceiptKey: SQL = SQL(stringLiteral: CodingKeys.hasAtLeastOneReadReceipt.stringValue) + public static let mostRecentFailureTextKey: SQL = SQL(stringLiteral: CodingKeys.mostRecentFailureText.stringValue) + public static let isTypingIndicatorKey: SQL = SQL(stringLiteral: CodingKeys.isTypingIndicator.stringValue) + public static let isSenderOpenGroupModeratorKey: SQL = SQL(stringLiteral: CodingKeys.isSenderOpenGroupModerator.stringValue) + public static let profileKey: SQL = SQL(stringLiteral: CodingKeys.profile.stringValue) + public static let quoteKey: SQL = SQL(stringLiteral: CodingKeys.quote.stringValue) + public static let quoteAttachmentKey: SQL = SQL(stringLiteral: CodingKeys.quoteAttachment.stringValue) + public static let linkPreviewKey: SQL = SQL(stringLiteral: CodingKeys.linkPreview.stringValue) + public static let linkPreviewAttachmentKey: SQL = SQL(stringLiteral: CodingKeys.linkPreviewAttachment.stringValue) + public static let cellTypeKey: SQL = SQL(stringLiteral: CodingKeys.cellType.stringValue) + public static let authorNameKey: SQL = SQL(stringLiteral: CodingKeys.authorName.stringValue) + public static let shouldShowProfileKey: SQL = SQL(stringLiteral: CodingKeys.shouldShowProfile.stringValue) + public static let positionInClusterKey: SQL = SQL(stringLiteral: CodingKeys.positionInCluster.stringValue) + public static let isOnlyMessageInClusterKey: SQL = SQL(stringLiteral: CodingKeys.isOnlyMessageInCluster.stringValue) + public static let isLastKey: SQL = SQL(stringLiteral: CodingKeys.isLast.stringValue) + + public static let profileString: String = CodingKeys.profile.stringValue + public static let quoteString: String = CodingKeys.quote.stringValue + public static let quoteAttachmentString: String = CodingKeys.quoteAttachment.stringValue + public static let linkPreviewString: String = CodingKeys.linkPreview.stringValue + public static let linkPreviewAttachmentString: String = CodingKeys.linkPreviewAttachment.stringValue + + public enum Position: Int, Decodable, Equatable, Hashable, DatabaseValueConvertible { + case top + case middle + case bottom + } + + public enum CellType: Int, Decodable, Equatable, Hashable, DatabaseValueConvertible { + case textOnlyMessage + case mediaMessage + case audio + case genericAttachment + case typingIndicator + } + + public var differenceIdentifier: ViewModel { self } + + // Thread Info + + let threadVariant: SessionThread.Variant + let threadIsTrusted: Bool + let threadHasDisappearingMessagesEnabled: Bool + + // Interaction Info + + public let rowId: Int64 + public let id: Int64 + let variant: Interaction.Variant + let timestampMs: Int64 + let authorId: String + private let authorNameInternal: String? + let body: String? + let expiresStartedAtMs: Double? + let expiresInSeconds: TimeInterval? + + let state: RecipientState.State + let hasAtLeastOneReadReceipt: Bool + let mostRecentFailureText: String? + let isTypingIndicator: Bool + let isSenderOpenGroupModerator: Bool + let profile: Profile? + let quote: Quote? + let quoteAttachment: Attachment? + let linkPreview: LinkPreview? + let linkPreviewAttachment: Attachment? + + // Post-Query Processing Data + + /// This value includes the associated attachments + let attachments: [Attachment]? + + /// This value defines what type of cell should appear and is generated based on the interaction variant + /// and associated attachment data + let cellType: CellType + + /// This value includes the author name information + let authorName: String + + /// This value will be used to populate the author label, if it's null then the label will be hidden + let senderName: String? + + /// A flag indicating whether the profile view should be displayed + let shouldShowProfile: Bool + + /// This value will be used to populate the date header, if it's null then the header will be hidden + let dateForUI: Date? + + /// This value indicates the variant of the previous ViewModel item, if it's null then there is no previous item + let previousVariant: Interaction.Variant? + + /// This value indicates the position of this message within a cluser of messages + let positionInCluster: Position + + /// This value indicates whether this is the only message in a cluser of messages + let isOnlyMessageInCluster: Bool + + /// This value indicates whether this is the last message in the thread + let isLast: Bool + + // MARK: - Mutation + + public func with(attachments: [Attachment]) -> ViewModel { + return ViewModel( + threadVariant: self.threadVariant, + threadIsTrusted: self.threadIsTrusted, + threadHasDisappearingMessagesEnabled: self.threadHasDisappearingMessagesEnabled, + rowId: self.rowId, + id: self.id, + variant: self.variant, + timestampMs: self.timestampMs, + authorId: self.authorId, + authorNameInternal: self.authorNameInternal, + body: self.body, + expiresStartedAtMs: self.expiresStartedAtMs, + expiresInSeconds: self.expiresInSeconds, + state: self.state, + hasAtLeastOneReadReceipt: self.hasAtLeastOneReadReceipt, + mostRecentFailureText: self.mostRecentFailureText, + isTypingIndicator: self.isTypingIndicator, + isSenderOpenGroupModerator: self.isSenderOpenGroupModerator, + profile: self.profile, + quote: self.quote, + quoteAttachment: self.quoteAttachment, + linkPreview: self.linkPreview, + linkPreviewAttachment: self.linkPreviewAttachment, + attachments: attachments, + cellType: self.cellType, + authorName: self.authorName, + senderName: self.senderName, + shouldShowProfile: self.shouldShowProfile, + dateForUI: self.dateForUI, + previousVariant: self.previousVariant, + positionInCluster: self.positionInCluster, + isOnlyMessageInCluster: self.isOnlyMessageInCluster, + isLast: self.isLast + ) + } + + public func withClusteringChanges( + prevModel: ViewModel?, + nextModel: ViewModel?, + isLast: Bool + ) -> ViewModel { + let cellType: CellType = { + guard !self.isTypingIndicator else { return .typingIndicator } + guard self.variant != .standardIncomingDeleted else { return .textOnlyMessage } + guard let attachment: Attachment = self.attachments?.first else { return .textOnlyMessage } + + // The only case which currently supports multiple attachments is a 'mediaMessage' + // (the album view) + guard self.attachments?.count == 1 else { return .mediaMessage } + + // Quote and LinkPreview overload the 'attachments' array and use it for their + // own purposes, otherwise check if the attachment is visual media + guard self.quote == nil else { return .textOnlyMessage } + guard self.linkPreview == nil else { return .textOnlyMessage } + + // Pending audio attachments won't have a duration + if + attachment.isAudio && ( + ((attachment.duration ?? 0) > 0) || + ( + attachment.state != .downloaded && + attachment.state != .uploaded + ) + ) + { + return .audio + } + + if attachment.isVisualMedia { + return .mediaMessage + } + + return .genericAttachment + }() + let authorDisplayName: String = Profile.displayName( + for: self.threadVariant, + id: self.authorId, + name: self.authorNameInternal, + nickname: nil // Folded into 'authorName' within the Query + ) + let shouldShowDateOnThisModel: Bool = { + guard !self.isTypingIndicator else { return false } + guard let prevModel: ViewModel = prevModel else { return true } + + return DateUtil.shouldShowDateBreak( + forTimestamp: UInt64(prevModel.timestampMs), + timestamp: UInt64(self.timestampMs) + ) + }() + let shouldShowDateOnNextModel: Bool = { + // Should be nothing after a typing indicator + guard !self.isTypingIndicator else { return false } + guard let nextModel: ViewModel = nextModel else { return false } + + return DateUtil.shouldShowDateBreak( + forTimestamp: UInt64(self.timestampMs), + timestamp: UInt64(nextModel.timestampMs) + ) + }() + let (positionInCluster, isOnlyMessageInCluster): (Position, Bool) = { + let isFirstInCluster: Bool = ( + prevModel == nil || + shouldShowDateOnThisModel || ( + self.variant == .standardOutgoing && + prevModel?.variant != .standardOutgoing + ) || ( + ( + self.variant == .standardIncoming || + self.variant == .standardIncomingDeleted + ) && ( + prevModel?.variant != .standardIncoming && + prevModel?.variant != .standardIncomingDeleted + ) + ) || + self.authorId != prevModel?.authorId + ) + let isLastInCluster: Bool = ( + nextModel == nil || + shouldShowDateOnNextModel || ( + self.variant == .standardOutgoing && + nextModel?.variant != .standardOutgoing + ) || ( + ( + self.variant == .standardIncoming || + self.variant == .standardIncomingDeleted + ) && ( + nextModel?.variant != .standardIncoming && + nextModel?.variant != .standardIncomingDeleted + ) + ) || + self.authorId != nextModel?.authorId + ) + + let isOnlyMessageInCluster: Bool = (isFirstInCluster && isLastInCluster) + + switch (isFirstInCluster, isLastInCluster) { + case (true, true), (false, false): return (.middle, isOnlyMessageInCluster) + case (true, false): return (.top, isOnlyMessageInCluster) + case (false, true): return (.bottom, isOnlyMessageInCluster) + } + }() + + return ViewModel( + threadVariant: self.threadVariant, + threadIsTrusted: self.threadIsTrusted, + threadHasDisappearingMessagesEnabled: self.threadHasDisappearingMessagesEnabled, + rowId: self.rowId, + id: self.id, + variant: self.variant, + timestampMs: self.timestampMs, + authorId: self.authorId, + authorNameInternal: self.authorNameInternal, + body: (!self.variant.isInfoMessage ? + self.body : + // Info messages might not have a body so we should use the 'previewText' value instead + Interaction.previewText( + variant: self.variant, + body: self.body, + authorDisplayName: authorDisplayName, + attachmentDescriptionInfo: self.attachments?.first.map { firstAttachment in + Attachment.DescriptionInfo( + id: firstAttachment.id, + variant: firstAttachment.variant, + contentType: firstAttachment.contentType, + sourceFilename: firstAttachment.sourceFilename + ) + }, + attachmentCount: self.attachments?.count, + isOpenGroupInvitation: (self.linkPreview?.variant == .openGroupInvitation) + ) + ), + expiresStartedAtMs: self.expiresStartedAtMs, + expiresInSeconds: self.expiresInSeconds, + state: self.state, + hasAtLeastOneReadReceipt: self.hasAtLeastOneReadReceipt, + mostRecentFailureText: self.mostRecentFailureText, + isTypingIndicator: self.isTypingIndicator, + isSenderOpenGroupModerator: self.isSenderOpenGroupModerator, + profile: self.profile, + quote: self.quote, + quoteAttachment: self.quoteAttachment, + linkPreview: self.linkPreview, + linkPreviewAttachment: self.linkPreviewAttachment, + attachments: self.attachments, + cellType: cellType, + authorName: authorDisplayName, + senderName: { + // Only show for group threads + guard self.threadVariant == .openGroup || self.threadVariant == .closedGroup else { + return nil + } + + // Only if there is a date header or the senders are different + guard shouldShowDateOnThisModel || self.authorId != prevModel?.authorId else { + return nil + } + + return authorDisplayName + }(), + shouldShowProfile: ( + // Only group threads + (self.threadVariant == .openGroup || self.threadVariant == .closedGroup) && + + // Only incoming messages + (self.variant == .standardIncoming || self.variant == .standardIncomingDeleted) && + + // Show if the next message has a different sender or has a "date break" + ( + self.authorId != nextModel?.authorId || + shouldShowDateOnNextModel + ) && + + // Need a profile to be able to show it + self.profile != nil + ), + dateForUI: (shouldShowDateOnThisModel ? + Date(timeIntervalSince1970: (TimeInterval(self.timestampMs) / 1000)) : + nil + ), + previousVariant: prevModel?.variant, + positionInCluster: positionInCluster, + isOnlyMessageInCluster: isOnlyMessageInCluster, + isLast: isLast + ) + } + } + + public struct AttachmentInteractionInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable, Comparable { + public static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue) + public static let attachmentKey: SQL = SQL(stringLiteral: CodingKeys.attachment.stringValue) + public static let interactionAttachmentKey: SQL = SQL(stringLiteral: CodingKeys.interactionAttachment.stringValue) + + public static let attachmentString: String = CodingKeys.attachment.stringValue + public static let interactionAttachmentString: String = CodingKeys.interactionAttachment.stringValue + + public let rowId: Int64 + public let attachment: Attachment + public let interactionAttachment: InteractionAttachment + + // MARK: - Identifiable + + public var id: String { + "\(interactionAttachment.interactionId)-\(interactionAttachment.albumIndex)" + } + + // MARK: - Comparable + + public static func < (lhs: AttachmentInteractionInfo, rhs: AttachmentInteractionInfo) -> Bool { + return (lhs.interactionAttachment.albumIndex < rhs.interactionAttachment.albumIndex) + } + } +} + +// MARK: - ConversationVC + +extension MessageCell.ViewModel { + public static func filterSQL(threadId: String) -> SQL { + let interaction: TypedTableAlias = TypedTableAlias() + + return SQL("\(interaction[.threadId]) = \(threadId)") + } + + public static let orderSQL: SQL = { + let interaction: TypedTableAlias = TypedTableAlias() + + return SQL("\(interaction[.timestampMs].desc)") + }() + + public static func baseQuery(orderSQL: SQL, baseFilterSQL: SQL) -> ((SQL?, SQL?) -> AdaptedFetchRequest>) { + return { additionalFilters, limitSQL -> AdaptedFetchRequest> in + let interaction: TypedTableAlias = TypedTableAlias() + let thread: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() + let disappearingMessagesConfig: TypedTableAlias = TypedTableAlias() + let profile: TypedTableAlias = TypedTableAlias() + let quote: TypedTableAlias = TypedTableAlias() + let linkPreview: TypedTableAlias = TypedTableAlias() + + let interactionStateTableLiteral: SQL = SQL(stringLiteral: "interactionState") + let interactionStateInteractionIdColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.interactionId.name) + let interactionStateStateColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.state.name) + let interactionStateMostRecentFailureTextColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.mostRecentFailureText.name) + let readReceiptTableLiteral: SQL = SQL(stringLiteral: "readReceipt") + let readReceiptReadTimestampMsColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.readTimestampMs.name) + let attachmentIdColumnLiteral: SQL = SQL(stringLiteral: Attachment.Columns.id.name) + + let finalFilterSQL: SQL = { + guard let additionalFilters: SQL = additionalFilters else { + return """ + WHERE \(baseFilterSQL) + """ + } + + return """ + WHERE ( + \(baseFilterSQL) AND + \(additionalFilters) + ) + """ + }() + let finalLimitSQL: SQL = (limitSQL ?? SQL(stringLiteral: "")) + let numColumnsBeforeLinkedRecords: Int = 17 + let request: SQLRequest = """ + SELECT + \(thread[.variant]) AS \(ViewModel.threadVariantKey), + -- Default to 'true' for non-contact threads + IFNULL(\(contact[.isTrusted]), true) AS \(ViewModel.threadIsTrustedKey), + -- Default to 'false' when no contact exists + IFNULL(\(disappearingMessagesConfig[.isEnabled]), false) AS \(ViewModel.threadHasDisappearingMessagesEnabledKey), + + \(interaction.alias[Column.rowID]) AS \(ViewModel.rowIdKey), + \(interaction[.id]), + \(interaction[.variant]), + \(interaction[.timestampMs]), + \(interaction[.authorId]), + IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.authorNameInternalKey), + \(interaction[.body]), + \(interaction[.expiresStartedAtMs]), + \(interaction[.expiresInSeconds]), + + -- Default to 'sending' assuming non-processed interaction when null + IFNULL(\(interactionStateTableLiteral).\(interactionStateStateColumnLiteral), \(SQL("\(RecipientState.State.sending)"))) AS \(ViewModel.stateKey), + (\(readReceiptTableLiteral).\(readReceiptReadTimestampMsColumnLiteral) IS NOT NULL) AS \(ViewModel.hasAtLeastOneReadReceiptKey), + \(interactionStateTableLiteral).\(interactionStateMostRecentFailureTextColumnLiteral) AS \(ViewModel.mostRecentFailureTextKey), + + false AS \(ViewModel.isTypingIndicatorKey), + false AS \(ViewModel.isSenderOpenGroupModeratorKey), + + \(ViewModel.profileKey).*, + \(ViewModel.quoteKey).*, + \(ViewModel.quoteAttachmentKey).*, + \(ViewModel.linkPreviewKey).*, + \(ViewModel.linkPreviewAttachmentKey).*, + + -- All of the below properties are set in post-query processing but to prevent the + -- query from crashing when decoding we need to provide default values + \(CellType.textOnlyMessage) AS \(ViewModel.cellTypeKey), + '' AS \(ViewModel.authorNameKey), + false AS \(ViewModel.shouldShowProfileKey), + \(Position.middle) AS \(ViewModel.positionInClusterKey), + false AS \(ViewModel.isOnlyMessageInClusterKey), + false AS \(ViewModel.isLastKey) + + FROM \(Interaction.self) + JOIN \(SessionThread.self) ON \(thread[.id]) = \(interaction[.threadId]) + LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId]) + LEFT JOIN \(DisappearingMessagesConfiguration.self) ON \(disappearingMessagesConfig[.threadId]) = \(interaction[.threadId]) + LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId]) + LEFT JOIN \(Quote.self) ON \(quote[.interactionId]) = \(interaction[.id]) + LEFT JOIN \(Attachment.self) AS \(ViewModel.quoteAttachmentKey) ON \(ViewModel.quoteAttachmentKey).\(attachmentIdColumnLiteral) = \(quote[.attachmentId]) + LEFT JOIN \(LinkPreview.self) ON ( + \(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND + \(Interaction.linkPreviewFilterLiteral) + ) + LEFT JOIN \(Attachment.self) AS \(ViewModel.linkPreviewAttachmentKey) ON \(ViewModel.linkPreviewAttachmentKey).\(attachmentIdColumnLiteral) = \(linkPreview[.attachmentId]) + LEFT JOIN ( + \(RecipientState.selectInteractionState( + tableLiteral: interactionStateTableLiteral, + idColumnLiteral: interactionStateInteractionIdColumnLiteral + )) + ) AS \(interactionStateTableLiteral) ON \(interactionStateTableLiteral).\(interactionStateInteractionIdColumnLiteral) = \(interaction[.id]) + LEFT JOIN \(RecipientState.self) AS \(readReceiptTableLiteral) ON ( + \(readReceiptTableLiteral).\(readReceiptReadTimestampMsColumnLiteral) IS NOT NULL AND + \(interaction[.id]) = \(readReceiptTableLiteral).\(interactionStateInteractionIdColumnLiteral) + ) + \(finalFilterSQL) + ORDER BY \(orderSQL) + \(finalLimitSQL) + """ + + return request.adapted { db in + let adapters = try splittingRowAdapters(columnCounts: [ + numColumnsBeforeLinkedRecords, + Profile.numberOfSelectedColumns(db), + Quote.numberOfSelectedColumns(db), + Attachment.numberOfSelectedColumns(db), + LinkPreview.numberOfSelectedColumns(db), + Attachment.numberOfSelectedColumns(db) + ]) + + return ScopeAdapter([ + ViewModel.profileString: adapters[1], + ViewModel.quoteString: adapters[2], + ViewModel.quoteAttachmentString: adapters[3], + ViewModel.linkPreviewString: adapters[4], + ViewModel.linkPreviewAttachmentString: adapters[5] + ]) + } + } + } +} + +extension MessageCell.AttachmentInteractionInfo { + public static let baseQuery: ((SQL?) -> AdaptedFetchRequest>) = { + return { additionalFilters -> AdaptedFetchRequest> in + let attachment: TypedTableAlias = TypedTableAlias() + let interactionAttachment: TypedTableAlias = TypedTableAlias() + + let finalFilterSQL: SQL = { + guard let additionalFilters: SQL = additionalFilters else { + return SQL(stringLiteral: "") + } + + return """ + WHERE \(additionalFilters) + """ + }() + let numColumnsBeforeLinkedRecords: Int = 1 + let request: SQLRequest = """ + SELECT + \(attachment.alias[Column.rowID]) AS \(AttachmentInteractionInfo.rowIdKey), + \(AttachmentInteractionInfo.attachmentKey).*, + \(AttachmentInteractionInfo.interactionAttachmentKey).* + FROM \(Attachment.self) + JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id]) + \(finalFilterSQL) + """ + + return request.adapted { db in + let adapters = try splittingRowAdapters(columnCounts: [ + numColumnsBeforeLinkedRecords, + Attachment.numberOfSelectedColumns(db), + InteractionAttachment.numberOfSelectedColumns(db) + ]) + + return ScopeAdapter([ + AttachmentInteractionInfo.attachmentString: adapters[1], + AttachmentInteractionInfo.interactionAttachmentString: adapters[2] + ]) + } + } + }() + + public static var joinToViewModelQuerySQL: SQL = { + let interaction: TypedTableAlias = TypedTableAlias() + let attachment: TypedTableAlias = TypedTableAlias() + let interactionAttachment: TypedTableAlias = TypedTableAlias() + + return """ + JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id]) + JOIN \(Interaction.self) ON + \(interaction[.id]) = \(interactionAttachment[.interactionId]) + """ + }() + + public static func createAssociateDataClosure() -> (DataCache, DataCache) -> DataCache { + return { dataCache, pagedDataCache -> DataCache in + var updatedPagedDataCache: DataCache = pagedDataCache + + dataCache + .values + .grouped(by: \.interactionAttachment.interactionId) + .forEach { (interactionId: Int64, attachments: [MessageCell.AttachmentInteractionInfo]) in + guard + let interactionRowId: Int64 = updatedPagedDataCache.lookup[interactionId], + let dataToUpdate: ViewModel = updatedPagedDataCache.data[interactionRowId] + else { return } + + updatedPagedDataCache = updatedPagedDataCache.upserting( + dataToUpdate.with( + attachments: attachments + .sorted() + .map { $0.attachment } + ) + ) + } + + return updatedPagedDataCache + } + } +} diff --git a/Session/Conversations/Message Cells/TypingIndicatorCell.swift b/Session/Conversations/Message Cells/TypingIndicatorCell.swift index 3650fbf39..d95a445cb 100644 --- a/Session/Conversations/Message Cells/TypingIndicatorCell.swift +++ b/Session/Conversations/Message Cells/TypingIndicatorCell.swift @@ -39,10 +39,10 @@ final class TypingIndicatorCell: MessageCell { // MARK: - Updating - override func update(with item: ConversationViewModel.Item, mediaCache: NSCache, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) { - guard item.cellType == .typingIndicator else { return } + override func update(with cellViewModel: MessageCell.ViewModel, mediaCache: NSCache, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) { + guard cellViewModel.cellType == .typingIndicator else { return } - self.item = item + self.viewModel = cellViewModel // Bubble view updateBubbleViewCorners() @@ -51,7 +51,7 @@ final class TypingIndicatorCell: MessageCell { typingIndicatorView.startAnimation() } - override func dynamicUpdate(with item: ConversationViewModel.Item, playbackInfo: ConversationViewModel.PlaybackInfo?) { + override func dynamicUpdate(with cellViewModel: MessageCell.ViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) { } override func layoutSubviews() { @@ -82,9 +82,9 @@ final class TypingIndicatorCell: MessageCell { // MARK: - Convenience private func getCornersToRound() -> UIRectCorner { - guard item?.isOnlyMessageInCluster == false else { return .allCorners } + guard viewModel?.isOnlyMessageInCluster == false else { return .allCorners } - switch item?.positionInCluster { + switch viewModel?.positionInCluster { case .top: return [ .topLeft, .topRight, .bottomRight ] case .middle: return [ .topRight, .bottomRight ] case .bottom: return [ .topRight, .bottomRight, .bottomLeft ] diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index cdbfade3c..96410cadf 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -35,8 +35,6 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel result.delegate = self return result }() - - var lastSearchedText: String? { delegate?.lastSearchedText } // MARK: - UI Components @@ -200,79 +198,80 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel // MARK: - Updating override func update( - with item: ConversationViewModel.Item, + with cellViewModel: MessageCell.ViewModel, mediaCache: NSCache, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String? ) { - self.item = item + self.viewModel = cellViewModel - let isGroupThread: Bool = (item.threadVariant == .openGroup || item.threadVariant == .closedGroup) + let isGroupThread: Bool = (cellViewModel.threadVariant == .openGroup || cellViewModel.threadVariant == .closedGroup) let shouldInsetHeader: Bool = ( - item.previousInteractionVariant?.isInfoMessage != true && + cellViewModel.previousVariant?.isInfoMessage != true && ( - item.positionInCluster == .top || - item.isOnlyMessageInCluster + cellViewModel.positionInCluster == .top || + cellViewModel.isOnlyMessageInCluster ) ) // Profile picture view profilePictureViewLeftConstraint.constant = (isGroupThread ? VisibleMessageCell.groupThreadHSpacing : 0) profilePictureViewWidthConstraint.constant = (isGroupThread ? VisibleMessageCell.profilePictureSize : 0) - profilePictureView.isHidden = (!item.shouldShowProfile || item.profile == nil) + profilePictureView.isHidden = (!cellViewModel.shouldShowProfile || cellViewModel.profile == nil) profilePictureView.update( - publicKey: item.authorId, - profile: item.profile, - threadVariant: item.threadVariant + publicKey: cellViewModel.authorId, + profile: cellViewModel.profile, + threadVariant: cellViewModel.threadVariant ) - moderatorIconImageView.isHidden = !item.isSenderOpenGroupModerator + moderatorIconImageView.isHidden = !cellViewModel.isSenderOpenGroupModerator // Bubble view bubbleViewLeftConstraint1.isActive = ( - item.interactionVariant == .standardIncoming || - item.interactionVariant == .standardIncomingDeleted + cellViewModel.variant == .standardIncoming || + cellViewModel.variant == .standardIncomingDeleted ) bubbleViewLeftConstraint1.constant = (isGroupThread ? VisibleMessageCell.groupThreadHSpacing : VisibleMessageCell.contactThreadHSpacing) - bubbleViewLeftConstraint2.isActive = (item.interactionVariant == .standardOutgoing) - bubbleViewTopConstraint.constant = (item.senderName == nil ? 0 : VisibleMessageCell.authorLabelBottomSpacing) - bubbleViewRightConstraint1.isActive = (item.interactionVariant == .standardOutgoing) + bubbleViewLeftConstraint2.isActive = (cellViewModel.variant == .standardOutgoing) + bubbleViewTopConstraint.constant = (cellViewModel.senderName == nil ? 0 : VisibleMessageCell.authorLabelBottomSpacing) + bubbleViewRightConstraint1.isActive = (cellViewModel.variant == .standardOutgoing) bubbleViewRightConstraint2.isActive = ( - item.interactionVariant == .standardIncoming || - item.interactionVariant == .standardIncomingDeleted + cellViewModel.variant == .standardIncoming || + cellViewModel.variant == .standardIncomingDeleted ) bubbleView.backgroundColor = (( - item.interactionVariant == .standardIncoming || - item.interactionVariant == .standardIncomingDeleted + cellViewModel.variant == .standardIncoming || + cellViewModel.variant == .standardIncomingDeleted ) ? Colors.receivedMessageBackground : Colors.sentMessageBackground) updateBubbleViewCorners() // Content view - populateContentView(for: item, mediaCache: mediaCache, playbackInfo: playbackInfo, lastSearchText: lastSearchText) + populateContentView(for: cellViewModel, mediaCache: mediaCache, playbackInfo: playbackInfo, lastSearchText: lastSearchText) // Date break headerViewTopConstraint.constant = (shouldInsetHeader ? Values.mediumSpacing : 1) headerView.subviews.forEach { $0.removeFromSuperview() } - populateHeader(for: item, shouldInsetHeader: shouldInsetHeader) + populateHeader(for: cellViewModel, shouldInsetHeader: shouldInsetHeader) // Author label authorLabel.textColor = Colors.text - authorLabel.isHidden = (item.senderName == nil) - authorLabel.text = item.senderName + authorLabel.isHidden = (cellViewModel.senderName == nil) + authorLabel.text = cellViewModel.senderName - let authorLabelAvailableWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: item) - 2 * VisibleMessageCell.authorLabelInset) + let authorLabelAvailableWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: cellViewModel) - 2 * VisibleMessageCell.authorLabelInset) let authorLabelAvailableSpace = CGSize(width: authorLabelAvailableWidth, height: .greatestFiniteMagnitude) let authorLabelSize = authorLabel.sizeThatFits(authorLabelAvailableSpace) - authorLabelHeightConstraint.constant = (item.senderName != nil ? authorLabelSize.height : 0) + authorLabelHeightConstraint.constant = (cellViewModel.senderName != nil ? authorLabelSize.height : 0) // Message status image view - let (image, tintColor, backgroundColor) = getMessageStatusImage(for: item) + let (image, tintColor, backgroundColor) = getMessageStatusImage(for: cellViewModel) messageStatusImageView.image = image messageStatusImageView.tintColor = tintColor messageStatusImageView.backgroundColor = backgroundColor messageStatusImageView.isHidden = ( - item.interactionVariant != .standardOutgoing || ( - item.state == .sent && - item.isLastInteraction + cellViewModel.variant != .standardOutgoing || + ( + cellViewModel.state == .sent && + !cellViewModel.isLast ) ) messageStatusImageViewTopConstraint.constant = (messageStatusImageView.isHidden ? 0 : 5) @@ -283,9 +282,8 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel // Timer if - item.isExpiringMessage, - let expiresStartedAtMs: Double = item.expiresStartedAtMs, - let expiresInSeconds: TimeInterval = item.expiresInSeconds + let expiresStartedAtMs: Double = cellViewModel.expiresStartedAtMs, + let expiresInSeconds: TimeInterval = cellViewModel.expiresInSeconds { let expirationTimestampMs: Double = (expiresStartedAtMs + (expiresInSeconds * 1000)) @@ -294,17 +292,20 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel initialDurationSeconds: UInt32(floor(expiresInSeconds)), tintColor: Colors.text ) + timerView.isHidden = false + } + else { + timerView.isHidden = true } - timerView.isHidden = !item.isExpiringMessage - timerViewOutgoingMessageConstraint.isActive = (item.interactionVariant == .standardOutgoing) + timerViewOutgoingMessageConstraint.isActive = (cellViewModel.variant == .standardOutgoing) timerViewIncomingMessageConstraint.isActive = ( - item.interactionVariant == .standardIncoming || - item.interactionVariant == .standardIncomingDeleted + cellViewModel.variant == .standardIncoming || + cellViewModel.variant == .standardIncomingDeleted ) // Swipe to reply - if item.interactionVariant == .standardIncomingDeleted { + if cellViewModel.variant == .standardIncomingDeleted { removeGestureRecognizer(panGestureRecognizer) } else { @@ -312,8 +313,8 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel } } - private func populateHeader(for item: ConversationViewModel.Item, shouldInsetHeader: Bool) { - guard let date: Date = item.dateForUI else { return } + private func populateHeader(for cellViewModel: MessageCell.ViewModel, shouldInsetHeader: Bool) { + guard let date: Date = cellViewModel.dateForUI else { return } let dateBreakLabel: UILabel = UILabel() dateBreakLabel.font = .boldSystemFont(ofSize: Values.verySmallFontSize) @@ -329,20 +330,20 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel headerView.pin(.bottom, to: .bottom, of: dateBreakLabel, withInset: Values.smallSpacing + additionalBottomInset) dateBreakLabel.center(.horizontal, in: headerView) - let availableWidth = VisibleMessageCell.getMaxWidth(for: item) + let availableWidth = VisibleMessageCell.getMaxWidth(for: cellViewModel) let availableSpace = CGSize(width: availableWidth, height: .greatestFiniteMagnitude) let dateBreakLabelSize = dateBreakLabel.sizeThatFits(availableSpace) dateBreakLabel.set(.height, to: dateBreakLabelSize.height) } private func populateContentView( - for item: ConversationViewModel.Item, + for cellViewModel: MessageCell.ViewModel, mediaCache: NSCache, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String? ) { let bodyLabelTextColor: UIColor = { - let direction: Direction = (item.interactionVariant == .standardOutgoing ? + let direction: Direction = (cellViewModel.variant == .standardOutgoing ? .outgoing : .incoming ) @@ -359,7 +360,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel bodyTextView = nil // Handle the deleted state first (it's much simpler than the others) - guard item.interactionVariant != .standardIncomingDeleted else { + guard cellViewModel.variant != .standardIncomingDeleted else { let deletedMessageView: DeletedMessageView = DeletedMessageView(textColor: bodyLabelTextColor) snContentView.addSubview(deletedMessageView) deletedMessageView.pin(to: snContentView) @@ -367,32 +368,32 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel } // If it's an incoming media message and the thread isn't trusted then show the placeholder view - if item.cellType != .textOnlyMessage && item.interactionVariant == .standardIncoming && !item.isThreadTrusted { - let mediaPlaceholderView = MediaPlaceholderView(item: item, textColor: bodyLabelTextColor) + if cellViewModel.cellType != .textOnlyMessage && cellViewModel.variant == .standardIncoming && !cellViewModel.threadIsTrusted { + let mediaPlaceholderView = MediaPlaceholderView(cellViewModel: cellViewModel, textColor: bodyLabelTextColor) snContentView.addSubview(mediaPlaceholderView) mediaPlaceholderView.pin(to: snContentView) return } - switch item.cellType { + switch cellViewModel.cellType { case .typingIndicator: break case .textOnlyMessage: let inset: CGFloat = 12 - let maxWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: item) - 2 * inset) + let maxWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: cellViewModel) - 2 * inset) - if let linkPreview: LinkPreview = item.linkPreview { + if let linkPreview: LinkPreview = cellViewModel.linkPreview { switch linkPreview.variant { case .standard: let linkPreviewView: LinkPreviewView = LinkPreviewView(maxWidth: maxWidth) linkPreviewView.update( with: LinkPreview.SentState( linkPreview: linkPreview, - imageAttachment: item.attachments?.first + imageAttachment: cellViewModel.linkPreviewAttachment ), - isOutgoing: (item.interactionVariant == .standardOutgoing), + isOutgoing: (cellViewModel.variant == .standardOutgoing), delegate: self, - item: item, + cellViewModel: cellViewModel, bodyLabelTextColor: bodyLabelTextColor, lastSearchText: lastSearchText ) @@ -406,7 +407,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel name: (linkPreview.title ?? ""), url: linkPreview.url, textColor: bodyLabelTextColor, - isOutgoing: (item.interactionVariant == .standardOutgoing) + isOutgoing: (cellViewModel.variant == .standardOutgoing) ) snContentView.addSubview(openGroupInvitationView) @@ -421,18 +422,18 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel stackView.spacing = 2 // Quote view - if let quote: Quote = item.quote { + if let quote: Quote = cellViewModel.quote { let hInset: CGFloat = 2 let quoteView: QuoteView = QuoteView( for: .regular, authorId: quote.authorId, quotedText: quote.body, - threadVariant: item.threadVariant, - direction: (item.interactionVariant == .standardOutgoing ? + threadVariant: cellViewModel.threadVariant, + direction: (cellViewModel.variant == .standardOutgoing ? .outgoing : .incoming ), - attachment: item.attachments?.first, + attachment: cellViewModel.quoteAttachment, hInset: hInset, maxWidth: maxWidth ) @@ -441,7 +442,13 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel } // Body text view - let bodyTextView = VisibleMessageCell.getBodyTextView(for: item, with: maxWidth, textColor: bodyLabelTextColor, searchText: lastSearchText, delegate: self) + let bodyTextView = VisibleMessageCell.getBodyTextView( + for: cellViewModel, + with: maxWidth, + textColor: bodyLabelTextColor, + searchText: lastSearchText, + delegate: self + ) self.bodyTextView = bodyTextView stackView.addArrangedSubview(bodyTextView) @@ -457,17 +464,17 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel stackView.spacing = Values.smallSpacing // Album view - let maxMessageWidth: CGFloat = VisibleMessageCell.getMaxWidth(for: item) + let maxMessageWidth: CGFloat = VisibleMessageCell.getMaxWidth(for: cellViewModel) let albumView = MediaAlbumView( mediaCache: mediaCache, - items: (item.attachments? + items: (cellViewModel.attachments? .filter { $0.isVisualMedia }) .defaulting(to: []), - isOutgoing: (item.interactionVariant == .standardOutgoing), + isOutgoing: (cellViewModel.variant == .standardOutgoing), maxMessageWidth: maxMessageWidth ) self.albumView = albumView - let size = getSize(for: item) + let size = getSize(for: cellViewModel) albumView.set(.width, to: size.width) albumView.set(.height, to: size.height) albumView.loadMedia() @@ -475,10 +482,16 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel stackView.addArrangedSubview(albumView) // Body text view - if let body: String = item.body, !body.isEmpty { + if let body: String = cellViewModel.body, !body.isEmpty { let inset: CGFloat = 12 let maxWidth = size.width - 2 * inset - let bodyTextView = VisibleMessageCell.getBodyTextView(for: item, with: maxWidth, textColor: bodyLabelTextColor, searchText: lastSearchText, delegate: self) + let bodyTextView = VisibleMessageCell.getBodyTextView( + for: cellViewModel, + with: maxWidth, + textColor: bodyLabelTextColor, + searchText: lastSearchText, + delegate: self + ) self.bodyTextView = bodyTextView stackView.addArrangedSubview(UIView(wrapping: bodyTextView, withInsets: UIEdgeInsets(top: 0, left: inset, bottom: inset, right: inset))) } @@ -489,7 +502,9 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel stackView.pin(to: snContentView) case .audio: - guard let attachment: Attachment = item.attachments?.first(where: { $0.isAudio }) else { return } + guard let attachment: Attachment = cellViewModel.attachments?.first(where: { $0.isAudio }) else { + return + } let voiceMessageView: VoiceMessageView = VoiceMessageView() voiceMessageView.update( @@ -506,10 +521,10 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel self.voiceMessageView = voiceMessageView case .genericAttachment: - guard let attachment: Attachment = item.attachments?.first else { preconditionFailure() } + guard let attachment: Attachment = cellViewModel.attachments?.first else { preconditionFailure() } let inset: CGFloat = 12 - let maxWidth = (VisibleMessageCell.getMaxWidth(for: item) - 2 * inset) + let maxWidth = (VisibleMessageCell.getMaxWidth(for: cellViewModel) - 2 * inset) // Stack view let stackView = UIStackView(arrangedSubviews: []) @@ -521,8 +536,14 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel stackView.addArrangedSubview(documentView) // Body text view - if let body: String = item.body, !body.isEmpty { // delegate should always be set at this point - let bodyTextView = VisibleMessageCell.getBodyTextView(for: item, with: maxWidth, textColor: bodyLabelTextColor, searchText: lastSearchText, delegate: self) + if let body: String = cellViewModel.body, !body.isEmpty { // delegate should always be set at this point + let bodyTextView = VisibleMessageCell.getBodyTextView( + for: cellViewModel, + with: maxWidth, + textColor: bodyLabelTextColor, + searchText: lastSearchText, + delegate: self + ) self.bodyTextView = bodyTextView stackView.addArrangedSubview(bodyTextView) } @@ -554,17 +575,19 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel bubbleView.layer.maskedCorners = getCornerMask(from: cornersToRound) } - override func dynamicUpdate(with item: ConversationViewModel.Item, playbackInfo: ConversationViewModel.PlaybackInfo?) { - guard item.interactionVariant != .standardIncomingDeleted else { return } + override func dynamicUpdate(with cellViewModel: MessageCell.ViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) { + guard cellViewModel.variant != .standardIncomingDeleted else { return } // If it's an incoming media message and the thread isn't trusted then show the placeholder view - if item.cellType != .textOnlyMessage && item.interactionVariant == .standardIncoming && !item.isThreadTrusted { + if cellViewModel.cellType != .textOnlyMessage && cellViewModel.variant == .standardIncoming && !cellViewModel.threadIsTrusted { return } - switch item.cellType { + switch cellViewModel.cellType { case .audio: - guard let attachment: Attachment = item.attachments?.first(where: { $0.isAudio }) else { return } + guard let attachment: Attachment = cellViewModel.attachments?.first(where: { $0.isAudio }) else { + return + } self.voiceMessageView?.update( with: attachment, @@ -631,17 +654,17 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel } @objc func handleLongPress() { - guard let item: ConversationViewModel.Item = self.item else { return } + guard let cellViewModel: MessageCell.ViewModel = self.viewModel else { return } - delegate?.handleItemLongPressed(item) + delegate?.handleItemLongPressed(cellViewModel) } @objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) { - guard let item: ConversationViewModel.Item = self.item else { return } + guard let cellViewModel: MessageCell.ViewModel = self.viewModel else { return } let location = gestureRecognizer.location(in: self) - if profilePictureView.frame.contains(location), let profile: Profile = item.profile, item.threadVariant != .openGroup { + if profilePictureView.frame.contains(location), let profile: Profile = cellViewModel.profile, cellViewModel.threadVariant != .openGroup { delegate?.showUserDetails(for: profile) } else if replyButton.alpha > 0 && replyButton.frame.contains(location) { @@ -649,18 +672,18 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel reply() } else if bubbleView.frame.contains(location) { - delegate?.handleItemTapped(item, gestureRecognizer: gestureRecognizer) + delegate?.handleItemTapped(cellViewModel, gestureRecognizer: gestureRecognizer) } } @objc private func handleDoubleTap() { - guard let item: ConversationViewModel.Item = self.item else { return } + guard let cellViewModel: MessageCell.ViewModel = self.viewModel else { return } - delegate?.handleItemDoubleTapped(item) + delegate?.handleItemDoubleTapped(cellViewModel) } @objc private func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) { - guard let item: ConversationViewModel.Item = self.item else { return } + guard let cellViewModel: MessageCell.ViewModel = self.viewModel else { return } let viewsToMove: [UIView] = [ bubbleView, profilePictureView, replyButton, timerView, messageStatusImageView @@ -668,7 +691,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel let translationX = gestureRecognizer.translation(in: self).x.clamp(-CGFloat.greatestFiniteMagnitude, 0) switch gestureRecognizer.state { - case .began: delegate?.handleItemSwiped(item, state: .began) + case .began: delegate?.handleItemSwiped(cellViewModel, state: .began) case .changed: // The idea here is to asymptotically approach a maximum drag distance @@ -688,11 +711,11 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel case .ended, .cancelled: if abs(translationX) > VisibleMessageCell.swipeToReplyThreshold { - delegate?.handleItemSwiped(item, state: .ended) + delegate?.handleItemSwiped(cellViewModel, state: .ended) reply() } else { - delegate?.handleItemSwiped(item, state: .cancelled) + delegate?.handleItemSwiped(cellViewModel, state: .cancelled) resetReply() } @@ -722,20 +745,20 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel } private func reply() { - guard let item: ConversationViewModel.Item = self.item else { return } + guard let cellViewModel: MessageCell.ViewModel = self.viewModel else { return } resetReply() - delegate?.handleReplyButtonTapped(for: item) + delegate?.handleReplyButtonTapped(for: cellViewModel) } // MARK: - Convenience private func getCornersToRound() -> UIRectCorner { - guard item?.isOnlyMessageInCluster == false else { return .allCorners } + guard viewModel?.isOnlyMessageInCluster == false else { return .allCorners } - let direction: Direction = (item?.interactionVariant == .standardOutgoing ? .outgoing : .incoming) + let direction: Direction = (viewModel?.variant == .standardOutgoing ? .outgoing : .incoming) - switch (item?.positionInCluster, direction) { + switch (viewModel?.positionInCluster, direction) { case (.top, .outgoing): return [ .bottomLeft, .topLeft, .topRight ] case (.middle, .outgoing): return [ .bottomLeft, .topLeft ] case (.bottom, .outgoing): return [ .bottomRight, .bottomLeft, .topLeft ] @@ -759,7 +782,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel return cornerMask } - private static func getFontSize(for item: ConversationViewModel.Item) -> CGFloat { + private static func getFontSize(for cellViewModel: MessageCell.ViewModel) -> CGFloat { let baselineFontSize = Values.mediumFontSize switch viewItem.displayableBodyText?.jumbomojiCount { case 1: return baselineFontSize + 30 @@ -769,14 +792,14 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel } } - private func getMessageStatusImage(for item: ConversationViewModel.Item) -> (image: UIImage?, tintColor: UIColor?, backgroundColor: UIColor?) { - guard item.interactionVariant == .standardOutgoing else { return (nil, nil, nil) } + private func getMessageStatusImage(for cellViewModel: MessageCell.ViewModel) -> (image: UIImage?, tintColor: UIColor?, backgroundColor: UIColor?) { + guard cellViewModel.variant == .standardOutgoing else { return (nil, nil, nil) } let image: UIImage var tintColor: UIColor? = nil var backgroundColor: UIColor? = nil - switch (item.state, item.hasAtLeastOneReadReceipt) { + switch (cellViewModel.state, cellViewModel.hasAtLeastOneReadReceipt) { case (.sending, _): image = #imageLiteral(resourceName: "CircleDotDotDot").withRenderingMode(.alwaysTemplate) tintColor = Colors.text @@ -797,10 +820,12 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel return (image, tintColor, backgroundColor) } - private func getSize(for item: ConversationViewModel.Item) -> CGSize { - guard let mediaAttachments: [Attachment] = item.attachments?.filter({ $0.isVisualMedia }) else { preconditionFailure() } + private func getSize(for cellViewModel: MessageCell.ViewModel) -> CGSize { + guard let mediaAttachments: [Attachment] = cellViewModel.attachments?.filter({ $0.isVisualMedia }) else { + preconditionFailure() + } - let maxMessageWidth = VisibleMessageCell.getMaxWidth(for: item) + let maxMessageWidth = VisibleMessageCell.getMaxWidth(for: cellViewModel) let defaultSize = MediaAlbumView.layoutSize(forMaxMessageWidth: maxMessageWidth, items: mediaAttachments) guard @@ -843,13 +868,16 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel return CGSize(width: width, height: height) } - static func getMaxWidth(for item: ConversationViewModel.Item) -> CGFloat { + static func getMaxWidth(for cellViewModel: MessageCell.ViewModel) -> CGFloat { let screen: CGRect = UIScreen.main.bounds - switch item.interactionVariant { + switch cellViewModel.variant { case .standardOutgoing: return (screen.width - contactThreadHSpacing - gutterSize) case .standardIncoming, .standardIncomingDeleted: - let isGroupThread = (item.threadVariant == .openGroup || item.threadVariant == .closedGroup) + let isGroupThread = ( + cellViewModel.threadVariant == .openGroup || + cellViewModel.threadVariant == .closedGroup + ) let leftGutterSize = (isGroupThread ? gutterSize : contactThreadHSpacing) return (screen.width - leftGutterSize - gutterSize) @@ -859,7 +887,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel } static func getBodyTextView( - for item: ConversationViewModel.Item, + for cellViewModel: MessageCell.ViewModel, with availableWidth: CGFloat, textColor: UIColor, searchText: String?, @@ -872,18 +900,18 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel // // Note: We can't just set 'isSelectable' to false otherwise the link detection/selection // stops working - let isOutgoing: Bool = (item.interactionVariant == .standardOutgoing) + let isOutgoing: Bool = (cellViewModel.variant == .standardOutgoing) let result: BodyTextView = BodyTextView(snDelegate: delegate) result.isEditable = false let attributedText: NSMutableAttributedString = NSMutableAttributedString( attributedString: MentionUtilities.highlightMentions( - in: (item.body ?? ""), - threadVariant: item.threadVariant, + in: (cellViewModel.body ?? ""), + threadVariant: cellViewModel.threadVariant, isOutgoingMessage: isOutgoing, attributes: [ .foregroundColor : textColor, - .font : UIFont.systemFont(ofSize: getFontSize(for: item)) + .font : UIFont.systemFont(ofSize: getFontSize(for: cellViewModel)) ] ) ) diff --git a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift index 7d9c17f02..65aa4c09c 100644 --- a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift +++ b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift @@ -4,8 +4,20 @@ import Foundation import GRDB import DifferenceKit import SignalUtilitiesKit +import SessionUtilitiesKit -public class MediaGalleryViewModel: TransactionObserver { +public class MediaGalleryViewModel { + public typealias SectionModel = ArraySection + + // MARK: - Section + + public enum Section: Differentiable, Equatable, Comparable, Hashable { + case emptyGallery + case loadOlder + case galleryMonth(date: GalleryDate) + case loadNewer + } + public let threadId: String public let threadVariant: SessionThread.Variant private var focusedAttachmentId: String? @@ -18,59 +30,61 @@ public class MediaGalleryViewModel: TransactionObserver { public var interactionIdBefore: [Int64: Int64] { cachedInteractionIdBefore.wrappedValue } public var interactionIdAfter: [Int64: Int64] { cachedInteractionIdAfter.wrappedValue } public private(set) var albumData: [Int64: [Item]] = [:] + public private(set) var pagedDatabaseObserver: PagedDatabaseObserver? /// This value is the current state of a gallery view public private(set) var galleryData: [SectionModel] = [] - - // MARK: - Paging - - public struct PageInfo { - public enum Target: Equatable { - case before - case around(id: String) - case after - } - - let pageSize: Int - let pageOffset: Int - let currentCount: Int - let totalCount: Int - - // MARK: - Initizliation - - init( - pageSize: Int, - pageOffset: Int = 0, - currentCount: Int = 0, - totalCount: Int = 0 - ) { - self.pageSize = pageSize - self.pageOffset = pageOffset - self.currentCount = currentCount - self.totalCount = totalCount - } - } - - private var isFetchingMoreItems: Atomic = Atomic(false) - private var pageInfo: Atomic - - // Gallery observing - - private let updatedRows: Atomic> = Atomic([]) - public var onGalleryChange: (([SectionModel], PageInfo) -> ())? + public var onGalleryChange: (([SectionModel]) -> ())? // MARK: - Initialization init( threadId: String, threadVariant: SessionThread.Variant, + isPagedData: Bool, pageSize: Int = 1, focusedAttachmentId: String? = nil ) { self.threadId = threadId self.threadVariant = threadVariant - self.pageInfo = Atomic(PageInfo(pageSize: pageSize)) self.focusedAttachmentId = focusedAttachmentId + self.pagedDatabaseObserver = nil + + guard isPagedData else { return } + + var hasSavedIntialUpdate: Bool = false + let filterSQL: SQL = Item.filterSQL(threadId: threadId) + self.pagedDatabaseObserver = PagedDatabaseObserver( + pagedTable: Attachment.self, + pageSize: pageSize, + idColumn: .id, + initialFocusedId: focusedAttachmentId, + observedChanges: [ + PagedData.ObservedChanges( + table: Attachment.self, + columns: [.isValid] + ) + ], + joinSQL: Item.joinSQL, + filterSQL: filterSQL, + orderSQL: Item.galleryOrderSQL, + dataQuery: Item.baseQuery(orderSQL: Item.galleryOrderSQL, baseFilterSQL: filterSQL), + onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in + guard let updatedGalleryData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else { + return + } + + // If we haven't stored the data for the initial fetch then do so now (no need + // to call 'onGalleryChange' in this case as it will always be null) + guard hasSavedIntialUpdate else { + self?.updateGalleryData(updatedGalleryData) + hasSavedIntialUpdate = true + return + } + + self?.onGalleryChange?(updatedGalleryData) + } + ) } // MARK: - Data @@ -132,33 +146,26 @@ public class MediaGalleryViewModel: TransactionObserver { } } - public typealias SectionModel = ArraySection - - public enum Section: Differentiable, Equatable, Comparable, Hashable { - case emptyGallery - case loadNewer - case galleryMonth(date: GalleryDate) - case loadOlder - } - - public struct Item: FetchableRecord, Decodable, Differentiable, Equatable, Hashable, Comparable { - fileprivate static let interactionIdKey: String = CodingKeys.interactionId.stringValue - fileprivate static let interactionVariantKey: String = CodingKeys.interactionVariant.stringValue - fileprivate static let interactionAuthorIdKey: String = CodingKeys.interactionAuthorId.stringValue - fileprivate static let interactionTimestampMsKey: String = CodingKeys.interactionTimestampMs.stringValue - fileprivate static let attachmentRowIdKey: String = CodingKeys.attachmentRowId.stringValue - fileprivate static let attachmentAlbumIndexKey: String = CodingKeys.attachmentAlbumIndex.stringValue + public struct Item: FetchableRecordWithRowId, Decodable, Identifiable, Differentiable, Equatable, Hashable { + fileprivate static let interactionIdKey: SQL = SQL(stringLiteral: CodingKeys.interactionId.stringValue) + fileprivate static let interactionVariantKey: SQL = SQL(stringLiteral: CodingKeys.interactionVariant.stringValue) + fileprivate static let interactionAuthorIdKey: SQL = SQL(stringLiteral: CodingKeys.interactionAuthorId.stringValue) + fileprivate static let interactionTimestampMsKey: SQL = SQL(stringLiteral: CodingKeys.interactionTimestampMs.stringValue) + fileprivate static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue) + fileprivate static let attachmentKey: SQL = SQL(stringLiteral: CodingKeys.attachment.stringValue) + fileprivate static let attachmentAlbumIndexKey: SQL = SQL(stringLiteral: CodingKeys.attachmentAlbumIndex.stringValue) - public var differenceIdentifier: String { - return attachment.id - } + fileprivate static let attachmentString: String = CodingKeys.attachment.stringValue + + public var id: String { attachment.id } + public var differenceIdentifier: String { attachment.id } let interactionId: Int64 let interactionVariant: Interaction.Variant let interactionAuthorId: String let interactionTimestampMs: Int64 - let attachmentRowId: Int64 + public var rowId: Int64 let attachmentAlbumIndex: Int let attachment: Attachment @@ -182,25 +189,31 @@ public class MediaGalleryViewModel: TransactionObserver { var captionForDisplay: String? { attachment.caption?.filterForDisplay } - // MARK: - Comparable - - public static func < (lhs: Item, rhs: Item) -> Bool { - if lhs.interactionTimestampMs == rhs.interactionTimestampMs { - return (lhs.attachmentAlbumIndex < rhs.attachmentAlbumIndex) - } - - return (lhs.interactionTimestampMs < rhs.interactionTimestampMs) - } - // MARK: - Query - private static let baseQueryFilterSQL: SQL = { + fileprivate static let joinSQL: SQL = { let attachment: TypedTableAlias = TypedTableAlias() + let interaction: TypedTableAlias = TypedTableAlias() + let interactionAttachment: TypedTableAlias = TypedTableAlias() - return SQL("\(attachment[.isVisualMedia]) = true AND \(attachment[.isValid]) = true") + return """ + JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id]) + JOIN \(Interaction.self) ON \(interaction[.id]) = \(interactionAttachment[.interactionId]) + """ }() - private static let galleryQueryOrderSQL: SQL = { + fileprivate static func filterSQL(threadId: String) -> SQL { + let interaction: TypedTableAlias = TypedTableAlias() + let attachment: TypedTableAlias = TypedTableAlias() + + return SQL(""" + \(attachment[.isVisualMedia]) = true AND + \(attachment[.isValid]) = true AND + \(interaction[.threadId]) = \(threadId) + """) + } + + fileprivate static let galleryOrderSQL: SQL = { let interaction: TypedTableAlias = TypedTableAlias() let interactionAttachment: TypedTableAlias = TypedTableAlias() @@ -209,101 +222,72 @@ public class MediaGalleryViewModel: TransactionObserver { return SQL("\(interaction[.timestampMs].desc), \(interactionAttachment[.albumIndex])") }() - /// Retrieve the index that the attachment with the given `attachmentId` will have in the gallery - fileprivate static func galleryIndex(for attachmentId: String) -> SQLRequest { - let attachment: TypedTableAlias = TypedTableAlias() + fileprivate static let galleryReverseOrderSQL: SQL = { let interaction: TypedTableAlias = TypedTableAlias() let interactionAttachment: TypedTableAlias = TypedTableAlias() - return """ - SELECT - (gallery.galleryIndex - 1) AS galleryIndex -- Converting from 1-Indexed to 0-indexed - FROM ( - SELECT - \(attachment[.id]) AS id, - ROW_NUMBER() OVER (ORDER BY \(galleryQueryOrderSQL)) AS galleryIndex - FROM \(Attachment.self) - JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id]) - JOIN \(Interaction.self) ON \(interaction[.id]) = \(interactionAttachment[.interactionId]) - WHERE \(baseQueryFilterSQL) - ) AS gallery - WHERE \(SQL("gallery.id = \(attachmentId)")) - """ - } + /// **Note:** This **MUST** match the desired sort behaviour for the screen otherwise paging will be + /// very broken + return SQL("\(interaction[.timestampMs]), \(interactionAttachment[.albumIndex].desc)") + }() - /// Retrieve the indexes the given attachment row will have in the gallery - fileprivate static func galleryIndexes(for rowIds: Set, threadId: String) -> SQLRequest { - let attachment: TypedTableAlias = TypedTableAlias() - let interaction: TypedTableAlias = TypedTableAlias() - let interactionAttachment: TypedTableAlias = TypedTableAlias() - - return """ - SELECT - (gallery.galleryIndex - 1) AS galleryIndex -- Converting from 1-Indexed to 0-indexed - FROM ( - SELECT - \(attachment.alias[Column.rowID]) AS rowid, - ROW_NUMBER() OVER (ORDER BY \(galleryQueryOrderSQL)) AS galleryIndex - FROM \(Attachment.self) - JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id]) - JOIN \(Interaction.self) ON ( - \(interaction[.id]) = \(interactionAttachment[.interactionId]) AND - \(SQL("\(interaction[.threadId]) = \(threadId)")) - ) - WHERE \(baseQueryFilterSQL) - ) AS gallery - WHERE \(SQL("gallery.rowid IN \(rowIds)")) - """ - } - - private static let baseQuery: QueryInterfaceRequest = { - let attachment: TypedTableAlias = TypedTableAlias() - let interaction: TypedTableAlias = TypedTableAlias() - let interactionAttachment: TypedTableAlias = TypedTableAlias() - - return Attachment - .select( - interaction[.id].forKey(Item.interactionIdKey), - interaction[.variant].forKey(Item.interactionVariantKey), - interaction[.authorId].forKey(Item.interactionAuthorIdKey), - interaction[.timestampMs].forKey(Item.interactionTimestampMsKey), - - attachment.alias[Column.rowID].forKey(Item.attachmentRowIdKey), - interactionAttachment[.albumIndex].forKey(Item.attachmentAlbumIndexKey), - attachment.allColumns() - ) - .aliased(attachment) - .filter(literal: baseQueryFilterSQL) - .joining( - required: Attachment.interactionAttachments - .aliased(interactionAttachment) - .joining( - required: InteractionAttachment.interaction - .aliased(interaction) + fileprivate static func baseQuery(orderSQL: SQL, baseFilterSQL: SQL) -> ((SQL?, SQL?) -> AdaptedFetchRequest>) { + return { additionalFilters, limitSQL -> AdaptedFetchRequest> in + let attachment: TypedTableAlias = TypedTableAlias() + let interaction: TypedTableAlias = TypedTableAlias() + let interactionAttachment: TypedTableAlias = TypedTableAlias() + + let finalFilterSQL: SQL = { + guard let additionalFilters: SQL = additionalFilters else { + return """ + WHERE ( + \(baseFilterSQL) + ) + """ + } + + return """ + WHERE ( + \(baseFilterSQL) AND + \(additionalFilters) ) - ) - .asRequest(of: Item.self) - }() + """ + }() + let finalLimitSQL: SQL = (limitSQL ?? SQL(stringLiteral: "")) + let numColumnsBeforeLinkedRecords: Int = 6 + let request: SQLRequest = """ + SELECT + \(interaction[.id]) AS \(Item.interactionIdKey), + \(interaction[.variant]) AS \(Item.interactionVariantKey), + \(interaction[.authorId]) AS \(Item.interactionAuthorIdKey), + \(interaction[.timestampMs]) AS \(Item.interactionTimestampMsKey), + + \(attachment.alias[Column.rowID]) AS \(Item.rowIdKey), + \(interactionAttachment[.albumIndex]) AS \(Item.attachmentAlbumIndexKey), + \(Item.attachmentKey).* + FROM \(Attachment.self) + \(joinSQL) + \(finalFilterSQL) + ORDER BY \(orderSQL) + \(finalLimitSQL) + """ + + return request.adapted { db in + let adapters = try splittingRowAdapters(columnCounts: [ + numColumnsBeforeLinkedRecords, + Attachment.numberOfSelectedColumns(db) + ]) + + return ScopeAdapter([ + Item.attachmentString: adapters[1] + ]) + } + } + } - fileprivate static let albumQuery: QueryInterfaceRequest = { - let interactionAttachment: TypedTableAlias = TypedTableAlias() - - return Item.baseQuery.order(interactionAttachment[.albumIndex]) - }() - - fileprivate static let galleryQuery: QueryInterfaceRequest = { - return Item.baseQuery - .order(literal: galleryQueryOrderSQL) - }() - - fileprivate static let galleryQueryReversed: QueryInterfaceRequest = { - let interaction: TypedTableAlias = TypedTableAlias() - let interactionAttachment: TypedTableAlias = TypedTableAlias() - - /// **Note:** This **MUST** always result in the same data as `galleryQuery` but in the opposite order - return Item.baseQuery - .order(interaction[.timestampMs], interactionAttachment[.albumIndex].desc) - }() + fileprivate static func baseQuery(orderSQL: SQL, baseFilterSQL: SQL) -> AdaptedFetchRequest> { + return Item.baseQuery(orderSQL: orderSQL, baseFilterSQL: baseFilterSQL)(nil, nil) + } func thumbnailImage(async: @escaping (UIImage) -> ()) { attachment.thumbnail(size: .small, success: { image, _ in async(image) }, failure: {}) @@ -326,345 +310,18 @@ public class MediaGalleryViewModel: TransactionObserver { guard let interactionId: Int64 = interactionId else { return [] } let interaction: TypedTableAlias = TypedTableAlias() + let interactionAttachment: TypedTableAlias = TypedTableAlias() - return try Item.albumQuery - .filter(interaction[.id] == interactionId) + return try Item + .baseQuery( + orderSQL: SQL(interactionAttachment[.albumIndex]), + baseFilterSQL: SQL("\(interaction[.id]) = \(interactionId)") + ) .fetchAll(db) } .removeDuplicates() } - - // MARK: - Gallery - - /// This function is used to load a gallery page using the provided `limitInfo`, if a `focusedAttachmentId` is provided then - /// the `limitInfo.offset` value will be ignored and it will retrieve `limitInfo.limit` values positioning the focussed item - /// as closed to the middle as possible prioritising retrieving `limitInfo.limit` items total - /// - /// **Note:** The `focusedAttachmentId` should only be provided during the first call, subsequent calls should solely provide - /// the `limitInfo` so content can be added before and after the initial page - private func loadGalleryPage( - _ target: PageInfo.Target, - currentPageInfo: PageInfo - ) -> (items: [Item], updatedPageInfo: PageInfo) { - return GRDBStorage.shared - .read { db in - let interaction: TypedTableAlias = TypedTableAlias() - let totalCount: Int = try Item.galleryQuery - .filter(interaction[.threadId] == threadId) - .fetchCount(db) - let queryOffset: Int = { - switch target { - case .before: - return max(0, (currentPageInfo.pageOffset - currentPageInfo.pageSize)) - - case .around(let targetId): - // If we want to focus on a specific item then we need to find it's index in - // the queried data - guard let targetIndex: Int = try? Int.fetchOne(db, Item.galleryIndex(for: targetId)) else { - // If we couldn't find the targetId then just load the page after the current one - return (currentPageInfo.pageOffset + currentPageInfo.pageSize) - } - - // If the focused item is within the first half of the page then we still want - // to retrieve a full page so calculate the offset needed to do so - let halfPageSize: Int = Int(floor(Double(currentPageInfo.pageSize) / 2)) - - // If the focused item is within the first or last half page then just - // start from the start/end of the content - guard targetIndex > halfPageSize else { return 0 } - guard targetIndex < (totalCount - halfPageSize) else { - return (totalCount - currentPageInfo.pageSize) - } - - return (targetIndex - halfPageSize) - - case .after: - return (currentPageInfo.pageOffset + currentPageInfo.currentCount) - } - }() - - let items: [Item] = try Item.galleryQuery - .filter(interaction[.threadId] == threadId) - .limit(currentPageInfo.pageSize, offset: queryOffset) - .fetchAll(db) - let updatedLimitInfo: PageInfo = PageInfo( - pageSize: currentPageInfo.pageSize, - pageOffset: (target != .after ? - queryOffset : - currentPageInfo.pageOffset - ), - currentCount: (currentPageInfo.currentCount + items.count), - totalCount: totalCount - ) - - return (items, updatedLimitInfo) - } - .defaulting(to: ([], currentPageInfo)) - } - - private func addingSystemSections(to data: [SectionModel], for pageInfo: PageInfo) -> [SectionModel] { - // Remove and re-add the custom sections as needed - return [ - (data.isEmpty ? [SectionModel(section: .emptyGallery)] : []), - (!data.isEmpty && pageInfo.pageOffset > 0 ? [SectionModel(section: .loadNewer)] : []), - data.filter { section -> Bool in - switch section.model { - case .galleryMonth: return true - case .emptyGallery, .loadOlder, .loadNewer: return false - } - }, - (!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ? - [SectionModel(section: .loadOlder)] : - [] - ) - ] - .flatMap { $0 } - } - - private func updatedGalleryData( - with existingData: [SectionModel], - dataToUpsert: [Item], - pageInfoToUpdate: PageInfo - ) -> (sections: [SectionModel], pageInfo: PageInfo) { - guard !dataToUpsert.isEmpty else { return (existingData, pageInfoToUpdate) } - - let updatedGalleryData: (sections: [SectionModel], pageInfo: PageInfo) = updatedGalleryData( - with: self.galleryData, - dataToUpsert: (dataToUpsert, pageInfoToUpdate) - ) - let existingDataCount: Int = existingData - .map { $0.elements.count } - .reduce(0, +) - let updatedGalleryDataCount: Int = updatedGalleryData.sections - .map { $0.elements.count } - .reduce(0, +) - let gallerySizeDiff: Int = (updatedGalleryDataCount - existingDataCount) - let updatedPageInfo: PageInfo = PageInfo( - pageSize: pageInfoToUpdate.pageSize, - pageOffset: pageInfoToUpdate.pageOffset, - currentCount: (pageInfoToUpdate.currentCount + gallerySizeDiff), - totalCount: (pageInfoToUpdate.totalCount + gallerySizeDiff) - ) - - // Add the "system" sections, sort the sections and return the result - return ( - self.addingSystemSections(to: updatedGalleryData.sections, for: updatedPageInfo) - .sorted { lhs, rhs -> Bool in (lhs.model > rhs.model) }, - updatedPageInfo - ) - } - - private func updatedGalleryData( - with existingData: [SectionModel], - dataToUpsert: (items: [Item], updatedPageInfo: PageInfo) - ) -> (sections: [SectionModel], pageInfo: PageInfo) { - var updatedGalleryData: [SectionModel] = existingData - - dataToUpsert - .items - .grouped(by: \.galleryDate) - .forEach { key, items in - guard let existingIndex = galleryData.firstIndex(where: { $0.model == .galleryMonth(date: key) }) else { - // Insert a new section - updatedGalleryData.append( - ArraySection( - model: .galleryMonth(date: key), - elements: items - .sorted() - .reversed() - ) - ) - return - } - - // Filter out collisions, replacing them with the updated values and insert - // and new values - let itemRowIds: Set = items.map { $0.attachmentRowId }.asSet() - - updatedGalleryData[existingIndex] = ArraySection( - model: .galleryMonth(date: key), - elements: updatedGalleryData[existingIndex].elements - .filter { !itemRowIds.contains($0.attachmentRowId) } - .appending(contentsOf: items) - .sorted() - .reversed() - ) - } - - // Add the "system" sections, sort the sections and return the result - return ( - self.addingSystemSections(to: updatedGalleryData, for: dataToUpsert.updatedPageInfo) - .sorted { lhs, rhs -> Bool in (lhs.model > rhs.model) }, - dataToUpsert.updatedPageInfo - ) - } - - // MARK: - TransactionObserver - - private struct TrackedChange: Equatable, Hashable { - let kind: DatabaseEvent.Kind - let rowId: Int64 - } - - public func observes(eventsOfKind eventKind: DatabaseEventKind) -> Bool { - switch eventKind { - case .delete(let tableName): return (tableName == Attachment.databaseTableName) - case .update(let tableName, let columnNames): - /// **Warning:** This filtering allows us to ignore all changes to attachments except - /// for the 'isValid' column, unfortunately calling the `with()` function on an attachment - /// does result in this column being seen as updated (even if the value doesn't change) so - /// we need to be careful where we set it to avoid unnecessarily triggering updates - return ( - tableName == Attachment.databaseTableName && - columnNames.contains(Attachment.Columns.isValid.name) - ) - - // We can ignore 'insert' events as we only care about valid attachments - case .insert: return false - } - } - - public func databaseDidChange(with event: DatabaseEvent) { - // This will get called for whenever an Attachment's 'isValid' column is - // updated (ie. an attachment finished uploading/downloading), unfortunately - // we won't know if the attachment is actually relevant yet as it could be for - // another thread or it might not be a media attachment - let trackedChange: TrackedChange = TrackedChange( - kind: event.kind, - rowId: event.rowID - ) - updatedRows.mutate { $0.insert(trackedChange) } - } - - // Note: We will process all updates which come through this method even if - // 'onGalleryChange' is null because if the UI stops observing and then starts again - // later we don't want them to have missed out on changes which happened while they - // weren't subscribed (and doing a full re-query seems painful...) - public func databaseDidCommit(_ db: Database) { - var committedUpdatedRows: Set = [] - self.updatedRows.mutate { updatedRows in - committedUpdatedRows = updatedRows - updatedRows.removeAll() - } - - // Note: This method will be called regardless of whether there were actually changes - // in the areas we are observing so we want to early-out if there aren't any relevant - // updated rows - guard !committedUpdatedRows.isEmpty else { return } - - var updatedPageInfo: PageInfo = self.pageInfo.wrappedValue - let attachmentRowIdsToQuery: Set = committedUpdatedRows - .filter { $0.kind != .delete } - .map { $0.rowId } - .asSet() - let attachmentRowIdsToDelete: Set = committedUpdatedRows - .filter { $0.kind == .delete } - .map { $0.rowId } - .asSet() - let oldGalleryDataCount: Int = self.galleryData - .map { $0.elements.count } - .reduce(0, +) - var galleryDataWithDeletions: [SectionModel] = self.galleryData - - // First remove any items which have been deleted - if !attachmentRowIdsToDelete.isEmpty { - galleryDataWithDeletions = galleryDataWithDeletions - .map { section -> SectionModel in - ArraySection( - model: section.model, - elements: section.elements - .filter { item -> Bool in !attachmentRowIdsToDelete.contains(item.attachmentRowId) } - ) - } - .filter { section -> Bool in !section.elements.isEmpty } - let updatedGalleryDataCount: Int = galleryDataWithDeletions - .map { $0.elements.count } - .reduce(0, +) - - // Make sure there were actually changes - if updatedGalleryDataCount != oldGalleryDataCount { - let gallerySizeDiff: Int = (updatedGalleryDataCount - oldGalleryDataCount) - - updatedPageInfo = PageInfo( - pageSize: updatedPageInfo.pageSize, - pageOffset: updatedPageInfo.pageOffset, - currentCount: (updatedPageInfo.currentCount + gallerySizeDiff), - totalCount: (updatedPageInfo.totalCount + gallerySizeDiff) - ) - } - } - - /// Store the 'deletions-only' update logic in a block as there are a number of places we will fallback to this logic - let sendDeletionsOnlyUpdateIfNeeded: () -> () = { - guard !attachmentRowIdsToDelete.isEmpty else { return } - - DispatchQueue.main.async { [weak self] in - self?.onGalleryChange?(galleryDataWithDeletions, updatedPageInfo) - } - } - - // If there are no inserted/updated rows then trigger the update callback and stop here - guard !attachmentRowIdsToQuery.isEmpty else { - sendDeletionsOnlyUpdateIfNeeded() - return - } - - // Fetch the indexes of the rowIds so we can determine whether they should be added to the screen - let interaction: TypedTableAlias = TypedTableAlias() - let itemIndexes: [Int] = (try? Item.galleryIndexes(for: attachmentRowIdsToQuery, threadId: self.threadId) - .fetchAll(db)) - .defaulting(to: []) - - // Determine if the indexes for the row ids should be displayed on the screen and remove any - // which shouldn't - values less than 'currentCount' or if there is at least one value less than - // 'currentCount' and the indexes are sequential (ie. more than the current loaded content was - // added at once) - let itemsAreSequential: Bool = (itemIndexes.map { $0 - 1 }.dropFirst() == itemIndexes.dropLast()) - let validAttachmentRowIds: Set = (itemsAreSequential && itemIndexes.contains(where: { $0 < updatedPageInfo.currentCount }) ? - attachmentRowIdsToQuery : - zip(itemIndexes, attachmentRowIdsToQuery) - .filter { index, _ -> Bool in index < updatedPageInfo.currentCount } - .map { _, rowId -> Int64 in rowId } - .asSet() - ) - - // If there are no valid attachment row ids then stop here - guard !validAttachmentRowIds.isEmpty else { - sendDeletionsOnlyUpdateIfNeeded() - return - } - - // Fetch the inserted/updated rows - let updatedItems: [Item] = (try? Item.galleryQuery - .filter(validAttachmentRowIds.contains(Column.rowID)) - .filter(interaction[.threadId] == self.threadId) - .fetchAll(db)) - .defaulting(to: []) - - // If the inserted/updated rows we irrelevant (eg. associated to another thread, a quote or a link - // preview) then trigger the update callback (if there were deletions) and stop here - guard !updatedItems.isEmpty else { - sendDeletionsOnlyUpdateIfNeeded() - return - } - - // Process the upserted data - let updatedGalleryData: (sections: [SectionModel], pageInfo: PageInfo) = updatedGalleryData( - with: galleryDataWithDeletions, - dataToUpsert: updatedItems, - pageInfoToUpdate: updatedPageInfo - ) - - DispatchQueue.main.async { [weak self] in - self?.onGalleryChange?(updatedGalleryData.sections, updatedGalleryData.pageInfo) - } - } - - public func databaseDidRollback(_ db: Database) {} - - // MARK: - Functions - @discardableResult public func loadAndCacheAlbumData(for interactionId: Int64) -> [Item] { typealias AlbumInfo = (albumData: [Item], interactionIdBefore: Int64?, interactionIdAfter: Int64?) @@ -673,19 +330,30 @@ public class MediaGalleryViewModel: TransactionObserver { let maybeAlbumInfo: AlbumInfo? = GRDBStorage.shared .read { db -> AlbumInfo in let interaction: TypedTableAlias = TypedTableAlias() - let newAlbumData: [Item] = try Item.albumQuery - .filter(interaction[.id] == interactionId) + let interactionAttachment: TypedTableAlias = TypedTableAlias() + + let newAlbumData: [Item] = try Item + .baseQuery( + orderSQL: SQL(interactionAttachment[.albumIndex]), + baseFilterSQL: SQL("\(interaction[.id]) = \(interactionId)") + ) .fetchAll(db) guard let albumTimestampMs: Int64 = newAlbumData.first?.interactionTimestampMs else { return (newAlbumData, nil, nil) } - let itemBefore: Item? = try Item.galleryQueryReversed - .filter(interaction[.timestampMs] > albumTimestampMs) + let itemBefore: Item? = try Item + .baseQuery( + orderSQL: Item.galleryReverseOrderSQL, + baseFilterSQL: SQL("\(interaction[.timestampMs]) > \(albumTimestampMs)") + ) .fetchOne(db) - let itemAfter: Item? = try Item.galleryQuery - .filter(interaction[.timestampMs] < albumTimestampMs) + let itemAfter: Item? = try Item + .baseQuery( + orderSQL: Item.galleryOrderSQL, + baseFilterSQL: SQL("\(interaction[.timestampMs]) < \(albumTimestampMs)") + ) .fetchOne(db) return (newAlbumData, itemBefore?.interactionId, itemAfter?.interactionId) @@ -709,9 +377,43 @@ public class MediaGalleryViewModel: TransactionObserver { self.albumData[interactionId] = updatedData } - public func updateGalleryData(_ updatedData: [SectionModel], pageInfo: PageInfo) { + // MARK: - Gallery + + private func process(data: [Item], for pageInfo: PagedData.PageInfo) -> [SectionModel] { + let galleryData: [SectionModel] = data + .grouped(by: \.galleryDate) + .mapValues { sectionItems -> [Item] in + sectionItems + .sorted { lhs, rhs -> Bool in + if lhs.interactionTimestampMs == rhs.interactionTimestampMs { + // Start of album first + return (lhs.attachmentAlbumIndex < rhs.attachmentAlbumIndex) + } + + // Newer interactions first + return (lhs.interactionTimestampMs > rhs.interactionTimestampMs) + } + } + .map { galleryDate, items in + SectionModel(model: .galleryMonth(date: galleryDate), elements: items) + } + + // Remove and re-add the custom sections as needed + return [ + (data.isEmpty ? [SectionModel(section: .emptyGallery)] : []), + (!data.isEmpty && pageInfo.pageOffset > 0 ? [SectionModel(section: .loadNewer)] : []), + galleryData, + (!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ? + [SectionModel(section: .loadOlder)] : + [] + ) + ] + .flatMap { $0 } + .sorted { lhs, rhs -> Bool in (lhs.model > rhs.model) } + } + + public func updateGalleryData(_ updatedData: [SectionModel]) { self.galleryData = updatedData - self.pageInfo.mutate { $0 = pageInfo } // If we have a focused attachment id then we need to make sure the 'focusedIndexPath' // is updated to be accurate @@ -732,49 +434,11 @@ public class MediaGalleryViewModel: TransactionObserver { } public func loadNewerGalleryItems() { - // Only allow on 'load older' fetch at a time - guard !isFetchingMoreItems.wrappedValue else { return } - - // Prevent more fetching until we have completed adding the page - isFetchingMoreItems.mutate { $0 = true } - - // Load the page before the current data (newer items) then merge and sort - // with the current data - let updatedGalleryData: (sections: [SectionModel], pageInfo: PageInfo) = updatedGalleryData( - with: galleryData, - dataToUpsert: loadGalleryPage( - .before, - currentPageInfo: pageInfo.wrappedValue - ) - ) - - DispatchQueue.main.async { [weak self] in - self?.onGalleryChange?(updatedGalleryData.sections, updatedGalleryData.pageInfo) - self?.isFetchingMoreItems.mutate { $0 = false } - } + self.pagedDatabaseObserver?.load(.pageBefore) } public func loadOlderGalleryItems() { - // Only allow on 'load older' fetch at a time - guard !isFetchingMoreItems.wrappedValue else { return } - - // Prevent more fetching until we have completed adding the page - isFetchingMoreItems.mutate { $0 = true } - - // Load the page after the current data (older items) then merge and sort - // with the current data - let updatedGalleryData: (sections: [SectionModel], pageInfo: PageInfo) = updatedGalleryData( - with: galleryData, - dataToUpsert: loadGalleryPage( - .after, - currentPageInfo: pageInfo.wrappedValue - ) - ) - - DispatchQueue.main.async { [weak self] in - self?.onGalleryChange?(updatedGalleryData.sections, updatedGalleryData.pageInfo) - self?.isFetchingMoreItems.mutate { $0 = false } - } + self.pagedDatabaseObserver?.load(.pageAfter) } public func updateFocusedItem(attachmentId: String, indexPath: IndexPath) { @@ -798,7 +462,8 @@ public class MediaGalleryViewModel: TransactionObserver { // transitions work nicely) let viewModel: MediaGalleryViewModel = MediaGalleryViewModel( threadId: threadId, - threadVariant: threadVariant + threadVariant: threadVariant, + isPagedData: false ) viewModel.loadAndCacheAlbumData(for: interactionId) viewModel.replaceAlbumObservation(toObservationFor: interactionId) @@ -831,32 +496,11 @@ public class MediaGalleryViewModel: TransactionObserver { let viewModel: MediaGalleryViewModel = MediaGalleryViewModel( threadId: threadId, threadVariant: threadVariant, + isPagedData: true, pageSize: MediaTileViewController.itemPageSize, focusedAttachmentId: focusedAttachmentId ) - // Load the data for the album immediately (needed before pushing to the screen so - // transitions work nicely) - let pageTarget: PageInfo.Target = { - // If we don't have a `focusedAttachmentId` then default to `.before` (it'll query - // from a `0` offset - guard let targetId: String = focusedAttachmentId else { return .before } - - return .around(id: targetId) - }() - let initialGalleryData: (sections: [SectionModel], pageInfo: PageInfo) = viewModel.updatedGalleryData( - with: [], - dataToUpsert: viewModel.loadGalleryPage( - pageTarget, - currentPageInfo: PageInfo(pageSize: MediaTileViewController.itemPageSize) - ) - ) - - viewModel.updateGalleryData( - initialGalleryData.sections, - pageInfo: initialGalleryData.pageInfo - ) - return MediaTileViewController( viewModel: viewModel ) diff --git a/Session/Media Viewing & Editing/MediaTileViewController.swift b/Session/Media Viewing & Editing/MediaTileViewController.swift index 049938c62..8f2aac326 100644 --- a/Session/Media Viewing & Editing/MediaTileViewController.swift +++ b/Session/Media Viewing & Editing/MediaTileViewController.swift @@ -34,9 +34,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour init(viewModel: MediaGalleryViewModel) { self.viewModel = viewModel - - // Start observing database changes - GRDBStorage.shared.addObserver(viewModel) + GRDBStorage.shared.addObserver(viewModel.pagedDatabaseObserver) super.init(nibName: nil, bundle: nil) } @@ -163,8 +161,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour public override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - // Stop observing database changes - self.viewModel.onGalleryChange = nil + stopObservingChanges() } @objc func applicationDidBecomeActive(_ notification: Notification) { @@ -172,8 +169,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour } @objc func applicationDidResignActive(_ notification: Notification) { - // Stop observing database changes - self.viewModel.onGalleryChange = nil + stopObservingChanges() } override public func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { @@ -240,17 +236,23 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour private func startObservingChanges() { // Start observing for data changes (will callback on the main thread) - self.viewModel.onGalleryChange = { [weak self] updatedGalleryData, pageInfo in - self?.handleUpdates(updatedGalleryData, pageInfo: pageInfo) + self.viewModel.onGalleryChange = { [weak self] updatedGalleryData in + self?.handleUpdates(updatedGalleryData) } } - private func handleUpdates(_ updatedGalleryData: [MediaGalleryViewModel.SectionModel], pageInfo: MediaGalleryViewModel.PageInfo) { + private func stopObservingChanges() { + // Note: The 'PagedDatabaseObserver' will continue to get changes but + // we don't want to trigger any UI updates + self.viewModel.onGalleryChange = nil + } + + private func handleUpdates(_ updatedGalleryData: [MediaGalleryViewModel.SectionModel]) { // 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 { UIView.performWithoutAnimation { - handleUpdates(updatedGalleryData, pageInfo: pageInfo) + handleUpdates(updatedGalleryData) triggerInitialDataLoadIfNeeded() } return @@ -291,7 +293,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour using: StagedChangeset(source: self.viewModel.galleryData, target: updatedGalleryData), interrupt: { $0.changeCount > MediaTileViewController.itemPageSize } ) { [weak self] updatedData in - self?.viewModel.updateGalleryData(updatedData, pageInfo: pageInfo) + self?.viewModel.updateGalleryData(updatedData) } CATransaction.setCompletionBlock { [weak self] in diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index e37a43bcf..bc503dda3 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -36,7 +36,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu public static let recipientStates = hasMany(RecipientState.self, using: RecipientState.interactionForeignKey) public typealias Columns = CodingKeys - public enum CodingKeys: String, CodingKey, ColumnExpression { + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { case id case serverHash case threadId @@ -60,7 +60,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu case openGroupWhisperTo } - public enum Variant: Int, Codable, DatabaseValueConvertible { + public enum Variant: Int, Codable, Hashable, DatabaseValueConvertible { case standardIncoming case standardOutgoing case standardIncomingDeleted diff --git a/SessionMessagingKit/Database/Models/InteractionAttachment.swift b/SessionMessagingKit/Database/Models/InteractionAttachment.swift index 879ac0f45..465c124b2 100644 --- a/SessionMessagingKit/Database/Models/InteractionAttachment.swift +++ b/SessionMessagingKit/Database/Models/InteractionAttachment.swift @@ -4,7 +4,7 @@ import Foundation import GRDB import SessionUtilitiesKit -public struct InteractionAttachment: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct InteractionAttachment: Codable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "interactionAttachment" } internal static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id]) internal static let attachmentForeignKey = ForeignKey([Columns.attachmentId], to: [Attachment.Columns.id]) diff --git a/SessionMessagingKit/Database/Models/LinkPreview.swift b/SessionMessagingKit/Database/Models/LinkPreview.swift index ca6a2d439..9fe7eaa0e 100644 --- a/SessionMessagingKit/Database/Models/LinkPreview.swift +++ b/SessionMessagingKit/Database/Models/LinkPreview.swift @@ -7,7 +7,7 @@ import AFNetworking import SignalCoreKit import SessionUtilitiesKit -public struct LinkPreview: Codable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct LinkPreview: Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "linkPreview" } internal static let interactionForeignKey = ForeignKey( [Columns.url], @@ -28,7 +28,7 @@ public struct LinkPreview: Codable, Equatable, FetchableRecord, PersistableRecor case attachmentId } - public enum Variant: Int, Codable, DatabaseValueConvertible { + public enum Variant: Int, Codable, Hashable, DatabaseValueConvertible { case standard case openGroupInvitation } diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index 1967ca1bc..4c3178c74 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -345,8 +345,8 @@ public extension Profile { } /// A standardised mechanism for truncating a user id for a given thread - static func truncated(id: String, thread: SessionThread) -> String { - switch thread.variant { + static func truncated(id: String, threadVariant: SessionThread.Variant = .contact) -> String { + switch threadVariant { case .openGroup: return truncated(id: id, truncating: .start) default: return truncated(id: id, truncating: .middle) } @@ -378,7 +378,7 @@ public extension Profile { if let nickname: String = nickname { return nickname } guard let name: String = name, name != id else { - return (customFallback ?? Profile.truncated(id: id, truncating: .middle)) + return (customFallback ?? Profile.truncated(id: id, threadVariant: threadVariant)) } switch threadVariant { diff --git a/SessionMessagingKit/Database/Models/Quote.swift b/SessionMessagingKit/Database/Models/Quote.swift index 9da8cd81c..5a867f1de 100644 --- a/SessionMessagingKit/Database/Models/Quote.swift +++ b/SessionMessagingKit/Database/Models/Quote.swift @@ -4,7 +4,7 @@ import Foundation import GRDB import SessionUtilitiesKit -public struct Quote: Codable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct Quote: Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "quote" } public static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id]) internal static let originalInteractionForeignKey = ForeignKey( diff --git a/SessionMessagingKit/Database/Models/RecipientState.swift b/SessionMessagingKit/Database/Models/RecipientState.swift index e96da4c71..e56c3f05d 100644 --- a/SessionMessagingKit/Database/Models/RecipientState.swift +++ b/SessionMessagingKit/Database/Models/RecipientState.swift @@ -21,7 +21,7 @@ public struct RecipientState: Codable, Equatable, FetchableRecord, PersistableRe case mostRecentFailureText } - public enum State: Int, Codable, DatabaseValueConvertible { + public enum State: Int, Codable, Hashable, DatabaseValueConvertible { case failed case sending case skipped @@ -117,3 +117,30 @@ public extension RecipientState { ) } } + +// MARK: - GRDB Queries + +public extension RecipientState { + static func selectInteractionState(tableLiteral: SQL, idColumnLiteral: SQL) -> SQL { + let interaction: TypedTableAlias = TypedTableAlias() + let recipientState: TypedTableAlias = TypedTableAlias() + + return """ + SELECT * FROM ( + SELECT + \(recipientState[.interactionId]), + \(recipientState[.state]), + \(recipientState[.mostRecentFailureText]) + FROM \(RecipientState.self) + JOIN \(Interaction.self) ON \(interaction[.id]) = \(recipientState[.interactionId]) + WHERE \(SQL("\(recipientState[.state]) != \(RecipientState.State.skipped)")) -- Ignore 'skipped' + ORDER BY + -- If there is a single 'sending' then should be 'sending', otherwise if there is a single + -- 'failed' and there is no 'sending' then it should be 'failed' + \(SQL("\(recipientState[.state]) = \(RecipientState.State.sending)")) DESC, + \(SQL("\(recipientState[.state]) = \(RecipientState.State.failed)")) DESC + ) AS \(tableLiteral) + GROUP BY \(tableLiteral).\(idColumnLiteral) + """ + } +} diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index 2fd51a151..f181f3a5a 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -32,7 +32,7 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, case onlyNotifyForMentions } - public enum Variant: Int, Codable, DatabaseValueConvertible { + public enum Variant: Int, Codable, Hashable, DatabaseValueConvertible { case contact case closedGroup case openGroup diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index 6e092a15b..b58b7614d 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -105,13 +105,15 @@ extension MessageReceiver { case .started: TypingIndicators.didStartTyping( db, - in: thread, + threadId: thread.id, + threadVariant: thread.variant, + threadIsMessageRequest: thread.isMessageRequest(db), direction: .incoming, timestampMs: message.sentTimestamp.map { Int64($0) } ) case .stopped: - TypingIndicators.didStopTyping(db, in: thread, direction: .incoming) + TypingIndicators.didStopTyping(db, threadId: thread.id, direction: .incoming) default: SNLog("Unknown TypingIndicator Kind ignored") @@ -582,7 +584,7 @@ extension MessageReceiver { // Cancel any typing indicators if needed if isMainAppActive { - TypingIndicators.didStopTyping(db, in: thread, direction: .incoming) + TypingIndicators.didStopTyping(db, threadId: thread.id, direction: .incoming) } // Update the contact's approval status of the current user if needed (if we are getting messages from diff --git a/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift b/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift index 07cb06126..abde118f3 100644 --- a/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift +++ b/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift @@ -42,19 +42,21 @@ public struct QuotedReplyModel { body: String?, timestampMs: Int64, attachments: [Attachment]?, - linkPreview: LinkPreview? + linkPreviewAttachment: Attachment? ) -> QuotedReplyModel? { guard variant == .standardOutgoing || variant == .standardIncoming else { return nil } guard (body != nil && body?.isEmpty == false) || attachments?.isEmpty == false else { return nil } + let targetAttachment: Attachment? = (attachments?.first ?? linkPreviewAttachment) + return QuotedReplyModel( threadId: threadId, authorId: authorId, timestampMs: timestampMs, body: body, - attachment: attachments?.first, - contentType: attachments?.first?.contentType, - sourceFileName: attachments?.first?.sourceFilename, + attachment: targetAttachment, + contentType: targetAttachment?.contentType, + sourceFileName: targetAttachment?.sourceFilename, thumbnailDownloadFailed: false ) } diff --git a/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift b/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift index 5c2d83611..71ebf2553 100644 --- a/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift +++ b/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift @@ -13,38 +13,38 @@ public class TypingIndicators { } private class Indicator { - fileprivate let thread: SessionThread + fileprivate let threadId: String fileprivate let direction: Direction fileprivate let timestampMs: Int64 fileprivate var refreshTimer: Timer? fileprivate var stopTimer: Timer? - init?(thread: SessionThread, direction: Direction, timestampMs: Int64?) { + init?( + threadId: String, + threadVariant: SessionThread.Variant, + threadIsMessageRequest: Bool, + direction: Direction, + timestampMs: Int64? + ) { // The `typingIndicatorsEnabled` flag reflects the user-facing setting in the app // preferences, if it's disabled we don't want to emit "typing indicator" messages // or show typing indicators for other users // // We also don't want to show/send typing indicators for message requests - guard GRDBStorage.shared.read({ db in - ( - db[.typingIndicatorsEnabled] == true && - thread.isMessageRequest(db) == false - ) - }) == true else { + guard GRDBStorage.shared[.typingIndicatorsEnabled] && !threadIsMessageRequest else { return nil } // Don't send typing indicators in group threads - guard thread.variant != .closedGroup && thread.variant != .openGroup else { return nil } + guard threadVariant != .closedGroup && threadVariant != .openGroup else { return nil } - self.thread = thread + self.threadId = threadId self.direction = direction self.timestampMs = (timestampMs ?? Int64(floor(Date().timeIntervalSince1970 * 1000))) } fileprivate func starting(_ db: Database) -> Indicator { - let thread: SessionThread = self.thread let direction: Direction = self.direction let timestampMs: Int64 = self.timestampMs @@ -55,7 +55,7 @@ public class TypingIndicators { case .incoming: try? ThreadTypingIndicator( - threadId: thread.id, + threadId: self.threadId, timestampMs: timestampMs ) .save(db) @@ -83,6 +83,10 @@ public class TypingIndicators { switch direction { case .outgoing: + guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: self.threadId) else { + return nil + } + try? MessageSender.send( db, message: TypingIndicator(kind: .stopped), @@ -92,7 +96,7 @@ public class TypingIndicators { case .incoming: _ = try? ThreadTypingIndicator - .filter(ThreadTypingIndicator.Columns.threadId == thread.id) + .filter(ThreadTypingIndicator.Columns.threadId == self.threadId) .deleteAll(db) } @@ -101,6 +105,10 @@ public class TypingIndicators { private func scheduleRefreshCallback(_ db: Database, shouldSend: Bool = true) { if shouldSend { + guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: self.threadId) else { + return + } + try? MessageSender.send( db, message: TypingIndicator(kind: .started), @@ -130,37 +138,56 @@ public class TypingIndicators { // MARK: - Functions - public static func didStartTyping(_ db: Database, in thread: SessionThread, direction: Direction, timestampMs: Int64?) { + public static func didStartTyping( + _ db: Database, + threadId: String, + threadVariant: SessionThread.Variant, + threadIsMessageRequest: Bool, + direction: Direction, + timestampMs: Int64? + ) { switch direction { case .outgoing: let updatedIndicator: Indicator? = ( - outgoing.wrappedValue[thread.id] ?? - Indicator(thread: thread, direction: direction, timestampMs: timestampMs) + outgoing.wrappedValue[threadId] ?? + Indicator( + threadId: threadId, + threadVariant: threadVariant, + threadIsMessageRequest: threadIsMessageRequest, + direction: direction, + timestampMs: timestampMs + ) )?.starting(db) - outgoing.mutate { $0[thread.id] = updatedIndicator } + outgoing.mutate { $0[threadId] = updatedIndicator } case .incoming: let updatedIndicator: Indicator? = ( - incoming.wrappedValue[thread.id] ?? - Indicator(thread: thread, direction: direction, timestampMs: timestampMs) + incoming.wrappedValue[threadId] ?? + Indicator( + threadId: threadId, + threadVariant: threadVariant, + threadIsMessageRequest: threadIsMessageRequest, + direction: direction, + timestampMs: timestampMs + ) )?.starting(db) - incoming.mutate { $0[thread.id] = updatedIndicator } + incoming.mutate { $0[threadId] = updatedIndicator } } } - public static func didStopTyping(_ db: Database, in thread: SessionThread, direction: Direction) { + public static func didStopTyping(_ db: Database, threadId: String, direction: Direction) { switch direction { case .outgoing: - let updatedIndicator: Indicator? = outgoing.wrappedValue[thread.id]?.stoping(db) + let updatedIndicator: Indicator? = outgoing.wrappedValue[threadId]?.stoping(db) - outgoing.mutate { $0[thread.id] = updatedIndicator } + outgoing.mutate { $0[threadId] = updatedIndicator } case .incoming: - let updatedIndicator: Indicator? = incoming.wrappedValue[thread.id]?.stoping(db) + let updatedIndicator: Indicator? = incoming.wrappedValue[threadId]?.stoping(db) - incoming.mutate { $0[thread.id] = updatedIndicator } + incoming.mutate { $0[threadId] = updatedIndicator } } } } diff --git a/SessionMessagingKit/Shared Models/ConversationCellViewModel.swift b/SessionMessagingKit/Shared Models/ConversationCellViewModel.swift index 4126a2ffb..7d9b6d93a 100644 --- a/SessionMessagingKit/Shared Models/ConversationCellViewModel.swift +++ b/SessionMessagingKit/Shared Models/ConversationCellViewModel.swift @@ -23,21 +23,31 @@ extension ConversationCell { public static let threadCreationDateTimestampKey: SQL = SQL(stringLiteral: CodingKeys.threadCreationDateTimestamp.stringValue) public static let threadMemberNamesKey: SQL = SQL(stringLiteral: CodingKeys.threadMemberNames.stringValue) public static let threadIsNoteToSelfKey: SQL = SQL(stringLiteral: CodingKeys.threadIsNoteToSelf.stringValue) + public static let threadIsMessageRequestKey: SQL = SQL(stringLiteral: CodingKeys.threadIsMessageRequest.stringValue) + public static let threadRequiresApprovalKey: SQL = SQL(stringLiteral: CodingKeys.threadRequiresApproval.stringValue) + public static let threadShouldBeVisibleKey: SQL = SQL(stringLiteral: CodingKeys.threadShouldBeVisible.stringValue) public static let threadIsPinnedKey: SQL = SQL(stringLiteral: CodingKeys.threadIsPinned.stringValue) public static let threadIsBlockedKey: SQL = SQL(stringLiteral: CodingKeys.threadIsBlocked.stringValue) public static let threadMutedUntilTimestampKey: SQL = SQL(stringLiteral: CodingKeys.threadMutedUntilTimestamp.stringValue) public static let threadOnlyNotifyForMentionsKey: SQL = SQL(stringLiteral: CodingKeys.threadOnlyNotifyForMentions.stringValue) + public static let threadMessageDraftKey: SQL = SQL(stringLiteral: CodingKeys.threadMessageDraft.stringValue) public static let threadContactIsTypingKey: SQL = SQL(stringLiteral: CodingKeys.threadContactIsTyping.stringValue) public static let threadUnreadCountKey: SQL = SQL(stringLiteral: CodingKeys.threadUnreadCount.stringValue) public static let threadUnreadMentionCountKey: SQL = SQL(stringLiteral: CodingKeys.threadUnreadMentionCount.stringValue) + public static let threadFirstUnreadInteractionIdKey: SQL = SQL(stringLiteral: CodingKeys.threadFirstUnreadInteractionId.stringValue) public static let contactProfileKey: SQL = SQL(stringLiteral: CodingKeys.contactProfile.stringValue) public static let closedGroupNameKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupName.stringValue) + public static let closedGroupUserCountKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupUserCount.stringValue) + public static let currentUserIsClosedGroupMemberKey: SQL = SQL(stringLiteral: CodingKeys.currentUserIsClosedGroupMember.stringValue) public static let currentUserIsClosedGroupAdminKey: SQL = SQL(stringLiteral: CodingKeys.currentUserIsClosedGroupAdmin.stringValue) public static let closedGroupProfileFrontKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupProfileFront.stringValue) public static let closedGroupProfileBackKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupProfileBack.stringValue) public static let closedGroupProfileBackFallbackKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupProfileBackFallback.stringValue) public static let openGroupNameKey: SQL = SQL(stringLiteral: CodingKeys.openGroupName.stringValue) + public static let openGroupServerKey: SQL = SQL(stringLiteral: CodingKeys.openGroupServer.stringValue) + public static let openGroupRoomKey: SQL = SQL(stringLiteral: CodingKeys.openGroupRoom.stringValue) public static let openGroupProfilePictureDataKey: SQL = SQL(stringLiteral: CodingKeys.openGroupProfilePictureData.stringValue) + public static let openGroupUserCountKey: SQL = SQL(stringLiteral: CodingKeys.openGroupUserCount.stringValue) public static let interactionIdKey: SQL = SQL(stringLiteral: CodingKeys.interactionId.stringValue) public static let interactionVariantKey: SQL = SQL(stringLiteral: CodingKeys.interactionVariant.stringValue) public static let interactionTimestampMsKey: SQL = SQL(stringLiteral: CodingKeys.interactionTimestampMs.stringValue) @@ -50,6 +60,9 @@ extension ConversationCell { public static let threadUnreadCountString: String = CodingKeys.threadUnreadCount.stringValue public static let threadUnreadMentionCountString: String = CodingKeys.threadUnreadMentionCount.stringValue + public static let threadFirstUnreadInteractionIdString: String = CodingKeys.threadFirstUnreadInteractionId.stringValue + public static let closedGroupUserCountString: String = CodingKeys.closedGroupUserCount.stringValue + public static let openGroupUserCountString: String = CodingKeys.openGroupUserCount.stringValue public static let contactProfileString: String = CodingKeys.contactProfile.stringValue public static let closedGroupProfileFrontString: String = CodingKeys.closedGroupProfileFront.stringValue public static let closedGroupProfileBackString: String = CodingKeys.closedGroupProfileBack.stringValue @@ -64,14 +77,19 @@ extension ConversationCell { public let threadMemberNames: String? public let threadIsNoteToSelf: Bool + public var threadIsMessageRequest: Bool? + public let threadRequiresApproval: Bool? + public let threadShouldBeVisible: Bool? public let threadIsPinned: Bool public var threadIsBlocked: Bool? public let threadMutedUntilTimestamp: TimeInterval? public let threadOnlyNotifyForMentions: Bool? + public let threadMessageDraft: String? public let threadContactIsTyping: Bool? public let threadUnreadCount: UInt? public let threadUnreadMentionCount: UInt? + public let threadFirstUnreadInteractionId: Int64? // Thread display info @@ -80,9 +98,14 @@ extension ConversationCell { private let closedGroupProfileBack: Profile? private let closedGroupProfileBackFallback: Profile? public let closedGroupName: String? + private let closedGroupUserCount: Int? + public let currentUserIsClosedGroupMember: Bool? public let currentUserIsClosedGroupAdmin: Bool? public let openGroupName: String? + public let openGroupServer: String? + public let openGroupRoom: String? public let openGroupProfilePictureData: Data? + private let openGroupUserCount: Int? // Interaction display info @@ -135,6 +158,23 @@ extension ConversationCell { return Date(timeIntervalSince1970: (TimeInterval(interactionTimestampMs) / 1000)) } + public var enabledMessageTypes: MessageInputTypes { + guard !threadIsNoteToSelf else { return .all } + + return (threadRequiresApproval == false && threadIsMessageRequest == false ? + .all : + .textOnly + ) + } + + public var userCount: Int? { + switch threadVariant { + case .contact: return nil + case .closedGroup: return closedGroupUserCount + case .openGroup: return openGroupUserCount + } + } + /// This function returns the profile name formatted for the specific type of thread provided /// /// **Note:** The 'threadVariant' parameter is used for profile context but in the search results we actually want this @@ -166,14 +206,19 @@ public extension ConversationCell.ViewModel { self.threadMemberNames = nil self.threadIsNoteToSelf = false + self.threadIsMessageRequest = false + self.threadRequiresApproval = false + self.threadShouldBeVisible = false self.threadIsPinned = false self.threadIsBlocked = nil self.threadMutedUntilTimestamp = nil self.threadOnlyNotifyForMentions = nil + self.threadMessageDraft = nil self.threadContactIsTyping = nil self.threadUnreadCount = unreadCount self.threadUnreadMentionCount = nil + self.threadFirstUnreadInteractionId = nil // Thread display info @@ -182,9 +227,14 @@ public extension ConversationCell.ViewModel { self.closedGroupProfileBack = nil self.closedGroupProfileBackFallback = nil self.closedGroupName = nil + self.closedGroupUserCount = nil + self.currentUserIsClosedGroupMember = nil self.currentUserIsClosedGroupAdmin = nil self.openGroupName = nil + self.openGroupServer = nil + self.openGroupRoom = nil self.openGroupProfilePictureData = nil + self.openGroupUserCount = nil // Interaction display info @@ -221,7 +271,6 @@ public extension ConversationCell.ViewModel { let linkPreview: TypedTableAlias = TypedTableAlias() let interactionAttachment: TypedTableAlias = TypedTableAlias() let attachment: TypedTableAlias = TypedTableAlias() - let recipientState: TypedTableAlias = TypedTableAlias() let unreadCountTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.threadUnreadCountString)_table") let unreadMentionCountTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.threadUnreadMentionCountString)_table") @@ -240,7 +289,7 @@ public extension ConversationCell.ViewModel { let interactionStateStateColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.state.name) /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `ViewModel.closedGroupProfileFrontKey` entry below otherwise the query will fail to + /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to /// parse and might throw /// /// Explicitly set default values for the fields ignored for search results @@ -372,20 +421,10 @@ public extension ConversationCell.ViewModel { FROM \(Attachment.self) ) AS \(ViewModel.interactionAttachmentDescriptionInfoKey) ON \(ViewModel.interactionAttachmentDescriptionInfoKey).\(attachmentIdColumnLiteral) = \(firstInteractionAttachmentLiteral).\(interactionAttachmentAttachmentIdColumnLiteral) LEFT JOIN ( - SELECT * FROM ( - SELECT - \(recipientState[.interactionId]), - \(recipientState[.state]) - FROM \(RecipientState.self) - JOIN \(Interaction.self) ON \(interaction[.id]) = \(recipientState[.interactionId]) - WHERE \(SQL("\(recipientState[.state]) != \(RecipientState.State.skipped)")) -- Ignore 'skipped' - ORDER BY - -- If there is a single 'sending' then should be 'sending', otherwise if there is a single - -- 'failed' and there is no 'sending' then it should be 'failed' - \(SQL("\(recipientState[.state]) = \(RecipientState.State.sending)")) DESC, - \(SQL("\(recipientState[.state]) = \(RecipientState.State.failed)")) DESC - ) AS \(interactionStateTableLiteral) - GROUP BY \(interactionStateTableLiteral).\(interactionStateInteractionIdColumnLiteral) + \(RecipientState.selectInteractionState( + tableLiteral: interactionStateTableLiteral, + idColumnLiteral: interactionStateInteractionIdColumnLiteral + )) ) AS \(interactionStateTableLiteral) ON \(interactionStateTableLiteral).\(interactionStateInteractionIdColumnLiteral) = \(interaction[.id]) WHERE ( @@ -467,6 +506,153 @@ public extension ConversationCell.ViewModel { } } +// MARK: - ConversationVC + +public extension ConversationCell.ViewModel { + static func conversationQuery(threadId: String, userPublicKey: String) -> AdaptedFetchRequest> { + let thread: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() + let typingIndicator: TypedTableAlias = TypedTableAlias() + let closedGroup: TypedTableAlias = TypedTableAlias() + let groupMember: TypedTableAlias = TypedTableAlias() + let openGroup: TypedTableAlias = TypedTableAlias() + let interaction: TypedTableAlias = TypedTableAlias() + + let unreadCountTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.threadUnreadCountString)_table") + let unreadMentionCountTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.threadUnreadMentionCountString)_table") + let firstUnreadInteractionTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.threadFirstUnreadInteractionIdString)_table") + let interactionIdLiteral: SQL = SQL(stringLiteral: Interaction.Columns.id.name) + let interactionThreadIdLiteral: SQL = SQL(stringLiteral: Interaction.Columns.threadId.name) + let closedGroupUserCountTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.closedGroupUserCountString)_table") + let groupMemberGroupIdColumnLiteral: SQL = SQL(stringLiteral: GroupMember.Columns.groupId.name) + let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) + + /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before + /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to + /// parse and might throw + /// + /// Explicitly set default values for the fields ignored for search results + let numColumnsBeforeProfiles: Int = 16 + let request: SQLRequest = """ + SELECT + \(thread[.id]) AS \(ViewModel.threadIdKey), + \(thread[.variant]) AS \(ViewModel.threadVariantKey), + \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), + + (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.threadIsNoteToSelfKey), + ( + \(thread[.shouldBeVisible]) = true AND + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND + \(SQL("\(thread[.id]) != \(userPublicKey)")) AND ( + -- A '!= true' check doesn't work properly so we need to be explicit + \(contact[.isApproved]) IS NULL OR + \(contact[.isApproved]) = false + ) + ) AS \(ViewModel.threadIsMessageRequestKey), + ( + IFNULL(\(contact[.isApproved]), false) = false OR + IFNULL(\(contact[.didApproveMe]), false) = false + ) AS \(ViewModel.threadRequiresApprovalKey), + \(thread[.shouldBeVisible]) AS \(ViewModel.threadShouldBeVisibleKey), + + \(thread[.isPinned]) AS \(ViewModel.threadIsPinnedKey), + \(contact[.isBlocked]) AS \(ViewModel.threadIsBlockedKey), + \(thread[.mutedUntilTimestamp]) AS \(ViewModel.threadMutedUntilTimestampKey), + \(thread[.onlyNotifyForMentions]) AS \(ViewModel.threadOnlyNotifyForMentionsKey), + \(thread[.messageDraft]) AS \(ViewModel.threadMessageDraftKey), + + (\(typingIndicator[.threadId]) IS NOT NULL) AS \(ViewModel.threadContactIsTypingKey), + \(unreadCountTableLiteral).\(ViewModel.threadUnreadCountKey) AS \(ViewModel.threadUnreadCountKey), + \(unreadMentionCountTableLiteral).\(ViewModel.threadUnreadMentionCountKey) AS \(ViewModel.threadUnreadMentionCountKey), + \(firstUnreadInteractionTableLiteral).\(interactionIdLiteral) AS \(ViewModel.threadFirstUnreadInteractionIdKey), + + \(ViewModel.contactProfileKey).*, + \(closedGroup[.name]) AS \(ViewModel.closedGroupNameKey), + \(closedGroupUserCountTableLiteral).\(ViewModel.closedGroupUserCountKey) AS \(ViewModel.closedGroupUserCountKey), + (\(groupMember[.profileId]) IS NOT NULL) AS \(ViewModel.currentUserIsClosedGroupMemberKey), + \(openGroup[.name]) AS \(ViewModel.openGroupNameKey), + \(openGroup[.server]) AS \(ViewModel.openGroupServerKey), + \(openGroup[.room]) AS \(ViewModel.openGroupRoomKey), + \(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey), + \(openGroup[.userCount]) AS \(ViewModel.openGroupUserCountKey), + + \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) + + FROM \(SessionThread.self) + LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) + LEFT JOIN \(ThreadTypingIndicator.self) ON \(typingIndicator[.threadId]) = \(thread[.id]) + LEFT JOIN ( + SELECT + \(interaction[.id]), + \(interaction[.threadId]), + MIN(\(interaction[.timestampMs])) + FROM \(Interaction.self) + WHERE ( + \(interaction[.wasRead]) = false AND + \(SQL("\(interaction[.threadId]) = \(threadId)")) + ) + ) AS \(firstUnreadInteractionTableLiteral) ON \(firstUnreadInteractionTableLiteral).\(interactionThreadIdLiteral) = \(thread[.id]) + LEFT JOIN ( + SELECT + \(interaction[.threadId]), + COUNT(*) AS \(ViewModel.threadUnreadCountKey) + FROM \(Interaction.self) + WHERE ( + \(interaction[.wasRead]) = false AND + \(SQL("\(interaction[.threadId]) = \(threadId)")) + ) + GROUP BY \(interaction[.threadId]) + ) AS \(unreadCountTableLiteral) ON \(unreadCountTableLiteral).\(interactionThreadIdLiteral) = \(thread[.id]) + LEFT JOIN ( + SELECT + \(interaction[.threadId]), + COUNT(*) AS \(ViewModel.threadUnreadMentionCountKey) + FROM \(Interaction.self) + WHERE ( + \(interaction[.wasRead]) = false AND + \(interaction[.hasMention]) = true AND + \(SQL("\(interaction[.threadId]) = \(threadId)")) + ) + GROUP BY \(interaction[.threadId]) + ) AS \(unreadMentionCountTableLiteral) ON \(unreadMentionCountTableLiteral).\(interactionThreadIdLiteral) = \(thread[.id]) + + LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) + LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) + LEFT JOIN \(GroupMember.self) ON ( + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND + \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(SQL("\(groupMember[.profileId]) = \(userPublicKey)")) + ) + LEFT JOIN ( + SELECT + \(groupMember[.groupId]), + COUNT(*) AS \(ViewModel.closedGroupUserCountKey) + FROM \(GroupMember.self) + WHERE ( + \(SQL("\(groupMember[.groupId]) = \(threadId)")) AND + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) + ) + GROUP BY \(groupMember[.groupId]) + ) AS \(closedGroupUserCountTableLiteral) ON \(SQL("\(closedGroupUserCountTableLiteral).\(groupMemberGroupIdColumnLiteral) = \(threadId)")) + LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) + + WHERE \(SQL("\(thread[.id]) = \(threadId)")) + GROUP BY \(thread[.id]) + """ + + return request.adapted { db in + let adapters = try splittingRowAdapters(columnCounts: [ + numColumnsBeforeProfiles, + Profile.numberOfSelectedColumns(db) + ]) + + return ScopeAdapter([ + ViewModel.contactProfileString: adapters[1] + ]) + } + } +} + // MARK: - Search Queries public extension ConversationCell.ViewModel { @@ -524,7 +710,7 @@ public extension ConversationCell.ViewModel { let interactionFullTextSearch: SQL = SQL(stringLiteral: Interaction.fullTextSearchTableName) /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `ViewModel.closedGroupProfileFrontKey` entry below otherwise the query will fail to + /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to /// parse and might throw /// /// Explicitly set default values for the fields ignored for search results @@ -651,7 +837,7 @@ public extension ConversationCell.ViewModel { let searchTermLiteral: SQL = SQL(stringLiteral: searchTerm.lowercased()) /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `ViewModel.closedGroupProfileFrontKey` entry below otherwise the query will fail to + /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to /// parse and might throw /// /// We use `IFNULL(rank, 100)` because the custom `Note to Self` like comparison will get a null @@ -1005,7 +1191,7 @@ public extension ConversationCell.ViewModel { let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `ViewModel.closedGroupProfileFrontKey` entry below otherwise the query will fail to + /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to /// parse and might throw /// /// Explicitly set default values for the fields ignored for search results @@ -1018,6 +1204,7 @@ public extension ConversationCell.ViewModel { \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.threadIsNoteToSelfKey), + \(thread[.isPinned]) AS \(ViewModel.threadIsPinnedKey), \(contact[.isBlocked]) AS \(ViewModel.threadIsBlockedKey), diff --git a/SessionMessagingKit/Shared Models/MessageInputTypes.swift b/SessionMessagingKit/Shared Models/MessageInputTypes.swift new file mode 100644 index 000000000..3e5769615 --- /dev/null +++ b/SessionMessagingKit/Shared Models/MessageInputTypes.swift @@ -0,0 +1,9 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum MessageInputTypes: Equatable { + case all + case textOnly + case none +} diff --git a/SessionUtilitiesKit/Database/GRDBStorage.swift b/SessionUtilitiesKit/Database/GRDBStorage.swift index 8c859e727..30bfdddeb 100644 --- a/SessionUtilitiesKit/Database/GRDBStorage.swift +++ b/SessionUtilitiesKit/Database/GRDBStorage.swift @@ -270,7 +270,9 @@ public final class GRDBStorage { ) } - public func addObserver(_ observer: TransactionObserver) { + public func addObserver(_ observer: TransactionObserver?) { + guard let observer: TransactionObserver = observer else { return } + dbPool.add(transactionObserver: observer) } } diff --git a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift new file mode 100644 index 000000000..46fa83059 --- /dev/null +++ b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift @@ -0,0 +1,1058 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +// MARK: - PagedDatabaseObserver + +/// This type manages observation and paging for the provided dataQuery +/// +/// **Note:** We **MUST** have accurate `filterSQL` and `orderSQL` values otherwise the indexing won't work +public class PagedDatabaseObserver: TransactionObserver where ObservedTable: TableRecord & ColumnExpressible & Identifiable, T: FetchableRecordWithRowId & Identifiable { + // MARK: - Variables + + private let pagedTableName: String + private let idColumnName: String + private var pageInfo: Atomic + + private let allObservedTableNames: Set + private let observedInserts: Set + private let observedUpdateColumns: [String: Set] + private let observedDeletes: Set + + private let joinSQL: SQL? + private let filterSQL: SQL + private let orderSQL: SQL + private let dataQuery: (SQL?, SQL?) -> AdaptedFetchRequest> + private let associatedRecords: [ErasedAssociatedRecord] + + private var dataCache: Atomic> = Atomic(DataCache()) + private var isLoadingMoreData: Atomic = Atomic(false) + private let changesInCommit: Atomic> = Atomic([]) + private let onChangeUnsorted: (([T], PagedData.PageInfo) -> ()) + + // MARK: - Initialization + + fileprivate init( + pagedTable: ObservedTable.Type, + pageSize: Int, + idColumn: ObservedTable.Columns, + observedChanges: [PagedData.ObservedChanges], + joinSQL: SQL? = nil, + filterSQL: SQL, + orderSQL: SQL, + dataQuery: @escaping (SQL?, SQL?) -> AdaptedFetchRequest>, + associatedRecords: [ErasedAssociatedRecord] = [], + onChangeUnsorted: @escaping ([T], PagedData.PageInfo) -> (), + initialQueryTarget: PagedData.PageInfo.InternalTarget? + ) { + let associatedTables: Set = associatedRecords.map { $0.databaseTableName }.asSet() + assert(!associatedTables.contains(pagedTable.databaseTableName), "The paged table cannot also exist as an associatedRecord") + + self.pagedTableName = pagedTable.databaseTableName + self.idColumnName = idColumn.name + self.pageInfo = Atomic(PagedData.PageInfo(pageSize: pageSize)) + self.joinSQL = joinSQL + self.filterSQL = filterSQL + self.orderSQL = orderSQL + self.dataQuery = dataQuery + self.associatedRecords = associatedRecords + self.onChangeUnsorted = onChangeUnsorted + + // Combine the various observed changes into a single set + let allObservedChanges: [PagedData.ObservedChanges] = observedChanges + .appending(contentsOf: associatedRecords.flatMap { $0.observedChanges }) + self.allObservedTableNames = allObservedChanges + .map { $0.databaseTableName } + .asSet() + self.observedInserts = allObservedChanges + .filter { $0.events.contains(.insert) } + .map { $0.databaseTableName } + .asSet() + self.observedUpdateColumns = allObservedChanges + .filter { $0.events.contains(.update) } + .reduce(into: [:]) { (prev: inout [String: Set], next: PagedData.ObservedChanges) in + guard !next.columns.isEmpty else { return } + + prev[next.databaseTableName] = next.columns.asSet() + } + self.observedDeletes = allObservedChanges + .filter { $0.events.contains(.delete) } + .map { $0.databaseTableName } + .asSet() + + // Run the initial query if there is one + guard let initialQueryTarget: PagedData.PageInfo.InternalTarget = initialQueryTarget else { return } + + self.load(initialQueryTarget) + } + + // MARK: - TransactionObserver + + public func observes(eventsOfKind eventKind: DatabaseEventKind) -> Bool { + switch eventKind { + case .insert(let tableName): return self.observedInserts.contains(tableName) + case .delete(let tableName): return self.observedDeletes.contains(tableName) + + case .update(let tableName, let columnNames): + return (self.observedUpdateColumns[tableName]? + .intersection(columnNames) + .isEmpty == false) + } + } + + public func databaseDidChange(with event: DatabaseEvent) { + // This will get called whenever the `observes(eventsOfKind:)` returns + // true and will include all changes which occurred in the commit so we + // need to ignore any non-observed tables, unfortunately we also won't + // know if the changes to observed tables are actually relevant yet as + // changes only include table and column info at this stage + guard allObservedTableNames.contains(event.tableName) else { return } + + // The 'event' object only exists during this method so we need to copy the info + // from it, otherwise it will cease to exist after this metod call finishes + changesInCommit.mutate { $0.insert(PagedData.TrackedChange(event: event)) } + } + + // Note: We will process all updates which come through this method even if + // 'onChange' is null because if the UI stops observing and then starts again + // later we don't want to have missed any changes which happened while the UI + // wasn't subscribed (and doing a full re-query seems painful...) + public func databaseDidCommit(_ db: Database) { + var committedChanges: Set = [] + self.changesInCommit.mutate { cachedChanges in + committedChanges = cachedChanges + cachedChanges.removeAll() + } + + // Note: This method will be called regardless of whether there were actually changes + // in the areas we are observing so we want to early-out if there aren't any relevant + // updated rows + guard !committedChanges.isEmpty else { return } + + let orderSQL: SQL = self.orderSQL + let filterSQL: SQL = self.filterSQL + let associatedRecords: [ErasedAssociatedRecord] = self.associatedRecords + + let updateDataAndCallbackIfNeeded: (DataCache, PagedData.PageInfo, Bool) -> () = { [weak self] updatedDataCache, updatedPageInfo, cacheHasChanges in + let associatedDataInfo: [(hasChanges: Bool, data: ErasedAssociatedRecord)] = associatedRecords + .map { associatedRecord in + let hasChanges: Bool = associatedRecord.tryUpdateForDatabaseCommit( + db, + changes: committedChanges, + orderSQL: orderSQL, + filterSQL: filterSQL, + pageInfo: updatedPageInfo + ) + + return (hasChanges, associatedRecord) + } + + // Check if we need to trigger a change callback + guard cacheHasChanges || associatedDataInfo.contains(where: { hasChanges, _ in hasChanges }) else { + return + } + + // If the associated data changed then update the updatedCachedData with the + // updated associated data + var finalUpdatedDataCache: DataCache = updatedDataCache + + associatedDataInfo.forEach { hasChanges, associatedData in + guard cacheHasChanges || hasChanges else { return } + + finalUpdatedDataCache = associatedData.attachAssociatedData(to: finalUpdatedDataCache) + } + + // Update the cache, pageInfo and the change callback + self?.dataCache.mutate { $0 = finalUpdatedDataCache } + self?.pageInfo.mutate { $0 = updatedPageInfo } + + DispatchQueue.main.async { [weak self] in + self?.onChangeUnsorted(finalUpdatedDataCache.values, updatedPageInfo) + } + } + + // Determing if there were any relevant paged data changes + let relevantChanges: Set = committedChanges + .filter { $0.tableName == pagedTableName } + + guard !relevantChanges.isEmpty else { + updateDataAndCallbackIfNeeded(self.dataCache.wrappedValue, self.pageInfo.wrappedValue, false) + return + } + + var updatedPageInfo: PagedData.PageInfo = self.pageInfo.wrappedValue + var updatedDataCache: DataCache = self.dataCache.wrappedValue + let deletionChanges: [Int64] = relevantChanges + .filter { $0.kind == .delete } + .map { $0.rowId } + let oldDataCount: Int = dataCache.wrappedValue.count + + // First remove any items which have been deleted + if !deletionChanges.isEmpty { + updatedDataCache = updatedDataCache.deleting(rowIds: deletionChanges) + + // Make sure there were actually changes + if updatedDataCache.count != oldDataCount { + let dataSizeDiff: Int = (updatedDataCache.count - oldDataCount) + + updatedPageInfo = PagedData.PageInfo( + pageSize: updatedPageInfo.pageSize, + pageOffset: updatedPageInfo.pageOffset, + currentCount: (updatedPageInfo.currentCount + dataSizeDiff), + totalCount: (updatedPageInfo.totalCount + dataSizeDiff) + ) + } + } + + // If there are no inserted/updated rows then trigger the update callback and stop here + let rowIdsToQuery: [Int64] = committedChanges + .filter { $0.kind != .delete } + .map { $0.rowId } + + guard !rowIdsToQuery.isEmpty else { + updateDataAndCallbackIfNeeded(updatedDataCache, updatedPageInfo, !deletionChanges.isEmpty) + return + } + + // Fetch the indexes of the rowIds so we can determine whether they should be added to the screen + let itemIndexes: [Int64] = PagedData.indexes( + db, + rowIds: rowIdsToQuery, + tableName: pagedTableName, + orderSQL: orderSQL, + filterSQL: filterSQL + ) + + // Determine if the indexes for the row ids should be displayed on the screen and remove any + // which shouldn't - values less than 'currentCount' or if there is at least one value less than + // 'currentCount' and the indexes are sequential (ie. more than the current loaded content was + // added at once) + let itemIndexesAreSequential: Bool = (itemIndexes.map { $0 - 1 }.dropFirst() == itemIndexes.dropLast()) + let hasOneValidIndex: Bool = itemIndexes.contains(where: { $0 < updatedPageInfo.currentCount }) + let validRowIds: [Int64] = (itemIndexesAreSequential && hasOneValidIndex ? + rowIdsToQuery : + zip(itemIndexes, rowIdsToQuery) + .filter { index, _ -> Bool in index < updatedPageInfo.currentCount } + .map { _, rowId -> Int64 in rowId } + ) + + // If there are no valid attachment row ids then stop here + guard !validRowIds.isEmpty else { + updateDataAndCallbackIfNeeded(updatedDataCache, updatedPageInfo, !deletionChanges.isEmpty) + return + } + + // Fetch the inserted/updated rows + let additionalFilters: SQL = SQL(validRowIds.contains(Column.rowID)) + let updatedItems: [T] = (try? dataQuery(additionalFilters, nil) + .fetchAll(db)) + .defaulting(to: []) + + // If the inserted/updated rows we irrelevant (associated to data which doesn't pass + // the filter) then trigger the update callback (if there were deletions) and stop here + guard !updatedItems.isEmpty else { + updateDataAndCallbackIfNeeded(updatedDataCache, updatedPageInfo, !deletionChanges.isEmpty) + return + } + + // Process the upserted data + updatedDataCache = updatedDataCache.upserting(items: updatedItems) + + // Update the page info for the upserted data + let dataSizeDiff: Int = (updatedDataCache.count - oldDataCount) + + updatedPageInfo = PagedData.PageInfo( + pageSize: updatedPageInfo.pageSize, + pageOffset: updatedPageInfo.pageOffset, + currentCount: (updatedPageInfo.currentCount + dataSizeDiff), + totalCount: (updatedPageInfo.totalCount + dataSizeDiff) + ) + + updateDataAndCallbackIfNeeded(updatedDataCache, updatedPageInfo, true) + } + + public func databaseDidRollback(_ db: Database) {} + + // MARK: - Functions + + fileprivate func load(_ target: PagedData.PageInfo.InternalTarget) { + // Only allow a single page load at a time + guard !self.isLoadingMoreData.wrappedValue else { return } + + // Prevent more fetching until we have completed adding the page + self.isLoadingMoreData.mutate { $0 = true } + + let currentPageInfo: PagedData.PageInfo = self.pageInfo.wrappedValue + + if case .initialPageAround(_) = target, currentPageInfo.currentCount > 0 { + SNLog("Unable to load initialPageAround if there is already data") + return + } + + // Store locally to avoid giant capture code + let pagedTableName: String = self.pagedTableName + let idColumnName: String = self.idColumnName + let joinSQL: SQL? = self.joinSQL + let filterSQL: SQL = self.filterSQL + let orderSQL: SQL = self.orderSQL + let dataQuery: (SQL?, SQL?) -> AdaptedFetchRequest> = self.dataQuery + + let loadedPage: (data: [T]?, pageInfo: PagedData.PageInfo)? = GRDBStorage.shared.read { [weak self] db in + let totalCount: Int = try dataQuery(filterSQL, nil) + .fetchCount(db) + let queryInfo: (limit: Int, offset: Int, updatedCacheOffset: Int)? = { + switch target { + case .initialPageAround(let targetId): + // If we want to focus on a specific item then we need to find it's index in + // the queried data + let maybeIndex: Int? = PagedData.index( + db, + for: targetId, + tableName: pagedTableName, + idColumn: idColumnName, + requiredJoinSQL: joinSQL, + orderSQL: orderSQL, + filterSQL: filterSQL + ) + + // If we couldn't find the targetId then just load the first page + guard let targetIndex: Int = maybeIndex else { + return (currentPageInfo.pageSize, 0, 0) + } + + let updatedOffset: Int = { + // If the focused item is within the first or last half of the page + // then we still want to retrieve a full page so calculate the offset + // needed to do so (snapping to the ends) + let halfPageSize: Int = Int(floor(Double(currentPageInfo.pageSize) / 2)) + + guard targetIndex > halfPageSize else { return 0 } + guard targetIndex < (totalCount - halfPageSize) else { + return (totalCount - currentPageInfo.pageSize) + } + + return (targetIndex - halfPageSize) + }() + + return (currentPageInfo.pageSize, updatedOffset, updatedOffset) + + case .pageBefore: + let updatedOffset: Int = max(0, (currentPageInfo.pageOffset - currentPageInfo.pageSize)) + + return ( + currentPageInfo.pageSize, + updatedOffset, + updatedOffset + ) + + case .pageAfter: + return ( + currentPageInfo.pageSize, + (currentPageInfo.pageOffset + currentPageInfo.currentCount), + currentPageInfo.pageOffset + ) + + case .untilInclusive(let targetId, let padding): + // If we want to focus on a specific item then we need to find it's index in + // the queried data + let maybeIndex: Int? = PagedData.index( + db, + for: targetId, + tableName: pagedTableName, + idColumn: idColumnName, + orderSQL: orderSQL, + filterSQL: filterSQL + ) + let cacheCurrentEndIndex: Int = (currentPageInfo.pageOffset + currentPageInfo.currentCount) + + // If we couldn't find the targetId or it's already in the cache then do nothing + guard + let targetIndex: Int = maybeIndex.map({ max(0, min(totalCount, $0)) }), + ( + targetIndex < currentPageInfo.pageOffset || + targetIndex > cacheCurrentEndIndex + ) + else { return nil } + + // If the target is before the cached data then load before + if targetIndex < currentPageInfo.pageOffset { + let finalIndex: Int = max(0, (targetIndex - abs(padding))) + + return ( + (currentPageInfo.pageOffset - finalIndex), + finalIndex, + finalIndex + ) + } + + // Otherwise load after + let finalIndex: Int = min(totalCount, (targetIndex + abs(padding))) + + return ( + (finalIndex - cacheCurrentEndIndex), + cacheCurrentEndIndex, + currentPageInfo.pageOffset + ) + } + }() + + // If there is no queryOffset then we already have the data we need so + // early-out (may as well update the 'totalCount' since it may be relevant) + guard let queryInfo: (limit: Int, offset: Int, updatedCacheOffset: Int) = queryInfo else { + return ( + nil, + PagedData.PageInfo( + pageSize: currentPageInfo.pageSize, + pageOffset: currentPageInfo.pageOffset, + currentCount: currentPageInfo.currentCount, + totalCount: totalCount + ) + ) + } + + // Fetch the desired data + let limitSQL: SQL = SQL(stringLiteral: "LIMIT \(queryInfo.limit) OFFSET \(queryInfo.offset)") + let newData: [T] = try dataQuery(filterSQL, limitSQL) + .fetchAll(db) + let updatedLimitInfo: PagedData.PageInfo = PagedData.PageInfo( + pageSize: currentPageInfo.pageSize, + pageOffset: queryInfo.updatedCacheOffset, + currentCount: (currentPageInfo.currentCount + newData.count), + totalCount: totalCount + ) + + // Update the associatedRecords for the newly retrieved data + self?.associatedRecords.forEach { record in + record.updateCache( + db, + rowIds: PagedData.associatedRowIds( + db, + tableName: record.databaseTableName, + pagedTableName: pagedTableName, + pagedTypeRowIds: newData.map { $0.rowId }, + joinToPagedType: record.joinToPagedType + ), + hasOtherChanges: false + ) + } + + return (newData, updatedLimitInfo) + } + + // Unwrap the updated data + guard + let loadedPageData: [T] = loadedPage?.data, + let updatedPageInfo: PagedData.PageInfo = loadedPage?.pageInfo + else { + // It's possible to get updated page info without having updated data, in that case + // we do want to update the cache but probably don't need to trigger the change callback + if let updatedPageInfo: PagedData.PageInfo = loadedPage?.pageInfo { + self.pageInfo.mutate { $0 = updatedPageInfo } + } + return + } + + // Attach any associated data to the loadedPageData + var associatedLoadedData: DataCache = DataCache(items: loadedPageData) + + self.associatedRecords.forEach { record in + associatedLoadedData = record.attachAssociatedData(to: associatedLoadedData) + } + + // Update the cache and pageInfo + self.dataCache.mutate { $0 = $0.upserting(items: associatedLoadedData.values) } + self.pageInfo.mutate { $0 = updatedPageInfo } + + let triggerUpdates: () -> () = { [weak self, dataCache = self.dataCache.wrappedValue] in + self?.onChangeUnsorted(dataCache.values, updatedPageInfo) + self?.isLoadingMoreData.mutate { $0 = false } + } + + // Make sure the updates run on the main thread + guard Thread.isMainThread else { + DispatchQueue.main.async { triggerUpdates() } + return + } + + triggerUpdates() + } +} + +// MARK: - Convenience + +public extension PagedDatabaseObserver { + fileprivate static func initialQueryTarget( + for initialFocusedId: ID?, + skipInitialQuery: Bool + ) -> PagedData.PageInfo.InternalTarget? { + // Determine if we want to laod the first page immediately (this is generally needed + // to prevent transitions from looking buggy) + guard !skipInitialQuery else { return nil } + + switch initialFocusedId { + case .some(let targetId): return .initialPageAround(id: targetId.sqlExpression) + + // If we don't have a `initialFocusedId` then default to `.pageBefore` (it'll query + // from a `0` offset + case .none: return .pageBefore + } + } + + convenience init( + pagedTable: ObservedTable.Type, + pageSize: Int, + idColumn: ObservedTable.Columns, + initialFocusedId: ObservedTable.ID? = nil, + observedChanges: [PagedData.ObservedChanges], + joinSQL: SQL? = nil, + filterSQL: SQL, + orderSQL: SQL, + dataQuery: @escaping (SQL?, SQL?) -> AdaptedFetchRequest>, + associatedRecords: [ErasedAssociatedRecord] = [], + onChangeUnsorted: @escaping ([T], PagedData.PageInfo) -> (), + skipInitialQuery: Bool = false + ) where ObservedTable.ID: SQLExpressible { + self.init( + pagedTable: pagedTable, + pageSize: pageSize, + idColumn: idColumn, + observedChanges: observedChanges, + joinSQL: joinSQL, + filterSQL: filterSQL, + orderSQL: orderSQL, + dataQuery: dataQuery, + associatedRecords: associatedRecords, + onChangeUnsorted: onChangeUnsorted, + initialQueryTarget: PagedDatabaseObserver.initialQueryTarget( + for: initialFocusedId, + skipInitialQuery: skipInitialQuery + ) + ) + } + + convenience init( + pagedTable: ObservedTable.Type, + pageSize: Int, + idColumn: ObservedTable.Columns, + initialFocusedId: ObservedTable.ID? = nil, + observedChanges: [PagedData.ObservedChanges], + joinSQL: SQL? = nil, + filterSQL: SQL, + orderSQL: SQL, + dataQuery: @escaping (SQL?, SQL?) -> SQLRequest, + associatedRecords: [ErasedAssociatedRecord] = [], + onChangeUnsorted: @escaping ([T], PagedData.PageInfo) -> (), + skipInitialQuery: Bool = false + ) where ObservedTable.ID: SQLExpressible { + self.init( + pagedTable: pagedTable, + pageSize: pageSize, + idColumn: idColumn, + observedChanges: observedChanges, + joinSQL: joinSQL, + filterSQL: filterSQL, + orderSQL: orderSQL, + dataQuery: { additionalFilters, limit in + dataQuery(additionalFilters, limit).adapted { _ in ScopeAdapter([:]) } + }, + associatedRecords: associatedRecords, + onChangeUnsorted: onChangeUnsorted, + initialQueryTarget: PagedDatabaseObserver.initialQueryTarget( + for: initialFocusedId, + skipInitialQuery: skipInitialQuery + ) + ) + } + + convenience init( + pagedTable: ObservedTable.Type, + pageSize: Int, + idColumn: ObservedTable.Columns, + initialFocusedId: ID? = nil, + observedChanges: [PagedData.ObservedChanges], + joinSQL: SQL? = nil, + filterSQL: SQL, + orderSQL: SQL, + dataQuery: @escaping (SQL?, SQL?) -> AdaptedFetchRequest>, + associatedRecords: [ErasedAssociatedRecord] = [], + onChangeUnsorted: @escaping ([T], PagedData.PageInfo) -> (), + skipInitialQuery: Bool = false + ) where ObservedTable.ID == Optional, ID: SQLExpressible { + self.init( + pagedTable: pagedTable, + pageSize: pageSize, + idColumn: idColumn, + observedChanges: observedChanges, + joinSQL: joinSQL, + filterSQL: filterSQL, + orderSQL: orderSQL, + dataQuery: dataQuery, + associatedRecords: associatedRecords, + onChangeUnsorted: onChangeUnsorted, + initialQueryTarget: PagedDatabaseObserver.initialQueryTarget( + for: initialFocusedId, + skipInitialQuery: skipInitialQuery + ) + ) + } + + convenience init( + pagedTable: ObservedTable.Type, + pageSize: Int, + idColumn: ObservedTable.Columns, + initialFocusedId: ID? = nil, + observedChanges: [PagedData.ObservedChanges], + joinSQL: SQL? = nil, + filterSQL: SQL, + orderSQL: SQL, + dataQuery: @escaping (SQL?, SQL?) -> SQLRequest, + associatedRecords: [ErasedAssociatedRecord] = [], + onChangeUnsorted: @escaping ([T], PagedData.PageInfo) -> (), + skipInitialQuery: Bool = false + ) where ObservedTable.ID == Optional, ID: SQLExpressible { + self.init( + pagedTable: pagedTable, + pageSize: pageSize, + idColumn: idColumn, + observedChanges: observedChanges, + joinSQL: joinSQL, + filterSQL: filterSQL, + orderSQL: orderSQL, + dataQuery: { additionalFilters, limit in + dataQuery(additionalFilters, limit).adapted { _ in ScopeAdapter([:]) } + }, + associatedRecords: associatedRecords, + onChangeUnsorted: onChangeUnsorted, + initialQueryTarget: PagedDatabaseObserver.initialQueryTarget( + for: initialFocusedId, + skipInitialQuery: skipInitialQuery + ) + ) + } + + func load(_ target: PagedData.PageInfo.Target) where ObservedTable.ID: SQLExpressible { + self.load(target.internalTarget) + } + + func load(_ target: PagedData.PageInfo.Target) where ObservedTable.ID == Optional, ID: SQLExpressible { + self.load(target.internalTarget) + } +} + +// MARK: - FetchableRecordWithRowId + +public protocol FetchableRecordWithRowId: FetchableRecord { + var rowId: Int64 { get } +} + +// MARK: - ErasedAssociatedRecord + +public protocol ErasedAssociatedRecord { + var databaseTableName: String { get } + var observedChanges: [PagedData.ObservedChanges] { get } + var joinToPagedType: SQL { get } + + func tryUpdateForDatabaseCommit( + _ db: Database, + changes: Set, + orderSQL: SQL, + filterSQL: SQL, + pageInfo: PagedData.PageInfo + ) -> Bool + @discardableResult func updateCache(_ db: Database, rowIds: [Int64], hasOtherChanges: Bool) -> Bool + func attachAssociatedData(to unassociatedCache: DataCache) -> DataCache +} + +// MARK: - DataCache + +public struct DataCache { + /// This is a map of `[RowId: Value]` + public let data: [Int64: T] + + /// This is a map of `[(Identifiable)id: RowId]` and can be used to find the RowId for + /// a cached value given it's `Identifiable` `id` value + public let lookup: [AnyHashable: Int64] + + public var count: Int { data.count } + public var values: [T] { Array(data.values) } + + // MARK: - Initialization + + public init( + data: [Int64: T] = [:], + lookup: [AnyHashable: Int64] = [:] + ) { + self.data = data + self.lookup = lookup + } + + fileprivate init(items: [T]) { + self = DataCache().upserting(items: items) + } + + // MARK: - Functions + + public func deleting(rowIds: [Int64]) -> DataCache { + var updatedData: [Int64: T] = self.data + var updatedLookup: [AnyHashable: Int64] = self.lookup + + rowIds.forEach { rowId in + if let cachedItem: T = updatedData.removeValue(forKey: rowId) { + updatedLookup.removeValue(forKey: cachedItem.id) + } + } + + return DataCache( + data: updatedData, + lookup: updatedLookup + ) + } + + public func upserting(_ item: T) -> DataCache { + return upserting(items: [item]) + } + + public func upserting(items: [T]) -> DataCache { + var updatedData: [Int64: T] = self.data + var updatedLookup: [AnyHashable: Int64] = self.lookup + + items.forEach { item in + updatedData[item.rowId] = item + updatedLookup[item.id] = item.rowId + } + + return DataCache( + data: updatedData, + lookup: updatedLookup + ) + } +} + +// MARK: - PagedData + +public enum PagedData { + // MARK: - PageInfo + + public struct PageInfo { + /// This type is identical to the 'Target' type but has it's 'SQLExpressible' requirement removed + fileprivate enum InternalTarget { + case initialPageAround(id: SQLExpression) + case pageBefore + case pageAfter + case untilInclusive(id: SQLExpression, padding: Int) + } + + public enum Target { + /// This will attempt to load a page of data around a specified id + /// + /// **Note:** This target will only work if there is no other data in the cache + case initialPageAround(id: ID) + + /// This will attempt to load a page of data before the first item in the cache + case pageBefore + + /// This will attempt to load a page of data after the last item in the cache + case pageAfter + + /// This will attempt to load all data between what is currently in the cache until the + /// specified id (plus the padding amount) + /// + /// **Note:** If the id is already within the cache then this will do nothing (even if + /// the padding would mean more data should be loaded) + case untilInclusive(id: ID, padding: Int) + + fileprivate var internalTarget: InternalTarget { + switch self { + case .initialPageAround(let id): return .initialPageAround(id: id.sqlExpression) + case .pageBefore: return .pageBefore + case .pageAfter: return .pageAfter + case .untilInclusive(let id, let padding): + return .untilInclusive(id: id.sqlExpression, padding: padding) + } + } + } + + public let pageSize: Int + public let pageOffset: Int + public let currentCount: Int + public let totalCount: Int + + // MARK: - Initizliation + + public init( + pageSize: Int, + pageOffset: Int = 0, + currentCount: Int = 0, + totalCount: Int = 0 + ) { + self.pageSize = pageSize + self.pageOffset = pageOffset + self.currentCount = currentCount + self.totalCount = totalCount + } + } + + // MARK: - ObservedChanges + + /// This type contains the information needed to define what changes should be included when observing + /// changes to a database + /// + /// - Parameters: + /// - table: The table whose changes should be observed + /// - events: The database events which should be observed + /// - columns: The specific columns which should trigger changes (**Note:** These only apply to `update` changes) + public struct ObservedChanges { + public let databaseTableName: String + public let events: [DatabaseEvent.Kind] + public let columns: [String] + + public init( + table: T.Type, + events: [DatabaseEvent.Kind] = [.insert, .update, .delete], + columns: [T.Columns] + ) { + self.databaseTableName = table.databaseTableName + self.events = events + self.columns = columns.map { $0.name } + } + } + + // MARK: - TrackedChange + + public struct TrackedChange: Hashable { + let tableName: String + let kind: DatabaseEvent.Kind + let rowId: Int64 + + init(event: DatabaseEvent) { + self.tableName = event.tableName + self.kind = event.kind + self.rowId = event.rowID + } + } + + // MARK: - Internal Functions + + fileprivate static func index( + _ db: Database, + for id: ID, + tableName: String, + idColumn: String, + requiredJoinSQL: SQL? = nil, + orderSQL: SQL, + filterSQL: SQL, + joinToPagedType: SQL? = nil + ) -> Int? { + let tableNameLiteral: SQL = SQL(stringLiteral: tableName) + let idColumnLiteral: SQL = SQL(stringLiteral: idColumn) + let request: SQLRequest = """ + SELECT + (data.rowIndex - 1) AS rowIndex -- Converting from 1-Indexed to 0-indexed + FROM ( + SELECT + \(tableNameLiteral).\(idColumnLiteral) AS \(idColumnLiteral), + ROW_NUMBER() OVER (ORDER BY \(orderSQL)) AS rowIndex + FROM \(tableNameLiteral) + \(requiredJoinSQL ?? "") + \(joinToPagedType ?? "") + WHERE \(filterSQL) + ) AS data + WHERE \(SQL("data.\(idColumnLiteral) = \(id)")) + """ + + return try? request.fetchOne(db) + } + + /// Returns the indexes the requested rowIds will have in the paged query + /// + /// **Note:** If the `associatedRecord` is null then the index for the rowId of the paged data type will be returned + fileprivate static func indexes( + _ db: Database, + rowIds: [Int64], + tableName: String, + requiredJoinSQL: SQL? = nil, + orderSQL: SQL, + filterSQL: SQL, + joinToPagedType: SQL? = nil + ) -> [Int64] { + let tableNameLiteral: SQL = SQL(stringLiteral: tableName) + let request: SQLRequest = """ + SELECT + (data.rowIndex - 1) AS rowIndex -- Converting from 1-Indexed to 0-indexed + FROM ( + SELECT + \(tableNameLiteral).rowid AS rowid, + ROW_NUMBER() OVER (ORDER BY \(orderSQL)) AS rowIndex + FROM \(tableNameLiteral) + \(requiredJoinSQL ?? "") + \(joinToPagedType ?? "") + WHERE \(filterSQL) + ) AS data + WHERE \(SQL("data.rowid IN \(rowIds)")) + """ + + return (try? request.fetchAll(db)) + .defaulting(to: []) + } + + /// Returns the rowIds for the associated types based on the specified pagedTypeRowIds + fileprivate static func associatedRowIds( + _ db: Database, + tableName: String, + pagedTableName: String, + pagedTypeRowIds: [Int64], + joinToPagedType: SQL + ) -> [Int64] { + let tableNameLiteral: SQL = SQL(stringLiteral: tableName) + let pagedTableNameLiteral: SQL = SQL(stringLiteral: pagedTableName) + let request: SQLRequest = """ + SELECT \(tableNameLiteral).rowid AS rowid + FROM \(tableNameLiteral) + \(joinToPagedType) + WHERE \(pagedTableNameLiteral).rowId IN \(pagedTypeRowIds) + """ + + return (try? request.fetchAll(db)) + .defaulting(to: []) + } +} + +// MARK: - AssociatedRecord + +public class AssociatedRecord: ErasedAssociatedRecord where T: FetchableRecordWithRowId & Identifiable, PagedType: FetchableRecordWithRowId & Identifiable { + public let databaseTableName: String + public let observedChanges: [PagedData.ObservedChanges] + public let joinToPagedType: SQL + + fileprivate let dataCache: Atomic> = Atomic(DataCache()) + fileprivate let dataQuery: (SQL?) -> AdaptedFetchRequest> + fileprivate let associateData: (DataCache, DataCache) -> DataCache + + // MARK: - Initialization + + public init( + trackedAgainst: Table.Type, + observedChanges: [PagedData.ObservedChanges], + dataQuery: @escaping (SQL?) -> AdaptedFetchRequest>, + joinToPagedType: SQL, + associateData: @escaping (DataCache, DataCache) -> DataCache + ) { + self.databaseTableName = trackedAgainst.databaseTableName + self.observedChanges = observedChanges + self.dataQuery = dataQuery + self.joinToPagedType = joinToPagedType + self.associateData = associateData + } + + convenience init( + trackedAgainst: Table.Type, + observedChanges: [PagedData.ObservedChanges], + dataQuery: @escaping (SQL?) -> SQLRequest, + joinToPagedType: SQL, + associateData: @escaping (DataCache, DataCache) -> DataCache + ) { + self.init( + trackedAgainst: trackedAgainst, + observedChanges: observedChanges, + dataQuery: { additionalFilters in + dataQuery(additionalFilters).adapted { _ in ScopeAdapter([:]) } + }, + joinToPagedType: joinToPagedType, + associateData: associateData + ) + } + + // MARK: - AssociatedRecord + + public func tryUpdateForDatabaseCommit( + _ db: Database, + changes: Set, + orderSQL: SQL, + filterSQL: SQL, + pageInfo: PagedData.PageInfo + ) -> Bool { + // Ignore any changes which aren't relevant to this type + let relevantChanges: Set = changes + .filter { $0.tableName == databaseTableName } + + guard !relevantChanges.isEmpty else { return false } + + // First remove any items which have been deleted + let oldCount: Int = self.dataCache.wrappedValue.count + let deletionChanges: [Int64] = relevantChanges + .filter { $0.kind == .delete } + .map { $0.rowId } + + dataCache.mutate { $0 = $0.deleting(rowIds: deletionChanges) } + + // Get an updated count to avoid locking the dataCache unnecessarily + let countAfterDeletions: Int = self.dataCache.wrappedValue.count + + // If there are no inserted/updated rows then trigger the update callback and stop here + let rowIdsToQuery: [Int64] = relevantChanges + .filter { $0.kind != .delete } + .map { $0.rowId } + + guard !rowIdsToQuery.isEmpty else { return (oldCount != countAfterDeletions) } + + // Fetch the indexes of the rowIds so we can determine whether they should be added to the screen + let itemIndexes: [Int64] = PagedData.indexes( + db, + rowIds: rowIdsToQuery, + tableName: databaseTableName, + orderSQL: orderSQL, + filterSQL: filterSQL, + joinToPagedType: joinToPagedType + ) + + // Determine if the indexes for the row ids should be displayed on the screen and remove any + // which shouldn't - values less than 'currentCount' or if there is at least one value less than + // 'currentCount' and the indexes are sequential (ie. more than the current loaded content was + // added at once) + let itemIndexesAreSequential: Bool = (itemIndexes.map { $0 - 1 }.dropFirst() == itemIndexes.dropLast()) + let hasOneValidIndex: Bool = itemIndexes.contains(where: { $0 < pageInfo.currentCount }) + let validRowIds: [Int64] = (itemIndexesAreSequential && hasOneValidIndex ? + itemIndexes : + zip(itemIndexes, rowIdsToQuery) + .filter { index, _ -> Bool in index < pageInfo.currentCount } + .map { _, rowId -> Int64 in rowId } + ) + + // Attempt to update the cache with the `validRowIds` array + return updateCache( + db, + rowIds: validRowIds, + hasOtherChanges: (oldCount != countAfterDeletions) + ) + } + + @discardableResult public func updateCache(_ db: Database, rowIds: [Int64], hasOtherChanges: Bool = false) -> Bool { + // If there are no rowIds then stop here + guard !rowIds.isEmpty else { return hasOtherChanges } + + // Fetch the inserted/updated rows + let additionalFilters: SQL = SQL(rowIds.contains(Column.rowID)) + let updatedItems: [T] = (try? dataQuery(additionalFilters) + .fetchAll(db)) + .defaulting(to: []) + + // If the inserted/updated rows we irrelevant (eg. associated to another thread, a quote or a link + // preview) then trigger the update callback (if there were deletions) and stop here + guard !updatedItems.isEmpty else { return hasOtherChanges } + + // Process the upserted data (assume at least one value changed) + dataCache.mutate { $0 = $0.upserting(items: updatedItems) } + + return true + } + + public func attachAssociatedData(to unassociatedCache: DataCache) -> DataCache { + guard let typedCache: DataCache = unassociatedCache as? DataCache else { + return unassociatedCache + } + + return (associateData(dataCache.wrappedValue, typedCache) as? DataCache) + .defaulting(to: unassociatedCache) + } +} diff --git a/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift b/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift index 810a862b6..09a6cb7a5 100644 --- a/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift +++ b/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift @@ -16,7 +16,7 @@ public extension Database { } } - public func makeFTS5Pattern(rawPattern: String, forTable table: T.Type) throws -> FTS5Pattern where T: TableRecord, T: ColumnExpressible { + func makeFTS5Pattern(rawPattern: String, forTable table: T.Type) throws -> FTS5Pattern where T: TableRecord, T: ColumnExpressible { return try makeFTS5Pattern(rawPattern: rawPattern, forTable: table.databaseTableName) } } diff --git a/SessionUtilitiesKit/General/Dictionary+Description.swift b/SessionUtilitiesKit/General/Dictionary+Utilities.swift similarity index 100% rename from SessionUtilitiesKit/General/Dictionary+Description.swift rename to SessionUtilitiesKit/General/Dictionary+Utilities.swift From 62c886e764e741d229f77fe304c2bbb9d3cee761 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 26 May 2022 18:13:16 +1000 Subject: [PATCH 088/157] Got paging working on the conversation screen Fixed a couple of issues where attachment messages would flicker due to thread changing Fixed a couple of issues with page loading Connected the global search result select back up --- Session.xcodeproj/project.pbxproj | 4 + .../ConversationVC+Interaction.swift | 16 +- Session/Conversations/ConversationVC.swift | 46 ++-- .../Conversations/ConversationViewModel.swift | 170 +++++++++------ .../Content Views/MediaView.swift | 6 +- .../Content Views/QuoteView.swift | 12 +- .../InsetLockableTableView.swift | 38 +++- .../GlobalSearch/EmptySearchResultCell.swift | 1 + .../GlobalSearchViewController.swift | 198 ++++++++++-------- .../MediaGalleryViewModel.swift | 16 +- .../MediaTileViewController.swift | 75 +++---- .../Utilities/UIScrollView+Utilities.swift | 37 ++++ .../ConversationCellViewModel.swift | 51 ++++- .../Types/PagedDatabaseObserver.swift | 1 + SignalUtilitiesKit/Utilities/UIView+OWS.swift | 4 +- 15 files changed, 431 insertions(+), 244 deletions(-) create mode 100644 Session/Utilities/UIScrollView+Utilities.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 71ecea48f..59d4def34 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -685,6 +685,7 @@ FD848B87283B844B000E298B /* MessageCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B86283B844B000E298B /* MessageCellViewModel.swift */; }; FD848B8B283DC509000E298B /* PagedDatabaseObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B8A283DC509000E298B /* PagedDatabaseObserver.swift */; }; FD848B8D283E0B26000E298B /* MessageInputTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B8C283E0B26000E298B /* MessageInputTypes.swift */; }; + FD848B8F283EF2A8000E298B /* UIScrollView+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B8E283EF2A8000E298B /* UIScrollView+Utilities.swift */; }; FD859F0027C4691300510D0C /* MockDataGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EFF27C4691300510D0C /* MockDataGenerator.swift */; }; FD88BAD927A7439C00BBC442 /* MessageRequestsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */; }; FD88BADB27A750F200BBC442 /* MessageRequestsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */; }; @@ -1661,6 +1662,7 @@ FD848B86283B844B000E298B /* MessageCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCellViewModel.swift; sourceTree = ""; }; FD848B8A283DC509000E298B /* PagedDatabaseObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagedDatabaseObserver.swift; sourceTree = ""; }; FD848B8C283E0B26000E298B /* MessageInputTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageInputTypes.swift; sourceTree = ""; }; + FD848B8E283EF2A8000E298B /* UIScrollView+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIScrollView+Utilities.swift"; sourceTree = ""; }; FD859EFF27C4691300510D0C /* MockDataGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDataGenerator.swift; sourceTree = ""; }; FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsCell.swift; sourceTree = ""; }; FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsMigration.swift; sourceTree = ""; }; @@ -1935,6 +1937,7 @@ B8783E9D23EB948D00404FB8 /* UILabel+Interaction.swift */, B83F2B87240CB75A000A54AB /* UIImage+Scaling.swift */, FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */, + FD848B8E283EF2A8000E298B /* UIScrollView+Utilities.swift */, C31A6C59247F214E001123EF /* UIView+Glow.swift */, C3548F0724456AB6009433A8 /* UIView+Wrapping.swift */, FD859EFF27C4691300510D0C /* MockDataGenerator.swift */, @@ -4705,6 +4708,7 @@ C3E5C2FA251DBABB0040DFFC /* EditClosedGroupVC.swift in Sources */, B8783E9E23EB948D00404FB8 /* UILabel+Interaction.swift in Sources */, B893063F2383961A005EAA8E /* ScanQRCodeWrapperVC.swift in Sources */, + FD848B8F283EF2A8000E298B /* UIScrollView+Utilities.swift in Sources */, B879D449247E1BE300DB3608 /* PathVC.swift in Sources */, 454A84042059C787008B8C75 /* MediaTileViewController.swift in Sources */, 34D1F0871F8678AA0066283D /* ConversationViewItem.m in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 755ba8991..d468d0669 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -632,8 +632,12 @@ extension ConversationVC: // Show the context menu if applicable guard let keyWindow: UIWindow = UIApplication.shared.keyWindow, - let index = viewModel.interactionData.firstIndex(of: cellViewModel), - let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell, + let sectionIndex: Int = self.viewModel.interactionData + .firstIndex(where: { $0.model == .messages }), + let index = self.viewModel.interactionData[sectionIndex] + .elements + .firstIndex(of: cellViewModel), + let cell = tableView.cellForRow(at: IndexPath(row: index, section: sectionIndex)) as? VisibleMessageCell, let snapshot = cell.bubbleView.snapshotView(afterScreenUpdates: false), contextMenuWindow == nil, let actions: [ContextMenuVC.Action] = ContextMenuVC.actions( @@ -693,8 +697,12 @@ extension ConversationVC: case .mediaMessage: guard - let index = self.viewModel.interactionData.firstIndex(where: { $0.id == cellViewModel.id }), - let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell, + let sectionIndex: Int = self.viewModel.interactionData + .firstIndex(where: { $0.model == .messages }), + let messageIndex: Int = self.viewModel.interactionData[sectionIndex] + .elements + .firstIndex(where: { $0.id == cellViewModel.id }), + let cell = tableView.cellForRow(at: IndexPath(row: messageIndex, section: sectionIndex)) as? VisibleMessageCell, let albumView: MediaAlbumView = cell.albumView else { return } diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 69c5b293e..40323d700 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -937,12 +937,20 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers } func scrollToBottom(isAnimated: Bool) { - guard !isUserScrolling && !viewModel.interactionData.isEmpty else { return } + guard + !isUserScrolling, + let messagesSectionIndex: Int = self.viewModel.interactionData + .firstIndex(where: { $0.model == .messages }), + !self.viewModel.interactionData[messagesSectionIndex] + .elements + .isEmpty + else { return } tableView.scrollToRow( at: IndexPath( - row: viewModel.interactionData.count - 1, - section: 0), + row: viewModel.interactionData[messagesSectionIndex].elements.count - 1, + section: messagesSectionIndex + ), at: .bottom, animated: isAnimated ) @@ -959,7 +967,6 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers func scrollViewDidScroll(_ scrollView: UIScrollView) { scrollButton.alpha = getScrollButtonOpacity() unreadCountView.alpha = scrollButton.alpha - autoLoadMoreIfNeeded() } func updateUnreadCountView(unreadCount: UInt?) { @@ -970,14 +977,6 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers unreadCountView.isHidden = (unreadCount == 0) } - func autoLoadMoreIfNeeded() { - let isMainAppAndActive = CurrentAppContext().isMainAppAndActive - guard isMainAppAndActive && didFinishInitialLayout && viewModel.canLoadMoreItems() && !isLoadingMore - && messagesTableView.contentOffset.y < ConversationVC.loadMoreThreshold else { return } - isLoadingMore = true - viewModel.loadAnotherPageOfMessages() - } - func getScrollButtonOpacity() -> CGFloat { let contentOffsetY = tableView.contentOffset.y let x = (lastPageTop - ConversationVC.bottomInset - contentOffsetY).clamp(0, .greatestFiniteMagnitude) @@ -1078,5 +1077,28 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers isAnimated: Bool = true, highlighted: Bool = false ) { + // Ensure the interaction is loaded + self.viewModel.pagedDataObserver?.load(.untilInclusive(id: interactionId, padding: 0)) + + guard + let messageSectionIndex: Int = self.viewModel.interactionData + .firstIndex(where: { $0.model == .messages }), + let targetMessageIndex = self.viewModel.interactionData[messageSectionIndex] + .elements + .firstIndex(where: { $0.id == interactionId }) + else { return } + + tableView.scrollToRow( + at: IndexPath( + row: targetMessageIndex, + section: messageSectionIndex + ), + at: position, + animated: isAnimated + ) + + if highlighted { + focusedMessageId = interactionId + } } } diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 7ef7ef60b..f6c1c89ea 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -7,6 +7,10 @@ import SessionMessagingKit import SessionUtilitiesKit public class ConversationViewModel: OWSAudioPlayerDelegate { + public typealias SectionModel = ArraySection + + // MARK: - Action + public enum Action { case none case compose @@ -15,6 +19,16 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { } public static let pageSize: Int = 50 + // MARK: - Section + + public enum Section: Differentiable, Equatable, Comparable, Hashable { + case loadOlder + case messages + case loadNewer + } + + // MARK: - Variables + // MARK: - Initialization @@ -34,58 +48,52 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { self.threadId = threadId self.threadData = threadData - self.focusedInteractionId = focusedInteractionId // TODO: This + self.focusedInteractionId = focusedInteractionId self.pagedDataObserver = nil - var hasSavedIntialUpdate: Bool = false - self.pagedDataObserver = PagedDatabaseObserver( - pagedTable: Interaction.self, - pageSize: ConversationViewModel.pageSize, - idColumn: .id, - initialFocusedId: nil, - observedChanges: [ - PagedData.ObservedChanges( - table: Interaction.self, - columns: Interaction.Columns - .allCases - .filter { $0 != .wasRead } - ) - ], - filterSQL: MessageCell.ViewModel.filterSQL(threadId: threadId), - orderSQL: MessageCell.ViewModel.orderSQL, - dataQuery: MessageCell.ViewModel.baseQuery( + + DispatchQueue.global(qos: .default).async { [weak self] in + self?.pagedDataObserver = PagedDatabaseObserver( + pagedTable: Interaction.self, + pageSize: ConversationViewModel.pageSize, + idColumn: .id, + initialFocusedId: focusedInteractionId, + observedChanges: [ + PagedData.ObservedChanges( + table: Interaction.self, + columns: Interaction.Columns + .allCases + .filter { $0 != .wasRead } + ) + ], + filterSQL: MessageCell.ViewModel.filterSQL(threadId: threadId), orderSQL: MessageCell.ViewModel.orderSQL, - baseFilterSQL: MessageCell.ViewModel.filterSQL(threadId: threadId) - ), - associatedRecords: [ - AssociatedRecord( - trackedAgainst: Attachment.self, - observedChanges: [ - PagedData.ObservedChanges( - table: Attachment.self, - columns: [.state] - ) - ], - dataQuery: MessageCell.AttachmentInteractionInfo.baseQuery, - joinToPagedType: MessageCell.AttachmentInteractionInfo.joinToViewModelQuerySQL, - associateData: MessageCell.AttachmentInteractionInfo.createAssociateDataClosure() - ) - ], - onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in - guard let updatedInteractionData: [MessageCell.ViewModel] = self?.process(data: updatedData, for: updatedPageInfo) else { - return + dataQuery: MessageCell.ViewModel.baseQuery( + orderSQL: MessageCell.ViewModel.orderSQL, + baseFilterSQL: MessageCell.ViewModel.filterSQL(threadId: threadId) + ), + associatedRecords: [ + AssociatedRecord( + trackedAgainst: Attachment.self, + observedChanges: [ + PagedData.ObservedChanges( + table: Attachment.self, + columns: [.state] + ) + ], + dataQuery: MessageCell.AttachmentInteractionInfo.baseQuery, + joinToPagedType: MessageCell.AttachmentInteractionInfo.joinToViewModelQuerySQL, + associateData: MessageCell.AttachmentInteractionInfo.createAssociateDataClosure() + ) + ], + onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in + guard let updatedInteractionData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else { + return + } + + self?.onInteractionChange?(updatedInteractionData) } - - // If we haven't stored the data for the initial fetch then do so now (no need - // to call 'onInteractionsChange' in this case as it will always be null) - guard hasSavedIntialUpdate else { - self?.updateInteractionData(updatedInteractionData) - hasSavedIntialUpdate = true - return - } - - self?.onInteractionChange?(updatedInteractionData) - } - ) + ) + } } // MARK: - Variables @@ -130,29 +138,44 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { // MARK: - Interaction Data - public private(set) var interactionData: [MessageCell.ViewModel] = [] + public private(set) var interactionData: [SectionModel] = [] public private(set) var pagedDataObserver: PagedDatabaseObserver? - public var onInteractionChange: (([MessageCell.ViewModel]) -> ())? + public var onInteractionChange: (([SectionModel]) -> ())? - private func process(data: [MessageCell.ViewModel], for pageInfo: PagedData.PageInfo) -> [MessageCell.ViewModel] { + private func process(data: [MessageCell.ViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] { let sortedData: [MessageCell.ViewModel] = data .sorted { lhs, rhs -> Bool in lhs.timestampMs < rhs.timestampMs } - return sortedData - .enumerated() - .map { index, cellViewModel -> MessageCell.ViewModel in - cellViewModel.withClusteringChanges( - prevModel: (index > 0 ? sortedData[index - 1] : nil), - nextModel: (index < (sortedData.count - 2) ? sortedData[index + 1] : nil), - isLast: ( - index == (sortedData.count - 1) && - pageInfo.currentCount == pageInfo.totalCount - ) + return [ + (!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ? + [SectionModel(section: .loadOlder)] : + [] + ), + [ + SectionModel( + section: .messages, + elements: sortedData + .enumerated() + .map { index, cellViewModel -> MessageCell.ViewModel in + cellViewModel.withClusteringChanges( + prevModel: (index > 0 ? sortedData[index - 1] : nil), + nextModel: (index < (sortedData.count - 2) ? sortedData[index + 1] : nil), + isLast: ( + index == (sortedData.count - 1) && + pageInfo.currentCount == pageInfo.totalCount + ) + ) + } ) - } + ], + (data.isEmpty && pageInfo.pageOffset > 0 ? + [SectionModel(section: .loadNewer)] : + [] + ) + ].flatMap { $0 } } - public func updateInteractionData(_ updatedData: [MessageCell.ViewModel]) { + public func updateInteractionData(_ updatedData: [SectionModel]) { self.interactionData = updatedData } @@ -288,7 +311,13 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { } public func markAllAsRead() { - guard let lastInteractionId: Int64 = self.interactionData.last?.id else { return } + guard + let lastInteractionId: Int64 = self.interactionData + .first(where: { $0.model == .messages })? + .elements + .last? + .id + else { return } GRDBStorage.shared.write { db in try Interaction.markAsRead( @@ -487,12 +516,15 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { // If the next interaction is another voice message then autoplay it guard - let currentIndex: Int = self.interactionData.firstIndex(where: { $0.id == interactionId }), - currentIndex < (self.interactionData.count - 1), - self.interactionData[currentIndex + 1].cellType == .audio + let messageSection: SectionModel = self.interactionData + .first(where: { $0.model == .messages }), + let currentIndex: Int = messageSection.elements + .firstIndex(where: { $0.id == interactionId }), + currentIndex < (messageSection.elements.count - 1), + messageSection.elements[currentIndex + 1].cellType == .audio else { return } - let nextItem: MessageCell.ViewModel = self.interactionData[currentIndex + 1] + let nextItem: MessageCell.ViewModel = messageSection.elements[currentIndex + 1] playOrPauseAudio(for: nextItem) } diff --git a/Session/Conversations/Message Cells/Content Views/MediaView.swift b/Session/Conversations/Message Cells/Content Views/MediaView.swift index 7185a076b..9faee9f5d 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaView.swift @@ -173,7 +173,7 @@ public class MediaView: UIView { owsFailDebug("Media has unexpected type: \(type(of: media))") return } - + // FIXME: Animated images flicker when reloading the cells (even though they are in the cache) animatedImageView.image = image }, cacheKey: attachment.id @@ -365,9 +365,9 @@ public class MediaView: UIView { if let media: AnyObject = self.mediaCache.object(forKey: cacheKey as NSString) { Logger.verbose("media cache hit") - guard !Thread.isMainThread else { + guard Thread.isMainThread else { DispatchQueue.main.async { - loadMediaBlock(loadCompletion) + loadCompletion(media) } return } diff --git a/Session/Conversations/Message Cells/Content Views/QuoteView.swift b/Session/Conversations/Message Cells/Content Views/QuoteView.swift index ddeb9ae33..b5db4cd45 100644 --- a/Session/Conversations/Message Cells/Content Views/QuoteView.swift +++ b/Session/Conversations/Message Cells/Content Views/QuoteView.swift @@ -136,10 +136,16 @@ final class QuoteView: UIView { attachment.thumbnail( size: .small, success: { image, _ in - DispatchQueue.main.async { - imageView.image = image - imageView.contentMode = .scaleAspectFill + guard Thread.isMainThread else { + DispatchQueue.main.async { + imageView.image = image + imageView.contentMode = .scaleAspectFill + } + return } + + imageView.image = image + imageView.contentMode = .scaleAspectFill }, failure: {} ) diff --git a/Session/Conversations/Views & Modals/InsetLockableTableView.swift b/Session/Conversations/Views & Modals/InsetLockableTableView.swift index b724eeb82..9fd38c50e 100644 --- a/Session/Conversations/Views & Modals/InsetLockableTableView.swift +++ b/Session/Conversations/Views & Modals/InsetLockableTableView.swift @@ -2,9 +2,14 @@ import UIKit -/// This custom UITableView allows us to lock the contentOffset to a specific value - it's current used to prevent -/// the ConversationVC first responder resignation from making the MediaGalleryDetailViewController transition -/// from looking buggy (ie. the table scrolls down with the resignation during the transition) +/// This custom UITableView gives us two convenience behaviours: +/// +/// 1. It allows us to lock the contentOffset to a specific value - it's currently used to prevent the ConversationVC first +/// responder resignation from making the MediaGalleryDetailViewController transition from looking buggy (ie. the table +/// scrolls down with the resignation during the transition) +/// +/// 2. It allows us to provode a callback which gets triggered if a condition closure returns true - it's currently used to prevent +/// the table view from jumping when inserting new pages at the top of a conversation screen public class InsetLockableTableView: UITableView { public var lockContentOffset: Bool = false { didSet { @@ -15,6 +20,8 @@ public class InsetLockableTableView: UITableView { } public var oldOffset: CGPoint = .zero public var newOffset: CGPoint = .zero + private var afterNextLayoutCondition: ((Int, [Int]) -> Bool)? + private var afterNextLayoutCallback: (() -> ())? public override func layoutSubviews() { newOffset = self.contentOffset @@ -24,12 +31,35 @@ public class InsetLockableTableView: UITableView { x: newOffset.x, y: oldOffset.y ) + super.layoutSubviews() + + self.performNextLayoutCallbackIfPossible() return } super.layoutSubviews() - oldOffset = self.contentOffset + self.performNextLayoutCallbackIfPossible() + self.oldOffset = self.contentOffset + } + + // MARK: - Function + + public func afterNextLayout(when condition: @escaping (Int, [Int]) -> Bool, then callback: @escaping () -> ()) { + self.afterNextLayoutCondition = condition + self.afterNextLayoutCallback = callback + } + + private func performNextLayoutCallbackIfPossible() { + let numSections: Int = self.numberOfSections + let numRowInSections: [Int] = (0.. + + // MARK: - SearchSection + + enum SearchSection: Int, Differentiable { + case noResults + case contactsAndGroups + case messages } - let isRecentSearchResultsEnabled = false - + // MARK: - Variables + + private lazy var defaultSearchResults: [SectionModel] = { + let result: ConversationCell.ViewModel? = GRDBStorage.shared.read { db -> ConversationCell.ViewModel? in + try ConversationCell.ViewModel + .noteToSelfOnlyQuery(userPublicKey: getUserHexEncodedPublicKey(db)) + .fetchOne(db) + } + + return [ result.map { ArraySection(model: .contactsAndGroups, elements: [$0]) } ] + .compactMap { $0 } + }() + private lazy var searchResultSet: [SectionModel] = self.defaultSearchResults + private var termForCurrentSearchResultSet: String = "" + private var lastSearchText: String? + private var refreshTimer: Timer? + + var isLoading = false + @objc public var searchText = "" { didSet { AssertIsOnMainThread() @@ -23,23 +45,6 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo refreshSearchResults() } } - var defaultSearchResults: HomeScreenSearchResultSet = HomeScreenSearchResultSet.noteToSelfOnly - - var searchResultSet: [ArraySection] = [] - private var termForCurrentSearchResultSet: String = "" - - - private var lastSearchText: String? - var searcher: FullTextSearcher { - return FullTextSearcher.shared - } - var isLoading = false - - enum SearchSection: Int, Differentiable { - case noResults - case contactsAndGroups - case messages - } // MARK: - UI Components @@ -114,8 +119,6 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo // MARK: - Update Search Results - var refreshTimer: Timer? - private func refreshSearchResults() { refreshTimer?.invalidate() refreshTimer = WeakTimer.scheduledTimer(timeInterval: 0.1, target: self, userInfo: nil, repeats: false) { [weak self] _ in @@ -136,49 +139,55 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo lastSearchText = searchText - GRDBStorage.shared - .read { db -> Result in - do { - let contactsAndGroupsResults: [ConversationCell.ViewModel] = try ConversationCell.ViewModel - .contactsAndGroupsQuery( - userPublicKey: getUserHexEncodedPublicKey(db), - pattern: try ConversationCell.ViewModel.pattern(db, searchTerm: searchText), - searchTerm: searchText - ) - .fetchAll(db) - - let messageResults: [ConversationCell.ViewModel] = try ConversationCell.ViewModel - .messagesQuery( - userPublicKey: getUserHexEncodedPublicKey(db), - pattern: try ConversationCell.ViewModel.pattern(db, searchTerm: searchText) - ) - .fetchAll(db) - - return .success(SearchResultSet( - contactsAndGroups: contactsAndGroupsResults, - messages: messageResults - )) - } - catch { - return .failure(error) - } + let result: Result<[SectionModel], Error>? = GRDBStorage.shared.read { db -> Result<[SectionModel], Error> in + do { + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + let contactsAndGroupsResults: [ConversationCell.ViewModel] = try ConversationCell.ViewModel + .contactsAndGroupsQuery( + userPublicKey: userPublicKey, + pattern: try ConversationCell.ViewModel.pattern(db, searchTerm: searchText), + searchTerm: searchText + ) + .fetchAll(db) + + let messageResults: [ConversationCell.ViewModel] = try ConversationCell.ViewModel + .messagesQuery( + userPublicKey: userPublicKey, + pattern: try ConversationCell.ViewModel.pattern(db, searchTerm: searchText) + ) + .fetchAll(db) + + return .success([ + ArraySection(model: .contactsAndGroups, elements: contactsAndGroupsResults), + ArraySection(model: .messages, elements: messageResults) + ]) } - .map { [weak self] result in - switch result { - case .success(let resultSet): - self?.termForCurrentSearchResultSet = searchText - self?.searchResultSet = [ - ArraySection(model: .contactsAndGroups, elements: resultSet.contactsAndGroups), - ArraySection(model: .messages, elements: resultSet.messages) - ] - self?.isLoading = false - self?.reloadTableData() - self?.refreshTimer = nil - - - case .failure: break - } + catch { + return .failure(error) } + } + + switch result { + case .success(let sections): + let hasResults: Bool = ( + !searchText.isEmpty && + (sections.map { $0.elements.count }.reduce(0, +) > 0) + ) + + self.termForCurrentSearchResultSet = searchText + self.searchResultSet = [ + (hasResults ? nil : [ArraySection(model: .noResults, elements: [ConversationCell.ViewModel(unreadCount: 0)])]), + (hasResults ? sections : nil) + ] + .compactMap { $0 } + .flatMap { $0 } + self.isLoading = false + self.reloadTableData() + self.refreshTimer = nil + + default: break + } } } @@ -218,30 +227,40 @@ extension GlobalSearchViewController { public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: false) - guard let searchSection = SearchSection(rawValue: indexPath.section) else { return } + let section: SectionModel = self.searchResultSet[indexPath.section] - switch searchSection { - case .noResults: - SNLog("shouldn't be able to tap 'no results' section") - - case .contactsAndGroups: - break - - case .messages: - break + switch section.model { + case .noResults: break + case .contactsAndGroups, .messages: + show( + threadId: section.elements[indexPath.row].threadId, + focusedInteractionId: section.elements[indexPath.row].interactionId + ) } } - private func show(_ thread: TSThread, highlightedMessageID: String?, animated: Bool, isFromRecent: Bool = false) { - if let presentedVC = self.presentedViewController { - presentedVC.dismiss(animated: false, completion: nil) + private func show(threadId: String, focusedInteractionId: Int64? = nil, animated: Bool = true) { + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in + self?.show(threadId: threadId, focusedInteractionId: focusedInteractionId, animated: animated) } - let conversationVC = ConversationVC(thread: thread, focusedMessageID: highlightedMessageID) - var viewControllers = self.navigationController?.viewControllers - if isFromRecent, let index = viewControllers?.firstIndex(of: self) { viewControllers?.remove(at: index) } - viewControllers?.append(conversationVC) - self.navigationController?.setViewControllers(viewControllers!, animated: true) + return } + + guard let conversationVC: ConversationVC = ConversationVC(threadId: threadId, focusedInteractionId: focusedInteractionId) else { + return + } + + if let presentedVC = self.presentedViewController { + presentedVC.dismiss(animated: false, completion: nil) + } + + let viewControllers: [UIViewController] = (self.navigationController? + .viewControllers) + .defaulting(to: []) + .appending(conversationVC) + + self.navigationController?.setViewControllers(viewControllers, animated: true) } // MARK: - UITableViewDataSource @@ -249,6 +268,10 @@ extension GlobalSearchViewController { public func numberOfSections(in tableView: UITableView) -> Int { return self.searchResultSet.count } + + public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return self.searchResultSet[section].elements.count + } public func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { UIView() @@ -286,7 +309,8 @@ extension GlobalSearchViewController { } public func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - let section: ArraySection = self.searchResultSet[section] + let section: SectionModel = self.searchResultSet[section] + switch section.model { case .noResults: return nil case .contactsAndGroups: return (section.elements.isEmpty ? nil : "SEARCH_SECTION_CONTACTS".localized()) @@ -294,16 +318,12 @@ extension GlobalSearchViewController { } } - public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return self.searchResultSet[section].elements.count - } - public func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { return UITableView.automaticDimension } public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let section: ArraySection = self.searchResultSet[indexPath.section] + let section: SectionModel = self.searchResultSet[indexPath.section] switch section.model { case .noResults: diff --git a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift index 65aa4c09c..d54c1d919 100644 --- a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift +++ b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift @@ -18,6 +18,8 @@ public class MediaGalleryViewModel { case loadNewer } + // MARK: - Variables + public let threadId: String public let threadVariant: SessionThread.Variant private var focusedAttachmentId: String? @@ -30,7 +32,7 @@ public class MediaGalleryViewModel { public var interactionIdBefore: [Int64: Int64] { cachedInteractionIdBefore.wrappedValue } public var interactionIdAfter: [Int64: Int64] { cachedInteractionIdAfter.wrappedValue } public private(set) var albumData: [Int64: [Item]] = [:] - public private(set) var pagedDatabaseObserver: PagedDatabaseObserver? + public private(set) var pagedDataObserver: PagedDatabaseObserver? /// This value is the current state of a gallery view public private(set) var galleryData: [SectionModel] = [] @@ -48,13 +50,13 @@ public class MediaGalleryViewModel { self.threadId = threadId self.threadVariant = threadVariant self.focusedAttachmentId = focusedAttachmentId - self.pagedDatabaseObserver = nil + self.pagedDataObserver = nil guard isPagedData else { return } var hasSavedIntialUpdate: Bool = false let filterSQL: SQL = Item.filterSQL(threadId: threadId) - self.pagedDatabaseObserver = PagedDatabaseObserver( + self.pagedDataObserver = PagedDatabaseObserver( pagedTable: Attachment.self, pageSize: pageSize, idColumn: .id, @@ -433,14 +435,6 @@ public class MediaGalleryViewModel { } } - public func loadNewerGalleryItems() { - self.pagedDatabaseObserver?.load(.pageBefore) - } - - public func loadOlderGalleryItems() { - self.pagedDatabaseObserver?.load(.pageAfter) - } - public func updateFocusedItem(attachmentId: String, indexPath: IndexPath) { // Note: We need to set both of these as the 'focusedIndexPath' is usually // derived and if the data changes it will be regenerated using the diff --git a/Session/Media Viewing & Editing/MediaTileViewController.swift b/Session/Media Viewing & Editing/MediaTileViewController.swift index 8f2aac326..2e42d3676 100644 --- a/Session/Media Viewing & Editing/MediaTileViewController.swift +++ b/Session/Media Viewing & Editing/MediaTileViewController.swift @@ -20,6 +20,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour private let viewModel: MediaGalleryViewModel private var hasLoadedInitialData: Bool = false + private var isAutoLoadingNextPage: Bool = false private var currentTargetOffset: CGPoint? var isInBatchSelectMode = false { @@ -34,7 +35,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour init(viewModel: MediaGalleryViewModel) { self.viewModel = viewModel - GRDBStorage.shared.addObserver(viewModel.pagedDatabaseObserver) + GRDBStorage.shared.addObserver(viewModel.pagedDataObserver) super.init(nibName: nil, bundle: nil) } @@ -212,20 +213,30 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour } private func autoLoadNextPageIfNeeded() { + guard !self.isAutoLoadingNextPage else { return } + + self.isAutoLoadingNextPage = true + DispatchQueue.main.asyncAfter(deadline: .now() + MediaTileViewController.autoLoadNextPageDelay) { [weak self] in + self?.isAutoLoadingNextPage = false + + // Note: We sort the headers as we want to prioritise loading newer pages over older ones let sortedVisibleIndexPaths: [IndexPath] = (self?.collectionView .indexPathsForVisibleSupplementaryElements(ofKind: UICollectionView.elementKindSectionHeader)) .defaulting(to: []) .sorted() for headerIndexPath in sortedVisibleIndexPaths { - switch self?.viewModel.galleryData[safe: headerIndexPath.section]?.model { - case .loadNewer: - self?.viewModel.loadNewerGalleryItems() - return - - case .loadOlder: - self?.viewModel.loadOlderGalleryItems() + let section: MediaGalleryViewModel.SectionModel? = self?.viewModel.galleryData[safe: headerIndexPath.section] + + switch section?.model { + case .loadNewer, .loadOlder: + // Attachments are loaded in descending order so 'loadOlder' actually corresponds with + // 'pageAfter' in this case + self?.viewModel.pagedDataObserver?.load(section?.model == .loadOlder ? + .pageAfter : + .pageBefore + ) return default: continue @@ -242,7 +253,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour } private func stopObservingChanges() { - // Note: The 'PagedDatabaseObserver' will continue to get changes but + // Note: The 'pagedDataObserver' will continue to get changes but // we don't want to trigger any UI updates self.viewModel.onGalleryChange = nil } @@ -261,8 +272,8 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour // Determine if we are inserting content at the top of the collectionView let isInsertingAtTop: Bool = { let oldFirstSectionIsLoadMore: Bool = ( - self.viewModel.galleryData[safe: 0]?.model == .loadNewer || - self.viewModel.galleryData[safe: 0]?.model == .loadOlder + self.viewModel.galleryData.first?.model == .loadNewer || + self.viewModel.galleryData.first?.model == .loadOlder ) let oldTargetSectionIndex: Int = (oldFirstSectionIsLoadMore ? 1 : 0) @@ -399,41 +410,17 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour guard self.hasLoadedInitialData else { return } let section: MediaGalleryViewModel.SectionModel = self.viewModel.galleryData[indexPath.section] - let fastEndScrollingThen: ((@escaping () -> ()) -> ()) = { callback in - let endOffset: CGPoint - - if let currentTargetOffset: CGPoint = self.currentTargetOffset { - endOffset = currentTargetOffset - } - else { - let currentVelocity: CGPoint = collectionView.panGestureRecognizer.velocity(in: collectionView) - - endOffset = CGPoint( - x: collectionView.contentOffset.x, - y: collectionView.contentOffset.y - (currentVelocity.y / 100) - ) - } - - guard endOffset != collectionView.contentOffset else { - return callback() - } - - UIView.animate( - withDuration: 0.1, - delay: 0, - options: .curveEaseOut, - animations: { - collectionView.setContentOffset(endOffset, animated: false) - }, - completion: { _ in - callback() - } - ) - } switch section.model { - case .loadOlder: fastEndScrollingThen { self.viewModel.loadOlderGalleryItems() } - case .loadNewer: fastEndScrollingThen { self.viewModel.loadNewerGalleryItems() } + case .loadOlder, .loadNewer: + UIScrollView.fastEndScrollingThen(collectionView, self.currentTargetOffset) { [weak self] in + // Attachments are loaded in descending order so 'loadOlder' actually corresponds with + // 'pageAfter' in this case + self?.viewModel.pagedDataObserver?.load(section.model == .loadOlder ? + .pageAfter : + .pageBefore + ) + } case .emptyGallery, .galleryMonth: break } diff --git a/Session/Utilities/UIScrollView+Utilities.swift b/Session/Utilities/UIScrollView+Utilities.swift new file mode 100644 index 000000000..e72f27d2d --- /dev/null +++ b/Session/Utilities/UIScrollView+Utilities.swift @@ -0,0 +1,37 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit + +public extension UIScrollView { + static let fastEndScrollingThen: ((UIScrollView, CGPoint?, @escaping () -> ()) -> ()) = { scrollView, currentTargetOffset, callback in + let endOffset: CGPoint + + if let currentTargetOffset: CGPoint = currentTargetOffset { + endOffset = currentTargetOffset + } + else { + let currentVelocity: CGPoint = scrollView.panGestureRecognizer.velocity(in: scrollView) + + endOffset = CGPoint( + x: scrollView.contentOffset.x, + y: scrollView.contentOffset.y - (currentVelocity.y / 100) + ) + } + + guard endOffset != scrollView.contentOffset else { + return callback() + } + + UIView.animate( + withDuration: 0.1, + delay: 0, + options: .curveEaseOut, + animations: { + scrollView.setContentOffset(endOffset, animated: false) + }, + completion: { _ in + callback() + } + ) + } +} diff --git a/SessionMessagingKit/Shared Models/ConversationCellViewModel.swift b/SessionMessagingKit/Shared Models/ConversationCellViewModel.swift index 7d9b6d93a..2f457b6b7 100644 --- a/SessionMessagingKit/Shared Models/ConversationCellViewModel.swift +++ b/SessionMessagingKit/Shared Models/ConversationCellViewModel.swift @@ -198,9 +198,9 @@ extension ConversationCell { // MARK: - Convenience Initialization public extension ConversationCell.ViewModel { - // Note: This init method is only used for the message requests cell on the home screen so we can avoid having - init(unreadCount: UInt) { - self.threadId = "UNREAD_MESSAGE_REQUEST_THREADS" + // Note: This init method is only used for the message requests cell or empty states + init(unreadCount: UInt = 0) { + self.threadId = "INVALID_THREAD_ID" self.threadVariant = .contact self.threadCreationDateTimestamp = 0 self.threadMemberNames = nil @@ -1175,6 +1175,51 @@ public extension ConversationCell.ViewModel { ]) } } + + /// This method returns only the 'Note to Self' thread in the structure of a search result conversation + static func noteToSelfOnlyQuery(userPublicKey: String) -> AdaptedFetchRequest> { + let thread: TypedTableAlias = TypedTableAlias() + let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) + + /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before + /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to + /// parse and might throw + let numColumnsBeforeProfiles: Int = 7 + let request: SQLRequest = """ + SELECT + 100 AS \(Column.rank), + + \(thread[.id]) AS \(ViewModel.threadIdKey), + \(thread[.variant]) AS \(ViewModel.threadVariantKey), + \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), + '' AS \(ViewModel.threadMemberNamesKey), + + true AS \(ViewModel.threadIsNoteToSelfKey), + \(thread[.isPinned]) AS \(ViewModel.threadIsPinnedKey), + + \(ViewModel.contactProfileKey).*, + + \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) + + FROM \(SessionThread.self) + JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) + + WHERE \(SQL("\(thread[.id]) = \(userPublicKey)")) + """ + + // Add adapters which will group the various 'Profile' columns so they can be decoded + // as instances of 'Profile' types + return request.adapted { db in + let adapters = try splittingRowAdapters(columnCounts: [ + numColumnsBeforeProfiles, + Profile.numberOfSelectedColumns(db) + ]) + + return ScopeAdapter([ + ViewModel.contactProfileString: adapters[1] + ]) + } + } } // MARK: - Share Extension diff --git a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift index 46fa83059..c0d4ac419 100644 --- a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift +++ b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift @@ -450,6 +450,7 @@ public class PagedDatabaseObserver: TransactionObserver where if let updatedPageInfo: PagedData.PageInfo = loadedPage?.pageInfo { self.pageInfo.mutate { $0 = updatedPageInfo } } + self.isLoadingMoreData.mutate { $0 = false } return } diff --git a/SignalUtilitiesKit/Utilities/UIView+OWS.swift b/SignalUtilitiesKit/Utilities/UIView+OWS.swift index 2a8f2c736..34f2227a6 100644 --- a/SignalUtilitiesKit/Utilities/UIView+OWS.swift +++ b/SignalUtilitiesKit/Utilities/UIView+OWS.swift @@ -137,7 +137,7 @@ public extension UIViewController { } func presentAlert(_ alert: UIAlertController, animated: Bool) { - if !Thread.isMainThread { + guard Thread.isMainThread else { DispatchQueue.main.async { [weak self] in self?.presentAlert(alert, animated: animated) } @@ -150,7 +150,7 @@ public extension UIViewController { } func presentAlert(_ alert: UIAlertController, completion: @escaping (() -> Void)) { - if !Thread.isMainThread { + guard Thread.isMainThread else { DispatchQueue.main.async { [weak self] in self?.presentAlert(alert, completion: completion) } From 45d0faee6a55b70b074ce1278f812c2c341e98e6 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 27 May 2022 18:37:59 +1000 Subject: [PATCH 089/157] Cleaned up the rest of the search functionality Removed some debug text which appearing in the in-conversation search UI Fixed a number of small UI glitches --- Session.xcodeproj/project.pbxproj | 24 - .../ConversationMessageMapping.swift | 333 ---- .../Conversations/ConversationSearch.swift | 210 ++- Session/Conversations/ConversationVC.swift | 636 ++++--- Session/Conversations/ConversationViewItem.h | 157 -- Session/Conversations/ConversationViewItem.m | 1161 ------------- Session/Conversations/ConversationViewModel.h | 142 -- Session/Conversations/ConversationViewModel.m | 1467 ----------------- .../Conversations/ConversationViewModel.swift | 2 +- .../Content Views/MediaView.swift | 13 +- .../Models/MessageCellViewModel.swift | 47 + .../OWSConversationSettingsViewDelegate.h | 2 - .../InsetLockableTableView.swift | 43 +- .../GlobalSearchViewController.swift | 2 +- .../Storage+RecentSearchResults.swift | 32 - Session/Home/HomeVC.swift | 6 +- .../MessageRequestsViewController.swift | 4 +- .../MediaTileViewController.swift | 3 +- Session/Meta/SessionApp.swift | 2 +- Session/Meta/Signal-Bridging-Header.h | 2 - .../Database/Models/Interaction.swift | 23 + .../Models/ThreadTypingIndicator.swift | 2 +- .../ConversationCellViewModel.swift | 2 +- .../Utilities/FullTextSearchFinder.swift | 255 --- .../Types/PagedDatabaseObserver.swift | 2 + .../Messaging/FullTextSearcher.swift | 400 ----- 26 files changed, 635 insertions(+), 4337 deletions(-) delete mode 100644 Session/Conversations/ConversationMessageMapping.swift delete mode 100644 Session/Conversations/ConversationViewItem.h delete mode 100644 Session/Conversations/ConversationViewItem.m delete mode 100644 Session/Conversations/ConversationViewModel.h delete mode 100644 Session/Conversations/ConversationViewModel.m delete mode 100644 Session/Home/GlobalSearch/Storage+RecentSearchResults.swift delete mode 100644 SessionMessagingKit/Utilities/FullTextSearchFinder.swift delete mode 100644 SignalUtilitiesKit/Messaging/FullTextSearcher.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 59d4def34..dddd16e09 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -16,7 +16,6 @@ 340FC8AE204DAC8D007AEB0F /* OWSSoundSettingsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC883204DAC8C007AEB0F /* OWSSoundSettingsViewController.m */; }; 340FC8B6204DAC8D007AEB0F /* OWSQRCodeScanningViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC896204DAC8C007AEB0F /* OWSQRCodeScanningViewController.m */; }; 340FC8B7204DAC8D007AEB0F /* OWSConversationSettingsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC89A204DAC8D007AEB0F /* OWSConversationSettingsViewController.m */; }; - 341341EF2187467A00192D59 /* ConversationViewModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 341341EE2187467900192D59 /* ConversationViewModel.m */; }; 3427C64320F500E000EEC730 /* OWSMessageTimerView.m in Sources */ = {isa = PBXBuildFile; fileRef = 3427C64220F500DF00EEC730 /* OWSMessageTimerView.m */; }; 3430FE181F7751D4000EC51B /* GiphyAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3430FE171F7751D4000EC51B /* GiphyAPI.swift */; }; 34330AA31E79686200DF2FB9 /* OWSProgressView.m in Sources */ = {isa = PBXBuildFile; fileRef = 34330AA21E79686200DF2FB9 /* OWSProgressView.m */; }; @@ -32,7 +31,6 @@ 3496956021A2FC8100DCFE74 /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3496955F21A2FC8100DCFE74 /* CloudKit.framework */; }; 34A6C28021E503E700B5B12E /* OWSImagePickerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A6C27F21E503E600B5B12E /* OWSImagePickerController.swift */; }; 34A8B3512190A40E00218A25 /* MediaAlbumView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A8B3502190A40E00218A25 /* MediaAlbumView.swift */; }; - 34ABC0E421DD20C500ED9469 /* ConversationMessageMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34ABC0E321DD20C500ED9469 /* ConversationMessageMapping.swift */; }; 34B0796D1FCF46B100E248C2 /* MainAppContext.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B0796B1FCF46B000E248C2 /* MainAppContext.m */; }; 34B6A903218B3F63007C4606 /* TypingIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34B6A902218B3F62007C4606 /* TypingIndicatorView.swift */; }; 34BECE2E1F7ABCE000D7438D /* GifPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34BECE2D1F7ABCE000D7438D /* GifPickerViewController.swift */; }; @@ -44,7 +42,6 @@ 34CF078A203E6B78005C4D61 /* end_call_tone_cept.caf in Resources */ = {isa = PBXBuildFile; fileRef = 34CF0786203E6B78005C4D61 /* end_call_tone_cept.caf */; }; 34D1F0501F7D45A60066283D /* GifPickerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D1F04F1F7D45A60066283D /* GifPickerCell.swift */; }; 34D1F0521F7E8EA30066283D /* GiphyDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D1F0511F7E8EA30066283D /* GiphyDownloader.swift */; }; - 34D1F0871F8678AA0066283D /* ConversationViewItem.m in Sources */ = {isa = PBXBuildFile; fileRef = 34D1F0701F8678AA0066283D /* ConversationViewItem.m */; }; 34D2CCDA2062E7D000CB1A14 /* OWSScreenLockUI.m in Sources */ = {isa = PBXBuildFile; fileRef = 34D2CCD92062E7D000CB1A14 /* OWSScreenLockUI.m */; }; 34D5CCA91EAE3D30005515DB /* AvatarViewHelper.m in Sources */ = {isa = PBXBuildFile; fileRef = 34D5CCA81EAE3D30005515DB /* AvatarViewHelper.m */; }; 34D99CE4217509C2000AFB39 /* AppEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D99CE3217509C1000AFB39 /* AppEnvironment.swift */; }; @@ -122,7 +119,6 @@ 7B4C75CD26BB92060000AC89 /* DeletedMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B4C75CC26BB92060000AC89 /* DeletedMessageView.swift */; }; 7B7CB18B270591630079FF93 /* ShareLogsModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B7CB18A270591630079FF93 /* ShareLogsModal.swift */; }; 7BA7F4BB279F9F5800B3A466 /* EmptySearchResultCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BA7F4BA279F9F5800B3A466 /* EmptySearchResultCell.swift */; }; - 7BA7F4BD27A216B600B3A466 /* Storage+RecentSearchResults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BA7F4BC27A216B600B3A466 /* Storage+RecentSearchResults.swift */; }; 7BA9057E27911C5800998B3C /* GlobalSearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BA9057D27911C5800998B3C /* GlobalSearchViewController.swift */; }; 7BC01A3E241F40AB00BC7C55 /* NotificationServiceExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BC01A3D241F40AB00BC7C55 /* NotificationServiceExtension.swift */; }; 7BC01A42241F40AB00BC7C55 /* SessionNotificationServiceExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 7BC01A3B241F40AB00BC7C55 /* SessionNotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -434,7 +430,6 @@ C38EF2B3255B6D9C007E1867 /* UIViewController+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2B1255B6D9C007E1867 /* UIViewController+Utilities.swift */; }; C38EF2B4255B6D9C007E1867 /* UIView+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2B2255B6D9C007E1867 /* UIView+Utilities.swift */; }; C38EF30C255B6DBF007E1867 /* OWSScreenLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2E2255B6DB9007E1867 /* OWSScreenLock.swift */; }; - C38EF30E255B6DBF007E1867 /* FullTextSearcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2E4255B6DB9007E1867 /* FullTextSearcher.swift */; }; C38EF317255B6DBF007E1867 /* DisplayableText.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2ED255B6DBB007E1867 /* DisplayableText.swift */; }; C38EF31A255B6DBF007E1867 /* OWSAnyTouchGestureRecognizer.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2F0255B6DBB007E1867 /* OWSAnyTouchGestureRecognizer.m */; }; C38EF31C255B6DBF007E1867 /* Searcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2F2255B6DBC007E1867 /* Searcher.swift */; }; @@ -910,8 +905,6 @@ 340FC899204DAC8D007AEB0F /* OWSConversationSettingsViewDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSConversationSettingsViewDelegate.h; sourceTree = ""; }; 340FC89A204DAC8D007AEB0F /* OWSConversationSettingsViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSConversationSettingsViewController.m; sourceTree = ""; }; 340FC8A0204DAC8D007AEB0F /* OWSConversationSettingsViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSConversationSettingsViewController.h; sourceTree = ""; }; - 341341ED2187467900192D59 /* ConversationViewModel.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ConversationViewModel.h; sourceTree = ""; }; - 341341EE2187467900192D59 /* ConversationViewModel.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ConversationViewModel.m; sourceTree = ""; }; 3427C64120F500DE00EEC730 /* OWSMessageTimerView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSMessageTimerView.h; sourceTree = ""; }; 3427C64220F500DF00EEC730 /* OWSMessageTimerView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSMessageTimerView.m; sourceTree = ""; }; 3430FE171F7751D4000EC51B /* GiphyAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GiphyAPI.swift; sourceTree = ""; }; @@ -929,7 +922,6 @@ 3496955F21A2FC8100DCFE74 /* CloudKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CloudKit.framework; path = System/Library/Frameworks/CloudKit.framework; sourceTree = SDKROOT; }; 34A6C27F21E503E600B5B12E /* OWSImagePickerController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSImagePickerController.swift; sourceTree = ""; }; 34A8B3502190A40E00218A25 /* MediaAlbumView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaAlbumView.swift; sourceTree = ""; }; - 34ABC0E321DD20C500ED9469 /* ConversationMessageMapping.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConversationMessageMapping.swift; sourceTree = ""; }; 34B0796B1FCF46B000E248C2 /* MainAppContext.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MainAppContext.m; sourceTree = ""; }; 34B0796C1FCF46B000E248C2 /* MainAppContext.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MainAppContext.h; sourceTree = ""; }; 34B0796E1FD07B1E00E248C2 /* SignalShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SignalShareExtension.entitlements; sourceTree = ""; }; @@ -943,8 +935,6 @@ 34CF0786203E6B78005C4D61 /* end_call_tone_cept.caf */ = {isa = PBXFileReference; lastKnownFileType = file; name = end_call_tone_cept.caf; path = Session/Meta/AudioFiles/end_call_tone_cept.caf; sourceTree = SOURCE_ROOT; }; 34D1F04F1F7D45A60066283D /* GifPickerCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GifPickerCell.swift; sourceTree = ""; }; 34D1F0511F7E8EA30066283D /* GiphyDownloader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GiphyDownloader.swift; sourceTree = ""; }; - 34D1F06F1F8678AA0066283D /* ConversationViewItem.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ConversationViewItem.h; sourceTree = ""; }; - 34D1F0701F8678AA0066283D /* ConversationViewItem.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ConversationViewItem.m; sourceTree = ""; }; 34D2CCD82062E7D000CB1A14 /* OWSScreenLockUI.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSScreenLockUI.h; sourceTree = ""; }; 34D2CCD92062E7D000CB1A14 /* OWSScreenLockUI.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSScreenLockUI.m; sourceTree = ""; }; 34D5CCA71EAE3D30005515DB /* AvatarViewHelper.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AvatarViewHelper.h; sourceTree = ""; }; @@ -1048,7 +1038,6 @@ 7B7CB18A270591630079FF93 /* ShareLogsModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareLogsModal.swift; sourceTree = ""; }; 7BA6F47DAD18D44D75B7110F /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionSnodeKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionSnodeKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionSnodeKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionSnodeKit.debug.xcconfig"; sourceTree = ""; }; 7BA7F4BA279F9F5800B3A466 /* EmptySearchResultCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptySearchResultCell.swift; sourceTree = ""; }; - 7BA7F4BC27A216B600B3A466 /* Storage+RecentSearchResults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+RecentSearchResults.swift"; sourceTree = ""; }; 7BA9057D27911C5800998B3C /* GlobalSearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchViewController.swift; sourceTree = ""; }; 7BC01A3B241F40AB00BC7C55 /* SessionNotificationServiceExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SessionNotificationServiceExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 7BC01A3D241F40AB00BC7C55 /* NotificationServiceExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationServiceExtension.swift; sourceTree = ""; }; @@ -1389,7 +1378,6 @@ C38EF2B1255B6D9C007E1867 /* UIViewController+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "UIViewController+Utilities.swift"; path = "SignalUtilitiesKit/Utilities/UIViewController+Utilities.swift"; sourceTree = SOURCE_ROOT; }; C38EF2B2255B6D9C007E1867 /* UIView+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "UIView+Utilities.swift"; path = "SignalUtilitiesKit/Utilities/UIView+Utilities.swift"; sourceTree = SOURCE_ROOT; }; C38EF2E2255B6DB9007E1867 /* OWSScreenLock.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = OWSScreenLock.swift; path = "SignalUtilitiesKit/Screen Lock/OWSScreenLock.swift"; sourceTree = SOURCE_ROOT; }; - C38EF2E4255B6DB9007E1867 /* FullTextSearcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = FullTextSearcher.swift; path = SignalUtilitiesKit/Messaging/FullTextSearcher.swift; sourceTree = SOURCE_ROOT; }; C38EF2EC255B6DBA007E1867 /* ProximityMonitoringManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ProximityMonitoringManager.swift; path = SessionMessagingKit/Utilities/ProximityMonitoringManager.swift; sourceTree = SOURCE_ROOT; }; C38EF2ED255B6DBB007E1867 /* DisplayableText.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = DisplayableText.swift; path = SignalUtilitiesKit/Utilities/DisplayableText.swift; sourceTree = SOURCE_ROOT; }; C38EF2EF255B6DBB007E1867 /* Weak.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Weak.swift; path = SessionUtilitiesKit/General/Weak.swift; sourceTree = SOURCE_ROOT; }; @@ -1950,7 +1938,6 @@ children = ( 7BA9057D27911C5800998B3C /* GlobalSearchViewController.swift */, 7BA7F4BA279F9F5800B3A466 /* EmptySearchResultCell.swift */, - 7BA7F4BC27A216B600B3A466 /* Storage+RecentSearchResults.swift */, ); path = GlobalSearch; sourceTree = ""; @@ -2104,11 +2091,6 @@ B835246D25C38ABF0089A44F /* ConversationVC.swift */, B8569AC225CB5D2900DBA3DB /* ConversationVC+Interaction.swift */, 4CC613352227A00400E21A3A /* ConversationSearch.swift */, - 34D1F06F1F8678AA0066283D /* ConversationViewItem.h */, - 34D1F0701F8678AA0066283D /* ConversationViewItem.m */, - 341341ED2187467900192D59 /* ConversationViewModel.h */, - 341341EE2187467900192D59 /* ConversationViewModel.m */, - 34ABC0E321DD20C500ED9469 /* ConversationMessageMapping.swift */, ); path = Conversations; sourceTree = ""; @@ -2853,7 +2835,6 @@ isa = PBXGroup; children = ( FD28A4F327EA79F800FF65E7 /* BlockListUIUtils.swift */, - C38EF2E4255B6DB9007E1867 /* FullTextSearcher.swift */, ); path = Messaging; sourceTree = ""; @@ -4396,7 +4377,6 @@ C38EF365255B6DCC007E1867 /* OWSTableViewController.m in Sources */, C38EF36B255B6DCC007E1867 /* ScreenLockViewController.m in Sources */, C38EF40C255B6DF7007E1867 /* GradientView.swift in Sources */, - C38EF30E255B6DBF007E1867 /* FullTextSearcher.swift in Sources */, C38EF3FA255B6DF7007E1867 /* DirectionalPanGestureRecognizer.swift in Sources */, C38EF3BB255B6DE7007E1867 /* ImageEditorStrokeItem.swift in Sources */, C38EF3C0255B6DE7007E1867 /* ImageEditorCropViewController.swift in Sources */, @@ -4711,7 +4691,6 @@ FD848B8F283EF2A8000E298B /* UIScrollView+Utilities.swift in Sources */, B879D449247E1BE300DB3608 /* PathVC.swift in Sources */, 454A84042059C787008B8C75 /* MediaTileViewController.swift in Sources */, - 34D1F0871F8678AA0066283D /* ConversationViewItem.m in Sources */, 451A13B11E13DED2000A50FD /* AppNotifications.swift in Sources */, 34D99CE4217509C2000AFB39 /* AppEnvironment.swift in Sources */, C328255225CA64470062D0A7 /* ContextMenuVC+ActionView.swift in Sources */, @@ -4778,7 +4757,6 @@ 4542DF54208D40AC007B4E76 /* LoadingViewController.swift in Sources */, 34D5CCA91EAE3D30005515DB /* AvatarViewHelper.m in Sources */, B8F5F71A25F1B35C003BF8D4 /* MediaPlaceholderView.swift in Sources */, - 341341EF2187467A00192D59 /* ConversationViewModel.m in Sources */, 4C21D5D8223AC60F00EF8A77 /* PhotoCapture.swift in Sources */, C331FFF32558FF0300070591 /* PathStatusView.swift in Sources */, 4CC1ECFB211A553000CC13BE /* AppUpdateNag.swift in Sources */, @@ -4792,7 +4770,6 @@ B82149C125D605C6009C0F2A /* InfoBanner.swift in Sources */, C3DAB3242480CB2B00725F25 /* SRCopyableLabel.swift in Sources */, B8CCF63F23975CFB0091D419 /* JoinOpenGroupVC.swift in Sources */, - 34ABC0E421DD20C500ED9469 /* ConversationMessageMapping.swift in Sources */, B85357C323A1BD1200AAF6CD /* SeedVC.swift in Sources */, 45B5360E206DD8BB00D61655 /* UIResponder+OWS.swift in Sources */, B8D84ECF25E3108A005A043E /* ExpandingAttachmentsButton.swift in Sources */, @@ -4856,7 +4833,6 @@ 3427C64320F500E000EEC730 /* OWSMessageTimerView.m in Sources */, B90418E6183E9DD40038554A /* DateUtil.m in Sources */, C33100092558FF6D00070591 /* UserCell.swift in Sources */, - 7BA7F4BD27A216B600B3A466 /* Storage+RecentSearchResults.swift in Sources */, B8269D2925C7A4B400488AB4 /* InputView.swift in Sources */, C374EEE225DA26740073A857 /* LinkPreviewModal.swift in Sources */, ); diff --git a/Session/Conversations/ConversationMessageMapping.swift b/Session/Conversations/ConversationMessageMapping.swift deleted file mode 100644 index 7075edc57..000000000 --- a/Session/Conversations/ConversationMessageMapping.swift +++ /dev/null @@ -1,333 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -import Foundation - -@objc -public class ConversationMessageMapping: NSObject { - private let viewName: String - private let group: String? - - // The desired number of the items to load BEFORE the pivot (see below). - @objc - public var desiredLength: UInt - - typealias ItemId = String - - // The list of currently loaded items. - private var itemIds = [ItemId]() - - // When we enter a conversation, we want to load up to N interactions. This - // is the "initial load window". - // - // We subsequently expand the load window in two directions using two very - // different behaviors. - // - // * We expand the load window "upwards" (backwards in time) only when - // loadMore() is called, in "pages". - // * We auto-expand the load window "downwards" (forward in time) to include - // any new interactions created after the initial load. - // - // We define the "pivot" as the last item in the initial load window. This - // value is only set once. - // - // For example, if you enter a conversation with messages, 1..15: - // - // 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 - // - // We initially load just the last 5 (if 5 is the initial desired length): - // - // 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 - // | pivot ^ | <-- load window - // pivot: 15, desired length=5. - // - // If a few more messages (16..18) are sent or received, we'll always load - // them immediately (they're after the pivot): - // - // 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 - // | pivot ^ | <-- load window - // pivot: 15, desired length=5. - // - // To load an additional page of items (perhaps due to user scrolling - // upward), we extend the desired length and thereby load more items - // before the pivot. - // - // 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 - // | pivot ^ | <-- load window - // pivot: 15, desired length=10. - // - // To reiterate: - // - // * The pivot doesn't move. - // * The desired length applies _before_ the pivot. - // * Everything after the pivot is auto-loaded. - // - // One last optimization: - // - // After an update, we _can sometimes_ move the pivot (for perf - // reasons), but we also adjust the "desired length" so that this - // no effect on the load behavior. - // - // And note: we use the pivot's sort id, not its uniqueId, which works - // even if the pivot itself is deleted. - private var pivotSortId: UInt64? - - @objc - public var canLoadMore = false - - @objc - public required init(group: String?, desiredLength: UInt) { - self.viewName = TSMessageDatabaseViewExtensionName - self.group = group - self.desiredLength = desiredLength - } - - @objc - public func loadedUniqueIds() -> [String] { - return itemIds - } - - @objc - public func contains(uniqueId: String) -> Bool { - return loadedUniqueIds().contains(uniqueId) - } - - // This method can be used to extend the desired length - // and update. - @objc - public func update(withDesiredLength desiredLength: UInt, transaction: YapDatabaseReadTransaction) { - assert(desiredLength >= self.desiredLength) - - self.desiredLength = desiredLength - - update(transaction: transaction) - } - - // This is the core method of the class. It updates the state to - // reflect the latest database state & the current desired length. - @objc - public func update(transaction: YapDatabaseReadTransaction) { - AssertIsOnMainThread() - - guard let view = transaction.ext(viewName) as? YapDatabaseAutoViewTransaction else { - owsFailDebug("Could not load view.") - return - } - guard let group = group else { - owsFailDebug("No group.") - return - } - - // Deserializing interactions is expensive, so we only - // do that when necessary. - let sortIdForItemId: (String) -> UInt64? = { (itemId) in - guard let interaction = TSInteraction.fetch(uniqueId: itemId, transaction: transaction) else { - owsFailDebug("Could not load interaction.") - return nil - } - return interaction.sortId - } - - // If we have a "pivot", load all items AFTER the pivot and up to minDesiredLength items BEFORE the pivot. - // If we do not have a "pivot", load up to minDesiredLength BEFORE the pivot. - var newItemIds = [ItemId]() - var canLoadMore = false - let desiredLength = self.desiredLength - // Not all items "count" towards the desired length. On an initial load, all items count. Subsequently, - // only items above the pivot count. - var afterPivotCount: UInt = 0 - var beforePivotCount: UInt = 0 - // (void (^)(NSString *collection, NSString *key, id object, NSUInteger index, BOOL *stop))block; - view.enumerateKeys(inGroup: group, with: NSEnumerationOptions.reverse) { (_, key, _, stop) in - let itemId = key - - // Load "uncounted" items after the pivot if possible. - // - // As an optimization, we can skip this check (which requires - // deserializing the interaction) if beforePivotCount is non-zero, - // e.g. after we "pass" the pivot. - if beforePivotCount == 0, - let pivotSortId = self.pivotSortId { - if let sortId = sortIdForItemId(itemId) { - let isAfterPivot = sortId > pivotSortId - if isAfterPivot { - newItemIds.append(itemId) - afterPivotCount += 1 - return - } - } else { - owsFailDebug("Could not determine sort id for interaction: \(itemId)") - } - } - - // Load "counted" items unless the load window overflows. - if beforePivotCount >= desiredLength { - // Overflow - canLoadMore = true - stop.pointee = true - } else { - newItemIds.append(itemId) - beforePivotCount += 1 - } - } - - // The items need to be reversed, since we load them in reverse order. - self.itemIds = Array(newItemIds.reversed()) - self.canLoadMore = canLoadMore - - // Establish the pivot, if necessary and possible. - // - // Deserializing interactions is expensive. We only need to deserialize - // interactions that are "after" the pivot. So there would be performance - // benefits to moving the pivot after each update to the last loaded item. - // - // However, this would undesirable side effects. The desired length for - // conversations with very short disappearing message durations would - // continuously grow as messages appeared and disappeared. - // - // Therefore, we only move the pivot when we've accumulated N items after - // the pivot. This puts an upper bound on the number of interactions we - // have to deserialize while minimizing "load window size creep". - let kMaxItemCountAfterPivot = 32 - let shouldSetPivot = (self.pivotSortId == nil || - afterPivotCount > kMaxItemCountAfterPivot) - if shouldSetPivot { - if let newLastItemId = newItemIds.first { - // newItemIds is in reverse order, so its "first" element is actually last. - if let sortId = sortIdForItemId(newLastItemId) { - // Update the pivot. - if self.pivotSortId != nil { - self.desiredLength += afterPivotCount - } - self.pivotSortId = sortId - } else { - owsFailDebug("Could not determine sort id for interaction: \(newLastItemId)") - } - } - } - } - - // Tries to ensure that the load window includes a given item. - // On success, returns the index path of that item. - // On failure, returns nil. - @objc(ensureLoadWindowContainsUniqueId:transaction:) - public func ensureLoadWindowContains(uniqueId: String, - transaction: YapDatabaseReadTransaction) -> IndexPath? { - if let oldIndex = loadedUniqueIds().firstIndex(of: uniqueId) { - return IndexPath(row: oldIndex, section: 0) - } - guard let view = transaction.ext(viewName) as? YapDatabaseAutoViewTransaction else { - owsFailDebug("Could not load view.") - return nil - } - guard let group = group else { - owsFailDebug("No group.") - return nil - } - - let indexPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: 1) - let wasFound = view.getGroup(nil, index: indexPtr, forKey: uniqueId, inCollection: TSInteraction.collection()) - guard wasFound else { - SNLog("Could not find interaction.") - return nil - } - let index = indexPtr.pointee - let threadInteractionCount = view.numberOfItems(inGroup: group) - guard index < threadInteractionCount else { - owsFailDebug("Invalid index.") - return nil - } - // This math doesn't take into account the number of items loaded _after_ the pivot. - // That's fine; it's okay to load too many interactions here. - let desiredWindowSize: UInt = threadInteractionCount - index - self.update(withDesiredLength: desiredWindowSize, transaction: transaction) - - guard let newIndex = loadedUniqueIds().firstIndex(of: uniqueId) else { - owsFailDebug("Couldn't find interaction.") - return nil - } - return IndexPath(row: newIndex, section: 0) - } - - @objc - public class ConversationMessageMappingDiff: NSObject { - @objc - public let addedItemIds: Set - @objc - public let removedItemIds: Set - @objc - public let updatedItemIds: Set - - init(addedItemIds: Set, removedItemIds: Set, updatedItemIds: Set) { - self.addedItemIds = addedItemIds - self.removedItemIds = removedItemIds - self.updatedItemIds = updatedItemIds - } - } - - // Updates and then calculates which items were inserted, removed or modified. - @objc - public func updateAndCalculateDiff(transaction: YapDatabaseReadTransaction, - notifications: [NSNotification]) -> ConversationMessageMappingDiff? { - let oldItemIds = Set(self.itemIds) - self.update(transaction: transaction) - let newItemIds = Set(self.itemIds) - - let removedItemIds = oldItemIds.subtracting(newItemIds) - let addedItemIds = newItemIds.subtracting(oldItemIds) - // We only notify for updated items that a) were previously loaded b) weren't also inserted or removed. - let updatedItemIds = (self.updatedItemIds(for: notifications) - .subtracting(addedItemIds) - .subtracting(removedItemIds) - .intersection(oldItemIds)) - - return ConversationMessageMappingDiff(addedItemIds: addedItemIds, - removedItemIds: removedItemIds, - updatedItemIds: updatedItemIds) - } - - // For performance reasons, the database modification notifications are used - // to determine which items were modified. If YapDatabase ever changes the - // structure or semantics of these notifications, we'll need to update this - // code to reflect that. - private func updatedItemIds(for notifications: [NSNotification]) -> Set { - var updatedItemIds = Set() - for notification in notifications { - // Unpack the YDB notification, looking for row changes. - guard let userInfo = - notification.userInfo else { - owsFailDebug("Missing userInfo.") - continue - } - guard let viewChangesets = - userInfo[YapDatabaseExtensionsKey] as? NSDictionary else { - // No changes for any views, skip. - continue - } - guard let changeset = - viewChangesets[viewName] as? NSDictionary else { - // No changes for this view, skip. - continue - } - // This constant matches a private constant in YDB. - let changeset_key_changes: String = "changes" - guard let changesetChanges = changeset[changeset_key_changes] as? [Any] else { - owsFailDebug("Missing changeset changes.") - continue - } - for change in changesetChanges { - if change as? YapDatabaseViewSectionChange != nil { - // Ignore. - } else if let rowChange = change as? YapDatabaseViewRowChange { - updatedItemIds.insert(rowChange.collectionKey.key) - } else { - owsFailDebug("Invalid change: \(type(of: change)).") - continue - } - } - } - - return updatedItemIds - } -} diff --git a/Session/Conversations/ConversationSearch.swift b/Session/Conversations/ConversationSearch.swift index 24ecf4f91..53ec8eb35 100644 --- a/Session/Conversations/ConversationSearch.swift +++ b/Session/Conversations/ConversationSearch.swift @@ -4,23 +4,26 @@ import UIKit import SignalUtilitiesKit public class ConversationSearchController: NSObject { - public static let kMinimumSearchTextLength: UInt = 2 + public static let minimumSearchTextLength: UInt = 2 + private let threadId: String public weak var delegate: ConversationSearchControllerDelegate? public let uiSearchController: UISearchController = UISearchController(searchResultsController: nil) public let resultsBar: SearchResultsBar = SearchResultsBar() // MARK: Initializer - override public init() { + public init(threadId: String) { + self.threadId = threadId + super.init() - resultsBar.resultsBarDelegate = self - uiSearchController.delegate = self - uiSearchController.searchResultsUpdater = self + self.resultsBar.resultsBarDelegate = self + self.uiSearchController.delegate = self + self.uiSearchController.searchResultsUpdater = self - uiSearchController.hidesNavigationBarDuringPresentation = false - uiSearchController.searchBar.inputAccessoryView = resultsBar + self.uiSearchController.hidesNavigationBarDuringPresentation = false + self.uiSearchController.searchBar.inputAccessoryView = resultsBar } } @@ -28,12 +31,10 @@ public class ConversationSearchController: NSObject { extension ConversationSearchController: UISearchControllerDelegate { public func didPresentSearchController(_ searchController: UISearchController) { - Logger.verbose("") delegate?.didPresentSearchController?(searchController) } public func didDismissSearchController(_ searchController: UISearchController) { - Logger.verbose("") delegate?.didDismissSearchController?(searchController) } } @@ -41,39 +42,30 @@ extension ConversationSearchController: UISearchControllerDelegate { // MARK: - UISearchResultsUpdating extension ConversationSearchController: UISearchResultsUpdating { - var dbSearcher: FullTextSearcher { - return FullTextSearcher.shared - } - public func updateSearchResults(for searchController: UISearchController) { Logger.verbose("searchBar.text: \( searchController.searchBar.text ?? "")") - guard let rawSearchText = searchController.searchBar.text?.stripped else { - self.resultsBar.updateResults(resultSet: nil) - self.delegate?.conversationSearchController(self, didUpdateSearchResults: nil) + guard + let searchText: String = searchController.searchBar.text?.stripped, + searchText.count >= ConversationSearchController.minimumSearchTextLength + else { + self.resultsBar.updateResults(results: nil) + self.delegate?.conversationSearchController(self, didUpdateSearchResults: nil, searchText: nil) return } - let searchText = FullTextSearchFinder.normalize(text: rawSearchText) - - guard searchText.count >= ConversationSearchController.kMinimumSearchTextLength else { - self.resultsBar.updateResults(resultSet: nil) - self.delegate?.conversationSearchController(self, didUpdateSearchResults: nil) - return + + let threadId: String = self.threadId + let results: [Int64] = GRDBStorage.shared.read { db -> [Int64] in + try Interaction.idsForTermWithin( + threadId: threadId, + pattern: try ConversationCell.ViewModel.pattern(db, searchTerm: searchText) + ) + .fetchAll(db) } - - var resultSet: ConversationScreenSearchResultSet? - self.dbReadConnection.asyncRead({ [weak self] transaction in - guard let self = self else { - return - } - resultSet = self.dbSearcher.searchWithinConversation(thread: self.thread, searchText: searchText, transaction: transaction) - }, completionBlock: { [weak self] in - guard let self = self else { - return - } - self.resultsBar.updateResults(resultSet: resultSet) - self.delegate?.conversationSearchController(self, didUpdateSearchResults: resultSet) - }) + .defaulting(to: []) + + self.resultsBar.updateResults(results: results) + self.delegate?.conversationSearchController(self, didUpdateSearchResults: results, searchText: searchText) } } @@ -83,15 +75,11 @@ extension ConversationSearchController: SearchResultsBarDelegate { func searchResultsBar( _ searchResultsBar: SearchResultsBar, setCurrentIndex currentIndex: Int, - resultSet: ConversationScreenSearchResultSet + results: [Int64] ) { - guard let searchResult = resultSet.messages[safe: currentIndex] else { - owsFailDebug("messageId was unexpectedly nil") - return - } - - BenchEventStart(title: "Conversation Search Nav", eventId: "Conversation Search Nav: \(searchResult.messageId)") - self.delegate?.conversationSearchController(self, didSelectInteractionId: searchResult.messageId) + guard let interactionId: Int64 = results[safe: currentIndex] else { return } + + self.delegate?.conversationSearchController(self, didSelectInteractionId: interactionId) } } @@ -99,12 +87,12 @@ protocol SearchResultsBarDelegate: AnyObject { func searchResultsBar( _ searchResultsBar: SearchResultsBar, setCurrentIndex currentIndex: Int, - resultSet: ConversationScreenSearchResultSet + results: [Int64] ) } public final class SearchResultsBar: UIView { - private var resultSet: ConversationScreenSearchResultSet? + private var results: [Int64]? var currentIndex: Int? weak var resultsBarDelegate: SearchResultsBarDelegate? @@ -112,7 +100,6 @@ public final class SearchResultsBar: UIView { private lazy var label: UILabel = { let result = UILabel() - result.text = "Test" result.font = .boldSystemFont(ofSize: Values.smallFontSize) result.textColor = Colors.text return result @@ -136,6 +123,14 @@ public final class SearchResultsBar: UIView { return result }() + private lazy var loadingIndicator: UIActivityIndicatorView = { + let result = UIActivityIndicatorView(style: .medium) + result.tintColor = Colors.text + result.alpha = 0.5 + result.hidesWhenStopped = true + return result + }() + override init(frame: CGRect) { super.init(frame: frame) setUpViewHierarchy() @@ -148,6 +143,7 @@ public final class SearchResultsBar: UIView { private func setUpViewHierarchy() { autoresizingMask = .flexibleHeight + // Background & blur let backgroundView = UIView() backgroundView.backgroundColor = isLightMode ? .white : .black @@ -157,18 +153,22 @@ public final class SearchResultsBar: UIView { let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) addSubview(blurView) blurView.pin(to: self) + // Separator let separator = UIView() separator.backgroundColor = Colors.text.withAlphaComponent(0.2) separator.set(.height, to: 1 / UIScreen.main.scale) addSubview(separator) separator.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.top, UIView.HorizontalEdge.trailing ], to: self) + // Spacers let spacer1 = UIView.hStretchingSpacer() let spacer2 = UIView.hStretchingSpacer() + // Button containers let upButtonContainer = UIView(wrapping: upButton, withInsets: UIEdgeInsets(top: 2, left: 0, bottom: 0, right: 0)) let downButtonContainer = UIView(wrapping: downButton, withInsets: UIEdgeInsets(top: 0, left: 0, bottom: 2, right: 0)) + // Main stack view let mainStackView = UIStackView(arrangedSubviews: [ upButtonContainer, downButtonContainer, spacer1, label, spacer2 ]) mainStackView.axis = .horizontal @@ -176,117 +176,113 @@ public final class SearchResultsBar: UIView { mainStackView.isLayoutMarginsRelativeArrangement = true mainStackView.layoutMargins = UIEdgeInsets(top: Values.smallSpacing, leading: Values.largeSpacing, bottom: Values.smallSpacing, trailing: Values.largeSpacing) addSubview(mainStackView) + mainStackView.pin(.top, to: .bottom, of: separator) mainStackView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: self) mainStackView.pin(.bottom, to: .bottom, of: self, withInset: -2) + + addSubview(loadingIndicator) + loadingIndicator.pin(.left, to: .right, of: label, withInset: 10) + loadingIndicator.centerYAnchor.constraint(equalTo: label.centerYAnchor).isActive = true + // Remaining constraints label.center(.horizontal, in: self) } + // MARK: - Functions + @objc public func handleUpButtonTapped() { - Logger.debug("") - guard let resultSet = resultSet else { - owsFailDebug("resultSet was unexpectedly nil") - return - } - - guard let currentIndex = currentIndex else { - owsFailDebug("currentIndex was unexpectedly nil") - return - } - - guard currentIndex + 1 < resultSet.messages.count else { - owsFailDebug("showLessRecent button should be disabled") - return - } + guard let results: [Int64] = results else { return } + guard let currentIndex: Int = currentIndex else { return } + guard currentIndex + 1 < results.count else { return } let newIndex = currentIndex + 1 self.currentIndex = newIndex updateBarItems() - resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: newIndex, resultSet: resultSet) + resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: newIndex, results: results) } @objc public func handleDownButtonTapped() { Logger.debug("") - guard let resultSet = resultSet else { - owsFailDebug("resultSet was unexpectedly nil") - return - } - - guard let currentIndex = currentIndex else { - owsFailDebug("currentIndex was unexpectedly nil") - return - } - - guard currentIndex > 0 else { - owsFailDebug("showMoreRecent button should be disabled") - return - } + guard let results: [Int64] = results else { return } + guard let currentIndex: Int = currentIndex, currentIndex > 0 else { return } let newIndex = currentIndex - 1 self.currentIndex = newIndex updateBarItems() - resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: newIndex, resultSet: resultSet) + resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: newIndex, results: results) } - func updateResults(resultSet: ConversationScreenSearchResultSet?) { - if let resultSet = resultSet { - if resultSet.messages.count > 0 { - currentIndex = min(currentIndex ?? 0, resultSet.messages.count - 1) - } else { - currentIndex = nil - } - } else { + func updateResults(results: [Int64]?) { + if let results: [Int64] = results, !results.isEmpty { + currentIndex = min(currentIndex ?? 0, results.count - 1) + } + else { currentIndex = nil } - self.resultSet = resultSet + self.results = results updateBarItems() - if let currentIndex = currentIndex, let resultSet = resultSet { - resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: currentIndex, resultSet: resultSet) + + if let currentIndex = currentIndex, let results = results { + resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: currentIndex, results: results) } } func updateBarItems() { - guard let resultSet = resultSet else { + guard let results: [Int64] = results else { label.text = "" downButton.isEnabled = false upButton.isEnabled = false return } - switch resultSet.messages.count { - case 0: - label.text = NSLocalizedString("CONVERSATION_SEARCH_NO_RESULTS", comment: "keyboard toolbar label when no messages match the search string") - case 1: - label.text = NSLocalizedString("CONVERSATION_SEARCH_ONE_RESULT", comment: "keyboard toolbar label when exactly 1 message matches the search string") - default: - let format = NSLocalizedString("CONVERSATION_SEARCH_RESULTS_FORMAT", - comment: "keyboard toolbar label when more than 1 message matches the search string. Embeds {{number/position of the 'currently viewed' result}} and the {{total number of results}}") + switch results.count { + case 0: + // Keyboard toolbar label when no messages match the search string + label.text = "CONVERSATION_SEARCH_NO_RESULTS".localized() + + case 1: + // Keyboard toolbar label when exactly 1 message matches the search string + label.text = "CONVERSATION_SEARCH_ONE_RESULT".localized() + + default: + // Keyboard toolbar label when more than 1 message matches the search string + // + // Embeds {{number/position of the 'currently viewed' result}} and + // the {{total number of results}} + let format = "CONVERSATION_SEARCH_RESULTS_FORMAT".localized() - guard let currentIndex = currentIndex else { - owsFailDebug("currentIndex was unexpectedly nil") - return + guard let currentIndex: Int = currentIndex else { return } + + label.text = String(format: format, currentIndex + 1, results.count) } - label.text = String(format: format, currentIndex + 1, resultSet.messages.count) - } - if let currentIndex = currentIndex { + if let currentIndex: Int = currentIndex { downButton.isEnabled = currentIndex > 0 - upButton.isEnabled = currentIndex + 1 < resultSet.messages.count - } else { + upButton.isEnabled = (currentIndex + 1 < results.count) + } + else { downButton.isEnabled = false upButton.isEnabled = false } } + + public func startLoading() { + loadingIndicator.startAnimating() + } + + public func stopLoading() { + loadingIndicator.stopAnimating() + } } // MARK: - ConversationSearchControllerDelegate public protocol ConversationSearchControllerDelegate: UISearchControllerDelegate { - func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults resultSet: ConversationScreenSearchResultSet?) + func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults results: [Int64]?, searchText: String?) func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectInteractionId: Int64) } diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 40323d700..439dcd7ba 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -14,24 +14,28 @@ import SignalUtilitiesKit // • Remaining search glitchiness final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, ConversationSearchControllerDelegate, UITableViewDataSource, UITableViewDelegate { + private static let loadingHeaderHeight: CGFloat = 20 + internal let viewModel: ConversationViewModel private var dataChangeObservable: DatabaseCancellable? - private var hasLoadedInitialData: Bool = false + private var hasLoadedInitialThreadData: Bool = false + private var hasLoadedInitialInteractionData: Bool = false + private var currentTargetOffset: CGPoint? + private var isAutoLoadingNextPage: Bool = false + private var isLoadingMore: Bool = false - /// This flag indicates whether the data has been reloaded after a disappearance (it defaults to true as it will never - /// have disappeared before) - private var hasReloadedDataAfterDisappearance: Bool = true + /// This flag indicates whether the thread data has been reloaded after a disappearance (it defaults to true as it will + /// never have disappeared before - this is only needed for value observers since they run asynchronously) + private var hasReloadedThreadDataAfterDisappearance: Bool = true - var focusedMessageIndexPath: IndexPath? - var initialUnreadCount: UInt = 0 - var unreadViewItems: [ConversationViewItem] = [] + var focusedInteractionId: Int64? + var shouldHighlightNextScrollToInteraction: Bool = false var scrollButtonBottomConstraint: NSLayoutConstraint? var scrollButtonMessageRequestsBottomConstraint: NSLayoutConstraint? var messageRequestsViewBotomConstraint: NSLayoutConstraint? // Search var isShowingSearchUI = false - var lastSearchedText: String? // Audio playback & recording var audioPlayer: OWSAudioPlayer? @@ -49,7 +53,6 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers // Scrolling & paging var isUserScrolling = false var didFinishInitialLayout = false - var isLoadingMore = false var scrollDistanceToBottomBeforeUpdate: CGFloat? var baselineKeyboardHeight: CGFloat = 0 @@ -105,7 +108,9 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers lazy var recordVoiceMessageActivity = AudioActivity(audioDescription: "Voice message", behavior: .playAndRecord) lazy var searchController: ConversationSearchController = { - let result: ConversationSearchController = ConversationSearchController() + let result: ConversationSearchController = ConversationSearchController( + threadId: self.viewModel.threadData.threadId + ) result.uiSearchController.obscuresBackgroundDuringPresentation = false result.delegate = self @@ -140,6 +145,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers bottom: Values.mediumSpacing, trailing: 0 ) + result.registerHeaderFooterView(view: UITableViewHeaderFooterView.self) result.register(view: VisibleMessageCell.self) result.register(view: InfoMessageCell.self) result.register(view: TypingIndicatorCell.self) @@ -305,6 +311,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers } self.viewModel = viewModel + GRDBStorage.shared.addObserver(viewModel.pagedDataObserver) super.init(nibName: nil, bundle: nil) } @@ -426,31 +433,6 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers // Update the input state snInputView.setEnabledMessageTypes(viewModel.threadData.enabledMessageTypes, message: nil) - - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - - guard !didFinishInitialLayout else { return } - - // Scroll to the last unread message if possible; otherwise scroll to the bottom. - // When the unread message count is more than the number of view items of a page, - // the screen will scroll to the bottom instead of the first unread message - DispatchQueue.main.async { - if let focusedInteractionId: Int64 = self.viewModel.focusedInteractionId { - self.scrollToInteraction(with: focusedInteractionId, isAnimated: false, highlighted: true) - } - else if let firstUnreadInteractionId: Int64 = self.viewModel.threadData.threadFirstUnreadInteractionId { - self.scrollToInteraction(with: firstUnreadInteractionId, position: .top, isAnimated: false) - self.unreadCountView.alpha = self.scrollButton.alpha - } - else { - self.scrollToBottom(isAnimated: false) - } - - self.scrollButton.alpha = self.getScrollButtonOpacity() - } } override func viewWillAppear(_ animated: Bool) { @@ -462,15 +444,17 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - highlightFocusedMessageIfNeeded() didFinishInitialLayout = true viewModel.markAllAsRead() - if delayFirstResponder { + if delayFirstResponder || isShowingSearchUI { delayFirstResponder = false DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) { [weak self] in - self?.becomeFirstResponder() + (self?.isShowingSearchUI == false ? + self : + self?.searchController.uiSearchController.searchBar + )?.becomeFirstResponder() } } } @@ -487,7 +471,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers super.viewDidDisappear(animated) mediaCache.removeAllObjects() - hasReloadedDataAfterDisappearance = false + hasReloadedThreadDataAfterDisappearance = false } @objc func applicationDidBecomeActive(_ notification: Notification) { @@ -510,6 +494,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers // The default scheduler emits changes on the main thread self?.handleThreadUpdates(threadData) + self?.performInitialScrollIfNeeded() } ) @@ -527,9 +512,9 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers private func handleThreadUpdates(_ updatedThreadData: ConversationCell.ViewModel, initialLoad: Bool = false) { // Ensure the first load or a load when returning from a child screen runs without animations (if // we don't do this the cells will animate in from a frame of CGRect.zero or have a buggy transition) - guard hasLoadedInitialData && hasReloadedDataAfterDisappearance else { - hasLoadedInitialData = true - hasReloadedDataAfterDisappearance = true + guard hasLoadedInitialThreadData && hasReloadedThreadDataAfterDisappearance else { + hasLoadedInitialThreadData = true + hasReloadedThreadDataAfterDisappearance = true UIView.performWithoutAnimation { handleThreadUpdates(updatedThreadData, initialLoad: true) } return } @@ -578,27 +563,159 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers } } - private func handleInteractionUpdates(_ updatedViewData: [MessageCell.ViewModel], initialLoad: Bool = false) { + private func handleInteractionUpdates(_ updatedData: [ConversationViewModel.SectionModel], initialLoad: Bool = false) { // Ensure the first load or a load when returning from a child screen runs without animations (if // we don't do this the cells will animate in from a frame of CGRect.zero or have a buggy transition) - guard hasLoadedInitialData && hasReloadedDataAfterDisappearance else { - hasLoadedInitialData = true - hasReloadedDataAfterDisappearance = true - UIView.performWithoutAnimation { handleInteractionUpdates(updatedViewData, initialLoad: true) } + guard self.hasLoadedInitialInteractionData else { + self.hasLoadedInitialInteractionData = true + self.viewModel.updateInteractionData(updatedData) + + UIView.performWithoutAnimation { + self.tableView.reloadData() + self.performInitialScrollIfNeeded() + } return } - // Reload the table content (animate changes after the first load) - let changeset = StagedChangeset(source: viewModel.interactionData, target: updatedViewData) - tableView.reload( - using: StagedChangeset(source: viewModel.interactionData, target: updatedViewData), - deleteSectionsAnimation: .bottom, - insertSectionsAnimation: .bottom, + // Determine if we are inserting content at the top of the collectionView + struct ItemChangeInfo { + let insertedAtTop: Bool + let firstIndexIsVisible: Bool + let visibleInteractionId: Int64 + let visibleIndexPath: IndexPath + let oldVisibleIndexPath: IndexPath + + init( + insertedAtTop: Bool, + firstIndexIsVisible: Bool = false, + visibleInteractionId: Int64 = -1, + visibleIndexPath: IndexPath = IndexPath(row: 0, section: 0), + oldVisibleIndexPath: IndexPath = IndexPath(row: 0, section: 0) + ) { + self.insertedAtTop = insertedAtTop + self.firstIndexIsVisible = firstIndexIsVisible + self.visibleInteractionId = visibleInteractionId + self.visibleIndexPath = visibleIndexPath + self.oldVisibleIndexPath = oldVisibleIndexPath + } + } + + let itemChangeInfo: ItemChangeInfo = { + guard + let oldSectionIndex: Int = self.viewModel.interactionData.firstIndex(where: { $0.model == .messages }), + let newSectionIndex: Int = updatedData.firstIndex(where: { $0.model == .messages }), + let newFirstItemIndex: Int = updatedData[newSectionIndex].elements + .firstIndex(where: { item -> Bool in + item.id == self.viewModel.interactionData[oldSectionIndex].elements.first?.id + }), + let firstVisibleIndexPath: IndexPath = self.tableView.indexPathsForVisibleRows? + .filter({ $0.section == oldSectionIndex }) + .sorted() + .first, + let newVisibleIndex: Int = updatedData[newSectionIndex].elements + .firstIndex(where: { item in + item.id == self.viewModel.interactionData[oldSectionIndex] + .elements[firstVisibleIndexPath.row] + .id + }), + ( + newSectionIndex > oldSectionIndex || + newFirstItemIndex > 0 + ) + else { return ItemChangeInfo(insertedAtTop: false) } + + return ItemChangeInfo( + insertedAtTop: true, + firstIndexIsVisible: (firstVisibleIndexPath.row == 0), + visibleInteractionId: updatedData[newSectionIndex].elements[newVisibleIndex].id, + visibleIndexPath: IndexPath(row: newVisibleIndex, section: newSectionIndex), + oldVisibleIndexPath: firstVisibleIndexPath + ) + }() + + /// If we are inserting at the top then we want to maintain the same visual position from before the table view was updated, + /// unfortunately the UITableView does some weird things when updating (where it won't have updated data until after it + /// performs the next layout); the below code checks a condition on layout and if it passes it calls a closure + /// + /// In the below case we set the tableView offset of the first row to the same offset it had before the UI loaded with new + /// data (including the difference in height in case the date header was removed when loading the new cell) + if itemChangeInfo.insertedAtTop { + let numItemsInUpdatedData: [Int] = updatedData.map { $0.elements.count } + let cellSorting: (MessageCell, MessageCell) -> Bool = { lhs, rhs -> Bool in + if !lhs.isHidden && rhs.isHidden { return true } + if lhs.isHidden && !rhs.isHidden { return false } + + return (lhs.frame.minY < rhs.frame.minY) + } + let oldRect: CGRect = (self.tableView.subviews + .compactMap { $0 as? MessageCell } + .sorted(by: cellSorting) + .first(where: { cell -> Bool in cell.viewModel?.id == itemChangeInfo.visibleInteractionId })? + .frame) + .defaulting(to: self.tableView.rectForRow(at: itemChangeInfo.oldVisibleIndexPath)) + let oldContentSize: CGSize = self.tableView.contentSize + let oldContentOffset: CGPoint = self.tableView.contentOffset + + // Distance of 64 when paging works properly + self.tableView.afterNextLayoutSubviews( + when: { numSections, numRowsInSections -> Bool in + numSections == updatedData.count && + numRowsInSections == numItemsInUpdatedData + }, + then: { [weak self] in + self?.tableView.scrollToRow(at: itemChangeInfo.visibleIndexPath, at: .top, animated: false) + self?.tableView.layoutIfNeeded() + + /// **Note:** I wasn't able to get a prober equation to handle both "insert above first item" and "insert + /// at top off screen", it seems that the 'contentOffset' value won't expose negative values (eg. when you + /// over-scroll and trigger the bounce effect) and this results in requiring the conditional logic below + if itemChangeInfo.firstIndexIsVisible { + let newRect: CGRect = (self?.tableView.subviews + .compactMap { $0 as? MessageCell } + .sorted(by: cellSorting) + .first(where: { $0.viewModel?.id == itemChangeInfo.visibleInteractionId })? + .frame) + .defaulting(to: oldRect) + let heightDiff: CGFloat = (oldRect.height - newRect.height) + + self?.tableView.contentOffset.y = (newRect.minY - (oldRect.minY + heightDiff)) + } + else { + let newContentSize: CGSize = (self?.tableView.contentSize) + .defaulting(to: oldContentSize) + let contentSizeDiff: CGFloat = (newContentSize.height - oldContentSize.height) + + self?.tableView.contentOffset.y = (contentSizeDiff + oldContentOffset.y) + } + + if let focusedInteractionId: Int64 = self?.focusedInteractionId { + DispatchQueue.main.async { + self?.searchController.resultsBar.stopLoading() + self?.scrollToInteractionIfNeeded( + with: focusedInteractionId, + isAnimated: true, + highlight: (self?.shouldHighlightNextScrollToInteraction == true) + ) + } + } + + // Complete page loading + self?.isLoadingMore = false + self?.autoLoadNextPageIfNeeded() + } + ) + } + + // Reload the table content (animate changes if we aren't inserting at the top) + self.tableView.reload( + using: StagedChangeset(source: viewModel.interactionData, target: updatedData), + deleteSectionsAnimation: .none, + insertSectionsAnimation: .none, reloadSectionsAnimation: .none, deleteRowsAnimation: .bottom, insertRowsAnimation: .bottom, reloadRowsAnimation: .none, - interrupt: { $0.changeCount > ConversationViewModel.pageSize } + interrupt: { itemChangeInfo.insertedAtTop || $0.changeCount > ConversationViewModel.pageSize } ) { [weak self] updatedData in self?.viewModel.updateInteractionData(updatedData) } @@ -619,6 +736,76 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers viewModel.sentMessageBeforeUpdate = false } + private func performInitialScrollIfNeeded() { + guard !didFinishInitialLayout && hasLoadedInitialThreadData && hasLoadedInitialInteractionData else { return } + + // Scroll to the last unread message if possible; otherwise scroll to the bottom. + // When the unread message count is more than the number of view items of a page, + // the screen will scroll to the bottom instead of the first unread message + DispatchQueue.main.async { + if let focusedInteractionId: Int64 = self.viewModel.focusedInteractionId { + self.scrollToInteractionIfNeeded(with: focusedInteractionId, isAnimated: false, highlight: true) + } + else if let firstUnreadInteractionId: Int64 = self.viewModel.threadData.threadFirstUnreadInteractionId { + self.scrollToInteractionIfNeeded(with: firstUnreadInteractionId, position: .top, isAnimated: false) + self.unreadCountView.alpha = self.scrollButton.alpha + } + else { + self.scrollToBottom(isAnimated: false) + } + + self.scrollButton.alpha = self.getScrollButtonOpacity() + + // Now that the data has loaded we need to check if either of the "load more" sections are + // visible and trigger them if so + // + // Note: We do it this way as we want to trigger the load behaviour for the first section + // if it has one before trying to trigger the load behaviour for the last section + self.autoLoadNextPageIfNeeded() + } + } + + private func autoLoadNextPageIfNeeded() { + guard !self.isAutoLoadingNextPage && !self.isLoadingMore else { return } + + self.isAutoLoadingNextPage = true + + DispatchQueue.main.asyncAfter(deadline: .now() + PagedData.autoLoadNextPageDelay) { [weak self] in + self?.isAutoLoadingNextPage = false + + // Note: We sort the headers as we want to prioritise loading newer pages over older ones + let sections: [(ConversationViewModel.Section, CGRect)] = (self?.viewModel.interactionData + .enumerated() + .map { index, section in (section.model, (self?.tableView.rectForHeader(inSection: 0) ?? .zero)) }) + .defaulting(to: []) + let shouldLoadOlder: Bool = sections + .contains { section, headerRect in + section == .loadOlder && + headerRect != .zero && + (self?.tableView.bounds.contains(headerRect) == true) + } + let shouldLoadNewer: Bool = sections + .contains { section, headerRect in + section == .loadNewer && + headerRect != .zero && + (self?.tableView.bounds.contains(headerRect) == true) + } + + guard shouldLoadOlder || shouldLoadNewer else { return } + + self?.isLoadingMore = true + + DispatchQueue.global(qos: .default).async { [weak self] in + // Attachments are loaded in descending order so 'loadOlder' actually corresponds with + // 'pageAfter' in this case + self?.viewModel.pagedDataObserver?.load(shouldLoadOlder ? + .pageAfter : + .pageBefore + ) + } + } + } + func updateNavBarButtons(threadData: ConversationCell.ViewModel) { navigationItem.hidesBackButton = isShowingSearchUI @@ -675,15 +862,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers } } - - // MARK: Notifications - - private func highlightFocusedMessageIfNeeded() { - if let indexPath = focusedMessageIndexPath, let cell = tableView.cellForRow(at: indexPath) as? VisibleMessageCell { - cell.highlight() - focusedMessageIndexPath = nil - } - } + // MARK: - Notifications @objc func handleKeyboardWillChangeFrameNotification(_ notification: Notification) { // Please refer to https://github.com/mapbox/mapbox-navigation-ios/issues/1600 @@ -777,113 +956,6 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers completion: nil ) } - - func conversationViewModelWillUpdate() { - // Not currently in use - } - - func conversationViewModelDidUpdate(_ conversationUpdate: ConversationUpdate) { - guard self.isViewLoaded else { return } - let updateType = conversationUpdate.conversationUpdateType - guard updateType != .minor else { return } // No view items were affected - if updateType == .reload { - if threadStartedAsMessageRequest { - updateNavBarButtons() // In case the message request was approved - } - - return messagesTableView.reloadData() - } - var shouldScrollToBottom = false - let batchUpdates: () -> Void = { - for update in conversationUpdate.updateItems! { - switch update.updateItemType { - case .delete: - self.messagesTableView.deleteRows(at: [ IndexPath(row: Int(update.oldIndex), section: 0) ], with: .none) - case .insert: - // Perform inserts before updates - self.messagesTableView.insertRows(at: [ IndexPath(row: Int(update.newIndex), section: 0) ], with: .none) - if update.viewItem?.interaction is TSOutgoingMessage { - shouldScrollToBottom = true - } else { - shouldScrollToBottom = self.isCloseToBottom - } - case .update: - self.messagesTableView.reloadRows(at: [ IndexPath(row: Int(update.oldIndex), section: 0) ], with: .none) - default: preconditionFailure() - } - - // Update the nav items if the message request was approved - if (update.viewItem?.interaction as? TSInfoMessage)?.messageType == .messageRequestAccepted { - self.updateNavBarButtons() - } - } - } - UIView.performWithoutAnimation { - messagesTableView.performBatchUpdates(batchUpdates) { _ in - if shouldScrollToBottom { - self.scrollToBottom(isAnimated: false) - } - self.markAllAsRead() - } - } - - // Update the input state if this is a contact thread - if let contactThread: TSContactThread = thread as? TSContactThread { - let contact: Contact? = GRDBStorage.shared.read { db in try Contact.fetchOne(db, id: contactThread.contactSessionID()) } - - // If the contact doesn't exist yet then it's a message request without the first message sent - // so only allow text-based messages - self.snInputView.setEnabledMessageTypes( - (thread.isNoteToSelf() || contact?.didApproveMe == true || thread.isMessageRequest() ? - .all : .textOnly - ), - message: nil - ) - } - } - - func conversationViewModelWillLoadMoreItems() { - view.layoutIfNeeded() - // The scroll distance to bottom will be restored in conversationViewModelDidLoadMoreItems - scrollDistanceToBottomBeforeUpdate = messagesTableView.contentSize.height - messagesTableView.contentOffset.y - } - - func conversationViewModelDidLoadMoreItems() { - guard let scrollDistanceToBottomBeforeUpdate = scrollDistanceToBottomBeforeUpdate else { return } - view.layoutIfNeeded() - messagesTableView.contentOffset.y = messagesTableView.contentSize.height - scrollDistanceToBottomBeforeUpdate - isLoadingMore = false - } - - func conversationViewModelDidLoadPrevPage() { - // Not currently in use - } - - func conversationViewModelRangeDidChange() { - // Not currently in use - } - - func conversationViewModelDidReset() { - // Not currently in use - } - - @objc private func handleMessageSentStatusChanged() { - DispatchQueue.main.async { - guard let indexPaths = self.tableView.indexPathsForVisibleRows else { return } - var indexPathsToReload: [IndexPath] = [] - for indexPath in indexPaths { - guard let cell = self.tableView.cellForRow(at: indexPath) as? VisibleMessageCell else { continue } - let isLast = (indexPath.item == (self.tableView.numberOfRows(inSection: 0) - 1)) - guard !isLast else { continue } - if !cell.messageStatusImageView.isHidden { - indexPathsToReload.append(indexPath) - } - } - UIView.performWithoutAnimation { - self.tableView.reloadRows(at: indexPathsToReload, with: .none) - } - } - } // MARK: - General @@ -899,31 +971,64 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers // MARK: - UITableViewDataSource - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + func numberOfSections(in tableView: UITableView) -> Int { return viewModel.interactionData.count } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + let section: ConversationViewModel.SectionModel = viewModel.interactionData[section] + + return section.elements.count + } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cellViewModel: MessageCell.ViewModel = viewModel.interactionData[indexPath.row] - let cell: MessageCell = tableView.dequeue(type: MessageCell.cellType(for: cellViewModel), for: indexPath) - cell.update( - with: cellViewModel, - mediaCache: mediaCache, - playbackInfo: viewModel.playbackInfo(for: cellViewModel) { updatedInfo, error in - DispatchQueue.main.async { - guard error == nil else { - OWSAlerts.showErrorAlert(message: "INVALID_AUDIO_FILE_ALERT_ERROR_MESSAGE".localized()) - return - } - - cell.dynamicUpdate(with: cellViewModel, playbackInfo: updatedInfo) - } - }, - lastSearchText: viewModel.lastSearchedText - ) - cell.delegate = self + let section: ConversationViewModel.SectionModel = viewModel.interactionData[indexPath.section] - return cell + switch section.model { + case .messages: + let cellViewModel: MessageCell.ViewModel = section.elements[indexPath.row] + let cell: MessageCell = tableView.dequeue(type: MessageCell.cellType(for: cellViewModel), for: indexPath) + cell.update( + with: cellViewModel, + mediaCache: mediaCache, + playbackInfo: viewModel.playbackInfo(for: cellViewModel) { updatedInfo, error in + DispatchQueue.main.async { + guard error == nil else { + OWSAlerts.showErrorAlert(message: "INVALID_AUDIO_FILE_ALERT_ERROR_MESSAGE".localized()) + return + } + // TODO: Looks like the 'play/pause' icon isn't swapping when it auto-plays to the next item) + cell.dynamicUpdate(with: cellViewModel, playbackInfo: updatedInfo) + } + }, + lastSearchText: viewModel.lastSearchedText + ) + cell.delegate = self + + return cell + + default: preconditionFailure("Other sections should have no content") + } + } + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + let section: ConversationViewModel.SectionModel = viewModel.interactionData[section] + + switch section.model { + case .loadOlder, .loadNewer: + let loadingIndicator: UIActivityIndicatorView = UIActivityIndicatorView(style: .medium) + loadingIndicator.tintColor = Colors.text + loadingIndicator.alpha = 0.5 + loadingIndicator.startAnimating() + + let view: UIView = UIView() + view.addSubview(loadingIndicator) + loadingIndicator.center(in: view) + + return view + + case .messages: return nil + } } // MARK: - UITableViewDelegate @@ -935,6 +1040,37 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { return UITableView.automaticDimension } + + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + let section: ConversationViewModel.SectionModel = viewModel.interactionData[section] + + switch section.model { + case .loadOlder, .loadNewer: return ConversationVC.loadingHeaderHeight + case .messages: return 0 + } + } + + func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) { + guard self.didFinishInitialLayout && !self.isLoadingMore else { return } + + let section: ConversationViewModel.SectionModel = self.viewModel.interactionData[section] + + switch section.model { + case .loadOlder, .loadNewer: + self.isLoadingMore = true + + DispatchQueue.global(qos: .default).async { [weak self] in + // Messages are loaded in descending order so 'loadOlder' actually corresponds with + // 'pageAfter' in this case + self?.viewModel.pagedDataObserver?.load(section.model == .loadOlder ? + .pageAfter : + .pageBefore + ) + } + + case .messages: break + } + } func scrollToBottom(isAnimated: Bool) { guard @@ -968,6 +1104,18 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers scrollButton.alpha = getScrollButtonOpacity() unreadCountView.alpha = scrollButton.alpha } + + func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + guard + let focusedInteractionId: Int64 = self.focusedInteractionId, + self.shouldHighlightNextScrollToInteraction + else { + self.focusedInteractionId = nil + return + } + + self.highlightCellIfNeeded(interactionId: focusedInteractionId) + } func updateUnreadCountView(unreadCount: UInt?) { let unreadCount: Int = Int(unreadCount ?? 0) @@ -988,20 +1136,14 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers func conversationSettingsDidRequestConversationSearch(_ conversationSettingsViewController: OWSConversationSettingsViewController) { showSearchUI() - popAllConversationSettingsViews { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { // Without this delay the search bar doesn't show - self.searchController.uiSearchController.searchBar.becomeFirstResponder() - } + + guard presentedViewController != nil else { + self.navigationController?.popToViewController(self, animated: true, completion: nil) + return } - } - - func popAllConversationSettingsViews(completion completionBlock: (() -> Void)? = nil) { - if presentedViewController != nil { - dismiss(animated: true) { - self.navigationController!.popToViewController(self, animated: true, completion: completionBlock) - } - } else { - navigationController!.popToViewController(self, animated: true, completion: completionBlock) + + dismiss(animated: true) { + self.navigationController?.popToViewController(self, animated: true, completion: nil) } } @@ -1052,8 +1194,8 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers navigationItem.titleView = titleView updateNavBarButtons(threadData: self.viewModel.threadData) - let navBar = navigationController!.navigationBar as! OWSNavigationBar - navBar.stubbedNextResponder = nil + let navBar: OWSNavigationBar? = navigationController?.navigationBar as? OWSNavigationBar + navBar?.stubbedNextResponder = nil becomeFirstResponder() reloadInputViews() } @@ -1062,43 +1204,89 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers hideSearchUI() } - func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults resultSet: ConversationScreenSearchResultSet?) { - lastSearchedText = resultSet?.searchText + func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults results: [Int64]?, searchText: String?) { tableView.reloadRows(at: tableView.indexPathsForVisibleRows ?? [], with: UITableView.RowAnimation.none) } func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectInteractionId interactionId: Int64) { - scrollToInteraction(with: interactionId) + scrollToInteractionIfNeeded(with: interactionId, highlight: true) } - func scrollToInteraction( + func scrollToInteractionIfNeeded( with interactionId: Int64, position: UITableView.ScrollPosition = .middle, isAnimated: Bool = true, - highlighted: Bool = false + highlight: Bool = false ) { - // Ensure the interaction is loaded - self.viewModel.pagedDataObserver?.load(.untilInclusive(id: interactionId, padding: 0)) + // Store the info incase we need to load more data (call will be re-triggered) + self.focusedInteractionId = interactionId + self.shouldHighlightNextScrollToInteraction = highlight + // Ensure the target interaction has been loaded guard let messageSectionIndex: Int = self.viewModel.interactionData .firstIndex(where: { $0.model == .messages }), let targetMessageIndex = self.viewModel.interactionData[messageSectionIndex] .elements .firstIndex(where: { $0.id == interactionId }) - else { return } - - tableView.scrollToRow( - at: IndexPath( - row: targetMessageIndex, - section: messageSectionIndex - ), - at: position, - animated: isAnimated + else { + // If not the make sure we have finished the initial layout before trying to + // load the up until the specified interaction + guard self.didFinishInitialLayout else { return } + + self.searchController.resultsBar.startLoading() + + DispatchQueue.global(qos: .default).async { [weak self] in + self?.viewModel.pagedDataObserver?.load(.untilInclusive( + id: interactionId, + padding: 5 + )) + } + return + } + + let targetIndexPath: IndexPath = IndexPath( + row: targetMessageIndex, + section: messageSectionIndex ) - - if highlighted { - focusedMessageId = interactionId + + // If we aren't animating or aren't highlighting then everything can be run immediately + guard isAnimated && highlight else { + self.tableView.scrollToRow(at: targetIndexPath, at: position, animated: isAnimated) + self.focusedInteractionId = nil + self.shouldHighlightNextScrollToInteraction = false + + if highlight { + self.highlightCellIfNeeded(interactionId: interactionId) + } + return + } + + // If we are animating and highlighting then determine if we want to scroll to the target + // cell (if we try to trigger the `scrollToRow` call and the animation doesn't occur then + // the highlight will not be triggered so if a cell is entirely on the screen then just + // don't bother scrolling) + let targetRect: CGRect = self.tableView.rectForRow(at: targetIndexPath) + + guard !self.tableView.bounds.contains(targetRect) else { + self.highlightCellIfNeeded(interactionId: interactionId) + return + } + + self.tableView.scrollToRow(at: targetIndexPath, at: position, animated: true) + } + + func highlightCellIfNeeded(interactionId: Int64) { + self.shouldHighlightNextScrollToInteraction = false + self.focusedInteractionId = nil + + // Trigger on the next run loop incase we are still finishing some other animation + DispatchQueue.main.async { + self.tableView + .visibleCells + .first(where: { ($0 as? VisibleMessageCell)?.viewModel?.id == interactionId }) + .asType(VisibleMessageCell.self)? + .highlight(interactionId: interactionId) } } } diff --git a/Session/Conversations/ConversationViewItem.h b/Session/Conversations/ConversationViewItem.h deleted file mode 100644 index c5479f1e2..000000000 --- a/Session/Conversations/ConversationViewItem.h +++ /dev/null @@ -1,157 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -extern NSString *const SNAudioDidFinishPlayingNotification; - -typedef NS_ENUM(NSInteger, OWSMessageCellType) { - OWSMessageCellType_Unknown, - OWSMessageCellType_TextOnlyMessage, - OWSMessageCellType_Audio, - OWSMessageCellType_GenericAttachment, - OWSMessageCellType_MediaMessage, - OWSMessageCellType_OversizeTextDownloading, - OWSMessageCellType_DeletedMessage -}; - -NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType); - -#pragma mark - - -@class ContactShareViewModel; -@class ConversationViewCell; -@class DisplayableText; -@class YapDatabaseReadTransaction; - -@interface ConversationMediaAlbumItem : NSObject - -@property (nonatomic, readonly) TSAttachment *attachment; - -// This property will only be set if the attachment is downloaded. -@property (nonatomic, readonly, nullable) TSAttachmentStream *attachmentStream; - -// This property will be non-zero if the attachment is valid. -@property (nonatomic, readonly) CGSize mediaSize; - -@property (nonatomic, readonly, nullable) NSString *caption; - -@property (nonatomic, readonly) BOOL isFailedDownload; - -@end - -#pragma mark - - -@protocol ConversationViewItem - -@property (nonatomic, readonly) TSInteraction *interaction; - -@property (nonatomic, readonly, nullable) OWSQuotedReplyModel *quotedReply; - -@property (nonatomic, readonly) BOOL isGroupThread; -@property (nonatomic, readonly) BOOL userCanDeleteGroupMessage; -@property (nonatomic, readonly) BOOL userHasModerationPermission; - -@property (nonatomic, readonly) BOOL hasBodyText; - -@property (nonatomic, readonly) BOOL isQuotedReply; -@property (nonatomic, readonly) BOOL hasQuotedAttachment; -@property (nonatomic, readonly) BOOL hasQuotedText; -@property (nonatomic, readonly) BOOL hasCellHeader; - -@property (nonatomic, readonly) BOOL isExpiringMessage; - -@property (nonatomic) BOOL shouldShowDate; -@property (nonatomic) BOOL shouldShowSenderProfilePicture; -@property (nonatomic, nullable) NSAttributedString *senderName; -@property (nonatomic) BOOL shouldHideFooter; -@property (nonatomic) BOOL isFirstInCluster; -@property (nonatomic) BOOL isOnlyMessageInCluster; -@property (nonatomic) BOOL isLastInCluster; -@property (nonatomic) BOOL wasPreviousItemInfoMessage; - -@property (nonatomic, nullable) OWSUnreadIndicator *unreadIndicator; - -- (void)replaceInteraction:(TSInteraction *)interaction transaction:(YapDatabaseReadTransaction *)transaction; - -- (void)clearCachedLayoutState; - -@property (nonatomic, readonly) BOOL hasCachedLayoutState; - -#pragma mark - Audio Playback - -@property (nonatomic, weak) SNVoiceMessageView *lastAudioMessageView; - -@property (nonatomic, readonly) CGFloat audioDurationSeconds; -@property (nonatomic, readonly) CGFloat audioProgressSeconds; - -#pragma mark - View State Caching - -// These methods only apply to text & attachment messages. -@property (nonatomic, readonly) OWSMessageCellType messageCellType; -@property (nonatomic, readonly, nullable) DisplayableText *displayableBodyText; -@property (nonatomic, readonly, nullable) TSAttachmentStream *attachmentStream; -@property (nonatomic, readonly, nullable) TSAttachmentPointer *attachmentPointer; -@property (nonatomic, readonly, nullable) NSArray *mediaAlbumItems; - -@property (nonatomic, readonly, nullable) DisplayableText *displayableQuotedText; -@property (nonatomic, readonly, nullable) NSString *quotedAttachmentMimetype; -@property (nonatomic, readonly, nullable) NSString *quotedRecipientId; - -// We don't want to try to load the media for this item (if any) -// if a load has previously failed. -@property (nonatomic) BOOL didCellMediaFailToLoad; - -@property (nonatomic, readonly, nullable) ContactShareViewModel *contactShare; - -@property (nonatomic, readonly, nullable) OWSLinkPreview *linkPreview; -@property (nonatomic, readonly, nullable) TSAttachment *linkPreviewAttachment; - -@property (nonatomic, readonly, nullable) NSString *systemMessageText; - -// NOTE: This property is only set for incoming messages. -@property (nonatomic, readonly, nullable) NSString *authorConversationColorName; - -#pragma mark - MessageActions - -@property (nonatomic, readonly) BOOL hasBodyTextActionContent; -@property (nonatomic, readonly) BOOL hasMediaActionContent; - -- (void)copyMediaAction; -- (void)copyTextAction; -- (void)shareMediaAction; -- (void)saveMediaAction; -- (void)deleteLocallyAction; -- (void)deleteRemotelyAction; - -- (void)deleteAction; // Remove this after the unsend request is enabled - -- (BOOL)canCopyMedia; -- (BOOL)canSaveMedia; - -// For view items that correspond to interactions, this is the interaction's unique id. -// For other view views (like the typing indicator), this is a unique, stable string. -- (NSString *)itemId; - -- (nullable TSAttachmentStream *)firstValidAlbumAttachment; - -- (BOOL)mediaAlbumHasFailedAttachment; - -@end - -#pragma mark - - -@interface ConversationInteractionViewItem - : NSObject - -- (instancetype)init NS_UNAVAILABLE; -- (instancetype)initWithInteraction:(TSInteraction *)interaction - isGroupThread:(BOOL)isGroupThread - transaction:(YapDatabaseReadTransaction *)transaction; - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/ConversationViewItem.m b/Session/Conversations/ConversationViewItem.m deleted file mode 100644 index 102803dfc..000000000 --- a/Session/Conversations/ConversationViewItem.m +++ /dev/null @@ -1,1161 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import -#import "ConversationViewItem.h" -#import "Session-Swift.h" -#import "AnyPromise.h" -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -NSString *const SNAudioDidFinishPlayingNotification = @"SNAudioDidFinishPlayingNotification"; - -NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) -{ - switch (cellType) { - case OWSMessageCellType_TextOnlyMessage: - return @"OWSMessageCellType_TextOnlyMessage"; - case OWSMessageCellType_Audio: - return @"OWSMessageCellType_Audio"; - case OWSMessageCellType_GenericAttachment: - return @"OWSMessageCellType_GenericAttachment"; - case OWSMessageCellType_Unknown: - return @"OWSMessageCellType_Unknown"; - case OWSMessageCellType_MediaMessage: - return @"OWSMessageCellType_MediaMessage"; - case OWSMessageCellType_OversizeTextDownloading: - return @"OWSMessageCellType_OversizeTextDownloading"; - } -} - -#pragma mark - - -@implementation ConversationMediaAlbumItem - -- (instancetype)initWithAttachment:(TSAttachment *)attachment - attachmentStream:(nullable TSAttachmentStream *)attachmentStream - caption:(nullable NSString *)caption - mediaSize:(CGSize)mediaSize -{ - OWSAssertDebug(attachment); - - self = [super init]; - - if (!self) { - return self; - } - - _attachment = attachment; - _attachmentStream = attachmentStream; - _caption = caption; - _mediaSize = mediaSize; - - return self; -} - -- (BOOL)isFailedDownload -{ - if (![self.attachment isKindOfClass:[TSAttachmentPointer class]]) { - return NO; - } - TSAttachmentPointer *attachmentPointer = (TSAttachmentPointer *)self.attachment; - return attachmentPointer.state == TSAttachmentPointerStateFailed; -} - -@end - -#pragma mark - - -@interface ConversationInteractionViewItem () - -@property (nonatomic, nullable) NSValue *cachedCellSize; - -#pragma mark - OWSAudioPlayerDelegate - -@property (nonatomic) AudioPlaybackState audioPlaybackState; -@property (nonatomic) CGFloat audioProgressSeconds; -@property (nonatomic) CGFloat audioDurationSeconds; - -#pragma mark - View State - -@property (nonatomic) BOOL hasViewState; -@property (nonatomic) OWSMessageCellType messageCellType; -@property (nonatomic, nullable) DisplayableText *displayableBodyText; -@property (nonatomic, nullable) DisplayableText *displayableQuotedText; -@property (nonatomic, nullable) OWSQuotedReplyModel *quotedReply; -@property (nonatomic, nullable) TSAttachmentStream *attachmentStream; -@property (nonatomic, nullable) TSAttachmentPointer *attachmentPointer; -@property (nonatomic, nullable) ContactShareViewModel *contactShare; -@property (nonatomic, nullable) OWSLinkPreview *linkPreview; -@property (nonatomic, nullable) TSAttachment *linkPreviewAttachment; -@property (nonatomic, nullable) NSArray *mediaAlbumItems; -@property (nonatomic, nullable) NSString *systemMessageText; -@property (nonatomic, nullable) TSThread *incomingMessageAuthorThread; -@property (nonatomic, nullable) NSString *authorConversationColorName; - -@end - -#pragma mark - - -@implementation ConversationInteractionViewItem - -@synthesize shouldShowDate = _shouldShowDate; -@synthesize shouldShowSenderProfilePicture = _shouldShowSenderProfilePicture; -@synthesize unreadIndicator = _unreadIndicator; -@synthesize didCellMediaFailToLoad = _didCellMediaFailToLoad; -@synthesize interaction = _interaction; -@synthesize isFirstInCluster = _isFirstInCluster; -@synthesize isGroupThread = _isGroupThread; -@synthesize isOnlyMessageInCluster = _isOnlyMessageInCluster; -@synthesize isLastInCluster = _isLastInCluster; -@synthesize wasPreviousItemInfoMessage = _wasPreviousItemInfoMessage; -@synthesize lastAudioMessageView = _lastAudioMessageView; -@synthesize senderName = _senderName; -@synthesize shouldHideFooter = _shouldHideFooter; - -- (instancetype)initWithInteraction:(TSInteraction *)interaction - isGroupThread:(BOOL)isGroupThread - transaction:(YapDatabaseReadTransaction *)transaction -{ - OWSAssertDebug(interaction); - OWSAssertDebug(transaction); - - self = [super init]; - - if (!self) { - return self; - } - - _interaction = interaction; - _isGroupThread = isGroupThread; - - [self ensureViewState:transaction]; - - return self; -} - -- (void)replaceInteraction:(TSInteraction *)interaction transaction:(YapDatabaseReadTransaction *)transaction -{ - OWSAssertDebug(interaction); - - _interaction = interaction; - - self.hasViewState = NO; - self.messageCellType = OWSMessageCellType_Unknown; - self.displayableBodyText = nil; - self.attachmentStream = nil; - self.attachmentPointer = nil; - self.mediaAlbumItems = nil; - self.displayableQuotedText = nil; - self.quotedReply = nil; - self.contactShare = nil; - self.systemMessageText = nil; - self.authorConversationColorName = nil; - self.linkPreview = nil; - self.linkPreviewAttachment = nil; - - [self clearCachedLayoutState]; - - [self ensureViewState:transaction]; -} - -- (OWSPrimaryStorage *)primaryStorage -{ - return SSKEnvironment.shared.primaryStorage; -} - -- (NSString *)itemId -{ - return self.interaction.uniqueId; -} - -- (BOOL)hasBodyText -{ - return _displayableBodyText != nil; -} - -- (BOOL)hasQuotedText -{ - return _displayableQuotedText != nil; -} - -- (BOOL)hasQuotedAttachment -{ - return self.quotedAttachmentMimetype.length > 0; -} - -- (BOOL)isQuotedReply -{ - return self.hasQuotedAttachment || self.hasQuotedText; -} - -- (BOOL)isExpiringMessage -{ - if (self.interaction.interactionType != OWSInteractionType_OutgoingMessage - && self.interaction.interactionType != OWSInteractionType_IncomingMessage) { - return NO; - } - - TSMessage *message = (TSMessage *)self.interaction; - return message.isExpiringMessage; -} - -- (BOOL)hasCellHeader -{ - return self.shouldShowDate || self.unreadIndicator; -} - -- (void)setShouldShowDate:(BOOL)shouldShowDate -{ - if (_shouldShowDate == shouldShowDate) { - return; - } - - _shouldShowDate = shouldShowDate; - - [self clearCachedLayoutState]; -} - -- (void)setShouldShowSenderAvatar:(BOOL)shouldShowSenderProfilePicture -{ - if (_shouldShowSenderProfilePicture == shouldShowSenderProfilePicture) { - return; - } - - _shouldShowSenderProfilePicture = shouldShowSenderProfilePicture; - - [self clearCachedLayoutState]; -} - -- (void)setSenderName:(nullable NSAttributedString *)senderName -{ - if ([NSObject isNullableObject:senderName equalTo:_senderName]) { - return; - } - - _senderName = senderName; - - [self clearCachedLayoutState]; -} - -- (void)setShouldHideFooter:(BOOL)shouldHideFooter -{ - if (_shouldHideFooter == shouldHideFooter) { - return; - } - - _shouldHideFooter = shouldHideFooter; - - [self clearCachedLayoutState]; -} - -- (void)setIsFirstInCluster:(BOOL)isFirstInCluster -{ - if (_isFirstInCluster == isFirstInCluster) { - return; - } - - _isFirstInCluster = isFirstInCluster; - - // Although this doesn't affect layout size, the view model use - // hasCachedLayoutState to detect which cells needs to be redrawn due to changes. - [self clearCachedLayoutState]; -} - -- (void)setIsLastInCluster:(BOOL)isLastInCluster -{ - if (_isLastInCluster == isLastInCluster) { - return; - } - - _isLastInCluster = isLastInCluster; - - // Although this doesn't affect layout size, the view model use - // hasCachedLayoutState to detect which cells needs to be redrawn due to changes. - [self clearCachedLayoutState]; -} - -- (void)setUnreadIndicator:(nullable OWSUnreadIndicator *)unreadIndicator -{ - if ([NSObject isNullableObject:_unreadIndicator equalTo:unreadIndicator]) { - return; - } - - _unreadIndicator = unreadIndicator; - - [self clearCachedLayoutState]; -} - -- (void)clearCachedLayoutState -{ - self.cachedCellSize = nil; -} - -- (BOOL)hasCachedLayoutState { - return self.cachedCellSize != nil; -} - -- (nullable TSAttachmentStream *)firstValidAlbumAttachment -{ - OWSAssertDebug(self.mediaAlbumItems.count > 0); - - // For now, use first valid attachment. - TSAttachmentStream *_Nullable attachmentStream = nil; - for (ConversationMediaAlbumItem *mediaAlbumItem in self.mediaAlbumItems) { - if (mediaAlbumItem.attachmentStream && mediaAlbumItem.attachmentStream.isValidVisualMedia) { - attachmentStream = mediaAlbumItem.attachmentStream; - break; - } - } - return attachmentStream; -} - -#pragma mark - OWSAudioPlayerDelegate - -- (void)setAudioPlaybackState:(AudioPlaybackState)audioPlaybackState -{ - _audioPlaybackState = audioPlaybackState; - - BOOL isPlaying = (audioPlaybackState == AudioPlaybackState_Playing); - [self.lastAudioMessageView setIsPlaying:isPlaying]; -} - -- (void)setAudioProgress:(CGFloat)progress duration:(CGFloat)duration -{ - OWSAssertIsOnMainThread(); - - self.audioProgressSeconds = progress; - - [self.lastAudioMessageView setProgress:(int)(progress)]; -} - -- (void)showInvalidAudioFileAlert -{ - OWSAssertIsOnMainThread(); - - [OWSAlerts - showErrorAlertWithMessage:NSLocalizedString(@"INVALID_AUDIO_FILE_ALERT_ERROR_MESSAGE", - @"Message for the alert indicating that an audio file is invalid.")]; -} - -- (void)audioPlayerDidFinishPlaying:(OWSAudioPlayer *)player successfully:(BOOL)flag -{ - if (!flag) { return; } - [NSNotificationCenter.defaultCenter postNotificationName:SNAudioDidFinishPlayingNotification object:nil]; -} - -#pragma mark - Displayable Text - -// TODO: Now that we're caching the displayable text on the view items, -// I don't think we need this cache any more. -- (NSCache *)displayableTextCache -{ - static NSCache *cache = nil; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - cache = [NSCache new]; - // Cache the results for up to 1,000 messages. - cache.countLimit = 1000; - }); - return cache; -} - -- (DisplayableText *)displayableBodyTextForText:(NSString *)text interactionId:(NSString *)interactionId -{ - OWSAssertDebug(text); - OWSAssertDebug(interactionId.length > 0); - - NSString *displayableTextCacheKey = [@"body-" stringByAppendingString:interactionId]; - - return [self displayableTextForCacheKey:displayableTextCacheKey - textBlock:^{ - return text; - }]; -} - -- (DisplayableText *)displayableBodyTextForOversizeTextAttachment:(TSAttachmentStream *)attachmentStream - interactionId:(NSString *)interactionId -{ - OWSAssertDebug(attachmentStream); - OWSAssertDebug(interactionId.length > 0); - - NSString *displayableTextCacheKey = [@"oversize-body-" stringByAppendingString:interactionId]; - - return [self displayableTextForCacheKey:displayableTextCacheKey - textBlock:^{ - NSData *textData = - [NSData dataWithContentsOfURL:attachmentStream.originalMediaURL]; - NSString *text = - [[NSString alloc] initWithData:textData encoding:NSUTF8StringEncoding]; - return text; - }]; -} - -- (DisplayableText *)displayableQuotedTextForText:(NSString *)text interactionId:(NSString *)interactionId -{ - OWSAssertDebug(text); - OWSAssertDebug(interactionId.length > 0); - - NSString *displayableTextCacheKey = [@"quoted-" stringByAppendingString:interactionId]; - - return [self displayableTextForCacheKey:displayableTextCacheKey - textBlock:^{ - return text; - }]; -} - -- (DisplayableText *)displayableCaptionForText:(NSString *)text attachmentId:(NSString *)attachmentId -{ - OWSAssertDebug(text); - OWSAssertDebug(attachmentId.length > 0); - - NSString *displayableTextCacheKey = [@"attachment-caption-" stringByAppendingString:attachmentId]; - - return [self displayableTextForCacheKey:displayableTextCacheKey - textBlock:^{ - return text; - }]; -} - -- (DisplayableText *)displayableTextForCacheKey:(NSString *)displayableTextCacheKey - textBlock:(NSString * (^_Nonnull)(void))textBlock -{ - OWSAssertDebug(displayableTextCacheKey.length > 0); - - DisplayableText *_Nullable displayableText = [[self displayableTextCache] objectForKey:displayableTextCacheKey]; - if (!displayableText) { - NSString *text = textBlock(); - displayableText = [DisplayableText displayableText:text]; - [[self displayableTextCache] setObject:displayableText forKey:displayableTextCacheKey]; - } - return displayableText; -} - -#pragma mark - View State - -- (void)ensureViewState:(YapDatabaseReadTransaction *)transaction -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(transaction); - OWSAssertDebug(!self.hasViewState); - - switch (self.interaction.interactionType) { - case OWSInteractionType_Unknown: - case OWSInteractionType_Offer: - case OWSInteractionType_TypingIndicator: - return; - case OWSInteractionType_Info: - case OWSInteractionType_Call: - self.systemMessageText = [self systemMessageTextWithTransaction:transaction]; - OWSAssertDebug(self.systemMessageText.length > 0); - return; - case OWSInteractionType_IncomingMessage: - case OWSInteractionType_OutgoingMessage: - break; - default: - OWSFailDebug(@"Unknown interaction type."); - return; - } - - OWSAssertDebug([self.interaction isKindOfClass:[TSOutgoingMessage class]] || - [self.interaction isKindOfClass:[TSIncomingMessage class]]); - - self.hasViewState = YES; - - TSMessage *message = (TSMessage *)self.interaction; - - if (message.isDeleted) { - self.messageCellType = OWSMessageCellType_DeletedMessage; - return; - } - - // Check for quoted replies _before_ media album handling, - // since that logic may exit early. - if (message.quotedMessage) { - self.quotedReply = - [OWSQuotedReplyModel quotedReplyWithQuotedMessage:message.quotedMessage threadId:message.uniqueThreadId transaction:transaction]; - - if (self.quotedReply.body.length > 0) { - self.displayableQuotedText = - [self displayableQuotedTextForText:self.quotedReply.body interactionId:message.uniqueId]; - } - } - - TSAttachment *_Nullable oversizeTextAttachment = [message oversizeTextAttachmentWithTransaction:transaction]; - if ([oversizeTextAttachment isKindOfClass:[TSAttachmentStream class]]) { - TSAttachmentStream *oversizeTextAttachmentStream = (TSAttachmentStream *)oversizeTextAttachment; - self.displayableBodyText = [self displayableBodyTextForOversizeTextAttachment:oversizeTextAttachmentStream - interactionId:message.uniqueId]; - } else if ([oversizeTextAttachment isKindOfClass:[TSAttachmentPointer class]]) { - TSAttachmentPointer *oversizeTextAttachmentPointer = (TSAttachmentPointer *)oversizeTextAttachment; - // TODO: Handle backup restore. - self.messageCellType = OWSMessageCellType_OversizeTextDownloading; - self.attachmentPointer = (TSAttachmentPointer *)oversizeTextAttachmentPointer; - return; - } else { - NSString *_Nullable bodyText = [message bodyTextWithTransaction:transaction]; - if (bodyText) { - self.displayableBodyText = [self displayableBodyTextForText:bodyText interactionId:message.uniqueId]; - } - } - - NSArray *mediaAttachments = [message mediaAttachmentsWithTransaction:transaction]; - NSArray *mediaAlbumItems = [self albumItemsForMediaAttachments:mediaAttachments]; - if (mediaAlbumItems.count > 0) { - if (mediaAlbumItems.count == 1) { - ConversationMediaAlbumItem *mediaAlbumItem = mediaAlbumItems.firstObject; - if (mediaAlbumItem.attachmentStream && !mediaAlbumItem.attachmentStream.isValidVisualMedia) { - OWSLogWarn(@"Treating invalid media as generic attachment."); - self.messageCellType = OWSMessageCellType_GenericAttachment; - return; - } - } - - self.mediaAlbumItems = mediaAlbumItems; - self.messageCellType = OWSMessageCellType_MediaMessage; - return; - } - - // Only media galleries should have more than one attachment. - OWSAssertDebug(mediaAttachments.count <= 1); - - TSAttachment *_Nullable mediaAttachment = mediaAttachments.firstObject; - if (mediaAttachment) { - if ([mediaAttachment isKindOfClass:[TSAttachmentStream class]]) { - self.attachmentStream = (TSAttachmentStream *)mediaAttachment; - if ([self.attachmentStream isAudio]) { - CGFloat audioDurationSeconds = [self.attachmentStream audioDurationSeconds]; - if (audioDurationSeconds > 0) { - self.audioDurationSeconds = audioDurationSeconds; - self.messageCellType = OWSMessageCellType_Audio; - } else { - self.messageCellType = OWSMessageCellType_GenericAttachment; - } - } else if (self.messageCellType == OWSMessageCellType_Unknown) { - self.messageCellType = OWSMessageCellType_GenericAttachment; - } - } else if ([mediaAttachment isKindOfClass:[TSAttachmentPointer class]]) { - if ([mediaAttachment isAudio]) { - self.audioDurationSeconds = 0; - self.messageCellType = OWSMessageCellType_Audio; - } else { - self.messageCellType = OWSMessageCellType_GenericAttachment; - } - self.attachmentPointer = (TSAttachmentPointer *)mediaAttachment; - } else { - OWSFailDebug(@"Unknown attachment type"); - } - } - - if (self.hasBodyText) { - if (self.messageCellType == OWSMessageCellType_Unknown) { - self.messageCellType = OWSMessageCellType_TextOnlyMessage; - } - OWSAssertDebug(self.displayableBodyText); - } - - if (self.hasBodyText && message.linkPreview) { - self.linkPreview = message.linkPreview; - if (message.linkPreview.imageAttachmentId && message.linkPreview.imageAttachmentId.length > 0) { - TSAttachment *_Nullable linkPreviewAttachment = - [TSAttachment fetchObjectWithUniqueID:message.linkPreview.imageAttachmentId transaction:transaction]; - if (!linkPreviewAttachment) { - OWSLogDebug(@"Could not load link preview image attachment."); - } else if (!linkPreviewAttachment.isImage) { - OWSLogDebug(@"Link preview attachment isn't an image."); - } else if ([linkPreviewAttachment isKindOfClass:[TSAttachmentStream class]]) { - TSAttachmentStream *attachmentStream = (TSAttachmentStream *)linkPreviewAttachment; - if (!attachmentStream.isValidImage) { - OWSLogDebug(@"Link preview image attachment isn't valid."); - } else { - self.linkPreviewAttachment = linkPreviewAttachment; - } - } else { - self.linkPreviewAttachment = linkPreviewAttachment; - } - } - } - - if (self.messageCellType == OWSMessageCellType_Unknown) { - // Messages of unknown type (including messages with missing attachments) - // are rendered like empty text messages, but without any interactivity. - OWSLogWarn(@"Treating unknown message as empty text message: %@ %llu", message.class, message.timestamp); - self.messageCellType = OWSMessageCellType_TextOnlyMessage; - self.displayableBodyText = [[DisplayableText alloc] initWithFullText:@"" displayText:@"" isTextTruncated:NO]; - } -} - -- (NSArray *)albumItemsForMediaAttachments:(NSArray *)attachments -{ - OWSAssertIsOnMainThread(); - - NSMutableArray *mediaAlbumItems = [NSMutableArray new]; - for (TSAttachment *attachment in attachments) { - if (!attachment.isVisualMedia) { - // Well behaving clients should not send a mix of visual media (like JPG) and non-visual media (like PDF's) - // Since we're not coped to handle a mix of media, return @[] - OWSAssertDebug(mediaAlbumItems.count == 0); - return @[]; - } - - NSString *_Nullable caption = (attachment.caption - ? [self displayableCaptionForText:attachment.caption attachmentId:attachment.uniqueId].displayText - : nil); - - if (![attachment isKindOfClass:[TSAttachmentStream class]]) { - TSAttachmentPointer *attachmentPointer = (TSAttachmentPointer *)attachment; - CGSize mediaSize = CGSizeZero; - if (attachmentPointer.mediaSize.width > 0 && attachmentPointer.mediaSize.height > 0) { - mediaSize = attachmentPointer.mediaSize; - } - [mediaAlbumItems addObject:[[ConversationMediaAlbumItem alloc] initWithAttachment:attachment - attachmentStream:nil - caption:caption - mediaSize:mediaSize]]; - continue; - } - TSAttachmentStream *attachmentStream = (TSAttachmentStream *)attachment; - if (![attachmentStream isValidVisualMedia]) { - OWSLogWarn(@"Filtering invalid media."); - [mediaAlbumItems addObject:[[ConversationMediaAlbumItem alloc] initWithAttachment:attachment - attachmentStream:nil - caption:caption - mediaSize:CGSizeZero]]; - continue; - } - CGSize mediaSize = [attachmentStream imageSize]; - if (mediaSize.width <= 0 || mediaSize.height <= 0) { - OWSLogWarn(@"Filtering media with invalid size."); - [mediaAlbumItems addObject:[[ConversationMediaAlbumItem alloc] initWithAttachment:attachment - attachmentStream:nil - caption:caption - mediaSize:CGSizeZero]]; - continue; - } - - ConversationMediaAlbumItem *mediaAlbumItem = - [[ConversationMediaAlbumItem alloc] initWithAttachment:attachment - attachmentStream:attachmentStream - caption:caption - mediaSize:mediaSize]; - [mediaAlbumItems addObject:mediaAlbumItem]; - } - return mediaAlbumItems; -} - -- (NSString *)systemMessageTextWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - OWSAssertDebug(transaction); - - switch (self.interaction.interactionType) { - case OWSInteractionType_Info: { - TSInfoMessage *infoMessage = (TSInfoMessage *)self.interaction; - return [infoMessage previewTextWithTransaction:transaction]; - } - default: - OWSFailDebug(@"not a system message."); - return nil; - } -} - -- (nullable NSString *)quotedAttachmentMimetype -{ - return self.quotedReply.contentType; -} - -- (nullable NSString *)quotedRecipientId -{ - return self.quotedReply.authorId; -} - -- (OWSMessageCellType)messageCellType -{ - OWSAssertIsOnMainThread(); - - return _messageCellType; -} - -- (nullable DisplayableText *)displayableBodyText -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(self.hasViewState); - - OWSAssertDebug(_displayableBodyText); - OWSAssertDebug(_displayableBodyText.displayText); - OWSAssertDebug(_displayableBodyText.fullText); - - return _displayableBodyText; -} - -- (nullable TSAttachmentStream *)attachmentStream -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(self.hasViewState); - - return _attachmentStream; -} - -- (nullable TSAttachmentPointer *)attachmentPointer -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(self.hasViewState); - - return _attachmentPointer; -} - -- (nullable DisplayableText *)displayableQuotedText -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(self.hasViewState); - - OWSAssertDebug(_displayableQuotedText); - OWSAssertDebug(_displayableQuotedText.displayText); - OWSAssertDebug(_displayableQuotedText.fullText); - - return _displayableQuotedText; -} - -- (void)copyTextAction -{ - if (self.attachmentPointer != nil) { - OWSFailDebug(@"Can't copy not-yet-downloaded attachment"); - return; - } - - switch (self.messageCellType) { - case OWSMessageCellType_TextOnlyMessage: - case OWSMessageCellType_Audio: - case OWSMessageCellType_MediaMessage: - case OWSMessageCellType_GenericAttachment: { - OWSAssertDebug(self.displayableBodyText); - [UIPasteboard.generalPasteboard setString:self.displayableBodyText.fullText]; - break; - } - case OWSMessageCellType_Unknown: { - OWSFailDebug(@"No text to copy"); - break; - } - case OWSMessageCellType_OversizeTextDownloading: - OWSFailDebug(@"Can't copy not-yet-downloaded attachment"); - return; - } -} - -- (void)copyMediaAction -{ - if (self.attachmentPointer != nil) { - OWSFailDebug(@"Can't copy not-yet-downloaded attachment"); - return; - } - - switch (self.messageCellType) { - case OWSMessageCellType_Unknown: - case OWSMessageCellType_TextOnlyMessage: - case OWSMessageCellType_Audio: - case OWSMessageCellType_GenericAttachment: { - [self copyAttachmentToPasteboard:self.attachmentStream]; - break; - } - case OWSMessageCellType_MediaMessage: { - if (self.mediaAlbumItems.count == 1) { - ConversationMediaAlbumItem *mediaAlbumItem = self.mediaAlbumItems.firstObject; - if (mediaAlbumItem.attachmentStream && mediaAlbumItem.attachmentStream.isValidVisualMedia) { - [self copyAttachmentToPasteboard:mediaAlbumItem.attachmentStream]; - return; - } - } - - OWSFailDebug(@"Can't copy media album"); - break; - } - case OWSMessageCellType_OversizeTextDownloading: - OWSFailDebug(@"Can't copy not-yet-downloaded attachment"); - return; - } -} - -- (void)copyAttachmentToPasteboard:(TSAttachmentStream *)attachment -{ - OWSAssertDebug(attachment); - - NSString *utiType = [MIMETypeUtil utiTypeForMIMEType:attachment.contentType]; - if (!utiType) { - OWSFailDebug(@"Unknown MIME type: %@", attachment.contentType); - utiType = (NSString *)kUTTypeGIF; - } - NSData *data = [NSData dataWithContentsOfURL:[attachment originalMediaURL]]; - if (!data) { - OWSFailDebug(@"Could not load attachment data"); - return; - } - [UIPasteboard.generalPasteboard setData:data forPasteboardType:utiType]; -} - -- (void)shareMediaAction -{ - if (self.attachmentPointer != nil) { - OWSFailDebug(@"Can't share not-yet-downloaded attachment"); - return; - } - - switch (self.messageCellType) { - case OWSMessageCellType_Unknown: - case OWSMessageCellType_TextOnlyMessage: - case OWSMessageCellType_Audio: - case OWSMessageCellType_GenericAttachment: - [AttachmentSharing showShareUIForAttachment:self.attachmentStream]; - break; - case OWSMessageCellType_MediaMessage: { - // TODO: We need a "canShareMediaAction" method. - OWSAssertDebug(self.mediaAlbumItems); - NSMutableArray *attachmentStreams = [NSMutableArray new]; - for (ConversationMediaAlbumItem *mediaAlbumItem in self.mediaAlbumItems) { - if (mediaAlbumItem.attachmentStream && mediaAlbumItem.attachmentStream.isValidVisualMedia) { - [attachmentStreams addObject:mediaAlbumItem.attachmentStream]; - } - } - if (attachmentStreams.count < 1) { - OWSFailDebug(@"Can't share media album; no valid items."); - return; - } - [AttachmentSharing showShareUIForAttachments:attachmentStreams completion:nil]; - break; - } - case OWSMessageCellType_OversizeTextDownloading: - OWSFailDebug(@"Can't share not-yet-downloaded attachment"); - return; - } -} - -- (BOOL)canCopyMedia -{ - if (self.attachmentPointer != nil) { - // The attachment is still downloading. - return NO; - } - - switch (self.messageCellType) { - case OWSMessageCellType_Unknown: - case OWSMessageCellType_TextOnlyMessage: - case OWSMessageCellType_Audio: - return NO; - case OWSMessageCellType_GenericAttachment: - case OWSMessageCellType_MediaMessage: { - if (self.mediaAlbumItems.count == 1) { - ConversationMediaAlbumItem *mediaAlbumItem = self.mediaAlbumItems.firstObject; - if (mediaAlbumItem.attachmentStream && mediaAlbumItem.attachmentStream.isValidVisualMedia) { - return YES; - } - } - return NO; - } - case OWSMessageCellType_OversizeTextDownloading: - return NO; - } -} - -- (BOOL)canSaveMedia -{ - if (self.attachmentPointer != nil) { - // The attachment is still downloading. - return NO; - } - - switch (self.messageCellType) { - case OWSMessageCellType_Unknown: - case OWSMessageCellType_TextOnlyMessage: - case OWSMessageCellType_Audio: - return NO; - case OWSMessageCellType_GenericAttachment: - return NO; - case OWSMessageCellType_MediaMessage: { - for (ConversationMediaAlbumItem *mediaAlbumItem in self.mediaAlbumItems) { - if (!mediaAlbumItem.attachmentStream) { - continue; - } - if (!mediaAlbumItem.attachmentStream.isValidVisualMedia) { - continue; - } - if (mediaAlbumItem.attachmentStream.isImage || mediaAlbumItem.attachmentStream.isAnimated) { - return YES; - } - if (mediaAlbumItem.attachmentStream.isVideo) { - if (UIVideoAtPathIsCompatibleWithSavedPhotosAlbum( - mediaAlbumItem.attachmentStream.originalFilePath)) { - return YES; - } - } - } - return NO; - } - case OWSMessageCellType_OversizeTextDownloading: - return NO; - } -} - -- (void)saveMediaAction -{ - if (self.attachmentPointer != nil) { - OWSFailDebug(@"Can't save not-yet-downloaded attachment"); - return; - } - switch (self.messageCellType) { - case OWSMessageCellType_Unknown: - case OWSMessageCellType_TextOnlyMessage: - case OWSMessageCellType_Audio: - OWSFailDebug(@"Cannot save media data."); - break; - case OWSMessageCellType_GenericAttachment: - OWSFailDebug(@"Cannot save media data."); - break; - case OWSMessageCellType_MediaMessage: { - [self saveMediaAlbumItems]; - break; - } - case OWSMessageCellType_OversizeTextDownloading: - OWSFailDebug(@"Can't save not-yet-downloaded attachment"); - return; - } -} - -- (void)saveMediaAlbumItems -{ - // We need to do these writes serially to avoid "write busy" errors - // from too many concurrent asset saves. - [self saveMediaAlbumItems:[self.mediaAlbumItems mutableCopy]]; -} - -- (void)saveMediaAlbumItems:(NSMutableArray *)mediaAlbumItems -{ - if (mediaAlbumItems.count < 1) { - return; - } - ConversationMediaAlbumItem *mediaAlbumItem = mediaAlbumItems.firstObject; - [mediaAlbumItems removeObjectAtIndex:0]; - - if (!mediaAlbumItem.attachmentStream || !mediaAlbumItem.attachmentStream.isValidVisualMedia) { - // Skip this item. - } else if (mediaAlbumItem.attachmentStream.isImage || mediaAlbumItem.attachmentStream.isAnimated) { - [[PHPhotoLibrary sharedPhotoLibrary] - performChanges:^{ - [PHAssetChangeRequest - creationRequestForAssetFromImageAtFileURL:mediaAlbumItem.attachmentStream.originalMediaURL]; - } - completionHandler:^(BOOL success, NSError *error) { - if (error || !success) { - OWSFailDebug(@"Image save failed: %@", error); - } - [self saveMediaAlbumItems:mediaAlbumItems]; - }]; - return; - } else if (mediaAlbumItem.attachmentStream.isVideo) { - [[PHPhotoLibrary sharedPhotoLibrary] - performChanges:^{ - [PHAssetChangeRequest - creationRequestForAssetFromVideoAtFileURL:mediaAlbumItem.attachmentStream.originalMediaURL]; - } - completionHandler:^(BOOL success, NSError *error) { - if (error || !success) { - OWSFailDebug(@"Video save failed: %@", error); - } - [self saveMediaAlbumItems:mediaAlbumItems]; - }]; - return; - } - return [self saveMediaAlbumItems:mediaAlbumItems]; -} - -- (void)deleteLocallyAction -{ - TSMessage *message = (TSMessage *)self.interaction; - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [MessageInvalidator invalidate:message with:transaction]; - [self.interaction removeWithTransaction:transaction]; - if (self.interaction.interactionType == OWSInteractionType_OutgoingMessage) { - [LKStorage.shared cancelPendingMessageSendJobIfNeededForMessage:self.interaction.timestamp using:transaction]; - } - }]; -} - -- (void)deleteRemotelyAction -{ - TSMessage *message = (TSMessage *)self.interaction; - - if (self.isGroupThread) { - TSGroupThread *groupThread = (TSGroupThread *)self.interaction.thread; - - // Only allow deletion on incoming and outgoing messages - OWSInteractionType interationType = self.interaction.interactionType; - if (interationType != OWSInteractionType_IncomingMessage && interationType != OWSInteractionType_OutgoingMessage) return; - - if (groupThread.isOpenGroup) { - // Make sure it's an open group message - if (!message.isOpenGroupMessage) return; - - // Get the open group - SNOpenGroupV2 *openGroupV2 = [LKStorage.shared getV2OpenGroupForThreadID:groupThread.uniqueId]; - if (openGroupV2 == nil) return; - - // If it's an incoming message the user must have moderator status - if (self.interaction.interactionType == OWSInteractionType_IncomingMessage) { - NSString *userPublicKey = [SNGeneralUtilities getUserPublicKey]; - if (![SNOpenGroupAPIV2 isUserModerator:userPublicKey forRoom:openGroupV2.room onServer:openGroupV2.server]) { return; } - } - - // Delete the message - [[SNOpenGroupAPIV2 deleteMessageWithServerID:message.openGroupServerMessageID fromRoom:openGroupV2.room onServer:openGroupV2.server].catch(^(NSError *error) { - // Roll back - [self.interaction save]; - }) retainUntilComplete]; - } else { - NSString *groupPublicKey = [LKGroupUtilities getDecodedGroupID:groupThread.groupModel.groupId]; - [[SNSnodeAPI deleteMessageForPublickKey:groupPublicKey serverHashes:@[message.serverHash]].catch(^(NSError *error) { - // Roll back - [self.interaction save]; - }) retainUntilComplete]; - } - } else { - TSContactThread *contactThread = (TSContactThread *)self.interaction.thread; - [[SNSnodeAPI deleteMessageForPublickKey:contactThread.contactSessionID serverHashes:@[message.serverHash]].catch(^(NSError *error) { - // Roll back - [self.interaction save]; - }) retainUntilComplete]; - } - -} - -// Remove this after the unsend request is enabled -- (void)deleteAction -{ - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [self.interaction removeWithTransaction:transaction]; - if (self.interaction.interactionType == OWSInteractionType_OutgoingMessage) { - [LKStorage.shared cancelPendingMessageSendJobIfNeededForMessage:self.interaction.timestamp using:transaction]; - } - }]; - - if (self.isGroupThread) { - TSGroupThread *groupThread = (TSGroupThread *)self.interaction.thread; - - // Only allow deletion on incoming and outgoing messages - OWSInteractionType interationType = self.interaction.interactionType; - if (interationType != OWSInteractionType_IncomingMessage && interationType != OWSInteractionType_OutgoingMessage) return; - - // Make sure it's an open group message - TSMessage *message = (TSMessage *)self.interaction; - if (!message.isOpenGroupMessage) return; - - // Get the open group - SNOpenGroupV2 *openGroupV2 = [LKStorage.shared getV2OpenGroupForThreadID:groupThread.uniqueId]; - if (openGroup == nil && openGroupV2 == nil) return; - - // If it's an incoming message the user must have moderator status - if (self.interaction.interactionType == OWSInteractionType_IncomingMessage) { - NSString *userPublicKey = [SNGeneralUtilities getUserPublicKey]; - if (openGroupV2 != nil) { - if (![SNOpenGroupAPIV2 isUserModerator:userPublicKey forRoom:openGroupV2.room onServer:openGroupV2.server]) { return; } - } - } - - // Delete the message - BOOL wasSentByUser = (interationType == OWSInteractionType_OutgoingMessage); - if (openGroupV2 != nil) { - [[SNOpenGroupAPIV2 deleteMessageWithServerID:message.openGroupServerMessageID fromRoom:openGroupV2.room onServer:openGroupV2.server].catch(^(NSError *error) { - // Roll back - [self.interaction save]; - }) retainUntilComplete]; - } - } -} - -- (BOOL)hasBodyTextActionContent -{ - return self.hasBodyText && self.displayableBodyText.fullText.length > 0; -} - -- (BOOL)hasMediaActionContent -{ - if (self.attachmentPointer != nil) { - // The attachment is still downloading. - return NO; - } - - switch (self.messageCellType) { - case OWSMessageCellType_Unknown: - case OWSMessageCellType_TextOnlyMessage: - case OWSMessageCellType_Audio: - case OWSMessageCellType_GenericAttachment: - return self.attachmentStream != nil; - case OWSMessageCellType_MediaMessage: - return self.firstValidAlbumAttachment != nil; - case OWSMessageCellType_OversizeTextDownloading: - return NO; - } -} - -- (BOOL)mediaAlbumHasFailedAttachment -{ - OWSAssertDebug(self.messageCellType == OWSMessageCellType_MediaMessage); - OWSAssertDebug(self.mediaAlbumItems.count > 0); - - for (ConversationMediaAlbumItem *mediaAlbumItem in self.mediaAlbumItems) { - if (mediaAlbumItem.isFailedDownload) { - return YES; - } - } - return NO; -} - -- (BOOL)userCanDeleteGroupMessage -{ - if (!self.isGroupThread) return false; - TSGroupThread *groupThread = (TSGroupThread *)self.interaction.thread; - - // Only allow deletion on incoming and outgoing messages - OWSInteractionType interationType = self.interaction.interactionType; - if (interationType != OWSInteractionType_OutgoingMessage && interationType != OWSInteractionType_IncomingMessage) return false; - - // Make sure it's an open group message - TSMessage *message = (TSMessage *)self.interaction; - if (!message.isOpenGroupMessage) return true; - - // Ensure we have the details needed to contact the server - SNOpenGroupV2 *openGroupV2 = [LKStorage.shared getV2OpenGroupForThreadID:groupThread.uniqueId]; - if (openGroupV2 == nil) return true; - - if (interationType == OWSInteractionType_IncomingMessage) { - // Only allow deletion on incoming messages if the user has moderation permission - if (openGroupV2 != nil) { - return [SNOpenGroupAPIV2 isUserModerator:[SNGeneralUtilities getUserPublicKey] forRoom:openGroupV2.room onServer:openGroupV2.server]; - } - } else { - return YES; - } -} - -- (BOOL)userHasModerationPermission -{ - if (!self.isGroupThread) return false; - TSGroupThread *groupThread = (TSGroupThread *)self.interaction.thread; - - // Make sure it's an open group message - TSMessage *message = (TSMessage *)self.interaction; - if (!message.isOpenGroupMessage) return false; - - // Ensure we have the details needed to contact the server - SNOpenGroupV2 *openGroupV2 = [LKStorage.shared getV2OpenGroupForThreadID:groupThread.uniqueId]; - if (openGroupV2 == nil) return false; - - // Check that we're a moderator - if (openGroupV2 != nil) { - return [SNOpenGroupAPIV2 isUserModerator:[SNGeneralUtilities getUserPublicKey] forRoom:openGroupV2.room onServer:openGroupV2.server]; - } -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/ConversationViewModel.h b/Session/Conversations/ConversationViewModel.h deleted file mode 100644 index 36ea8e697..000000000 --- a/Session/Conversations/ConversationViewModel.h +++ /dev/null @@ -1,142 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -NS_ASSUME_NONNULL_BEGIN - -@class ConversationStyle; -@class ConversationViewModel; -@class OWSQuotedReplyModel; -@class TSOutgoingMessage; -@class TSThread; -@class ThreadDynamicInteractions; - -@protocol ConversationViewItem; - -typedef NS_ENUM(NSUInteger, ConversationUpdateType) { - // No view items in the load window were effected. - ConversationUpdateType_Minor, - // A subset of view items in the load window were effected; - // the view should be updated using the update items. - ConversationUpdateType_Diff, - // Complicated or unexpected changes occurred in the load window; - // the view should be reloaded. - ConversationUpdateType_Reload, -}; - -#pragma mark - - -typedef NS_ENUM(NSUInteger, ConversationUpdateItemType) { - ConversationUpdateItemType_Insert, - ConversationUpdateItemType_Delete, - ConversationUpdateItemType_Update, -}; - -#pragma mark - - -@interface ConversationViewState : NSObject - -@property (nonatomic, readonly) NSArray> *viewItems; -@property (nonatomic, readonly) NSDictionary *interactionIndexMap; -// We have to track interactionIds separately. We can't just use interactionIndexMap.allKeys, -// as that won't preserve ordering. -@property (nonatomic, readonly) NSArray *interactionIds; -@property (nonatomic, readonly, nullable) NSNumber *unreadIndicatorIndex; - -@end - -#pragma mark - - -@interface ConversationUpdateItem : NSObject - -@property (nonatomic, readonly) ConversationUpdateItemType updateItemType; -// Only applies in the "delete" and "update" cases. -@property (nonatomic, readonly) NSUInteger oldIndex; -// Only applies in the "insert" and "update" cases. -@property (nonatomic, readonly) NSUInteger newIndex; -// Only applies in the "insert" and "update" cases. -@property (nonatomic, readonly, nullable) id viewItem; - -@end - -#pragma mark - - -@interface ConversationUpdate : NSObject - -@property (nonatomic, readonly) ConversationUpdateType conversationUpdateType; -// Only applies in the "diff" case. -@property (nonatomic, readonly, nullable) NSArray *updateItems; -//// Only applies in the "diff" case. -@property (nonatomic, readonly) BOOL shouldAnimateUpdates; - -@end - -#pragma mark - - -@protocol ConversationViewModelDelegate - -- (void)conversationViewModelWillUpdate; -- (void)conversationViewModelDidUpdate:(ConversationUpdate *)conversationUpdate; - -- (void)conversationViewModelWillLoadMoreItems; -- (void)conversationViewModelDidLoadMoreItems; -- (void)conversationViewModelDidLoadPrevPage; -- (void)conversationViewModelRangeDidChange; - -// Called after the view model recovers from a severe error -// to prod the view to reset its scroll state, etc. -- (void)conversationViewModelDidReset; - -@end - -#pragma mark - - -// Always load up to n messages when user arrives. -// -// The smaller this number is, the faster the conversation can display. -// To test, shrink you accessibility font as much as possible, then count how many 1-line system info messages (our -// shortest cells) can fit on screen at a time on an iPhoneX -// -// PERF: we could do less messages on shorter (older, slower) devices -// PERF: we could cache the cell height, since some messages will be much taller. -static const int kYapDatabasePageSize = 250; - -// Never show more than n messages in conversation view when user arrives. -static const int kConversationInitialMaxRangeSize = 250; - -// Never show more than n messages in conversation view at a time. -static const int kYapDatabaseRangeMaxLength = 250000; - -#pragma mark - - -@interface ConversationViewModel : NSObject - -@property (nonatomic, readonly) ConversationViewState *viewState; -@property (nonatomic, nullable) NSString *focusMessageIdOnOpen; -@property (nonatomic, readonly, nullable) ThreadDynamicInteractions *dynamicInteractions; - -- (instancetype)init NS_UNAVAILABLE; -- (instancetype)initWithThread:(TSThread *)thread - focusMessageIdOnOpen:(nullable NSString *)focusMessageIdOnOpen - delegate:(id)delegate NS_DESIGNATED_INITIALIZER; - -- (void)ensureDynamicInteractionsAndUpdateIfNecessary; - -- (void)loadAnotherPageOfMessages; - -- (void)viewDidResetContentAndLayout; - -- (void)viewDidLoad; - -- (BOOL)canLoadMoreItems; - -- (nullable NSIndexPath *)ensureLoadWindowContainsQuotedReply:(OWSQuotedReplyModel *)quotedReply; -- (nullable NSIndexPath *)ensureLoadWindowContainsInteractionId:(NSString *)interactionId; - -- (void)appendUnsavedOutgoingTextMessage:(TSOutgoingMessage *)outgoingMessage; - -- (BOOL)reloadViewItems; - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/ConversationViewModel.m b/Session/Conversations/ConversationViewModel.m deleted file mode 100644 index 1ba2ef903..000000000 --- a/Session/Conversations/ConversationViewModel.m +++ /dev/null @@ -1,1467 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "ConversationViewModel.h" -#import "ConversationViewItem.h" -#import "DateUtil.h" -#import "OWSQuotedReplyModel.h" -#import "Session-Swift.h" -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface ConversationProfileState : NSObject - -@property (nonatomic) BOOL hasLocalProfile; -@property (nonatomic) BOOL isThreadInProfileWhitelist; -@property (nonatomic) BOOL hasUnwhitelistedMember; - -@end - -#pragma mark - - -@implementation ConversationProfileState - -@end - -#pragma mark - - -@implementation ConversationViewState - -- (instancetype)initWithViewItems:(NSArray> *)viewItems -{ - self = [super init]; - if (!self) { - return self; - } - - _viewItems = viewItems; - NSMutableDictionary *interactionIndexMap = [NSMutableDictionary new]; - NSMutableArray *interactionIds = [NSMutableArray new]; - for (NSUInteger i = 0; i < self.viewItems.count; i++) { - id viewItem = self.viewItems[i]; - interactionIndexMap[viewItem.interaction.uniqueId] = @(i); - [interactionIds addObject:viewItem.interaction.uniqueId]; - - if (viewItem.unreadIndicator != nil && [viewItem.interaction conformsToProtocol:@protocol(OWSReadTracking)]) { - id interaction = (id)viewItem.interaction; - - // Under normal circumstances !interaction.read should always evaluate to true at this point, but - // there is a bug that can somehow cause it to be false leading to conversations permanently being - // stuck with "unread" messages. - - if (!interaction.read) { - _unreadIndicatorIndex = @(i); - } - } - } - _interactionIndexMap = [interactionIndexMap copy]; - _interactionIds = [interactionIds copy]; - - return self; -} - -- (nullable id)unreadIndicatorViewItem -{ - if (self.unreadIndicatorIndex == nil) { - return nil; - } - NSUInteger index = self.unreadIndicatorIndex.unsignedIntegerValue; - if (index >= self.viewItems.count) { - OWSFailDebug(@"Invalid index."); - return nil; - } - return self.viewItems[index]; -} - -@end - -#pragma mark - - -@implementation ConversationUpdateItem - -- (instancetype)initWithUpdateItemType:(ConversationUpdateItemType)updateItemType - oldIndex:(NSUInteger)oldIndex - newIndex:(NSUInteger)newIndex - viewItem:(nullable id)viewItem -{ - self = [super init]; - if (!self) { - return self; - } - - _updateItemType = updateItemType; - _oldIndex = oldIndex; - _newIndex = newIndex; - _viewItem = viewItem; - - return self; -} - -@end - -#pragma mark - - -@implementation ConversationUpdate - -- (instancetype)initWithConversationUpdateType:(ConversationUpdateType)conversationUpdateType - updateItems:(nullable NSArray *)updateItems - shouldAnimateUpdates:(BOOL)shouldAnimateUpdates -{ - self = [super init]; - if (!self) { - return self; - } - - _conversationUpdateType = conversationUpdateType; - _updateItems = updateItems; - _shouldAnimateUpdates = shouldAnimateUpdates; - - return self; -} - -+ (ConversationUpdate *)minorUpdate -{ - return [[ConversationUpdate alloc] initWithConversationUpdateType:ConversationUpdateType_Minor - updateItems:nil - shouldAnimateUpdates:NO]; -} - -+ (ConversationUpdate *)reloadUpdate -{ - return [[ConversationUpdate alloc] initWithConversationUpdateType:ConversationUpdateType_Reload - updateItems:nil - shouldAnimateUpdates:NO]; -} - -+ (ConversationUpdate *)diffUpdateWithUpdateItems:(nullable NSArray *)updateItems - shouldAnimateUpdates:(BOOL)shouldAnimateUpdates -{ - return [[ConversationUpdate alloc] initWithConversationUpdateType:ConversationUpdateType_Diff - updateItems:updateItems - shouldAnimateUpdates:shouldAnimateUpdates]; -} - -@end - -#pragma mark - - -@interface ConversationViewModel () - -@property (nonatomic, weak) id delegate; - -@property (nonatomic, readonly) TSThread *thread; - -// The mapping must be updated in lockstep with the uiDatabaseConnection. -// -// * The first (required) step is to update uiDatabaseConnection using beginLongLivedReadTransaction. -// * The second (required) step is to update messageMapping. The desired length of the mapping -// can be modified at this time. -// * The third (optional) step is to update the view items using reloadViewItems. -// * The steps must be done in strict order. -// * If we do any of the steps, we must do all of the required steps. -// * We can't use messageMapping or viewItems after the first step until we've -// done the last step; i.e.. we can't do any layout, since that uses the view -// items which haven't been updated yet. -// * Afterward, we must prod the view controller to update layout & view state. -@property (nonatomic) ConversationMessageMapping *messageMapping; - -@property (nonatomic) ConversationViewState *viewState; -@property (nonatomic) NSMutableDictionary> *viewItemCache; - -@property (nonatomic, nullable) ThreadDynamicInteractions *dynamicInteractions; -@property (nonatomic) BOOL hasClearedUnreadMessagesIndicator; -@property (nonatomic, nullable) NSDate *collapseCutoffDate; -@property (nonatomic, nullable) NSString *typingIndicatorsSender; - -@property (nonatomic, nullable) ConversationProfileState *conversationProfileState; -@property (nonatomic) BOOL hasTooManyOutgoingMessagesToBlockCached; - -@property (nonatomic) NSArray> *persistedViewItems; -@property (nonatomic) NSArray *unsavedOutgoingMessages; - -@property (nonatomic) BOOL hasUiDatabaseUpdatedExternally; - -@end - -#pragma mark - - -@implementation ConversationViewModel - -- (instancetype)initWithThread:(TSThread *)thread - focusMessageIdOnOpen:(nullable NSString *)focusMessageIdOnOpen - delegate:(id)delegate -{ - self = [super init]; - if (!self) { - return self; - } - - OWSAssertDebug(thread); - OWSAssertDebug(delegate); - - _thread = thread; - _delegate = delegate; - _persistedViewItems = @[]; - _unsavedOutgoingMessages = @[]; - self.focusMessageIdOnOpen = focusMessageIdOnOpen; - _viewState = [[ConversationViewState alloc] initWithViewItems:@[]]; - - [self configure]; - - return self; -} - -#pragma mark - Dependencies - -- (OWSPrimaryStorage *)primaryStorage -{ - OWSAssertDebug(SSKEnvironment.shared.primaryStorage); - - return SSKEnvironment.shared.primaryStorage; -} - -- (YapDatabaseConnection *)uiDatabaseConnection -{ - return self.primaryStorage.uiDatabaseConnection; -} - -- (YapDatabaseConnection *)editingDatabaseConnection -{ - return self.primaryStorage.dbReadWriteConnection; -} - -- (id)typingIndicators -{ - return SSKEnvironment.shared.typingIndicators; -} - -- (TSAccountManager *)tsAccountManager -{ - OWSAssertDebug(SSKEnvironment.shared.tsAccountManager); - - return SSKEnvironment.shared.tsAccountManager; -} - -#pragma mark - - -- (void)addNotificationListeners -{ - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(applicationDidEnterBackground:) - name:OWSApplicationDidEnterBackgroundNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(typingIndicatorStateDidChange:) - name:[OWSTypingIndicatorsImpl typingIndicatorStateDidChange] - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(blockListDidChange:) - name:NSNotification.contactBlockedStateChanged - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(localProfileDidChange:) - name:NSNotification.localProfileDidChange - object:nil]; -} - -- (void)localProfileDidChange:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - - self.conversationProfileState = nil; - [self updateForTransientItems]; -} - -- (void)blockListDidChange:(id)notification -{ - OWSAssertIsOnMainThread(); - - [self updateForTransientItems]; -} - -- (void)configure -{ - OWSLogInfo(@""); - - // We need to update the "unread indicator" _before_ we determine the initial range - // size, since it depends on where the unread indicator is placed. - self.typingIndicatorsSender = [self.typingIndicators typingRecipientIdForThread:self.thread]; - self.collapseCutoffDate = [NSDate new]; - - [self ensureDynamicInteractionsAndUpdateIfNecessary]; - [self.primaryStorage updateUIDatabaseConnectionToLatest]; - - [self createNewMessageMapping]; - if (![self reloadViewItems]) { - OWSFailDebug(@"failed to reload view items in configureForThread."); - } - - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(uiDatabaseDidUpdateExternally:) - name:OWSUIDatabaseConnectionDidUpdateExternallyNotification - object:self.primaryStorage.dbNotificationObject]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(uiDatabaseWillUpdate:) - name:OWSUIDatabaseConnectionWillUpdateNotification - object:self.primaryStorage.dbNotificationObject]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(uiDatabaseDidUpdate:) - name:OWSUIDatabaseConnectionDidUpdateNotification - object:self.primaryStorage.dbNotificationObject]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(applicationWillEnterForeground:) - name:OWSApplicationWillEnterForegroundNotification - object:nil]; -} - -- (void)viewDidLoad -{ - [self addNotificationListeners]; - - [self touchDbAsync]; -} - -- (void)touchDbAsync -{ - // See comments in primaryStorage.touchDbAsync. - [self.primaryStorage touchDbAsync]; -} - -- (void)dealloc -{ - [[NSNotificationCenter defaultCenter] removeObserver:self]; -} - -- (BOOL)canLoadMoreItems -{ - if (self.messageMapping.desiredLength >= kYapDatabaseRangeMaxLength) { - return NO; - } - - return self.messageMapping.canLoadMore; -} - -- (void)applicationDidEnterBackground:(NSNotification *)notification -{ - if (self.hasClearedUnreadMessagesIndicator) { - self.hasClearedUnreadMessagesIndicator = NO; - [self.dynamicInteractions clearUnreadIndicatorState]; - } -} - -- (void)viewDidResetContentAndLayout -{ - self.collapseCutoffDate = [NSDate new]; - if (![self reloadViewItems]) { - OWSFailDebug(@"failed to reload view items in resetContentAndLayout."); - } -} - -- (void)loadAnotherPageOfMessages -{ - BOOL hasEarlierUnseenMessages = self.dynamicInteractions.unreadIndicator.hasMoreUnseenMessages; - - // Now that we're using a "minimal" page size, we should - // increase the load window by 2 pages at a time. - [self loadNMoreMessages:kYapDatabasePageSize * 2]; - - // Don’t auto-scroll after “loading more messages” unless we have “more unseen messages”. - // - // Otherwise, tapping on "load more messages" autoscrolls you downward which is completely wrong. - if (hasEarlierUnseenMessages && !self.focusMessageIdOnOpen) { - // Ensure view items are updated before trying to scroll to the - // unread indicator. - // - // loadNMoreMessages calls resetMapping which calls ensureDynamicInteractions, - // which may move the unread indicator, and for scrollToUnreadIndicatorAnimated - // to work properly, the view items need to be updated to reflect that change. - [self.primaryStorage updateUIDatabaseConnectionToLatest]; - - [self.delegate conversationViewModelDidLoadPrevPage]; - } -} - -- (void)loadNMoreMessages:(NSUInteger)numberOfMessagesToLoad -{ - [self.delegate conversationViewModelWillLoadMoreItems]; - - [self resetMappingWithAdditionalLength:numberOfMessagesToLoad]; - - [self.delegate conversationViewModelDidLoadMoreItems]; -} - -- (NSUInteger)initialMessageMappingLength -{ - NSUInteger rangeLength = kYapDatabasePageSize; - - // If this is the first time we're configuring the range length, - // try to take into account the position of the unread indicator - // and the "focus message". - OWSAssertDebug(self.dynamicInteractions); - - if (self.focusMessageIdOnOpen) { - OWSAssertDebug(self.dynamicInteractions.focusMessagePosition); - if (self.dynamicInteractions.focusMessagePosition) { - OWSLogVerbose(@"ensuring load of focus message: %@", self.dynamicInteractions.focusMessagePosition); - rangeLength = MAX(rangeLength, 1 + self.dynamicInteractions.focusMessagePosition.unsignedIntegerValue); - } - } - - if (self.dynamicInteractions.unreadIndicator) { - NSUInteger unreadIndicatorPosition - = (NSUInteger)self.dynamicInteractions.unreadIndicator.unreadIndicatorPosition; - - // If there is an unread indicator, increase the initial load window - // to include it. - OWSAssertDebug(unreadIndicatorPosition > 0); - OWSAssertDebug(unreadIndicatorPosition <= kYapDatabaseRangeMaxLength); - - // We'd like to include at least N seen messages, - // to give the user the context of where they left off the conversation. - const NSUInteger kPreferredSeenMessageCount = 1; - rangeLength = MAX(rangeLength, unreadIndicatorPosition + kPreferredSeenMessageCount); - } - - return rangeLength; -} - -- (void)updateMessageMappingWithAdditionalLength:(NSUInteger)additionalLength -{ - // Range size should monotonically increase. - NSUInteger rangeLength = self.messageMapping.desiredLength + additionalLength; - - // Always try to load at least a single page of messages. - rangeLength = MAX(rangeLength, kYapDatabasePageSize); - - // Enforce max range size. - rangeLength = MIN(rangeLength, kYapDatabaseRangeMaxLength); - - [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - [self.messageMapping updateWithDesiredLength:rangeLength transaction:transaction]; - }]; - - [self.delegate conversationViewModelRangeDidChange]; - self.collapseCutoffDate = [NSDate new]; -} - -- (void)ensureDynamicInteractionsAndUpdateIfNecessary -{ - OWSAssertIsOnMainThread(); - - const int currentMaxRangeSize = (int)self.messageMapping.desiredLength; - const int maxRangeSize = MAX(kConversationInitialMaxRangeSize, currentMaxRangeSize); - - ThreadDynamicInteractions *dynamicInteractions = - [ThreadUtil ensureDynamicInteractionsForThread:self.thread - dbConnection:self.editingDatabaseConnection - hideUnreadMessagesIndicator:self.hasClearedUnreadMessagesIndicator - lastUnreadIndicator:self.dynamicInteractions.unreadIndicator - focusMessageId:self.focusMessageIdOnOpen - maxRangeSize:maxRangeSize]; - BOOL didChange = ![NSObject isNullableObject:self.dynamicInteractions equalTo:dynamicInteractions]; - self.dynamicInteractions = dynamicInteractions; - - if (didChange) { - if (![self reloadViewItems]) { - OWSFailDebug(@"Failed to reload view items."); - } - - [self.delegate conversationViewModelDidUpdate:ConversationUpdate.reloadUpdate]; - } -} - -#pragma mark - Storage access - -- (void)uiDatabaseDidUpdateExternally:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - // External database modifications (e.g. changes from another process such as the SAE) - // are "flushed" using touchDbAsync when the app re-enters the foreground. - // - // The NSE will trigger this when we receive a new message through a remote notification. - // In this scenario, touchDbAsync will trigger uiDatabaseDidUpdate, but with a notification - // that does NOT include the recent update from NSE. This flag lets uiDatabaseDidUpdate - // know it needs to expect more updates than those in the notification. - _hasUiDatabaseUpdatedExternally = true; -} - -- (void)uiDatabaseWillUpdate:(NSNotification *)notification -{ - [self.delegate conversationViewModelWillUpdate]; -} - -- (void)uiDatabaseDidUpdate:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - - NSArray *notifications = notification.userInfo[OWSUIDatabaseConnectionNotificationsKey]; - OWSAssertDebug([notifications isKindOfClass:[NSArray class]]); - - YapDatabaseAutoViewConnection *messageDatabaseView = - [self.uiDatabaseConnection ext:TSMessageDatabaseViewExtensionName]; - OWSAssertDebug([messageDatabaseView isKindOfClass:[YapDatabaseAutoViewConnection class]]); - if (![messageDatabaseView hasChangesForGroup:self.thread.uniqueId inNotifications:notifications] && !self.hasUiDatabaseUpdatedExternally) { - [self.delegate conversationViewModelDidUpdate:ConversationUpdate.minorUpdate]; - return; - } - - _hasUiDatabaseUpdatedExternally = false; - - __block ConversationMessageMappingDiff *_Nullable diff = nil; - [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - diff = [self.messageMapping updateAndCalculateDiffWithTransaction:transaction notifications:notifications]; - }]; - if (!diff) { - OWSFailDebug(@"Could not determine diff"); - // resetMapping will call delegate.conversationViewModelDidUpdate. - [self resetMapping]; - [self.delegate conversationViewModelDidReset]; - return; - } - if (diff.addedItemIds.count < 1 && diff.removedItemIds.count < 1 && diff.updatedItemIds.count < 1) { - // This probably isn't an error; presumably the modifications - // occurred outside the load window. - OWSLogDebug(@"Empty diff."); - [self.delegate conversationViewModelDidUpdate:ConversationUpdate.minorUpdate]; - return; - } - - NSMutableSet *diffAddedItemIds = [diff.addedItemIds mutableCopy]; - NSMutableSet *diffRemovedItemIds = [diff.removedItemIds mutableCopy]; - NSMutableSet *diffUpdatedItemIds = [diff.updatedItemIds mutableCopy]; - for (TSOutgoingMessage *unsavedOutgoingMessage in self.unsavedOutgoingMessages) { - BOOL isFound = ([diff.addedItemIds containsObject:unsavedOutgoingMessage.uniqueId] || - [diff.removedItemIds containsObject:unsavedOutgoingMessage.uniqueId] || - [diff.updatedItemIds containsObject:unsavedOutgoingMessage.uniqueId]); - if (isFound) { - // Convert the "insert" to an "update". - if ([diffAddedItemIds containsObject:unsavedOutgoingMessage.uniqueId]) { - OWSLogVerbose(@"Converting insert to update: %@", unsavedOutgoingMessage.uniqueId); - [diffAddedItemIds removeObject:unsavedOutgoingMessage.uniqueId]; - [diffUpdatedItemIds addObject:unsavedOutgoingMessage.uniqueId]; - } - - // Remove the unsavedOutgoingViewItem since it now exists as a persistedViewItem - NSMutableArray *unsavedOutgoingMessages = [self.unsavedOutgoingMessages mutableCopy]; - [unsavedOutgoingMessages removeObject:unsavedOutgoingMessage]; - self.unsavedOutgoingMessages = [unsavedOutgoingMessages copy]; - } - } - - NSArray *oldItemIdList = self.viewState.interactionIds; - - // We need to reload any modified interactions _before_ we call - // reloadViewItems. - BOOL hasMalformedRowChange = NO; - NSMutableSet *updatedItemSet = [NSMutableSet new]; - for (NSString *uniqueId in diffUpdatedItemIds) { - id _Nullable viewItem = self.viewItemCache[uniqueId]; - if (viewItem) { - [self reloadInteractionForViewItem:viewItem]; - [updatedItemSet addObject:viewItem.itemId]; - } else { - OWSFailDebug(@"Update is missing view item"); - hasMalformedRowChange = YES; - } - } - for (NSString *uniqueId in diffRemovedItemIds) { - [self.viewItemCache removeObjectForKey:uniqueId]; - } - - if (hasMalformedRowChange) { - // These errors seems to be very rare; they can only be reproduced - // using the more extreme actions in the debug UI. - OWSFailDebug(@"hasMalformedRowChange"); - // resetMapping will call delegate.conversationViewModelDidUpdate. - [self resetMapping]; - [self.delegate conversationViewModelDidReset]; - return; - } - - if (![self reloadViewItems]) { - // These errors are rare. - OWSFailDebug(@"could not reload view items; hard resetting message mapping."); - // resetMapping will call delegate.conversationViewModelDidUpdate. - [self resetMapping]; - [self.delegate conversationViewModelDidReset]; - return; - } - - OWSLogVerbose(@"self.viewItems.count: %zd -> %zd", oldItemIdList.count, self.viewState.viewItems.count); - - [self updateViewWithOldItemIdList:oldItemIdList updatedItemSet:updatedItemSet]; -} - -// A simpler version of the update logic we use when -// only transient items have changed. -- (void)updateForTransientItems -{ - OWSAssertIsOnMainThread(); - - OWSLogVerbose(@""); - - NSArray *oldItemIdList = self.viewState.interactionIds; - - if (![self reloadViewItems]) { - // These errors are rare. - OWSFailDebug(@"could not reload view items; hard resetting message mapping."); - // resetMapping will call delegate.conversationViewModelDidUpdate. - [self resetMapping]; - [self.delegate conversationViewModelDidReset]; - return; - } - - OWSLogVerbose(@"self.viewItems.count: %zd -> %zd", oldItemIdList.count, self.viewState.viewItems.count); - - [self updateViewWithOldItemIdList:oldItemIdList updatedItemSet:[NSSet set]]; -} - -- (void)updateViewWithOldItemIdList:(NSArray *)oldItemIdList - updatedItemSet:(NSSet *)updatedItemSetParam { - OWSAssertDebug(oldItemIdList); - OWSAssertDebug(updatedItemSetParam); - - if (oldItemIdList.count != [NSSet setWithArray:oldItemIdList].count) { - OWSFailDebug(@"Old view item list has duplicates."); - [self.delegate conversationViewModelDidUpdate:ConversationUpdate.reloadUpdate]; - return; - } - - NSArray *newItemIdList = self.viewState.interactionIds; - NSMutableDictionary> *newViewItemMap = [NSMutableDictionary new]; - for (id viewItem in self.viewState.viewItems) { - newViewItemMap[viewItem.itemId] = viewItem; - } - - if (newItemIdList.count != [NSSet setWithArray:newItemIdList].count) { - OWSFailDebug(@"New view item list has duplicates."); - [self.delegate conversationViewModelDidUpdate:ConversationUpdate.reloadUpdate]; - return; - } - - NSSet *oldItemIdSet = [NSSet setWithArray:oldItemIdList]; - NSSet *newItemIdSet = [NSSet setWithArray:newItemIdList]; - - // We use sets and dictionaries here to ensure perf. - // We use NSMutableOrderedSet to preserve item ordering. - NSMutableOrderedSet *deletedItemIdSet = [NSMutableOrderedSet orderedSetWithArray:oldItemIdList]; - [deletedItemIdSet minusSet:newItemIdSet]; - NSMutableOrderedSet *insertedItemIdSet = [NSMutableOrderedSet orderedSetWithArray:newItemIdList]; - [insertedItemIdSet minusSet:oldItemIdSet]; - NSArray *deletedItemIdList = [deletedItemIdSet.array copy]; - NSArray *insertedItemIdList = [insertedItemIdSet.array copy]; - - // Try to generate a series of "update items" that safely transform - // the "old item list" into the "new item list". - NSMutableArray *updateItems = [NSMutableArray new]; - NSMutableArray *transformedItemList = [oldItemIdList mutableCopy]; - - // 1. Deletes - Always perform deletes before inserts and updates. - // - // NOTE: We use reverseObjectEnumerator to ensure that items - // are deleted in reverse order, to avoid confusion around - // each deletion affecting the indices of subsequent deletions. - for (NSString *itemId in deletedItemIdList.reverseObjectEnumerator) { - OWSAssertDebug([oldItemIdSet containsObject:itemId]); - OWSAssertDebug(![newItemIdSet containsObject:itemId]); - - NSUInteger oldIndex = [oldItemIdList indexOfObject:itemId]; - if (oldIndex == NSNotFound) { - OWSFailDebug(@"Can't find index of deleted view item."); - return [self.delegate conversationViewModelDidUpdate:ConversationUpdate.reloadUpdate]; - } - - [updateItems addObject:[[ConversationUpdateItem alloc] initWithUpdateItemType:ConversationUpdateItemType_Delete - oldIndex:oldIndex - newIndex:NSNotFound - viewItem:nil]]; - [transformedItemList removeObject:itemId]; - } - - // 2. Inserts - Always perform inserts before updates. - // - // NOTE: We DO NOT use reverseObjectEnumerator. - for (NSString *itemId in insertedItemIdList) { - OWSAssertDebug(![oldItemIdSet containsObject:itemId]); - OWSAssertDebug([newItemIdSet containsObject:itemId]); - - NSUInteger newIndex = [newItemIdList indexOfObject:itemId]; - if (newIndex == NSNotFound) { - OWSFailDebug(@"Can't find index of inserted view item."); - return [self.delegate conversationViewModelDidUpdate:ConversationUpdate.reloadUpdate]; - } - id _Nullable viewItem = newViewItemMap[itemId]; - if (!viewItem) { - OWSFailDebug(@"Can't find inserted view item."); - return [self.delegate conversationViewModelDidUpdate:ConversationUpdate.reloadUpdate]; - } - - [updateItems addObject:[[ConversationUpdateItem alloc] initWithUpdateItemType:ConversationUpdateItemType_Insert - oldIndex:NSNotFound - newIndex:newIndex - viewItem:viewItem]]; - [transformedItemList insertObject:itemId atIndex:newIndex]; - } - - if (![newItemIdList isEqualToArray:transformedItemList]) { - // We should be able to represent all transformations as a series of - // inserts, updates and deletes - moves should not be necessary. - // - // TODO: The unread indicator might end up being an exception. - OWSLogWarn(@"New and updated view item lists don't match."); - return [self.delegate conversationViewModelDidUpdate:ConversationUpdate.reloadUpdate]; - } - - // In addition to "update" items from the database change notification, - // we may need to update other items. One example is neighbors of modified - // cells. Another is cells whose appearance has changed due to the passage - // of time. We detect "dirty" items by whether or not they have cached layout - // state, since that is cleared whenever we change the properties of the - // item that affect its appearance. - // - // This replaces the setCellDrawingDependencyOffsets/ - // YapDatabaseViewChangedDependency logic offered by YDB mappings, - // which only reflects changes in the data store, not at the view - // level. - NSMutableSet *updatedItemSet = [updatedItemSetParam mutableCopy]; - NSMutableSet *updatedNeighborItemSet = [NSMutableSet new]; - for (NSString *itemId in newItemIdSet) { - if (![oldItemIdSet containsObject:itemId]) { - continue; - } - if ([insertedItemIdSet containsObject:itemId] || [updatedItemSet containsObject:itemId]) { - continue; - } - OWSAssertDebug(![deletedItemIdSet containsObject:itemId]); - - NSUInteger newIndex = [newItemIdList indexOfObject:itemId]; - if (newIndex == NSNotFound) { - OWSFailDebug(@"Can't find index of holdover view item."); - return [self.delegate conversationViewModelDidUpdate:ConversationUpdate.reloadUpdate]; - } - id _Nullable viewItem = newViewItemMap[itemId]; - if (!viewItem) { - OWSFailDebug(@"Can't find holdover view item."); - return [self.delegate conversationViewModelDidUpdate:ConversationUpdate.reloadUpdate]; - } - - if ([viewItem.interaction isKindOfClass:TSMessage.class]) { - TSMessage *message = (TSMessage *)viewItem.interaction; - if ([MessageInvalidator isInvalidated:message]) { - [updatedItemSet addObject:itemId]; - [updatedNeighborItemSet addObject:itemId]; - } - } - - // Add the following item of a deleted message to update - // to show the date header of the deleted message if needed - for (NSString *deletedItemId in deletedItemIdSet) { - NSUInteger oldIndex = [oldItemIdList indexOfObject:deletedItemId]; - if (oldIndex != NSNotFound && oldIndex + 1 < oldItemIdList.count) { - NSString *nextItemId = oldItemIdList[oldIndex + 1]; - [updatedItemSet addObject:nextItemId]; - [updatedNeighborItemSet addObject:nextItemId]; - } - } - } - - // 3. Updates. - // - // NOTE: Order doesn't matter. - for (NSString *itemId in updatedItemSet) { - if (![newItemIdList containsObject:itemId]) { - OWSFailDebug(@"Updated view item not in new view item list."); - continue; - } - if ([insertedItemIdList containsObject:itemId]) { - continue; - } - NSUInteger oldIndex = [oldItemIdList indexOfObject:itemId]; - if (oldIndex == NSNotFound) { - OWSFailDebug(@"Can't find old index of updated view item."); - return [self.delegate conversationViewModelDidUpdate:ConversationUpdate.reloadUpdate]; - } - NSUInteger newIndex = [newItemIdList indexOfObject:itemId]; - if (newIndex == NSNotFound) { - OWSFailDebug(@"Can't find new index of updated view item."); - return [self.delegate conversationViewModelDidUpdate:ConversationUpdate.reloadUpdate]; - } - id _Nullable viewItem = newViewItemMap[itemId]; - if (!viewItem) { - OWSFailDebug(@"Can't find inserted view item."); - return [self.delegate conversationViewModelDidUpdate:ConversationUpdate.reloadUpdate]; - } - [updateItems addObject:[[ConversationUpdateItem alloc] initWithUpdateItemType:ConversationUpdateItemType_Update - oldIndex:oldIndex - newIndex:newIndex - viewItem:viewItem]]; - } - - BOOL shouldAnimateUpdates = [self shouldAnimateUpdateItems:updateItems - oldViewItemCount:oldItemIdList.count - updatedNeighborItemSet:updatedNeighborItemSet]; - - for (NSString *itemID in updatedItemSet) { - [MessageInvalidator markAsUpdated:itemID]; - } - - return [self.delegate - conversationViewModelDidUpdate:[ConversationUpdate diffUpdateWithUpdateItems:updateItems - shouldAnimateUpdates:shouldAnimateUpdates]]; -} - -- (BOOL)shouldAnimateUpdateItems:(NSArray *)updateItems - oldViewItemCount:(NSUInteger)oldViewItemCount - updatedNeighborItemSet:(nullable NSMutableSet *)updatedNeighborItemSet -{ - OWSAssertDebug(updateItems); - - // If user sends a new outgoing message, don't animate the change. - BOOL isOnlyModifyingLastMessage = YES; - for (ConversationUpdateItem *updateItem in updateItems) { - switch (updateItem.updateItemType) { - case ConversationUpdateItemType_Delete: - isOnlyModifyingLastMessage = NO; - break; - case ConversationUpdateItemType_Insert: { - id viewItem = updateItem.viewItem; - OWSAssertDebug(viewItem); - switch (viewItem.interaction.interactionType) { - case OWSInteractionType_IncomingMessage: - case OWSInteractionType_OutgoingMessage: - case OWSInteractionType_TypingIndicator: - if (updateItem.newIndex < oldViewItemCount) { - isOnlyModifyingLastMessage = NO; - } - break; - default: - isOnlyModifyingLastMessage = NO; - break; - } - break; - } - case ConversationUpdateItemType_Update: { - id viewItem = updateItem.viewItem; - if ([updatedNeighborItemSet containsObject:viewItem.itemId]) { - continue; - } - OWSAssertDebug(viewItem); - switch (viewItem.interaction.interactionType) { - case OWSInteractionType_IncomingMessage: - case OWSInteractionType_OutgoingMessage: - case OWSInteractionType_TypingIndicator: - // We skip animations for the last _two_ - // interactions, not one since there - // may be a typing indicator. - if (updateItem.newIndex + 2 < updateItems.count) { - isOnlyModifyingLastMessage = NO; - } - break; - default: - isOnlyModifyingLastMessage = NO; - break; - } - break; - } - } - } - BOOL shouldAnimateRowUpdates = !isOnlyModifyingLastMessage; - return shouldAnimateRowUpdates; -} - -- (void)createNewMessageMapping -{ - if (self.thread.uniqueId.length < 1) { - OWSFailDebug(@"uniqueId unexpectedly empty for thread: %@", self.thread); - } - - self.messageMapping = [[ConversationMessageMapping alloc] initWithGroup:self.thread.uniqueId - desiredLength:self.initialMessageMappingLength]; - - [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - [self.messageMapping updateWithTransaction:transaction]; - }]; -} - -// This is more expensive than incremental updates. -// -// We call `resetMapping` for two separate reasons: -// -// * Most of the time, we call `resetMapping` after a severe error to get back into a known good state. -// We then call `conversationViewModelDidReset` to get the view back into a known good state (by -// scrolling to the bottom). -// * We also call `resetMapping` to load an additional page of older message. We very much _do not_ -// want to change view scroll state in this case. -- (void)resetMapping -{ - // Don't extend the mapping's desired length. - [self resetMappingWithAdditionalLength:0]; -} - -- (void)resetMappingWithAdditionalLength:(NSUInteger)additionalLength -{ - OWSAssertDebug(self.messageMapping); - - [self updateMessageMappingWithAdditionalLength:additionalLength]; - - self.collapseCutoffDate = [NSDate new]; - - [self ensureDynamicInteractionsAndUpdateIfNecessary]; - - if (![self reloadViewItems]) { - OWSFailDebug(@"failed to reload view items in resetMapping."); - } - - [self.delegate conversationViewModelDidUpdate:ConversationUpdate.reloadUpdate]; -} - -- (void)applicationWillEnterForeground:(NSNotification *)notification -{ - [self touchDbAsync]; -} - -#pragma mark - View Items - -- (void)ensureConversationProfileState -{ - if (self.conversationProfileState) { - return; - } - - ConversationProfileState *conversationProfileState = [ConversationProfileState new]; - conversationProfileState.hasLocalProfile = YES; - conversationProfileState.isThreadInProfileWhitelist = YES; - conversationProfileState.hasUnwhitelistedMember = NO; - self.conversationProfileState = conversationProfileState; -} - -- (nullable TSInteraction *)firstCallOrMessageForLoadedInteractions:(NSArray *)loadedInteractions - -{ - for (TSInteraction *interaction in loadedInteractions) { - switch (interaction.interactionType) { - case OWSInteractionType_Unknown: - OWSFailDebug(@"Unknown interaction type."); - return nil; - case OWSInteractionType_IncomingMessage: - case OWSInteractionType_OutgoingMessage: - return interaction; - case OWSInteractionType_Info: - break; - case OWSInteractionType_Call: - case OWSInteractionType_Offer: - case OWSInteractionType_TypingIndicator: - break; - } - } - return nil; -} - -// This is a key method. It builds or rebuilds the list of -// cell view models. -// -// Returns NO on error. -- (BOOL)reloadViewItems -{ - NSMutableArray> *viewItems = [NSMutableArray new]; - NSMutableDictionary> *viewItemCache = [NSMutableDictionary new]; - - NSArray *loadedUniqueIds = [self.messageMapping loadedUniqueIds]; - BOOL isGroupThread = self.thread.isGroupThread; - - [self ensureConversationProfileState]; - - __block BOOL hasError = NO; - id (^tryToAddViewItem)(TSInteraction *, YapDatabaseReadTransaction *) - = ^(TSInteraction *interaction, YapDatabaseReadTransaction *transaction) { - OWSAssertDebug(interaction.uniqueId.length > 0); - - id _Nullable viewItem = self.viewItemCache[interaction.uniqueId]; - if (!viewItem) { - viewItem = [[ConversationInteractionViewItem alloc] initWithInteraction:interaction - isGroupThread:isGroupThread - transaction:transaction]; - } - OWSAssertDebug(!viewItemCache[interaction.uniqueId]); - viewItemCache[interaction.uniqueId] = viewItem; - [viewItems addObject:viewItem]; - - return viewItem; - }; - - NSMutableSet *interactionIds = [NSMutableSet new]; - [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - NSMutableArray *interactions = [NSMutableArray new]; - - YapDatabaseViewTransaction *viewTransaction = [transaction ext:TSMessageDatabaseViewExtensionName]; - OWSAssertDebug(viewTransaction); - for (NSString *uniqueId in loadedUniqueIds) { - TSInteraction *_Nullable interaction = - [TSInteraction fetchObjectWithUniqueID:uniqueId transaction:transaction]; - if (!interaction) { - OWSFailDebug(@"missing interaction in message mapping: %@.", uniqueId); - hasError = YES; - continue; - } - if (!interaction.uniqueId) { - OWSFailDebug(@"invalid interaction in message mapping: %@.", interaction); - hasError = YES; - continue; - } - [interactions addObject:interaction]; - if ([interactionIds containsObject:interaction.uniqueId]) { - OWSFailDebug(@"Duplicate interaction: %@", interaction.uniqueId); - continue; - } - [interactionIds addObject:interaction.uniqueId]; - } - - for (TSInteraction *interaction in interactions) { - tryToAddViewItem(interaction, transaction); - } - }]; - - // This will usually be redundant, but this will resolve one of the symptoms - // of the "corrupt YDB view" issue caused by multi-process writes. - [viewItems sortUsingComparator:^NSComparisonResult(id left, id right) { - return [left.interaction compareForSorting:right.interaction]; - }]; - - if (self.unsavedOutgoingMessages.count > 0) { - [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *_Nonnull transaction) { - for (TSOutgoingMessage *outgoingMessage in self.unsavedOutgoingMessages) { - if ([interactionIds containsObject:outgoingMessage.uniqueId]) { - OWSFailDebug(@"Duplicate interaction: %@", outgoingMessage.uniqueId); - continue; - } - tryToAddViewItem(outgoingMessage, transaction); - [interactionIds addObject:outgoingMessage.uniqueId]; - } - }]; - } - - if (self.typingIndicatorsSender) { - OWSTypingIndicatorInteraction *typingIndicatorInteraction = - [[OWSTypingIndicatorInteraction alloc] initWithThread:self.thread - timestamp:[NSDate ows_millisecondTimeStamp] - recipientId:self.typingIndicatorsSender]; - - [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *_Nonnull transaction) { - tryToAddViewItem(typingIndicatorInteraction, transaction); - }]; - } - - // Flag to ensure that we only increment once per launch. - if (hasError) { - OWSLogWarn(@"incrementing version of: %@", TSMessageDatabaseViewExtensionName); - [OWSPrimaryStorage incrementVersionOfDatabaseExtension:TSMessageDatabaseViewExtensionName]; - } - - // Update the "break" properties (shouldShowDate and unreadIndicator) of the view items. - BOOL shouldShowDateOnNextViewItem = YES; - uint64_t previousViewItemTimestamp = 0; - OWSUnreadIndicator *_Nullable unreadIndicator = self.dynamicInteractions.unreadIndicator; - uint64_t collapseCutoffTimestamp = [NSDate ows_millisecondsSince1970ForDate:self.collapseCutoffDate]; - - BOOL hasPlacedUnreadIndicator = NO; - for (id viewItem in viewItems) { - BOOL canShowDate = NO; - switch (viewItem.interaction.interactionType) { - case OWSInteractionType_Unknown: - case OWSInteractionType_Offer: - case OWSInteractionType_TypingIndicator: - canShowDate = NO; - break; - case OWSInteractionType_IncomingMessage: - case OWSInteractionType_OutgoingMessage: - case OWSInteractionType_Info: - case OWSInteractionType_Call: - canShowDate = YES; - break; - } - - uint64_t viewItemTimestamp = viewItem.interaction.timestamp; - OWSAssertDebug(viewItemTimestamp > 0); - - BOOL shouldShowDate = NO; - if (previousViewItemTimestamp == 0) { - shouldShowDateOnNextViewItem = YES; - } else { - shouldShowDateOnNextViewItem = [DateUtil shouldShowDateBreakForTimestamp:previousViewItemTimestamp timestamp:viewItemTimestamp]; - } - - if (shouldShowDateOnNextViewItem && canShowDate) { - shouldShowDate = YES; - shouldShowDateOnNextViewItem = NO; - } - - viewItem.shouldShowDate = shouldShowDate; - - previousViewItemTimestamp = viewItemTimestamp; - - // When a conversation without unread messages receives an incoming message, - // we call ensureDynamicInteractions to ensure that the unread indicator (etc.) - // state is updated accordingly. However this is done in a separate transaction. - // We don't want to show the incoming message _without_ an unread indicator and - // then immediately re-render it _with_ an unread indicator. - // - // To avoid this, we use a temporary instance of OWSUnreadIndicator whenever - // we find an unread message that _should_ have an unread indicator, but no - // unread indicator exists yet on dynamicInteractions. - BOOL isItemUnread = ([viewItem.interaction conformsToProtocol:@protocol(OWSReadTracking)] - && !((id)viewItem.interaction).wasRead); - if (isItemUnread && !unreadIndicator && !hasPlacedUnreadIndicator && !self.hasClearedUnreadMessagesIndicator) { - unreadIndicator = [[OWSUnreadIndicator alloc] initWithFirstUnseenSortId:viewItem.interaction.sortId - hasMoreUnseenMessages:NO - missingUnseenSafetyNumberChangeCount:0 - unreadIndicatorPosition:0]; - } - - // Place the unread indicator onto the first appropriate view item, - // if any. - if (unreadIndicator && viewItem.interaction.sortId >= unreadIndicator.firstUnseenSortId) { - viewItem.unreadIndicator = unreadIndicator; - unreadIndicator = nil; - hasPlacedUnreadIndicator = YES; - } else { - viewItem.unreadIndicator = nil; - } - } - if (unreadIndicator) { - // This isn't necessarily a bug - all of the interactions after the - // unread indicator may have disappeared or been deleted. - OWSLogWarn(@"Couldn't find an interaction to hang the unread indicator on."); - } - - // Update the properties of the view items. - // - // NOTE: This logic uses the break properties which are set in the previous pass. - for (NSUInteger i = 0; i < viewItems.count; i++) { - id viewItem = viewItems[i]; - id _Nullable previousViewItem = (i > 0 ? viewItems[i - 1] : nil); - id _Nullable nextViewItem = (i + 1 < viewItems.count ? viewItems[i + 1] : nil); - BOOL shouldShowSenderProfilePicture = NO; - BOOL shouldHideFooter = NO; - BOOL isFirstInCluster = YES; - BOOL isLastInCluster = YES; - NSAttributedString *_Nullable senderName = nil; - - OWSInteractionType interactionType = viewItem.interaction.interactionType; - NSString *timestampText = [DateUtil formatTimestampShort:viewItem.interaction.timestamp]; - - if (interactionType == OWSInteractionType_OutgoingMessage) { - TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)viewItem.interaction; - MessageReceiptStatus receiptStatus = - [MessageRecipientStatusUtils recipientStatusWithOutgoingMessage:outgoingMessage]; - BOOL isDisappearingMessage = outgoingMessage.isExpiringMessage; - - if (nextViewItem && nextViewItem.interaction.interactionType == interactionType) { - TSOutgoingMessage *nextOutgoingMessage = (TSOutgoingMessage *)nextViewItem.interaction; - MessageReceiptStatus nextReceiptStatus = - [MessageRecipientStatusUtils recipientStatusWithOutgoingMessage:nextOutgoingMessage]; - NSString *nextTimestampText = [DateUtil formatTimestampShort:nextViewItem.interaction.timestamp]; - - // We can skip the "outgoing message status" footer if the next message - // has the same footer and no "date break" separates us... - // ...but always show "failed to send" status - // ...and always show the "disappearing messages" animation. - shouldHideFooter - = ([timestampText isEqualToString:nextTimestampText] && receiptStatus == nextReceiptStatus - && outgoingMessage.messageState != TSOutgoingMessageStateFailed - && outgoingMessage.messageState != TSOutgoingMessageStateSending && !nextViewItem.hasCellHeader - && !isDisappearingMessage); - } - - // clustering - if (previousViewItem == nil) { - isFirstInCluster = YES; - } else if (viewItem.hasCellHeader) { - isFirstInCluster = YES; - } else { - isFirstInCluster = previousViewItem.interaction.interactionType != OWSInteractionType_OutgoingMessage; - } - - if (nextViewItem == nil) { - isLastInCluster = YES; - } else if (nextViewItem.hasCellHeader) { - isLastInCluster = YES; - } else { - isLastInCluster = nextViewItem.interaction.interactionType != OWSInteractionType_OutgoingMessage; - } - } else if (interactionType == OWSInteractionType_IncomingMessage) { - - TSIncomingMessage *incomingMessage = (TSIncomingMessage *)viewItem.interaction; - NSString *incomingSenderId = incomingMessage.authorId; - OWSAssertDebug(incomingSenderId.length > 0); - BOOL isDisappearingMessage = incomingMessage.isExpiringMessage; - - NSString *_Nullable nextIncomingSenderId = nil; - if (nextViewItem && nextViewItem.interaction.interactionType == interactionType) { - TSIncomingMessage *nextIncomingMessage = (TSIncomingMessage *)nextViewItem.interaction; - nextIncomingSenderId = nextIncomingMessage.authorId; - OWSAssertDebug(nextIncomingSenderId.length > 0); - } - - if (nextViewItem && nextViewItem.interaction.interactionType == interactionType) { - NSString *nextTimestampText = [DateUtil formatTimestampShort:nextViewItem.interaction.timestamp]; - // We can skip the "incoming message status" footer in a cluster if the next message - // has the same footer and no "date break" separates us. - // ...but always show the "disappearing messages" animation. - shouldHideFooter = ([timestampText isEqualToString:nextTimestampText] && !nextViewItem.hasCellHeader && - [NSObject isNullableObject:nextIncomingSenderId equalTo:incomingSenderId] - && !isDisappearingMessage); - } - - // clustering - if (previousViewItem == nil) { - isFirstInCluster = YES; - } else if (viewItem.hasCellHeader) { - isFirstInCluster = YES; - } else if (previousViewItem.interaction.interactionType != OWSInteractionType_IncomingMessage) { - isFirstInCluster = YES; - } else { - TSIncomingMessage *previousIncomingMessage = (TSIncomingMessage *)previousViewItem.interaction; - isFirstInCluster = ![incomingSenderId isEqual:previousIncomingMessage.authorId]; - } - - if (nextViewItem == nil) { - isLastInCluster = YES; - } else if (nextViewItem.interaction.interactionType != OWSInteractionType_IncomingMessage) { - isLastInCluster = YES; - } else if (nextViewItem.hasCellHeader) { - isLastInCluster = YES; - } else { - TSIncomingMessage *nextIncomingMessage = (TSIncomingMessage *)nextViewItem.interaction; - isLastInCluster = ![incomingSenderId isEqual:nextIncomingMessage.authorId]; - } - - if (viewItem.isGroupThread) { - // Show the sender name for incoming group messages unless the - // previous message has the same sender and no "date break" separates us. - BOOL shouldShowSenderName = YES; - NSString *_Nullable previousIncomingSenderId = nil; - if (previousViewItem && previousViewItem.interaction.interactionType == interactionType) { - - TSIncomingMessage *previousIncomingMessage = (TSIncomingMessage *)previousViewItem.interaction; - previousIncomingSenderId = previousIncomingMessage.authorId; - OWSAssertDebug(previousIncomingSenderId.length > 0); - - shouldShowSenderName = (![NSObject isNullableObject:previousIncomingSenderId equalTo:incomingSenderId] || viewItem.hasCellHeader); - } - - if (shouldShowSenderName) { - senderName = [[NSAttributedString alloc] initWithString:[SMKProfile displayNameWithId:incomingSenderId thread:self.thread]]; - } - - // Show the sender profile picture for incoming group messages unless the - // next message has the same sender and no "date break" separates us. - shouldShowSenderProfilePicture = YES; - if (nextViewItem && nextViewItem.interaction.interactionType == interactionType) { - shouldShowSenderProfilePicture = (![NSObject isNullableObject:nextIncomingSenderId equalTo:incomingSenderId]); - } - } - } - - if (viewItem.interaction.receivedAtTimestamp > collapseCutoffTimestamp) { - shouldHideFooter = NO; - } - - viewItem.isFirstInCluster = isFirstInCluster; - viewItem.isLastInCluster = isLastInCluster; - viewItem.shouldShowSenderProfilePicture = shouldShowSenderProfilePicture; - viewItem.shouldHideFooter = shouldHideFooter; - viewItem.senderName = senderName; - viewItem.wasPreviousItemInfoMessage = (previousViewItem.interaction.interactionType == OWSInteractionType_Info); - } - - self.viewState = [[ConversationViewState alloc] initWithViewItems:viewItems]; - self.viewItemCache = viewItemCache; - - return !hasError; -} - -- (void)appendUnsavedOutgoingTextMessage:(TSOutgoingMessage *)outgoingMessage -{ - // Because the message isn't yet saved, we don't have sufficient information to build - // in-memory placeholder for message types more complex than plain text. - OWSAssertDebug(outgoingMessage.attachmentIds.count == 0); - - NSMutableArray *unsavedOutgoingMessages = [self.unsavedOutgoingMessages mutableCopy]; - [unsavedOutgoingMessages addObject:outgoingMessage]; - self.unsavedOutgoingMessages = unsavedOutgoingMessages; - - [self updateForTransientItems]; -} - -// Whenever an interaction is modified, we need to reload it from the DB -// and update the corresponding view item. -- (void)reloadInteractionForViewItem:(id)viewItem -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(viewItem); - - // This should never happen, but don't crash in production if we have a bug. - if (!viewItem) { - return; - } - - [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - TSInteraction *_Nullable interaction = - [TSInteraction fetchObjectWithUniqueID:viewItem.interaction.uniqueId transaction:transaction]; - if (!interaction) { - OWSFailDebug(@"could not reload interaction"); - } else { - [viewItem replaceInteraction:interaction transaction:transaction]; - } - }]; -} - -- (nullable NSIndexPath *)ensureLoadWindowContainsQuotedReply:(OWSQuotedReplyModel *)quotedReply -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(quotedReply); - OWSAssertDebug(quotedReply.timestamp > 0); - OWSAssertDebug(quotedReply.authorId.length > 0); - - if (quotedReply.isRemotelySourced) { - return nil; - } - - __block NSIndexPath *_Nullable indexPath = nil; - [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - TSInteraction *_Nullable quotedInteraction = - [ThreadUtil findInteractionInThreadByTimestamp:quotedReply.timestamp - authorId:quotedReply.authorId - threadUniqueId:self.thread.uniqueId - transaction:transaction]; - if (!quotedInteraction) { - return; - } - - indexPath = - [self.messageMapping ensureLoadWindowContainsUniqueId:quotedInteraction.uniqueId transaction:transaction]; - }]; - - self.collapseCutoffDate = [NSDate new]; - - [self ensureDynamicInteractionsAndUpdateIfNecessary]; - - if (![self reloadViewItems]) { - OWSFailDebug(@"failed to reload view items in resetMapping."); - } - - [self.delegate conversationViewModelDidUpdate:ConversationUpdate.reloadUpdate]; - [self.delegate conversationViewModelRangeDidChange]; - - return indexPath; -} - -- (nullable NSIndexPath *)ensureLoadWindowContainsInteractionId:(NSString *)interactionId -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(interactionId); - - __block NSIndexPath *_Nullable indexPath = nil; - [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - indexPath = [self.messageMapping ensureLoadWindowContainsUniqueId:interactionId transaction:transaction]; - }]; - - self.collapseCutoffDate = [NSDate new]; - - [self ensureDynamicInteractionsAndUpdateIfNecessary]; - - if (![self reloadViewItems]) { - OWSFailDebug(@"failed to reload view items in resetMapping."); - } - - [self.delegate conversationViewModelDidUpdate:ConversationUpdate.reloadUpdate]; - [self.delegate conversationViewModelRangeDidChange]; - - return indexPath; -} - -- (nullable NSNumber *)findGroupIndexOfThreadInteraction:(TSInteraction *)interaction - transaction:(YapDatabaseReadTransaction *)transaction -{ - OWSAssertDebug(interaction); - OWSAssertDebug(transaction); - - YapDatabaseAutoViewTransaction *_Nullable extension = [transaction extension:TSMessageDatabaseViewExtensionName]; - if (!extension) { - OWSFailDebug(@"Couldn't load view."); - return nil; - } - - NSUInteger groupIndex = 0; - BOOL foundInGroup = - [extension getGroup:nil index:&groupIndex forKey:interaction.uniqueId inCollection:TSInteraction.collection]; - if (!foundInGroup) { - OWSLogError(@"Couldn't find quoted message in group."); - return nil; - } - return @(groupIndex); -} - -- (void)typingIndicatorStateDidChange:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(self.thread); - - if (notification.object && ![notification.object isEqual:self.thread.uniqueId]) { - return; - } - - self.typingIndicatorsSender = [self.typingIndicators typingRecipientIdForThread:self.thread]; -} - -- (void)setTypingIndicatorsSender:(nullable NSString *)typingIndicatorsSender -{ - OWSAssertIsOnMainThread(); - - BOOL didChange = ![NSObject isNullableObject:typingIndicatorsSender equalTo:_typingIndicatorsSender]; - - _typingIndicatorsSender = typingIndicatorsSender; - - // Update the view items if necessary. - // We don't have to do this if they haven't been configured yet. - if (didChange && self.viewState.viewItems != nil) { - // When we receive an incoming message, we clear any typing indicators - // from that sender. Ideally, we'd like both changes (disappearance of - // the typing indicators, appearance of the incoming message) to show up - // in the view at the same time, rather than as a "jerky" two-step - // visual change. - // - // Unfortunately, the view model learns of these changes by separate - // channels: the incoming message is a database modification and the - // typing indicator change arrives via this notification. - // - // Therefore we pause briefly before updating the view model to reflect - // typing indicators state changes so that the database modification - // can usually arrive first and update the view to reflect both changes. - __weak ConversationViewModel *weakSelf = self; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1f * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - [weakSelf updateForTransientItems]; - }); - } -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index f6c1c89ea..47e163b27 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -18,7 +18,6 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { case videoCall } - public static let pageSize: Int = 50 // MARK: - Section public enum Section: Differentiable, Equatable, Comparable, Hashable { @@ -29,6 +28,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { // MARK: - Variables + public static let pageSize: Int = 50 // MARK: - Initialization diff --git a/Session/Conversations/Message Cells/Content Views/MediaView.swift b/Session/Conversations/Message Cells/Content Views/MediaView.swift index 9faee9f5d..c26cbd295 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaView.swift @@ -383,9 +383,16 @@ public class MediaView: UIView { Logger.verbose("Skipping obsolete load.") return } - - DispatchQueue.main.async { - loadMediaBlock(loadCompletion) + + loadMediaBlock { media in + guard Thread.isMainThread else { + DispatchQueue.main.async { + loadCompletion(media) + } + return + } + + loadCompletion(media) } } } diff --git a/Session/Conversations/Message Cells/Models/MessageCellViewModel.swift b/Session/Conversations/Message Cells/Models/MessageCellViewModel.swift index adbcd40c0..520110d7d 100644 --- a/Session/Conversations/Message Cells/Models/MessageCellViewModel.swift +++ b/Session/Conversations/Message Cells/Models/MessageCellViewModel.swift @@ -373,6 +373,53 @@ extension MessageCell { } } +// MARK: - Convenience Initialization + +public extension MessageCell.ViewModel { + // Note: This init method is only used system-created cells or empty states + init(isTypingIndicator: Bool = false) { + self.threadVariant = .contact + self.threadIsTrusted = false + self.threadHasDisappearingMessagesEnabled = false + + // Interaction Info + + self.rowId = -1 + self.id = -1 + self.variant = .standardOutgoing + self.timestampMs = Int64.max + self.authorId = "" + self.authorNameInternal = nil + self.body = nil + self.expiresStartedAtMs = nil + self.expiresInSeconds = nil + + self.state = .sent + self.hasAtLeastOneReadReceipt = false + self.mostRecentFailureText = nil + self.isTypingIndicator = isTypingIndicator + self.isSenderOpenGroupModerator = false + self.profile = nil + self.quote = nil + self.quoteAttachment = nil + self.linkPreview = nil + self.linkPreviewAttachment = nil + + // Post-Query Processing Data + + self.attachments = nil + self.cellType = .typingIndicator + self.authorName = "" + self.senderName = nil + self.shouldShowProfile = false + self.dateForUI = nil + self.previousVariant = nil + self.positionInCluster = .middle + self.isOnlyMessageInCluster = true + self.isLast = true + } +} + // MARK: - ConversationVC extension MessageCell.ViewModel { diff --git a/Session/Conversations/Settings/OWSConversationSettingsViewDelegate.h b/Session/Conversations/Settings/OWSConversationSettingsViewDelegate.h index b3e432abc..b9fcaa2c0 100644 --- a/Session/Conversations/Settings/OWSConversationSettingsViewDelegate.h +++ b/Session/Conversations/Settings/OWSConversationSettingsViewDelegate.h @@ -11,8 +11,6 @@ NS_ASSUME_NONNULL_BEGIN - (void)conversationSettingsDidRequestConversationSearch:(OWSConversationSettingsViewController *)conversationSettingsViewController; -- (void)popAllConversationSettingsViewsWithCompletion:(void (^_Nullable)(void))completionBlock; - @end NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/Views & Modals/InsetLockableTableView.swift b/Session/Conversations/Views & Modals/InsetLockableTableView.swift index 9fd38c50e..e1c67b4f0 100644 --- a/Session/Conversations/Views & Modals/InsetLockableTableView.swift +++ b/Session/Conversations/Views & Modals/InsetLockableTableView.swift @@ -20,11 +20,19 @@ public class InsetLockableTableView: UITableView { } public var oldOffset: CGPoint = .zero public var newOffset: CGPoint = .zero - private var afterNextLayoutCondition: ((Int, [Int]) -> Bool)? - private var afterNextLayoutCallback: (() -> ())? + private var callbackCondition: ((Int, [Int]) -> Bool)? + private var afterLayoutSubviewsCallback: (() -> ())? public override func layoutSubviews() { - newOffset = self.contentOffset + self.newOffset = self.contentOffset + + // Store the callback locally to prevent infinite loops + var callback: (() -> ())? + + if self.testCallbackCondition() { + callback = self.afterLayoutSubviewsCallback + self.afterLayoutSubviewsCallback = nil + } guard !lockContentOffset else { self.contentOffset = CGPoint( @@ -33,33 +41,38 @@ public class InsetLockableTableView: UITableView { ) super.layoutSubviews() - - self.performNextLayoutCallbackIfPossible() + callback?() return } super.layoutSubviews() + callback?() - self.performNextLayoutCallbackIfPossible() self.oldOffset = self.contentOffset } - // MARK: - Function + // MARK: - Functions - public func afterNextLayout(when condition: @escaping (Int, [Int]) -> Bool, then callback: @escaping () -> ()) { - self.afterNextLayoutCondition = condition - self.afterNextLayoutCallback = callback + public func afterNextLayoutSubviews( + when condition: @escaping (Int, [Int]) -> Bool, + then callback: @escaping () -> () + ) { + self.callbackCondition = condition + self.afterLayoutSubviewsCallback = callback } - private func performNextLayoutCallbackIfPossible() { + private func testCallbackCondition() -> Bool { + guard self.callbackCondition != nil else { return false } + let numSections: Int = self.numberOfSections let numRowInSections: [Int] = (0.. 0 else { searchResultSet = defaultSearchResults lastSearchText = nil diff --git a/Session/Home/GlobalSearch/Storage+RecentSearchResults.swift b/Session/Home/GlobalSearch/Storage+RecentSearchResults.swift deleted file mode 100644 index 9327b7abf..000000000 --- a/Session/Home/GlobalSearch/Storage+RecentSearchResults.swift +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -extension Storage{ - - private static let recentSearchResultDatabaseCollection = "RecentSearchResultDatabaseCollection" - private static let recentSearchResultKey = "RecentSearchResult" - - public func getRecentSearchResults() -> [String] { - var result: [String]? - Storage.read { transaction in - result = transaction.object(forKey: Storage.recentSearchResultKey, inCollection: Storage.recentSearchResultDatabaseCollection) as? [String] - } - return result ?? [] - } - - public func clearRecentSearchResults() { - Storage.write { transaction in - transaction.removeObject(forKey: Storage.recentSearchResultKey, inCollection: Storage.recentSearchResultDatabaseCollection) - } - } - - public func addSearchResults(threadID: String) -> [String] { - var recentSearchResults = getRecentSearchResults() - if recentSearchResults.count > 20 { recentSearchResults.remove(at: 0) } // Limit the size of the collection to 20 - if let index = recentSearchResults.firstIndex(of: threadID) { recentSearchResults.remove(at: index) } - recentSearchResults.append(threadID) - Storage.write { transaction in - transaction.setObject(recentSearchResults, forKey: Storage.recentSearchResultKey, inCollection: Storage.recentSearchResultDatabaseCollection) - } - return recentSearchResults - } -} diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index d7a4a20ce..695a22a86 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -375,7 +375,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve case .threads: let threadId: String = section.elements[indexPath.row].threadId - show(threadId, with: .none, highlightedInteractionId: nil, animated: true) + show(threadId, with: .none, focusedInteractionId: nil, animated: true) } } @@ -505,10 +505,10 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve func show( _ threadId: String, with action: ConversationViewModel.Action, - highlightedInteractionId: Int64?, + focusedInteractionId: Int64?, animated: Bool ) { - guard let conversationVC: ConversationVC = ConversationVC(threadId: threadId) else { + guard let conversationVC: ConversationVC = ConversationVC(threadId: threadId, focusedInteractionId: focusedInteractionId) else { return } diff --git a/Session/Home/Message Requests/MessageRequestsViewController.swift b/Session/Home/Message Requests/MessageRequestsViewController.swift index 2446cb518..a2c6e0efd 100644 --- a/Session/Home/Message Requests/MessageRequestsViewController.swift +++ b/Session/Home/Message Requests/MessageRequestsViewController.swift @@ -163,9 +163,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat // Start observing for data changes dataChangeObservable = GRDBStorage.shared.start( viewModel.observableViewData, - onError: { error in - print("Update error \(error)!!!!") - }, + onError: { _ in }, onChange: { [weak self] viewData in // The defaul scheduler emits changes on the main thread self?.handleUpdates(viewData) diff --git a/Session/Media Viewing & Editing/MediaTileViewController.swift b/Session/Media Viewing & Editing/MediaTileViewController.swift index 2e42d3676..c7291ea29 100644 --- a/Session/Media Viewing & Editing/MediaTileViewController.swift +++ b/Session/Media Viewing & Editing/MediaTileViewController.swift @@ -16,7 +16,6 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour static let interItemSpacing: CGFloat = 2 static let footerBarHeight: CGFloat = 40 static let loadMoreHeaderHeight: CGFloat = 100 - static let autoLoadNextPageDelay: DispatchTimeInterval = .milliseconds(400) private let viewModel: MediaGalleryViewModel private var hasLoadedInitialData: Bool = false @@ -217,7 +216,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour self.isAutoLoadingNextPage = true - DispatchQueue.main.asyncAfter(deadline: .now() + MediaTileViewController.autoLoadNextPageDelay) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + PagedData.autoLoadNextPageDelay) { [weak self] in self?.isAutoLoadingNextPage = false // Note: We sort the headers as we want to prioritise loading newer pages over older ones diff --git a/Session/Meta/SessionApp.swift b/Session/Meta/SessionApp.swift index 1c600081f..c45aa7a45 100644 --- a/Session/Meta/SessionApp.swift +++ b/Session/Meta/SessionApp.swift @@ -45,7 +45,7 @@ public struct SessionApp { homeViewController.wrappedValue?.show( threadId, with: action, - highlightedInteractionId: focusInteractionId, + focusedInteractionId: focusInteractionId, animated: animated ) } diff --git a/Session/Meta/Signal-Bridging-Header.h b/Session/Meta/Signal-Bridging-Header.h index ab957879b..6cdb5eb57 100644 --- a/Session/Meta/Signal-Bridging-Header.h +++ b/Session/Meta/Signal-Bridging-Header.h @@ -9,8 +9,6 @@ // Separate iOS Frameworks from other imports. #import "AvatarViewHelper.h" #import "AVAudioSession+OWS.h" -#import "ConversationViewItem.h" -#import "ConversationViewModel.h" #import "DateUtil.h" #import "NotificationSettingsViewController.h" #import "OWSAnyTouchGestureRecognizer.h" diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index bc503dda3..695b4f5b3 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -501,6 +501,29 @@ public extension Interaction { } } +// MARK: - Search Queries + +public extension Interaction { + static func idsForTermWithin(threadId: String, pattern: FTS5Pattern) -> SQLRequest { + let interaction: TypedTableAlias = TypedTableAlias() + let interactionFullTextSearch: SQL = SQL(stringLiteral: Interaction.fullTextSearchTableName) + + let request: SQLRequest = """ + SELECT \(interaction[.id]) + FROM \(Interaction.self) + JOIN \(interactionFullTextSearch) ON ( + \(interactionFullTextSearch).rowid = \(interaction.alias[Column.rowID]) AND + \(interactionFullTextSearch).\(SQL(stringLiteral: Interaction.Columns.body.name)) MATCH \(pattern) + ) + WHERE \(SQL("\(interaction[.threadId]) = \(threadId)")) + + ORDER BY \(interaction[.timestampMs].desc) + """ + + return request + } +} + // MARK: - Convenience public extension Interaction { diff --git a/SessionMessagingKit/Database/Models/ThreadTypingIndicator.swift b/SessionMessagingKit/Database/Models/ThreadTypingIndicator.swift index bad5e96dd..43fed0f54 100644 --- a/SessionMessagingKit/Database/Models/ThreadTypingIndicator.swift +++ b/SessionMessagingKit/Database/Models/ThreadTypingIndicator.swift @@ -14,7 +14,7 @@ public struct ThreadTypingIndicator: Codable, FetchableRecord, PersistableRecord private static let thread = belongsTo(SessionThread.self, using: threadForeignKey) public typealias Columns = CodingKeys - public enum CodingKeys: String, CodingKey, ColumnExpression { + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { case threadId case timestampMs } diff --git a/SessionMessagingKit/Shared Models/ConversationCellViewModel.swift b/SessionMessagingKit/Shared Models/ConversationCellViewModel.swift index 2f457b6b7..19c26492f 100644 --- a/SessionMessagingKit/Shared Models/ConversationCellViewModel.swift +++ b/SessionMessagingKit/Shared Models/ConversationCellViewModel.swift @@ -198,7 +198,7 @@ extension ConversationCell { // MARK: - Convenience Initialization public extension ConversationCell.ViewModel { - // Note: This init method is only used for the message requests cell or empty states + // Note: This init method is only used system-created cells or empty states init(unreadCount: UInt = 0) { self.threadId = "INVALID_THREAD_ID" self.threadVariant = .contact diff --git a/SessionMessagingKit/Utilities/FullTextSearchFinder.swift b/SessionMessagingKit/Utilities/FullTextSearchFinder.swift deleted file mode 100644 index 94af03021..000000000 --- a/SessionMessagingKit/Utilities/FullTextSearchFinder.swift +++ /dev/null @@ -1,255 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -import Foundation - -// Create a searchable index for objects of type T -public class SearchIndexer { - - private let indexBlock: (T, YapDatabaseReadTransaction) -> String - - public init(indexBlock: @escaping (T, YapDatabaseReadTransaction) -> String) { - self.indexBlock = indexBlock - } - - public func index(_ item: T, transaction: YapDatabaseReadTransaction) -> String { - return normalize(indexingText: indexBlock(item, transaction)) - } - - private func normalize(indexingText: String) -> String { - return FullTextSearchFinder.normalize(text: indexingText) - } -} - -@objc -public class FullTextSearchFinder: NSObject { - - // MARK: - Dependencies - - private static var tsAccountManager: TSAccountManager { - return TSAccountManager.sharedInstance() - } - - // MARK: - Querying - - // We want to match by prefix for "search as you type" functionality. - // SQLite does not support suffix or contains matches. - public class func query(searchText: String) -> String { - // 1. Normalize the search text. - // - // TODO: We could arguably convert to lowercase since the search - // is case-insensitive. - let normalizedSearchText = FullTextSearchFinder.normalize(text: searchText) - - // 2. Split the non-numeric text into query terms (or tokens). - let nonNumericText = String(String.UnicodeScalarView(normalizedSearchText.unicodeScalars.lazy.map { - if CharacterSet.decimalDigits.contains($0) { - return " " - } else { - return $0 - } - })) - var queryTerms = nonNumericText.split(separator: " ") - - // 3. Add an additional numeric-only query term. - let digitsOnlyScalars = normalizedSearchText.unicodeScalars.lazy.filter { - CharacterSet.decimalDigits.contains($0) - } - let digitsOnly: Substring = Substring(String(String.UnicodeScalarView(digitsOnlyScalars))) - queryTerms.append(digitsOnly) - - // 4. De-duplicate and sort query terms. - // Duplicate terms are redundant. - // Sorting terms makes the output of this method deterministic and easier to test, - // and the order won't affect the search results. - queryTerms = Array(Set(queryTerms)).sorted() - - // 5. Filter the query terms. - let filteredQueryTerms = queryTerms.filter { - // Ignore empty terms. - $0.count > 0 - }.map { - // Allow partial match of each term. - // - // Note that we use double-quotes to enclose each search term. - // Quoted search terms can include a few more characters than - // "bareword" (non-quoted) search terms. This shouldn't matter, - // since we're filtering all of the affected characters, but - // quoting protects us from any bugs in that logic. - "\"\($0)\"*" - } - - // 6. Join terms into query string. - let query = filteredQueryTerms.joined(separator: " ") - return query - } - - public func enumerateObjects(searchText: String, maxSearchResults: Int? = nil, transaction: YapDatabaseReadTransaction, block: @escaping (Any, String) -> Void) { - guard let ext: YapDatabaseFullTextSearchTransaction = ext(transaction: transaction) else { - return - } - - let query = FullTextSearchFinder.query(searchText: searchText) - - let maxSearchResults = maxSearchResults ?? 500 - var searchResultCount = 0 - let snippetOptions = YapDatabaseFullTextSearchSnippetOptions() - snippetOptions.startMatchText = "" - snippetOptions.endMatchText = "" - snippetOptions.numberOfTokens = 5 - ext.enumerateKeysAndObjects(matching: query, with: snippetOptions) { (snippet: String, _: String, _: String, object: Any, stop: UnsafeMutablePointer) in - guard searchResultCount < maxSearchResults else { - stop.pointee = true - return - } - searchResultCount += 1 - - block(object, snippet) - } - } - - // MARK: - Normalization - - fileprivate static var charactersToRemove: CharacterSet = { - // * We want to strip punctuation - and our definition of "punctuation" - // is broader than `CharacterSet.punctuationCharacters`. - // * FTS should be robust to (i.e. ignore) illegal and control characters, - // but it's safer if we filter them ourselves as well. - var charactersToFilter = CharacterSet.punctuationCharacters - charactersToFilter.formUnion(CharacterSet.illegalCharacters) - charactersToFilter.formUnion(CharacterSet.controlCharacters) - - // We want to strip all ASCII characters except: - // * Letters a-z, A-Z - // * Numerals 0-9 - // * Whitespace - var asciiToFilter = CharacterSet(charactersIn: UnicodeScalar(0x0)!.. String { - // 1. Filter out invalid characters. - let filtered = text.removeCharacters(characterSet: charactersToRemove) - - // 2. Simplify whitespace. - let simplified = filtered.replaceCharacters(characterSet: .whitespacesAndNewlines, - replacement: " ") - - // 3. Strip leading & trailing whitespace last, since we may replace - // filtered characters with whitespace. - return simplified.trimmingCharacters(in: .whitespacesAndNewlines) - } - - // MARK: - Index Building - - private static let groupThreadIndexer: SearchIndexer = SearchIndexer { (groupThread: TSGroupThread, transaction: YapDatabaseReadTransaction) in - let groupName = groupThread.groupModel.groupName ?? "" - - let memberStrings = groupThread.groupModel.groupMemberIds.map { recipientId in - recipientIndexer.index(recipientId, transaction: transaction) - }.joined(separator: " ") - - return "\(groupName) \(memberStrings)" - } - - private static let contactThreadIndexer: SearchIndexer = SearchIndexer { (contactThread: TSContactThread, transaction: YapDatabaseReadTransaction) in - let recipientId = contactThread.contactSessionID() - var result = recipientIndexer.index(recipientId, transaction: transaction) - - if IsNoteToSelfEnabled(), - let localNumber = tsAccountManager.storedOrCachedLocalNumber(transaction), - localNumber == recipientId { - - let noteToSelfLabel = NSLocalizedString("NOTE_TO_SELF", comment: "Label for 1:1 conversation with yourself.") - result += " \(noteToSelfLabel)" - } - - return result - } - - private static let recipientIndexer: SearchIndexer = SearchIndexer { (recipientId: String, transaction: YapDatabaseReadTransaction) in - let profile: Profile? = GRDBStorage.shared.read { db in try Profile.fetchOne(db, id: recipientId) } - - return [ - recipientId, - profile?.name, - profile?.nickname - ] - .compactMap { $0 } - .filter { !$0.isEmpty } - .joined(separator: " ") - } - - private static let messageIndexer: SearchIndexer = SearchIndexer { (message: TSMessage, transaction: YapDatabaseReadTransaction) in - if let bodyText = message.bodyText(with: transaction) { - return bodyText - } - return "" - } - - private class func indexContent(object: Any, transaction: YapDatabaseReadTransaction) -> String? { - if let groupThread = object as? TSGroupThread { - return self.groupThreadIndexer.index(groupThread, transaction: transaction) - } else if let contactThread = object as? TSContactThread { - guard contactThread.shouldBeVisible else { - // If we've never sent/received a message in a TSContactThread, - // then we want it to appear in the "Other Contacts" section rather - // than in the "Conversations" section. - return nil - } - return self.contactThreadIndexer.index(contactThread, transaction: transaction) - } else if let message = object as? TSMessage { - return self.messageIndexer.index(message, transaction: transaction) - } else { - return nil - } - } - - // MARK: - Extension Registration - - private static let dbExtensionName: String = "FullTextSearchFinderExtension" - - private func ext(transaction: YapDatabaseReadTransaction) -> YapDatabaseFullTextSearchTransaction? { - return transaction.ext(FullTextSearchFinder.dbExtensionName) as? YapDatabaseFullTextSearchTransaction - } - - @objc - public class func asyncRegisterDatabaseExtension(storage: OWSStorage) { - storage.asyncRegister(dbExtensionConfig, withName: dbExtensionName) - } - - // Only for testing. - public class func ensureDatabaseExtensionRegistered(storage: OWSStorage) { - guard storage.registeredExtension(dbExtensionName) == nil else { - return - } - - storage.register(dbExtensionConfig, withName: dbExtensionName) - } - - private class var dbExtensionConfig: YapDatabaseFullTextSearch { - let contentColumnName = "content" - - let handler = YapDatabaseFullTextSearchHandler.withObjectBlock { (transaction: YapDatabaseReadTransaction, dict: NSMutableDictionary, _: String, _: String, object: Any) in - dict[contentColumnName] = indexContent(object: object, transaction: transaction) - } - - // update search index on contact name changes? - - return YapDatabaseFullTextSearch(columnNames: ["content"], - options: nil, - handler: handler, - ftsVersion: YapDatabaseFullTextSearchFTS5Version, - versionTag: "2") - } -} diff --git a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift index c0d4ac419..e53912c8c 100644 --- a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift +++ b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift @@ -733,6 +733,8 @@ public struct DataCache { // MARK: - PagedData public enum PagedData { + public static let autoLoadNextPageDelay: DispatchTimeInterval = .milliseconds(400) + // MARK: - PageInfo public struct PageInfo { diff --git a/SignalUtilitiesKit/Messaging/FullTextSearcher.swift b/SignalUtilitiesKit/Messaging/FullTextSearcher.swift deleted file mode 100644 index c7487bf85..000000000 --- a/SignalUtilitiesKit/Messaging/FullTextSearcher.swift +++ /dev/null @@ -1,400 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -import Foundation -import SessionMessagingKit - -public typealias MessageSortKey = UInt64 -public struct ConversationSortKey: Comparable { - let creationDate: Date - let lastMessageReceivedAtDate: Date? - - // MARK: Comparable - - public static func < (lhs: ConversationSortKey, rhs: ConversationSortKey) -> Bool { - let lhsDate = lhs.lastMessageReceivedAtDate ?? lhs.creationDate - let rhsDate = rhs.lastMessageReceivedAtDate ?? rhs.creationDate - return lhsDate < rhsDate - } -} - -public class ConversationSearchResult: Comparable where SortKey: Comparable { - public let thread: ThreadViewModel - - public let message: TSMessage? - - public let snippet: String? - - private let sortKey: SortKey - - init(thread: ThreadViewModel, sortKey: SortKey, message: TSMessage? = nil, snippet: String? = nil) { - self.thread = thread - self.sortKey = sortKey - self.message = message - self.snippet = snippet - } - - // MARK: Comparable - - public static func < (lhs: ConversationSearchResult, rhs: ConversationSearchResult) -> Bool { - return lhs.sortKey < rhs.sortKey - } - - // MARK: Equatable - - public static func == (lhs: ConversationSearchResult, rhs: ConversationSearchResult) -> Bool { - return lhs.thread.thread == rhs.thread.thread && - lhs.message?.uniqueId == rhs.message?.uniqueId - } -} - -public class HomeScreenSearchResultSet: NSObject { - public let searchText: String - public let conversations: [ConversationSearchResult] - public let messages: [ConversationSearchResult] - - public init(searchText: String, conversations: [ConversationSearchResult], messages: [ConversationSearchResult]) { - self.searchText = searchText - self.conversations = conversations - self.messages = messages - } - - public class var empty: HomeScreenSearchResultSet { - return HomeScreenSearchResultSet(searchText: "", conversations: [], messages: []) - } - - public class var noteToSelfOnly: HomeScreenSearchResultSet { - var conversations: [ConversationSearchResult] = [] - Storage.read { transaction in - if let thread = TSContactThread.getWithContactSessionID(getUserHexEncodedPublicKey(), transaction: transaction) { - let threadViewModel = ThreadViewModel(thread: thread, transaction: transaction) - let sortKey = ConversationSortKey(creationDate: thread.creationDate, - lastMessageReceivedAtDate: thread.lastInteractionForInbox(transaction: transaction)?.receivedAtDate()) - let searchResult = ConversationSearchResult(thread: threadViewModel, sortKey: sortKey) - conversations.append(searchResult) - } - } - return HomeScreenSearchResultSet(searchText: "", conversations: conversations, messages: []) - } - - public var isEmpty: Bool { - return conversations.isEmpty && messages.isEmpty - } -} - -@objc -public class GroupSearchResult: NSObject, Comparable { - public let thread: ThreadViewModel - - private let sortKey: ConversationSortKey - - init(thread: ThreadViewModel, sortKey: ConversationSortKey) { - self.thread = thread - self.sortKey = sortKey - } - - // MARK: Comparable - - public static func < (lhs: GroupSearchResult, rhs: GroupSearchResult) -> Bool { - return lhs.sortKey < rhs.sortKey - } - - // MARK: Equatable - - public static func == (lhs: GroupSearchResult, rhs: GroupSearchResult) -> Bool { - return lhs.thread.thread == rhs.thread.thread - } -} - -@objc -public class ComposeScreenSearchResultSet: NSObject { - - @objc - public let searchText: String - - @objc - public let groups: [GroupSearchResult] - - @objc - public var groupThreads: [TSGroupThread] { - return groups.compactMap { $0.thread.threadRecord as? TSGroupThread } - } - - public init(searchText: String, groups: [GroupSearchResult]) { - self.searchText = searchText - self.groups = groups - } - - @objc - public static let empty = ComposeScreenSearchResultSet(searchText: "", groups: []) - - @objc - public var isEmpty: Bool { - return groups.isEmpty - } -} - -@objc -public class MessageSearchResult: NSObject, Comparable { - - public let messageId: String - public let sortId: UInt64 - - init(messageId: String, sortId: UInt64) { - self.messageId = messageId - self.sortId = sortId - } - - // MARK: - Comparable - - public static func < (lhs: MessageSearchResult, rhs: MessageSearchResult) -> Bool { - return lhs.sortId < rhs.sortId - } -} - -@objc -public class ConversationScreenSearchResultSet: NSObject { - - @objc - public let searchText: String - - @objc - public let messages: [MessageSearchResult] - - @objc - public lazy var messageSortIds: [UInt64] = { - return messages.map { $0.sortId } - }() - - // MARK: Static members - - public static let empty: ConversationScreenSearchResultSet = ConversationScreenSearchResultSet(searchText: "", messages: []) - - // MARK: Init - - public init(searchText: String, messages: [MessageSearchResult]) { - self.searchText = searchText - self.messages = messages - } - - // MARK: - CustomDebugStringConvertible - - override public var debugDescription: String { - return "ConversationScreenSearchResultSet(searchText: \(searchText), messages: [\(messages.count) matches])" - } -} - -@objc -public class FullTextSearcher: NSObject { - - // MARK: - Dependencies - - private var tsAccountManager: TSAccountManager { - return TSAccountManager.sharedInstance() - } - - // MARK: - - - private let finder: FullTextSearchFinder - - @objc - public static let shared: FullTextSearcher = FullTextSearcher() - override private init() { - finder = FullTextSearchFinder() - super.init() - } - - @objc - public func searchForComposeScreen(searchText: String, - transaction: YapDatabaseReadTransaction) -> ComposeScreenSearchResultSet { - - var groups: [GroupSearchResult] = [] - - self.finder.enumerateObjects(searchText: searchText, transaction: transaction) { (match: Any, snippet: String?) in - - switch match { - case let groupThread as TSGroupThread: - let sortKey = ConversationSortKey(creationDate: groupThread.creationDate, - lastMessageReceivedAtDate: groupThread.lastInteractionForInbox(transaction: transaction)?.receivedAtDate()) - let threadViewModel = ThreadViewModel(thread: groupThread, transaction: transaction) - let searchResult = GroupSearchResult(thread: threadViewModel, sortKey: sortKey) - groups.append(searchResult) - case is TSContactThread: - // not included in compose screen results - break - case is TSMessage: - // not included in compose screen results - break - default: - owsFailDebug("unhandled item: \(match)") - } - } - - // Order the conversation and message results in reverse chronological order. - // The contact results are pre-sorted by display name. - groups.sort(by: >) - - return ComposeScreenSearchResultSet(searchText: searchText, groups: groups) - } - - public func searchForHomeScreen(searchText: String, - maxSearchResults: Int? = nil, - transaction: YapDatabaseReadTransaction) -> HomeScreenSearchResultSet { - - var conversations: [ConversationSearchResult] = [] - var messages: [ConversationSearchResult] = [] - - var existingConversationRecipientIds: Set = Set() - - self.finder.enumerateObjects(searchText: searchText, maxSearchResults: maxSearchResults, transaction: transaction) { (match: Any, snippet: String?) in - - if let thread = match as? TSThread { - let threadViewModel = ThreadViewModel(thread: thread, transaction: transaction) - let sortKey = ConversationSortKey(creationDate: thread.creationDate, - lastMessageReceivedAtDate: thread.lastInteractionForInbox(transaction: transaction)?.receivedAtDate()) - let searchResult = ConversationSearchResult(thread: threadViewModel, sortKey: sortKey) - - if let contactThread = thread as? TSContactThread { - let recipientId = contactThread.contactSessionID() - existingConversationRecipientIds.insert(recipientId) - } - - conversations.append(searchResult) - } else if let message = match as? TSMessage { - let thread = message.thread(with: transaction) - - let threadViewModel = ThreadViewModel(thread: thread, transaction: transaction) - let sortKey = message.sortId - let searchResult = ConversationSearchResult(thread: threadViewModel, - sortKey: sortKey, - message: message, - snippet: snippet) - - messages.append(searchResult) - } else { - owsFailDebug("unhandled item: \(match)") - } - } - - // Order the conversation and message results in reverse chronological order. - // The contact results are pre-sorted by display name. - conversations.sort(by: >) - messages.sort(by: >) - - return HomeScreenSearchResultSet(searchText: searchText, conversations: conversations, messages: messages) - } - - public func searchWithinConversation(thread: TSThread, - searchText: String, - transaction: YapDatabaseReadTransaction) -> ConversationScreenSearchResultSet { - - var messages: [MessageSearchResult] = [] - - guard let threadId = thread.uniqueId else { - owsFailDebug("threadId was unexpectedly nil") - return ConversationScreenSearchResultSet.empty - } - - self.finder.enumerateObjects(searchText: searchText, transaction: transaction) { (match: Any, snippet: String?) in - if let message = match as? TSMessage { - guard message.uniqueThreadId == threadId else { - return - } - - guard let messageId = message.uniqueId else { - owsFailDebug("messageId was unexpectedly nil") - return - } - - let searchResult = MessageSearchResult(messageId: messageId, sortId: message.sortId) - messages.append(searchResult) - } - } - - // We want most recent first - messages.sort(by: >) - - return ConversationScreenSearchResultSet(searchText: searchText, messages: messages) - } - - @objc(filterThreads:withSearchText:) - public func filterThreads(_ threads: [TSThread], searchText: String) -> [TSThread] { - let threads = threads.filter { $0.name() != "Session Updates" && $0.name() != "Loki News" } - guard searchText.trimmingCharacters(in: .whitespacesAndNewlines).count > 0 else { - return threads - } - - return threads.filter { thread in - switch thread { - case let groupThread as TSGroupThread: - return self.groupThreadSearcher.matches(item: groupThread, query: searchText) - case let contactThread as TSContactThread: - return self.contactThreadSearcher.matches(item: contactThread, query: searchText) - default: - owsFailDebug("Unexpected thread type: \(thread)") - return false - } - } - } - - @objc(filterGroupThreads:withSearchText:) - public func filterGroupThreads(_ groupThreads: [TSGroupThread], searchText: String) -> [TSGroupThread] { - guard searchText.trimmingCharacters(in: .whitespacesAndNewlines).count > 0 else { - return groupThreads - } - - return groupThreads.filter { groupThread in - return self.groupThreadSearcher.matches(item: groupThread, query: searchText) - } - } - - @objc(filterSignalAccounts:withSearchText:) - public func filterSignalAccounts(_ signalAccounts: [SignalAccount], searchText: String) -> [SignalAccount] { - guard searchText.trimmingCharacters(in: .whitespacesAndNewlines).count > 0 else { - return signalAccounts - } - - return signalAccounts.filter { signalAccount in - self.signalAccountSearcher.matches(item: signalAccount, query: searchText) - } - } - - // MARK: Searchers - - private lazy var groupThreadSearcher: Searcher = Searcher { (groupThread: TSGroupThread) in - let groupName = groupThread.groupModel.groupName - let memberStrings = groupThread.groupModel.groupMemberIds.map { recipientId in - self.indexingString(recipientId: recipientId) - }.joined(separator: " ") - - return "\(memberStrings) \(groupName ?? "")" - } - - private lazy var contactThreadSearcher: Searcher = Searcher { (contactThread: TSContactThread) in - let recipientId = contactThread.contactSessionID() - return self.conversationIndexingString(recipientId: recipientId) - } - - private lazy var signalAccountSearcher: Searcher = Searcher { (signalAccount: SignalAccount) in - let recipientId = signalAccount.recipientId - return self.conversationIndexingString(recipientId: recipientId) - } - - private func conversationIndexingString(recipientId: String) -> String { - var result = self.indexingString(recipientId: recipientId) - - if IsNoteToSelfEnabled(), - let localNumber = tsAccountManager.localNumber(), - localNumber == recipientId { - let noteToSelfLabel = NSLocalizedString("NOTE_TO_SELF", comment: "Label for 1:1 conversation with yourself.") - result += " \(noteToSelfLabel)" - } - - return result - } - - private func indexingString(recipientId: String) -> String { - return "\(recipientId) \(Profile.fetchOrCreate(id: recipientId).name)" - } -} From 3514ed4f50daff4b5cb12ef1486b4e01721fc759 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Sat, 28 May 2022 17:25:38 +1000 Subject: [PATCH 090/157] Updated the JobRunner to have multiple job queues (needs more testing) Added a backoff to the Poller retry Updated the "blocking" behaviour of the JobRunner Tweaked the Job dependency handling to better handle orphaned dependencies Fixed an issue where the Conversation screen wasn't observing database changes --- Session.xcodeproj/project.pbxproj | 14 +- .../ConversationVC+Interaction.swift | 27 +- Session/Conversations/ConversationVC.swift | 26 +- .../Conversations/ConversationViewModel.swift | 97 +-- .../Models/MessageCellViewModel.swift | 12 + .../Message Cells/VisibleMessageCell.swift | 109 ++- .../MediaGalleryViewModel.swift | 26 +- .../MediaTileViewController.swift | 30 +- Session/Notifications/AppNotifications.swift | 2 +- Session/Onboarding/LinkDeviceVC.swift | 6 + Session/Onboarding/PNModeVC.swift | 14 +- .../Migrations/_002_SetupStandardJobs.swift | 10 +- .../Jobs/Types/GarbageCollectionJob.swift | 6 +- .../Sending & Receiving/Pollers/Poller.swift | 14 +- SessionMessagingKit/Storage.swift | 35 - .../Migrations/_002_SetupStandardJobs.swift | 3 +- SessionSnodeKit/GetSnodePoolJob.swift | 26 + SessionSnodeKit/SnodeAPI.swift | 13 +- .../_001_InitialSetupMigration.swift | 7 +- SessionUtilitiesKit/Database/Models/Job.swift | 90 +-- .../Database/Models/JobDependencies.swift | 10 +- .../Types/PagedDatabaseObserver.swift | 137 +--- .../General/String+Utilities.swift | 36 + .../General/UnicodeScalar+Utilities.swift | 121 ++++ SessionUtilitiesKit/JobRunner/JobRunner.swift | 672 +++++++++++------- .../JobRunner/JobRunnerError.swift | 1 + .../Utilities/DisplayableText.swift | 298 -------- 27 files changed, 930 insertions(+), 912 deletions(-) create mode 100644 SessionUtilitiesKit/General/UnicodeScalar+Utilities.swift delete mode 100644 SignalUtilitiesKit/Utilities/DisplayableText.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index dddd16e09..0ab30227c 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -430,7 +430,6 @@ C38EF2B3255B6D9C007E1867 /* UIViewController+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2B1255B6D9C007E1867 /* UIViewController+Utilities.swift */; }; C38EF2B4255B6D9C007E1867 /* UIView+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2B2255B6D9C007E1867 /* UIView+Utilities.swift */; }; C38EF30C255B6DBF007E1867 /* OWSScreenLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2E2255B6DB9007E1867 /* OWSScreenLock.swift */; }; - C38EF317255B6DBF007E1867 /* DisplayableText.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2ED255B6DBB007E1867 /* DisplayableText.swift */; }; C38EF31A255B6DBF007E1867 /* OWSAnyTouchGestureRecognizer.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2F0255B6DBB007E1867 /* OWSAnyTouchGestureRecognizer.m */; }; C38EF31C255B6DBF007E1867 /* Searcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2F2255B6DBC007E1867 /* Searcher.swift */; }; C38EF31D255B6DBF007E1867 /* UIImage+OWS.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2F3255B6DBC007E1867 /* UIImage+OWS.swift */; }; @@ -498,7 +497,6 @@ C3A01E05261D24C400290BEB /* public-loki-foundation.der in Resources */ = {isa = PBXBuildFile; fileRef = C3A01E02261D24C400290BEB /* public-loki-foundation.der */; }; C3A01E06261D24C400290BEB /* storage-seed-1.der in Resources */ = {isa = PBXBuildFile; fileRef = C3A01E03261D24C400290BEB /* storage-seed-1.der */; }; C3A01E07261D24C400290BEB /* storage-seed-3.der in Resources */ = {isa = PBXBuildFile; fileRef = C3A01E04261D24C400290BEB /* storage-seed-3.der */; }; - C3A3A08F256E1728004D228D /* FullTextSearchFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB7F255A581100E217F9 /* FullTextSearchFinder.swift */; }; C3A3A0FE256E1A3C004D228D /* TSDatabaseSecondaryIndexes.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB20255A580900E217F9 /* TSDatabaseSecondaryIndexes.m */; }; C3A3A12B256E1AD5004D228D /* TSDatabaseSecondaryIndexes.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB25255A580900E217F9 /* TSDatabaseSecondaryIndexes.h */; settings = {ATTRIBUTES = (Public, ); }; }; C3A3A171256E1D25004D228D /* SSKReachabilityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A3A170256E1D25004D228D /* SSKReachabilityManager.swift */; }; @@ -681,6 +679,7 @@ FD848B8B283DC509000E298B /* PagedDatabaseObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B8A283DC509000E298B /* PagedDatabaseObserver.swift */; }; FD848B8D283E0B26000E298B /* MessageInputTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B8C283E0B26000E298B /* MessageInputTypes.swift */; }; FD848B8F283EF2A8000E298B /* UIScrollView+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B8E283EF2A8000E298B /* UIScrollView+Utilities.swift */; }; + FD848B9328420164000E298B /* UnicodeScalar+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B9228420164000E298B /* UnicodeScalar+Utilities.swift */; }; FD859F0027C4691300510D0C /* MockDataGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EFF27C4691300510D0C /* MockDataGenerator.swift */; }; FD88BAD927A7439C00BBC442 /* MessageRequestsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */; }; FD88BADB27A750F200BBC442 /* MessageRequestsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */; }; @@ -1287,7 +1286,6 @@ C33FDB75255A581000E217F9 /* AppReadiness.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppReadiness.m; sourceTree = ""; }; C33FDB77255A581000E217F9 /* NSUserDefaults+OWS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSUserDefaults+OWS.m"; sourceTree = ""; }; C33FDB78255A581000E217F9 /* OWSOperation.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSOperation.m; sourceTree = ""; }; - C33FDB7F255A581100E217F9 /* FullTextSearchFinder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FullTextSearchFinder.swift; sourceTree = ""; }; C33FDB80255A581100E217F9 /* Notification+Loki.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Notification+Loki.swift"; sourceTree = ""; }; C33FDB81255A581100E217F9 /* UIImage+OWS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "UIImage+OWS.m"; sourceTree = ""; }; C33FDB85255A581100E217F9 /* AppContext.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppContext.m; sourceTree = ""; }; @@ -1379,7 +1377,6 @@ C38EF2B2255B6D9C007E1867 /* UIView+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "UIView+Utilities.swift"; path = "SignalUtilitiesKit/Utilities/UIView+Utilities.swift"; sourceTree = SOURCE_ROOT; }; C38EF2E2255B6DB9007E1867 /* OWSScreenLock.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = OWSScreenLock.swift; path = "SignalUtilitiesKit/Screen Lock/OWSScreenLock.swift"; sourceTree = SOURCE_ROOT; }; C38EF2EC255B6DBA007E1867 /* ProximityMonitoringManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ProximityMonitoringManager.swift; path = SessionMessagingKit/Utilities/ProximityMonitoringManager.swift; sourceTree = SOURCE_ROOT; }; - C38EF2ED255B6DBB007E1867 /* DisplayableText.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = DisplayableText.swift; path = SignalUtilitiesKit/Utilities/DisplayableText.swift; sourceTree = SOURCE_ROOT; }; C38EF2EF255B6DBB007E1867 /* Weak.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Weak.swift; path = SessionUtilitiesKit/General/Weak.swift; sourceTree = SOURCE_ROOT; }; C38EF2F0255B6DBB007E1867 /* OWSAnyTouchGestureRecognizer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = OWSAnyTouchGestureRecognizer.m; path = SignalUtilitiesKit/Utilities/OWSAnyTouchGestureRecognizer.m; sourceTree = SOURCE_ROOT; }; C38EF2F1255B6DBB007E1867 /* OWSPreferences.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSPreferences.h; path = SessionMessagingKit/Utilities/OWSPreferences.h; sourceTree = SOURCE_ROOT; }; @@ -1651,6 +1648,7 @@ FD848B8A283DC509000E298B /* PagedDatabaseObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagedDatabaseObserver.swift; sourceTree = ""; }; FD848B8C283E0B26000E298B /* MessageInputTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageInputTypes.swift; sourceTree = ""; }; FD848B8E283EF2A8000E298B /* UIScrollView+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIScrollView+Utilities.swift"; sourceTree = ""; }; + FD848B9228420164000E298B /* UnicodeScalar+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UnicodeScalar+Utilities.swift"; sourceTree = ""; }; FD859EFF27C4691300510D0C /* MockDataGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDataGenerator.swift; sourceTree = ""; }; FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsCell.swift; sourceTree = ""; }; FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsMigration.swift; sourceTree = ""; }; @@ -2227,6 +2225,7 @@ C33FDB51255A580D00E217F9 /* NSUserDefaults+OWS.h */, C33FDB77255A581000E217F9 /* NSUserDefaults+OWS.m */, C33FDB14255A580800E217F9 /* OWSMath.h */, + FD705A91278D051200F16121 /* ReusableView.swift */, FD17D7AF27F4225C00122BE0 /* Set+Utilities.swift */, C33FDB6B255A580F00E217F9 /* SNUserDefaults.swift */, C33FDB3F255A580C00E217F9 /* String+SSK.swift */, @@ -2234,11 +2233,11 @@ FD705A8D278CE29800F16121 /* String+Utilities.swift */, C38EF237255B6D65007E1867 /* UIDevice+featureSupport.swift */, C35D0DB425AE5F1200B6BF49 /* UIEdgeInsets.swift */, - FD705A91278D051200F16121 /* ReusableView.swift */, FDED2E3B282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift */, FD705A93278D052B00F16121 /* UITableView+ReusableView.swift */, C38EF23D255B6D66007E1867 /* UIView+OWS.h */, C38EF23E255B6D66007E1867 /* UIView+OWS.m */, + FD848B9228420164000E298B /* UnicodeScalar+Utilities.swift */, C38EF2EF255B6DBB007E1867 /* Weak.swift */, ); path = General; @@ -2879,7 +2878,6 @@ C37F53E8255BA9BB002AEA92 /* Environment.h */, C37F5402255BA9ED002AEA92 /* Environment.m */, C3BBE0C62554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift */, - C33FDB7F255A581100E217F9 /* FullTextSearchFinder.swift */, C3A71D0A2558989C0043A11F /* MessageWrapper.swift */, C3A71D4E25589FF30043A11F /* NSData+messagePadding.h */, C3A71D4825589FF20043A11F /* NSData+messagePadding.m */, @@ -3041,7 +3039,6 @@ C38EF302255B6DBE007E1867 /* OWSAnyTouchGestureRecognizer.h */, C38EF2F0255B6DBB007E1867 /* OWSAnyTouchGestureRecognizer.m */, FDCDB8DD2810F73B00352A0C /* Differentiable+Utilities.swift */, - C38EF2ED255B6DBB007E1867 /* DisplayableText.swift */, C38EF3DC255B6DF1007E1867 /* DirectionalPanGestureRecognizer.swift */, C38EF240255B6D67007E1867 /* UIView+OWS.swift */, C38EF236255B6D65007E1867 /* UIViewController+OWS.h */, @@ -4305,7 +4302,6 @@ files = ( C38EF3FD255B6DF7007E1867 /* OWSTextView.m in Sources */, C38EF3C6255B6DE7007E1867 /* ImageEditorModel.swift in Sources */, - C38EF317255B6DBF007E1867 /* DisplayableText.swift in Sources */, C38EF3C3255B6DE7007E1867 /* ImageEditorTextItem.swift in Sources */, FD28A4F227E990E800FF65E7 /* BlockingManagerRemovalMigration.swift in Sources */, C33FDC7D255A582000E217F9 /* OWSDispatch.m in Sources */, @@ -4497,6 +4493,7 @@ FD17D7C727F5207C00122BE0 /* DatabaseMigrator+Utilities.swift in Sources */, C3D9E3C925676AF30040E4F3 /* TSYapDatabaseObject.m in Sources */, C352A3A62557B60D00338F3E /* TSRequest.m in Sources */, + FD848B9328420164000E298B /* UnicodeScalar+Utilities.swift in Sources */, FD09796B27F6C67500936362 /* Failable.swift in Sources */, FD705A92278D051200F16121 /* ReusableView.swift in Sources */, FD17D7BA27F51F2100122BE0 /* TargetMigrations.swift in Sources */, @@ -4574,7 +4571,6 @@ C32C5A76256DBBCF003C73A2 /* SignalAttachment.swift in Sources */, FDA8EB00280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift in Sources */, FD09798927FD1C5A00936362 /* OpenGroup.swift in Sources */, - C3A3A08F256E1728004D228D /* FullTextSearchFinder.swift in Sources */, FDF0B7472804F0CE004C14C5 /* DisappearingMessagesJob.swift in Sources */, B8856D1A256F114D001CE70E /* ProximityMonitoringManager.swift in Sources */, C3D9E52725677DF20040E4F3 /* ThumbnailService.swift in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index d468d0669..8a635d2f0 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -712,7 +712,6 @@ extension ConversationVC: let locationInAlbumView: CGPoint = cell.convert(locationInCell, to: albumView) guard let mediaView = albumView.mediaView(forLocation: locationInAlbumView) else { return } - switch mediaView.attachment.state { case .pendingDownload, .downloading, .uploading: // TODO: Tapped a failed incoming attachment @@ -779,14 +778,26 @@ extension ConversationVC: navigationController?.present(shareVC, animated: true, completion: nil) case .textOnlyMessage: - if let reply = viewItem.quotedReply { - // Scroll to the source of the reply - guard let indexPath = viewModel.ensureLoadWindowContainsQuotedReply(reply) else { return } - messagesTableView.scrollToRow(at: indexPath, at: UITableView.ScrollPosition.middle, animated: true) - } else if let message = viewItem.interaction as? TSIncomingMessage, let name = message.openGroupInvitationName, - let url = message.openGroupInvitationURL { - joinOpenGroup(name: name, url: url) + if let quote: Quote = cellViewModel.quote { + // Scroll to the original quoted message + let maybeOriginalInteractionId: Int64? = GRDBStorage.shared.read { db in + try quote.originalInteraction + .select(.id) + .asRequest(of: Int64.self) + .fetchOne(db) + } + + guard let interactionId: Int64 = maybeOriginalInteractionId else { return } + + self.scrollToInteractionIfNeeded(with: interactionId, highlight: true) } + else if let linkPreview: LinkPreview = cellViewModel.linkPreview { + switch linkPreview.variant { + case .standard: openUrl(linkPreview.url) + case .openGroupInvitation: joinOpenGroup(name: linkPreview.title, url: linkPreview.url) + } + } + default: break } } diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 439dcd7ba..ebe03829f 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -444,8 +444,17 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + // Perform the initial scroll and highlight if needed (if we started with a focused message + // this will have already been called to instantly snap to the destination but we don't + // trigger the highlight until after the screen has appeared to make it more obvious) + performInitialScrollIfNeeded() + + // Flag that the initial layout has been completed (the flag blocks and unblocks a number + // of different behaviours) + // + // Note: This MUST be set after the above 'performInitialScrollIfNeeded' is called as it + // won't run if this flag is set to true didFinishInitialLayout = true - viewModel.markAllAsRead() if delayFirstResponder || isShowingSearchUI { delayFirstResponder = false @@ -457,6 +466,8 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers )?.becomeFirstResponder() } } + + viewModel.markAllAsRead() } override func viewWillDisappear(_ animated: Bool) { @@ -1252,7 +1263,16 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers // If we aren't animating or aren't highlighting then everything can be run immediately guard isAnimated && highlight else { - self.tableView.scrollToRow(at: targetIndexPath, at: position, animated: isAnimated) + self.tableView.scrollToRow( + at: targetIndexPath, + at: position, + animated: (self.didFinishInitialLayout && isAnimated) + ) + + // Don't clear these values if we have't done the initial layout (we will call this + // method a second time to trigger the highlight after the screen appears) + guard self.didFinishInitialLayout else { return } + self.focusedInteractionId = nil self.shouldHighlightNextScrollToInteraction = false @@ -1286,7 +1306,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers .visibleCells .first(where: { ($0 as? VisibleMessageCell)?.viewModel?.id == interactionId }) .asType(VisibleMessageCell.self)? - .highlight(interactionId: interactionId) + .highlight() } } } diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 47e163b27..949f18c56 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -51,48 +51,65 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { self.focusedInteractionId = focusedInteractionId self.pagedDataObserver = nil - DispatchQueue.global(qos: .default).async { [weak self] in - self?.pagedDataObserver = PagedDatabaseObserver( - pagedTable: Interaction.self, - pageSize: ConversationViewModel.pageSize, - idColumn: .id, - initialFocusedId: focusedInteractionId, - observedChanges: [ - PagedData.ObservedChanges( - table: Interaction.self, - columns: Interaction.Columns - .allCases - .filter { $0 != .wasRead } - ) - ], - filterSQL: MessageCell.ViewModel.filterSQL(threadId: threadId), - orderSQL: MessageCell.ViewModel.orderSQL, - dataQuery: MessageCell.ViewModel.baseQuery( - orderSQL: MessageCell.ViewModel.orderSQL, - baseFilterSQL: MessageCell.ViewModel.filterSQL(threadId: threadId) + // Note: Since this references self we need to finish initializing before setting it, we + // also want to skip the initial query and trigger it async so that the push animation + // doesn't stutter (it should load basically immediately but without this there is a + // distinct stutter) + self.pagedDataObserver = PagedDatabaseObserver( + pagedTable: Interaction.self, + pageSize: ConversationViewModel.pageSize, + idColumn: .id, + observedChanges: [ + PagedData.ObservedChanges( + table: Interaction.self, + columns: Interaction.Columns + .allCases + .filter { $0 != .wasRead } ), - associatedRecords: [ - AssociatedRecord( - trackedAgainst: Attachment.self, - observedChanges: [ - PagedData.ObservedChanges( - table: Attachment.self, - columns: [.state] - ) - ], - dataQuery: MessageCell.AttachmentInteractionInfo.baseQuery, - joinToPagedType: MessageCell.AttachmentInteractionInfo.joinToViewModelQuerySQL, - associateData: MessageCell.AttachmentInteractionInfo.createAssociateDataClosure() - ) - ], - onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in - guard let updatedInteractionData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else { - return - } - - self?.onInteractionChange?(updatedInteractionData) + PagedData.ObservedChanges( + table: ThreadTypingIndicator.self, + columns: ThreadTypingIndicator.Columns.allCases + ) + ], + filterSQL: MessageCell.ViewModel.filterSQL(threadId: threadId), + orderSQL: MessageCell.ViewModel.orderSQL, + dataQuery: MessageCell.ViewModel.baseQuery( + orderSQL: MessageCell.ViewModel.orderSQL, + baseFilterSQL: MessageCell.ViewModel.filterSQL(threadId: threadId) + ), + associatedRecords: [ + AssociatedRecord( + trackedAgainst: Attachment.self, + observedChanges: [ + PagedData.ObservedChanges( + table: Attachment.self, + columns: [.state] + ) + ], + dataQuery: MessageCell.AttachmentInteractionInfo.baseQuery, + joinToPagedType: MessageCell.AttachmentInteractionInfo.joinToViewModelQuerySQL, + associateData: MessageCell.AttachmentInteractionInfo.createAssociateDataClosure() + ) + ], + onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in + guard let updatedInteractionData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else { + return } - ) + + self?.onInteractionChange?(updatedInteractionData) + } + ) + + // Run the initial query on a backgorund thread so we don't block the push transition + DispatchQueue.global(qos: .default).async { [weak self] in + // If we don't have a `initialFocusedId` then default to `.pageBefore` (it'll query + // from a `0` offset) + guard let initialFocusedId: Int64 = focusedInteractionId else { + self?.pagedDataObserver?.load(.pageBefore) + return + } + + self?.pagedDataObserver?.load(.initialPageAround(id: initialFocusedId)) } } diff --git a/Session/Conversations/Message Cells/Models/MessageCellViewModel.swift b/Session/Conversations/Message Cells/Models/MessageCellViewModel.swift index 520110d7d..ca13a5c94 100644 --- a/Session/Conversations/Message Cells/Models/MessageCellViewModel.swift +++ b/Session/Conversations/Message Cells/Models/MessageCellViewModel.swift @@ -105,6 +105,12 @@ extension MessageCell { /// This value will be used to populate the date header, if it's null then the header will be hidden let dateForUI: Date? + /// This value specifies whether the body contains only emoji characters + let containsOnlyEmoji: Bool? + + /// This value specifies the number of emoji characters the body contains + let glyphCount: Int? + /// This value indicates the variant of the previous ViewModel item, if it's null then there is no previous item let previousVariant: Interaction.Variant? @@ -149,6 +155,8 @@ extension MessageCell { senderName: self.senderName, shouldShowProfile: self.shouldShowProfile, dateForUI: self.dateForUI, + containsOnlyEmoji: self.containsOnlyEmoji, + glyphCount: self.glyphCount, previousVariant: self.previousVariant, positionInCluster: self.positionInCluster, isOnlyMessageInCluster: self.isOnlyMessageInCluster, @@ -339,6 +347,8 @@ extension MessageCell { Date(timeIntervalSince1970: (TimeInterval(self.timestampMs) / 1000)) : nil ), + containsOnlyEmoji: self.body?.containsOnlyEmoji, + glyphCount: self.body?.glyphCount, previousVariant: prevModel?.variant, positionInCluster: positionInCluster, isOnlyMessageInCluster: isOnlyMessageInCluster, @@ -413,6 +423,8 @@ public extension MessageCell.ViewModel { self.senderName = nil self.shouldShowProfile = false self.dateForUI = nil + self.containsOnlyEmoji = nil + self.glyphCount = nil self.previousVariant = nil self.positionInCluster = .middle self.isOnlyMessageInCluster = true diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 96410cadf..3216593d4 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -47,16 +47,21 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel }() private lazy var moderatorIconImageView = UIImageView(image: #imageLiteral(resourceName: "Crown")) + + lazy var bubbleBackgroundView: UIView = { + let result = UIView() + result.layer.cornerRadius = VisibleMessageCell.largeCornerRadius + return result + }() lazy var bubbleView: UIView = { let result = UIView() + result.clipsToBounds = true result.layer.cornerRadius = VisibleMessageCell.largeCornerRadius result.set(.width, greaterThanOrEqualTo: VisibleMessageCell.largeCornerRadius * 2) return result }() - private let bubbleViewMaskLayer = CAShapeLayer() - private lazy var headerView = UIView() private lazy var authorLabel: UILabel = { @@ -147,11 +152,15 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel moderatorIconImageView.pin(.trailing, to: .trailing, of: profilePictureView, withInset: 1) moderatorIconImageView.pin(.bottom, to: .bottom, of: profilePictureView, withInset: 4.5) + // Bubble background view (used for the 'highlighted' animation) + addSubview(bubbleBackgroundView) + // Bubble view addSubview(bubbleView) bubbleViewLeftConstraint1.isActive = true bubbleViewTopConstraint.isActive = true bubbleViewRightConstraint1.isActive = true + bubbleBackgroundView.pin(to: bubbleView) // Timer view addSubview(timerView) @@ -242,10 +251,16 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel cellViewModel.variant == .standardIncoming || cellViewModel.variant == .standardIncomingDeleted ) ? Colors.receivedMessageBackground : Colors.sentMessageBackground) + bubbleBackgroundView.backgroundColor = bubbleView.backgroundColor updateBubbleViewCorners() // Content view - populateContentView(for: cellViewModel, mediaCache: mediaCache, playbackInfo: playbackInfo, lastSearchText: lastSearchText) + populateContentView( + for: cellViewModel, + mediaCache: mediaCache, + playbackInfo: playbackInfo, + lastSearchText: lastSearchText + ) // Date break headerViewTopConstraint.constant = (shouldInsetHeader ? Values.mediumSpacing : 1) @@ -399,7 +414,6 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel ) snContentView.addSubview(linkPreviewView) linkPreviewView.pin(to: snContentView) - linkPreviewView.layer.mask = bubbleViewMaskLayer self.bodyTextView = linkPreviewView.bodyTextView case .openGroupInvitation: @@ -412,7 +426,6 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel snContentView.addSubview(openGroupInvitationView) openGroupInvitationView.pin(to: snContentView) - openGroupInvitationView.layer.mask = bubbleViewMaskLayer } } else { @@ -478,7 +491,6 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel albumView.set(.width, to: size.width) albumView.set(.height, to: size.height) albumView.loadMedia() - albumView.layer.mask = bubbleViewMaskLayer stackView.addArrangedSubview(albumView) // Body text view @@ -517,7 +529,6 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel snContentView.addSubview(voiceMessageView) voiceMessageView.pin(to: snContentView) - voiceMessageView.layer.mask = bubbleViewMaskLayer self.voiceMessageView = voiceMessageView case .genericAttachment: @@ -561,16 +572,9 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel private func updateBubbleViewCorners() { let cornersToRound: UIRectCorner = getCornersToRound() - let maskPath: UIBezierPath = UIBezierPath( - roundedRect: bubbleView.bounds, - byRoundingCorners: cornersToRound, - cornerRadii: CGSize( - width: VisibleMessageCell.largeCornerRadius, - height: VisibleMessageCell.largeCornerRadius - ) - ) - bubbleViewMaskLayer.path = maskPath.cgPath + bubbleBackgroundView.layer.cornerRadius = VisibleMessageCell.largeCornerRadius + bubbleBackgroundView.layer.maskedCorners = getCornerMask(from: cornersToRound) bubbleView.layer.cornerRadius = VisibleMessageCell.largeCornerRadius bubbleView.layer.maskedCorners = getCornerMask(from: cornersToRound) } @@ -644,12 +648,23 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel // FIXME: This will have issues with themes let shawdowColour = (isLightMode ? UIColor.black.cgColor : Colors.accent.cgColor) let opacity: Float = (isLightMode ? 0.5 : 1) - bubbleView.setShadow(radius: 10, opacity: opacity, offset: .zero, color: shawdowColour) - DispatchQueue.main.async { - UIView.animate(withDuration: 1.6) { - self.bubbleView.setShadow(radius: 0, opacity: 0, offset: .zero, color: UIColor.clear.cgColor) - } + DispatchQueue.main.async { [weak self] in + let oldMasksToBounds: Bool = (self?.layer.masksToBounds ?? false) + self?.layer.masksToBounds = false + self?.bubbleBackgroundView.setShadow(radius: 10, opacity: opacity, offset: .zero, color: shawdowColour) + + UIView.animate( + withDuration: 1.6, + delay: 0, + options: .curveEaseInOut, + animations: { + self?.bubbleBackgroundView.setShadow(radius: 0, opacity: 0, offset: .zero, color: UIColor.clear.cgColor) + }, + completion: { _ in + self?.layer.masksToBounds = oldMasksToBounds + } + ) } } @@ -784,11 +799,14 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel private static func getFontSize(for cellViewModel: MessageCell.ViewModel) -> CGFloat { let baselineFontSize = Values.mediumFontSize - switch viewItem.displayableBodyText?.jumbomojiCount { - case 1: return baselineFontSize + 30 - case 2: return baselineFontSize + 24 - case 3, 4, 5: return baselineFontSize + 18 - default: return baselineFontSize + + guard cellViewModel.containsOnlyEmoji == true else { return baselineFontSize } + + switch (cellViewModel.glyphCount ?? 0) { + case 1: return baselineFontSize + 30 + case 2: return baselineFontSize + 24 + case 3, 4, 5: return baselineFontSize + 18 + default: return baselineFontSize } } @@ -915,19 +933,34 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel ] ) ) - if let searchText = searchText, searchText.count >= ConversationSearchController.kMinimumSearchTextLength { - let normalizedSearchText = FullTextSearchFinder.normalize(text: searchText) - do { - let regex = try NSRegularExpression(pattern: NSRegularExpression.escapedPattern(for: normalizedSearchText), options: .caseInsensitive) - let matches = regex.matches(in: attributedText.string, options: .withoutAnchoringBounds, range: NSRange(location: 0, length: (attributedText.string as NSString).length)) - for match in matches { - guard match.range.location + match.range.length < attributedText.length else { continue } - attributedText.addAttribute(.backgroundColor, value: UIColor.white, range: match.range) - attributedText.addAttribute(.foregroundColor, value: UIColor.black, range: match.range) + + // If there is a valid search term then highlight each part that matched + if let searchText = searchText, searchText.count >= ConversationSearchController.minimumSearchTextLength { + let normalizedBody: String = attributedText.string.lowercased() + + ConversationCell.ViewModel.searchTermParts(searchText) + .map { part -> String in + guard part.hasPrefix("\"") && part.hasSuffix("\"") else { return part } + + return String(part[part.index(after: part.startIndex).. Void, completion: @escaping () -> Void) -> Promise func writeSync(with block: @escaping (Any) -> Void) - // MARK: - Closed Groups - - func getUserClosedGroupPublicKeys() -> Set - func getUserClosedGroupPublicKeys(using transaction: YapDatabaseReadTransaction) -> Set - func getZombieMembers(for groupPublicKey: String) -> Set - func setZombieMembers(for groupPublicKey: String, to zombies: Set, using transaction: Any) - func isClosedGroup(_ publicKey: String) -> Bool - func isClosedGroup(_ publicKey: String, using transaction: YapDatabaseReadTransaction) -> Bool - - // MARK: - Jobs - - func persist(_ job: Job, using transaction: Any) - func markJobAsSucceeded(_ job: Job, using transaction: Any) - func markJobAsFailed(_ job: Job, using transaction: Any) - func getAllPendingJobs(of type: Job.Type) -> [Job] - func getAttachmentUploadJob(for attachmentID: String) -> AttachmentUploadJob? - func getMessageSendJob(for messageSendJobID: String) -> MessageSendJob? - func resumeMessageSendJobIfNeeded(_ messageSendJobID: String) - func isJobCanceled(_ job: Job) -> Bool - // MARK: - Authorization func getAuthToken(for room: String, on server: String) -> String? @@ -71,21 +51,6 @@ public protocol SessionMessagingKitStorageProtocol { // MARK: - Open Group Metadata func setUserCount(to newValue: UInt64, forV2OpenGroupWithID openGroupID: String, using transaction: Any) - - // MARK: - Message Handling - - func getReceivedMessageTimestamps(using transaction: Any) -> [UInt64] - func addReceivedMessageTimestamp(_ timestamp: UInt64, using transaction: Any) - /// Returns the ID of the thread. - func getOrCreateThread(for publicKey: String, groupPublicKey: String?, openGroupID: String?, using transaction: Any) -> String? - /// Returns the ID of the `TSIncomingMessage` that was constructed. - func persist(_ message: VisibleMessage, quotedMessage: TSQuotedMessage?, linkPreview: OWSLinkPreview?, groupPublicKey: String?, openGroupID: String?, using transaction: Any) -> String? - /// Returns the IDs of the saved attachments. - func persist(_ attachments: [VisibleMessage.Attachment], using transaction: Any) -> [String] - /// Also touches the associated message. - func setAttachmentState(to state: TSAttachmentPointerState, for pointer: TSAttachmentPointer, associatedWith tsIncomingMessageID: String, using transaction: Any) - /// Also touches the associated message. - func persist(_ stream: TSAttachmentStream, associatedWith tsIncomingMessageID: String, using transaction: Any) } extension Storage: SessionMessagingKitStorageProtocol {} diff --git a/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift index 5f7965c6b..81fb80437 100644 --- a/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -13,7 +13,8 @@ enum _002_SetupStandardJobs: Migration { try autoreleasepool { _ = try Job( variant: .getSnodePool, - behaviour: .recurringOnActiveBlocking + behaviour: .recurringOnActive, + shouldBlockFirstRunEachSession: true ).inserted(db) } } diff --git a/SessionSnodeKit/GetSnodePoolJob.swift b/SessionSnodeKit/GetSnodePoolJob.swift index eeb9f7fe2..2b72ea944 100644 --- a/SessionSnodeKit/GetSnodePoolJob.swift +++ b/SessionSnodeKit/GetSnodePoolJob.swift @@ -16,9 +16,35 @@ public enum GetSnodePoolJob: JobExecutor { failure: @escaping (Job, Error?, Bool) -> (), deferred: @escaping (Job) -> () ) { + // If the user doesn't exist then don't do anything (when the user registers we run this + // job directly) + guard Identity.userExists() else { + deferred(job) + return + } + + // If we already have cached Snodes then we still want to trigger the 'SnodeAPI.getSnodePool' + // but we want to succeed this job immediately (since it's marked as blocking), this allows us + // to block if we have no Snode pool and prevent other jobs from failing but avoids having to + // wait if we already have a potentially valid snode pool + guard !SnodeAPI.hasCachedSnodesInclusingExpired() else { + SnodeAPI.getSnodePool().retainUntilComplete() + success(job, false) + return + } + SnodeAPI.getSnodePool() .done { _ in success(job, false) } .catch { error in failure(job, error, false) } .retainUntilComplete() } + + public static func run() { + GetSnodePoolJob.run( + Job(variant: .getSnodePool), + success: { _, _ in }, + failure: { _, _, _ in }, + deferred: { _ in } + ) + } } diff --git a/SessionSnodeKit/SnodeAPI.swift b/SessionSnodeKit/SnodeAPI.swift index f6df27b5a..4f8d7ef56 100644 --- a/SessionSnodeKit/SnodeAPI.swift +++ b/SessionSnodeKit/SnodeAPI.swift @@ -38,6 +38,9 @@ public final class SnodeAPI : NSObject { public typealias RawResponsePromise = Promise // MARK: Snode Pool Interaction + + private static var hasInsufficientSnodes: Bool { snodePool.count < minSnodePoolCount } + private static func loadSnodePoolIfNeeded() { guard !hasLoadedSnodePool else { return } @@ -250,9 +253,10 @@ public final class SnodeAPI : NSObject { // MARK: Public API - @objc(getSnodePool) - public static func objc_getSnodePool() -> AnyPromise { - AnyPromise.from(getSnodePool()) + public static func hasCachedSnodesInclusingExpired() -> Bool { + loadSnodePoolIfNeeded() + + return !hasInsufficientSnodes } public static func getSnodePool() -> Promise> { @@ -261,8 +265,7 @@ public final class SnodeAPI : NSObject { let hasSnodePoolExpired = given(GRDBStorage.shared[.lastSnodePoolRefreshDate]) { now.timeIntervalSince($0) > 2 * 60 * 60 }.defaulting(to: true) - let snodePool = SnodeAPI.snodePool - let hasInsufficientSnodes = (snodePool.count < minSnodePoolCount) + let snodePool: Set = SnodeAPI.snodePool if hasInsufficientSnodes || hasSnodePoolExpired { if let getSnodePoolPromise = getSnodePoolPromise { return getSnodePoolPromise } diff --git a/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift index 0f5a2d5ef..ec4369845 100644 --- a/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -28,6 +28,10 @@ enum _001_InitialSetupMigration: Migration { t.column(.behaviour, .integer) .notNull() .indexed() // Quicker querying + t.column(.shouldBlockFirstRunEachSession, .boolean) + .notNull() + .indexed() // Quicker querying + .defaults(to: false) t.column(.nextRunTimestamp, .double) .notNull() .indexed() // Quicker querying @@ -44,9 +48,8 @@ enum _001_InitialSetupMigration: Migration { .notNull() .references(Job.self, onDelete: .cascade) // Delete if Job deleted t.column(.dependantId, .integer) - .notNull() .indexed() // Quicker querying - .references(Job.self, onDelete: .cascade) // Delete if Job deleted + .references(Job.self, onDelete: .setNull) // Delete if Job deleted t.primaryKey([.jobId, .dependantId]) } diff --git a/SessionUtilitiesKit/Database/Models/Job.swift b/SessionUtilitiesKit/Database/Models/Job.swift index 15adf0a34..e0db073ca 100644 --- a/SessionUtilitiesKit/Database/Models/Job.swift +++ b/SessionUtilitiesKit/Database/Models/Job.swift @@ -16,18 +16,19 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer case failureCount case variant case behaviour + case shouldBlockFirstRunEachSession case nextRunTimestamp case threadId case interactionId case details } - public enum Variant: Int, Codable, DatabaseValueConvertible { + public enum Variant: Int, Codable, DatabaseValueConvertible, CaseIterable { /// This is a recurring job that handles the removal of disappearing messages and is triggered /// at the timestamp of the next disappearing message case disappearingMessages - /// This is a recurring job that ensures the app retrieves a service node pool on active + /// This is a recurring job that ensures the app retrieves a service node pool on become active /// /// **Note:** This is a blocking job so it will run before any other jobs and prevent them from /// running until it's complete @@ -87,7 +88,7 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer case attachmentDownload } - public enum Behaviour: Int, Codable, DatabaseValueConvertible { + public enum Behaviour: Int, Codable, DatabaseValueConvertible, CaseIterable { /// This job will run once and then be removed from the jobs table case runOnce @@ -102,22 +103,9 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer /// gets set case recurringOnLaunch - /// This job will run once each launch and may run again during the same session if `nextRunTimestamp` - /// gets set, it also must complete before any other jobs can run - case recurringOnLaunchBlocking - - /// This job will run once each launch and may run again during the same session if `nextRunTimestamp` - /// gets set, it also must complete before any other jobs can run - case recurringOnLaunchBlockingOncePerSession - /// This job will run once each whenever the app becomes active (launch and return from background) and /// may run again during the same session if `nextRunTimestamp` gets set case recurringOnActive - - /// This job will run once each whenever the app becomes active (launch and return from background) and - /// may run again during the same session if `nextRunTimestamp` gets set, it also must complete before - /// any other jobs can run - case recurringOnActiveBlocking } /// The `id` value is auto incremented by the database, if the `Job` hasn't been inserted into @@ -130,9 +118,16 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer /// The type of job public let variant: Variant - /// The type of job + /// How the job should behave public let behaviour: Behaviour + /// When the app starts or returns from the background this flag controls whether the job should prevent other + /// jobs from starting until after it completes + /// + /// **Note:** `OnLaunch` blocking jobs will be started on launch and all others will be triggered when becoming + /// active but the "blocking" behaviour will only occur if there are no other jobs already running + public let shouldBlockFirstRunEachSession: Bool + /// Seconds since epoch to indicate the next datetime that this job should run public let nextRunTimestamp: TimeInterval @@ -174,6 +169,7 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer failureCount: UInt, variant: Variant, behaviour: Behaviour, + shouldBlockFirstRunEachSession: Bool, nextRunTimestamp: TimeInterval, threadId: String?, interactionId: Int64?, @@ -183,6 +179,7 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer self.failureCount = failureCount self.variant = variant self.behaviour = behaviour + self.shouldBlockFirstRunEachSession = shouldBlockFirstRunEachSession self.nextRunTimestamp = nextRunTimestamp self.threadId = threadId self.interactionId = interactionId @@ -193,6 +190,7 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer failureCount: UInt = 0, variant: Variant, behaviour: Behaviour = .runOnce, + shouldBlockFirstRunEachSession: Bool = false, nextRunTimestamp: TimeInterval = 0, threadId: String? = nil, interactionId: Int64? = nil @@ -200,6 +198,7 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer self.failureCount = failureCount self.variant = variant self.behaviour = behaviour + self.shouldBlockFirstRunEachSession = shouldBlockFirstRunEachSession self.nextRunTimestamp = nextRunTimestamp self.threadId = threadId self.interactionId = interactionId @@ -210,6 +209,7 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer failureCount: UInt = 0, variant: Variant, behaviour: Behaviour = .runOnce, + shouldBlockFirstRunEachSession: Bool = false, nextRunTimestamp: TimeInterval = 0, threadId: String? = nil, interactionId: Int64? = nil, @@ -225,6 +225,7 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer self.failureCount = failureCount self.variant = variant self.behaviour = behaviour + self.shouldBlockFirstRunEachSession = shouldBlockFirstRunEachSession self.nextRunTimestamp = nextRunTimestamp self.threadId = threadId self.interactionId = interactionId @@ -236,23 +237,14 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer public mutating func didInsert(with rowID: Int64, for column: String?) { self.id = rowID } - - public func delete(_ db: Database) throws -> Bool { - // Delete any dependencies - try dependantJobs - .deleteAll(db) - - return try performDelete(db) - } } // MARK: - GRDB Interactions extension Job { - internal static func filterPendingJobs(excludeFutureJobs: Bool = true) -> QueryInterfaceRequest { + internal static func filterPendingJobs(variants: [Variant], excludeFutureJobs: Bool = true) -> QueryInterfaceRequest { let query: QueryInterfaceRequest = Job .filter( - // TODO: Should this include other behaviours? (what happens if one of the other types fails???? Just leave it until the next launch/active???) Set a 'failureCount' and use that to determine if it should run? (reset on success) // Retrieve all 'runOnce' and 'recurring' jobs [ Job.Behaviour.runOnce, @@ -262,13 +254,12 @@ extension Job { // 'nextRunTimestamp' [ Job.Behaviour.recurringOnLaunch, - Job.Behaviour.recurringOnLaunchBlocking, - Job.Behaviour.recurringOnActive, - Job.Behaviour.recurringOnActiveBlocking + Job.Behaviour.recurringOnActive ].contains(Job.Columns.behaviour) && Job.Columns.nextRunTimestamp > 0 ) ) + .filter(variants.contains(Job.Columns.variant)) .order(Job.Columns.nextRunTimestamp) .order(Job.Columns.id) @@ -284,30 +275,20 @@ extension Job { // MARK: - Convenience public extension Job { - var isBlocking: Bool { - switch self.behaviour { - case .recurringOnLaunchBlocking, - .recurringOnLaunchBlockingOncePerSession, - .recurringOnActiveBlocking: - return true - - default: return false - } - } - func with( failureCount: UInt = 0, nextRunTimestamp: TimeInterval ) -> Job { return Job( - id: id, + id: self.id, failureCount: failureCount, - variant: variant, - behaviour: behaviour, + variant: self.variant, + behaviour: self.behaviour, + shouldBlockFirstRunEachSession: self.shouldBlockFirstRunEachSession, nextRunTimestamp: nextRunTimestamp, - threadId: threadId, - interactionId: interactionId, - details: details + threadId: self.threadId, + interactionId: self.interactionId, + details: self.details ) } @@ -315,13 +296,14 @@ public extension Job { guard let detailsData: Data = try? JSONEncoder().encode(details) else { return nil } return Job( - id: id, - failureCount: failureCount, - variant: variant, - behaviour: behaviour, - nextRunTimestamp: nextRunTimestamp, - threadId: threadId, - interactionId: interactionId, + id: self.id, + failureCount: self.failureCount, + variant: self.variant, + behaviour: self.behaviour, + shouldBlockFirstRunEachSession: self.shouldBlockFirstRunEachSession, + nextRunTimestamp: self.nextRunTimestamp, + threadId: self.threadId, + interactionId: self.interactionId, details: detailsData ) } diff --git a/SessionUtilitiesKit/Database/Models/JobDependencies.swift b/SessionUtilitiesKit/Database/Models/JobDependencies.swift index fd98fa61f..0ee8c10b0 100644 --- a/SessionUtilitiesKit/Database/Models/JobDependencies.swift +++ b/SessionUtilitiesKit/Database/Models/JobDependencies.swift @@ -15,8 +15,16 @@ public struct JobDependencies: Codable, FetchableRecord, PersistableRecord, Tabl case dependantId } + /// The is the id of the main job public let jobId: Int64 - public let dependantId: Int64 + + /// The is the id of the job that the main job is dependant on + /// + /// **Note:** If this is `null` it means the dependant job has been deleted (but the dependency wasn't + /// removed) this generally means a job has been directly deleted without it's dependencies getting cleaned + /// up - If we find a job that has a dependency with no `dependantId` then it's likely an invalid job and + /// should be removed + public let dependantId: Int64? // MARK: - Initialization diff --git a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift index e53912c8c..6c29b8a42 100644 --- a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift +++ b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift @@ -33,7 +33,7 @@ public class PagedDatabaseObserver: TransactionObserver where // MARK: - Initialization - fileprivate init( + public init( pagedTable: ObservedTable.Type, pageSize: Int, idColumn: ObservedTable.Columns, @@ -43,8 +43,7 @@ public class PagedDatabaseObserver: TransactionObserver where orderSQL: SQL, dataQuery: @escaping (SQL?, SQL?) -> AdaptedFetchRequest>, associatedRecords: [ErasedAssociatedRecord] = [], - onChangeUnsorted: @escaping ([T], PagedData.PageInfo) -> (), - initialQueryTarget: PagedData.PageInfo.InternalTarget? + onChangeUnsorted: @escaping ([T], PagedData.PageInfo) -> () ) { let associatedTables: Set = associatedRecords.map { $0.databaseTableName }.asSet() assert(!associatedTables.contains(pagedTable.databaseTableName), "The paged table cannot also exist as an associatedRecord") @@ -80,11 +79,6 @@ public class PagedDatabaseObserver: TransactionObserver where .filter { $0.events.contains(.delete) } .map { $0.databaseTableName } .asSet() - - // Run the initial query if there is one - guard let initialQueryTarget: PagedData.PageInfo.InternalTarget = initialQueryTarget else { return } - - self.load(initialQueryTarget) } // MARK: - TransactionObserver @@ -483,69 +477,18 @@ public class PagedDatabaseObserver: TransactionObserver where // MARK: - Convenience public extension PagedDatabaseObserver { - fileprivate static func initialQueryTarget( - for initialFocusedId: ID?, - skipInitialQuery: Bool - ) -> PagedData.PageInfo.InternalTarget? { - // Determine if we want to laod the first page immediately (this is generally needed - // to prevent transitions from looking buggy) - guard !skipInitialQuery else { return nil } - - switch initialFocusedId { - case .some(let targetId): return .initialPageAround(id: targetId.sqlExpression) - - // If we don't have a `initialFocusedId` then default to `.pageBefore` (it'll query - // from a `0` offset - case .none: return .pageBefore - } - } - convenience init( pagedTable: ObservedTable.Type, pageSize: Int, idColumn: ObservedTable.Columns, - initialFocusedId: ObservedTable.ID? = nil, - observedChanges: [PagedData.ObservedChanges], - joinSQL: SQL? = nil, - filterSQL: SQL, - orderSQL: SQL, - dataQuery: @escaping (SQL?, SQL?) -> AdaptedFetchRequest>, - associatedRecords: [ErasedAssociatedRecord] = [], - onChangeUnsorted: @escaping ([T], PagedData.PageInfo) -> (), - skipInitialQuery: Bool = false - ) where ObservedTable.ID: SQLExpressible { - self.init( - pagedTable: pagedTable, - pageSize: pageSize, - idColumn: idColumn, - observedChanges: observedChanges, - joinSQL: joinSQL, - filterSQL: filterSQL, - orderSQL: orderSQL, - dataQuery: dataQuery, - associatedRecords: associatedRecords, - onChangeUnsorted: onChangeUnsorted, - initialQueryTarget: PagedDatabaseObserver.initialQueryTarget( - for: initialFocusedId, - skipInitialQuery: skipInitialQuery - ) - ) - } - - convenience init( - pagedTable: ObservedTable.Type, - pageSize: Int, - idColumn: ObservedTable.Columns, - initialFocusedId: ObservedTable.ID? = nil, observedChanges: [PagedData.ObservedChanges], joinSQL: SQL? = nil, filterSQL: SQL, orderSQL: SQL, dataQuery: @escaping (SQL?, SQL?) -> SQLRequest, associatedRecords: [ErasedAssociatedRecord] = [], - onChangeUnsorted: @escaping ([T], PagedData.PageInfo) -> (), - skipInitialQuery: Bool = false - ) where ObservedTable.ID: SQLExpressible { + onChangeUnsorted: @escaping ([T], PagedData.PageInfo) -> () + ) { self.init( pagedTable: pagedTable, pageSize: pageSize, @@ -558,77 +501,7 @@ public extension PagedDatabaseObserver { dataQuery(additionalFilters, limit).adapted { _ in ScopeAdapter([:]) } }, associatedRecords: associatedRecords, - onChangeUnsorted: onChangeUnsorted, - initialQueryTarget: PagedDatabaseObserver.initialQueryTarget( - for: initialFocusedId, - skipInitialQuery: skipInitialQuery - ) - ) - } - - convenience init( - pagedTable: ObservedTable.Type, - pageSize: Int, - idColumn: ObservedTable.Columns, - initialFocusedId: ID? = nil, - observedChanges: [PagedData.ObservedChanges], - joinSQL: SQL? = nil, - filterSQL: SQL, - orderSQL: SQL, - dataQuery: @escaping (SQL?, SQL?) -> AdaptedFetchRequest>, - associatedRecords: [ErasedAssociatedRecord] = [], - onChangeUnsorted: @escaping ([T], PagedData.PageInfo) -> (), - skipInitialQuery: Bool = false - ) where ObservedTable.ID == Optional, ID: SQLExpressible { - self.init( - pagedTable: pagedTable, - pageSize: pageSize, - idColumn: idColumn, - observedChanges: observedChanges, - joinSQL: joinSQL, - filterSQL: filterSQL, - orderSQL: orderSQL, - dataQuery: dataQuery, - associatedRecords: associatedRecords, - onChangeUnsorted: onChangeUnsorted, - initialQueryTarget: PagedDatabaseObserver.initialQueryTarget( - for: initialFocusedId, - skipInitialQuery: skipInitialQuery - ) - ) - } - - convenience init( - pagedTable: ObservedTable.Type, - pageSize: Int, - idColumn: ObservedTable.Columns, - initialFocusedId: ID? = nil, - observedChanges: [PagedData.ObservedChanges], - joinSQL: SQL? = nil, - filterSQL: SQL, - orderSQL: SQL, - dataQuery: @escaping (SQL?, SQL?) -> SQLRequest, - associatedRecords: [ErasedAssociatedRecord] = [], - onChangeUnsorted: @escaping ([T], PagedData.PageInfo) -> (), - skipInitialQuery: Bool = false - ) where ObservedTable.ID == Optional, ID: SQLExpressible { - self.init( - pagedTable: pagedTable, - pageSize: pageSize, - idColumn: idColumn, - observedChanges: observedChanges, - joinSQL: joinSQL, - filterSQL: filterSQL, - orderSQL: orderSQL, - dataQuery: { additionalFilters, limit in - dataQuery(additionalFilters, limit).adapted { _ in ScopeAdapter([:]) } - }, - associatedRecords: associatedRecords, - onChangeUnsorted: onChangeUnsorted, - initialQueryTarget: PagedDatabaseObserver.initialQueryTarget( - for: initialFocusedId, - skipInitialQuery: skipInitialQuery - ) + onChangeUnsorted: onChangeUnsorted ) } diff --git a/SessionUtilitiesKit/General/String+Utilities.swift b/SessionUtilitiesKit/General/String+Utilities.swift index 9b5777417..4b9dd58e3 100644 --- a/SessionUtilitiesKit/General/String+Utilities.swift +++ b/SessionUtilitiesKit/General/String+Utilities.swift @@ -3,6 +3,31 @@ import SignalCoreKit public extension String { + var glyphCount: Int { + let richText = NSAttributedString(string: self) + let line = CTLineCreateWithAttributedString(richText) + + return CTLineGetGlyphCount(line) + } + + var isSingleEmoji: Bool { + return (glyphCount == 1 && containsEmoji) + } + + var containsEmoji: Bool { + return unicodeScalars.contains { $0.isEmoji } + } + + var containsOnlyEmoji: Bool { + return ( + !isEmpty && + !unicodeScalars.contains(where: { + !$0.isEmoji && + !$0.isZeroWidthJoiner + }) + ) + } + func localized() -> String { // If the localized string matches the key provided then the localisation failed let localizedString = NSLocalizedString(self, comment: "") @@ -28,4 +53,15 @@ public extension String { return ranges } + + static func filterNotificationText(_ text: String?) -> String? { + guard let text = text?.filterStringForDisplay() 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: "%%") + } } diff --git a/SessionUtilitiesKit/General/UnicodeScalar+Utilities.swift b/SessionUtilitiesKit/General/UnicodeScalar+Utilities.swift new file mode 100644 index 000000000..e535e32f3 --- /dev/null +++ b/SessionUtilitiesKit/General/UnicodeScalar+Utilities.swift @@ -0,0 +1,121 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension UnicodeScalar { + class EmojiRange { + // rangeStart and rangeEnd are inclusive. + let rangeStart: UInt32 + let rangeEnd: UInt32 + + // MARK: - Initializers + + init(rangeStart: UInt32, rangeEnd: UInt32) { + self.rangeStart = rangeStart + self.rangeEnd = rangeEnd + } + } + + // From: + // https://www.unicode.org/Public/emoji/ + // Current Version: + // https://www.unicode.org/Public/emoji/6.0/emoji-data.txt + // + // These ranges can be code-generated using: + // + // * Scripts/emoji-data.txt + // * Scripts/emoji_ranges.py + static let kEmojiRanges = [ + // NOTE: Don't treat Pound Sign # as Jumbomoji. + // EmojiRange(rangeStart:0x23, rangeEnd:0x23), + // NOTE: Don't treat Asterisk * as Jumbomoji. + // EmojiRange(rangeStart:0x2A, rangeEnd:0x2A), + // NOTE: Don't treat Digits 0..9 as Jumbomoji. + // EmojiRange(rangeStart:0x30, rangeEnd:0x39), + // NOTE: Don't treat Copyright Symbol © as Jumbomoji. + // EmojiRange(rangeStart:0xA9, rangeEnd:0xA9), + // NOTE: Don't treat Trademark Sign ® as Jumbomoji. + // EmojiRange(rangeStart:0xAE, rangeEnd:0xAE), + EmojiRange(rangeStart: 0x200D, rangeEnd: 0x200D), + EmojiRange(rangeStart: 0x203C, rangeEnd: 0x203C), + EmojiRange(rangeStart: 0x2049, rangeEnd: 0x2049), + EmojiRange(rangeStart: 0x20D0, rangeEnd: 0x20FF), + EmojiRange(rangeStart: 0x2122, rangeEnd: 0x2122), + EmojiRange(rangeStart: 0x2139, rangeEnd: 0x2139), + EmojiRange(rangeStart: 0x2194, rangeEnd: 0x2199), + EmojiRange(rangeStart: 0x21A9, rangeEnd: 0x21AA), + EmojiRange(rangeStart: 0x231A, rangeEnd: 0x231B), + EmojiRange(rangeStart: 0x2328, rangeEnd: 0x2328), + EmojiRange(rangeStart: 0x2388, rangeEnd: 0x2388), + EmojiRange(rangeStart: 0x23CF, rangeEnd: 0x23CF), + EmojiRange(rangeStart: 0x23E9, rangeEnd: 0x23F3), + EmojiRange(rangeStart: 0x23F8, rangeEnd: 0x23FA), + EmojiRange(rangeStart: 0x24C2, rangeEnd: 0x24C2), + EmojiRange(rangeStart: 0x25AA, rangeEnd: 0x25AB), + EmojiRange(rangeStart: 0x25B6, rangeEnd: 0x25B6), + EmojiRange(rangeStart: 0x25C0, rangeEnd: 0x25C0), + EmojiRange(rangeStart: 0x25FB, rangeEnd: 0x25FE), + EmojiRange(rangeStart: 0x2600, rangeEnd: 0x27BF), + EmojiRange(rangeStart: 0x2934, rangeEnd: 0x2935), + EmojiRange(rangeStart: 0x2B05, rangeEnd: 0x2B07), + EmojiRange(rangeStart: 0x2B1B, rangeEnd: 0x2B1C), + EmojiRange(rangeStart: 0x2B50, rangeEnd: 0x2B50), + EmojiRange(rangeStart: 0x2B55, rangeEnd: 0x2B55), + EmojiRange(rangeStart: 0x3030, rangeEnd: 0x3030), + EmojiRange(rangeStart: 0x303D, rangeEnd: 0x303D), + EmojiRange(rangeStart: 0x3297, rangeEnd: 0x3297), + EmojiRange(rangeStart: 0x3299, rangeEnd: 0x3299), + EmojiRange(rangeStart: 0xFE00, rangeEnd: 0xFE0F), + EmojiRange(rangeStart: 0x1F000, rangeEnd: 0x1F0FF), + EmojiRange(rangeStart: 0x1F10D, rangeEnd: 0x1F10F), + EmojiRange(rangeStart: 0x1F12F, rangeEnd: 0x1F12F), + EmojiRange(rangeStart: 0x1F16C, rangeEnd: 0x1F171), + EmojiRange(rangeStart: 0x1F17E, rangeEnd: 0x1F17F), + EmojiRange(rangeStart: 0x1F18E, rangeEnd: 0x1F18E), + EmojiRange(rangeStart: 0x1F191, rangeEnd: 0x1F19A), + EmojiRange(rangeStart: 0x1F1AD, rangeEnd: 0x1F1FF), + EmojiRange(rangeStart: 0x1F201, rangeEnd: 0x1F20F), + EmojiRange(rangeStart: 0x1F21A, rangeEnd: 0x1F21A), + EmojiRange(rangeStart: 0x1F22F, rangeEnd: 0x1F22F), + EmojiRange(rangeStart: 0x1F232, rangeEnd: 0x1F23A), + EmojiRange(rangeStart: 0x1F23C, rangeEnd: 0x1F23F), + EmojiRange(rangeStart: 0x1F249, rangeEnd: 0x1F64F), + EmojiRange(rangeStart: 0x1F680, rangeEnd: 0x1F6FF), + EmojiRange(rangeStart: 0x1F774, rangeEnd: 0x1F77F), + EmojiRange(rangeStart: 0x1F7D5, rangeEnd: 0x1F7FF), + EmojiRange(rangeStart: 0x1F80C, rangeEnd: 0x1F80F), + EmojiRange(rangeStart: 0x1F848, rangeEnd: 0x1F84F), + EmojiRange(rangeStart: 0x1F85A, rangeEnd: 0x1F85F), + EmojiRange(rangeStart: 0x1F888, rangeEnd: 0x1F88F), + EmojiRange(rangeStart: 0x1F8AE, rangeEnd: 0x1FFFD), + EmojiRange(rangeStart: 0xE0020, rangeEnd: 0xE007F) + ] + + var isEmoji: Bool { + // Binary search + var left: Int = 0 + var right = Int(UnicodeScalar.kEmojiRanges.count - 1) + + while true { + let mid = (left + right) / 2 + let midRange = UnicodeScalar.kEmojiRanges[mid] + if value < midRange.rangeStart { + if mid == left { + return false + } + right = mid - 1 + } else if value > midRange.rangeEnd { + if mid == right { + return false + } + left = mid + 1 + } else { + return true + } + } + } + + var isZeroWidthJoiner: Bool { + return value == 8205 + } +} diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index df07cc562..3fb4f50c1 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -35,48 +35,64 @@ public protocol JobExecutor { } public final class JobRunner { - private class Trigger { - private var timer: Timer? + private static let blockingQueue: Atomic = Atomic( + JobQueue( + type: .blocking, + qos: .userInitiated, + jobVariants: [], + onQueueDrained: { + // Once all blocking jobs have been completed we want to start running + // the remaining job queues + queues.wrappedValue.forEach { _, queue in queue.start() } + } + ) + ) + private static let queues: Atomic<[Job.Variant: JobQueue]> = { + var jobVariants: Set = Job.Variant.allCases.asSet() - static func create(timestamp: TimeInterval) -> Trigger? { - // Setup the trigger (wait at least 1 second before triggering) - let trigger: Trigger = Trigger() - trigger.timer = Timer.scheduledTimer( - timeInterval: max(1, (timestamp - Date().timeIntervalSince1970)), - target: self, - selector: #selector(start), - userInfo: nil, - repeats: false - ) - - return trigger - } + let messageSendQueue: JobQueue = JobQueue( + type: .messageSend, + qos: .default, + jobVariants: [ + jobVariants.remove(.attachmentUpload), + jobVariants.remove(.messageSend), + jobVariants.remove(.notifyPushServer)// TODO: Read receipts + ].compactMap { $0 } + ) + let messageReceiveQueue: JobQueue = JobQueue( + type: .messageReceive, + qos: .default, + jobVariants: [ + jobVariants.remove(.messageReceive) + ].compactMap { $0 } + ) + let attachmentDownloadQueue: JobQueue = JobQueue( + type: .attachmentDownload, + qos: .utility, + jobVariants: [ + jobVariants.remove(.attachmentDownload) + ].compactMap { $0 } + ) + let generalQueue: JobQueue = JobQueue( + type: .general(number: 0), + qos: .utility, + jobVariants: Array(jobVariants) + ) - deinit { timer?.invalidate() } - - @objc func start() { - JobRunner.start() - } - } - - // TODO: Could this be a bottleneck? (single serial queue to process all these jobs? Group by thread?). - // TODO: Multi-thread support. - private static let queueKey: DispatchSpecificKey = DispatchSpecificKey() - private static let queueContext: String = "JobRunner" - private static let internalQueue: DispatchQueue = { - let result: DispatchQueue = DispatchQueue(label: queueContext) - result.setSpecific(key: queueKey, value: queueContext) - - return result + return Atomic([ + messageSendQueue, + messageReceiveQueue, + attachmentDownloadQueue, + generalQueue + ].reduce(into: [:]) { prev, next in + next.jobVariants.forEach { variant in + prev[variant] = next + } + }) }() internal static var executorMap: Atomic<[Job.Variant: JobExecutor.Type]> = Atomic([:]) - private static var nextTrigger: Atomic = Atomic(nil) - private static var isRunning: Atomic = Atomic(false) - private static var jobQueue: Atomic<[Job]> = Atomic([]) - - private static var jobsCurrentlyRunning: Atomic> = Atomic([]) - private static var perSessionJobsCompleted: Atomic> = Atomic([]) + fileprivate static var perSessionJobsCompleted: Atomic> = Atomic([]) // MARK: - Configuration @@ -98,20 +114,11 @@ public final class JobRunner { return } - // Check if the job should be added to the queue - guard - canStartJob, - updatedJob.behaviour != .runOnceNextLaunch, - updatedJob.nextRunTimestamp <= Date().timeIntervalSince1970 - else { return } - - jobQueue.mutate { $0.append(updatedJob) } + queues.mutate { $0[updatedJob.variant]?.add(updatedJob, canStartJob: canStartJob) } // Start the job runner if needed db.afterNextTransactionCommit { _ in - if !isRunning.wrappedValue { - start() - } + queues.wrappedValue[updatedJob.variant]?.start() } } @@ -122,29 +129,8 @@ public final class JobRunner { /// is in the future then the job won't be started public static func upsert(_ db: Database, job: Job?, canStartJob: Bool = true) { guard let job: Job = job else { return } // Ignore null jobs - guard let jobId: Int64 = job.id else { - add(db, job: job, canStartJob: canStartJob) - return - } - // Lock the queue while checking the index and inserting to ensure we don't run into - // any multi-threading shenanigans - // - // Note: currently running jobs are removed from the queue so we don't need to check - // the 'jobsCurrentlyRunning' set - var didUpdateExistingJob: Bool = false - - jobQueue.mutate { queue in - if let jobIndex: Array.Index = queue.firstIndex(where: { $0.id == jobId }) { - queue[jobIndex] = job - didUpdateExistingJob = true - } - } - - // If we didn't update an existing job then we need to add it to the queue - guard !didUpdateExistingJob else { return } - - add(db, job: job, canStartJob: canStartJob) + queues.wrappedValue[job.variant]?.upsert(job, canStartJob: canStartJob) } @discardableResult public static func insert(_ db: Database, job: Job?, before otherJob: Job) -> Job? { @@ -162,18 +148,7 @@ public final class JobRunner { return nil } - // Insert the job before the current job (re-adding the current job to - // the start of the queue if it's not in there) - this will mean the new - // job will run and then the otherJob will run (or run again) once it's - // done - jobQueue.mutate { - guard let otherJobIndex: Int = $0.firstIndex(of: otherJob) else { - $0.insert(contentsOf: [updatedJob, otherJob], at: 0) - return - } - - $0.insert(updatedJob, at: otherJobIndex) - } + queues.wrappedValue[updatedJob.variant]?.insert(updatedJob, before: otherJob) return updatedJob } @@ -181,85 +156,303 @@ public final class JobRunner { public static func appDidFinishLaunching() { // Note: 'appDidBecomeActive' will run on first launch anyway so we can // leave those jobs out and can wait until then to start the JobRunner - let maybeJobsToRun: [Job]? = GRDBStorage.shared.read { db in - try Job - .filter( - [ - Job.Behaviour.recurringOnLaunch, - Job.Behaviour.recurringOnLaunchBlocking, - Job.Behaviour.recurringOnLaunchBlockingOncePerSession, - Job.Behaviour.runOnceNextLaunch - ].contains(Job.Columns.behaviour) - ) - .order(Job.Columns.id) - .fetchAll(db) - } + let jobsToRun: (blocking: [Job], nonBlocking: [Job]) = GRDBStorage.shared + .read { db in + let blockingJobs: [Job] = try Job + .filter( + [ + Job.Behaviour.recurringOnLaunch, + Job.Behaviour.runOnceNextLaunch + ].contains(Job.Columns.behaviour) + ) + .filter(Job.Columns.shouldBlockFirstRunEachSession == true) + .order(Job.Columns.id) + .fetchAll(db) + let nonblockingJobs: [Job] = try Job + .filter( + [ + Job.Behaviour.recurringOnLaunch, + Job.Behaviour.runOnceNextLaunch + ].contains(Job.Columns.behaviour) + ) + .filter(Job.Columns.shouldBlockFirstRunEachSession == false) + .order(Job.Columns.id) + .fetchAll(db) + + return (blockingJobs, nonblockingJobs) + } + .defaulting(to: ([], [])) - guard let jobsToRun: [Job] = maybeJobsToRun else { return } + guard !jobsToRun.blocking.isEmpty || !jobsToRun.nonBlocking.isEmpty else { return } - jobQueue.mutate { - // Insert any blocking jobs after any existing blocking jobs then add - // the remaining jobs to the end of the queue - let lastBlockingIndex = $0.lastIndex(where: { $0.isBlocking }) - .defaulting(to: $0.startIndex.advanced(by: -1)) - .advanced(by: 1) - - $0.insert( - contentsOf: jobsToRun.filter { $0.isBlocking }, - at: lastBlockingIndex - ) - $0.append( - contentsOf: jobsToRun.filter { !$0.isBlocking } - ) + // Add and start any blocking jobs + blockingQueue.wrappedValue?.appDidFinishLaunching(with: jobsToRun.blocking, canStart: true) + + // Add any non-blocking jobs (we don't start these incase there are blocking "on active" + // jobs as well) + let jobsByVariant: [Job.Variant: [Job]] = jobsToRun.nonBlocking.grouped(by: \.variant) + let jobQueues: [Job.Variant: JobQueue] = queues.wrappedValue + + jobsByVariant.forEach { variant, jobs in + jobQueues[variant]?.appDidFinishLaunching(with: jobs, canStart: false) } } public static func appDidBecomeActive() { - let maybeJobsToRun: [Job]? = GRDBStorage.shared.read { db in - try Job - .filter( - [ - Job.Behaviour.recurringOnActive, - Job.Behaviour.recurringOnActiveBlocking - ].contains(Job.Columns.behaviour) - ) - .order(Job.Columns.id) - .fetchAll(db) - } + // Note: When becoming active we want to start all non-on-launch blocking jobs as + // long as there are no other jobs already running + let alreadyRunningOtherJobs: Bool = queues.wrappedValue + .contains(where: { _, queue -> Bool in queue.isRunning.wrappedValue }) + let jobsToRun: (blocking: [Job], nonBlocking: [Job]) = GRDBStorage.shared + .read { db in + guard !alreadyRunningOtherJobs else { + let onActiveJobs: [Job] = try Job + .filter(Job.Columns.behaviour == Job.Behaviour.recurringOnActive) + .order(Job.Columns.id) + .fetchAll(db) + + return ([], onActiveJobs) + } + + let blockingJobs: [Job] = try Job + .filter( + Job.Behaviour.allCases + .filter { + $0 != .recurringOnLaunch && + $0 != .runOnceNextLaunch + } + .contains(Job.Columns.behaviour) + ) + .filter(Job.Columns.shouldBlockFirstRunEachSession == true) + .order(Job.Columns.id) + .fetchAll(db) + let nonBlockingJobs: [Job] = try Job + .filter(Job.Columns.behaviour == Job.Behaviour.recurringOnActive) + .filter(Job.Columns.shouldBlockFirstRunEachSession == false) + .order(Job.Columns.id) + .fetchAll(db) + + return (blockingJobs, nonBlockingJobs) + } + .defaulting(to: ([], [])) - guard let jobsToRun: [Job] = maybeJobsToRun else { return } + guard !jobsToRun.blocking.isEmpty || !jobsToRun.nonBlocking.isEmpty else { return } - jobQueue.mutate { - // Insert any blocking jobs after any existing blocking jobs then add - // the remaining jobs to the end of the queue - let lastBlockingIndex = $0.lastIndex(where: { $0.isBlocking }) - .defaulting(to: $0.startIndex.advanced(by: -1)) - .advanced(by: 1) - - $0.insert( - contentsOf: jobsToRun.filter { $0.isBlocking }, - at: lastBlockingIndex + // Add and start any blocking jobs + blockingQueue.wrappedValue?.appDidFinishLaunching(with: jobsToRun.blocking, canStart: true) + + let blockingQueueIsRunning: Bool = (blockingQueue.wrappedValue?.isRunning.wrappedValue == true) + let jobsByVariant: [Job.Variant: [Job]] = jobsToRun.nonBlocking.grouped(by: \.variant) + let jobQueues: [Job.Variant: JobQueue] = queues.wrappedValue + + jobsByVariant.forEach { variant, jobs in + jobQueues[variant]?.appDidBecomeActive( + with: jobs, + canStart: !blockingQueueIsRunning ) - $0.append( - contentsOf: jobsToRun.filter { !$0.isBlocking } - ) - } - - // Start the job runner if needed - if !isRunning.wrappedValue { - start() } } public static func isCurrentlyRunning(_ job: Job?) -> Bool { guard let job: Job = job, let jobId: Int64 = job.id else { return false } + return (queues.wrappedValue[job.variant]?.isCurrentlyRunning(jobId) == true) + } + + // MARK: - Convenience + + fileprivate static func getRetryInterval(for job: Job) -> TimeInterval { + // Arbitrary backoff factor... + // try 1 delay: 0.5s + // try 2 delay: 1s + // ... + // try 5 delay: 16s + // ... + // try 11 delay: 512s + let maxBackoff: Double = 10 * 60 // 10 minutes + return 0.25 * min(maxBackoff, pow(2, Double(job.failureCount))) + } +} + +// MARK: - JobQueue + +private final class JobQueue { + fileprivate enum QueueType: Hashable { + case blocking + case general(number: Int) + case messageSend + case messageReceive + case attachmentDownload + + var name: String { + switch self { + case .blocking: return "Blocking" + case .general(let number): return "General-\(number)" + case .messageSend: return "MessageSend" + case .messageReceive: return "MessageReceive" + case .attachmentDownload: return "AttachmentDownload" + } + } + } + + private class Trigger { + private weak var queue: JobQueue? + private var timer: Timer? + + static func create(queue: JobQueue, timestamp: TimeInterval) -> Trigger? { + // Setup the trigger (wait at least 1 second before triggering) + let trigger: Trigger = Trigger() + trigger.queue = queue + trigger.timer = Timer.scheduledTimer( + timeInterval: max(1, (timestamp - Date().timeIntervalSince1970)), + target: self, + selector: #selector(start), + userInfo: nil, + repeats: false + ) + + return trigger + } + + deinit { timer?.invalidate() } + + @objc func start() { + queue?.start() + } + } + + private let type: QueueType + private let qosClass: DispatchQoS + private let queueKey: DispatchSpecificKey = DispatchSpecificKey() + private let queueContext: String + + /// The specific types of jobs this queue manages, if this is left empty it will handle all jobs not handled by other queues + fileprivate let jobVariants: [Job.Variant] + + private let onQueueDrained: (() -> ())? + + private lazy var internalQueue: DispatchQueue = { + let result: DispatchQueue = DispatchQueue( + label: self.queueContext, + qos: self.qosClass, + attributes: [], + autoreleaseFrequency: .inherit, + target: nil + ) + result.setSpecific(key: queueKey, value: queueContext) + + return result + }() + + private var nextTrigger: Atomic = Atomic(nil) + fileprivate var isRunning: Atomic = Atomic(false) + private var queue: Atomic<[Job]> = Atomic([]) + private var jobsCurrentlyRunning: Atomic> = Atomic([]) + + fileprivate var hasPendingJobs: Bool { !queue.wrappedValue.isEmpty } + + // MARK: - Initialization + + init(type: QueueType, qos: DispatchQoS, jobVariants: [Job.Variant], onQueueDrained: (() -> ())? = nil) { + self.type = type + self.queueContext = "JobQueue-\(type.name)" + self.qosClass = qos + self.jobVariants = jobVariants + self.onQueueDrained = onQueueDrained + } + + // MARK: - Execution + + fileprivate func add(_ job: Job, canStartJob: Bool = true) { + // Check if the job should be added to the queue + guard + canStartJob, + job.behaviour != .runOnceNextLaunch, + job.nextRunTimestamp <= Date().timeIntervalSince1970 + else { return } + + queue.mutate { $0.append(job) } + } + + /// Upsert a job onto the queue, if the queue isn't currently running and 'canStartJob' is true then this will start + /// the JobRunner + /// + /// **Note:** If the job has a `behaviour` of `runOnceNextLaunch` or the `nextRunTimestamp` + /// is in the future then the job won't be started + fileprivate func upsert(_ job: Job, canStartJob: Bool = true) { + guard let jobId: Int64 = job.id else { + add(job, canStartJob: canStartJob) + return + } + + // Lock the queue while checking the index and inserting to ensure we don't run into + // any multi-threading shenanigans + // + // Note: currently running jobs are removed from the queue so we don't need to check + // the 'jobsCurrentlyRunning' set + var didUpdateExistingJob: Bool = false + + queue.mutate { queue in + if let jobIndex: Array.Index = queue.firstIndex(where: { $0.id == jobId }) { + queue[jobIndex] = job + didUpdateExistingJob = true + } + } + + // If we didn't update an existing job then we need to add it to the queue + guard !didUpdateExistingJob else { return } + + add(job, canStartJob: canStartJob) + } + + fileprivate func insert(_ job: Job, before otherJob: Job) { + // Insert the job before the current job (re-adding the current job to + // the start of the queue if it's not in there) - this will mean the new + // job will run and then the otherJob will run (or run again) once it's + // done + queue.mutate { + guard let otherJobIndex: Int = $0.firstIndex(of: otherJob) else { + $0.insert(contentsOf: [job, otherJob], at: 0) + return + } + + $0.insert(job, at: otherJobIndex) + } + } + + fileprivate func appDidFinishLaunching(with jobs: [Job], canStart: Bool) { + queue.mutate { $0.append(contentsOf: jobs) } + + // Start the job runner if needed + if canStart && !isRunning.wrappedValue { + start() + } + } + + fileprivate func appDidBecomeActive(with jobs: [Job], canStart: Bool) { + queue.mutate { queue in + // Avoid re-adding jobs to the queue that are already in it (this can + // happen if the user sends the app to the background before the 'onActive' + // jobs and then brings it back to the foreground) + let jobsNotAlreadyInQueue: [Job] = jobs + .filter { job in !queue.contains(where: { $0.id == job.id }) } + + queue.append(contentsOf: jobsNotAlreadyInQueue) + } + + // Start the job runner if needed + if canStart && !isRunning.wrappedValue { + start() + } + } + + fileprivate func isCurrentlyRunning(_ jobId: Int64) -> Bool { return jobsCurrentlyRunning.wrappedValue.contains(jobId) } // MARK: - Job Running - public static func start() { + fileprivate func start() { // We only want the JobRunner to run in the main app guard CurrentAppContext().isMainApp else { return } guard !isRunning.wrappedValue else { return } @@ -267,25 +460,29 @@ public final class JobRunner { // The JobRunner runs synchronously we need to ensure this doesn't start // on the main thread (if it is on the main thread then swap to a different thread) guard DispatchQueue.getSpecific(key: queueKey) == queueContext else { - internalQueue.async { - start() - }// TODO: Want to have multiple threads for this (attachment download should be separate - do we even use attachment upload anymore???) + internalQueue.async { [weak self] in + self?.start() + } return } // Get any pending jobs - let maybeJobsToRun: [Job]? = GRDBStorage.shared.read { db in - try Job// TODO: Test this - .filterPendingJobs() + let jobsToRun: [Job] = GRDBStorage.shared.read { db in + try Job.filterPendingJobs(variants: jobVariants) .fetchAll(db) } + .defaulting(to: []) // Determine the number of jobs to run var jobCount: Int = 0 - jobQueue.mutate { queue in + queue.mutate { queue in + // Avoid re-adding jobs to the queue that are already in it + let jobsNotAlreadyInQueue: [Job] = jobsToRun + .filter { job in !queue.contains(where: { $0.id == job.id }) } + // Add the jobs to the queue - if let jobsToRun: [Job] = maybeJobsToRun { + if !jobsNotAlreadyInQueue.isEmpty { queue.append(contentsOf: jobsToRun) } @@ -301,35 +498,35 @@ public final class JobRunner { } // Run the first job in the queue - SNLog("[JobRunner] Starting with (\(jobCount) job\(jobCount != 1 ? "s" : ""))") + SNLog("[JobRunner] Starting \(queueContext) with (\(jobCount) job\(jobCount != 1 ? "s" : ""))") runNextJob() } - private static func runNextJob() { + private func runNextJob() { // Ensure this is running on the correct queue guard DispatchQueue.getSpecific(key: queueKey) == queueContext else { - internalQueue.async { - runNextJob() + internalQueue.async { [weak self] in + self?.runNextJob() } return } - guard let (nextJob, numJobsRemaining): (Job, Int) = jobQueue.mutate({ queue in queue.popFirst().map { ($0, queue.count) } }) else { + guard let (nextJob, numJobsRemaining): (Job, Int) = queue.mutate({ queue in queue.popFirst().map { ($0, queue.count) } }) else { isRunning.mutate { $0 = false } scheduleNextSoonestJob() return } - guard let jobExecutor: JobExecutor.Type = executorMap.wrappedValue[nextJob.variant] else { - SNLog("[JobRunner] Unable to run \(nextJob.variant) job due to missing executor") + guard let jobExecutor: JobExecutor.Type = JobRunner.executorMap.wrappedValue[nextJob.variant] else { + SNLog("[JobRunner] \(queueContext) Unable to run \(nextJob.variant) job due to missing executor") handleJobFailed(nextJob, error: JobRunnerError.executorMissing, permanentFailure: true) return } guard !jobExecutor.requiresThreadId || nextJob.threadId != nil else { - SNLog("[JobRunner] Unable to run \(nextJob.variant) job due to missing required threadId") + SNLog("[JobRunner] \(queueContext) Unable to run \(nextJob.variant) job due to missing required threadId") handleJobFailed(nextJob, error: JobRunnerError.requiredThreadIdMissing, permanentFailure: true) return } guard !jobExecutor.requiresInteractionId || nextJob.interactionId != nil else { - SNLog("[JobRunner] Unable to run \(nextJob.variant) job due to missing required interactionId") + SNLog("[JobRunner] \(queueContext) Unable to run \(nextJob.variant) job due to missing required interactionId") handleJobFailed(nextJob, error: JobRunnerError.requiredInteractionIdMissing, permanentFailure: true) return } @@ -341,24 +538,35 @@ public final class JobRunner { } // Check if the next job has any dependencies - let jobDependencies: [Job] = GRDBStorage.shared - .read { db in try nextJob.dependencies.fetchAll(db) } - .defaulting(to: []) - - guard jobDependencies.isEmpty else { - SNLog("[JobRunner] Found job with \(jobDependencies.count) dependencies, running those first") + let dependencyInfo: (expectedCount: Int, jobs: [Job]) = GRDBStorage.shared.read { db in + let numExpectedDependencies: Int = try JobDependencies + .filter(JobDependencies.Columns.jobId == nextJob.id) + .fetchCount(db) + let jobDependencies: [Job] = try nextJob.dependencies.fetchAll(db) - let jobDependencyIds: [Int64] = jobDependencies + return (numExpectedDependencies, jobDependencies) + } + .defaulting(to: (0, [])) + + guard dependencyInfo.jobs.count == dependencyInfo.expectedCount else { + SNLog("[JobRunner] \(queueContext) found job with missing dependencies, removing the job") + handleJobFailed(nextJob, error: JobRunnerError.missingDependencies, permanentFailure: true) + return + } + guard dependencyInfo.jobs.isEmpty else { + SNLog("[JobRunner] \(queueContext) found job with \(dependencyInfo.jobs.count) dependencies, running those first") + + let jobDependencyIds: [Int64] = dependencyInfo.jobs .compactMap { $0.id } let jobIdsNotInQueue: Set = jobDependencyIds .asSet() - .subtracting(jobQueue.wrappedValue.compactMap { $0.id }) + .subtracting(queue.wrappedValue.compactMap { $0.id }) // If there are dependencies which aren't in the queue we should just append them guard !jobIdsNotInQueue.isEmpty else { - jobQueue.mutate { queue in + queue.mutate { queue in queue.append( - contentsOf: jobDependencies + contentsOf: dependencyInfo.jobs .filter { jobIdsNotInQueue.contains($0.id ?? -1) } ) queue.append(nextJob) @@ -368,7 +576,7 @@ public final class JobRunner { } // Otherwise re-add the current job after it's dependencies - jobQueue.mutate { queue in + queue.mutate { queue in guard let lastDependencyIndex: Int = queue.lastIndex(where: { jobDependencyIds.contains($0.id ?? -1) }) else { queue.append(nextJob) return @@ -388,7 +596,7 @@ public final class JobRunner { nextTrigger.mutate { $0 = nil } isRunning.mutate { $0 = true } jobsCurrentlyRunning.mutate { $0 = $0.inserting(nextJob.id) } - SNLog("[JobRunner] Start job (\(numJobsRemaining) remaining)") + SNLog("[JobRunner] \(queueContext) started job (\(numJobsRemaining) remaining)") jobExecutor.run( nextJob, @@ -398,41 +606,41 @@ public final class JobRunner { ) } - private static func scheduleNextSoonestJob() { - let nextJobTimestamp: TimeInterval? = GRDBStorage.shared - .read { db in - try TimeInterval - .fetchOne( - db, - Job - .filterPendingJobs(excludeFutureJobs: false) - .select(.nextRunTimestamp) - ) - } + private func scheduleNextSoonestJob() { + let nextJobTimestamp: TimeInterval? = GRDBStorage.shared.read { db in + try Job.filterPendingJobs(variants: jobVariants, excludeFutureJobs: false) + .select(.nextRunTimestamp) + .asRequest(of: TimeInterval.self) + .fetchOne(db) + } - guard let nextJobTimestamp: TimeInterval = nextJobTimestamp else { return } + // If there are no remaining jobs the trigger the 'onQueueDrained' callback and stop + guard let nextJobTimestamp: TimeInterval = nextJobTimestamp else { + self.onQueueDrained?() + return + } // If the next job isn't scheduled in the future then just restart the JobRunner immediately let secondsUntilNextJob: TimeInterval = (nextJobTimestamp - Date().timeIntervalSince1970) guard secondsUntilNextJob > 0 else { - SNLog("[JobRunner] Restarting immediately for job scheduled \(Int(ceil(abs(secondsUntilNextJob)))) second\(Int(ceil(abs(secondsUntilNextJob))) == 1 ? "" : "s")) ago") + SNLog("[JobRunner] Restarting \(queueContext) immediately for job scheduled \(Int(ceil(abs(secondsUntilNextJob)))) second\(Int(ceil(abs(secondsUntilNextJob))) == 1 ? "" : "s")) ago") - internalQueue.async { - JobRunner.start() + internalQueue.async { [weak self] in + self?.start() } return } // Setup a trigger - SNLog("[JobRunner] Stopping until next job in \(Int(ceil(abs(secondsUntilNextJob)))) second\(Int(ceil(abs(secondsUntilNextJob))) == 1 ? "" : "s"))") - nextTrigger.mutate { $0 = Trigger.create(timestamp: nextJobTimestamp) } + SNLog("[JobRunner] Stopping \(queueContext) until next job in \(Int(ceil(abs(secondsUntilNextJob)))) second\(Int(ceil(abs(secondsUntilNextJob))) == 1 ? "" : "s"))") + nextTrigger.mutate { $0 = Trigger.create(queue: self, timestamp: nextJobTimestamp) } } // MARK: - Handling Results /// This function is called when a job succeeds - private static func handleJobSucceeded(_ job: Job, shouldStop: Bool) { + private func handleJobSucceeded(_ job: Job, shouldStop: Bool) { switch job.behaviour { case .runOnce, .runOnceNextLaunch: GRDBStorage.shared.write { db in @@ -465,73 +673,53 @@ public final class JobRunner { .with(nextRunTimestamp: (Date().timeIntervalSince1970 + 1)) .saved(db) } - - case .recurringOnLaunchBlockingOncePerSession: - perSessionJobsCompleted.mutate { $0 = $0.inserting(job.id) } - + default: break } // The job is removed from the queue before it runs so all we need to to is remove it // from the 'currentlyRunning' set and start the next one jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) } - internalQueue.async { - runNextJob() + internalQueue.async { [weak self] in + self?.runNextJob() } } /// This function is called when a job fails, if it's wasn't a permanent failure then the 'failureCount' for the job will be incremented and it'll /// be re-run after a retry interval has passed - private static func handleJobFailed(_ job: Job, error: Error?, permanentFailure: Bool) { + private func handleJobFailed(_ job: Job, error: Error?, permanentFailure: Bool) { guard GRDBStorage.shared.read({ db in try Job.exists(db, id: job.id ?? -1) }) == true else { - SNLog("[JobRunner] \(job.variant) job canceled") + SNLog("[JobRunner] \(queueContext) \(job.variant) job canceled") jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) } - internalQueue.async { - runNextJob() + internalQueue.async { [weak self] in + self?.runNextJob() } return } - switch job.behaviour { - // If a "blocking" job failed then rerun it immediately - case .recurringOnLaunchBlocking, .recurringOnActiveBlocking: - SNLog("[JobRunner] blocking \(job.variant) job failed; retrying immediately") - jobQueue.mutate({ $0.insert(job, at: 0) }) - - internalQueue.async { - runNextJob() - } - return + // If this is the blocking queue and a "blocking" job failed then rerun it immediately + if self.type == .blocking && job.shouldBlockFirstRunEachSession { + SNLog("[JobRunner] \(queueContext) \(job.variant) job failed; retrying immediately") + queue.mutate { $0.insert(job, at: 0) } - // For "blocking once per session" jobs only rerun it immediately if it hasn't already - // run this session - case .recurringOnLaunchBlockingOncePerSession: - guard !perSessionJobsCompleted.wrappedValue.contains(job.id ?? -1) else { break } - - SNLog("[JobRunner] blocking \(job.variant) job failed; retrying immediately") - perSessionJobsCompleted.mutate { $0 = $0.inserting(job.id) } - jobQueue.mutate({ $0.insert(job, at: 0) }) - - internalQueue.async { - runNextJob() - } - return - - default: break + internalQueue.async { [weak self] in + self?.runNextJob() + } + return } + // Get the max failure count for the job (a value of '-1' means it will retry indefinitely) + let maxFailureCount: Int = (JobRunner.executorMap.wrappedValue[job.variant]?.maxFailureCount ?? 0) + let nextRunTimestamp: TimeInterval = (Date().timeIntervalSince1970 + JobRunner.getRetryInterval(for: job)) + GRDBStorage.shared.write { db in - // Get the max failure count for the job (a value of '-1' means it will retry indefinitely) - let maxFailureCount: Int = (executorMap.wrappedValue[job.variant]?.maxFailureCount ?? 0) - let nextRunTimestamp: TimeInterval = (Date().timeIntervalSince1970 + getRetryInterval(for: job)) - guard !permanentFailure && maxFailureCount >= 0 && job.failureCount + 1 < maxFailureCount else { - SNLog("[JobRunner] \(job.variant) failed permanently\(maxFailureCount >= 0 ? "; too many retries" : "")") + SNLog("[JobRunner] \(queueContext) \(job.variant) failed permanently\(maxFailureCount >= 0 ? "; too many retries" : "")") // If the job permanently failed or we have performed all of our retry attempts // then delete the job (it'll probably never succeed) @@ -539,7 +727,7 @@ public final class JobRunner { return } - SNLog("[JobRunner] \(job.variant) job failed; scheduling retry (failure count is \(job.failureCount + 1))") + SNLog("[JobRunner] \(queueContext) \(job.variant) job failed; scheduling retry (failure count is \(job.failureCount + 1))") _ = try job .with( @@ -566,38 +754,24 @@ public final class JobRunner { // Remove the dependant jobs from the queue (so we don't get stuck in a loop of trying // to run dependecies indefinitely if !dependantJobIds.isEmpty { - jobQueue.mutate { queue in + queue.mutate { queue in queue = queue.filter { !dependantJobIds.contains($0.id ?? -1) } } } } jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) } - internalQueue.async { - runNextJob() + internalQueue.async { [weak self] in + self?.runNextJob() } } /// This function is called when a job neither succeeds or fails (this should only occur if the job has specific logic that makes it dependant /// on other jobs, and it should automatically manage those dependencies) - private static func handleJobDeferred(_ job: Job) { + private func handleJobDeferred(_ job: Job) { jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) } - internalQueue.async { - runNextJob() + internalQueue.async { [weak self] in + self?.runNextJob() } } - - // MARK: - Convenience - - private static func getRetryInterval(for job: Job) -> TimeInterval { - // Arbitrary backoff factor... - // try 1 delay: 0.5s - // try 2 delay: 1s - // ... - // try 5 delay: 16s - // ... - // try 11 delay: 512s - let maxBackoff: Double = 10 * 60 // 10 minutes - return 0.25 * min(maxBackoff, pow(2, Double(job.failureCount))) - } } diff --git a/SessionUtilitiesKit/JobRunner/JobRunnerError.swift b/SessionUtilitiesKit/JobRunner/JobRunnerError.swift index 8a88fa80e..15e2b23a2 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunnerError.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunnerError.swift @@ -10,4 +10,5 @@ public enum JobRunnerError: Error { case requiredInteractionIdMissing case missingRequiredDetails + case missingDependencies } diff --git a/SignalUtilitiesKit/Utilities/DisplayableText.swift b/SignalUtilitiesKit/Utilities/DisplayableText.swift deleted file mode 100644 index d63caad7a..000000000 --- a/SignalUtilitiesKit/Utilities/DisplayableText.swift +++ /dev/null @@ -1,298 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -import Foundation - -extension UnicodeScalar { - class EmojiRange { - // rangeStart and rangeEnd are inclusive. - let rangeStart: UInt32 - let rangeEnd: UInt32 - - // MARK: Initializers - - init(rangeStart: UInt32, rangeEnd: UInt32) { - self.rangeStart = rangeStart - self.rangeEnd = rangeEnd - } - } - - // From: - // https://www.unicode.org/Public/emoji/ - // Current Version: - // https://www.unicode.org/Public/emoji/6.0/emoji-data.txt - // - // These ranges can be code-generated using: - // - // * Scripts/emoji-data.txt - // * Scripts/emoji_ranges.py - static let kEmojiRanges = [ - // NOTE: Don't treat Pound Sign # as Jumbomoji. - // EmojiRange(rangeStart:0x23, rangeEnd:0x23), - // NOTE: Don't treat Asterisk * as Jumbomoji. - // EmojiRange(rangeStart:0x2A, rangeEnd:0x2A), - // NOTE: Don't treat Digits 0..9 as Jumbomoji. - // EmojiRange(rangeStart:0x30, rangeEnd:0x39), - // NOTE: Don't treat Copyright Symbol © as Jumbomoji. - // EmojiRange(rangeStart:0xA9, rangeEnd:0xA9), - // NOTE: Don't treat Trademark Sign ® as Jumbomoji. - // EmojiRange(rangeStart:0xAE, rangeEnd:0xAE), - EmojiRange(rangeStart: 0x200D, rangeEnd: 0x200D), - EmojiRange(rangeStart: 0x203C, rangeEnd: 0x203C), - EmojiRange(rangeStart: 0x2049, rangeEnd: 0x2049), - EmojiRange(rangeStart: 0x20D0, rangeEnd: 0x20FF), - EmojiRange(rangeStart: 0x2122, rangeEnd: 0x2122), - EmojiRange(rangeStart: 0x2139, rangeEnd: 0x2139), - EmojiRange(rangeStart: 0x2194, rangeEnd: 0x2199), - EmojiRange(rangeStart: 0x21A9, rangeEnd: 0x21AA), - EmojiRange(rangeStart: 0x231A, rangeEnd: 0x231B), - EmojiRange(rangeStart: 0x2328, rangeEnd: 0x2328), - EmojiRange(rangeStart: 0x2388, rangeEnd: 0x2388), - EmojiRange(rangeStart: 0x23CF, rangeEnd: 0x23CF), - EmojiRange(rangeStart: 0x23E9, rangeEnd: 0x23F3), - EmojiRange(rangeStart: 0x23F8, rangeEnd: 0x23FA), - EmojiRange(rangeStart: 0x24C2, rangeEnd: 0x24C2), - EmojiRange(rangeStart: 0x25AA, rangeEnd: 0x25AB), - EmojiRange(rangeStart: 0x25B6, rangeEnd: 0x25B6), - EmojiRange(rangeStart: 0x25C0, rangeEnd: 0x25C0), - EmojiRange(rangeStart: 0x25FB, rangeEnd: 0x25FE), - EmojiRange(rangeStart: 0x2600, rangeEnd: 0x27BF), - EmojiRange(rangeStart: 0x2934, rangeEnd: 0x2935), - EmojiRange(rangeStart: 0x2B05, rangeEnd: 0x2B07), - EmojiRange(rangeStart: 0x2B1B, rangeEnd: 0x2B1C), - EmojiRange(rangeStart: 0x2B50, rangeEnd: 0x2B50), - EmojiRange(rangeStart: 0x2B55, rangeEnd: 0x2B55), - EmojiRange(rangeStart: 0x3030, rangeEnd: 0x3030), - EmojiRange(rangeStart: 0x303D, rangeEnd: 0x303D), - EmojiRange(rangeStart: 0x3297, rangeEnd: 0x3297), - EmojiRange(rangeStart: 0x3299, rangeEnd: 0x3299), - EmojiRange(rangeStart: 0xFE00, rangeEnd: 0xFE0F), - EmojiRange(rangeStart: 0x1F000, rangeEnd: 0x1F0FF), - EmojiRange(rangeStart: 0x1F10D, rangeEnd: 0x1F10F), - EmojiRange(rangeStart: 0x1F12F, rangeEnd: 0x1F12F), - EmojiRange(rangeStart: 0x1F16C, rangeEnd: 0x1F171), - EmojiRange(rangeStart: 0x1F17E, rangeEnd: 0x1F17F), - EmojiRange(rangeStart: 0x1F18E, rangeEnd: 0x1F18E), - EmojiRange(rangeStart: 0x1F191, rangeEnd: 0x1F19A), - EmojiRange(rangeStart: 0x1F1AD, rangeEnd: 0x1F1FF), - EmojiRange(rangeStart: 0x1F201, rangeEnd: 0x1F20F), - EmojiRange(rangeStart: 0x1F21A, rangeEnd: 0x1F21A), - EmojiRange(rangeStart: 0x1F22F, rangeEnd: 0x1F22F), - EmojiRange(rangeStart: 0x1F232, rangeEnd: 0x1F23A), - EmojiRange(rangeStart: 0x1F23C, rangeEnd: 0x1F23F), - EmojiRange(rangeStart: 0x1F249, rangeEnd: 0x1F64F), - EmojiRange(rangeStart: 0x1F680, rangeEnd: 0x1F6FF), - EmojiRange(rangeStart: 0x1F774, rangeEnd: 0x1F77F), - EmojiRange(rangeStart: 0x1F7D5, rangeEnd: 0x1F7FF), - EmojiRange(rangeStart: 0x1F80C, rangeEnd: 0x1F80F), - EmojiRange(rangeStart: 0x1F848, rangeEnd: 0x1F84F), - EmojiRange(rangeStart: 0x1F85A, rangeEnd: 0x1F85F), - EmojiRange(rangeStart: 0x1F888, rangeEnd: 0x1F88F), - EmojiRange(rangeStart: 0x1F8AE, rangeEnd: 0x1FFFD), - EmojiRange(rangeStart: 0xE0020, rangeEnd: 0xE007F) - ] - - var isEmoji: Bool { - - // Binary search. - var left: Int = 0 - var right = Int(UnicodeScalar.kEmojiRanges.count - 1) - while true { - let mid = (left + right) / 2 - let midRange = UnicodeScalar.kEmojiRanges[mid] - if value < midRange.rangeStart { - if mid == left { - return false - } - right = mid - 1 - } else if value > midRange.rangeEnd { - if mid == right { - return false - } - left = mid + 1 - } else { - return true - } - } - } - - var isZeroWidthJoiner: Bool { - - return value == 8205 - } -} - -extension String { - - var glyphCount: Int { - let richText = NSAttributedString(string: self) - let line = CTLineCreateWithAttributedString(richText) - return CTLineGetGlyphCount(line) - } - - var isSingleEmoji: Bool { - return glyphCount == 1 && containsEmoji - } - - var containsEmoji: Bool { - return unicodeScalars.contains { $0.isEmoji } - } - - var containsOnlyEmoji: Bool { - return !isEmpty - && !unicodeScalars.contains(where: { - !$0.isEmoji - && !$0.isZeroWidthJoiner - }) - } -} - -@objc public class DisplayableText: NSObject { - - @objc public let fullText: String - @objc public let displayText: String - @objc public let isTextTruncated: Bool - @objc public let jumbomojiCount: UInt - - @objc - public static let kMaxJumbomojiCount: UInt = 5 - // This value is a bit arbitrary since we don't need to be 100% correct about - // rendering "Jumbomoji". It allows us to place an upper bound on worst-case - // performacne. - @objc - public static let kMaxCharactersPerEmojiCount: UInt = 10 - - // MARK: Initializers - - @objc - public init(fullText: String, displayText: String, isTextTruncated: Bool) { - self.fullText = fullText - self.displayText = displayText - self.isTextTruncated = isTextTruncated - self.jumbomojiCount = DisplayableText.jumbomojiCount(in: fullText) - } - - // MARK: Emoji - - // If the string is... - // - // * Non-empty - // * Only contains emoji - // * Contains <= kMaxJumbomojiCount emoji - // - // ...return the number of emoji (to be treated as "Jumbomoji") in the string. - private class func jumbomojiCount(in string: String) -> UInt { - if string == "" { - return 0 - } - if string.count > Int(kMaxJumbomojiCount * kMaxCharactersPerEmojiCount) { - return 0 - } - guard string.containsOnlyEmoji else { - return 0 - } - let emojiCount = string.glyphCount - if UInt(emojiCount) > kMaxJumbomojiCount { - return 0 - } - return UInt(emojiCount) - } - - // For perf we use a static linkDetector. It doesn't change and building DataDetectors is - // surprisingly expensive. This should be fine, since NSDataDetector is an NSRegularExpression - // and NSRegularExpressions are thread safe. - private static let linkDetector: NSDataDetector? = { - return try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) - }() - - private static let hostRegex: NSRegularExpression? = { - let pattern = "^(?:https?:\\/\\/)?([^:\\/\\s]+)(.*)?$" - return try? NSRegularExpression(pattern: pattern) - }() - - @objc - public lazy var shouldAllowLinkification: Bool = { - guard let linkDetector: NSDataDetector = DisplayableText.linkDetector else { - owsFailDebug("linkDetector was unexpectedly nil") - return false - } - - func isValidLink(linkText: String) -> Bool { - guard let hostRegex = DisplayableText.hostRegex else { - owsFailDebug("hostRegex was unexpectedly nil") - return false - } - - guard let hostText = hostRegex.parseFirstMatch(inText: linkText) else { - owsFailDebug("hostText was unexpectedly nil") - return false - } - - let strippedHost = hostText.replacingOccurrences(of: ".", with: "") as NSString - - if strippedHost.isOnlyASCII { - return true - } else if strippedHost.hasAnyASCII { - // mix of ascii and non-ascii is invalid - return false - } else { - // IDN - return true - } - } - - for match in linkDetector.matches(in: fullText, options: [], range: NSRange(location: 0, length: fullText.utf16.count)) { - guard let matchURL: URL = match.url else { - continue - } - - // We extract the exact text from the `fullText` rather than use match.url.host - // because match.url.host actually escapes non-ascii domains into puny-code. - // - // But what we really want is to check the text which will ultimately be presented to - // the user. - let rawTextOfMatch = (fullText as NSString).substring(with: match.range) - guard isValidLink(linkText: rawTextOfMatch) else { - return false - } - } - return true - }() - - // MARK: Filter Methods - - @objc - public class func filterNotificationText(_ text: String?) -> String? { - guard let text = text?.filterStringForDisplay() 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: "%%") - } - - @objc - public class func displayableText(_ rawText: String) -> DisplayableText { - // Only show up to N characters of text. - let kMaxTextDisplayLength = 512 - let fullText = rawText.filterStringForDisplay() - var isTextTruncated = false - var displayText = fullText - if displayText.count > kMaxTextDisplayLength { - // Trim whitespace before _AND_ after slicing the snipper from the string. - let snippet = String(displayText.prefix(kMaxTextDisplayLength)).ows_stripped() - displayText = String(format: NSLocalizedString("OVERSIZE_TEXT_DISPLAY_FORMAT", comment: - "A display format for oversize text messages."), - snippet) - isTextTruncated = true - } - - let displayableText = DisplayableText(fullText: fullText, displayText: displayText, isTextTruncated: isTextTruncated) - return displayableText - } -} From e2ee0e94eecb99bc1f120dc9a5455e3e4d9339bb Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Sun, 29 May 2022 19:26:06 +1000 Subject: [PATCH 091/157] Finished of the conversation screen and resolved a bug of bugs/TODOs Fixed a number of scrolling behaviours in the ConversationVC Fixed a bug with the PagedDataObserver when observing associated data (multiple associations with a single paged result were broken) Fixed a bug with the PagedDataObserver where it would trigger updates for new entries even if the user is offset from the latest data Fixed a bug where marking as read wasn't working properly Fixed a bug where outgoing messages were being considered unread Added an error state for a failed attachment send Renamed a few types for clarity Resolved a bunch of TODOs --- Configuration.swift | 2 +- Session.xcodeproj/project.pbxproj | 34 +- .../Context Menu/ContextMenuVC+Action.swift | 31 +- .../Context Menu/ContextMenuVC.swift | 7 +- .../Conversations/ConversationSearch.swift | 2 +- .../ConversationVC+Interaction.swift | 30 +- Session/Conversations/ConversationVC.swift | 236 +++- .../Conversations/ConversationViewModel.swift | 68 +- .../Content Views/LinkPreviewState.swift | 2 +- .../Content Views/LinkPreviewView.swift | 4 +- .../Content Views/MediaPlaceholderView.swift | 4 +- .../Content Views/MediaView.swift | 16 +- .../Message Cells/InfoMessageCell.swift | 4 +- .../Message Cells/MessageCell.swift | 18 +- .../Models/MessageCellViewModel.swift | 652 ---------- .../Message Cells/TypingIndicatorCell.swift | 4 +- .../Message Cells/VisibleMessageCell.swift | 34 +- .../InsetLockableTableView.swift | 8 +- .../GlobalSearchViewController.swift | 22 +- Session/Home/HomeVC.swift | 9 +- Session/Home/HomeViewModel.swift | 10 +- .../MessageRequestsViewController.swift | 6 +- .../MessageRequestsViewModel.swift | 8 +- Session/Home/Views/MessageRequestsCell.swift | 6 +- .../MediaPageViewController.swift | 3 +- Session/Meta/Signal-Bridging-Header.h | 1 - .../UserNotificationsAdaptee.swift | 48 +- Session/Shared/FullConversationCell.swift | 1058 ++++++++--------- Session/Utilities/Date+Utilities.swift | 89 ++ Session/Utilities/DateUtil.h | 49 - Session/Utilities/DateUtil.m | 526 -------- .../Migrations/_002_SetupStandardJobs.swift | 7 +- .../Database/Models/Attachment.swift | 8 +- .../Database/Models/Interaction.swift | 15 +- .../Database/Models/SessionThread.swift | 30 - ...sJob.swift => FailedMessageSendsJob.swift} | 9 +- .../Jobs/Types/GarbageCollectionJob.swift | 1 + .../Jobs/Types/MessageSendJob.swift | 14 +- .../MessageReceiver+Handling.swift | 5 +- .../Shared Models/MessageViewModel.swift | 699 +++++++++++ ...del.swift => SessionThreadViewModel.swift} | 495 ++++---- .../Utilities/OWSPreferences.h | 11 - .../SimplifiedConversationCell.swift | 2 +- SessionShareExtension/ThreadPickerVC.swift | 2 +- .../ThreadPickerViewModel.swift | 8 +- SessionUtilitiesKit/Database/Models/Job.swift | 44 +- .../Database/Models/JobDependencies.swift | 1 + .../Types/PagedDatabaseObserver.swift | 101 +- SessionUtilitiesKit/JobRunner/JobRunner.swift | 49 +- .../Profile Pictures/ProfilePictureView.swift | 77 +- 50 files changed, 2201 insertions(+), 2368 deletions(-) delete mode 100644 Session/Conversations/Message Cells/Models/MessageCellViewModel.swift create mode 100644 Session/Utilities/Date+Utilities.swift delete mode 100644 Session/Utilities/DateUtil.h delete mode 100644 Session/Utilities/DateUtil.m rename SessionMessagingKit/Jobs/Types/{FailedMessagesJob.swift => FailedMessageSendsJob.swift} (65%) create mode 100644 SessionMessagingKit/Shared Models/MessageViewModel.swift rename SessionMessagingKit/Shared Models/{ConversationCellViewModel.swift => SessionThreadViewModel.swift} (77%) diff --git a/Configuration.swift b/Configuration.swift index 791f38f7c..055bd318a 100644 --- a/Configuration.swift +++ b/Configuration.swift @@ -31,7 +31,7 @@ public enum SNMessagingKit { // Just to make the external API nice public static func configure(storage: SessionMessagingKitStorageProtocol) { // Configure the job executors JobRunner.add(executor: DisappearingMessagesJob.self, for: .disappearingMessages) - JobRunner.add(executor: FailedMessagesJob.self, for: .failedMessages) + JobRunner.add(executor: FailedMessageSendsJob.self, for: .failedMessageSends) JobRunner.add(executor: FailedAttachmentDownloadsJob.self, for: .failedAttachmentDownloads) JobRunner.add(executor: UpdateProfilePictureJob.self, for: .updateProfilePicture) JobRunner.add(executor: RetrieveDefaultOpenGroupRoomsJob.self, for: .retrieveDefaultOpenGroupRooms) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 0ab30227c..483ebef34 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -242,7 +242,6 @@ B8FF8E6225C10DA5004D1F22 /* GeoLite2-Country-Blocks-IPv4 in Resources */ = {isa = PBXBuildFile; fileRef = B8FF8E6125C10DA5004D1F22 /* GeoLite2-Country-Blocks-IPv4 */; }; B8FF8E7425C10FC3004D1F22 /* GeoLite2-Country-Locations-English in Resources */ = {isa = PBXBuildFile; fileRef = B8FF8E7325C10FC3004D1F22 /* GeoLite2-Country-Locations-English */; }; B8FF8EA625C11FEF004D1F22 /* IPv4.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8FF8EA525C11FEF004D1F22 /* IPv4.swift */; }; - B90418E6183E9DD40038554A /* DateUtil.m in Sources */ = {isa = PBXBuildFile; fileRef = B90418E5183E9DD40038554A /* DateUtil.m */; }; B9EB5ABD1884C002007CBB57 /* MessageUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B9EB5ABC1884C002007CBB57 /* MessageUI.framework */; }; C193959302ABEA1B4B1CDAFC /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionShareExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 038A3BABD5BA0CE41D8C17F5 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionShareExtension.framework */; }; C300A5B22554AF9800555489 /* VisibleMessage+Profile.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5B12554AF9800555489 /* VisibleMessage+Profile.swift */; }; @@ -660,7 +659,7 @@ FD3AABE928306BBD00E5099A /* ThreadPickerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */; }; FD3C907327E8387300CD579F /* OpenGroupServerIdLookupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907227E8387300CD579F /* OpenGroupServerIdLookupMigration.swift */; }; FD3C907527E83AC200CD579F /* OpenGroupServerIdLookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */; }; - FD3E0C84283B5835002A425C /* ConversationCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3E0C83283B5835002A425C /* ConversationCellViewModel.swift */; }; + FD3E0C84283B5835002A425C /* SessionThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */; }; FD4B200E283492210034334B /* InsetLockableTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4B200D283492210034334B /* InsetLockableTableView.swift */; }; FD5D200F27AA2B6000FEA984 /* MessageRequestResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */; }; FD5D201127AA331F00FEA984 /* ConfigurationMessage+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D201027AA331F00FEA984 /* ConfigurationMessage+Convenience.swift */; }; @@ -675,11 +674,12 @@ FD705A94278D052B00F16121 /* UITableView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A93278D052B00F16121 /* UITableView+ReusableView.swift */; }; FD705A98278E9F4D00F16121 /* UIColor+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A97278E9F4D00F16121 /* UIColor+Extensions.swift */; }; FD7162DB281B6C440060647B /* TypedTableAlias.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7162DA281B6C440060647B /* TypedTableAlias.swift */; }; - FD848B87283B844B000E298B /* MessageCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B86283B844B000E298B /* MessageCellViewModel.swift */; }; FD848B8B283DC509000E298B /* PagedDatabaseObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B8A283DC509000E298B /* PagedDatabaseObserver.swift */; }; FD848B8D283E0B26000E298B /* MessageInputTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B8C283E0B26000E298B /* MessageInputTypes.swift */; }; FD848B8F283EF2A8000E298B /* UIScrollView+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B8E283EF2A8000E298B /* UIScrollView+Utilities.swift */; }; FD848B9328420164000E298B /* UnicodeScalar+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B9228420164000E298B /* UnicodeScalar+Utilities.swift */; }; + FD848B9628422A2A000E298B /* MessageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B86283B844B000E298B /* MessageViewModel.swift */; }; + FD848B9828422F1A000E298B /* Date+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B9728422F1A000E298B /* Date+Utilities.swift */; }; FD859F0027C4691300510D0C /* MockDataGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EFF27C4691300510D0C /* MockDataGenerator.swift */; }; FD88BAD927A7439C00BBC442 /* MessageRequestsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */; }; FD88BADB27A750F200BBC442 /* MessageRequestsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */; }; @@ -688,7 +688,7 @@ FD9004142818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9004132818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift */; }; FD9004152818B46300ABAAF6 /* JobRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7432804EF1B004C14C5 /* JobRunner.swift */; }; FD9004162818B46700ABAAF6 /* JobRunnerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE77F68280F9EDA002CFC5D /* JobRunnerError.swift */; }; - FDA8EAFE280E8B78002B68E5 /* FailedMessagesJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA8EAFD280E8B78002B68E5 /* FailedMessagesJob.swift */; }; + FDA8EAFE280E8B78002B68E5 /* FailedMessageSendsJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA8EAFD280E8B78002B68E5 /* FailedMessageSendsJob.swift */; }; FDA8EB00280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA8EAFF280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift */; }; FDA8EB09280E90FB002B68E5 /* AppSetup.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF287255B6D85007E1867 /* AppSetup.m */; }; FDA8EB0A280E9103002B68E5 /* AppSetup.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF284255B6D84007E1867 /* AppSetup.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -1184,8 +1184,6 @@ B8FF8E6125C10DA5004D1F22 /* GeoLite2-Country-Blocks-IPv4 */ = {isa = PBXFileReference; lastKnownFileType = file.bplist; name = "GeoLite2-Country-Blocks-IPv4"; path = "Countries/GeoLite2-Country-Blocks-IPv4"; sourceTree = ""; }; B8FF8E7325C10FC3004D1F22 /* GeoLite2-Country-Locations-English */ = {isa = PBXFileReference; lastKnownFileType = file.bplist; name = "GeoLite2-Country-Locations-English"; path = "Countries/GeoLite2-Country-Locations-English"; sourceTree = ""; }; B8FF8EA525C11FEF004D1F22 /* IPv4.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPv4.swift; sourceTree = ""; }; - B90418E4183E9DD40038554A /* DateUtil.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DateUtil.h; sourceTree = ""; }; - B90418E5183E9DD40038554A /* DateUtil.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DateUtil.m; sourceTree = ""; }; B9EB5ABC1884C002007CBB57 /* MessageUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MessageUI.framework; path = System/Library/Frameworks/MessageUI.framework; sourceTree = SDKROOT; }; C022DD8E076866C6241610BF /* Pods-SessionSnodeKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionSnodeKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionSnodeKit/Pods-SessionSnodeKit.app store release.xcconfig"; sourceTree = ""; }; C1A746BC424B531D8ED478F6 /* Pods-SessionUIKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionUIKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionUIKit/Pods-SessionUIKit.app store release.xcconfig"; sourceTree = ""; }; @@ -1629,7 +1627,7 @@ FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadPickerViewModel.swift; sourceTree = ""; }; FD3C907227E8387300CD579F /* OpenGroupServerIdLookupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookupMigration.swift; sourceTree = ""; }; FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookup.swift; sourceTree = ""; }; - FD3E0C83283B5835002A425C /* ConversationCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationCellViewModel.swift; sourceTree = ""; }; + FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionThreadViewModel.swift; sourceTree = ""; }; FD4B200D283492210034334B /* InsetLockableTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsetLockableTableView.swift; sourceTree = ""; }; FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestResponse.swift; sourceTree = ""; }; FD5D201027AA331F00FEA984 /* ConfigurationMessage+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConfigurationMessage+Convenience.swift"; sourceTree = ""; }; @@ -1644,18 +1642,19 @@ FD705A93278D052B00F16121 /* UITableView+ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITableView+ReusableView.swift"; sourceTree = ""; }; FD705A97278E9F4D00F16121 /* UIColor+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Extensions.swift"; sourceTree = ""; }; FD7162DA281B6C440060647B /* TypedTableAlias.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypedTableAlias.swift; sourceTree = ""; }; - FD848B86283B844B000E298B /* MessageCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCellViewModel.swift; sourceTree = ""; }; + FD848B86283B844B000E298B /* MessageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageViewModel.swift; sourceTree = ""; }; FD848B8A283DC509000E298B /* PagedDatabaseObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagedDatabaseObserver.swift; sourceTree = ""; }; FD848B8C283E0B26000E298B /* MessageInputTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageInputTypes.swift; sourceTree = ""; }; FD848B8E283EF2A8000E298B /* UIScrollView+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIScrollView+Utilities.swift"; sourceTree = ""; }; FD848B9228420164000E298B /* UnicodeScalar+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UnicodeScalar+Utilities.swift"; sourceTree = ""; }; + FD848B9728422F1A000E298B /* Date+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Utilities.swift"; sourceTree = ""; }; FD859EFF27C4691300510D0C /* MockDataGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDataGenerator.swift; sourceTree = ""; }; FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsCell.swift; sourceTree = ""; }; FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsMigration.swift; sourceTree = ""; }; FD90040E2818AB6D00ABAAF6 /* GetSnodePoolJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetSnodePoolJob.swift; sourceTree = ""; }; FD9004132818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_SetupStandardJobs.swift; sourceTree = ""; }; FD9039443F7CB729CF71350E /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig"; sourceTree = ""; }; - FDA8EAFD280E8B78002B68E5 /* FailedMessagesJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedMessagesJob.swift; sourceTree = ""; }; + FDA8EAFD280E8B78002B68E5 /* FailedMessageSendsJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedMessageSendsJob.swift; sourceTree = ""; }; FDA8EAFF280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedAttachmentDownloadsJob.swift; sourceTree = ""; }; FDA8EB0F280F8238002B68E5 /* Codable+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Codable+Utilities.swift"; sourceTree = ""; }; FDB4BBC62838B91E00B7C95D /* LinkPreviewError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewError.swift; sourceTree = ""; }; @@ -1902,8 +1901,7 @@ 34D5CCA71EAE3D30005515DB /* AvatarViewHelper.h */, 34D5CCA81EAE3D30005515DB /* AvatarViewHelper.m */, 4CC1ECFA211A553000CC13BE /* AppUpdateNag.swift */, - B90418E4183E9DD40038554A /* DateUtil.h */, - B90418E5183E9DD40038554A /* DateUtil.m */, + FD848B9728422F1A000E298B /* Date+Utilities.swift */, FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */, 4C21D5D5223A9DC500EF8A77 /* UIAlerts+iOS9.m */, 45C0DC1A1E68FE9000E04C47 /* UIApplication+OWS.swift */, @@ -3431,8 +3429,9 @@ FD3E0C82283B581F002A425C /* Shared Models */ = { isa = PBXGroup; children = ( - FD3E0C83283B5835002A425C /* ConversationCellViewModel.swift */, FD848B8C283E0B26000E298B /* MessageInputTypes.swift */, + FD848B86283B844B000E298B /* MessageViewModel.swift */, + FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */, ); path = "Shared Models"; sourceTree = ""; @@ -3456,7 +3455,6 @@ FD848B85283B8438000E298B /* Models */ = { isa = PBXGroup; children = ( - FD848B86283B844B000E298B /* MessageCellViewModel.swift */, ); path = Models; sourceTree = ""; @@ -3482,7 +3480,7 @@ isa = PBXGroup; children = ( FDF0B7462804F0CE004C14C5 /* DisappearingMessagesJob.swift */, - FDA8EAFD280E8B78002B68E5 /* FailedMessagesJob.swift */, + FDA8EAFD280E8B78002B68E5 /* FailedMessageSendsJob.swift */, FDA8EAFF280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift */, FD6A7A6A2818C17C00035AC1 /* UpdateProfilePictureJob.swift */, FD6A7A682818BE7300035AC1 /* RetrieveDefaultOpenGroupRoomsJob.swift */, @@ -4571,13 +4569,14 @@ C32C5A76256DBBCF003C73A2 /* SignalAttachment.swift in Sources */, FDA8EB00280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift in Sources */, FD09798927FD1C5A00936362 /* OpenGroup.swift in Sources */, + FD848B9628422A2A000E298B /* MessageViewModel.swift in Sources */, FDF0B7472804F0CE004C14C5 /* DisappearingMessagesJob.swift in Sources */, B8856D1A256F114D001CE70E /* ProximityMonitoringManager.swift in Sources */, C3D9E52725677DF20040E4F3 /* ThumbnailService.swift in Sources */, C32C5E75256DE020003C73A2 /* YapDatabaseTransaction+OWS.m in Sources */, FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */, FD09797527FAB64300936362 /* ProfileManager.swift in Sources */, - FDA8EAFE280E8B78002B68E5 /* FailedMessagesJob.swift in Sources */, + FDA8EAFE280E8B78002B68E5 /* FailedMessageSendsJob.swift in Sources */, C3BBE0802554CDD70050F1E3 /* Storage.swift in Sources */, C3DB66AC260ACA42001EFC55 /* OpenGroupManagerV2.swift in Sources */, C379DCF4256735770002D4EB /* VisibleMessage+Attachment.swift in Sources */, @@ -4591,7 +4590,7 @@ FDB4BBC92839BEF000B7C95D /* ProfileManagerError.swift in Sources */, FDF0B7552807C4BB004C14C5 /* SSKEnvironment.swift in Sources */, B8856ECE256F1E58001CE70E /* OWSPreferences.m in Sources */, - FD3E0C84283B5835002A425C /* ConversationCellViewModel.swift in Sources */, + FD3E0C84283B5835002A425C /* SessionThreadViewModel.swift in Sources */, FD09C5EC282B8F18000CE219 /* AttachmentError.swift in Sources */, FD17D79927F40AB800122BE0 /* _003_YDBToGRDBMigration.swift in Sources */, C3C2A7842553AAF300C340D1 /* SNProto.swift in Sources */, @@ -4680,7 +4679,6 @@ FDFDE12A282D056B0098B17F /* MediaZoomAnimationController.swift in Sources */, 4C1885D2218F8E1C00B67051 /* PhotoGridViewCell.swift in Sources */, 34D1F0501F7D45A60066283D /* GifPickerCell.swift in Sources */, - FD848B87283B844B000E298B /* MessageCellViewModel.swift in Sources */, C3E5C2FA251DBABB0040DFFC /* EditClosedGroupVC.swift in Sources */, B8783E9E23EB948D00404FB8 /* UILabel+Interaction.swift in Sources */, B893063F2383961A005EAA8E /* ScanQRCodeWrapperVC.swift in Sources */, @@ -4766,6 +4764,7 @@ B82149C125D605C6009C0F2A /* InfoBanner.swift in Sources */, C3DAB3242480CB2B00725F25 /* SRCopyableLabel.swift in Sources */, B8CCF63F23975CFB0091D419 /* JoinOpenGroupVC.swift in Sources */, + FD848B9828422F1A000E298B /* Date+Utilities.swift in Sources */, B85357C323A1BD1200AAF6CD /* SeedVC.swift in Sources */, 45B5360E206DD8BB00D61655 /* UIResponder+OWS.swift in Sources */, B8D84ECF25E3108A005A043E /* ExpandingAttachmentsButton.swift in Sources */, @@ -4827,7 +4826,6 @@ B86BD08423399ACF000F5AE3 /* Modal.swift in Sources */, C328254025CA55880062D0A7 /* ContextMenuVC.swift in Sources */, 3427C64320F500E000EEC730 /* OWSMessageTimerView.m in Sources */, - B90418E6183E9DD40038554A /* DateUtil.m in Sources */, C33100092558FF6D00070591 /* UserCell.swift in Sources */, B8269D2925C7A4B400488AB4 /* InputView.swift in Sources */, C374EEE225DA26740073A857 /* LinkPreviewModal.swift in Sources */, diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index 63ba37af9..cf70f104d 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import SessionMessagingKit extension ContextMenuVC { struct Action { @@ -8,49 +9,49 @@ extension ContextMenuVC { let title: String let work: () -> Void - static func reply(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + static func reply(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_reply"), title: "context_menu_reply".localized() ) { delegate?.reply(cellViewModel) } } - static func copy(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + static func copy(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_copy"), title: "copy".localized() ) { delegate?.copy(cellViewModel) } } - static func copySessionID(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + static func copySessionID(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_copy"), title: "vc_conversation_settings_copy_session_id_button_title".localized() ) { delegate?.copySessionID(cellViewModel) } } - static func delete(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + static func delete(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_trash"), title: "TXT_DELETE_TITLE".localized() ) { delegate?.delete(cellViewModel) } } - static func save(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + static func save(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_download"), title: "context_menu_save".localized() ) { delegate?.save(cellViewModel) } } - static func ban(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + static func ban(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_block"), title: "context_menu_ban_user".localized() ) { delegate?.ban(cellViewModel) } } - static func banAndDeleteAllMessages(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + static func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_block"), title: "context_menu_ban_and_delete_all".localized() @@ -58,7 +59,7 @@ extension ContextMenuVC { } } - static func actions(for cellViewModel: MessageCell.ViewModel, currentUserIsOpenGroupModerator: Bool, delegate: ContextMenuActionDelegate?) -> [Action]? { + static func actions(for cellViewModel: MessageViewModel, currentUserIsOpenGroupModerator: Bool, delegate: ContextMenuActionDelegate?) -> [Action]? { // No context items for info messages guard cellViewModel.variant == .standardOutgoing || cellViewModel.variant == .standardIncoming else { return nil @@ -124,11 +125,11 @@ extension ContextMenuVC { // MARK: - Delegate protocol ContextMenuActionDelegate { - func reply(_ cellViewModel: MessageCell.ViewModel) - func copy(_ cellViewModel: MessageCell.ViewModel) - func copySessionID(_ cellViewModel: MessageCell.ViewModel) - func delete(_ cellViewModel: MessageCell.ViewModel) - func save(_ cellViewModel: MessageCell.ViewModel) - func ban(_ cellViewModel: MessageCell.ViewModel) - func banAndDeleteAllMessages(_ cellViewModel: MessageCell.ViewModel) + func reply(_ cellViewModel: MessageViewModel) + func copy(_ cellViewModel: MessageViewModel) + func copySessionID(_ cellViewModel: MessageViewModel) + func delete(_ cellViewModel: MessageViewModel) + func save(_ cellViewModel: MessageViewModel) + func ban(_ cellViewModel: MessageViewModel) + func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel) } diff --git a/Session/Conversations/Context Menu/ContextMenuVC.swift b/Session/Conversations/Context Menu/ContextMenuVC.swift index 6cf1b0bc5..8d5340e3b 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC.swift @@ -2,6 +2,7 @@ import UIKit import SessionUIKit +import SessionMessagingKit final class ContextMenuVC: UIViewController { private static let actionViewHeight: CGFloat = 40 @@ -9,7 +10,7 @@ final class ContextMenuVC: UIViewController { private let snapshot: UIView private let frame: CGRect - private let cellViewModel: MessageCell.ViewModel + private let cellViewModel: MessageViewModel private let actions: [Action] private let dismiss: () -> Void @@ -33,7 +34,7 @@ final class ContextMenuVC: UIViewController { result.textColor = (isLightMode ? .black : .white) if let dateForUI: Date = cellViewModel.dateForUI { - result.text = DateUtil.formatDate(forDisplay: dateForUI) + result.text = dateForUI.formattedForDisplay } return result @@ -44,7 +45,7 @@ final class ContextMenuVC: UIViewController { init( snapshot: UIView, frame: CGRect, - cellViewModel: MessageCell.ViewModel, + cellViewModel: MessageViewModel, actions: [Action], dismiss: @escaping () -> Void ) { diff --git a/Session/Conversations/ConversationSearch.swift b/Session/Conversations/ConversationSearch.swift index 53ec8eb35..4b8f20602 100644 --- a/Session/Conversations/ConversationSearch.swift +++ b/Session/Conversations/ConversationSearch.swift @@ -58,7 +58,7 @@ extension ConversationSearchController: UISearchResultsUpdating { let results: [Int64] = GRDBStorage.shared.read { db -> [Int64] in try Interaction.idsForTermWithin( threadId: threadId, - pattern: try ConversationCell.ViewModel.pattern(db, searchTerm: searchText) + pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText) ) .fetchAll(db) } diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 8a635d2f0..d29acf2f6 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -398,7 +398,7 @@ extension ConversationVC: func sendAttachments(_ attachments: [SignalAttachment], with text: String, onComplete: (() -> ())? = nil) { guard !showBlockedModalIfNeeded() else { return } - + for attachment in attachments { if attachment.hasError { return showErrorAlert(for: attachment, onDismiss: onComplete) @@ -628,7 +628,7 @@ extension ConversationVC: // MARK: MessageCellDelegate - func handleItemLongPressed(_ cellViewModel: MessageCell.ViewModel) { + func handleItemLongPressed(_ cellViewModel: MessageViewModel) { // Show the context menu if applicable guard let keyWindow: UIWindow = UIApplication.shared.keyWindow, @@ -675,7 +675,7 @@ extension ConversationVC: self.contextMenuWindow?.makeKeyAndVisible() } - func handleItemTapped(_ cellViewModel: MessageCell.ViewModel, gestureRecognizer: UITapGestureRecognizer) { + func handleItemTapped(_ cellViewModel: MessageViewModel, gestureRecognizer: UITapGestureRecognizer) { guard cellViewModel.variant != .standardOutgoing || cellViewModel.state != .failed else { // Show the failed message sheet showFailedMessageSheet(for: cellViewModel) @@ -717,7 +717,7 @@ extension ConversationVC: // TODO: Tapped a failed incoming attachment break - case .failedDownload: + case .failedDownload, .failedUpload: // TODO: Tapped a failed incoming attachment break @@ -802,7 +802,7 @@ extension ConversationVC: } } - func handleItemDoubleTapped(_ cellViewModel: MessageCell.ViewModel) { + func handleItemDoubleTapped(_ cellViewModel: MessageViewModel) { switch cellViewModel.cellType { // The user can double tap a voice message when it's playing to speed it up case .audio: self.viewModel.speedUpAudio(for: cellViewModel) @@ -810,7 +810,7 @@ extension ConversationVC: } } - func handleItemSwiped(_ cellViewModel: MessageCell.ViewModel, state: SwipeState) { + func handleItemSwiped(_ cellViewModel: MessageViewModel, state: SwipeState) { switch state { case .began: tableView.isScrollEnabled = false case .ended, .cancelled: tableView.isScrollEnabled = true @@ -841,7 +841,7 @@ extension ConversationVC: self.presentAlert(alertVC) } - func handleReplyButtonTapped(for cellViewModel: MessageCell.ViewModel) { + func handleReplyButtonTapped(for cellViewModel: MessageViewModel) { reply(cellViewModel) } @@ -856,7 +856,7 @@ extension ConversationVC: // MARK: --action handling - func showFailedMessageSheet(for cellViewModel: MessageCell.ViewModel) { + func showFailedMessageSheet(for cellViewModel: MessageViewModel) { let sheet = UIAlertController(title: cellViewModel.mostRecentFailureText, message: nil, preferredStyle: .actionSheet) sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) sheet.addAction(UIAlertAction(title: "Delete", style: .destructive, handler: { _ in @@ -909,7 +909,7 @@ extension ConversationVC: // MARK: - ContextMenuActionDelegate - func reply(_ cellViewModel: MessageCell.ViewModel) { + func reply(_ cellViewModel: MessageViewModel) { let maybeQuoteDraft: QuotedReplyModel? = QuotedReplyModel.quotedReplyForSending( threadId: self.viewModel.threadData.threadId, authorId: cellViewModel.authorId, @@ -929,7 +929,7 @@ extension ConversationVC: snInputView.becomeFirstResponder() } - func copy(_ cellViewModel: MessageCell.ViewModel) { + func copy(_ cellViewModel: MessageViewModel) { switch cellViewModel.cellType { case .typingIndicator: break @@ -954,7 +954,7 @@ extension ConversationVC: } } - func copySessionID(_ cellViewModel: MessageCell.ViewModel) { + func copySessionID(_ cellViewModel: MessageViewModel) { guard cellViewModel.variant == .standardIncoming || cellViewModel.variant == .standardIncomingDeleted else { return } @@ -962,7 +962,7 @@ extension ConversationVC: UIPasteboard.general.string = cellViewModel.authorId } - func delete(_ cellViewModel: MessageCell.ViewModel) { + func delete(_ cellViewModel: MessageViewModel) { // Only allow deletion on incoming and outgoing messages guard cellViewModel.variant == .standardIncoming || cellViewModel.variant == .standardOutgoing else { return @@ -1141,7 +1141,7 @@ extension ConversationVC: } } - func save(_ cellViewModel: MessageCell.ViewModel) { + func save(_ cellViewModel: MessageViewModel) { guard cellViewModel.cellType == .mediaMessage else { return } let mediaAttachments: [(Attachment, String)] = (cellViewModel.attachments ?? []) @@ -1199,7 +1199,7 @@ extension ConversationVC: } } - func ban(_ cellViewModel: MessageCell.ViewModel) { + func ban(_ cellViewModel: MessageViewModel) { guard cellViewModel.threadVariant == .openGroup else { return } let threadId: String = self.viewModel.threadData.threadId @@ -1222,7 +1222,7 @@ extension ConversationVC: present(alert, animated: true, completion: nil) } - func banAndDeleteAllMessages(_ cellViewModel: MessageCell.ViewModel) { + func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel) { guard cellViewModel.threadVariant == .openGroup else { return } let threadId: String = self.viewModel.threadData.threadId diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index ebe03829f..c261436f7 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -501,7 +501,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers viewModel.observableThreadData, onError: { _ in }, onChange: { [weak self] maybeThreadData in - guard let threadData: ConversationCell.ViewModel = maybeThreadData else { return } + guard let threadData: SessionThreadViewModel = maybeThreadData else { return } // The default scheduler emits changes on the main thread self?.handleThreadUpdates(threadData) @@ -520,7 +520,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers self.viewModel.onInteractionChange = nil } - private func handleThreadUpdates(_ updatedThreadData: ConversationCell.ViewModel, initialLoad: Bool = false) { + private func handleThreadUpdates(_ updatedThreadData: SessionThreadViewModel, initialLoad: Bool = false) { // Ensure the first load or a load when returning from a child screen runs without animations (if // we don't do this the cells will animate in from a frame of CGRect.zero or have a buggy transition) guard hasLoadedInitialThreadData && hasReloadedThreadDataAfterDisappearance else { @@ -529,6 +529,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers UIView.performWithoutAnimation { handleThreadUpdates(updatedThreadData, initialLoad: true) } return } + // Update general conversation UI if @@ -572,6 +573,9 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers if initialLoad || viewModel.threadData.threadUnreadCount != updatedThreadData.threadUnreadCount { updateUnreadCountView(unreadCount: updatedThreadData.threadUnreadCount) } + + // Now we have done all the needed diffs, update the viewModel with the latest data + self.viewModel.updateThreadData(updatedThreadData) } private func handleInteractionUpdates(_ updatedData: [ConversationViewModel.SectionModel], initialLoad: Bool = false) { @@ -590,68 +594,125 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers // Determine if we are inserting content at the top of the collectionView struct ItemChangeInfo { - let insertedAtTop: Bool + enum InsertLocation { + case top + case bottom + case other + case none + } + + let insertLocation: InsertLocation + let wasCloseToBottom: Bool + let sentMessageBeforeUpdate: Bool let firstIndexIsVisible: Bool let visibleInteractionId: Int64 let visibleIndexPath: IndexPath let oldVisibleIndexPath: IndexPath + let lastVisibleIndexPath: IndexPath init( - insertedAtTop: Bool, + insertLocation: InsertLocation, + wasCloseToBottom: Bool, + sentMessageBeforeUpdate: Bool, firstIndexIsVisible: Bool = false, visibleInteractionId: Int64 = -1, visibleIndexPath: IndexPath = IndexPath(row: 0, section: 0), - oldVisibleIndexPath: IndexPath = IndexPath(row: 0, section: 0) + oldVisibleIndexPath: IndexPath = IndexPath(row: 0, section: 0), + lastVisibleIndexPath: IndexPath = IndexPath(row: 0, section: 0) ) { - self.insertedAtTop = insertedAtTop + self.insertLocation = insertLocation + self.wasCloseToBottom = wasCloseToBottom + self.sentMessageBeforeUpdate = sentMessageBeforeUpdate self.firstIndexIsVisible = firstIndexIsVisible self.visibleInteractionId = visibleInteractionId self.visibleIndexPath = visibleIndexPath self.oldVisibleIndexPath = oldVisibleIndexPath + self.lastVisibleIndexPath = lastVisibleIndexPath } } + let changeset: StagedChangeset<[ConversationViewModel.SectionModel]> = StagedChangeset( + source: viewModel.interactionData, + target: updatedData + ) + let numItemsInUpdatedData: [Int] = updatedData.map { $0.elements.count } let itemChangeInfo: ItemChangeInfo = { guard + changeset.map { $0.elementInserted.count }.reduce(0, +) > 0, let oldSectionIndex: Int = self.viewModel.interactionData.firstIndex(where: { $0.model == .messages }), let newSectionIndex: Int = updatedData.firstIndex(where: { $0.model == .messages }), let newFirstItemIndex: Int = updatedData[newSectionIndex].elements .firstIndex(where: { item -> Bool in item.id == self.viewModel.interactionData[oldSectionIndex].elements.first?.id }), + let newLastItemIndex: Int = updatedData[newSectionIndex].elements + .lastIndex(where: { item -> Bool in + item.id == self.viewModel.interactionData[oldSectionIndex].elements.last?.id + }), let firstVisibleIndexPath: IndexPath = self.tableView.indexPathsForVisibleRows? .filter({ $0.section == oldSectionIndex }) .sorted() .first, + let lastVisibleIndexPath: IndexPath = self.tableView.indexPathsForVisibleRows? + .filter({ $0.section == oldSectionIndex }) + .sorted() + .last, let newVisibleIndex: Int = updatedData[newSectionIndex].elements .firstIndex(where: { item in item.id == self.viewModel.interactionData[oldSectionIndex] .elements[firstVisibleIndexPath.row] .id }), - ( - newSectionIndex > oldSectionIndex || - newFirstItemIndex > 0 + let newLastVisibleIndex: Int = updatedData[newSectionIndex].elements + .firstIndex(where: { item in + item.id == self.viewModel.interactionData[oldSectionIndex] + .elements[lastVisibleIndexPath.row] + .id + }) + else { + return ItemChangeInfo( + insertLocation: .none, + wasCloseToBottom: isCloseToBottom, + sentMessageBeforeUpdate: self.viewModel.sentMessageBeforeUpdate ) - else { return ItemChangeInfo(insertedAtTop: false) } + } return ItemChangeInfo( - insertedAtTop: true, + insertLocation: { + let insertedAtTop: Bool = ( + newSectionIndex > oldSectionIndex || + newFirstItemIndex > 0 + ) + let insertedAtBot: Bool = ( + newSectionIndex < oldSectionIndex || + newLastItemIndex < (updatedData[newSectionIndex].elements.count - 1) + ) + + // If anything was inserted at the top then we need to maintain the current + // offset so always return a 'top' insert location + switch (insertedAtTop, insertedAtBot) { + case (true, _): return .top + case (false, true): return .bottom + case (false, false): return .other + } + }(), + wasCloseToBottom: isCloseToBottom, + sentMessageBeforeUpdate: self.viewModel.sentMessageBeforeUpdate, firstIndexIsVisible: (firstVisibleIndexPath.row == 0), visibleInteractionId: updatedData[newSectionIndex].elements[newVisibleIndex].id, visibleIndexPath: IndexPath(row: newVisibleIndex, section: newSectionIndex), - oldVisibleIndexPath: firstVisibleIndexPath + oldVisibleIndexPath: firstVisibleIndexPath, + lastVisibleIndexPath: IndexPath(row: newLastVisibleIndex, section: newSectionIndex) ) }() - /// If we are inserting at the top then we want to maintain the same visual position from before the table view was updated, - /// unfortunately the UITableView does some weird things when updating (where it won't have updated data until after it - /// performs the next layout); the below code checks a condition on layout and if it passes it calls a closure + /// UITableView doesn't really support bottom-aligned content very well and as such jumps around a lot when inserting content but + /// we want to maintain the current offset from before the data was inserted (except when adding at the bottom while the user is at + /// the bottom, in which case we want to scroll down) /// - /// In the below case we set the tableView offset of the first row to the same offset it had before the UI loaded with new - /// data (including the difference in height in case the date header was removed when loading the new cell) - if itemChangeInfo.insertedAtTop { - let numItemsInUpdatedData: [Int] = updatedData.map { $0.elements.count } + /// Unfortunately the UITableView also does some weird things when updating (where it won't have updated it's internal data until + /// after it performs the next layout); the below code checks a condition on layout and if it passes it calls a closure + if itemChangeInfo.insertLocation != .none { let cellSorting: (MessageCell, MessageCell) -> Bool = { lhs, rhs -> Bool in if !lhs.isHidden && rhs.isHidden { return true } if lhs.isHidden && !rhs.isHidden { return false } @@ -665,42 +726,77 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers .frame) .defaulting(to: self.tableView.rectForRow(at: itemChangeInfo.oldVisibleIndexPath)) let oldContentSize: CGSize = self.tableView.contentSize - let oldContentOffset: CGPoint = self.tableView.contentOffset + let oldOffsetFromTop: CGFloat = (self.tableView.contentOffset.y - oldRect.minY) + let oldOffsetFromBottom: CGFloat = (oldContentSize.height - self.tableView.contentOffset.y) - // Distance of 64 when paging works properly + // Wait until the tableView has completed a layout and reported the correct number of + // sections/rows and then update the contentOffset self.tableView.afterNextLayoutSubviews( - when: { numSections, numRowsInSections -> Bool in + when: { numSections, numRowsInSections, _ -> Bool in numSections == updatedData.count && numRowsInSections == numItemsInUpdatedData }, then: { [weak self] in - self?.tableView.scrollToRow(at: itemChangeInfo.visibleIndexPath, at: .top, animated: false) - self?.tableView.layoutIfNeeded() - - /// **Note:** I wasn't able to get a prober equation to handle both "insert above first item" and "insert - /// at top off screen", it seems that the 'contentOffset' value won't expose negative values (eg. when you - /// over-scroll and trigger the bounce effect) and this results in requiring the conditional logic below - if itemChangeInfo.firstIndexIsVisible { - let newRect: CGRect = (self?.tableView.subviews - .compactMap { $0 as? MessageCell } - .sorted(by: cellSorting) - .first(where: { $0.viewModel?.id == itemChangeInfo.visibleInteractionId })? - .frame) - .defaulting(to: oldRect) - let heightDiff: CGFloat = (oldRect.height - newRect.height) + UIView.performWithoutAnimation { + self?.tableView.scrollToRow( + at: (itemChangeInfo.insertLocation == .top ? + itemChangeInfo.visibleIndexPath : + itemChangeInfo.lastVisibleIndexPath + ), + at: (itemChangeInfo.insertLocation == .top ? + .top : + .bottom + ), + animated: false + ) + self?.tableView.layoutIfNeeded() - self?.tableView.contentOffset.y = (newRect.minY - (oldRect.minY + heightDiff)) - } - else { let newContentSize: CGSize = (self?.tableView.contentSize) .defaulting(to: oldContentSize) - let contentSizeDiff: CGFloat = (newContentSize.height - oldContentSize.height) - self?.tableView.contentOffset.y = (contentSizeDiff + oldContentOffset.y) + /// **Note:** I wasn't able to get a prober equation to handle both "insert" and "insert at top off screen", it + /// seems that the 'contentOffset' value won't expose negative values (eg. when you over-scroll and trigger + /// the bounce effect) and this results in requiring the conditional logic below + if itemChangeInfo.insertLocation == .top { + let newRect: CGRect = (self?.tableView.subviews + .compactMap { $0 as? MessageCell } + .sorted(by: cellSorting) + .first(where: { $0.viewModel?.id == itemChangeInfo.visibleInteractionId })? + .frame) + .defaulting(to: oldRect) + let heightDiff: CGFloat = (oldRect.height - newRect.height) + + if itemChangeInfo.firstIndexIsVisible { + self?.tableView.contentOffset.y = (newRect.minY - (oldRect.minY + heightDiff)) + } + else { + self?.tableView.contentOffset.y = ((newRect.minY + heightDiff) + oldOffsetFromTop) + } + } + else { + self?.tableView.contentOffset.y = (newContentSize.height - oldOffsetFromBottom) + } + + /// **Note:** There is yet another weird issue where the tableView will layout again shortly after the initial + /// layout with a slightly different contentSize (usually about 8pt off), this catches that case and prevents it + /// from affecting the UI + if !itemChangeInfo.firstIndexIsVisible { + self?.tableView.afterNextLayoutSubviews( + when: { _, _, contentSize in (contentSize.height != newContentSize.height) }, + then: { [weak self] in + let finalContentSize: CGSize = (self?.tableView.contentSize) + .defaulting(to: newContentSize) + + self?.tableView.contentOffset.y += (finalContentSize.height - newContentSize.height) + } + ) + } } - if let focusedInteractionId: Int64 = self?.focusedInteractionId { - DispatchQueue.main.async { + DispatchQueue.main.async { [weak self] in + if let focusedInteractionId: Int64 = self?.focusedInteractionId { + // If we had a focusedInteractionId then scroll to it (and hide the search + // result bar loading indicator) self?.searchController.resultsBar.stopLoading() self?.scrollToInteractionIfNeeded( with: focusedInteractionId, @@ -708,8 +804,13 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers highlight: (self?.shouldHighlightNextScrollToInteraction == true) ) } + else if itemChangeInfo.sentMessageBeforeUpdate || itemChangeInfo.wasCloseToBottom { + // Scroll to the bottom if an interaction was just inserted and we either + // just sent a message or are close enough to the bottom + self?.scrollToBottom(isAnimated: true) + } } - + // Complete page loading self?.isLoadingMore = false self?.autoLoadNextPageIfNeeded() @@ -719,29 +820,18 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers // Reload the table content (animate changes if we aren't inserting at the top) self.tableView.reload( - using: StagedChangeset(source: viewModel.interactionData, target: updatedData), + using: changeset, deleteSectionsAnimation: .none, insertSectionsAnimation: .none, reloadSectionsAnimation: .none, deleteRowsAnimation: .bottom, insertRowsAnimation: .bottom, reloadRowsAnimation: .none, - interrupt: { itemChangeInfo.insertedAtTop || $0.changeCount > ConversationViewModel.pageSize } + interrupt: { itemChangeInfo.insertLocation == .top || $0.changeCount > ConversationViewModel.pageSize } ) { [weak self] updatedData in self?.viewModel.updateInteractionData(updatedData) } - // Scroll to the bottom if we just inserted a message and are close enough - // to the bottom - if - changeset.contains(where: { !$0.elementInserted.isEmpty }) && ( - updatedViewData.items.last?.interactionVariant == .standardOutgoing || - isCloseToBottom - ) - { - scrollToBottom(isAnimated: true) - } - // Mark received messages as read viewModel.markAllAsRead() viewModel.sentMessageBeforeUpdate = false @@ -817,7 +907,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers } } - func updateNavBarButtons(threadData: ConversationCell.ViewModel) { + func updateNavBarButtons(threadData: SessionThreadViewModel) { navigationItem.hidesBackButton = isShowingSearchUI if isShowingSearchUI { @@ -997,7 +1087,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers switch section.model { case .messages: - let cellViewModel: MessageCell.ViewModel = section.elements[indexPath.row] + let cellViewModel: MessageViewModel = section.elements[indexPath.row] let cell: MessageCell = tableView.dequeue(type: MessageCell.cellType(for: cellViewModel), for: indexPath) cell.update( with: cellViewModel, @@ -1085,7 +1175,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers func scrollToBottom(isAnimated: Bool) { guard - !isUserScrolling, + !self.isUserScrolling, let messagesSectionIndex: Int = self.viewModel.interactionData .firstIndex(where: { $0.model == .messages }), !self.viewModel.interactionData[messagesSectionIndex] @@ -1093,9 +1183,26 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers .isEmpty else { return } - tableView.scrollToRow( + // If the last interaction isn't loaded then scroll to the final interactionId on + // the thread data + let hasNewerItems: Bool = self.viewModel.interactionData.contains(where: { $0.model == .loadNewer }) + + guard !self.didFinishInitialLayout || !hasNewerItems else { + let messages: [MessageViewModel] = self.viewModel.interactionData[messagesSectionIndex].elements + let lastInteractionId: Int64 = self.viewModel.threadData.interactionId + .defaulting(to: messages[messages.count - 1].id) + + self.scrollToInteractionIfNeeded( + with: lastInteractionId, + position: .bottom, + isAnimated: true + ) + return + } + + self.tableView.scrollToRow( at: IndexPath( - row: viewModel.interactionData[messagesSectionIndex].elements.count - 1, + row: (self.viewModel.interactionData[messagesSectionIndex].elements.count - 1), section: messagesSectionIndex ), at: .bottom, @@ -1125,7 +1232,9 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers return } - self.highlightCellIfNeeded(interactionId: focusedInteractionId) + DispatchQueue.main.async { [weak self] in + self?.highlightCellIfNeeded(interactionId: focusedInteractionId) + } } func updateUnreadCountView(unreadCount: UInt?) { @@ -1245,6 +1354,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers // load the up until the specified interaction guard self.didFinishInitialLayout else { return } + self.isLoadingMore = true self.searchController.resultsBar.startLoading() DispatchQueue.global(qos: .default).async { [weak self] in diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 949f18c56..d25a8f966 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -7,7 +7,7 @@ import SessionMessagingKit import SessionUtilitiesKit public class ConversationViewModel: OWSAudioPlayerDelegate { - public typealias SectionModel = ArraySection + public typealias SectionModel = ArraySection // MARK: - Action @@ -33,10 +33,10 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { // MARK: - Initialization init?(threadId: String, focusedInteractionId: Int64?) { - let maybeThreadData: ConversationCell.ViewModel? = GRDBStorage.shared.read { db in + let maybeThreadData: SessionThreadViewModel? = GRDBStorage.shared.read { db in let userPublicKey: String = getUserHexEncodedPublicKey(db) - return try ConversationCell.ViewModel + return try SessionThreadViewModel .conversationQuery( threadId: threadId, userPublicKey: userPublicKey @@ -44,7 +44,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { .fetchOne(db) } - guard let threadData: ConversationCell.ViewModel = maybeThreadData else { return nil } + guard let threadData: SessionThreadViewModel = maybeThreadData else { return nil } self.threadId = threadId self.threadData = threadData @@ -71,14 +71,14 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { columns: ThreadTypingIndicator.Columns.allCases ) ], - filterSQL: MessageCell.ViewModel.filterSQL(threadId: threadId), - orderSQL: MessageCell.ViewModel.orderSQL, - dataQuery: MessageCell.ViewModel.baseQuery( - orderSQL: MessageCell.ViewModel.orderSQL, - baseFilterSQL: MessageCell.ViewModel.filterSQL(threadId: threadId) + filterSQL: MessageViewModel.filterSQL(threadId: threadId), + orderSQL: MessageViewModel.orderSQL, + dataQuery: MessageViewModel.baseQuery( + orderSQL: MessageViewModel.orderSQL, + baseFilterSQL: MessageViewModel.filterSQL(threadId: threadId) ), associatedRecords: [ - AssociatedRecord( + AssociatedRecord( trackedAgainst: Attachment.self, observedChanges: [ PagedData.ObservedChanges( @@ -86,9 +86,10 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { columns: [.state] ) ], - dataQuery: MessageCell.AttachmentInteractionInfo.baseQuery, - joinToPagedType: MessageCell.AttachmentInteractionInfo.joinToViewModelQuerySQL, - associateData: MessageCell.AttachmentInteractionInfo.createAssociateDataClosure() + dataQuery: MessageViewModel.AttachmentInteractionInfo.baseQuery, + joinToPagedType: MessageViewModel.AttachmentInteractionInfo.joinToViewModelQuerySQL, + groupPagedType: MessageViewModel.AttachmentInteractionInfo.groupViewModelQuerySQL, + associateData: MessageViewModel.AttachmentInteractionInfo.createAssociateDataClosure() ) ], onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in @@ -137,32 +138,34 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { // MARK: - Thread Data /// This value is the current state of the view - public private(set) var threadData: ConversationCell.ViewModel + public private(set) var threadData: SessionThreadViewModel public lazy var observableThreadData = ValueObservation - .trackingConstantRegion { [threadId = self.threadId] db -> ConversationCell.ViewModel? in + .trackingConstantRegion { [threadId = self.threadId] db -> SessionThreadViewModel? in let userPublicKey: String = getUserHexEncodedPublicKey(db) - return try ConversationCell.ViewModel + return try SessionThreadViewModel .conversationQuery(threadId: threadId, userPublicKey: userPublicKey) .fetchOne(db) } .removeDuplicates() - public func updateThreadData(_ updatedData: ConversationCell.ViewModel) { + public func updateThreadData(_ updatedData: SessionThreadViewModel) { self.threadData = updatedData } // MARK: - Interaction Data public private(set) var interactionData: [SectionModel] = [] - public private(set) var pagedDataObserver: PagedDatabaseObserver? + public private(set) var pagedDataObserver: PagedDatabaseObserver? public var onInteractionChange: (([SectionModel]) -> ())? - private func process(data: [MessageCell.ViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] { - let sortedData: [MessageCell.ViewModel] = data + private func process(data: [MessageViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] { + let sortedData: [MessageViewModel] = data .sorted { lhs, rhs -> Bool in lhs.timestampMs < rhs.timestampMs } + // We load messages from newest to oldest so having a pageOffset larger than zero means + // there are newer pages to load return [ (!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ? [SectionModel(section: .loadOlder)] : @@ -173,10 +176,10 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { section: .messages, elements: sortedData .enumerated() - .map { index, cellViewModel -> MessageCell.ViewModel in + .map { index, cellViewModel -> MessageViewModel in cellViewModel.withClusteringChanges( prevModel: (index > 0 ? sortedData[index - 1] : nil), - nextModel: (index < (sortedData.count - 2) ? sortedData[index + 1] : nil), + nextModel: (index < (sortedData.count - 1) ? sortedData[index + 1] : nil), isLast: ( index == (sortedData.count - 1) && pageInfo.currentCount == pageInfo.totalCount @@ -185,7 +188,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { } ) ], - (data.isEmpty && pageInfo.pageOffset > 0 ? + (!data.isEmpty && pageInfo.pageOffset > 0 ? [SectionModel(section: .loadNewer)] : [] ) @@ -210,7 +213,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { } public func mentions(for query: String = "") -> [MentionInfo] { - let threadData: ConversationCell.ViewModel = self.threadData + let threadData: SessionThreadViewModel = self.threadData let results: [MentionInfo] = GRDBStorage.shared .read { db -> [MentionInfo] in @@ -336,13 +339,16 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { .id else { return } - GRDBStorage.shared.write { db in + let threadId: String = self.threadData.threadId + let trySendReadReceipt: Bool = (self.threadData.threadIsMessageRequest == false) + + GRDBStorage.shared.writeAsync { db in try Interaction.markAsRead( db, interactionId: lastInteractionId, - threadId: self.threadData.threadId, + threadId: threadId, includingOlder: true, - trySendReadReceipt: (self.threadData.threadIsMessageRequest == false) + trySendReadReceipt: trySendReadReceipt ) } } @@ -376,7 +382,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { private var currentPlayingInteraction: Atomic = Atomic(nil) private var playbackInfo: Atomic<[Int64: PlaybackInfo]> = Atomic([:]) - public func playbackInfo(for viewModel: MessageCell.ViewModel, updateCallback: ((PlaybackInfo?, Error?) -> ())? = nil) -> PlaybackInfo? { + public func playbackInfo(for viewModel: MessageViewModel, updateCallback: ((PlaybackInfo?, Error?) -> ())? = nil) -> PlaybackInfo? { // Use the existing info if it already exists (update it's callback if provided as that means // the cell was reloaded) if let currentPlaybackInfo: PlaybackInfo = playbackInfo.wrappedValue[viewModel.id] { @@ -413,7 +419,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { return newPlaybackInfo } - public func playOrPauseAudio(for viewModel: MessageCell.ViewModel) { + public func playOrPauseAudio(for viewModel: MessageViewModel) { guard let attachment: Attachment = viewModel.attachments?.first, let originalFilePath: String = attachment.originalFilePath, @@ -460,7 +466,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { } } - public func speedUpAudio(for viewModel: MessageCell.ViewModel) { + public func speedUpAudio(for viewModel: MessageViewModel) { // If we aren't playing the specified item then just start playing it guard viewModel.id == currentPlayingInteraction.wrappedValue else { playOrPauseAudio(for: viewModel) @@ -541,7 +547,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { messageSection.elements[currentIndex + 1].cellType == .audio else { return } - let nextItem: MessageCell.ViewModel = messageSection.elements[currentIndex + 1] + let nextItem: MessageViewModel = messageSection.elements[currentIndex + 1] playOrPauseAudio(for: nextItem) } diff --git a/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift b/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift index 6559d4078..22fc0ea74 100644 --- a/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift +++ b/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift @@ -96,7 +96,7 @@ public extension LinkPreview { return .loaded case .pendingDownload, .downloading, .uploading: return .loading - case .failedDownload: return .invalid + case .failedDownload, .failedUpload: return .invalid } } diff --git a/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift b/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift index 076d49d94..1d8c058bd 100644 --- a/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift +++ b/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift @@ -128,7 +128,7 @@ final class LinkPreviewView: UIView { with state: LinkPreviewState, isOutgoing: Bool, delegate: (UITextViewDelegate & BodyTextViewDelegate)? = nil, - cellViewModel: MessageCell.ViewModel? = nil, + cellViewModel: MessageViewModel? = nil, bodyLabelTextColor: UIColor? = nil, lastSearchText: String? = nil ) { @@ -184,7 +184,7 @@ final class LinkPreviewView: UIView { // Body text view bodyTextViewContainer.subviews.forEach { $0.removeFromSuperview() } - if let cellViewModel: MessageCell.ViewModel = cellViewModel { + if let cellViewModel: MessageViewModel = cellViewModel { let bodyTextView = VisibleMessageCell.getBodyTextView( for: cellViewModel, with: maxWidth, diff --git a/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift b/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift index f25b33bf6..fbd65d20a 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift @@ -9,7 +9,7 @@ final class MediaPlaceholderView: UIView { // MARK: - Lifecycle - init(cellViewModel: MessageCell.ViewModel, textColor: UIColor) { + init(cellViewModel: MessageViewModel, textColor: UIColor) { super.init(frame: CGRect.zero) setUpViewHierarchy(cellViewModel: cellViewModel, textColor: textColor) @@ -24,7 +24,7 @@ final class MediaPlaceholderView: UIView { } private func setUpViewHierarchy( - cellViewModel: MessageCell.ViewModel, + cellViewModel: MessageViewModel, textColor: UIColor ) { let (iconName, attachmentDescription): (String, String) = { diff --git a/Session/Conversations/Message Cells/Content Views/MediaView.swift b/Session/Conversations/Message Cells/Content Views/MediaView.swift index c26cbd295..a22cce4c6 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaView.swift @@ -121,6 +121,10 @@ public class MediaView: UIView { private func addUploadProgressIfNecessary(_ subview: UIView) -> Bool { guard isOutgoing else { return false } + guard attachment.state != .failedUpload else { + configure(forError: .failed) + return false + } guard attachment.state != .uploaded else { return false } let loader = MediaLoaderView() @@ -326,8 +330,18 @@ public class MediaView: UIView { backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05) + // For failed ougoing messages add an overlay to make the icon more visible + if isOutgoing { + let attachmentOverlayView: UIView = UIView() + attachmentOverlayView.backgroundColor = Colors.navigationBarBackground + .withAlphaComponent(Values.lowOpacity) + addSubview(attachmentOverlayView) + attachmentOverlayView.pin(to: self) + } + let iconView = UIImageView(image: icon.withRenderingMode(.alwaysTemplate)) - iconView.tintColor = Colors.text.withAlphaComponent(Values.mediumOpacity) + iconView.tintColor = Colors.text + .withAlphaComponent(Values.mediumOpacity) addSubview(iconView) iconView.autoCenterInSuperview() } diff --git a/Session/Conversations/Message Cells/InfoMessageCell.swift b/Session/Conversations/Message Cells/InfoMessageCell.swift index a8dfb6ae0..86b270498 100644 --- a/Session/Conversations/Message Cells/InfoMessageCell.swift +++ b/Session/Conversations/Message Cells/InfoMessageCell.swift @@ -52,7 +52,7 @@ final class InfoMessageCell: MessageCell { // MARK: - Updating - override func update(with cellViewModel: MessageCell.ViewModel, mediaCache: NSCache, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) { + override func update(with cellViewModel: MessageViewModel, mediaCache: NSCache, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) { guard cellViewModel.variant.isInfoMessage else { return } self.viewModel = cellViewModel @@ -81,6 +81,6 @@ final class InfoMessageCell: MessageCell { self.label.text = cellViewModel.body } - override func dynamicUpdate(with cellViewModel: MessageCell.ViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) { + override func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) { } } diff --git a/Session/Conversations/Message Cells/MessageCell.swift b/Session/Conversations/Message Cells/MessageCell.swift index 809ae0f14..ea24e5ba3 100644 --- a/Session/Conversations/Message Cells/MessageCell.swift +++ b/Session/Conversations/Message Cells/MessageCell.swift @@ -11,7 +11,7 @@ public enum SwipeState { public class MessageCell: UITableViewCell { weak var delegate: MessageCellDelegate? - var viewModel: MessageCell.ViewModel? + var viewModel: MessageViewModel? // MARK: - Lifecycle @@ -43,19 +43,19 @@ public class MessageCell: UITableViewCell { // MARK: - Updating - func update(with cellViewModel: MessageCell.ViewModel, mediaCache: NSCache, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) { + func update(with cellViewModel: MessageViewModel, mediaCache: NSCache, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) { preconditionFailure("Must be overridden by subclasses.") } /// This is a cut-down version of the 'update' function which doesn't re-create the UI (it should be used for dynamically-updating content /// like playing inline audio/video) - func dynamicUpdate(with cellViewModel: MessageCell.ViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) { + func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) { preconditionFailure("Must be overridden by subclasses.") } // MARK: - Convenience - static func cellType(for viewModel: MessageCell.ViewModel) -> MessageCell.Type { + static func cellType(for viewModel: MessageViewModel) -> MessageCell.Type { guard viewModel.cellType != .typingIndicator else { return TypingIndicatorCell.self } switch viewModel.variant { @@ -73,11 +73,11 @@ public class MessageCell: UITableViewCell { // MARK: - MessageCellDelegate protocol MessageCellDelegate: AnyObject { - func handleItemLongPressed(_ cellViewModel: MessageCell.ViewModel) - func handleItemTapped(_ cellViewModel: MessageCell.ViewModel, gestureRecognizer: UITapGestureRecognizer) - func handleItemDoubleTapped(_ cellViewModel: MessageCell.ViewModel) - func handleItemSwiped(_ cellViewModel: MessageCell.ViewModel, state: SwipeState) + func handleItemLongPressed(_ cellViewModel: MessageViewModel) + func handleItemTapped(_ cellViewModel: MessageViewModel, gestureRecognizer: UITapGestureRecognizer) + func handleItemDoubleTapped(_ cellViewModel: MessageViewModel) + func handleItemSwiped(_ cellViewModel: MessageViewModel, state: SwipeState) func openUrl(_ urlString: String) - func handleReplyButtonTapped(for cellViewModel: MessageCell.ViewModel) + func handleReplyButtonTapped(for cellViewModel: MessageViewModel) func showUserDetails(for profile: Profile) } diff --git a/Session/Conversations/Message Cells/Models/MessageCellViewModel.swift b/Session/Conversations/Message Cells/Models/MessageCellViewModel.swift deleted file mode 100644 index ca13a5c94..000000000 --- a/Session/Conversations/Message Cells/Models/MessageCellViewModel.swift +++ /dev/null @@ -1,652 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import GRDB -import DifferenceKit -import SessionUtilitiesKit -import SessionMessagingKit - -fileprivate typealias ViewModel = MessageCell.ViewModel -fileprivate typealias AttachmentInteractionInfo = MessageCell.AttachmentInteractionInfo - -extension MessageCell { - public struct ViewModel: FetchableRecordWithRowId, Decodable, Equatable, Hashable, Identifiable, Differentiable { - public static let threadVariantKey: SQL = SQL(stringLiteral: CodingKeys.threadVariant.stringValue) - public static let threadIsTrustedKey: SQL = SQL(stringLiteral: CodingKeys.threadIsTrusted.stringValue) - public static let threadHasDisappearingMessagesEnabledKey: SQL = SQL(stringLiteral: CodingKeys.threadHasDisappearingMessagesEnabled.stringValue) - public static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue) - public static let authorNameInternalKey: SQL = SQL(stringLiteral: CodingKeys.authorNameInternal.stringValue) - public static let stateKey: SQL = SQL(stringLiteral: CodingKeys.state.stringValue) - public static let hasAtLeastOneReadReceiptKey: SQL = SQL(stringLiteral: CodingKeys.hasAtLeastOneReadReceipt.stringValue) - public static let mostRecentFailureTextKey: SQL = SQL(stringLiteral: CodingKeys.mostRecentFailureText.stringValue) - public static let isTypingIndicatorKey: SQL = SQL(stringLiteral: CodingKeys.isTypingIndicator.stringValue) - public static let isSenderOpenGroupModeratorKey: SQL = SQL(stringLiteral: CodingKeys.isSenderOpenGroupModerator.stringValue) - public static let profileKey: SQL = SQL(stringLiteral: CodingKeys.profile.stringValue) - public static let quoteKey: SQL = SQL(stringLiteral: CodingKeys.quote.stringValue) - public static let quoteAttachmentKey: SQL = SQL(stringLiteral: CodingKeys.quoteAttachment.stringValue) - public static let linkPreviewKey: SQL = SQL(stringLiteral: CodingKeys.linkPreview.stringValue) - public static let linkPreviewAttachmentKey: SQL = SQL(stringLiteral: CodingKeys.linkPreviewAttachment.stringValue) - public static let cellTypeKey: SQL = SQL(stringLiteral: CodingKeys.cellType.stringValue) - public static let authorNameKey: SQL = SQL(stringLiteral: CodingKeys.authorName.stringValue) - public static let shouldShowProfileKey: SQL = SQL(stringLiteral: CodingKeys.shouldShowProfile.stringValue) - public static let positionInClusterKey: SQL = SQL(stringLiteral: CodingKeys.positionInCluster.stringValue) - public static let isOnlyMessageInClusterKey: SQL = SQL(stringLiteral: CodingKeys.isOnlyMessageInCluster.stringValue) - public static let isLastKey: SQL = SQL(stringLiteral: CodingKeys.isLast.stringValue) - - public static let profileString: String = CodingKeys.profile.stringValue - public static let quoteString: String = CodingKeys.quote.stringValue - public static let quoteAttachmentString: String = CodingKeys.quoteAttachment.stringValue - public static let linkPreviewString: String = CodingKeys.linkPreview.stringValue - public static let linkPreviewAttachmentString: String = CodingKeys.linkPreviewAttachment.stringValue - - public enum Position: Int, Decodable, Equatable, Hashable, DatabaseValueConvertible { - case top - case middle - case bottom - } - - public enum CellType: Int, Decodable, Equatable, Hashable, DatabaseValueConvertible { - case textOnlyMessage - case mediaMessage - case audio - case genericAttachment - case typingIndicator - } - - public var differenceIdentifier: ViewModel { self } - - // Thread Info - - let threadVariant: SessionThread.Variant - let threadIsTrusted: Bool - let threadHasDisappearingMessagesEnabled: Bool - - // Interaction Info - - public let rowId: Int64 - public let id: Int64 - let variant: Interaction.Variant - let timestampMs: Int64 - let authorId: String - private let authorNameInternal: String? - let body: String? - let expiresStartedAtMs: Double? - let expiresInSeconds: TimeInterval? - - let state: RecipientState.State - let hasAtLeastOneReadReceipt: Bool - let mostRecentFailureText: String? - let isTypingIndicator: Bool - let isSenderOpenGroupModerator: Bool - let profile: Profile? - let quote: Quote? - let quoteAttachment: Attachment? - let linkPreview: LinkPreview? - let linkPreviewAttachment: Attachment? - - // Post-Query Processing Data - - /// This value includes the associated attachments - let attachments: [Attachment]? - - /// This value defines what type of cell should appear and is generated based on the interaction variant - /// and associated attachment data - let cellType: CellType - - /// This value includes the author name information - let authorName: String - - /// This value will be used to populate the author label, if it's null then the label will be hidden - let senderName: String? - - /// A flag indicating whether the profile view should be displayed - let shouldShowProfile: Bool - - /// This value will be used to populate the date header, if it's null then the header will be hidden - let dateForUI: Date? - - /// This value specifies whether the body contains only emoji characters - let containsOnlyEmoji: Bool? - - /// This value specifies the number of emoji characters the body contains - let glyphCount: Int? - - /// This value indicates the variant of the previous ViewModel item, if it's null then there is no previous item - let previousVariant: Interaction.Variant? - - /// This value indicates the position of this message within a cluser of messages - let positionInCluster: Position - - /// This value indicates whether this is the only message in a cluser of messages - let isOnlyMessageInCluster: Bool - - /// This value indicates whether this is the last message in the thread - let isLast: Bool - - // MARK: - Mutation - - public func with(attachments: [Attachment]) -> ViewModel { - return ViewModel( - threadVariant: self.threadVariant, - threadIsTrusted: self.threadIsTrusted, - threadHasDisappearingMessagesEnabled: self.threadHasDisappearingMessagesEnabled, - rowId: self.rowId, - id: self.id, - variant: self.variant, - timestampMs: self.timestampMs, - authorId: self.authorId, - authorNameInternal: self.authorNameInternal, - body: self.body, - expiresStartedAtMs: self.expiresStartedAtMs, - expiresInSeconds: self.expiresInSeconds, - state: self.state, - hasAtLeastOneReadReceipt: self.hasAtLeastOneReadReceipt, - mostRecentFailureText: self.mostRecentFailureText, - isTypingIndicator: self.isTypingIndicator, - isSenderOpenGroupModerator: self.isSenderOpenGroupModerator, - profile: self.profile, - quote: self.quote, - quoteAttachment: self.quoteAttachment, - linkPreview: self.linkPreview, - linkPreviewAttachment: self.linkPreviewAttachment, - attachments: attachments, - cellType: self.cellType, - authorName: self.authorName, - senderName: self.senderName, - shouldShowProfile: self.shouldShowProfile, - dateForUI: self.dateForUI, - containsOnlyEmoji: self.containsOnlyEmoji, - glyphCount: self.glyphCount, - previousVariant: self.previousVariant, - positionInCluster: self.positionInCluster, - isOnlyMessageInCluster: self.isOnlyMessageInCluster, - isLast: self.isLast - ) - } - - public func withClusteringChanges( - prevModel: ViewModel?, - nextModel: ViewModel?, - isLast: Bool - ) -> ViewModel { - let cellType: CellType = { - guard !self.isTypingIndicator else { return .typingIndicator } - guard self.variant != .standardIncomingDeleted else { return .textOnlyMessage } - guard let attachment: Attachment = self.attachments?.first else { return .textOnlyMessage } - - // The only case which currently supports multiple attachments is a 'mediaMessage' - // (the album view) - guard self.attachments?.count == 1 else { return .mediaMessage } - - // Quote and LinkPreview overload the 'attachments' array and use it for their - // own purposes, otherwise check if the attachment is visual media - guard self.quote == nil else { return .textOnlyMessage } - guard self.linkPreview == nil else { return .textOnlyMessage } - - // Pending audio attachments won't have a duration - if - attachment.isAudio && ( - ((attachment.duration ?? 0) > 0) || - ( - attachment.state != .downloaded && - attachment.state != .uploaded - ) - ) - { - return .audio - } - - if attachment.isVisualMedia { - return .mediaMessage - } - - return .genericAttachment - }() - let authorDisplayName: String = Profile.displayName( - for: self.threadVariant, - id: self.authorId, - name: self.authorNameInternal, - nickname: nil // Folded into 'authorName' within the Query - ) - let shouldShowDateOnThisModel: Bool = { - guard !self.isTypingIndicator else { return false } - guard let prevModel: ViewModel = prevModel else { return true } - - return DateUtil.shouldShowDateBreak( - forTimestamp: UInt64(prevModel.timestampMs), - timestamp: UInt64(self.timestampMs) - ) - }() - let shouldShowDateOnNextModel: Bool = { - // Should be nothing after a typing indicator - guard !self.isTypingIndicator else { return false } - guard let nextModel: ViewModel = nextModel else { return false } - - return DateUtil.shouldShowDateBreak( - forTimestamp: UInt64(self.timestampMs), - timestamp: UInt64(nextModel.timestampMs) - ) - }() - let (positionInCluster, isOnlyMessageInCluster): (Position, Bool) = { - let isFirstInCluster: Bool = ( - prevModel == nil || - shouldShowDateOnThisModel || ( - self.variant == .standardOutgoing && - prevModel?.variant != .standardOutgoing - ) || ( - ( - self.variant == .standardIncoming || - self.variant == .standardIncomingDeleted - ) && ( - prevModel?.variant != .standardIncoming && - prevModel?.variant != .standardIncomingDeleted - ) - ) || - self.authorId != prevModel?.authorId - ) - let isLastInCluster: Bool = ( - nextModel == nil || - shouldShowDateOnNextModel || ( - self.variant == .standardOutgoing && - nextModel?.variant != .standardOutgoing - ) || ( - ( - self.variant == .standardIncoming || - self.variant == .standardIncomingDeleted - ) && ( - nextModel?.variant != .standardIncoming && - nextModel?.variant != .standardIncomingDeleted - ) - ) || - self.authorId != nextModel?.authorId - ) - - let isOnlyMessageInCluster: Bool = (isFirstInCluster && isLastInCluster) - - switch (isFirstInCluster, isLastInCluster) { - case (true, true), (false, false): return (.middle, isOnlyMessageInCluster) - case (true, false): return (.top, isOnlyMessageInCluster) - case (false, true): return (.bottom, isOnlyMessageInCluster) - } - }() - - return ViewModel( - threadVariant: self.threadVariant, - threadIsTrusted: self.threadIsTrusted, - threadHasDisappearingMessagesEnabled: self.threadHasDisappearingMessagesEnabled, - rowId: self.rowId, - id: self.id, - variant: self.variant, - timestampMs: self.timestampMs, - authorId: self.authorId, - authorNameInternal: self.authorNameInternal, - body: (!self.variant.isInfoMessage ? - self.body : - // Info messages might not have a body so we should use the 'previewText' value instead - Interaction.previewText( - variant: self.variant, - body: self.body, - authorDisplayName: authorDisplayName, - attachmentDescriptionInfo: self.attachments?.first.map { firstAttachment in - Attachment.DescriptionInfo( - id: firstAttachment.id, - variant: firstAttachment.variant, - contentType: firstAttachment.contentType, - sourceFilename: firstAttachment.sourceFilename - ) - }, - attachmentCount: self.attachments?.count, - isOpenGroupInvitation: (self.linkPreview?.variant == .openGroupInvitation) - ) - ), - expiresStartedAtMs: self.expiresStartedAtMs, - expiresInSeconds: self.expiresInSeconds, - state: self.state, - hasAtLeastOneReadReceipt: self.hasAtLeastOneReadReceipt, - mostRecentFailureText: self.mostRecentFailureText, - isTypingIndicator: self.isTypingIndicator, - isSenderOpenGroupModerator: self.isSenderOpenGroupModerator, - profile: self.profile, - quote: self.quote, - quoteAttachment: self.quoteAttachment, - linkPreview: self.linkPreview, - linkPreviewAttachment: self.linkPreviewAttachment, - attachments: self.attachments, - cellType: cellType, - authorName: authorDisplayName, - senderName: { - // Only show for group threads - guard self.threadVariant == .openGroup || self.threadVariant == .closedGroup else { - return nil - } - - // Only if there is a date header or the senders are different - guard shouldShowDateOnThisModel || self.authorId != prevModel?.authorId else { - return nil - } - - return authorDisplayName - }(), - shouldShowProfile: ( - // Only group threads - (self.threadVariant == .openGroup || self.threadVariant == .closedGroup) && - - // Only incoming messages - (self.variant == .standardIncoming || self.variant == .standardIncomingDeleted) && - - // Show if the next message has a different sender or has a "date break" - ( - self.authorId != nextModel?.authorId || - shouldShowDateOnNextModel - ) && - - // Need a profile to be able to show it - self.profile != nil - ), - dateForUI: (shouldShowDateOnThisModel ? - Date(timeIntervalSince1970: (TimeInterval(self.timestampMs) / 1000)) : - nil - ), - containsOnlyEmoji: self.body?.containsOnlyEmoji, - glyphCount: self.body?.glyphCount, - previousVariant: prevModel?.variant, - positionInCluster: positionInCluster, - isOnlyMessageInCluster: isOnlyMessageInCluster, - isLast: isLast - ) - } - } - - public struct AttachmentInteractionInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable, Comparable { - public static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue) - public static let attachmentKey: SQL = SQL(stringLiteral: CodingKeys.attachment.stringValue) - public static let interactionAttachmentKey: SQL = SQL(stringLiteral: CodingKeys.interactionAttachment.stringValue) - - public static let attachmentString: String = CodingKeys.attachment.stringValue - public static let interactionAttachmentString: String = CodingKeys.interactionAttachment.stringValue - - public let rowId: Int64 - public let attachment: Attachment - public let interactionAttachment: InteractionAttachment - - // MARK: - Identifiable - - public var id: String { - "\(interactionAttachment.interactionId)-\(interactionAttachment.albumIndex)" - } - - // MARK: - Comparable - - public static func < (lhs: AttachmentInteractionInfo, rhs: AttachmentInteractionInfo) -> Bool { - return (lhs.interactionAttachment.albumIndex < rhs.interactionAttachment.albumIndex) - } - } -} - -// MARK: - Convenience Initialization - -public extension MessageCell.ViewModel { - // Note: This init method is only used system-created cells or empty states - init(isTypingIndicator: Bool = false) { - self.threadVariant = .contact - self.threadIsTrusted = false - self.threadHasDisappearingMessagesEnabled = false - - // Interaction Info - - self.rowId = -1 - self.id = -1 - self.variant = .standardOutgoing - self.timestampMs = Int64.max - self.authorId = "" - self.authorNameInternal = nil - self.body = nil - self.expiresStartedAtMs = nil - self.expiresInSeconds = nil - - self.state = .sent - self.hasAtLeastOneReadReceipt = false - self.mostRecentFailureText = nil - self.isTypingIndicator = isTypingIndicator - self.isSenderOpenGroupModerator = false - self.profile = nil - self.quote = nil - self.quoteAttachment = nil - self.linkPreview = nil - self.linkPreviewAttachment = nil - - // Post-Query Processing Data - - self.attachments = nil - self.cellType = .typingIndicator - self.authorName = "" - self.senderName = nil - self.shouldShowProfile = false - self.dateForUI = nil - self.containsOnlyEmoji = nil - self.glyphCount = nil - self.previousVariant = nil - self.positionInCluster = .middle - self.isOnlyMessageInCluster = true - self.isLast = true - } -} - -// MARK: - ConversationVC - -extension MessageCell.ViewModel { - public static func filterSQL(threadId: String) -> SQL { - let interaction: TypedTableAlias = TypedTableAlias() - - return SQL("\(interaction[.threadId]) = \(threadId)") - } - - public static let orderSQL: SQL = { - let interaction: TypedTableAlias = TypedTableAlias() - - return SQL("\(interaction[.timestampMs].desc)") - }() - - public static func baseQuery(orderSQL: SQL, baseFilterSQL: SQL) -> ((SQL?, SQL?) -> AdaptedFetchRequest>) { - return { additionalFilters, limitSQL -> AdaptedFetchRequest> in - let interaction: TypedTableAlias = TypedTableAlias() - let thread: TypedTableAlias = TypedTableAlias() - let contact: TypedTableAlias = TypedTableAlias() - let disappearingMessagesConfig: TypedTableAlias = TypedTableAlias() - let profile: TypedTableAlias = TypedTableAlias() - let quote: TypedTableAlias = TypedTableAlias() - let linkPreview: TypedTableAlias = TypedTableAlias() - - let interactionStateTableLiteral: SQL = SQL(stringLiteral: "interactionState") - let interactionStateInteractionIdColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.interactionId.name) - let interactionStateStateColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.state.name) - let interactionStateMostRecentFailureTextColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.mostRecentFailureText.name) - let readReceiptTableLiteral: SQL = SQL(stringLiteral: "readReceipt") - let readReceiptReadTimestampMsColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.readTimestampMs.name) - let attachmentIdColumnLiteral: SQL = SQL(stringLiteral: Attachment.Columns.id.name) - - let finalFilterSQL: SQL = { - guard let additionalFilters: SQL = additionalFilters else { - return """ - WHERE \(baseFilterSQL) - """ - } - - return """ - WHERE ( - \(baseFilterSQL) AND - \(additionalFilters) - ) - """ - }() - let finalLimitSQL: SQL = (limitSQL ?? SQL(stringLiteral: "")) - let numColumnsBeforeLinkedRecords: Int = 17 - let request: SQLRequest = """ - SELECT - \(thread[.variant]) AS \(ViewModel.threadVariantKey), - -- Default to 'true' for non-contact threads - IFNULL(\(contact[.isTrusted]), true) AS \(ViewModel.threadIsTrustedKey), - -- Default to 'false' when no contact exists - IFNULL(\(disappearingMessagesConfig[.isEnabled]), false) AS \(ViewModel.threadHasDisappearingMessagesEnabledKey), - - \(interaction.alias[Column.rowID]) AS \(ViewModel.rowIdKey), - \(interaction[.id]), - \(interaction[.variant]), - \(interaction[.timestampMs]), - \(interaction[.authorId]), - IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.authorNameInternalKey), - \(interaction[.body]), - \(interaction[.expiresStartedAtMs]), - \(interaction[.expiresInSeconds]), - - -- Default to 'sending' assuming non-processed interaction when null - IFNULL(\(interactionStateTableLiteral).\(interactionStateStateColumnLiteral), \(SQL("\(RecipientState.State.sending)"))) AS \(ViewModel.stateKey), - (\(readReceiptTableLiteral).\(readReceiptReadTimestampMsColumnLiteral) IS NOT NULL) AS \(ViewModel.hasAtLeastOneReadReceiptKey), - \(interactionStateTableLiteral).\(interactionStateMostRecentFailureTextColumnLiteral) AS \(ViewModel.mostRecentFailureTextKey), - - false AS \(ViewModel.isTypingIndicatorKey), - false AS \(ViewModel.isSenderOpenGroupModeratorKey), - - \(ViewModel.profileKey).*, - \(ViewModel.quoteKey).*, - \(ViewModel.quoteAttachmentKey).*, - \(ViewModel.linkPreviewKey).*, - \(ViewModel.linkPreviewAttachmentKey).*, - - -- All of the below properties are set in post-query processing but to prevent the - -- query from crashing when decoding we need to provide default values - \(CellType.textOnlyMessage) AS \(ViewModel.cellTypeKey), - '' AS \(ViewModel.authorNameKey), - false AS \(ViewModel.shouldShowProfileKey), - \(Position.middle) AS \(ViewModel.positionInClusterKey), - false AS \(ViewModel.isOnlyMessageInClusterKey), - false AS \(ViewModel.isLastKey) - - FROM \(Interaction.self) - JOIN \(SessionThread.self) ON \(thread[.id]) = \(interaction[.threadId]) - LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId]) - LEFT JOIN \(DisappearingMessagesConfiguration.self) ON \(disappearingMessagesConfig[.threadId]) = \(interaction[.threadId]) - LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId]) - LEFT JOIN \(Quote.self) ON \(quote[.interactionId]) = \(interaction[.id]) - LEFT JOIN \(Attachment.self) AS \(ViewModel.quoteAttachmentKey) ON \(ViewModel.quoteAttachmentKey).\(attachmentIdColumnLiteral) = \(quote[.attachmentId]) - LEFT JOIN \(LinkPreview.self) ON ( - \(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND - \(Interaction.linkPreviewFilterLiteral) - ) - LEFT JOIN \(Attachment.self) AS \(ViewModel.linkPreviewAttachmentKey) ON \(ViewModel.linkPreviewAttachmentKey).\(attachmentIdColumnLiteral) = \(linkPreview[.attachmentId]) - LEFT JOIN ( - \(RecipientState.selectInteractionState( - tableLiteral: interactionStateTableLiteral, - idColumnLiteral: interactionStateInteractionIdColumnLiteral - )) - ) AS \(interactionStateTableLiteral) ON \(interactionStateTableLiteral).\(interactionStateInteractionIdColumnLiteral) = \(interaction[.id]) - LEFT JOIN \(RecipientState.self) AS \(readReceiptTableLiteral) ON ( - \(readReceiptTableLiteral).\(readReceiptReadTimestampMsColumnLiteral) IS NOT NULL AND - \(interaction[.id]) = \(readReceiptTableLiteral).\(interactionStateInteractionIdColumnLiteral) - ) - \(finalFilterSQL) - ORDER BY \(orderSQL) - \(finalLimitSQL) - """ - - return request.adapted { db in - let adapters = try splittingRowAdapters(columnCounts: [ - numColumnsBeforeLinkedRecords, - Profile.numberOfSelectedColumns(db), - Quote.numberOfSelectedColumns(db), - Attachment.numberOfSelectedColumns(db), - LinkPreview.numberOfSelectedColumns(db), - Attachment.numberOfSelectedColumns(db) - ]) - - return ScopeAdapter([ - ViewModel.profileString: adapters[1], - ViewModel.quoteString: adapters[2], - ViewModel.quoteAttachmentString: adapters[3], - ViewModel.linkPreviewString: adapters[4], - ViewModel.linkPreviewAttachmentString: adapters[5] - ]) - } - } - } -} - -extension MessageCell.AttachmentInteractionInfo { - public static let baseQuery: ((SQL?) -> AdaptedFetchRequest>) = { - return { additionalFilters -> AdaptedFetchRequest> in - let attachment: TypedTableAlias = TypedTableAlias() - let interactionAttachment: TypedTableAlias = TypedTableAlias() - - let finalFilterSQL: SQL = { - guard let additionalFilters: SQL = additionalFilters else { - return SQL(stringLiteral: "") - } - - return """ - WHERE \(additionalFilters) - """ - }() - let numColumnsBeforeLinkedRecords: Int = 1 - let request: SQLRequest = """ - SELECT - \(attachment.alias[Column.rowID]) AS \(AttachmentInteractionInfo.rowIdKey), - \(AttachmentInteractionInfo.attachmentKey).*, - \(AttachmentInteractionInfo.interactionAttachmentKey).* - FROM \(Attachment.self) - JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id]) - \(finalFilterSQL) - """ - - return request.adapted { db in - let adapters = try splittingRowAdapters(columnCounts: [ - numColumnsBeforeLinkedRecords, - Attachment.numberOfSelectedColumns(db), - InteractionAttachment.numberOfSelectedColumns(db) - ]) - - return ScopeAdapter([ - AttachmentInteractionInfo.attachmentString: adapters[1], - AttachmentInteractionInfo.interactionAttachmentString: adapters[2] - ]) - } - } - }() - - public static var joinToViewModelQuerySQL: SQL = { - let interaction: TypedTableAlias = TypedTableAlias() - let attachment: TypedTableAlias = TypedTableAlias() - let interactionAttachment: TypedTableAlias = TypedTableAlias() - - return """ - JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id]) - JOIN \(Interaction.self) ON - \(interaction[.id]) = \(interactionAttachment[.interactionId]) - """ - }() - - public static func createAssociateDataClosure() -> (DataCache, DataCache) -> DataCache { - return { dataCache, pagedDataCache -> DataCache in - var updatedPagedDataCache: DataCache = pagedDataCache - - dataCache - .values - .grouped(by: \.interactionAttachment.interactionId) - .forEach { (interactionId: Int64, attachments: [MessageCell.AttachmentInteractionInfo]) in - guard - let interactionRowId: Int64 = updatedPagedDataCache.lookup[interactionId], - let dataToUpdate: ViewModel = updatedPagedDataCache.data[interactionRowId] - else { return } - - updatedPagedDataCache = updatedPagedDataCache.upserting( - dataToUpdate.with( - attachments: attachments - .sorted() - .map { $0.attachment } - ) - ) - } - - return updatedPagedDataCache - } - } -} diff --git a/Session/Conversations/Message Cells/TypingIndicatorCell.swift b/Session/Conversations/Message Cells/TypingIndicatorCell.swift index d95a445cb..0b40253c2 100644 --- a/Session/Conversations/Message Cells/TypingIndicatorCell.swift +++ b/Session/Conversations/Message Cells/TypingIndicatorCell.swift @@ -39,7 +39,7 @@ final class TypingIndicatorCell: MessageCell { // MARK: - Updating - override func update(with cellViewModel: MessageCell.ViewModel, mediaCache: NSCache, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) { + override func update(with cellViewModel: MessageViewModel, mediaCache: NSCache, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) { guard cellViewModel.cellType == .typingIndicator else { return } self.viewModel = cellViewModel @@ -51,7 +51,7 @@ final class TypingIndicatorCell: MessageCell { typingIndicatorView.startAnimation() } - override func dynamicUpdate(with cellViewModel: MessageCell.ViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) { + override func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) { } override func layoutSubviews() { diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 3216593d4..408daab64 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -207,7 +207,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel // MARK: - Updating override func update( - with cellViewModel: MessageCell.ViewModel, + with cellViewModel: MessageViewModel, mediaCache: NSCache, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String? @@ -328,16 +328,14 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel } } - private func populateHeader(for cellViewModel: MessageCell.ViewModel, shouldInsetHeader: Bool) { + private func populateHeader(for cellViewModel: MessageViewModel, shouldInsetHeader: Bool) { guard let date: Date = cellViewModel.dateForUI else { return } let dateBreakLabel: UILabel = UILabel() dateBreakLabel.font = .boldSystemFont(ofSize: Values.verySmallFontSize) dateBreakLabel.textColor = Colors.text dateBreakLabel.textAlignment = .center - - let description: String = DateUtil.formatDate(forDisplay: date) - dateBreakLabel.text = description + dateBreakLabel.text = date.formattedForDisplay headerView.addSubview(dateBreakLabel) dateBreakLabel.pin(.top, to: .top, of: headerView, withInset: Values.smallSpacing) @@ -352,7 +350,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel } private func populateContentView( - for cellViewModel: MessageCell.ViewModel, + for cellViewModel: MessageViewModel, mediaCache: NSCache, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String? @@ -579,7 +577,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel bubbleView.layer.maskedCorners = getCornerMask(from: cornersToRound) } - override func dynamicUpdate(with cellViewModel: MessageCell.ViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) { + override func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) { guard cellViewModel.variant != .standardIncomingDeleted else { return } // If it's an incoming media message and the thread isn't trusted then show the placeholder view @@ -669,13 +667,13 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel } @objc func handleLongPress() { - guard let cellViewModel: MessageCell.ViewModel = self.viewModel else { return } + guard let cellViewModel: MessageViewModel = self.viewModel else { return } delegate?.handleItemLongPressed(cellViewModel) } @objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) { - guard let cellViewModel: MessageCell.ViewModel = self.viewModel else { return } + guard let cellViewModel: MessageViewModel = self.viewModel else { return } let location = gestureRecognizer.location(in: self) @@ -692,13 +690,13 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel } @objc private func handleDoubleTap() { - guard let cellViewModel: MessageCell.ViewModel = self.viewModel else { return } + guard let cellViewModel: MessageViewModel = self.viewModel else { return } delegate?.handleItemDoubleTapped(cellViewModel) } @objc private func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) { - guard let cellViewModel: MessageCell.ViewModel = self.viewModel else { return } + guard let cellViewModel: MessageViewModel = self.viewModel else { return } let viewsToMove: [UIView] = [ bubbleView, profilePictureView, replyButton, timerView, messageStatusImageView @@ -760,7 +758,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel } private func reply() { - guard let cellViewModel: MessageCell.ViewModel = self.viewModel else { return } + guard let cellViewModel: MessageViewModel = self.viewModel else { return } resetReply() delegate?.handleReplyButtonTapped(for: cellViewModel) @@ -797,7 +795,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel return cornerMask } - private static func getFontSize(for cellViewModel: MessageCell.ViewModel) -> CGFloat { + private static func getFontSize(for cellViewModel: MessageViewModel) -> CGFloat { let baselineFontSize = Values.mediumFontSize guard cellViewModel.containsOnlyEmoji == true else { return baselineFontSize } @@ -810,7 +808,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel } } - private func getMessageStatusImage(for cellViewModel: MessageCell.ViewModel) -> (image: UIImage?, tintColor: UIColor?, backgroundColor: UIColor?) { + private func getMessageStatusImage(for cellViewModel: MessageViewModel) -> (image: UIImage?, tintColor: UIColor?, backgroundColor: UIColor?) { guard cellViewModel.variant == .standardOutgoing else { return (nil, nil, nil) } let image: UIImage @@ -838,7 +836,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel return (image, tintColor, backgroundColor) } - private func getSize(for cellViewModel: MessageCell.ViewModel) -> CGSize { + private func getSize(for cellViewModel: MessageViewModel) -> CGSize { guard let mediaAttachments: [Attachment] = cellViewModel.attachments?.filter({ $0.isVisualMedia }) else { preconditionFailure() } @@ -886,7 +884,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel return CGSize(width: width, height: height) } - static func getMaxWidth(for cellViewModel: MessageCell.ViewModel) -> CGFloat { + static func getMaxWidth(for cellViewModel: MessageViewModel) -> CGFloat { let screen: CGRect = UIScreen.main.bounds switch cellViewModel.variant { @@ -905,7 +903,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel } static func getBodyTextView( - for cellViewModel: MessageCell.ViewModel, + for cellViewModel: MessageViewModel, with availableWidth: CGFloat, textColor: UIColor, searchText: String?, @@ -938,7 +936,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel if let searchText = searchText, searchText.count >= ConversationSearchController.minimumSearchTextLength { let normalizedBody: String = attributedText.string.lowercased() - ConversationCell.ViewModel.searchTermParts(searchText) + SessionThreadViewModel.searchTermParts(searchText) .map { part -> String in guard part.hasPrefix("\"") && part.hasSuffix("\"") else { return part } diff --git a/Session/Conversations/Views & Modals/InsetLockableTableView.swift b/Session/Conversations/Views & Modals/InsetLockableTableView.swift index e1c67b4f0..cb0abdc1f 100644 --- a/Session/Conversations/Views & Modals/InsetLockableTableView.swift +++ b/Session/Conversations/Views & Modals/InsetLockableTableView.swift @@ -20,7 +20,7 @@ public class InsetLockableTableView: UITableView { } public var oldOffset: CGPoint = .zero public var newOffset: CGPoint = .zero - private var callbackCondition: ((Int, [Int]) -> Bool)? + private var callbackCondition: ((Int, [Int], CGSize) -> Bool)? private var afterLayoutSubviewsCallback: (() -> ())? public override func layoutSubviews() { @@ -54,7 +54,7 @@ public class InsetLockableTableView: UITableView { // MARK: - Functions public func afterNextLayoutSubviews( - when condition: @escaping (Int, [Int]) -> Bool, + when condition: @escaping (Int, [Int], CGSize) -> Bool, then callback: @escaping () -> () ) { self.callbackCondition = condition @@ -70,7 +70,9 @@ public class InsetLockableTableView: UITableView { // Store the layout info locally so if they pass we can clear the states before running to // prevent layouts within the callbacks from triggering infinite loops - guard self.callbackCondition?(numSections, numRowInSections) == true else { return false } + guard self.callbackCondition?(numSections, numRowInSections, self.contentSize) == true else { + return false + } self.callbackCondition = nil return true diff --git a/Session/Home/GlobalSearch/GlobalSearchViewController.swift b/Session/Home/GlobalSearch/GlobalSearchViewController.swift index b5b7cdb68..98ee2f928 100644 --- a/Session/Home/GlobalSearch/GlobalSearchViewController.swift +++ b/Session/Home/GlobalSearch/GlobalSearchViewController.swift @@ -9,7 +9,7 @@ import SessionUtilitiesKit import SignalUtilitiesKit class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSource { - fileprivate typealias SectionModel = ArraySection + fileprivate typealias SectionModel = ArraySection // MARK: - SearchSection @@ -22,8 +22,8 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo // MARK: - Variables private lazy var defaultSearchResults: [SectionModel] = { - let result: ConversationCell.ViewModel? = GRDBStorage.shared.read { db -> ConversationCell.ViewModel? in - try ConversationCell.ViewModel + let result: SessionThreadViewModel? = GRDBStorage.shared.read { db -> SessionThreadViewModel? in + try SessionThreadViewModel .noteToSelfOnlyQuery(userPublicKey: getUserHexEncodedPublicKey(db)) .fetchOne(db) } @@ -63,7 +63,7 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo result.separatorStyle = .none result.keyboardDismissMode = .onDrag result.register(view: EmptySearchResultCell.self) - result.register(view: ConversationCell.Full.self) + result.register(view: FullConversationCell.self) result.showsVerticalScrollIndicator = false return result @@ -143,18 +143,18 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo do { let userPublicKey: String = getUserHexEncodedPublicKey(db) - let contactsAndGroupsResults: [ConversationCell.ViewModel] = try ConversationCell.ViewModel + let contactsAndGroupsResults: [SessionThreadViewModel] = try SessionThreadViewModel .contactsAndGroupsQuery( userPublicKey: userPublicKey, - pattern: try ConversationCell.ViewModel.pattern(db, searchTerm: searchText), + pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText), searchTerm: searchText ) .fetchAll(db) - let messageResults: [ConversationCell.ViewModel] = try ConversationCell.ViewModel + let messageResults: [SessionThreadViewModel] = try SessionThreadViewModel .messagesQuery( userPublicKey: userPublicKey, - pattern: try ConversationCell.ViewModel.pattern(db, searchTerm: searchText) + pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText) ) .fetchAll(db) @@ -177,7 +177,7 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo self.termForCurrentSearchResultSet = searchText self.searchResultSet = [ - (hasResults ? nil : [ArraySection(model: .noResults, elements: [ConversationCell.ViewModel(unreadCount: 0)])]), + (hasResults ? nil : [ArraySection(model: .noResults, elements: [SessionThreadViewModel(unreadCount: 0)])]), (hasResults ? sections : nil) ] .compactMap { $0 } @@ -332,12 +332,12 @@ extension GlobalSearchViewController { return cell case .contactsAndGroups: - let cell: ConversationCell.Full = tableView.dequeue(type: ConversationCell.Full.self, for: indexPath) + let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath) cell.updateForContactAndGroupSearchResult(with: section.elements[indexPath.row], searchText: self.termForCurrentSearchResultSet) return cell case .messages: - let cell: ConversationCell.Full = tableView.dequeue(type: ConversationCell.Full.self, for: indexPath) + let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath) cell.updateForMessageSearchResult(with: section.elements[indexPath.row], searchText: self.termForCurrentSearchResultSet) return cell } diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 695a22a86..74eee689b 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -9,7 +9,7 @@ import SignalUtilitiesKit final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConversationButtonSetDelegate, SeedReminderViewDelegate { typealias Section = HomeViewModel.Section - typealias Item = ConversationCell.ViewModel + typealias Item = SessionThreadViewModel private let viewModel: HomeViewModel = HomeViewModel() private var dataChangeObservable: DatabaseCancellable? @@ -55,7 +55,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve ) result.showsVerticalScrollIndicator = false result.register(view: MessageRequestsCell.self) - result.register(view: ConversationCell.Full.self) + result.register(view: FullConversationCell.self) result.dataSource = self result.delegate = self @@ -118,6 +118,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve } updateNavBarButtons() setUpNavBarSessionHeading() + // Recovery phrase reminder let hasViewedSeed = UserDefaults.standard[.hasViewedSeed] if !hasViewedSeed { @@ -355,7 +356,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve return cell case .threads: - let cell: ConversationCell.Full = tableView.dequeue(type: ConversationCell.Full.self, for: indexPath) + let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath) cell.update(with: section.elements[indexPath.row]) return cell } @@ -401,7 +402,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve return [hide] case .threads: - let cellViewModel: ConversationCell.ViewModel = section.elements[indexPath.row] + let cellViewModel: SessionThreadViewModel = section.elements[indexPath.row] let delete: UITableViewRowAction = UITableViewRowAction( style: .destructive, title: "TXT_DELETE_TITLE".localized() diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 0ad4fd760..e309e8076 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -12,7 +12,7 @@ public class HomeViewModel { } /// This value is the current state of the view - public private(set) var viewData: [ArraySection] = [] + public private(set) var viewData: [ArraySection] = [] /// 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 @@ -20,7 +20,7 @@ public class HomeViewModel { /// **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 public lazy var observableViewData = ValueObservation - .trackingConstantRegion { db -> [ArraySection] in + .trackingConstantRegion { db -> [ArraySection] in let userPublicKey: String = getUserHexEncodedPublicKey(db) let unreadMessageRequestCount: Int = try SessionThread .filter(SessionThread.isMessageRequest(userPublicKey: userPublicKey)) @@ -40,7 +40,7 @@ public class HomeViewModel { // If there are no unread message requests then hide the message request banner (finalUnreadMessageRequestCount == 0 ? nil : - ConversationCell.ViewModel( + SessionThreadViewModel( unreadCount: UInt(finalUnreadMessageRequestCount) ) ) @@ -48,7 +48,7 @@ public class HomeViewModel { ), ArraySection( model: .threads, - elements: try ConversationCell.ViewModel + elements: try SessionThreadViewModel .homeQuery(userPublicKey: userPublicKey) .fetchAll(db) ) @@ -58,7 +58,7 @@ public class HomeViewModel { // MARK: - Functions - public func updateData(_ updatedData: [ArraySection]) { + public func updateData(_ updatedData: [ArraySection]) { self.viewData = updatedData } } diff --git a/Session/Home/Message Requests/MessageRequestsViewController.swift b/Session/Home/Message Requests/MessageRequestsViewController.swift index a2c6e0efd..4773b50aa 100644 --- a/Session/Home/Message Requests/MessageRequestsViewController.swift +++ b/Session/Home/Message Requests/MessageRequestsViewController.swift @@ -19,7 +19,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat result.translatesAutoresizingMaskIntoConstraints = false result.backgroundColor = .clear result.separatorStyle = .none - result.register(view: ConversationCell.Full.self) + result.register(view: FullConversationCell.self) result.dataSource = self result.delegate = self @@ -171,7 +171,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat ) } - private func handleUpdates(_ updatedViewData: [ConversationCell.ViewModel]) { + 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 { @@ -214,7 +214,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell: ConversationCell.Full = tableView.dequeue(type: ConversationCell.Full.self, for: indexPath) + let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath) cell.update(with: viewModel.viewData[indexPath.row]) return cell } diff --git a/Session/Home/Message Requests/MessageRequestsViewModel.swift b/Session/Home/Message Requests/MessageRequestsViewModel.swift index 56f845f16..9688d1c71 100644 --- a/Session/Home/Message Requests/MessageRequestsViewModel.swift +++ b/Session/Home/Message Requests/MessageRequestsViewModel.swift @@ -7,7 +7,7 @@ import SignalUtilitiesKit public class MessageRequestsViewModel { /// This value is the current state of the view - public private(set) var viewData: [ConversationCell.ViewModel] = [] + 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 @@ -15,10 +15,10 @@ public class MessageRequestsViewModel { /// **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 public lazy var observableViewData = ValueObservation - .trackingConstantRegion { db -> [ConversationCell.ViewModel] in + .trackingConstantRegion { db -> [SessionThreadViewModel] in let userPublicKey: String = getUserHexEncodedPublicKey(db) - return try ConversationCell.ViewModel + return try SessionThreadViewModel .messageRequestsQuery(userPublicKey: userPublicKey) .fetchAll(db) } @@ -26,7 +26,7 @@ public class MessageRequestsViewModel { // MARK: - Functions - public func updateData(_ updatedData: [ConversationCell.ViewModel]) { + public func updateData(_ updatedData: [SessionThreadViewModel]) { self.viewData = updatedData } } diff --git a/Session/Home/Views/MessageRequestsCell.swift b/Session/Home/Views/MessageRequestsCell.swift index c2807e1d6..b2b72f799 100644 --- a/Session/Home/Views/MessageRequestsCell.swift +++ b/Session/Home/Views/MessageRequestsCell.swift @@ -60,7 +60,7 @@ class MessageRequestsCell: UITableViewCell { result.translatesAutoresizingMaskIntoConstraints = false result.clipsToBounds = true result.backgroundColor = Colors.text.withAlphaComponent(Values.veryLowOpacity) - result.layer.cornerRadius = (ConversationCell.Full.unreadCountViewSize / 2) + result.layer.cornerRadius = (FullConversationCell.unreadCountViewSize / 2) return result }() @@ -115,8 +115,8 @@ class MessageRequestsCell: UITableViewCell { unreadCountView.leftAnchor.constraint(equalTo: titleLabel.rightAnchor, constant: (Values.smallSpacing / 2)), unreadCountView.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), - unreadCountView.widthAnchor.constraint(equalToConstant: ConversationCell.Full.unreadCountViewSize), - unreadCountView.heightAnchor.constraint(equalToConstant: ConversationCell.Full.unreadCountViewSize), + unreadCountView.widthAnchor.constraint(equalToConstant: FullConversationCell.unreadCountViewSize), + unreadCountView.heightAnchor.constraint(equalToConstant: FullConversationCell.unreadCountViewSize), unreadCountLabel.topAnchor.constraint(equalTo: unreadCountView.topAnchor), unreadCountLabel.leftAnchor.constraint(equalTo: unreadCountView.leftAnchor), diff --git a/Session/Media Viewing & Editing/MediaPageViewController.swift b/Session/Media Viewing & Editing/MediaPageViewController.swift index 61bddbf34..b02107a0b 100644 --- a/Session/Media Viewing & Editing/MediaPageViewController.swift +++ b/Session/Media Viewing & Editing/MediaPageViewController.swift @@ -386,8 +386,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou // Start observing for data changes dataChangeObservable = GRDBStorage.shared.start( viewModel.observableAlbumData, - onError: { error in - }, + onError: { _ in }, onChange: { [weak self] albumData in // The defaul scheduler emits changes on the main thread self?.handleUpdates(albumData) diff --git a/Session/Meta/Signal-Bridging-Header.h b/Session/Meta/Signal-Bridging-Header.h index 6cdb5eb57..01ad3a46c 100644 --- a/Session/Meta/Signal-Bridging-Header.h +++ b/Session/Meta/Signal-Bridging-Header.h @@ -9,7 +9,6 @@ // Separate iOS Frameworks from other imports. #import "AvatarViewHelper.h" #import "AVAudioSession+OWS.h" -#import "DateUtil.h" #import "NotificationSettingsViewController.h" #import "OWSAnyTouchGestureRecognizer.h" #import "OWSAudioPlayer.h" diff --git a/Session/Notifications/UserNotificationsAdaptee.swift b/Session/Notifications/UserNotificationsAdaptee.swift index 23349fb82..c7fd88113 100644 --- a/Session/Notifications/UserNotificationsAdaptee.swift +++ b/Session/Notifications/UserNotificationsAdaptee.swift @@ -192,13 +192,13 @@ extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee { owsFailDebug("threadId was unexpectedly nil") return true } - + guard let conversationViewController = UIApplication.shared.frontmostViewController as? ConversationVC else { return true } - // Show notifications for any *other* thread - return conversationViewController.thread.uniqueId != notificationThreadId + /// Show notifications for any **other** threads + return (conversationViewController.viewModel.threadData.threadId != notificationThreadId) } } @@ -230,16 +230,18 @@ public class UserNotificationActionHandler: NSObject { let userInfo = response.notification.request.content.userInfo switch response.actionIdentifier { - case UNNotificationDefaultActionIdentifier: - Logger.debug("default action") - return try actionHandler.showThread(userInfo: userInfo) - case UNNotificationDismissActionIdentifier: - // TODO - mark as read? - Logger.debug("dismissed notification") - return Promise.value(()) - default: - // proceed - break + case UNNotificationDefaultActionIdentifier: + Logger.debug("default action") + return try actionHandler.showThread(userInfo: userInfo) + + case UNNotificationDismissActionIdentifier: + // TODO - mark as read? + Logger.debug("dismissed notification") + return Promise.value(()) + + default: + // proceed + break } guard let action = UserNotificationConfig.action(identifier: response.actionIdentifier) else { @@ -247,16 +249,18 @@ public class UserNotificationActionHandler: NSObject { } switch action { - case .markAsRead: - return try actionHandler.markAsRead(userInfo: userInfo) - case .reply: - guard let textInputResponse = response as? UNTextInputNotificationResponse else { - throw NotificationError.failDebug("response had unexpected type: \(response)") - } + case .markAsRead: + return try actionHandler.markAsRead(userInfo: userInfo) + + case .reply: + guard let textInputResponse = response as? UNTextInputNotificationResponse else { + throw NotificationError.failDebug("response had unexpected type: \(response)") + } - return try actionHandler.reply(userInfo: userInfo, replyText: textInputResponse.userText) - case .showThread: - return try actionHandler.showThread(userInfo: userInfo) + return try actionHandler.reply(userInfo: userInfo, replyText: textInputResponse.userText) + + case .showThread: + return try actionHandler.showThread(userInfo: userInfo) } } } diff --git a/Session/Shared/FullConversationCell.swift b/Session/Shared/FullConversationCell.swift index a906c9320..d645035d1 100644 --- a/Session/Shared/FullConversationCell.swift +++ b/Session/Shared/FullConversationCell.swift @@ -5,566 +5,564 @@ import SessionUIKit import SignalUtilitiesKit import SessionMessagingKit -public extension ConversationCell { - public final class Full: UITableViewCell { - // MARK: - UI +public final class FullConversationCell: UITableViewCell { + // MARK: - UI + + private let accentLineView: UIView = UIView() + + private lazy var profilePictureView: ProfilePictureView = ProfilePictureView() + + private lazy var displayNameLabel: UILabel = { + let result: UILabel = UILabel() + result.font = .boldSystemFont(ofSize: Values.mediumFontSize) + result.textColor = Colors.text + result.lineBreakMode = .byTruncatingTail - private let accentLineView: UIView = UIView() + return result + }() - private lazy var profilePictureView: ProfilePictureView = ProfilePictureView() - - private lazy var displayNameLabel: UILabel = { - let result: UILabel = UILabel() - result.font = .boldSystemFont(ofSize: Values.mediumFontSize) - result.textColor = Colors.text - result.lineBreakMode = .byTruncatingTail - - return result - }() - - private lazy var unreadCountView: UIView = { - let result: UIView = UIView() - result.backgroundColor = Colors.text.withAlphaComponent(Values.veryLowOpacity) - let size = ConversationCell.Full.unreadCountViewSize - result.set(.width, greaterThanOrEqualTo: size) - result.set(.height, to: size) - result.layer.masksToBounds = true - result.layer.cornerRadius = (size / 2) - - return result - }() - - private lazy var unreadCountLabel: UILabel = { - let result: UILabel = UILabel() - result.font = .boldSystemFont(ofSize: Values.verySmallFontSize) - result.textColor = Colors.text - result.textAlignment = .center - - return result - }() - - private lazy var hasMentionView: UIView = { - let result: UIView = UIView() - result.backgroundColor = Colors.accent - let size = ConversationCell.Full.unreadCountViewSize - result.set(.width, to: size) - result.set(.height, to: size) - result.layer.masksToBounds = true - result.layer.cornerRadius = (size / 2) - - return result - }() - - private lazy var hasMentionLabel: UILabel = { - let result: UILabel = UILabel() - result.font = .boldSystemFont(ofSize: Values.verySmallFontSize) - result.textColor = Colors.text - result.text = "@" - result.textAlignment = .center - - return result - }() - - private lazy var isPinnedIcon: UIImageView = { - let result: UIImageView = UIImageView(image: UIImage(named: "Pin")?.withRenderingMode(.alwaysTemplate)) - result.contentMode = .scaleAspectFit - let size = ConversationCell.Full.unreadCountViewSize - result.set(.width, to: size) - result.set(.height, to: size) - result.tintColor = Colors.pinIcon - result.layer.masksToBounds = true - - return result - }() - - private lazy var timestampLabel: UILabel = { - let result: UILabel = UILabel() - result.font = .systemFont(ofSize: Values.smallFontSize) - result.textColor = Colors.text - result.lineBreakMode = .byTruncatingTail - result.alpha = Values.lowOpacity - - return result - }() - - private lazy var snippetLabel: UILabel = { - let result: UILabel = UILabel() - result.font = .systemFont(ofSize: Values.smallFontSize) - result.textColor = Colors.text - result.lineBreakMode = .byTruncatingTail - - return result - }() - - private lazy var typingIndicatorView = TypingIndicatorView() - - private lazy var statusIndicatorView: UIImageView = { - let result: UIImageView = UIImageView() - result.contentMode = .scaleAspectFit - result.layer.cornerRadius = (ConversationCell.Full.statusIndicatorSize / 2) - result.layer.masksToBounds = true - - return result - }() - - private lazy var topLabelStackView: UIStackView = { - let result: UIStackView = UIStackView() - result.axis = .horizontal - result.alignment = .center - result.spacing = Values.smallSpacing / 2 // Effectively Values.smallSpacing because there'll be spacing before and after the invisible spacer - - return result - }() - - private lazy var bottomLabelStackView: UIStackView = { - let result: UIStackView = UIStackView() - result.axis = .horizontal - result.alignment = .center - result.spacing = Values.smallSpacing / 2 // Effectively Values.smallSpacing because there'll be spacing before and after the invisible spacer - - return result - }() - - // MARK: Settings - - public static let unreadCountViewSize: CGFloat = 20 - private static let statusIndicatorSize: CGFloat = 14 - - // MARK: - Initialization + private lazy var unreadCountView: UIView = { + let result: UIView = UIView() + result.backgroundColor = Colors.text.withAlphaComponent(Values.veryLowOpacity) + let size = FullConversationCell.unreadCountViewSize + result.set(.width, greaterThanOrEqualTo: size) + result.set(.height, to: size) + result.layer.masksToBounds = true + result.layer.cornerRadius = (size / 2) - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - setUpViewHierarchy() - } + return result + }() - required init?(coder: NSCoder) { - super.init(coder: coder) - setUpViewHierarchy() - } + private lazy var unreadCountLabel: UILabel = { + let result: UILabel = UILabel() + result.font = .boldSystemFont(ofSize: Values.verySmallFontSize) + result.textColor = Colors.text + result.textAlignment = .center + + return result + }() - private func setUpViewHierarchy() { - let cellHeight: CGFloat = 68 - - // Background color - backgroundColor = Colors.cellBackground - - // Highlight color - let selectedBackgroundView = UIView() - selectedBackgroundView.backgroundColor = Colors.cellSelected - self.selectedBackgroundView = selectedBackgroundView - - // Accent line view - accentLineView.set(.width, to: Values.accentLineThickness) - accentLineView.set(.height, to: cellHeight) - - // Profile picture view - let profilePictureViewSize = Values.mediumProfilePictureSize - profilePictureView.set(.width, to: profilePictureViewSize) - profilePictureView.set(.height, to: profilePictureViewSize) - profilePictureView.size = profilePictureViewSize - - // Unread count view - unreadCountView.addSubview(unreadCountLabel) - unreadCountLabel.pin([ VerticalEdge.top, VerticalEdge.bottom ], to: unreadCountView) - unreadCountView.pin(.leading, to: .leading, of: unreadCountLabel, withInset: -4) - unreadCountView.pin(.trailing, to: .trailing, of: unreadCountLabel, withInset: 4) - - // Has mention view - hasMentionView.addSubview(hasMentionLabel) - hasMentionLabel.pin(to: hasMentionView) - - // Label stack view - let topLabelSpacer = UIView.hStretchingSpacer() - [ displayNameLabel, isPinnedIcon, unreadCountView, hasMentionView, topLabelSpacer, timestampLabel ].forEach{ view in - topLabelStackView.addArrangedSubview(view) - } - - let snippetLabelContainer = UIView() - snippetLabelContainer.addSubview(snippetLabel) - snippetLabelContainer.addSubview(typingIndicatorView) - - let bottomLabelSpacer = UIView.hStretchingSpacer() - [ snippetLabelContainer, bottomLabelSpacer, statusIndicatorView ].forEach{ view in - bottomLabelStackView.addArrangedSubview(view) - } - - let labelContainerView = UIStackView(arrangedSubviews: [ topLabelStackView, bottomLabelStackView ]) - labelContainerView.axis = .vertical - labelContainerView.alignment = .leading - labelContainerView.spacing = 6 - labelContainerView.isUserInteractionEnabled = false - - // Main stack view - let stackView = UIStackView(arrangedSubviews: [ accentLineView, profilePictureView, labelContainerView ]) - stackView.axis = .horizontal - stackView.alignment = .center - stackView.spacing = Values.mediumSpacing - contentView.addSubview(stackView) - - // Constraints - accentLineView.pin(.top, to: .top, of: contentView) - accentLineView.pin(.bottom, to: .bottom, of: contentView) - timestampLabel.setContentCompressionResistancePriority(.required, for: NSLayoutConstraint.Axis.horizontal) - - // HACK: The six lines below are part of a workaround for a weird layout bug - topLabelStackView.set(.width, to: UIScreen.main.bounds.width - Values.accentLineThickness - profilePictureViewSize - 3 * Values.mediumSpacing) - topLabelStackView.set(.height, to: 20) - topLabelSpacer.set(.height, to: 20) - - bottomLabelStackView.set(.width, to: UIScreen.main.bounds.width - Values.accentLineThickness - profilePictureViewSize - 3 * Values.mediumSpacing) - bottomLabelStackView.set(.height, to: 18) - bottomLabelSpacer.set(.height, to: 18) - - statusIndicatorView.set(.width, to: ConversationCell.Full.statusIndicatorSize) - statusIndicatorView.set(.height, to: ConversationCell.Full.statusIndicatorSize) - - snippetLabel.pin(to: snippetLabelContainer) - - typingIndicatorView.pin(.leading, to: .leading, of: snippetLabelContainer) - typingIndicatorView.centerYAnchor.constraint(equalTo: snippetLabel.centerYAnchor).isActive = true - - stackView.pin(.leading, to: .leading, of: contentView) - stackView.pin(.top, to: .top, of: contentView) - - // HACK: The two lines below are part of a workaround for a weird layout bug - stackView.set(.width, to: UIScreen.main.bounds.width - Values.mediumSpacing) - stackView.set(.height, to: cellHeight) + private lazy var hasMentionView: UIView = { + let result: UIView = UIView() + result.backgroundColor = Colors.accent + let size = FullConversationCell.unreadCountViewSize + result.set(.width, to: size) + result.set(.height, to: size) + result.layer.masksToBounds = true + result.layer.cornerRadius = (size / 2) + + return result + }() + + private lazy var hasMentionLabel: UILabel = { + let result: UILabel = UILabel() + result.font = .boldSystemFont(ofSize: Values.verySmallFontSize) + result.textColor = Colors.text + result.text = "@" + result.textAlignment = .center + + return result + }() + + private lazy var isPinnedIcon: UIImageView = { + let result: UIImageView = UIImageView(image: UIImage(named: "Pin")?.withRenderingMode(.alwaysTemplate)) + result.contentMode = .scaleAspectFit + let size = FullConversationCell.unreadCountViewSize + result.set(.width, to: size) + result.set(.height, to: size) + result.tintColor = Colors.pinIcon + result.layer.masksToBounds = true + + return result + }() + + private lazy var timestampLabel: UILabel = { + let result: UILabel = UILabel() + result.font = .systemFont(ofSize: Values.smallFontSize) + result.textColor = Colors.text + result.lineBreakMode = .byTruncatingTail + result.alpha = Values.lowOpacity + + return result + }() + + private lazy var snippetLabel: UILabel = { + let result: UILabel = UILabel() + result.font = .systemFont(ofSize: Values.smallFontSize) + result.textColor = Colors.text + result.lineBreakMode = .byTruncatingTail + + return result + }() + + private lazy var typingIndicatorView = TypingIndicatorView() + + private lazy var statusIndicatorView: UIImageView = { + let result: UIImageView = UIImageView() + result.contentMode = .scaleAspectFit + result.layer.cornerRadius = (FullConversationCell.statusIndicatorSize / 2) + result.layer.masksToBounds = true + + return result + }() + + private lazy var topLabelStackView: UIStackView = { + let result: UIStackView = UIStackView() + result.axis = .horizontal + result.alignment = .center + result.spacing = Values.smallSpacing / 2 // Effectively Values.smallSpacing because there'll be spacing before and after the invisible spacer + + return result + }() + + private lazy var bottomLabelStackView: UIStackView = { + let result: UIStackView = UIStackView() + result.axis = .horizontal + result.alignment = .center + result.spacing = Values.smallSpacing / 2 // Effectively Values.smallSpacing because there'll be spacing before and after the invisible spacer + + return result + }() + + // MARK: Settings + + public static let unreadCountViewSize: CGFloat = 20 + private static let statusIndicatorSize: CGFloat = 14 + + // MARK: - Initialization + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setUpViewHierarchy() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setUpViewHierarchy() + } + + private func setUpViewHierarchy() { + let cellHeight: CGFloat = 68 + + // Background color + backgroundColor = Colors.cellBackground + + // Highlight color + let selectedBackgroundView = UIView() + selectedBackgroundView.backgroundColor = Colors.cellSelected + self.selectedBackgroundView = selectedBackgroundView + + // Accent line view + accentLineView.set(.width, to: Values.accentLineThickness) + accentLineView.set(.height, to: cellHeight) + + // Profile picture view + let profilePictureViewSize = Values.mediumProfilePictureSize + profilePictureView.set(.width, to: profilePictureViewSize) + profilePictureView.set(.height, to: profilePictureViewSize) + profilePictureView.size = profilePictureViewSize + + // Unread count view + unreadCountView.addSubview(unreadCountLabel) + unreadCountLabel.pin([ VerticalEdge.top, VerticalEdge.bottom ], to: unreadCountView) + unreadCountView.pin(.leading, to: .leading, of: unreadCountLabel, withInset: -4) + unreadCountView.pin(.trailing, to: .trailing, of: unreadCountLabel, withInset: 4) + + // Has mention view + hasMentionView.addSubview(hasMentionLabel) + hasMentionLabel.pin(to: hasMentionView) + + // Label stack view + let topLabelSpacer = UIView.hStretchingSpacer() + [ displayNameLabel, isPinnedIcon, unreadCountView, hasMentionView, topLabelSpacer, timestampLabel ].forEach{ view in + topLabelStackView.addArrangedSubview(view) } - // MARK: - Content + let snippetLabelContainer = UIView() + snippetLabelContainer.addSubview(snippetLabel) + snippetLabelContainer.addSubview(typingIndicatorView) - // MARK: --Search Results - - public func updateForMessageSearchResult(with cellViewModel: ViewModel, searchText: String) { - profilePictureView.update( - publicKey: cellViewModel.threadId, - profile: cellViewModel.profile, - additionalProfile: cellViewModel.additionalProfile, - threadVariant: cellViewModel.threadVariant, - openGroupProfilePicture: cellViewModel.openGroupProfilePictureData.map { UIImage(data: $0) }, - useFallbackPicture: (cellViewModel.threadVariant == .openGroup && cellViewModel.openGroupProfilePictureData == nil) - ) - - isPinnedIcon.isHidden = true - unreadCountView.isHidden = true - hasMentionView.isHidden = true - displayNameLabel.attributedText = NSMutableAttributedString( - string: cellViewModel.displayName, - attributes: [ .foregroundColor: Colors.text] - ) - timestampLabel.isHidden = false - timestampLabel.text = DateUtil.formatDate(forDisplay: cellViewModel.lastInteractionDate) - bottomLabelStackView.isHidden = false - snippetLabel.attributedText = getHighlightedSnippet( - content: Interaction.previewText( - variant: (cellViewModel.interactionVariant ?? .standardIncoming), - body: cellViewModel.interactionBody, - authorDisplayName: cellViewModel.authorName(for: .contact), - attachmentDescriptionInfo: cellViewModel.interactionAttachmentDescriptionInfo, - attachmentCount: cellViewModel.interactionAttachmentCount, - isOpenGroupInvitation: (cellViewModel.interactionIsOpenGroupInvitation == true) - ), - authorName: (cellViewModel.authorId != cellViewModel.currentUserPublicKey ? - cellViewModel.authorName(for: .contact) : - nil - ), - searchText: searchText.lowercased(), - fontSize: Values.smallFontSize - ) + let bottomLabelSpacer = UIView.hStretchingSpacer() + [ snippetLabelContainer, bottomLabelSpacer, statusIndicatorView ].forEach{ view in + bottomLabelStackView.addArrangedSubview(view) } - public func updateForContactAndGroupSearchResult(with cellViewModel: ViewModel, searchText: String) { - profilePictureView.update( - publicKey: cellViewModel.threadId, - profile: cellViewModel.profile, - additionalProfile: cellViewModel.additionalProfile, - threadVariant: cellViewModel.threadVariant, - openGroupProfilePicture: cellViewModel.openGroupProfilePictureData.map { UIImage(data: $0) }, - useFallbackPicture: (cellViewModel.threadVariant == .openGroup && cellViewModel.openGroupProfilePictureData == nil) - ) - - isPinnedIcon.isHidden = true - unreadCountView.isHidden = true - hasMentionView.isHidden = true - timestampLabel.isHidden = true - displayNameLabel.attributedText = getHighlightedSnippet( - content: cellViewModel.displayName, - searchText: searchText.lowercased(), - fontSize: Values.mediumFontSize - ) - - switch cellViewModel.threadVariant { - case .contact, .openGroup: bottomLabelStackView.isHidden = true - - case .closedGroup: - bottomLabelStackView.isHidden = (cellViewModel.threadMemberNames ?? "").isEmpty - snippetLabel.attributedText = getHighlightedSnippet( - content: (cellViewModel.threadMemberNames ?? ""), - searchText: searchText.lowercased(), - fontSize: Values.smallFontSize - ) - } - } - - // MARK: --Standard + let labelContainerView = UIStackView(arrangedSubviews: [ topLabelStackView, bottomLabelStackView ]) + labelContainerView.axis = .vertical + labelContainerView.alignment = .leading + labelContainerView.spacing = 6 + labelContainerView.isUserInteractionEnabled = false - public func update(with cellViewModel: ViewModel) { - let unreadCount: UInt = (cellViewModel.threadUnreadCount ?? 0) - backgroundColor = (cellViewModel.threadIsPinned ? Colors.cellPinned : Colors.cellBackground) - - if cellViewModel.threadIsBlocked == true { - accentLineView.backgroundColor = Colors.destructive - accentLineView.alpha = 1 - } - else { - accentLineView.backgroundColor = Colors.accent - accentLineView.alpha = (unreadCount > 0 ? 1 : 0.0001) // Setting the alpha to exactly 0 causes an issue on iOS 12 - } - - isPinnedIcon.isHidden = !cellViewModel.threadIsPinned - unreadCountView.isHidden = (unreadCount <= 0) - unreadCountLabel.text = (unreadCount < 10000 ? "\(unreadCount)" : "9999+") - unreadCountLabel.font = .boldSystemFont( - ofSize: (unreadCount < 10000 ? Values.verySmallFontSize : 8) - ) - hasMentionView.isHidden = !( - ((cellViewModel.threadUnreadMentionCount ?? 0) > 0) && - (cellViewModel.threadVariant == .closedGroup || cellViewModel.threadVariant == .openGroup) - ) - profilePictureView.update( - publicKey: cellViewModel.threadId, - profile: cellViewModel.profile, - additionalProfile: cellViewModel.additionalProfile, - threadVariant: cellViewModel.threadVariant, - openGroupProfilePicture: cellViewModel.openGroupProfilePictureData.map { UIImage(data: $0) }, - useFallbackPicture: ( - cellViewModel.threadVariant == .openGroup && - cellViewModel.openGroupProfilePictureData == nil + // Main stack view + let stackView = UIStackView(arrangedSubviews: [ accentLineView, profilePictureView, labelContainerView ]) + stackView.axis = .horizontal + stackView.alignment = .center + stackView.spacing = Values.mediumSpacing + contentView.addSubview(stackView) + + // Constraints + accentLineView.pin(.top, to: .top, of: contentView) + accentLineView.pin(.bottom, to: .bottom, of: contentView) + timestampLabel.setContentCompressionResistancePriority(.required, for: NSLayoutConstraint.Axis.horizontal) + + // HACK: The six lines below are part of a workaround for a weird layout bug + topLabelStackView.set(.width, to: UIScreen.main.bounds.width - Values.accentLineThickness - profilePictureViewSize - 3 * Values.mediumSpacing) + topLabelStackView.set(.height, to: 20) + topLabelSpacer.set(.height, to: 20) + + bottomLabelStackView.set(.width, to: UIScreen.main.bounds.width - Values.accentLineThickness - profilePictureViewSize - 3 * Values.mediumSpacing) + bottomLabelStackView.set(.height, to: 18) + bottomLabelSpacer.set(.height, to: 18) + + statusIndicatorView.set(.width, to: FullConversationCell.statusIndicatorSize) + statusIndicatorView.set(.height, to: FullConversationCell.statusIndicatorSize) + + snippetLabel.pin(to: snippetLabelContainer) + + typingIndicatorView.pin(.leading, to: .leading, of: snippetLabelContainer) + typingIndicatorView.centerYAnchor.constraint(equalTo: snippetLabel.centerYAnchor).isActive = true + + stackView.pin(.leading, to: .leading, of: contentView) + stackView.pin(.top, to: .top, of: contentView) + + // HACK: The two lines below are part of a workaround for a weird layout bug + stackView.set(.width, to: UIScreen.main.bounds.width - Values.mediumSpacing) + stackView.set(.height, to: cellHeight) + } + + // MARK: - Content + + // MARK: --Search Results + + public func updateForMessageSearchResult(with cellViewModel: SessionThreadViewModel, searchText: String) { + profilePictureView.update( + publicKey: cellViewModel.threadId, + profile: cellViewModel.profile, + additionalProfile: cellViewModel.additionalProfile, + threadVariant: cellViewModel.threadVariant, + openGroupProfilePicture: cellViewModel.openGroupProfilePictureData.map { UIImage(data: $0) }, + useFallbackPicture: (cellViewModel.threadVariant == .openGroup && cellViewModel.openGroupProfilePictureData == nil) + ) + + isPinnedIcon.isHidden = true + unreadCountView.isHidden = true + hasMentionView.isHidden = true + displayNameLabel.attributedText = NSMutableAttributedString( + string: cellViewModel.displayName, + attributes: [ .foregroundColor: Colors.text] + ) + timestampLabel.isHidden = false + timestampLabel.text = cellViewModel.lastInteractionDate.formattedForDisplay + bottomLabelStackView.isHidden = false + snippetLabel.attributedText = getHighlightedSnippet( + content: Interaction.previewText( + variant: (cellViewModel.interactionVariant ?? .standardIncoming), + body: cellViewModel.interactionBody, + authorDisplayName: cellViewModel.authorName(for: .contact), + attachmentDescriptionInfo: cellViewModel.interactionAttachmentDescriptionInfo, + attachmentCount: cellViewModel.interactionAttachmentCount, + isOpenGroupInvitation: (cellViewModel.interactionIsOpenGroupInvitation == true) + ), + authorName: (cellViewModel.authorId != cellViewModel.currentUserPublicKey ? + cellViewModel.authorName(for: .contact) : + nil + ), + searchText: searchText.lowercased(), + fontSize: Values.smallFontSize + ) + } + + public func updateForContactAndGroupSearchResult(with cellViewModel: SessionThreadViewModel, searchText: String) { + profilePictureView.update( + publicKey: cellViewModel.threadId, + profile: cellViewModel.profile, + additionalProfile: cellViewModel.additionalProfile, + threadVariant: cellViewModel.threadVariant, + openGroupProfilePicture: cellViewModel.openGroupProfilePictureData.map { UIImage(data: $0) }, + useFallbackPicture: (cellViewModel.threadVariant == .openGroup && cellViewModel.openGroupProfilePictureData == nil) + ) + + isPinnedIcon.isHidden = true + unreadCountView.isHidden = true + hasMentionView.isHidden = true + timestampLabel.isHidden = true + displayNameLabel.attributedText = getHighlightedSnippet( + content: cellViewModel.displayName, + searchText: searchText.lowercased(), + fontSize: Values.mediumFontSize + ) + + switch cellViewModel.threadVariant { + case .contact, .openGroup: bottomLabelStackView.isHidden = true + + case .closedGroup: + bottomLabelStackView.isHidden = (cellViewModel.threadMemberNames ?? "").isEmpty + snippetLabel.attributedText = getHighlightedSnippet( + content: (cellViewModel.threadMemberNames ?? ""), + searchText: searchText.lowercased(), + fontSize: Values.smallFontSize ) - ) - displayNameLabel.text = cellViewModel.displayName - timestampLabel.text = DateUtil.formatDate(forDisplay: cellViewModel.lastInteractionDate) - - if cellViewModel.threadContactIsTyping == true { - snippetLabel.text = "" - typingIndicatorView.isHidden = false - typingIndicatorView.startAnimation() - } - else { - snippetLabel.attributedText = getSnippet(cellViewModel: cellViewModel) - typingIndicatorView.isHidden = true - typingIndicatorView.stopAnimation() - } - - statusIndicatorView.backgroundColor = nil - - switch (cellViewModel.interactionVariant, cellViewModel.interactionState) { - case (.standardOutgoing, .sending): - statusIndicatorView.image = #imageLiteral(resourceName: "CircleDotDotDot").withRenderingMode(.alwaysTemplate) - statusIndicatorView.tintColor = Colors.text - statusIndicatorView.isHidden = false - - case (.standardOutgoing, .sent): - statusIndicatorView.image = #imageLiteral(resourceName: "CircleCheck").withRenderingMode(.alwaysTemplate) - statusIndicatorView.tintColor = Colors.text - statusIndicatorView.isHidden = false - - case (.standardOutgoing, .failed): - statusIndicatorView.image = #imageLiteral(resourceName: "message_status_failed").withRenderingMode(.alwaysTemplate) - statusIndicatorView.tintColor = Colors.destructive - statusIndicatorView.isHidden = false - - default: - statusIndicatorView.isHidden = false - } + } + } + + // MARK: --Standard + + public func update(with cellViewModel: SessionThreadViewModel) { + let unreadCount: UInt = (cellViewModel.threadUnreadCount ?? 0) + backgroundColor = (cellViewModel.threadIsPinned ? Colors.cellPinned : Colors.cellBackground) + + if cellViewModel.threadIsBlocked == true { + accentLineView.backgroundColor = Colors.destructive + accentLineView.alpha = 1 + } + else { + accentLineView.backgroundColor = Colors.accent + accentLineView.alpha = (unreadCount > 0 ? 1 : 0.0001) // Setting the alpha to exactly 0 causes an issue on iOS 12 } - // MARK: - Snippet generation - - private func getSnippet(cellViewModel: ViewModel) -> NSMutableAttributedString { - let result = NSMutableAttributedString() - - if Date().timeIntervalSince1970 < (cellViewModel.threadMutedUntilTimestamp ?? 0) { - result.append(NSAttributedString( - string: "\u{e067} ", - attributes: [ - .font: UIFont.ows_elegantIconsFont(10), - .foregroundColor :Colors.unimportant - ] - )) - } - else if cellViewModel.threadOnlyNotifyForMentions == true { - let imageAttachment = NSTextAttachment() - imageAttachment.image = UIImage(named: "NotifyMentions.png")?.asTintedImage(color: Colors.unimportant) - imageAttachment.bounds = CGRect(x: 0, y: -2, width: Values.smallFontSize, height: Values.smallFontSize) - - let imageString = NSAttributedString(attachment: imageAttachment) - result.append(imageString) - result.append(NSAttributedString( - string: " ", - attributes: [ - .font: UIFont.ows_elegantIconsFont(10), - .foregroundColor: Colors.unimportant - ] - )) - } - - let font: UIFont = ((cellViewModel.threadUnreadCount ?? 0) > 0 ? - .boldSystemFont(ofSize: Values.smallFontSize) : - .systemFont(ofSize: Values.smallFontSize) + isPinnedIcon.isHidden = !cellViewModel.threadIsPinned + unreadCountView.isHidden = (unreadCount <= 0) + unreadCountLabel.text = (unreadCount < 10000 ? "\(unreadCount)" : "9999+") + unreadCountLabel.font = .boldSystemFont( + ofSize: (unreadCount < 10000 ? Values.verySmallFontSize : 8) + ) + hasMentionView.isHidden = !( + ((cellViewModel.threadUnreadMentionCount ?? 0) > 0) && + (cellViewModel.threadVariant == .closedGroup || cellViewModel.threadVariant == .openGroup) + ) + profilePictureView.update( + publicKey: cellViewModel.threadId, + profile: cellViewModel.profile, + additionalProfile: cellViewModel.additionalProfile, + threadVariant: cellViewModel.threadVariant, + openGroupProfilePicture: cellViewModel.openGroupProfilePictureData.map { UIImage(data: $0) }, + useFallbackPicture: ( + cellViewModel.threadVariant == .openGroup && + cellViewModel.openGroupProfilePictureData == nil ) - - if cellViewModel.threadVariant == .closedGroup || cellViewModel.threadVariant == .openGroup { - let authorName: String = cellViewModel.authorName(for: cellViewModel.threadVariant) + ) + displayNameLabel.text = cellViewModel.displayName + timestampLabel.text = cellViewModel.lastInteractionDate.formattedForDisplay + + if cellViewModel.threadContactIsTyping == true { + snippetLabel.text = "" + typingIndicatorView.isHidden = false + typingIndicatorView.startAnimation() + } + else { + snippetLabel.attributedText = getSnippet(cellViewModel: cellViewModel) + typingIndicatorView.isHidden = true + typingIndicatorView.stopAnimation() + } + + statusIndicatorView.backgroundColor = nil + + switch (cellViewModel.interactionVariant, cellViewModel.interactionState) { + case (.standardOutgoing, .sending): + statusIndicatorView.image = #imageLiteral(resourceName: "CircleDotDotDot").withRenderingMode(.alwaysTemplate) + statusIndicatorView.tintColor = Colors.text + statusIndicatorView.isHidden = false - result.append(NSAttributedString( - string: "\(authorName): ", - attributes: [ - .font: font, - .foregroundColor: Colors.text - ] - )) - } + case (.standardOutgoing, .sent): + statusIndicatorView.image = #imageLiteral(resourceName: "CircleCheck").withRenderingMode(.alwaysTemplate) + statusIndicatorView.tintColor = Colors.text + statusIndicatorView.isHidden = false + + case (.standardOutgoing, .failed): + statusIndicatorView.image = #imageLiteral(resourceName: "message_status_failed").withRenderingMode(.alwaysTemplate) + statusIndicatorView.tintColor = Colors.destructive + statusIndicatorView.isHidden = false + + default: + statusIndicatorView.isHidden = false + } + } + + // MARK: - Snippet generation + + private func getSnippet(cellViewModel: SessionThreadViewModel) -> NSMutableAttributedString { + let result = NSMutableAttributedString() + + if Date().timeIntervalSince1970 < (cellViewModel.threadMutedUntilTimestamp ?? 0) { + result.append(NSAttributedString( + string: "\u{e067} ", + attributes: [ + .font: UIFont.ows_elegantIconsFont(10), + .foregroundColor :Colors.unimportant + ] + )) + } + else if cellViewModel.threadOnlyNotifyForMentions == true { + let imageAttachment = NSTextAttachment() + imageAttachment.image = UIImage(named: "NotifyMentions.png")?.asTintedImage(color: Colors.unimportant) + imageAttachment.bounds = CGRect(x: 0, y: -2, width: Values.smallFontSize, height: Values.smallFontSize) + + let imageString = NSAttributedString(attachment: imageAttachment) + result.append(imageString) + result.append(NSAttributedString( + string: " ", + attributes: [ + .font: UIFont.ows_elegantIconsFont(10), + .foregroundColor: Colors.unimportant + ] + )) + } + + let font: UIFont = ((cellViewModel.threadUnreadCount ?? 0) > 0 ? + .boldSystemFont(ofSize: Values.smallFontSize) : + .systemFont(ofSize: Values.smallFontSize) + ) + + if cellViewModel.threadVariant == .closedGroup || cellViewModel.threadVariant == .openGroup { + let authorName: String = cellViewModel.authorName(for: cellViewModel.threadVariant) result.append(NSAttributedString( - string: MentionUtilities.highlightMentions( - in: Interaction.previewText( - variant: (cellViewModel.interactionVariant ?? .standardIncoming), - body: cellViewModel.interactionBody, - authorDisplayName: cellViewModel.authorName(for: cellViewModel.threadVariant), - attachmentDescriptionInfo: cellViewModel.interactionAttachmentDescriptionInfo, - attachmentCount: cellViewModel.interactionAttachmentCount, - isOpenGroupInvitation: (cellViewModel.interactionIsOpenGroupInvitation == true) - ), - threadVariant: cellViewModel.threadVariant - ), + string: "\(authorName): ", attributes: [ .font: font, .foregroundColor: Colors.text ] )) - - return result } - private func getHighlightedSnippet( - content: String, - authorName: String? = nil, - searchText: String, - fontSize: CGFloat - ) -> NSAttributedString { - guard !content.isEmpty, content != "NOTE_TO_SELF".localized() else { - return NSMutableAttributedString( - string: (authorName != nil && authorName?.isEmpty != true ? - "\(authorName ?? ""): \(content)" : - content - ), + result.append(NSAttributedString( + string: MentionUtilities.highlightMentions( + in: Interaction.previewText( + variant: (cellViewModel.interactionVariant ?? .standardIncoming), + body: cellViewModel.interactionBody, + authorDisplayName: cellViewModel.authorName(for: cellViewModel.threadVariant), + attachmentDescriptionInfo: cellViewModel.interactionAttachmentDescriptionInfo, + attachmentCount: cellViewModel.interactionAttachmentCount, + isOpenGroupInvitation: (cellViewModel.interactionIsOpenGroupInvitation == true) + ), + threadVariant: cellViewModel.threadVariant + ), + attributes: [ + .font: font, + .foregroundColor: Colors.text + ] + )) + + return result + } + + private func getHighlightedSnippet( + content: String, + authorName: String? = nil, + searchText: String, + fontSize: CGFloat + ) -> NSAttributedString { + guard !content.isEmpty, content != "NOTE_TO_SELF".localized() else { + return NSMutableAttributedString( + string: (authorName != nil && authorName?.isEmpty != true ? + "\(authorName ?? ""): \(content)" : + content + ), + attributes: [ .foregroundColor: Colors.text ] + ) + } + + // Replace mentions in the content + // + // Note: The 'threadVariant' is used for profile context but in the search results + // we don't want to include the truncated id as part of the name so we exclude it + let mentionReplacedContent: String = MentionUtilities.highlightMentions( + in: content, + threadVariant: .contact + ) + let result: NSMutableAttributedString = NSMutableAttributedString( + string: mentionReplacedContent, + attributes: [ + .foregroundColor: Colors.text + .withAlphaComponent(Values.lowOpacity) + ] + ) + + // Bold each part of the searh term which matched + let normalizedSnippet: String = mentionReplacedContent.lowercased() + var firstMatchRange: Range? + + SessionThreadViewModel.searchTermParts(searchText) + .map { part -> String in + guard part.hasPrefix("\"") && part.hasSuffix("\"") else { return part } + + return String(part[part.index(after: part.startIndex).. NSAttributedString { + let approxFullWidth: CGFloat = (approxWidth + profilePictureView.size + (Values.mediumSpacing * 3)) + + guard ((bounds.width - approxFullWidth) < 0) else { return content } + + return content.attributedSubstring( + from: NSRange(startOfSnippet.. NSAttributedString? in + guard !authorName.isEmpty else { return nil } + + let authorPrefix: NSAttributedString = NSAttributedString( + string: "\(authorName): ...", attributes: [ .foregroundColor: Colors.text ] ) - } - - // Replace mentions in the content - // - // Note: The 'threadVariant' is used for profile context but in the search results - // we don't want to include the truncated id as part of the name so we exclude it - let mentionReplacedContent: String = MentionUtilities.highlightMentions( - in: content, - threadVariant: .contact - ) - let result: NSMutableAttributedString = NSMutableAttributedString( - string: mentionReplacedContent, - attributes: [ - .foregroundColor: Colors.text - .withAlphaComponent(Values.lowOpacity) - ] - ) - - // Bold each part of the searh term which matched - let normalizedSnippet: String = mentionReplacedContent.lowercased() - var firstMatchRange: Range? - - ConversationCell.ViewModel.searchTermParts(searchText) - .map { part -> String in - guard part.hasPrefix("\"") && part.hasSuffix("\"") else { return part } - - return String(part[part.index(after: part.startIndex).. NSAttributedString { - let approxFullWidth: CGFloat = (approxWidth + profilePictureView.size + (Values.mediumSpacing * 3)) - guard ((bounds.width - approxFullWidth) < 0) else { return content } - - return content.attributedSubstring( - from: NSRange(startOfSnippet.. NSAttributedString? in - guard !authorName.isEmpty else { return nil } - - let authorPrefix: NSAttributedString = NSAttributedString( - string: "\(authorName): ...", - attributes: [ .foregroundColor: Colors.text ] - ) - - return authorPrefix - .appending( - truncatingIfNeeded( - approxWidth: (authorPrefix.size().width + result.size().width), - content: result - ) + return authorPrefix + .appending( + truncatingIfNeeded( + approxWidth: (authorPrefix.size().width + result.size().width), + content: result ) - } - .defaulting( - to: truncatingIfNeeded( - approxWidth: result.size().width, - content: result ) + } + .defaulting( + to: truncatingIfNeeded( + approxWidth: result.size().width, + content: result ) - } + ) } } diff --git a/Session/Utilities/Date+Utilities.swift b/Session/Utilities/Date+Utilities.swift new file mode 100644 index 000000000..5336c20b4 --- /dev/null +++ b/Session/Utilities/Date+Utilities.swift @@ -0,0 +1,89 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +public extension Date { + var formattedForDisplay: String { + let dateNow: Date = Date() + + guard Calendar.current.isDate(self, equalTo: dateNow, toGranularity: .year) else { + // Last year formatter: Nov 11 13:32 am, 2017 + return Date.oldDateFormatter.string(from: self) + } + + guard Calendar.current.isDate(self, equalTo: dateNow, toGranularity: .weekOfYear) else { + // This year formatter: Jun 6 10:12 am + return Date.thisYearFormatter.string(from: self) + } + + guard Calendar.current.isDate(self, equalTo: dateNow, toGranularity: .day) else { + // Day of week formatter: Thu 9:11 pm + return Date.thisWeekFormatter.string(from: self) + } + + guard Calendar.current.isDate(self, equalTo: dateNow, toGranularity: .minute) else { + // Today formatter: 8:32 am + return Date.todayFormatter.string(from: self) + } + + return "DATE_NOW".localized() + } +} + +// MARK: - Formatters + +fileprivate extension Date { + static let oldDateFormatter: DateFormatter = { + let result: DateFormatter = DateFormatter() + result.locale = Locale.current + result.dateStyle = .medium + result.timeStyle = .short + result.doesRelativeDateFormatting = true + + return result + }() + + static let thisYearFormatter: DateFormatter = { + let result: DateFormatter = DateFormatter() + result.locale = Locale.current + + // Jun 6 10:12 am + result.dateFormat = "MMM d \(hourFormat)" + + return result + }() + + static let thisWeekFormatter: DateFormatter = { + let result: DateFormatter = DateFormatter() + result.locale = Locale.current + + // Mon 11:36 pm + result.dateFormat = "EEE \(hourFormat)" + + return result + }() + + static let todayFormatter: DateFormatter = { + let result: DateFormatter = DateFormatter() + result.locale = Locale.current + + // 9:10 am + result.dateFormat = hourFormat + + return result + }() + + static var hourFormat: String { + guard + let format: String = DateFormatter.dateFormat(fromTemplate: "j", options: 0, locale: Locale.current), + format.range(of: "a") != nil + else { + // If we didn't find 'a' then it's 24-hour time + return "HH:mm" + } + + // If we found 'a' in the format then it's 12-hour time + return "h:mm a" + } +} diff --git a/Session/Utilities/DateUtil.h b/Session/Utilities/DateUtil.h deleted file mode 100644 index 1fc506fda..000000000 --- a/Session/Utilities/DateUtil.h +++ /dev/null @@ -1,49 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -NS_ASSUME_NONNULL_BEGIN - -@interface DateUtil : NSObject - -+ (NSDateFormatter *)dateFormatter; -+ (NSDateFormatter *)timeFormatter; -+ (NSDateFormatter *)monthAndDayFormatter; -+ (NSDateFormatter *)shortDayOfWeekFormatter; - -+ (BOOL)dateIsOlderThanToday:(NSDate *)date; -+ (BOOL)dateIsOlderThanOneWeek:(NSDate *)date; -+ (BOOL)dateIsToday:(NSDate *)date; -+ (BOOL)dateIsThisYear:(NSDate *)date; -+ (BOOL)dateIsYesterday:(NSDate *)date; - -+ (NSString *)formatPastTimestampRelativeToNow:(uint64_t)pastTimestamp - NS_SWIFT_NAME(formatPastTimestampRelativeToNow(_:)); - -+ (NSString *)formatTimestampShort:(uint64_t)timestamp; -+ (NSString *)formatDateShort:(NSDate *)date; - -+ (NSString *)formatTimestampAsTime:(uint64_t)timestamp; -+ (NSString *)formatDateAsTime:(NSDate *)date; - -+ (NSString *)formatMessageTimestamp:(uint64_t)timestamp; - -+ (BOOL)isTimestampFromLastHour:(uint64_t)timestamp; -// These two "exemplary" values can be used by views to measure -// the likely size for recent values formatted using isTimestampFromLastHour:. -+ (NSString *)exemplaryNowTimeFormat; -+ (NSString *)exemplaryMinutesTimeFormat; - -+ (NSString *)formatDateForDisplay:(NSDate *)date; - -+ (BOOL)isSameDayWithTimestamp:(uint64_t)timestamp1 timestamp:(uint64_t)timestamp2; -+ (BOOL)isSameDayWithDate:(NSDate *)date1 date:(NSDate *)date2; - -+ (BOOL)isSameHourWithTimestamp:(uint64_t)timestamp1 timestamp:(uint64_t)timestamp2; -+ (BOOL)isSameHourWithDate:(NSDate *)date1 date:(NSDate *)date2; - -+ (BOOL)shouldShowDateBreakForTimestamp:(uint64_t)timestamp1 timestamp:(uint64_t)timestamp2; - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Utilities/DateUtil.m b/Session/Utilities/DateUtil.m deleted file mode 100644 index 566b47b6e..000000000 --- a/Session/Utilities/DateUtil.m +++ /dev/null @@ -1,526 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "DateUtil.h" -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -static NSString *const DATE_FORMAT_WEEKDAY = @"EEEE"; - -@implementation DateUtil - -+ (NSString *)getHourFormat { - NSString *format = [NSDateFormatter dateFormatFromTemplate:@"j" options:0 locale:[NSLocale currentLocale]]; - NSRange range = [format rangeOfString:@"a"]; - BOOL is12HourTime = (range.location != NSNotFound); - return (is12HourTime) ? @"h:mm a" : @"HH:mm"; -} - -+ (NSDateFormatter *)dateFormatter { - static NSDateFormatter *formatter; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - formatter = [NSDateFormatter new]; - [formatter setLocale:[NSLocale currentLocale]]; - [formatter setTimeStyle:NSDateFormatterNoStyle]; - [formatter setDateStyle:NSDateFormatterShortStyle]; - }); - return formatter; -} - -+ (NSDateFormatter *)displayDateTodayFormatter -{ - static NSDateFormatter *formatter; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - formatter = [NSDateFormatter new]; - formatter.locale = [NSLocale currentLocale]; - // 9:10 am - formatter.dateFormat = [self getHourFormat]; - }); - - return formatter; -} - -+ (NSDateFormatter *)displayDateThisWeekDateFormatter -{ - static NSDateFormatter *formatter; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - formatter = [NSDateFormatter new]; - formatter.locale = [NSLocale currentLocale]; - // Mon 11:36 pm - formatter.dateFormat = [NSString stringWithFormat:@"EEE %@", [self getHourFormat]]; - }); - - return formatter; -} - -+ (NSDateFormatter *)displayDateThisYearDateFormatter -{ - static NSDateFormatter *formatter; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - formatter = [NSDateFormatter new]; - formatter.locale = [NSLocale currentLocale]; - // Jun 6 10:12 am - formatter.dateFormat = [NSString stringWithFormat:@"MMM d %@", [self getHourFormat]]; - }); - - return formatter; -} - -+ (NSDateFormatter *)displayDateOldDateFormatter -{ - static NSDateFormatter *formatter; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - formatter = [NSDateFormatter new]; - formatter.locale = [NSLocale currentLocale]; - formatter.dateStyle = NSDateFormatterMediumStyle; - formatter.timeStyle = NSDateFormatterShortStyle; - formatter.doesRelativeDateFormatting = YES; - }); - - return formatter; -} - -+ (NSDateFormatter *)weekdayFormatter { - static NSDateFormatter *formatter; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - formatter = [NSDateFormatter new]; - [formatter setLocale:[NSLocale currentLocale]]; - [formatter setDateFormat:DATE_FORMAT_WEEKDAY]; - }); - return formatter; -} - -+ (NSDateFormatter *)timeFormatter { - static NSDateFormatter *formatter; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - formatter = [NSDateFormatter new]; - [formatter setLocale:[NSLocale currentLocale]]; - [formatter setTimeStyle:NSDateFormatterShortStyle]; - [formatter setDateStyle:NSDateFormatterNoStyle]; - }); - return formatter; -} - -+ (NSDateFormatter *)monthAndDayFormatter -{ - static NSDateFormatter *formatter; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - formatter = [NSDateFormatter new]; - [formatter setLocale:[NSLocale currentLocale]]; - formatter.dateFormat = @"MMM d"; - }); - return formatter; -} - -+ (NSDateFormatter *)shortDayOfWeekFormatter -{ - static NSDateFormatter *formatter; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - formatter = [NSDateFormatter new]; - [formatter setLocale:[NSLocale currentLocale]]; - formatter.dateFormat = @"E"; - }); - return formatter; -} - -+ (BOOL)isWithinOneMinute:(NSDate *)date -{ - NSTimeInterval interval = [[NSDate new] timeIntervalSince1970] - [date timeIntervalSince1970]; - return interval < 60; -} - -+ (BOOL)dateIsOlderThanToday:(NSDate *)date -{ - return [self dateIsOlderThanToday:date now:[NSDate date]]; -} - -+ (BOOL)dateIsOlderThanToday:(NSDate *)date now:(NSDate *)now -{ - NSInteger dayDifference = [self daysFromFirstDate:date toSecondDate:now]; - return dayDifference > 0; -} - -+ (BOOL)dateIsOlderThanYesterday:(NSDate *)date -{ - return [self dateIsOlderThanYesterday:date now:[NSDate date]]; -} - -+ (BOOL)dateIsOlderThanYesterday:(NSDate *)date now:(NSDate *)now -{ - NSInteger dayDifference = [self daysFromFirstDate:date toSecondDate:now]; - return dayDifference > 1; -} - -+ (BOOL)dateIsOlderThanOneWeek:(NSDate *)date -{ - return [self dateIsOlderThanOneWeek:date now:[NSDate date]]; -} - -+ (BOOL)dateIsOlderThanOneWeek:(NSDate *)date now:(NSDate *)now -{ - NSInteger dayDifference = [self daysFromFirstDate:date toSecondDate:now]; - return dayDifference > 6; -} - -+ (BOOL)dateIsToday:(NSDate *)date -{ - return [self dateIsToday:date now:[NSDate date]]; -} - -+ (BOOL)dateIsToday:(NSDate *)date now:(NSDate *)now -{ - NSInteger dayDifference = [self daysFromFirstDate:date toSecondDate:now]; - return dayDifference == 0; -} - -+ (BOOL)dateIsThisWeek:(NSDate *)date -{ - return [self dateIsThisWeek:date now:[NSDate date]]; -} - -+ (BOOL)dateIsThisWeek:(NSDate *)date now:(NSDate *)now -{ - NSCalendar *calendar = [NSCalendar currentCalendar]; - return ( - [calendar component:NSCalendarUnitWeekOfYear fromDate:date] == [calendar component:NSCalendarUnitWeekOfYear fromDate:now]); -} - -+ (BOOL)dateIsThisYear:(NSDate *)date -{ - return [self dateIsThisYear:date now:[NSDate date]]; -} - -+ (BOOL)dateIsThisYear:(NSDate *)date now:(NSDate *)now -{ - NSCalendar *calendar = [NSCalendar currentCalendar]; - return ( - [calendar component:NSCalendarUnitYear fromDate:date] == [calendar component:NSCalendarUnitYear fromDate:now]); -} - -+ (BOOL)dateIsYesterday:(NSDate *)date -{ - return [self dateIsYesterday:date now:[NSDate date]]; -} - -+ (BOOL)dateIsYesterday:(NSDate *)date now:(NSDate *)now -{ - NSInteger dayDifference = [self daysFromFirstDate:date toSecondDate:now]; - return dayDifference == 1; -} - -// Returns the difference in minutes, ignoring seconds. -// If both dates are the same date, returns 0. -// If firstDate is one minute before secondDate, returns 1. -// -// Note: Assumes both dates use the "current" calendar. -+ (NSInteger)MinutesFromFirstDate:(NSDate *)firstDate toSecondDate:(NSDate *)secondDate -{ - NSCalendar *calendar = [NSCalendar currentCalendar]; - NSCalendarUnit units = NSCalendarUnitEra | NSCalendarUnitYear | NSCalendarUnitMonth | NSCalendarUnitDay | NSCalendarUnitHour | NSCalendarUnitMinute; - NSDateComponents *comp1 = [calendar components:units fromDate:firstDate]; - NSDateComponents *comp2 = [calendar components:units fromDate:secondDate]; - NSDate *date1 = [calendar dateFromComponents:comp1]; - NSDate *date2 = [calendar dateFromComponents:comp2]; - return [[calendar components:NSCalendarUnitMinute fromDate:date1 toDate:date2 options:0] minute]; -} - -// Returns the difference in hours, ignoring minutes, seconds. -// If both dates are the same date, returns 0. -// If firstDate is an hour before secondDate, returns 1. -// -// Note: Assumes both dates use the "current" calendar. -+ (NSInteger)hoursFromFirstDate:(NSDate *)firstDate toSecondDate:(NSDate *)secondDate -{ - NSCalendar *calendar = [NSCalendar currentCalendar]; - NSCalendarUnit units = NSCalendarUnitEra | NSCalendarUnitYear | NSCalendarUnitMonth | NSCalendarUnitDay | NSCalendarUnitHour; - NSDateComponents *comp1 = [calendar components:units fromDate:firstDate]; - NSDateComponents *comp2 = [calendar components:units fromDate:secondDate]; - NSDate *date1 = [calendar dateFromComponents:comp1]; - NSDate *date2 = [calendar dateFromComponents:comp2]; - return [[calendar components:NSCalendarUnitHour fromDate:date1 toDate:date2 options:0] hour]; -} - -// Returns the difference in days, ignoring hours, minutes, seconds. -// If both dates are the same date, returns 0. -// If firstDate is a day before secondDate, returns 1. -// -// Note: Assumes both dates use the "current" calendar. -+ (NSInteger)daysFromFirstDate:(NSDate *)firstDate toSecondDate:(NSDate *)secondDate -{ - NSCalendar *calendar = [NSCalendar currentCalendar]; - NSCalendarUnit units = NSCalendarUnitEra | NSCalendarUnitYear | NSCalendarUnitMonth | NSCalendarUnitDay; - NSDateComponents *comp1 = [calendar components:units fromDate:firstDate]; - NSDateComponents *comp2 = [calendar components:units fromDate:secondDate]; - [comp1 setHour:12]; - [comp2 setHour:12]; - NSDate *date1 = [calendar dateFromComponents:comp1]; - NSDate *date2 = [calendar dateFromComponents:comp2]; - return [[calendar components:NSCalendarUnitDay fromDate:date1 toDate:date2 options:0] day]; -} - -// Returns the difference in years, ignoring shorter units of time. -// If both dates fall in the same year, returns 0. -// If firstDate is from the year before secondDate, returns 1. -// -// Note: Assumes both dates use the "current" calendar. -+ (NSInteger)yearsFromFirstDate:(NSDate *)firstDate toSecondDate:(NSDate *)secondDate -{ - NSCalendar *calendar = [NSCalendar currentCalendar]; - NSCalendarUnit units = NSCalendarUnitEra | NSCalendarUnitYear; - NSDateComponents *comp1 = [calendar components:units fromDate:firstDate]; - NSDateComponents *comp2 = [calendar components:units fromDate:secondDate]; - [comp1 setHour:12]; - [comp2 setHour:12]; - NSDate *date1 = [calendar dateFromComponents:comp1]; - NSDate *date2 = [calendar dateFromComponents:comp2]; - return [[calendar components:NSCalendarUnitYear fromDate:date1 toDate:date2 options:0] year]; -} - -+ (NSString *)formatPastTimestampRelativeToNow:(uint64_t)pastTimestamp -{ - OWSCAssertDebug(pastTimestamp > 0); - - uint64_t nowTimestamp = [NSDate ows_millisecondTimeStamp]; - BOOL isFutureTimestamp = pastTimestamp >= nowTimestamp; - - NSDate *pastDate = [NSDate ows_dateWithMillisecondsSince1970:pastTimestamp]; - NSString *dateString; - if (isFutureTimestamp || [self dateIsToday:pastDate]) { - dateString = NSLocalizedString(@"DATE_TODAY", @"The current day."); - } else if ([self dateIsYesterday:pastDate]) { - dateString = NSLocalizedString(@"DATE_YESTERDAY", @"The day before today."); - } else { - dateString = [[self dateFormatter] stringFromDate:pastDate]; - } - return [[dateString rtlSafeAppend:@" "] rtlSafeAppend:[[self timeFormatter] stringFromDate:pastDate]]; -} - -+ (NSString *)formatTimestampShort:(uint64_t)timestamp -{ - return [self formatDateShort:[NSDate ows_dateWithMillisecondsSince1970:timestamp]]; -} - -+ (NSString *)formatDateShort:(NSDate *)date -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(date); - - NSDate *now = [NSDate date]; - NSInteger dayDifference = [self daysFromFirstDate:date toSecondDate:now]; - BOOL dateIsOlderThanToday = dayDifference > 0; - BOOL dateIsOlderThanOneWeek = dayDifference > 6; - - NSString *dateTimeString; - if (![DateUtil dateIsThisYear:date]) { - dateTimeString = [[DateUtil dateFormatter] stringFromDate:date]; - } else if (dateIsOlderThanOneWeek) { - dateTimeString = [[DateUtil monthAndDayFormatter] stringFromDate:date]; - } else if (dateIsOlderThanToday) { - dateTimeString = [[DateUtil shortDayOfWeekFormatter] stringFromDate:date]; - } else { - dateTimeString = [[DateUtil timeFormatter] stringFromDate:date]; - } - - return dateTimeString.localizedUppercaseString; -} - -+ (NSString *)formatDateForDisplay:(NSDate *)date -{ - OWSAssertDebug(date); - - if (![self dateIsThisYear:date]) { - // last year formatter: Nov 11 13:32 am, 2017 - return [self.displayDateOldDateFormatter stringFromDate:date]; - } else if (![self dateIsThisWeek:date]) { - // this year formatter: Jun 6 10:12 am - return [self.displayDateThisYearDateFormatter stringFromDate:date]; - } else if (![self dateIsToday:date]) { - // day of week formatter: Thu 9:11 pm - return [self.displayDateThisWeekDateFormatter stringFromDate:date]; - } else if (![self isWithinOneMinute:date]) { - // today formatter: 8:32 am - return [self.displayDateTodayFormatter stringFromDate:date]; - } else { - return NSLocalizedString(@"DATE_NOW", @""); - } -} - -+ (NSString *)formatTimestampAsTime:(uint64_t)timestamp -{ - return [self formatDateAsTime:[NSDate ows_dateWithMillisecondsSince1970:timestamp]]; -} - -+ (NSString *)formatDateAsTime:(NSDate *)date -{ - OWSAssertDebug(date); - - NSString *dateTimeString = [[DateUtil timeFormatter] stringFromDate:date]; - return dateTimeString.localizedUppercaseString; -} - -+ (NSDateFormatter *)otherYearMessageFormatter -{ - static NSDateFormatter *formatter; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - formatter = [NSDateFormatter new]; - [formatter setLocale:[NSLocale currentLocale]]; - [formatter setDateFormat:@"MMM d, yyyy"]; - }); - return formatter; -} - -+ (NSDateFormatter *)thisYearMessageFormatter -{ - static NSDateFormatter *formatter; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - formatter = [NSDateFormatter new]; - [formatter setLocale:[NSLocale currentLocale]]; - [formatter setDateFormat:@"MMM d"]; - }); - return formatter; -} - -+ (NSDateFormatter *)thisWeekMessageFormatter -{ - static NSDateFormatter *formatter; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - formatter = [NSDateFormatter new]; - [formatter setLocale:[NSLocale currentLocale]]; - [formatter setDateFormat:@"E"]; - }); - return formatter; -} - -+ (NSString *)formatMessageTimestamp:(uint64_t)timestamp -{ - NSDate *date = [NSDate ows_dateWithMillisecondsSince1970:timestamp]; - uint64_t nowTimestamp = [NSDate ows_millisecondTimeStamp]; - NSDate *nowDate = [NSDate ows_dateWithMillisecondsSince1970:nowTimestamp]; - - NSCalendar *calendar = [NSCalendar currentCalendar]; - - NSDateComponents *relativeDiffComponents = - [calendar components:NSCalendarUnitMinute | NSCalendarUnitHour fromDate:date toDate:nowDate options:0]; - - NSInteger minutesDiff = MAX(0, [relativeDiffComponents minute]); - NSInteger hoursDiff = MAX(0, [relativeDiffComponents hour]); - if (hoursDiff < 1 && minutesDiff < 1) { - return NSLocalizedString(@"DATE_NOW", @"The present; the current time."); - } - - if (hoursDiff < 1) { - NSString *minutesString = [OWSFormat formatInt:(int)minutesDiff]; - return [NSString stringWithFormat:NSLocalizedString(@"DATE_MINUTES_AGO_FORMAT", - @"Format string for a relative time, expressed as a certain number of " - @"minutes in the past. Embeds {{The number of minutes}}."), - minutesString]; - } - - // Note: we are careful to treat "future" dates as "now". - NSInteger yearsDiff = [self yearsFromFirstDate:date toSecondDate:nowDate]; - if (yearsDiff > 0) { - // "Long date" + locale-specific "short" time format. - NSString *dayOfWeek = [self.otherYearMessageFormatter stringFromDate:date]; - NSString *formattedTime = [[self timeFormatter] stringFromDate:date]; - return [[dayOfWeek rtlSafeAppend:@" "] rtlSafeAppend:formattedTime]; - } - - NSInteger daysDiff = [self daysFromFirstDate:date toSecondDate:nowDate]; - if (daysDiff >= 7) { - // "Short date" + locale-specific "short" time format. - NSString *dayOfWeek = [self.thisYearMessageFormatter stringFromDate:date]; - NSString *formattedTime = [[self timeFormatter] stringFromDate:date]; - return [[dayOfWeek rtlSafeAppend:@" "] rtlSafeAppend:formattedTime]; - } else if (daysDiff > 0) { - // "Day of week" + locale-specific "short" time format. - NSString *dayOfWeek = [self.thisWeekMessageFormatter stringFromDate:date]; - NSString *formattedTime = [[self timeFormatter] stringFromDate:date]; - return [[dayOfWeek rtlSafeAppend:@" "] rtlSafeAppend:formattedTime]; - } else { - NSString *hoursString = [OWSFormat formatInt:(int)hoursDiff]; - return [NSString stringWithFormat:NSLocalizedString(@"DATE_HOURS_AGO_FORMAT", - @"Format string for a relative time, expressed as a certain number of " - @"hours in the past. Embeds {{The number of hours}}."), - hoursString]; - } -} - -+ (BOOL)isTimestampFromLastHour:(uint64_t)timestamp -{ - NSDate *date = [NSDate ows_dateWithMillisecondsSince1970:timestamp]; - uint64_t nowTimestamp = [NSDate ows_millisecondTimeStamp]; - NSDate *nowDate = [NSDate ows_dateWithMillisecondsSince1970:nowTimestamp]; - - NSCalendar *calendar = [NSCalendar currentCalendar]; - - NSInteger hoursDiff - = MAX(0, [[calendar components:NSCalendarUnitHour fromDate:date toDate:nowDate options:0] hour]); - return hoursDiff < 1; -} - -+ (NSString *)exemplaryNowTimeFormat -{ - return NSLocalizedString(@"DATE_NOW", @"The present; the current time.").localizedUppercaseString; -} - -+ (NSString *)exemplaryMinutesTimeFormat -{ - NSString *minutesString = [OWSFormat formatInt:(int)59]; - return [NSString stringWithFormat:NSLocalizedString(@"DATE_MINUTES_AGO_FORMAT", - @"Format string for a relative time, expressed as a certain number of " - @"minutes in the past. Embeds {{The number of minutes}}."), - minutesString] - .uppercaseString; -} - -+ (BOOL)isSameDayWithTimestamp:(uint64_t)timestamp1 timestamp:(uint64_t)timestamp2 -{ - return [self isSameDayWithDate:[NSDate ows_dateWithMillisecondsSince1970:timestamp1] - date:[NSDate ows_dateWithMillisecondsSince1970:timestamp2]]; -} - -+ (BOOL)isSameDayWithDate:(NSDate *)date1 date:(NSDate *)date2 -{ - NSInteger dayDifference = [self daysFromFirstDate:date1 toSecondDate:date2]; - return dayDifference == 0; -} - -+ (BOOL)isSameHourWithTimestamp:(uint64_t)timestamp1 timestamp:(uint64_t)timestamp2 -{ - return [self isSameHourWithDate:[NSDate ows_dateWithMillisecondsSince1970:timestamp1] - date:[NSDate ows_dateWithMillisecondsSince1970:timestamp2]]; -} - -+ (BOOL)isSameHourWithDate:(NSDate *)date1 date:(NSDate *)date2 -{ - NSInteger hourDifference = [self hoursFromFirstDate:date1 toSecondDate:date2]; - return hourDifference == 0; -} - -+ (BOOL)shouldShowDateBreakForTimestamp:(uint64_t)timestamp1 timestamp:(uint64_t)timestamp2 -{ - NSInteger maxMinutesBetweenTwoDateBreaks = 5; - NSDate *date1 = [NSDate ows_dateWithMillisecondsSince1970:timestamp1]; - NSDate *date2 = [NSDate ows_dateWithMillisecondsSince1970:timestamp2]; - return [self MinutesFromFirstDate:date1 toSecondDate:date2] > maxMinutesBetweenTwoDateBreaks; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift index b9c3a88aa..b0300db4b 100644 --- a/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -22,7 +22,7 @@ enum _002_SetupStandardJobs: Migration { ).inserted(db) _ = try Job( - variant: .failedMessages, + variant: .failedMessageSends, behaviour: .recurringOnLaunch, shouldBlockFirstRunEachSession: true ).inserted(db) @@ -42,6 +42,11 @@ enum _002_SetupStandardJobs: Migration { variant: .retrieveDefaultOpenGroupRooms, behaviour: .recurringOnActive ).inserted(db) + + _ = try Job( + variant: .garbageCollection, + behaviour: .recurringOnLaunch + ).inserted(db) } } } diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index 3d22369bb..acc93db4d 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -53,6 +53,7 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR case pendingDownload case downloading case downloaded + case failedUpload case uploading case uploaded } @@ -351,7 +352,7 @@ extension Attachment { ) // Assume the data is already correct for "uploading" attachments (and don't override it) - case (.uploading, .failedDownload), (.uploaded, .failedDownload): return (self.isValid, self.duration) + case (.uploading, _), (.uploaded, _), (.failedUpload, _): return (self.isValid, self.duration) case (_, .failedDownload): return (false, nil) default: return (self.isValid, self.duration) @@ -1055,6 +1056,11 @@ extension Attachment { success?() } .catch { error in + GRDBStorage.shared.write { db in + try updatedAttachment? + .with(state: .failedUpload) + .saved(db) + } failure?(error) } } diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 695b4f5b3..3a3779c0d 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -465,19 +465,22 @@ public extension Interaction { let interactionQuery = Interaction .filter(Columns.threadId == threadId) .filter(Columns.id <= interactionId) + .filter(Columns.wasRead == false) // The `wasRead` flag doesn't apply to `standardOutgoing` or `standardIncomingDeleted` .filter(Columns.variant != Variant.standardOutgoing && Columns.variant != Variant.standardIncomingDeleted) + let interactionIdsToMarkAsRead: [Int64] = try interactionQuery + .select(.id) + .asRequest(of: Int64.self) + .fetchAll(db) + + // Don't bother continuing if there are not interactions to mark as read + guard !interactionIdsToMarkAsRead.isEmpty else { return } // Update the `wasRead` flag to true try interactionQuery.updateAll(db, Columns.wasRead.set(to: true)) // Retrieve the interaction ids we want to update - scheduleJobs( - interactionIds: try Int64.fetchAll( - db, - interactionQuery.select(.id) - ) - ) + scheduleJobs(interactionIds: interactionIdsToMarkAsRead) } /// This method flags sent messages as read for the specified recipients diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index f181f3a5a..48c643611 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -209,36 +209,6 @@ public extension SessionThread { // MARK: - Convenience public extension SessionThread { - static func displayName(userPublicKey: String) -> SQLSpecificExpressible { - let contactAlias: TypedTableAlias = TypedTableAlias() - - return ( - ( - ( - SessionThread.Columns.variant == SessionThread.Variant.closedGroup && - ClosedGroup.Columns.name - ) || ( - SessionThread.Columns.variant == SessionThread.Variant.openGroup && - OpenGroup.Columns.name - ) || ( - isNoteToSelf(userPublicKey: userPublicKey) - ) || ( - Profile.Columns.nickname || - Profile.Columns.name - //customFallback: Profile.truncated(id: thread.id, truncating: .middle) - ) - ) - ) - } - - /// This method can be used to create a query based on whether a thread is the note to self thread - static func isNoteToSelf(userPublicKey: String) -> SQLSpecificExpressible { - return ( - SessionThread.Columns.variant == SessionThread.Variant.contact && - SessionThread.Columns.id == userPublicKey - ) - } - /// This method can be used to filter a thread query to only include messages requests /// /// **Note:** In order to use this filter you **MUST** have a `joining(required/optional:)` to the diff --git a/SessionMessagingKit/Jobs/Types/FailedMessagesJob.swift b/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift similarity index 65% rename from SessionMessagingKit/Jobs/Types/FailedMessagesJob.swift rename to SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift index b72e37e05..0729b8445 100644 --- a/SessionMessagingKit/Jobs/Types/FailedMessagesJob.swift +++ b/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift @@ -5,7 +5,7 @@ import GRDB import SignalCoreKit import SessionUtilitiesKit -public enum FailedMessagesJob: JobExecutor { +public enum FailedMessageSendsJob: JobExecutor { public static let maxFailureCount: Int = -1 public static let requiresThreadId: Bool = false public static let requiresInteractionId: Bool = false @@ -21,8 +21,11 @@ public enum FailedMessagesJob: JobExecutor { let changeCount: Int = try RecipientState .filter(RecipientState.Columns.state == RecipientState.State.sending) .updateAll(db, RecipientState.Columns.state.set(to: RecipientState.State.failed)) - - Logger.debug("Marked \(changeCount) messages as failed") + let attachmentChangeCount: Int = try Attachment + .filter(Attachment.Columns.state == Attachment.State.uploading) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedUpload)) + + Logger.debug("Marked \(changeCount) message\(changeCount == 1 ? "" : "s") as failed (\(attachmentChangeCount) upload\(attachmentChangeCount == 1 ? "" : "s") cancelled)") } success(job, false) diff --git a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift index 9d88c56f7..0d9bd516f 100644 --- a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift +++ b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift @@ -39,6 +39,7 @@ extension GarbageCollectionJob { case threadTypingIndicators case orphanedAttachmentFiles case orphanedProfileAvatars + case orphanedLinkPreviews } public struct Details: Codable { diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift index f39421bec..dde5fd1fe 100644 --- a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift @@ -51,7 +51,7 @@ public enum MessageSendJob: JobExecutor { return (true, false) } - // Create jobs for any pending attachment jobs and insert them into the + // Create jobs for any pending (or failed) attachment jobs and insert them into the // queue before the current job (this will mean the current job will re-run // after these inserted jobs complete) // @@ -60,7 +60,17 @@ public enum MessageSendJob: JobExecutor { // but not on the message recipients device - both LinkPreview and Quote can // have this case) try allAttachmentStateInfo - .filter { $0.state == .uploading || $0.state == .downloaded } + .filter { $0.state == .uploading || $0.state == .failedUpload || $0.state == .downloaded } + .filter { stateInfo in + // Don't add a new job if there is one already in the queue + !JobRunner.hasPendingOrRunningJob( + with: .attachmentUpload, + details: AttachmentUploadJob.Details( + messageSendJobId: jobId, + attachmentId: stateInfo.attachmentId + ) + ) + } .compactMap { stateInfo in JobRunner .insert( diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index b58b7614d..3b435b178 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -444,6 +444,7 @@ extension MessageReceiver { variant: variant, body: message.text, timestampMs: Int64(messageSentTimestamp * 1000), + wasRead: (variant == .standardOutgoing), // Auto-mark sent messages as read hasMention: ( message.text?.contains("@\(currentUserPublicKey)") == true || dataMessage.quote?.author == currentUserPublicKey @@ -646,7 +647,9 @@ extension MessageReceiver { } } - // For outgoing messages mark it and all older interactions as read + // For outgoing messages mark all older interactions as read (the user should have seen + // them if they send a message - also avoids a situation where the user has "phantom" + // unread messages that they need to scroll back to before they become marked as read) try Interaction.markAsRead( db, interactionId: interactionId, diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift new file mode 100644 index 000000000..cf780cdcb --- /dev/null +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -0,0 +1,699 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import DifferenceKit +import SessionUtilitiesKit + +fileprivate typealias ViewModel = MessageViewModel +fileprivate typealias AttachmentInteractionInfo = MessageViewModel.AttachmentInteractionInfo + +public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, Hashable, Identifiable, Differentiable { + public static let threadVariantKey: SQL = SQL(stringLiteral: CodingKeys.threadVariant.stringValue) + public static let threadIsTrustedKey: SQL = SQL(stringLiteral: CodingKeys.threadIsTrusted.stringValue) + public static let threadHasDisappearingMessagesEnabledKey: SQL = SQL(stringLiteral: CodingKeys.threadHasDisappearingMessagesEnabled.stringValue) + public static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue) + public static let authorNameInternalKey: SQL = SQL(stringLiteral: CodingKeys.authorNameInternal.stringValue) + public static let stateKey: SQL = SQL(stringLiteral: CodingKeys.state.stringValue) + public static let hasAtLeastOneReadReceiptKey: SQL = SQL(stringLiteral: CodingKeys.hasAtLeastOneReadReceipt.stringValue) + public static let mostRecentFailureTextKey: SQL = SQL(stringLiteral: CodingKeys.mostRecentFailureText.stringValue) + public static let isTypingIndicatorKey: SQL = SQL(stringLiteral: CodingKeys.isTypingIndicator.stringValue) + public static let isSenderOpenGroupModeratorKey: SQL = SQL(stringLiteral: CodingKeys.isSenderOpenGroupModerator.stringValue) + public static let profileKey: SQL = SQL(stringLiteral: CodingKeys.profile.stringValue) + public static let quoteKey: SQL = SQL(stringLiteral: CodingKeys.quote.stringValue) + public static let quoteAttachmentKey: SQL = SQL(stringLiteral: CodingKeys.quoteAttachment.stringValue) + public static let linkPreviewKey: SQL = SQL(stringLiteral: CodingKeys.linkPreview.stringValue) + public static let linkPreviewAttachmentKey: SQL = SQL(stringLiteral: CodingKeys.linkPreviewAttachment.stringValue) + public static let cellTypeKey: SQL = SQL(stringLiteral: CodingKeys.cellType.stringValue) + public static let authorNameKey: SQL = SQL(stringLiteral: CodingKeys.authorName.stringValue) + public static let shouldShowProfileKey: SQL = SQL(stringLiteral: CodingKeys.shouldShowProfile.stringValue) + public static let positionInClusterKey: SQL = SQL(stringLiteral: CodingKeys.positionInCluster.stringValue) + public static let isOnlyMessageInClusterKey: SQL = SQL(stringLiteral: CodingKeys.isOnlyMessageInCluster.stringValue) + public static let isLastKey: SQL = SQL(stringLiteral: CodingKeys.isLast.stringValue) + + public static let profileString: String = CodingKeys.profile.stringValue + public static let quoteString: String = CodingKeys.quote.stringValue + public static let quoteAttachmentString: String = CodingKeys.quoteAttachment.stringValue + public static let linkPreviewString: String = CodingKeys.linkPreview.stringValue + public static let linkPreviewAttachmentString: String = CodingKeys.linkPreviewAttachment.stringValue + + public enum Position: Int, Decodable, Equatable, Hashable, DatabaseValueConvertible { + case top + case middle + case bottom + } + + public enum CellType: Int, Decodable, Equatable, Hashable, DatabaseValueConvertible { + case textOnlyMessage + case mediaMessage + case audio + case genericAttachment + case typingIndicator + } + + public var differenceIdentifier: Int64 { id } + + // Thread Info + + public let threadVariant: SessionThread.Variant + public let threadIsTrusted: Bool + public let threadHasDisappearingMessagesEnabled: Bool + + // Interaction Info + + public let rowId: Int64 + public let id: Int64 + public let variant: Interaction.Variant + public let timestampMs: Int64 + public let authorId: String + private let authorNameInternal: String? + public let body: String? + public let expiresStartedAtMs: Double? + public let expiresInSeconds: TimeInterval? + + public let state: RecipientState.State + public let hasAtLeastOneReadReceipt: Bool + public let mostRecentFailureText: String? + public let isTypingIndicator: Bool + public let isSenderOpenGroupModerator: Bool + public let profile: Profile? + public let quote: Quote? + public let quoteAttachment: Attachment? + public let linkPreview: LinkPreview? + public let linkPreviewAttachment: Attachment? + + // Post-Query Processing Data + + /// This value includes the associated attachments + public let attachments: [Attachment]? + + /// This value defines what type of cell should appear and is generated based on the interaction variant + /// and associated attachment data + public let cellType: CellType + + /// This value includes the author name information + public let authorName: String + + /// This value will be used to populate the author label, if it's null then the label will be hidden + public let senderName: String? + + /// A flag indicating whether the profile view should be displayed + public let shouldShowProfile: Bool + + /// This value will be used to populate the date header, if it's null then the header will be hidden + public let dateForUI: Date? + + /// This value specifies whether the body contains only emoji characters + public let containsOnlyEmoji: Bool? + + /// This value specifies the number of emoji characters the body contains + public let glyphCount: Int? + + /// This value indicates the variant of the previous ViewModel item, if it's null then there is no previous item + public let previousVariant: Interaction.Variant? + + /// This value indicates the position of this message within a cluser of messages + public let positionInCluster: Position + + /// This value indicates whether this is the only message in a cluser of messages + public let isOnlyMessageInCluster: Bool + + /// This value indicates whether this is the last message in the thread + public let isLast: Bool + + // MARK: - Mutation + + public func with(attachments: [Attachment]) -> MessageViewModel { + return MessageViewModel( + threadVariant: self.threadVariant, + threadIsTrusted: self.threadIsTrusted, + threadHasDisappearingMessagesEnabled: self.threadHasDisappearingMessagesEnabled, + rowId: self.rowId, + id: self.id, + variant: self.variant, + timestampMs: self.timestampMs, + authorId: self.authorId, + authorNameInternal: self.authorNameInternal, + body: self.body, + expiresStartedAtMs: self.expiresStartedAtMs, + expiresInSeconds: self.expiresInSeconds, + state: self.state, + hasAtLeastOneReadReceipt: self.hasAtLeastOneReadReceipt, + mostRecentFailureText: self.mostRecentFailureText, + isTypingIndicator: self.isTypingIndicator, + isSenderOpenGroupModerator: self.isSenderOpenGroupModerator, + profile: self.profile, + quote: self.quote, + quoteAttachment: self.quoteAttachment, + linkPreview: self.linkPreview, + linkPreviewAttachment: self.linkPreviewAttachment, + attachments: attachments, + cellType: self.cellType, + authorName: self.authorName, + senderName: self.senderName, + shouldShowProfile: self.shouldShowProfile, + dateForUI: self.dateForUI, + containsOnlyEmoji: self.containsOnlyEmoji, + glyphCount: self.glyphCount, + previousVariant: self.previousVariant, + positionInCluster: self.positionInCluster, + isOnlyMessageInCluster: self.isOnlyMessageInCluster, + isLast: self.isLast + ) + } + + public func withClusteringChanges( + prevModel: MessageViewModel?, + nextModel: MessageViewModel?, + isLast: Bool + ) -> MessageViewModel { + let cellType: CellType = { + guard !self.isTypingIndicator else { return .typingIndicator } + guard self.variant != .standardIncomingDeleted else { return .textOnlyMessage } + guard let attachment: Attachment = self.attachments?.first else { return .textOnlyMessage } + + // The only case which currently supports multiple attachments is a 'mediaMessage' + // (the album view) + guard self.attachments?.count == 1 else { return .mediaMessage } + + // Quote and LinkPreview overload the 'attachments' array and use it for their + // own purposes, otherwise check if the attachment is visual media + guard self.quote == nil else { return .textOnlyMessage } + guard self.linkPreview == nil else { return .textOnlyMessage } + + // Pending audio attachments won't have a duration + if + attachment.isAudio && ( + ((attachment.duration ?? 0) > 0) || + ( + attachment.state != .downloaded && + attachment.state != .uploaded + ) + ) + { + return .audio + } + + if attachment.isVisualMedia { + return .mediaMessage + } + + return .genericAttachment + }() + let authorDisplayName: String = Profile.displayName( + for: self.threadVariant, + id: self.authorId, + name: self.authorNameInternal, + nickname: nil // Folded into 'authorName' within the Query + ) + let shouldShowDateOnThisModel: Bool = { + guard !self.isTypingIndicator else { return false } + guard let prevModel: ViewModel = prevModel else { return true } + + return MessageViewModel.shouldShowDateBreak( + between: prevModel.timestampMs, + and: self.timestampMs + ) + }() + let shouldShowDateOnNextModel: Bool = { + // Should be nothing after a typing indicator + guard !self.isTypingIndicator else { return false } + guard let nextModel: ViewModel = nextModel else { return false } + + return MessageViewModel.shouldShowDateBreak( + between: self.timestampMs, + and: nextModel.timestampMs + ) + }() + let (positionInCluster, isOnlyMessageInCluster): (Position, Bool) = { + let isFirstInCluster: Bool = ( + prevModel == nil || + shouldShowDateOnThisModel || ( + self.variant == .standardOutgoing && + prevModel?.variant != .standardOutgoing + ) || ( + ( + self.variant == .standardIncoming || + self.variant == .standardIncomingDeleted + ) && ( + prevModel?.variant != .standardIncoming && + prevModel?.variant != .standardIncomingDeleted + ) + ) || + self.authorId != prevModel?.authorId + ) + let isLastInCluster: Bool = ( + nextModel == nil || + shouldShowDateOnNextModel || ( + self.variant == .standardOutgoing && + nextModel?.variant != .standardOutgoing + ) || ( + ( + self.variant == .standardIncoming || + self.variant == .standardIncomingDeleted + ) && ( + nextModel?.variant != .standardIncoming && + nextModel?.variant != .standardIncomingDeleted + ) + ) || + self.authorId != nextModel?.authorId + ) + + let isOnlyMessageInCluster: Bool = (isFirstInCluster && isLastInCluster) + + switch (isFirstInCluster, isLastInCluster) { + case (true, true), (false, false): return (.middle, isOnlyMessageInCluster) + case (true, false): return (.top, isOnlyMessageInCluster) + case (false, true): return (.bottom, isOnlyMessageInCluster) + } + }() + + return ViewModel( + threadVariant: self.threadVariant, + threadIsTrusted: self.threadIsTrusted, + threadHasDisappearingMessagesEnabled: self.threadHasDisappearingMessagesEnabled, + rowId: self.rowId, + id: self.id, + variant: self.variant, + timestampMs: self.timestampMs, + authorId: self.authorId, + authorNameInternal: self.authorNameInternal, + body: (!self.variant.isInfoMessage ? + self.body : + // Info messages might not have a body so we should use the 'previewText' value instead + Interaction.previewText( + variant: self.variant, + body: self.body, + authorDisplayName: authorDisplayName, + attachmentDescriptionInfo: self.attachments?.first.map { firstAttachment in + Attachment.DescriptionInfo( + id: firstAttachment.id, + variant: firstAttachment.variant, + contentType: firstAttachment.contentType, + sourceFilename: firstAttachment.sourceFilename + ) + }, + attachmentCount: self.attachments?.count, + isOpenGroupInvitation: (self.linkPreview?.variant == .openGroupInvitation) + ) + ), + expiresStartedAtMs: self.expiresStartedAtMs, + expiresInSeconds: self.expiresInSeconds, + state: self.state, + hasAtLeastOneReadReceipt: self.hasAtLeastOneReadReceipt, + mostRecentFailureText: self.mostRecentFailureText, + isTypingIndicator: self.isTypingIndicator, + isSenderOpenGroupModerator: self.isSenderOpenGroupModerator, + profile: self.profile, + quote: self.quote, + quoteAttachment: self.quoteAttachment, + linkPreview: self.linkPreview, + linkPreviewAttachment: self.linkPreviewAttachment, + attachments: self.attachments, + cellType: cellType, + authorName: authorDisplayName, + senderName: { + // Only show for group threads + guard self.threadVariant == .openGroup || self.threadVariant == .closedGroup else { + return nil + } + + // Only if there is a date header or the senders are different + guard shouldShowDateOnThisModel || self.authorId != prevModel?.authorId else { + return nil + } + + return authorDisplayName + }(), + shouldShowProfile: ( + // Only group threads + (self.threadVariant == .openGroup || self.threadVariant == .closedGroup) && + + // Only incoming messages + (self.variant == .standardIncoming || self.variant == .standardIncomingDeleted) && + + // Show if the next message has a different sender or has a "date break" + ( + self.authorId != nextModel?.authorId || + shouldShowDateOnNextModel + ) && + + // Need a profile to be able to show it + self.profile != nil + ), + dateForUI: (shouldShowDateOnThisModel ? + Date(timeIntervalSince1970: (TimeInterval(self.timestampMs) / 1000)) : + nil + ), + containsOnlyEmoji: self.body?.containsOnlyEmoji, + glyphCount: self.body?.glyphCount, + previousVariant: prevModel?.variant, + positionInCluster: positionInCluster, + isOnlyMessageInCluster: isOnlyMessageInCluster, + isLast: isLast + ) + } +} + +// MARK: - AttachmentInteractionInfo + +public extension MessageViewModel { + struct AttachmentInteractionInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable, Comparable { + public static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue) + public static let attachmentKey: SQL = SQL(stringLiteral: CodingKeys.attachment.stringValue) + public static let interactionAttachmentKey: SQL = SQL(stringLiteral: CodingKeys.interactionAttachment.stringValue) + + public static let attachmentString: String = CodingKeys.attachment.stringValue + public static let interactionAttachmentString: String = CodingKeys.interactionAttachment.stringValue + + public let rowId: Int64 + public let attachment: Attachment + public let interactionAttachment: InteractionAttachment + + // MARK: - Identifiable + + public var id: String { + "\(interactionAttachment.interactionId)-\(interactionAttachment.albumIndex)" + } + + // MARK: - Comparable + + public static func < (lhs: AttachmentInteractionInfo, rhs: AttachmentInteractionInfo) -> Bool { + return (lhs.interactionAttachment.albumIndex < rhs.interactionAttachment.albumIndex) + } + } +} + +// MARK: - Convenience Initialization + +public extension MessageViewModel { + // Note: This init method is only used system-created cells or empty states + init(isTypingIndicator: Bool = false) { + self.threadVariant = .contact + self.threadIsTrusted = false + self.threadHasDisappearingMessagesEnabled = false + + // Interaction Info + + self.rowId = -1 + self.id = -1 + self.variant = .standardOutgoing + self.timestampMs = Int64.max + self.authorId = "" + self.authorNameInternal = nil + self.body = nil + self.expiresStartedAtMs = nil + self.expiresInSeconds = nil + + self.state = .sent + self.hasAtLeastOneReadReceipt = false + self.mostRecentFailureText = nil + self.isTypingIndicator = isTypingIndicator + self.isSenderOpenGroupModerator = false + self.profile = nil + self.quote = nil + self.quoteAttachment = nil + self.linkPreview = nil + self.linkPreviewAttachment = nil + + // Post-Query Processing Data + + self.attachments = nil + self.cellType = .typingIndicator + self.authorName = "" + self.senderName = nil + self.shouldShowProfile = false + self.dateForUI = nil + self.containsOnlyEmoji = nil + self.glyphCount = nil + self.previousVariant = nil + self.positionInCluster = .middle + self.isOnlyMessageInCluster = true + self.isLast = true + } +} + +// MARK: - Convenience + +extension MessageViewModel { + private static let maxMinutesBetweenTwoDateBreaks: Int = 5 + + /// Returns the difference in minutes, ignoring seconds + /// + /// If both dates are the same date, returns 0 + /// If firstDate is one minute before secondDate, returns 1 + /// + /// **Note:** Assumes both dates use the "current" calendar + private static func minutesFrom(_ firstDate: Date, to secondDate: Date) -> Int? { + let calendar: Calendar = Calendar.current + let components1: DateComponents = calendar.dateComponents( + [.era, .year, .month, .day, .hour, .minute], + from: firstDate + ) + let components2: DateComponents = calendar.dateComponents( + [.era, .year, .month, .day, .hour, .minute], + from: secondDate + ) + + guard + let date1: Date = calendar.date(from: components1), + let date2: Date = calendar.date(from: components2) + else { return nil } + + return calendar.dateComponents([.minute], from: date1, to: date2).minute + } + + fileprivate static func shouldShowDateBreak(between timestamp1: Int64, and timestamp2: Int64) -> Bool { + let date1: Date = Date(timeIntervalSince1970: (TimeInterval(timestamp1) / 1000)) + let date2: Date = Date(timeIntervalSince1970: (TimeInterval(timestamp2) / 1000)) + + return ((minutesFrom(date1, to: date2) ?? 0) > maxMinutesBetweenTwoDateBreaks) + } +} + +// MARK: - ConversationVC + +public extension MessageViewModel { + static func filterSQL(threadId: String) -> SQL { + let interaction: TypedTableAlias = TypedTableAlias() + + return SQL("\(interaction[.threadId]) = \(threadId)") + } + + static let orderSQL: SQL = { + let interaction: TypedTableAlias = TypedTableAlias() + + return SQL("\(interaction[.timestampMs].desc)") + }() + + static func baseQuery(orderSQL: SQL, baseFilterSQL: SQL) -> ((SQL?, SQL?) -> AdaptedFetchRequest>) { + return { additionalFilters, limitSQL -> AdaptedFetchRequest> in + let interaction: TypedTableAlias = TypedTableAlias() + let thread: TypedTableAlias = TypedTableAlias() + let typingIndicator: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() + let disappearingMessagesConfig: TypedTableAlias = TypedTableAlias() + let profile: TypedTableAlias = TypedTableAlias() + let quote: TypedTableAlias = TypedTableAlias() + let linkPreview: TypedTableAlias = TypedTableAlias() + + let interactionStateTableLiteral: SQL = SQL(stringLiteral: "interactionState") + let interactionStateInteractionIdColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.interactionId.name) + let interactionStateStateColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.state.name) + let interactionStateMostRecentFailureTextColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.mostRecentFailureText.name) + let readReceiptTableLiteral: SQL = SQL(stringLiteral: "readReceipt") + let readReceiptReadTimestampMsColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.readTimestampMs.name) + let attachmentIdColumnLiteral: SQL = SQL(stringLiteral: Attachment.Columns.id.name) + + let finalFilterSQL: SQL = { + guard let additionalFilters: SQL = additionalFilters else { + return """ + WHERE \(baseFilterSQL) + """ + } + + return """ + WHERE ( + \(baseFilterSQL) AND + \(additionalFilters) + ) + """ + }() + let finalLimitSQL: SQL = (limitSQL ?? SQL(stringLiteral: "")) + let numColumnsBeforeLinkedRecords: Int = 17 + let request: SQLRequest = """ + SELECT + \(thread[.variant]) AS \(ViewModel.threadVariantKey), + -- Default to 'true' for non-contact threads + IFNULL(\(contact[.isTrusted]), true) AS \(ViewModel.threadIsTrustedKey), + -- Default to 'false' when no contact exists + IFNULL(\(disappearingMessagesConfig[.isEnabled]), false) AS \(ViewModel.threadHasDisappearingMessagesEnabledKey), + + \(interaction.alias[Column.rowID]) AS \(ViewModel.rowIdKey), + \(interaction[.id]), + \(interaction[.variant]), + \(interaction[.timestampMs]), + \(interaction[.authorId]), + IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.authorNameInternalKey), + \(interaction[.body]), + \(interaction[.expiresStartedAtMs]), + \(interaction[.expiresInSeconds]), + + -- Default to 'sending' assuming non-processed interaction when null + IFNULL(\(interactionStateTableLiteral).\(interactionStateStateColumnLiteral), \(SQL("\(RecipientState.State.sending)"))) AS \(ViewModel.stateKey), + (\(readReceiptTableLiteral).\(readReceiptReadTimestampMsColumnLiteral) IS NOT NULL) AS \(ViewModel.hasAtLeastOneReadReceiptKey), + \(interactionStateTableLiteral).\(interactionStateMostRecentFailureTextColumnLiteral) AS \(ViewModel.mostRecentFailureTextKey), + + (\(typingIndicator[.threadId]) IS NOT NULL) AS \(ViewModel.isTypingIndicatorKey), + false AS \(ViewModel.isSenderOpenGroupModeratorKey), + + \(ViewModel.profileKey).*, + \(ViewModel.quoteKey).*, + \(ViewModel.quoteAttachmentKey).*, + \(ViewModel.linkPreviewKey).*, + \(ViewModel.linkPreviewAttachmentKey).*, + + -- All of the below properties are set in post-query processing but to prevent the + -- query from crashing when decoding we need to provide default values + \(CellType.textOnlyMessage) AS \(ViewModel.cellTypeKey), + '' AS \(ViewModel.authorNameKey), + false AS \(ViewModel.shouldShowProfileKey), + \(Position.middle) AS \(ViewModel.positionInClusterKey), + false AS \(ViewModel.isOnlyMessageInClusterKey), + false AS \(ViewModel.isLastKey) + + FROM \(Interaction.self) + JOIN \(SessionThread.self) ON \(thread[.id]) = \(interaction[.threadId]) + LEFT JOIN \(ThreadTypingIndicator.self) ON \(typingIndicator[.threadId]) = \(thread[.id]) + LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId]) + LEFT JOIN \(DisappearingMessagesConfiguration.self) ON \(disappearingMessagesConfig[.threadId]) = \(interaction[.threadId]) + LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId]) + LEFT JOIN \(Quote.self) ON \(quote[.interactionId]) = \(interaction[.id]) + LEFT JOIN \(Attachment.self) AS \(ViewModel.quoteAttachmentKey) ON \(ViewModel.quoteAttachmentKey).\(attachmentIdColumnLiteral) = \(quote[.attachmentId]) + LEFT JOIN \(LinkPreview.self) ON ( + \(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND + \(Interaction.linkPreviewFilterLiteral) + ) + LEFT JOIN \(Attachment.self) AS \(ViewModel.linkPreviewAttachmentKey) ON \(ViewModel.linkPreviewAttachmentKey).\(attachmentIdColumnLiteral) = \(linkPreview[.attachmentId]) + LEFT JOIN ( + \(RecipientState.selectInteractionState( + tableLiteral: interactionStateTableLiteral, + idColumnLiteral: interactionStateInteractionIdColumnLiteral + )) + ) AS \(interactionStateTableLiteral) ON \(interactionStateTableLiteral).\(interactionStateInteractionIdColumnLiteral) = \(interaction[.id]) + LEFT JOIN \(RecipientState.self) AS \(readReceiptTableLiteral) ON ( + \(readReceiptTableLiteral).\(readReceiptReadTimestampMsColumnLiteral) IS NOT NULL AND + \(interaction[.id]) = \(readReceiptTableLiteral).\(interactionStateInteractionIdColumnLiteral) + ) + \(finalFilterSQL) + ORDER BY \(orderSQL) + \(finalLimitSQL) + """ + + return request.adapted { db in + let adapters = try splittingRowAdapters(columnCounts: [ + numColumnsBeforeLinkedRecords, + Profile.numberOfSelectedColumns(db), + Quote.numberOfSelectedColumns(db), + Attachment.numberOfSelectedColumns(db), + LinkPreview.numberOfSelectedColumns(db), + Attachment.numberOfSelectedColumns(db) + ]) + + return ScopeAdapter([ + ViewModel.profileString: adapters[1], + ViewModel.quoteString: adapters[2], + ViewModel.quoteAttachmentString: adapters[3], + ViewModel.linkPreviewString: adapters[4], + ViewModel.linkPreviewAttachmentString: adapters[5] + ]) + } + } + } +} + +public extension MessageViewModel.AttachmentInteractionInfo { + static let baseQuery: ((SQL?) -> AdaptedFetchRequest>) = { + return { additionalFilters -> AdaptedFetchRequest> in + let attachment: TypedTableAlias = TypedTableAlias() + let interactionAttachment: TypedTableAlias = TypedTableAlias() + + let finalFilterSQL: SQL = { + guard let additionalFilters: SQL = additionalFilters else { + return SQL(stringLiteral: "") + } + + return """ + WHERE \(additionalFilters) + """ + }() + let numColumnsBeforeLinkedRecords: Int = 1 + let request: SQLRequest = """ + SELECT + \(attachment.alias[Column.rowID]) AS \(AttachmentInteractionInfo.rowIdKey), + \(AttachmentInteractionInfo.attachmentKey).*, + \(AttachmentInteractionInfo.interactionAttachmentKey).* + FROM \(Attachment.self) + JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id]) + \(finalFilterSQL) + """ + + return request.adapted { db in + let adapters = try splittingRowAdapters(columnCounts: [ + numColumnsBeforeLinkedRecords, + Attachment.numberOfSelectedColumns(db), + InteractionAttachment.numberOfSelectedColumns(db) + ]) + + return ScopeAdapter([ + AttachmentInteractionInfo.attachmentString: adapters[1], + AttachmentInteractionInfo.interactionAttachmentString: adapters[2] + ]) + } + } + }() + + static var joinToViewModelQuerySQL: SQL = { + let interaction: TypedTableAlias = TypedTableAlias() + let attachment: TypedTableAlias = TypedTableAlias() + let interactionAttachment: TypedTableAlias = TypedTableAlias() + + return """ + JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id]) + JOIN \(Interaction.self) ON + \(interaction[.id]) = \(interactionAttachment[.interactionId]) + """ + }() + + static var groupViewModelQuerySQL: SQL = { + let interaction: TypedTableAlias = TypedTableAlias() + + return "\(interaction[.id])" + }() + + static func createAssociateDataClosure() -> (DataCache, DataCache) -> DataCache { + return { dataCache, pagedDataCache -> DataCache in + var updatedPagedDataCache: DataCache = pagedDataCache + + dataCache + .values + .grouped(by: \.interactionAttachment.interactionId) + .forEach { (interactionId: Int64, attachments: [MessageViewModel.AttachmentInteractionInfo]) in + guard + let interactionRowId: Int64 = updatedPagedDataCache.lookup[interactionId], + let dataToUpdate: ViewModel = updatedPagedDataCache.data[interactionRowId] + else { return } + + updatedPagedDataCache = updatedPagedDataCache.upserting( + dataToUpdate.with( + attachments: attachments + .sorted() + .map { $0.attachment } + ) + ) + } + + return updatedPagedDataCache + } + } +} diff --git a/SessionMessagingKit/Shared Models/ConversationCellViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift similarity index 77% rename from SessionMessagingKit/Shared Models/ConversationCellViewModel.swift rename to SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 19c26492f..3170537d3 100644 --- a/SessionMessagingKit/Shared Models/ConversationCellViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -4,200 +4,194 @@ import Foundation import GRDB import DifferenceKit -fileprivate typealias ViewModel = ConversationCell.ViewModel +fileprivate typealias ViewModel = SessionThreadViewModel -public enum ConversationCell {} - -// MARK: - ViewModel - -extension ConversationCell { - /// This type is used to populate the `ConversationCell` in the `HomeVC`, `MessageRequestsViewController` and the - /// `GlobalSearchViewController`, it has a number of query methods which can be used to retrieve the relevant data for each - /// screen in a single location in an attempt to avoid spreading out _almost_ duplicated code in multiple places +/// This type is used to populate the `ConversationCell` in the `HomeVC`, `MessageRequestsViewController` and the +/// `GlobalSearchViewController`, it has a number of query methods which can be used to retrieve the relevant data for each +/// screen in a single location in an attempt to avoid spreading out _almost_ duplicated code in multiple places +/// +/// **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 +public struct SessionThreadViewModel: FetchableRecord, Decodable, Equatable, Hashable, Differentiable { + public static let threadIdKey: SQL = SQL(stringLiteral: CodingKeys.threadId.stringValue) + public static let threadVariantKey: SQL = SQL(stringLiteral: CodingKeys.threadVariant.stringValue) + public static let threadCreationDateTimestampKey: SQL = SQL(stringLiteral: CodingKeys.threadCreationDateTimestamp.stringValue) + public static let threadMemberNamesKey: SQL = SQL(stringLiteral: CodingKeys.threadMemberNames.stringValue) + public static let threadIsNoteToSelfKey: SQL = SQL(stringLiteral: CodingKeys.threadIsNoteToSelf.stringValue) + public static let threadIsMessageRequestKey: SQL = SQL(stringLiteral: CodingKeys.threadIsMessageRequest.stringValue) + public static let threadRequiresApprovalKey: SQL = SQL(stringLiteral: CodingKeys.threadRequiresApproval.stringValue) + public static let threadShouldBeVisibleKey: SQL = SQL(stringLiteral: CodingKeys.threadShouldBeVisible.stringValue) + public static let threadIsPinnedKey: SQL = SQL(stringLiteral: CodingKeys.threadIsPinned.stringValue) + public static let threadIsBlockedKey: SQL = SQL(stringLiteral: CodingKeys.threadIsBlocked.stringValue) + public static let threadMutedUntilTimestampKey: SQL = SQL(stringLiteral: CodingKeys.threadMutedUntilTimestamp.stringValue) + public static let threadOnlyNotifyForMentionsKey: SQL = SQL(stringLiteral: CodingKeys.threadOnlyNotifyForMentions.stringValue) + public static let threadMessageDraftKey: SQL = SQL(stringLiteral: CodingKeys.threadMessageDraft.stringValue) + public static let threadContactIsTypingKey: SQL = SQL(stringLiteral: CodingKeys.threadContactIsTyping.stringValue) + public static let threadUnreadCountKey: SQL = SQL(stringLiteral: CodingKeys.threadUnreadCount.stringValue) + public static let threadUnreadMentionCountKey: SQL = SQL(stringLiteral: CodingKeys.threadUnreadMentionCount.stringValue) + public static let threadFirstUnreadInteractionIdKey: SQL = SQL(stringLiteral: CodingKeys.threadFirstUnreadInteractionId.stringValue) + public static let contactProfileKey: SQL = SQL(stringLiteral: CodingKeys.contactProfile.stringValue) + public static let closedGroupNameKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupName.stringValue) + public static let closedGroupUserCountKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupUserCount.stringValue) + public static let currentUserIsClosedGroupMemberKey: SQL = SQL(stringLiteral: CodingKeys.currentUserIsClosedGroupMember.stringValue) + public static let currentUserIsClosedGroupAdminKey: SQL = SQL(stringLiteral: CodingKeys.currentUserIsClosedGroupAdmin.stringValue) + public static let closedGroupProfileFrontKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupProfileFront.stringValue) + public static let closedGroupProfileBackKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupProfileBack.stringValue) + public static let closedGroupProfileBackFallbackKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupProfileBackFallback.stringValue) + public static let openGroupNameKey: SQL = SQL(stringLiteral: CodingKeys.openGroupName.stringValue) + public static let openGroupServerKey: SQL = SQL(stringLiteral: CodingKeys.openGroupServer.stringValue) + public static let openGroupRoomKey: SQL = SQL(stringLiteral: CodingKeys.openGroupRoom.stringValue) + public static let openGroupProfilePictureDataKey: SQL = SQL(stringLiteral: CodingKeys.openGroupProfilePictureData.stringValue) + public static let openGroupUserCountKey: SQL = SQL(stringLiteral: CodingKeys.openGroupUserCount.stringValue) + public static let interactionIdKey: SQL = SQL(stringLiteral: CodingKeys.interactionId.stringValue) + public static let interactionVariantKey: SQL = SQL(stringLiteral: CodingKeys.interactionVariant.stringValue) + public static let interactionTimestampMsKey: SQL = SQL(stringLiteral: CodingKeys.interactionTimestampMs.stringValue) + public static let interactionBodyKey: SQL = SQL(stringLiteral: CodingKeys.interactionBody.stringValue) + public static let interactionIsOpenGroupInvitationKey: SQL = SQL(stringLiteral: CodingKeys.interactionIsOpenGroupInvitation.stringValue) + public static let interactionAttachmentDescriptionInfoKey: SQL = SQL(stringLiteral: CodingKeys.interactionAttachmentDescriptionInfo.stringValue) + public static let interactionAttachmentCountKey: SQL = SQL(stringLiteral: CodingKeys.interactionAttachmentCount.stringValue) + public static let authorNameInternalKey: SQL = SQL(stringLiteral: CodingKeys.authorNameInternal.stringValue) + public static let currentUserPublicKeyKey: SQL = SQL(stringLiteral: CodingKeys.currentUserPublicKey.stringValue) + + public static let threadUnreadCountString: String = CodingKeys.threadUnreadCount.stringValue + public static let threadUnreadMentionCountString: String = CodingKeys.threadUnreadMentionCount.stringValue + public static let threadFirstUnreadInteractionIdString: String = CodingKeys.threadFirstUnreadInteractionId.stringValue + public static let closedGroupUserCountString: String = CodingKeys.closedGroupUserCount.stringValue + public static let openGroupUserCountString: String = CodingKeys.openGroupUserCount.stringValue + public static let contactProfileString: String = CodingKeys.contactProfile.stringValue + public static let closedGroupProfileFrontString: String = CodingKeys.closedGroupProfileFront.stringValue + public static let closedGroupProfileBackString: String = CodingKeys.closedGroupProfileBack.stringValue + public static let closedGroupProfileBackFallbackString: String = CodingKeys.closedGroupProfileBackFallback.stringValue + public static let interactionAttachmentDescriptionInfoString: String = CodingKeys.interactionAttachmentDescriptionInfo.stringValue + + public var differenceIdentifier: String { threadId } + + public let threadId: String + public let threadVariant: SessionThread.Variant + private let threadCreationDateTimestamp: TimeInterval + public let threadMemberNames: String? + + public let threadIsNoteToSelf: Bool + public var threadIsMessageRequest: Bool? + public let threadRequiresApproval: Bool? + public let threadShouldBeVisible: Bool? + public let threadIsPinned: Bool + public var threadIsBlocked: Bool? + public let threadMutedUntilTimestamp: TimeInterval? + public let threadOnlyNotifyForMentions: Bool? + public let threadMessageDraft: String? + + public let threadContactIsTyping: Bool? + public let threadUnreadCount: UInt? + public let threadUnreadMentionCount: UInt? + public let threadFirstUnreadInteractionId: Int64? + + // Thread display info + + private let contactProfile: Profile? + private let closedGroupProfileFront: Profile? + private let closedGroupProfileBack: Profile? + private let closedGroupProfileBackFallback: Profile? + public let closedGroupName: String? + private let closedGroupUserCount: Int? + public let currentUserIsClosedGroupMember: Bool? + public let currentUserIsClosedGroupAdmin: Bool? + public let openGroupName: String? + public let openGroupServer: String? + public let openGroupRoom: String? + public let openGroupProfilePictureData: Data? + private let openGroupUserCount: Int? + + // Interaction display info + + public let interactionId: Int64? + public let interactionVariant: Interaction.Variant? + private let interactionTimestampMs: Int64? + public let interactionBody: String? + public let interactionState: RecipientState.State? + public let interactionIsOpenGroupInvitation: Bool? + public let interactionAttachmentDescriptionInfo: Attachment.DescriptionInfo? + public let interactionAttachmentCount: Int? + + public let authorId: String? + private let authorNameInternal: String? + public let currentUserPublicKey: String + + // UI specific logic + + public var displayName: String { + return SessionThread.displayName( + threadId: threadId, + variant: threadVariant, + closedGroupName: closedGroupName, + openGroupName: openGroupName, + isNoteToSelf: threadIsNoteToSelf, + profile: profile + ) + } + + public var profile: Profile? { + switch threadVariant { + case .contact: return contactProfile + case .closedGroup: return (closedGroupProfileBack ?? closedGroupProfileBackFallback) + case .openGroup: return nil + } + } + + public var additionalProfile: Profile? { + switch threadVariant { + case .closedGroup: return closedGroupProfileFront + default: return nil + } + } + + public var lastInteractionDate: Date { + guard let interactionTimestampMs: Int64 = interactionTimestampMs else { + return Date(timeIntervalSince1970: threadCreationDateTimestamp) + } + + return Date(timeIntervalSince1970: (TimeInterval(interactionTimestampMs) / 1000)) + } + + public var enabledMessageTypes: MessageInputTypes { + guard !threadIsNoteToSelf else { return .all } + + return (threadRequiresApproval == false && threadIsMessageRequest == false ? + .all : + .textOnly + ) + } + + public var userCount: Int? { + switch threadVariant { + case .contact: return nil + case .closedGroup: return closedGroupUserCount + case .openGroup: return openGroupUserCount + } + } + + /// This function returns the profile name formatted for the specific type of thread provided /// - /// **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 - public struct ViewModel: FetchableRecord, Decodable, Equatable, Hashable, Differentiable { - public static let threadIdKey: SQL = SQL(stringLiteral: CodingKeys.threadId.stringValue) - public static let threadVariantKey: SQL = SQL(stringLiteral: CodingKeys.threadVariant.stringValue) - public static let threadCreationDateTimestampKey: SQL = SQL(stringLiteral: CodingKeys.threadCreationDateTimestamp.stringValue) - public static let threadMemberNamesKey: SQL = SQL(stringLiteral: CodingKeys.threadMemberNames.stringValue) - public static let threadIsNoteToSelfKey: SQL = SQL(stringLiteral: CodingKeys.threadIsNoteToSelf.stringValue) - public static let threadIsMessageRequestKey: SQL = SQL(stringLiteral: CodingKeys.threadIsMessageRequest.stringValue) - public static let threadRequiresApprovalKey: SQL = SQL(stringLiteral: CodingKeys.threadRequiresApproval.stringValue) - public static let threadShouldBeVisibleKey: SQL = SQL(stringLiteral: CodingKeys.threadShouldBeVisible.stringValue) - public static let threadIsPinnedKey: SQL = SQL(stringLiteral: CodingKeys.threadIsPinned.stringValue) - public static let threadIsBlockedKey: SQL = SQL(stringLiteral: CodingKeys.threadIsBlocked.stringValue) - public static let threadMutedUntilTimestampKey: SQL = SQL(stringLiteral: CodingKeys.threadMutedUntilTimestamp.stringValue) - public static let threadOnlyNotifyForMentionsKey: SQL = SQL(stringLiteral: CodingKeys.threadOnlyNotifyForMentions.stringValue) - public static let threadMessageDraftKey: SQL = SQL(stringLiteral: CodingKeys.threadMessageDraft.stringValue) - public static let threadContactIsTypingKey: SQL = SQL(stringLiteral: CodingKeys.threadContactIsTyping.stringValue) - public static let threadUnreadCountKey: SQL = SQL(stringLiteral: CodingKeys.threadUnreadCount.stringValue) - public static let threadUnreadMentionCountKey: SQL = SQL(stringLiteral: CodingKeys.threadUnreadMentionCount.stringValue) - public static let threadFirstUnreadInteractionIdKey: SQL = SQL(stringLiteral: CodingKeys.threadFirstUnreadInteractionId.stringValue) - public static let contactProfileKey: SQL = SQL(stringLiteral: CodingKeys.contactProfile.stringValue) - public static let closedGroupNameKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupName.stringValue) - public static let closedGroupUserCountKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupUserCount.stringValue) - public static let currentUserIsClosedGroupMemberKey: SQL = SQL(stringLiteral: CodingKeys.currentUserIsClosedGroupMember.stringValue) - public static let currentUserIsClosedGroupAdminKey: SQL = SQL(stringLiteral: CodingKeys.currentUserIsClosedGroupAdmin.stringValue) - public static let closedGroupProfileFrontKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupProfileFront.stringValue) - public static let closedGroupProfileBackKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupProfileBack.stringValue) - public static let closedGroupProfileBackFallbackKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupProfileBackFallback.stringValue) - public static let openGroupNameKey: SQL = SQL(stringLiteral: CodingKeys.openGroupName.stringValue) - public static let openGroupServerKey: SQL = SQL(stringLiteral: CodingKeys.openGroupServer.stringValue) - public static let openGroupRoomKey: SQL = SQL(stringLiteral: CodingKeys.openGroupRoom.stringValue) - public static let openGroupProfilePictureDataKey: SQL = SQL(stringLiteral: CodingKeys.openGroupProfilePictureData.stringValue) - public static let openGroupUserCountKey: SQL = SQL(stringLiteral: CodingKeys.openGroupUserCount.stringValue) - public static let interactionIdKey: SQL = SQL(stringLiteral: CodingKeys.interactionId.stringValue) - public static let interactionVariantKey: SQL = SQL(stringLiteral: CodingKeys.interactionVariant.stringValue) - public static let interactionTimestampMsKey: SQL = SQL(stringLiteral: CodingKeys.interactionTimestampMs.stringValue) - public static let interactionBodyKey: SQL = SQL(stringLiteral: CodingKeys.interactionBody.stringValue) - public static let interactionIsOpenGroupInvitationKey: SQL = SQL(stringLiteral: CodingKeys.interactionIsOpenGroupInvitation.stringValue) - public static let interactionAttachmentDescriptionInfoKey: SQL = SQL(stringLiteral: CodingKeys.interactionAttachmentDescriptionInfo.stringValue) - public static let interactionAttachmentCountKey: SQL = SQL(stringLiteral: CodingKeys.interactionAttachmentCount.stringValue) - public static let authorNameInternalKey: SQL = SQL(stringLiteral: CodingKeys.authorNameInternal.stringValue) - public static let currentUserPublicKeyKey: SQL = SQL(stringLiteral: CodingKeys.currentUserPublicKey.stringValue) - - public static let threadUnreadCountString: String = CodingKeys.threadUnreadCount.stringValue - public static let threadUnreadMentionCountString: String = CodingKeys.threadUnreadMentionCount.stringValue - public static let threadFirstUnreadInteractionIdString: String = CodingKeys.threadFirstUnreadInteractionId.stringValue - public static let closedGroupUserCountString: String = CodingKeys.closedGroupUserCount.stringValue - public static let openGroupUserCountString: String = CodingKeys.openGroupUserCount.stringValue - public static let contactProfileString: String = CodingKeys.contactProfile.stringValue - public static let closedGroupProfileFrontString: String = CodingKeys.closedGroupProfileFront.stringValue - public static let closedGroupProfileBackString: String = CodingKeys.closedGroupProfileBack.stringValue - public static let closedGroupProfileBackFallbackString: String = CodingKeys.closedGroupProfileBackFallback.stringValue - public static let interactionAttachmentDescriptionInfoString: String = CodingKeys.interactionAttachmentDescriptionInfo.stringValue - - public var differenceIdentifier: ViewModel { self } // TODO: Confirm this does what we want (ie. update on any data change) - - public let threadId: String - public let threadVariant: SessionThread.Variant - private let threadCreationDateTimestamp: TimeInterval - public let threadMemberNames: String? - - public let threadIsNoteToSelf: Bool - public var threadIsMessageRequest: Bool? - public let threadRequiresApproval: Bool? - public let threadShouldBeVisible: Bool? - public let threadIsPinned: Bool - public var threadIsBlocked: Bool? - public let threadMutedUntilTimestamp: TimeInterval? - public let threadOnlyNotifyForMentions: Bool? - public let threadMessageDraft: String? - - public let threadContactIsTyping: Bool? - public let threadUnreadCount: UInt? - public let threadUnreadMentionCount: UInt? - public let threadFirstUnreadInteractionId: Int64? - - // Thread display info - - private let contactProfile: Profile? - private let closedGroupProfileFront: Profile? - private let closedGroupProfileBack: Profile? - private let closedGroupProfileBackFallback: Profile? - public let closedGroupName: String? - private let closedGroupUserCount: Int? - public let currentUserIsClosedGroupMember: Bool? - public let currentUserIsClosedGroupAdmin: Bool? - public let openGroupName: String? - public let openGroupServer: String? - public let openGroupRoom: String? - public let openGroupProfilePictureData: Data? - private let openGroupUserCount: Int? - - // Interaction display info - - public let interactionId: Int64? - public let interactionVariant: Interaction.Variant? - private let interactionTimestampMs: Int64? - public let interactionBody: String? - public let interactionState: RecipientState.State? - public let interactionIsOpenGroupInvitation: Bool? - public let interactionAttachmentDescriptionInfo: Attachment.DescriptionInfo? - public let interactionAttachmentCount: Int? - - public let authorId: String? - private let authorNameInternal: String? - public let currentUserPublicKey: String - - // UI specific logic - - public var displayName: String { - return SessionThread.displayName( - threadId: threadId, - variant: threadVariant, - closedGroupName: closedGroupName, - openGroupName: openGroupName, - isNoteToSelf: threadIsNoteToSelf, - profile: profile + /// **Note:** The 'threadVariant' parameter is used for profile context but in the search results we actually want this + /// to always behave as the `contact` variant which is why this needs to be a function instead of just using the provided + /// parameter + public func authorName(for threadVariant: SessionThread.Variant) -> String { + return Profile.displayName( + for: threadVariant, + id: (authorId ?? threadId), + name: authorNameInternal, + nickname: nil, // Folded into 'authorName' within the Query + customFallback: (threadVariant == .contact ? + "Anonymous" : + nil ) - } - - public var profile: Profile? { - switch threadVariant { - case .contact: return contactProfile - case .closedGroup: return (closedGroupProfileBack ?? closedGroupProfileBackFallback) - case .openGroup: return nil - } - } - - public var additionalProfile: Profile? { - switch threadVariant { - case .closedGroup: return closedGroupProfileFront - default: return nil - } - } - - public var lastInteractionDate: Date { - guard let interactionTimestampMs: Int64 = interactionTimestampMs else { - return Date(timeIntervalSince1970: threadCreationDateTimestamp) - } - - return Date(timeIntervalSince1970: (TimeInterval(interactionTimestampMs) / 1000)) - } - - public var enabledMessageTypes: MessageInputTypes { - guard !threadIsNoteToSelf else { return .all } - - return (threadRequiresApproval == false && threadIsMessageRequest == false ? - .all : - .textOnly - ) - } - - public var userCount: Int? { - switch threadVariant { - case .contact: return nil - case .closedGroup: return closedGroupUserCount - case .openGroup: return openGroupUserCount - } - } - - /// This function returns the profile name formatted for the specific type of thread provided - /// - /// **Note:** The 'threadVariant' parameter is used for profile context but in the search results we actually want this - /// to always behave as the `contact` variant which is why this needs to be a function instead of just using the provided - /// parameter - public func authorName(for threadVariant: SessionThread.Variant) -> String { - return Profile.displayName( - for: threadVariant, - id: (authorId ?? threadId), - name: authorNameInternal, - nickname: nil, // Folded into 'authorName' within the Query - customFallback: (threadVariant == .contact ? - "Anonymous" : - nil - ) - ) - } + ) } } // MARK: - Convenience Initialization -public extension ConversationCell.ViewModel { +public extension SessionThreadViewModel { // Note: This init method is only used system-created cells or empty states init(unreadCount: UInt = 0) { self.threadId = "INVALID_THREAD_ID" @@ -255,12 +249,12 @@ public extension ConversationCell.ViewModel { // MARK: - HomeVC & MessageRequestsViewController -public extension ConversationCell.ViewModel { +public extension SessionThreadViewModel { private static func baseQuery( userPublicKey: String, filters: SQL, ordering: SQL - ) -> AdaptedFetchRequest> { + ) -> AdaptedFetchRequest> { let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() let typingIndicator: TypedTableAlias = TypedTableAlias() @@ -370,7 +364,7 @@ public extension ConversationCell.ViewModel { \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND \(SQL("\(groupMember[.profileId]) = \(userPublicKey)")) ) - LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(interaction[.threadId]) + LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON ( \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( @@ -456,7 +450,7 @@ public extension ConversationCell.ViewModel { } } - static func homeQuery(userPublicKey: String) -> AdaptedFetchRequest> { + static func homeQuery(userPublicKey: String) -> AdaptedFetchRequest> { let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() @@ -481,7 +475,7 @@ public extension ConversationCell.ViewModel { ) } - static func messageRequestsQuery(userPublicKey: String) -> AdaptedFetchRequest> { + static func messageRequestsQuery(userPublicKey: String) -> AdaptedFetchRequest> { let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() @@ -508,11 +502,11 @@ public extension ConversationCell.ViewModel { // MARK: - ConversationVC -public extension ConversationCell.ViewModel { - static func conversationQuery(threadId: String, userPublicKey: String) -> AdaptedFetchRequest> { +public extension SessionThreadViewModel { + static func conversationQuery(threadId: String, userPublicKey: String) -> AdaptedFetchRequest> { let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() - let typingIndicator: TypedTableAlias = TypedTableAlias() + let typingIndicator: TypedTableAlias = TypedTableAlias() // TODO: Remove this (not needed here - tracked via the messages) let closedGroup: TypedTableAlias = TypedTableAlias() let groupMember: TypedTableAlias = TypedTableAlias() let openGroup: TypedTableAlias = TypedTableAlias() @@ -550,8 +544,10 @@ public extension ConversationCell.ViewModel { ) ) AS \(ViewModel.threadIsMessageRequestKey), ( - IFNULL(\(contact[.isApproved]), false) = false OR - IFNULL(\(contact[.didApproveMe]), false) = false + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND ( + IFNULL(\(contact[.isApproved]), false) = false OR + IFNULL(\(contact[.didApproveMe]), false) = false + ) ) AS \(ViewModel.threadRequiresApprovalKey), \(thread[.shouldBeVisible]) AS \(ViewModel.threadShouldBeVisibleKey), @@ -575,6 +571,8 @@ public extension ConversationCell.ViewModel { \(openGroup[.room]) AS \(ViewModel.openGroupRoomKey), \(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey), \(openGroup[.userCount]) AS \(ViewModel.openGroupUserCountKey), + + \(interaction[.id]) AS \(ViewModel.interactionIdKey), \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) @@ -592,6 +590,11 @@ public extension ConversationCell.ViewModel { \(SQL("\(interaction[.threadId]) = \(threadId)")) ) ) AS \(firstUnreadInteractionTableLiteral) ON \(firstUnreadInteractionTableLiteral).\(interactionThreadIdLiteral) = \(thread[.id]) + LEFT JOIN ( + SELECT *, MAX(\(interaction[.timestampMs])) + FROM \(Interaction.self) + GROUP BY \(interaction[.threadId]) + ) AS \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id]) LEFT JOIN ( SELECT \(interaction[.threadId]), @@ -651,11 +654,97 @@ public extension ConversationCell.ViewModel { ]) } } + + static func conversationSettingsProfileQuery(threadId: String, userPublicKey: String) -> AdaptedFetchRequest> { + let thread: TypedTableAlias = TypedTableAlias() + let closedGroup: TypedTableAlias = TypedTableAlias() + let groupMember: TypedTableAlias = TypedTableAlias() + let openGroup: TypedTableAlias = TypedTableAlias() + + let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) + + /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before + /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to + /// parse and might throw + /// + /// Explicitly set default values for the fields ignored for search results + let numColumnsBeforeProfiles: Int = 5 + let request: SQLRequest = """ + SELECT + \(thread[.id]) AS \(ViewModel.threadIdKey), + \(thread[.variant]) AS \(ViewModel.threadVariantKey), + \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), + + false AS \(ViewModel.threadIsNoteToSelfKey), + false AS \(ViewModel.threadIsPinnedKey), + + \(ViewModel.contactProfileKey).*, + \(ViewModel.closedGroupProfileFrontKey).*, + \(ViewModel.closedGroupProfileBackKey).*, + \(ViewModel.closedGroupProfileBackFallbackKey).*, + \(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey), + + \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) + + FROM \(SessionThread.self) + LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) + LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) + LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) + + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON ( + \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( + SELECT MIN(\(groupMember[.profileId])) + FROM \(GroupMember.self) + WHERE ( + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND + \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(SQL("\(groupMember[.profileId]) != \(userPublicKey)")) + ) + ) + ) + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON ( + \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) != \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) AND + \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) = ( + SELECT MAX(\(groupMember[.profileId])) + FROM \(GroupMember.self) + WHERE ( + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND + \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(SQL("\(groupMember[.profileId]) != \(userPublicKey)")) + ) + ) + ) + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON ( + \(closedGroup[.threadId]) IS NOT NULL AND + \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) IS NULL AND + \(ViewModel.closedGroupProfileBackFallbackKey).\(profileIdColumnLiteral) = \(SQL("\(userPublicKey)")) + ) + + WHERE \(SQL("\(thread[.id]) = \(threadId)")) + """ + + return request.adapted { db in + let adapters = try splittingRowAdapters(columnCounts: [ + numColumnsBeforeProfiles, + Profile.numberOfSelectedColumns(db), + Profile.numberOfSelectedColumns(db), + Profile.numberOfSelectedColumns(db), + Profile.numberOfSelectedColumns(db) + ]) + + return ScopeAdapter([ + ViewModel.contactProfileString: adapters[1], + ViewModel.closedGroupProfileFrontString: adapters[2], + ViewModel.closedGroupProfileBackString: adapters[3], + ViewModel.closedGroupProfileBackFallbackString: adapters[4] + ]) + } + } } // MARK: - Search Queries -public extension ConversationCell.ViewModel { +public extension SessionThreadViewModel { static func searchTermParts(_ searchTerm: String) -> [String] { /// Process the search term in order to extract the parts of the search pattern we want /// @@ -698,7 +787,7 @@ public extension ConversationCell.ViewModel { return pattern } - static func messagesQuery(userPublicKey: String, pattern: FTS5Pattern) -> AdaptedFetchRequest> { + static func messagesQuery(userPublicKey: String, pattern: FTS5Pattern) -> AdaptedFetchRequest> { let interaction: TypedTableAlias = TypedTableAlias() let thread: TypedTableAlias = TypedTableAlias() let closedGroup: TypedTableAlias = TypedTableAlias() @@ -813,7 +902,7 @@ public extension ConversationCell.ViewModel { /// - Closed group member name /// - Open group name /// - "Note to self" text match - static func contactsAndGroupsQuery(userPublicKey: String, pattern: FTS5Pattern, searchTerm: String) -> AdaptedFetchRequest> { + static func contactsAndGroupsQuery(userPublicKey: String, pattern: FTS5Pattern, searchTerm: String) -> AdaptedFetchRequest> { let thread: TypedTableAlias = TypedTableAlias() let closedGroup: TypedTableAlias = TypedTableAlias() let groupMember: TypedTableAlias = TypedTableAlias() @@ -1177,7 +1266,7 @@ public extension ConversationCell.ViewModel { } /// This method returns only the 'Note to Self' thread in the structure of a search result conversation - static func noteToSelfOnlyQuery(userPublicKey: String) -> AdaptedFetchRequest> { + static func noteToSelfOnlyQuery(userPublicKey: String) -> AdaptedFetchRequest> { let thread: TypedTableAlias = TypedTableAlias() let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) @@ -1224,8 +1313,8 @@ public extension ConversationCell.ViewModel { // MARK: - Share Extension -public extension ConversationCell.ViewModel { - static func shareQuery(userPublicKey: String) -> AdaptedFetchRequest> { +public extension SessionThreadViewModel { + static func shareQuery(userPublicKey: String) -> AdaptedFetchRequest> { let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() let closedGroup: TypedTableAlias = TypedTableAlias() @@ -1272,7 +1361,7 @@ public extension ConversationCell.ViewModel { ) AS \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id]) LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) - LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(interaction[.threadId]) + LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON ( \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( diff --git a/SessionMessagingKit/Utilities/OWSPreferences.h b/SessionMessagingKit/Utilities/OWSPreferences.h index 39904bd44..6bc4234fb 100644 --- a/SessionMessagingKit/Utilities/OWSPreferences.h +++ b/SessionMessagingKit/Utilities/OWSPreferences.h @@ -9,17 +9,6 @@ NS_ASSUME_NONNULL_BEGIN -/** - * The users privacy preference for what kind of content to show in lock screen notifications. - */ -typedef NS_ENUM(NSUInteger, NotificationType) { - NotificationNoNameNoPreview, - NotificationNameNoPreview, - NotificationNamePreview, -}; - -NSString *NSStringForNotificationType(NotificationType value); - // Used when migrating logging to NSUserDefaults. extern NSString *const OWSPreferencesSignalDatabaseCollection; extern NSString *const OWSPreferencesCallLoggingDidChangeNotification; diff --git a/SessionShareExtension/SimplifiedConversationCell.swift b/SessionShareExtension/SimplifiedConversationCell.swift index c1dc7cca8..b3427ebb3 100644 --- a/SessionShareExtension/SimplifiedConversationCell.swift +++ b/SessionShareExtension/SimplifiedConversationCell.swift @@ -87,7 +87,7 @@ final class SimplifiedConversationCell: UITableViewCell { // MARK: - Updating - public func update(with cellViewModel: ConversationCell.ViewModel) { + public func update(with cellViewModel: SessionThreadViewModel) { accentLineView.alpha = (cellViewModel.threadIsBlocked == true ? 1 : 0) profilePictureView.update( publicKey: cellViewModel.threadId, diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index aee2717be..40f76ef27 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -152,7 +152,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView ) } - private func handleUpdates(_ updatedViewData: [ConversationCell.ViewModel]) { + 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 { diff --git a/SessionShareExtension/ThreadPickerViewModel.swift b/SessionShareExtension/ThreadPickerViewModel.swift index 120171882..b3090120e 100644 --- a/SessionShareExtension/ThreadPickerViewModel.swift +++ b/SessionShareExtension/ThreadPickerViewModel.swift @@ -8,7 +8,7 @@ import SessionMessagingKit public class ThreadPickerViewModel { /// This value is the current state of the view - public private(set) var viewData: [ConversationCell.ViewModel] = [] + 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 @@ -16,10 +16,10 @@ public class ThreadPickerViewModel { /// **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 public lazy var observableViewData = ValueObservation - .trackingConstantRegion { db -> [ConversationCell.ViewModel] in + .trackingConstantRegion { db -> [SessionThreadViewModel] in let userPublicKey: String = getUserHexEncodedPublicKey(db) - return try ConversationCell.ViewModel + return try SessionThreadViewModel .shareQuery(userPublicKey: userPublicKey) .fetchAll(db) } @@ -27,7 +27,7 @@ public class ThreadPickerViewModel { // MARK: - Functions - public func updateData(_ updatedData: [ConversationCell.ViewModel]) { + public func updateData(_ updatedData: [SessionThreadViewModel]) { self.viewData = updatedData } } diff --git a/SessionUtilitiesKit/Database/Models/Job.swift b/SessionUtilitiesKit/Database/Models/Job.swift index e0db073ca..147d87fd1 100644 --- a/SessionUtilitiesKit/Database/Models/Job.swift +++ b/SessionUtilitiesKit/Database/Models/Job.swift @@ -6,9 +6,24 @@ import GRDB public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "job" } internal static let dependencyForeignKey = ForeignKey([Columns.id], to: [JobDependencies.Columns.dependantId]) - internal static let dependantJobForeignKey = ForeignKey([Columns.id], to: [JobDependencies.Columns.jobId]) - internal static let dependencies = hasMany(Job.self, using: dependencyForeignKey) - internal static let dependantJobs = hasMany(Job.self, using: dependencyForeignKey) + public static let dependantJobDependency = hasMany( + JobDependencies.self, + using: JobDependencies.jobForeignKey + ) + public static let dependancyJobDependency = hasMany( + JobDependencies.self, + using: JobDependencies.dependantForeignKey + ) + internal static let jobsThisJobDependsOn = hasMany( + Job.self, + through: dependantJobDependency, + using: JobDependencies.dependant + ) + internal static let jobsThatDependOnThisJob = hasMany( + Job.self, + through: dependancyJobDependency, + using: JobDependencies.job + ) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -50,7 +65,7 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer /// /// **Note:** This is a blocking job so it will run before any other jobs and prevent them from /// running until it's complete - case failedMessages = 1000 + case failedMessageSends = 1000 /// This is a recurring job that runs on launch and flags any attachments marked as 'uploading' to /// be in their 'failed' state @@ -151,7 +166,7 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer /// **Note:** When completing a job the dependencies **MUST** be cleared before the job is /// deleted or it will automatically delete any dependant jobs public var dependencies: QueryInterfaceRequest { - request(for: Job.dependencies) + request(for: Job.jobsThisJobDependsOn) } /// The other jobs which depend on this job @@ -159,7 +174,7 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer /// **Note:** When completing a job the dependencies **MUST** be cleared before the job is /// deleted or it will automatically delete any dependant jobs public var dependantJobs: QueryInterfaceRequest { - request(for: Job.dependantJobs) + request(for: Job.jobsThatDependOnThisJob) } // MARK: - Initialization @@ -242,8 +257,12 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer // MARK: - GRDB Interactions extension Job { - internal static func filterPendingJobs(variants: [Variant], excludeFutureJobs: Bool = true) -> QueryInterfaceRequest { - let query: QueryInterfaceRequest = Job + internal static func filterPendingJobs( + variants: [Variant], + excludeFutureJobs: Bool = true, + includeJobsWithDependencies: Bool = false + ) -> QueryInterfaceRequest { + var query: QueryInterfaceRequest = Job .filter( // Retrieve all 'runOnce' and 'recurring' jobs [ @@ -263,12 +282,15 @@ extension Job { .order(Job.Columns.nextRunTimestamp) .order(Job.Columns.id) - guard excludeFutureJobs else { - return query + if excludeFutureJobs { + query = query.filter(Job.Columns.nextRunTimestamp <= Date().timeIntervalSince1970) + } + + if !includeJobsWithDependencies { + query = query.having(Job.jobsThisJobDependsOn.isEmpty) } return query - .filter(Job.Columns.nextRunTimestamp <= Date().timeIntervalSince1970) } } diff --git a/SessionUtilitiesKit/Database/Models/JobDependencies.swift b/SessionUtilitiesKit/Database/Models/JobDependencies.swift index 0ee8c10b0..9cda7ceb1 100644 --- a/SessionUtilitiesKit/Database/Models/JobDependencies.swift +++ b/SessionUtilitiesKit/Database/Models/JobDependencies.swift @@ -6,6 +6,7 @@ import GRDB public struct JobDependencies: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "jobDependencies" } internal static let jobForeignKey = ForeignKey([Columns.jobId], to: [Job.Columns.id]) + internal static let dependantForeignKey = ForeignKey([Columns.dependantId], to: [Job.Columns.id]) internal static let job = belongsTo(Job.self, using: jobForeignKey) internal static let dependant = hasOne(Job.self, using: Job.dependencyForeignKey) diff --git a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift index 6c29b8a42..caf730300 100644 --- a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift +++ b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift @@ -200,7 +200,7 @@ public class PagedDatabaseObserver: TransactionObserver where } // If there are no inserted/updated rows then trigger the update callback and stop here - let rowIdsToQuery: [Int64] = committedChanges + let rowIdsToQuery: [Int64] = relevantChanges .filter { $0.kind != .delete } .map { $0.rowId } @@ -223,17 +223,34 @@ public class PagedDatabaseObserver: TransactionObserver where // 'currentCount' and the indexes are sequential (ie. more than the current loaded content was // added at once) let itemIndexesAreSequential: Bool = (itemIndexes.map { $0 - 1 }.dropFirst() == itemIndexes.dropLast()) - let hasOneValidIndex: Bool = itemIndexes.contains(where: { $0 < updatedPageInfo.currentCount }) + let hasOneValidIndex: Bool = itemIndexes.contains(where: { index -> Bool in + index >= updatedPageInfo.pageOffset && + index < updatedPageInfo.currentCount + }) let validRowIds: [Int64] = (itemIndexesAreSequential && hasOneValidIndex ? rowIdsToQuery : zip(itemIndexes, rowIdsToQuery) - .filter { index, _ -> Bool in index < updatedPageInfo.currentCount } + .filter { index, _ -> Bool in + index >= updatedPageInfo.pageOffset && + index < updatedPageInfo.currentCount + } .map { _, rowId -> Int64 in rowId } ) + let countBefore: Int = itemIndexes.filter { $0 < updatedPageInfo.pageOffset }.count + + // Update the offset and totalCount even if the rows are outside of the current page (need to + // in order to ensure the 'load more' sections are accurate) + updatedPageInfo = PagedData.PageInfo( + pageSize: updatedPageInfo.pageSize, + pageOffset: (updatedPageInfo.pageOffset + countBefore), + currentCount: updatedPageInfo.currentCount, + totalCount: (updatedPageInfo.totalCount + itemIndexes.count) + ) - // If there are no valid attachment row ids then stop here + // If there are no valid row ids then stop here (trigger updates though since the page info + // has changes) guard !validRowIds.isEmpty else { - updateDataAndCallbackIfNeeded(updatedDataCache, updatedPageInfo, !deletionChanges.isEmpty) + updateDataAndCallbackIfNeeded(updatedDataCache, updatedPageInfo, true) return } @@ -243,24 +260,17 @@ public class PagedDatabaseObserver: TransactionObserver where .fetchAll(db)) .defaulting(to: []) - // If the inserted/updated rows we irrelevant (associated to data which doesn't pass - // the filter) then trigger the update callback (if there were deletions) and stop here - guard !updatedItems.isEmpty else { - updateDataAndCallbackIfNeeded(updatedDataCache, updatedPageInfo, !deletionChanges.isEmpty) - return - } - // Process the upserted data updatedDataCache = updatedDataCache.upserting(items: updatedItems) - // Update the page info for the upserted data + // Update the currentCount for the upserted data let dataSizeDiff: Int = (updatedDataCache.count - oldDataCount) updatedPageInfo = PagedData.PageInfo( pageSize: updatedPageInfo.pageSize, pageOffset: updatedPageInfo.pageOffset, currentCount: (updatedPageInfo.currentCount + dataSizeDiff), - totalCount: (updatedPageInfo.totalCount + dataSizeDiff) + totalCount: updatedPageInfo.totalCount ) updateDataAndCallbackIfNeeded(updatedDataCache, updatedPageInfo, true) @@ -526,6 +536,7 @@ public protocol ErasedAssociatedRecord { var databaseTableName: String { get } var observedChanges: [PagedData.ObservedChanges] { get } var joinToPagedType: SQL { get } + var groupPagedType: SQL? { get } func tryUpdateForDatabaseCommit( _ db: Database, @@ -717,8 +728,7 @@ public enum PagedData { idColumn: String, requiredJoinSQL: SQL? = nil, orderSQL: SQL, - filterSQL: SQL, - joinToPagedType: SQL? = nil + filterSQL: SQL ) -> Int? { let tableNameLiteral: SQL = SQL(stringLiteral: tableName) let idColumnLiteral: SQL = SQL(stringLiteral: idColumn) @@ -731,7 +741,6 @@ public enum PagedData { ROW_NUMBER() OVER (ORDER BY \(orderSQL)) AS rowIndex FROM \(tableNameLiteral) \(requiredJoinSQL ?? "") - \(joinToPagedType ?? "") WHERE \(filterSQL) ) AS data WHERE \(SQL("data.\(idColumnLiteral) = \(id)")) @@ -750,9 +759,42 @@ public enum PagedData { requiredJoinSQL: SQL? = nil, orderSQL: SQL, filterSQL: SQL, - joinToPagedType: SQL? = nil + joinToPagedType: SQL? = nil, + groupPagedType: SQL? = nil ) -> [Int64] { let tableNameLiteral: SQL = SQL(stringLiteral: tableName) + + /// **Note:** `ROW_NUMBER` works by returning the index of the row in a given query, unfortunately when dealing + /// with associated data its possible for multiple results to connect to an individual paged result, this throws off the + /// indexes so in this case we need to do some sneaky aggregation and grouping and then individually retrieve each + /// index to prevent this + guard joinToPagedType == nil || rowIds.count == 1 else { + guard let groupPagedType: SQL = groupPagedType else { return [] } + + let groupByLiteral: SQL = SQL(stringLiteral: "GROUP BY ") + + return rowIds.compactMap { rowId in + let groupedRequest: SQLRequest = """ + SELECT + (data.rowIndex - 1) AS rowIndex -- Converting from 1-Indexed to 0-indexed + FROM ( + SELECT + \(tableNameLiteral).rowid AS rowid, + \(SQL("MAX(\(tableNameLiteral).rowid = \(rowId))")), + ROW_NUMBER() OVER (ORDER BY \(orderSQL)) AS rowIndex + FROM \(tableNameLiteral) + \(requiredJoinSQL ?? "") + \(joinToPagedType ?? "") + WHERE \(filterSQL) + \(groupByLiteral)\(groupPagedType) + ) AS data + WHERE \(SQL("data.rowid = \(rowId)")) + """ + + return try? groupedRequest.fetchOne(db) + } + } + let request: SQLRequest = """ SELECT (data.rowIndex - 1) AS rowIndex -- Converting from 1-Indexed to 0-indexed @@ -800,6 +842,7 @@ public class AssociatedRecord: ErasedAssociatedRecord where T: Fet public let databaseTableName: String public let observedChanges: [PagedData.ObservedChanges] public let joinToPagedType: SQL + public let groupPagedType: SQL? fileprivate let dataCache: Atomic> = Atomic(DataCache()) fileprivate let dataQuery: (SQL?) -> AdaptedFetchRequest> @@ -812,12 +855,14 @@ public class AssociatedRecord: ErasedAssociatedRecord where T: Fet observedChanges: [PagedData.ObservedChanges], dataQuery: @escaping (SQL?) -> AdaptedFetchRequest>, joinToPagedType: SQL, + groupPagedType: SQL? = nil, associateData: @escaping (DataCache, DataCache) -> DataCache ) { self.databaseTableName = trackedAgainst.databaseTableName self.observedChanges = observedChanges self.dataQuery = dataQuery self.joinToPagedType = joinToPagedType + self.groupPagedType = groupPagedType self.associateData = associateData } @@ -826,6 +871,7 @@ public class AssociatedRecord: ErasedAssociatedRecord where T: Fet observedChanges: [PagedData.ObservedChanges], dataQuery: @escaping (SQL?) -> SQLRequest, joinToPagedType: SQL, + groupPagedType: SQL? = nil, associateData: @escaping (DataCache, DataCache) -> DataCache ) { self.init( @@ -835,6 +881,7 @@ public class AssociatedRecord: ErasedAssociatedRecord where T: Fet dataQuery(additionalFilters).adapted { _ in ScopeAdapter([:]) } }, joinToPagedType: joinToPagedType, + groupPagedType: groupPagedType, associateData: associateData ) } @@ -879,19 +926,27 @@ public class AssociatedRecord: ErasedAssociatedRecord where T: Fet tableName: databaseTableName, orderSQL: orderSQL, filterSQL: filterSQL, - joinToPagedType: joinToPagedType + joinToPagedType: joinToPagedType, + groupPagedType: groupPagedType ) // Determine if the indexes for the row ids should be displayed on the screen and remove any // which shouldn't - values less than 'currentCount' or if there is at least one value less than // 'currentCount' and the indexes are sequential (ie. more than the current loaded content was // added at once) - let itemIndexesAreSequential: Bool = (itemIndexes.map { $0 - 1 }.dropFirst() == itemIndexes.dropLast()) - let hasOneValidIndex: Bool = itemIndexes.contains(where: { $0 < pageInfo.currentCount }) + let uniqueIndexes: [Int64] = itemIndexes.asSet().sorted() + let itemIndexesAreSequential: Bool = (uniqueIndexes.map { $0 - 1 }.dropFirst() == uniqueIndexes.dropLast()) + let hasOneValidIndex: Bool = itemIndexes.contains(where: { index -> Bool in + index >= pageInfo.pageOffset && + index < pageInfo.currentCount + }) let validRowIds: [Int64] = (itemIndexesAreSequential && hasOneValidIndex ? - itemIndexes : + rowIdsToQuery : zip(itemIndexes, rowIdsToQuery) - .filter { index, _ -> Bool in index < pageInfo.currentCount } + .filter { index, _ -> Bool in + index >= pageInfo.pageOffset && + index < pageInfo.currentCount + } .map { _, rowId -> Int64 in rowId } ) diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index 3fb4f50c1..77598766a 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -56,7 +56,8 @@ public final class JobRunner { jobVariants: [ jobVariants.remove(.attachmentUpload), jobVariants.remove(.messageSend), - jobVariants.remove(.notifyPushServer)// TODO: Read receipts + jobVariants.remove(.notifyPushServer), + jobVariants.remove(.sendReadReceipts) ].compactMap { $0 } ) let messageReceiveQueue: JobQueue = JobQueue( @@ -131,6 +132,11 @@ public final class JobRunner { guard let job: Job = job else { return } // Ignore null jobs queues.wrappedValue[job.variant]?.upsert(job, canStartJob: canStartJob) + + // Start the job runner if needed + db.afterNextTransactionCommit { _ in + queues.wrappedValue[job.variant]?.start() + } } @discardableResult public static func insert(_ db: Database, job: Job?, before otherJob: Job) -> Job? { @@ -150,6 +156,11 @@ public final class JobRunner { queues.wrappedValue[updatedJob.variant]?.insert(updatedJob, before: otherJob) + // Start the job runner if needed + db.afterNextTransactionCommit { _ in + queues.wrappedValue[updatedJob.variant]?.start() + } + return updatedJob } @@ -236,19 +247,26 @@ public final class JobRunner { } .defaulting(to: ([], [])) - guard !jobsToRun.blocking.isEmpty || !jobsToRun.nonBlocking.isEmpty else { return } + // Store the current queue state locally to avoid multiple atomic retrievals + let jobQueues: [Job.Variant: JobQueue] = queues.wrappedValue + let blockingQueueIsRunning: Bool = (blockingQueue.wrappedValue?.isRunning.wrappedValue == true) + + guard !jobsToRun.blocking.isEmpty || !jobsToRun.nonBlocking.isEmpty else { + if !blockingQueueIsRunning { + jobQueues.forEach { _, queue in queue.start() } + } + return + } // Add and start any blocking jobs blockingQueue.wrappedValue?.appDidFinishLaunching(with: jobsToRun.blocking, canStart: true) - - let blockingQueueIsRunning: Bool = (blockingQueue.wrappedValue?.isRunning.wrappedValue == true) + // Add and start any non-blocking jobs (if there are no blocking jobs) let jobsByVariant: [Job.Variant: [Job]] = jobsToRun.nonBlocking.grouped(by: \.variant) - let jobQueues: [Job.Variant: JobQueue] = queues.wrappedValue - jobsByVariant.forEach { variant, jobs in - jobQueues[variant]?.appDidBecomeActive( - with: jobs, - canStart: !blockingQueueIsRunning + jobQueues.forEach { variant, queue in + queue.appDidBecomeActive( + with: (jobsByVariant[variant] ?? []), + canStart: (!blockingQueueIsRunning && jobsToRun.blocking.isEmpty) ) } } @@ -259,6 +277,13 @@ public final class JobRunner { return (queues.wrappedValue[job.variant]?.isCurrentlyRunning(jobId) == true) } + public static func hasPendingOrRunningJob(with variant: Job.Variant, details: T) -> Bool { + guard let targetQueue: JobQueue = queues.wrappedValue[variant] else { return false } + guard let detailsData: Data = try? JSONEncoder().encode(details) else { return false } + + return targetQueue.hasPendingOrRunningJob(with: detailsData) + } + // MARK: - Convenience fileprivate static func getRetryInterval(for job: Job) -> TimeInterval { @@ -450,6 +475,12 @@ private final class JobQueue { return jobsCurrentlyRunning.wrappedValue.contains(jobId) } + fileprivate func hasPendingOrRunningJob(with detailsData: Data?) -> Bool { + let pendingJobs: [Job] = queue.wrappedValue + + return pendingJobs.contains { job in job.details == detailsData } + } + // MARK: - Job Running fileprivate func start() { diff --git a/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift b/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift index 7381468ad..cecd4d3c8 100644 --- a/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift +++ b/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift @@ -7,17 +7,6 @@ import SessionMessagingKit @objc(LKProfilePictureView) public final class ProfilePictureView: UIView { - public static func closedGroupProfileQuery(threadId: String, userPublicKey: String) -> QueryInterfaceRequest { - return Profile - .filter(Profile.Columns.id != userPublicKey) - .joining( - required: Profile.groupMembers - .filter(GroupMember.Columns.groupId == threadId) - ) - .order(.id) - .limit(2) - } - private var hasTappableProfilePicture: Bool = false @objc public var size: CGFloat = 0 // Not an implicitly unwrapped optional due to Obj-C limitations @@ -65,66 +54,30 @@ public final class ProfilePictureView: UIView { additionalImageView.layer.cornerRadius = additionalImageViewSize / 2 } - // FIXME: Look to deprecate this and replace it with the pattern in HomeViewModel (screen should fetch only the required info) + // FIXME: Remove this once we refactor the ConversationVC to Swift (use the HomeViewModel approach) @objc(updateForThreadId:) public func update(forThreadId threadId: String?) { guard let threadId: String = threadId, - let (thread, profiles, imageData) = GRDBStorage.shared.read({ db -> (SessionThread, [Profile], Data?) in - guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { - throw GRDBStorageError.objectNotFound - } + let viewModel: SessionThreadViewModel = GRDBStorage.shared.read({ db -> SessionThreadViewModel? in + let userPublicKey: String = getUserHexEncodedPublicKey(db) - switch thread.variant { - case .contact: - return ( - thread, - [try? Profile.fetchOne(db, id: thread.id)].compactMap { $0 }, - nil - ) - - case .closedGroup: - let userPublicKey: String = getUserHexEncodedPublicKey(db) - let randomUsers: [Profile] = (try? ProfilePictureView - .closedGroupProfileQuery(threadId: thread.id, userPublicKey: userPublicKey) - .fetchAll(db)) - .defaulting(to: []) - - // If there is only a single user in the group then insert the current user - // at the back - if randomUsers.count == 1 { - return ( - thread, - randomUsers.inserting( - Profile.fetchOrCreateCurrentUser(db), - at: 0 - ), - nil - ) - } - - return (thread, randomUsers, nil) - - case .openGroup: - return ( - thread, - [], - try? thread.openGroup - .select(OpenGroup.Columns.imageData) - .asRequest(of: Data.self) - .fetchOne(db) - ) - } + return try SessionThreadViewModel + .conversationSettingsProfileQuery(threadId: threadId, userPublicKey: userPublicKey) + .fetchOne(db) }) else { return } update( - publicKey: (imageData != nil ? "" : thread.id), - profile: profiles.first, - additionalProfile: profiles.last, - threadVariant: thread.variant, - openGroupProfilePicture: imageData.map { UIImage(data: $0) }, - useFallbackPicture: (thread.variant == .openGroup && imageData == nil) + publicKey: viewModel.threadId, + profile: viewModel.profile, + additionalProfile: viewModel.additionalProfile, + threadVariant: viewModel.threadVariant, + openGroupProfilePicture: viewModel.openGroupProfilePictureData.map { UIImage(data: $0) }, + useFallbackPicture: ( + viewModel.threadVariant == .openGroup && + viewModel.openGroupProfilePictureData == nil + ) ) } From 26c7a5022a890df4e276778e1a50df6172cb4123 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 30 May 2022 13:04:26 +1000 Subject: [PATCH 092/157] Added a simple migration progress indicator and animation (need timing tweaks) Cleaned up the creation of the GRDBStorage instance Fixed an issue where the launch screen wasn't setting it's background colour based on the system setting Renamed the GRDBStorageError to StorageError (in preparation of legacy 'Storage' relocation) Consolidated the two Environment classes (in Swift) Refactored the AppSetup class to Swift --- Session.xcodeproj/project.pbxproj | 40 +-- .../ConversationVC+Interaction.swift | 1 + Session/Meta/AppDelegate.swift | 68 ++++-- Session/Meta/AppEnvironment.swift | 2 +- Session/Meta/Launch Screen.storyboard | 10 +- Session/Shared/LoadingViewController.swift | 229 ++++++++++-------- Session/Shared/OWSScreenLockUI.m | 2 +- .../Database/LegacyDatabase/SMKLegacy.swift | 18 +- .../_001_InitialSetupMigration.swift | 2 + .../Migrations/_002_SetupStandardJobs.swift | 2 + .../Migrations/_003_YDBToGRDBMigration.swift | 26 +- .../Database/Models/Attachment.swift | 6 +- .../Database/Models/Profile.swift | 2 +- .../Jobs/Types/DisappearingMessagesJob.swift | 2 +- .../Jobs/Types/MessageSendJob.swift | 8 +- .../Messages/Message+Destination.swift | 2 +- .../MessageReceiver+Handling.swift | 2 +- .../MessageSender+ClosedGroups.swift | 18 +- .../MessageSender+Convenience.swift | 14 +- .../SessionThreadViewModel.swift | 2 +- SessionMessagingKit/Utilities/Environment.h | 39 --- SessionMessagingKit/Utilities/Environment.m | 62 ----- .../Utilities/Environment.swift | 76 ++++++ .../Utilities/OWSAudioPlayer.m | 2 +- .../Utilities/OWSWindowManager.m | 3 +- .../Utilities/SSKEnvironment.swift | 51 ---- .../NotificationServiceExtension.swift | 6 +- SessionShareExtension/ShareVC.swift | 8 +- .../_001_InitialSetupMigration.swift | 2 + .../Migrations/_002_SetupStandardJobs.swift | 2 + .../Migrations/_003_YDBToGRDBMigration.swift | 2 + .../Utilities/UIView+Constraints.swift | 8 +- .../Database/GRDBStorage.swift | 172 +++++++++---- .../_001_InitialSetupMigration.swift | 2 + .../Migrations/_002_SetupStandardJobs.swift | 2 + .../Migrations/_003_YDBToGRDBMigration.swift | 4 +- .../Database/StorageError.swift | 17 ++ .../Database/Types/Migration.swift | 2 + .../Database/Types/TargetMigrations.swift | 4 + SignalUtilitiesKit/Configuration.swift | 19 -- SignalUtilitiesKit/Utilities/AppSetup.h | 17 -- SignalUtilitiesKit/Utilities/AppSetup.m | 93 ------- SignalUtilitiesKit/Utilities/AppSetup.swift | 71 ++++++ .../Utilities/VersionMigrations.h | 25 -- .../Utilities/VersionMigrations.m | 126 ---------- 45 files changed, 567 insertions(+), 704 deletions(-) delete mode 100644 SessionMessagingKit/Utilities/Environment.h delete mode 100644 SessionMessagingKit/Utilities/Environment.m create mode 100644 SessionMessagingKit/Utilities/Environment.swift delete mode 100644 SessionMessagingKit/Utilities/SSKEnvironment.swift create mode 100644 SessionUtilitiesKit/Database/StorageError.swift delete mode 100644 SignalUtilitiesKit/Utilities/AppSetup.h delete mode 100644 SignalUtilitiesKit/Utilities/AppSetup.m create mode 100644 SignalUtilitiesKit/Utilities/AppSetup.swift delete mode 100644 SignalUtilitiesKit/Utilities/VersionMigrations.h delete mode 100644 SignalUtilitiesKit/Utilities/VersionMigrations.m diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 483ebef34..853ff8d42 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -184,8 +184,6 @@ B8856D11256F112A001CE70E /* OWSAudioSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF281255B6D84007E1867 /* OWSAudioSession.swift */; }; B8856D1A256F114D001CE70E /* ProximityMonitoringManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2EC255B6DBA007E1867 /* ProximityMonitoringManager.swift */; }; B8856D23256F116B001CE70E /* Weak.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2EF255B6DBB007E1867 /* Weak.swift */; }; - B8856D34256F1192001CE70E /* Environment.m in Sources */ = {isa = PBXBuildFile; fileRef = C37F5402255BA9ED002AEA92 /* Environment.m */; }; - B8856D3D256F11B2001CE70E /* Environment.h in Headers */ = {isa = PBXBuildFile; fileRef = C37F53E8255BA9BB002AEA92 /* Environment.h */; settings = {ATTRIBUTES = (Public, ); }; }; B8856D60256F129B001CE70E /* OWSAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8856D5F256F129B001CE70E /* OWSAlerts.swift */; }; B8856D69256F141F001CE70E /* OWSWindowManager.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF306255B6DBE007E1867 /* OWSWindowManager.m */; }; B8856D72256F1421001CE70E /* OWSWindowManager.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF2FB255B6DBD007E1867 /* OWSWindowManager.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -421,8 +419,6 @@ C38EF275255B6D7A007E1867 /* OWSDatabaseMigrationRunner.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF26F255B6D79007E1867 /* OWSDatabaseMigrationRunner.h */; settings = {ATTRIBUTES = (Public, ); }; }; C38EF276255B6D7A007E1867 /* OWSDatabaseMigration.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF270255B6D79007E1867 /* OWSDatabaseMigration.m */; }; C38EF277255B6D7A007E1867 /* OWSDatabaseMigration.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF271255B6D79007E1867 /* OWSDatabaseMigration.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C38EF28F255B6D86007E1867 /* VersionMigrations.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF283255B6D84007E1867 /* VersionMigrations.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C38EF292255B6D86007E1867 /* VersionMigrations.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF286255B6D85007E1867 /* VersionMigrations.m */; }; C38EF2A5255B6D93007E1867 /* Identicon+ObjC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2A2255B6D93007E1867 /* Identicon+ObjC.swift */; }; C38EF2A6255B6D93007E1867 /* PlaceholderIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2A3255B6D93007E1867 /* PlaceholderIcon.swift */; }; C38EF2A7255B6D93007E1867 /* ProfilePictureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2A4255B6D93007E1867 /* ProfilePictureView.swift */; }; @@ -680,6 +676,8 @@ FD848B9328420164000E298B /* UnicodeScalar+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B9228420164000E298B /* UnicodeScalar+Utilities.swift */; }; 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 */; }; + FD848B9C284435D7000E298B /* AppSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B9B284435D7000E298B /* AppSetup.swift */; }; FD859F0027C4691300510D0C /* MockDataGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EFF27C4691300510D0C /* MockDataGenerator.swift */; }; FD88BAD927A7439C00BBC442 /* MessageRequestsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */; }; FD88BADB27A750F200BBC442 /* MessageRequestsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */; }; @@ -690,8 +688,6 @@ FD9004162818B46700ABAAF6 /* JobRunnerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE77F68280F9EDA002CFC5D /* JobRunnerError.swift */; }; FDA8EAFE280E8B78002B68E5 /* FailedMessageSendsJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA8EAFD280E8B78002B68E5 /* FailedMessageSendsJob.swift */; }; FDA8EB00280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA8EAFF280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift */; }; - FDA8EB09280E90FB002B68E5 /* AppSetup.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF287255B6D85007E1867 /* AppSetup.m */; }; - FDA8EB0A280E9103002B68E5 /* AppSetup.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF284255B6D84007E1867 /* AppSetup.h */; settings = {ATTRIBUTES = (Public, ); }; }; FDA8EB10280F8238002B68E5 /* Codable+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA8EB0F280F8238002B68E5 /* Codable+Utilities.swift */; }; FDB4BBC72838B91E00B7C95D /* LinkPreviewError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB4BBC62838B91E00B7C95D /* LinkPreviewError.swift */; }; FDB4BBC92839BEF000B7C95D /* ProfileManagerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB4BBC82839BEF000B7C95D /* ProfileManagerError.swift */; }; @@ -710,7 +706,7 @@ FDF0B74B28061F7A004C14C5 /* InteractionAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B74A28061F7A004C14C5 /* InteractionAttachment.swift */; }; FDF0B74F28079E5E004C14C5 /* SendReadReceiptsJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B74E28079E5E004C14C5 /* SendReadReceiptsJob.swift */; }; FDF0B7512807BA56004C14C5 /* NotificationsProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7502807BA56004C14C5 /* NotificationsProtocol.swift */; }; - FDF0B7552807C4BB004C14C5 /* SSKEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7542807C4BB004C14C5 /* SSKEnvironment.swift */; }; + FDF0B7552807C4BB004C14C5 /* Environment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7542807C4BB004C14C5 /* Environment.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 */; }; @@ -1337,8 +1333,6 @@ C374EEEA25DA3CA70073A857 /* ConversationTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationTitleView.swift; sourceTree = ""; }; C374EEF325DB31D40073A857 /* VoiceMessageRecordingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRecordingView.swift; sourceTree = ""; }; C379DCF3256735770002D4EB /* VisibleMessage+Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VisibleMessage+Attachment.swift"; sourceTree = ""; }; - C37F53E8255BA9BB002AEA92 /* Environment.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Environment.h; sourceTree = ""; }; - C37F5402255BA9ED002AEA92 /* Environment.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = Environment.m; sourceTree = ""; }; C38D5E8C2575011E00B6A65C /* MessageSender+ClosedGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageSender+ClosedGroups.swift"; sourceTree = ""; }; C38EEF09255B49A8007E1867 /* SNProtoEnvelope+Conversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SNProtoEnvelope+Conversion.swift"; sourceTree = ""; }; C38EF224255B6D5D007E1867 /* SignalAttachment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SignalAttachment.swift; path = "SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift"; sourceTree = SOURCE_ROOT; }; @@ -1364,10 +1358,6 @@ C38EF270255B6D79007E1867 /* OWSDatabaseMigration.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = OWSDatabaseMigration.m; path = SignalUtilitiesKit/Database/Migrations/OWSDatabaseMigration.m; sourceTree = SOURCE_ROOT; }; C38EF271255B6D79007E1867 /* OWSDatabaseMigration.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSDatabaseMigration.h; path = SignalUtilitiesKit/Database/Migrations/OWSDatabaseMigration.h; sourceTree = SOURCE_ROOT; }; C38EF281255B6D84007E1867 /* OWSAudioSession.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = OWSAudioSession.swift; path = SessionMessagingKit/Utilities/OWSAudioSession.swift; sourceTree = SOURCE_ROOT; }; - C38EF283255B6D84007E1867 /* VersionMigrations.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = VersionMigrations.h; path = SignalUtilitiesKit/Utilities/VersionMigrations.h; sourceTree = SOURCE_ROOT; }; - C38EF284255B6D84007E1867 /* AppSetup.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppSetup.h; path = SignalUtilitiesKit/Utilities/AppSetup.h; sourceTree = SOURCE_ROOT; }; - C38EF286255B6D85007E1867 /* VersionMigrations.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = VersionMigrations.m; path = SignalUtilitiesKit/Utilities/VersionMigrations.m; sourceTree = SOURCE_ROOT; }; - C38EF287255B6D85007E1867 /* AppSetup.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = AppSetup.m; path = SignalUtilitiesKit/Utilities/AppSetup.m; sourceTree = SOURCE_ROOT; }; C38EF2A2255B6D93007E1867 /* Identicon+ObjC.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "Identicon+ObjC.swift"; path = "SignalUtilitiesKit/Profile Pictures/Identicon+ObjC.swift"; sourceTree = SOURCE_ROOT; }; C38EF2A3255B6D93007E1867 /* PlaceholderIcon.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = PlaceholderIcon.swift; path = "SignalUtilitiesKit/Profile Pictures/PlaceholderIcon.swift"; sourceTree = SOURCE_ROOT; }; C38EF2A4255B6D93007E1867 /* ProfilePictureView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ProfilePictureView.swift; path = "SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift"; sourceTree = SOURCE_ROOT; }; @@ -1648,6 +1638,8 @@ FD848B8E283EF2A8000E298B /* UIScrollView+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIScrollView+Utilities.swift"; sourceTree = ""; }; FD848B9228420164000E298B /* UnicodeScalar+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UnicodeScalar+Utilities.swift"; sourceTree = ""; }; FD848B9728422F1A000E298B /* Date+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Utilities.swift"; sourceTree = ""; }; + FD848B9928442CE6000E298B /* StorageError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageError.swift; sourceTree = ""; }; + FD848B9B284435D7000E298B /* AppSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSetup.swift; sourceTree = ""; }; FD859EFF27C4691300510D0C /* MockDataGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDataGenerator.swift; sourceTree = ""; }; FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsCell.swift; sourceTree = ""; }; FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsMigration.swift; sourceTree = ""; }; @@ -1676,7 +1668,7 @@ FDF0B74A28061F7A004C14C5 /* InteractionAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractionAttachment.swift; sourceTree = ""; }; FDF0B74E28079E5E004C14C5 /* SendReadReceiptsJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendReadReceiptsJob.swift; sourceTree = ""; }; FDF0B7502807BA56004C14C5 /* NotificationsProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsProtocol.swift; sourceTree = ""; }; - FDF0B7542807C4BB004C14C5 /* SSKEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSKEnvironment.swift; sourceTree = ""; }; + FDF0B7542807C4BB004C14C5 /* Environment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Environment.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 = ""; }; @@ -2125,6 +2117,7 @@ FD17D7CB27F546F500122BE0 /* Models */, FD17D7B427F51E6700122BE0 /* Types */, FD17D7BB27F51F5C00122BE0 /* Utilities */, + FD848B9928442CE6000E298B /* StorageError.swift */, FD28A4F527EAD44C00FF65E7 /* GRDBStorage.swift */, C33FDBAB255A581500E217F9 /* OWSFileSystem.h */, C33FDA8E255A57FD00E217F9 /* OWSFileSystem.m */, @@ -2873,8 +2866,7 @@ C33FDB75255A581000E217F9 /* AppReadiness.m */, FD09799027FD499200936362 /* BoxKeyPair+Utilities.swift */, C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */, - C37F53E8255BA9BB002AEA92 /* Environment.h */, - C37F5402255BA9ED002AEA92 /* Environment.m */, + FDF0B7542807C4BB004C14C5 /* Environment.swift */, C3BBE0C62554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift */, C3A71D0A2558989C0043A11F /* MessageWrapper.swift */, C3A71D4E25589FF30043A11F /* NSData+messagePadding.h */, @@ -2894,7 +2886,6 @@ FDB4BBC82839BEF000B7C95D /* ProfileManagerError.swift */, C38EF2EC255B6DBA007E1867 /* ProximityMonitoringManager.swift */, C38EEF09255B49A8007E1867 /* SNProtoEnvelope+Conversion.swift */, - FDF0B7542807C4BB004C14C5 /* SSKEnvironment.swift */, C3A3A170256E1D25004D228D /* SSKReachabilityManager.swift */, C3ECBF7A257056B700EA7FCE /* Threading.swift */, C33FDB5F255A580E00E217F9 /* YapDatabaseConnection+OWS.h */, @@ -3032,8 +3023,7 @@ C3CA3B11255CF17200F4C6D4 /* Utilities */ = { isa = PBXGroup; children = ( - C38EF284255B6D84007E1867 /* AppSetup.h */, - C38EF287255B6D85007E1867 /* AppSetup.m */, + FD848B9B284435D7000E298B /* AppSetup.swift */, C38EF302255B6DBE007E1867 /* OWSAnyTouchGestureRecognizer.h */, C38EF2F0255B6DBB007E1867 /* OWSAnyTouchGestureRecognizer.m */, FDCDB8DD2810F73B00352A0C /* Differentiable+Utilities.swift */, @@ -3071,8 +3061,6 @@ C38EF2F2255B6DBC007E1867 /* Searcher.swift */, B8856D5F256F129B001CE70E /* OWSAlerts.swift */, C3F0A52F255C80BC007BE2A3 /* NoopNotificationsManager.swift */, - C38EF283255B6D84007E1867 /* VersionMigrations.h */, - C38EF286255B6D85007E1867 /* VersionMigrations.m */, C33FDA8B255A57FD00E217F9 /* AppVersion.m */, C33FDB69255A580F00E217F9 /* FeatureFlags.swift */, C33FDA99255A57FE00E217F9 /* OutageDetection.swift */, @@ -3538,7 +3526,6 @@ C33FDD7C255A582000E217F9 /* SSKAsserts.h in Headers */, C38EF3F6255B6DF7007E1867 /* OWSTextView.h in Headers */, C38EF24C255B6D67007E1867 /* NSAttributedString+OWS.h in Headers */, - FDA8EB0A280E9103002B68E5 /* AppSetup.h in Headers */, C38EF32B255B6DBF007E1867 /* OWSFormat.h in Headers */, C33FDDB8255A582000E217F9 /* NSSet+Functional.h in Headers */, C33FDDCC255A582000E217F9 /* TSConstants.h in Headers */, @@ -3562,7 +3549,6 @@ C33FD9AF255A548A00E217F9 /* SignalUtilitiesKit.h in Headers */, C33FDC50255A582000E217F9 /* OWSDispatch.h in Headers */, C33FDD06255A582000E217F9 /* AppVersion.h in Headers */, - C38EF28F255B6D86007E1867 /* VersionMigrations.h in Headers */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3613,7 +3599,6 @@ C32A026C25A801AF000ED5D4 /* NSData+messagePadding.h in Headers */, B8856D72256F1421001CE70E /* OWSWindowManager.h in Headers */, B8856CF7256F105E001CE70E /* OWSAudioPlayer.h in Headers */, - B8856D3D256F11B2001CE70E /* Environment.h in Headers */, C32C5E7E256DE023003C73A2 /* YapDatabaseTransaction+OWS.h in Headers */, C32C5EA0256DE0D6003C73A2 /* OWSPrimaryStorage.h in Headers */, B8856E33256F18D5001CE70E /* OWSStorage+Subclass.h in Headers */, @@ -4306,7 +4291,6 @@ C38EF247255B6D67007E1867 /* NSAttributedString+OWS.m in Sources */, C33FDD49255A582000E217F9 /* ParamParser.swift in Sources */, C38EF3C5255B6DE7007E1867 /* OWSViewController+ImageEditor.swift in Sources */, - FDA8EB09280E90FB002B68E5 /* AppSetup.m in Sources */, C38EF2A5255B6D93007E1867 /* Identicon+ObjC.swift in Sources */, C38EF273255B6D7A007E1867 /* OWSDatabaseMigrationRunner.m in Sources */, C38EF31A255B6DBF007E1867 /* OWSAnyTouchGestureRecognizer.m in Sources */, @@ -4384,7 +4368,6 @@ C33FDC78255A582000E217F9 /* TSConstants.m in Sources */, C38EF324255B6DBF007E1867 /* Bench.swift in Sources */, FDCDB8DE2810F73B00352A0C /* Differentiable+Utilities.swift in Sources */, - C38EF292255B6D86007E1867 /* VersionMigrations.m in Sources */, C38EF3F9255B6DF7007E1867 /* OWSLayerView.swift in Sources */, C33FDD03255A582000E217F9 /* WeakTimer.swift in Sources */, B8B3204E258C15C80020074B /* ContactsMigration.swift in Sources */, @@ -4400,6 +4383,7 @@ B8C2B2C82563685C00551B4D /* CircleView.swift in Sources */, C38EF331255B6DBF007E1867 /* UIGestureRecognizer+OWS.swift in Sources */, C33FDDC5255A582000E217F9 /* OWSError.m in Sources */, + FD848B9C284435D7000E298B /* AppSetup.swift in Sources */, C38EF38D255B6DD2007E1867 /* AttachmentCaptionViewController.swift in Sources */, C38EF31C255B6DBF007E1867 /* Searcher.swift in Sources */, C38EF2B3255B6D9C007E1867 /* UIViewController+Utilities.swift in Sources */, @@ -4501,6 +4485,7 @@ B8856DE6256F15F2001CE70E /* String+SSK.swift in Sources */, FDF2220F281B55E6000A4995 /* QueryInterfaceRequest+Utilities.swift in Sources */, C3471ED42555386B00297E91 /* AESGCM.swift in Sources */, + FD848B9A28442CE6000E298B /* StorageError.swift in Sources */, FD17D7B827F51ECA00122BE0 /* Migration.swift in Sources */, C32C5DDB256DD9FF003C73A2 /* ContentProxy.swift in Sources */, C3A71F892558BA9F0043A11F /* Mnemonic.swift in Sources */, @@ -4583,12 +4568,11 @@ FD09797F27FCFBFF00936362 /* OWSAES256Key+Utilities.swift in Sources */, FDB4BBC72838B91E00B7C95D /* LinkPreviewError.swift in Sources */, FD09798327FD1A1500936362 /* ClosedGroup.swift in Sources */, - B8856D34256F1192001CE70E /* Environment.m in Sources */, B8B320B7258C30D70020074B /* HTMLMetadata.swift in Sources */, FD09798727FD1B7800936362 /* GroupMember.swift in Sources */, FD09799127FD499200936362 /* BoxKeyPair+Utilities.swift in Sources */, FDB4BBC92839BEF000B7C95D /* ProfileManagerError.swift in Sources */, - FDF0B7552807C4BB004C14C5 /* SSKEnvironment.swift in Sources */, + FDF0B7552807C4BB004C14C5 /* Environment.swift in Sources */, B8856ECE256F1E58001CE70E /* OWSPreferences.m in Sources */, FD3E0C84283B5835002A425C /* SessionThreadViewModel.swift in Sources */, FD09C5EC282B8F18000CE219 /* AttachmentError.swift in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index d29acf2f6..9f7cdec20 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -873,6 +873,7 @@ extension ConversationVC: let interaction: Interaction = try? Interaction.fetchOne(db, id: cellViewModel.id), let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { return } + try MessageSender.send( db, interaction: interaction, diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 97c2c0957..2b2ad8a6f 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -15,7 +15,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD var window: UIWindow? var backgroundSnapshotBlockerWindow: UIWindow? var appStartupWindow: UIWindow? - var poller: Poller = Poller() + var hasInitialRootViewController: Bool = false + + /// This needs to be a lazy variable to ensure it doesn't get initialized before it actually needs to be used + lazy var poller: Poller = { + return Poller() + }() // MARK: - Lifecycle @@ -26,8 +31,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD AppModeManager.configure(delegate: self) Cryptography.seedRandom() - - AppVersion.sharedInstance() // TODO: ??? + AppVersion.sharedInstance() // Prevent the device from sleeping during database view async registration // (e.g. long database upgrades). @@ -35,14 +39,38 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD // This block will be cleared in storageIsReady. DeviceSleepManager.sharedInstance.addBlock(blockObject: self) + let mainWindow: UIWindow = UIWindow(frame: UIScreen.main.bounds) + let loadingViewController: LoadingViewController = LoadingViewController() + AppSetup.setupEnvironment( - appSpecificSingletonBlock: { + appSpecificBlock: { // Create AppEnvironment AppEnvironment.shared.setup() - }, - migrationCompletion: { [weak self] successful, needsConfigSync in - guard let strongSelf = self else { return } + // Note: Intentionally dispatching sync as we want to wait for these to complete before + // continuing + DispatchQueue.main.sync { + OWSScreenLockUI.sharedManager().setup(withRootWindow: mainWindow) + OWSWindowManager.shared().setup( + withRootWindow: mainWindow, + screenBlockingWindow: OWSScreenLockUI.sharedManager().screenBlockingWindow + ) + OWSScreenLockUI.sharedManager().startObserving() + } + }, + migrationProgressChanged: { progress, minEstimatedTotalTime in + loadingViewController.updateProgress( + progress: progress, + minEstimatedTotalTime: minEstimatedTotalTime + ) + }, + migrationsCompletion: { [weak self] successful, needsConfigSync in + guard let strongSelf = self else { return } + guard successful else { + return + } + + Configuration.performMainSetup() JobRunner.add(executor: SyncPushTokensJob.self, for: .syncPushTokens) // Trigger any launch-specific jobs and start the JobRunner @@ -56,7 +84,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD DeviceSleepManager.sharedInstance.removeBlock(blockObject: strongSelf) AppVersion.sharedInstance().mainAppLaunchDidComplete() Environment.shared.audioSession.setup() - SSKEnvironment.shared.reachabilityManager.setup() + Environment.shared.reachabilityManager.setup() GRDBStorage.shared.writeAsync { db in // Disable the SAE until the main app has successfully completed launch process @@ -83,18 +111,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD } ) - Configuration.performMainSetup() SNAppearance.switchToSessionAppearance() // No point continuing if we are running tests guard !CurrentAppContext().isRunningTests else { return true } - let mainWindow: UIWindow = UIWindow(frame: UIScreen.main.bounds) self.window = mainWindow CurrentAppContext().mainWindow = mainWindow // Show LoadingViewController until the async database view registrations are complete. - mainWindow.rootViewController = LoadingViewController() + mainWindow.rootViewController = loadingViewController mainWindow.makeKeyAndVisible() adapt(appMode: AppModeManager.getAppModeOrSystemDefault()) @@ -104,14 +130,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD // Setting the delegate also seems to prevent us from getting the legacy notification // notification callbacks upon launch e.g. 'didReceiveLocalNotification' UNUserNotificationCenter.current().delegate = self - - OWSScreenLockUI.sharedManager().setup(withRootWindow: mainWindow) - OWSWindowManager.shared().setup( - withRootWindow: mainWindow, - screenBlockingWindow: OWSScreenLockUI.sharedManager().screenBlockingWindow - ) - OWSScreenLockUI.sharedManager().startObserving() - + NotificationCenter.default.addObserver( self, selector: #selector(registrationStateDidChange), @@ -196,10 +215,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD private func verifyDBKeysAvailableBeforeBackgroundLaunch() { guard UIApplication.shared.applicationState == .background else { return } - let migrationHasRun: Bool = false - + // Ensure both databases are accessible (as long as we are supporting the YDB migration + // we should keep this check) let databasePasswordAccessible: Bool = ( - (migrationHasRun && GRDBStorage.isDatabasePasswordAccessible) || // GRDB password access + GRDBStorage.isDatabasePasswordAccessible && // GRDB password access OWSStorage.isDatabasePasswordAccessible() // YapDatabase password access ) @@ -250,8 +269,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD } private func ensureRootViewController() { - // TODO: Add 'MigrationProcessingViewController' in here as well - guard self.window?.rootViewController is LoadingViewController else { return } + guard AppReadiness.isAppReady() && GRDBStorage.shared.isValid && !hasInitialRootViewController else { + return + } let navController: UINavigationController = OWSNavigationController( rootViewController: (Identity.userExists() ? diff --git a/Session/Meta/AppEnvironment.swift b/Session/Meta/AppEnvironment.swift index f2fa40af4..7e1926745 100644 --- a/Session/Meta/AppEnvironment.swift +++ b/Session/Meta/AppEnvironment.swift @@ -57,7 +57,7 @@ import SignalUtilitiesKit @objc public func setup() { // Hang certain singletons on SSKEnvironment too. - SSKEnvironment.shared.notificationsManager.mutate { + Environment.shared.notificationsManager.mutate { $0 = notificationPresenter } setupLogFiles() diff --git a/Session/Meta/Launch Screen.storyboard b/Session/Meta/Launch Screen.storyboard index 07bbc6333..56a52cb2c 100644 --- a/Session/Meta/Launch Screen.storyboard +++ b/Session/Meta/Launch Screen.storyboard @@ -1,9 +1,10 @@ - + - + + @@ -27,7 +28,7 @@ - + @@ -41,5 +42,8 @@ + + + diff --git a/Session/Shared/LoadingViewController.swift b/Session/Shared/LoadingViewController.swift index c961caad4..8bbe92fba 100644 --- a/Session/Shared/LoadingViewController.swift +++ b/Session/Shared/LoadingViewController.swift @@ -1,115 +1,150 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -import Foundation +import UIKit import PromiseKit +import SessionUIKit // The initial presentation is intended to be indistinguishable from the Launch Screen. // After a delay we present some "loading" UI so the user doesn't think the app is frozen. -@objc public class LoadingViewController: UIViewController { + /// This value specifies the minimum expected duration which needs to be hit before the loading UI needs to be show + private static let minExpectedDurationToShowLoading: TimeInterval = 5 + + /// This value specifies the minimum expected duration which needs to be hit before the additional "might take a few minutes" + /// label gets shown + private static let minExpectedDurationAdditionalLabel: TimeInterval = 15 + + private var isShowingProgress: Bool = false + + // MARK: - UI + + override public var supportedInterfaceOrientations: UIInterfaceOrientationMask { + return .portrait + } + + private var logoView: UIImageView = { + let result: UIImageView = UIImageView(image: #imageLiteral(resourceName: "SessionGreen64")) + result.contentMode = .scaleAspectFit + result.layer.shadowColor = Colors.accent.cgColor + result.layer.shadowOffset = .zero + result.layer.shadowRadius = 3 + result.layer.shadowOpacity = 0 + + return result + }() + + private var progressBar: UIProgressView = { + let result: UIProgressView = UIProgressView(progressViewStyle: .bar) + result.clipsToBounds = true + result.progress = 0 + result.tintColor = Colors.accent + result.trackTintColor = Colors.text.withAlphaComponent(0.1) + result.layer.cornerRadius = 6 - var logoView: UIImageView! - var topLabel: UILabel! - var bottomLabel: UILabel! + return result + }() + + private var topLabel: UILabel = { + let result: UILabel = UILabel() + result.font = UIFont.systemFont(ofSize: Values.mediumFontSize) + result.text = "DATABASE_VIEW_OVERLAY_TITLE".localized() + result.textColor = Colors.text + result.textAlignment = .center + result.numberOfLines = 0 + result.lineBreakMode = .byWordWrapping + + return result + }() + + private var bottomLabel: UILabel = { + let result: UILabel = UILabel() + result.font = UIFont.systemFont(ofSize: Values.verySmallFontSize) + result.text = "DATABASE_VIEW_OVERLAY_SUBTITLE".localized() + result.textColor = Colors.text + result.textAlignment = .center + result.numberOfLines = 0 + result.lineBreakMode = .byWordWrapping + result.isHidden = true + + return result + }() + + private lazy var labelStack: UIStackView = { + let result: UIStackView = UIStackView() + result.axis = .vertical + result.alignment = .center + result.spacing = 20 + result.alpha = 0 + + return result + }() + + // MARK: - Lifecycle override public func loadView() { self.view = UIView() - // Loki: Set gradient background - view.backgroundColor = .clear - let gradient = Gradients.defaultBackground - view.setGradient(gradient) + self.view.backgroundColor = Colors.navigationBarBackground - self.logoView = UIImageView(image: #imageLiteral(resourceName: "SessionGreen64")) - view.addSubview(logoView) + self.view.addSubview(self.logoView) + self.view.addSubview(self.labelStack) + self.view.addSubview(self.bottomLabel) + + self.labelStack.addArrangedSubview(self.progressBar) + self.labelStack.addArrangedSubview(self.topLabel) - logoView.autoCenterInSuperview() - logoView.autoSetDimension(.width, toSize: 64) - logoView.autoSetDimension(.height, toSize: 64) - logoView.contentMode = .scaleAspectFit - - self.topLabel = buildLabel() - topLabel.alpha = 0 - topLabel.font = UIFont.ows_dynamicTypeTitle2 - topLabel.text = NSLocalizedString("DATABASE_VIEW_OVERLAY_TITLE", comment: "Title shown while the app is updating its database.") - - self.bottomLabel = buildLabel() - bottomLabel.alpha = 0 - bottomLabel.font = UIFont.ows_dynamicTypeBody - bottomLabel.text = NSLocalizedString("DATABASE_VIEW_OVERLAY_SUBTITLE", comment: "Subtitle shown while the app is updating its database.") - - let labelStack = UIStackView(arrangedSubviews: [topLabel, bottomLabel]) - labelStack.axis = .vertical - labelStack.alignment = .center - labelStack.spacing = 8 - view.addSubview(labelStack) - - labelStack.autoPinEdge(.top, to: .bottom, of: logoView, withOffset: 20) - labelStack.autoPinLeadingToSuperviewMargin() - labelStack.autoPinTrailingToSuperviewMargin() - labelStack.setCompressionResistanceHigh() - labelStack.setContentHuggingHigh() + // Layout + + self.logoView.autoCenterInSuperview() + self.logoView.autoSetDimension(.width, toSize: 64) + self.logoView.autoSetDimension(.height, toSize: 64) + + self.progressBar.set(.height, to: (self.progressBar.layer.cornerRadius * 2)) + self.progressBar.set(.width, to: .width, of: self.view, multiplier: 0.5) + + self.labelStack.pin(.top, to: .bottom, of: self.logoView, withInset: 40) + self.labelStack.pin(.left, to: .left, of: self.view) + self.labelStack.pin(.right, to: .right, of: self.view) + self.labelStack.setCompressionResistanceHigh() + self.labelStack.setContentHuggingHigh() + + self.bottomLabel.pin(.top, to: .bottom, of: self.labelStack, withInset: 10) + self.bottomLabel.pin(.left, to: .left, of: self.view) + self.bottomLabel.pin(.right, to: .right, of: self.view) + self.bottomLabel.setCompressionResistanceHigh() + self.bottomLabel.setContentHuggingHigh() } - - var isShowingTopLabel = false - var isShowingBottomLabel = false - override public func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - // We only show the "loading" UI if it's a slow launch. Otherwise this ViewController - // should be indistinguishable from the launch screen. - let kTopLabelThreshold: TimeInterval = 5 - DispatchQueue.main.asyncAfter(deadline: .now() + kTopLabelThreshold) { [weak self] in - guard let strongSelf = self else { - return + + // MARK: - Functions + + public func updateProgress(progress: CGFloat, minEstimatedTotalTime: TimeInterval) { + guard minEstimatedTotalTime >= LoadingViewController.minExpectedDurationToShowLoading else { return } + + if !self.isShowingProgress { + self.isShowingProgress = true + self.bottomLabel.isHidden = ( + minEstimatedTotalTime < LoadingViewController.minExpectedDurationAdditionalLabel + ) + + UIView.animate(withDuration: 0.3) { [weak self] in + self?.labelStack.alpha = 1 } - - guard !strongSelf.isShowingTopLabel else { - return - } - - strongSelf.isShowingTopLabel = true - UIView.animate(withDuration: 0.1) { - strongSelf.topLabel.alpha = 1 - } - UIView.animate(withDuration: 0.9, delay: 2, options: [.autoreverse, .repeat, .curveEaseInOut], animations: { - strongSelf.topLabel.alpha = 0.2 - }, completion: nil) + + UIView.animate( + withDuration: 1.95, + delay: 0.05, + options: [ + .curveEaseInOut, + .autoreverse, + .repeat + ], + animations: { [weak self] in + self?.logoView.layer.shadowOpacity = 1 + }, + completion: nil + ) } - - let kBottomLabelThreshold: TimeInterval = 15 - DispatchQueue.main.asyncAfter(deadline: .now() + kBottomLabelThreshold) { [weak self] in - guard let strongSelf = self else { - return - } - guard !strongSelf.isShowingBottomLabel else { - return - } - - strongSelf.isShowingBottomLabel = true - UIView.animate(withDuration: 0.1) { - strongSelf.bottomLabel.alpha = 1 - } - } - } - - // MARK: Orientation - - override public var supportedInterfaceOrientations: UIInterfaceOrientationMask { - return .portrait - } - - // MARK: - - private func buildLabel() -> UILabel { - let label = UILabel() - - label.textColor = .white - label.numberOfLines = 0 - label.lineBreakMode = .byWordWrapping - - return label + + self.progressBar.setProgress(Float(progress), animated: true) } } diff --git a/Session/Shared/OWSScreenLockUI.m b/Session/Shared/OWSScreenLockUI.m index 0e8055ed2..a95a42c80 100644 --- a/Session/Shared/OWSScreenLockUI.m +++ b/Session/Shared/OWSScreenLockUI.m @@ -348,7 +348,7 @@ NS_ASSUME_NONNULL_BEGIN return ScreenLockUIStateNone; } - if (Environment.shared.isRequestingPermission) { + if (SMKEnvironment.shared.isRequestingPermission) { return ScreenLockUIStateNone; } diff --git a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift index 06d6b7816..e06e2fade 100644 --- a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift +++ b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift @@ -486,7 +486,7 @@ public enum SMKLegacy { let admins: [Data] = self.admins else { SNLog("[Migration Error] Unable to decode Legacy ClosedGroupControlMessage") - throw GRDBStorageError.migrationFailed + throw StorageError.migrationFailed } return .new( @@ -504,7 +504,7 @@ public enum SMKLegacy { case "encryptionKeyPair": guard let wrappers: [_KeyPairWrapper] = self.wrappers else { SNLog("[Migration Error] Unable to decode Legacy ClosedGroupControlMessage") - throw GRDBStorageError.migrationFailed + throw StorageError.migrationFailed } return .encryptionKeyPair( @@ -515,7 +515,7 @@ public enum SMKLegacy { let encryptedKeyPair: Data = wrapper.encryptedKeyPair else { SNLog("[Migration Error] Unable to decode Legacy ClosedGroupControlMessage") - throw GRDBStorageError.migrationFailed + throw StorageError.migrationFailed } return SessionMessagingKit.ClosedGroupControlMessage.KeyPairWrapper( @@ -528,7 +528,7 @@ public enum SMKLegacy { case "nameChange": guard let name: String = self.name else { SNLog("[Migration Error] Unable to decode Legacy ClosedGroupControlMessage") - throw GRDBStorageError.migrationFailed + throw StorageError.migrationFailed } return .nameChange( @@ -538,7 +538,7 @@ public enum SMKLegacy { case "membersAdded": guard let members: [Data] = self.members else { SNLog("[Migration Error] Unable to decode Legacy ClosedGroupControlMessage") - throw GRDBStorageError.migrationFailed + throw StorageError.migrationFailed } return .membersAdded(members: members) @@ -546,14 +546,14 @@ public enum SMKLegacy { case "membersRemoved": guard let members: [Data] = self.members else { SNLog("[Migration Error] Unable to decode Legacy ClosedGroupControlMessage") - throw GRDBStorageError.migrationFailed + throw StorageError.migrationFailed } return .membersRemoved(members: members) case "memberLeft": return .memberLeft case "encryptionKeyPairRequest": return .encryptionKeyPairRequest - default: throw GRDBStorageError.migrationFailed + default: throw StorageError.migrationFailed } }() ) @@ -592,12 +592,12 @@ public enum SMKLegacy { case "mediaSaved": guard let timestamp: UInt64 = self.timestamp else { SNLog("[Migration Error] Unable to decode Legacy DataExtractionNotification") - throw GRDBStorageError.migrationFailed + throw StorageError.migrationFailed } return .mediaSaved(timestamp: timestamp) - default: throw GRDBStorageError.migrationFailed + default: throw StorageError.migrationFailed } }() ) diff --git a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift index 70c68de09..e25f9541e 100644 --- a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -6,6 +6,8 @@ import SessionUtilitiesKit enum _001_InitialSetupMigration: Migration { static let identifier: String = "initialSetup" + static let minExpectedRunDuration: TimeInterval = 0.1 + static let needsConfigSync: Bool = false static func migrate(_ db: Database) throws { // Define the tokenizer to be used in all the FTS tables diff --git a/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift index b0300db4b..55b0bec75 100644 --- a/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -10,6 +10,8 @@ import SessionSnodeKit /// before running the `YDBToGRDBMigration` enum _002_SetupStandardJobs: Migration { static let identifier: String = "SetupStandardJobs" + static let minExpectedRunDuration: TimeInterval = 0.1 + static let needsConfigSync: Bool = false static func migrate(_ db: Database) throws { // Start by adding the jobs that don't have collections (in the jobs like these diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 6f323bb51..7cb7042e5 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -11,8 +11,12 @@ import SessionSnodeKit // ~250k messages and ~1000 threads seems to take up enum _003_YDBToGRDBMigration: Migration { static let identifier: String = "YDBToGRDBMigration" + static let minExpectedRunDuration: TimeInterval = 20 + static let needsConfigSync: Bool = true static func migrate(_ db: Database) throws { + let targetIdentifier: TargetMigrations.Identifier = .messagingKit + // MARK: - Process Contacts, Threads & Interactions print("RAWR [\(Date().timeIntervalSince1970)] - SessionMessagingKit migration - Start") var shouldFailMigration: Bool = false @@ -283,7 +287,7 @@ enum _003_YDBToGRDBMigration: Migration { } // We can't properly throw within the 'enumerateKeysAndObjects' block so have to throw here - guard !shouldFailMigration else { throw GRDBStorageError.migrationFailed } + guard !shouldFailMigration else { throw StorageError.migrationFailed } // Insert the data into GRDB @@ -411,7 +415,7 @@ enum _003_YDBToGRDBMigration: Migration { try legacyThreads.sorted(by: { lhs, rhs in lhs.uniqueId < rhs.uniqueId }).forEach { legacyThread in guard let threadId: String = legacyThreadIdToIdMap[legacyThread.uniqueId] else { SNLog("[Migration Error] Unable to migrate thread with no id mapping") - throw GRDBStorageError.migrationFailed + throw StorageError.migrationFailed } let threadVariant: SessionThread.Variant @@ -464,7 +468,7 @@ enum _003_YDBToGRDBMigration: Migration { let formationTimestamp: UInt64 = closedGroupFormation[legacyThread.uniqueId] else { SNLog("[Migration Error] Closed group missing required data") - throw GRDBStorageError.migrationFailed + throw StorageError.migrationFailed } try ClosedGroup( @@ -519,7 +523,7 @@ enum _003_YDBToGRDBMigration: Migration { if legacyThread.isOpenGroup { guard let openGroup: OpenGroupV2 = openGroupInfo[legacyThread.uniqueId] else { SNLog("[Migration Error] Open group missing required data") - throw GRDBStorageError.migrationFailed + throw StorageError.migrationFailed } try OpenGroup( @@ -673,7 +677,7 @@ enum _003_YDBToGRDBMigration: Migration { default: // TODO: What message types have no body? SNLog("[Migration Error] Unsupported interaction type") - throw GRDBStorageError.migrationFailed + throw StorageError.migrationFailed } // Insert the data @@ -727,7 +731,7 @@ enum _003_YDBToGRDBMigration: Migration { guard let interactionId: Int64 = interaction.id else { // TODO: Is it possible the old database has duplicates which could hit this case? SNLog("[Migration Error] Failed to insert interaction") - throw GRDBStorageError.migrationFailed + throw StorageError.migrationFailed } // Store the interactionId in the lookup map to simplify job creation later @@ -874,7 +878,7 @@ enum _003_YDBToGRDBMigration: Migration { guard linkPreview.imageAttachmentId == nil || attachments[linkPreview.imageAttachmentId ?? ""] != nil else { // TODO: Is it possible to hit this case if a quoted attachment hasn't been downloaded? SNLog("[Migration Error] Missing link preview attachment") - throw GRDBStorageError.migrationFailed + throw StorageError.migrationFailed } // Setup the attachment and add it to the lookup (if it exists) @@ -1268,7 +1272,7 @@ enum _003_YDBToGRDBMigration: Migration { try attachmentUploadJobs.forEach { legacyJob in guard let sendJob: Job = messageSendJobLegacyMap[legacyJob.messageSendJobID], let sendJobId: Int64 = sendJob.id else { SNLog("[Migration Error] attachmentUpload job missing associated MessageSendJob") - throw GRDBStorageError.migrationFailed + throw StorageError.migrationFailed } let uploadJob: Job? = try Job( @@ -1286,7 +1290,7 @@ enum _003_YDBToGRDBMigration: Migration { // Add the dependency to the relevant MessageSendJob guard let uploadJobId: Int64 = uploadJob?.id else { SNLog("[Migration Error] attachmentUpload job was not created") - throw GRDBStorageError.migrationFailed + throw StorageError.migrationFailed } try JobDependencies( @@ -1302,7 +1306,7 @@ enum _003_YDBToGRDBMigration: Migration { try attachmentDownloadJobs.forEach { legacyJob in guard let interactionId: Int64 = legacyInteractionToIdMap[legacyJob.tsMessageID] else { SNLog("[Migration Error] attachmentDownload job unable to find interaction") - throw GRDBStorageError.migrationFailed + throw StorageError.migrationFailed } guard processedAttachmentIds.contains(legacyJob.attachmentID) else { SNLog("[Migration Error] attachmentDownload job unable to find attachment") @@ -1441,7 +1445,7 @@ enum _003_YDBToGRDBMigration: Migration { guard !processedAttachmentIds.contains(legacyAttachmentId) else { guard isQuotedMessage else { SNLog("[Migration Error] Attempted to process duplicate attachment") - throw GRDBStorageError.migrationFailed + throw StorageError.migrationFailed } return legacyAttachmentId diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index acc93db4d..43700cc79 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -983,7 +983,7 @@ extension Attachment { guard uploadedAttachment != nil else { SNLog("Couldn't update attachmentUpload job.") - failure?(GRDBStorageError.failedToSave) + failure?(StorageError.failedToSave) return } @@ -1025,7 +1025,7 @@ extension Attachment { guard updatedAttachment != nil else { SNLog("Couldn't update attachmentUpload job.") - failure?(GRDBStorageError.failedToSave) + failure?(StorageError.failedToSave) return } @@ -1049,7 +1049,7 @@ extension Attachment { guard uploadedAttachment != nil else { SNLog("Couldn't update attachmentUpload job.") - failure?(GRDBStorageError.failedToSave) + failure?(StorageError.failedToSave) return } diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index 4c3178c74..bd6cbf6d9 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -121,7 +121,7 @@ public extension Profile { { guard let validProfileKey: OWSAES256Key = OWSAES256Key(data: profileKeyData) else { owsFailDebug("Failed to make profile key for key data") - throw GRDBStorageError.decodingFailed + throw StorageError.decodingFailed } profileKey = validProfileKey diff --git a/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift b/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift index fb3b5ccd1..2a42f72a5 100644 --- a/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift +++ b/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift @@ -94,7 +94,7 @@ public extension DisappearingMessagesJob { do { guard let interactionId: Int64 = try? (interaction.id ?? interaction.inserted(db).id) else { - throw GRDBStorageError.objectNotFound + throw StorageError.objectNotFound } return updateNextRunIfNeeded(db, interactionIds: [interactionId], startedAtMs: startedAtMs) diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift index dde5fd1fe..51fbe5ef6 100644 --- a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift @@ -211,18 +211,18 @@ extension MessageSendJob { guard let messageType: String = try? container.decode(String.self, forKey: .messageType) else { Logger.error("Unable to decode messageSend job due to missing messageType") - throw GRDBStorageError.decodingFailed + throw StorageError.decodingFailed } /// Note: This **MUST** be a `Codable.Type` rather than a `Message.Type` otherwise the decoding will result /// in a `Message` object being returned rather than the desired subclass guard let MessageType: Codable.Type = MessageSendJob.Details.supportedMessageTypes[messageType] else { Logger.error("Unable to decode messageSend job due to unsupported messageType") - throw GRDBStorageError.decodingFailed + throw StorageError.decodingFailed } guard let message: Message = try MessageType.decoded(with: container, forKey: .message) as? Message else { Logger.error("Unable to decode messageSend job due to message conversion issue") - throw GRDBStorageError.decodingFailed + throw StorageError.decodingFailed } self = Details( @@ -241,7 +241,7 @@ extension MessageSendJob { guard let messageTypeString: String = maybeMessageTypeString else { Logger.error("Unable to encode messageSend job due to unsupported messageType") - throw GRDBStorageError.objectNotFound + throw StorageError.objectNotFound } try container.encode(destination, forKey: .destination) diff --git a/SessionMessagingKit/Messages/Message+Destination.swift b/SessionMessagingKit/Messages/Message+Destination.swift index c129e83bb..ac0522460 100644 --- a/SessionMessagingKit/Messages/Message+Destination.swift +++ b/SessionMessagingKit/Messages/Message+Destination.swift @@ -17,7 +17,7 @@ public extension Message { case .closedGroup: return .closedGroup(groupPublicKey: thread.id) case .openGroup: guard let openGroup: OpenGroup = try thread.openGroup.fetchOne(db) else { - throw GRDBStorageError.objectNotFound + throw StorageError.objectNotFound } return .openGroupV2(room: openGroup.room, server: openGroup.server) diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index 3b435b178..eeebdeeba 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -494,7 +494,7 @@ extension MessageReceiver { throw error } - guard let interactionId: Int64 = interaction.id else { throw GRDBStorageError.failedToSave } + guard let interactionId: Int64 = interaction.id else { throw StorageError.failedToSave } // Update and recipient and read states as needed try updateRecipientAndReadStates( diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift index 3215309a7..1e01e6012 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift @@ -230,9 +230,7 @@ extension MessageSender { timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) ).inserted(db) - guard let interactionId: Int64 = interaction.id else { - throw GRDBStorageError.objectNotSaved - } + guard let interactionId: Int64 = interaction.id else { throw StorageError.objectNotSaved } // Send the update to the group let closedGroupControlMessage = ClosedGroupControlMessage(kind: .nameChange(name: name)) @@ -305,10 +303,10 @@ extension MessageSender { thread: SessionThread ) throws { guard let disappearingMessagesConfig: DisappearingMessagesConfiguration = try thread.disappearingMessagesConfiguration.fetchOne(db) else { - throw GRDBStorageError.objectNotFound + throw StorageError.objectNotFound } guard let encryptionKeyPair: ClosedGroupKeyPair = try closedGroup.fetchLatestKeyPair(db) else { - throw GRDBStorageError.objectNotFound + throw StorageError.objectNotFound } let groupMemberIds: [String] = allGroupMembers @@ -332,9 +330,7 @@ extension MessageSender { timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) ).inserted(db) - guard let interactionId: Int64 = interaction.id else { - throw GRDBStorageError.objectNotSaved - } + guard let interactionId: Int64 = interaction.id else { throw StorageError.objectNotSaved } // Send the update to the group try MessageSender.send( @@ -437,9 +433,7 @@ extension MessageSender { timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) ).inserted(db) - guard let newInteractionId: Int64 = interaction.id else { - throw GRDBStorageError.objectNotSaved - } + guard let newInteractionId: Int64 = interaction.id else { throw StorageError.objectNotSaved } interactionId = newInteractionId } @@ -505,7 +499,7 @@ extension MessageSender { ).inserted(db) guard let interactionId: Int64 = interaction.id else { - throw GRDBStorageError.objectNotSaved + throw StorageError.objectNotSaved } // Send the update to the group diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index 9325c1362..df96dd2fb 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -10,7 +10,7 @@ extension MessageSender { // MARK: - Durable public static func send(_ db: Database, interaction: Interaction, with attachments: [SignalAttachment], in thread: SessionThread) throws { - guard let interactionId: Int64 = interaction.id else { throw GRDBStorageError.objectNotSaved } + guard let interactionId: Int64 = interaction.id else { throw StorageError.objectNotSaved } try prep(db, signalAttachments: attachments, for: interactionId) send( @@ -25,7 +25,7 @@ extension MessageSender { public static func send(_ db: Database, interaction: Interaction, in thread: SessionThread) throws { // Only 'VisibleMessage' types can be sent via this method guard interaction.variant == .standardOutgoing else { throw MessageSenderError.invalidMessage } - guard let interactionId: Int64 = interaction.id else { throw GRDBStorageError.objectNotSaved } + guard let interactionId: Int64 = interaction.id else { throw StorageError.objectNotSaved } send( db, @@ -64,7 +64,7 @@ extension MessageSender { // MARK: - Non-Durable public static func sendNonDurably(_ db: Database, interaction: Interaction, with attachments: [SignalAttachment], in thread: SessionThread) throws -> Promise { - guard let interactionId: Int64 = interaction.id else { return Promise(error: GRDBStorageError.objectNotSaved) } + guard let interactionId: Int64 = interaction.id else { return Promise(error: StorageError.objectNotSaved) } try prep(db, signalAttachments: attachments, for: interactionId) @@ -80,7 +80,7 @@ extension MessageSender { public static func sendNonDurably(_ db: Database, interaction: Interaction, in thread: SessionThread) throws -> Promise { // Only 'VisibleMessage' types can be sent via this method guard interaction.variant == .standardOutgoing else { throw MessageSenderError.invalidMessage } - guard let interactionId: Int64 = interaction.id else { throw GRDBStorageError.objectNotSaved } + guard let interactionId: Int64 = interaction.id else { throw StorageError.objectNotSaved } return sendNonDurably( db, @@ -174,9 +174,7 @@ extension MessageSender { // If we don't have a userKeyPair yet then there is no need to sync the configuration // as the user doesn't exist yet (this will get triggered on the first launch of a // fresh install due to the migrations getting run) - guard Identity.userExists(db) else { - return Promise(error: GRDBStorageError.generic) - } + guard Identity.userExists(db) else { return Promise(error: StorageError.generic) } let destination: Message.Destination = Message.Destination.contact( publicKey: getUserHexEncodedPublicKey(db) @@ -188,7 +186,7 @@ extension MessageSender { try MessageSender .sendImmediate(db, message: configurationMessage, to: destination, interactionId: nil) .done { seal.fulfill(()) } - .catch { _ in seal.reject(GRDBStorageError.generic) } + .catch { _ in seal.reject(StorageError.generic) } .retainUntilComplete() } else { diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 3170537d3..a49d37007 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -782,7 +782,7 @@ public extension SessionThreadViewModel { .defaulting(to: FTS5Pattern(matchingAnyTokenIn: searchTerm)) ) - guard let pattern: FTS5Pattern = maybePattern else { throw GRDBStorageError.invalidSearchPattern } + guard let pattern: FTS5Pattern = maybePattern else { throw StorageError.invalidSearchPattern } return pattern } diff --git a/SessionMessagingKit/Utilities/Environment.h b/SessionMessagingKit/Utilities/Environment.h deleted file mode 100644 index 16b2f8004..000000000 --- a/SessionMessagingKit/Utilities/Environment.h +++ /dev/null @@ -1,39 +0,0 @@ -#import - -@class OWSAudioSession; -@class OWSPreferences; -@class OWSWindowManager; - -@protocol OWSProximityMonitoringManager; - -/** - * - * Environment is a data and data accessor class. - * It handles application-level component wiring in order to support mocks for testing. - * It also handles network configuration for testing/deployment server configurations. - * - **/ -@interface Environment : NSObject - -- (instancetype)init NS_UNAVAILABLE; - -- (instancetype)initWithAudioSession:(OWSAudioSession *)audioSession - preferences:(OWSPreferences *)preferences - proximityMonitoringManager:(id)proximityMonitoringManager - windowManager:(OWSWindowManager *)windowManager; - -@property (nonatomic, readonly) OWSAudioSession *audioSession; -@property (nonatomic, readonly) id proximityMonitoringManager; -@property (nonatomic, readonly) OWSPreferences *preferences; -@property (nonatomic, readonly) OWSWindowManager *windowManager; -// We don't want to cover the window when we request the photo library permission -@property (nonatomic, readwrite) BOOL isRequestingPermission; - -@property (class, nonatomic) Environment *shared; - -#ifdef DEBUG -// Should only be called by tests. -+ (void)clearSharedForTests; -#endif - -@end diff --git a/SessionMessagingKit/Utilities/Environment.m b/SessionMessagingKit/Utilities/Environment.m deleted file mode 100644 index ee8f5c284..000000000 --- a/SessionMessagingKit/Utilities/Environment.m +++ /dev/null @@ -1,62 +0,0 @@ - -#import -#import "OWSWindowManager.h" -#import -#import "OWSPreferences.h" - -static Environment *sharedEnvironment = nil; - -@interface Environment () - -@property (nonatomic) OWSAudioSession *audioSession; -@property (nonatomic) OWSPreferences *preferences; -@property (nonatomic) id proximityMonitoringManager; -@property (nonatomic) OWSWindowManager *windowManager; - -@end - -#pragma mark - - -@implementation Environment - -+ (Environment *)shared -{ - return sharedEnvironment; -} - -+ (void)setShared:(Environment *)environment -{ - // The main app environment should only be set once. - // - // App extensions may be opened multiple times in the same process, - // so statics will persist. - - sharedEnvironment = environment; -} - -+ (void)clearSharedForTests -{ - sharedEnvironment = nil; -} - -- (instancetype)initWithAudioSession:(OWSAudioSession *)audioSession - preferences:(OWSPreferences *)preferences - proximityMonitoringManager:(id)proximityMonitoringManager - windowManager:(OWSWindowManager *)windowManager -{ - self = [super init]; - - if (!self) { - return self; - } - - _audioSession = audioSession; - _preferences = preferences; - _proximityMonitoringManager = proximityMonitoringManager; - _windowManager = windowManager; - _isRequestingPermission = false; - - return self; -} - -@end diff --git a/SessionMessagingKit/Utilities/Environment.swift b/SessionMessagingKit/Utilities/Environment.swift new file mode 100644 index 000000000..6d616b06a --- /dev/null +++ b/SessionMessagingKit/Utilities/Environment.swift @@ -0,0 +1,76 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +public class Environment { + public static var shared: Environment! + + public let primaryStorage: OWSPrimaryStorage + public let reachabilityManager: SSKReachabilityManager + + public let audioSession: OWSAudioSession + public let preferences: OWSPreferences + public let proximityMonitoringManager: OWSProximityMonitoringManager + public let windowManager: OWSWindowManager + public var isRequestingPermission: Bool + + // Note: This property is configured after Environment is created. + public let notificationsManager: Atomic = Atomic(nil) + + public var isComplete: Bool { + (notificationsManager.wrappedValue != nil) + } + + public var objectReadWriteConnection: YapDatabaseConnection + public var sessionStoreDBConnection: YapDatabaseConnection + public var migrationDBConnection: YapDatabaseConnection + public var analyticsDBConnection: YapDatabaseConnection + + // MARK: - Initialization + + public init( + primaryStorage: OWSPrimaryStorage, + reachabilityManager: SSKReachabilityManager, + audioSession: OWSAudioSession, + preferences: OWSPreferences, + proximityMonitoringManager: OWSProximityMonitoringManager, + windowManager: OWSWindowManager + ) { + self.primaryStorage = primaryStorage + self.reachabilityManager = reachabilityManager + self.audioSession = audioSession + self.preferences = preferences + self.proximityMonitoringManager = proximityMonitoringManager + self.windowManager = windowManager + self.isRequestingPermission = false + + self.objectReadWriteConnection = primaryStorage.newDatabaseConnection() + self.sessionStoreDBConnection = primaryStorage.newDatabaseConnection() + self.migrationDBConnection = primaryStorage.newDatabaseConnection() + self.analyticsDBConnection = primaryStorage.newDatabaseConnection() + + if Environment.shared == nil { + Environment.shared = self + } + } + + // MARK: - Functions + + public static func clearSharedForTests() { + shared = nil + } +} + +// MARK: - Objective C Support + +@objc(SMKEnvironment) +class SMKEnvironment: NSObject { + @objc public static let shared: SMKEnvironment = SMKEnvironment() + + @objc public var primaryStorage: OWSPrimaryStorage { Environment.shared.primaryStorage } + @objc public var audioSession: OWSAudioSession { Environment.shared.audioSession } + @objc public var windowManager: OWSWindowManager { Environment.shared.windowManager } + + @objc public var isRequestingPermission: Bool { Environment.shared.isRequestingPermission } +} diff --git a/SessionMessagingKit/Utilities/OWSAudioPlayer.m b/SessionMessagingKit/Utilities/OWSAudioPlayer.m index f59af6416..49a272b63 100644 --- a/SessionMessagingKit/Utilities/OWSAudioPlayer.m +++ b/SessionMessagingKit/Utilities/OWSAudioPlayer.m @@ -94,7 +94,7 @@ NS_ASSUME_NONNULL_BEGIN - (OWSAudioSession *)audioSession { - return Environment.shared.audioSession; + return SMKEnvironment.shared.audioSession; } #pragma mark diff --git a/SessionMessagingKit/Utilities/OWSWindowManager.m b/SessionMessagingKit/Utilities/OWSWindowManager.m index 9083d9fb4..c1fa33d4f 100644 --- a/SessionMessagingKit/Utilities/OWSWindowManager.m +++ b/SessionMessagingKit/Utilities/OWSWindowManager.m @@ -4,6 +4,7 @@ #import "OWSWindowManager.h" #import "Environment.h" +#import #import NS_ASSUME_NONNULL_BEGIN @@ -153,7 +154,7 @@ const UIWindowLevel UIWindowLevel_MessageActions(void) + (instancetype)sharedManager { - return Environment.shared.windowManager; + return SMKEnvironment.shared.windowManager; } - (instancetype)initDefault diff --git a/SessionMessagingKit/Utilities/SSKEnvironment.swift b/SessionMessagingKit/Utilities/SSKEnvironment.swift deleted file mode 100644 index 9338ccab1..000000000 --- a/SessionMessagingKit/Utilities/SSKEnvironment.swift +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionUtilitiesKit - -@objc -public class SSKEnvironment: NSObject { - @objc public let primaryStorage: OWSPrimaryStorage - public let reachabilityManager: SSKReachabilityManager - - // Note: This property is configured after Environment is created. - public let notificationsManager: Atomic = Atomic(nil) - - @objc public static var shared: SSKEnvironment! - - public var isComplete: Bool { - (notificationsManager.wrappedValue != nil) - } - - public var objectReadWriteConnection: YapDatabaseConnection - public var sessionStoreDBConnection: YapDatabaseConnection - public var migrationDBConnection: YapDatabaseConnection - public var analyticsDBConnection: YapDatabaseConnection - - // MARK: - Initialization - - @objc public init( - primaryStorage: OWSPrimaryStorage, - reachabilityManager: SSKReachabilityManager - ) { - self.primaryStorage = primaryStorage - self.reachabilityManager = reachabilityManager - - self.objectReadWriteConnection = primaryStorage.newDatabaseConnection() - self.sessionStoreDBConnection = primaryStorage.newDatabaseConnection() - self.migrationDBConnection = primaryStorage.newDatabaseConnection() - self.analyticsDBConnection = primaryStorage.newDatabaseConnection() - - super.init() - - if SSKEnvironment.shared == nil { - SSKEnvironment.shared = self - } - } - - // MARK: - Functions - - public static func clearSharedForTests() { - shared = nil - } -} diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index 6c3c60b47..c0df8e76d 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -115,12 +115,12 @@ public final class NotificationServiceExtension : UNNotificationServiceExtension guard GRDBStorage.shared[.isReadyForAppExtensions] else { return completeSilenty() } AppSetup.setupEnvironment( - appSpecificSingletonBlock: { - SSKEnvironment.shared.notificationsManager.mutate { + appSpecificBlock: { + Environment.shared.notificationsManager.mutate { $0 = NSENotificationPresenter() } }, - migrationCompletion: { [weak self] _, needsConfigSync in + migrationsCompletion: { [weak self] _, needsConfigSync in self?.versionMigrationsDidComplete(needsConfigSync: needsConfigSync) completion() } diff --git a/SessionShareExtension/ShareVC.swift b/SessionShareExtension/ShareVC.swift index d8b30de32..a300e2bbe 100644 --- a/SessionShareExtension/ShareVC.swift +++ b/SessionShareExtension/ShareVC.swift @@ -43,14 +43,12 @@ final class ShareVC : UINavigationController, ShareViewDelegate, AppModeManagerD } AppSetup.setupEnvironment( - appSpecificSingletonBlock: { - SSKEnvironment.shared.notificationsManager.mutate { + appSpecificBlock: { + Environment.shared.notificationsManager.mutate { $0 = NoopNotificationsManager() } }, - migrationCompletion: { [weak self] _, needsConfigSync in - AssertIsOnMainThread() - + migrationsCompletion: { [weak self] _, needsConfigSync in // performUpdateCheck must be invoked after Environment has been initialized because // upgrade process may depend on Environment. self?.versionMigrationsDidComplete(needsConfigSync: needsConfigSync) diff --git a/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift index 7ee5082d7..d8b29ae3c 100644 --- a/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -6,6 +6,8 @@ import SessionUtilitiesKit enum _001_InitialSetupMigration: Migration { static let identifier: String = "initialSetup" + static let minExpectedRunDuration: TimeInterval = 0.1 + static let needsConfigSync: Bool = false static func migrate(_ db: Database) throws { try db.create(table: Snode.self) { t in diff --git a/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift index 81fb80437..145cc544b 100644 --- a/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -8,6 +8,8 @@ import SessionUtilitiesKit /// before running the `YDBToGRDBMigration` enum _002_SetupStandardJobs: Migration { static let identifier: String = "SetupStandardJobs" + static let minExpectedRunDuration: TimeInterval = 0.1 + static let needsConfigSync: Bool = false static func migrate(_ db: Database) throws { try autoreleasepool { diff --git a/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift index a29fde245..0896fa6a5 100644 --- a/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -6,6 +6,8 @@ import SessionUtilitiesKit enum _003_YDBToGRDBMigration: Migration { static let identifier: String = "YDBToGRDBMigration" + static let minExpectedRunDuration: TimeInterval = 0.2 + static let needsConfigSync: Bool = false static func migrate(_ db: Database) throws { // MARK: - OnionRequestPath, Snode Pool & Swarm diff --git a/SessionUIKit/Utilities/UIView+Constraints.swift b/SessionUIKit/Utilities/UIView+Constraints.swift index 61e6e4f36..f9b237c64 100644 --- a/SessionUIKit/Utilities/UIView+Constraints.swift +++ b/SessionUIKit/Utilities/UIView+Constraints.swift @@ -97,9 +97,9 @@ public extension UIView { } @discardableResult - func set(_ dimension: Dimension, to otherDimension: Dimension, of view: UIView, withOffset offset: CGFloat = 0) -> NSLayoutConstraint { + func set(_ dimension: Dimension, to otherDimension: Dimension, of view: UIView, withOffset offset: CGFloat = 0, multiplier: CGFloat = 1) -> NSLayoutConstraint { translatesAutoresizingMaskIntoConstraints = false - let otherAnchor: NSLayoutAnchor = { + let otherAnchor: NSLayoutDimension = { switch otherDimension { case .width: return view.widthAnchor case .height: return view.heightAnchor @@ -107,8 +107,8 @@ public extension UIView { }() let constraint: NSLayoutConstraint = { switch dimension { - case .width: return widthAnchor.constraint(equalTo: otherAnchor, constant: offset) - case .height: return heightAnchor.constraint(equalTo: otherAnchor, constant: offset) + case .width: return widthAnchor.constraint(equalTo: otherAnchor, multiplier: multiplier, constant: offset) + case .height: return heightAnchor.constraint(equalTo: otherAnchor, multiplier: multiplier, constant: offset) } }() constraint.isActive = true diff --git a/SessionUtilitiesKit/Database/GRDBStorage.swift b/SessionUtilitiesKit/Database/GRDBStorage.swift index 30bfdddeb..305cdedf4 100644 --- a/SessionUtilitiesKit/Database/GRDBStorage.swift +++ b/SessionUtilitiesKit/Database/GRDBStorage.swift @@ -5,25 +5,8 @@ import GRDB import PromiseKit import SignalCoreKit -public enum GRDBStorageError: Error { // TODO: Rename to `StorageError` - case generic - case migrationFailed - case invalidKeySpec - case decodingFailed - - case failedToSave - case objectNotFound - case objectNotSaved - - case invalidSearchPattern -} -// TODO: Protocol for storage (just need to have 'read' and 'write' methods and mock 'Database'? - -// TODO: Rename to `Storage` public final class GRDBStorage { - public static var shared: GRDBStorage! // TODO: Figure out how/if we want to do this - private static let dbFileName: String = "Session.sqlite" private static let keychainService: String = "TSKeyChainService" private static let dbCipherKeySpecKey: String = "GRDBDatabaseCipherKeySpec" @@ -40,14 +23,17 @@ public final class GRDBStorage { return true } - private let dbPool: DatabasePool - private let migrator: DatabaseMigrator + public static let shared: GRDBStorage = GRDBStorage() + public private(set) var isValid: Bool = false + public private(set) var hasCompletedMigrations: Bool = false + + private var dbPool: DatabasePool? + private var migrator: DatabaseMigrator? + private var migrationProgressUpdater: Atomic<((String, CGFloat) -> ())>? // MARK: - Initialization - public init?( - migrations: [TargetMigrations] - ) throws { + public init() { print("RAWR START \("\(GRDBStorage.sharedDatabaseDirectoryPath)/\(GRDBStorage.dbFileName)")") GRDBStorage.deleteDatabaseFiles() // TODO: Remove this try! GRDBStorage.deleteDbKeys() // TODO: Remove this @@ -76,7 +62,7 @@ public final class GRDBStorage { // using explicit BLOB syntax, e.g.: // // x'98483C6EB40B6C31A448C22A66DED3B5E5E8D5119CAC8327B655C8B5C483648101010101010101010101010101010101' - keySpec = try (keySpec.toHexString().data(using: .utf8) ?? { throw GRDBStorageError.invalidKeySpec }()) + keySpec = try (keySpec.toHexString().data(using: .utf8) ?? { throw StorageError.invalidKeySpec }()) keySpec.insert(contentsOf: [120, 39], at: 0) // "x'" prefix keySpec.append(39) // "'" suffix @@ -90,40 +76,111 @@ public final class GRDBStorage { try db.execute(sql: "PRAGMA cipher_plaintext_header_size = 32") } - // Create the DatabasePool to allow us to connect to the database - dbPool = try DatabasePool( - path: "\(GRDBStorage.sharedDatabaseDirectoryPath)/\(GRDBStorage.dbFileName)", - configuration: config - ) + // Create the DatabasePool to allow us to connect to the database and mark the storage as valid + do { + dbPool = try DatabasePool( + path: "\(GRDBStorage.sharedDatabaseDirectoryPath)/\(GRDBStorage.dbFileName)", + configuration: config + ) + isValid = true + } + catch {} + } + + // MARK: - Migrations + + public func perform( + migrations: [TargetMigrations], + onProgressUpdate: ((CGFloat, TimeInterval) -> ())?, + onComplete: @escaping (Bool, Bool) -> () + ) { + guard isValid, let dbPool: DatabasePool = dbPool else { return } + + typealias MigrationInfo = (identifier: TargetMigrations.Identifier, migrations: TargetMigrations.MigrationSet) + let sortedMigrationInfo: [MigrationInfo] = migrations + .sorted() + .reduce(into: [[MigrationInfo]]()) { result, next in + next.migrations.enumerated().forEach { index, migrationSet in + if result.count <= index { + result.append([]) + } + + result[index] = (result[index] + [(next.identifier, migrationSet)]) + } + } + .reduce(into: []) { result, next in result.append(contentsOf: next) } // Setup and run any required migrations migrator = { var migrator: DatabaseMigrator = DatabaseMigrator() - migrations - .sorted() - .reduce(into: [[(identifier: TargetMigrations.Identifier, migrations: TargetMigrations.MigrationSet)]]()) { result, next in - next.migrations.enumerated().forEach { index, migrationSet in - if result.count <= index { - result.append([]) - } - - result[index] = (result[index] + [(next.identifier, migrationSet)]) - } - } - .compactMap { $0 } - .forEach { sortedMigrationInfo in - sortedMigrationInfo.forEach { migrationInfo in - migrationInfo.migrations.forEach { migration in - migrator.registerMigration(migrationInfo.identifier, migration: migration) - } - } + sortedMigrationInfo.forEach { migrationInfo in + migrationInfo.migrations.forEach { migration in + migrator.registerMigration(migrationInfo.identifier, migration: migration) } + } return migrator }() - try! migrator.migrate(dbPool) - GRDBStorage.shared = self // TODO: Fix this + // Determine which migrations need to be performed and gather the relevant settings needed to + // inform the app of progress/states + let completedMigrations: [String] = (try? dbPool.read { db in try migrator?.completedMigrations(db) }) + .defaulting(to: []) + let unperformedMigrations: [(key: String, migration: Migration.Type)] = sortedMigrationInfo + .reduce(into: []) { result, next in + next.migrations.forEach { migration in + let key: String = next.identifier.key(with: migration) + + guard !completedMigrations.contains(key) else { return } + + result.append((key, migration)) + } + } + let migrationToDurationMap: [String: TimeInterval] = unperformedMigrations + .reduce(into: [:]) { result, next in + result[next.key] = next.migration.minExpectedRunDuration + } + let unperformedMigrationDurations: [TimeInterval] = unperformedMigrations + .map { _, migration in migration.minExpectedRunDuration } + let totalMinExpectedDuration: TimeInterval = migrationToDurationMap.values.reduce(0, +) + let needsConfigSync: Bool = unperformedMigrations + .contains(where: { _, migration in migration.needsConfigSync }) + + self.migrationProgressUpdater = Atomic({ targetKey, progress in + guard let migrationIndex: Int = unperformedMigrations.firstIndex(where: { key, _ in key == targetKey }) else { + return + } + + let completedExpectedDuration: TimeInterval = ( + (migrationIndex > 0 ? unperformedMigrationDurations[0..(updates: (Database) throws -> T?) -> T? { + guard isValid, let dbPool: DatabasePool = dbPool else { return nil } + return try? dbPool.write(updates) } @@ -235,6 +294,8 @@ public final class GRDBStorage { } public func writeAsync(updates: @escaping (Database) throws -> T, completion: @escaping (Database, Swift.Result) throws -> Void) { + guard isValid, let dbPool: DatabasePool = dbPool else { return } + dbPool.asyncWrite( updates, completion: { db, result in @@ -244,6 +305,8 @@ public final class GRDBStorage { } @discardableResult public func read(_ value: (Database) throws -> T?) -> T? { + guard isValid, let dbPool: DatabasePool = dbPool else { return nil } + return try? dbPool.read(value) } @@ -262,7 +325,9 @@ public final class GRDBStorage { onError: @escaping (Error) -> Void, onChange: @escaping (Reducer.Value) -> Void ) -> DatabaseCancellable { - observation.start( + guard isValid, let dbPool: DatabasePool = dbPool else { return AnyDatabaseCancellable(cancel: {}) } + + return observation.start( in: dbPool, scheduling: scheduler, onError: onError, @@ -271,6 +336,7 @@ public final class GRDBStorage { } public func addObserver(_ observer: TransactionObserver?) { + guard isValid, let dbPool: DatabasePool = dbPool else { return } guard let observer: TransactionObserver = observer else { return } dbPool.add(transactionObserver: observer) @@ -282,6 +348,8 @@ public final class GRDBStorage { public extension GRDBStorage { // FIXME: Would be good to replace these with Swift Combine @discardableResult func read(_ value: (Database) throws -> Promise) -> Promise { + guard isValid, let dbPool: DatabasePool = dbPool else { return Promise(error: StorageError.databaseInvalid) } + do { return try dbPool.read(value) } @@ -291,6 +359,8 @@ public extension GRDBStorage { } @discardableResult func write(updates: (Database) throws -> Promise) -> Promise { + guard isValid, let dbPool: DatabasePool = dbPool else { return Promise(error: StorageError.databaseInvalid) } + do { return try dbPool.write(updates) } diff --git a/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift index ec4369845..fd1392956 100644 --- a/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -5,6 +5,8 @@ import GRDB enum _001_InitialSetupMigration: Migration { static let identifier: String = "initialSetup" + static let minExpectedRunDuration: TimeInterval = 0.1 + static let needsConfigSync: Bool = false static func migrate(_ db: Database) throws { try db.create(table: Identity.self) { t in diff --git a/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift index 473c9ff9a..dd8e6b800 100644 --- a/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -8,6 +8,8 @@ import Curve25519Kit /// before running the `YDBToGRDBMigration` enum _002_SetupStandardJobs: Migration { static let identifier: String = "SetupStandardJobs" + static let minExpectedRunDuration: TimeInterval = 0.1 + static let needsConfigSync: Bool = false static func migrate(_ db: Database) throws { try autoreleasepool { diff --git a/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 3f8cc5b32..a5b04d43e 100644 --- a/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -5,6 +5,8 @@ import GRDB enum _003_YDBToGRDBMigration: Migration { static let identifier: String = "YDBToGRDBMigration" + static let minExpectedRunDuration: TimeInterval = 0.1 + static let needsConfigSync: Bool = false static func migrate(_ db: Database) throws { // MARK: - Identity keys @@ -69,7 +71,7 @@ enum _003_YDBToGRDBMigration: Migration { return } - throw GRDBStorageError.migrationFailed + throw StorageError.migrationFailed } print("RAWR publicKey \(userX25519KeyPair.publicKey.toHexString())") try autoreleasepool { diff --git a/SessionUtilitiesKit/Database/StorageError.swift b/SessionUtilitiesKit/Database/StorageError.swift new file mode 100644 index 000000000..04ad00a98 --- /dev/null +++ b/SessionUtilitiesKit/Database/StorageError.swift @@ -0,0 +1,17 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum StorageError: Error { + case generic + case databaseInvalid + case migrationFailed + case invalidKeySpec + case decodingFailed + + case failedToSave + case objectNotFound + case objectNotSaved + + case invalidSearchPattern +} diff --git a/SessionUtilitiesKit/Database/Types/Migration.swift b/SessionUtilitiesKit/Database/Types/Migration.swift index 493e00d06..69d27e754 100644 --- a/SessionUtilitiesKit/Database/Types/Migration.swift +++ b/SessionUtilitiesKit/Database/Types/Migration.swift @@ -5,6 +5,8 @@ import GRDB public protocol Migration { static var identifier: String { get } + static var needsConfigSync: Bool { get } + static var minExpectedRunDuration: TimeInterval { get } static func migrate(_ db: Database) throws } diff --git a/SessionUtilitiesKit/Database/Types/TargetMigrations.swift b/SessionUtilitiesKit/Database/Types/TargetMigrations.swift index 4718d5714..de793d071 100644 --- a/SessionUtilitiesKit/Database/Types/TargetMigrations.swift +++ b/SessionUtilitiesKit/Database/Types/TargetMigrations.swift @@ -26,6 +26,10 @@ public struct TargetMigrations: Comparable { return (lhsIndex < rhsIndex) } + + func key(with migration: Migration.Type) -> String { + return "\(self.rawValue).\(migration.identifier)" + } } public typealias MigrationSet = [Migration.Type] diff --git a/SignalUtilitiesKit/Configuration.swift b/SignalUtilitiesKit/Configuration.swift index f8382dacc..000a6ef43 100644 --- a/SignalUtilitiesKit/Configuration.swift +++ b/SignalUtilitiesKit/Configuration.swift @@ -3,8 +3,6 @@ import SessionSnodeKit extension OWSPrimaryStorage : OWSPrimaryStorageProtocol { } -var isSetup: Bool = false // TODO: Remove this - @objc(SNConfiguration) public final class Configuration : NSObject { @@ -19,21 +17,4 @@ public final class Configuration : NSObject { SNMessagingKit.configure(storage: Storage.shared) SNSnodeKit.configure() } - - @objc public static func performDatabaseSetup() { - if !isSetup { - isSetup = true - - // TODO: Need to store this result somewhere? - // TODO: This function seems to get called multiple times - //DispatchQueue.main.once - let storage: GRDBStorage? = try? GRDBStorage( - migrations: [ - SNUtilitiesKit.migrations(), - SNSnodeKit.migrations(), - SNMessagingKit.migrations() - ] - ) - } - } } diff --git a/SignalUtilitiesKit/Utilities/AppSetup.h b/SignalUtilitiesKit/Utilities/AppSetup.h deleted file mode 100644 index aa4f587c7..000000000 --- a/SignalUtilitiesKit/Utilities/AppSetup.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -NS_ASSUME_NONNULL_BEGIN - -typedef void (^OWSDatabaseMigrationCompletion)(BOOL success, BOOL requiresConfigurationSync); - -// This is _NOT_ a singleton and will be instantiated each time that the SAE is used. -@interface AppSetup : NSObject - -+ (void)setupEnvironmentWithAppSpecificSingletonBlock:(dispatch_block_t)appSpecificSingletonBlock - migrationCompletion:(OWSDatabaseMigrationCompletion)migrationCompletion; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/AppSetup.m b/SignalUtilitiesKit/Utilities/AppSetup.m deleted file mode 100644 index 8c2e33da3..000000000 --- a/SignalUtilitiesKit/Utilities/AppSetup.m +++ /dev/null @@ -1,93 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "AppSetup.h" -#import "Environment.h" -#import "VersionMigrations.h" -#import -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@implementation AppSetup - -+ (void)setupEnvironmentWithAppSpecificSingletonBlock:(dispatch_block_t)appSpecificSingletonBlock - migrationCompletion:(OWSDatabaseMigrationCompletion)migrationCompletion -{ - OWSAssertDebug(appSpecificSingletonBlock); - OWSAssertDebug(migrationCompletion); - - __block OWSBackgroundTask *_Nullable backgroundTask = - [OWSBackgroundTask backgroundTaskWithLabelStr:__PRETTY_FUNCTION__]; - - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - // Order matters here. - // - // All of these "singletons" should have any dependencies used in their - // initializers injected. - [[OWSBackgroundTaskManager sharedManager] observeNotifications]; - - OWSPrimaryStorage *primaryStorage = [[OWSPrimaryStorage alloc] initStorage]; - [OWSPrimaryStorage protectFiles]; - - // AFNetworking (via CFNetworking) spools it's attachments to NSTemporaryDirectory(). - // If you receive a media message while the device is locked, the download will fail if the temporary directory - // is NSFileProtectionComplete - BOOL success = [OWSFileSystem protectFileOrFolderAtPath:NSTemporaryDirectory() - fileProtectionType:NSFileProtectionCompleteUntilFirstUserAuthentication]; - OWSAssert(success); - - OWSPreferences *preferences = [OWSPreferences new]; - - id reachabilityManager = [SSKReachabilityManagerImpl new]; - - OWSAudioSession *audioSession = [OWSAudioSession new]; - id proximityMonitoringManager = [OWSProximityMonitoringManagerImpl new]; - OWSWindowManager *windowManager = [[OWSWindowManager alloc] initDefault]; - - [Environment setShared:[[Environment alloc] initWithAudioSession:audioSession - preferences:preferences - proximityMonitoringManager:proximityMonitoringManager - windowManager:windowManager]]; - - // TODO: Add this back - // TODO: Refactor this file to Swift - [SSKEnvironment setShared:[[SSKEnvironment alloc] initWithPrimaryStorage:primaryStorage - reachabilityManager:reachabilityManager]]; - - appSpecificSingletonBlock(); - - // TODO: Add this back? -// OWSAssertDebug(SSKEnvironment.shared.isComplete); - - [SNConfiguration performMainSetup]; // Must happen before the performUpdateCheck call below - - // Register renamed classes. - [NSKeyedUnarchiver setClass:[OWSDatabaseMigration class] forClassName:[OWSDatabaseMigration collection]]; - - [OWSStorage registerExtensionsWithMigrationBlock:^() { - dispatch_async(dispatch_get_main_queue(), ^{ - // Don't start database migrations until storage is ready. - [VersionMigrations performUpdateCheckWithCompletion:^(BOOL successful, BOOL needsConfigSync) { - OWSAssertIsOnMainThread(); - - migrationCompletion(successful, needsConfigSync); - - OWSAssertDebug(backgroundTask); - backgroundTask = nil; - }]; - }); - }]; - - // Must happen after the performUpdateCheck above to ensure all legacy database migrations have run - [SNConfiguration performDatabaseSetup]; - }); -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/AppSetup.swift b/SignalUtilitiesKit/Utilities/AppSetup.swift new file mode 100644 index 000000000..021f2a3c6 --- /dev/null +++ b/SignalUtilitiesKit/Utilities/AppSetup.swift @@ -0,0 +1,71 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionMessagingKit +import SessionUtilitiesKit +import UIKit + +public enum AppSetup { + private static var hasRun: Bool = false + + public static func setupEnvironment( + appSpecificBlock: @escaping () -> (), + migrationProgressChanged: ((CGFloat, TimeInterval) -> ())? = nil, + migrationsCompletion: @escaping (Bool, Bool) -> () + ) { + guard !AppSetup.hasRun else { return } + + AppSetup.hasRun = true + + var backgroundTask: OWSBackgroundTask? = OWSBackgroundTask(labelStr: #function) + + DispatchQueue.global(qos: .userInitiated).async { + // Order matters here. + // + // All of these "singletons" should have any dependencies used in their + // initializers injected. + OWSBackgroundTaskManager.shared().observeNotifications() + + let primaryStorage: OWSPrimaryStorage = OWSPrimaryStorage(storage: ()) + OWSPrimaryStorage.protectFiles() + + // AFNetworking (via CFNetworking) spools it's attachments to NSTemporaryDirectory(). + // If you receive a media message while the device is locked, the download will fail if the temporary directory + // is NSFileProtectionComplete + let success: Bool = OWSFileSystem.protectFileOrFolder( + atPath: NSTemporaryDirectory(), + fileProtectionType: .completeUntilFirstUserAuthentication + ) + assert(success) + + Environment.shared = Environment( + primaryStorage: primaryStorage, + reachabilityManager: SSKReachabilityManagerImpl(), + audioSession: OWSAudioSession(), + preferences: OWSPreferences(), + proximityMonitoringManager: OWSProximityMonitoringManagerImpl(), + windowManager: OWSWindowManager(default: ()) + ) + appSpecificBlock() + + /// `performMainSetup` **MUST** run before `perform(migrations:)` + Configuration.performMainSetup() + GRDBStorage.shared.perform( + migrations: [ + SNUtilitiesKit.migrations(), + SNSnodeKit.migrations(), + SNMessagingKit.migrations() + ], + onProgressUpdate: migrationProgressChanged, + onComplete: { success, needsConfigSync in + DispatchQueue.main.async { + migrationsCompletion(success, needsConfigSync) + + // The 'if' is only there to prevent the "variable never read" warning from showing + if backgroundTask != nil { backgroundTask = nil } + } + } + ) + } + } +} diff --git a/SignalUtilitiesKit/Utilities/VersionMigrations.h b/SignalUtilitiesKit/Utilities/VersionMigrations.h deleted file mode 100644 index 932366703..000000000 --- a/SignalUtilitiesKit/Utilities/VersionMigrations.h +++ /dev/null @@ -1,25 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -NS_ASSUME_NONNULL_BEGIN - -#define RECENT_CALLS_DEFAULT_KEY @"RPRecentCallsDefaultKey" - -typedef void (^VersionMigrationCompletion)(BOOL success, BOOL requiresConfigurationSync); - -@interface VersionMigrations : NSObject - -+ (void)performUpdateCheckWithCompletion:(VersionMigrationCompletion)completion; - -+ (BOOL)isVersion:(NSString *)thisVersionString - atLeast:(NSString *)openLowerBoundVersionString - andLessThan:(NSString *)closedUpperBoundVersionString; - -+ (BOOL)isVersion:(NSString *)thisVersionString atLeast:(NSString *)thatVersionString; - -+ (BOOL)isVersion:(NSString *)thisVersionString lessThan:(NSString *)thatVersionString; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/VersionMigrations.m b/SignalUtilitiesKit/Utilities/VersionMigrations.m deleted file mode 100644 index f7d7c0a26..000000000 --- a/SignalUtilitiesKit/Utilities/VersionMigrations.m +++ /dev/null @@ -1,126 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "VersionMigrations.h" -#import "OWSDatabaseMigrationRunner.h" -#import -#import -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -#define NEEDS_TO_REGISTER_PUSH_KEY @"Register For Push" -#define NEEDS_TO_REGISTER_ATTRIBUTES @"Register Attributes" - -@implementation VersionMigrations - -#pragma mark - Utility methods - -+ (void)performUpdateCheckWithCompletion:(VersionMigrationCompletion)completion -{ - OWSLogInfo(@""); - - // performUpdateCheck must be invoked after Environment has been initialized because - // upgrade process may depend on Environment. - OWSAssertDebug(Environment.shared); - OWSAssertDebug(completion); - - NSString *previousVersion = AppVersion.sharedInstance.lastAppVersion; - NSString *currentVersion = AppVersion.sharedInstance.currentAppVersion; - - OWSLogInfo(@"Checking migrations. currentVersion: %@, lastRanVersion: %@", currentVersion, previousVersion); - - if (!previousVersion) { - // Note: We need to run the migrations here anyway to ensure that they don't run on subsequent launches - // and result in unexpected data changes (eg. 'MessageRequestsMigration' auto-approves all threads - // if this happens on the 2nd launch then any threads created during the 1st launch which haven't - // been approved would get auto-approved, allowing the user to use contacts which haven't approved - // comms to appear as options when creating closed groups) - OWSLogInfo(@"No previous version found. Probably first launch since install - running migrations so they don't run on second launch."); - - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - [[[OWSDatabaseMigrationRunner alloc] init] runAllOutstandingWithCompletion:completion]; - }); - return; - } - - if ([self isVersion:previousVersion atLeast:@"2.0.0" andLessThan:@"2.1.70"] && [SUKIdentity userExists]) { - [self clearVideoCache]; - } - - if ([self isVersion:previousVersion atLeast:@"2.0.0" andLessThan:@"2.3.0"] && [SUKIdentity userExists]) { - [self clearBloomFilterCache]; - } - - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - [[[OWSDatabaseMigrationRunner alloc] init] runAllOutstandingWithCompletion:completion]; - }); -} - -+ (BOOL)isVersion:(NSString *)thisVersionString - atLeast:(NSString *)openLowerBoundVersionString - andLessThan:(NSString *)closedUpperBoundVersionString -{ - return [self isVersion:thisVersionString atLeast:openLowerBoundVersionString] && - [self isVersion:thisVersionString lessThan:closedUpperBoundVersionString]; -} - -+ (BOOL)isVersion:(NSString *)thisVersionString atLeast:(NSString *)thatVersionString -{ - return [thisVersionString compare:thatVersionString options:NSNumericSearch] != NSOrderedAscending; -} - -+ (BOOL)isVersion:(NSString *)thisVersionString lessThan:(NSString *)thatVersionString -{ - return [thisVersionString compare:thatVersionString options:NSNumericSearch] == NSOrderedAscending; -} - -#pragma mark Upgrading to 2.1 - Removing video cache folder - -+ (void)clearVideoCache -{ - NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES); - NSString *basePath = ([paths count] > 0) ? [paths objectAtIndex:0] : nil; - basePath = [basePath stringByAppendingPathComponent:@"videos"]; - - NSError *error; - if ([[NSFileManager defaultManager] fileExistsAtPath:basePath]) { - [NSFileManager.defaultManager removeItemAtPath:basePath error:&error]; - } - - if (error) { - OWSLogError( - @"An error occured while removing the videos cache folder from old location: %@", error.debugDescription); - } -} - -#pragma mark Upgrading to 2.3.0 - -// We removed bloom filter contact discovery. Clean up any local bloom filter data. -+ (void)clearBloomFilterCache -{ - NSFileManager *fm = [NSFileManager defaultManager]; - NSArray *cachesDir = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES); - NSString *bloomFilterPath = [[cachesDir objectAtIndex:0] stringByAppendingPathComponent:@"bloomfilter"]; - - if ([fm fileExistsAtPath:bloomFilterPath]) { - NSError *deleteError; - if ([fm removeItemAtPath:bloomFilterPath error:&deleteError]) { - OWSLogInfo(@"Successfully removed bloom filter cache."); - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) { - [transaction removeAllObjectsInCollection:@"TSRecipient"]; - }]; - } else { - OWSLogError(@"Failed to remove bloom filter cache with error: %@", deleteError.localizedDescription); - } - } else { - OWSLogDebug(@"No bloom filter cache to remove."); - } -} - -@end - -NS_ASSUME_NONNULL_END From 8ff542405c7032c2e40f334316b2ae47476f5be2 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 31 May 2022 17:41:02 +1000 Subject: [PATCH 093/157] Finished of the Conversation screen and JobQueue concurrency Updated the migrations to indicate progress (Potential to base progress for the "processing" sections on the file size of the legacy database) Updated the JobRunner to properly support concurrent queues for sending/receiving (other queues are still serial) Added the typing indicator logic into the ConversationVC Put code into SUKLegacy for connecting to the YDB database Fixed a couple of minor UI bugs with the GalleryRailView Updated the media gallery selection screen to use the appropriate system theme colouring (was painful to randomly swap from dark mode to like for one screen...) Added an alert for when the database migration fails Deleted the legacy migrations (manually applying any unapplied changes as part of the YDB to GRDB migration process) --- Session.xcodeproj/project.pbxproj | 80 --- Session/Conversations/ConversationVC.swift | 1 + .../Conversations/ConversationViewModel.swift | 30 +- .../OWSConversationSettingsViewController.m | 1 - Session/Home/HomeVC.swift | 27 +- Session/Home/HomeViewModel.swift | 95 ++- .../MessageRequestsViewModel.swift | 5 + .../ImagePickerController.swift | 31 +- .../MediaGalleryViewModel.swift | 5 + .../SendMediaNavigationController.swift | 4 - Session/Meta/AppDelegate.swift | 27 +- Session/Meta/MainAppContext.m | 1 - Session/Meta/Session-Prefix.pch | 3 - Session/Meta/Signal-Bridging-Header.h | 3 - .../Translations/de.lproj/Localizable.strings | 2 + .../Translations/en.lproj/Localizable.strings | 2 + .../Translations/es.lproj/Localizable.strings | 2 + .../Translations/fa.lproj/Localizable.strings | 2 + .../Translations/fi.lproj/Localizable.strings | 2 + .../Translations/fr.lproj/Localizable.strings | 2 + .../Translations/hi.lproj/Localizable.strings | 2 + .../Translations/hr.lproj/Localizable.strings | 2 + .../id-ID.lproj/Localizable.strings | 2 + .../Translations/it.lproj/Localizable.strings | 2 + .../Translations/ja.lproj/Localizable.strings | 2 + .../Translations/nl.lproj/Localizable.strings | 2 + .../Translations/pl.lproj/Localizable.strings | 2 + .../pt_BR.lproj/Localizable.strings | 2 + .../Translations/ru.lproj/Localizable.strings | 2 + .../Translations/si.lproj/Localizable.strings | 2 + .../Translations/sk.lproj/Localizable.strings | 2 + .../Translations/sv.lproj/Localizable.strings | 2 + .../Translations/th.lproj/Localizable.strings | 2 + .../vi-VN.lproj/Localizable.strings | 2 + .../zh-Hant.lproj/Localizable.strings | 2 + .../zh_CN.lproj/Localizable.strings | 2 + .../PrivacySettingsTableViewController.m | 1 - Session/Settings/ShareLogsModal.swift | 11 +- .../_001_InitialSetupMigration.swift | 3 +- .../Migrations/_002_SetupStandardJobs.swift | 3 +- .../Migrations/_003_YDBToGRDBMigration.swift | 670 ++++++++++-------- .../Database/Models/RecipientState.swift | 2 - .../Database/Models/SessionThread.swift | 38 +- .../Models/ThreadTypingIndicator.swift | 2 +- .../Meta/SessionMessagingKit.h | 1 - .../Sending & Receiving/Pollers/Poller.swift | 12 +- .../Shared Models/MessageViewModel.swift | 77 +- .../SessionThreadViewModel.swift | 28 +- .../Utilities/OWSWindowManager.m | 1 - .../SignalShareExtension-Bridging-Header.h | 2 - .../ThreadPickerViewModel.swift | 5 + .../_001_InitialSetupMigration.swift | 3 +- .../Migrations/_002_SetupStandardJobs.swift | 3 +- .../Migrations/_003_YDBToGRDBMigration.swift | 115 ++- .../Database/GRDBStorage.swift | 1 + .../Database/LegacyDatabase/SUKLegacy.swift | 106 +++ .../_001_InitialSetupMigration.swift | 3 +- .../Migrations/_002_SetupStandardJobs.swift | 3 +- .../Migrations/_003_YDBToGRDBMigration.swift | 21 +- .../Database/Types/Migration.swift | 11 + .../Types/PagedDatabaseObserver.swift | 2 +- .../Database/Types/TargetMigrations.swift | 6 +- .../DatabaseMigrator+Utilities.swift | 7 +- .../General/NSArray+Functional.h | 9 - .../General/NSArray+Functional.m | 32 - SessionUtilitiesKit/JobRunner/JobRunner.swift | 213 ++++-- .../Meta/SessionUtilitiesKit.h | 1 - .../BlockingManagerRemovalMigration.swift | 62 -- .../Migrations/ContactsMigration.swift | 57 -- .../Migrations/MessageRequestsMigration.swift | 93 --- .../Migrations/OWSDatabaseMigration.h | 25 - .../Migrations/OWSDatabaseMigration.m | 109 --- .../Migrations/OWSDatabaseMigrationRunner.h | 23 - .../Migrations/OWSDatabaseMigrationRunner.m | 124 ---- .../OWSResaveCollectionDBMigration.h | 24 - .../OWSResaveCollectionDBMigration.m | 80 --- .../OpenGroupServerIdLookupMigration.swift | 71 -- .../AttachmentApprovalViewController.swift | 8 +- .../AttachmentItemCollection.swift | 18 +- SignalUtilitiesKit/Meta/SignalUtilitiesKit.h | 8 - .../Shared Views/GalleryRailView.swift | 107 ++- SignalUtilitiesKit/Utilities/NSArray+OWS.h | 15 - SignalUtilitiesKit/Utilities/NSArray+OWS.m | 25 - .../Utilities/NSObject+Casting.h | 7 - .../Utilities/NSObject+Casting.m | 10 - .../Utilities/NSSet+Functional.h | 9 - .../Utilities/NSSet+Functional.m | 32 - .../Utilities/OWSAnyTouchGestureRecognizer.h | 22 - .../Utilities/OWSAnyTouchGestureRecognizer.m | 132 ---- .../Utilities/UIGestureRecognizer+OWS.swift | 17 +- 90 files changed, 1192 insertions(+), 1635 deletions(-) delete mode 100644 SessionUtilitiesKit/General/NSArray+Functional.h delete mode 100644 SessionUtilitiesKit/General/NSArray+Functional.m delete mode 100644 SignalUtilitiesKit/Database/Migrations/BlockingManagerRemovalMigration.swift delete mode 100644 SignalUtilitiesKit/Database/Migrations/ContactsMigration.swift delete mode 100644 SignalUtilitiesKit/Database/Migrations/MessageRequestsMigration.swift delete mode 100644 SignalUtilitiesKit/Database/Migrations/OWSDatabaseMigration.h delete mode 100644 SignalUtilitiesKit/Database/Migrations/OWSDatabaseMigration.m delete mode 100644 SignalUtilitiesKit/Database/Migrations/OWSDatabaseMigrationRunner.h delete mode 100644 SignalUtilitiesKit/Database/Migrations/OWSDatabaseMigrationRunner.m delete mode 100644 SignalUtilitiesKit/Database/Migrations/OWSResaveCollectionDBMigration.h delete mode 100644 SignalUtilitiesKit/Database/Migrations/OWSResaveCollectionDBMigration.m delete mode 100644 SignalUtilitiesKit/Database/Migrations/OpenGroupServerIdLookupMigration.swift delete mode 100644 SignalUtilitiesKit/Utilities/NSArray+OWS.h delete mode 100644 SignalUtilitiesKit/Utilities/NSArray+OWS.m delete mode 100644 SignalUtilitiesKit/Utilities/NSObject+Casting.h delete mode 100644 SignalUtilitiesKit/Utilities/NSObject+Casting.m delete mode 100644 SignalUtilitiesKit/Utilities/NSSet+Functional.h delete mode 100644 SignalUtilitiesKit/Utilities/NSSet+Functional.m delete mode 100644 SignalUtilitiesKit/Utilities/OWSAnyTouchGestureRecognizer.h delete mode 100644 SignalUtilitiesKit/Utilities/OWSAnyTouchGestureRecognizer.m diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 853ff8d42..ef125b14f 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -207,7 +207,6 @@ B897621C25D201F7004F83B2 /* ScrollToBottomButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B897621B25D201F7004F83B2 /* ScrollToBottomButton.swift */; }; B8AE75A425A6C6A6001A84D2 /* Data+Trimming.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8AE75A325A6C6A6001A84D2 /* Data+Trimming.swift */; }; B8AF4BB426A5204600583500 /* SendSeedModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8AF4BB326A5204600583500 /* SendSeedModal.swift */; }; - B8B3204E258C15C80020074B /* ContactsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B32044258C117C0020074B /* ContactsMigration.swift */; }; B8B320B7258C30D70020074B /* HTMLMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B320B6258C30D70020074B /* HTMLMetadata.swift */; }; B8BB82A5238F627000BA5194 /* HomeVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8BB82A4238F627000BA5194 /* HomeVC.swift */; }; B8BC00C0257D90E30032E807 /* General.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8BC00BF257D90E30032E807 /* General.swift */; }; @@ -297,8 +296,6 @@ C32C5EE5256DF506003C73A2 /* YapDatabaseConnection+OWS.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB5F255A580E00E217F9 /* YapDatabaseConnection+OWS.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5EEE256DF54E003C73A2 /* TSDatabaseView.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB46255A580C00E217F9 /* TSDatabaseView.m */; }; C32C5EF7256DF567003C73A2 /* TSDatabaseView.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB2C255A580A00E217F9 /* TSDatabaseView.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C32C5FA1256DFED5003C73A2 /* NSArray+Functional.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAB8255A580100E217F9 /* NSArray+Functional.m */; }; - C32C5FAA256DFED9003C73A2 /* NSArray+Functional.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB5C255A580E00E217F9 /* NSArray+Functional.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5FBB256E0206003C73A2 /* OWSBackgroundTask.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDC1B255A581F00E217F9 /* OWSBackgroundTask.m */; }; C32C5FC4256E0209003C73A2 /* OWSBackgroundTask.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB38255A580B00E217F9 /* OWSBackgroundTask.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C600F256E07F5003C73A2 /* NSUserDefaults+OWS.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB77255A581000E217F9 /* NSUserDefaults+OWS.m */; }; @@ -340,14 +337,10 @@ C33FDC50255A582000E217F9 /* OWSDispatch.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDA96255A57FE00E217F9 /* OWSDispatch.h */; settings = {ATTRIBUTES = (Public, ); }; }; C33FDC53255A582000E217F9 /* OutageDetection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA99255A57FE00E217F9 /* OutageDetection.swift */; }; C33FDC58255A582000E217F9 /* ReverseDispatchQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA9E255A57FF00E217F9 /* ReverseDispatchQueue.swift */; }; - C33FDC64255A582000E217F9 /* NSObject+Casting.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAAA255A580000E217F9 /* NSObject+Casting.m */; }; C33FDC78255A582000E217F9 /* TSConstants.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDABE255A580100E217F9 /* TSConstants.m */; }; - C33FDC7B255A582000E217F9 /* NSSet+Functional.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAC1255A580100E217F9 /* NSSet+Functional.m */; }; C33FDC7D255A582000E217F9 /* OWSDispatch.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAC3255A580200E217F9 /* OWSDispatch.m */; }; - C33FDC96255A582000E217F9 /* NSObject+Casting.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDADC255A580400E217F9 /* NSObject+Casting.h */; settings = {ATTRIBUTES = (Public, ); }; }; C33FDC98255A582000E217F9 /* SwiftSingletons.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDADE255A580400E217F9 /* SwiftSingletons.swift */; }; C33FDC9A255A582000E217F9 /* ByteParser.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAE0255A580400E217F9 /* ByteParser.m */; }; - C33FDCC7255A582000E217F9 /* NSArray+OWS.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB0D255A580800E217F9 /* NSArray+OWS.m */; }; C33FDCD1255A582000E217F9 /* FunctionalUtil.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB17255A580800E217F9 /* FunctionalUtil.m */; }; C33FDCFA255A582000E217F9 /* SignalIOSProto.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB40255A580C00E217F9 /* SignalIOSProto.swift */; }; C33FDD03255A582000E217F9 /* WeakTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB49255A580C00E217F9 /* WeakTimer.swift */; }; @@ -362,9 +355,7 @@ C33FDD8D255A582000E217F9 /* OWSSignalAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBD3255A581800E217F9 /* OWSSignalAddress.swift */; }; C33FDD92255A582000E217F9 /* SignalIOS.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBD8255A581900E217F9 /* SignalIOS.pb.swift */; }; C33FDDB0255A582000E217F9 /* NSURLSessionDataTask+StatusCode.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBF6255A581C00E217F9 /* NSURLSessionDataTask+StatusCode.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C33FDDB2255A582000E217F9 /* NSArray+OWS.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBF8255A581C00E217F9 /* NSArray+OWS.h */; settings = {ATTRIBUTES = (Public, ); }; }; C33FDDB3255A582000E217F9 /* OWSError.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBF9255A581C00E217F9 /* OWSError.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C33FDDB8255A582000E217F9 /* NSSet+Functional.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBFE255A581C00E217F9 /* NSSet+Functional.h */; settings = {ATTRIBUTES = (Public, ); }; }; C33FDDBD255A582000E217F9 /* ByteParser.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDC03255A581D00E217F9 /* ByteParser.h */; settings = {ATTRIBUTES = (Public, ); }; }; C33FDDC5255A582000E217F9 /* OWSError.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDC0B255A581D00E217F9 /* OWSError.m */; }; C33FDDCC255A582000E217F9 /* TSConstants.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDC12255A581E00E217F9 /* TSConstants.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -396,7 +387,6 @@ C374EEEB25DA3CA70073A857 /* ConversationTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C374EEEA25DA3CA70073A857 /* ConversationTitleView.swift */; }; C374EEF425DB31D40073A857 /* VoiceMessageRecordingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C374EEF325DB31D40073A857 /* VoiceMessageRecordingView.swift */; }; C379DCF4256735770002D4EB /* VisibleMessage+Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = C379DCF3256735770002D4EB /* VisibleMessage+Attachment.swift */; }; - C37F5396255B95BD002AEA92 /* OWSAnyTouchGestureRecognizer.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF302255B6DBE007E1867 /* OWSAnyTouchGestureRecognizer.h */; settings = {ATTRIBUTES = (Public, ); }; }; C37F5414255BAFA7002AEA92 /* SignalUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C33FD9AB255A548A00E217F9 /* SignalUtilitiesKit.framework */; }; C37F54DC255BB84A002AEA92 /* SessionSnodeKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A59F255385C100C340D1 /* SessionSnodeKit.framework */; }; C38D5E8D2575011E00B6A65C /* MessageSender+ClosedGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38D5E8C2575011E00B6A65C /* MessageSender+ClosedGroups.swift */; }; @@ -413,19 +403,12 @@ C38EF24D255B6D67007E1867 /* UIView+OWS.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF240255B6D67007E1867 /* UIView+OWS.swift */; }; C38EF24E255B6D67007E1867 /* Collection+OWS.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF241255B6D67007E1867 /* Collection+OWS.swift */; }; C38EF24F255B6D67007E1867 /* UIColor+OWS.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF242255B6D67007E1867 /* UIColor+OWS.m */; }; - C38EF272255B6D7A007E1867 /* OWSResaveCollectionDBMigration.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF26C255B6D79007E1867 /* OWSResaveCollectionDBMigration.m */; }; - C38EF273255B6D7A007E1867 /* OWSDatabaseMigrationRunner.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF26D255B6D79007E1867 /* OWSDatabaseMigrationRunner.m */; }; - C38EF274255B6D7A007E1867 /* OWSResaveCollectionDBMigration.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF26E255B6D79007E1867 /* OWSResaveCollectionDBMigration.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C38EF275255B6D7A007E1867 /* OWSDatabaseMigrationRunner.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF26F255B6D79007E1867 /* OWSDatabaseMigrationRunner.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C38EF276255B6D7A007E1867 /* OWSDatabaseMigration.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF270255B6D79007E1867 /* OWSDatabaseMigration.m */; }; - C38EF277255B6D7A007E1867 /* OWSDatabaseMigration.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF271255B6D79007E1867 /* OWSDatabaseMigration.h */; settings = {ATTRIBUTES = (Public, ); }; }; C38EF2A5255B6D93007E1867 /* Identicon+ObjC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2A2255B6D93007E1867 /* Identicon+ObjC.swift */; }; C38EF2A6255B6D93007E1867 /* PlaceholderIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2A3255B6D93007E1867 /* PlaceholderIcon.swift */; }; C38EF2A7255B6D93007E1867 /* ProfilePictureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2A4255B6D93007E1867 /* ProfilePictureView.swift */; }; C38EF2B3255B6D9C007E1867 /* UIViewController+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2B1255B6D9C007E1867 /* UIViewController+Utilities.swift */; }; C38EF2B4255B6D9C007E1867 /* UIView+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2B2255B6D9C007E1867 /* UIView+Utilities.swift */; }; C38EF30C255B6DBF007E1867 /* OWSScreenLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2E2255B6DB9007E1867 /* OWSScreenLock.swift */; }; - C38EF31A255B6DBF007E1867 /* OWSAnyTouchGestureRecognizer.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2F0255B6DBB007E1867 /* OWSAnyTouchGestureRecognizer.m */; }; C38EF31C255B6DBF007E1867 /* Searcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2F2255B6DBC007E1867 /* Searcher.swift */; }; C38EF31D255B6DBF007E1867 /* UIImage+OWS.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2F3255B6DBC007E1867 /* UIImage+OWS.swift */; }; C38EF324255B6DBF007E1867 /* Bench.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2FA255B6DBD007E1867 /* Bench.swift */; }; @@ -650,10 +633,8 @@ FD17D7E727F6A16700122BE0 /* _003_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E627F6A16700122BE0 /* _003_YDBToGRDBMigration.swift */; }; FD17D7EA27F6A1C600122BE0 /* SUKLegacy.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E927F6A1C600122BE0 /* SUKLegacy.swift */; }; FD1C98E4282E3C5B00B76F9E /* UINavigationBar+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */; }; - FD28A4F227E990E800FF65E7 /* BlockingManagerRemovalMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD28A4F127E990E800FF65E7 /* BlockingManagerRemovalMigration.swift */; }; FD28A4F427EA79F800FF65E7 /* BlockListUIUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD28A4F327EA79F800FF65E7 /* BlockListUIUtils.swift */; }; FD3AABE928306BBD00E5099A /* ThreadPickerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */; }; - FD3C907327E8387300CD579F /* OpenGroupServerIdLookupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907227E8387300CD579F /* OpenGroupServerIdLookupMigration.swift */; }; FD3C907527E83AC200CD579F /* OpenGroupServerIdLookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */; }; FD3E0C84283B5835002A425C /* SessionThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */; }; FD4B200E283492210034334B /* InsetLockableTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4B200D283492210034334B /* InsetLockableTableView.swift */; }; @@ -680,7 +661,6 @@ FD848B9C284435D7000E298B /* AppSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B9B284435D7000E298B /* AppSetup.swift */; }; FD859F0027C4691300510D0C /* MockDataGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EFF27C4691300510D0C /* MockDataGenerator.swift */; }; FD88BAD927A7439C00BBC442 /* MessageRequestsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */; }; - FD88BADB27A750F200BBC442 /* MessageRequestsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */; }; FD90040F2818AB6D00ABAAF6 /* GetSnodePoolJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD90040E2818AB6D00ABAAF6 /* GetSnodePoolJob.swift */; }; FD9004122818ABDC00ABAAF6 /* Job.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B73F280402C4004C14C5 /* Job.swift */; }; FD9004142818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9004132818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift */; }; @@ -1139,7 +1119,6 @@ B897621B25D201F7004F83B2 /* ScrollToBottomButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollToBottomButton.swift; sourceTree = ""; }; B8AE75A325A6C6A6001A84D2 /* Data+Trimming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Trimming.swift"; sourceTree = ""; }; B8AF4BB326A5204600583500 /* SendSeedModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendSeedModal.swift; sourceTree = ""; }; - B8B32044258C117C0020074B /* ContactsMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsMigration.swift; sourceTree = ""; }; B8B320B6258C30D70020074B /* HTMLMetadata.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTMLMetadata.swift; sourceTree = ""; }; B8B5BCEB2394D869003823C9 /* Button.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Button.swift; sourceTree = ""; }; B8BAC75B2695645400EA1759 /* hr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hr; path = hr.lproj/Localizable.strings; sourceTree = ""; }; @@ -1226,14 +1205,10 @@ C33FDA9E255A57FF00E217F9 /* ReverseDispatchQueue.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReverseDispatchQueue.swift; sourceTree = ""; }; C33FDAA1255A57FF00E217F9 /* TSYapDatabaseObject.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSYapDatabaseObject.h; sourceTree = ""; }; C33FDAA8255A57FF00E217F9 /* BuildConfiguration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BuildConfiguration.swift; sourceTree = ""; }; - C33FDAAA255A580000E217F9 /* NSObject+Casting.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSObject+Casting.m"; sourceTree = ""; }; C33FDAB1255A580000E217F9 /* OWSStorage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSStorage.m; sourceTree = ""; }; - C33FDAB8255A580100E217F9 /* NSArray+Functional.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSArray+Functional.m"; sourceTree = ""; }; C33FDAB9255A580100E217F9 /* OWSStorage+Subclass.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "OWSStorage+Subclass.h"; sourceTree = ""; }; C33FDABE255A580100E217F9 /* TSConstants.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSConstants.m; sourceTree = ""; }; - C33FDAC1255A580100E217F9 /* NSSet+Functional.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSSet+Functional.m"; sourceTree = ""; }; C33FDAC3255A580200E217F9 /* OWSDispatch.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSDispatch.m; sourceTree = ""; }; - C33FDADC255A580400E217F9 /* NSObject+Casting.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSObject+Casting.h"; sourceTree = ""; }; C33FDADE255A580400E217F9 /* SwiftSingletons.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftSingletons.swift; sourceTree = ""; }; C33FDAE0255A580400E217F9 /* ByteParser.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ByteParser.m; sourceTree = ""; }; C33FDAEA255A580500E217F9 /* OWSBackupFragment.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSBackupFragment.h; sourceTree = ""; }; @@ -1245,7 +1220,6 @@ C33FDAFE255A580600E217F9 /* OWSStorage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSStorage.h; sourceTree = ""; }; C33FDB01255A580700E217F9 /* AppReadiness.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppReadiness.h; sourceTree = ""; }; C33FDB07255A580700E217F9 /* OWSBackupFragment.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSBackupFragment.m; sourceTree = ""; }; - C33FDB0D255A580800E217F9 /* NSArray+OWS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSArray+OWS.m"; sourceTree = ""; }; C33FDB12255A580800E217F9 /* NSString+SSK.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSString+SSK.h"; sourceTree = ""; }; C33FDB14255A580800E217F9 /* OWSMath.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSMath.h; sourceTree = ""; }; C33FDB17255A580800E217F9 /* FunctionalUtil.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FunctionalUtil.m; sourceTree = ""; }; @@ -1271,7 +1245,6 @@ C33FDB51255A580D00E217F9 /* NSUserDefaults+OWS.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSUserDefaults+OWS.h"; sourceTree = ""; }; C33FDB54255A580D00E217F9 /* DataSource.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DataSource.h; sourceTree = ""; }; C33FDB5B255A580E00E217F9 /* YapDatabaseTransaction+OWS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "YapDatabaseTransaction+OWS.m"; sourceTree = ""; }; - C33FDB5C255A580E00E217F9 /* NSArray+Functional.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSArray+Functional.h"; sourceTree = ""; }; C33FDB5F255A580E00E217F9 /* YapDatabaseConnection+OWS.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "YapDatabaseConnection+OWS.h"; sourceTree = ""; }; C33FDB68255A580F00E217F9 /* ContentProxy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentProxy.swift; sourceTree = ""; }; C33FDB69255A580F00E217F9 /* FeatureFlags.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeatureFlags.swift; sourceTree = ""; }; @@ -1298,9 +1271,7 @@ C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PushNotificationAPI.swift; sourceTree = ""; }; C33FDBE1255A581A00E217F9 /* LKGroupUtilities.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = LKGroupUtilities.m; sourceTree = ""; }; C33FDBF6255A581C00E217F9 /* NSURLSessionDataTask+StatusCode.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSURLSessionDataTask+StatusCode.h"; sourceTree = ""; }; - C33FDBF8255A581C00E217F9 /* NSArray+OWS.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSArray+OWS.h"; sourceTree = ""; }; C33FDBF9255A581C00E217F9 /* OWSError.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSError.h; sourceTree = ""; }; - C33FDBFE255A581C00E217F9 /* NSSet+Functional.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSSet+Functional.h"; sourceTree = ""; }; C33FDC02255A581D00E217F9 /* OWSPrimaryStorage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSPrimaryStorage.m; sourceTree = ""; }; C33FDC03255A581D00E217F9 /* ByteParser.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ByteParser.h; sourceTree = ""; }; C33FDC0B255A581D00E217F9 /* OWSError.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSError.m; sourceTree = ""; }; @@ -1351,12 +1322,6 @@ C38EF240255B6D67007E1867 /* UIView+OWS.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "UIView+OWS.swift"; path = "SignalUtilitiesKit/Utilities/UIView+OWS.swift"; sourceTree = SOURCE_ROOT; }; C38EF241255B6D67007E1867 /* Collection+OWS.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "Collection+OWS.swift"; path = "SignalUtilitiesKit/Utilities/Collection+OWS.swift"; sourceTree = SOURCE_ROOT; }; C38EF242255B6D67007E1867 /* UIColor+OWS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = "UIColor+OWS.m"; path = "SignalUtilitiesKit/Utilities/UIColor+OWS.m"; sourceTree = SOURCE_ROOT; }; - C38EF26C255B6D79007E1867 /* OWSResaveCollectionDBMigration.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = OWSResaveCollectionDBMigration.m; path = SignalUtilitiesKit/Database/Migrations/OWSResaveCollectionDBMigration.m; sourceTree = SOURCE_ROOT; }; - C38EF26D255B6D79007E1867 /* OWSDatabaseMigrationRunner.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = OWSDatabaseMigrationRunner.m; path = SignalUtilitiesKit/Database/Migrations/OWSDatabaseMigrationRunner.m; sourceTree = SOURCE_ROOT; }; - C38EF26E255B6D79007E1867 /* OWSResaveCollectionDBMigration.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSResaveCollectionDBMigration.h; path = SignalUtilitiesKit/Database/Migrations/OWSResaveCollectionDBMigration.h; sourceTree = SOURCE_ROOT; }; - C38EF26F255B6D79007E1867 /* OWSDatabaseMigrationRunner.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSDatabaseMigrationRunner.h; path = SignalUtilitiesKit/Database/Migrations/OWSDatabaseMigrationRunner.h; sourceTree = SOURCE_ROOT; }; - C38EF270255B6D79007E1867 /* OWSDatabaseMigration.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = OWSDatabaseMigration.m; path = SignalUtilitiesKit/Database/Migrations/OWSDatabaseMigration.m; sourceTree = SOURCE_ROOT; }; - C38EF271255B6D79007E1867 /* OWSDatabaseMigration.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSDatabaseMigration.h; path = SignalUtilitiesKit/Database/Migrations/OWSDatabaseMigration.h; sourceTree = SOURCE_ROOT; }; C38EF281255B6D84007E1867 /* OWSAudioSession.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = OWSAudioSession.swift; path = SessionMessagingKit/Utilities/OWSAudioSession.swift; sourceTree = SOURCE_ROOT; }; C38EF2A2255B6D93007E1867 /* Identicon+ObjC.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "Identicon+ObjC.swift"; path = "SignalUtilitiesKit/Profile Pictures/Identicon+ObjC.swift"; sourceTree = SOURCE_ROOT; }; C38EF2A3255B6D93007E1867 /* PlaceholderIcon.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = PlaceholderIcon.swift; path = "SignalUtilitiesKit/Profile Pictures/PlaceholderIcon.swift"; sourceTree = SOURCE_ROOT; }; @@ -1366,7 +1331,6 @@ C38EF2E2255B6DB9007E1867 /* OWSScreenLock.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = OWSScreenLock.swift; path = "SignalUtilitiesKit/Screen Lock/OWSScreenLock.swift"; sourceTree = SOURCE_ROOT; }; C38EF2EC255B6DBA007E1867 /* ProximityMonitoringManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ProximityMonitoringManager.swift; path = SessionMessagingKit/Utilities/ProximityMonitoringManager.swift; sourceTree = SOURCE_ROOT; }; C38EF2EF255B6DBB007E1867 /* Weak.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Weak.swift; path = SessionUtilitiesKit/General/Weak.swift; sourceTree = SOURCE_ROOT; }; - C38EF2F0255B6DBB007E1867 /* OWSAnyTouchGestureRecognizer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = OWSAnyTouchGestureRecognizer.m; path = SignalUtilitiesKit/Utilities/OWSAnyTouchGestureRecognizer.m; sourceTree = SOURCE_ROOT; }; C38EF2F1255B6DBB007E1867 /* OWSPreferences.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSPreferences.h; path = SessionMessagingKit/Utilities/OWSPreferences.h; sourceTree = SOURCE_ROOT; }; C38EF2F2255B6DBC007E1867 /* Searcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Searcher.swift; path = SignalUtilitiesKit/Utilities/Searcher.swift; sourceTree = SOURCE_ROOT; }; C38EF2F3255B6DBC007E1867 /* UIImage+OWS.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "UIImage+OWS.swift"; path = "SignalUtilitiesKit/Utilities/UIImage+OWS.swift"; sourceTree = SOURCE_ROOT; }; @@ -1376,7 +1340,6 @@ C38EF2FB255B6DBD007E1867 /* OWSWindowManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSWindowManager.h; path = SessionMessagingKit/Utilities/OWSWindowManager.h; sourceTree = SOURCE_ROOT; }; C38EF300255B6DBD007E1867 /* UIUtil.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = UIUtil.m; path = SignalUtilitiesKit/Utilities/UIUtil.m; sourceTree = SOURCE_ROOT; }; C38EF301255B6DBD007E1867 /* OWSFormat.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSFormat.h; path = SignalUtilitiesKit/Utilities/OWSFormat.h; sourceTree = SOURCE_ROOT; }; - C38EF302255B6DBE007E1867 /* OWSAnyTouchGestureRecognizer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSAnyTouchGestureRecognizer.h; path = SignalUtilitiesKit/Utilities/OWSAnyTouchGestureRecognizer.h; sourceTree = SOURCE_ROOT; }; C38EF304255B6DBE007E1867 /* ImageCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ImageCache.swift; path = SignalUtilitiesKit/Utilities/ImageCache.swift; sourceTree = SOURCE_ROOT; }; C38EF305255B6DBE007E1867 /* OWSFormat.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = OWSFormat.m; path = SignalUtilitiesKit/Utilities/OWSFormat.m; sourceTree = SOURCE_ROOT; }; C38EF306255B6DBE007E1867 /* OWSWindowManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = OWSWindowManager.m; path = SessionMessagingKit/Utilities/OWSWindowManager.m; sourceTree = SOURCE_ROOT; }; @@ -1611,11 +1574,9 @@ FD17D7E627F6A16700122BE0 /* _003_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _003_YDBToGRDBMigration.swift; sourceTree = ""; }; FD17D7E927F6A1C600122BE0 /* SUKLegacy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SUKLegacy.swift; sourceTree = ""; }; FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationBar+Utilities.swift"; sourceTree = ""; }; - FD28A4F127E990E800FF65E7 /* BlockingManagerRemovalMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockingManagerRemovalMigration.swift; sourceTree = ""; }; FD28A4F327EA79F800FF65E7 /* BlockListUIUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockListUIUtils.swift; sourceTree = ""; }; FD28A4F527EAD44C00FF65E7 /* GRDBStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GRDBStorage.swift; sourceTree = ""; }; FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadPickerViewModel.swift; sourceTree = ""; }; - FD3C907227E8387300CD579F /* OpenGroupServerIdLookupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookupMigration.swift; sourceTree = ""; }; FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookup.swift; sourceTree = ""; }; FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionThreadViewModel.swift; sourceTree = ""; }; FD4B200D283492210034334B /* InsetLockableTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsetLockableTableView.swift; sourceTree = ""; }; @@ -1642,7 +1603,6 @@ FD848B9B284435D7000E298B /* AppSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSetup.swift; sourceTree = ""; }; FD859EFF27C4691300510D0C /* MockDataGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDataGenerator.swift; sourceTree = ""; }; FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsCell.swift; sourceTree = ""; }; - FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsMigration.swift; sourceTree = ""; }; FD90040E2818AB6D00ABAAF6 /* GetSnodePoolJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetSnodePoolJob.swift; sourceTree = ""; }; FD9004132818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_SetupStandardJobs.swift; sourceTree = ""; }; FD9039443F7CB729CF71350E /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig"; sourceTree = ""; }; @@ -2203,8 +2163,6 @@ B8BC00BF257D90E30032E807 /* General.swift */, C3C2A5CE2553860700C340D1 /* Logging.swift */, C33FDAFD255A580600E217F9 /* LRUCache.swift */, - C33FDB5C255A580E00E217F9 /* NSArray+Functional.h */, - C33FDAB8255A580100E217F9 /* NSArray+Functional.m */, C33FDB3B255A580B00E217F9 /* NSNotificationCenter+OWS.h */, C33FDB6C255A580F00E217F9 /* NSNotificationCenter+OWS.m */, C33FDA7A255A57FB00E217F9 /* NSRegularExpression+SSK.swift */, @@ -2749,16 +2707,6 @@ C379DCE82567330E0002D4EB /* Migrations */ = { isa = PBXGroup; children = ( - B8B32044258C117C0020074B /* ContactsMigration.swift */, - FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */, - FD3C907227E8387300CD579F /* OpenGroupServerIdLookupMigration.swift */, - FD28A4F127E990E800FF65E7 /* BlockingManagerRemovalMigration.swift */, - C38EF271255B6D79007E1867 /* OWSDatabaseMigration.h */, - C38EF270255B6D79007E1867 /* OWSDatabaseMigration.m */, - C38EF26F255B6D79007E1867 /* OWSDatabaseMigrationRunner.h */, - C38EF26D255B6D79007E1867 /* OWSDatabaseMigrationRunner.m */, - C38EF26E255B6D79007E1867 /* OWSResaveCollectionDBMigration.h */, - C38EF26C255B6D79007E1867 /* OWSResaveCollectionDBMigration.m */, ); path = Migrations; sourceTree = ""; @@ -3024,8 +2972,6 @@ isa = PBXGroup; children = ( FD848B9B284435D7000E298B /* AppSetup.swift */, - C38EF302255B6DBE007E1867 /* OWSAnyTouchGestureRecognizer.h */, - C38EF2F0255B6DBB007E1867 /* OWSAnyTouchGestureRecognizer.m */, FDCDB8DD2810F73B00352A0C /* Differentiable+Utilities.swift */, C38EF3DC255B6DF1007E1867 /* DirectionalPanGestureRecognizer.swift */, C38EF240255B6D67007E1867 /* UIView+OWS.swift */, @@ -3072,14 +3018,8 @@ C33FDB49255A580C00E217F9 /* WeakTimer.swift */, C33FDBC2255A581700E217F9 /* SSKAsserts.h */, C33FDA9E255A57FF00E217F9 /* ReverseDispatchQueue.swift */, - C33FDADC255A580400E217F9 /* NSObject+Casting.h */, - C33FDAAA255A580000E217F9 /* NSObject+Casting.m */, - C33FDBFE255A581C00E217F9 /* NSSet+Functional.h */, - C33FDAC1255A580100E217F9 /* NSSet+Functional.m */, C33FDBF6255A581C00E217F9 /* NSURLSessionDataTask+StatusCode.h */, C33FDBB4255A581600E217F9 /* NSURLSessionDataTask+StatusCode.m */, - C33FDBF8255A581C00E217F9 /* NSArray+OWS.h */, - C33FDB0D255A580800E217F9 /* NSArray+OWS.m */, C33FDC03255A581D00E217F9 /* ByteParser.h */, C33FDAE0255A580400E217F9 /* ByteParser.m */, C38EF3DD255B6DF1007E1867 /* UIAlertController+OWS.swift */, @@ -3527,23 +3467,16 @@ C38EF3F6255B6DF7007E1867 /* OWSTextView.h in Headers */, C38EF24C255B6D67007E1867 /* NSAttributedString+OWS.h in Headers */, C38EF32B255B6DBF007E1867 /* OWSFormat.h in Headers */, - C33FDDB8255A582000E217F9 /* NSSet+Functional.h in Headers */, C33FDDCC255A582000E217F9 /* TSConstants.h in Headers */, C33FDDBD255A582000E217F9 /* ByteParser.h in Headers */, C38EF243255B6D67007E1867 /* UIViewController+OWS.h in Headers */, C38EF35D255B6DCC007E1867 /* OWSNavigationController.h in Headers */, C38EF249255B6D67007E1867 /* UIColor+OWS.h in Headers */, - C38EF274255B6D7A007E1867 /* OWSResaveCollectionDBMigration.h in Headers */, - C38EF277255B6D7A007E1867 /* OWSDatabaseMigration.h in Headers */, C38EF3F5255B6DF7007E1867 /* OWSTextField.h in Headers */, - C38EF275255B6D7A007E1867 /* OWSDatabaseMigrationRunner.h in Headers */, C38EF366255B6DCC007E1867 /* ScreenLockViewController.h in Headers */, C33FDDD3255A582000E217F9 /* OWSQueues.h in Headers */, - C33FDC96255A582000E217F9 /* NSObject+Casting.h in Headers */, C33FDDB3255A582000E217F9 /* OWSError.h in Headers */, C38EF35E255B6DCC007E1867 /* OWSViewController.h in Headers */, - C37F5396255B95BD002AEA92 /* OWSAnyTouchGestureRecognizer.h in Headers */, - C33FDDB2255A582000E217F9 /* NSArray+OWS.h in Headers */, C38EF367255B6DCC007E1867 /* OWSTableViewController.h in Headers */, C38EF246255B6D67007E1867 /* UIFont+OWS.h in Headers */, C33FD9AF255A548A00E217F9 /* SignalUtilitiesKit.h in Headers */, @@ -3564,7 +3497,6 @@ isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( - C32C5FAA256DFED9003C73A2 /* NSArray+Functional.h in Headers */, C3D9E3FA25676BCE0040E4F3 /* TSYapDatabaseObject.h in Headers */, C3D9E3A4256763DE0040E4F3 /* AppContext.h in Headers */, C3D9E38A256760390040E4F3 /* OWSFileSystem.h in Headers */, @@ -4286,14 +4218,11 @@ C38EF3FD255B6DF7007E1867 /* OWSTextView.m in Sources */, C38EF3C6255B6DE7007E1867 /* ImageEditorModel.swift in Sources */, C38EF3C3255B6DE7007E1867 /* ImageEditorTextItem.swift in Sources */, - FD28A4F227E990E800FF65E7 /* BlockingManagerRemovalMigration.swift in Sources */, C33FDC7D255A582000E217F9 /* OWSDispatch.m in Sources */, C38EF247255B6D67007E1867 /* NSAttributedString+OWS.m in Sources */, C33FDD49255A582000E217F9 /* ParamParser.swift in Sources */, C38EF3C5255B6DE7007E1867 /* OWSViewController+ImageEditor.swift in Sources */, C38EF2A5255B6D93007E1867 /* Identicon+ObjC.swift in Sources */, - C38EF273255B6D7A007E1867 /* OWSDatabaseMigrationRunner.m in Sources */, - C38EF31A255B6DBF007E1867 /* OWSAnyTouchGestureRecognizer.m in Sources */, C38EF385255B6DD2007E1867 /* AttachmentTextToolbar.swift in Sources */, C33FDD23255A582000E217F9 /* FeatureFlags.swift in Sources */, C38EF389255B6DD2007E1867 /* AttachmentTextView.swift in Sources */, @@ -4311,19 +4240,16 @@ C33FDCD1255A582000E217F9 /* FunctionalUtil.m in Sources */, C38EF402255B6DF7007E1867 /* CommonStrings.swift in Sources */, C38EF3C1255B6DE7007E1867 /* ImageEditorBrushViewController.swift in Sources */, - C33FDCC7255A582000E217F9 /* NSArray+OWS.m in Sources */, C33FDD32255A582000E217F9 /* OWSOperation.m in Sources */, C3F0A530255C80BC007BE2A3 /* NoopNotificationsManager.swift in Sources */, C3D90A7A25773A93002C9DF5 /* Configuration.swift in Sources */, C33FDD8D255A582000E217F9 /* OWSSignalAddress.swift in Sources */, - C33FDC64255A582000E217F9 /* NSObject+Casting.m in Sources */, C38EF388255B6DD2007E1867 /* AttachmentApprovalViewController.swift in Sources */, C38EF2A7255B6D93007E1867 /* ProfilePictureView.swift in Sources */, C38EF22C255B6D5D007E1867 /* OWSVideoPlayer.swift in Sources */, C33FDC29255A581F00E217F9 /* ReachabilityManager.swift in Sources */, C38EF407255B6DF7007E1867 /* Toast.swift in Sources */, C38EF38C255B6DD2007E1867 /* ApprovalRailCellView.swift in Sources */, - FD3C907327E8387300CD579F /* OpenGroupServerIdLookupMigration.swift in Sources */, C38EF32A255B6DBF007E1867 /* UIUtil.m in Sources */, C38EF2A6255B6D93007E1867 /* PlaceholderIcon.swift in Sources */, C33FDD92255A582000E217F9 /* SignalIOS.pb.swift in Sources */, @@ -4335,16 +4261,13 @@ C38EF3BA255B6DE7007E1867 /* ImageEditorItem.swift in Sources */, C38EF3F7255B6DF7007E1867 /* OWSNavigationBar.swift in Sources */, C38EF248255B6D67007E1867 /* UIViewController+OWS.m in Sources */, - C38EF272255B6D7A007E1867 /* OWSResaveCollectionDBMigration.m in Sources */, C33FDD3A255A582000E217F9 /* Notification+Loki.swift in Sources */, FD705A98278E9F4D00F16121 /* UIColor+Extensions.swift in Sources */, - C38EF276255B6D7A007E1867 /* OWSDatabaseMigration.m in Sources */, C38EF370255B6DCC007E1867 /* OWSNavigationController.m in Sources */, C38EF24E255B6D67007E1867 /* Collection+OWS.swift in Sources */, C33FDCFA255A582000E217F9 /* SignalIOSProto.swift in Sources */, C38EF24D255B6D67007E1867 /* UIView+OWS.swift in Sources */, C38EF38B255B6DD2007E1867 /* AttachmentPrepViewController.swift in Sources */, - C33FDC7B255A582000E217F9 /* NSSet+Functional.m in Sources */, C38EF405255B6DF7007E1867 /* OWSButton.swift in Sources */, C38EF3C4255B6DE7007E1867 /* ImageEditorContents.swift in Sources */, C38EF3BC255B6DE7007E1867 /* ImageEditorPanGestureRecognizer.swift in Sources */, @@ -4370,11 +4293,9 @@ FDCDB8DE2810F73B00352A0C /* Differentiable+Utilities.swift in Sources */, C38EF3F9255B6DF7007E1867 /* OWSLayerView.swift in Sources */, C33FDD03255A582000E217F9 /* WeakTimer.swift in Sources */, - B8B3204E258C15C80020074B /* ContactsMigration.swift in Sources */, C38EF3B9255B6DE7007E1867 /* ImageEditorPinchGestureRecognizer.swift in Sources */, C33FDC98255A582000E217F9 /* SwiftSingletons.swift in Sources */, C33FDC27255A581F00E217F9 /* YapDatabase+Promise.swift in Sources */, - FD88BADB27A750F200BBC442 /* MessageRequestsMigration.swift in Sources */, C38EF3B8255B6DE7007E1867 /* ImageEditorTextViewController.swift in Sources */, C38EF40B255B6DF7007E1867 /* TappableStackView.swift in Sources */, C38EF31D255B6DBF007E1867 /* UIImage+OWS.swift in Sources */, @@ -4460,7 +4381,6 @@ FD705A94278D052B00F16121 /* UITableView+ReusableView.swift in Sources */, FD9004152818B46300ABAAF6 /* JobRunner.swift in Sources */, C3A7211A2558BCA10043A11F /* DiffieHellman.swift in Sources */, - C32C5FA1256DFED5003C73A2 /* NSArray+Functional.m in Sources */, C3A7225E2558C38D0043A11F /* Promise+Retaining.swift in Sources */, FD17D7BD27F51F6900122BE0 /* GRDB+Notifications.swift in Sources */, FD17D7CA27F546D900122BE0 /* _001_InitialSetupMigration.swift in Sources */, diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index c261436f7..dfdb0b215 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -1325,6 +1325,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers } func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults results: [Int64]?, searchText: String?) { + viewModel.lastSearchedText = searchText tableView.reloadRows(at: tableView.indexPathsForVisibleRows ?? [], with: UITableView.RowAnimation.none) } diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index d25a8f966..8c7d2ea0d 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -65,10 +65,6 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { columns: Interaction.Columns .allCases .filter { $0 != .wasRead } - ), - PagedData.ObservedChanges( - table: ThreadTypingIndicator.self, - columns: ThreadTypingIndicator.Columns.allCases ) ], filterSQL: MessageViewModel.filterSQL(threadId: threadId), @@ -90,6 +86,19 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { joinToPagedType: MessageViewModel.AttachmentInteractionInfo.joinToViewModelQuerySQL, groupPagedType: MessageViewModel.AttachmentInteractionInfo.groupViewModelQuerySQL, associateData: MessageViewModel.AttachmentInteractionInfo.createAssociateDataClosure() + ), + AssociatedRecord( + trackedAgainst: ThreadTypingIndicator.self, + observedChanges: [ + PagedData.ObservedChanges( + table: ThreadTypingIndicator.self, + events: [.insert, .delete], + columns: [] + ) + ], + dataQuery: MessageViewModel.TypingIndicatorInfo.baseQuery, + joinToPagedType: MessageViewModel.TypingIndicatorInfo.joinToViewModelQuerySQL, + associateData: MessageViewModel.TypingIndicatorInfo.createAssociateDataClosure() ) ], onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in @@ -140,6 +149,16 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { /// This value is the current state of the view public private(set) var threadData: 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 observableThreadData = ValueObservation .trackingConstantRegion { [threadId = self.threadId] db -> SessionThreadViewModel? in let userPublicKey: String = getUserHexEncodedPublicKey(db) @@ -161,7 +180,9 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { public var onInteractionChange: (([SectionModel]) -> ())? private func process(data: [MessageViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] { + let typingIndicator: MessageViewModel? = data.first(where: { $0.isTypingIndicator }) let sortedData: [MessageViewModel] = data + .filter { !$0.isTypingIndicator } .sorted { lhs, rhs -> Bool in lhs.timestampMs < rhs.timestampMs } // We load messages from newest to oldest so having a pageOffset larger than zero means @@ -186,6 +207,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { ) ) } + .appending(typingIndicator) ) ], (!data.isEmpty && pageInfo.pageOffset > 0 ? diff --git a/Session/Conversations/Settings/OWSConversationSettingsViewController.m b/Session/Conversations/Settings/OWSConversationSettingsViewController.m index d53be144d..8a0db3674 100644 --- a/Session/Conversations/Settings/OWSConversationSettingsViewController.m +++ b/Session/Conversations/Settings/OWSConversationSettingsViewController.m @@ -9,7 +9,6 @@ #import "UIView+OWS.h" #import #import -#import #import #import #import diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 74eee689b..7cd8d113b 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -37,6 +37,17 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve return result }() + + private lazy var loadingConversationsLabel: UILabel = { + let result: UILabel = UILabel() + result.font = UIFont.systemFont(ofSize: Values.smallFontSize) + result.text = "LOADING_CONVERSATIONS".localized() + result.textColor = Colors.text + result.textAlignment = .center + result.numberOfLines = 0 + + return result + }() private lazy var tableView: UITableView = { let result = UITableView() @@ -128,6 +139,13 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve seedReminderView.pin(.trailing, to: .trailing, of: view) } + // Loading conversations label + view.addSubview(loadingConversationsLabel) + + loadingConversationsLabel.pin(.top, to: .top, of: view, withInset: Values.veryLargeSpacing) + loadingConversationsLabel.pin(.leading, to: .leading, of: view, withInset: 50) + loadingConversationsLabel.pin(.trailing, to: .trailing, of: view, withInset: -50) + // Table view view.addSubview(tableView) tableView.pin(.leading, to: .leading, of: view) @@ -218,11 +236,9 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve // Start observing for data changes dataChangeObservable = GRDBStorage.shared.start( viewModel.observableViewData, - onError: { error in - print("Update error!!!!") - }, + onError: { _ in }, onChange: { [weak self] viewData in - // The defaul scheduler emits changes on the main thread + // The default scheduler emits changes on the main thread self?.handleUpdates(viewData) } ) @@ -237,6 +253,9 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve return } + // Hide the 'loading conversations' label (now that we have received conversation data) + loadingConversationsLabel.isHidden = true + // Show the empty state if there is no data emptyStateView.isHidden = ( !updatedViewData.isEmpty && diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index e309e8076..a17730a42 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -17,43 +17,68 @@ public class HomeViewModel { /// 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 { db -> [ArraySection] in - let userPublicKey: String = getUserHexEncodedPublicKey(db) - let unreadMessageRequestCount: Int = try SessionThread - .filter(SessionThread.isMessageRequest(userPublicKey: userPublicKey)) - .joining(optional: SessionThread.contact) - .joining( - required: SessionThread.interactions - .filter(Interaction.Columns.wasRead == false) - ) - .group(SessionThread.Columns.id) - .fetchCount(db) - let finalUnreadMessageRequestCount: Int = (db[.hasHiddenMessageRequests] ? 0 : unreadMessageRequestCount) - - return [ - ArraySection( - model: .messageRequests, - elements: [ - // If there are no unread message requests then hide the message request banner - (finalUnreadMessageRequestCount == 0 ? - nil : - SessionThreadViewModel( - unreadCount: UInt(finalUnreadMessageRequestCount) - ) - ) - ].compactMap { $0 } + .tracking( + regions: [ + // We explicitly define the regions we want to track as the automatic detection + // seems to include a bunch of columns we will fetch but probably don't need to + // track changes for + SessionThread.select( + .id, + .shouldBeVisible, + .isPinned, + .mutedUntilTimestamp, + .onlyNotifyForMentions ), - ArraySection( - model: .threads, - elements: try SessionThreadViewModel - .homeQuery(userPublicKey: userPublicKey) - .fetchAll(db) - ) - ] - } + Setting.filter(id: Setting.BoolKey.hasHiddenMessageRequests.rawValue), + Contact.select(.isBlocked, .isApproved), // 'isApproved' for message requests + Profile.select(.name, .nickname, .profilePictureFileName), + ClosedGroup.select(.name), + OpenGroup.select(.name, .imageData), + GroupMember.select(.groupId), + Interaction.select( + .body, + .wasRead + ), + Attachment.select(.state), + RecipientState.select(.state), + ThreadTypingIndicator.select(.threadId) + ], + fetch: { db -> [ArraySection] in + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let unreadMessageRequestCount: Int = try SessionThread + .unreadMessageRequestsCountQuery(userPublicKey: userPublicKey) + .fetchOne(db) + .defaulting(to: 0) + let finalUnreadMessageRequestCount: Int = (db[.hasHiddenMessageRequests] ? 0 : unreadMessageRequestCount) + let threads: [SessionThreadViewModel] = try SessionThreadViewModel + .homeQuery(userPublicKey: userPublicKey) + .fetchAll(db) + + return [ + ArraySection( + model: .messageRequests, + elements: [ + // If there are no unread message requests then hide the message request banner + (finalUnreadMessageRequestCount == 0 ? + nil : + SessionThreadViewModel( + unreadCount: UInt(finalUnreadMessageRequestCount) + ) + ) + ].compactMap { $0 } + ), + ArraySection( + model: .threads, + elements: threads + ) + ] + } + ) .removeDuplicates() // MARK: - Functions diff --git a/Session/Home/Message Requests/MessageRequestsViewModel.swift b/Session/Home/Message Requests/MessageRequestsViewModel.swift index 9688d1c71..d1ce5633d 100644 --- a/Session/Home/Message Requests/MessageRequestsViewModel.swift +++ b/Session/Home/Message Requests/MessageRequestsViewModel.swift @@ -14,6 +14,11 @@ public class MessageRequestsViewModel { /// /// **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 { db -> [SessionThreadViewModel] in let userPublicKey: String = getUserHexEncodedPublicKey(db) diff --git a/Session/Media Viewing & Editing/ImagePickerController.swift b/Session/Media Viewing & Editing/ImagePickerController.swift index 45d316bc1..f61ea68b6 100644 --- a/Session/Media Viewing & Editing/ImagePickerController.swift +++ b/Session/Media Viewing & Editing/ImagePickerController.swift @@ -5,6 +5,7 @@ import Foundation import Photos import PromiseKit +import SessionUIKit protocol ImagePickerGridControllerDelegate: AnyObject { func imagePickerDidCompleteSelection(_ imagePicker: ImagePickerGridController) @@ -46,6 +47,8 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat override func viewDidLoad() { super.viewDidLoad() + + self.view.backgroundColor = Colors.navigationBarBackground library.add(delegate: self) @@ -59,7 +62,6 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat // ensure images at the end of the list can be scrolled above the bottom buttons let bottomButtonInset = -1 * SendMediaNavigationController.bottomButtonsCenterOffset + SendMediaNavigationController.bottomButtonWidth / 2 + 16 collectionView.contentInset.bottom = bottomButtonInset + 16 - view.backgroundColor = .white // The PhotoCaptureVC needs a shadow behind it's cancel button, so we use a custom icon. // This VC has a visible navbar so doesn't need the shadow, but because the user can @@ -69,7 +71,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat let cancelImage = UIImage(imageLiteralResourceName: "X") let cancelButton = UIBarButtonItem(image: cancelImage, style: .plain, target: self, action: #selector(didPressCancel)) - cancelButton.tintColor = .black + cancelButton.tintColor = Colors.text navigationItem.leftBarButtonItem = cancelButton let titleView = TitleView() @@ -86,7 +88,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat navigationItem.titleView = titleView self.titleView = titleView - collectionView.backgroundColor = .white + collectionView.backgroundColor = Colors.navigationBarBackground let selectionPanGesture = DirectionalPanGestureRecognizer(direction: [.horizontal], target: self, action: #selector(didPanSelection)) selectionPanGesture.delegate = self @@ -187,16 +189,15 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - // Loki: Set navigation bar background color - let navigationBar = navigationController!.navigationBar - navigationBar.setBackgroundImage(UIImage(), for: UIBarMetrics.default) - navigationBar.shadowImage = UIImage() - navigationBar.isTranslucent = false - navigationBar.barTintColor = .white - (navigationBar as! OWSNavigationBar).respectsTheme = false - navigationBar.backgroundColor = .white - let backgroundImage = UIImage(color: .white) - navigationBar.setBackgroundImage(backgroundImage, for: .default) + let backgroundImage: UIImage = UIImage(color: Colors.navigationBarBackground) + self.navigationItem.title = nil + self.navigationController?.navigationBar.setBackgroundImage(UIImage(), for: .default) + self.navigationController?.navigationBar.shadowImage = UIImage() + self.navigationController?.navigationBar.isTranslucent = false + self.navigationController?.navigationBar.barTintColor = Colors.navigationBarBackground + (self.navigationController?.navigationBar as? OWSNavigationBar)?.respectsTheme = true + self.navigationController?.navigationBar.backgroundColor = Colors.navigationBarBackground + self.navigationController?.navigationBar.setBackgroundImage(backgroundImage, for: .default) // Determine the size of the thumbnails to request let scale = UIScreen.main.scale @@ -605,10 +606,10 @@ class TitleView: UIView { addSubview(stackView) stackView.autoPinEdgesToSuperviewEdges() - label.textColor = .black + label.textColor = Colors.text label.font = .boldSystemFont(ofSize: Values.mediumFontSize) - iconView.tintColor = .black + iconView.tintColor = Colors.text iconView.image = UIImage(named: "navbar_disclosure_down")?.withRenderingMode(.alwaysTemplate) addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(titleTapped))) diff --git a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift index 18f41c8a1..22f7249f6 100644 --- a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift +++ b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift @@ -309,6 +309,11 @@ public class MediaGalleryViewModel { /// /// **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 typealias AlbumObservation = ValueObservation>> public lazy var observableAlbumData: AlbumObservation = buildAlbumObservation(for: nil) diff --git a/Session/Media Viewing & Editing/SendMediaNavigationController.swift b/Session/Media Viewing & Editing/SendMediaNavigationController.swift index e0211fab3..6af3f3090 100644 --- a/Session/Media Viewing & Editing/SendMediaNavigationController.swift +++ b/Session/Media Viewing & Editing/SendMediaNavigationController.swift @@ -282,8 +282,6 @@ extension SendMediaNavigationController: UINavigationControllerDelegate { func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) { if viewController == captureViewController { setNavBarBackgroundColor(to: .black) - } else if viewController == mediaLibraryViewController { - setNavBarBackgroundColor(to: .white) } else { setNavBarBackgroundColor(to: Colors.navigationBarBackground) } @@ -311,8 +309,6 @@ extension SendMediaNavigationController: UINavigationControllerDelegate { func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) { if viewController == captureViewController { setNavBarBackgroundColor(to: .black) - } else if viewController == mediaLibraryViewController { - setNavBarBackgroundColor(to: .white) } else { setNavBarBackgroundColor(to: Colors.navigationBarBackground) } diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 2b2ad8a6f..846daa04e 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -18,9 +18,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD var hasInitialRootViewController: Bool = false /// This needs to be a lazy variable to ensure it doesn't get initialized before it actually needs to be used - lazy var poller: Poller = { - return Poller() - }() + lazy var poller: Poller = Poller() // MARK: - Lifecycle @@ -67,6 +65,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD migrationsCompletion: { [weak self] successful, needsConfigSync in guard let strongSelf = self else { return } guard successful else { + self?.showFailedMigrationAlert() return } @@ -211,6 +210,28 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD // MARK: - App Readiness + private func showFailedMigrationAlert() { + let alert = UIAlertController( + title: "Session", + message: [ + "DATABASE_MIGRATION_FAILED".localized(), + "modal_share_logs_explanation".localized() + ].joined(separator: "\n\n"), + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "modal_share_logs_title".localized(), style: .default) { _ in + ShareLogsModal.shareLogs(from: alert) { [weak self] in + self?.showFailedMigrationAlert() + } + }) + alert.addAction(UIAlertAction(title: "Close", style: .destructive) { _ in + DDLog.flushLog() + exit(0) + }) + + self.window?.rootViewController?.present(alert, animated: true, completion: nil) + } + /// The user must unlock the device once after reboot before the database encryption key can be accessed. private func verifyDBKeysAvailableBeforeBackgroundLaunch() { guard UIApplication.shared.applicationState == .background else { return } diff --git a/Session/Meta/MainAppContext.m b/Session/Meta/MainAppContext.m index 1a1cd6039..bfcf801a2 100644 --- a/Session/Meta/MainAppContext.m +++ b/Session/Meta/MainAppContext.m @@ -5,7 +5,6 @@ #import "MainAppContext.h" #import "Session-Swift.h" #import -#import #import NS_ASSUME_NONNULL_BEGIN diff --git a/Session/Meta/Session-Prefix.pch b/Session/Meta/Session-Prefix.pch index 5dbd16ebf..8998c4792 100644 --- a/Session/Meta/Session-Prefix.pch +++ b/Session/Meta/Session-Prefix.pch @@ -11,8 +11,5 @@ #import #import #import - #import - #import - #import #import #endif diff --git a/Session/Meta/Signal-Bridging-Header.h b/Session/Meta/Signal-Bridging-Header.h index 01ad3a46c..bee17ac82 100644 --- a/Session/Meta/Signal-Bridging-Header.h +++ b/Session/Meta/Signal-Bridging-Header.h @@ -10,11 +10,9 @@ #import "AvatarViewHelper.h" #import "AVAudioSession+OWS.h" #import "NotificationSettingsViewController.h" -#import "OWSAnyTouchGestureRecognizer.h" #import "OWSAudioPlayer.h" #import "OWSBezierPathView.h" #import "OWSConversationSettingsViewController.h" -#import "OWSDatabaseMigration.h" #import "OWSMessageTimerView.h" #import "OWSNavigationController.h" #import "OWSProgressView.h" @@ -32,7 +30,6 @@ #import #import #import -#import #import #import #import diff --git a/Session/Meta/Translations/de.lproj/Localizable.strings b/Session/Meta/Translations/de.lproj/Localizable.strings index 9d8adff9c..4c2df0832 100644 --- a/Session/Meta/Translations/de.lproj/Localizable.strings +++ b/Session/Meta/Translations/de.lproj/Localizable.strings @@ -621,3 +621,5 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Fehler"; +"LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; diff --git a/Session/Meta/Translations/en.lproj/Localizable.strings b/Session/Meta/Translations/en.lproj/Localizable.strings index ca173d7f7..75ed86d1a 100644 --- a/Session/Meta/Translations/en.lproj/Localizable.strings +++ b/Session/Meta/Translations/en.lproj/Localizable.strings @@ -631,3 +631,5 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Error"; +"LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; diff --git a/Session/Meta/Translations/es.lproj/Localizable.strings b/Session/Meta/Translations/es.lproj/Localizable.strings index 6ba0ab4f5..c6d4c4720 100644 --- a/Session/Meta/Translations/es.lproj/Localizable.strings +++ b/Session/Meta/Translations/es.lproj/Localizable.strings @@ -621,3 +621,5 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Fallo"; +"LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; diff --git a/Session/Meta/Translations/fa.lproj/Localizable.strings b/Session/Meta/Translations/fa.lproj/Localizable.strings index 710e5f4e7..af92c8e01 100644 --- a/Session/Meta/Translations/fa.lproj/Localizable.strings +++ b/Session/Meta/Translations/fa.lproj/Localizable.strings @@ -621,3 +621,5 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "خطاء"; +"LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; diff --git a/Session/Meta/Translations/fi.lproj/Localizable.strings b/Session/Meta/Translations/fi.lproj/Localizable.strings index c50297985..986517c15 100644 --- a/Session/Meta/Translations/fi.lproj/Localizable.strings +++ b/Session/Meta/Translations/fi.lproj/Localizable.strings @@ -621,3 +621,5 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Error"; +"LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; diff --git a/Session/Meta/Translations/fr.lproj/Localizable.strings b/Session/Meta/Translations/fr.lproj/Localizable.strings index bd4752b8b..55d711fce 100644 --- a/Session/Meta/Translations/fr.lproj/Localizable.strings +++ b/Session/Meta/Translations/fr.lproj/Localizable.strings @@ -621,3 +621,5 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Erreur"; +"LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; diff --git a/Session/Meta/Translations/hi.lproj/Localizable.strings b/Session/Meta/Translations/hi.lproj/Localizable.strings index babc35e71..ccd1b2a66 100644 --- a/Session/Meta/Translations/hi.lproj/Localizable.strings +++ b/Session/Meta/Translations/hi.lproj/Localizable.strings @@ -621,3 +621,5 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Error"; +"LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; diff --git a/Session/Meta/Translations/hr.lproj/Localizable.strings b/Session/Meta/Translations/hr.lproj/Localizable.strings index 5705b0b19..7dc53a8c8 100644 --- a/Session/Meta/Translations/hr.lproj/Localizable.strings +++ b/Session/Meta/Translations/hr.lproj/Localizable.strings @@ -621,3 +621,5 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Error"; +"LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; diff --git a/Session/Meta/Translations/id-ID.lproj/Localizable.strings b/Session/Meta/Translations/id-ID.lproj/Localizable.strings index f6c91ddf2..02a4dbb4d 100644 --- a/Session/Meta/Translations/id-ID.lproj/Localizable.strings +++ b/Session/Meta/Translations/id-ID.lproj/Localizable.strings @@ -621,3 +621,5 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Galat"; +"LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; diff --git a/Session/Meta/Translations/it.lproj/Localizable.strings b/Session/Meta/Translations/it.lproj/Localizable.strings index f73ace460..9e11deb4a 100644 --- a/Session/Meta/Translations/it.lproj/Localizable.strings +++ b/Session/Meta/Translations/it.lproj/Localizable.strings @@ -621,3 +621,5 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Errore"; +"LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; diff --git a/Session/Meta/Translations/ja.lproj/Localizable.strings b/Session/Meta/Translations/ja.lproj/Localizable.strings index 1717a4e37..1dbafa69c 100644 --- a/Session/Meta/Translations/ja.lproj/Localizable.strings +++ b/Session/Meta/Translations/ja.lproj/Localizable.strings @@ -621,3 +621,5 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "エラー"; +"LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; diff --git a/Session/Meta/Translations/nl.lproj/Localizable.strings b/Session/Meta/Translations/nl.lproj/Localizable.strings index 22ae40e3e..877f962b9 100644 --- a/Session/Meta/Translations/nl.lproj/Localizable.strings +++ b/Session/Meta/Translations/nl.lproj/Localizable.strings @@ -621,3 +621,5 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Error"; +"LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; diff --git a/Session/Meta/Translations/pl.lproj/Localizable.strings b/Session/Meta/Translations/pl.lproj/Localizable.strings index 9ee0cb99e..504bd1e68 100644 --- a/Session/Meta/Translations/pl.lproj/Localizable.strings +++ b/Session/Meta/Translations/pl.lproj/Localizable.strings @@ -621,3 +621,5 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Błąd"; +"LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; diff --git a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings index c74587594..07c3a29e1 100644 --- a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings +++ b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings @@ -621,3 +621,5 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Erro"; +"LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; diff --git a/Session/Meta/Translations/ru.lproj/Localizable.strings b/Session/Meta/Translations/ru.lproj/Localizable.strings index 16f8f85e2..f9ed4e582 100644 --- a/Session/Meta/Translations/ru.lproj/Localizable.strings +++ b/Session/Meta/Translations/ru.lproj/Localizable.strings @@ -621,3 +621,5 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Ошибка"; +"LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; diff --git a/Session/Meta/Translations/si.lproj/Localizable.strings b/Session/Meta/Translations/si.lproj/Localizable.strings index 6e6dbc59f..a1cf256f7 100644 --- a/Session/Meta/Translations/si.lproj/Localizable.strings +++ b/Session/Meta/Translations/si.lproj/Localizable.strings @@ -622,3 +622,5 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Error"; +"LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; diff --git a/Session/Meta/Translations/sk.lproj/Localizable.strings b/Session/Meta/Translations/sk.lproj/Localizable.strings index 49fae9bf2..1f7490d42 100644 --- a/Session/Meta/Translations/sk.lproj/Localizable.strings +++ b/Session/Meta/Translations/sk.lproj/Localizable.strings @@ -621,3 +621,5 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Error"; +"LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; diff --git a/Session/Meta/Translations/sv.lproj/Localizable.strings b/Session/Meta/Translations/sv.lproj/Localizable.strings index 7bc60b944..33bc3d2bf 100644 --- a/Session/Meta/Translations/sv.lproj/Localizable.strings +++ b/Session/Meta/Translations/sv.lproj/Localizable.strings @@ -621,3 +621,5 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Error"; +"LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; diff --git a/Session/Meta/Translations/th.lproj/Localizable.strings b/Session/Meta/Translations/th.lproj/Localizable.strings index 9095870a8..a8f58a441 100644 --- a/Session/Meta/Translations/th.lproj/Localizable.strings +++ b/Session/Meta/Translations/th.lproj/Localizable.strings @@ -621,3 +621,5 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Error"; +"LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; diff --git a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings index 6b4204a04..e11143ca7 100644 --- a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings +++ b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings @@ -621,3 +621,5 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Error"; +"LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; diff --git a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings index 5f1146f43..cf53c9623 100644 --- a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings @@ -621,3 +621,5 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Error"; +"LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; diff --git a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings index 83d8edef6..b13723ac2 100644 --- a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings @@ -621,3 +621,5 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "错误"; +"LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; diff --git a/Session/Settings/PrivacySettingsTableViewController.m b/Session/Settings/PrivacySettingsTableViewController.m index 153ba68ab..08c3d5197 100644 --- a/Session/Settings/PrivacySettingsTableViewController.m +++ b/Session/Settings/PrivacySettingsTableViewController.m @@ -6,7 +6,6 @@ #import "Session-Swift.h" #import -#import #import #import diff --git a/Session/Settings/ShareLogsModal.swift b/Session/Settings/ShareLogsModal.swift index 0a93acff1..4bd28d6b0 100644 --- a/Session/Settings/ShareLogsModal.swift +++ b/Session/Settings/ShareLogsModal.swift @@ -55,17 +55,24 @@ final class ShareLogsModal : Modal { contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing) } - // MARK: Interaction + // MARK: - Interaction + @objc private func shareLogs() { + ShareLogsModal.shareLogs(from: self) + } + + public static func shareLogs(from viewController: UIViewController, onShareComplete: (() -> ())? = nil) { let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" OWSLogger.info("[Version] iOS \(UIDevice.current.systemVersion) \(version)") DDLog.flushLog() let logFilePaths = AppEnvironment.shared.fileLogger.logFileManager.sortedLogFilePaths if let latestLogFilePath = logFilePaths.first { let latestLogFileURL = URL(fileURLWithPath: latestLogFilePath) - self.dismiss(animated: true, completion: { + + viewController.dismiss(animated: true, completion: { if let vc = CurrentAppContext().frontmostViewController() { let shareVC = UIActivityViewController(activityItems: [ latestLogFileURL ], applicationActivities: nil) + shareVC.completionWithItemsHandler = { _, _, _, _ in onShareComplete?() } if UIDevice.current.isIPad { shareVC.excludedActivityTypes = [] shareVC.popoverPresentationController?.permittedArrowDirections = [] diff --git a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift index e25f9541e..5b4dcf4ad 100644 --- a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -5,9 +5,10 @@ import GRDB import SessionUtilitiesKit enum _001_InitialSetupMigration: Migration { + static let target: TargetMigrations.Identifier = .messagingKit static let identifier: String = "initialSetup" - static let minExpectedRunDuration: TimeInterval = 0.1 static let needsConfigSync: Bool = false + static let minExpectedRunDuration: TimeInterval = 0.1 static func migrate(_ db: Database) throws { // Define the tokenizer to be used in all the FTS tables diff --git a/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift index 55b0bec75..dac6eaebc 100644 --- a/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -9,9 +9,10 @@ import SessionSnodeKit /// This migration sets up the standard jobs, since we want these jobs to run before any "once-off" jobs we do this migration /// before running the `YDBToGRDBMigration` enum _002_SetupStandardJobs: Migration { + static let target: TargetMigrations.Identifier = .messagingKit static let identifier: String = "SetupStandardJobs" - static let minExpectedRunDuration: TimeInterval = 0.1 static let needsConfigSync: Bool = false + static let minExpectedRunDuration: TimeInterval = 0.1 static func migrate(_ db: Database) throws { // Start by adding the jobs that don't have collections (in the jobs like these diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 7cb7042e5..38d28b322 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -10,15 +10,19 @@ import SessionSnodeKit // Note: Looks like the oldest iOS device we support (min iOS 13.0) has 2Gb of RAM, processing // ~250k messages and ~1000 threads seems to take up enum _003_YDBToGRDBMigration: Migration { + static let target: TargetMigrations.Identifier = .messagingKit static let identifier: String = "YDBToGRDBMigration" - static let minExpectedRunDuration: TimeInterval = 20 static let needsConfigSync: Bool = true + static let minExpectedRunDuration: TimeInterval = 20 static func migrate(_ db: Database) throws { - let targetIdentifier: TargetMigrations.Identifier = .messagingKit + guard let dbConnection: YapDatabaseConnection = SUKLegacy.newDatabaseConnection() else { + SNLog("[Migration Warning] No legacy database, skipping \(target.key(with: self))") + return + } + + // MARK: - Read from Legacy Database - // MARK: - Process Contacts, Threads & Interactions - print("RAWR [\(Date().timeIntervalSince1970)] - SessionMessagingKit migration - Start") var shouldFailMigration: Bool = false var legacyMigrations: Set = [] var contacts: Set = [] @@ -48,89 +52,24 @@ enum _003_YDBToGRDBMigration: Migration { var outgoingReadReceiptsTimestampsMs: [String: Set] = [:] var receivedMessageTimestamps: Set = [] - // Map the Legacy types for the NSKeyedUnarchiver - NSKeyedUnarchiver.setClass( - SMKLegacy._Thread.self, - forClassName: "TSThread" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._ContactThread.self, - forClassName: "TSContactThread" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._GroupThread.self, - forClassName: "TSGroupThread" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._GroupModel.self, - forClassName: "TSGroupModel" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._Contact.self, - forClassName: "SNContact" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._DBInteraction.self, - forClassName: "TSInteraction" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._DBMessage.self, - forClassName: "TSMessage" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._DBQuotedMessage.self, - forClassName: "TSQuotedMessage" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._DBQuotedMessage._DBAttachmentInfo.self, - forClassName: "OWSAttachmentInfo" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._DBLinkPreview.self, - forClassName: "SessionServiceKit.OWSLinkPreview" // Very old legacy name - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._DBLinkPreview.self, - forClassName: "SessionMessagingKit.OWSLinkPreview" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._DBIncomingMessage.self, - forClassName: "TSIncomingMessage" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._DBOutgoingMessage.self, - forClassName: "TSOutgoingMessage" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._DBOutgoingMessageRecipientState.self, - forClassName: "TSOutgoingMessageRecipientState" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._DBInfoMessage.self, - forClassName: "TSInfoMessage" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._DisappearingConfigurationUpdateInfoMessage.self, - forClassName: "OWSDisappearingConfigurationUpdateInfoMessage" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._Attachment.self, - forClassName: "TSAttachment" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._AttachmentStream.self, - forClassName: "TSAttachmentStream" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._AttachmentPointer.self, - forClassName: "TSAttachmentPointer" - ) + var notifyPushServerJobs: Set = [] + var messageReceiveJobs: Set = [] + var messageSendJobs: Set = [] + var attachmentUploadJobs: Set = [] + var attachmentDownloadJobs: Set = [] - Storage.read { transaction in + var legacyPreferences: [String: Any] = [:] + + // Map the Legacy types for the NSKeyedUnarchivez + self.mapLegacyTypesForNSKeyedUnarchiver() + + dbConnection.read { transaction in + // MARK: --Migrations + // Process the migrations (we don't want to bother running the old migrations as it would be // a waste of time, rather we include the logic from the old migrations in here and make the // same changes if the migration hasn't already run) - transaction.enumerateRows(inCollection: SMKLegacy.databaseMigrationCollection) { key, _, _, _ in + transaction.enumerateKeys(inCollection: SMKLegacy.databaseMigrationCollection) { key, _ in guard let legacyMigration: SMKLegacy._DBMigration = SMKLegacy._DBMigration(rawValue: key) else { SNLog("[Migration Error] Found unknown migration") shouldFailMigration = true @@ -139,23 +78,28 @@ enum _003_YDBToGRDBMigration: Migration { legacyMigrations.insert(legacyMigration) } + GRDBStorage.shared.update(progress: 0.01, for: self, in: target) + + // MARK: --Contacts + + SNLog("[Migration Info] \(target.key(with: self)) - Processing Contacts") - // Process the Contacts transaction.enumerateRows(inCollection: SMKLegacy.contactCollection) { _, object, _, _ in guard let contact = object as? SMKLegacy._Contact else { return } contacts.insert(contact) validProfileIds.insert(contact.sessionID) } - // Process legacy blocked contacts legacyBlockedSessionIds = Set(transaction.object( forKey: SMKLegacy.blockedPhoneNumbersKey, inCollection: SMKLegacy.blockListCollection ) as? [String] ?? []) - - print("RAWR [\(Date().timeIntervalSince1970)] - Process threads - Start") + GRDBStorage.shared.update(progress: 0.02, for: self, in: target) + + // MARK: --Threads + + SNLog("[Migration Info] \(target.key(with: self)) - Processing Threads") - // Process the threads transaction.enumerateKeysAndObjects(inCollection: SMKLegacy.threadCollection) { key, object, _ in guard let thread: SMKLegacy._Thread = object as? SMKLegacy._Thread else { return } @@ -237,10 +181,12 @@ enum _003_YDBToGRDBMigration: Migration { openGroupLastDeletionServerId[thread.uniqueId] = transaction.object(forKey: openGroup.id, inCollection: SMKLegacy.openGroupLastDeletionServerIDCollection) as? Int64 } } - print("RAWR [\(Date().timeIntervalSince1970)] - Process threads - End") + GRDBStorage.shared.update(progress: 0.04, for: self, in: target) + + // MARK: --Interactions + + SNLog("[Migration Info] \(target.key(with: self)) - Processing Interactions") - // Process interactions - print("RAWR [\(Date().timeIntervalSince1970)] - Process interactions - Start") transaction.enumerateKeysAndObjects(inCollection: SMKLegacy.interactionCollection) { _, object, _ in guard let interaction: SMKLegacy._DBInteraction = object as? SMKLegacy._DBInteraction else { SNLog("[Migration Error] Unable to process interaction") @@ -251,10 +197,12 @@ enum _003_YDBToGRDBMigration: Migration { interactions[interaction.uniqueThreadId] = (interactions[interaction.uniqueThreadId] ?? []) .appending(interaction) } - print("RAWR [\(Date().timeIntervalSince1970)] - Process interactions - End") + GRDBStorage.shared.update(progress: 0.19, for: self, in: target) + + // MARK: --Attachments + + SNLog("[Migration Info] \(target.key(with: self)) - Processing Attachments") - // Process attachments - print("RAWR [\(Date().timeIntervalSince1970)] - Process attachments - Start") transaction.enumerateKeysAndObjects(inCollection: SMKLegacy.attachmentsCollection) { key, object, _ in guard let attachment: SMKLegacy._Attachment = object as? SMKLegacy._Attachment else { SNLog("[Migration Error] Unable to process attachment") @@ -264,9 +212,10 @@ enum _003_YDBToGRDBMigration: Migration { attachments[key] = attachment } - print("RAWR [\(Date().timeIntervalSince1970)] - Process attachments - End") + GRDBStorage.shared.update(progress: 0.21, for: self, in: target) + + // MARK: --Read Receipts - // Process read receipts transaction.enumerateKeysAndObjects(inCollection: SMKLegacy.outgoingReadReceiptManagerCollection) { key, object, _ in guard let timestampsMs: Set = object as? Set else { return } @@ -284,6 +233,84 @@ enum _003_YDBToGRDBMigration: Migration { .defaulting(to: []) .asSet() ) + + // MARK: --Jobs + + SNLog("[Migration Info] \(target.key(with: self)) - Processing Jobs") + + transaction.enumerateRows(inCollection: SMKLegacy.notifyPushServerJobCollection) { _, object, _, _ in + guard let job = object as? SMKLegacy._NotifyPNServerJob else { return } + notifyPushServerJobs.insert(job) + } + + transaction.enumerateRows(inCollection: SMKLegacy.messageReceiveJobCollection) { _, object, _, _ in + guard let job = object as? SMKLegacy._MessageReceiveJob else { return } + messageReceiveJobs.insert(job) + } + + transaction.enumerateRows(inCollection: SMKLegacy.messageSendJobCollection) { _, object, _, _ in + guard let job = object as? SMKLegacy._MessageSendJob else { return } + messageSendJobs.insert(job) + } + + transaction.enumerateRows(inCollection: SMKLegacy.attachmentUploadJobCollection) { _, object, _, _ in + guard let job = object as? SMKLegacy._AttachmentUploadJob else { return } + attachmentUploadJobs.insert(job) + } + + transaction.enumerateRows(inCollection: SMKLegacy.attachmentDownloadJobCollection) { _, object, _, _ in + guard let job = object as? SMKLegacy._AttachmentDownloadJob else { return } + attachmentDownloadJobs.insert(job) + } + GRDBStorage.shared.update(progress: 0.22, for: self, in: target) + + // MARK: --Preferences + + SNLog("[Migration Info] \(target.key(with: self)) - Processing Preferences") + + transaction.enumerateKeysAndObjects(inCollection: SMKLegacy.preferencesCollection) { key, object, _ in + legacyPreferences[key] = object + } + + transaction.enumerateKeysAndObjects(inCollection: SMKLegacy.additionalPreferencesCollection) { key, object, _ in + legacyPreferences[key] = object + } + + // Note: The 'int(forKey:inCollection:)' defaults to `0` which is an incorrect value + // for the notification sound so catch it and default + let globalNotificationSoundValue: Int32 = transaction.int( + forKey: SMKLegacy.soundsGlobalNotificationKey, + inCollection: SMKLegacy.soundsStorageNotificationCollection + ) + legacyPreferences[SMKLegacy.soundsGlobalNotificationKey] = (globalNotificationSoundValue > 0 ? + Int(globalNotificationSoundValue) : + Preferences.Sound.defaultNotificationSound.rawValue + ) + + legacyPreferences[SMKLegacy.readReceiptManagerAreReadReceiptsEnabled] = transaction.bool( + forKey: SMKLegacy.readReceiptManagerAreReadReceiptsEnabled, + inCollection: SMKLegacy.readReceiptManagerCollection, + defaultValue: false + ) + + legacyPreferences[SMKLegacy.typingIndicatorsEnabledKey] = transaction.bool( + forKey: SMKLegacy.typingIndicatorsEnabledKey, + inCollection: SMKLegacy.typingIndicatorsCollection, + defaultValue: false + ) + + legacyPreferences[SMKLegacy.screenLockIsScreenLockEnabledKey] = transaction.bool( + forKey: SMKLegacy.screenLockIsScreenLockEnabledKey, + inCollection: SMKLegacy.screenLockCollection, + defaultValue: false + ) + + legacyPreferences[SMKLegacy.screenLockScreenLockTimeoutSecondsKey] = transaction.double( + forKey: SMKLegacy.screenLockScreenLockTimeoutSecondsKey, + inCollection: SMKLegacy.screenLockCollection, + defaultValue: (15 * 60) + ) + GRDBStorage.shared.update(progress: 0.23, for: self, in: target) } // We can't properly throw within the 'enumerateKeysAndObjects' block so have to throw here @@ -295,10 +322,14 @@ enum _003_YDBToGRDBMigration: Migration { // MARK: - Insert Contacts + SNLog("[Migration Info] \(target.key(with: self)) - Inserting Contacts") + try autoreleasepool { - let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) + // Values for contact progress + let contactStartProgress: CGFloat = 0.23 + let progressPerContact: CGFloat = (0.05 / CGFloat(contacts.count)) - try contacts.forEach { legacyContact in + try contacts.enumerated().forEach { index, legacyContact in let isCurrentUser: Bool = (legacyContact.sessionID == currentUserPublicKey) let contactThreadId: String = SMKLegacy._ContactThread.threadId(from: legacyContact.sessionID) @@ -368,12 +399,25 @@ enum _003_YDBToGRDBMigration: Migration { hasBeenBlocked: (!isCurrentUser && (legacyContact.hasBeenBlocked || legacyContact.isBlocked)) ).insert(db) } + + // Increment the progress for each contact + GRDBStorage.shared.update( + progress: contactStartProgress + (progressPerContact * CGFloat(index + 1)), + for: self, + in: target + ) } } + // Clear out processed data (give the memory a change to be freed) + contacts = [] + legacyBlockedSessionIds = [] + contactThreadIds = [] + // MARK: - Insert Threads - print("RAWR [\(Date().timeIntervalSince1970)] - Process thread inserts - Start") + SNLog("[Migration Info] \(target.key(with: self)) - Inserting Threads & Interactions") + var legacyInteractionToIdMap: [String: Int64] = [:] var legacyInteractionIdentifierToIdMap: [String: Int64] = [:] var legacyInteractionIdentifierToIdFallbackMap: [String: Int64] = [:] @@ -411,6 +455,12 @@ enum _003_YDBToGRDBMigration: Migration { .joined(separator: "-") } + // Values for thread progress + var interactionCounter: CGFloat = 0 + let allInteractionsCount: Int = interactions.map { $0.value.count }.reduce(0, +) + let threadInteractionsStartProgress: CGFloat = 0.28 + let progressPerInteraction: CGFloat = (0.70 / CGFloat(allInteractionsCount)) + // Sort by id just so we can make the migration process more determinstic try legacyThreads.sorted(by: { lhs, rhs in lhs.uniqueId < rhs.uniqueId }).forEach { legacyThread in guard let threadId: String = legacyThreadIdToIdMap[legacyThread.uniqueId] else { @@ -921,25 +971,22 @@ enum _003_YDBToGRDBMigration: Migration { attachmentId: attachmentId ).insert(db) } - } + + // Increment the progress for each contact + GRDBStorage.shared.update( + progress: ( + threadInteractionsStartProgress + + (progressPerInteraction * (interactionCounter + 1)) + ), + for: self, + in: target + ) + interactionCounter += 1 + } } } - // Insert a 'ControlMessageProcessRecord' for any remaining 'receivedMessageTimestamp' - // entries as "legacy" - try ControlMessageProcessRecord.generateLegacyProcessRecords( - db, - receivedMessageTimestamps: receivedMessageTimestamps.map { Int64($0) } - ) - - print("RAWR [\(Date().timeIntervalSince1970)] - Process thread inserts - End") - // Clear out processed data (give the memory a change to be freed) - - contacts = [] - legacyBlockedSessionIds = [] - contactThreadIds = [] - legacyThreads = [] disappearingMessagesConfiguration = [:] @@ -957,150 +1004,24 @@ enum _003_YDBToGRDBMigration: Migration { interactions = [:] attachments = [:] + + // MARK: --Received Message Timestamps + + // Insert a 'ControlMessageProcessRecord' for any remaining 'receivedMessageTimestamp' + // entries as "legacy" + try ControlMessageProcessRecord.generateLegacyProcessRecords( + db, + receivedMessageTimestamps: receivedMessageTimestamps.map { Int64($0) } + ) + + // Clear out processed data (give the memory a change to be freed) receivedMessageTimestamps = [] - // MARK: - Process Legacy Jobs - - print("RAWR [\(Date().timeIntervalSince1970)] - Process jobs - Start") - - var notifyPushServerJobs: Set = [] - var messageReceiveJobs: Set = [] - var messageSendJobs: Set = [] - var attachmentUploadJobs: Set = [] - var attachmentDownloadJobs: Set = [] - - // Map the Legacy types for the NSKeyedUnarchiver - NSKeyedUnarchiver.setClass( - SMKLegacy._NotifyPNServerJob.self, - forClassName: "SessionMessagingKit.NotifyPNServerJob" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._NotifyPNServerJob._SnodeMessage.self, - forClassName: "SessionSnodeKit.SnodeMessage" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._MessageSendJob.self, - forClassName: "SessionMessagingKit.SNMessageSendJob" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._MessageReceiveJob.self, - forClassName: "SessionMessagingKit.MessageReceiveJob" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._AttachmentUploadJob.self, - forClassName: "SessionMessagingKit.AttachmentUploadJob" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._AttachmentDownloadJob.self, - forClassName: "SessionMessagingKit.AttachmentDownloadJob" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._Message.self, - forClassName: "SNMessage" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._VisibleMessage.self, - forClassName: "SNVisibleMessage" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._Quote.self, - forClassName: "SNQuote" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._LinkPreview.self, - forClassName: "SNLinkPreview" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._Profile.self, - forClassName: "SNProfile" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._OpenGroupInvitation.self, - forClassName: "SNOpenGroupInvitation" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._ControlMessage.self, - forClassName: "SNControlMessage" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._ReadReceipt.self, - forClassName: "SNReadReceipt" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._TypingIndicator.self, - forClassName: "SNTypingIndicator" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._ClosedGroupControlMessage.self, - forClassName: "SessionMessagingKit.ClosedGroupControlMessage" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._ClosedGroupControlMessage._KeyPairWrapper.self, - forClassName: "ClosedGroupControlMessage.SNKeyPairWrapper" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._DataExtractionNotification.self, - forClassName: "SessionMessagingKit.DataExtractionNotification" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._ExpirationTimerUpdate.self, - forClassName: "SNExpirationTimerUpdate" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._ConfigurationMessage.self, - forClassName: "SNConfigurationMessage" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._CMClosedGroup.self, - forClassName: "SNClosedGroup" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._CMContact.self, - forClassName: "SNConfigurationMessage.SNConfigurationMessageContact" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._UnsendRequest.self, - forClassName: "SNUnsendRequest" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._MessageRequestResponse.self, - forClassName: "SNMessageRequestResponse" - ) - - Storage.read { transaction in - transaction.enumerateRows(inCollection: SMKLegacy.notifyPushServerJobCollection) { _, object, _, _ in - guard let job = object as? SMKLegacy._NotifyPNServerJob else { return } - notifyPushServerJobs.insert(job) - } - - transaction.enumerateRows(inCollection: SMKLegacy.messageReceiveJobCollection) { _, object, _, _ in - guard let job = object as? SMKLegacy._MessageReceiveJob else { return } - messageReceiveJobs.insert(job) - } - - transaction.enumerateRows(inCollection: SMKLegacy.messageSendJobCollection) { _, object, _, _ in - guard let job = object as? SMKLegacy._MessageSendJob else { return } - messageSendJobs.insert(job) - } - - transaction.enumerateRows(inCollection: SMKLegacy.attachmentUploadJobCollection) { _, object, _, _ in - guard let job = object as? SMKLegacy._AttachmentUploadJob else { return } - attachmentUploadJobs.insert(job) - } - - transaction.enumerateRows(inCollection: SMKLegacy.attachmentDownloadJobCollection) { _, object, _, _ in - guard let job = object as? SMKLegacy._AttachmentDownloadJob else { return } - attachmentDownloadJobs.insert(job) - } - } - - print("RAWR [\(Date().timeIntervalSince1970)] - Process jobs - End") - // MARK: - Insert Jobs - print("RAWR [\(Date().timeIntervalSince1970)] - Process job inserts - Start") + SNLog("[Migration Info] \(target.key(with: self)) - Inserting Jobs") - // MARK: - --notifyPushServer + // MARK: --notifyPushServer try autoreleasepool { try notifyPushServerJobs.forEach { legacyJob in @@ -1123,7 +1044,7 @@ enum _003_YDBToGRDBMigration: Migration { } } - // MARK: - --messageReceive + // MARK: --messageReceive try autoreleasepool { try messageReceiveJobs.forEach { legacyJob in @@ -1172,7 +1093,7 @@ enum _003_YDBToGRDBMigration: Migration { } } - // MARK: - --messageSend + // MARK: --messageSend var messageSendJobLegacyMap: [String: Job] = [:] @@ -1266,7 +1187,7 @@ enum _003_YDBToGRDBMigration: Migration { } } - // MARK: - --attachmentUpload + // MARK: --attachmentUpload try autoreleasepool { try attachmentUploadJobs.forEach { legacyJob in @@ -1300,7 +1221,7 @@ enum _003_YDBToGRDBMigration: Migration { } } - // MARK: - --attachmentDownload + // MARK: --attachmentDownload try autoreleasepool { try attachmentDownloadJobs.forEach { legacyJob in @@ -1327,7 +1248,7 @@ enum _003_YDBToGRDBMigration: Migration { } } - // MARK: - --sendReadReceipts + // MARK: --sendReadReceipts try autoreleasepool { try outgoingReadReceiptsTimestampsMs.forEach { threadId, timestampsMs in @@ -1342,59 +1263,11 @@ enum _003_YDBToGRDBMigration: Migration { )?.inserted(db) } } + GRDBStorage.shared.update(progress: 0.99, for: self, in: target) - print("RAWR [\(Date().timeIntervalSince1970)] - Process job inserts - End") + // MARK: - Preferences - // MARK: - Process Preferences - - print("RAWR [\(Date().timeIntervalSince1970)] - Process preferences inserts - Start") - - var legacyPreferences: [String: Any] = [:] - - Storage.read { transaction in - transaction.enumerateKeysAndObjects(inCollection: SMKLegacy.preferencesCollection) { key, object, _ in - legacyPreferences[key] = object - } - - transaction.enumerateKeysAndObjects(inCollection: SMKLegacy.additionalPreferencesCollection) { key, object, _ in - legacyPreferences[key] = object - } - - // Note: The 'int(forKey:inCollection:)' defaults to `0` which is an incorrect value - // for the notification sound so catch it and default - let globalNotificationSoundValue: Int32 = transaction.int( - forKey: SMKLegacy.soundsGlobalNotificationKey, - inCollection: SMKLegacy.soundsStorageNotificationCollection - ) - legacyPreferences[SMKLegacy.soundsGlobalNotificationKey] = (globalNotificationSoundValue > 0 ? - Int(globalNotificationSoundValue) : - Preferences.Sound.defaultNotificationSound.rawValue - ) - - legacyPreferences[SMKLegacy.readReceiptManagerAreReadReceiptsEnabled] = transaction.bool( - forKey: SMKLegacy.readReceiptManagerAreReadReceiptsEnabled, - inCollection: SMKLegacy.readReceiptManagerCollection, - defaultValue: false - ) - - legacyPreferences[SMKLegacy.typingIndicatorsEnabledKey] = transaction.bool( - forKey: SMKLegacy.typingIndicatorsEnabledKey, - inCollection: SMKLegacy.typingIndicatorsCollection, - defaultValue: false - ) - - legacyPreferences[SMKLegacy.screenLockIsScreenLockEnabledKey] = transaction.bool( - forKey: SMKLegacy.screenLockIsScreenLockEnabledKey, - inCollection: SMKLegacy.screenLockCollection, - defaultValue: false - ) - - legacyPreferences[SMKLegacy.screenLockScreenLockTimeoutSecondsKey] = transaction.double( - forKey: SMKLegacy.screenLockScreenLockTimeoutSecondsKey, - inCollection: SMKLegacy.screenLockCollection, - defaultValue: (15 * 60) - ) - } + SNLog("[Migration Info] \(target.key(with: self)) - Inserting Preferences") db[.defaultNotificationSound] = Preferences.Sound(rawValue: legacyPreferences[SMKLegacy.soundsGlobalNotificationKey] as? Int ?? -1) .defaulting(to: Preferences.Sound.defaultNotificationSound) @@ -1425,10 +1298,6 @@ enum _003_YDBToGRDBMigration: Migration { db[.hasSavedThread] = (legacyPreferences[SMKLegacy.preferencesKeyHasSavedThreadKey] as? Bool == true) db[.hasSentAMessage] = (legacyPreferences[SMKLegacy.preferencesKeyHasSentAMessageKey] as? Bool == true) db[.isReadyForAppExtensions] = CurrentAppContext().appUserDefaults().bool(forKey: SMKLegacy.preferencesKeyIsReadyForAppExtensions) - - print("RAWR [\(Date().timeIntervalSince1970)] - Process preferences inserts - End") - - print("RAWR Done!!!") } // MARK: - Convenience @@ -1582,4 +1451,179 @@ enum _003_YDBToGRDBMigration: Migration { return legacyAttachmentId } + + private static func mapLegacyTypesForNSKeyedUnarchiver() { + NSKeyedUnarchiver.setClass( + SMKLegacy._Thread.self, + forClassName: "TSThread" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._ContactThread.self, + forClassName: "TSContactThread" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._GroupThread.self, + forClassName: "TSGroupThread" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._GroupModel.self, + forClassName: "TSGroupModel" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._Contact.self, + forClassName: "SNContact" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._DBInteraction.self, + forClassName: "TSInteraction" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._DBMessage.self, + forClassName: "TSMessage" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._DBQuotedMessage.self, + forClassName: "TSQuotedMessage" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._DBQuotedMessage._DBAttachmentInfo.self, + forClassName: "OWSAttachmentInfo" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._DBLinkPreview.self, + forClassName: "SessionServiceKit.OWSLinkPreview" // Very old legacy name + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._DBLinkPreview.self, + forClassName: "SessionMessagingKit.OWSLinkPreview" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._DBIncomingMessage.self, + forClassName: "TSIncomingMessage" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._DBOutgoingMessage.self, + forClassName: "TSOutgoingMessage" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._DBOutgoingMessageRecipientState.self, + forClassName: "TSOutgoingMessageRecipientState" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._DBInfoMessage.self, + forClassName: "TSInfoMessage" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._DisappearingConfigurationUpdateInfoMessage.self, + forClassName: "OWSDisappearingConfigurationUpdateInfoMessage" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._Attachment.self, + forClassName: "TSAttachment" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._AttachmentStream.self, + forClassName: "TSAttachmentStream" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._AttachmentPointer.self, + forClassName: "TSAttachmentPointer" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._NotifyPNServerJob.self, + forClassName: "SessionMessagingKit.NotifyPNServerJob" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._NotifyPNServerJob._SnodeMessage.self, + forClassName: "SessionSnodeKit.SnodeMessage" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._MessageSendJob.self, + forClassName: "SessionMessagingKit.SNMessageSendJob" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._MessageReceiveJob.self, + forClassName: "SessionMessagingKit.MessageReceiveJob" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._AttachmentUploadJob.self, + forClassName: "SessionMessagingKit.AttachmentUploadJob" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._AttachmentDownloadJob.self, + forClassName: "SessionMessagingKit.AttachmentDownloadJob" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._Message.self, + forClassName: "SNMessage" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._VisibleMessage.self, + forClassName: "SNVisibleMessage" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._Quote.self, + forClassName: "SNQuote" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._LinkPreview.self, + forClassName: "SNLinkPreview" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._Profile.self, + forClassName: "SNProfile" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._OpenGroupInvitation.self, + forClassName: "SNOpenGroupInvitation" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._ControlMessage.self, + forClassName: "SNControlMessage" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._ReadReceipt.self, + forClassName: "SNReadReceipt" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._TypingIndicator.self, + forClassName: "SNTypingIndicator" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._ClosedGroupControlMessage.self, + forClassName: "SessionMessagingKit.ClosedGroupControlMessage" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._ClosedGroupControlMessage._KeyPairWrapper.self, + forClassName: "ClosedGroupControlMessage.SNKeyPairWrapper" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._DataExtractionNotification.self, + forClassName: "SessionMessagingKit.DataExtractionNotification" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._ExpirationTimerUpdate.self, + forClassName: "SNExpirationTimerUpdate" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._ConfigurationMessage.self, + forClassName: "SNConfigurationMessage" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._CMClosedGroup.self, + forClassName: "SNClosedGroup" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._CMContact.self, + forClassName: "SNConfigurationMessage.SNConfigurationMessageContact" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._UnsendRequest.self, + forClassName: "SNUnsendRequest" + ) + NSKeyedUnarchiver.setClass( + SMKLegacy._MessageRequestResponse.self, + forClassName: "SNMessageRequestResponse" + ) + } } diff --git a/SessionMessagingKit/Database/Models/RecipientState.swift b/SessionMessagingKit/Database/Models/RecipientState.swift index e56c3f05d..01bb0a1b0 100644 --- a/SessionMessagingKit/Database/Models/RecipientState.swift +++ b/SessionMessagingKit/Database/Models/RecipientState.swift @@ -122,7 +122,6 @@ public extension RecipientState { public extension RecipientState { static func selectInteractionState(tableLiteral: SQL, idColumnLiteral: SQL) -> SQL { - let interaction: TypedTableAlias = TypedTableAlias() let recipientState: TypedTableAlias = TypedTableAlias() return """ @@ -132,7 +131,6 @@ public extension RecipientState { \(recipientState[.state]), \(recipientState[.mostRecentFailureText]) FROM \(RecipientState.self) - JOIN \(Interaction.self) ON \(interaction[.id]) = \(recipientState[.interactionId]) WHERE \(SQL("\(recipientState[.state]) != \(RecipientState.State.skipped)")) -- Ignore 'skipped' ORDER BY -- If there is a single 'sending' then should be 'sending', otherwise if there is a single diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index 48c643611..c2f517965 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -209,22 +209,46 @@ public extension SessionThread { // MARK: - Convenience public extension SessionThread { + static func unreadMessageRequestsCountQuery(userPublicKey: String) -> SQLRequest { + let thread: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() + let interaction: TypedTableAlias = TypedTableAlias() + + let unreadInteractionLiteral: SQL = SQL(stringLiteral: "unreadInteraction") + let interactionThreadIdColumnLiteral: SQL = SQL(stringLiteral: Interaction.Columns.threadId.name) + + return """ + SELECT COUNT(\(thread[.id])) + FROM \(SessionThread.self) + JOIN ( + SELECT \(interaction[.threadId]) + FROM \(Interaction.self) + WHERE \(interaction[.wasRead]) = false + GROUP BY \(interaction[.threadId]) + ) AS \(unreadInteractionLiteral) ON \(unreadInteractionLiteral).\(interactionThreadIdColumnLiteral) = \(thread[.id]) + LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) + WHERE ( + \(SessionThread.isMessageRequest(userPublicKey: userPublicKey)) + ) + """ + } + /// This method can be used to filter a thread query to only include messages requests /// /// **Note:** In order to use this filter you **MUST** have a `joining(required/optional:)` to the /// `SessionThread.contact` association or it won't work static func isMessageRequest(userPublicKey: String) -> SQLSpecificExpressible { - let threadAlias: TypedTableAlias = TypedTableAlias() - let contactAlias: TypedTableAlias = TypedTableAlias() + let thread: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() return SQL( """ - \(threadAlias[.shouldBeVisible]) = true AND - \(SQL("\(threadAlias[.variant]) = \(SessionThread.Variant.contact)")) AND - \(SQL("\(threadAlias[.id]) != \(userPublicKey)")) AND ( + \(thread[.shouldBeVisible]) = true AND + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND + \(SQL("\(thread[.id]) != \(userPublicKey)")) AND ( /* Note: A '!= true' check doesn't work properly so we need to be explicit */ - \(contactAlias[.isApproved]) IS NULL OR - \(contactAlias[.isApproved]) = false + \(contact[.isApproved]) IS NULL OR + \(contact[.isApproved]) = false ) """ ) diff --git a/SessionMessagingKit/Database/Models/ThreadTypingIndicator.swift b/SessionMessagingKit/Database/Models/ThreadTypingIndicator.swift index 43fed0f54..bad5e96dd 100644 --- a/SessionMessagingKit/Database/Models/ThreadTypingIndicator.swift +++ b/SessionMessagingKit/Database/Models/ThreadTypingIndicator.swift @@ -14,7 +14,7 @@ public struct ThreadTypingIndicator: Codable, FetchableRecord, PersistableRecord private static let thread = belongsTo(SessionThread.self, using: threadForeignKey) public typealias Columns = CodingKeys - public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + public enum CodingKeys: String, CodingKey, ColumnExpression { case threadId case timestampMs } diff --git a/SessionMessagingKit/Meta/SessionMessagingKit.h b/SessionMessagingKit/Meta/SessionMessagingKit.h index 262399fb4..6006d21c1 100644 --- a/SessionMessagingKit/Meta/SessionMessagingKit.h +++ b/SessionMessagingKit/Meta/SessionMessagingKit.h @@ -4,7 +4,6 @@ FOUNDATION_EXPORT double SessionMessagingKitVersionNumber; FOUNDATION_EXPORT const unsigned char SessionMessagingKitVersionString[]; #import -#import #import #import #import diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift index 1d0e64eb0..e94e8d9ab 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift @@ -6,9 +6,7 @@ import PromiseKit import Sodium import SessionSnodeKit -@objc(LKPoller) -public final class Poller : NSObject { - private let storage = OWSPrimaryStorage.shared() +public final class Poller { private var isPolling: Atomic = Atomic(false) private var usedSnodes = Set() private var pollCount = 0 @@ -27,7 +25,7 @@ public final class Poller : NSObject { // MARK: - Error - private enum Error : LocalizedError { + private enum Error: LocalizedError { case pollLimitReached var localizedDescription: String { @@ -39,7 +37,9 @@ public final class Poller : NSObject { // MARK: - Public API - @objc public func startIfNeeded() { + public init() {} + + public func startIfNeeded() { guard !isPolling.wrappedValue else { return } SNLog("Started polling.") @@ -47,7 +47,7 @@ public final class Poller : NSObject { setUpPolling() } - @objc public func stop() { + public func stop() { SNLog("Stopped polling.") isPolling.mutate { $0 = false } usedSnodes.removeAll() diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index cf780cdcb..e7e5ea9e4 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -7,6 +7,7 @@ import SessionUtilitiesKit fileprivate typealias ViewModel = MessageViewModel fileprivate typealias AttachmentInteractionInfo = MessageViewModel.AttachmentInteractionInfo +fileprivate typealias TypingIndicatorInfo = MessageViewModel.TypingIndicatorInfo public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, Hashable, Identifiable, Differentiable { public static let threadVariantKey: SQL = SQL(stringLiteral: CodingKeys.threadVariant.stringValue) @@ -384,9 +385,28 @@ public extension MessageViewModel { } } +// MARK: - TypingIndicatorInfo + +public extension MessageViewModel { + struct TypingIndicatorInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable { + public static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue) + public static let threadIdKey: SQL = SQL(stringLiteral: CodingKeys.threadId.stringValue) + + public let rowId: Int64 + public let threadId: String + + // MARK: - Identifiable + + public var id: String { threadId } + } +} + // MARK: - Convenience Initialization public extension MessageViewModel { + public static let genericId: Int64 = -2 + public static let typingIndicatorId: Int64 = -2 + // Note: This init method is only used system-created cells or empty states init(isTypingIndicator: Bool = false) { self.threadVariant = .contact @@ -395,8 +415,9 @@ public extension MessageViewModel { // Interaction Info - self.rowId = -1 - self.id = -1 + let targetId: Int64 = (isTypingIndicator ? MessageViewModel.typingIndicatorId : MessageViewModel.genericId) + self.rowId = targetId + self.id = targetId self.variant = .standardOutgoing self.timestampMs = Int64.max self.authorId = "" @@ -473,6 +494,8 @@ extension MessageViewModel { // MARK: - ConversationVC +// MARK: --MessageViewModel + public extension MessageViewModel { static func filterSQL(threadId: String) -> SQL { let interaction: TypedTableAlias = TypedTableAlias() @@ -612,6 +635,8 @@ public extension MessageViewModel { } } +// MARK: --AttachmentInteractionInfo + public extension MessageViewModel.AttachmentInteractionInfo { static let baseQuery: ((SQL?) -> AdaptedFetchRequest>) = { return { additionalFilters -> AdaptedFetchRequest> in @@ -697,3 +722,51 @@ public extension MessageViewModel.AttachmentInteractionInfo { } } } + +// MARK: --TypingIndicatorInfo + +public extension MessageViewModel.TypingIndicatorInfo { + static let baseQuery: ((SQL?) -> SQLRequest) = { + return { additionalFilters -> SQLRequest in + let threadTypingIndicator: TypedTableAlias = TypedTableAlias() + let finalFilterSQL: SQL = { + guard let additionalFilters: SQL = additionalFilters else { + return SQL(stringLiteral: "") + } + + return """ + WHERE \(additionalFilters) + """ + }() + let request: SQLRequest = """ + SELECT + \(threadTypingIndicator.alias[Column.rowID]) AS \(MessageViewModel.TypingIndicatorInfo.rowIdKey), + \(threadTypingIndicator[.threadId]) AS \(MessageViewModel.TypingIndicatorInfo.threadIdKey) + FROM \(ThreadTypingIndicator.self) + \(finalFilterSQL) + """ + + return request + } + }() + + static var joinToViewModelQuerySQL: SQL = { + let interaction: TypedTableAlias = TypedTableAlias() + let threadTypingIndicator: TypedTableAlias = TypedTableAlias() + + return """ + JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(threadTypingIndicator[.threadId]) + """ + }() + + static func createAssociateDataClosure() -> (DataCache, DataCache) -> DataCache { + return { dataCache, pagedDataCache -> DataCache in + guard !dataCache.data.isEmpty else { + return pagedDataCache.deleting(rowIds: [MessageViewModel.typingIndicatorId]) + } + + return pagedDataCache + .upserting(MessageViewModel(isTypingIndicator: true)) + } + } +} diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index a49d37007..29c9e9361 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -264,7 +264,6 @@ public extension SessionThreadViewModel { let interaction: TypedTableAlias = TypedTableAlias() let linkPreview: TypedTableAlias = TypedTableAlias() let interactionAttachment: TypedTableAlias = TypedTableAlias() - let attachment: TypedTableAlias = TypedTableAlias() let unreadCountTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.threadUnreadCountString)_table") let unreadMentionCountTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.threadUnreadMentionCountString)_table") @@ -274,6 +273,9 @@ public extension SessionThreadViewModel { let profileNameColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.name.name) let authorProfileLiteral: SQL = SQL(stringLiteral: "authorProfile") let attachmentIdColumnLiteral: SQL = SQL(stringLiteral: Attachment.Columns.id.name) + let attachmentVariantColumnLiteral: SQL = SQL(stringLiteral: Attachment.Columns.variant.name) + let attachmentContentTypeColumnLiteral: SQL = SQL(stringLiteral: Attachment.Columns.contentType.name) + let attachmentSourceFilenameColumnLiteral: SQL = SQL(stringLiteral: Attachment.Columns.sourceFilename.name) let firstInteractionAttachmentLiteral: SQL = SQL(stringLiteral: "firstInteractionAttachment") let interactionAttachmentAttachmentIdColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.attachmentId.name) let interactionAttachmentInteractionIdColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.interactionId.name) @@ -288,8 +290,9 @@ public extension SessionThreadViewModel { /// /// Explicitly set default values for the fields ignored for search results let numColumnsBeforeProfiles: Int = 11 - let numColumnsBetweenProfilesAndAttachmentInfo: Int = 10 + let numColumnsBetweenProfilesAndAttachmentInfo: Int = 10 // The attachment info columns will be combined // TODO: Some testing around the subqueries in the joins to see if they impact performance ('Simulator1' device takes ~125ms to complete this query) + let request: SQLRequest = """ SELECT \(thread[.id]) AS \(ViewModel.threadIdKey), @@ -322,7 +325,10 @@ public extension SessionThreadViewModel { -- Default to 'sending' assuming non-processed interaction when null IFNULL(\(interactionStateTableLiteral).\(interactionStateStateColumnLiteral), \(SQL("\(RecipientState.State.sending)"))) AS \(interactionStateTableLiteral), (\(linkPreview[.url]) IS NOT NULL) AS \(ViewModel.interactionIsOpenGroupInvitationKey), - \(ViewModel.interactionAttachmentDescriptionInfoKey).*, + \(ViewModel.interactionAttachmentDescriptionInfoKey).\(attachmentIdColumnLiteral), + \(ViewModel.interactionAttachmentDescriptionInfoKey).\(attachmentVariantColumnLiteral), + \(ViewModel.interactionAttachmentDescriptionInfoKey).\(attachmentContentTypeColumnLiteral), + \(ViewModel.interactionAttachmentDescriptionInfoKey).\(attachmentSourceFilenameColumnLiteral), COUNT(\(interactionAttachment[.interactionId])) AS \(ViewModel.interactionAttachmentCountKey), \(interaction[.authorId]), @@ -406,14 +412,7 @@ public extension SessionThreadViewModel { \(firstInteractionAttachmentLiteral).\(interactionAttachmentAlbumIndexColumnLiteral) = 0 AND \(firstInteractionAttachmentLiteral).\(interactionAttachmentInteractionIdColumnLiteral) = \(interaction[.id]) ) - LEFT JOIN ( - SELECT - \(attachment[.id]), - \(attachment[.variant]), - \(attachment[.contentType]), - \(attachment[.sourceFilename]) - FROM \(Attachment.self) - ) AS \(ViewModel.interactionAttachmentDescriptionInfoKey) ON \(ViewModel.interactionAttachmentDescriptionInfoKey).\(attachmentIdColumnLiteral) = \(firstInteractionAttachmentLiteral).\(interactionAttachmentAttachmentIdColumnLiteral) + LEFT JOIN \(Attachment.self) AS \(ViewModel.interactionAttachmentDescriptionInfoKey) ON \(ViewModel.interactionAttachmentDescriptionInfoKey).\(attachmentIdColumnLiteral) = \(firstInteractionAttachmentLiteral).\(interactionAttachmentAttachmentIdColumnLiteral) LEFT JOIN ( \(RecipientState.selectInteractionState( tableLiteral: interactionStateTableLiteral, @@ -506,7 +505,6 @@ public extension SessionThreadViewModel { static func conversationQuery(threadId: String, userPublicKey: String) -> AdaptedFetchRequest> { let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() - let typingIndicator: TypedTableAlias = TypedTableAlias() // TODO: Remove this (not needed here - tracked via the messages) let closedGroup: TypedTableAlias = TypedTableAlias() let groupMember: TypedTableAlias = TypedTableAlias() let openGroup: TypedTableAlias = TypedTableAlias() @@ -526,7 +524,7 @@ public extension SessionThreadViewModel { /// parse and might throw /// /// Explicitly set default values for the fields ignored for search results - let numColumnsBeforeProfiles: Int = 16 + let numColumnsBeforeProfiles: Int = 15 let request: SQLRequest = """ SELECT \(thread[.id]) AS \(ViewModel.threadIdKey), @@ -557,7 +555,6 @@ public extension SessionThreadViewModel { \(thread[.onlyNotifyForMentions]) AS \(ViewModel.threadOnlyNotifyForMentionsKey), \(thread[.messageDraft]) AS \(ViewModel.threadMessageDraftKey), - (\(typingIndicator[.threadId]) IS NOT NULL) AS \(ViewModel.threadContactIsTypingKey), \(unreadCountTableLiteral).\(ViewModel.threadUnreadCountKey) AS \(ViewModel.threadUnreadCountKey), \(unreadMentionCountTableLiteral).\(ViewModel.threadUnreadMentionCountKey) AS \(ViewModel.threadUnreadMentionCountKey), \(firstUnreadInteractionTableLiteral).\(interactionIdLiteral) AS \(ViewModel.threadFirstUnreadInteractionIdKey), @@ -578,7 +575,6 @@ public extension SessionThreadViewModel { FROM \(SessionThread.self) LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) - LEFT JOIN \(ThreadTypingIndicator.self) ON \(typingIndicator[.threadId]) = \(thread[.id]) LEFT JOIN ( SELECT \(interaction[.id]), @@ -870,7 +866,7 @@ public extension SessionThreadViewModel { \(ViewModel.closedGroupProfileBackFallbackKey).\(profileIdColumnLiteral) = \(userPublicKey) ) - ORDER BY \(Column.rank) + ORDER BY \(Column.rank), \(interaction[.timestampMs].desc) """ return request.adapted { db in diff --git a/SessionMessagingKit/Utilities/OWSWindowManager.m b/SessionMessagingKit/Utilities/OWSWindowManager.m index c1fa33d4f..a1ddc0f2a 100644 --- a/SessionMessagingKit/Utilities/OWSWindowManager.m +++ b/SessionMessagingKit/Utilities/OWSWindowManager.m @@ -3,7 +3,6 @@ // #import "OWSWindowManager.h" -#import "Environment.h" #import #import diff --git a/SessionShareExtension/Meta/SignalShareExtension-Bridging-Header.h b/SessionShareExtension/Meta/SignalShareExtension-Bridging-Header.h index 282ed4347..5cf67e263 100644 --- a/SessionShareExtension/Meta/SignalShareExtension-Bridging-Header.h +++ b/SessionShareExtension/Meta/SignalShareExtension-Bridging-Header.h @@ -9,12 +9,10 @@ #import #import #import -#import #import #import #import #import -#import #import #import #import diff --git a/SessionShareExtension/ThreadPickerViewModel.swift b/SessionShareExtension/ThreadPickerViewModel.swift index b3090120e..93035647f 100644 --- a/SessionShareExtension/ThreadPickerViewModel.swift +++ b/SessionShareExtension/ThreadPickerViewModel.swift @@ -15,6 +15,11 @@ public class ThreadPickerViewModel { /// /// **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 { db -> [SessionThreadViewModel] in let userPublicKey: String = getUserHexEncodedPublicKey(db) diff --git a/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift index d8b29ae3c..f807c07b1 100644 --- a/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -5,9 +5,10 @@ import GRDB import SessionUtilitiesKit enum _001_InitialSetupMigration: Migration { + static let target: TargetMigrations.Identifier = .snodeKit static let identifier: String = "initialSetup" - static let minExpectedRunDuration: TimeInterval = 0.1 static let needsConfigSync: Bool = false + static let minExpectedRunDuration: TimeInterval = 0.1 static func migrate(_ db: Database) throws { try db.create(table: Snode.self) { t in diff --git a/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift index 145cc544b..ad4037ecf 100644 --- a/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -7,9 +7,10 @@ import SessionUtilitiesKit /// This migration sets up the standard jobs, since we want these jobs to run before any "once-off" jobs we do this migration /// before running the `YDBToGRDBMigration` enum _002_SetupStandardJobs: Migration { + static let target: TargetMigrations.Identifier = .snodeKit static let identifier: String = "SetupStandardJobs" - static let minExpectedRunDuration: TimeInterval = 0.1 static let needsConfigSync: Bool = false + static let minExpectedRunDuration: TimeInterval = 0.1 static func migrate(_ db: Database) throws { try autoreleasepool { diff --git a/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 0896fa6a5..6f2242741 100644 --- a/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -5,17 +5,29 @@ import GRDB import SessionUtilitiesKit enum _003_YDBToGRDBMigration: Migration { + static let target: TargetMigrations.Identifier = .snodeKit static let identifier: String = "YDBToGRDBMigration" - static let minExpectedRunDuration: TimeInterval = 0.2 static let needsConfigSync: Bool = false + /// This migration can take a while if it's a very large database or there are lots of closed groups (want this to account + /// for about 10% of the progress bar so we intentionally have a higher `minExpectedRunDuration` so show more + /// progress during the migration) + static let minExpectedRunDuration: TimeInterval = 2.0 + static func migrate(_ db: Database) throws { - // MARK: - OnionRequestPath, Snode Pool & Swarm + guard let dbConnection: YapDatabaseConnection = SUKLegacy.newDatabaseConnection() else { + SNLog("[Migration Warning] No legacy database, skipping \(target.key(with: self))") + return + } + + // MARK: - Read from Legacy Database // Note: Want to exclude the Snode's we already added from the 'onionRequestPathResult' var snodeResult: Set = [] var snodeSetResult: [String: Set] = [:] var lastSnodePoolRefreshDate: Date? = nil + var lastMessageResults: [String: (hash: String, json: JSON)] = [:] + var receivedMessageResults: [String: Set] = [:] // Map the Legacy types for the NSKeyedUnarchiver NSKeyedUnarchiver.setClass( @@ -23,14 +35,16 @@ enum _003_YDBToGRDBMigration: Migration { forClassName: "SessionSnodeKit.Snode" ) - Storage.read { transaction in - // Process the lastSnodePoolRefreshDate + dbConnection.read { transaction in + // MARK: --lastSnodePoolRefreshDate + lastSnodePoolRefreshDate = transaction.object( forKey: SSKLegacy.lastSnodePoolRefreshDateKey, inCollection: SSKLegacy.lastSnodePoolRefreshDateCollection ) as? Date - // Process the OnionRequestPaths + // MARK: --OnionRequestPaths + if let path0Snode0 = transaction.object(forKey: "0-0", inCollection: SSKLegacy.onionRequestPathCollection) as? SSKLegacy.Snode, let path0Snode1 = transaction.object(forKey: "0-1", inCollection: SSKLegacy.onionRequestPathCollection) as? SSKLegacy.Snode, @@ -52,21 +66,44 @@ enum _003_YDBToGRDBMigration: Migration { snodeSetResult["\(SnodeSet.onionRequestPathPrefix)1"] = [ path1Snode0, path1Snode1, path1Snode2 ] } } + GRDBStorage.shared.update(progress: 0.02, for: self, in: target) + + // MARK: --SnodePool - // Process the SnodePool transaction.enumerateKeysAndObjects(inCollection: SSKLegacy.snodePoolCollection) { _, object, _ in guard let snode = object as? SSKLegacy.Snode else { return } snodeResult.insert(snode) } - // Process the Swarms - var swarmCollections: Set = [] + // MARK: --Swarms + // Note: There is no index on the collection column so unfortunately it takes the same amount of + // time to enumerate through all collections as it does to just get the count of collections, as + // a result if the database is very large this part can be slow (~15s with 2,000,000 rows) - we + // want to show some kind of progress while doing this enumeration so the below code includes a + // number of rough values to show some kind of progression while the enumeration occurs (most users + // won't run into issues with this at all) + var swarmCollections: Set = [] + let startProgress: CGFloat = 0.02 + let swarmCompleteProgress: CGFloat = 0.90 + let interEnumerationMaxProgress: CGFloat = ((swarmCompleteProgress - startProgress) * 0.8) + let maxCollectionsEstimate: CGFloat = 1000 + let numCollectionsToTriggerProgressUpdate: CGFloat = 20 + var collectionIndex: CGFloat = 0 + var oldProgress: CGFloat = startProgress transaction.enumerateCollections { collectionName, _ in if collectionName.starts(with: SSKLegacy.swarmCollectionPrefix) { swarmCollections.insert(collectionName.substring(from: SSKLegacy.swarmCollectionPrefix.count)) } + + collectionIndex += 1 + + if collectionIndex.truncatingRemainder(dividingBy: numCollectionsToTriggerProgressUpdate) == 0 { + oldProgress = (startProgress + (interEnumerationMaxProgress * (collectionIndex / maxCollectionsEstimate))) + GRDBStorage.shared.update(progress: oldProgress, for: self, in: target) + } } + GRDBStorage.shared.update(progress: swarmCompleteProgress, for: self, in: target) for swarmCollection in swarmCollections { let collection: String = "\(SSKLegacy.swarmCollectionPrefix)\(swarmCollection)" @@ -77,13 +114,39 @@ enum _003_YDBToGRDBMigration: Migration { snodeSetResult[swarmCollection] = (snodeSetResult[swarmCollection] ?? Set()).inserting(snode) } } + GRDBStorage.shared.update(progress: 0.92, for: self, in: target) + + // MARK: --Received message hashes + + transaction.enumerateKeysAndObjects(inCollection: SSKLegacy.receivedMessagesCollection) { key, object, _ in + guard let hashSet = object as? Set else { return } + receivedMessageResults[key] = hashSet + } + GRDBStorage.shared.update(progress: 0.93, for: self, in: target) + + // MARK: --Last message info + + transaction.enumerateKeysAndObjects(inCollection: SSKLegacy.lastMessageHashCollection) { key, object, _ in + guard let lastMessageJson = object as? JSON else { return } + guard let lastMessageHash: String = lastMessageJson["hash"] as? String else { return } + + // Note: We remove the value from 'receivedMessageResults' as we want to try and use + // it's actual 'expirationDate' value + lastMessageResults[key] = (lastMessageHash, lastMessageJson) + receivedMessageResults[key] = receivedMessageResults[key]?.removing(lastMessageHash) + } + GRDBStorage.shared.update(progress: 0.94, for: self, in: target) } - // Insert the data into GRDB + // MARK: - Insert into GRDB try autoreleasepool { + // MARK: --lastSnodePoolRefreshDate + db[.lastSnodePoolRefreshDate] = lastSnodePoolRefreshDate + // MARK: --SnodePool + try snodeResult.forEach { legacySnode in try Snode( address: legacySnode.address, @@ -92,6 +155,9 @@ enum _003_YDBToGRDBMigration: Migration { x25519PublicKey: legacySnode.publicKeySet.x25519Key ).insert(db) } + GRDBStorage.shared.update(progress: 0.96, for: self, in: target) + + // MARK: --SnodeSets try snodeSetResult.forEach { key, legacySnodeSet in try legacySnodeSet.enumerated().forEach { nodeIndex, legacySnode in @@ -104,34 +170,12 @@ enum _003_YDBToGRDBMigration: Migration { ).insert(db) } } + GRDBStorage.shared.update(progress: 0.98, for: self, in: target) } - // MARK: - Received Messages & Last Message Hash - - var lastMessageResults: [String: (hash: String, json: JSON)] = [:] - var receivedMessageResults: [String: Set] = [:] - - // TODO: Move into the top read block??? - Storage.read { transaction in - // Extract the received message hashes - transaction.enumerateKeysAndObjects(inCollection: SSKLegacy.receivedMessagesCollection) { key, object, _ in - guard let hashSet = object as? Set else { return } - receivedMessageResults[key] = hashSet - } - - // Retrieve the last message info - transaction.enumerateKeysAndObjects(inCollection: SSKLegacy.lastMessageHashCollection) { key, object, _ in - guard let lastMessageJson = object as? JSON else { return } - guard let lastMessageHash: String = lastMessageJson["hash"] as? String else { return } - - // Note: We remove the value from 'receivedMessageResults' as we want to try and use - // it's actual 'expirationDate' value - lastMessageResults[key] = (lastMessageHash, lastMessageJson) - receivedMessageResults[key] = receivedMessageResults[key]?.removing(lastMessageHash) - } - } - try autoreleasepool { + // MARK: --Received Messages + try receivedMessageResults.forEach { key, hashes in try hashes.forEach { hash in _ = try SnodeReceivedMessageInfo( @@ -141,6 +185,9 @@ enum _003_YDBToGRDBMigration: Migration { ).inserted(db) } } + GRDBStorage.shared.update(progress: 0.99, for: self, in: target) + + // MARK: --Last Message Hash try lastMessageResults.forEach { key, data in let expirationDateMs: Int64 = ((data.json["expirationDate"] as? Int64) ?? 0) diff --git a/SessionUtilitiesKit/Database/GRDBStorage.swift b/SessionUtilitiesKit/Database/GRDBStorage.swift index 305cdedf4..4009d7b3c 100644 --- a/SessionUtilitiesKit/Database/GRDBStorage.swift +++ b/SessionUtilitiesKit/Database/GRDBStorage.swift @@ -170,6 +170,7 @@ public final class GRDBStorage { self.migrator?.asyncMigrate(dbPool) { [weak self] _, error in self?.hasCompletedMigrations = true self?.migrationProgressUpdater = nil + SUKLegacy.clearLegacyDatabaseInstance() onComplete((error == nil), needsConfigSync) } diff --git a/SessionUtilitiesKit/Database/LegacyDatabase/SUKLegacy.swift b/SessionUtilitiesKit/Database/LegacyDatabase/SUKLegacy.swift index 1bd4f7da4..a691334df 100644 --- a/SessionUtilitiesKit/Database/LegacyDatabase/SUKLegacy.swift +++ b/SessionUtilitiesKit/Database/LegacyDatabase/SUKLegacy.swift @@ -1,8 +1,17 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import YapDatabase public enum SUKLegacy { + // MARK: - YapDatabase + + private static let keychainService = "TSKeyChainService" + private static let keychainDBCipherKeySpec = "OWSDatabaseCipherKeySpec" + private static let sqlCipherKeySpecLength = 48 + + private static var database: Atomic? + // MARK: - Collections and Keys internal static let userAccountRegisteredNumberKey = "TSStorageRegisteredNumberKey" @@ -14,6 +23,103 @@ public enum SUKLegacy { internal static let identityKeyStoreIdentityKey = "TSStorageManagerIdentityKeyStoreIdentityKey" internal static let identityKeyStoreCollection = "TSStorageManagerIdentityKeyStoreCollection" + // MARK: - Database Functions + + private static var legacyDatabaseFilepath: String { + let sharedDirUrl: URL = URL(fileURLWithPath: OWSFileSystem.appSharedDataDirectoryPath()) + + return sharedDirUrl + .appendingPathComponent("database") + .appendingPathComponent("Signal.sqlite") + .path + } + + private static let legacyDatabaseDeserializer: YapDatabaseDeserializer = { + return { (collection: String, key: String, data: Data) -> Any in + /// **Note:** The old `init(forReadingWith:)` method has been deprecated with `init(forReadingFrom:)` + /// and Apple changed the default of `requiresSecureCoding` to be true, this results in some of the types from failing + /// to decode, as a result we need to set it to false here + let unarchiver: NSKeyedUnarchiver? = try? NSKeyedUnarchiver(forReadingFrom: data) + unarchiver?.requiresSecureCoding = false + + guard !data.isEmpty, let result = unarchiver?.decodeObject(forKey: "root") else { + return UnknownDBObject() + } + + return result + } + }() + + public static var hasLegacyDatabaseFile: Bool { + return FileManager.default.fileExists(atPath: legacyDatabaseFilepath) + } + + @discardableResult public static func loadDatabaseIfNeeded() -> Bool { + guard SUKLegacy.database == nil else { return true } + + /// Ensure the databaseKeySpec exists + var maybeKeyData: Data? = try? CurrentAppContext().keychainStorage().data( + forService: keychainService, + key: keychainDBCipherKeySpec + ) + defer { if maybeKeyData != nil { maybeKeyData!.resetBytes(in: 0.. YapDatabaseConnection? { + SUKLegacy.loadDatabaseIfNeeded() + + return self.database?.wrappedValue.newConnection() + } + + public static func clearLegacyDatabaseInstance() { + self.database = nil + } + + // MARK: - UnknownDBObject + + @objc(LegacyUnknownDBObject) + public class UnknownDBObject: NSObject, NSCoding { + override public init() {} + public required init?(coder: NSCoder) {} + public func encode(with coder: NSCoder) { fatalError("Shouldn't be encoding this type") } + } + + // MARK: - LagacyKeyPair + @objc(LegacyKeyPair) public class KeyPair: NSObject, NSCoding { private static let keyLength: Int = 32 diff --git a/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift index fd1392956..e1e19ea0c 100644 --- a/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -4,9 +4,10 @@ import Foundation import GRDB enum _001_InitialSetupMigration: Migration { + static let target: TargetMigrations.Identifier = .utilitiesKit static let identifier: String = "initialSetup" - static let minExpectedRunDuration: TimeInterval = 0.1 static let needsConfigSync: Bool = false + static let minExpectedRunDuration: TimeInterval = 0.1 static func migrate(_ db: Database) throws { try db.create(table: Identity.self) { t in diff --git a/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift index dd8e6b800..61fb1aed3 100644 --- a/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -7,9 +7,10 @@ import Curve25519Kit /// This migration sets up the standard jobs, since we want these jobs to run before any "once-off" jobs we do this migration /// before running the `YDBToGRDBMigration` enum _002_SetupStandardJobs: Migration { + static let target: TargetMigrations.Identifier = .utilitiesKit static let identifier: String = "SetupStandardJobs" - static let minExpectedRunDuration: TimeInterval = 0.1 static let needsConfigSync: Bool = false + static let minExpectedRunDuration: TimeInterval = 0.1 static func migrate(_ db: Database) throws { try autoreleasepool { diff --git a/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift index a5b04d43e..d70216cc3 100644 --- a/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -4,12 +4,18 @@ import Foundation import GRDB enum _003_YDBToGRDBMigration: Migration { + static let target: TargetMigrations.Identifier = .utilitiesKit static let identifier: String = "YDBToGRDBMigration" - static let minExpectedRunDuration: TimeInterval = 0.1 static let needsConfigSync: Bool = false + static let minExpectedRunDuration: TimeInterval = 0.1 static func migrate(_ db: Database) throws { - // MARK: - Identity keys + guard let dbConnection: YapDatabaseConnection = SUKLegacy.newDatabaseConnection() else { + SNLog("[Migration Warning] No legacy database, skipping \(target.key(with: self))") + return + } + + // MARK: - Read from Legacy Database // Note: Want to exclude the Snode's we already added from the 'onionRequestPathResult' var registeredNumber: String? @@ -24,7 +30,9 @@ enum _003_YDBToGRDBMigration: Migration { forClassName: "ECKeyPair" ) - Storage.read { transaction in + dbConnection.read { transaction in + // MARK: --Identity keys + registeredNumber = transaction.object( forKey: SUKLegacy.userAccountRegisteredNumberKey, inCollection: SUKLegacy.userAccountCollection @@ -73,9 +81,12 @@ enum _003_YDBToGRDBMigration: Migration { throw StorageError.migrationFailed } - print("RAWR publicKey \(userX25519KeyPair.publicKey.toHexString())") + + // MARK: - Insert into GRDB + try autoreleasepool { - // Insert the data into GRDB + // MARK: --Identity keys + try Identity( variant: .seed, data: Data(hex: seedHexString) diff --git a/SessionUtilitiesKit/Database/Types/Migration.swift b/SessionUtilitiesKit/Database/Types/Migration.swift index 69d27e754..b26bf1b97 100644 --- a/SessionUtilitiesKit/Database/Types/Migration.swift +++ b/SessionUtilitiesKit/Database/Types/Migration.swift @@ -4,9 +4,20 @@ import Foundation import GRDB public protocol Migration { + static var target: TargetMigrations.Identifier { get } static var identifier: String { get } static var needsConfigSync: Bool { get } static var minExpectedRunDuration: TimeInterval { get } static func migrate(_ db: Database) throws } + +public extension Migration { + static func loggedMigrate(_ targetIdentifier: TargetMigrations.Identifier) -> ((_ db: Database) throws -> ()) { + return { (db: Database) in + SNLog("[Migration Info] Starting \(targetIdentifier.key(with: self))") + try migrate(db) + SNLog("[Migration Info] Completed \(targetIdentifier.key(with: self))") + } + } +} diff --git a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift index caf730300..25e67d4ec 100644 --- a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift +++ b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift @@ -866,7 +866,7 @@ public class AssociatedRecord: ErasedAssociatedRecord where T: Fet self.associateData = associateData } - convenience init( + public convenience init( trackedAgainst: Table.Type, observedChanges: [PagedData.ObservedChanges], dataQuery: @escaping (SQL?) -> SQLRequest, diff --git a/SessionUtilitiesKit/Database/Types/TargetMigrations.swift b/SessionUtilitiesKit/Database/Types/TargetMigrations.swift index de793d071..8c3916b3a 100644 --- a/SessionUtilitiesKit/Database/Types/TargetMigrations.swift +++ b/SessionUtilitiesKit/Database/Types/TargetMigrations.swift @@ -27,7 +27,7 @@ public struct TargetMigrations: Comparable { return (lhsIndex < rhsIndex) } - func key(with migration: Migration.Type) -> String { + public func key(with migration: Migration.Type) -> String { return "\(self.rawValue).\(migration.identifier)" } } @@ -43,6 +43,10 @@ public struct TargetMigrations: Comparable { identifier: Identifier, migrations: [MigrationSet] ) { + guard !migrations.contains(where: { migration in migration.contains(where: { $0.target != identifier }) }) else { + preconditionFailure("Attempted to register a migration with the wrong target") + } + self.identifier = identifier self.migrations = migrations } diff --git a/SessionUtilitiesKit/Database/Utilities/DatabaseMigrator+Utilities.swift b/SessionUtilitiesKit/Database/Utilities/DatabaseMigrator+Utilities.swift index ccc073f35..337dd805f 100644 --- a/SessionUtilitiesKit/Database/Utilities/DatabaseMigrator+Utilities.swift +++ b/SessionUtilitiesKit/Database/Utilities/DatabaseMigrator+Utilities.swift @@ -4,7 +4,10 @@ import Foundation import GRDB public extension DatabaseMigrator { - mutating func registerMigration(_ identifier: TargetMigrations.Identifier, migration: Migration.Type, foreignKeyChecks: ForeignKeyChecks = .deferred) { - self.registerMigration("\(identifier).\(migration.identifier)", migrate: migration.migrate) + mutating func registerMigration(_ targetIdentifier: TargetMigrations.Identifier, migration: Migration.Type, foreignKeyChecks: ForeignKeyChecks = .deferred) { + self.registerMigration( + targetIdentifier.key(with: migration), + migrate: migration.loggedMigrate(targetIdentifier) + ) } } diff --git a/SessionUtilitiesKit/General/NSArray+Functional.h b/SessionUtilitiesKit/General/NSArray+Functional.h deleted file mode 100644 index e8a293376..000000000 --- a/SessionUtilitiesKit/General/NSArray+Functional.h +++ /dev/null @@ -1,9 +0,0 @@ -#import - -@interface NSArray (Functional) - -- (BOOL)contains:(BOOL (^)(id))predicate; -- (NSArray *)filtered:(BOOL (^)(id))isIncluded; -- (NSArray *)map:(id (^)(id))transform; - -@end diff --git a/SessionUtilitiesKit/General/NSArray+Functional.m b/SessionUtilitiesKit/General/NSArray+Functional.m deleted file mode 100644 index 8e8e5a131..000000000 --- a/SessionUtilitiesKit/General/NSArray+Functional.m +++ /dev/null @@ -1,32 +0,0 @@ -#import "NSArray+Functional.h" - -@implementation NSArray (Functional) - -- (BOOL)contains:(BOOL (^)(id))predicate { - for (id object in self) { - BOOL isPredicateSatisfied = predicate(object); - if (isPredicateSatisfied) { return YES; } - } - return NO; -} - -- (NSArray *)filtered:(BOOL (^)(id))isIncluded { - NSMutableArray *result = [NSMutableArray new]; - for (id object in self) { - if (isIncluded(object)) { - [result addObject:object]; - } - } - return result; -} - -- (NSArray *)map:(id (^)(id))transform { - NSMutableArray *result = [NSMutableArray new]; - for (id object in self) { - id transformedObject = transform(object); - [result addObject:transformedObject]; - } - return result; -} - -@end diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index 77598766a..4b29aa631 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -52,6 +52,7 @@ public final class JobRunner { let messageSendQueue: JobQueue = JobQueue( type: .messageSend, + executionType: .concurrent, // Allow as many jobs to run at once as supported by the device qos: .default, jobVariants: [ jobVariants.remove(.attachmentUpload), @@ -62,6 +63,7 @@ public final class JobRunner { ) let messageReceiveQueue: JobQueue = JobQueue( type: .messageReceive, + executionType: .concurrent, // Allow as many jobs to run at once as supported by the device qos: .default, jobVariants: [ jobVariants.remove(.messageReceive) @@ -320,33 +322,48 @@ private final class JobQueue { } } + fileprivate enum ExecutionType { + /// A serial queue will execute one job at a time until the queue is empty, then will load any new/deferred + /// jobs and run those one at a time + case serial + + /// A concurrent queue will execute as many jobs as the device supports at once until the queue is empty, + /// then will load any new/deferred jobs and try to start them all + case concurrent + } + private class Trigger { - private weak var queue: JobQueue? private var timer: Timer? + fileprivate var fireTimestamp: TimeInterval = 0 static func create(queue: JobQueue, timestamp: TimeInterval) -> Trigger? { - // Setup the trigger (wait at least 1 second before triggering) + /// Setup the trigger (wait at least 1 second before triggering) + /// + /// **Note:** We use the `Timer.scheduledTimerOnMainThread` method because running a timer + /// on our random queue threads results in the timer never firing, the `start` method will redirect itself to + /// the correct thread let trigger: Trigger = Trigger() - trigger.queue = queue - trigger.timer = Timer.scheduledTimer( - timeInterval: max(1, (timestamp - Date().timeIntervalSince1970)), - target: self, - selector: #selector(start), - userInfo: nil, - repeats: false + trigger.fireTimestamp = max(1, (timestamp - Date().timeIntervalSince1970)) + trigger.timer = Timer.scheduledTimerOnMainThread( + withTimeInterval: trigger.fireTimestamp, + repeats: false, + block: { [weak queue] _ in + queue?.start() + } ) return trigger } - deinit { timer?.invalidate() } - - @objc func start() { - queue?.start() + func invalidate() { + // Need to do this to prevent a strong reference cycle + timer?.invalidate() + timer = nil } } private let type: QueueType + private let executionType: ExecutionType private let qosClass: DispatchQoS private let queueKey: DispatchSpecificKey = DispatchSpecificKey() private let queueContext: String @@ -360,7 +377,7 @@ private final class JobQueue { let result: DispatchQueue = DispatchQueue( label: self.queueContext, qos: self.qosClass, - attributes: [], + attributes: (self.executionType == .concurrent ? [.concurrent] : []), autoreleaseFrequency: .inherit, target: nil ) @@ -378,8 +395,15 @@ private final class JobQueue { // MARK: - Initialization - init(type: QueueType, qos: DispatchQoS, jobVariants: [Job.Variant], onQueueDrained: (() -> ())? = nil) { + init( + type: QueueType, + executionType: ExecutionType = .serial, + qos: DispatchQoS, + jobVariants: [Job.Variant], + onQueueDrained: (() -> ())? = nil + ) { self.type = type + self.executionType = executionType self.queueContext = "JobQueue-\(type.name)" self.qosClass = qos self.jobVariants = jobVariants @@ -483,10 +507,10 @@ private final class JobQueue { // MARK: - Job Running - fileprivate func start() { + fileprivate func start(force: Bool = false) { // We only want the JobRunner to run in the main app guard CurrentAppContext().isMainApp else { return } - guard !isRunning.wrappedValue else { return } + guard force || !isRunning.wrappedValue else { return } // The JobRunner runs synchronously we need to ensure this doesn't start // on the main thread (if it is on the main thread then swap to a different thread) @@ -497,9 +521,21 @@ private final class JobQueue { return } + // Flag the JobRunner as running (to prevent something else from trying to start it + // and messing with the execution behaviour) + var wasAlreadyRunning: Bool = false + isRunning.mutate { isRunning in + wasAlreadyRunning = isRunning + isRunning = true + } + // Get any pending jobs + let jobIdsAlreadyRunning: Set = jobsCurrentlyRunning.wrappedValue + let jobsAlreadyInQueue: Set = queue.wrappedValue.compactMap { $0.id }.asSet() let jobsToRun: [Job] = GRDBStorage.shared.read { db in try Job.filterPendingJobs(variants: jobVariants) + .filter(!jobIdsAlreadyRunning.contains(Job.Columns.id)) // Exclude jobs already running + .filter(!jobsAlreadyInQueue.contains(Job.Columns.id)) // Exclude jobs already in the queue .fetchAll(db) } .defaulting(to: []) @@ -508,28 +544,24 @@ private final class JobQueue { var jobCount: Int = 0 queue.mutate { queue in - // Avoid re-adding jobs to the queue that are already in it - let jobsNotAlreadyInQueue: [Job] = jobsToRun - .filter { job in !queue.contains(where: { $0.id == job.id }) } - - // Add the jobs to the queue - if !jobsNotAlreadyInQueue.isEmpty { - queue.append(contentsOf: jobsToRun) - } - + queue.append(contentsOf: jobsToRun) jobCount = queue.count } - // If there are no pending jobs then schedule the JobRunner to start again - // when the next scheduled job should start + // If there are no pending jobs and nothing in the queue then schedule the JobRunner + // to start again when the next scheduled job should start guard jobCount > 0 else { - isRunning.mutate { $0 = false } - scheduleNextSoonestJob() + if jobIdsAlreadyRunning.isEmpty { + isRunning.mutate { $0 = false } + scheduleNextSoonestJob() + } return } // Run the first job in the queue - SNLog("[JobRunner] Starting \(queueContext) with (\(jobCount) job\(jobCount != 1 ? "s" : ""))") + if !wasAlreadyRunning { + SNLog("[JobRunner] Starting \(queueContext) with (\(jobCount) job\(jobCount != 1 ? "s" : ""))") + } runNextJob() } @@ -542,7 +574,13 @@ private final class JobQueue { return } guard let (nextJob, numJobsRemaining): (Job, Int) = queue.mutate({ queue in queue.popFirst().map { ($0, queue.count) } }) else { - isRunning.mutate { $0 = false } + // If it's a serial queue, or there are no more jobs running then update the 'isRunning' flag + if executionType != .concurrent || jobsCurrentlyRunning.wrappedValue.isEmpty { + isRunning.mutate { $0 = false } + } + + // Always attempt to schedule the next soonest job (otherwise if enough jobs get started in rapid + // succession then pending/failed jobs in the database may never get re-started in a concurrent queue) scheduleNextSoonestJob() return } @@ -606,15 +644,20 @@ private final class JobQueue { return } - // Otherwise re-add the current job after it's dependencies - queue.mutate { queue in - guard let lastDependencyIndex: Int = queue.lastIndex(where: { jobDependencyIds.contains($0.id ?? -1) }) else { - queue.append(nextJob) - return + // Otherwise re-add the current job after it's dependencies (if this isn't a concurrent + // queue - don't want to immediately try to start the job again only for it to end up back + // in here) + if executionType != .concurrent { + queue.mutate { queue in + guard let lastDependencyIndex: Int = queue.lastIndex(where: { jobDependencyIds.contains($0.id ?? -1) }) else { + queue.append(nextJob) + return + } + + queue.insert(nextJob, at: lastDependencyIndex + 1) } - - queue.insert(nextJob, at: lastDependencyIndex + 1) } + handleJobDeferred(nextJob) return } @@ -624,10 +667,17 @@ private final class JobQueue { // Note: We need to store 'numJobsRemaining' in it's own variable because // the 'SNLog' seems to dispatch to it's own queue which ends up getting // blocked by the JobRunner's queue becuase 'jobQueue' is Atomic - nextTrigger.mutate { $0 = nil } + var numJobsRunning: Int = 0 + nextTrigger.mutate { trigger in + trigger?.invalidate() // Need to invalidate to prevent a memory leak + trigger = nil + } isRunning.mutate { $0 = true } - jobsCurrentlyRunning.mutate { $0 = $0.inserting(nextJob.id) } - SNLog("[JobRunner] \(queueContext) started job (\(numJobsRemaining) remaining)") + jobsCurrentlyRunning.mutate { jobsCurrentlyRunning in + jobsCurrentlyRunning = jobsCurrentlyRunning.inserting(nextJob.id) + numJobsRunning = jobsCurrentlyRunning.count + } + SNLog("[JobRunner] \(queueContext) started job (\(executionType == .concurrent ? "\(numJobsRunning) currently running, " : "")\(numJobsRemaining) remaining)") jobExecutor.run( nextJob, @@ -635,6 +685,14 @@ private final class JobQueue { failure: handleJobFailed, deferred: handleJobDeferred ) + + // If this queue executes concurrently and there are still jobs remaining then immediately attempt + // to start the next job + if executionType == .concurrent && numJobsRemaining > 0 { + internalQueue.async { [weak self] in + self?.runNextJob() + } + } } private func scheduleNextSoonestJob() { @@ -647,7 +705,9 @@ private final class JobQueue { // If there are no remaining jobs the trigger the 'onQueueDrained' callback and stop guard let nextJobTimestamp: TimeInterval = nextJobTimestamp else { - self.onQueueDrained?() + if executionType != .concurrent || jobsCurrentlyRunning.wrappedValue.isEmpty { + self.onQueueDrained?() + } return } @@ -655,17 +715,33 @@ private final class JobQueue { let secondsUntilNextJob: TimeInterval = (nextJobTimestamp - Date().timeIntervalSince1970) guard secondsUntilNextJob > 0 else { - SNLog("[JobRunner] Restarting \(queueContext) immediately for job scheduled \(Int(ceil(abs(secondsUntilNextJob)))) second\(Int(ceil(abs(secondsUntilNextJob))) == 1 ? "" : "s")) ago") + // Only log that the queue is getting restarted if this queue had actually been about to stop + if executionType != .concurrent || jobsCurrentlyRunning.wrappedValue.isEmpty { + let timingString: String = (nextJobTimestamp == 0 ? + "that should be in the queue" : + "scheduled \(Int(ceil(abs(secondsUntilNextJob)))) second\(Int(ceil(abs(secondsUntilNextJob))) == 1 ? "" : "s") ago" + ) + SNLog("[JobRunner] Restarting \(queueContext) immediately for job \(timingString)") + } + // Trigger the 'start' function to load in any pending jobs that aren't already in the + // queue (for concurrent queues we want to force them to load in pending jobs and add + // them to the queue regardless of whether the queue is already running) internalQueue.async { [weak self] in - self?.start() + self?.start(force: (self?.executionType == .concurrent)) } return } + // Only schedule a trigger if this queue has actually completed + guard executionType != .concurrent || jobsCurrentlyRunning.wrappedValue.isEmpty else { return } + // Setup a trigger - SNLog("[JobRunner] Stopping \(queueContext) until next job in \(Int(ceil(abs(secondsUntilNextJob)))) second\(Int(ceil(abs(secondsUntilNextJob))) == 1 ? "" : "s"))") - nextTrigger.mutate { $0 = Trigger.create(queue: self, timestamp: nextJobTimestamp) } + SNLog("[JobRunner] Stopping \(queueContext) until next job in \(Int(ceil(abs(secondsUntilNextJob)))) second\(Int(ceil(abs(secondsUntilNextJob))) == 1 ? "" : "s")") + nextTrigger.mutate { trigger in + trigger?.invalidate() // Need to invalidate the old trigger to prevent a memory leak + trigger = Trigger.create(queue: self, timestamp: nextJobTimestamp) + } } // MARK: - Handling Results @@ -708,6 +784,29 @@ private final class JobQueue { default: break } + // For concurrent queues retrieve any 'dependant' jobs and re-add them here (if they have other + // dependencies they will be removed again when they try to execute) + if executionType == .concurrent { + let dependantJobs: [Job] = GRDBStorage.shared + .read { db in try job.dependantJobs.fetchAll(db) } + .defaulting(to: []) + let dependantJobIds: [Int64] = dependantJobs + .compactMap { $0.id } + let jobIdsNotInQueue: Set = dependantJobIds + .asSet() + .subtracting(queue.wrappedValue.compactMap { $0.id }) + + // If there are dependant jobs which aren't in the queue we should just append them + if !jobIdsNotInQueue.isEmpty { + queue.mutate { queue in + queue.append( + contentsOf: dependantJobs + .filter { jobIdsNotInQueue.contains($0.id ?? -1) } + ) + } + } + } + // The job is removed from the queue before it runs so all we need to to is remove it // from the 'currentlyRunning' set and start the next one jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) } @@ -732,6 +831,7 @@ private final class JobQueue { // If this is the blocking queue and a "blocking" job failed then rerun it immediately if self.type == .blocking && job.shouldBlockFirstRunEachSession { SNLog("[JobRunner] \(queueContext) \(job.variant) job failed; retrying immediately") + jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) } queue.mutate { $0.insert(job, at: 0) } internalQueue.async { [weak self] in @@ -752,9 +852,24 @@ private final class JobQueue { else { SNLog("[JobRunner] \(queueContext) \(job.variant) failed permanently\(maxFailureCount >= 0 ? "; too many retries" : "")") + let dependantJobIds: [Int64] = try job.dependantJobs + .select(.id) + .asRequest(of: Int64.self) + .fetchAll(db) + // If the job permanently failed or we have performed all of our retry attempts - // then delete the job (it'll probably never succeed) + // then delete the job and all of it's dependant jobs (it'll probably never succeed) + _ = try job.dependantJobs + .deleteAll(db) + _ = try job.delete(db) + + // Remove the dependant jobs from the queue (so we don't try to run a deleted job) + if !dependantJobIds.isEmpty { + queue.mutate { queue in + queue = queue.filter { !dependantJobIds.contains($0.id ?? -1) } + } + } return } @@ -783,7 +898,7 @@ private final class JobQueue { .fetchAll(db) // Remove the dependant jobs from the queue (so we don't get stuck in a loop of trying - // to run dependecies indefinitely + // to run dependecies indefinitely) if !dependantJobIds.isEmpty { queue.mutate { queue in queue = queue.filter { !dependantJobIds.contains($0.id ?? -1) } diff --git a/SessionUtilitiesKit/Meta/SessionUtilitiesKit.h b/SessionUtilitiesKit/Meta/SessionUtilitiesKit.h index 02e57fb9a..fc889a5b2 100644 --- a/SessionUtilitiesKit/Meta/SessionUtilitiesKit.h +++ b/SessionUtilitiesKit/Meta/SessionUtilitiesKit.h @@ -7,7 +7,6 @@ FOUNDATION_EXPORT const unsigned char SessionUtilitiesKitVersionString[]; #import #import #import -#import #import #import #import diff --git a/SignalUtilitiesKit/Database/Migrations/BlockingManagerRemovalMigration.swift b/SignalUtilitiesKit/Database/Migrations/BlockingManagerRemovalMigration.swift deleted file mode 100644 index 1e2ead55e..000000000 --- a/SignalUtilitiesKit/Database/Migrations/BlockingManagerRemovalMigration.swift +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import YapDatabase -import SessionMessagingKit - -@objc(SNBlockingManagerRemovalMigration) -public class BlockingManagerRemovalMigration: OWSDatabaseMigration { - @objc - class func migrationId() -> String { - return "004" - } - - override public func runUp(completion: @escaping OWSDatabaseMigrationCompletion) { - self.doMigrationAsync(completion: completion) - } - - private func doMigrationAsync(completion: @escaping OWSDatabaseMigrationCompletion) { - // These are the legacy keys that were used to persist the "block list" state - let kOWSBlockingManager_BlockListCollection: String = "kOWSBlockingManager_BlockedPhoneNumbersCollection" - let kOWSBlockingManager_BlockedPhoneNumbersKey: String = "kOWSBlockingManager_BlockedPhoneNumbersKey" - - // Note: These will be done in the YDB to GRDB migration but have added it here to be safe - NSKeyedUnarchiver.setClass( - SMKLegacy._Contact.self, - forClassName: "SNContact" - ) - - let dbConnection: YapDatabaseConnection = primaryStorage.newDatabaseConnection() - - let blockedSessionIds: Set = Set(dbConnection.object( - forKey: kOWSBlockingManager_BlockedPhoneNumbersKey, - inCollection: kOWSBlockingManager_BlockListCollection - ) as? [String] ?? []) - - Storage.write( - with: { transaction in - var result: Set = [] - - transaction.enumerateRows(inCollection: SMKLegacy.contactCollection) { _, object, _, _ in - guard let contact = object as? SMKLegacy._Contact else { return } - result.insert(contact) - } - - result - .filter { contact -> Bool in blockedSessionIds.contains(contact.sessionID) } - .forEach { contact in - contact.isBlocked = true - transaction.setObject(contact, forKey: contact.sessionID, inCollection: SMKLegacy.contactCollection) - } - - // Now that the values have been migrated we can clear out the old collection - transaction.removeAllObjects(inCollection: kOWSBlockingManager_BlockListCollection) - - self.save(with: transaction) // Intentionally capture self - }, - completion: { - completion(true, true) - } - ) - } -} diff --git a/SignalUtilitiesKit/Database/Migrations/ContactsMigration.swift b/SignalUtilitiesKit/Database/Migrations/ContactsMigration.swift deleted file mode 100644 index b2290d10b..000000000 --- a/SignalUtilitiesKit/Database/Migrations/ContactsMigration.swift +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import YapDatabase -import SessionMessagingKit - -@objc(SNContactsMigration) -public class ContactsMigration: OWSDatabaseMigration { - - @objc - class func migrationId() -> String { - return "001" - } - - override public func runUp(completion: @escaping OWSDatabaseMigrationCompletion) { - self.doMigrationAsync(completion: completion) - } - - private func doMigrationAsync(completion: @escaping OWSDatabaseMigrationCompletion) { - var contacts: [SMKLegacy._Contact] = [] - - // Note: These will be done in the YDB to GRDB migration but have added it here to be safe - NSKeyedUnarchiver.setClass( - SMKLegacy._Thread.self, - forClassName: "TSThread" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._ContactThread.self, - forClassName: "TSContactThread" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._Contact.self, - forClassName: "SNContact" - ) - - Storage.read { transaction in - transaction.enumerateKeysAndObjects(inCollection: SMKLegacy.threadCollection) { _, object, _ in - guard let thread = object as? SMKLegacy._ContactThread else { return } - - let sessionId: String = SMKLegacy._ContactThread.contactSessionId(fromThreadId: thread.uniqueId) - let contact: SMKLegacy._Contact? = transaction.object(forKey: sessionId, inCollection: SMKLegacy.contactCollection) as? SMKLegacy._Contact - - contact?.isTrusted = true - contacts = contacts.appending(contact) - } - } - - Storage.write(with: { transaction in - contacts.forEach { contact in - transaction.setObject(contact, forKey: contact.sessionID, inCollection: SMKLegacy.contactCollection) - } - self.save(with: transaction) // Intentionally capture self - }, completion: { - completion(true, false) - }) - } -} diff --git a/SignalUtilitiesKit/Database/Migrations/MessageRequestsMigration.swift b/SignalUtilitiesKit/Database/Migrations/MessageRequestsMigration.swift deleted file mode 100644 index 92223ee70..000000000 --- a/SignalUtilitiesKit/Database/Migrations/MessageRequestsMigration.swift +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import YapDatabase -import SessionMessagingKit - -@objc(SNMessageRequestsMigration) -public class MessageRequestsMigration: OWSDatabaseMigration { - - @objc - class func migrationId() -> String { - return "002" - } - - override public func runUp(completion: @escaping OWSDatabaseMigrationCompletion) { - self.doMigrationAsync(completion: completion) - } - - private func doMigrationAsync(completion: @escaping OWSDatabaseMigrationCompletion) { - let userPublicKey: String = getUserHexEncodedPublicKey() - var contacts: Set = Set() - var threads: [SMKLegacy._Thread] = [] - - // Note: These will be done in the YDB to GRDB migration but have added it here to be safe - NSKeyedUnarchiver.setClass( - SMKLegacy._Thread.self, - forClassName: "TSThread" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._ContactThread.self, - forClassName: "TSContactThread" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._GroupThread.self, - forClassName: "TSGroupThread" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._GroupModel.self, - forClassName: "TSGroupModel" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._Contact.self, - forClassName: "SNContact" - ) - - Storage.read { transaction in - transaction.enumerateKeysAndObjects(inCollection: SMKLegacy.threadCollection) { _, object, _ in - guard let thread: SMKLegacy._Thread = object as? SMKLegacy._Thread else { return } - - if thread is SMKLegacy._ContactThread { - let sessionId: String = SMKLegacy._ContactThread.contactSessionId(fromThreadId: thread.uniqueId) - - if let contact: SMKLegacy._Contact = transaction.object(forKey: sessionId, inCollection: SMKLegacy.contactCollection) as? SMKLegacy._Contact { - contact.isApproved = true - contact.didApproveMe = true - contacts.insert(contact) - } - } - else if let groupThread: SMKLegacy._GroupThread = thread as? SMKLegacy._GroupThread, groupThread.isClosedGroup { - let groupAdmins: [String] = groupThread.groupModel.groupAdminIds - - groupAdmins.forEach { sessionId in - if let contact: SMKLegacy._Contact = transaction.object(forKey: sessionId, inCollection: SMKLegacy.contactCollection) as? SMKLegacy._Contact { - contact.isApproved = true - contact.didApproveMe = true - contacts.insert(contact) - } - } - } - - threads.append(thread) - } - - if let user = transaction.object(forKey: userPublicKey, inCollection: SMKLegacy.contactCollection) as? SMKLegacy._Contact { - user.isApproved = true - user.didApproveMe = true - contacts.insert(user) - } - } - - Storage.write(with: { transaction in - contacts.forEach { contact in - transaction.setObject(contact, forKey: contact.sessionID, inCollection: SMKLegacy.contactCollection) - } - threads.forEach { thread in - transaction.setObject(thread, forKey: thread.uniqueId, inCollection: SMKLegacy.threadCollection) - } - self.save(with: transaction) // Intentionally capture self - }, completion: { - completion(true, true) - }) - } -} diff --git a/SignalUtilitiesKit/Database/Migrations/OWSDatabaseMigration.h b/SignalUtilitiesKit/Database/Migrations/OWSDatabaseMigration.h deleted file mode 100644 index d99decd7e..000000000 --- a/SignalUtilitiesKit/Database/Migrations/OWSDatabaseMigration.h +++ /dev/null @@ -1,25 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -typedef void (^OWSDatabaseMigrationCompletion)(BOOL success, BOOL requiresConfigurationSync); - -@class OWSPrimaryStorage; - -@interface OWSDatabaseMigration : TSYapDatabaseObject - -@property (nonatomic, readonly) OWSPrimaryStorage *primaryStorage; - -// Prefer nonblocking (async) migrations by overriding `runUpWithTransaction:` in a subclass. -// Blocking migrations running too long will crash the app, effectively bricking install -// because the user will never get past it. -// If you must write a launch-blocking migration, override runUp. -- (void)runUpWithCompletion:(OWSDatabaseMigrationCompletion)completion; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Database/Migrations/OWSDatabaseMigration.m b/SignalUtilitiesKit/Database/Migrations/OWSDatabaseMigration.m deleted file mode 100644 index 53ba11003..000000000 --- a/SignalUtilitiesKit/Database/Migrations/OWSDatabaseMigration.m +++ /dev/null @@ -1,109 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSDatabaseMigration.h" -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@implementation OWSDatabaseMigration - -#pragma mark - Dependencies - -- (OWSPrimaryStorage *)primaryStorage -{ - OWSAssertDebug(SSKEnvironment.shared.primaryStorage); - - return SSKEnvironment.shared.primaryStorage; -} - -#pragma mark - - -- (void)saveWithTransaction:(YapDatabaseReadWriteTransaction *)transaction -{ - OWSLogInfo(@"marking migration as complete."); - - [super saveWithTransaction:transaction]; -} - -- (instancetype)init -{ - self = [super initWithUniqueId:[self.class migrationId]]; - if (!self) { - return self; - } - - return self; -} - -+ (MTLPropertyStorage)storageBehaviorForPropertyWithKey:(NSString *)propertyKey -{ - if ([propertyKey isEqualToString:@"primaryStorage"]) { - return MTLPropertyStorageNone; - } else { - return [super storageBehaviorForPropertyWithKey:propertyKey]; - } -} - -+ (NSString *)migrationId -{ - OWSAbstractMethod(); - return @""; -} - -+ (NSString *)collection -{ - // We want all subclasses in the same collection - return @"OWSDatabaseMigration"; -} - -- (void)runUpWithTransaction:(YapDatabaseReadWriteTransaction *)transaction -{ - OWSAbstractMethod(); -} - -- (void)runUpWithCompletion:(OWSDatabaseMigrationCompletion)completion -{ - OWSAssertDebug(completion); - - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) { - [self runUpWithTransaction:transaction]; - } - completion:^{ - OWSLogInfo(@"Completed migration %@", self.uniqueId); - [self save]; - - completion(true, false); - }]; -} - -#pragma mark - Database Connections - -#ifdef DEBUG -+ (YapDatabaseConnection *)dbReadConnection -{ - return self.dbReadWriteConnection; -} - -+ (YapDatabaseConnection *)dbReadWriteConnection -{ - return SSKEnvironment.shared.migrationDBConnection; -} - -- (YapDatabaseConnection *)dbReadConnection -{ - return OWSDatabaseMigration.dbReadConnection; -} - -- (YapDatabaseConnection *)dbReadWriteConnection -{ - return OWSDatabaseMigration.dbReadWriteConnection; -} -#endif - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Database/Migrations/OWSDatabaseMigrationRunner.h b/SignalUtilitiesKit/Database/Migrations/OWSDatabaseMigrationRunner.h deleted file mode 100644 index 58c75ec8c..000000000 --- a/SignalUtilitiesKit/Database/Migrations/OWSDatabaseMigrationRunner.h +++ /dev/null @@ -1,23 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -NS_ASSUME_NONNULL_BEGIN - -typedef void (^OWSDatabaseMigrationCompletion)(BOOL success, BOOL requiresConfigurationSync); - -@interface OWSDatabaseMigrationRunner : NSObject - -/** - * Run any outstanding version migrations. - */ -- (void)runAllOutstandingWithCompletion:(OWSDatabaseMigrationCompletion)completion; - -/** - * On new installations, no need to migrate anything. - */ -- (void)assumeAllExistingMigrationsRun; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Database/Migrations/OWSDatabaseMigrationRunner.m b/SignalUtilitiesKit/Database/Migrations/OWSDatabaseMigrationRunner.m deleted file mode 100644 index 559c8dc03..000000000 --- a/SignalUtilitiesKit/Database/Migrations/OWSDatabaseMigrationRunner.m +++ /dev/null @@ -1,124 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "OWSDatabaseMigrationRunner.h" -#import "OWSDatabaseMigration.h" -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@implementation OWSDatabaseMigrationRunner - -#pragma mark - Dependencies - -- (OWSPrimaryStorage *)primaryStorage -{ - OWSAssertDebug(SSKEnvironment.shared.primaryStorage); - - return SSKEnvironment.shared.primaryStorage; -} - -#pragma mark - - -// This should all migrations which do NOT qualify as safeBlockingMigrations: -- (NSArray *)allMigrations -{ - return @[ - [SNOpenGroupServerIdLookupMigration new], - [SNMessageRequestsMigration new], - [SNContactsMigration new], - [SNBlockingManagerRemovalMigration new] - ]; -} - -- (void)assumeAllExistingMigrationsRun -{ - for (OWSDatabaseMigration *migration in self.allMigrations) { - OWSLogInfo(@"Skipping migration on new install: %@", migration); - [migration save]; - } -} - -- (void)runAllOutstandingWithCompletion:(OWSDatabaseMigrationCompletion)completion -{ - [self removeUnknownMigrations]; - - [self runMigrations:[self.allMigrations mutableCopy] - prevWasSuccessful: true - prevNeedsConfigSync:false - completion:completion]; -} - -// Some users (especially internal users) will move back and forth between -// app versions. Whenever they move "forward" in the version history, we -// want them to re-run any new migrations. Therefore, when they move "backward" -// in the version history, we cull any unknown migrations. -- (void)removeUnknownMigrations -{ - NSMutableSet *knownMigrationIds = [NSMutableSet new]; - for (OWSDatabaseMigration *migration in self.allMigrations) { - [knownMigrationIds addObject:migration.uniqueId]; - } - - [OWSPrimaryStorage.sharedManager.dbReadWriteConnection readWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - NSArray *savedMigrationIds = [transaction allKeysInCollection:OWSDatabaseMigration.collection]; - - NSMutableSet *unknownMigrationIds = [NSMutableSet new]; - [unknownMigrationIds addObjectsFromArray:savedMigrationIds]; - [unknownMigrationIds minusSet:knownMigrationIds]; - - for (NSString *unknownMigrationId in unknownMigrationIds) { - OWSLogInfo(@"Culling unknown migration: %@", unknownMigrationId); - [transaction removeObjectForKey:unknownMigrationId inCollection:OWSDatabaseMigration.collection]; - } - }]; -} - -// Run migrations serially to: -// -// * Ensure predictable ordering. -// * Prevent them from interfering with each other (e.g. deadlock). -- (void)runMigrations:(NSMutableArray *)migrations - prevWasSuccessful:(BOOL)prevWasSuccessful - prevNeedsConfigSync:(BOOL)prevNeedsConfigSync - completion:(OWSDatabaseMigrationCompletion)completion -{ - OWSAssertDebug(migrations); - OWSAssertDebug(completion); - - // If there are no more migrations to run, complete. - if (migrations.count < 1) { - dispatch_async(dispatch_get_main_queue(), ^{ - completion(prevWasSuccessful, prevNeedsConfigSync); - }); - return; - } - - // Pop next migration from front of queue. - OWSDatabaseMigration *migration = migrations.firstObject; - [migrations removeObjectAtIndex:0]; - - // If migration has already been run, skip it. - if ([OWSDatabaseMigration fetchObjectWithUniqueID:migration.uniqueId] != nil) { - [self runMigrations:migrations - prevWasSuccessful:prevWasSuccessful - prevNeedsConfigSync:prevNeedsConfigSync - completion:completion]; - return; - } - - OWSLogInfo(@"Running migration: %@", migration); - [migration runUpWithCompletion:^(BOOL successful, BOOL needsConfigSync){ - OWSLogInfo(@"Migration complete: %@", migration); - [self runMigrations:migrations - prevWasSuccessful:(prevWasSuccessful && successful) - prevNeedsConfigSync:(prevNeedsConfigSync || needsConfigSync) - completion:completion]; - }]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Database/Migrations/OWSResaveCollectionDBMigration.h b/SignalUtilitiesKit/Database/Migrations/OWSResaveCollectionDBMigration.h deleted file mode 100644 index d7c793e1b..000000000 --- a/SignalUtilitiesKit/Database/Migrations/OWSResaveCollectionDBMigration.h +++ /dev/null @@ -1,24 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -typedef BOOL (^DBRecordFilterBlock)(id record); - -@class YapDatabaseConnection; - -// Base class for migrations that resave all or a subset of -// records in a database collection. -@interface OWSResaveCollectionDBMigration : OWSDatabaseMigration - -- (void)resaveDBCollection:(NSString *)collection - filter:(nullable DBRecordFilterBlock)filter - dbConnection:(YapDatabaseConnection *)dbConnection - completion:(OWSDatabaseMigrationCompletion)completion; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Database/Migrations/OWSResaveCollectionDBMigration.m b/SignalUtilitiesKit/Database/Migrations/OWSResaveCollectionDBMigration.m deleted file mode 100644 index f899bed9b..000000000 --- a/SignalUtilitiesKit/Database/Migrations/OWSResaveCollectionDBMigration.m +++ /dev/null @@ -1,80 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSResaveCollectionDBMigration.h" -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@implementation OWSResaveCollectionDBMigration - -- (void)resaveDBCollection:(NSString *)collection - filter:(nullable DBRecordFilterBlock)filter - dbConnection:(YapDatabaseConnection *)dbConnection - completion:(OWSDatabaseMigrationCompletion)completion -{ - OWSAssertDebug(collection.length > 0); - OWSAssertDebug(dbConnection); - OWSAssertDebug(completion); - - NSMutableArray *recordIds = [NSMutableArray new]; - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) { - [recordIds addObjectsFromArray:[transaction allKeysInCollection:collection]]; - OWSLogInfo(@"Migrating %lu records from: %@.", (unsigned long)recordIds.count, collection); - } - completion:^{ - [self resaveBatch:recordIds - collection:collection - filter:filter - dbConnection:dbConnection - completion:completion]; - }]; -} - -- (void)resaveBatch:(NSMutableArray *)recordIds - collection:(NSString *)collection - filter:(nullable DBRecordFilterBlock)filter - dbConnection:(YapDatabaseConnection *)dbConnection - completion:(OWSDatabaseMigrationCompletion)completion -{ - OWSAssertDebug(recordIds); - OWSAssertDebug(collection.length > 0); - OWSAssertDebug(dbConnection); - OWSAssertDebug(completion); - - OWSLogVerbose(@"%lu", (unsigned long)recordIds.count); - - if (recordIds.count < 1) { - completion(true, false); - return; - } - - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) { - const int kBatchSize = 1000; - for (int i = 0; i < kBatchSize && recordIds.count > 0; i++) { - NSString *messageId = [recordIds lastObject]; - [recordIds removeLastObject]; - id record = [transaction objectForKey:messageId inCollection:collection]; - if (filter && !filter(record)) { - continue; - } - TSYapDatabaseObject *entity = (TSYapDatabaseObject *)record; - [entity saveWithTransaction:transaction]; - } - } - completion:^{ - // Process the next batch. - [self resaveBatch:recordIds - collection:collection - filter:filter - dbConnection:dbConnection - completion:completion]; - }]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Database/Migrations/OpenGroupServerIdLookupMigration.swift b/SignalUtilitiesKit/Database/Migrations/OpenGroupServerIdLookupMigration.swift deleted file mode 100644 index 54f7f0e83..000000000 --- a/SignalUtilitiesKit/Database/Migrations/OpenGroupServerIdLookupMigration.swift +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import YapDatabase -import SessionMessagingKit - -@objc(SNOpenGroupServerIdLookupMigration) -public class OpenGroupServerIdLookupMigration: OWSDatabaseMigration { - @objc - class func migrationId() -> String { - return "003" - } - - override public func runUp(completion: @escaping OWSDatabaseMigrationCompletion) { - self.doMigrationAsync(completion: completion) - } - - private func doMigrationAsync(completion: @escaping OWSDatabaseMigrationCompletion) { - var lookups: [OpenGroupServerIdLookup] = [] - - // Note: These will be done in the YDB to GRDB migration but have added it here to be safe - NSKeyedUnarchiver.setClass( - SMKLegacy._Thread.self, - forClassName: "TSThread" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._ContactThread.self, - forClassName: "TSContactThread" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._GroupThread.self, - forClassName: "TSGroupThread" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._GroupModel.self, - forClassName: "TSGroupModel" - ) - // TODO: Add, SMKLegacy._OpenGroup, SMKLegacy._TSMessage (and related) - - Storage.write(with: { transaction in - transaction.enumerateKeysAndObjects(inCollection: SMKLegacy.threadCollection) { _, object, _ in - guard let thread = object as? SMKLegacy._GroupThread else { return } - guard let openGroup: OpenGroupV2 = Storage.shared.getV2OpenGroup(for: thread.uniqueId) else { return } - guard let interactionsByThread: YapDatabaseViewTransaction = transaction.ext(SMKLegacy.messageDatabaseViewExtensionName) as? YapDatabaseViewTransaction else { - return - } - - interactionsByThread.enumerateKeysAndObjects(inGroup: thread.uniqueId) { _, _, object, _, _ in - guard let tsMessage: TSMessage = object as? TSMessage else { return } - guard let tsMessageId: String = tsMessage.uniqueId else { return } - - lookups.append( - OpenGroupServerIdLookup( - server: openGroup.server, - room: openGroup.room, - serverId: tsMessage.openGroupServerMessageID, - tsMessageId: tsMessageId - ) - ) - } - } - - lookups.forEach { lookup in - Storage.shared.addOpenGroupServerIdLookup(lookup, using: transaction) - } - self.save(with: transaction) // Intentionally capture self - }, completion: { - completion(true, false) - }) - } -} diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift index 552e6569c..87593e7c3 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift @@ -769,11 +769,9 @@ extension SignalAttachmentItem: GalleryRailItem { func buildRailItemView() -> UIView { let imageView = UIImageView() imageView.contentMode = .scaleAspectFill - - getThumbnailImage().map { image in - imageView.image = image - }.retainUntilComplete() - + imageView.backgroundColor = UIColor.black.withAlphaComponent(0.33) + imageView.image = getThumbnailImage() + return imageView } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift index dcb63474f..503b28ad0 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift @@ -61,22 +61,8 @@ class SignalAttachmentItem: Hashable { return attachment.captionText } - var imageSize: CGSize = .zero - - func getThumbnailImage() -> Promise { - return DispatchQueue.global().async(.promise) { () -> UIImage in - guard let image = self.attachment.staticThumbnail() else { - throw SignalAttachmentItemError.noThumbnail - } - return image - }.tap { result in - switch result { - case .fulfilled(let image): - self.imageSize = image.size - case .rejected(let error): - owsFailDebug("failed with error: \(error)") - } - } + func getThumbnailImage() -> UIImage? { + return attachment.staticThumbnail() } // MARK: Hashable diff --git a/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h b/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h index e127a8286..7863687e9 100644 --- a/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h +++ b/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h @@ -7,18 +7,11 @@ FOUNDATION_EXPORT const unsigned char SignalUtilitiesKitVersionString[]; @import SessionSnodeKit; @import SessionUtilitiesKit; -#import #import #import #import -#import #import -#import -#import #import -#import -#import -#import #import #import #import @@ -35,4 +28,3 @@ FOUNDATION_EXPORT const unsigned char SignalUtilitiesKitVersionString[]; #import #import #import -#import diff --git a/SignalUtilitiesKit/Shared Views/GalleryRailView.swift b/SignalUtilitiesKit/Shared Views/GalleryRailView.swift index 74d001b1a..a4f585135 100644 --- a/SignalUtilitiesKit/Shared Views/GalleryRailView.swift +++ b/SignalUtilitiesKit/Shared Views/GalleryRailView.swift @@ -129,9 +129,12 @@ public class GalleryRailView: UIView, GalleryRailCellViewDelegate { addSubview(scrollView) scrollView.autoPinEdgesToSuperviewMargins() - scrollView.addSubview(stackView) + scrollView.addSubview(stackClippingView) + stackClippingView.addSubview(stackView) + + stackClippingView.autoPinEdgesToSuperviewEdges() + stackClippingView.autoMatch(.height, to: .height, of: scrollView) stackView.autoPinEdgesToSuperviewEdges() - stackView.autoMatch(.height, to: .height, of: scrollView) } public required init?(coder aDecoder: NSCoder) { @@ -145,6 +148,14 @@ public class GalleryRailView: UIView, GalleryRailCellViewDelegate { result.clipsToBounds = false result.layoutMargins = .zero result.isScrollEnabled = true + result.scrollIndicatorInsets = UIEdgeInsets(top: 0, leading: 0, bottom: -10, trailing: 0) + + return result + }() + + private let stackClippingView: UIView = { + let result: UIView = UIView() + result.clipsToBounds = true return result }() @@ -194,7 +205,7 @@ public class GalleryRailView: UIView, GalleryRailCellViewDelegate { completion: { [weak self] _ in self?.stackView.arrangedSubviews.forEach { $0.removeFromSuperview() } self?.stackView.frame = oldFrame - self?.isHidden = true + self?.stackClippingView.isHidden = true self?.cellViews = [] } ) @@ -202,54 +213,74 @@ public class GalleryRailView: UIView, GalleryRailCellViewDelegate { } // Otherwise slide it away, recreate it and then slide it back - var oldFrame: CGRect = self.stackView.frame let newCellViews: [GalleryRailCellView] = buildCellViews( items: album, cellViewBuilder: cellViewBuilder ) - - UIView.animate( - withDuration: (animationDuration / 2), - delay: 0, - options: .curveEaseIn, - animations: { [weak self] in - self?.stackView.frame = oldFrame.offsetBy( - dx: 0, - dy: oldFrame.height - ) - }, - completion: { [weak self] _ in + + let animateOut: ((CGRect, @escaping (CGRect) -> CGRect, @escaping (CGRect) -> ()) -> ()) = { [weak self] oldFrame, layoutNewItems, animateIn in + UIView.animate( + withDuration: (animationDuration / 2), + delay: 0, + options: .curveEaseIn, + animations: { + self?.stackView.frame = oldFrame.offsetBy( + dx: 0, + dy: oldFrame.height + ) + }, + completion: { _ in + let updatedOldFrame: CGRect = layoutNewItems(oldFrame) + animateIn(updatedOldFrame) + } + ) + } + let layoutNewItems: (CGRect) -> CGRect = { [weak self] oldFrame -> CGRect in + var updatedOldFrame: CGRect = oldFrame + + // Update the UI (need to re-offset it as the position gets reset during + // during these changes) + UIView.performWithoutAnimation { self?.stackView.arrangedSubviews.forEach { $0.removeFromSuperview() } newCellViews.forEach { cellView in self?.stackView.addArrangedSubview(cellView) } self?.cellViews = newCellViews - // Update the UI (need to re-offset it as the position gets reset during - // during these changes) - UIView.performWithoutAnimation { - self?.updateFocusedItem(focusedItem) - self?.isHidden = false - - oldFrame = (self?.stackView.frame) - .defaulting(to: oldFrame) - self?.stackView.frame = oldFrame.offsetBy( - dx: 0, - dy: oldFrame.height - ) - } + self?.updateFocusedItem(focusedItem) + self?.stackView.layoutIfNeeded() + self?.stackClippingView.isHidden = false - UIView.animate( - withDuration: (animationDuration / 2), - delay: 0, - options: .curveEaseOut, - animations: { [weak self] in - self?.stackView.frame = oldFrame - }, - completion: nil + updatedOldFrame = (self?.stackView.frame) + .defaulting(to: oldFrame) + self?.stackView.frame = oldFrame.offsetBy( + dx: 0, + dy: oldFrame.height ) } - ) + + return updatedOldFrame + } + let animateIn: (CGRect) -> () = { [weak self] oldFrame in + UIView.animate( + withDuration: (animationDuration / 2), + delay: 0, + options: .curveEaseOut, + animations: { [weak self] in + self?.stackView.frame = oldFrame + }, + completion: nil + ) + } + + // If we don't have arranged subviews already we can skip the 'animateOut' + guard !self.stackView.arrangedSubviews.isEmpty else { + let updatedOldFrame: CGRect = layoutNewItems(self.stackView.frame) + animateIn(updatedOldFrame) + return + } + + animateOut(self.stackView.frame, layoutNewItems, animateIn) } // MARK: - GalleryRailCellViewDelegate diff --git a/SignalUtilitiesKit/Utilities/NSArray+OWS.h b/SignalUtilitiesKit/Utilities/NSArray+OWS.h deleted file mode 100644 index 17d2b34e2..000000000 --- a/SignalUtilitiesKit/Utilities/NSArray+OWS.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Copyright (c) 2017 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface NSArray (OWS) - -- (NSArray *)uniqueIds; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/NSArray+OWS.m b/SignalUtilitiesKit/Utilities/NSArray+OWS.m deleted file mode 100644 index cb6c06376..000000000 --- a/SignalUtilitiesKit/Utilities/NSArray+OWS.m +++ /dev/null @@ -1,25 +0,0 @@ -// -// Copyright (c) 2017 Open Whisper Systems. All rights reserved. -// - -#import "NSArray+OWS.h" -#import "TSYapDatabaseObject.h" - -NS_ASSUME_NONNULL_BEGIN - -@implementation NSArray (OWS) - -- (NSArray *)uniqueIds -{ - NSMutableArray *result = [NSMutableArray new]; - for (id object in self) { - OWSAssertDebug([object isKindOfClass:[TSYapDatabaseObject class]]); - TSYapDatabaseObject *dbObject = object; - [result addObject:dbObject.uniqueId]; - } - return result; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/NSObject+Casting.h b/SignalUtilitiesKit/Utilities/NSObject+Casting.h deleted file mode 100644 index 4b502bd31..000000000 --- a/SignalUtilitiesKit/Utilities/NSObject+Casting.h +++ /dev/null @@ -1,7 +0,0 @@ -#import - -@interface NSObject (Casting) - -- (id)as:(Class)cls; - -@end diff --git a/SignalUtilitiesKit/Utilities/NSObject+Casting.m b/SignalUtilitiesKit/Utilities/NSObject+Casting.m deleted file mode 100644 index 33afb994e..000000000 --- a/SignalUtilitiesKit/Utilities/NSObject+Casting.m +++ /dev/null @@ -1,10 +0,0 @@ -#import "NSObject+Casting.h" - -@implementation NSObject (Casting) - -- (id)as:(Class)cls { - if ([self isKindOfClass:cls]) { return self; } - return nil; -} - -@end diff --git a/SignalUtilitiesKit/Utilities/NSSet+Functional.h b/SignalUtilitiesKit/Utilities/NSSet+Functional.h deleted file mode 100644 index 14932e2ff..000000000 --- a/SignalUtilitiesKit/Utilities/NSSet+Functional.h +++ /dev/null @@ -1,9 +0,0 @@ -#import - -@interface NSSet (Functional) - -- (BOOL)contains:(BOOL (^)(id))predicate; -- (NSSet *)filtered:(BOOL (^)(id))isIncluded; -- (NSSet *)map:(id (^)(id))transform; - -@end diff --git a/SignalUtilitiesKit/Utilities/NSSet+Functional.m b/SignalUtilitiesKit/Utilities/NSSet+Functional.m deleted file mode 100644 index c19d814fd..000000000 --- a/SignalUtilitiesKit/Utilities/NSSet+Functional.m +++ /dev/null @@ -1,32 +0,0 @@ -#import "NSSet+Functional.h" - -@implementation NSSet (Functional) - -- (BOOL)contains:(BOOL (^)(id))predicate { - for (id object in self) { - BOOL isPredicateSatisfied = predicate(object); - if (isPredicateSatisfied) { return YES; } - } - return NO; -} - -- (NSSet *)filtered:(BOOL (^)(id))isIncluded { - NSMutableSet *result = [NSMutableSet new]; - for (id object in self) { - if (isIncluded(object)) { - [result addObject:object]; - } - } - return result; -} - -- (NSSet *)map:(id (^)(id))transform { - NSMutableSet *result = [NSMutableSet new]; - for (id object in self) { - id transformedObject = transform(object); - [result addObject:transformedObject]; - } - return result; -} - -@end diff --git a/SignalUtilitiesKit/Utilities/OWSAnyTouchGestureRecognizer.h b/SignalUtilitiesKit/Utilities/OWSAnyTouchGestureRecognizer.h deleted file mode 100644 index ce356c118..000000000 --- a/SignalUtilitiesKit/Utilities/OWSAnyTouchGestureRecognizer.h +++ /dev/null @@ -1,22 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -NSString *NSStringForUIGestureRecognizerState(UIGestureRecognizerState state); - -// This custom GR can be used to detect touches when they -// begin in a view. In order to honor touch dispatch, this -// GR will ignore touches that: -// -// * Are not single touches. -// * Are not in the view for this GR. -// * Are inside a visible, interaction-enabled subview. -@interface OWSAnyTouchGestureRecognizer : UIGestureRecognizer - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/OWSAnyTouchGestureRecognizer.m b/SignalUtilitiesKit/Utilities/OWSAnyTouchGestureRecognizer.m deleted file mode 100644 index bd3f849cb..000000000 --- a/SignalUtilitiesKit/Utilities/OWSAnyTouchGestureRecognizer.m +++ /dev/null @@ -1,132 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSAnyTouchGestureRecognizer.h" -#import - -NS_ASSUME_NONNULL_BEGIN - -NSString *NSStringForUIGestureRecognizerState(UIGestureRecognizerState state) -{ - switch (state) { - case UIGestureRecognizerStatePossible: - return @"UIGestureRecognizerStatePossible"; - case UIGestureRecognizerStateBegan: - return @"UIGestureRecognizerStateBegan"; - case UIGestureRecognizerStateChanged: - return @"UIGestureRecognizerStateChanged"; - case UIGestureRecognizerStateEnded: - return @"UIGestureRecognizerStateEnded"; - case UIGestureRecognizerStateCancelled: - return @"UIGestureRecognizerStateCancelled"; - case UIGestureRecognizerStateFailed: - return @"UIGestureRecognizerStateFailed"; - } -} - -@implementation OWSAnyTouchGestureRecognizer - -- (BOOL)canPreventGestureRecognizer:(UIGestureRecognizer *)preventedGestureRecognizer -{ - return NO; -} - -- (BOOL)canBePreventedByGestureRecognizer:(UIGestureRecognizer *)preventedGestureRecognizer -{ - return NO; -} - -- (BOOL)shouldRequireFailureOfGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer -{ - return NO; -} - -- (BOOL)shouldBeRequiredToFailByGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer -{ - return YES; -} - -- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event -{ - [super touchesBegan:touches withEvent:event]; - - if (self.state == UIGestureRecognizerStatePossible && [self isValidTouch:touches event:event]) { - self.state = UIGestureRecognizerStateRecognized; - } else { - self.state = UIGestureRecognizerStateFailed; - } -} - -- (UIView *)rootViewInViewHierarchy:(UIView *)view -{ - OWSAssertDebug(view); - UIResponder *responder = view; - UIView *lastView = nil; - while (responder) { - if ([responder isKindOfClass:[UIView class]]) { - lastView = (UIView *)responder; - } - responder = [responder nextResponder]; - } - return lastView; -} - -- (BOOL)isValidTouch:(NSSet *)touches event:(UIEvent *)event -{ - if (event.allTouches.count > 1) { - return NO; - } - if (touches.count != 1) { - return NO; - } - - UITouch *touch = touches.anyObject; - CGPoint location = [touch locationInView:self.view]; - if (!CGRectContainsPoint(self.view.bounds, location)) { - return NO; - } - - if ([self subviewControlOfView:self.view containsTouch:touch]) { - return NO; - } - - // Ignore touches that start near the top or bottom edge of the screen; - // they may be a system edge swipe gesture. - UIView *rootView = [self rootViewInViewHierarchy:self.view]; - CGPoint rootLocation = [touch locationInView:rootView]; - CGFloat distanceToTopEdge = MAX(0, rootLocation.y); - CGFloat distanceToBottomEdge = MAX(0, rootView.bounds.size.height - rootLocation.y); - CGFloat distanceToNearestEdge = MIN(distanceToTopEdge, distanceToBottomEdge); - CGFloat kSystemEdgeSwipeTolerance = 50.f; - if (distanceToNearestEdge < kSystemEdgeSwipeTolerance) { - return NO; - } - - return YES; -} - -- (BOOL)subviewControlOfView:(UIView *)superview containsTouch:(UITouch *)touch -{ - for (UIView *subview in superview.subviews) { - if (subview.hidden || !subview.userInteractionEnabled) { - continue; - } - CGPoint location = [touch locationInView:subview]; - if (!CGRectContainsPoint(subview.bounds, location)) { - continue; - } - if ([subview isKindOfClass:[UIControl class]]) { - return YES; - } - if ([self subviewControlOfView:subview containsTouch:touch]) { - return YES; - } - } - - return NO; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/UIGestureRecognizer+OWS.swift b/SignalUtilitiesKit/Utilities/UIGestureRecognizer+OWS.swift index 01dad6400..5baf4ac43 100644 --- a/SignalUtilitiesKit/Utilities/UIGestureRecognizer+OWS.swift +++ b/SignalUtilitiesKit/Utilities/UIGestureRecognizer+OWS.swift @@ -3,10 +3,25 @@ // import Foundation +import UIKit extension UIGestureRecognizer { @objc public var stateString: String { - return NSStringForUIGestureRecognizerState(state) + return state.asString + } +} + +extension UIGestureRecognizer.State { + fileprivate var asString: String { + switch self { + case .possible: return "UIGestureRecognizerStatePossible" + case .began: return "UIGestureRecognizerStateBegan" + case .changed: return "UIGestureRecognizerStateChanged" + case .ended: return "UIGestureRecognizerStateEnded" + case .cancelled: return "UIGestureRecognizerStateCancelled" + case .failed: return "UIGestureRecognizerStateFailed" + @unknown default: return "UIGestureRecognizerStateUnknown" + } } } From 18d833f152f102db59add1b15deb1947fbfb94c5 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 1 Jun 2022 16:50:21 +1000 Subject: [PATCH 094/157] Optimised the home screen query (~50% speed improvement) Updated to the latest version of GRDB Renamed some variables for clarity Updated the "seed viewed" banner on the HomeVC to be driven by a database setting to be consistent with other UI changes --- Podfile.lock | 4 +- .../Conversations/ConversationViewModel.swift | 4 +- Session/Home/HomeVC.swift | 107 +++++------ Session/Home/HomeViewModel.swift | 71 ++++--- Session/Notifications/AppNotifications.swift | 14 +- Session/Onboarding/Onboarding.swift | 4 +- Session/Onboarding/SeedReminderView.swift | 6 +- Session/Onboarding/SeedVC.swift | 4 +- Session/Settings/SeedModal.swift | 4 +- .../Database/LegacyDatabase/SMKLegacy.swift | 1 + .../_001_InitialSetupMigration.swift | 4 +- .../Migrations/_002_SetupStandardJobs.swift | 2 + .../Migrations/_003_YDBToGRDBMigration.swift | 5 + .../Database/Models/Interaction.swift | 11 +- .../Database/Models/Profile.swift | 10 +- .../Database/Models/RecipientState.swift | 43 ++--- .../Database/Models/SessionThread.swift | 63 +++---- .../Database/Notification+Contacts.swift | 4 - .../Shared Models/MessageViewModel.swift | 46 ++--- .../SessionThreadViewModel.swift | 178 +++++++----------- .../Utilities/Preferences.swift | 3 + .../NSENotificationPresenter.swift | 10 +- .../_001_InitialSetupMigration.swift | 2 + .../Migrations/_002_SetupStandardJobs.swift | 2 + .../Migrations/_003_YDBToGRDBMigration.swift | 2 + .../_001_InitialSetupMigration.swift | 2 + .../Migrations/_002_SetupStandardJobs.swift | 2 + .../Migrations/_003_YDBToGRDBMigration.swift | 2 + .../General/SNUserDefaults.swift | 1 - .../Utilities/Notification+Loki.swift | 6 - 30 files changed, 300 insertions(+), 317 deletions(-) diff --git a/Podfile.lock b/Podfile.lock index f4f18dfca..fc074549d 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -27,7 +27,7 @@ PODS: - DifferenceKit/Core (1.2.0) - DifferenceKit/UIKitExtension (1.2.0): - DifferenceKit/Core - - GRDB.swift/SQLCipher (5.24.0): + - GRDB.swift/SQLCipher (5.24.1): - SQLCipher (>= 3.4.0) - Mantle (2.1.0): - Mantle/extobjc (= 2.1.0) @@ -203,7 +203,7 @@ SPEC CHECKSUMS: CryptoSwift: a532e74ed010f8c95f611d00b8bbae42e9fe7c17 Curve25519Kit: e63f9859ede02438ae3defc5e1a87e09d1ec7ee6 DifferenceKit: 5659c430bb7fe45876fa32ce5cba5d6167f0c805 - GRDB.swift: 7ecc8799aaa97cf1fbbcfa9d75821aa920cb713f + GRDB.swift: b3180ce2135fc06a453297889b746b1478c4d8c7 Mantle: 2fa750afa478cd625a94230fbf1c13462f29395b NVActivityIndicatorView: 1f6c5687f1171810aa27a3296814dc2d7dec3667 OpenSSL-Universal: e7311447fd2419f57420c79524b641537387eff2 diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 8c7d2ea0d..98901985f 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -180,9 +180,9 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { public var onInteractionChange: (([SectionModel]) -> ())? private func process(data: [MessageViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] { - let typingIndicator: MessageViewModel? = data.first(where: { $0.isTypingIndicator }) + let typingIndicator: MessageViewModel? = data.first(where: { $0.isTypingIndicator == true }) let sortedData: [MessageViewModel] = data - .filter { !$0.isTypingIndicator } + .filter { $0.isTypingIndicator != true } .sorted { lhs, rhs -> Bool in lhs.timestampMs < rhs.timestampMs } // We load messages from newest to oldest so having a pageOffset larger than zero means diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 7cd8d113b..b2469e626 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -34,6 +34,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve result.subtitle = NSLocalizedString("view_seed_reminder_subtitle_1", comment: "") result.setProgress(0.8, animated: false) result.delegate = self + result.isHidden = !self.viewModel.state.showViewedSeedBanner return result }() @@ -131,13 +132,10 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve setUpNavBarSessionHeading() // Recovery phrase reminder - let hasViewedSeed = UserDefaults.standard[.hasViewedSeed] - if !hasViewedSeed { - view.addSubview(seedReminderView) - seedReminderView.pin(.leading, to: .leading, of: view) - seedReminderView.pin(.top, to: .top, of: view) - seedReminderView.pin(.trailing, to: .trailing, of: view) - } + view.addSubview(seedReminderView) + seedReminderView.pin(.leading, to: .leading, of: view) + seedReminderView.pin(.top, to: .top, of: view) + seedReminderView.pin(.trailing, to: .trailing, of: view) // Loading conversations label view.addSubview(loadingConversationsLabel) @@ -149,9 +147,10 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve // Table view view.addSubview(tableView) tableView.pin(.leading, to: .leading, of: view) - if !hasViewedSeed { + if self.viewModel.state.showViewedSeedBanner { tableViewTopConstraint = tableView.pin(.top, to: .bottom, of: seedReminderView) - } else { + } + else { tableViewTopConstraint = tableView.pin(.top, to: .top, of: view, withInset: Values.smallSpacing) } tableView.pin(.trailing, to: .trailing, of: view) @@ -187,11 +186,6 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve name: UIApplication.didEnterBackgroundNotification, object: nil ) - notificationCenter.addObserver(self, selector: #selector(handleProfileDidChangeNotification(_:)), name: .otherUsersProfileDidChange, object: nil) - notificationCenter.addObserver(self, selector: #selector(handleLocalProfileDidChangeNotification(_:)), name: .localProfileDidChange, object: nil) - notificationCenter.addObserver(self, selector: #selector(handleSeedViewedNotification(_:)), name: .seedViewed, object: nil) - notificationCenter.addObserver(self, selector: #selector(handleBlockedContactsUpdatedNotification(_:)), name: .blockedContactsUpdated, object: nil) - // Start polling if needed (i.e. if the user just created or restored their Session ID) if Identity.userExists(), let appDelegate: AppDelegate = UIApplication.shared.delegate as? AppDelegate { appDelegate.startPollersIfNeeded() @@ -235,21 +229,28 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve private func startObservingChanges() { // Start observing for data changes dataChangeObservable = GRDBStorage.shared.start( - viewModel.observableViewData, + viewModel.observableState, + // If we haven't done the initial load the trigger it immediately (blocking the main + // thread so we remain on the launch screen until it completes to be consistent with + // the old behaviour) + scheduling: (hasLoadedInitialData ? + .async(onQueue: .main) : + .immediate + ), onError: { _ in }, - onChange: { [weak self] viewData in + onChange: { [weak self] state in // The default scheduler emits changes on the main thread - self?.handleUpdates(viewData) + self?.handleUpdates(state) } ) } - private func handleUpdates(_ updatedViewData: [ArraySection]) { + private func handleUpdates(_ updatedState: HomeViewModel.State) { // 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) } + UIView.performWithoutAnimation { handleUpdates(updatedState) } return } @@ -258,48 +259,42 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve // Show the empty state if there is no data emptyStateView.isHidden = ( - !updatedViewData.isEmpty && - updatedViewData.contains(where: { !$0.elements.isEmpty }) + !updatedState.sections.isEmpty && + updatedState.sections.contains(where: { !$0.elements.isEmpty }) ) + // Update the 'view seed' UI + if updatedState.showViewedSeedBanner != self.viewModel.state.showViewedSeedBanner { + tableViewTopConstraint.isActive = false + seedReminderView.isHidden = !updatedState.showViewedSeedBanner + + if updatedState.showViewedSeedBanner { + tableViewTopConstraint = tableView.pin(.top, to: .bottom, of: seedReminderView) + } + else { + tableViewTopConstraint = tableView.pin(.top, to: .top, of: view, withInset: Values.smallSpacing) + } + } + // Reload the table content (animate changes after the first load) tableView.reload( - using: StagedChangeset(source: viewModel.viewData, target: updatedViewData), + using: StagedChangeset(source: viewModel.state.sections, target: updatedState.sections), deleteSectionsAnimation: .none, insertSectionsAnimation: .none, reloadSectionsAnimation: .none, deleteRowsAnimation: .bottom, insertRowsAnimation: .top, reloadRowsAnimation: .none, - interrupt: { - print("Interrupt change check: \($0.changeCount)") - return $0.changeCount > 100 - } // Prevent too many changes from causing performance issues - ) { [weak self] updatedData in - self?.viewModel.updateData(updatedData) + interrupt: { $0.changeCount > 100 } // Prevent too many changes from causing performance issues + ) { [weak self] updatedSections in + guard let currentState: HomeViewModel.State = self?.viewModel.state else { return } + + self?.viewModel.updateState(currentState.with(sections: updatedSections)) } - } - - @objc private func handleProfileDidChangeNotification(_ notification: Notification) { - DispatchQueue.main.async { - self.tableView.reloadData() // TODO: Just reload the affected cell - } - } - - @objc private func handleLocalProfileDidChangeNotification(_ notification: Notification) { - DispatchQueue.main.async { - self.updateNavBarButtons() - } - } - - @objc private func handleSeedViewedNotification(_ notification: Notification) { - tableViewTopConstraint.isActive = false - tableViewTopConstraint = tableView.pin(.top, to: .top, of: view, withInset: Values.smallSpacing) - seedReminderView.removeFromSuperview() - } - - @objc private func handleBlockedContactsUpdatedNotification(_ notification: Notification) { - self.tableView.reloadData() // TODO: Just reload the affected cell + + self.viewModel.updateState( + self.viewModel.state.with(showViewedSeedBanner: updatedState.showViewedSeedBanner) + ) } private func updateNavBarButtons() { @@ -358,15 +353,15 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve // MARK: - UITableViewDataSource func numberOfSections(in tableView: UITableView) -> Int { - return viewModel.viewData.count + return viewModel.state.sections.count } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return viewModel.viewData[section].elements.count + return viewModel.state.sections[section].elements.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let section: ArraySection = viewModel.viewData[indexPath.section] + let section: ArraySection = viewModel.state.sections[indexPath.section] switch section.model { case .messageRequests: @@ -386,7 +381,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - let section: ArraySection = viewModel.viewData[indexPath.section] + let section: ArraySection = viewModel.state.sections[indexPath.section] switch section.model { case .messageRequests: @@ -404,11 +399,11 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve } func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? { - let section: ArraySection = viewModel.viewData[indexPath.section] + let section: ArraySection = viewModel.state.sections[indexPath.section] switch section.model { case .messageRequests: - let hide = UITableViewRowAction(style: .destructive, title: NSLocalizedString("TXT_HIDE_TITLE", comment: "")) { [weak self] _, _ in + let hide = UITableViewRowAction(style: .destructive, title: "TXT_HIDE_TITLE".localized()) { [weak self] _, _ in GRDBStorage.shared.write { db in db[.hasHiddenMessageRequests] = true } // Animate the row removal diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index a17730a42..95c3429ee 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -11,8 +11,26 @@ public class HomeViewModel { case threads } + public struct State: Equatable { + let showViewedSeedBanner: Bool + let sections: [ArraySection] + + func with( + showViewedSeedBanner: Bool? = nil, + sections: [ArraySection]? = nil + ) -> State { + return State( + showViewedSeedBanner: (showViewedSeedBanner ?? self.showViewedSeedBanner), + sections: (sections ?? self.sections) + ) + } + } + /// This value is the current state of the view - public private(set) var viewData: [ArraySection] = [] + public private(set) var state: State = State( + showViewedSeedBanner: !GRDBStorage.shared[.hasViewedSeed], + sections: [] + ) /// 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 @@ -21,7 +39,7 @@ public class HomeViewModel { /// 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 + public lazy var observableState = ValueObservation .tracking( regions: [ // We explicitly define the regions we want to track as the automatic detection @@ -34,7 +52,10 @@ public class HomeViewModel { .mutedUntilTimestamp, .onlyNotifyForMentions ), - Setting.filter(id: Setting.BoolKey.hasHiddenMessageRequests.rawValue), + Setting.filter(ids: [ + Setting.BoolKey.hasHiddenMessageRequests.rawValue, + Setting.BoolKey.hasViewedSeed.rawValue + ]), Contact.select(.isBlocked, .isApproved), // 'isApproved' for message requests Profile.select(.name, .nickname, .profilePictureFileName), ClosedGroup.select(.name), @@ -48,7 +69,8 @@ public class HomeViewModel { RecipientState.select(.state), ThreadTypingIndicator.select(.threadId) ], - fetch: { db -> [ArraySection] in + fetch: { db -> State in + let hasViewedSeed: Bool = db[.hasViewedSeed] let userPublicKey: String = getUserHexEncodedPublicKey(db) let unreadMessageRequestCount: Int = try SessionThread .unreadMessageRequestsCountQuery(userPublicKey: userPublicKey) @@ -59,31 +81,34 @@ public class HomeViewModel { .homeQuery(userPublicKey: userPublicKey) .fetchAll(db) - return [ - ArraySection( - model: .messageRequests, - elements: [ - // If there are no unread message requests then hide the message request banner - (finalUnreadMessageRequestCount == 0 ? - nil : - SessionThreadViewModel( - unreadCount: UInt(finalUnreadMessageRequestCount) + return State( + showViewedSeedBanner: !hasViewedSeed, + sections: [ + ArraySection( + model: .messageRequests, + elements: [ + // If there are no unread message requests then hide the message request banner + (finalUnreadMessageRequestCount == 0 ? + nil : + SessionThreadViewModel( + unreadCount: UInt(finalUnreadMessageRequestCount) + ) ) - ) - ].compactMap { $0 } - ), - ArraySection( - model: .threads, - elements: threads - ) - ] + ].compactMap { $0 } + ), + ArraySection( + model: .threads, + elements: threads + ) + ] + ) } ) .removeDuplicates() // MARK: - Functions - public func updateData(_ updatedData: [ArraySection]) { - self.viewData = updatedData + public func updateState(_ updatedState: State) { + self.state = updatedState } } diff --git a/Session/Notifications/AppNotifications.swift b/Session/Notifications/AppNotifications.swift index 337a4d4a1..0315b8eeb 100644 --- a/Session/Notifications/AppNotifications.swift +++ b/Session/Notifications/AppNotifications.swift @@ -152,7 +152,8 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, isBackgroundPoll: Bool) { guard Date().timeIntervalSince1970 < (thread.mutedUntilTimestamp ?? 0) else { return } - let isMessageRequest = thread.isMessageRequest(db) + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let isMessageRequest: Bool = thread.isMessageRequest(db) // If the thread is a message request and the user hasn't hidden message requests then we need // to check if this is the only message request thread (group threads can't be message requests @@ -160,8 +161,10 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { // notification regardless of how many message requests there are) if thread.variant == .contact { if isMessageRequest && !db[.hasHiddenMessageRequests] { - let numMessageRequestThreads: Int? = try? SessionThread.messageRequestThreads(db) - .fetchCount(db) + let numMessageRequestThreads: Int? = (try? SessionThread + .messageRequestsCountQuery(userPublicKey: userPublicKey) + .fetchOne(db)) + .defaulting(to: 0) // Allow this to show a notification if there are no message requests (ie. this is the first one) guard (numMessageRequestThreads ?? 0) == 0 else { return } @@ -244,7 +247,10 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { notificationBody = "MESSAGE_REQUESTS_NOTIFICATION".localized() } - assert((notificationBody ?? notificationTitle) != nil) + guard notificationBody != nil || notificationTitle != nil else { + SNLog("AppNotifications error: No notification content") + return + } // Don't reply from lockscreen if anyone in this conversation is // "no longer verified". diff --git a/Session/Onboarding/Onboarding.swift b/Session/Onboarding/Onboarding.swift index aad20b279..f5d1fc564 100644 --- a/Session/Onboarding/Onboarding.swift +++ b/Session/Onboarding/Onboarding.swift @@ -28,7 +28,7 @@ enum Onboarding { switch self { case .register: - userDefaults[.hasViewedSeed] = false + GRDBStorage.shared.write { db in db[.hasViewedSeed] = false } // Set hasSyncedInitialConfiguration to true so that when we hit the // home screen a configuration sync is triggered (yes, the logic is a // bit weird). This is needed so that if the user registers and @@ -37,7 +37,7 @@ enum Onboarding { case .recover, .link: // No need to show it again if the user is restoring or linking - userDefaults[.hasViewedSeed] = true + GRDBStorage.shared.write { db in db[.hasViewedSeed] = true } userDefaults[.hasSyncedInitialConfiguration] = false } diff --git a/Session/Onboarding/SeedReminderView.swift b/Session/Onboarding/SeedReminderView.swift index ca165b034..f75e4e48f 100644 --- a/Session/Onboarding/SeedReminderView.swift +++ b/Session/Onboarding/SeedReminderView.swift @@ -1,5 +1,9 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -final class SeedReminderView : UIView { +import UIKit +import SessionUIKit + +final class SeedReminderView: UIView { private let hasContinueButton: Bool var title = NSAttributedString(string: "") { didSet { titleLabel.attributedText = title } } var subtitle = "" { didSet { subtitleLabel.text = subtitle } } diff --git a/Session/Onboarding/SeedVC.swift b/Session/Onboarding/SeedVC.swift index 3a3405ead..c519275a3 100644 --- a/Session/Onboarding/SeedVC.swift +++ b/Session/Onboarding/SeedVC.swift @@ -170,8 +170,8 @@ final class SeedVC: BaseVC { self.seedReminderView.subtitle = NSLocalizedString("view_seed_reminder_subtitle_3", comment: "") }, completion: nil) seedReminderView.setProgress(1, animated: true) - UserDefaults.standard[.hasViewedSeed] = true - NotificationCenter.default.post(name: .seedViewed, object: nil) + + GRDBStorage.shared.write { db in db[.hasViewedSeed] = true } } @objc private func copyMnemonic() { diff --git a/Session/Settings/SeedModal.swift b/Session/Settings/SeedModal.swift index 6146dced3..f62fd4252 100644 --- a/Session/Settings/SeedModal.swift +++ b/Session/Settings/SeedModal.swift @@ -71,9 +71,9 @@ final class SeedModal: Modal { stackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing) contentView.pin(.trailing, to: .trailing, of: stackView, withInset: Values.largeSpacing) contentView.pin(.bottom, to: .bottom, of: stackView, withInset: Values.largeSpacing) + // Mark seed as viewed - UserDefaults.standard[.hasViewedSeed] = true - NotificationCenter.default.post(name: .seedViewed, object: nil) + GRDBStorage.shared.write { db in db[.hasViewedSeed] = true } } // MARK: Interaction diff --git a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift index e06e2fade..5660d060d 100644 --- a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift +++ b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift @@ -75,6 +75,7 @@ public enum SMKLegacy { internal static let soundsGlobalNotificationKey = "kOWSSoundsStorageGlobalNotificationKey" internal static let userDefaultsHasHiddenMessageRequests = "hasHiddenMessageRequests" + internal static let userDefaultsHasViewedSeedKey = "hasViewedSeed" // MARK: - DatabaseMigration diff --git a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift index 5b4dcf4ad..345068b11 100644 --- a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -335,8 +335,10 @@ enum _001_InitialSetupMigration: Migration { try db.create(table: ThreadTypingIndicator.self) { t in t.column(.threadId, .text) .primaryKey() - .references(SessionThread.self, onDelete: .cascade) // Delete if thread deleted + .references(SessionThread.self, onDelete: .cascade) // Delete if thread deleted t.column(.timestampMs, .integer).notNull() } + + GRDBStorage.shared.update(progress: 1, for: self, in: target) // In case this is the last migration } } diff --git a/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift index dac6eaebc..877693bf5 100644 --- a/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -51,5 +51,7 @@ enum _002_SetupStandardJobs: Migration { behaviour: .recurringOnLaunch ).inserted(db) } + + GRDBStorage.shared.update(progress: 1, for: self, in: target) // In case this is the last migration } } diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 38d28b322..1d14576af 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -1295,9 +1295,14 @@ enum _003_YDBToGRDBMigration: Migration { db[.areLinkPreviewsEnabled] = (legacyPreferences[SMKLegacy.preferencesKeyAreLinkPreviewsEnabled] as? Bool == true) db[.hasHiddenMessageRequests] = CurrentAppContext().appUserDefaults() .bool(forKey: SMKLegacy.userDefaultsHasHiddenMessageRequests) + + // Note: The 'hasViewedSeed' was originally stored on standard user defaults + db[.hasViewedSeed] = UserDefaults.standard.bool(forKey: SMKLegacy.userDefaultsHasViewedSeedKey) db[.hasSavedThread] = (legacyPreferences[SMKLegacy.preferencesKeyHasSavedThreadKey] as? Bool == true) db[.hasSentAMessage] = (legacyPreferences[SMKLegacy.preferencesKeyHasSentAMessageKey] as? Bool == true) db[.isReadyForAppExtensions] = CurrentAppContext().appUserDefaults().bool(forKey: SMKLegacy.preferencesKeyIsReadyForAppExtensions) + + GRDBStorage.shared.update(progress: 1, for: self, in: target) // In case this is the last migration } // MARK: - Convenience diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 3a3779c0d..12160586a 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -27,12 +27,13 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu /// Whenever using this `linkPreview` association make sure to filter the result using /// `.filter(literal: Interaction.linkPreviewFilterLiteral)` to ensure the correct LinkPreview is returned public static let linkPreview = hasOne(LinkPreview.self, using: LinkPreview.interactionForeignKey) - public static let linkPreviewFilterLiteral: SQL = { - let interaction: TypedTableAlias = TypedTableAlias() + public static func linkPreviewFilterLiteral( + timestampColumn: SQL = SQL(stringLiteral: Interaction.Columns.timestampMs.name) + ) -> SQL { let linkPreview: TypedTableAlias = TypedTableAlias() - return "(ROUND((\(interaction[.timestampMs]) / 1000 / 100000) - 0.5) * 100000) = \(linkPreview[.timestamp])" - }() + return "(ROUND((\(Interaction.self).\(timestampColumn) / 1000 / 100000) - 0.5) * 100000) = \(linkPreview[.timestamp])" + } public static let recipientStates = hasMany(RecipientState.self, using: RecipientState.interactionForeignKey) public typealias Columns = CodingKeys @@ -354,7 +355,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu .aliased(interactionAlias) .joining( required: Interaction.linkPreview - .filter(literal: Interaction.linkPreviewFilterLiteral) + .filter(literal: Interaction.linkPreviewFilterLiteral()) ) .fetchCount(db) let tmp = try linkPreview.fetchAll(db) diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index bd6cbf6d9..977eb4541 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -90,14 +90,8 @@ public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableReco } } - // Since it's possible this profile is currently being displayed, send notifications - // indicating that it has been updated - NotificationCenter.default.post(name: .profileUpdated, object: id) - - if id == getUserHexEncodedPublicKey(db) { - NotificationCenter.default.post(name: .localProfileDidChange, object: nil) - } - else { + // FIXME: Remove this once the OWSConversationSettingsViewController has been refactored and is observing DB changes + if id != getUserHexEncodedPublicKey(db) { let userInfo = [ Notification.Key.profileRecipientId.rawValue: id ] NotificationCenter.default.post(name: .otherUsersProfileDidChange, object: nil, userInfo: userInfo) } diff --git a/SessionMessagingKit/Database/Models/RecipientState.swift b/SessionMessagingKit/Database/Models/RecipientState.swift index 01bb0a1b0..9c29d2002 100644 --- a/SessionMessagingKit/Database/Models/RecipientState.swift +++ b/SessionMessagingKit/Database/Models/RecipientState.swift @@ -22,20 +22,34 @@ public struct RecipientState: Codable, Equatable, FetchableRecord, PersistableRe } public enum State: Int, Codable, Hashable, DatabaseValueConvertible { - case failed + /// These cases **MUST** remain in this order (even though having `failed` as `0` would be more logical) as the order + /// is optimised for the desired "interactionState" grouping behaviour we want which makes the query to retrieve the interaction + /// state run ~16 times than the alternate approach which required a sub-query (check git history to see the old approach at the + /// bottom of this file if desired) + /// + /// The expected behaviour of the grouped "interactionState" that both the `SessionThreadViewModel` and + /// `MessageViewModel` should use is `IFNULL(MIN("recipientState"."state"), 'sending')` (joining on the + /// `interaction.id` and `state != 'skipped'`): + /// - The 'skipped' state should be ignored entirely + /// - If there is no state (ie. interaction recipient records not yet created) then the interaction state should be 'sending' + /// - If there is a single 'sending' state then the interaction state should be 'sending' + /// - If there is a single 'failed' state and no 'sending' state then the interaction state should be 'failed' + /// - If there are neither 'sending' or 'failed' states then the interaction state should be 'sent' case sending + case failed case skipped case sent func message(hasAttachments: Bool, hasAtLeastOneReadReceipt: Bool) -> String { switch self { - case .failed: return "MESSAGE_STATUS_FAILED".localized() case .sending: guard hasAttachments else { return "MESSAGE_STATUS_SENDING".localized() } return "MESSAGE_STATUS_UPLOADING".localized() + + case .failed: return "MESSAGE_STATUS_FAILED".localized() case .sent: guard hasAtLeastOneReadReceipt else { @@ -117,28 +131,3 @@ public extension RecipientState { ) } } - -// MARK: - GRDB Queries - -public extension RecipientState { - static func selectInteractionState(tableLiteral: SQL, idColumnLiteral: SQL) -> SQL { - let recipientState: TypedTableAlias = TypedTableAlias() - - return """ - SELECT * FROM ( - SELECT - \(recipientState[.interactionId]), - \(recipientState[.state]), - \(recipientState[.mostRecentFailureText]) - FROM \(RecipientState.self) - WHERE \(SQL("\(recipientState[.state]) != \(RecipientState.State.skipped)")) -- Ignore 'skipped' - ORDER BY - -- If there is a single 'sending' then should be 'sending', otherwise if there is a single - -- 'failed' and there is no 'sending' then it should be 'failed' - \(SQL("\(recipientState[.state]) = \(RecipientState.State.sending)")) DESC, - \(SQL("\(recipientState[.state]) = \(RecipientState.State.failed)")) DESC - ) AS \(tableLiteral) - GROUP BY \(tableLiteral).\(idColumnLiteral) - """ - } -} diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index c2f517965..803fe7f22 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -185,17 +185,6 @@ public extension SessionThread { return existingThread } - static func messageRequestThreads(_ db: Database) -> QueryInterfaceRequest { - return SessionThread - .filter(Columns.shouldBeVisible == true) - .filter(Columns.variant == Variant.contact) - .filter(Columns.id != getUserHexEncodedPublicKey(db)) - .joining( - optional: contact - .filter(Contact.Columns.isApproved == false) - ) - } - func isMessageRequest(_ db: Database) -> Bool { return ( shouldBeVisible && @@ -209,23 +198,38 @@ public extension SessionThread { // MARK: - Convenience public extension SessionThread { + static func messageRequestsCountQuery(userPublicKey: String) -> SQLRequest { + let thread: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() + + return """ + SELECT COUNT(\(thread[.id])) + FROM \(SessionThread.self) + LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) + WHERE ( + \(SessionThread.isMessageRequest(userPublicKey: userPublicKey)) + ) + """ + } + static func unreadMessageRequestsCountQuery(userPublicKey: String) -> SQLRequest { let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() - let unreadInteractionLiteral: SQL = SQL(stringLiteral: "unreadInteraction") - let interactionThreadIdColumnLiteral: SQL = SQL(stringLiteral: Interaction.Columns.threadId.name) - return """ SELECT COUNT(\(thread[.id])) FROM \(SessionThread.self) JOIN ( - SELECT \(interaction[.threadId]) + SELECT + \(interaction[.threadId]), + MIN(\(interaction[.wasRead])) AS \(SQL(stringLiteral: "\(Interaction.Columns.wasRead.name)")) FROM \(Interaction.self) - WHERE \(interaction[.wasRead]) = false GROUP BY \(interaction[.threadId]) - ) AS \(unreadInteractionLiteral) ON \(unreadInteractionLiteral).\(interactionThreadIdColumnLiteral) = \(thread[.id]) + ) AS \(Interaction.self) ON ( + \(interaction[.threadId]) = \(thread[.id]) AND + \(interaction[.wasRead]) = false + ) LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) WHERE ( \(SessionThread.isMessageRequest(userPublicKey: userPublicKey)) @@ -244,32 +248,13 @@ public extension SessionThread { return SQL( """ \(thread[.shouldBeVisible]) = true AND - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND - \(SQL("\(thread[.id]) != \(userPublicKey)")) AND ( - /* Note: A '!= true' check doesn't work properly so we need to be explicit */ - \(contact[.isApproved]) IS NULL OR - \(contact[.isApproved]) = false - ) + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND + \(SQL("\(thread[.id]) != \(userPublicKey)")) AND + IFNULL(\(contact[.isApproved]), false) = false """ ) } - /// This method can be used to filter a thread query to exclude messages requests - /// - /// **Note:** In order to use this filter you **MUST** have a `joining(required/optional:)` to the - /// `SessionThread.contact` association or it won't work - static func isNotMessageRequest(userPublicKey: String) -> SQLSpecificExpressible { - let contactAlias: TypedTableAlias = TypedTableAlias() - - return ( - SessionThread.Columns.shouldBeVisible == true && ( - SessionThread.Columns.variant != SessionThread.Variant.contact || - SessionThread.Columns.id == userPublicKey || // Note to self - contactAlias[.isApproved] == true - ) - ) - } - func isNoteToSelf(_ db: Database? = nil) -> Bool { return ( variant == .contact && diff --git a/SessionMessagingKit/Database/Notification+Contacts.swift b/SessionMessagingKit/Database/Notification+Contacts.swift index 857d88cb4..6679490cf 100644 --- a/SessionMessagingKit/Database/Notification+Contacts.swift +++ b/SessionMessagingKit/Database/Notification+Contacts.swift @@ -4,15 +4,11 @@ import SessionUtilitiesKit public extension Notification.Name { - static let profileUpdated = Notification.Name("profileUpdated") - static let localProfileDidChange = Notification.Name("localProfileDidChange") static let otherUsersProfileDidChange = Notification.Name("otherUsersProfileDidChange") } @objc public extension NSNotification { - @objc static let profileUpdated = Notification.Name.profileUpdated.rawValue as NSString - @objc static let localProfileDidChange = Notification.Name.localProfileDidChange.rawValue as NSString @objc static let otherUsersProfileDidChange = Notification.Name.otherUsersProfileDidChange.rawValue as NSString } diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index e7e5ea9e4..403f0913c 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -75,8 +75,8 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, public let state: RecipientState.State public let hasAtLeastOneReadReceipt: Bool public let mostRecentFailureText: String? - public let isTypingIndicator: Bool public let isSenderOpenGroupModerator: Bool + public let isTypingIndicator: Bool? public let profile: Profile? public let quote: Quote? public let quoteAttachment: Attachment? @@ -169,7 +169,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, isLast: Bool ) -> MessageViewModel { let cellType: CellType = { - guard !self.isTypingIndicator else { return .typingIndicator } + guard self.isTypingIndicator != true else { return .typingIndicator } guard self.variant != .standardIncomingDeleted else { return .textOnlyMessage } guard let attachment: Attachment = self.attachments?.first else { return .textOnlyMessage } @@ -208,7 +208,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, nickname: nil // Folded into 'authorName' within the Query ) let shouldShowDateOnThisModel: Bool = { - guard !self.isTypingIndicator else { return false } + guard self.isTypingIndicator != true else { return false } guard let prevModel: ViewModel = prevModel else { return true } return MessageViewModel.shouldShowDateBreak( @@ -218,7 +218,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, }() let shouldShowDateOnNextModel: Bool = { // Should be nothing after a typing indicator - guard !self.isTypingIndicator else { return false } + guard self.isTypingIndicator != true else { return false } guard let nextModel: ViewModel = nextModel else { return false } return MessageViewModel.shouldShowDateBreak( @@ -404,18 +404,21 @@ public extension MessageViewModel { // MARK: - Convenience Initialization public extension MessageViewModel { - public static let genericId: Int64 = -2 - public static let typingIndicatorId: Int64 = -2 + static let genericId: Int64 = -2 + static let typingIndicatorId: Int64 = -2 // Note: This init method is only used system-created cells or empty states - init(isTypingIndicator: Bool = false) { + init(isTypingIndicator: Bool? = nil) { self.threadVariant = .contact self.threadIsTrusted = false self.threadHasDisappearingMessagesEnabled = false // Interaction Info - let targetId: Int64 = (isTypingIndicator ? MessageViewModel.typingIndicatorId : MessageViewModel.genericId) + let targetId: Int64 = (isTypingIndicator == true ? + MessageViewModel.typingIndicatorId : + MessageViewModel.genericId + ) self.rowId = targetId self.id = targetId self.variant = .standardOutgoing @@ -513,7 +516,7 @@ public extension MessageViewModel { return { additionalFilters, limitSQL -> AdaptedFetchRequest> in let interaction: TypedTableAlias = TypedTableAlias() let thread: TypedTableAlias = TypedTableAlias() - let typingIndicator: TypedTableAlias = TypedTableAlias() + let recipientState: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() let disappearingMessagesConfig: TypedTableAlias = TypedTableAlias() let profile: TypedTableAlias = TypedTableAlias() @@ -522,7 +525,6 @@ public extension MessageViewModel { let interactionStateTableLiteral: SQL = SQL(stringLiteral: "interactionState") let interactionStateInteractionIdColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.interactionId.name) - let interactionStateStateColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.state.name) let interactionStateMostRecentFailureTextColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.mostRecentFailureText.name) let readReceiptTableLiteral: SQL = SQL(stringLiteral: "readReceipt") let readReceiptReadTimestampMsColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.readTimestampMs.name) @@ -543,7 +545,7 @@ public extension MessageViewModel { """ }() let finalLimitSQL: SQL = (limitSQL ?? SQL(stringLiteral: "")) - let numColumnsBeforeLinkedRecords: Int = 17 + let numColumnsBeforeLinkedRecords: Int = 16 let request: SQLRequest = """ SELECT \(thread[.variant]) AS \(ViewModel.threadVariantKey), @@ -563,11 +565,10 @@ public extension MessageViewModel { \(interaction[.expiresInSeconds]), -- Default to 'sending' assuming non-processed interaction when null - IFNULL(\(interactionStateTableLiteral).\(interactionStateStateColumnLiteral), \(SQL("\(RecipientState.State.sending)"))) AS \(ViewModel.stateKey), + IFNULL(MIN(\(recipientState[.state])), \(SQL("\(RecipientState.State.sending)"))) AS \(ViewModel.stateKey), (\(readReceiptTableLiteral).\(readReceiptReadTimestampMsColumnLiteral) IS NOT NULL) AS \(ViewModel.hasAtLeastOneReadReceiptKey), - \(interactionStateTableLiteral).\(interactionStateMostRecentFailureTextColumnLiteral) AS \(ViewModel.mostRecentFailureTextKey), - - (\(typingIndicator[.threadId]) IS NOT NULL) AS \(ViewModel.isTypingIndicatorKey), + \(recipientState[.mostRecentFailureText]) AS \(ViewModel.mostRecentFailureTextKey), + false AS \(ViewModel.isSenderOpenGroupModeratorKey), \(ViewModel.profileKey).*, @@ -587,7 +588,6 @@ public extension MessageViewModel { FROM \(Interaction.self) JOIN \(SessionThread.self) ON \(thread[.id]) = \(interaction[.threadId]) - LEFT JOIN \(ThreadTypingIndicator.self) ON \(typingIndicator[.threadId]) = \(thread[.id]) LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId]) LEFT JOIN \(DisappearingMessagesConfiguration.self) ON \(disappearingMessagesConfig[.threadId]) = \(interaction[.threadId]) LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId]) @@ -595,20 +595,20 @@ public extension MessageViewModel { LEFT JOIN \(Attachment.self) AS \(ViewModel.quoteAttachmentKey) ON \(ViewModel.quoteAttachmentKey).\(attachmentIdColumnLiteral) = \(quote[.attachmentId]) LEFT JOIN \(LinkPreview.self) ON ( \(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND - \(Interaction.linkPreviewFilterLiteral) + \(Interaction.linkPreviewFilterLiteral()) ) LEFT JOIN \(Attachment.self) AS \(ViewModel.linkPreviewAttachmentKey) ON \(ViewModel.linkPreviewAttachmentKey).\(attachmentIdColumnLiteral) = \(linkPreview[.attachmentId]) - LEFT JOIN ( - \(RecipientState.selectInteractionState( - tableLiteral: interactionStateTableLiteral, - idColumnLiteral: interactionStateInteractionIdColumnLiteral - )) - ) AS \(interactionStateTableLiteral) ON \(interactionStateTableLiteral).\(interactionStateInteractionIdColumnLiteral) = \(interaction[.id]) + LEFT JOIN \(RecipientState.self) ON ( + -- Ignore 'skipped' states + \(SQL("\(recipientState[.state]) != \(RecipientState.State.skipped)")) AND + \(recipientState[.interactionId]) = \(interaction[.id]) + ) LEFT JOIN \(RecipientState.self) AS \(readReceiptTableLiteral) ON ( \(readReceiptTableLiteral).\(readReceiptReadTimestampMsColumnLiteral) IS NOT NULL AND \(interaction[.id]) = \(readReceiptTableLiteral).\(interactionStateInteractionIdColumnLiteral) ) \(finalFilterSQL) + GROUP BY \(interaction[.id]) ORDER BY \(orderSQL) \(finalLimitSQL) """ diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 29c9e9361..8da43ce45 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -47,6 +47,7 @@ public struct SessionThreadViewModel: FetchableRecord, Decodable, Equatable, Has public static let interactionVariantKey: SQL = SQL(stringLiteral: CodingKeys.interactionVariant.stringValue) public static let interactionTimestampMsKey: SQL = SQL(stringLiteral: CodingKeys.interactionTimestampMs.stringValue) public static let interactionBodyKey: SQL = SQL(stringLiteral: CodingKeys.interactionBody.stringValue) + public static let interactionStateKey: SQL = SQL(stringLiteral: CodingKeys.interactionState.stringValue) public static let interactionIsOpenGroupInvitationKey: SQL = SQL(stringLiteral: CodingKeys.interactionIsOpenGroupInvitation.stringValue) public static let interactionAttachmentDescriptionInfoKey: SQL = SQL(stringLiteral: CodingKeys.interactionAttachmentDescriptionInfo.stringValue) public static let interactionAttachmentCountKey: SQL = SQL(stringLiteral: CodingKeys.interactionAttachmentCount.stringValue) @@ -262,27 +263,17 @@ public extension SessionThreadViewModel { let groupMember: TypedTableAlias = TypedTableAlias() let openGroup: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() + let recipientState: TypedTableAlias = TypedTableAlias() let linkPreview: TypedTableAlias = TypedTableAlias() + let attachment: TypedTableAlias = TypedTableAlias() let interactionAttachment: TypedTableAlias = TypedTableAlias() + let profile: TypedTableAlias = TypedTableAlias() - let unreadCountTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.threadUnreadCountString)_table") - let unreadMentionCountTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.threadUnreadMentionCountString)_table") - let interactionThreadIdLiteral: SQL = SQL(stringLiteral: Interaction.Columns.threadId.name) let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) - let profileNicknameColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.nickname.name) - let profileNameColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.name.name) - let authorProfileLiteral: SQL = SQL(stringLiteral: "authorProfile") - let attachmentIdColumnLiteral: SQL = SQL(stringLiteral: Attachment.Columns.id.name) - let attachmentVariantColumnLiteral: SQL = SQL(stringLiteral: Attachment.Columns.variant.name) - let attachmentContentTypeColumnLiteral: SQL = SQL(stringLiteral: Attachment.Columns.contentType.name) - let attachmentSourceFilenameColumnLiteral: SQL = SQL(stringLiteral: Attachment.Columns.sourceFilename.name) let firstInteractionAttachmentLiteral: SQL = SQL(stringLiteral: "firstInteractionAttachment") let interactionAttachmentAttachmentIdColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.attachmentId.name) let interactionAttachmentInteractionIdColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.interactionId.name) let interactionAttachmentAlbumIndexColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.albumIndex.name) - let interactionStateTableLiteral: SQL = SQL(stringLiteral: "interactionState") - let interactionStateInteractionIdColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.interactionId.name) - let interactionStateStateColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.state.name) /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to @@ -291,7 +282,6 @@ public extension SessionThreadViewModel { /// Explicitly set default values for the fields ignored for search results let numColumnsBeforeProfiles: Int = 11 let numColumnsBetweenProfilesAndAttachmentInfo: Int = 10 // The attachment info columns will be combined - // TODO: Some testing around the subqueries in the joins to see if they impact performance ('Simulator1' device takes ~125ms to complete this query) let request: SQLRequest = """ SELECT @@ -306,8 +296,8 @@ public extension SessionThreadViewModel { \(thread[.onlyNotifyForMentions]) AS \(ViewModel.threadOnlyNotifyForMentionsKey), (\(typingIndicator[.threadId]) IS NOT NULL) AS \(ViewModel.threadContactIsTypingKey), - \(unreadCountTableLiteral).\(ViewModel.threadUnreadCountKey) AS \(ViewModel.threadUnreadCountKey), - \(unreadMentionCountTableLiteral).\(ViewModel.threadUnreadMentionCountKey) AS \(ViewModel.threadUnreadMentionCountKey), + \(Interaction.self).\(ViewModel.threadUnreadCountKey), + \(Interaction.self).\(ViewModel.threadUnreadMentionCountKey), \(ViewModel.contactProfileKey).*, \(ViewModel.closedGroupProfileFrontKey).*, @@ -318,59 +308,75 @@ public extension SessionThreadViewModel { \(openGroup[.name]) AS \(ViewModel.openGroupNameKey), \(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey), - \(interaction[.id]) AS \(ViewModel.interactionIdKey), - \(interaction[.variant]) AS \(ViewModel.interactionVariantKey), - \(interaction[.timestampMs]) AS \(ViewModel.interactionTimestampMsKey), - \(interaction[.body]) AS \(ViewModel.interactionBodyKey), + \(Interaction.self).\(ViewModel.interactionIdKey), + \(Interaction.self).\(ViewModel.interactionVariantKey), + \(Interaction.self).\(ViewModel.interactionTimestampMsKey), + \(Interaction.self).\(ViewModel.interactionBodyKey), + -- Default to 'sending' assuming non-processed interaction when null - IFNULL(\(interactionStateTableLiteral).\(interactionStateStateColumnLiteral), \(SQL("\(RecipientState.State.sending)"))) AS \(interactionStateTableLiteral), + IFNULL(MIN(\(recipientState[.state])), \(SQL("\(RecipientState.State.sending)"))) AS \(ViewModel.interactionStateKey), (\(linkPreview[.url]) IS NOT NULL) AS \(ViewModel.interactionIsOpenGroupInvitationKey), - \(ViewModel.interactionAttachmentDescriptionInfoKey).\(attachmentIdColumnLiteral), - \(ViewModel.interactionAttachmentDescriptionInfoKey).\(attachmentVariantColumnLiteral), - \(ViewModel.interactionAttachmentDescriptionInfoKey).\(attachmentContentTypeColumnLiteral), - \(ViewModel.interactionAttachmentDescriptionInfoKey).\(attachmentSourceFilenameColumnLiteral), + + -- These 4 properties will be combined into 'Attachment.DescriptionInfo' + \(attachment[.id]), + \(attachment[.variant]), + \(attachment[.contentType]), + \(attachment[.sourceFilename]), COUNT(\(interactionAttachment[.interactionId])) AS \(ViewModel.interactionAttachmentCountKey), \(interaction[.authorId]), - IFNULL(\(authorProfileLiteral).\(profileNicknameColumnLiteral), \(authorProfileLiteral).\(profileNameColumnLiteral)) AS \(ViewModel.authorNameInternalKey), + IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.authorNameInternalKey), \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) FROM \(SessionThread.self) LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) LEFT JOIN \(ThreadTypingIndicator.self) ON \(typingIndicator[.threadId]) = \(thread[.id]) LEFT JOIN ( - SELECT *, MAX(\(interaction[.timestampMs])) + -- Fetch all interaction-specific data in a subquery to be more efficient + SELECT + \(interaction[.id]) AS \(ViewModel.interactionIdKey), + \(interaction[.threadId]), + \(interaction[.variant]) AS \(ViewModel.interactionVariantKey), + MAX(\(interaction[.timestampMs])) AS \(ViewModel.interactionTimestampMsKey), + \(interaction[.body]) AS \(ViewModel.interactionBodyKey), + \(interaction[.authorId]), + \(interaction[.linkPreviewUrl]), + + SUM(\(interaction[.wasRead]) = false) AS \(ViewModel.threadUnreadCountKey), + SUM(\(interaction[.wasRead]) = false AND \(interaction[.hasMention]) = true) AS \(ViewModel.threadUnreadMentionCountKey) + FROM \(Interaction.self) GROUP BY \(interaction[.threadId]) ) AS \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id]) - LEFT JOIN ( - SELECT - \(interaction[.threadId]), - COUNT(*) AS \(ViewModel.threadUnreadCountKey) - FROM \(Interaction.self) - WHERE \(interaction[.wasRead]) = false - GROUP BY \(interaction[.threadId]) - ) AS \(unreadCountTableLiteral) ON \(unreadCountTableLiteral).\(interactionThreadIdLiteral) = \(thread[.id]) - LEFT JOIN ( - SELECT - \(interaction[.threadId]), - COUNT(*) AS \(ViewModel.threadUnreadMentionCountKey) - FROM \(Interaction.self) - WHERE ( - \(interaction[.wasRead]) = false AND - \(interaction[.hasMention]) = true - ) - GROUP BY \(interaction[.threadId]) - ) AS \(unreadMentionCountTableLiteral) ON \(unreadMentionCountTableLiteral).\(interactionThreadIdLiteral) = \(thread[.id]) + + LEFT JOIN \(RecipientState.self) ON ( + -- Ignore 'skipped' states + \(SQL("\(recipientState[.state]) != \(RecipientState.State.skipped)")) AND + \(recipientState[.interactionId]) = \(Interaction.self).\(ViewModel.interactionIdKey) + ) + LEFT JOIN \(LinkPreview.self) ON ( + \(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND + \(SQL("\(linkPreview[.variant]) = \(LinkPreview.Variant.openGroupInvitation)")) AND + \(Interaction.linkPreviewFilterLiteral(timestampColumn: ViewModel.interactionTimestampMsKey)) + ) + LEFT JOIN \(InteractionAttachment.self) AS \(firstInteractionAttachmentLiteral) ON ( + \(firstInteractionAttachmentLiteral).\(interactionAttachmentAlbumIndexColumnLiteral) = 0 AND + \(firstInteractionAttachmentLiteral).\(interactionAttachmentInteractionIdColumnLiteral) = \(Interaction.self).\(ViewModel.interactionIdKey) + ) + LEFT JOIN \(Attachment.self) ON \(attachment[.id]) = \(firstInteractionAttachmentLiteral).\(interactionAttachmentAttachmentIdColumnLiteral) + LEFT JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.interactionId]) = \(Interaction.self).\(ViewModel.interactionIdKey) + LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId]) + + -- Thread naming & avatar content LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) + LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) LEFT JOIN \(GroupMember.self) ON ( \(SQL("\(groupMember[.role]) = \(GroupMember.Role.admin)")) AND \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND \(SQL("\(groupMember[.profileId]) = \(userPublicKey)")) ) - LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON ( \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( @@ -400,25 +406,6 @@ public extension SessionThreadViewModel { \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) IS NULL AND \(ViewModel.closedGroupProfileBackFallbackKey).\(profileIdColumnLiteral) = \(SQL("\(userPublicKey)")) ) - - LEFT JOIN \(Profile.self) AS \(authorProfileLiteral) ON \(authorProfileLiteral).\(profileIdColumnLiteral) = \(interaction[.authorId]) - LEFT JOIN \(LinkPreview.self) ON ( - \(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND - \(SQL("\(linkPreview[.variant]) = \(LinkPreview.Variant.openGroupInvitation)")) AND - \(Interaction.linkPreviewFilterLiteral) - ) - LEFT JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.interactionId]) = \(interaction[.id]) - LEFT JOIN \(InteractionAttachment.self) AS \(firstInteractionAttachmentLiteral) ON ( - \(firstInteractionAttachmentLiteral).\(interactionAttachmentAlbumIndexColumnLiteral) = 0 AND - \(firstInteractionAttachmentLiteral).\(interactionAttachmentInteractionIdColumnLiteral) = \(interaction[.id]) - ) - LEFT JOIN \(Attachment.self) AS \(ViewModel.interactionAttachmentDescriptionInfoKey) ON \(ViewModel.interactionAttachmentDescriptionInfoKey).\(attachmentIdColumnLiteral) = \(firstInteractionAttachmentLiteral).\(interactionAttachmentAttachmentIdColumnLiteral) - LEFT JOIN ( - \(RecipientState.selectInteractionState( - tableLiteral: interactionStateTableLiteral, - idColumnLiteral: interactionStateInteractionIdColumnLiteral - )) - ) AS \(interactionStateTableLiteral) ON \(interactionStateTableLiteral).\(interactionStateInteractionIdColumnLiteral) = \(interaction[.id]) WHERE ( \(filters) @@ -452,7 +439,6 @@ public extension SessionThreadViewModel { static func homeQuery(userPublicKey: String) -> AdaptedFetchRequest> { let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() - let interaction: TypedTableAlias = TypedTableAlias() return baseQuery( userPublicKey: userPublicKey, @@ -465,11 +451,11 @@ public extension SessionThreadViewModel { ) AND ( -- Only show the 'Note to Self' thread if it has an interaction \(SQL("\(thread[.id]) != \(userPublicKey)")) OR - \(interaction[.id]) IS NOT NULL + \(Interaction.self).\(ViewModel.interactionIdKey) IS NOT NULL ) """, ordering: """ - \(thread[.isPinned]) DESC, IFNULL(\(interaction[.timestampMs]), \(thread[.creationDateTimestamp])) DESC + \(thread[.isPinned]) DESC, IFNULL(\(Interaction.self).\(ViewModel.interactionTimestampMsKey), \(thread[.creationDateTimestamp])) DESC """ ) } @@ -477,7 +463,6 @@ public extension SessionThreadViewModel { static func messageRequestsQuery(userPublicKey: String) -> AdaptedFetchRequest> { let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() - let interaction: TypedTableAlias = TypedTableAlias() return baseQuery( userPublicKey: userPublicKey, @@ -493,7 +478,7 @@ public extension SessionThreadViewModel { ) """, ordering: """ - IFNULL(\(interaction[.timestampMs]), \(thread[.creationDateTimestamp])) DESC + IFNULL(\(Interaction.self).\(ViewModel.interactionTimestampMsKey), \(thread[.creationDateTimestamp])) DESC """ ) } @@ -510,8 +495,6 @@ public extension SessionThreadViewModel { let openGroup: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() - let unreadCountTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.threadUnreadCountString)_table") - let unreadMentionCountTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.threadUnreadMentionCountString)_table") let firstUnreadInteractionTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.threadFirstUnreadInteractionIdString)_table") let interactionIdLiteral: SQL = SQL(stringLiteral: Interaction.Columns.id.name) let interactionThreadIdLiteral: SQL = SQL(stringLiteral: Interaction.Columns.threadId.name) @@ -555,8 +538,8 @@ public extension SessionThreadViewModel { \(thread[.onlyNotifyForMentions]) AS \(ViewModel.threadOnlyNotifyForMentionsKey), \(thread[.messageDraft]) AS \(ViewModel.threadMessageDraftKey), - \(unreadCountTableLiteral).\(ViewModel.threadUnreadCountKey) AS \(ViewModel.threadUnreadCountKey), - \(unreadMentionCountTableLiteral).\(ViewModel.threadUnreadMentionCountKey) AS \(ViewModel.threadUnreadMentionCountKey), + \(Interaction.self).\(ViewModel.threadUnreadCountKey), + \(Interaction.self).\(ViewModel.threadUnreadMentionCountKey), \(firstUnreadInteractionTableLiteral).\(interactionIdLiteral) AS \(ViewModel.threadFirstUnreadInteractionIdKey), \(ViewModel.contactProfileKey).*, @@ -569,12 +552,25 @@ public extension SessionThreadViewModel { \(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey), \(openGroup[.userCount]) AS \(ViewModel.openGroupUserCountKey), - \(interaction[.id]) AS \(ViewModel.interactionIdKey), + \(Interaction.self).\(ViewModel.interactionIdKey), \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) FROM \(SessionThread.self) LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) + LEFT JOIN ( + -- Fetch all interaction-specific data in a subquery to be more efficient + SELECT + \(interaction[.id]) AS \(ViewModel.interactionIdKey), + \(interaction[.threadId]), + MAX(\(interaction[.timestampMs])), + + SUM(\(interaction[.wasRead]) = false) AS \(ViewModel.threadUnreadCountKey), + SUM(\(interaction[.wasRead]) = false AND \(interaction[.hasMention]) = true) AS \(ViewModel.threadUnreadMentionCountKey) + + FROM \(Interaction.self) + GROUP BY \(interaction[.threadId]) + ) AS \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id]) LEFT JOIN ( SELECT \(interaction[.id]), @@ -586,36 +582,9 @@ public extension SessionThreadViewModel { \(SQL("\(interaction[.threadId]) = \(threadId)")) ) ) AS \(firstUnreadInteractionTableLiteral) ON \(firstUnreadInteractionTableLiteral).\(interactionThreadIdLiteral) = \(thread[.id]) - LEFT JOIN ( - SELECT *, MAX(\(interaction[.timestampMs])) - FROM \(Interaction.self) - GROUP BY \(interaction[.threadId]) - ) AS \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id]) - LEFT JOIN ( - SELECT - \(interaction[.threadId]), - COUNT(*) AS \(ViewModel.threadUnreadCountKey) - FROM \(Interaction.self) - WHERE ( - \(interaction[.wasRead]) = false AND - \(SQL("\(interaction[.threadId]) = \(threadId)")) - ) - GROUP BY \(interaction[.threadId]) - ) AS \(unreadCountTableLiteral) ON \(unreadCountTableLiteral).\(interactionThreadIdLiteral) = \(thread[.id]) - LEFT JOIN ( - SELECT - \(interaction[.threadId]), - COUNT(*) AS \(ViewModel.threadUnreadMentionCountKey) - FROM \(Interaction.self) - WHERE ( - \(interaction[.wasRead]) = false AND - \(interaction[.hasMention]) = true AND - \(SQL("\(interaction[.threadId]) = \(threadId)")) - ) - GROUP BY \(interaction[.threadId]) - ) AS \(unreadMentionCountTableLiteral) ON \(unreadMentionCountTableLiteral).\(interactionThreadIdLiteral) = \(thread[.id]) LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) + LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) LEFT JOIN \(GroupMember.self) ON ( \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND @@ -633,7 +602,6 @@ public extension SessionThreadViewModel { ) GROUP BY \(groupMember[.groupId]) ) AS \(closedGroupUserCountTableLiteral) ON \(SQL("\(closedGroupUserCountTableLiteral).\(groupMemberGroupIdColumnLiteral) = \(threadId)")) - LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) WHERE \(SQL("\(thread[.id]) = \(threadId)")) GROUP BY \(thread[.id]) diff --git a/SessionMessagingKit/Utilities/Preferences.swift b/SessionMessagingKit/Utilities/Preferences.swift index 913b76f92..6badcaeae 100644 --- a/SessionMessagingKit/Utilities/Preferences.swift +++ b/SessionMessagingKit/Utilities/Preferences.swift @@ -44,6 +44,9 @@ public extension Setting.BoolKey { /// Controls whether the notification sound should play while the app is in the foreground static let playNotificationSoundInForeground: Setting.BoolKey = "playNotificationSoundInForeground" + /// A flag indicating whether the user has ever viewed their seed + static let hasViewedSeed: Setting.BoolKey = "hasViewedSeed" + /// A flag indicating whether the user has ever saved a thread static let hasSavedThread: Setting.BoolKey = "hasSavedThread" diff --git a/SessionNotificationServiceExtension/NSENotificationPresenter.swift b/SessionNotificationServiceExtension/NSENotificationPresenter.swift index cdc20df0c..8dd26e205 100644 --- a/SessionNotificationServiceExtension/NSENotificationPresenter.swift +++ b/SessionNotificationServiceExtension/NSENotificationPresenter.swift @@ -11,7 +11,8 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, isBackgroundPoll: Bool) { guard Date().timeIntervalSince1970 < (thread.mutedUntilTimestamp ?? 0) else { return } - let isMessageRequest = thread.isMessageRequest(db) + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let isMessageRequest: Bool = thread.isMessageRequest(db) // If the thread is a message request and the user hasn't hidden message requests then we need // to check if this is the only message request thread (group threads can't be message requests @@ -19,8 +20,10 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { // notification regardless of how many message requests there are) if thread.variant == .contact { if isMessageRequest && !db[.hasHiddenMessageRequests] { - let numMessageRequestThreads: Int? = try? SessionThread.messageRequestThreads(db) - .fetchCount(db) + let numMessageRequestThreads: Int? = (try? SessionThread + .messageRequestsCountQuery(userPublicKey: userPublicKey) + .fetchOne(db)) + .defaulting(to: 0) // Allow this to show a notification if there are no message requests (ie. this is the first one) guard (numMessageRequestThreads ?? 0) == 0 else { return } @@ -34,7 +37,6 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { } let senderPublicKey: String = interaction.authorId - let userPublicKey: String = getUserHexEncodedPublicKey() guard senderPublicKey != userPublicKey else { // Ignore PNs for messages sent by the current user diff --git a/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift index f807c07b1..9bb714b0f 100644 --- a/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -51,5 +51,7 @@ enum _001_InitialSetupMigration: Migration { t.uniqueKey([.key, .hash]) } + + GRDBStorage.shared.update(progress: 1, for: self, in: target) // In case this is the last migration } } diff --git a/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift index ad4037ecf..94d722639 100644 --- a/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -20,5 +20,7 @@ enum _002_SetupStandardJobs: Migration { shouldBlockFirstRunEachSession: true ).inserted(db) } + + GRDBStorage.shared.update(progress: 1, for: self, in: target) // In case this is the last migration } } diff --git a/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 6f2242741..65f982d4d 100644 --- a/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -202,5 +202,7 @@ enum _003_YDBToGRDBMigration: Migration { ).inserted(db) } } + + GRDBStorage.shared.update(progress: 1, for: self, in: target) // In case this is the last migration } } diff --git a/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift index e1e19ea0c..d37a5b598 100644 --- a/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -64,5 +64,7 @@ enum _001_InitialSetupMigration: Migration { .primaryKey() t.column(.value, .blob).notNull() } + + GRDBStorage.shared.update(progress: 1, for: self, in: target) // In case this is the last migration } } diff --git a/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift index 61fb1aed3..f08cb1ce9 100644 --- a/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -20,5 +20,7 @@ enum _002_SetupStandardJobs: Migration { behaviour: .recurringOnLaunch ).inserted(db) } + + GRDBStorage.shared.update(progress: 1, for: self, in: target) // In case this is the last migration } } diff --git a/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift index d70216cc3..fc87b4c85 100644 --- a/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -112,5 +112,7 @@ enum _003_YDBToGRDBMigration: Migration { data: userX25519KeyPair.publicKey ).insert(db) } + + GRDBStorage.shared.update(progress: 1, for: self, in: target) // In case this is the last migration } } diff --git a/SessionUtilitiesKit/General/SNUserDefaults.swift b/SessionUtilitiesKit/General/SNUserDefaults.swift index 6e09a5711..b95910a43 100644 --- a/SessionUtilitiesKit/General/SNUserDefaults.swift +++ b/SessionUtilitiesKit/General/SNUserDefaults.swift @@ -4,7 +4,6 @@ public enum SNUserDefaults { public enum Bool : Swift.String { case hasSyncedInitialConfiguration = "hasSyncedConfiguration" - case hasViewedSeed case hasSeenLinkPreviewSuggestion case isUsingFullAPNs case wasUnlinked diff --git a/SignalUtilitiesKit/Utilities/Notification+Loki.swift b/SignalUtilitiesKit/Utilities/Notification+Loki.swift index 6383bc077..46d08fadf 100644 --- a/SignalUtilitiesKit/Utilities/Notification+Loki.swift +++ b/SignalUtilitiesKit/Utilities/Notification+Loki.swift @@ -3,12 +3,9 @@ import Foundation public extension Notification.Name { // State changes - static let blockedContactsUpdated = Notification.Name("blockedContactsUpdated") static let contactOnlineStatusChanged = Notification.Name("contactOnlineStatusChanged") static let threadDeleted = Notification.Name("threadDeleted") static let threadSessionRestoreDevicesChanged = Notification.Name("threadSessionRestoreDevicesChanged") - // Onboarding - static let seedViewed = Notification.Name("seedViewed") // Interaction static let dataNukeRequested = Notification.Name("dataNukeRequested") } @@ -16,12 +13,9 @@ public extension Notification.Name { @objc public extension NSNotification { // State changes - @objc static let blockedContactsUpdated = Notification.Name.blockedContactsUpdated.rawValue as NSString @objc static let contactOnlineStatusChanged = Notification.Name.contactOnlineStatusChanged.rawValue as NSString @objc static let threadDeleted = Notification.Name.threadDeleted.rawValue as NSString @objc static let threadSessionRestoreDevicesChanged = Notification.Name.threadSessionRestoreDevicesChanged.rawValue as NSString - // Onboarding - @objc static let seedViewed = Notification.Name.seedViewed.rawValue as NSString // Interaction @objc static let dataNukeRequested = Notification.Name.dataNukeRequested.rawValue as NSString } From 59696f7d2c66d5bf3eea56c1b2ea0df72cd43bd0 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 1 Jun 2022 17:41:20 +1000 Subject: [PATCH 095/157] Removed all the old Storage classes and YapDB extensions Removed the AppUpdateNag code (unused) Removed the Mantle dependency --- Configuration.swift | 15 +- Podfile | 2 - Podfile.lock | 13 +- Session.xcodeproj/project.pbxproj | 112 --- .../OWSConversationSettingsViewController.m | 8 - Session/Meta/AppDelegate.swift | 9 +- Session/Meta/SessionApp.swift | 3 +- Session/Notifications/AppNotifications.swift | 10 - Session/Utilities/AppUpdateNag.swift | 243 ------ .../Migrations/_003_YDBToGRDBMigration.swift | 68 +- .../Database/Notification+Contacts.swift | 2 +- .../Database/OWSBackupFragment.h | 44 - .../Database/OWSBackupFragment.m | 13 - .../Database/OWSPrimaryStorage.h | 51 -- .../Database/OWSPrimaryStorage.m | 382 --------- .../Database/OWSStorage+Subclass.h | 32 - SessionMessagingKit/Database/OWSStorage.h | 116 --- SessionMessagingKit/Database/OWSStorage.m | 808 ------------------ .../Database/Storage+OpenGroups.swift | 209 ----- .../Database/Storage+Shared.swift | 40 - .../Database/TSDatabaseSecondaryIndexes.h | 22 - .../Database/TSDatabaseSecondaryIndexes.m | 53 -- SessionMessagingKit/Database/TSDatabaseView.h | 69 -- SessionMessagingKit/Database/TSDatabaseView.m | 441 ---------- .../File Server/FileServerAPIV2.swift | 4 + .../Meta/SessionMessagingKit.h | 8 - .../MessageReceiver+Handling.swift | 1 + .../Notifications/PushNotificationAPI.swift | 1 + .../Pollers/ClosedGroupPoller.swift | 1 + .../Sending & Receiving/Pollers/Poller.swift | 1 + .../SessionThreadViewModel.swift | 1 + SessionMessagingKit/Storage.swift | 56 -- .../Utilities/Environment.swift | 17 - .../Utilities/OWSAudioSession.swift | 1 + .../ProximityMonitoringManager.swift | 1 + .../SNProtoEnvelope+Conversion.swift | 1 + .../Utilities/YapDatabaseConnection+OWS.h | 43 - .../Utilities/YapDatabaseConnection+OWS.m | 154 ---- .../Utilities/YapDatabaseTransaction+OWS.h | 43 - .../Utilities/YapDatabaseTransaction+OWS.m | 116 --- .../NotificationServiceExtension.swift | 11 +- SessionShareExtension/ShareVC.swift | 18 +- SessionSnodeKit/Configuration.swift | 4 - .../Migrations/_003_YDBToGRDBMigration.swift | 1 + SessionUtilitiesKit/Configuration.swift | 11 +- .../Database/GRDBStorage.swift | 2 +- .../Migrations/_003_YDBToGRDBMigration.swift | 1 + .../Database/OWSPrimaryStorageProtocol.swift | 7 - SessionUtilitiesKit/Database/Storage.swift | 71 -- .../Database/TSYapDatabaseObject.h | 165 ---- .../Database/TSYapDatabaseObject.m | 229 ----- .../Meta/SessionUtilitiesKit.h | 1 - SignalUtilitiesKit/Configuration.swift | 12 +- .../Database/YapDatabase+Promise.swift | 52 -- SignalUtilitiesKit/Utilities/AppSetup.swift | 11 +- SignalUtilitiesKit/Utilities/OWSAlerts.swift | 29 +- 56 files changed, 75 insertions(+), 3764 deletions(-) delete mode 100644 Session/Utilities/AppUpdateNag.swift delete mode 100644 SessionMessagingKit/Database/OWSBackupFragment.h delete mode 100644 SessionMessagingKit/Database/OWSBackupFragment.m delete mode 100644 SessionMessagingKit/Database/OWSPrimaryStorage.h delete mode 100644 SessionMessagingKit/Database/OWSPrimaryStorage.m delete mode 100644 SessionMessagingKit/Database/OWSStorage+Subclass.h delete mode 100644 SessionMessagingKit/Database/OWSStorage.h delete mode 100644 SessionMessagingKit/Database/OWSStorage.m delete mode 100644 SessionMessagingKit/Database/Storage+OpenGroups.swift delete mode 100644 SessionMessagingKit/Database/Storage+Shared.swift delete mode 100644 SessionMessagingKit/Database/TSDatabaseSecondaryIndexes.h delete mode 100644 SessionMessagingKit/Database/TSDatabaseSecondaryIndexes.m delete mode 100644 SessionMessagingKit/Database/TSDatabaseView.h delete mode 100644 SessionMessagingKit/Database/TSDatabaseView.m delete mode 100644 SessionMessagingKit/Storage.swift delete mode 100644 SessionMessagingKit/Utilities/YapDatabaseConnection+OWS.h delete mode 100644 SessionMessagingKit/Utilities/YapDatabaseConnection+OWS.m delete mode 100644 SessionMessagingKit/Utilities/YapDatabaseTransaction+OWS.h delete mode 100644 SessionMessagingKit/Utilities/YapDatabaseTransaction+OWS.m delete mode 100644 SessionUtilitiesKit/Database/OWSPrimaryStorageProtocol.swift delete mode 100644 SessionUtilitiesKit/Database/Storage.swift delete mode 100644 SessionUtilitiesKit/Database/TSYapDatabaseObject.h delete mode 100644 SessionUtilitiesKit/Database/TSYapDatabaseObject.m delete mode 100644 SignalUtilitiesKit/Database/YapDatabase+Promise.swift diff --git a/Configuration.swift b/Configuration.swift index 055bd318a..bdc23dc0d 100644 --- a/Configuration.swift +++ b/Configuration.swift @@ -1,17 +1,6 @@ import Foundation import SessionUtilitiesKit -@objc -public final class SNMessagingKitConfiguration : NSObject { - public let storage: SessionMessagingKitStorageProtocol - - @objc public static var shared: SNMessagingKitConfiguration! - - fileprivate init(storage: SessionMessagingKitStorageProtocol) { - self.storage = storage - } -} - public enum SNMessagingKit { // Just to make the external API nice public static func migrations() -> TargetMigrations { return TargetMigrations( @@ -28,7 +17,7 @@ public enum SNMessagingKit { // Just to make the external API nice ) } - public static func configure(storage: SessionMessagingKitStorageProtocol) { + public static func configure() { // Configure the job executors JobRunner.add(executor: DisappearingMessagesJob.self, for: .disappearingMessages) JobRunner.add(executor: FailedMessageSendsJob.self, for: .failedMessageSends) @@ -42,7 +31,5 @@ public enum SNMessagingKit { // Just to make the external API nice JobRunner.add(executor: SendReadReceiptsJob.self, for: .sendReadReceipts) JobRunner.add(executor: AttachmentDownloadJob.self, for: .attachmentDownload) JobRunner.add(executor: AttachmentUploadJob.self, for: .attachmentUpload) - - SNMessagingKitConfiguration.shared = SNMessagingKitConfiguration(storage: storage) } } diff --git a/Podfile b/Podfile index cfd179fd7..b1deaf9f6 100644 --- a/Podfile +++ b/Podfile @@ -21,7 +21,6 @@ abstract_target 'GlobalDependencies' do pod 'PureLayout', '~> 3.1.8' pod 'NVActivityIndicatorView' pod 'YYImage', git: 'https://github.com/signalapp/YYImage' - pod 'Mantle', git: 'https://github.com/signalapp/Mantle', branch: 'signal-master' pod 'ZXingObjC' pod 'DifferenceKit' end @@ -38,7 +37,6 @@ abstract_target 'GlobalDependencies' do abstract_target 'ExtendedDependencies' do pod 'AFNetworking' pod 'PureLayout', '~> 3.1.8' - pod 'Mantle', git: 'https://github.com/signalapp/Mantle', branch: 'signal-master' target 'SessionShareExtension' do pod 'NVActivityIndicatorView' diff --git a/Podfile.lock b/Podfile.lock index fc074549d..819153e15 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -29,9 +29,6 @@ PODS: - DifferenceKit/Core - GRDB.swift/SQLCipher (5.24.1): - SQLCipher (>= 3.4.0) - - Mantle (2.1.0): - - Mantle/extobjc (= 2.1.0) - - Mantle/extobjc (2.1.0) - NVActivityIndicatorView (5.1.1): - NVActivityIndicatorView/Base (= 5.1.1) - NVActivityIndicatorView/Base (5.1.1) @@ -133,7 +130,6 @@ DEPENDENCIES: - Curve25519Kit (from `https://github.com/signalapp/Curve25519Kit.git`) - DifferenceKit - GRDB.swift/SQLCipher - - Mantle (from `https://github.com/signalapp/Mantle`, branch `signal-master`) - NVActivityIndicatorView - PromiseKit - PureLayout (~> 3.1.8) @@ -168,9 +164,6 @@ SPEC REPOS: EXTERNAL SOURCES: Curve25519Kit: :git: https://github.com/signalapp/Curve25519Kit.git - Mantle: - :branch: signal-master - :git: https://github.com/signalapp/Mantle SignalCoreKit: :branch: session-version :git: https://github.com/oxen-io/session-ios-core-kit @@ -184,9 +177,6 @@ CHECKOUT OPTIONS: Curve25519Kit: :commit: 4fc1c10e98fff2534b5379a9bb587430fdb8e577 :git: https://github.com/signalapp/Curve25519Kit.git - Mantle: - :commit: e7e46253bb01ce39525d90aa69ed9e85e758bfc4 - :git: https://github.com/signalapp/Mantle SignalCoreKit: :commit: 4590c2737a2b5dc0ef4ace9f9019b581caccc1de :git: https://github.com/oxen-io/session-ios-core-kit @@ -204,7 +194,6 @@ SPEC CHECKSUMS: Curve25519Kit: e63f9859ede02438ae3defc5e1a87e09d1ec7ee6 DifferenceKit: 5659c430bb7fe45876fa32ce5cba5d6167f0c805 GRDB.swift: b3180ce2135fc06a453297889b746b1478c4d8c7 - Mantle: 2fa750afa478cd625a94230fbf1c13462f29395b NVActivityIndicatorView: 1f6c5687f1171810aa27a3296814dc2d7dec3667 OpenSSL-Universal: e7311447fd2419f57420c79524b641537387eff2 PromiseKit: 3b2b6995e51a954c46dbc550ce3da44fbfb563c5 @@ -219,6 +208,6 @@ SPEC CHECKSUMS: YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331 ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: 9715c163fab54d487be0c32357d6d1729aa96a7b +PODFILE CHECKSUM: 05dc0000aee6d863406fc684884935594fcf14fa COCOAPODS: 1.11.2 diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index ef125b14f..87e8381e1 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -107,7 +107,6 @@ 4C9CA25D217E676900607C63 /* ZXingObjC.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C9CA25C217E676900607C63 /* ZXingObjC.framework */; }; 4CA46F4C219CCC630038ABDE /* CaptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA46F4B219CCC630038ABDE /* CaptionView.swift */; }; 4CA485BB2232339F004B9E7D /* PhotoCaptureViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA485BA2232339F004B9E7D /* PhotoCaptureViewController.swift */; }; - 4CC1ECFB211A553000CC13BE /* AppUpdateNag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CC1ECFA211A553000CC13BE /* AppUpdateNag.swift */; }; 4CC613362227A00400E21A3A /* ConversationSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CC613352227A00400E21A3A /* ConversationSearch.swift */; }; 70377AAB1918450100CAF501 /* MobileCoreServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 70377AAA1918450100CAF501 /* MobileCoreServices.framework */; }; 768A1A2B17FC9CD300E00ED8 /* libz.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = 768A1A2A17FC9CD300E00ED8 /* libz.dylib */; }; @@ -176,8 +175,6 @@ B879D449247E1BE300DB3608 /* PathVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B879D448247E1BE300DB3608 /* PathVC.swift */; }; B87EF17126367CF800124B3C /* FileServerAPIV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = B87EF17026367CF800124B3C /* FileServerAPIV2.swift */; }; B87EF18126377A1D00124B3C /* Features.swift in Sources */ = {isa = PBXBuildFile; fileRef = B87EF18026377A1D00124B3C /* Features.swift */; }; - B8856CA8256F0F42001CE70E /* OWSBackupFragment.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB07255A580700E217F9 /* OWSBackupFragment.m */; }; - B8856CB1256F0F47001CE70E /* OWSBackupFragment.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDAEA255A580500E217F9 /* OWSBackupFragment.h */; settings = {ATTRIBUTES = (Public, ); }; }; B8856CEE256F1054001CE70E /* OWSAudioPlayer.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2F7255B6DBC007E1867 /* OWSAudioPlayer.m */; }; B8856CF7256F105E001CE70E /* OWSAudioPlayer.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF2F5255B6DBC007E1867 /* OWSAudioPlayer.h */; settings = {ATTRIBUTES = (Public, ); }; }; B8856D08256F10F1001CE70E /* DeviceSleepManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */; }; @@ -194,7 +191,6 @@ B8856DF8256F1633001CE70E /* NSString+SSK.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB12255A580800E217F9 /* NSString+SSK.h */; settings = {ATTRIBUTES = (Public, ); }; }; B8856E09256F1676001CE70E /* UIDevice+featureSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF237255B6D65007E1867 /* UIDevice+featureSupport.swift */; }; B8856E1A256F1700001CE70E /* OWSMath.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB14255A580800E217F9 /* OWSMath.h */; settings = {ATTRIBUTES = (Public, ); }; }; - B8856E33256F18D5001CE70E /* OWSStorage+Subclass.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDAB9255A580100E217F9 /* OWSStorage+Subclass.h */; settings = {ATTRIBUTES = (Public, ); }; }; B8856ECE256F1E58001CE70E /* OWSPreferences.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF308255B6DBE007E1867 /* OWSPreferences.m */; }; B8856ED7256F1EB4001CE70E /* OWSPreferences.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF2F1255B6DBB007E1867 /* OWSPreferences.h */; settings = {ATTRIBUTES = (Public, ); }; }; B886B4A72398B23E00211ABE /* QRCodeVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B886B4A62398B23E00211ABE /* QRCodeVC.swift */; }; @@ -276,8 +272,6 @@ C32C5B51256DC219003C73A2 /* NSNotificationCenter+OWS.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB3B255A580B00E217F9 /* NSNotificationCenter+OWS.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5C3D256DCBAF003C73A2 /* AppReadiness.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB75255A581000E217F9 /* AppReadiness.m */; }; C32C5C46256DCBB2003C73A2 /* AppReadiness.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB01255A580700E217F9 /* AppReadiness.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C32C5C4F256DCC36003C73A2 /* Storage+OpenGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8D8F18825661BA50092EF10 /* Storage+OpenGroups.swift */; }; - C32C5CF0256DD3E4003C73A2 /* Storage+Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3F0A5FD255C988A007BE2A3 /* Storage+Shared.swift */; }; C32C5D19256DD493003C73A2 /* LinkPreviewDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBA8255A581500E217F9 /* LinkPreviewDraft.swift */; }; C32C5D83256DD5B6003C73A2 /* SSKKeychainStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBBC255A581600E217F9 /* SSKKeychainStorage.swift */; }; C32C5DBF256DD743003C73A2 /* ClosedGroupPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB34255A580B00E217F9 /* ClosedGroupPoller.swift */; }; @@ -286,16 +280,6 @@ C32C5DD2256DD9E5003C73A2 /* LRUCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAFD255A580600E217F9 /* LRUCache.swift */; }; C32C5DDB256DD9FF003C73A2 /* ContentProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB68255A580F00E217F9 /* ContentProxy.swift */; }; C32C5E0C256DDAFA003C73A2 /* NSRegularExpression+SSK.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA7A255A57FB00E217F9 /* NSRegularExpression+SSK.swift */; }; - C32C5E5B256DDF45003C73A2 /* OWSStorage.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAB1255A580000E217F9 /* OWSStorage.m */; }; - C32C5E64256DDFD6003C73A2 /* OWSStorage.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDAFE255A580600E217F9 /* OWSStorage.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C32C5E75256DE020003C73A2 /* YapDatabaseTransaction+OWS.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB5B255A580E00E217F9 /* YapDatabaseTransaction+OWS.m */; }; - C32C5E7E256DE023003C73A2 /* YapDatabaseTransaction+OWS.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDA88255A57FD00E217F9 /* YapDatabaseTransaction+OWS.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C32C5E97256DE0CB003C73A2 /* OWSPrimaryStorage.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDC02255A581D00E217F9 /* OWSPrimaryStorage.m */; }; - C32C5EA0256DE0D6003C73A2 /* OWSPrimaryStorage.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDA67255A57F900E217F9 /* OWSPrimaryStorage.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C32C5EDC256DF501003C73A2 /* YapDatabaseConnection+OWS.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB43255A580C00E217F9 /* YapDatabaseConnection+OWS.m */; }; - C32C5EE5256DF506003C73A2 /* YapDatabaseConnection+OWS.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB5F255A580E00E217F9 /* YapDatabaseConnection+OWS.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C32C5EEE256DF54E003C73A2 /* TSDatabaseView.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB46255A580C00E217F9 /* TSDatabaseView.m */; }; - C32C5EF7256DF567003C73A2 /* TSDatabaseView.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB2C255A580A00E217F9 /* TSDatabaseView.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5FBB256E0206003C73A2 /* OWSBackgroundTask.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDC1B255A581F00E217F9 /* OWSBackgroundTask.m */; }; C32C5FC4256E0209003C73A2 /* OWSBackgroundTask.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB38255A580B00E217F9 /* OWSBackgroundTask.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C600F256E07F5003C73A2 /* NSUserDefaults+OWS.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB77255A581000E217F9 /* NSUserDefaults+OWS.m */; }; @@ -331,7 +315,6 @@ C33FD9C2255A54EF00E217F9 /* SessionMessagingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A6F025539DE700C340D1 /* SessionMessagingKit.framework */; }; C33FD9C4255A54EF00E217F9 /* SessionSnodeKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A59F255385C100C340D1 /* SessionSnodeKit.framework */; }; C33FD9C5255A54EF00E217F9 /* SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */; }; - C33FDC27255A581F00E217F9 /* YapDatabase+Promise.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA6D255A57FA00E217F9 /* YapDatabase+Promise.swift */; }; C33FDC29255A581F00E217F9 /* ReachabilityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA6F255A57FA00E217F9 /* ReachabilityManager.swift */; }; C33FDC45255A581F00E217F9 /* AppVersion.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA8B255A57FD00E217F9 /* AppVersion.m */; }; C33FDC50255A582000E217F9 /* OWSDispatch.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDA96255A57FE00E217F9 /* OWSDispatch.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -475,8 +458,6 @@ C3A01E05261D24C400290BEB /* public-loki-foundation.der in Resources */ = {isa = PBXBuildFile; fileRef = C3A01E02261D24C400290BEB /* public-loki-foundation.der */; }; C3A01E06261D24C400290BEB /* storage-seed-1.der in Resources */ = {isa = PBXBuildFile; fileRef = C3A01E03261D24C400290BEB /* storage-seed-1.der */; }; C3A01E07261D24C400290BEB /* storage-seed-3.der in Resources */ = {isa = PBXBuildFile; fileRef = C3A01E04261D24C400290BEB /* storage-seed-3.der */; }; - C3A3A0FE256E1A3C004D228D /* TSDatabaseSecondaryIndexes.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB20255A580900E217F9 /* TSDatabaseSecondaryIndexes.m */; }; - C3A3A12B256E1AD5004D228D /* TSDatabaseSecondaryIndexes.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB25255A580900E217F9 /* TSDatabaseSecondaryIndexes.h */; settings = {ATTRIBUTES = (Public, ); }; }; C3A3A171256E1D25004D228D /* SSKReachabilityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A3A170256E1D25004D228D /* SSKReachabilityManager.swift */; }; C3A71D0B2558989C0043A11F /* MessageWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A71D0A2558989C0043A11F /* MessageWrapper.swift */; }; C3A71D1E25589AC30043A11F /* WebSocketProto.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A71D1C25589AC30043A11F /* WebSocketProto.swift */; }; @@ -490,7 +471,6 @@ C3AAFFF225AE99710089E6DD /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3AAFFF125AE99710089E6DD /* AppDelegate.swift */; }; C3ADC66126426688005F1414 /* ShareVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3ADC66026426688005F1414 /* ShareVC.swift */; }; C3BBE0762554CDA60050F1E3 /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3BBE0752554CDA60050F1E3 /* Configuration.swift */; }; - C3BBE0802554CDD70050F1E3 /* Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3BBE07F2554CDD70050F1E3 /* Storage.swift */; }; C3BBE0A72554D4DE0050F1E3 /* Promise+Retrying.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D62553860B00C340D1 /* Promise+Retrying.swift */; }; C3BBE0A82554D4DE0050F1E3 /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D92553860B00C340D1 /* JSON.swift */; }; C3BBE0A92554D4DE0050F1E3 /* HTTP.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5BC255385EE00C340D1 /* HTTP.swift */; }; @@ -539,10 +519,6 @@ C3D9E38A256760390040E4F3 /* OWSFileSystem.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBAB255A581500E217F9 /* OWSFileSystem.h */; settings = {ATTRIBUTES = (Public, ); }; }; C3D9E39B256763C20040E4F3 /* AppContext.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB85255A581100E217F9 /* AppContext.m */; }; C3D9E3A4256763DE0040E4F3 /* AppContext.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB8A255A581200E217F9 /* AppContext.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C3D9E3C925676AF30040E4F3 /* TSYapDatabaseObject.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA90255A57FD00E217F9 /* TSYapDatabaseObject.m */; }; - C3D9E3FA25676BCE0040E4F3 /* TSYapDatabaseObject.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDAA1255A57FF00E217F9 /* TSYapDatabaseObject.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C3D9E41525676C320040E4F3 /* Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB36255A580B00E217F9 /* Storage.swift */; }; - C3D9E41F25676C870040E4F3 /* OWSPrimaryStorageProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3D9E41E25676C870040E4F3 /* OWSPrimaryStorageProtocol.swift */; }; C3D9E43125676D3D0040E4F3 /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3D9E43025676D3D0040E4F3 /* Configuration.swift */; }; C3D9E4C02567767F0040E4F3 /* DataSource.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBB6255A581600E217F9 /* DataSource.m */; }; C3D9E4D12567777D0040E4F3 /* OWSMediaUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB22255A580900E217F9 /* OWSMediaUtils.swift */; }; @@ -989,7 +965,6 @@ 4C9CA25C217E676900607C63 /* ZXingObjC.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ZXingObjC.framework; path = ThirdParty/Carthage/Build/iOS/ZXingObjC.framework; sourceTree = ""; }; 4CA46F4B219CCC630038ABDE /* CaptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptionView.swift; sourceTree = ""; }; 4CA485BA2232339F004B9E7D /* PhotoCaptureViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoCaptureViewController.swift; sourceTree = ""; }; - 4CC1ECFA211A553000CC13BE /* AppUpdateNag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateNag.swift; sourceTree = ""; }; 4CC613352227A00400E21A3A /* ConversationSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationSearch.swift; sourceTree = ""; }; 5A3F440C6CC32A23AD67A2FD /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionUtilitiesKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionUtilitiesKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionUtilitiesKit.debug.xcconfig"; sourceTree = ""; }; 5B7FDA4BA2DDFF4612600FB8 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.debug.xcconfig"; sourceTree = ""; }; @@ -1147,7 +1122,6 @@ B8D0A26825E4A2C200C1835E /* Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Onboarding.swift; sourceTree = ""; }; B8D84EA225DF745A005A043E /* LinkPreviewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewState.swift; sourceTree = ""; }; B8D84ECE25E3108A005A043E /* ExpandingAttachmentsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandingAttachmentsButton.swift; sourceTree = ""; }; - B8D8F18825661BA50092EF10 /* Storage+OpenGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+OpenGroups.swift"; sourceTree = ""; }; B8EB20E6263F7E4B00773E52 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = ""; }; B8EB20ED2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VisibleMessage+OpenGroupInvitation.swift"; sourceTree = ""; }; B8EB20EF2640F7F000773E52 /* OpenGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupInvitationView.swift; sourceTree = ""; }; @@ -1190,62 +1164,44 @@ C33FD9AB255A548A00E217F9 /* SignalUtilitiesKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SignalUtilitiesKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C33FD9AD255A548A00E217F9 /* SignalUtilitiesKit.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SignalUtilitiesKit.h; sourceTree = ""; }; C33FD9AE255A548A00E217F9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - C33FDA67255A57F900E217F9 /* OWSPrimaryStorage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSPrimaryStorage.h; sourceTree = ""; }; - C33FDA6D255A57FA00E217F9 /* YapDatabase+Promise.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "YapDatabase+Promise.swift"; sourceTree = ""; }; C33FDA6F255A57FA00E217F9 /* ReachabilityManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReachabilityManager.swift; sourceTree = ""; }; C33FDA73255A57FA00E217F9 /* ECKeyPair+Hexadecimal.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ECKeyPair+Hexadecimal.swift"; sourceTree = ""; }; C33FDA7A255A57FB00E217F9 /* NSRegularExpression+SSK.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSRegularExpression+SSK.swift"; sourceTree = ""; }; C33FDA87255A57FC00E217F9 /* TypingIndicators.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingIndicators.swift; sourceTree = ""; }; - C33FDA88255A57FD00E217F9 /* YapDatabaseTransaction+OWS.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "YapDatabaseTransaction+OWS.h"; sourceTree = ""; }; C33FDA8B255A57FD00E217F9 /* AppVersion.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppVersion.m; sourceTree = ""; }; C33FDA8E255A57FD00E217F9 /* OWSFileSystem.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSFileSystem.m; sourceTree = ""; }; - C33FDA90255A57FD00E217F9 /* TSYapDatabaseObject.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSYapDatabaseObject.m; sourceTree = ""; }; C33FDA96255A57FE00E217F9 /* OWSDispatch.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSDispatch.h; sourceTree = ""; }; C33FDA99255A57FE00E217F9 /* OutageDetection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutageDetection.swift; sourceTree = ""; }; C33FDA9E255A57FF00E217F9 /* ReverseDispatchQueue.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReverseDispatchQueue.swift; sourceTree = ""; }; - C33FDAA1255A57FF00E217F9 /* TSYapDatabaseObject.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSYapDatabaseObject.h; sourceTree = ""; }; C33FDAA8255A57FF00E217F9 /* BuildConfiguration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BuildConfiguration.swift; sourceTree = ""; }; - C33FDAB1255A580000E217F9 /* OWSStorage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSStorage.m; sourceTree = ""; }; - C33FDAB9255A580100E217F9 /* OWSStorage+Subclass.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "OWSStorage+Subclass.h"; sourceTree = ""; }; C33FDABE255A580100E217F9 /* TSConstants.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSConstants.m; sourceTree = ""; }; C33FDAC3255A580200E217F9 /* OWSDispatch.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSDispatch.m; sourceTree = ""; }; C33FDADE255A580400E217F9 /* SwiftSingletons.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftSingletons.swift; sourceTree = ""; }; C33FDAE0255A580400E217F9 /* ByteParser.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ByteParser.m; sourceTree = ""; }; - C33FDAEA255A580500E217F9 /* OWSBackupFragment.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSBackupFragment.h; sourceTree = ""; }; C33FDAEF255A580500E217F9 /* NSData+Image.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSData+Image.m"; sourceTree = ""; }; C33FDAF1255A580500E217F9 /* ThumbnailService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThumbnailService.swift; sourceTree = ""; }; C33FDAF2255A580500E217F9 /* ProxiedContentDownloader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProxiedContentDownloader.swift; sourceTree = ""; }; C33FDAFC255A580600E217F9 /* MIMETypeUtil.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MIMETypeUtil.h; sourceTree = ""; }; C33FDAFD255A580600E217F9 /* LRUCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LRUCache.swift; sourceTree = ""; }; - C33FDAFE255A580600E217F9 /* OWSStorage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSStorage.h; sourceTree = ""; }; C33FDB01255A580700E217F9 /* AppReadiness.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppReadiness.h; sourceTree = ""; }; - C33FDB07255A580700E217F9 /* OWSBackupFragment.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSBackupFragment.m; sourceTree = ""; }; C33FDB12255A580800E217F9 /* NSString+SSK.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSString+SSK.h"; sourceTree = ""; }; C33FDB14255A580800E217F9 /* OWSMath.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSMath.h; sourceTree = ""; }; C33FDB17255A580800E217F9 /* FunctionalUtil.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FunctionalUtil.m; sourceTree = ""; }; C33FDB1C255A580900E217F9 /* UIImage+OWS.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "UIImage+OWS.h"; sourceTree = ""; }; - C33FDB20255A580900E217F9 /* TSDatabaseSecondaryIndexes.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSDatabaseSecondaryIndexes.m; sourceTree = ""; }; C33FDB22255A580900E217F9 /* OWSMediaUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSMediaUtils.swift; sourceTree = ""; }; - C33FDB25255A580900E217F9 /* TSDatabaseSecondaryIndexes.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSDatabaseSecondaryIndexes.h; sourceTree = ""; }; C33FDB29255A580A00E217F9 /* NSData+Image.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSData+Image.h"; sourceTree = ""; }; - C33FDB2C255A580A00E217F9 /* TSDatabaseView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSDatabaseView.h; sourceTree = ""; }; C33FDB34255A580B00E217F9 /* ClosedGroupPoller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ClosedGroupPoller.swift; sourceTree = ""; }; - C33FDB36255A580B00E217F9 /* Storage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = ""; }; C33FDB38255A580B00E217F9 /* OWSBackgroundTask.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSBackgroundTask.h; sourceTree = ""; }; C33FDB3A255A580B00E217F9 /* Poller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Poller.swift; sourceTree = ""; }; C33FDB3B255A580B00E217F9 /* NSNotificationCenter+OWS.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSNotificationCenter+OWS.h"; sourceTree = ""; }; C33FDB3F255A580C00E217F9 /* String+SSK.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+SSK.swift"; sourceTree = ""; }; C33FDB40255A580C00E217F9 /* SignalIOSProto.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SignalIOSProto.swift; sourceTree = ""; }; C33FDB41255A580C00E217F9 /* MIMETypeUtil.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MIMETypeUtil.m; sourceTree = ""; }; - C33FDB43255A580C00E217F9 /* YapDatabaseConnection+OWS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "YapDatabaseConnection+OWS.m"; sourceTree = ""; }; C33FDB45255A580C00E217F9 /* NSString+SSK.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSString+SSK.m"; sourceTree = ""; }; - C33FDB46255A580C00E217F9 /* TSDatabaseView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSDatabaseView.m; sourceTree = ""; }; C33FDB49255A580C00E217F9 /* WeakTimer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WeakTimer.swift; sourceTree = ""; }; C33FDB4C255A580D00E217F9 /* AppVersion.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppVersion.h; sourceTree = ""; }; C33FDB51255A580D00E217F9 /* NSUserDefaults+OWS.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSUserDefaults+OWS.h"; sourceTree = ""; }; C33FDB54255A580D00E217F9 /* DataSource.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DataSource.h; sourceTree = ""; }; - C33FDB5B255A580E00E217F9 /* YapDatabaseTransaction+OWS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "YapDatabaseTransaction+OWS.m"; sourceTree = ""; }; - C33FDB5F255A580E00E217F9 /* YapDatabaseConnection+OWS.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "YapDatabaseConnection+OWS.h"; sourceTree = ""; }; C33FDB68255A580F00E217F9 /* ContentProxy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentProxy.swift; sourceTree = ""; }; C33FDB69255A580F00E217F9 /* FeatureFlags.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeatureFlags.swift; sourceTree = ""; }; C33FDB6B255A580F00E217F9 /* SNUserDefaults.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SNUserDefaults.swift; sourceTree = ""; }; @@ -1272,7 +1228,6 @@ C33FDBE1255A581A00E217F9 /* LKGroupUtilities.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = LKGroupUtilities.m; sourceTree = ""; }; C33FDBF6255A581C00E217F9 /* NSURLSessionDataTask+StatusCode.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSURLSessionDataTask+StatusCode.h"; sourceTree = ""; }; C33FDBF9255A581C00E217F9 /* OWSError.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSError.h; sourceTree = ""; }; - C33FDC02255A581D00E217F9 /* OWSPrimaryStorage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSPrimaryStorage.m; sourceTree = ""; }; C33FDC03255A581D00E217F9 /* ByteParser.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ByteParser.h; sourceTree = ""; }; C33FDC0B255A581D00E217F9 /* OWSError.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSError.m; sourceTree = ""; }; C33FDC12255A581E00E217F9 /* TSConstants.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSConstants.h; sourceTree = ""; }; @@ -1426,7 +1381,6 @@ C3ADC66026426688005F1414 /* ShareVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareVC.swift; sourceTree = ""; }; C3AECBEA24EF5244005743DE /* fa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fa; path = fa.lproj/Localizable.strings; sourceTree = ""; }; C3BBE0752554CDA60050F1E3 /* Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = ""; }; - C3BBE07F2554CDD70050F1E3 /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = ""; }; C3BBE0C62554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FixedWidthInteger+BigEndian.swift"; sourceTree = ""; }; C3C2A59F255385C100C340D1 /* SessionSnodeKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SessionSnodeKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C3C2A5A1255385C100C340D1 /* SessionSnodeKit.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SessionSnodeKit.h; sourceTree = ""; }; @@ -1472,7 +1426,6 @@ C3CA3ABD255CDB0D00F4C6D4 /* portuguese.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = portuguese.txt; sourceTree = ""; }; C3CA3AC7255CDB2900F4C6D4 /* spanish.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = spanish.txt; sourceTree = ""; }; C3D0972A2510499C00F6E3E4 /* BackgroundPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundPoller.swift; sourceTree = ""; }; - C3D9E41E25676C870040E4F3 /* OWSPrimaryStorageProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSPrimaryStorageProtocol.swift; sourceTree = ""; }; C3D9E43025676D3D0040E4F3 /* Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = ""; }; C3DA9C0625AE7396008F7C7E /* ConfigurationMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationMessage.swift; sourceTree = ""; }; C3DAB3232480CB2A00725F25 /* SRCopyableLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SRCopyableLabel.swift; sourceTree = ""; }; @@ -1487,7 +1440,6 @@ C3F0A52F255C80BC007BE2A3 /* NoopNotificationsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoopNotificationsManager.swift; sourceTree = ""; }; C3F0A5B2255C915C007BE2A3 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; C3F0A5EB255C970D007BE2A3 /* Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = ""; }; - C3F0A5FD255C988A007BE2A3 /* Storage+Shared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+Shared.swift"; sourceTree = ""; }; C5060C3B36A848B71CCE4685 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C88965DE4F4EC4FC919BEC4E /* Pods-SessionUIKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionUIKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SessionUIKit/Pods-SessionUIKit.debug.xcconfig"; sourceTree = ""; }; C98441E849C3CA7FE8220D33 /* Pods-SessionNotificationServiceExtension.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionNotificationServiceExtension.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionNotificationServiceExtension/Pods-SessionNotificationServiceExtension.app store release.xcconfig"; sourceTree = ""; }; @@ -1852,7 +1804,6 @@ 4C090A1A210FD9C7001FD7F9 /* HapticFeedback.swift */, 34D5CCA71EAE3D30005515DB /* AvatarViewHelper.h */, 34D5CCA81EAE3D30005515DB /* AvatarViewHelper.m */, - 4CC1ECFA211A553000CC13BE /* AppUpdateNag.swift */, FD848B9728422F1A000E298B /* Date+Utilities.swift */, FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */, 4C21D5D5223A9DC500EF8A77 /* UIAlerts+iOS9.m */, @@ -2081,11 +2032,7 @@ FD28A4F527EAD44C00FF65E7 /* GRDBStorage.swift */, C33FDBAB255A581500E217F9 /* OWSFileSystem.h */, C33FDA8E255A57FD00E217F9 /* OWSFileSystem.m */, - C3D9E41E25676C870040E4F3 /* OWSPrimaryStorageProtocol.swift */, C33FDBBC255A581600E217F9 /* SSKKeychainStorage.swift */, - C33FDB36255A580B00E217F9 /* Storage.swift */, - C33FDAA1255A57FF00E217F9 /* TSYapDatabaseObject.h */, - C33FDA90255A57FD00E217F9 /* TSYapDatabaseObject.m */, ); path = Database; sourceTree = ""; @@ -2385,19 +2332,6 @@ FD17D79427F3E03300122BE0 /* Migrations */, FD09796C27FA6C8B00936362 /* Models */, B8F5F56425EC8453003BF8D4 /* Notification+Contacts.swift */, - C33FDAEA255A580500E217F9 /* OWSBackupFragment.h */, - C33FDB07255A580700E217F9 /* OWSBackupFragment.m */, - C33FDA67255A57F900E217F9 /* OWSPrimaryStorage.h */, - C33FDC02255A581D00E217F9 /* OWSPrimaryStorage.m */, - C33FDAFE255A580600E217F9 /* OWSStorage.h */, - C33FDAB1255A580000E217F9 /* OWSStorage.m */, - C33FDAB9255A580100E217F9 /* OWSStorage+Subclass.h */, - B8D8F18825661BA50092EF10 /* Storage+OpenGroups.swift */, - C3F0A5FD255C988A007BE2A3 /* Storage+Shared.swift */, - C33FDB25255A580900E217F9 /* TSDatabaseSecondaryIndexes.h */, - C33FDB20255A580900E217F9 /* TSDatabaseSecondaryIndexes.m */, - C33FDB2C255A580A00E217F9 /* TSDatabaseView.h */, - C33FDB46255A580C00E217F9 /* TSDatabaseView.m */, ); path = Database; sourceTree = ""; @@ -2475,7 +2409,6 @@ children = ( C33FD9B7255A54A300E217F9 /* Meta */, C3F0A5EB255C970D007BE2A3 /* Configuration.swift */, - C38BBA0E255E32440041B9A3 /* Database */, C36096ED25AD20FD008B62B2 /* Media Viewing & Editing */, C38BBA0D255E321C0041B9A3 /* Messaging */, C36096EF25AD2268008B62B2 /* Profile Pictures */, @@ -2704,13 +2637,6 @@ path = Notifications; sourceTree = ""; }; - C379DCE82567330E0002D4EB /* Migrations */ = { - isa = PBXGroup; - children = ( - ); - path = Migrations; - sourceTree = ""; - }; C379DCE9256733390002D4EB /* Image Editing */ = { isa = PBXGroup; children = ( @@ -2777,15 +2703,6 @@ path = Messaging; sourceTree = ""; }; - C38BBA0E255E32440041B9A3 /* Database */ = { - isa = PBXGroup; - children = ( - C379DCE82567330E0002D4EB /* Migrations */, - C33FDA6D255A57FA00E217F9 /* YapDatabase+Promise.swift */, - ); - path = Database; - sourceTree = ""; - }; C3A721332558BDDF0043A11F /* Open Groups */ = { isa = PBXGroup; children = ( @@ -2836,10 +2753,6 @@ C38EEF09255B49A8007E1867 /* SNProtoEnvelope+Conversion.swift */, C3A3A170256E1D25004D228D /* SSKReachabilityManager.swift */, C3ECBF7A257056B700EA7FCE /* Threading.swift */, - C33FDB5F255A580E00E217F9 /* YapDatabaseConnection+OWS.h */, - C33FDB43255A580C00E217F9 /* YapDatabaseConnection+OWS.m */, - C33FDA88255A57FD00E217F9 /* YapDatabaseTransaction+OWS.h */, - C33FDB5B255A580E00E217F9 /* YapDatabaseTransaction+OWS.m */, ); path = Utilities; sourceTree = ""; @@ -2916,7 +2829,6 @@ children = ( C3C2A7802553AA6300C340D1 /* Protos */, C3C2A70A25539DF900C340D1 /* Meta */, - C3BBE07F2554CDD70050F1E3 /* Storage.swift */, C32C5BCB256DC818003C73A2 /* Database */, C300A5BB2554AFFB00555489 /* Messages */, C300A5F02554B08500555489 /* Sending & Receiving */, @@ -3497,7 +3409,6 @@ isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( - C3D9E3FA25676BCE0040E4F3 /* TSYapDatabaseObject.h in Headers */, C3D9E3A4256763DE0040E4F3 /* AppContext.h in Headers */, C3D9E38A256760390040E4F3 /* OWSFileSystem.h in Headers */, C352A3B72557B6ED00338F3E /* TSRequest.h in Headers */, @@ -3520,21 +3431,13 @@ isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( - C32C5EE5256DF506003C73A2 /* YapDatabaseConnection+OWS.h in Headers */, C3C2A6F425539DE700C340D1 /* SessionMessagingKit.h in Headers */, C32C5C46256DCBB2003C73A2 /* AppReadiness.h in Headers */, - B8856CB1256F0F47001CE70E /* OWSBackupFragment.h in Headers */, - C3A3A12B256E1AD5004D228D /* TSDatabaseSecondaryIndexes.h in Headers */, C32C5FC4256E0209003C73A2 /* OWSBackgroundTask.h in Headers */, - C32C5EF7256DF567003C73A2 /* TSDatabaseView.h in Headers */, B8856ED7256F1EB4001CE70E /* OWSPreferences.h in Headers */, C32A026C25A801AF000ED5D4 /* NSData+messagePadding.h in Headers */, B8856D72256F1421001CE70E /* OWSWindowManager.h in Headers */, B8856CF7256F105E001CE70E /* OWSAudioPlayer.h in Headers */, - C32C5E7E256DE023003C73A2 /* YapDatabaseTransaction+OWS.h in Headers */, - C32C5EA0256DE0D6003C73A2 /* OWSPrimaryStorage.h in Headers */, - B8856E33256F18D5001CE70E /* OWSStorage+Subclass.h in Headers */, - C32C5E64256DDFD6003C73A2 /* OWSStorage.h in Headers */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4295,7 +4198,6 @@ C33FDD03255A582000E217F9 /* WeakTimer.swift in Sources */, C38EF3B9255B6DE7007E1867 /* ImageEditorPinchGestureRecognizer.swift in Sources */, C33FDC98255A582000E217F9 /* SwiftSingletons.swift in Sources */, - C33FDC27255A581F00E217F9 /* YapDatabase+Promise.swift in Sources */, C38EF3B8255B6DE7007E1867 /* ImageEditorTextViewController.swift in Sources */, C38EF40B255B6DF7007E1867 /* TappableStackView.swift in Sources */, C38EF31D255B6DBF007E1867 /* UIImage+OWS.swift in Sources */, @@ -4354,7 +4256,6 @@ 7B1D74AC27BDE7510030B423 /* Promise+Timeout.swift in Sources */, C3AABDDF2553ECF00042FF4C /* Array+Utilities.swift in Sources */, C32C5A47256DB8F0003C73A2 /* ECKeyPair+Hexadecimal.swift in Sources */, - C3D9E41525676C320040E4F3 /* Storage.swift in Sources */, 7B1D74B027C365960030B423 /* Timer+MainThread.swift in Sources */, C32C5D83256DD5B6003C73A2 /* SSKKeychainStorage.swift in Sources */, C3D9E39B256763C20040E4F3 /* AppContext.m in Sources */, @@ -4393,7 +4294,6 @@ C3C2AC2E2553CBEB00C340D1 /* String+Trimming.swift in Sources */, C32C5B48256DC211003C73A2 /* NSNotificationCenter+OWS.m in Sources */, FD17D7C727F5207C00122BE0 /* DatabaseMigrator+Utilities.swift in Sources */, - C3D9E3C925676AF30040E4F3 /* TSYapDatabaseObject.m in Sources */, C352A3A62557B60D00338F3E /* TSRequest.m in Sources */, FD848B9328420164000E298B /* UnicodeScalar+Utilities.swift in Sources */, FD09796B27F6C67500936362 /* Failable.swift in Sources */, @@ -4415,7 +4315,6 @@ B8BC00C0257D90E30032E807 /* General.swift in Sources */, FD17D7A127F40D2500122BE0 /* GRDBStorage.swift in Sources */, C32C5A24256DB7DB003C73A2 /* SNUserDefaults.swift in Sources */, - C3D9E41F25676C870040E4F3 /* OWSPrimaryStorageProtocol.swift in Sources */, C3BBE0A72554D4DE0050F1E3 /* Promise+Retrying.swift in Sources */, B8856D7B256F14F4001CE70E /* UIView+OWS.m in Sources */, FD17D7B027F4225C00122BE0 /* Set+Utilities.swift in Sources */, @@ -4478,11 +4377,9 @@ FDF0B7472804F0CE004C14C5 /* DisappearingMessagesJob.swift in Sources */, B8856D1A256F114D001CE70E /* ProximityMonitoringManager.swift in Sources */, C3D9E52725677DF20040E4F3 /* ThumbnailService.swift in Sources */, - C32C5E75256DE020003C73A2 /* YapDatabaseTransaction+OWS.m in Sources */, FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */, FD09797527FAB64300936362 /* ProfileManager.swift in Sources */, FDA8EAFE280E8B78002B68E5 /* FailedMessageSendsJob.swift in Sources */, - C3BBE0802554CDD70050F1E3 /* Storage.swift in Sources */, C3DB66AC260ACA42001EFC55 /* OpenGroupManagerV2.swift in Sources */, C379DCF4256735770002D4EB /* VisibleMessage+Attachment.swift in Sources */, FD09797F27FCFBFF00936362 /* OWSAES256Key+Utilities.swift in Sources */, @@ -4513,18 +4410,14 @@ C3C2A7712553A41E00C340D1 /* ControlMessage.swift in Sources */, C32C5D19256DD493003C73A2 /* LinkPreviewDraft.swift in Sources */, FD3C907527E83AC200CD579F /* OpenGroupServerIdLookup.swift in Sources */, - C32C5CF0256DD3E4003C73A2 /* Storage+Shared.swift in Sources */, C300A5BD2554B00D00555489 /* ReadReceipt.swift in Sources */, FDF0B7582807F368004C14C5 /* MessageReceiverError.swift in Sources */, - C32C5E5B256DDF45003C73A2 /* OWSStorage.m in Sources */, - C32C5C4F256DCC36003C73A2 /* Storage+OpenGroups.swift in Sources */, FD6A7A692818BE7300035AC1 /* RetrieveDefaultOpenGroupRoomsJob.swift in Sources */, C3DA9C0725AE7396008F7C7E /* ConfigurationMessage.swift in Sources */, B8856CEE256F1054001CE70E /* OWSAudioPlayer.m in Sources */, FD5D200F27AA2B6000FEA984 /* MessageRequestResponse.swift in Sources */, FD09798D27FD1D8900936362 /* DisappearingMessageConfiguration.swift in Sources */, FDF0B75A2807F3A3004C14C5 /* MessageSenderError.swift in Sources */, - C32C5EDC256DF501003C73A2 /* YapDatabaseConnection+OWS.m in Sources */, C3BBE0762554CDA60050F1E3 /* Configuration.swift in Sources */, B8856D69256F141F001CE70E /* OWSWindowManager.m in Sources */, C3ECBF7B257056B700EA7FCE /* Threading.swift in Sources */, @@ -4535,12 +4428,10 @@ C34A977425A3E34A00852C71 /* ClosedGroupControlMessage.swift in Sources */, FD09799527FE7B8E00936362 /* Interaction.swift in Sources */, B88FA7B826045D100049422F /* OpenGroupAPIV2.swift in Sources */, - C32C5E97256DE0CB003C73A2 /* OWSPrimaryStorage.m in Sources */, C3A71D1F25589AC30043A11F /* WebSocketResources.pb.swift in Sources */, FDF0B74B28061F7A004C14C5 /* InteractionAttachment.swift in Sources */, B8F5F56525EC8453003BF8D4 /* Notification+Contacts.swift in Sources */, FD09796E27FA6D0000936362 /* Contact.swift in Sources */, - C3A3A0FE256E1A3C004D228D /* TSDatabaseSecondaryIndexes.m in Sources */, C38D5E8D2575011E00B6A65C /* MessageSender+ClosedGroups.swift in Sources */, C3DB6695260AC923001EFC55 /* OpenGroupV2.swift in Sources */, C352A349255781F400338F3E /* AttachmentDownloadJob.swift in Sources */, @@ -4553,14 +4444,12 @@ C3C2A75F2553A3C500C340D1 /* VisibleMessage+LinkPreview.swift in Sources */, C32C5FBB256E0206003C73A2 /* OWSBackgroundTask.m in Sources */, FD848B8D283E0B26000E298B /* MessageInputTypes.swift in Sources */, - B8856CA8256F0F42001CE70E /* OWSBackupFragment.m in Sources */, C32C5C3D256DCBAF003C73A2 /* AppReadiness.m in Sources */, B87EF17126367CF800124B3C /* FileServerAPIV2.swift in Sources */, FD09799B27FFC82D00936362 /* Quote.swift in Sources */, C3C2A74425539EB700C340D1 /* Message.swift in Sources */, FD09798527FD1A6500936362 /* ClosedGroupKeyPair.swift in Sources */, C32C5DBF256DD743003C73A2 /* ClosedGroupPoller.swift in Sources */, - C32C5EEE256DF54E003C73A2 /* TSDatabaseView.m in Sources */, C352A35B2557824E00338F3E /* AttachmentUploadJob.swift in Sources */, FD09797027FA6FF300936362 /* Profile.swift in Sources */, FD09798B27FD1CFE00936362 /* Capability.swift in Sources */, @@ -4657,7 +4546,6 @@ B8F5F71A25F1B35C003BF8D4 /* MediaPlaceholderView.swift in Sources */, 4C21D5D8223AC60F00EF8A77 /* PhotoCapture.swift in Sources */, C331FFF32558FF0300070591 /* PathStatusView.swift in Sources */, - 4CC1ECFB211A553000CC13BE /* AppUpdateNag.swift in Sources */, B848A4C5269EAAA200617031 /* UserDetailsSheet.swift in Sources */, 34B6A903218B3F63007C4606 /* TypingIndicatorView.swift in Sources */, B886B4A72398B23E00211ABE /* QRCodeVC.swift in Sources */, diff --git a/Session/Conversations/Settings/OWSConversationSettingsViewController.m b/Session/Conversations/Settings/OWSConversationSettingsViewController.m index 8a0db3674..80f7c1dbb 100644 --- a/Session/Conversations/Settings/OWSConversationSettingsViewController.m +++ b/Session/Conversations/Settings/OWSConversationSettingsViewController.m @@ -11,7 +11,6 @@ #import #import #import -#import @import ContactsUI; @import PromiseKit; @@ -27,8 +26,6 @@ CGFloat kIconViewLength = 24; @property (nonatomic) BOOL isNoteToSelf; @property (nonatomic) BOOL isClosedGroup; @property (nonatomic) BOOL isOpenGroup; -@property (nonatomic) YapDatabaseConnection *uiDatabaseConnection; -@property (nonatomic, readonly) YapDatabaseConnection *editingDatabaseConnection; @property (nonatomic) NSArray *disappearingMessagesDurations; @property (nonatomic) BOOL originalIsDisappearingMessagesEnabled; @@ -106,11 +103,6 @@ CGFloat kIconViewLength = 24; object:nil]; } -- (YapDatabaseConnection *)editingDatabaseConnection -{ - return [OWSPrimaryStorage sharedManager].dbReadWriteConnection; -} - - (void)configureWithThreadId:(NSString *)threadId threadName:(nullable NSString *)threadName isClosedGroup:(BOOL)isClosedGroup isOpenGroup:(BOOL)isOpenGroup isNoteToSelf:(BOOL)isNoteToSelf { self.threadId = threadId; self.threadName = threadName; diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 846daa04e..d12f8a3b1 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -236,14 +236,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD private func verifyDBKeysAvailableBeforeBackgroundLaunch() { guard UIApplication.shared.applicationState == .background else { return } - // Ensure both databases are accessible (as long as we are supporting the YDB migration - // we should keep this check) - let databasePasswordAccessible: Bool = ( - GRDBStorage.isDatabasePasswordAccessible && // GRDB password access - OWSStorage.isDatabasePasswordAccessible() // YapDatabase password access - ) - - guard !databasePasswordAccessible else { return } // All good + guard !GRDBStorage.isDatabasePasswordAccessible else { return } // All good Logger.info("Exiting because we are in the background and the database password is not accessible.") diff --git a/Session/Meta/SessionApp.swift b/Session/Meta/SessionApp.swift index c45aa7a45..dd00e2683 100644 --- a/Session/Meta/SessionApp.swift +++ b/Session/Meta/SessionApp.swift @@ -57,9 +57,8 @@ public struct SessionApp { Logger.error("") DDLog.flushLog() - OWSStorage.resetAllStorage() + GRDBStorage.resetAllStorage() ProfileManager.resetProfileStorage() - Environment.shared.preferences.clear() AppEnvironment.shared.notificationPresenter.clearAllNotifications() onReset?() diff --git a/Session/Notifications/AppNotifications.swift b/Session/Notifications/AppNotifications.swift index 0315b8eeb..48c792314 100644 --- a/Session/Notifications/AppNotifications.swift +++ b/Session/Notifications/AppNotifications.swift @@ -118,12 +118,6 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { SwiftSingletons.register(self) } - // MARK: - Dependencies - - var preferences: OWSPreferences { - return Environment.shared.preferences - } - // MARK: - @objc @@ -380,10 +374,6 @@ class NotificationActionHandler { return AppEnvironment.shared.notificationPresenter } - var dbConnection: YapDatabaseConnection { - return OWSPrimaryStorage.shared().dbReadWriteConnection - } - // MARK: - func markAsRead(userInfo: [AnyHashable: Any]) throws -> Promise { diff --git a/Session/Utilities/AppUpdateNag.swift b/Session/Utilities/AppUpdateNag.swift deleted file mode 100644 index eee81cde8..000000000 --- a/Session/Utilities/AppUpdateNag.swift +++ /dev/null @@ -1,243 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -import Foundation -import PromiseKit - -@objc -class AppUpdateNag: NSObject { - - // MARK: Public - - @objc(sharedInstance) - public static let shared: AppUpdateNag = { - let versionService = AppStoreVersionService() - let nagManager = AppUpdateNag(versionService: versionService) - return nagManager - }() - - @objc - public func showAppUpgradeNagIfNecessary() { - return - - /* - guard let currentVersion = self.currentVersion else { - owsFailDebug("currentVersion was unexpectedly nil") - return - } - - guard let bundleIdentifier = self.bundleIdentifier else { - owsFailDebug("bundleIdentifier was unexpectedly nil") - return - } - - guard let lookupURL = lookupURL(bundleIdentifier: bundleIdentifier) else { - owsFailDebug("appStoreURL was unexpectedly nil") - return - } - - firstly { - self.versionService.fetchLatestVersion(lookupURL: lookupURL) - }.done { appStoreRecord in - guard appStoreRecord.version.compare(currentVersion, options: .numeric) == ComparisonResult.orderedDescending else { - Logger.debug("remote version: \(appStoreRecord) is not newer than currentVersion: \(currentVersion)") - return - } - - Logger.info("new version available: \(appStoreRecord)") - self.showUpdateNagIfEnoughTimeHasPassed(appStoreRecord: appStoreRecord) - }.catch { error in - Logger.error("failed with error: \(error)") - }.retainUntilComplete() - */ - } - - // MARK: - Internal - - let kUpgradeNagCollection = "TSStorageManagerAppUpgradeNagCollection" - let kLastNagDateKey = "TSStorageManagerAppUpgradeNagDate" - let kFirstHeardOfNewVersionDateKey = "TSStorageManagerAppUpgradeFirstHeardOfNewVersionDate" - - var dbConnection: YapDatabaseConnection { - return OWSPrimaryStorage.shared().dbReadWriteConnection - } - - // MARK: Bundle accessors - - var bundle: Bundle { - return Bundle.main - } - - var currentVersion: String? { - return bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String - } - - var bundleIdentifier: String? { - return bundle.bundleIdentifier - } - - func lookupURL(bundleIdentifier: String) -> URL? { - return URL(string: "https://itunes.apple.com/lookup?bundleId=\(bundleIdentifier)") - } - - let versionService: AppStoreVersionService - - required init(versionService: AppStoreVersionService) { - self.versionService = versionService - super.init() - - SwiftSingletons.register(self) - } - - func showUpdateNagIfEnoughTimeHasPassed(appStoreRecord: AppStoreRecord) { - guard let firstHeardOfNewVersionDate = self.firstHeardOfNewVersionDate else { - self.setFirstHeardOfNewVersionDate(Date()) - return - } - - let intervalBeforeNag = 7 * kDayInterval - guard Date() > Date.init(timeInterval: intervalBeforeNag, since: firstHeardOfNewVersionDate) else { - Logger.info("firstHeardOfNewVersionDate: \(firstHeardOfNewVersionDate) not nagging for new release yet.") - return - } - - if let lastNagDate = self.lastNagDate { - let intervalBetweenNags = 14 * kDayInterval - guard Date() > Date.init(timeInterval: intervalBetweenNags, since: lastNagDate) else { - Logger.info("lastNagDate: \(lastNagDate) not nagging again so soon.") - return - } - } - - // Only show nag if we are "at rest" in the home view or registration view without any - // alerts or dialogs showing. - guard UIApplication.shared.frontmostViewController != nil else { - owsFailDebug("frontmostViewController was unexpectedly nil") - return - } - - /* - switch frontmostViewController { - case is OnboardingSplashViewController: - self.setLastNagDate(Date()) - self.clearFirstHeardOfNewVersionDate() - presentUpgradeNag(appStoreRecord: appStoreRecord) - default: - Logger.debug("not presenting alert due to frontmostViewController: \(frontmostViewController)") - break - } - */ - } - - func presentUpgradeNag(appStoreRecord: AppStoreRecord) { - let title = NSLocalizedString("APP_UPDATE_NAG_ALERT_TITLE", comment: "Title for the 'new app version available' alert.") - - let bodyFormat = NSLocalizedString("APP_UPDATE_NAG_ALERT_MESSAGE_FORMAT", comment: "Message format for the 'new app version available' alert. Embeds: {{The latest app version number}}") - let bodyText = String(format: bodyFormat, appStoreRecord.version) - let updateButtonText = NSLocalizedString("APP_UPDATE_NAG_ALERT_UPDATE_BUTTON", comment: "Label for the 'update' button in the 'new app version available' alert.") - let dismissButtonText = NSLocalizedString("APP_UPDATE_NAG_ALERT_DISMISS_BUTTON", comment: "Label for the 'dismiss' button in the 'new app version available' alert.") - - let alert = UIAlertController(title: title, message: bodyText, preferredStyle: .alert) - - let updateAction = UIAlertAction(title: updateButtonText, style: .default) { [weak self] _ in - guard let strongSelf = self else { - return - } - - strongSelf.showAppStore(appStoreURL: appStoreRecord.appStoreURL) - } - - alert.addAction(updateAction) - alert.addAction(UIAlertAction(title: dismissButtonText, style: .cancel, handler: nil)) - - OWSAlerts.showAlert(alert) - } - - func showAppStore(appStoreURL: URL) { - Logger.debug("") - UIApplication.shared.openURL(appStoreURL) - } - - // MARK: Storage - - var firstHeardOfNewVersionDate: Date? { - return self.dbConnection.date(forKey: kFirstHeardOfNewVersionDateKey, inCollection: kUpgradeNagCollection) - } - - func setFirstHeardOfNewVersionDate(_ date: Date) { - self.dbConnection.setDate(date, forKey: kFirstHeardOfNewVersionDateKey, inCollection: kUpgradeNagCollection) - } - - func clearFirstHeardOfNewVersionDate() { - self.dbConnection.removeObject(forKey: kFirstHeardOfNewVersionDateKey, inCollection: kUpgradeNagCollection) - } - - var lastNagDate: Date? { - return self.dbConnection.date(forKey: kLastNagDateKey, inCollection: kUpgradeNagCollection) - } - - func setLastNagDate(_ date: Date) { - self.dbConnection.setDate(date, forKey: kLastNagDateKey, inCollection: kUpgradeNagCollection) - } -} - -// MARK: Parsing Structs - -struct AppStoreLookupResultSet: Codable { - let resultCount: UInt - let results: [AppStoreRecord] -} - -struct AppStoreRecord: Codable { - let appStoreURL: URL - let version: String - - private enum CodingKeys: String, CodingKey { - case appStoreURL = "trackViewUrl" - case version - } -} - -class AppStoreVersionService: NSObject { - - // MARK: - - func fetchLatestVersion(lookupURL: URL) -> Promise { - Logger.debug("lookupURL:\(lookupURL)") - - let (promise, resolver) = Promise.pending() - - let task = URLSession.ephemeral.dataTask(with: lookupURL) { (data, _, error) in - guard let data = data else { - Logger.warn("data was unexpectedly nil") - resolver.reject(OWSErrorMakeUnableToProcessServerResponseError()) - return - } - - do { - let decoder = JSONDecoder() - let resultSet = try decoder.decode(AppStoreLookupResultSet.self, from: data) - guard let appStoreRecord = resultSet.results.first else { - Logger.warn("record was unexpectedly nil") - resolver.reject(OWSErrorMakeUnableToProcessServerResponseError()) - return - } - - resolver.fulfill(appStoreRecord) - } catch { - resolver.reject(error) - } - } - - task.resume() - - return promise - } -} - -extension URLSession { - static var ephemeral: URLSession { - return URLSession(configuration: .ephemeral) - } -} diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 1d14576af..9497d4ce1 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -3,6 +3,7 @@ import Foundation import AVKit import GRDB +import YapDatabase import Curve25519Kit import SessionUtilitiesKit import SessionSnodeKit @@ -278,38 +279,49 @@ enum _003_YDBToGRDBMigration: Migration { // Note: The 'int(forKey:inCollection:)' defaults to `0` which is an incorrect value // for the notification sound so catch it and default - let globalNotificationSoundValue: Int32 = transaction.int( - forKey: SMKLegacy.soundsGlobalNotificationKey, - inCollection: SMKLegacy.soundsStorageNotificationCollection - ) - legacyPreferences[SMKLegacy.soundsGlobalNotificationKey] = (globalNotificationSoundValue > 0 ? - Int(globalNotificationSoundValue) : - Preferences.Sound.defaultNotificationSound.rawValue - ) + legacyPreferences[SMKLegacy.soundsGlobalNotificationKey] = (transaction + .object( + forKey: SMKLegacy.soundsGlobalNotificationKey, + inCollection: SMKLegacy.soundsStorageNotificationCollection + ) + .asType(NSNumber.self)? + .intValue) + .defaulting(to: Preferences.Sound.defaultNotificationSound.rawValue) - legacyPreferences[SMKLegacy.readReceiptManagerAreReadReceiptsEnabled] = transaction.bool( - forKey: SMKLegacy.readReceiptManagerAreReadReceiptsEnabled, - inCollection: SMKLegacy.readReceiptManagerCollection, - defaultValue: false - ) + legacyPreferences[SMKLegacy.readReceiptManagerAreReadReceiptsEnabled] = (transaction + .object( + forKey: SMKLegacy.readReceiptManagerAreReadReceiptsEnabled, + inCollection: SMKLegacy.readReceiptManagerCollection + ) + .asType(NSNumber.self)? + .boolValue) + .defaulting(to: false) - legacyPreferences[SMKLegacy.typingIndicatorsEnabledKey] = transaction.bool( - forKey: SMKLegacy.typingIndicatorsEnabledKey, - inCollection: SMKLegacy.typingIndicatorsCollection, - defaultValue: false - ) + legacyPreferences[SMKLegacy.typingIndicatorsEnabledKey] = (transaction + .object( + forKey: SMKLegacy.typingIndicatorsEnabledKey, + inCollection: SMKLegacy.typingIndicatorsCollection + ) + .asType(NSNumber.self)? + .boolValue) + .defaulting(to: false) - legacyPreferences[SMKLegacy.screenLockIsScreenLockEnabledKey] = transaction.bool( - forKey: SMKLegacy.screenLockIsScreenLockEnabledKey, - inCollection: SMKLegacy.screenLockCollection, - defaultValue: false - ) + legacyPreferences[SMKLegacy.screenLockIsScreenLockEnabledKey] = (transaction + .object( + forKey: SMKLegacy.screenLockIsScreenLockEnabledKey, + inCollection: SMKLegacy.screenLockCollection + ) + .asType(NSNumber.self)? + .boolValue) + .defaulting(to: false) - legacyPreferences[SMKLegacy.screenLockScreenLockTimeoutSecondsKey] = transaction.double( - forKey: SMKLegacy.screenLockScreenLockTimeoutSecondsKey, - inCollection: SMKLegacy.screenLockCollection, - defaultValue: (15 * 60) - ) + legacyPreferences[SMKLegacy.screenLockScreenLockTimeoutSecondsKey] = (transaction + .object( + forKey: SMKLegacy.screenLockScreenLockTimeoutSecondsKey, + inCollection: SMKLegacy.screenLockCollection) + .asType(NSNumber.self)? + .doubleValue) + .defaulting(to: (15 * 60)) GRDBStorage.shared.update(progress: 0.23, for: self, in: target) } diff --git a/SessionMessagingKit/Database/Notification+Contacts.swift b/SessionMessagingKit/Database/Notification+Contacts.swift index 6679490cf..a61ca1aa8 100644 --- a/SessionMessagingKit/Database/Notification+Contacts.swift +++ b/SessionMessagingKit/Database/Notification+Contacts.swift @@ -1,7 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import SessionUtilitiesKit - +// FIXME: Remove these extensions once the OWSConversationSettingsViewModel is refactored to swift and uses proper database observation public extension Notification.Name { static let otherUsersProfileDidChange = Notification.Name("otherUsersProfileDidChange") diff --git a/SessionMessagingKit/Database/OWSBackupFragment.h b/SessionMessagingKit/Database/OWSBackupFragment.h deleted file mode 100644 index 392a7e73a..000000000 --- a/SessionMessagingKit/Database/OWSBackupFragment.h +++ /dev/null @@ -1,44 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -// We store metadata for known backup fragments (i.e. CloudKit record) in -// the database. We might learn about them from: -// -// * Past backup exports. -// * An import downloading and parsing the manifest of the last complete backup. -// -// Storing this data in the database provides continuity. -// -// * Backup exports can reuse fragments from previous Backup exports even if they -// don't complete (i.e. backup export resume). -// * Backup exports can reuse fragments from the backup import, if any. -@interface OWSBackupFragment : TSYapDatabaseObject - -@property (nonatomic) NSString *recordName; - -@property (nonatomic) NSData *encryptionKey; - -// This property is only set for certain types of manifest item, -// namely attachments where we need to know where the attachment's -// file should reside relative to the attachments folder. -@property (nonatomic, nullable) NSString *relativeFilePath; - -// This property is only set for attachments. -@property (nonatomic, nullable) NSString *attachmentId; - -// This property is only set if the manifest item is downloaded. -@property (nonatomic, nullable) NSString *downloadFilePath; - -// This property is only set if the manifest item is compressed. -@property (nonatomic, nullable) NSNumber *uncompressedDataLength; - -- (instancetype)init NS_UNAVAILABLE; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Database/OWSBackupFragment.m b/SessionMessagingKit/Database/OWSBackupFragment.m deleted file mode 100644 index 87627f26f..000000000 --- a/SessionMessagingKit/Database/OWSBackupFragment.m +++ /dev/null @@ -1,13 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSBackupFragment.h" - -NS_ASSUME_NONNULL_BEGIN - -@implementation OWSBackupFragment - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Database/OWSPrimaryStorage.h b/SessionMessagingKit/Database/OWSPrimaryStorage.h deleted file mode 100644 index 269ad1fc9..000000000 --- a/SessionMessagingKit/Database/OWSPrimaryStorage.h +++ /dev/null @@ -1,51 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -extern NSString *const OWSUIDatabaseConnectionWillUpdateNotification; -extern NSString *const OWSUIDatabaseConnectionDidUpdateNotification; -extern NSString *const OWSUIDatabaseConnectionWillUpdateExternallyNotification; -extern NSString *const OWSUIDatabaseConnectionDidUpdateExternallyNotification; -extern NSString *const OWSUIDatabaseConnectionNotificationsKey; - -@interface OWSPrimaryStorage : OWSStorage - -- (instancetype)init NS_UNAVAILABLE; - -- (instancetype)initStorage; - -+ (instancetype)sharedManager NS_SWIFT_NAME(shared()); - -@property (nonatomic, readonly) YapDatabaseConnection *uiDatabaseConnection; -@property (nonatomic, readonly) YapDatabaseConnection *dbReadConnection; -@property (nonatomic, readonly) YapDatabaseConnection *dbReadWriteConnection; - -- (void)updateUIDatabaseConnectionToLatest; - -+ (YapDatabaseConnection *)dbReadConnection; -+ (YapDatabaseConnection *)dbReadWriteConnection; - -+ (nullable NSError *)migrateToSharedData; - -+ (NSString *)databaseFilePath; - -+ (NSString *)legacyDatabaseFilePath; -+ (NSString *)legacyDatabaseFilePath_SHM; -+ (NSString *)legacyDatabaseFilePath_WAL; -+ (NSString *)sharedDataDatabaseFilePath; -+ (NSString *)sharedDataDatabaseFilePath_SHM; -+ (NSString *)sharedDataDatabaseFilePath_WAL; - -+ (void)protectFiles; - -#pragma mark - Misc. - -- (void)touchDbAsync; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Database/OWSPrimaryStorage.m b/SessionMessagingKit/Database/OWSPrimaryStorage.m deleted file mode 100644 index fd1cc007e..000000000 --- a/SessionMessagingKit/Database/OWSPrimaryStorage.m +++ /dev/null @@ -1,382 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "OWSPrimaryStorage.h" -#import "AppContext.h" -#import "OWSFileSystem.h" -#import -#import "OWSStorage.h" -#import "OWSStorage+Subclass.h" -#import "TSDatabaseSecondaryIndexes.h" -#import "TSDatabaseView.h" -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -NSString *const OWSUIDatabaseConnectionWillUpdateNotification = @"OWSUIDatabaseConnectionWillUpdateNotification"; -NSString *const OWSUIDatabaseConnectionDidUpdateNotification = @"OWSUIDatabaseConnectionDidUpdateNotification"; -NSString *const OWSUIDatabaseConnectionWillUpdateExternallyNotification = @"OWSUIDatabaseConnectionWillUpdateExternallyNotification"; -NSString *const OWSUIDatabaseConnectionDidUpdateExternallyNotification = @"OWSUIDatabaseConnectionDidUpdateExternallyNotification"; - -NSString *const OWSUIDatabaseConnectionNotificationsKey = @"OWSUIDatabaseConnectionNotificationsKey"; - -void VerifyRegistrationsForPrimaryStorage(OWSStorage *storage) -{ - [[storage newDatabaseConnection] asyncReadWithBlock:^(YapDatabaseReadTransaction *transaction) { - for (NSString *extensionName in storage.registeredExtensionNames) { - YapDatabaseViewTransaction *_Nullable viewTransaction = [transaction ext:extensionName]; - if (!viewTransaction) { - [OWSStorage incrementVersionOfDatabaseExtension:extensionName]; - } - } - }]; -} - -#pragma mark - - -@interface OWSPrimaryStorage () - -@property (atomic) BOOL areAsyncRegistrationsComplete; -@property (atomic) BOOL areSyncRegistrationsComplete; -@property (nonatomic, readonly) YapDatabaseConnectionPool *dbReadPool; - -@end - -#pragma mark - - -@implementation OWSPrimaryStorage - -@synthesize uiDatabaseConnection = _uiDatabaseConnection; - -+ (instancetype)sharedManager -{ - return SSKEnvironment.shared.primaryStorage; -} - -- (instancetype)initStorage -{ - self = [super initStorage]; - - if (self) { - [self loadDatabase]; - - _dbReadPool = [[YapDatabaseConnectionPool alloc] initWithDatabase:self.database]; - _dbReadPool.connectionLimit = 10; // Increase max read connection limit. Default is 3. - _dbReadWriteConnection = [self newDatabaseConnection]; - _uiDatabaseConnection = [self newDatabaseConnection]; - - // Increase object cache limit. Default is 250. - _uiDatabaseConnection.objectCacheLimit = 500; - [_uiDatabaseConnection beginLongLivedReadTransaction]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(yapDatabaseModified:) - name:YapDatabaseModifiedNotification - object:self.dbNotificationObject]; - - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(yapDatabaseModifiedExternally:) - name:YapDatabaseModifiedExternallyNotification - object:nil]; - } - - return self; -} - -- (void)dealloc -{ - [[NSNotificationCenter defaultCenter] removeObserver:self]; -} - -- (void)yapDatabaseModifiedExternally:(NSNotification *)notification -{ - // Notify observers we're about to update the database connection - [[NSNotificationCenter defaultCenter] postNotificationName:OWSUIDatabaseConnectionWillUpdateExternallyNotification object:self.dbNotificationObject]; - - // Move uiDatabaseConnection to the latest commit. - // Do so atomically, and fetch all the notifications for each commit we jump. - NSArray *notifications = [self.uiDatabaseConnection beginLongLivedReadTransaction]; - - // Notify observers that the uiDatabaseConnection was updated - NSDictionary *userInfo = @{ OWSUIDatabaseConnectionNotificationsKey: notifications }; - [[NSNotificationCenter defaultCenter] postNotificationName:OWSUIDatabaseConnectionDidUpdateExternallyNotification - object:self.dbNotificationObject - userInfo:userInfo]; -} - -- (void)yapDatabaseModified:(NSNotification *)notification -{ - [self updateUIDatabaseConnectionToLatest]; -} - -- (void)updateUIDatabaseConnectionToLatest -{ - // Notify observers we're about to update the database connection - [[NSNotificationCenter defaultCenter] postNotificationName:OWSUIDatabaseConnectionWillUpdateNotification object:self.dbNotificationObject]; - - // Move uiDatabaseConnection to the latest commit. - // Do so atomically, and fetch all the notifications for each commit we jump. - NSArray *notifications = [self.uiDatabaseConnection beginLongLivedReadTransaction]; - - // Notify observers that the uiDatabaseConnection was updated - NSDictionary *userInfo = @{ OWSUIDatabaseConnectionNotificationsKey: notifications }; - [[NSNotificationCenter defaultCenter] postNotificationName:OWSUIDatabaseConnectionDidUpdateNotification - object:self.dbNotificationObject - userInfo:userInfo]; -} - -- (YapDatabaseConnection *)uiDatabaseConnection -{ - return _uiDatabaseConnection; -} - -- (void)resetStorage -{ - _dbReadPool = nil; - _uiDatabaseConnection = nil; - _dbReadWriteConnection = nil; - - [super resetStorage]; -} - -- (void)runSyncRegistrations -{ - // Synchronously register extensions which are essential for views. - [TSDatabaseView registerCrossProcessNotifier:self]; - - // See comments on OWSDatabaseConnection. - // - // In the absence of finding documentation that can shed light on the issue we've been - // seeing, this issue only seems to affect sync and not async registrations. We've always - // been opening write transactions before the async registrations complete without negative - // consequences. - - self.areSyncRegistrationsComplete = YES; -} - -- (void)runAsyncRegistrationsWithCompletion:(void (^_Nonnull)(void))completion -{ - // Asynchronously register other extensions. - // - // All sync registrations must be done before all async registrations, - // or the sync registrations will block on the async registrations. - [TSDatabaseView asyncRegisterLegacyThreadInteractionsDatabaseView:self]; - [TSDatabaseView asyncRegisterThreadInteractionsDatabaseView:self]; - [TSDatabaseView asyncRegisterThreadDatabaseView:self]; - [self asyncRegisterExtension:[TSDatabaseSecondaryIndexes registerTimeStampIndex] - withName:[TSDatabaseSecondaryIndexes registerTimeStampIndexExtensionName]]; - - [TSDatabaseView asyncRegisterUnreadMentionDatabaseView:self]; - [TSDatabaseView asyncRegisterThreadOutgoingMessagesDatabaseView:self]; - - [FullTextSearchFinder asyncRegisterDatabaseExtensionWithStorage:self]; - [TSDatabaseView asyncRegisterLazyRestoreAttachmentsDatabaseView:self]; - - [self.database - flushExtensionRequestsWithCompletionQueue:dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0) - completionBlock:^{ - self.areAsyncRegistrationsComplete = YES; - - completion(); - - [self verifyDatabaseViews]; - }]; -} - -- (void)verifyDatabaseViews -{ - VerifyRegistrationsForPrimaryStorage(self); -} - -+ (void)protectFiles -{ - // Protect the entire new database directory. - [OWSFileSystem protectFileOrFolderAtPath:self.sharedDataDatabaseDirPath]; -} - -+ (NSString *)legacyDatabaseDirPath -{ - return [OWSFileSystem appDocumentDirectoryPath]; -} - -+ (NSString *)sharedDataDatabaseDirPath -{ - NSString *databaseDirPath = [[OWSFileSystem appSharedDataDirectoryPath] stringByAppendingPathComponent:@"database"]; - - [OWSFileSystem ensureDirectoryExists:databaseDirPath]; - return databaseDirPath; -} - -+ (NSString *)databaseFilename -{ - return @"Signal.sqlite"; -} - -+ (NSString *)databaseFilename_SHM -{ - return [self.databaseFilename stringByAppendingString:@"-shm"]; -} - -+ (NSString *)databaseFilename_WAL -{ - return [self.databaseFilename stringByAppendingString:@"-wal"]; -} - -+ (NSString *)legacyDatabaseFilePath -{ - return [self.legacyDatabaseDirPath stringByAppendingPathComponent:self.databaseFilename]; -} - -+ (NSString *)legacyDatabaseFilePath_SHM -{ - return [self.legacyDatabaseDirPath stringByAppendingPathComponent:self.databaseFilename_SHM]; -} - -+ (NSString *)legacyDatabaseFilePath_WAL -{ - return [self.legacyDatabaseDirPath stringByAppendingPathComponent:self.databaseFilename_WAL]; -} - -+ (NSString *)sharedDataDatabaseFilePath -{ - return [self.sharedDataDatabaseDirPath stringByAppendingPathComponent:self.databaseFilename]; -} - -+ (NSString *)sharedDataDatabaseFilePath_SHM -{ - return [self.sharedDataDatabaseDirPath stringByAppendingPathComponent:self.databaseFilename_SHM]; -} - -+ (NSString *)sharedDataDatabaseFilePath_WAL -{ - return [self.sharedDataDatabaseDirPath stringByAppendingPathComponent:self.databaseFilename_WAL]; -} - -+ (nullable NSError *)migrateToSharedData -{ - // Given how sensitive this migration is, we verbosely - // log the contents of all involved paths before and after. - NSFileManager *fileManager = [NSFileManager defaultManager]; - - // We protect the db files here, which is somewhat redundant with what will happen in - // `moveAppFilePath:` which also ensures file protection. - // However that method dispatches async, since it can take a while with large attachment directories. - // - // Since we only have three files here it'll be quick to do it sync, and we want to make - // sure it happens as part of the migration. - // - // FileProtection attributes move with the file, so we do it on the legacy files before moving - // them. - [OWSFileSystem protectFileOrFolderAtPath:self.legacyDatabaseFilePath]; - [OWSFileSystem protectFileOrFolderAtPath:self.legacyDatabaseFilePath_SHM]; - [OWSFileSystem protectFileOrFolderAtPath:self.legacyDatabaseFilePath_WAL]; - - NSError *_Nullable error = nil; - if ([fileManager fileExistsAtPath:self.legacyDatabaseFilePath] && - [fileManager fileExistsAtPath:self.sharedDataDatabaseFilePath]) { - // In the case that we have a "database conflict" (i.e. database files - // in the src and dst locations), ensure database integrity by renaming - // all of the dst database files. - for (NSString *filePath in @[ - self.sharedDataDatabaseFilePath, - self.sharedDataDatabaseFilePath_SHM, - self.sharedDataDatabaseFilePath_WAL, - ]) { - error = [OWSFileSystem renameFilePathUsingRandomExtension:filePath]; - if (error) { - return error; - } - } - } - - error = - [OWSFileSystem moveAppFilePath:self.legacyDatabaseFilePath sharedDataFilePath:self.sharedDataDatabaseFilePath]; - if (error) { - return error; - } - error = [OWSFileSystem moveAppFilePath:self.legacyDatabaseFilePath_SHM - sharedDataFilePath:self.sharedDataDatabaseFilePath_SHM]; - if (error) { - return error; - } - error = [OWSFileSystem moveAppFilePath:self.legacyDatabaseFilePath_WAL - sharedDataFilePath:self.sharedDataDatabaseFilePath_WAL]; - if (error) { - return error; - } - - return nil; -} - -+ (NSString *)databaseFilePath -{ - return self.sharedDataDatabaseFilePath; -} - -+ (NSString *)databaseFilePath_SHM -{ - return self.sharedDataDatabaseFilePath_SHM; -} - -+ (NSString *)databaseFilePath_WAL -{ - return self.sharedDataDatabaseFilePath_WAL; -} - -- (NSString *)databaseFilePath -{ - return OWSPrimaryStorage.databaseFilePath; -} - -- (NSString *)databaseFilePath_SHM -{ - return OWSPrimaryStorage.databaseFilePath_SHM; -} - -- (NSString *)databaseFilePath_WAL -{ - return OWSPrimaryStorage.databaseFilePath_WAL; -} - -- (NSString *)databaseFilename_SHM -{ - return OWSPrimaryStorage.databaseFilename_SHM; -} - -- (NSString *)databaseFilename_WAL -{ - return OWSPrimaryStorage.databaseFilename_WAL; -} - -+ (YapDatabaseConnection *)dbReadConnection -{ - return OWSPrimaryStorage.sharedManager.dbReadConnection; -} - -- (YapDatabaseConnection *)dbReadConnection -{ - return self.dbReadPool.connection; -} - -+ (YapDatabaseConnection *)dbReadWriteConnection -{ - return OWSPrimaryStorage.sharedManager.dbReadWriteConnection; -} - -#pragma mark - Misc. - -- (void)touchDbAsync -{ - // There appears to be a bug in YapDatabase that sometimes delays modifications - // made in another process (e.g. the SAE) from showing up in other processes. - // There's a simple workaround: a trivial write to the database flushes changes - // made from other processes. - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [transaction setObject:[NSUUID UUID].UUIDString forKey:@"conversation_view_noop_mod" inCollection:@"temp"]; - }]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Database/OWSStorage+Subclass.h b/SessionMessagingKit/Database/OWSStorage+Subclass.h deleted file mode 100644 index 9f3fd0be1..000000000 --- a/SessionMessagingKit/Database/OWSStorage+Subclass.h +++ /dev/null @@ -1,32 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@class YapDatabase; - -@interface OWSStorage (Subclass) - -@property (atomic, nullable, readonly) YapDatabase *database; - -- (void)loadDatabase; - -- (void)runSyncRegistrations; -// completion will be invoked _off_ the main thread. -- (void)runAsyncRegistrationsWithCompletion:(void (^_Nonnull)(void))completion; - -- (BOOL)areAsyncRegistrationsComplete; -- (BOOL)areSyncRegistrationsComplete; - -- (NSString *)databaseFilePath; -- (NSString *)databaseFilePath_SHM; -- (NSString *)databaseFilePath_WAL; - -- (void)resetStorage; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Database/OWSStorage.h b/SessionMessagingKit/Database/OWSStorage.h deleted file mode 100644 index 386794369..000000000 --- a/SessionMessagingKit/Database/OWSStorage.h +++ /dev/null @@ -1,116 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -extern NSString *const StorageIsReadyNotification; - -@class YapDatabaseExtension; - -@protocol OWSDatabaseConnectionDelegate - -- (BOOL)areAllRegistrationsComplete; - -@end - -#pragma mark - - -@interface OWSDatabaseConnection : YapDatabaseConnection - -@property (atomic, weak) id delegate; - -- (instancetype)init NS_UNAVAILABLE; -- (instancetype)initWithDatabase:(YapDatabase *)database - delegate:(id)delegate NS_DESIGNATED_INITIALIZER; - -@end - -#pragma mark - - -@interface OWSDatabase : YapDatabase - -- (instancetype)init NS_UNAVAILABLE; - -- (id)initWithPath:(NSString *)inPath - serializer:(nullable YapDatabaseSerializer)inSerializer - deserializer:(YapDatabaseDeserializer)inDeserializer - options:(YapDatabaseOptions *)inOptions - delegate:(id)delegate NS_DESIGNATED_INITIALIZER; - -@end - -#pragma mark - - -typedef void (^OWSStorageMigrationBlock)(void); - -@interface OWSStorage : NSObject - -- (instancetype)init NS_UNAVAILABLE; -- (instancetype)initStorage NS_DESIGNATED_INITIALIZER; - -// Returns YES if _ALL_ storage classes have completed both their -// sync _AND_ async view registrations. -+ (BOOL)isStorageReady; - -// This object can be used to filter database notifications. -@property (nonatomic, readonly, nullable) id dbNotificationObject; - -// migrationBlock will be invoked _off_ the main thread. -+ (void)registerExtensionsWithMigrationBlock:(OWSStorageMigrationBlock)migrationBlock; - -#ifdef DEBUG -- (void)closeStorageForTests; -#endif - -+ (void)resetAllStorage; - -- (YapDatabaseConnection *)newDatabaseConnection; - -+ (YapDatabaseOptions *)defaultDatabaseOptions; - -#pragma mark - Extension Registration - -+ (void)incrementVersionOfDatabaseExtension:(NSString *)extensionName; - -- (BOOL)registerExtension:(YapDatabaseExtension *)extension withName:(NSString *)extensionName; - -- (void)asyncRegisterExtension:(YapDatabaseExtension *)extension withName:(NSString *)extensionName; -- (void)asyncRegisterExtension:(YapDatabaseExtension *)extension - withName:(NSString *)extensionName - completion:(nullable dispatch_block_t)completion; - -- (nullable id)registeredExtension:(NSString *)extensionName; - -- (NSArray *)registeredExtensionNames; - -#pragma mark - - -- (unsigned long long)databaseFileSize; -- (unsigned long long)databaseWALFileSize; -- (unsigned long long)databaseSHMFileSize; - -- (YapDatabaseConnection *)registrationConnection; - -#pragma mark - Password - -/** - * Returns NO if: - * - * - Keychain is locked because device has just been restarted. - * - Password could not be retrieved because of a keychain error. - */ -+ (BOOL)isDatabasePasswordAccessible; - -+ (nullable NSData *)tryToLoadDatabaseLegacyPassphrase:(NSError **)errorHandle; -+ (void)removeLegacyPassphrase; - -+ (void)storeDatabaseCipherKeySpec:(NSData *)cipherKeySpecData; - -- (void)logFileSizes; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Database/OWSStorage.m b/SessionMessagingKit/Database/OWSStorage.m deleted file mode 100644 index b3f07ce8e..000000000 --- a/SessionMessagingKit/Database/OWSStorage.m +++ /dev/null @@ -1,808 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "OWSStorage.h" -#import "AppContext.h" -#import "OWSBackgroundTask.h" -#import "OWSFileSystem.h" -#import "OWSPrimaryStorage.h" -#import "TSYapDatabaseObject.h" -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -NSString *const StorageIsReadyNotification = @"StorageIsReadyNotification"; -NSString *const OWSResetStorageNotification = @"OWSResetStorageNotification"; - -static NSString *keychainService = @"TSKeyChainService"; -static NSString *keychainDBLegacyPassphrase = @"TSDatabasePass"; -static NSString *keychainDBCipherKeySpec = @"OWSDatabaseCipherKeySpec"; - -const NSUInteger kDatabasePasswordLength = 30; - -typedef NSData *_Nullable (^LoadDatabaseMetadataBlock)(NSError **_Nullable); -typedef NSData *_Nullable (^CreateDatabaseMetadataBlock)(void); - -NSString *const kNSUserDefaults_DatabaseExtensionVersionMap = @"kNSUserDefaults_DatabaseExtensionVersionMap"; - -#pragma mark - - -@interface YapDatabaseConnection () - -- (id)initWithDatabase:(YapDatabase *)database; - -@end - -#pragma mark - - -@implementation OWSDatabaseConnection - -- (id)initWithDatabase:(YapDatabase *)database delegate:(id)delegate -{ - self = [super initWithDatabase:database]; - - if (!self) { - return self; - } - - self.delegate = delegate; - - return self; -} - -// Assert that the database is in a ready state (specifically that any sync database -// view registrations have completed and any async registrations have been started) -// before creating write transactions. -// -// Creating write transactions before the _sync_ database views are registered -// causes YapDatabase to rebuild all of our database views, which is catastrophic. -// Specifically, it causes YDB's "view version" checks to fail. -- (void)readWriteWithBlock:(void (^)(YapDatabaseReadWriteTransaction *transaction))block -{ - OWSBackgroundTask *_Nullable backgroundTask = nil; - if (CurrentAppContext().isMainApp && !CurrentAppContext().isRunningTests) { - backgroundTask = [OWSBackgroundTask backgroundTaskWithLabelStr:__PRETTY_FUNCTION__]; - } - [super readWriteWithBlock:block]; - backgroundTask = nil; -} - -- (void)asyncReadWriteWithBlock:(void (^)(YapDatabaseReadWriteTransaction *transaction))block -{ - [self asyncReadWriteWithBlock:block completionQueue:NULL completionBlock:NULL]; -} - -- (void)asyncReadWriteWithBlock:(void (^)(YapDatabaseReadWriteTransaction *transaction))block - completionBlock:(nullable dispatch_block_t)completionBlock -{ - [self asyncReadWriteWithBlock:block completionQueue:NULL completionBlock:completionBlock]; -} - -- (void)asyncReadWriteWithBlock:(void (^)(YapDatabaseReadWriteTransaction *transaction))block - completionQueue:(nullable dispatch_queue_t)completionQueue - completionBlock:(nullable dispatch_block_t)completionBlock -{ - __block OWSBackgroundTask *_Nullable backgroundTask = nil; - if (CurrentAppContext().isMainApp) { - backgroundTask = [OWSBackgroundTask backgroundTaskWithLabelStr:__PRETTY_FUNCTION__]; - } - [super asyncReadWriteWithBlock:block completionQueue:completionQueue completionBlock:^{ - if (completionBlock) { - completionBlock(); - } - backgroundTask = nil; - }]; -} - -@end - -#pragma mark - - -// This class is only used in DEBUG builds. -@interface YapDatabase () - -- (void)addConnection:(YapDatabaseConnection *)connection; - -- (YapDatabaseConnection *)registrationConnection; - -@end - -#pragma mark - - -@interface OWSDatabase () - -@property (atomic, weak) id delegate; - -@end - -#pragma mark - - -@implementation OWSDatabase - -- (id)initWithPath:(NSString *)inPath - serializer:(nullable YapDatabaseSerializer)inSerializer - deserializer:(YapDatabaseDeserializer)inDeserializer - options:(YapDatabaseOptions *)inOptions - delegate:(id)delegate -{ - self = [super initWithPath:inPath serializer:inSerializer deserializer:inDeserializer options:inOptions]; - - if (!self) { - return self; - } - - self.delegate = delegate; - - return self; -} - -// This clobbers the superclass implementation to include asserts which -// ensure that the database is in a ready state before creating write transactions. -// -// See comments in OWSDatabaseConnection. -- (YapDatabaseConnection *)newConnection -{ - id delegate = self.delegate; - - OWSDatabaseConnection *connection = [[OWSDatabaseConnection alloc] initWithDatabase:self delegate:delegate]; - [self addConnection:connection]; - return connection; -} - -- (YapDatabaseConnection *)registrationConnection -{ - YapDatabaseConnection *connection = [super registrationConnection]; - return connection; -} - -@end - -#pragma mark - - -@interface OWSUnknownDBObject : TSYapDatabaseObject - -@end - -#pragma mark - - -/** - * A default object to return when we can't deserialize an object from YapDB. This can prevent crashes when - * old objects linger after their definition file is removed. The danger is that, the objects can lay in wait - * until the next time a DB extension is added and we necessarily enumerate the entire DB. - */ -@implementation OWSUnknownDBObject - -- (void)encodeWithCoder:(NSCoder *)aCoder -{ - return [super encodeWithCoder:aCoder]; -} - -- (nullable instancetype)initWithCoder:(NSCoder *)coder -{ - self = [super initWithCoder:coder]; - if (!self) { - return self; - } - - return self; -} - -- (void)saveWithTransaction:(YapDatabaseReadWriteTransaction *)transaction -{ - // No-op. -} - -- (void)touchWithTransaction:(YapDatabaseReadWriteTransaction *)transaction -{ - // No-op. -} - -- (void)removeWithTransaction:(YapDatabaseReadWriteTransaction *)transaction -{ - // No-op. -} - -@end - -#pragma mark - - -@interface OWSUnarchiverDelegate : NSObject - -@end - -#pragma mark - - -@implementation OWSUnarchiverDelegate - -- (nullable Class)unarchiver:(NSKeyedUnarchiver *)unarchiver - cannotDecodeObjectOfClassName:(NSString *)name - originalClasses:(NSArray *)classNames -{ - return [OWSUnknownDBObject class]; -} - -@end - -#pragma mark - - -@interface OWSStorage () - -@property (atomic, nullable) YapDatabase *database; - -@property (nonatomic) NSMutableArray *extensionNames; - -@end - -#pragma mark - - -@implementation OWSStorage - -- (instancetype)initStorage -{ - self = [super init]; - - if (self) { - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(resetStorage) - name:OWSResetStorageNotification - object:nil]; - - self.extensionNames = [NSMutableArray new]; - } - - return self; -} - -- (void)dealloc -{ - [[NSNotificationCenter defaultCenter] removeObserver:self]; -} - -- (void)loadDatabase -{ - if (![self tryToLoadDatabase]) { - // Failing to load the database is catastrophic. - // - // The best we can try to do is to discard the current database - // and behave like a clean install. - - // Try to reset app by deleting all databases. - // - // TODO: Possibly clean up all app files. - // [OWSStorage deleteDatabaseFiles]; - - if (![self tryToLoadDatabase]) { - - // Sleep to give analytics events time to be delivered. - [NSThread sleepForTimeInterval:15.0f]; - - NSAssert(NO, @"Couldn't load database"); - } - } -} - -- (nullable id)dbNotificationObject -{ - return self.database; -} - -- (BOOL)areAsyncRegistrationsComplete -{ - return NO; -} - -- (BOOL)areSyncRegistrationsComplete -{ - return NO; -} - -- (BOOL)areAllRegistrationsComplete -{ - return self.areSyncRegistrationsComplete && self.areAsyncRegistrationsComplete; -} - -- (void)runSyncRegistrations -{ - -} - -- (void)runAsyncRegistrationsWithCompletion:(void (^_Nonnull)(void))completion -{ - -} - -+ (void)registerExtensionsWithMigrationBlock:(OWSStorageMigrationBlock)migrationBlock -{ - __block OWSBackgroundTask *_Nullable backgroundTask = - [OWSBackgroundTask backgroundTaskWithLabelStr:__PRETTY_FUNCTION__]; - - [OWSPrimaryStorage.sharedManager runSyncRegistrations]; - - [OWSPrimaryStorage.sharedManager runAsyncRegistrationsWithCompletion:^{ - [self postRegistrationCompleteNotification]; - - migrationBlock(); - - backgroundTask = nil; - }]; -} - -- (YapDatabaseConnection *)registrationConnection -{ - return self.database.registrationConnection; -} - -// Returns YES IFF all registrations are complete. -+ (void)postRegistrationCompleteNotification -{ - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - [[NSNotificationCenter defaultCenter] postNotificationNameAsync:StorageIsReadyNotification - object:nil - userInfo:nil]; - }); -} - -+ (BOOL)isStorageReady -{ - return OWSPrimaryStorage.sharedManager.areAllRegistrationsComplete; -} - -+ (YapDatabaseOptions *)defaultDatabaseOptions -{ - YapDatabaseOptions *options = [[YapDatabaseOptions alloc] init]; - options.corruptAction = YapDatabaseCorruptAction_Fail; - options.enableMultiProcessSupport = YES; - - // We leave a portion of the header decrypted so that iOS will recognize the file - // as a SQLite database. Otherwise, because the database lives in a shared data container, - // and our usage of sqlite's write-ahead logging retains a lock on the database, the OS - // would kill the app/share extension as soon as it is backgrounded. - options.cipherUnencryptedHeaderLength = kSqliteHeaderLength; - - // If we want to migrate to the new cipher defaults in SQLCipher4+ we'll need to do a one time - // migration. See the `PRAGMA cipher_migrate` documentation for details. - // https://www.zetetic.net/sqlcipher/sqlcipher-api/#cipher_migrate - options.legacyCipherCompatibilityVersion = 3; - - return options; -} - -- (BOOL)tryToLoadDatabase -{ - __weak OWSStorage *weakSelf = self; - YapDatabaseOptions *options = [self.class defaultDatabaseOptions]; - options.cipherKeySpecBlock = ^{ - // NOTE: It's critical that we don't capture a reference to self - // (e.g. by using OWSAssertDebug()) or this database will contain a - // circular reference and will leak. - OWSStorage *strongSelf = weakSelf; - - // Rather than compute this once and capture the value of the key - // in the closure, we prefer to fetch the key from the keychain multiple times - // in order to keep the key out of application memory. - NSData *databaseKeySpec = [strongSelf databaseKeySpec]; - return databaseKeySpec; - }; - - // Sanity checking elsewhere asserts we should only regenerate key specs when - // there is no existing database, so rather than lazily generate in the cipherKeySpecBlock - // we must ensure the keyspec exists before we create the database. - [self ensureDatabaseKeySpecExists]; - - OWSDatabase *database = [[OWSDatabase alloc] initWithPath:[self databaseFilePath] - serializer:nil - deserializer:[[self class] logOnFailureDeserializer] - options:options - delegate:self]; - - if (!database) { - return NO; - } - - _database = database; - - return YES; -} - -/** - * NSCoding sometimes throws exceptions killing our app. We want to log that exception. - **/ -+ (YapDatabaseDeserializer)logOnFailureDeserializer -{ - OWSUnarchiverDelegate *unarchiverDelegate = [OWSUnarchiverDelegate new]; - - return ^id(NSString __unused *collection, NSString __unused *key, NSData *data) { - if (!data || data.length <= 0) { - return [OWSUnknownDBObject new]; - } - - @try { - NSKeyedUnarchiver *unarchiver = [[NSKeyedUnarchiver alloc] initForReadingWithData:data]; - unarchiver.delegate = unarchiverDelegate; - return [unarchiver decodeObjectForKey:@"root"]; - } @catch (NSException *exception) { - // Sync log in case we bail - @throw exception; - } - }; -} - -- (YapDatabaseConnection *)newDatabaseConnection -{ - YapDatabaseConnection *dbConnection = self.database.newConnection; - return dbConnection; -} - -#pragma mark - Extension Registration - -+ (void)incrementVersionOfDatabaseExtension:(NSString *)extensionName -{ - // Don't increment version of a given extension more than once - // per launch. - static NSMutableSet *incrementedViewSet = nil; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - incrementedViewSet = [NSMutableSet new]; - }); - @synchronized(incrementedViewSet) { - if ([incrementedViewSet containsObject:extensionName]) { - return; - } - [incrementedViewSet addObject:extensionName]; - } - - NSUserDefaults *appUserDefaults = [NSUserDefaults appUserDefaults]; - NSMutableDictionary *_Nullable versionMap = - [[appUserDefaults valueForKey:kNSUserDefaults_DatabaseExtensionVersionMap] mutableCopy]; - if (!versionMap) { - versionMap = [NSMutableDictionary new]; - } - NSNumber *_Nullable versionSuffix = versionMap[extensionName]; - versionMap[extensionName] = @(versionSuffix.intValue + 1); - [appUserDefaults setValue:versionMap forKey:kNSUserDefaults_DatabaseExtensionVersionMap]; - [appUserDefaults synchronize]; -} - -- (nullable NSString *)appendSuffixToDatabaseExtensionVersionIfNecessary:(nullable NSString *)versionTag - extensionName:(NSString *)extensionName -{ - NSUserDefaults *appUserDefaults = [NSUserDefaults appUserDefaults]; - NSDictionary *_Nullable versionMap = - [appUserDefaults valueForKey:kNSUserDefaults_DatabaseExtensionVersionMap]; - NSNumber *_Nullable versionSuffix = versionMap[extensionName]; - - if (versionSuffix) { - NSString *result = - [NSString stringWithFormat:@"%@.%@", (versionTag.length < 1 ? @"0" : versionTag), versionSuffix]; - return result; - } - return versionTag; -} - -- (YapDatabaseExtension *)updateExtensionVersion:(YapDatabaseExtension *)extension withName:(NSString *)extensionName -{ - if ([extension isKindOfClass:[YapDatabaseAutoView class]]) { - YapDatabaseAutoView *databaseView = (YapDatabaseAutoView *)extension; - YapDatabaseAutoView *databaseViewCopy = [[YapDatabaseAutoView alloc] - initWithGrouping:databaseView.grouping - sorting:databaseView.sorting - versionTag:[self appendSuffixToDatabaseExtensionVersionIfNecessary:databaseView.versionTag - extensionName:extensionName] - options:databaseView.options]; - return databaseViewCopy; - } else if ([extension isKindOfClass:[YapDatabaseSecondaryIndex class]]) { - YapDatabaseSecondaryIndex *secondaryIndex = (YapDatabaseSecondaryIndex *)extension; - YapDatabaseSecondaryIndex *secondaryIndexCopy = [[YapDatabaseSecondaryIndex alloc] - initWithSetup:secondaryIndex->setup - handler:secondaryIndex->handler - versionTag:[self appendSuffixToDatabaseExtensionVersionIfNecessary:secondaryIndex.versionTag - extensionName:extensionName] - options:secondaryIndex->options]; - return secondaryIndexCopy; - } else if ([extension isKindOfClass:[YapDatabaseFullTextSearch class]]) { - YapDatabaseFullTextSearch *fullTextSearch = (YapDatabaseFullTextSearch *)extension; - - NSString *versionTag = [self appendSuffixToDatabaseExtensionVersionIfNecessary:fullTextSearch.versionTag extensionName:extensionName]; - YapDatabaseFullTextSearch *fullTextSearchCopy = - [[YapDatabaseFullTextSearch alloc] initWithColumnNames:fullTextSearch->columnNames.array - options:fullTextSearch->options - handler:fullTextSearch->handler - ftsVersion:fullTextSearch->ftsVersion - versionTag:versionTag]; - - return fullTextSearchCopy; - } else if ([extension isKindOfClass:[YapDatabaseCrossProcessNotification class]]) { - // versionTag doesn't matter for YapDatabaseCrossProcessNotification. - return extension; - } else { - // This method needs to be able to update the versionTag of all extensions. - // If we start using other extension types, we need to modify this method to - // handle them as well. - - return extension; - } -} - -- (BOOL)registerExtension:(YapDatabaseExtension *)extension withName:(NSString *)extensionName -{ - extension = [self updateExtensionVersion:extension withName:extensionName]; - - [self.extensionNames addObject:extensionName]; - - return [self.database registerExtension:extension withName:extensionName]; -} - -- (void)asyncRegisterExtension:(YapDatabaseExtension *)extension - withName:(NSString *)extensionName -{ - [self asyncRegisterExtension:extension withName:extensionName completion:nil]; -} - -- (void)asyncRegisterExtension:(YapDatabaseExtension *)extension - withName:(NSString *)extensionName - completion:(nullable dispatch_block_t)completion -{ - extension = [self updateExtensionVersion:extension withName:extensionName]; - - [self.extensionNames addObject:extensionName]; - - [self.database asyncRegisterExtension:extension - withName:extensionName - completionBlock:^(BOOL ready) { - dispatch_async(dispatch_get_main_queue(), ^{ - if (completion) { - completion(); - } - }); - }]; -} - -- (nullable id)registeredExtension:(NSString *)extensionName -{ - return [self.database registeredExtension:extensionName]; -} - -- (NSArray *)registeredExtensionNames -{ - return [self.extensionNames copy]; -} - -#pragma mark - Password - -+ (void)deleteDatabaseFiles -{ - [OWSFileSystem deleteFile:[OWSPrimaryStorage legacyDatabaseFilePath]]; - [OWSFileSystem deleteFile:[OWSPrimaryStorage legacyDatabaseFilePath_SHM]]; - [OWSFileSystem deleteFile:[OWSPrimaryStorage legacyDatabaseFilePath_WAL]]; - [OWSFileSystem deleteFile:[OWSPrimaryStorage sharedDataDatabaseFilePath]]; - [OWSFileSystem deleteFile:[OWSPrimaryStorage sharedDataDatabaseFilePath_SHM]]; - [OWSFileSystem deleteFile:[OWSPrimaryStorage sharedDataDatabaseFilePath_WAL]]; -} - -- (void)closeStorageForTests -{ - [self resetStorage]; - - [[NSNotificationCenter defaultCenter] removeObserver:self]; -} - -- (void)resetStorage -{ - self.database = nil; - - [OWSStorage deleteDatabaseFiles]; -} - -+ (void)resetAllStorage -{ - [[NSNotificationCenter defaultCenter] postNotificationName:OWSResetStorageNotification object:nil]; - - // This might be redundant but in the spirit of thoroughness... - [self deleteDatabaseFiles]; - - [self deleteDBKeys]; - - if (CurrentAppContext().isMainApp) { - [TSAttachmentStream deleteAttachments]; - } - - // TODO: Delete Profiles on Disk? -} - -#pragma mark - Password - -- (NSString *)databaseFilePath -{ - return @""; -} - -- (NSString *)databaseFilePath_SHM -{ - return @""; -} - -- (NSString *)databaseFilePath_WAL -{ - return @""; -} - -#pragma mark - Keychain - -+ (BOOL)isDatabasePasswordAccessible -{ - NSError *error; - NSData *cipherKeySpec = [self tryToLoadDatabaseCipherKeySpec:&error]; - - if (cipherKeySpec && !error) { - return YES; - } - - return NO; -} - -+ (nullable NSData *)tryToLoadDatabaseLegacyPassphrase:(NSError **)errorHandle -{ - return [self tryToLoadKeyChainValue:keychainDBLegacyPassphrase errorHandle:errorHandle]; -} - -+ (nullable NSData *)tryToLoadDatabaseCipherKeySpec:(NSError **)errorHandle -{ - NSData *_Nullable data = [self tryToLoadKeyChainValue:keychainDBCipherKeySpec errorHandle:errorHandle]; - - return data; -} - -+ (void)storeDatabaseCipherKeySpec:(NSData *)cipherKeySpecData -{ - [self storeKeyChainValue:cipherKeySpecData keychainKey:keychainDBCipherKeySpec]; -} - -+ (void)removeLegacyPassphrase -{ - NSError *_Nullable error; - BOOL result = [CurrentAppContext().keychainStorage removeWithService:keychainService - key:keychainDBLegacyPassphrase - error:&error]; -} - -- (void)ensureDatabaseKeySpecExists -{ - NSError *error; - NSData *_Nullable keySpec = [[self class] tryToLoadDatabaseCipherKeySpec:&error]; - - if (error || (keySpec.length != kSQLCipherKeySpecLength)) { - // Because we use kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, - // the keychain will be inaccessible after device restart until - // device is unlocked for the first time. If the app receives - // a push notification, we won't be able to access the keychain to - // process that notification, so we should just terminate by throwing - // an uncaught exception. - NSString *errorDescription = [NSString - stringWithFormat:@"CipherKeySpec inaccessible. New install or no unlock since device restart? Error: %@", - error]; - if (CurrentAppContext().isMainApp) { - UIApplicationState applicationState = CurrentAppContext().reportedApplicationState; - errorDescription = [errorDescription - stringByAppendingFormat:@", ApplicationState: %@", NSStringForUIApplicationState(applicationState)]; - } - - if (CurrentAppContext().isMainApp) { - if (CurrentAppContext().isInBackground) { - // Rather than crash here, we should have already detected the situation earlier - // and exited gracefully (in the app delegate) using isDatabasePasswordAccessible. - // This is a last ditch effort to avoid blowing away the user's database. - [self raiseKeySpecInaccessibleExceptionWithErrorDescription:errorDescription]; - } - } else { - [self raiseKeySpecInaccessibleExceptionWithErrorDescription:@"CipherKeySpec inaccessible; not main app."]; - } - - // At this point, either this is a new install so there's no existing password to retrieve - // or the keychain has become corrupt. Either way, we want to get back to a - // "known good state" and behave like a new install. - BOOL doesDBExist = [NSFileManager.defaultManager fileExistsAtPath:[self databaseFilePath]]; - - if (!CurrentAppContext().isRunningTests) { - // Try to reset app by deleting database. - [OWSStorage resetAllStorage]; - } - - keySpec = [Randomness generateRandomBytes:(int)kSQLCipherKeySpecLength]; - [[self class] storeDatabaseCipherKeySpec:keySpec]; - } -} - -- (NSData *)databaseKeySpec -{ - NSError *error; - NSData *_Nullable keySpec = [[self class] tryToLoadDatabaseCipherKeySpec:&error]; - - if (error) { - [self raiseKeySpecInaccessibleExceptionWithErrorDescription:@"CipherKeySpec inaccessible"]; - } - - if (keySpec.length != kSQLCipherKeySpecLength) { - [self raiseKeySpecInaccessibleExceptionWithErrorDescription:@"CipherKeySpec invalid"]; - } - - return keySpec; -} - -- (void)raiseKeySpecInaccessibleExceptionWithErrorDescription:(NSString *)errorDescription -{ - // Sleep to give analytics events time to be delivered. - [NSThread sleepForTimeInterval:5.0f]; - - // Presumably this happened in response to a push notification. It's possible that the keychain is corrupted - // but it could also just be that the user hasn't yet unlocked their device since our password is - // kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly -} - -+ (void)deleteDBKeys -{ - NSError *_Nullable error; - BOOL result = [CurrentAppContext().keychainStorage removeWithService:keychainService - key:keychainDBLegacyPassphrase - error:&error]; - result = [CurrentAppContext().keychainStorage removeWithService:keychainService - key:keychainDBCipherKeySpec - error:&error]; -} - -- (unsigned long long)databaseFileSize -{ - return [OWSFileSystem fileSizeOfPath:self.databaseFilePath].unsignedLongLongValue; -} - -- (unsigned long long)databaseWALFileSize -{ - return [OWSFileSystem fileSizeOfPath:self.databaseFilePath_WAL].unsignedLongLongValue; -} - -- (unsigned long long)databaseSHMFileSize -{ - return [OWSFileSystem fileSizeOfPath:self.databaseFilePath_SHM].unsignedLongLongValue; -} - -+ (nullable NSData *)tryToLoadKeyChainValue:(NSString *)keychainKey errorHandle:(NSError **)errorHandle -{ - NSData *_Nullable data = - [CurrentAppContext().keychainStorage dataForService:keychainService key:keychainKey error:errorHandle]; - return data; -} - -+ (void)storeKeyChainValue:(NSData *)data keychainKey:(NSString *)keychainKey -{ - NSError *error; - BOOL success = - [CurrentAppContext().keychainStorage setWithData:data service:keychainService key:keychainKey error:&error]; - if (!success || error) { - - // Sleep to give analytics events time to be delivered. - [NSThread sleepForTimeInterval:15.0f]; - - } -} - -- (void)logFileSizes -{ - -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Database/Storage+OpenGroups.swift b/SessionMessagingKit/Database/Storage+OpenGroups.swift deleted file mode 100644 index ddcbc5268..000000000 --- a/SessionMessagingKit/Database/Storage+OpenGroups.swift +++ /dev/null @@ -1,209 +0,0 @@ - -extension Storage { - - // MARK: - Open Groups - - private static let openGroupCollection = "SNOpenGroupCollection" - - @objc public func getAllV2OpenGroups() -> [String:OpenGroupV2] { - var result = [String:OpenGroupV2]() - Storage.read { transaction in - transaction.enumerateKeysAndObjects(inCollection: Storage.openGroupCollection) { threadID, object, _ in - guard let openGroup = object as? OpenGroupV2 else { return } - result[threadID] = openGroup - } - } - return result - } - - @objc(getV2OpenGroupForThreadID:) - public func getV2OpenGroup(for threadID: String) -> OpenGroupV2? { - var result: OpenGroupV2? - Storage.read { transaction in - result = transaction.object(forKey: threadID, inCollection: Storage.openGroupCollection) as? OpenGroupV2 - } - return result - } - - public func v2GetThreadID(for v2OpenGroupID: String) -> String? { - var result: String? - Storage.read { transaction in - transaction.enumerateKeysAndObjects(inCollection: Storage.openGroupCollection, using: { threadID, object, stop in - guard let openGroup = object as? OpenGroupV2, openGroup.id == v2OpenGroupID else { return } - result = threadID - stop.pointee = true - }) - } - return result - } - - @objc(setV2OpenGroup:forThreadWithID:using:) - public func setV2OpenGroup(_ openGroup: OpenGroupV2, for threadID: String, using transaction: Any) { - (transaction as! YapDatabaseReadWriteTransaction).setObject(openGroup, forKey: threadID, inCollection: Storage.openGroupCollection) - } - - @objc(removeV2OpenGroupForThreadID:using:) - public func removeV2OpenGroup(for threadID: String, using transaction: Any) { - (transaction as! YapDatabaseReadWriteTransaction).removeObject(forKey: threadID, inCollection: Storage.openGroupCollection) - } - - - - // MARK: - Authorization - - private static let authTokenCollection = "SNAuthTokenCollection" - - public func getAuthToken(for room: String, on server: String) -> String? { - let collection = Storage.authTokenCollection - let key = "\(server).\(room)" - var result: String? = nil - Storage.read { transaction in - result = transaction.object(forKey: key, inCollection: collection) as? String - } - return result - } - - public func setAuthToken(for room: String, on server: String, to newValue: String, using transaction: Any) { - let collection = Storage.authTokenCollection - let key = "\(server).\(room)" - (transaction as! YapDatabaseReadWriteTransaction).setObject(newValue, forKey: key, inCollection: collection) - } - - public func removeAuthToken(for room: String, on server: String, using transaction: Any) { - let collection = Storage.authTokenCollection - let key = "\(server).\(room)" - (transaction as! YapDatabaseReadWriteTransaction).removeObject(forKey: key, inCollection: collection) - } - - - - // MARK: - Public Keys - - private static let openGroupPublicKeyCollection = "LokiOpenGroupPublicKeyCollection" - - public func getOpenGroupPublicKey(for server: String) -> String? { - var result: String? = nil - Storage.read { transaction in - result = transaction.object(forKey: server, inCollection: Storage.openGroupPublicKeyCollection) as? String - } - return result - } - - public func setOpenGroupPublicKey(for server: String, to newValue: String, using transaction: Any) { - (transaction as! YapDatabaseReadWriteTransaction).setObject(newValue, forKey: server, inCollection: Storage.openGroupPublicKeyCollection) - } - - public func removeOpenGroupPublicKey(for server: String, using transaction: Any) { - (transaction as! YapDatabaseReadWriteTransaction).removeObject(forKey: server, inCollection: Storage.openGroupPublicKeyCollection) - } - - - - // MARK: - Last Message Server ID - - public static let lastMessageServerIDCollection = "SNLastMessageServerIDCollection" - - public func getLastMessageServerID(for room: String, on server: String) -> Int64? { - let collection = Storage.lastMessageServerIDCollection - let key = "\(server).\(room)" - var result: Int64? = nil - Storage.read { transaction in - result = transaction.object(forKey: key, inCollection: collection) as? Int64 - } - return result - } - - public func setLastMessageServerID(for room: String, on server: String, to newValue: Int64, using transaction: Any) { - let collection = Storage.lastMessageServerIDCollection - let key = "\(server).\(room)" - (transaction as! YapDatabaseReadWriteTransaction).setObject(newValue, forKey: key, inCollection: collection) - } - - public func removeLastMessageServerID(for room: String, on server: String, using transaction: Any) { - let collection = Storage.lastMessageServerIDCollection - let key = "\(server).\(room)" - (transaction as! YapDatabaseReadWriteTransaction).removeObject(forKey: key, inCollection: collection) - } - - - - // MARK: - Last Deletion Server ID - - public static let lastDeletionServerIDCollection = "SNLastDeletionServerIDCollection" - - public func getLastDeletionServerID(for room: String, on server: String) -> Int64? { - let collection = Storage.lastDeletionServerIDCollection - let key = "\(server).\(room)" - var result: Int64? = nil - Storage.read { transaction in - result = transaction.object(forKey: key, inCollection: collection) as? Int64 - } - return result - } - - public func setLastDeletionServerID(for room: String, on server: String, to newValue: Int64, using transaction: Any) { - let collection = Storage.lastDeletionServerIDCollection - let key = "\(server).\(room)" - (transaction as! YapDatabaseReadWriteTransaction).setObject(newValue, forKey: key, inCollection: collection) - } - - public func removeLastDeletionServerID(for room: String, on server: String, using transaction: Any) { - let collection = Storage.lastDeletionServerIDCollection - let key = "\(server).\(room)" - (transaction as! YapDatabaseReadWriteTransaction).removeObject(forKey: key, inCollection: collection) - } - - // MARK: - OpenGroupServerIdToUniqueIdLookup - - public static let openGroupServerIdToUniqueIdLookupCollection = "SNOpenGroupServerIdToUniqueIdLookup" - - public func getOpenGroupServerIdLookup(_ serverId: UInt64, in room: String, on server: String, using transaction: YapDatabaseReadTransaction) -> OpenGroupServerIdLookup? { - let key: String = OpenGroupServerIdLookup.id(serverId: serverId, in: room, on: server) - return transaction.object(forKey: key, inCollection: Storage.openGroupServerIdToUniqueIdLookupCollection) as? OpenGroupServerIdLookup - } - - public func addOpenGroupServerIdLookup(_ serverId: UInt64?, tsMessageId: String?, in room: String, on server: String, using transaction: YapDatabaseReadWriteTransaction) { - guard let serverId: UInt64 = serverId, let tsMessageId: String = tsMessageId else { return } - - let lookup: OpenGroupServerIdLookup = OpenGroupServerIdLookup(server: server, room: room, serverId: serverId, tsMessageId: tsMessageId) - addOpenGroupServerIdLookup(lookup, using: transaction) - } - - public func addOpenGroupServerIdLookup(_ lookup: OpenGroupServerIdLookup, using transaction: YapDatabaseReadWriteTransaction) { - transaction.setObject(lookup, forKey: lookup.id, inCollection: Storage.openGroupServerIdToUniqueIdLookupCollection) - } - - public func removeOpenGroupServerIdLookup(_ serverId: UInt64, in room: String, on server: String, using transaction: YapDatabaseReadWriteTransaction) { - let key: String = OpenGroupServerIdLookup.id(serverId: serverId, in: room, on: server) - transaction.removeObject(forKey: key, inCollection: Storage.openGroupServerIdToUniqueIdLookupCollection) - } - - // MARK: - Metadata - - private static let openGroupUserCountCollection = "SNOpenGroupUserCountCollection" - private static let openGroupImageCollection = "SNOpenGroupImageCollection" - - public func getUserCount(forV2OpenGroupWithID openGroupID: String) -> UInt64? { - var result: UInt64? - Storage.read { transaction in - result = transaction.object(forKey: openGroupID, inCollection: Storage.openGroupUserCountCollection) as? UInt64 - } - return result - } - - public func setUserCount(to newValue: UInt64, forV2OpenGroupWithID openGroupID: String, using transaction: Any) { - (transaction as! YapDatabaseReadWriteTransaction).setObject(newValue, forKey: openGroupID, inCollection: Storage.openGroupUserCountCollection) - } - - public func getOpenGroupImage(for room: String, on server: String) -> Data? { - var result: Data? - Storage.read { transaction in - result = transaction.object(forKey: "\(server).\(room)", inCollection: Storage.openGroupImageCollection) as? Data - } - return result - } - - public func setOpenGroupImage(to data: Data, for room: String, on server: String, using transaction: Any) { - (transaction as! YapDatabaseReadWriteTransaction).setObject(data, forKey: "\(server).\(room)", inCollection: Storage.openGroupImageCollection) - } -} diff --git a/SessionMessagingKit/Database/Storage+Shared.swift b/SessionMessagingKit/Database/Storage+Shared.swift deleted file mode 100644 index 394f3b98c..000000000 --- a/SessionMessagingKit/Database/Storage+Shared.swift +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import PromiseKit -import Sodium - -extension Storage { - - @discardableResult - public func write(with block: @escaping (Any) -> Void) -> Promise { - Storage.write(with: { block($0) }) - } - - @discardableResult - public func write(with block: @escaping (Any) -> Void, completion: @escaping () -> Void) -> Promise { - Storage.write(with: { block($0) }, completion: completion) - } - - public func writeSync(with block: @escaping (Any) -> Void) { - Storage.writeSync { block($0) } - } -// @objc public func getUser() -> Legacy.Contact? { -// return getUser(using: nil) -// } -// -// public func getUser(using transaction: YapDatabaseReadTransaction?) -> Legacy.Contact? { -// let userPublicKey = getUserHexEncodedPublicKey() -// var result: Legacy.Contact? -// -// if let transaction = transaction { -// result = Storage.shared.getContact(with: userPublicKey, using: transaction) -// } -// else { -// Storage.read { transaction in -// result = Storage.shared.getContact(with: userPublicKey, using: transaction) -// } -// } -// return result -// } -} diff --git a/SessionMessagingKit/Database/TSDatabaseSecondaryIndexes.h b/SessionMessagingKit/Database/TSDatabaseSecondaryIndexes.h deleted file mode 100644 index f2e377654..000000000 --- a/SessionMessagingKit/Database/TSDatabaseSecondaryIndexes.h +++ /dev/null @@ -1,22 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface TSDatabaseSecondaryIndexes : NSObject - -+ (NSString *)registerTimeStampIndexExtensionName; - -+ (YapDatabaseSecondaryIndex *)registerTimeStampIndex; - -+ (void)enumerateMessagesWithTimestamp:(uint64_t)timestamp - withBlock:(void (^)(NSString *collection, NSString *key, BOOL *stop))block - usingTransaction:(YapDatabaseReadTransaction *)transaction; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Database/TSDatabaseSecondaryIndexes.m b/SessionMessagingKit/Database/TSDatabaseSecondaryIndexes.m deleted file mode 100644 index 893a8e9ea..000000000 --- a/SessionMessagingKit/Database/TSDatabaseSecondaryIndexes.m +++ /dev/null @@ -1,53 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "TSDatabaseSecondaryIndexes.h" -#import "OWSStorage.h" - -NS_ASSUME_NONNULL_BEGIN - -#define TSTimeStampSQLiteIndex @"messagesTimeStamp" - -@implementation TSDatabaseSecondaryIndexes - -+ (NSString *)registerTimeStampIndexExtensionName -{ - return @"idx"; -} - -+ (YapDatabaseSecondaryIndex *)registerTimeStampIndex { - YapDatabaseSecondaryIndexSetup *setup = [[YapDatabaseSecondaryIndexSetup alloc] init]; - [setup addColumn:TSTimeStampSQLiteIndex withType:YapDatabaseSecondaryIndexTypeReal]; - - YapDatabaseSecondaryIndexWithObjectBlock block = - ^(YapDatabaseReadTransaction *transaction, NSMutableDictionary *dict, NSString *collection, NSString *key, id object) { - - if ([object isKindOfClass:[TSInteraction class]]) { - TSInteraction *interaction = (TSInteraction *)object; - - [dict setObject:@(interaction.timestamp) forKey:TSTimeStampSQLiteIndex]; - } - }; - - YapDatabaseSecondaryIndexHandler *handler = [YapDatabaseSecondaryIndexHandler withObjectBlock:block]; - - YapDatabaseSecondaryIndex *secondaryIndex = - [[YapDatabaseSecondaryIndex alloc] initWithSetup:setup handler:handler versionTag:nil]; - - return secondaryIndex; -} - - -+ (void)enumerateMessagesWithTimestamp:(uint64_t)timestamp - withBlock:(void (^)(NSString *collection, NSString *key, BOOL *stop))block - usingTransaction:(YapDatabaseReadTransaction *)transaction -{ - NSString *formattedString = [NSString stringWithFormat:@"WHERE %@ = %lld", TSTimeStampSQLiteIndex, timestamp]; - YapDatabaseQuery *query = [YapDatabaseQuery queryWithFormat:formattedString]; - [[transaction ext:[self registerTimeStampIndexExtensionName]] enumerateKeysMatchingQuery:query usingBlock:block]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Database/TSDatabaseView.h b/SessionMessagingKit/Database/TSDatabaseView.h deleted file mode 100644 index f6244dba2..000000000 --- a/SessionMessagingKit/Database/TSDatabaseView.h +++ /dev/null @@ -1,69 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -extern NSString *const TSInboxGroup; -extern NSString *const TSMessageRequestGroup; -extern NSString *const TSArchiveGroup; -extern NSString *const TSShareExtensionGroup; -extern NSString *const TSUnreadIncomingMessagesGroup; -extern NSString *const TSSecondaryDevicesGroup; - -extern NSString *const TSThreadDatabaseViewExtensionName; -extern NSString *const TSThreadShareExtensionDatabaseViewExtensionName; - -extern NSString *const TSMessageDatabaseViewExtensionName; -extern NSString *const TSMessageDatabaseViewExtensionName_Legacy; - -extern NSString *const TSUnreadDatabaseViewExtensionName; -extern NSString *const TSUnseenDatabaseViewExtensionName; -extern NSString *const TSUnreadMentionDatabaseViewExtensionName; -extern NSString *const TSThreadOutgoingMessageDatabaseViewExtensionName; -extern NSString *const TSThreadSpecialMessagesDatabaseViewExtensionName; - -extern NSString *const TSSecondaryDevicesDatabaseViewExtensionName; - -extern NSString *const TSLazyRestoreAttachmentsGroup; -extern NSString *const TSLazyRestoreAttachmentsDatabaseViewExtensionName; - -@interface TSDatabaseView : NSObject - -- (instancetype)init NS_UNAVAILABLE; - -#pragma mark - Views - -// Returns the "unseen" database view if it is ready; -// otherwise it returns the "unread" database view. -+ (id)unseenDatabaseViewExtension:(YapDatabaseReadTransaction *)transaction; - -+ (id)threadOutgoingMessageDatabaseView:(YapDatabaseReadTransaction *)transaction; - -+ (id)threadSpecialMessagesDatabaseView:(YapDatabaseReadTransaction *)transaction; - -#pragma mark - Registration - -+ (void)registerCrossProcessNotifier:(OWSStorage *)storage; - -// This method must be called _AFTER_ asyncRegisterThreadInteractionsDatabaseView. -+ (void)asyncRegisterThreadDatabaseView:(OWSStorage *)storage; - -+ (void)asyncRegisterThreadInteractionsDatabaseView:(OWSStorage *)storage; -+ (void)asyncRegisterLegacyThreadInteractionsDatabaseView:(OWSStorage *)storage; - -+ (void)asyncRegisterThreadOutgoingMessagesDatabaseView:(OWSStorage *)storage; - -// Should be used for "mention indicator". -// -// Instances of OWSReadTracking for wasRead is NO and isUserMentioned is YES. -+ (void)asyncRegisterUnreadMentionDatabaseView:(OWSStorage *)storage; - -+ (void)asyncRegisterLazyRestoreAttachmentsDatabaseView:(OWSStorage *)storage; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Database/TSDatabaseView.m b/SessionMessagingKit/Database/TSDatabaseView.m deleted file mode 100644 index 9051d0a18..000000000 --- a/SessionMessagingKit/Database/TSDatabaseView.m +++ /dev/null @@ -1,441 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "TSDatabaseView.h" -#import -#import -#import -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -NSString *const TSInboxGroup = @"TSInboxGroup"; -NSString *const TSMessageRequestGroup = @"TSMessageRequestGroup"; -NSString *const TSArchiveGroup = @"TSArchiveGroup"; -NSString *const TSShareExtensionGroup = @"TSShareExtensionGroup"; - -NSString *const TSUnreadIncomingMessagesGroup = @"TSUnreadIncomingMessagesGroup"; -NSString *const TSSecondaryDevicesGroup = @"TSSecondaryDevicesGroup"; - -// YAPDB BUG: when changing from non-persistent to persistent view, we had to rename TSThreadDatabaseViewExtensionName -// -> TSThreadDatabaseViewExtensionName2 to work around https://github.com/yapstudios/YapDatabase/issues/324 -NSString *const TSThreadDatabaseViewExtensionName = @"TSThreadDatabaseViewExtensionName2"; - -NSString *const TSThreadShareExtensionDatabaseViewExtensionName = @"TSThreadShareExtensionDatabaseViewExtensionName"; - -// We sort interactions by a monotonically increasing counter. -// -// Previously we sorted the interactions database by local timestamp, which was problematic if the local clock changed. -// We need to maintain the legacy extension for purposes of migration. -// -// The "Legacy" sorting extension name constant has the same value as always, so that it won't need to be rebuilt, while -// the "Modern" sorting extension name constant has the same symbol name that we've always used for sorting -// interactions, so that the callsites won't need to change. -NSString *const TSMessageDatabaseViewExtensionName = @"TSMessageDatabaseViewExtensionName_Monotonic"; -NSString *const TSMessageDatabaseViewExtensionName_Legacy = @"TSMessageDatabaseViewExtensionName"; - -NSString *const TSThreadOutgoingMessageDatabaseViewExtensionName = @"TSThreadOutgoingMessageDatabaseViewExtensionName"; -NSString *const TSUnreadDatabaseViewExtensionName = @"TSUnreadDatabaseViewExtensionName"; -NSString *const TSUnseenDatabaseViewExtensionName = @"TSUnseenDatabaseViewExtensionName"; -NSString *const TSUnreadMentionDatabaseViewExtensionName = @"TSUnreadMentionDatabaseViewExtensionName"; -NSString *const TSThreadSpecialMessagesDatabaseViewExtensionName = @"TSThreadSpecialMessagesDatabaseViewExtensionName"; -NSString *const TSSecondaryDevicesDatabaseViewExtensionName = @"TSSecondaryDevicesDatabaseViewExtensionName"; -NSString *const TSLazyRestoreAttachmentsDatabaseViewExtensionName - = @"TSLazyRestoreAttachmentsDatabaseViewExtensionName"; -NSString *const TSLazyRestoreAttachmentsGroup = @"TSLazyRestoreAttachmentsGroup"; - -@interface OWSStorage (TSDatabaseView) - -- (BOOL)registerExtension:(YapDatabaseExtension *)extension withName:(NSString *)extensionName; - -@end - -#pragma mark - - -@implementation TSDatabaseView - -+ (void)registerCrossProcessNotifier:(OWSStorage *)storage -{ - // I don't think the identifier and name of this extension matter for our purposes, - // so long as they don't conflict with any other extension names. - YapDatabaseExtension *extension = - [[YapDatabaseCrossProcessNotification alloc] initWithIdentifier:@"SignalCrossProcessNotifier"]; - [storage registerExtension:extension withName:@"SignalCrossProcessNotifier"]; -} - -+ (void)registerMessageDatabaseViewWithName:(NSString *)viewName - viewGrouping:(YapDatabaseViewGrouping *)viewGrouping - version:(NSString *)version - storage:(OWSStorage *)storage -{ - YapDatabaseView *existingView = [storage registeredExtension:viewName]; - if (existingView) { - return; - } - - YapDatabaseViewSorting *viewSorting = [self messagesSorting]; - - YapDatabaseViewOptions *options = [[YapDatabaseViewOptions alloc] init]; - options.isPersistent = YES; - options.allowedCollections = - [[YapWhitelistBlacklist alloc] initWithWhitelist:[NSSet setWithObject:[TSInteraction collection]]]; - - YapDatabaseView *view = [[YapDatabaseAutoView alloc] initWithGrouping:viewGrouping - sorting:viewSorting - versionTag:version - options:options]; - [storage asyncRegisterExtension:view withName:viewName]; -} - -+ (void)asyncRegisterUnreadMentionDatabaseView:(OWSStorage *)storage -{ - YapDatabaseViewGrouping *viewGrouping = [YapDatabaseViewGrouping withObjectBlock:^NSString *( - YapDatabaseReadTransaction *transaction, NSString *collection, NSString *key, id object) { - if ([object isKindOfClass:[TSIncomingMessage class]]) { - TSIncomingMessage *message = (TSIncomingMessage *)object; - if (!message.wasRead && message.isUserMentioned) { - return message.uniqueThreadId; - } - } - return nil; - }]; - - [self registerMessageDatabaseViewWithName:TSUnreadMentionDatabaseViewExtensionName - viewGrouping:viewGrouping - version:@"2" - storage:storage]; -} - -+ (void)asyncRegisterLegacyThreadInteractionsDatabaseView:(OWSStorage *)storage -{ - YapDatabaseView *existingView = [storage registeredExtension:TSMessageDatabaseViewExtensionName_Legacy]; - if (existingView) { - return; - } - - YapDatabaseViewGrouping *viewGrouping = [YapDatabaseViewGrouping withObjectBlock:^NSString *( - YapDatabaseReadTransaction *transaction, NSString *collection, NSString *key, id object) { - if (![object isKindOfClass:[TSInteraction class]]) { - return nil; - } - TSInteraction *interaction = (TSInteraction *)object; - - return interaction.uniqueThreadId; - }]; - - YapDatabaseViewSorting *viewSorting = - [YapDatabaseViewSorting withObjectBlock:^NSComparisonResult(YapDatabaseReadTransaction *transaction, - NSString *group, - NSString *collection1, - NSString *key1, - id object1, - NSString *collection2, - NSString *key2, - id object2) { - if (![object1 isKindOfClass:[TSInteraction class]]) { - return NSOrderedSame; - } - if (![object2 isKindOfClass:[TSInteraction class]]) { - return NSOrderedSame; - } - TSInteraction *interaction1 = (TSInteraction *)object1; - TSInteraction *interaction2 = (TSInteraction *)object2; - - // Legit usage of timestampForLegacySorting since we're registering the - // legacy extension - uint64_t timestamp1 = interaction1.timestampForLegacySorting; - uint64_t timestamp2 = interaction2.timestampForLegacySorting; - - if (timestamp1 > timestamp2) { - return NSOrderedDescending; - } else if (timestamp1 < timestamp2) { - return NSOrderedAscending; - } else { - return NSOrderedSame; - } - }]; - - YapDatabaseViewOptions *options = [YapDatabaseViewOptions new]; - options.isPersistent = YES; - options.allowedCollections = - [[YapWhitelistBlacklist alloc] initWithWhitelist:[NSSet setWithObject:[TSInteraction collection]]]; - - YapDatabaseView *view = - [[YapDatabaseAutoView alloc] initWithGrouping:viewGrouping sorting:viewSorting versionTag:@"1" options:options]; - - [storage asyncRegisterExtension:view withName:TSMessageDatabaseViewExtensionName_Legacy]; -} - -+ (void)asyncRegisterThreadInteractionsDatabaseView:(OWSStorage *)storage -{ - YapDatabaseViewGrouping *viewGrouping = [YapDatabaseViewGrouping withObjectBlock:^NSString *( - YapDatabaseReadTransaction *transaction, NSString *collection, NSString *key, id object) { - if (![object isKindOfClass:[TSInteraction class]]) { - return nil; - } - TSInteraction *interaction = (TSInteraction *)object; - - return interaction.uniqueThreadId; - }]; - - [self registerMessageDatabaseViewWithName:TSMessageDatabaseViewExtensionName - viewGrouping:viewGrouping - version:@"2" - storage:storage]; -} - -+ (void)asyncRegisterThreadOutgoingMessagesDatabaseView:(OWSStorage *)storage -{ - YapDatabaseViewGrouping *viewGrouping = [YapDatabaseViewGrouping withObjectBlock:^NSString *( - YapDatabaseReadTransaction *transaction, NSString *collection, NSString *key, id object) { - if ([object isKindOfClass:[TSOutgoingMessage class]]) { - return ((TSOutgoingMessage *)object).uniqueThreadId; - } - return nil; - }]; - - [self registerMessageDatabaseViewWithName:TSThreadOutgoingMessageDatabaseViewExtensionName - viewGrouping:viewGrouping - version:@"3" - storage:storage]; -} - -+ (void)asyncRegisterThreadDatabaseView:(OWSStorage *)storage -{ - YapDatabaseView *threadView = [storage registeredExtension:TSThreadDatabaseViewExtensionName]; - if (threadView) { - return; - } - - YapDatabaseViewGrouping *viewGrouping = [YapDatabaseViewGrouping withObjectBlock:^NSString *( - YapDatabaseReadTransaction *transaction, NSString *collection, NSString *key, id object) { - if (![object isKindOfClass:[TSThread class]]) { - return nil; - } - TSThread *thread = (TSThread *)object; - - if ([thread isMessageRequestUsingTransaction:transaction]) { - // Don't show blocked threads at all - if (thread.isBlocked) { - return nil; - } - - return TSMessageRequestGroup; - } - else if (thread.shouldBeVisible) { - // Do nothing; we never hide threads that have ever had a message. - } else { - YapDatabaseViewTransaction *viewTransaction = [transaction ext:TSMessageDatabaseViewExtensionName]; - NSUInteger threadMessageCount = [viewTransaction numberOfItemsInGroup:thread.uniqueId]; - if (threadMessageCount < 1) { - return nil; - } - } - - return TSInboxGroup; - }]; - - YapDatabaseViewSorting *viewSorting = [self threadSorting]; - - YapDatabaseViewOptions *options = [[YapDatabaseViewOptions alloc] init]; - options.isPersistent = YES; - options.allowedCollections = - [[YapWhitelistBlacklist alloc] initWithWhitelist:[NSSet setWithObject:[TSThread collection]]]; - - YapDatabaseView *databaseView = - [[YapDatabaseAutoView alloc] initWithGrouping:viewGrouping sorting:viewSorting versionTag:@"4" options:options]; - - [storage asyncRegisterExtension:databaseView withName:TSThreadDatabaseViewExtensionName]; - - YapDatabaseView *shareExtensionThreadView = [storage registeredExtension:TSThreadShareExtensionDatabaseViewExtensionName]; - if (shareExtensionThreadView) { - return; - } - - YapDatabaseViewGrouping *shareExtensionViewGrouping = [YapDatabaseViewGrouping withObjectBlock:^NSString *( - YapDatabaseReadTransaction *transaction, NSString *collection, NSString *key, id object) { - if (![object isKindOfClass:[TSThread class]]) { - return nil; - } - TSThread *thread = (TSThread *)object; - - if ([thread isMessageRequestUsingTransaction:transaction]) { - return nil; - } - else { - YapDatabaseViewTransaction *viewTransaction = [transaction ext:TSMessageDatabaseViewExtensionName]; - NSUInteger threadMessageCount = [viewTransaction numberOfItemsInGroup:thread.uniqueId]; - if (threadMessageCount < 1) { - return nil; - } - - if (!thread.isGroupThread) { - TSContactThread *contactThead = (TSContactThread *)thread; - SMKContact *contact = [SMKContact fetchOrCreateWithId:[contactThead contactSessionID]]; - - if (contact == nil || !contact.didApproveMe) { - return nil; - } - } - } - - return TSShareExtensionGroup; - }]; - - YapDatabaseViewSorting *shareExtensionViewSorting = [self threadSorting]; - - YapDatabaseViewOptions *shareExtensionOptions = [[YapDatabaseViewOptions alloc] init]; - shareExtensionOptions.isPersistent = YES; - shareExtensionOptions.allowedCollections = - [[YapWhitelistBlacklist alloc] initWithWhitelist:[NSSet setWithObject:[TSThread collection]]]; - - YapDatabaseView *shareExtensionDatabaseView = - [[YapDatabaseAutoView alloc] initWithGrouping:shareExtensionViewGrouping sorting:shareExtensionViewSorting versionTag:@"1" options:shareExtensionOptions]; - - [storage asyncRegisterExtension:shareExtensionDatabaseView withName:TSThreadShareExtensionDatabaseViewExtensionName]; -} - -+ (YapDatabaseViewSorting *)threadSorting { - return [YapDatabaseViewSorting withObjectBlock:^NSComparisonResult(YapDatabaseReadTransaction *transaction, - NSString *group, - NSString *collection1, - NSString *key1, - id object1, - NSString *collection2, - NSString *key2, - id object2) { - if (![object1 isKindOfClass:[TSThread class]]) { - return NSOrderedSame; - } - if (![object2 isKindOfClass:[TSThread class]]) { - return NSOrderedSame; - } - TSThread *thread1 = (TSThread *)object1; - TSThread *thread2 = (TSThread *)object2; - if ([group isEqualToString:TSArchiveGroup] || [group isEqualToString:TSInboxGroup]) { - if (thread1.isPinned != thread2.isPinned) { - if (thread1.isPinned) { return NSOrderedDescending; } - if (thread2.isPinned) { return NSOrderedAscending; } - } - TSInteraction *_Nullable lastInteractionForInbox1 = - [thread1 lastInteractionForInboxWithTransaction:transaction]; - NSDate *lastInteractionForInboxDate1 = lastInteractionForInbox1 ? lastInteractionForInbox1.receivedAtDate : thread1.creationDate; - - TSInteraction *_Nullable lastInteractionForInbox2 = - [thread2 lastInteractionForInboxWithTransaction:transaction]; - NSDate *lastInteractionForInboxDate2 = lastInteractionForInbox2 ? lastInteractionForInbox2.receivedAtDate : thread2.creationDate; - - - NSDate *date1 = thread1.lastInteractionDate ?: lastInteractionForInboxDate1 ?: thread1.creationDate; - NSDate *date2 = thread2.lastInteractionDate ?: lastInteractionForInboxDate2 ?: thread2.creationDate; - return [date1 compare:date2]; - } - - return NSOrderedSame; - }]; -} - -+ (YapDatabaseViewSorting *)messagesSorting { - return [YapDatabaseViewSorting withObjectBlock:^NSComparisonResult(YapDatabaseReadTransaction *transaction, - NSString *group, - NSString *collection1, - NSString *key1, - id object1, - NSString *collection2, - NSString *key2, - id object2) { - if (![object1 isKindOfClass:[TSInteraction class]]) { - return NSOrderedSame; - } - if (![object2 isKindOfClass:[TSInteraction class]]) { - return NSOrderedSame; - } - TSInteraction *message1 = (TSInteraction *)object1; - TSInteraction *message2 = (TSInteraction *)object2; - - return [message1 compareForSorting:message2]; - }]; -} - -+ (void)asyncRegisterLazyRestoreAttachmentsDatabaseView:(OWSStorage *)storage -{ - YapDatabaseViewGrouping *viewGrouping = [YapDatabaseViewGrouping withObjectBlock:^NSString *_Nullable( - YapDatabaseReadTransaction *transaction, NSString *collection, NSString *key, id object) { - if (![object isKindOfClass:[TSAttachment class]]) { - return nil; - } - if (![object isKindOfClass:[TSAttachmentPointer class]]) { - return nil; - } - TSAttachmentPointer *attachmentPointer = (TSAttachmentPointer *)object; - if (attachmentPointer.lazyRestoreFragment) { - return TSLazyRestoreAttachmentsGroup; - } else { - return nil; - } - }]; - - YapDatabaseViewSorting *viewSorting = - [YapDatabaseViewSorting withObjectBlock:^NSComparisonResult(YapDatabaseReadTransaction *transaction, - NSString *group, - NSString *collection1, - NSString *key1, - id object1, - NSString *collection2, - NSString *key2, - id object2) { - if (![object1 isKindOfClass:[TSAttachmentPointer class]]) { - return NSOrderedSame; - } - if (![object2 isKindOfClass:[TSAttachmentPointer class]]) { - return NSOrderedSame; - } - - // Specific ordering doesn't matter; we just need a stable ordering. - TSAttachmentPointer *attachmentPointer1 = (TSAttachmentPointer *)object1; - TSAttachmentPointer *attachmentPointer2 = (TSAttachmentPointer *)object2; - return [attachmentPointer1.uniqueId compare:attachmentPointer2.uniqueId]; - }]; - - YapDatabaseViewOptions *options = [YapDatabaseViewOptions new]; - options.isPersistent = YES; - options.allowedCollections = - [[YapWhitelistBlacklist alloc] initWithWhitelist:[NSSet setWithObject:[TSAttachment collection]]]; - YapDatabaseView *view = - [[YapDatabaseAutoView alloc] initWithGrouping:viewGrouping sorting:viewSorting versionTag:@"4" options:options]; - [storage asyncRegisterExtension:view withName:TSLazyRestoreAttachmentsDatabaseViewExtensionName]; -} - -+ (id)unseenDatabaseViewExtension:(YapDatabaseReadTransaction *)transaction -{ - id _Nullable result = [transaction ext:TSUnseenDatabaseViewExtensionName]; - - // TODO: I believe we can now safely remove this? - if (!result) { - result = [transaction ext:TSUnreadDatabaseViewExtensionName]; - } - - return result; -} - -// MJK TODO - dynamic interactions -+ (id)threadOutgoingMessageDatabaseView:(YapDatabaseReadTransaction *)transaction -{ - id result = [transaction ext:TSThreadOutgoingMessageDatabaseViewExtensionName]; - - return result; -} - -+ (id)threadSpecialMessagesDatabaseView:(YapDatabaseReadTransaction *)transaction -{ - id result = [transaction ext:TSThreadSpecialMessagesDatabaseViewExtensionName]; - - return result; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/File Server/FileServerAPIV2.swift b/SessionMessagingKit/File Server/FileServerAPIV2.swift index a5b880b36..553005494 100644 --- a/SessionMessagingKit/File Server/FileServerAPIV2.swift +++ b/SessionMessagingKit/File Server/FileServerAPIV2.swift @@ -1,5 +1,9 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation import PromiseKit import SessionSnodeKit +import SessionUtilitiesKit @objc(SNFileServerAPIV2) public final class FileServerAPIV2 : NSObject { diff --git a/SessionMessagingKit/Meta/SessionMessagingKit.h b/SessionMessagingKit/Meta/SessionMessagingKit.h index 6006d21c1..c7ef2d60a 100644 --- a/SessionMessagingKit/Meta/SessionMessagingKit.h +++ b/SessionMessagingKit/Meta/SessionMessagingKit.h @@ -7,13 +7,5 @@ FOUNDATION_EXPORT const unsigned char SessionMessagingKitVersionString[]; #import #import #import -#import #import -#import -#import -#import #import -#import -#import -#import -#import diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index eeebdeeba..2a091b7a9 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -6,6 +6,7 @@ import Sodium import Curve25519Kit import SignalCoreKit import SessionSnodeKit +import SessionUtilitiesKit extension MessageReceiver { diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift index 9e07f583c..0702cae33 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift @@ -3,6 +3,7 @@ import GRDB import PromiseKit import SessionSnodeKit +import SessionUtilitiesKit @objc(LKPushNotificationAPI) public final class PushNotificationAPI : NSObject { diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift index 56ba23f92..9eadf1d85 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift @@ -4,6 +4,7 @@ import Foundation import GRDB import PromiseKit import SessionSnodeKit +import SessionUtilitiesKit @objc(LKClosedGroupPoller) public final class ClosedGroupPoller: NSObject { diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift index e94e8d9ab..4aac480a2 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift @@ -5,6 +5,7 @@ import GRDB import PromiseKit import Sodium import SessionSnodeKit +import SessionUtilitiesKit public final class Poller { private var isPolling: Atomic = Atomic(false) diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 8da43ce45..51c288269 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -3,6 +3,7 @@ import Foundation import GRDB import DifferenceKit +import SessionUtilitiesKit fileprivate typealias ViewModel = SessionThreadViewModel diff --git a/SessionMessagingKit/Storage.swift b/SessionMessagingKit/Storage.swift deleted file mode 100644 index b543b8552..000000000 --- a/SessionMessagingKit/Storage.swift +++ /dev/null @@ -1,56 +0,0 @@ -import PromiseKit -import Sodium -import SessionSnodeKit - -public protocol SessionMessagingKitStorageProtocol { - - // MARK: - Shared - - @discardableResult - func write(with block: @escaping (Any) -> Void) -> Promise - @discardableResult - func write(with block: @escaping (Any) -> Void, completion: @escaping () -> Void) -> Promise - func writeSync(with block: @escaping (Any) -> Void) - - // MARK: - Authorization - - func getAuthToken(for room: String, on server: String) -> String? - func setAuthToken(for room: String, on server: String, to newValue: String, using transaction: Any) - func removeAuthToken(for room: String, on server: String, using transaction: Any) - - // MARK: - Open Groups - - func getAllV2OpenGroups() -> [String:OpenGroupV2] - func getV2OpenGroup(for threadID: String) -> OpenGroupV2? - func v2GetThreadID(for v2OpenGroupID: String) -> String? - - // MARK: - Open Group Public Keys - - func getOpenGroupPublicKey(for server: String) -> String? - func setOpenGroupPublicKey(for server: String, to newValue: String, using transaction: Any) - - // MARK: - Last Message Server ID - - func getLastMessageServerID(for room: String, on server: String) -> Int64? - func setLastMessageServerID(for room: String, on server: String, to newValue: Int64, using transaction: Any) - func removeLastMessageServerID(for room: String, on server: String, using transaction: Any) - - // MARK: - Last Deletion Server ID - - func getLastDeletionServerID(for room: String, on server: String) -> Int64? - func setLastDeletionServerID(for room: String, on server: String, to newValue: Int64, using transaction: Any) - func removeLastDeletionServerID(for room: String, on server: String, using transaction: Any) - - // MARK: - OpenGroupServerIdToUniqueIdLookup - - func getOpenGroupServerIdLookup(_ serverId: UInt64, in room: String, on server: String, using transaction: YapDatabaseReadTransaction) -> OpenGroupServerIdLookup? - func addOpenGroupServerIdLookup(_ serverId: UInt64?, tsMessageId: String?, in room: String, on server: String, using transaction: YapDatabaseReadWriteTransaction) - func addOpenGroupServerIdLookup(_ lookup: OpenGroupServerIdLookup, using transaction: YapDatabaseReadWriteTransaction) - func removeOpenGroupServerIdLookup(_ serverId: UInt64, in room: String, on server: String, using transaction: YapDatabaseReadWriteTransaction) - - // MARK: - Open Group Metadata - - func setUserCount(to newValue: UInt64, forV2OpenGroupWithID openGroupID: String, using transaction: Any) -} - -extension Storage: SessionMessagingKitStorageProtocol {} diff --git a/SessionMessagingKit/Utilities/Environment.swift b/SessionMessagingKit/Utilities/Environment.swift index 6d616b06a..1630ed8f7 100644 --- a/SessionMessagingKit/Utilities/Environment.swift +++ b/SessionMessagingKit/Utilities/Environment.swift @@ -6,11 +6,9 @@ import SessionUtilitiesKit public class Environment { public static var shared: Environment! - public let primaryStorage: OWSPrimaryStorage public let reachabilityManager: SSKReachabilityManager public let audioSession: OWSAudioSession - public let preferences: OWSPreferences public let proximityMonitoringManager: OWSProximityMonitoringManager public let windowManager: OWSWindowManager public var isRequestingPermission: Bool @@ -22,34 +20,20 @@ public class Environment { (notificationsManager.wrappedValue != nil) } - public var objectReadWriteConnection: YapDatabaseConnection - public var sessionStoreDBConnection: YapDatabaseConnection - public var migrationDBConnection: YapDatabaseConnection - public var analyticsDBConnection: YapDatabaseConnection - // MARK: - Initialization public init( - primaryStorage: OWSPrimaryStorage, reachabilityManager: SSKReachabilityManager, audioSession: OWSAudioSession, - preferences: OWSPreferences, proximityMonitoringManager: OWSProximityMonitoringManager, windowManager: OWSWindowManager ) { - self.primaryStorage = primaryStorage self.reachabilityManager = reachabilityManager self.audioSession = audioSession - self.preferences = preferences self.proximityMonitoringManager = proximityMonitoringManager self.windowManager = windowManager self.isRequestingPermission = false - self.objectReadWriteConnection = primaryStorage.newDatabaseConnection() - self.sessionStoreDBConnection = primaryStorage.newDatabaseConnection() - self.migrationDBConnection = primaryStorage.newDatabaseConnection() - self.analyticsDBConnection = primaryStorage.newDatabaseConnection() - if Environment.shared == nil { Environment.shared = self } @@ -68,7 +52,6 @@ public class Environment { class SMKEnvironment: NSObject { @objc public static let shared: SMKEnvironment = SMKEnvironment() - @objc public var primaryStorage: OWSPrimaryStorage { Environment.shared.primaryStorage } @objc public var audioSession: OWSAudioSession { Environment.shared.audioSession } @objc public var windowManager: OWSWindowManager { Environment.shared.windowManager } diff --git a/SessionMessagingKit/Utilities/OWSAudioSession.swift b/SessionMessagingKit/Utilities/OWSAudioSession.swift index 890c25055..65adae8e5 100644 --- a/SessionMessagingKit/Utilities/OWSAudioSession.swift +++ b/SessionMessagingKit/Utilities/OWSAudioSession.swift @@ -3,6 +3,7 @@ import Foundation import AVFoundation import SignalCoreKit +import SessionUtilitiesKit @objc(OWSAudioActivity) public class AudioActivity: NSObject { diff --git a/SessionMessagingKit/Utilities/ProximityMonitoringManager.swift b/SessionMessagingKit/Utilities/ProximityMonitoringManager.swift index 5a870cdd0..2739f356a 100644 --- a/SessionMessagingKit/Utilities/ProximityMonitoringManager.swift +++ b/SessionMessagingKit/Utilities/ProximityMonitoringManager.swift @@ -2,6 +2,7 @@ import Foundation import SignalCoreKit +import SessionUtilitiesKit @objc public protocol OWSProximityMonitoringManager: AnyObject { diff --git a/SessionMessagingKit/Utilities/SNProtoEnvelope+Conversion.swift b/SessionMessagingKit/Utilities/SNProtoEnvelope+Conversion.swift index d17f65563..5cb291067 100644 --- a/SessionMessagingKit/Utilities/SNProtoEnvelope+Conversion.swift +++ b/SessionMessagingKit/Utilities/SNProtoEnvelope+Conversion.swift @@ -2,6 +2,7 @@ import Foundation import SessionSnodeKit +import SessionUtilitiesKit public extension SNProtoEnvelope { static func from(_ message: SnodeReceivedMessage) -> SNProtoEnvelope? { diff --git a/SessionMessagingKit/Utilities/YapDatabaseConnection+OWS.h b/SessionMessagingKit/Utilities/YapDatabaseConnection+OWS.h deleted file mode 100644 index 2c7e6cc77..000000000 --- a/SessionMessagingKit/Utilities/YapDatabaseConnection+OWS.h +++ /dev/null @@ -1,43 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import - -@class ECKeyPair; -@class PreKeyRecord; -@class SignedPreKeyRecord; -@class PreKeyBundle; - -NS_ASSUME_NONNULL_BEGIN - -@interface YapDatabaseConnection (OWS) - -- (BOOL)hasObjectForKey:(NSString *)key inCollection:(NSString *)collection; -- (BOOL)boolForKey:(NSString *)key inCollection:(NSString *)collection defaultValue:(BOOL)defaultValue; -- (double)doubleForKey:(NSString *)key inCollection:(NSString *)collection defaultValue:(double)defaultValue; -- (int)intForKey:(NSString *)key inCollection:(NSString *)collection; -- (nullable id)objectForKey:(NSString *)key inCollection:(NSString *)collection; -- (nullable NSDate *)dateForKey:(NSString *)key inCollection:(NSString *)collection; -- (nullable NSDictionary *)dictionaryForKey:(NSString *)key inCollection:(NSString *)collection; -- (nullable NSString *)stringForKey:(NSString *)key inCollection:(NSString *)collection; -- (nullable NSData *)dataForKey:(NSString *)key inCollection:(NSString *)collection; -- (nullable ECKeyPair *)keyPairForKey:(NSString *)key inCollection:(NSString *)collection; - -- (NSUInteger)numberOfKeysInCollection:(NSString *)collection; - -#pragma mark - - -- (void)setObject:(id)object forKey:(NSString *)key inCollection:(NSString *)collection; -- (void)setBool:(BOOL)value forKey:(NSString *)key inCollection:(NSString *)collection; -- (void)setDouble:(double)value forKey:(NSString *)key inCollection:(NSString *)collection; -- (void)removeObjectForKey:(NSString *)string inCollection:(NSString *)collection; -- (void)setInt:(int)integer forKey:(NSString *)key inCollection:(NSString *)collection; -- (void)setDate:(NSDate *)value forKey:(NSString *)key inCollection:(NSString *)collection; -- (int)incrementIntForKey:(NSString *)key inCollection:(NSString *)collection; - -- (void)purgeCollection:(NSString *)collection; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Utilities/YapDatabaseConnection+OWS.m b/SessionMessagingKit/Utilities/YapDatabaseConnection+OWS.m deleted file mode 100644 index 3f30cf2a4..000000000 --- a/SessionMessagingKit/Utilities/YapDatabaseConnection+OWS.m +++ /dev/null @@ -1,154 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "YapDatabaseConnection+OWS.h" -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@implementation YapDatabaseConnection (OWS) - -- (BOOL)hasObjectForKey:(NSString *)key inCollection:(NSString *)collection -{ - return nil != [self objectForKey:key inCollection:collection]; -} - -- (nullable id)objectForKey:(NSString *)key inCollection:(NSString *)collection -{ - __block NSString *_Nullable object; - - [self readWithBlock:^(YapDatabaseReadTransaction *transaction) { - object = [transaction objectForKey:key inCollection:collection]; - }]; - - return object; -} - -- (nullable id)objectForKey:(NSString *)key inCollection:(NSString *)collection ofExpectedType:(Class)class -{ - id _Nullable value = [self objectForKey:key inCollection:collection]; - return value; -} - -- (nullable NSDictionary *)dictionaryForKey:(NSString *)key inCollection:(NSString *)collection -{ - return [self objectForKey:key inCollection:collection ofExpectedType:[NSDictionary class]]; -} - -- (nullable NSString *)stringForKey:(NSString *)key inCollection:(NSString *)collection -{ - return [self objectForKey:key inCollection:collection ofExpectedType:[NSString class]]; -} - -- (BOOL)boolForKey:(NSString *)key inCollection:(NSString *)collection defaultValue:(BOOL)defaultValue -{ - NSNumber *_Nullable value = [self objectForKey:key inCollection:collection ofExpectedType:[NSNumber class]]; - return value ? [value boolValue] : defaultValue; -} - -- (double)doubleForKey:(NSString *)key inCollection:(NSString *)collection defaultValue:(double)defaultValue -{ - NSNumber *_Nullable value = [self objectForKey:key inCollection:collection ofExpectedType:[NSNumber class]]; - return value ? [value doubleValue] : defaultValue; -} - -- (nullable NSData *)dataForKey:(NSString *)key inCollection:(NSString *)collection -{ - return [self objectForKey:key inCollection:collection ofExpectedType:[NSData class]]; -} - -- (nullable ECKeyPair *)keyPairForKey:(NSString *)key inCollection:(NSString *)collection -{ - return [self objectForKey:key inCollection:collection ofExpectedType:[ECKeyPair class]]; -} - -- (int)intForKey:(NSString *)key inCollection:(NSString *)collection -{ - NSNumber *_Nullable number = [self objectForKey:key inCollection:collection ofExpectedType:[NSNumber class]]; - return [number intValue]; -} - -- (nullable NSDate *)dateForKey:(NSString *)key inCollection:(NSString *)collection -{ - NSNumber *_Nullable value = [self objectForKey:key inCollection:collection ofExpectedType:[NSNumber class]]; - if (value) { - return [NSDate dateWithTimeIntervalSince1970:value.doubleValue]; - } else { - return nil; - } -} - -#pragma mark - - -- (NSUInteger)numberOfKeysInCollection:(NSString *)collection -{ - __block NSUInteger result; - [self readWithBlock:^(YapDatabaseReadTransaction *transaction) { - result = [transaction numberOfKeysInCollection:collection]; - }]; - return result; -} - -- (void)purgeCollection:(NSString *)collection -{ - [self readWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [transaction removeAllObjectsInCollection:collection]; - }]; -} - -- (void)setObject:(id)object forKey:(NSString *)key inCollection:(NSString *)collection -{ - [self readWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [transaction setObject:object forKey:key inCollection:collection]; - }]; -} - -- (void)setBool:(BOOL)value forKey:(NSString *)key inCollection:(NSString *)collection -{ - NSNumber *_Nullable oldValue = [self objectForKey:key inCollection:collection ofExpectedType:[NSNumber class]]; - if (oldValue && [@(value) isEqual:oldValue]) { - // Skip redundant writes. - return; - } - - [self setObject:@(value) forKey:key inCollection:collection]; -} - -- (void)setDouble:(double)value forKey:(NSString *)key inCollection:(NSString *)collection -{ - [self setObject:@(value) forKey:key inCollection:collection]; -} - -- (void)removeObjectForKey:(NSString *)key inCollection:(NSString *)collection -{ - [self readWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [transaction removeObjectForKey:key inCollection:collection]; - }]; -} - -- (void)setInt:(int)value forKey:(NSString *)key inCollection:(NSString *)collection -{ - [self setObject:@(value) forKey:key inCollection:collection]; -} - -- (int)incrementIntForKey:(NSString *)key inCollection:(NSString *)collection -{ - __block int value = 0; - [self readWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - value = [[transaction objectForKey:key inCollection:collection] intValue]; - value++; - [transaction setObject:@(value) forKey:key inCollection:collection]; - }]; - return value; -} - -- (void)setDate:(NSDate *)value forKey:(NSString *)key inCollection:(NSString *)collection -{ - [self setObject:@(value.timeIntervalSince1970) forKey:key inCollection:collection]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Utilities/YapDatabaseTransaction+OWS.h b/SessionMessagingKit/Utilities/YapDatabaseTransaction+OWS.h deleted file mode 100644 index 056a20aa5..000000000 --- a/SessionMessagingKit/Utilities/YapDatabaseTransaction+OWS.h +++ /dev/null @@ -1,43 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import - -@class ECKeyPair; -@class PreKeyRecord; -@class PreKeyBundle; -@class SignedPreKeyRecord; - -NS_ASSUME_NONNULL_BEGIN - -@interface YapDatabaseReadTransaction (OWS) - -- (BOOL)boolForKey:(NSString *)key inCollection:(NSString *)collection defaultValue:(BOOL)defaultValue; -- (double)doubleForKey:(NSString *)key inCollection:(NSString *)collection defaultValue:(double)defaultValue; -- (int)intForKey:(NSString *)key inCollection:(NSString *)collection; -- (nullable NSDate *)dateForKey:(NSString *)key inCollection:(NSString *)collection; -- (nullable NSDictionary *)dictionaryForKey:(NSString *)key inCollection:(NSString *)collection; -- (nullable NSString *)stringForKey:(NSString *)key inCollection:(NSString *)collection; -- (nullable NSData *)dataForKey:(NSString *)key inCollection:(NSString *)collection; -- (nullable ECKeyPair *)keyPairForKey:(NSString *)key inCollection:(NSString *)collection; - -@end - -#pragma mark - - -@interface YapDatabaseReadWriteTransaction (OWS) - -#pragma mark - Debug - -#if DEBUG -- (void)snapshotCollection:(NSString *)collection snapshotFilePath:(NSString *)snapshotFilePath; -- (void)restoreSnapshotOfCollection:(NSString *)collection snapshotFilePath:(NSString *)snapshotFilePath; -#endif - -- (void)setBool:(BOOL)value forKey:(NSString *)key inCollection:(NSString *)collection; -- (void)setDate:(NSDate *)value forKey:(NSString *)key inCollection:(NSString *)collection; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Utilities/YapDatabaseTransaction+OWS.m b/SessionMessagingKit/Utilities/YapDatabaseTransaction+OWS.m deleted file mode 100644 index c102ee6e3..000000000 --- a/SessionMessagingKit/Utilities/YapDatabaseTransaction+OWS.m +++ /dev/null @@ -1,116 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "YapDatabaseTransaction+OWS.h" -#import - -NS_ASSUME_NONNULL_BEGIN - -@implementation YapDatabaseReadTransaction (OWS) - -- (nullable id)objectForKey:(NSString *)key inCollection:(NSString *)collection ofExpectedType:(Class) class { - id _Nullable value = [self objectForKey:key inCollection:collection]; - return value; -} - -- (nullable NSDictionary *)dictionaryForKey : (NSString *)key inCollection : (NSString *)collection -{ - return [self objectForKey:key inCollection:collection ofExpectedType:[NSDictionary class]]; -} - -- (nullable NSString *)stringForKey:(NSString *)key inCollection:(NSString *)collection -{ - return [self objectForKey:key inCollection:collection ofExpectedType:[NSString class]]; -} - -- (BOOL)boolForKey:(NSString *)key inCollection:(NSString *)collection defaultValue:(BOOL)defaultValue -{ - NSNumber *_Nullable value = [self objectForKey:key inCollection:collection ofExpectedType:[NSNumber class]]; - return value ? [value boolValue] : defaultValue; -} - -- (double)doubleForKey:(NSString *)key inCollection:(NSString *)collection defaultValue:(double)defaultValue -{ - NSNumber *_Nullable value = [self objectForKey:key inCollection:collection ofExpectedType:[NSNumber class]]; - return value ? [value doubleValue] : defaultValue; -} - -- (nullable NSData *)dataForKey:(NSString *)key inCollection:(NSString *)collection -{ - return [self objectForKey:key inCollection:collection ofExpectedType:[NSData class]]; -} - -- (nullable ECKeyPair *)keyPairForKey:(NSString *)key inCollection:(NSString *)collection -{ - return [self objectForKey:key inCollection:collection ofExpectedType:[ECKeyPair class]]; -} - -- (int)intForKey:(NSString *)key inCollection:(NSString *)collection -{ - NSNumber *_Nullable number = [self objectForKey:key inCollection:collection ofExpectedType:[NSNumber class]]; - return [number intValue]; -} - -- (nullable NSDate *)dateForKey:(NSString *)key inCollection:(NSString *)collection -{ - NSNumber *_Nullable value = [self objectForKey:key inCollection:collection ofExpectedType:[NSNumber class]]; - if (value) { - return [NSDate dateWithTimeIntervalSince1970:value.doubleValue]; - } else { - return nil; - } -} - -@end - -#pragma mark - - -@implementation YapDatabaseReadWriteTransaction (OWS) - -#pragma mark - Debug - -#if DEBUG -- (void)snapshotCollection:(NSString *)collection snapshotFilePath:(NSString *)snapshotFilePath -{ - NSMutableDictionary *snapshot = [NSMutableDictionary new]; - [self enumerateKeysAndObjectsInCollection:collection - usingBlock:^(NSString *_Nonnull key, id _Nonnull value, BOOL *_Nonnull stop) { - snapshot[key] = value; - }]; - NSData *_Nullable data = [NSKeyedArchiver archivedDataWithRootObject:snapshot]; - BOOL success = [data writeToFile:snapshotFilePath atomically:YES]; -} - -- (void)restoreSnapshotOfCollection:(NSString *)collection snapshotFilePath:(NSString *)snapshotFilePath -{ - - NSData *_Nullable data = [NSData dataWithContentsOfFile:snapshotFilePath]; - NSMutableDictionary *_Nullable snapshot = [NSKeyedUnarchiver unarchiveObjectWithData:data]; - - [self removeAllObjectsInCollection:collection]; - [snapshot enumerateKeysAndObjectsUsingBlock:^(NSString *_Nonnull key, id _Nonnull value, BOOL *_Nonnull stop) { - [self setObject:value forKey:key inCollection:collection]; - }]; -} -#endif - -- (void)setBool:(BOOL)value forKey:(NSString *)key inCollection:(NSString *)collection -{ - NSNumber *_Nullable oldValue = [self objectForKey:key inCollection:collection ofExpectedType:[NSNumber class]]; - if (oldValue && [@(value) isEqual:oldValue]) { - // Skip redundant writes. - return; - } - - [self setObject:@(value) forKey:key inCollection:collection]; -} - -- (void)setDate:(NSDate *)value forKey:(NSString *)key inCollection:(NSString *)collection -{ - [self setObject:@(value.timeIntervalSince1970) forKey:key inCollection:collection]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index c0df8e76d..77055d422 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -125,8 +125,6 @@ public final class NotificationServiceExtension : UNNotificationServiceExtension completion() } ) - - NotificationCenter.default.addObserver(self, selector: #selector(storageIsReady), name: .StorageIsReady, object: nil) } @objc @@ -145,13 +143,6 @@ public final class NotificationServiceExtension : UNNotificationServiceExtension checkIsAppReady() } - @objc - private func storageIsReady() { - AssertIsOnMainThread() - - checkIsAppReady() - } - @objc private func checkIsAppReady() { AssertIsOnMainThread() @@ -160,7 +151,7 @@ public final class NotificationServiceExtension : UNNotificationServiceExtension guard !AppReadiness.isAppReady() else { return } // App isn't ready until storage is ready AND all version migrations are complete. - guard OWSStorage.isStorageReady() && areVersionMigrationsComplete else { return } + guard GRDBStorage.shared.isValid && areVersionMigrationsComplete else { return } SignalUtilitiesKit.Configuration.performMainSetup() diff --git a/SessionShareExtension/ShareVC.swift b/SessionShareExtension/ShareVC.swift index a300e2bbe..0f168e201 100644 --- a/SessionShareExtension/ShareVC.swift +++ b/SessionShareExtension/ShareVC.swift @@ -56,13 +56,6 @@ final class ShareVC : UINavigationController, ShareViewDelegate, AppModeManagerD ) // We don't need to use "screen protection" in the SAE. - - NotificationCenter.default.addObserver( - self, - selector: #selector(storageIsReady), - name: .StorageIsReady, - object: nil - ) NotificationCenter.default.addObserver( self, selector: #selector(applicationDidEnterBackground), @@ -89,22 +82,13 @@ final class ShareVC : UINavigationController, ShareViewDelegate, AppModeManagerD checkIsAppReady() } - @objc - func storageIsReady() { - AssertIsOnMainThread() - - Logger.debug("") - - checkIsAppReady() - } - @objc func checkIsAppReady() { AssertIsOnMainThread() // App isn't ready until storage is ready AND all version migrations are complete. guard areVersionMigrationsComplete else { return } - guard OWSStorage.isStorageReady() else { return } + guard GRDBStorage.shared.isValid else { return } guard !AppReadiness.isAppReady() else { // Only mark the app as ready once. return diff --git a/SessionSnodeKit/Configuration.swift b/SessionSnodeKit/Configuration.swift index 24df07e8e..11a6c6944 100644 --- a/SessionSnodeKit/Configuration.swift +++ b/SessionSnodeKit/Configuration.swift @@ -3,10 +3,6 @@ import Foundation import SessionUtilitiesKit -public struct SNSnodeKitConfiguration { - internal static var shared: SNSnodeKitConfiguration! -} - public enum SNSnodeKit { // Just to make the external API nice public static func migrations() -> TargetMigrations { return TargetMigrations( diff --git a/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 65f982d4d..2919396f3 100644 --- a/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -2,6 +2,7 @@ import Foundation import GRDB +import YapDatabase import SessionUtilitiesKit enum _003_YDBToGRDBMigration: Migration { diff --git a/SessionUtilitiesKit/Configuration.swift b/SessionUtilitiesKit/Configuration.swift index c102292d2..b0f728ee0 100644 --- a/SessionUtilitiesKit/Configuration.swift +++ b/SessionUtilitiesKit/Configuration.swift @@ -1,13 +1,14 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation @objc public final class SNUtilitiesKitConfiguration : NSObject { - @objc public let owsPrimaryStorage: OWSPrimaryStorageProtocol public let maxFileSize: UInt @objc public static var shared: SNUtilitiesKitConfiguration! - fileprivate init(owsPrimaryStorage: OWSPrimaryStorageProtocol, maxFileSize: UInt) { - self.owsPrimaryStorage = owsPrimaryStorage + fileprivate init(maxFileSize: UInt) { self.maxFileSize = maxFileSize } } @@ -29,7 +30,7 @@ public enum SNUtilitiesKit { // Just to make the external API nice ) } - public static func configure(owsPrimaryStorage: OWSPrimaryStorageProtocol, maxFileSize: UInt) { - SNUtilitiesKitConfiguration.shared = SNUtilitiesKitConfiguration(owsPrimaryStorage: owsPrimaryStorage, maxFileSize: maxFileSize) + public static func configure(maxFileSize: UInt) { + SNUtilitiesKitConfiguration.shared = SNUtilitiesKitConfiguration(maxFileSize: maxFileSize) } } diff --git a/SessionUtilitiesKit/Database/GRDBStorage.swift b/SessionUtilitiesKit/Database/GRDBStorage.swift index 4009d7b3c..ff3326221 100644 --- a/SessionUtilitiesKit/Database/GRDBStorage.swift +++ b/SessionUtilitiesKit/Database/GRDBStorage.swift @@ -257,7 +257,7 @@ public final class GRDBStorage { // MARK: - File Management - private static func resetAllStorage() { + public static func resetAllStorage() { NotificationCenter.default.post(name: .resetStorage, object: nil) // This might be redundant but in the spirit of thoroughness... diff --git a/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift index fc87b4c85..df806b93c 100644 --- a/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -2,6 +2,7 @@ import Foundation import GRDB +import YapDatabase enum _003_YDBToGRDBMigration: Migration { static let target: TargetMigrations.Identifier = .utilitiesKit diff --git a/SessionUtilitiesKit/Database/OWSPrimaryStorageProtocol.swift b/SessionUtilitiesKit/Database/OWSPrimaryStorageProtocol.swift deleted file mode 100644 index 49f6b327c..000000000 --- a/SessionUtilitiesKit/Database/OWSPrimaryStorageProtocol.swift +++ /dev/null @@ -1,7 +0,0 @@ -import YapDatabase - -@objc public protocol OWSPrimaryStorageProtocol { - - var dbReadConnection: YapDatabaseConnection { get } - var dbReadWriteConnection: YapDatabaseConnection { get } -} diff --git a/SessionUtilitiesKit/Database/Storage.swift b/SessionUtilitiesKit/Database/Storage.swift deleted file mode 100644 index a2e2dcbc5..000000000 --- a/SessionUtilitiesKit/Database/Storage.swift +++ /dev/null @@ -1,71 +0,0 @@ -import PromiseKit -import YapDatabase - -// Some important notes about YapDatabase: -// -// • Connections are thread-safe. -// • Executing a write transaction from within a write transaction is NOT allowed. - -@objc(LKStorage) -public final class Storage : NSObject { - public static let serialQueue = DispatchQueue(label: "Storage.serialQueue", qos: .userInitiated) - - private static var owsStorage: OWSPrimaryStorageProtocol { SNUtilitiesKitConfiguration.shared.owsPrimaryStorage } - - @objc public static let shared = Storage() - - // MARK: Reading - - // Some important points regarding reading from the database: - // - // • Background threads should use `OWSPrimaryStorage`'s `dbReadConnection`, whereas the main thread should use `OWSPrimaryStorage`'s `uiDatabaseConnection` (see the `YapDatabaseConnectionPool` documentation for more information). - // • Multiple read transactions can safely be executed at the same time. - - @objc(readWithBlock:) - public static func read(with block: @escaping (YapDatabaseReadTransaction) -> Void) { - owsStorage.dbReadConnection.read(block) - } - - // MARK: Writing - - // Some important points regarding writing to the database: - // - // • There can only be a single write transaction per database at any one time, so all write transactions must use `OWSPrimaryStorage`'s `dbReadWriteConnection`. - // • Executing a write transaction from within a write transaction causes a deadlock and must be avoided. - - @discardableResult - @objc(writeWithBlock:) - public static func objc_write(with block: @escaping (YapDatabaseReadWriteTransaction) -> Void) -> AnyPromise { - return AnyPromise.from(write(with: block) { }) - } - - @discardableResult - public static func write(with block: @escaping (YapDatabaseReadWriteTransaction) -> Void) -> Promise { - return write(with: block) { } - } - - @discardableResult - @objc(writeWithBlock:completion:) - public static func objc_write(with block: @escaping (YapDatabaseReadWriteTransaction) -> Void, completion: @escaping () -> Void) -> AnyPromise { - return AnyPromise.from(write(with: block, completion: completion)) - } - - @discardableResult - public static func write(with block: @escaping (YapDatabaseReadWriteTransaction) -> Void, completion: @escaping () -> Void) -> Promise { - let (promise, seal) = Promise.pending() - serialQueue.async { - owsStorage.dbReadWriteConnection.readWrite { transaction in - transaction.addCompletionQueue(DispatchQueue.main, completionBlock: completion) - block(transaction) - } - seal.fulfill(()) - } - return promise - } - - /// Blocks the calling thread until the write has finished. - @objc(writeSyncWithBlock:) - public static func writeSync(with block: @escaping (YapDatabaseReadWriteTransaction) -> Void) { - try! write(with: block, completion: { }).wait() // The promise returned by write(with:completion:) never rejects - } -} diff --git a/SessionUtilitiesKit/Database/TSYapDatabaseObject.h b/SessionUtilitiesKit/Database/TSYapDatabaseObject.h deleted file mode 100644 index 4ddcdcedd..000000000 --- a/SessionUtilitiesKit/Database/TSYapDatabaseObject.h +++ /dev/null @@ -1,165 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@class OWSPrimaryStorage; -@class YapDatabaseConnection; -@class YapDatabaseReadTransaction; -@class YapDatabaseReadWriteTransaction; - -@interface TSYapDatabaseObject : MTLModel - -- (instancetype)init NS_DESIGNATED_INITIALIZER; - -/** - * Initializes a new database object with a unique identifier - * - * @param uniqueId Key used for the key-value store - * - * @return Initialized object - */ -- (instancetype)initWithUniqueId:(NSString *_Nullable)uniqueId NS_DESIGNATED_INITIALIZER; - -- (nullable instancetype)initWithCoder:(NSCoder *)coder NS_DESIGNATED_INITIALIZER; - -/** - * Returns the collection to which the object belongs. - * - * @return Key (string) identifying the collection - */ -+ (NSString *)collection; - -/** - * Get the number of keys in the models collection. Be aware that if there - * are multiple object types in this collection that the count will include - * the count of other objects in the same collection. - * - * @return The number of keys in the classes collection. - */ -+ (NSUInteger)numberOfKeysInCollection; -+ (NSUInteger)numberOfKeysInCollectionWithTransaction:(YapDatabaseReadTransaction *)transaction; - -/** - * Removes all objects in the classes collection. - */ -+ (void)removeAllObjectsInCollection; - -/** - * A memory intesive method to get all objects in the collection. You should prefer using enumeration over this method - * whenever feasible. See `enumerateObjectsInCollectionUsingBlock` - * - * @return All objects in the classes collection. - */ -+ (NSArray *)allObjectsInCollection; - -/** - * Enumerates all objects in collection. - */ -+ (void)enumerateCollectionObjectsUsingBlock:(void (^)(id obj, BOOL *stop))block; -+ (void)enumerateCollectionObjectsWithTransaction:(YapDatabaseReadTransaction *)transaction - usingBlock:(void (^)(id object, BOOL *stop))block; - -/** - * @return Shared database connections for reading and writing. - */ -- (YapDatabaseConnection *)dbReadConnection; -+ (YapDatabaseConnection *)dbReadConnection; -- (YapDatabaseConnection *)dbReadWriteConnection; -+ (YapDatabaseConnection *)dbReadWriteConnection; - -/** - * Fetches the object with the provided identifier - * - * @param uniqueID Unique identifier of the entry in a collection - * @param transaction Transaction used for fetching the object - * - * @return Instance of the object or nil if non-existent - */ -+ (nullable instancetype)fetchObjectWithUniqueID:(NSString *)uniqueID - transaction:(YapDatabaseReadTransaction *)transaction - NS_SWIFT_NAME(fetch(uniqueId:transaction:)); -+ (nullable instancetype)fetchObjectWithUniqueID:(NSString *)uniqueID NS_SWIFT_NAME(fetch(uniqueId:)); - -/** - * Saves the object with the shared readWrite connection. - * - * This method will block if another readWrite transaction is open. - */ -- (void)save; - -/** - * Assign the latest persisted values from the database. - */ -- (void)reload; -- (void)reloadWithTransaction:(YapDatabaseReadTransaction *)transaction; -- (void)reloadWithTransaction:(YapDatabaseReadTransaction *)transaction ignoreMissing:(BOOL)ignoreMissing; - -/** - * Saves the object with the shared readWrite connection - does not block. - * - * Be mindful that this method may clobber other changes persisted - * while waiting to open the readWrite transaction. - * - * @param completionBlock is called on the main thread - */ -- (void)saveAsyncWithCompletionBlock:(void (^_Nullable)(void))completionBlock; - -/** - * Saves the object with the provided transaction - * - * @param transaction Database transaction - */ -- (void)saveWithTransaction:(YapDatabaseReadWriteTransaction *)transaction; - -/** - * `touch` is a cheap way to fire a YapDatabaseModified notification to redraw anythign depending on the model. - */ -- (void)touch; -- (void)touchWithTransaction:(YapDatabaseReadWriteTransaction *)transaction; - -/** - * The unique identifier of the stored object - */ -@property (nonatomic, nullable) NSString *uniqueId; - -- (void)removeWithTransaction:(YapDatabaseReadWriteTransaction *)transaction; -- (void)remove; - -#pragma mark - Update With... - -// This method is used by "updateWith..." methods. -// -// This model may be updated from many threads. We don't want to save -// our local copy (this instance) since it may be out of date. We also -// want to avoid re-saving a model that has been deleted. Therefore, we -// use "updateWith..." methods to: -// -// a) Update a property of this instance. -// b) If a copy of this model exists in the database, load an up-to-date copy, -// and update and save that copy. -// b) If a copy of this model _DOES NOT_ exist in the database, do _NOT_ save -// this local instance. -// -// After "updateWith...": -// -// a) Any copy of this model in the database will have been updated. -// b) The local property on this instance will always have been updated. -// c) Other properties on this instance may be out of date. -// -// All mutable properties of this class have been made read-only to -// prevent accidentally modifying them directly. -// -// This isn't a perfect arrangement, but in practice this will prevent -// data loss and will resolve all known issues. -- (void)applyChangeToSelfAndLatestCopy:(YapDatabaseReadWriteTransaction *)transaction - changeBlock:(void (^)(id))changeBlock; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionUtilitiesKit/Database/TSYapDatabaseObject.m b/SessionUtilitiesKit/Database/TSYapDatabaseObject.m deleted file mode 100644 index 1c3c84b0a..000000000 --- a/SessionUtilitiesKit/Database/TSYapDatabaseObject.m +++ /dev/null @@ -1,229 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "TSYapDatabaseObject.h" -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@implementation TSYapDatabaseObject - -- (instancetype)init -{ - return [self initWithUniqueId:[[NSUUID UUID] UUIDString]]; -} - -- (instancetype)initWithUniqueId:(NSString *_Nullable)aUniqueId -{ - self = [super init]; - if (!self) { - return self; - } - - _uniqueId = aUniqueId; - - return self; -} - -- (nullable instancetype)initWithCoder:(NSCoder *)coder -{ - self = [super initWithCoder:coder]; - if (!self) { - return self; - } - - return self; -} - -- (void)saveWithTransaction:(YapDatabaseReadWriteTransaction *)transaction -{ - [transaction setObject:self forKey:self.uniqueId inCollection:[[self class] collection]]; -} - -- (void)save -{ - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [self saveWithTransaction:transaction]; - }]; -} - -- (void)saveAsyncWithCompletionBlock:(void (^_Nullable)(void))completionBlock -{ - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) { - [self saveWithTransaction:transaction]; - } completion:completionBlock]; -} - -- (void)touchWithTransaction:(YapDatabaseReadWriteTransaction *)transaction -{ - [transaction touchObjectForKey:self.uniqueId inCollection:[self.class collection]]; -} - -- (void)touch -{ - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [self touchWithTransaction:transaction]; - }]; -} - -- (void)removeWithTransaction:(YapDatabaseReadWriteTransaction *)transaction -{ - [transaction removeObjectForKey:self.uniqueId inCollection:[[self class] collection]]; -} - -- (void)remove -{ - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [self removeWithTransaction:transaction]; - }]; -} - -- (YapDatabaseConnection *)dbReadConnection -{ - return [[self class] dbReadConnection]; -} - -- (YapDatabaseConnection *)dbReadWriteConnection -{ - return [[self class] dbReadWriteConnection]; -} - -#pragma mark Class Methods - -+ (MTLPropertyStorage)storageBehaviorForPropertyWithKey:(NSString *)propertyKey -{ - if ([propertyKey isEqualToString:@"TAG"]) { - return MTLPropertyStorageNone; - } else { - return [super storageBehaviorForPropertyWithKey:propertyKey]; - } -} - -+ (YapDatabaseConnection *)dbReadConnection -{ - // We use TSYapDatabaseObject's dbReadWriteConnection (not OWSPrimaryStorage's - // dbReadConnection) for consistency, since we tend to [TSYapDatabaseObject - // save] and want to write to the same connection we read from. To get true - // consistency, we'd want to update entities by reading & writing from within - // the same transaction, but that'll be a big refactor. - return self.dbReadWriteConnection; -} - -+ (YapDatabaseConnection *)dbReadWriteConnection -{ - return SNUtilitiesKitConfiguration.shared.owsPrimaryStorage.dbReadWriteConnection; -} - -+ (NSString *)collection -{ - return NSStringFromClass([self class]); -} - -+ (NSUInteger)numberOfKeysInCollection -{ - __block NSUInteger count; - [[self dbReadConnection] readWithBlock:^(YapDatabaseReadTransaction *transaction) { - count = [self numberOfKeysInCollectionWithTransaction:transaction]; - }]; - return count; -} - -+ (NSUInteger)numberOfKeysInCollectionWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - return [transaction numberOfKeysInCollection:[self collection]]; -} - -+ (NSArray *)allObjectsInCollection -{ - __block NSMutableArray *all = [[NSMutableArray alloc] initWithCapacity:[self numberOfKeysInCollection]]; - [self enumerateCollectionObjectsUsingBlock:^(id object, BOOL *stop) { - [all addObject:object]; - }]; - return [all copy]; -} - -+ (void)enumerateCollectionObjectsUsingBlock:(void (^)(id object, BOOL *stop))block -{ - [[self dbReadConnection] readWithBlock:^(YapDatabaseReadTransaction *transaction) { - [self enumerateCollectionObjectsWithTransaction:transaction usingBlock:block]; - }]; -} - -+ (void)enumerateCollectionObjectsWithTransaction:(YapDatabaseReadTransaction *)transaction - usingBlock:(void (^)(id object, BOOL *stop))block -{ - // Ignoring most of the YapDB parameters, and just passing through the ones we usually use. - void (^yapBlock)(NSString *key, id object, id metadata, BOOL *stop) - = ^void(NSString *key, id object, id metadata, BOOL *stop) { - block(object, stop); - }; - - [transaction enumerateRowsInCollection:[self collection] usingBlock:yapBlock]; -} - -+ (void)removeAllObjectsInCollection -{ - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [transaction removeAllObjectsInCollection:[self collection]]; - }]; -} - -+ (nullable instancetype)fetchObjectWithUniqueID:(NSString *)uniqueID - transaction:(YapDatabaseReadTransaction *)transaction -{ - return [transaction objectForKey:uniqueID inCollection:[self collection]]; -} - -+ (nullable instancetype)fetchObjectWithUniqueID:(NSString *)uniqueID -{ - __block id _Nullable object = nil; - [[self dbReadConnection] readWithBlock:^(YapDatabaseReadTransaction *transaction) { - object = [transaction objectForKey:uniqueID inCollection:[self collection]]; - }]; - return object; -} - -#pragma mark - Update With... - -- (void)applyChangeToSelfAndLatestCopy:(YapDatabaseReadWriteTransaction *)transaction - changeBlock:(void (^)(id))changeBlock -{ - changeBlock(self); - - NSString *collection = [[self class] collection]; - id latestInstance = [transaction objectForKey:self.uniqueId inCollection:collection]; - if (latestInstance) { - changeBlock(latestInstance); - [latestInstance saveWithTransaction:transaction]; - } -} - -#pragma mark Reload - -- (void)reload -{ - [self.dbReadConnection readWithBlock:^(YapDatabaseReadTransaction *_Nonnull transaction) { - [self reloadWithTransaction:transaction]; - }]; -} - -- (void)reloadWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - [self reloadWithTransaction:transaction ignoreMissing:NO]; -} - -- (void)reloadWithTransaction:(YapDatabaseReadTransaction *)transaction ignoreMissing:(BOOL)ignoreMissing -{ - TSYapDatabaseObject *latest = [[self class] fetchObjectWithUniqueID:self.uniqueId transaction:transaction]; - if (!latest) { - return; - } - - [self setValuesForKeysWithDictionary:latest.dictionaryValue]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionUtilitiesKit/Meta/SessionUtilitiesKit.h b/SessionUtilitiesKit/Meta/SessionUtilitiesKit.h index fc889a5b2..4e92094f7 100644 --- a/SessionUtilitiesKit/Meta/SessionUtilitiesKit.h +++ b/SessionUtilitiesKit/Meta/SessionUtilitiesKit.h @@ -15,7 +15,6 @@ FOUNDATION_EXPORT const unsigned char SessionUtilitiesKitVersionString[]; #import #import #import -#import #import #import diff --git a/SignalUtilitiesKit/Configuration.swift b/SignalUtilitiesKit/Configuration.swift index 000a6ef43..a89b6e5d9 100644 --- a/SignalUtilitiesKit/Configuration.swift +++ b/SignalUtilitiesKit/Configuration.swift @@ -1,20 +1,14 @@ import SessionMessagingKit import SessionSnodeKit -extension OWSPrimaryStorage : OWSPrimaryStorageProtocol { } - -@objc(SNConfiguration) -public final class Configuration : NSObject { - - - @objc public static func performMainSetup() { +public enum Configuration { + public static func performMainSetup() { // Need to do this first to ensure the legacy database exists SNUtilitiesKit.configure( - owsPrimaryStorage: OWSPrimaryStorage.shared(), maxFileSize: UInt(Double(FileServerAPIV2.maxFileSize) / FileServerAPIV2.fileSizeORMultiplier) ) - SNMessagingKit.configure(storage: Storage.shared) + SNMessagingKit.configure() SNSnodeKit.configure() } } diff --git a/SignalUtilitiesKit/Database/YapDatabase+Promise.swift b/SignalUtilitiesKit/Database/YapDatabase+Promise.swift deleted file mode 100644 index 49ad9fcc0..000000000 --- a/SignalUtilitiesKit/Database/YapDatabase+Promise.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -import Foundation -import PromiseKit - -public extension YapDatabaseConnection { - - @objc - func readWritePromise(_ block: @escaping (YapDatabaseReadWriteTransaction) -> Void) -> AnyPromise { - return AnyPromise(readWritePromise(block) as Promise) - } - - func readWritePromise(_ block: @escaping (YapDatabaseReadWriteTransaction) -> Void) -> Promise { - return Promise { resolver in - self.asyncReadWrite(block, completionBlock: { resolver.fulfill(()) }) - } - } - - func read(_ block: @escaping (YapDatabaseReadTransaction) throws -> Void) throws { - var errorToRaise: Error? - - read { transaction in - do { - try block(transaction) - } catch { - errorToRaise = error - } - } - - if let errorToRaise = errorToRaise { - throw errorToRaise - } - } - - func readWrite(_ block: @escaping (YapDatabaseReadWriteTransaction) throws -> Void) throws { - var errorToRaise: Error? - - readWrite { transaction in - do { - try block(transaction) - } catch { - errorToRaise = error - } - } - - if let errorToRaise = errorToRaise { - throw errorToRaise - } - } -} diff --git a/SignalUtilitiesKit/Utilities/AppSetup.swift b/SignalUtilitiesKit/Utilities/AppSetup.swift index 021f2a3c6..61a0fc0b3 100644 --- a/SignalUtilitiesKit/Utilities/AppSetup.swift +++ b/SignalUtilitiesKit/Utilities/AppSetup.swift @@ -25,13 +25,10 @@ public enum AppSetup { // All of these "singletons" should have any dependencies used in their // initializers injected. OWSBackgroundTaskManager.shared().observeNotifications() - - let primaryStorage: OWSPrimaryStorage = OWSPrimaryStorage(storage: ()) - OWSPrimaryStorage.protectFiles() - + // AFNetworking (via CFNetworking) spools it's attachments to NSTemporaryDirectory(). - // If you receive a media message while the device is locked, the download will fail if the temporary directory - // is NSFileProtectionComplete + // If you receive a media message while the device is locked, the download will fail if + // the temporary directory is NSFileProtectionComplete let success: Bool = OWSFileSystem.protectFileOrFolder( atPath: NSTemporaryDirectory(), fileProtectionType: .completeUntilFirstUserAuthentication @@ -39,10 +36,8 @@ public enum AppSetup { assert(success) Environment.shared = Environment( - primaryStorage: primaryStorage, reachabilityManager: SSKReachabilityManagerImpl(), audioSession: OWSAudioSession(), - preferences: OWSPreferences(), proximityMonitoringManager: OWSProximityMonitoringManagerImpl(), windowManager: OWSWindowManager(default: ()) ) diff --git a/SignalUtilitiesKit/Utilities/OWSAlerts.swift b/SignalUtilitiesKit/Utilities/OWSAlerts.swift index 5db9ecf7d..caa9d7e6d 100644 --- a/SignalUtilitiesKit/Utilities/OWSAlerts.swift +++ b/SignalUtilitiesKit/Utilities/OWSAlerts.swift @@ -3,6 +3,7 @@ // import Foundation +import SessionUtilitiesKit @objc public class OWSAlerts: NSObject { @@ -93,32 +94,4 @@ import Foundation action.accessibilityIdentifier = "OWSAlerts.\("cancel")" return action } - - @objc - public class func showIOSUpgradeNagIfNecessary() { - // Our min SDK is iOS9, so this will only show for iOS9 users - if #available(iOS 10.0, *) { - return - } - - // Don't show the nag to users who have just launched - // the app for the first time. - guard AppVersion.sharedInstance().lastAppVersion != nil else { - return - } - - if let iOSUpgradeNagDate = Environment.shared.preferences.iOSUpgradeNagDate() { - let kNagFrequencySeconds = 14 * kDayInterval - guard fabs(iOSUpgradeNagDate.timeIntervalSinceNow) > kNagFrequencySeconds else { - return - } - } - - Environment.shared.preferences.setIOSUpgradeNagDate(Date()) - - OWSAlerts.showAlert(title: NSLocalizedString("UPGRADE_IOS_ALERT_TITLE", - comment: "Title for the alert indicating that user should upgrade iOS."), - message: NSLocalizedString("UPGRADE_IOS_ALERT_MESSAGE", - comment: "Message for the alert indicating that user should upgrade iOS.")) - } } From 93b54a3b7d1a518feea33ab7f7e6a26e08d3f52f Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 2 Jun 2022 14:13:07 +1000 Subject: [PATCH 096/157] Added logic for the GarbageCollectionJob Fixed a bug where the GalleryRailView wasn't appearing Fixed a database reentrancy error Fixed a bug where the threadId for migrated attachmentUpload jobs wasn't getting set to the non-legacy version Deleted the buggy overwritten 'delete' methods to avoid confusion Updated the GroupMember table to cascade delete if it's thread is deleted (can't do so for the Closed/Open group but they share the same id conveniently) Updated the 'isRunningTests' to be based on the existence of XCInjectBundleInfo which seems to be more consistent than the old approach --- Session/Meta/MainAppContext.m | 2 +- .../_001_InitialSetupMigration.swift | 8 +- .../Migrations/_002_SetupStandardJobs.swift | 7 +- .../Migrations/_003_YDBToGRDBMigration.swift | 4 +- .../Database/Models/Attachment.swift | 57 +++-- .../Database/Models/ClosedGroup.swift | 10 - .../Database/Models/Interaction.swift | 27 --- .../Models/InteractionAttachment.swift | 21 -- .../Database/Models/LinkPreview.swift | 21 -- .../Database/Models/OpenGroup.swift | 10 - .../Database/Models/Quote.swift | 21 -- .../Database/Models/SessionThread.swift | 16 -- .../Jobs/Types/AttachmentUploadJob.swift | 30 +-- .../Jobs/Types/GarbageCollectionJob.swift | 218 +++++++++++++++++- .../Utilities/ProfileManager.swift | 2 +- .../Database/GRDBStorage.swift | 9 - SessionUtilitiesKit/JobRunner/JobRunner.swift | 7 +- .../Shared Views/GalleryRailView.swift | 7 +- 18 files changed, 280 insertions(+), 197 deletions(-) diff --git a/Session/Meta/MainAppContext.m b/Session/Meta/MainAppContext.m index bfcf801a2..704bf481d 100644 --- a/Session/Meta/MainAppContext.m +++ b/Session/Meta/MainAppContext.m @@ -245,7 +245,7 @@ NSString *const ReportedApplicationStateDidChangeNotification = @"ReportedApplic - (BOOL)isRunningTests { - return getenv("runningTests_dontStartApp"); + return (NSProcessInfo.processInfo.environment[@"XCInjectBundleInto"] != nil); } - (void)setNetworkActivityIndicatorVisible:(BOOL)value diff --git a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift index 345068b11..8084cfcbd 100644 --- a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -152,11 +152,13 @@ enum _001_InitialSetupMigration: Migration { } try db.create(table: GroupMember.self) { t in - // Note: Not adding a "proper" foreign key constraint as this - // table gets used by both 'OpenGroup' and 'ClosedGroup' types + // Note: Since we don't know whether this will be stored against a 'ClosedGroup' or + // an 'OpenGroup' we add the foreign key constraint against the thread itself (which + // shares the same 'id' as the 'groupId') so we can cascade delete automatically t.column(.groupId, .text) .notNull() .indexed() // Quicker querying + .references(SessionThread.self, onDelete: .cascade) // Delete if Thread deleted t.column(.profileId, .text).notNull() t.column(.role, .integer).notNull() } @@ -316,7 +318,7 @@ enum _001_InitialSetupMigration: Migration { t.column(.variant, .integer).notNull() t.column(.title, .text) t.column(.attachmentId, .text) - .references(Attachment.self, onDelete: .setNull) // Clear if attachment deleted + .references(Attachment.self) // Managed via garbage collection t.primaryKey([.url, .timestamp]) } diff --git a/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift index 877693bf5..005506099 100644 --- a/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -48,8 +48,11 @@ enum _002_SetupStandardJobs: Migration { _ = try Job( variant: .garbageCollection, - behaviour: .recurringOnLaunch - ).inserted(db) + behaviour: .recurringOnLaunch, + details: GarbageCollectionJob.Details( + typesToCollect: GarbageCollectionJob.Types.allCases + ) + )?.inserted(db) } GRDBStorage.shared.update(progress: 1, for: self, in: target) // In case this is the last migration diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 9497d4ce1..2b8cac9f7 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -1207,12 +1207,12 @@ enum _003_YDBToGRDBMigration: Migration { SNLog("[Migration Error] attachmentUpload job missing associated MessageSendJob") throw StorageError.migrationFailed } - + let uploadJob: Job? = try Job( failureCount: legacyJob.failureCount, variant: .attachmentUpload, behaviour: .runOnce, - threadId: legacyJob.threadID, + threadId: sendJob.threadId, interactionId: sendJob.interactionId, details: AttachmentUploadJob.Details( messageSendJobId: sendJobId, diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index 43700cc79..e8b08a686 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -219,28 +219,6 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR self.digest = nil self.caption = caption } - - // MARK: - Custom Database Interaction - - public func delete(_ db: Database) throws -> Bool { - // Delete all associated files - if FileManager.default.fileExists(atPath: thumbnailsDirPath) { - try? FileManager.default.removeItem(atPath: thumbnailsDirPath) - } - - if - let legacyThumbnailPath: String = legacyThumbnailPath, - FileManager.default.fileExists(atPath: legacyThumbnailPath) - { - try? FileManager.default.removeItem(atPath: legacyThumbnailPath) - } - - if let originalFilePath: String = originalFilePath { - try? FileManager.default.removeItem(atPath: originalFilePath) - } - - return try performDelete(db) - } } // MARK: - CustomStringConvertible @@ -941,7 +919,7 @@ extension Attachment { extension Attachment { internal func upload( - _ db: Database, + _ db: Database? = nil, using upload: (Data) -> Promise, encrypt: Bool, success: (() -> Void)?, @@ -977,9 +955,19 @@ extension Attachment { digest == nil else { // Save the final upload info - let uploadedAttachment: Attachment? = try? self - .with(state: .uploaded) - .saved(db) + let uploadedAttachment: Attachment? = { + guard let db: Database = db else { + return GRDBStorage.shared.write { db in + try? self + .with(state: .uploaded) + .saved(db) + } + } + + return try? self + .with(state: .uploaded) + .saved(db) + }() guard uploadedAttachment != nil else { SNLog("Couldn't update attachmentUpload job.") @@ -1019,9 +1007,19 @@ extension Attachment { } // Update the attachment to the 'uploading' state - let updatedAttachment: Attachment? = try? processedAttachment - .with(state: .uploading) - .saved(db) + let updatedAttachment: Attachment? = { + guard let db: Database = db else { + return GRDBStorage.shared.write { db in + try? processedAttachment + .with(state: .uploading) + .saved(db) + } + } + + return try? processedAttachment + .with(state: .uploading) + .saved(db) + }() guard updatedAttachment != nil else { SNLog("Couldn't update attachmentUpload job.") @@ -1061,6 +1059,7 @@ extension Attachment { .with(state: .failedUpload) .saved(db) } + failure?(error) } } diff --git a/SessionMessagingKit/Database/Models/ClosedGroup.swift b/SessionMessagingKit/Database/Models/ClosedGroup.swift index d9c087310..dcd4b9e81 100644 --- a/SessionMessagingKit/Database/Models/ClosedGroup.swift +++ b/SessionMessagingKit/Database/Models/ClosedGroup.swift @@ -76,16 +76,6 @@ public struct ClosedGroup: Codable, Identifiable, FetchableRecord, PersistableRe self.name = name self.formationTimestamp = formationTimestamp } - - // MARK: - Custom Database Interaction - - public func delete(_ db: Database) throws -> Bool { - // Delete all 'GroupMember' records associated with this ClosedGroup (can't - // have a proper ForeignKey constraint as 'GroupMember' is reused for the - // 'OpenGroup' table as well) - try request(for: ClosedGroup.members).deleteAll(db) - return try performDelete(db) - } } // MARK: - Mutation diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 12160586a..bef5e5226 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -345,33 +345,6 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu default: break } } - - public func delete(_ db: Database) throws -> Bool { - // If we have a LinkPreview then check if this is the only interaction that has it - // and delete the LinkPreview if so - if linkPreviewUrl != nil { - let interactionAlias: TableAlias = TableAlias() - let numInteractions: Int? = try? Interaction - .aliased(interactionAlias) - .joining( - required: Interaction.linkPreview - .filter(literal: Interaction.linkPreviewFilterLiteral()) - ) - .fetchCount(db) - let tmp = try linkPreview.fetchAll(db) - - if numInteractions == 1 { - try linkPreview.deleteAll(db) - } - } - - // Delete any jobs associated to this interaction - try Job - .filter(Job.Columns.interactionId == id) - .deleteAll(db) - - return try performDelete(db) - } } // MARK: - Mutation diff --git a/SessionMessagingKit/Database/Models/InteractionAttachment.swift b/SessionMessagingKit/Database/Models/InteractionAttachment.swift index 465c124b2..05feb2132 100644 --- a/SessionMessagingKit/Database/Models/InteractionAttachment.swift +++ b/SessionMessagingKit/Database/Models/InteractionAttachment.swift @@ -43,25 +43,4 @@ public struct InteractionAttachment: Codable, Equatable, FetchableRecord, Persis self.interactionId = interactionId self.attachmentId = attachmentId } - - // MARK: - Custom Database Interaction - - public func delete(_ db: Database) throws -> Bool { - // If we have an Attachment then check if this is the only type that is referencing it - // and delete the Attachment if so - let quoteUses: Int? = try? Quote - .select(.attachmentId) - .filter(Quote.Columns.attachmentId == attachmentId) - .fetchCount(db) - let linkPreviewUses: Int? = try? LinkPreview - .select(.attachmentId) - .filter(LinkPreview.Columns.attachmentId == attachmentId) - .fetchCount(db) - - if (quoteUses ?? 0) == 0 && (linkPreviewUses ?? 0) == 0 { - try attachment.deleteAll(db) - } - - return try performDelete(db) - } } diff --git a/SessionMessagingKit/Database/Models/LinkPreview.swift b/SessionMessagingKit/Database/Models/LinkPreview.swift index 9fe7eaa0e..03cb18f66 100644 --- a/SessionMessagingKit/Database/Models/LinkPreview.swift +++ b/SessionMessagingKit/Database/Models/LinkPreview.swift @@ -72,27 +72,6 @@ public struct LinkPreview: Codable, Equatable, Hashable, FetchableRecord, Persis self.title = title self.attachmentId = attachmentId } - - // MARK: - Custom Database Interaction - - public func delete(_ db: Database) throws -> Bool { - // If we have an Attachment then check if this is the only type that is referencing it - // and delete the Attachment if so - if let attachmentId: String = attachmentId { - let interactionUses: Int? = try? InteractionAttachment - .filter(InteractionAttachment.Columns.attachmentId == attachmentId) - .fetchCount(db) - let quoteUses: Int? = try? Quote - .filter(Quote.Columns.attachmentId == attachmentId) - .fetchCount(db) - - if (interactionUses ?? 0) == 0 && (quoteUses ?? 0) == 0 { - try attachment.deleteAll(db) - } - } - - return try performDelete(db) - } } // MARK: - Protobuf diff --git a/SessionMessagingKit/Database/Models/OpenGroup.swift b/SessionMessagingKit/Database/Models/OpenGroup.swift index ddb12ab03..c85aa7dea 100644 --- a/SessionMessagingKit/Database/Models/OpenGroup.swift +++ b/SessionMessagingKit/Database/Models/OpenGroup.swift @@ -104,16 +104,6 @@ public struct OpenGroup: Codable, Identifiable, FetchableRecord, PersistableReco self.userCount = userCount self.infoUpdates = infoUpdates } - - // MARK: - Custom Database Interaction - - public func delete(_ db: Database) throws -> Bool { - // Delete all 'GroupMember' records associated with this OpenGroup (can't - // have a proper ForeignKey constraint as 'GroupMember' is reused for the - // 'ClosedGroup' table as well) - try request(for: OpenGroup.members).deleteAll(db) - return try performDelete(db) - } } // MARK: - Convenience diff --git a/SessionMessagingKit/Database/Models/Quote.swift b/SessionMessagingKit/Database/Models/Quote.swift index 5a867f1de..9ac83853c 100644 --- a/SessionMessagingKit/Database/Models/Quote.swift +++ b/SessionMessagingKit/Database/Models/Quote.swift @@ -74,27 +74,6 @@ public struct Quote: Codable, Equatable, Hashable, FetchableRecord, PersistableR self.body = body self.attachmentId = attachmentId } - - // MARK: - Custom Database Interaction - - public func delete(_ db: Database) throws -> Bool { - // If we have an Attachment then check if this is the only type that is referencing it - // and delete the Attachment if so - if let attachmentId: String = attachmentId { - let interactionUses: Int? = try? InteractionAttachment - .filter(InteractionAttachment.Columns.attachmentId == attachmentId) - .fetchCount(db) - let linkPreviewUses: Int? = try? LinkPreview - .filter(LinkPreview.Columns.attachmentId == attachmentId) - .fetchCount(db) - - if (interactionUses ?? 0) == 0 && (linkPreviewUses ?? 0) == 0 { - try attachment.deleteAll(db) - } - } - - return try performDelete(db) - } } // MARK: - Protobuf diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index 803fe7f22..b37e0fe9f 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -129,22 +129,6 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, db[.hasSavedThread] = true } - - public func delete(_ db: Database) throws -> Bool { - // Delete any jobs associated to this thread - try Job - .filter(Job.Columns.threadId == id) - .deleteAll(db) - - // Delete any GroupMembers associated to this thread - if variant == .closedGroup || variant == .openGroup { - try GroupMember - .filter(GroupMember.Columns.groupId == id) - .deleteAll(db) - } - - return try performDelete(db) - } } // MARK: - Mutation diff --git a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift index f9965ba89..5d9eb1f82 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift @@ -33,21 +33,21 @@ public enum AttachmentUploadJob: JobExecutor { return } - GRDBStorage.shared.writeAsync { db in - attachment.upload( - db, - using: { data in - if let openGroup: OpenGroup = openGroup { - return OpenGroupAPIV2.upload(data, to: openGroup.room, on: openGroup.server) - } - - return FileServerAPIV2.upload(data) - }, - encrypt: (openGroup == nil), - success: { success(job, false) }, - failure: { error in failure(job, error, false) } - ) - } + // Note: In the AttachmentUploadJob we intentionally don't provide our own db instance to prevent reentrancy + // issues when the success/failure closures get called before the upload as the JobRunner will attempt to + // update the state of the job immediately + attachment.upload( + using: { data in + if let openGroup: OpenGroup = openGroup { + return OpenGroupAPIV2.upload(data, to: openGroup.room, on: openGroup.server) + } + + return FileServerAPIV2.upload(data) + }, + encrypt: (openGroup == nil), + success: { success(job, false) }, + failure: { error in failure(job, error, false) } + ) } } diff --git a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift index 0d9bd516f..551f7d7fd 100644 --- a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift +++ b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift @@ -11,6 +11,7 @@ public enum GarbageCollectionJob: JobExecutor { public static var maxFailureCount: Int = -1 public static var requiresThreadId: Bool = false public static let requiresInteractionId: Bool = false + private static let approxSixMonthsInSeconds: TimeInterval = (6 * 30 * 24 * 60 * 60) public static func run( _ job: Job, @@ -26,7 +27,216 @@ public enum GarbageCollectionJob: JobExecutor { return } - failure(job, JobRunnerError.missingRequiredDetails, true) + // If there are no types to collect then complete the job (and never run again - it doesn't do anything) + guard !details.typesToCollect.isEmpty else { + success(job, true) + return + } + + let timestampNow: TimeInterval = Date().timeIntervalSince1970 + var attachmentLocalRelativePaths: Set = [] + var profileAvatarFilenames: Set = [] + + GRDBStorage.shared.writeAsync( + updates: { db in + // Remove any expired controlMessageProcessRecords + if details.typesToCollect.contains(.expiredControlMessageProcessRecords) { + _ = try ControlMessageProcessRecord + .filter(ControlMessageProcessRecord.Columns.serverExpirationTimestamp <= timestampNow) + .deleteAll(db) + } + + // Remove any typing indicators + if details.typesToCollect.contains(.threadTypingIndicators) { + _ = try ThreadTypingIndicator + .deleteAll(db) + } + + // Remove any typing indicators + if details.typesToCollect.contains(.oldOpenGroupMessages) { + let interaction: TypedTableAlias = TypedTableAlias() + let thread: TypedTableAlias = TypedTableAlias() + + try db.execute(literal: """ + DELETE FROM \(Interaction.self) + WHERE \(Column.rowID) IN ( + SELECT \(interaction.alias[Column.rowID]) + FROM \(Interaction.self) + JOIN \(SessionThread.self) ON ( + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.openGroup)")) AND + \(thread[.id]) = \(interaction[.threadId]) + ) + WHERE \(interaction[.timestampMs]) < \(timestampNow - approxSixMonthsInSeconds) + ) + """) + } + + // Orphaned jobs + if details.typesToCollect.contains(.orphanedJobs) { + let job: TypedTableAlias = TypedTableAlias() + let thread: TypedTableAlias = TypedTableAlias() + let interaction: TypedTableAlias = TypedTableAlias() + + try db.execute(literal: """ + DELETE FROM \(Job.self) + WHERE \(Column.rowID) IN ( + SELECT \(job.alias[Column.rowID]) + FROM \(Job.self) + LEFT JOIN \(SessionThread.self) ON \(thread[.id]) = \(job[.threadId]) + LEFT JOIN \(Interaction.self) ON \(interaction[.id]) = \(job[.interactionId]) + WHERE ( + ( + \(job[.threadId]) IS NOT NULL AND + \(thread[.id]) IS NULL + ) OR ( + \(job[.interactionId]) IS NOT NULL AND + \(interaction[.id]) IS NULL + ) + ) + ) + """) + } + + // Orphaned link previews + if details.typesToCollect.contains(.orphanedLinkPreviews) { + let linkPreview: TypedTableAlias = TypedTableAlias() + let interaction: TypedTableAlias = TypedTableAlias() + + try db.execute(literal: """ + DELETE FROM \(LinkPreview.self) + WHERE \(Column.rowID) IN ( + SELECT \(linkPreview.alias[Column.rowID]) + FROM \(LinkPreview.self) + LEFT JOIN \(Interaction.self) ON ( + \(interaction[.linkPreviewUrl]) = \(linkPreview[.url]) AND + \(Interaction.linkPreviewFilterLiteral()) + ) + WHERE \(interaction[.id]) IS NULL + ) + """) + } + + // Orphaned attachments + if details.typesToCollect.contains(.orphanedAttachments) { + let attachment: TypedTableAlias = TypedTableAlias() + let quote: TypedTableAlias = TypedTableAlias() + let linkPreview: TypedTableAlias = TypedTableAlias() + let interactionAttachment: TypedTableAlias = TypedTableAlias() + + try db.execute(literal: """ + DELETE FROM \(Attachment.self) + WHERE \(Column.rowID) IN ( + SELECT \(attachment.alias[Column.rowID]) + FROM \(Attachment.self) + LEFT JOIN \(Quote.self) ON \(quote[.attachmentId]) = \(attachment[.id]) + LEFT JOIN \(LinkPreview.self) ON \(linkPreview[.attachmentId]) = \(attachment[.id]) + LEFT JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id]) + WHERE ( + \(quote[.attachmentId]) IS NULL AND + \(linkPreview[.url]) IS NULL AND + \(interactionAttachment[.attachmentId]) IS NULL + ) + ) + """) + } + + // Orphaned attachment files + if details.typesToCollect.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 profile avatar files + if details.typesToCollect.contains(.orphanedProfileAvatars) { + profileAvatarFilenames = try Profile + .select(.profilePictureFileName) + .filter(Profile.Columns.profilePictureFileName != nil) + .asRequest(of: String.self) + .fetchSet(db) + } + }, + completion: { _, _ in + var deletionErrors: [Error] = [] + + // Orphaned attachment files (actual deletion) + if details.typesToCollect.contains(.orphanedAttachmentFiles) { + // Note: Looks like in order to recursively look through files we need to use the + // enumerator method + let fileEnumerator = FileManager.default.enumerator( + at: URL(fileURLWithPath: Attachment.attachmentsFolder), + includingPropertiesForKeys: nil, + options: .skipsHiddenFiles // Ignore the `.DS_Store` for the simulator + ) + + let allAttachmentFilePaths: Set = (fileEnumerator? + .allObjects + .compactMap { Attachment.localRelativeFilePath(from: ($0 as? URL)?.path) }) + .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) + let directoryNamesContainingContent: [String] = allAttachmentFilePaths + .filter { path -> Bool in path.contains("/") } + .compactMap { path -> String? in path.components(separatedBy: "/").first } + let orphanedAttachmentFiles: Set = allAttachmentFilePaths + .subtracting(attachmentLocalRelativePaths) + .subtracting(directoryNamesContainingContent) + + 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 + do { + try FileManager.default.removeItem( + atPath: URL(fileURLWithPath: Attachment.attachmentsFolder) + .appendingPathComponent(filepath) + .path + ) + } + catch { deletionErrors.append(error) } + } + } + + // Orphaned profile avatar files (actual deletion) + if details.typesToCollect.contains(.orphanedProfileAvatars) { + let allAvatarProfileFilenames: Set = (try? FileManager.default + .contentsOfDirectory(atPath: ProfileManager.sharedDataProfileAvatarsDirPath)) + .defaulting(to: []) + .asSet() + let orphanedAvatarFiles: Set = allAvatarProfileFilenames + .subtracting(profileAvatarFilenames) + + orphanedAvatarFiles.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 + do { + try FileManager.default.removeItem( + atPath: ProfileManager.profileAvatarFilepath(filename: filename) + ) + } + 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 + } + + success(job, false) + } + ) } } @@ -34,12 +244,14 @@ public enum GarbageCollectionJob: JobExecutor { extension GarbageCollectionJob { public enum Types: Codable, CaseIterable { - case oldOpenGroupMessages case expiredControlMessageProcessRecords case threadTypingIndicators + case oldOpenGroupMessages + case orphanedJobs + case orphanedLinkPreviews + case orphanedAttachments case orphanedAttachmentFiles case orphanedProfileAvatars - case orphanedLinkPreviews } public struct Details: Codable { diff --git a/SessionMessagingKit/Utilities/ProfileManager.swift b/SessionMessagingKit/Utilities/ProfileManager.swift index edcdb3f52..809e334ae 100644 --- a/SessionMessagingKit/Utilities/ProfileManager.swift +++ b/SessionMessagingKit/Utilities/ProfileManager.swift @@ -82,7 +82,7 @@ public struct ProfileManager { // MARK: - File Paths - private static let sharedDataProfileAvatarsDirPath: String = { + public static let sharedDataProfileAvatarsDirPath: String = { URL(fileURLWithPath: OWSFileSystem.appSharedDataDirectoryPath()) .appendingPathComponent("ProfileAvatars") .path diff --git a/SessionUtilitiesKit/Database/GRDBStorage.swift b/SessionUtilitiesKit/Database/GRDBStorage.swift index ff3326221..66d8cdf1d 100644 --- a/SessionUtilitiesKit/Database/GRDBStorage.swift +++ b/SessionUtilitiesKit/Database/GRDBStorage.swift @@ -200,20 +200,11 @@ public final class GRDBStorage { return keySpec } catch { - print("RAWR \(error.localizedDescription), \((error as? KeychainStorageError)?.code), \(errSecItemNotFound)") - switch (error, (error as? KeychainStorageError)?.code) { - // TODO: Are there other errors we know about that indicate an invalid keychain? -// errSecNotAvailable: OSStatus { get } /* No keychain is available. You may need to restart your computer. */ -// public var errSecNoSuchKeychain - - //errSecInteractionNotAllowed - case (StorageError.invalidKeySpec, _): // For these cases it means either the keySpec or the keychain has become corrupt so in order to // get back to a "known good state" and behave like a new install we need to reset the storage // and regenerate the key - // TODO: Check what this 'isRunningTests' does (use the approach to check if XCTTestCase exists instead?) if !CurrentAppContext().isRunningTests { // Try to reset app by deleting database. resetAllStorage() diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index 4b29aa631..4f15da4d6 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -846,9 +846,10 @@ private final class JobQueue { GRDBStorage.shared.write { db in guard - !permanentFailure && - maxFailureCount >= 0 && - job.failureCount + 1 < maxFailureCount + !permanentFailure && ( + maxFailureCount < 0 || + job.failureCount + 1 < maxFailureCount + ) else { SNLog("[JobRunner] \(queueContext) \(job.variant) failed permanently\(maxFailureCount >= 0 ? "; too many retries" : "")") diff --git a/SignalUtilitiesKit/Shared Views/GalleryRailView.swift b/SignalUtilitiesKit/Shared Views/GalleryRailView.swift index a4f585135..ef256f75e 100644 --- a/SignalUtilitiesKit/Shared Views/GalleryRailView.swift +++ b/SignalUtilitiesKit/Shared Views/GalleryRailView.swift @@ -205,7 +205,7 @@ public class GalleryRailView: UIView, GalleryRailCellViewDelegate { completion: { [weak self] _ in self?.stackView.arrangedSubviews.forEach { $0.removeFromSuperview() } self?.stackView.frame = oldFrame - self?.stackClippingView.isHidden = true + self?.isHidden = true self?.cellViews = [] } ) @@ -249,11 +249,11 @@ public class GalleryRailView: UIView, GalleryRailCellViewDelegate { self?.updateFocusedItem(focusedItem) self?.stackView.layoutIfNeeded() - self?.stackClippingView.isHidden = false + self?.isHidden = false updatedOldFrame = (self?.stackView.frame) .defaulting(to: oldFrame) - self?.stackView.frame = oldFrame.offsetBy( + self?.stackView.frame = updatedOldFrame.offsetBy( dx: 0, dy: oldFrame.height ) @@ -324,6 +324,7 @@ public class GalleryRailView: UIView, GalleryRailCellViewDelegate { selectedCellView?.setIsSelected(true) self.layoutIfNeeded() + self.stackView.layoutIfNeeded() switch scrollFocusMode { case .keepCentered: From cf1f1b0e1a63e497b1808e94f5998ca9009327db Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 2 Jun 2022 16:32:05 +1000 Subject: [PATCH 097/157] Added code so the 'isUserMentioned' flag should support mentions within open groups Added the new storage methods to MockStorage Fixed an odd bug where the 'isBackgroundPoll' flag could have been coded incorrectly preventing the MessageReceiveJob from getting processed --- Session/Notifications/AppNotifications.swift | 2 +- SessionMessagingKit/Database/TSDatabaseView.m | 2 +- .../Jobs/MessageReceiveJob.swift | 5 ++- .../Messages/Signal/TSIncomingMessage.h | 4 +-- .../Messages/Signal/TSIncomingMessage.m | 36 +++++++++++++++++-- .../Utilities/Sodium+Utilities.swift | 17 +++++++++ .../_TestUtilities/MockStorage.swift | 10 ++++++ .../NSENotificationPresenter.swift | 2 +- 8 files changed, 68 insertions(+), 10 deletions(-) diff --git a/Session/Notifications/AppNotifications.swift b/Session/Notifications/AppNotifications.swift index a39574cce..0698b0118 100644 --- a/Session/Notifications/AppNotifications.swift +++ b/Session/Notifications/AppNotifications.swift @@ -197,7 +197,7 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { // Don't fire the notification if the current user isn't mentioned // and isOnlyNotifyingForMentions is on. - if let groupThread = thread as? TSGroupThread, groupThread.isOnlyNotifyingForMentions && !incomingMessage.isUserMentioned { + if let groupThread = thread as? TSGroupThread, groupThread.isOnlyNotifyingForMentions && !incomingMessage.isUserMentioned(with: transaction) { return } diff --git a/SessionMessagingKit/Database/TSDatabaseView.m b/SessionMessagingKit/Database/TSDatabaseView.m index ec47b53b7..7f78ac4e0 100644 --- a/SessionMessagingKit/Database/TSDatabaseView.m +++ b/SessionMessagingKit/Database/TSDatabaseView.m @@ -140,7 +140,7 @@ NSString *const TSLazyRestoreAttachmentsGroup = @"TSLazyRestoreAttachmentsGroup" YapDatabaseReadTransaction *transaction, NSString *collection, NSString *key, id object) { if ([object isKindOfClass:[TSIncomingMessage class]]) { TSIncomingMessage *message = (TSIncomingMessage *)object; - if (!message.wasRead && message.isUserMentioned) { + if (!message.wasRead && [message isUserMentionedWithTransaction: transaction]) { return message.uniqueThreadId; } } diff --git a/SessionMessagingKit/Jobs/MessageReceiveJob.swift b/SessionMessagingKit/Jobs/MessageReceiveJob.swift index 16827b0de..6b3944b98 100644 --- a/SessionMessagingKit/Jobs/MessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/MessageReceiveJob.swift @@ -31,13 +31,12 @@ public final class MessageReceiveJob : NSObject, Job, NSCoding { // NSObject/NSC // MARK: Coding public init?(coder: NSCoder) { guard let data = coder.decodeObject(forKey: "data") as! Data?, - let id = coder.decodeObject(forKey: "id") as! String?, - let isBackgroundPoll = coder.decodeObject(forKey: "isBackgroundPoll") as! Bool? else { return nil } + let id = coder.decodeObject(forKey: "id") as! String? else { return nil } self.data = data self.serverHash = coder.decodeObject(forKey: "serverHash") as! String? self.openGroupMessageServerID = coder.decodeObject(forKey: "openGroupMessageServerID") as! UInt64? self.openGroupID = coder.decodeObject(forKey: "openGroupID") as! String? - self.isBackgroundPoll = isBackgroundPoll + self.isBackgroundPoll = ((coder.decodeObject(forKey: "isBackgroundPoll") as? Bool) ?? false) self.id = id self.failureCount = coder.decodeObject(forKey: "failureCount") as! UInt? ?? 0 } diff --git a/SessionMessagingKit/Messages/Signal/TSIncomingMessage.h b/SessionMessagingKit/Messages/Signal/TSIncomingMessage.h index c965ff51b..a1aef0933 100644 --- a/SessionMessagingKit/Messages/Signal/TSIncomingMessage.h +++ b/SessionMessagingKit/Messages/Signal/TSIncomingMessage.h @@ -14,8 +14,6 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, readonly) BOOL wasReceivedByUD; -@property (nonatomic, readonly) BOOL isUserMentioned; - @property (nonatomic, readonly, nullable) NSString *notificationIdentifier; - (instancetype)initMessageWithTimestamp:(uint64_t)timestamp @@ -91,6 +89,8 @@ NS_ASSUME_NONNULL_BEGIN - (void)setNotificationIdentifier:(NSString * _Nullable)notificationIdentifier transaction:(YapDatabaseReadWriteTransaction *)transaction; +- (BOOL)isUserMentionedWithTransaction:(YapDatabaseReadTransaction *)transaction; + @end NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Messages/Signal/TSIncomingMessage.m b/SessionMessagingKit/Messages/Signal/TSIncomingMessage.m index a6578df0c..25ece9ca0 100644 --- a/SessionMessagingKit/Messages/Signal/TSIncomingMessage.m +++ b/SessionMessagingKit/Messages/Signal/TSIncomingMessage.m @@ -121,10 +121,42 @@ NS_ASSUME_NONNULL_BEGIN return self.isExpiringMessage; } -- (BOOL)isUserMentioned +- (BOOL)isUserMentionedWithTransaction:(YapDatabaseReadTransaction *)transaction { NSString *userPublicKey = [SNGeneralUtilities getUserPublicKey]; - return (self.body != nil && [self.body containsString:[NSString stringWithFormat:@"@%@", userPublicKey]]) || (self.quotedMessage != nil && [self.quotedMessage.authorId isEqualToString:userPublicKey]); + NSArray *publicKeysToCheck = @[userPublicKey]; + TSThread *thread = [self threadWithTransaction:transaction]; + + if (thread != nil) { + BOOL isOpenGroupThread = (thread.isGroupThread && ((TSGroupThread *)thread).isOpenGroup); + + if (isOpenGroupThread) { + SNOpenGroupV2 *openGroup = [[LKStorage shared] getOpenGroupForThreadID:self.uniqueThreadId]; + + if (openGroup != nil) { + NSString *openGroupPublicKey = [SNBlindingUtils userBlindedIdFor:openGroup.publicKey]; + + if (openGroupPublicKey != nil) { + publicKeysToCheck = [publicKeysToCheck arrayByAddingObject:openGroupPublicKey]; + } + } + } + } + + BOOL userMentioned = false; + + for (NSString *publicKey in publicKeysToCheck) { + userMentioned = ( + (self.body != nil && [self.body containsString:[NSString stringWithFormat:@"@%@", publicKey]]) || + (self.quotedMessage != nil && [self.quotedMessage.authorId isEqualToString:publicKey]) + ); + + if (userMentioned == true) { + break; + } + } + + return userMentioned; } - (void)setNotificationIdentifier:(NSString * _Nullable)notificationIdentifier transaction:(nonnull YapDatabaseReadWriteTransaction *)transaction diff --git a/SessionMessagingKit/Utilities/Sodium+Utilities.swift b/SessionMessagingKit/Utilities/Sodium+Utilities.swift index feddd0df4..de0ea0ac0 100644 --- a/SessionMessagingKit/Utilities/Sodium+Utilities.swift +++ b/SessionMessagingKit/Utilities/Sodium+Utilities.swift @@ -311,3 +311,20 @@ extension AeadXChaCha20Poly1305IetfType { return authenticatedCipherText } } + +// MARK: - Objective-C Support + +@objc public class SNBlindingUtils: NSObject { + @objc public static func userBlindedId(for openGroupPublicKey: String) -> String? { + let sodium: Sodium = Sodium() + + guard let userEd25519KeyPair = Storage.shared.getUserED25519KeyPair() else { + return nil + } + guard let blindedKeyPair = sodium.blindedKeyPair(serverPublicKey: openGroupPublicKey, edKeyPair: userEd25519KeyPair, genericHash: sodium.genericHash) else { + return nil + } + + return SessionId(.blinded, publicKey: blindedKeyPair.publicKey).hexString + } +} diff --git a/SessionMessagingKitTests/_TestUtilities/MockStorage.swift b/SessionMessagingKitTests/_TestUtilities/MockStorage.swift index de0bdcb19..4f6b46c0f 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockStorage.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockStorage.swift @@ -215,4 +215,14 @@ class MockStorage: Mock, SessionMessagingKit func persist(_ stream: TSAttachmentStream, associatedWith tsIncomingMessageID: String, using transaction: Any) { accept(args: [stream, tsIncomingMessageID, transaction]) } + + // MARK: - Calls + + func getReceivedCalls(for publicKey: String, using transaction: Any) -> Set { + return accept(args: [publicKey, transaction]) as! Set + } + + func setReceivedCalls(to receivedCalls: Set, for publicKey: String, using transaction: Any) { + accept(args: [receivedCalls, publicKey, transaction]) + } } diff --git a/SessionNotificationServiceExtension/NSENotificationPresenter.swift b/SessionNotificationServiceExtension/NSENotificationPresenter.swift index c19184a5d..081204ed5 100644 --- a/SessionNotificationServiceExtension/NSENotificationPresenter.swift +++ b/SessionNotificationServiceExtension/NSENotificationPresenter.swift @@ -46,7 +46,7 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { var notificationTitle = senderName if let group = thread as? TSGroupThread { - if group.isOnlyNotifyingForMentions && !incomingMessage.isUserMentioned { + if group.isOnlyNotifyingForMentions && !incomingMessage.isUserMentioned(with: transaction) { // Ignore PNs if the group is set to only notify for mentions return } From af073657a2ed0b7abb17650e4061b4b8f19cbd5e Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 3 Jun 2022 15:47:16 +1000 Subject: [PATCH 098/157] Cleaned up received message handling and a few bugs with duplicate message handling Updated the YDB to GRDB migrations to include some progress when importing swarms & interactions (ie. the slow parts we can't properly show progress for) Changed the MessageReceiveJob into a MessageHandlingJob (when receiving a message we now parse and store everything immediately to avoid a number of weird edge-cases) Fixed a bug where the Poller would drop a Snode when returning from the background because it's last request would generally time out Fixed a few bugs with invalid attachments Added the ability to retry downloading a failed attachment Added back the search results limit --- .../ConversationVC+Interaction.swift | 29 +- .../Content Views/MediaView.swift | 22 +- .../GlobalSearchViewController.swift | 2 - .../MediaGalleryViewModel.swift | 12 +- Session/Notifications/AppNotifications.swift | 16 +- Session/Utilities/BackgroundPoller.swift | 32 +-- .../Migrations/_003_YDBToGRDBMigration.swift | 75 +++--- .../Database/Models/Attachment.swift | 2 +- .../Models/ControlMessageProcessRecord.swift | 9 +- .../Jobs/Types/FailedMessageSendsJob.swift | 2 +- .../Jobs/Types/MessageReceiveJob.swift | 107 +++++--- .../Jobs/Types/MessageSendJob.swift | 51 +--- SessionMessagingKit/Messages/Message.swift | 253 ++++++++++++++++++ .../Errors/MessageReceiverError.swift | 4 +- .../MessageReceiver+Handling.swift | 6 +- .../Sending & Receiving/MessageReceiver.swift | 117 ++++---- .../Sending & Receiving/MessageSender.swift | 3 +- .../Pollers/ClosedGroupPoller.swift | 31 +-- .../Sending & Receiving/Pollers/Poller.swift | 50 ++-- .../SessionThreadViewModel.swift | 4 + .../NotificationServiceExtension.swift | 27 +- .../Migrations/_003_YDBToGRDBMigration.swift | 35 +-- .../Database/LegacyDatabase/SUKLegacy.swift | 2 +- SessionUtilitiesKit/JobRunner/JobRunner.swift | 7 +- SessionUtilitiesKit/Media/NSData+Image.m | 1 + 25 files changed, 579 insertions(+), 320 deletions(-) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 9f7cdec20..945c7f319 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -713,15 +713,34 @@ extension ConversationVC: guard let mediaView = albumView.mediaView(forLocation: locationInAlbumView) else { return } switch mediaView.attachment.state { - case .pendingDownload, .downloading, .uploading: - // TODO: Tapped a failed incoming attachment - break + case .pendingDownload, .downloading, .uploading: break + + // Failed uploads should be handled via the "resend" process instead + case .failedUpload: break - case .failedDownload, .failedUpload: - // TODO: Tapped a failed incoming attachment + case .failedDownload: + let threadId: String = self.viewModel.threadData.threadId + + // Retry downloading the failed attachment + GRDBStorage.shared.writeAsync { db in + JobRunner.add( + db, + job: Job( + variant: .attachmentDownload, + threadId: threadId, + interactionId: cellViewModel.id, + details: AttachmentDownloadJob.Details( + attachmentId: mediaView.attachment.id + ) + ) + ) + } break default: + // Ignore invalid media + guard mediaView.attachment.isValid else { return } + let viewController: UIViewController? = MediaGalleryViewModel.createDetailViewController( for: self.viewModel.threadData.threadId, threadVariant: self.viewModel.threadData.threadVariant, diff --git a/Session/Conversations/Message Cells/Content Views/MediaView.swift b/Session/Conversations/Message Cells/Content Views/MediaView.swift index a22cce4c6..a194e6588 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaView.swift @@ -86,6 +86,10 @@ public class MediaView: UIView { configure(forError: .failed) return } + guard attachment.isValid else { + configure(forError: .invalid) + return + } if attachment.isAnimated { configureForAnimatedImage(attachment: attachment) @@ -144,6 +148,7 @@ public class MediaView: UIView { animatedImageView.layer.minificationFilter = .trilinear animatedImageView.layer.magnificationFilter = .trilinear animatedImageView.backgroundColor = Colors.unimportant + animatedImageView.isHidden = !attachment.isValid addSubview(animatedImageView) animatedImageView.autoPinEdgesToSuperviewEdges() _ = addUploadProgressIfNecessary(animatedImageView) @@ -159,10 +164,7 @@ public class MediaView: UIView { } strongSelf.tryToLoadMedia( loadMediaBlock: { applyMediaBlock in - guard attachment.isValid else { - Logger.warn("Ignoring invalid attachment.") - return - } + guard attachment.isValid else { return } guard let filePath: String = attachment.originalFilePath else { owsFailDebug("Attachment stream missing original file path.") return @@ -200,6 +202,7 @@ public class MediaView: UIView { stillImageView.layer.minificationFilter = .trilinear stillImageView.layer.magnificationFilter = .trilinear stillImageView.backgroundColor = Colors.unimportant + stillImageView.isHidden = !attachment.isValid addSubview(stillImageView) stillImageView.autoPinEdgesToSuperviewEdges() _ = addUploadProgressIfNecessary(stillImageView) @@ -213,10 +216,7 @@ public class MediaView: UIView { } self?.tryToLoadMedia( loadMediaBlock: { applyMediaBlock in - guard attachment.isValid else { - Logger.warn("Ignoring invalid attachment.") - return - } + guard attachment.isValid else { return } attachment.thumbnail( size: .large, @@ -254,6 +254,7 @@ public class MediaView: UIView { stillImageView.layer.minificationFilter = .trilinear stillImageView.layer.magnificationFilter = .trilinear stillImageView.backgroundColor = Colors.unimportant + stillImageView.isHidden = !attachment.isValid addSubview(stillImageView) stillImageView.autoPinEdgesToSuperviewEdges() @@ -276,10 +277,7 @@ public class MediaView: UIView { } self?.tryToLoadMedia( loadMediaBlock: { applyMediaBlock in - guard attachment.isValid else { - Logger.warn("Ignoring invalid attachment.") - return - } + guard attachment.isValid else { return } attachment.thumbnail( size: .medium, diff --git a/Session/Home/GlobalSearch/GlobalSearchViewController.swift b/Session/Home/GlobalSearch/GlobalSearchViewController.swift index 98ee2f928..6d41fe275 100644 --- a/Session/Home/GlobalSearch/GlobalSearchViewController.swift +++ b/Session/Home/GlobalSearch/GlobalSearchViewController.swift @@ -142,7 +142,6 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo let result: Result<[SectionModel], Error>? = GRDBStorage.shared.read { db -> Result<[SectionModel], Error> in do { let userPublicKey: String = getUserHexEncodedPublicKey(db) - let contactsAndGroupsResults: [SessionThreadViewModel] = try SessionThreadViewModel .contactsAndGroupsQuery( userPublicKey: userPublicKey, @@ -150,7 +149,6 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo searchTerm: searchText ) .fetchAll(db) - let messageResults: [SessionThreadViewModel] = try SessionThreadViewModel .messagesQuery( userPublicKey: userPublicKey, diff --git a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift index 22f7249f6..86e2de343 100644 --- a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift +++ b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift @@ -322,13 +322,17 @@ public class MediaGalleryViewModel { .trackingConstantRegion { db -> [Item] in guard let interactionId: Int64 = interactionId else { return [] } + let attachment: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() let interactionAttachment: TypedTableAlias = TypedTableAlias() return try Item .baseQuery( orderSQL: SQL(interactionAttachment[.albumIndex]), - baseFilterSQL: SQL("\(interaction[.id]) = \(interactionId)") + baseFilterSQL: SQL(""" + \(attachment[.isValid]) = true AND + \(interaction[.id]) = \(interactionId) + """) ) .fetchAll(db) } @@ -342,13 +346,17 @@ public class MediaGalleryViewModel { // but to avoid displaying stale data we re-fetch from the database anyway let maybeAlbumInfo: AlbumInfo? = GRDBStorage.shared .read { db -> AlbumInfo in + let attachment: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() let interactionAttachment: TypedTableAlias = TypedTableAlias() let newAlbumData: [Item] = try Item .baseQuery( orderSQL: SQL(interactionAttachment[.albumIndex]), - baseFilterSQL: SQL("\(interaction[.id]) = \(interactionId)") + baseFilterSQL: SQL(""" + \(attachment[.isValid]) = true AND + \(interaction[.id]) = \(interactionId) + """) ) .fetchAll(db) diff --git a/Session/Notifications/AppNotifications.swift b/Session/Notifications/AppNotifications.swift index 48c792314..3c559c85e 100644 --- a/Session/Notifications/AppNotifications.swift +++ b/Session/Notifications/AppNotifications.swift @@ -125,15 +125,13 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { AssertIsOnMainThread() switch notification.object { - case let incomingMessage as TSIncomingMessage: - Logger.debug("canceled notification for message: \(incomingMessage)") - if let identifier = incomingMessage.notificationIdentifier { - cancelNotification(identifier) - } else { - cancelNotifications(threadId: incomingMessage.uniqueThreadId) - } - default: - break + case let interaction as Interaction: + guard interaction.variant == .standardIncoming else { return } + + Logger.debug("canceled notification for message: \(interaction)") + cancelNotifications(identifiers: interaction.notificationIdentifiers) + + default: break } } diff --git a/Session/Utilities/BackgroundPoller.swift b/Session/Utilities/BackgroundPoller.swift index 4f0f0e329..4b4cd5166 100644 --- a/Session/Utilities/BackgroundPoller.swift +++ b/Session/Utilities/BackgroundPoller.swift @@ -73,27 +73,27 @@ public final class BackgroundPoller : NSObject { var threadMessages: [String: [MessageReceiveJob.Details.MessageInfo]] = [:] messages.forEach { message in - guard let envelope = SNProtoEnvelope.from(message) else { return } - - // Extract the threadId and add that to the messageReceive job for - // multi-threading and garbage collection purposes - let threadId: String? = MessageReceiver.extractSenderPublicKey(db, from: envelope) - do { - threadMessages[threadId ?? ""] = (threadMessages[threadId ?? ""] ?? []) - .appending( - MessageReceiveJob.Details.MessageInfo( - data: try envelope.serializedData(), - serverHash: message.info.hash, - serverExpirationTimestamp: (TimeInterval(message.info.expirationDateMs) / 1000) - ) - ) + let processedMessage: ProcessedMessage? = try Message.processRawReceivedMessage(db, rawMessage: message) + let key: String = (processedMessage?.threadId ?? Message.nonThreadMessageId) // Persist the received message after the MessageReceiveJob is created _ = try message.info.saved(db) + threadMessages[key] = (threadMessages[key] ?? []) + .appending(processedMessage?.messageInfo) } catch { - SNLog("Failed to deserialize envelope due to error: \(error).") + 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, + MessageReceiverError.duplicateMessage, + MessageReceiverError.duplicateControlMessage, + MessageReceiverError.selfSend: + break + + default: SNLog("Failed to deserialize envelope due to error: \(error).") + } } } @@ -105,7 +105,7 @@ public final class BackgroundPoller : NSObject { threadId: threadId, details: MessageReceiveJob.Details( messages: threadMessages, - isBackgroundPoll: false + isBackgroundPoll: true ) ) diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 2b8cac9f7..715f99c68 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -188,6 +188,20 @@ enum _003_YDBToGRDBMigration: Migration { SNLog("[Migration Info] \(target.key(with: self)) - Processing Interactions") + /// **Note:** There is no index on the collection column so unfortunately it takes the same amount of time to enumerate through all + /// collections as it does to just get the count of collections, due to this, if the database is very large, importing thecollections can be + /// very slow (~15s with 2,000,000 rows) - we want to show some kind of progress while enumerating so the below code creates a + /// very rought guess of the number of collections based on the file size of the database (this shouldn't affect most users at all) + let roughKbPerRow: CGFloat = 2.25 + let oldDatabaseSizeBytes: CGFloat = (try? FileManager.default + .attributesOfItem(atPath: SUKLegacy.legacyDatabaseFilepath)[.size] + .asType(CGFloat.self)) + .defaulting(to: 0) + let roughNumRows: CGFloat = ((oldDatabaseSizeBytes / 1024) / roughKbPerRow) + let startProgress: CGFloat = 0.04 + let interactionsCompleteProgress: CGFloat = 0.19 + var rowIndex: CGFloat = 0 + transaction.enumerateKeysAndObjects(inCollection: SMKLegacy.interactionCollection) { _, object, _ in guard let interaction: SMKLegacy._DBInteraction = object as? SMKLegacy._DBInteraction else { SNLog("[Migration Error] Unable to process interaction") @@ -197,8 +211,19 @@ enum _003_YDBToGRDBMigration: Migration { interactions[interaction.uniqueThreadId] = (interactions[interaction.uniqueThreadId] ?? []) .appending(interaction) + + rowIndex += 1 + + GRDBStorage.shared.update( + progress: min( + interactionsCompleteProgress, + ((rowIndex / roughNumRows) * (interactionsCompleteProgress - startProgress)) + ), + for: self, + in: target + ) } - GRDBStorage.shared.update(progress: 0.19, for: self, in: target) + GRDBStorage.shared.update(progress: interactionsCompleteProgress, for: self, in: target) // MARK: --Attachments @@ -1066,39 +1091,21 @@ enum _003_YDBToGRDBMigration: Migration { return } - // We need to extract the `threadId` from the legacyJob data as the new - // MessageReceiveJob requires it for multi-threading and garbage collection purposes - guard let envelope: SNProtoEnvelope = try? SNProtoEnvelope.parseData(legacyJob.data) else { + // We have changed how messageReceive jobs work - we now parse the message upon receipt and + // the MessageReceiveJob only does the handling - as a result we need to do the same behaviour + // here so we don't need to support the legacy behaviour + guard let processedMessage: ProcessedMessage = try? Message.processRawReceivedMessage(db, serializedData: legacyJob.data, serverHash: legacyJob.serverHash) else { return } - let threadId: String? - - switch envelope.type { - // For closed group messages the 'groupPublicKey' is stored in the - // 'envelope.source' value and that should be used for the 'threadId' - case .closedGroupMessage: - threadId = envelope.source - break - - default: - threadId = MessageReceiver.extractSenderPublicKey(db, from: envelope) - } - _ = try Job( failureCount: legacyJob.failureCount, variant: .messageReceive, behaviour: .runOnce, nextRunTimestamp: 0, - threadId: threadId, + threadId: processedMessage.threadId, details: MessageReceiveJob.Details( - messages: [ - MessageReceiveJob.Details.MessageInfo( - data: legacyJob.data, - serverHash: legacyJob.serverHash, - serverExpirationTimestamp: (Date().timeIntervalSince1970 + ControlMessageProcessRecord.defaultExpirationSeconds) - ) - ], + messages: [processedMessage.messageInfo], isBackgroundPoll: legacyJob.isBackgroundPoll ) )?.inserted(db) @@ -1238,8 +1245,8 @@ enum _003_YDBToGRDBMigration: Migration { try autoreleasepool { try attachmentDownloadJobs.forEach { legacyJob in guard let interactionId: Int64 = legacyInteractionToIdMap[legacyJob.tsMessageID] else { - SNLog("[Migration Error] attachmentDownload job unable to find interaction") - throw StorageError.migrationFailed + SNLog("[Migration Warning] attachmentDownload job with no interaction found - ignoring") + return } guard processedAttachmentIds.contains(legacyJob.attachmentID) else { SNLog("[Migration Error] attachmentDownload job unable to find attachment") @@ -1422,7 +1429,7 @@ enum _003_YDBToGRDBMigration: Migration { return (attachmentVailidityInfo.isValid, attachmentVailidityInfo.duration) } - if stream.isVideo { + if stream.isVisualMedia { let attachmentVailidityInfo = Attachment.determineValidityAndDuration( contentType: stream.contentType, localRelativeFilePath: processedLocalRelativeFilePath, @@ -1432,10 +1439,6 @@ enum _003_YDBToGRDBMigration: Migration { return (attachmentVailidityInfo.isValid, attachmentVailidityInfo.duration) } - if stream.isVisualMedia { - return (stream.isValidVisualMedia, nil) - } - return (true, nil) }() @@ -1460,7 +1463,13 @@ enum _003_YDBToGRDBMigration: Migration { duration: duration, isValid: isValid, encryptionKey: legacyAttachment.encryptionKey, - digest: (legacyAttachment as? SMKLegacy._AttachmentStream)?.digest, + digest: { + switch legacyAttachment { + case let stream as SMKLegacy._AttachmentStream: return stream.digest + case let pointer as SMKLegacy._AttachmentPointer: return pointer.digest + default: return nil + } + }(), caption: legacyAttachment.caption ).inserted(db) diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index e8b08a686..dc1f7d662 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -662,7 +662,7 @@ extension Attachment { } // Process image attachments - if MIMETypeUtil.isImage(contentType) { + if MIMETypeUtil.isImage(contentType) || MIMETypeUtil.isAnimated(contentType) { return ( NSData.ows_isValidImage(atPath: targetPath, mimeType: contentType), nil diff --git a/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift b/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift index b2ed7024a..2d6e86a65 100644 --- a/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift +++ b/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift @@ -64,19 +64,12 @@ public struct ControlMessageProcessRecord: Codable, FetchableRecord, Persistable public init?( threadId: String, message: Message, - serverExpirationTimestamp: TimeInterval?, - isRetry: Bool = false + serverExpirationTimestamp: TimeInterval? ) { // All `VisibleMessage` values will have an associated `Interaction` so just let // the unique constraints on that table prevent duplicate messages if message is VisibleMessage { return nil } - // TODO: Need to allow duplicates for call messages - - // If the message failed to process and we are retrying then there will already - // be a `ControlMessageProcessRecord`, so return nil to prevent the insertion - // causing a unique constraint violation - if isRetry { return nil } // Allow '.new' and 'encryptionKeyPair' closed group control message duplicates to avoid // the following situation: diff --git a/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift b/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift index 0729b8445..3b6086d3d 100644 --- a/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift +++ b/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift @@ -25,7 +25,7 @@ public enum FailedMessageSendsJob: JobExecutor { .filter(Attachment.Columns.state == Attachment.State.uploading) .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedUpload)) - Logger.debug("Marked \(changeCount) message\(changeCount == 1 ? "" : "s") as failed (\(attachmentChangeCount) upload\(attachmentChangeCount == 1 ? "" : "s") cancelled)") + SNLog("Marked \(changeCount) message\(changeCount == 1 ? "" : "s") as failed (\(attachmentChangeCount) upload\(attachmentChangeCount == 1 ? "" : "s") cancelled)") } success(job, false) diff --git a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift index fc8076d96..a04d0fccb 100644 --- a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift @@ -32,46 +32,27 @@ public enum MessageReceiveJob: JobExecutor { for messageInfo in details.messages { do { - // Note: It generally shouldn't be possible for 'MessageReceiver.parse' to fail - // the main situation where this can happen is when the jobs run out of order (eg. - // a closed group message encrypted with a new key gets processed before the key - // gets added - this shouldn't be as possible with the updated JobRunner) - let isRetry: Bool = (job.failureCount > 0) - let (message, proto) = try MessageReceiver.parse( - db, - data: messageInfo.data, - serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, - isRetry: isRetry - ) - message.serverHash = messageInfo.serverHash - try MessageReceiver.handle( db, - message: message, - associatedWithProto: proto, + message: messageInfo.message, + associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData), openGroupId: nil, isBackgroundPoll: details.isBackgroundPoll ) } catch { - switch error { - // Note: This is the same as the 'MessageReceiverError.duplicateMessage' - // which is not retryable so just skip to the next message to process (no - // longer logging this because all de-duping happens here now rather than - // when parsing as it did previously - this change results in excessive - // logging which isn't useful) - case DatabaseError.SQLITE_CONSTRAINT_UNIQUE: continue - - default: break - } - // If the current message is a permanent failure then override it with the // new error (we want to retry if there is a single non-permanent error) switch error { - // Ignore self-send errors (they will be permanently failed but no need - // to log since we are going to have a lot of the due to the change to the - // de-duping logic) - case MessageReceiverError.selfSend: continue + // Ignore duplicate and self-send errors (these will usually be caught during + // parsing but sometimes can get past and conflict at database insertion - eg. + // for open group messages) we also don't bother logging as it results in + // excessive logging which isn't useful) + case DatabaseError.SQLITE_CONSTRAINT_UNIQUE, + MessageReceiverError.duplicateMessage, + MessageReceiverError.duplicateControlMessage, + MessageReceiverError.selfSend: + break case let receiverError as MessageReceiverError where !receiverError.isRetryable: SNLog("MessageReceiveJob permanently failed message due to error: \(error)") @@ -107,7 +88,7 @@ public enum MessageReceiveJob: JobExecutor { failure(updatedJob, error, true) case .some(let error): - failure(updatedJob, error, false) + failure(updatedJob, error, false) // TODO: Confirm the 'noKeyPair' errors here aren't an issue case .none: success(updatedJob, false) @@ -120,18 +101,64 @@ public enum MessageReceiveJob: JobExecutor { extension MessageReceiveJob { public struct Details: Codable { public struct MessageInfo: Codable { - public let data: Data - public let serverHash: String? - public let serverExpirationTimestamp: TimeInterval? + private enum CodingKeys: String, CodingKey { + case message + case variant + case serializedProtoData + } + + public let message: Message + public let variant: Message.Variant + public let serializedProtoData: Data public init( - data: Data, - serverHash: String?, - serverExpirationTimestamp: TimeInterval? + message: Message, + variant: Message.Variant, + proto: SNProtoContent + ) throws { + self.message = message + self.variant = variant + self.serializedProtoData = try proto.serializedData() + } + + private init( + message: Message, + variant: Message.Variant, + serializedProtoData: Data ) { - self.data = data - self.serverHash = serverHash - self.serverExpirationTimestamp = serverExpirationTimestamp + self.message = message + self.variant = variant + self.serializedProtoData = serializedProtoData + } + + // MARK: - Codable + + public init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + guard let variant: Message.Variant = try? container.decode(Message.Variant.self, forKey: .variant) else { + SNLog("Unable to decode messageReceive job due to missing variant") + throw StorageError.decodingFailed + } + + self = MessageInfo( + message: try variant.decode(from: container, forKey: .message), + variant: variant, + serializedProtoData: try container.decode(Data.self, forKey: .serializedProtoData) + ) + } + + public func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + guard let variant: Message.Variant = Message.Variant(from: message) else { + SNLog("Unable to encode messageReceive job due to unsupported variant") + throw StorageError.objectNotFound + } + + try container.encode(message, forKey: .message) + try container.encode(variant, forKey: .variant) + try container.encode(serializedProtoData, forKey: .serializedProtoData) } } diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift index 51fbe5ef6..44baf85e0 100644 --- a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift @@ -170,31 +170,15 @@ public enum MessageSendJob: JobExecutor { extension MessageSendJob { public struct Details: Codable { - // Note: This approach is less than ideal (since it needs to be manually maintained) but - // I couldn't think of an easy way to support a generic decoded type for the 'message' - // value in the database while using Codable - private static let supportedMessageTypes: [String: Message.Type] = [ - "VisibleMessage": VisibleMessage.self, - - "ReadReceipt": ReadReceipt.self, - "TypingIndicator": TypingIndicator.self, - "ClosedGroupControlMessage": ClosedGroupControlMessage.self, - "DataExtractionNotification": DataExtractionNotification.self, - "ExpirationTimerUpdate": ExpirationTimerUpdate.self, - "ConfigurationMessage": ConfigurationMessage.self, - "UnsendRequest": UnsendRequest.self, - "MessageRequestResponse": MessageRequestResponse.self - ] - private enum CodingKeys: String, CodingKey { - case interactionId case destination - case messageType case message + case variant } public let destination: Message.Destination public let message: Message + public let variant: Message.Variant? // MARK: - Initialization @@ -204,49 +188,36 @@ extension MessageSendJob { ) { self.destination = destination self.message = message + self.variant = Message.Variant(from: message) } + // MARK: - Codable + public init(from decoder: Decoder) throws { let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) - guard let messageType: String = try? container.decode(String.self, forKey: .messageType) else { - Logger.error("Unable to decode messageSend job due to missing messageType") + guard let variant: Message.Variant = try? container.decode(Message.Variant.self, forKey: .variant) else { + SNLog("Unable to decode messageSend job due to missing variant") throw StorageError.decodingFailed } - /// Note: This **MUST** be a `Codable.Type` rather than a `Message.Type` otherwise the decoding will result - /// in a `Message` object being returned rather than the desired subclass - guard let MessageType: Codable.Type = MessageSendJob.Details.supportedMessageTypes[messageType] else { - Logger.error("Unable to decode messageSend job due to unsupported messageType") - throw StorageError.decodingFailed - } - guard let message: Message = try MessageType.decoded(with: container, forKey: .message) as? Message else { - Logger.error("Unable to decode messageSend job due to message conversion issue") - throw StorageError.decodingFailed - } - self = Details( destination: try container.decode(Message.Destination.self, forKey: .destination), - message: message + message: try variant.decode(from: container, forKey: .message) ) } public func encode(to encoder: Encoder) throws { var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) - let messageType: Codable.Type = type(of: message) - let maybeMessageTypeString: String? = MessageSendJob.Details.supportedMessageTypes - .first(where: { _, type in messageType == type })? - .key - - guard let messageTypeString: String = maybeMessageTypeString else { - Logger.error("Unable to encode messageSend job due to unsupported messageType") + guard let variant: Message.Variant = Message.Variant(from: message) else { + SNLog("Unable to encode messageSend job due to unsupported variant") throw StorageError.objectNotFound } try container.encode(destination, forKey: .destination) - try container.encode(messageTypeString, forKey: .messageType) try container.encode(message, forKey: .message) + try container.encode(variant, forKey: .variant) } } } diff --git a/SessionMessagingKit/Messages/Message.swift b/SessionMessagingKit/Messages/Message.swift index aeb747893..187fda7f4 100644 --- a/SessionMessagingKit/Messages/Message.swift +++ b/SessionMessagingKit/Messages/Message.swift @@ -2,6 +2,7 @@ import Foundation import GRDB +import SessionSnodeKit /// Abstract base class for `VisibleMessage` and `ControlMessage`. public class Message: Codable { @@ -76,6 +77,258 @@ public class Message: Codable { } } +// MARK: - Message Parsing/Processing + +public typealias ProcessedMessage = ( + threadId: String?, + proto: SNProtoContent, + messageInfo: MessageReceiveJob.Details.MessageInfo +) + +public extension Message { + static let nonThreadMessageId: String = "NON_THREAD_MESSAGE" + + enum Variant: String, Codable { + case readReceipt + case typingIndicator + case closedGroupControlMessage + case dataExtractionNotification + case expirationTimerUpdate + case configurationMessage + case unsendRequest + case messageRequestResponse + case visibleMessage + + init?(from type: Message) { + switch type { + case is ReadReceipt: self = .readReceipt + case is TypingIndicator: self = .typingIndicator + case is ClosedGroupControlMessage: self = .closedGroupControlMessage + case is DataExtractionNotification: self = .dataExtractionNotification + case is ExpirationTimerUpdate: self = .expirationTimerUpdate + case is ConfigurationMessage: self = .configurationMessage + case is UnsendRequest: self = .unsendRequest + case is MessageRequestResponse: self = .messageRequestResponse + case is VisibleMessage: self = .visibleMessage + default: return nil + } + } + + var messageType: Message.Type { + switch self { + case .readReceipt: return ReadReceipt.self + case .typingIndicator: return TypingIndicator.self + case .closedGroupControlMessage: return ClosedGroupControlMessage.self + case .dataExtractionNotification: return DataExtractionNotification.self + case .expirationTimerUpdate: return ExpirationTimerUpdate.self + case .configurationMessage: return ConfigurationMessage.self + case .unsendRequest: return UnsendRequest.self + case .messageRequestResponse: return MessageRequestResponse.self + case .visibleMessage: return VisibleMessage.self + } + } + + func decode(from container: KeyedDecodingContainer, forKey key: CodingKeys) throws -> Message { + switch self { + case .readReceipt: return try container.decode(ReadReceipt.self, forKey: key) + case .typingIndicator: return try container.decode(TypingIndicator.self, forKey: key) + + case .closedGroupControlMessage: + return try container.decode(ClosedGroupControlMessage.self, forKey: key) + + case .dataExtractionNotification: + return try container.decode(DataExtractionNotification.self, forKey: key) + + case .expirationTimerUpdate: return try container.decode(ExpirationTimerUpdate.self, forKey: key) + case .configurationMessage: return try container.decode(ConfigurationMessage.self, forKey: key) + case .unsendRequest: return try container.decode(UnsendRequest.self, forKey: key) + case .messageRequestResponse: return try container.decode(MessageRequestResponse.self, forKey: key) + case .visibleMessage: return try container.decode(VisibleMessage.self, forKey: key) + } + } + } + + static func createMessageFrom(_ proto: SNProtoContent, sender: String) -> Message? { + // Note: This array is ordered intentionally to ensure the correct types are processed + // and aren't parsed as the wrong type + let prioritisedVariants: [Variant] = [ + .readReceipt, + .typingIndicator, + .closedGroupControlMessage, + .dataExtractionNotification, + .expirationTimerUpdate, + .configurationMessage, + .unsendRequest, + .messageRequestResponse, + .visibleMessage + ] + + return prioritisedVariants + .reduce(nil) { prev, variant in + guard prev == nil else { return prev } + + return variant.messageType.fromProto(proto, sender: sender) + } + } + + static func processRawReceivedMessage( + _ db: Database, + rawMessage: SnodeReceivedMessage + ) throws -> ProcessedMessage? { + guard let envelope = SNProtoEnvelope.from(rawMessage) else { + throw MessageReceiverError.invalidMessage + } + + do { + let processedMessage: ProcessedMessage? = try processRawReceivedMessage( + db, + envelope: envelope, + serverExpirationTimestamp: (TimeInterval(rawMessage.info.expirationDateMs) / 1000), + serverHash: rawMessage.info.hash, + handleClosedGroupKeyUpdateMessages: true + ) + + // 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.duplicateMessage } + + return processedMessage + } + catch { + // If we get 'selfSend' or 'duplicateControlMessage' errors then we still want to insert + // the SnodeReceivedMessageInfo to prevent retrieving and attempting to process the same + // message again (as well as ensure the next poll doesn't retrieve the same message) + switch error { + case MessageReceiverError.selfSend, MessageReceiverError.duplicateControlMessage: + _ = try? rawMessage.info.inserted(db) + break + + default: break + } + + throw error + } + } + + static func processRawReceivedMessage( + _ db: Database, + serializedData: Data, + serverHash: String? + ) throws -> ProcessedMessage? { + guard let envelope = try? SNProtoEnvelope.parseData(serializedData) else { + throw MessageReceiverError.invalidMessage + } + + return try processRawReceivedMessage( + db, + envelope: envelope, + serverExpirationTimestamp: (Date().timeIntervalSince1970 + ControlMessageProcessRecord.defaultExpirationSeconds), + serverHash: serverHash, + handleClosedGroupKeyUpdateMessages: true + ) + } + + /// 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, + envelope: SNProtoEnvelope + ) throws -> ProcessedMessage? { + let processedMessage: ProcessedMessage? = try processRawReceivedMessage( + db, + envelope: envelope, + serverExpirationTimestamp: (Date().timeIntervalSince1970 + ControlMessageProcessRecord.defaultExpirationSeconds), + serverHash: nil, + handleClosedGroupKeyUpdateMessages: false + ) + + return processedMessage + } + + private static func processRawReceivedMessage( + _ db: Database, + envelope: SNProtoEnvelope, + serverExpirationTimestamp: TimeInterval, + serverHash: String?, + // TODO: These + openGroupId: String? = nil, + openGroupMessageServerId: UInt64? = nil, + handleClosedGroupKeyUpdateMessages: Bool + ) throws -> ProcessedMessage? { + let (message, proto, threadId) = try MessageReceiver.parse( + db, + envelope: envelope, + serverExpirationTimestamp: serverExpirationTimestamp, + openGroupId: openGroupId, + openGroupMessageServerId: openGroupMessageServerId + ) + message.serverHash = serverHash + + // Ignore invalid messages and hashes for messages we have previously handled + guard let variant: Message.Variant = Message.Variant(from: message) else { + throw MessageReceiverError.invalidMessage + } + + /// **Note:** We want to immediately handle any `ClosedGroupControlMessage` with the kind `encryptionKeyPair` as + /// we need the keyPair in storage in order to be able to parse and messages which were signed with the new key (also no need to add + /// these as jobs as they will be fully handled in here) + if handleClosedGroupKeyUpdateMessages { + switch message { + case let closedGroupControlMessage as ClosedGroupControlMessage: + switch closedGroupControlMessage.kind { + case .encryptionKeyPair: + try MessageReceiver.handleClosedGroupControlMessage(db, closedGroupControlMessage) + return nil + + default: break + } + + default: break + } + } + + // Prevent ControlMessages from being handled multiple times if not supported + do { + try ControlMessageProcessRecord( + threadId: threadId, + message: message, + serverExpirationTimestamp: serverExpirationTimestamp + )?.insert(db) + } + catch { + // We want to custom handle this + if case DatabaseError.SQLITE_CONSTRAINT_UNIQUE = error { + throw MessageReceiverError.duplicateControlMessage + } + + throw error + } + + return ( + threadId, + proto, + try MessageReceiveJob.Details.MessageInfo( + message: message, + variant: variant, + proto: proto + ) + ) + } +} + // MARK: - Mutation internal extension Message { diff --git a/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift b/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift index 9fcc17398..2d94b8946 100644 --- a/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift +++ b/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift @@ -4,6 +4,7 @@ import Foundation public enum MessageReceiverError: LocalizedError { case duplicateMessage + case duplicateControlMessage case invalidMessage case unknownMessage case unknownEnvelopeType @@ -20,7 +21,7 @@ public enum MessageReceiverError: LocalizedError { public var isRetryable: Bool { switch self { - case .duplicateMessage, .invalidMessage, .unknownMessage, .unknownEnvelopeType, + case .duplicateMessage, .duplicateControlMessage, .invalidMessage, .unknownMessage, .unknownEnvelopeType, .invalidSignature, .noData, .senderBlocked, .noThread, .selfSend, .decryptionFailed: return false @@ -31,6 +32,7 @@ public enum MessageReceiverError: LocalizedError { public var errorDescription: String? { switch self { case .duplicateMessage: return "Duplicate message." + case .duplicateControlMessage: return "Duplicate control message." case .invalidMessage: return "Invalid message." case .unknownMessage: return "Unknown message type." case .unknownEnvelopeType: return "Unknown envelope type." diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index 2a091b7a9..9a679f252 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -924,7 +924,11 @@ extension MessageReceiver { ).insert(db) } catch { - return SNLog("Ignoring duplicate closed group encryption key pair.") + if case DatabaseError.SQLITE_CONSTRAINT_UNIQUE = error { + return SNLog("Ignoring duplicate closed group encryption key pair.") + } + + throw error } SNLog("Received a new closed group encryption key pair.") diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index 312448d0a..c43122d3f 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -10,18 +10,14 @@ public enum MessageReceiver { public static func parse( _ db: Database, - data: Data, + envelope: SNProtoEnvelope, serverExpirationTimestamp: TimeInterval?, - openGroupId: String? = nil, - openGroupMessageServerId: UInt64? = nil, - isRetry: Bool = false - ) throws -> (Message, SNProtoContent) { + openGroupId: String?, + openGroupMessageServerId: UInt64? + ) throws -> (Message, SNProtoContent, String) { let userPublicKey: String = getUserHexEncodedPublicKey() let isOpenGroupMessage: Bool = (openGroupMessageServerId != nil) - // Parse the envelope - let envelope = try SNProtoEnvelope.parseData(data) - // Decrypt the contents guard let ciphertext = envelope.content else { throw MessageReceiverError.noData } @@ -118,69 +114,50 @@ public enum MessageReceiver { } // Parse the message - let message: Message? = { - if let readReceipt = ReadReceipt.fromProto(proto, sender: sender) { return readReceipt } - if let typingIndicator = TypingIndicator.fromProto(proto, sender: sender) { return typingIndicator } - if let closedGroupControlMessage = ClosedGroupControlMessage.fromProto(proto, sender: sender) { return closedGroupControlMessage } - if let dataExtractionNotification = DataExtractionNotification.fromProto(proto, sender: sender) { return dataExtractionNotification } - if let expirationTimerUpdate = ExpirationTimerUpdate.fromProto(proto, sender: sender) { return expirationTimerUpdate } - if let configurationMessage = ConfigurationMessage.fromProto(proto, sender: sender) { return configurationMessage } - if let unsendRequest = UnsendRequest.fromProto(proto, sender: sender) { return unsendRequest } - if let messageRequestResponse = MessageRequestResponse.fromProto(proto, sender: sender) { return messageRequestResponse } - if let visibleMessage = VisibleMessage.fromProto(proto, sender: sender) { return visibleMessage } - return nil - }() - - if let message = message { - // Ignore self sends if needed - if !message.isSelfSendValid { - guard sender != userPublicKey else { throw MessageReceiverError.selfSend } - } - - // Guard against control messages in open groups - if isOpenGroupMessage { - guard message is VisibleMessage else { throw MessageReceiverError.invalidMessage } - } - - // Finish parsing - message.sender = sender - message.recipient = userPublicKey - message.sentTimestamp = envelope.timestamp - message.receivedTimestamp = UInt64((Date().timeIntervalSince1970) * 1000) - message.groupPublicKey = groupPublicKey - message.openGroupServerMessageId = openGroupMessageServerId - - // Validate - var isValid: Bool = message.isValid - if message is VisibleMessage && !isValid && proto.dataMessage?.attachments.isEmpty == false { - isValid = true - } - - guard isValid else { - throw MessageReceiverError.invalidMessage - } - - // Prevent ControlMessages from being handled multiple times if not supported - try ControlMessageProcessRecord( - threadId: { - if let groupPublicKey: String = groupPublicKey { return groupPublicKey } - if let openGroupId: String = openGroupId { return openGroupId } - - switch message { - case let message as VisibleMessage: return (message.syncTarget ?? sender) - case let message as ExpirationTimerUpdate: return (message.syncTarget ?? sender) - default: return sender - } - }(), - message: message, - serverExpirationTimestamp: serverExpirationTimestamp, - isRetry: false - )?.insert(db) - - // Return - return (message, proto) + guard let message: Message = Message.createMessageFrom(proto, sender: sender) else { + throw MessageReceiverError.unknownMessage } - throw MessageReceiverError.unknownMessage + // Ignore self sends if needed + guard message.isSelfSendValid || sender != userPublicKey else { + throw MessageReceiverError.selfSend + } + + // Guard against control messages in open groups + guard !isOpenGroupMessage || message is VisibleMessage else { + throw MessageReceiverError.invalidMessage + } + + // Finish parsing + message.sender = sender + message.recipient = userPublicKey + message.sentTimestamp = envelope.timestamp + message.receivedTimestamp = UInt64((Date().timeIntervalSince1970) * 1000) + message.groupPublicKey = groupPublicKey + message.openGroupServerMessageId = openGroupMessageServerId + + // Validate + var isValid: Bool = message.isValid + if message is VisibleMessage && !isValid && proto.dataMessage?.attachments.isEmpty == false { + isValid = true + } + + guard isValid else { + throw MessageReceiverError.invalidMessage + } + + // Extract the proper threadId for the message + let threadId: String = { + if let groupPublicKey: String = groupPublicKey { return groupPublicKey } + if let openGroupId: String = openGroupId { return openGroupId } + + switch message { + case let message as VisibleMessage: return (message.syncTarget ?? sender) + case let message as ExpirationTimerUpdate: return (message.syncTarget ?? sender) + default: return sender + } + }() + + return (message, proto, threadId) } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index eb6672145..f757e2c29 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -468,8 +468,7 @@ public final class MessageSender { } }(), message: message, - serverExpirationTimestamp: (Date().timeIntervalSince1970 + ControlMessageProcessRecord.defaultExpirationSeconds), - isRetry: false + serverExpirationTimestamp: (Date().timeIntervalSince1970 + ControlMessageProcessRecord.defaultExpirationSeconds) )?.insert(db) // Sync the message if: diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift index 9eadf1d85..2a6ab401c 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift @@ -174,29 +174,22 @@ public final class ClosedGroupPoller: NSObject { var jobDetailMessages: [MessageReceiveJob.Details.MessageInfo] = [] messages.forEach { message in - guard let envelope = SNProtoEnvelope.from(message) else { return } - do { - let serialisedData: Data = try envelope.serializedData() - _ = try message.info.inserted(db) + let processedMessage: ProcessedMessage? = try Message.processRawReceivedMessage(db, rawMessage: message) - // Ignore hashes for messages we have previously handled - guard try SnodeReceivedMessageInfo.filter(SnodeReceivedMessageInfo.Columns.hash == message.info.hash).fetchCount(db) == 1 else { - throw MessageReceiverError.duplicateMessage - } - - jobDetailMessages.append( - MessageReceiveJob.Details.MessageInfo( - data: serialisedData, - serverHash: message.info.hash, - serverExpirationTimestamp: (TimeInterval(message.info.expirationDateMs) / 1000) - ) - ) + jobDetailMessages = jobDetailMessages + .appending(processedMessage?.messageInfo) } catch { switch error { - // Ignore duplicate messages - case .SQLITE_CONSTRAINT_UNIQUE, MessageReceiverError.duplicateMessage: break + // 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, + MessageReceiverError.duplicateMessage, + MessageReceiverError.duplicateControlMessage, + MessageReceiverError.selfSend: + break + default: SNLog("Failed to deserialize envelope due to error: \(error).") } } @@ -218,7 +211,7 @@ public final class ClosedGroupPoller: NSObject { ) } - SNLog("Received \(messageCount) new message\(messageCount == 1 ? "" : "s") in closed group with public key: \(groupPublicKey) (\(messages.count - messageCount) duplicates)") + SNLog("Received \(messageCount) new message\(messageCount == 1 ? "" : "s") in closed group with public key: \(groupPublicKey) (duplicates: \(messages.count - messageCount))") } } .map { _ in } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift index 4aac480a2..bfc130d5e 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift @@ -109,6 +109,9 @@ public final class Poller { if let error = error as? Error, error == .pollLimitReached { self?.pollCount = 0 } + else if UserDefaults.sharedLokiProject?[.isMainAppActive] != true { + // Do nothing when an error gets throws right after returning from the background (happens frequently) + } else { SNLog("Polling \(nextSnode) failed; dropping it and switching to next snode.") SnodeAPI.dropSnodeFromSwarmIfNeeded(nextSnode, publicKey: userPublicKey) @@ -123,7 +126,7 @@ public final class Poller { private func poll(_ snode: Snode, seal longTermSeal: Resolver) -> Promise { guard isPolling.wrappedValue else { return Promise { $0.fulfill(()) } } - let userPublicKey = getUserHexEncodedPublicKey() + let userPublicKey: String = getUserHexEncodedPublicKey() return SnodeAPI.getMessages(from: snode, associatedWith: userPublicKey) .then(on: Threading.pollerQueue) { [weak self] messages -> Promise in @@ -136,43 +139,26 @@ public final class Poller { var threadMessages: [String: [MessageReceiveJob.Details.MessageInfo]] = [:] messages.forEach { message in - guard let envelope = SNProtoEnvelope.from(message) else { return } - - // Extract the threadId and add that to the messageReceive job for - // multi-threading and garbage collection purposes - let threadId: String? = MessageReceiver.extractSenderPublicKey(db, from: envelope) - - if threadId == nil { - // TODO: I assume a configuration message doesn't need a 'threadId' (confirm this and set the 'requiresThreadId' requirement accordingly) - // TODO: Does the configuration message come through here???? - print("RAWR WHAT CASES LETS THIS BE NIL????") - } - do { - let serialisedData: Data = try envelope.serializedData() - _ = try message.info.inserted(db) + let processedMessage: ProcessedMessage? = try Message.processRawReceivedMessage(db, rawMessage: message) + let key: String = (processedMessage?.threadId ?? Message.nonThreadMessageId) - // Ignore hashes for messages we have previously handled - guard try SnodeReceivedMessageInfo.filter(SnodeReceivedMessageInfo.Columns.hash == message.info.hash).fetchCount(db) == 1 else { - throw MessageReceiverError.duplicateMessage } - threadMessages[threadId ?? ""] = (threadMessages[threadId ?? ""] ?? []) - .appending( - MessageReceiveJob.Details.MessageInfo( - data: serialisedData, - serverHash: message.info.hash, - serverExpirationTimestamp: (TimeInterval(message.info.expirationDateMs) / 1000) - ) - ) + threadMessages[key] = (threadMessages[key] ?? []) + .appending(processedMessage?.messageInfo) } catch { switch error { - // Ignore duplicate messages - case .SQLITE_CONSTRAINT_UNIQUE, MessageReceiverError.duplicateMessage: break - - default: - SNLog("Failed to deserialize envelope due to error: \(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, + MessageReceiverError.duplicateMessage, + MessageReceiverError.duplicateControlMessage, + MessageReceiverError.selfSend: + break + + default: SNLog("Failed to deserialize envelope due to error: \(error).") } } } @@ -197,7 +183,7 @@ public final class Poller { } } - SNLog("Received \(messageCount) new message\(messageCount == 1 ? "" : "s") (\(messages.count - messageCount) duplicates)") + SNLog("Received \(messageCount) new message\(messageCount == 1 ? "" : "s") (duplicates: \(messages.count - messageCount))") } self?.pollCount += 1 diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 51c288269..1267a2a71 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -710,6 +710,8 @@ public extension SessionThreadViewModel { // MARK: - Search Queries public extension SessionThreadViewModel { + fileprivate static let searchResultsLimit: Int = 500 + static func searchTermParts(_ searchTerm: String) -> [String] { /// Process the search term in order to extract the parts of the search pattern we want /// @@ -836,6 +838,7 @@ public extension SessionThreadViewModel { ) ORDER BY \(Column.rank), \(interaction[.timestampMs].desc) + LIMIT \(SQL("\(SessionThreadViewModel.searchResultsLimit)")) """ return request.adapted { db in @@ -1194,6 +1197,7 @@ public extension SessionThreadViewModel { \(ViewModel.closedGroupNameKey), \(ViewModel.openGroupNameKey), \(ViewModel.threadIdKey) + LIMIT \(SQL("\(SessionThreadViewModel.searchResultsLimit)")) """ // Construct the actual request diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index 77055d422..b22daa151 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -35,8 +35,11 @@ public final class NotificationServiceExtension : UNNotificationServiceExtension } } let notificationContent = self.notificationContent! - guard let base64EncodedData = notificationContent.userInfo["ENCRYPTED_DATA"] as! String?, let data = Data(base64Encoded: base64EncodedData), - let envelope = try? MessageWrapper.unwrap(data: data), let envelopeAsData = try? envelope.serializedData() else { + guard + let base64EncodedData: String = notificationContent.userInfo["ENCRYPTED_DATA"] as? String, + let data: Data = Data(base64Encoded: base64EncodedData), + let envelope = try? MessageWrapper.unwrap(data: data) + else { return self.handleFailure(for: notificationContent) } @@ -45,14 +48,20 @@ public final class NotificationServiceExtension : UNNotificationServiceExtension // is added to notification center GRDBStorage.shared.write { db in do { - let (message, proto) = try MessageReceiver.parse( - db, - data: envelopeAsData, - serverExpirationTimestamp: (Date().timeIntervalSince1970 + ControlMessageProcessRecord.defaultExpirationSeconds) - ) - switch message { + guard let processedMessage: ProcessedMessage = try Message.processRawReceivedMessageAsNotification(db, envelope: envelope) else { + self.handleFailure(for: notificationContent) + return + } + + switch processedMessage.messageInfo.message { case let visibleMessage as VisibleMessage: - let interactionId: Int64 = try MessageReceiver.handleVisibleMessage(db, message: visibleMessage, associatedWithProto: proto, openGroupId: nil, isBackgroundPoll: false) + let interactionId: Int64 = try MessageReceiver.handleVisibleMessage( + db, + message: visibleMessage, + associatedWithProto: processedMessage.proto, + openGroupId: nil, + isBackgroundPoll: false + ) // Remove the notifications if there is an outgoing messages from a linked device if diff --git a/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 2919396f3..7fad36bc4 100644 --- a/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -78,20 +78,21 @@ enum _003_YDBToGRDBMigration: Migration { // MARK: --Swarms - // Note: There is no index on the collection column so unfortunately it takes the same amount of - // time to enumerate through all collections as it does to just get the count of collections, as - // a result if the database is very large this part can be slow (~15s with 2,000,000 rows) - we - // want to show some kind of progress while doing this enumeration so the below code includes a - // number of rough values to show some kind of progression while the enumeration occurs (most users - // won't run into issues with this at all) - var swarmCollections: Set = [] + /// **Note:** There is no index on the collection column so unfortunately it takes the same amount of time to enumerate through all + /// collections as it does to just get the count of collections, due to this, if the database is very large, importing thecollections can be + /// very slow (~15s with 2,000,000 rows) - we want to show some kind of progress while enumerating so the below code creates a + /// very rought guess of the number of collections based on the file size of the database (this shouldn't affect most users at all) + let roughMbPerCollection: CGFloat = 2.5 + let oldDatabaseSizeBytes: CGFloat = (try? FileManager.default + .attributesOfItem(atPath: SUKLegacy.legacyDatabaseFilepath)[.size] + .asType(CGFloat.self)) + .defaulting(to: 0) + let roughNumCollections: CGFloat = (((oldDatabaseSizeBytes / 1024) / 1024) / roughMbPerCollection) let startProgress: CGFloat = 0.02 let swarmCompleteProgress: CGFloat = 0.90 - let interEnumerationMaxProgress: CGFloat = ((swarmCompleteProgress - startProgress) * 0.8) - let maxCollectionsEstimate: CGFloat = 1000 - let numCollectionsToTriggerProgressUpdate: CGFloat = 20 + var swarmCollections: Set = [] var collectionIndex: CGFloat = 0 - var oldProgress: CGFloat = startProgress + transaction.enumerateCollections { collectionName, _ in if collectionName.starts(with: SSKLegacy.swarmCollectionPrefix) { swarmCollections.insert(collectionName.substring(from: SSKLegacy.swarmCollectionPrefix.count)) @@ -99,10 +100,14 @@ enum _003_YDBToGRDBMigration: Migration { collectionIndex += 1 - if collectionIndex.truncatingRemainder(dividingBy: numCollectionsToTriggerProgressUpdate) == 0 { - oldProgress = (startProgress + (interEnumerationMaxProgress * (collectionIndex / maxCollectionsEstimate))) - GRDBStorage.shared.update(progress: oldProgress, for: self, in: target) - } + GRDBStorage.shared.update( + progress: min( + swarmCompleteProgress, + ((collectionIndex / roughNumCollections) * (swarmCompleteProgress - startProgress)) + ), + for: self, + in: target + ) } GRDBStorage.shared.update(progress: swarmCompleteProgress, for: self, in: target) diff --git a/SessionUtilitiesKit/Database/LegacyDatabase/SUKLegacy.swift b/SessionUtilitiesKit/Database/LegacyDatabase/SUKLegacy.swift index a691334df..26cfa11a4 100644 --- a/SessionUtilitiesKit/Database/LegacyDatabase/SUKLegacy.swift +++ b/SessionUtilitiesKit/Database/LegacyDatabase/SUKLegacy.swift @@ -25,7 +25,7 @@ public enum SUKLegacy { // MARK: - Database Functions - private static var legacyDatabaseFilepath: String { + public static var legacyDatabaseFilepath: String { let sharedDirUrl: URL = URL(fileURLWithPath: OWSFileSystem.appSharedDataDirectoryPath()) return sharedDirUrl diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index 4f15da4d6..7b2073f3c 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -63,7 +63,12 @@ public final class JobRunner { ) let messageReceiveQueue: JobQueue = JobQueue( type: .messageReceive, - executionType: .concurrent, // Allow as many jobs to run at once as supported by the device + // Explicitly serial as executing concurrently means message receives getting processed at + // different speeds which can result in: + // • Small batches of messages appearing in the UI before larger batches + // • Closed group messages encrypted with updated keys could start parsing before it's key + // update message has been processed (ie. guaranteed to fail) + executionType: .serial, qos: .default, jobVariants: [ jobVariants.remove(.messageReceive) diff --git a/SessionUtilitiesKit/Media/NSData+Image.m b/SessionUtilitiesKit/Media/NSData+Image.m index dde75950b..9d2747809 100644 --- a/SessionUtilitiesKit/Media/NSData+Image.m +++ b/SessionUtilitiesKit/Media/NSData+Image.m @@ -15,6 +15,7 @@ typedef NS_ENUM(NSInteger, ImageFormat) { ImageFormat_Bmp, }; +// FIXME: Refactor all of these to be in Swift against 'Data' @implementation NSData (Image) + (BOOL)ows_isValidImageAtPath:(NSString *)filePath From 4dced25e85d1e8dce06abf2a93b4b747576a55c6 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 3 Jun 2022 16:44:29 +1000 Subject: [PATCH 099/157] Removed an initial request occurring when pushing the conversation screen (could hold up main thread) Removed the old OWSPreferences file (everything left over seems to be unused) --- Session.xcodeproj/project.pbxproj | 8 - Session/Conversations/ConversationVC.swift | 94 +++---- .../Conversations/ConversationViewModel.swift | 16 +- Session/Meta/Signal-Bridging-Header.h | 1 - .../PrivacySettingsTableViewController.m | 1 - Session/Utilities/BackgroundPoller.swift | 3 +- .../Migrations/_003_YDBToGRDBMigration.swift | 1 + .../Meta/SessionMessagingKit.h | 1 - .../Pollers/ClosedGroupPoller.swift | 3 + .../Sending & Receiving/Pollers/Poller.swift | 3 + .../Utilities/OWSPreferences.h | 67 ----- .../Utilities/OWSPreferences.m | 256 ------------------ .../SignalShareExtension-Bridging-Header.h | 1 - 13 files changed, 52 insertions(+), 403 deletions(-) delete mode 100644 SessionMessagingKit/Utilities/OWSPreferences.h delete mode 100644 SessionMessagingKit/Utilities/OWSPreferences.m diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 87e8381e1..509b65bec 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -191,8 +191,6 @@ B8856DF8256F1633001CE70E /* NSString+SSK.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB12255A580800E217F9 /* NSString+SSK.h */; settings = {ATTRIBUTES = (Public, ); }; }; B8856E09256F1676001CE70E /* UIDevice+featureSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF237255B6D65007E1867 /* UIDevice+featureSupport.swift */; }; B8856E1A256F1700001CE70E /* OWSMath.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB14255A580800E217F9 /* OWSMath.h */; settings = {ATTRIBUTES = (Public, ); }; }; - B8856ECE256F1E58001CE70E /* OWSPreferences.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF308255B6DBE007E1867 /* OWSPreferences.m */; }; - B8856ED7256F1EB4001CE70E /* OWSPreferences.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF2F1255B6DBB007E1867 /* OWSPreferences.h */; settings = {ATTRIBUTES = (Public, ); }; }; B886B4A72398B23E00211ABE /* QRCodeVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B886B4A62398B23E00211ABE /* QRCodeVC.swift */; }; B886B4A92398BA1500211ABE /* QRCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = B886B4A82398BA1500211ABE /* QRCode.swift */; }; B88FA7B826045D100049422F /* OpenGroupAPIV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88FA7B726045D100049422F /* OpenGroupAPIV2.swift */; }; @@ -1286,7 +1284,6 @@ C38EF2E2255B6DB9007E1867 /* OWSScreenLock.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = OWSScreenLock.swift; path = "SignalUtilitiesKit/Screen Lock/OWSScreenLock.swift"; sourceTree = SOURCE_ROOT; }; C38EF2EC255B6DBA007E1867 /* ProximityMonitoringManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ProximityMonitoringManager.swift; path = SessionMessagingKit/Utilities/ProximityMonitoringManager.swift; sourceTree = SOURCE_ROOT; }; C38EF2EF255B6DBB007E1867 /* Weak.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Weak.swift; path = SessionUtilitiesKit/General/Weak.swift; sourceTree = SOURCE_ROOT; }; - C38EF2F1255B6DBB007E1867 /* OWSPreferences.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSPreferences.h; path = SessionMessagingKit/Utilities/OWSPreferences.h; sourceTree = SOURCE_ROOT; }; C38EF2F2255B6DBC007E1867 /* Searcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Searcher.swift; path = SignalUtilitiesKit/Utilities/Searcher.swift; sourceTree = SOURCE_ROOT; }; C38EF2F3255B6DBC007E1867 /* UIImage+OWS.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "UIImage+OWS.swift"; path = "SignalUtilitiesKit/Utilities/UIImage+OWS.swift"; sourceTree = SOURCE_ROOT; }; C38EF2F5255B6DBC007E1867 /* OWSAudioPlayer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSAudioPlayer.h; path = SessionMessagingKit/Utilities/OWSAudioPlayer.h; sourceTree = SOURCE_ROOT; }; @@ -1299,7 +1296,6 @@ C38EF305255B6DBE007E1867 /* OWSFormat.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = OWSFormat.m; path = SignalUtilitiesKit/Utilities/OWSFormat.m; sourceTree = SOURCE_ROOT; }; C38EF306255B6DBE007E1867 /* OWSWindowManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = OWSWindowManager.m; path = SessionMessagingKit/Utilities/OWSWindowManager.m; sourceTree = SOURCE_ROOT; }; C38EF307255B6DBE007E1867 /* UIGestureRecognizer+OWS.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "UIGestureRecognizer+OWS.swift"; path = "SignalUtilitiesKit/Utilities/UIGestureRecognizer+OWS.swift"; sourceTree = SOURCE_ROOT; }; - C38EF308255B6DBE007E1867 /* OWSPreferences.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = OWSPreferences.m; path = SessionMessagingKit/Utilities/OWSPreferences.m; sourceTree = SOURCE_ROOT; }; C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = DeviceSleepManager.swift; path = SessionMessagingKit/Utilities/DeviceSleepManager.swift; sourceTree = SOURCE_ROOT; }; C38EF30A255B6DBE007E1867 /* UIUtil.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = UIUtil.h; path = SignalUtilitiesKit/Utilities/UIUtil.h; sourceTree = SOURCE_ROOT; }; C38EF33F255B6DC5007E1867 /* SheetViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SheetViewController.swift; path = "SignalUtilitiesKit/Shared View Controllers/SheetViewController.swift"; sourceTree = SOURCE_ROOT; }; @@ -2742,8 +2738,6 @@ C38EF281255B6D84007E1867 /* OWSAudioSession.swift */, C33FDB38255A580B00E217F9 /* OWSBackgroundTask.h */, C33FDC1B255A581F00E217F9 /* OWSBackgroundTask.m */, - C38EF2F1255B6DBB007E1867 /* OWSPreferences.h */, - C38EF308255B6DBE007E1867 /* OWSPreferences.m */, FDF0B75D280AAF35004C14C5 /* Preferences.swift */, C38EF2FB255B6DBD007E1867 /* OWSWindowManager.h */, C38EF306255B6DBE007E1867 /* OWSWindowManager.m */, @@ -3434,7 +3428,6 @@ C3C2A6F425539DE700C340D1 /* SessionMessagingKit.h in Headers */, C32C5C46256DCBB2003C73A2 /* AppReadiness.h in Headers */, C32C5FC4256E0209003C73A2 /* OWSBackgroundTask.h in Headers */, - B8856ED7256F1EB4001CE70E /* OWSPreferences.h in Headers */, C32A026C25A801AF000ED5D4 /* NSData+messagePadding.h in Headers */, B8856D72256F1421001CE70E /* OWSWindowManager.h in Headers */, B8856CF7256F105E001CE70E /* OWSAudioPlayer.h in Headers */, @@ -4390,7 +4383,6 @@ FD09799127FD499200936362 /* BoxKeyPair+Utilities.swift in Sources */, FDB4BBC92839BEF000B7C95D /* ProfileManagerError.swift in Sources */, FDF0B7552807C4BB004C14C5 /* Environment.swift in Sources */, - B8856ECE256F1E58001CE70E /* OWSPreferences.m in Sources */, FD3E0C84283B5835002A425C /* SessionThreadViewModel.swift in Sources */, FD09C5EC282B8F18000CE219 /* AttachmentError.swift in Sources */, FD17D79927F40AB800122BE0 /* _003_YDBToGRDBMigration.swift in Sources */, diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index dfdb0b215..7ce4f3e32 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -336,21 +336,16 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers setUpNavBarStyle() navigationItem.titleView = titleView - titleView.update( - with: viewModel.threadData.displayName, - mutedUntilTimestamp: viewModel.threadData.threadMutedUntilTimestamp, - onlyNotifyForMentions: (viewModel.threadData.threadOnlyNotifyForMentions == true), - userCount: viewModel.threadData.userCount - ) - updateNavBarButtons(threadData: viewModel.threadData) + // Note: We need to update the nav bar buttons here (with invalid data) because if we don't the + // nav will be offset incorrectly during the push animation (unfortunately the profile icon still + // doesn't appear until after the animation, I assume it's taking a snapshot or something, but + // there isn't much we can do about that unfortunately) + updateNavBarButtons(threadData: nil) // Constraints view.addSubview(tableView) tableView.pin(to: view) - // Blocked banner - addOrRemoveBlockedBanner(threadIsBlocked: (viewModel.threadData.threadIsBlocked == true)) - // Message requests view & scroll to bottom view.addSubview(scrollButton) view.addSubview(messageRequestView) @@ -366,8 +361,6 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers self.scrollButtonBottomConstraint = scrollButton.pin(.bottom, to: .bottom, of: view, withInset: -16) self.scrollButtonBottomConstraint?.isActive = false // Note: Need to disable this to avoid a conflict with the other bottom constraint self.scrollButtonMessageRequestsBottomConstraint = scrollButton.pin(.bottom, to: .top, of: messageRequestView, withInset: -16) - self.scrollButtonMessageRequestsBottomConstraint?.isActive = (viewModel.threadData.threadIsMessageRequest == true) - self.scrollButtonBottomConstraint?.isActive = (viewModel.threadData.threadIsMessageRequest == false) messageRequestDescriptionLabel.pin(.top, to: .top, of: messageRequestView, withInset: 10) messageRequestDescriptionLabel.pin(.left, to: .left, of: messageRequestView, withInset: 40) @@ -399,7 +392,6 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers unreadCountView.pin(.trailing, to: .trailing, of: unreadCountLabel, withInset: 4) unreadCountView.centerYAnchor.constraint(equalTo: scrollButton.topAnchor).isActive = true unreadCountView.center(.horizontal, in: scrollButton) - updateUnreadCountView(unreadCount: viewModel.threadData.threadUnreadCount) // Notifications NotificationCenter.default.addObserver( @@ -425,14 +417,6 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers name: UIResponder.keyboardWillHideNotification, object: nil ) - - // Draft - if let draft: String = viewModel.threadData.threadMessageDraft, !draft.isEmpty { - snInputView.text = draft - } - - // Update the input state - snInputView.setEnabledMessageTypes(viewModel.threadData.enabledMessageTypes, message: nil) } override func viewWillAppear(_ animated: Bool) { @@ -555,8 +539,17 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers updateNavBarButtons(threadData: updatedThreadData) } - if viewModel.threadData.currentUserIsClosedGroupMember != updatedThreadData.currentUserIsClosedGroupMember { - reloadInputViews() + if initialLoad || viewModel.threadData.threadIsBlocked != updatedThreadData.threadIsBlocked { + addOrRemoveBlockedBanner(threadIsBlocked: (updatedThreadData.threadIsBlocked == true)) + } + + if initialLoad || viewModel.threadData.threadIsMessageRequest != updatedThreadData.threadIsMessageRequest { + scrollButtonMessageRequestsBottomConstraint?.isActive = (updatedThreadData.threadIsMessageRequest == true) + scrollButtonBottomConstraint?.isActive = (updatedThreadData.threadIsMessageRequest == false) + } + + if initialLoad || viewModel.threadData.threadUnreadCount != updatedThreadData.threadUnreadCount { + updateUnreadCountView(unreadCount: updatedThreadData.threadUnreadCount) } if initialLoad || viewModel.threadData.enabledMessageTypes != updatedThreadData.enabledMessageTypes { @@ -566,12 +559,13 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers ) } - if initialLoad || viewModel.threadData.threadIsBlocked != updatedThreadData.threadIsBlocked { - addOrRemoveBlockedBanner(threadIsBlocked: (updatedThreadData.threadIsBlocked == true)) + // Only set the draft content on the initial load + if initialLoad, let draft: String = updatedThreadData.threadMessageDraft, !draft.isEmpty { + snInputView.text = draft } - if initialLoad || viewModel.threadData.threadUnreadCount != updatedThreadData.threadUnreadCount { - updateUnreadCountView(unreadCount: updatedThreadData.threadUnreadCount) + if viewModel.threadData.currentUserIsClosedGroupMember != updatedThreadData.currentUserIsClosedGroupMember { + reloadInputViews() } // Now we have done all the needed diffs, update the viewModel with the latest data @@ -638,7 +632,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers let numItemsInUpdatedData: [Int] = updatedData.map { $0.elements.count } let itemChangeInfo: ItemChangeInfo = { guard - changeset.map { $0.elementInserted.count }.reduce(0, +) > 0, + changeset.map({ $0.elementInserted.count }).reduce(0, +) > 0, let oldSectionIndex: Int = self.viewModel.interactionData.firstIndex(where: { $0.model == .messages }), let newSectionIndex: Int = updatedData.firstIndex(where: { $0.model == .messages }), let newFirstItemIndex: Int = updatedData[newSectionIndex].elements @@ -843,27 +837,25 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers // Scroll to the last unread message if possible; otherwise scroll to the bottom. // When the unread message count is more than the number of view items of a page, // the screen will scroll to the bottom instead of the first unread message - DispatchQueue.main.async { - if let focusedInteractionId: Int64 = self.viewModel.focusedInteractionId { - self.scrollToInteractionIfNeeded(with: focusedInteractionId, isAnimated: false, highlight: true) - } - else if let firstUnreadInteractionId: Int64 = self.viewModel.threadData.threadFirstUnreadInteractionId { - self.scrollToInteractionIfNeeded(with: firstUnreadInteractionId, position: .top, isAnimated: false) - self.unreadCountView.alpha = self.scrollButton.alpha - } - else { - self.scrollToBottom(isAnimated: false) - } - - self.scrollButton.alpha = self.getScrollButtonOpacity() - - // Now that the data has loaded we need to check if either of the "load more" sections are - // visible and trigger them if so - // - // Note: We do it this way as we want to trigger the load behaviour for the first section - // if it has one before trying to trigger the load behaviour for the last section - self.autoLoadNextPageIfNeeded() + if let focusedInteractionId: Int64 = self.viewModel.focusedInteractionId { + self.scrollToInteractionIfNeeded(with: focusedInteractionId, isAnimated: false, highlight: true) } + else if let firstUnreadInteractionId: Int64 = self.viewModel.threadData.threadFirstUnreadInteractionId { + self.scrollToInteractionIfNeeded(with: firstUnreadInteractionId, position: .top, isAnimated: false) + self.unreadCountView.alpha = self.scrollButton.alpha + } + else { + self.scrollToBottom(isAnimated: false) + } + + self.scrollButton.alpha = self.getScrollButtonOpacity() + + // Now that the data has loaded we need to check if either of the "load more" sections are + // visible and trigger them if so + // + // Note: We do it this way as we want to trigger the load behaviour for the first section + // if it has one before trying to trigger the load behaviour for the last section + self.autoLoadNextPageIfNeeded() } private func autoLoadNextPageIfNeeded() { @@ -907,7 +899,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers } } - func updateNavBarButtons(threadData: SessionThreadViewModel) { + func updateNavBarButtons(threadData: SessionThreadViewModel?) { navigationItem.hidesBackButton = isShowingSearchUI if isShowingSearchUI { @@ -915,7 +907,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers navigationItem.rightBarButtonItems = [] } else { - guard threadData.threadRequiresApproval == false else { + guard let threadData: SessionThreadViewModel = threadData, threadData.threadRequiresApproval == false else { // Note: Adding an empty button because without it the title alignment is // busted (Note: The size was taken from the layout inspector for the back // button in Xcode @@ -950,7 +942,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers let rightBarButtonItem: UIBarButtonItem = UIBarButtonItem(customView: profilePictureView) rightBarButtonItem.accessibilityLabel = "Settings button" rightBarButtonItem.isAccessibilityElement = true - + navigationItem.rightBarButtonItem = rightBarButtonItem default: diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 98901985f..4bae5c77e 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -33,21 +33,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { // MARK: - Initialization init?(threadId: String, focusedInteractionId: Int64?) { - let maybeThreadData: SessionThreadViewModel? = GRDBStorage.shared.read { db in - let userPublicKey: String = getUserHexEncodedPublicKey(db) - - return try SessionThreadViewModel - .conversationQuery( - threadId: threadId, - userPublicKey: userPublicKey - ) - .fetchOne(db) - } - - guard let threadData: SessionThreadViewModel = maybeThreadData else { return nil } - self.threadId = threadId - self.threadData = threadData self.focusedInteractionId = focusedInteractionId self.pagedDataObserver = nil @@ -147,7 +133,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { // MARK: - Thread Data /// This value is the current state of the view - public private(set) var threadData: SessionThreadViewModel + public private(set) var threadData: SessionThreadViewModel = 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 diff --git a/Session/Meta/Signal-Bridging-Header.h b/Session/Meta/Signal-Bridging-Header.h index bee17ac82..1f8a14ea7 100644 --- a/Session/Meta/Signal-Bridging-Header.h +++ b/Session/Meta/Signal-Bridging-Header.h @@ -32,7 +32,6 @@ #import #import #import -#import #import #import #import diff --git a/Session/Settings/PrivacySettingsTableViewController.m b/Session/Settings/PrivacySettingsTableViewController.m index 08c3d5197..14e5842fd 100644 --- a/Session/Settings/PrivacySettingsTableViewController.m +++ b/Session/Settings/PrivacySettingsTableViewController.m @@ -6,7 +6,6 @@ #import "Session-Swift.h" #import -#import #import #import diff --git a/Session/Utilities/BackgroundPoller.swift b/Session/Utilities/BackgroundPoller.swift index 4b4cd5166..5044c1224 100644 --- a/Session/Utilities/BackgroundPoller.swift +++ b/Session/Utilities/BackgroundPoller.swift @@ -5,6 +5,7 @@ import GRDB import PromiseKit import SessionSnodeKit import SessionMessagingKit +import SessionUtilitiesKit @objc(LKBackgroundPoller) public final class BackgroundPoller : NSObject { @@ -77,8 +78,6 @@ public final class BackgroundPoller : NSObject { let processedMessage: ProcessedMessage? = try Message.processRawReceivedMessage(db, rawMessage: message) let key: String = (processedMessage?.threadId ?? Message.nonThreadMessageId) - // Persist the received message after the MessageReceiveJob is created - _ = try message.info.saved(db) threadMessages[key] = (threadMessages[key] ?? []) .appending(processedMessage?.messageInfo) } diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 715f99c68..96d56c1d6 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -1245,6 +1245,7 @@ enum _003_YDBToGRDBMigration: Migration { try autoreleasepool { try attachmentDownloadJobs.forEach { legacyJob in guard let interactionId: Int64 = legacyInteractionToIdMap[legacyJob.tsMessageID] else { + // This can happen if an UnsendRequest came before an AttachmentDownloadJob completed SNLog("[Migration Warning] attachmentDownload job with no interaction found - ignoring") return } diff --git a/SessionMessagingKit/Meta/SessionMessagingKit.h b/SessionMessagingKit/Meta/SessionMessagingKit.h index c7ef2d60a..a0fda3d4a 100644 --- a/SessionMessagingKit/Meta/SessionMessagingKit.h +++ b/SessionMessagingKit/Meta/SessionMessagingKit.h @@ -7,5 +7,4 @@ FOUNDATION_EXPORT const unsigned char SessionMessagingKitVersionString[]; #import #import #import -#import #import diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift index 2a6ab401c..07208804e 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift @@ -213,6 +213,9 @@ public final class ClosedGroupPoller: NSObject { SNLog("Received \(messageCount) new message\(messageCount == 1 ? "" : "s") in closed group with public key: \(groupPublicKey) (duplicates: \(messages.count - messageCount))") } + else { + SNLog("Received no new messages in closed group with public key: \(groupPublicKey)") + } } .map { _ in } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift index bfc130d5e..fa95b411d 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift @@ -185,6 +185,9 @@ public final class Poller { SNLog("Received \(messageCount) new message\(messageCount == 1 ? "" : "s") (duplicates: \(messages.count - messageCount))") } + else { + SNLog("Received no new messages") + } self?.pollCount += 1 diff --git a/SessionMessagingKit/Utilities/OWSPreferences.h b/SessionMessagingKit/Utilities/OWSPreferences.h deleted file mode 100644 index 6bc4234fb..000000000 --- a/SessionMessagingKit/Utilities/OWSPreferences.h +++ /dev/null @@ -1,67 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -// Used when migrating logging to NSUserDefaults. -extern NSString *const OWSPreferencesSignalDatabaseCollection; -extern NSString *const OWSPreferencesCallLoggingDidChangeNotification; - -@class YapDatabaseReadWriteTransaction; - -@interface OWSPreferences : NSObject - -#pragma mark - Helpers - -- (nullable id)tryGetValueForKey:(NSString *)key; -- (void)setValueForKey:(NSString *)key toValue:(nullable id)value; -- (void)clear; - -#pragma mark - Specific Preferences - -- (BOOL)hasSentAMessage; -- (void)setHasSentAMessage:(BOOL)enabled; - -- (BOOL)hasDeclinedNoContactsView; -- (void)setHasDeclinedNoContactsView:(BOOL)value; - -- (void)setIOSUpgradeNagDate:(NSDate *)value; -- (nullable NSDate *)iOSUpgradeNagDate; - -- (BOOL)shouldShowUnidentifiedDeliveryIndicators; -- (void)setShouldShowUnidentifiedDeliveryIndicators:(BOOL)value; - -#pragma mark Callkit - -- (BOOL)isSystemCallLogEnabled; -- (void)setIsSystemCallLogEnabled:(BOOL)flag; - -#pragma mark - Legacy CallKit settings - -- (void)applyCallLoggingSettingsForLegacyUsersWithTransaction:(YapDatabaseReadWriteTransaction *)transaction; - -- (BOOL)isCallKitEnabled; -- (void)setIsCallKitEnabled:(BOOL)flag; - -// Returns YES IFF isCallKitEnabled has been set by user. -- (BOOL)isCallKitEnabledSet; - -- (BOOL)isCallKitPrivacyEnabled; -- (void)setIsCallKitPrivacyEnabled:(BOOL)flag; -// Returns YES IFF isCallKitPrivacyEnabled has been set by user. -- (BOOL)isCallKitPrivacySet; - -#pragma mark direct call connectivity (non-TURN) - -- (BOOL)doCallsHideIPAddress; -- (void)setDoCallsHideIPAddress:(BOOL)flag; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Utilities/OWSPreferences.m b/SessionMessagingKit/Utilities/OWSPreferences.m deleted file mode 100644 index 80779d80d..000000000 --- a/SessionMessagingKit/Utilities/OWSPreferences.m +++ /dev/null @@ -1,256 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "OWSPreferences.h" - -NS_ASSUME_NONNULL_BEGIN - -NSString *const OWSPreferencesSignalDatabaseCollection = @"SignalPreferences"; -NSString *const OWSPreferencesCallLoggingDidChangeNotification = @"OWSPreferencesCallLoggingDidChangeNotification"; -NSString *const OWSPreferencesKeyCallKitEnabled = @"CallKitEnabled"; -NSString *const OWSPreferencesKeyCallKitPrivacyEnabled = @"CallKitPrivacyEnabled"; -NSString *const OWSPreferencesKeyCallsHideIPAddress = @"CallsHideIPAddress"; -NSString *const OWSPreferencesKeyHasDeclinedNoContactsView = @"hasDeclinedNoContactsView"; -NSString *const OWSPreferencesKeyShouldShowUnidentifiedDeliveryIndicators - = @"OWSPreferencesKeyShouldShowUnidentifiedDeliveryIndicators"; -NSString *const OWSPreferencesKeyIOSUpgradeNagDate = @"iOSUpgradeNagDate"; -NSString *const OWSPreferencesKeySystemCallLogEnabled = @"OWSPreferencesKeySystemCallLogEnabled"; - -@implementation OWSPreferences - -- (instancetype)init -{ - self = [super init]; - if (!self) { - return self; - } - - return self; -} - -#pragma mark - Helpers - -- (void)clear -{ - [NSUserDefaults removeAll]; -} - -- (nullable id)tryGetValueForKey:(NSString *)key -{ - __block id result; - [LKStorage readWithBlock:^(YapDatabaseReadTransaction *transaction) { - result = [self tryGetValueForKey:key transaction:transaction]; - }]; - return result; -} - -- (nullable id)tryGetValueForKey:(NSString *)key transaction:(YapDatabaseReadTransaction *)transaction -{ - return [transaction objectForKey:key inCollection:OWSPreferencesSignalDatabaseCollection]; -} - -- (void)setValueForKey:(NSString *)key toValue:(nullable id)value -{ - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [self setValueForKey:key toValue:value transaction:transaction]; - }]; -} - -- (void)setValueForKey:(NSString *)key - toValue:(nullable id)value - transaction:(YapDatabaseReadWriteTransaction *)transaction -{ - [transaction setObject:value forKey:key inCollection:OWSPreferencesSignalDatabaseCollection]; -} - -#pragma mark - Specific Preferences - -- (BOOL)hasDeclinedNoContactsView -{ - NSNumber *preference = [self tryGetValueForKey:OWSPreferencesKeyHasDeclinedNoContactsView]; - // Default to NO. - return preference ? [preference boolValue] : NO; -} - -- (void)setHasDeclinedNoContactsView:(BOOL)value -{ - [self setValueForKey:OWSPreferencesKeyHasDeclinedNoContactsView toValue:@(value)]; -} - -- (void)setIOSUpgradeNagDate:(NSDate *)value -{ - [self setValueForKey:OWSPreferencesKeyIOSUpgradeNagDate toValue:value]; -} - -- (nullable NSDate *)iOSUpgradeNagDate -{ - return [self tryGetValueForKey:OWSPreferencesKeyIOSUpgradeNagDate]; -} - -- (BOOL)shouldShowUnidentifiedDeliveryIndicators -{ - NSNumber *preference = [self tryGetValueForKey:OWSPreferencesKeyShouldShowUnidentifiedDeliveryIndicators]; - return preference ? [preference boolValue] : NO; -} - -- (void)setShouldShowUnidentifiedDeliveryIndicators:(BOOL)value -{ - [self setValueForKey:OWSPreferencesKeyShouldShowUnidentifiedDeliveryIndicators toValue:@(value)]; -} - -#pragma mark - Calling - -#pragma mark CallKit - -- (BOOL)isSystemCallLogEnabled -{ - if (@available(iOS 11, *)) { - // do nothing - } else { - return NO; - } - - NSNumber *preference = [self tryGetValueForKey:OWSPreferencesKeySystemCallLogEnabled]; - return preference ? preference.boolValue : YES; -} - -- (void)setIsSystemCallLogEnabled:(BOOL)flag -{ - if (@available(iOS 11, *)) { - // do nothing - } else { - return; - } - - [self setValueForKey:OWSPreferencesKeySystemCallLogEnabled toValue:@(flag)]; -} - -// In iOS 10.2.1, Apple fixed a bug wherein call history was backed up to iCloud. -// -// See: https://support.apple.com/en-us/HT207482 -// -// In iOS 11, Apple introduced a property CXProviderConfiguration.includesCallsInRecents -// that allows us to prevent Signal calls made with CallKit from showing up in the device's -// call history. -// -// Therefore in versions of iOS after 11, we have no need of call privacy. -#pragma mark Legacy CallKit - -// Be a little conservative with system call logging with legacy users, even though it's -// not synced to iCloud, users could be concerned to suddenly see caller names in their -// recent calls list. -- (void)applyCallLoggingSettingsForLegacyUsersWithTransaction:(YapDatabaseReadWriteTransaction *)transaction -{ - NSNumber *_Nullable callKitPreference = - [self tryGetValueForKey:OWSPreferencesKeyCallKitEnabled transaction:transaction]; - BOOL wasUsingCallKit = callKitPreference ? [callKitPreference boolValue] : YES; - - NSNumber *_Nullable callKitPrivacyPreference = - [self tryGetValueForKey:OWSPreferencesKeyCallKitPrivacyEnabled transaction:transaction]; - BOOL wasUsingCallKitPrivacy = callKitPrivacyPreference ? callKitPrivacyPreference.boolValue : YES; - - BOOL shouldLogCallsInRecents = ^{ - if (wasUsingCallKit && !wasUsingCallKitPrivacy) { - // User was using CallKit and explicitly opted in to showing names/numbers, - // so it's OK to continue to show names/numbers in the system recents list. - return YES; - } else { - // User was not previously showing names/numbers in the system - // recents list, so don't opt them in. - return NO; - } - }(); - - [self setValueForKey:OWSPreferencesKeySystemCallLogEnabled - toValue:@(shouldLogCallsInRecents) - transaction:transaction]; - - // We need to reload the callService.callUIAdapter here, but SignalMessaging doesn't know about CallService, so we use - // notifications to decouple the code. This is admittedly awkward, but it only happens once, and the alternative would - // be importing all the call related classes into SignalMessaging. - [[NSNotificationCenter defaultCenter] postNotificationNameAsync:OWSPreferencesCallLoggingDidChangeNotification object:nil]; -} - -- (BOOL)isCallKitEnabled -{ - if (@available(iOS 11, *)) { - return YES; - } - - NSNumber *preference = [self tryGetValueForKey:OWSPreferencesKeyCallKitEnabled]; - return preference ? [preference boolValue] : YES; -} - -- (void)setIsCallKitEnabled:(BOOL)flag -{ - if (@available(iOS 11, *)) { - return; - } - - [self setValueForKey:OWSPreferencesKeyCallKitEnabled toValue:@(flag)]; - // Rev callUIAdaptee to get new setting -} - -- (BOOL)isCallKitEnabledSet -{ - if (@available(iOS 11, *)) { - return NO; - } - - NSNumber *preference = [self tryGetValueForKey:OWSPreferencesKeyCallKitEnabled]; - return preference != nil; -} - -- (BOOL)isCallKitPrivacyEnabled -{ - if (@available(iOS 11, *)) { - return NO; - } - - NSNumber *_Nullable preference = [self tryGetValueForKey:OWSPreferencesKeyCallKitPrivacyEnabled]; - if (preference) { - return [preference boolValue]; - } else { - // Private by default. - return YES; - } -} - -- (void)setIsCallKitPrivacyEnabled:(BOOL)flag -{ - if (@available(iOS 11, *)) { - return; - } - - [self setValueForKey:OWSPreferencesKeyCallKitPrivacyEnabled toValue:@(flag)]; -} - -- (BOOL)isCallKitPrivacySet -{ - if (@available(iOS 11, *)) { - return NO; - } - - NSNumber *preference = [self tryGetValueForKey:OWSPreferencesKeyCallKitPrivacyEnabled]; - return preference != nil; -} - -#pragma mark direct call connectivity (non-TURN) - -// Allow callers to connect directly, when desirable, vs. enforcing TURN only proxy connectivity - -- (BOOL)doCallsHideIPAddress -{ - NSNumber *preference = [self tryGetValueForKey:OWSPreferencesKeyCallsHideIPAddress]; - return preference ? [preference boolValue] : NO; -} - -- (void)setDoCallsHideIPAddress:(BOOL)flag -{ - [self setValueForKey:OWSPreferencesKeyCallsHideIPAddress toValue:@(flag)]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionShareExtension/Meta/SignalShareExtension-Bridging-Header.h b/SessionShareExtension/Meta/SignalShareExtension-Bridging-Header.h index 5cf67e263..dd11e0e98 100644 --- a/SessionShareExtension/Meta/SignalShareExtension-Bridging-Header.h +++ b/SessionShareExtension/Meta/SignalShareExtension-Bridging-Header.h @@ -9,7 +9,6 @@ #import #import #import -#import #import #import #import From eeccfb47d5473ccf2a2eadc973364375651a0797 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 9 Jun 2022 18:37:44 +1000 Subject: [PATCH 100/157] Fixed all of the build errors from merge, migrated Call logic, started idBlinding migration and bug fixes Fixed some broken file paths Fixed a couple of bugs with closed groups Fixed a few migration issues Fixed a bug with the ProfilePictureView in open groups (was including the open parenthesis in the initials) Migrated the Id Blinding changes to work with GRDB Migrated the call logic to work with GRDB Updated the code to work the with hard fork changes --- Podfile.lock | 2 +- Session.xcodeproj/project.pbxproj | 301 +++--- .../Calls/Call Management/SessionCall.swift | 295 +++--- .../SessionCallManager+Action.swift | 35 +- .../SessionCallManager+CXCallController.swift | 28 +- .../SessionCallManager+CXProvider.swift | 34 +- .../Call Management/SessionCallManager.swift | 149 ++- Session/Calls/CallVC.swift | 2 +- .../Views & Modals/CallMissedTipsModal.swift | 34 +- .../Views & Modals/IncomingCallBanner.swift | 11 +- Session/Closed Groups/EditClosedGroupVC.swift | 1 - .../Context Menu/ContextMenuVC+Action.swift | 36 +- .../Context Menu/ContextMenuVC.swift | 3 +- .../ConversationVC+Interaction.swift | 130 ++- Session/Conversations/ConversationVC.swift | 53 +- .../ConversationViewItem+Refactor.swift | 126 --- .../Conversations/ConversationViewModel.swift | 56 +- .../Conversations/Input View/InputView.swift | 2 +- .../Input View/MentionSelectionView.swift | 6 +- .../Message Cells/CallMessageCell.swift | 133 ++- .../Content Views/CallMessageView.swift | 38 +- .../Message Cells/MessageCell.swift | 4 +- .../Message Cells/VisibleMessageCell.swift | 23 +- .../OWSConversationSettingsViewController.h | 2 +- .../OWSConversationSettingsViewController.m | 2 +- .../Views & Modals/CallModal.swift | 25 +- .../ConversationTitleView.swift | 58 +- Session/DMs/NewDMVC.swift | 7 +- .../GlobalSearchViewController.swift | 13 +- Session/Home/HomeVC.swift | 15 +- .../MessageRequestsViewController.swift | 8 +- .../Views/MessageRequestsCell.swift | 0 Session/Meta/AppDelegate.swift | 196 +--- Session/Meta/AppEnvironment.swift | 9 +- Session/Meta/SessionApp.swift | 6 +- Session/Notifications/AppNotifications.swift | 2 +- .../PushRegistrationManager.swift | 39 +- Session/Open Groups/JoinOpenGroupVC.swift | 34 +- .../Open Groups/OpenGroupSuggestionGrid.swift | 6 +- .../PrivacySettingsTableViewController.m | 7 +- Session/Utilities/BackgroundPoller.swift | 129 +-- Session/Utilities/MockDataGenerator.swift | 38 +- .../Calls/CallManagerProtocol.swift | 11 + SessionMessagingKit/Calls/CallMode.swift | 5 + .../Calls/CurrentCallProtocol.swift | 13 + SessionMessagingKit/Calls/EndCallMode.swift | 7 + .../Calls/TurnServerInfo.swift | 32 +- .../Calls/WebRTCSession+DataChannel.swift | 4 + .../Calls/WebRTCSession+MessageHandling.swift | 19 +- SessionMessagingKit/Calls/WebRTCSession.swift | 231 +++-- .../Configuration.swift | 0 .../Database/LegacyDatabase/SMKLegacy.swift | 120 ++- .../_001_InitialSetupMigration.swift | 46 +- .../Migrations/_003_YDBToGRDBMigration.swift | 157 ++- .../Database/Models/Attachment.swift | 18 +- .../Database/Models/BlindedIdLookup.swift | 141 +++ .../Database/Models/Capability.swift | 53 +- .../Models/ControlMessageProcessRecord.swift | 8 + .../Database/Models/Interaction.swift | 72 +- .../Database/Models/OpenGroup.swift | 112 ++- .../File Server/FileServerAPI.swift | 13 +- .../File Server/Types/FSEndpoint.swift | 2 +- .../Jobs/Types/AttachmentDownloadJob.swift | 20 +- .../Jobs/Types/AttachmentUploadJob.swift | 14 +- .../Jobs/Types/GarbageCollectionJob.swift | 91 +- .../Jobs/Types/MessageSendJob.swift | 9 +- .../Jobs/Types/NotifyPushServerJob.swift | 35 +- .../RetrieveDefaultOpenGroupRoomsJob.swift | 17 +- .../Control Messages/CallMessage.swift | 290 ++++-- .../ClosedGroupControlMessage.swift | 2 +- .../ConfigurationMessage+Convenience.swift | 9 +- .../Messages/Message+Destination.swift | 11 +- SessionMessagingKit/Messages/Message.swift | 110 ++- .../VisibleMessage+Profile.swift | 6 +- .../Open Groups/Models/Capabilities.swift | 24 +- .../Open Groups/Models/Room.swift | 4 +- .../Open Groups/Models/SOGSMessage.swift | 1 + .../Open Groups/Models/Server.swift | 46 - .../Open Groups/OpenGroupAPI.swift | 907 +++++++++++------- .../Open Groups/OpenGroupManager.swift | 780 ++++++++------- .../Open Groups/Types/SOGSEndpoint.swift | 10 +- .../Open Groups/Types/SodiumProtocols.swift | 11 +- .../MessageReceiver+Calls.swift | 249 +++++ .../MessageReceiver+ClosedGroups.swift | 16 +- ...essageReceiver+ConfigurationMessages.swift | 139 +++ ...eReceiver+DataExtractionNotification.swift | 24 + .../MessageReceiver+ExpirationTimers.swift | 49 + .../MessageReceiver+MessageRequests.swift | 147 +++ .../MessageReceiver+ReadReceipts.swift | 16 + .../MessageReceiver+TypingIndicators.swift | 30 + .../MessageReceiver+UnsendRequests.swift | 53 + .../MessageReceiver+VisibleMessages.swift | 287 ++++++ .../MessageSender+ClosedGroups.swift | 9 +- .../MessageReceiver+Decryption.swift | 34 +- .../Sending & Receiving/MessageReceiver.swift | 203 +++- .../MessageSender+Convenience.swift | 22 +- .../Sending & Receiving/MessageSender.swift | 32 +- .../Notification+MessageReceiver.swift | 8 + .../Pollers/ClosedGroupPoller.swift | 205 ++-- .../Pollers/OpenGroupPoller.swift | 88 +- .../Sending & Receiving/Pollers/Poller.swift | 2 - .../Shared Models/MessageViewModel.swift | 48 +- .../SessionThreadViewModel.swift | 8 +- .../Utilities/BoxKeyPair+Utilities.swift | 12 - .../Utilities/Data+Utilities.swift | 1 + .../Utilities/Dependencies.swift | 16 +- .../Utilities/Environment.swift | 3 + .../Utilities/Preferences.swift | 20 + .../Utilities/ProfileManager.swift | 12 +- .../Utilities/Promise+Utilities.swift | 1 + .../Utilities/Sodium+Utilities.swift | 60 +- .../_TestUtilities/MockEd25519.swift | 2 +- .../NSENotificationPresenter.swift | 2 +- .../NotificationServiceExtension.swift | 130 +-- SessionSnodeKit/Database/Models/Snode.swift | 2 +- .../Models/SnodeReceivedMessageInfo.swift | 23 +- .../Models/OnionRequestAPIDestination.swift | 18 +- .../Models/OnionRequestAPIError.swift | 80 +- SessionSnodeKit/Models/SnodeAPIError.swift | 34 + .../Models/SnodePoolResponse.swift | 13 + .../Models/SnodeReceivedMessage.swift | 3 +- SessionSnodeKit/Models/SwarmSnode.swift | 2 +- .../OnionRequestAPI+Encryption.swift | 42 +- SessionSnodeKit/OnionRequestAPI.swift | 75 +- SessionSnodeKit/SnodeAPI.swift | 239 +++-- SessionSnodeKit/Utilities/Threading.swift | 4 +- .../Database/GRDBStorage.swift | 12 +- .../Database/Models/Identity.swift | 23 +- .../General/Sodium+Utilities.swift | 42 + .../Profile Pictures/Identicon+ObjC.swift | 8 +- .../Utilities/CommonStrings.swift | 3 - .../Utilities/NoopNotificationsManager.swift | 6 +- 132 files changed, 5405 insertions(+), 2711 deletions(-) delete mode 100644 Session/Conversations/ConversationViewItem+Refactor.swift rename Session/Home/{ => Message Requests}/Views/MessageRequestsCell.swift (100%) rename Configuration.swift => SessionMessagingKit/Configuration.swift (100%) delete mode 100644 SessionMessagingKit/Open Groups/Models/Server.swift delete mode 100644 SessionMessagingKit/Utilities/BoxKeyPair+Utilities.swift diff --git a/Podfile.lock b/Podfile.lock index cb5904815..160625ab4 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -230,6 +230,6 @@ SPEC CHECKSUMS: YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331 ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: 834c7307b7e53560d3b40bb4d54d789739efcd88 +PODFILE CHECKSUM: 386b63ccd9f91d308417daddf35710eadca22e03 COCOAPODS: 1.11.3 diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 9d4c8cf96..5a47d7a75 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -133,7 +133,6 @@ 7B7CB190270FB2150079FF93 /* MiniCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B7CB18F270FB2150079FF93 /* MiniCallView.swift */; }; 7B7CB192271508AD0079FF93 /* CallRingTonePlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B7CB191271508AD0079FF93 /* CallRingTonePlayer.swift */; }; 7B93D06A27CF173D00811CB6 /* MessageRequestsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B93D06927CF173D00811CB6 /* MessageRequestsViewController.swift */; }; - 7B93D06D27CF175800811CB6 /* MessageRequestsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B93D06C27CF175800811CB6 /* MessageRequestsCell.swift */; }; 7B93D07027CF194000811CB6 /* ConfigurationMessage+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B93D06E27CF194000811CB6 /* ConfigurationMessage+Convenience.swift */; }; 7B93D07127CF194000811CB6 /* MessageRequestResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B93D06F27CF194000811CB6 /* MessageRequestResponse.swift */; }; 7B93D07727CF1A8A00811CB6 /* MockDataGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B93D07527CF1A8900811CB6 /* MockDataGenerator.swift */; }; @@ -142,7 +141,6 @@ 7BA6890F27325CE300EFC32F /* SessionCallManager+CXProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BA6890E27325CE300EFC32F /* SessionCallManager+CXProvider.swift */; }; 7BAADFCC27B0EF23007BCF92 /* CallVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BAADFCB27B0EF23007BCF92 /* CallVideoView.swift */; }; 7BAADFCE27B215FE007BCF92 /* UIView+Draggable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BAADFCD27B215FE007BCF92 /* UIView+Draggable.swift */; }; - 7BAF54CE27ACCEEC003D12F8 /* Storage+RecentSearchResults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BAF54CB27ACCEEC003D12F8 /* Storage+RecentSearchResults.swift */; }; 7BAF54CF27ACCEEC003D12F8 /* GlobalSearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BAF54CC27ACCEEC003D12F8 /* GlobalSearchViewController.swift */; }; 7BAF54D027ACCEEC003D12F8 /* EmptySearchResultCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BAF54CD27ACCEEC003D12F8 /* EmptySearchResultCell.swift */; }; 7BAF54D327ACCF01003D12F8 /* ShareAppExtensionContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BAF54D127ACCF01003D12F8 /* ShareAppExtensionContext.swift */; }; @@ -154,7 +152,6 @@ 7BC707F227290ACB002817AD /* SessionCallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BC707F127290ACB002817AD /* SessionCallManager.swift */; }; 7BCD116C27016062006330F1 /* WebRTCSession+DataChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BCD116B27016062006330F1 /* WebRTCSession+DataChannel.swift */; }; 7BD477A827EC39F5004E2822 /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BD477A727EC39F5004E2822 /* Atomic.swift */; }; - 7BD477AA27F15F24004E2822 /* OpenGroupServerIdLookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BD477A927F15F24004E2822 /* OpenGroupServerIdLookup.swift */; }; 7BD477B027F526FF004E2822 /* BlockListUIUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BD477AF27F526FF004E2822 /* BlockListUIUtils.swift */; }; 7BDCFC08242186E700641C39 /* NotificationServiceExtensionContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BDCFC07242186E700641C39 /* NotificationServiceExtensionContext.swift */; }; 7BDCFC0B2421EB7600641C39 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = B6F509951AA53F760068F56A /* Localizable.strings */; }; @@ -216,16 +213,12 @@ B877E24626CA13BA0007970A /* CallVC+Camera.swift in Sources */ = {isa = PBXBuildFile; fileRef = B877E24526CA13BA0007970A /* CallVC+Camera.swift */; }; B8783E9E23EB948D00404FB8 /* UILabel+Interaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8783E9D23EB948D00404FB8 /* UILabel+Interaction.swift */; }; B879D449247E1BE300DB3608 /* PathVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B879D448247E1BE300DB3608 /* PathVC.swift */; }; - B87EF17126367CF800124B3C /* FileServerAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = B87EF17026367CF800124B3C /* FileServerAPI.swift */; }; B87EF18126377A1D00124B3C /* Features.swift in Sources */ = {isa = PBXBuildFile; fileRef = B87EF18026377A1D00124B3C /* Features.swift */; }; - B8856CEE256F1054001CE70E /* OWSAudioPlayer.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2F7255B6DBC007E1867 /* OWSAudioPlayer.m */; }; B8856CF7256F105E001CE70E /* OWSAudioPlayer.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF2F5255B6DBC007E1867 /* OWSAudioPlayer.h */; settings = {ATTRIBUTES = (Public, ); }; }; B8856D08256F10F1001CE70E /* DeviceSleepManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */; }; B8856D11256F112A001CE70E /* OWSAudioSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF281255B6D84007E1867 /* OWSAudioSession.swift */; }; - B8856D1A256F114D001CE70E /* ProximityMonitoringManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2EC255B6DBA007E1867 /* ProximityMonitoringManager.swift */; }; B8856D23256F116B001CE70E /* Weak.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2EF255B6DBB007E1867 /* Weak.swift */; }; B8856D60256F129B001CE70E /* OWSAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8856D5F256F129B001CE70E /* OWSAlerts.swift */; }; - B8856D69256F141F001CE70E /* OWSWindowManager.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF306255B6DBE007E1867 /* OWSWindowManager.m */; }; B8856D72256F1421001CE70E /* OWSWindowManager.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF2FB255B6DBD007E1867 /* OWSWindowManager.h */; settings = {ATTRIBUTES = (Public, ); }; }; B8856D7B256F14F4001CE70E /* UIView+OWS.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF23E255B6D66007E1867 /* UIView+OWS.m */; }; B8856D8D256F1502001CE70E /* UIView+OWS.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF23D255B6D66007E1867 /* UIView+OWS.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -236,8 +229,6 @@ B8856E1A256F1700001CE70E /* OWSMath.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB14255A580800E217F9 /* OWSMath.h */; settings = {ATTRIBUTES = (Public, ); }; }; B886B4A72398B23E00211ABE /* QRCodeVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B886B4A62398B23E00211ABE /* QRCodeVC.swift */; }; B886B4A92398BA1500211ABE /* QRCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = B886B4A82398BA1500211ABE /* QRCode.swift */; }; - B88A1AC725C90A4700E6D421 /* (null) in Sources */ = {isa = PBXBuildFile; }; - B88FA7B826045D100049422F /* OpenGroupAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88FA7B726045D100049422F /* OpenGroupAPI.swift */; }; B88FA7F2260C3EB10049422F /* OpenGroupSuggestionGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88FA7F1260C3EB10049422F /* OpenGroupSuggestionGrid.swift */; }; B88FA7FB26114EA70049422F /* Hex.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88FA7FA26114EA70049422F /* Hex.swift */; }; B893063F2383961A005EAA8E /* ScanQRCodeWrapperVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B893063E2383961A005EAA8E /* ScanQRCodeWrapperVC.swift */; }; @@ -283,12 +274,8 @@ B8FF8EA625C11FEF004D1F22 /* IPv4.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8FF8EA525C11FEF004D1F22 /* IPv4.swift */; }; B9EB5ABD1884C002007CBB57 /* MessageUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B9EB5ABC1884C002007CBB57 /* MessageUI.framework */; }; C2CAA4A9737D865B34560B8C /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionSnodeKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6737124ECBC2DFEE2DD716D3 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionSnodeKit.framework */; }; - C300A5B22554AF9800555489 /* VisibleMessage+Profile.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5B12554AF9800555489 /* VisibleMessage+Profile.swift */; }; - C300A5BD2554B00D00555489 /* ReadReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5BC2554B00D00555489 /* ReadReceipt.swift */; }; C300A5D32554B05A00555489 /* TypingIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5D22554B05A00555489 /* TypingIndicator.swift */; }; - C300A5E72554B07300555489 /* ExpirationTimerUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5E62554B07300555489 /* ExpirationTimerUpdate.swift */; }; C300A5F22554B09800555489 /* MessageSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5F12554B09800555489 /* MessageSender.swift */; }; - C300A5FC2554B0A000555489 /* MessageReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5FB2554B0A000555489 /* MessageReceiver.swift */; }; C300A60D2554B31900555489 /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5CE2553860700C340D1 /* Logging.swift */; }; C302093E25DCBF08001F572D /* MentionSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C302093D25DCBF07001F572D /* MentionSelectionView.swift */; }; C31A6C5A247F214E001123EF /* UIView+Glow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31A6C59247F214E001123EF /* UIView+Glow.swift */; }; @@ -301,26 +288,20 @@ C328254025CA55880062D0A7 /* ContextMenuVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C328253F25CA55880062D0A7 /* ContextMenuVC.swift */; }; C328254925CA60E60062D0A7 /* ContextMenuVC+Action.swift in Sources */ = {isa = PBXBuildFile; fileRef = C328254825CA60E60062D0A7 /* ContextMenuVC+Action.swift */; }; C328255225CA64470062D0A7 /* ContextMenuVC+ActionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C328255125CA64470062D0A7 /* ContextMenuVC+ActionView.swift */; }; - C32A026325A801AA000ED5D4 /* NSData+messagePadding.m in Sources */ = {isa = PBXBuildFile; fileRef = C3A71D4825589FF20043A11F /* NSData+messagePadding.m */; }; - C32A026C25A801AF000ED5D4 /* NSData+messagePadding.h in Headers */ = {isa = PBXBuildFile; fileRef = C3A71D4E25589FF30043A11F /* NSData+messagePadding.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C598A256D0664003C73A2 /* SNProtoEnvelope+Conversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EEF09255B49A8007E1867 /* SNProtoEnvelope+Conversion.swift */; }; C32C599E256DB02B003C73A2 /* TypingIndicators.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA87255A57FC00E217F9 /* TypingIndicators.swift */; }; - C32C5A13256DB7A5003C73A2 /* PushNotificationAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */; }; C32C5A24256DB7DB003C73A2 /* SNUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB6B255A580F00E217F9 /* SNUserDefaults.swift */; }; C32C5A2D256DB849003C73A2 /* LKGroupUtilities.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBE1255A581A00E217F9 /* LKGroupUtilities.m */; }; C32C5A36256DB856003C73A2 /* LKGroupUtilities.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBCA255A581700E217F9 /* LKGroupUtilities.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5A47256DB8F0003C73A2 /* ECKeyPair+Hexadecimal.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA73255A57FA00E217F9 /* ECKeyPair+Hexadecimal.swift */; }; C32C5A48256DB8F0003C73A2 /* BuildConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAA8255A57FF00E217F9 /* BuildConfiguration.swift */; }; - C32C5A76256DBBCF003C73A2 /* SignalAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF224255B6D5D007E1867 /* SignalAttachment.swift */; }; - C32C5A88256DBCF9003C73A2 /* MessageReceiver+Handling.swift in Sources */ = {isa = PBXBuildFile; fileRef = C32C5A87256DBCF9003C73A2 /* MessageReceiver+Handling.swift */; }; + C32C5A88256DBCF9003C73A2 /* MessageReceiver+ClosedGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = C32C5A87256DBCF9003C73A2 /* MessageReceiver+ClosedGroups.swift */; }; C32C5B48256DC211003C73A2 /* NSNotificationCenter+OWS.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB6C255A580F00E217F9 /* NSNotificationCenter+OWS.m */; }; C32C5B51256DC219003C73A2 /* NSNotificationCenter+OWS.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB3B255A580B00E217F9 /* NSNotificationCenter+OWS.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5C3D256DCBAF003C73A2 /* AppReadiness.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB75255A581000E217F9 /* AppReadiness.m */; }; C32C5C46256DCBB2003C73A2 /* AppReadiness.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB01255A580700E217F9 /* AppReadiness.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C32C5D19256DD493003C73A2 /* LinkPreviewDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBA8255A581500E217F9 /* LinkPreviewDraft.swift */; }; C32C5D83256DD5B6003C73A2 /* SSKKeychainStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBBC255A581600E217F9 /* SSKKeychainStorage.swift */; }; C32C5DBF256DD743003C73A2 /* ClosedGroupPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB34255A580B00E217F9 /* ClosedGroupPoller.swift */; }; - C32C5DC0256DD743003C73A2 /* Poller.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB3A255A580B00E217F9 /* Poller.swift */; }; C32C5DC9256DD935003C73A2 /* ProxiedContentDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAF2255A580500E217F9 /* ProxiedContentDownloader.swift */; }; C32C5DD2256DD9E5003C73A2 /* LRUCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAFD255A580600E217F9 /* LRUCache.swift */; }; C32C5DDB256DD9FF003C73A2 /* ContentProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB68255A580F00E217F9 /* ContentProxy.swift */; }; @@ -394,13 +375,8 @@ C3471ECB2555356A00297E91 /* MessageSender+Encryption.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3471ECA2555356A00297E91 /* MessageSender+Encryption.swift */; }; C3471ED42555386B00297E91 /* AESGCM.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D72553860B00C340D1 /* AESGCM.swift */; }; C3471F4C25553AB000297E91 /* MessageReceiver+Decryption.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3471F4B25553AB000297E91 /* MessageReceiver+Decryption.swift */; }; - C34A977425A3E34A00852C71 /* ClosedGroupControlMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C34A977325A3E34A00852C71 /* ClosedGroupControlMessage.swift */; }; C34C8F7423A7830B00D82669 /* SpaceMono-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = C34C8F7323A7830A00D82669 /* SpaceMono-Bold.ttf */; }; C352A2FF25574B6300338F3E /* MessageSendJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A2FE25574B6300338F3E /* MessageSendJob.swift */; }; - C352A30925574D8500338F3E /* Message+Destination.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A30825574D8400338F3E /* Message+Destination.swift */; }; - C352A31325574F5200338F3E /* MessageReceiveJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A31225574F5200338F3E /* MessageReceiveJob.swift */; }; - C352A32F2557549C00338F3E /* NotifyPushServerJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A32E2557549C00338F3E /* NotifyPushServerJob.swift */; }; - C352A349255781F400338F3E /* AttachmentDownloadJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A348255781F400338F3E /* AttachmentDownloadJob.swift */; }; C352A35B2557824E00338F3E /* AttachmentUploadJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A35A2557824E00338F3E /* AttachmentUploadJob.swift */; }; C352A36D2557858E00338F3E /* NSTimer+Proxying.m in Sources */ = {isa = PBXBuildFile; fileRef = C352A36C2557858D00338F3E /* NSTimer+Proxying.m */; }; C352A3772557864000338F3E /* NSTimer+Proxying.h in Headers */ = {isa = PBXBuildFile; fileRef = C352A3762557859C00338F3E /* NSTimer+Proxying.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -512,7 +488,6 @@ C3A76A8D25DB83F90074CB90 /* PermissionMissingModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A76A8C25DB83F90074CB90 /* PermissionMissingModal.swift */; }; C3AAFFF225AE99710089E6DD /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3AAFFF125AE99710089E6DD /* AppDelegate.swift */; }; C3ADC66126426688005F1414 /* ShareVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3ADC66026426688005F1414 /* ShareVC.swift */; }; - C3BBE0762554CDA60050F1E3 /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3BBE0752554CDA60050F1E3 /* Configuration.swift */; }; C3BBE0A72554D4DE0050F1E3 /* Promise+Retrying.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D62553860B00C340D1 /* Promise+Retrying.swift */; }; C3BBE0A82554D4DE0050F1E3 /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D92553860B00C340D1 /* JSON.swift */; }; C3BBE0A92554D4DE0050F1E3 /* HTTP.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5BC255385EE00C340D1 /* HTTP.swift */; }; @@ -541,10 +516,7 @@ C3C2A70B25539E1E00C340D1 /* SessionSnodeKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A59F255385C100C340D1 /* SessionSnodeKit.framework */; }; C3C2A74425539EB700C340D1 /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A74325539EB700C340D1 /* Message.swift */; }; C3C2A74D2553A39700C340D1 /* VisibleMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A74C2553A39700C340D1 /* VisibleMessage.swift */; }; - C3C2A7562553A3AB00C340D1 /* VisibleMessage+Quote.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7552553A3AB00C340D1 /* VisibleMessage+Quote.swift */; }; C3C2A75F2553A3C500C340D1 /* VisibleMessage+LinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A75E2553A3C500C340D1 /* VisibleMessage+LinkPreview.swift */; }; - C3C2A7712553A41E00C340D1 /* ControlMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7702553A41E00C340D1 /* ControlMessage.swift */; }; - C3C2A7842553AAF300C340D1 /* SNProto.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7822553AAF200C340D1 /* SNProto.swift */; }; C3C2A7852553AAF300C340D1 /* SessionProtos.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7832553AAF300C340D1 /* SessionProtos.pb.swift */; }; C3C2ABD22553C6C900C340D1 /* Data+SecureRandom.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2ABD12553C6C900C340D1 /* Data+SecureRandom.swift */; }; C3C2AC2E2553CBEB00C340D1 /* String+Trimming.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2AC2D2553CBEB00C340D1 /* String+Trimming.swift */; }; @@ -564,7 +536,6 @@ C3D9E3A4256763DE0040E4F3 /* AppContext.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB8A255A581200E217F9 /* AppContext.h */; settings = {ATTRIBUTES = (Public, ); }; }; C3D9E3BE25676AD70040E4F3 /* (null) in Sources */ = {isa = PBXBuildFile; }; C3D9E3BF25676AD70040E4F3 /* (null) in Sources */ = {isa = PBXBuildFile; }; - C3D9E3C025676AD70040E4F3 /* (null) in Sources */ = {isa = PBXBuildFile; }; C3D9E43125676D3D0040E4F3 /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3D9E43025676D3D0040E4F3 /* Configuration.swift */; }; C3D9E4C02567767F0040E4F3 /* DataSource.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBB6255A581600E217F9 /* DataSource.m */; }; C3D9E4D12567777D0040E4F3 /* OWSMediaUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB22255A580900E217F9 /* OWSMediaUtils.swift */; }; @@ -573,14 +544,10 @@ C3D9E4F4256778AF0040E4F3 /* NSData+Image.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAEF255A580500E217F9 /* NSData+Image.m */; }; C3D9E4FD256778E30040E4F3 /* NSData+Image.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB29255A580A00E217F9 /* NSData+Image.h */; settings = {ATTRIBUTES = (Public, ); }; }; C3D9E50E25677A510040E4F3 /* DataSource.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB54255A580D00E217F9 /* DataSource.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C3D9E52725677DF20040E4F3 /* ThumbnailService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAF1255A580500E217F9 /* ThumbnailService.swift */; }; - C3DA9C0725AE7396008F7C7E /* ConfigurationMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DA9C0625AE7396008F7C7E /* ConfigurationMessage.swift */; }; C3DAB3242480CB2B00725F25 /* SRCopyableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DAB3232480CB2A00725F25 /* SRCopyableLabel.swift */; }; - C3DB66AC260ACA42001EFC55 /* OpenGroupManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DB66AB260ACA42001EFC55 /* OpenGroupManager.swift */; }; C3DB66C3260ACCE6001EFC55 /* OpenGroupPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DB66C2260ACCE6001EFC55 /* OpenGroupPoller.swift */; }; C3DFFAC623E96F0D0058DAF8 /* Sheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DFFAC523E96F0D0058DAF8 /* Sheet.swift */; }; C3E5C2FA251DBABB0040DFFC /* EditClosedGroupVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E5C2F9251DBABB0040DFFC /* EditClosedGroupVC.swift */; }; - C3ECBF7B257056B700EA7FCE /* Threading.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3ECBF7A257056B700EA7FCE /* Threading.swift */; }; C3F0A530255C80BC007BE2A3 /* NoopNotificationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3F0A52F255C80BC007BE2A3 /* NoopNotificationsManager.swift */; }; CEE449BA3596483519120D91 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionMessagingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A8A44E3F8AC9282AC5E6E5A /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionMessagingKit.framework */; }; D2179CFC16BB0B3A0006F3AB /* CoreTelephony.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2179CFB16BB0B3A0006F3AB /* CoreTelephony.framework */; }; @@ -603,7 +570,6 @@ FD078E5827E1B831000769AF /* TestIncomingMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E5727E1B831000769AF /* TestIncomingMessage.swift */; }; FD078E5A27E29F09000769AF /* MockNonce16Generator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E5927E29F09000769AF /* MockNonce16Generator.swift */; }; FD078E5C27E29F78000769AF /* MockNonce24Generator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E5B27E29F78000769AF /* MockNonce24Generator.swift */; }; - FD078E5E27E2B9C2000769AF /* IdentityManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E5D27E2B9C2000769AF /* IdentityManagerProtocol.swift */; }; FD078E6027E2BB36000769AF /* MockIdentityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E5F27E2BB36000769AF /* MockIdentityManager.swift */; }; FD09796927F6BEA700936362 /* SwarmSnode.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09796827F6BEA700936362 /* SwarmSnode.swift */; }; FD09796B27F6C67500936362 /* Failable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09796A27F6C67500936362 /* Failable.swift */; }; @@ -623,13 +589,11 @@ FD09798927FD1C5A00936362 /* OpenGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09798827FD1C5A00936362 /* OpenGroup.swift */; }; FD09798B27FD1CFE00936362 /* Capability.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09798A27FD1CFE00936362 /* Capability.swift */; }; FD09798D27FD1D8900936362 /* DisappearingMessageConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09798C27FD1D8900936362 /* DisappearingMessageConfiguration.swift */; }; - FD09799127FD499200936362 /* BoxKeyPair+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799027FD499200936362 /* BoxKeyPair+Utilities.swift */; }; FD09799527FE7B8E00936362 /* Interaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799227FE693200936362 /* Interaction.swift */; }; FD09799727FFA84A00936362 /* RecipientState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799627FFA84900936362 /* RecipientState.swift */; }; FD09799927FFC1A300936362 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799827FFC1A300936362 /* Attachment.swift */; }; FD09799B27FFC82D00936362 /* Quote.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799A27FFC82D00936362 /* Quote.swift */; }; FD09C5E2282212B3000CE219 /* JobDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E1282212B3000CE219 /* JobDependencies.swift */; }; - FD09C5E428237209000CE219 /* MessageRequestsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E328237209000CE219 /* MessageRequestsViewModel.swift */; }; FD09C5E628260FF9000CE219 /* MediaGalleryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E528260FF9000CE219 /* MediaGalleryViewModel.swift */; }; FD09C5E828264937000CE219 /* MediaDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E728264937000CE219 /* MediaDetailViewController.swift */; }; FD09C5EA282A1BB2000CE219 /* ThreadTypingIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E9282A1BB2000CE219 /* ThreadTypingIndicator.swift */; }; @@ -664,6 +628,32 @@ FD17D7E727F6A16700122BE0 /* _003_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E627F6A16700122BE0 /* _003_YDBToGRDBMigration.swift */; }; FD17D7EA27F6A1C600122BE0 /* SUKLegacy.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E927F6A1C600122BE0 /* SUKLegacy.swift */; }; FD1C98E4282E3C5B00B76F9E /* UINavigationBar+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */; }; + FD245C50285065C700B966DD /* VisibleMessage+Quote.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7552553A3AB00C340D1 /* VisibleMessage+Quote.swift */; }; + FD245C51285065CC00B966DD /* MessageReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5FB2554B0A000555489 /* MessageReceiver.swift */; }; + FD245C52285065D500B966DD /* SignalAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF224255B6D5D007E1867 /* SignalAttachment.swift */; }; + FD245C53285065DB00B966DD /* ProximityMonitoringManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2EC255B6DBA007E1867 /* ProximityMonitoringManager.swift */; }; + FD245C54285065E000B966DD /* ThumbnailService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAF1255A580500E217F9 /* ThumbnailService.swift */; }; + FD245C55285065E500B966DD /* OpenGroupManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DB66AB260ACA42001EFC55 /* OpenGroupManager.swift */; }; + FD245C56285065EA00B966DD /* SNProto.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7822553AAF200C340D1 /* SNProto.swift */; }; + FD245C57285065F100B966DD /* Poller.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB3A255A580B00E217F9 /* Poller.swift */; }; + FD245C58285065F700B966DD /* OpenGroupServerIdLookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */; }; + FD245C59285065FC00B966DD /* ControlMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7702553A41E00C340D1 /* ControlMessage.swift */; }; + FD245C5A2850660100B966DD /* LinkPreviewDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBA8255A581500E217F9 /* LinkPreviewDraft.swift */; }; + FD245C5B2850660500B966DD /* ReadReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5BC2554B00D00555489 /* ReadReceipt.swift */; }; + FD245C5C2850660A00B966DD /* ConfigurationMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DA9C0625AE7396008F7C7E /* ConfigurationMessage.swift */; }; + FD245C5D2850660F00B966DD /* OWSAudioPlayer.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2F7255B6DBC007E1867 /* OWSAudioPlayer.m */; }; + FD245C5F2850662200B966DD /* OWSWindowManager.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF306255B6DBE007E1867 /* OWSWindowManager.m */; }; + FD245C632850664600B966DD /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD245C612850664300B966DD /* Configuration.swift */; }; + FD245C642850664F00B966DD /* Threading.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3ECBF7A257056B700EA7FCE /* Threading.swift */; }; + FD245C652850665400B966DD /* ClosedGroupControlMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C34A977325A3E34A00852C71 /* ClosedGroupControlMessage.swift */; }; + FD245C662850665900B966DD /* OpenGroupAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88FA7B726045D100049422F /* OpenGroupAPI.swift */; }; + FD245C672850665E00B966DD /* AttachmentDownloadJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A348255781F400338F3E /* AttachmentDownloadJob.swift */; }; + FD245C682850666300B966DD /* Message+Destination.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A30825574D8400338F3E /* Message+Destination.swift */; }; + FD245C692850666800B966DD /* ExpirationTimerUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5E62554B07300555489 /* ExpirationTimerUpdate.swift */; }; + FD245C6A2850666F00B966DD /* FileServerAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = B87EF17026367CF800124B3C /* FileServerAPI.swift */; }; + FD245C6B2850667400B966DD /* VisibleMessage+Profile.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5B12554AF9800555489 /* VisibleMessage+Profile.swift */; }; + FD245C6C2850669200B966DD /* MessageReceiveJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A31225574F5200338F3E /* MessageReceiveJob.swift */; }; + FD245C6D285066A400B966DD /* NotifyPushServerJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A32E2557549C00338F3E /* NotifyPushServerJob.swift */; }; FD3AABE928306BBD00E5099A /* ThreadPickerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */; }; FD3C905C27E3FBEF00CD579F /* BatchRequestInfoSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C905B27E3FBEF00CD579F /* BatchRequestInfoSpec.swift */; }; FD3C906027E410F700CD579F /* FileUploadResponseSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C905F27E410F700CD579F /* FileUploadResponseSpec.swift */; }; @@ -674,23 +664,39 @@ FD3C906D27E43C4B00CD579F /* MessageSenderEncryptionSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906C27E43C4B00CD579F /* MessageSenderEncryptionSpec.swift */; }; FD3C906F27E43E8700CD579F /* MockBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906E27E43E8700CD579F /* MockBox.swift */; }; FD3C907127E445E500CD579F /* MessageReceiverDecryptionSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907027E445E500CD579F /* MessageReceiverDecryptionSpec.swift */; }; - FD3C907527E83AC200CD579F /* OpenGroupServerIdLookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */; }; FD3E0C84283B5835002A425C /* SessionThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */; }; + FD42F9A8285064B800A0C77D /* PushNotificationAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */; }; FD4B200E283492210034334B /* InsetLockableTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4B200D283492210034334B /* InsetLockableTableView.swift */; }; + FD5C72F7284F0E560029977D /* MessageReceiver+ReadReceipts.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C72F6284F0E560029977D /* MessageReceiver+ReadReceipts.swift */; }; + FD5C72F9284F0E880029977D /* MessageReceiver+TypingIndicators.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C72F8284F0E880029977D /* MessageReceiver+TypingIndicators.swift */; }; + FD5C72FB284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C72FA284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift */; }; + FD5C72FD284F0EC90029977D /* MessageReceiver+ExpirationTimers.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C72FC284F0EC90029977D /* MessageReceiver+ExpirationTimers.swift */; }; + FD5C72FF284F0F120029977D /* MessageReceiver+ConfigurationMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C72FE284F0F120029977D /* MessageReceiver+ConfigurationMessages.swift */; }; + FD5C7301284F0F7A0029977D /* MessageReceiver+UnsendRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C7300284F0F7A0029977D /* MessageReceiver+UnsendRequests.swift */; }; + FD5C7303284F0FA50029977D /* MessageReceiver+Calls.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C7302284F0FA50029977D /* MessageReceiver+Calls.swift */; }; + FD5C7305284F0FF30029977D /* MessageReceiver+VisibleMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C7304284F0FF30029977D /* MessageReceiver+VisibleMessages.swift */; }; + FD5C7307284F103B0029977D /* MessageReceiver+MessageRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C7306284F103B0029977D /* MessageReceiver+MessageRequests.swift */; }; + FD5C7309285007920029977D /* BlindedIdLookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C7308285007920029977D /* BlindedIdLookup.swift */; }; FD5D201E27B0D87C00FEA984 /* SessionId.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D201D27B0D87C00FEA984 /* SessionId.swift */; }; - FD659AC027A7649600F12C02 /* MessageRequestsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD659ABF27A7649600F12C02 /* MessageRequestsViewController.swift */; }; FD6A7A692818BE7300035AC1 /* RetrieveDefaultOpenGroupRoomsJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6A7A682818BE7300035AC1 /* RetrieveDefaultOpenGroupRoomsJob.swift */; }; FD6A7A6B2818C17C00035AC1 /* UpdateProfilePictureJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6A7A6A2818C17C00035AC1 /* UpdateProfilePictureJob.swift */; }; FD6A7A6D2818C61500035AC1 /* _002_SetupStandardJobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6A7A6C2818C61500035AC1 /* _002_SetupStandardJobs.swift */; }; FD705A92278D051200F16121 /* ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A91278D051200F16121 /* ReusableView.swift */; }; FD7162DB281B6C440060647B /* TypedTableAlias.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7162DA281B6C440060647B /* TypedTableAlias.swift */; }; + FD716E6428502DDD00C96BF4 /* CallManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD716E6328502DDD00C96BF4 /* CallManagerProtocol.swift */; }; + FD716E6628502EE200C96BF4 /* CurrentCallProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD716E6528502EE200C96BF4 /* CurrentCallProtocol.swift */; }; + FD716E682850318E00C96BF4 /* CallMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD716E672850318E00C96BF4 /* CallMode.swift */; }; + FD716E6A2850327900C96BF4 /* EndCallMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD716E692850327900C96BF4 /* EndCallMode.swift */; }; + FD716E6C28505E1C00C96BF4 /* MessageRequestsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD716E6B28505E1C00C96BF4 /* MessageRequestsViewModel.swift */; }; + FD716E7128505E5200C96BF4 /* MessageRequestsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD716E7028505E5100C96BF4 /* MessageRequestsCell.swift */; }; + FD716E722850647600C96BF4 /* Data+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF127BF6BA200510D0C /* Data+Utilities.swift */; }; + FD716E732850647900C96BF4 /* NSData+messagePadding.h in Headers */ = {isa = PBXBuildFile; fileRef = C3A71D4E25589FF30043A11F /* NSData+messagePadding.h */; settings = {ATTRIBUTES = (Public, ); }; }; FD7728962849E7E90018502F /* String+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7728952849E7E90018502F /* String+Utilities.swift */; }; FD7728982849E8110018502F /* UITableView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7728972849E8110018502F /* UITableView+ReusableView.swift */; }; FD77289A284AF1BD0018502F /* Sodium+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD772899284AF1BD0018502F /* Sodium+Utilities.swift */; }; FD77289C284DDCE10018502F /* SnodePoolResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD77289B284DDCE10018502F /* SnodePoolResponse.swift */; }; FD77289E284EF1C50018502F /* Sodium+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD77289D284EF1C50018502F /* Sodium+Utilities.swift */; }; FD7728A0284EF5810018502F /* SnodeAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD77289F284EF5810018502F /* SnodeAPIError.swift */; }; - FD83B9AA27CF149D005E1583 /* ContactUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9A927CF149D005E1583 /* ContactUtilities.swift */; }; FD83B9B327CF200A005E1583 /* SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */; platformFilter = ios; }; FD83B9BB27CF20AF005E1583 /* SessionIdSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9BA27CF20AF005E1583 /* SessionIdSpec.swift */; }; FD83B9BF27CF2294005E1583 /* TestConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9BD27CF2243005E1583 /* TestConstants.swift */; }; @@ -702,7 +708,6 @@ FD83B9CC27D179BC005E1583 /* FSEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9CB27D179BC005E1583 /* FSEndpoint.swift */; }; FD83B9CE27D17A04005E1583 /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9CD27D17A04005E1583 /* Request.swift */; }; FD83B9D227D59495005E1583 /* MockUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9D127D59495005E1583 /* MockUserDefaults.swift */; }; - FD83B9D427D5A7D5005E1583 /* ConversationViewItem+Refactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9D327D5A7D5005E1583 /* ConversationViewItem+Refactor.swift */; }; FD848B8B283DC509000E298B /* PagedDatabaseObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B8A283DC509000E298B /* PagedDatabaseObserver.swift */; }; FD848B8D283E0B26000E298B /* MessageInputTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B8C283E0B26000E298B /* MessageInputTypes.swift */; }; FD848B8F283EF2A8000E298B /* UIScrollView+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B8E283EF2A8000E298B /* UIScrollView+Utilities.swift */; }; @@ -711,12 +716,12 @@ FD848B9828422F1A000E298B /* Date+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B9728422F1A000E298B /* Date+Utilities.swift */; }; FD848B9A28442CE6000E298B /* StorageError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B9928442CE6000E298B /* StorageError.swift */; }; FD848B9C284435D7000E298B /* AppSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B9B284435D7000E298B /* AppSetup.swift */; }; - FD859EF227BF6BA200510D0C /* Data+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF127BF6BA200510D0C /* Data+Utilities.swift */; }; FD859EF427C2F49200510D0C /* MockSodium.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF327C2F49200510D0C /* MockSodium.swift */; }; FD859EF627C2F52C00510D0C /* MockSign.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF527C2F52C00510D0C /* MockSign.swift */; }; FD859EF827C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF727C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift */; }; FD859EFA27C2F5C500510D0C /* MockGenericHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF927C2F5C500510D0C /* MockGenericHash.swift */; }; FD859EFC27C2F60700510D0C /* MockEd25519.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EFB27C2F60700510D0C /* MockEd25519.swift */; }; + FD86585828507B24008B6CF9 /* NSData+messagePadding.m in Sources */ = {isa = PBXBuildFile; fileRef = C3A71D4825589FF20043A11F /* NSData+messagePadding.m */; }; FD90040F2818AB6D00ABAAF6 /* GetSnodePoolJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD90040E2818AB6D00ABAAF6 /* GetSnodePoolJob.swift */; }; FD9004122818ABDC00ABAAF6 /* Job.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B73F280402C4004C14C5 /* Job.swift */; }; FD9004142818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9004132818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift */; }; @@ -754,7 +759,6 @@ FDC4382027B36ADC00C60D73 /* SOGSEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381F27B36ADC00C60D73 /* SOGSEndpoint.swift */; }; FDC4382F27B383AF00C60D73 /* UpdateRegistrationResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382E27B383AF00C60D73 /* UpdateRegistrationResponse.swift */; }; FDC4383827B3863200C60D73 /* VersionResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4383727B3863200C60D73 /* VersionResponse.swift */; }; - FDC4384C27B47F7700C60D73 /* OpenGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384B27B47F7700C60D73 /* OpenGroup.swift */; }; FDC4384F27B4804F00C60D73 /* Header.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384E27B4804F00C60D73 /* Header.swift */; }; FDC4385127B4807400C60D73 /* QueryParam.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4385027B4807400C60D73 /* QueryParam.swift */; }; FDC4385D27B4C18900C60D73 /* Room.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4385C27B4C18900C60D73 /* Room.swift */; }; @@ -784,7 +788,6 @@ 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 */; }; - FDC438CF27BCA45400C60D73 /* Server.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438CE27BCA45400C60D73 /* Server.swift */; }; FDCDB8DE2810F73B00352A0C /* Differentiable+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DD2810F73B00352A0C /* Differentiable+Utilities.swift */; }; FDCDB8E02811007F00352A0C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */; }; FDCDB8E42817819600352A0C /* (null) in Sources */ = {isa = PBXBuildFile; }; @@ -1161,18 +1164,14 @@ 7B7CB18F270FB2150079FF93 /* MiniCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MiniCallView.swift; sourceTree = ""; }; 7B7CB191271508AD0079FF93 /* CallRingTonePlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallRingTonePlayer.swift; sourceTree = ""; }; 7B93D06927CF173D00811CB6 /* MessageRequestsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageRequestsViewController.swift; sourceTree = ""; }; - 7B93D06C27CF175800811CB6 /* MessageRequestsCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageRequestsCell.swift; sourceTree = ""; }; 7B93D06E27CF194000811CB6 /* ConfigurationMessage+Convenience.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ConfigurationMessage+Convenience.swift"; sourceTree = ""; }; 7B93D06F27CF194000811CB6 /* MessageRequestResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageRequestResponse.swift; sourceTree = ""; }; 7B93D07527CF1A8900811CB6 /* MockDataGenerator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockDataGenerator.swift; sourceTree = ""; }; 7BA68908272A27BE00EFC32F /* SessionCall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionCall.swift; sourceTree = ""; }; 7BA6890C27325CCC00EFC32F /* SessionCallManager+CXCallController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionCallManager+CXCallController.swift"; sourceTree = ""; }; 7BA6890E27325CE300EFC32F /* SessionCallManager+CXProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionCallManager+CXProvider.swift"; sourceTree = ""; }; - 7BA7F4BA279F9F5800B3A466 /* EmptySearchResultCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptySearchResultCell.swift; sourceTree = ""; }; - 7BA9057D27911C5800998B3C /* GlobalSearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchViewController.swift; sourceTree = ""; }; 7BAADFCB27B0EF23007BCF92 /* CallVideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallVideoView.swift; sourceTree = ""; }; 7BAADFCD27B215FE007BCF92 /* UIView+Draggable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Draggable.swift"; sourceTree = ""; }; - 7BAF54CB27ACCEEC003D12F8 /* Storage+RecentSearchResults.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Storage+RecentSearchResults.swift"; sourceTree = ""; }; 7BAF54CC27ACCEEC003D12F8 /* GlobalSearchViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlobalSearchViewController.swift; sourceTree = ""; }; 7BAF54CD27ACCEEC003D12F8 /* EmptySearchResultCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmptySearchResultCell.swift; sourceTree = ""; }; 7BAF54D127ACCF01003D12F8 /* ShareAppExtensionContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShareAppExtensionContext.swift; sourceTree = ""; }; @@ -1339,7 +1338,7 @@ C328253F25CA55880062D0A7 /* ContextMenuVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuVC.swift; sourceTree = ""; }; C328254825CA60E60062D0A7 /* ContextMenuVC+Action.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContextMenuVC+Action.swift"; sourceTree = ""; }; C328255125CA64470062D0A7 /* ContextMenuVC+ActionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContextMenuVC+ActionView.swift"; sourceTree = ""; }; - C32C5A87256DBCF9003C73A2 /* MessageReceiver+Handling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+Handling.swift"; sourceTree = ""; }; + C32C5A87256DBCF9003C73A2 /* MessageReceiver+ClosedGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+ClosedGroups.swift"; sourceTree = ""; }; C33100132558FFC200070591 /* UIImage+Tinting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Tinting.swift"; sourceTree = ""; }; C33100272559000A00070591 /* UIView+Rendering.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Rendering.swift"; sourceTree = ""; }; C3310032255900A400070591 /* Notification+AppMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+AppMode.swift"; sourceTree = ""; }; @@ -1561,7 +1560,6 @@ C3AAFFF125AE99710089E6DD /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; C3ADC66026426688005F1414 /* ShareVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareVC.swift; sourceTree = ""; }; C3AECBEA24EF5244005743DE /* fa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fa; path = fa.lproj/Localizable.strings; sourceTree = ""; }; - C3BBE0752554CDA60050F1E3 /* Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = ""; }; C3BBE0C62554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FixedWidthInteger+BigEndian.swift"; sourceTree = ""; }; C3C2A59F255385C100C340D1 /* SessionSnodeKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SessionSnodeKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C3C2A5A1255385C100C340D1 /* SessionSnodeKit.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SessionSnodeKit.h; sourceTree = ""; }; @@ -1648,7 +1646,6 @@ FD078E5727E1B831000769AF /* TestIncomingMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestIncomingMessage.swift; sourceTree = ""; }; FD078E5927E29F09000769AF /* MockNonce16Generator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNonce16Generator.swift; sourceTree = ""; }; FD078E5B27E29F78000769AF /* MockNonce24Generator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNonce24Generator.swift; sourceTree = ""; }; - FD078E5D27E2B9C2000769AF /* IdentityManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityManagerProtocol.swift; sourceTree = ""; }; FD078E5F27E2BB36000769AF /* MockIdentityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockIdentityManager.swift; sourceTree = ""; }; FD09796827F6BEA700936362 /* SwarmSnode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwarmSnode.swift; sourceTree = ""; }; FD09796A27F6C67500936362 /* Failable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Failable.swift; sourceTree = ""; }; @@ -1668,13 +1665,11 @@ FD09798827FD1C5A00936362 /* OpenGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroup.swift; sourceTree = ""; }; FD09798A27FD1CFE00936362 /* Capability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Capability.swift; sourceTree = ""; }; FD09798C27FD1D8900936362 /* DisappearingMessageConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisappearingMessageConfiguration.swift; sourceTree = ""; }; - FD09799027FD499200936362 /* BoxKeyPair+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BoxKeyPair+Utilities.swift"; sourceTree = ""; }; FD09799227FE693200936362 /* Interaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Interaction.swift; sourceTree = ""; }; FD09799627FFA84900936362 /* RecipientState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipientState.swift; sourceTree = ""; }; FD09799827FFC1A300936362 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; FD09799A27FFC82D00936362 /* Quote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Quote.swift; sourceTree = ""; }; FD09C5E1282212B3000CE219 /* JobDependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobDependencies.swift; sourceTree = ""; }; - FD09C5E328237209000CE219 /* MessageRequestsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsViewModel.swift; sourceTree = ""; }; FD09C5E528260FF9000CE219 /* MediaGalleryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGalleryViewModel.swift; sourceTree = ""; }; FD09C5E728264937000CE219 /* MediaDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaDetailViewController.swift; sourceTree = ""; }; FD09C5E9282A1BB2000CE219 /* ThreadTypingIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadTypingIndicator.swift; sourceTree = ""; }; @@ -1708,6 +1703,7 @@ FD17D7E627F6A16700122BE0 /* _003_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _003_YDBToGRDBMigration.swift; sourceTree = ""; }; FD17D7E927F6A1C600122BE0 /* SUKLegacy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SUKLegacy.swift; sourceTree = ""; }; FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationBar+Utilities.swift"; sourceTree = ""; }; + FD245C612850664300B966DD /* Configuration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Configuration.swift; path = SessionMessagingKit/Configuration.swift; sourceTree = ""; }; FD28A4F327EA79F800FF65E7 /* BlockListUIUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockListUIUtils.swift; sourceTree = ""; }; FD28A4F527EAD44C00FF65E7 /* GRDBStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GRDBStorage.swift; sourceTree = ""; }; FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadPickerViewModel.swift; sourceTree = ""; }; @@ -1723,20 +1719,34 @@ FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookup.swift; sourceTree = ""; }; FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionThreadViewModel.swift; sourceTree = ""; }; FD4B200D283492210034334B /* InsetLockableTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsetLockableTableView.swift; sourceTree = ""; }; + FD5C72F6284F0E560029977D /* MessageReceiver+ReadReceipts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+ReadReceipts.swift"; sourceTree = ""; }; + FD5C72F8284F0E880029977D /* MessageReceiver+TypingIndicators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+TypingIndicators.swift"; sourceTree = ""; }; + FD5C72FA284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+DataExtractionNotification.swift"; sourceTree = ""; }; + FD5C72FC284F0EC90029977D /* MessageReceiver+ExpirationTimers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+ExpirationTimers.swift"; sourceTree = ""; }; + FD5C72FE284F0F120029977D /* MessageReceiver+ConfigurationMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+ConfigurationMessages.swift"; sourceTree = ""; }; + FD5C7300284F0F7A0029977D /* MessageReceiver+UnsendRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+UnsendRequests.swift"; sourceTree = ""; }; + FD5C7302284F0FA50029977D /* MessageReceiver+Calls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+Calls.swift"; sourceTree = ""; }; + FD5C7304284F0FF30029977D /* MessageReceiver+VisibleMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+VisibleMessages.swift"; sourceTree = ""; }; + FD5C7306284F103B0029977D /* MessageReceiver+MessageRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+MessageRequests.swift"; sourceTree = ""; }; + FD5C7308285007920029977D /* BlindedIdLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlindedIdLookup.swift; sourceTree = ""; }; FD5D201D27B0D87C00FEA984 /* SessionId.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionId.swift; sourceTree = ""; }; - FD659ABF27A7649600F12C02 /* MessageRequestsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsViewController.swift; sourceTree = ""; }; FD6A7A682818BE7300035AC1 /* RetrieveDefaultOpenGroupRoomsJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetrieveDefaultOpenGroupRoomsJob.swift; sourceTree = ""; }; FD6A7A6A2818C17C00035AC1 /* UpdateProfilePictureJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateProfilePictureJob.swift; sourceTree = ""; }; FD6A7A6C2818C61500035AC1 /* _002_SetupStandardJobs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_SetupStandardJobs.swift; sourceTree = ""; }; FD705A91278D051200F16121 /* ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReusableView.swift; sourceTree = ""; }; FD7162DA281B6C440060647B /* TypedTableAlias.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypedTableAlias.swift; sourceTree = ""; }; + FD716E6328502DDD00C96BF4 /* CallManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallManagerProtocol.swift; sourceTree = ""; }; + FD716E6528502EE200C96BF4 /* CurrentCallProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentCallProtocol.swift; sourceTree = ""; }; + FD716E672850318E00C96BF4 /* CallMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallMode.swift; sourceTree = ""; }; + FD716E692850327900C96BF4 /* EndCallMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EndCallMode.swift; sourceTree = ""; }; + FD716E6B28505E1C00C96BF4 /* MessageRequestsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageRequestsViewModel.swift; sourceTree = ""; }; + FD716E7028505E5100C96BF4 /* MessageRequestsCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageRequestsCell.swift; sourceTree = ""; }; FD7728952849E7E90018502F /* String+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Utilities.swift"; sourceTree = ""; }; FD7728972849E8110018502F /* UITableView+ReusableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UITableView+ReusableView.swift"; sourceTree = ""; }; FD772899284AF1BD0018502F /* Sodium+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Sodium+Utilities.swift"; sourceTree = ""; }; FD77289B284DDCE10018502F /* SnodePoolResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnodePoolResponse.swift; sourceTree = ""; }; FD77289D284EF1C50018502F /* Sodium+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sodium+Utilities.swift"; sourceTree = ""; }; FD77289F284EF5810018502F /* SnodeAPIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnodeAPIError.swift; sourceTree = ""; }; - FD83B9A927CF149D005E1583 /* ContactUtilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContactUtilities.swift; sourceTree = ""; }; FD83B9AF27CF200A005E1583 /* SessionUtilitiesKitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SessionUtilitiesKitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; FD83B9BA27CF20AF005E1583 /* SessionIdSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionIdSpec.swift; sourceTree = ""; }; FD83B9BD27CF2243005E1583 /* TestConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestConstants.swift; sourceTree = ""; }; @@ -1747,7 +1757,6 @@ FD83B9CB27D179BC005E1583 /* FSEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FSEndpoint.swift; sourceTree = ""; }; FD83B9CD27D17A04005E1583 /* Request.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Request.swift; sourceTree = ""; }; FD83B9D127D59495005E1583 /* MockUserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockUserDefaults.swift; sourceTree = ""; }; - FD83B9D327D5A7D5005E1583 /* ConversationViewItem+Refactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConversationViewItem+Refactor.swift"; sourceTree = ""; }; FD848B86283B844B000E298B /* MessageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageViewModel.swift; sourceTree = ""; }; FD848B8A283DC509000E298B /* PagedDatabaseObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagedDatabaseObserver.swift; sourceTree = ""; }; FD848B8C283E0B26000E298B /* MessageInputTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageInputTypes.swift; sourceTree = ""; }; @@ -1798,7 +1807,6 @@ FDC4382E27B383AF00C60D73 /* UpdateRegistrationResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateRegistrationResponse.swift; sourceTree = ""; }; FDC4383727B3863200C60D73 /* VersionResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionResponse.swift; sourceTree = ""; }; FDC4383D27B4708600C60D73 /* Atomic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = ""; }; - FDC4384B27B47F7700C60D73 /* OpenGroup.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OpenGroup.swift; sourceTree = ""; }; FDC4384E27B4804F00C60D73 /* Header.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Header.swift; sourceTree = ""; }; FDC4385027B4807400C60D73 /* QueryParam.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryParam.swift; sourceTree = ""; }; FDC4385C27B4C18900C60D73 /* Room.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Room.swift; sourceTree = ""; }; @@ -1827,7 +1835,6 @@ 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 = ""; }; - FDC438CE27BCA45400C60D73 /* Server.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.swift; sourceTree = ""; }; FDCDB8DD2810F73B00352A0C /* Differentiable+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Differentiable+Utilities.swift"; sourceTree = ""; }; FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; FDCDB8F02817ABE600352A0C /* Optional+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Utilities.swift"; sourceTree = ""; }; @@ -2161,21 +2168,13 @@ 7B93D06827CF173D00811CB6 /* Message Requests */ = { isa = PBXGroup; children = ( + FD716E6F28505E5100C96BF4 /* Views */, + FD716E6B28505E1C00C96BF4 /* MessageRequestsViewModel.swift */, 7B93D06927CF173D00811CB6 /* MessageRequestsViewController.swift */, ); path = "Message Requests"; sourceTree = ""; }; - 7B93D06B27CF175800811CB6 /* Views */ = { - isa = PBXGroup; - children = ( - 7BA9057D27911C5800998B3C /* GlobalSearchViewController.swift */, - 7BA7F4BA279F9F5800B3A466 /* EmptySearchResultCell.swift */, - 7B93D06C27CF175800811CB6 /* MessageRequestsCell.swift */, - ); - path = Views; - sourceTree = ""; - }; 7BA68907272A279900EFC32F /* Call Management */ = { isa = PBXGroup; children = ( @@ -2191,7 +2190,6 @@ 7BAF54CA27ACCEEC003D12F8 /* GlobalSearch */ = { isa = PBXGroup; children = ( - 7BAF54CB27ACCEEC003D12F8 /* Storage+RecentSearchResults.swift */, 7BAF54CC27ACCEEC003D12F8 /* GlobalSearchViewController.swift */, 7BAF54CD27ACCEEC003D12F8 /* EmptySearchResultCell.swift */, ); @@ -2284,7 +2282,6 @@ B835246C25C38AA20089A44F /* Conversations */ = { isa = PBXGroup; children = ( - FD83B9D327D5A7D5005E1583 /* ConversationViewItem+Refactor.swift */, B887C38125C7C79700E11DAE /* Input View */, B835247725C38D190089A44F /* Message Cells */, C328252E25CA54F70062D0A7 /* Context Menu */, @@ -2514,6 +2511,10 @@ B8BF43B926CC95FB007828D1 /* WebRTC+Utilities.swift */, 7BCD116B27016062006330F1 /* WebRTCSession+DataChannel.swift */, 7BFD1A8B2747150E00FB91B9 /* TurnServerInfo.swift */, + FD716E672850318E00C96BF4 /* CallMode.swift */, + FD716E692850327900C96BF4 /* EndCallMode.swift */, + FD716E6528502EE200C96BF4 /* CurrentCallProtocol.swift */, + FD716E6328502DDD00C96BF4 /* CallManagerProtocol.swift */, ); path = Calls; sourceTree = ""; @@ -2579,14 +2580,13 @@ C32C59F8256DB5A6003C73A2 /* Pollers */, C32C5B1B256DC160003C73A2 /* Quotes */, C32C5995256DAF85003C73A2 /* Typing Indicators */, + FD7728A1284F0DF50018502F /* Message Handling */, B8D0A25825E367AC00C1835E /* Notification+MessageReceiver.swift */, C300A5F12554B09800555489 /* MessageSender.swift */, - C38D5E8C2575011E00B6A65C /* MessageSender+ClosedGroups.swift */, FDF0B75B2807F41D004C14C5 /* MessageSender+Convenience.swift */, C3471ECA2555356A00297E91 /* MessageSender+Encryption.swift */, C300A5FB2554B0A000555489 /* MessageReceiver.swift */, C3471F4B25553AB000297E91 /* MessageReceiver+Decryption.swift */, - C32C5A87256DBCF9003C73A2 /* MessageReceiver+Handling.swift */, ); path = "Sending & Receiving"; sourceTree = ""; @@ -2803,11 +2803,9 @@ C360968E25AD16E8008B62B2 /* Home */ = { isa = PBXGroup; children = ( - FD659ABE27A7648200F12C02 /* Common Networking */, - FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */, - 7B93D06B27CF175800811CB6 /* Views */, 7B93D06827CF173D00811CB6 /* Message Requests */, 7BAF54CA27ACCEEC003D12F8 /* GlobalSearch */, + FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */, B8BB82A4238F627000BA5194 /* HomeVC.swift */, B83F2B85240C7B8F000A54AB /* NewConversationButtonSet.swift */, ); @@ -2977,8 +2975,8 @@ C379DC6825672B5E0002D4EB /* Notifications */ = { isa = PBXGroup; children = ( - FDF0B7502807BA56004C14C5 /* NotificationsProtocol.swift */, FDC4382D27B383A600C60D73 /* Models */, + FDF0B7502807BA56004C14C5 /* NotificationsProtocol.swift */, C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */, ); path = Notifications; @@ -3079,9 +3077,7 @@ children = ( C33FDB01255A580700E217F9 /* AppReadiness.h */, C33FDB75255A581000E217F9 /* AppReadiness.m */, - FD09799027FD499200936362 /* BoxKeyPair+Utilities.swift */, FDF0B7542807C4BB004C14C5 /* Environment.swift */, - FD83B9A927CF149D005E1583 /* ContactUtilities.swift */, FD859EF127BF6BA200510D0C /* Data+Utilities.swift */, FDC438C027BB4E6800C60D73 /* Dependencies.swift */, C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */, @@ -3186,7 +3182,6 @@ children = ( C3C2A7802553AA6300C340D1 /* Protos */, C3C2A70A25539DF900C340D1 /* Meta */, - FD078E5D27E2B9C2000769AF /* IdentityManagerProtocol.swift */, FDC4384D27B47FD600C60D73 /* Common Networking */, B8DE1FB226C22F1F0079C9CE /* Calls */, B8B3201F258B1A540020074B /* Contacts */, @@ -3356,7 +3351,7 @@ C33FD9AC255A548A00E217F9 /* SignalUtilitiesKit */, C331FF1C2558F9D300070591 /* SessionUIKit */, C3C2A6F125539DE700C340D1 /* SessionMessagingKit */, - C3BBE0752554CDA60050F1E3 /* Configuration.swift */, + FD245C612850664300B966DD /* Configuration.swift */, C3C2A5A0255385C100C340D1 /* SessionSnodeKit */, C3C2A67A255388CC00C340D1 /* SessionUtilitiesKit */, FD83B9BC27CF2215005E1583 /* SharedTest */, @@ -3492,6 +3487,7 @@ FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */, FD09C5E9282A1BB2000CE219 /* ThreadTypingIndicator.swift */, FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */, + FD5C7308285007920029977D /* BlindedIdLookup.swift */, ); path = Models; sourceTree = ""; @@ -3690,13 +3686,30 @@ path = Models; sourceTree = ""; }; - FD659ABE27A7648200F12C02 /* Common Networking */ = { + FD716E6F28505E5100C96BF4 /* Views */ = { isa = PBXGroup; children = ( - FD09C5E328237209000CE219 /* MessageRequestsViewModel.swift */, - FD659ABF27A7649600F12C02 /* MessageRequestsViewController.swift */, + FD716E7028505E5100C96BF4 /* MessageRequestsCell.swift */, ); - path = "Common Networking"; + path = Views; + sourceTree = ""; + }; + FD7728A1284F0DF50018502F /* Message Handling */ = { + isa = PBXGroup; + children = ( + C38D5E8C2575011E00B6A65C /* MessageSender+ClosedGroups.swift */, + FD5C72F6284F0E560029977D /* MessageReceiver+ReadReceipts.swift */, + FD5C72F8284F0E880029977D /* MessageReceiver+TypingIndicators.swift */, + FD5C72FA284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift */, + FD5C72FC284F0EC90029977D /* MessageReceiver+ExpirationTimers.swift */, + FD5C72FE284F0F120029977D /* MessageReceiver+ConfigurationMessages.swift */, + FD5C7300284F0F7A0029977D /* MessageReceiver+UnsendRequests.swift */, + FD5C7302284F0FA50029977D /* MessageReceiver+Calls.swift */, + FD5C7304284F0FF30029977D /* MessageReceiver+VisibleMessages.swift */, + C32C5A87256DBCF9003C73A2 /* MessageReceiver+ClosedGroups.swift */, + FD5C7306284F103B0029977D /* MessageReceiver+MessageRequests.swift */, + ); + path = "Message Handling"; sourceTree = ""; }; FD83B9B027CF200A005E1583 /* SessionUtilitiesKitTests */ = { @@ -3787,8 +3800,6 @@ FDC4381827B34EAD00C60D73 /* Models */ = { isa = PBXGroup; children = ( - FDC438CE27BCA45400C60D73 /* Server.swift */, - FDC4384B27B47F7700C60D73 /* OpenGroup.swift */, FDC4386A27B4E88F00C60D73 /* BatchRequestInfo.swift */, FDC4386627B4E10E00C60D73 /* Capabilities.swift */, FDC4385C27B4C18900C60D73 /* Room.swift */, @@ -4038,7 +4049,7 @@ C3C2A6F425539DE700C340D1 /* SessionMessagingKit.h in Headers */, C32C5C46256DCBB2003C73A2 /* AppReadiness.h in Headers */, C32C5FC4256E0209003C73A2 /* OWSBackgroundTask.h in Headers */, - C32A026C25A801AF000ED5D4 /* NSData+messagePadding.h in Headers */, + FD716E732850647900C96BF4 /* NSData+messagePadding.h in Headers */, B8856D72256F1421001CE70E /* OWSWindowManager.h in Headers */, B8856CF7256F105E001CE70E /* OWSAudioPlayer.h in Headers */, ); @@ -5114,61 +5125,51 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + FD86585828507B24008B6CF9 /* NSData+messagePadding.m in Sources */, + FD245C52285065D500B966DD /* SignalAttachment.swift in Sources */, B8856D08256F10F1001CE70E /* DeviceSleepManager.swift in Sources */, C3471F4C25553AB000297E91 /* MessageReceiver+Decryption.swift in Sources */, + FD245C672850665E00B966DD /* AttachmentDownloadJob.swift in Sources */, C300A5D32554B05A00555489 /* TypingIndicator.swift in Sources */, FD09799927FFC1A300936362 /* Attachment.swift in Sources */, + FD245C5F2850662200B966DD /* OWSWindowManager.m in Sources */, C3471ECB2555356A00297E91 /* MessageSender+Encryption.swift in Sources */, FDF0B74928060D13004C14C5 /* QuotedReplyModel.swift in Sources */, - C352A32F2557549C00338F3E /* NotifyPushServerJob.swift in Sources */, - C352A32F2557549C00338F3E /* NotifyPushServerJob.swift in Sources */, FD77289A284AF1BD0018502F /* Sodium+Utilities.swift in Sources */, + FD5C7309285007920029977D /* BlindedIdLookup.swift in Sources */, 7B4C75CB26B37E0F0000AC89 /* UnsendRequest.swift in Sources */, C300A5F22554B09800555489 /* MessageSender.swift in Sources */, B8B558FF26C4E05E00693325 /* WebRTCSession+MessageHandling.swift in Sources */, C3C2A74D2553A39700C340D1 /* VisibleMessage.swift in Sources */, - C32C5A13256DB7A5003C73A2 /* PushNotificationAPI.swift in Sources */, + FD245C58285065F700B966DD /* OpenGroupServerIdLookup.swift in Sources */, + FD245C5A2850660100B966DD /* LinkPreviewDraft.swift in Sources */, FDD250702837199200198BDA /* GarbageCollectionJob.swift in Sources */, - C32A026325A801AA000ED5D4 /* NSData+messagePadding.m in Sources */, FD6A7A6B2818C17C00035AC1 /* UpdateProfilePictureJob.swift in Sources */, - C352A31325574F5200338F3E /* MessageReceiveJob.swift in Sources */, - C3C2A7562553A3AB00C340D1 /* VisibleMessage+Quote.swift in Sources */, + FD716E6A2850327900C96BF4 /* EndCallMode.swift in Sources */, FDF0B75C2807F41D004C14C5 /* MessageSender+Convenience.swift in Sources */, FD09799727FFA84A00936362 /* RecipientState.swift in Sources */, - C300A5FC2554B0A000555489 /* MessageReceiver.swift in Sources */, - C32C5A76256DBBCF003C73A2 /* SignalAttachment.swift in Sources */, FDA8EB00280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift in Sources */, FD09798927FD1C5A00936362 /* OpenGroup.swift in Sources */, FD848B9628422A2A000E298B /* MessageViewModel.swift in Sources */, FDF0B7472804F0CE004C14C5 /* DisappearingMessagesJob.swift in Sources */, - B8856D1A256F114D001CE70E /* ProximityMonitoringManager.swift in Sources */, - C3D9E52725677DF20040E4F3 /* ThumbnailService.swift in Sources */, FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */, FD09797527FAB64300936362 /* ProfileManager.swift in Sources */, + FD245C57285065F100B966DD /* Poller.swift in Sources */, FDA8EAFE280E8B78002B68E5 /* FailedMessageSendsJob.swift in Sources */, - C3DB66AC260ACA42001EFC55 /* OpenGroupManager.swift in Sources */, FD0BA51B27CD88EC00CC6805 /* BlindedIdMapping.swift in Sources */, + FD245C6A2850666F00B966DD /* FileServerAPI.swift in Sources */, FDC4386927B4E6B800C60D73 /* String+Utlities.swift in Sources */, - C32C5A13256DB7A5003C73A2 /* PushNotificationAPI.swift in Sources */, + FD716E6628502EE200C96BF4 /* CurrentCallProtocol.swift in Sources */, FDC4385F27B4C4A200C60D73 /* PinnedMessage.swift in Sources */, 7BFD1A8C2747150E00FB91B9 /* TurnServerInfo.swift in Sources */, - 7BD477AA27F15F24004E2822 /* OpenGroupServerIdLookup.swift in Sources */, - C32A026325A801AA000ED5D4 /* NSData+messagePadding.m in Sources */, - FD859EF227BF6BA200510D0C /* Data+Utilities.swift in Sources */, FDC4386527B4DE7600C60D73 /* RoomPollInfo.swift in Sources */, - C352A31325574F5200338F3E /* MessageReceiveJob.swift in Sources */, + FD245C6B2850667400B966DD /* VisibleMessage+Profile.swift in Sources */, C3D9E3BF25676AD70040E4F3 /* (null) in Sources */, B8BF43BA26CC95FB007828D1 /* WebRTC+Utilities.swift in Sources */, - C3C2A7562553A3AB00C340D1 /* VisibleMessage+Quote.swift in Sources */, - C300A5FC2554B0A000555489 /* MessageReceiver.swift in Sources */, FDC438A627BB113A00C60D73 /* UserUnbanRequest.swift in Sources */, - C32C5A76256DBBCF003C73A2 /* SignalAttachment.swift in Sources */, C3BBE0B52554F0E10050F1E3 /* (null) in Sources */, - FDC438CF27BCA45400C60D73 /* Server.swift in Sources */, + FD5C72FB284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift in Sources */, FDC4386727B4E10E00C60D73 /* Capabilities.swift in Sources */, - B8856D1A256F114D001CE70E /* ProximityMonitoringManager.swift in Sources */, - C3D9E52725677DF20040E4F3 /* ThumbnailService.swift in Sources */, - C3DB66AC260ACA42001EFC55 /* OpenGroupManager.swift in Sources */, FDC438A427BB107F00C60D73 /* UserBanRequest.swift in Sources */, C379DCF4256735770002D4EB /* VisibleMessage+Attachment.swift in Sources */, FD09797F27FCFBFF00936362 /* OWSAES256Key+Utilities.swift in Sources */, @@ -5176,25 +5177,24 @@ FD09798327FD1A1500936362 /* ClosedGroup.swift in Sources */, B8B320B7258C30D70020074B /* HTMLMetadata.swift in Sources */, FD09798727FD1B7800936362 /* GroupMember.swift in Sources */, - FD09799127FD499200936362 /* BoxKeyPair+Utilities.swift in Sources */, FDB4BBC92839BEF000B7C95D /* ProfileManagerError.swift in Sources */, FDF0B7552807C4BB004C14C5 /* Environment.swift in Sources */, FD3E0C84283B5835002A425C /* SessionThreadViewModel.swift in Sources */, FD09C5EC282B8F18000CE219 /* AttachmentError.swift in Sources */, FD17D79927F40AB800122BE0 /* _003_YDBToGRDBMigration.swift in Sources */, - C3C2A7842553AAF300C340D1 /* SNProto.swift in Sources */, FDF0B7512807BA56004C14C5 /* NotificationsProtocol.swift in Sources */, - C32C5DC0256DD743003C73A2 /* Poller.swift in Sources */, FDC4387427B5BB9B00C60D73 /* Promise+Utilities.swift in Sources */, - C3C2A7842553AAF300C340D1 /* SNProto.swift in Sources */, - FD078E5E27E2B9C2000769AF /* IdentityManagerProtocol.swift in Sources */, - C32C5DC0256DD743003C73A2 /* Poller.swift in Sources */, B8DE1FB426C22F2F0079C9CE /* WebRTCSession.swift in Sources */, C3A3A171256E1D25004D228D /* SSKReachabilityManager.swift in Sources */, + FD245C59285065FC00B966DD /* ControlMessage.swift in Sources */, B8DE1FB626C22FCB0079C9CE /* CallMessage.swift in Sources */, + FD245C50285065C700B966DD /* VisibleMessage+Quote.swift in Sources */, + FD5C7307284F103B0029977D /* MessageReceiver+MessageRequests.swift in Sources */, C3A71D0B2558989C0043A11F /* MessageWrapper.swift in Sources */, FDE77F6B280FEB28002CFC5D /* ControlMessageProcessRecord.swift in Sources */, 7B93D07127CF194000811CB6 /* MessageRequestResponse.swift in Sources */, + FD245C662850665900B966DD /* OpenGroupAPI.swift in Sources */, + FD245C5B2850660500B966DD /* ReadReceipt.swift in Sources */, B8F5F60325EDE16F003BF8D4 /* DataExtractionNotification.swift in Sources */, C3A71D1E25589AC30043A11F /* WebSocketProto.swift in Sources */, C3C2A7852553AAF300C340D1 /* SessionProtos.pb.swift in Sources */, @@ -5202,64 +5202,46 @@ FDF0B7422804EA4F004C14C5 /* _002_SetupStandardJobs.swift in Sources */, B8EB20EE2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift in Sources */, FD17D7A227F40F0500122BE0 /* _001_InitialSetupMigration.swift in Sources */, - C3C2A7712553A41E00C340D1 /* ControlMessage.swift in Sources */, - C32C5D19256DD493003C73A2 /* LinkPreviewDraft.swift in Sources */, - FD3C907527E83AC200CD579F /* OpenGroupServerIdLookup.swift in Sources */, - C300A5BD2554B00D00555489 /* ReadReceipt.swift in Sources */, + FD245C5D2850660F00B966DD /* OWSAudioPlayer.m in Sources */, FDF0B7582807F368004C14C5 /* MessageReceiverError.swift in Sources */, FD6A7A692818BE7300035AC1 /* RetrieveDefaultOpenGroupRoomsJob.swift in Sources */, - C3DA9C0725AE7396008F7C7E /* ConfigurationMessage.swift in Sources */, - B8856CEE256F1054001CE70E /* OWSAudioPlayer.m in Sources */, FD09798D27FD1D8900936362 /* DisappearingMessageConfiguration.swift in Sources */, FDF0B75A2807F3A3004C14C5 /* MessageSenderError.swift in Sources */, - C3BBE0762554CDA60050F1E3 /* Configuration.swift in Sources */, - B8856D69256F141F001CE70E /* OWSWindowManager.m in Sources */, - C3ECBF7B257056B700EA7FCE /* Threading.swift in Sources */, FDC4382F27B383AF00C60D73 /* UpdateRegistrationResponse.swift in Sources */, FDC4386327B4D94E00C60D73 /* SOGSMessage.swift in Sources */, - C3C2A7712553A41E00C340D1 /* ControlMessage.swift in Sources */, - C32C5D19256DD493003C73A2 /* LinkPreviewDraft.swift in Sources */, - C300A5BD2554B00D00555489 /* ReadReceipt.swift in Sources */, + FD245C692850666800B966DD /* ExpirationTimerUpdate.swift in Sources */, + FD42F9A8285064B800A0C77D /* PushNotificationAPI.swift in Sources */, + FD245C6C2850669200B966DD /* MessageReceiveJob.swift in Sources */, FD83B9CC27D179BC005E1583 /* FSEndpoint.swift in Sources */, + FD716E6428502DDD00C96BF4 /* CallManagerProtocol.swift in Sources */, FDC438C727BB6DF000C60D73 /* DirectMessage.swift in Sources */, FDC4384F27B4804F00C60D73 /* Header.swift in Sources */, - C3DA9C0725AE7396008F7C7E /* ConfigurationMessage.swift in Sources */, - B8856CEE256F1054001CE70E /* OWSAudioPlayer.m in Sources */, FDC4381727B32EC700C60D73 /* Personalization.swift in Sources */, + FD245C51285065CC00B966DD /* MessageReceiver.swift in Sources */, + FD245C652850665400B966DD /* ClosedGroupControlMessage.swift in Sources */, FDC4387827B5C35400C60D73 /* SendMessageRequest.swift in Sources */, FDC4381527B329CE00C60D73 /* NonceGenerator.swift in Sources */, - C3BBE0762554CDA60050F1E3 /* Configuration.swift in Sources */, 7B93D07027CF194000811CB6 /* ConfigurationMessage+Convenience.swift in Sources */, - B8856D69256F141F001CE70E /* OWSWindowManager.m in Sources */, - FD83B9AA27CF149D005E1583 /* ContactUtilities.swift in Sources */, + FD5C72FD284F0EC90029977D /* MessageReceiver+ExpirationTimers.swift in Sources */, C3D9E3BE25676AD70040E4F3 /* (null) in Sources */, - C3ECBF7B257056B700EA7FCE /* Threading.swift in Sources */, - C32C5A88256DBCF9003C73A2 /* MessageReceiver+Handling.swift in Sources */, + C32C5A88256DBCF9003C73A2 /* MessageReceiver+ClosedGroups.swift in Sources */, B8D0A25925E367AC00C1835E /* Notification+MessageReceiver.swift in Sources */, + FD245C53285065DB00B966DD /* ProximityMonitoringManager.swift in Sources */, + FD245C55285065E500B966DD /* OpenGroupManager.swift in Sources */, FDC4387227B5BB3B00C60D73 /* FileUploadResponse.swift in Sources */, C32C599E256DB02B003C73A2 /* TypingIndicators.swift in Sources */, - C34A977425A3E34A00852C71 /* ClosedGroupControlMessage.swift in Sources */, + FD716E682850318E00C96BF4 /* CallMode.swift in Sources */, FD09799527FE7B8E00936362 /* Interaction.swift in Sources */, - B88FA7B826045D100049422F /* OpenGroupAPI.swift in Sources */, + FD5C72FF284F0F120029977D /* MessageReceiver+ConfigurationMessages.swift in Sources */, FDC4380927B31D4E00C60D73 /* SOGSError.swift in Sources */, FDC4382027B36ADC00C60D73 /* SOGSEndpoint.swift in Sources */, - C34A977425A3E34A00852C71 /* ClosedGroupControlMessage.swift in Sources */, - FDC4384C27B47F7700C60D73 /* OpenGroup.swift in Sources */, - B88FA7B826045D100049422F /* OpenGroupAPI.swift in Sources */, FDC438C927BB706500C60D73 /* SendDirectMessageRequest.swift in Sources */, C3A71D1F25589AC30043A11F /* WebSocketResources.pb.swift in Sources */, FDF0B74B28061F7A004C14C5 /* InteractionAttachment.swift in Sources */, B8F5F56525EC8453003BF8D4 /* Notification+Contacts.swift in Sources */, FD09796E27FA6D0000936362 /* Contact.swift in Sources */, C38D5E8D2575011E00B6A65C /* MessageSender+ClosedGroups.swift in Sources */, - C352A349255781F400338F3E /* AttachmentDownloadJob.swift in Sources */, - C352A30925574D8500338F3E /* Message+Destination.swift in Sources */, - C300A5E72554B07300555489 /* ExpirationTimerUpdate.swift in Sources */, - C352A349255781F400338F3E /* AttachmentDownloadJob.swift in Sources */, - C352A30925574D8500338F3E /* Message+Destination.swift in Sources */, - C300A5E72554B07300555489 /* ExpirationTimerUpdate.swift in Sources */, - B88A1AC725C90A4700E6D421 /* (null) in Sources */, - C3D9E3C025676AD70040E4F3 /* (null) in Sources */, + FD5C72F7284F0E560029977D /* MessageReceiver+ReadReceipts.swift in Sources */, FD83B9CE27D17A04005E1583 /* Request.swift in Sources */, C32C598A256D0664003C73A2 /* SNProtoEnvelope+Conversion.swift in Sources */, FDC438CB27BB7DB100C60D73 /* UpdateMessageRequest.swift in Sources */, @@ -5268,22 +5250,28 @@ FD078E4B27E02C5D000769AF /* Failable.swift in Sources */, B8856D11256F112A001CE70E /* OWSAudioSession.swift in Sources */, C3DB66C3260ACCE6001EFC55 /* OpenGroupPoller.swift in Sources */, + FD716E722850647600C96BF4 /* Data+Utilities.swift in Sources */, + FD245C5C2850660A00B966DD /* ConfigurationMessage.swift in Sources */, + FD5C7301284F0F7A0029977D /* MessageReceiver+UnsendRequests.swift in Sources */, C3C2A75F2553A3C500C340D1 /* VisibleMessage+LinkPreview.swift in Sources */, C32C5FBB256E0206003C73A2 /* OWSBackgroundTask.m in Sources */, + FD245C642850664F00B966DD /* Threading.swift in Sources */, FD848B8D283E0B26000E298B /* MessageInputTypes.swift in Sources */, C32C5C3D256DCBAF003C73A2 /* AppReadiness.m in Sources */, - B87EF17126367CF800124B3C /* FileServerAPI.swift in Sources */, FD09799B27FFC82D00936362 /* Quote.swift in Sources */, - B87EF17126367CF800124B3C /* FileServerAPI.swift in Sources */, + FD245C6D285066A400B966DD /* NotifyPushServerJob.swift in Sources */, C3C2A74425539EB700C340D1 /* Message.swift in Sources */, + FD245C682850666300B966DD /* Message+Destination.swift in Sources */, FD09798527FD1A6500936362 /* ClosedGroupKeyPair.swift in Sources */, + FD245C632850664600B966DD /* Configuration.swift in Sources */, C32C5DBF256DD743003C73A2 /* ClosedGroupPoller.swift in Sources */, C352A35B2557824E00338F3E /* AttachmentUploadJob.swift in Sources */, + FD5C7305284F0FF30029977D /* MessageReceiver+VisibleMessages.swift in Sources */, FD09797027FA6FF300936362 /* Profile.swift in Sources */, + FD245C56285065EA00B966DD /* SNProto.swift in Sources */, FD09798B27FD1CFE00936362 /* Capability.swift in Sources */, C3BBE0C72554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift in Sources */, FD17D79C27F40B2E00122BE0 /* SMKLegacy.swift in Sources */, - C300A5B22554AF9800555489 /* VisibleMessage+Profile.swift in Sources */, FD09798127FCFEE800936362 /* SessionThread.swift in Sources */, FD09C5EA282A1BB2000CE219 /* ThreadTypingIndicator.swift in Sources */, FDF0B75E280AAF35004C14C5 /* Preferences.swift in Sources */, @@ -5291,11 +5279,13 @@ FDC4383827B3863200C60D73 /* VersionResponse.swift in Sources */, B806ECA126C4A7E4008BDA44 /* WebRTCSession+UI.swift in Sources */, 7BCD116C27016062006330F1 /* WebRTCSession+DataChannel.swift in Sources */, - C300A5B22554AF9800555489 /* VisibleMessage+Profile.swift in Sources */, + FD5C72F9284F0E880029977D /* MessageReceiver+TypingIndicators.swift in Sources */, + FD5C7303284F0FA50029977D /* MessageReceiver+Calls.swift in Sources */, FDC4386B27B4E88F00C60D73 /* BatchRequestInfo.swift in Sources */, FDC4385127B4807400C60D73 /* QueryParam.swift in Sources */, FD83B9C927D0487A005E1583 /* SendDirectMessageResponse.swift in Sources */, FDC438AA27BB12BB00C60D73 /* UserModeratorRequest.swift in Sources */, + FD245C54285065E000B966DD /* ThumbnailService.swift in Sources */, FDC4385D27B4C18900C60D73 /* Room.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -5338,7 +5328,6 @@ B83F2B88240CB75A000A54AB /* UIImage+Scaling.swift in Sources */, 3430FE181F7751D4000EC51B /* GiphyAPI.swift in Sources */, 340FC8AA204DAC8D007AEB0F /* NotificationSettingsViewController.m in Sources */, - 7BAF54CE27ACCEEC003D12F8 /* Storage+RecentSearchResults.swift in Sources */, 4C090A1B210FD9C7001FD7F9 /* HapticFeedback.swift in Sources */, 34F308A21ECB469700BB7697 /* OWSBezierPathView.m in Sources */, B886B4A92398BA1500211ABE /* QRCode.swift in Sources */, @@ -5368,7 +5357,6 @@ 34BECE301F7ABCF800D7438D /* GifPickerLayout.swift in Sources */, C331FFFE2558FF3B00070591 /* FullConversationCell.swift in Sources */, FDFDE128282D05530098B17F /* MediaPresentationContext.swift in Sources */, - FD659AC027A7649600F12C02 /* MessageRequestsViewController.swift in Sources */, FDFDE124282D04F20098B17F /* MediaDismissAnimationController.swift in Sources */, 7BA6890F27325CE300EFC32F /* SessionCallManager+CXProvider.swift in Sources */, 34BECE301F7ABCF800D7438D /* GifPickerLayout.swift in Sources */, @@ -5385,6 +5373,7 @@ B877E24226CA12910007970A /* CallVC.swift in Sources */, 7BA6890D27325CCC00EFC32F /* SessionCallManager+CXCallController.swift in Sources */, C374EEEB25DA3CA70073A857 /* ConversationTitleView.swift in Sources */, + FD716E7128505E5200C96BF4 /* MessageRequestsCell.swift in Sources */, B88FA7F2260C3EB10049422F /* OpenGroupSuggestionGrid.swift in Sources */, 4CA485BB2232339F004B9E7D /* PhotoCaptureViewController.swift in Sources */, 34330AA31E79686200DF2FB9 /* OWSProgressView.m in Sources */, @@ -5460,14 +5449,12 @@ 7BFD1A8A2745C4F000FB91B9 /* Permissions.swift in Sources */, B85357BF23A1AE0800AAF6CD /* SeedReminderView.swift in Sources */, B821494F25D4E163009C0F2A /* BodyTextView.swift in Sources */, + FD716E6C28505E1C00C96BF4 /* MessageRequestsViewModel.swift in Sources */, C35E8AAE2485E51D00ACB629 /* IP2Country.swift in Sources */, B835249B25C3AB650089A44F /* VisibleMessageCell.swift in Sources */, 340FC8AE204DAC8D007AEB0F /* OWSSoundSettingsViewController.m in Sources */, - FD09C5E428237209000CE219 /* MessageRequestsViewModel.swift in Sources */, - FD83B9D427D5A7D5005E1583 /* ConversationViewItem+Refactor.swift in Sources */, B8D0A25025E3678700C1835E /* LinkDeviceVC.swift in Sources */, B894D0752339EDCF00B4D94D /* NukeDataModal.swift in Sources */, - 7B93D06D27CF175800811CB6 /* MessageRequestsCell.swift in Sources */, 7B93D07727CF1A8A00811CB6 /* MockDataGenerator.swift in Sources */, 7B4C75CD26BB92060000AC89 /* DeletedMessageView.swift in Sources */, FDD250722837234B00198BDA /* MediaGalleryNavigationController.swift in Sources */, diff --git a/Session/Calls/Call Management/SessionCall.swift b/Session/Calls/Call Management/SessionCall.swift index 930175214..1d106c38b 100644 --- a/Session/Calls/Call Management/SessionCall.swift +++ b/Session/Calls/Call Management/SessionCall.swift @@ -1,37 +1,33 @@ -import Foundation -import WebRTC -import SessionMessagingKit -import PromiseKit -import CallKit +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -public final class SessionCall: NSObject, WebRTCSessionDelegate { - +import Foundation +import CallKit +import GRDB +import WebRTC +import PromiseKit +import SignalUtilitiesKit +import SessionMessagingKit + +public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { @objc static let isEnabled = true - // MARK: Metadata Properties - let uuid: String - let callID: UUID // This is for CallKit - let sessionID: String - let mode: Mode + // MARK: - Metadata Properties + public let uuid: String + public let callId: UUID // This is for CallKit + let sessionId: String + let mode: CallMode var audioMode: AudioMode - let webRTCSession: WebRTCSession + public let webRTCSession: WebRTCSession let isOutgoing: Bool var remoteSDP: RTCSessionDescription? = nil - var callMessageID: String? + var callInteractionId: Int64? var answerCallAction: CXAnswerCallAction? = nil - var contactName: String { - let contact = Storage.shared.getContact(with: self.sessionID) - return contact?.displayName(for: Contact.Context.regular) ?? "\(self.sessionID.prefix(4))...\(self.sessionID.suffix(4))" - } - var profilePicture: UIImage { - if let result = OWSProfileManager.shared().profileAvatar(forRecipientId: sessionID) { - return result - } else { - return Identicon.generatePlaceholderIcon(seed: sessionID, text: contactName, size: 300) - } - } - // MARK: Control + let contactName: String + let profilePicture: UIImage + + // MARK: - Control + lazy public var videoCapturer: RTCVideoCapturer = { return RTCCameraVideoCapturer(delegate: webRTCSession.localVideoSource) }() @@ -61,21 +57,8 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate { } } - // MARK: Mode - enum Mode { - case offer - case answer - } + // MARK: - Audio I/O mode - // MARK: End call mode - enum EndCallMode { - case local - case remote - case unanswered - case answeredElsewhere - } - - // MARK: Audio I/O mode enum AudioMode { case earpiece case speaker @@ -83,7 +66,8 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate { case bluetooth } - // MARK: Call State Properties + // MARK: - Call State Properties + var connectingDate: Date? { didSet { stateDidChange?() @@ -112,7 +96,8 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate { } } - // MARK: State Change Callbacks + // MARK: - State Change Callbacks + var stateDidChange: (() -> Void)? var hasStartedConnectingDidChange: (() -> Void)? var hasConnectedDidChange: (() -> Void)? @@ -121,8 +106,9 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate { var hasStartedReconnecting: (() -> Void)? var hasReconnected: (() -> Void)? - // MARK: Derived Properties - var hasStartedConnecting: Bool { + // MARK: - Derived Properties + + public var hasStartedConnecting: Bool { get { return connectingDate != nil } set { connectingDate = newValue ? Date() : nil } } @@ -153,73 +139,110 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate { var reconnectTimer: Timer? = nil - // MARK: Initialization - init(for sessionID: String, uuid: String, mode: Mode, outgoing: Bool = false) { - self.sessionID = sessionID + // MARK: - Initialization + + init(_ db: Database, for sessionId: String, uuid: String, mode: CallMode, outgoing: Bool = false) { + self.sessionId = sessionId self.uuid = uuid - self.callID = UUID() + self.callId = UUID() self.mode = mode self.audioMode = .earpiece - self.webRTCSession = WebRTCSession.current ?? WebRTCSession(for: sessionID, with: uuid) + self.webRTCSession = WebRTCSession.current ?? WebRTCSession(for: sessionId, with: uuid) self.isOutgoing = outgoing + + self.contactName = Profile.displayName(db, id: sessionId, threadVariant: .contact) + self.profilePicture = ProfileManager.profileAvatar(db, id: sessionId) + .defaulting(to: Identicon.generatePlaceholderIcon(seed: sessionId, text: self.contactName, size: 300)) + WebRTCSession.current = self.webRTCSession - super.init() self.webRTCSession.delegate = self + if AppEnvironment.shared.callManager.currentCall == nil { AppEnvironment.shared.callManager.currentCall = self - } else { + } + else { SNLog("[Calls] A call is ongoing.") } } func reportIncomingCallIfNeeded(completion: @escaping (Error?) -> Void) { guard case .answer = mode else { return } + setupTimeoutTimer() AppEnvironment.shared.callManager.reportIncomingCall(self, callerName: contactName) { error in completion(error) } } - func didReceiveRemoteSDP(sdp: RTCSessionDescription) { + public func didReceiveRemoteSDP(sdp: RTCSessionDescription) { + guard Thread.isMainThread else { + DispatchQueue.main.async { + self.didReceiveRemoteSDP(sdp: sdp) + } + return + } + SNLog("[Calls] Did receive remote sdp.") remoteSDP = sdp if hasStartedConnecting { - webRTCSession.handleRemoteSDP(sdp, from: sessionID) // This sends an answer message internally + webRTCSession.handleRemoteSDP(sdp, from: sessionId) // This sends an answer message internally } } - // MARK: Actions - func startSessionCall() { - guard case .offer = mode else { return } - guard let thread = TSContactThread.fetch(uniqueId: TSContactThread.threadID(fromContactSessionID: sessionID)) else { return } + // MARK: - Actions + + public func startSessionCall(_ db: Database) { + let sessionId: String = self.sessionId + let messageInfo: CallMessage.MessageInfo = CallMessage.MessageInfo(state: .outgoing) - let message = CallMessage() - message.sender = getUserHexEncodedPublicKey() - message.sentTimestamp = NSDate.millisecondTimestamp() - message.uuid = self.uuid - message.kind = .preOffer - let infoMessage = TSInfoMessage.from(message, associatedWith: thread) - infoMessage.save() - self.callMessageID = infoMessage.uniqueId + guard + case .offer = mode, + let messageInfoData: Data = try? JSONEncoder().encode(messageInfo), + let thread: SessionThread = try? SessionThread.fetchOne(db, id: sessionId) + else { return } - var promise: Promise! - Storage.write(with: { transaction in - promise = self.webRTCSession.sendPreOffer(message, in: thread, using: transaction) - }, completion: { [weak self] in - let _ = promise.done { - Storage.shared.write { transaction in - self?.webRTCSession.sendOffer(to: self!.sessionID, using: transaction as! YapDatabaseReadWriteTransaction).retainUntilComplete() + let timestampMs: Int64 = Int64(floor(Date().timeIntervalSince1970 * 1000)) + let message: CallMessage = CallMessage( + uuid: self.uuid, + kind: .preOffer, + sdps: [], + sentTimestampMs: UInt64(timestampMs) + ) + let interaction: Interaction? = try? Interaction( + messageUuid: self.uuid, + threadId: sessionId, + authorId: getUserHexEncodedPublicKey(db), + variant: .infoCall, + body: String(data: messageInfoData, encoding: .utf8), + timestampMs: timestampMs + ) + .inserted(db) + + self.callInteractionId = interaction?.id + try? self.webRTCSession + .sendPreOffer( + db, + message: message, + interactionId: interaction?.id, + in: thread + ) + .done { [weak self] _ in + GRDBStorage.shared.writeAsync { db in + self?.webRTCSession.sendOffer(db, to: sessionId) } + self?.setupTimeoutTimer() } - }) + .retainUntilComplete() } func answerSessionCall() { guard case .answer = mode else { return } + hasStartedConnecting = true + if let sdp = remoteSDP { - webRTCSession.handleRemoteSDP(sdp, from: sessionID) // This sends an answer message internally + webRTCSession.handleRemoteSDP(sdp, from: sessionId) // This sends an answer message internally } } @@ -230,47 +253,79 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate { func endSessionCall() { guard !hasEnded else { return } + + let sessionId: String = self.sessionId + webRTCSession.hangUp() - Storage.write { transaction in - self.webRTCSession.endCall(with: self.sessionID, using: transaction) + + GRDBStorage.shared.writeAsync { [weak self] db in + try self?.webRTCSession.endCall(db, with: sessionId) } + hasEnded = true } - // MARK: Update call message - func updateCallMessage(mode: EndCallMode) { - guard let callMessageID = callMessageID else { return } - Storage.write { transaction in - let infoMessage = TSInfoMessage.fetch(uniqueId: callMessageID, transaction: transaction) - if let messageToUpdate = infoMessage { - var shouldMarkAsRead = false - if self.duration > 0 { - shouldMarkAsRead = true - } else if self.hasStartedConnecting { - shouldMarkAsRead = true - } else { - switch mode { - case .local: - shouldMarkAsRead = true - fallthrough - case .remote: - fallthrough - case .unanswered: - if messageToUpdate.callState == .incoming { - messageToUpdate.updateCallInfoMessage(.missed, using: transaction) - } - case .answeredElsewhere: - shouldMarkAsRead = true - } - } - if shouldMarkAsRead { - messageToUpdate.markAsRead(atTimestamp: NSDate.ows_millisecondTimeStamp(), trySendReadReceipt: false, transaction: transaction) - } + // MARK: - Call Message Handling + + public func updateCallMessage(mode: EndCallMode) { + guard let callInteractionId: Int64 = callInteractionId else { return } + + let duration: TimeInterval = self.duration + let hasStartedConnecting: Bool = self.hasStartedConnecting + + GRDBStorage.shared.writeAsync { db in + guard let interaction: Interaction = try? Interaction.fetchOne(db, id: callInteractionId) else { + return } + + let updateToMissedIfNeeded: () throws -> () = { + let missedCallInfo: CallMessage.MessageInfo = CallMessage.MessageInfo(state: .missed) + + guard + let infoMessageData: Data = (interaction.body ?? "").data(using: .utf8), + let messageInfo: CallMessage.MessageInfo = try? JSONDecoder().decode( + CallMessage.MessageInfo.self, + from: infoMessageData + ), + messageInfo.state == .incoming, + let missedCallInfoData: Data = try? JSONEncoder().encode(missedCallInfo) + else { return } + + _ = try interaction + .with(body: String(data: missedCallInfoData, encoding: .utf8)) + .saved(db) + } + let shouldMarkAsRead: Bool = try { + if duration > 0 { return true } + if hasStartedConnecting { return true } + + switch mode { + case .local: + try updateToMissedIfNeeded() + return true + + case .remote, .unanswered: + try updateToMissedIfNeeded() + return false + + case .answeredElsewhere: return true + } + }() + + guard shouldMarkAsRead else { return } + + try Interaction.markAsRead( + db, + interactionId: interaction.id, + threadId: interaction.threadId, + includingOlder: false, + trySendReadReceipt: false + ) } } - // MARK: Renderer + // MARK: - Renderer + func attachRemoteVideoRenderer(_ renderer: RTCVideoRenderer) { webRTCSession.attachRemoteRenderer(renderer) } @@ -283,14 +338,17 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate { webRTCSession.attachLocalRenderer(renderer) } - // MARK: Delegate + // MARK: - Delegate + public func webRTCIsConnected() { self.invalidateTimeoutTimer() self.reconnectTimer?.invalidate() + guard !self.hasConnected else { hasReconnected?() return } + self.hasConnected = true self.answerCallAction?.fulfill() } @@ -327,23 +385,32 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate { private func tryToReconnect() { reconnectTimer?.invalidate() - if SSKEnvironment.shared.reachabilityManager.isReachable { - Storage.write { transaction in - self.webRTCSession.sendOffer(to: self.sessionID, using: transaction, isRestartingICEConnection: true).retainUntilComplete() - } - } else { + + guard Environment.shared.reachabilityManager.isReachable else { reconnectTimer = Timer.scheduledTimerOnMainThread(withTimeInterval: 5, repeats: false) { _ in self.tryToReconnect() } + return } + + let sessionId: String = self.sessionId + let webRTCSession: WebRTCSession = self.webRTCSession + + GRDBStorage.shared + .write { db in webRTCSession.sendOffer(db, to: sessionId, isRestartingICEConnection: true) } + .retainUntilComplete() } - // MARK: Timeout + // MARK: - Timeout + public func setupTimeoutTimer() { invalidateTimeoutTimer() - let timeInterval: TimeInterval = hasConnected ? 60 : 30 + + let timeInterval: TimeInterval = (hasConnected ? 60 : 30) + timeOutTimer = Timer.scheduledTimerOnMainThread(withTimeInterval: timeInterval, repeats: false) { _ in self.didTimeout = true + AppEnvironment.shared.callManager.endCall(self) { error in self.timeOutTimer = nil } diff --git a/Session/Calls/Call Management/SessionCallManager+Action.swift b/Session/Calls/Call Management/SessionCallManager+Action.swift index 66e9814ea..32482d5de 100644 --- a/Session/Calls/Call Management/SessionCallManager+Action.swift +++ b/Session/Calls/Call Management/SessionCallManager+Action.swift @@ -1,24 +1,37 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import GRDB + extension SessionCallManager { @discardableResult public func startCallAction() -> Bool { - guard let call = self.currentCall else { return false } - call.startSessionCall() + guard let call: CurrentCallProtocol = self.currentCall else { return false } + + GRDBStorage.shared.writeAsync { db in + call.startSessionCall(db) + } + return true } @discardableResult public func answerCallAction() -> Bool { - guard let call = self.currentCall else { return false } + guard let call: SessionCall = (self.currentCall as? SessionCall) else { return false } + if let _ = CurrentAppContext().frontmostViewController() as? CallVC { call.answerSessionCall() - } else { + } + else { guard let presentingVC = CurrentAppContext().frontmostViewController() else { return false } // FIXME: Handle more gracefully - let callVC = CallVC(for: self.currentCall!) + let callVC = CallVC(for: call) + if let conversationVC = presentingVC as? ConversationVC { callVC.conversationVC = conversationVC conversationVC.inputAccessoryView?.isHidden = true conversationVC.inputAccessoryView?.alpha = 0 } + presentingVC.present(callVC, animated: true) { call.answerSessionCall() } @@ -28,20 +41,26 @@ extension SessionCallManager { @discardableResult public func endCallAction() -> Bool { - guard let call = self.currentCall else { return false } + guard let call: SessionCall = (self.currentCall as? SessionCall) else { return false } + call.endSessionCall() + if call.didTimeout { reportCurrentCallEnded(reason: .unanswered) - } else { + } + else { reportCurrentCallEnded(reason: nil) } + return true } @discardableResult public func setMutedCallAction(isMuted: Bool) -> Bool { - guard let call = self.currentCall else { return false } + guard let call: SessionCall = (self.currentCall as? SessionCall) else { return false } + call.isMuted = isMuted + return true } } diff --git a/Session/Calls/Call Management/SessionCallManager+CXCallController.swift b/Session/Calls/Call Management/SessionCallManager+CXCallController.swift index 704a590e0..c6f65af5c 100644 --- a/Session/Calls/Call Management/SessionCallManager+CXCallController.swift +++ b/Session/Calls/Call Management/SessionCallManager+CXCallController.swift @@ -1,3 +1,6 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation import CallKit import SessionUtilitiesKit @@ -5,10 +8,12 @@ extension SessionCallManager { public func startCall(_ call: SessionCall, completion: ((Error?) -> Void)?) { guard case .offer = call.mode else { return } guard !call.hasConnected else { return } + reportOutgoingCall(call) + if callController != nil { - let handle = CXHandle(type: .generic, value: call.sessionID) - let startCallAction = CXStartCallAction(call: call.callID, handle: handle) + let handle = CXHandle(type: .generic, value: call.sessionId) + let startCallAction = CXStartCallAction(call: call.callId, handle: handle) startCallAction.isVideo = false @@ -16,7 +21,8 @@ extension SessionCallManager { transaction.addAction(startCallAction) requestTransaction(transaction, completion: completion) - } else { + } + else { startCallAction() completion?(nil) } @@ -24,12 +30,13 @@ extension SessionCallManager { public func answerCall(_ call: SessionCall, completion: ((Error?) -> Void)?) { if callController != nil { - let answerCallAction = CXAnswerCallAction(call: call.callID) + let answerCallAction = CXAnswerCallAction(call: call.callId) let transaction = CXTransaction() transaction.addAction(answerCallAction) requestTransaction(transaction, completion: completion) - } else { + } + else { answerCallAction() completion?(nil) } @@ -37,12 +44,13 @@ extension SessionCallManager { public func endCall(_ call: SessionCall, completion: ((Error?) -> Void)?) { if callController != nil { - let endCallAction = CXEndCallAction(call: call.callID) + let endCallAction = CXEndCallAction(call: call.callId) let transaction = CXTransaction() transaction.addAction(endCallAction) requestTransaction(transaction, completion: completion) - } else { + } + else { endCallAction() completion?(nil) } @@ -51,7 +59,7 @@ extension SessionCallManager { // Not currently in use public func setOnHoldStatus(for call: SessionCall) { if callController != nil { - let setHeldCallAction = CXSetHeldCallAction(call: call.callID, onHold: true) + let setHeldCallAction = CXSetHeldCallAction(call: call.callId, onHold: true) let transaction = CXTransaction() transaction.addAction(setHeldCallAction) @@ -63,9 +71,11 @@ extension SessionCallManager { callController?.request(transaction) { error in if let error = error { SNLog("Error requesting transaction: \(error)") - } else { + } + else { SNLog("Requested transaction successfully") } + completion?(error) } } diff --git a/Session/Calls/Call Management/SessionCallManager+CXProvider.swift b/Session/Calls/Call Management/SessionCallManager+CXProvider.swift index c66932788..612742bc5 100644 --- a/Session/Calls/Call Management/SessionCallManager+CXProvider.swift +++ b/Session/Calls/Call Management/SessionCallManager+CXProvider.swift @@ -1,16 +1,22 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation import CallKit +import SignalCoreKit +import SessionUtilitiesKit extension SessionCallManager: CXProviderDelegate { public func providerDidReset(_ provider: CXProvider) { AssertIsOnMainThread() - currentCall?.endSessionCall() + (currentCall as? SessionCall)?.endSessionCall() } public func provider(_ provider: CXProvider, perform action: CXStartCallAction) { AssertIsOnMainThread() if startCallAction() { action.fulfill() - } else { + } + else { action.fail() } } @@ -18,14 +24,18 @@ extension SessionCallManager: CXProviderDelegate { public func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { AssertIsOnMainThread() print("[CallKit] Perform CXAnswerCallAction") - guard let call = self.currentCall else { return action.fail() } + + guard let call: SessionCall = (self.currentCall as? SessionCall) else { return action.fail() } + if CurrentAppContext().isMainAppAndActive { if answerCallAction() { action.fulfill() - } else { + } + else { action.fail() } - } else { + } + else { call.answerSessionCallInBackground(action: action) } } @@ -33,9 +43,11 @@ extension SessionCallManager: CXProviderDelegate { public func provider(_ provider: CXProvider, perform action: CXEndCallAction) { print("[CallKit] Perform CXEndCallAction") AssertIsOnMainThread() + if endCallAction() { action.fulfill() - } else { + } + else { action.fail() } } @@ -43,9 +55,11 @@ extension SessionCallManager: CXProviderDelegate { public func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) { print("[CallKit] Perform CXSetMutedCallAction, isMuted: \(action.isMuted)") AssertIsOnMainThread() + if setMutedCallAction(isMuted: action.isMuted) { action.fulfill() - } else { + } + else { action.fail() } } @@ -61,7 +75,8 @@ extension SessionCallManager: CXProviderDelegate { public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) { print("[CallKit] Audio session did activate.") AssertIsOnMainThread() - guard let call = self.currentCall else { return } + guard let call: SessionCall = (self.currentCall as? SessionCall) else { return } + call.webRTCSession.audioSessionDidActivate(audioSession) if call.isOutgoing && !call.hasConnected { CallRingTonePlayer.shared.startPlayingRingTone() } } @@ -69,7 +84,8 @@ extension SessionCallManager: CXProviderDelegate { public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) { print("[CallKit] Audio session did deactivate.") AssertIsOnMainThread() - guard let call = self.currentCall else { return } + guard let call: SessionCall = (self.currentCall as? SessionCall) else { return } + call.webRTCSession.audioSessionDidDeactivate(audioSession) } } diff --git a/Session/Calls/Call Management/SessionCallManager.swift b/Session/Calls/Call Management/SessionCallManager.swift index 2a2fa5313..a1ebc6028 100644 --- a/Session/Calls/Call Management/SessionCallManager.swift +++ b/Session/Calls/Call Management/SessionCallManager.swift @@ -1,10 +1,15 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit import CallKit +import GRDB import SessionMessagingKit -public final class SessionCallManager: NSObject { +public final class SessionCallManager: NSObject, CallManagerProtocol { let provider: CXProvider? let callController: CXCallController? - var currentCall: SessionCall? = nil { + + public var currentCall: CurrentCallProtocol? = nil { willSet { if (newValue != nil) { DispatchQueue.main.async { @@ -19,13 +24,14 @@ public final class SessionCallManager: NSObject { } private static var _sharedProvider: CXProvider? - class func sharedProvider(useSystemCallLog: Bool) -> CXProvider { + static func sharedProvider(useSystemCallLog: Bool) -> CXProvider { let configuration = buildProviderConfiguration(useSystemCallLog: useSystemCallLog) if let sharedProvider = self._sharedProvider { sharedProvider.configuration = configuration return sharedProvider - } else { + } + else { SwiftSingletons.register(self) let provider = CXProvider(configuration: configuration) _sharedProvider = provider @@ -33,7 +39,7 @@ public final class SessionCallManager: NSObject { } } - class func buildProviderConfiguration(useSystemCallLog: Bool) -> CXProviderConfiguration { + static func buildProviderConfiguration(useSystemCallLog: Bool) -> CXProviderConfiguration { let localizedName = NSLocalizedString("APPLICATION_NAME", comment: "Name of application") let providerConfiguration = CXProviderConfiguration(localizedName: localizedName) providerConfiguration.supportsVideo = true @@ -47,30 +53,37 @@ public final class SessionCallManager: NSObject { return providerConfiguration } + // MARK: - Initialization + init(useSystemCallLog: Bool = false) { - AssertIsOnMainThread() - if SSKPreferences.isCallKitSupported { - self.provider = type(of: self).sharedProvider(useSystemCallLog: useSystemCallLog) + if Preferences.isCallKitSupported { + self.provider = SessionCallManager.sharedProvider(useSystemCallLog: useSystemCallLog) self.callController = CXCallController() - } else { + } + else { self.provider = nil self.callController = nil } + super.init() + // We cannot assert singleton here, because this class gets rebuilt when the user changes relevant call settings self.provider?.setDelegate(self, queue: nil) } - // MARK: Report calls + // MARK: - Report calls + public func reportOutgoingCall(_ call: SessionCall) { AssertIsOnMainThread() - UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(true, forKey: "isCallOngoing") + UserDefaults.sharedLokiProject?.set(true, forKey: "isCallOngoing") + call.stateDidChange = { if call.hasStartedConnecting { - self.provider?.reportOutgoingCall(with: call.callID, startedConnectingAt: call.connectingDate) + self.provider?.reportOutgoingCall(with: call.callId, startedConnectingAt: call.connectingDate) } + if call.hasConnected { - self.provider?.reportOutgoingCall(with: call.callID, connectedAt: call.connectedDate) + self.provider?.reportOutgoingCall(with: call.callId, connectedAt: call.connectedDate) } } } @@ -82,47 +95,59 @@ public final class SessionCallManager: NSObject { // Construct a CXCallUpdate describing the incoming call, including the caller. let update = CXCallUpdate() update.localizedCallerName = callerName - update.remoteHandle = CXHandle(type: .generic, value: call.callID.uuidString) + update.remoteHandle = CXHandle(type: .generic, value: call.callId.uuidString) update.hasVideo = false disableUnsupportedFeatures(callUpdate: update) // Report the incoming call to the system - provider.reportNewIncomingCall(with: call.callID, update: update) { error in + provider.reportNewIncomingCall(with: call.callId, update: update) { error in guard error == nil else { self.reportCurrentCallEnded(reason: .failed) completion(error) return } - UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(true, forKey: "isCallOngoing") + UserDefaults.sharedLokiProject?.set(true, forKey: "isCallOngoing") completion(nil) } } else { - UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(true, forKey: "isCallOngoing") + UserDefaults.sharedLokiProject?.set(true, forKey: "isCallOngoing") completion(nil) } } public func reportCurrentCallEnded(reason: CXCallEndedReason?) { - guard let call = currentCall else { return } - if let reason = reason { - self.provider?.reportCall(with: call.callID, endedAt: nil, reason: reason) - switch (reason) { - case .answeredElsewhere: call.updateCallMessage(mode: .answeredElsewhere) - case .unanswered: call.updateCallMessage(mode: .unanswered) - case .declinedElsewhere: call.updateCallMessage(mode: .local) - default: call.updateCallMessage(mode: .remote) + guard Thread.isMainThread else { + DispatchQueue.main.async { + self.reportCurrentCallEnded(reason: reason) } - } else { + return + } + + guard let call = currentCall else { return } + + if let reason = reason { + self.provider?.reportCall(with: call.callId, endedAt: nil, reason: reason) + + switch (reason) { + case .answeredElsewhere: call.updateCallMessage(mode: .answeredElsewhere) + case .unanswered: call.updateCallMessage(mode: .unanswered) + case .declinedElsewhere: call.updateCallMessage(mode: .local) + default: call.updateCallMessage(mode: .remote) + } + } + else { call.updateCallMessage(mode: .local) } + call.webRTCSession.dropConnection() self.currentCall = nil WebRTCSession.current = nil - UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(false, forKey: "isCallOngoing") + UserDefaults.sharedLokiProject?.set(false, forKey: "isCallOngoing") } - // MARK: Util + // MARK: - Util + private func disableUnsupportedFeatures(callUpdate: CXCallUpdate) { // Call Holding is failing to restart audio when "swapping" calls on the CallKit screen // until user returns to in-app call screen. @@ -136,17 +161,67 @@ public final class SessionCallManager: NSObject { callUpdate.supportsDTMF = false } - public func handleIncomingCallOfferInBusyState(offerMessage: CallMessage, using transaction: YapDatabaseReadWriteTransaction) { - guard let caller = offerMessage.sender, let thread = TSContactThread.fetch(for: caller, using: transaction) else { return } - let message = CallMessage() - message.uuid = offerMessage.uuid - message.kind = .endCall - SNLog("[Calls] Sending end call message because there is an ongoing call.") - MessageSender.sendNonDurably(message, in: thread, using: transaction).retainUntilComplete() - let infoMessage = TSInfoMessage.from(offerMessage, associatedWith: thread) - infoMessage.updateCallInfoMessage(.missed, using: transaction) + // MARK: - UI + + public func showCallUIForCall(caller: String, uuid: String, mode: CallMode, interactionId: Int64?) { + guard Thread.isMainThread else { + DispatchQueue.main.async { + self.showCallUIForCall(caller: caller, uuid: uuid, mode: mode, interactionId: interactionId) + } + return + } + guard let call: SessionCall = GRDBStorage.shared.read({ db in SessionCall(db, for: caller, uuid: uuid, mode: mode) }) else { + return + } + + call.callInteractionId = interactionId + call.reportIncomingCallIfNeeded { error in + if let error = error { + SNLog("[Calls] Failed to report incoming call to CallKit due to error: \(error)") + return + } + + guard CurrentAppContext().isMainAppAndActive else { return } + guard let presentingVC = CurrentAppContext().frontmostViewController() else { + preconditionFailure() // FIXME: Handle more gracefully + } + + if let conversationVC: ConversationVC = presentingVC as? ConversationVC, conversationVC.viewModel.threadData.threadId == call.sessionId { + let callVC = CallVC(for: call) + callVC.conversationVC = conversationVC + conversationVC.inputAccessoryView?.isHidden = true + conversationVC.inputAccessoryView?.alpha = 0 + presentingVC.present(callVC, animated: true, completion: nil) + } + else if !Preferences.isCallKitSupported { + let incomingCallBanner = IncomingCallBanner(for: call) + incomingCallBanner.show() + } + } } + public func handleAnswerMessage(_ message: CallMessage) { + guard Thread.isMainThread else { + DispatchQueue.main.async { + self.handleAnswerMessage(message) + } + return + } + + (CurrentAppContext().frontmostViewController() as? CallVC)?.handleAnswerMessage(message) + } + public func dismissAllCallUI() { + guard Thread.isMainThread else { + DispatchQueue.main.async { + self.dismissAllCallUI() + } + return + } + + IncomingCallBanner.current?.dismiss() + (CurrentAppContext().frontmostViewController() as? CallVC)?.handleEndCallMessage() + MiniCallView.current?.dismiss() + } } diff --git a/Session/Calls/CallVC.swift b/Session/Calls/CallVC.swift index 20a6a3394..704df50d8 100644 --- a/Session/Calls/CallVC.swift +++ b/Session/Calls/CallVC.swift @@ -5,7 +5,7 @@ import SessionUtilitiesKit import UIKit import MediaPlayer -final class CallVC : UIViewController, VideoPreviewDelegate { +final class CallVC: UIViewController, VideoPreviewDelegate { let call: SessionCall var latestKnownAudioOutputDeviceName: String? var durationTimer: Timer? diff --git a/Session/Calls/Views & Modals/CallMissedTipsModal.swift b/Session/Calls/Views & Modals/CallMissedTipsModal.swift index eadb71868..01da57ac9 100644 --- a/Session/Calls/Views & Modals/CallMissedTipsModal.swift +++ b/Session/Calls/Views & Modals/CallMissedTipsModal.swift @@ -1,11 +1,13 @@ -import UIKit +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -@objc -final class CallMissedTipsModal : Modal { +import UIKit +import SessionUIKit + +final class CallMissedTipsModal: Modal { private let caller: String - // MARK: Lifecycle - @objc + // MARK: - Lifecycle + init(caller: String) { self.caller = caller super.init(nibName: nil, bundle: nil) @@ -26,27 +28,37 @@ final class CallMissedTipsModal : Modal { let tipsIconImageView = UIImageView(image: UIImage(named: "Tips")?.withTint(Colors.text)) tipsIconImageView.set(.width, to: 19) tipsIconImageView.set(.height, to: 28) + + // Tips icon container view + let tipsIconContainerView = UIView() + tipsIconContainerView.addSubview(tipsIconImageView) + tipsIconImageView.pin(.top, to: .top, of: tipsIconContainerView) + tipsIconImageView.pin(.bottom, to: .bottom, of: tipsIconContainerView) + tipsIconImageView.center(in: tipsIconContainerView) + // Title let titleLabel = UILabel() titleLabel.textColor = Colors.text titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize) - titleLabel.text = NSLocalizedString("modal_call_missed_tips_title", comment: "") + titleLabel.text = "modal_call_missed_tips_title".localized() titleLabel.textAlignment = .center + // Message let messageLabel = UILabel() messageLabel.textColor = Colors.text messageLabel.font = .systemFont(ofSize: Values.smallFontSize) - let message = String(format: NSLocalizedString("modal_call_missed_tips_explanation", comment: ""), caller) - messageLabel.text = message + messageLabel.text = String(format: "modal_call_missed_tips_explanation".localized(), caller) messageLabel.numberOfLines = 0 messageLabel.lineBreakMode = .byWordWrapping messageLabel.textAlignment = .natural + // Cancel Button - cancelButton.setTitle(NSLocalizedString("OK", comment: ""), for: .normal) + cancelButton.setTitle("BUTTON_OK".localized(), for: .normal) + // Main stack view - let mainStackView = UIStackView(arrangedSubviews: [ tipsIconImageView, titleLabel, messageLabel, cancelButton ]) + let mainStackView = UIStackView(arrangedSubviews: [ tipsIconContainerView, titleLabel, messageLabel, cancelButton ]) mainStackView.axis = .vertical - mainStackView.alignment = .center + mainStackView.alignment = .fill mainStackView.spacing = Values.largeSpacing contentView.addSubview(mainStackView) mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing) diff --git a/Session/Calls/Views & Modals/IncomingCallBanner.swift b/Session/Calls/Views & Modals/IncomingCallBanner.swift index 243a40150..bc570d30d 100644 --- a/Session/Calls/Views & Modals/IncomingCallBanner.swift +++ b/Session/Calls/Views & Modals/IncomingCallBanner.swift @@ -1,5 +1,8 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + import UIKit import WebRTC +import SessionUIKit import SessionMessagingKit final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate { @@ -82,8 +85,12 @@ final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate { self.layer.cornerRadius = Values.largeSpacing self.layer.masksToBounds = true self.set(.height, to: 100) - profilePictureView.publicKey = call.sessionID - profilePictureView.update() + + profilePictureView.update( + publicKey: call.sessionId, + profile: Profile.fetchOrCreate(id: call.sessionId), + threadVariant: .contact + ) displayNameLabel.text = call.contactName let stackView = UIStackView(arrangedSubviews: [profilePictureView, displayNameLabel, hangUpButton, answerButton]) stackView.axis = .horizontal diff --git a/Session/Closed Groups/EditClosedGroupVC.swift b/Session/Closed Groups/EditClosedGroupVC.swift index 060a496b4..a6c2e0731 100644 --- a/Session/Closed Groups/EditClosedGroupVC.swift +++ b/Session/Closed Groups/EditClosedGroupVC.swift @@ -416,7 +416,6 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat } ModalActivityIndicatorViewController.present(fromViewController: navigationController) { _ in - // TODO: WriteAsync??? GRDBStorage.shared .write { db in if !updatedMemberIds.contains(userPublicKey) { diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index 4fb78efb5..13b3b8e0c 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -7,56 +7,72 @@ extension ContextMenuVC { struct Action { let icon: UIImage? let title: String + let isDismissAction: Bool let work: () -> Void static func reply(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_reply"), - title: "context_menu_reply".localized() + title: "context_menu_reply".localized(), + isDismissAction: false ) { delegate?.reply(cellViewModel) } } static func copy(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_copy"), - title: "copy".localized() + title: "copy".localized(), + isDismissAction: false ) { delegate?.copy(cellViewModel) } } static func copySessionID(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_copy"), - title: "vc_conversation_settings_copy_session_id_button_title".localized() + title: "vc_conversation_settings_copy_session_id_button_title".localized(), + isDismissAction: false ) { delegate?.copySessionID(cellViewModel) } } static func delete(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_trash"), - title: "TXT_DELETE_TITLE".localized() + title: "TXT_DELETE_TITLE".localized(), + isDismissAction: false ) { delegate?.delete(cellViewModel) } } static func save(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_download"), - title: "context_menu_save".localized() + title: "context_menu_save".localized(), + isDismissAction: false ) { delegate?.save(cellViewModel) } } static func ban(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_block"), - title: "context_menu_ban_user".localized() + title: "context_menu_ban_user".localized(), + isDismissAction: false ) { delegate?.ban(cellViewModel) } } static func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_block"), - title: "context_menu_ban_and_delete_all".localized() + title: "context_menu_ban_and_delete_all".localized(), + isDismissAction: false ) { delegate?.banAndDeleteAllMessages(cellViewModel) } } + + static func dismiss(_ delegate: ContextMenuActionDelegate?) -> Action { + return Action( + icon: nil, + title: "", + isDismissAction: true + ) { delegate?.contextMenuDismissed() } + } } static func actions(for cellViewModel: MessageViewModel, currentUserIsOpenGroupModerator: Bool, delegate: ContextMenuActionDelegate?) -> [Action]? { @@ -109,7 +125,7 @@ extension ContextMenuVC { currentUserIsOpenGroupModerator ) - return [ + let generatedActions: [Action] = [ (canReply ? Action.reply(cellViewModel, delegate) : nil), (canCopy ? Action.copy(cellViewModel, delegate) : nil), (canSave ? Action.save(cellViewModel, delegate) : nil), @@ -119,6 +135,10 @@ extension ContextMenuVC { (canBan ? Action.banAndDeleteAllMessages(cellViewModel, delegate) : nil) ] .compactMap { $0 } + + guard !generatedActions.isEmpty else { return [] } + + return generatedActions.appending(Action.dismiss(delegate)) } } diff --git a/Session/Conversations/Context Menu/ContextMenuVC.swift b/Session/Conversations/Context Menu/ContextMenuVC.swift index a34ef497b..b80de3eda 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC.swift @@ -111,6 +111,7 @@ final class ContextMenuVC: UIViewController { let menuStackView = UIStackView( arrangedSubviews: actions + .filter { !$0.isDismissAction } .map { action -> ActionView in ActionView(for: action, dismiss: snDismiss) } ) menuStackView.axis = .vertical @@ -176,7 +177,7 @@ final class ContextMenuVC: UIViewController { }, completion: { [weak self] _ in self?.dismiss() - self.delegate?.contextMenuDismissed() + self?.actions.first(where: { $0.isDismissAction })?.work() } ) } diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 7cead44b7..e328ead7c 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -50,10 +50,11 @@ extension ConversationVC: scrollToBottom(isAnimated: true) } - // MARK: Call + // MARK: - Call + @objc func startCall(_ sender: Any?) { guard SessionCall.isEnabled else { return } - guard SSKPreferences.areCallsEnabled else { + guard GRDBStorage.shared[.areCallsEnabled] else { let callPermissionRequestModal = CallPermissionRequestModal() self.navigationController?.present(callPermissionRequestModal, animated: true, completion: nil) return @@ -61,11 +62,15 @@ extension ConversationVC: requestMicrophonePermissionIfNeeded { } + let threadId: String = self.viewModel.threadData.threadId + guard AVAudioSession.sharedInstance().recordPermission == .granted else { return } guard self.viewModel.threadData.threadVariant == .contact else { return } guard AppEnvironment.shared.callManager.currentCall == nil else { return } + guard let call: SessionCall = GRDBStorage.shared.read({ db in SessionCall(db, for: threadId, uuid: UUID().uuidString.lowercased(), mode: .offer, outgoing: true) }) else { + return + } - let call = SessionCall(for: self.viewModel.threadId, uuid: UUID().uuidString.lowercased(), mode: .offer, outgoing: true) let callVC = CallVC(for: call) callVC.conversationVC = self self.inputAccessoryView?.isHidden = true @@ -668,9 +673,9 @@ extension ConversationVC: contextMenuWindow == nil, let actions: [ContextMenuVC.Action] = ContextMenuVC.actions( for: cellViewModel, - currentUserIsOpenGroupModerator: OpenGroupAPIV2.isUserModerator( + currentUserIsOpenGroupModerator: OpenGroupManager.isUserModeratorOrAdmin( self.viewModel.threadData.currentUserPublicKey, - for: self.viewModel.threadData.openGroupRoom, + for: self.viewModel.threadData.openGroupRoomToken, on: self.viewModel.threadData.openGroupServer ), delegate: self @@ -708,6 +713,13 @@ extension ConversationVC: return } + // For call info messages show the "call missed" modal + guard cellViewModel.variant != .infoCall else { + let callMissedTipsModal: CallMissedTipsModal = CallMissedTipsModal(caller: cellViewModel.authorName) + present(callMissedTipsModal, animated: true, completion: nil) + return + } + // If it's an incoming media message and the thread isn't trusted then show the placeholder view if cellViewModel.cellType != .textOnlyMessage && cellViewModel.variant == .standardIncoming && !cellViewModel.threadIsTrusted { let modal = DownloadAttachmentModal(profile: cellViewModel.profile) @@ -721,10 +733,6 @@ extension ConversationVC: switch cellViewModel.cellType { case .audio: viewModel.playOrPauseAudio(for: cellViewModel) - case .call: - let callMissedTipsModal: CallMissedTipsModal = CallMissedTipsModal(caller: cellViewModel.authorName) - present(callMissedTipsModal, animated: true, completion: nil) - case .mediaMessage: guard let sectionIndex: Int = self.viewModel.interactionData @@ -910,24 +918,42 @@ extension ConversationVC: present(userDetailsSheet, animated: true, completion: nil) } - func startThread(with sessionId: String, openGroupServer: String, openGroupPublicKey: String) { - // If the sessionId is blinded then check if there is an existing un-blinded thread with the contact - if SessionId.Prefix(from: sessionId) == .blinded, let mapping: BlindedIdMapping = ContactUtilities.mapping(for: sessionId, serverPublicKey: openGroupPublicKey) { - let thread: TSContactThread = TSContactThread.getOrCreateThread(contactSessionID: mapping.sessionId) - let conversationVC: ConversationVC = ConversationVC(thread: thread) + func startThread(with sessionId: String, openGroupServer: String?, openGroupPublicKey: String?) { + guard SessionId.Prefix(from: sessionId) == .blinded else { + GRDBStorage.shared.write { db in + try SessionThread.fetchOrCreate(db, id: sessionId, variant: .contact) + } + let conversationVC: ConversationVC = ConversationVC(threadId: sessionId, threadVariant: .contact) + self.navigationController?.pushViewController(conversationVC, animated: true) return } - // Just create a new thread with the provided sessionId - let thread = TSContactThread.getOrCreateThread( - contactSessionID: sessionId, - openGroupServer: openGroupServer, - openGroupPublicKey: openGroupPublicKey - ) - let conversationVC: ConversationVC = ConversationVC(thread: thread) + // If the sessionId is blinded then check if there is an existing un-blinded thread with the contact + // and use that, otherwise just use the blinded id + guard let openGroupServer: String = openGroupServer, let openGroupPublicKey: String = openGroupPublicKey else { + return + } + let targetThreadId: String? = GRDBStorage.shared.write { db in + let lookup: BlindedIdLookup = try BlindedIdLookup + .fetchOrCreate( + db, + blindedId: sessionId, + openGroupServer: openGroupServer, + openGroupPublicKey: openGroupPublicKey + ) + + return try SessionThread + .fetchOrCreate(db, id: (lookup.sessionId ?? lookup.blindedId), variant: .contact) + .id + } + + guard let threadId: String = targetThreadId else { return } + + let conversationVC: ConversationVC = ConversationVC(threadId: threadId, threadVariant: .contact) + self.navigationController?.pushViewController(conversationVC, animated: true) } @@ -1105,18 +1131,26 @@ extension ConversationVC: let openGroup: OpenGroup = result?.openGroup, let openGroupServerMessageId: Int64 = result?.openGroupServerMessageId, ( cellViewModel.variant != .standardIncoming || - OpenGroupAPIV2.isUserModerator(userPublicKey, for: openGroup.room, on: openGroup.server) + OpenGroupManager.isUserModeratorOrAdmin( + userPublicKey, + for: openGroup.roomToken, + on: openGroup.server + ) ) else { return } // Delete the message from the open group deleteRemotely( from: self, - request: OpenGroupAPIV2.deleteMessage( - with: openGroupServerMessageId, - from: openGroup.room, - on: openGroup.server - ) + request: GRDBStorage.shared.read { db in + OpenGroupAPI.messageDelete( + db, + id: openGroupServerMessageId, + in: openGroup.roomToken, + on: openGroup.server + ) + .map { _ in () } + } ) { [weak self] in self?.showInputAccessoryView() } @@ -1291,12 +1325,21 @@ extension ConversationVC: preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { [weak self] _ in - guard let openGroup: OpenGroup = GRDBStorage.shared.read({ db in try OpenGroup.fetchOne(db, id: threadId) }) else { - return - } - - OpenGroupAPI - .userBan(cellViewModel.authorId, from: [openGroup.room], on: openGroup.server) + GRDBStorage.shared + .read { db -> Promise in + guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { + return Promise(error: StorageError.objectNotFound) + } + + return OpenGroupAPI + .userBan( + db, + sessionId: cellViewModel.authorId, + from: [openGroup.roomToken], + on: openGroup.server + ) + .map { _ in () } + } .catch(on: DispatchQueue.main) { _ in OWSAlerts.showErrorAlert(message: "context_menu_ban_user_error_alert_message".localized()) } @@ -1321,12 +1364,21 @@ extension ConversationVC: preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { [weak self] _ in - guard let openGroup: OpenGroup = GRDBStorage.shared.read({ db in try OpenGroup.fetchOne(db, id: threadId) }) else { - return - } - - OpenGroupAPI - .userBanAndDeleteAllMessages(cellViewModel.authorId, in: openGroup.room, on: openGroup.server) + GRDBStorage.shared + .read { db -> Promise in + guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { + return Promise(error: StorageError.objectNotFound) + } + + return OpenGroupAPI + .userBanAndDeleteAllMessages( + db, + sessionId: cellViewModel.authorId, + in: openGroup.roomToken, + on: openGroup.server + ) + .map { _ in () } + } .catch(on: DispatchQueue.main) { _ in OWSAlerts.showErrorAlert(message: "context_menu_ban_user_error_alert_message".localized()) } diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index fc297b143..4082d0b70 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -10,6 +10,7 @@ import SignalUtilitiesKit final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, ConversationSearchControllerDelegate, UITableViewDataSource, UITableViewDelegate { private static let loadingHeaderHeight: CGFloat = 20 + private static let messageRequestButtonHeight: CGFloat = 34 internal let viewModel: ConversationViewModel private var dataChangeObservable: DatabaseCancellable? @@ -119,12 +120,6 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers }() // MARK: - UI - - private static let messageRequestButtonHeight: CGFloat = 34 - - var scrollButtonBottomConstraint: NSLayoutConstraint? - var scrollButtonMessageRequestsBottomConstraint: NSLayoutConstraint? - var messageRequestsViewBotomConstraint: NSLayoutConstraint? lazy var titleView: ConversationTitleView = { let result: ConversationTitleView = ConversationTitleView() @@ -154,7 +149,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers result.register(view: VisibleMessageCell.self) result.register(view: InfoMessageCell.self) result.register(view: TypingIndicatorCell.self) - register(CallMessageCell.self, forCellReuseIdentifier: CallMessageCell.identifier) + result.register(view: CallMessageCell.self) result.dataSource = self result.delegate = self @@ -311,12 +306,9 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers // MARK: - Initialization - init?(threadId: String, focusedInteractionId: Int64? = nil) { - guard let viewModel: ConversationViewModel = ConversationViewModel(threadId: threadId, focusedInteractionId: focusedInteractionId) else { - return nil - } + init(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionId: Int64? = nil) { + self.viewModel = ConversationViewModel(threadId: threadId, threadVariant: threadVariant, focusedInteractionId: focusedInteractionId) - self.viewModel = viewModel GRDBStorage.shared.addObserver(viewModel.pagedDataObserver) super.init(nibName: nil, bundle: nil) @@ -346,7 +338,8 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers // nav will be offset incorrectly during the push animation (unfortunately the profile icon still // doesn't appear until after the animation, I assume it's taking a snapshot or something, but // there isn't much we can do about that unfortunately) - updateNavBarButtons(threadData: nil) + updateNavBarButtons(threadData: nil, initialVariant: self.viewModel.initialThreadVariant) + titleView.initialSetup(with: self.viewModel.initialThreadVariant) // Constraints view.addSubview(tableView) @@ -528,12 +521,16 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers if initialLoad || viewModel.threadData.displayName != updatedThreadData.displayName || + viewModel.threadData.threadVariant != updatedThreadData.threadVariant || + viewModel.threadData.threadIsNoteToSelf != updatedThreadData.threadIsNoteToSelf || viewModel.threadData.threadMutedUntilTimestamp != updatedThreadData.threadMutedUntilTimestamp || viewModel.threadData.threadOnlyNotifyForMentions != updatedThreadData.threadOnlyNotifyForMentions || viewModel.threadData.userCount != updatedThreadData.userCount { titleView.update( with: updatedThreadData.displayName, + isNoteToSelf: updatedThreadData.threadIsNoteToSelf, + threadVariant: updatedThreadData.threadVariant, mutedUntilTimestamp: updatedThreadData.threadMutedUntilTimestamp, onlyNotifyForMentions: (updatedThreadData.threadOnlyNotifyForMentions == true), userCount: updatedThreadData.userCount @@ -545,7 +542,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers viewModel.threadData.threadRequiresApproval != updatedThreadData.threadRequiresApproval || viewModel.threadData.profile != updatedThreadData.profile { - updateNavBarButtons(threadData: updatedThreadData) + updateNavBarButtons(threadData: updatedThreadData, initialVariant: viewModel.initialThreadVariant) } if initialLoad || viewModel.threadData.threadIsBlocked != updatedThreadData.threadIsBlocked { @@ -908,7 +905,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers } } - func updateNavBarButtons(threadData: SessionThreadViewModel?) { + func updateNavBarButtons(threadData: SessionThreadViewModel?, initialVariant: SessionThread.Variant) { navigationItem.hidesBackButton = isShowingSearchUI if isShowingSearchUI { @@ -925,13 +922,18 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers frame: CGRect( x: 0, y: 0, - width: (44 - 16), // Width of the standard back button + // Width of the standard back button minus an arbitrary amount to make the + // animation look good + width: (44 - 10), height: 44 ) ) ), - UIBarButtonItem(customView: UIView(frame: CGRect(x: 0, y: 0, width: 44, height: 44))) - ] + (initialVariant == .contact ? + UIBarButtonItem(customView: UIView(frame: CGRect(x: 0, y: 0, width: 44, height: 44))) : + nil + ) + ].compactMap { $0 } return } @@ -954,7 +956,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers settingsButtonItem.accessibilityLabel = "Settings button" settingsButtonItem.isAccessibilityElement = true - if SessionCall.isEnabled && !threadData.threadIsNoteToSelf && !threadData.threadIsMessageRequest { + if SessionCall.isEnabled && !threadData.threadIsNoteToSelf && threadData.threadIsMessageRequest == false { let callButton = UIBarButtonItem( image: UIImage(named: "Phone"), style: .plain, @@ -965,12 +967,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers navigationItem.rightBarButtonItems = [settingsButtonItem, callButton] } else { - navigationItem.rightBarButtonItem = rightBarButtonItem - } - let shouldShowCallButton = SessionCall.isEnabled && !thread.isNoteToSelf() && !thread.isMessageRequest() - if shouldShowCallButton { - let callButton = UIBarButtonItem(image: UIImage(named: "Phone")!, style: .plain, target: self, action: #selector(startCall)) - rightBarButtonItems.append(callButton) + navigationItem.rightBarButtonItem = settingsButtonItem } default: @@ -978,7 +975,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers rightBarButtonItem.accessibilityLabel = "Settings button" rightBarButtonItem.isAccessibilityElement = true - navigationItem.rightBarButtonItem = rightBarButtonItem + navigationItem.rightBarButtonItems = [rightBarButtonItem] } } } @@ -1412,7 +1409,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers } // Nav bar buttons - updateNavBarButtons(threadData: self.viewModel.threadData) + updateNavBarButtons(threadData: self.viewModel.threadData, initialVariant: viewModel.initialThreadVariant) // Hack so that the ResultsBar stays on the screen when dismissing the search field // keyboard. @@ -1448,7 +1445,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers @objc func hideSearchUI() { isShowingSearchUI = false navigationItem.titleView = titleView - updateNavBarButtons(threadData: self.viewModel.threadData) + updateNavBarButtons(threadData: self.viewModel.threadData, initialVariant: viewModel.initialThreadVariant) let navBar: OWSNavigationBar? = navigationController?.navigationBar as? OWSNavigationBar navBar?.stubbedNextResponder = nil diff --git a/Session/Conversations/ConversationViewItem+Refactor.swift b/Session/Conversations/ConversationViewItem+Refactor.swift deleted file mode 100644 index 1c47d6cda..000000000 --- a/Session/Conversations/ConversationViewItem+Refactor.swift +++ /dev/null @@ -1,126 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionSnodeKit -import SessionMessagingKit - -extension ConversationViewItem { - func deleteLocallyAction() { - guard let message: TSMessage = self.interaction as? TSMessage else { return } - - Storage.write { transaction in - MessageInvalidator.invalidate(message, with: transaction) - message.remove(with: transaction) - - if message.interactionType() == .outgoingMessage { - Storage.shared.cancelPendingMessageSendJobIfNeeded(for: message.timestamp, using: transaction) - } - } - } - - func deleteRemotelyAction() { - guard let message: TSMessage = self.interaction as? TSMessage else { return } - - if isGroupThread { - guard let groupThread: TSGroupThread = message.thread as? TSGroupThread else { return } - - // Only allow deletion on incoming and outgoing messages - guard message.interactionType() == .incomingMessage || message.interactionType() == .outgoingMessage else { - return - } - - if groupThread.isOpenGroup { - // Make sure it's an open group message and get the open group - guard message.isOpenGroupMessage, let uniqueId: String = groupThread.uniqueId, let openGroup: OpenGroup = Storage.shared.getOpenGroup(for: uniqueId) else { - return - } - - // If it's an incoming message the user must have moderator status - if message.interactionType() == .incomingMessage { - guard let userPublicKey: String = Storage.shared.getUserPublicKey() else { return } - - if !OpenGroupManager.isUserModeratorOrAdmin(userPublicKey, for: openGroup.room, on: openGroup.server) { - return - } - } - - // Delete the message - OpenGroupAPI.messageDelete(message.openGroupServerMessageID, in: openGroup.room, on: openGroup.server) - .catch { _ in - // Roll back - message.save() - } - .retainUntilComplete() - } - else { - guard let serverHash: String = message.serverHash else { return } - - let groupPublicKey: String = LKGroupUtilities.getDecodedGroupID(groupThread.groupModel.groupId) - - SnodeAPI.deleteMessage(publicKey: groupPublicKey, serverHashes: [serverHash]) - .catch { _ in - // Roll back - message.save() - } - .retainUntilComplete() - } - } - else { - guard let contactThread: TSContactThread = message.thread as? TSContactThread, let serverHash: String = message.serverHash else { - return - } - - SnodeAPI.deleteMessage(publicKey: contactThread.contactSessionID(), serverHashes: [serverHash]) - .catch { _ in - // Roll back - message.save() - } - .retainUntilComplete() - } - } - - // Remove this after the unsend request is enabled - func deleteAction() { - Storage.write { transaction in - self.interaction.remove(with: transaction) - - if self.interaction.interactionType() == .outgoingMessage { - Storage.shared.cancelPendingMessageSendJobIfNeeded(for: self.interaction.timestamp, using: transaction) - } - } - - - if self.isGroupThread { - guard let message: TSMessage = self.interaction as? TSMessage, let groupThread: TSGroupThread = message.thread as? TSGroupThread else { - return - } - - // Only allow deletion on incoming and outgoing messages - guard message.interactionType() == .incomingMessage || message.interactionType() == .outgoingMessage else { - return - } - - // Make sure it's an open group message and get the open group - guard message.isOpenGroupMessage, let uniqueId: String = groupThread.uniqueId, let openGroup: OpenGroup = Storage.shared.getOpenGroup(for: uniqueId) else { - return - } - - // If it's an incoming message the user must have moderator status - if message.interactionType() == .incomingMessage { - guard let userPublicKey: String = Storage.shared.getUserPublicKey() else { return } - - if !OpenGroupManager.isUserModeratorOrAdmin(userPublicKey, for: openGroup.room, on: openGroup.server) { - return - } - } - - // Delete the message - OpenGroupAPI.messageDelete(message.openGroupServerMessageID, in: openGroup.room, on: openGroup.server) - .catch { _ in - // Roll back - message.save() - } - .retainUntilComplete() - } - } -} diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 4bae5c77e..3d0502bc9 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -30,10 +30,31 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { public static let pageSize: Int = 50 + private let threadId: String + public let initialThreadVariant: SessionThread.Variant + public var sentMessageBeforeUpdate: Bool = false + public var lastSearchedText: String? + public let focusedInteractionId: Int64? // Note: This is used for global search + + public lazy var blockedBannerMessage: String = { + switch self.threadData.threadVariant { + case .contact: + let name: String = Profile.displayName( + id: self.threadData.threadId, + threadVariant: self.threadData.threadVariant + ) + + return "\(name) is blocked. Unblock them?" + + default: return "Thread is blocked. Unblock it?" + } + }() + // MARK: - Initialization - init?(threadId: String, focusedInteractionId: Int64?) { + init(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionId: Int64?) { self.threadId = threadId + self.initialThreadVariant = threadVariant self.focusedInteractionId = focusedInteractionId self.pagedDataObserver = nil @@ -109,27 +130,6 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { } } - // MARK: - Variables - - private let threadId: String - public var sentMessageBeforeUpdate: Bool = false - public var lastSearchedText: String? - public let focusedInteractionId: Int64? // Note: This is used for global search - - public lazy var blockedBannerMessage: String = { - switch self.threadData.threadVariant { - case .contact: - let name: String = Profile.displayName( - id: self.threadData.threadId, - threadVariant: self.threadData.threadVariant - ) - - return "\(name) is blocked. Unblock them?" - - default: return "Thread is blocked. Unblock it?" - } - }() - // MARK: - Thread Data /// This value is the current state of the view @@ -211,13 +211,13 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { public struct MentionInfo: FetchableRecord, Decodable { fileprivate static let threadVariantKey = CodingKeys.threadVariant.stringValue - fileprivate static let openGroupRoomKey = CodingKeys.openGroupRoom.stringValue fileprivate static let openGroupServerKey = CodingKeys.openGroupServer.stringValue + fileprivate static let openGroupRoomTokenKey = CodingKeys.openGroupRoomToken.stringValue let profile: Profile let threadVariant: SessionThread.Variant - let openGroupRoom: String? let openGroupServer: String? + let openGroupRoomToken: String? } public func mentions(for query: String = "") -> [MentionInfo] { @@ -236,8 +236,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { MentionInfo( profile: profile, threadVariant: threadData.threadVariant, - openGroupRoom: nil, - openGroupServer: nil + openGroupServer: nil, + openGroupRoomToken: nil ) } .filter { @@ -280,8 +280,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { .select( profile.allColumns(), SQL("\(threadData.threadVariant)").forKey(MentionInfo.threadVariantKey), - SQL("\(threadData.openGroupRoom)").forKey(MentionInfo.openGroupRoomKey), - SQL("\(threadData.openGroupServer)").forKey(MentionInfo.openGroupServerKey) + SQL("\(threadData.openGroupServer)").forKey(MentionInfo.openGroupServerKey), + SQL("\(threadData.openGroupRoomToken)").forKey(MentionInfo.openGroupRoomTokenKey) ) .distinct() .group(Interaction.Columns.authorId) diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift index 8ce66fe30..2e2c13174 100644 --- a/Session/Conversations/Input View/InputView.swift +++ b/Session/Conversations/Input View/InputView.swift @@ -38,7 +38,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M var inputTextViewIsFirstResponder: Bool { inputTextView.isFirstResponder } - var enabledMessageTypes: MessageTypes = .all { + var enabledMessageTypes: MessageInputTypes = .all { didSet { setEnabledMessageTypes(enabledMessageTypes, message: nil) } diff --git a/Session/Conversations/Input View/MentionSelectionView.swift b/Session/Conversations/Input View/MentionSelectionView.swift index 6f005fca7..0eceafcea 100644 --- a/Session/Conversations/Input View/MentionSelectionView.swift +++ b/Session/Conversations/Input View/MentionSelectionView.swift @@ -84,10 +84,10 @@ final class MentionSelectionView: UIView, UITableViewDataSource, UITableViewDele cell.update( with: candidates[indexPath.row].profile, threadVariant: candidates[indexPath.row].threadVariant, - isUserModeratorOrAdmin: OpenGroupAPIV2.isUserModerator( // TODO: This + isUserModeratorOrAdmin: OpenGroupManager.isUserModeratorOrAdmin( candidates[indexPath.row].profile.id, - for: (candidates[indexPath.row].openGroupRoom ?? ""), - on: (candidates[indexPath.row].openGroupServer ?? "") + for: candidates[indexPath.row].openGroupRoomToken, + on: candidates[indexPath.row].openGroupServer ), isLast: (indexPath.row == (candidates.count - 1)) ) diff --git a/Session/Conversations/Message Cells/CallMessageCell.swift b/Session/Conversations/Message Cells/CallMessageCell.swift index f199b49b6..eb87954a9 100644 --- a/Session/Conversations/Message Cells/CallMessageCell.swift +++ b/Session/Conversations/Message Cells/CallMessageCell.swift @@ -1,73 +1,88 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + import UIKit +import SessionUIKit import SessionMessagingKit -final class CallMessageCell : MessageCell { +final class CallMessageCell: MessageCell { + private static let iconSize: CGFloat = 16 + private static let inset = Values.mediumSpacing + private static let margin = UIScreen.main.bounds.width * 0.1 + private lazy var iconImageViewWidthConstraint = iconImageView.set(.width, to: 0) private lazy var iconImageViewHeightConstraint = iconImageView.set(.height, to: 0) private lazy var infoImageViewWidthConstraint = infoImageView.set(.width, to: 0) private lazy var infoImageViewHeightConstraint = infoImageView.set(.height, to: 0) - // MARK: UI Components - private lazy var iconImageView = UIImageView() + // MARK: - UI - private lazy var infoImageView = UIImageView(image: UIImage(named: "ic_info")?.withTint(Colors.text)) + private lazy var iconImageView: UIImageView = UIImageView() + private lazy var infoImageView: UIImageView = { + let result: UIImageView = UIImageView(image: UIImage(named: "ic_info")?.withRenderingMode(.alwaysTemplate)) + result.tintColor = Colors.text + + return result + }() private lazy var timestampLabel: UILabel = { - let result = UILabel() + let result: UILabel = UILabel() result.font = .boldSystemFont(ofSize: Values.verySmallFontSize) result.textColor = Colors.text result.textAlignment = .center + return result }() private lazy var label: UILabel = { - let result = UILabel() + let result: UILabel = UILabel() result.numberOfLines = 0 result.lineBreakMode = .byWordWrapping result.font = .boldSystemFont(ofSize: Values.smallFontSize) result.textColor = Colors.text result.textAlignment = .center + return result }() private lazy var container: UIView = { - let result = UIView() + let result: UIView = UIView() result.set(.height, to: 50) result.layer.cornerRadius = 18 result.backgroundColor = Colors.callMessageBackground result.addSubview(label) + label.autoCenterInSuperview() result.addSubview(iconImageView) + iconImageView.autoVCenterInSuperview() iconImageView.pin(.left, to: .left, of: result, withInset: CallMessageCell.inset) result.addSubview(infoImageView) + infoImageView.autoVCenterInSuperview() infoImageView.pin(.right, to: .right, of: result, withInset: -CallMessageCell.inset) + return result }() private lazy var stackView: UIStackView = { - let result = UIStackView(arrangedSubviews: [ timestampLabel, container ]) + let result: UIStackView = UIStackView(arrangedSubviews: [ timestampLabel, container ]) result.axis = .vertical result.alignment = .center result.spacing = Values.smallSpacing + return result }() - // MARK: Settings - private static let iconSize: CGFloat = 16 - private static let inset = Values.mediumSpacing - private static let margin = UIScreen.main.bounds.width * 0.1 + // MARK: - Lifecycle - override class var identifier: String { "CallMessageCell" } - - // MARK: Lifecycle override func setUpViewHierarchy() { super.setUpViewHierarchy() + iconImageViewWidthConstraint.isActive = true iconImageViewHeightConstraint.isActive = true addSubview(stackView) + container.autoPinWidthToSuperview() stackView.pin(.left, to: .left, of: self, withInset: CallMessageCell.margin) stackView.pin(.top, to: .top, of: self, withInset: CallMessageCell.inset) @@ -81,39 +96,71 @@ final class CallMessageCell : MessageCell { addGestureRecognizer(tapGestureRecognizer) } - // MARK: Updating - override func update() { - guard let message = viewItem?.interaction as? TSInfoMessage, message.messageType == .call else { return } - let icon: UIImage? - switch message.callState { - case .outgoing: icon = UIImage(named: "CallOutgoing")?.withTint(Colors.text) - case .incoming: icon = UIImage(named: "CallIncoming")?.withTint(Colors.text) - case .missed, .permissionDenied: icon = UIImage(named: "CallMissed")?.withTint(Colors.destructive) - default: icon = nil - } - iconImageView.image = icon - iconImageViewWidthConstraint.constant = (icon != nil) ? CallMessageCell.iconSize : 0 - iconImageViewHeightConstraint.constant = (icon != nil) ? CallMessageCell.iconSize : 0 + // MARK: - Updating + + override func update( + with cellViewModel: MessageViewModel, + mediaCache: NSCache, + playbackInfo: ConversationViewModel.PlaybackInfo?, + lastSearchText: String? + ) { + guard + cellViewModel.variant == .infoCall, + let infoMessageData: Data = (cellViewModel.rawBody ?? "").data(using: .utf8), + let messageInfo: CallMessage.MessageInfo = try? JSONDecoder().decode( + CallMessage.MessageInfo.self, + from: infoMessageData + ) + else { return } - let shouldShowInfoIcon = message.callState == .permissionDenied && !SSKPreferences.areCallsEnabled - infoImageViewWidthConstraint.constant = shouldShowInfoIcon ? CallMessageCell.iconSize : 0 - infoImageViewHeightConstraint.constant = shouldShowInfoIcon ? CallMessageCell.iconSize : 0 + self.viewModel = cellViewModel - Storage.read { transaction in - self.label.text = message.previewText(with: transaction) - } + iconImageView.image = { + switch messageInfo.state { + case .outgoing: return UIImage(named: "CallOutgoing")?.withRenderingMode(.alwaysTemplate) + case .incoming: return UIImage(named: "CallIncoming")?.withRenderingMode(.alwaysTemplate) + case .missed, .permissionDenied: return UIImage(named: "CallMissed")?.withRenderingMode(.alwaysTemplate) + default: return nil + } + }() + iconImageView.tintColor = { + switch messageInfo.state { + case .outgoing, .incoming: return Colors.text + case .missed, .permissionDenied: return Colors.destructive + default: return nil + } + }() + iconImageViewWidthConstraint.constant = (iconImageView.image != nil ? CallMessageCell.iconSize : 0) + iconImageViewHeightConstraint.constant = (iconImageView.image != nil ? CallMessageCell.iconSize : 0) - let date = message.dateForUI() - let description = DateUtil.formatDate(forDisplay: date) - timestampLabel.text = description + let shouldShowInfoIcon: Bool = ( + messageInfo.state == .permissionDenied && + !GRDBStorage.shared[.areCallsEnabled] + ) + infoImageViewWidthConstraint.constant = (shouldShowInfoIcon ? CallMessageCell.iconSize : 0) + infoImageViewHeightConstraint.constant = (shouldShowInfoIcon ? CallMessageCell.iconSize : 0) + + label.text = cellViewModel.body + timestampLabel.text = cellViewModel.dateForUI?.formattedForDisplay + } + + override func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) { } @objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) { - guard let viewItem = viewItem, let message = viewItem.interaction as? TSInfoMessage, message.messageType == .call else { return } - let shouldBeTappable = message.callState == .permissionDenied && !SSKPreferences.areCallsEnabled - if shouldBeTappable { - delegate?.handleViewItemTapped(viewItem, gestureRecognizer: gestureRecognizer) - } + guard + let cellViewModel: MessageViewModel = self.viewModel, + cellViewModel.variant == .infoCall, + let infoMessageData: Data = (cellViewModel.rawBody ?? "").data(using: .utf8), + let messageInfo: CallMessage.MessageInfo = try? JSONDecoder().decode( + CallMessage.MessageInfo.self, + from: infoMessageData + ) + else { return } + + // Should only be tappable if the info icon is visible + guard messageInfo.state == .permissionDenied && !GRDBStorage.shared[.areCallsEnabled] else { return } + + self.delegate?.handleItemTapped(cellViewModel, gestureRecognizer: gestureRecognizer) } - } diff --git a/Session/Conversations/Message Cells/Content Views/CallMessageView.swift b/Session/Conversations/Message Cells/Content Views/CallMessageView.swift index 359d1ce98..ffc527311 100644 --- a/Session/Conversations/Message Cells/Content Views/CallMessageView.swift +++ b/Session/Conversations/Message Cells/Content Views/CallMessageView.swift @@ -1,18 +1,19 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -final class CallMessageView : UIView { - private let viewItem: ConversationViewItem - private let textColor: UIColor - - // MARK: Settings +import UIKit +import SessionUIKit +import SessionMessagingKit + +final class CallMessageView: UIView { private static let iconSize: CGFloat = 24 private static let iconImageViewSize: CGFloat = 40 - // MARK: Lifecycle - init(viewItem: ConversationViewItem, textColor: UIColor) { - self.viewItem = viewItem - self.textColor = textColor + // MARK: - Lifecycle + + init(cellViewModel: MessageViewModel, textColor: UIColor) { super.init(frame: CGRect.zero) - setUpViewHierarchy() + + setUpViewHierarchy(cellViewModel: cellViewModel, textColor: textColor) } override init(frame: CGRect) { @@ -23,22 +24,27 @@ final class CallMessageView : UIView { preconditionFailure("Use init(viewItem:textColor:) instead.") } - private func setUpViewHierarchy() { - guard let message = viewItem.interaction as? TSMessage else { preconditionFailure() } + private func setUpViewHierarchy(cellViewModel: MessageViewModel, textColor: UIColor) { // Image view - let iconSize = CallMessageView.iconSize - let icon = UIImage(named: "Phone")?.withTint(textColor)?.resizedImage(to: CGSize(width: iconSize, height: iconSize)) - let imageView = UIImageView(image: icon) + let imageView: UIImageView = UIImageView( + image: UIImage(named: "Phone")? + .withRenderingMode(.alwaysTemplate) + .resizedImage(to: CGSize(width: CallMessageView.iconSize, height: CallMessageView.iconSize)) + ) + imageView.tintColor = textColor imageView.contentMode = .center + let iconImageViewSize = CallMessageView.iconImageViewSize imageView.set(.width, to: iconImageViewSize) imageView.set(.height, to: iconImageViewSize) + // Body label let titleLabel = UILabel() titleLabel.lineBreakMode = .byTruncatingTail - titleLabel.text = message.body + titleLabel.text = cellViewModel.body titleLabel.textColor = textColor titleLabel.font = .systemFont(ofSize: Values.mediumFontSize) + // Stack view let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ]) stackView.axis = .horizontal diff --git a/Session/Conversations/Message Cells/MessageCell.swift b/Session/Conversations/Message Cells/MessageCell.swift index 9642a3695..ef4155580 100644 --- a/Session/Conversations/Message Cells/MessageCell.swift +++ b/Session/Conversations/Message Cells/MessageCell.swift @@ -67,7 +67,7 @@ public class MessageCell: UITableViewCell { .infoMessageRequestAccepted: return InfoMessageCell.self - case .infoMessageCall: + case .infoCall: return CallMessageCell.self } } @@ -83,5 +83,5 @@ protocol MessageCellDelegate: AnyObject { func openUrl(_ urlString: String) func handleReplyButtonTapped(for cellViewModel: MessageViewModel) func showUserDetails(for profile: Profile) - func startThread(with sessionId: String, openGroupServer: String, openGroupPublicKey: String) + func startThread(with sessionId: String, openGroupServer: String?, openGroupPublicKey: String?) } diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index c0c864b6d..370047e7b 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -292,7 +292,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel messageStatusImageView.backgroundColor = backgroundColor messageStatusImageView.isHidden = ( cellViewModel.variant != .standardOutgoing || - cellViewModel.variant == .infoMessageCall || + cellViewModel.variant == .infoCall || ( cellViewModel.state == .sent && !cellViewModel.isLast @@ -329,7 +329,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel ) // Swipe to reply - if cellViewModel.variant == .standardIncomingDeleted || cellViewModel.variant == .infoMessageCall { + if cellViewModel.variant == .standardIncomingDeleted || cellViewModel.variant == .infoCall { removeGestureRecognizer(panGestureRecognizer) } else { @@ -688,21 +688,24 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel let location = gestureRecognizer.location(in: self) - if profilePictureView.frame.contains(location), let profile: Profile = cellViewModel.profile, cellViewModel.shouldShowProfile { + if profilePictureView.frame.contains(location), cellViewModel.shouldShowProfile { // For open groups only attempt to start a conversation if the author has a blinded id - if cellViewModel.threadVariant != .openGroup { - guard let openGroup: OpenGroup = Storage.shared.getOpenGroup(for: message.uniqueThreadId) else { return } + guard cellViewModel.threadVariant != .openGroup else { guard SessionId.Prefix(from: cellViewModel.authorId) == .blinded else { return } delegate?.startThread( with: cellViewModel.authorId, - openGroupServer: openGroup.server, - openGroupPublicKey: openGroup.publicKey + openGroupServer: cellViewModel.threadOpenGroupServer, + openGroupPublicKey: cellViewModel.threadOpenGroupPublicKey ) + return } - else { - delegate?.showUserDetails(for: profile) - } + + delegate?.startThread( + with: cellViewModel.authorId, + openGroupServer: nil, + openGroupPublicKey: nil + ) } else if replyButton.alpha > 0 && replyButton.frame.contains(location) { UIImpactFeedbackGenerator(style: .heavy).impactOccurred() diff --git a/Session/Conversations/Settings/OWSConversationSettingsViewController.h b/Session/Conversations/Settings/OWSConversationSettingsViewController.h index a072027a1..d2970deca 100644 --- a/Session/Conversations/Settings/OWSConversationSettingsViewController.h +++ b/Session/Conversations/Settings/OWSConversationSettingsViewController.h @@ -17,7 +17,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic) BOOL showVerificationOnAppear; -- (void)configureWithThreadId:(NSString *)threadId threadName:(nullable NSString *)threadName isClosedGroup:(BOOL)isClosedGroup isOpenGroup:(BOOL)isOpenGroup isNoteToSelf:(BOOL)isNoteToSelf; +- (void)configureWithThreadId:(NSString *)threadId threadName:(NSString *)threadName isClosedGroup:(BOOL)isClosedGroup isOpenGroup:(BOOL)isOpenGroup isNoteToSelf:(BOOL)isNoteToSelf; @end diff --git a/Session/Conversations/Settings/OWSConversationSettingsViewController.m b/Session/Conversations/Settings/OWSConversationSettingsViewController.m index 78e09e86d..96bc904aa 100644 --- a/Session/Conversations/Settings/OWSConversationSettingsViewController.m +++ b/Session/Conversations/Settings/OWSConversationSettingsViewController.m @@ -268,7 +268,7 @@ CGFloat kIconViewLength = 24; }]]; // Disappearing messages - if (![self isOpenGroup] && !self.thread.isBlocked) { + if (![self isOpenGroup] && ![SMKContact isBlockedFor:self.threadId]) { [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ UITableViewCell *cell = [OWSTableItem newCell]; OWSConversationSettingsViewController *strongSelf = weakSelf; diff --git a/Session/Conversations/Views & Modals/CallModal.swift b/Session/Conversations/Views & Modals/CallModal.swift index d6e512027..d5a4a4073 100644 --- a/Session/Conversations/Views & Modals/CallModal.swift +++ b/Session/Conversations/Views & Modals/CallModal.swift @@ -1,13 +1,21 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SessionUIKit +import SessionMessagingKit @objc -final class CallModal : Modal { +final class CallModal: Modal { private let onCallEnabled: () -> Void - // MARK: Lifecycle + // MARK: - Lifecycle + @objc init(onCallEnabled: @escaping () -> Void) { self.onCallEnabled = onCallEnabled + super.init(nibName: nil, bundle: nil) + self.modalPresentationStyle = .overFullScreen self.modalTransitionStyle = .crossDissolve } @@ -27,15 +35,16 @@ final class CallModal : Modal { titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize) titleLabel.text = NSLocalizedString("modal_call_title", comment: "") titleLabel.textAlignment = .center + // Message let messageLabel = UILabel() messageLabel.textColor = Colors.text messageLabel.font = .systemFont(ofSize: Values.smallFontSize) - let message = NSLocalizedString("modal_call_explanation", comment: "") - messageLabel.text = message + messageLabel.text = "modal_call_explanation".localized() messageLabel.numberOfLines = 0 messageLabel.lineBreakMode = .byWordWrapping messageLabel.textAlignment = .center + // Enable button let enableButton = UIButton() enableButton.set(.height, to: Values.mediumButtonHeight) @@ -45,25 +54,29 @@ final class CallModal : Modal { enableButton.setTitleColor(Colors.text, for: UIControl.State.normal) enableButton.setTitle(NSLocalizedString("modal_link_previews_button_title", comment: ""), for: UIControl.State.normal) enableButton.addTarget(self, action: #selector(enable), for: UIControl.Event.touchUpInside) + // Button stack view let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, enableButton ]) buttonStackView.axis = .horizontal buttonStackView.spacing = Values.mediumSpacing buttonStackView.distribution = .fillEqually + // Main stack view let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ]) mainStackView.axis = .vertical mainStackView.spacing = Values.largeSpacing contentView.addSubview(mainStackView) + mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing) mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing) contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing) contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing) } - // MARK: Interaction + // MARK: - Interaction + @objc private func enable() { - SSKPreferences.areCallsEnabled = true + GRDBStorage.shared.writeAsync { db in db[.areCallsEnabled] = true } presentingViewController?.dismiss(animated: true, completion: nil) onCallEnabled() } diff --git a/Session/Conversations/Views & Modals/ConversationTitleView.swift b/Session/Conversations/Views & Modals/ConversationTitleView.swift index 0052bb240..1f337e75d 100644 --- a/Session/Conversations/Views & Modals/ConversationTitleView.swift +++ b/Session/Conversations/Views & Modals/ConversationTitleView.swift @@ -6,6 +6,9 @@ import SessionMessagingKit import SessionUtilitiesKit final class ConversationTitleView: UIView { + private static let leftInset: CGFloat = 8 + private static let leftInsetWithCallButton: CGFloat = 54 + override var intrinsicContentSize: CGSize { return UIView.layoutFittingExpandedSize } @@ -35,6 +38,7 @@ final class ConversationTitleView: UIView { result.axis = .vertical result.alignment = .center result.isLayoutMarginsRelativeArrangement = true + return result }() @@ -43,25 +47,9 @@ final class ConversationTitleView: UIView { init() { super.init(frame: .zero) - let stackView: UIStackView = UIStackView(arrangedSubviews: [ titleLabel, subtitleLabel ]) - stackView.axis = .vertical - stackView.alignment = .center - stackView.isLayoutMarginsRelativeArrangement = true addSubview(stackView) stackView.pin(to: self) - - let shouldShowCallButton = SessionCall.isEnabled && !thread.isNoteToSelf() && !thread.isGroupThread() - let leftMargin: CGFloat = shouldShowCallButton ? 54 : 8 // Contact threads also have the call button to compensate for - stackView.layoutMargins = UIEdgeInsets(top: 0, left: leftMargin, bottom: 0, right: 0) - - let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap)) - addGestureRecognizer(tapGestureRecognizer) - let notificationCenter = NotificationCenter.default - notificationCenter.addObserver(self, selector: #selector(update), name: Notification.Name.groupThreadUpdated, object: nil) - notificationCenter.addObserver(self, selector: #selector(update), name: Notification.Name.muteSettingUpdated, object: nil) - notificationCenter.addObserver(self, selector: #selector(update), name: Notification.Name.contactUpdated, object: nil) - update() } deinit { @@ -74,15 +62,35 @@ final class ConversationTitleView: UIView { // MARK: - Content + public func initialSetup(with threadVariant: SessionThread.Variant) { + self.update( + with: " ", + isNoteToSelf: false, + threadVariant: threadVariant, + mutedUntilTimestamp: nil, + onlyNotifyForMentions: false, + userCount: (threadVariant != .contact ? 0 : nil) + ) + } + public func update( with name: String, + isNoteToSelf: Bool, + threadVariant: SessionThread.Variant, mutedUntilTimestamp: TimeInterval?, onlyNotifyForMentions: Bool, userCount: Int? ) { guard Thread.isMainThread else { DispatchQueue.main.async { [weak self] in - self?.update(with: name, mutedUntilTimestamp: mutedUntilTimestamp, onlyNotifyForMentions: onlyNotifyForMentions, userCount: userCount) + self?.update( + with: name, + isNoteToSelf: isNoteToSelf, + threadVariant: threadVariant, + mutedUntilTimestamp: mutedUntilTimestamp, + onlyNotifyForMentions: onlyNotifyForMentions, + userCount: userCount + ) } return } @@ -128,5 +136,21 @@ final class ConversationTitleView: UIView { ) ) self.subtitleLabel.attributedText = subtitle + + // Contact threads also have the call button to compensate for + let shouldShowCallButton: Bool = ( + SessionCall.isEnabled && + !isNoteToSelf && + threadVariant == .contact + ) + self.stackView.layoutMargins = UIEdgeInsets( + top: 0, + left: (shouldShowCallButton ? + ConversationTitleView.leftInsetWithCallButton : + ConversationTitleView.leftInset + ), + bottom: 0, + right: 0 + ) } } diff --git a/Session/DMs/NewDMVC.swift b/Session/DMs/NewDMVC.swift index 7635c819b..a943e15c3 100644 --- a/Session/DMs/NewDMVC.swift +++ b/Session/DMs/NewDMVC.swift @@ -157,10 +157,11 @@ final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControll }.catch { error in modalActivityIndicator.dismiss { var messageOrNil: String? - if let error = error as? SnodeAPI.Error { + if let error = error as? SnodeAPIError { switch error { - case .decryptionFailed, .hashingFailed, .validationFailed: messageOrNil = error.errorDescription - default: break + case .decryptionFailed, .hashingFailed, .validationFailed: + messageOrNil = error.errorDescription + default: break } } let message = messageOrNil ?? "Please check the Session ID or ONS name and try again" diff --git a/Session/Home/GlobalSearch/GlobalSearchViewController.swift b/Session/Home/GlobalSearch/GlobalSearchViewController.swift index 929ff6530..bd87a81c5 100644 --- a/Session/Home/GlobalSearch/GlobalSearchViewController.swift +++ b/Session/Home/GlobalSearch/GlobalSearchViewController.swift @@ -252,23 +252,20 @@ extension GlobalSearchViewController { case .contactsAndGroups, .messages: show( threadId: section.elements[indexPath.row].threadId, + threadVariant: section.elements[indexPath.row].threadVariant, focusedInteractionId: section.elements[indexPath.row].interactionId ) } } - private func show(threadId: String, focusedInteractionId: Int64? = nil, animated: Bool = true) { + private func show(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionId: Int64? = nil, animated: Bool = true) { guard Thread.isMainThread else { DispatchQueue.main.async { [weak self] in - self?.show(threadId: threadId, focusedInteractionId: focusedInteractionId, animated: animated) + self?.show(threadId: threadId, threadVariant: threadVariant, focusedInteractionId: focusedInteractionId, animated: animated) } return } - guard let conversationVC: ConversationVC = ConversationVC(threadId: threadId, focusedInteractionId: focusedInteractionId) else { - return - } - if let presentedVC = self.presentedViewController { presentedVC.dismiss(animated: false, completion: nil) } @@ -276,7 +273,9 @@ extension GlobalSearchViewController { let viewControllers: [UIViewController] = (self.navigationController? .viewControllers) .defaulting(to: []) - .appending(conversationVC) + .appending( + ConversationVC(threadId: threadId, threadVariant: threadVariant, focusedInteractionId: focusedInteractionId) + ) self.navigationController?.setViewControllers(viewControllers, animated: true) } diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 44cd38959..a1c3e00d9 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -392,8 +392,13 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve self.navigationController?.pushViewController(viewController, animated: true) case .threads: - let threadId: String = section.elements[indexPath.row].threadId - show(threadId, with: .none, focusedInteractionId: nil, animated: true) + show( + section.elements[indexPath.row].threadId, + variant: section.elements[indexPath.row].threadVariant, + with: .none, + focusedInteractionId: nil, + animated: true + ) } } @@ -522,18 +527,16 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve func show( _ threadId: String, + variant: SessionThread.Variant, with action: ConversationViewModel.Action, focusedInteractionId: Int64?, animated: Bool ) { - guard let conversationVC: ConversationVC = ConversationVC(threadId: threadId, focusedInteractionId: focusedInteractionId) else { - return - } - if let presentedVC = self.presentedViewController { presentedVC.dismiss(animated: false, completion: nil) } + let conversationVC: ConversationVC = ConversationVC(threadId: threadId, threadVariant: variant, focusedInteractionId: focusedInteractionId) self.navigationController?.setViewControllers([ self, conversationVC ], animated: true) } diff --git a/Session/Home/Message Requests/MessageRequestsViewController.swift b/Session/Home/Message Requests/MessageRequestsViewController.swift index 6fdd40050..593dbd6fe 100644 --- a/Session/Home/Message Requests/MessageRequestsViewController.swift +++ b/Session/Home/Message Requests/MessageRequestsViewController.swift @@ -222,11 +222,11 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - - guard let conversationVC: ConversationVC = ConversationVC(threadId: viewModel.viewData[indexPath.row].threadId) else { - return - } + let conversationVC: ConversationVC = ConversationVC( + threadId: viewModel.viewData[indexPath.row].threadId, + threadVariant: viewModel.viewData[indexPath.row].threadVariant + ) self.navigationController?.pushViewController(conversationVC, animated: true) } diff --git a/Session/Home/Views/MessageRequestsCell.swift b/Session/Home/Message Requests/Views/MessageRequestsCell.swift similarity index 100% rename from Session/Home/Views/MessageRequestsCell.swift rename to Session/Home/Message Requests/Views/MessageRequestsCell.swift diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index ab9934fc4..27f6c6680 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -145,6 +145,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD name: .dataNukeRequested, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(showMissedCallTipsIfNeeded(_:)), + name: .missedCall, + object: nil + ) Logger.info("application: didFinishLaunchingWithOptions completed.") @@ -284,6 +290,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD if CurrentAppContext().isMainApp { syncConfigurationIfNeeded() + handleAppActivatedWithOngoingCallIfNeeded() } } @@ -442,12 +449,36 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD } } + @objc public func showMissedCallTipsIfNeeded(_ notification: Notification) { + guard !UserDefaults.standard[.hasSeenCallMissedTips] else { return } + guard Thread.isMainThread else { + DispatchQueue.main.async { + self.showMissedCallTipsIfNeeded(notification) + } + return + } + guard let callerId: String = notification.userInfo?[Notification.Key.senderId.rawValue] as? String else { + return + } + guard let presentingVC = CurrentAppContext().frontmostViewController() else { preconditionFailure() } + + let callMissedTipsModal: CallMissedTipsModal = CallMissedTipsModal( + caller: Profile.displayName(id: callerId) + ) + presentingVC.present(callMissedTipsModal, animated: true, completion: nil) + + UserDefaults.standard[.hasSeenCallMissedTips] = true + } + // MARK: - Polling - public func startPollersIfNeeded() { + public func startPollersIfNeeded(shouldStartGroupPollers: Bool = true) { guard Identity.userExists() else { return } poller.startIfNeeded() + + guard shouldStartGroupPollers else { return } + ClosedGroupPoller.shared.start() OpenGroupManager.shared.startPolling() } @@ -532,17 +563,21 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD } func handleAppActivatedWithOngoingCallIfNeeded() { - guard let call = AppEnvironment.shared.callManager.currentCall else { return } - guard MiniCallView.current == nil else { return } + guard + let call: SessionCall = (AppEnvironment.shared.callManager.currentCall as? SessionCall), + MiniCallView.current == nil + else { return } - if let callVC = CurrentAppContext().frontmostViewController() as? CallVC, callVC.call == call { return } + if let callVC = CurrentAppContext().frontmostViewController() as? CallVC, callVC.call.uuid == call.uuid { + return + } // FIXME: Handle more gracefully guard let presentingVC = CurrentAppContext().frontmostViewController() else { preconditionFailure() } - let callVC = CallVC(for: call) + let callVC: CallVC = CallVC(for: call) - if let conversationVC = presentingVC as? ConversationVC, let contactThread = conversationVC.thread as? TSContactThread, contactThread.contactSessionID() == call.sessionID { + if let conversationVC: ConversationVC = presentingVC as? ConversationVC, conversationVC.viewModel.threadData.threadId == call.sessionId { callVC.conversationVC = conversationVC conversationVC.inputAccessoryView?.isHidden = true conversationVC.inputAccessoryView?.alpha = 0 @@ -551,155 +586,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD presentingVC.present(callVC, animated: true, completion: nil) } - private func dismissAllCallUI() { - if let currentBanner = IncomingCallBanner.current { currentBanner.dismiss() } - if let callVC = CurrentAppContext().frontmostViewController() as? CallVC { callVC.handleEndCallMessage() } - if let miniCallView = MiniCallView.current { miniCallView.dismiss() } - } - - private func showCallUIForCall(_ call: SessionCall) { - DispatchQueue.main.async { - call.reportIncomingCallIfNeeded{ error in - if let error = error { - SNLog("[Calls] Failed to report incoming call to CallKit due to error: \(error)") - } - else { - if CurrentAppContext().isMainAppAndActive { - guard let presentingVC = CurrentAppContext().frontmostViewController() else { - preconditionFailure() // FIXME: Handle more gracefully - } - - if let conversationVC = presentingVC as? ConversationVC, let contactThread = conversationVC.thread as? TSContactThread, contactThread.contactSessionID() == call.sessionID { - let callVC = CallVC(for: call) - callVC.conversationVC = conversationVC - conversationVC.inputAccessoryView?.isHidden = true - conversationVC.inputAccessoryView?.alpha = 0 - presentingVC.present(callVC, animated: true, completion: nil) - } - else if !SSKPreferences.isCallKitSupported { - let incomingCallBanner = IncomingCallBanner(for: call) - incomingCallBanner.show() - } - } - } - } - } - } - - private func insertCallInfoMessage(for message: CallMessage, using transaction: YapDatabaseReadWriteTransaction) -> TSInfoMessage? { - guard let sender = message.sender, let uuid = message.uuid else { return nil } - - var receivedCalls = Storage.shared.getReceivedCalls(for: sender, using: transaction) - - guard !receivedCalls.contains(uuid) else { return nil } - - let thread = TSContactThread.getOrCreateThread(withContactSessionID: message.sender!, transaction: transaction) - let infoMessage = TSInfoMessage.from(message, associatedWith: thread) - infoMessage.save(with: transaction) - receivedCalls.insert(uuid) - Storage.shared.setReceivedCalls(to: receivedCalls, for: sender, using: transaction) - - return infoMessage - } - - private func showMissedCallTipsIfNeeded(caller: String) { - guard !UserDefaults.standard[.hasSeenCallMissedTips] else { return } - guard let presentingVC = CurrentAppContext().frontmostViewController() else { preconditionFailure() } - - let callMissedTipsModal = CallMissedTipsModal(caller: caller) - presentingVC.present(callMissedTipsModal, animated: true, completion: nil) - - userDefaults[.hasSeenCallMissedTips] = true - } - - func setUpCallHandling() { - // Pre offer messages - MessageReceiver.handleNewCallOfferMessageIfNeeded = { (message, transaction) in - guard CurrentAppContext().isMainApp else { return } - guard let timestamp = message.sentTimestamp, TimestampUtils.isWithinOneMinute(timestamp: timestamp) else { - // Add missed call message for call offer messages from more than one minute - if let infoMessage = self.insertCallInfoMessage(for: message, using: transaction) { - infoMessage.updateCallInfoMessage(.missed, using: transaction) - let thread = TSContactThread.getOrCreateThread(withContactSessionID: message.sender!, transaction: transaction) - SSKEnvironment.shared.notificationsManager?.notifyUser(forIncomingCall: infoMessage, in: thread, transaction: transaction) - } - return - } - - guard SSKPreferences.areCallsEnabled else { - if let infoMessage = self.insertCallInfoMessage(for: message, using: transaction) { - infoMessage.updateCallInfoMessage(.permissionDenied, using: transaction) - let thread = TSContactThread.getOrCreateThread(withContactSessionID: message.sender!, transaction: transaction) - SSKEnvironment.shared.notificationsManager?.notifyUser(forIncomingCall: infoMessage, in: thread, transaction: transaction) - let contactName = Storage.shared.getContact(with: message.sender!, using: transaction)?.displayName(for: Contact.Context.regular) ?? message.sender! - DispatchQueue.main.async { - self.showMissedCallTipsIfNeeded(caller: contactName) - } - } - return - } - - let callManager = AppEnvironment.shared.callManager - - // Ignore pre offer message after the same call instance has been generated - if let currentCall = callManager.currentCall, currentCall.uuid == message.uuid! { return } - - guard callManager.currentCall == nil else { - callManager.handleIncomingCallOfferInBusyState(offerMessage: message, using: transaction) - return - } - - let infoMessage = self.insertCallInfoMessage(for: message, using: transaction) - - // Handle UI - if let caller = message.sender, let uuid = message.uuid { - let call = SessionCall(for: caller, uuid: uuid, mode: .answer) - call.callMessageID = infoMessage?.uniqueId - self.showCallUIForCall(call) - } - } - - // Offer messages - MessageReceiver.handleOfferCallMessage = { message in - DispatchQueue.main.async { - guard let call = AppEnvironment.shared.callManager.currentCall, message.uuid! == call.uuid else { return } - let sdp = RTCSessionDescription(type: .offer, sdp: message.sdps![0]) - call.didReceiveRemoteSDP(sdp: sdp) - } - } - - // Answer messages - MessageReceiver.handleAnswerCallMessage = { message in - DispatchQueue.main.async { - guard let call = AppEnvironment.shared.callManager.currentCall, message.uuid! == call.uuid else { return } - if message.sender! == getUserHexEncodedPublicKey() { - guard !call.hasStartedConnecting else { return } - self.dismissAllCallUI() - AppEnvironment.shared.callManager.reportCurrentCallEnded(reason: .answeredElsewhere) - } else { - call.hasStartedConnecting = true - let sdp = RTCSessionDescription(type: .answer, sdp: message.sdps![0]) - call.didReceiveRemoteSDP(sdp: sdp) - guard let callVC = CurrentAppContext().frontmostViewController() as? CallVC else { return } - callVC.handleAnswerMessage(message) - } - } - } - - // End call messages - MessageReceiver.handleEndCallMessage = { message in - DispatchQueue.main.async { - guard let call = AppEnvironment.shared.callManager.currentCall, message.uuid! == call.uuid else { return } - self.dismissAllCallUI() - if message.sender! == getUserHexEncodedPublicKey() { - AppEnvironment.shared.callManager.reportCurrentCallEnded(reason: .declinedElsewhere) - } else { - AppEnvironment.shared.callManager.reportCurrentCallEnded(reason: .remoteEnded) - } - } - } - } - // MARK: - Config Sync func syncConfigurationIfNeeded() { diff --git a/Session/Meta/AppEnvironment.swift b/Session/Meta/AppEnvironment.swift index aaba5a6c0..33ec44a6d 100644 --- a/Session/Meta/AppEnvironment.swift +++ b/Session/Meta/AppEnvironment.swift @@ -38,14 +38,15 @@ public class AppEnvironment { self.pushRegistrationManager = PushRegistrationManager() self._userNotificationActionHandler = UserNotificationActionHandler() self.fileLogger = DDFileLogger() - - super.init() - + SwiftSingletons.register(self) } public func setup() { - // Hang certain singletons on SSKEnvironment too. + // Hang certain singletons on Environment too. + Environment.shared.callManager.mutate { + $0 = callManager + } Environment.shared.notificationsManager.mutate { $0 = notificationPresenter } diff --git a/Session/Meta/SessionApp.swift b/Session/Meta/SessionApp.swift index dd00e2683..c223987b2 100644 --- a/Session/Meta/SessionApp.swift +++ b/Session/Meta/SessionApp.swift @@ -14,10 +14,11 @@ public struct SessionApp { try SessionThread.fetchOrCreate(db, id: threadId, variant: .contact) } - guard maybeThread != nil else { return } + guard let variant: SessionThread.Variant = maybeThread?.variant else { return } self.presentConversation( for: threadId, + threadVariant: variant, action: action, focusInteractionId: nil, animated: animated @@ -26,6 +27,7 @@ public struct SessionApp { public static func presentConversation( for threadId: String, + threadVariant: SessionThread.Variant, action: ConversationViewModel.Action, focusInteractionId: Int64?, animated: Bool @@ -34,6 +36,7 @@ public struct SessionApp { DispatchQueue.main.async { self.presentConversation( for: threadId, + threadVariant: threadVariant, action: action, focusInteractionId: focusInteractionId, animated: animated @@ -44,6 +47,7 @@ public struct SessionApp { homeViewController.wrappedValue?.show( threadId, + variant: threadVariant, with: action, focusedInteractionId: focusInteractionId, animated: animated diff --git a/Session/Notifications/AppNotifications.swift b/Session/Notifications/AppNotifications.swift index da29f96d9..b9cc2d4cf 100644 --- a/Session/Notifications/AppNotifications.swift +++ b/Session/Notifications/AppNotifications.swift @@ -273,7 +273,7 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return } guard thread.variant != .closedGroup && thread.variant != .openGroup else { return } guard - interaction.variant == .infoMessageCall, + interaction.variant == .infoCall, let infoMessageData: Data = (interaction.body ?? "").data(using: .utf8), let messageInfo: CallMessage.MessageInfo = try? JSONDecoder().decode( CallMessage.MessageInfo.self, diff --git a/Session/Notifications/PushRegistrationManager.swift b/Session/Notifications/PushRegistrationManager.swift index 1856caa58..6ba0134c4 100644 --- a/Session/Notifications/PushRegistrationManager.swift +++ b/Session/Notifications/PushRegistrationManager.swift @@ -241,18 +241,37 @@ public enum PushRegistrationError: Error { owsAssertDebug(CurrentAppContext().isMainApp) owsAssertDebug(type == .voIP) let payload = payload.dictionaryPayload - if let uuid = payload["uuid"] as? String, let caller = payload["caller"] as? String, let timestamp = payload["timestamp"] as? UInt64 { - let call = SessionCall(for: caller, uuid: uuid, mode: .answer) - Storage.write{ transaction in - let thread = TSContactThread.getOrCreateThread(withContactSessionID: caller, transaction: transaction) - let infoMessage = TSInfoMessage.callInfoMessage(from: caller, timestamp: timestamp, in: thread) - infoMessage.save(with: transaction) - call.callMessageID = infoMessage.uniqueId + if let uuid = payload["uuid"] as? String, let caller = payload["caller"] as? String, let timestampMs = payload["timestamp"] as? Int64 { + let call: SessionCall? = GRDBStorage.shared.write { db in + let messageInfo: CallMessage.MessageInfo = CallMessage.MessageInfo( + state: (caller == getUserHexEncodedPublicKey(db) ? + .outgoing : + .incoming + ) + ) + + guard let messageInfoData: Data = try? JSONEncoder().encode(messageInfo) else { return nil } + + let call: SessionCall = SessionCall(db, for: caller, uuid: uuid, mode: .answer) + let thread: SessionThread = try SessionThread.fetchOrCreate(db, id: caller, variant: .contact) + + let interaction: Interaction = try Interaction( + messageUuid: uuid, + threadId: thread.id, + authorId: caller, + variant: .infoCall, + body: String(data: messageInfoData, encoding: .utf8), + timestampMs: timestampMs + ).inserted(db) + call.callInteractionId = interaction.id + + return call } - let appDelegate = UIApplication.shared.delegate as! AppDelegate + // NOTE: Just start 1-1 poller so that it won't wait for polling group messages - appDelegate.startPollerIfNeeded() - call.reportIncomingCallIfNeeded { error in + (UIApplication.shared.delegate as? AppDelegate)?.startPollersIfNeeded(shouldStartGroupPollers: false) + + call?.reportIncomingCallIfNeeded { error in if let error = error { SNLog("[Calls] Failed to report incoming call to CallKit due to error: \(error)") } diff --git a/Session/Open Groups/JoinOpenGroupVC.swift b/Session/Open Groups/JoinOpenGroupVC.swift index a88eab7ed..aca0f2ce6 100644 --- a/Session/Open Groups/JoinOpenGroupVC.swift +++ b/Session/Open Groups/JoinOpenGroupVC.swift @@ -60,7 +60,7 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC setNavBarTitle("vc_join_public_chat_title".localized()) // Navigation bar buttons - let navBarHeight: CGFloat = (navigationController?.navigationBar.height() ?? 0) + let navBarHeight: CGFloat = (navigationController?.navigationBar.frame.size.height ?? 0) let closeButton = UIBarButtonItem(image: #imageLiteral(resourceName: "X"), style: .plain, target: self, action: #selector(close)) closeButton.tintColor = Colors.text navigationItem.leftBarButtonItem = closeButton @@ -79,7 +79,7 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC .top, to: .top, of: view, - withInset: (UIDevice.current.isIPad ? navigationBar.height() + 20 : navigationBar.height()) + withInset: (UIDevice.current.isIPad ? navBarHeight + 20 : navBarHeight) ) view.pin(.trailing, to: .trailing, of: tabBar) @@ -163,9 +163,17 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC ModalActivityIndicatorViewController.present(fromViewController: navigationController, canCancel: false) { [weak self] _ in GRDBStorage.shared - .write { db in OpenGroupManager.shared.add(db, roomToken: roomToken, server: server, publicKey: publicKey, isConfigMessage: false) } + .write { db in + OpenGroupManager.shared.add( + db, + roomToken: roomToken, + server: server, + publicKey: publicKey, + isConfigMessage: false + ) + } .done(on: DispatchQueue.main) { [weak self] _ in - GRDBStorage.shared.write { db in + GRDBStorage.shared.writeAsync { db in try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...) } @@ -216,6 +224,7 @@ private final class EnterURLVC: UIViewController, UIGestureRecognizerDelegate, O result.text = "vc_join_open_group_suggestions_title".localized() result.numberOfLines = 0 result.lineBreakMode = .byWordWrapping + result.setContentHuggingPriority(.required, for: .vertical) return result }() @@ -299,11 +308,19 @@ private final class EnterURLVC: UIViewController, UIGestureRecognizerDelegate, O func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { let location = gestureRecognizer.location(in: view) - return !suggestionGrid.frame.contains(location) + + return ( + (!suggestionGrid.isHidden && !suggestionGrid.frame.contains(location)) || + location.y > urlTextView.frame.maxY + ) } func join(_ room: OpenGroupAPI.Room) { - joinOpenGroupVC.joinOpenGroup(roomToken: room.token, server: OpenGroupAPI.defaultServer, publicKey: OpenGroupAPI.defaultServerPublicKey) + joinOpenGroupVC?.joinOpenGroup( + roomToken: room.token, + server: OpenGroupAPI.defaultServer, + publicKey: OpenGroupAPI.defaultServerPublicKey + ) } @objc private func joinOpenGroup() { @@ -317,9 +334,10 @@ private final class EnterURLVC: UIViewController, UIGestureRecognizerDelegate, O guard !isKeyboardShowing else { return } isKeyboardShowing = true - guard let newHeight = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue.size.height else { return } + guard let endFrame: CGRect = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue else { return } + guard endFrame.minY < UIScreen.main.bounds.height else { return } - bottomConstraint.constant = newHeight + bottomMargin + bottomConstraint.constant = endFrame.size.height + bottomMargin UIView.animate( withDuration: 0.25, diff --git a/Session/Open Groups/OpenGroupSuggestionGrid.swift b/Session/Open Groups/OpenGroupSuggestionGrid.swift index 78e7da8c6..3527a24d5 100644 --- a/Session/Open Groups/OpenGroupSuggestionGrid.swift +++ b/Session/Open Groups/OpenGroupSuggestionGrid.swift @@ -245,12 +245,14 @@ extension OpenGroupSuggestionGrid { label.text = room.name // Only continue if we have a room image - guard let imageId: UInt64 = room.imageId else { + guard let imageId: Int64 = room.imageId else { imageView.isHidden = true return } - let promise = OpenGroupManager.roomImage(imageId, for: room.token, on: OpenGroupAPI.defaultServer) + let promise = GRDBStorage.shared.read { db in + OpenGroupManager.roomImage(db, fileId: imageId, for: room.token, on: OpenGroupAPI.defaultServer) + } if let imageData: Data = promise.value { imageView.image = UIImage(data: imageData) diff --git a/Session/Settings/PrivacySettingsTableViewController.m b/Session/Settings/PrivacySettingsTableViewController.m index ecc995d81..de52c3f75 100644 --- a/Session/Settings/PrivacySettingsTableViewController.m +++ b/Session/Settings/PrivacySettingsTableViewController.m @@ -192,7 +192,7 @@ static NSString *const kSealedSenderInfoURL = @"https://signal.org/blog/sealed-s @"Setting for enabling & disabling voice & video calls.") accessibilityIdentifier:[NSString stringWithFormat:@"settings.privacy.%@", @"calls"] isOnBlock:^{ - return [SSKPreferences areCallsEnabled]; + return [SMKPreferences areCallsEnabled]; } isEnabledBlock:^{ return YES; @@ -277,9 +277,10 @@ static NSString *const kSealedSenderInfoURL = @"https://signal.org/blog/sealed-s [self objc_requestMicrophonePermissionIfNeeded]; }]; [self presentViewController:modal animated:YES completion:nil]; - } else { + } + else { OWSLogInfo(@"toggled to: %@", (enabled ? @"ON" : @"OFF")); - SSKPreferences.areCallsEnabled = enabled; + [SMKPreferences setCallsEnabled:enabled]; } } diff --git a/Session/Utilities/BackgroundPoller.swift b/Session/Utilities/BackgroundPoller.swift index cd145fc84..034e42c30 100644 --- a/Session/Utilities/BackgroundPoller.swift +++ b/Session/Utilities/BackgroundPoller.swift @@ -17,13 +17,21 @@ public final class BackgroundPoller: NSObject { public static func poll(completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { promises = [] .appending(pollForMessages()) - .appending(pollForClosedGroupMessages()) + .appending(contentsOf: pollForClosedGroupMessages()) .appending( - GRDBStorage.shared - .read { db in try OpenGroup.fetchAll(db) } + contentsOf: GRDBStorage.shared + .read { db in + // The default room promise creates an OpenGroup with an empty `roomToken` value, + // we don't want to start a poller for this as the user hasn't actually joined a room + try OpenGroup + .select(.server) + .filter(OpenGroup.Columns.roomToken != "") + .filter(OpenGroup.Columns.isActive) + .distinct() + .asRequest(of: String.self) + .fetchSet(db) + } .defaulting(to: []) - .map { openGroup -> String in openGroup.server } - .asSet() .map { server in let poller: OpenGroupAPI.Poller = OpenGroupAPI.Poller(for: server) poller.stop() @@ -63,14 +71,19 @@ public final class BackgroundPoller: NSObject { } .defaulting(to: []) .map { groupPublicKey in - getClosedGroupMessages(for: groupPublicKey) + ClosedGroupPoller.poll( + groupPublicKey, + on: DispatchQueue.main, + maxRetryCount: 4, + isBackgroundPoll: true + ) } } private static func getMessages(for publicKey: String) -> Promise { return SnodeAPI.getSwarm(for: publicKey) .then(on: DispatchQueue.main) { swarm -> Promise in - guard let snode = swarm.randomElement() else { throw SnodeAPI.Error.generic } + guard let snode = swarm.randomElement() else { throw SnodeAPIError.generic } return attempt(maxRetryCount: 4, recoveringOn: DispatchQueue.main) { return SnodeAPI.getMessages(from: snode, associatedWith: publicKey) @@ -119,12 +132,14 @@ public final class BackgroundPoller: NSObject { guard let job: Job = maybeJob else { return } - JobRunner.add(db, job: job) + // Add to the JobRunner so they are persistent and will retry on + // the next app run if they fail + JobRunner.add(db, job: job, canStartJob: false) jobsToRun.append(job) } } - let promises = jobsToRun.compactMap { job -> Promise? in + let promises: [Promise] = jobsToRun.map { job -> Promise in let (promise, seal) = Promise.pending() // Note: In the background we just want jobs to fail silently @@ -143,100 +158,4 @@ public final class BackgroundPoller: NSObject { } } } - - private static func getClosedGroupMessages(for publicKey: String) -> Promise { - return SnodeAPI.getSwarm(for: publicKey) - .then(on: DispatchQueue.main) { swarm -> Promise in - guard let snode = swarm.randomElement() else { throw SnodeAPI.Error.generic } - - return attempt(maxRetryCount: 4, recoveringOn: DispatchQueue.main) { - var promises: [Promise] = [] - var namespaces: [Int] = [] - - // We have to poll for both namespace 0 and -10 when hardfork == 19 && softfork == 0 - if SnodeAPI.hardfork <= 19, SnodeAPI.softfork == 0 { - let promise = SnodeAPI.getRawClosedGroupMessagesFromDefaultNamespace(from: snode, associatedWith: publicKey) - promises.append(promise) - namespaces.append(SnodeAPI.defaultNamespace) - } - - if SnodeAPI.hardfork >= 19 && SnodeAPI.softfork >= 0 { - let promise = SnodeAPI.getRawMessages(from: snode, associatedWith: publicKey, authenticated: false) - promises.append(promise) - namespaces.append(SnodeAPI.closedGroupNamespace) - } - - return when(resolved: promises) - .then(on: DispatchQueue.main) { results -> Promise in - var promises: [Promise] = [] - var index = 0 - - for result in results { - if case .fulfilled(let messages) = result { - guard !messages.isEmpty else { return Promise.value(()) } - - var jobsToRun: [Job] = [] - - GRDBStorage.shared.write { db in - var jobDetailMessages: [MessageReceiveJob.Details.MessageInfo] = [] - - messages.forEach { message in - do { - let processedMessage: ProcessedMessage? = try Message.processRawReceivedMessage(db, rawMessage: message) - - jobDetailMessages = jobDetailMessages - .appending(processedMessage?.messageInfo) - } - 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, - MessageReceiverError.duplicateMessage, - MessageReceiverError.duplicateControlMessage, - MessageReceiverError.selfSend: - break - - default: SNLog("Failed to deserialize envelope due to error: \(error).") - } - } - } - - let maybeJob: Job? = Job( - variant: .messageReceive, - behaviour: .runOnce, - threadId: groupPublicKey, - details: MessageReceiveJob.Details( - messages: jobDetailMessages, - isBackgroundPoll: true - ) - ) - - guard let job: Job = maybeJob else { return } - - JobRunner.add(db, job: job) - jobsToRun.append(job) - } - - let (promise, seal) = Promise.pending() - - // Note: In the background we just want jobs to fail silently - MessageReceiveJob.run( - job, - success: { _, _ in seal.fulfill(()) }, - failure: { _, _, _ in seal.fulfill(()) }, - deferred: { _ in seal.fulfill(()) } - ) - - promises.append(promise) - } - - index += 1 - } - - return when(fulfilled: promises) // The promise returned by MessageReceiveJob never rejects - } - } - } - } } diff --git a/Session/Utilities/MockDataGenerator.swift b/Session/Utilities/MockDataGenerator.swift index 0aceae041..2eed4aeae 100644 --- a/Session/Utilities/MockDataGenerator.swift +++ b/Session/Utilities/MockDataGenerator.swift @@ -323,17 +323,17 @@ enum MockDataGenerator { let randomGroupPublicKey: String = ((0..<32).map { _ in UInt8.random(in: UInt8.min...UInt8.max, using: &dmThreadRandomGenerator) }).toHexString() let serverNameLength: Int = ((5..<20).randomElement(using: &ogThreadRandomGenerator) ?? 0) let roomNameLength: Int = ((5..<20).randomElement(using: &ogThreadRandomGenerator) ?? 0) - let groupDescriptionLength: Int = ((10..<50).randomElement(using: &ogThreadRandomGenerator) ?? 0) + let roomDescriptionLength: Int = ((10..<50).randomElement(using: &ogThreadRandomGenerator) ?? 0) let serverName: String = (0.. Promise { + // MARK: - Signaling + + public func sendPreOffer( + _ db: Database, + message: CallMessage, + interactionId: Int64?, + in thread: SessionThread + ) throws -> Promise { SNLog("[Calls] Sending pre-offer message.") - let (promise, seal) = Promise.pending() - DispatchQueue.main.async { - MessageSender.sendNonDurably(message, in: thread, using: transaction).done2 { + + return try MessageSender + .sendNonDurably( + db, + message: message, + interactionId: interactionId, + in: thread + ) + .done2 { SNLog("[Calls] Pre-offer message has been sent.") - seal.fulfill(()) - }.catch2 { error in - seal.reject(error) } - } - return promise } - public func sendOffer(to sessionID: String, using transaction: YapDatabaseReadWriteTransaction, isRestartingICEConnection: Bool = false) -> Promise { + public func sendOffer( + _ db: Database, + to sessionId: String, + isRestartingICEConnection: Bool = false + ) -> Promise { SNLog("[Calls] Sending offer message.") - guard let thread = TSContactThread.fetch(for: sessionID, using: transaction) else { return Promise(error: Error.noThread) } let (promise, seal) = Promise.pending() - peerConnection?.offer(for: mediaConstraints(isRestartingICEConnection)) { [weak self] sdp, error in + let uuid: String = self.uuid + let mediaConstraints: RTCMediaConstraints = mediaConstraints(isRestartingICEConnection) + + guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: sessionId) else { + return Promise(error: Error.noThread) + } + + self.peerConnection?.offer(for: mediaConstraints) { [weak self] sdp, error in if let error = error { seal.reject(error) - } else { - guard let self = self, let sdp = self.correctSessionDescription(sdp: sdp) else { preconditionFailure() } - self.peerConnection?.setLocalDescription(sdp) { error in - if let error = error { - print("Couldn't initiate call due to error: \(error).") - return seal.reject(error) - } - } - DispatchQueue.main.async { - let message = CallMessage() - message.sentTimestamp = NSDate.millisecondTimestamp() - message.uuid = self.uuid - message.kind = .offer - message.sdps = [ sdp.sdp ] - MessageSender.sendNonDurably(message, in: thread, using: transaction).done2 { - seal.fulfill(()) - }.catch2 { error in - seal.reject(error) - } + return + } + + guard let sdp: RTCSessionDescription = self?.correctSessionDescription(sdp: sdp) else { + preconditionFailure() + } + + self?.peerConnection?.setLocalDescription(sdp) { error in + if let error = error { + print("Couldn't initiate call due to error: \(error).") + return seal.reject(error) } } + GRDBStorage.shared + .write { db in + try MessageSender + .sendNonDurably( + db, + message: CallMessage( + uuid: uuid, + kind: .offer, + sdps: [ sdp.sdp ], + sentTimestampMs: UInt64(floor(Date().timeIntervalSince1970 * 1000)) + ), + interactionId: nil, + in: thread + ) + } + .done2 { + seal.fulfill(()) + } + .catch2 { error in + seal.reject(error) + } + .retainUntilComplete() } + return promise } - public func sendAnswer(to sessionID: String, using transaction: YapDatabaseReadWriteTransaction) -> Promise { + public func sendAnswer(to sessionId: String) -> Promise { SNLog("[Calls] Sending answer message.") - guard let thread = TSContactThread.fetch(for: sessionID, using: transaction) else { return Promise(error: Error.noThread) } let (promise, seal) = Promise.pending() - peerConnection?.answer(for: mediaConstraints(false)) { [weak self] sdp, error in - if let error = error { - seal.reject(error) - } else { - guard let self = self, let sdp = self.correctSessionDescription(sdp: sdp) else { preconditionFailure() } - self.peerConnection?.setLocalDescription(sdp) { error in + let uuid: String = self.uuid + let mediaConstraints: RTCMediaConstraints = mediaConstraints(false) + + GRDBStorage.shared.writeAsync { [weak self] db in + guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: sessionId) else { + seal.reject(Error.noThread) + return + } + + self?.peerConnection?.answer(for: mediaConstraints) { [weak self] sdp, error in + if let error = error { + seal.reject(error) + return + } + + guard let sdp: RTCSessionDescription = self?.correctSessionDescription(sdp: sdp) else { + preconditionFailure() + } + + self?.peerConnection?.setLocalDescription(sdp) { error in if let error = error { print("Couldn't accept call due to error: \(error).") return seal.reject(error) } } - DispatchQueue.main.async { - let message = CallMessage() - message.uuid = self.uuid - message.kind = .answer - message.sdps = [ sdp.sdp ] - MessageSender.sendNonDurably(message, in: thread, using: transaction).done2 { + + try? MessageSender + .sendNonDurably( + db, + message: CallMessage( + uuid: uuid, + kind: .answer, + sdps: [ sdp.sdp ] + ), + interactionId: nil, + in: thread + ) + .done2 { seal.fulfill(()) - }.catch2 { error in + } + .catch2 { error in seal.reject(error) } - } + .retainUntilComplete() } } + return promise } @@ -195,29 +260,51 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { } private func sendICECandidates() { - Storage.write { transaction in - let candidates = self.queuedICECandidates - guard let thread = TSContactThread.fetch(for: self.contactSessionID, using: transaction) else { return } + let candidates: [RTCIceCandidate] = self.queuedICECandidates + let uuid: String = self.uuid + let contactSessionId: String = self.contactSessionId + + // Empty the queue + self.queuedICECandidates.removeAll() + + GRDBStorage.shared.writeAsync { db in + guard let thread: SessionThread = try SessionThread.fetchOne(db, id: contactSessionId) else { return } + SNLog("[Calls] Batch sending \(candidates.count) ICE candidates.") - let message = CallMessage() - let sdps = candidates.map { $0.sdp } - let sdpMLineIndexes = candidates.map { UInt32($0.sdpMLineIndex) } - let sdpMids = candidates.map { $0.sdpMid! } - message.uuid = self.uuid - message.kind = .iceCandidates(sdpMLineIndexes: sdpMLineIndexes, sdpMids: sdpMids) - message.sdps = sdps - self.queuedICECandidates.removeAll() - MessageSender.sendNonDurably(message, in: thread, using: transaction).retainUntilComplete() + + try MessageSender.sendNonDurably( + db, + message: CallMessage( + uuid: uuid, + kind: .iceCandidates( + sdpMLineIndexes: candidates.map { UInt32($0.sdpMLineIndex) }, + sdpMids: candidates.map { $0.sdpMid! } + ), + sdps: candidates.map { $0.sdp } + ), + interactionId: nil, + in: thread + ) + .retainUntilComplete() } } - public func endCall(with sessionID: String, using transaction: YapDatabaseReadWriteTransaction) { - guard let thread = TSContactThread.fetch(for: sessionID, using: transaction) else { return } - let message = CallMessage() - message.uuid = self.uuid - message.kind = .endCall + public func endCall(_ db: Database, with sessionId: String) throws { + guard let thread: SessionThread = try SessionThread.fetchOne(db, id: sessionId) else { return } + SNLog("[Calls] Sending end call message.") - MessageSender.sendNonDurably(message, in: thread, using: transaction).retainUntilComplete() + + try MessageSender.sendNonDurably( + db, + message: CallMessage( + uuid: self.uuid, + kind: .endCall, + sdps: [] + ), + interactionId: nil, + in: thread + ) + .retainUntilComplete() } public func dropConnection() { diff --git a/Configuration.swift b/SessionMessagingKit/Configuration.swift similarity index 100% rename from Configuration.swift rename to SessionMessagingKit/Configuration.swift diff --git a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift index 5660d060d..d96986b73 100644 --- a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift +++ b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift @@ -37,6 +37,7 @@ public enum SMKLegacy { internal static let outgoingReadReceiptManagerCollection = "kOutgoingReadReceiptManagerCollection" internal static let receivedMessageTimestampsCollection = "ReceivedMessageTimestampsCollection" internal static let receivedMessageTimestampsKey = "receivedMessageTimestamps" + internal static let receivedCallsCollection = "LokiReceivedCallsCollection" internal static let notifyPushServerJobCollection = "NotifyPNServerJobCollection" internal static let messageReceiveJobCollection = "MessageReceiveJobCollection" @@ -84,6 +85,7 @@ public enum SMKLegacy { case messageRequestsMigration = "002" // Handled during contact migration case openGroupServerIdLookupMigration = "003" // Ignored (creates a lookup table, replaced with an index) case blockingManagerRemovalMigration = "004" // Handled during contact migration + case sogsV4Migration = "005" // Ignored (deletes unused data, replaced by not migrating) } // MARK: - Contact @@ -852,6 +854,68 @@ public enum SMKLegacy { } } + // MARK: - Call Message + + /// See https://developer.mozilla.org/en-US/docs/Web/API/RTCSessionDescription for more information. + @objc(SNCallMessage) + internal final class _CallMessage: _ControlMessage { + internal var uuid: String + internal var rawKind: String + internal var sdpMLineIndexes: [UInt32]? + internal var sdpMids: [String]? + + /// See https://developer.mozilla.org/en-US/docs/Glossary/SDP for more information. + internal var sdps: [String] + + // MARK: - NSCoding + + public required init?(coder: NSCoder) { + self.uuid = coder.decodeObject(forKey: "uuid") as! String + self.rawKind = coder.decodeObject(forKey: "kind") as! String + self.sdps = (coder.decodeObject(forKey: "sdps") as? [String]) + .defaulting(to: []) + + // These two values only exist for kind of type 'iceCandidates' + self.sdpMLineIndexes = coder.decodeObject(forKey: "sdpMLineIndexes") as? [UInt32] + self.sdpMids = coder.decodeObject(forKey: "sdpMids") as? [String] + + super.init(coder: coder) + } + + public override func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") + } + + // MARK: Non-Legacy Conversion + + override internal func toNonLegacy(_ instance: Message? = nil) throws -> Message { + return try super.toNonLegacy( + CallMessage( + uuid: self.uuid, + kind: { + switch self.rawKind { + case "preOffer": return .preOffer + case "offer": return .offer + case "answer": return .answer + case "provisionalAnswer": return .provisionalAnswer + case "iceCandidates": + return .iceCandidates( + sdpMLineIndexes: self.sdpMLineIndexes + .defaulting(to: []), + sdpMids: self.sdpMids + .defaulting(to: []) + ) + + case "endCall": return .endCall + default: throw StorageError.migrationFailed + } + }(), + sdps: self.sdps + ) + ) + } + } + // MARK: - Thread @objc(TSThread) @@ -968,6 +1032,34 @@ public enum SMKLegacy { } } + // MARK: - Group Model + + @objc(SNOpenGroupV2) + internal class _OpenGroup: NSObject, NSCoding { + internal let server: String + internal let room: String + internal let id: String + internal let name: String + internal let publicKey: String + internal let imageID: String? + + // MARK: NSCoder + + public required init(coder: NSCoder) { + self.server = coder.decodeObject(forKey: "server") as! String + self.room = coder.decodeObject(forKey: "room") as! String + self.id = "\(self.server).\(self.room)" + + self.name = coder.decodeObject(forKey: "name") as! String + self.publicKey = coder.decodeObject(forKey: "publicKey") as! String + self.imageID = coder.decodeObject(forKey: "imageID") as? String + } + + public func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") + } + } + // MARK: - Disappearing Messages Config @objc(OWSDisappearingMessagesConfiguration) @@ -1220,6 +1312,7 @@ public enum SMKLegacy { case disappearingMessagesUpdate case screenshotNotification case mediaSavedNotification + case call case messageRequestAccepted = 99 } @@ -1259,6 +1352,12 @@ public enum SMKLegacy { super.init(coder: coder) } } + + // MARK: - Data Extraction Info Message + + @objc(SNDataExtractionNotificationInfoMessage) + public final class _DataExtractionNotificationInfoMessage: _DBInfoMessage { + } // MARK: - Attachments @@ -1505,15 +1604,10 @@ public enum SMKLegacy { else if let destString: String = _MessageSendJob.process(rawDestination, type: "closedGroup") { destination = .closedGroup(groupPublicKey: destString) } - else if let destString: String = _MessageSendJob.process(rawDestination, type: "openGroup") { - let components = destString - .split(separator: ",") - .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } - - guard components.count == 2, let channel = UInt64(components[0]) else { return nil } - - let server = components[1] - destination = .openGroup(channel: channel, server: server) + else if _MessageSendJob.process(rawDestination, type: "openGroup") != nil { + // We can no longer support sending messages to legacy open groups + SNLog("[Migration Warning] Ignoring pending messageSend job for V1 OpenGroup") + return nil } else if let destString: String = _MessageSendJob.process(rawDestination, type: "openGroupV2") { let components = destString @@ -1524,7 +1618,13 @@ public enum SMKLegacy { let room = components[0] let server = components[1] - destination = .openGroupV2(room: room, server: server) + destination = .openGroup( + roomToken: room, + server: server, + whisperTo: nil, + whisperMods: false, + fileIds: nil + ) } else { return nil diff --git a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift index 8084cfcbd..9acab8e89 100644 --- a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -117,19 +117,27 @@ enum _001_InitialSetupMigration: Migration { } try db.create(table: OpenGroup.self) { t in + // Note: There is no foreign key constraint here because we need an OpenGroup entry to + // exist to be able to retrieve the default open group rooms - as a result we need to + // manually handle deletion of this object (in both OpenGroupManager and GarbageCollectionJob) t.column(.threadId, .text) .notNull() .primaryKey() - .references(SessionThread.self, onDelete: .cascade) // Delete if Thread deleted t.column(.server, .text).notNull() - t.column(.room, .text).notNull() + t.column(.roomToken, .text).notNull() t.column(.publicKey, .text).notNull() + t.column(.isActive, .boolean) + .notNull() + .defaults(to: false) t.column(.name, .text).notNull() - t.column(.groupDescription, .text) + t.column(.roomDescription, .text) t.column(.imageId, .text) t.column(.imageData, .blob) t.column(.userCount, .integer).notNull() t.column(.infoUpdates, .integer).notNull() + t.column(.sequenceNumber, .integer).notNull() + t.column(.inboxLatestMessageId, .integer).notNull() + t.column(.outboxLatestMessageId, .integer).notNull() } /// Create a full-text search table synchronized with the OpenGroup table @@ -141,14 +149,25 @@ enum _001_InitialSetupMigration: Migration { } try db.create(table: Capability.self) { t in - t.column(.openGroupId, .text) + t.column(.openGroupServer, .text) .notNull() .indexed() // Quicker querying - .references(OpenGroup.self, onDelete: .cascade) // Delete if OpenGroup deleted - t.column(.capability, .text).notNull() + t.column(.variant, .text).notNull() t.column(.isMissing, .boolean).notNull() - t.primaryKey([.openGroupId, .capability]) + t.primaryKey([.openGroupServer, .variant]) + } + + try db.create(table: BlindedIdLookup.self) { t in + t.column(.blindedId, .text) + .primaryKey() + t.column(.sessionId, .text) + .indexed() // Quicker querying + t.column(.openGroupServer, .text) + .notNull() + .indexed() // Quicker querying + t.column(.openGroupPublicKey, .text) + .notNull() } try db.create(table: GroupMember.self) { t in @@ -159,7 +178,9 @@ enum _001_InitialSetupMigration: Migration { .notNull() .indexed() // Quicker querying .references(SessionThread.self, onDelete: .cascade) // Delete if Thread deleted - t.column(.profileId, .text).notNull() + t.column(.profileId, .text) + .notNull() + .indexed() // Quicker querying t.column(.role, .integer).notNull() } @@ -168,6 +189,8 @@ enum _001_InitialSetupMigration: Migration { .notNull() .primaryKey(autoincrement: true) t.column(.serverHash, .text) + t.column(.messageUuid, .text) + .indexed() // Quicker querying t.column(.threadId, .text) .notNull() .indexed() // Quicker querying @@ -212,15 +235,20 @@ enum _001_InitialSetupMigration: Migration { /// `authorId` - Unique per user /// `timestampMs` - Very low chance of collision (especially combined with other two) /// - /// Standard messages: + /// Standard messages #1: /// `threadId` - Unique per thread /// `serverHash` - Unique per message (deterministically generated) /// + /// Standard messages #1: + /// `threadId` - Unique per thread + /// `messageUuid` - Very low chance of collision (especially combined with threadId) + /// /// Threads with variants: [`openGroup`]: /// `threadId` - Unique per thread /// `openGroupServerMessageId` - Unique for VisibleMessage's on an OpenGroup server t.uniqueKey([.threadId, .authorId, .timestampMs]) t.uniqueKey([.threadId, .serverHash]) + t.uniqueKey([.threadId, .messageUuid]) t.uniqueKey([.threadId, .openGroupServerMessageId]) } diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 96d56c1d6..17d7b2b44 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -41,8 +41,8 @@ enum _003_YDBToGRDBMigration: Migration { var closedGroupModel: [String: SMKLegacy._GroupModel] = [:] var closedGroupZombieMemberIds: [String: Set] = [:] - var openGroupInfo: [String: OpenGroupV2] = [:] - var openGroupUserCount: [String: Int] = [:] + var openGroupInfo: [String: SMKLegacy._OpenGroup] = [:] + var openGroupUserCount: [String: Int64] = [:] var openGroupImage: [String: Data] = [:] var openGroupLastMessageServerId: [String: Int64] = [:] // Optional var openGroupLastDeletionServerId: [String: Int64] = [:] // Optional @@ -52,6 +52,7 @@ enum _003_YDBToGRDBMigration: Migration { var processedAttachmentIds: Set = [] var outgoingReadReceiptsTimestampsMs: [String: Set] = [:] var receivedMessageTimestamps: Set = [] + var receivedCallUUIDs: [String: Set] = [:] var notifyPushServerJobs: Set = [] var messageReceiveJobs: Set = [] @@ -165,18 +166,18 @@ enum _003_YDBToGRDBMigration: Migration { } } else if groupThread.isOpenGroup { - guard let openGroup: OpenGroupV2 = transaction.object(forKey: thread.uniqueId, inCollection: SMKLegacy.openGroupCollection) as? OpenGroupV2 else { + guard let openGroup: SMKLegacy._OpenGroup = transaction.object(forKey: thread.uniqueId, inCollection: SMKLegacy.openGroupCollection) as? SMKLegacy._OpenGroup else { SNLog("[Migration Error] Unable to find open group info") shouldFailMigration = true return } legacyThreadIdToIdMap[thread.uniqueId] = OpenGroup.idFor( - room: openGroup.room, + roomToken: openGroup.room, server: openGroup.server ) openGroupInfo[thread.uniqueId] = openGroup - openGroupUserCount[thread.uniqueId] = ((transaction.object(forKey: openGroup.id, inCollection: SMKLegacy.openGroupUserCountCollection) as? Int) ?? 0) + openGroupUserCount[thread.uniqueId] = ((transaction.object(forKey: openGroup.id, inCollection: SMKLegacy.openGroupUserCountCollection) as? Int64) ?? 0) openGroupImage[thread.uniqueId] = transaction.object(forKey: openGroup.id, inCollection: SMKLegacy.openGroupImageCollection) as? Data openGroupLastMessageServerId[thread.uniqueId] = transaction.object(forKey: openGroup.id, inCollection: SMKLegacy.openGroupLastMessageServerIDCollection) as? Int64 openGroupLastDeletionServerId[thread.uniqueId] = transaction.object(forKey: openGroup.id, inCollection: SMKLegacy.openGroupLastDeletionServerIDCollection) as? Int64 @@ -249,6 +250,8 @@ enum _003_YDBToGRDBMigration: Migration { .union(timestampsMs) } + // MARK: --De-duping + receivedMessageTimestamps = receivedMessageTimestamps.inserting( contentsOf: transaction .object( @@ -260,6 +263,13 @@ enum _003_YDBToGRDBMigration: Migration { .asSet() ) + transaction.enumerateKeysAndObjects(inCollection: SMKLegacy.receivedCallsCollection) { key, object, _ in + guard let uuids: Set = object as? Set else { return } + + receivedCallUUIDs[key] = (receivedCallUUIDs[key] ?? Set()) + .union(uuids) + } + // MARK: --Jobs SNLog("[Migration Info] \(target.key(with: self)) - Processing Jobs") @@ -409,7 +419,6 @@ enum _003_YDBToGRDBMigration: Migration { shouldForceBlockContact { // Create the contact - // TODO: Closed group admins??? try Contact( id: legacyContact.sessionID, isTrusted: ( @@ -608,21 +617,25 @@ enum _003_YDBToGRDBMigration: Migration { // Open Groups if legacyThread.isOpenGroup { - guard let openGroup: OpenGroupV2 = openGroupInfo[legacyThread.uniqueId] else { + guard let openGroup: SMKLegacy._OpenGroup = openGroupInfo[legacyThread.uniqueId] else { SNLog("[Migration Error] Open group missing required data") throw StorageError.migrationFailed } try OpenGroup( server: openGroup.server, - room: openGroup.room, + roomToken: openGroup.room, publicKey: openGroup.publicKey, + isActive: true, name: openGroup.name, - groupDescription: nil, // TODO: Add with SOGS V4. - imageId: nil, // TODO: Add with SOGS V4. + roomDescription: nil, + imageId: openGroup.imageID, imageData: openGroupImage[legacyThread.uniqueId], userCount: (openGroupUserCount[legacyThread.uniqueId] ?? 0), // Will be updated next poll - infoUpdates: 0 // TODO: Add with SOGS V4. + infoUpdates: 0, + sequenceNumber: 0, + inboxLatestMessageId: 0, + outboxLatestMessageId: 0 ).insert(db) } } @@ -752,13 +765,14 @@ enum _003_YDBToGRDBMigration: Migration { case .groupUpdated: variant = .infoClosedGroupUpdated case .groupCurrentUserLeft: variant = .infoClosedGroupCurrentUserLeft case .disappearingMessagesUpdate: variant = .infoDisappearingMessagesUpdate - case .messageRequestAccepted: variant = .infoMessageRequestAccepted case .screenshotNotification: variant = .infoScreenshotNotification case .mediaSavedNotification: variant = .infoMediaSavedNotification + case .call: variant = .infoCall + case .messageRequestAccepted: variant = .infoMessageRequestAccepted @unknown default: SNLog("[Migration Error] Unsupported info message type") - throw GRDBStorageError.migrationFailed + throw StorageError.migrationFailed } default: @@ -768,41 +782,70 @@ enum _003_YDBToGRDBMigration: Migration { } // Insert the data - let interaction: Interaction = try Interaction( - serverHash: { - switch variant { - // Don't store the 'serverHash' for these so sync messages - // are seen as duplicates - case .infoDisappearingMessagesUpdate: return nil - - default: return serverHash - } - }(), - threadId: threadId, - authorId: authorId, - variant: variant, - body: body, - timestampMs: Int64(legacyInteraction.timestamp), - receivedAtTimestampMs: Int64(legacyInteraction.receivedAtTimestamp), - wasRead: wasRead, - hasMention: ( - body?.contains("@\(currentUserPublicKey)") == true || - quotedMessage?.authorId == currentUserPublicKey - ), - // For both of these '0' used to be equivalent to null - expiresInSeconds: ((expiresInSeconds ?? 0) > 0 ? - expiresInSeconds.map { TimeInterval($0) } : - nil - ), - expiresStartedAtMs: ((expiresStartedAtMs ?? 0) > 0 ? - expiresStartedAtMs.map { Double($0) } : - nil - ), - linkPreviewUrl: linkPreview?.urlString, // Only a soft link so save to set - openGroupServerMessageId: openGroupServerMessageId.map { Int64($0) }, - openGroupWhisperMods: false, // TODO: This in SOGSV4 - openGroupWhisperTo: nil // TODO: This in SOGSV4 - ).inserted(db) + let interaction: Interaction + + do { + interaction = try Interaction( + serverHash: { + switch variant { + // Don't store the 'serverHash' for these so sync messages + // are seen as duplicates + case .infoDisappearingMessagesUpdate: return nil + + default: return serverHash + } + }(), + messageUuid: { + guard variant == .infoCall else { return nil } + + /// **Note:** Unfortunately there is no good way to properly match this UUID up with the correct + /// interaction (and it was previously stored as a Set so the values will be unsorted anyway); luckily + /// we are only using this value for updating and de-duping purposes at this stage so it _shouldn't_ + /// matter if the values end up being assigned to the wrong interactions, we do still want to try and + /// store each value through so mutate the list as we process each UUID + /// + /// **Note:** It looks like these values were stored against the sessionId rather than the legacy + /// thread unique id + return receivedCallUUIDs[threadId]?.popFirst() + }(), + threadId: threadId, + authorId: authorId, + variant: variant, + body: body, + timestampMs: Int64(legacyInteraction.timestamp), + receivedAtTimestampMs: Int64(legacyInteraction.receivedAtTimestamp), + wasRead: wasRead, + hasMention: ( + body?.contains("@\(currentUserPublicKey)") == true || + quotedMessage?.authorId == currentUserPublicKey + ), + // For both of these '0' used to be equivalent to null + expiresInSeconds: ((expiresInSeconds ?? 0) > 0 ? + expiresInSeconds.map { TimeInterval($0) } : + nil + ), + expiresStartedAtMs: ((expiresStartedAtMs ?? 0) > 0 ? + expiresStartedAtMs.map { Double($0) } : + nil + ), + linkPreviewUrl: linkPreview?.urlString, // Only a soft link so save to set + openGroupServerMessageId: openGroupServerMessageId.map { Int64($0) }, + openGroupWhisperMods: false, + openGroupWhisperTo: nil + ).inserted(db) + } + catch { + switch error { + // Ignore duplicate interactions + case DatabaseError.SQLITE_CONSTRAINT_UNIQUE: + SNLog("[Migration Warning] Found duplicate message of variant: \(variant); skipping") + return + + default: + SNLog("[Migration Error] Failed to insert interaction") + throw StorageError.migrationFailed + } + } // Insert a 'ControlMessageProcessRecord' if needed (for duplication prevention) try ControlMessageProcessRecord( @@ -998,7 +1041,7 @@ enum _003_YDBToGRDBMigration: Migration { processedAttachmentIds: &processedAttachmentIds ) else { SNLog("[Migration Error] Missing interaction attachment") - throw GRDBStorageError.migrationFailed + throw StorageError.migrationFailed } // Link the attachment to the interaction and add to the id lookup @@ -1123,10 +1166,10 @@ enum _003_YDBToGRDBMigration: Migration { switch legacyJob.destination { case .contact(let publicKey): return publicKey case .closedGroup(let groupPublicKey): return groupPublicKey - case .openGroupV2(let room, let server): - return OpenGroup.idFor(room: room, server: server) - - case .openGroup: return "" + case .openGroup(let roomToken, let server, _, _, _): + return OpenGroup.idFor(roomToken: roomToken, server: server) + + case .openGroupInbox(_, _, let blindedPublicKey): return blindedPublicKey } }() let interactionId: Int64? = { @@ -1496,6 +1539,10 @@ enum _003_YDBToGRDBMigration: Migration { SMKLegacy._GroupModel.self, forClassName: "TSGroupModel" ) + NSKeyedUnarchiver.setClass( + SMKLegacy._OpenGroup.self, + forClassName: "SNOpenGroupV2" + ) NSKeyedUnarchiver.setClass( SMKLegacy._Contact.self, forClassName: "SNContact" @@ -1544,6 +1591,10 @@ enum _003_YDBToGRDBMigration: Migration { SMKLegacy._DisappearingConfigurationUpdateInfoMessage.self, forClassName: "OWSDisappearingConfigurationUpdateInfoMessage" ) + NSKeyedUnarchiver.setClass( + SMKLegacy._DataExtractionNotificationInfoMessage.self, + forClassName: "SNDataExtractionNotificationInfoMessage" + ) NSKeyedUnarchiver.setClass( SMKLegacy._Attachment.self, forClassName: "TSAttachment" diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index dc1f7d662..4a167fc35 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -920,7 +920,7 @@ extension Attachment { extension Attachment { internal func upload( _ db: Database? = nil, - using upload: (Data) -> Promise, + using upload: (Database, Data) -> Promise, encrypt: Bool, success: (() -> Void)?, failure: ((Error) -> Void)? @@ -1001,8 +1001,8 @@ extension Attachment { // Check the file size SNLog("File size: \(data.count) bytes.") - if Double(data.count) > Double(FileServerAPIV2.maxFileSize) / FileServerAPIV2.fileSizeORMultiplier { - failure?(FileServerAPIV2.Error.maxFileSizeExceeded) + if Double(data.count) > Double(FileServerAPI.maxFileSize) / FileServerAPI.fileSizeORMultiplier { + failure?(HTTP.Error.maxFileSizeExceeded) return } @@ -1028,7 +1028,15 @@ extension Attachment { } // Perform the upload - upload(data) + let uploadPromise: Promise = { + guard let db: Database = db else { + return GRDBStorage.shared.read { db in upload(db, data) } + } + + return upload(db, data) + }() + + uploadPromise .done(on: DispatchQueue.global(qos: .userInitiated)) { fileId in // Save the final upload info let uploadedAttachment: Attachment? = GRDBStorage.shared.write { db in @@ -1040,7 +1048,7 @@ extension Attachment { updatedAttachment?.creationTimestamp ?? Date().timeIntervalSince1970 ), - downloadUrl: "\(FileServerAPIV2.server)/files/\(fileId)" + downloadUrl: "\(FileServerAPI.server)/files/\(fileId)" ) .saved(db) } diff --git a/SessionMessagingKit/Database/Models/BlindedIdLookup.swift b/SessionMessagingKit/Database/Models/BlindedIdLookup.swift index 0a8ab0dac..081042fe3 100644 --- a/SessionMessagingKit/Database/Models/BlindedIdLookup.swift +++ b/SessionMessagingKit/Database/Models/BlindedIdLookup.swift @@ -1,3 +1,144 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB +import SessionUtilitiesKit + +/// This lookup is created when the user interacts with a blinded id +public struct BlindedIdLookup: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "blindedIdLookup" } + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case blindedId + case sessionId + case openGroupServer + case openGroupPublicKey + } + + public var id: String { blindedId } + + /// The blinded id for the user on this open group server + public let blindedId: String + + /// The standard sessionId which can be used to generate this blindedId on this open group server + /// + /// **Note:** This value will be null if the user owning the blinded id hasn’t accepted the message request + public let sessionId: String? + + /// The server for the Open Group server this blinded id belongs to + public let openGroupServer: String + + /// The public key for the Open Group server this blinded id belongs to + public let openGroupPublicKey: String + + // MARK: - Initialization + + public init( + blindedId: String, + sessionId: String? = nil, + openGroupServer: String, + openGroupPublicKey: String + ) { + self.blindedId = blindedId + self.sessionId = sessionId + self.openGroupServer = openGroupServer + self.openGroupPublicKey = openGroupPublicKey + } +} + +// MARK: - Mutation + +public extension BlindedIdLookup { + func with(sessionId: String) -> BlindedIdLookup { + return BlindedIdLookup( + blindedId: self.blindedId, + sessionId: sessionId, + openGroupServer: self.openGroupServer, + openGroupPublicKey: self.openGroupPublicKey + ) + } +} + +// MARK: - GRDB Interactions + +public extension BlindedIdLookup { + /// Unfortunately the whole point of id-blinding is to make it hard to reverse-engineer a standard sessionId, as a result in order + /// to see if there is an unblinded contact for this blindedId we can only really generate blinded ids for each contact and check + /// if any match + /// + /// If we can't find a match this method will still store a lookup, just with no standard sessionId value (this gives us a method to + /// link back to the open group the blindedId originated from) + static func fetchOrCreate( + _ db: Database, + blindedId: String, + openGroupServer: String, + openGroupPublicKey: String, + dependencies: Dependencies = Dependencies() + ) throws -> BlindedIdLookup { + var lookup: BlindedIdLookup = (try? BlindedIdLookup + .fetchOne(db, id: blindedId)) + .defaulting( + to: BlindedIdLookup( + blindedId: blindedId, + openGroupServer: openGroupServer.lowercased(), + openGroupPublicKey: openGroupPublicKey + ) + ) + + // If the lookup already has a resolved sessionId then just return it immediately + guard lookup.sessionId == nil else { return lookup } + + // We now need to try to match the blinded id to an existing contact, this can only be done by looping + // through all approved contacts and generating a blinded id for the provided open group for each to + // see if it matches the provided blindedId + let approvedContactCursor: RecordCursor = try Contact + .filter(Contact.Columns.isApproved == true) + .fetchCursor(db) + + while let contact: Contact = try approvedContactCursor.next() { + guard dependencies.sodium.sessionId(contact.id, matchesBlindedId: blindedId, serverPublicKey: openGroupPublicKey, genericHash: dependencies.genericHash) else { + continue + } + + // We found a match so update the lookup and leave the loop + lookup = try lookup + .with(sessionId: contact.id) + .saved(db) + break + } + + // Finish if we have a result + guard lookup.sessionId == nil else { return lookup } + + // Lastly loop through existing id lookups (in case the user is looking at a different SOGS but once had + // a thread with this contact in a different SOGS and had cached the lookup) + let blindedIdLookupCursor: RecordCursor = try BlindedIdLookup + .filter(BlindedIdLookup.Columns.sessionId != nil) + .filter(BlindedIdLookup.Columns.openGroupServer != openGroupServer.lowercased()) + .fetchCursor(db) + + while let otherLookup: BlindedIdLookup = try blindedIdLookupCursor.next() { + guard + let sessionId: String = otherLookup.sessionId, + dependencies.sodium.sessionId( + sessionId, + matchesBlindedId: blindedId, + serverPublicKey: openGroupPublicKey, + genericHash: dependencies.genericHash + ) + else { continue } + + // We found a match so update the lookup and leave the loop + lookup = try lookup + .with(sessionId: sessionId) + .saved(db) + break + } + + // Want to save the lookup even if it doesn't have a sessionId so it can be used when handling + // MessageRequestResponse messages + return try lookup + .saved(db) + } +} diff --git a/SessionMessagingKit/Database/Models/Capability.swift b/SessionMessagingKit/Database/Models/Capability.swift index 4b5be2f0b..ef68e2895 100644 --- a/SessionMessagingKit/Database/Models/Capability.swift +++ b/SessionMessagingKit/Database/Models/Capability.swift @@ -6,23 +6,56 @@ import SessionUtilitiesKit public struct Capability: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "capability" } - internal static let openGroupForeignKey = ForeignKey([Columns.openGroupId], to: [OpenGroup.Columns.threadId]) - private static let openGroup = belongsTo(OpenGroup.self, using: openGroupForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { - case openGroupId - case capability + case openGroupServer + case variant case isMissing } - public let openGroupId: String - public let capability: String + public enum Variant: Equatable, Hashable, CaseIterable, Codable, DatabaseValueConvertible { + public static var allCases: [Variant] { + [.sogs, .blind] + } + + case sogs + case blind + + /// Fallback case if the capability isn't supported by this version of the app + case unsupported(String) + + // MARK: - Convenience + + public var rawValue: String { + switch self { + case .unsupported(let originalValue): return originalValue + default: return "\(self)" + } + } + + // MARK: - Initialization + + public init(from valueString: String) { + let maybeValue: Variant? = Variant.allCases.first { $0.rawValue == valueString } + + self = (maybeValue ?? .unsupported(valueString)) + } + } + + public let openGroupServer: String + public let variant: Variant public let isMissing: Bool - // MARK: - Relationships - - public var openGroup: QueryInterfaceRequest { - request(for: Capability.openGroup) + // MARK: - Initialization + + public init( + openGroupServer: String, + variant: Variant, + isMissing: Bool + ) { + self.openGroupServer = openGroupServer + self.variant = variant + self.isMissing = isMissing } } diff --git a/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift b/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift index 2d6e86a65..5d13f758b 100644 --- a/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift +++ b/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift @@ -39,6 +39,7 @@ public struct ControlMessageProcessRecord: Codable, FetchableRecord, Persistable case configurationMessage = 6 case unsendRequest = 7 case messageRequestResponse = 8 + case call = 9 } /// The id for the thread the control message is associated to @@ -70,6 +71,9 @@ public struct ControlMessageProcessRecord: Codable, FetchableRecord, Persistable // the unique constraints on that table prevent duplicate messages if message is VisibleMessage { 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 } // Allow '.new' and 'encryptionKeyPair' closed group control message duplicates to avoid // the following situation: @@ -94,6 +98,7 @@ public struct ControlMessageProcessRecord: Codable, FetchableRecord, Persistable case is ConfigurationMessage: return .configurationMessage case is UnsendRequest: return .unsendRequest case is MessageRequestResponse: return .messageRequestResponse + case is CallMessage: return .call default: preconditionFailure("[ControlMessageProcessRecord] Unsupported message type") } }() @@ -149,6 +154,9 @@ internal extension ControlMessageProcessRecord { case .infoMessageRequestAccepted: self.variant = .messageRequestResponse + + case .infoCall: + self.variant = .call } self.threadId = threadId diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 156a36d37..62af0107d 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -40,6 +40,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { case id case serverHash + case messageUuid case threadId case authorId @@ -78,7 +79,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu case infoMessageRequestAccepted = 4000 - case infoMessageCall = 5000 + case infoCall = 5000 // MARK: - Convenience @@ -86,18 +87,34 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu switch self { case .infoClosedGroupCreated, .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft, .infoDisappearingMessagesUpdate, .infoScreenshotNotification, .infoMediaSavedNotification, - .infoMessageRequestAccepted, .infoMessageCall: + .infoMessageRequestAccepted, .infoCall: return true case .standardIncoming, .standardOutgoing, .standardIncomingDeleted: return false } } + + /// This flag controls whether the `wasRead` flag is automatically set to true based on the message variant (as a result it they will + /// or won't affect the unread count) + fileprivate var canBeUnread: Bool { + switch self { + case .standardIncoming: return true + case .infoCall: return true + + case .standardOutgoing, .standardIncomingDeleted: return false + + case .infoClosedGroupCreated, .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft, + .infoDisappearingMessagesUpdate, .infoScreenshotNotification, .infoMediaSavedNotification, + .infoMessageRequestAccepted: + return false + } + } } /// The `id` value is auto incremented by the database, if the `Interaction` hasn't been inserted into /// the database yet this value will be `nil` - public var id: Int64? = nil + public private(set) var id: Int64? = nil /// The hash returned by the server when this message was created on the server /// @@ -108,6 +125,11 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu /// on all linked devices) because the data in the message is slightly different public let serverHash: String? + /// The UUID specified when sending the message to allow for custom updating and de-duping behaviours + /// + /// **Note:** Currently only `infoCall` messages utilise this value + public let messageUuid: String? + /// The id of the thread that this interaction belongs to (used to expose the `thread` variable) public let threadId: String @@ -141,7 +163,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu /// we couldn’t know if a read timestamp is accurate) /// /// **Note:** This flag is not applicable to standardOutgoing or standardIncomingDeleted interactions - public let wasRead: Bool + public private(set) var wasRead: Bool /// A flag indicating whether the current user was mentioned in this interaction (or the associated quote) public let hasMention: Bool @@ -214,6 +236,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu internal init( id: Int64? = nil, serverHash: String?, + messageUuid: String?, threadId: String, authorId: String, variant: Variant, @@ -231,6 +254,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu ) { self.id = id self.serverHash = serverHash + self.messageUuid = messageUuid self.threadId = threadId self.authorId = authorId self.variant = variant @@ -249,6 +273,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu public init( serverHash: String? = nil, + messageUuid: String? = nil, threadId: String, authorId: String, variant: Variant, @@ -264,6 +289,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu openGroupWhisperTo: String? = nil ) throws { self.serverHash = serverHash + self.messageUuid = messageUuid self.threadId = threadId self.authorId = authorId self.variant = variant @@ -290,6 +316,10 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu // MARK: - Custom Database Interaction public mutating func insert(_ db: Database) throws { + // Automatically mark interactions which can't be unread as read so the unread count + // isn't impacted + self.wasRead = (self.wasRead || !self.variant.canBeUnread) + try performInsert(db) // Since we need to do additional logic upon insert we can just set the 'id' value @@ -355,6 +385,7 @@ public extension Interaction { func with( serverHash: String? = nil, authorId: String? = nil, + body: String? = nil, timestampMs: Int64? = nil, wasRead: Bool? = nil, hasMention: Bool? = nil, @@ -363,22 +394,23 @@ public extension Interaction { openGroupServerMessageId: Int64? = nil ) -> Interaction { return Interaction( - id: id, + id: self.id, serverHash: (serverHash ?? self.serverHash), - threadId: threadId, + messageUuid: self.messageUuid, + threadId: self.threadId, authorId: (authorId ?? self.authorId), - variant: variant, - body: body, + variant: self.variant, + body: (body ?? self.body), timestampMs: (timestampMs ?? self.timestampMs), - receivedAtTimestampMs: receivedAtTimestampMs, + receivedAtTimestampMs: self.receivedAtTimestampMs, wasRead: (wasRead ?? self.wasRead), hasMention: (hasMention ?? self.hasMention), expiresInSeconds: (expiresInSeconds ?? self.expiresInSeconds), expiresStartedAtMs: (expiresStartedAtMs ?? self.expiresStartedAtMs), - linkPreviewUrl: linkPreviewUrl, + linkPreviewUrl: self.linkPreviewUrl, openGroupServerMessageId: (openGroupServerMessageId ?? self.openGroupServerMessageId), - openGroupWhisperMods: openGroupWhisperMods, - openGroupWhisperTo: openGroupWhisperTo + openGroupWhisperMods: self.openGroupWhisperMods, + openGroupWhisperTo: self.openGroupWhisperTo ) } } @@ -538,6 +570,7 @@ public extension Interaction { return Interaction( id: id, serverHash: nil, + messageUuid: messageUuid, threadId: threadId, authorId: authorId, variant: .standardIncomingDeleted, @@ -589,8 +622,8 @@ public extension Interaction { .defaulting(to: false) ) - case .infoMediaSavedNotification, .infoScreenshotNotification: - // Note: This should only occur in 'contact' threads so the `threadId` + case .infoMediaSavedNotification, .infoScreenshotNotification, .infoCall: + // Note: These should only occur in 'contact' threads so the `threadId` // is the contact id return Interaction.previewText( variant: self.variant, @@ -673,6 +706,17 @@ public extension Interaction { else { return (body ?? "") } return messageInfo.previewText + + case .infoCall: + guard + let infoMessageData: Data = (body ?? "").data(using: .utf8), + let messageInfo: CallMessage.MessageInfo = try? JSONDecoder().decode( + CallMessage.MessageInfo.self, + from: infoMessageData + ) + else { return (body ?? "") } + + return messageInfo.previewText(authorDisplayName: authorDisplayName) } } diff --git a/SessionMessagingKit/Database/Models/OpenGroup.swift b/SessionMessagingKit/Database/Models/OpenGroup.swift index c85aa7dea..6ccb8f784 100644 --- a/SessionMessagingKit/Database/Models/OpenGroup.swift +++ b/SessionMessagingKit/Database/Models/OpenGroup.swift @@ -8,21 +8,24 @@ public struct OpenGroup: Codable, Identifiable, FetchableRecord, PersistableReco public static var databaseTableName: String { "openGroup" } internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id]) private static let thread = belongsTo(SessionThread.self, using: threadForeignKey) - private static let capabilities = hasMany(Capability.self, using: Capability.openGroupForeignKey) private static let members = hasMany(GroupMember.self, using: GroupMember.openGroupForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { case threadId case server - case room + case roomToken case publicKey case name - case groupDescription = "description" + case isActive + case roomDescription = "description" case imageId case imageData case userCount case infoUpdates + case sequenceNumber + case inboxLatestMessageId + case outboxLatestMessageId } public var id: String { threadId } // Identifiable @@ -37,38 +40,57 @@ public struct OpenGroup: Codable, Identifiable, FetchableRecord, PersistableReco public let server: String /// The specific room on the server for the group - public let room: String + /// + /// **Note:** In order to support the default open group query we need an OpenGroup entry in + /// the database, for this entry the `roomToken` value will be an empty string so we can ignore + /// it when polling + public let roomToken: String /// The public key for the group public let publicKey: String + /// Flag indicating whether this is an OpenGroup the user has actively joined (we store inactive + /// open groups so we can display them in the UI but they won't be polled for) + public let isActive: Bool + /// The name for the group public let name: String - /// The description for the group - public let groupDescription: String? + /// The description for the room + public let roomDescription: String? /// The ID with which the image can be retrieved from the server - public let imageId: Int? + public let imageId: String? /// The image for the group public let imageData: Data? /// The number of users in the group - public let userCount: Int + public let userCount: Int64 /// Monotonic room information counter that increases each time the room's metadata changes - public let infoUpdates: Int + public let infoUpdates: Int64 + + /// Sequence number for the most recently received message from the open group + public let sequenceNumber: Int64 + + /// The id of the most recently received inbox message + /// + /// **Note:** This value is unique per server rather than per room (ie. all rooms in the same server will be + /// updated whenever this value changes) + public let inboxLatestMessageId: Int64 + + /// The id of the most recently received outbox message + /// + /// **Note:** This value is unique per server rather than per room (ie. all rooms in the same server will be + /// updated whenever this value changes) + public let outboxLatestMessageId: Int64 // MARK: - Relationships public var thread: QueryInterfaceRequest { request(for: OpenGroup.thread) } - - public var capabilities: QueryInterfaceRequest { - request(for: OpenGroup.capabilities) - } public var moderatorIds: QueryInterfaceRequest { request(for: OpenGroup.members) @@ -84,33 +106,75 @@ public struct OpenGroup: Codable, Identifiable, FetchableRecord, PersistableReco public init( server: String, - room: String, + roomToken: String, publicKey: String, + isActive: Bool, name: String, - groupDescription: String? = nil, - imageId: Int? = nil, + roomDescription: String? = nil, + imageId: String? = nil, imageData: Data? = nil, - userCount: Int, - infoUpdates: Int + userCount: Int64, + infoUpdates: Int64, + sequenceNumber: Int64 = 0, + inboxLatestMessageId: Int64 = 0, + outboxLatestMessageId: Int64 = 0 ) { - self.threadId = OpenGroup.idFor(room: room, server: server) + self.threadId = OpenGroup.idFor(roomToken: roomToken, server: server) self.server = server.lowercased() - self.room = room + self.roomToken = roomToken self.publicKey = publicKey + self.isActive = isActive self.name = name - self.groupDescription = groupDescription + self.roomDescription = roomDescription self.imageId = imageId self.imageData = imageData self.userCount = userCount self.infoUpdates = infoUpdates + self.sequenceNumber = sequenceNumber + self.inboxLatestMessageId = inboxLatestMessageId + self.outboxLatestMessageId = outboxLatestMessageId + } +} + +// MARK: - Mutation + +public extension OpenGroup { + func with( + isActive: Bool? = nil, + name: String? = nil, + roomDescription: String? = nil, + imageId: String? = nil, + imageData: Data? = nil, + userCount: Int64? = nil, + infoUpdates: Int64? = nil, + sequenceNumber: Int64? = nil + ) -> OpenGroup { + return OpenGroup( + server: self.server, + roomToken: self.roomToken, + publicKey: self.publicKey, + isActive: (isActive ?? self.isActive), + name: (name ?? self.name), + roomDescription: (roomDescription ?? self.roomDescription), + imageId: (imageId ?? self.imageId), + imageData: (imageData ?? self.imageData), + userCount: (userCount ?? self.userCount), + infoUpdates: (infoUpdates ?? self.infoUpdates), + sequenceNumber: (sequenceNumber ?? self.sequenceNumber), + inboxLatestMessageId: self.inboxLatestMessageId, + outboxLatestMessageId: self.outboxLatestMessageId + ) } } // MARK: - Convenience public extension OpenGroup { - static func idFor(room: String, server: String) -> String { - return "\(server.lowercased()).\(room)" + static func idFor(roomToken: String, server: String) -> String { + // Always force the server to lowercase + return "\(server.lowercased()).\(roomToken)" + } +} // MARK: - Objective-C Support @@ -123,7 +187,7 @@ public class SMKOpenGroup: NSObject { GRDBStorage.shared.write { db in guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: openGroupThreadId) else { return } - let urlString: String = "\(openGroup.server)/\(openGroup.room)?public_key=\(openGroup.publicKey)" + let urlString: String = "\(openGroup.server)/\(openGroup.roomToken)?public_key=\(openGroup.publicKey)" try selectedUsers.forEach { userId in let thread: SessionThread = try SessionThread.fetchOrCreate(db, id: userId, variant: .contact) diff --git a/SessionMessagingKit/File Server/FileServerAPI.swift b/SessionMessagingKit/File Server/FileServerAPI.swift index 762ed7ed7..c1aed7116 100644 --- a/SessionMessagingKit/File Server/FileServerAPI.swift +++ b/SessionMessagingKit/File Server/FileServerAPI.swift @@ -25,11 +25,6 @@ public final class FileServerAPI: NSObject { // MARK: - File Storage - @objc(upload:) - public static func objc_upload(file: Data) -> AnyPromise { - return AnyPromise.from(upload(file).map { String($0.id) }) - } - public static func upload(_ file: Data) -> Promise { let request = Request( method: .post, @@ -46,13 +41,7 @@ public final class FileServerAPI: NSObject { .decoded(as: FileUploadResponse.self, on: .global(qos: .userInitiated)) } - @objc(download:useOldServer:) - public static func objc_download(file: String, useOldServer: Bool) -> AnyPromise { - guard let id = UInt64(file) else { return AnyPromise.from(Promise(error: HTTP.Error.invalidURL)) } - return AnyPromise.from(download(id, useOldServer: useOldServer)) - } - - public static func download(_ file: UInt64, useOldServer: Bool) -> Promise { + public static func download(_ file: Int64, useOldServer: Bool) -> Promise { let serverPublicKey: String = (useOldServer ? oldServerPublicKey : serverPublicKey) let request = Request( server: (useOldServer ? oldServer : server), diff --git a/SessionMessagingKit/File Server/Types/FSEndpoint.swift b/SessionMessagingKit/File Server/Types/FSEndpoint.swift index 54169bccb..d2c9aa668 100644 --- a/SessionMessagingKit/File Server/Types/FSEndpoint.swift +++ b/SessionMessagingKit/File Server/Types/FSEndpoint.swift @@ -5,7 +5,7 @@ import Foundation extension FileServerAPI { public enum Endpoint: EndpointType { case file - case fileIndividual(fileId: UInt64) + case fileIndividual(fileId: Int64) case sessionVersion var path: String { diff --git a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift index edb7a62be..5ea862585 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift @@ -50,16 +50,24 @@ public enum AttachmentDownloadJob: JobExecutor { guard let downloadUrl: String = attachment.downloadUrl, let fileAsString: String = downloadUrl.split(separator: "/").last.map({ String($0) }), - let file: UInt64 = UInt64(fileAsString) + let file: Int64 = Int64(fileAsString) else { return Promise(error: AttachmentDownloadError.invalidUrl) } - if let openGroup: OpenGroup = GRDBStorage.shared.read({ db in try OpenGroup.fetchOne(db, id: threadId) }) { - return OpenGroupAPIV2.download(file, from: openGroup.room, on: openGroup.server) - } + let maybeOpenGroupDownloadPromise: Promise? = GRDBStorage.shared.read({ db in + guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { + return nil // Not an open group so just use standard FileServer upload + } + + return OpenGroupAPI.downloadFile(db, fileId: file, from: openGroup.roomToken, on: openGroup.server) + .map { _, data in data } + }) - return FileServerAPIV2.download(file, useOldServer: downloadUrl.contains(FileServerAPIV2.oldServer)) + return ( + maybeOpenGroupDownloadPromise ?? + FileServerAPI.download(file, useOldServer: downloadUrl.contains(FileServerAPI.oldServer)) + ) }() downloadPromise @@ -115,7 +123,7 @@ public enum AttachmentDownloadJob: JobExecutor { OWSFileSystem.deleteFile(temporaryFileUrl.path) switch error { - case OnionRequestAPI.Error.httpRequestFailedAtDestination(let statusCode, _, _) where statusCode == 400: + case OnionRequestAPIError.httpRequestFailedAtDestination(let statusCode, _, _) where statusCode == 400: /// Otherwise, the attachment will show a state of downloading forever, and the message /// won't be able to be marked as read /// diff --git a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift index 5d9eb1f82..aa2c20c09 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift @@ -37,12 +37,20 @@ public enum AttachmentUploadJob: JobExecutor { // issues when the success/failure closures get called before the upload as the JobRunner will attempt to // update the state of the job immediately attachment.upload( - using: { data in + using: { db, data in if let openGroup: OpenGroup = openGroup { - return OpenGroupAPIV2.upload(data, to: openGroup.room, on: openGroup.server) + return OpenGroupAPI + .uploadFile( + db, + bytes: data.bytes, + to: openGroup.roomToken, + on: openGroup.server + ) + .map { _, response -> String in response.id } } - return FileServerAPIV2.upload(data) + return FileServerAPI.upload(data) + .map { response -> String in response.id } }, encrypt: (openGroup == nil), success: { success(job, false) }, diff --git a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift index 551f7d7fd..33c643d8b 100644 --- a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift +++ b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift @@ -39,20 +39,20 @@ public enum GarbageCollectionJob: JobExecutor { GRDBStorage.shared.writeAsync( updates: { db in - // Remove any expired controlMessageProcessRecords + /// Remove any expired controlMessageProcessRecords if details.typesToCollect.contains(.expiredControlMessageProcessRecords) { _ = try ControlMessageProcessRecord .filter(ControlMessageProcessRecord.Columns.serverExpirationTimestamp <= timestampNow) .deleteAll(db) } - // Remove any typing indicators + /// Remove any typing indicators if details.typesToCollect.contains(.threadTypingIndicators) { _ = try ThreadTypingIndicator .deleteAll(db) } - // Remove any typing indicators + /// Remove any old open group messages - open group messages which are older than six months if details.typesToCollect.contains(.oldOpenGroupMessages) { let interaction: TypedTableAlias = TypedTableAlias() let thread: TypedTableAlias = TypedTableAlias() @@ -71,7 +71,7 @@ public enum GarbageCollectionJob: JobExecutor { """) } - // Orphaned jobs + /// Orphaned jobs - jobs which have had their threads or interactions removed if details.typesToCollect.contains(.orphanedJobs) { let job: TypedTableAlias = TypedTableAlias() let thread: TypedTableAlias = TypedTableAlias() @@ -97,7 +97,7 @@ public enum GarbageCollectionJob: JobExecutor { """) } - // Orphaned link previews + /// Orphaned link previews - link previews which have no interactions with matching url & rounded timestamps if details.typesToCollect.contains(.orphanedLinkPreviews) { let linkPreview: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() @@ -116,7 +116,70 @@ public enum GarbageCollectionJob: JobExecutor { """) } - // Orphaned attachments + /// Orphaned open groups - open groups which are no longer associated to a thread (except for the session-run ones for which + /// we want cached image data even if the user isn't in the group) + if details.typesToCollect.contains(.orphanedOpenGroups) { + let openGroup: TypedTableAlias = TypedTableAlias() + let thread: TypedTableAlias = TypedTableAlias() + + try db.execute(literal: """ + DELETE FROM \(OpenGroup.self) + WHERE \(Column.rowID) IN ( + SELECT \(openGroup.alias[Column.rowID]) + FROM \(OpenGroup.self) + LEFT JOIN \(SessionThread.self) ON \(thread[.id]) = \(openGroup[.threadId]) + WHERE ( + \(thread[.id]) IS NULL AND + \(SQL("\(openGroup[.server]) != \(OpenGroupAPI.defaultServer.lowercased())")) + ) + ) + """) + } + + /// Orphaned open group capabilities - capabilities which have no existing open groups with the same server + if details.typesToCollect.contains(.orphanedOpenGroupCapabilities) { + let capability: TypedTableAlias = TypedTableAlias() + let openGroup: TypedTableAlias = TypedTableAlias() + + try db.execute(literal: """ + DELETE FROM \(Capability.self) + WHERE \(Column.rowID) IN ( + SELECT \(capability.alias[Column.rowID]) + FROM \(Capability.self) + LEFT JOIN \(OpenGroup.self) ON \(openGroup[.server]) = \(capability[.openGroupServer]) + WHERE \(openGroup[.threadId]) IS NULL + ) + """) + } + + /// Orphaned blinded id lookups - lookups which have no existing threads or approval/block settings for either blinded/un-blinded id + if details.typesToCollect.contains(.orphanedBlindedIdLookups) { + let blindedIdLookup: TypedTableAlias = TypedTableAlias() + let thread: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() + + try db.execute(literal: """ + DELETE FROM \(BlindedIdLookup.self) + WHERE \(Column.rowID) IN ( + SELECT \(blindedIdLookup.alias[Column.rowID]) + FROM \(BlindedIdLookup.self) + LEFT JOIN \(SessionThread.self) ON ( + \(thread[.id]) = \(blindedIdLookup[.blindedId]) OR + \(thread[.id]) = \(blindedIdLookup[.sessionId]) + ) + LEFT JOIN \(Contact.self) ON ( + \(contact[.id]) = \(blindedIdLookup[.blindedId]) OR + \(contact[.id]) = \(blindedIdLookup[.sessionId]) + ) + WHERE ( + \(thread[.id]) IS NULL AND + \(contact[.id]) IS NULL + ) + ) + """) + } + + /// Orphaned attachments - attachments which have no related interactions, quotes or link previews if details.typesToCollect.contains(.orphanedAttachments) { let attachment: TypedTableAlias = TypedTableAlias() let quote: TypedTableAlias = TypedTableAlias() @@ -140,7 +203,7 @@ public enum GarbageCollectionJob: JobExecutor { """) } - // Orphaned attachment files + /// Orphaned attachment files - attachment files which don't have an associated record in the database if details.typesToCollect.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 @@ -153,7 +216,7 @@ public enum GarbageCollectionJob: JobExecutor { .fetchSet(db) } - // Orphaned profile avatar files + /// Orphaned profile avatar files - profile avatar files which don't have an associated record in the database if details.typesToCollect.contains(.orphanedProfileAvatars) { profileAvatarFilenames = try Profile .select(.profilePictureFileName) @@ -162,7 +225,14 @@ public enum GarbageCollectionJob: JobExecutor { .fetchSet(db) } }, - completion: { _, _ in + completion: { _, result in + // If any of the above failed then we don't want to continue (we would end up deleting all files since + // neither of the arrays would have been populated correctly) + guard case .success = result else { + SNLog("[GarbageCollectionJob] Database queries failed, skipping file cleanup") + return + } + var deletionErrors: [Error] = [] // Orphaned attachment files (actual deletion) @@ -249,6 +319,9 @@ extension GarbageCollectionJob { case oldOpenGroupMessages case orphanedJobs case orphanedLinkPreviews + case orphanedOpenGroups + case orphanedOpenGroupCapabilities + case orphanedBlindedIdLookups case orphanedAttachments case orphanedAttachmentFiles case orphanedProfileAvatars diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift index 44baf85e0..96436c6f4 100644 --- a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift @@ -123,6 +123,9 @@ public enum MessageSendJob: JobExecutor { } } + // Store the sentTimestamp from the message in case it fails due to a clockOutOfSync error + let originalSentTimestamp: UInt64? = details.message.sentTimestamp + // Add the threadId to the message if there isn't one set details.message.threadId = (details.message.threadId ?? job.threadId) @@ -143,9 +146,13 @@ public enum MessageSendJob: JobExecutor { case let senderError as MessageSenderError where !senderError.isRetryable: failure(job, error, true) - case OnionRequestAPI.Error.httpRequestFailedAtDestination(let statusCode, _, _) where statusCode == 429: // Rate limited + case OnionRequestAPIError.httpRequestFailedAtDestination(let statusCode, _, _) where statusCode == 429: // Rate limited failure(job, error, true) + case SnodeAPIError.clockOutOfSync: + SNLog("\(originalSentTimestamp != nil ? "Permanently Failing" : "Failing") to send \(type(of: details.message)) due to clock out of sync issue.") + failure(job, error, (originalSentTimestamp != nil)) + default: SNLog("Failed to send \(type(of: details.message)).") diff --git a/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift b/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift index 4ade85d97..3c22f7430 100644 --- a/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift +++ b/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift @@ -27,23 +27,28 @@ public enum NotifyPushServerJob: JobExecutor { return } - let parameters: JSON = [ - "data": details.message.data.description, - "send_to": details.message.recipient - ] + let requestBody: RequestBody = RequestBody( + data: details.message.data.description, + sendTo: details.message.recipient + ) - let request = TSRequest(url: url, method: "POST", parameters: parameters) - request.allHTTPHeaderFields = [ - "Content-Type": "application/json" - ] + guard let body: Data = try? JSONEncoder().encode(requestBody) else { + failure(job, HTTP.Error.invalidJSON, true) + return + } + + var request: URLRequest = URLRequest(url: url) + request.httpMethod = "POST" + request.allHTTPHeaderFields = [ Header.contentType.rawValue: "application/json" ] + request.httpBody = body attempt(maxRetryCount: 4, recoveringOn: DispatchQueue.global()) { OnionRequestAPI .sendOnionRequest( request, to: server, - target: "/loki/v2/lsrpc", - using: PushNotificationAPI.serverPublicKey + using: .v2, + with: PushNotificationAPI.serverPublicKey ) .map { _ in } } @@ -63,4 +68,14 @@ extension NotifyPushServerJob { public struct Details: Codable { public let message: SnodeMessage } + + struct RequestBody: Codable { + enum CodingKeys: String, CodingKey { + case data + case sendTo = "send_to" + } + + let data: String + let sendTo: String + } } diff --git a/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift b/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift index d9a9b4c2c..85e6bbd1e 100644 --- a/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift +++ b/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift @@ -22,7 +22,22 @@ public enum RetrieveDefaultOpenGroupRoomsJob: JobExecutor { return } - OpenGroupAPIV2.getDefaultRoomsIfNeeded() + // The OpenGroupAPI won't make any API calls if there is no entry for an OpenGroup + // in the database so we need to create a dummy one to retrieve the default room data + GRDBStorage.shared.write { db in + _ = try OpenGroup( + server: OpenGroupAPI.defaultServer, + roomToken: "", + publicKey: OpenGroupAPI.defaultServerPublicKey, + isActive: false, + name: "", + userCount: 0, + infoUpdates: 0 + ) + .saved(db) + } + + OpenGroupManager.getDefaultRoomsIfNeeded() .done { _ in success(job, false) } .catch { error in failure(job, error, false) } .retainUntilComplete() diff --git a/SessionMessagingKit/Messages/Control Messages/CallMessage.swift b/SessionMessagingKit/Messages/Control Messages/CallMessage.swift index d0ff3caed..e53aa06fc 100644 --- a/SessionMessagingKit/Messages/Control Messages/CallMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/CallMessage.swift @@ -1,26 +1,41 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB import WebRTC +import SessionUtilitiesKit /// See https://developer.mozilla.org/en-US/docs/Web/API/RTCSessionDescription for more information. -@objc(SNCallMessage) -public final class CallMessage : ControlMessage { - public var uuid: String? - public var kind: Kind? +public final class CallMessage: ControlMessage { + private enum CodingKeys: String, CodingKey { + case uuid + case kind + case sdps + } + + public var uuid: String + public var kind: Kind + /// See https://developer.mozilla.org/en-US/docs/Glossary/SDP for more information. - public var sdps: [String]? + public var sdps: [String] public override var isSelfSendValid: Bool { switch kind { - case .answer, .endCall: return true - default: return false + case .answer, .endCall: return true + default: return false } } - public override var shouldBeRetryable: Bool { true } + // MARK: - Kind - // NOTE: Multiple ICE candidates may be batched together for performance - - // MARK: Kind - public enum Kind : Codable, CustomStringConvertible { + /// **Note:** Multiple ICE candidates may be batched together for performance + public enum Kind: Codable, CustomStringConvertible { + private enum CodingKeys: String, CodingKey { + case description + case sdpMLineIndexes + case sdpMids + } + case preOffer case offer case answer @@ -30,130 +45,219 @@ public final class CallMessage : ControlMessage { public var description: String { switch self { - case .preOffer: return "preOffer" - case .offer: return "offer" - case .answer: return "answer" - case .provisionalAnswer: return "provisionalAnswer" - case .iceCandidates(_, _): return "iceCandidates" - case .endCall: return "endCall" + case .preOffer: return "preOffer" + case .offer: return "offer" + case .answer: return "answer" + case .provisionalAnswer: return "provisionalAnswer" + case .iceCandidates(_, _): return "iceCandidates" + case .endCall: return "endCall" + } + } + + public init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + // Compare the descriptions to find the appropriate case + let description: String = try container.decode(String.self, forKey: .description) + + switch description { + case Kind.preOffer.description: self = .preOffer + case Kind.offer.description: self = .offer + case Kind.answer.description: self = .answer + case Kind.provisionalAnswer.description: self = .provisionalAnswer + + case Kind.iceCandidates(sdpMLineIndexes: [], sdpMids: []).description: + self = .iceCandidates( + sdpMLineIndexes: try container.decode([UInt32].self, forKey: .sdpMLineIndexes), + sdpMids: try container.decode([String].self, forKey: .sdpMids) + ) + + case Kind.endCall.description: self = .endCall + + default: fatalError("Invalid case when trying to decode ClosedGroupControlMessage.Kind") + } + } + + public func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(description, forKey: .description) + + // Note: If you modify the below make sure to update the above 'init(from:)' method + switch self { + case .preOffer: break // Only 'description' + case .offer: break // Only 'description' + case .answer: break // Only 'description' + case .provisionalAnswer: break // Only 'description' + case .iceCandidates(let sdpMLineIndexes, let sdpMids): + try container.encode(sdpMLineIndexes, forKey: .sdpMLineIndexes) + try container.encode(sdpMids, forKey: .sdpMids) + + case .endCall: break // Only 'description' } } } - // MARK: Initialization - public override init() { super.init() } + // MARK: - Initialization - internal init(uuid: String, kind: Kind, sdps: [String]) { - super.init() + public init( + uuid: String, + kind: Kind, + sdps: [String], + sentTimestampMs: UInt64? = nil + ) { self.uuid = uuid self.kind = kind self.sdps = sdps + + super.init(sentTimestamp: sentTimestampMs) } - // MARK: Validation - public override var isValid: Bool { - guard super.isValid else { return false } - return kind != nil && uuid != nil + // MARK: - Codable + + required init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + self.uuid = try container.decode(String.self, forKey: .uuid) + self.kind = try container.decode(Kind.self, forKey: .kind) + self.sdps = try container.decode([String].self, forKey: .sdps) + + try super.init(from: decoder) } - // MARK: Coding - public required init?(coder: NSCoder) { - super.init(coder: coder) - guard let rawKind = coder.decodeObject(forKey: "kind") as! String? else { return nil } - switch rawKind { - case "preOffer": kind = .preOffer - case "offer": kind = .offer - case "answer": kind = .answer - case "provisionalAnswer": kind = .provisionalAnswer - case "iceCandidates": - guard let sdpMLineIndexes = coder.decodeObject(forKey: "sdpMLineIndexes") as? [UInt32], - let sdpMids = coder.decodeObject(forKey: "sdpMids") as? [String] else { return nil } - kind = .iceCandidates(sdpMLineIndexes: sdpMLineIndexes, sdpMids: sdpMids) - case "endCall": kind = .endCall - default: preconditionFailure() - } - if let sdps = coder.decodeObject(forKey: "sdps") as! [String]? { self.sdps = sdps } - if let uuid = coder.decodeObject(forKey: "uuid") as! String? { self.uuid = uuid } - } - - public override func encode(with coder: NSCoder) { - super.encode(with: coder) - switch kind { - case .preOffer: coder.encode("preOffer", forKey: "kind") - case .offer: coder.encode("offer", forKey: "kind") - case .answer: coder.encode("answer", forKey: "kind") - case .provisionalAnswer: coder.encode("provisionalAnswer", forKey: "kind") - case let .iceCandidates(sdpMLineIndexes, sdpMids): - coder.encode("iceCandidates", forKey: "kind") - coder.encode(sdpMLineIndexes, forKey: "sdpMLineIndexes") - coder.encode(sdpMids, forKey: "sdpMids") - case .endCall: coder.encode("endCall", forKey: "kind") - default: preconditionFailure() - } - coder.encode(sdps, forKey: "sdps") - coder.encode(uuid, forKey: "uuid") + public override func encode(to encoder: Encoder) throws { + try super.encode(to: encoder) + + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(uuid, forKey: .uuid) + try container.encode(kind, forKey: .kind) + try container.encode(sdps, forKey: .sdps) } - // MARK: Proto Conversion - public override class func fromProto(_ proto: SNProtoContent) -> CallMessage? { + // MARK: - Proto Conversion + + public override class func fromProto(_ proto: SNProtoContent, sender: String) -> CallMessage? { guard let callMessageProto = proto.callMessage else { return nil } + let kind: Kind + switch callMessageProto.type { - case .preOffer: kind = .preOffer - case .offer: kind = .offer - case .answer: kind = .answer - case .provisionalAnswer: kind = .provisionalAnswer - case .iceCandidates: - let sdpMLineIndexes = callMessageProto.sdpMlineIndexes - let sdpMids = callMessageProto.sdpMids - kind = .iceCandidates(sdpMLineIndexes: sdpMLineIndexes, sdpMids: sdpMids) - case .endCall: kind = .endCall + case .preOffer: kind = .preOffer + case .offer: kind = .offer + case .answer: kind = .answer + case .provisionalAnswer: kind = .provisionalAnswer + case .iceCandidates: + kind = .iceCandidates( + sdpMLineIndexes: callMessageProto.sdpMlineIndexes, + sdpMids: callMessageProto.sdpMids + ) + + case .endCall: kind = .endCall } + let sdps = callMessageProto.sdps let uuid = callMessageProto.uuid - return CallMessage(uuid: uuid, kind: kind, sdps: sdps) + + return CallMessage( + uuid: uuid, + kind: kind, + sdps: sdps + ) } - - public override func toProto(using transaction: YapDatabaseReadWriteTransaction) -> SNProtoContent? { - guard let kind = kind, let uuid = uuid else { - SNLog("Couldn't construct call message proto from: \(self).") - return nil - } + + public override func toProto(_ db: Database) -> SNProtoContent? { let type: SNProtoCallMessage.SNProtoCallMessageType + switch kind { - case .preOffer: type = .preOffer - case .offer: type = .offer - case .answer: type = .answer - case .provisionalAnswer: type = .provisionalAnswer - case .iceCandidates(_, _): type = .iceCandidates - case .endCall: type = .endCall + case .preOffer: type = .preOffer + case .offer: type = .offer + case .answer: type = .answer + case .provisionalAnswer: type = .provisionalAnswer + case .iceCandidates(_, _): type = .iceCandidates + case .endCall: type = .endCall } + let callMessageProto = SNProtoCallMessage.builder(type: type, uuid: uuid) - if let sdps = sdps, !sdps.isEmpty { + if !sdps.isEmpty { callMessageProto.setSdps(sdps) } + if case let .iceCandidates(sdpMLineIndexes, sdpMids) = kind { callMessageProto.setSdpMlineIndexes(sdpMLineIndexes) callMessageProto.setSdpMids(sdpMids) } + let contentProto = SNProtoContent.builder() do { contentProto.setCallMessage(try callMessageProto.build()) + return try contentProto.build() - } catch { + } + catch { SNLog("Couldn't construct call message proto from: \(self).") return nil } } - // MARK: Description - public override var description: String { + // MARK: - Description + + public var description: String { """ CallMessage( - uuid: \(uuid ?? "null"), - kind: \(kind?.description ?? "null"), - sdps: \(sdps?.description ?? "null") + uuid: \(uuid), + kind: \(kind.description), + sdps: \(sdps.description) ) """ } } + +// MARK: - Convenience + +public extension CallMessage { + struct MessageInfo: Codable { + public enum State: Codable { + case incoming + case outgoing + case missed + case permissionDenied + case unknown + } + + public let state: State + + // MARK: - Initialization + + public init(state: State) { + self.state = state + } + + // MARK: - Content + + func previewText(authorDisplayName: String) -> String { + switch state { + case .incoming: + return String( + format: "call_incoming".localized(), + authorDisplayName + ) + + case .outgoing: + return String( + format: "call_outgoing".localized(), + authorDisplayName + ) + + case .missed, .permissionDenied: + return String( + format: "call_missed".localized(), + authorDisplayName + ) + + // TODO: We should do better here + case .unknown: return "" + } + } + } +} diff --git a/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift b/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift index cf611786d..a8160efe1 100644 --- a/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift @@ -254,7 +254,7 @@ public final class ClosedGroupControlMessage: ControlMessage { publicKey: publicKey, name: name, encryptionKeyPair: Box.KeyPair( - publicKey: encryptionKeyPairAsProto.publicKey.removing05PrefixIfNeeded().bytes, + publicKey: encryptionKeyPairAsProto.publicKey.removingIdPrefixIfNeeded().bytes, secretKey: encryptionKeyPairAsProto.privateKey.bytes ), members: closedGroupControlMessageProto.members, diff --git a/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift b/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift index 481d977df..a2d7bf096 100644 --- a/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift +++ b/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift @@ -36,8 +36,13 @@ extension ConfigurationMessage { ) } .asSet() - let openGroups: Set = try OpenGroup.fetchAll(db) - .map { "\($0.server)/\($0.room)?public_key=\($0.publicKey)" } + // The default room promise creates an OpenGroup with an empty `roomToken` value, + // we don't want to start a poller for this as the user hasn't actually joined a room + let openGroups: Set = try OpenGroup + .filter(OpenGroup.Columns.roomToken != "") + .filter(OpenGroup.Columns.isActive) + .fetchAll(db) + .map { "\($0.server)/\($0.roomToken)?public_key=\($0.publicKey)" } .asSet() let contacts: Set = try Contact .filter(Contact.Columns.id != currentUserProfile.id) diff --git a/SessionMessagingKit/Messages/Message+Destination.swift b/SessionMessagingKit/Messages/Message+Destination.swift index b5f51c14b..a61a05344 100644 --- a/SessionMessagingKit/Messages/Message+Destination.swift +++ b/SessionMessagingKit/Messages/Message+Destination.swift @@ -8,7 +8,6 @@ public extension Message { enum Destination: Codable { case contact(publicKey: String) case closedGroup(groupPublicKey: String) - case legacyOpenGroup(channel: UInt64, server: String) case openGroup( roomToken: String, server: String, @@ -25,14 +24,14 @@ public extension Message { ) throws -> Message.Destination { switch thread.variant { case .contact: - if SessionId.Prefix(from: thread.contactSessionID()) == .blinded { - guard let server: String = thread.originalOpenGroupServer, let publicKey: String = thread.originalOpenGroupPublicKey else { + if SessionId.Prefix(from: thread.id) == .blinded { + guard let lookup: BlindedIdLookup = try? BlindedIdLookup.fetchOne(db, id: thread.id) else { preconditionFailure("Attempting to send message to blinded id without the Open Group information") } return .openGroupInbox( - server: server, - openGroupPublicKey: publicKey, + server: lookup.openGroupServer, + openGroupPublicKey: lookup.openGroupPublicKey, blindedPublicKey: thread.id ) } @@ -47,7 +46,7 @@ public extension Message { throw StorageError.objectNotFound } - return .openGroup(roomToken: openGroup.room, server: openGroup.server, fileIds: fileIds) + return .openGroup(roomToken: openGroup.roomToken, server: openGroup.server, fileIds: fileIds) } } } diff --git a/SessionMessagingKit/Messages/Message.swift b/SessionMessagingKit/Messages/Message.swift index cef27a06a..26ff79c98 100644 --- a/SessionMessagingKit/Messages/Message.swift +++ b/SessionMessagingKit/Messages/Message.swift @@ -100,6 +100,7 @@ public extension Message { case unsendRequest case messageRequestResponse case visibleMessage + case callMessage init?(from type: Message) { switch type { @@ -112,6 +113,7 @@ public extension Message { case is UnsendRequest: self = .unsendRequest case is MessageRequestResponse: self = .messageRequestResponse case is VisibleMessage: self = .visibleMessage + case is CallMessage: self = .callMessage default: return nil } } @@ -127,6 +129,7 @@ public extension Message { case .unsendRequest: return UnsendRequest.self case .messageRequestResponse: return MessageRequestResponse.self case .visibleMessage: return VisibleMessage.self + case .callMessage: return CallMessage.self } } @@ -146,6 +149,7 @@ public extension Message { case .unsendRequest: return try container.decode(UnsendRequest.self, forKey: key) case .messageRequestResponse: return try container.decode(MessageRequestResponse.self, forKey: key) case .visibleMessage: return try container.decode(VisibleMessage.self, forKey: key) + case .callMessage: return try container.decode(CallMessage.self, forKey: key) } } } @@ -162,7 +166,8 @@ public extension Message { .configurationMessage, .unsendRequest, .messageRequestResponse, - .visibleMessage + .visibleMessage, + .callMessage ] return prioritisedVariants @@ -173,6 +178,26 @@ public extension Message { } } + static func shouldSync(message: Message) -> Bool { + switch message { + case let controlMessage as ClosedGroupControlMessage: + switch controlMessage.kind { + case .new: return true + default: return false + } + + case let callMessage as CallMessage: + switch callMessage.kind { + case .answer, .endCall: return true + default: return false + } + + case is ConfigurationMessage: return true + case is UnsendRequest: return true + default: return false + } + } + static func processRawReceivedMessage( _ db: Database, rawMessage: SnodeReceivedMessage @@ -260,22 +285,95 @@ public extension Message { return processedMessage } + static func processReceivedOpenGroupMessage( + _ db: Database, + openGroupId: String, + openGroupServerPublicKey: String, + message: OpenGroupAPI.Message, + data: Data, + dependencies: Dependencies = Dependencies() + ) throws -> ProcessedMessage? { + // Need a sender in order to process the message + guard let sender: String = message.sender else { return nil } + + // Note: The `posted` value is in seconds but all messages in the database use milliseconds for timestamps + let envelopeBuilder = SNProtoEnvelope.builder(type: .sessionMessage, timestamp: UInt64(floor(message.posted * 1000))) + envelopeBuilder.setContent(data) + envelopeBuilder.setSource(sender) + + guard let envelope = try? envelopeBuilder.build() else { + throw MessageReceiverError.invalidMessage + } + + return try processRawReceivedMessage( + db, + envelope: envelope, + serverExpirationTimestamp: nil, + serverHash: nil, + openGroupId: openGroupId, + openGroupMessageServerId: message.id, + openGroupServerPublicKey: openGroupServerPublicKey, + handleClosedGroupKeyUpdateMessages: false, + dependencies: dependencies + ) + } + + static func processReceivedOpenGroupDirectMessage( + _ db: Database, + openGroupServerPublicKey: String, + message: OpenGroupAPI.DirectMessage, + data: Data, + isOutgoing: Bool? = nil, + otherBlindedPublicKey: String? = nil, + dependencies: Dependencies = Dependencies() + ) throws -> ProcessedMessage? { + // Note: The `posted` value is in seconds but all messages in the database use milliseconds for timestamps + let envelopeBuilder = SNProtoEnvelope.builder(type: .sessionMessage, timestamp: UInt64(floor(message.posted * 1000))) + envelopeBuilder.setContent(data) + envelopeBuilder.setSource(message.sender) + + guard let envelope = try? envelopeBuilder.build() else { + throw MessageReceiverError.invalidMessage + } + + return try processRawReceivedMessage( + db, + envelope: envelope, + serverExpirationTimestamp: nil, + serverHash: nil, + openGroupId: nil, // Explicitly null since it shouldn't be handled as an open group message + openGroupMessageServerId: message.id, + openGroupServerPublicKey: openGroupServerPublicKey, + isOutgoing: isOutgoing, + otherBlindedPublicKey: otherBlindedPublicKey, + handleClosedGroupKeyUpdateMessages: false, + dependencies: dependencies + ) + } + private static func processRawReceivedMessage( _ db: Database, envelope: SNProtoEnvelope, - serverExpirationTimestamp: TimeInterval, + serverExpirationTimestamp: TimeInterval?, serverHash: String?, - // TODO: These openGroupId: String? = nil, - openGroupMessageServerId: UInt64? = nil, - handleClosedGroupKeyUpdateMessages: Bool + openGroupMessageServerId: Int64? = nil, + openGroupServerPublicKey: String? = nil, + isOutgoing: Bool? = nil, + otherBlindedPublicKey: String? = nil, + handleClosedGroupKeyUpdateMessages: Bool, + dependencies: Dependencies = Dependencies() ) throws -> ProcessedMessage? { let (message, proto, threadId) = try MessageReceiver.parse( db, envelope: envelope, serverExpirationTimestamp: serverExpirationTimestamp, openGroupId: openGroupId, - openGroupMessageServerId: openGroupMessageServerId + openGroupMessageServerId: openGroupMessageServerId, + openGroupServerPublicKey: openGroupServerPublicKey, + isOutgoing: isOutgoing, + otherBlindedPublicKey: otherBlindedPublicKey, + dependencies: dependencies ) message.serverHash = serverHash diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift index 79ae5c07e..a45766c36 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift @@ -12,9 +12,11 @@ public extension VisibleMessage { // MARK: - Initialization internal init(displayName: String, profileKey: Data? = nil, profilePictureUrl: String? = nil) { + let hasUrlAndKey: Bool = (profileKey != nil && profilePictureUrl != nil) + self.displayName = displayName - self.profileKey = profileKey - self.profilePictureUrl = profilePictureUrl + self.profileKey = (hasUrlAndKey ? profileKey : nil) + self.profilePictureUrl = (hasUrlAndKey ? profilePictureUrl : nil) } // MARK: - Proto Conversion diff --git a/SessionMessagingKit/Open Groups/Models/Capabilities.swift b/SessionMessagingKit/Open Groups/Models/Capabilities.swift index 30da0117b..4c4332485 100644 --- a/SessionMessagingKit/Open Groups/Models/Capabilities.swift +++ b/SessionMessagingKit/Open Groups/Models/Capabilities.swift @@ -8,15 +8,15 @@ extension OpenGroupAPI { public static var allCases: [Capability] { [.sogs, .blind] } - + case sogs case blind - + /// Fallback case if the capability isn't supported by this version of the app case unsupported(String) - + // MARK: - Convenience - + public var rawValue: String { switch self { case .unsupported(let originalValue): return originalValue @@ -25,19 +25,19 @@ extension OpenGroupAPI { } // MARK: - Initialization - + public init(from valueString: String) { let maybeValue: Capability? = Capability.allCases.first { $0.rawValue == valueString } self = (maybeValue ?? .unsupported(valueString)) } } - + public let capabilities: [Capability] public let missing: [Capability]? - + // MARK: - Initialization - + public init(capabilities: [Capability], missing: [Capability]? = nil) { self.capabilities = capabilities self.missing = missing @@ -47,17 +47,17 @@ extension OpenGroupAPI { extension OpenGroupAPI.Capabilities.Capability { // MARK: - Codable - + public init(from decoder: Decoder) throws { let container: SingleValueDecodingContainer = try decoder.singleValueContainer() let valueString: String = try container.decode(String.self) - + self = OpenGroupAPI.Capabilities.Capability(from: valueString) } - + public func encode(to encoder: Encoder) throws { var container: SingleValueEncodingContainer = encoder.singleValueContainer() - + try container.encode(rawValue) } } diff --git a/SessionMessagingKit/Open Groups/Models/Room.swift b/SessionMessagingKit/Open Groups/Models/Room.swift index cfcddbc22..c791e04dd 100644 --- a/SessionMessagingKit/Open Groups/Models/Room.swift +++ b/SessionMessagingKit/Open Groups/Models/Room.swift @@ -70,7 +70,7 @@ extension OpenGroupAPI { /// File ID of an uploaded file containing the room's image /// /// Omitted if there is no image - public let imageId: UInt64? + public let imageId: Int64? /// Array of pinned message information (omitted entirely if there are no pinned messages) public let pinnedMessages: [PinnedMessage]? @@ -160,7 +160,7 @@ extension OpenGroupAPI.Room { activeUsers: try container.decode(Int64.self, forKey: .activeUsers), activeUsersCutoff: try container.decode(Int64.self, forKey: .activeUsersCutoff), - imageId: try? container.decode(UInt64.self, forKey: .imageId), + imageId: try? container.decode(Int64.self, forKey: .imageId), pinnedMessages: try? container.decode([OpenGroupAPI.PinnedMessage].self, forKey: .pinnedMessages), admin: ((try? container.decode(Bool.self, forKey: .admin)) ?? false), diff --git a/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift b/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift index 296bd8467..8a69a520c 100644 --- a/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift +++ b/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import SessionUtilitiesKit extension OpenGroupAPI { public struct Message: Codable, Equatable { diff --git a/SessionMessagingKit/Open Groups/Models/Server.swift b/SessionMessagingKit/Open Groups/Models/Server.swift deleted file mode 100644 index 019939d15..000000000 --- a/SessionMessagingKit/Open Groups/Models/Server.swift +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Sodium -import SessionUtilitiesKit - -extension OpenGroupAPI { - @objc(SOGSServer) - public final class Server: NSObject, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility - @objc public let name: String - public let capabilities: Capabilities - - public init( - name: String, - capabilities: Capabilities - ) { - self.name = name.lowercased() - self.capabilities = capabilities - } - - // MARK: - Coding - - public init?(coder: NSCoder) { - let capabilitiesString: [String] = coder.decodeObject(forKey: "capabilities") as! [String] - let missingCapabilitiesString: [String]? = coder.decodeObject(forKey: "missingCapabilities") as? [String] - - name = coder.decodeObject(forKey: "name") as! String - capabilities = Capabilities( - capabilities: capabilitiesString.map { Capabilities.Capability(from: $0) }, - missing: missingCapabilitiesString?.map { Capabilities.Capability(from: $0) } - ) - - super.init() - } - - public func encode(with coder: NSCoder) { - coder.encode(name, forKey: "name") - coder.encode(capabilities.capabilities.map { $0.rawValue }, forKey: "capabilities") - coder.encode(capabilities.missing?.map { $0.rawValue }, forKey: "missingCapabilities") - } - - override public var description: String { - "\(name) (Capabilities: [\(capabilities.capabilities.map { $0.rawValue }.joined(separator: ", "))], Missing: [\((capabilities.missing ?? []).map { $0.rawValue }.joined(separator: ", "))])" - } - } -} diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index 294e74b8f..55b507135 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -1,3 +1,7 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB import PromiseKit import Sodium import Curve25519Kit @@ -24,16 +28,30 @@ public enum OpenGroupAPI { /// - Inbox for the server /// - Outbox for the server public static func poll( - _ server: String, + _ db: Database, + server: String, hasPerformedInitialPoll: Bool, timeSinceLastPoll: TimeInterval, using dependencies: Dependencies = Dependencies() ) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable?)]> { - let maybeLastInboxMessageId: Int64? = dependencies.storage.getOpenGroupInboxLatestMessageId(for: server) - let maybeLastOutboxMessageId: Int64? = dependencies.storage.getOpenGroupOutboxLatestMessageId(for: server) - let lastInboxMessageId: Int64 = (maybeLastInboxMessageId ?? 0) - let lastOutboxMessageId: Int64 = (maybeLastOutboxMessageId ?? 0) - let serverConfig: Server? = dependencies.storage.getOpenGroupServer(name: server) + let lastInboxMessageId: Int64 = (try? OpenGroup + .select(.inboxLatestMessageId) + .filter(OpenGroup.Columns.server == server) + .asRequest(of: Int64.self) + .fetchOne(db)) + .defaulting(to: 0) + let lastOutboxMessageId: Int64 = (try? OpenGroup + .select(.outboxLatestMessageId) + .filter(OpenGroup.Columns.server == server) + .asRequest(of: Int64.self) + .fetchOne(db)) + .defaulting(to: 0) + let capabilities: Set = (try? Capability + .select(.variant) + .filter(Capability.Columns.openGroupServer == server) + .asRequest(of: Capability.Variant.self) + .fetchSet(db)) + .defaulting(to: []) // Generate the requests let requestResponseType: [BatchRequestInfoType] = [ @@ -47,13 +65,13 @@ public enum OpenGroupAPI { ] .appending( // Per-room requests - dependencies.storage.getAllOpenGroups().values - .filter { $0.server == server.lowercased() } // Note: The `OpenGroup` type converts to lowercase in init + contentsOf: (try? OpenGroup + .filter(OpenGroup.Columns.server == server.lowercased()) // Note: The `OpenGroup` type converts to lowercase in init + .fetchAll(db)) + .defaulting(to: []) .flatMap { openGroup -> [BatchRequestInfoType] in - let lastSeqNo: Int64? = dependencies.storage.getOpenGroupSequenceNumber(for: openGroup.room, on: server) - let targetSeqNo: Int64 = (lastSeqNo ?? 0) let shouldRetrieveRecentMessages: Bool = ( - lastSeqNo == nil || ( + openGroup.sequenceNumber == 0 || ( // If it's the first poll for this launch and it's been longer than // 'maxInactivityPeriod' then just retrieve recent messages instead // of trying to get all messages since the last one retrieved @@ -66,7 +84,7 @@ public enum OpenGroupAPI { BatchRequestInfo( request: Request( server: server, - endpoint: .roomPollInfo(openGroup.room, openGroup.infoUpdates) + endpoint: .roomPollInfo(openGroup.roomToken, openGroup.infoUpdates) ), responseType: RoomPollInfo.self ), @@ -74,8 +92,8 @@ public enum OpenGroupAPI { request: Request( server: server, endpoint: (shouldRetrieveRecentMessages ? - .roomMessagesRecent(openGroup.room) : - .roomMessagesSince(openGroup.room, seqNo: targetSeqNo) + .roomMessagesRecent(openGroup.roomToken) : + .roomMessagesSince(openGroup.roomToken, seqNo: openGroup.sequenceNumber) ) ), responseType: [Failable].self @@ -84,36 +102,38 @@ public enum OpenGroupAPI { } ) .appending( - // The 'inbox' and 'outbox' only work with blinded keys so don't bother polling them if not blinded - serverConfig?.capabilities.capabilities.contains(.blind) != true ? [] : - [ - // Inbox - BatchRequestInfo( - request: Request( - server: server, - endpoint: (maybeLastInboxMessageId == nil ? - .inbox : - .inboxSince(id: lastInboxMessageId) - ) + contentsOf: ( + // The 'inbox' and 'outbox' only work with blinded keys so don't bother polling them if not blinded + !capabilities.contains(.blind) ? [] : + [ + // Inbox + BatchRequestInfo( + request: Request( + server: server, + endpoint: (lastInboxMessageId == 0 ? + .inbox : + .inboxSince(id: lastInboxMessageId) + ) + ), + responseType: [DirectMessage]?.self // 'inboxSince' will return a `304` with an empty response if no messages ), - responseType: [DirectMessage]?.self // 'inboxSince' will return a `304` with an empty response if no messages - ), - - // Outbox - BatchRequestInfo( - request: Request( - server: server, - endpoint: (maybeLastOutboxMessageId == nil ? - .outbox : - .outboxSince(id: lastOutboxMessageId) - ) - ), - responseType: [DirectMessage]?.self // 'outboxSince' will return a `304` with an empty response if no messages - ) - ] + + // Outbox + BatchRequestInfo( + request: Request( + server: server, + endpoint: (lastOutboxMessageId == 0 ? + .outbox : + .outboxSince(id: lastOutboxMessageId) + ) + ), + responseType: [DirectMessage]?.self // 'outboxSince' will return a `304` with an empty response if no messages + ) + ] + ) ) - return batch(server, requests: requestResponseType, using: dependencies) + return OpenGroupAPI.batch(db, server: server, requests: requestResponseType, using: dependencies) } /// Submits multiple requests wrapped up in a single request, runs them all, then returns the result of each one @@ -122,18 +142,26 @@ public enum OpenGroupAPI { /// carried out (for sequential, related requests invoke via `/sequence` instead) /// /// For contained subrequests that specify a body (i.e. POST or PUT requests) exactly one of `json`, `b64`, or `bytes` must be provided with the request body. - private static func batch(_ server: String, requests: [BatchRequestInfoType], using dependencies: Dependencies = Dependencies()) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable?)]> { + private static func batch( + _ db: Database, + server: String, + requests: [BatchRequestInfoType], + using dependencies: Dependencies = Dependencies() + ) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable?)]> { let requestBody: BatchRequest = requests.map { $0.toSubRequest() } let responseTypes = requests.map { $0.responseType } - let request: Request = Request( - method: .post, - server: server, - endpoint: Endpoint.batch, - body: requestBody - ) - - return send(request, using: dependencies) + return OpenGroupAPI + .send( + db, + request: Request( + method: .post, + server: server, + endpoint: Endpoint.batch, + body: requestBody + ), + using: dependencies + ) .decoded(as: responseTypes, on: OpenGroupAPI.workQueue, using: dependencies) .map { result in result.enumerated() @@ -152,18 +180,26 @@ public enum OpenGroupAPI { /// /// Like `/batch`, responses are returned in the same order as requests, but unlike `/batch` there may be fewer elements in the response list (if requests were /// stopped because of a non-2xx response) - In such a case, the final, non-2xx response is still included as the final response value - private static func sequence(_ server: String, requests: [BatchRequestInfoType], using dependencies: Dependencies = Dependencies()) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable?)]> { + private static func sequence( + _ db: Database, + server: String, + requests: [BatchRequestInfoType], + using dependencies: Dependencies = Dependencies() + ) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable?)]> { let requestBody: BatchRequest = requests.map { $0.toSubRequest() } let responseTypes = requests.map { $0.responseType } - let request: Request = Request( - method: .post, - server: server, - endpoint: Endpoint.sequence, - body: requestBody - ) - - return send(request, using: dependencies) + return OpenGroupAPI + .send( + db, + request: Request( + method: .post, + server: server, + endpoint: Endpoint.sequence, + body: requestBody + ), + using: dependencies + ) .decoded(as: responseTypes, on: OpenGroupAPI.workQueue, using: dependencies) .map { result in result.enumerated() @@ -182,13 +218,20 @@ public enum OpenGroupAPI { /// /// Eg. `GET /capabilities` could return `{"capabilities": ["sogs", "batch"]}` `GET /capabilities?required=magic,batch` /// could return: `{"capabilities": ["sogs", "batch"], "missing": ["magic"]}` - public static func capabilities(on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Capabilities)> { - let request: Request = Request( - server: server, - endpoint: .capabilities - ) - - return send(request, using: dependencies) + public static func capabilities( + _ db: Database, + on server: String, + using dependencies: Dependencies = Dependencies() + ) -> Promise<(OnionRequestResponseInfoType, Capabilities)> { + return OpenGroupAPI + .send( + db, + request: Request( + server: server, + endpoint: .capabilities + ), + using: dependencies + ) .decoded(as: Capabilities.self, on: OpenGroupAPI.workQueue, using: dependencies) } @@ -197,13 +240,20 @@ public enum OpenGroupAPI { /// Returns a list of available rooms on the server /// /// Rooms to which the user does not have access (e.g. because they are banned, or the room has restricted access permissions) are not included - public static func rooms(for server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [Room])> { - let request: Request = Request( - server: server, - endpoint: .rooms - ) - - return send(request, using: dependencies) + public static func rooms( + _ db: Database, + for server: String, + using dependencies: Dependencies = Dependencies() + ) -> Promise<(OnionRequestResponseInfoType, [Room])> { + return OpenGroupAPI + .send( + db, + request: Request( + server: server, + endpoint: .rooms + ), + using: dependencies + ) .decoded(as: [Room].self, on: OpenGroupAPI.workQueue, using: dependencies) } @@ -213,13 +263,21 @@ public enum OpenGroupAPI { /// this directly remove the `@available` line and make sure to route the response of this method to the `OpenGroupManager.handlePollInfo` /// method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") - public static func room(for roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Room)> { - let request: Request = Request( - server: server, - endpoint: .room(roomToken) - ) - - return send(request, using: dependencies) + public static func room( + _ db: Database, + for roomToken: String, + on server: String, + using dependencies: Dependencies = Dependencies() + ) -> Promise<(OnionRequestResponseInfoType, Room)> { + return OpenGroupAPI + .send( + db, + request: Request( + server: server, + endpoint: .room(roomToken) + ), + using: dependencies + ) .decoded(as: Room.self, on: OpenGroupAPI.workQueue, using: dependencies) } @@ -232,19 +290,29 @@ public enum OpenGroupAPI { /// this directly remove the `@available` line and make sure to route the response of this method to the `OpenGroupManager.handlePollInfo` /// method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") - public static func roomPollInfo(lastUpdated: Int64, for roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, RoomPollInfo)> { - let request: Request = Request( - server: server, - endpoint: .roomPollInfo(roomToken, lastUpdated) - ) - - return send(request, using: dependencies) + public static func roomPollInfo( + _ db: Database, + lastUpdated: Int64, + for roomToken: String, + on server: String, + using dependencies: Dependencies = Dependencies() + ) -> Promise<(OnionRequestResponseInfoType, RoomPollInfo)> { + return OpenGroupAPI + .send( + db, + request: Request( + server: server, + endpoint: .roomPollInfo(roomToken, lastUpdated) + ), + using: dependencies + ) .decoded(as: RoomPollInfo.self, on: OpenGroupAPI.workQueue, using: dependencies) } /// This is a convenience method which constructs a `/sequence` of the `capabilities` and `room` requests, refer to those /// methods for the documented behaviour of each method public static func capabilitiesAndRoom( + _ db: Database, for roomToken: String, on server: String, using dependencies: Dependencies = Dependencies() @@ -269,7 +337,13 @@ public enum OpenGroupAPI { ) ] - return sequence(server, requests: requestResponseType, using: dependencies) + return OpenGroupAPI + .sequence( + db, + server: server, + requests: requestResponseType, + using: dependencies + ) .map { (response: [Endpoint: (OnionRequestResponseInfoType, Codable?)]) -> (capabilities: (OnionRequestResponseInfoType, Capabilities), room: (OnionRequestResponseInfoType, Room)) in let maybeCapabilities: (info: OnionRequestResponseInfoType, data: Capabilities?)? = response[.capabilities] .map { info, data in (info, (data as? BatchSubResponse)?.body) } @@ -304,7 +378,8 @@ public enum OpenGroupAPI { /// Posts a new message to a room public static func send( - _ plaintext: Data, + _ db: Database, + plaintext: Data, to roomToken: String, on server: String, whisperTo: String?, @@ -312,46 +387,47 @@ public enum OpenGroupAPI { fileIds: [String]?, using dependencies: Dependencies = Dependencies() ) -> Promise<(OnionRequestResponseInfoType, Message)> { - guard let signResult: (publicKey: String, signature: Bytes) = sign(plaintext.bytes, for: server, fallbackSigningType: .standard, using: dependencies) else { + guard let signResult: (publicKey: String, signature: Bytes) = sign(db, messageBytes: plaintext.bytes, for: server, fallbackSigningType: .standard, using: dependencies) else { return Promise(error: Error.signingFailed) } - let requestBody: SendMessageRequest = SendMessageRequest( - data: plaintext, - signature: Data(signResult.signature), - whisperTo: whisperTo, - whisperMods: whisperMods, - fileIds: fileIds - ) - - let request = Request( - method: .post, - server: server, - endpoint: Endpoint.roomMessage(roomToken), - body: requestBody - ) - - return send(request, using: dependencies) + return OpenGroupAPI + .send( + db, + request: Request( + method: .post, + server: server, + endpoint: Endpoint.roomMessage(roomToken), + body: SendMessageRequest( + data: plaintext, + signature: Data(signResult.signature), + whisperTo: whisperTo, + whisperMods: whisperMods, + fileIds: fileIds + ) + ), + using: dependencies + ) .decoded(as: Message.self, on: OpenGroupAPI.workQueue, using: dependencies) - .map { response, message in - // Store the 'message.posted' timestamp to prevent the sent message getting duplicated when it is later retrieved - dependencies.storage.write { transaction in - // The `posted` value is in seconds but we sent it in ms so need that for de-duping - dependencies.storage.addReceivedMessageTimestamp(UInt64(floor(message.posted * 1000)), using: transaction) - } - - return (response, message) - } } /// Returns a single message by ID - public static func message(_ id: UInt64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Message)> { - let request: Request = Request( - server: server, - endpoint: .roomMessageIndividual(roomToken, id: id) - ) - - return send(request, using: dependencies) + public static func message( + _ db: Database, + id: Int64, + in roomToken: String, + on server: String, + using dependencies: Dependencies = Dependencies() + ) -> Promise<(OnionRequestResponseInfoType, Message)> { + return OpenGroupAPI + .send( + db, + request: Request( + server: server, + endpoint: .roomMessageIndividual(roomToken, id: id) + ), + using: dependencies + ) .decoded(as: Message.self, on: OpenGroupAPI.workQueue, using: dependencies) } @@ -359,59 +435,73 @@ public enum OpenGroupAPI { /// /// **Note:** This edit may only be initiated by the creator of the post, and the poster must currently have write permissions in the room public static func messageUpdate( - _ id: UInt64, + _ db: Database, + id: Int64, plaintext: Data, fileIds: [Int64]?, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies() ) -> Promise<(OnionRequestResponseInfoType, Data?)> { - guard let signResult: (publicKey: String, signature: Bytes) = sign(plaintext.bytes, for: server, fallbackSigningType: .standard, using: dependencies) else { + guard let signResult: (publicKey: String, signature: Bytes) = sign(db, messageBytes: plaintext.bytes, for: server, fallbackSigningType: .standard, using: dependencies) else { return Promise(error: Error.signingFailed) } - let requestBody: UpdateMessageRequest = UpdateMessageRequest( - data: plaintext, - signature: Data(signResult.signature), - fileIds: fileIds - ) - - let request: Request = Request( - method: .put, - server: server, - endpoint: Endpoint.roomMessageIndividual(roomToken, id: id), - body: requestBody - ) - - return send(request, using: dependencies) + return OpenGroupAPI + .send( + db, + request: Request( + method: .put, + server: server, + endpoint: Endpoint.roomMessageIndividual(roomToken, id: id), + body: UpdateMessageRequest( + data: plaintext, + signature: Data(signResult.signature), + fileIds: fileIds + ) + ), + using: dependencies + ) } public static func messageDelete( - _ id: UInt64, + _ db: Database, + id: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies() ) -> Promise<(OnionRequestResponseInfoType, Data?)> { - let request: Request = Request( - method: .delete, - server: server, - endpoint: .roomMessageIndividual(roomToken, id: id) - ) - - return send(request, using: dependencies) + return OpenGroupAPI + .send( + db, + request: Request( + method: .delete, + server: server, + endpoint: .roomMessageIndividual(roomToken, id: id) + ), + using: dependencies + ) } /// **Note:** This is the direct request to retrieve recent messages so should be retrieved automatically from the `poll()` method, in order to call /// this directly remove the `@available` line and make sure to route the response of this method to the `OpenGroupManager.handleMessages` /// method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") - public static func recentMessages(in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [Message])> { - let request: Request = Request( - server: server, - endpoint: .roomMessagesRecent(roomToken) - ) - - return send(request, using: dependencies) + public static func recentMessages( + _ db: Database, + in roomToken: String, + on server: String, + using dependencies: Dependencies = Dependencies() + ) -> Promise<(OnionRequestResponseInfoType, [Message])> { + return OpenGroupAPI + .send( + db, + request: Request( + server: server, + endpoint: .roomMessagesRecent(roomToken) + ), + using: dependencies + ) .decoded(as: [Message].self, on: OpenGroupAPI.workQueue, using: dependencies) } @@ -419,13 +509,22 @@ public enum OpenGroupAPI { /// remove the `@available` line and make sure to route the response of this method to the `OpenGroupManager.handleMessages` /// method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") - public static func messagesBefore(messageId: UInt64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [Message])> { - let request: Request = Request( - server: server, - endpoint: .roomMessagesBefore(roomToken, id: messageId) - ) - - return send(request, using: dependencies) + public static func messagesBefore( + _ db: Database, + messageId: Int64, + in roomToken: String, + on server: String, + using dependencies: Dependencies = Dependencies() + ) -> Promise<(OnionRequestResponseInfoType, [Message])> { + return OpenGroupAPI + .send( + db, + request: Request( + server: server, + endpoint: .roomMessagesBefore(roomToken, id: messageId) + ), + using: dependencies + ) .decoded(as: [Message].self, on: OpenGroupAPI.workQueue, using: dependencies) } @@ -433,13 +532,22 @@ public enum OpenGroupAPI { /// `poll()` method, in order to call this directly remove the `@available` line and make sure to route the response of this method to the /// `OpenGroupManager.handleMessages` method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") - public static func messagesSince(seqNo: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [Message])> { - let request: Request = Request( - server: server, - endpoint: .roomMessagesSince(roomToken, seqNo: seqNo) - ) - - return send(request, using: dependencies) + public static func messagesSince( + _ db: Database, + seqNo: Int64, + in roomToken: String, + on server: String, + using dependencies: Dependencies = Dependencies() + ) -> Promise<(OnionRequestResponseInfoType, [Message])> { + return OpenGroupAPI + .send( + db, + request: Request( + server: server, + endpoint: .roomMessagesSince(roomToken, seqNo: seqNo) + ), + using: dependencies + ) .decoded(as: [Message].self, on: OpenGroupAPI.workQueue, using: dependencies) } @@ -457,18 +565,22 @@ public enum OpenGroupAPI { /// /// - dependencies: Injected dependencies (used for unit testing) public static func messagesDeleteAll( - _ sessionId: String, + _ db: Database, + sessionId: String, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies() ) -> Promise<(OnionRequestResponseInfoType, Data?)> { - let request: Request = Request( - method: .delete, - server: server, - endpoint: Endpoint.roomDeleteMessages(roomToken, sessionId: sessionId) - ) - - return send(request, using: dependencies) + return OpenGroupAPI + .send( + db, + request: Request( + method: .delete, + server: server, + endpoint: Endpoint.roomDeleteMessages(roomToken, sessionId: sessionId) + ), + using: dependencies + ) } // MARK: - Pinning @@ -483,72 +595,117 @@ public enum OpenGroupAPI { /// Pinned messages that are already pinned will be re-pinned (that is, their pin timestamp and pinning admin user will be updated) - because pinned /// messages are returned in pinning-order this allows admins to order multiple pinned messages in a room by re-pinning (via this endpoint) in the /// order in which pinned messages should be displayed - public static func pinMessage(id: UInt64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise { - let request: Request = Request( - method: .post, - server: server, - endpoint: .roomPinMessage(roomToken, id: id) - ) - - return send(request, using: dependencies) + public static func pinMessage( + _ db: Database, + id: Int64, + in roomToken: String, + on server: String, + using dependencies: Dependencies = Dependencies() + ) -> Promise { + return OpenGroupAPI + .send( + db, + request: Request( + method: .post, + server: server, + endpoint: .roomPinMessage(roomToken, id: id) + ), + using: dependencies + ) .map { responseInfo, _ in responseInfo } } /// Remove a message from this room's pinned message list /// /// The user must have `admin` (not just `moderator`) permissions in the room - public static func unpinMessage(id: UInt64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise { - let request: Request = Request( - method: .post, - server: server, - endpoint: .roomUnpinMessage(roomToken, id: id) - ) - - return send(request, using: dependencies) + public static func unpinMessage( + _ db: Database, + id: Int64, + in roomToken: String, + on server: String, + using dependencies: Dependencies = Dependencies() + ) -> Promise { + return OpenGroupAPI + .send( + db, + request: Request( + method: .post, + server: server, + endpoint: .roomUnpinMessage(roomToken, id: id) + ), + using: dependencies + ) .map { responseInfo, _ in responseInfo } } /// Removes _all_ pinned messages from this room /// /// The user must have `admin` (not just `moderator`) permissions in the room - public static func unpinAll(in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise { - let request: Request = Request( - method: .post, - server: server, - endpoint: .roomUnpinAll(roomToken) - ) - - return send(request, using: dependencies) + public static func unpinAll( + _ db: Database, + in roomToken: String, + on server: String, + using dependencies: Dependencies = Dependencies() + ) -> Promise { + return OpenGroupAPI + .send( + db, + request: Request( + method: .post, + server: server, + endpoint: .roomUnpinAll(roomToken) + ), + using: dependencies + ) .map { responseInfo, _ in responseInfo } } // MARK: - Files - public static func uploadFile(_ bytes: [UInt8], fileName: String? = nil, to roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, FileUploadResponse)> { - let request: Request = Request( - method: .post, - server: server, - endpoint: Endpoint.roomFile(roomToken), - headers: [ - .contentDisposition: [ "attachment", fileName.map { "filename=\"\($0)\"" } ] - .compactMap{ $0 } - .joined(separator: "; "), - .contentType: "application/octet-stream" - ], - body: bytes - ) - - return send(request, using: dependencies) + public static func uploadFile( + _ db: Database, + bytes: [UInt8], + fileName: String? = nil, + to roomToken: String, + on server: String, + using dependencies: Dependencies = Dependencies() + ) -> Promise<(OnionRequestResponseInfoType, FileUploadResponse)> { + return OpenGroupAPI + .send( + db, + request: Request( + method: .post, + server: server, + endpoint: Endpoint.roomFile(roomToken), + headers: [ + .contentDisposition: [ "attachment", fileName.map { "filename=\"\($0)\"" } ] + .compactMap{ $0 } + .joined(separator: "; "), + .contentType: "application/octet-stream" + ], + body: bytes + ), + using: dependencies + ) .decoded(as: FileUploadResponse.self, on: OpenGroupAPI.workQueue, using: dependencies) } - public static func downloadFile(_ fileId: UInt64, from roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Data)> { - let request: Request = Request( - server: server, - endpoint: .roomFileIndividual(roomToken, fileId) - ) - - return send(request, using: dependencies) + public static func downloadFile( + _ db: Database, + fileId: Int64, + from roomToken: String, + on server: String, + using dependencies: Dependencies = Dependencies() + ) -> Promise<(OnionRequestResponseInfoType, Data)> { + return OpenGroupAPI + .send( + db, + request: Request( + server: server, + endpoint: .roomFileIndividual(roomToken, fileId) + ), + using: dependencies + ) .map { responseInfo, maybeData in guard let data: Data = maybeData else { throw HTTP.Error.parsingFailed } @@ -564,13 +721,20 @@ public enum OpenGroupAPI { /// method, in order to call this directly remove the `@available` line and make sure to route the response of this method to the /// `OpenGroupManager.handleDirectMessages` method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") - public static func inbox(on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> { - let request: Request = Request( - server: server, - endpoint: .inbox - ) - - return send(request, using: dependencies) + public static func inbox( + _ db: Database, + on server: String, + using dependencies: Dependencies = Dependencies() + ) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> { + return OpenGroupAPI + .send( + db, + request: Request( + server: server, + endpoint: .inbox + ), + using: dependencies + ) .decoded(as: [DirectMessage]?.self, on: OpenGroupAPI.workQueue, using: dependencies) } @@ -580,42 +744,48 @@ public enum OpenGroupAPI { /// automatically from the `poll()` method, in order to call this directly remove the `@available` line and make sure to route the response /// of this method to the `OpenGroupManager.handleDirectMessages` method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") - public static func inboxSince(id: Int64, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> { - let request: Request = Request( - server: server, - endpoint: .inboxSince(id: id) - ) - - return send(request, using: dependencies) + public static func inboxSince( + _ db: Database, + id: Int64, + on server: String, + using dependencies: Dependencies = Dependencies() + ) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> { + return OpenGroupAPI + .send( + db, + request: Request( + server: server, + endpoint: .inboxSince(id: id) + ), + using: dependencies + ) .decoded(as: [DirectMessage]?.self, on: OpenGroupAPI.workQueue, using: dependencies) } /// Delivers a direct message to a user via their blinded Session ID /// /// The body of this request is a JSON object containing a message key with a value of the encrypted-then-base64-encoded message to deliver - public static func send(_ ciphertext: Data, toInboxFor blindedSessionId: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, SendDirectMessageResponse)> { - let requestBody: SendDirectMessageRequest = SendDirectMessageRequest( - message: ciphertext - ) - - let request: Request = Request( - method: .post, - server: server, - endpoint: Endpoint.inboxFor(sessionId: blindedSessionId), - body: requestBody - ) - - return send(request, using: dependencies) + public static func send( + _ db: Database, + ciphertext: Data, + toInboxFor blindedSessionId: String, + on server: String, + using dependencies: Dependencies = Dependencies() + ) -> Promise<(OnionRequestResponseInfoType, SendDirectMessageResponse)> { + return OpenGroupAPI + .send( + db, + request: Request( + method: .post, + server: server, + endpoint: Endpoint.inboxFor(sessionId: blindedSessionId), + body: SendDirectMessageRequest( + message: ciphertext + ) + ), + using: dependencies + ) .decoded(as: SendDirectMessageResponse.self, on: OpenGroupAPI.workQueue, using: dependencies) - .map { response, message in - // Store the 'message.posted' timestamp to prevent the sent message getting duplicated when it is later retrieved - dependencies.storage.write { transaction in - // The `posted` value is in seconds but we sent it in ms so need that for de-duping - dependencies.storage.addReceivedMessageTimestamp(UInt64(floor(message.posted * 1000)), using: transaction) - } - - return (response, message) - } } /// Retrieves all of the user's sent DMs (up to limit) @@ -624,13 +794,20 @@ public enum OpenGroupAPI { /// from the `poll()` method, in order to call this directly remove the `@available` line and make sure to route the response of /// this method to the `OpenGroupManager.handleDirectMessages` method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") - public static func outbox(on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> { - let request: Request = Request( - server: server, - endpoint: .outbox - ) - - return send(request, using: dependencies) + public static func outbox( + _ db: Database, + on server: String, + using dependencies: Dependencies = Dependencies() + ) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> { + return OpenGroupAPI + .send( + db, + request: Request( + server: server, + endpoint: .outbox + ), + using: dependencies + ) .decoded(as: [DirectMessage]?.self, on: OpenGroupAPI.workQueue, using: dependencies) } @@ -640,13 +817,21 @@ public enum OpenGroupAPI { /// should be retrieved automatically from the `poll()` method, in order to call this directly remove the `@available` line and make sure /// to route the response of this method to the `OpenGroupManager.handleDirectMessages` method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") - public static func outboxSince(id: Int64, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> { - let request: Request = Request( - server: server, - endpoint: .outboxSince(id: id) - ) - - return send(request, using: dependencies) + public static func outboxSince( + _ db: Database, + id: Int64, + on server: String, + using dependencies: Dependencies = Dependencies() + ) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> { + return OpenGroupAPI + .send( + db, + request: Request( + server: server, + endpoint: .outboxSince(id: id) + ), + using: dependencies + ) .decoded(as: [DirectMessage]?.self, on: OpenGroupAPI.workQueue, using: dependencies) } @@ -684,26 +869,28 @@ public enum OpenGroupAPI { /// /// - dependencies: Injected dependencies (used for unit testing) public static func userBan( - _ sessionId: String, + _ db: Database, + sessionId: String, for timeout: TimeInterval? = nil, from roomTokens: [String]? = nil, on server: String, using dependencies: Dependencies = Dependencies() ) -> Promise<(OnionRequestResponseInfoType, Data?)> { - let requestBody: UserBanRequest = UserBanRequest( - rooms: roomTokens, - global: (roomTokens == nil ? true : nil), - timeout: timeout - ) - - let request: Request = Request( - method: .post, - server: server, - endpoint: Endpoint.userBan(sessionId), - body: requestBody - ) - - return send(request, using: dependencies) + return OpenGroupAPI + .send( + db, + request: Request( + method: .post, + server: server, + endpoint: Endpoint.userBan(sessionId), + body: UserBanRequest( + rooms: roomTokens, + global: (roomTokens == nil ? true : nil), + timeout: timeout + ) + ), + using: dependencies + ) } /// Removes a user ban from specific rooms, or from the server globally @@ -731,24 +918,26 @@ public enum OpenGroupAPI { /// /// - dependencies: Injected dependencies (used for unit testing) public static func userUnban( - _ sessionId: String, + _ db: Database, + sessionId: String, from roomTokens: [String]?, on server: String, using dependencies: Dependencies = Dependencies() ) -> Promise<(OnionRequestResponseInfoType, Data?)> { - let requestBody: UserUnbanRequest = UserUnbanRequest( - rooms: roomTokens, - global: (roomTokens == nil ? true : nil) - ) - - let request: Request = Request( - method: .post, - server: server, - endpoint: Endpoint.userUnban(sessionId), - body: requestBody - ) - - return send(request, using: dependencies) + return OpenGroupAPI + .send( + db, + request: Request( + method: .post, + server: server, + endpoint: Endpoint.userUnban(sessionId), + body: UserUnbanRequest( + rooms: roomTokens, + global: (roomTokens == nil ? true : nil) + ) + ), + using: dependencies + ) } /// Appoints or removes a moderator or admin @@ -803,7 +992,8 @@ public enum OpenGroupAPI { /// /// - dependencies: Injected dependencies (used for unit testing) public static func userModeratorUpdate( - _ sessionId: String, + _ db: Database, + sessionId: String, moderator: Bool? = nil, admin: Bool? = nil, visible: Bool, @@ -815,28 +1005,30 @@ public enum OpenGroupAPI { return Promise(error: HTTP.Error.generic) } - let requestBody: UserModeratorRequest = UserModeratorRequest( - rooms: roomTokens, - global: (roomTokens == nil ? true : nil), - moderator: moderator, - admin: admin, - visible: visible - ) - - let request: Request = Request( - method: .post, - server: server, - endpoint: Endpoint.userModerator(sessionId), - body: requestBody - ) - - return send(request, using: dependencies) + return OpenGroupAPI + .send( + db, + request: Request( + method: .post, + server: server, + endpoint: Endpoint.userModerator(sessionId), + body: UserModeratorRequest( + rooms: roomTokens, + global: (roomTokens == nil ? true : nil), + moderator: moderator, + admin: admin, + visible: visible + ) + ), + using: dependencies + ) } /// This is a convenience method which constructs a `/sequence` of the `userBan` and `userDeleteMessages` requests, refer to those /// methods for the documented behaviour of each method public static func userBanAndDeleteAllMessages( - _ sessionId: String, + _ db: Database, + sessionId: String, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies() @@ -866,23 +1058,44 @@ public enum OpenGroupAPI { ) ] - return sequence(server, requests: requestResponseType, using: dependencies) + return OpenGroupAPI + .sequence( + db, + server: server, + requests: requestResponseType, + using: dependencies + ) .map { $0.values.map { responseInfo, _ in responseInfo } } } // MARK: - Authentication /// Sign a message to be sent to SOGS (handles both un-blinded and blinded signing based on the server capabilities) - private static func sign(_ messageBytes: Bytes, for serverName: String, fallbackSigningType signingType: SessionId.Prefix, using dependencies: Dependencies = Dependencies()) -> (publicKey: String, signature: Bytes)? { - guard let userEdKeyPair: Box.KeyPair = dependencies.storage.getUserED25519KeyPair() else { return nil } - guard let serverPublicKey: String = dependencies.storage.getOpenGroupPublicKey(for: serverName) else { - return nil - } + private static func sign( + _ db: Database, + messageBytes: Bytes, + for serverName: String, + fallbackSigningType signingType: SessionId.Prefix, + using dependencies: Dependencies = Dependencies() + ) -> (publicKey: String, signature: Bytes)? { + guard + let userEdKeyPair: Box.KeyPair = Identity.fetchUserEd25519KeyPair(db), + let serverPublicKey: String = try? OpenGroup + .select(.publicKey) + .filter(OpenGroup.Columns.server == serverName.lowercased()) + .asRequest(of: String.self) + .fetchOne(db) + else { return nil } - let server: Server? = dependencies.storage.getOpenGroupServer(name: serverName) + let capabilities: Set = (try? Capability + .select(.variant) + .filter(Capability.Columns.openGroupServer == serverName.lowercased()) + .asRequest(of: Capability.Variant.self) + .fetchSet(db)) + .defaulting(to: []) // Check if the server supports blinded keys, if so then sign using the blinded key - if server?.capabilities.capabilities.contains(.blind) == true { + if capabilities.contains(.blind) { guard let blindedKeyPair: Box.KeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: serverPublicKey, edKeyPair: userEdKeyPair, genericHash: dependencies.genericHash) else { return nil } @@ -911,20 +1124,26 @@ public enum OpenGroupAPI { // Default to using the 'standard' key default: - guard let userKeyPair: ECKeyPair = dependencies.storage.getUserKeyPair() else { return nil } + guard let userKeyPair: Box.KeyPair = Identity.fetchUserKeyPair(db) else { return nil } guard let signatureResult: Bytes = try? dependencies.ed25519.sign(data: messageBytes, keyPair: userKeyPair) else { return nil } return ( - publicKey: SessionId(.standard, publicKey: userKeyPair.publicKey.bytes).hexString, + publicKey: SessionId(.standard, publicKey: userKeyPair.publicKey).hexString, signature: signatureResult ) } } /// Sign a request to be sent to SOGS (handles both un-blinded and blinded signing based on the server capabilities) - private static func sign(_ request: URLRequest, for serverName: String, with serverPublicKey: String, using dependencies: Dependencies = Dependencies()) -> URLRequest? { + private static func sign( + _ db: Database, + request: URLRequest, + for serverName: String, + with serverPublicKey: String, + using dependencies: Dependencies = Dependencies() + ) -> URLRequest? { guard let url: URL = request.url else { return nil } var updatedRequest: URLRequest = request @@ -953,14 +1172,14 @@ public enum OpenGroupAPI { /// `Path` /// `Body` is a Blake2b hash of the data (if there is a body) let messageBytes: Bytes = serverPublicKeyData.bytes - .appending(nonce.bytes) - .appending(timestampBytes) - .appending(method.bytes) - .appending(path.bytes) - .appending(bodyHash ?? []) + .appending(contentsOf: nonce.bytes) + .appending(contentsOf: timestampBytes) + .appending(contentsOf: method.bytes) + .appending(contentsOf: path.bytes) + .appending(contentsOf: bodyHash ?? []) /// Sign the above message - guard let signResult: (publicKey: String, signature: Bytes) = sign(messageBytes, for: serverName, fallbackSigningType: .unblinded, using: dependencies) else { + guard let signResult: (publicKey: String, signature: Bytes) = sign(db, messageBytes: messageBytes, for: serverName, fallbackSigningType: .unblinded, using: dependencies) else { return nil } @@ -977,7 +1196,11 @@ public enum OpenGroupAPI { // MARK: - Convenience - private static func send(_ request: Request, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Data?)> { + private static func send( + _ db: Database, + request: Request, + using dependencies: Dependencies = Dependencies() + ) -> Promise<(OnionRequestResponseInfoType, Data?)> { let urlRequest: URLRequest do { @@ -987,12 +1210,16 @@ public enum OpenGroupAPI { return Promise(error: error) } - guard let publicKey = dependencies.storage.getOpenGroupPublicKey(for: request.server) else { - return Promise(error: Error.noPublicKey) - } + let maybePublicKey: String? = try? OpenGroup + .select(.publicKey) + .filter(OpenGroup.Columns.server == request.server.lowercased()) + .asRequest(of: String.self) + .fetchOne(db) + + guard let publicKey: String = maybePublicKey else { return Promise(error: Error.noPublicKey) } // Attempt to sign the request with the new auth - guard let signedRequest: URLRequest = sign(urlRequest, for: request.server, with: publicKey, using: dependencies) else { + guard let signedRequest: URLRequest = sign(db, request: urlRequest, for: request.server, with: publicKey, using: dependencies) else { return Promise(error: Error.signingFailed) } diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index e221ec64c..9c63fc773 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -16,9 +16,6 @@ public protocol OGMCacheType { var pollers: [String: OpenGroupAPI.Poller] { get set } var isPolling: Bool { get set } - var moderators: [String: [String: Set]] { get set } - var admins: [String: [String: Set]] { get set } - var hasPerformedInitialPoll: [String: Bool] { get set } var timeSinceLastPoll: [String: TimeInterval] { get set } @@ -38,10 +35,6 @@ public final class OpenGroupManager: NSObject { public var pollers: [String: OpenGroupAPI.Poller] = [:] // One for each server public var isPolling: Bool = false - /// Server URL to room ID to set of user IDs - public var moderators: [String: [String: Set]] = [:] - public var admins: [String: [String: Set]] = [:] - /// Server URL to value public var hasPerformedInitialPoll: [String: Bool] = [:] public var timeSinceLastPoll: [String: TimeInterval] = [:] @@ -74,20 +67,24 @@ public final class OpenGroupManager: NSObject { public func startPolling(using dependencies: OGMDependencies = OGMDependencies()) { guard !dependencies.cache.isPolling else { return } + let servers: Set = GRDBStorage.shared + .read { db in + // The default room promise creates an OpenGroup with an empty `roomToken` value, + // we don't want to start a poller for this as the user hasn't actually joined a room + try OpenGroup + .select(.server) + .filter(OpenGroup.Columns.roomToken != "") + .filter(OpenGroup.Columns.isActive) + .distinct() + .asRequest(of: String.self) + .fetchSet(db) + } + .defaulting(to: []) + dependencies.mutableCache.mutate { cache in cache.isPolling = true - - let servers: Set = GRDBStorage.shared - .read { db in - try OpenGroup - .select(.server) - .distinct() - .asRequest(of: String.self) - .fetchSet(db) - } - .defaulting(to: []) cache.pollers = servers - .reduce([:]) { result, server in + .reduce(into: [:]) { result, server in result[server]?.stop() // Should never occur result[server] = OpenGroupAPI.Poller(for: server) @@ -106,7 +103,7 @@ public final class OpenGroupManager: NSObject { // MARK: - Adding & Removing - public func hasExistingOpenGroup(roomToken: String, server: String, publicKey: String, using transaction: YapDatabaseReadWriteTransaction, dependencies: OGMDependencies = OGMDependencies()) -> Bool { + public func hasExistingOpenGroup(_ db: Database, roomToken: String, server: String, publicKey: String, dependencies: OGMDependencies = OGMDependencies()) -> Bool { guard let serverUrl: URL = URL(string: server) else { return false } let serverHost: String = (serverUrl.host ?? server) @@ -143,50 +140,85 @@ public final class OpenGroupManager: NSObject { // Then check if there is an existing open group thread let hasExistingThread: Bool = serverOptions.contains(where: { serverName in - let groupId: Data = LKGroupUtilities.getEncodedOpenGroupIDAsData("\(serverName).\(roomToken)") - - return (TSGroupThread.fetch(groupId: groupId, transaction: transaction) != nil) + (try? SessionThread + .exists( + db, + id: OpenGroup.idFor(roomToken: roomToken, server: serverName) + )) + .defaulting(to: false) }) return hasExistingThread } - public func add(roomToken: String, server: String, publicKey: String, isConfigMessage: Bool, using transaction: YapDatabaseReadWriteTransaction, dependencies: OGMDependencies = OGMDependencies()) -> Promise { + public func add(_ db: Database, roomToken: String, server: String, publicKey: String, isConfigMessage: Bool, dependencies: OGMDependencies = OGMDependencies()) -> Promise { // If we are currently polling for this server and already have a TSGroupThread for this room the do nothing - if hasExistingOpenGroup(roomToken: roomToken, server: server, publicKey: publicKey, using: transaction, dependencies: dependencies) { + if hasExistingOpenGroup(db, roomToken: roomToken, server: server, publicKey: publicKey, dependencies: dependencies) { SNLog("Ignoring join open group attempt (already joined), user initiated: \(!isConfigMessage)") return Promise.value(()) } - // Clear any existing data if needed - dependencies.storage.removeOpenGroupSequenceNumber(for: roomToken, on: server, using: transaction) + // Store the open group information + let threadId: String = OpenGroup.idFor(roomToken: roomToken, server: server) - // Store the public key - dependencies.storage.setOpenGroupPublicKey(for: server, to: publicKey, using: transaction) + _ = try? SessionThread.fetchOrCreate(db, id: threadId, variant: .openGroup) + _ = try? OpenGroup + .fetchOne(db, id: threadId) + .defaulting( + to: OpenGroup( + server: server, + roomToken: roomToken, + publicKey: publicKey, + isActive: true, + name: "", + roomDescription: nil, + imageId: nil, + imageData: nil, + userCount: 0, + infoUpdates: -1, + sequenceNumber: 0, + inboxLatestMessageId: 0, + outboxLatestMessageId: 0 + ) + ) + .with( + // Set the group to active and reset the sequenceNumber (handle groups which have + // been deactivated) + isActive: true, + sequenceNumber: 0 + ) + .saved(db) let (promise, seal) = Promise.pending() - transaction.addCompletionQueue(DispatchQueue.global(qos: .userInitiated)) { - OpenGroupAPI.capabilitiesAndRoom(for: roomToken, on: server, using: dependencies) - .done(on: DispatchQueue.global(qos: .userInitiated)) { response in - dependencies.storage.write { anyTransaction in - guard let transaction: YapDatabaseReadWriteTransaction = anyTransaction as? YapDatabaseReadWriteTransaction else { return } - + // Note: We don't do this after the db commit as it can fail (resulting in endless loading) + OpenGroupAPI.workQueue.async { + dependencies.storage + .write { db in + OpenGroupAPI + .capabilitiesAndRoom( + db, + for: roomToken, + on: server, + using: dependencies + ) + } + .done(on: OpenGroupAPI.workQueue) { response in + dependencies.storage.write { db in // Store the capabilities first OpenGroupManager.handleCapabilities( - response.capabilities.data, - on: server, - using: transaction, - dependencies: dependencies + db, + capabilities: response.capabilities.data, + on: server ) // Then the room - OpenGroupManager.handlePollInfo( - OpenGroupAPI.RoomPollInfo(room: response.room.data), + try OpenGroupManager.handlePollInfo( + db, + pollInfo: OpenGroupAPI.RoomPollInfo(room: response.room.data), publicKey: publicKey, for: roomToken, on: server, - using: transaction, dependencies: dependencies ) { seal.fulfill(()) @@ -197,6 +229,7 @@ public final class OpenGroupManager: NSObject { SNLog("Failed to join open group.") seal.reject(error) } + .retainUntilComplete() } return promise @@ -210,19 +243,42 @@ public final class OpenGroupManager: NSObject { .fetchOne(db) // Stop the poller if needed - let numRooms: Int = (try? OpenGroup + // + // Note: The default room promise creates an OpenGroup with an empty `roomToken` value, + // we don't want to start a poller for this as the user hasn't actually joined a room + let numActiveRooms: Int = (try? OpenGroup .filter(OpenGroup.Columns.server == server) + .filter(OpenGroup.Columns.roomToken != "") + .filter(OpenGroup.Columns.isActive) .fetchCount(db)) .defaulting(to: 1) - if numRooms == 1, let server: String = server { + if numActiveRooms == 1, let server: String = server { let poller = dependencies.cache.pollers[server] poller?.stop() dependencies.mutableCache.mutate { $0.pollers[server] = nil } } - // Remove all data - _ = SessionThread + // Remove all the data (everything should cascade delete) + _ = try? SessionThread + .filter(id: openGroupId) + .deleteAll(db) + + // Remove the open group (no foreign key to the thread so it won't auto-delete) + if server?.lowercased() != OpenGroupAPI.defaultServer.lowercased() { + _ = try? OpenGroup + .filter(id: openGroupId) + .deleteAll(db) + } + else { + // If it's a session-run room then just set it to inactive + _ = try? OpenGroup + .filter(id: openGroupId) + .updateAll(db, OpenGroup.Columns.isActive.set(to: false)) + } + + // Remove the thread and associated data + _ = try? SessionThread .filter(id: openGroupId) .deleteAll(db) } @@ -230,88 +286,83 @@ public final class OpenGroupManager: NSObject { // MARK: - Response Processing internal static func handleCapabilities( - _ capabilities: OpenGroupAPI.Capabilities, - on server: String, - using transaction: YapDatabaseReadWriteTransaction, - dependencies: OGMDependencies = OGMDependencies() + _ db: Database, + capabilities: OpenGroupAPI.Capabilities, + on server: String ) { - let updatedServer: OpenGroupAPI.Server = OpenGroupAPI.Server( - name: server, - capabilities: capabilities - ) + // Remove old capabilities first + _ = try? Capability + .filter(Capability.Columns.openGroupServer == server) + .deleteAll(db) - dependencies.storage.setOpenGroupServer(updatedServer, using: transaction) + // Then insert the new capabilities (both present and missing) + capabilities.capabilities.forEach { capability in + _ = try? Capability( + openGroupServer: server, + variant: Capability.Variant(from: capability.rawValue), + isMissing: false + ) + .saved(db) + } + capabilities.missing?.forEach { capability in + _ = try? Capability( + openGroupServer: server, + variant: Capability.Variant(from: capability.rawValue), + isMissing: true + ) + .saved(db) + } } internal static func handlePollInfo( - _ pollInfo: OpenGroupAPI.RoomPollInfo, + _ db: Database, + pollInfo: OpenGroupAPI.RoomPollInfo, publicKey maybePublicKey: String?, for roomToken: String, on server: String, waitForImageToComplete: Bool = false, - using transaction: YapDatabaseReadWriteTransaction, dependencies: OGMDependencies = OGMDependencies(), completion: (() -> ())? = nil - ) { + ) throws { // Create the open group model and get or create the thread - let groupId: Data = LKGroupUtilities.getEncodedOpenGroupIDAsData("\(server).\(roomToken)") - let userPublicKey: String = getUserHexEncodedPublicKey(using: dependencies) - let initialModel: TSGroupModel = TSGroupModel( - title: (pollInfo.details?.name ?? ""), - memberIds: [ userPublicKey ], - image: nil, - groupId: groupId, - groupType: .openGroup, - adminIds: (pollInfo.details?.admins ?? []), - moderatorIds: (pollInfo.details?.moderators ?? []) - ) - var maybeUpdatedModel: TSGroupModel? = nil + let threadId: String = OpenGroup.idFor(roomToken: roomToken, server: server) - // Store/Update everything - let thread = TSGroupThread.getOrCreateThread(with: initialModel, transaction: transaction) - let existingOpenGroup: OpenGroup? = thread.uniqueId.flatMap { uniqueId -> OpenGroup? in - dependencies.storage.getOpenGroup(for: uniqueId) + guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { return } + + let updatedOpenGroup: OpenGroup = try openGroup + .with( + name: pollInfo.details?.name, + roomDescription: pollInfo.details?.roomDescription, + imageId: pollInfo.details?.imageId.map { "\($0)" }, + userCount: pollInfo.activeUsers, + infoUpdates: pollInfo.details?.infoUpdates + ) + .saved(db) + + // Update the admin/moderator group members + if let roomDetails: OpenGroupAPI.Room = pollInfo.details { + _ = try? GroupMember + .filter(GroupMember.Columns.groupId == threadId) + .deleteAll(db) + + try roomDetails.admins.forEach { adminId in + _ = try GroupMember( + groupId: threadId, + profileId: adminId, + role: .admin + ).saved(db) + } + + try roomDetails.moderators.forEach { moderatorId in + _ = try GroupMember( + groupId: threadId, + profileId: moderatorId, + role: .moderator + ).saved(db) + } } - - guard let threadUniqueId: String = thread.uniqueId else { return } - guard let publicKey: String = (maybePublicKey ?? existingOpenGroup?.publicKey) else { return } - let updatedModel: TSGroupModel = TSGroupModel( - title: (pollInfo.details?.name ?? thread.groupModel.groupName), - memberIds: Array(Set(thread.groupModel.groupMemberIds).inserting(userPublicKey)), - image: thread.groupModel.groupImage, - groupId: groupId, - groupType: .openGroup, - adminIds: (pollInfo.details?.admins ?? thread.groupModel.groupAdminIds), - moderatorIds: (pollInfo.details?.moderators ?? thread.groupModel.groupModeratorIds) - ) - maybeUpdatedModel = updatedModel - let updatedOpenGroup: OpenGroup = OpenGroup( - server: server, - room: pollInfo.token, - publicKey: publicKey, - name: (pollInfo.details?.name ?? thread.name()), - groupDescription: (pollInfo.details?.roomDescription ?? existingOpenGroup?.groupDescription), - imageID: (pollInfo.details?.imageId.map { "\($0)" } ?? existingOpenGroup?.imageID), - infoUpdates: ((pollInfo.details?.infoUpdates ?? existingOpenGroup?.infoUpdates) ?? 0) - ) - - // - Thread changes - thread.shouldBeVisible = true - thread.groupModel = updatedModel - thread.save(with: transaction) - - // - Open Group changes - dependencies.storage.setOpenGroup(updatedOpenGroup, for: threadUniqueId, using: transaction) - - // - User Count - dependencies.storage.setUserCount( - to: UInt64(pollInfo.activeUsers), - forOpenGroupWithID: updatedOpenGroup.id, - using: transaction - ) - - transaction.addCompletionQueue(DispatchQueue.global(qos: .userInitiated)) { + db.afterNextTransactionCommit { db in // Start the poller if needed if dependencies.cache.pollers[server] == nil { dependencies.mutableCache.mutate { @@ -320,30 +371,20 @@ public final class OpenGroupManager: NSObject { } } - // - Moderators - if let moderators: [String] = (pollInfo.details?.moderators ?? maybeUpdatedModel?.groupModeratorIds) { - dependencies.mutableCache.mutate { cache in - cache.moderators[server] = (cache.moderators[server] ?? [:]).setting(roomToken, Set(moderators)) - } - } - - // - Admins - if let admins: [String] = (pollInfo.details?.admins ?? maybeUpdatedModel?.groupAdminIds) { - dependencies.mutableCache.mutate { cache in - cache.admins[server] = (cache.admins[server] ?? [:]).setting(roomToken, Set(admins)) - } - } - - // - Room image (if there is one and it's different from the existing one, or we don't have the existing one) - if let imageId: UInt64 = UInt64(updatedOpenGroup.imageID ?? ""), (updatedModel.groupImage == nil || updatedOpenGroup.imageID != existingOpenGroup?.imageID) { - OpenGroupManager.roomImage(imageId, for: roomToken, on: server, using: dependencies) + /// Start downloading the room image (if we don't have one or it's been updated) + if + let imageId: Int64 = Int64(updatedOpenGroup.imageId ?? ""), + ( + updatedOpenGroup.imageData == nil || + updatedOpenGroup.imageId != openGroup.imageId + ) + { + OpenGroupManager.roomImage(db, fileId: imageId, for: roomToken, on: server, using: dependencies) .done { data in - dependencies.storage.write { transaction in - // Update the thread - let transaction = transaction as! YapDatabaseReadWriteTransaction - let thread = TSGroupThread.getOrCreateThread(with: initialModel, transaction: transaction) - thread.groupModel.groupImage = UIImage(data: data) - thread.save(with: transaction) + dependencies.storage.write { db in + _ = try OpenGroup + .filter(id: threadId) + .updateAll(db, OpenGroup.Columns.imageData.set(to: data)) if waitForImageToComplete { completion?() @@ -370,83 +411,98 @@ public final class OpenGroupManager: NSObject { } internal static func handleMessages( - _ messages: [OpenGroupAPI.Message], + _ db: Database, + messages: [OpenGroupAPI.Message], for roomToken: String, on server: String, isBackgroundPoll: Bool, - using transaction: YapDatabaseReadWriteTransaction, dependencies: OGMDependencies = OGMDependencies() ) { // Sorting the messages by server ID before importing them fixes an issue where messages // that quote older messages can't find those older messages - let openGroupID = "\(server).\(roomToken)" + guard let openGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: OpenGroup.idFor(roomToken: roomToken, server: server)) else { + SNLog("Couldn't handle open group messages.") + return + } + let sortedMessages: [OpenGroupAPI.Message] = messages .sorted { lhs, rhs in lhs.id < rhs.id } let seqNo: Int64? = sortedMessages.map { $0.seqNo }.max() - var messageServerIDsToRemove: [UInt64] = [] + var messageServerIdsToRemove: [UInt64] = [] // Update the 'openGroupSequenceNumber' value (Note: SOGS V4 uses the 'seqNo' instead of the 'serverId') if let seqNo: Int64 = seqNo { - dependencies.storage.setOpenGroupSequenceNumber(for: roomToken, on: server, to: seqNo, using: transaction) + _ = try? OpenGroup + .filter(id: openGroup.id) + .updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: seqNo)) } // Process the messages sortedMessages.forEach { message in - guard let base64EncodedString: String = message.base64EncodedData, let data = Data(base64Encoded: base64EncodedString) else { + guard + let base64EncodedString: String = message.base64EncodedData, + let data = Data(base64Encoded: base64EncodedString) + else { // A message with no data has been deleted so add it to the list to remove - messageServerIDsToRemove.append(UInt64(message.id)) + messageServerIdsToRemove.append(UInt64(message.id)) return } - guard let sender: String = message.sender else { return } // Need a sender in order to process the message - - // Note: The `posted` value is in seconds but all messages in the database use milliseconds for timestamps - let envelope = SNProtoEnvelope.builder(type: .sessionMessage, timestamp: UInt64(floor(message.posted * 1000))) - envelope.setContent(data) - envelope.setSource(sender) do { - let data = try envelope.buildSerializedData() - let (message, proto) = try MessageReceiver.parse(data, openGroupMessageServerID: UInt64(message.id), isRetry: false, using: transaction, dependencies: dependencies) - try MessageReceiver.handle(message, associatedWithProto: proto, openGroupID: openGroupID, isBackgroundPoll: isBackgroundPoll, using: transaction, dependencies: dependencies) + let processedMessage: ProcessedMessage? = try Message.processReceivedOpenGroupMessage( + db, + openGroupId: openGroup.id, + openGroupServerPublicKey: openGroup.publicKey, + message: message, + data: data, + dependencies: dependencies + ) + + if let messageInfo: MessageReceiveJob.Details.MessageInfo = processedMessage?.messageInfo { + try MessageReceiver.handle( + db, + message: messageInfo.message, + associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData), + openGroupId: openGroup.id, + isBackgroundPoll: isBackgroundPoll, + dependencies: dependencies + ) + } } catch { - SNLog("Couldn't receive open group message due to error: \(error).") + 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, + MessageReceiverError.duplicateMessage, + MessageReceiverError.duplicateControlMessage, + MessageReceiverError.selfSend: + break + + default: SNLog("Couldn't receive open group message due to error: \(error).") + } } } // Handle any deletions that are needed - guard !messageServerIDsToRemove.isEmpty else { return } + guard !messageServerIdsToRemove.isEmpty else { return } - dependencies.storage.write { transaction in - guard let transaction: YapDatabaseReadWriteTransaction = transaction as? YapDatabaseReadWriteTransaction else { - return - } - - messageServerIDsToRemove.forEach { openGroupServerMessageId in - guard let messageLookup: OpenGroupServerIdLookup = dependencies.storage.getOpenGroupServerIdLookup(openGroupServerMessageId, in: roomToken, on: server, using: transaction) else { - return - } - guard let tsMessage: TSMessage = TSMessage.fetch(uniqueId: messageLookup.tsMessageId, transaction: transaction) else { - return - } - - tsMessage.remove(with: transaction) - dependencies.storage.removeOpenGroupServerIdLookup(openGroupServerMessageId, in: roomToken, on: server, using: transaction) - } - } + _ = try? Interaction + .filter(messageServerIdsToRemove.contains(Interaction.Columns.openGroupServerMessageId)) + .deleteAll(db) } internal static func handleDirectMessages( - _ messages: [OpenGroupAPI.DirectMessage], + _ db: Database, + messages: [OpenGroupAPI.DirectMessage], fromOutbox: Bool, on server: String, isBackgroundPoll: Bool, - using transaction: YapDatabaseReadWriteTransaction, dependencies: OGMDependencies = OGMDependencies() ) { // Don't need to do anything if we have no messages (it's a valid case) guard !messages.isEmpty else { return } - guard let serverPublicKey: String = dependencies.storage.getOpenGroupPublicKey(for: server) else { + guard let openGroup: OpenGroup = try? OpenGroup.filter(OpenGroup.Columns.server == server).fetchOne(db) else { SNLog("Couldn't receive inbox message.") return } @@ -456,14 +512,18 @@ public final class OpenGroupManager: NSObject { let sortedMessages: [OpenGroupAPI.DirectMessage] = messages .sorted { lhs, rhs in lhs.id < rhs.id } let latestMessageId: Int64 = sortedMessages[sortedMessages.count - 1].id - var mappingCache: [String: BlindedIdMapping] = [:] // Only want this cache to exist for the current loop + var lookupCache: [String: BlindedIdLookup] = [:] // Only want this cache to exist for the current loop // Update the 'latestMessageId' value if fromOutbox { - dependencies.storage.setOpenGroupOutboxLatestMessageId(for: server, to: latestMessageId, using: transaction) + _ = try? OpenGroup + .filter(OpenGroup.Columns.server == server) + .updateAll(db, OpenGroup.Columns.outboxLatestMessageId.set(to: latestMessageId)) } else { - dependencies.storage.setOpenGroupInboxLatestMessageId(for: server, to: latestMessageId, using: transaction) + _ = try? OpenGroup + .filter(OpenGroup.Columns.server == server) + .updateAll(db, OpenGroup.Columns.inboxLatestMessageId.set(to: latestMessageId)) } // Process the messages @@ -473,21 +533,14 @@ public final class OpenGroupManager: NSObject { return } - // Note: The `posted` value is in seconds but all messages in the database use milliseconds for timestamps - let envelope = SNProtoEnvelope.builder(type: .sessionMessage, timestamp: UInt64(floor(message.posted * 1000))) - envelope.setContent(messageData) - envelope.setSource(message.sender) - do { - let data = try envelope.buildSerializedData() - let (receivedMessage, proto) = try MessageReceiver.parse( - data, - openGroupMessageServerID: nil, - openGroupServerPublicKey: serverPublicKey, + let processedMessage: ProcessedMessage? = try Message.processReceivedOpenGroupDirectMessage( + db, + openGroupServerPublicKey: openGroup.publicKey, + message: message, + data: messageData, isOutgoing: fromOutbox, otherBlindedPublicKey: (fromOutbox ? message.recipient : message.sender), - isRetry: false, - using: transaction, dependencies: dependencies ) @@ -496,45 +549,45 @@ public final class OpenGroupManager: NSObject { // during device sync'ing and restoration) if fromOutbox { // Attempt to un-blind the 'message.recipient' - let mapping: BlindedIdMapping - - // 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: BlindedIdMapping = mappingCache[message.recipient] { - mapping = result - } - else if let result: BlindedIdMapping = ContactUtilities.mapping(for: message.recipient, serverPublicKey: serverPublicKey, using: transaction, dependencies: dependencies) { - mapping = result - } - else { - // Cache an "invalid" mapping that has the 'sessionId' set to the recipient so we don't - // re-process this recipient if there is another message from them - mapping = BlindedIdMapping( - blindedId: "", - sessionId: message.recipient, - serverPublicKey: "" + 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) + if let result: BlindedIdLookup = lookupCache[message.recipient] { + return result + } + + return try BlindedIdLookup.fetchOrCreate( + db, + blindedId: message.recipient, + openGroupServer: server.lowercased(), + openGroupPublicKey: openGroup.publicKey ) - } + }() + let syncTarget: String = (lookup.sessionId ?? message.recipient) - switch receivedMessage { - case let receivedMessage as VisibleMessage: receivedMessage.syncTarget = mapping.sessionId - case let receivedMessage as ExpirationTimerUpdate: receivedMessage.syncTarget = mapping.sessionId + switch processedMessage?.messageInfo.variant { + case .visibleMessage: + (processedMessage?.messageInfo.message as? VisibleMessage)?.syncTarget = syncTarget + + case .expirationTimerUpdate: + (processedMessage?.messageInfo.message as? ExpirationTimerUpdate)?.syncTarget = syncTarget + default: break } - mappingCache[message.recipient] = mapping + lookupCache[message.recipient] = lookup } - try MessageReceiver.handle(receivedMessage, associatedWithProto: proto, openGroupID: nil, isBackgroundPoll: isBackgroundPoll, using: transaction, dependencies: dependencies) - - // If this message is from the outbox then we should add the open group details back to the - // thread just in case this is from a restore (otherwise the user won't be able to send a new - // message to the target inbox if they are still blinded) - if fromOutbox, let contactThread: TSContactThread = TSContactThread.fetch(uniqueId: TSContactThread.threadID(fromContactSessionID: message.recipient), transaction: transaction) { - contactThread.originalOpenGroupServer = server - contactThread.originalOpenGroupPublicKey = serverPublicKey - contactThread.save(with: transaction) + if let messageInfo: MessageReceiveJob.Details.MessageInfo = processedMessage?.messageInfo { + try MessageReceiver.handle( + db, + message: messageInfo.message, + associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData), + openGroupId: nil, // Intentionally nil as they are technically not open group messages + isBackgroundPoll: isBackgroundPoll, + dependencies: dependencies + ) } } catch let error { @@ -546,52 +599,87 @@ public final class OpenGroupManager: NSObject { // MARK: - Convenience /// This method specifies if the given publicKey is a moderator or an admin within a specified Open Group - public static func isUserModeratorOrAdmin(_ publicKey: String, for room: String, on server: String, using dependencies: OGMDependencies = OGMDependencies()) -> Bool { - let modAndAdminKeys: Set = (dependencies.cache.moderators[server]?[room] ?? Set()) - .union(dependencies.cache.admins[server]?[room] ?? Set()) + public static func isUserModeratorOrAdmin( + _ publicKey: String, + for roomToken: String?, + on server: String?, + using dependencies: OGMDependencies = OGMDependencies() + ) -> Bool { + guard let roomToken: String = roomToken, let server: String = server else { return false } - // If the publicKey is in the set then return immediately, otherwise only continue if it's the - // current user - guard !modAndAdminKeys.contains(publicKey) else { return true } - guard let sessionId: SessionId = SessionId(from: publicKey) else { return false } + let groupId: String = OpenGroup.idFor(roomToken: roomToken, server: server) + let targetRoles: [GroupMember.Role] = [.moderator, .admin] - // Conveniently the logic for these different cases works in order so we can fallthrough each - // case with only minor efficiency losses - switch sessionId.prefix { - case .standard: - guard publicKey == getUserHexEncodedPublicKey(using: dependencies) else { return false } - fallthrough + return dependencies.storage + .read { db in + let isDirectModOrAdmin: Bool = (try? GroupMember + .filter(GroupMember.Columns.groupId == groupId) + .filter(GroupMember.Columns.profileId == publicKey) + .filter(targetRoles.contains(GroupMember.Columns.role)) + .isNotEmpty(db)) + .defaulting(to: false) - case .unblinded: - guard let userEdKeyPair: Box.KeyPair = dependencies.storage.getUserED25519KeyPair() else { return false } - guard sessionId.prefix != .unblinded || publicKey == SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString else { - return false - } - fallthrough + // If the publicKey provided matches a mod or admin directly then just return immediately + if isDirectModOrAdmin { return true } - case .blinded: - guard let userEdKeyPair: Box.KeyPair = dependencies.storage.getUserED25519KeyPair() else { return false } - guard let serverPublicKey: String = dependencies.storage.getOpenGroupPublicKey(for: server) else { - return false - } - guard let blindedKeyPair: Box.KeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: serverPublicKey, edKeyPair: userEdKeyPair, genericHash: dependencies.genericHash) else { - return false - } - guard sessionId.prefix != .blinded || publicKey == SessionId(.blinded, publicKey: blindedKeyPair.publicKey).hexString else { - return false - } + // 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 = SessionId(from: publicKey) 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([ - getUserHexEncodedPublicKey(using: dependencies), - SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString, - SessionId(.blinded, publicKey: blindedKeyPair.publicKey).hexString - ]) + // Conveniently the logic for these different cases works in order so we can fallthrough each + // case with only minor efficiency losses + let userPublicKey: String = getUserHexEncodedPublicKey(db) - return !modAndAdminKeys.intersection(possibleKeys).isEmpty - } + switch sessionId.prefix { + case .standard: + guard publicKey == userPublicKey else { return false } + fallthrough + + case .unblinded: + guard let userEdKeyPair: Box.KeyPair = Identity.fetchUserEd25519KeyPair(db) else { + return false + } + guard sessionId.prefix != .unblinded || publicKey == SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString else { + return false + } + fallthrough + + case .blinded: + guard + let userEdKeyPair: Box.KeyPair = Identity.fetchUserEd25519KeyPair(db), + let openGroupPublicKey: String = try? OpenGroup + .select(.publicKey) + .filter(id: groupId) + .asRequest(of: String.self) + .fetchOne(db), + let blindedKeyPair: Box.KeyPair = dependencies.sodium.blindedKeyPair( + serverPublicKey: openGroupPublicKey, + edKeyPair: userEdKeyPair, + genericHash: dependencies.genericHash + ) + else { return false } + guard sessionId.prefix != .blinded || publicKey == SessionId(.blinded, publicKey: blindedKeyPair.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([ + userPublicKey, + SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString, + SessionId(.blinded, publicKey: blindedKeyPair.publicKey).hexString + ]) + + return (try? GroupMember + .filter(GroupMember.Columns.groupId == groupId) + .filter(possibleKeys.contains(GroupMember.Columns.profileId)) + .filter(targetRoles.contains(GroupMember.Columns.role)) + .isNotEmpty(db)) + .defaulting(to: false) + } + } + .defaulting(to: false) } @discardableResult public static func getDefaultRoomsIfNeeded(using dependencies: OGMDependencies = OGMDependencies()) -> Promise<[OpenGroupAPI.Room]> { @@ -602,44 +690,65 @@ public final class OpenGroupManager: NSObject { let (promise, seal) = Promise<[OpenGroupAPI.Room]>.pending() - dependencies.storage.write( - with: { transaction in - dependencies.storage.setOpenGroupPublicKey( - for: OpenGroupAPI.defaultServer, - to: OpenGroupAPI.defaultServerPublicKey, - using: transaction - ) - }, - completion: { - let internalPromise: Promise<[OpenGroupAPI.Room]> = attempt(maxRetryCount: 8, recoveringOn: DispatchQueue.main) { - OpenGroupAPI.rooms(for: OpenGroupAPI.defaultServer, using: dependencies) - .map { _, data in data } - } - internalPromise - .done(on: OpenGroupAPI.workQueue) { items in - items - .compactMap { room -> (UInt64, String)? in - guard let imageId: UInt64 = room.imageId else { return nil} - - return (imageId, room.token) - } - .forEach { imageId, roomToken in - roomImage(imageId, for: roomToken, on: OpenGroupAPI.defaultServer, using: dependencies) - .retainUntilComplete() - } - seal.fulfill(items) - } - .retainUntilComplete() - - internalPromise - .catch(on: OpenGroupAPI.workQueue) { error in - dependencies.mutableCache.mutate { cache in - cache.defaultRoomsPromise = nil + // Try to retrieve the default rooms 8 times + attempt(maxRetryCount: 8, recoveringOn: DispatchQueue.main) { + dependencies.storage.read { db in + OpenGroupAPI.rooms(db, for: OpenGroupAPI.defaultServer, using: dependencies) + } + .map { _, data in data } + } + .done(on: OpenGroupAPI.workQueue) { items in + dependencies.storage.writeAsync { db in + items + .compactMap { room -> (Int64, String)? in + // Try to insert an inactive version of the OpenGroup (use 'insert' rather than 'save' + // as we want it to fail if the room already exists) + do { + _ = try OpenGroup( + server: OpenGroupAPI.defaultServer, + roomToken: room.token, + publicKey: OpenGroupAPI.defaultServerPublicKey, + isActive: false, + name: room.name, + roomDescription: room.roomDescription, + imageId: room.imageId.map { "\($0)" }, + imageData: nil, + userCount: room.activeUsers, + infoUpdates: room.infoUpdates, + sequenceNumber: 0, + inboxLatestMessageId: 0, + outboxLatestMessageId: 0 + ) + .inserted(db) } - seal.reject(error) + catch {} + + guard let imageId: Int64 = room.imageId else { return nil } + + return (imageId, room.token) + } + .forEach { imageId, roomToken in + roomImage( + db, + fileId: imageId, + for: roomToken, + on: OpenGroupAPI.defaultServer, + using: dependencies + ) + .retainUntilComplete() } } - ) + + seal.fulfill(items) + } + .catch(on: OpenGroupAPI.workQueue) { error in + dependencies.mutableCache.mutate { cache in + cache.defaultRoomsPromise = nil + } + + seal.reject(error) + } + .retainUntilComplete() dependencies.mutableCache.mutate { cache in cache.defaultRoomsPromise = promise @@ -649,7 +758,8 @@ public final class OpenGroupManager: NSObject { } public static func roomImage( - _ fileId: UInt64, + _ db: Database, + fileId: Int64, for roomToken: String, on server: String, using dependencies: OGMDependencies = OGMDependencies() @@ -663,30 +773,56 @@ public final class OpenGroupManager: NSObject { // we only need to maintain one date in user defaults. On top of all of this we also // don't double up on fetch requests by storing the existing request as a promise if // there is one. + let threadId: String = OpenGroup.idFor(roomToken: roomToken, server: server) let lastOpenGroupImageUpdate: Date? = dependencies.standardUserDefaults[.lastOpenGroupImageUpdate] let now: Date = dependencies.date let timeSinceLastUpdate: TimeInterval = (lastOpenGroupImageUpdate.map { now.timeIntervalSince($0) } ?? .greatestFiniteMagnitude) let updateInterval: TimeInterval = (7 * 24 * 60 * 60) - if let data = dependencies.storage.getOpenGroupImage(for: roomToken, on: server), server == OpenGroupAPI.defaultServer, timeSinceLastUpdate < updateInterval { - return Promise.value(data) - } + if + server == OpenGroupAPI.defaultServer, + timeSinceLastUpdate < updateInterval, + let data = try? OpenGroup + .select(.imageData) + .filter(id: threadId) + .asRequest(of: Data.self) + .fetchOne(db) + { return Promise.value(data) } if let promise = dependencies.cache.groupImagePromises["\(server).\(roomToken)"] { return promise } - let promise: Promise = OpenGroupAPI - .downloadFile(fileId, from: roomToken, on: server, using: dependencies) - .map { _, data in data } - _ = promise.done(on: OpenGroupAPI.workQueue) { imageData in - if server == OpenGroupAPI.defaultServer { - dependencies.storage.write { transaction in - dependencies.storage.setOpenGroupImage(to: imageData, for: roomToken, on: server, using: transaction) + let (promise, seal) = Promise.pending() + + // Trigger the download on a background queue + DispatchQueue.global(qos: .background).async { + dependencies.storage + .write { db in + OpenGroupAPI + .downloadFile( + db, + fileId: fileId, + from: roomToken, + on: server, + using: dependencies + ) } - dependencies.standardUserDefaults[.lastOpenGroupImageUpdate] = now - } + .done(on: OpenGroupAPI.workQueue) { _, imageData in + if server == OpenGroupAPI.defaultServer { + dependencies.storage.write { db in + _ = try OpenGroup + .filter(id: threadId) + .updateAll(db, OpenGroup.Columns.imageData.set(to: imageData)) + } + dependencies.standardUserDefaults[.lastOpenGroupImageUpdate] = now + } + + seal.fulfill(imageData) + } + .catch { seal.reject($0) } } + dependencies.mutableCache.mutate { cache in cache.groupImagePromises["\(server).\(roomToken)"] = promise } @@ -723,30 +859,6 @@ public final class OpenGroupManager: NSObject { } } -// MARK: - Objective C Methods - -extension OpenGroupManager { - @objc(startPolling) - public func objc_startPolling() { - startPolling() - } - - @objc(stopPolling) - public func objc_stopPolling() { - stopPolling() - } - - @objc(getDefaultRoomsIfNeeded) - public static func objc_getDefaultRoomsIfNeeded() { - getDefaultRoomsIfNeeded() - } - - @objc(isUserModeratorOrAdmin:forRoom:onServer:) - public static func isUserModeratorOrAdmin(_ publicKey: String, for room: String, on server: String) -> Bool { - return isUserModeratorOrAdmin(publicKey, for: room, on: server, using: OGMDependencies()) - } -} - // MARK: - OGMDependencies @@ -763,9 +875,8 @@ extension OpenGroupManager { public init( cache: Atomic? = nil, onionApi: OnionRequestAPIType.Type? = nil, - identityManager: IdentityManagerProtocol? = nil, generalCache: Atomic? = nil, - storage: SessionMessagingKitStorageProtocol? = nil, + storage: GRDBStorage? = nil, sodium: SodiumType? = nil, box: BoxType? = nil, genericHash: GenericHashType? = nil, @@ -781,7 +892,6 @@ extension OpenGroupManager { super.init( onionApi: onionApi, - identityManager: identityManager, generalCache: generalCache, storage: storage, sodium: sodium, diff --git a/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift b/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift index 330647db6..1c1ee52dc 100644 --- a/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift +++ b/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift @@ -20,22 +20,22 @@ extension OpenGroupAPI { // Messages case roomMessage(String) - case roomMessageIndividual(String, id: UInt64) + case roomMessageIndividual(String, id: Int64) case roomMessagesRecent(String) - case roomMessagesBefore(String, id: UInt64) + case roomMessagesBefore(String, id: Int64) case roomMessagesSince(String, seqNo: Int64) case roomDeleteMessages(String, sessionId: String) // Pinning - case roomPinMessage(String, id: UInt64) - case roomUnpinMessage(String, id: UInt64) + case roomPinMessage(String, id: Int64) + case roomUnpinMessage(String, id: Int64) case roomUnpinAll(String) // Files case roomFile(String) - case roomFileIndividual(String, UInt64) + case roomFileIndividual(String, Int64) // Inbox/Outbox (Message Requests) diff --git a/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift b/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift index a9f15622a..3e3842f4d 100644 --- a/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift +++ b/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift @@ -29,7 +29,7 @@ public protocol AeadXChaCha20Poly1305IetfType { } public protocol Ed25519Type { - func sign(data: Bytes, keyPair: ECKeyPair) throws -> Bytes? + func sign(data: Bytes, keyPair: Box.KeyPair) throws -> Bytes? func verifySignature(_ signature: Data, publicKey: Data, data: Data) throws -> Bool } @@ -92,8 +92,13 @@ extension Sign: SignType {} extension Aead.XChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType {} struct Ed25519Wrapper: Ed25519Type { - func sign(data: Bytes, keyPair: ECKeyPair) throws -> Bytes? { - return try Ed25519.sign(Data(data), with: keyPair).bytes + func sign(data: Bytes, keyPair: Box.KeyPair) throws -> Bytes? { + let ecKeyPair: ECKeyPair = try ECKeyPair( + publicKeyData: Data(keyPair.publicKey), + privateKeyData: Data(keyPair.secretKey) + ) + + return try Ed25519.sign(Data(data), with: ecKeyPair).bytes } func verifySignature(_ signature: Data, publicKey: Data, data: Data) throws -> Bool { diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift index 0a8ab0dac..fde313728 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift @@ -1,3 +1,252 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB +import WebRTC +import SessionUtilitiesKit + +extension MessageReceiver { + public static func handleCallMessage(_ db: Database, message: CallMessage) throws { + switch message.kind { + case .preOffer: try MessageReceiver.handleNewCallMessage(db, message: message) + case .offer: MessageReceiver.handleOfferCallMessage(db, message: message) + case .answer: MessageReceiver.handleAnswerCallMessage(db, message: message) + case .provisionalAnswer: break // TODO: Implement + + case let .iceCandidates(sdpMLineIndexes, sdpMids): + guard let currentWebRTCSession = WebRTCSession.current, currentWebRTCSession.uuid == message.uuid else { + return + } + var candidates: [RTCIceCandidate] = [] + let sdps = message.sdps + for i in 0.. Interaction? { + guard + (try? Interaction + .filter(Interaction.Columns.variant == Interaction.Variant.infoCall) + .filter(Interaction.Columns.messageUuid == message.uuid) + .isEmpty(db)) + .defaulting(to: false), + let sender: String = message.sender, + let thread: SessionThread = try SessionThread.fetchOne(db, id: sender), + !thread.isMessageRequest(db) + else { return nil } + + let messageInfo: CallMessage.MessageInfo = CallMessage.MessageInfo( + state: state.defaulting( + to: (sender == getUserHexEncodedPublicKey(db) ? + .outgoing : + .incoming + ) + ) + ) + let timestampMs: Int64 = ( + message.sentTimestamp.map { Int64($0) } ?? + Int64(floor(Date().timeIntervalSince1970 * 1000)) + ) + + guard let messageInfoData: Data = try? JSONEncoder().encode(messageInfo) else { return nil } + + return try Interaction( + serverHash: message.serverHash, + messageUuid: message.uuid, + threadId: thread.id, + authorId: sender, + variant: .infoCall, + body: String(data: messageInfoData, encoding: .utf8), + timestampMs: timestampMs + ).inserted(db) + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift index 619dfd6a4..fb17368fd 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift @@ -2,16 +2,12 @@ import Foundation import GRDB -import WebRTC import Sodium -import Curve25519Kit -import SignalCoreKit -import SessionSnodeKit import SessionUtilitiesKit extension MessageReceiver { public static func handleClosedGroupControlMessage(_ db: Database, _ message: ClosedGroupControlMessage) throws { - switch message.kind! { + switch message.kind { case .new: try handleNewClosedGroup(db, message: message) case .encryptionKeyPair: try handleClosedGroupEncryptionKeyPair(db, message: message) case .nameChange: try handleClosedGroupNameChanged(db, message: message) @@ -20,9 +16,13 @@ extension MessageReceiver { case .memberLeft: try handleClosedGroupMemberLeft(db, message: message) case .encryptionKeyPairRequest: handleClosedGroupEncryptionKeyPairRequest(db, message: message) // Currently not used + + default: throw MessageReceiverError.invalidMessage } } + // MARK: - Specific Handling + private static func handleNewClosedGroup(_ db: Database, message: ClosedGroupControlMessage) throws { guard case let .new(publicKeyAsData, name, encryptionKeyPair, membersAsData, adminsAsData, expirationTimer) = message.kind else { return @@ -41,7 +41,7 @@ extension MessageReceiver { ) } - private static func handleNewClosedGroup( + internal static func handleNewClosedGroup( _ db: Database, groupPublicKey: String, name: String, @@ -162,7 +162,7 @@ extension MessageReceiver { return SNLog("Ignoring closed group encryption key pair from non-admin.") } // Find our wrapper and decrypt it if possible - let userPublicKey: String = userKeyPair.publicKey.toHexString() + let userPublicKey: String = SessionId(.standard, publicKey: userKeyPair.publicKey).hexString guard let wrapper = wrappers.first(where: { $0.publicKey == userPublicKey }), @@ -486,6 +486,8 @@ extension MessageReceiver { */ } + // MARK: - Convenience + private static func performIfValid( _ db: Database, message: ClosedGroupControlMessage, diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift index 0a8ab0dac..4f1419775 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift @@ -1,3 +1,142 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB +import Sodium +import SignalCoreKit +import SessionUtilitiesKit + +extension MessageReceiver { + internal static func handleConfigurationMessage(_ db: Database, message: ConfigurationMessage) throws { + let userPublicKey = getUserHexEncodedPublicKey(db) + + guard message.sender == userPublicKey else { return } + + SNLog("Configuration message received.") + + // Note: `message.sentTimestamp` is in ms (convert to TimeInterval before converting to + // seconds to maintain the accuracy) + let isInitialSync: Bool = (!UserDefaults.standard[.hasSyncedInitialConfiguration]) + let messageSentTimestamp: TimeInterval = TimeInterval((message.sentTimestamp ?? 0) / 1000) + let lastConfigTimestamp: TimeInterval = UserDefaults.standard[.lastConfigurationSync] + .defaulting(to: Date(timeIntervalSince1970: 0)) + .timeIntervalSince1970 + + // Profile + try MessageReceiver.updateProfileIfNeeded( + db, + publicKey: userPublicKey, + name: message.displayName, + profilePictureUrl: message.profilePictureUrl, + profileKey: OWSAES256Key(data: message.profileKey), + sentTimestamp: messageSentTimestamp + ) + + if isInitialSync || messageSentTimestamp > lastConfigTimestamp { + if isInitialSync { + UserDefaults.standard[.hasSyncedInitialConfiguration] = true + NotificationCenter.default.post(name: .initialConfigurationMessageReceived, object: nil) + } + + UserDefaults.standard[.lastConfigurationSync] = Date(timeIntervalSince1970: messageSentTimestamp) + + // Contacts + try message.contacts.forEach { contactInfo in + guard let sessionId: String = contactInfo.publicKey else { return } + + let contact: Contact = Contact.fetchOrCreate(db, id: sessionId) + let profile: Profile = Profile.fetchOrCreate(db, id: sessionId) + + try profile + .with( + name: contactInfo.displayName, + profilePictureUrl: .updateIf(contactInfo.profilePictureUrl), + profileEncryptionKey: .updateIf( + contactInfo.profileKey.map { OWSAES256Key(data: $0) } + ) + ) + .save(db) + + /// We only update these values if the proto actually has values for them (this is to prevent an + /// edge case where an old client could override the values with default values since they aren't included) + /// + /// **Note:** Since message requests have no reverse, we should only handle setting `isApproved` + /// and `didApproveMe` to `true`. This may prevent some weird edge cases where a config message + /// swapping `isApproved` and `didApproveMe` to `false` + try contact + .with( + isApproved: (contactInfo.hasIsApproved && contactInfo.isApproved ? + true : + .existing + ), + isBlocked: (contactInfo.hasIsBlocked ? + .update(contactInfo.isBlocked) : + .existing + ), + didApproveMe: (contactInfo.hasDidApproveMe && contactInfo.didApproveMe ? + true : + .existing + ) + ) + .save(db) + + // If the contact is blocked + if contactInfo.hasIsBlocked && contactInfo.isBlocked { + // If this message changed them to the blocked state and there is an existing thread + // associated with them that is a message request thread then delete it (assume + // that the current user had deleted that message request) + if + contactInfo.isBlocked != contact.isBlocked, // 'contact.isBlocked' will be the old value + let thread: SessionThread = try? SessionThread.fetchOne(db, id: sessionId), + thread.isMessageRequest(db) + { + _ = try thread.delete(db) + } + } + } + + // Closed groups + // + // Note: Only want to add these for initial sync to avoid re-adding closed groups the user + // intentionally left (any closed groups joined since the first processed sync message should + // get added via the 'handleNewClosedGroup' method anyway as they will have come through in the + // past two weeks) + if isInitialSync { + let existingClosedGroupsIds: [String] = (try? SessionThread + .filter(SessionThread.Columns.variant == SessionThread.Variant.closedGroup) + .fetchAll(db)) + .defaulting(to: []) + .map { $0.id } + + try message.closedGroups.forEach { closedGroup in + guard !existingClosedGroupsIds.contains(closedGroup.publicKey) else { return } + + let keyPair: Box.KeyPair = Box.KeyPair( + publicKey: closedGroup.encryptionKeyPublicKey.bytes, + secretKey: closedGroup.encryptionKeySecretKey.bytes + ) + + try MessageReceiver.handleNewClosedGroup( + db, + groupPublicKey: closedGroup.publicKey, + name: closedGroup.name, + encryptionKeyPair: keyPair, + members: [String](closedGroup.members), + admins: [String](closedGroup.admins), + expirationTimer: closedGroup.expirationTimer, + messageSentTimestamp: message.sentTimestamp! + ) + } + } + + // Open groups + for openGroupURL in message.openGroups { + if let (room, server, publicKey) = OpenGroupManager.parseOpenGroup(from: openGroupURL) { + OpenGroupManager.shared + .add(db, roomToken: room, server: server, publicKey: publicKey, isConfigMessage: true) + .retainUntilComplete() + } + } + } + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift index 0a8ab0dac..4b1766a0a 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift @@ -1,3 +1,27 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB + +extension MessageReceiver { + internal static func handleDataExtractionNotification(_ db: Database, message: DataExtractionNotification) throws { + guard + let sender: String = message.sender, + let messageKind: DataExtractionNotification.Kind = message.kind, + let thread: SessionThread = try? SessionThread.fetchOne(db, id: sender), + thread.variant == .contact + else { return } + + _ = try Interaction( + serverHash: message.serverHash, + threadId: thread.id, + authorId: sender, + variant: { + switch messageKind { + case .screenshot: return .infoScreenshotNotification + case .mediaSaved: return .infoMediaSavedNotification + } + }() + ).inserted(db) + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ExpirationTimers.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ExpirationTimers.swift index 0a8ab0dac..bb5f3bfaa 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ExpirationTimers.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ExpirationTimers.swift @@ -1,3 +1,52 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB +import SessionUtilitiesKit + +extension MessageReceiver { + internal static func handleExpirationTimerUpdate(_ db: Database, message: ExpirationTimerUpdate) throws { + // Get the target thread + guard + let targetId: String = MessageReceiver.threadInfo(db, message: message, openGroupId: nil)?.id, + let sender: String = message.sender, + let thread: SessionThread = try? SessionThread.fetchOne(db, id: targetId) + else { return } + + // Update the configuration + // + // Note: Messages which had been sent during the previous configuration will still + // use it's settings (so if you enable, send a message and then disable disappearing + // message then the message you had sent will still disappear) + let config: DisappearingMessagesConfiguration = try thread.disappearingMessagesConfiguration + .fetchOne(db) + .defaulting(to: DisappearingMessagesConfiguration.defaultWith(thread.id)) + .with( + // If there is no duration then we should disable the expiration timer + isEnabled: ((message.duration ?? 0) > 0), + durationSeconds: ( + message.duration.map { TimeInterval($0) } ?? + DisappearingMessagesConfiguration.defaultDuration + ) + ) + + // Add an info message for the user + _ = try Interaction( + serverHash: nil, // Intentionally null so sync messages are seen as duplicates + threadId: thread.id, + authorId: sender, + variant: .infoDisappearingMessagesUpdate, + body: config.messageInfoString( + with: (sender != getUserHexEncodedPublicKey(db) ? + Profile.displayName(db, id: sender) : + nil + ) + ), + timestampMs: Int64(message.sentTimestamp ?? 0) // Default to `0` if not set + ).inserted(db) + + // Finally save the changes to the DisappearingMessagesConfiguration (If it's a duplicate + // then the interaction unique constraint will prevent the code from getting here) + try config.save(db) + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift index 0a8ab0dac..a90aa1db4 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift @@ -1,3 +1,150 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB +import SessionUtilitiesKit + +extension MessageReceiver { + internal static func handleMessageRequestResponse( + _ db: Database, + message: MessageRequestResponse, + dependencies: Dependencies + ) throws { + let userPublicKey = getUserHexEncodedPublicKey(db) + var hadBlindedContact: Bool = false + + // Ignore messages which were sent from the current user + guard message.sender != userPublicKey else { return } + guard let senderId: String = message.sender else { return } + + // Prep the unblinded thread + let unblindedThread: SessionThread = try SessionThread.fetchOrCreate(db, id: senderId, variant: .contact) + + // Need to handle a `MessageRequestResponse` sent to a blinded thread (ie. check if the sender matches + // the blinded ids of any threads) + let blindedThreadIds: Set = (try? SessionThread + .select(.id) + .filter(SessionThread.Columns.variant == SessionThread.Variant.contact) + .filter(SessionThread.Columns.id.like("\(SessionId.Prefix.blinded.rawValue)%")) + .asRequest(of: String.self) + .fetchSet(db)) + .defaulting(to: []) + let pendingBlindedIdLookups: [BlindedIdLookup] = (try? BlindedIdLookup + .filter(blindedThreadIds.contains(BlindedIdLookup.Columns.blindedId)) + .fetchAll(db)) + .defaulting(to: []) + + // Loop through all blinded threads and extract any interactions relating to the user accepting + // the message request + try pendingBlindedIdLookups.forEach { blindedIdLookup in + // If the sessionId matches the blindedId then this thread needs to be converted to an un-blinded thread + guard + dependencies.sodium.sessionId( + senderId, + matchesBlindedId: blindedIdLookup.blindedId, + serverPublicKey: blindedIdLookup.openGroupPublicKey, + genericHash: dependencies.genericHash + ) + else { return } + + // Update the lookup + _ = try blindedIdLookup + .with(sessionId: senderId) + .saved(db) + + // Flag that we had a blinded contact and add the `blindedThreadId` to an array so we can remove + // them at the end of processing + hadBlindedContact = true + + // Update all interactions to be on the new thread + // Note: Pending `MessageSendJobs` _shouldn't_ be an issue as even if they are sent after the + // un-blinding of a thread, the logic when handling the sent messages should automatically + // assign them to the correct thread + try Interaction + .filter(Interaction.Columns.threadId == blindedIdLookup.blindedId) + .updateAll(db, Interaction.Columns.threadId.set(to: unblindedThread.id)) + + _ = try SessionThread + .filter(id: blindedIdLookup.blindedId) + .deleteAll(db) + } + + // Update the `didApproveMe` state of the sender + try updateContactApprovalStatusIfNeeded( + db, + senderSessionId: senderId, + threadId: nil, + forceConfigSync: !hadBlindedContact // Sync here if there were no blinded contacts + ) + + // If there were blinded contacts then we need to assume that the 'sender' is a newly create contact and hence + // need to update it's `isApproved` state + if hadBlindedContact { + try updateContactApprovalStatusIfNeeded( + db, + senderSessionId: userPublicKey, + threadId: unblindedThread.id, + forceConfigSync: true + ) + } + + // Notify the user of their approval (Note: This will always appear in the un-blinded thread) + // + // Note: We want to do this last as it'll mean the un-blinded thread gets updated and the + // contact approval status will have been updated at this point (which will mean the + // `isMessageRequest` will return correctly after this is saved) + _ = try Interaction( + serverHash: message.serverHash, + threadId: unblindedThread.id, + authorId: senderId, + variant: .infoMessageRequestAccepted, + timestampMs: ( + message.sentTimestamp.map { Int64($0) } ?? + Int64(floor(Date().timeIntervalSince1970 * 1000)) + ) + ).inserted(db) + } + + internal static func updateContactApprovalStatusIfNeeded( + _ db: Database, + senderSessionId: String, + threadId: String?, + forceConfigSync: Bool + ) throws { + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + // If the sender of the message was the current user + if senderSessionId == userPublicKey { + // Retrieve the contact for the thread the message was sent to (excluding 'NoteToSelf' + // threads) and if the contact isn't flagged as approved then do so + guard + let threadId: String = threadId, + let thread: SessionThread = try? SessionThread.fetchOne(db, id: threadId), + !thread.isNoteToSelf(db), + let contact: Contact = try? thread.contact.fetchOne(db), + !contact.isApproved + else { return } + + try? contact + .with(isApproved: true) + .update(db) + } + else { + // The message was sent to the current user so flag their 'didApproveMe' as true (can't send a message to + // someone without approving them) + guard + let contact: Contact = try? Contact.fetchOne(db, id: senderSessionId), + !contact.didApproveMe + else { return } + + try? contact + .with(didApproveMe: true) + .update(db) + } + + // Force a config sync to ensure all devices know the contact approval state if desired + guard forceConfigSync else { return } + + try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ReadReceipts.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ReadReceipts.swift index 0a8ab0dac..913610e83 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ReadReceipts.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ReadReceipts.swift @@ -1,3 +1,19 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB + +extension MessageReceiver { + internal static func handleReadReceipt(_ db: Database, message: ReadReceipt) throws { + guard let sender: String = message.sender else { return } + guard let timestampMsValues: [Double] = message.timestamps?.map({ Double($0) }) else { return } + guard let readTimestampMs: Double = message.receivedTimestamp.map({ Double($0) }) else { return } + + try Interaction.markAsRead( + db, + recipientId: sender, + timestampMsValues: timestampMsValues, + readTimestampMs: readTimestampMs + ) + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+TypingIndicators.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+TypingIndicators.swift index 0a8ab0dac..d875c0f0e 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+TypingIndicators.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+TypingIndicators.swift @@ -1,3 +1,33 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB +import SessionUtilitiesKit + +extension MessageReceiver { + internal static func handleTypingIndicator(_ db: Database, message: TypingIndicator) throws { + guard + let senderPublicKey: String = message.sender, + let thread: SessionThread = try SessionThread.fetchOne(db, id: senderPublicKey) + else { return } + + switch message.kind { + case .started: + TypingIndicators.didStartTyping( + db, + threadId: thread.id, + threadVariant: thread.variant, + threadIsMessageRequest: thread.isMessageRequest(db), + direction: .incoming, + timestampMs: message.sentTimestamp.map { Int64($0) } + ) + + case .stopped: + TypingIndicators.didStopTyping(db, threadId: thread.id, direction: .incoming) + + default: + SNLog("Unknown TypingIndicator Kind ignored") + return + } + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift index 0a8ab0dac..03a9e1bf3 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift @@ -1,3 +1,56 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB +import SessionSnodeKit +import SessionUtilitiesKit + +extension MessageReceiver { + public static func handleUnsendRequest(_ db: Database, message: UnsendRequest) throws { + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + guard message.sender == message.author || userPublicKey == message.sender else { return } + guard let author: String = message.author, let timestampMs: UInt64 = message.timestamp else { return } + + let maybeInteraction: Interaction? = try Interaction + .filter(Interaction.Columns.timestampMs == Int64(timestampMs)) + .filter(Interaction.Columns.authorId == author) + .fetchOne(db) + + guard + let interactionId: Int64 = maybeInteraction?.id, + let interaction: Interaction = maybeInteraction + else { return } + + // Mark incoming messages as read and remove any of their notifications + if interaction.variant == .standardIncoming { + try Interaction.markAsRead( + db, + interactionId: interactionId, + threadId: interaction.threadId, + includingOlder: false, + trySendReadReceipt: false + ) + + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: interaction.notificationIdentifiers) + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: interaction.notificationIdentifiers) + } + + if author == message.sender, let serverHash: String = interaction.serverHash { + SnodeAPI.deleteMessage(publicKey: author, serverHashes: [serverHash]).retainUntilComplete() + } + + switch (interaction.variant, (author == message.sender)) { + case (.standardOutgoing, _), (_, false): + _ = try interaction.delete(db) + + case (_, true): + _ = try interaction + .markingAsDeleted() + .saved(db) + + _ = try interaction.attachments + .deleteAll(db) + } + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index 0a8ab0dac..f42f9878d 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -1,3 +1,290 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB +import SignalCoreKit +import SessionUtilitiesKit + +extension MessageReceiver { + @discardableResult public static func handleVisibleMessage( + _ db: Database, + message: VisibleMessage, + associatedWithProto proto: SNProtoContent, + openGroupId: String?, + isBackgroundPoll: Bool, + dependencies: Dependencies = Dependencies() + ) throws -> Int64 { + guard let sender: String = message.sender, let dataMessage = proto.dataMessage else { + throw MessageReceiverError.invalidMessage + } + + // Note: `message.sentTimestamp` is in ms (convert to TimeInterval before converting to + // seconds to maintain the accuracy) + let messageSentTimestamp: TimeInterval = (TimeInterval(message.sentTimestamp ?? 0) / 1000) + let isMainAppActive: Bool = (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) + + // Update profile if needed (want to do this regardless of whether the message exists or + // not to ensure the profile info gets sync between a users devices at every chance) + if let profile = message.profile { + var contactProfileKey: OWSAES256Key? = nil + if let profileKey = profile.profileKey { contactProfileKey = OWSAES256Key(data: profileKey) } + + try MessageReceiver.updateProfileIfNeeded( + db, + publicKey: sender, + name: profile.displayName, + profilePictureUrl: profile.profilePictureUrl, + profileKey: contactProfileKey, + sentTimestamp: messageSentTimestamp + ) + } + + // Get or create thread + guard let threadInfo: (id: String, variant: SessionThread.Variant) = MessageReceiver.threadInfo(db, message: message, openGroupId: openGroupId) else { + throw MessageReceiverError.noThread + } + + // Store the message variant so we can run variant-specific behaviours + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) + let thread: SessionThread = try SessionThread + .fetchOrCreate(db, id: threadInfo.id, variant: threadInfo.variant) + let variant: Interaction.Variant = (sender == currentUserPublicKey ? + .standardOutgoing : + .standardIncoming + ) + + // Retrieve the disappearing messages config to set the 'expiresInSeconds' value + // accoring to the config + let disappearingMessagesConfiguration: DisappearingMessagesConfiguration = (try? thread.disappearingMessagesConfiguration.fetchOne(db)) + .defaulting(to: DisappearingMessagesConfiguration.defaultWith(thread.id)) + + // Try to insert the interaction + // + // Note: There are now a number of unique constraints on the database which + // prevent the ability to insert duplicate interactions at a database level + // so we don't need to check for the existance of a message beforehand anymore + let interaction: Interaction + + do { + interaction = try Interaction( + serverHash: message.serverHash, // Keep track of server hash + threadId: thread.id, + authorId: sender, + variant: variant, + body: message.text, + timestampMs: Int64(messageSentTimestamp * 1000), + wasRead: (variant == .standardOutgoing), // Auto-mark sent messages as read + hasMention: ( + message.text?.contains("@\(currentUserPublicKey)") == true || + dataMessage.quote?.author == currentUserPublicKey + ), + // Note: Ensure we don't ever expire open group messages + expiresInSeconds: (disappearingMessagesConfiguration.isEnabled && message.openGroupServerMessageId == nil ? + disappearingMessagesConfiguration.durationSeconds : + nil + ), + expiresStartedAtMs: nil, + // OpenGroupInvitations are stored as LinkPreview's in the database + linkPreviewUrl: (message.linkPreview?.url ?? message.openGroupInvitation?.url), + // Keep track of the open group server message ID ↔ message ID relationship + openGroupServerMessageId: message.openGroupServerMessageId.map { Int64($0) }, + openGroupWhisperMods: false, + openGroupWhisperTo: nil + ).inserted(db) + } + catch { + switch error { + case DatabaseError.SQLITE_CONSTRAINT_UNIQUE: + guard + variant == .standardOutgoing, + let existingInteractionId: Int64 = try? thread.interactions + .select(.id) + .filter(Interaction.Columns.timestampMs == (messageSentTimestamp * 1000)) + .filter(Interaction.Columns.variant == variant) + .filter(Interaction.Columns.authorId == sender) + .asRequest(of: Int64.self) + .fetchOne(db) + else { break } + + // If we receive an outgoing message that already exists in the database + // then we still need up update the recipient and read states for the + // message (even if we don't need to do anything else) + try updateRecipientAndReadStates( + db, + thread: thread, + interactionId: existingInteractionId, + variant: variant, + syncTarget: message.syncTarget + ) + + default: break + } + + throw error + } + + guard let interactionId: Int64 = interaction.id else { throw StorageError.failedToSave } + + // Update and recipient and read states as needed + try updateRecipientAndReadStates( + db, + thread: thread, + interactionId: interactionId, + variant: variant, + syncTarget: message.syncTarget + ) + + // Parse & persist attachments + let attachments: [Attachment] = try dataMessage.attachments + .compactMap { proto -> Attachment? in + let attachment: Attachment = Attachment(proto: proto) + + // Attachments on received messages must have a 'downloadUrl' otherwise + // they are invalid and we can ignore them + return (attachment.downloadUrl != nil ? attachment : nil) + } + .enumerated() + .map { index, attachment in + let savedAttachment: Attachment = try attachment.saved(db) + + // Link the attachment to the interaction and add to the id lookup + try InteractionAttachment( + albumIndex: index, + interactionId: interactionId, + attachmentId: savedAttachment.id + ).insert(db) + + return savedAttachment + } + + message.attachmentIds = attachments.map { $0.id } + + // Persist quote if needed + let quote: Quote? = try? Quote( + db, + proto: dataMessage, + interactionId: interactionId, + thread: thread + )?.inserted(db) + + // Parse link preview if needed + let linkPreview: LinkPreview? = try? LinkPreview( + db, + proto: dataMessage, + body: message.text, + sentTimestampMs: (messageSentTimestamp * 1000) + )?.saved(db) + + // Open group invitations are stored as LinkPreview values so create one if needed + if + let openGroupInvitationUrl: String = message.openGroupInvitation?.url, + let openGroupInvitationName: String = message.openGroupInvitation?.name + { + try LinkPreview( + url: openGroupInvitationUrl, + timestamp: LinkPreview.timestampFor(sentTimestampMs: (messageSentTimestamp * 1000)), + variant: .openGroupInvitation, + title: openGroupInvitationName + ).save(db) + } + + // Start attachment downloads if needed (ie. trusted contact or group thread) + let isContactTrusted: Bool = ((try? Contact.fetchOne(db, id: sender))?.isTrusted ?? false) + + if isContactTrusted || thread.variant != .contact { + attachments + .map { $0.id } + .appending(quote?.attachmentId) + .appending(linkPreview?.attachmentId) + .forEach { attachmentId in + JobRunner.add( + db, + job: Job( + variant: .attachmentDownload, + threadId: thread.id, + interactionId: interactionId, + details: AttachmentDownloadJob.Details( + attachmentId: attachmentId + ) + ), + canStartJob: isMainAppActive + ) + } + } + + // Cancel any typing indicators if needed + if isMainAppActive { + TypingIndicators.didStopTyping(db, threadId: thread.id, direction: .incoming) + } + + // Update the contact's approval status of the current user if needed (if we are getting messages from + // them outside of a group then we can assume they have approved the current user) + // + // Note: This is to resolve a rare edge-case where a conversation was started with a user on an old + // version of the app and their message request approval state was set via a migration rather than + // by using the approval process + if thread.variant == .contact { + try MessageReceiver.updateContactApprovalStatusIfNeeded( + db, + senderSessionId: sender, + threadId: thread.id, + forceConfigSync: false + ) + } + + // Notify the user if needed + guard variant == .standardIncoming else { return interactionId } + + // Use the same identifier for notifications when in backgroud polling to prevent spam + Environment.shared.notificationsManager.wrappedValue? + .notifyUser( + db, + for: interaction, + in: thread, + isBackgroundPoll: isBackgroundPoll + ) + + return interactionId + } + + private static func updateRecipientAndReadStates( + _ db: Database, + thread: SessionThread, + interactionId: Int64, + variant: Interaction.Variant, + syncTarget: String? + ) throws { + guard variant == .standardOutgoing else { return } + + if let syncTarget: String = syncTarget { + try RecipientState( + interactionId: interactionId, + recipientId: syncTarget, + state: .sent + ).save(db) + } + else if thread.variant == .closedGroup { + try GroupMember + .filter(GroupMember.Columns.groupId == thread.id) + .fetchAll(db) + .forEach { member in + try RecipientState( + interactionId: interactionId, + recipientId: member.profileId, + state: .sent + ).save(db) + } + } + + // For outgoing messages mark all older interactions as read (the user should have seen + // them if they send a message - also avoids a situation where the user has "phantom" + // unread messages that they need to scroll back to before they become marked as read) + try Interaction.markAsRead( + db, + interactionId: interactionId, + threadId: thread.id, + includingOlder: true, + trySendReadReceipt: true + ) + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift index 947593741..f02c32532 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift @@ -410,14 +410,19 @@ extension MessageSender { let members: Set = Set(groupMemberIds).subtracting(removedMembers) // Update zombie * member list - try allGroupMembers + let profileIdsToRemove: Set = allGroupMembers .filter { member in removedMembers.contains(member.profileId) && ( member.role == .standard || member.role == .zombie ) } - .forEach { try $0.delete(db) } + .map { $0.profileId } + .asSet() + try GroupMember + .filter(GroupMember.Columns.groupId == thread.id) + .filter(profileIdsToRemove.contains(GroupMember.Columns.profileId)) + .deleteAll(db) let interactionId: Int64? diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift index 8952bb9d2..0a79510ef 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift @@ -23,7 +23,7 @@ extension MessageReceiver { ), plaintextWithMetadata.count > (signatureSize + ed25519PublicKeySize) else { - throw Error.decryptionFailed + throw MessageReceiverError.decryptionFailed } // 2. ) Get the message parts @@ -35,12 +35,12 @@ extension MessageReceiver { let verificationData = plaintext + senderED25519PublicKey + recipientX25519PublicKey guard dependencies.sign.verify(message: verificationData, publicKey: senderED25519PublicKey, signature: signature) else { - throw Error.invalidSignature + throw MessageReceiverError.invalidSignature } // 4. ) Get the sender's X25519 public key guard let senderX25519PublicKey = dependencies.sign.toX25519(ed25519PublicKey: senderED25519PublicKey) else { - throw Error.decryptionFailed + throw MessageReceiverError.decryptionFailed } return (Data(plaintext), SessionId(.standard, publicKey: senderX25519PublicKey).hexString) @@ -48,10 +48,14 @@ extension MessageReceiver { internal static func decryptWithSessionBlindingProtocol(data: Data, isOutgoing: Bool, otherBlindedPublicKey: String, with openGroupPublicKey: String, userEd25519KeyPair: Box.KeyPair, using dependencies: Dependencies = Dependencies()) throws -> (plaintext: Data, senderX25519PublicKey: String) { /// Ensure the data is at least long enough to have the required components - guard data.count > dependencies.nonceGenerator24.NonceBytes + 2 else { throw Error.decryptionFailed } - guard let blindedKeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: openGroupPublicKey, edKeyPair: userEd25519KeyPair, genericHash: dependencies.genericHash) else { - throw Error.decryptionFailed - } + guard + data.count > (dependencies.nonceGenerator24.NonceBytes + 2), + let blindedKeyPair = dependencies.sodium.blindedKeyPair( + serverPublicKey: openGroupPublicKey, + edKeyPair: userEd25519KeyPair, + genericHash: dependencies.genericHash + ) + else { throw MessageReceiverError.decryptionFailed } /// Step one: calculate the shared encryption key, receiving from A to B let otherKeyBytes: Bytes = Data(hex: otherBlindedPublicKey.removingIdPrefixIfNeeded()).bytes @@ -63,7 +67,7 @@ extension MessageReceiver { toBlindedPublicKey: (isOutgoing ? otherKeyBytes : blindedKeyPair.publicKey), genericHash: dependencies.genericHash ) else { - throw Error.decryptionFailed + throw MessageReceiverError.decryptionFailed } /// v, ct, nc = data[0], data[1:-24], data[-24:] @@ -72,15 +76,15 @@ extension MessageReceiver { let nonce: Bytes = Bytes(data.bytes[(data.count - dependencies.nonceGenerator24.NonceBytes).. dependencies.sign.PublicKeyBytes else { throw Error.decryptionFailed } + guard innerBytes.count > dependencies.sign.PublicKeyBytes else { throw MessageReceiverError.decryptionFailed } /// Split up: the last 32 bytes are the sender's *unblinded* ed25519 key let plaintext: Bytes = Bytes(innerBytes[ @@ -92,16 +96,16 @@ extension MessageReceiver { /// Verify that the inner sender_edpk (A) yields the same outer kA we got with the message guard let blindingFactor: Bytes = dependencies.sodium.generateBlindingFactor(serverPublicKey: openGroupPublicKey, genericHash: dependencies.genericHash) else { - throw Error.invalidSignature + throw MessageReceiverError.invalidSignature } guard let sharedSecret: Bytes = dependencies.sodium.combineKeys(lhsKeyBytes: blindingFactor, rhsKeyBytes: sender_edpk) else { - throw Error.invalidSignature + throw MessageReceiverError.invalidSignature } - guard kA == sharedSecret else { throw Error.invalidSignature } + guard kA == sharedSecret else { throw MessageReceiverError.invalidSignature } /// Get the sender's X25519 public key guard let senderSessionIdBytes: Bytes = dependencies.sign.toX25519(ed25519PublicKey: sender_edpk) else { - throw Error.decryptionFailed + throw MessageReceiverError.decryptionFailed } return (Data(plaintext), SessionId(.standard, publicKey: senderSessionIdBytes).hexString) diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index fd1b123fb..078740d19 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -3,28 +3,25 @@ import Foundation import GRDB import Sodium +import SignalCoreKit import SessionUtilitiesKit public enum MessageReceiver { private static var lastEncryptionKeyPairRequest: [String: Date] = [:] - // TODO: Remove these (bad convention) - public static var handleNewCallOfferMessageIfNeeded: ((CallMessage, YapDatabaseReadWriteTransaction) -> Void)? - public static var handleOfferCallMessage: ((CallMessage) -> Void)? - public static var handleAnswerCallMessage: ((CallMessage) -> Void)? - public static var handleEndCallMessage: ((CallMessage) -> Void)? - + public static func parse( _ db: Database, envelope: SNProtoEnvelope, serverExpirationTimestamp: TimeInterval?, openGroupId: String?, - openGroupMessageServerId: UInt64?, + openGroupMessageServerId: Int64?, + openGroupServerPublicKey: String?, isOutgoing: Bool? = nil, otherBlindedPublicKey: String? = nil, dependencies: Dependencies = Dependencies() ) throws -> (Message, SNProtoContent, String) { let userPublicKey: String = getUserHexEncodedPublicKey(db) - let isOpenGroupMessage: Bool = (openGroupMessageServerId != nil) + let isOpenGroupMessage: Bool = (openGroupId != nil) // Decrypt the contents guard let ciphertext = envelope.content else { throw MessageReceiverError.noData } @@ -42,19 +39,21 @@ public enum MessageReceiver { // Default to 'standard' as the old code didn't seem to require an `envelope.source` switch (SessionId.Prefix(from: envelope.source) ?? .standard) { case .standard, .unblinded: - guard let userX25519KeyPair = dependencies.storage.getUserKeyPair() else { - throw Error.noUserX25519KeyPair + guard let userX25519KeyPair: Box.KeyPair = Identity.fetchUserKeyPair(db) else { + throw MessageReceiverError.noUserX25519KeyPair } (plaintext, sender) = try decryptWithSessionProtocol(ciphertext: ciphertext, using: userX25519KeyPair) case .blinded: - guard let otherBlindedPublicKey: String = otherBlindedPublicKey else { throw Error.noData } - guard let openGroupServerPublicKey: String = openGroupServerPublicKey else { - throw Error.invalidGroupPublicKey + guard let otherBlindedPublicKey: String = otherBlindedPublicKey else { + throw MessageReceiverError.noData } - guard let userEd25519KeyPair = dependencies.storage.getUserED25519KeyPair() else { - throw Error.noUserED25519KeyPair + guard let openGroupServerPublicKey: String = openGroupServerPublicKey else { + throw MessageReceiverError.invalidGroupPublicKey + } + guard let userEd25519KeyPair: Box.KeyPair = Identity.fetchUserEd25519KeyPair(db) else { + throw MessageReceiverError.noUserED25519KeyPair } (plaintext, sender) = try decryptWithSessionBlindingProtocol( @@ -148,7 +147,7 @@ public enum MessageReceiver { message.receivedTimestamp = UInt64((Date().timeIntervalSince1970) * 1000) message.groupPublicKey = groupPublicKey message.openGroupServerTimestamp = (isOpenGroupMessage ? envelope.serverTimestamp : nil) - message.openGroupServerMessageId = openGroupMessageServerId + message.openGroupServerMessageId = openGroupMessageServerId.map { UInt64($0) } // Validate var isValid: Bool = message.isValid @@ -174,4 +173,176 @@ public enum MessageReceiver { return (message, proto, threadId) } + + // MARK: - Handling + + public static func handle( + _ db: Database, + message: Message, + associatedWithProto proto: SNProtoContent, + openGroupId: String?, + isBackgroundPoll: Bool, + dependencies: Dependencies = Dependencies() + ) throws { + switch message { + case let message as ReadReceipt: + try MessageReceiver.handleReadReceipt(db, message: message) + + case let message as TypingIndicator: + try MessageReceiver.handleTypingIndicator(db, message: message) + + case let message as ClosedGroupControlMessage: + try MessageReceiver.handleClosedGroupControlMessage(db, message) + + case let message as DataExtractionNotification: + try MessageReceiver.handleDataExtractionNotification(db, message: message) + + case let message as ExpirationTimerUpdate: + try MessageReceiver.handleExpirationTimerUpdate(db, message: message) + + case let message as ConfigurationMessage: + try MessageReceiver.handleConfigurationMessage(db, message: message) + + case let message as UnsendRequest: + try MessageReceiver.handleUnsendRequest(db, message: message) + + case let message as CallMessage: + try MessageReceiver.handleCallMessage(db, message: message) + + case let message as MessageRequestResponse: + try MessageReceiver.handleMessageRequestResponse(db, message: message, dependencies: dependencies) + + case let message as VisibleMessage: + try MessageReceiver.handleVisibleMessage( + db, + message: message, + associatedWithProto: proto, + openGroupId: openGroupId, + isBackgroundPoll: isBackgroundPoll + ) + + default: fatalError() + } + + // When handling any non-typing indicator message we want to make sure the thread becomes + // visible (the only other spot this flag gets set is when sending messages) + switch message { + case is TypingIndicator: break + + default: + guard let threadInfo: (id: String, variant: SessionThread.Variant) = threadInfo(db, message: message, openGroupId: openGroupId) else { + return + } + + _ = try SessionThread + .fetchOrCreate(db, id: threadInfo.id, variant: threadInfo.variant) + .with(shouldBeVisible: true) + .saved(db) + } + } + + // MARK: - Convenience + + internal static func threadInfo(_ db: Database, message: Message, openGroupId: String?) -> (id: String, variant: SessionThread.Variant)? { + if let openGroupId: String = openGroupId { + // Note: We don't want to create a thread for an open group if it doesn't exist + if (try? SessionThread.exists(db, id: openGroupId)) != true { return nil } + + return (openGroupId, .openGroup) + } + + if let groupPublicKey: String = message.groupPublicKey { + // Note: We don't want to create a thread for a closed group if it doesn't exist + if (try? SessionThread.exists(db, id: groupPublicKey)) != true { return nil } + + return (groupPublicKey, .closedGroup) + } + + // Extract the 'syncTarget' value if there is one + let maybeSyncTarget: String? + + switch message { + case let message as VisibleMessage: maybeSyncTarget = message.syncTarget + case let message as ExpirationTimerUpdate: maybeSyncTarget = message.syncTarget + default: maybeSyncTarget = nil + } + + // Note: We don't want to create a thread for a closed group if it doesn't exist + guard let contactId: String = (maybeSyncTarget ?? message.sender) else { return nil } + + return (contactId, .contact) + } + + internal static func updateProfileIfNeeded( + _ db: Database, + publicKey: String, + name: String?, + profilePictureUrl: String?, + profileKey: OWSAES256Key?, + sentTimestamp: TimeInterval, + dependencies: Dependencies = Dependencies() + ) throws { + let isCurrentUser = (publicKey == getUserHexEncodedPublicKey(db)) + var profile: Profile = Profile.fetchOrCreate(id: publicKey) + + // Name + if let name = name, name != profile.name { + let shouldUpdate: Bool + if isCurrentUser { + shouldUpdate = given(UserDefaults.standard[.lastDisplayNameUpdate]) { + sentTimestamp > $0.timeIntervalSince1970 + } + .defaulting(to: true) + } + else { + shouldUpdate = true + } + + if shouldUpdate { + if isCurrentUser { + UserDefaults.standard[.lastDisplayNameUpdate] = Date(timeIntervalSince1970: sentTimestamp) + } + + profile = profile.with(name: name) + } + } + + // Profile picture & profile key + if + let profileKey: OWSAES256Key = profileKey, + let profilePictureUrl: String = profilePictureUrl, + profileKey.keyData.count == kAES256_KeyByteLength, + profileKey != profile.profileEncryptionKey + { + let shouldUpdate: Bool + if isCurrentUser { + shouldUpdate = given(UserDefaults.standard[.lastProfilePictureUpdate]) { + sentTimestamp > $0.timeIntervalSince1970 + } + .defaulting(to: true) + } + else { + shouldUpdate = true + } + + if shouldUpdate { + if isCurrentUser { + UserDefaults.standard[.lastProfilePictureUpdate] = Date(timeIntervalSince1970: sentTimestamp) + } + + profile = profile.with( + profilePictureUrl: .update(profilePictureUrl), + profileEncryptionKey: .update(profileKey) + ) + } + } + + // Persist changes + try profile.save(db) + + // Download the profile picture if needed + db.afterNextTransactionCommit { _ in + ProfileManager.downloadAvatar(for: profile) + } + } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index df96dd2fb..bd6f0952f 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -108,10 +108,10 @@ extension MessageSender { switch destination { case .contact(let publicKey): return publicKey case .closedGroup(let groupPublicKey): return groupPublicKey - case .openGroupV2(let room, let server): - return OpenGroup.idFor(room: room, server: server) - - case .openGroup: return "" + case .openGroup(let roomToken, let server, _, _, _): + return OpenGroup.idFor(roomToken: roomToken, server: server) + + case .openGroupInbox(_, _, let blindedPublicKey): return blindedPublicKey } }() let openGroup: OpenGroup? = try? OpenGroup.fetchOne(db, id: threadId) @@ -129,12 +129,20 @@ extension MessageSender { attachment.upload( db, - using: { data in + using: { db, data in if let openGroup: OpenGroup = openGroup { - return OpenGroupAPIV2.upload(data, to: openGroup.room, on: openGroup.server) + return OpenGroupAPI + .uploadFile( + db, + bytes: data.bytes, + to: openGroup.roomToken, + on: openGroup.server + ) + .map { _, response -> String in response.id } } - return FileServerAPIV2.upload(data) + return FileServerAPI.upload(data) + .map { response -> String in response.id } }, encrypt: (openGroup == nil), success: { seal.fulfill(()) }, diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index f0e51338d..ca80c1015 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -131,6 +131,7 @@ public final class MessageSender { // Serialize the protobuf let plaintext: Data + do { plaintext = (try proto.serializedData() as NSData).paddedMessageBody() } @@ -208,7 +209,7 @@ public final class MessageSender { .sendMessage( snodeMessage, isClosedGroupMessage: (kind == .closedGroupMessage), - isConfigMessage: message.isKind(of: ConfigurationMessage.self) + isConfigMessage: (message is ConfigurationMessage) ) .done(on: DispatchQueue.global(qos: .userInitiated)) { promises in let promiseCount = promises.count @@ -222,8 +223,7 @@ public final class MessageSender { GRDBStorage.shared.write { db in let responseJson: JSON? = try? JSONSerialization.jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? JSON - let hash = json?["hash"] as? String - message.serverHash = hash + message.serverHash = (responseJson?["hash"] as? String) try MessageSender.handleSuccessfulMessageSend( db, @@ -332,16 +332,18 @@ public final class MessageSender { guard let openGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: threadId), - let userEdKeyPair: Box.KeyPair = try? Identity.fetchUserEd25519KeyPair(db) + let userEdKeyPair: Box.KeyPair = Identity.fetchUserEd25519KeyPair(db), + case .openGroup(let roomToken, let server, let whisperTo, let whisperMods, let fileIds) = destination else { preconditionFailure() } message.sender = { - let capabilities: [Capability.Variant] = Capability + let capabilities: [Capability.Variant] = (try? Capability .select(.variant) - .filter(Capability.Columns.openGroupId == threadId) + .filter(Capability.Columns.openGroupServer == server) .filter(Capability.Columns.isMissing == false) .asRequest(of: Capability.Variant.self) - .fetchAll(db) + .fetchAll(db)) + .defaulting(to: []) // If the server doesn't support blinding then go with an unblinded id guard capabilities.contains(.blind) else { @@ -405,8 +407,9 @@ public final class MessageSender { // Send the result OpenGroupAPI .send( - plaintext, - to: room, + db, + plaintext: plaintext, + to: roomToken, on: server, whisperTo: whisperTo, whisperMods: whisperMods, @@ -414,7 +417,7 @@ public final class MessageSender { using: dependencies ) .done(on: DispatchQueue.global(qos: .userInitiated)) { responseInfo, data in - message.openGroupServerMessageID = UInt64(data.id) + message.openGroupServerMessageId = UInt64(data.id) dependencies.storage.write { db in // The `posted` value is in seconds but we sent it in ms so need that for de-duping @@ -519,7 +522,8 @@ public final class MessageSender { // Send the result OpenGroupAPI .send( - ciphertext, + db, + ciphertext: ciphertext, toInboxFor: recipientBlindedPublicKey, on: server, using: dependencies @@ -595,8 +599,10 @@ public final class MessageSender { switch destination { case .contact(let publicKey): return publicKey case .closedGroup(let groupPublicKey): return groupPublicKey - case .openGroup(let room, let server, _, _, _): - return OpenGroup.idFor(room: room, server: server) + case .openGroup(let roomToken, let server, _, _, _): + return OpenGroup.idFor(roomToken: roomToken, server: server) + + case .openGroupInbox(_, _, let blindedPublicKey): return blindedPublicKey } }(), message: message, diff --git a/SessionMessagingKit/Sending & Receiving/Notification+MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/Notification+MessageReceiver.swift index f369ac1ef..d8ceb8bba 100644 --- a/SessionMessagingKit/Sending & Receiving/Notification+MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/Notification+MessageReceiver.swift @@ -1,8 +1,16 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation public extension Notification.Name { static let initialConfigurationMessageReceived = Notification.Name("initialConfigurationMessageReceived") static let incomingMessageMarkedAsRead = Notification.Name("incomingMessageMarkedAsRead") + static let missedCall = Notification.Name("missedCall") +} + +public extension Notification.Key { + static let senderId = Notification.Key("senderId") } @objc public extension NSNotification { diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift index 7eddf6a22..bc28b2bcc 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift @@ -33,8 +33,6 @@ public final class ClosedGroupPoller { public static let shared = ClosedGroupPoller() - private override init() { } - // MARK: - Public API @objc public func start() { @@ -90,7 +88,7 @@ public final class ClosedGroupPoller { private func setUpPolling(for groupPublicKey: String) { Threading.pollerQueue.async { - self.poll(groupPublicKey) + ClosedGroupPoller.poll(groupPublicKey, poller: self) .done(on: Threading.pollerQueue) { [weak self] _ in self?.pollRecursively(groupPublicKey) } @@ -137,16 +135,7 @@ public final class ClosedGroupPoller { timer.invalidate() Threading.pollerQueue.async { - var promises: [Promise] = [] - if SnodeAPI.hardfork <= 19, SnodeAPI.softfork == 0, let promise = self?.poll(groupPublicKey, defaultInbox: true) { - promises.append(promise) - } - - if SnodeAPI.hardfork >= 19, SnodeAPI.softfork >= 0,let promise = self?.poll(groupPublicKey) { - promises.append(promise) - } - - when(resolved: promises) + ClosedGroupPoller.poll(groupPublicKey, poller: self) .done(on: Threading.pollerQueue) { _ in self?.pollRecursively(groupPublicKey) } @@ -158,82 +147,138 @@ public final class ClosedGroupPoller { } } - private func poll(_ groupPublicKey: String, defaultInbox: Bool = false) -> Promise { - guard isPolling.wrappedValue[groupPublicKey] == true else { return Promise.value(()) } - + public static func poll( + _ groupPublicKey: String, + on queue: DispatchQueue = SessionSnodeKit.Threading.workQueue, + maxRetryCount: UInt = 0, + isBackgroundPoll: Bool = false, + poller: ClosedGroupPoller? = nil + ) -> Promise { let promise: Promise = SnodeAPI.getSwarm(for: groupPublicKey) - .then2 { [weak self] swarm -> Promise<(Snode, [SnodeReceivedMessage])> in + .then(on: queue) { swarm -> Promise in // randomElement() uses the system's default random generator, which is cryptographically secure guard let snode = swarm.randomElement() else { return Promise(error: Error.insufficientSnodes) } - guard self?.isPolling.wrappedValue[groupPublicKey] == true else { - return Promise(error: Error.pollingCanceled) - } - let getMessagesPromise: Promise<[SnodeReceivedMessage]> = (defaultInbox ? - SnodeAPI.getClosedGroupMessagesFromDefaultNamespace(from: snode, associatedWith: groupPublicKey) : - SnodeAPI.getMessages(from: snode, associatedWith: groupPublicKey, authenticated: false) - ) - - return getMessagesPromise - .map { messages in (snode, messages) } - } - .done2 { [weak self] snode, messages in - guard self?.isPolling.wrappedValue[groupPublicKey] == true else { return } - - if !messages.isEmpty { - var messageCount: Int = 0 - - GRDBStorage.shared.write { db in - var jobDetailMessages: [MessageReceiveJob.Details.MessageInfo] = [] - - messages.forEach { message in - do { - let processedMessage: ProcessedMessage? = try Message.processRawReceivedMessage(db, rawMessage: message) - - jobDetailMessages = jobDetailMessages - .appending(processedMessage?.messageInfo) - } - 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, - MessageReceiverError.duplicateMessage, - MessageReceiverError.duplicateControlMessage, - MessageReceiverError.selfSend: - break - - default: SNLog("Failed to deserialize envelope due to error: \(error).") - } - } - } - - messageCount = jobDetailMessages.count - - JobRunner.add( - db, - job: Job( - variant: .messageReceive, - behaviour: .runOnce, - threadId: groupPublicKey, - details: MessageReceiveJob.Details( - messages: jobDetailMessages, - isBackgroundPoll: false - ) - ) - ) + return attempt(maxRetryCount: maxRetryCount, recoveringOn: queue) { + guard isBackgroundPoll || poller?.isPolling.wrappedValue[groupPublicKey] == true else { + return Promise(error: Error.pollingCanceled) } - SNLog("Received \(messageCount) new message\(messageCount == 1 ? "" : "s") in closed group with public key: \(groupPublicKey) (duplicates: \(messages.count - messageCount))") - } - else { - SNLog("Received no new messages in closed group with public key: \(groupPublicKey)") + let promises: [Promise<[SnodeReceivedMessage]>] = { + if SnodeAPI.hardfork >= 19 && SnodeAPI.softfork >= 1 { + return [ SnodeAPI.getMessages(from: snode, associatedWith: groupPublicKey, authenticated: false) ] + } + + if SnodeAPI.hardfork >= 19 { + return [ + SnodeAPI.getClosedGroupMessagesFromDefaultNamespace(from: snode, associatedWith: groupPublicKey), + SnodeAPI.getMessages(from: snode, associatedWith: groupPublicKey, authenticated: false) + ] + } + + return [ SnodeAPI.getClosedGroupMessagesFromDefaultNamespace(from: snode, associatedWith: groupPublicKey) ] + }() + + return when(resolved: promises) + .then(on: queue) { messageResults -> Promise in + guard isBackgroundPoll || poller?.isPolling.wrappedValue[groupPublicKey] == true else { return Promise.value(()) } + + var promises: [Promise] = [] + var messageCount: Int = 0 + let totalMessagesCount: Int = messageResults + .map { result -> Int in + switch result { + case .fulfilled(let messages): return messages.count + default: return 0 + } + } + .reduce(0, +) + + messageResults.forEach { result in + guard case .fulfilled(let messages) = result else { return } + guard !messages.isEmpty else { return } + + var jobToRun: Job? + + GRDBStorage.shared.write { db in + var jobDetailMessages: [MessageReceiveJob.Details.MessageInfo] = [] + + messages.forEach { message in + do { + let processedMessage: ProcessedMessage? = try Message.processRawReceivedMessage(db, rawMessage: message) + + jobDetailMessages = jobDetailMessages + .appending(processedMessage?.messageInfo) + } + 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, + MessageReceiverError.duplicateMessage, + MessageReceiverError.duplicateControlMessage, + MessageReceiverError.selfSend: + break + + default: SNLog("Failed to deserialize envelope due to error: \(error).") + } + } + } + + messageCount += jobDetailMessages.count + jobToRun = Job( + variant: .messageReceive, + behaviour: .runOnce, + threadId: groupPublicKey, + details: MessageReceiveJob.Details( + messages: jobDetailMessages, + isBackgroundPoll: isBackgroundPoll + ) + ) + + // 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 + JobRunner.add(db, job: jobToRun, canStartJob: !isBackgroundPoll) + } + + // We want to try to handle the receive jobs immediately in the background + if isBackgroundPoll { + promises = promises.appending( + jobToRun.map { job -> Promise in + let (promise, seal) = Promise.pending() + + // Note: In the background we just want jobs to fail silently + MessageReceiveJob.run( + job, + success: { _, _ in seal.fulfill(()) }, + failure: { _, _, _ in seal.fulfill(()) }, + deferred: { _ in seal.fulfill(()) } + ) + + return promise + } + ) + } + } + + if !isBackgroundPoll { + if totalMessagesCount > 0 { + SNLog("Received \(messageCount) new message\(messageCount == 1 ? "" : "s") in closed group with public key: \(groupPublicKey) (duplicates: \(totalMessagesCount - messageCount))") + } + else { + SNLog("Received no new messages in closed group with public key: \(groupPublicKey)") + } + } + + return when(fulfilled: promises) + } } } - .map { _ in } - promise.catch2 { error in - SNLog("Polling failed for closed group with public key: \(groupPublicKey) due to error: \(error).") + if !isBackgroundPoll { + promise.catch2 { error in + SNLog("Polling failed for closed group with public key: \(groupPublicKey) due to error: \(error).") + } } return promise diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift index 0fa3130a2..3d49d6b28 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift @@ -1,5 +1,10 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB import PromiseKit import SessionSnodeKit +import SessionUtilitiesKit extension OpenGroupAPI { public final class Poller { @@ -51,16 +56,20 @@ extension OpenGroupAPI { promise.retainUntilComplete() Threading.pollerQueue.async { - OpenGroupAPI - .poll( - server, - hasPerformedInitialPoll: dependencies.cache.hasPerformedInitialPoll[server] == true, - timeSinceLastPoll: ( - dependencies.cache.timeSinceLastPoll[server] ?? - dependencies.cache.getTimeSinceLastOpen(using: dependencies) - ), - using: dependencies - ) + GRDBStorage.shared + .read { db in + OpenGroupAPI + .poll( + db, + server: server, + hasPerformedInitialPoll: dependencies.cache.hasPerformedInitialPoll[server] == true, + timeSinceLastPoll: ( + dependencies.cache.timeSinceLastPoll[server] ?? + dependencies.cache.getTimeSinceLastOpen(using: dependencies) + ), + using: dependencies + ) + } .done(on: OpenGroupAPI.workQueue) { [weak self] response in self?.isPolling = false self?.handlePollResponse(response, isBackgroundPoll: isBackgroundPoll, using: dependencies) @@ -86,13 +95,8 @@ extension OpenGroupAPI { private func handlePollResponse(_ response: [OpenGroupAPI.Endpoint: (info: OnionRequestResponseInfoType, data: Codable?)], isBackgroundPoll: Bool, using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies()) { let server: String = self.server - dependencies.storage.write { anyTransaction in - guard let transaction: YapDatabaseReadWriteTransaction = anyTransaction as? YapDatabaseReadWriteTransaction else { - SNLog("Open group polling failed due to invalid database transaction.") - return - } - - response.forEach { endpoint, endpointResponse in + dependencies.storage.write { db in + try response.forEach { endpoint, endpointResponse in switch endpoint { case .capabilities: guard let responseData: BatchSubResponse = endpointResponse.data as? BatchSubResponse, let responseBody: Capabilities = responseData.body else { @@ -101,9 +105,23 @@ extension OpenGroupAPI { } OpenGroupManager.handleCapabilities( - responseBody, + db, + capabilities: responseBody, + on: server + ) + + case .roomPollInfo(let roomToken, _): + guard let responseData: BatchSubResponse = endpointResponse.data as? BatchSubResponse, let responseBody: RoomPollInfo = responseData.body else { + SNLog("Open group polling failed due to invalid data.") + return + } + + try OpenGroupManager.handlePollInfo( + db, + pollInfo: responseBody, + publicKey: nil, + for: roomToken, on: server, - using: transaction, dependencies: dependencies ) @@ -121,26 +139,11 @@ extension OpenGroupAPI { } OpenGroupManager.handleMessages( - successfulMessages, + db, + messages: successfulMessages, for: roomToken, on: server, isBackgroundPoll: isBackgroundPoll, - using: transaction, - dependencies: dependencies - ) - - case .roomPollInfo(let roomToken, _): - guard let responseData: BatchSubResponse = endpointResponse.data as? BatchSubResponse, let responseBody: RoomPollInfo = responseData.body else { - SNLog("Open group polling failed due to invalid data.") - return - } - - OpenGroupManager.handlePollInfo( - responseBody, - publicKey: nil, - for: roomToken, - on: server, - using: transaction, dependencies: dependencies ) @@ -150,6 +153,8 @@ extension OpenGroupAPI { return } + // Double optional because the server can return a `304` with an empty body + let messages: [OpenGroupAPI.DirectMessage] = ((responseData.body ?? []) ?? []) let fromOutbox: Bool = { switch endpoint { case .outbox, .outboxSince: return true @@ -158,11 +163,11 @@ extension OpenGroupAPI { }() OpenGroupManager.handleDirectMessages( - ((responseData.body ?? []) ?? []), // Double optional because the server can return a `304` with an empty body + db, + messages: messages, fromOutbox: fromOutbox, on: server, isBackgroundPoll: isBackgroundPoll, - using: transaction, dependencies: dependencies ) @@ -173,10 +178,3 @@ extension OpenGroupAPI { } } } - -extension OpenGroupAPI.Poller { - @objc(startIfNeeded) - public func objc_startIfNeeded() { - startIfNeeded() - } -} diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift index fa95b411d..be7f3c3cb 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift @@ -143,8 +143,6 @@ public final class Poller { let processedMessage: ProcessedMessage? = try Message.processRawReceivedMessage(db, rawMessage: message) let key: String = (processedMessage?.threadId ?? Message.nonThreadMessageId) - } - threadMessages[key] = (threadMessages[key] ?? []) .appending(processedMessage?.messageInfo) } diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index 403f0913c..5f2c037ec 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -13,6 +13,8 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, public static let threadVariantKey: SQL = SQL(stringLiteral: CodingKeys.threadVariant.stringValue) public static let threadIsTrustedKey: SQL = SQL(stringLiteral: CodingKeys.threadIsTrusted.stringValue) public static let threadHasDisappearingMessagesEnabledKey: SQL = SQL(stringLiteral: CodingKeys.threadHasDisappearingMessagesEnabled.stringValue) + public static let threadOpenGroupServerKey: SQL = SQL(stringLiteral: CodingKeys.threadOpenGroupServer.stringValue) + public static let threadOpenGroupPublicKeyKey: SQL = SQL(stringLiteral: CodingKeys.threadOpenGroupPublicKey.stringValue) public static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue) public static let authorNameInternalKey: SQL = SQL(stringLiteral: CodingKeys.authorNameInternal.stringValue) public static let stateKey: SQL = SQL(stringLiteral: CodingKeys.state.stringValue) @@ -59,6 +61,8 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, public let threadVariant: SessionThread.Variant public let threadIsTrusted: Bool public let threadHasDisappearingMessagesEnabled: Bool + public let threadOpenGroupServer: String? + public let threadOpenGroupPublicKey: String? // Interaction Info @@ -69,6 +73,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, public let authorId: String private let authorNameInternal: String? public let body: String? + public let rawBody: String? public let expiresStartedAtMs: Double? public let expiresInSeconds: TimeInterval? @@ -129,6 +134,8 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, threadVariant: self.threadVariant, threadIsTrusted: self.threadIsTrusted, threadHasDisappearingMessagesEnabled: self.threadHasDisappearingMessagesEnabled, + threadOpenGroupServer: self.threadOpenGroupServer, + threadOpenGroupPublicKey: self.threadOpenGroupPublicKey, rowId: self.rowId, id: self.id, variant: self.variant, @@ -136,13 +143,14 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, authorId: self.authorId, authorNameInternal: self.authorNameInternal, body: self.body, + rawBody: self.rawBody, expiresStartedAtMs: self.expiresStartedAtMs, expiresInSeconds: self.expiresInSeconds, state: self.state, hasAtLeastOneReadReceipt: self.hasAtLeastOneReadReceipt, mostRecentFailureText: self.mostRecentFailureText, - isTypingIndicator: self.isTypingIndicator, isSenderOpenGroupModerator: self.isSenderOpenGroupModerator, + isTypingIndicator: self.isTypingIndicator, profile: self.profile, quote: self.quote, quoteAttachment: self.quoteAttachment, @@ -209,6 +217,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, ) let shouldShowDateOnThisModel: Bool = { guard self.isTypingIndicator != true else { return false } + guard self.variant != .infoCall else { return true } // Always show on calls guard let prevModel: ViewModel = prevModel else { return true } return MessageViewModel.shouldShowDateBreak( @@ -273,6 +282,8 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, threadVariant: self.threadVariant, threadIsTrusted: self.threadIsTrusted, threadHasDisappearingMessagesEnabled: self.threadHasDisappearingMessagesEnabled, + threadOpenGroupServer: self.threadOpenGroupServer, + threadOpenGroupPublicKey: self.threadOpenGroupPublicKey, rowId: self.rowId, id: self.id, variant: self.variant, @@ -298,13 +309,14 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, isOpenGroupInvitation: (self.linkPreview?.variant == .openGroupInvitation) ) ), + rawBody: self.body, expiresStartedAtMs: self.expiresStartedAtMs, expiresInSeconds: self.expiresInSeconds, state: self.state, hasAtLeastOneReadReceipt: self.hasAtLeastOneReadReceipt, mostRecentFailureText: self.mostRecentFailureText, - isTypingIndicator: self.isTypingIndicator, isSenderOpenGroupModerator: self.isSenderOpenGroupModerator, + isTypingIndicator: self.isTypingIndicator, profile: self.profile, quote: self.quote, quoteAttachment: self.quoteAttachment, @@ -412,6 +424,8 @@ public extension MessageViewModel { self.threadVariant = .contact self.threadIsTrusted = false self.threadHasDisappearingMessagesEnabled = false + self.threadOpenGroupServer = nil + self.threadOpenGroupPublicKey = nil // Interaction Info @@ -426,14 +440,15 @@ public extension MessageViewModel { self.authorId = "" self.authorNameInternal = nil self.body = nil + self.rawBody = nil self.expiresStartedAtMs = nil self.expiresInSeconds = nil self.state = .sent self.hasAtLeastOneReadReceipt = false self.mostRecentFailureText = nil - self.isTypingIndicator = isTypingIndicator self.isSenderOpenGroupModerator = false + self.isTypingIndicator = isTypingIndicator self.profile = nil self.quote = nil self.quoteAttachment = nil @@ -516,6 +531,7 @@ public extension MessageViewModel { return { additionalFilters, limitSQL -> AdaptedFetchRequest> in let interaction: TypedTableAlias = TypedTableAlias() let thread: TypedTableAlias = TypedTableAlias() + let openGroup: TypedTableAlias = TypedTableAlias() let recipientState: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() let disappearingMessagesConfig: TypedTableAlias = TypedTableAlias() @@ -523,12 +539,14 @@ public extension MessageViewModel { let quote: TypedTableAlias = TypedTableAlias() let linkPreview: TypedTableAlias = TypedTableAlias() - let interactionStateTableLiteral: SQL = SQL(stringLiteral: "interactionState") let interactionStateInteractionIdColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.interactionId.name) - let interactionStateMostRecentFailureTextColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.mostRecentFailureText.name) let readReceiptTableLiteral: SQL = SQL(stringLiteral: "readReceipt") let readReceiptReadTimestampMsColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.readTimestampMs.name) let attachmentIdColumnLiteral: SQL = SQL(stringLiteral: Attachment.Columns.id.name) + let groupMemberModeratorTableLiteral: SQL = SQL(stringLiteral: "groupMemberModerator") + let groupMemberAdminTableLiteral: SQL = SQL(stringLiteral: "groupMemberAdmin") + let groupMemberProfileIdColumnLiteral: SQL = SQL(stringLiteral: GroupMember.Columns.profileId.name) + let groupMemberRoleColumnLiteral: SQL = SQL(stringLiteral: GroupMember.Columns.role.name) let finalFilterSQL: SQL = { guard let additionalFilters: SQL = additionalFilters else { @@ -545,7 +563,7 @@ public extension MessageViewModel { """ }() let finalLimitSQL: SQL = (limitSQL ?? SQL(stringLiteral: "")) - let numColumnsBeforeLinkedRecords: Int = 16 + let numColumnsBeforeLinkedRecords: Int = 18 let request: SQLRequest = """ SELECT \(thread[.variant]) AS \(ViewModel.threadVariantKey), @@ -553,6 +571,8 @@ public extension MessageViewModel { IFNULL(\(contact[.isTrusted]), true) AS \(ViewModel.threadIsTrustedKey), -- Default to 'false' when no contact exists IFNULL(\(disappearingMessagesConfig[.isEnabled]), false) AS \(ViewModel.threadHasDisappearingMessagesEnabledKey), + \(openGroup[.server]) AS \(ViewModel.threadOpenGroupServerKey), + \(openGroup[.publicKey]) AS \(ViewModel.threadOpenGroupPublicKeyKey), \(interaction.alias[Column.rowID]) AS \(ViewModel.rowIdKey), \(interaction[.id]), @@ -569,7 +589,10 @@ public extension MessageViewModel { (\(readReceiptTableLiteral).\(readReceiptReadTimestampMsColumnLiteral) IS NOT NULL) AS \(ViewModel.hasAtLeastOneReadReceiptKey), \(recipientState[.mostRecentFailureText]) AS \(ViewModel.mostRecentFailureTextKey), - false AS \(ViewModel.isSenderOpenGroupModeratorKey), + ( + \(groupMemberModeratorTableLiteral).\(groupMemberProfileIdColumnLiteral) IS NOT NULL OR + \(groupMemberAdminTableLiteral).\(groupMemberProfileIdColumnLiteral) IS NOT NULL + ) AS \(ViewModel.isSenderOpenGroupModeratorKey), \(ViewModel.profileKey).*, \(ViewModel.quoteKey).*, @@ -590,6 +613,7 @@ public extension MessageViewModel { JOIN \(SessionThread.self) ON \(thread[.id]) = \(interaction[.threadId]) LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId]) LEFT JOIN \(DisappearingMessagesConfiguration.self) ON \(disappearingMessagesConfig[.threadId]) = \(interaction[.threadId]) + LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(interaction[.threadId]) LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId]) LEFT JOIN \(Quote.self) ON \(quote[.interactionId]) = \(interaction[.id]) LEFT JOIN \(Attachment.self) AS \(ViewModel.quoteAttachmentKey) ON \(ViewModel.quoteAttachmentKey).\(attachmentIdColumnLiteral) = \(quote[.attachmentId]) @@ -607,6 +631,16 @@ public extension MessageViewModel { \(readReceiptTableLiteral).\(readReceiptReadTimestampMsColumnLiteral) IS NOT NULL AND \(interaction[.id]) = \(readReceiptTableLiteral).\(interactionStateInteractionIdColumnLiteral) ) + LEFT JOIN \(GroupMember.self) AS \(groupMemberModeratorTableLiteral) ON ( + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.openGroup)")) AND + \(groupMemberModeratorTableLiteral).\(groupMemberProfileIdColumnLiteral) = \(interaction[.authorId]) AND + \(SQL("\(groupMemberModeratorTableLiteral).\(groupMemberRoleColumnLiteral) = \(GroupMember.Role.moderator)")) + ) + LEFT JOIN \(GroupMember.self) AS \(groupMemberAdminTableLiteral) ON ( + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.openGroup)")) AND + \(groupMemberAdminTableLiteral).\(groupMemberProfileIdColumnLiteral) = \(interaction[.authorId]) AND + \(SQL("\(groupMemberAdminTableLiteral).\(groupMemberRoleColumnLiteral) = \(GroupMember.Role.admin)")) + ) \(finalFilterSQL) GROUP BY \(interaction[.id]) ORDER BY \(orderSQL) diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 1267a2a71..5becf86be 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -41,7 +41,7 @@ public struct SessionThreadViewModel: FetchableRecord, Decodable, Equatable, Has public static let closedGroupProfileBackFallbackKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupProfileBackFallback.stringValue) public static let openGroupNameKey: SQL = SQL(stringLiteral: CodingKeys.openGroupName.stringValue) public static let openGroupServerKey: SQL = SQL(stringLiteral: CodingKeys.openGroupServer.stringValue) - public static let openGroupRoomKey: SQL = SQL(stringLiteral: CodingKeys.openGroupRoom.stringValue) + public static let openGroupRoomTokenKey: SQL = SQL(stringLiteral: CodingKeys.openGroupRoomToken.stringValue) public static let openGroupProfilePictureDataKey: SQL = SQL(stringLiteral: CodingKeys.openGroupProfilePictureData.stringValue) public static let openGroupUserCountKey: SQL = SQL(stringLiteral: CodingKeys.openGroupUserCount.stringValue) public static let interactionIdKey: SQL = SQL(stringLiteral: CodingKeys.interactionId.stringValue) @@ -100,7 +100,7 @@ public struct SessionThreadViewModel: FetchableRecord, Decodable, Equatable, Has public let currentUserIsClosedGroupAdmin: Bool? public let openGroupName: String? public let openGroupServer: String? - public let openGroupRoom: String? + public let openGroupRoomToken: String? public let openGroupProfilePictureData: Data? private let openGroupUserCount: Int? @@ -228,7 +228,7 @@ public extension SessionThreadViewModel { self.currentUserIsClosedGroupAdmin = nil self.openGroupName = nil self.openGroupServer = nil - self.openGroupRoom = nil + self.openGroupRoomToken = nil self.openGroupProfilePictureData = nil self.openGroupUserCount = nil @@ -549,7 +549,7 @@ public extension SessionThreadViewModel { (\(groupMember[.profileId]) IS NOT NULL) AS \(ViewModel.currentUserIsClosedGroupMemberKey), \(openGroup[.name]) AS \(ViewModel.openGroupNameKey), \(openGroup[.server]) AS \(ViewModel.openGroupServerKey), - \(openGroup[.room]) AS \(ViewModel.openGroupRoomKey), + \(openGroup[.roomToken]) AS \(ViewModel.openGroupRoomTokenKey), \(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey), \(openGroup[.userCount]) AS \(ViewModel.openGroupUserCountKey), diff --git a/SessionMessagingKit/Utilities/BoxKeyPair+Utilities.swift b/SessionMessagingKit/Utilities/BoxKeyPair+Utilities.swift deleted file mode 100644 index 9bbf84f83..000000000 --- a/SessionMessagingKit/Utilities/BoxKeyPair+Utilities.swift +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Sodium -extension Box.KeyPair: Equatable { - public static func == (lhs: Box.KeyPair, rhs: Box.KeyPair) -> Bool { - return ( - lhs.publicKey == rhs.publicKey && - lhs.secretKey == rhs.secretKey - ) - } -} diff --git a/SessionMessagingKit/Utilities/Data+Utilities.swift b/SessionMessagingKit/Utilities/Data+Utilities.swift index b99c50da0..c04495f6a 100644 --- a/SessionMessagingKit/Utilities/Data+Utilities.swift +++ b/SessionMessagingKit/Utilities/Data+Utilities.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import SessionUtilitiesKit // MARK: - Decoding diff --git a/SessionMessagingKit/Utilities/Dependencies.swift b/SessionMessagingKit/Utilities/Dependencies.swift index b4709d0f0..62c6dcadf 100644 --- a/SessionMessagingKit/Utilities/Dependencies.swift +++ b/SessionMessagingKit/Utilities/Dependencies.swift @@ -12,21 +12,15 @@ public class Dependencies { set { _onionApi = newValue } } - internal var _identityManager: IdentityManagerProtocol? - public var identityManager: IdentityManagerProtocol { - get { Dependencies.getValueSettingIfNull(&_identityManager) { OWSIdentityManager.shared() } } - set { _identityManager = newValue } - } - internal var _generalCache: Atomic? public var generalCache: Atomic { get { Dependencies.getValueSettingIfNull(&_generalCache) { General.cache } } set { _generalCache = newValue } } - internal var _storage: SessionMessagingKitStorageProtocol? - public var storage: SessionMessagingKitStorageProtocol { - get { Dependencies.getValueSettingIfNull(&_storage) { SNMessagingKitConfiguration.shared.storage } } + internal var _storage: GRDBStorage? + public var storage: GRDBStorage { + get { Dependencies.getValueSettingIfNull(&_storage) { GRDBStorage.shared } } set { _storage = newValue } } @@ -94,9 +88,8 @@ public class Dependencies { public init( onionApi: OnionRequestAPIType.Type? = nil, - identityManager: IdentityManagerProtocol? = nil, generalCache: Atomic? = nil, - storage: SessionMessagingKitStorageProtocol? = nil, + storage: GRDBStorage? = nil, sodium: SodiumType? = nil, box: BoxType? = nil, genericHash: GenericHashType? = nil, @@ -109,7 +102,6 @@ public class Dependencies { date: Date? = nil ) { _onionApi = onionApi - _identityManager = identityManager _generalCache = generalCache _storage = storage _sodium = sodium diff --git a/SessionMessagingKit/Utilities/Environment.swift b/SessionMessagingKit/Utilities/Environment.swift index 1630ed8f7..0bf48760c 100644 --- a/SessionMessagingKit/Utilities/Environment.swift +++ b/SessionMessagingKit/Utilities/Environment.swift @@ -13,6 +13,9 @@ public class Environment { public let windowManager: OWSWindowManager public var isRequestingPermission: Bool + // Note: This property is configured after Environment is created. + public let callManager: Atomic = Atomic(nil) + // Note: This property is configured after Environment is created. public let notificationsManager: Atomic = Atomic(nil) diff --git a/SessionMessagingKit/Utilities/Preferences.swift b/SessionMessagingKit/Utilities/Preferences.swift index 6badcaeae..243efec10 100644 --- a/SessionMessagingKit/Utilities/Preferences.swift +++ b/SessionMessagingKit/Utilities/Preferences.swift @@ -38,6 +38,9 @@ public extension Setting.BoolKey { /// **Note:** Link Previews are only enabled for HTTPS urls static let areLinkPreviewsEnabled: Setting.BoolKey = "areLinkPreviewsEnabled" + /// Controls whether Calls are enabled + static let areCallsEnabled: Setting.BoolKey = "areCallsEnabled" + /// Controls whether the message requests item has been hidden on the home screen static let hasHiddenMessageRequests: Setting.BoolKey = "hasHiddenMessageRequests" @@ -285,6 +288,13 @@ public enum Preferences { return player } } + + public static var isCallKitSupported: Bool { + guard let regionCode: String = NSLocale.current.regionCode else { return false } + guard !regionCode.contains("CN") && !regionCode.contains("CHN") else { return false } + + return true + } } // MARK: - Objective C Support @@ -376,6 +386,16 @@ public class SMKPreferences: NSObject { static func objc_areLinkPreviewsEnabled() -> Bool { return GRDBStorage.shared[.areLinkPreviewsEnabled] } + + @objc(setCallsEnabled:) + static func objc_setCallsEnabled(_ enabled: Bool) { + GRDBStorage.shared.write { db in db[.areCallsEnabled] = enabled } + } + + @objc(areCallsEnabled) + static func objc_areCallsEnabled() -> Bool { + return GRDBStorage.shared[.areCallsEnabled] + } } @objc(SMKSound) diff --git a/SessionMessagingKit/Utilities/ProfileManager.swift b/SessionMessagingKit/Utilities/ProfileManager.swift index 809e334ae..1f192b2b5 100644 --- a/SessionMessagingKit/Utilities/ProfileManager.swift +++ b/SessionMessagingKit/Utilities/ProfileManager.swift @@ -122,7 +122,7 @@ public struct ProfileManager { return } guard - let fileId: UInt64 = UInt64(profileUrlAtStart.lastPathComponent), + let fileId: Int64 = Int64(profileUrlAtStart.lastPathComponent), let profileKeyAtStart: OWSAES256Key = profile.profileEncryptionKey, profileKeyAtStart.keyData.count > 0 else { @@ -137,9 +137,9 @@ public struct ProfileManager { OWSLogger.verbose("downloading profile avatar: \(profile.id)") currentAvatarDownloads.mutate { $0.insert(profile.id) } - let useOldServer: Bool = (profileUrlStringAtStart.contains(FileServerAPIV2.oldServer)) + let useOldServer: Bool = (profileUrlStringAtStart.contains(FileServerAPI.oldServer)) - FileServerAPIV2 + FileServerAPI .download(fileId, useOldServer: useOldServer) .done { data in currentAvatarDownloads.mutate { $0.remove(profile.id) } @@ -302,10 +302,10 @@ public struct ProfileManager { } // Upload the avatar to the FileServer - FileServerAPIV2 + FileServerAPI .upload(encryptedAvatarData) .done { fileId in - let downloadUrl: String = "\(FileServerAPIV2.server)/files/\(fileId)" + let downloadUrl: String = "\(FileServerAPI.server)/files/\(fileId)" UserDefaults.standard[.lastProfilePictureUpload] = Date() GRDBStorage.shared.writeAsync { db in @@ -330,7 +330,7 @@ public struct ProfileManager { DispatchQueue.main.async { SNLog("Updating service with profile failed.") - let isMaxFileSizeExceeded: Bool = ((error as? FileServerAPIV2.Error) == FileServerAPIV2.Error.maxFileSizeExceeded) + let isMaxFileSizeExceeded: Bool = ((error as? HTTP.Error) == HTTP.Error.maxFileSizeExceeded) failure?(isMaxFileSizeExceeded ? .avatarUploadMaxFileSizeExceeded : .avatarUploadFailed diff --git a/SessionMessagingKit/Utilities/Promise+Utilities.swift b/SessionMessagingKit/Utilities/Promise+Utilities.swift index cd72cd3c3..ed2f23f4b 100644 --- a/SessionMessagingKit/Utilities/Promise+Utilities.swift +++ b/SessionMessagingKit/Utilities/Promise+Utilities.swift @@ -3,6 +3,7 @@ import Foundation import PromiseKit import SessionSnodeKit +import SessionUtilitiesKit extension Promise where T == Data { func decoded(as type: R.Type, on queue: DispatchQueue? = nil, using dependencies: Dependencies = Dependencies()) -> Promise { diff --git a/SessionMessagingKit/Utilities/Sodium+Utilities.swift b/SessionMessagingKit/Utilities/Sodium+Utilities.swift index e211412d5..e0f953130 100644 --- a/SessionMessagingKit/Utilities/Sodium+Utilities.swift +++ b/SessionMessagingKit/Utilities/Sodium+Utilities.swift @@ -4,45 +4,7 @@ import Foundation import Clibsodium import Sodium import Curve25519Kit - -extension Sign { - - /** - Converts an Ed25519 public key to an X25519 public key. - - Parameter ed25519PublicKey: The Ed25519 public key to convert. - - Returns: The X25519 public key if conversion is successful. - */ - public func toX25519(ed25519PublicKey: PublicKey) -> PublicKey? { - var x25519PublicKey = PublicKey(repeating: 0, count: 32) - - // FIXME: It'd be nice to check the exit code here, but all the properties of the object - // returned by the call below are internal. - let _ = crypto_sign_ed25519_pk_to_curve25519 ( - &x25519PublicKey, - ed25519PublicKey - ) - - return x25519PublicKey - } - - /** - Converts an Ed25519 secret key to an X25519 secret key. - - Parameter ed25519SecretKey: The Ed25519 secret key to convert. - - Returns: The X25519 secret key if conversion is successful. - */ - public func toX25519(ed25519SecretKey: SecretKey) -> SecretKey? { - var x25519SecretKey = SecretKey(repeating: 0, count: 32) - - // FIXME: It'd be nice to check the exit code here, but all the properties of the object - // returned by the call below are internal. - let _ = crypto_sign_ed25519_sk_to_curve25519 ( - &x25519SecretKey, - ed25519SecretKey - ) - - return x25519SecretKey - } -} +import SessionUtilitiesKit /// These extenion methods are used to generate a sign "blinded" messages /// @@ -315,19 +277,11 @@ extension AeadXChaCha20Poly1305IetfType { } } -// MARK: - Objective-C Support - -@objc public class SNBlindingUtils: NSObject { - @objc public static func userBlindedId(for openGroupPublicKey: String) -> String? { - let sodium: Sodium = Sodium() - - guard let userEd25519KeyPair = Storage.shared.getUserED25519KeyPair() else { - return nil - } - guard let blindedKeyPair = sodium.blindedKeyPair(serverPublicKey: openGroupPublicKey, edKeyPair: userEd25519KeyPair, genericHash: sodium.genericHash) else { - return nil - } - - return SessionId(.blinded, publicKey: blindedKeyPair.publicKey).hexString +extension Box.KeyPair: Equatable { + public static func == (lhs: Box.KeyPair, rhs: Box.KeyPair) -> Bool { + return ( + lhs.publicKey == rhs.publicKey && + lhs.secretKey == rhs.secretKey + ) } } diff --git a/SessionMessagingKitTests/_TestUtilities/MockEd25519.swift b/SessionMessagingKitTests/_TestUtilities/MockEd25519.swift index 23632d67e..d92250663 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockEd25519.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockEd25519.swift @@ -7,7 +7,7 @@ import Sodium @testable import SessionMessagingKit class MockEd25519: Mock, Ed25519Type { - func sign(data: Bytes, keyPair: ECKeyPair) throws -> Bytes? { + func sign(data: Bytes, keyPair: Box.KeyPair) throws -> Bytes? { return accept(args: [data, keyPair]) as? Bytes } diff --git a/SessionNotificationServiceExtension/NSENotificationPresenter.swift b/SessionNotificationServiceExtension/NSENotificationPresenter.swift index 6217933f8..1c1cdb1f7 100644 --- a/SessionNotificationServiceExtension/NSENotificationPresenter.swift +++ b/SessionNotificationServiceExtension/NSENotificationPresenter.swift @@ -162,7 +162,7 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return } guard thread.variant != .closedGroup && thread.variant != .openGroup else { return } guard - interaction.variant == .infoMessageCall, + interaction.variant == .infoCall, let infoMessageData: Data = (interaction.body ?? "").data(using: .utf8), let messageInfo: CallMessage.MessageInfo = try? JSONDecoder().decode( CallMessage.MessageInfo.self, diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index 6314368d3..446d1048a 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -1,11 +1,15 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import CallKit import UserNotifications import BackgroundTasks +import PromiseKit import SessionMessagingKit import SignalUtilitiesKit -import CallKit -import PromiseKit -public final class NotificationServiceExtension : UNNotificationServiceExtension { +public final class NotificationServiceExtension: UNNotificationServiceExtension { private var didPerformSetup = false private var areVersionMigrationsComplete = false private var contentHandler: ((UNNotificationContent) -> Void)? @@ -38,14 +42,13 @@ public final class NotificationServiceExtension : UNNotificationServiceExtension // Handle the push notification AppReadiness.runNowOrWhenAppDidBecomeReady { - let openGorupPollingPromises = self.pollForOpenGroups() + let openGroupPollingPromises = self.pollForOpenGroups() defer { - when(resolved: openGorupPollingPromises).done { _ in + when(resolved: openGroupPollingPromises).done { _ in self.completeSilenty() } } - let notificationContent = self.notificationContent! guard let base64EncodedData: String = notificationContent.userInfo["ENCRYPTED_DATA"] as? String, let data: Data = Data(base64Encoded: base64EncodedData), @@ -97,31 +100,30 @@ public final class NotificationServiceExtension : UNNotificationServiceExtension try MessageReceiver.handleClosedGroupControlMessage(db, closedGroupControlMessage) case let callMessage as CallMessage: - MessageReceiver.handleCallMessage(callMessage, using: transaction) + try MessageReceiver.handleCallMessage(db, message: callMessage) guard case .preOffer = callMessage.kind else { return self.completeSilenty() } - if !SSKPreferences.areCallsEnabled { - if let sender = callMessage.sender, let thread = TSContactThread.fetch(for: sender, using: transaction), !thread.isMessageRequest(using: transaction) { - self.insertCallInfoMessage(for: callMessage, in: thread, reason: .permissionDenied, using: transaction) + if db[.areCallsEnabled] { + if let sender: String = callMessage.sender, let interaction: Interaction = try MessageReceiver.insertCallInfoMessage(db, for: callMessage, state: .permissionDenied) { + let thread: SessionThread = try SessionThread.fetchOrCreate(db, id: sender, variant: .contact) + + Environment.shared.notificationsManager.wrappedValue? + .notifyUser( + db, + forIncomingCall: interaction, + in: thread + ) } break } if isCallOngoing { - if let sender = callMessage.sender, let thread = TSContactThread.fetch(for: sender, using: transaction), !thread.isMessageRequest(using: transaction) { - // Handle call in busy state - let message = CallMessage() - message.uuid = callMessage.uuid - message.kind = .endCall - SNLog("[Calls] Sending end call message because there is an ongoing call.") - MessageSender.sendNonDurably(message, in: thread, using: transaction).retainUntilComplete() - self.insertCallInfoMessage(for: callMessage, in: thread, reason: .missed, using: transaction) - } + try MessageReceiver.handleIncomingCallOfferInBusyState(db, message: callMessage) break } - self.handleSuccessForIncomingCall(for: callMessage, using: transaction) + self.handleSuccessForIncomingCall(db, for: callMessage) default: break } @@ -137,21 +139,6 @@ public final class NotificationServiceExtension : UNNotificationServiceExtension } } } - - private func insertCallInfoMessage(for message: CallMessage, in thread: TSThread, reason: TSInfoMessageCallState, using transaction: YapDatabaseReadWriteTransaction) { - guard let sender = message.sender, let uuid = message.uuid else { return } - - var receivedCalls = Storage.shared.getReceivedCalls(for: sender, using: transaction) - - if !receivedCalls.contains(uuid) { - let infoMessage = TSInfoMessage.from(message, associatedWith: thread) - infoMessage.updateCallInfoMessage(reason, using: transaction) - SSKEnvironment.shared.notificationsManager?.notifyUser(forIncomingCall: infoMessage, in: thread, transaction: transaction) - receivedCalls.insert(uuid) - - Storage.shared.setReceivedCalls(to: receivedCalls, for: sender, using: transaction) - } - } // MARK: Setup @@ -237,26 +224,33 @@ public final class NotificationServiceExtension : UNNotificationServiceExtension self.contentHandler!(.init()) } - private func handleSuccessForIncomingCall(for callMessage: CallMessage, using transaction: YapDatabaseReadWriteTransaction) { - if #available(iOSApplicationExtension 14.5, *), SSKPreferences.isCallKitSupported { - if let uuid = callMessage.uuid, let caller = callMessage.sender, let timestamp = callMessage.sentTimestamp { - let payload: JSON = ["uuid": uuid, "caller": caller, "timestamp": timestamp] - CXProvider.reportNewIncomingVoIPPushPayload(payload) { error in - if let error = error { - self.handleFailureForVoIP(for: callMessage, using: transaction) - SNLog("Failed to notify main app of call message: \(error)") - } else { - self.completeSilenty() - SNLog("Successfully notified main app of call message.") - } + private func handleSuccessForIncomingCall(_ db: Database, for callMessage: CallMessage) { + if #available(iOSApplicationExtension 14.5, *), Preferences.isCallKitSupported { + guard let caller: String = callMessage.sender, let timestamp = callMessage.sentTimestamp else { return } + + let payload: JSON = [ + "uuid": callMessage.uuid, + "caller": caller, + "timestamp": timestamp + ] + + CXProvider.reportNewIncomingVoIPPushPayload(payload) { error in + if let error = error { + self.handleFailureForVoIP(db, for: callMessage) + SNLog("Failed to notify main app of call message: \(error)") + } + else { + self.completeSilenty() + SNLog("Successfully notified main app of call message.") } } - } else { - self.handleFailureForVoIP(for: callMessage, using: transaction) + } + else { + self.handleFailureForVoIP(db, for: callMessage) } } - private func handleFailureForVoIP(for callMessage: CallMessage, using transaction: YapDatabaseReadWriteTransaction) { + private func handleFailureForVoIP(_ db: Database, for callMessage: CallMessage) { let notificationContent = UNMutableNotificationContent() notificationContent.userInfo = [ NotificationServiceExtension.isFromRemoteKey : true ] notificationContent.title = "Session" @@ -266,15 +260,18 @@ public final class NotificationServiceExtension : UNNotificationServiceExtension notificationContent.badge = NSNumber(value: newBadgeNumber) CurrentAppContext().appUserDefaults().set(newBadgeNumber, forKey: "currentBadgeNumber") - if let sender = callMessage.sender, let contact = Storage.shared.getContact(with: sender, using: transaction) { - let senderDisplayName = contact.displayName(for: .regular) ?? sender + if let sender: String = callMessage.sender { + let senderDisplayName: String = Profile.displayName(db, id: sender, threadVariant: .contact) notificationContent.body = "\(senderDisplayName) is calling..." - } else { + } + else { notificationContent.body = "Incoming call..." } + let identifier = self.request?.identifier ?? UUID().uuidString let request = UNNotificationRequest(identifier: identifier, content: notificationContent, trigger: nil) let semaphore = DispatchSemaphore(value: 0) + UNUserNotificationCenter.current().add(request) { error in if let error = error { SNLog("Failed to add notification request due to error:\(error)") @@ -297,16 +294,27 @@ public final class NotificationServiceExtension : UNNotificationServiceExtension contentHandler!(content) } - // MARK: Poll for open groups + // MARK: - Poll for open groups private func pollForOpenGroups() -> [Promise] { - var promises: [Promise] = [] - let servers = Set(Storage.shared.getAllOpenGroups().values.map { $0.server }) - servers.forEach { server in - let poller = OpenGroupAPI.Poller(for: server) - let promise = poller.poll(isBackgroundPoll: true).timeout(seconds: 20, timeoutError: NotificationServiceError.timeout) - promises.append(promise) - } + let promises: [Promise] = GRDBStorage.shared + .read { db in + try OpenGroup + .select(.server) + .distinct() + .asRequest(of: String.self) + .fetchSet(db) + } + .defaulting(to: []) + .map { server in + OpenGroupAPI.Poller(for: server) + .poll(isBackgroundPoll: true) + .timeout( + seconds: 20, + timeoutError: NotificationServiceError.timeout + ) + } + return promises } diff --git a/SessionSnodeKit/Database/Models/Snode.swift b/SessionSnodeKit/Database/Models/Snode.swift index d6303394d..9bcf6bc8c 100644 --- a/SessionSnodeKit/Database/Models/Snode.swift +++ b/SessionSnodeKit/Database/Models/Snode.swift @@ -49,7 +49,7 @@ extension Snode { do { let address: String = try container.decode(String.self, forKey: .address) - guard address != "0.0.0.0" else { throw SnodeAPI.Error.invalidIP } + guard address != "0.0.0.0" else { throw SnodeAPIError.invalidIP } self = Snode( address: (address.starts(with: "https://") ? address : "https://\(address)"), diff --git a/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift b/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift index 4d1b8cf96..ee65b75c9 100644 --- a/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift +++ b/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift @@ -43,17 +43,22 @@ public struct SnodeReceivedMessageInfo: Codable, FetchableRecord, MutablePersist // MARK: - Convenience public extension SnodeReceivedMessageInfo { - private static func key(for snode: Snode, publicKey: String) -> String { - return "\(snode.address):\(snode.port).\(publicKey)" + private static func key(for snode: Snode, publicKey: String, namespace: Int) -> String { + guard namespace != SnodeAPI.defaultNamespace else { + return "\(snode.address):\(snode.port).\(publicKey)" + } + + return "\(snode.address):\(snode.port).\(publicKey).\(namespace)" } init( snode: Snode, publicKey: String, + namespace: Int, hash: String, expirationDateMs: Int64? ) { - self.key = SnodeReceivedMessageInfo.key(for: snode, publicKey: publicKey) + self.key = SnodeReceivedMessageInfo.key(for: snode, publicKey: publicKey, namespace: namespace) self.hash = hash self.expirationDateMs = (expirationDateMs ?? 0) } @@ -62,19 +67,19 @@ public extension SnodeReceivedMessageInfo { // MARK: - GRDB Interactions public extension SnodeReceivedMessageInfo { - static func pruneExpiredMessageHashInfo(for snode: Snode, associatedWith publicKey: String) { + static func pruneExpiredMessageHashInfo(for snode: Snode, namespace: Int, associatedWith publicKey: String) { // Delete any expired SnodeReceivedMessageInfo values associated to a specific node GRDBStorage.shared.write { db in // Only prune the hashes if new hashes exist for this Snode (if they don't then we don't want // to clear out the legacy hashes) let hasNonLegacyHash: Bool = try SnodeReceivedMessageInfo - .filter(SnodeReceivedMessageInfo.Columns.key == key(for: snode, publicKey: publicKey)) + .filter(SnodeReceivedMessageInfo.Columns.key == key(for: snode, publicKey: publicKey, namespace: namespace)) .isNotEmpty(db) guard hasNonLegacyHash else { return } try SnodeReceivedMessageInfo - .filter(SnodeReceivedMessageInfo.Columns.key == key(for: snode, publicKey: publicKey)) + .filter(SnodeReceivedMessageInfo.Columns.key == key(for: snode, publicKey: publicKey, namespace: namespace)) .filter(SnodeReceivedMessageInfo.Columns.expirationDateMs <= (Date().timeIntervalSince1970 * 1000)) .deleteAll(db) } @@ -85,10 +90,10 @@ public extension SnodeReceivedMessageInfo { /// **Note:** This method uses a `write` instead of a read because there is a single write queue for the database and it's very common for /// this method to be called after the hash value has been updated but before the various `read` threads have been updated, resulting in a /// pointless fetch for data the app has already received - static func fetchLastNotExpired(for snode: Snode, associatedWith publicKey: String) -> SnodeReceivedMessageInfo? { - return GRDBStorage.shared.write { db in + static func fetchLastNotExpired(for snode: Snode, namespace: Int, associatedWith publicKey: String) -> SnodeReceivedMessageInfo? { + return GRDBStorage.shared.read { db in let nonLegacyHash: SnodeReceivedMessageInfo? = try SnodeReceivedMessageInfo - .filter(SnodeReceivedMessageInfo.Columns.key == key(for: snode, publicKey: publicKey)) + .filter(SnodeReceivedMessageInfo.Columns.key == key(for: snode, publicKey: publicKey, namespace: namespace)) .filter(SnodeReceivedMessageInfo.Columns.expirationDateMs > (Date().timeIntervalSince1970 * 1000)) .order(SnodeReceivedMessageInfo.Columns.id.desc) .fetchOne(db) diff --git a/SessionSnodeKit/Models/OnionRequestAPIDestination.swift b/SessionSnodeKit/Models/OnionRequestAPIDestination.swift index f879c1034..8483ce347 100644 --- a/SessionSnodeKit/Models/OnionRequestAPIDestination.swift +++ b/SessionSnodeKit/Models/OnionRequestAPIDestination.swift @@ -2,16 +2,14 @@ import Foundation -extension OnionRequestAPI { - public enum Destination: CustomStringConvertible { - case snode(Snode) - case server(host: String, target: String, x25519PublicKey: String, scheme: String?, port: UInt16?) - - public var description: String { - switch self { - case .snode(let snode): return "Service node \(snode.ip):\(snode.port)" - case .server(let host, _, _, _, _): return host - } +public enum OnionRequestAPIDestination: CustomStringConvertible { + case snode(Snode) + case server(host: String, target: String, x25519PublicKey: String, scheme: String?, port: UInt16?) + + public var description: String { + switch self { + case .snode(let snode): return "Service node \(snode.ip):\(snode.port)" + case .server(let host, _, _, _, _): return host } } } diff --git a/SessionSnodeKit/Models/OnionRequestAPIError.swift b/SessionSnodeKit/Models/OnionRequestAPIError.swift index 8bba1e66a..6f555542c 100644 --- a/SessionSnodeKit/Models/OnionRequestAPIError.swift +++ b/SessionSnodeKit/Models/OnionRequestAPIError.swift @@ -3,66 +3,28 @@ import Foundation import SessionUtilitiesKit -extension OnionRequestAPI { - public enum Error: LocalizedError { - case httpRequestFailedAtDestination(statusCode: UInt, data: Data, destination: Destination) - case insufficientSnodes - case invalidURL - case missingSnodeVersion - case snodePublicKeySetMissing - case unsupportedSnodeVersion(String) - case invalidRequestInfo +public enum OnionRequestAPIError: LocalizedError { + case httpRequestFailedAtDestination(statusCode: UInt, data: Data, destination: OnionRequestAPIDestination) + case insufficientSnodes + case invalidURL + case missingSnodeVersion + case snodePublicKeySetMissing + case unsupportedSnodeVersion(String) + case invalidRequestInfo - public var errorDescription: String? { - switch self { - case .httpRequestFailedAtDestination(let statusCode, _, let destination): - if statusCode == 429 { return "Rate limited." } - - return "HTTP request failed at destination (\(destination)) with status code: \(statusCode)." - - case .insufficientSnodes: return "Couldn't find enough Service Nodes to build a path." - case .invalidURL: return "Invalid URL" - case .missingSnodeVersion: return "Missing Service Node version." - case .snodePublicKeySetMissing: return "Missing Service Node public key set." - case .unsupportedSnodeVersion(let version): return "Unsupported Service Node version: \(version)." - case .invalidRequestInfo: return "Invalid Request Info" - } - } - } -} - -extension SnodeAPI { - public enum Error: LocalizedError { - case generic - case clockOutOfSync - case snodePoolUpdatingFailed - case inconsistentSnodePools - case noKeyPair - case signingFailed - case signatureVerificationFailed - case invalidIP - - // ONS - case decryptionFailed - case hashingFailed - case validationFailed - - public var errorDescription: String? { - switch self { - case .generic: return "An error occurred." - case .clockOutOfSync: return "Your clock is out of sync with the Service Node network. Please check that your device's clock is set to automatic time." - case .snodePoolUpdatingFailed: return "Failed to update the Service Node pool." - case .inconsistentSnodePools: return "Received inconsistent Service Node pool information from the Service Node network." - case .noKeyPair: return "Missing user key pair." - case .signingFailed: return "Couldn't sign message." - case .signatureVerificationFailed: return "Failed to verify the signature." - case .invalidIP: return "Invalid IP." - - // ONS - case .decryptionFailed: return "Couldn't decrypt ONS name." - case .hashingFailed: return "Couldn't compute ONS name hash." - case .validationFailed: return "ONS name validation failed." - } + public var errorDescription: String? { + switch self { + case .httpRequestFailedAtDestination(let statusCode, _, let destination): + if statusCode == 429 { return "Rate limited." } + + return "HTTP request failed at destination (\(destination)) with status code: \(statusCode)." + + case .insufficientSnodes: return "Couldn't find enough Service Nodes to build a path." + case .invalidURL: return "Invalid URL" + case .missingSnodeVersion: return "Missing Service Node version." + case .snodePublicKeySetMissing: return "Missing Service Node public key set." + case .unsupportedSnodeVersion(let version): return "Unsupported Service Node version: \(version)." + case .invalidRequestInfo: return "Invalid Request Info" } } } diff --git a/SessionSnodeKit/Models/SnodeAPIError.swift b/SessionSnodeKit/Models/SnodeAPIError.swift index 0a8ab0dac..60403b0ec 100644 --- a/SessionSnodeKit/Models/SnodeAPIError.swift +++ b/SessionSnodeKit/Models/SnodeAPIError.swift @@ -1,3 +1,37 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation + +public enum SnodeAPIError: LocalizedError { + case generic + case clockOutOfSync + case snodePoolUpdatingFailed + case inconsistentSnodePools + case noKeyPair + case signingFailed + case signatureVerificationFailed + case invalidIP + + // ONS + case decryptionFailed + case hashingFailed + case validationFailed + + public var errorDescription: String? { + switch self { + case .generic: return "An error occurred." + case .clockOutOfSync: return "Your clock is out of sync with the Service Node network. Please check that your device's clock is set to automatic time." + case .snodePoolUpdatingFailed: return "Failed to update the Service Node pool." + case .inconsistentSnodePools: return "Received inconsistent Service Node pool information from the Service Node network." + case .noKeyPair: return "Missing user key pair." + case .signingFailed: return "Couldn't sign message." + case .signatureVerificationFailed: return "Failed to verify the signature." + case .invalidIP: return "Invalid IP." + + // ONS + case .decryptionFailed: return "Couldn't decrypt ONS name." + case .hashingFailed: return "Couldn't compute ONS name hash." + case .validationFailed: return "ONS name validation failed." + } + } +} diff --git a/SessionSnodeKit/Models/SnodePoolResponse.swift b/SessionSnodeKit/Models/SnodePoolResponse.swift index 0a8ab0dac..d91e59183 100644 --- a/SessionSnodeKit/Models/SnodePoolResponse.swift +++ b/SessionSnodeKit/Models/SnodePoolResponse.swift @@ -1,3 +1,16 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import SessionUtilitiesKit + +struct SnodePoolResponse: Codable { + struct SnodePool: Codable { + public enum CodingKeys: String, CodingKey { + case serviceNodeStates = "service_node_states" + } + + let serviceNodeStates: [Failable] + } + + let result: SnodePool +} diff --git a/SessionSnodeKit/Models/SnodeReceivedMessage.swift b/SessionSnodeKit/Models/SnodeReceivedMessage.swift index 85d1b0d9e..bf2832f5c 100644 --- a/SessionSnodeKit/Models/SnodeReceivedMessage.swift +++ b/SessionSnodeKit/Models/SnodeReceivedMessage.swift @@ -11,7 +11,7 @@ public struct SnodeReceivedMessage: CustomDebugStringConvertible { public let info: SnodeReceivedMessageInfo public let data: Data - init?(snode: Snode, publicKey: String, rawMessage: JSON) { + init?(snode: Snode, publicKey: String, namespace: Int, rawMessage: JSON) { guard let hash: String = rawMessage["hash"] as? String else { return nil } guard @@ -26,6 +26,7 @@ public struct SnodeReceivedMessage: CustomDebugStringConvertible { self.info = SnodeReceivedMessageInfo( snode: snode, publicKey: publicKey, + namespace: namespace, hash: hash, expirationDateMs: (expirationDateMs ?? SnodeReceivedMessage.defaultExpirationSeconds) ) diff --git a/SessionSnodeKit/Models/SwarmSnode.swift b/SessionSnodeKit/Models/SwarmSnode.swift index d4adc2ded..727d79191 100644 --- a/SessionSnodeKit/Models/SwarmSnode.swift +++ b/SessionSnodeKit/Models/SwarmSnode.swift @@ -29,7 +29,7 @@ extension SwarmSnode { do { let address: String = try container.decode(String.self, forKey: .address) - guard address != "0.0.0.0" else { throw SnodeAPI.Error.invalidIP } + guard address != "0.0.0.0" else { throw SnodeAPIError.invalidIP } self = SwarmSnode( address: (address.starts(with: "https://") ? address : "https://\(address)"), diff --git a/SessionSnodeKit/OnionRequestAPI+Encryption.swift b/SessionSnodeKit/OnionRequestAPI+Encryption.swift index de71576c4..d3f3f8c89 100644 --- a/SessionSnodeKit/OnionRequestAPI+Encryption.swift +++ b/SessionSnodeKit/OnionRequestAPI+Encryption.swift @@ -16,7 +16,7 @@ internal extension OnionRequestAPI { } /// Encrypts `payload` for `destination` and returns the result. Use this to build the core of an onion request. - static func encrypt(_ payload: Data, for destination: Destination, with version: Version) -> Promise { + static func encrypt(_ payload: Data, for destination: OnionRequestAPIDestination, with version: OnionRequestAPIVersion) -> Promise { let (promise, seal) = Promise.pending() DispatchQueue.global(qos: .userInitiated).async { do { @@ -45,7 +45,7 @@ internal extension OnionRequestAPI { return promise } - private static func encrypt(_ payload: Data, for destination: Destination) throws -> AESGCM.EncryptionResult { + private static func encrypt(_ payload: Data, for destination: OnionRequestAPIDestination) throws -> AESGCM.EncryptionResult { switch destination { case .snode(let snode): return try AESGCM.encrypt(payload, for: snode.x25519PublicKey) @@ -56,36 +56,46 @@ internal extension OnionRequestAPI { } /// Encrypts the previous encryption result (i.e. that of the hop after this one) for this hop. Use this to build the layers of an onion request. - static func encryptHop(from lhs: Destination, to rhs: Destination, using previousEncryptionResult: AESGCM.EncryptionResult) -> Promise { + static func encryptHop(from lhs: OnionRequestAPIDestination, to rhs: OnionRequestAPIDestination, using previousEncryptionResult: AESGCM.EncryptionResult) -> Promise { let (promise, seal) = Promise.pending() + DispatchQueue.global(qos: .userInitiated).async { var parameters: JSON + switch rhs { - case .snode(let snode): - let snodeED25519PublicKey = snode.ed25519PublicKey - parameters = [ "destination" : snodeED25519PublicKey ] - case .server(let host, let target, _, let scheme, let port): - let scheme = scheme ?? "https" - let port = port ?? (scheme == "https" ? 443 : 80) - parameters = [ "host" : host, "target" : target, "method" : "POST", "protocol" : scheme, "port" : port ] + case .snode(let snode): + let snodeED25519PublicKey = snode.ed25519PublicKey + parameters = [ "destination" : snodeED25519PublicKey ] + + case .server(let host, let target, _, let scheme, let port): + let scheme = scheme ?? "https" + let port = port ?? (scheme == "https" ? 443 : 80) + parameters = [ "host" : host, "target" : target, "method" : "POST", "protocol" : scheme, "port" : port ] } + parameters["ephemeral_key"] = previousEncryptionResult.ephemeralPublicKey.toHexString() + let x25519PublicKey: String + switch lhs { - case .snode(let snode): - let snodeX25519PublicKey = snode.x25519PublicKey - x25519PublicKey = snodeX25519PublicKey - case .server(_, _, let serverX25519PublicKey, _, _): - x25519PublicKey = serverX25519PublicKey + case .snode(let snode): + let snodeX25519PublicKey = snode.x25519PublicKey + x25519PublicKey = snodeX25519PublicKey + + case .server(_, _, let serverX25519PublicKey, _, _): + x25519PublicKey = serverX25519PublicKey } + do { let plaintext = try encode(ciphertext: previousEncryptionResult.ciphertext, json: parameters) let result = try AESGCM.encrypt(plaintext, for: x25519PublicKey) seal.fulfill(result) - } catch (let error) { + } + catch (let error) { seal.reject(error) } } + return promise } } diff --git a/SessionSnodeKit/OnionRequestAPI.swift b/SessionSnodeKit/OnionRequestAPI.swift index c9832abfa..860c0af76 100644 --- a/SessionSnodeKit/OnionRequestAPI.swift +++ b/SessionSnodeKit/OnionRequestAPI.swift @@ -5,8 +5,8 @@ import PromiseKit import SessionUtilitiesKit public protocol OnionRequestAPIType { - static func sendOnionRequest(to snode: Snode, invoking method: Snode.Method, with parameters: JSON, using version: OnionRequestAPI.Version, associatedWith publicKey: String?) -> Promise - static func sendOnionRequest(_ request: URLRequest, to server: String, using version: OnionRequestAPI.Version, with x25519PublicKey: String) -> Promise<(OnionRequestResponseInfoType, Data?)> + static func sendOnionRequest(to snode: Snode, invoking method: SnodeAPIEndpoint, with parameters: JSON, using version: OnionRequestAPIVersion, associatedWith publicKey: String?) -> Promise + static func sendOnionRequest(_ request: URLRequest, to server: String, using version: OnionRequestAPIVersion, with x25519PublicKey: String) -> Promise<(OnionRequestResponseInfoType, Data?)> } public extension OnionRequestAPIType { @@ -78,7 +78,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { throw HTTP.Error.invalidJSON } guard let version = responseJson["version"] as? String else { - return seal.reject(Error.missingSnodeVersion) + return seal.reject(OnionRequestAPIError.missingSnodeVersion) } if version >= "2.0.7" { @@ -86,7 +86,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { } else { SNLog("Unsupported snode version: \(version).") - seal.reject(Error.unsupportedSnodeVersion(version)) + seal.reject(OnionRequestAPIError.unsupportedSnodeVersion(version)) } } .catch2 { error in @@ -110,14 +110,14 @@ public enum OnionRequestAPI: OnionRequestAPIType { let reusableGuardSnodeCount = UInt(reusableGuardSnodes.count) guard unusedSnodes.count >= (targetGuardSnodeCount - reusableGuardSnodeCount) else { - return Promise(error: Error.insufficientSnodes) + return Promise(error: OnionRequestAPIError.insufficientSnodes) } func getGuardSnode() -> Promise { // randomElement() uses the system's default random generator, which // is cryptographically secure guard let candidate = unusedSnodes.randomElement() else { - return Promise { $0.reject(Error.insufficientSnodes) } + return Promise { $0.reject(OnionRequestAPIError.insufficientSnodes) } } unusedSnodes.remove(candidate) // All used snodes should be unique @@ -160,7 +160,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { let reusableGuardSnodeCount = UInt(reusableGuardSnodes.count) let pathSnodeCount = (targetGuardSnodeCount - reusableGuardSnodeCount) * pathSize - (targetGuardSnodeCount - reusableGuardSnodeCount) - guard unusedSnodes.count >= pathSnodeCount else { throw Error.insufficientSnodes } + guard unusedSnodes.count >= pathSnodeCount else { throw OnionRequestAPIError.insufficientSnodes } // Don't test path snodes as this would reveal the user's IP to them return guardSnodes.subtracting(reusableGuardSnodes).map { guardSnode in @@ -242,7 +242,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { return path } - throw Error.insufficientSnodes + throw OnionRequestAPIError.insufficientSnodes } return paths.randomElement()! @@ -271,7 +271,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { guard let snodeIndex = path.firstIndex(of: snode) else { return } path.remove(at: snodeIndex) let unusedSnodes = SnodeAPI.snodePool.subtracting(oldPaths.flatMap { $0 }) - guard !unusedSnodes.isEmpty else { throw Error.insufficientSnodes } + guard !unusedSnodes.isEmpty else { throw OnionRequestAPIError.insufficientSnodes } // randomElement() uses the system's default random generator, which is cryptographically secure path.append(unusedSnodes.randomElement()!) // Don't test the new snode as this would reveal the user's IP @@ -308,7 +308,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { } /// Builds an onion around `payload` and returns the result. - private static func buildOnion(around payload: Data, targetedAt destination: Destination, version: Version) -> Promise { + private static func buildOnion(around payload: Data, targetedAt destination: OnionRequestAPIDestination, version: OnionRequestAPIVersion) -> Promise { var guardSnode: Snode! var targetSnodeSymmetricKey: Data! // Needed by invoke(_:on:with:) to decrypt the response sent back by the destination var encryptionResult: AESGCM.EncryptionResult! @@ -321,7 +321,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { guardSnode = path.first! // Encrypt in reverse order, i.e. the destination first - return encrypt(payload, for: destination) + return encrypt(payload, for: destination, with: version) .then2 { r -> Promise in targetSnodeSymmetricKey = r.symmetricKey @@ -335,7 +335,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { return Promise { $0.fulfill(encryptionResult) } } - let lhs = Destination.snode(path.removeLast()) + let lhs = OnionRequestAPIDestination.snode(path.removeLast()) return OnionRequestAPI .encryptHop(from: lhs, to: rhs, using: encryptionResult) .then2 { r -> Promise in @@ -354,21 +354,21 @@ public enum OnionRequestAPI: OnionRequestAPIType { // MARK: - Public API /// Sends an onion request to `snode`. Builds new paths as needed. - public static func sendOnionRequest(to snode: Snode, invoking method: SnodeAPI.Endpoint, with parameters: JSON, using version: Version, associatedWith publicKey: String? = nil) -> Promise { - let payload: JSON = [ "method" : method.rawValue, "params" : parameters ] + public static func sendOnionRequest(to snode: Snode, invoking method: SnodeAPIEndpoint, with parameters: JSON, using version: OnionRequestAPIVersion, associatedWith publicKey: String? = nil) -> Promise { + let payloadJson: JSON = [ "method" : method.rawValue, "params" : parameters ] guard let payload: Data = try? JSONSerialization.data(withJSONObject: payloadJson, options: []) else { return Promise(error: HTTP.Error.invalidJSON) } - return sendOnionRequest(with: payload, to: Destination.snode(snode), version: version) + return sendOnionRequest(with: payload, to: OnionRequestAPIDestination.snode(snode), version: version) .map { _, maybeData in guard let data: Data = maybeData else { throw HTTP.Error.invalidResponse } return data } - .recover2 { error -> Promise in - guard case OnionRequestAPI.Error.httpRequestFailedAtDestination(let statusCode, let json, _) = error else { + .recover2 { error -> Promise in + guard case OnionRequestAPIError.httpRequestFailedAtDestination(let statusCode, let data, _) = error else { throw error } @@ -377,17 +377,25 @@ public enum OnionRequestAPI: OnionRequestAPIType { } /// Sends an onion request to `server`. Builds new paths as needed. - public static func sendOnionRequest(_ request: URLRequest, to server: String, using version: Version = .v4, with x25519PublicKey: String) -> Promise<(OnionRequestResponseInfoType, Data?)> { - guard let url = request.url, let host = request.url?.host else { return Promise(error: Error.invalidURL) } + public static func sendOnionRequest(_ request: URLRequest, to server: String, using version: OnionRequestAPIVersion = .v4, with x25519PublicKey: String) -> Promise<(OnionRequestResponseInfoType, Data?)> { + guard let url = request.url, let host = request.url?.host else { + return Promise(error: OnionRequestAPIError.invalidURL) + } let scheme: String? = url.scheme let port: UInt16? = url.port.map { UInt16($0) } guard let payload: Data = generatePayload(for: request, with: version) else { - return Promise(error: Error.invalidRequestInfo) + return Promise(error: OnionRequestAPIError.invalidRequestInfo) } - let destination = Destination.server(host: host, target: version.rawValue, x25519PublicKey: x25519PublicKey, scheme: scheme, port: port) + let destination = OnionRequestAPIDestination.server( + host: host, + target: version.rawValue, + x25519PublicKey: x25519PublicKey, + scheme: scheme, + port: port + ) let promise = sendOnionRequest(with: payload, to: destination, version: version) promise.catch2 { error in SNLog("Couldn't reach server: \(url) due to error: \(error).") @@ -395,16 +403,17 @@ public enum OnionRequestAPI: OnionRequestAPIType { return promise } - public static func sendOnionRequest(with payload: Data, to destination: Destination, version: Version) -> Promise<(OnionRequestResponseInfoType, Data?)> { + public static func sendOnionRequest(with payload: Data, to destination: OnionRequestAPIDestination, version: OnionRequestAPIVersion) -> Promise<(OnionRequestResponseInfoType, Data?)> { let (promise, seal) = Promise<(OnionRequestResponseInfoType, Data?)>.pending() var guardSnode: Snode? + Threading.workQueue.async { // Avoid race conditions on `guardSnodes` and `paths` buildOnion(around: payload, targetedAt: destination, version: version).done2 { intermediate in guardSnode = intermediate.guardSnode let url = "\(guardSnode!.address):\(guardSnode!.port)/onion_req/v2" let finalEncryptionResult = intermediate.finalEncryptionResult let onion = finalEncryptionResult.ciphertext - if case Destination.server = destination, Double(onion.count) > 0.75 * Double(maxRequestSize) { + if case OnionRequestAPIDestination.server = destination, Double(onion.count) > 0.75 * Double(maxRequestSize) { SNLog("Approaching request size limit: ~\(onion.count) bytes.") } let parameters: JSON = [ @@ -519,7 +528,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { // MARK: - Version Handling - private static func generatePayload(for request: URLRequest, with version: Version) -> Data? { + private static func generatePayload(for request: URLRequest, with version: OnionRequestAPIVersion) -> Data? { guard let url = request.url else { return nil } switch version { @@ -596,8 +605,8 @@ public enum OnionRequestAPI: OnionRequestAPIType { private static func handleResponse( responseData: Data, destinationSymmetricKey: Data, - version: Version, - destination: Destination, + version: OnionRequestAPIVersion, + destination: OnionRequestAPIDestination, seal: Resolver<(OnionRequestResponseInfoType, Data?)> ) { switch version { @@ -628,12 +637,12 @@ public enum OnionRequestAPI: OnionRequestAPIType { if statusCode == 406 { // Clock out of sync SNLog("The user's clock is out of sync with the service node network.") - return seal.reject(SnodeAPI.Error.clockOutOfSync) + return seal.reject(SnodeAPIError.clockOutOfSync) } if statusCode == 401 { // Signature verification failed SNLog("Failed to verify the signature.") - return seal.reject(SnodeAPI.Error.signatureVerificationFailed) + return seal.reject(SnodeAPIError.signatureVerificationFailed) } if let bodyAsString = json["body"] as? String { @@ -647,14 +656,14 @@ public enum OnionRequestAPI: OnionRequestAPIType { } guard 200...299 ~= statusCode else { - return seal.reject(Error.httpRequestFailedAtDestination(statusCode: UInt(statusCode), data: bodyAsData, destination: destination)) + return seal.reject(OnionRequestAPIError.httpRequestFailedAtDestination(statusCode: UInt(statusCode), data: bodyAsData, destination: destination)) } return seal.fulfill((OnionRequestAPI.ResponseInfo(code: statusCode, headers: [:]), bodyAsData)) } guard 200...299 ~= statusCode else { - return seal.reject(Error.httpRequestFailedAtDestination(statusCode: UInt(statusCode), data: data, destination: destination)) + return seal.reject(OnionRequestAPIError.httpRequestFailedAtDestination(statusCode: UInt(statusCode), data: data, destination: destination)) } return seal.fulfill((OnionRequestAPI.ResponseInfo(code: statusCode, headers: [:]), data)) @@ -694,18 +703,18 @@ public enum OnionRequestAPI: OnionRequestAPIType { // Custom handle a clock out of sync error (v4 returns '425' but included the '406' just in case) guard responseInfo.code != 406 && responseInfo.code != 425 else { SNLog("The user's clock is out of sync with the service node network.") - return seal.reject(SnodeAPI.Error.clockOutOfSync) + return seal.reject(SnodeAPIError.clockOutOfSync) } guard responseInfo.code != 401 else { // Signature verification failed SNLog("Failed to verify the signature.") - return seal.reject(SnodeAPI.Error.signatureVerificationFailed) + return seal.reject(SnodeAPIError.signatureVerificationFailed) } // Handle error status codes guard 200...299 ~= responseInfo.code else { return seal.reject( - Error.httpRequestFailedAtDestination( + OnionRequestAPIError.httpRequestFailedAtDestination( statusCode: UInt(responseInfo.code), data: data, destination: destination diff --git a/SessionSnodeKit/SnodeAPI.swift b/SessionSnodeKit/SnodeAPI.swift index d4537477d..1bf2e988e 100644 --- a/SessionSnodeKit/SnodeAPI.swift +++ b/SessionSnodeKit/SnodeAPI.swift @@ -1,10 +1,12 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation import PromiseKit import Sodium import GRDB import SessionUtilitiesKit -@objc(SNSnodeAPI) -public final class SnodeAPI : NSObject { +public final class SnodeAPI { private static let sodium = Sodium() private static var hasLoadedSnodePool = false @@ -125,7 +127,7 @@ public final class SnodeAPI : NSObject { // MARK: Internal API - internal static func invoke(_ method: Snode.Method, on snode: Snode, associatedWith publicKey: String? = nil, parameters: JSON) -> Promise { + internal static func invoke(_ method: SnodeAPIEndpoint, on snode: Snode, associatedWith publicKey: String? = nil, parameters: JSON) -> Promise { if Features.useOnionRequests { return OnionRequestAPI .sendOnionRequest( @@ -207,7 +209,7 @@ public final class SnodeAPI : NSObject { .map2 { responseData -> Set in // TODO: Validate this works guard let snodePool: SnodePoolResponse = try? JSONDecoder().decode(SnodePoolResponse.self, from: responseData) else { - throw Error.snodePoolUpdatingFailed + throw SnodeAPIError.snodePoolUpdatingFailed } return snodePool.result @@ -261,7 +263,7 @@ public final class SnodeAPI : NSObject { .map2 { responseData in // TODO: Validate this works guard let snodePool: SnodePoolResponse = try? JSONDecoder().decode(SnodePoolResponse.self, from: responseData) else { - throw Error.snodePoolUpdatingFailed + throw SnodeAPIError.snodePoolUpdatingFailed } return snodePool.result @@ -276,7 +278,7 @@ public final class SnodeAPI : NSObject { let result: Set = results.reduce(Set()) { prev, next in prev.intersection(next) } // We want the snodes to agree on at least this many snodes - guard result.count > 24 else { throw Error.inconsistentSnodePools } + guard result.count > 24 else { throw SnodeAPIError.inconsistentSnodePools } // Limit the snode pool size to 256 so that we don't go too long without // refreshing it @@ -317,7 +319,7 @@ public final class SnodeAPI : NSObject { getSnodePoolPromise = promise promise.map2 { snodePool -> Set in - guard !snodePool.isEmpty else { throw Error.snodePoolUpdatingFailed } + guard !snodePool.isEmpty else { throw SnodeAPIError.snodePoolUpdatingFailed } return snodePool } @@ -357,7 +359,10 @@ public final class SnodeAPI : NSObject { let onsName = onsName.lowercased() // Hash the ONS name using BLAKE2b let nameAsData = [UInt8](onsName.data(using: String.Encoding.utf8)!) - guard let nameHash = sodium.genericHash.hash(message: nameAsData) else { return Promise(error: Error.hashingFailed) } + + guard let nameHash = sodium.genericHash.hash(message: nameAsData) else { + return Promise(error: SnodeAPIError.hashingFailed) + } // Ask 3 different snodes for the Session ID associated with the given name hash let base64EncodedNameHash = nameHash.toBase64() @@ -376,49 +381,77 @@ public final class SnodeAPI : NSObject { } } let (promise, seal) = Promise.pending() + when(resolved: promises).done2 { results in var sessionIDs: [String] = [] for result in results { switch result { - case .rejected(let error): return seal.reject(error) - case .fulfilled(let responseData): - guard let responseJson: JSON = try? JSONSerialization.jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? JSON else { - throw HTTP.Error.invalidJSON - } - guard let intermediate = responseJson["result"] as? JSON, - let hexEncodedCiphertext = intermediate["encrypted_value"] as? String else { return seal.reject(HTTP.Error.invalidJSON) } - let ciphertext = [UInt8](Data(hex: hexEncodedCiphertext)) - let isArgon2Based = (intermediate["nonce"] == nil) - if isArgon2Based { - // Handle old Argon2-based encryption used before HF16 - let salt = [UInt8](Data(repeating: 0, count: sodium.pwHash.SaltBytes)) - guard let key = sodium.pwHash.hash(outputLength: sodium.secretBox.KeyBytes, passwd: nameAsData, salt: salt, - opsLimit: sodium.pwHash.OpsLimitModerate, memLimit: sodium.pwHash.MemLimitModerate, alg: .Argon2ID13) else { return seal.reject(Error.hashingFailed) } - let nonce = [UInt8](Data(repeating: 0, count: sodium.secretBox.NonceBytes)) - guard let sessionIDAsData = sodium.secretBox.open(authenticatedCipherText: ciphertext, secretKey: key, nonce: nonce) else { - return seal.reject(Error.decryptionFailed) + case .rejected(let error): return seal.reject(error) + + case .fulfilled(let responseData): + guard let responseJson: JSON = try? JSONSerialization.jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? JSON else { + throw HTTP.Error.invalidJSON } - sessionIDs.append(sessionIDAsData.toHexString()) - } else { - guard let hexEncodedNonce = intermediate["nonce"] as? String else { return seal.reject(HTTP.Error.invalidJSON) } - let nonce = [UInt8](Data(hex: hexEncodedNonce)) - // xchacha-based encryption - guard let key = sodium.genericHash.hash(message: nameAsData, key: nameHash) else { // key = H(name, key=H(name)) - return seal.reject(Error.hashingFailed) + guard + let intermediate = responseJson["result"] as? JSON, + let hexEncodedCiphertext = intermediate["encrypted_value"] as? String + else { return seal.reject(HTTP.Error.invalidJSON) } + + let ciphertext = [UInt8](Data(hex: hexEncodedCiphertext)) + let isArgon2Based = (intermediate["nonce"] == nil) + + if isArgon2Based { + // Handle old Argon2-based encryption used before HF16 + let salt = [UInt8](Data(repeating: 0, count: sodium.pwHash.SaltBytes)) + guard + let key = sodium.pwHash.hash( + outputLength: sodium.secretBox.KeyBytes, + passwd: nameAsData, + salt: salt, + opsLimit: sodium.pwHash.OpsLimitModerate, + memLimit: sodium.pwHash.MemLimitModerate, + alg: .Argon2ID13 + ) + else { return seal.reject(SnodeAPIError.hashingFailed) } + + let nonce = [UInt8](Data(repeating: 0, count: sodium.secretBox.NonceBytes)) + + guard let sessionIDAsData = sodium.secretBox.open(authenticatedCipherText: ciphertext, secretKey: key, nonce: nonce) else { + return seal.reject(SnodeAPIError.decryptionFailed) + } + + sessionIDs.append(sessionIDAsData.toHexString()) } - guard ciphertext.count >= (sessionIDByteCount + sodium.aead.xchacha20poly1305ietf.ABytes) else { // Should always be equal in practice - return seal.reject(Error.decryptionFailed) + else { + guard let hexEncodedNonce = intermediate["nonce"] as? String else { + return seal.reject(HTTP.Error.invalidJSON) + } + + let nonce = [UInt8](Data(hex: hexEncodedNonce)) + + // xchacha-based encryption + guard let key = sodium.genericHash.hash(message: nameAsData, key: nameHash) else { // key = H(name, key=H(name)) + return seal.reject(SnodeAPIError.hashingFailed) + } + guard ciphertext.count >= (sessionIDByteCount + sodium.aead.xchacha20poly1305ietf.ABytes) else { // Should always be equal in practice + return seal.reject(SnodeAPIError.decryptionFailed) + } + guard let sessionIDAsData = sodium.aead.xchacha20poly1305ietf.decrypt(authenticatedCipherText: ciphertext, secretKey: key, nonce: nonce) else { + return seal.reject(SnodeAPIError.decryptionFailed) + } + + sessionIDs.append(sessionIDAsData.toHexString()) } - guard let sessionIDAsData = sodium.aead.xchacha20poly1305ietf.decrypt(authenticatedCipherText: ciphertext, secretKey: key, nonce: nonce) else { - return seal.reject(Error.decryptionFailed) - } - sessionIDs.append(sessionIDAsData.toHexString()) - } } } - guard sessionIDs.count == validationCount && Set(sessionIDs).count == 1 else { return seal.reject(Error.validationFailed) } + + guard sessionIDs.count == validationCount && Set(sessionIDs).count == 1 else { + return seal.reject(SnodeAPIError.validationFailed) + } + seal.fulfill(sessionIDs.first!) } + return promise } @@ -445,7 +478,7 @@ public final class SnodeAPI : NSObject { invoke(.getSwarm, on: snode, associatedWith: publicKey, parameters: parameters) } } - .map2 { rawSnodes in + .map2 { responseData in let swarm = parseSnodes(from: responseData) setSwarm(to: swarm, for: publicKey) @@ -456,8 +489,9 @@ public final class SnodeAPI : NSObject { // MARK: - Retrieve // Not in use until we can batch delete and store config messages - public static func getConfigMessages(from snode: Snode, associatedWith publicKey: String) -> Promise { - let (promise, seal) = Promise.pending() + public static func getConfigMessages(from snode: Snode, associatedWith publicKey: String) -> Promise<[SnodeReceivedMessage]> { + let (promise, seal) = Promise<[SnodeReceivedMessage]>.pending() + Threading.workQueue.async { getMessagesWithAuthentication(from: snode, associatedWith: publicKey, namespace: configNamespace) .done2 { @@ -471,8 +505,9 @@ public final class SnodeAPI : NSObject { return promise } - public static func getMessages(from snode: Snode, associatedWith publicKey: String) -> Promise<[SnodeReceivedMessage]> { + public static func getMessages(from snode: Snode, associatedWith publicKey: String, authenticated: Bool = true) -> Promise<[SnodeReceivedMessage]> { let (promise, seal) = Promise<[SnodeReceivedMessage]>.pending() + Threading.workQueue.async { let retrievePromise = (authenticated ? getMessagesWithAuthentication(from: snode, associatedWith: publicKey, namespace: defaultNamespace) : @@ -482,50 +517,28 @@ public final class SnodeAPI : NSObject { retrievePromise .done2 { seal.fulfill($0) } .catch2 { seal.reject($0) } -// getMessagesInternal(from: snode, associatedWith: publicKey) -// .done2 { rawResponse in -// guard -// let json: JSON = rawResponse as? JSON, -// let rawMessages: [JSON] = json["messages"] as? [JSON] -// else { -// seal.fulfill([]) -// return -// } -// -// let messages: [SnodeReceivedMessage] = rawMessages -// .compactMap { rawMessage -> SnodeReceivedMessage? in -// SnodeReceivedMessage( -// snode: snode, -// publicKey: publicKey, -// rawMessage: rawMessage -// ) -// } -// -// seal.fulfill(messages) -// } -// .catch2 { seal.reject($0) } } return promise } public static func getClosedGroupMessagesFromDefaultNamespace(from snode: Snode, associatedWith publicKey: String) -> Promise<[SnodeReceivedMessage]> { - let (promise, seal) = Promise.pending() + let (promise, seal) = Promise<[SnodeReceivedMessage]>.pending() + Threading.workQueue.async { getMessagesUnauthenticated(from: snode, associatedWith: publicKey, namespace: defaultNamespace) .done2 { seal.fulfill($0) } .catch2 { seal.reject($0) } } + return promise } - private static func getMessagesWithAuthentication(from snode: Snode, associatedWith publicKey: String, namespace: Int) -> Promise { - let storage = SNSnodeKitConfiguration.shared.storage - + private static func getMessagesWithAuthentication(from snode: Snode, associatedWith publicKey: String, namespace: Int) -> Promise<[SnodeReceivedMessage]> { /// **Note:** All authentication logic is only apply to 1-1 chats, the reason being that we can't currently support it yet for /// closed groups. The Storage Server requires an ed25519 key pair, but we don't have that for our closed groups. - guard let userED25519KeyPair: Box.KeyPair = GRDBStorage.shared.read { db in try Identity.fetchUserEd25519KeyPair(db) } else { - return Promise(error: Error.noKeyPair) + guard let userED25519KeyPair: Box.KeyPair = GRDBStorage.shared.read({ db in Identity.fetchUserEd25519KeyPair(db) }) else { + return Promise(error: SnodeAPIError.noKeyPair) } // Get last message hash @@ -540,7 +553,7 @@ public final class SnodeAPI : NSObject { guard let verificationData = ("retrieve" + namespaceVerificationString + String(timestamp)).data(using: String.Encoding.utf8), let signature = sodium.sign.signature(message: Bytes(verificationData), secretKey: userED25519KeyPair.secretKey) - else { return Promise(error: Error.signingFailed) } + else { return Promise(error: SnodeAPIError.signingFailed) } // Make the request let parameters: JSON = [ @@ -553,17 +566,33 @@ public final class SnodeAPI : NSObject { ] return invoke(.getMessages, on: snode, associatedWith: publicKey, parameters: parameters) + .map { responseData -> [SnodeReceivedMessage] in + guard + let responseJson: JSON = try? JSONSerialization.jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? JSON, + let rawMessages: [JSON] = responseJson["messages"] as? [JSON] + else { + return [] + } + + return rawMessages + .compactMap { rawMessage -> SnodeReceivedMessage? in + SnodeReceivedMessage( + snode: snode, + publicKey: publicKey, + namespace: namespace, + rawMessage: rawMessage + ) + } + } } private static func getMessagesUnauthenticated( from snode: Snode, associatedWith publicKey: String, namespace: Int = closedGroupNamespace - ) -> Promise { - let storage = SNSnodeKitConfiguration.shared.storage - + ) -> Promise<[SnodeReceivedMessage]> { // Get last message hash - SnodeReceivedMessageInfo.pruneLastMessageHashInfoIfExpired(for: snode, namespace: namespace, associatedWith: publicKey) + SnodeReceivedMessageInfo.pruneExpiredMessageHashInfo(for: snode, namespace: namespace, associatedWith: publicKey) let lastHash = SnodeReceivedMessageInfo.fetchLastNotExpired(for: snode, namespace: namespace, associatedWith: publicKey)?.hash ?? "" // Make the request @@ -578,6 +607,24 @@ public final class SnodeAPI : NSObject { } return invoke(.getMessages, on: snode, associatedWith: publicKey, parameters: parameters) + .map { responseData -> [SnodeReceivedMessage] in + guard + let responseJson: JSON = try? JSONSerialization.jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? JSON, + let rawMessages: [JSON] = responseJson["messages"] as? [JSON] + else { + return [] + } + + return rawMessages + .compactMap { rawMessage -> SnodeReceivedMessage? in + SnodeReceivedMessage( + snode: snode, + publicKey: publicKey, + namespace: namespace, + rawMessage: rawMessage + ) + } + } } // MARK: Store @@ -588,10 +635,13 @@ public final class SnodeAPI : NSObject { // Not in use until we can batch delete and store config messages private static func sendMessageWithAuthentication(_ message: SnodeMessage, namespace: Int) -> Promise>> { - let storage = SNSnodeKitConfiguration.shared.storage + guard + let messageData: Data = try? JSONEncoder().encode(message), + let messageJson: JSON = try? JSONSerialization.jsonObject(with: messageData, options: [ .fragmentsAllowed ]) as? JSON + else { return Promise(error: HTTP.Error.invalidJSON) } - guard let userED25519KeyPair: Box.KeyPair = GRDBStorage.shared.read { db in try Identity.fetchUserEd25519KeyPair(db) } else { - return Promise(error: Error.noKeyPair) + guard let userED25519KeyPair: Box.KeyPair = GRDBStorage.shared.read({ db in Identity.fetchUserEd25519KeyPair(db) }) else { + return Promise(error: SnodeAPIError.noKeyPair) } // Construct signature @@ -601,7 +651,7 @@ public final class SnodeAPI : NSObject { guard let verificationData = ("store" + String(namespace) + String(timestamp)).data(using: String.Encoding.utf8), let signature = sodium.sign.signature(message: Bytes(verificationData), secretKey: userED25519KeyPair.secretKey) - else { return Promise(error: Error.signingFailed) } + else { return Promise(error: SnodeAPIError.signingFailed) } // Make the request let (promise, seal) = Promise>>.pending() @@ -610,7 +660,7 @@ public final class SnodeAPI : NSObject { Threading.workQueue.async { getTargetSnodes(for: publicKey) .map2 { targetSnodes in - var parameters = message.toJSON() + var parameters: JSON = messageJson parameters["namespace"] = namespace parameters["sig_timestamp"] = timestamp parameters["pubkey_ed25519"] = ed25519PublicKey @@ -630,6 +680,11 @@ public final class SnodeAPI : NSObject { } private static func sendMessageUnauthenticated(_ message: SnodeMessage, isClosedGroupMessage: Bool) -> Promise>> { + guard + let messageData: Data = try? JSONEncoder().encode(message), + let messageJson: JSON = try? JSONSerialization.jsonObject(with: messageData, options: [ .fragmentsAllowed ]) as? JSON + else { return Promise(error: HTTP.Error.invalidJSON) } + let (promise, seal) = Promise>>.pending() let publicKey = Features.useTestnet ? message.recipient.removingIdPrefixIfNeeded() : message.recipient @@ -637,7 +692,7 @@ public final class SnodeAPI : NSObject { getTargetSnodes(for: publicKey) .map2 { targetSnodes in var rawResponsePromises: Set> = Set() - var parameters = message.toJSON() + var parameters: JSON = messageJson parameters["namespace"] = (isClosedGroupMessage ? closedGroupNamespace : defaultNamespace) for targetSnode in targetSnodes { @@ -671,7 +726,7 @@ public final class SnodeAPI : NSObject { public static func deleteMessage(publicKey: String, serverHashes: [String]) -> Promise<[String: Bool]> { guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { - return Promise(error: Error.noKeyPair) + return Promise(error: SnodeAPIError.noKeyPair) } let publicKey = (Features.useTestnet ? publicKey.removingIdPrefixIfNeeded() : publicKey) @@ -682,10 +737,10 @@ public final class SnodeAPI : NSObject { .then2 { swarm -> Promise<[String: Bool]> in guard let snode = swarm.randomElement(), - let verificationData = (Endpoint.deleteMessage.rawValue + serverHashes.joined()).data(using: String.Encoding.utf8), + let verificationData = (SnodeAPIEndpoint.deleteMessage.rawValue + serverHashes.joined()).data(using: String.Encoding.utf8), let signature = sodium.sign.signature(message: Bytes(verificationData), secretKey: userED25519KeyPair.secretKey) else { - throw Error.signingFailed + throw SnodeAPIError.signingFailed } let parameters: JSON = [ @@ -755,7 +810,7 @@ public final class SnodeAPI : NSObject { /// Clears all the user's data from their swarm. Returns a dictionary of snode public key to deletion confirmation. public static func clearAllData() -> Promise<[String:Bool]> { guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { - return Promise(error: Error.noKeyPair) + return Promise(error: SnodeAPIError.noKeyPair) } let userX25519PublicKey: String = getUserHexEncodedPublicKey() @@ -767,10 +822,10 @@ public final class SnodeAPI : NSObject { return attempt(maxRetryCount: maxRetryCount, recoveringOn: Threading.workQueue) { getNetworkTime(from: snode).then2 { timestamp -> Promise<[String: Bool]> in - let verificationData = (Endpoint.clearAllData.rawValue + String(timestamp)).data(using: String.Encoding.utf8)! + let verificationData = (SnodeAPIEndpoint.clearAllData.rawValue + String(timestamp)).data(using: String.Encoding.utf8)! guard let signature = sodium.sign.signature(message: Bytes(verificationData), secretKey: userED25519KeyPair.secretKey) else { - throw Error.signingFailed + throw SnodeAPIError.signingFailed } let parameters: JSON = [ @@ -806,7 +861,9 @@ public final class SnodeAPI : NSObject { userX25519PublicKey, String(timestamp), hashes.joined() - ].data(using: String.Encoding.utf8)! + ] + .joined() + .data(using: String.Encoding.utf8)! let isValid = sodium.sign.verify( message: Bytes(verificationData), publicKey: Bytes(Data(hex: snodePublicKey)), @@ -901,7 +958,7 @@ public final class SnodeAPI : NSObject { case 406: SNLog("The user's clock is out of sync with the service node network.") - return Error.clockOutOfSync + return SnodeAPIError.clockOutOfSync case 421: // The snode isn't associated with the given public key anymore diff --git a/SessionSnodeKit/Utilities/Threading.swift b/SessionSnodeKit/Utilities/Threading.swift index 830d0d957..67e6fa4d4 100644 --- a/SessionSnodeKit/Utilities/Threading.swift +++ b/SessionSnodeKit/Utilities/Threading.swift @@ -1,6 +1,6 @@ import Foundation -internal enum Threading { +public enum Threading { - internal static let workQueue = DispatchQueue(label: "SessionSnodeKit.workQueue", qos: .userInitiated) // It's important that this is a serial queue + public static let workQueue = DispatchQueue(label: "SessionSnodeKit.workQueue", qos: .userInitiated) // It's important that this is a serial queue } diff --git a/SessionUtilitiesKit/Database/GRDBStorage.swift b/SessionUtilitiesKit/Database/GRDBStorage.swift index 66d8cdf1d..1b4ef46ec 100644 --- a/SessionUtilitiesKit/Database/GRDBStorage.swift +++ b/SessionUtilitiesKit/Database/GRDBStorage.swift @@ -5,7 +5,6 @@ import GRDB import PromiseKit import SignalCoreKit - public final class GRDBStorage { private static let dbFileName: String = "Session.sqlite" private static let keychainService: String = "TSKeyChainService" @@ -34,9 +33,10 @@ public final class GRDBStorage { // MARK: - Initialization public init() { - print("RAWR START \("\(GRDBStorage.sharedDatabaseDirectoryPath)/\(GRDBStorage.dbFileName)")") - GRDBStorage.deleteDatabaseFiles() // TODO: Remove this - try! GRDBStorage.deleteDbKeys() // TODO: Remove this +// if CurrentAppContext().isMainApp { +// GRDBStorage.deleteDatabaseFiles() // TODO: Remove this. +// try! GRDBStorage.deleteDbKeys() // TODO: Remove this. +// } // 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 @@ -172,6 +172,10 @@ public final class GRDBStorage { self?.migrationProgressUpdater = nil SUKLegacy.clearLegacyDatabaseInstance() + if let error = error { + SNLog("[Migration Error] Migration failed with error: \(error)") + } + onComplete((error == nil), needsConfigSync) } } diff --git a/SessionUtilitiesKit/Database/Models/Identity.swift b/SessionUtilitiesKit/Database/Models/Identity.swift index 2bd3cc2ea..aa96971d7 100644 --- a/SessionUtilitiesKit/Database/Models/Identity.swift +++ b/SessionUtilitiesKit/Database/Models/Identity.swift @@ -104,18 +104,19 @@ public extension Identity { ) } - static func fetchUserEd25519KeyPair() -> Box.KeyPair? { - return GRDBStorage.shared.read { db -> Box.KeyPair? in - guard - let publicKey: Data = try? Identity.fetchOne(db, id: .ed25519PublicKey)?.data, - let secretKey: Data = try? Identity.fetchOne(db, id: .ed25519SecretKey)?.data - else { return nil } - - return Box.KeyPair( - publicKey: publicKey.bytes, - secretKey: secretKey.bytes - ) + static func fetchUserEd25519KeyPair(_ db: Database? = nil) -> Box.KeyPair? { + guard let db: Database = db else { + return GRDBStorage.shared.read { db in fetchUserEd25519KeyPair(db) } } + guard + let publicKey: Data = try? Identity.fetchOne(db, id: .ed25519PublicKey)?.data, + let secretKey: Data = try? Identity.fetchOne(db, id: .ed25519SecretKey)?.data + else { return nil } + + return Box.KeyPair( + publicKey: publicKey.bytes, + secretKey: secretKey.bytes + ) } static func fetchHexEncodedSeed() -> String? { diff --git a/SessionUtilitiesKit/General/Sodium+Utilities.swift b/SessionUtilitiesKit/General/Sodium+Utilities.swift index 0a8ab0dac..b9161af12 100644 --- a/SessionUtilitiesKit/General/Sodium+Utilities.swift +++ b/SessionUtilitiesKit/General/Sodium+Utilities.swift @@ -1,3 +1,45 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Clibsodium +import Sodium +import Curve25519Kit + +extension Sign { + + /** + Converts an Ed25519 public key to an X25519 public key. + - Parameter ed25519PublicKey: The Ed25519 public key to convert. + - Returns: The X25519 public key if conversion is successful. + */ + public func toX25519(ed25519PublicKey: PublicKey) -> PublicKey? { + var x25519PublicKey = PublicKey(repeating: 0, count: 32) + + // FIXME: It'd be nice to check the exit code here, but all the properties of the object + // returned by the call below are internal. + let _ = crypto_sign_ed25519_pk_to_curve25519 ( + &x25519PublicKey, + ed25519PublicKey + ) + + return x25519PublicKey + } + + /** + Converts an Ed25519 secret key to an X25519 secret key. + - Parameter ed25519SecretKey: The Ed25519 secret key to convert. + - Returns: The X25519 secret key if conversion is successful. + */ + public func toX25519(ed25519SecretKey: SecretKey) -> SecretKey? { + var x25519SecretKey = SecretKey(repeating: 0, count: 32) + + // FIXME: It'd be nice to check the exit code here, but all the properties of the object + // returned by the call below are internal. + let _ = crypto_sign_ed25519_sk_to_curve25519 ( + &x25519SecretKey, + ed25519SecretKey + ) + + return x25519SecretKey + } +} diff --git a/SignalUtilitiesKit/Profile Pictures/Identicon+ObjC.swift b/SignalUtilitiesKit/Profile Pictures/Identicon+ObjC.swift index 1a87159ab..286c10cfd 100644 --- a/SignalUtilitiesKit/Profile Pictures/Identicon+ObjC.swift +++ b/SignalUtilitiesKit/Profile Pictures/Identicon+ObjC.swift @@ -9,7 +9,13 @@ public final class Identicon: NSObject { @objc public static func generatePlaceholderIcon(seed: String, text: String, size: CGFloat) -> UIImage { let icon = PlaceholderIcon(seed: seed) - var content = text + var content: String = (text.hasSuffix("\(String(seed.suffix(4))))") ? + (text.split(separator: "(") + .first + .map { String($0) }) + .defaulting(to: text) : + text + ) if content.count > 2 && SessionId.Prefix(from: content) != nil { content.removeFirst(2) diff --git a/SignalUtilitiesKit/Utilities/CommonStrings.swift b/SignalUtilitiesKit/Utilities/CommonStrings.swift index 2d7199593..0bcac0c4f 100644 --- a/SignalUtilitiesKit/Utilities/CommonStrings.swift +++ b/SignalUtilitiesKit/Utilities/CommonStrings.swift @@ -24,9 +24,6 @@ import Foundation } @objc public class MessageStrings: NSObject { - @objc - static public let newGroupDefaultTitle = NSLocalizedString("NEW_GROUP_DEFAULT_TITLE", comment: "Used in place of the group name when a group has not yet been named.") - @objc static public let replyNotificationAction = NSLocalizedString("PUSH_MANAGER_REPLY", comment: "Notification action button title") diff --git a/SignalUtilitiesKit/Utilities/NoopNotificationsManager.swift b/SignalUtilitiesKit/Utilities/NoopNotificationsManager.swift index da2de7976..76b31539a 100644 --- a/SignalUtilitiesKit/Utilities/NoopNotificationsManager.swift +++ b/SignalUtilitiesKit/Utilities/NoopNotificationsManager.swift @@ -4,12 +4,14 @@ import Foundation import GRDB import SessionMessagingKit -public class NoopNotificationsManager, NotificationsProtocol { +public class NoopNotificationsManager: NotificationsProtocol { + public init() {} + public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, isBackgroundPoll: Bool) { owsFailDebug("") } - public func notifyUser(_ db: Database, forIncomingCall callInfoMessage: Interaction, in thread: SessionThread) { + public func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread) { owsFailDebug("") } From 07f4f7a4eae1c2b68dfef39b1b9ae9cced735e0f Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 9 Jun 2022 19:00:43 +1000 Subject: [PATCH 101/157] Added code to ignore migrating open group messages older than 6 months --- Session.xcodeproj/project.pbxproj | 16 -- Session/Conversations/ConversationVC.swift | 165 +++++++++--------- Session/Meta/Signal-Bridging-Header.h | 1 - .../Database/LegacyDatabase/SMKLegacy.swift | 1 + .../Migrations/_003_YDBToGRDBMigration.swift | 18 ++ .../Jobs/Types/GarbageCollectionJob.swift | 2 +- .../Messaging/LKGroupUtilities.h | 23 --- .../Messaging/LKGroupUtilities.m | 58 ------ .../Meta/SessionUtilitiesKit.h | 1 - 9 files changed, 103 insertions(+), 182 deletions(-) delete mode 100644 SessionUtilitiesKit/Messaging/LKGroupUtilities.h delete mode 100644 SessionUtilitiesKit/Messaging/LKGroupUtilities.m diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 5a47d7a75..e6b1a1c01 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -291,8 +291,6 @@ C32C598A256D0664003C73A2 /* SNProtoEnvelope+Conversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EEF09255B49A8007E1867 /* SNProtoEnvelope+Conversion.swift */; }; C32C599E256DB02B003C73A2 /* TypingIndicators.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA87255A57FC00E217F9 /* TypingIndicators.swift */; }; C32C5A24256DB7DB003C73A2 /* SNUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB6B255A580F00E217F9 /* SNUserDefaults.swift */; }; - C32C5A2D256DB849003C73A2 /* LKGroupUtilities.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBE1255A581A00E217F9 /* LKGroupUtilities.m */; }; - C32C5A36256DB856003C73A2 /* LKGroupUtilities.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBCA255A581700E217F9 /* LKGroupUtilities.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C5A47256DB8F0003C73A2 /* ECKeyPair+Hexadecimal.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA73255A57FA00E217F9 /* ECKeyPair+Hexadecimal.swift */; }; C32C5A48256DB8F0003C73A2 /* BuildConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAA8255A57FF00E217F9 /* BuildConfiguration.swift */; }; C32C5A88256DBCF9003C73A2 /* MessageReceiver+ClosedGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = C32C5A87256DBCF9003C73A2 /* MessageReceiver+ClosedGroups.swift */; }; @@ -1405,11 +1403,9 @@ C33FDBB6255A581600E217F9 /* DataSource.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DataSource.m; sourceTree = ""; }; C33FDBBC255A581600E217F9 /* SSKKeychainStorage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SSKKeychainStorage.swift; sourceTree = ""; }; C33FDBC2255A581700E217F9 /* SSKAsserts.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SSKAsserts.h; sourceTree = ""; }; - C33FDBCA255A581700E217F9 /* LKGroupUtilities.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = LKGroupUtilities.h; sourceTree = ""; }; C33FDBD3255A581800E217F9 /* OWSSignalAddress.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSSignalAddress.swift; sourceTree = ""; }; C33FDBD8255A581900E217F9 /* SignalIOS.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SignalIOS.pb.swift; sourceTree = ""; }; C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PushNotificationAPI.swift; sourceTree = ""; }; - C33FDBE1255A581A00E217F9 /* LKGroupUtilities.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = LKGroupUtilities.m; sourceTree = ""; }; C33FDBF6255A581C00E217F9 /* NSURLSessionDataTask+StatusCode.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSURLSessionDataTask+StatusCode.h"; sourceTree = ""; }; C33FDBF9255A581C00E217F9 /* OWSError.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSError.h; sourceTree = ""; }; C33FDC03255A581D00E217F9 /* ByteParser.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ByteParser.h; sourceTree = ""; }; @@ -2445,15 +2441,6 @@ path = General; sourceTree = ""; }; - B8A582B9258C696200AFD84C /* Messaging */ = { - isa = PBXGroup; - children = ( - C33FDBCA255A581700E217F9 /* LKGroupUtilities.h */, - C33FDBE1255A581A00E217F9 /* LKGroupUtilities.m */, - ); - path = Messaging; - sourceTree = ""; - }; B8B3201F258B1A540020074B /* Contacts */ = { isa = PBXGroup; children = ( @@ -3158,7 +3145,6 @@ B8A582B0258C66C900AFD84C /* General */, FD9004102818ABB000ABAAF6 /* JobRunner */, B8A582AF258C665E00AFD84C /* Media */, - B8A582B9258C696200AFD84C /* Messaging */, B8A582AE258C65D000AFD84C /* Networking */, B8A582AD258C655E00AFD84C /* PromiseKit */, FD09796527F6B0A800936362 /* Utilities */, @@ -4027,7 +4013,6 @@ files = ( C3D9E3A4256763DE0040E4F3 /* AppContext.h in Headers */, C3D9E38A256760390040E4F3 /* OWSFileSystem.h in Headers */, - C32C5A36256DB856003C73A2 /* LKGroupUtilities.h in Headers */, C3D9E379256760340040E4F3 /* MIMETypeUtil.h in Headers */, C3D9E50E25677A510040E4F3 /* DataSource.h in Headers */, B8856DF8256F1633001CE70E /* NSString+SSK.h in Headers */, @@ -5036,7 +5021,6 @@ C352A36D2557858E00338F3E /* NSTimer+Proxying.m in Sources */, FD09797B27FBB25900936362 /* Updatable.swift in Sources */, FDCDB8F12817ABE600352A0C /* Optional+Utilities.swift in Sources */, - C32C5A2D256DB849003C73A2 /* LKGroupUtilities.m in Sources */, 7B7CB192271508AD0079FF93 /* CallRingTonePlayer.swift in Sources */, C3C2ABD22553C6C900C340D1 /* Data+SecureRandom.swift in Sources */, FD848B8B283DC509000E298B /* PagedDatabaseObserver.swift in Sources */, diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 4082d0b70..1904c914f 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -412,7 +412,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers object: nil ) - notificationCenter.addObserver(self, selector: #selector(handleContactThreadReplaced(_:)), name: .contactThreadReplaced, object: nil) // TODO: Is this needed??? +// notificationCenter.addObserver(self, selector: #selector(handleContactThreadReplaced(_:)), name: .contactThreadReplaced, object: nil) } override func viewWillAppear(_ animated: Bool) { @@ -1096,87 +1096,88 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers } @objc private func handleContactThreadReplaced(_ notification: Notification) { - // Ensure the current thread is one of the removed ones - guard let newThreadId: String = notification.userInfo?[NotificationUserInfoKey.threadId] as? String else { return } - guard let removedThreadIds: [String] = notification.userInfo?[NotificationUserInfoKey.removedThreadIds] as? [String] else { - return - } - guard let threadId: String = thread.uniqueId, removedThreadIds.contains(threadId) else { return } - - // Then look to swap the current ConversationVC with a replacement one with the new thread - DispatchQueue.main.async { - guard let navController: UINavigationController = self.navigationController else { return } - guard let viewControllerIndex: Int = navController.viewControllers.firstIndex(of: self) else { return } - guard let newThread: TSContactThread = TSContactThread.fetch(uniqueId: newThreadId) else { return } - - // Let the view controller know we are replacing the thread - self.isReplacingThread = true - - // Create the new ConversationVC and swap the old one out for it - let conversationVC: ConversationVC = ConversationVC(thread: newThread) - let currentlyOnThisScreen: Bool = (navController.topViewController == self) - - navController.viewControllers = [ - (viewControllerIndex == 0 ? - [] : - navController.viewControllers[0.. #import #import -#import #import #import diff --git a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift index d96986b73..31f49e9ab 100644 --- a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift +++ b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift @@ -12,6 +12,7 @@ public enum SMKLegacy { internal static let contactThreadPrefix = "c" internal static let groupThreadPrefix = "g" internal static let closedGroupIdPrefix = "__textsecure_group__!" + internal static let openGroupIdPrefix = "__loki_public_chat_group__!" internal static let closedGroupKeyPairPrefix = "SNClosedGroupEncryptionKeyPairCollection-" internal static let databaseMigrationCollection = "OWSDatabaseMigration" diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 17d7b2b44..22c5d4fb3 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -24,6 +24,7 @@ enum _003_YDBToGRDBMigration: Migration { // MARK: - Read from Legacy Database + let timestampNow: TimeInterval = Date().timeIntervalSince1970 var shouldFailMigration: Bool = false var legacyMigrations: Set = [] var contacts: Set = [] @@ -210,6 +211,23 @@ enum _003_YDBToGRDBMigration: Migration { return } + /// Prune interactions from OpenGroup thread interactions which are older than 6 months + /// + /// The old structure for the open group id was `g{base64String(Data(__loki_public_chat_group__!{server.room}))} + /// so we process the uniqueThreadId to see if it matches that + if + interaction.uniqueThreadId.starts(with: SMKLegacy.groupThreadPrefix), + let base64Data: Data = Data(base64Encoded: interaction.uniqueThreadId.substring(from: SMKLegacy.groupThreadPrefix.count)), + let groupIdString: String = String(data: base64Data, encoding: .utf8), + ( + groupIdString.starts(with: SMKLegacy.openGroupIdPrefix) || + groupIdString.starts(with: "http") + ), + interaction.timestamp < UInt64(floor((timestampNow - GarbageCollectionJob.approxSixMonthsInSeconds) * 1000)) + { + return + } + interactions[interaction.uniqueThreadId] = (interactions[interaction.uniqueThreadId] ?? []) .appending(interaction) diff --git a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift index 33c643d8b..d08547bb9 100644 --- a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift +++ b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift @@ -11,7 +11,7 @@ public enum GarbageCollectionJob: JobExecutor { public static var maxFailureCount: Int = -1 public static var requiresThreadId: Bool = false public static let requiresInteractionId: Bool = false - private static let approxSixMonthsInSeconds: TimeInterval = (6 * 30 * 24 * 60 * 60) + public static let approxSixMonthsInSeconds: TimeInterval = (6 * 30 * 24 * 60 * 60) public static func run( _ job: Job, diff --git a/SessionUtilitiesKit/Messaging/LKGroupUtilities.h b/SessionUtilitiesKit/Messaging/LKGroupUtilities.h deleted file mode 100644 index ac74482f4..000000000 --- a/SessionUtilitiesKit/Messaging/LKGroupUtilities.h +++ /dev/null @@ -1,23 +0,0 @@ -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface LKGroupUtilities : NSObject - -+(NSString *)getEncodedOpenGroupID:(NSString *)groupID; -+(NSData *)getEncodedOpenGroupIDAsData:(NSString *)groupID; - -+(NSString *)getEncodedClosedGroupID:(NSString *)groupID; -+(NSData *)getEncodedClosedGroupIDAsData:(NSString *)groupID; - -+(NSString *)getEncodedMMSGroupID:(NSString *)groupID; -+(NSData *)getEncodedMMSGroupIDAsData:(NSString *)groupID; - -+(NSString *)getEncodedGroupID:(NSData *)groupID; - -+(NSString *)getDecodedGroupID:(NSData *)groupID; -+(NSData *)getDecodedGroupIDAsData:(NSData *)groupID; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionUtilitiesKit/Messaging/LKGroupUtilities.m b/SessionUtilitiesKit/Messaging/LKGroupUtilities.m deleted file mode 100644 index a291ec6d7..000000000 --- a/SessionUtilitiesKit/Messaging/LKGroupUtilities.m +++ /dev/null @@ -1,58 +0,0 @@ -#import "LKGroupUtilities.h" - -@implementation LKGroupUtilities - -#define ClosedGroupPrefix @"__textsecure_group__!" -#define MMSGroupPrefix @"__signal_mms_group__!" -#define OpenGroupPrefix @"__loki_public_chat_group__!" - -+(NSString *)getEncodedOpenGroupID:(NSString *)groupID -{ - return [OpenGroupPrefix stringByAppendingString:groupID]; -} - -+(NSData *)getEncodedOpenGroupIDAsData:(NSString *)groupID -{ - return [[OpenGroupPrefix stringByAppendingString:groupID] dataUsingEncoding:NSUTF8StringEncoding]; -} - -+(NSString *)getEncodedClosedGroupID:(NSString *)groupID -{ - return [ClosedGroupPrefix stringByAppendingString:groupID]; -} - -+(NSData *)getEncodedClosedGroupIDAsData:(NSString *)groupID -{ - return [[ClosedGroupPrefix stringByAppendingString:groupID] dataUsingEncoding:NSUTF8StringEncoding]; -} - -+(NSString *)getEncodedMMSGroupID:(NSString *)groupID -{ - return [MMSGroupPrefix stringByAppendingString:groupID]; -} - -+(NSData *)getEncodedMMSGroupIDAsData:(NSString *)groupID -{ - return [[MMSGroupPrefix stringByAppendingString:groupID] dataUsingEncoding:NSUTF8StringEncoding]; -} - -+(NSString *)getEncodedGroupID:(NSData *)groupID -{ - return [[NSString alloc] initWithData:groupID encoding:NSUTF8StringEncoding]; -} - -+(NSString *)getDecodedGroupID:(NSData *)groupID -{ - NSString *encodedGroupID = [[NSString alloc] initWithData:groupID encoding:NSUTF8StringEncoding]; - if ([encodedGroupID componentsSeparatedByString:@"!"].count > 1) { - return [encodedGroupID componentsSeparatedByString:@"!"][1]; - } - return [encodedGroupID componentsSeparatedByString:@"!"][0]; -} - -+(NSData *)getDecodedGroupIDAsData:(NSData *)groupID -{ - return [[LKGroupUtilities getDecodedGroupID:groupID] dataUsingEncoding:NSUTF8StringEncoding]; -} - -@end diff --git a/SessionUtilitiesKit/Meta/SessionUtilitiesKit.h b/SessionUtilitiesKit/Meta/SessionUtilitiesKit.h index b984a8147..8624165de 100644 --- a/SessionUtilitiesKit/Meta/SessionUtilitiesKit.h +++ b/SessionUtilitiesKit/Meta/SessionUtilitiesKit.h @@ -5,7 +5,6 @@ FOUNDATION_EXPORT const unsigned char SessionUtilitiesKitVersionString[]; #import #import -#import #import #import #import From c56cc99d4046df0c05ad930c419d499c704621d5 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 9 Jun 2022 19:01:39 +1000 Subject: [PATCH 102/157] Commented out a specific migration failure case (as people will likely hit this one) --- .../Database/Migrations/_003_YDBToGRDBMigration.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 22c5d4fb3..4d97e3661 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -1059,7 +1059,8 @@ enum _003_YDBToGRDBMigration: Migration { processedAttachmentIds: &processedAttachmentIds ) else { SNLog("[Migration Error] Missing interaction attachment") - throw StorageError.migrationFailed +// throw StorageError.migrationFailed + return } // Link the attachment to the interaction and add to the id lookup From ff08579088a8dc53cffd8e159bf3e63f4b4184e0 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 16 Jun 2022 13:14:56 +1000 Subject: [PATCH 103/157] Added logic to for unblinding current conversation & bug fixes Added logic to handle unblinding the conversation the user currently has open Fixed a bug where the nav bar wouldn't appear when creating a new account Fixed a bug where messages send to an open group inbox weren't getting their open group server id set (causing duplicates) Fixed a bug where the interaction/gallery data might not get updated in certain cases Fixed an issue where visible messages which were getting sent over 24 hours than when they were originally meant to be sent would fail due to clock offset issues --- .../ConversationVC+Interaction.swift | 6 +- Session/Conversations/ConversationVC.swift | 118 ++++---------- .../Conversations/ConversationViewModel.swift | 148 ++++++++++++------ .../MediaGalleryViewModel.swift | 23 ++- Session/Meta/AppDelegate.swift | 4 +- Session/Notifications/AppNotifications.swift | 7 +- .../Migrations/_003_YDBToGRDBMigration.swift | 8 +- .../Database/Models/BlindedIdLookup.swift | 4 +- .../Database/Models/Interaction.swift | 40 ++++- .../Open Groups/OpenGroupManager.swift | 15 +- .../MessageReceiver+MessageRequests.swift | 11 +- .../MessageReceiver+VisibleMessages.swift | 51 +++++- .../Sending & Receiving/MessageSender.swift | 8 +- .../NSENotificationPresenter.swift | 2 +- SessionShareExtension/ThreadPickerVC.swift | 4 +- .../Database/GRDBStorage.swift | 7 + .../Types/PagedDatabaseObserver.swift | 50 +++--- SessionUtilitiesKit/Media/Updatable.swift | 8 + 18 files changed, 310 insertions(+), 204 deletions(-) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index e328ead7c..10b4cf8d5 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -366,14 +366,13 @@ extension ConversationVC: .updateAll(db, SessionThread.Columns.shouldBeVisible.set(to: true)) // Create the interaction - let userPublicKey: String = getUserHexEncodedPublicKey(db) let interaction: Interaction = try Interaction( threadId: threadId, authorId: getUserHexEncodedPublicKey(db), variant: .standardOutgoing, body: text, timestampMs: sentTimestampMs, - hasMention: text.contains("@\(userPublicKey)"), + hasMention: Interaction.isUserMentioned(db, threadId: threadId, body: text), linkPreviewUrl: linkPreviewDraft?.urlString ).inserted(db) @@ -464,14 +463,13 @@ extension ConversationVC: .updateAll(db, SessionThread.Columns.shouldBeVisible.set(to: true)) // Create the interaction - let userPublicKey: String = getUserHexEncodedPublicKey(db) let interaction: Interaction = try Interaction( threadId: threadId, authorId: getUserHexEncodedPublicKey(db), variant: .standardOutgoing, body: text, timestampMs: sentTimestampMs, - hasMention: text.contains("@\(userPublicKey)") + hasMention: Interaction.isUserMentioned(db, threadId: threadId, body: text) ).inserted(db) try MessageSender.send( diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 1904c914f..cac1a3ccc 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -411,8 +411,6 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers name: UIResponder.keyboardWillHideNotification, object: nil ) - -// notificationCenter.addObserver(self, selector: #selector(handleContactThreadReplaced(_:)), name: .contactThreadReplaced, object: nil) } override func viewWillAppear(_ animated: Bool) { @@ -487,7 +485,36 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers viewModel.observableThreadData, onError: { _ in }, onChange: { [weak self] maybeThreadData in - guard let threadData: SessionThreadViewModel = maybeThreadData else { return } + guard let threadData: SessionThreadViewModel = maybeThreadData else { + // If the thread data is null and the id was blinded then we just unblinded the thread + // and need to swap over to the new one + guard + let sessionId: String = self?.viewModel.threadData.threadId, + SessionId.Prefix(from: sessionId) == .blinded, + let blindedLookup: BlindedIdLookup = GRDBStorage.shared.read({ db in + try BlindedIdLookup + .filter(id: sessionId) + .fetchOne(db) + }), + let unblindedId: String = blindedLookup.sessionId + else { + // If we don't have an unblinded id then something has gone very wrong so pop to the HomeVC + self?.navigationController?.popToRootViewController(animated: true) + return + } + + // Stop observing changes + self?.stopObservingChanges() + GRDBStorage.shared.removeObserver(self?.viewModel.pagedDataObserver) + + // Swap the observing to the updated thread + self?.viewModel.swapToThread(updatedThreadId: unblindedId) + + // Start observing changes again + GRDBStorage.shared.addObserver(self?.viewModel.pagedDataObserver) + self?.startObservingChanges() + return + } // The default scheduler emits changes on the main thread self?.handleThreadUpdates(threadData) @@ -1094,91 +1121,6 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers self.snInputView.text = self.snInputView.text } } - - @objc private func handleContactThreadReplaced(_ notification: Notification) { - print("ASDASDASD") -// // Ensure the current thread is one of the removed ones -// guard let newThreadId: String = notification.userInfo?[NotificationUserInfoKey.threadId] as? String else { return } -// guard let removedThreadIds: [String] = notification.userInfo?[NotificationUserInfoKey.removedThreadIds] as? [String] else { -// return -// } -// guard let threadId: String = thread.uniqueId, removedThreadIds.contains(threadId) else { return } -// -// // Then look to swap the current ConversationVC with a replacement one with the new thread -// DispatchQueue.main.async { -// guard let navController: UINavigationController = self.navigationController else { return } -// guard let viewControllerIndex: Int = navController.viewControllers.firstIndex(of: self) else { return } -// guard let newThread: TSContactThread = TSContactThread.fetch(uniqueId: newThreadId) else { return } -// -// // Let the view controller know we are replacing the thread -// self.isReplacingThread = true -// -// // Create the new ConversationVC and swap the old one out for it -// let conversationVC: ConversationVC = ConversationVC(thread: newThread) -// let currentlyOnThisScreen: Bool = (navController.topViewController == self) -// -// navController.viewControllers = [ -// (viewControllerIndex == 0 ? -// [] : -// navController.viewControllers[0..>> = setupObservableThreadData(for: self.threadId) + + private func setupObservableThreadData(for threadId: String) -> ValueObservation>> { + return ValueObservation + .trackingConstantRegion { db -> SessionThreadViewModel? in + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + return try SessionThreadViewModel + .conversationQuery(threadId: threadId, userPublicKey: userPublicKey) + .fetchOne(db) + } + .removeDuplicates() + } + + public func updateThreadData(_ updatedData: SessionThreadViewModel) { + self.threadData = updatedData + } + + // MARK: - Interaction Data + + public private(set) var unobservedInteractionDataChanges: [SectionModel]? + public private(set) var interactionData: [SectionModel] = [] + public private(set) var pagedDataObserver: PagedDatabaseObserver? + + public var onInteractionChange: (([SectionModel]) -> ())? { + didSet { + // When starting to observe interaction changes we want to trigger a UI update just in case the + // data was changed while we weren't observing + if let unobservedInteractionDataChanges: [SectionModel] = self.unobservedInteractionDataChanges { + onInteractionChange?(unobservedInteractionDataChanges) + self.unobservedInteractionDataChanges = nil + } + } + } + + private func setupPagedObserver(for threadId: String) -> PagedDatabaseObserver { + return PagedDatabaseObserver( pagedTable: Interaction.self, pageSize: ConversationViewModel.pageSize, idColumn: .id, @@ -113,58 +179,20 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { return } - self?.onInteractionChange?(updatedInteractionData) + // If we have the 'onInteractionChanged' callback then trigger it, otherwise just store the changes + // to be sent to the callback if we ever start observing again (when we have the callback it needs + // to do the data updating as it's tied to UI updates and can cause crashes if not updated in the + // correct order) + guard let onInteractionChange: (([SectionModel]) -> ()) = self?.onInteractionChange else { + self?.unobservedInteractionDataChanges = updatedInteractionData + return + } + + onInteractionChange(updatedInteractionData) } ) - - // Run the initial query on a backgorund thread so we don't block the push transition - DispatchQueue.global(qos: .default).async { [weak self] in - // If we don't have a `initialFocusedId` then default to `.pageBefore` (it'll query - // from a `0` offset) - guard let initialFocusedId: Int64 = focusedInteractionId else { - self?.pagedDataObserver?.load(.pageBefore) - return - } - - self?.pagedDataObserver?.load(.initialPageAround(id: initialFocusedId)) - } } - // MARK: - Thread Data - - /// This value is the current state of the view - public private(set) var threadData: SessionThreadViewModel = 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 observableThreadData = ValueObservation - .trackingConstantRegion { [threadId = self.threadId] db -> SessionThreadViewModel? in - let userPublicKey: String = getUserHexEncodedPublicKey(db) - - return try SessionThreadViewModel - .conversationQuery(threadId: threadId, userPublicKey: userPublicKey) - .fetchOne(db) - } - .removeDuplicates() - - public func updateThreadData(_ updatedData: SessionThreadViewModel) { - self.threadData = updatedData - } - - // MARK: - Interaction Data - - public private(set) var interactionData: [SectionModel] = [] - public private(set) var pagedDataObserver: PagedDatabaseObserver? - public var onInteractionChange: (([SectionModel]) -> ())? - private func process(data: [MessageViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] { let typingIndicator: MessageViewModel? = data.first(where: { $0.isTypingIndicator == true }) let sortedData: [MessageViewModel] = data @@ -361,6 +389,26 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { } } + public func swapToThread(updatedThreadId: String) { + let oldestMessageId: Int64? = self.interactionData + .filter { $0.model == .messages } + .first? + .elements + .first? + .id + + self.threadId = updatedThreadId + self.observableThreadData = self.setupObservableThreadData(for: updatedThreadId) + self.pagedDataObserver = self.setupPagedObserver(for: updatedThreadId) + + // Try load everything up to the initial visible message, fallback to just the initial page of messages + // if we don't have one + switch oldestMessageId { + case .some(let id): self.pagedDataObserver?.load(.untilInclusive(id: id, padding: 0)) + case .none: self.pagedDataObserver?.load(.pageBefore) + } + } + // MARK: - Audio Playback public struct PlaybackInfo { diff --git a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift index 86e2de343..b7334eeae 100644 --- a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift +++ b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift @@ -35,8 +35,18 @@ public class MediaGalleryViewModel { public private(set) var pagedDataObserver: PagedDatabaseObserver? /// This value is the current state of a gallery view + private var unobservedGalleryDataChanges: [SectionModel]? public private(set) var galleryData: [SectionModel] = [] - public var onGalleryChange: (([SectionModel]) -> ())? + public var onGalleryChange: (([SectionModel]) -> ())? { + didSet { + // When starting to observe interaction changes we want to trigger a UI update just in case the + // data was changed while we weren't observing + if let unobservedGalleryDataChanges: [SectionModel] = self.unobservedGalleryDataChanges { + onGalleryChange?(unobservedGalleryDataChanges) + self.unobservedGalleryDataChanges = nil + } + } + } // MARK: - Initialization @@ -78,7 +88,16 @@ public class MediaGalleryViewModel { return } - self?.onGalleryChange?(updatedGalleryData) + // If we have the 'onGalleryChange' callback then trigger it, otherwise just store the changes + // to be sent to the callback if we ever start observing again (when we have the callback it needs + // to do the data updating as it's tied to UI updates and can cause crashes if not updated in the + // correct order) + guard let onGalleryChange: (([SectionModel]) -> ()) = self?.onGalleryChange else { + self?.unobservedGalleryDataChanges = updatedGalleryData + return + } + + onGalleryChange(updatedGalleryData) } ) diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 27f6c6680..82bee6cff 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -299,14 +299,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD return } - let navController: UINavigationController = OWSNavigationController( + self.window?.rootViewController = OWSNavigationController( rootViewController: (Identity.userExists() ? HomeVC() : LandingVC() ) ) - navController.isNavigationBarHidden = !(navController.viewControllers.first is HomeVC) - self.window?.rootViewController = navController UIViewController.attemptRotationToDeviceOrientation() } diff --git a/Session/Notifications/AppNotifications.swift b/Session/Notifications/AppNotifications.swift index b9cc2d4cf..50fe281b6 100644 --- a/Session/Notifications/AppNotifications.swift +++ b/Session/Notifications/AppNotifications.swift @@ -183,8 +183,8 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { // Don't fire the notification if the current user isn't mentioned // and isOnlyNotifyingForMentions is on. - guard !thread.onlyNotifyForMentions || interaction.isUserMentioned(db) else { return } - + guard !thread.onlyNotifyForMentions || interaction.hasMention else { return } + let notificationTitle: String? var notificationBody: String? @@ -445,14 +445,13 @@ class NotificationActionHandler { } let promise: Promise = GRDBStorage.shared.write { db in - let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) let interaction: Interaction = try Interaction( threadId: thread.id, authorId: getUserHexEncodedPublicKey(db), variant: .standardOutgoing, body: replyText, timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)), - hasMention: replyText.contains("@\(currentUserPublicKey)") + hasMention: Interaction.isUserMentioned(db, threadId: threadId, body: replyText) ).inserted(db) try Interaction.markAsRead( diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 4d97e3661..7fd2a55db 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -833,9 +833,11 @@ enum _003_YDBToGRDBMigration: Migration { timestampMs: Int64(legacyInteraction.timestamp), receivedAtTimestampMs: Int64(legacyInteraction.receivedAtTimestamp), wasRead: wasRead, - hasMention: ( - body?.contains("@\(currentUserPublicKey)") == true || - quotedMessage?.authorId == currentUserPublicKey + hasMention: Interaction.isUserMentioned( + db, + threadId: threadId, + body: body, + quoteAuthorId: quotedMessage?.authorId ), // For both of these '0' used to be equivalent to null expiresInSeconds: ((expiresInSeconds ?? 0) > 0 ? diff --git a/SessionMessagingKit/Database/Models/BlindedIdLookup.swift b/SessionMessagingKit/Database/Models/BlindedIdLookup.swift index 081042fe3..bba1bb48e 100644 --- a/SessionMessagingKit/Database/Models/BlindedIdLookup.swift +++ b/SessionMessagingKit/Database/Models/BlindedIdLookup.swift @@ -112,7 +112,9 @@ public extension BlindedIdLookup { guard lookup.sessionId == nil else { return lookup } // Lastly loop through existing id lookups (in case the user is looking at a different SOGS but once had - // a thread with this contact in a different SOGS and had cached the lookup) + // a thread with this contact in a different SOGS and had cached the lookup) - we really should never hit + // this case since the contact approval status is sync'ed (the only situation I can think of is a config + // message hasn't been handled correctly?) let blindedIdLookupCursor: RecordCursor = try BlindedIdLookup .filter(BlindedIdLookup.Columns.sessionId != nil) .filter(BlindedIdLookup.Columns.openGroupServer != openGroupServer.lowercased()) diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 62af0107d..43c63b5c1 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -2,6 +2,7 @@ import Foundation import GRDB +import Sodium import SessionUtilitiesKit public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible { @@ -588,19 +589,44 @@ public extension Interaction { ) } - func isUserMentioned(_ db: Database) -> Bool { - guard variant == .standardIncoming else { return false } + static func isUserMentioned( + _ db: Database, + threadId: String, + body: String?, + quoteAuthorId: String? = nil + ) -> Bool { + var publicKeysToCheck: [String] = [ + getUserHexEncodedPublicKey(db) + ] - let userPublicKey: String = getUserHexEncodedPublicKey(db) + // 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) { + let sodium: Sodium = Sodium() + + if + let userEd25519KeyPair: Box.KeyPair = Identity.fetchUserEd25519KeyPair(db), + let blindedKeyPair: Box.KeyPair = sodium.blindedKeyPair( + serverPublicKey: openGroup.publicKey, + edKeyPair: userEd25519KeyPair, + genericHash: sodium.genericHash + ) + { + publicKeysToCheck.append( + SessionId(.blinded, publicKey: blindedKeyPair.publicKey).hexString + ) + } + } - return ( + // A user is mentioned if their public key is in the body of a message or one of their messages + // was quoted + return publicKeysToCheck.contains { publicKey in ( body != nil && - (body ?? "").contains("@\(userPublicKey)") + (body ?? "").contains("@\(publicKey)") ) || ( - (try? quote.fetchOne(db))?.authorId == userPublicKey + quoteAuthorId == publicKey ) - ) + } } /// Use the `Interaction.previewText` method directly where possible rather than this method as it diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 9c63fc773..e9e96e518 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -590,8 +590,19 @@ public final class OpenGroupManager: NSObject { ) } } - catch let error { - SNLog("Couldn't receive inbox message due to error: \(error).") + catch { + switch error { + // Ignore duplicate and self-send errors (we will always receive a duplicate message back + // whenever we send a message so this ends up being spam otherwise) + case DatabaseError.SQLITE_CONSTRAINT_UNIQUE, + MessageReceiverError.duplicateMessage, + MessageReceiverError.duplicateControlMessage, + MessageReceiverError.selfSend: + break + + default: + SNLog("Couldn't receive inbox message due to error: \(error).") + } } } } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift index a90aa1db4..8f38073d2 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift @@ -132,14 +132,13 @@ extension MessageReceiver { else { // The message was sent to the current user so flag their 'didApproveMe' as true (can't send a message to // someone without approving them) - guard - let contact: Contact = try? Contact.fetchOne(db, id: senderSessionId), - !contact.didApproveMe - else { return } + let contact: Contact = Contact.fetchOrCreate(db, id: senderSessionId) + + guard !contact.didApproveMe else { return } - try? contact + _ = try? contact .with(didApproveMe: true) - .update(db) + .saved(db) } // Force a config sync to ensure all devices know the contact approval state if desired diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index f42f9878d..ebaf49fe8 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -2,6 +2,7 @@ import Foundation import GRDB +import Sodium import SignalCoreKit import SessionUtilitiesKit @@ -48,10 +49,44 @@ extension MessageReceiver { let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) let thread: SessionThread = try SessionThread .fetchOrCreate(db, id: threadInfo.id, variant: threadInfo.variant) - let variant: Interaction.Variant = (sender == currentUserPublicKey ? - .standardOutgoing : - .standardIncoming - ) + let variant: Interaction.Variant = { + guard + let openGroupId: String = openGroupId, + let senderSessionId: SessionId = SessionId(from: sender), + let openGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: openGroupId) + else { + return (sender == currentUserPublicKey ? + .standardOutgoing : + .standardIncoming + ) + } + + // Need to check if the blinded id matches for open groups + switch senderSessionId.prefix { + case .blinded: + let sodium: Sodium = Sodium() + + guard + let userEdKeyPair: Box.KeyPair = Identity.fetchUserEd25519KeyPair(db), + let blindedKeyPair: Box.KeyPair = sodium.blindedKeyPair( + serverPublicKey: openGroup.publicKey, + edKeyPair: userEdKeyPair, + genericHash: sodium.genericHash + ) + else { return .standardIncoming } + + return (sender == SessionId(.blinded, publicKey: blindedKeyPair.publicKey).hexString ? + .standardOutgoing : + .standardIncoming + ) + + case .standard, .unblinded: + return (sender == currentUserPublicKey ? + .standardOutgoing : + .standardIncoming + ) + } + }() // Retrieve the disappearing messages config to set the 'expiresInSeconds' value // accoring to the config @@ -74,9 +109,11 @@ extension MessageReceiver { body: message.text, timestampMs: Int64(messageSentTimestamp * 1000), wasRead: (variant == .standardOutgoing), // Auto-mark sent messages as read - hasMention: ( - message.text?.contains("@\(currentUserPublicKey)") == true || - dataMessage.quote?.author == currentUserPublicKey + hasMention: Interaction.isUserMentioned( + db, + threadId: thread.id, + body: message.text, + quoteAuthorId: dataMessage.quote?.author ), // Note: Ensure we don't ever expire open group messages expiresInSeconds: (disappearingMessagesConfiguration.isEnabled && message.openGroupServerMessageId == nil ? diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index ca80c1015..eaf209a87 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -67,11 +67,12 @@ public final class MessageSender { let (promise, seal) = Promise.pending() let userPublicKey: String = getUserHexEncodedPublicKey(db) let isMainAppActive: Bool = (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) + let messageSendTimestamp: Int64 = Int64(floor(Date().timeIntervalSince1970 * 1000)) // Set the timestamp, sender and recipient message.sentTimestamp = ( message.sentTimestamp ?? // Visible messages will already have their sent timestamp set - UInt64(floor(Date().timeIntervalSince1970 * 1000)) + UInt64(messageSendTimestamp) ) message.sender = userPublicKey message.recipient = { @@ -196,13 +197,12 @@ public final class MessageSender { // Send the result let base64EncodedData = wrappedMessage.base64EncodedString() - let timestamp = UInt64(Int64(message.sentTimestamp!) + SnodeAPI.clockOffset) let snodeMessage = SnodeMessage( recipient: message.recipient!, data: base64EncodedData, ttl: message.ttl, - timestampMs: timestamp + timestampMs: UInt64(messageSendTimestamp + SnodeAPI.clockOffset) ) SnodeAPI @@ -529,6 +529,8 @@ public final class MessageSender { using: dependencies ) .done(on: DispatchQueue.global(qos: .userInitiated)) { responseInfo, data in + message.openGroupServerMessageId = UInt64(data.id) + dependencies.storage.write { transaction in try MessageSender.handleSuccessfulMessageSend( db, diff --git a/SessionNotificationServiceExtension/NSENotificationPresenter.swift b/SessionNotificationServiceExtension/NSENotificationPresenter.swift index 1c1cdb1f7..491c55275 100644 --- a/SessionNotificationServiceExtension/NSENotificationPresenter.swift +++ b/SessionNotificationServiceExtension/NSENotificationPresenter.swift @@ -50,7 +50,7 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { var notificationTitle: String = senderName if thread.variant == .closedGroup || thread.variant == .openGroup { - if thread.onlyNotifyForMentions && !interaction.isUserMentioned(db) { + if thread.onlyNotifyForMentions && !interaction.hasMention { // Ignore PNs if the group is set to only notify for mentions return } diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index 40f76ef27..e444bbe48 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -4,6 +4,7 @@ import UIKit import GRDB import PromiseKit import DifferenceKit +import Sodium import SessionUIKit import SignalUtilitiesKit import SessionMessagingKit @@ -228,14 +229,13 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView } // Create the interaction - let userPublicKey: String = getUserHexEncodedPublicKey(db) let interaction: Interaction = try Interaction( threadId: threadId, authorId: getUserHexEncodedPublicKey(db), variant: .standardOutgoing, body: body, timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)), - hasMention: (body?.contains("@\(userPublicKey)") == true), + hasMention: Interaction.isUserMentioned(db, threadId: threadId, body: body), linkPreviewUrl: (isSharingUrl ? attachments.first?.linkPreviewDraft?.urlString : nil) ).inserted(db) diff --git a/SessionUtilitiesKit/Database/GRDBStorage.swift b/SessionUtilitiesKit/Database/GRDBStorage.swift index 1b4ef46ec..11f74acec 100644 --- a/SessionUtilitiesKit/Database/GRDBStorage.swift +++ b/SessionUtilitiesKit/Database/GRDBStorage.swift @@ -337,6 +337,13 @@ public final class GRDBStorage { dbPool.add(transactionObserver: observer) } + + public func removeObserver(_ observer: TransactionObserver?) { + guard isValid, let dbPool: DatabasePool = dbPool else { return } + guard let observer: TransactionObserver = observer else { return } + + dbPool.remove(transactionObserver: observer) + } } // MARK: - Promise Extensions diff --git a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift index 25e67d4ec..7aee892a3 100644 --- a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift +++ b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift @@ -200,11 +200,10 @@ public class PagedDatabaseObserver: TransactionObserver where } // If there are no inserted/updated rows then trigger the update callback and stop here - let rowIdsToQuery: [Int64] = relevantChanges + let changesToQuery: [PagedData.TrackedChange] = relevantChanges .filter { $0.kind != .delete } - .map { $0.rowId } - guard !rowIdsToQuery.isEmpty else { + guard !changesToQuery.isEmpty else { updateDataAndCallbackIfNeeded(updatedDataCache, updatedPageInfo, !deletionChanges.isEmpty) return } @@ -212,7 +211,7 @@ public class PagedDatabaseObserver: TransactionObserver where // Fetch the indexes of the rowIds so we can determine whether they should be added to the screen let itemIndexes: [Int64] = PagedData.indexes( db, - rowIds: rowIdsToQuery, + rowIds: changesToQuery.map { $0.rowId }, tableName: pagedTableName, orderSQL: orderSQL, filterSQL: filterSQL @@ -224,17 +223,21 @@ public class PagedDatabaseObserver: TransactionObserver where // added at once) let itemIndexesAreSequential: Bool = (itemIndexes.map { $0 - 1 }.dropFirst() == itemIndexes.dropLast()) let hasOneValidIndex: Bool = itemIndexes.contains(where: { index -> Bool in - index >= updatedPageInfo.pageOffset && - index < updatedPageInfo.currentCount + index >= updatedPageInfo.pageOffset && ( + index < updatedPageInfo.currentCount || + updatedPageInfo.currentCount == 0 + ) }) - let validRowIds: [Int64] = (itemIndexesAreSequential && hasOneValidIndex ? - rowIdsToQuery : - zip(itemIndexes, rowIdsToQuery) + let validChanges: [PagedData.TrackedChange] = (itemIndexesAreSequential && hasOneValidIndex ? + changesToQuery : + zip(itemIndexes, changesToQuery) .filter { index, _ -> Bool in - index >= updatedPageInfo.pageOffset && - index < updatedPageInfo.currentCount + index >= updatedPageInfo.pageOffset && ( + index < updatedPageInfo.currentCount || + updatedPageInfo.currentCount == 0 + ) } - .map { _, rowId -> Int64 in rowId } + .map { _, change -> PagedData.TrackedChange in change } ) let countBefore: Int = itemIndexes.filter { $0 < updatedPageInfo.pageOffset }.count @@ -244,18 +247,18 @@ public class PagedDatabaseObserver: TransactionObserver where pageSize: updatedPageInfo.pageSize, pageOffset: (updatedPageInfo.pageOffset + countBefore), currentCount: updatedPageInfo.currentCount, - totalCount: (updatedPageInfo.totalCount + itemIndexes.count) + totalCount: (updatedPageInfo.totalCount + validChanges.filter { $0.kind == .insert }.count) ) // If there are no valid row ids then stop here (trigger updates though since the page info // has changes) - guard !validRowIds.isEmpty else { + guard !validChanges.isEmpty else { updateDataAndCallbackIfNeeded(updatedDataCache, updatedPageInfo, true) return } // Fetch the inserted/updated rows - let additionalFilters: SQL = SQL(validRowIds.contains(Column.rowID)) + let additionalFilters: SQL = SQL(validChanges.map { $0.rowId }.contains(Column.rowID)) let updatedItems: [T] = (try? dataQuery(additionalFilters, nil) .fetchAll(db)) .defaulting(to: []) @@ -390,8 +393,9 @@ public class PagedDatabaseObserver: TransactionObserver where ) } - // Otherwise load after - let finalIndex: Int = min(totalCount, (targetIndex + abs(padding))) + // Otherwise load after (targetIndex is 0-indexed so we need to add 1 for this to + // have the correct 'limit' value) + let finalIndex: Int = min(totalCount, (targetIndex + 1 + abs(padding))) return ( (finalIndex - cacheCurrentEndIndex), @@ -937,15 +941,19 @@ public class AssociatedRecord: ErasedAssociatedRecord where T: Fet let uniqueIndexes: [Int64] = itemIndexes.asSet().sorted() let itemIndexesAreSequential: Bool = (uniqueIndexes.map { $0 - 1 }.dropFirst() == uniqueIndexes.dropLast()) let hasOneValidIndex: Bool = itemIndexes.contains(where: { index -> Bool in - index >= pageInfo.pageOffset && - index < pageInfo.currentCount + index >= pageInfo.pageOffset && ( + index < pageInfo.currentCount || + pageInfo.currentCount == 0 + ) }) let validRowIds: [Int64] = (itemIndexesAreSequential && hasOneValidIndex ? rowIdsToQuery : zip(itemIndexes, rowIdsToQuery) .filter { index, _ -> Bool in - index >= pageInfo.pageOffset && - index < pageInfo.currentCount + index >= pageInfo.pageOffset && ( + index < pageInfo.currentCount || + pageInfo.currentCount == 0 + ) } .map { _, rowId -> Int64 in rowId } ) diff --git a/SessionUtilitiesKit/Media/Updatable.swift b/SessionUtilitiesKit/Media/Updatable.swift index ccd7fa03b..4a4a39495 100644 --- a/SessionUtilitiesKit/Media/Updatable.swift +++ b/SessionUtilitiesKit/Media/Updatable.swift @@ -72,6 +72,14 @@ public func ?? (updatable: Updatable, existingValue: @autoclosure () throw } } +public func ?? (updatable: Updatable>, existingValue: @autoclosure () throws -> T?) rethrows -> T? { + switch updatable { + case .remove: return nil + case .existing: return try existingValue() + case .update(let newValue): return newValue + } +} + // MARK: - ExpressibleBy Conformance extension Updatable { From 428cc95ec2aff30b81787cc2a276556650430e1d Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 16 Jun 2022 14:30:14 +1000 Subject: [PATCH 104/157] Started working on fixing the broken unit tests Updated the GRDB storage to support custom writer injection --- Session.xcodeproj/project.pbxproj | 16 - .../Database/Models/OpenGroup.swift | 21 ++ SessionMessagingKit/Utilities/Failable.swift | 24 -- .../Common Networking/RequestSpec.swift | 1 + .../Models/BatchRequestInfoSpec.swift | 1 + .../Open Groups/Models/OpenGroupSpec.swift | 71 ++--- .../Open Groups/Models/SOGSMessageSpec.swift | 1 + .../Open Groups/Models/ServerSpec.swift | 74 ----- .../Open Groups/OpenGroupAPISpec.swift | 297 ++++++++++-------- .../Open Groups/OpenGroupManagerSpec.swift | 140 +++------ .../MessageSenderEncryptionSpec.swift | 43 +-- .../_TestUtilities/DependencyExtensions.swift | 5 +- .../_TestUtilities/MockGeneralCache.swift | 1 + .../_TestUtilities/MockIdentityManager.swift | 9 - .../_TestUtilities/MockStorage.swift | 228 -------------- .../_TestUtilities/MockedExtensions.swift | 21 +- .../OGMDependencyExtensions.swift | 5 +- .../_TestUtilities/TestOnionRequestAPI.swift | 7 +- SessionSnodeKit/OnionRequestAPI.swift | 2 + .../Database/GRDBStorage.swift | 55 ++-- .../Database/Models/Identity.swift | 10 + 21 files changed, 339 insertions(+), 693 deletions(-) delete mode 100644 SessionMessagingKit/Utilities/Failable.swift delete mode 100644 SessionMessagingKitTests/Open Groups/Models/ServerSpec.swift delete mode 100644 SessionMessagingKitTests/_TestUtilities/MockIdentityManager.swift delete mode 100644 SessionMessagingKitTests/_TestUtilities/MockStorage.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index e6b1a1c01..1697b0a96 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -560,7 +560,6 @@ FCB11D8C1A129A76002F93FB /* CoreMedia.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FCB11D8B1A129A76002F93FB /* CoreMedia.framework */; }; FD078E4827E02561000769AF /* CommonMockedExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E4727E02561000769AF /* CommonMockedExtensions.swift */; }; FD078E4927E02576000769AF /* CommonMockedExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E4727E02561000769AF /* CommonMockedExtensions.swift */; }; - FD078E4B27E02C5D000769AF /* Failable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E4A27E02C5D000769AF /* Failable.swift */; }; FD078E4D27E17156000769AF /* MockOGMCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E4C27E17156000769AF /* MockOGMCache.swift */; }; FD078E4F27E175F1000769AF /* DependencyExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E4E27E175F1000769AF /* DependencyExtensions.swift */; }; FD078E5227E1760A000769AF /* OGMDependencyExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E5127E1760A000769AF /* OGMDependencyExtensions.swift */; }; @@ -568,7 +567,6 @@ FD078E5827E1B831000769AF /* TestIncomingMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E5727E1B831000769AF /* TestIncomingMessage.swift */; }; FD078E5A27E29F09000769AF /* MockNonce16Generator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E5927E29F09000769AF /* MockNonce16Generator.swift */; }; FD078E5C27E29F78000769AF /* MockNonce24Generator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E5B27E29F78000769AF /* MockNonce24Generator.swift */; }; - FD078E6027E2BB36000769AF /* MockIdentityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E5F27E2BB36000769AF /* MockIdentityManager.swift */; }; FD09796927F6BEA700936362 /* SwarmSnode.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09796827F6BEA700936362 /* SwarmSnode.swift */; }; FD09796B27F6C67500936362 /* Failable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09796A27F6C67500936362 /* Failable.swift */; }; FD09796E27FA6D0000936362 /* Contact.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09796D27FA6D0000936362 /* Contact.swift */; }; @@ -699,7 +697,6 @@ FD83B9BB27CF20AF005E1583 /* SessionIdSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9BA27CF20AF005E1583 /* SessionIdSpec.swift */; }; FD83B9BF27CF2294005E1583 /* TestConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9BD27CF2243005E1583 /* TestConstants.swift */; }; FD83B9C027CF2294005E1583 /* TestConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9BD27CF2243005E1583 /* TestConstants.swift */; }; - FD83B9C327CF33F7005E1583 /* ServerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9C227CF33F7005E1583 /* ServerSpec.swift */; }; FD83B9C527CF3E2A005E1583 /* OpenGroupSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9C427CF3E2A005E1583 /* OpenGroupSpec.swift */; }; FD83B9C727CF3F10005E1583 /* CapabilitiesSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9C627CF3F10005E1583 /* CapabilitiesSpec.swift */; }; FD83B9C927D0487A005E1583 /* SendDirectMessageResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9C827D0487A005E1583 /* SendDirectMessageResponse.swift */; }; @@ -772,7 +769,6 @@ FDC4387827B5C35400C60D73 /* SendMessageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4387727B5C35400C60D73 /* SendMessageRequest.swift */; }; FDC4389227B9FFC700C60D73 /* SessionMessagingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A6F025539DE700C340D1 /* SessionMessagingKit.framework */; platformFilter = ios; }; FDC4389A27BA002500C60D73 /* OpenGroupAPISpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4389927BA002500C60D73 /* OpenGroupAPISpec.swift */; }; - FDC4389D27BA01F000C60D73 /* MockStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4389C27BA01F000C60D73 /* MockStorage.swift */; }; FDC438A427BB107F00C60D73 /* UserBanRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438A327BB107F00C60D73 /* UserBanRequest.swift */; }; FDC438A627BB113A00C60D73 /* UserUnbanRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438A527BB113A00C60D73 /* UserUnbanRequest.swift */; }; FDC438AA27BB12BB00C60D73 /* UserModeratorRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438A927BB12BB00C60D73 /* UserModeratorRequest.swift */; }; @@ -1635,14 +1631,12 @@ FC3BD9871A30A790005B96BB /* Social.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Social.framework; path = System/Library/Frameworks/Social.framework; sourceTree = SDKROOT; }; FCB11D8B1A129A76002F93FB /* CoreMedia.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreMedia.framework; path = System/Library/Frameworks/CoreMedia.framework; sourceTree = SDKROOT; }; FD078E4727E02561000769AF /* CommonMockedExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonMockedExtensions.swift; sourceTree = ""; }; - FD078E4A27E02C5D000769AF /* Failable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Failable.swift; sourceTree = ""; }; FD078E4C27E17156000769AF /* MockOGMCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockOGMCache.swift; sourceTree = ""; }; FD078E4E27E175F1000769AF /* DependencyExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DependencyExtensions.swift; sourceTree = ""; }; FD078E5127E1760A000769AF /* OGMDependencyExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OGMDependencyExtensions.swift; sourceTree = ""; }; FD078E5727E1B831000769AF /* TestIncomingMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestIncomingMessage.swift; sourceTree = ""; }; FD078E5927E29F09000769AF /* MockNonce16Generator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNonce16Generator.swift; sourceTree = ""; }; FD078E5B27E29F78000769AF /* MockNonce24Generator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNonce24Generator.swift; sourceTree = ""; }; - FD078E5F27E2BB36000769AF /* MockIdentityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockIdentityManager.swift; sourceTree = ""; }; FD09796827F6BEA700936362 /* SwarmSnode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwarmSnode.swift; sourceTree = ""; }; FD09796A27F6C67500936362 /* Failable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Failable.swift; sourceTree = ""; }; FD09796D27FA6D0000936362 /* Contact.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Contact.swift; sourceTree = ""; }; @@ -1746,7 +1740,6 @@ FD83B9AF27CF200A005E1583 /* SessionUtilitiesKitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SessionUtilitiesKitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; FD83B9BA27CF20AF005E1583 /* SessionIdSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionIdSpec.swift; sourceTree = ""; }; FD83B9BD27CF2243005E1583 /* TestConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestConstants.swift; sourceTree = ""; }; - FD83B9C227CF33F7005E1583 /* ServerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSpec.swift; sourceTree = ""; }; FD83B9C427CF3E2A005E1583 /* OpenGroupSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupSpec.swift; sourceTree = ""; }; FD83B9C627CF3F10005E1583 /* CapabilitiesSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapabilitiesSpec.swift; sourceTree = ""; }; FD83B9C827D0487A005E1583 /* SendDirectMessageResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendDirectMessageResponse.swift; sourceTree = ""; }; @@ -1817,7 +1810,6 @@ FDC4387727B5C35400C60D73 /* SendMessageRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageRequest.swift; sourceTree = ""; }; FDC4388E27B9FFC700C60D73 /* SessionMessagingKitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SessionMessagingKitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; FDC4389927BA002500C60D73 /* OpenGroupAPISpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupAPISpec.swift; sourceTree = ""; }; - FDC4389C27BA01F000C60D73 /* MockStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockStorage.swift; sourceTree = ""; }; FDC438A327BB107F00C60D73 /* UserBanRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserBanRequest.swift; sourceTree = ""; }; FDC438A527BB113A00C60D73 /* UserUnbanRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserUnbanRequest.swift; sourceTree = ""; }; FDC438A927BB12BB00C60D73 /* UserModeratorRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserModeratorRequest.swift; sourceTree = ""; }; @@ -3068,7 +3060,6 @@ FD859EF127BF6BA200510D0C /* Data+Utilities.swift */, FDC438C027BB4E6800C60D73 /* Dependencies.swift */, C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */, - FD078E4A27E02C5D000769AF /* Failable.swift */, C3BBE0C62554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift */, C3A71D0A2558989C0043A11F /* MessageWrapper.swift */, C3A71D4E25589FF30043A11F /* NSData+messagePadding.h */, @@ -3728,7 +3719,6 @@ FD83B9C127CF33EE005E1583 /* Models */ = { isa = PBXGroup; children = ( - FD83B9C227CF33F7005E1583 /* ServerSpec.swift */, FD83B9C427CF3E2A005E1583 /* OpenGroupSpec.swift */, FD3C905B27E3FBEF00CD579F /* BatchRequestInfoSpec.swift */, FD83B9C627CF3F10005E1583 /* CapabilitiesSpec.swift */, @@ -3867,9 +3857,7 @@ isa = PBXGroup; children = ( FDC438BC27BB2AB400C60D73 /* Mockable.swift */, - FD078E5F27E2BB36000769AF /* MockIdentityManager.swift */, FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */, - FDC4389C27BA01F000C60D73 /* MockStorage.swift */, FD859EF327C2F49200510D0C /* MockSodium.swift */, FD3C906E27E43E8700CD579F /* MockBox.swift */, FD859EF927C2F5C500510D0C /* MockGenericHash.swift */, @@ -5231,7 +5219,6 @@ FDC438CB27BB7DB100C60D73 /* UpdateMessageRequest.swift in Sources */, C352A2FF25574B6300338F3E /* MessageSendJob.swift in Sources */, FDC438C327BB512200C60D73 /* SodiumProtocols.swift in Sources */, - FD078E4B27E02C5D000769AF /* Failable.swift in Sources */, B8856D11256F112A001CE70E /* OWSAudioSession.swift in Sources */, C3DB66C3260ACCE6001EFC55 /* OpenGroupPoller.swift in Sources */, FD716E722850647600C96BF4 /* Data+Utilities.swift in Sources */, @@ -5484,14 +5471,12 @@ FDC290B327DFF9F5005DAE71 /* TestOnionRequestAPI.swift in Sources */, FDC2909127D709CA005DAE71 /* SOGSMessageSpec.swift in Sources */, FD3C906A27E417CE00CD579F /* SodiumUtilitiesSpec.swift in Sources */, - FD078E6027E2BB36000769AF /* MockIdentityManager.swift in Sources */, FD3C907127E445E500CD579F /* MessageReceiverDecryptionSpec.swift in Sources */, FDC2909627D71252005DAE71 /* SOGSErrorSpec.swift in Sources */, FDC2908727D7047F005DAE71 /* RoomSpec.swift in Sources */, FD078E4F27E175F1000769AF /* DependencyExtensions.swift in Sources */, FDC2909C27D713D2005DAE71 /* SodiumProtocolsSpec.swift in Sources */, FD3C906027E410F700CD579F /* FileUploadResponseSpec.swift in Sources */, - FD83B9C327CF33F7005E1583 /* ServerSpec.swift in Sources */, FD83B9C727CF3F10005E1583 /* CapabilitiesSpec.swift in Sources */, FDC2909A27D71376005DAE71 /* NonceGeneratorSpec.swift in Sources */, FD3C906427E4122F00CD579F /* RequestSpec.swift in Sources */, @@ -5522,7 +5507,6 @@ FDC2908D27D70905005DAE71 /* UpdateMessageRequestSpec.swift in Sources */, FDC290A227D85890005DAE71 /* TestInteraction.swift in Sources */, FD078E5427E197CA000769AF /* OpenGroupManagerSpec.swift in Sources */, - FDC4389D27BA01F000C60D73 /* MockStorage.swift in Sources */, FD3C906727E416AF00CD579F /* BlindedIdMappingSpec.swift in Sources */, FD83B9D227D59495005E1583 /* MockUserDefaults.swift in Sources */, FD078E5C27E29F78000769AF /* MockNonce24Generator.swift in Sources */, diff --git a/SessionMessagingKit/Database/Models/OpenGroup.swift b/SessionMessagingKit/Database/Models/OpenGroup.swift index 6ccb8f784..f877929cd 100644 --- a/SessionMessagingKit/Database/Models/OpenGroup.swift +++ b/SessionMessagingKit/Database/Models/OpenGroup.swift @@ -176,6 +176,27 @@ public extension OpenGroup { } } +extension OpenGroup: CustomStringConvertible, CustomDebugStringConvertible { + public var description: String { "\(name) (Server: \(server), Room: \(roomToken))" } + public var debugDescription: String { + [ + "OpenGroup(server: \"\(server)\"", + "roomToken: \"\(roomToken)\"", + "id: \"\(id)\"", + "publicKey: \"\(publicKey)\"", + "isActive: \"\(isActive)\"", + "name: \"\(name)\"", + "roomDescription: \(roomDescription.map { "\"\($0)\"" } ?? "null")", + "imageId: \(imageId ?? "null")", + "userCount: \(userCount)", + "infoUpdates: \(infoUpdates)", + "sequenceNumber: \(sequenceNumber)", + "inboxLatestMessageId: \(inboxLatestMessageId)", + "outboxLatestMessageId: \(outboxLatestMessageId))" + ].joined(separator: ", ") + } +} + // MARK: - Objective-C Support // TODO: Remove this when possible diff --git a/SessionMessagingKit/Utilities/Failable.swift b/SessionMessagingKit/Utilities/Failable.swift deleted file mode 100644 index 80aa529a0..000000000 --- a/SessionMessagingKit/Utilities/Failable.swift +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -struct Failable: Codable { - let value: T? - - init(from decoder: Decoder) throws { - guard let container = try? decoder.singleValueContainer() else { - self.value = nil - return - } - - self.value = try? container.decode(T.self) - } - - func encode(to encoder: Encoder) throws { - guard let value: T = value else { return } - - var container: SingleValueEncodingContainer = encoder.singleValueContainer() - - try container.encode(value) - } -} diff --git a/SessionMessagingKitTests/Common Networking/RequestSpec.swift b/SessionMessagingKitTests/Common Networking/RequestSpec.swift index b23921fd3..44d87d22d 100644 --- a/SessionMessagingKitTests/Common Networking/RequestSpec.swift +++ b/SessionMessagingKitTests/Common Networking/RequestSpec.swift @@ -4,6 +4,7 @@ import Foundation import Quick import Nimble +import SessionUtilitiesKit @testable import SessionMessagingKit diff --git a/SessionMessagingKitTests/Open Groups/Models/BatchRequestInfoSpec.swift b/SessionMessagingKitTests/Open Groups/Models/BatchRequestInfoSpec.swift index f988057a8..97fbebfdb 100644 --- a/SessionMessagingKitTests/Open Groups/Models/BatchRequestInfoSpec.swift +++ b/SessionMessagingKitTests/Open Groups/Models/BatchRequestInfoSpec.swift @@ -3,6 +3,7 @@ import Foundation import PromiseKit import SessionSnodeKit +import SessionUtilitiesKit import Quick import Nimble diff --git a/SessionMessagingKitTests/Open Groups/Models/OpenGroupSpec.swift b/SessionMessagingKitTests/Open Groups/Models/OpenGroupSpec.swift index 17cd0e496..966a270c4 100644 --- a/SessionMessagingKitTests/Open Groups/Models/OpenGroupSpec.swift +++ b/SessionMessagingKitTests/Open Groups/Models/OpenGroupSpec.swift @@ -16,55 +16,40 @@ class OpenGroupSpec: QuickSpec { it("generates the id") { let openGroup: OpenGroup = OpenGroup( server: "server", - room: "room", + roomToken: "room", publicKey: "1234", + isActive: true, name: "name", - groupDescription: nil, - imageID: nil, - infoUpdates: 0 + roomDescription: nil, + imageId: nil, + imageData: nil, + userCount: 0, + infoUpdates: 0, + sequenceNumber: 0, + inboxLatestMessageId: 0, + outboxLatestMessageId: 0 ) expect(openGroup.id).to(equal("server.room")) } } - context("when NSCoding") { - // Note: Unit testing NSCoder is horrible so we won't do it properly - wait until we refactor it to Codable - it("successfully encodes and decodes") { - let openGroupToEncode: OpenGroup = OpenGroup( - server: "server", - room: "room", - publicKey: "1234", - name: "name", - groupDescription: "desc", - imageID: "image", - infoUpdates: 1 - ) - let encodedData: Data = try! NSKeyedArchiver.archivedData(withRootObject: openGroupToEncode, requiringSecureCoding: false) - let openGroup: OpenGroup? = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(encodedData) as? OpenGroup - - expect(openGroup).toNot(beNil()) - expect(openGroup?.id).to(equal("server.room")) - expect(openGroup?.server).to(equal("server")) - expect(openGroup?.room).to(equal("room")) - expect(openGroup?.publicKey).to(equal("1234")) - expect(openGroup?.name).to(equal("name")) - expect(openGroup?.groupDescription).to(equal("desc")) - expect(openGroup?.imageID).to(equal("image")) - expect(openGroup?.infoUpdates).to(equal(1)) - } - } - context("when describing") { it("includes relevant information") { let openGroup: OpenGroup = OpenGroup( server: "server", - room: "room", + roomToken: "room", publicKey: "1234", + isActive: true, name: "name", - groupDescription: nil, - imageID: nil, - infoUpdates: 0 + roomDescription: nil, + imageId: nil, + imageData: nil, + userCount: 0, + infoUpdates: 0, + sequenceNumber: 0, + inboxLatestMessageId: 0, + outboxLatestMessageId: 0 ) expect(openGroup.description) @@ -76,16 +61,22 @@ class OpenGroupSpec: QuickSpec { it("includes relevant information") { let openGroup: OpenGroup = OpenGroup( server: "server", - room: "room", + roomToken: "room", publicKey: "1234", + isActive: true, name: "name", - groupDescription: nil, - imageID: nil, - infoUpdates: 0 + roomDescription: nil, + imageId: nil, + imageData: nil, + userCount: 0, + infoUpdates: 0, + sequenceNumber: 0, + inboxLatestMessageId: 0, + outboxLatestMessageId: 0 ) expect(openGroup.debugDescription) - .to(equal("OpenGroup(server: \"server\", room: \"room\", id: \"server.room\", publicKey: \"1234\", name: \"name\", groupDescription: null, imageID: null, infoUpdates: 0)")) + .to(equal("OpenGroup(server: \"server\", roomToken: \"room\", id: \"server.room\", publicKey: \"1234\", isActive: true, name: \"name\", roomDescription: null, imageId: null, userCount: 0, infoUpdates: 0, sequenceNumber: 0, inboxLatestMessageId: 0, outboxLatestMessageId: 0)")) } } } diff --git a/SessionMessagingKitTests/Open Groups/Models/SOGSMessageSpec.swift b/SessionMessagingKitTests/Open Groups/Models/SOGSMessageSpec.swift index ca36ffcd8..819a3a782 100644 --- a/SessionMessagingKitTests/Open Groups/Models/SOGSMessageSpec.swift +++ b/SessionMessagingKitTests/Open Groups/Models/SOGSMessageSpec.swift @@ -4,6 +4,7 @@ import Foundation import Quick import Nimble +import SessionUtilitiesKit @testable import SessionMessagingKit diff --git a/SessionMessagingKitTests/Open Groups/Models/ServerSpec.swift b/SessionMessagingKitTests/Open Groups/Models/ServerSpec.swift deleted file mode 100644 index 6b5d47a4e..000000000 --- a/SessionMessagingKitTests/Open Groups/Models/ServerSpec.swift +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -import Quick -import Nimble - -@testable import SessionMessagingKit - -class ServerSpec: QuickSpec { - // MARK: - Spec - - override func spec() { - describe("an Open Group Server") { - context("when initializing") { - it("converts the server name to lowercase") { - let server: OpenGroupAPI.Server = OpenGroupAPI.Server( - name: "TeSt", - capabilities: OpenGroupAPI.Capabilities(capabilities: [], missing: nil) - ) - - expect(server.name).to(equal("test")) - } - } - - context("when NSCoding") { - // Note: Unit testing NSCoder is horrible so we won't do it properly - wait until we refactor it to Codable - it("successfully encodes and decodes") { - let serverToEncode: OpenGroupAPI.Server = OpenGroupAPI.Server( - name: "test", - capabilities: OpenGroupAPI.Capabilities( - capabilities: [.sogs, .unsupported("other")], - missing: [.blind, .unsupported("other2")]) - ) - let encodedData: Data = try! NSKeyedArchiver.archivedData(withRootObject: serverToEncode, requiringSecureCoding: false) - let server: OpenGroupAPI.Server? = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(encodedData) as? OpenGroupAPI.Server - - expect(server).toNot(beNil()) - expect(server?.name).to(equal("test")) - expect(server?.capabilities.capabilities).to(equal([.sogs, .unsupported("other")])) - expect(server?.capabilities.missing).to(equal([.blind, .unsupported("other2")])) - } - } - - context("when describing") { - it("includes relevant information") { - let server: OpenGroupAPI.Server = OpenGroupAPI.Server( - name: "TeSt", - capabilities: OpenGroupAPI.Capabilities( - capabilities: [.sogs, .unsupported("other")], - missing: [.blind, .unsupported("other2")] - ) - ) - - expect(server.description) - .to(equal("test (Capabilities: [sogs, other], Missing: [blind, other2])")) - } - - it("handles nil missing capabilities") { - let server: OpenGroupAPI.Server = OpenGroupAPI.Server( - name: "TeSt", - capabilities: OpenGroupAPI.Capabilities( - capabilities: [.sogs, .unsupported("other")], - missing: nil - ) - ) - - expect(server.description) - .to(equal("test (Capabilities: [sogs, other], Missing: [])")) - } - } - } - } -} diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift index cbf9262c9..7ae639d0e 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift @@ -1,8 +1,10 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import PromiseKit +import GRDB import Sodium import SessionSnodeKit +import SessionUtilitiesKit import Quick import Nimble @@ -13,7 +15,7 @@ class OpenGroupAPISpec: QuickSpec { // MARK: - Spec override func spec() { - var mockStorage: MockStorage! + var mockStorage: GRDBStorage! var mockSodium: MockSodium! var mockAeadXChaCha20Poly1305Ietf: MockAeadXChaCha20Poly1305Ietf! var mockSign: MockSign! @@ -31,7 +33,7 @@ class OpenGroupAPISpec: QuickSpec { // MARK: - Configuration beforeEach { - mockStorage = MockStorage() + mockStorage = GRDBStorage(customWriter: DatabaseQueue()) mockSodium = MockSodium() mockAeadXChaCha20Poly1305Ietf = MockAeadXChaCha20Poly1305Ietf() mockSign = MockSign() @@ -52,61 +54,23 @@ class OpenGroupAPISpec: QuickSpec { date: Date(timeIntervalSince1970: 1234567890) ) - mockStorage - .when { $0.write(with: { _ in }) } - .then { args in (args.first as? ((Any) -> Void))?(anyAny()) } - .thenReturn(Promise.value(())) - mockStorage - .when { $0.write(with: { _ in }, completion: { }) } - .then { args in - (args.first as? ((Any) -> Void))?(anyAny()) - (args.last as? (() -> Void))?() - } - .thenReturn(Promise.value(())) - mockStorage - .when { $0.getUserKeyPair() } - .thenReturn( - try! ECKeyPair( - publicKeyData: Data.data(fromHex: TestConstants.publicKey)!, - privateKeyData: Data.data(fromHex: TestConstants.privateKey)! - ) - ) - mockStorage - .when { $0.getUserED25519KeyPair() } - .thenReturn( - Box.KeyPair( - publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, - secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes - ) - ) - mockStorage - .when { $0.getAllOpenGroups() } - .thenReturn([ - "0": OpenGroup( - server: "testServer", - room: "testRoom", - publicKey: TestConstants.publicKey, - name: "Test", - groupDescription: nil, - imageID: nil, - infoUpdates: 0 - ) - ]) - mockStorage - .when { $0.getOpenGroupServer(name: any()) } - .thenReturn( - OpenGroupAPI.Server( - name: "testServer", - capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: []) - ) - ) - mockStorage - .when { $0.getOpenGroupPublicKey(for: any()) } - .thenReturn(TestConstants.publicKey) - mockStorage.when { $0.getOpenGroupSequenceNumber(for: any(), on: any()) }.thenReturn(nil) - mockStorage.when { $0.getOpenGroupInboxLatestMessageId(for: any()) }.thenReturn(nil) - mockStorage.when { $0.getOpenGroupOutboxLatestMessageId(for: any()) }.thenReturn(nil) - mockStorage.when { $0.addReceivedMessageTimestamp(any(), using: anyAny()) }.thenReturn(()) + mockStorage.write { db in + try Identity(variant: .x25519PublicKey, data: Data.data(fromHex: TestConstants.publicKey)).insert(db) + try Identity(variant: .x25519PrivateKey, data: Data.data(fromHex: TestConstants.privateKey)).insert(db) + try Identity(variant: .ed25519PublicKey, data: Data.data(fromHex: TestConstants.edPublicKey)).insert(db) + try Identity(variant: .ed25519SecretKey, data: Data.data(fromHex: TestConstants.edSecretKey)).insert(db) + + try OpenGroup( + server: "testServer", + roomToken: "testRoom", + publicKey: TestConstants.publicKey, + name: "Test", + groupDescription: nil, + imageID: nil, + infoUpdates: 0 + ).insert(db) + try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false) + } mockGenericHash.when { $0.hash(message: anyArray(), outputLength: any()) }.thenReturn([]) mockSodium @@ -233,13 +197,16 @@ class OpenGroupAPISpec: QuickSpec { } it("retrieves recent messages if there was no last message") { - OpenGroupAPI - .poll( - "testServer", - hasPerformedInitialPoll: false, - timeSinceLastPoll: 0, - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI.poll( + db, + server: "testserver", + hasPerformedInitialPoll: false, + timeSinceLastPoll: 0, + using: dependencies + ) + } .get { result in pollResponse = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -254,15 +221,21 @@ class OpenGroupAPISpec: QuickSpec { } it("retrieves recent messages if there was a last message and it has not performed the initial poll and the last message was too long ago") { - mockStorage.when { $0.getOpenGroupSequenceNumber(for: any(), on: any()) }.thenReturn(123) + mockStorage.write { db in + try OpenGroup + .updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: 123)) + } - OpenGroupAPI - .poll( - "testServer", - hasPerformedInitialPoll: false, - timeSinceLastPoll: (OpenGroupAPI.Poller.maxInactivityPeriod + 1), - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI.poll( + db, + server: "testserver", + hasPerformedInitialPoll: false, + timeSinceLastPoll: (OpenGroupAPI.Poller.maxInactivityPeriod + 1), + using: dependencies + ) + } .get { result in pollResponse = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -277,15 +250,21 @@ class OpenGroupAPISpec: QuickSpec { } it("retrieves recent messages if there was a last message and it has performed an initial poll but it was not too long ago") { - mockStorage.when { $0.getOpenGroupSequenceNumber(for: any(), on: any()) }.thenReturn(123) + mockStorage.write { db in + try OpenGroup + .updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: 123)) + } - OpenGroupAPI - .poll( - "testServer", - hasPerformedInitialPoll: false, - timeSinceLastPoll: 0, - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI.poll( + db, + server: "testserver", + hasPerformedInitialPoll: false, + timeSinceLastPoll: 0, + using: dependencies + ) + } .get { result in pollResponse = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -300,15 +279,21 @@ class OpenGroupAPISpec: QuickSpec { } it("retrieves recent messages if there was a last message and there has already been a poll this session") { - mockStorage.when { $0.getOpenGroupSequenceNumber(for: any(), on: any()) }.thenReturn(123) + mockStorage.write { db in + try OpenGroup + .updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: 123)) + } - OpenGroupAPI - .poll( - "testServer", - hasPerformedInitialPoll: true, - timeSinceLastPoll: 0, - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI.poll( + db, + server: "testserver", + hasPerformedInitialPoll: true, + timeSinceLastPoll: 0, + using: dependencies + ) + } .get { result in pollResponse = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -324,18 +309,23 @@ class OpenGroupAPISpec: QuickSpec { context("when unblinded") { beforeEach { - mockStorage - .when { $0.getOpenGroupServer(name: any()) } - .thenReturn( - OpenGroupAPI.Server( - name: "testServer", - capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: []) - ) - ) + mockStorage.write { db in + _ = try Capability.deleteAll(db) + try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) + } } it("does not call the inbox and outbox endpoints") { - OpenGroupAPI.poll("testServer", hasPerformedInitialPoll: false, timeSinceLastPoll: 0, using: dependencies) + mockStorage + .read { db in + OpenGroupAPI.poll( + db, + server: "testserver", + hasPerformedInitialPoll: false, + timeSinceLastPoll: 0, + using: dependencies + ) + } .get { result in pollResponse = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -417,18 +407,24 @@ class OpenGroupAPISpec: QuickSpec { dependencies = dependencies.with(onionApi: TestApi.self) - mockStorage - .when { $0.getOpenGroupServer(name: any()) } - .thenReturn( - OpenGroupAPI.Server( - name: "testServer", - capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs, .blind], missing: []) - ) - ) + mockStorage.write { db in + _ = try Capability.deleteAll(db) + try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) + try Capability(openGroupServer: "testserver", variant: .blind, isMissing: false).insert(db) + } } it("includes the inbox and outbox endpoints") { - OpenGroupAPI.poll("testServer", hasPerformedInitialPoll: false, timeSinceLastPoll: 0, using: dependencies) + mockStorage + .read { db in + OpenGroupAPI.poll( + db, + server: "testserver", + hasPerformedInitialPoll: false, + timeSinceLastPoll: 0, + using: dependencies + ) + } .get { result in pollResponse = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -446,13 +442,16 @@ class OpenGroupAPISpec: QuickSpec { } it("retrieves recent inbox messages if there was no last message") { - OpenGroupAPI - .poll( - "testServer", - hasPerformedInitialPoll: true, - timeSinceLastPoll: 0, - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI.poll( + db, + server: "testserver", + hasPerformedInitialPoll: true, + timeSinceLastPoll: 0, + using: dependencies + ) + } .get { result in pollResponse = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -467,15 +466,21 @@ class OpenGroupAPISpec: QuickSpec { } it("retrieves inbox messages since the last message if there was one") { - mockStorage.when { $0.getOpenGroupInboxLatestMessageId(for: any()) }.thenReturn(124) + mockStorage.write { db in + try OpenGroup + .updateAll(db, OpenGroup.Columns.inboxLatestMessageId.set(to: 124)) + } - OpenGroupAPI - .poll( - "testServer", - hasPerformedInitialPoll: true, - timeSinceLastPoll: 0, - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI.poll( + db, + server: "testserver", + hasPerformedInitialPoll: true, + timeSinceLastPoll: 0, + using: dependencies + ) + } .get { result in pollResponse = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -490,13 +495,16 @@ class OpenGroupAPISpec: QuickSpec { } it("retrieves recent outbox messages if there was no last message") { - OpenGroupAPI - .poll( - "testServer", - hasPerformedInitialPoll: true, - timeSinceLastPoll: 0, - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI.poll( + db, + server: "testserver", + hasPerformedInitialPoll: true, + timeSinceLastPoll: 0, + using: dependencies + ) + } .get { result in pollResponse = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -511,15 +519,21 @@ class OpenGroupAPISpec: QuickSpec { } it("retrieves outbox messages since the last message if there was one") { - mockStorage.when { $0.getOpenGroupOutboxLatestMessageId(for: any()) }.thenReturn(125) + mockStorage.write { db in + try OpenGroup + .updateAll(db, OpenGroup.Columns.outboxLatestMessageId.set(to: 125)) + } - OpenGroupAPI - .poll( - "testServer", - hasPerformedInitialPoll: true, - timeSinceLastPoll: 0, - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI.poll( + db, + server: "testserver", + hasPerformedInitialPoll: true, + timeSinceLastPoll: 0, + using: dependencies + ) + } .get { result in pollResponse = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -571,7 +585,16 @@ class OpenGroupAPISpec: QuickSpec { } dependencies = dependencies.with(onionApi: TestApi.self) - OpenGroupAPI.poll("testServer", hasPerformedInitialPoll: false, timeSinceLastPoll: 0, using: dependencies) + mockStorage + .read { db in + OpenGroupAPI.poll( + db, + server: "testserver", + hasPerformedInitialPoll: false, + timeSinceLastPoll: 0, + using: dependencies + ) + } .get { result in pollResponse = result } .catch { requestError in error = requestError } .retainUntilComplete() diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift index d730e9138..240648781 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -1,8 +1,10 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import PromiseKit +import GRDB import Sodium import SessionSnodeKit +import SessionUtilitiesKit import Quick import Nimble @@ -70,9 +72,8 @@ class OpenGroupManagerSpec: QuickSpec { override func spec() { var mockOGMCache: MockOGMCache! - var mockIdentityManager: MockIdentityManager! var mockGeneralCache: MockGeneralCache! - var mockStorage: MockStorage! + var mockStorage: GRDBStorage! var mockSodium: MockSodium! var mockAeadXChaCha20Poly1305Ietf: MockAeadXChaCha20Poly1305Ietf! var mockGenericHash: MockGenericHash! @@ -100,9 +101,8 @@ class OpenGroupManagerSpec: QuickSpec { beforeEach { mockOGMCache = MockOGMCache() - mockIdentityManager = MockIdentityManager() mockGeneralCache = MockGeneralCache() - mockStorage = MockStorage() + mockStorage = GRDBStorage(customWriter: DatabaseQueue()) mockSodium = MockSodium() mockAeadXChaCha20Poly1305Ietf = MockAeadXChaCha20Poly1305Ietf() mockGenericHash = MockGenericHash() @@ -155,7 +155,7 @@ class OpenGroupManagerSpec: QuickSpec { testOpenGroup = OpenGroup( server: "testServer", - room: "testRoom", + roomToken: "testRoom", publicKey: TestConstants.publicKey, name: "Test", groupDescription: nil, @@ -215,62 +215,16 @@ class OpenGroupManagerSpec: QuickSpec { ).base64EncodedString() ) - mockIdentityManager - .when { $0.identityKeyPair() } - .thenReturn( - try! ECKeyPair( - publicKeyData: Data.data(fromHex: TestConstants.publicKey)!, - privateKeyData: Data.data(fromHex: TestConstants.privateKey)! - ) - ) + mockStorage.write { db in + try Identity(variant: .x25519PublicKey, data: Data.data(fromHex: TestConstants.publicKey)).insert(db) + try Identity(variant: .x25519PrivateKey, data: Data.data(fromHex: TestConstants.privateKey)).insert(db) + try Identity(variant: .ed25519PublicKey, data: Data.data(fromHex: TestConstants.edPublicKey)).insert(db) + try Identity(variant: .ed25519SecretKey, data: Data.data(fromHex: TestConstants.edSecretKey)).insert(db) + + try testOpenGroup.insert(db) + try Capability(openGroupServer: testOpenGroup.server, variant: .sogs, isMissing: false).insert(db) + } mockGeneralCache.when { $0.encodedPublicKey }.thenReturn("05\(TestConstants.publicKey)") - mockStorage - .when { $0.write(with: { _ in }) } - .then { [testTransaction] args in (args.first as? ((Any) -> Void))?(testTransaction! as Any) } - .thenReturn(Promise.value(())) - mockStorage - .when { $0.write(with: { _ in }, completion: { }) } - .then { [testTransaction] args in - (args.first as? ((Any) -> Void))?(testTransaction! as Any) - (args.last as? (() -> Void))?() - } - .thenReturn(Promise.value(())) - mockStorage - .when { $0.getUserKeyPair() } - .thenReturn( - try! ECKeyPair( - publicKeyData: Data.data(fromHex: TestConstants.publicKey)!, - privateKeyData: Data.data(fromHex: TestConstants.privateKey)! - ) - ) - mockStorage - .when { $0.getUserED25519KeyPair() } - .thenReturn( - Box.KeyPair( - publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, - secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes - ) - ) - mockStorage - .when { $0.getAllOpenGroups() } - .thenReturn([ - "0": testOpenGroup - ]) - mockStorage - .when { $0.getOpenGroup(for: any()) } - .thenReturn(testOpenGroup) - mockStorage - .when { $0.getOpenGroupServer(name: any()) } - .thenReturn( - OpenGroupAPI.Server( - name: "testServer", - capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: []) - ) - ) - mockStorage - .when { $0.getOpenGroupPublicKey(for: any()) } - .thenReturn(TestConstants.publicKey) - mockGenericHash.when { $0.hash(message: anyArray(), outputLength: any()) }.thenReturn([]) mockSodium .when { $0.blindedKeyPair(serverPublicKey: any(), edKeyPair: any(), genericHash: mockGenericHash) } @@ -370,28 +324,17 @@ class OpenGroupManagerSpec: QuickSpec { context("when starting polling") { beforeEach { - mockStorage - .when { $0.getAllOpenGroups() } - .thenReturn([ - "0": testOpenGroup, - "1": OpenGroup( - server: "testServer1", - room: "testRoom1", - publicKey: TestConstants.publicKey, - name: "Test1", - groupDescription: nil, - imageID: nil, - infoUpdates: 0 - ) - ]) - mockStorage.when { $0.removeOpenGroupSequenceNumber(for: any(), on: any(), using: anyAny()) }.thenReturn(()) - mockStorage.when { $0.setOpenGroupPublicKey(for: any(), to: any(), using: anyAny()) }.thenReturn(()) - mockStorage.when { $0.setOpenGroupServer(any(), using: anyAny()) }.thenReturn(()) - mockStorage.when { $0.setOpenGroup(any(), for: any(), using: anyAny()) }.thenReturn(()) - mockStorage.when { $0.setUserCount(to: any(), forOpenGroupWithID: any(), using: anyAny()) }.thenReturn(()) - mockStorage.when { $0.getOpenGroupInboxLatestMessageId(for: any()) }.thenReturn(nil) - mockStorage.when { $0.getOpenGroupOutboxLatestMessageId(for: any()) }.thenReturn(nil) - mockStorage.when { $0.getOpenGroupSequenceNumber(for: any(), on: any()) }.thenReturn(nil) + mockStorage.write { db in + try OpenGroup( + server: "testServer1", + room: "testRoom1", + publicKey: TestConstants.publicKey, + name: "Test1", + groupDescription: nil, + imageID: nil, + infoUpdates: 0 + ).insert(db) + } mockOGMCache.when { $0.hasPerformedInitialPoll }.thenReturn([:]) mockOGMCache.when { $0.timeSinceLastPoll }.thenReturn([:]) @@ -433,28 +376,17 @@ class OpenGroupManagerSpec: QuickSpec { context("when stopping polling") { beforeEach { - mockStorage - .when { $0.getAllOpenGroups() } - .thenReturn([ - "0": testOpenGroup, - "1": OpenGroup( - server: "testServer1", - room: "testRoom1", - publicKey: TestConstants.publicKey, - name: "Test1", - groupDescription: nil, - imageID: nil, - infoUpdates: 0 - ) - ]) - mockStorage.when { $0.removeOpenGroupSequenceNumber(for: any(), on: any(), using: anyAny()) }.thenReturn(()) - mockStorage.when { $0.setOpenGroupPublicKey(for: any(), to: any(), using: anyAny()) }.thenReturn(()) - mockStorage.when { $0.setOpenGroupServer(any(), using: anyAny()) }.thenReturn(()) - mockStorage.when { $0.setOpenGroup(any(), for: any(), using: anyAny()) }.thenReturn(()) - mockStorage.when { $0.setUserCount(to: any(), forOpenGroupWithID: any(), using: anyAny()) }.thenReturn(()) - mockStorage.when { $0.getOpenGroupInboxLatestMessageId(for: any()) }.thenReturn(nil) - mockStorage.when { $0.getOpenGroupOutboxLatestMessageId(for: any()) }.thenReturn(nil) - mockStorage.when { $0.getOpenGroupSequenceNumber(for: any(), on: any()) }.thenReturn(nil) + mockStorage.write { db in + try OpenGroup( + server: "testServer1", + room: "testRoom1", + publicKey: TestConstants.publicKey, + name: "Test1", + groupDescription: nil, + imageId: nil, + infoUpdates: 0 + ).insert(db) + } mockOGMCache.when { $0.isPolling }.thenReturn(true) mockOGMCache.when { $0.pollers }.thenReturn(["testserver": OpenGroupAPI.Poller(for: "testserver")]) diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageSenderEncryptionSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageSenderEncryptionSpec.swift index 77b888308..6e11b9ee3 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageSenderEncryptionSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageSenderEncryptionSpec.swift @@ -1,7 +1,9 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB import Sodium +import SessionUtilitiesKit import Quick import Nimble @@ -12,7 +14,7 @@ class MessageSenderEncryptionSpec: QuickSpec { // MARK: - Spec override func spec() { - var mockStorage: MockStorage! + var mockStorage: GRDBStorage! var mockBox: MockBox! var mockSign: MockSign! var mockNonce24Generator: MockNonce24Generator! @@ -20,7 +22,7 @@ class MessageSenderEncryptionSpec: QuickSpec { describe("a MessageSender") { beforeEach { - mockStorage = MockStorage() + mockStorage = GRDBStorage(customWriter: DatabaseQueue()) mockBox = MockBox() mockSign = MockSign() mockNonce24Generator = MockNonce24Generator() @@ -32,13 +34,10 @@ class MessageSenderEncryptionSpec: QuickSpec { nonceGenerator24: mockNonce24Generator ) - mockStorage.when { $0.getUserED25519KeyPair() } - .thenReturn( - Box.KeyPair( - publicKey: Data(hex: TestConstants.edPublicKey).bytes, - secretKey: Data(hex: TestConstants.edSecretKey).bytes - ) - ) + mockStorage.write { db in + try Identity(variant: .ed25519PublicKey, data: Data(hex: TestConstants.edPublicKey)).insert(db) + try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).insert(db) + } mockNonce24Generator .when { $0.nonce() } .thenReturn(Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")!.bytes) @@ -73,7 +72,10 @@ class MessageSenderEncryptionSpec: QuickSpec { } it("throws an error if there is no ed25519 keyPair") { - mockStorage.when { $0.getUserED25519KeyPair() }.thenReturn(nil) + mockStorage.write { db in + _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) + _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) + } expect { try MessageSender.encryptWithSessionProtocol( @@ -82,7 +84,7 @@ class MessageSenderEncryptionSpec: QuickSpec { using: dependencies ) } - .to(throwError(MessageSender.Error.noUserED25519KeyPair)) + .to(throwError(MessageSenderError.noUserED25519KeyPair)) } it("throws an error if the signature generation fails") { @@ -95,7 +97,7 @@ class MessageSenderEncryptionSpec: QuickSpec { using: dependencies ) } - .to(throwError(MessageSender.Error.signingFailed)) + .to(throwError(MessageSenderError.signingFailed)) } it("throws an error if the encryption fails") { @@ -108,7 +110,7 @@ class MessageSenderEncryptionSpec: QuickSpec { using: dependencies ) } - .to(throwError(MessageSender.Error.encryptionFailed)) + .to(throwError(MessageSenderError.encryptionFailed)) } } @@ -163,11 +165,14 @@ class MessageSenderEncryptionSpec: QuickSpec { using: dependencies ) } - .to(throwError(MessageSender.Error.signingFailed)) + .to(throwError(MessageSenderError.signingFailed)) } it("throws an error if there is no ed25519 keyPair") { - mockStorage.when { $0.getUserED25519KeyPair() }.thenReturn(nil) + mockStorage.write { db in + _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) + _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) + } expect { try MessageSender.encryptWithSessionBlindingProtocol( @@ -177,7 +182,7 @@ class MessageSenderEncryptionSpec: QuickSpec { using: dependencies ) } - .to(throwError(MessageSender.Error.noUserED25519KeyPair)) + .to(throwError(MessageSenderError.noUserED25519KeyPair)) } it("throws an error if it fails to generate a blinded keyPair") { @@ -203,7 +208,7 @@ class MessageSenderEncryptionSpec: QuickSpec { using: dependencies ) } - .to(throwError(MessageSender.Error.signingFailed)) + .to(throwError(MessageSenderError.signingFailed)) } it("throws an error if it fails to generate an encryption key") { @@ -245,7 +250,7 @@ class MessageSenderEncryptionSpec: QuickSpec { using: dependencies ) } - .to(throwError(MessageSender.Error.signingFailed)) + .to(throwError(MessageSenderError.signingFailed)) } it("throws an error if it fails to encrypt") { @@ -264,7 +269,7 @@ class MessageSenderEncryptionSpec: QuickSpec { using: dependencies ) } - .to(throwError(MessageSender.Error.encryptionFailed)) + .to(throwError(MessageSenderError.encryptionFailed)) } } } diff --git a/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift b/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift index cf5021489..b4a57bd99 100644 --- a/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift +++ b/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift @@ -2,15 +2,15 @@ import Foundation import SessionSnodeKit +import SessionUtilitiesKit @testable import SessionMessagingKit extension Dependencies { public func with( onionApi: OnionRequestAPIType.Type? = nil, - identityManager: IdentityManagerProtocol? = nil, generalCache: Atomic? = nil, - storage: SessionMessagingKitStorageProtocol? = nil, + storage: GRDBStorage? = nil, sodium: SodiumType? = nil, box: BoxType? = nil, genericHash: GenericHashType? = nil, @@ -24,7 +24,6 @@ extension Dependencies { ) -> Dependencies { return Dependencies( onionApi: (onionApi ?? self._onionApi), - identityManager: (identityManager ?? self._identityManager), generalCache: (generalCache ?? self._generalCache), storage: (storage ?? self._storage), sodium: (sodium ?? self._sodium), diff --git a/SessionMessagingKitTests/_TestUtilities/MockGeneralCache.swift b/SessionMessagingKitTests/_TestUtilities/MockGeneralCache.swift index 9a0d5d0a6..c47b8d6eb 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockGeneralCache.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockGeneralCache.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import SessionUtilitiesKit @testable import SessionMessagingKit diff --git a/SessionMessagingKitTests/_TestUtilities/MockIdentityManager.swift b/SessionMessagingKitTests/_TestUtilities/MockIdentityManager.swift deleted file mode 100644 index a5aec7c6a..000000000 --- a/SessionMessagingKitTests/_TestUtilities/MockIdentityManager.swift +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -@testable import SessionMessagingKit - -class MockIdentityManager: Mock, IdentityManagerProtocol { - func identityKeyPair() -> ECKeyPair? { return accept() as? ECKeyPair } -} diff --git a/SessionMessagingKitTests/_TestUtilities/MockStorage.swift b/SessionMessagingKitTests/_TestUtilities/MockStorage.swift deleted file mode 100644 index 4f6b46c0f..000000000 --- a/SessionMessagingKitTests/_TestUtilities/MockStorage.swift +++ /dev/null @@ -1,228 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import PromiseKit -import Sodium - -@testable import SessionMessagingKit - -class MockStorage: Mock, SessionMessagingKitStorageProtocol { - // MARK: - Shared - - @discardableResult func write(with block: @escaping (Any) -> Void) -> Promise { - return accept(args: [block]) as! Promise - } - - @discardableResult func write(with block: @escaping (Any) -> Void, completion: @escaping () -> Void) -> Promise { - return accept(args: [block, completion]) as! Promise - } - - func writeSync(with block: @escaping (Any) -> Void) { - accept(args: [block]) - } - - // MARK: - General - - func getUserPublicKey() -> String? { return accept() as? String } - func getUserKeyPair() -> ECKeyPair? { return accept() as? ECKeyPair } - func getUserED25519KeyPair() -> Box.KeyPair? { return accept() as? Box.KeyPair } - func getUser() -> Contact? { return accept() as? Contact } - func getUser(using transaction: YapDatabaseReadTransaction?) -> Contact? { - return accept(args: [transaction]) as? Contact - } - - // MARK: - Contacts - - func getContact(with sessionID: String) -> Contact? { return accept(args: [sessionID]) as? Contact } - func getContact(with sessionID: String, using transaction: Any) -> Contact? { - return accept(args: [sessionID, transaction]) as? Contact - } - func setContact(_ contact: Contact, using transaction: Any) { accept(args: [contact, transaction]) } - func getAllContacts() -> Set { return accept() as! Set } - func getAllContacts(with transaction: YapDatabaseReadTransaction) -> Set { return accept() as! Set } - - // MARK: - Blinded Id cache - - func getBlindedIdMapping(with blindedId: String) -> BlindedIdMapping? { - return accept(args: [blindedId]) as? BlindedIdMapping - } - - func getBlindedIdMapping(with blindedId: String, using transaction: YapDatabaseReadTransaction) -> BlindedIdMapping? { - return accept(args: [blindedId, transaction]) as? BlindedIdMapping - } - - func cacheBlindedIdMapping(_ mapping: BlindedIdMapping) { accept(args: [mapping]) } - func cacheBlindedIdMapping(_ mapping: BlindedIdMapping, using transaction: YapDatabaseReadWriteTransaction) { - accept(args: [mapping, transaction]) - } - func enumerateBlindedIdMapping(with block: @escaping (BlindedIdMapping, UnsafeMutablePointer) -> ()) { - accept(args: [block]) - } - func enumerateBlindedIdMapping(using transaction: YapDatabaseReadTransaction, with block: @escaping (BlindedIdMapping, UnsafeMutablePointer) -> ()) { - accept(args: [transaction, block]) - } - - // MARK: - Closed Groups - - func getClosedGroupEncryptionKeyPairs(for groupPublicKey: String) -> [ECKeyPair] { - return accept(args: [groupPublicKey]) as! [ECKeyPair] - } - func getLatestClosedGroupEncryptionKeyPair(for groupPublicKey: String) -> ECKeyPair? { - return accept(args: [groupPublicKey]) as? ECKeyPair - } - func addClosedGroupEncryptionKeyPair(_ keyPair: ECKeyPair, for groupPublicKey: String, using transaction: Any) { - accept(args: [keyPair, groupPublicKey, transaction]) - } - func removeAllClosedGroupEncryptionKeyPairs(for groupPublicKey: String, using transaction: Any) { - accept(args: [groupPublicKey, transaction]) - } - func getUserClosedGroupPublicKeys() -> Set { return accept() as! Set } - func getUserClosedGroupPublicKeys(using transaction: YapDatabaseReadTransaction) -> Set { - return accept(args: [transaction]) as! Set - } - func getZombieMembers(for groupPublicKey: String) -> Set { return accept(args: [groupPublicKey]) as! Set } - func setZombieMembers(for groupPublicKey: String, to zombies: Set, using transaction: Any) { - accept(args: [groupPublicKey, zombies, transaction]) - } - func isClosedGroup(_ publicKey: String) -> Bool { return accept(args: [publicKey]) as! Bool } - func isClosedGroup(_ publicKey: String, using transaction: YapDatabaseReadTransaction) -> Bool { - return accept(args: [publicKey, transaction]) as! Bool - } - - // MARK: - Jobs - - func persist(_ job: Job, using transaction: Any) { accept(args: [job, transaction]) } - func markJobAsSucceeded(_ job: Job, using transaction: Any) { accept(args: [job, transaction]) } - func markJobAsFailed(_ job: Job, using transaction: Any) { accept(args: [job, transaction]) } - func getAllPendingJobs(of type: Job.Type) -> [Job] { - return accept(args: [type]) as! [Job] - } - func getAttachmentUploadJob(for attachmentID: String) -> AttachmentUploadJob? { - return accept(args: [attachmentID]) as? AttachmentUploadJob - } - func getMessageSendJob(for messageSendJobID: String) -> MessageSendJob? { - return accept(args: [messageSendJobID]) as? MessageSendJob - } - func getMessageSendJob(for messageSendJobID: String, using transaction: Any) -> MessageSendJob? { - return accept(args: [messageSendJobID, transaction]) as? MessageSendJob - } - func resumeMessageSendJobIfNeeded(_ messageSendJobID: String) { accept(args: [messageSendJobID]) } - func isJobCanceled(_ job: Job) -> Bool { - return accept(args: [job]) as! Bool - } - - // MARK: - Open Groups - - func getAllOpenGroups() -> [String: OpenGroup] { return accept() as! [String: OpenGroup] } - func getThreadID(for v2OpenGroupID: String) -> String? { return accept(args: [v2OpenGroupID]) as? String } - - func getOpenGroupImage(for room: String, on server: String) -> Data? { return accept(args: [room, server]) as? Data } - func setOpenGroupImage(to data: Data, for room: String, on server: String, using transaction: Any) { - accept(args: [data, room, server, transaction]) - } - - func getOpenGroup(for threadID: String) -> OpenGroup? { return accept(args: [threadID]) as? OpenGroup } - func setOpenGroup(_ openGroup: OpenGroup, for threadID: String, using transaction: Any) { - accept(args: [openGroup, threadID, transaction]) - } - func removeOpenGroup(for threadID: String, using transaction: Any) { accept(args: [threadID, transaction]) } - func getOpenGroupServer(name: String) -> OpenGroupAPI.Server? { return accept(args: [name]) as? OpenGroupAPI.Server } - func setOpenGroupServer(_ server: OpenGroupAPI.Server, using transaction: Any) { accept(args: [server, transaction]) } - func removeOpenGroupServer(name: String, using transaction: Any) { - accept(args: [name, transaction]) - } - - func getUserCount(forOpenGroupWithID openGroupID: String) -> UInt64? { return accept(args: [openGroupID]) as? UInt64 } - func setUserCount(to newValue: UInt64, forOpenGroupWithID openGroupID: String, using transaction: Any) { - accept(args: [newValue, openGroupID, transaction]) - } - - func getOpenGroupSequenceNumber(for room: String, on server: String) -> Int64? { - return accept(args: [room, server]) as? Int64 - } - func setOpenGroupSequenceNumber(for room: String, on server: String, to newValue: Int64, using transaction: Any) { - accept(args: [room, server, newValue, transaction]) - } - func removeOpenGroupSequenceNumber(for room: String, on server: String, using transaction: Any) { - accept(args: [room, server, transaction]) - } - - func getOpenGroupServerIdLookup(_ serverId: UInt64, in room: String, on server: String, using transaction: YapDatabaseReadTransaction) -> OpenGroupServerIdLookup? { - return accept(args: [serverId, room, server, transaction]) as? OpenGroupServerIdLookup - } - func addOpenGroupServerIdLookup(_ serverId: UInt64?, tsMessageId: String?, in room: String, on server: String, using transaction: YapDatabaseReadWriteTransaction) { - accept(args: [serverId, tsMessageId, room, server, transaction]) - } - func addOpenGroupServerIdLookup(_ lookup: OpenGroupServerIdLookup, using transaction: YapDatabaseReadWriteTransaction) { - accept(args: [lookup, transaction]) - } - func removeOpenGroupServerIdLookup(_ serverId: UInt64, in room: String, on server: String, using transaction: YapDatabaseReadWriteTransaction) { - accept(args: [serverId, room, server, transaction]) - } - - func getOpenGroupInboxLatestMessageId(for server: String) -> Int64? { return accept(args: [server]) as? Int64 } - func setOpenGroupInboxLatestMessageId(for server: String, to newValue: Int64, using transaction: Any) { - accept(args: [server, newValue, transaction]) - } - func removeOpenGroupInboxLatestMessageId(for server: String, using transaction: Any) { accept(args: [server, transaction]) } - - func getOpenGroupOutboxLatestMessageId(for server: String) -> Int64? { return accept(args: [server]) as? Int64 } - func setOpenGroupOutboxLatestMessageId(for server: String, to newValue: Int64, using transaction: Any) { - accept(args: [server, newValue, transaction]) - } - func removeOpenGroupOutboxLatestMessageId(for server: String, using transaction: Any) { - accept(args: [server, transaction]) - } - - // MARK: - Open Group Public Keys - - func getOpenGroupPublicKey(for server: String) -> String? { return accept(args: [server]) as? String } - func setOpenGroupPublicKey(for server: String, to newValue: String, using transaction: Any) { - accept(args: [server, newValue, transaction]) - } - func removeOpenGroupPublicKey(for server: String, using transaction: Any) { accept(args: [server, transaction]) } - - // MARK: - Message Handling - - func getAllMessageRequestThreads() -> [String: TSContactThread] { return accept() as! [String: TSContactThread] } - func getAllMessageRequestThreads(using transaction: YapDatabaseReadTransaction) -> [String: TSContactThread] { - return accept(args: [transaction]) as! [String: TSContactThread] - } - - func getReceivedMessageTimestamps(using transaction: Any) -> [UInt64] { - return accept(args: [transaction]) as! [UInt64] - } - - func removeReceivedMessageTimestamps(_ timestamps: Set, using transaction: Any) { - accept(args: [timestamps, transaction]) - } - func addReceivedMessageTimestamp(_ timestamp: UInt64, using transaction: Any) { - accept(args: [timestamp, transaction]) - } - - func getOrCreateThread(for publicKey: String, groupPublicKey: String?, openGroupID: String?, using transaction: Any) -> String? { - return accept(args: [publicKey, groupPublicKey, openGroupID, transaction]) as? String - } - func persist(_ message: VisibleMessage, quotedMessage: TSQuotedMessage?, linkPreview: OWSLinkPreview?, groupPublicKey: String?, openGroupID: String?, using transaction: Any) -> String? { - return accept(args: [message, quotedMessage, linkPreview, groupPublicKey, openGroupID, transaction]) as? String - } - func persist(_ attachments: [VisibleMessage.Attachment], using transaction: Any) -> [String] { - return accept(args: [attachments, transaction]) as! [String] - } - func setAttachmentState(to state: TSAttachmentPointerState, for pointer: TSAttachmentPointer, associatedWith tsIncomingMessageID: String, using transaction: Any) { - accept(args: [state, pointer, tsIncomingMessageID, transaction]) - } - func persist(_ stream: TSAttachmentStream, associatedWith tsIncomingMessageID: String, using transaction: Any) { - accept(args: [stream, tsIncomingMessageID, transaction]) - } - - // MARK: - Calls - - func getReceivedCalls(for publicKey: String, using transaction: Any) -> Set { - return accept(args: [publicKey, transaction]) as! Set - } - - func setReceivedCalls(to receivedCalls: Set, for publicKey: String, using transaction: Any) { - accept(args: [receivedCalls, publicKey, transaction]) - } -} diff --git a/SessionMessagingKitTests/_TestUtilities/MockedExtensions.swift b/SessionMessagingKitTests/_TestUtilities/MockedExtensions.swift index 3763fd517..cf0b1152c 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockedExtensions.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockedExtensions.swift @@ -6,19 +6,18 @@ import SessionMessagingKit extension OpenGroup: Mocked { static var mockValue: OpenGroup = OpenGroup( server: any(), - room: any(), + roomToken: any(), publicKey: TestConstants.publicKey, name: any(), - groupDescription: any(), - imageID: any(), - infoUpdates: any() - ) -} - -extension OpenGroupAPI.Server: Mocked { - static var mockValue: OpenGroupAPI.Server = OpenGroupAPI.Server( - name: any(), - capabilities: OpenGroupAPI.Capabilities(capabilities: anyArray(), missing: anyArray()) + isActive: any(), + roomDescription: any(), + imageId: any(), + imageData: any(), + userCount: any(), + infoUpdates: any(), + sequenceNumber: any(), + inboxLatestMessageId: any(), + outboxLatestMessageId: any() ) } diff --git a/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift b/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift index dd0a8413b..fa5883b73 100644 --- a/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift +++ b/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift @@ -2,6 +2,7 @@ import Foundation import SessionSnodeKit +import SessionUtilitiesKit @testable import SessionMessagingKit @@ -9,9 +10,8 @@ extension OpenGroupManager.OGMDependencies { public func with( cache: Atomic? = nil, onionApi: OnionRequestAPIType.Type? = nil, - identityManager: IdentityManagerProtocol? = nil, generalCache: Atomic? = nil, - storage: SessionMessagingKitStorageProtocol? = nil, + storage: GRDBStorage? = nil, sodium: SodiumType? = nil, box: BoxType? = nil, genericHash: GenericHashType? = nil, @@ -26,7 +26,6 @@ extension OpenGroupManager.OGMDependencies { return OpenGroupManager.OGMDependencies( cache: (cache ?? self._mutableCache), onionApi: (onionApi ?? self._onionApi), - identityManager: (identityManager ?? self._identityManager), generalCache: (generalCache ?? self._generalCache), storage: (storage ?? self._storage), sodium: (sodium ?? self._sodium), diff --git a/SessionMessagingKitTests/_TestUtilities/TestOnionRequestAPI.swift b/SessionMessagingKitTests/_TestUtilities/TestOnionRequestAPI.swift index 034a2b565..33039ef03 100644 --- a/SessionMessagingKitTests/_TestUtilities/TestOnionRequestAPI.swift +++ b/SessionMessagingKitTests/_TestUtilities/TestOnionRequestAPI.swift @@ -3,6 +3,7 @@ import Foundation import PromiseKit import SessionSnodeKit +import SessionUtilitiesKit @testable import SessionMessagingKit @@ -16,7 +17,7 @@ class TestOnionRequestAPI: OnionRequestAPIType { let body: Data? let server: String - let version: OnionRequestAPI.Version + let version: OnionRequestAPIVersion let publicKey: String? } class ResponseInfo: OnionRequestResponseInfoType { @@ -33,7 +34,7 @@ class TestOnionRequestAPI: OnionRequestAPIType { class var mockResponse: Data? { return nil } - static func sendOnionRequest(_ request: URLRequest, to server: String, using version: OnionRequestAPI.Version, with x25519PublicKey: String) -> Promise<(OnionRequestResponseInfoType, Data?)> { + static func sendOnionRequest(_ request: URLRequest, to server: String, using version: OnionRequestAPIVersion, with x25519PublicKey: String) -> Promise<(OnionRequestResponseInfoType, Data?)> { let responseInfo: ResponseInfo = ResponseInfo( requestData: RequestData( urlString: request.url?.absoluteString, @@ -53,7 +54,7 @@ class TestOnionRequestAPI: OnionRequestAPIType { return Promise.value((responseInfo, mockResponse)) } - static func sendOnionRequest(to snode: Snode, invoking method: Snode.Method, with parameters: JSON, using version: OnionRequestAPI.Version, associatedWith publicKey: String?) -> Promise { + static func sendOnionRequest(to snode: Snode, invoking method: SnodeAPIEndpoint, with parameters: JSON, using version: OnionRequestAPIVersion, associatedWith publicKey: String?) -> Promise { return Promise.value(mockResponse!) } } diff --git a/SessionSnodeKit/OnionRequestAPI.swift b/SessionSnodeKit/OnionRequestAPI.swift index 860c0af76..918fa00e1 100644 --- a/SessionSnodeKit/OnionRequestAPI.swift +++ b/SessionSnodeKit/OnionRequestAPI.swift @@ -1,3 +1,5 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + import Foundation import CryptoSwift import GRDB diff --git a/SessionUtilitiesKit/Database/GRDBStorage.swift b/SessionUtilitiesKit/Database/GRDBStorage.swift index 11f74acec..bccd9cb6e 100644 --- a/SessionUtilitiesKit/Database/GRDBStorage.swift +++ b/SessionUtilitiesKit/Database/GRDBStorage.swift @@ -26,17 +26,17 @@ public final class GRDBStorage { public private(set) var isValid: Bool = false public private(set) var hasCompletedMigrations: Bool = false - private var dbPool: DatabasePool? + private var dbWriter: DatabaseWriter? private var migrator: DatabaseMigrator? private var migrationProgressUpdater: Atomic<((String, CGFloat) -> ())>? // MARK: - Initialization - public init() { // if CurrentAppContext().isMainApp { // GRDBStorage.deleteDatabaseFiles() // TODO: Remove this. // try! GRDBStorage.deleteDbKeys() // TODO: Remove this. // } + public init(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 @@ -76,9 +76,16 @@ public final class GRDBStorage { try db.execute(sql: "PRAGMA cipher_plaintext_header_size = 32") } + // If a custom writer was provided then use that (for unit testing) + guard customWriter == nil else { + dbWriter = customWriter + isValid = true + return + } + // Create the DatabasePool to allow us to connect to the database and mark the storage as valid do { - dbPool = try DatabasePool( + dbWriter = try DatabasePool( path: "\(GRDBStorage.sharedDatabaseDirectoryPath)/\(GRDBStorage.dbFileName)", configuration: config ) @@ -94,7 +101,7 @@ public final class GRDBStorage { onProgressUpdate: ((CGFloat, TimeInterval) -> ())?, onComplete: @escaping (Bool, Bool) -> () ) { - guard isValid, let dbPool: DatabasePool = dbPool else { return } + guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return } typealias MigrationInfo = (identifier: TargetMigrations.Identifier, migrations: TargetMigrations.MigrationSet) let sortedMigrationInfo: [MigrationInfo] = migrations @@ -124,7 +131,7 @@ public final class GRDBStorage { // Determine which migrations need to be performed and gather the relevant settings needed to // inform the app of progress/states - let completedMigrations: [String] = (try? dbPool.read { db in try migrator?.completedMigrations(db) }) + let completedMigrations: [String] = (try? dbWriter.read { db in try migrator?.completedMigrations(db) }) .defaulting(to: []) let unperformedMigrations: [(key: String, migration: Migration.Type)] = sortedMigrationInfo .reduce(into: []) { result, next in @@ -167,7 +174,7 @@ public final class GRDBStorage { self.migrationProgressUpdater?.wrappedValue(firstMigrationKey, 0) } - self.migrator?.asyncMigrate(dbPool) { [weak self] _, error in + self.migrator?.asyncMigrate(dbWriter) { [weak self] _, error in self?.hasCompletedMigrations = true self?.migrationProgressUpdater = nil SUKLegacy.clearLegacyDatabaseInstance() @@ -280,9 +287,9 @@ public final class GRDBStorage { // MARK: - Functions @discardableResult public func write(updates: (Database) throws -> T?) -> T? { - guard isValid, let dbPool: DatabasePool = dbPool else { return nil } + guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return nil } - return try? dbPool.write(updates) + return try? dbWriter.write(updates) } public func writeAsync(updates: @escaping (Database) throws -> T) { @@ -290,9 +297,9 @@ public final class GRDBStorage { } public func writeAsync(updates: @escaping (Database) throws -> T, completion: @escaping (Database, Swift.Result) throws -> Void) { - guard isValid, let dbPool: DatabasePool = dbPool else { return } + guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return } - dbPool.asyncWrite( + dbWriter.asyncWrite( updates, completion: { db, result in try? completion(db, result) @@ -301,9 +308,9 @@ public final class GRDBStorage { } @discardableResult public func read(_ value: (Database) throws -> T?) -> T? { - guard isValid, let dbPool: DatabasePool = dbPool else { return nil } + guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return nil } - return try? dbPool.read(value) + return try? dbWriter.read(value) } /// Rever to the `ValueObservation.start` method for full documentation @@ -321,10 +328,10 @@ public final class GRDBStorage { onError: @escaping (Error) -> Void, onChange: @escaping (Reducer.Value) -> Void ) -> DatabaseCancellable { - guard isValid, let dbPool: DatabasePool = dbPool else { return AnyDatabaseCancellable(cancel: {}) } + guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return AnyDatabaseCancellable(cancel: {}) } return observation.start( - in: dbPool, + in: dbWriter, scheduling: scheduler, onError: onError, onChange: onChange @@ -332,17 +339,17 @@ public final class GRDBStorage { } public func addObserver(_ observer: TransactionObserver?) { - guard isValid, let dbPool: DatabasePool = dbPool else { return } + guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return } guard let observer: TransactionObserver = observer else { return } - dbPool.add(transactionObserver: observer) + dbWriter.add(transactionObserver: observer) } public func removeObserver(_ observer: TransactionObserver?) { - guard isValid, let dbPool: DatabasePool = dbPool else { return } + guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return } guard let observer: TransactionObserver = observer else { return } - dbPool.remove(transactionObserver: observer) + dbWriter.remove(transactionObserver: observer) } } @@ -351,10 +358,12 @@ public final class GRDBStorage { public extension GRDBStorage { // FIXME: Would be good to replace these with Swift Combine @discardableResult func read(_ value: (Database) throws -> Promise) -> Promise { - guard isValid, let dbPool: DatabasePool = dbPool else { return Promise(error: StorageError.databaseInvalid) } + guard isValid, let dbWriter: DatabaseWriter = dbWriter else { + return Promise(error: StorageError.databaseInvalid) + } do { - return try dbPool.read(value) + return try dbWriter.read(value) } catch { return Promise(error: error) @@ -362,10 +371,12 @@ public extension GRDBStorage { } @discardableResult func write(updates: (Database) throws -> Promise) -> Promise { - guard isValid, let dbPool: DatabasePool = dbPool else { return Promise(error: StorageError.databaseInvalid) } + guard isValid, let dbWriter: DatabaseWriter = dbWriter else { + return Promise(error: StorageError.databaseInvalid) + } do { - return try dbPool.write(updates) + return try dbWriter.write(updates) } catch { return Promise(error: error) diff --git a/SessionUtilitiesKit/Database/Models/Identity.swift b/SessionUtilitiesKit/Database/Models/Identity.swift index aa96971d7..39517fa58 100644 --- a/SessionUtilitiesKit/Database/Models/Identity.swift +++ b/SessionUtilitiesKit/Database/Models/Identity.swift @@ -27,6 +27,16 @@ public struct Identity: Codable, Identifiable, FetchableRecord, PersistableRecor let variant: Variant let data: Data + + // MARK: - Initialization + + public init( + variant: Variant, + data: Data + ) { + self.variant = variant + self.data = data + } } // MARK: - Convenience From 1720e85e8f4218851f4daedbd7940cb315deb73f Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 20 Jun 2022 18:12:19 +1000 Subject: [PATCH 105/157] Updated the Sodium library to fix a production linker error Fixed a missing import/public modifier --- Podfile | 4 +- Podfile.lock | 4 +- Session.xcodeproj/project.pbxproj | 44 +++++++++---------- Session/Shared/OWSScreenLockUI.m | 1 + .../Utilities/Environment.swift | 2 +- .../Utilities/Sodium+Utilities.swift | 16 +++---- 6 files changed, 36 insertions(+), 35 deletions(-) diff --git a/Podfile b/Podfile index 706c57ff3..705f23a53 100644 --- a/Podfile +++ b/Podfile @@ -1,4 +1,4 @@ -platform :ios, '12.0' +platform :ios, '13.0' source 'https://github.com/CocoaPods/Specs.git' use_frameworks! @@ -107,7 +107,7 @@ end def set_minimum_deployment_target(installer) installer.pods_project.targets.each do |target| target.build_configurations.each do |build_configuration| - build_configuration.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '12.0' + build_configuration.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0' end end end diff --git a/Podfile.lock b/Podfile.lock index 160625ab4..70045a1da 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -196,7 +196,7 @@ CHECKOUT OPTIONS: :commit: 4590c2737a2b5dc0ef4ace9f9019b581caccc1de :git: https://github.com/oxen-io/session-ios-core-kit Sodium: - :commit: 6d4317cd4c67e7a617d474d7c5bf20d319aa4536 + :commit: 4ecfe2ddfd75e7b396c57975b4163e5c8cf4d5cc :git: https://github.com/oxen-io/session-ios-swift-sodium.git YapDatabase: :commit: d84069e25e12a16ab4422e5258127a04b70489ad @@ -230,6 +230,6 @@ SPEC CHECKSUMS: YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331 ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: 386b63ccd9f91d308417daddf35710eadca22e03 +PODFILE CHECKSUM: 6ab902a81a379cc2c0a9a92c334c78d413190338 COCOAPODS: 1.11.3 diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 1697b0a96..d640c35fc 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -782,6 +782,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 */; }; + FDC6D6F32860607300B04575 /* Environment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7542807C4BB004C14C5 /* Environment.swift */; }; FDCDB8DE2810F73B00352A0C /* Differentiable+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DD2810F73B00352A0C /* Differentiable+Utilities.swift */; }; FDCDB8E02811007F00352A0C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */; }; FDCDB8E42817819600352A0C /* (null) in Sources */ = {isa = PBXBuildFile; }; @@ -798,7 +799,6 @@ FDF0B74B28061F7A004C14C5 /* InteractionAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B74A28061F7A004C14C5 /* InteractionAttachment.swift */; }; FDF0B74F28079E5E004C14C5 /* SendReadReceiptsJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B74E28079E5E004C14C5 /* SendReadReceiptsJob.swift */; }; FDF0B7512807BA56004C14C5 /* NotificationsProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7502807BA56004C14C5 /* NotificationsProtocol.swift */; }; - FDF0B7552807C4BB004C14C5 /* Environment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7542807C4BB004C14C5 /* Environment.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 */; }; @@ -1693,7 +1693,7 @@ FD17D7E627F6A16700122BE0 /* _003_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _003_YDBToGRDBMigration.swift; sourceTree = ""; }; FD17D7E927F6A1C600122BE0 /* SUKLegacy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SUKLegacy.swift; sourceTree = ""; }; FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationBar+Utilities.swift"; sourceTree = ""; }; - FD245C612850664300B966DD /* Configuration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Configuration.swift; path = SessionMessagingKit/Configuration.swift; sourceTree = ""; }; + FD245C612850664300B966DD /* Configuration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = ""; }; FD28A4F327EA79F800FF65E7 /* BlockListUIUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockListUIUtils.swift; sourceTree = ""; }; FD28A4F527EAD44C00FF65E7 /* GRDBStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GRDBStorage.swift; sourceTree = ""; }; FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadPickerViewModel.swift; sourceTree = ""; }; @@ -3170,6 +3170,7 @@ C3A721332558BDDF0043A11F /* Open Groups */, FD3E0C82283B581F002A425C /* Shared Models */, C3BBE0B32554F0D30050F1E3 /* Utilities */, + FD245C612850664300B966DD /* Configuration.swift */, ); path = SessionMessagingKit; sourceTree = ""; @@ -3328,7 +3329,6 @@ C33FD9AC255A548A00E217F9 /* SignalUtilitiesKit */, C331FF1C2558F9D300070591 /* SessionUIKit */, C3C2A6F125539DE700C340D1 /* SessionMessagingKit */, - FD245C612850664300B966DD /* Configuration.swift */, C3C2A5A0255385C100C340D1 /* SessionSnodeKit */, C3C2A67A255388CC00C340D1 /* SessionUtilitiesKit */, FD83B9BC27CF2215005E1583 /* SharedTest */, @@ -5150,13 +5150,13 @@ B8B320B7258C30D70020074B /* HTMLMetadata.swift in Sources */, FD09798727FD1B7800936362 /* GroupMember.swift in Sources */, FDB4BBC92839BEF000B7C95D /* ProfileManagerError.swift in Sources */, - FDF0B7552807C4BB004C14C5 /* Environment.swift in Sources */, FD3E0C84283B5835002A425C /* SessionThreadViewModel.swift in Sources */, FD09C5EC282B8F18000CE219 /* AttachmentError.swift in Sources */, FD17D79927F40AB800122BE0 /* _003_YDBToGRDBMigration.swift in Sources */, FDF0B7512807BA56004C14C5 /* NotificationsProtocol.swift in Sources */, FDC4387427B5BB9B00C60D73 /* Promise+Utilities.swift in Sources */, B8DE1FB426C22F2F0079C9CE /* WebRTCSession.swift in Sources */, + FDC6D6F32860607300B04575 /* Environment.swift in Sources */, C3A3A171256E1D25004D228D /* SSKReachabilityManager.swift in Sources */, FD245C59285065FC00B966DD /* ControlMessage.swift in Sources */, B8DE1FB626C22FCB0079C9CE /* CallMessage.swift in Sources */, @@ -5959,7 +5959,7 @@ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; @@ -6028,7 +6028,7 @@ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -6098,7 +6098,7 @@ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; @@ -6175,7 +6175,7 @@ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -6253,7 +6253,7 @@ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; @@ -6322,7 +6322,7 @@ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -6392,7 +6392,7 @@ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; @@ -6470,7 +6470,7 @@ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -6549,7 +6549,7 @@ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; @@ -6618,7 +6618,7 @@ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -6690,6 +6690,7 @@ CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_RECEIVER_WEAK = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_IMPLICIT_CONVERSION = YES; @@ -6766,6 +6767,7 @@ CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_RECEIVER_WEAK = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_IMPLICIT_CONVERSION = YES; @@ -6824,7 +6826,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = 56F41C56FC7B2F381E440FB0 /* Pods-GlobalDependencies-Session.debug.xcconfig */; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ADDRESS_SANITIZER_CONTAINER_OVERFLOW = YES; CLANG_ANALYZER_SECURITY_FLOATLOOPCOUNTER = YES; @@ -6841,7 +6843,6 @@ FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(SRCROOT)", - "$(PROJECT_DIR)/Dependencies", ); GCC_OPTIMIZATION_LEVEL = 0; GCC_PRECOMPILE_PREFIX_HEADER = YES; @@ -6899,7 +6900,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = 6BE8FBF62464A7177034A0AB /* Pods-GlobalDependencies-Session.app store release.xcconfig */; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ADDRESS_SANITIZER_CONTAINER_OVERFLOW = NO; CLANG_ANALYZER_SECURITY_FLOATLOOPCOUNTER = YES; @@ -6914,7 +6915,6 @@ FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(SRCROOT)", - "$(PROJECT_DIR)/Dependencies", ); GCC_OPTIMIZATION_LEVEL = 3; GCC_PRECOMPILE_PREFIX_HEADER = YES; @@ -6980,7 +6980,7 @@ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; @@ -7033,7 +7033,7 @@ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -7086,7 +7086,7 @@ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; @@ -7139,7 +7139,7 @@ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; diff --git a/Session/Shared/OWSScreenLockUI.m b/Session/Shared/OWSScreenLockUI.m index a95a42c80..59d13d917 100644 --- a/Session/Shared/OWSScreenLockUI.m +++ b/Session/Shared/OWSScreenLockUI.m @@ -6,6 +6,7 @@ #import "OWSWindowManager.h" #import "Session-Swift.h" #import +#import #import #import diff --git a/SessionMessagingKit/Utilities/Environment.swift b/SessionMessagingKit/Utilities/Environment.swift index 0bf48760c..1309225fe 100644 --- a/SessionMessagingKit/Utilities/Environment.swift +++ b/SessionMessagingKit/Utilities/Environment.swift @@ -52,7 +52,7 @@ public class Environment { // MARK: - Objective C Support @objc(SMKEnvironment) -class SMKEnvironment: NSObject { +public class SMKEnvironment: NSObject { @objc public static let shared: SMKEnvironment = SMKEnvironment() @objc public var audioSession: OWSAudioSession { Environment.shared.audioSession } diff --git a/SessionMessagingKit/Utilities/Sodium+Utilities.swift b/SessionMessagingKit/Utilities/Sodium+Utilities.swift index e0f953130..bdc48469f 100644 --- a/SessionMessagingKit/Utilities/Sodium+Utilities.swift +++ b/SessionMessagingKit/Utilities/Sodium+Utilities.swift @@ -17,7 +17,7 @@ import SessionUtilitiesKit /// https://github.com/apple/swift-evolution/blob/master/proposals/0055-optional-unsafe-pointers.md#unsafebufferpointer extension Sodium { private static let scalarLength: Int = Int(crypto_core_ed25519_scalarbytes()) // 32 - private static let noClampLength: Int = Int(crypto_scalarmult_ed25519_bytes()) // 32 + private static let noClampLength: Int = Int(Sodium.lib_crypto_scalarmult_ed25519_bytes()) // 32 private static let scalarMultLength: Int = Int(crypto_scalarmult_bytes()) // 32 private static let publicKeyLength: Int = Int(crypto_scalarmult_bytes()) // 32 private static let secretKeyLength: Int = Int(crypto_sign_secretkeybytes()) // 64 @@ -38,7 +38,7 @@ extension Sodium { return -1 // Impossible case (refer to comments at top of extension) } - crypto_core_ed25519_scalar_reduce(kPtr, serverPublicKeyHashBaseAddress) + Sodium.lib_crypto_core_ed25519_scalar_reduce(kPtr, serverPublicKeyHashBaseAddress) return 0 } @@ -89,7 +89,7 @@ extension Sodium { return -1 // Impossible case (refer to comments at top of extension) } - crypto_core_ed25519_scalar_mul(kaPtr, kBaseAddress, aBaseAddress) + Sodium.lib_crypto_core_ed25519_scalar_mul(kaPtr, kBaseAddress, aBaseAddress) return 0 } } @@ -118,7 +118,7 @@ extension Sodium { return -1 // Impossible case (refer to comments at top of extension) } - crypto_core_ed25519_scalar_reduce(rPtr, combinedHashBaseAddress) + Sodium.lib_crypto_core_ed25519_scalar_reduce(rPtr, combinedHashBaseAddress) return 0 } @@ -136,7 +136,7 @@ extension Sodium { return -1 // Impossible case (refer to comments at top of extension) } - crypto_core_ed25519_scalar_reduce(HRAMPtr, HRAMHashBaseAddress) + Sodium.lib_crypto_core_ed25519_scalar_reduce(HRAMPtr, HRAMHashBaseAddress) return 0 } @@ -149,8 +149,8 @@ extension Sodium { return -1 // Impossible case (refer to comments at top of extension) } - crypto_core_ed25519_scalar_mul(sig_sMulPtr, HRAMPtr, kaBaseAddress) - crypto_core_ed25519_scalar_add(sig_sPtr, rPtr, sig_sMulPtr) + Sodium.lib_crypto_core_ed25519_scalar_mul(sig_sMulPtr, HRAMPtr, kaBaseAddress) + Sodium.lib_crypto_core_ed25519_scalar_add(sig_sPtr, rPtr, sig_sMulPtr) return 0 } @@ -171,7 +171,7 @@ extension Sodium { return -1 // Impossible case (refer to comments at top of extension) } - return crypto_scalarmult_ed25519_noclamp(combinedPtr, lhsKeyBytesBaseAddress, rhsKeyBytesBaseAddress) + return Sodium.lib_crypto_scalarmult_ed25519_noclamp(combinedPtr, lhsKeyBytesBaseAddress, rhsKeyBytesBaseAddress) } } From 4133a49a34473a608c2ab3a5d32409ab8f10f5f3 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 21 Jun 2022 13:39:46 +1000 Subject: [PATCH 106/157] Made a couple of tweaks to the GRDBStorage interface Updated the ControlMessageProcessRecord to allow for duplicate handling of UnsendRequest messages --- .../Calls/Call Management/SessionCall.swift | 2 +- Session/Closed Groups/EditClosedGroupVC.swift | 2 +- Session/Closed Groups/NewClosedGroupVC.swift | 41 ++++++++++--------- .../ConversationVC+Interaction.swift | 2 +- .../Conversations/ConversationViewModel.swift | 2 +- .../Views & Modals/JoinOpenGroupModal.swift | 38 +++++++++-------- Session/Meta/AppDelegate.swift | 22 +++++----- Session/Notifications/AppNotifications.swift | 13 ++++-- Session/Open Groups/JoinOpenGroupVC.swift | 2 +- Session/Settings/NukeDataModal.swift | 21 +++++----- SessionMessagingKit/Calls/WebRTCSession.swift | 2 +- .../Models/ControlMessageProcessRecord.swift | 7 ++++ .../Jobs/Types/MessageSendJob.swift | 3 +- .../Jobs/Types/SendReadReceiptsJob.swift | 2 +- .../Open Groups/OpenGroupManager.swift | 5 ++- .../MessageSender+Convenience.swift | 3 +- SessionShareExtension/ThreadPickerVC.swift | 2 +- 17 files changed, 91 insertions(+), 78 deletions(-) diff --git a/Session/Calls/Call Management/SessionCall.swift b/Session/Calls/Call Management/SessionCall.swift index 1d106c38b..0d9c03563 100644 --- a/Session/Calls/Call Management/SessionCall.swift +++ b/Session/Calls/Call Management/SessionCall.swift @@ -397,7 +397,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { let webRTCSession: WebRTCSession = self.webRTCSession GRDBStorage.shared - .write { db in webRTCSession.sendOffer(db, to: sessionId, isRestartingICEConnection: true) } + .read { db in webRTCSession.sendOffer(db, to: sessionId, isRestartingICEConnection: true) } .retainUntilComplete() } diff --git a/Session/Closed Groups/EditClosedGroupVC.swift b/Session/Closed Groups/EditClosedGroupVC.swift index a6c2e0731..7ed1b4b7b 100644 --- a/Session/Closed Groups/EditClosedGroupVC.swift +++ b/Session/Closed Groups/EditClosedGroupVC.swift @@ -417,7 +417,7 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat ModalActivityIndicatorViewController.present(fromViewController: navigationController) { _ in GRDBStorage.shared - .write { db in + .writeAsync { db in if !updatedMemberIds.contains(userPublicKey) { return try MessageSender.leave(db, groupPublicKey: threadId) } diff --git a/Session/Closed Groups/NewClosedGroupVC.swift b/Session/Closed Groups/NewClosedGroupVC.swift index 3e9b60e43..7ec2c4b57 100644 --- a/Session/Closed Groups/NewClosedGroupVC.swift +++ b/Session/Closed Groups/NewClosedGroupVC.swift @@ -196,27 +196,28 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate let selectedContacts = self.selectedContacts let message: String? = (selectedContacts.count > 20) ? "Please wait while the group is created..." : nil ModalActivityIndicatorViewController.present(fromViewController: navigationController!, message: message) { [weak self] _ in - let promise: Promise = GRDBStorage.shared.write { db in - try MessageSender.createClosedGroup(db, name: name, members: selectedContacts) - } - - let _ = promise.done(on: DispatchQueue.main) { thread in - GRDBStorage.shared.write { db in - try? MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() + GRDBStorage.shared + .writeAsync { db in + try MessageSender.createClosedGroup(db, name: name, members: selectedContacts) } - - self?.presentingViewController?.dismiss(animated: true, completion: nil) - SessionApp.presentConversation(for: thread.id, action: .compose, animated: false) - } - promise.catch(on: DispatchQueue.main) { [weak self] _ in - self?.dismiss(animated: true, completion: nil) // Dismiss the loader - - let title = "Couldn't Create Group" - let message = "Please check your internet connection and try again." - let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil)) - self?.presentAlert(alert) - } + .done(on: DispatchQueue.main) { thread in + GRDBStorage.shared.writeAsync { db in + try? MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() + } + + self?.presentingViewController?.dismiss(animated: true, completion: nil) + SessionApp.presentConversation(for: thread.id, action: .compose, animated: false) + } + .catch(on: DispatchQueue.main) { [weak self] _ in + self?.dismiss(animated: true, completion: nil) // Dismiss the loader + + let title = "Couldn't Create Group" + let message = "Please check your internet connection and try again." + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil)) + self?.presentAlert(alert) + } + .retainUntilComplete() } } diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 10b4cf8d5..99a1e31be 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -1680,7 +1680,7 @@ extension ConversationVC { return promise .then { _ -> Promise in - GRDBStorage.shared.write { db in + GRDBStorage.shared.writeAsync { db in try MessageSender.sendNonDurably( db, message: messageRequestResponse, diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index a41b2642c..6b46e6d34 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -359,7 +359,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { // MARK: - Functions public func updateDraft(to draft: String) { - GRDBStorage.shared.write { db in + GRDBStorage.shared.writeAsync { db in try SessionThread .filter(id: self.threadId) .updateAll(db, SessionThread.Columns.messageDraft.set(to: draft)) diff --git a/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift b/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift index 87db2390d..0335df69c 100644 --- a/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift +++ b/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift @@ -92,24 +92,26 @@ final class JoinOpenGroupModal: Modal { presentingViewController.dismiss(animated: true, completion: nil) - GRDBStorage.shared.write { db in - OpenGroupManager.shared.add( - db, - roomToken: room, - server: server, - publicKey: publicKey, - isConfigMessage: false - ) - } - .done(on: DispatchQueue.main) { _ in - GRDBStorage.shared.write { db in - try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...) + GRDBStorage.shared + .writeAsync { db in + OpenGroupManager.shared.add( + db, + roomToken: room, + server: server, + publicKey: publicKey, + isConfigMessage: false + ) } - } - .catch(on: DispatchQueue.main) { error in - let alert = UIAlertController(title: "Couldn't Join", message: error.localizedDescription, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "BUTTON_OK".localized(), style: .default, handler: nil)) - presentingViewController.present(alert, animated: true, completion: nil) - } + .done(on: DispatchQueue.main) { _ in + GRDBStorage.shared.writeAsync { db in + try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...) + } + } + .catch(on: DispatchQueue.main) { error in + let alert = UIAlertController(title: "Couldn't Join", message: error.localizedDescription, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "BUTTON_OK".localized(), style: .default, handler: nil)) + presentingViewController.present(alert, animated: true, completion: nil) + } + .retainUntilComplete() } } diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 82bee6cff..3ab8107ac 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -591,18 +591,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD guard Date().timeIntervalSince(lastSync) > (7 * 24 * 60 * 60) else { return } // Sync every 2 days - GRDBStorage.shared.write { db in - try MessageSender.syncConfiguration(db, forceSyncNow: false) - .done { - // Only update the 'lastConfigurationSync' timestamp if we have done the - // first sync (Don't want a new device config sync to override config - // syncs from other devices) - if UserDefaults.standard[.hasSyncedInitialConfiguration] { - UserDefaults.standard[.lastConfigurationSync] = Date() - } + GRDBStorage.shared + .writeAsync { db in try MessageSender.syncConfiguration(db, forceSyncNow: false) } + .done { + // Only update the 'lastConfigurationSync' timestamp if we have done the + // first sync (Don't want a new device config sync to override config + // syncs from other devices) + if UserDefaults.standard[.hasSyncedInitialConfiguration] { + UserDefaults.standard[.lastConfigurationSync] = Date() } - .retainUntilComplete() - } + } + .retainUntilComplete() } - } diff --git a/Session/Notifications/AppNotifications.swift b/Session/Notifications/AppNotifications.swift index 50fe281b6..146685c77 100644 --- a/Session/Notifications/AppNotifications.swift +++ b/Session/Notifications/AppNotifications.swift @@ -444,7 +444,9 @@ class NotificationActionHandler { throw NotificationError.failDebug("unable to find thread with id: \(threadId)") } - let promise: Promise = GRDBStorage.shared.write { db in + let (promise, seal) = Promise.pending() + + GRDBStorage.shared.writeAsync { db in let interaction: Interaction = try Interaction( threadId: thread.id, authorId: getUserHexEncodedPublicKey(db), @@ -468,12 +470,15 @@ class NotificationActionHandler { in: thread ) } - - promise.catch { [weak self] error in - GRDBStorage.shared.read { db in + .done { seal.fulfill(()) } + .catch { error in + GRDBStorage.shared.read { [weak self] db in self?.notificationPresenter.notifyForFailedSend(db, in: thread) } + + seal.reject(error) } + .retainUntilComplete() return promise } diff --git a/Session/Open Groups/JoinOpenGroupVC.swift b/Session/Open Groups/JoinOpenGroupVC.swift index aca0f2ce6..866f2442b 100644 --- a/Session/Open Groups/JoinOpenGroupVC.swift +++ b/Session/Open Groups/JoinOpenGroupVC.swift @@ -163,7 +163,7 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC ModalActivityIndicatorViewController.present(fromViewController: navigationController, canCancel: false) { [weak self] _ in GRDBStorage.shared - .write { db in + .writeAsync { db in OpenGroupManager.shared.add( db, roomToken: roomToken, diff --git a/Session/Settings/NukeDataModal.swift b/Session/Settings/NukeDataModal.swift index 54ccf1d22..06a35c38c 100644 --- a/Session/Settings/NukeDataModal.swift +++ b/Session/Settings/NukeDataModal.swift @@ -154,17 +154,16 @@ final class NukeDataModal: Modal { @objc private func clearDeviceOnly() { ModalActivityIndicatorViewController.present(fromViewController: self, canCancel: false) { [weak self] _ in - GRDBStorage.shared.write { db in - try MessageSender.syncConfiguration(db, forceSyncNow: true) - .ensure(on: DispatchQueue.main) { - self?.dismiss(animated: true, completion: nil) // Dismiss the loader - - UserDefaults.removeAll() // Not done in the nuke data implementation as unlinking requires this to happen later - General.cache.mutate { $0.encodedPublicKey = nil } // Remove the cached key so it gets re-cached on next access - NotificationCenter.default.post(name: .dataNukeRequested, object: nil) - } - .retainUntilComplete() - } + GRDBStorage.shared + .writeAsync { db in try MessageSender.syncConfiguration(db, forceSyncNow: true) } + .ensure(on: DispatchQueue.main) { + self?.dismiss(animated: true, completion: nil) // Dismiss the loader + + UserDefaults.removeAll() // Not done in the nuke data implementation as unlinking requires this to happen later + General.cache.mutate { $0.encodedPublicKey = nil } // Remove the cached key so it gets re-cached on next access + NotificationCenter.default.post(name: .dataNukeRequested, object: nil) + } + .retainUntilComplete() } } diff --git a/SessionMessagingKit/Calls/WebRTCSession.swift b/SessionMessagingKit/Calls/WebRTCSession.swift index b73558450..20345393c 100644 --- a/SessionMessagingKit/Calls/WebRTCSession.swift +++ b/SessionMessagingKit/Calls/WebRTCSession.swift @@ -170,7 +170,7 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { } } GRDBStorage.shared - .write { db in + .writeAsync { db in try MessageSender .sendNonDurably( db, diff --git a/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift b/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift index 5d13f758b..b2efb6b98 100644 --- a/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift +++ b/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift @@ -71,6 +71,13 @@ public struct ControlMessageProcessRecord: Codable, FetchableRecord, Persistable // the unique constraints on that table prevent duplicate messages if message is VisibleMessage { return nil } + // 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 } diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift index 96436c6f4..1f9dda368 100644 --- a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift @@ -130,7 +130,7 @@ public enum MessageSendJob: JobExecutor { details.message.threadId = (details.message.threadId ?? job.threadId) // Perform the actual message sending - GRDBStorage.shared.write { db -> Promise in + GRDBStorage.shared.writeAsync { db -> Promise in try MessageSender.sendImmediate( db, message: details.message, @@ -170,6 +170,7 @@ public enum MessageSendJob: JobExecutor { failure(job, error, false) } } + .retainUntilComplete() } } diff --git a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift index c1194a920..429bda7e6 100644 --- a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift +++ b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift @@ -35,7 +35,7 @@ public enum SendReadReceiptsJob: JobExecutor { } GRDBStorage.shared - .write { db in + .writeAsync { db in try MessageSender.sendImmediate( db, message: ReadReceipt( diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index e9e96e518..a2e99cac6 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -194,7 +194,7 @@ public final class OpenGroupManager: NSObject { // Note: We don't do this after the db commit as it can fail (resulting in endless loading) OpenGroupAPI.workQueue.async { dependencies.storage - .write { db in + .writeAsync { db in OpenGroupAPI .capabilitiesAndRoom( db, @@ -809,7 +809,7 @@ public final class OpenGroupManager: NSObject { // Trigger the download on a background queue DispatchQueue.global(qos: .background).async { dependencies.storage - .write { db in + .writeAsync { db in OpenGroupAPI .downloadFile( db, @@ -832,6 +832,7 @@ public final class OpenGroupManager: NSObject { seal.fulfill(imageData) } .catch { seal.reject($0) } + .retainUntilComplete() } dependencies.mutableCache.mutate { cache in diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index bd6f0952f..c0e0b0115 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -164,8 +164,7 @@ extension MessageSender { } if let error: Error = errors.first { return Promise(error: error) } - - return GRDBStorage.shared.write { db in + return GRDBStorage.shared.writeAsync { db in try MessageSender.sendImmediate( db, message: message, diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index e444bbe48..82d4f99ab 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -221,7 +221,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView ModalActivityIndicatorViewController.present(fromViewController: shareVC!, canCancel: false, message: "vc_share_sending_message".localized()) { activityIndicator in GRDBStorage.shared - .write { [weak self] db -> Promise in + .writeAsync { [weak self] db -> Promise in guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { activityIndicator.dismiss { } self?.shareVC?.shareViewFailed(error: MessageSenderError.noThread) From 56d919af2cd747e7a51e195d295c6f342a1eee42 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 21 Jun 2022 13:47:03 +1000 Subject: [PATCH 107/157] Fixed a couple of build errors --- .../Database/Migrations/_003_YDBToGRDBMigration.swift | 2 +- SessionMessagingKit/Sending & Receiving/MessageSender.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 7fd2a55db..965d6f4ae 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -1315,7 +1315,7 @@ enum _003_YDBToGRDBMigration: Migration { } guard processedAttachmentIds.contains(legacyJob.attachmentID) else { SNLog("[Migration Error] attachmentDownload job unable to find attachment") - throw GRDBStorageError.migrationFailed + throw StorageError.migrationFailed } _ = try Job( diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index eaf209a87..338eff6d5 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -681,7 +681,7 @@ public final class MessageSender { public class SMKMessageSender: NSObject { @objc(leaveClosedGroupWithPublicKey:) public static func objc_leave(_ groupPublicKey: String) -> AnyPromise { - let promise = GRDBStorage.shared.write { db in + let promise = GRDBStorage.shared.writeAsync { db in try MessageSender.leave(db, groupPublicKey: groupPublicKey) } From 3261f12ea716a1eae8d574248d94774d8d3a9d04 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 21 Jun 2022 14:13:41 +1000 Subject: [PATCH 108/157] Added a missing new function --- .../Database/GRDBStorage.swift | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/SessionUtilitiesKit/Database/GRDBStorage.swift b/SessionUtilitiesKit/Database/GRDBStorage.swift index bccd9cb6e..6d9f030dd 100644 --- a/SessionUtilitiesKit/Database/GRDBStorage.swift +++ b/SessionUtilitiesKit/Database/GRDBStorage.swift @@ -370,16 +370,28 @@ public extension GRDBStorage { } } - @discardableResult func write(updates: (Database) throws -> Promise) -> Promise { + @discardableResult func writeAsync(updates: @escaping (Database) throws -> Promise) -> Promise { guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return Promise(error: StorageError.databaseInvalid) } - do { - return try dbWriter.write(updates) - } - catch { - return Promise(error: error) - } + let (promise, seal) = Promise.pending() + + dbWriter.asyncWrite( + { db in + try updates(db) + .done { result in seal.fulfill(result) } + .catch { error in seal.reject(error) } + .retainUntilComplete() + }, + completion: { _, result in + switch result { + case .failure(let error): seal.reject(error) + default: break + } + } + ) + + return promise } } From 153880cf4de80b03e24ced3e10798553d08588f7 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 21 Jun 2022 17:43:27 +1000 Subject: [PATCH 109/157] Fixed a few bugs and continued work on fixing unit tests Fixed a bug where notifications might not work for messages Fixed a bug where auto-playing audio messages wouldn't update the states correctly Fixed a bug where a user wouldn't be able to join an open group with blinding enabled --- Session/Conversations/ConversationVC.swift | 2 +- .../Conversations/ConversationViewModel.swift | 19 +- Session/Home/HomeVC.swift | 16 +- Session/Home/HomeViewModel.swift | 5 +- Session/Notifications/AppNotifications.swift | 12 +- Session/Utilities/MockDataGenerator.swift | 1 - .../Database/LegacyDatabase/SMKLegacy.swift | 4 - .../Migrations/_003_YDBToGRDBMigration.swift | 11 - .../Database/Models/Capability.swift | 2 +- .../Database/Models/SessionThread.swift | 22 +- .../Open Groups/OpenGroupAPI.swift | 14 +- .../Open Groups/OpenGroupManager.swift | 7 +- .../Utilities/OWSAudioPlayer.h | 2 +- .../Open Groups/OpenGroupAPISpec.swift | 1227 +++++++++++------ .../Open Groups/OpenGroupManagerSpec.swift | 1 - .../MessageReceiverDecryptionSpec.swift | 76 +- .../_TestUtilities/MockedExtensions.swift | 4 +- .../NSENotificationPresenter.swift | 10 +- 18 files changed, 894 insertions(+), 541 deletions(-) diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index cac1a3ccc..ce2d93e00 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -1150,7 +1150,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers OWSAlerts.showErrorAlert(message: "INVALID_AUDIO_FILE_ALERT_ERROR_MESSAGE".localized()) return } - // TODO: Looks like the 'play/pause' icon isn't swapping when it auto-plays to the next item) + cell.dynamicUpdate(with: cellViewModel, playbackInfo: updatedInfo) } }, diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 6b46e6d34..2248cb7cc 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -511,6 +511,11 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { currentPlayingInteraction.mutate { $0 = viewModel.id } audioPlayer.mutate { [weak self] player in + // Note: We clear the delegate and explicitly set to nil here as when the OWSAudioPlayer + // gets deallocated it triggers state changes which cause UI bugs when auto-playing + player?.delegate = nil + player = nil + let audioPlayer: OWSAudioPlayer = OWSAudioPlayer( mediaUrl: URL(fileURLWithPath: originalFilePath), audioBehavior: .audioMessagePlayback, @@ -543,7 +548,12 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { audioPlayer.wrappedValue?.stop() currentPlayingInteraction.mutate { $0 = nil } - audioPlayer.mutate { $0 = nil } + audioPlayer.mutate { + // Note: We clear the delegate and explicitly set to nil here as when the OWSAudioPlayer + // gets deallocated it triggers state changes which cause UI bugs when auto-playing + $0?.delegate = nil + $0 = nil + } } // MARK: - OWSAudioPlayerDelegate @@ -591,7 +601,12 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { // Clear out the currently playing record currentPlayingInteraction.mutate { $0 = nil } - audioPlayer.mutate { $0 = nil } + audioPlayer.mutate { + // Note: We clear the delegate and explicitly set to nil here as when the OWSAudioPlayer + // gets deallocated it triggers state changes which cause UI bugs when auto-playing + $0?.delegate = nil + $0 = nil + } // If the next interaction is another voice message then autoplay it guard diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index a1c3e00d9..56b8eddfb 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -200,9 +200,6 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve DispatchQueue.global(qos: .utility).sync { let _ = IP2Country.shared.populateCacheIfNeeded() } - - // Get default open group rooms if needed -// OpenGroupManager.getDefaultRoomsIfNeeded() // TODO: Needed??? } override func viewWillAppear(_ animated: Bool) { @@ -411,13 +408,8 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve switch section.model { case .messageRequests: - let hide = UITableViewRowAction(style: .destructive, title: "TXT_HIDE_TITLE".localized()) { [weak self] _, _ in + let hide = UITableViewRowAction(style: .destructive, title: "TXT_HIDE_TITLE".localized()) { _, _ in GRDBStorage.shared.write { db in db[.hasHiddenMessageRequests] = true } - - // Animate the row removal - self?.tableView.beginUpdates() - self?.tableView.deleteRows(at: [indexPath], with: .automatic) - self?.tableView.endUpdates() } hide.backgroundColor = Colors.destructive @@ -443,7 +435,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve title: "TXT_DELETE_TITLE".localized(), style: .destructive ) { _ in - GRDBStorage.shared.write { db in + GRDBStorage.shared.writeAsync { db in switch cellViewModel.threadVariant { case .closedGroup: try MessageSender @@ -477,7 +469,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve "PIN_BUTTON_TEXT".localized() ) ) { _, _ in - GRDBStorage.shared.write { db in + GRDBStorage.shared.writeAsync { db in try SessionThread .filter(id: cellViewModel.threadId) .updateAll(db, SessionThread.Columns.isPinned.set(to: !cellViewModel.threadIsPinned)) @@ -495,7 +487,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve "BLOCK_LIST_BLOCK_BUTTON".localized() ) ) { _, _ in - GRDBStorage.shared.write { db in + GRDBStorage.shared.writeAsync { db in try Contact .filter(id: cellViewModel.threadId) .updateAll( diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 95c3429ee..1115f6499 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -73,9 +73,8 @@ public class HomeViewModel { let hasViewedSeed: Bool = db[.hasViewedSeed] let userPublicKey: String = getUserHexEncodedPublicKey(db) let unreadMessageRequestCount: Int = try SessionThread - .unreadMessageRequestsCountQuery(userPublicKey: userPublicKey) - .fetchOne(db) - .defaulting(to: 0) + .unreadMessageRequestsQuery(userPublicKey: userPublicKey) + .fetchCount(db) let finalUnreadMessageRequestCount: Int = (db[.hasHiddenMessageRequests] ? 0 : unreadMessageRequestCount) let threads: [SessionThreadViewModel] = try SessionThreadViewModel .homeQuery(userPublicKey: userPublicKey) diff --git a/Session/Notifications/AppNotifications.swift b/Session/Notifications/AppNotifications.swift index 146685c77..92330e838 100644 --- a/Session/Notifications/AppNotifications.swift +++ b/Session/Notifications/AppNotifications.swift @@ -142,10 +142,10 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { } public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, isBackgroundPoll: Bool) { - guard Date().timeIntervalSince1970 < (thread.mutedUntilTimestamp ?? 0) else { return } + guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return } let userPublicKey: String = getUserHexEncodedPublicKey(db) - let isMessageRequest: Bool = thread.isMessageRequest(db) + let isMessageRequest: Bool = thread.isMessageRequest(db, includeNonVisible: true) // If the thread is a message request and the user hasn't hidden message requests then we need // to check if this is the only message request thread (group threads can't be message requests @@ -153,13 +153,13 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { // notification regardless of how many message requests there are) if thread.variant == .contact { if isMessageRequest && !db[.hasHiddenMessageRequests] { - let numMessageRequestThreads: Int? = (try? SessionThread - .messageRequestsCountQuery(userPublicKey: userPublicKey) - .fetchOne(db)) + let numMessageRequestThreads: Int = (try? SessionThread + .messageRequestsQuery(userPublicKey: userPublicKey, includeNonVisible: true) + .fetchCount(db)) .defaulting(to: 0) // Allow this to show a notification if there are no message requests (ie. this is the first one) - guard (numMessageRequestThreads ?? 0) == 0 else { return } + guard numMessageRequestThreads == 0 else { return } } else if isMessageRequest && db[.hasHiddenMessageRequests] { // If there are other interactions on this thread already then don't show the notification diff --git a/Session/Utilities/MockDataGenerator.swift b/Session/Utilities/MockDataGenerator.swift index 2eed4aeae..9b0b12090 100644 --- a/Session/Utilities/MockDataGenerator.swift +++ b/Session/Utilities/MockDataGenerator.swift @@ -93,7 +93,6 @@ enum MockDataGenerator { let cgRandomSeed: Int = 2222 let ogRandomSeed: Int = 3333 let chunkSize: Int = 1000 // Chunk up the thread writing to prevent memory issues - let openGroupBaseUrl: String = "https://chat.lokinet.dev" let stringContent: [String] = "abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ 0123456789 ".map { String($0) } let wordContent: [String] = ["alias", "consequatur", "aut", "perferendis", "sit", "voluptatem", "accusantium", "doloremque", "aperiam", "eaque", "ipsa", "quae", "ab", "illo", "inventore", "veritatis", "et", "quasi", "architecto", "beatae", "vitae", "dicta", "sunt", "explicabo", "aspernatur", "aut", "odit", "aut", "fugit", "sed", "quia", "consequuntur", "magni", "dolores", "eos", "qui", "ratione", "voluptatem", "sequi", "nesciunt", "neque", "dolorem", "ipsum", "quia", "dolor", "sit", "amet", "consectetur", "adipisci", "velit", "sed", "quia", "non", "numquam", "eius", "modi", "tempora", "incidunt", "ut", "labore", "et", "dolore", "magnam", "aliquam", "quaerat", "voluptatem", "ut", "enim", "ad", "minima", "veniam", "quis", "nostrum", "exercitationem", "ullam", "corporis", "nemo", "enim", "ipsam", "voluptatem", "quia", "voluptas", "sit", "suscipit", "laboriosam", "nisi", "ut", "aliquid", "ex", "ea", "commodi", "consequatur", "quis", "autem", "vel", "eum", "iure", "reprehenderit", "qui", "in", "ea", "voluptate", "velit", "esse", "quam", "nihil", "molestiae", "et", "iusto", "odio", "dignissimos", "ducimus", "qui", "blanditiis", "praesentium", "laudantium", "totam", "rem", "voluptatum", "deleniti", "atque", "corrupti", "quos", "dolores", "et", "quas", "molestias", "excepturi", "sint", "occaecati", "cupiditate", "non", "provident", "sed", "ut", "perspiciatis", "unde", "omnis", "iste", "natus", "error", "similique", "sunt", "in", "culpa", "qui", "officia", "deserunt", "mollitia", "animi", "id", "est", "laborum", "et", "dolorum", "fuga", "et", "harum", "quidem", "rerum", "facilis", "est", "et", "expedita", "distinctio", "nam", "libero", "tempore", "cum", "soluta", "nobis", "est", "eligendi", "optio", "cumque", "nihil", "impedit", "quo", "porro", "quisquam", "est", "qui", "minus", "id", "quod", "maxime", "placeat", "facere", "possimus", "omnis", "voluptas", "assumenda", "est", "omnis", "dolor", "repellendus", "temporibus", "autem", "quibusdam", "et", "aut", "consequatur", "vel", "illum", "qui", "dolorem", "eum", "fugiat", "quo", "voluptas", "nulla", "pariatur", "at", "vero", "eos", "et", "accusamus", "officiis", "debitis", "aut", "rerum", "necessitatibus", "saepe", "eveniet", "ut", "et", "voluptates", "repudiandae", "sint", "et", "molestiae", "non", "recusandae", "itaque", "earum", "rerum", "hic", "tenetur", "a", "sapiente", "delectus", "ut", "aut", "reiciendis", "voluptatibus", "maiores", "doloribus", "asperiores", "repellat"] let timestampNow: TimeInterval = Date().timeIntervalSince1970 diff --git a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift index 31f49e9ab..25273ebfb 100644 --- a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift +++ b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift @@ -21,16 +21,12 @@ public enum SMKLegacy { public static let threadCollection = "TSThread" internal static let disappearingMessagesCollection = "OWSDisappearingMessagesConfiguration" - internal static let closedGroupPublicKeyCollection = "SNClosedGroupPublicKeyCollection" internal static let closedGroupFormationTimestampCollection = "SNClosedGroupFormationTimestampCollection" internal static let closedGroupZombieMembersCollection = "SNClosedGroupZombieMembersCollection" internal static let openGroupCollection = "SNOpenGroupCollection" internal static let openGroupUserCountCollection = "SNOpenGroupUserCountCollection" internal static let openGroupImageCollection = "SNOpenGroupImageCollection" - internal static let openGroupLastMessageServerIDCollection = "SNLastMessageServerIDCollection" - internal static let openGroupLastDeletionServerIDCollection = "SNLastDeletionServerIDCollection" - internal static let openGroupServerIdToUniqueIdLookupCollection = "SNOpenGroupServerIdToUniqueIdLookup" public static let messageDatabaseViewExtensionName = "TSMessageDatabaseViewExtensionName_Monotonic" internal static let interactionCollection = "TSInteraction" diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 965d6f4ae..057bf1269 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -45,8 +45,6 @@ enum _003_YDBToGRDBMigration: Migration { var openGroupInfo: [String: SMKLegacy._OpenGroup] = [:] var openGroupUserCount: [String: Int64] = [:] var openGroupImage: [String: Data] = [:] - var openGroupLastMessageServerId: [String: Int64] = [:] // Optional - var openGroupLastDeletionServerId: [String: Int64] = [:] // Optional var interactions: [String: [SMKLegacy._DBInteraction]] = [:] var attachments: [String: SMKLegacy._Attachment] = [:] @@ -180,8 +178,6 @@ enum _003_YDBToGRDBMigration: Migration { openGroupInfo[thread.uniqueId] = openGroup openGroupUserCount[thread.uniqueId] = ((transaction.object(forKey: openGroup.id, inCollection: SMKLegacy.openGroupUserCountCollection) as? Int64) ?? 0) openGroupImage[thread.uniqueId] = transaction.object(forKey: openGroup.id, inCollection: SMKLegacy.openGroupImageCollection) as? Data - openGroupLastMessageServerId[thread.uniqueId] = transaction.object(forKey: openGroup.id, inCollection: SMKLegacy.openGroupLastMessageServerIDCollection) as? Int64 - openGroupLastDeletionServerId[thread.uniqueId] = transaction.object(forKey: openGroup.id, inCollection: SMKLegacy.openGroupLastDeletionServerIDCollection) as? Int64 } } GRDBStorage.shared.update(progress: 0.04, for: self, in: target) @@ -787,10 +783,6 @@ enum _003_YDBToGRDBMigration: Migration { case .mediaSavedNotification: variant = .infoMediaSavedNotification case .call: variant = .infoCall case .messageRequestAccepted: variant = .infoMessageRequestAccepted - - @unknown default: - SNLog("[Migration Error] Unsupported info message type") - throw StorageError.migrationFailed } default: @@ -1100,8 +1092,6 @@ enum _003_YDBToGRDBMigration: Migration { openGroupInfo = [:] openGroupUserCount = [:] openGroupImage = [:] - openGroupLastMessageServerId = [:] - openGroupLastDeletionServerId = [:] interactions = [:] attachments = [:] @@ -1507,7 +1497,6 @@ enum _003_YDBToGRDBMigration: Migration { return (true, nil) }() - _ = try Attachment( // Note: The legacy attachment object used a UUID string for it's id as well // and saved files using these id's so just used the existing id so we don't diff --git a/SessionMessagingKit/Database/Models/Capability.swift b/SessionMessagingKit/Database/Models/Capability.swift index ef68e2895..60437fa23 100644 --- a/SessionMessagingKit/Database/Models/Capability.swift +++ b/SessionMessagingKit/Database/Models/Capability.swift @@ -38,7 +38,7 @@ public struct Capability: Codable, FetchableRecord, PersistableRecord, TableReco public init(from valueString: String) { let maybeValue: Variant? = Variant.allCases.first { $0.rawValue == valueString } - + self = (maybeValue ?? .unsupported(valueString)) } } diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index b37e0fe9f..5394cc1ae 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -169,9 +169,9 @@ public extension SessionThread { return existingThread } - func isMessageRequest(_ db: Database) -> Bool { + func isMessageRequest(_ db: Database, includeNonVisible: Bool = false) -> Bool { return ( - shouldBeVisible && + (includeNonVisible || shouldBeVisible) && variant == .contact && id != getUserHexEncodedPublicKey(db) && // Note to self (try? Contact.fetchOne(db, id: id))?.isApproved != true @@ -182,27 +182,27 @@ public extension SessionThread { // MARK: - Convenience public extension SessionThread { - static func messageRequestsCountQuery(userPublicKey: String) -> SQLRequest { + static func messageRequestsQuery(userPublicKey: String, includeNonVisible: Bool = false) -> SQLRequest { let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() return """ - SELECT COUNT(\(thread[.id])) + SELECT \(thread.allColumns()) FROM \(SessionThread.self) LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) WHERE ( - \(SessionThread.isMessageRequest(userPublicKey: userPublicKey)) + \(SessionThread.isMessageRequest(userPublicKey: userPublicKey, includeNonVisible: includeNonVisible)) ) """ } - static func unreadMessageRequestsCountQuery(userPublicKey: String) -> SQLRequest { + static func unreadMessageRequestsQuery(userPublicKey: String) -> SQLRequest { let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() return """ - SELECT COUNT(\(thread[.id])) + SELECT \(thread.allColumns()) FROM \(SessionThread.self) JOIN ( SELECT @@ -225,13 +225,17 @@ public extension SessionThread { /// /// **Note:** In order to use this filter you **MUST** have a `joining(required/optional:)` to the /// `SessionThread.contact` association or it won't work - static func isMessageRequest(userPublicKey: String) -> SQLSpecificExpressible { + static func isMessageRequest(userPublicKey: String, includeNonVisible: Bool = false) -> SQLSpecificExpressible { let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() + let shouldBeVisibleSQL: SQL = (includeNonVisible ? + SQL(stringLiteral: "true") : + SQL("\(thread[.shouldBeVisible]) = true") + ) return SQL( """ - \(thread[.shouldBeVisible]) = true AND + \(shouldBeVisibleSQL) AND \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND \(SQL("\(thread[.id]) != \(userPublicKey)")) AND IFNULL(\(contact[.isApproved]), false) = false diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index 55b507135..2e9a8588f 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -184,6 +184,7 @@ public enum OpenGroupAPI { _ db: Database, server: String, requests: [BatchRequestInfoType], + authenticated: Bool = true, using dependencies: Dependencies = Dependencies() ) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable?)]> { let requestBody: BatchRequest = requests.map { $0.toSubRequest() } @@ -198,6 +199,7 @@ public enum OpenGroupAPI { endpoint: Endpoint.sequence, body: requestBody ), + authenticated: authenticated, using: dependencies ) .decoded(as: responseTypes, on: OpenGroupAPI.workQueue, using: dependencies) @@ -220,7 +222,7 @@ public enum OpenGroupAPI { /// could return: `{"capabilities": ["sogs", "batch"], "missing": ["magic"]}` public static func capabilities( _ db: Database, - on server: String, + server: String, using dependencies: Dependencies = Dependencies() ) -> Promise<(OnionRequestResponseInfoType, Capabilities)> { return OpenGroupAPI @@ -242,7 +244,7 @@ public enum OpenGroupAPI { /// Rooms to which the user does not have access (e.g. because they are banned, or the room has restricted access permissions) are not included public static func rooms( _ db: Database, - for server: String, + server: String, using dependencies: Dependencies = Dependencies() ) -> Promise<(OnionRequestResponseInfoType, [Room])> { return OpenGroupAPI @@ -315,6 +317,7 @@ public enum OpenGroupAPI { _ db: Database, for roomToken: String, on server: String, + authenticated: Bool = true, using dependencies: Dependencies = Dependencies() ) -> Promise<(capabilities: (info: OnionRequestResponseInfoType, data: Capabilities), room: (info: OnionRequestResponseInfoType, data: Room))> { let requestResponseType: [BatchRequestInfoType] = [ @@ -342,6 +345,7 @@ public enum OpenGroupAPI { db, server: server, requests: requestResponseType, + authenticated: authenticated, using: dependencies ) .map { (response: [Endpoint: (OnionRequestResponseInfoType, Codable?)]) -> (capabilities: (OnionRequestResponseInfoType, Capabilities), room: (OnionRequestResponseInfoType, Room)) in @@ -1199,6 +1203,7 @@ public enum OpenGroupAPI { private static func send( _ db: Database, request: Request, + authenticated: Bool = true, using dependencies: Dependencies = Dependencies() ) -> Promise<(OnionRequestResponseInfoType, Data?)> { let urlRequest: URLRequest @@ -1218,6 +1223,11 @@ public enum OpenGroupAPI { guard let publicKey: String = maybePublicKey else { return Promise(error: Error.noPublicKey) } + // If we don't want to authenticate the request then send it immediately + guard authenticated else { + return dependencies.onionApi.sendOnionRequest(urlRequest, to: request.server, with: publicKey) + } + // Attempt to sign the request with the new auth guard let signedRequest: URLRequest = sign(db, request: urlRequest, for: request.server, with: publicKey, using: dependencies) else { return Promise(error: Error.signingFailed) diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index a2e99cac6..de130aacd 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -195,11 +195,16 @@ public final class OpenGroupManager: NSObject { OpenGroupAPI.workQueue.async { dependencies.storage .writeAsync { db in + // Note: The initial request for room info and it's capabilities should NOT be + // authenticated (this is because if the server requires blinding and the auth + // headers aren't blinded it will error - these endpoints do support unauthenticated + // retrieval so doing so prevents the error) OpenGroupAPI .capabilitiesAndRoom( db, for: roomToken, on: server, + authenticated: false, using: dependencies ) } @@ -704,7 +709,7 @@ public final class OpenGroupManager: NSObject { // Try to retrieve the default rooms 8 times attempt(maxRetryCount: 8, recoveringOn: DispatchQueue.main) { dependencies.storage.read { db in - OpenGroupAPI.rooms(db, for: OpenGroupAPI.defaultServer, using: dependencies) + OpenGroupAPI.rooms(db, server: OpenGroupAPI.defaultServer, using: dependencies) } .map { _, data in data } } diff --git a/SessionMessagingKit/Utilities/OWSAudioPlayer.h b/SessionMessagingKit/Utilities/OWSAudioPlayer.h index 63d74acf0..759086b5e 100644 --- a/SessionMessagingKit/Utilities/OWSAudioPlayer.h +++ b/SessionMessagingKit/Utilities/OWSAudioPlayer.h @@ -37,7 +37,7 @@ typedef NS_ENUM(NSUInteger, OWSAudioBehavior) { @interface OWSAudioPlayer : NSObject -@property (nonatomic, readonly, weak) id delegate; +@property (nonatomic, weak) id delegate; // This property can be used to associate instances of the player with view or model objects. @property (nonatomic, weak) id owner; @property (nonatomic) BOOL isLooping; diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift index 7ae639d0e..3feba33a3 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift @@ -55,21 +55,26 @@ class OpenGroupAPISpec: QuickSpec { ) mockStorage.write { db in - try Identity(variant: .x25519PublicKey, data: Data.data(fromHex: TestConstants.publicKey)).insert(db) - try Identity(variant: .x25519PrivateKey, data: Data.data(fromHex: TestConstants.privateKey)).insert(db) - try Identity(variant: .ed25519PublicKey, data: Data.data(fromHex: TestConstants.edPublicKey)).insert(db) - try Identity(variant: .ed25519SecretKey, data: Data.data(fromHex: TestConstants.edSecretKey)).insert(db) + try Identity(variant: .x25519PublicKey, data: Data.data(fromHex: TestConstants.publicKey)!).insert(db) + try Identity(variant: .x25519PrivateKey, data: Data.data(fromHex: TestConstants.privateKey)!).insert(db) + try Identity(variant: .ed25519PublicKey, data: Data.data(fromHex: TestConstants.edPublicKey)!).insert(db) + try Identity(variant: .ed25519SecretKey, data: Data.data(fromHex: TestConstants.edSecretKey)!).insert(db) try OpenGroup( server: "testServer", roomToken: "testRoom", publicKey: TestConstants.publicKey, + isActive: true, name: "Test", - groupDescription: nil, - imageID: nil, - infoUpdates: 0 + roomDescription: nil, + imageId: nil, + userCount: 0, + infoUpdates: 0, + sequenceNumber: 0, + inboxLatestMessageId: 0, + outboxLatestMessageId: 0 ).insert(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false) + try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) } mockGenericHash.when { $0.hash(message: anyArray(), outputLength: any()) }.thenReturn([]) @@ -169,7 +174,16 @@ class OpenGroupAPISpec: QuickSpec { } it("generates the correct request") { - OpenGroupAPI.poll("testServer", hasPerformedInitialPoll: false, timeSinceLastPoll: 0, using: dependencies) + mockStorage + .read { db in + OpenGroupAPI.poll( + db, + server: "testserver", + hasPerformedInitialPoll: false, + timeSinceLastPoll: 0, + using: dependencies + ) + } .get { result in pollResponse = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -615,7 +629,16 @@ class OpenGroupAPISpec: QuickSpec { } it("errors when no data is returned") { - OpenGroupAPI.poll("testServer", hasPerformedInitialPoll: false, timeSinceLastPoll: 0, using: dependencies) + mockStorage + .read { db in + OpenGroupAPI.poll( + db, + server: "testserver", + hasPerformedInitialPoll: false, + timeSinceLastPoll: 0, + using: dependencies + ) + } .get { result in pollResponse = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -635,7 +658,16 @@ class OpenGroupAPISpec: QuickSpec { } dependencies = dependencies.with(onionApi: TestApi.self) - OpenGroupAPI.poll("testServer", hasPerformedInitialPoll: false, timeSinceLastPoll: 0, using: dependencies) + mockStorage + .read { db in + OpenGroupAPI.poll( + db, + server: "testserver", + hasPerformedInitialPoll: false, + timeSinceLastPoll: 0, + using: dependencies + ) + } .get { result in pollResponse = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -655,7 +687,16 @@ class OpenGroupAPISpec: QuickSpec { } dependencies = dependencies.with(onionApi: TestApi.self) - OpenGroupAPI.poll("testServer", hasPerformedInitialPoll: false, timeSinceLastPoll: 0, using: dependencies) + mockStorage + .read { db in + OpenGroupAPI.poll( + db, + server: "testserver", + hasPerformedInitialPoll: false, + timeSinceLastPoll: 0, + using: dependencies + ) + } .get { result in pollResponse = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -675,7 +716,16 @@ class OpenGroupAPISpec: QuickSpec { } dependencies = dependencies.with(onionApi: TestApi.self) - OpenGroupAPI.poll("testServer", hasPerformedInitialPoll: false, timeSinceLastPoll: 0, using: dependencies) + mockStorage + .read { db in + OpenGroupAPI.poll( + db, + server: "testserver", + hasPerformedInitialPoll: false, + timeSinceLastPoll: 0, + using: dependencies + ) + } .get { result in pollResponse = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -727,7 +777,16 @@ class OpenGroupAPISpec: QuickSpec { } dependencies = dependencies.with(onionApi: TestApi.self) - OpenGroupAPI.poll("testServer", hasPerformedInitialPoll: false, timeSinceLastPoll: 0, using: dependencies) + mockStorage + .read { db in + OpenGroupAPI.poll( + db, + server: "testserver", + hasPerformedInitialPoll: false, + timeSinceLastPoll: 0, + using: dependencies + ) + } .get { result in pollResponse = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -756,7 +815,14 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Capabilities)? - OpenGroupAPI.capabilities(on: "testServer", using: dependencies) + mockStorage + .read { db in + OpenGroupAPI.capabilities( + db, + server: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -820,7 +886,14 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: OnionRequestResponseInfoType, data: [OpenGroupAPI.Room])? - OpenGroupAPI.rooms(for: "testServer", using: dependencies) + mockStorage + .read { db in + OpenGroupAPI.rooms( + db, + server: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -905,7 +978,15 @@ class OpenGroupAPISpec: QuickSpec { var response: (capabilities: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Capabilities?), room: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Room?))? - OpenGroupAPI.capabilitiesAndRoom(for: "testRoom", on: "testServer", using: dependencies) + mockStorage + .read { db in + OpenGroupAPI.capabilitiesAndRoom( + db, + for: "testRoom", + on: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -953,7 +1034,16 @@ class OpenGroupAPISpec: QuickSpec { var response: (capabilities: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Capabilities?), room: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Room?))? - OpenGroupAPI.capabilitiesAndRoom(for: "testRoom", on: "testServer", using: dependencies) + mockStorage + .read { db in + OpenGroupAPI + .capabilitiesAndRoom( + db, + for: "testRoom", + on: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -1016,7 +1106,16 @@ class OpenGroupAPISpec: QuickSpec { var response: (capabilities: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Capabilities?), room: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Room?))? - OpenGroupAPI.capabilitiesAndRoom(for: "testRoom", on: "testServer", using: dependencies) + mockStorage + .read { db in + OpenGroupAPI + .capabilitiesAndRoom( + db, + for: "testRoom", + on: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -1096,7 +1195,15 @@ class OpenGroupAPISpec: QuickSpec { var response: (capabilities: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Capabilities?), room: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Room?))? - OpenGroupAPI.capabilitiesAndRoom(for: "testRoom", on: "testServer", using: dependencies) + mockStorage + .read { db in + OpenGroupAPI.capabilitiesAndRoom( + db, + for: "testRoom", + on: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -1145,16 +1252,20 @@ class OpenGroupAPISpec: QuickSpec { it("correctly sends the message") { var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? - OpenGroupAPI - .send( - "test".data(using: .utf8)!, - to: "testRoom", - on: "testServer", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI + .send( + db, + plaintext: "test".data(using: .utf8)!, + to: "testRoom", + on: "testServer", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -1176,60 +1287,31 @@ class OpenGroupAPISpec: QuickSpec { expect(requestData?.urlString).to(equal("testServer/room/testRoom/message")) } - it("saves the received message timestamp to the database in milliseconds") { - var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? - - OpenGroupAPI - .send( - "test".data(using: .utf8)!, - to: "testRoom", - on: "testServer", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - expect(mockStorage) - .to(call(matchingParameters: true) { - $0.addReceivedMessageTimestamp(321000, using: anyAny()) - }) - } - context("when unblinded") { beforeEach { - mockStorage - .when { $0.getOpenGroupServer(name: any()) } - .thenReturn( - OpenGroupAPI.Server( - name: "testServer", - capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: []) - ) - ) + mockStorage.write { db in + _ = try Capability.deleteAll(db) + try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) + } } it("signs the message correctly") { var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? - OpenGroupAPI - .send( - "test".data(using: .utf8)!, - to: "testRoom", - on: "testServer", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI + .send( + db, + plaintext: "test".data(using: .utf8)!, + to: "testRoom", + on: "testServer", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -1249,21 +1331,27 @@ class OpenGroupAPISpec: QuickSpec { expect(requestBody.signature).to(equal("TestStandardSignature".data(using: .utf8))) } - it("fails to sign if there is no public key") { - mockStorage.when { $0.getOpenGroupPublicKey(for: any()) }.thenReturn(nil) + it("fails to sign if there is no open group") { + mockStorage.write { db in + _ = try OpenGroup.deleteAll(db) + } var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? - OpenGroupAPI - .send( - "test".data(using: .utf8)!, - to: "testRoom", - on: "testServer", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI + .send( + db, + plaintext: "test".data(using: .utf8)!, + to: "testRoom", + on: "testserver", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -1278,20 +1366,27 @@ class OpenGroupAPISpec: QuickSpec { } it("fails to sign if there is no user key pair") { - mockStorage.when { $0.getUserKeyPair() }.thenReturn(nil) + mockStorage.write { db in + _ = try Identity.filter(id: .x25519PublicKey).deleteAll(db) + _ = try Identity.filter(id: .x25519PrivateKey).deleteAll(db) + } var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? - OpenGroupAPI - .send( - "test".data(using: .utf8)!, - to: "testRoom", - on: "testServer", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI + .send( + db, + plaintext: "test".data(using: .utf8)!, + to: "testRoom", + on: "testserver", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -1311,16 +1406,20 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? - OpenGroupAPI - .send( - "test".data(using: .utf8)!, - to: "testRoom", - on: "testServer", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI + .send( + db, + plaintext: "test".data(using: .utf8)!, + to: "testRoom", + on: "testserver", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -1337,29 +1436,30 @@ class OpenGroupAPISpec: QuickSpec { context("when blinded") { beforeEach { - mockStorage - .when { $0.getOpenGroupServer(name: any()) } - .thenReturn( - OpenGroupAPI.Server( - name: "testServer", - capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs, .blind], missing: []) - ) - ) + mockStorage.write { db in + _ = try Capability.deleteAll(db) + try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) + try Capability(openGroupServer: "testserver", variant: .blind, isMissing: false).insert(db) + } } it("signs the message correctly") { var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? - OpenGroupAPI - .send( - "test".data(using: .utf8)!, - to: "testRoom", - on: "testServer", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI + .send( + db, + plaintext: "test".data(using: .utf8)!, + to: "testRoom", + on: "testserver", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -1379,21 +1479,27 @@ class OpenGroupAPISpec: QuickSpec { expect(requestBody.signature).to(equal("TestSogsSignature".data(using: .utf8))) } - it("fails to sign if there is no public key") { - mockStorage.when { $0.getOpenGroupPublicKey(for: any()) }.thenReturn(nil) + it("fails to sign if there is no open group") { + mockStorage.write { db in + _ = try OpenGroup.deleteAll(db) + } var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? - OpenGroupAPI - .send( - "test".data(using: .utf8)!, - to: "testRoom", - on: "testServer", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI + .send( + db, + plaintext: "test".data(using: .utf8)!, + to: "testRoom", + on: "testServer", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -1408,20 +1514,27 @@ class OpenGroupAPISpec: QuickSpec { } it("fails to sign if there is no ed key pair key") { - mockStorage.when { $0.getUserED25519KeyPair() }.thenReturn(nil) + mockStorage.write { db in + _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) + _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) + } var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? - OpenGroupAPI - .send( - "test".data(using: .utf8)!, - to: "testRoom", - on: "testServer", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI + .send( + db, + plaintext: "test".data(using: .utf8)!, + to: "testRoom", + on: "testserver", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -1449,16 +1562,20 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? - OpenGroupAPI - .send( - "test".data(using: .utf8)!, - to: "testRoom", - on: "testServer", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI + .send( + db, + plaintext: "test".data(using: .utf8)!, + to: "testRoom", + on: "testserver", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -1496,7 +1613,17 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? - OpenGroupAPI.message(123, in: "testRoom", on: "testServer", using: dependencies) + mockStorage + .read { db in + OpenGroupAPI + .message( + db, + id: 123, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -1526,21 +1653,32 @@ class OpenGroupAPISpec: QuickSpec { } dependencies = dependencies.with(onionApi: TestApi.self) - mockStorage.when { $0.getUserED25519KeyPair() }.thenReturn(Box.KeyPair(publicKey: [], secretKey: [])) + mockStorage.write { db in + _ = try Identity + .filter(id: .ed25519PublicKey) + .updateAll(db, Identity.Columns.data.set(to: Data())) + _ = try Identity + .filter(id: .ed25519SecretKey) + .updateAll(db, Identity.Columns.data.set(to: Data())) + } } it("correctly sends the update") { var response: (info: OnionRequestResponseInfoType, data: Data?)? - OpenGroupAPI - .messageUpdate( - 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testServer", - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI + .messageUpdate( + db, + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -1561,28 +1699,28 @@ class OpenGroupAPISpec: QuickSpec { context("when unblinded") { beforeEach { - mockStorage - .when { $0.getOpenGroupServer(name: any()) } - .thenReturn( - OpenGroupAPI.Server( - name: "testServer", - capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: []) - ) - ) + mockStorage.write { db in + _ = try Capability.deleteAll(db) + try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) + } } it("signs the message correctly") { var response: (info: OnionRequestResponseInfoType, data: Data?)? - OpenGroupAPI - .messageUpdate( - 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testServer", - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI + .messageUpdate( + db, + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testServer", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -1602,20 +1740,26 @@ class OpenGroupAPISpec: QuickSpec { expect(requestBody.signature).to(equal("TestStandardSignature".data(using: .utf8))) } - it("fails to sign if there is no public key") { - mockStorage.when { $0.getOpenGroupPublicKey(for: any()) }.thenReturn(nil) + it("fails to sign if there is no open group") { + mockStorage.write { db in + _ = try OpenGroup.deleteAll(db) + } var response: (info: OnionRequestResponseInfoType, data: Data?)? - OpenGroupAPI - .messageUpdate( - 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testServer", - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI + .messageUpdate( + db, + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -1630,19 +1774,26 @@ class OpenGroupAPISpec: QuickSpec { } it("fails to sign if there is no user key pair") { - mockStorage.when { $0.getUserKeyPair() }.thenReturn(nil) + mockStorage.write { db in + _ = try Identity.filter(id: .x25519PublicKey).deleteAll(db) + _ = try Identity.filter(id: .x25519PrivateKey).deleteAll(db) + } var response: (info: OnionRequestResponseInfoType, data: Data?)? - OpenGroupAPI - .messageUpdate( - 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testServer", - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI + .messageUpdate( + db, + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -1662,15 +1813,19 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: OnionRequestResponseInfoType, data: Data?)? - OpenGroupAPI - .messageUpdate( - 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testServer", - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI + .messageUpdate( + db, + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testServer", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -1687,28 +1842,29 @@ class OpenGroupAPISpec: QuickSpec { context("when blinded") { beforeEach { - mockStorage - .when { $0.getOpenGroupServer(name: any()) } - .thenReturn( - OpenGroupAPI.Server( - name: "testServer", - capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs, .blind], missing: []) - ) - ) + mockStorage.write { db in + _ = try Capability.deleteAll(db) + try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) + try Capability(openGroupServer: "testserver", variant: .blind, isMissing: false).insert(db) + } } it("signs the message correctly") { var response: (info: OnionRequestResponseInfoType, data: Data?)? - OpenGroupAPI - .messageUpdate( - 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testServer", - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI + .messageUpdate( + db, + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testServer", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -1728,20 +1884,26 @@ class OpenGroupAPISpec: QuickSpec { expect(requestBody.signature).to(equal("TestSogsSignature".data(using: .utf8))) } - it("fails to sign if there is no public key") { - mockStorage.when { $0.getOpenGroupPublicKey(for: any()) }.thenReturn(nil) + it("fails to sign if there is no open group") { + mockStorage.write { db in + _ = try OpenGroup.deleteAll(db) + } var response: (info: OnionRequestResponseInfoType, data: Data?)? - OpenGroupAPI - .messageUpdate( - 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testServer", - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI + .messageUpdate( + db, + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -1756,19 +1918,26 @@ class OpenGroupAPISpec: QuickSpec { } it("fails to sign if there is no ed key pair key") { - mockStorage.when { $0.getUserED25519KeyPair() }.thenReturn(nil) + mockStorage.write { db in + _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) + _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) + } var response: (info: OnionRequestResponseInfoType, data: Data?)? - OpenGroupAPI - .messageUpdate( - 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testServer", - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI + .messageUpdate( + db, + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -1796,15 +1965,19 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: OnionRequestResponseInfoType, data: Data?)? - OpenGroupAPI - .messageUpdate( - 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testServer", - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI + .messageUpdate( + db, + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testServer", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -1829,7 +2002,17 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: OnionRequestResponseInfoType, data: Data?)? - OpenGroupAPI.messageDelete(123, in: "testRoom", on: "testServer", using: dependencies) + mockStorage + .read { db in + OpenGroupAPI + .messageDelete( + db, + id: 123, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -1864,13 +2047,17 @@ class OpenGroupAPISpec: QuickSpec { } it("generates the request and handles the response correctly") { - OpenGroupAPI - .messagesDeleteAll( - "testUserId", - in: "testRoom", - on: "testServer", - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI + .messagesDeleteAll( + db, + sessionId: "testUserId", + in: "testRoom", + on: "testServer", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -1901,7 +2088,17 @@ class OpenGroupAPISpec: QuickSpec { var response: OnionRequestResponseInfoType? - OpenGroupAPI.pinMessage(id: 123, in: "testRoom", on: "testServer", using: dependencies) + mockStorage + .read { db in + OpenGroupAPI + .pinMessage( + db, + id: 123, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -1930,7 +2127,17 @@ class OpenGroupAPISpec: QuickSpec { var response: OnionRequestResponseInfoType? - OpenGroupAPI.unpinMessage(id: 123, in: "testRoom", on: "testServer", using: dependencies) + mockStorage + .read { db in + OpenGroupAPI + .unpinMessage( + db, + id: 123, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -1959,7 +2166,16 @@ class OpenGroupAPISpec: QuickSpec { var response: OnionRequestResponseInfoType? - OpenGroupAPI.unpinAll(in: "testRoom", on: "testServer", using: dependencies) + mockStorage + .read { db in + OpenGroupAPI + .unpinAll( + db, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -1990,7 +2206,17 @@ class OpenGroupAPISpec: QuickSpec { } dependencies = dependencies.with(onionApi: TestApi.self) - OpenGroupAPI.uploadFile([], to: "testRoom", on: "testServer", using: dependencies) + mockStorage + .read { db in + OpenGroupAPI + .uploadFile( + db, + bytes: [], + to: "testRoom", + on: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -2017,7 +2243,17 @@ class OpenGroupAPISpec: QuickSpec { } dependencies = dependencies.with(onionApi: TestApi.self) - OpenGroupAPI.uploadFile([], to: "testRoom", on: "testServer", using: dependencies) + mockStorage + .read { db in + OpenGroupAPI + .uploadFile( + db, + bytes: [], + to: "testRoom", + on: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -2043,7 +2279,18 @@ class OpenGroupAPISpec: QuickSpec { } dependencies = dependencies.with(onionApi: TestApi.self) - OpenGroupAPI.uploadFile([], fileName: "TestFileName", to: "testRoom", on: "testServer", using: dependencies) + mockStorage + .read { db in + OpenGroupAPI + .uploadFile( + db, + bytes: [], + fileName: "TestFileName", + to: "testRoom", + on: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -2070,7 +2317,17 @@ class OpenGroupAPISpec: QuickSpec { } dependencies = dependencies.with(onionApi: TestApi.self) - OpenGroupAPI.downloadFile(1, from: "testRoom", on: "testServer", using: dependencies) + mockStorage + .read { db in + OpenGroupAPI + .downloadFile( + db, + fileId: 1, + from: "testRoom", + on: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -2118,13 +2375,17 @@ class OpenGroupAPISpec: QuickSpec { it("correctly sends the message request") { var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.SendDirectMessageResponse)? - OpenGroupAPI - .send( - "test".data(using: .utf8)!, - toInboxFor: "testUserId", - on: "testServer", - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI + .send( + db, + ciphertext: "test".data(using: .utf8)!, + toInboxFor: "testUserId", + on: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -2145,32 +2406,6 @@ class OpenGroupAPISpec: QuickSpec { expect(requestData?.server).to(equal("testServer")) expect(requestData?.urlString).to(equal("testServer/inbox/testUserId")) } - - it("saves the received message timestamp to the database in milliseconds") { - var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.SendDirectMessageResponse)? - - OpenGroupAPI - .send( - "test".data(using: .utf8)!, - toInboxFor: "testUserId", - on: "testServer", - using: dependencies - ) - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - expect(mockStorage) - .to(call(matchingParameters: true) { - $0.addReceivedMessageTimestamp(321000, using: anyAny()) - }) - } } // MARK: - Users @@ -2190,14 +2425,18 @@ class OpenGroupAPISpec: QuickSpec { } it("generates the request and handles the response correctly") { - OpenGroupAPI - .userBan( - "testUserId", - for: nil, - from: nil, - on: "testServer", - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI + .userBan( + db, + sessionId: "testUserId", + for: nil, + from: nil, + on: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -2217,14 +2456,18 @@ class OpenGroupAPISpec: QuickSpec { } it("does a global ban if no room tokens are provided") { - OpenGroupAPI - .userBan( - "testUserId", - for: nil, - from: nil, - on: "testServer", - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI + .userBan( + db, + sessionId: "testUserId", + for: nil, + from: nil, + on: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -2245,14 +2488,18 @@ class OpenGroupAPISpec: QuickSpec { } it("does room specific bans if room tokens are provided") { - OpenGroupAPI - .userBan( - "testUserId", - for: nil, - from: ["testRoom"], - on: "testServer", - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI + .userBan( + db, + sessionId: "testUserId", + for: nil, + from: ["testRoom"], + on: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -2288,13 +2535,17 @@ class OpenGroupAPISpec: QuickSpec { } it("generates the request and handles the response correctly") { - OpenGroupAPI - .userUnban( - "testUserId", - from: nil, - on: "testServer", - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI + .userUnban( + db, + sessionId: "testUserId", + from: nil, + on: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -2314,13 +2565,17 @@ class OpenGroupAPISpec: QuickSpec { } it("does a global ban if no room tokens are provided") { - OpenGroupAPI - .userUnban( - "testUserId", - from: nil, - on: "testServer", - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI + .userUnban( + db, + sessionId: "testUserId", + from: nil, + on: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -2341,13 +2596,17 @@ class OpenGroupAPISpec: QuickSpec { } it("does room specific bans if room tokens are provided") { - OpenGroupAPI - .userUnban( - "testUserId", - from: ["testRoom"], - on: "testServer", - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI + .userUnban( + db, + sessionId: "testUserId", + from: ["testRoom"], + on: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -2383,16 +2642,20 @@ class OpenGroupAPISpec: QuickSpec { } it("generates the request and handles the response correctly") { - OpenGroupAPI - .userModeratorUpdate( - "testUserId", - moderator: true, - admin: nil, - visible: true, - for: nil, - on: "testServer", - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI + .userModeratorUpdate( + db, + sessionId: "testUserId", + moderator: true, + admin: nil, + visible: true, + for: nil, + on: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -2412,16 +2675,20 @@ class OpenGroupAPISpec: QuickSpec { } it("does a global update if no room tokens are provided") { - OpenGroupAPI - .userModeratorUpdate( - "testUserId", - moderator: true, - admin: nil, - visible: true, - for: nil, - on: "testServer", - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI + .userModeratorUpdate( + db, + sessionId: "testUserId", + moderator: true, + admin: nil, + visible: true, + for: nil, + on: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -2442,16 +2709,20 @@ class OpenGroupAPISpec: QuickSpec { } it("does room specific updates if room tokens are provided") { - OpenGroupAPI - .userModeratorUpdate( - "testUserId", - moderator: true, - admin: nil, - visible: true, - for: ["testRoom"], - on: "testServer", - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI + .userModeratorUpdate( + db, + sessionId: "testUserId", + moderator: true, + admin: nil, + visible: true, + for: ["testRoom"], + on: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -2472,16 +2743,20 @@ class OpenGroupAPISpec: QuickSpec { } it("fails if neither moderator or admin are set") { - OpenGroupAPI - .userModeratorUpdate( - "testUserId", - moderator: nil, - admin: nil, - visible: true, - for: nil, - on: "testServer", - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI + .userModeratorUpdate( + db, + sessionId: "testUserId", + moderator: nil, + admin: nil, + visible: true, + for: nil, + on: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -2532,13 +2807,17 @@ class OpenGroupAPISpec: QuickSpec { } it("generates the request and handles the response correctly") { - OpenGroupAPI - .userBanAndDeleteAllMessages( - "testUserId", - in: "testRoom", - on: "testServer", - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI + .userBanAndDeleteAllMessages( + db, + sessionId: "testUserId", + in: "testRoom", + on: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -2558,13 +2837,17 @@ class OpenGroupAPISpec: QuickSpec { } it("bans the user from the specified room rather than globally") { - OpenGroupAPI - .userBanAndDeleteAllMessages( - "testUserId", - in: "testRoom", - on: "testServer", - using: dependencies - ) + mockStorage + .read { db in + OpenGroupAPI + .userBanAndDeleteAllMessages( + db, + sessionId: "testUserId", + in: "testRoom", + on: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -2606,9 +2889,20 @@ class OpenGroupAPISpec: QuickSpec { } it("fails when there is no userEdKeyPair") { - mockStorage.when { $0.getUserED25519KeyPair() }.thenReturn(nil) + mockStorage.write { db in + _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) + _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) + } - OpenGroupAPI.rooms(for: "testServer", using: dependencies) + mockStorage + .read { db in + OpenGroupAPI + .rooms( + db, + server: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -2623,9 +2917,19 @@ class OpenGroupAPISpec: QuickSpec { } it("fails when there is no serverPublicKey") { - mockStorage.when { $0.getOpenGroupPublicKey(for: any()) }.thenReturn(nil) + mockStorage.write { db in + _ = try OpenGroup.deleteAll(db) + } - OpenGroupAPI.rooms(for: "testServer", using: dependencies) + mockStorage + .read { db in + OpenGroupAPI + .rooms( + db, + server: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -2640,9 +2944,19 @@ class OpenGroupAPISpec: QuickSpec { } it("fails when the serverPublicKey is not a hex string") { - mockStorage.when { $0.getOpenGroupPublicKey(for: any()) }.thenReturn("TestString!!!") + mockStorage.write { db in + _ = try OpenGroup.updateAll(db, OpenGroup.Columns.publicKey.set(to: "TestString!!!")) + } - OpenGroupAPI.rooms(for: "testServer", using: dependencies) + mockStorage + .read { db in + OpenGroupAPI + .rooms( + db, + server: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -2658,18 +2972,22 @@ class OpenGroupAPISpec: QuickSpec { context("when unblinded") { beforeEach { - mockStorage - .when { $0.getOpenGroupServer(name: any()) } - .thenReturn( - OpenGroupAPI.Server( - name: "testServer", - capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: []) - ) - ) + mockStorage.write { db in + _ = try Capability.deleteAll(db) + try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) + } } it("signs correctly") { - OpenGroupAPI.rooms(for: "testServer", using: dependencies) + mockStorage + .read { db in + OpenGroupAPI + .rooms( + db, + server: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -2698,7 +3016,15 @@ class OpenGroupAPISpec: QuickSpec { it("fails when the signature is not generated") { mockSign.when { $0.signature(message: anyArray(), secretKey: anyArray()) }.thenReturn(nil) - OpenGroupAPI.rooms(for: "testServer", using: dependencies) + mockStorage + .read { db in + OpenGroupAPI + .rooms( + db, + server: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -2715,18 +3041,23 @@ class OpenGroupAPISpec: QuickSpec { context("when blinded") { beforeEach { - mockStorage - .when { $0.getOpenGroupServer(name: any()) } - .thenReturn( - OpenGroupAPI.Server( - name: "testServer", - capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs, .blind], missing: []) - ) - ) + mockStorage.write { db in + _ = try Capability.deleteAll(db) + try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) + try Capability(openGroupServer: "testserver", variant: .blind, isMissing: false).insert(db) + } } it("signs correctly") { - OpenGroupAPI.rooms(for: "testServer", using: dependencies) + mockStorage + .read { db in + OpenGroupAPI + .rooms( + db, + server: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -2756,7 +3087,15 @@ class OpenGroupAPISpec: QuickSpec { .when { $0.blindedKeyPair(serverPublicKey: any(), edKeyPair: any(), genericHash: mockGenericHash) } .thenReturn(nil) - OpenGroupAPI.rooms(for: "testServer", using: dependencies) + mockStorage + .read { db in + OpenGroupAPI + .rooms( + db, + server: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() @@ -2775,7 +3114,15 @@ class OpenGroupAPISpec: QuickSpec { .when { $0.blindedKeyPair(serverPublicKey: any(), edKeyPair: any(), genericHash: mockGenericHash) } .thenReturn(nil) - OpenGroupAPI.rooms(for: "testServer", using: dependencies) + mockStorage + .read { db in + OpenGroupAPI + .rooms( + db, + server: "testserver", + using: dependencies + ) + } .get { result in response = result } .catch { requestError in error = requestError } .retainUntilComplete() diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift index 240648781..55c01e1d6 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -113,7 +113,6 @@ class OpenGroupManagerSpec: QuickSpec { dependencies = OpenGroupManager.OGMDependencies( cache: Atomic(mockOGMCache), onionApi: TestCapabilitiesAndRoomApi.self, - identityManager: mockIdentityManager, generalCache: Atomic(mockGeneralCache), storage: mockStorage, sodium: mockSodium, diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverDecryptionSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverDecryptionSpec.swift index c59f20e23..abd8393aa 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverDecryptionSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverDecryptionSpec.swift @@ -2,6 +2,8 @@ import Foundation import Sodium +import GRDB +import SessionUtilitiesKit import Quick import Nimble @@ -12,7 +14,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { // MARK: - Spec override func spec() { - var mockStorage: MockStorage! + var mockStorage: GRDBStorage! var mockSodium: MockSodium! var mockBox: MockBox! var mockGenericHash: MockGenericHash! @@ -23,7 +25,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { describe("a MessageReceiver") { beforeEach { - mockStorage = MockStorage() + mockStorage = GRDBStorage(customWriter: DatabaseQueue()) mockSodium = MockSodium() mockBox = MockBox() mockGenericHash = MockGenericHash() @@ -45,14 +47,10 @@ class MessageReceiverDecryptionSpec: QuickSpec { nonceGenerator24: mockNonce24Generator ) - mockStorage - .when { $0.getUserED25519KeyPair() } - .thenReturn( - Box.KeyPair( - publicKey: Data(hex: TestConstants.edPublicKey).bytes, - secretKey: Data(hex: TestConstants.edSecretKey).bytes - ) - ) + mockStorage.write { db in + try Identity(variant: .ed25519PublicKey, data: Data(hex: TestConstants.edPublicKey)).insert(db) + try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).insert(db) + } mockBox .when { $0.open( @@ -109,9 +107,9 @@ class MessageReceiverDecryptionSpec: QuickSpec { "sFMhE5G4PbRtQFey1hsxLl221Qivc3ayaX2Mm/X89Dl8e45BC+Lb/KU9EdesxIK4pVgYXs9XrMtX3v8" + "dt0eBaXneOBfr7qB8pHwwMZjtkOu1ED07T9nszgbWabBphUfWXe2U9K3PTRisSCI=" )!, - using: try! ECKeyPair( - publicKeyData: Data.data(fromHex: TestConstants.publicKey)!, - privateKeyData: Data.data(fromHex: TestConstants.privateKey)! + using: Box.KeyPair( + publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.privateKey)!.bytes ), dependencies: Dependencies() ) @@ -135,14 +133,14 @@ class MessageReceiverDecryptionSpec: QuickSpec { expect { try MessageReceiver.decryptWithSessionProtocol( ciphertext: "TestMessage".data(using: .utf8)!, - using: try! ECKeyPair( - publicKeyData: Data.data(fromHex: TestConstants.publicKey)!, - privateKeyData: Data.data(fromHex: TestConstants.privateKey)! + using: Box.KeyPair( + publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.privateKey)!.bytes ), dependencies: dependencies ) } - .to(throwError(MessageReceiver.Error.decryptionFailed)) + .to(throwError(MessageReceiverError.decryptionFailed)) } it("throws an error if the open message is too short") { @@ -159,14 +157,14 @@ class MessageReceiverDecryptionSpec: QuickSpec { expect { try MessageReceiver.decryptWithSessionProtocol( ciphertext: "TestMessage".data(using: .utf8)!, - using: try! ECKeyPair( - publicKeyData: Data.data(fromHex: TestConstants.publicKey)!, - privateKeyData: Data.data(fromHex: TestConstants.privateKey)! + using: Box.KeyPair( + publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.privateKey)!.bytes ), dependencies: dependencies ) } - .to(throwError(MessageReceiver.Error.decryptionFailed)) + .to(throwError(MessageReceiverError.decryptionFailed)) } it("throws an error if it cannot verify the message") { @@ -177,14 +175,14 @@ class MessageReceiverDecryptionSpec: QuickSpec { expect { try MessageReceiver.decryptWithSessionProtocol( ciphertext: "TestMessage".data(using: .utf8)!, - using: try! ECKeyPair( - publicKeyData: Data.data(fromHex: TestConstants.publicKey)!, - privateKeyData: Data.data(fromHex: TestConstants.privateKey)! + using: Box.KeyPair( + publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.privateKey)!.bytes ), dependencies: dependencies ) } - .to(throwError(MessageReceiver.Error.invalidSignature)) + .to(throwError(MessageReceiverError.invalidSignature)) } it("throws an error if it cannot get the senders x25519 public key") { @@ -193,14 +191,14 @@ class MessageReceiverDecryptionSpec: QuickSpec { expect { try MessageReceiver.decryptWithSessionProtocol( ciphertext: "TestMessage".data(using: .utf8)!, - using: try! ECKeyPair( - publicKeyData: Data.data(fromHex: TestConstants.publicKey)!, - privateKeyData: Data.data(fromHex: TestConstants.privateKey)! + using: Box.KeyPair( + publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.privateKey)!.bytes ), dependencies: dependencies ) } - .to(throwError(MessageReceiver.Error.decryptionFailed)) + .to(throwError(MessageReceiverError.decryptionFailed)) } } @@ -263,7 +261,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { using: dependencies ) } - .to(throwError(MessageReceiver.Error.decryptionFailed)) + .to(throwError(MessageReceiverError.decryptionFailed)) } it("throws an error if it cannot get the blinded keyPair") { @@ -288,7 +286,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { using: dependencies ) } - .to(throwError(MessageReceiver.Error.decryptionFailed)) + .to(throwError(MessageReceiverError.decryptionFailed)) } it("throws an error if it cannot get the decryption key") { @@ -321,7 +319,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { using: dependencies ) } - .to(throwError(MessageReceiver.Error.decryptionFailed)) + .to(throwError(MessageReceiverError.decryptionFailed)) } it("throws an error if the data version is not 0") { @@ -342,7 +340,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { using: dependencies ) } - .to(throwError(MessageReceiver.Error.decryptionFailed)) + .to(throwError(MessageReceiverError.decryptionFailed)) } it("throws an error if it cannot decrypt the data") { @@ -367,7 +365,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { using: dependencies ) } - .to(throwError(MessageReceiver.Error.decryptionFailed)) + .to(throwError(MessageReceiverError.decryptionFailed)) } it("throws an error if the inner bytes are too short") { @@ -392,7 +390,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { using: dependencies ) } - .to(throwError(MessageReceiver.Error.decryptionFailed)) + .to(throwError(MessageReceiverError.decryptionFailed)) } it("throws an error if it cannot generate the blinding factor") { @@ -417,7 +415,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { using: dependencies ) } - .to(throwError(MessageReceiver.Error.invalidSignature)) + .to(throwError(MessageReceiverError.invalidSignature)) } it("throws an error if it cannot generate the combined key") { @@ -442,7 +440,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { using: dependencies ) } - .to(throwError(MessageReceiver.Error.invalidSignature)) + .to(throwError(MessageReceiverError.invalidSignature)) } it("throws an error if the combined key does not match kA") { @@ -467,7 +465,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { using: dependencies ) } - .to(throwError(MessageReceiver.Error.invalidSignature)) + .to(throwError(MessageReceiverError.invalidSignature)) } it("throws an error if it cannot get the senders x25519 public key") { @@ -492,7 +490,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { using: dependencies ) } - .to(throwError(MessageReceiver.Error.decryptionFailed)) + .to(throwError(MessageReceiverError.decryptionFailed)) } } } diff --git a/SessionMessagingKitTests/_TestUtilities/MockedExtensions.swift b/SessionMessagingKitTests/_TestUtilities/MockedExtensions.swift index cf0b1152c..021b59581 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockedExtensions.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockedExtensions.swift @@ -8,8 +8,8 @@ extension OpenGroup: Mocked { server: any(), roomToken: any(), publicKey: TestConstants.publicKey, - name: any(), isActive: any(), + name: any(), roomDescription: any(), imageId: any(), imageData: any(), @@ -22,7 +22,7 @@ extension OpenGroup: Mocked { } extension VisibleMessage: Mocked { - static var mockValue: VisibleMessage = VisibleMessage() + static var mockValue: VisibleMessage = VisibleMessage(text: "") } extension BlindedIdMapping: Mocked { diff --git a/SessionNotificationServiceExtension/NSENotificationPresenter.swift b/SessionNotificationServiceExtension/NSENotificationPresenter.swift index 491c55275..2f487f70e 100644 --- a/SessionNotificationServiceExtension/NSENotificationPresenter.swift +++ b/SessionNotificationServiceExtension/NSENotificationPresenter.swift @@ -13,7 +13,7 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return } let userPublicKey: String = getUserHexEncodedPublicKey(db) - let isMessageRequest: Bool = thread.isMessageRequest(db) + let isMessageRequest: Bool = thread.isMessageRequest(db, includeNonVisible: true) // If the thread is a message request and the user hasn't hidden message requests then we need // to check if this is the only message request thread (group threads can't be message requests @@ -21,13 +21,13 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { // notification regardless of how many message requests there are) if thread.variant == .contact { if isMessageRequest && !db[.hasHiddenMessageRequests] { - let numMessageRequestThreads: Int? = (try? SessionThread - .messageRequestsCountQuery(userPublicKey: userPublicKey) - .fetchOne(db)) + let numMessageRequestThreads: Int = (try? SessionThread + .messageRequestsQuery(userPublicKey: userPublicKey, includeNonVisible: true) + .fetchCount(db)) .defaulting(to: 0) // Allow this to show a notification if there are no message requests (ie. this is the first one) - guard (numMessageRequestThreads ?? 0) == 0 else { return } + guard numMessageRequestThreads == 0 else { return } } else if isMessageRequest && db[.hasHiddenMessageRequests] { // If there are other interactions on this thread already then don't show the notification From a998cadbb72c3f1a48a0baadeaa36fe284adca8c Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 22 Jun 2022 14:27:34 +1000 Subject: [PATCH 110/157] Fixed the broken unit tests Fixed a few bugs uncovered by the unit tests --- Session.xcodeproj/project.pbxproj | 32 +- .../Calls/Call Management/SessionCall.swift | 2 +- .../ConversationVC+Interaction.swift | 8 +- Session/Conversations/ConversationVC.swift | 2 - .../PhotoCapture.swift | 9 +- Session/Meta/AppDelegate.swift | 4 +- Session/Meta/AppEnvironment.swift | 4 +- Session/Meta/MainAppContext.m | 7 +- Session/Utilities/Permissions.swift | 4 +- .../_001_InitialSetupMigration.swift | 3 +- .../Migrations/_002_SetupStandardJobs.swift | 2 +- .../Migrations/_003_YDBToGRDBMigration.swift | 24 +- .../Database/Models/BlindedIdLookup.swift | 2 +- .../Database/Models/GroupMember.swift | 2 +- .../Database/Models/OpenGroup.swift | 5 +- SessionMessagingKit/Messages/Message.swift | 6 +- .../Open Groups/Models/SOGSMessage.swift | 2 +- .../Open Groups/OpenGroupAPI.swift | 66 +- .../Open Groups/OpenGroupManager.swift | 60 +- .../MessageReceiver+Calls.swift | 12 +- .../MessageReceiver+ClosedGroups.swift | 2 +- .../MessageReceiver+MessageRequests.swift | 4 +- .../MessageReceiver+VisibleMessages.swift | 4 +- .../MessageSender+ClosedGroups.swift | 2 +- .../MessageReceiver+Decryption.swift | 4 +- .../Sending & Receiving/MessageReceiver.swift | 8 +- .../MessageSender+Encryption.swift | 13 +- .../Sending & Receiving/MessageSender.swift | 8 +- .../Pollers/OpenGroupPoller.swift | 2 +- .../Utilities/Environment.swift | 8 +- .../Utilities/OWSAudioSession.swift | 16 +- ...pendencies.swift => SMKDependencies.swift} | 47 +- .../Open Groups/Models/SOGSMessageSpec.swift | 4 +- .../Open Groups/OpenGroupAPISpec.swift | 102 +- .../Open Groups/OpenGroupManagerSpec.swift | 3532 ++++++++--------- .../MessageReceiverDecryptionSpec.swift | 16 +- .../MessageSenderEncryptionSpec.swift | 14 +- .../_TestUtilities/DependencyExtensions.swift | 6 +- .../_TestUtilities/MockOGMCache.swift | 11 +- .../_TestUtilities/MockUserDefaults.swift | 2 - .../_TestUtilities/MockedExtensions.swift | 34 - .../_TestUtilities/TestContactThread.swift | 51 - .../_TestUtilities/TestGroupThread.swift | 57 - .../_TestUtilities/TestIncomingMessage.swift | 23 - .../_TestUtilities/TestInteraction.swift | 32 - .../_TestUtilities/TestTransaction.swift | 35 - .../NotificationServiceExtension.swift | 4 +- .../NotificationServiceExtensionContext.swift | 4 - .../ShareAppExtensionContext.swift | 4 - SessionShareExtension/ShareVC.swift | 2 +- .../_001_InitialSetupMigration.swift | 2 +- .../Migrations/_002_SetupStandardJobs.swift | 2 +- .../Migrations/_003_YDBToGRDBMigration.swift | 20 +- .../Database/GRDBStorage.swift | 49 +- .../Database/LegacyDatabase/SUKLegacy.swift | 4 +- .../_001_InitialSetupMigration.swift | 2 +- .../Migrations/_002_SetupStandardJobs.swift | 2 +- .../Migrations/_003_YDBToGRDBMigration.swift | 2 +- SessionUtilitiesKit/General/AppContext.h | 2 - .../General/Dependencies.swift | 55 + SessionUtilitiesKit/General/General.swift | 6 +- .../OWSVideoPlayer.swift | 15 +- 62 files changed, 1956 insertions(+), 2512 deletions(-) rename SessionMessagingKit/Utilities/{Dependencies.swift => SMKDependencies.swift} (70%) delete mode 100644 SessionMessagingKitTests/_TestUtilities/MockedExtensions.swift delete mode 100644 SessionMessagingKitTests/_TestUtilities/TestContactThread.swift delete mode 100644 SessionMessagingKitTests/_TestUtilities/TestGroupThread.swift delete mode 100644 SessionMessagingKitTests/_TestUtilities/TestIncomingMessage.swift delete mode 100644 SessionMessagingKitTests/_TestUtilities/TestInteraction.swift delete mode 100644 SessionMessagingKitTests/_TestUtilities/TestTransaction.swift create mode 100644 SessionUtilitiesKit/General/Dependencies.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index d640c35fc..16e425017 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -564,7 +564,6 @@ FD078E4F27E175F1000769AF /* DependencyExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E4E27E175F1000769AF /* DependencyExtensions.swift */; }; FD078E5227E1760A000769AF /* OGMDependencyExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E5127E1760A000769AF /* OGMDependencyExtensions.swift */; }; FD078E5427E197CA000769AF /* OpenGroupManagerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909D27D85751005DAE71 /* OpenGroupManagerSpec.swift */; }; - FD078E5827E1B831000769AF /* TestIncomingMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E5727E1B831000769AF /* TestIncomingMessage.swift */; }; FD078E5A27E29F09000769AF /* MockNonce16Generator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E5927E29F09000769AF /* MockNonce16Generator.swift */; }; FD078E5C27E29F78000769AF /* MockNonce24Generator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E5B27E29F78000769AF /* MockNonce24Generator.swift */; }; FD09796927F6BEA700936362 /* SwarmSnode.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09796827F6BEA700936362 /* SwarmSnode.swift */; }; @@ -738,16 +737,12 @@ FDC2909827D7129B005DAE71 /* PersonalizationSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909727D7129B005DAE71 /* PersonalizationSpec.swift */; }; FDC2909A27D71376005DAE71 /* NonceGeneratorSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909927D71376005DAE71 /* NonceGeneratorSpec.swift */; }; FDC2909C27D713D2005DAE71 /* SodiumProtocolsSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909B27D713D2005DAE71 /* SodiumProtocolsSpec.swift */; }; - FDC290A027D85826005DAE71 /* TestContactThread.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909F27D85826005DAE71 /* TestContactThread.swift */; }; - FDC290A227D85890005DAE71 /* TestInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A127D85890005DAE71 /* TestInteraction.swift */; }; FDC290A627D860CE005DAE71 /* Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A527D860CE005DAE71 /* Mock.swift */; }; FDC290A827D9B46D005DAE71 /* NimbleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */; }; FDC290A927D9B46D005DAE71 /* NimbleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */; }; FDC290AA27D9B6FD005DAE71 /* Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A527D860CE005DAE71 /* Mock.swift */; }; FDC290AC27DB0B1C005DAE71 /* MockedExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290AB27DB0B1C005DAE71 /* MockedExtensions.swift */; }; - FDC290AF27DFEE97005DAE71 /* TestTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290AE27DFEE97005DAE71 /* TestTransaction.swift */; }; FDC290B327DFF9F5005DAE71 /* TestOnionRequestAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290B227DFF9F5005DAE71 /* TestOnionRequestAPI.swift */; }; - FDC290B727E00FDB005DAE71 /* TestGroupThread.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290B627E00FDB005DAE71 /* TestGroupThread.swift */; }; FDC4380927B31D4E00C60D73 /* SOGSError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4380827B31D4E00C60D73 /* SOGSError.swift */; }; FDC4381527B329CE00C60D73 /* NonceGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381427B329CE00C60D73 /* NonceGenerator.swift */; }; FDC4381727B32EC700C60D73 /* Personalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381627B32EC700C60D73 /* Personalization.swift */; }; @@ -776,13 +771,14 @@ FDC438B327BB15B400C60D73 /* ResponseInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438B227BB15B400C60D73 /* ResponseInfo.swift */; }; FDC438B927BB161E00C60D73 /* OnionRequestAPIVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438B827BB161E00C60D73 /* OnionRequestAPIVersion.swift */; }; FDC438BD27BB2AB400C60D73 /* Mockable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438BC27BB2AB400C60D73 /* Mockable.swift */; }; - FDC438C127BB4E6800C60D73 /* Dependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438C027BB4E6800C60D73 /* Dependencies.swift */; }; + FDC438C127BB4E6800C60D73 /* SMKDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438C027BB4E6800C60D73 /* SMKDependencies.swift */; }; FDC438C327BB512200C60D73 /* SodiumProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438C227BB512200C60D73 /* SodiumProtocols.swift */; }; FDC438C727BB6DF000C60D73 /* DirectMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438C627BB6DF000C60D73 /* DirectMessage.swift */; }; 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 */; }; FDC6D6F32860607300B04575 /* Environment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7542807C4BB004C14C5 /* Environment.swift */; }; + FDC6D7602862B3F600B04575 /* Dependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC6D75F2862B3F600B04575 /* Dependencies.swift */; }; FDCDB8DE2810F73B00352A0C /* Differentiable+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DD2810F73B00352A0C /* Differentiable+Utilities.swift */; }; FDCDB8E02811007F00352A0C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */; }; FDCDB8E42817819600352A0C /* (null) in Sources */ = {isa = PBXBuildFile; }; @@ -1634,7 +1630,6 @@ FD078E4C27E17156000769AF /* MockOGMCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockOGMCache.swift; sourceTree = ""; }; FD078E4E27E175F1000769AF /* DependencyExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DependencyExtensions.swift; sourceTree = ""; }; FD078E5127E1760A000769AF /* OGMDependencyExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OGMDependencyExtensions.swift; sourceTree = ""; }; - FD078E5727E1B831000769AF /* TestIncomingMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestIncomingMessage.swift; sourceTree = ""; }; FD078E5927E29F09000769AF /* MockNonce16Generator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNonce16Generator.swift; sourceTree = ""; }; FD078E5B27E29F78000769AF /* MockNonce24Generator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNonce24Generator.swift; sourceTree = ""; }; FD09796827F6BEA700936362 /* SwarmSnode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwarmSnode.swift; sourceTree = ""; }; @@ -1781,14 +1776,10 @@ FDC2909927D71376005DAE71 /* NonceGeneratorSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonceGeneratorSpec.swift; sourceTree = ""; }; FDC2909B27D713D2005DAE71 /* SodiumProtocolsSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SodiumProtocolsSpec.swift; sourceTree = ""; }; FDC2909D27D85751005DAE71 /* OpenGroupManagerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupManagerSpec.swift; sourceTree = ""; }; - FDC2909F27D85826005DAE71 /* TestContactThread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestContactThread.swift; sourceTree = ""; }; - FDC290A127D85890005DAE71 /* TestInteraction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestInteraction.swift; sourceTree = ""; }; FDC290A527D860CE005DAE71 /* Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mock.swift; sourceTree = ""; }; FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NimbleExtensions.swift; sourceTree = ""; }; FDC290AB27DB0B1C005DAE71 /* MockedExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockedExtensions.swift; sourceTree = ""; }; - FDC290AE27DFEE97005DAE71 /* TestTransaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestTransaction.swift; sourceTree = ""; }; FDC290B227DFF9F5005DAE71 /* TestOnionRequestAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestOnionRequestAPI.swift; sourceTree = ""; }; - FDC290B627E00FDB005DAE71 /* TestGroupThread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestGroupThread.swift; sourceTree = ""; }; FDC4380827B31D4E00C60D73 /* SOGSError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSError.swift; sourceTree = ""; }; FDC4381427B329CE00C60D73 /* NonceGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonceGenerator.swift; sourceTree = ""; }; FDC4381627B32EC700C60D73 /* Personalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Personalization.swift; sourceTree = ""; }; @@ -1817,12 +1808,13 @@ FDC438B227BB15B400C60D73 /* ResponseInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResponseInfo.swift; sourceTree = ""; }; FDC438B827BB161E00C60D73 /* OnionRequestAPIVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnionRequestAPIVersion.swift; sourceTree = ""; }; FDC438BC27BB2AB400C60D73 /* Mockable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mockable.swift; sourceTree = ""; }; - FDC438C027BB4E6800C60D73 /* Dependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dependencies.swift; sourceTree = ""; }; + FDC438C027BB4E6800C60D73 /* SMKDependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMKDependencies.swift; sourceTree = ""; }; FDC438C227BB512200C60D73 /* SodiumProtocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SodiumProtocols.swift; sourceTree = ""; }; FDC438C627BB6DF000C60D73 /* DirectMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectMessage.swift; sourceTree = ""; }; 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 = ""; }; + FDC6D75F2862B3F600B04575 /* Dependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dependencies.swift; sourceTree = ""; }; FDCDB8DD2810F73B00352A0C /* Differentiable+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Differentiable+Utilities.swift"; sourceTree = ""; }; FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; FDCDB8F02817ABE600352A0C /* Optional+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Utilities.swift"; sourceTree = ""; }; @@ -2395,6 +2387,7 @@ C33FDAA8255A57FF00E217F9 /* BuildConfiguration.swift */, B8F5F58225EC94A6003BF8D4 /* Collection+Subscripting.swift */, FDFD645A27F26D4600808CA1 /* Data+Utilities.swift */, + FDC6D75F2862B3F600B04575 /* Dependencies.swift */, C3C2A5D52553860A00C340D1 /* Dictionary+Utilities.swift */, B87EF18026377A1D00124B3C /* Features.swift */, B8BC00BF257D90E30032E807 /* General.swift */, @@ -3058,7 +3051,7 @@ C33FDB75255A581000E217F9 /* AppReadiness.m */, FDF0B7542807C4BB004C14C5 /* Environment.swift */, FD859EF127BF6BA200510D0C /* Data+Utilities.swift */, - FDC438C027BB4E6800C60D73 /* Dependencies.swift */, + FDC438C027BB4E6800C60D73 /* SMKDependencies.swift */, C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */, C3BBE0C62554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift */, C3A71D0A2558989C0043A11F /* MessageWrapper.swift */, @@ -3869,11 +3862,6 @@ FD83B9D127D59495005E1583 /* MockUserDefaults.swift */, FD078E4C27E17156000769AF /* MockOGMCache.swift */, FDC290B227DFF9F5005DAE71 /* TestOnionRequestAPI.swift */, - FDC290AE27DFEE97005DAE71 /* TestTransaction.swift */, - FDC290B627E00FDB005DAE71 /* TestGroupThread.swift */, - FDC2909F27D85826005DAE71 /* TestContactThread.swift */, - FDC290A127D85890005DAE71 /* TestInteraction.swift */, - FD078E5727E1B831000769AF /* TestIncomingMessage.swift */, FDC290AB27DB0B1C005DAE71 /* MockedExtensions.swift */, FD078E4E27E175F1000769AF /* DependencyExtensions.swift */, FD078E5127E1760A000769AF /* OGMDependencyExtensions.swift */, @@ -5034,6 +5022,7 @@ C32C600F256E07F5003C73A2 /* NSUserDefaults+OWS.m in Sources */, C3D9E35E25675F640040E4F3 /* OWSFileSystem.m in Sources */, FD09797D27FBDB2000936362 /* Notification+Utilities.swift in Sources */, + FDC6D7602862B3F600B04575 /* Dependencies.swift in Sources */, C3C2AC2E2553CBEB00C340D1 /* String+Trimming.swift in Sources */, C32C5B48256DC211003C73A2 /* NSNotificationCenter+OWS.m in Sources */, FD17D7C727F5207C00122BE0 /* DatabaseMigrator+Utilities.swift in Sources */, @@ -5246,7 +5235,7 @@ FD09798127FCFEE800936362 /* SessionThread.swift in Sources */, FD09C5EA282A1BB2000CE219 /* ThreadTypingIndicator.swift in Sources */, FDF0B75E280AAF35004C14C5 /* Preferences.swift in Sources */, - FDC438C127BB4E6800C60D73 /* Dependencies.swift in Sources */, + FDC438C127BB4E6800C60D73 /* SMKDependencies.swift in Sources */, FDC4383827B3863200C60D73 /* VersionResponse.swift in Sources */, B806ECA126C4A7E4008BDA44 /* WebRTCSession+UI.swift in Sources */, 7BCD116C27016062006330F1 /* WebRTCSession+DataChannel.swift in Sources */, @@ -5464,10 +5453,8 @@ files = ( FDC290AC27DB0B1C005DAE71 /* MockedExtensions.swift in Sources */, FD3C905C27E3FBEF00CD579F /* BatchRequestInfoSpec.swift in Sources */, - FD078E5827E1B831000769AF /* TestIncomingMessage.swift in Sources */, FD859EFA27C2F5C500510D0C /* MockGenericHash.swift in Sources */, FDC2909427D710B4005DAE71 /* SOGSEndpointSpec.swift in Sources */, - FDC290AF27DFEE97005DAE71 /* TestTransaction.swift in Sources */, FDC290B327DFF9F5005DAE71 /* TestOnionRequestAPI.swift in Sources */, FDC2909127D709CA005DAE71 /* SOGSMessageSpec.swift in Sources */, FD3C906A27E417CE00CD579F /* SodiumUtilitiesSpec.swift in Sources */, @@ -5481,7 +5468,6 @@ FDC2909A27D71376005DAE71 /* NonceGeneratorSpec.swift in Sources */, FD3C906427E4122F00CD579F /* RequestSpec.swift in Sources */, FD078E4827E02561000769AF /* CommonMockedExtensions.swift in Sources */, - FDC290A027D85826005DAE71 /* TestContactThread.swift in Sources */, FD859EF827C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift in Sources */, FDC2909827D7129B005DAE71 /* PersonalizationSpec.swift in Sources */, FD859EF427C2F49200510D0C /* MockSodium.swift in Sources */, @@ -5496,7 +5482,6 @@ FDC2908B27D707F3005DAE71 /* SendMessageRequestSpec.swift in Sources */, FDC290A827D9B46D005DAE71 /* NimbleExtensions.swift in Sources */, FD3C906227E411AF00CD579F /* HeaderSpec.swift in Sources */, - FDC290B727E00FDB005DAE71 /* TestGroupThread.swift in Sources */, FDC2908F27D70938005DAE71 /* SendDirectMessageRequestSpec.swift in Sources */, FDC438BD27BB2AB400C60D73 /* Mockable.swift in Sources */, FD859EF627C2F52C00510D0C /* MockSign.swift in Sources */, @@ -5505,7 +5490,6 @@ FD078E5A27E29F09000769AF /* MockNonce16Generator.swift in Sources */, FD3C906D27E43C4B00CD579F /* MessageSenderEncryptionSpec.swift in Sources */, FDC2908D27D70905005DAE71 /* UpdateMessageRequestSpec.swift in Sources */, - FDC290A227D85890005DAE71 /* TestInteraction.swift in Sources */, FD078E5427E197CA000769AF /* OpenGroupManagerSpec.swift in Sources */, FD3C906727E416AF00CD579F /* BlindedIdMappingSpec.swift in Sources */, FD83B9D227D59495005E1583 /* MockUserDefaults.swift in Sources */, diff --git a/Session/Calls/Call Management/SessionCall.swift b/Session/Calls/Call Management/SessionCall.swift index 0d9c03563..95a973304 100644 --- a/Session/Calls/Call Management/SessionCall.swift +++ b/Session/Calls/Call Management/SessionCall.swift @@ -386,7 +386,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { private func tryToReconnect() { reconnectTimer?.invalidate() - guard Environment.shared.reachabilityManager.isReachable else { + guard Environment.shared?.reachabilityManager.isReachable == true else { reconnectTimer = Timer.scheduledTimerOnMainThread(withTimeInterval: 5, repeats: false) { _ in self.tryToReconnect() } diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 99a1e31be..c2e3a801d 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -1412,7 +1412,7 @@ extension ConversationVC: let url: URL = URL(fileURLWithPath: directory).appendingPathComponent(fileName) // Set up audio session - let isConfigured = audioSession.startAudioActivity(recordVoiceMessageActivity) + let isConfigured = (Environment.shared?.audioSession.startAudioActivity(recordVoiceMessageActivity) == true) guard isConfigured else { return cancelVoiceMessageRecording() } @@ -1514,7 +1514,7 @@ extension ConversationVC: func stopVoiceMessageRecording() { audioRecorder?.stop() - audioSession.endAudioActivity(recordVoiceMessageActivity) + Environment.shared?.audioSession.endAudioActivity(recordVoiceMessageActivity) } // MARK: - Permissions @@ -1570,7 +1570,7 @@ extension ConversationVC: // the picker view then will dismiss, too. The selection process cannot be finished // this way. So we add a flag (isRequestingPermission) to prevent the ScreenLockUI // from showing when we request the photo library permission. - Environment.shared.isRequestingPermission = true + Environment.shared?.isRequestingPermission = true let appMode = AppModeManager.shared.currentAppMode // FIXME: Rather than setting the app mode to light and then to dark again once we're done, // it'd be better to just customize the appearance of the image picker. There doesn't currently @@ -1580,7 +1580,7 @@ extension ConversationVC: DispatchQueue.main.async { AppModeManager.shared.setCurrentAppMode(to: appMode) } - Environment.shared.isRequestingPermission = false + Environment.shared?.isRequestingPermission = false if [ PHAuthorizationStatus.authorized, PHAuthorizationStatus.limited ].contains(status) { onAuthorized() } diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index ce2d93e00..993c9e8b0 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -53,8 +53,6 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers var scrollDistanceToBottomBeforeUpdate: CGFloat? var baselineKeyboardHeight: CGFloat = 0 - var audioSession: OWSAudioSession { Environment.shared.audioSession } - /// This flag is used to temporarily prevent the ConversationVC from becoming the first responder (primarily used with /// custom transitions from preventing them from being buggy var delayFirstResponder: Bool = false diff --git a/Session/Media Viewing & Editing/PhotoCapture.swift b/Session/Media Viewing & Editing/PhotoCapture.swift index 64d7ed650..aa6db0714 100644 --- a/Session/Media Viewing & Editing/PhotoCapture.swift +++ b/Session/Media Viewing & Editing/PhotoCapture.swift @@ -41,18 +41,13 @@ class PhotoCapture: NSObject { self.session = AVCaptureSession() self.captureOutput = CaptureOutput() } - - // MARK: - Dependencies - var audioSession: OWSAudioSession { - return Environment.shared.audioSession - } // MARK: - var audioDeviceInput: AVCaptureDeviceInput? func startAudioCapture() throws { assertIsOnSessionQueue() - guard audioSession.startAudioActivity(recordingAudioActivity) else { + guard Environment.shared?.audioSession.startAudioActivity(recordingAudioActivity) == true else { throw PhotoCaptureError.assertionError(description: "unable to capture audio activity") } @@ -83,7 +78,7 @@ class PhotoCapture: NSObject { } session.removeInput(audioDeviceInput) self.audioDeviceInput = nil - audioSession.endAudioActivity(recordingAudioActivity) + Environment.shared?.audioSession.endAudioActivity(recordingAudioActivity) } func startCapture() -> Promise { diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 3ab8107ac..cbe6f8f5e 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -85,8 +85,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD DeviceSleepManager.sharedInstance.removeBlock(blockObject: strongSelf) AppVersion.sharedInstance().mainAppLaunchDidComplete() - Environment.shared.audioSession.setup() - Environment.shared.reachabilityManager.setup() + Environment.shared?.audioSession.setup() + Environment.shared?.reachabilityManager.setup() GRDBStorage.shared.writeAsync { db in // Disable the SAE until the main app has successfully completed launch process diff --git a/Session/Meta/AppEnvironment.swift b/Session/Meta/AppEnvironment.swift index 33ec44a6d..1dbb82087 100644 --- a/Session/Meta/AppEnvironment.swift +++ b/Session/Meta/AppEnvironment.swift @@ -44,10 +44,10 @@ public class AppEnvironment { public func setup() { // Hang certain singletons on Environment too. - Environment.shared.callManager.mutate { + Environment.shared?.callManager.mutate { $0 = callManager } - Environment.shared.notificationsManager.mutate { + Environment.shared?.notificationsManager.mutate { $0 = notificationPresenter } setupLogFiles() diff --git a/Session/Meta/MainAppContext.m b/Session/Meta/MainAppContext.m index 704bf481d..6b70781cb 100644 --- a/Session/Meta/MainAppContext.m +++ b/Session/Meta/MainAppContext.m @@ -245,7 +245,7 @@ NSString *const ReportedApplicationStateDidChangeNotification = @"ReportedApplic - (BOOL)isRunningTests { - return (NSProcessInfo.processInfo.environment[@"XCInjectBundleInto"] != nil); + return (NSProcessInfo.processInfo.environment[@"XCTestConfigurationFilePath"] != nil); } - (void)setNetworkActivityIndicatorVisible:(BOOL)value @@ -296,11 +296,6 @@ NSString *const ReportedApplicationStateDidChangeNotification = @"ReportedApplic backgroundTask = nil; } -- (id)keychainStorage -{ - return [SSKDefaultKeychainStorage shared]; -} - - (NSString *)appDocumentDirectoryPath { NSFileManager *fileManager = [NSFileManager defaultManager]; diff --git a/Session/Utilities/Permissions.swift b/Session/Utilities/Permissions.swift index 7146580e3..66cd628b3 100644 --- a/Session/Utilities/Permissions.swift +++ b/Session/Utilities/Permissions.swift @@ -50,7 +50,7 @@ public func requestLibraryPermissionIfNeeded(onAuthorized: @escaping () -> Void) // the picker view then will dismiss, too. The selection process cannot be finished // this way. So we add a flag (isRequestingPermission) to prevent the ScreenLockUI // from showing when we request the photo library permission. - Environment.shared.isRequestingPermission = true + Environment.shared?.isRequestingPermission = true let appMode = AppModeManager.shared.currentAppMode // FIXME: Rather than setting the app mode to light and then to dark again once we're done, // it'd be better to just customize the appearance of the image picker. There doesn't currently @@ -60,7 +60,7 @@ public func requestLibraryPermissionIfNeeded(onAuthorized: @escaping () -> Void) DispatchQueue.main.async { AppModeManager.shared.setCurrentAppMode(to: appMode) } - Environment.shared.isRequestingPermission = false + Environment.shared?.isRequestingPermission = false if [ PHAuthorizationStatus.authorized, PHAuthorizationStatus.limited ].contains(status) { onAuthorized() } diff --git a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift index 9acab8e89..4bcdc7e46 100644 --- a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -198,7 +198,6 @@ enum _001_InitialSetupMigration: Migration { t.column(.authorId, .text) .notNull() .indexed() // Quicker querying - .references(Profile.self) t.column(.variant, .integer).notNull() t.column(.body, .text) @@ -369,6 +368,6 @@ enum _001_InitialSetupMigration: Migration { t.column(.timestampMs, .integer).notNull() } - GRDBStorage.shared.update(progress: 1, for: self, in: target) // In case this is the last migration + GRDBStorage.update(progress: 1, for: self, in: target) // In case this is the last migration } } diff --git a/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift index 005506099..616038fde 100644 --- a/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -55,6 +55,6 @@ enum _002_SetupStandardJobs: Migration { )?.inserted(db) } - GRDBStorage.shared.update(progress: 1, for: self, in: target) // In case this is the last migration + GRDBStorage.update(progress: 1, for: self, in: target) // In case this is the last migration } } diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 057bf1269..750d491be 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -79,7 +79,7 @@ enum _003_YDBToGRDBMigration: Migration { legacyMigrations.insert(legacyMigration) } - GRDBStorage.shared.update(progress: 0.01, for: self, in: target) + GRDBStorage.update(progress: 0.01, for: self, in: target) // MARK: --Contacts @@ -95,7 +95,7 @@ enum _003_YDBToGRDBMigration: Migration { forKey: SMKLegacy.blockedPhoneNumbersKey, inCollection: SMKLegacy.blockListCollection ) as? [String] ?? []) - GRDBStorage.shared.update(progress: 0.02, for: self, in: target) + GRDBStorage.update(progress: 0.02, for: self, in: target) // MARK: --Threads @@ -180,7 +180,7 @@ enum _003_YDBToGRDBMigration: Migration { openGroupImage[thread.uniqueId] = transaction.object(forKey: openGroup.id, inCollection: SMKLegacy.openGroupImageCollection) as? Data } } - GRDBStorage.shared.update(progress: 0.04, for: self, in: target) + GRDBStorage.update(progress: 0.04, for: self, in: target) // MARK: --Interactions @@ -229,7 +229,7 @@ enum _003_YDBToGRDBMigration: Migration { rowIndex += 1 - GRDBStorage.shared.update( + GRDBStorage.update( progress: min( interactionsCompleteProgress, ((rowIndex / roughNumRows) * (interactionsCompleteProgress - startProgress)) @@ -238,7 +238,7 @@ enum _003_YDBToGRDBMigration: Migration { in: target ) } - GRDBStorage.shared.update(progress: interactionsCompleteProgress, for: self, in: target) + GRDBStorage.update(progress: interactionsCompleteProgress, for: self, in: target) // MARK: --Attachments @@ -253,7 +253,7 @@ enum _003_YDBToGRDBMigration: Migration { attachments[key] = attachment } - GRDBStorage.shared.update(progress: 0.21, for: self, in: target) + GRDBStorage.update(progress: 0.21, for: self, in: target) // MARK: --Read Receipts @@ -312,7 +312,7 @@ enum _003_YDBToGRDBMigration: Migration { guard let job = object as? SMKLegacy._AttachmentDownloadJob else { return } attachmentDownloadJobs.insert(job) } - GRDBStorage.shared.update(progress: 0.22, for: self, in: target) + GRDBStorage.update(progress: 0.22, for: self, in: target) // MARK: --Preferences @@ -371,7 +371,7 @@ enum _003_YDBToGRDBMigration: Migration { .asType(NSNumber.self)? .doubleValue) .defaulting(to: (15 * 60)) - GRDBStorage.shared.update(progress: 0.23, for: self, in: target) + GRDBStorage.update(progress: 0.23, for: self, in: target) } // We can't properly throw within the 'enumerateKeysAndObjects' block so have to throw here @@ -461,7 +461,7 @@ enum _003_YDBToGRDBMigration: Migration { } // Increment the progress for each contact - GRDBStorage.shared.update( + GRDBStorage.update( progress: contactStartProgress + (progressPerContact * CGFloat(index + 1)), for: self, in: target @@ -1066,7 +1066,7 @@ enum _003_YDBToGRDBMigration: Migration { } // Increment the progress for each contact - GRDBStorage.shared.update( + GRDBStorage.update( progress: ( threadInteractionsStartProgress + (progressPerInteraction * (interactionCounter + 1)) @@ -1337,7 +1337,7 @@ enum _003_YDBToGRDBMigration: Migration { )?.inserted(db) } } - GRDBStorage.shared.update(progress: 0.99, for: self, in: target) + GRDBStorage.update(progress: 0.99, for: self, in: target) // MARK: - Preferences @@ -1376,7 +1376,7 @@ enum _003_YDBToGRDBMigration: Migration { db[.hasSentAMessage] = (legacyPreferences[SMKLegacy.preferencesKeyHasSentAMessageKey] as? Bool == true) db[.isReadyForAppExtensions] = CurrentAppContext().appUserDefaults().bool(forKey: SMKLegacy.preferencesKeyIsReadyForAppExtensions) - GRDBStorage.shared.update(progress: 1, for: self, in: target) // In case this is the last migration + GRDBStorage.update(progress: 1, for: self, in: target) // In case this is the last migration } // MARK: - Convenience diff --git a/SessionMessagingKit/Database/Models/BlindedIdLookup.swift b/SessionMessagingKit/Database/Models/BlindedIdLookup.swift index bba1bb48e..79d033931 100644 --- a/SessionMessagingKit/Database/Models/BlindedIdLookup.swift +++ b/SessionMessagingKit/Database/Models/BlindedIdLookup.swift @@ -74,7 +74,7 @@ public extension BlindedIdLookup { blindedId: String, openGroupServer: String, openGroupPublicKey: String, - dependencies: Dependencies = Dependencies() + dependencies: SMKDependencies = SMKDependencies() ) throws -> BlindedIdLookup { var lookup: BlindedIdLookup = (try? BlindedIdLookup .fetchOne(db, id: blindedId)) diff --git a/SessionMessagingKit/Database/Models/GroupMember.swift b/SessionMessagingKit/Database/Models/GroupMember.swift index 1387d9eda..0c6afa4f4 100644 --- a/SessionMessagingKit/Database/Models/GroupMember.swift +++ b/SessionMessagingKit/Database/Models/GroupMember.swift @@ -4,7 +4,7 @@ import Foundation import GRDB import SessionUtilitiesKit -public struct GroupMember: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct GroupMember: Codable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "groupMember" } internal static let openGroupForeignKey = ForeignKey([Columns.groupId], to: [OpenGroup.Columns.threadId]) internal static let closedGroupForeignKey = ForeignKey([Columns.groupId], to: [ClosedGroup.Columns.threadId]) diff --git a/SessionMessagingKit/Database/Models/OpenGroup.swift b/SessionMessagingKit/Database/Models/OpenGroup.swift index f877929cd..baa8acba8 100644 --- a/SessionMessagingKit/Database/Models/OpenGroup.swift +++ b/SessionMessagingKit/Database/Models/OpenGroup.swift @@ -140,6 +140,7 @@ public struct OpenGroup: Codable, Identifiable, FetchableRecord, PersistableReco public extension OpenGroup { func with( + publicKey: String? = nil, isActive: Bool? = nil, name: String? = nil, roomDescription: String? = nil, @@ -152,7 +153,7 @@ public extension OpenGroup { return OpenGroup( server: self.server, roomToken: self.roomToken, - publicKey: self.publicKey, + publicKey: (publicKey ?? self.publicKey), isActive: (isActive ?? self.isActive), name: (name ?? self.name), roomDescription: (roomDescription ?? self.roomDescription), @@ -184,7 +185,7 @@ extension OpenGroup: CustomStringConvertible, CustomDebugStringConvertible { "roomToken: \"\(roomToken)\"", "id: \"\(id)\"", "publicKey: \"\(publicKey)\"", - "isActive: \"\(isActive)\"", + "isActive: \(isActive)", "name: \"\(name)\"", "roomDescription: \(roomDescription.map { "\"\($0)\"" } ?? "null")", "imageId: \(imageId ?? "null")", diff --git a/SessionMessagingKit/Messages/Message.swift b/SessionMessagingKit/Messages/Message.swift index 26ff79c98..0f09de1e0 100644 --- a/SessionMessagingKit/Messages/Message.swift +++ b/SessionMessagingKit/Messages/Message.swift @@ -291,7 +291,7 @@ public extension Message { openGroupServerPublicKey: String, message: OpenGroupAPI.Message, data: Data, - dependencies: Dependencies = Dependencies() + dependencies: SMKDependencies = SMKDependencies() ) throws -> ProcessedMessage? { // Need a sender in order to process the message guard let sender: String = message.sender else { return nil } @@ -325,7 +325,7 @@ public extension Message { data: Data, isOutgoing: Bool? = nil, otherBlindedPublicKey: String? = nil, - dependencies: Dependencies = Dependencies() + dependencies: SMKDependencies = SMKDependencies() ) throws -> ProcessedMessage? { // Note: The `posted` value is in seconds but all messages in the database use milliseconds for timestamps let envelopeBuilder = SNProtoEnvelope.builder(type: .sessionMessage, timestamp: UInt64(floor(message.posted * 1000))) @@ -362,7 +362,7 @@ public extension Message { isOutgoing: Bool? = nil, otherBlindedPublicKey: String? = nil, handleClosedGroupKeyUpdateMessages: Bool, - dependencies: Dependencies = Dependencies() + dependencies: SMKDependencies = SMKDependencies() ) throws -> ProcessedMessage? { let (message, proto, threadId) = try MessageReceiver.parse( db, diff --git a/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift b/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift index 8a69a520c..f668454bb 100644 --- a/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift +++ b/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift @@ -48,7 +48,7 @@ extension OpenGroupAPI.Message { guard let sender: String = maybeSender, let data = Data(base64Encoded: base64EncodedData), let signature = Data(base64Encoded: base64EncodedSignature) else { throw HTTP.Error.parsingFailed } - guard let dependencies: Dependencies = decoder.userInfo[Dependencies.userInfoKey] as? Dependencies else { + guard let dependencies: SMKDependencies = decoder.userInfo[Dependencies.userInfoKey] as? SMKDependencies else { throw HTTP.Error.parsingFailed } diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index 2e9a8588f..157328c08 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -32,7 +32,7 @@ public enum OpenGroupAPI { server: String, hasPerformedInitialPoll: Bool, timeSinceLastPoll: TimeInterval, - using dependencies: Dependencies = Dependencies() + using dependencies: SMKDependencies = SMKDependencies() ) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable?)]> { let lastInboxMessageId: Int64 = (try? OpenGroup .select(.inboxLatestMessageId) @@ -146,7 +146,7 @@ public enum OpenGroupAPI { _ db: Database, server: String, requests: [BatchRequestInfoType], - using dependencies: Dependencies = Dependencies() + using dependencies: SMKDependencies = SMKDependencies() ) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable?)]> { let requestBody: BatchRequest = requests.map { $0.toSubRequest() } let responseTypes = requests.map { $0.responseType } @@ -185,7 +185,7 @@ public enum OpenGroupAPI { server: String, requests: [BatchRequestInfoType], authenticated: Bool = true, - using dependencies: Dependencies = Dependencies() + using dependencies: SMKDependencies = SMKDependencies() ) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable?)]> { let requestBody: BatchRequest = requests.map { $0.toSubRequest() } let responseTypes = requests.map { $0.responseType } @@ -223,7 +223,7 @@ public enum OpenGroupAPI { public static func capabilities( _ db: Database, server: String, - using dependencies: Dependencies = Dependencies() + using dependencies: SMKDependencies = SMKDependencies() ) -> Promise<(OnionRequestResponseInfoType, Capabilities)> { return OpenGroupAPI .send( @@ -245,7 +245,7 @@ public enum OpenGroupAPI { public static func rooms( _ db: Database, server: String, - using dependencies: Dependencies = Dependencies() + using dependencies: SMKDependencies = SMKDependencies() ) -> Promise<(OnionRequestResponseInfoType, [Room])> { return OpenGroupAPI .send( @@ -269,7 +269,7 @@ public enum OpenGroupAPI { _ db: Database, for roomToken: String, on server: String, - using dependencies: Dependencies = Dependencies() + using dependencies: SMKDependencies = SMKDependencies() ) -> Promise<(OnionRequestResponseInfoType, Room)> { return OpenGroupAPI .send( @@ -297,7 +297,7 @@ public enum OpenGroupAPI { lastUpdated: Int64, for roomToken: String, on server: String, - using dependencies: Dependencies = Dependencies() + using dependencies: SMKDependencies = SMKDependencies() ) -> Promise<(OnionRequestResponseInfoType, RoomPollInfo)> { return OpenGroupAPI .send( @@ -318,7 +318,7 @@ public enum OpenGroupAPI { for roomToken: String, on server: String, authenticated: Bool = true, - using dependencies: Dependencies = Dependencies() + using dependencies: SMKDependencies = SMKDependencies() ) -> Promise<(capabilities: (info: OnionRequestResponseInfoType, data: Capabilities), room: (info: OnionRequestResponseInfoType, data: Room))> { let requestResponseType: [BatchRequestInfoType] = [ // Get the latest capabilities for the server (in case it's a new server or the cached ones are stale) @@ -389,7 +389,7 @@ public enum OpenGroupAPI { whisperTo: String?, whisperMods: Bool, fileIds: [String]?, - using dependencies: Dependencies = Dependencies() + using dependencies: SMKDependencies = SMKDependencies() ) -> Promise<(OnionRequestResponseInfoType, Message)> { guard let signResult: (publicKey: String, signature: Bytes) = sign(db, messageBytes: plaintext.bytes, for: server, fallbackSigningType: .standard, using: dependencies) else { return Promise(error: Error.signingFailed) @@ -421,7 +421,7 @@ public enum OpenGroupAPI { id: Int64, in roomToken: String, on server: String, - using dependencies: Dependencies = Dependencies() + using dependencies: SMKDependencies = SMKDependencies() ) -> Promise<(OnionRequestResponseInfoType, Message)> { return OpenGroupAPI .send( @@ -445,7 +445,7 @@ public enum OpenGroupAPI { fileIds: [Int64]?, in roomToken: String, on server: String, - using dependencies: Dependencies = Dependencies() + using dependencies: SMKDependencies = SMKDependencies() ) -> Promise<(OnionRequestResponseInfoType, Data?)> { guard let signResult: (publicKey: String, signature: Bytes) = sign(db, messageBytes: plaintext.bytes, for: server, fallbackSigningType: .standard, using: dependencies) else { return Promise(error: Error.signingFailed) @@ -473,7 +473,7 @@ public enum OpenGroupAPI { id: Int64, in roomToken: String, on server: String, - using dependencies: Dependencies = Dependencies() + using dependencies: SMKDependencies = SMKDependencies() ) -> Promise<(OnionRequestResponseInfoType, Data?)> { return OpenGroupAPI .send( @@ -495,7 +495,7 @@ public enum OpenGroupAPI { _ db: Database, in roomToken: String, on server: String, - using dependencies: Dependencies = Dependencies() + using dependencies: SMKDependencies = SMKDependencies() ) -> Promise<(OnionRequestResponseInfoType, [Message])> { return OpenGroupAPI .send( @@ -518,7 +518,7 @@ public enum OpenGroupAPI { messageId: Int64, in roomToken: String, on server: String, - using dependencies: Dependencies = Dependencies() + using dependencies: SMKDependencies = SMKDependencies() ) -> Promise<(OnionRequestResponseInfoType, [Message])> { return OpenGroupAPI .send( @@ -541,7 +541,7 @@ public enum OpenGroupAPI { seqNo: Int64, in roomToken: String, on server: String, - using dependencies: Dependencies = Dependencies() + using dependencies: SMKDependencies = SMKDependencies() ) -> Promise<(OnionRequestResponseInfoType, [Message])> { return OpenGroupAPI .send( @@ -573,7 +573,7 @@ public enum OpenGroupAPI { sessionId: String, in roomToken: String, on server: String, - using dependencies: Dependencies = Dependencies() + using dependencies: SMKDependencies = SMKDependencies() ) -> Promise<(OnionRequestResponseInfoType, Data?)> { return OpenGroupAPI .send( @@ -604,7 +604,7 @@ public enum OpenGroupAPI { id: Int64, in roomToken: String, on server: String, - using dependencies: Dependencies = Dependencies() + using dependencies: SMKDependencies = SMKDependencies() ) -> Promise { return OpenGroupAPI .send( @@ -627,7 +627,7 @@ public enum OpenGroupAPI { id: Int64, in roomToken: String, on server: String, - using dependencies: Dependencies = Dependencies() + using dependencies: SMKDependencies = SMKDependencies() ) -> Promise { return OpenGroupAPI .send( @@ -649,7 +649,7 @@ public enum OpenGroupAPI { _ db: Database, in roomToken: String, on server: String, - using dependencies: Dependencies = Dependencies() + using dependencies: SMKDependencies = SMKDependencies() ) -> Promise { return OpenGroupAPI .send( @@ -672,7 +672,7 @@ public enum OpenGroupAPI { fileName: String? = nil, to roomToken: String, on server: String, - using dependencies: Dependencies = Dependencies() + using dependencies: SMKDependencies = SMKDependencies() ) -> Promise<(OnionRequestResponseInfoType, FileUploadResponse)> { return OpenGroupAPI .send( @@ -699,7 +699,7 @@ public enum OpenGroupAPI { fileId: Int64, from roomToken: String, on server: String, - using dependencies: Dependencies = Dependencies() + using dependencies: SMKDependencies = SMKDependencies() ) -> Promise<(OnionRequestResponseInfoType, Data)> { return OpenGroupAPI .send( @@ -728,7 +728,7 @@ public enum OpenGroupAPI { public static func inbox( _ db: Database, on server: String, - using dependencies: Dependencies = Dependencies() + using dependencies: SMKDependencies = SMKDependencies() ) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> { return OpenGroupAPI .send( @@ -752,7 +752,7 @@ public enum OpenGroupAPI { _ db: Database, id: Int64, on server: String, - using dependencies: Dependencies = Dependencies() + using dependencies: SMKDependencies = SMKDependencies() ) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> { return OpenGroupAPI .send( @@ -774,7 +774,7 @@ public enum OpenGroupAPI { ciphertext: Data, toInboxFor blindedSessionId: String, on server: String, - using dependencies: Dependencies = Dependencies() + using dependencies: SMKDependencies = SMKDependencies() ) -> Promise<(OnionRequestResponseInfoType, SendDirectMessageResponse)> { return OpenGroupAPI .send( @@ -801,7 +801,7 @@ public enum OpenGroupAPI { public static func outbox( _ db: Database, on server: String, - using dependencies: Dependencies = Dependencies() + using dependencies: SMKDependencies = SMKDependencies() ) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> { return OpenGroupAPI .send( @@ -825,7 +825,7 @@ public enum OpenGroupAPI { _ db: Database, id: Int64, on server: String, - using dependencies: Dependencies = Dependencies() + using dependencies: SMKDependencies = SMKDependencies() ) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> { return OpenGroupAPI .send( @@ -878,7 +878,7 @@ public enum OpenGroupAPI { for timeout: TimeInterval? = nil, from roomTokens: [String]? = nil, on server: String, - using dependencies: Dependencies = Dependencies() + using dependencies: SMKDependencies = SMKDependencies() ) -> Promise<(OnionRequestResponseInfoType, Data?)> { return OpenGroupAPI .send( @@ -926,7 +926,7 @@ public enum OpenGroupAPI { sessionId: String, from roomTokens: [String]?, on server: String, - using dependencies: Dependencies = Dependencies() + using dependencies: SMKDependencies = SMKDependencies() ) -> Promise<(OnionRequestResponseInfoType, Data?)> { return OpenGroupAPI .send( @@ -1003,7 +1003,7 @@ public enum OpenGroupAPI { visible: Bool, for roomTokens: [String]?, on server: String, - using dependencies: Dependencies = Dependencies() + using dependencies: SMKDependencies = SMKDependencies() ) -> Promise<(OnionRequestResponseInfoType, Data?)> { guard (moderator != nil && admin == nil) || (moderator == nil && admin != nil) else { return Promise(error: HTTP.Error.generic) @@ -1035,7 +1035,7 @@ public enum OpenGroupAPI { sessionId: String, in roomToken: String, on server: String, - using dependencies: Dependencies = Dependencies() + using dependencies: SMKDependencies = SMKDependencies() ) -> Promise<[OnionRequestResponseInfoType]> { let banRequestBody: UserBanRequest = UserBanRequest( rooms: [roomToken], @@ -1080,7 +1080,7 @@ public enum OpenGroupAPI { messageBytes: Bytes, for serverName: String, fallbackSigningType signingType: SessionId.Prefix, - using dependencies: Dependencies = Dependencies() + using dependencies: SMKDependencies = SMKDependencies() ) -> (publicKey: String, signature: Bytes)? { guard let userEdKeyPair: Box.KeyPair = Identity.fetchUserEd25519KeyPair(db), @@ -1146,7 +1146,7 @@ public enum OpenGroupAPI { request: URLRequest, for serverName: String, with serverPublicKey: String, - using dependencies: Dependencies = Dependencies() + using dependencies: SMKDependencies = SMKDependencies() ) -> URLRequest? { guard let url: URL = request.url else { return nil } @@ -1204,7 +1204,7 @@ public enum OpenGroupAPI { _ db: Database, request: Request, authenticated: Bool = true, - using dependencies: Dependencies = Dependencies() + using dependencies: SMKDependencies = SMKDependencies() ) -> Promise<(OnionRequestResponseInfoType, Data?)> { let urlRequest: URLRequest diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index de130aacd..e951ebf15 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -67,7 +67,7 @@ public final class OpenGroupManager: NSObject { public func startPolling(using dependencies: OGMDependencies = OGMDependencies()) { guard !dependencies.cache.isPolling else { return } - let servers: Set = GRDBStorage.shared + let servers: Set = dependencies.storage .read { db in // The default room promise creates an OpenGroup with an empty `roomToken` value, // we don't want to start a poller for this as the user hasn't actually joined a room @@ -85,11 +85,16 @@ public final class OpenGroupManager: NSObject { cache.isPolling = true cache.pollers = servers .reduce(into: [:]) { result, server in - result[server]?.stop() // Should never occur - - result[server] = OpenGroupAPI.Poller(for: server) - result[server]?.startIfNeeded(using: dependencies) + result[server.lowercased()]?.stop() // Should never occur + result[server.lowercased()] = OpenGroupAPI.Poller(for: server.lowercased()) } + + // Note: We loop separately here because when the cache is mocked-out for tests it + // doesn't actually store the value (meaning the pollers won't be started), but if + // we do it in the 'reduce' function, the 'reduce' result will actually store the + // poller value resulting in a bunch of OpenGroup pollers running in a way that can't + // be stopped during unit tests + cache.pollers.forEach { _, poller in poller.startIfNeeded(using: dependencies) } } } @@ -104,13 +109,13 @@ public final class OpenGroupManager: NSObject { // MARK: - Adding & Removing public func hasExistingOpenGroup(_ db: Database, roomToken: String, server: String, publicKey: String, dependencies: OGMDependencies = OGMDependencies()) -> Bool { - guard let serverUrl: URL = URL(string: server) else { return false } + guard let serverUrl: URL = URL(string: server.lowercased()) else { return false } - let serverHost: String = (serverUrl.host ?? server) + let serverHost: String = (serverUrl.host ?? server.lowercased()) let serverPort: String = (serverUrl.port.map { ":\($0)" } ?? "") let defaultServerHost: String = OpenGroupAPI.defaultServer.substring(from: "http://".count) var serverOptions: Set = Set([ - server, + server.lowercased(), "\(serverHost)\(serverPort)", "http://\(serverHost)\(serverPort)", "https://\(serverHost)\(serverPort)" @@ -252,13 +257,13 @@ public final class OpenGroupManager: NSObject { // Note: The default room promise creates an OpenGroup with an empty `roomToken` value, // we don't want to start a poller for this as the user hasn't actually joined a room let numActiveRooms: Int = (try? OpenGroup - .filter(OpenGroup.Columns.server == server) + .filter(OpenGroup.Columns.server == server?.lowercased()) .filter(OpenGroup.Columns.roomToken != "") .filter(OpenGroup.Columns.isActive) .fetchCount(db)) .defaulting(to: 1) - if numActiveRooms == 1, let server: String = server { + if numActiveRooms == 1, let server: String = server?.lowercased() { let poller = dependencies.cache.pollers[server] poller?.stop() dependencies.mutableCache.mutate { $0.pollers[server] = nil } @@ -297,13 +302,13 @@ public final class OpenGroupManager: NSObject { ) { // Remove old capabilities first _ = try? Capability - .filter(Capability.Columns.openGroupServer == server) + .filter(Capability.Columns.openGroupServer == server.lowercased()) .deleteAll(db) // Then insert the new capabilities (both present and missing) capabilities.capabilities.forEach { capability in _ = try? Capability( - openGroupServer: server, + openGroupServer: server.lowercased(), variant: Capability.Variant(from: capability.rawValue), isMissing: false ) @@ -311,7 +316,7 @@ public final class OpenGroupManager: NSObject { } capabilities.missing?.forEach { capability in _ = try? Capability( - openGroupServer: server, + openGroupServer: server.lowercased(), variant: Capability.Variant(from: capability.rawValue), isMissing: true ) @@ -336,6 +341,7 @@ public final class OpenGroupManager: NSObject { let updatedOpenGroup: OpenGroup = try openGroup .with( + publicKey: maybePublicKey, name: pollInfo.details?.name, roomDescription: pollInfo.details?.roomDescription, imageId: pollInfo.details?.imageId.map { "\($0)" }, @@ -369,10 +375,10 @@ public final class OpenGroupManager: NSObject { db.afterNextTransactionCommit { db in // Start the poller if needed - if dependencies.cache.pollers[server] == nil { + if dependencies.cache.pollers[server.lowercased()] == nil { dependencies.mutableCache.mutate { - $0.pollers[server] = OpenGroupAPI.Poller(for: server) - $0.pollers[server]?.startIfNeeded(using: dependencies) + $0.pollers[server.lowercased()] = OpenGroupAPI.Poller(for: server.lowercased()) + $0.pollers[server.lowercased()]?.startIfNeeded(using: dependencies) } } @@ -507,7 +513,7 @@ public final class OpenGroupManager: NSObject { ) { // Don't need to do anything if we have no messages (it's a valid case) guard !messages.isEmpty else { return } - guard let openGroup: OpenGroup = try? OpenGroup.filter(OpenGroup.Columns.server == server).fetchOne(db) else { + guard let openGroup: OpenGroup = try? OpenGroup.filter(OpenGroup.Columns.server == server.lowercased()).fetchOne(db) else { SNLog("Couldn't receive inbox message.") return } @@ -522,12 +528,12 @@ public final class OpenGroupManager: NSObject { // Update the 'latestMessageId' value if fromOutbox { _ = try? OpenGroup - .filter(OpenGroup.Columns.server == server) + .filter(OpenGroup.Columns.server == server.lowercased()) .updateAll(db, OpenGroup.Columns.outboxLatestMessageId.set(to: latestMessageId)) } else { _ = try? OpenGroup - .filter(OpenGroup.Columns.server == server) + .filter(OpenGroup.Columns.server == server.lowercased()) .updateAll(db, OpenGroup.Columns.inboxLatestMessageId.set(to: latestMessageId)) } @@ -644,7 +650,7 @@ public final class OpenGroupManager: NSObject { // Conveniently the logic for these different cases works in order so we can fallthrough each // case with only minor efficiency losses - let userPublicKey: String = getUserHexEncodedPublicKey(db) + let userPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies) switch sessionId.prefix { case .standard: @@ -796,7 +802,7 @@ public final class OpenGroupManager: NSObject { let updateInterval: TimeInterval = (7 * 24 * 60 * 60) if - server == OpenGroupAPI.defaultServer, + server.lowercased() == OpenGroupAPI.defaultServer, timeSinceLastUpdate < updateInterval, let data = try? OpenGroup .select(.imageData) @@ -805,7 +811,7 @@ public final class OpenGroupManager: NSObject { .fetchOne(db) { return Promise.value(data) } - if let promise = dependencies.cache.groupImagePromises["\(server).\(roomToken)"] { + if let promise = dependencies.cache.groupImagePromises[threadId] { return promise } @@ -814,7 +820,7 @@ public final class OpenGroupManager: NSObject { // Trigger the download on a background queue DispatchQueue.global(qos: .background).async { dependencies.storage - .writeAsync { db in + .read { db in OpenGroupAPI .downloadFile( db, @@ -824,8 +830,8 @@ public final class OpenGroupManager: NSObject { using: dependencies ) } - .done(on: OpenGroupAPI.workQueue) { _, imageData in - if server == OpenGroupAPI.defaultServer { + .done { _, imageData in + if server.lowercased() == OpenGroupAPI.defaultServer { dependencies.storage.write { db in _ = try OpenGroup .filter(id: threadId) @@ -841,7 +847,7 @@ public final class OpenGroupManager: NSObject { } dependencies.mutableCache.mutate { cache in - cache.groupImagePromises["\(server).\(roomToken)"] = promise + cache.groupImagePromises[threadId] = promise } return promise @@ -880,7 +886,7 @@ public final class OpenGroupManager: NSObject { // MARK: - OGMDependencies extension OpenGroupManager { - public class OGMDependencies: Dependencies { + public class OGMDependencies: SMKDependencies { internal var _mutableCache: Atomic? public var mutableCache: Atomic { get { Dependencies.getValueSettingIfNull(&_mutableCache) { OpenGroupManager.shared.mutableCache } } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift index fde313728..de74c53f7 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift @@ -49,7 +49,7 @@ extension MessageReceiver { if let interaction: Interaction = try MessageReceiver.insertCallInfoMessage(db, for: message, state: .missed) { let thread: SessionThread = try SessionThread.fetchOrCreate(db, id: sender, variant: .contact) - Environment.shared.notificationsManager.wrappedValue? + Environment.shared?.notificationsManager.wrappedValue? .notifyUser( db, forIncomingCall: interaction, @@ -63,7 +63,7 @@ extension MessageReceiver { if let interaction: Interaction = try MessageReceiver.insertCallInfoMessage(db, for: message, state: .permissionDenied) { let thread: SessionThread = try SessionThread.fetchOrCreate(db, id: sender, variant: .contact) - Environment.shared.notificationsManager.wrappedValue? + Environment.shared?.notificationsManager.wrappedValue? .notifyUser( db, forIncomingCall: interaction, @@ -81,7 +81,7 @@ extension MessageReceiver { } // Ensure we have a call manager before continuing - guard let callManager: CallManagerProtocol = Environment.shared.callManager.wrappedValue else { return } + guard let callManager: CallManagerProtocol = Environment.shared?.callManager.wrappedValue else { return } // Ignore pre offer message after the same call instance has been generated if let currentCall: CurrentCallProtocol = callManager.currentCall, currentCall.uuid == message.uuid { @@ -109,7 +109,7 @@ extension MessageReceiver { // Ensure we have a call manager before continuing guard - let callManager: CallManagerProtocol = Environment.shared.callManager.wrappedValue, + let callManager: CallManagerProtocol = Environment.shared?.callManager.wrappedValue, let currentCall: CurrentCallProtocol = callManager.currentCall, currentCall.uuid == message.uuid, let sdp: String = message.sdps.first @@ -125,7 +125,7 @@ extension MessageReceiver { guard let currentWebRTCSession: WebRTCSession = WebRTCSession.current, currentWebRTCSession.uuid == message.uuid, - let callManager: CallManagerProtocol = Environment.shared.callManager.wrappedValue, + let callManager: CallManagerProtocol = Environment.shared?.callManager.wrappedValue, var currentCall: CurrentCallProtocol = callManager.currentCall, currentCall.uuid == message.uuid, let sender: String = message.sender @@ -151,7 +151,7 @@ extension MessageReceiver { guard WebRTCSession.current?.uuid == message.uuid, - let callManager: CallManagerProtocol = Environment.shared.callManager.wrappedValue, + let callManager: CallManagerProtocol = Environment.shared?.callManager.wrappedValue, let currentCall: CurrentCallProtocol = callManager.currentCall, currentCall.uuid == message.uuid, let sender: String = message.sender diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift index fb17368fd..764830c23 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift @@ -140,7 +140,7 @@ extension MessageReceiver { ClosedGroupPoller.shared.startPolling(for: groupPublicKey) // Notify the PN server - let _ = PushNotificationAPI.performOperation(.subscribe, for: groupPublicKey, publicKey: getUserHexEncodedPublicKey()) + let _ = PushNotificationAPI.performOperation(.subscribe, for: groupPublicKey, publicKey: getUserHexEncodedPublicKey(db)) } /// Extracts and adds the new encryption key pair to our list of key pairs if there is one for our public key, AND the message was diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift index 8f38073d2..405ba8568 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift @@ -8,9 +8,9 @@ extension MessageReceiver { internal static func handleMessageRequestResponse( _ db: Database, message: MessageRequestResponse, - dependencies: Dependencies + dependencies: SMKDependencies ) throws { - let userPublicKey = getUserHexEncodedPublicKey(db) + let userPublicKey = getUserHexEncodedPublicKey(db, dependencies: dependencies) var hadBlindedContact: Bool = false // Ignore messages which were sent from the current user diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index ebaf49fe8..0bd20d00a 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -46,7 +46,7 @@ extension MessageReceiver { } // Store the message variant so we can run variant-specific behaviours - let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies) let thread: SessionThread = try SessionThread .fetchOrCreate(db, id: threadInfo.id, variant: threadInfo.variant) let variant: Interaction.Variant = { @@ -273,7 +273,7 @@ extension MessageReceiver { guard variant == .standardIncoming else { return interactionId } // Use the same identifier for notifications when in backgroud polling to prevent spam - Environment.shared.notificationsManager.wrappedValue? + Environment.shared?.notificationsManager.wrappedValue? .notifyUser( db, for: interaction, diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift index f02c32532..374c13cec 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift @@ -11,7 +11,7 @@ extension MessageSender { public static var distributingKeyPairs: Atomic<[String: [ClosedGroupKeyPair]]> = Atomic([:]) public static func createClosedGroup(_ db: Database, name: String, members: Set) throws -> Promise { - let userPublicKey: String = getUserHexEncodedPublicKey() + let userPublicKey: String = getUserHexEncodedPublicKey(db) var members: Set = members // Generate the group's public key diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift index 0a79510ef..e1ac4b392 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift @@ -8,7 +8,7 @@ import Curve25519Kit import SessionUtilitiesKit extension MessageReceiver { - internal static func decryptWithSessionProtocol(ciphertext: Data, using x25519KeyPair: Box.KeyPair, dependencies: Dependencies = Dependencies()) throws -> (plaintext: Data, senderX25519PublicKey: String) { + internal static func decryptWithSessionProtocol(ciphertext: Data, using x25519KeyPair: Box.KeyPair, dependencies: SMKDependencies = SMKDependencies()) throws -> (plaintext: Data, senderX25519PublicKey: String) { let recipientX25519PrivateKey = x25519KeyPair.secretKey let recipientX25519PublicKey = x25519KeyPair.publicKey let signatureSize = dependencies.sign.Bytes @@ -46,7 +46,7 @@ extension MessageReceiver { return (Data(plaintext), SessionId(.standard, publicKey: senderX25519PublicKey).hexString) } - internal static func decryptWithSessionBlindingProtocol(data: Data, isOutgoing: Bool, otherBlindedPublicKey: String, with openGroupPublicKey: String, userEd25519KeyPair: Box.KeyPair, using dependencies: Dependencies = Dependencies()) throws -> (plaintext: Data, senderX25519PublicKey: String) { + internal static func decryptWithSessionBlindingProtocol(data: Data, isOutgoing: Bool, otherBlindedPublicKey: String, with openGroupPublicKey: String, userEd25519KeyPair: Box.KeyPair, using dependencies: SMKDependencies = SMKDependencies()) throws -> (plaintext: Data, senderX25519PublicKey: String) { /// Ensure the data is at least long enough to have the required components guard data.count > (dependencies.nonceGenerator24.NonceBytes + 2), diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index 078740d19..dcc120311 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -18,9 +18,9 @@ public enum MessageReceiver { openGroupServerPublicKey: String?, isOutgoing: Bool? = nil, otherBlindedPublicKey: String? = nil, - dependencies: Dependencies = Dependencies() + dependencies: SMKDependencies = SMKDependencies() ) throws -> (Message, SNProtoContent, String) { - let userPublicKey: String = getUserHexEncodedPublicKey(db) + let userPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies) let isOpenGroupMessage: Bool = (openGroupId != nil) // Decrypt the contents @@ -182,7 +182,7 @@ public enum MessageReceiver { associatedWithProto proto: SNProtoContent, openGroupId: String?, isBackgroundPoll: Bool, - dependencies: Dependencies = Dependencies() + dependencies: SMKDependencies = SMKDependencies() ) throws { switch message { case let message as ReadReceipt: @@ -282,7 +282,7 @@ public enum MessageReceiver { sentTimestamp: TimeInterval, dependencies: Dependencies = Dependencies() ) throws { - let isCurrentUser = (publicKey == getUserHexEncodedPublicKey(db)) + let isCurrentUser = (publicKey == getUserHexEncodedPublicKey(db, dependencies: dependencies)) var profile: Profile = Profile.fetchOrCreate(id: publicKey) // Name diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift index db1552058..99f4e6765 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift @@ -5,7 +5,11 @@ import Sodium import SessionUtilitiesKit extension MessageSender { - internal static func encryptWithSessionProtocol(_ plaintext: Data, for recipientHexEncodedX25519PublicKey: String, using dependencies: Dependencies = Dependencies()) throws -> Data { + internal static func encryptWithSessionProtocol( + _ plaintext: Data, + for recipientHexEncodedX25519PublicKey: String, + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> Data { guard let userEd25519KeyPair: Box.KeyPair = dependencies.storage.read({ db in Identity.fetchUserEd25519KeyPair(db) }) else { throw MessageSenderError.noUserED25519KeyPair } @@ -25,7 +29,12 @@ extension MessageSender { return Data(ciphertext) } - internal static func encryptWithSessionBlindingProtocol(_ plaintext: Data, for recipientBlindedId: String, openGroupPublicKey: String, using dependencies: Dependencies = Dependencies()) throws -> Data { + internal static func encryptWithSessionBlindingProtocol( + _ plaintext: Data, + for recipientBlindedId: String, + openGroupPublicKey: String, + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> Data { guard SessionId.Prefix(from: recipientBlindedId) == .blinded else { throw MessageSenderError.signingFailed } guard let userEd25519KeyPair: Box.KeyPair = dependencies.storage.read({ db in Identity.fetchUserEd25519KeyPair(db) }) else { throw MessageSenderError.noUserED25519KeyPair diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 338eff6d5..e870118be 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -306,7 +306,7 @@ public final class MessageSender { message: Message, to destination: Message.Destination, interactionId: Int64?, - dependencies: Dependencies = Dependencies() + dependencies: SMKDependencies = SMKDependencies() ) -> Promise { let (promise, seal) = Promise.pending() let threadId: String @@ -445,10 +445,10 @@ public final class MessageSender { message: Message, to destination: Message.Destination, interactionId: Int64?, - dependencies: Dependencies = Dependencies() + dependencies: SMKDependencies = SMKDependencies() ) -> Promise { let (promise, seal) = Promise.pending() - let userPublicKey: String = getUserHexEncodedPublicKey(db) + let userPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies) guard case .openGroupInbox(let server, let openGroupPublicKey, let recipientBlindedPublicKey) = destination else { preconditionFailure() @@ -615,7 +615,7 @@ public final class MessageSender { // • it's a visible message or an expiration timer update // • the destination was a contact // • we didn't sync it already - let userPublicKey = getUserHexEncodedPublicKey() + let userPublicKey = getUserHexEncodedPublicKey(db) if case .contact(let publicKey) = destination, !isSyncMessage { if let message = message as? VisibleMessage { message.syncTarget = publicKey } if let message = message as? ExpirationTimerUpdate { message.syncTarget = publicKey } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift index 3d49d6b28..40f256b49 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift @@ -56,7 +56,7 @@ extension OpenGroupAPI { promise.retainUntilComplete() Threading.pollerQueue.async { - GRDBStorage.shared + dependencies.storage .read { db in OpenGroupAPI .poll( diff --git a/SessionMessagingKit/Utilities/Environment.swift b/SessionMessagingKit/Utilities/Environment.swift index 1309225fe..3c0bc1ff5 100644 --- a/SessionMessagingKit/Utilities/Environment.swift +++ b/SessionMessagingKit/Utilities/Environment.swift @@ -4,7 +4,7 @@ import Foundation import SessionUtilitiesKit public class Environment { - public static var shared: Environment! + public static var shared: Environment? public let reachabilityManager: SSKReachabilityManager @@ -55,8 +55,8 @@ public class Environment { public class SMKEnvironment: NSObject { @objc public static let shared: SMKEnvironment = SMKEnvironment() - @objc public var audioSession: OWSAudioSession { Environment.shared.audioSession } - @objc public var windowManager: OWSWindowManager { Environment.shared.windowManager } + @objc public var audioSession: OWSAudioSession? { Environment.shared?.audioSession } + @objc public var windowManager: OWSWindowManager? { Environment.shared?.windowManager } - @objc public var isRequestingPermission: Bool { Environment.shared.isRequestingPermission } + @objc public var isRequestingPermission: Bool { (Environment.shared?.isRequestingPermission == true) } } diff --git a/SessionMessagingKit/Utilities/OWSAudioSession.swift b/SessionMessagingKit/Utilities/OWSAudioSession.swift index 65adae8e5..cbc5b547f 100644 --- a/SessionMessagingKit/Utilities/OWSAudioSession.swift +++ b/SessionMessagingKit/Utilities/OWSAudioSession.swift @@ -18,13 +18,7 @@ public class AudioActivity: NSObject { } deinit { - audioSession.ensureAudioSessionActivationStateAfterDelay() - } - - // MARK: Dependencies - - var audioSession: OWSAudioSession { - return Environment.shared.audioSession + Environment.shared?.audioSession.ensureAudioSessionActivationStateAfterDelay() } // MARK: @@ -44,10 +38,6 @@ public class OWSAudioSession: NSObject { // MARK: Dependencies - var proximityMonitoringManager: OWSProximityMonitoringManager { - return Environment.shared.proximityMonitoringManager - } - private let avAudioSession = AVAudioSession.sharedInstance() private let device = UIDevice.current @@ -94,9 +84,9 @@ public class OWSAudioSession: NSObject { func ensureAudioCategory() throws { if aggregateBehaviors.contains(.audioMessagePlayback) { - self.proximityMonitoringManager.add(lifetime: self) + Environment.shared?.proximityMonitoringManager.add(lifetime: self) } else { - self.proximityMonitoringManager.remove(lifetime: self) + Environment.shared?.proximityMonitoringManager.remove(lifetime: self) } if aggregateBehaviors.contains(.call) { diff --git a/SessionMessagingKit/Utilities/Dependencies.swift b/SessionMessagingKit/Utilities/SMKDependencies.swift similarity index 70% rename from SessionMessagingKit/Utilities/Dependencies.swift rename to SessionMessagingKit/Utilities/SMKDependencies.swift index 62c6dcadf..eea334c04 100644 --- a/SessionMessagingKit/Utilities/Dependencies.swift +++ b/SessionMessagingKit/Utilities/SMKDependencies.swift @@ -5,25 +5,13 @@ import Sodium import SessionSnodeKit import SessionUtilitiesKit -public class Dependencies { +public class SMKDependencies: Dependencies { internal var _onionApi: OnionRequestAPIType.Type? public var onionApi: OnionRequestAPIType.Type { get { Dependencies.getValueSettingIfNull(&_onionApi) { OnionRequestAPI.self } } set { _onionApi = newValue } } - internal var _generalCache: Atomic? - public var generalCache: Atomic { - get { Dependencies.getValueSettingIfNull(&_generalCache) { General.cache } } - set { _generalCache = newValue } - } - - internal var _storage: GRDBStorage? - public var storage: GRDBStorage { - get { Dependencies.getValueSettingIfNull(&_storage) { GRDBStorage.shared } } - set { _storage = newValue } - } - internal var _sodium: SodiumType? public var sodium: SodiumType { get { Dependencies.getValueSettingIfNull(&_sodium) { Sodium() } } @@ -72,18 +60,6 @@ public class Dependencies { set { _nonceGenerator24 = newValue } } - internal var _standardUserDefaults: UserDefaultsType? - public var standardUserDefaults: UserDefaultsType { - get { Dependencies.getValueSettingIfNull(&_standardUserDefaults) { UserDefaults.standard } } - set { _standardUserDefaults = newValue } - } - - internal var _date: Date? - public var date: Date { - get { Dependencies.getValueSettingIfNull(&_date) { Date() } } - set { _date = newValue } - } - // MARK: - Initialization public init( @@ -102,8 +78,6 @@ public class Dependencies { date: Date? = nil ) { _onionApi = onionApi - _generalCache = generalCache - _storage = storage _sodium = sodium _box = box _genericHash = genericHash @@ -112,19 +86,12 @@ public class Dependencies { _ed25519 = ed25519 _nonceGenerator16 = nonceGenerator16 _nonceGenerator24 = nonceGenerator24 - _standardUserDefaults = standardUserDefaults - _date = date - } - - // MARK: - Convenience - - internal static func getValueSettingIfNull(_ maybeValue: inout T?, _ valueGenerator: () -> T) -> T { - guard let value: T = maybeValue else { - let value: T = valueGenerator() - maybeValue = value - return value - } - return value + super.init( + generalCache: generalCache, + storage: storage, + standardUserDefaults: standardUserDefaults, + date: date + ) } } diff --git a/SessionMessagingKitTests/Open Groups/Models/SOGSMessageSpec.swift b/SessionMessagingKitTests/Open Groups/Models/SOGSMessageSpec.swift index 819a3a782..859cff307 100644 --- a/SessionMessagingKitTests/Open Groups/Models/SOGSMessageSpec.swift +++ b/SessionMessagingKitTests/Open Groups/Models/SOGSMessageSpec.swift @@ -18,7 +18,7 @@ class SOGSMessageSpec: QuickSpec { var decoder: JSONDecoder! var mockSign: MockSign! var mockEd25519: MockEd25519! - var dependencies: Dependencies! + var dependencies: SMKDependencies! beforeEach { messageJson = """ @@ -37,7 +37,7 @@ class SOGSMessageSpec: QuickSpec { messageData = messageJson.data(using: .utf8)! mockSign = MockSign() mockEd25519 = MockEd25519() - dependencies = Dependencies( + dependencies = SMKDependencies( sign: mockSign, ed25519: mockEd25519 ) diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift index 3feba33a3..3a2250073 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift @@ -23,7 +23,7 @@ class OpenGroupAPISpec: QuickSpec { var mockEd25519: MockEd25519! var mockNonce16Generator: MockNonce16Generator! var mockNonce24Generator: MockNonce24Generator! - var dependencies: Dependencies! + var dependencies: SMKDependencies! var response: (OnionRequestResponseInfoType, Codable)? = nil var pollResponse: [OpenGroupAPI.Endpoint: (OnionRequestResponseInfoType, Codable?)]? @@ -33,7 +33,13 @@ class OpenGroupAPISpec: QuickSpec { // MARK: - Configuration beforeEach { - mockStorage = GRDBStorage(customWriter: DatabaseQueue()) + mockStorage = GRDBStorage( + customWriter: DatabaseQueue(), + customMigrations: [ + SNUtilitiesKit.migrations(), + SNMessagingKit.migrations() + ] + ) mockSodium = MockSodium() mockAeadXChaCha20Poly1305Ietf = MockAeadXChaCha20Poly1305Ietf() mockSign = MockSign() @@ -41,7 +47,7 @@ class OpenGroupAPISpec: QuickSpec { mockNonce16Generator = MockNonce16Generator() mockNonce24Generator = MockNonce24Generator() mockEd25519 = MockEd25519() - dependencies = Dependencies( + dependencies = SMKDependencies( onionApi: TestOnionRequestAPI.self, storage: mockStorage, sodium: mockSodium, @@ -204,9 +210,9 @@ class OpenGroupAPISpec: QuickSpec { // Validate request data let requestData: TestOnionRequestAPI.RequestData? = (pollResponse?[.capabilities]?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.urlString).to(equal("testServer/batch")) + expect(requestData?.urlString).to(equal("testserver/batch")) expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.server).to(equal("testServer")) + expect(requestData?.server).to(equal("testserver")) expect(requestData?.publicKey).to(equal("88672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) } @@ -840,8 +846,8 @@ class OpenGroupAPISpec: QuickSpec { // Validate request data let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("GET")) - expect(requestData?.server).to(equal("testServer")) - expect(requestData?.urlString).to(equal("testServer/capabilities")) + expect(requestData?.server).to(equal("testserver")) + expect(requestData?.urlString).to(equal("testserver/capabilities")) } } @@ -911,8 +917,8 @@ class OpenGroupAPISpec: QuickSpec { // Validate request data let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("GET")) - expect(requestData?.server).to(equal("testServer")) - expect(requestData?.urlString).to(equal("testServer/rooms")) + expect(requestData?.server).to(equal("testserver")) + expect(requestData?.urlString).to(equal("testserver/rooms")) } } @@ -1005,8 +1011,8 @@ class OpenGroupAPISpec: QuickSpec { // Validate request data let requestData: TestOnionRequestAPI.RequestData? = (response?.capabilities.info as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.server).to(equal("testServer")) - expect(requestData?.urlString).to(equal("testServer/sequence")) + expect(requestData?.server).to(equal("testserver")) + expect(requestData?.urlString).to(equal("testserver/sequence")) } } @@ -1641,8 +1647,8 @@ class OpenGroupAPISpec: QuickSpec { // Validate request data let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("GET")) - expect(requestData?.server).to(equal("testServer")) - expect(requestData?.urlString).to(equal("testServer/room/testRoom/message/123")) + expect(requestData?.server).to(equal("testserver")) + expect(requestData?.urlString).to(equal("testserver/room/testRoom/message/123")) } } @@ -1693,8 +1699,8 @@ class OpenGroupAPISpec: QuickSpec { // Validate signature headers let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("PUT")) - expect(requestData?.server).to(equal("testServer")) - expect(requestData?.urlString).to(equal("testServer/room/testRoom/message/123")) + expect(requestData?.server).to(equal("testserver")) + expect(requestData?.urlString).to(equal("testserver/room/testRoom/message/123")) } context("when unblinded") { @@ -1717,7 +1723,7 @@ class OpenGroupAPISpec: QuickSpec { plaintext: "test".data(using: .utf8)!, fileIds: nil, in: "testRoom", - on: "testServer", + on: "testserver", using: dependencies ) } @@ -1861,7 +1867,7 @@ class OpenGroupAPISpec: QuickSpec { plaintext: "test".data(using: .utf8)!, fileIds: nil, in: "testRoom", - on: "testServer", + on: "testserver", using: dependencies ) } @@ -1974,7 +1980,7 @@ class OpenGroupAPISpec: QuickSpec { plaintext: "test".data(using: .utf8)!, fileIds: nil, in: "testRoom", - on: "testServer", + on: "testserver", using: dependencies ) } @@ -2027,8 +2033,8 @@ class OpenGroupAPISpec: QuickSpec { // Validate request data let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("DELETE")) - expect(requestData?.server).to(equal("testServer")) - expect(requestData?.urlString).to(equal("testServer/room/testRoom/message/123")) + expect(requestData?.server).to(equal("testserver")) + expect(requestData?.urlString).to(equal("testserver/room/testRoom/message/123")) } } @@ -2054,7 +2060,7 @@ class OpenGroupAPISpec: QuickSpec { db, sessionId: "testUserId", in: "testRoom", - on: "testServer", + on: "testserver", using: dependencies ) } @@ -2072,8 +2078,8 @@ class OpenGroupAPISpec: QuickSpec { // Validate request data let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("DELETE")) - expect(requestData?.server).to(equal("testServer")) - expect(requestData?.urlString).to(equal("testServer/room/testRoom/all/testUserId")) + expect(requestData?.server).to(equal("testserver")) + expect(requestData?.urlString).to(equal("testserver/room/testRoom/all/testUserId")) } } @@ -2113,8 +2119,8 @@ class OpenGroupAPISpec: QuickSpec { // Validate request data let requestData: TestOnionRequestAPI.RequestData? = (response as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.server).to(equal("testServer")) - expect(requestData?.urlString).to(equal("testServer/room/testRoom/pin/123")) + expect(requestData?.server).to(equal("testserver")) + expect(requestData?.urlString).to(equal("testserver/room/testRoom/pin/123")) } } @@ -2152,8 +2158,8 @@ class OpenGroupAPISpec: QuickSpec { // Validate request data let requestData: TestOnionRequestAPI.RequestData? = (response as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.server).to(equal("testServer")) - expect(requestData?.urlString).to(equal("testServer/room/testRoom/unpin/123")) + expect(requestData?.server).to(equal("testserver")) + expect(requestData?.urlString).to(equal("testserver/room/testRoom/unpin/123")) } } @@ -2190,8 +2196,8 @@ class OpenGroupAPISpec: QuickSpec { // Validate request data let requestData: TestOnionRequestAPI.RequestData? = (response as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.server).to(equal("testServer")) - expect(requestData?.urlString).to(equal("testServer/room/testRoom/unpin/all")) + expect(requestData?.server).to(equal("testserver")) + expect(requestData?.urlString).to(equal("testserver/room/testRoom/unpin/all")) } } @@ -2231,8 +2237,8 @@ class OpenGroupAPISpec: QuickSpec { // Validate signature headers let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.server).to(equal("testServer")) - expect(requestData?.urlString).to(equal("testServer/room/testRoom/file")) + expect(requestData?.server).to(equal("testserver")) + expect(requestData?.urlString).to(equal("testserver/room/testRoom/file")) } it("doesn't add a fileName to the content-disposition header when not provided") { @@ -2342,8 +2348,8 @@ class OpenGroupAPISpec: QuickSpec { // Validate signature headers let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("GET")) - expect(requestData?.server).to(equal("testServer")) - expect(requestData?.urlString).to(equal("testServer/room/testRoom/file/1")) + expect(requestData?.server).to(equal("testserver")) + expect(requestData?.urlString).to(equal("testserver/room/testRoom/file/1")) } } @@ -2403,8 +2409,8 @@ class OpenGroupAPISpec: QuickSpec { // Validate signature headers let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.server).to(equal("testServer")) - expect(requestData?.urlString).to(equal("testServer/inbox/testUserId")) + expect(requestData?.server).to(equal("testserver")) + expect(requestData?.urlString).to(equal("testserver/inbox/testUserId")) } } @@ -2451,8 +2457,8 @@ class OpenGroupAPISpec: QuickSpec { // Validate request data let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.server).to(equal("testServer")) - expect(requestData?.urlString).to(equal("testServer/user/testUserId/ban")) + expect(requestData?.server).to(equal("testserver")) + expect(requestData?.urlString).to(equal("testserver/user/testUserId/ban")) } it("does a global ban if no room tokens are provided") { @@ -2560,8 +2566,8 @@ class OpenGroupAPISpec: QuickSpec { // Validate request data let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.server).to(equal("testServer")) - expect(requestData?.urlString).to(equal("testServer/user/testUserId/unban")) + expect(requestData?.server).to(equal("testserver")) + expect(requestData?.urlString).to(equal("testserver/user/testUserId/unban")) } it("does a global ban if no room tokens are provided") { @@ -2670,8 +2676,8 @@ class OpenGroupAPISpec: QuickSpec { // Validate request data let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.server).to(equal("testServer")) - expect(requestData?.urlString).to(equal("testServer/user/testUserId/moderator")) + expect(requestData?.server).to(equal("testserver")) + expect(requestData?.urlString).to(equal("testserver/user/testUserId/moderator")) } it("does a global update if no room tokens are provided") { @@ -2832,8 +2838,8 @@ class OpenGroupAPISpec: QuickSpec { // Validate request data let requestData: TestOnionRequestAPI.RequestData? = (response?.first as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.server).to(equal("testServer")) - expect(requestData?.urlString).to(equal("testServer/sequence")) + expect(requestData?.server).to(equal("testserver")) + expect(requestData?.urlString).to(equal("testserver/sequence")) } it("bans the user from the specified room rather than globally") { @@ -3001,13 +3007,13 @@ class OpenGroupAPISpec: QuickSpec { // Validate signature headers let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.urlString).to(equal("testServer/rooms")) + expect(requestData?.urlString).to(equal("testserver/rooms")) expect(requestData?.httpMethod).to(equal("GET")) - expect(requestData?.server).to(equal("testServer")) + expect(requestData?.server).to(equal("testserver")) expect(requestData?.publicKey).to(equal("88672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) expect(requestData?.headers).to(haveCount(4)) expect(requestData?.headers[Header.sogsPubKey.rawValue]) - .to(equal("0088672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) + .to(equal("00bac6e71efd7dfa4a83c98ed24f254ab2c267f9ccdb172a5280a0444ad24e89cc")) expect(requestData?.headers[Header.sogsTimestamp.rawValue]).to(equal("1234567890")) expect(requestData?.headers[Header.sogsNonce.rawValue]).to(equal("pK6YRtQApl4NhECGizF0Cg==")) expect(requestData?.headers[Header.sogsSignature.rawValue]).to(equal("TestSignature".bytes.toBase64())) @@ -3071,9 +3077,9 @@ class OpenGroupAPISpec: QuickSpec { // Validate signature headers let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.urlString).to(equal("testServer/rooms")) + expect(requestData?.urlString).to(equal("testserver/rooms")) expect(requestData?.httpMethod).to(equal("GET")) - expect(requestData?.server).to(equal("testServer")) + expect(requestData?.server).to(equal("testserver")) expect(requestData?.publicKey).to(equal("88672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) expect(requestData?.headers).to(haveCount(4)) expect(requestData?.headers[Header.sogsPubKey.rawValue]).to(equal("1588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift index 55c01e1d6..16b203ffa 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -83,11 +83,8 @@ class OpenGroupManagerSpec: QuickSpec { var mockUserDefaults: MockUserDefaults! var dependencies: OpenGroupManager.OGMDependencies! - var testInteraction: TestInteraction! - var testIncomingMessage: TestIncomingMessage! - var testGroupThread: TestGroupThread! - var testContactThread: TestContactThread! - var testTransaction: TestTransaction! + var testInteraction1: Interaction! + var testGroupThread: SessionThread! var testOpenGroup: OpenGroup! var testPollInfo: OpenGroupAPI.RoomPollInfo! var testMessage: OpenGroupAPI.Message! @@ -102,7 +99,13 @@ class OpenGroupManagerSpec: QuickSpec { beforeEach { mockOGMCache = MockOGMCache() mockGeneralCache = MockGeneralCache() - mockStorage = GRDBStorage(customWriter: DatabaseQueue()) + mockStorage = GRDBStorage( + customWriter: DatabaseQueue(), + customMigrations: [ + SNUtilitiesKit.migrations(), + SNMessagingKit.migrations() + ] + ) mockSodium = MockSodium() mockAeadXChaCha20Poly1305Ietf = MockAeadXChaCha20Poly1305Ietf() mockGenericHash = MockGenericHash() @@ -125,41 +128,42 @@ class OpenGroupManagerSpec: QuickSpec { standardUserDefaults: mockUserDefaults, date: Date(timeIntervalSince1970: 1234567890) ) - testInteraction = TestInteraction() - testInteraction.mockData[.uniqueId] = "TestInteractionId" - testInteraction.mockData[.timestamp] = UInt64(123) - - testIncomingMessage = TestIncomingMessage(uniqueId: "TestMessageId") - testIncomingMessage.openGroupServerMessageID = 127 - - testGroupThread = TestGroupThread() - testGroupThread.mockData[.uniqueId] = "TestGroupId" - testGroupThread.mockData[.groupModel] = TSGroupModel( - title: "TestTitle", - memberIds: [], - image: nil, - groupId: LKGroupUtilities.getEncodedOpenGroupIDAsData("testServer.testRoom"), - groupType: .openGroup, - adminIds: [], - moderatorIds: [] + testInteraction1 = Interaction( + id: 234, + serverHash: "TestServerHash", + messageUuid: nil, + threadId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"), + authorId: "TestAuthorId", + variant: .standardOutgoing, + body: "Test", + timestampMs: 123, + receivedAtTimestampMs: 124, + wasRead: false, + hasMention: false, + expiresInSeconds: nil, + expiresStartedAtMs: nil, + linkPreviewUrl: nil, + openGroupServerMessageId: nil, + openGroupWhisperMods: false, + openGroupWhisperTo: nil ) - testGroupThread.mockData[.interactions] = [testInteraction, testIncomingMessage] - - testContactThread = TestContactThread() - testContactThread.mockData[.uniqueId] = "TestContactId" - testContactThread.mockData[.interactions] = [testInteraction, testIncomingMessage] - - testTransaction = TestTransaction() - testTransaction.mockData[.objectForKey] = testGroupThread + testGroupThread = SessionThread( + id: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"), + variant: .openGroup + ) testOpenGroup = OpenGroup( server: "testServer", roomToken: "testRoom", publicKey: TestConstants.publicKey, + isActive: true, name: "Test", - groupDescription: nil, - imageID: nil, - infoUpdates: 10 + roomDescription: nil, + imageId: nil, + imageData: nil, + userCount: 0, + infoUpdates: 10, + sequenceNumber: 5 ) testPollInfo = OpenGroupAPI.RoomPollInfo( token: "testRoom", @@ -215,11 +219,12 @@ class OpenGroupManagerSpec: QuickSpec { ) mockStorage.write { db in - try Identity(variant: .x25519PublicKey, data: Data.data(fromHex: TestConstants.publicKey)).insert(db) - try Identity(variant: .x25519PrivateKey, data: Data.data(fromHex: TestConstants.privateKey)).insert(db) - try Identity(variant: .ed25519PublicKey, data: Data.data(fromHex: TestConstants.edPublicKey)).insert(db) - try Identity(variant: .ed25519SecretKey, data: Data.data(fromHex: TestConstants.edSecretKey)).insert(db) + try Identity(variant: .x25519PublicKey, data: Data.data(fromHex: TestConstants.publicKey)!).insert(db) + try Identity(variant: .x25519PrivateKey, data: Data.data(fromHex: TestConstants.privateKey)!).insert(db) + try Identity(variant: .ed25519PublicKey, data: Data.data(fromHex: TestConstants.edPublicKey)!).insert(db) + try Identity(variant: .ed25519SecretKey, data: Data.data(fromHex: TestConstants.edSecretKey)!).insert(db) + try testGroupThread.insert(db) try testOpenGroup.insert(db) try Capability(openGroupServer: testOpenGroup.server, variant: .sogs, isMissing: false).insert(db) } @@ -269,10 +274,8 @@ class OpenGroupManagerSpec: QuickSpec { mockUserDefaults = nil dependencies = nil - testInteraction = nil + testInteraction1 = nil testGroupThread = nil - testContactThread = nil - testTransaction = nil testOpenGroup = nil openGroupManager = nil @@ -326,11 +329,14 @@ class OpenGroupManagerSpec: QuickSpec { mockStorage.write { db in try OpenGroup( server: "testServer1", - room: "testRoom1", + roomToken: "testRoom1", publicKey: TestConstants.publicKey, + isActive: true, name: "Test1", - groupDescription: nil, - imageID: nil, + roomDescription: nil, + imageId: nil, + imageData: nil, + userCount: 0, infoUpdates: 0 ).insert(db) } @@ -378,11 +384,14 @@ class OpenGroupManagerSpec: QuickSpec { mockStorage.write { db in try OpenGroup( server: "testServer1", - room: "testRoom1", + roomToken: "testRoom1", publicKey: TestConstants.publicKey, + isActive: true, name: "Test1", - groupDescription: nil, + roomDescription: nil, imageId: nil, + imageData: nil, + userCount: 0, infoUpdates: 0 ).insert(db) } @@ -416,141 +425,155 @@ class OpenGroupManagerSpec: QuickSpec { context("when checking it has an existing open group") { context("when there is a thread for the room and the cache has a poller") { - beforeEach { - testTransaction.mockData[.objectForKey] = testGroupThread - } - context("for the no-scheme variant") { beforeEach { - mockOGMCache.when { $0.pollers }.thenReturn(["testServer": OpenGroupAPI.Poller(for: "testServer")]) + mockOGMCache.when { $0.pollers }.thenReturn(["testserver": OpenGroupAPI.Poller(for: "testserver")]) } it("returns true when no scheme is provided") { expect( - openGroupManager - .hasExistingOpenGroup( - roomToken: "testRoom", - server: "testServer", - publicKey: "testKey", - using: testTransaction, - dependencies: dependencies - ) + mockStorage.read { db in + openGroupManager + .hasExistingOpenGroup( + db, + roomToken: "testRoom", + server: "testServer", + publicKey: "testKey", + dependencies: dependencies + ) + } ).to(beTrue()) } it("returns true when a http scheme is provided") { expect( - openGroupManager - .hasExistingOpenGroup( - roomToken: "testRoom", - server: "http://testServer", - publicKey: "testKey", - using: testTransaction, - dependencies: dependencies - ) + mockStorage.read { db in + openGroupManager + .hasExistingOpenGroup( + db, + roomToken: "testRoom", + server: "http://testServer", + publicKey: "testKey", + dependencies: dependencies + ) + } ).to(beTrue()) } it("returns true when a https scheme is provided") { expect( - openGroupManager - .hasExistingOpenGroup( - roomToken: "testRoom", - server: "https://testServer", - publicKey: "testKey", - using: testTransaction, - dependencies: dependencies - ) + mockStorage.read { db in + openGroupManager + .hasExistingOpenGroup( + db, + roomToken: "testRoom", + server: "https://testServer", + publicKey: "testKey", + dependencies: dependencies + ) + } ).to(beTrue()) } } context("for the http variant") { beforeEach { - mockOGMCache.when { $0.pollers }.thenReturn(["http://testServer": OpenGroupAPI.Poller(for: "http://testServer")]) + mockOGMCache.when { $0.pollers }.thenReturn(["http://testserver": OpenGroupAPI.Poller(for: "http://testserver")]) } it("returns true when no scheme is provided") { expect( - openGroupManager - .hasExistingOpenGroup( - roomToken: "testRoom", - server: "testServer", - publicKey: "testKey", - using: testTransaction, - dependencies: dependencies - ) + mockStorage.read { db in + openGroupManager + .hasExistingOpenGroup( + db, + roomToken: "testRoom", + server: "testServer", + publicKey: "testKey", + dependencies: dependencies + ) + } ).to(beTrue()) } it("returns true when a http scheme is provided") { expect( - openGroupManager - .hasExistingOpenGroup( - roomToken: "testRoom", - server: "http://testServer", - publicKey: "testKey", - using: testTransaction, - dependencies: dependencies - ) + mockStorage.read { db in + openGroupManager + .hasExistingOpenGroup( + db, + roomToken: "testRoom", + server: "http://testServer", + publicKey: "testKey", + dependencies: dependencies + ) + } ).to(beTrue()) } it("returns true when a https scheme is provided") { expect( - openGroupManager - .hasExistingOpenGroup( - roomToken: "testRoom", - server: "https://testServer", - publicKey: "testKey", - using: testTransaction, - dependencies: dependencies - ) + mockStorage.read { db in + openGroupManager + .hasExistingOpenGroup( + db, + roomToken: "testRoom", + server: "https://testServer", + publicKey: "testKey", + dependencies: dependencies + ) + } ).to(beTrue()) } } context("for the https variant") { beforeEach { - mockOGMCache.when { $0.pollers }.thenReturn(["https://testServer": OpenGroupAPI.Poller(for: "https://testServer")]) + mockOGMCache.when { $0.pollers }.thenReturn(["https://testserver": OpenGroupAPI.Poller(for: "https://testserver")]) } it("returns true when no scheme is provided") { expect( - openGroupManager - .hasExistingOpenGroup( - roomToken: "testRoom", - server: "testServer", - publicKey: "testKey", - using: testTransaction, - dependencies: dependencies - ) + mockStorage.read { db in + openGroupManager + .hasExistingOpenGroup( + db, + roomToken: "testRoom", + server: "testServer", + publicKey: "testKey", + dependencies: dependencies + ) + } ).to(beTrue()) } it("returns true when a http scheme is provided") { expect( - openGroupManager - .hasExistingOpenGroup( - roomToken: "testRoom", - server: "http://testServer", - publicKey: "testKey", - using: testTransaction, - dependencies: dependencies - ) + mockStorage.read { db in + openGroupManager + .hasExistingOpenGroup( + db, + roomToken: "testRoom", + server: "http://testServer", + publicKey: "testKey", + dependencies: dependencies + ) + } ).to(beTrue()) } it("returns true when a https scheme is provided") { expect( - openGroupManager - .hasExistingOpenGroup( - roomToken: "testRoom", - server: "https://testServer", - publicKey: "testKey", - using: testTransaction, - dependencies: dependencies - ) + mockStorage.read { db in + openGroupManager + .hasExistingOpenGroup( + db, + roomToken: "testRoom", + server: "https://testServer", + publicKey: "testKey", + dependencies: dependencies + ) + } ).to(beTrue()) } } @@ -559,17 +582,31 @@ class OpenGroupManagerSpec: QuickSpec { context("when given the legacy DNS host and there is a cached poller for the default server") { it("returns true") { mockOGMCache.when { $0.pollers }.thenReturn(["http://116.203.70.33": OpenGroupAPI.Poller(for: "http://116.203.70.33")]) - testTransaction.mockData[.objectForKey] = testGroupThread + mockStorage.write { db in + try SessionThread( + id: OpenGroup.idFor(roomToken: "testRoom", server: "http://116.203.70.33"), + variant: .openGroup, + creationDateTimestamp: 0, + shouldBeVisible: true, + isPinned: false, + messageDraft: nil, + notificationSound: nil, + mutedUntilTimestamp: nil, + onlyNotifyForMentions: false + ).insert(db) + } expect( - openGroupManager - .hasExistingOpenGroup( - roomToken: "testRoom", - server: "http://open.getsession.org", - publicKey: "testKey", - using: testTransaction, - dependencies: dependencies - ) + mockStorage.read { db in + openGroupManager + .hasExistingOpenGroup( + db, + roomToken: "testRoom", + server: "http://open.getsession.org", + publicKey: "testKey", + dependencies: dependencies + ) + } ).to(beTrue()) } } @@ -577,66 +614,86 @@ class OpenGroupManagerSpec: QuickSpec { context("when given the default server and there is a cached poller for the legacy DNS host") { it("returns true") { mockOGMCache.when { $0.pollers }.thenReturn(["http://open.getsession.org": OpenGroupAPI.Poller(for: "http://open.getsession.org")]) - testTransaction.mockData[.objectForKey] = testGroupThread + mockStorage.write { db in + try SessionThread( + id: OpenGroup.idFor(roomToken: "testRoom", server: "http://open.getsession.org"), + variant: .openGroup, + creationDateTimestamp: 0, + shouldBeVisible: true, + isPinned: false, + messageDraft: nil, + notificationSound: nil, + mutedUntilTimestamp: nil, + onlyNotifyForMentions: false + ).insert(db) + } expect( - openGroupManager - .hasExistingOpenGroup( - roomToken: "testRoom", - server: "http://116.203.70.33", - publicKey: "testKey", - using: testTransaction, - dependencies: dependencies - ) + mockStorage.read { db in + openGroupManager + .hasExistingOpenGroup( + db, + roomToken: "testRoom", + server: "http://116.203.70.33", + publicKey: "testKey", + dependencies: dependencies + ) + } ).to(beTrue()) } } it("returns false when given an invalid server") { - mockOGMCache.when { $0.pollers }.thenReturn(["testServer": OpenGroupAPI.Poller(for: "testServer")]) - testTransaction.mockData[.objectForKey] = testGroupThread + mockOGMCache.when { $0.pollers }.thenReturn(["testserver": OpenGroupAPI.Poller(for: "testserver")]) expect( - openGroupManager - .hasExistingOpenGroup( - roomToken: "testRoom", - server: "%%%", - publicKey: "testKey", - using: testTransaction, - dependencies: dependencies - ) + mockStorage.read { db in + openGroupManager + .hasExistingOpenGroup( + db, + roomToken: "testRoom", + server: "%%%", + publicKey: "testKey", + dependencies: dependencies + ) + } ).to(beFalse()) } it("returns false if there is not a poller for the server in the cache") { mockOGMCache.when { $0.pollers }.thenReturn([:]) - testTransaction.mockData[.objectForKey] = testGroupThread expect( - openGroupManager - .hasExistingOpenGroup( - roomToken: "testRoom", - server: "testServer", - publicKey: "testKey", - using: testTransaction, - dependencies: dependencies - ) + mockStorage.read { db in + openGroupManager + .hasExistingOpenGroup( + db, + roomToken: "testRoom", + server: "testServer", + publicKey: "testKey", + dependencies: dependencies + ) + } ).to(beFalse()) } it("returns false if there is a poller for the server in the cache but no thread for the room") { - mockOGMCache.when { $0.pollers }.thenReturn(["testServer": OpenGroupAPI.Poller(for: "testServer")]) - testTransaction.mockData[.objectForKey] = nil + mockOGMCache.when { $0.pollers }.thenReturn(["testserver": OpenGroupAPI.Poller(for: "testserver")]) + mockStorage.write { db in + try SessionThread.deleteAll(db) + } expect( - openGroupManager - .hasExistingOpenGroup( - roomToken: "testRoom", - server: "testServer", - publicKey: "testKey", - using: testTransaction, - dependencies: dependencies - ) + mockStorage.read { db in + openGroupManager + .hasExistingOpenGroup( + db, + roomToken: "testRoom", + server: "testServer", + publicKey: "testKey", + dependencies: dependencies + ) + } ).to(beFalse()) } } @@ -645,92 +702,63 @@ class OpenGroupManagerSpec: QuickSpec { context("when adding") { beforeEach { - mockStorage.when { $0.removeOpenGroupSequenceNumber(for: any(), on: any(), using: anyAny()) }.thenReturn(()) - mockStorage.when { $0.setOpenGroupPublicKey(for: any(), to: any(), using: anyAny()) }.thenReturn(()) - mockStorage.when { $0.setOpenGroupServer(any(), using: anyAny()) }.thenReturn(()) - mockStorage.when { $0.setOpenGroup(any(), for: any(), using: anyAny()) }.thenReturn(()) - mockStorage.when { $0.setUserCount(to: any(), forOpenGroupWithID: any(), using: anyAny()) }.thenReturn(()) - mockStorage.when { $0.getOpenGroupInboxLatestMessageId(for: any()) }.thenReturn(nil) - mockStorage.when { $0.getOpenGroupOutboxLatestMessageId(for: any()) }.thenReturn(nil) - mockStorage.when { $0.getOpenGroupSequenceNumber(for: any(), on: any()) }.thenReturn(nil) + mockStorage.write { db in + try OpenGroup.deleteAll(db) + } mockOGMCache.when { $0.pollers }.thenReturn([:]) - mockOGMCache.when { $0.moderators }.thenReturn([:]) - mockOGMCache.when { $0.admins }.thenReturn([:]) mockUserDefaults .when { $0.object(forKey: SNUserDefaults.Date.lastOpen.rawValue) } .thenReturn(Date(timeIntervalSince1970: 1234567890)) } - it("resets the sequence number of the open group") { + it("stores the open group server") { var didComplete: Bool = false // Prevent multi-threading test bugs - openGroupManager - .add( - roomToken: "testRoom", - server: "testServer", - publicKey: "testKey", - isConfigMessage: false, - using: testTransaction, - dependencies: dependencies - ) + mockStorage + .writeAsync { db in + openGroupManager + .add( + db, + roomToken: "testRoom", + server: "testServer", + publicKey: "testKey", + isConfigMessage: false, + dependencies: dependencies + ) + } .map { _ -> Void in didComplete = true } .retainUntilComplete() expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) - expect(mockStorage) - .to( - call(.exactly(times: 1)) { - $0.removeOpenGroupSequenceNumber( - for: "testRoom", - on: "testServer", - using: testTransaction! as Any - ) + expect( + mockStorage + .read { db in + try OpenGroup + .select(.threadId) + .asRequest(of: String.self) + .fetchOne(db) } - ) - } - - it("sets the public key of the open group server") { - var didComplete: Bool = false // Prevent multi-threading test bugs - - openGroupManager - .add( - roomToken: "testRoom", - server: "testServer", - publicKey: "testKey", - isConfigMessage: false, - using: testTransaction, - dependencies: dependencies - ) - .map { _ -> Void in didComplete = true } - .retainUntilComplete() - - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) - expect(mockStorage) - .to( - call(.exactly(times: 1)) { - $0.setOpenGroupPublicKey( - for: "testRoom", - to: "testKey", - using: testTransaction! as Any - ) - } - ) + ) + .to(equal(OpenGroup.idFor(roomToken: "testRoom", server: "testServer"))) } it("adds a poller") { var didComplete: Bool = false // Prevent multi-threading test bugs - openGroupManager - .add( - roomToken: "testRoom", - server: "testServer", - publicKey: "testKey", - isConfigMessage: false, - using: testTransaction, - dependencies: dependencies - ) + mockStorage + .writeAsync { db in + openGroupManager + .add( + db, + roomToken: "testRoom", + server: "testServer", + publicKey: "testKey", + isConfigMessage: false, + dependencies: dependencies + ) + } .map { _ -> Void in didComplete = true } .retainUntilComplete() @@ -738,7 +766,7 @@ class OpenGroupManagerSpec: QuickSpec { expect(mockOGMCache) .toEventually( call(matchingParameters: true) { - $0.pollers = ["testServer": OpenGroupAPI.Poller(for: "testServer")] + $0.pollers = ["testserver": OpenGroupAPI.Poller(for: "testserver")] }, timeout: .milliseconds(50) ) @@ -746,47 +774,50 @@ class OpenGroupManagerSpec: QuickSpec { context("an existing room") { beforeEach { - mockOGMCache.when { $0.pollers }.thenReturn(["testServer": OpenGroupAPI.Poller(for: "testServer")]) + mockOGMCache.when { $0.pollers } + .thenReturn(["testserver": OpenGroupAPI.Poller(for: "testserver")]) + mockStorage.write { db in + try testOpenGroup.insert(db) + } } it("does not reset the sequence number or update the public key") { var didComplete: Bool = false // Prevent multi-threading test bugs - openGroupManager - .add( - roomToken: "testRoom", - server: "testServer", - publicKey: "testKey", - isConfigMessage: false, - using: testTransaction, - dependencies: dependencies - ) + mockStorage + .writeAsync { db in + openGroupManager + .add( + db, + roomToken: "testRoom", + server: "testServer", + publicKey: "testKey", + isConfigMessage: false, + dependencies: dependencies + ) + } .map { _ -> Void in didComplete = true } .retainUntilComplete() expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) - expect(mockStorage) - .toEventuallyNot( - call { - $0.removeOpenGroupSequenceNumber( - for: "testRoom", - on: "testServer", - using: testTransaction! as Any - ) - }, - timeout: .milliseconds(50) - ) - expect(mockStorage) - .toEventuallyNot( - call { - $0.setOpenGroupPublicKey( - for: "testRoom", - to: "testKey", - using: testTransaction! as Any - ) - }, - timeout: .milliseconds(50) - ) + expect( + mockStorage + .read { db in + try OpenGroup + .select(.sequenceNumber) + .asRequest(of: Int64.self) + .fetchOne(db) + } + ).to(equal(5)) + expect( + mockStorage + .read { db in + try OpenGroup + .select(.publicKey) + .asRequest(of: String.self) + .fetchOne(db) + } + ).to(equal(TestConstants.publicKey)) } } @@ -805,17 +836,20 @@ class OpenGroupManagerSpec: QuickSpec { it("fails with the error") { var error: Error? - let promise = openGroupManager - .add( - roomToken: "testRoom", - server: "testServer", - publicKey: "testKey", - isConfigMessage: false, - using: testTransaction, - dependencies: dependencies - ) - promise.catch { error = $0 } - promise.retainUntilComplete() + mockStorage + .writeAsync { db in + openGroupManager + .add( + db, + roomToken: "testRoom", + server: "testServer", + publicKey: "testKey", + isConfigMessage: false, + dependencies: dependencies + ) + } + .catch { error = $0 } + .retainUntilComplete() expect(error?.localizedDescription) .toEventually( @@ -830,204 +864,191 @@ class OpenGroupManagerSpec: QuickSpec { context("when deleting") { beforeEach { - testGroupThread.mockData[.interactions] = [testInteraction] - - mockStorage.when { $0.removeReceivedMessageTimestamps(anySet(), using: anyAny()) }.thenReturn(()) - mockStorage.when { $0.removeOpenGroupSequenceNumber(for: any(), on: any(), using: anyAny()) }.thenReturn(()) - mockStorage.when { $0.removeOpenGroup(for: any(), using: anyAny()) }.thenReturn(()) - mockStorage.when { $0.removeOpenGroupServer(name: any(), using: anyAny()) }.thenReturn(()) - mockStorage.when { $0.removeOpenGroupPublicKey(for: any(), using: anyAny()) }.thenReturn(()) + mockStorage.write { db in + try Interaction.deleteAll(db) + try SessionThread.deleteAll(db) + + try testGroupThread.insert(db) + try testOpenGroup.insert(db) + try testInteraction1.insert(db) + try Interaction + .updateAll( + db, + Interaction.Columns.threadId + .set(to: OpenGroup.idFor(roomToken: "testRoom", server: "testServer")) + ) + } mockOGMCache.when { $0.pollers }.thenReturn([:]) } - it("removes received timestamps for the given thread") { - openGroupManager - .delete( - testOpenGroup, - associatedWith: testGroupThread, - using: testTransaction, - dependencies: dependencies - ) - - expect(mockStorage) - .to(call(matchingParameters: true) { - $0.removeReceivedMessageTimestamps( - Set(arrayLiteral: testInteraction.timestamp), - using: testTransaction! as Any + it("removes all interactions for the thread") { + mockStorage.write { db in + openGroupManager + .delete( + db, + openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"), + dependencies: dependencies ) - }) - } - - it("removes the sequence number for the given thread") { - openGroupManager - .delete( - testOpenGroup, - associatedWith: testGroupThread, - using: testTransaction, - dependencies: dependencies - ) + } - expect(mockStorage) - .to(call(matchingParameters: true) { - $0.removeOpenGroupSequenceNumber( - for: "testRoom", - on: "testserver", - using: testTransaction! as Any - ) - }) - } - - it("removes all interactions for the given thread") { - openGroupManager - .delete( - testOpenGroup, - associatedWith: testGroupThread, - using: YapDatabaseReadWriteTransaction(), - dependencies: dependencies - ) - - expect(testGroupThread.didCallRemoveAllThreadInteractions).to(beTrue()) + expect(mockStorage.read { db in try Interaction.fetchCount(db) }) + .to(equal(0)) } it("removes the given thread") { - openGroupManager - .delete( - testOpenGroup, - associatedWith: testGroupThread, - using: YapDatabaseReadWriteTransaction(), - dependencies: dependencies - ) - - expect(testGroupThread.didCallRemove).to(beTrue()) - } - - it("removes the open group") { - openGroupManager - .delete( - testOpenGroup, - associatedWith: testGroupThread, - using: testTransaction, - dependencies: dependencies - ) - - expect(mockStorage) - .to(call(matchingParameters: true) { - $0.removeOpenGroup( - for: testGroupThread.uniqueId!, - using: testTransaction! as Any + mockStorage.write { db in + openGroupManager + .delete( + db, + openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"), + dependencies: dependencies ) - }) + } + + expect(mockStorage.read { db in try SessionThread.fetchCount(db) }) + .to(equal(0)) } context("and there is only one open group for this server") { it("stops the poller") { mockOGMCache.when { $0.pollers }.thenReturn(["testserver": OpenGroupAPI.Poller(for: "testserver")]) - openGroupManager - .delete( - testOpenGroup, - associatedWith: testGroupThread, - using: testTransaction, - dependencies: dependencies - ) + mockStorage.write { db in + openGroupManager + .delete( + db, + openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"), + dependencies: dependencies + ) + } expect(mockOGMCache).to(call(matchingParameters: true) { $0.pollers = [:] }) } - it("removes the open group server") { - openGroupManager - .delete( - testOpenGroup, - associatedWith: testGroupThread, - using: testTransaction, - dependencies: dependencies - ) - - expect(mockStorage) - .to(call(matchingParameters: true) { - $0.removeOpenGroupServer( - name: "testserver", - using: testTransaction! as Any + it("removes the open group") { + mockStorage.write { db in + openGroupManager + .delete( + db, + openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"), + dependencies: dependencies ) - }) - } - - it("removes the open group public key") { - openGroupManager - .delete( - testOpenGroup, - associatedWith: testGroupThread, - using: testTransaction, - dependencies: dependencies - ) + } - expect(mockStorage) - .to(call(matchingParameters: true) { - $0.removeOpenGroupPublicKey( - for: "testserver", - using: testTransaction! as Any - ) - }) + expect(mockStorage.read { db in try OpenGroup.fetchCount(db) }) + .to(equal(0)) } } context("and the are multiple open groups for this server") { beforeEach { - mockStorage - .when { $0.getAllOpenGroups() } - .thenReturn([ - "0": testOpenGroup, - "1": OpenGroup( - server: "testServer", - room: "testRoom1", - publicKey: TestConstants.publicKey, - name: "Test1", - groupDescription: nil, - imageID: nil, - infoUpdates: 0 + mockStorage.write { db in + try OpenGroup.deleteAll(db) + try testOpenGroup.insert(db) + try OpenGroup( + server: "testServer", + roomToken: "testRoom1", + publicKey: TestConstants.publicKey, + isActive: true, + name: "Test1", + roomDescription: nil, + imageId: nil, + imageData: nil, + userCount: 0, + infoUpdates: 0, + sequenceNumber: 0, + inboxLatestMessageId: 0, + outboxLatestMessageId: 0 + ).insert(db) + } + } + + it("removes the open group") { + mockStorage.write { db in + openGroupManager + .delete( + db, + openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"), + dependencies: dependencies ) - ]) + } + + expect(mockStorage.read { db in try OpenGroup.fetchCount(db) }) + .to(equal(1)) + } + } + + context("and it is the default server") { + beforeEach { + mockStorage.write { db in + try OpenGroup.deleteAll(db) + try OpenGroup( + server: OpenGroupAPI.defaultServer, + roomToken: "testRoom", + publicKey: TestConstants.publicKey, + isActive: true, + name: "Test1", + roomDescription: nil, + imageId: nil, + imageData: nil, + userCount: 0, + infoUpdates: 0, + sequenceNumber: 0, + inboxLatestMessageId: 0, + outboxLatestMessageId: 0 + ).insert(db) + try OpenGroup( + server: OpenGroupAPI.defaultServer, + roomToken: "testRoom1", + publicKey: TestConstants.publicKey, + isActive: true, + name: "Test1", + roomDescription: nil, + imageId: nil, + imageData: nil, + userCount: 0, + infoUpdates: 0, + sequenceNumber: 0, + inboxLatestMessageId: 0, + outboxLatestMessageId: 0 + ).insert(db) + } } - it("does not stop the poller") { - mockOGMCache.when { $0.pollers } - .thenReturn(["testserver": OpenGroupAPI.Poller(for: "testserver")]) + it("does not remove the open group") { + mockStorage.write { db in + openGroupManager + .delete( + db, + openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: OpenGroupAPI.defaultServer), + dependencies: dependencies + ) + } - openGroupManager - .delete( - testOpenGroup, - associatedWith: testGroupThread, - using: testTransaction, - dependencies: dependencies - ) - - expect(mockOGMCache).toNot(call { $0.pollers }) + expect(mockStorage.read { db in try OpenGroup.fetchCount(db) }) + .to(equal(2)) } - it("does not remove the open group server") { - openGroupManager - .delete( - testOpenGroup, - associatedWith: testGroupThread, - using: testTransaction, - dependencies: dependencies - ) + it("deactivates the open group") { + mockStorage.write { db in + openGroupManager + .delete( + db, + openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: OpenGroupAPI.defaultServer), + dependencies: dependencies + ) + } - expect(mockStorage) - .toNot(call { $0.removeOpenGroupServer(name: any(), using: anyAny()) }) - } - - it("does not remove the open group public key") { - openGroupManager - .delete( - testOpenGroup, - associatedWith: testGroupThread, - using: testTransaction, - dependencies: dependencies - ) - - expect(mockStorage).toNot(call { $0.removeOpenGroupPublicKey(for: any(), using: anyAny()) }) + expect( + mockStorage.read { db in + try OpenGroup + .select(.isActive) + .filter(id: OpenGroup.idFor(roomToken: "testRoom", server: OpenGroupAPI.defaultServer)) + .asRequest(of: Bool.self) + .fetchOne(db) + } + ).to(beFalse()) } } } @@ -1038,19 +1059,19 @@ class OpenGroupManagerSpec: QuickSpec { context("when handling capabilities") { beforeEach { - mockStorage.when { $0.setOpenGroupServer(any(), using: anyAny()) }.thenReturn(()) - - OpenGroupManager - .handleCapabilities( - OpenGroupAPI.Capabilities(capabilities: [], missing: []), - on: "testserver", - using: testTransaction, - dependencies: dependencies - ) + mockStorage.write { db in + OpenGroupManager + .handleCapabilities( + db, + capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: []), + on: "testserver" + ) + } } it("stores the capabilities") { - expect(mockStorage).to(call { $0.setOpenGroupServer(any(), using: anyAny()) }) + expect(mockStorage.read { db in try Capability.fetchCount(db) }) + .to(equal(1)) } } @@ -1058,116 +1079,57 @@ class OpenGroupManagerSpec: QuickSpec { context("when handling room poll info") { beforeEach { - mockStorage.when { $0.setOpenGroup(any(), for: any(), using: anyAny()) }.thenReturn(()) - mockStorage.when { $0.setOpenGroupServer(any(), using: anyAny()) }.thenReturn(()) - mockStorage.when { $0.setUserCount(to: any(), forOpenGroupWithID: any(), using: anyAny()) }.thenReturn(()) - mockStorage.when { $0.getOpenGroupSequenceNumber(for: any(), on: any()) }.thenReturn(nil) - mockStorage.when { $0.getOpenGroupInboxLatestMessageId(for: any()) }.thenReturn(nil) - mockStorage.when { $0.getOpenGroupOutboxLatestMessageId(for: any()) }.thenReturn(nil) + mockStorage.write { db in + try OpenGroup.deleteAll(db) + + try testOpenGroup.insert(db) + } mockOGMCache.when { $0.pollers }.thenReturn([:]) - mockOGMCache.when { $0.moderators }.thenReturn([:]) - mockOGMCache.when { $0.admins }.thenReturn([:]) mockUserDefaults .when { $0.object(forKey: SNUserDefaults.Date.lastOpen.rawValue) } .thenReturn(nil) } - it("attempts to retrieve the existing thread") { + it("saves the updated open group") { var didComplete: Bool = false // Prevent multi-threading test bugs - OpenGroupManager.handlePollInfo( - testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "testServer", - using: testTransaction, - dependencies: dependencies - ) { didComplete = true } + mockStorage.write { db in + try OpenGroupManager.handlePollInfo( + db, + pollInfo: testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + dependencies: dependencies + ) { didComplete = true } + } expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) - expect(testGroupThread.numSaveCalls).to(equal(1)) - } - - it("attempts to retrieve the existing open group") { - var didComplete: Bool = false // Prevent multi-threading test bugs - - OpenGroupManager.handlePollInfo( - testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "testServer", - using: testTransaction, - dependencies: dependencies - ) { didComplete = true } - - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) - expect(mockStorage).to(call { $0.getOpenGroup(for: any()) }) - } - - it("saves the thread") { - var didComplete: Bool = false // Prevent multi-threading test bugs - - OpenGroupManager.handlePollInfo( - testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "testServer", - using: testTransaction, - dependencies: dependencies - ) { didComplete = true } - - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) - expect(testGroupThread.numSaveCalls).to(equal(1)) - } - - it("saves the open group") { - var didComplete: Bool = false // Prevent multi-threading test bugs - - OpenGroupManager.handlePollInfo( - testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "testServer", - using: testTransaction, - dependencies: dependencies - ) { didComplete = true } - - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) - expect(mockStorage).to(call { $0.setOpenGroup(any(), for: any(), using: anyAny()) }) - } - - it("saves the updated user count") { - var didComplete: Bool = false // Prevent multi-threading test bugs - - OpenGroupManager.handlePollInfo( - testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "testServer", - using: testTransaction, - dependencies: dependencies - ) { didComplete = true } - - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) - expect(mockStorage) - .to(call(matchingParameters: true) { - $0.setUserCount(to: 10, forOpenGroupWithID: "testServer.testRoom", using: testTransaction! as Any) - }) + expect( + mockStorage.read { db in + try OpenGroup + .select(.userCount) + .asRequest(of: Int64.self) + .fetchOne(db) + } + ).to(equal(10)) } it("calls the completion block") { var didCallComplete: Bool = false - OpenGroupManager.handlePollInfo( - testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "testServer", - using: testTransaction, - dependencies: dependencies - ) { didCallComplete = true } + mockStorage.write { db in + try OpenGroupManager.handlePollInfo( + db, + pollInfo: testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + dependencies: dependencies + ) { didCallComplete = true } + } expect(didCallComplete) .toEventually( @@ -1179,15 +1141,17 @@ class OpenGroupManagerSpec: QuickSpec { it("calls the room image completion block when waiting but there is no image") { var didCallComplete: Bool = false - OpenGroupManager.handlePollInfo( - testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "testServer", - waitForImageToComplete: true, - using: testTransaction, - dependencies: dependencies - ) { didCallComplete = true } + mockStorage.write { db in + try OpenGroupManager.handlePollInfo( + db, + pollInfo: testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + waitForImageToComplete: true, + dependencies: dependencies + ) { didCallComplete = true } + } expect(didCallComplete) .toEventually( @@ -1199,32 +1163,35 @@ class OpenGroupManagerSpec: QuickSpec { it("calls the room image completion block when waiting and there is an image") { var didCallComplete: Bool = false - mockStorage.when { $0.getOpenGroupImage(for: any(), on: any()) }.thenReturn(nil) - mockOGMCache.when { $0.groupImagePromises } - .thenReturn(["testServer.testRoom": Promise.value(Data())]) - mockStorage - .when { $0.getOpenGroup(for: any()) } - .thenReturn( - OpenGroup( - server: "testServer", - room: "testRoom", - publicKey: TestConstants.publicKey, - name: "Test", - groupDescription: nil, - imageID: "12", - infoUpdates: 10 - ) - ) + mockStorage.write { db in + try OpenGroup.deleteAll(db) + try OpenGroup( + server: "testServer", + roomToken: "testRoom", + publicKey: TestConstants.publicKey, + isActive: true, + name: "Test", + imageId: "12", + imageData: nil, + userCount: 0, + infoUpdates: 10 + ).insert(db) + } - OpenGroupManager.handlePollInfo( - testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "testServer", - waitForImageToComplete: true, - using: testTransaction, - dependencies: dependencies - ) { didCallComplete = true } + mockOGMCache.when { $0.groupImagePromises } + .thenReturn([OpenGroup.idFor(roomToken: "testRoom", server: "testServer"): Promise.value(Data())]) + + mockStorage.write { db in + try OpenGroupManager.handlePollInfo( + db, + pollInfo: testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + waitForImageToComplete: true, + dependencies: dependencies + ) { didCallComplete = true } + } expect(didCallComplete) .toEventually( @@ -1237,7 +1204,6 @@ class OpenGroupManagerSpec: QuickSpec { it("successfully updates") { var didComplete: Bool = false // Prevent multi-threading test bugs - mockOGMCache.when { $0.moderators }.thenReturn([:]) testPollInfo = OpenGroupAPI.RoomPollInfo( token: "testRoom", activeUsers: 10, @@ -1255,29 +1221,42 @@ class OpenGroupManagerSpec: QuickSpec { details: TestCapabilitiesAndRoomApi.roomData.with(moderators: ["TestMod"], admins: []) ) - OpenGroupManager.handlePollInfo( - testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "testServer", - using: testTransaction, - dependencies: dependencies - ) { didComplete = true } + mockStorage.write { db in + try OpenGroupManager.handlePollInfo( + db, + pollInfo: testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + dependencies: dependencies + ) { didComplete = true } + } expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) - expect(mockOGMCache) - .toEventually( - call(matchingParameters: true) { - $0.moderators = ["testServer": ["testRoom": Set(arrayLiteral: "TestMod")]] - }, - timeout: .milliseconds(50) + expect( + mockStorage.read { db in + try GroupMember + .filter(GroupMember.Columns.groupId == OpenGroup.idFor( + roomToken: "testRoom", + server: "testServer" + )) + .fetchOne(db) + } + ).to(equal( + GroupMember( + groupId: OpenGroup.idFor( + roomToken: "testRoom", + server: "testServer" + ), + profileId: "TestMod", + role: .moderator ) + )) } - it("defaults to an empty array if no moderators are provided") { + it("does not insert mods if no moderators are provided") { var didComplete: Bool = false // Prevent multi-threading test bugs - mockOGMCache.when { $0.moderators }.thenReturn([:]) testPollInfo = OpenGroupAPI.RoomPollInfo( token: "testRoom", activeUsers: 10, @@ -1295,23 +1274,20 @@ class OpenGroupManagerSpec: QuickSpec { details: nil ) - OpenGroupManager.handlePollInfo( - testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "testServer", - using: testTransaction, - dependencies: dependencies - ) { didComplete = true } + mockStorage.write { db in + try OpenGroupManager.handlePollInfo( + db, + pollInfo: testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + dependencies: dependencies + ) { didComplete = true } + } expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) - expect(mockOGMCache) - .toEventually( - call(matchingParameters: true) { - $0.moderators = ["testServer": ["testRoom": Set()]] - }, - timeout: .milliseconds(50) - ) + expect(mockStorage.read { db in try GroupMember.fetchCount(db) }) + .to(equal(0)) } } @@ -1319,7 +1295,6 @@ class OpenGroupManagerSpec: QuickSpec { it("successfully updates") { var didComplete: Bool = false // Prevent multi-threading test bugs - mockOGMCache.when { $0.admins }.thenReturn([:]) testPollInfo = OpenGroupAPI.RoomPollInfo( token: "testRoom", activeUsers: 10, @@ -1337,29 +1312,42 @@ class OpenGroupManagerSpec: QuickSpec { details: TestCapabilitiesAndRoomApi.roomData.with(moderators: [], admins: ["TestAdmin"]) ) - OpenGroupManager.handlePollInfo( - testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "testServer", - using: testTransaction, - dependencies: dependencies - ) { didComplete = true } + mockStorage.write { db in + try OpenGroupManager.handlePollInfo( + db, + pollInfo: testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + dependencies: dependencies + ) { didComplete = true } + } expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) - expect(mockOGMCache) - .toEventually( - call(matchingParameters: true) { - $0.admins = ["testServer": ["testRoom": Set(arrayLiteral: "TestAdmin")]] - }, - timeout: .milliseconds(50) + expect( + mockStorage.read { db in + try GroupMember + .filter(GroupMember.Columns.groupId == OpenGroup.idFor( + roomToken: "testRoom", + server: "testServer" + )) + .fetchOne(db) + } + ).to(equal( + GroupMember( + groupId: OpenGroup.idFor( + roomToken: "testRoom", + server: "testServer" + ), + profileId: "TestAdmin", + role: .admin ) + )) } - it("defaults to an empty array if no moderators are provided") { + it("does not insert an admin if no admins are provided") { var didComplete: Bool = false // Prevent multi-threading test bugs - mockOGMCache.when { $0.admins }.thenReturn([:]) testPollInfo = OpenGroupAPI.RoomPollInfo( token: "testRoom", activeUsers: 10, @@ -1377,40 +1365,41 @@ class OpenGroupManagerSpec: QuickSpec { details: nil ) - OpenGroupManager.handlePollInfo( - testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "testServer", - using: testTransaction, - dependencies: dependencies - ) { didComplete = true } + mockStorage.write { db in + try OpenGroupManager.handlePollInfo( + db, + pollInfo: testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + dependencies: dependencies + ) { didComplete = true } + } expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) - expect(mockOGMCache) - .toEventually( - call(matchingParameters: true) { - $0.admins = ["testServer": ["testRoom": Set()]] - }, - timeout: .milliseconds(50) - ) + expect(mockStorage.read { db in try GroupMember.fetchCount(db) }) + .to(equal(0)) } } - context("when it cannot get the thread id") { + context("when it cannot get the open group") { it("does not save the thread") { - testGroupThread.mockData[.uniqueId] = nil + mockStorage.write { db in + try OpenGroup.deleteAll(db) + } - OpenGroupManager.handlePollInfo( - testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "testServer", - using: testTransaction, - dependencies: dependencies - ) + mockStorage.write { db in + try OpenGroupManager.handlePollInfo( + db, + pollInfo: testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + dependencies: dependencies + ) + } - expect(testGroupThread.numSaveCalls).to(equal(0)) + expect(mockStorage.read { db in try OpenGroup.fetchCount(db) }).to(equal(0)) } } @@ -1418,100 +1407,26 @@ class OpenGroupManagerSpec: QuickSpec { it("saves the open group with the existing public key") { var didComplete: Bool = false // Prevent multi-threading test bugs - OpenGroupManager.handlePollInfo( - testPollInfo, - publicKey: nil, - for: "testRoom", - on: "testServer", - using: testTransaction, - dependencies: dependencies - ) { didComplete = true } + mockStorage.write { db in + try OpenGroupManager.handlePollInfo( + db, + pollInfo: testPollInfo, + publicKey: nil, + for: "testRoom", + on: "testServer", + dependencies: dependencies + ) { didComplete = true } + } expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) - expect(mockStorage) - .to(call(matchingParameters: true) { - $0.setOpenGroup( - OpenGroup( - server: "testServer", - room: "testRoom", - publicKey: TestConstants.publicKey, - name: "test", - groupDescription: nil, - imageID: nil, - infoUpdates: 10 - ), - for: "TestGroupId", - using: testTransaction! as Any - ) - }) - } - } - - context("when it cannot get the public key") { - it("does not save the thread") { - mockStorage.when { $0.getOpenGroup(for: any()) }.thenReturn(nil) - - OpenGroupManager.handlePollInfo( - testPollInfo, - publicKey: nil, - for: "testRoom", - on: "testServer", - using: testTransaction, - dependencies: dependencies - ) - - expect(testGroupThread.numSaveCalls).to(equal(0)) - } - } - - context("when storing the open group") { - it("defaults the infoUpdates to zero") { - var didComplete: Bool = false // Prevent multi-threading test bugs - - mockStorage.when { $0.getOpenGroup(for: any()) }.thenReturn(nil) - testPollInfo = OpenGroupAPI.RoomPollInfo( - token: "testRoom", - activeUsers: 10, - admin: false, - globalAdmin: false, - moderator: false, - globalModerator: false, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil, - details: nil - ) - - OpenGroupManager.handlePollInfo( - testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "testServer", - using: testTransaction, - dependencies: dependencies - ) { didComplete = true } - - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) - expect(mockStorage) - .to(call(matchingParameters: true) { - $0.setOpenGroup( - OpenGroup( - server: "testServer", - room: "testRoom", - publicKey: TestConstants.publicKey, - name: "TestTitle", - groupDescription: nil, - imageID: nil, - infoUpdates: 0 - ), - for: "TestGroupId", - using: testTransaction! as Any - ) - }) + expect( + mockStorage.read { db in + try OpenGroup + .select(.publicKey) + .asRequest(of: String.self) + .fetchOne(db) + } + ).to(equal(TestConstants.publicKey)) } } @@ -1521,35 +1436,39 @@ class OpenGroupManagerSpec: QuickSpec { mockOGMCache.when { $0.pollers }.thenReturn([:]) - OpenGroupManager.handlePollInfo( - testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "testServer", - using: testTransaction, - dependencies: dependencies - ) { didComplete = true } + mockStorage.write { db in + try OpenGroupManager.handlePollInfo( + db, + pollInfo: testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + dependencies: dependencies + ) { didComplete = true } + } expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockOGMCache) .to(call(matchingParameters: true) { - $0.pollers = ["testServer": OpenGroupAPI.Poller(for: "testServer")] + $0.pollers = ["testserver": OpenGroupAPI.Poller(for: "testserver")] }) } it("does not start a new poller when already polling") { var didComplete: Bool = false // Prevent multi-threading test bugs - mockOGMCache.when { $0.pollers }.thenReturn(["testServer": OpenGroupAPI.Poller(for: "testServer")]) + mockOGMCache.when { $0.pollers }.thenReturn(["testserver": OpenGroupAPI.Poller(for: "testserver")]) - OpenGroupManager.handlePollInfo( - testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "testServer", - using: testTransaction, - dependencies: dependencies - ) { didComplete = true } + mockStorage.write { db in + try OpenGroupManager.handlePollInfo( + db, + pollInfo: testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + dependencies: dependencies + ) { didComplete = true } + } expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockOGMCache).to(call(.exactly(times: 1)) { $0.pollers }) @@ -1560,10 +1479,14 @@ class OpenGroupManagerSpec: QuickSpec { beforeEach { let image: UIImage = UIImage(color: .red, size: CGSize(width: 1, height: 1)) let imageData: Data = image.pngData()! - mockStorage.when { $0.getOpenGroupImage(for: any(), on: any()) }.thenReturn(nil) + + mockStorage.write { db in + try OpenGroup + .updateAll(db, OpenGroup.Columns.imageData.set(to: nil)) + } mockOGMCache.when { $0.groupImagePromises } - .thenReturn(["testServer.testRoom": Promise.value(imageData)]) + .thenReturn([OpenGroup.idFor(roomToken: "testRoom", server: "testServer"): Promise.value(imageData)]) } it("uses the provided room image id if available") { @@ -1612,61 +1535,54 @@ class OpenGroupManagerSpec: QuickSpec { ) ) - OpenGroupManager.handlePollInfo( - testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "testServer", - waitForImageToComplete: true, - using: testTransaction, - dependencies: dependencies - ) { didComplete = true } + mockStorage.write { db in + try OpenGroupManager.handlePollInfo( + db, + pollInfo: testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + waitForImageToComplete: true, + dependencies: dependencies + ) { didComplete = true } + } expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) - expect(mockStorage) - .to(call(matchingParameters: true) { - $0.setOpenGroup( - OpenGroup( - server: "testServer", - room: "testRoom", - publicKey: TestConstants.publicKey, - name: "test", - groupDescription: nil, - imageID: "10", - infoUpdates: 0 - ), - for: "TestGroupId", - using: testTransaction! as Any - ) - }) - expect(testGroupThread.groupModel.groupImage) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(50) - ) - expect(testGroupThread.numSaveCalls) - .toEventually( - equal(2), // Call to save the open group and then to save the image - timeout: .milliseconds(50) - ) + expect( + mockStorage.read { db in + try OpenGroup + .select(.imageId) + .asRequest(of: String.self) + .fetchOne(db) + } + ).to(equal("10")) + expect( + mockStorage.read { db in + try OpenGroup + .select(.imageData) + .asRequest(of: Data.self) + .fetchOne(db) + } + ).toNot(beNil()) } it("uses the existing room image id if none is provided") { var didComplete: Bool = false // Prevent multi-threading test bugs - mockStorage - .when { $0.getOpenGroup(for: any()) } - .thenReturn( - OpenGroup( - server: "testServer", - room: "testRoom", - publicKey: TestConstants.publicKey, - name: "Test", - groupDescription: nil, - imageID: "12", - infoUpdates: 10 - ) - ) + mockStorage.write { db in + try OpenGroup.deleteAll(db) + try OpenGroup( + server: "testServer", + roomToken: "testRoom", + publicKey: TestConstants.publicKey, + isActive: true, + name: "Test", + imageId: "12", + userCount: 0, + infoUpdates: 10 + ).insert(db) + } + testPollInfo = OpenGroupAPI.RoomPollInfo( token: "testRoom", activeUsers: 10, @@ -1684,70 +1600,55 @@ class OpenGroupManagerSpec: QuickSpec { details: nil ) - OpenGroupManager.handlePollInfo( - testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "testServer", - waitForImageToComplete: true, - using: testTransaction, - dependencies: dependencies - ) { didComplete = true } + mockStorage.write { db in + try OpenGroupManager.handlePollInfo( + db, + pollInfo: testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + waitForImageToComplete: true, + dependencies: dependencies + ) { didComplete = true } + } expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) - expect(mockStorage) - .to(call(matchingParameters: true) { - $0.setOpenGroup( - OpenGroup( - server: "testServer", - room: "testRoom", - publicKey: TestConstants.publicKey, - name: "TestTitle", - groupDescription: nil, - imageID: "12", - infoUpdates: 10 - ), - for: "TestGroupId", - using: testTransaction! as Any - ) - }) - expect(testGroupThread.groupModel.groupImage) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(50) - ) - expect(testGroupThread.numSaveCalls) - .toEventually( - equal(2), // Call to save the open group and then to save the image - timeout: .milliseconds(50) - ) + expect( + mockStorage.read { db in + try OpenGroup + .select(.imageId) + .asRequest(of: String.self) + .fetchOne(db) + } + ).to(equal("12")) + expect( + mockStorage.read { db in + try OpenGroup + .select(.imageData) + .asRequest(of: Data.self) + .fetchOne(db) + } + ).toNot(beNil()) } it("uses the new room image id if there is an existing one") { var didComplete: Bool = false // Prevent multi-threading test bugs - testGroupThread.mockData[.groupModel] = TSGroupModel( - title: "TestTitle", - memberIds: [], - image: UIImage(color: .blue, size: CGSize(width: 1, height: 1)), - groupId: LKGroupUtilities.getEncodedOpenGroupIDAsData("testServer.testRoom"), - groupType: .openGroup, - adminIds: [], - moderatorIds: [] - ) - mockStorage - .when { $0.getOpenGroup(for: any()) } - .thenReturn( - OpenGroup( - server: "testServer", - room: "testRoom", - publicKey: TestConstants.publicKey, - name: "Test", - groupDescription: nil, - imageID: "12", - infoUpdates: 10 - ) - ) + mockStorage.write { db in + try OpenGroup.deleteAll(db) + try OpenGroup( + server: "testServer", + roomToken: "testRoom", + publicKey: TestConstants.publicKey, + isActive: true, + name: "Test", + imageId: "12", + imageData: UIImage(color: .blue, size: CGSize(width: 1, height: 1)).pngData(), + userCount: 0, + infoUpdates: 10 + ).insert(db) + } + testPollInfo = OpenGroupAPI.RoomPollInfo( token: "testRoom", activeUsers: 10, @@ -1791,81 +1692,73 @@ class OpenGroupManagerSpec: QuickSpec { ) ) - OpenGroupManager.handlePollInfo( - testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "testServer", - waitForImageToComplete: true, - using: testTransaction, - dependencies: dependencies - ) { didComplete = true } + mockStorage.write { db in + try OpenGroupManager.handlePollInfo( + db, + pollInfo: testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + waitForImageToComplete: true, + dependencies: dependencies + ) { didComplete = true } + } expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) - expect(mockStorage) - .toEventually(call(matchingParameters: true) { - $0.setOpenGroup( - OpenGroup( - server: "testServer", - room: "testRoom", - publicKey: TestConstants.publicKey, - name: "test", - groupDescription: nil, - imageID: "10", - infoUpdates: 10 - ), - for: "TestGroupId", - using: testTransaction! as Any - ) - }) - expect(testGroupThread.groupModel.groupImage) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(50) - ) + expect( + mockStorage.read { db in + try OpenGroup + .select(.imageId) + .asRequest(of: String.self) + .fetchOne(db) + } + ).to(equal("10")) + expect( + mockStorage.read { db in + try OpenGroup + .select(.imageData) + .asRequest(of: Data.self) + .fetchOne(db) + } + ).toNot(beNil()) expect(mockOGMCache) .toEventually( call(.exactly(times: 1)) { $0.groupImagePromises }, timeout: .milliseconds(50) ) - expect(testGroupThread.numSaveCalls) - .toEventually( - equal(2), // Call to save the open group and then to save the image - timeout: .milliseconds(50) - ) } it("does nothing if there is no room image") { var didComplete: Bool = false // Prevent multi-threading test bugs - OpenGroupManager.handlePollInfo( - testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "testServer", - waitForImageToComplete: true, - using: testTransaction, - dependencies: dependencies - ) { didComplete = true } + mockStorage.write { db in + try OpenGroupManager.handlePollInfo( + db, + pollInfo: testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + waitForImageToComplete: true, + dependencies: dependencies + ) { didComplete = true } + } expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) - expect(testGroupThread.groupModel.groupImage) - .toEventually( - beNil(), - timeout: .milliseconds(50) - ) - expect(testGroupThread.numSaveCalls) - .toEventually( - equal(1), - timeout: .milliseconds(50) - ) + expect( + mockStorage.read { db in + try OpenGroup + .select(.imageData) + .asRequest(of: Data.self) + .fetchOne(db) + } + ).to(beNil()) } it("does nothing if it fails to retrieve the room image") { var didComplete: Bool = false // Prevent multi-threading test bugs mockOGMCache.when { $0.groupImagePromises } - .thenReturn(["testServer.testRoom": Promise(error: HTTP.Error.generic)]) + .thenReturn([OpenGroup.idFor(roomToken: "testRoom", server: "testServer"): Promise(error: HTTP.Error.generic)]) testPollInfo = OpenGroupAPI.RoomPollInfo( token: "testRoom", @@ -1910,27 +1803,27 @@ class OpenGroupManagerSpec: QuickSpec { ) ) - OpenGroupManager.handlePollInfo( - testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "testServer", - waitForImageToComplete: true, - using: testTransaction, - dependencies: dependencies - ) { didComplete = true } + mockStorage.write { db in + try OpenGroupManager.handlePollInfo( + db, + pollInfo: testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + waitForImageToComplete: true, + dependencies: dependencies + ) { didComplete = true } + } expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) - expect(testGroupThread.groupModel.groupImage) - .toEventually( - beNil(), - timeout: .milliseconds(50) - ) - expect(testGroupThread.numSaveCalls) - .toEventually( - equal(1), - timeout: .milliseconds(50) - ) + expect( + mockStorage.read { db in + try OpenGroup + .select(.imageData) + .asRequest(of: Data.self) + .fetchOne(db) + } + ).to(beNil()) } it("saves the retrieved room image") { @@ -1979,27 +1872,27 @@ class OpenGroupManagerSpec: QuickSpec { ) ) - OpenGroupManager.handlePollInfo( - testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "testServer", - waitForImageToComplete: true, - using: testTransaction, - dependencies: dependencies - ) { didComplete = true } + mockStorage.write { db in + try OpenGroupManager.handlePollInfo( + db, + pollInfo: testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + waitForImageToComplete: true, + dependencies: dependencies + ) { didComplete = true } + } expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) - expect(testGroupThread.groupModel.groupImage) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(50) - ) - expect(testGroupThread.numSaveCalls) - .toEventually( - equal(2), // Call to save the open group and then to save the image - timeout: .milliseconds(50) - ) + expect( + mockStorage.read { db in + try OpenGroup + .select(.imageData) + .asRequest(of: Data.self) + .fetchOne(db) + } + ).toNot(beNil()) } } } @@ -2008,408 +1901,241 @@ class OpenGroupManagerSpec: QuickSpec { context("when handling messages") { beforeEach { - testTransaction.mockData[.objectForKey] = [ - "TestGroupId": testGroupThread, - "TestMessageId": testIncomingMessage - ] - - mockStorage - .when { - $0.setOpenGroupSequenceNumber( - for: any(), - on: any(), - to: any(), - using: testTransaction as Any - ) - } - .thenReturn(()) - mockStorage - .when { - $0.addOpenGroupServerIdLookup( - any(), - tsMessageId: any(), - in: any(), - on: any(), - using: testTransaction - ) - } - .thenReturn(()) - mockStorage - .when { $0.getOpenGroupServerIdLookup(any(), in: any(), on: any(), using: testTransaction) } - .thenReturn(nil) - mockStorage - .when { $0.removeOpenGroupServerIdLookup(any(), in: any(), on: any(), using: testTransaction) } - .thenReturn(nil) - mockStorage.when { $0.getUserPublicKey() }.thenReturn("05\(TestConstants.publicKey)") - mockStorage.when { $0.getReceivedMessageTimestamps(using: testTransaction as Any) }.thenReturn([]) - mockStorage.when { $0.addReceivedMessageTimestamp(any(), using: testTransaction as Any) }.thenReturn(()) - mockStorage.when { $0.persist(anyArray(), using: testTransaction as Any) }.thenReturn([]) - mockStorage - .when { - $0.getOrCreateThread( - for: any(), - groupPublicKey: any(), - openGroupID: any(), - using: testTransaction as Any - ) - } - .thenReturn("TestGroupId") - mockStorage - .when { - $0.persist( - any(), - quotedMessage: nil, - linkPreview: nil, - groupPublicKey: any(), - openGroupID: any(), - using: testTransaction as Any - ) - } - .thenReturn("TestMessageId") - mockStorage.when { $0.getContact(with: any()) }.thenReturn(nil) + mockStorage.write { db in + try testGroupThread.insert(db) + try testOpenGroup.insert(db) + try testInteraction1.insert(db) + } } it("updates the sequence number when there are messages") { - OpenGroupManager.handleMessages( - [ - OpenGroupAPI.Message( - id: 1, - sender: nil, - posted: 123, - edited: nil, - seqNo: 124, - whisper: false, - whisperMods: false, - whisperTo: nil, - base64EncodedData: nil, - base64EncodedSignature: nil - ) - ], - for: "testRoom", - on: "testServer", - isBackgroundPoll: false, - using: testTransaction, - dependencies: dependencies - ) + mockStorage.write { db in + OpenGroupManager.handleMessages( + db, + messages: [ + OpenGroupAPI.Message( + id: 1, + sender: nil, + posted: 123, + edited: nil, + seqNo: 124, + whisper: false, + whisperMods: false, + whisperTo: nil, + base64EncodedData: nil, + base64EncodedSignature: nil + ) + ], + for: "testRoom", + on: "testServer", + isBackgroundPoll: false, + dependencies: dependencies + ) + } - expect(mockStorage) - .to(call(matchingParameters: true) { - $0.setOpenGroupSequenceNumber( - for: "testRoom", - on: "testServer", - to: 124, - using: testTransaction! as Any - ) - }) + expect( + mockStorage.read { db in + try OpenGroup + .select(.sequenceNumber) + .asRequest(of: Int64.self) + .fetchOne(db) + } + ).to(equal(124)) } it("does not update the sequence number if there are no messages") { - OpenGroupManager.handleMessages( - [], - for: "testRoom", - on: "testServer", - isBackgroundPoll: false, - using: testTransaction, - dependencies: dependencies - ) + mockStorage.write { db in + OpenGroupManager.handleMessages( + db, + messages: [], + for: "testRoom", + on: "testServer", + isBackgroundPoll: false, + dependencies: dependencies + ) + } - expect(mockStorage) - .toNot(call { - $0.setOpenGroupSequenceNumber(for: any(), on: any(), to: any(), using: testTransaction as Any) - }) + expect( + mockStorage.read { db in + try OpenGroup + .select(.sequenceNumber) + .asRequest(of: Int64.self) + .fetchOne(db) + } + ).to(equal(5)) } it("ignores a message with no sender") { - OpenGroupManager.handleMessages( - [ - OpenGroupAPI.Message( - id: 1, - sender: nil, - posted: 123, - edited: nil, - seqNo: 124, - whisper: false, - whisperMods: false, - whisperTo: nil, - base64EncodedData: Data([1, 2, 3]).base64EncodedString(), - base64EncodedSignature: nil - ) - ], - for: "testRoom", - on: "testServer", - isBackgroundPoll: false, - using: testTransaction, - dependencies: dependencies - ) + mockStorage.write { db in + try Interaction.deleteAll(db) + } - expect(testIncomingMessage.didCallSave).toEventuallyNot(beTrue(), timeout: .milliseconds(50)) - expect(testIncomingMessage.didCallRemove).toEventuallyNot(beTrue(), timeout: .milliseconds(50)) + mockStorage.write { db in + OpenGroupManager.handleMessages( + db, + messages: [ + OpenGroupAPI.Message( + id: 1, + sender: nil, + posted: 123, + edited: nil, + seqNo: 124, + whisper: false, + whisperMods: false, + whisperTo: nil, + base64EncodedData: Data([1, 2, 3]).base64EncodedString(), + base64EncodedSignature: nil + ) + ], + for: "testRoom", + on: "testServer", + isBackgroundPoll: false, + dependencies: dependencies + ) + } + + expect(mockStorage.read { db in try Interaction.fetchCount(db) }).to(equal(0)) } it("ignores a message with invalid data") { - OpenGroupManager.handleMessages( - [ - OpenGroupAPI.Message( - id: 1, - sender: "05\(TestConstants.publicKey)", - posted: 123, - edited: nil, - seqNo: 124, - whisper: false, - whisperMods: false, - whisperTo: nil, - base64EncodedData: Data([1, 2, 3]).base64EncodedString(), - base64EncodedSignature: nil - ) - ], - for: "testRoom", - on: "testServer", - isBackgroundPoll: false, - using: testTransaction, - dependencies: dependencies - ) + mockStorage.write { db in + try Interaction.deleteAll(db) + } - expect(testIncomingMessage.didCallSave).toEventuallyNot(beTrue(), timeout: .milliseconds(50)) - expect(testIncomingMessage.didCallRemove).toEventuallyNot(beTrue(), timeout: .milliseconds(50)) + mockStorage.write { db in + OpenGroupManager.handleMessages( + db, + messages: [ + OpenGroupAPI.Message( + id: 1, + sender: "05\(TestConstants.publicKey)", + posted: 123, + edited: nil, + seqNo: 124, + whisper: false, + whisperMods: false, + whisperTo: nil, + base64EncodedData: Data([1, 2, 3]).base64EncodedString(), + base64EncodedSignature: nil + ) + ], + for: "testRoom", + on: "testServer", + isBackgroundPoll: false, + dependencies: dependencies + ) + } + + expect(mockStorage.read { db in try Interaction.fetchCount(db) }).to(equal(0)) } it("processes a message with valid data") { - OpenGroupManager.handleMessages( - [testMessage], - for: "testRoom", - on: "testServer", - isBackgroundPoll: false, - using: testTransaction, - dependencies: dependencies - ) - - expect(testIncomingMessage.didCallSave) - .toEventually( - beTrue(), - timeout: .milliseconds(50) + mockStorage.write { db in + OpenGroupManager.handleMessages( + db, + messages: [testMessage], + for: "testRoom", + on: "testServer", + isBackgroundPoll: false, + dependencies: dependencies ) - } - - it("adds the open group server id lookup") { - OpenGroupManager.handleMessages( - [testMessage], - for: "testRoom", - on: "testServer", - isBackgroundPoll: false, - using: testTransaction, - dependencies: dependencies - ) + } - expect(mockStorage) - .toEventually( - call(matchingParameters: true) { - $0.addOpenGroupServerIdLookup( - 127, - tsMessageId: "TestMessageId", - in: "testRoom", - on: "testserver", - using: testTransaction - ) - }, - timeout: .milliseconds(50) - ) + expect(mockStorage.read { db in try Interaction.fetchCount(db) }).to(equal(1)) } it("processes valid messages when combined with invalid ones") { - OpenGroupManager.handleMessages( - [ - OpenGroupAPI.Message( - id: 2, - sender: "05\(TestConstants.publicKey)", - posted: 122, - edited: nil, - seqNo: 123, - whisper: false, - whisperMods: false, - whisperTo: nil, - base64EncodedData: Data([1, 2, 3]).base64EncodedString(), - base64EncodedSignature: nil - ), - testMessage, - ], - for: "testRoom", - on: "testServer", - isBackgroundPoll: false, - using: testTransaction, - dependencies: dependencies - ) - - expect(testIncomingMessage.didCallSave) - .toEventually( - beTrue(), - timeout: .milliseconds(50) + mockStorage.write { db in + OpenGroupManager.handleMessages( + db, + messages: [ + OpenGroupAPI.Message( + id: 2, + sender: "05\(TestConstants.publicKey)", + posted: 122, + edited: nil, + seqNo: 123, + whisper: false, + whisperMods: false, + whisperTo: nil, + base64EncodedData: Data([1, 2, 3]).base64EncodedString(), + base64EncodedSignature: nil + ), + testMessage, + ], + for: "testRoom", + on: "testServer", + isBackgroundPoll: false, + dependencies: dependencies ) + } + + expect(mockStorage.read { db in try Interaction.fetchCount(db) }).to(equal(1)) } context("with no data") { it("deletes the message if we have the message") { - mockStorage - .when { $0.getOpenGroupServerIdLookup(any(), in: any(), on: any(), using: testTransaction) } - .thenReturn( - OpenGroupServerIdLookup( - server: "testServer", - room: "testRoom", - serverId: 127, - tsMessageId: "TestMessageId" + mockStorage.write { db in + try Interaction + .updateAll( + db, + Interaction.Columns.openGroupServerMessageId.set(to: 127) ) - ) + } - OpenGroupManager.handleMessages( - [ - OpenGroupAPI.Message( - id: 127, - sender: "05\(TestConstants.publicKey)", - posted: 123, - edited: nil, - seqNo: 123, - whisper: false, - whisperMods: false, - whisperTo: nil, - base64EncodedData: nil, - base64EncodedSignature: nil - ) - ], - for: "testRoom", - on: "testServer", - isBackgroundPoll: false, - using: testTransaction, - dependencies: dependencies - ) - - expect(testIncomingMessage.didCallRemove) - .toEventually( - beTrue(), - timeout: .milliseconds(50) - ) - } - - it("deletes the open group server lookup id if we have the message") { - mockStorage - .when { $0.getOpenGroupServerIdLookup(any(), in: any(), on: any(), using: testTransaction) } - .thenReturn( - OpenGroupServerIdLookup( - server: "testServer", - room: "testRoom", - serverId: 127, - tsMessageId: "TestMessageId" - ) - ) - - OpenGroupManager.handleMessages( - [ - OpenGroupAPI.Message( - id: 127, - sender: "05\(TestConstants.publicKey)", - posted: 123, - edited: nil, - seqNo: 123, - whisper: false, - whisperMods: false, - whisperTo: nil, - base64EncodedData: nil, - base64EncodedSignature: nil - ) - ], - for: "testRoom", - on: "testServer", - isBackgroundPoll: false, - using: testTransaction, - dependencies: dependencies - ) - - expect(mockStorage) - .toEventually( - call(matchingParameters: true) { - $0.removeOpenGroupServerIdLookup( - 127, - in: "testRoom", - on: "testServer", - using: testTransaction + mockStorage.write { db in + OpenGroupManager.handleMessages( + db, + messages: [ + OpenGroupAPI.Message( + id: 127, + sender: "05\(TestConstants.publicKey)", + posted: 123, + edited: nil, + seqNo: 123, + whisper: false, + whisperMods: false, + whisperTo: nil, + base64EncodedData: nil, + base64EncodedSignature: nil ) - }, - timeout: .milliseconds(50) + ], + for: "testRoom", + on: "testServer", + isBackgroundPoll: false, + dependencies: dependencies ) - } - - it("does nothing if we do not have the lookup") { - mockStorage - .when { $0.getOpenGroupServerIdLookup(any(), in: any(), on: any(), using: testTransaction) } - .thenReturn(nil) + } - OpenGroupManager.handleMessages( - [ - OpenGroupAPI.Message( - id: 127, - sender: "05\(TestConstants.publicKey)", - posted: 123, - edited: nil, - seqNo: 123, - whisper: false, - whisperMods: false, - whisperTo: nil, - base64EncodedData: nil, - base64EncodedSignature: nil - ) - ], - for: "testRoom", - on: "testServer", - isBackgroundPoll: false, - using: testTransaction, - dependencies: dependencies - ) - - expect(testIncomingMessage.didCallRemove) - .toEventuallyNot( - beTrue(), - timeout: .milliseconds(50) - ) + expect(mockStorage.read { db in try Interaction.fetchCount(db) }).to(equal(0)) } it("does nothing if we do not have the message") { - mockStorage - .when { $0.getOpenGroupServerIdLookup(any(), in: any(), on: any(), using: testTransaction) } - .thenReturn( - OpenGroupServerIdLookup( - server: "testServer", - room: "testRoom", - serverId: 127, - tsMessageId: "TestMessageId" - ) + mockStorage.write { db in + OpenGroupManager.handleMessages( + db, + messages: [ + OpenGroupAPI.Message( + id: 127, + sender: "05\(TestConstants.publicKey)", + posted: 123, + edited: nil, + seqNo: 123, + whisper: false, + whisperMods: false, + whisperTo: nil, + base64EncodedData: nil, + base64EncodedSignature: nil + ) + ], + for: "testRoom", + on: "testServer", + isBackgroundPoll: false, + dependencies: dependencies ) - testTransaction.mockData[.objectForKey] = nil + } - OpenGroupManager.handleMessages( - [ - OpenGroupAPI.Message( - id: 127, - sender: "05\(TestConstants.publicKey)", - posted: 123, - edited: nil, - seqNo: 123, - whisper: false, - whisperMods: false, - whisperTo: nil, - base64EncodedData: nil, - base64EncodedSignature: nil - ) - ], - for: "testRoom", - on: "testServer", - isBackgroundPoll: false, - using: testTransaction, - dependencies: dependencies - ) - - expect(testIncomingMessage.didCallRemove) - .toEventuallyNot( - beTrue(), - timeout: .milliseconds(50) - ) + expect(mockStorage.read { db in try Interaction.fetchCount(db) }).to(equal(0)) } } } @@ -2418,18 +2144,6 @@ class OpenGroupManagerSpec: QuickSpec { context("when handling direct messages") { beforeEach { - testTransaction.mockData[.objectForKey] = testContactThread - - mockStorage - .when { $0.setOpenGroupInboxLatestMessageId(for: any(), to: any(), using: testTransaction as Any) } - .thenReturn(()) - - mockStorage - .when { $0.setOpenGroupOutboxLatestMessageId(for: any(), to: any(), using: testTransaction as Any) } - .thenReturn(()) - mockStorage.when { $0.getUserPublicKey() }.thenReturn("05\(TestConstants.publicKey)") - mockStorage.when { $0.getReceivedMessageTimestamps(using: testTransaction as Any) }.thenReturn([]) - mockStorage.when { $0.addReceivedMessageTimestamp(any(), using: testTransaction as Any) }.thenReturn(()) mockSodium .when { $0.sharedBlindedEncryptionKey( @@ -2459,128 +2173,94 @@ class OpenGroupManagerSpec: QuickSpec { mockSign .when { $0.toX25519(ed25519PublicKey: anyArray()) } .thenReturn(Data(hex: TestConstants.publicKey).bytes) - mockStorage.when { $0.persist(anyArray(), using: testTransaction as Any) }.thenReturn([]) - mockStorage - .when { - $0.getOrCreateThread( - for: any(), - groupPublicKey: any(), - openGroupID: any(), - using: testTransaction as Any - ) - } - .thenReturn("TestContactId") - mockStorage - .when { - $0.persist( - any(), - quotedMessage: nil, - linkPreview: nil, - groupPublicKey: any(), - openGroupID: any(), - using: testTransaction as Any - ) - } - .thenReturn("TestMessageId") - mockStorage.when { $0.getContact(with: any()) }.thenReturn(nil) - mockStorage - .when { $0.getBlindedIdMapping(with: any(), using: testTransaction) } - .thenReturn(nil) - mockStorage - .when { $0.enumerateBlindedIdMapping(using: testTransaction, with: { _, _ in }) } - .then { args in - guard let block = args.first as? (BlindedIdMapping, UnsafeMutablePointer) -> () else { - return - } - - var stop: ObjCBool = false - block(any(), &stop) - } - .thenReturn(()) } it("does nothing if there are no messages") { - OpenGroupManager.handleDirectMessages( - [], - fromOutbox: false, - on: "testServer", - isBackgroundPoll: false, - using: testTransaction, - dependencies: dependencies - ) + mockStorage.write { db in + OpenGroupManager.handleDirectMessages( + db, + messages: [], + fromOutbox: false, + on: "testServer", + isBackgroundPoll: false, + dependencies: dependencies + ) + } - expect(testContactThread.numSaveCalls).to(equal(0)) - expect(mockStorage) - .toNot(call { - $0.setOpenGroupInboxLatestMessageId( - for: any(), - to: any(), - using: testTransaction! as Any - ) - }) - expect(mockStorage) - .toNot(call { - $0.setOpenGroupOutboxLatestMessageId( - for: any(), - to: any(), - using: testTransaction! as Any - ) - }) + expect( + mockStorage.read { db in + try OpenGroup + .select(.inboxLatestMessageId) + .asRequest(of: Int64.self) + .fetchOne(db) + } + ).to(equal(0)) + expect( + mockStorage.read { db in + try OpenGroup + .select(.outboxLatestMessageId) + .asRequest(of: Int64.self) + .fetchOne(db) + } + ).to(equal(0)) } - it("does nothing if it cannot get the open group public key") { - mockStorage - .when { $0.getOpenGroupPublicKey(for: any()) } - .thenReturn(nil) + it("does nothing if it cannot get the open group") { + mockStorage.write { db in + try OpenGroup.deleteAll(db) + } - OpenGroupManager.handleDirectMessages( - [testDirectMessage], - fromOutbox: false, - on: "testServer", - isBackgroundPoll: false, - using: testTransaction, - dependencies: dependencies - ) + mockStorage.write { db in + OpenGroupManager.handleDirectMessages( + db, + messages: [testDirectMessage], + fromOutbox: false, + on: "testServer", + isBackgroundPoll: false, + dependencies: dependencies + ) + } - expect(testContactThread.numSaveCalls).to(equal(0)) - expect(mockStorage) - .toNot(call { - $0.setOpenGroupInboxLatestMessageId( - for: any(), - to: any(), - using: testTransaction! as Any - ) - }) - expect(mockStorage) - .toNot(call { - $0.setOpenGroupOutboxLatestMessageId( - for: any(), - to: any(), - using: testTransaction! as Any - ) - }) + expect( + mockStorage.read { db in + try OpenGroup + .select(.inboxLatestMessageId) + .asRequest(of: Int64.self) + .fetchOne(db) + } + ).to(beNil()) + expect( + mockStorage.read { db in + try OpenGroup + .select(.outboxLatestMessageId) + .asRequest(of: Int64.self) + .fetchOne(db) + } + ).to(beNil()) } it("ignores messages with non base64 encoded data") { testDirectMessage = OpenGroupAPI.DirectMessage( id: testDirectMessage.id, - sender: testDirectMessage.sender, + sender: testDirectMessage.sender.replacingOccurrences(of: "8", with: "9"), recipient: testDirectMessage.recipient, posted: testDirectMessage.posted, expires: testDirectMessage.expires, base64EncodedMessage: "TestMessage%%%" ) - OpenGroupManager.handleDirectMessages( - [testDirectMessage], - fromOutbox: false, - on: "testServer", - isBackgroundPoll: false, - using: testTransaction, - dependencies: dependencies - ) + mockStorage.write { db in + OpenGroupManager.handleDirectMessages( + db, + messages: [testDirectMessage], + fromOutbox: false, + on: "testServer", + isBackgroundPoll: false, + dependencies: dependencies + ) + } - expect(testContactThread.numSaveCalls).to(equal(0)) + expect(mockStorage.read { db in try Interaction.fetchCount(db) }).to(equal(0)) } context("for the inbox") { @@ -2591,83 +2271,89 @@ class OpenGroupManagerSpec: QuickSpec { } it("updates the inbox latest message id") { - OpenGroupManager.handleDirectMessages( - [testDirectMessage], - fromOutbox: false, - on: "testServer", - isBackgroundPoll: false, - using: testTransaction, - dependencies: dependencies - ) + mockStorage.write { db in + OpenGroupManager.handleDirectMessages( + db, + messages: [testDirectMessage], + fromOutbox: false, + on: "testServer", + isBackgroundPoll: false, + dependencies: dependencies + ) + } - expect(mockStorage) - .to(call(matchingParameters: true) { - $0.setOpenGroupInboxLatestMessageId( - for: "testServer", - to: 128, - using: testTransaction! as Any - ) - }) + expect( + mockStorage.read { db in + try OpenGroup + .select(.inboxLatestMessageId) + .asRequest(of: Int64.self) + .fetchOne(db) + } + ).to(equal(128)) } it("ignores a message with invalid data") { testDirectMessage = OpenGroupAPI.DirectMessage( id: testDirectMessage.id, - sender: testDirectMessage.sender, + sender: testDirectMessage.sender.replacingOccurrences(of: "8", with: "9"), recipient: testDirectMessage.recipient, posted: testDirectMessage.posted, expires: testDirectMessage.expires, base64EncodedMessage: Data([1, 2, 3]).base64EncodedString() ) - OpenGroupManager.handleDirectMessages( - [testDirectMessage], - fromOutbox: false, - on: "testServer", - isBackgroundPoll: false, - using: testTransaction, - dependencies: dependencies - ) + mockStorage.write { db in + OpenGroupManager.handleDirectMessages( + db, + messages: [testDirectMessage], + fromOutbox: false, + on: "testServer", + isBackgroundPoll: false, + dependencies: dependencies + ) + } - expect(testContactThread.numSaveCalls).to(equal(0)) + expect(mockStorage.read { db in try Interaction.fetchCount(db) }).to(equal(0)) } it("processes a message with valid data") { - OpenGroupManager.handleDirectMessages( - [testDirectMessage], - fromOutbox: false, - on: "testServer", - isBackgroundPoll: false, - using: testTransaction, - dependencies: dependencies - ) + mockStorage.write { db in + OpenGroupManager.handleDirectMessages( + db, + messages: [testDirectMessage], + fromOutbox: false, + on: "testServer", + isBackgroundPoll: false, + dependencies: dependencies + ) + } - // Saved once per valid inbox message - expect(testContactThread.numSaveCalls).to(equal(1)) + expect(mockStorage.read { db in try Interaction.fetchCount(db) }).to(equal(1)) } it("processes valid messages when combined with invalid ones") { - OpenGroupManager.handleDirectMessages( - [ - OpenGroupAPI.DirectMessage( - id: testDirectMessage.id, - sender: testDirectMessage.sender, - recipient: testDirectMessage.recipient, - posted: testDirectMessage.posted, - expires: testDirectMessage.expires, - base64EncodedMessage: Data([1, 2, 3]).base64EncodedString() - ), - testDirectMessage - ], - fromOutbox: false, - on: "testServer", - isBackgroundPoll: false, - using: testTransaction, - dependencies: dependencies - ) + mockStorage.write { db in + OpenGroupManager.handleDirectMessages( + db, + messages: [ + OpenGroupAPI.DirectMessage( + id: testDirectMessage.id, + sender: testDirectMessage.sender.replacingOccurrences(of: "8", with: "9"), + recipient: testDirectMessage.recipient, + posted: testDirectMessage.posted, + expires: testDirectMessage.expires, + base64EncodedMessage: Data([1, 2, 3]).base64EncodedString() + ), + testDirectMessage + ], + fromOutbox: false, + on: "testServer", + isBackgroundPoll: false, + dependencies: dependencies + ) + } - // Saved once per valid inbox message - expect(testContactThread.numSaveCalls).to(equal(1)) + expect(mockStorage.read { db in try Interaction.fetchCount(db) }).to(equal(1)) } } @@ -2679,205 +2365,141 @@ class OpenGroupManagerSpec: QuickSpec { } it("updates the outbox latest message id") { - OpenGroupManager.handleDirectMessages( - [testDirectMessage], - fromOutbox: true, - on: "testServer", - isBackgroundPoll: false, - using: testTransaction, - dependencies: dependencies - ) - - expect(mockStorage) - .to(call { - $0.setOpenGroupOutboxLatestMessageId( - for: "testServer", - to: 128, - using: testTransaction! as Any - ) - }) - } - - it("retrieves an existing blinded id mapping") { - mockStorage - .when { $0.getBlindedIdMapping(with: any(), using: testTransaction) } - .thenReturn( - BlindedIdMapping( - blindedId: "15\(TestConstants.publicKey)", - sessionId: "TestSessionId", - serverPublicKey: "05\(TestConstants.publicKey)" - ) + mockStorage.write { db in + OpenGroupManager.handleDirectMessages( + db, + messages: [testDirectMessage], + fromOutbox: true, + on: "testServer", + isBackgroundPoll: false, + dependencies: dependencies ) + } - OpenGroupManager.handleDirectMessages( - [testDirectMessage], - fromOutbox: true, - on: "testServer", - isBackgroundPoll: false, - using: testTransaction, - dependencies: dependencies - ) - - expect(mockStorage) - .to(call(.exactly(times: 1)) { - $0.getBlindedIdMapping(with: any(), using: testTransaction) - }) - expect(mockStorage) - .to(call(matchingParameters: true) { - $0.getOrCreateThread( - for: "TestSessionId", - groupPublicKey: nil, - openGroupID: nil, - using: testTransaction! as Any - ) - }) - - // Saved twice per valid outbox message - expect(testContactThread.numSaveCalls).to(equal(2)) + expect( + mockStorage.read { db in + try OpenGroup + .select(.outboxLatestMessageId) + .asRequest(of: Int64.self) + .fetchOne(db) + } + ).to(equal(128)) } - it("locally caches blinded id mappings for the same recipient") { - mockStorage - .when { $0.getBlindedIdMapping(with: any(), using: testTransaction) } - .thenReturn( - BlindedIdMapping( - blindedId: "15\(TestConstants.publicKey)", - sessionId: "TestSessionId", - serverPublicKey: "05\(TestConstants.publicKey)" - ) + it("retrieves an existing blinded id lookup") { + mockStorage.write { db in + try BlindedIdLookup( + blindedId: "15\(TestConstants.publicKey)", + sessionId: "TestSessionId", + openGroupServer: "testserver", + openGroupPublicKey: "05\(TestConstants.publicKey)" + ).insert(db) + } + + mockStorage.write { db in + OpenGroupManager.handleDirectMessages( + db, + messages: [testDirectMessage], + fromOutbox: true, + on: "testServer", + isBackgroundPoll: false, + dependencies: dependencies ) + } - OpenGroupManager.handleDirectMessages( - [ - testDirectMessage, - OpenGroupAPI.DirectMessage( - id: testDirectMessage.id + 1, - sender: testDirectMessage.sender, - recipient: testDirectMessage.recipient, - posted: testDirectMessage.posted + 1, - expires: testDirectMessage.expires + 1, - base64EncodedMessage: testDirectMessage.base64EncodedMessage - ) - ], - fromOutbox: true, - on: "testServer", - isBackgroundPoll: false, - using: testTransaction, - dependencies: dependencies - ) - - expect(mockStorage) - .to(call(.exactly(times: 1)) { - $0.getBlindedIdMapping(with: any(), using: testTransaction) - }) - - // Saved twice per valid outbox message - expect(testContactThread.numSaveCalls).to(equal(4)) + expect(mockStorage.read { db in try BlindedIdLookup.fetchCount(db) }).to(equal(1)) + expect(mockStorage.read { db in try SessionThread.fetchCount(db) }).to(equal(2)) } - it("falls back to using the blinded id if no mapping is found") { - OpenGroupManager.handleDirectMessages( - [testDirectMessage], - fromOutbox: true, - on: "testServer", - isBackgroundPoll: false, - using: testTransaction, - dependencies: dependencies - ) + it("falls back to using the blinded id if no lookup is found") { + mockStorage.write { db in + OpenGroupManager.handleDirectMessages( + db, + messages: [testDirectMessage], + fromOutbox: true, + on: "testServer", + isBackgroundPoll: false, + dependencies: dependencies + ) + } - expect(mockStorage) - .to(call(.exactly(times: 1)) { - $0.getBlindedIdMapping(with: any(), using: testTransaction) - }) - expect(mockStorage) - .to(call(matchingParameters: true) { - $0.getOrCreateThread( - for: "15\(TestConstants.publicKey)", - groupPublicKey: nil, - openGroupID: nil, - using: testTransaction! as Any - ) - }) - - // Saved twice per valid outbox message - expect(testContactThread.numSaveCalls).to(equal(2)) + expect(mockStorage.read { db in try BlindedIdLookup.fetchCount(db) }).to(equal(1)) + expect(mockStorage + .read { db in + try BlindedIdLookup + .select(.sessionId) + .asRequest(of: String.self) + .fetchOne(db) + } + ).to(beNil()) + expect(mockStorage.read { db in try SessionThread.fetchCount(db) }).to(equal(2)) + expect( + mockStorage.read { db in try SessionThread.fetchOne(db, id: "15\(TestConstants.publicKey)") } + ).toNot(beNil()) } it("ignores a message with invalid data") { testDirectMessage = OpenGroupAPI.DirectMessage( id: testDirectMessage.id, - sender: testDirectMessage.sender, + sender: testDirectMessage.sender.replacingOccurrences(of: "8", with: "9"), recipient: testDirectMessage.recipient, posted: testDirectMessage.posted, expires: testDirectMessage.expires, base64EncodedMessage: Data([1, 2, 3]).base64EncodedString() ) - OpenGroupManager.handleDirectMessages( - [testDirectMessage], - fromOutbox: true, - on: "testServer", - isBackgroundPoll: false, - using: testTransaction, - dependencies: dependencies - ) + mockStorage.write { db in + OpenGroupManager.handleDirectMessages( + db, + messages: [testDirectMessage], + fromOutbox: true, + on: "testServer", + isBackgroundPoll: false, + dependencies: dependencies + ) + } - expect(testContactThread.numSaveCalls).to(equal(0)) + expect(mockStorage.read { db in try SessionThread.fetchCount(db) }).to(equal(1)) } it("processes a message with valid data") { - OpenGroupManager.handleDirectMessages( - [testDirectMessage], - fromOutbox: true, - on: "testServer", - isBackgroundPoll: false, - using: testTransaction, - dependencies: dependencies - ) + mockStorage.write { db in + OpenGroupManager.handleDirectMessages( + db, + messages: [testDirectMessage], + fromOutbox: true, + on: "testServer", + isBackgroundPoll: false, + dependencies: dependencies + ) + } - // Saved twice per valid outbox message - expect(testContactThread.numSaveCalls).to(equal(2)) + expect(mockStorage.read { db in try SessionThread.fetchCount(db) }).to(equal(2)) } it("processes valid messages when combined with invalid ones") { - OpenGroupManager.handleDirectMessages( - [ - OpenGroupAPI.DirectMessage( - id: testDirectMessage.id, - sender: testDirectMessage.sender, - recipient: testDirectMessage.recipient, - posted: testDirectMessage.posted, - expires: testDirectMessage.expires, - base64EncodedMessage: Data([1, 2, 3]).base64EncodedString() - ), - testDirectMessage - ], - fromOutbox: true, - on: "testServer", - isBackgroundPoll: false, - using: testTransaction, - dependencies: dependencies - ) + mockStorage.write { db in + OpenGroupManager.handleDirectMessages( + db, + messages: [ + OpenGroupAPI.DirectMessage( + id: testDirectMessage.id, + sender: testDirectMessage.sender.replacingOccurrences(of: "8", with: "9"), + recipient: testDirectMessage.recipient, + posted: testDirectMessage.posted, + expires: testDirectMessage.expires, + base64EncodedMessage: Data([1, 2, 3]).base64EncodedString() + ), + testDirectMessage + ], + fromOutbox: true, + on: "testServer", + isBackgroundPoll: false, + dependencies: dependencies + ) + } - // Saved twice per valid outbox message - expect(testContactThread.numSaveCalls).to(equal(2)) - } - - it("updates the contact thread with the open group information") { - expect(testContactThread.originalOpenGroupServer).to(beNil()) - expect(testContactThread.originalOpenGroupPublicKey).to(beNil()) - - OpenGroupManager.handleDirectMessages( - [testDirectMessage], - fromOutbox: true, - on: "testServer", - isBackgroundPoll: false, - using: testTransaction, - dependencies: dependencies - ) - - expect(testContactThread.originalOpenGroupServer).to(equal("testServer")) - expect(testContactThread.originalOpenGroupPublicKey).to(equal(TestConstants.publicKey)) + expect(mockStorage.read { db in try SessionThread.fetchCount(db) }).to(equal(2)) } } } @@ -2888,8 +2510,9 @@ class OpenGroupManagerSpec: QuickSpec { context("when determining if a user is a moderator or an admin") { beforeEach { - mockOGMCache.when { $0.moderators }.thenReturn([:]) - mockOGMCache.when { $0.admins }.thenReturn([:]) + mockStorage.write { db in + _ = try GroupMember.deleteAll(db) + } } it("uses an empty set for moderators by default") { @@ -2915,12 +2538,13 @@ class OpenGroupManagerSpec: QuickSpec { } it("returns true if the key is in the moderator set") { - mockOGMCache.when { $0.moderators } - .thenReturn([ - "testServer": [ - "testRoom": Set(arrayLiteral: "05\(TestConstants.publicKey)") - ] - ]) + mockStorage.write { db in + try GroupMember( + groupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"), + profileId: "05\(TestConstants.publicKey)", + role: .moderator + ).insert(db) + } expect( OpenGroupManager.isUserModeratorOrAdmin( @@ -2933,12 +2557,13 @@ class OpenGroupManagerSpec: QuickSpec { } it("returns true if the key is in the admin set") { - mockOGMCache.when { $0.admins } - .thenReturn([ - "testServer": [ - "testRoom": Set(arrayLiteral: "05\(TestConstants.publicKey)") - ] - ]) + mockStorage.write { db in + try GroupMember( + groupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"), + profileId: "05\(TestConstants.publicKey)", + role: .admin + ).insert(db) + } expect( OpenGroupManager.isUserModeratorOrAdmin( @@ -2963,15 +2588,12 @@ class OpenGroupManagerSpec: QuickSpec { context("and the key is a standard session id") { it("returns false if the key is not the users session id") { - let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") - mockIdentityManager - .when { $0.identityKeyPair() } - .thenReturn( - try! ECKeyPair( - publicKeyData: Data.data(fromHex: otherKey)!, - privateKeyData: Data.data(fromHex: TestConstants.privateKey)! - ) - ) + mockStorage.write { db in + let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") + + try Identity(variant: .x25519PublicKey, data: Data.data(fromHex: otherKey)!).save(db) + try Identity(variant: .x25519PrivateKey, data: Data.data(fromHex: TestConstants.privateKey)!).save(db) + } expect( OpenGroupManager.isUserModeratorOrAdmin( @@ -2984,21 +2606,18 @@ class OpenGroupManagerSpec: QuickSpec { } it("returns true if the key is the current users and the users unblinded id is a moderator or admin") { - let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") - mockOGMCache.when { $0.moderators } - .thenReturn([ - "testServer": [ - "testRoom": Set(arrayLiteral: "00\(otherKey)") - ] - ]) - mockStorage - .when { $0.getUserED25519KeyPair() } - .thenReturn( - Box.KeyPair( - publicKey: Data.data(fromHex: otherKey)!.bytes, - secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes - ) - ) + mockStorage.write { db in + let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") + + try GroupMember( + groupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"), + profileId: "00\(otherKey)", + role: .moderator + ).insert(db) + + try Identity(variant: .ed25519PublicKey, data: Data.data(fromHex: otherKey)!).save(db) + try Identity(variant: .ed25519SecretKey, data: Data.data(fromHex: TestConstants.edSecretKey)!).save(db) + } expect( OpenGroupManager.isUserModeratorOrAdmin( @@ -3012,12 +2631,6 @@ class OpenGroupManagerSpec: QuickSpec { 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") - mockOGMCache.when { $0.moderators } - .thenReturn([ - "testServer": [ - "testRoom": Set(arrayLiteral: "15\(otherKey)") - ] - ]) mockSodium .when { $0.blindedKeyPair( @@ -3032,6 +2645,13 @@ class OpenGroupManagerSpec: QuickSpec { secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes ) ) + mockStorage.write { db in + try GroupMember( + groupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"), + profileId: "15\(otherKey)", + role: .moderator + ).insert(db) + } expect( OpenGroupManager.isUserModeratorOrAdmin( @@ -3046,9 +2666,10 @@ class OpenGroupManagerSpec: QuickSpec { context("and the key is unblinded") { it("returns false if unable to retrieve the user ed25519 key") { - mockStorage - .when { $0.getUserED25519KeyPair() } - .thenReturn(nil) + mockStorage.write { db in + try Identity.filter(id: .ed25519PublicKey).deleteAll(db) + try Identity.filter(id: .ed25519SecretKey).deleteAll(db) + } expect( OpenGroupManager.isUserModeratorOrAdmin( @@ -3061,15 +2682,12 @@ class OpenGroupManagerSpec: QuickSpec { } it("returns false if the key is not the users unblinded id") { - let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") - mockStorage - .when { $0.getUserED25519KeyPair() } - .thenReturn( - Box.KeyPair( - publicKey: Data.data(fromHex: otherKey)!.bytes, - secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes - ) - ) + mockStorage.write { db in + let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") + + try Identity(variant: .ed25519PublicKey, data: Data.data(fromHex: otherKey)!).save(db) + try Identity(variant: .ed25519SecretKey, data: Data.data(fromHex: TestConstants.edSecretKey)!).save(db) + } expect( OpenGroupManager.isUserModeratorOrAdmin( @@ -3083,29 +2701,19 @@ class OpenGroupManagerSpec: QuickSpec { 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") - mockOGMCache.when { $0.moderators } - .thenReturn([ - "testServer": [ - "testRoom": Set(arrayLiteral: "05\(otherKey)") - ] - ]) - mockIdentityManager - .when { $0.identityKeyPair() } - .thenReturn( - try! ECKeyPair( - publicKeyData: Data.data(fromHex: otherKey)!, - privateKeyData: Data.data(fromHex: TestConstants.privateKey)! - ) - ) mockGeneralCache.when { $0.encodedPublicKey }.thenReturn("05\(otherKey)") - mockStorage - .when { $0.getUserED25519KeyPair() } - .thenReturn( - Box.KeyPair( - publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, - secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes - ) - ) + mockStorage.write { db in + try GroupMember( + groupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"), + profileId: "05\(otherKey)", + role: .moderator + ).insert(db) + + try Identity(variant: .x25519PublicKey, data: Data.data(fromHex: otherKey)!).save(db) + try Identity(variant: .x25519PrivateKey, data: Data.data(fromHex: TestConstants.privateKey)!).save(db) + try Identity(variant: .ed25519PublicKey, data: Data.data(fromHex: TestConstants.publicKey)!).save(db) + try Identity(variant: .ed25519SecretKey, data: Data.data(fromHex: TestConstants.edSecretKey)!).save(db) + } expect( OpenGroupManager.isUserModeratorOrAdmin( @@ -3119,12 +2727,6 @@ class OpenGroupManagerSpec: QuickSpec { 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") - mockOGMCache.when { $0.moderators } - .thenReturn([ - "testServer": [ - "testRoom": Set(arrayLiteral: "15\(otherKey)") - ] - ]) mockSodium .when { $0.blindedKeyPair( @@ -3139,6 +2741,18 @@ class OpenGroupManagerSpec: QuickSpec { secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes ) ) + mockStorage.write { db in + try GroupMember( + groupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"), + profileId: "15\(otherKey)", + role: .moderator + ).insert(db) + + try Identity(variant: .x25519PublicKey, data: Data.data(fromHex: TestConstants.publicKey)!).save(db) + try Identity(variant: .x25519PrivateKey, data: Data.data(fromHex: TestConstants.privateKey)!).save(db) + try Identity(variant: .ed25519PublicKey, data: Data.data(fromHex: TestConstants.publicKey)!).save(db) + try Identity(variant: .ed25519SecretKey, data: Data.data(fromHex: TestConstants.edSecretKey)!).save(db) + } expect( OpenGroupManager.isUserModeratorOrAdmin( @@ -3153,24 +2767,10 @@ class OpenGroupManagerSpec: QuickSpec { context("and the key is blinded") { it("returns false if unable to retrieve the user ed25519 key") { - mockStorage - .when { $0.getUserED25519KeyPair() } - .thenReturn(nil) - - expect( - OpenGroupManager.isUserModeratorOrAdmin( - "15\(TestConstants.publicKey)", - for: "testRoom", - on: "testServer", - using: dependencies - ) - ).to(beFalse()) - } - - it("returns false if unable to retrieve the public key for the open group server") { - mockStorage - .when { $0.getOpenGroupPublicKey(for: any()) } - .thenReturn(nil) + mockStorage.write { db in + try Identity.filter(id: .ed25519PublicKey).deleteAll(db) + try Identity.filter(id: .ed25519SecretKey).deleteAll(db) + } expect( OpenGroupManager.isUserModeratorOrAdmin( @@ -3232,29 +2832,7 @@ class OpenGroupManagerSpec: QuickSpec { 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") - mockOGMCache.when { $0.moderators } - .thenReturn([ - "testServer": [ - "testRoom": Set(arrayLiteral: "05\(otherKey)") - ] - ]) - mockIdentityManager - .when { $0.identityKeyPair() } - .thenReturn( - try! ECKeyPair( - publicKeyData: Data.data(fromHex: otherKey)!, - privateKeyData: Data.data(fromHex: TestConstants.privateKey)! - ) - ) mockGeneralCache.when { $0.encodedPublicKey }.thenReturn("05\(otherKey)") - mockStorage - .when { $0.getUserED25519KeyPair() } - .thenReturn( - Box.KeyPair( - publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, - secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes - ) - ) mockSodium .when { $0.blindedKeyPair( @@ -3269,6 +2847,18 @@ class OpenGroupManagerSpec: QuickSpec { secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes ) ) + mockStorage.write { db in + try GroupMember( + groupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"), + profileId: "05\(otherKey)", + role: .moderator + ).insert(db) + + try Identity(variant: .x25519PublicKey, data: Data.data(fromHex: otherKey)!).save(db) + try Identity(variant: .x25519PrivateKey, data: Data.data(fromHex: TestConstants.privateKey)!).save(db) + try Identity(variant: .ed25519PublicKey, data: Data.data(fromHex: TestConstants.publicKey)!).save(db) + try Identity(variant: .ed25519SecretKey, data: Data.data(fromHex: TestConstants.edSecretKey)!).save(db) + } expect( OpenGroupManager.isUserModeratorOrAdmin( @@ -3281,29 +2871,6 @@ class OpenGroupManagerSpec: QuickSpec { } it("returns true if the key is the current users and the users unblinded id is a moderator or admin") { - let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") - mockOGMCache.when { $0.moderators } - .thenReturn([ - "testServer": [ - "testRoom": Set(arrayLiteral: "00\(otherKey)") - ] - ]) - mockIdentityManager - .when { $0.identityKeyPair() } - .thenReturn( - try! ECKeyPair( - publicKeyData: Data.data(fromHex: TestConstants.publicKey)!, - privateKeyData: Data.data(fromHex: TestConstants.privateKey)! - ) - ) - mockStorage - .when { $0.getUserED25519KeyPair() } - .thenReturn( - Box.KeyPair( - publicKey: Data.data(fromHex: otherKey)!.bytes, - secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes - ) - ) mockSodium .when { $0.blindedKeyPair( @@ -3318,6 +2885,20 @@ class OpenGroupManagerSpec: QuickSpec { secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes ) ) + mockStorage.write { db in + let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") + + try GroupMember( + groupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"), + profileId: "00\(otherKey)", + role: .moderator + ).insert(db) + + try Identity(variant: .x25519PublicKey, data: Data.data(fromHex: TestConstants.publicKey)!).save(db) + try Identity(variant: .x25519PrivateKey, data: Data.data(fromHex: TestConstants.privateKey)!).save(db) + try Identity(variant: .ed25519PublicKey, data: Data.data(fromHex: otherKey)!).save(db) + try Identity(variant: .ed25519SecretKey, data: Data.data(fromHex: TestConstants.edSecretKey)!).save(db) + } expect( OpenGroupManager.isUserModeratorOrAdmin( @@ -3373,15 +2954,24 @@ class OpenGroupManagerSpec: QuickSpec { } dependencies = dependencies.with(onionApi: TestRoomsApi.self) + mockStorage.write { db in + try OpenGroup.deleteAll(db) + + // This is done in the 'RetrieveDefaultOpenGroupRoomsJob' + _ = try OpenGroup( + server: OpenGroupAPI.defaultServer, + roomToken: "", + publicKey: OpenGroupAPI.defaultServerPublicKey, + isActive: false, + name: "", + userCount: 0, + infoUpdates: 0 + ) + .insert(db) + } + mockOGMCache.when { $0.defaultRoomsPromise }.thenReturn(nil) mockOGMCache.when { $0.groupImagePromises }.thenReturn([:]) - mockStorage - .when { $0.setOpenGroupPublicKey(for: any(), to: any(), using: anyAny())} - .thenReturn(()) - mockStorage.when { $0.getOpenGroupImage(for: any(), on: any()) }.thenReturn(nil) - mockStorage - .when { $0.setOpenGroupImage(to: any(), for: any(), on: any(), using: anyAny()) } - .thenReturn(()) mockUserDefaults.when { $0.object(forKey: any()) }.thenReturn(nil) mockUserDefaults.when { $0.set(anyAny(), forKey: any()) }.thenReturn(()) } @@ -3403,17 +2993,34 @@ class OpenGroupManagerSpec: QuickSpec { .to(equal(promise)) } - it("stores the public key information") { + it("stores the open group information") { OpenGroupManager.getDefaultRoomsIfNeeded(using: dependencies) - - expect(mockStorage) - .to(call(matchingParameters: true) { - $0.setOpenGroupPublicKey( - for: "http://116.203.70.33", - to: "a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238", - using: testTransaction! as Any - ) - }) + + expect(mockStorage.read { db in try OpenGroup.fetchCount(db) }).to(equal(1)) + expect( + mockStorage.read { db in + try OpenGroup + .select(.server) + .asRequest(of: String.self) + .fetchOne(db) + } + ).to(equal("http://116.203.70.33")) + expect( + mockStorage.read { db in + try OpenGroup + .select(.publicKey) + .asRequest(of: String.self) + .fetchOne(db) + } + ).to(equal("a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238")) + expect( + mockStorage.read { db in + try OpenGroup + .select(.isActive) + .asRequest(of: Bool.self) + .fetchOne(db) + } + ).to(beFalse()) } it("fetches rooms for the server") { @@ -3545,22 +3152,33 @@ class OpenGroupManagerSpec: QuickSpec { return try! JSONEncoder().encode(roomsData) } } - dependencies = dependencies.with(onionApi: TestRoomsApi.self) + let testDate: Date = Date(timeIntervalSince1970: 1234567890) + dependencies = dependencies.with( + onionApi: TestRoomsApi.self, + date: testDate + ) OpenGroupManager.getDefaultRoomsIfNeeded(using: dependencies) - expect(mockStorage) + expect(mockUserDefaults) .toEventually( call(matchingParameters: true) { - $0.setOpenGroupImage( - to: TestRoomsApi.mockResponse!, - for: "test2", - on: "http://116.203.70.33", - using: testTransaction! as Any + $0.set( + testDate, + forKey: SNUserDefaults.Date.lastOpenGroupImageUpdate.rawValue ) }, timeout: .milliseconds(50) ) + expect( + mockStorage.read { db in + try OpenGroup + .select(.imageData) + .filter(id: OpenGroup.idFor(roomToken: "test2", server: OpenGroupAPI.defaultServer)) + .asRequest(of: Data.self) + .fetchOne(db) + } + ).to(equal(TestRoomsApi.mockResponse!)) } } @@ -3575,63 +3193,80 @@ class OpenGroupManagerSpec: QuickSpec { mockUserDefaults.when { $0.object(forKey: any()) }.thenReturn(nil) mockUserDefaults.when { $0.set(anyAny(), forKey: any()) }.thenReturn(()) - mockStorage.when { $0.getOpenGroupImage(for: any(), on: any()) }.thenReturn(nil) - mockStorage - .when { $0.setOpenGroupImage(to: any(), for: any(), on: any(), using: anyAny()) } - .thenReturn(()) mockOGMCache.when { $0.groupImagePromises }.thenReturn([:]) + + mockStorage.write { db in + _ = try OpenGroup( + server: OpenGroupAPI.defaultServer, + roomToken: "testRoom", + publicKey: OpenGroupAPI.defaultServerPublicKey, + isActive: false, + name: "", + userCount: 0, + infoUpdates: 0 + ) + .insert(db) + } } it("retrieves the image retrieval promise from the cache if it exists") { let (promise, _) = Promise.pending() mockOGMCache .when { $0.groupImagePromises } - .thenReturn(["testServer.testRoom": promise]) + .thenReturn([OpenGroup.idFor(roomToken: "testRoom", server: "testServer"): promise]) - expect( + let promise2 = mockStorage.read { db in OpenGroupManager .roomImage( - 1, + db, + fileId: 1, for: "testRoom", on: "testServer", using: dependencies ) - ).to(equal(promise)) + } + expect(promise2).to(equal(promise)) } it("does not save the fetched image to storage") { - let promise = OpenGroupManager - .roomImage( - 1, - for: "testRoom", - on: "testServer", - using: dependencies - ) + let promise = mockStorage.read { db in + OpenGroupManager + .roomImage( + db, + fileId: 1, + for: "testRoom", + on: "testServer", + using: dependencies + ) + } promise.retainUntilComplete() expect(promise.isFulfilled).toEventually(beTrue(), timeout: .milliseconds(50)) - expect(mockStorage) - .toEventuallyNot( - call(matchingParameters: true) { - $0.setOpenGroupImage( - to: Data([1, 2, 3]), - for: "testRoom", - on: "testServer", - using: testTransaction! as Any - ) - }, - timeout: .milliseconds(50) - ) + expect( + mockStorage.read { db in + try OpenGroup + .select(.imageData) + .filter(id: OpenGroup.idFor(roomToken: "testRoom", server: "testServer")) + .asRequest(of: Data.self) + .fetchOne(db) + } + ).toEventually( + beNil(), + timeout: .milliseconds(50) + ) } it("does not update the image update timestamp") { - let promise = OpenGroupManager - .roomImage( - 1, - for: "testRoom", - on: "testServer", - using: dependencies - ) + let promise = mockStorage.read { db in + OpenGroupManager + .roomImage( + db, + fileId: 1, + for: "testRoom", + on: "testServer", + using: dependencies + ) + } promise.retainUntilComplete() expect(promise.isFulfilled).toEventually(beTrue(), timeout: .milliseconds(50)) @@ -3649,27 +3284,30 @@ class OpenGroupManagerSpec: QuickSpec { it("adds the image retrieval promise to the cache") { class TestNeverReturningApi: OnionRequestAPIType { - static func sendOnionRequest(_ request: URLRequest, to server: String, using version: OnionRequestAPI.Version, with x25519PublicKey: String) -> Promise<(OnionRequestResponseInfoType, Data?)> { + static func sendOnionRequest(_ request: URLRequest, to server: String, using version: OnionRequestAPIVersion, with x25519PublicKey: String) -> Promise<(OnionRequestResponseInfoType, Data?)> { return Promise<(OnionRequestResponseInfoType, Data?)>.pending().promise } - static func sendOnionRequest(to snode: Snode, invoking method: Snode.Method, with parameters: JSON, using version: OnionRequestAPI.Version, associatedWith publicKey: String?) -> Promise { + static func sendOnionRequest(to snode: Snode, invoking method: SnodeAPIEndpoint, with parameters: JSON, using version: OnionRequestAPIVersion, associatedWith publicKey: String?) -> Promise { return Promise.value(Data()) } } dependencies = dependencies.with(onionApi: TestNeverReturningApi.self) - let promise = OpenGroupManager.roomImage( - 1, - for: "testRoom", - on: "testServer", - using: dependencies - ) + let promise = mockStorage.read { db in + OpenGroupManager.roomImage( + db, + fileId: 1, + for: "testRoom", + on: "testServer", + using: dependencies + ) + } expect(mockOGMCache) .toEventually( call(matchingParameters: true) { - $0.groupImagePromises = ["testServer.testRoom": promise] + $0.groupImagePromises = [OpenGroup.idFor(roomToken: "testRoom", server: "testServer"): promise] }, timeout: .milliseconds(50) ) @@ -3679,13 +3317,17 @@ class OpenGroupManagerSpec: QuickSpec { it("fetches a new image if there is no cached one") { var result: Data? - let promise = OpenGroupManager - .roomImage( - 1, - for: "testRoom", - on: OpenGroupAPI.defaultServer, - using: dependencies - ) + let promise = mockStorage + .read { db in + OpenGroupManager + .roomImage( + db, + fileId: 1, + for: "testRoom", + on: OpenGroupAPI.defaultServer, + using: dependencies + ) + } .done { result = $0 } promise.retainUntilComplete() @@ -3694,38 +3336,44 @@ class OpenGroupManagerSpec: QuickSpec { } it("saves the fetched image to storage") { - let promise = OpenGroupManager - .roomImage( - 1, - for: "testRoom", - on: OpenGroupAPI.defaultServer, - using: dependencies - ) + let promise = mockStorage.read { db in + OpenGroupManager + .roomImage( + db, + fileId: 1, + for: "testRoom", + on: OpenGroupAPI.defaultServer, + using: dependencies + ) + } promise.retainUntilComplete() expect(promise.isFulfilled).toEventually(beTrue(), timeout: .milliseconds(50)) - expect(mockStorage) - .toEventually( - call(matchingParameters: true) { - $0.setOpenGroupImage( - to: Data([1, 2, 3]), - for: "testRoom", - on: OpenGroupAPI.defaultServer, - using: testTransaction! as Any - ) - }, - timeout: .milliseconds(50) - ) + expect( + mockStorage.read { db in + try OpenGroup + .select(.imageData) + .filter(id: OpenGroup.idFor(roomToken: "testRoom", server: OpenGroupAPI.defaultServer)) + .asRequest(of: Data.self) + .fetchOne(db) + } + ).toEventuallyNot( + beNil(), + timeout: .milliseconds(50) + ) } it("updates the image update timestamp") { - let promise = OpenGroupManager - .roomImage( - 1, - for: "testRoom", - on: OpenGroupAPI.defaultServer, - using: dependencies - ) + let promise = mockStorage.read { db in + OpenGroupManager + .roomImage( + db, + fileId: 1, + for: "testRoom", + on: OpenGroupAPI.defaultServer, + using: dependencies + ) + } promise.retainUntilComplete() expect(promise.isFulfilled).toEventually(beTrue(), timeout: .milliseconds(50)) @@ -3743,22 +3391,32 @@ class OpenGroupManagerSpec: QuickSpec { context("and there is a cached image") { beforeEach { + dependencies = dependencies.with(date: Date(timeIntervalSince1970: 1234567890)) mockUserDefaults.when { $0.object(forKey: any()) }.thenReturn(dependencies.date) - mockStorage - .when { $0.getOpenGroupImage(for: any(), on: any()) } - .thenReturn(Data([2, 3, 4])) + mockStorage.write(updates: { db in + try OpenGroup + .filter(id: OpenGroup.idFor(roomToken: "testRoom", server: OpenGroupAPI.defaultServer)) + .updateAll( + db, + OpenGroup.Columns.imageData.set(to: Data([2, 3, 4])) + ) + }) } it("retrieves the cached image") { var result: Data? - let promise = OpenGroupManager - .roomImage( - 1, - for: "testRoom", - on: OpenGroupAPI.defaultServer, - using: dependencies - ) + let promise = mockStorage + .read { db in + OpenGroupManager + .roomImage( + db, + fileId: 1, + for: "testRoom", + on: OpenGroupAPI.defaultServer, + using: dependencies + ) + } .done { result = $0 } promise.retainUntilComplete() @@ -3777,13 +3435,17 @@ class OpenGroupManagerSpec: QuickSpec { var result: Data? - let promise = OpenGroupManager - .roomImage( - 1, - for: "testRoom", - on: OpenGroupAPI.defaultServer, - using: dependencies - ) + let promise = mockStorage + .read { db in + OpenGroupManager + .roomImage( + db, + fileId: 1, + for: "testRoom", + on: OpenGroupAPI.defaultServer, + using: dependencies + ) + } .done { result = $0 } promise.retainUntilComplete() diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverDecryptionSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverDecryptionSpec.swift index abd8393aa..f083cb217 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverDecryptionSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverDecryptionSpec.swift @@ -21,11 +21,17 @@ class MessageReceiverDecryptionSpec: QuickSpec { var mockSign: MockSign! var mockAeadXChaCha: MockAeadXChaCha20Poly1305Ietf! var mockNonce24Generator: MockNonce24Generator! - var dependencies: Dependencies! + var dependencies: SMKDependencies! describe("a MessageReceiver") { beforeEach { - mockStorage = GRDBStorage(customWriter: DatabaseQueue()) + mockStorage = GRDBStorage( + customWriter: DatabaseQueue(), + customMigrations: [ + SNUtilitiesKit.migrations(), + SNMessagingKit.migrations() + ] + ) mockSodium = MockSodium() mockBox = MockBox() mockGenericHash = MockGenericHash() @@ -37,7 +43,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { .when { $0.encrypt(message: anyArray(), secretKey: anyArray(), nonce: anyArray()) } .thenReturn(nil) - dependencies = Dependencies( + dependencies = SMKDependencies( storage: mockStorage, sodium: mockSodium, box: mockBox, @@ -111,7 +117,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.privateKey)!.bytes ), - dependencies: Dependencies() + dependencies: SMKDependencies() ) expect(String(data: (result?.plaintext ?? Data()), encoding: .utf8)).to(equal("TestMessage")) @@ -217,7 +223,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { publicKey: Data.data(fromHex: TestConstants.edPublicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes ), - using: Dependencies() + using: SMKDependencies() ) expect(String(data: (result?.plaintext ?? Data()), encoding: .utf8)).to(equal("TestMessage")) diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageSenderEncryptionSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageSenderEncryptionSpec.swift index 6e11b9ee3..c8966abb7 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageSenderEncryptionSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageSenderEncryptionSpec.swift @@ -18,16 +18,22 @@ class MessageSenderEncryptionSpec: QuickSpec { var mockBox: MockBox! var mockSign: MockSign! var mockNonce24Generator: MockNonce24Generator! - var dependencies: Dependencies! + var dependencies: SMKDependencies! describe("a MessageSender") { beforeEach { - mockStorage = GRDBStorage(customWriter: DatabaseQueue()) + mockStorage = GRDBStorage( + customWriter: DatabaseQueue(), + customMigrations: [ + SNUtilitiesKit.migrations(), + SNMessagingKit.migrations() + ] + ) mockBox = MockBox() mockSign = MockSign() mockNonce24Generator = MockNonce24Generator() - dependencies = Dependencies( + dependencies = SMKDependencies( storage: mockStorage, box: mockBox, sign: mockSign, @@ -53,7 +59,7 @@ class MessageSenderEncryptionSpec: QuickSpec { let result = try? MessageSender.encryptWithSessionProtocol( "TestMessage".data(using: .utf8)!, for: "05\(TestConstants.publicKey)", - using: Dependencies(storage: mockStorage) + using: SMKDependencies(storage: mockStorage) ) // Note: A Nonce is used for this so we can't compare the exact value when not mocked diff --git a/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift b/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift index b4a57bd99..d7e2e0da0 100644 --- a/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift +++ b/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift @@ -6,7 +6,7 @@ import SessionUtilitiesKit @testable import SessionMessagingKit -extension Dependencies { +extension SMKDependencies { public func with( onionApi: OnionRequestAPIType.Type? = nil, generalCache: Atomic? = nil, @@ -21,8 +21,8 @@ extension Dependencies { nonceGenerator24: NonceGenerator24ByteType? = nil, standardUserDefaults: UserDefaultsType? = nil, date: Date? = nil - ) -> Dependencies { - return Dependencies( + ) -> SMKDependencies { + return SMKDependencies( onionApi: (onionApi ?? self._onionApi), generalCache: (generalCache ?? self._generalCache), storage: (storage ?? self._storage), diff --git a/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift b/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift index 3bea2f036..31bace48f 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift @@ -2,6 +2,7 @@ import Foundation import PromiseKit +import SessionUtilitiesKit @testable import SessionMessagingKit @@ -26,16 +27,6 @@ class MockOGMCache: Mock, OGMCacheType { set { accept(args: [newValue]) } } - var moderators: [String: [String: Set]] { - get { return accept() as! [String: [String: Set]] } - set { accept(args: [newValue]) } - } - - var admins: [String: [String: Set]] { - get { return accept() as! [String: [String: Set]] } - set { accept(args: [newValue]) } - } - var hasPerformedInitialPoll: [String: Bool] { get { return accept() as! [String: Bool] } set { accept(args: [newValue]) } diff --git a/SessionMessagingKitTests/_TestUtilities/MockUserDefaults.swift b/SessionMessagingKitTests/_TestUtilities/MockUserDefaults.swift index 4814016a5..dcf3f41b4 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockUserDefaults.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockUserDefaults.swift @@ -4,8 +4,6 @@ import Foundation import SessionUtilitiesKit class MockUserDefaults: Mock, UserDefaultsType { - var storage: [String: Any] = [:] - func object(forKey defaultName: String) -> Any? { return accept(args: [defaultName]) } func string(forKey defaultName: String) -> String? { return accept(args: [defaultName]) as? String } func array(forKey defaultName: String) -> [Any]? { return accept(args: [defaultName]) as? [Any] } diff --git a/SessionMessagingKitTests/_TestUtilities/MockedExtensions.swift b/SessionMessagingKitTests/_TestUtilities/MockedExtensions.swift deleted file mode 100644 index 021b59581..000000000 --- a/SessionMessagingKitTests/_TestUtilities/MockedExtensions.swift +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionMessagingKit - -extension OpenGroup: Mocked { - static var mockValue: OpenGroup = OpenGroup( - server: any(), - roomToken: any(), - publicKey: TestConstants.publicKey, - isActive: any(), - name: any(), - roomDescription: any(), - imageId: any(), - imageData: any(), - userCount: any(), - infoUpdates: any(), - sequenceNumber: any(), - inboxLatestMessageId: any(), - outboxLatestMessageId: any() - ) -} - -extension VisibleMessage: Mocked { - static var mockValue: VisibleMessage = VisibleMessage(text: "") -} - -extension BlindedIdMapping: Mocked { - static var mockValue: BlindedIdMapping = BlindedIdMapping( - blindedId: any(), - sessionId: any(), - serverPublicKey: any() - ) -} diff --git a/SessionMessagingKitTests/_TestUtilities/TestContactThread.swift b/SessionMessagingKitTests/_TestUtilities/TestContactThread.swift deleted file mode 100644 index 6150599eb..000000000 --- a/SessionMessagingKitTests/_TestUtilities/TestContactThread.swift +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionMessagingKit - -// FIXME: Turn this into a protocol to make mocking possible -class TestContactThread: TSContactThread, Mockable { - // MARK: - Mockable - - enum DataKey: Hashable { - case uniqueId - case interactions - } - - typealias Key = DataKey - - var mockData: [DataKey: Any] = [:] - var numSaveCalls: Int = 0 - var didCallRemoveAllThreadInteractions: Bool = false - var didCallRemove: Bool = false - - // MARK: - TSContactThread - - override var uniqueId: String? { - get { (mockData[.uniqueId] as? String) } - set {} - } - - override func enumerateInteractions(_ block: @escaping (TSInteraction) -> Void) { - ((mockData[.interactions] as? [TSInteraction]) ?? []).forEach(block) - } - - override func enumerateInteractions(with transaction: YapDatabaseReadTransaction, using block: @escaping (TSInteraction, UnsafeMutablePointer) -> Void) { - var stop: ObjCBool = false - for interaction in ((mockData[.interactions] as? [TSInteraction]) ?? []) { - block(interaction, &stop) - - if stop.boolValue { break } - } - } - - override func removeAllThreadInteractions(with transaction: YapDatabaseReadWriteTransaction) { - didCallRemoveAllThreadInteractions = true - } - - override func remove(with transaction: YapDatabaseReadWriteTransaction) { - didCallRemove = true - } - - override func save(with transaction: YapDatabaseReadWriteTransaction) { numSaveCalls += 1 } -} diff --git a/SessionMessagingKitTests/_TestUtilities/TestGroupThread.swift b/SessionMessagingKitTests/_TestUtilities/TestGroupThread.swift deleted file mode 100644 index 49cff1341..000000000 --- a/SessionMessagingKitTests/_TestUtilities/TestGroupThread.swift +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionMessagingKit - -// FIXME: Turn this into a protocol to make mocking possible -class TestGroupThread: TSGroupThread, Mockable { - // MARK: - Mockable - - enum DataKey: Hashable { - case uniqueId - case groupModel - case interactions - } - - typealias Key = DataKey - - var mockData: [DataKey: Any] = [:] - var numSaveCalls: Int = 0 - var didCallRemoveAllThreadInteractions: Bool = false - var didCallRemove: Bool = false - - // MARK: - TSGroupThread - - override var uniqueId: String? { - get { (mockData[.uniqueId] as? String) } - set {} - } - - override var groupModel: TSGroupModel { - get { (mockData[.groupModel] as! TSGroupModel) } - set { mockData[.groupModel] = newValue } - } - - override func enumerateInteractions(_ block: @escaping (TSInteraction) -> Void) { - ((mockData[.interactions] as? [TSInteraction]) ?? []).forEach(block) - } - - override func enumerateInteractions(with transaction: YapDatabaseReadTransaction, using block: @escaping (TSInteraction, UnsafeMutablePointer) -> Void) { - var stop: ObjCBool = false - for interaction in ((mockData[.interactions] as? [TSInteraction]) ?? []) { - block(interaction, &stop) - - if stop.boolValue { break } - } - } - - override func removeAllThreadInteractions(with transaction: YapDatabaseReadWriteTransaction) { - didCallRemoveAllThreadInteractions = true - } - - override func remove(with transaction: YapDatabaseReadWriteTransaction) { - didCallRemove = true - } - - override func save(with transaction: YapDatabaseReadWriteTransaction) { numSaveCalls += 1 } -} diff --git a/SessionMessagingKitTests/_TestUtilities/TestIncomingMessage.swift b/SessionMessagingKitTests/_TestUtilities/TestIncomingMessage.swift deleted file mode 100644 index 66743e49e..000000000 --- a/SessionMessagingKitTests/_TestUtilities/TestIncomingMessage.swift +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionMessagingKit - -// FIXME: Turn this into a protocol to make mocking possible -class TestIncomingMessage: TSIncomingMessage, Mockable { - // MARK: - Mockable - - enum DataKey: Hashable { - } - - typealias Key = DataKey - - var mockData: [DataKey: Any] = [:] - var didCallSave: Bool = false - var didCallRemove: Bool = false - - // MARK: - TSInteraction - - override func save(with transaction: YapDatabaseReadWriteTransaction) { didCallSave = true } - override func remove(with transaction: YapDatabaseReadWriteTransaction) { didCallRemove = true } -} diff --git a/SessionMessagingKitTests/_TestUtilities/TestInteraction.swift b/SessionMessagingKitTests/_TestUtilities/TestInteraction.swift deleted file mode 100644 index 1c8a85160..000000000 --- a/SessionMessagingKitTests/_TestUtilities/TestInteraction.swift +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionMessagingKit - -// FIXME: Turn this into a protocol to make mocking possible -class TestInteraction: TSInteraction, Mockable { - // MARK: - Mockable - - enum DataKey: Hashable { - case uniqueId - case timestamp - } - - typealias Key = DataKey - - var mockData: [DataKey: Any] = [:] - var didCallSave: Bool = false - - // MARK: - TSInteraction - - override var uniqueId: String? { - get { (mockData[.uniqueId] as? String) } - set { mockData[.uniqueId] = newValue } - } - - override var timestamp: UInt64 { - (mockData[.timestamp] as! UInt64) - } - - override func save(with transaction: YapDatabaseReadWriteTransaction) { didCallSave = true } -} diff --git a/SessionMessagingKitTests/_TestUtilities/TestTransaction.swift b/SessionMessagingKitTests/_TestUtilities/TestTransaction.swift deleted file mode 100644 index bfe5f0e4c..000000000 --- a/SessionMessagingKitTests/_TestUtilities/TestTransaction.swift +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import YapDatabase - -// FIXME: Turn this into a protocol to make mocking possible -final class TestTransaction: YapDatabaseReadWriteTransaction, Mockable { - // MARK: - Mockable - - enum DataKey: Hashable { - case objectForKey - } - - typealias Key = DataKey - - var mockData: [DataKey: Any] = [:] - - // MARK: - YapDatabaseReadWriteTransaction - - override func object(forKey key: String, inCollection collection: String?) -> Any? { - if let dictionary: [String: Any] = mockData[.objectForKey] as? [String: Any] { - return dictionary[key] - } - - return mockData[.objectForKey] - } - - override func addCompletionQueue(_ completionQueue: DispatchQueue?, completionBlock: @escaping () -> Void) { - completionBlock() - } -} - -extension TestTransaction: Mocked { - static var mockValue: TestTransaction = TestTransaction() -} diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index 446d1048a..75119f337 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -108,7 +108,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension if let sender: String = callMessage.sender, let interaction: Interaction = try MessageReceiver.insertCallInfoMessage(db, for: callMessage, state: .permissionDenied) { let thread: SessionThread = try SessionThread.fetchOrCreate(db, id: sender, variant: .contact) - Environment.shared.notificationsManager.wrappedValue? + Environment.shared?.notificationsManager.wrappedValue? .notifyUser( db, forIncomingCall: interaction, @@ -168,7 +168,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension AppSetup.setupEnvironment( appSpecificBlock: { - Environment.shared.notificationsManager.mutate { + Environment.shared?.notificationsManager.mutate { $0 = NSENotificationPresenter() } }, diff --git a/SessionNotificationServiceExtension/NotificationServiceExtensionContext.swift b/SessionNotificationServiceExtension/NotificationServiceExtensionContext.swift index 469f280e1..c7e89d012 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtensionContext.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtensionContext.swift @@ -56,10 +56,6 @@ final class NotificationServiceExtensionContext : NSObject, AppContext { return userDefaults } - func keychainStorage() -> SSKKeychainStorage { - return SSKDefaultKeychainStorage.shared - } - // MARK: - Currently Unused let frame = CGRect.zero diff --git a/SessionShareExtension/ShareAppExtensionContext.swift b/SessionShareExtension/ShareAppExtensionContext.swift index e25e91f47..f5fbcc86a 100644 --- a/SessionShareExtension/ShareAppExtensionContext.swift +++ b/SessionShareExtension/ShareAppExtensionContext.swift @@ -142,10 +142,6 @@ final class ShareAppExtensionContext: NSObject, AppContext { return rootViewController.findFrontmostViewController(true) } - func keychainStorage() -> SSKKeychainStorage { - return SSKDefaultKeychainStorage.shared - } - func appDocumentDirectoryPath() -> String { let targetPath: String? = FileManager.default .urls( diff --git a/SessionShareExtension/ShareVC.swift b/SessionShareExtension/ShareVC.swift index 92a85f0ad..49541e6f5 100644 --- a/SessionShareExtension/ShareVC.swift +++ b/SessionShareExtension/ShareVC.swift @@ -44,7 +44,7 @@ final class ShareVC : UINavigationController, ShareViewDelegate, AppModeManagerD AppSetup.setupEnvironment( appSpecificBlock: { - Environment.shared.notificationsManager.mutate { + Environment.shared?.notificationsManager.mutate { $0 = NoopNotificationsManager() } }, diff --git a/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift index 9bb714b0f..4ca4a6730 100644 --- a/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -52,6 +52,6 @@ enum _001_InitialSetupMigration: Migration { t.uniqueKey([.key, .hash]) } - GRDBStorage.shared.update(progress: 1, for: self, in: target) // In case this is the last migration + GRDBStorage.update(progress: 1, for: self, in: target) // In case this is the last migration } } diff --git a/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift index 94d722639..9bf961039 100644 --- a/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -21,6 +21,6 @@ enum _002_SetupStandardJobs: Migration { ).inserted(db) } - GRDBStorage.shared.update(progress: 1, for: self, in: target) // In case this is the last migration + GRDBStorage.update(progress: 1, for: self, in: target) // In case this is the last migration } } diff --git a/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 7fad36bc4..0eae08fa3 100644 --- a/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -67,7 +67,7 @@ enum _003_YDBToGRDBMigration: Migration { snodeSetResult["\(SnodeSet.onionRequestPathPrefix)1"] = [ path1Snode0, path1Snode1, path1Snode2 ] } } - GRDBStorage.shared.update(progress: 0.02, for: self, in: target) + GRDBStorage.update(progress: 0.02, for: self, in: target) // MARK: --SnodePool @@ -100,7 +100,7 @@ enum _003_YDBToGRDBMigration: Migration { collectionIndex += 1 - GRDBStorage.shared.update( + GRDBStorage.update( progress: min( swarmCompleteProgress, ((collectionIndex / roughNumCollections) * (swarmCompleteProgress - startProgress)) @@ -109,7 +109,7 @@ enum _003_YDBToGRDBMigration: Migration { in: target ) } - GRDBStorage.shared.update(progress: swarmCompleteProgress, for: self, in: target) + GRDBStorage.update(progress: swarmCompleteProgress, for: self, in: target) for swarmCollection in swarmCollections { let collection: String = "\(SSKLegacy.swarmCollectionPrefix)\(swarmCollection)" @@ -120,7 +120,7 @@ enum _003_YDBToGRDBMigration: Migration { snodeSetResult[swarmCollection] = (snodeSetResult[swarmCollection] ?? Set()).inserting(snode) } } - GRDBStorage.shared.update(progress: 0.92, for: self, in: target) + GRDBStorage.update(progress: 0.92, for: self, in: target) // MARK: --Received message hashes @@ -128,7 +128,7 @@ enum _003_YDBToGRDBMigration: Migration { guard let hashSet = object as? Set else { return } receivedMessageResults[key] = hashSet } - GRDBStorage.shared.update(progress: 0.93, for: self, in: target) + GRDBStorage.update(progress: 0.93, for: self, in: target) // MARK: --Last message info @@ -141,7 +141,7 @@ enum _003_YDBToGRDBMigration: Migration { lastMessageResults[key] = (lastMessageHash, lastMessageJson) receivedMessageResults[key] = receivedMessageResults[key]?.removing(lastMessageHash) } - GRDBStorage.shared.update(progress: 0.94, for: self, in: target) + GRDBStorage.update(progress: 0.94, for: self, in: target) } // MARK: - Insert into GRDB @@ -161,7 +161,7 @@ enum _003_YDBToGRDBMigration: Migration { x25519PublicKey: legacySnode.publicKeySet.x25519Key ).insert(db) } - GRDBStorage.shared.update(progress: 0.96, for: self, in: target) + GRDBStorage.update(progress: 0.96, for: self, in: target) // MARK: --SnodeSets @@ -176,7 +176,7 @@ enum _003_YDBToGRDBMigration: Migration { ).insert(db) } } - GRDBStorage.shared.update(progress: 0.98, for: self, in: target) + GRDBStorage.update(progress: 0.98, for: self, in: target) } try autoreleasepool { @@ -191,7 +191,7 @@ enum _003_YDBToGRDBMigration: Migration { ).inserted(db) } } - GRDBStorage.shared.update(progress: 0.99, for: self, in: target) + GRDBStorage.update(progress: 0.99, for: self, in: target) // MARK: --Last Message Hash @@ -209,6 +209,6 @@ enum _003_YDBToGRDBMigration: Migration { } } - GRDBStorage.shared.update(progress: 1, for: self, in: target) // In case this is the last migration + GRDBStorage.update(progress: 1, for: self, in: target) // In case this is the last migration } } diff --git a/SessionUtilitiesKit/Database/GRDBStorage.swift b/SessionUtilitiesKit/Database/GRDBStorage.swift index 6d9f030dd..89e5ecb42 100644 --- a/SessionUtilitiesKit/Database/GRDBStorage.swift +++ b/SessionUtilitiesKit/Database/GRDBStorage.swift @@ -36,13 +36,24 @@ public final class GRDBStorage { // GRDBStorage.deleteDatabaseFiles() // TODO: Remove this. // try! GRDBStorage.deleteDbKeys() // TODO: Remove this. // } - public init(customWriter: DatabaseWriter? = nil) { + public init( + customWriter: DatabaseWriter? = nil, + customMigrations: [TargetMigrations]? = 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 OWSFileSystem.ensureDirectoryExists(GRDBStorage.sharedDatabaseDirectoryPath) OWSFileSystem.protectFileOrFolder(atPath: GRDBStorage.sharedDatabaseDirectoryPath) + // If a custom writer was provided then use that (for unit testing) + guard customWriter == nil else { + dbWriter = customWriter + isValid = true + perform(migrations: (customMigrations ?? []), async: false, onProgressUpdate: nil, onComplete: { _, _ in }) + return + } + // Generate the database KeySpec if needed (this MUST be done before we try to access the database // as a different thread might attempt to access the database before the key is successfully created) // @@ -76,13 +87,6 @@ public final class GRDBStorage { try db.execute(sql: "PRAGMA cipher_plaintext_header_size = 32") } - // If a custom writer was provided then use that (for unit testing) - guard customWriter == nil else { - dbWriter = customWriter - isValid = true - return - } - // Create the DatabasePool to allow us to connect to the database and mark the storage as valid do { dbWriter = try DatabasePool( @@ -98,6 +102,7 @@ public final class GRDBStorage { public func perform( migrations: [TargetMigrations], + async: Bool = true, onProgressUpdate: ((CGFloat, TimeInterval) -> ())?, onComplete: @escaping (Bool, Bool) -> () ) { @@ -174,7 +179,8 @@ public final class GRDBStorage { self.migrationProgressUpdater?.wrappedValue(firstMigrationKey, 0) } - self.migrator?.asyncMigrate(dbWriter) { [weak self] _, error in + // Store the logic to run when the migration completes + let migrationCompleted: (Error?) -> () = { [weak self] error in self?.hasCompletedMigrations = true self?.migrationProgressUpdater = nil SUKLegacy.clearLegacyDatabaseInstance() @@ -185,20 +191,37 @@ public final class GRDBStorage { onComplete((error == nil), needsConfigSync) } + + // Note: The non-async migration should only be used for unit tests + guard async else { + do { try self.migrator?.migrate(dbWriter) } + catch { migrationCompleted(error) } + return + } + + self.migrator?.asyncMigrate(dbWriter) { _, error in + migrationCompleted(error) + } } - public func update( + public static func update( progress: CGFloat, for migration: Migration.Type, in target: TargetMigrations.Identifier ) { + // In test builds ignore any migration progress updates (we run in a custom database writer anyway), + // this code should be the same as 'CurrentAppContext().isRunningTests' but since the tests can run + // without being attached to a host application the `CurrentAppContext` might not have been set and + // would crash as it gets force-unwrapped - better to just do the check explicitly instead + guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil else { return } + GRDBStorage.shared.migrationProgressUpdater?.wrappedValue(target.key(with: migration), progress) } // MARK: - Security private static func getDatabaseCipherKeySpec() throws -> Data { - return try CurrentAppContext().keychainStorage().data(forService: keychainService, key: dbCipherKeySpecKey) + return try SSKDefaultKeychainStorage.shared.data(forService: keychainService, key: dbCipherKeySpecKey) } @discardableResult private static func getOrGenerateDatabaseKeySpec() -> Data { @@ -228,7 +251,7 @@ public final class GRDBStorage { var keySpec: Data = Randomness.generateRandomBytes(kSQLCipherKeySpecLength) defer { keySpec.resetBytes(in: 0..)keychainStorage; - - (NSString *)appDocumentDirectoryPath; - (NSString *)appSharedDataDirectoryPath; diff --git a/SessionUtilitiesKit/General/Dependencies.swift b/SessionUtilitiesKit/General/Dependencies.swift new file mode 100644 index 000000000..3e7525fcc --- /dev/null +++ b/SessionUtilitiesKit/General/Dependencies.swift @@ -0,0 +1,55 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +open class Dependencies { + public var _generalCache: Atomic? + public var generalCache: Atomic { + get { Dependencies.getValueSettingIfNull(&_generalCache) { General.cache } } + set { _generalCache = newValue } + } + + public var _storage: GRDBStorage? + public var storage: GRDBStorage { + get { Dependencies.getValueSettingIfNull(&_storage) { GRDBStorage.shared } } + set { _storage = newValue } + } + + public var _standardUserDefaults: UserDefaultsType? + public var standardUserDefaults: UserDefaultsType { + get { Dependencies.getValueSettingIfNull(&_standardUserDefaults) { UserDefaults.standard } } + set { _standardUserDefaults = newValue } + } + + public var _date: Date? + public var date: Date { + get { Dependencies.getValueSettingIfNull(&_date) { Date() } } + set { _date = newValue } + } + + // MARK: - Initialization + + public init( + generalCache: Atomic? = nil, + storage: GRDBStorage? = nil, + standardUserDefaults: UserDefaultsType? = nil, + date: Date? = nil + ) { + _generalCache = generalCache + _storage = storage + _standardUserDefaults = standardUserDefaults + _date = date + } + + // MARK: - Convenience + + public static func getValueSettingIfNull(_ maybeValue: inout T?, _ valueGenerator: () -> T) -> T { + guard let value: T = maybeValue else { + let value: T = valueGenerator() + maybeValue = value + return value + } + + return value + } +} diff --git a/SessionUtilitiesKit/General/General.swift b/SessionUtilitiesKit/General/General.swift index f3d08c7b6..7fd3487e0 100644 --- a/SessionUtilitiesKit/General/General.swift +++ b/SessionUtilitiesKit/General/General.swift @@ -20,13 +20,13 @@ public enum GeneralError: Error { case keyGenerationFailed } -public func getUserHexEncodedPublicKey(_ db: Database? = nil) -> String { - if let cachedKey: String = General.cache.wrappedValue.encodedPublicKey { return cachedKey } +public func getUserHexEncodedPublicKey(_ db: Database? = nil, dependencies: Dependencies = Dependencies()) -> String { + if let cachedKey: String = dependencies.generalCache.wrappedValue.encodedPublicKey { return cachedKey } if let publicKey: Data = Identity.fetchUserPublicKey(db) { // Can be nil under some circumstances let sessionId: SessionId = SessionId(.standard, publicKey: publicKey.bytes) - General.cache.mutate { $0.encodedPublicKey = sessionId.hexString } + dependencies.generalCache.mutate { $0.encodedPublicKey = sessionId.hexString } return sessionId.hexString } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/OWSVideoPlayer.swift b/SignalUtilitiesKit/Media Viewing & Editing/OWSVideoPlayer.swift index 12f1e85f6..c99513091 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/OWSVideoPlayer.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/OWSVideoPlayer.swift @@ -4,6 +4,7 @@ import Foundation import AVFoundation +import SessionMessagingKit public protocol OWSVideoPlayerDelegate: AnyObject { func videoPlayerDidPlayToCompletion(_ videoPlayer: OWSVideoPlayer) @@ -26,23 +27,17 @@ public class OWSVideoPlayer { object: avPlayer.currentItem) } - // MARK: Dependencies - - var audioSession: OWSAudioSession { - return Environment.shared.audioSession - } - // MARK: Playback Controls @objc public func pause() { avPlayer.pause() - audioSession.endAudioActivity(self.audioActivity) + Environment.shared?.audioSession.endAudioActivity(self.audioActivity) } @objc public func play() { - let success = audioSession.startAudioActivity(self.audioActivity) + let success = (Environment.shared?.audioSession.startAudioActivity(self.audioActivity) == true) assert(success) guard let item = avPlayer.currentItem else { @@ -62,7 +57,7 @@ public class OWSVideoPlayer { public func stop() { avPlayer.pause() avPlayer.seek(to: CMTime.zero, toleranceBefore: .zero, toleranceAfter: .zero) - audioSession.endAudioActivity(self.audioActivity) + Environment.shared?.audioSession.endAudioActivity(self.audioActivity) } @objc(seekToTime:) @@ -75,6 +70,6 @@ public class OWSVideoPlayer { @objc private func playerItemDidPlayToCompletion(_ notification: Notification) { self.delegate?.videoPlayerDidPlayToCompletion(self) - audioSession.endAudioActivity(self.audioActivity) + Environment.shared?.audioSession.endAudioActivity(self.audioActivity) } } From 12f1e955341bf6764715ed2e54aa86ea6dfbdb88 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 22 Jun 2022 14:30:14 +1000 Subject: [PATCH 111/157] Changed a missing attachment download job to be a warning instead of a failure --- Session.xcodeproj/project.pbxproj | 4 ---- .../Database/Migrations/_003_YDBToGRDBMigration.swift | 5 +++-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 16e425017..a47bfd9a7 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -741,7 +741,6 @@ FDC290A827D9B46D005DAE71 /* NimbleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */; }; FDC290A927D9B46D005DAE71 /* NimbleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */; }; FDC290AA27D9B6FD005DAE71 /* Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A527D860CE005DAE71 /* Mock.swift */; }; - FDC290AC27DB0B1C005DAE71 /* MockedExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290AB27DB0B1C005DAE71 /* MockedExtensions.swift */; }; FDC290B327DFF9F5005DAE71 /* TestOnionRequestAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290B227DFF9F5005DAE71 /* TestOnionRequestAPI.swift */; }; FDC4380927B31D4E00C60D73 /* SOGSError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4380827B31D4E00C60D73 /* SOGSError.swift */; }; FDC4381527B329CE00C60D73 /* NonceGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381427B329CE00C60D73 /* NonceGenerator.swift */; }; @@ -1778,7 +1777,6 @@ FDC2909D27D85751005DAE71 /* OpenGroupManagerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupManagerSpec.swift; sourceTree = ""; }; FDC290A527D860CE005DAE71 /* Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mock.swift; sourceTree = ""; }; FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NimbleExtensions.swift; sourceTree = ""; }; - FDC290AB27DB0B1C005DAE71 /* MockedExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockedExtensions.swift; sourceTree = ""; }; FDC290B227DFF9F5005DAE71 /* TestOnionRequestAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestOnionRequestAPI.swift; sourceTree = ""; }; FDC4380827B31D4E00C60D73 /* SOGSError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSError.swift; sourceTree = ""; }; FDC4381427B329CE00C60D73 /* NonceGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonceGenerator.swift; sourceTree = ""; }; @@ -3862,7 +3860,6 @@ FD83B9D127D59495005E1583 /* MockUserDefaults.swift */, FD078E4C27E17156000769AF /* MockOGMCache.swift */, FDC290B227DFF9F5005DAE71 /* TestOnionRequestAPI.swift */, - FDC290AB27DB0B1C005DAE71 /* MockedExtensions.swift */, FD078E4E27E175F1000769AF /* DependencyExtensions.swift */, FD078E5127E1760A000769AF /* OGMDependencyExtensions.swift */, ); @@ -5451,7 +5448,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - FDC290AC27DB0B1C005DAE71 /* MockedExtensions.swift in Sources */, FD3C905C27E3FBEF00CD579F /* BatchRequestInfoSpec.swift in Sources */, FD859EFA27C2F5C500510D0C /* MockGenericHash.swift in Sources */, FDC2909427D710B4005DAE71 /* SOGSEndpointSpec.swift in Sources */, diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 750d491be..1faa74994 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -1304,8 +1304,9 @@ enum _003_YDBToGRDBMigration: Migration { return } guard processedAttachmentIds.contains(legacyJob.attachmentID) else { - SNLog("[Migration Error] attachmentDownload job unable to find attachment") - throw StorageError.migrationFailed + // Unsure how this case can occur but it seemed to happen when testing internally + SNLog("[Migration Warning] attachmentDownload job unable to find attachment - ignoring") + return } _ = try Job( From 4a29ad1f4fcd68b6ed47d3b86db7b3a3be22f0b9 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 22 Jun 2022 18:32:17 +1000 Subject: [PATCH 112/157] Fixed a few bugs with scrolling behaviour on the conversation screen Fixed a couple of bugs with in-conversation search --- Session.xcodeproj/project.pbxproj | 8 +- .../Conversations/ConversationSearch.swift | 15 ++- Session/Conversations/ConversationVC.swift | 119 ++++++++---------- .../Types/PagedDatabaseObserver.swift | 2 +- 4 files changed, 66 insertions(+), 78 deletions(-) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index a47bfd9a7..19b991c44 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -6818,7 +6818,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 346; + CURRENT_PROJECT_VERSION = 350; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -6857,7 +6857,7 @@ "$(SRCROOT)", ); LLVM_LTO = NO; - MARKETING_VERSION = 1.12.9; + MARKETING_VERSION = 2.0.0; OTHER_LDFLAGS = "$(inherited)"; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger"; @@ -6890,7 +6890,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 346; + CURRENT_PROJECT_VERSION = 350; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -6929,7 +6929,7 @@ "$(SRCROOT)", ); LLVM_LTO = NO; - MARKETING_VERSION = 1.12.9; + MARKETING_VERSION = 2.0.0; OTHER_LDFLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger"; PRODUCT_NAME = Session; diff --git a/Session/Conversations/ConversationSearch.swift b/Session/Conversations/ConversationSearch.swift index c0c0b6279..f1e365b37 100644 --- a/Session/Conversations/ConversationSearch.swift +++ b/Session/Conversations/ConversationSearch.swift @@ -218,12 +218,15 @@ public final class SearchResultsBar: UIView { } func updateResults(results: [Int64]?) { - if let results: [Int64] = results, !results.isEmpty { - currentIndex = min(currentIndex ?? 0, results.count - 1) - } - else { - currentIndex = nil - } + currentIndex = { + guard let results: [Int64] = results, !results.isEmpty else { return nil } + + if let currentIndex: Int = currentIndex { + return max(0, min(currentIndex, results.count - 1)) + } + + return 0 + }() self.results = results diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 993c9e8b0..3ac99bbdb 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -715,10 +715,10 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers // If anything was inserted at the top then we need to maintain the current // offset so always return a 'top' insert location - switch (insertedAtTop, insertedAtBot) { - case (true, _): return .top - case (false, true): return .bottom - case (false, false): return .other + switch (insertedAtTop, insertedAtBot, isLoadingMore) { + case (true, _, true), (true, false, false): return .top + case (false, true, _): return .bottom + case (false, false, _), (true, true, false): return .other } }(), wasCloseToBottom: isCloseToBottom, @@ -737,7 +737,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers /// /// Unfortunately the UITableView also does some weird things when updating (where it won't have updated it's internal data until /// after it performs the next layout); the below code checks a condition on layout and if it passes it calls a closure - if itemChangeInfo.insertLocation != .none { + if itemChangeInfo.insertLocation == .top { let cellSorting: (MessageCell, MessageCell) -> Bool = { lhs, rhs -> Bool in if !lhs.isHidden && rhs.isHidden { return true } if lhs.isHidden && !rhs.isHidden { return false } @@ -750,9 +750,12 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers .first(where: { cell -> Bool in cell.viewModel?.id == itemChangeInfo.visibleInteractionId })? .frame) .defaulting(to: self.tableView.rectForRow(at: itemChangeInfo.oldVisibleIndexPath)) - let oldContentSize: CGSize = self.tableView.contentSize - let oldOffsetFromTop: CGFloat = (self.tableView.contentOffset.y - oldRect.minY) - let oldOffsetFromBottom: CGFloat = (oldContentSize.height - self.tableView.contentOffset.y) + + // The the user triggered the 'scrollToTop' animation (by tapping in the nav bar) then we + // need to stop the animation before attempting to lock the offset (otherwise things break) + if itemChangeInfo.firstIndexIsVisible { + self.tableView.setContentOffset(self.tableView.contentOffset, animated: false) + } // Wait until the tableView has completed a layout and reported the correct number of // sections/rows and then update the contentOffset @@ -763,63 +766,26 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers }, then: { [weak self] in UIView.performWithoutAnimation { - self?.tableView.scrollToRow( - at: (itemChangeInfo.insertLocation == .top ? - itemChangeInfo.visibleIndexPath : - itemChangeInfo.lastVisibleIndexPath - ), - at: (itemChangeInfo.insertLocation == .top ? - .top : - .bottom - ), - animated: false - ) - self?.tableView.layoutIfNeeded() - - let newContentSize: CGSize = (self?.tableView.contentSize) - .defaulting(to: oldContentSize) - - /// **Note:** I wasn't able to get a prober equation to handle both "insert" and "insert at top off screen", it - /// seems that the 'contentOffset' value won't expose negative values (eg. when you over-scroll and trigger - /// the bounce effect) and this results in requiring the conditional logic below - if itemChangeInfo.insertLocation == .top { - let newRect: CGRect = (self?.tableView.subviews - .compactMap { $0 as? MessageCell } - .sorted(by: cellSorting) - .first(where: { $0.viewModel?.id == itemChangeInfo.visibleInteractionId })? - .frame) - .defaulting(to: oldRect) - let heightDiff: CGFloat = (oldRect.height - newRect.height) - - if itemChangeInfo.firstIndexIsVisible { - self?.tableView.contentOffset.y = (newRect.minY - (oldRect.minY + heightDiff)) + let calculatedRowHeights: CGFloat = (0..: TransactionObserver where let targetIndex: Int = maybeIndex.map({ max(0, min(totalCount, $0)) }), ( targetIndex < currentPageInfo.pageOffset || - targetIndex > cacheCurrentEndIndex + targetIndex >= cacheCurrentEndIndex ) else { return nil } From 5722cfe7d012838d066c8a3f089fa34e41ecb975 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 23 Jun 2022 15:51:19 +1000 Subject: [PATCH 113/157] Fixed a bunch of bugs Fixed a bug where call messages weren't getting migrated correctly Fixed a bug where the conversation screen would be dismissed when returning from the background Fixed a bug where the conversation screen wasn't starting focused on the first unread message Fixed a bug where contacts that were approved might not be approved after the migration (flags weren't stored correctly previously???) Fixed a bug where the closed group members might not be migrated correctly Fixed a bug where some legacy info messages could be mistakenly migrated as call messages instead of message request acceptance messages Fixed a bug where the last message wasn't showing it's "sent" status correctly Fixed a bug where the QuoteView wasn't laying out the same way it used to Removed some buggy animations when sending/receiving single messages --- .../ConversationVC+Interaction.swift | 14 +- Session/Conversations/ConversationVC.swift | 237 +++++++----------- .../Conversations/ConversationViewModel.swift | 27 +- .../Content Views/QuoteView.swift | 2 +- Session/Meta/AppDelegate.swift | 1 + .../Database/LegacyDatabase/SMKLegacy.swift | 34 ++- .../Migrations/_003_YDBToGRDBMigration.swift | 132 ++++++---- .../MessageReceiver+ClosedGroups.swift | 41 ++- .../MessageReceiver+VisibleMessages.swift | 13 +- .../MessageSender+ClosedGroups.swift | 38 +-- .../Sending & Receiving/MessageSender.swift | 2 - .../SessionThreadViewModel.swift | 24 +- 12 files changed, 296 insertions(+), 269 deletions(-) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index c2e3a801d..bb76b60dc 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -360,6 +360,9 @@ extension ConversationVC: return } + // Let the viewModel know we are about to send a message + self?.viewModel.sentMessageBeforeUpdate = true + // Update the thread to be visible _ = try SessionThread .filter(id: threadId) @@ -391,11 +394,9 @@ extension ConversationVC: ) ).insert(db) } - - guard let interactionId: Int64 = interaction.id else { return } - + // If there is a Quote the insert it now - if let quoteModel: QuotedReplyModel = quoteModel { + if let interactionId: Int64 = interaction.id, let quoteModel: QuotedReplyModel = quoteModel { try Quote( interactionId: interactionId, authorId: quoteModel.authorId, @@ -412,7 +413,6 @@ extension ConversationVC: ) }, completion: { [weak self] _, _ in - self?.viewModel.sentMessageBeforeUpdate = true self?.handleMessageSent() } ) @@ -457,6 +457,9 @@ extension ConversationVC: return } + // Let the viewModel know we are about to send a message + self?.viewModel.sentMessageBeforeUpdate = true + // Update the thread to be visible _ = try SessionThread .filter(id: threadId) @@ -480,7 +483,6 @@ extension ConversationVC: ) }, completion: { [weak self] _, _ in - self?.viewModel.sentMessageBeforeUpdate = true self?.handleMessageSent() // Attachment successfully sent - dismiss the screen diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 3ac99bbdb..660b29e7b 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -49,6 +49,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers // Scrolling & paging var isUserScrolling = false + var hasPerformedInitialScroll = false var didFinishInitialLayout = false var scrollDistanceToBottomBeforeUpdate: CGFloat? var baselineKeyboardHeight: CGFloat = 0 @@ -420,16 +421,8 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - // Perform the initial scroll and highlight if needed (if we started with a focused message - // this will have already been called to instantly snap to the destination but we don't - // trigger the highlight until after the screen has appeared to make it more obvious) - performInitialScrollIfNeeded() - // Flag that the initial layout has been completed (the flag blocks and unblocks a number // of different behaviours) - // - // Note: This MUST be set after the above 'performInitialScrollIfNeeded' is called as it - // won't run if this flag is set to true didFinishInitialLayout = true if delayFirstResponder || isShowingSearchUI { @@ -516,13 +509,16 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers // The default scheduler emits changes on the main thread self?.handleThreadUpdates(threadData) - self?.performInitialScrollIfNeeded() + + // Note: We want to load the interaction data into the UI after the initial thread data + // has loaded to prevent an issue where the conversation loads with the wrong offset + if self?.viewModel.onInteractionChange == nil { + self?.viewModel.onInteractionChange = { [weak self] updatedInteractionData in + self?.handleInteractionUpdates(updatedInteractionData) + } + } } ) - - self.viewModel.onInteractionChange = { [weak self] updatedInteractionData in - self?.handleInteractionUpdates(updatedInteractionData) - } } private func stopObservingChanges() { @@ -617,42 +613,42 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers return } - // Determine if we are inserting content at the top of the collectionView - struct ItemChangeInfo { - enum InsertLocation { - case top - case bottom - case other - case none - } + // Mark received messages as read + let didSendMessageBeforeUpdate: Bool = self.viewModel.sentMessageBeforeUpdate + self.viewModel.markAllAsRead() + self.viewModel.sentMessageBeforeUpdate = false + + // When sending a message we want to reload the UI instantly (with any form of animation the message + // sending feels somewhat unresponsive but an instant update feels snappy) + guard !didSendMessageBeforeUpdate else { + self.viewModel.updateInteractionData(updatedData) + self.tableView.reloadData() - let insertLocation: InsertLocation - let wasCloseToBottom: Bool - let sentMessageBeforeUpdate: Bool + // Note: The scroll button alpha won't get set correctly in this case so we forcibly set it to + // have an alpha of 0 to stop it appearing buggy + self.scrollToBottom(isAnimated: false) + self.scrollButton.alpha = 0 + self.unreadCountView.alpha = scrollButton.alpha + return + } + + // Reload the table content animating changes if they'll look good + struct ItemChangeInfo { + let isInsertAtTop: Bool let firstIndexIsVisible: Bool - let visibleInteractionId: Int64 let visibleIndexPath: IndexPath let oldVisibleIndexPath: IndexPath - let lastVisibleIndexPath: IndexPath init( - insertLocation: InsertLocation, - wasCloseToBottom: Bool, - sentMessageBeforeUpdate: Bool, + isInsertAtTop: Bool = false, firstIndexIsVisible: Bool = false, - visibleInteractionId: Int64 = -1, visibleIndexPath: IndexPath = IndexPath(row: 0, section: 0), - oldVisibleIndexPath: IndexPath = IndexPath(row: 0, section: 0), - lastVisibleIndexPath: IndexPath = IndexPath(row: 0, section: 0) + oldVisibleIndexPath: IndexPath = IndexPath(row: 0, section: 0) ) { - self.insertLocation = insertLocation - self.wasCloseToBottom = wasCloseToBottom - self.sentMessageBeforeUpdate = sentMessageBeforeUpdate + self.isInsertAtTop = isInsertAtTop self.firstIndexIsVisible = firstIndexIsVisible - self.visibleInteractionId = visibleInteractionId self.visibleIndexPath = visibleIndexPath self.oldVisibleIndexPath = oldVisibleIndexPath - self.lastVisibleIndexPath = lastVisibleIndexPath } } @@ -660,96 +656,79 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers source: viewModel.interactionData, target: updatedData ) + let isInsert: Bool = (changeset.map({ $0.elementInserted.count }).reduce(0, +) > 0) + let wasLoadingMore: Bool = self.isLoadingMore + let wasOffsetCloseToBottom: Bool = self.isCloseToBottom let numItemsInUpdatedData: [Int] = updatedData.map { $0.elements.count } - let itemChangeInfo: ItemChangeInfo = { + let itemChangeInfo: ItemChangeInfo? = { guard - changeset.map({ $0.elementInserted.count }).reduce(0, +) > 0, + isInsert, let oldSectionIndex: Int = self.viewModel.interactionData.firstIndex(where: { $0.model == .messages }), let newSectionIndex: Int = updatedData.firstIndex(where: { $0.model == .messages }), let newFirstItemIndex: Int = updatedData[newSectionIndex].elements .firstIndex(where: { item -> Bool in item.id == self.viewModel.interactionData[oldSectionIndex].elements.first?.id }), - let newLastItemIndex: Int = updatedData[newSectionIndex].elements - .lastIndex(where: { item -> Bool in - item.id == self.viewModel.interactionData[oldSectionIndex].elements.last?.id - }), let firstVisibleIndexPath: IndexPath = self.tableView.indexPathsForVisibleRows? .filter({ $0.section == oldSectionIndex }) .sorted() .first, - let lastVisibleIndexPath: IndexPath = self.tableView.indexPathsForVisibleRows? - .filter({ $0.section == oldSectionIndex }) - .sorted() - .last, let newVisibleIndex: Int = updatedData[newSectionIndex].elements .firstIndex(where: { item in item.id == self.viewModel.interactionData[oldSectionIndex] .elements[firstVisibleIndexPath.row] .id - }), - let newLastVisibleIndex: Int = updatedData[newSectionIndex].elements - .firstIndex(where: { item in - item.id == self.viewModel.interactionData[oldSectionIndex] - .elements[lastVisibleIndexPath.row] - .id }) - else { - return ItemChangeInfo( - insertLocation: .none, - wasCloseToBottom: isCloseToBottom, - sentMessageBeforeUpdate: self.viewModel.sentMessageBeforeUpdate - ) - } + else { return nil } return ItemChangeInfo( - insertLocation: { - let insertedAtTop: Bool = ( - newSectionIndex > oldSectionIndex || - newFirstItemIndex > 0 - ) - let insertedAtBot: Bool = ( - newSectionIndex < oldSectionIndex || - newLastItemIndex < (updatedData[newSectionIndex].elements.count - 1) - ) - - // If anything was inserted at the top then we need to maintain the current - // offset so always return a 'top' insert location - switch (insertedAtTop, insertedAtBot, isLoadingMore) { - case (true, _, true), (true, false, false): return .top - case (false, true, _): return .bottom - case (false, false, _), (true, true, false): return .other - } - }(), - wasCloseToBottom: isCloseToBottom, - sentMessageBeforeUpdate: self.viewModel.sentMessageBeforeUpdate, + isInsertAtTop: ( + newSectionIndex > oldSectionIndex || + newFirstItemIndex > 0 + ), firstIndexIsVisible: (firstVisibleIndexPath.row == 0), - visibleInteractionId: updatedData[newSectionIndex].elements[newVisibleIndex].id, visibleIndexPath: IndexPath(row: newVisibleIndex, section: newSectionIndex), - oldVisibleIndexPath: firstVisibleIndexPath, - lastVisibleIndexPath: IndexPath(row: newLastVisibleIndex, section: newSectionIndex) + oldVisibleIndexPath: firstVisibleIndexPath ) }() + guard !isInsert || wasLoadingMore || itemChangeInfo?.isInsertAtTop == true else { + self.viewModel.updateInteractionData(updatedData) + self.tableView.reloadData() + + // Animate to the target interaction (or the bottom) after a slightly delay to prevent buggy + // animation conflicts + if let focusedInteractionId: Int64 = self.focusedInteractionId { + // If we had a focusedInteractionId then scroll to it (and hide the search + // result bar loading indicator) + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { [weak self] in + self?.searchController.resultsBar.stopLoading() + self?.scrollToInteractionIfNeeded( + with: focusedInteractionId, + isAnimated: true, + highlight: (self?.shouldHighlightNextScrollToInteraction == true) + ) + } + } + else if wasOffsetCloseToBottom { + // Scroll to the bottom if an interaction was just inserted and we either + // just sent a message or are close enough to the bottom (wait a tiny fraction + // to avoid buggy animation behaviour) + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { [weak self] in + self?.scrollToBottom(isAnimated: true) + } + } + return + } + /// UITableView doesn't really support bottom-aligned content very well and as such jumps around a lot when inserting content but /// we want to maintain the current offset from before the data was inserted (except when adding at the bottom while the user is at /// the bottom, in which case we want to scroll down) /// /// Unfortunately the UITableView also does some weird things when updating (where it won't have updated it's internal data until /// after it performs the next layout); the below code checks a condition on layout and if it passes it calls a closure - if itemChangeInfo.insertLocation == .top { - let cellSorting: (MessageCell, MessageCell) -> Bool = { lhs, rhs -> Bool in - if !lhs.isHidden && rhs.isHidden { return true } - if lhs.isHidden && !rhs.isHidden { return false } - - return (lhs.frame.minY < rhs.frame.minY) - } - let oldRect: CGRect = (self.tableView.subviews - .compactMap { $0 as? MessageCell } - .sorted(by: cellSorting) - .first(where: { cell -> Bool in cell.viewModel?.id == itemChangeInfo.visibleInteractionId })? - .frame) - .defaulting(to: self.tableView.rectForRow(at: itemChangeInfo.oldVisibleIndexPath)) + if let itemChangeInfo: ItemChangeInfo = itemChangeInfo, (itemChangeInfo.isInsertAtTop || wasLoadingMore) { + let oldCellHeight: CGFloat = self.tableView.rectForRow(at: itemChangeInfo.oldVisibleIndexPath).height // The the user triggered the 'scrollToTop' animation (by tapping in the nav bar) then we // need to stop the animation before attempting to lock the offset (otherwise things break) @@ -778,8 +757,10 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers .height) .defaulting(to: 0) } - let newTargetRect: CGRect? = self?.tableView.rectForRow(at: itemChangeInfo.visibleIndexPath) - let heightDiff: CGFloat = (oldRect.height - (newTargetRect ?? oldRect).height) + let newTargetHeight: CGFloat? = self?.tableView + .rectForRow(at: itemChangeInfo.visibleIndexPath) + .height + let heightDiff: CGFloat = (oldCellHeight - (newTargetHeight ?? oldCellHeight)) self?.tableView.contentOffset.y += (calculatedRowHeights - heightDiff) } @@ -803,61 +784,31 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers } ) } - else if itemChangeInfo.insertLocation == .bottom || itemChangeInfo.insertLocation == .other { - CATransaction.begin() - CATransaction.setCompletionBlock { [weak self] in - if let focusedInteractionId: Int64 = self?.focusedInteractionId { - // If we had a focusedInteractionId then scroll to it (and hide the search - // result bar loading indicator) - self?.searchController.resultsBar.stopLoading() - self?.scrollToInteractionIfNeeded( - with: focusedInteractionId, - isAnimated: true, - highlight: (self?.shouldHighlightNextScrollToInteraction == true) - ) - } - else if itemChangeInfo.sentMessageBeforeUpdate || itemChangeInfo.wasCloseToBottom { - // Scroll to the bottom if an interaction was just inserted and we either - // just sent a message or are close enough to the bottom - self?.scrollToBottom(isAnimated: true) - } - } - } - // Reload the table content (animate changes if we aren't inserting at the top) self.tableView.reload( using: changeset, deleteSectionsAnimation: .none, insertSectionsAnimation: .none, reloadSectionsAnimation: .none, deleteRowsAnimation: .bottom, - insertRowsAnimation: .bottom, + insertRowsAnimation: .none, reloadRowsAnimation: .none, - interrupt: { itemChangeInfo.insertLocation == .top || $0.changeCount > ConversationViewModel.pageSize } + interrupt: { itemChangeInfo?.isInsertAtTop == true || $0.changeCount > ConversationViewModel.pageSize } ) { [weak self] updatedData in self?.viewModel.updateInteractionData(updatedData) } - - if itemChangeInfo.insertLocation == .bottom || itemChangeInfo.insertLocation == .other { - CATransaction.commit() - } - - // Mark received messages as read - viewModel.markAllAsRead() - viewModel.sentMessageBeforeUpdate = false } private func performInitialScrollIfNeeded() { - guard !didFinishInitialLayout && hasLoadedInitialThreadData && hasLoadedInitialInteractionData else { return } + guard !hasPerformedInitialScroll && hasLoadedInitialThreadData && hasLoadedInitialInteractionData else { + return + } // Scroll to the last unread message if possible; otherwise scroll to the bottom. // When the unread message count is more than the number of view items of a page, // the screen will scroll to the bottom instead of the first unread message if let focusedInteractionId: Int64 = self.viewModel.focusedInteractionId { self.scrollToInteractionIfNeeded(with: focusedInteractionId, isAnimated: false, highlight: true) - } - else if let firstUnreadInteractionId: Int64 = self.viewModel.threadData.threadFirstUnreadInteractionId { - self.scrollToInteractionIfNeeded(with: firstUnreadInteractionId, position: .top, isAnimated: false) self.unreadCountView.alpha = self.scrollButton.alpha } else { @@ -865,6 +816,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers } self.scrollButton.alpha = self.getScrollButtonOpacity() + self.hasPerformedInitialScroll = true // Now that the data has loaded we need to check if either of the "load more" sections are // visible and trigger them if so @@ -1187,7 +1139,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers } func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) { - guard self.didFinishInitialLayout && !self.isLoadingMore else { return } + guard self.hasPerformedInitialScroll && !self.isLoadingMore else { return } let section: ConversationViewModel.SectionModel = self.viewModel.interactionData[section] @@ -1264,6 +1216,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers self.shouldHighlightNextScrollToInteraction else { self.focusedInteractionId = nil + self.shouldHighlightNextScrollToInteraction = false return } @@ -1439,16 +1392,16 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers animated: (self.didFinishInitialLayout && isAnimated) ) - // Don't clear these values if we have't done the initial layout (we will call this - // method a second time to trigger the highlight after the screen appears) - guard self.didFinishInitialLayout else { return } - - self.focusedInteractionId = nil - self.shouldHighlightNextScrollToInteraction = false - + // If we haven't finished the initial layout then we want to delay the highlight slightly + // so it doesn't look buggy with the push transition if highlight { - self.highlightCellIfNeeded(interactionId: interactionId) + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(self.didFinishInitialLayout ? 0 : 150)) { [weak self] in + self?.highlightCellIfNeeded(interactionId: interactionId) + } } + + self.shouldHighlightNextScrollToInteraction = false + self.focusedInteractionId = nil return } diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 2248cb7cc..3881a4ccb 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -53,9 +53,27 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { // MARK: - Initialization init(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionId: Int64?) { + // If we have a specified 'focusedInteractionId' then use that, otherwise retrieve the oldest + // unread interaction and start focused around that one + let targetInteractionId: Int64? = { + if let focusedInteractionId: Int64 = focusedInteractionId { return focusedInteractionId } + + return GRDBStorage.shared.read { db in + let interaction: TypedTableAlias = TypedTableAlias() + + return try Interaction + .select(.id) + .filter(interaction[.wasRead] == false) + .filter(interaction[.threadId] == threadId) + .order(interaction[.timestampMs].asc) + .asRequest(of: Int64.self) + .fetchOne(db) + } + }() + self.threadId = threadId self.initialThreadVariant = threadVariant - self.focusedInteractionId = focusedInteractionId + self.focusedInteractionId = targetInteractionId self.pagedDataObserver = nil // Note: Since this references self we need to finish initializing before setting it, we @@ -68,7 +86,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { DispatchQueue.global(qos: .default).async { [weak self] in // If we don't have a `initialFocusedId` then default to `.pageBefore` (it'll query // from a `0` offset) - guard let initialFocusedId: Int64 = focusedInteractionId else { + guard let initialFocusedId: Int64 = targetInteractionId else { self?.pagedDataObserver?.load(.pageBefore) return } @@ -216,8 +234,11 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { prevModel: (index > 0 ? sortedData[index - 1] : nil), nextModel: (index < (sortedData.count - 1) ? sortedData[index + 1] : nil), isLast: ( + // The database query sorts by timestampMs descending so the "last" + // interaction will actually have a 'pageOffset' of '0' even though + // it's the last element in the 'sortedData' array index == (sortedData.count - 1) && - pageInfo.currentCount == pageInfo.totalCount + pageInfo.pageOffset == 0 ) ) } diff --git a/Session/Conversations/Message Cells/Content Views/QuoteView.swift b/Session/Conversations/Message Cells/Content Views/QuoteView.swift index b5db4cd45..d17e1dc24 100644 --- a/Session/Conversations/Message Cells/Content Views/QuoteView.swift +++ b/Session/Conversations/Message Cells/Content Views/QuoteView.swift @@ -84,7 +84,7 @@ final class QuoteView: UIView { // Subtract smallSpacing twice; once for the spacing in between the stack view elements and // once for the trailing margin. - if attachment != nil { + if attachment == nil { availableWidth = maxWidth - 2 * hInset - Values.accentLineThickness - 2 * smallSpacing } else { diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index cbe6f8f5e..8d79922a8 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -299,6 +299,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD return } + self.hasInitialRootViewController = true self.window?.rootViewController = OWSNavigationController( rootViewController: (Identity.userExists() ? HomeVC() : diff --git a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift index 25273ebfb..b27cc5e9e 100644 --- a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift +++ b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift @@ -1312,19 +1312,51 @@ public enum SMKLegacy { case call case messageRequestAccepted = 99 } + public enum _InfoMessageCallState: Int { + case incoming + case outgoing + case missed + case permissionDenied + case unknown + } public var wasRead: Bool public var messageType: _InfoMessageType + public var callState: _InfoMessageCallState public var customMessage: String? // MARK: NSCoder public required init(coder: NSCoder) { + let parsedMessageType: _InfoMessageType = _InfoMessageType(rawValue: (coder.decodeObject(forKey: "messageType") as! NSNumber).intValue)! + let rawCallState: Int? = (coder.decodeObject(forKey: "callState") as? NSNumber)?.intValue + self.wasRead = (coder.decodeObject(forKey: "read") as? Bool) // Note: 'read' is the correct key .defaulting(to: false) - self.messageType = _InfoMessageType(rawValue: (coder.decodeObject(forKey: "messageType") as! NSNumber).intValue)! self.customMessage = coder.decodeObject(forKey: "customMessage") as? String + switch (parsedMessageType, rawCallState) { + // Note: There was a period of time where the 'messageType' value for both 'call' and + // 'messageRequestAccepted' was the same, this code is here to handle any messages which + // might have been mistakenly identified as 'call' messages when they should be seen as + // 'messageRequestAccepted' messages (hard-coding a timestamp to be sure that any calls + // after the value was changed are correctly identified as 'unknown') + case (.call, .none): + guard (coder.decodeObject(forKey: "timestamp") as? UInt64 ?? 0) < 1647500000000 else { + fallthrough + } + + self.messageType = .messageRequestAccepted + self.callState = .unknown + + default: + self.messageType = parsedMessageType + self.callState = _InfoMessageCallState( + rawValue: (rawCallState ?? _InfoMessageCallState.unknown.rawValue) + ) + .defaulting(to: .unknown) + } + super.init(coder: coder) } } diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 1faa74994..6c0005eb8 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -419,6 +419,15 @@ enum _003_YDBToGRDBMigration: Migration { legacyBlockedSessionIds.contains(legacyContact.sessionID) ) + /// Looks like there are some cases where conversations would be visible in the old version but wouldn't in the new version + /// it seems to be related to the `isApproved` and `didApproveMe` not being set correctly somehow, this logic is to + /// ensure the flags are set correctly based on sent/received messages + let interactionsForContact: [SMKLegacy._DBInteraction] = (interactions["\(SMKLegacy.contactThreadPrefix)\(legacyContact.sessionID)"] ?? []) + let shouldForceIsApproved: Bool = interactionsForContact + .contains(where: { $0 is SMKLegacy._DBOutgoingMessage }) + let shouldForceDidApproveMe: Bool = interactionsForContact + .contains(where: { $0 is SMKLegacy._DBIncomingMessage }) + // Determine if this contact is a "real" contact (don't want to create contacts for // every user in the new structure but still want profiles for every user) if @@ -430,7 +439,9 @@ enum _003_YDBToGRDBMigration: Migration { legacyContact.hasBeenBlocked || shouldForceTrustContact || shouldForceApproveContact || - shouldForceBlockContact + shouldForceBlockContact || + shouldForceIsApproved || + shouldForceDidApproveMe { // Create the contact try Contact( @@ -443,7 +454,8 @@ enum _003_YDBToGRDBMigration: Migration { isApproved: ( isCurrentUser || legacyContact.isApproved || - shouldForceApproveContact + shouldForceApproveContact || + shouldForceIsApproved ), isBlocked: ( !isCurrentUser && ( @@ -454,7 +466,8 @@ enum _003_YDBToGRDBMigration: Migration { didApproveMe: ( isCurrentUser || legacyContact.didApproveMe || - shouldForceApproveContact + shouldForceApproveContact || + shouldForceDidApproveMe ), hasBeenBlocked: (!isCurrentUser && (legacyContact.hasBeenBlocked || legacyContact.isBlocked)) ).insert(db) @@ -599,33 +612,30 @@ enum _003_YDBToGRDBMigration: Migration { ).insert(db) } - // Only create the 'GroupMember' models if the current user is actually a member - // of the group (if the user has left the group or been removed from it we now - // delete all of these records so want this to behave the same way) - if groupModel.groupMemberIds.contains(currentUserPublicKey) { - try groupModel.groupMemberIds.forEach { memberId in - try GroupMember( - groupId: threadId, - profileId: memberId, - role: .standard - ).insert(db) - } - - try groupModel.groupAdminIds.forEach { adminId in - try GroupMember( - groupId: threadId, - profileId: adminId, - role: .admin - ).insert(db) - } - - try (closedGroupZombieMemberIds[legacyThread.uniqueId] ?? []).forEach { zombieId in - try GroupMember( - groupId: threadId, - profileId: zombieId, - role: .zombie - ).insert(db) - } + // Create the 'GroupMember' models for the group (even if the current user is no longer + // a member as these objects are used to generate the group avatar icon) + try groupModel.groupMemberIds.forEach { memberId in + try GroupMember( + groupId: threadId, + profileId: memberId, + role: .standard + ).insert(db) + } + + try groupModel.groupAdminIds.forEach { adminId in + try GroupMember( + groupId: threadId, + profileId: adminId, + role: .admin + ).insert(db) + } + + try (closedGroupZombieMemberIds[legacyThread.uniqueId] ?? []).forEach { zombieId in + try GroupMember( + groupId: threadId, + profileId: zombieId, + role: .zombie + ).insert(db) } } @@ -746,27 +756,53 @@ enum _003_YDBToGRDBMigration: Migration { // way to determine who actually triggered the info message authorId = currentUserPublicKey body = { - // Note: The 'DisappearingConfigurationUpdateInfoMessage' stored additional info and constructed - // a string at display time so we want to continue that behaviour - guard - infoMessage.messageType == .disappearingMessagesUpdate, - let updateMessage: SMKLegacy._DisappearingConfigurationUpdateInfoMessage = infoMessage as? SMKLegacy._DisappearingConfigurationUpdateInfoMessage, - let infoMessageData: Data = try? JSONEncoder().encode( - DisappearingMessagesConfiguration.MessageInfo( - senderName: updateMessage.createdByRemoteName, - isEnabled: updateMessage.configurationIsEnabled, - durationSeconds: TimeInterval(updateMessage.configurationDurationSeconds) + // Note: Some message types stored additional info and constructed a string + // at display time, instead we encode the data into the body of the message + // as JSON so we want to continue that behaviour but not change the database + // structure for some edge cases + switch infoMessage.messageType { + case .disappearingMessagesUpdate: + guard + let updateMessage: SMKLegacy._DisappearingConfigurationUpdateInfoMessage = infoMessage as? SMKLegacy._DisappearingConfigurationUpdateInfoMessage, + let infoMessageData: Data = try? JSONEncoder().encode( + DisappearingMessagesConfiguration.MessageInfo( + senderName: updateMessage.createdByRemoteName, + isEnabled: updateMessage.configurationIsEnabled, + durationSeconds: TimeInterval(updateMessage.configurationDurationSeconds) + ) + ), + let infoMessageString: String = String(data: infoMessageData, encoding: .utf8) + else { break } + + return infoMessageString + + case .call: + let messageInfo: CallMessage.MessageInfo = CallMessage.MessageInfo( + state: { + switch infoMessage.callState { + case .incoming: return .incoming + case .outgoing: return .outgoing + case .missed: return .missed + case .permissionDenied: return .permissionDenied + case .unknown: return .unknown + } + }() ) - ), - let infoMessageString: String = String(data: infoMessageData, encoding: .utf8) - else { - return ((infoMessage.body ?? "").isEmpty ? - infoMessage.customMessage : - infoMessage.body - ) + + guard + let messageInfoData: Data = try? JSONEncoder().encode(messageInfo), + let messageInfoDataString: String = String(data: messageInfoData, encoding: .utf8) + else { break } + + return messageInfoDataString + + default: break } - return infoMessageString + return ((infoMessage.body ?? "").isEmpty ? + infoMessage.customMessage : + infoMessage.body + ) }() wasRead = infoMessage.wasRead expiresInSeconds = nil // Info messages don't expire diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift index 764830c23..d398e4b47 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift @@ -280,8 +280,9 @@ extension MessageReceiver { } // Remove any 'zombie' versions of the added members (in case they were re-added) - _ = try closedGroup - .zombies + _ = try GroupMember + .filter(GroupMember.Columns.groupId == id) + .filter(GroupMember.Columns.role == GroupMember.Role.zombie) .filter(addedMembers.contains(GroupMember.Columns.profileId)) .deleteAll(db) @@ -333,6 +334,13 @@ extension MessageReceiver { return SNLog("Ignoring invalid closed group update.") } + // Delete the removed members + try GroupMember + .filter(GroupMember.Columns.groupId == id) + .filter(removedMembers.contains(GroupMember.Columns.profileId)) + .filter([ GroupMember.Role.standard, GroupMember.Role.zombie ].contains(GroupMember.Columns.role)) + .deleteAll(db) + // If the current user was removed: // • Stop polling for the group // • Remove the key pairs associated with the group @@ -343,10 +351,6 @@ extension MessageReceiver { if wasCurrentUserRemoved { ClosedGroupPoller.shared.stopPolling(for: id) - try closedGroup - .allMembers - .deleteAll(db) - _ = try closedGroup .keyPairs .deleteAll(db) @@ -357,15 +361,6 @@ extension MessageReceiver { publicKey: userPublicKey ) } - else { - // Remove the member from the group and it's zombies - try closedGroup.members - .filter(removedMembers.contains(GroupMember.Columns.profileId)) - .deleteAll(db) - try closedGroup.zombies - .filter(removedMembers.contains(GroupMember.Columns.profileId)) - .deleteAll(db) - } // Notify the user if needed guard members != Set(groupMembers.map { $0.profileId }) else { return } @@ -418,15 +413,16 @@ extension MessageReceiver { .asSet() .subtracting(membersToRemove.map { $0.profileId }) + // Delete the members to remove + try GroupMember + .filter(GroupMember.Columns.groupId == id) + .filter(updatedMemberIds.contains(GroupMember.Columns.profileId)) + .deleteAll(db) if didAdminLeave || sender == userPublicKey { // Remove the group from the database and unsubscribe from PNs ClosedGroupPoller.shared.stopPolling(for: id) - try closedGroup - .allMembers - .deleteAll(db) - _ = try closedGroup .keyPairs .deleteAll(db) @@ -438,12 +434,7 @@ extension MessageReceiver { ) } else { - // Delete all old user roles and re-add them as a zombie - try closedGroup - .allMembers - .filter(GroupMember.Columns.profileId == sender) - .deleteAll(db) - + // Re-add the removed member as a zombie try GroupMember( groupId: id, profileId: sender, diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index 0bd20d00a..97806cff0 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -125,8 +125,15 @@ extension MessageReceiver { linkPreviewUrl: (message.linkPreview?.url ?? message.openGroupInvitation?.url), // Keep track of the open group server message ID ↔ message ID relationship openGroupServerMessageId: message.openGroupServerMessageId.map { Int64($0) }, - openGroupWhisperMods: false, - openGroupWhisperTo: nil + openGroupWhisperMods: (message.recipient?.contains(".mods") == true), + openGroupWhisperTo: { + guard + let recipientParts: [String] = message.recipient?.components(separatedBy: "."), + recipientParts.count >= 3 // 'server.roomToken.whisperTo.whisperMods' + else { return nil } + + return recipientParts[2] + }() ).inserted(db) } catch { @@ -170,7 +177,7 @@ extension MessageReceiver { variant: variant, syncTarget: message.syncTarget ) - + // Parse & persist attachments let attachments: [Attachment] = try dataMessage.attachments .compactMap { proto -> Attachment? in diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift index 374c13cec..d9332c814 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift @@ -409,19 +409,11 @@ extension MessageSender { .map { $0.profileId } let members: Set = Set(groupMemberIds).subtracting(removedMembers) - // Update zombie * member list - let profileIdsToRemove: Set = allGroupMembers - .filter { member in - removedMembers.contains(member.profileId) && ( - member.role == .standard || - member.role == .zombie - ) - } - .map { $0.profileId } - .asSet() + // Update zombie & member list try GroupMember .filter(GroupMember.Columns.groupId == thread.id) - .filter(profileIdsToRemove.contains(GroupMember.Columns.profileId)) + .filter(removedMembers.contains(GroupMember.Columns.profileId)) + .filter([ GroupMember.Role.standard, GroupMember.Role.zombie ].contains(GroupMember.Columns.role)) .deleteAll(db) let interactionId: Int64? @@ -522,7 +514,7 @@ extension MessageSender { ClosedGroupPoller.shared.stopPolling(for: groupPublicKey) GRDBStorage.shared.write { db in - _ = try closedGroup + try closedGroup .keyPairs .deleteAll(db) @@ -535,10 +527,24 @@ extension MessageSender { } .map { _ in } - // Update the group - _ = try closedGroup - .allMembers - .deleteAll(db) + // Update the group (if the admin leaves the group is disbanded) + let wasAdminUser: Bool = try GroupMember + .filter(GroupMember.Columns.groupId == thread.id) + .filter(GroupMember.Columns.profileId == userPublicKey) + .filter(GroupMember.Columns.role == GroupMember.Role.admin) + .isNotEmpty(db) + + if wasAdminUser { + try GroupMember + .filter(GroupMember.Columns.groupId == thread.id) + .deleteAll(db) + } + else { + try GroupMember + .filter(GroupMember.Columns.groupId == thread.id) + .filter(GroupMember.Columns.profileId == userPublicKey) + .deleteAll(db) + } // Return return promise diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index e870118be..ef3b3e3da 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -652,8 +652,6 @@ public final class MessageSender { mostRecentFailureText: error.localizedDescription ).save(db) } - - // Remove the message timestamps if it fails } // MARK: - Convenience diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 5becf86be..8e0e49d4d 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -30,7 +30,6 @@ public struct SessionThreadViewModel: FetchableRecord, Decodable, Equatable, Has public static let threadContactIsTypingKey: SQL = SQL(stringLiteral: CodingKeys.threadContactIsTyping.stringValue) public static let threadUnreadCountKey: SQL = SQL(stringLiteral: CodingKeys.threadUnreadCount.stringValue) public static let threadUnreadMentionCountKey: SQL = SQL(stringLiteral: CodingKeys.threadUnreadMentionCount.stringValue) - public static let threadFirstUnreadInteractionIdKey: SQL = SQL(stringLiteral: CodingKeys.threadFirstUnreadInteractionId.stringValue) public static let contactProfileKey: SQL = SQL(stringLiteral: CodingKeys.contactProfile.stringValue) public static let closedGroupNameKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupName.stringValue) public static let closedGroupUserCountKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupUserCount.stringValue) @@ -57,7 +56,6 @@ public struct SessionThreadViewModel: FetchableRecord, Decodable, Equatable, Has public static let threadUnreadCountString: String = CodingKeys.threadUnreadCount.stringValue public static let threadUnreadMentionCountString: String = CodingKeys.threadUnreadMentionCount.stringValue - public static let threadFirstUnreadInteractionIdString: String = CodingKeys.threadFirstUnreadInteractionId.stringValue public static let closedGroupUserCountString: String = CodingKeys.closedGroupUserCount.stringValue public static let openGroupUserCountString: String = CodingKeys.openGroupUserCount.stringValue public static let contactProfileString: String = CodingKeys.contactProfile.stringValue @@ -86,7 +84,6 @@ public struct SessionThreadViewModel: FetchableRecord, Decodable, Equatable, Has public let threadContactIsTyping: Bool? public let threadUnreadCount: UInt? public let threadUnreadMentionCount: UInt? - public let threadFirstUnreadInteractionId: Int64? // Thread display info @@ -214,7 +211,6 @@ public extension SessionThreadViewModel { self.threadContactIsTyping = nil self.threadUnreadCount = unreadCount self.threadUnreadMentionCount = nil - self.threadFirstUnreadInteractionId = nil // Thread display info @@ -496,7 +492,6 @@ public extension SessionThreadViewModel { let openGroup: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() - let firstUnreadInteractionTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.threadFirstUnreadInteractionIdString)_table") let interactionIdLiteral: SQL = SQL(stringLiteral: Interaction.Columns.id.name) let interactionThreadIdLiteral: SQL = SQL(stringLiteral: Interaction.Columns.threadId.name) let closedGroupUserCountTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.closedGroupUserCountString)_table") @@ -508,7 +503,7 @@ public extension SessionThreadViewModel { /// parse and might throw /// /// Explicitly set default values for the fields ignored for search results - let numColumnsBeforeProfiles: Int = 15 + let numColumnsBeforeProfiles: Int = 13 let request: SQLRequest = """ SELECT \(thread[.id]) AS \(ViewModel.threadIdKey), @@ -540,8 +535,6 @@ public extension SessionThreadViewModel { \(thread[.messageDraft]) AS \(ViewModel.threadMessageDraftKey), \(Interaction.self).\(ViewModel.threadUnreadCountKey), - \(Interaction.self).\(ViewModel.threadUnreadMentionCountKey), - \(firstUnreadInteractionTableLiteral).\(interactionIdLiteral) AS \(ViewModel.threadFirstUnreadInteractionIdKey), \(ViewModel.contactProfileKey).*, \(closedGroup[.name]) AS \(ViewModel.closedGroupNameKey), @@ -550,7 +543,6 @@ public extension SessionThreadViewModel { \(openGroup[.name]) AS \(ViewModel.openGroupNameKey), \(openGroup[.server]) AS \(ViewModel.openGroupServerKey), \(openGroup[.roomToken]) AS \(ViewModel.openGroupRoomTokenKey), - \(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey), \(openGroup[.userCount]) AS \(ViewModel.openGroupUserCountKey), \(Interaction.self).\(ViewModel.interactionIdKey), @@ -566,23 +558,11 @@ public extension SessionThreadViewModel { \(interaction[.threadId]), MAX(\(interaction[.timestampMs])), - SUM(\(interaction[.wasRead]) = false) AS \(ViewModel.threadUnreadCountKey), - SUM(\(interaction[.wasRead]) = false AND \(interaction[.hasMention]) = true) AS \(ViewModel.threadUnreadMentionCountKey) + SUM(\(interaction[.wasRead]) = false) AS \(ViewModel.threadUnreadCountKey) FROM \(Interaction.self) GROUP BY \(interaction[.threadId]) ) AS \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id]) - LEFT JOIN ( - SELECT - \(interaction[.id]), - \(interaction[.threadId]), - MIN(\(interaction[.timestampMs])) - FROM \(Interaction.self) - WHERE ( - \(interaction[.wasRead]) = false AND - \(SQL("\(interaction[.threadId]) = \(threadId)")) - ) - ) AS \(firstUnreadInteractionTableLiteral) ON \(firstUnreadInteractionTableLiteral).\(interactionThreadIdLiteral) = \(thread[.id]) LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) From 346ce3d24a015a8b2b160390553a4c1de91f3f7b Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 23 Jun 2022 15:52:50 +1000 Subject: [PATCH 114/157] Added a button to the settings screen to trigger a re-migration of the database --- Session/Settings/SettingsVC.swift | 10 ++++++++++ SessionUtilitiesKit/Database/GRDBStorage.swift | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/Session/Settings/SettingsVC.swift b/Session/Settings/SettingsVC.swift index 62ffb9fa6..5bab2109a 100644 --- a/Session/Settings/SettingsVC.swift +++ b/Session/Settings/SettingsVC.swift @@ -299,6 +299,8 @@ final class SettingsVC: BaseVC, AvatarViewHelperDelegate { return button } + let debugReMigrateButton = getSettingButton(withTitle: "DEBUG - Re-Migrate Database", color: Colors.destructive, action: #selector(remigrateDatabase)) + let pathButton = getSettingButton(withTitle: NSLocalizedString("vc_path_title", comment: ""), color: Colors.text, action: #selector(showPath)) let pathStatusView = PathStatusView() pathStatusView.set(.width, to: PathStatusView.size) @@ -309,6 +311,8 @@ final class SettingsVC: BaseVC, AvatarViewHelperDelegate { pathStatusView.autoVCenterInSuperview() return [ + getSeparator(), + debugReMigrateButton, getSeparator(), pathButton, getSeparator(), @@ -588,6 +592,12 @@ final class SettingsVC: BaseVC, AvatarViewHelperDelegate { navigationController!.present(shareVC, animated: true, completion: nil) } + @objc private func remigrateDatabase() { + GRDBStorage.deleteDatabaseFiles() + try? GRDBStorage.deleteDbKeys() + exit(1) + } + @objc private func showPath() { let pathVC = PathVC() navigationController!.pushViewController(pathVC, animated: true) diff --git a/SessionUtilitiesKit/Database/GRDBStorage.swift b/SessionUtilitiesKit/Database/GRDBStorage.swift index 89e5ecb42..c13e4d3ce 100644 --- a/SessionUtilitiesKit/Database/GRDBStorage.swift +++ b/SessionUtilitiesKit/Database/GRDBStorage.swift @@ -297,13 +297,13 @@ public final class GRDBStorage { // TODO: Delete Profiles on Disk? } - private static func deleteDatabaseFiles() { + public/*private*/ static func deleteDatabaseFiles() { OWSFileSystem.deleteFile(databasePath) OWSFileSystem.deleteFile(databasePathShm) OWSFileSystem.deleteFile(databasePathWal) } - private static func deleteDbKeys() throws { + public/*private*/ static func deleteDbKeys() throws { try SSKDefaultKeychainStorage.shared.remove(service: keychainService, key: dbCipherKeySpecKey) } From 8288680f72b60179958806f196fb474d99e0031d Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 23 Jun 2022 17:30:18 +1000 Subject: [PATCH 115/157] Added a restore account button to the failed migrations alert Fixed a couple of bugs around restoring the "approved" state for message requests Fixed a bug where the last message body for conversations was incorrectly trying to include deleted messages --- Session/Home/HomeVC.swift | 4 + Session/Home/HomeViewModel.swift | 11 +- Session/Meta/AppDelegate.swift | 123 +++++++++++------- .../Translations/de.lproj/Localizable.strings | 2 +- .../Translations/en.lproj/Localizable.strings | 2 +- .../Translations/es.lproj/Localizable.strings | 2 +- .../Translations/fa.lproj/Localizable.strings | 2 +- .../Translations/fi.lproj/Localizable.strings | 2 +- .../Translations/fr.lproj/Localizable.strings | 2 +- .../Translations/hi.lproj/Localizable.strings | 2 +- .../Translations/hr.lproj/Localizable.strings | 2 +- .../id-ID.lproj/Localizable.strings | 2 +- .../Translations/it.lproj/Localizable.strings | 2 +- .../Translations/ja.lproj/Localizable.strings | 2 +- .../Translations/nl.lproj/Localizable.strings | 2 +- .../Translations/pl.lproj/Localizable.strings | 2 +- .../pt_BR.lproj/Localizable.strings | 2 +- .../Translations/ru.lproj/Localizable.strings | 2 +- .../Translations/si.lproj/Localizable.strings | 2 +- .../Translations/sk.lproj/Localizable.strings | 2 +- .../Translations/sv.lproj/Localizable.strings | 2 +- .../Translations/th.lproj/Localizable.strings | 2 +- .../vi-VN.lproj/Localizable.strings | 2 +- .../zh-Hant.lproj/Localizable.strings | 2 +- .../zh_CN.lproj/Localizable.strings | 2 +- Session/Settings/SettingsVC.swift | 16 ++- ...essageReceiver+ConfigurationMessages.swift | 9 +- .../MessageReceiver+MessageRequests.swift | 14 +- .../SessionThreadViewModel.swift | 3 +- .../Database/GRDBStorage.swift | 5 +- .../Database/LegacyDatabase/SUKLegacy.swift | 7 + SignalUtilitiesKit/Utilities/AppSetup.swift | 48 ++++--- 32 files changed, 181 insertions(+), 103 deletions(-) diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 56b8eddfb..fbff99edb 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -263,6 +263,10 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve updatedState.sections.contains(where: { !$0.elements.isEmpty }) ) + if updatedState.userProfile != self.viewModel.state.userProfile { + updateNavBarButtons() + } + // Update the 'view seed' UI if updatedState.showViewedSeedBanner != self.viewModel.state.showViewedSeedBanner { tableViewTopConstraint.isActive = false diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 1115f6499..55e389e37 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -13,14 +13,17 @@ public class HomeViewModel { public struct State: Equatable { let showViewedSeedBanner: Bool + let userProfile: Profile? let sections: [ArraySection] func with( showViewedSeedBanner: Bool? = nil, + userProfile: Profile? = nil, sections: [ArraySection]? = nil ) -> State { return State( showViewedSeedBanner: (showViewedSeedBanner ?? self.showViewedSeedBanner), + userProfile: (userProfile ?? self.userProfile), sections: (sections ?? self.sections) ) } @@ -29,6 +32,7 @@ public class HomeViewModel { /// This value is the current state of the view public private(set) var state: State = State( showViewedSeedBanner: !GRDBStorage.shared[.hasViewedSeed], + userProfile: nil, sections: [] ) @@ -71,17 +75,18 @@ public class HomeViewModel { ], fetch: { db -> State in let hasViewedSeed: Bool = db[.hasViewedSeed] - let userPublicKey: String = getUserHexEncodedPublicKey(db) + let userProfile: Profile = Profile.fetchOrCreateCurrentUser(db) let unreadMessageRequestCount: Int = try SessionThread - .unreadMessageRequestsQuery(userPublicKey: userPublicKey) + .unreadMessageRequestsQuery(userPublicKey: userProfile.id) .fetchCount(db) let finalUnreadMessageRequestCount: Int = (db[.hasHiddenMessageRequests] ? 0 : unreadMessageRequestCount) let threads: [SessionThreadViewModel] = try SessionThreadViewModel - .homeQuery(userPublicKey: userPublicKey) + .homeQuery(userPublicKey: userProfile.id) .fetchAll(db) return State( showViewedSeedBanner: !hasViewedSeed, + userProfile: userProfile, sections: [ ArraySection( model: .messageRequests, diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 8d79922a8..9ed1c91ab 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -19,6 +19,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD var backgroundSnapshotBlockerWindow: UIWindow? var appStartupWindow: UIWindow? var hasInitialRootViewController: Bool = false + private var loadingViewController: LoadingViewController? /// This needs to be a lazy variable to ensure it doesn't get initialized before it actually needs to be used lazy var poller: Poller = Poller() @@ -41,7 +42,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD DeviceSleepManager.sharedInstance.addBlock(blockObject: self) let mainWindow: UIWindow = UIWindow(frame: UIScreen.main.bounds) - let loadingViewController: LoadingViewController = LoadingViewController() + self.loadingViewController = LoadingViewController() AppSetup.setupEnvironment( appSpecificBlock: { @@ -59,57 +60,19 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD OWSScreenLockUI.sharedManager().startObserving() } }, - migrationProgressChanged: { progress, minEstimatedTotalTime in - loadingViewController.updateProgress( + migrationProgressChanged: { [weak self] progress, minEstimatedTotalTime in + self?.loadingViewController?.updateProgress( progress: progress, minEstimatedTotalTime: minEstimatedTotalTime ) }, migrationsCompletion: { [weak self] successful, needsConfigSync in - guard let strongSelf = self else { return } guard successful else { self?.showFailedMigrationAlert() return } - Configuration.performMainSetup() - JobRunner.add(executor: SyncPushTokensJob.self, for: .syncPushTokens) - - // Trigger any launch-specific jobs and start the JobRunner - JobRunner.appDidFinishLaunching() - - // Note that this does much more than set a flag; - // it will also run all deferred blocks (including the JobRunner - // 'appDidBecomeActive' method) - AppReadiness.setAppIsReady() - - DeviceSleepManager.sharedInstance.removeBlock(blockObject: strongSelf) - AppVersion.sharedInstance().mainAppLaunchDidComplete() - Environment.shared?.audioSession.setup() - Environment.shared?.reachabilityManager.setup() - - GRDBStorage.shared.writeAsync { db in - // Disable the SAE until the main app has successfully completed launch process - // at least once in the post-SAE world. - db[.isReadyForAppExtensions] = true - - if Identity.userExists(db) { - let appVersion: AppVersion = AppVersion.sharedInstance() - - // If the device needs to sync config or the user updated to a new version - if - needsConfigSync || ( - (appVersion.lastAppVersion?.count ?? 0) > 0 && - appVersion.lastAppVersion != appVersion.currentAppVersion - ) - { - try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() - } - } - } - - // Setup the UI - self?.ensureRootViewController() + self?.completePostMigrationSetup(needsConfigSync: needsConfigSync) } ) @@ -122,7 +85,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD CurrentAppContext().mainWindow = mainWindow // Show LoadingViewController until the async database view registrations are complete. - mainWindow.rootViewController = loadingViewController + mainWindow.rootViewController = self.loadingViewController mainWindow.makeKeyAndVisible() adapt(appMode: AppModeManager.getAppModeOrSystemDefault()) @@ -221,13 +184,51 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD // MARK: - App Readiness + private func completePostMigrationSetup(needsConfigSync: Bool) { + Configuration.performMainSetup() + JobRunner.add(executor: SyncPushTokensJob.self, for: .syncPushTokens) + + // Trigger any launch-specific jobs and start the JobRunner + JobRunner.appDidFinishLaunching() + + // Note that this does much more than set a flag; + // it will also run all deferred blocks (including the JobRunner + // 'appDidBecomeActive' method) + AppReadiness.setAppIsReady() + + DeviceSleepManager.sharedInstance.removeBlock(blockObject: self) + AppVersion.sharedInstance().mainAppLaunchDidComplete() + Environment.shared?.audioSession.setup() + Environment.shared?.reachabilityManager.setup() + + GRDBStorage.shared.writeAsync { db in + // Disable the SAE until the main app has successfully completed launch process + // at least once in the post-SAE world. + db[.isReadyForAppExtensions] = true + + if Identity.userExists(db) { + let appVersion: AppVersion = AppVersion.sharedInstance() + + // If the device needs to sync config or the user updated to a new version + if + needsConfigSync || ( + (appVersion.lastAppVersion?.count ?? 0) > 0 && + appVersion.lastAppVersion != appVersion.currentAppVersion + ) + { + try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() + } + } + } + + // Setup the UI + self.ensureRootViewController() + } + private func showFailedMigrationAlert() { let alert = UIAlertController( title: "Session", - message: [ - "DATABASE_MIGRATION_FAILED".localized(), - "modal_share_logs_explanation".localized() - ].joined(separator: "\n\n"), + message: "DATABASE_MIGRATION_FAILED".localized(), preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "modal_share_logs_title".localized(), style: .default) { _ in @@ -235,7 +236,33 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD self?.showFailedMigrationAlert() } }) - alert.addAction(UIAlertAction(title: "Close", style: .destructive) { _ in + alert.addAction(UIAlertAction(title: "vc_restore_title".localized(), style: .destructive) { _ in + // Remove the legacy database and any message hashes that have been migrated to the new DB + try? SUKLegacy.deleteLegacyDatabaseFilesAndKey() + + GRDBStorage.shared.write { db in + try SnodeReceivedMessageInfo.deleteAll(db) + } + + // The re-run the migration (should succeed since there is no data) + AppSetup.runPostSetupMigrations( + migrationProgressChanged: { [weak self] progress, minEstimatedTotalTime in + self?.loadingViewController?.updateProgress( + progress: progress, + minEstimatedTotalTime: minEstimatedTotalTime + ) + }, + migrationsCompletion: { [weak self] successful, needsConfigSync in + guard successful else { + self?.showFailedMigrationAlert() + return + } + + self?.completePostMigrationSetup(needsConfigSync: needsConfigSync) + } + ) + }) + alert.addAction(UIAlertAction(title: "Close", style: .default) { _ in DDLog.flushLog() exit(0) }) diff --git a/Session/Meta/Translations/de.lproj/Localizable.strings b/Session/Meta/Translations/de.lproj/Localizable.strings index f2b31545d..9eb1c3640 100644 --- a/Session/Meta/Translations/de.lproj/Localizable.strings +++ b/Session/Meta/Translations/de.lproj/Localizable.strings @@ -655,4 +655,4 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Fehler"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; diff --git a/Session/Meta/Translations/en.lproj/Localizable.strings b/Session/Meta/Translations/en.lproj/Localizable.strings index 6fbaaf7a0..60cee62fc 100644 --- a/Session/Meta/Translations/en.lproj/Localizable.strings +++ b/Session/Meta/Translations/en.lproj/Localizable.strings @@ -655,4 +655,4 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Error"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; diff --git a/Session/Meta/Translations/es.lproj/Localizable.strings b/Session/Meta/Translations/es.lproj/Localizable.strings index b8976f4ab..9ffdb9505 100644 --- a/Session/Meta/Translations/es.lproj/Localizable.strings +++ b/Session/Meta/Translations/es.lproj/Localizable.strings @@ -655,4 +655,4 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Fallo"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; diff --git a/Session/Meta/Translations/fa.lproj/Localizable.strings b/Session/Meta/Translations/fa.lproj/Localizable.strings index 3b417a5ed..0c0b2a806 100644 --- a/Session/Meta/Translations/fa.lproj/Localizable.strings +++ b/Session/Meta/Translations/fa.lproj/Localizable.strings @@ -655,4 +655,4 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "خطاء"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; diff --git a/Session/Meta/Translations/fi.lproj/Localizable.strings b/Session/Meta/Translations/fi.lproj/Localizable.strings index ad574e77b..057b87804 100644 --- a/Session/Meta/Translations/fi.lproj/Localizable.strings +++ b/Session/Meta/Translations/fi.lproj/Localizable.strings @@ -655,4 +655,4 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Error"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; diff --git a/Session/Meta/Translations/fr.lproj/Localizable.strings b/Session/Meta/Translations/fr.lproj/Localizable.strings index 8a0cb7603..42a04ad7a 100644 --- a/Session/Meta/Translations/fr.lproj/Localizable.strings +++ b/Session/Meta/Translations/fr.lproj/Localizable.strings @@ -655,4 +655,4 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Erreur"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; diff --git a/Session/Meta/Translations/hi.lproj/Localizable.strings b/Session/Meta/Translations/hi.lproj/Localizable.strings index 8a1d1756e..6cb8421e2 100644 --- a/Session/Meta/Translations/hi.lproj/Localizable.strings +++ b/Session/Meta/Translations/hi.lproj/Localizable.strings @@ -655,4 +655,4 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Error"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; diff --git a/Session/Meta/Translations/hr.lproj/Localizable.strings b/Session/Meta/Translations/hr.lproj/Localizable.strings index 9ab1118e5..1ea6f38fa 100644 --- a/Session/Meta/Translations/hr.lproj/Localizable.strings +++ b/Session/Meta/Translations/hr.lproj/Localizable.strings @@ -655,4 +655,4 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Error"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; diff --git a/Session/Meta/Translations/id-ID.lproj/Localizable.strings b/Session/Meta/Translations/id-ID.lproj/Localizable.strings index 82d25a1cb..d46d1a539 100644 --- a/Session/Meta/Translations/id-ID.lproj/Localizable.strings +++ b/Session/Meta/Translations/id-ID.lproj/Localizable.strings @@ -655,4 +655,4 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Galat"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; diff --git a/Session/Meta/Translations/it.lproj/Localizable.strings b/Session/Meta/Translations/it.lproj/Localizable.strings index 0b0e191ed..d3d10bec1 100644 --- a/Session/Meta/Translations/it.lproj/Localizable.strings +++ b/Session/Meta/Translations/it.lproj/Localizable.strings @@ -655,4 +655,4 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Errore"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; diff --git a/Session/Meta/Translations/ja.lproj/Localizable.strings b/Session/Meta/Translations/ja.lproj/Localizable.strings index a55316f2b..2e984b53e 100644 --- a/Session/Meta/Translations/ja.lproj/Localizable.strings +++ b/Session/Meta/Translations/ja.lproj/Localizable.strings @@ -655,4 +655,4 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "エラー"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; diff --git a/Session/Meta/Translations/nl.lproj/Localizable.strings b/Session/Meta/Translations/nl.lproj/Localizable.strings index 4d162033a..d577ac53e 100644 --- a/Session/Meta/Translations/nl.lproj/Localizable.strings +++ b/Session/Meta/Translations/nl.lproj/Localizable.strings @@ -655,4 +655,4 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Error"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; diff --git a/Session/Meta/Translations/pl.lproj/Localizable.strings b/Session/Meta/Translations/pl.lproj/Localizable.strings index a3a719fbd..4b8359749 100644 --- a/Session/Meta/Translations/pl.lproj/Localizable.strings +++ b/Session/Meta/Translations/pl.lproj/Localizable.strings @@ -655,4 +655,4 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Błąd"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; diff --git a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings index d74af6dfb..38089a102 100644 --- a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings +++ b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings @@ -655,4 +655,4 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Erro"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; diff --git a/Session/Meta/Translations/ru.lproj/Localizable.strings b/Session/Meta/Translations/ru.lproj/Localizable.strings index bc67f61cf..8918c45ec 100644 --- a/Session/Meta/Translations/ru.lproj/Localizable.strings +++ b/Session/Meta/Translations/ru.lproj/Localizable.strings @@ -655,4 +655,4 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Ошибка"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; diff --git a/Session/Meta/Translations/si.lproj/Localizable.strings b/Session/Meta/Translations/si.lproj/Localizable.strings index 043265685..92bdd53c6 100644 --- a/Session/Meta/Translations/si.lproj/Localizable.strings +++ b/Session/Meta/Translations/si.lproj/Localizable.strings @@ -655,4 +655,4 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Error"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; diff --git a/Session/Meta/Translations/sk.lproj/Localizable.strings b/Session/Meta/Translations/sk.lproj/Localizable.strings index f418a60e7..9341ea3a5 100644 --- a/Session/Meta/Translations/sk.lproj/Localizable.strings +++ b/Session/Meta/Translations/sk.lproj/Localizable.strings @@ -655,4 +655,4 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Error"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; diff --git a/Session/Meta/Translations/sv.lproj/Localizable.strings b/Session/Meta/Translations/sv.lproj/Localizable.strings index 15e2a7a61..2fa616d8c 100644 --- a/Session/Meta/Translations/sv.lproj/Localizable.strings +++ b/Session/Meta/Translations/sv.lproj/Localizable.strings @@ -655,4 +655,4 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Error"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; diff --git a/Session/Meta/Translations/th.lproj/Localizable.strings b/Session/Meta/Translations/th.lproj/Localizable.strings index 6a6cf4813..c51a61b79 100644 --- a/Session/Meta/Translations/th.lproj/Localizable.strings +++ b/Session/Meta/Translations/th.lproj/Localizable.strings @@ -655,4 +655,4 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Error"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; diff --git a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings index b63b85a6b..185293321 100644 --- a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings +++ b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings @@ -655,4 +655,4 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Error"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; diff --git a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings index 3f8b36030..f5cb6a74a 100644 --- a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings @@ -655,4 +655,4 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "Error"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; diff --git a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings index 91c4c04c5..62f75a4ec 100644 --- a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings @@ -655,4 +655,4 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "ALERT_ERROR_TITLE" = "错误"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; diff --git a/Session/Settings/SettingsVC.swift b/Session/Settings/SettingsVC.swift index 5bab2109a..68075b8dc 100644 --- a/Session/Settings/SettingsVC.swift +++ b/Session/Settings/SettingsVC.swift @@ -593,9 +593,19 @@ final class SettingsVC: BaseVC, AvatarViewHelperDelegate { } @objc private func remigrateDatabase() { - GRDBStorage.deleteDatabaseFiles() - try? GRDBStorage.deleteDbKeys() - exit(1) + let alert = UIAlertController( + title: "Session", + message: "Are you sure you want to re-migrate from your old database state?\n\nWarning: If you had a migration error and picked the \"Restore your account\" option this will result in a complete loss of data and the need to manually restore from the seed", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "Re-migrate", style: .destructive) { _ in + GRDBStorage.deleteDatabaseFiles() + try? GRDBStorage.deleteDbKeys() + exit(1) + }) + alert.addAction(UIAlertAction(title: "Cancel", style: .default)) + + navigationController?.present(alert, animated: true) } @objc private func showPath() { diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift index 4f1419775..bda1e1e40 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift @@ -22,7 +22,8 @@ extension MessageReceiver { .defaulting(to: Date(timeIntervalSince1970: 0)) .timeIntervalSince1970 - // Profile + // Profile (also force-approve the current user in case the account got into a weird state or + // restored directly from a migration) try MessageReceiver.updateProfileIfNeeded( db, publicKey: userPublicKey, @@ -31,6 +32,12 @@ extension MessageReceiver { profileKey: OWSAES256Key(data: message.profileKey), sentTimestamp: messageSentTimestamp ) + try Contact(id: userPublicKey) + .with( + isApproved: true, + didApproveMe: true + ) + .save(db) if isInitialSync || messageSentTimestamp > lastConfigTimestamp { if isInitialSync { diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift index 405ba8568..cb065a635 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift @@ -120,14 +120,18 @@ extension MessageReceiver { guard let threadId: String = threadId, let thread: SessionThread = try? SessionThread.fetchOne(db, id: threadId), - !thread.isNoteToSelf(db), - let contact: Contact = try? thread.contact.fetchOne(db), - !contact.isApproved + !thread.isNoteToSelf(db) else { return } - try? contact + // Sending a message to someone flags them as approved so create the contact record if + // it doesn't exist + let contact: Contact = Contact.fetchOrCreate(db, id: threadId) + + guard !contact.isApproved else { return } + + _ = try? contact .with(isApproved: true) - .update(db) + .saved(db) } else { // The message was sent to the current user so flag their 'didApproveMe' as true (can't send a message to diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 8e0e49d4d..941f4615a 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -343,6 +343,7 @@ public extension SessionThreadViewModel { SUM(\(interaction[.wasRead]) = false AND \(interaction[.hasMention]) = true) AS \(ViewModel.threadUnreadMentionCountKey) FROM \(Interaction.self) + WHERE \(SQL("\(interaction[.variant]) != \(Interaction.Variant.standardIncomingDeleted)")) GROUP BY \(interaction[.threadId]) ) AS \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id]) @@ -492,8 +493,6 @@ public extension SessionThreadViewModel { let openGroup: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() - let interactionIdLiteral: SQL = SQL(stringLiteral: Interaction.Columns.id.name) - let interactionThreadIdLiteral: SQL = SQL(stringLiteral: Interaction.Columns.threadId.name) let closedGroupUserCountTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.closedGroupUserCountString)_table") let groupMemberGroupIdColumnLiteral: SQL = SQL(stringLiteral: GroupMember.Columns.groupId.name) let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) diff --git a/SessionUtilitiesKit/Database/GRDBStorage.swift b/SessionUtilitiesKit/Database/GRDBStorage.swift index c13e4d3ce..3d59ac1e6 100644 --- a/SessionUtilitiesKit/Database/GRDBStorage.swift +++ b/SessionUtilitiesKit/Database/GRDBStorage.swift @@ -32,10 +32,6 @@ public final class GRDBStorage { // MARK: - Initialization -// if CurrentAppContext().isMainApp { -// GRDBStorage.deleteDatabaseFiles() // TODO: Remove this. -// try! GRDBStorage.deleteDbKeys() // TODO: Remove this. -// } public init( customWriter: DatabaseWriter? = nil, customMigrations: [TargetMigrations]? = nil @@ -184,6 +180,7 @@ public final class GRDBStorage { self?.hasCompletedMigrations = true self?.migrationProgressUpdater = nil SUKLegacy.clearLegacyDatabaseInstance() +// SUKLegacy.deleteLegacyDatabaseFilesAndKey() // TODO: Delete legacy database after the migration is done if let error = error { SNLog("[Migration Error] Migration failed with error: \(error)") diff --git a/SessionUtilitiesKit/Database/LegacyDatabase/SUKLegacy.swift b/SessionUtilitiesKit/Database/LegacyDatabase/SUKLegacy.swift index 950952ef9..5d3756d74 100644 --- a/SessionUtilitiesKit/Database/LegacyDatabase/SUKLegacy.swift +++ b/SessionUtilitiesKit/Database/LegacyDatabase/SUKLegacy.swift @@ -109,6 +109,13 @@ public enum SUKLegacy { self.database = nil } + public static func deleteLegacyDatabaseFilesAndKey() throws { + OWSFileSystem.deleteFile(legacyDatabaseFilepath) + OWSFileSystem.deleteFile("\(legacyDatabaseFilepath)-shm") + OWSFileSystem.deleteFile("\(legacyDatabaseFilepath)-wal") + try SSKDefaultKeychainStorage.shared.remove(service: keychainService, key: keychainDBCipherKeySpec) + } + // MARK: - UnknownDBObject @objc(LegacyUnknownDBObject) diff --git a/SignalUtilitiesKit/Utilities/AppSetup.swift b/SignalUtilitiesKit/Utilities/AppSetup.swift index 61a0fc0b3..32221892c 100644 --- a/SignalUtilitiesKit/Utilities/AppSetup.swift +++ b/SignalUtilitiesKit/Utilities/AppSetup.swift @@ -45,22 +45,40 @@ public enum AppSetup { /// `performMainSetup` **MUST** run before `perform(migrations:)` Configuration.performMainSetup() - GRDBStorage.shared.perform( - migrations: [ - SNUtilitiesKit.migrations(), - SNSnodeKit.migrations(), - SNMessagingKit.migrations() - ], - onProgressUpdate: migrationProgressChanged, - onComplete: { success, needsConfigSync in - DispatchQueue.main.async { - migrationsCompletion(success, needsConfigSync) - - // The 'if' is only there to prevent the "variable never read" warning from showing - if backgroundTask != nil { backgroundTask = nil } - } - } + + runPostSetupMigrations( + backgroundTask: backgroundTask, + migrationProgressChanged: migrationProgressChanged, + migrationsCompletion: migrationsCompletion ) + + // The 'if' is only there to prevent the "variable never read" warning from showing + if backgroundTask != nil { backgroundTask = nil } } } + + public static func runPostSetupMigrations( + backgroundTask: OWSBackgroundTask? = nil, + migrationProgressChanged: ((CGFloat, TimeInterval) -> ())? = nil, + migrationsCompletion: @escaping (Bool, Bool) -> () + ) { + var backgroundTask: OWSBackgroundTask? = (backgroundTask ?? OWSBackgroundTask(labelStr: #function)) + + GRDBStorage.shared.perform( + migrations: [ + SNUtilitiesKit.migrations(), + SNSnodeKit.migrations(), + SNMessagingKit.migrations() + ], + onProgressUpdate: migrationProgressChanged, + onComplete: { success, needsConfigSync in + DispatchQueue.main.async { + migrationsCompletion(success, needsConfigSync) + + // The 'if' is only there to prevent the "variable never read" warning from showing + if backgroundTask != nil { backgroundTask = nil } + } + } + ) + } } From 20dc74bc96722de65c777ab7ece5bd6b376f945c Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 24 Jun 2022 18:29:45 +1000 Subject: [PATCH 116/157] Added paging to the Home/MessageRequests screens and fix a bunch of bugs Added a cache to the Identicon to prevent unneeded image generation Replaced some 'withTint' calls to use the standard 'withRenderingMode' instead Fixed a bug where the background would remain when swiping to reply Fixed a crash which could occur with String-based settings Fixed an issue where all messages in a thread wouldn't get marked as read when opening the thread (ie. existing behaviour) Fixed a bug where going to the all media screen from a specific Fixed a bug where the 'areCallsEnabled' preference wasn't getting migrated Fixed a bug where you couldn't join any of the default open groups Fixed a bug where it was polling for the invalid placeholder default open group Fixed a few threading issues related to PromiseKit defaulting to run on the main thread Updated and number of processes to run on "default" priority queues intead of "userInitiated" ones (since the docs suggest those are blocking) Optimised the PagedDatabaseObserver to do a much more efficient count query Updated the PagedDatabaseObserver to allow for triggering content updates when data changes outside of the paged or associated tables changes Updated the HomeVC and MessageRequestsViewController to use paged queries Made some optimisations to prevent unneeded database changes --- .../Conversations/ConversationViewModel.swift | 13 +- .../Content Views/DeletedMessageView.swift | 3 +- .../Content Views/LinkPreviewView.swift | 4 +- .../OpenGroupInvitationView.swift | 8 +- .../Content Views/QuoteView.swift | 4 +- .../Message Cells/InfoMessageCell.swift | 3 +- .../Message Cells/VisibleMessageCell.swift | 5 +- Session/Home/HomeVC.swift | 198 ++++++-- Session/Home/HomeViewModel.swift | 333 ++++++++++--- .../MessageRequestsViewController.swift | 176 +++++-- .../MessageRequestsViewModel.swift | 170 ++++++- .../MediaGalleryViewModel.swift | 59 ++- .../MediaPageViewController.swift | 3 +- .../PushRegistrationManager.swift | 1 + Session/Notifications/SyncPushTokensJob.swift | 35 +- Session/Onboarding/DisplayNameVC.swift | 7 +- Session/Open Groups/JoinOpenGroupVC.swift | 2 +- Session/Settings/SettingsVC.swift | 1 + Session/Utilities/BackgroundPoller.swift | 1 + .../Database/LegacyDatabase/SMKLegacy.swift | 3 +- .../Migrations/_003_YDBToGRDBMigration.swift | 1 + .../Database/Models/Attachment.swift | 5 +- .../Database/Models/OpenGroup.swift | 51 +- .../Database/Models/SessionThread.swift | 15 +- .../Jobs/Types/AttachmentDownloadJob.swift | 7 +- .../Jobs/Types/AttachmentUploadJob.swift | 2 + .../Jobs/Types/DisappearingMessagesJob.swift | 1 + .../Types/FailedAttachmentDownloadsJob.swift | 1 + .../Jobs/Types/FailedMessageSendsJob.swift | 1 + .../Jobs/Types/GarbageCollectionJob.swift | 1 + .../Jobs/Types/MessageReceiveJob.swift | 1 + .../Jobs/Types/MessageSendJob.swift | 5 +- .../Jobs/Types/NotifyPushServerJob.swift | 11 +- .../RetrieveDefaultOpenGroupRoomsJob.swift | 9 +- .../Jobs/Types/SendReadReceiptsJob.swift | 5 +- .../Jobs/Types/UpdateProfilePictureJob.swift | 2 + .../Open Groups/OpenGroupAPI.swift | 2 + .../Open Groups/OpenGroupManager.swift | 91 ++-- ...essageReceiver+ConfigurationMessages.swift | 60 ++- .../Sending & Receiving/MessageReceiver.swift | 13 +- .../MessageSender+Convenience.swift | 1 + .../Sending & Receiving/MessageSender.swift | 17 +- .../Pollers/ClosedGroupPoller.swift | 1 + .../Shared Models/MessageViewModel.swift | 32 +- .../SessionThreadViewModel.swift | 464 ++++++++++-------- .../Utilities/ProfileManager.swift | 49 +- SessionSnodeKit/GetSnodePoolJob.swift | 6 +- .../Database/Models/Setting.swift | 43 +- .../Types/PagedDatabaseObserver.swift | 227 +++++++-- SessionUtilitiesKit/JobRunner/JobRunner.swift | 4 +- .../MediaMessageView.swift | 3 +- .../Profile Pictures/Identicon+ObjC.swift | 17 +- 52 files changed, 1489 insertions(+), 688 deletions(-) diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 3881a4ccb..c5fb9aca5 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -82,7 +82,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { // distinct stutter) self.pagedDataObserver = self.setupPagedObserver(for: threadId) - // Run the initial query on a backgorund thread so we don't block the push transition + // Run the initial query on a background thread so we don't block the push transition DispatchQueue.global(qos: .default).async { [weak self] in // If we don't have a `initialFocusedId` then default to `.pageBefore` (it'll query // from a `0` offset) @@ -159,10 +159,11 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { ) ], filterSQL: MessageViewModel.filterSQL(threadId: threadId), + groupSQL: MessageViewModel.groupSQL, orderSQL: MessageViewModel.orderSQL, dataQuery: MessageViewModel.baseQuery( orderSQL: MessageViewModel.orderSQL, - baseFilterSQL: MessageViewModel.filterSQL(threadId: threadId) + groupSQL: MessageViewModel.groupSQL ), associatedRecords: [ AssociatedRecord( @@ -388,13 +389,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { } public func markAllAsRead() { - guard - let lastInteractionId: Int64 = self.interactionData - .first(where: { $0.model == .messages })? - .elements - .last? - .id - else { return } + guard let lastInteractionId: Int64 = self.threadData.interactionId else { return } let threadId: String = self.threadData.threadId let trySendReadReceipt: Bool = (self.threadData.threadIsMessageRequest == false) diff --git a/Session/Conversations/Message Cells/Content Views/DeletedMessageView.swift b/Session/Conversations/Message Cells/Content Views/DeletedMessageView.swift index 46b224bd2..437689b35 100644 --- a/Session/Conversations/Message Cells/Content Views/DeletedMessageView.swift +++ b/Session/Conversations/Message Cells/Content Views/DeletedMessageView.swift @@ -27,13 +27,14 @@ final class DeletedMessageView: UIView { private func setUpViewHierarchy(textColor: UIColor) { // Image view let icon = UIImage(named: "ic_trash")? - .withTint(textColor)? + .withRenderingMode(.alwaysTemplate) .resizedImage(to: CGSize( width: DeletedMessageView.iconSize, height: DeletedMessageView.iconSize )) let imageView = UIImageView(image: icon) + imageView.tintColor = textColor imageView.contentMode = .center imageView.set(.width, to: DeletedMessageView.iconImageViewSize) imageView.set(.height, to: DeletedMessageView.iconImageViewSize) diff --git a/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift b/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift index 7c61cc5c6..16eb6e9b5 100644 --- a/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift +++ b/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift @@ -56,9 +56,9 @@ final class LinkPreviewView: UIView { private lazy var cancelButton: UIButton = { // FIXME: This will have issues with theme transitions - let tint: UIColor = (isLightMode ? .black : .white) let result: UIButton = UIButton(type: .custom) - result.setImage(UIImage(named: "X")?.withTint(tint), for: UIControl.State.normal) + result.setImage(UIImage(named: "X")?.withRenderingMode(.alwaysTemplate), for: UIControl.State.normal) + result.tintColor = (isLightMode ? .black : .white) let cancelButtonSize = LinkPreviewView.cancelButtonSize result.set(.width, to: cancelButtonSize) diff --git a/Session/Conversations/Message Cells/Content Views/OpenGroupInvitationView.swift b/Session/Conversations/Message Cells/Content Views/OpenGroupInvitationView.swift index 98a653d61..f95841449 100644 --- a/Session/Conversations/Message Cells/Content Views/OpenGroupInvitationView.swift +++ b/Session/Conversations/Message Cells/Content Views/OpenGroupInvitationView.swift @@ -65,9 +65,13 @@ final class OpenGroupInvitationView: UIView { // Icon let iconSize = OpenGroupInvitationView.iconSize let iconName = (isOutgoing ? "Globe" : "Plus") - let icon = UIImage(named: iconName)?.withTint(.white)?.resizedImage(to: CGSize(width: iconSize, height: iconSize)) let iconImageViewSize = OpenGroupInvitationView.iconImageViewSize - let iconImageView = UIImageView(image: icon) + let iconImageView = UIImageView( + image: UIImage(named: iconName)? + .withRenderingMode(.alwaysTemplate) + .resizedImage(to: CGSize(width: iconSize, height: iconSize)) + ) + iconImageView.tintColor = .white iconImageView.contentMode = .center iconImageView.layer.cornerRadius = iconImageViewSize / 2 iconImageView.layer.masksToBounds = true diff --git a/Session/Conversations/Message Cells/Content Views/QuoteView.swift b/Session/Conversations/Message Cells/Content Views/QuoteView.swift index d17e1dc24..16b91f6ca 100644 --- a/Session/Conversations/Message Cells/Content Views/QuoteView.swift +++ b/Session/Conversations/Message Cells/Content Views/QuoteView.swift @@ -233,8 +233,8 @@ final class QuoteView: UIView { // Cancel button let cancelButton = UIButton(type: .custom) - let tint: UIColor = isLightMode ? .black : .white - cancelButton.setImage(UIImage(named: "X")?.withTint(tint), for: UIControl.State.normal) + cancelButton.setImage(UIImage(named: "X")?.withRenderingMode(.alwaysTemplate), for: UIControl.State.normal) + cancelButton.tintColor = (isLightMode ? .black : .white) cancelButton.set(.width, to: cancelButtonSize) cancelButton.set(.height, to: cancelButtonSize) cancelButton.addTarget(self, action: #selector(cancel), for: UIControl.Event.touchUpInside) diff --git a/Session/Conversations/Message Cells/InfoMessageCell.swift b/Session/Conversations/Message Cells/InfoMessageCell.swift index 86b270498..4e5b2ab94 100644 --- a/Session/Conversations/Message Cells/InfoMessageCell.swift +++ b/Session/Conversations/Message Cells/InfoMessageCell.swift @@ -72,7 +72,8 @@ final class InfoMessageCell: MessageCell { }() if let icon = icon { - iconImageView.image = icon.withTint(Colors.text) + iconImageView.image = icon.withRenderingMode(.alwaysTemplate) + iconImageView.tintColor = Colors.text } iconImageViewWidthConstraint.constant = (icon != nil) ? InfoMessageCell.iconSize : 0 diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 370047e7b..cc57d5282 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -98,7 +98,8 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel let size = VisibleMessageCell.replyButtonSize result.set(.width, to: size) result.set(.height, to: size) - result.image = UIImage(named: "ic_reply")!.withTint(Colors.text) + result.image = UIImage(named: "ic_reply")?.withRenderingMode(.alwaysTemplate) + result.tintColor = Colors.text return result }() @@ -726,7 +727,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel guard let cellViewModel: MessageViewModel = self.viewModel else { return } let viewsToMove: [UIView] = [ - bubbleView, profilePictureView, replyButton, timerView, messageStatusImageView + bubbleView, bubbleBackgroundView, profilePictureView, replyButton, timerView, messageStatusImageView ] let translationX = gestureRecognizer.translation(in: self).x.clamp(-CGFloat.greatestFiniteMagnitude, 0) diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index fbff99edb..ca1d002b5 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -8,15 +8,26 @@ import SessionUtilitiesKit import SignalUtilitiesKit final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConversationButtonSetDelegate, SeedReminderViewDelegate { - typealias Section = HomeViewModel.Section - typealias Item = SessionThreadViewModel + private static let loadingHeaderHeight: CGFloat = 20 private let viewModel: HomeViewModel = HomeViewModel() private var dataChangeObservable: DatabaseCancellable? - private var hasLoadedInitialData: Bool = false + private var hasLoadedInitialStateData: Bool = false + private var hasLoadedInitialThreadData: Bool = false + private var isLoadingMore: Bool = false // MARK: - Intialization + init() { + GRDBStorage.shared.addObserver(viewModel.pagedDataObserver) + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + preconditionFailure("Use init() instead.") + } + deinit { NotificationCenter.default.removeObserver(self) } @@ -71,6 +82,10 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve result.dataSource = self result.delegate = self + if #available(iOS 15.0, *) { + result.sectionHeaderTopPadding = 0 + } + return result }() @@ -151,7 +166,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve tableViewTopConstraint = tableView.pin(.top, to: .bottom, of: seedReminderView) } else { - tableViewTopConstraint = tableView.pin(.top, to: .top, of: view, withInset: Values.smallSpacing) + tableViewTopConstraint = tableView.pin(.top, to: .top, of: view) } tableView.pin(.trailing, to: .trailing, of: view) tableView.pin(.bottom, to: .bottom, of: view) @@ -211,8 +226,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - // Stop observing database changes - dataChangeObservable?.cancel() + stopObservingChanges() } @objc func applicationDidBecomeActive(_ notification: Notification) { @@ -220,8 +234,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve } @objc func applicationDidResignActive(_ notification: Notification) { - // Stop observing database changes - dataChangeObservable?.cancel() + stopObservingChanges() } // MARK: - Updating @@ -233,7 +246,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve // If we haven't done the initial load the trigger it immediately (blocking the main // thread so we remain on the launch screen until it completes to be consistent with // the old behaviour) - scheduling: (hasLoadedInitialData ? + scheduling: (hasLoadedInitialStateData ? .async(onQueue: .main) : .immediate ), @@ -243,26 +256,27 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve self?.handleUpdates(state) } ) + + self.viewModel.onThreadChange = { [weak self] updatedThreadData in + self?.handleThreadUpdates(updatedThreadData) + } } - private func handleUpdates(_ updatedState: HomeViewModel.State) { + private func stopObservingChanges() { + // Stop observing database changes + dataChangeObservable?.cancel() + self.viewModel.onThreadChange = nil + } + + private func handleUpdates(_ updatedState: HomeViewModel.State, initialLoad: Bool = false) { // 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(updatedState) } + guard hasLoadedInitialStateData else { + hasLoadedInitialStateData = true + UIView.performWithoutAnimation { handleUpdates(updatedState, initialLoad: true) } return } - // Hide the 'loading conversations' label (now that we have received conversation data) - loadingConversationsLabel.isHidden = true - - // Show the empty state if there is no data - emptyStateView.isHidden = ( - !updatedState.sections.isEmpty && - updatedState.sections.contains(where: { !$0.elements.isEmpty }) - ) - if updatedState.userProfile != self.viewModel.state.userProfile { updateNavBarButtons() } @@ -280,9 +294,36 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve } } + self.viewModel.updateState(updatedState) + } + + private func handleThreadUpdates(_ updatedData: [HomeViewModel.SectionModel], initialLoad: Bool = false) { + // 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 hasLoadedInitialThreadData else { + hasLoadedInitialThreadData = true + UIView.performWithoutAnimation { handleThreadUpdates(updatedData, initialLoad: true) } + return + } + + // Hide the 'loading conversations' label (now that we have received conversation data) + loadingConversationsLabel.isHidden = true + + // Show the empty state if there is no data + emptyStateView.isHidden = ( + !updatedData.isEmpty && + updatedData.contains(where: { !$0.elements.isEmpty }) + ) + + CATransaction.begin() + CATransaction.setCompletionBlock { [weak self] in + // Complete page loading + self?.isLoadingMore = false + } + // Reload the table content (animate changes after the first load) tableView.reload( - using: StagedChangeset(source: viewModel.state.sections, target: updatedState.sections), + using: StagedChangeset(source: viewModel.threadData, target: updatedData), deleteSectionsAnimation: .none, insertSectionsAnimation: .none, reloadSectionsAnimation: .none, @@ -290,15 +331,11 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve insertRowsAnimation: .top, reloadRowsAnimation: .none, interrupt: { $0.changeCount > 100 } // Prevent too many changes from causing performance issues - ) { [weak self] updatedSections in - guard let currentState: HomeViewModel.State = self?.viewModel.state else { return } - - self?.viewModel.updateState(currentState.with(sections: updatedSections)) + ) { [weak self] updatedData in + self?.viewModel.updateThreadData(updatedData) } - self.viewModel.updateState( - self.viewModel.state.with(showViewedSeedBanner: updatedState.showViewedSeedBanner) - ) + CATransaction.commit() } private func updateNavBarButtons() { @@ -357,35 +394,87 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve // MARK: - UITableViewDataSource func numberOfSections(in tableView: UITableView) -> Int { - return viewModel.state.sections.count + return viewModel.threadData.count } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return viewModel.state.sections[section].elements.count + let section: HomeViewModel.SectionModel = viewModel.threadData[section] + + return section.elements.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let section: ArraySection = viewModel.state.sections[indexPath.section] + let section: HomeViewModel.SectionModel = viewModel.threadData[indexPath.section] switch section.model { case .messageRequests: + let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row] let cell: MessageRequestsCell = tableView.dequeue(type: MessageRequestsCell.self, for: indexPath) - cell.update(with: Int(section.elements[indexPath.row].threadUnreadCount ?? 0)) + cell.update(with: Int(threadViewModel.threadUnreadCount ?? 0)) return cell case .threads: + let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row] let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath) - cell.update(with: section.elements[indexPath.row]) + cell.update(with: threadViewModel) return cell + + default: preconditionFailure("Other sections should have no content") + } + } + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + let section: HomeViewModel.SectionModel = viewModel.threadData[section] + + switch section.model { + case .loadMore: + let loadingIndicator: UIActivityIndicatorView = UIActivityIndicatorView(style: .medium) + loadingIndicator.tintColor = Colors.text + loadingIndicator.alpha = 0.5 + loadingIndicator.startAnimating() + + let view: UIView = UIView() + view.addSubview(loadingIndicator) + loadingIndicator.center(in: view) + + return view + + default: return nil } } // MARK: - UITableViewDelegate + + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + let section: HomeViewModel.SectionModel = viewModel.threadData[section] + + switch section.model { + case .loadMore: return HomeVC.loadingHeaderHeight + default: return 0 + } + } + + func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) { + guard self.hasLoadedInitialThreadData && !self.isLoadingMore else { return } + + let section: HomeViewModel.SectionModel = self.viewModel.threadData[section] + + switch section.model { + case .loadMore: + self.isLoadingMore = true + + DispatchQueue.global(qos: .default).async { [weak self] in + self?.viewModel.pagedDataObserver?.load(.pageAfter) + } + + default: break + } + } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - let section: ArraySection = viewModel.state.sections[indexPath.section] + let section: HomeViewModel.SectionModel = self.viewModel.threadData[indexPath.section] switch section.model { case .messageRequests: @@ -393,13 +482,16 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve self.navigationController?.pushViewController(viewController, animated: true) case .threads: + let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row] show( - section.elements[indexPath.row].threadId, - variant: section.elements[indexPath.row].threadVariant, + threadViewModel.threadId, + variant: threadViewModel.threadVariant, with: .none, focusedInteractionId: nil, animated: true ) + + default: break } } @@ -408,7 +500,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve } func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? { - let section: ArraySection = viewModel.state.sections[indexPath.section] + let section: HomeViewModel.SectionModel = self.viewModel.threadData[indexPath.section] switch section.model { case .messageRequests: @@ -420,12 +512,12 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve return [hide] case .threads: - let cellViewModel: SessionThreadViewModel = section.elements[indexPath.row] + let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row] let delete: UITableViewRowAction = UITableViewRowAction( style: .destructive, title: "TXT_DELETE_TITLE".localized() ) { [weak self] _, _ in - let message = (cellViewModel.currentUserIsClosedGroupAdmin == true ? + let message = (threadViewModel.currentUserIsClosedGroupAdmin == true ? "admin_group_leave_warning".localized() : "CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE".localized() ) @@ -440,20 +532,20 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve style: .destructive ) { _ in GRDBStorage.shared.writeAsync { db in - switch cellViewModel.threadVariant { + switch threadViewModel.threadVariant { case .closedGroup: try MessageSender - .leave(db, groupPublicKey: cellViewModel.threadId) + .leave(db, groupPublicKey: threadViewModel.threadId) .retainUntilComplete() case .openGroup: - OpenGroupManager.shared.delete(db, openGroupId: cellViewModel.threadId) + OpenGroupManager.shared.delete(db, openGroupId: threadViewModel.threadId) default: break } _ = try SessionThread - .filter(id: cellViewModel.threadId) + .filter(id: threadViewModel.threadId) .deleteAll(db) } }) @@ -468,36 +560,36 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve let pin: UITableViewRowAction = UITableViewRowAction( style: .normal, - title: (cellViewModel.threadIsPinned ? + title: (threadViewModel.threadIsPinned ? "UNPIN_BUTTON_TEXT".localized() : "PIN_BUTTON_TEXT".localized() ) ) { _, _ in GRDBStorage.shared.writeAsync { db in try SessionThread - .filter(id: cellViewModel.threadId) - .updateAll(db, SessionThread.Columns.isPinned.set(to: !cellViewModel.threadIsPinned)) + .filter(id: threadViewModel.threadId) + .updateAll(db, SessionThread.Columns.isPinned.set(to: !threadViewModel.threadIsPinned)) } } - guard cellViewModel.threadVariant == .contact && !cellViewModel.threadIsNoteToSelf else { + guard threadViewModel.threadVariant == .contact && !threadViewModel.threadIsNoteToSelf else { return [ delete, pin ] } let block: UITableViewRowAction = UITableViewRowAction( style: .normal, - title: (cellViewModel.threadIsBlocked == true ? + title: (threadViewModel.threadIsBlocked == true ? "BLOCK_LIST_UNBLOCK_BUTTON".localized() : "BLOCK_LIST_BLOCK_BUTTON".localized() ) ) { _, _ in GRDBStorage.shared.writeAsync { db in try Contact - .filter(id: cellViewModel.threadId) + .filter(id: threadViewModel.threadId) .updateAll( db, Contact.Columns.isBlocked.set( - to: (cellViewModel.threadIsBlocked == false ? + to: (threadViewModel.threadIsBlocked == false ? true: false ) @@ -510,6 +602,8 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve block.backgroundColor = Colors.unimportant return [ delete, block, pin ] + + default: return [] } } diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 55e389e37..543830292 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -4,37 +4,179 @@ import Foundation import GRDB import DifferenceKit import SignalUtilitiesKit +import SessionUtilitiesKit public class HomeViewModel { + public typealias SectionModel = ArraySection + + // MARK: - Section + public enum Section: Differentiable { case messageRequests case threads + case loadMore } + // MARK: - Variables + + public static let pageSize: Int = 14 + public struct State: Equatable { let showViewedSeedBanner: Bool + let hasHiddenMessageRequests: Bool + let unreadMessageRequestThreadCount: Int let userProfile: Profile? - let sections: [ArraySection] - func with( - showViewedSeedBanner: Bool? = nil, - userProfile: Profile? = nil, - sections: [ArraySection]? = nil - ) -> State { - return State( - showViewedSeedBanner: (showViewedSeedBanner ?? self.showViewedSeedBanner), - userProfile: (userProfile ?? self.userProfile), - sections: (sections ?? self.sections) - ) + init( + showViewedSeedBanner: Bool = !GRDBStorage.shared[.hasViewedSeed], + hasHiddenMessageRequests: Bool = GRDBStorage.shared[.hasHiddenMessageRequests], + unreadMessageRequestThreadCount: Int = 0, + userProfile: Profile? = nil + ) { + self.showViewedSeedBanner = showViewedSeedBanner + self.hasHiddenMessageRequests = hasHiddenMessageRequests + self.unreadMessageRequestThreadCount = unreadMessageRequestThreadCount + self.userProfile = userProfile } } + // MARK: - Initialization + + init() { + self.state = GRDBStorage.shared.read { db in try HomeViewModel.retrieveState(db) } + .defaulting(to: State()) + self.pagedDataObserver = nil + + // Note: Since this references self we need to finish initializing before setting it, we + // also want to skip the initial query and trigger it async so that the push animation + // doesn't stutter (it should load basically immediately but without this there is a + // distinct stutter) + let userPublicKey: String = getUserHexEncodedPublicKey() + let thread: TypedTableAlias = TypedTableAlias() + self.pagedDataObserver = PagedDatabaseObserver( + pagedTable: SessionThread.self, + pageSize: HomeViewModel.pageSize, + idColumn: .id, + observedChanges: [ + PagedData.ObservedChanges( + table: SessionThread.self, + columns: [ + .id, + .shouldBeVisible, + .isPinned, + .mutedUntilTimestamp, + .onlyNotifyForMentions + ] + ), + PagedData.ObservedChanges( + table: Interaction.self, + columns: [ + .body, + .wasRead + ], + joinToPagedType: { + let interaction: TypedTableAlias = TypedTableAlias() + + return SQL("LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])") + }() + ), + PagedData.ObservedChanges( + table: Contact.self, + columns: [.isBlocked], + joinToPagedType: { + let contact: TypedTableAlias = TypedTableAlias() + + return SQL("LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])") + }() + ), + PagedData.ObservedChanges( + table: Profile.self, + columns: [.name, .nickname, .profilePictureFileName], + joinToPagedType: { + let profile: TypedTableAlias = TypedTableAlias() + + return SQL("LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(thread[.id])") + }() + ), + PagedData.ObservedChanges( + table: ClosedGroup.self, + columns: [.name], + joinToPagedType: { + let closedGroup: TypedTableAlias = TypedTableAlias() + + return SQL("LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id])") + }() + ), + PagedData.ObservedChanges( + table: OpenGroup.self, + columns: [.name, .imageData], + joinToPagedType: { + let openGroup: TypedTableAlias = TypedTableAlias() + + return SQL("LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id])") + }() + ), + PagedData.ObservedChanges( + table: RecipientState.self, + columns: [.state], + joinToPagedType: { + let interaction: TypedTableAlias = TypedTableAlias() + let recipientState: TypedTableAlias = TypedTableAlias() + + return """ + LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id]) + LEFT JOIN \(RecipientState.self) ON \(recipientState[.interactionId]) = \(interaction[.id]) + """ + }() + ), + PagedData.ObservedChanges( + table: ThreadTypingIndicator.self, + columns: [.threadId], + joinToPagedType: { + let typingIndicator: TypedTableAlias = TypedTableAlias() + + return SQL("LEFT JOIN \(typingIndicator[.threadId]) = \(thread[.id])") + }() + ) + ], + /// **Note:** This `optimisedJoinSQL` value includes the required minimum joins needed for the query + joinSQL: SessionThreadViewModel.optimisedJoinSQL, + filterSQL: SessionThreadViewModel.homeFilterSQL(userPublicKey: userPublicKey), + groupSQL: SessionThreadViewModel.groupSQL, + orderSQL: SessionThreadViewModel.homeOrderSQL, + dataQuery: SessionThreadViewModel.baseQuery( + userPublicKey: userPublicKey, + filterSQL: SessionThreadViewModel.homeFilterSQL(userPublicKey: userPublicKey), + groupSQL: SessionThreadViewModel.groupSQL, + orderSQL: SessionThreadViewModel.homeOrderSQL + ), + onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in + guard let updatedThreadData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else { + return + } + + // If we have the 'onThreadChange' callback then trigger it, otherwise just store the changes + // to be sent to the callback if we ever start observing again (when we have the callback it needs + // to do the data updating as it's tied to UI updates and can cause crashes if not updated in the + // correct order) + guard let onThreadChange: (([SectionModel]) -> ()) = self?.onThreadChange else { + self?.unobservedThreadDataChanges = updatedThreadData + return + } + + onThreadChange(updatedThreadData) + } + ) + + // Run the initial query on the main thread so we prevent the app from leaving the loading screen + // until we have data (Note: the `.pageBefore` will query from a `0` offset loading the first page) + self.pagedDataObserver?.load(.pageBefore) + } + + // MARK: - State + /// This value is the current state of the view - public private(set) var state: State = State( - showViewedSeedBanner: !GRDBStorage.shared[.hasViewedSeed], - userProfile: nil, - sections: [] - ) + public private(set) var state: State /// 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 @@ -44,75 +186,104 @@ public class HomeViewModel { /// 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 observableState = ValueObservation - .tracking( - regions: [ - // We explicitly define the regions we want to track as the automatic detection - // seems to include a bunch of columns we will fetch but probably don't need to - // track changes for - SessionThread.select( - .id, - .shouldBeVisible, - .isPinned, - .mutedUntilTimestamp, - .onlyNotifyForMentions - ), - Setting.filter(ids: [ - Setting.BoolKey.hasHiddenMessageRequests.rawValue, - Setting.BoolKey.hasViewedSeed.rawValue - ]), - Contact.select(.isBlocked, .isApproved), // 'isApproved' for message requests - Profile.select(.name, .nickname, .profilePictureFileName), - ClosedGroup.select(.name), - OpenGroup.select(.name, .imageData), - GroupMember.select(.groupId), - Interaction.select( - .body, - .wasRead - ), - Attachment.select(.state), - RecipientState.select(.state), - ThreadTypingIndicator.select(.threadId) - ], - fetch: { db -> State in - let hasViewedSeed: Bool = db[.hasViewedSeed] - let userProfile: Profile = Profile.fetchOrCreateCurrentUser(db) - let unreadMessageRequestCount: Int = try SessionThread - .unreadMessageRequestsQuery(userPublicKey: userProfile.id) - .fetchCount(db) - let finalUnreadMessageRequestCount: Int = (db[.hasHiddenMessageRequests] ? 0 : unreadMessageRequestCount) - let threads: [SessionThreadViewModel] = try SessionThreadViewModel - .homeQuery(userPublicKey: userProfile.id) - .fetchAll(db) - - return State( - showViewedSeedBanner: !hasViewedSeed, - userProfile: userProfile, - sections: [ - ArraySection( - model: .messageRequests, - elements: [ - // If there are no unread message requests then hide the message request banner - (finalUnreadMessageRequestCount == 0 ? - nil : - SessionThreadViewModel( - unreadCount: UInt(finalUnreadMessageRequestCount) - ) - ) - ].compactMap { $0 } - ), - ArraySection( - model: .threads, - elements: threads - ) - ] - ) - } - ) + .trackingConstantRegion { db -> State in try HomeViewModel.retrieveState(db) } .removeDuplicates() - // MARK: - Functions + private static func retrieveState(_ db: Database) throws -> State { + let hasViewedSeed: Bool = db[.hasViewedSeed] + let hasHiddenMessageRequests: Bool = db[.hasHiddenMessageRequests] + let userProfile: Profile = Profile.fetchOrCreateCurrentUser(db) + let unreadMessageRequestThreadCount: Int = try SessionThread + .unreadMessageRequestsThreadIdQuery(userPublicKey: userProfile.id) + .fetchCount(db) + + return State( + showViewedSeedBanner: !hasViewedSeed, + hasHiddenMessageRequests: hasHiddenMessageRequests, + unreadMessageRequestThreadCount: unreadMessageRequestThreadCount, + userProfile: userProfile + ) + } public func updateState(_ updatedState: State) { + let oldState: State = self.state self.state = updatedState + + // If the messageRequest content changed then we need to re-process the thread data + guard + ( + oldState.hasHiddenMessageRequests != updatedState.hasHiddenMessageRequests || + oldState.unreadMessageRequestThreadCount != updatedState.unreadMessageRequestThreadCount + ), + let currentPageInfo: PagedData.PageInfo = self.pagedDataObserver?.pageInfo.wrappedValue + else { return } + + /// **MUST** have the same logic as in the 'PagedDataObserver.onChangeUnsorted' above + let currentData: [SessionThreadViewModel] = self.threadData.flatMap { $0.elements } + let updatedThreadData: [SectionModel] = self.process(data: currentData, for: currentPageInfo) + + guard let onThreadChange: (([SectionModel]) -> ()) = self.onThreadChange else { + self.unobservedThreadDataChanges = updatedThreadData + return + } + + onThreadChange(updatedThreadData) + } + + // MARK: - Thread Data + + public private(set) var unobservedThreadDataChanges: [SectionModel]? + public private(set) var threadData: [SectionModel] = [] + public private(set) var pagedDataObserver: PagedDatabaseObserver? + + public var onThreadChange: (([SectionModel]) -> ())? { + didSet { + // When starting to observe interaction changes we want to trigger a UI update just in case the + // data was changed while we weren't observing + if let unobservedThreadDataChanges: [SectionModel] = self.unobservedThreadDataChanges { + onThreadChange?(unobservedThreadDataChanges) + self.unobservedThreadDataChanges = nil + } + } + } + + private func process(data: [SessionThreadViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] { + let finalUnreadMessageRequestCount: Int = (self.state.hasHiddenMessageRequests ? + 0 : + self.state.unreadMessageRequestThreadCount + ) + + return [ + // If there are no unread message requests then hide the message request banner + (finalUnreadMessageRequestCount == 0 ? + [] : + [SectionModel( + section: .messageRequests, + elements: [ + SessionThreadViewModel(unreadCount: UInt(finalUnreadMessageRequestCount)) + ] + )] + ), + [ + SectionModel( + section: .threads, + elements: data + .sorted { lhs, rhs -> Bool in + if lhs.threadIsPinned && !rhs.threadIsPinned { return true } + if !lhs.threadIsPinned && rhs.threadIsPinned { return false } + + return lhs.lastInteractionDate > rhs.lastInteractionDate + } + ) + ], + (!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ? + [SectionModel(section: .loadMore)] : + [] + ) + ].flatMap { $0 } + } + + public func updateThreadData(_ updatedData: [SectionModel]) { + self.threadData = updatedData } } diff --git a/Session/Home/Message Requests/MessageRequestsViewController.swift b/Session/Home/Message Requests/MessageRequestsViewController.swift index 593dbd6fe..3d41a7fe7 100644 --- a/Session/Home/Message Requests/MessageRequestsViewController.swift +++ b/Session/Home/Message Requests/MessageRequestsViewController.swift @@ -8,9 +8,28 @@ import SessionMessagingKit import SignalUtilitiesKit class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDataSource { + private static let loadingHeaderHeight: CGFloat = 20 + private let viewModel: MessageRequestsViewModel = MessageRequestsViewModel() private var dataChangeObservable: DatabaseCancellable? - private var hasLoadedInitialData: Bool = false + private var hasLoadedInitialThreadData: Bool = false + private var isLoadingMore: Bool = false + + // MARK: - Intialization + + init() { + GRDBStorage.shared.addObserver(viewModel.pagedDataObserver) + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + preconditionFailure("Use init() instead.") + } + + deinit { + NotificationCenter.default.removeObserver(self) + } // MARK: - UI @@ -26,6 +45,10 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat let bottomInset = Values.newConversationButtonBottomOffset + NewConversationButtonSet.expandedButtonSize + Values.largeSpacing + NewConversationButtonSet.collapsedButtonSize result.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: bottomInset, right: 0) result.showsVerticalScrollIndicator = false + + if #available(iOS 15.0, *) { + result.sectionHeaderTopPadding = 0 + } return result }() @@ -122,10 +145,6 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat // Stop observing database changes dataChangeObservable?.cancel() } - - deinit { - NotificationCenter.default.removeObserver(self) - } // MARK: - Layout @@ -159,33 +178,27 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat // MARK: - Updating private func startObservingChanges() { - // Start observing for data changes - dataChangeObservable = GRDBStorage.shared.start( - viewModel.observableViewData, - onError: { _ in }, - onChange: { [weak self] viewData in - // The defaul scheduler emits changes on the main thread - self?.handleUpdates(viewData) - } - ) + self.viewModel.onThreadChange = { [weak self] updatedThreadData in + self?.handleThreadUpdates(updatedThreadData) + } } - private func handleUpdates(_ updatedViewData: [SessionThreadViewModel]) { + private func handleThreadUpdates(_ updatedData: [MessageRequestsViewModel.SectionModel], initialLoad: Bool = false) { // 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) } + guard hasLoadedInitialThreadData else { + hasLoadedInitialThreadData = true + UIView.performWithoutAnimation { handleThreadUpdates(updatedData, initialLoad: true) } return } // Show the empty state if there is no data - clearAllButton.isHidden = updatedViewData.isEmpty - emptyStateLabel.isHidden = !updatedViewData.isEmpty + clearAllButton.isHidden = updatedData.isEmpty + emptyStateLabel.isHidden = !updatedData.isEmpty // Reload the table content (animate changes after the first load) tableView.reload( - using: StagedChangeset(source: viewModel.viewData, target: updatedViewData), + using: StagedChangeset(source: viewModel.threadData, target: updatedData), deleteSectionsAnimation: .none, insertSectionsAnimation: .none, reloadSectionsAnimation: .none, @@ -194,7 +207,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat reloadRowsAnimation: .none, interrupt: { $0.changeCount > 100 } // Prevent too many changes from causing performance issues ) { [weak self] updatedData in - self?.viewModel.updateData(updatedData) + self?.viewModel.updateThreadData(updatedData) } } @@ -208,26 +221,94 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat // MARK: - UITableViewDataSource + func numberOfSections(in tableView: UITableView) -> Int { + return viewModel.threadData.count + } + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return viewModel.viewData.count + let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[section] + + return section.elements.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath) - cell.update(with: viewModel.viewData[indexPath.row]) - return cell + let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[indexPath.section] + + switch section.model { + case .threads: + let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row] + let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath) + cell.update(with: threadViewModel) + return cell + + default: preconditionFailure("Other sections should have no content") + } + } + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[section] + + switch section.model { + case .loadMore: + let loadingIndicator: UIActivityIndicatorView = UIActivityIndicatorView(style: .medium) + loadingIndicator.tintColor = Colors.text + loadingIndicator.alpha = 0.5 + loadingIndicator.startAnimating() + + let view: UIView = UIView() + view.addSubview(loadingIndicator) + loadingIndicator.center(in: view) + + return view + + default: return nil + } } // MARK: - UITableViewDelegate + + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[section] + + switch section.model { + case .loadMore: return MessageRequestsViewController.loadingHeaderHeight + default: return 0 + } + } + + func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) { + guard self.hasLoadedInitialThreadData && !self.isLoadingMore else { return } + + let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[section] + + switch section.model { + case .loadMore: + self.isLoadingMore = true + + DispatchQueue.global(qos: .default).async { [weak self] in + self?.viewModel.pagedDataObserver?.load(.pageAfter) + } + + default: break + } + } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - let conversationVC: ConversationVC = ConversationVC( - threadId: viewModel.viewData[indexPath.row].threadId, - threadVariant: viewModel.viewData[indexPath.row].threadVariant - ) - self.navigationController?.pushViewController(conversationVC, animated: true) + let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[indexPath.section] + + switch section.model { + case .threads: + let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row] + let conversationVC: ConversationVC = ConversationVC( + threadId: threadViewModel.threadId, + threadVariant: threadViewModel.threadVariant + ) + self.navigationController?.pushViewController(conversationVC, animated: true) + + default: break + } } func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { @@ -235,24 +316,35 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat } func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? { - let threadId: String = viewModel.viewData[indexPath.row].threadId - let delete = UITableViewRowAction( - style: .destructive, - title: "TXT_DELETE_TITLE".localized() - ) { [weak self] _, _ in - self?.delete(threadId) - } - delete.backgroundColor = Colors.destructive + let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[indexPath.section] + + switch section.model { + case .threads: + let threadId: String = section.elements[indexPath.row].threadId + let delete = UITableViewRowAction( + style: .destructive, + title: "TXT_DELETE_TITLE".localized() + ) { [weak self] _, _ in + self?.delete(threadId) + } + delete.backgroundColor = Colors.destructive - return [ delete ] + return [ delete ] + + default: return [] + } } // MARK: - Interaction @objc private func clearAllTapped() { - guard !viewModel.viewData.isEmpty else { return } + guard viewModel.threadData.first { $0.model == .threads }?.elements.isEmpty == false else { return } - let threadIds: [String] = viewModel.viewData.map { $0.threadId } + let threadIds: [String] = (viewModel.threadData + .first { $0.model == .threads }? + .elements + .map { $0.threadId }) + .defaulting(to: []) let alertVC: UIAlertController = UIAlertController( title: "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE".localized(), message: nil, diff --git a/Session/Home/Message Requests/MessageRequestsViewModel.swift b/Session/Home/Message Requests/MessageRequestsViewModel.swift index d1ce5633d..96a7c69ad 100644 --- a/Session/Home/Message Requests/MessageRequestsViewModel.swift +++ b/Session/Home/Message Requests/MessageRequestsViewModel.swift @@ -6,32 +6,156 @@ import DifferenceKit import SignalUtilitiesKit public class MessageRequestsViewModel { - /// This value is the current state of the view - public private(set) var viewData: [SessionThreadViewModel] = [] + public typealias SectionModel = ArraySection - /// 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 { db -> [SessionThreadViewModel] in - let userPublicKey: String = getUserHexEncodedPublicKey(db) - - return try SessionThreadViewModel - .messageRequestsQuery(userPublicKey: userPublicKey) - .fetchAll(db) + // MARK: - Section + + public enum Section: Differentiable { + case threads + case loadMore + } + + // MARK: - Variables + + public static let pageSize: Int = 20 + + // MARK: - Initialization + + init() { + self.pagedDataObserver = nil + + // Note: Since this references self we need to finish initializing before setting it, we + // also want to skip the initial query and trigger it async so that the push animation + // doesn't stutter (it should load basically immediately but without this there is a + // distinct stutter) + let userPublicKey: String = getUserHexEncodedPublicKey() + let thread: TypedTableAlias = TypedTableAlias() + self.pagedDataObserver = PagedDatabaseObserver( + pagedTable: SessionThread.self, + pageSize: MessageRequestsViewModel.pageSize, + idColumn: .id, + observedChanges: [ + PagedData.ObservedChanges( + table: SessionThread.self, + columns: [ + .id, + .shouldBeVisible + ] + ), + PagedData.ObservedChanges( + table: Interaction.self, + columns: [ + .body, + .wasRead + ], + joinToPagedType: { + let interaction: TypedTableAlias = TypedTableAlias() + + return SQL("LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])") + }() + ), + PagedData.ObservedChanges( + table: Contact.self, + columns: [.isBlocked], + joinToPagedType: { + let contact: TypedTableAlias = TypedTableAlias() + + return SQL("LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])") + }() + ), + PagedData.ObservedChanges( + table: Profile.self, + columns: [.name, .nickname, .profilePictureFileName], + joinToPagedType: { + let profile: TypedTableAlias = TypedTableAlias() + + return SQL("LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(thread[.id])") + }() + ), + PagedData.ObservedChanges( + table: RecipientState.self, + columns: [.state], + joinToPagedType: { + let interaction: TypedTableAlias = TypedTableAlias() + let recipientState: TypedTableAlias = TypedTableAlias() + + return """ + LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id]) + LEFT JOIN \(RecipientState.self) ON \(recipientState[.interactionId]) = \(interaction[.id]) + """ + }() + ) + ], + /// **Note:** This `optimisedJoinSQL` value includes the required minimum joins needed for the query + joinSQL: SessionThreadViewModel.optimisedJoinSQL, + filterSQL: SessionThreadViewModel.messageRequestsFilterSQL(userPublicKey: userPublicKey), + groupSQL: SessionThreadViewModel.groupSQL, + orderSQL: SessionThreadViewModel.messageRequetsOrderSQL, + dataQuery: SessionThreadViewModel.baseQuery( + userPublicKey: userPublicKey, + filterSQL: SessionThreadViewModel.messageRequestsFilterSQL(userPublicKey: userPublicKey), + groupSQL: SessionThreadViewModel.groupSQL, + orderSQL: SessionThreadViewModel.messageRequetsOrderSQL + ), + onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in + guard let updatedThreadData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else { + return + } + + // If we have the 'onThreadChange' callback then trigger it, otherwise just store the changes + // to be sent to the callback if we ever start observing again (when we have the callback it needs + // to do the data updating as it's tied to UI updates and can cause crashes if not updated in the + // correct order) + guard let onThreadChange: (([SectionModel]) -> ()) = self?.onThreadChange else { + self?.unobservedThreadDataChanges = updatedThreadData + return + } + + onThreadChange(updatedThreadData) + } + ) + + // Run the initial query on a background thread so we don't block the push transition + DispatchQueue.global(qos: .default).async { [weak self] in + // The `.pageBefore` will query from a `0` offset loading the first page + self?.pagedDataObserver?.load(.pageBefore) } - .removeDuplicates() + } - // MARK: - Functions + // MARK: - Thread Data - public func updateData(_ updatedData: [SessionThreadViewModel]) { - self.viewData = updatedData + public private(set) var unobservedThreadDataChanges: [SectionModel]? + public private(set) var threadData: [SectionModel] = [] + public private(set) var pagedDataObserver: PagedDatabaseObserver? + + public var onThreadChange: (([SectionModel]) -> ())? { + didSet { + // When starting to observe interaction changes we want to trigger a UI update just in case the + // data was changed while we weren't observing + if let unobservedThreadDataChanges: [SectionModel] = self.unobservedThreadDataChanges { + onThreadChange?(unobservedThreadDataChanges) + self.unobservedThreadDataChanges = nil + } + } + } + + private func process(data: [SessionThreadViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] { + return [ + [ + SectionModel( + section: .threads, + elements: data + .sorted { lhs, rhs -> Bool in lhs.lastInteractionDate > rhs.lastInteractionDate } + ) + ], + (!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ? + [SectionModel(section: .loadMore)] : + [] + ) + ].flatMap { $0 } + } + + public func updateThreadData(_ updatedData: [SectionModel]) { + self.threadData = updatedData } } diff --git a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift index b7334eeae..01032e870 100644 --- a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift +++ b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift @@ -55,7 +55,8 @@ public class MediaGalleryViewModel { threadVariant: SessionThread.Variant, isPagedData: Bool, pageSize: Int = 1, - focusedAttachmentId: String? = nil + focusedAttachmentId: String? = nil, + performInitialQuerySync: Bool = false ) { self.threadId = threadId self.threadVariant = threadVariant @@ -68,7 +69,6 @@ public class MediaGalleryViewModel { // also want to skip the initial query and trigger it async so that the push animation // doesn't stutter (it should load basically immediately but without this there is a // distinct stutter) - let filterSQL: SQL = Item.filterSQL(threadId: threadId) self.pagedDataObserver = PagedDatabaseObserver( pagedTable: Attachment.self, pageSize: pageSize, @@ -80,9 +80,9 @@ public class MediaGalleryViewModel { ) ], joinSQL: Item.joinSQL, - filterSQL: filterSQL, + filterSQL: Item.filterSQL(threadId: threadId), orderSQL: Item.galleryOrderSQL, - dataQuery: Item.baseQuery(orderSQL: Item.galleryOrderSQL, baseFilterSQL: filterSQL), + dataQuery: Item.baseQuery(orderSQL: Item.galleryOrderSQL), onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in guard let updatedGalleryData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else { return @@ -102,7 +102,7 @@ public class MediaGalleryViewModel { ) // Run the initial query on a backgorund thread so we don't block the push transition - DispatchQueue.global(qos: .default).async { [weak self] in + let loadInitialData: () -> () = { [weak self] in // If we don't have a `initialFocusedId` then default to `.pageBefore` (it'll query // from a `0` offset) guard let initialFocusedId: String = focusedAttachmentId else { @@ -112,6 +112,20 @@ public class MediaGalleryViewModel { self?.pagedDataObserver?.load(.initialPageAround(id: initialFocusedId)) } + + // We have a custom transition when going from an attachment detail screen to the tile gallery + // so in that case we want to perform the initial query synchronously so that we have the content + // to do the transition (we don't clear the 'unobservedGalleryDataChanges' after setting it as + // we don't want to mess with the initial view controller behaviour) + guard !performInitialQuerySync else { + loadInitialData() + updateGalleryData(self.unobservedGalleryDataChanges ?? []) + return + } + + DispatchQueue.global(qos: .default).async { + loadInitialData() + } } // MARK: - Data @@ -258,30 +272,26 @@ public class MediaGalleryViewModel { return SQL("\(interaction[.timestampMs]), \(interactionAttachment[.albumIndex].desc)") }() - fileprivate static func baseQuery(orderSQL: SQL, baseFilterSQL: SQL) -> ((SQL?, SQL?) -> AdaptedFetchRequest>) { - return { additionalFilters, limitSQL -> AdaptedFetchRequest> in + fileprivate static func baseQuery(orderSQL: SQL, customFilters: SQL? = nil) -> (([Int64]) -> AdaptedFetchRequest>) { + return { rowIds -> AdaptedFetchRequest> in let attachment: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() let interactionAttachment: TypedTableAlias = TypedTableAlias() + let numColumnsBeforeLinkedRecords: Int = 6 let finalFilterSQL: SQL = { - guard let additionalFilters: SQL = additionalFilters else { + guard let customFilters: SQL = customFilters else { return """ - WHERE ( - \(baseFilterSQL) - ) + WHERE \(attachment.alias[Column.rowID]) IN \(rowIds) """ } return """ WHERE ( - \(baseFilterSQL) AND - \(additionalFilters) + \(customFilters) ) """ }() - let finalLimitSQL: SQL = (limitSQL ?? SQL(stringLiteral: "")) - let numColumnsBeforeLinkedRecords: Int = 6 let request: SQLRequest = """ SELECT \(interaction[.id]) AS \(Item.interactionIdKey), @@ -296,7 +306,6 @@ public class MediaGalleryViewModel { \(joinSQL) \(finalFilterSQL) ORDER BY \(orderSQL) - \(finalLimitSQL) """ return request.adapted { db in @@ -312,8 +321,8 @@ public class MediaGalleryViewModel { } } - fileprivate static func baseQuery(orderSQL: SQL, baseFilterSQL: SQL) -> AdaptedFetchRequest> { - return Item.baseQuery(orderSQL: orderSQL, baseFilterSQL: baseFilterSQL)(nil, nil) + fileprivate static func baseQuery(orderSQL: SQL, customFilters: SQL) -> AdaptedFetchRequest> { + return Item.baseQuery(orderSQL: orderSQL, customFilters: customFilters)([]) } func thumbnailImage(async: @escaping (UIImage) -> ()) { @@ -348,7 +357,7 @@ public class MediaGalleryViewModel { return try Item .baseQuery( orderSQL: SQL(interactionAttachment[.albumIndex]), - baseFilterSQL: SQL(""" + customFilters: SQL(""" \(attachment[.isValid]) = true AND \(interaction[.id]) = \(interactionId) """) @@ -372,7 +381,7 @@ public class MediaGalleryViewModel { let newAlbumData: [Item] = try Item .baseQuery( orderSQL: SQL(interactionAttachment[.albumIndex]), - baseFilterSQL: SQL(""" + customFilters: SQL(""" \(attachment[.isValid]) = true AND \(interaction[.id]) = \(interactionId) """) @@ -386,13 +395,13 @@ public class MediaGalleryViewModel { let itemBefore: Item? = try Item .baseQuery( orderSQL: Item.galleryReverseOrderSQL, - baseFilterSQL: SQL("\(interaction[.timestampMs]) > \(albumTimestampMs)") + customFilters: SQL("\(interaction[.timestampMs]) > \(albumTimestampMs)") ) .fetchOne(db) let itemAfter: Item? = try Item .baseQuery( orderSQL: Item.galleryOrderSQL, - baseFilterSQL: SQL("\(interaction[.timestampMs]) < \(albumTimestampMs)") + customFilters: SQL("\(interaction[.timestampMs]) < \(albumTimestampMs)") ) .fetchOne(db) @@ -523,14 +532,16 @@ public class MediaGalleryViewModel { public static func createTileViewController( threadId: String, threadVariant: SessionThread.Variant, - focusedAttachmentId: String? + focusedAttachmentId: String?, + performInitialQuerySync: Bool = false ) -> MediaTileViewController { let viewModel: MediaGalleryViewModel = MediaGalleryViewModel( threadId: threadId, threadVariant: threadVariant, isPagedData: true, pageSize: MediaTileViewController.itemPageSize, - focusedAttachmentId: focusedAttachmentId + focusedAttachmentId: focusedAttachmentId, + performInitialQuerySync: performInitialQuerySync ) return MediaTileViewController( diff --git a/Session/Media Viewing & Editing/MediaPageViewController.swift b/Session/Media Viewing & Editing/MediaPageViewController.swift index 640d3bbf5..a772343dc 100644 --- a/Session/Media Viewing & Editing/MediaPageViewController.swift +++ b/Session/Media Viewing & Editing/MediaPageViewController.swift @@ -474,7 +474,8 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou let tileViewController: MediaTileViewController = MediaGalleryViewModel.createTileViewController( threadId: self.viewModel.threadId, threadVariant: self.viewModel.threadVariant, - focusedAttachmentId: currentItem.attachment.id + focusedAttachmentId: currentItem.attachment.id, + performInitialQuerySync: true ) let navController: MediaGalleryNavigationController = MediaGalleryNavigationController() diff --git a/Session/Notifications/PushRegistrationManager.swift b/Session/Notifications/PushRegistrationManager.swift index 6ba0134c4..71ff8f9c2 100644 --- a/Session/Notifications/PushRegistrationManager.swift +++ b/Session/Notifications/PushRegistrationManager.swift @@ -241,6 +241,7 @@ public enum PushRegistrationError: Error { owsAssertDebug(CurrentAppContext().isMainApp) owsAssertDebug(type == .voIP) let payload = payload.dictionaryPayload + if let uuid = payload["uuid"] as? String, let caller = payload["caller"] as? String, let timestampMs = payload["timestamp"] as? Int64 { let call: SessionCall? = GRDBStorage.shared.write { db in let messageInfo: CallMessage.MessageInfo = CallMessage.MessageInfo( diff --git a/Session/Notifications/SyncPushTokensJob.swift b/Session/Notifications/SyncPushTokensJob.swift index fbcfd8657..1ce1a1220 100644 --- a/Session/Notifications/SyncPushTokensJob.swift +++ b/Session/Notifications/SyncPushTokensJob.swift @@ -14,6 +14,7 @@ public enum SyncPushTokensJob: JobExecutor { public static func run( _ job: Job, + queue: DispatchQueue, success: @escaping (Job, Bool) -> (), failure: @escaping (Job, Error?, Bool) -> (), deferred: @escaping (Job) -> () @@ -28,11 +29,35 @@ public enum SyncPushTokensJob: JobExecutor { // the main thread then swap to it guard Thread.isMainThread else { DispatchQueue.main.async { - run(job, success: success, failure: failure, deferred: deferred) + run(job, queue: queue, success: success, failure: failure, deferred: deferred) } return } - guard !UIApplication.shared.isRegisteredForRemoteNotifications else { + let isRegisteredForRemoteNotifications: Bool = UIApplication.shared.isRegisteredForRemoteNotifications + + // Swap back to the correct queue before continuing (don't want to inadvertantly do stuff on the main + // thread that could block the user) + queue.async { + SyncPushTokensJob.internalRun( + job, + queue: queue, + isRegisteredForRemoteNotifications: isRegisteredForRemoteNotifications, + success: success, + failure: failure, + deferred: deferred + ) + } + } + + private static func internalRun( + _ job: Job, + queue: DispatchQueue, + isRegisteredForRemoteNotifications: Bool, + success: @escaping (Job, Bool) -> (), + failure: @escaping (Job, Error?, Bool) -> (), + deferred: @escaping (Job) -> () + ) { + guard !isRegisteredForRemoteNotifications else { deferred(job) // Don't need to do anything if push notifications are already registered return } @@ -41,7 +66,6 @@ public enum SyncPushTokensJob: JobExecutor { // Determine if we want to upload only if stale (Note: This should default to true, and be true if // 'details' isn't provided) - // TODO: Double check on a real device let uploadOnlyIfStale: Bool = ((try? JSONDecoder().decode(Details.self, from: job.details ?? Data()))?.uploadOnlyIfStale ?? true) // Get the app version info (used to determine if we want to update the push tokens) @@ -49,7 +73,7 @@ public enum SyncPushTokensJob: JobExecutor { let currentAppVersion: String? = AppVersion.sharedInstance().currentAppVersion PushRegistrationManager.shared.requestPushTokens() - .then { (pushToken: String, voipToken: String) -> Promise in + .then(on: queue) { (pushToken: String, voipToken: String) -> Promise in let lastPushToken: String? = GRDBStorage.shared[.lastRecordedPushToken] let lastVoipToken: String? = GRDBStorage.shared[.lastRecordedVoipToken] let shouldUploadTokens: Bool = ( @@ -82,7 +106,7 @@ public enum SyncPushTokensJob: JobExecutor { } } } - .ensure { success(job, false) } // We want to complete this job regardless of success or failure + .ensure(on: queue) { success(job, false) } // We want to complete this job regardless of success or failure .retainUntilComplete() } @@ -97,6 +121,7 @@ public enum SyncPushTokensJob: JobExecutor { SyncPushTokensJob.run( job, + queue: DispatchQueue.global(qos: .default), success: { _, _ in }, failure: { _, _, _ in }, deferred: { _ in } diff --git a/Session/Onboarding/DisplayNameVC.swift b/Session/Onboarding/DisplayNameVC.swift index 60aa1e1ea..d23d6b440 100644 --- a/Session/Onboarding/DisplayNameVC.swift +++ b/Session/Onboarding/DisplayNameVC.swift @@ -167,7 +167,12 @@ final class DisplayNameVC: BaseVC { } // Try to save the user name but ignore the result - ProfileManager.updateLocal(profileName: displayName, avatarImage: nil, requiredSync: false) + ProfileManager.updateLocal( + queue: DispatchQueue.global(qos: .default), + profileName: displayName, + avatarImage: nil, + requiredSync: false + ) let pnModeVC = PNModeVC() navigationController?.pushViewController(pnModeVC, animated: true) } diff --git a/Session/Open Groups/JoinOpenGroupVC.swift b/Session/Open Groups/JoinOpenGroupVC.swift index 866f2442b..74891013e 100644 --- a/Session/Open Groups/JoinOpenGroupVC.swift +++ b/Session/Open Groups/JoinOpenGroupVC.swift @@ -311,7 +311,7 @@ private final class EnterURLVC: UIViewController, UIGestureRecognizerDelegate, O return ( (!suggestionGrid.isHidden && !suggestionGrid.frame.contains(location)) || - location.y > urlTextView.frame.maxY + (suggestionGrid.isHidden && location.y > urlTextView.frame.maxY) ) } diff --git a/Session/Settings/SettingsVC.swift b/Session/Settings/SettingsVC.swift index 68075b8dc..2cd3a4799 100644 --- a/Session/Settings/SettingsVC.swift +++ b/Session/Settings/SettingsVC.swift @@ -443,6 +443,7 @@ final class SettingsVC: BaseVC, AvatarViewHelperDelegate { ModalActivityIndicatorViewController.present(fromViewController: navigationController!, canCancel: false) { [weak self, displayNameToBeUploaded, profilePictureToBeUploaded] modalActivityIndicator in ProfileManager.updateLocal( + queue: DispatchQueue.global(qos: .default), profileName: (name ?? ""), avatarImage: profilePicture, requiredSync: true, diff --git a/Session/Utilities/BackgroundPoller.swift b/Session/Utilities/BackgroundPoller.swift index 034e42c30..dfc1af817 100644 --- a/Session/Utilities/BackgroundPoller.swift +++ b/Session/Utilities/BackgroundPoller.swift @@ -145,6 +145,7 @@ public final class BackgroundPoller: NSObject { // Note: In the background we just want jobs to fail silently MessageReceiveJob.run( job, + queue: DispatchQueue.main, success: { _, _ in seal.fulfill(()) }, failure: { _, _, _ in seal.fulfill(()) }, deferred: { _ in seal.fulfill(()) } diff --git a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift index b27cc5e9e..41d9109d9 100644 --- a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift +++ b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift @@ -53,6 +53,7 @@ public enum SMKLegacy { internal static let preferencesKeyLastRecordedPushToken = "LastRecordedPushToken" internal static let preferencesKeyLastRecordedVoipToken = "LastRecordedVoipToken" internal static let preferencesKeyAreLinkPreviewsEnabled = "areLinkPreviewsEnabled" + internal static let preferencesKeyAreCallsEnabled = "areCallsEnabled" internal static let preferencesKeyNotificationPreviewType = "preferencesKeyNotificationPreviewType" internal static let preferencesKeyNotificationSoundInForeground = "NotificationSoundInForeground" internal static let preferencesKeyHasSavedThreadKey = "hasSavedThread" @@ -1342,7 +1343,7 @@ public enum SMKLegacy { // 'messageRequestAccepted' messages (hard-coding a timestamp to be sure that any calls // after the value was changed are correctly identified as 'unknown') case (.call, .none): - guard (coder.decodeObject(forKey: "timestamp") as? UInt64 ?? 0) < 1647500000000 else { + guard (coder.decodeObject(forKey: "timestamp") as? UInt64 ?? 0) < 1648000000000 else { fallthrough } diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 6c0005eb8..a0ad07650 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -1404,6 +1404,7 @@ enum _003_YDBToGRDBMigration: Migration { .defaulting(to: (15 * 60)) db[.appSwitcherPreviewEnabled] = (legacyPreferences[SMKLegacy.preferencesKeyScreenSecurityDisabled] as? Bool == false) db[.areLinkPreviewsEnabled] = (legacyPreferences[SMKLegacy.preferencesKeyAreLinkPreviewsEnabled] as? Bool == true) + db[.areCallsEnabled] = (legacyPreferences[SMKLegacy.preferencesKeyAreCallsEnabled] as? Bool == true) db[.hasHiddenMessageRequests] = CurrentAppContext().appUserDefaults() .bool(forKey: SMKLegacy.userDefaultsHasHiddenMessageRequests) diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index 4a167fc35..7b69b5e19 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -920,6 +920,7 @@ extension Attachment { extension Attachment { internal func upload( _ db: Database? = nil, + queue: DispatchQueue, using upload: (Database, Data) -> Promise, encrypt: Bool, success: (() -> Void)?, @@ -1037,7 +1038,7 @@ extension Attachment { }() uploadPromise - .done(on: DispatchQueue.global(qos: .userInitiated)) { fileId in + .done(on: queue) { fileId in // Save the final upload info let uploadedAttachment: Attachment? = GRDBStorage.shared.write { db in try updatedAttachment? @@ -1061,7 +1062,7 @@ extension Attachment { success?() } - .catch { error in + .catch(on: queue) { error in GRDBStorage.shared.write { db in try updatedAttachment? .with(state: .failedUpload) diff --git a/SessionMessagingKit/Database/Models/OpenGroup.swift b/SessionMessagingKit/Database/Models/OpenGroup.swift index baa8acba8..0ab4a96ce 100644 --- a/SessionMessagingKit/Database/Models/OpenGroup.swift +++ b/SessionMessagingKit/Database/Models/OpenGroup.swift @@ -136,35 +136,34 @@ public struct OpenGroup: Codable, Identifiable, FetchableRecord, PersistableReco } } -// MARK: - Mutation +// MARK: - GRDB Interactions public extension OpenGroup { - func with( - publicKey: String? = nil, - isActive: Bool? = nil, - name: String? = nil, - roomDescription: String? = nil, - imageId: String? = nil, - imageData: Data? = nil, - userCount: Int64? = nil, - infoUpdates: Int64? = nil, - sequenceNumber: Int64? = nil + static func fetchOrCreate( + _ db: Database, + server: String, + roomToken: String, + publicKey: String ) -> OpenGroup { - return OpenGroup( - server: self.server, - roomToken: self.roomToken, - publicKey: (publicKey ?? self.publicKey), - isActive: (isActive ?? self.isActive), - name: (name ?? self.name), - roomDescription: (roomDescription ?? self.roomDescription), - imageId: (imageId ?? self.imageId), - imageData: (imageData ?? self.imageData), - userCount: (userCount ?? self.userCount), - infoUpdates: (infoUpdates ?? self.infoUpdates), - sequenceNumber: (sequenceNumber ?? self.sequenceNumber), - inboxLatestMessageId: self.inboxLatestMessageId, - outboxLatestMessageId: self.outboxLatestMessageId - ) + guard let existingGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: OpenGroup.idFor(roomToken: roomToken, server: server)) else { + return OpenGroup( + server: server, + roomToken: roomToken, + publicKey: publicKey, + isActive: false, + name: "", + roomDescription: nil, + imageId: nil, + imageData: nil, + userCount: 0, + infoUpdates: -1, + sequenceNumber: 0, + inboxLatestMessageId: 0, + outboxLatestMessageId: 0 + ) + } + + return existingGroup } } diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index 5394cc1ae..f1c94ecf9 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -196,21 +196,15 @@ public extension SessionThread { """ } - static func unreadMessageRequestsQuery(userPublicKey: String) -> SQLRequest { + static func unreadMessageRequestsThreadIdQuery(userPublicKey: String) -> SQLRequest { let thread: TypedTableAlias = TypedTableAlias() - let contact: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() return """ - SELECT \(thread.allColumns()) + SELECT \(thread[.id]) FROM \(SessionThread.self) - JOIN ( - SELECT - \(interaction[.threadId]), - MIN(\(interaction[.wasRead])) AS \(SQL(stringLiteral: "\(Interaction.Columns.wasRead.name)")) - FROM \(Interaction.self) - GROUP BY \(interaction[.threadId]) - ) AS \(Interaction.self) ON ( + JOIN \(Interaction.self) ON ( \(interaction[.threadId]) = \(thread[.id]) AND \(interaction[.wasRead]) = false ) @@ -218,6 +212,7 @@ public extension SessionThread { WHERE ( \(SessionThread.isMessageRequest(userPublicKey: userPublicKey)) ) + GROUP BY \(thread[.id]) """ } diff --git a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift index 5ea862585..2c9755347 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift @@ -13,6 +13,7 @@ public enum AttachmentDownloadJob: JobExecutor { public static func run( _ job: Job, + queue: DispatchQueue, success: @escaping (Job, Bool) -> (), failure: @escaping (Job, Error?, Bool) -> (), deferred: @escaping (Job) -> () @@ -71,7 +72,7 @@ public enum AttachmentDownloadJob: JobExecutor { }() downloadPromise - .then { data -> Promise in + .then(on: queue) { data -> Promise in try data.write(to: temporaryFileUrl, options: .atomic) let plaintext: Data = try { @@ -96,7 +97,7 @@ public enum AttachmentDownloadJob: JobExecutor { return Promise.value(()) } - .done { + .done(on: queue) { // Remove the temporary file OWSFileSystem.deleteFile(temporaryFileUrl.path) @@ -119,7 +120,7 @@ public enum AttachmentDownloadJob: JobExecutor { success(job, false) } - .catch { error in + .catch(on: queue) { error in OWSFileSystem.deleteFile(temporaryFileUrl.path) switch error { diff --git a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift index aa2c20c09..c2b7173ce 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift @@ -13,6 +13,7 @@ public enum AttachmentUploadJob: JobExecutor { public static func run( _ job: Job, + queue: DispatchQueue, success: @escaping (Job, Bool) -> (), failure: @escaping (Job, Error?, Bool) -> (), deferred: @escaping (Job) -> () @@ -37,6 +38,7 @@ public enum AttachmentUploadJob: JobExecutor { // issues when the success/failure closures get called before the upload as the JobRunner will attempt to // update the state of the job immediately attachment.upload( + queue: queue, using: { db, data in if let openGroup: OpenGroup = openGroup { return OpenGroupAPI diff --git a/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift b/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift index 2a42f72a5..ebfea6a9b 100644 --- a/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift +++ b/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift @@ -11,6 +11,7 @@ public enum DisappearingMessagesJob: JobExecutor { public static func run( _ job: Job, + queue: DispatchQueue, success: @escaping (Job, Bool) -> (), failure: @escaping (Job, Error?, Bool) -> (), deferred: @escaping (Job) -> () diff --git a/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift b/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift index ff4b0703e..08fd8f2fe 100644 --- a/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift +++ b/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift @@ -12,6 +12,7 @@ public enum FailedAttachmentDownloadsJob: JobExecutor { public static func run( _ job: Job, + queue: DispatchQueue, success: @escaping (Job, Bool) -> (), failure: @escaping (Job, Error?, Bool) -> (), deferred: @escaping (Job) -> () diff --git a/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift b/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift index 3b6086d3d..e66f0775b 100644 --- a/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift +++ b/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift @@ -12,6 +12,7 @@ public enum FailedMessageSendsJob: JobExecutor { public static func run( _ job: Job, + queue: DispatchQueue, success: @escaping (Job, Bool) -> (), failure: @escaping (Job, Error?, Bool) -> (), deferred: @escaping (Job) -> () diff --git a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift index d08547bb9..b5d73c972 100644 --- a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift +++ b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift @@ -15,6 +15,7 @@ public enum GarbageCollectionJob: JobExecutor { public static func run( _ job: Job, + queue: DispatchQueue, success: @escaping (Job, Bool) -> (), failure: @escaping (Job, Error?, Bool) -> (), deferred: @escaping (Job) -> () diff --git a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift index a04d0fccb..3d4f158db 100644 --- a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift @@ -12,6 +12,7 @@ public enum MessageReceiveJob: JobExecutor { public static func run( _ job: Job, + queue: DispatchQueue, success: @escaping (Job, Bool) -> (), failure: @escaping (Job, Error?, Bool) -> (), deferred: @escaping (Job) -> () diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift index 1f9dda368..e4ddef856 100644 --- a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift @@ -14,6 +14,7 @@ public enum MessageSendJob: JobExecutor { public static func run( _ job: Job, + queue: DispatchQueue, success: @escaping (Job, Bool) -> (), failure: @escaping (Job, Error?, Bool) -> (), deferred: @escaping (Job) -> () @@ -138,8 +139,8 @@ public enum MessageSendJob: JobExecutor { interactionId: job.interactionId ) } - .done2 { _ in success(job, false) } - .catch2 { error in + .done(on: queue) { _ in success(job, false) } + .catch(on: queue) { error in SNLog("Couldn't send message due to error: \(error).") switch error { diff --git a/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift b/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift index 3c22f7430..313070dd5 100644 --- a/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift +++ b/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift @@ -12,6 +12,7 @@ public enum NotifyPushServerJob: JobExecutor { public static func run( _ job: Job, + queue: DispatchQueue, success: @escaping (Job, Bool) -> (), failure: @escaping (Job, Error?, Bool) -> (), deferred: @escaping (Job) -> () @@ -42,7 +43,7 @@ public enum NotifyPushServerJob: JobExecutor { request.allHTTPHeaderFields = [ Header.contentType.rawValue: "application/json" ] request.httpBody = body - attempt(maxRetryCount: 4, recoveringOn: DispatchQueue.global()) { + attempt(maxRetryCount: 4, recoveringOn: queue) { OnionRequestAPI .sendOnionRequest( request, @@ -52,12 +53,8 @@ public enum NotifyPushServerJob: JobExecutor { ) .map { _ in } } - .done { _ in - success(job, false) - } - .`catch` { error in - failure(job, error, false) - } + .done(on: queue) { _ in success(job, false) } + .catch(on: queue) { error in failure(job, error, false) } .retainUntilComplete() } } diff --git a/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift b/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift index 85e6bbd1e..7e2c6e4a4 100644 --- a/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift +++ b/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift @@ -12,6 +12,7 @@ public enum RetrieveDefaultOpenGroupRoomsJob: JobExecutor { public static func run( _ job: Job, + queue: DispatchQueue, success: @escaping (Job, Bool) -> (), failure: @escaping (Job, Error?, Bool) -> (), deferred: @escaping (Job) -> () @@ -24,7 +25,11 @@ public enum RetrieveDefaultOpenGroupRoomsJob: JobExecutor { // The OpenGroupAPI won't make any API calls if there is no entry for an OpenGroup // in the database so we need to create a dummy one to retrieve the default room data + let defaultGroupId: String = OpenGroup.idFor(roomToken: "", server: OpenGroupAPI.defaultServer) + GRDBStorage.shared.write { db in + guard try OpenGroup.exists(db, id: defaultGroupId) == false else { return } + _ = try OpenGroup( server: OpenGroupAPI.defaultServer, roomToken: "", @@ -38,8 +43,8 @@ public enum RetrieveDefaultOpenGroupRoomsJob: JobExecutor { } OpenGroupManager.getDefaultRoomsIfNeeded() - .done { _ in success(job, false) } - .catch { error in failure(job, error, false) } + .done(on: queue) { _ in success(job, false) } + .catch(on: queue) { error in failure(job, error, false) } .retainUntilComplete() } } diff --git a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift index 429bda7e6..b6bc5e5b9 100644 --- a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift +++ b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift @@ -13,6 +13,7 @@ public enum SendReadReceiptsJob: JobExecutor { public static func run( _ job: Job, + queue: DispatchQueue, success: @escaping (Job, Bool) -> (), failure: @escaping (Job, Error?, Bool) -> (), deferred: @escaping (Job) -> () @@ -45,7 +46,7 @@ public enum SendReadReceiptsJob: JobExecutor { interactionId: nil ) } - .done { + .done(on: queue) { // When we complete the 'SendReadReceiptsJob' we want to immediately schedule // another one for the same thread but with a 'nextRunTimestamp' set to the // 'minRunFrequency' value to throttle the read receipt requests @@ -79,7 +80,7 @@ public enum SendReadReceiptsJob: JobExecutor { success(updatedJob ?? job, shouldFinishCurrentJob) } - .catch { error in failure(job, error, false) } + .catch(on: queue) { error in failure(job, error, false) } .retainUntilComplete() } } diff --git a/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift b/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift index 884429958..ca26bd3ec 100644 --- a/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift +++ b/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift @@ -12,6 +12,7 @@ public enum UpdateProfilePictureJob: JobExecutor { public static func run( _ job: Job, + queue: DispatchQueue, success: @escaping (Job, Bool) -> (), failure: @escaping (Job, Error?, Bool) -> (), deferred: @escaping (Job) -> () @@ -36,6 +37,7 @@ public enum UpdateProfilePictureJob: JobExecutor { let profilePicture: UIImage? = ProfileManager.profileAvatar(id: profile.id) ProfileManager.updateLocal( + queue: queue, profileName: profile.name, avatarImage: profilePicture, requiredSync: true, diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index 157328c08..5cfa41638 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -67,6 +67,8 @@ public enum OpenGroupAPI { // Per-room requests contentsOf: (try? OpenGroup .filter(OpenGroup.Columns.server == server.lowercased()) // Note: The `OpenGroup` type converts to lowercase in init + .filter(OpenGroup.Columns.isActive == true) + .filter(OpenGroup.Columns.roomToken != "") .fetchAll(db)) .defaulting(to: []) .flatMap { openGroup -> [BatchRequestInfoType] in diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index e951ebf15..c3f60f60b 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -73,8 +73,8 @@ public final class OpenGroupManager: NSObject { // we don't want to start a poller for this as the user hasn't actually joined a room try OpenGroup .select(.server) + .filter(OpenGroup.Columns.isActive == true) .filter(OpenGroup.Columns.roomToken != "") - .filter(OpenGroup.Columns.isActive) .distinct() .asRequest(of: String.self) .fetchSet(db) @@ -166,33 +166,25 @@ public final class OpenGroupManager: NSObject { // Store the open group information let threadId: String = OpenGroup.idFor(roomToken: roomToken, server: server) + // Optionally try to insert a new version of the OpenGroup (it will fail if there is already an + // inactive one but that won't matter as we then activate it _ = try? SessionThread.fetchOrCreate(db, id: threadId, variant: .openGroup) + + if (try? OpenGroup.exists(db, id: threadId)) == false { + try? OpenGroup + .fetchOrCreate(db, server: server, roomToken: roomToken, publicKey: publicKey) + .save(db) + } + + // Set the group to active and reset the sequenceNumber (handle groups which have + // been deactivated) _ = try? OpenGroup - .fetchOne(db, id: threadId) - .defaulting( - to: OpenGroup( - server: server, - roomToken: roomToken, - publicKey: publicKey, - isActive: true, - name: "", - roomDescription: nil, - imageId: nil, - imageData: nil, - userCount: 0, - infoUpdates: -1, - sequenceNumber: 0, - inboxLatestMessageId: 0, - outboxLatestMessageId: 0 - ) + .filter(id: OpenGroup.idFor(roomToken: roomToken, server: server)) + .updateAll( + db, + OpenGroup.Columns.isActive.set(to: true), + OpenGroup.Columns.sequenceNumber.set(to: 0) ) - .with( - // Set the group to active and reset the sequenceNumber (handle groups which have - // been deactivated) - isActive: true, - sequenceNumber: 0 - ) - .saved(db) let (promise, seal) = Promise.pending() @@ -339,16 +331,39 @@ public final class OpenGroupManager: NSObject { guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { return } - let updatedOpenGroup: OpenGroup = try openGroup - .with( - publicKey: maybePublicKey, - name: pollInfo.details?.name, - roomDescription: pollInfo.details?.roomDescription, - imageId: pollInfo.details?.imageId.map { "\($0)" }, - userCount: pollInfo.activeUsers, - infoUpdates: pollInfo.details?.infoUpdates + // Only update the database columns which have changed (this is to prevent the UI from triggering + // updates due to changing database columns to the existing value) + try OpenGroup + .filter(id: openGroup.id) + .updateAll( + db, + [ + (openGroup.publicKey != maybePublicKey ? + maybePublicKey.map { OpenGroup.Columns.publicKey.set(to: $0) } : + nil + ), + (openGroup.name != pollInfo.details?.name ? + (pollInfo.details?.name).map { OpenGroup.Columns.name.set(to: $0) } : + nil + ), + (openGroup.roomDescription != pollInfo.details?.roomDescription ? + (pollInfo.details?.roomDescription).map { OpenGroup.Columns.roomDescription.set(to: $0) } : + nil + ), + (openGroup.imageId != pollInfo.details?.imageId.map { "\($0)" } ? + (pollInfo.details?.imageId).map { OpenGroup.Columns.roomDescription.set(to: "\($0)") } : + nil + ), + (openGroup.userCount != pollInfo.activeUsers ? + OpenGroup.Columns.userCount.set(to: pollInfo.activeUsers) : + nil + ), + (openGroup.infoUpdates != pollInfo.details?.infoUpdates ? + (pollInfo.details?.infoUpdates).map { OpenGroup.Columns.infoUpdates.set(to: $0) } : + nil + ) + ].compactMap { $0 } ) - .saved(db) // Update the admin/moderator group members if let roomDetails: OpenGroupAPI.Room = pollInfo.details { @@ -384,10 +399,10 @@ public final class OpenGroupManager: NSObject { /// Start downloading the room image (if we don't have one or it's been updated) if - let imageId: Int64 = Int64(updatedOpenGroup.imageId ?? ""), + let imageId: Int64 = pollInfo.details?.imageId, ( - updatedOpenGroup.imageData == nil || - updatedOpenGroup.imageId != openGroup.imageId + openGroup.imageData == nil || + openGroup.imageId != "\(imageId)" ) { OpenGroupManager.roomImage(db, fileId: imageId, for: roomToken, on: server, using: dependencies) @@ -713,7 +728,7 @@ public final class OpenGroupManager: NSObject { let (promise, seal) = Promise<[OpenGroupAPI.Room]>.pending() // Try to retrieve the default rooms 8 times - attempt(maxRetryCount: 8, recoveringOn: DispatchQueue.main) { + attempt(maxRetryCount: 8, recoveringOn: OpenGroupAPI.workQueue) { dependencies.storage.read { db in OpenGroupAPI.rooms(db, server: OpenGroupAPI.defaultServer, using: dependencies) } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift index bda1e1e40..9fc4b1ce5 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift @@ -51,18 +51,26 @@ extension MessageReceiver { try message.contacts.forEach { contactInfo in guard let sessionId: String = contactInfo.publicKey else { return } + // Note: We only update the contact and profile records if the data has actually changed + // in order to avoid triggering UI updates for every thread on the home screen let contact: Contact = Contact.fetchOrCreate(db, id: sessionId) let profile: Profile = Profile.fetchOrCreate(db, id: sessionId) - try profile - .with( - name: contactInfo.displayName, - profilePictureUrl: .updateIf(contactInfo.profilePictureUrl), - profileEncryptionKey: .updateIf( - contactInfo.profileKey.map { OWSAES256Key(data: $0) } + if + profile.name != contactInfo.displayName || + profile.profilePictureUrl != contactInfo.profilePictureUrl || + profile.profileEncryptionKey != contactInfo.profileKey.map({ OWSAES256Key(data: $0) }) + { + try profile + .with( + name: contactInfo.displayName, + profilePictureUrl: .updateIf(contactInfo.profilePictureUrl), + profileEncryptionKey: .updateIf( + contactInfo.profileKey.map { OWSAES256Key(data: $0) } + ) ) - ) - .save(db) + .save(db) + } /// We only update these values if the proto actually has values for them (this is to prevent an /// edge case where an old client could override the values with default values since they aren't included) @@ -70,22 +78,28 @@ extension MessageReceiver { /// **Note:** Since message requests have no reverse, we should only handle setting `isApproved` /// and `didApproveMe` to `true`. This may prevent some weird edge cases where a config message /// swapping `isApproved` and `didApproveMe` to `false` - try contact - .with( - isApproved: (contactInfo.hasIsApproved && contactInfo.isApproved ? - true : - .existing - ), - isBlocked: (contactInfo.hasIsBlocked ? - .update(contactInfo.isBlocked) : - .existing - ), - didApproveMe: (contactInfo.hasDidApproveMe && contactInfo.didApproveMe ? - true : - .existing + if + (contactInfo.hasIsApproved && (contact.isApproved != contactInfo.isApproved)) || + (contactInfo.hasIsBlocked && (contact.isBlocked != contactInfo.isBlocked)) || + (contactInfo.hasDidApproveMe && (contact.didApproveMe != contactInfo.didApproveMe)) + { + try contact + .with( + isApproved: (contactInfo.hasIsApproved && contactInfo.isApproved ? + true : + .existing + ), + isBlocked: (contactInfo.hasIsBlocked ? + .update(contactInfo.isBlocked) : + .existing + ), + didApproveMe: (contactInfo.hasDidApproveMe && contactInfo.didApproveMe ? + true : + .existing + ) ) - ) - .save(db) + .save(db) + } // If the contact is blocked if contactInfo.hasIsBlocked && contactInfo.isBlocked { diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index dcc120311..b1ba41403 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -283,7 +283,8 @@ public enum MessageReceiver { dependencies: Dependencies = Dependencies() ) throws { let isCurrentUser = (publicKey == getUserHexEncodedPublicKey(db, dependencies: dependencies)) - var profile: Profile = Profile.fetchOrCreate(id: publicKey) + let profile: Profile = Profile.fetchOrCreate(id: publicKey) + var updatedProfile: Profile = profile // Name if let name = name, name != profile.name { @@ -303,7 +304,7 @@ public enum MessageReceiver { UserDefaults.standard[.lastDisplayNameUpdate] = Date(timeIntervalSince1970: sentTimestamp) } - profile = profile.with(name: name) + updatedProfile = updatedProfile.with(name: name) } } @@ -330,15 +331,17 @@ public enum MessageReceiver { UserDefaults.standard[.lastProfilePictureUpdate] = Date(timeIntervalSince1970: sentTimestamp) } - profile = profile.with( + updatedProfile = updatedProfile.with( profilePictureUrl: .update(profilePictureUrl), profileEncryptionKey: .update(profileKey) ) } } - // Persist changes - try profile.save(db) + // Persist any changes + if updatedProfile != profile { + try updatedProfile.save(db) + } // Download the profile picture if needed db.afterNextTransactionCommit { _ in diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index c0e0b0115..0c5ceeb6b 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -129,6 +129,7 @@ extension MessageSender { attachment.upload( db, + queue: DispatchQueue.global(qos: .userInitiated), using: { db, data in if let openGroup: OpenGroup = openGroup { return OpenGroupAPI diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index ef3b3e3da..a1c045c46 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -211,13 +211,13 @@ public final class MessageSender { isClosedGroupMessage: (kind == .closedGroupMessage), isConfigMessage: (message is ConfigurationMessage) ) - .done(on: DispatchQueue.global(qos: .userInitiated)) { promises in + .done(on: DispatchQueue.global(qos: .default)) { promises in let promiseCount = promises.count var isSuccess = false var errorCount = 0 promises.forEach { - let _ = $0.done(on: DispatchQueue.global(qos: .userInitiated)) { responseData in + let _ = $0.done(on: DispatchQueue.global(qos: .default)) { responseData in guard !isSuccess else { return } // Succeed as soon as the first promise succeeds isSuccess = true @@ -261,6 +261,7 @@ public final class MessageSender { else if let job: Job = job { NotifyPushServerJob.run( job, + queue: DispatchQueue.global(qos: .default), success: { _, _ in seal.fulfill(()) }, failure: { _, _, _ in // Always fulfill because the notify PN server job isn't critical. @@ -278,7 +279,7 @@ public final class MessageSender { } } } - $0.catch(on: DispatchQueue.global(qos: .userInitiated)) { error in + $0.catch(on: DispatchQueue.global(qos: .default)) { error in errorCount += 1 guard errorCount == promiseCount else { return } // Only error out if all promises failed @@ -288,7 +289,7 @@ public final class MessageSender { } } } - .catch(on: DispatchQueue.global(qos: .userInitiated)) { error in + .catch(on: DispatchQueue.global(qos: .default)) { error in SNLog("Couldn't send message due to error: \(error).") GRDBStorage.shared.write { db in @@ -416,7 +417,7 @@ public final class MessageSender { fileIds: fileIds, using: dependencies ) - .done(on: DispatchQueue.global(qos: .userInitiated)) { responseInfo, data in + .done(on: DispatchQueue.global(qos: .default)) { responseInfo, data in message.openGroupServerMessageId = UInt64(data.id) dependencies.storage.write { db in @@ -431,7 +432,7 @@ public final class MessageSender { seal.fulfill(()) } } - .catch(on: DispatchQueue.global(qos: .userInitiated)) { error in + .catch(on: DispatchQueue.global(qos: .default)) { error in dependencies.storage.write { db in handleFailure(db, with: .other(error)) } @@ -528,7 +529,7 @@ public final class MessageSender { on: server, using: dependencies ) - .done(on: DispatchQueue.global(qos: .userInitiated)) { responseInfo, data in + .done(on: DispatchQueue.global(qos: .default)) { responseInfo, data in message.openGroupServerMessageId = UInt64(data.id) dependencies.storage.write { transaction in @@ -541,7 +542,7 @@ public final class MessageSender { seal.fulfill(()) } } - .catch(on: DispatchQueue.global(qos: .userInitiated)) { error in + .catch(on: DispatchQueue.global(qos: .default)) { error in dependencies.storage.write { db in handleFailure(db, with: .other(error)) } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift index bc28b2bcc..b44928006 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift @@ -250,6 +250,7 @@ public final class ClosedGroupPoller { // Note: In the background we just want jobs to fail silently MessageReceiveJob.run( job, + queue: queue, success: { _, _ in seal.fulfill(()) }, failure: { _, _, _ in seal.fulfill(()) }, deferred: { _ in seal.fulfill(()) } diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index 5f2c037ec..34193045f 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -416,7 +416,7 @@ public extension MessageViewModel { // MARK: - Convenience Initialization public extension MessageViewModel { - static let genericId: Int64 = -2 + static let genericId: Int64 = -1 static let typingIndicatorId: Int64 = -2 // Note: This init method is only used system-created cells or empty states @@ -521,14 +521,20 @@ public extension MessageViewModel { return SQL("\(interaction[.threadId]) = \(threadId)") } + static let groupSQL: SQL = { + let interaction: TypedTableAlias = TypedTableAlias() + + return SQL("GROUP BY \(interaction[.id])") + }() + static let orderSQL: SQL = { let interaction: TypedTableAlias = TypedTableAlias() return SQL("\(interaction[.timestampMs].desc)") }() - static func baseQuery(orderSQL: SQL, baseFilterSQL: SQL) -> ((SQL?, SQL?) -> AdaptedFetchRequest>) { - return { additionalFilters, limitSQL -> AdaptedFetchRequest> in + static func baseQuery(orderSQL: SQL, groupSQL: SQL?) -> (([Int64]) -> AdaptedFetchRequest>) { + return { rowIds -> AdaptedFetchRequest> in let interaction: TypedTableAlias = TypedTableAlias() let thread: TypedTableAlias = TypedTableAlias() let openGroup: TypedTableAlias = TypedTableAlias() @@ -548,21 +554,6 @@ public extension MessageViewModel { let groupMemberProfileIdColumnLiteral: SQL = SQL(stringLiteral: GroupMember.Columns.profileId.name) let groupMemberRoleColumnLiteral: SQL = SQL(stringLiteral: GroupMember.Columns.role.name) - let finalFilterSQL: SQL = { - guard let additionalFilters: SQL = additionalFilters else { - return """ - WHERE \(baseFilterSQL) - """ - } - - return """ - WHERE ( - \(baseFilterSQL) AND - \(additionalFilters) - ) - """ - }() - let finalLimitSQL: SQL = (limitSQL ?? SQL(stringLiteral: "")) let numColumnsBeforeLinkedRecords: Int = 18 let request: SQLRequest = """ SELECT @@ -641,10 +632,9 @@ public extension MessageViewModel { \(groupMemberAdminTableLiteral).\(groupMemberProfileIdColumnLiteral) = \(interaction[.authorId]) AND \(SQL("\(groupMemberAdminTableLiteral).\(groupMemberRoleColumnLiteral) = \(GroupMember.Role.admin)")) ) - \(finalFilterSQL) - GROUP BY \(interaction[.id]) + WHERE \(interaction.alias[Column.rowID]) IN \(rowIds) + \(groupSQL ?? "") ORDER BY \(orderSQL) - \(finalLimitSQL) """ return request.adapted { db in diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 941f4615a..2b3a085cd 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -13,7 +13,8 @@ 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 -public struct SessionThreadViewModel: FetchableRecord, Decodable, Equatable, Hashable, Differentiable { +public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equatable, Hashable, Identifiable, Differentiable { + public static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue) public static let threadIdKey: SQL = SQL(stringLiteral: CodingKeys.threadId.stringValue) public static let threadVariantKey: SQL = SQL(stringLiteral: CodingKeys.threadVariant.stringValue) public static let threadCreationDateTimestampKey: SQL = SQL(stringLiteral: CodingKeys.threadCreationDateTimestamp.stringValue) @@ -65,7 +66,9 @@ public struct SessionThreadViewModel: FetchableRecord, Decodable, Equatable, Has public static let interactionAttachmentDescriptionInfoString: String = CodingKeys.interactionAttachmentDescriptionInfo.stringValue public var differenceIdentifier: String { threadId } + public var id: String { threadId } + public let rowId: Int64 public let threadId: String public let threadVariant: SessionThread.Variant private let threadCreationDateTimestamp: TimeInterval @@ -193,6 +196,7 @@ public struct SessionThreadViewModel: FetchableRecord, Decodable, Equatable, Has public extension SessionThreadViewModel { // Note: This init method is only used system-created cells or empty states init(unreadCount: UInt = 0) { + self.rowId = -1 self.threadId = "INVALID_THREAD_ID" self.threadVariant = .contact self.threadCreationDateTimestamp = 0 @@ -247,239 +251,263 @@ public extension SessionThreadViewModel { // MARK: - HomeVC & MessageRequestsViewController +// MARK: --SessionThreadViewModel + public extension SessionThreadViewModel { - private static func baseQuery( + static func baseQuery( userPublicKey: String, - filters: SQL, - ordering: SQL - ) -> AdaptedFetchRequest> { + filterSQL: SQL, + groupSQL: SQL, + orderSQL: SQL + ) -> (([Int64]) -> AdaptedFetchRequest>) { + return { rowIds -> AdaptedFetchRequest> in + let thread: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() + let typingIndicator: TypedTableAlias = TypedTableAlias() + let closedGroup: TypedTableAlias = TypedTableAlias() + let groupMember: TypedTableAlias = TypedTableAlias() + let openGroup: TypedTableAlias = TypedTableAlias() + let interaction: TypedTableAlias = TypedTableAlias() + let recipientState: TypedTableAlias = TypedTableAlias() + let linkPreview: TypedTableAlias = TypedTableAlias() + let attachment: TypedTableAlias = TypedTableAlias() + let interactionAttachment: TypedTableAlias = TypedTableAlias() + let profile: TypedTableAlias = TypedTableAlias() + + let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) + let firstInteractionAttachmentLiteral: SQL = SQL(stringLiteral: "firstInteractionAttachment") + let interactionAttachmentAttachmentIdColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.attachmentId.name) + let interactionAttachmentInteractionIdColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.interactionId.name) + let interactionAttachmentAlbumIndexColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.albumIndex.name) + + /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before + /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to + /// parse and might throw + /// + /// Explicitly set default values for the fields ignored for search results + let numColumnsBeforeProfiles: Int = 12 + let numColumnsBetweenProfilesAndAttachmentInfo: Int = 10 // The attachment info columns will be combined + + let request: SQLRequest = """ + SELECT + \(thread.alias[Column.rowID]) AS \(ViewModel.rowIdKey), + \(thread[.id]) AS \(ViewModel.threadIdKey), + \(thread[.variant]) AS \(ViewModel.threadVariantKey), + \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), + + (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.threadIsNoteToSelfKey), + \(thread[.isPinned]) AS \(ViewModel.threadIsPinnedKey), + \(contact[.isBlocked]) AS \(ViewModel.threadIsBlockedKey), + \(thread[.mutedUntilTimestamp]) AS \(ViewModel.threadMutedUntilTimestampKey), + \(thread[.onlyNotifyForMentions]) AS \(ViewModel.threadOnlyNotifyForMentionsKey), + + (\(typingIndicator[.threadId]) IS NOT NULL) AS \(ViewModel.threadContactIsTypingKey), + \(Interaction.self).\(ViewModel.threadUnreadCountKey), + \(Interaction.self).\(ViewModel.threadUnreadMentionCountKey), + + \(ViewModel.contactProfileKey).*, + \(ViewModel.closedGroupProfileFrontKey).*, + \(ViewModel.closedGroupProfileBackKey).*, + \(ViewModel.closedGroupProfileBackFallbackKey).*, + \(closedGroup[.name]) AS \(ViewModel.closedGroupNameKey), + (\(groupMember[.profileId]) IS NOT NULL) AS \(ViewModel.currentUserIsClosedGroupAdminKey), + \(openGroup[.name]) AS \(ViewModel.openGroupNameKey), + \(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey), + + \(Interaction.self).\(ViewModel.interactionIdKey), + \(Interaction.self).\(ViewModel.interactionVariantKey), + \(Interaction.self).\(ViewModel.interactionTimestampMsKey), + \(Interaction.self).\(ViewModel.interactionBodyKey), + + -- Default to 'sending' assuming non-processed interaction when null + IFNULL(MIN(\(recipientState[.state])), \(SQL("\(RecipientState.State.sending)"))) AS \(ViewModel.interactionStateKey), + (\(linkPreview[.url]) IS NOT NULL) AS \(ViewModel.interactionIsOpenGroupInvitationKey), + + -- These 4 properties will be combined into 'Attachment.DescriptionInfo' + \(attachment[.id]), + \(attachment[.variant]), + \(attachment[.contentType]), + \(attachment[.sourceFilename]), + COUNT(\(interactionAttachment[.interactionId])) AS \(ViewModel.interactionAttachmentCountKey), + + \(interaction[.authorId]), + IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.authorNameInternalKey), + \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) + + FROM \(SessionThread.self) + LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) + LEFT JOIN \(ThreadTypingIndicator.self) ON \(typingIndicator[.threadId]) = \(thread[.id]) + LEFT JOIN ( + -- Fetch all interaction-specific data in a subquery to be more efficient + SELECT + \(interaction[.id]) AS \(ViewModel.interactionIdKey), + \(interaction[.threadId]), + \(interaction[.variant]) AS \(ViewModel.interactionVariantKey), + MAX(\(interaction[.timestampMs])) AS \(ViewModel.interactionTimestampMsKey), + \(interaction[.body]) AS \(ViewModel.interactionBodyKey), + \(interaction[.authorId]), + \(interaction[.linkPreviewUrl]), + + SUM(\(interaction[.wasRead]) = false) AS \(ViewModel.threadUnreadCountKey), + SUM(\(interaction[.wasRead]) = false AND \(interaction[.hasMention]) = true) AS \(ViewModel.threadUnreadMentionCountKey) + + FROM \(Interaction.self) + WHERE \(SQL("\(interaction[.variant]) != \(Interaction.Variant.standardIncomingDeleted)")) + GROUP BY \(interaction[.threadId]) + ) AS \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id]) + + LEFT JOIN \(RecipientState.self) ON ( + -- Ignore 'skipped' states + \(SQL("\(recipientState[.state]) != \(RecipientState.State.skipped)")) AND + \(recipientState[.interactionId]) = \(Interaction.self).\(ViewModel.interactionIdKey) + ) + LEFT JOIN \(LinkPreview.self) ON ( + \(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND + \(SQL("\(linkPreview[.variant]) = \(LinkPreview.Variant.openGroupInvitation)")) AND + \(Interaction.linkPreviewFilterLiteral(timestampColumn: ViewModel.interactionTimestampMsKey)) + ) + LEFT JOIN \(InteractionAttachment.self) AS \(firstInteractionAttachmentLiteral) ON ( + \(firstInteractionAttachmentLiteral).\(interactionAttachmentAlbumIndexColumnLiteral) = 0 AND + \(firstInteractionAttachmentLiteral).\(interactionAttachmentInteractionIdColumnLiteral) = \(Interaction.self).\(ViewModel.interactionIdKey) + ) + LEFT JOIN \(Attachment.self) ON \(attachment[.id]) = \(firstInteractionAttachmentLiteral).\(interactionAttachmentAttachmentIdColumnLiteral) + LEFT JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.interactionId]) = \(Interaction.self).\(ViewModel.interactionIdKey) + LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId]) + + -- Thread naming & avatar content + + LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) + LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) + LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) + LEFT JOIN \(GroupMember.self) ON ( + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.admin)")) AND + \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(SQL("\(groupMember[.profileId]) = \(userPublicKey)")) + ) + + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON ( + \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( + SELECT MIN(\(groupMember[.profileId])) + FROM \(GroupMember.self) + WHERE ( + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND + \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(SQL("\(groupMember[.profileId]) != \(userPublicKey)")) + ) + ) + ) + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON ( + \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) != \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) AND + \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) = ( + SELECT MAX(\(groupMember[.profileId])) + FROM \(GroupMember.self) + WHERE ( + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND + \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(SQL("\(groupMember[.profileId]) != \(userPublicKey)")) + ) + ) + ) + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON ( + \(closedGroup[.threadId]) IS NOT NULL AND + \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) IS NULL AND + \(ViewModel.closedGroupProfileBackFallbackKey).\(profileIdColumnLiteral) = \(SQL("\(userPublicKey)")) + ) + + WHERE \(thread.alias[Column.rowID]) IN \(rowIds) + \(groupSQL) + ORDER BY \(orderSQL) + """ + + return request.adapted { db in + let adapters = try splittingRowAdapters(columnCounts: [ + numColumnsBeforeProfiles, + Profile.numberOfSelectedColumns(db), + Profile.numberOfSelectedColumns(db), + Profile.numberOfSelectedColumns(db), + Profile.numberOfSelectedColumns(db), + numColumnsBetweenProfilesAndAttachmentInfo, + Attachment.DescriptionInfo.numberOfSelectedColumns() + ]) + + return ScopeAdapter([ + ViewModel.contactProfileString: adapters[1], + ViewModel.closedGroupProfileFrontString: adapters[2], + ViewModel.closedGroupProfileBackString: adapters[3], + ViewModel.closedGroupProfileBackFallbackString: adapters[4], + ViewModel.interactionAttachmentDescriptionInfoString: adapters[6] + ]) + } + } + } + + static var optimisedJoinSQL: SQL = { let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() - let typingIndicator: TypedTableAlias = TypedTableAlias() - let closedGroup: TypedTableAlias = TypedTableAlias() - let groupMember: TypedTableAlias = TypedTableAlias() - let openGroup: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() - let recipientState: TypedTableAlias = TypedTableAlias() - let linkPreview: TypedTableAlias = TypedTableAlias() - let attachment: TypedTableAlias = TypedTableAlias() - let interactionAttachment: TypedTableAlias = TypedTableAlias() - let profile: TypedTableAlias = TypedTableAlias() - let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) - let firstInteractionAttachmentLiteral: SQL = SQL(stringLiteral: "firstInteractionAttachment") - let interactionAttachmentAttachmentIdColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.attachmentId.name) - let interactionAttachmentInteractionIdColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.interactionId.name) - let interactionAttachmentAlbumIndexColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.albumIndex.name) - - /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to - /// parse and might throw - /// - /// Explicitly set default values for the fields ignored for search results - let numColumnsBeforeProfiles: Int = 11 - let numColumnsBetweenProfilesAndAttachmentInfo: Int = 10 // The attachment info columns will be combined - - let request: SQLRequest = """ - SELECT - \(thread[.id]) AS \(ViewModel.threadIdKey), - \(thread[.variant]) AS \(ViewModel.threadVariantKey), - \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), - - (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.threadIsNoteToSelfKey), - \(thread[.isPinned]) AS \(ViewModel.threadIsPinnedKey), - \(contact[.isBlocked]) AS \(ViewModel.threadIsBlockedKey), - \(thread[.mutedUntilTimestamp]) AS \(ViewModel.threadMutedUntilTimestampKey), - \(thread[.onlyNotifyForMentions]) AS \(ViewModel.threadOnlyNotifyForMentionsKey), - - (\(typingIndicator[.threadId]) IS NOT NULL) AS \(ViewModel.threadContactIsTypingKey), - \(Interaction.self).\(ViewModel.threadUnreadCountKey), - \(Interaction.self).\(ViewModel.threadUnreadMentionCountKey), - - \(ViewModel.contactProfileKey).*, - \(ViewModel.closedGroupProfileFrontKey).*, - \(ViewModel.closedGroupProfileBackKey).*, - \(ViewModel.closedGroupProfileBackFallbackKey).*, - \(closedGroup[.name]) AS \(ViewModel.closedGroupNameKey), - (\(groupMember[.profileId]) IS NOT NULL) AS \(ViewModel.currentUserIsClosedGroupAdminKey), - \(openGroup[.name]) AS \(ViewModel.openGroupNameKey), - \(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey), - - \(Interaction.self).\(ViewModel.interactionIdKey), - \(Interaction.self).\(ViewModel.interactionVariantKey), - \(Interaction.self).\(ViewModel.interactionTimestampMsKey), - \(Interaction.self).\(ViewModel.interactionBodyKey), - - -- Default to 'sending' assuming non-processed interaction when null - IFNULL(MIN(\(recipientState[.state])), \(SQL("\(RecipientState.State.sending)"))) AS \(ViewModel.interactionStateKey), - (\(linkPreview[.url]) IS NOT NULL) AS \(ViewModel.interactionIsOpenGroupInvitationKey), - - -- These 4 properties will be combined into 'Attachment.DescriptionInfo' - \(attachment[.id]), - \(attachment[.variant]), - \(attachment[.contentType]), - \(attachment[.sourceFilename]), - COUNT(\(interactionAttachment[.interactionId])) AS \(ViewModel.interactionAttachmentCountKey), - - \(interaction[.authorId]), - IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.authorNameInternalKey), - \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) - - FROM \(SessionThread.self) + return """ LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) - LEFT JOIN \(ThreadTypingIndicator.self) ON \(typingIndicator[.threadId]) = \(thread[.id]) LEFT JOIN ( - -- Fetch all interaction-specific data in a subquery to be more efficient SELECT - \(interaction[.id]) AS \(ViewModel.interactionIdKey), \(interaction[.threadId]), - \(interaction[.variant]) AS \(ViewModel.interactionVariantKey), - MAX(\(interaction[.timestampMs])) AS \(ViewModel.interactionTimestampMsKey), - \(interaction[.body]) AS \(ViewModel.interactionBodyKey), - \(interaction[.authorId]), - \(interaction[.linkPreviewUrl]), - - SUM(\(interaction[.wasRead]) = false) AS \(ViewModel.threadUnreadCountKey), - SUM(\(interaction[.wasRead]) = false AND \(interaction[.hasMention]) = true) AS \(ViewModel.threadUnreadMentionCountKey) - + MAX(\(interaction[.timestampMs])) AS \(ViewModel.interactionTimestampMsKey) FROM \(Interaction.self) WHERE \(SQL("\(interaction[.variant]) != \(Interaction.Variant.standardIncomingDeleted)")) GROUP BY \(interaction[.threadId]) ) AS \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id]) - - LEFT JOIN \(RecipientState.self) ON ( - -- Ignore 'skipped' states - \(SQL("\(recipientState[.state]) != \(RecipientState.State.skipped)")) AND - \(recipientState[.interactionId]) = \(Interaction.self).\(ViewModel.interactionIdKey) - ) - LEFT JOIN \(LinkPreview.self) ON ( - \(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND - \(SQL("\(linkPreview[.variant]) = \(LinkPreview.Variant.openGroupInvitation)")) AND - \(Interaction.linkPreviewFilterLiteral(timestampColumn: ViewModel.interactionTimestampMsKey)) - ) - LEFT JOIN \(InteractionAttachment.self) AS \(firstInteractionAttachmentLiteral) ON ( - \(firstInteractionAttachmentLiteral).\(interactionAttachmentAlbumIndexColumnLiteral) = 0 AND - \(firstInteractionAttachmentLiteral).\(interactionAttachmentInteractionIdColumnLiteral) = \(Interaction.self).\(ViewModel.interactionIdKey) - ) - LEFT JOIN \(Attachment.self) ON \(attachment[.id]) = \(firstInteractionAttachmentLiteral).\(interactionAttachmentAttachmentIdColumnLiteral) - LEFT JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.interactionId]) = \(Interaction.self).\(ViewModel.interactionIdKey) - LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId]) - - -- Thread naming & avatar content - - LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) - LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) - LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) - LEFT JOIN \(GroupMember.self) ON ( - \(SQL("\(groupMember[.role]) = \(GroupMember.Role.admin)")) AND - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.profileId]) = \(userPublicKey)")) - ) - - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON ( - \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( - SELECT MIN(\(groupMember[.profileId])) - FROM \(GroupMember.self) - WHERE ( - \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.profileId]) != \(userPublicKey)")) - ) - ) - ) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON ( - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) != \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) AND - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) = ( - SELECT MAX(\(groupMember[.profileId])) - FROM \(GroupMember.self) - WHERE ( - \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.profileId]) != \(userPublicKey)")) - ) - ) - ) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON ( - \(closedGroup[.threadId]) IS NOT NULL AND - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) IS NULL AND - \(ViewModel.closedGroupProfileBackFallbackKey).\(profileIdColumnLiteral) = \(SQL("\(userPublicKey)")) - ) - - WHERE ( - \(filters) - ) - - GROUP BY \(thread[.id]) - ORDER BY \(ordering) """ - - return request.adapted { db in - let adapters = try splittingRowAdapters(columnCounts: [ - numColumnsBeforeProfiles, - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - numColumnsBetweenProfilesAndAttachmentInfo, - Attachment.DescriptionInfo.numberOfSelectedColumns() - ]) - - return ScopeAdapter([ - ViewModel.contactProfileString: adapters[1], - ViewModel.closedGroupProfileFrontString: adapters[2], - ViewModel.closedGroupProfileBackString: adapters[3], - ViewModel.closedGroupProfileBackFallbackString: adapters[4], - ViewModel.interactionAttachmentDescriptionInfoString: adapters[6] - ]) - } - } + }() - static func homeQuery(userPublicKey: String) -> AdaptedFetchRequest> { + static func homeFilterSQL(userPublicKey: String) -> SQL { let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() - return baseQuery( - userPublicKey: userPublicKey, - filters: """ - \(thread[.shouldBeVisible]) = true AND ( - -- Is not a message request - \(SQL("\(thread[.variant]) != \(SessionThread.Variant.contact)")) OR - \(SQL("\(thread[.id]) = \(userPublicKey)")) OR - \(contact[.isApproved]) = true - ) AND ( - -- Only show the 'Note to Self' thread if it has an interaction - \(SQL("\(thread[.id]) != \(userPublicKey)")) OR - \(Interaction.self).\(ViewModel.interactionIdKey) IS NOT NULL - ) - """, - ordering: """ - \(thread[.isPinned]) DESC, IFNULL(\(Interaction.self).\(ViewModel.interactionTimestampMsKey), \(thread[.creationDateTimestamp])) DESC - """ - ) + return """ + \(thread[.shouldBeVisible]) = true AND ( + -- Is not a message request + \(SQL("\(thread[.variant]) != \(SessionThread.Variant.contact)")) OR + \(SQL("\(thread[.id]) = \(userPublicKey)")) OR + \(contact[.isApproved]) = true + ) AND ( + -- Only show the 'Note to Self' thread if it has an interaction + \(SQL("\(thread[.id]) != \(userPublicKey)")) OR + \(Interaction.self).\(ViewModel.interactionTimestampMsKey) IS NOT NULL + ) + """ } - static func messageRequestsQuery(userPublicKey: String) -> AdaptedFetchRequest> { + static func messageRequestsFilterSQL(userPublicKey: String) -> SQL { let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() - return baseQuery( - userPublicKey: userPublicKey, - filters: """ - \(thread[.shouldBeVisible]) = true AND ( - -- Is a message request - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND - \(SQL("\(thread[.id]) != \(userPublicKey)")) AND ( - -- A '!= true' check doesn't work properly so we need to be explicit - \(contact[.isApproved]) IS NULL OR - \(contact[.isApproved]) = false - ) - ) - """, - ordering: """ - IFNULL(\(Interaction.self).\(ViewModel.interactionTimestampMsKey), \(thread[.creationDateTimestamp])) DESC - """ - ) + return """ + \(thread[.shouldBeVisible]) = true AND ( + -- Is a message request + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND + \(SQL("\(thread[.id]) != \(userPublicKey)")) AND + IFNULL(\(contact[.isApproved]), false) = false + ) + """ } + + static let groupSQL: SQL = { + let thread: TypedTableAlias = TypedTableAlias() + + return SQL("GROUP BY \(thread[.id])") + }() + + static let homeOrderSQL: SQL = { + let thread: TypedTableAlias = TypedTableAlias() + + return SQL("\(thread[.isPinned]) DESC, IFNULL(\(Interaction.self).\(ViewModel.interactionTimestampMsKey), \(thread[.creationDateTimestamp])) DESC") + }() + + static let messageRequetsOrderSQL: SQL = { + let thread: TypedTableAlias = TypedTableAlias() + + return SQL("IFNULL(\(Interaction.self).\(ViewModel.interactionTimestampMsKey), \(thread[.creationDateTimestamp])) DESC") + }() } // MARK: - ConversationVC @@ -502,9 +530,10 @@ public extension SessionThreadViewModel { /// parse and might throw /// /// Explicitly set default values for the fields ignored for search results - let numColumnsBeforeProfiles: Int = 13 + let numColumnsBeforeProfiles: Int = 14 let request: SQLRequest = """ SELECT + \(thread.alias[Column.rowID]) AS \(ViewModel.rowIdKey), \(thread[.id]) AS \(ViewModel.threadIdKey), \(thread[.variant]) AS \(ViewModel.threadVariantKey), \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), @@ -612,9 +641,10 @@ public extension SessionThreadViewModel { /// parse and might throw /// /// Explicitly set default values for the fields ignored for search results - let numColumnsBeforeProfiles: Int = 5 + let numColumnsBeforeProfiles: Int = 6 let request: SQLRequest = """ SELECT + \(thread.alias[Column.rowID]) AS \(ViewModel.rowIdKey), \(thread[.id]) AS \(ViewModel.threadIdKey), \(thread[.variant]) AS \(ViewModel.threadVariantKey), \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), @@ -749,9 +779,10 @@ public extension SessionThreadViewModel { /// parse and might throw /// /// Explicitly set default values for the fields ignored for search results - let numColumnsBeforeProfiles: Int = 5 + let numColumnsBeforeProfiles: Int = 6 let request: SQLRequest = """ SELECT + \(interaction.alias[Column.rowID]) AS \(ViewModel.rowIdKey), \(thread[.id]) AS \(ViewModel.threadIdKey), \(thread[.variant]) AS \(ViewModel.threadVariantKey), \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), @@ -879,12 +910,13 @@ public extension SessionThreadViewModel { /// We use `IFNULL(rank, 100)` because the custom `Note to Self` like comparison will get a null /// `rank` value which ends up as the first result, by defaulting to `100` it will always be ranked last compared /// to any relevance-based results - let numColumnsBeforeProfiles: Int = 7 + let numColumnsBeforeProfiles: Int = 8 var sqlQuery: SQL = "" let selectQuery: SQL = """ SELECT IFNULL(\(Column.rank), 100) AS \(Column.rank), + \(thread.alias[Column.rowID]) AS \(ViewModel.rowIdKey), \(thread[.id]) AS \(ViewModel.threadIdKey), \(thread[.variant]) AS \(ViewModel.threadVariantKey), \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), @@ -1221,11 +1253,12 @@ public extension SessionThreadViewModel { /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to /// parse and might throw - let numColumnsBeforeProfiles: Int = 7 + let numColumnsBeforeProfiles: Int = 8 let request: SQLRequest = """ SELECT 100 AS \(Column.rank), + \(thread.alias[Column.rowID]) AS \(ViewModel.rowIdKey), \(thread[.id]) AS \(ViewModel.threadIdKey), \(thread[.variant]) AS \(ViewModel.threadVariantKey), \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), @@ -1277,10 +1310,11 @@ public extension SessionThreadViewModel { /// parse and might throw /// /// Explicitly set default values for the fields ignored for search results - let numColumnsBeforeProfiles: Int = 6 + let numColumnsBeforeProfiles: Int = 7 let request: SQLRequest = """ SELECT + \(thread.alias[Column.rowID]) AS \(ViewModel.rowIdKey), \(thread[.id]) AS \(ViewModel.threadIdKey), \(thread[.variant]) AS \(ViewModel.threadVariantKey), \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), diff --git a/SessionMessagingKit/Utilities/ProfileManager.swift b/SessionMessagingKit/Utilities/ProfileManager.swift index 1f192b2b5..885fdf0d3 100644 --- a/SessionMessagingKit/Utilities/ProfileManager.swift +++ b/SessionMessagingKit/Utilities/ProfileManager.swift @@ -196,13 +196,14 @@ public struct ProfileManager { // MARK: - Current User Profile public static func updateLocal( + queue: DispatchQueue, profileName: String, avatarImage: UIImage?, requiredSync: Bool, success: ((Database, Profile) throws -> ())? = nil, failure: ((ProfileManagerError) -> ())? = nil ) { - DispatchQueue.global(qos: .default).async { + queue.async { // If the profile avatar was updated or removed then encrypt with a new profile key // to ensure that other users know that our profile picture was updated let newProfileKey: OWSAES256Key = OWSAES256Key.generateRandom() @@ -260,10 +261,8 @@ public struct ProfileManager { } guard let data: Data = image.jpegData(compressionQuality: 0.95) else { - DispatchQueue.main.async { - SNLog("Updating service with profile failed.") - failure?(.avatarWriteFailed) - } + SNLog("Updating service with profile failed.") + failure?(.avatarWriteFailed) return } @@ -271,11 +270,9 @@ public struct ProfileManager { // Our avatar dimensions are so small that it's incredibly unlikely we wouldn't // be able to fit our profile photo (eg. generating pure noise at our resolution // compresses to ~200k) - DispatchQueue.main.async { - SNLog("Suprised to find profile avatar was too large. Was it scaled properly? image: \(image)") - SNLog("Updating service with profile failed.") - failure?(.avatarImageTooLarge) - } + SNLog("Suprised to find profile avatar was too large. Was it scaled properly? image: \(image)") + SNLog("Updating service with profile failed.") + failure?(.avatarImageTooLarge) return } @@ -285,26 +282,22 @@ public struct ProfileManager { // Write the avatar to disk do { try data.write(to: URL(fileURLWithPath: filePath), options: [.atomic]) } catch { - DispatchQueue.main.async { - SNLog("Updating service with profile failed.") - failure?(.avatarWriteFailed) - } + SNLog("Updating service with profile failed.") + failure?(.avatarWriteFailed) return } // Encrypt the avatar for upload guard let encryptedAvatarData: Data = encryptProfileData(data: data, key: newProfileKey) else { - DispatchQueue.main.async { - SNLog("Updating service with profile failed.") - failure?(.avatarEncryptionFailed) - } + SNLog("Updating service with profile failed.") + failure?(.avatarEncryptionFailed) return } // Upload the avatar to the FileServer FileServerAPI .upload(encryptedAvatarData) - .done { fileId in + .done(on: queue) { fileId in let downloadUrl: String = "\(FileServerAPI.server)/files/\(fileId)" UserDefaults.standard[.lastProfilePictureUpload] = Date() @@ -326,16 +319,14 @@ public struct ProfileManager { try success?(db, profile) } } - .recover { error in - DispatchQueue.main.async { - SNLog("Updating service with profile failed.") - - let isMaxFileSizeExceeded: Bool = ((error as? HTTP.Error) == HTTP.Error.maxFileSizeExceeded) - failure?(isMaxFileSizeExceeded ? - .avatarUploadMaxFileSizeExceeded : - .avatarUploadFailed - ) - } + .recover(on: queue) { error in + SNLog("Updating service with profile failed.") + + let isMaxFileSizeExceeded: Bool = ((error as? HTTP.Error) == HTTP.Error.maxFileSizeExceeded) + failure?(isMaxFileSizeExceeded ? + .avatarUploadMaxFileSizeExceeded : + .avatarUploadFailed + ) } .retainUntilComplete() } diff --git a/SessionSnodeKit/GetSnodePoolJob.swift b/SessionSnodeKit/GetSnodePoolJob.swift index 2b72ea944..af0c61d07 100644 --- a/SessionSnodeKit/GetSnodePoolJob.swift +++ b/SessionSnodeKit/GetSnodePoolJob.swift @@ -12,6 +12,7 @@ public enum GetSnodePoolJob: JobExecutor { public static func run( _ job: Job, + queue: DispatchQueue, success: @escaping (Job, Bool) -> (), failure: @escaping (Job, Error?, Bool) -> (), deferred: @escaping (Job) -> () @@ -34,14 +35,15 @@ public enum GetSnodePoolJob: JobExecutor { } SnodeAPI.getSnodePool() - .done { _ in success(job, false) } - .catch { error in failure(job, error, false) } + .done(on: queue) { _ in success(job, false) } + .catch(on: queue) { error in failure(job, error, false) } .retainUntilComplete() } public static func run() { GetSnodePoolJob.run( Job(variant: .getSnodePool), + queue: DispatchQueue.global(qos: .background), success: { _, _ in }, failure: { _, _, _ in }, deferred: { _ in } diff --git a/SessionUtilitiesKit/Database/Models/Setting.swift b/SessionUtilitiesKit/Database/Models/Setting.swift index ca9b672cd..bf31436a9 100644 --- a/SessionUtilitiesKit/Database/Models/Setting.swift +++ b/SessionUtilitiesKit/Database/Models/Setting.swift @@ -21,7 +21,9 @@ public struct Setting: Codable, Identifiable, FetchableRecord, PersistableRecord } extension Setting { - fileprivate init?(key: String, value: T?) { + // MARK: - Numeric Setting + + fileprivate init?(key: String, value: T?) { guard let value: T = value else { return nil } var targetValue: T = value @@ -30,7 +32,7 @@ extension Setting { self.value = Data(bytes: &targetValue, count: MemoryLayout.size(ofValue: targetValue)) } - fileprivate func value(as type: T.Type) -> T? { + fileprivate func value(as type: T.Type) -> T? { // Note: The 'assumingMemoryBound' is essentially going to try to convert // the memory into the provided type so can result in invalid data being // returned if the type is incorrect. But it does seem safer than the 'load' @@ -39,6 +41,43 @@ extension Setting { $0.baseAddress?.assumingMemoryBound(to: T.self).pointee } } + + // MARK: - Bool Setting + + fileprivate init?(key: String, value: Bool?) { + guard let value: Bool = value else { return nil } + + var targetValue: Bool = value + + self.key = key + self.value = Data(bytes: &targetValue, count: MemoryLayout.size(ofValue: targetValue)) + } + + fileprivate func value(as type: Bool.Type) -> Bool? { + // Note: The 'assumingMemoryBound' is essentially going to try to convert + // the memory into the provided type so can result in invalid data being + // returned if the type is incorrect. But it does seem safer than the 'load' + // method which crashed under certain circumstances (an `Int` value of 0) + return value.withUnsafeBytes { + $0.baseAddress?.assumingMemoryBound(to: Bool.self).pointee + } + } + + // MARK: - String Setting + + fileprivate init?(key: String, value: String?) { + guard + let value: String = value, + let valueData: Data = value.data(using: .utf8) + else { return nil } + + self.key = key + self.value = valueData + } + + fileprivate func value(as type: String.Type) -> String? { + return String(data: value, encoding: .utf8) + } } // MARK: - Keys diff --git a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift index ac6ee17a5..1dee7feed 100644 --- a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift +++ b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift @@ -13,8 +13,9 @@ public class PagedDatabaseObserver: TransactionObserver where private let pagedTableName: String private let idColumnName: String - private var pageInfo: Atomic + public var pageInfo: Atomic + private let observedTableChangeTypes: [String: PagedData.ObservedChanges] private let allObservedTableNames: Set private let observedInserts: Set private let observedUpdateColumns: [String: Set] @@ -22,8 +23,9 @@ public class PagedDatabaseObserver: TransactionObserver where private let joinSQL: SQL? private let filterSQL: SQL + private let groupSQL: SQL? private let orderSQL: SQL - private let dataQuery: (SQL?, SQL?) -> AdaptedFetchRequest> + private let dataQuery: ([Int64]) -> AdaptedFetchRequest> private let associatedRecords: [ErasedAssociatedRecord] private var dataCache: Atomic> = Atomic(DataCache()) @@ -40,8 +42,9 @@ public class PagedDatabaseObserver: TransactionObserver where observedChanges: [PagedData.ObservedChanges], joinSQL: SQL? = nil, filterSQL: SQL, + groupSQL: SQL? = nil, orderSQL: SQL, - dataQuery: @escaping (SQL?, SQL?) -> AdaptedFetchRequest>, + dataQuery: @escaping ([Int64]) -> AdaptedFetchRequest>, associatedRecords: [ErasedAssociatedRecord] = [], onChangeUnsorted: @escaping ([T], PagedData.PageInfo) -> () ) { @@ -53,12 +56,15 @@ public class PagedDatabaseObserver: TransactionObserver where self.pageInfo = Atomic(PagedData.PageInfo(pageSize: pageSize)) self.joinSQL = joinSQL self.filterSQL = filterSQL + self.groupSQL = groupSQL self.orderSQL = orderSQL self.dataQuery = dataQuery self.associatedRecords = associatedRecords self.onChangeUnsorted = onChangeUnsorted // Combine the various observed changes into a single set + self.observedTableChangeTypes = observedChanges + .reduce(into: [:]) { result, next in result[next.databaseTableName] = next } let allObservedChanges: [PagedData.ObservedChanges] = observedChanges .appending(contentsOf: associatedRecords.flatMap { $0.observedChanges }) self.allObservedTableNames = allObservedChanges @@ -124,6 +130,7 @@ public class PagedDatabaseObserver: TransactionObserver where // updated rows guard !committedChanges.isEmpty else { return } + let joinSQL: SQL? = self.joinSQL let orderSQL: SQL = self.orderSQL let filterSQL: SQL = self.filterSQL let associatedRecords: [ErasedAssociatedRecord] = self.associatedRecords @@ -166,18 +173,25 @@ public class PagedDatabaseObserver: TransactionObserver where } } - // Determing if there were any relevant paged data changes - let relevantChanges: Set = committedChanges + // Determing if there were any direct or related data changes + let directChanges: Set = committedChanges .filter { $0.tableName == pagedTableName } + let relatedChanges: [String: [PagedData.TrackedChange]] = committedChanges + .filter { $0.tableName != pagedTableName } + .reduce(into: [:]) { result, next in + guard observedTableChangeTypes[next.tableName] != nil else { return } + + result[next.tableName] = (result[next.tableName] ?? []).appending(next) + } - guard !relevantChanges.isEmpty else { + guard !directChanges.isEmpty || !relatedChanges.isEmpty else { updateDataAndCallbackIfNeeded(self.dataCache.wrappedValue, self.pageInfo.wrappedValue, false) return } var updatedPageInfo: PagedData.PageInfo = self.pageInfo.wrappedValue var updatedDataCache: DataCache = self.dataCache.wrappedValue - let deletionChanges: [Int64] = relevantChanges + let deletionChanges: [Int64] = directChanges .filter { $0.kind == .delete } .map { $0.rowId } let oldDataCount: Int = dataCache.wrappedValue.count @@ -200,10 +214,39 @@ public class PagedDatabaseObserver: TransactionObserver where } // If there are no inserted/updated rows then trigger the update callback and stop here - let changesToQuery: [PagedData.TrackedChange] = relevantChanges + let changesToQuery: [PagedData.TrackedChange] = directChanges .filter { $0.kind != .delete } - guard !changesToQuery.isEmpty else { + guard !changesToQuery.isEmpty || !relatedChanges.isEmpty else { + updateDataAndCallbackIfNeeded(updatedDataCache, updatedPageInfo, !deletionChanges.isEmpty) + return + } + + // First we need to get the rowIds for the paged data connected to any of the related changes + let pagedRowIdsForRelatedChanges: Set = { + guard !relatedChanges.isEmpty else { return [] } + + return relatedChanges + .reduce(into: []) { result, next in + guard + let observedChange: PagedData.ObservedChanges = observedTableChangeTypes[next.key], + let joinToPagedType: SQL = observedChange.joinToPagedType + else { return } + + let pagedRowIds: [Int64] = PagedData.pagedRowIdsForRelatedRowIds( + db, + tableName: next.key, + pagedTableName: pagedTableName, + relatedRowIds: Array(next.value.map { $0.rowId }.asSet()), + joinToPagedType: joinToPagedType + ) + + result.append(contentsOf: pagedRowIds) + } + .asSet() + }() + + guard !changesToQuery.isEmpty || !pagedRowIdsForRelatedChanges.isEmpty else { updateDataAndCallbackIfNeeded(updatedDataCache, updatedPageInfo, !deletionChanges.isEmpty) return } @@ -213,6 +256,15 @@ public class PagedDatabaseObserver: TransactionObserver where db, rowIds: changesToQuery.map { $0.rowId }, tableName: pagedTableName, + requiredJoinSQL: joinSQL, + orderSQL: orderSQL, + filterSQL: filterSQL + ) + let relatedChangeIndexes: [Int64] = PagedData.indexes( + db, + rowIds: Array(pagedRowIdsForRelatedChanges), + tableName: pagedTableName, + requiredJoinSQL: joinSQL, orderSQL: orderSQL, filterSQL: filterSQL ) @@ -221,23 +273,34 @@ public class PagedDatabaseObserver: TransactionObserver where // which shouldn't - values less than 'currentCount' or if there is at least one value less than // 'currentCount' and the indexes are sequential (ie. more than the current loaded content was // added at once) - let itemIndexesAreSequential: Bool = (itemIndexes.map { $0 - 1 }.dropFirst() == itemIndexes.dropLast()) - let hasOneValidIndex: Bool = itemIndexes.contains(where: { index -> Bool in - index >= updatedPageInfo.pageOffset && ( - index < updatedPageInfo.currentCount || - updatedPageInfo.currentCount == 0 + func determineValidChanges(for indexes: [Int64], with data: [T]) -> [T] { + let indexesAreSequential: Bool = (indexes.map { $0 - 1 }.dropFirst() == indexes.dropLast()) + let hasOneValidIndex: Bool = indexes.contains(where: { index -> Bool in + index >= updatedPageInfo.pageOffset && ( + index < updatedPageInfo.currentCount || + updatedPageInfo.currentCount == 0 + ) + }) + + return (indexesAreSequential && hasOneValidIndex ? + data : + zip(indexes, data) + .filter { index, _ -> Bool in + index >= updatedPageInfo.pageOffset && ( + index < updatedPageInfo.currentCount || + updatedPageInfo.currentCount == 0 + ) + } + .map { _, value -> T in value } ) - }) - let validChanges: [PagedData.TrackedChange] = (itemIndexesAreSequential && hasOneValidIndex ? - changesToQuery : - zip(itemIndexes, changesToQuery) - .filter { index, _ -> Bool in - index >= updatedPageInfo.pageOffset && ( - index < updatedPageInfo.currentCount || - updatedPageInfo.currentCount == 0 - ) - } - .map { _, change -> PagedData.TrackedChange in change } + } + let validChanges: [PagedData.TrackedChange] = determineValidChanges( + for: itemIndexes, + with: changesToQuery + ) + let validRelatedChangeRowIds: [Int64] = determineValidChanges( + for: relatedChangeIndexes, + with: Array(pagedRowIdsForRelatedChanges) ) let countBefore: Int = itemIndexes.filter { $0 < updatedPageInfo.pageOffset }.count @@ -252,14 +315,14 @@ public class PagedDatabaseObserver: TransactionObserver where // If there are no valid row ids then stop here (trigger updates though since the page info // has changes) - guard !validChanges.isEmpty else { + guard !validChanges.isEmpty || !validRelatedChangeRowIds.isEmpty else { updateDataAndCallbackIfNeeded(updatedDataCache, updatedPageInfo, true) return } // Fetch the inserted/updated rows - let additionalFilters: SQL = SQL(validChanges.map { $0.rowId }.contains(Column.rowID)) - let updatedItems: [T] = (try? dataQuery(additionalFilters, nil) + let targetRowIds: [Int64] = Array((validChanges.map { $0.rowId } + validRelatedChangeRowIds).asSet()) + let updatedItems: [T] = (try? dataQuery(targetRowIds) .fetchAll(db)) .defaulting(to: []) @@ -302,12 +365,18 @@ public class PagedDatabaseObserver: TransactionObserver where let idColumnName: String = self.idColumnName let joinSQL: SQL? = self.joinSQL let filterSQL: SQL = self.filterSQL + let groupSQL: SQL? = self.groupSQL let orderSQL: SQL = self.orderSQL - let dataQuery: (SQL?, SQL?) -> AdaptedFetchRequest> = self.dataQuery + let dataQuery: ([Int64]) -> AdaptedFetchRequest> = self.dataQuery let loadedPage: (data: [T]?, pageInfo: PagedData.PageInfo)? = GRDBStorage.shared.read { [weak self] db in - let totalCount: Int = try dataQuery(filterSQL, nil) - .fetchCount(db) + let totalCount: Int = PagedData.totalCount( + db, + tableName: pagedTableName, + requiredJoinSQL: joinSQL, + filterSQL: filterSQL + ) + let queryInfo: (limit: Int, offset: Int, updatedCacheOffset: Int)? = { switch target { case .initialPageAround(let targetId): @@ -420,8 +489,17 @@ public class PagedDatabaseObserver: TransactionObserver where } // Fetch the desired data - let limitSQL: SQL = SQL(stringLiteral: "LIMIT \(queryInfo.limit) OFFSET \(queryInfo.offset)") - let newData: [T] = try dataQuery(filterSQL, limitSQL) + let pageRowIds: [Int64] = PagedData.rowIds( + db, + tableName: pagedTableName, + requiredJoinSQL: joinSQL, + filterSQL: filterSQL, + groupSQL: groupSQL, + orderSQL: orderSQL, + limit: queryInfo.limit, + offset: queryInfo.offset + ) + let newData: [T] = try dataQuery(pageRowIds) .fetchAll(db) let updatedLimitInfo: PagedData.PageInfo = PagedData.PageInfo( pageSize: currentPageInfo.pageSize, @@ -498,8 +576,9 @@ public extension PagedDatabaseObserver { observedChanges: [PagedData.ObservedChanges], joinSQL: SQL? = nil, filterSQL: SQL, + groupSQL: SQL? = nil, orderSQL: SQL, - dataQuery: @escaping (SQL?, SQL?) -> SQLRequest, + dataQuery: @escaping ([Int64]) -> SQLRequest, associatedRecords: [ErasedAssociatedRecord] = [], onChangeUnsorted: @escaping ([T], PagedData.PageInfo) -> () ) { @@ -510,10 +589,9 @@ public extension PagedDatabaseObserver { observedChanges: observedChanges, joinSQL: joinSQL, filterSQL: filterSQL, + groupSQL: groupSQL, orderSQL: orderSQL, - dataQuery: { additionalFilters, limit in - dataQuery(additionalFilters, limit).adapted { _ in ScopeAdapter([:]) } - }, + dataQuery: { rowIds in dataQuery(rowIds).adapted { _ in ScopeAdapter([:]) } }, associatedRecords: associatedRecords, onChangeUnsorted: onChangeUnsorted ) @@ -697,15 +775,18 @@ public enum PagedData { public let databaseTableName: String public let events: [DatabaseEvent.Kind] public let columns: [String] + public let joinToPagedType: SQL? public init( table: T.Type, events: [DatabaseEvent.Kind] = [.insert, .update, .delete], - columns: [T.Columns] + columns: [T.Columns], + joinToPagedType: SQL? = nil ) { self.databaseTableName = table.databaseTableName self.events = events self.columns = columns.map { $0.name } + self.joinToPagedType = joinToPagedType } } @@ -725,6 +806,49 @@ public enum PagedData { // MARK: - Internal Functions + fileprivate static func totalCount( + _ db: Database, + tableName: String, + requiredJoinSQL: SQL? = nil, + filterSQL: SQL + ) -> Int { + let tableNameLiteral: SQL = SQL(stringLiteral: tableName) + let request: SQLRequest = """ + SELECT \(tableNameLiteral).rowId + FROM \(tableNameLiteral) + \(requiredJoinSQL ?? "") + WHERE \(filterSQL) + """ + + return (try? request.fetchCount(db)) + .defaulting(to: 0) + } + + fileprivate static func rowIds( + _ db: Database, + tableName: String, + requiredJoinSQL: SQL? = nil, + filterSQL: SQL, + groupSQL: SQL? = nil, + orderSQL: SQL, + limit: Int, + offset: Int + ) -> [Int64] { + let tableNameLiteral: SQL = SQL(stringLiteral: tableName) + let request: SQLRequest = """ + SELECT \(tableNameLiteral).rowId + FROM \(tableNameLiteral) + \(requiredJoinSQL ?? "") + WHERE \(filterSQL) + \(groupSQL ?? "") + ORDER BY \(orderSQL) + LIMIT \(limit) OFFSET \(offset) + """ + + return (try? request.fetchAll(db)) + .defaulting(to: []) + } + fileprivate static func index( _ db: Database, for id: ID, @@ -766,6 +890,8 @@ public enum PagedData { joinToPagedType: SQL? = nil, groupPagedType: SQL? = nil ) -> [Int64] { + guard !rowIds.isEmpty else { return [] } + let tableNameLiteral: SQL = SQL(stringLiteral: tableName) /// **Note:** `ROW_NUMBER` works by returning the index of the row in a given query, unfortunately when dealing @@ -826,6 +952,8 @@ public enum PagedData { pagedTypeRowIds: [Int64], joinToPagedType: SQL ) -> [Int64] { + guard !pagedTypeRowIds.isEmpty else { return [] } + let tableNameLiteral: SQL = SQL(stringLiteral: tableName) let pagedTableNameLiteral: SQL = SQL(stringLiteral: pagedTableName) let request: SQLRequest = """ @@ -838,6 +966,29 @@ public enum PagedData { return (try? request.fetchAll(db)) .defaulting(to: []) } + + /// Returns the rowIds for the paged type based on the specified relatedRowIds + fileprivate static func pagedRowIdsForRelatedRowIds( + _ db: Database, + tableName: String, + pagedTableName: String, + relatedRowIds: [Int64], + joinToPagedType: SQL + ) -> [Int64] { + guard !relatedRowIds.isEmpty else { return [] } + + let tableNameLiteral: SQL = SQL(stringLiteral: tableName) + let pagedTableNameLiteral: SQL = SQL(stringLiteral: pagedTableName) + let request: SQLRequest = """ + SELECT \(pagedTableNameLiteral).rowid AS rowid + FROM \(pagedTableNameLiteral) + \(joinToPagedType) + WHERE \(tableNameLiteral).rowId IN \(relatedRowIds) + """ + + return (try? request.fetchAll(db)) + .defaulting(to: []) + } } // MARK: - AssociatedRecord diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index 7b2073f3c..2ab114973 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -28,6 +28,7 @@ public protocol JobExecutor { /// updated `job`) static func run( _ job: Job, + queue: DispatchQueue, success: @escaping (Job, Bool) -> (), failure: @escaping (Job, Error?, Bool) -> (), deferred: @escaping (Job) -> () @@ -38,7 +39,7 @@ public final class JobRunner { private static let blockingQueue: Atomic = Atomic( JobQueue( type: .blocking, - qos: .userInitiated, + qos: .default, jobVariants: [], onQueueDrained: { // Once all blocking jobs have been completed we want to start running @@ -686,6 +687,7 @@ private final class JobQueue { jobExecutor.run( nextJob, + queue: internalQueue, success: handleJobSucceeded, failure: handleJobFailed, deferred: handleJobDeferred diff --git a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift index 52b17af03..bf8130c9e 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift @@ -162,7 +162,8 @@ public class MediaMessageView: UIView, OWSAudioPlayerDelegate { } else if attachment.isUrl { view.clipsToBounds = true - view.image = UIImage(named: "Link")?.withTint(Colors.text) + view.image = UIImage(named: "Link")?.withRenderingMode(.alwaysTemplate) + view.tintColor = Colors.text view.contentMode = .center view.backgroundColor = (isDarkMode ? .black : UIColor.black.withAlphaComponent(0.06)) view.layer.cornerRadius = 8 diff --git a/SignalUtilitiesKit/Profile Pictures/Identicon+ObjC.swift b/SignalUtilitiesKit/Profile Pictures/Identicon+ObjC.swift index 286c10cfd..feac3ea3f 100644 --- a/SignalUtilitiesKit/Profile Pictures/Identicon+ObjC.swift +++ b/SignalUtilitiesKit/Profile Pictures/Identicon+ObjC.swift @@ -5,6 +5,12 @@ import SessionUtilitiesKit @objc(LKIdenticon) public final class Identicon: NSObject { + private static let placeholderCache: Atomic> = { + let result = NSCache() + result.countLimit = 50 + + return Atomic(result) + }() @objc public static func generatePlaceholderIcon(seed: String, text: String, size: CGFloat) -> UIImage { let icon = PlaceholderIcon(seed: seed) @@ -25,6 +31,12 @@ public final class Identicon: NSObject { .split(separator: " ") .compactMap { word in word.first.map { String($0) } } .joined() + let cacheKey: String = "\(content)-\(Int(floor(size)))" + + if let cachedIcon: UIImage = placeholderCache.wrappedValue.object(forKey: cacheKey as NSString) { + return cachedIcon + } + let layer = icon.generateLayer( with: size, text: (initials.count >= 2 ? @@ -35,7 +47,10 @@ public final class Identicon: NSObject { let rect = CGRect(origin: CGPoint.zero, size: layer.frame.size) let renderer = UIGraphicsImageRenderer(size: rect.size) + let result = renderer.image { layer.render(in: $0.cgContext) } - return renderer.image { layer.render(in: $0.cgContext) } + placeholderCache.mutate { $0.setObject(result, forKey: cacheKey as NSString) } + + return result } } From 2cd9f571da5651c29f49cef982c02a96e81fcb04 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 27 Jun 2022 12:04:51 +1000 Subject: [PATCH 117/157] Fixed a number of bugs Fixed a bug where threads might not be getting marked as read correctly Fixed a bug where the GarbageCollectionJob could end up blocking the database write thread (seemed to only hang when the debugger was attached but may have affected devices at some point) Fixed a bug with thread sorting Fixed a bug where joining an open group wouldn't appear until after the first poll completed Fixed a bug where conversations with no interactions would display odd interaction copy Fixed a bug where the sender name was appearing above outgoing messages in groups --- Session/Shared/FullConversationCell.swift | 3 + .../Database/Models/Interaction.swift | 25 +- .../Jobs/Types/GarbageCollectionJob.swift | 214 ++++++++++-------- .../Open Groups/OpenGroupManager.swift | 1 + .../Shared Models/MessageViewModel.swift | 7 + .../SessionThreadViewModel.swift | 6 +- 6 files changed, 150 insertions(+), 106 deletions(-) diff --git a/Session/Shared/FullConversationCell.swift b/Session/Shared/FullConversationCell.swift index d645035d1..b131b808a 100644 --- a/Session/Shared/FullConversationCell.swift +++ b/Session/Shared/FullConversationCell.swift @@ -381,6 +381,9 @@ public final class FullConversationCell: UITableViewCell { // MARK: - Snippet generation private func getSnippet(cellViewModel: SessionThreadViewModel) -> NSMutableAttributedString { + // If we don't have an interaction then do nothing + guard cellViewModel.interactionId != nil else { return NSMutableAttributedString() } + let result = NSMutableAttributedString() if Date().timeIntervalSince1970 < (cellViewModel.threadMutedUntilTimestamp ?? 0) { diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 43c63b5c1..82ea89024 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -462,7 +462,24 @@ public extension Interaction { } // If we aren't including older interactions then update and save the current one - guard includingOlder else { + struct InteractionReadInfo: Decodable, FetchableRecord { + let timestampMs: Int64 + let wasRead: Bool + } + + // Since there is no guarantee on the order messages are inserted into the database + // fetch the timestamp for the interaction and set everything before that as read + let maybeInteractionInfo: InteractionReadInfo? = try Interaction + .select(.timestampMs, .wasRead) + .filter(id: interactionId) + .asRequest(of: InteractionReadInfo.self) + .fetchOne(db) + + guard includingOlder, let interactionInfo: InteractionReadInfo = maybeInteractionInfo else { + // Only mark as read and trigger the subsequent jobs if the interaction is + // actually not read (no point updating and triggering db changes otherwise) + guard maybeInteractionInfo?.wasRead == false else { return } + _ = try Interaction .filter(id: interactionId) .updateAll(db, Columns.wasRead.set(to: true)) @@ -472,9 +489,9 @@ public extension Interaction { } let interactionQuery = Interaction - .filter(Columns.threadId == threadId) - .filter(Columns.id <= interactionId) - .filter(Columns.wasRead == false) + .filter(Interaction.Columns.threadId == threadId) + .filter(Interaction.Columns.timestampMs <= interactionInfo.timestampMs) + .filter(Interaction.Columns.wasRead == false) // The `wasRead` flag doesn't apply to `standardOutgoing` or `standardIncomingDeleted` .filter(Columns.variant != Variant.standardOutgoing && Columns.variant != Variant.standardIncomingDeleted) let interactionIdsToMarkAsRead: [Int64] = try interactionQuery diff --git a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift index b5d73c972..42c8de672 100644 --- a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift +++ b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift @@ -35,8 +35,6 @@ public enum GarbageCollectionJob: JobExecutor { } let timestampNow: TimeInterval = Date().timeIntervalSince1970 - var attachmentLocalRelativePaths: Set = [] - var profileAvatarFilenames: Set = [] GRDBStorage.shared.writeAsync( updates: { db in @@ -203,109 +201,127 @@ public enum GarbageCollectionJob: JobExecutor { ) """) } - - /// Orphaned attachment files - attachment files which don't have an associated record in the database - if details.typesToCollect.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 profile avatar files - profile avatar files which don't have an associated record in the database - if details.typesToCollect.contains(.orphanedProfileAvatars) { - profileAvatarFilenames = try Profile - .select(.profilePictureFileName) - .filter(Profile.Columns.profilePictureFileName != nil) - .asRequest(of: String.self) - .fetchSet(db) - } }, - completion: { _, result in - // If any of the above failed then we don't want to continue (we would end up deleting all files since - // neither of the arrays would have been populated correctly) - guard case .success = result else { - SNLog("[GarbageCollectionJob] Database queries failed, skipping file cleanup") - return - } - - var deletionErrors: [Error] = [] - - // Orphaned attachment files (actual deletion) - if details.typesToCollect.contains(.orphanedAttachmentFiles) { - // Note: Looks like in order to recursively look through files we need to use the - // enumerator method - let fileEnumerator = FileManager.default.enumerator( - at: URL(fileURLWithPath: Attachment.attachmentsFolder), - includingPropertiesForKeys: nil, - options: .skipsHiddenFiles // Ignore the `.DS_Store` for the simulator - ) - - let allAttachmentFilePaths: Set = (fileEnumerator? - .allObjects - .compactMap { Attachment.localRelativeFilePath(from: ($0 as? URL)?.path) }) - .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) - let directoryNamesContainingContent: [String] = allAttachmentFilePaths - .filter { path -> Bool in path.contains("/") } - .compactMap { path -> String? in path.components(separatedBy: "/").first } - let orphanedAttachmentFiles: Set = allAttachmentFilePaths - .subtracting(attachmentLocalRelativePaths) - .subtracting(directoryNamesContainingContent) - - 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 - do { - try FileManager.default.removeItem( - atPath: URL(fileURLWithPath: Attachment.attachmentsFolder) - .appendingPathComponent(filepath) - .path - ) - } - catch { deletionErrors.append(error) } + completion: { _, _ in + // Dispatch async so we can swap from the write queue to a read one (we are done writing) + queue.async { + // Retrieve a list of all valid attachmnet and avatar file paths + struct FileInfo { + let attachmentLocalRelativePaths: Set + let profileAvatarFilenames: Set } - } - - // Orphaned profile avatar files (actual deletion) - if details.typesToCollect.contains(.orphanedProfileAvatars) { - let allAvatarProfileFilenames: Set = (try? FileManager.default - .contentsOfDirectory(atPath: ProfileManager.sharedDataProfileAvatarsDirPath)) - .defaulting(to: []) - .asSet() - let orphanedAvatarFiles: Set = allAvatarProfileFilenames - .subtracting(profileAvatarFilenames) - orphanedAvatarFiles.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 - do { - try FileManager.default.removeItem( - atPath: ProfileManager.profileAvatarFilepath(filename: filename) - ) + let maybeFileInfo: FileInfo? = GRDBStorage.shared.read { db -> FileInfo in + var attachmentLocalRelativePaths: Set = [] + var profileAvatarFilenames: Set = [] + + /// Orphaned attachment files - attachment files which don't have an associated record in the database + if details.typesToCollect.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) } - catch { deletionErrors.append(error) } + + /// Orphaned profile avatar files - profile avatar files which don't have an associated record in the database + if details.typesToCollect.contains(.orphanedProfileAvatars) { + profileAvatarFilenames = try Profile + .select(.profilePictureFileName) + .filter(Profile.Columns.profilePictureFileName != nil) + .asRequest(of: String.self) + .fetchSet(db) + } + + return FileInfo( + attachmentLocalRelativePaths: attachmentLocalRelativePaths, + profileAvatarFilenames: profileAvatarFilenames + ) } + + // 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) + if details.typesToCollect.contains(.orphanedAttachmentFiles) { + // Note: Looks like in order to recursively look through files we need to use the + // enumerator method + let fileEnumerator = FileManager.default.enumerator( + at: URL(fileURLWithPath: Attachment.attachmentsFolder), + includingPropertiesForKeys: nil, + options: .skipsHiddenFiles // Ignore the `.DS_Store` for the simulator + ) + + let allAttachmentFilePaths: Set = (fileEnumerator? + .allObjects + .compactMap { Attachment.localRelativeFilePath(from: ($0 as? URL)?.path) }) + .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) + let directoryNamesContainingContent: [String] = allAttachmentFilePaths + .filter { path -> Bool in path.contains("/") } + .compactMap { path -> String? in path.components(separatedBy: "/").first } + let orphanedAttachmentFiles: Set = allAttachmentFilePaths + .subtracting(fileInfo.attachmentLocalRelativePaths) + .subtracting(directoryNamesContainingContent) + + 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 + do { + try FileManager.default.removeItem( + atPath: URL(fileURLWithPath: Attachment.attachmentsFolder) + .appendingPathComponent(filepath) + .path + ) + } + catch { deletionErrors.append(error) } + } + } + + // Orphaned profile avatar files (actual deletion) + if details.typesToCollect.contains(.orphanedProfileAvatars) { + let allAvatarProfileFilenames: Set = (try? FileManager.default + .contentsOfDirectory(atPath: ProfileManager.sharedDataProfileAvatarsDirPath)) + .defaulting(to: []) + .asSet() + let orphanedAvatarFiles: Set = allAvatarProfileFilenames + .subtracting(fileInfo.profileAvatarFilenames) + + orphanedAvatarFiles.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 + do { + try FileManager.default.removeItem( + atPath: ProfileManager.profileAvatarFilepath(filename: filename) + ) + } + 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 + } + + success(job, false) } - - // 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 - } - - success(job, false) } ) } diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index c3f60f60b..602159fcf 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -169,6 +169,7 @@ public final class OpenGroupManager: NSObject { // Optionally try to insert a new version of the OpenGroup (it will fail if there is already an // inactive one but that won't matter as we then activate it _ = try? SessionThread.fetchOrCreate(db, id: threadId, variant: .openGroup) + _ = try? SessionThread.filter(id: threadId).updateAll(db, SessionThread.Columns.shouldBeVisible.set(to: true)) if (try? OpenGroup.exists(db, id: threadId)) == false { try? OpenGroup diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index 34193045f..67f37827a 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -101,6 +101,8 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, public let authorName: String /// This value will be used to populate the author label, if it's null then the label will be hidden + /// + /// **Note:** This will only be populated for incoming messages public let senderName: String? /// A flag indicating whether the profile view should be displayed @@ -330,6 +332,11 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, guard self.threadVariant == .openGroup || self.threadVariant == .closedGroup else { return nil } + + // Only show for incoming messages + guard self.variant == .standardIncoming || self.variant == .standardIncomingDeleted else { + return nil + } // Only if there is a date header or the senders are different guard shouldShowDateOnThisModel || self.authorId != prevModel?.authorId else { diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 2b3a085cd..a4401df7f 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -500,13 +500,13 @@ public extension SessionThreadViewModel { static let homeOrderSQL: SQL = { let thread: TypedTableAlias = TypedTableAlias() - return SQL("\(thread[.isPinned]) DESC, IFNULL(\(Interaction.self).\(ViewModel.interactionTimestampMsKey), \(thread[.creationDateTimestamp])) DESC") + return SQL("\(thread[.isPinned]) DESC, IFNULL(\(Interaction.self).\(ViewModel.interactionTimestampMsKey), (\(thread[.creationDateTimestamp]) * 1000)) DESC") }() static let messageRequetsOrderSQL: SQL = { let thread: TypedTableAlias = TypedTableAlias() - return SQL("IFNULL(\(Interaction.self).\(ViewModel.interactionTimestampMsKey), \(thread[.creationDateTimestamp])) DESC") + return SQL("IFNULL(\(Interaction.self).\(ViewModel.interactionTimestampMsKey), (\(thread[.creationDateTimestamp]) * 1000)) DESC") }() } @@ -1388,7 +1388,7 @@ public extension SessionThreadViewModel { ) GROUP BY \(thread[.id]) - ORDER BY IFNULL(\(interaction[.timestampMs]), \(thread[.creationDateTimestamp])) DESC + ORDER BY IFNULL(\(interaction[.timestampMs]), (\(thread[.creationDateTimestamp]) * 1000)) DESC """ return request.adapted { db in From ff2d96e0d5febe92feee8753fd000fbd25b2db34 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 27 Jun 2022 17:57:46 +1000 Subject: [PATCH 118/157] Fixed a handful more bugs Fixed an issue where I'd shifted push notification logic to a background thread resulting in crashes Fixed a bug where the status indicator view on the FullConversationCell was incorrectly showing for incoming messages Fixed a bug where outgoing messages to closed groups would all be flagged as failed to send Fixed a bug with the "autoLoadNextPageIfNeeded" on the conversation screen Fixed a bug where the input view on a closed group wouldn't appear correctly based on whether the user was a member or not Added the "autoLoadNextPageIfNeeded" logic to the home screen --- Session/Conversations/ConversationVC.swift | 9 ++--- .../Conversations/ConversationViewModel.swift | 15 +++++++- Session/Home/HomeVC.swift | 34 ++++++++++++++++++- Session/Home/HomeViewModel.swift | 2 +- Session/Notifications/SyncPushTokensJob.swift | 27 ++------------- Session/Shared/FullConversationCell.swift | 2 +- .../Database/Models/Interaction.swift | 19 +++++++---- .../SessionThreadViewModel.swift | 15 +++++--- 8 files changed, 79 insertions(+), 44 deletions(-) diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 660b29e7b..c3c767ae2 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -591,12 +591,13 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers snInputView.text = draft } + // Now we have done all the needed diffs, update the viewModel with the latest data + self.viewModel.updateThreadData(updatedThreadData) + + /// **Note:** This needs to happen **after** we have update the viewModel's thread data if viewModel.threadData.currentUserIsClosedGroupMember != updatedThreadData.currentUserIsClosedGroupMember { reloadInputViews() } - - // Now we have done all the needed diffs, update the viewModel with the latest data - self.viewModel.updateThreadData(updatedThreadData) } private func handleInteractionUpdates(_ updatedData: [ConversationViewModel.SectionModel], initialLoad: Bool = false) { @@ -837,7 +838,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers // Note: We sort the headers as we want to prioritise loading newer pages over older ones let sections: [(ConversationViewModel.Section, CGRect)] = (self?.viewModel.interactionData .enumerated() - .map { index, section in (section.model, (self?.tableView.rectForHeader(inSection: 0) ?? .zero)) }) + .map { index, section in (section.model, (self?.tableView.rectForHeader(inSection: index) ?? .zero)) }) .defaulting(to: []) let shouldLoadOlder: Bool = sections .contains { section, headerRect in diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index c5fb9aca5..d6e3aa314 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -98,7 +98,20 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { // MARK: - Thread Data /// This value is the current state of the view - public private(set) var threadData: SessionThreadViewModel = SessionThreadViewModel() + public private(set) lazy var threadData: SessionThreadViewModel = SessionThreadViewModel( + threadId: self.threadId, + threadVariant: self.initialThreadVariant, + currentUserIsClosedGroupMember: (self.initialThreadVariant != .closedGroup ? + nil : + GRDBStorage.shared.read { db in + try GroupMember + .filter(GroupMember.Columns.groupId == self.threadId) + .filter(GroupMember.Columns.profileId == getUserHexEncodedPublicKey(db)) + .filter(GroupMember.Columns.role == GroupMember.Role.standard) + .isNotEmpty(db) + } + ) + ) /// 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 diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index ca1d002b5..67c08c973 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -15,6 +15,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve private var hasLoadedInitialStateData: Bool = false private var hasLoadedInitialThreadData: Bool = false private var isLoadingMore: Bool = false + private var isAutoLoadingNextPage: Bool = false // MARK: - Intialization @@ -319,6 +320,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve CATransaction.setCompletionBlock { [weak self] in // Complete page loading self?.isLoadingMore = false + self?.autoLoadNextPageIfNeeded() } // Reload the table content (animate changes after the first load) @@ -328,7 +330,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve insertSectionsAnimation: .none, reloadSectionsAnimation: .none, deleteRowsAnimation: .bottom, - insertRowsAnimation: .top, + insertRowsAnimation: .none, reloadRowsAnimation: .none, interrupt: { $0.changeCount > 100 } // Prevent too many changes from causing performance issues ) { [weak self] updatedData in @@ -338,6 +340,36 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve CATransaction.commit() } + private func autoLoadNextPageIfNeeded() { + guard !self.isAutoLoadingNextPage && !self.isLoadingMore else { return } + + self.isAutoLoadingNextPage = true + + DispatchQueue.main.asyncAfter(deadline: .now() + PagedData.autoLoadNextPageDelay) { [weak self] in + self?.isAutoLoadingNextPage = false + + // Note: We sort the headers as we want to prioritise loading newer pages over older ones + let sections: [(HomeViewModel.Section, CGRect)] = (self?.viewModel.threadData + .enumerated() + .map { index, section in (section.model, (self?.tableView.rectForHeader(inSection: index) ?? .zero)) }) + .defaulting(to: []) + let shouldLoadMore: Bool = sections + .contains { section, headerRect in + section == .loadMore && + headerRect != .zero && + (self?.tableView.bounds.contains(headerRect) == true) + } + + guard shouldLoadMore else { return } + + self?.isLoadingMore = true + + DispatchQueue.global(qos: .default).async { [weak self] in + self?.viewModel.pagedDataObserver?.load(.pageAfter) + } + } + } + private func updateNavBarButtons() { // Profile picture view let profilePictureSize = Values.verySmallProfilePictureSize diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 543830292..a1ff37bfd 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -19,7 +19,7 @@ public class HomeViewModel { // MARK: - Variables - public static let pageSize: Int = 14 + public static let pageSize: Int = 15 public struct State: Equatable { let showViewedSeedBanner: Bool diff --git a/Session/Notifications/SyncPushTokensJob.swift b/Session/Notifications/SyncPushTokensJob.swift index 1ce1a1220..a5234e826 100644 --- a/Session/Notifications/SyncPushTokensJob.swift +++ b/Session/Notifications/SyncPushTokensJob.swift @@ -33,31 +33,8 @@ public enum SyncPushTokensJob: JobExecutor { } return } - let isRegisteredForRemoteNotifications: Bool = UIApplication.shared.isRegisteredForRemoteNotifications - // Swap back to the correct queue before continuing (don't want to inadvertantly do stuff on the main - // thread that could block the user) - queue.async { - SyncPushTokensJob.internalRun( - job, - queue: queue, - isRegisteredForRemoteNotifications: isRegisteredForRemoteNotifications, - success: success, - failure: failure, - deferred: deferred - ) - } - } - - private static func internalRun( - _ job: Job, - queue: DispatchQueue, - isRegisteredForRemoteNotifications: Bool, - success: @escaping (Job, Bool) -> (), - failure: @escaping (Job, Error?, Bool) -> (), - deferred: @escaping (Job) -> () - ) { - guard !isRegisteredForRemoteNotifications else { + guard !UIApplication.shared.isRegisteredForRemoteNotifications else { deferred(job) // Don't need to do anything if push notifications are already registered return } @@ -97,7 +74,7 @@ public enum SyncPushTokensJob: JobExecutor { ) return promise - .done { _ in + .done(on: queue) { _ in Logger.warn("Recording push tokens locally. pushToken: \(redact(pushToken)), voipToken: \(redact(voipToken))") GRDBStorage.shared.write { db in diff --git a/Session/Shared/FullConversationCell.swift b/Session/Shared/FullConversationCell.swift index b131b808a..fa9973805 100644 --- a/Session/Shared/FullConversationCell.swift +++ b/Session/Shared/FullConversationCell.swift @@ -374,7 +374,7 @@ public final class FullConversationCell: UITableViewCell { statusIndicatorView.isHidden = false default: - statusIndicatorView.isHidden = false + statusIndicatorView.isHidden = true } } diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 82ea89024..d082e108d 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -356,13 +356,18 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu return } - try members.forEach { member in - try RecipientState( - interactionId: interactionId, - recipientId: member.profileId, - state: .sending - ).insert(db) - } + // Exclude the current user when creating recipient states (as they will never + // receive the message resulting in the message getting flagged as failed) + let userPublicKey: String = getUserHexEncodedPublicKey(db) + try members + .filter { member -> Bool in member.profileId != userPublicKey } + .forEach { member in + try RecipientState( + interactionId: interactionId, + recipientId: member.profileId, + state: .sending + ).insert(db) + } case .openGroup: // Since we use the 'RecipientState' type to manage the message state diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index a4401df7f..d4f7083d3 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -194,11 +194,18 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat // MARK: - Convenience Initialization public extension SessionThreadViewModel { + static let invalidId: String = "INVALID_THREAD_ID" + // Note: This init method is only used system-created cells or empty states - init(unreadCount: UInt = 0) { + init( + threadId: String? = nil, + threadVariant: SessionThread.Variant? = nil, + currentUserIsClosedGroupMember: Bool? = nil, + unreadCount: UInt = 0 + ) { self.rowId = -1 - self.threadId = "INVALID_THREAD_ID" - self.threadVariant = .contact + self.threadId = (threadId ?? SessionThreadViewModel.invalidId) + self.threadVariant = (threadVariant ?? .contact) self.threadCreationDateTimestamp = 0 self.threadMemberNames = nil @@ -224,7 +231,7 @@ public extension SessionThreadViewModel { self.closedGroupProfileBackFallback = nil self.closedGroupName = nil self.closedGroupUserCount = nil - self.currentUserIsClosedGroupMember = nil + self.currentUserIsClosedGroupMember = currentUserIsClosedGroupMember self.currentUserIsClosedGroupAdmin = nil self.openGroupName = nil self.openGroupServer = nil From 76f7e4e246c39a0d202188434a12b9e032622f6e Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 28 Jun 2022 17:53:03 +1000 Subject: [PATCH 119/157] Fixed a number of bugs, added in logic to handle id blinding being enabled and migrated session SOGS IPs to domains Added logic to handle the case when ID blinded gets switched on server-side and the app already has open groups with cached capabilities Added logic to migrate users from using HTTP and IP-based session open groups to use the HTTPS domain name url instead Fixed a bug with the PushNotificationAPI update registration response structure Fixed some broken unit tests (and a bug which was introduced in an earlier optimisation) Fixed a bug where trusting a contact (to download their messages) wouldn't trigger the message UI to update Fixed a bug where tapping a push notification wasn't opening the associated thread when the app isn't running in the background --- Session.xcodeproj/project.pbxproj | 8 +- .../Conversations/ConversationViewModel.swift | 11 +- Session/Home/HomeVC.swift | 2 +- Session/Meta/AppDelegate.swift | 21 +++- Session/Notifications/AppNotifications.swift | 2 +- Session/Utilities/BackgroundPoller.swift | 2 +- .../Migrations/_003_YDBToGRDBMigration.swift | 25 +++- .../Open Groups/OpenGroupAPI.swift | 14 ++- .../Open Groups/OpenGroupManager.swift | 24 ++-- .../Open Groups/Types/OpenGroupAPIError.swift | 17 +++ .../Open Groups/Types/SOGSError.swift | 19 --- .../Models/UpdateRegistrationResponse.swift | 10 +- .../Notifications/PushNotificationAPI.swift | 12 +- .../Pollers/OpenGroupPoller.swift | 92 +++++++++++++- .../Shared Models/MessageViewModel.swift | 13 +- .../Open Groups/OpenGroupAPISpec.swift | 36 +++--- .../Open Groups/OpenGroupManagerSpec.swift | 3 +- .../Open Groups/Types/SOGSErrorSpec.swift | 6 +- .../NotificationServiceExtension.swift | 6 +- .../Types/PagedDatabaseObserver.swift | 115 +++++++----------- 20 files changed, 267 insertions(+), 171 deletions(-) create mode 100644 SessionMessagingKit/Open Groups/Types/OpenGroupAPIError.swift delete mode 100644 SessionMessagingKit/Open Groups/Types/SOGSError.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 19b991c44..7c9176f25 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -742,7 +742,7 @@ FDC290A927D9B46D005DAE71 /* NimbleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */; }; FDC290AA27D9B6FD005DAE71 /* Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A527D860CE005DAE71 /* Mock.swift */; }; FDC290B327DFF9F5005DAE71 /* TestOnionRequestAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290B227DFF9F5005DAE71 /* TestOnionRequestAPI.swift */; }; - FDC4380927B31D4E00C60D73 /* SOGSError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4380827B31D4E00C60D73 /* SOGSError.swift */; }; + FDC4380927B31D4E00C60D73 /* OpenGroupAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4380827B31D4E00C60D73 /* OpenGroupAPIError.swift */; }; FDC4381527B329CE00C60D73 /* NonceGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381427B329CE00C60D73 /* NonceGenerator.swift */; }; FDC4381727B32EC700C60D73 /* Personalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381627B32EC700C60D73 /* Personalization.swift */; }; FDC4382027B36ADC00C60D73 /* SOGSEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381F27B36ADC00C60D73 /* SOGSEndpoint.swift */; }; @@ -1778,7 +1778,7 @@ FDC290A527D860CE005DAE71 /* Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mock.swift; sourceTree = ""; }; FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NimbleExtensions.swift; sourceTree = ""; }; FDC290B227DFF9F5005DAE71 /* TestOnionRequestAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestOnionRequestAPI.swift; sourceTree = ""; }; - FDC4380827B31D4E00C60D73 /* SOGSError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSError.swift; sourceTree = ""; }; + FDC4380827B31D4E00C60D73 /* OpenGroupAPIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupAPIError.swift; sourceTree = ""; }; FDC4381427B329CE00C60D73 /* NonceGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonceGenerator.swift; sourceTree = ""; }; FDC4381627B32EC700C60D73 /* Personalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Personalization.swift; sourceTree = ""; }; FDC4381F27B36ADC00C60D73 /* SOGSEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSEndpoint.swift; sourceTree = ""; }; @@ -3756,7 +3756,7 @@ isa = PBXGroup; children = ( FDC4381F27B36ADC00C60D73 /* SOGSEndpoint.swift */, - FDC4380827B31D4E00C60D73 /* SOGSError.swift */, + FDC4380827B31D4E00C60D73 /* OpenGroupAPIError.swift */, FDC4381627B32EC700C60D73 /* Personalization.swift */, FDC4381427B329CE00C60D73 /* NonceGenerator.swift */, FDC438C227BB512200C60D73 /* SodiumProtocols.swift */, @@ -5191,7 +5191,7 @@ FD716E682850318E00C96BF4 /* CallMode.swift in Sources */, FD09799527FE7B8E00936362 /* Interaction.swift in Sources */, FD5C72FF284F0F120029977D /* MessageReceiver+ConfigurationMessages.swift in Sources */, - FDC4380927B31D4E00C60D73 /* SOGSError.swift in Sources */, + FDC4380927B31D4E00C60D73 /* OpenGroupAPIError.swift in Sources */, FDC4382027B36ADC00C60D73 /* SOGSEndpoint.swift in Sources */, FDC438C927BB706500C60D73 /* SendDirectMessageRequest.swift in Sources */, C3A71D1F25589AC30043A11F /* WebSocketResources.pb.swift in Sources */, diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index d6e3aa314..4d44fa286 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -169,6 +169,16 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { columns: Interaction.Columns .allCases .filter { $0 != .wasRead } + ), + PagedData.ObservedChanges( + table: Contact.self, + columns: [.isTrusted], + joinToPagedType: { + let interaction: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() + + return SQL("LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId])") + }() ) ], filterSQL: MessageViewModel.filterSQL(threadId: threadId), @@ -189,7 +199,6 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { ], dataQuery: MessageViewModel.AttachmentInteractionInfo.baseQuery, joinToPagedType: MessageViewModel.AttachmentInteractionInfo.joinToViewModelQuerySQL, - groupPagedType: MessageViewModel.AttachmentInteractionInfo.groupViewModelQuerySQL, associateData: MessageViewModel.AttachmentInteractionInfo.createAssociateDataClosure() ), AssociatedRecord( diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 67c08c973..53e2b98cf 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -659,7 +659,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve } let conversationVC: ConversationVC = ConversationVC(threadId: threadId, threadVariant: variant, focusedInteractionId: focusedInteractionId) - self.navigationController?.setViewControllers([ self, conversationVC ], animated: true) + self.navigationController?.setViewControllers([ self, conversationVC ], animated: animated) } @objc private func openSettings() { diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 9ed1c91ab..f090f7728 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -191,6 +191,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD // Trigger any launch-specific jobs and start the JobRunner JobRunner.appDidFinishLaunching() + /// Setup the UI + /// + /// **Note:** This **MUST** be run before calling `AppReadiness.setAppIsReady()` otherwise if + /// we are launching the app from a push notification the HomeVC won't be setup yet and it won't open the + /// related thread + self.ensureRootViewController(isPreAppReadyCall: true) + // Note that this does much more than set a flag; // it will also run all deferred blocks (including the JobRunner // 'appDidBecomeActive' method) @@ -220,9 +227,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD } } } - - // Setup the UI - self.ensureRootViewController() } private func showFailedMigrationAlert() { @@ -321,8 +325,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD } } - private func ensureRootViewController() { - guard AppReadiness.isAppReady() && GRDBStorage.shared.isValid && !hasInitialRootViewController else { + private func ensureRootViewController(isPreAppReadyCall: Bool = false) { + guard (AppReadiness.isAppReady() || isPreAppReadyCall) && GRDBStorage.shared.isValid && !hasInitialRootViewController else { return } @@ -334,6 +338,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD ) ) UIViewController.attemptRotationToDeviceOrientation() + + /// **Note:** There is an annoying case when starting the app by interacting with a push notification where + /// the `HomeVC` won't have completed loading it's view which means the `SessionApp.homeViewController` + /// won't have been set - we set the value directly here to resolve this edge case + if let homeViewController: HomeVC = (self.window?.rootViewController as? UINavigationController)?.viewControllers.first as? HomeVC { + SessionApp.homeViewController.mutate { $0 = homeViewController } + } } // MARK: - Notifications diff --git a/Session/Notifications/AppNotifications.swift b/Session/Notifications/AppNotifications.swift index 92330e838..3670cc668 100644 --- a/Session/Notifications/AppNotifications.swift +++ b/Session/Notifications/AppNotifications.swift @@ -491,7 +491,7 @@ class NotificationActionHandler { // If this happens when the the app is not, visible we skip the animation so the thread // can be visible to the user immediately upon opening the app, rather than having to watch // it animate in from the homescreen. - let shouldAnimate = UIApplication.shared.applicationState == .active + let shouldAnimate: Bool = (UIApplication.shared.applicationState == .active) SessionApp.presentConversation(for: threadId, animated: shouldAnimate) return Promise.value(()) } diff --git a/Session/Utilities/BackgroundPoller.swift b/Session/Utilities/BackgroundPoller.swift index dfc1af817..e09328840 100644 --- a/Session/Utilities/BackgroundPoller.swift +++ b/Session/Utilities/BackgroundPoller.swift @@ -36,7 +36,7 @@ public final class BackgroundPoller: NSObject { let poller: OpenGroupAPI.Poller = OpenGroupAPI.Poller(for: server) poller.stop() - return poller.poll(isBackgroundPoll: true) + return poller.poll(isBackgroundPoll: true, isPostCapabilitiesRetry: false) } ) diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index a0ad07650..0ef03a903 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -42,6 +42,7 @@ enum _003_YDBToGRDBMigration: Migration { var closedGroupModel: [String: SMKLegacy._GroupModel] = [:] var closedGroupZombieMemberIds: [String: Set] = [:] + var openGroupServer: [String: String] = [:] var openGroupInfo: [String: SMKLegacy._OpenGroup] = [:] var openGroupUserCount: [String: Int64] = [:] var openGroupImage: [String: Data] = [:] @@ -171,10 +172,25 @@ enum _003_YDBToGRDBMigration: Migration { return } + // We want to migrate everyone over to using the domain name for open group + // servers rather than the IP, also best to use HTTPS over HTTP where possible + // so catch the case where we have the domain with HTTP (the 'defaultServer' + // value contains a HTTPS scheme so we get IP HTTP -> HTTPS for free as well) + let processedOpenGroupServer: String = { + // Check if the server is a Session-run one based on it's + guard + openGroup.server.contains(OpenGroupAPI.legacyDefaultServerIP) || + openGroup.server == OpenGroupAPI.defaultServer + .replacingOccurrences(of: "https://", with: "http://") + else { return openGroup.server } + + return OpenGroupAPI.defaultServer + }() legacyThreadIdToIdMap[thread.uniqueId] = OpenGroup.idFor( roomToken: openGroup.room, - server: openGroup.server + server: processedOpenGroupServer ) + openGroupServer[thread.uniqueId] = processedOpenGroupServer openGroupInfo[thread.uniqueId] = openGroup openGroupUserCount[thread.uniqueId] = ((transaction.object(forKey: openGroup.id, inCollection: SMKLegacy.openGroupUserCountCollection) as? Int64) ?? 0) openGroupImage[thread.uniqueId] = transaction.object(forKey: openGroup.id, inCollection: SMKLegacy.openGroupImageCollection) as? Data @@ -641,13 +657,16 @@ enum _003_YDBToGRDBMigration: Migration { // Open Groups if legacyThread.isOpenGroup { - guard let openGroup: SMKLegacy._OpenGroup = openGroupInfo[legacyThread.uniqueId] else { + guard + let openGroup: SMKLegacy._OpenGroup = openGroupInfo[legacyThread.uniqueId], + let targetOpenGroupServer: String = openGroupServer[legacyThread.uniqueId] + else { SNLog("[Migration Error] Open group missing required data") throw StorageError.migrationFailed } try OpenGroup( - server: openGroup.server, + server: targetOpenGroupServer, roomToken: openGroup.room, publicKey: openGroup.publicKey, isActive: true, diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index 5cfa41638..bfc099aaf 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -11,8 +11,8 @@ import SessionUtilitiesKit public enum OpenGroupAPI { // MARK: - Settings - public static let legacyDefaultServerDNS = "open.getsession.org" - public static let defaultServer = "http://116.203.70.33" + public static let legacyDefaultServerIP = "116.203.70.33" + public static let defaultServer = "https://open.getsession.org" public static let defaultServerPublicKey = "a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238" public static let workQueue = DispatchQueue(label: "OpenGroupAPI.workQueue", qos: .userInitiated) // It's important that this is a serial queue @@ -225,6 +225,7 @@ public enum OpenGroupAPI { public static func capabilities( _ db: Database, server: String, + authenticated: Bool = true, using dependencies: SMKDependencies = SMKDependencies() ) -> Promise<(OnionRequestResponseInfoType, Capabilities)> { return OpenGroupAPI @@ -234,6 +235,7 @@ public enum OpenGroupAPI { server: server, endpoint: .capabilities ), + authenticated: authenticated, using: dependencies ) .decoded(as: Capabilities.self, on: OpenGroupAPI.workQueue, using: dependencies) @@ -394,7 +396,7 @@ public enum OpenGroupAPI { using dependencies: SMKDependencies = SMKDependencies() ) -> Promise<(OnionRequestResponseInfoType, Message)> { guard let signResult: (publicKey: String, signature: Bytes) = sign(db, messageBytes: plaintext.bytes, for: server, fallbackSigningType: .standard, using: dependencies) else { - return Promise(error: Error.signingFailed) + return Promise(error: OpenGroupAPIError.signingFailed) } return OpenGroupAPI @@ -450,7 +452,7 @@ public enum OpenGroupAPI { using dependencies: SMKDependencies = SMKDependencies() ) -> Promise<(OnionRequestResponseInfoType, Data?)> { guard let signResult: (publicKey: String, signature: Bytes) = sign(db, messageBytes: plaintext.bytes, for: server, fallbackSigningType: .standard, using: dependencies) else { - return Promise(error: Error.signingFailed) + return Promise(error: OpenGroupAPIError.signingFailed) } return OpenGroupAPI @@ -1223,7 +1225,7 @@ public enum OpenGroupAPI { .asRequest(of: String.self) .fetchOne(db) - guard let publicKey: String = maybePublicKey else { return Promise(error: Error.noPublicKey) } + guard let publicKey: String = maybePublicKey else { return Promise(error: OpenGroupAPIError.noPublicKey) } // If we don't want to authenticate the request then send it immediately guard authenticated else { @@ -1232,7 +1234,7 @@ public enum OpenGroupAPI { // Attempt to sign the request with the new auth guard let signedRequest: URLRequest = sign(db, request: urlRequest, for: request.server, with: publicKey, using: dependencies) else { - return Promise(error: Error.signingFailed) + return Promise(error: OpenGroupAPIError.signingFailed) } return dependencies.onionApi.sendOnionRequest(signedRequest, to: request.server, with: publicKey) diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 602159fcf..94a9d099b 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -113,7 +113,7 @@ public final class OpenGroupManager: NSObject { let serverHost: String = (serverUrl.host ?? server.lowercased()) let serverPort: String = (serverUrl.port.map { ":\($0)" } ?? "") - let defaultServerHost: String = OpenGroupAPI.defaultServer.substring(from: "http://".count) + let defaultServerHost: String = OpenGroupAPI.defaultServer.substring(from: "https://".count) var serverOptions: Set = Set([ server.lowercased(), "\(serverHost)\(serverPort)", @@ -121,21 +121,15 @@ public final class OpenGroupManager: NSObject { "https://\(serverHost)\(serverPort)" ]) - if serverHost == OpenGroupAPI.legacyDefaultServerDNS { - let defaultServerOptions: Set = Set([ - defaultServerHost, - OpenGroupAPI.defaultServer, - "https://\(defaultServerHost)" - ]) - serverOptions = serverOptions.union(defaultServerOptions) + if serverHost == OpenGroupAPI.legacyDefaultServerIP { + serverOptions.insert(defaultServerHost) + serverOptions.insert("http://\(defaultServerHost)") + serverOptions.insert(OpenGroupAPI.defaultServer) } else if serverHost == defaultServerHost { - let legacyServerOptions: Set = Set([ - OpenGroupAPI.legacyDefaultServerDNS, - "http://\(OpenGroupAPI.legacyDefaultServerDNS)", - "https://\(OpenGroupAPI.legacyDefaultServerDNS)" - ]) - serverOptions = serverOptions.union(legacyServerOptions) + serverOptions.insert(OpenGroupAPI.legacyDefaultServerIP) + serverOptions.insert("http://\(OpenGroupAPI.legacyDefaultServerIP)") + serverOptions.insert("https://\(OpenGroupAPI.legacyDefaultServerIP)") } // First check if there is no poller for the specified server @@ -352,7 +346,7 @@ public final class OpenGroupManager: NSObject { nil ), (openGroup.imageId != pollInfo.details?.imageId.map { "\($0)" } ? - (pollInfo.details?.imageId).map { OpenGroup.Columns.roomDescription.set(to: "\($0)") } : + (pollInfo.details?.imageId).map { OpenGroup.Columns.imageId.set(to: "\($0)") } : nil ), (openGroup.userCount != pollInfo.activeUsers ? diff --git a/SessionMessagingKit/Open Groups/Types/OpenGroupAPIError.swift b/SessionMessagingKit/Open Groups/Types/OpenGroupAPIError.swift new file mode 100644 index 000000000..b09b90d61 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Types/OpenGroupAPIError.swift @@ -0,0 +1,17 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum OpenGroupAPIError: LocalizedError { + case decryptionFailed + case signingFailed + case noPublicKey + + public var errorDescription: String? { + switch self { + case .decryptionFailed: return "Couldn't decrypt response." + case .signingFailed: return "Couldn't sign message." + case .noPublicKey: return "Couldn't find server public key." + } + } +} diff --git a/SessionMessagingKit/Open Groups/Types/SOGSError.swift b/SessionMessagingKit/Open Groups/Types/SOGSError.swift deleted file mode 100644 index 2d1b7e660..000000000 --- a/SessionMessagingKit/Open Groups/Types/SOGSError.swift +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -extension OpenGroupAPI { - public enum Error: LocalizedError { - case decryptionFailed - case signingFailed - case noPublicKey - - public var errorDescription: String? { - switch self { - case .decryptionFailed: return "Couldn't decrypt response." - case .signingFailed: return "Couldn't sign message." - case .noPublicKey: return "Couldn't find server public key." - } - } - } -} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/UpdateRegistrationResponse.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/UpdateRegistrationResponse.swift index 7d7cb788e..aaf4ff484 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/Models/UpdateRegistrationResponse.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/UpdateRegistrationResponse.swift @@ -4,8 +4,12 @@ import Foundation extension PushNotificationAPI { struct UpdateRegistrationResponse: Codable { - let body: String - let code: Int - let message: String? + struct Body: Codable { + let code: Int + let message: String? + } + + let status: Int + let body: Body } } diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift index 0ab01849c..8aca7d16e 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift @@ -60,8 +60,8 @@ public final class PushNotificationAPI : NSObject { guard let response: UpdateRegistrationResponse = try? response?.decoded(as: UpdateRegistrationResponse.self) else { return SNLog("Couldn't unregister from push notifications.") } - guard response.code != 0 else { - return SNLog("Couldn't unregister from push notifications due to error: \(response.message ?? "nil").") + guard response.body.code != 0 else { + return SNLog("Couldn't unregister from push notifications due to error: \(response.body.message ?? "nil").") } } } @@ -119,8 +119,8 @@ public final class PushNotificationAPI : NSObject { guard let response: UpdateRegistrationResponse = try? response?.decoded(as: UpdateRegistrationResponse.self) else { return SNLog("Couldn't register device token.") } - guard response.code != 0 else { - return SNLog("Couldn't register device token due to error: \(response.message ?? "nil").") + guard response.body.code != 0 else { + return SNLog("Couldn't register device token due to error: \(response.body.message ?? "nil").") } userDefaults[.deviceToken] = hexEncodedToken @@ -180,8 +180,8 @@ public final class PushNotificationAPI : NSObject { guard let response: UpdateRegistrationResponse = try? response?.decoded(as: UpdateRegistrationResponse.self) else { return SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey).") } - guard response.code != 0 else { - return SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey) due to error: \(response.message ?? "nil").") + guard response.body.code != 0 else { + return SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey) due to error: \(response.body.message ?? "nil").") } } } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift index 40f256b49..6ebedae5e 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift @@ -43,11 +43,15 @@ extension OpenGroupAPI { @discardableResult public func poll(using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies()) -> Promise { - return poll(isBackgroundPoll: false, using: dependencies) + return poll(isBackgroundPoll: false, isPostCapabilitiesRetry: false, using: dependencies) } @discardableResult - public func poll(isBackgroundPoll: Bool, using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies()) -> Promise { + public func poll( + isBackgroundPoll: Bool, + isPostCapabilitiesRetry: Bool, + using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies() + ) -> Promise { guard !self.isPolling else { return Promise.value(()) } self.isPolling = true @@ -83,15 +87,93 @@ extension OpenGroupAPI { seal.fulfill(()) } .catch(on: OpenGroupAPI.workQueue) { [weak self] error in - SNLog("Open group polling failed due to error: \(error).") - self?.isPolling = false - seal.fulfill(()) // The promise is just used to keep track of when we're done + // If we are retrying then the error is being handled so no need to continue (this + // method will always resolve) + self?.updateCapabilitiesAndRetryIfNeeded( + server: server, + isBackgroundPoll: isBackgroundPoll, + isPostCapabilitiesRetry: isPostCapabilitiesRetry, + error: error + ) + .done(on: OpenGroupAPI.workQueue) { [weak self] didHandleError in + if !didHandleError { + SNLog("Open group polling failed due to error: \(error).") + } + + self?.isPolling = false + seal.fulfill(()) // The promise is just used to keep track of when we're done + } + .retainUntilComplete() } } return promise } + private func updateCapabilitiesAndRetryIfNeeded( + server: String, + isBackgroundPoll: Bool, + isPostCapabilitiesRetry: Bool, + error: Error, + using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies() + ) -> Promise { + /// We want to custom handle a '400' error code due to not having blinded auth as it likely means that we join the + /// OpenGroup before blinding was enabled and need to update it's capabilities + /// + /// **Note:** To prevent an infinite loop caused by a server-side bug we want to prevent this capabilities request from + /// happening multiple times in a row + guard + !isPostCapabilitiesRetry, + let error: OnionRequestAPIError = error as? OnionRequestAPIError, + case .httpRequestFailedAtDestination(let statusCode, let data, _) = error, + statusCode == 400, + let dataString: String = String(data: data, encoding: .utf8), + dataString.contains("Invalid authentication: this server requires the use of blinded idse") + else { return Promise.value(false) } + + let (promise, seal) = Promise.pending() + + dependencies.storage + .read { db in + OpenGroupAPI.capabilities( + db, + server: server, + authenticated: false, + using: dependencies + ) + } + .then(on: OpenGroupAPI.workQueue) { [weak self] _, responseBody -> Promise in + guard let strongSelf = self else { return Promise.value(()) } + + // Handle the updated capabilities and re-trigger the poll + strongSelf.isPolling = false + + dependencies.storage.write { db in + OpenGroupManager.handleCapabilities( + db, + capabilities: responseBody, + on: server + ) + } + + // Regardless of the outcome we can just resolve this + // immediately as it'll handle it's own response + return strongSelf.poll( + isBackgroundPoll: isBackgroundPoll, + isPostCapabilitiesRetry: true, + using: dependencies + ) + .ensure { seal.fulfill(true) } + } + .catch(on: OpenGroupAPI.workQueue) { error in + SNLog("Open group updating capabilities failed due to error: \(error).") + seal.fulfill(true) + } + .retainUntilComplete() + + return promise + } + private func handlePollResponse(_ response: [OpenGroupAPI.Endpoint: (info: OnionRequestResponseInfoType, data: Codable?)], isBackgroundPoll: Bool, using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies()) { let server: String = self.server diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index 67f37827a..2756a29a7 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -715,18 +715,11 @@ public extension MessageViewModel.AttachmentInteractionInfo { let interactionAttachment: TypedTableAlias = TypedTableAlias() return """ - JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id]) - JOIN \(Interaction.self) ON - \(interaction[.id]) = \(interactionAttachment[.interactionId]) + JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.interactionId]) = \(interaction[.id]) + JOIN \(Attachment.self) ON \(attachment[.id]) = \(interactionAttachment[.attachmentId]) """ }() - static var groupViewModelQuerySQL: SQL = { - let interaction: TypedTableAlias = TypedTableAlias() - - return "\(interaction[.id])" - }() - static func createAssociateDataClosure() -> (DataCache, DataCache) -> DataCache { return { dataCache, pagedDataCache -> DataCache in var updatedPagedDataCache: DataCache = pagedDataCache @@ -786,7 +779,7 @@ public extension MessageViewModel.TypingIndicatorInfo { let threadTypingIndicator: TypedTableAlias = TypedTableAlias() return """ - JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(threadTypingIndicator[.threadId]) + JOIN \(ThreadTypingIndicator.self) ON \(threadTypingIndicator[.threadId]) = \(interaction[.threadId]) """ }() diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift index 3a2250073..a6321c9b5 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift @@ -1364,7 +1364,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + equal(OpenGroupAPIError.signingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -1399,7 +1399,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + equal(OpenGroupAPIError.signingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -1432,7 +1432,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + equal(OpenGroupAPIError.signingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -1512,7 +1512,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + equal(OpenGroupAPIError.signingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -1547,7 +1547,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + equal(OpenGroupAPIError.signingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -1588,7 +1588,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + equal(OpenGroupAPIError.signingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -1772,7 +1772,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + equal(OpenGroupAPIError.signingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -1806,7 +1806,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + equal(OpenGroupAPIError.signingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -1838,7 +1838,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + equal(OpenGroupAPIError.signingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -1916,7 +1916,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + equal(OpenGroupAPIError.signingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -1950,7 +1950,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + equal(OpenGroupAPIError.signingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -1990,7 +1990,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + equal(OpenGroupAPIError.signingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -2915,7 +2915,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + equal(OpenGroupAPIError.signingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -2942,7 +2942,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPI.Error.noPublicKey.localizedDescription), + equal(OpenGroupAPIError.noPublicKey.localizedDescription), timeout: .milliseconds(100) ) @@ -2969,7 +2969,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + equal(OpenGroupAPIError.signingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -3037,7 +3037,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + equal(OpenGroupAPIError.signingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -3108,7 +3108,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + equal(OpenGroupAPIError.signingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -3135,7 +3135,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription) .toEventually( - equal(OpenGroupAPI.Error.signingFailed.localizedDescription), + equal(OpenGroupAPIError.signingFailed.localizedDescription), timeout: .milliseconds(100) ) diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift index 16b203ffa..2f82fbaa0 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -1578,6 +1578,7 @@ class OpenGroupManagerSpec: QuickSpec { isActive: true, name: "Test", imageId: "12", + imageData: Data([1, 2, 3]), userCount: 0, infoUpdates: 10 ).insert(db) @@ -3004,7 +3005,7 @@ class OpenGroupManagerSpec: QuickSpec { .asRequest(of: String.self) .fetchOne(db) } - ).to(equal("http://116.203.70.33")) + ).to(equal("https://open.getsession.org")) expect( mockStorage.read { db in try OpenGroup diff --git a/SessionMessagingKitTests/Open Groups/Types/SOGSErrorSpec.swift b/SessionMessagingKitTests/Open Groups/Types/SOGSErrorSpec.swift index f637692b5..cba429cee 100644 --- a/SessionMessagingKitTests/Open Groups/Types/SOGSErrorSpec.swift +++ b/SessionMessagingKitTests/Open Groups/Types/SOGSErrorSpec.swift @@ -13,11 +13,11 @@ class SOGSErrorSpec: QuickSpec { override func spec() { describe("a SOGSError") { it("generates the error description correctly") { - expect(OpenGroupAPI.Error.decryptionFailed.errorDescription) + expect(OpenGroupAPIError.decryptionFailed.errorDescription) .to(equal("Couldn't decrypt response.")) - expect(OpenGroupAPI.Error.signingFailed.errorDescription) + expect(OpenGroupAPIError.signingFailed.errorDescription) .to(equal("Couldn't sign message.")) - expect(OpenGroupAPI.Error.noPublicKey.errorDescription) + expect(OpenGroupAPIError.noPublicKey.errorDescription) .to(equal("Couldn't find server public key.")) } } diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index 75119f337..5d831d2d6 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -299,8 +299,12 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension private func pollForOpenGroups() -> [Promise] { let promises: [Promise] = GRDBStorage.shared .read { db in + // The default room promise creates an OpenGroup with an empty `roomToken` value, + // we don't want to start a poller for this as the user hasn't actually joined a room try OpenGroup .select(.server) + .filter(OpenGroup.Columns.roomToken != "") + .filter(OpenGroup.Columns.isActive) .distinct() .asRequest(of: String.self) .fetchSet(db) @@ -308,7 +312,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension .defaulting(to: []) .map { server in OpenGroupAPI.Poller(for: server) - .poll(isBackgroundPoll: true) + .poll(isBackgroundPoll: true, isPostCapabilitiesRetry: false) .timeout( seconds: 20, timeoutError: NotificationServiceError.timeout diff --git a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift index 1dee7feed..d011ca025 100644 --- a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift +++ b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift @@ -60,6 +60,7 @@ public class PagedDatabaseObserver: TransactionObserver where self.orderSQL = orderSQL self.dataQuery = dataQuery self.associatedRecords = associatedRecords + .map { $0.settingPagedTableName(pagedTableName: pagedTable.databaseTableName) } self.onChangeUnsorted = onChangeUnsorted // Combine the various observed changes into a single set @@ -141,6 +142,7 @@ public class PagedDatabaseObserver: TransactionObserver where let hasChanges: Bool = associatedRecord.tryUpdateForDatabaseCommit( db, changes: committedChanges, + joinSQL: joinSQL, orderSQL: orderSQL, filterSQL: filterSQL, pageInfo: updatedPageInfo @@ -616,13 +618,15 @@ public protocol FetchableRecordWithRowId: FetchableRecord { public protocol ErasedAssociatedRecord { var databaseTableName: String { get } + var pagedTableName: String { get } var observedChanges: [PagedData.ObservedChanges] { get } var joinToPagedType: SQL { get } - var groupPagedType: SQL? { get } + func settingPagedTableName(pagedTableName: String) -> Self func tryUpdateForDatabaseCommit( _ db: Database, changes: Set, + joinSQL: SQL?, orderSQL: SQL, filterSQL: SQL, pageInfo: PagedData.PageInfo @@ -886,45 +890,11 @@ public enum PagedData { tableName: String, requiredJoinSQL: SQL? = nil, orderSQL: SQL, - filterSQL: SQL, - joinToPagedType: SQL? = nil, - groupPagedType: SQL? = nil + filterSQL: SQL ) -> [Int64] { guard !rowIds.isEmpty else { return [] } let tableNameLiteral: SQL = SQL(stringLiteral: tableName) - - /// **Note:** `ROW_NUMBER` works by returning the index of the row in a given query, unfortunately when dealing - /// with associated data its possible for multiple results to connect to an individual paged result, this throws off the - /// indexes so in this case we need to do some sneaky aggregation and grouping and then individually retrieve each - /// index to prevent this - guard joinToPagedType == nil || rowIds.count == 1 else { - guard let groupPagedType: SQL = groupPagedType else { return [] } - - let groupByLiteral: SQL = SQL(stringLiteral: "GROUP BY ") - - return rowIds.compactMap { rowId in - let groupedRequest: SQLRequest = """ - SELECT - (data.rowIndex - 1) AS rowIndex -- Converting from 1-Indexed to 0-indexed - FROM ( - SELECT - \(tableNameLiteral).rowid AS rowid, - \(SQL("MAX(\(tableNameLiteral).rowid = \(rowId))")), - ROW_NUMBER() OVER (ORDER BY \(orderSQL)) AS rowIndex - FROM \(tableNameLiteral) - \(requiredJoinSQL ?? "") - \(joinToPagedType ?? "") - WHERE \(filterSQL) - \(groupByLiteral)\(groupPagedType) - ) AS data - WHERE \(SQL("data.rowid = \(rowId)")) - """ - - return try? groupedRequest.fetchOne(db) - } - } - let request: SQLRequest = """ SELECT (data.rowIndex - 1) AS rowIndex -- Converting from 1-Indexed to 0-indexed @@ -934,7 +904,6 @@ public enum PagedData { ROW_NUMBER() OVER (ORDER BY \(orderSQL)) AS rowIndex FROM \(tableNameLiteral) \(requiredJoinSQL ?? "") - \(joinToPagedType ?? "") WHERE \(filterSQL) ) AS data WHERE \(SQL("data.rowid IN \(rowIds)")) @@ -958,7 +927,7 @@ public enum PagedData { let pagedTableNameLiteral: SQL = SQL(stringLiteral: pagedTableName) let request: SQLRequest = """ SELECT \(tableNameLiteral).rowid AS rowid - FROM \(tableNameLiteral) + FROM \(pagedTableNameLiteral) \(joinToPagedType) WHERE \(pagedTableNameLiteral).rowId IN \(pagedTypeRowIds) """ @@ -995,9 +964,9 @@ public enum PagedData { public class AssociatedRecord: ErasedAssociatedRecord where T: FetchableRecordWithRowId & Identifiable, PagedType: FetchableRecordWithRowId & Identifiable { public let databaseTableName: String + public private(set) var pagedTableName: String = "" public let observedChanges: [PagedData.ObservedChanges] public let joinToPagedType: SQL - public let groupPagedType: SQL? fileprivate let dataCache: Atomic> = Atomic(DataCache()) fileprivate let dataQuery: (SQL?) -> AdaptedFetchRequest> @@ -1010,14 +979,12 @@ public class AssociatedRecord: ErasedAssociatedRecord where T: Fet observedChanges: [PagedData.ObservedChanges], dataQuery: @escaping (SQL?) -> AdaptedFetchRequest>, joinToPagedType: SQL, - groupPagedType: SQL? = nil, associateData: @escaping (DataCache, DataCache) -> DataCache ) { self.databaseTableName = trackedAgainst.databaseTableName self.observedChanges = observedChanges self.dataQuery = dataQuery self.joinToPagedType = joinToPagedType - self.groupPagedType = groupPagedType self.associateData = associateData } @@ -1026,7 +993,6 @@ public class AssociatedRecord: ErasedAssociatedRecord where T: Fet observedChanges: [PagedData.ObservedChanges], dataQuery: @escaping (SQL?) -> SQLRequest, joinToPagedType: SQL, - groupPagedType: SQL? = nil, associateData: @escaping (DataCache, DataCache) -> DataCache ) { self.init( @@ -1036,16 +1002,21 @@ public class AssociatedRecord: ErasedAssociatedRecord where T: Fet dataQuery(additionalFilters).adapted { _ in ScopeAdapter([:]) } }, joinToPagedType: joinToPagedType, - groupPagedType: groupPagedType, associateData: associateData ) } // MARK: - AssociatedRecord + public func settingPagedTableName(pagedTableName: String) -> Self { + self.pagedTableName = pagedTableName + return self + } + public func tryUpdateForDatabaseCommit( _ db: Database, changes: Set, + joinSQL: SQL?, orderSQL: SQL, filterSQL: SQL, pageInfo: PagedData.PageInfo @@ -1075,44 +1046,52 @@ public class AssociatedRecord: ErasedAssociatedRecord where T: Fet guard !rowIdsToQuery.isEmpty else { return (oldCount != countAfterDeletions) } // Fetch the indexes of the rowIds so we can determine whether they should be added to the screen - let itemIndexes: [Int64] = PagedData.indexes( + let pagedRowIds: [Int64] = PagedData.pagedRowIdsForRelatedRowIds( db, - rowIds: rowIdsToQuery, tableName: databaseTableName, - orderSQL: orderSQL, - filterSQL: filterSQL, - joinToPagedType: joinToPagedType, - groupPagedType: groupPagedType + pagedTableName: pagedTableName, + relatedRowIds: rowIdsToQuery, + joinToPagedType: joinToPagedType ) - // Determine if the indexes for the row ids should be displayed on the screen and remove any - // which shouldn't - values less than 'currentCount' or if there is at least one value less than - // 'currentCount' and the indexes are sequential (ie. more than the current loaded content was - // added at once) - let uniqueIndexes: [Int64] = itemIndexes.asSet().sorted() - let itemIndexesAreSequential: Bool = (uniqueIndexes.map { $0 - 1 }.dropFirst() == uniqueIndexes.dropLast()) - let hasOneValidIndex: Bool = itemIndexes.contains(where: { index -> Bool in + // If the associated data change isn't related to the paged type then no need to continue + guard !pagedRowIds.isEmpty else { return (oldCount != countAfterDeletions) } + + let pagedItemIndexes: [Int64] = PagedData.indexes( + db, + rowIds: pagedRowIds, + tableName: pagedTableName, + requiredJoinSQL: joinSQL, + orderSQL: orderSQL, + filterSQL: filterSQL + ) + + // If we can't get the item indexes for the paged row ids then it's likely related to data + // which was filtered out (eg. message attachment related to a different thread) + guard !pagedItemIndexes.isEmpty else { return (oldCount != countAfterDeletions) } + + /// **Note:** The `PagedData.indexes` works by returning the index of a row in a given query, unfortunately when + /// dealing with associated data its possible for multiple associated data values to connect to an individual paged result, + /// this throws off the indexes so we can't actually tell what `rowIdsToQuery` value is associated to which + /// `pagedItemIndexes` value + /// + /// Instead of following the pattern the `PagedDatabaseObserver` does where we get the proper `validRowIds` we + /// basically have to check if there is a single valid index, and if so retrieve and store all data related to the changes for this + /// commit - this will mean in some cases we cache data which is actually unrelated to the filtered paged data + let hasOneValidIndex: Bool = pagedItemIndexes.contains(where: { index -> Bool in index >= pageInfo.pageOffset && ( index < pageInfo.currentCount || pageInfo.currentCount == 0 ) }) - let validRowIds: [Int64] = (itemIndexesAreSequential && hasOneValidIndex ? - rowIdsToQuery : - zip(itemIndexes, rowIdsToQuery) - .filter { index, _ -> Bool in - index >= pageInfo.pageOffset && ( - index < pageInfo.currentCount || - pageInfo.currentCount == 0 - ) - } - .map { _, rowId -> Int64 in rowId } - ) + + // Don't bother continuing if we don't have a valid index + guard hasOneValidIndex else { return (oldCount != countAfterDeletions) } // Attempt to update the cache with the `validRowIds` array return updateCache( db, - rowIds: validRowIds, + rowIds: rowIdsToQuery, hasOtherChanges: (oldCount != countAfterDeletions) ) } From c7e8071dd12be9997a6f945a66c5e2a9935b817a Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 29 Jun 2022 18:10:10 +1000 Subject: [PATCH 120/157] Fixed a large number of bugs and added a setting to control open group message deletion Added a setting to control whether open group messages older than 6 months should be pruned Added some defensive coding to prevent an edge-case which could cause a crash (wasn't filtering out a potential invalid row from the home screen data) Fixed a bug where preOffer call messages weren't correctly sending push notifications Fixed a bug where all incoming calls would be rejected and seen as calls disabled Fixed a bug where the copy on call info messages was displaying the sender's name instead of the thread contact's name for outgoing calls Fixed a bug where the input view wouldn't appear when creating new DM conversations Fixed a bug where threads might not show the message request approval UI Fixed an issue where some logic might not have run correctly when first registering an account Fixed a bug where the note to self thread could incorrectly appear when restoring a device Updated the GarbageCollectionJob to run onActive instead of onLaunch (since it's likely we will rarely launch) Updated the logic for erasing an account from a device --- Session.xcodeproj/project.pbxproj | 8 +-- .../ConversationVC+Interaction.swift | 3 +- Session/Conversations/ConversationVC.swift | 10 ++- Session/Home/HomeVC.swift | 1 + Session/Home/HomeViewModel.swift | 1 + Session/Meta/AppDelegate.swift | 36 +--------- Session/Meta/SessionApp.swift | 1 + .../Translations/de.lproj/Localizable.strings | 4 ++ .../Translations/en.lproj/Localizable.strings | 4 ++ .../Translations/es.lproj/Localizable.strings | 4 ++ .../Translations/fa.lproj/Localizable.strings | 4 ++ .../Translations/fi.lproj/Localizable.strings | 4 ++ .../Translations/fr.lproj/Localizable.strings | 4 ++ .../Translations/hi.lproj/Localizable.strings | 4 ++ .../Translations/hr.lproj/Localizable.strings | 4 ++ .../id-ID.lproj/Localizable.strings | 4 ++ .../Translations/it.lproj/Localizable.strings | 4 ++ .../Translations/ja.lproj/Localizable.strings | 4 ++ .../Translations/nl.lproj/Localizable.strings | 4 ++ .../Translations/pl.lproj/Localizable.strings | 4 ++ .../pt_BR.lproj/Localizable.strings | 4 ++ .../Translations/ru.lproj/Localizable.strings | 4 ++ .../Translations/si.lproj/Localizable.strings | 4 ++ .../Translations/sk.lproj/Localizable.strings | 4 ++ .../Translations/sv.lproj/Localizable.strings | 4 ++ .../Translations/th.lproj/Localizable.strings | 4 ++ .../vi-VN.lproj/Localizable.strings | 4 ++ .../zh-Hant.lproj/Localizable.strings | 4 ++ .../zh_CN.lproj/Localizable.strings | 4 ++ .../Settings/ChatSettingsViewController.swift | 63 +++++++++++++++++ Session/Settings/NukeDataModal.swift | 41 +++++++++-- Session/Settings/SettingsVC.swift | 7 ++ Session/Shared/FullConversationCell.swift | 1 + SessionMessagingKit/Calls/WebRTCSession.swift | 1 + .../Migrations/_002_SetupStandardJobs.swift | 2 +- .../Database/Models/Attachment.swift | 10 ++- .../Database/Models/BlindedIdLookup.swift | 19 +++-- .../Database/Models/Interaction.swift | 3 +- .../Jobs/Types/GarbageCollectionJob.swift | 2 +- .../Control Messages/CallMessage.swift | 8 +-- .../Open Groups/OpenGroupManager.swift | 4 +- ...essageReceiver+ConfigurationMessages.swift | 14 ++++ .../Sending & Receiving/MessageSender.swift | 16 +++-- .../Shared Models/MessageViewModel.swift | 25 ++++++- .../SessionThreadViewModel.swift | 36 ++++++++-- .../Utilities/Preferences.swift | 3 + .../NotificationServiceExtension.swift | 2 +- .../Database/GRDBStorage.swift | 16 ++--- .../Types/PagedDatabaseObserver.swift | 70 +++++++++++-------- .../Utilities/GRDB+Notifications.swift | 11 --- .../Utilities/Notification+Loki.swift | 4 -- 51 files changed, 372 insertions(+), 134 deletions(-) create mode 100644 Session/Settings/ChatSettingsViewController.swift delete mode 100644 SessionUtilitiesKit/Database/Utilities/GRDB+Notifications.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 7c9176f25..95da2432b 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -607,7 +607,6 @@ FD17D7B327F51E5B00122BE0 /* SSKSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7B227F51E5B00122BE0 /* SSKSetting.swift */; }; FD17D7B827F51ECA00122BE0 /* Migration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7B727F51ECA00122BE0 /* Migration.swift */; }; FD17D7BA27F51F2100122BE0 /* TargetMigrations.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7B927F51F2100122BE0 /* TargetMigrations.swift */; }; - FD17D7BD27F51F6900122BE0 /* GRDB+Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7BC27F51F6900122BE0 /* GRDB+Notifications.swift */; }; FD17D7BF27F51F8200122BE0 /* ColumnExpressible.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7BE27F51F8200122BE0 /* ColumnExpressible.swift */; }; FD17D7C127F5200100122BE0 /* TypedTableDefinition.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7C027F5200100122BE0 /* TypedTableDefinition.swift */; }; FD17D7C327F5204C00122BE0 /* Database+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7C227F5204C00122BE0 /* Database+Utilities.swift */; }; @@ -785,6 +784,7 @@ FDD2506E283711D600198BDA /* DifferenceKit+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */; }; FDD250702837199200198BDA /* GarbageCollectionJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD2506F2837199200198BDA /* GarbageCollectionJob.swift */; }; FDD250722837234B00198BDA /* MediaGalleryNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD250712837234B00198BDA /* MediaGalleryNavigationController.swift */; }; + FDE72118286C156E0093DF33 /* ChatSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE72117286C156E0093DF33 /* ChatSettingsViewController.swift */; }; FDE77F6B280FEB28002CFC5D /* ControlMessageProcessRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */; }; FDED2E3C282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDED2E3B282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift */; }; FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */; }; @@ -1671,7 +1671,6 @@ FD17D7B227F51E5B00122BE0 /* SSKSetting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSKSetting.swift; sourceTree = ""; }; FD17D7B727F51ECA00122BE0 /* Migration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Migration.swift; sourceTree = ""; }; FD17D7B927F51F2100122BE0 /* TargetMigrations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TargetMigrations.swift; sourceTree = ""; }; - FD17D7BC27F51F6900122BE0 /* GRDB+Notifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GRDB+Notifications.swift"; sourceTree = ""; }; FD17D7BE27F51F8200122BE0 /* ColumnExpressible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColumnExpressible.swift; sourceTree = ""; }; FD17D7C027F5200100122BE0 /* TypedTableDefinition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypedTableDefinition.swift; sourceTree = ""; }; FD17D7C227F5204C00122BE0 /* Database+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Database+Utilities.swift"; sourceTree = ""; }; @@ -1819,6 +1818,7 @@ FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DifferenceKit+Utilities.swift"; sourceTree = ""; }; FDD2506F2837199200198BDA /* GarbageCollectionJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarbageCollectionJob.swift; sourceTree = ""; }; FDD250712837234B00198BDA /* MediaGalleryNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGalleryNavigationController.swift; sourceTree = ""; }; + FDE72117286C156E0093DF33 /* ChatSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatSettingsViewController.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 = ""; }; FDED2E3B282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UICollectionView+ReusableView.swift"; sourceTree = ""; }; @@ -2798,6 +2798,7 @@ B886B4A62398B23E00211ABE /* QRCodeVC.swift */, B86BD08523399CEF000F5AE3 /* SeedModal.swift */, B8CCF6422397711F0091D419 /* SettingsVC.swift */, + FDE72117286C156E0093DF33 /* ChatSettingsViewController.swift */, 7B7CB18A270591630079FF93 /* ShareLogsModal.swift */, ); path = Settings; @@ -3544,7 +3545,6 @@ FD17D7C427F5206300122BE0 /* ColumnDefinition+Utilities.swift */, FD17D7C227F5204C00122BE0 /* Database+Utilities.swift */, FD17D7C627F5207C00122BE0 /* DatabaseMigrator+Utilities.swift */, - FD17D7BC27F51F6900122BE0 /* GRDB+Notifications.swift */, FDF22210281B5E0B000A4995 /* TableRecord+Utilities.swift */, FDF2220E281B55E6000A4995 /* QueryInterfaceRequest+Utilities.swift */, ); @@ -5011,7 +5011,6 @@ FD9004152818B46300ABAAF6 /* JobRunner.swift in Sources */, C3A7211A2558BCA10043A11F /* DiffieHellman.swift in Sources */, C3A7225E2558C38D0043A11F /* Promise+Retaining.swift in Sources */, - FD17D7BD27F51F6900122BE0 /* GRDB+Notifications.swift in Sources */, FD17D7CA27F546D900122BE0 /* _001_InitialSetupMigration.swift in Sources */, C3D9E4D12567777D0040E4F3 /* OWSMediaUtils.swift in Sources */, C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Utilities.swift in Sources */, @@ -5276,6 +5275,7 @@ EF764C351DB67CC5000D9A87 /* UIViewController+Permissions.m in Sources */, 45CD81EF1DC030E7004C9430 /* SyncPushTokensJob.swift in Sources */, B83524A525C3BA4B0089A44F /* InfoMessageCell.swift in Sources */, + FDE72118286C156E0093DF33 /* ChatSettingsViewController.swift in Sources */, B84A89BC25DE328A0040017D /* ProfilePictureVC.swift in Sources */, 34386A54207D271D009F5D9C /* NeverClearView.swift in Sources */, FDCDB8E02811007F00352A0C /* HomeViewModel.swift in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index bb76b60dc..2aea6b825 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -942,7 +942,8 @@ extension ConversationVC: db, blindedId: sessionId, openGroupServer: openGroupServer, - openGroupPublicKey: openGroupPublicKey + openGroupPublicKey: openGroupPublicKey, + isCheckingForOutbox: false ) return try SessionThread diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index c3c767ae2..73038c611 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -571,6 +571,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers } if initialLoad || viewModel.threadData.threadIsMessageRequest != updatedThreadData.threadIsMessageRequest { + messageRequestView.isHidden = (updatedThreadData.threadIsMessageRequest == false) scrollButtonMessageRequestsBottomConstraint?.isActive = (updatedThreadData.threadIsMessageRequest == true) scrollButtonBottomConstraint?.isActive = (updatedThreadData.threadIsMessageRequest == false) } @@ -595,8 +596,13 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers self.viewModel.updateThreadData(updatedThreadData) /// **Note:** This needs to happen **after** we have update the viewModel's thread data - if viewModel.threadData.currentUserIsClosedGroupMember != updatedThreadData.currentUserIsClosedGroupMember { - reloadInputViews() + if initialLoad || viewModel.threadData.currentUserIsClosedGroupMember != updatedThreadData.currentUserIsClosedGroupMember { + if !self.isFirstResponder { + self.becomeFirstResponder() + } + else { + self.reloadInputViews() + } } } diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 53e2b98cf..2c50fa346 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -627,6 +627,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve ) ) ) + try MessageSender.syncConfiguration(db, forceSyncNow: true) .retainUntilComplete() } diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index a1ff37bfd..8a6022005 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -268,6 +268,7 @@ public class HomeViewModel { SectionModel( section: .threads, elements: data + .filter { $0.id != SessionThreadViewModel.invalidId } .sorted { lhs, rhs -> Bool in if lhs.threadIsPinned && !rhs.threadIsPinned { return true } if !lhs.threadIsPinned && rhs.threadIsPinned { return false } diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index f090f7728..045566300 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -102,12 +102,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD name: .registrationStateDidChange, object: nil ) - NotificationCenter.default.addObserver( - self, - selector: #selector(handleDataNukeRequested), // TODO: This differently??? - name: .dataNukeRequested, - object: nil - ) NotificationCenter.default.addObserver( self, selector: #selector(showMissedCallTipsIfNeeded(_:)), @@ -455,35 +449,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD // MARK: - Notification Handling @objc private func registrationStateDidChange() { - enableBackgroundRefreshIfNecessary() - - guard Identity.userExists() else { return } - - startPollersIfNeeded() - } - - @objc public func handleDataNukeRequested() { - let isUsingFullAPNs: Bool = UserDefaults.standard[.isUsingFullAPNs] - let maybeDeviceToken: String? = UserDefaults.standard[.deviceToken] - // TODO: Clean up how this works - if isUsingFullAPNs, let deviceToken: String = maybeDeviceToken { - let data: Data = Data(hex: deviceToken) - PushNotificationAPI.unregister(data).retainUntilComplete() - } - - GRDBStorage.shared.write { db in - _ = try SessionThread.deleteAll(db) - _ = try Identity.deleteAll(db) - } - - SnodeAPI.clearSnodePool() - stopPollers() - - let wasUnlinked: Bool = UserDefaults.standard[.wasUnlinked] - SessionApp.resetAppData { - // Resetting the data clears the old user defaults. We need to restore the unlink default. - UserDefaults.standard[.wasUnlinked] = wasUnlinked - } + handleActivation() } @objc public func showMissedCallTipsIfNeeded(_ notification: Notification) { diff --git a/Session/Meta/SessionApp.swift b/Session/Meta/SessionApp.swift index c223987b2..dadebd048 100644 --- a/Session/Meta/SessionApp.swift +++ b/Session/Meta/SessionApp.swift @@ -63,6 +63,7 @@ public struct SessionApp { GRDBStorage.resetAllStorage() ProfileManager.resetProfileStorage() + Attachment.resetAttachmentStorage() AppEnvironment.shared.notificationPresenter.clearAllNotifications() onReset?() diff --git a/Session/Meta/Translations/de.lproj/Localizable.strings b/Session/Meta/Translations/de.lproj/Localizable.strings index 9eb1c3640..3ebda7272 100644 --- a/Session/Meta/Translations/de.lproj/Localizable.strings +++ b/Session/Meta/Translations/de.lproj/Localizable.strings @@ -656,3 +656,7 @@ "ALERT_ERROR_TITLE" = "Fehler"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"CHATS_TITLE" = "Chats"; +"MESSAGE_TRIMMING_TITLE" = "Message Trimming"; +"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; diff --git a/Session/Meta/Translations/en.lproj/Localizable.strings b/Session/Meta/Translations/en.lproj/Localizable.strings index 60cee62fc..f46ff4cbf 100644 --- a/Session/Meta/Translations/en.lproj/Localizable.strings +++ b/Session/Meta/Translations/en.lproj/Localizable.strings @@ -656,3 +656,7 @@ "ALERT_ERROR_TITLE" = "Error"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"CHATS_TITLE" = "Chats"; +"MESSAGE_TRIMMING_TITLE" = "Message Trimming"; +"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; diff --git a/Session/Meta/Translations/es.lproj/Localizable.strings b/Session/Meta/Translations/es.lproj/Localizable.strings index 9ffdb9505..6c0ce3a4f 100644 --- a/Session/Meta/Translations/es.lproj/Localizable.strings +++ b/Session/Meta/Translations/es.lproj/Localizable.strings @@ -656,3 +656,7 @@ "ALERT_ERROR_TITLE" = "Fallo"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"CHATS_TITLE" = "Chats"; +"MESSAGE_TRIMMING_TITLE" = "Message Trimming"; +"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; diff --git a/Session/Meta/Translations/fa.lproj/Localizable.strings b/Session/Meta/Translations/fa.lproj/Localizable.strings index 0c0b2a806..cf91d20ac 100644 --- a/Session/Meta/Translations/fa.lproj/Localizable.strings +++ b/Session/Meta/Translations/fa.lproj/Localizable.strings @@ -656,3 +656,7 @@ "ALERT_ERROR_TITLE" = "خطاء"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"CHATS_TITLE" = "Chats"; +"MESSAGE_TRIMMING_TITLE" = "Message Trimming"; +"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; diff --git a/Session/Meta/Translations/fi.lproj/Localizable.strings b/Session/Meta/Translations/fi.lproj/Localizable.strings index 057b87804..c30b60393 100644 --- a/Session/Meta/Translations/fi.lproj/Localizable.strings +++ b/Session/Meta/Translations/fi.lproj/Localizable.strings @@ -656,3 +656,7 @@ "ALERT_ERROR_TITLE" = "Error"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"CHATS_TITLE" = "Chats"; +"MESSAGE_TRIMMING_TITLE" = "Message Trimming"; +"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; diff --git a/Session/Meta/Translations/fr.lproj/Localizable.strings b/Session/Meta/Translations/fr.lproj/Localizable.strings index 42a04ad7a..820120d7c 100644 --- a/Session/Meta/Translations/fr.lproj/Localizable.strings +++ b/Session/Meta/Translations/fr.lproj/Localizable.strings @@ -656,3 +656,7 @@ "ALERT_ERROR_TITLE" = "Erreur"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"CHATS_TITLE" = "Chats"; +"MESSAGE_TRIMMING_TITLE" = "Message Trimming"; +"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; diff --git a/Session/Meta/Translations/hi.lproj/Localizable.strings b/Session/Meta/Translations/hi.lproj/Localizable.strings index 6cb8421e2..e862c25c0 100644 --- a/Session/Meta/Translations/hi.lproj/Localizable.strings +++ b/Session/Meta/Translations/hi.lproj/Localizable.strings @@ -656,3 +656,7 @@ "ALERT_ERROR_TITLE" = "Error"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"CHATS_TITLE" = "Chats"; +"MESSAGE_TRIMMING_TITLE" = "Message Trimming"; +"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; diff --git a/Session/Meta/Translations/hr.lproj/Localizable.strings b/Session/Meta/Translations/hr.lproj/Localizable.strings index 1ea6f38fa..e40b5492e 100644 --- a/Session/Meta/Translations/hr.lproj/Localizable.strings +++ b/Session/Meta/Translations/hr.lproj/Localizable.strings @@ -656,3 +656,7 @@ "ALERT_ERROR_TITLE" = "Error"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"CHATS_TITLE" = "Chats"; +"MESSAGE_TRIMMING_TITLE" = "Message Trimming"; +"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; diff --git a/Session/Meta/Translations/id-ID.lproj/Localizable.strings b/Session/Meta/Translations/id-ID.lproj/Localizable.strings index d46d1a539..3ffd04b51 100644 --- a/Session/Meta/Translations/id-ID.lproj/Localizable.strings +++ b/Session/Meta/Translations/id-ID.lproj/Localizable.strings @@ -656,3 +656,7 @@ "ALERT_ERROR_TITLE" = "Galat"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"CHATS_TITLE" = "Chats"; +"MESSAGE_TRIMMING_TITLE" = "Message Trimming"; +"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; diff --git a/Session/Meta/Translations/it.lproj/Localizable.strings b/Session/Meta/Translations/it.lproj/Localizable.strings index d3d10bec1..bbd0183d8 100644 --- a/Session/Meta/Translations/it.lproj/Localizable.strings +++ b/Session/Meta/Translations/it.lproj/Localizable.strings @@ -656,3 +656,7 @@ "ALERT_ERROR_TITLE" = "Errore"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"CHATS_TITLE" = "Chats"; +"MESSAGE_TRIMMING_TITLE" = "Message Trimming"; +"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; diff --git a/Session/Meta/Translations/ja.lproj/Localizable.strings b/Session/Meta/Translations/ja.lproj/Localizable.strings index 2e984b53e..108805053 100644 --- a/Session/Meta/Translations/ja.lproj/Localizable.strings +++ b/Session/Meta/Translations/ja.lproj/Localizable.strings @@ -656,3 +656,7 @@ "ALERT_ERROR_TITLE" = "エラー"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"CHATS_TITLE" = "Chats"; +"MESSAGE_TRIMMING_TITLE" = "Message Trimming"; +"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; diff --git a/Session/Meta/Translations/nl.lproj/Localizable.strings b/Session/Meta/Translations/nl.lproj/Localizable.strings index d577ac53e..6aadc1caf 100644 --- a/Session/Meta/Translations/nl.lproj/Localizable.strings +++ b/Session/Meta/Translations/nl.lproj/Localizable.strings @@ -656,3 +656,7 @@ "ALERT_ERROR_TITLE" = "Error"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"CHATS_TITLE" = "Chats"; +"MESSAGE_TRIMMING_TITLE" = "Message Trimming"; +"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; diff --git a/Session/Meta/Translations/pl.lproj/Localizable.strings b/Session/Meta/Translations/pl.lproj/Localizable.strings index 4b8359749..2e93d89d6 100644 --- a/Session/Meta/Translations/pl.lproj/Localizable.strings +++ b/Session/Meta/Translations/pl.lproj/Localizable.strings @@ -656,3 +656,7 @@ "ALERT_ERROR_TITLE" = "Błąd"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"CHATS_TITLE" = "Chats"; +"MESSAGE_TRIMMING_TITLE" = "Message Trimming"; +"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; diff --git a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings index 38089a102..3a933083a 100644 --- a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings +++ b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings @@ -656,3 +656,7 @@ "ALERT_ERROR_TITLE" = "Erro"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"CHATS_TITLE" = "Chats"; +"MESSAGE_TRIMMING_TITLE" = "Message Trimming"; +"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; diff --git a/Session/Meta/Translations/ru.lproj/Localizable.strings b/Session/Meta/Translations/ru.lproj/Localizable.strings index 8918c45ec..e6f0421e3 100644 --- a/Session/Meta/Translations/ru.lproj/Localizable.strings +++ b/Session/Meta/Translations/ru.lproj/Localizable.strings @@ -656,3 +656,7 @@ "ALERT_ERROR_TITLE" = "Ошибка"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"CHATS_TITLE" = "Chats"; +"MESSAGE_TRIMMING_TITLE" = "Message Trimming"; +"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; diff --git a/Session/Meta/Translations/si.lproj/Localizable.strings b/Session/Meta/Translations/si.lproj/Localizable.strings index 92bdd53c6..04d1ea3d2 100644 --- a/Session/Meta/Translations/si.lproj/Localizable.strings +++ b/Session/Meta/Translations/si.lproj/Localizable.strings @@ -656,3 +656,7 @@ "ALERT_ERROR_TITLE" = "Error"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"CHATS_TITLE" = "Chats"; +"MESSAGE_TRIMMING_TITLE" = "Message Trimming"; +"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; diff --git a/Session/Meta/Translations/sk.lproj/Localizable.strings b/Session/Meta/Translations/sk.lproj/Localizable.strings index 9341ea3a5..d2ed3836a 100644 --- a/Session/Meta/Translations/sk.lproj/Localizable.strings +++ b/Session/Meta/Translations/sk.lproj/Localizable.strings @@ -656,3 +656,7 @@ "ALERT_ERROR_TITLE" = "Error"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"CHATS_TITLE" = "Chats"; +"MESSAGE_TRIMMING_TITLE" = "Message Trimming"; +"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; diff --git a/Session/Meta/Translations/sv.lproj/Localizable.strings b/Session/Meta/Translations/sv.lproj/Localizable.strings index 2fa616d8c..617b930c5 100644 --- a/Session/Meta/Translations/sv.lproj/Localizable.strings +++ b/Session/Meta/Translations/sv.lproj/Localizable.strings @@ -656,3 +656,7 @@ "ALERT_ERROR_TITLE" = "Error"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"CHATS_TITLE" = "Chats"; +"MESSAGE_TRIMMING_TITLE" = "Message Trimming"; +"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; diff --git a/Session/Meta/Translations/th.lproj/Localizable.strings b/Session/Meta/Translations/th.lproj/Localizable.strings index c51a61b79..5832a403b 100644 --- a/Session/Meta/Translations/th.lproj/Localizable.strings +++ b/Session/Meta/Translations/th.lproj/Localizable.strings @@ -656,3 +656,7 @@ "ALERT_ERROR_TITLE" = "Error"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"CHATS_TITLE" = "Chats"; +"MESSAGE_TRIMMING_TITLE" = "Message Trimming"; +"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; diff --git a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings index 185293321..5835cc170 100644 --- a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings +++ b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings @@ -656,3 +656,7 @@ "ALERT_ERROR_TITLE" = "Error"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"CHATS_TITLE" = "Chats"; +"MESSAGE_TRIMMING_TITLE" = "Message Trimming"; +"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; diff --git a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings index f5cb6a74a..333724265 100644 --- a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings @@ -656,3 +656,7 @@ "ALERT_ERROR_TITLE" = "Error"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"CHATS_TITLE" = "Chats"; +"MESSAGE_TRIMMING_TITLE" = "Message Trimming"; +"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; diff --git a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings index 62f75a4ec..637d27c67 100644 --- a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings @@ -656,3 +656,7 @@ "ALERT_ERROR_TITLE" = "错误"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"CHATS_TITLE" = "Chats"; +"MESSAGE_TRIMMING_TITLE" = "Message Trimming"; +"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; diff --git a/Session/Settings/ChatSettingsViewController.swift b/Session/Settings/ChatSettingsViewController.swift new file mode 100644 index 000000000..1e013aea6 --- /dev/null +++ b/Session/Settings/ChatSettingsViewController.swift @@ -0,0 +1,63 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SessionUIKit +import SignalUtilitiesKit + +// FIXME: Refactor to be MVVM and use database observation +class ChatSettingsViewController: OWSTableViewController { + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + self.updateTableContents() + + ViewControllerUtilities.setUpDefaultSessionStyle(for: self, title: "CHATS_TITLE".localized(), hasCustomBackButton: false) + + let closeButton: UIBarButtonItem = UIBarButtonItem(image: UIImage(named: "X"), style: .plain, target: self, action: #selector(close(_:))) + self.navigationItem.leftBarButtonItem = closeButton + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + self.updateTableContents() + } + + // MARK: - Table Contents + + func updateTableContents() { + let updatedContents: OWSTableContents = OWSTableContents() + + let messageTrimming: OWSTableSection = OWSTableSection() + messageTrimming.headerTitle = "MESSAGE_TRIMMING_TITLE".localized() + messageTrimming.footerTitle = "MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION".localized() + messageTrimming.add(OWSTableItem.switch( + withText: "MESSAGE_TRIMMING_OPEN_GROUP_TITLE".localized(), + isOn: { GRDBStorage.shared[.trimOpenGroupMessagesOlderThanSixMonths] }, + target: self, + selector: #selector(didToggleTrimOpenGroupsSwitch(_:)) + )) + updatedContents.addSection(messageTrimming) + + self.contents = updatedContents + } + + // MARK: - Actions + + @objc private func didToggleTrimOpenGroupsSwitch(_ sender: UISwitch) { + GRDBStorage.shared.writeAsync( + updates: { db in + db[.trimOpenGroupMessagesOlderThanSixMonths] = !sender.isOn + }, + completion: { [weak self] _, _ in + self?.updateTableContents() + } + ) + } + + @objc private func close(_ sender: UIBarButtonItem) { + self.navigationController?.dismiss(animated: true) + } +} diff --git a/Session/Settings/NukeDataModal.swift b/Session/Settings/NukeDataModal.swift index 06a35c38c..823c7f474 100644 --- a/Session/Settings/NukeDataModal.swift +++ b/Session/Settings/NukeDataModal.swift @@ -157,11 +157,8 @@ final class NukeDataModal: Modal { GRDBStorage.shared .writeAsync { db in try MessageSender.syncConfiguration(db, forceSyncNow: true) } .ensure(on: DispatchQueue.main) { + self?.deleteAllLocalData() self?.dismiss(animated: true, completion: nil) // Dismiss the loader - - UserDefaults.removeAll() // Not done in the nuke data implementation as unlinking requires this to happen later - General.cache.mutate { $0.encodedPublicKey = nil } // Remove the cached key so it gets re-cached on next access - NotificationCenter.default.post(name: .dataNukeRequested, object: nil) } .retainUntilComplete() } @@ -177,9 +174,7 @@ final class NukeDataModal: Modal { let potentiallyMaliciousSnodes = confirmations.compactMap { $0.value == false ? $0.key : nil } if potentiallyMaliciousSnodes.isEmpty { - General.cache.mutate { $0.encodedPublicKey = nil } // Remove the cached key so it gets re-cached on next access - UserDefaults.removeAll() // Not done in the nuke data implementation as unlinking requires this to happen later - NotificationCenter.default.post(name: .dataNukeRequested, object: nil) + self?.deleteAllLocalData() } else { let message: String @@ -205,4 +200,36 @@ final class NukeDataModal: Modal { } } } + + private func deleteAllLocalData() { + // Unregister push notifications if needed + let isUsingFullAPNs: Bool = UserDefaults.standard[.isUsingFullAPNs] + let maybeDeviceToken: String? = UserDefaults.standard[.deviceToken] + + if isUsingFullAPNs, let deviceToken: String = maybeDeviceToken { + let data: Data = Data(hex: deviceToken) + PushNotificationAPI.unregister(data).retainUntilComplete() + } + + // Clear out the user defaults + UserDefaults.removeAll() + + // Remove the cached key so it gets re-cached on next access + General.cache.mutate { $0.encodedPublicKey = nil } + + // Clear the Snode pool + SnodeAPI.clearSnodePool() + + // Stop any pollers + (UIApplication.shared.delegate as? AppDelegate)?.stopPollers() + + // Call through to the SessionApp's "resetAppData" which will wipe out logs, database and + // profile storage + let wasUnlinked: Bool = UserDefaults.standard[.wasUnlinked] + + SessionApp.resetAppData { + // Resetting the data clears the old user defaults. We need to restore the unlink default. + UserDefaults.standard[.wasUnlinked] = wasUnlinked + } + } } diff --git a/Session/Settings/SettingsVC.swift b/Session/Settings/SettingsVC.swift index 2cd3a4799..56e8b4500 100644 --- a/Session/Settings/SettingsVC.swift +++ b/Session/Settings/SettingsVC.swift @@ -322,6 +322,8 @@ final class SettingsVC: BaseVC, AvatarViewHelperDelegate { getSeparator(), getSettingButton(withTitle: NSLocalizedString("MESSAGE_REQUESTS_TITLE", comment: ""), color: Colors.text, action: #selector(showMessageRequests)), getSeparator(), + getSettingButton(withTitle: NSLocalizedString("CHATS_TITLE", comment: ""), color: Colors.text, action: #selector(showChatSettings)), + getSeparator(), getSettingButton(withTitle: NSLocalizedString("vc_settings_recovery_phrase_button_title", comment: ""), color: Colors.text, action: #selector(showSeed)), getSeparator(), getSettingButton(withTitle: NSLocalizedString("vc_settings_clear_all_data_button_title", comment: ""), color: Colors.destructive, action: #selector(clearAllData)), @@ -629,6 +631,11 @@ final class SettingsVC: BaseVC, AvatarViewHelperDelegate { self.navigationController?.pushViewController(viewController, animated: true) } + @objc private func showChatSettings() { + let chatSettingsVC = ChatSettingsViewController() + navigationController!.pushViewController(chatSettingsVC, animated: true) + } + @objc private func showSeed() { let seedModal = SeedModal() seedModal.modalPresentationStyle = .overFullScreen diff --git a/Session/Shared/FullConversationCell.swift b/Session/Shared/FullConversationCell.swift index fa9973805..e8beb16b1 100644 --- a/Session/Shared/FullConversationCell.swift +++ b/Session/Shared/FullConversationCell.swift @@ -433,6 +433,7 @@ public final class FullConversationCell: UITableViewCell { in: Interaction.previewText( variant: (cellViewModel.interactionVariant ?? .standardIncoming), body: cellViewModel.interactionBody, + threadContactDisplayName: cellViewModel.threadContactName(), authorDisplayName: cellViewModel.authorName(for: cellViewModel.threadVariant), attachmentDescriptionInfo: cellViewModel.interactionAttachmentDescriptionInfo, attachmentCount: cellViewModel.interactionAttachmentCount, diff --git a/SessionMessagingKit/Calls/WebRTCSession.swift b/SessionMessagingKit/Calls/WebRTCSession.swift index 20345393c..3f82bda0c 100644 --- a/SessionMessagingKit/Calls/WebRTCSession.swift +++ b/SessionMessagingKit/Calls/WebRTCSession.swift @@ -169,6 +169,7 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { return seal.reject(error) } } + GRDBStorage.shared .writeAsync { db in try MessageSender diff --git a/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift index 616038fde..ac82f040a 100644 --- a/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -48,7 +48,7 @@ enum _002_SetupStandardJobs: Migration { _ = try Job( variant: .garbageCollection, - behaviour: .recurringOnLaunch, + behaviour: .recurringOnActive, details: GarbageCollectionJob.Details( typesToCollect: GarbageCollectionJob.Types.allCases ) diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index 7b69b5e19..c04ea6aef 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -263,6 +263,7 @@ extension Attachment: CustomStringConvertible { // We only support multi-attachment sending of images so we can just default to the image attachment // if there were multiple attachments guard count == 1 else { return "\(emoji(for: OWSMimeTypeImageJpeg)) \("ATTACHMENT".localized())" } + if MIMETypeUtil.isAudio(descriptionInfo.contentType) { // a missing filename is the legacy way to determine if an audio attachment is // a voice note vs. other arbitrary audio attachments. @@ -583,12 +584,9 @@ extension Attachment { return attachmentsFolder }() - private static var thumbnailsFolder: String = { - let attachmentsFolder: String = sharedDataAttachmentsDirPath - OWSFileSystem.ensureDirectoryExists(attachmentsFolder) - - return attachmentsFolder - }() + public static func resetAttachmentStorage() { + try? FileManager.default.removeItem(atPath: Attachment.sharedDataAttachmentsDirPath) + } public static func originalFilePath(id: String, mimeType: String, sourceFilename: String?) -> String? { return MIMETypeUtil.filePath( diff --git a/SessionMessagingKit/Database/Models/BlindedIdLookup.swift b/SessionMessagingKit/Database/Models/BlindedIdLookup.swift index 79d033931..6d63624ae 100644 --- a/SessionMessagingKit/Database/Models/BlindedIdLookup.swift +++ b/SessionMessagingKit/Database/Models/BlindedIdLookup.swift @@ -74,6 +74,7 @@ public extension BlindedIdLookup { blindedId: String, openGroupServer: String, openGroupPublicKey: String, + isCheckingForOutbox: Bool, dependencies: SMKDependencies = SMKDependencies() ) throws -> BlindedIdLookup { var lookup: BlindedIdLookup = (try? BlindedIdLookup @@ -92,11 +93,11 @@ public extension BlindedIdLookup { // We now need to try to match the blinded id to an existing contact, this can only be done by looping // through all approved contacts and generating a blinded id for the provided open group for each to // see if it matches the provided blindedId - let approvedContactCursor: RecordCursor = try Contact - .filter(Contact.Columns.isApproved == true) + let contactsThatApprovedMeCursor: RecordCursor = try Contact + .filter(Contact.Columns.didApproveMe == true) .fetchCursor(db) - - while let contact: Contact = try approvedContactCursor.next() { + + while let contact: Contact = try contactsThatApprovedMeCursor.next() { guard dependencies.sodium.sessionId(contact.id, matchesBlindedId: blindedId, serverPublicKey: openGroupPublicKey, genericHash: dependencies.genericHash) else { continue } @@ -105,6 +106,16 @@ public extension BlindedIdLookup { lookup = try lookup .with(sessionId: contact.id) .saved(db) + + // There is an edge-case where the contact might not have their 'isApproved' flag set to true + // but if we have a `BlindedIdLookup` for them and are performing the lookup from the outbox + // then that means we sent them a message request and the 'isApproved' flag should be true + if isCheckingForOutbox && !contact.isApproved { + try Contact + .filter(id: contact.id) + .updateAll(db, Contact.Columns.isApproved.set(to: true)) + } + break } diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index d082e108d..238816699 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -690,6 +690,7 @@ public extension Interaction { static func previewText( variant: Variant, body: String?, + threadContactDisplayName: String = "", authorDisplayName: String = "", attachmentDescriptionInfo: Attachment.DescriptionInfo? = nil, attachmentCount: Int? = nil, @@ -764,7 +765,7 @@ public extension Interaction { ) else { return (body ?? "") } - return messageInfo.previewText(authorDisplayName: authorDisplayName) + return messageInfo.previewText(threadContactDisplayName: threadContactDisplayName) } } diff --git a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift index 42c8de672..78225cf0c 100644 --- a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift +++ b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift @@ -52,7 +52,7 @@ public enum GarbageCollectionJob: JobExecutor { } /// Remove any old open group messages - open group messages which are older than six months - if details.typesToCollect.contains(.oldOpenGroupMessages) { + if details.typesToCollect.contains(.oldOpenGroupMessages) && db[.trimOpenGroupMessagesOlderThanSixMonths] { let interaction: TypedTableAlias = TypedTableAlias() let thread: TypedTableAlias = TypedTableAlias() diff --git a/SessionMessagingKit/Messages/Control Messages/CallMessage.swift b/SessionMessagingKit/Messages/Control Messages/CallMessage.swift index e53aa06fc..533f771ba 100644 --- a/SessionMessagingKit/Messages/Control Messages/CallMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/CallMessage.swift @@ -235,24 +235,24 @@ public extension CallMessage { // MARK: - Content - func previewText(authorDisplayName: String) -> String { + func previewText(threadContactDisplayName: String) -> String { switch state { case .incoming: return String( format: "call_incoming".localized(), - authorDisplayName + threadContactDisplayName ) case .outgoing: return String( format: "call_outgoing".localized(), - authorDisplayName + threadContactDisplayName ) case .missed, .permissionDenied: return String( format: "call_missed".localized(), - authorDisplayName + threadContactDisplayName ) // TODO: We should do better here diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 94a9d099b..57da8bfd3 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -582,7 +582,9 @@ public final class OpenGroupManager: NSObject { db, blindedId: message.recipient, openGroupServer: server.lowercased(), - openGroupPublicKey: openGroup.publicKey + openGroupPublicKey: openGroup.publicKey, + isCheckingForOutbox: true, + dependencies: dependencies ) }() let syncTarget: String = (lookup.sessionId ?? message.recipient) diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift index 9fc4b1ce5..1259720ae 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift @@ -51,6 +51,20 @@ extension MessageReceiver { try message.contacts.forEach { contactInfo in guard let sessionId: String = contactInfo.publicKey else { return } + // If the contact is a blinded contact then only add them if they haven't already been + // unblinded + if SessionId.Prefix(from: sessionId) == .blinded { + let hasUnblindedContact: Bool = (try? BlindedIdLookup + .filter(BlindedIdLookup.Columns.blindedId == sessionId) + .filter(BlindedIdLookup.Columns.sessionId != nil) + .isNotEmpty(db)) + .defaulting(to: false) + + if hasUnblindedContact { + return + } + } + // Note: We only update the contact and profile records if the data has actually changed // in order to avoid triggering UI updates for every thread on the home screen let contact: Contact = Contact.fetchOrCreate(db, id: sessionId) diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index a1c045c46..f470754c0 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -233,10 +233,18 @@ public final class MessageSender { isSyncMessage: isSyncMessage ) - let shouldNotify = ( - (message is VisibleMessage || message is UnsendRequest) && - !isSyncMessage - ) + let shouldNotify: Bool = { + switch message { + case is VisibleMessage, is UnsendRequest: return !isSyncMessage + case let callMessage as CallMessage: + switch callMessage.kind { + case .preOffer: return true + default: return false + } + + default: return false + } + }() /* if let closedGroupControlMessage = message as? ClosedGroupControlMessage, case .new = closedGroupControlMessage.kind { diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index 2756a29a7..363e98069 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -10,11 +10,13 @@ fileprivate typealias AttachmentInteractionInfo = MessageViewModel.AttachmentInt fileprivate typealias TypingIndicatorInfo = MessageViewModel.TypingIndicatorInfo public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, Hashable, Identifiable, Differentiable { + public static let threadIdKey: SQL = SQL(stringLiteral: CodingKeys.threadId.stringValue) public static let threadVariantKey: SQL = SQL(stringLiteral: CodingKeys.threadVariant.stringValue) public static let threadIsTrustedKey: SQL = SQL(stringLiteral: CodingKeys.threadIsTrusted.stringValue) public static let threadHasDisappearingMessagesEnabledKey: SQL = SQL(stringLiteral: CodingKeys.threadHasDisappearingMessagesEnabled.stringValue) public static let threadOpenGroupServerKey: SQL = SQL(stringLiteral: CodingKeys.threadOpenGroupServer.stringValue) public static let threadOpenGroupPublicKeyKey: SQL = SQL(stringLiteral: CodingKeys.threadOpenGroupPublicKey.stringValue) + public static let threadContactNameInternalKey: SQL = SQL(stringLiteral: CodingKeys.threadContactNameInternal.stringValue) public static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue) public static let authorNameInternalKey: SQL = SQL(stringLiteral: CodingKeys.authorNameInternal.stringValue) public static let stateKey: SQL = SQL(stringLiteral: CodingKeys.state.stringValue) @@ -58,11 +60,13 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, // Thread Info + public let threadId: String public let threadVariant: SessionThread.Variant public let threadIsTrusted: Bool public let threadHasDisappearingMessagesEnabled: Bool public let threadOpenGroupServer: String? public let threadOpenGroupPublicKey: String? + private let threadContactNameInternal: String? // Interaction Info @@ -133,11 +137,13 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, public func with(attachments: [Attachment]) -> MessageViewModel { return MessageViewModel( + threadId: self.threadId, threadVariant: self.threadVariant, threadIsTrusted: self.threadIsTrusted, threadHasDisappearingMessagesEnabled: self.threadHasDisappearingMessagesEnabled, threadOpenGroupServer: self.threadOpenGroupServer, threadOpenGroupPublicKey: self.threadOpenGroupPublicKey, + threadContactNameInternal: self.threadContactNameInternal, rowId: self.rowId, id: self.id, variant: self.variant, @@ -281,11 +287,13 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, }() return ViewModel( + threadId: self.threadId, threadVariant: self.threadVariant, threadIsTrusted: self.threadIsTrusted, threadHasDisappearingMessagesEnabled: self.threadHasDisappearingMessagesEnabled, threadOpenGroupServer: self.threadOpenGroupServer, threadOpenGroupPublicKey: self.threadOpenGroupPublicKey, + threadContactNameInternal: self.threadContactNameInternal, rowId: self.rowId, id: self.id, variant: self.variant, @@ -298,6 +306,12 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, Interaction.previewText( variant: self.variant, body: self.body, + threadContactDisplayName: Profile.displayName( + for: self.threadVariant, + id: self.threadId, + name: self.threadContactNameInternal, + nickname: nil // Folded into 'threadContactNameInternal' within the Query + ), authorDisplayName: authorDisplayName, attachmentDescriptionInfo: self.attachments?.first.map { firstAttachment in Attachment.DescriptionInfo( @@ -428,11 +442,13 @@ public extension MessageViewModel { // Note: This init method is only used system-created cells or empty states init(isTypingIndicator: Bool? = nil) { + self.threadId = "INVALID_THREAD_ID" self.threadVariant = .contact self.threadIsTrusted = false self.threadHasDisappearingMessagesEnabled = false self.threadOpenGroupServer = nil self.threadOpenGroupPublicKey = nil + self.threadContactNameInternal = nil // Interaction Info @@ -552,6 +568,10 @@ public extension MessageViewModel { let quote: TypedTableAlias = TypedTableAlias() let linkPreview: TypedTableAlias = TypedTableAlias() + let threadProfileTableLiteral: SQL = SQL(stringLiteral: "threadProfile") + let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) + let profileNicknameColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.nickname.name) + let profileNameColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.name.name) let interactionStateInteractionIdColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.interactionId.name) let readReceiptTableLiteral: SQL = SQL(stringLiteral: "readReceipt") let readReceiptReadTimestampMsColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.readTimestampMs.name) @@ -561,9 +581,10 @@ public extension MessageViewModel { let groupMemberProfileIdColumnLiteral: SQL = SQL(stringLiteral: GroupMember.Columns.profileId.name) let groupMemberRoleColumnLiteral: SQL = SQL(stringLiteral: GroupMember.Columns.role.name) - let numColumnsBeforeLinkedRecords: Int = 18 + let numColumnsBeforeLinkedRecords: Int = 20 let request: SQLRequest = """ SELECT + \(thread[.id]) AS \(ViewModel.threadIdKey), \(thread[.variant]) AS \(ViewModel.threadVariantKey), -- Default to 'true' for non-contact threads IFNULL(\(contact[.isTrusted]), true) AS \(ViewModel.threadIsTrustedKey), @@ -571,6 +592,7 @@ public extension MessageViewModel { IFNULL(\(disappearingMessagesConfig[.isEnabled]), false) AS \(ViewModel.threadHasDisappearingMessagesEnabledKey), \(openGroup[.server]) AS \(ViewModel.threadOpenGroupServerKey), \(openGroup[.publicKey]) AS \(ViewModel.threadOpenGroupPublicKeyKey), + IFNULL(\(threadProfileTableLiteral).\(profileNicknameColumnLiteral), \(threadProfileTableLiteral).\(profileNameColumnLiteral)) AS \(ViewModel.threadContactNameInternalKey), \(interaction.alias[Column.rowID]) AS \(ViewModel.rowIdKey), \(interaction[.id]), @@ -610,6 +632,7 @@ public extension MessageViewModel { FROM \(Interaction.self) JOIN \(SessionThread.self) ON \(thread[.id]) = \(interaction[.threadId]) LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId]) + LEFT JOIN \(Profile.self) AS \(threadProfileTableLiteral) ON \(threadProfileTableLiteral).\(profileIdColumnLiteral) = \(interaction[.threadId]) LEFT JOIN \(DisappearingMessagesConfiguration.self) ON \(disappearingMessagesConfig[.threadId]) = \(interaction[.threadId]) LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(interaction[.threadId]) LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId]) diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index d4f7083d3..e9132b51d 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -52,6 +52,7 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat public static let interactionIsOpenGroupInvitationKey: SQL = SQL(stringLiteral: CodingKeys.interactionIsOpenGroupInvitation.stringValue) public static let interactionAttachmentDescriptionInfoKey: SQL = SQL(stringLiteral: CodingKeys.interactionAttachmentDescriptionInfo.stringValue) public static let interactionAttachmentCountKey: SQL = SQL(stringLiteral: CodingKeys.interactionAttachmentCount.stringValue) + public static let threadContactNameInternalKey: SQL = SQL(stringLiteral: CodingKeys.threadContactNameInternal.stringValue) public static let authorNameInternalKey: SQL = SQL(stringLiteral: CodingKeys.authorNameInternal.stringValue) public static let currentUserPublicKeyKey: SQL = SQL(stringLiteral: CodingKeys.currentUserPublicKey.stringValue) @@ -75,11 +76,15 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat public let threadMemberNames: String? public let threadIsNoteToSelf: Bool - public var threadIsMessageRequest: Bool? + + /// This flag indicates whether the thread is an outgoing message request + public let threadIsMessageRequest: Bool? + + /// This flag indicates whether the thread is an incoming message request public let threadRequiresApproval: Bool? public let threadShouldBeVisible: Bool? public let threadIsPinned: Bool - public var threadIsBlocked: Bool? + public let threadIsBlocked: Bool? public let threadMutedUntilTimestamp: TimeInterval? public let threadOnlyNotifyForMentions: Bool? public let threadMessageDraft: String? @@ -116,6 +121,7 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat public let interactionAttachmentCount: Int? public let authorId: String? + private let threadContactNameInternal: String? private let authorNameInternal: String? public let currentUserPublicKey: String @@ -172,6 +178,21 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat } } + /// This function returns the thread contact profile name formatted for the specific type of thread provided + /// + /// **Note:** The 'threadVariant' parameter is used for profile context but in the search results we actually want this + /// to always behave as the `contact` variant which is why this needs to be a function instead of just using the provided + /// parameter + public func threadContactName() -> String { + return Profile.displayName( + for: .contact, + id: threadId, + name: threadContactNameInternal, + nickname: nil, // Folded into 'threadContactNameInternal' within the Query + customFallback: "Anonymous" + ) + } + /// This function returns the profile name formatted for the specific type of thread provided /// /// **Note:** The 'threadVariant' parameter is used for profile context but in the search results we actually want this @@ -251,6 +272,7 @@ public extension SessionThreadViewModel { self.interactionAttachmentCount = nil self.authorId = nil + self.threadContactNameInternal = nil self.authorNameInternal = nil self.currentUserPublicKey = getUserHexEncodedPublicKey() } @@ -282,6 +304,8 @@ public extension SessionThreadViewModel { let profile: TypedTableAlias = TypedTableAlias() let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) + let profileNicknameColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.nickname.name) + let profileNameColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.name.name) let firstInteractionAttachmentLiteral: SQL = SQL(stringLiteral: "firstInteractionAttachment") let interactionAttachmentAttachmentIdColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.attachmentId.name) let interactionAttachmentInteractionIdColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.interactionId.name) @@ -338,6 +362,7 @@ public extension SessionThreadViewModel { COUNT(\(interactionAttachment[.interactionId])) AS \(ViewModel.interactionAttachmentCountKey), \(interaction[.authorId]), + IFNULL(\(ViewModel.contactProfileKey).\(profileNicknameColumnLiteral), \(ViewModel.contactProfileKey).\(profileNameColumnLiteral)) AS \(ViewModel.threadContactNameInternalKey), IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.authorNameInternalKey), \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) @@ -549,11 +574,8 @@ public extension SessionThreadViewModel { ( \(thread[.shouldBeVisible]) = true AND \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND - \(SQL("\(thread[.id]) != \(userPublicKey)")) AND ( - -- A '!= true' check doesn't work properly so we need to be explicit - \(contact[.isApproved]) IS NULL OR - \(contact[.isApproved]) = false - ) + \(SQL("\(thread[.id]) != \(userPublicKey)")) AND + IFNULL(\(contact[.isApproved]), false) = false ) AS \(ViewModel.threadIsMessageRequestKey), ( \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND ( diff --git a/SessionMessagingKit/Utilities/Preferences.swift b/SessionMessagingKit/Utilities/Preferences.swift index 243efec10..414417396 100644 --- a/SessionMessagingKit/Utilities/Preferences.swift +++ b/SessionMessagingKit/Utilities/Preferences.swift @@ -41,6 +41,9 @@ public extension Setting.BoolKey { /// Controls whether Calls are enabled static let areCallsEnabled: Setting.BoolKey = "areCallsEnabled" + /// Controls whether open group messages older than 6 months should be deleted + static let trimOpenGroupMessagesOlderThanSixMonths: Setting.BoolKey = "trimOpenGroupMessagesOlderThanSixMonths" + /// Controls whether the message requests item has been hidden on the home screen static let hasHiddenMessageRequests: Setting.BoolKey = "hasHiddenMessageRequests" diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index 5d831d2d6..66cee426b 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -104,7 +104,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension guard case .preOffer = callMessage.kind else { return self.completeSilenty() } - if db[.areCallsEnabled] { + if !db[.areCallsEnabled] { if let sender: String = callMessage.sender, let interaction: Interaction = try MessageReceiver.insertCallInfoMessage(db, for: callMessage, state: .permissionDenied) { let thread: SessionThread = try SessionThread.fetchOrCreate(db, id: sender, variant: .contact) diff --git a/SessionUtilitiesKit/Database/GRDBStorage.swift b/SessionUtilitiesKit/Database/GRDBStorage.swift index 3d59ac1e6..ddda89bef 100644 --- a/SessionUtilitiesKit/Database/GRDBStorage.swift +++ b/SessionUtilitiesKit/Database/GRDBStorage.swift @@ -280,18 +280,16 @@ public final class GRDBStorage { // MARK: - File Management public static func resetAllStorage() { - NotificationCenter.default.post(name: .resetStorage, object: nil) + // Just in case they haven't been removed for some reason, delete the legacy database & keys + SUKLegacy.clearLegacyDatabaseInstance() + try? SUKLegacy.deleteLegacyDatabaseFilesAndKey() + + GRDBStorage.shared.isValid = false + GRDBStorage.shared.hasCompletedMigrations = false + GRDBStorage.shared.dbWriter = nil - // This might be redundant but in the spirit of thoroughness... self.deleteDatabaseFiles() - try? self.deleteDbKeys() - - if CurrentAppContext().isMainApp { -// TSAttachmentStream.deleteAttachments() - } - - // TODO: Delete Profiles on Disk? } public/*private*/ static func deleteDatabaseFiles() { diff --git a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift index d011ca025..561bb409e 100644 --- a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift +++ b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift @@ -254,7 +254,7 @@ public class PagedDatabaseObserver: TransactionObserver where } // Fetch the indexes of the rowIds so we can determine whether they should be added to the screen - let itemIndexes: [Int64] = PagedData.indexes( + let itemIndexes: [PagedData.RowIndexInfo] = PagedData.indexes( db, rowIds: changesToQuery.map { $0.rowId }, tableName: pagedTableName, @@ -262,7 +262,7 @@ public class PagedDatabaseObserver: TransactionObserver where orderSQL: orderSQL, filterSQL: filterSQL ) - let relatedChangeIndexes: [Int64] = PagedData.indexes( + let relatedChangeIndexes: [PagedData.RowIndexInfo] = PagedData.indexes( db, rowIds: Array(pagedRowIdsForRelatedChanges), tableName: pagedTableName, @@ -275,36 +275,34 @@ public class PagedDatabaseObserver: TransactionObserver where // which shouldn't - values less than 'currentCount' or if there is at least one value less than // 'currentCount' and the indexes are sequential (ie. more than the current loaded content was // added at once) - func determineValidChanges(for indexes: [Int64], with data: [T]) -> [T] { + func determineValidChanges(for indexInfo: [PagedData.RowIndexInfo]) -> [Int64] { + let indexes: [Int64] = Array(indexInfo + .map { $0.rowIndex } + .sorted() + .asSet()) let indexesAreSequential: Bool = (indexes.map { $0 - 1 }.dropFirst() == indexes.dropLast()) - let hasOneValidIndex: Bool = indexes.contains(where: { index -> Bool in - index >= updatedPageInfo.pageOffset && ( - index < updatedPageInfo.currentCount || + let hasOneValidIndex: Bool = indexInfo.contains(where: { info -> Bool in + info.rowIndex >= updatedPageInfo.pageOffset && ( + info.rowIndex < updatedPageInfo.currentCount || updatedPageInfo.currentCount == 0 ) }) return (indexesAreSequential && hasOneValidIndex ? - data : - zip(indexes, data) - .filter { index, _ -> Bool in - index >= updatedPageInfo.pageOffset && ( - index < updatedPageInfo.currentCount || + indexInfo.map { $0.rowId } : + indexInfo + .filter { info -> Bool in + info.rowIndex >= updatedPageInfo.pageOffset && ( + info.rowIndex < updatedPageInfo.currentCount || updatedPageInfo.currentCount == 0 ) } - .map { _, value -> T in value } + .map { info -> Int64 in info.rowId } ) } - let validChanges: [PagedData.TrackedChange] = determineValidChanges( - for: itemIndexes, - with: changesToQuery - ) - let validRelatedChangeRowIds: [Int64] = determineValidChanges( - for: relatedChangeIndexes, - with: Array(pagedRowIdsForRelatedChanges) - ) - let countBefore: Int = itemIndexes.filter { $0 < updatedPageInfo.pageOffset }.count + let validChangeRowIds: [Int64] = determineValidChanges(for: itemIndexes) + let validRelatedChangeRowIds: [Int64] = determineValidChanges(for: relatedChangeIndexes) + let countBefore: Int = itemIndexes.filter { $0.rowIndex < updatedPageInfo.pageOffset }.count // Update the offset and totalCount even if the rows are outside of the current page (need to // in order to ensure the 'load more' sections are accurate) @@ -312,18 +310,24 @@ public class PagedDatabaseObserver: TransactionObserver where pageSize: updatedPageInfo.pageSize, pageOffset: (updatedPageInfo.pageOffset + countBefore), currentCount: updatedPageInfo.currentCount, - totalCount: (updatedPageInfo.totalCount + validChanges.filter { $0.kind == .insert }.count) + totalCount: ( + updatedPageInfo.totalCount + + changesToQuery + .filter { $0.kind == .insert } + .filter { validChangeRowIds.contains($0.rowId) } + .count + ) ) // If there are no valid row ids then stop here (trigger updates though since the page info // has changes) - guard !validChanges.isEmpty || !validRelatedChangeRowIds.isEmpty else { + guard !validChangeRowIds.isEmpty || !validRelatedChangeRowIds.isEmpty else { updateDataAndCallbackIfNeeded(updatedDataCache, updatedPageInfo, true) return } // Fetch the inserted/updated rows - let targetRowIds: [Int64] = Array((validChanges.map { $0.rowId } + validRelatedChangeRowIds).asSet()) + let targetRowIds: [Int64] = Array((validChangeRowIds + validRelatedChangeRowIds).asSet()) let updatedItems: [T] = (try? dataQuery(targetRowIds) .fetchAll(db)) .defaulting(to: []) @@ -808,6 +812,11 @@ public enum PagedData { } } + fileprivate struct RowIndexInfo: Decodable, FetchableRecord { + let rowId: Int64 + let rowIndex: Int64 + } + // MARK: - Internal Functions fileprivate static func totalCount( @@ -891,12 +900,13 @@ public enum PagedData { requiredJoinSQL: SQL? = nil, orderSQL: SQL, filterSQL: SQL - ) -> [Int64] { + ) -> [RowIndexInfo] { guard !rowIds.isEmpty else { return [] } let tableNameLiteral: SQL = SQL(stringLiteral: tableName) - let request: SQLRequest = """ + let request: SQLRequest = """ SELECT + data.rowId AS rowId, (data.rowIndex - 1) AS rowIndex -- Converting from 1-Indexed to 0-indexed FROM ( SELECT @@ -1057,7 +1067,7 @@ public class AssociatedRecord: ErasedAssociatedRecord where T: Fet // If the associated data change isn't related to the paged type then no need to continue guard !pagedRowIds.isEmpty else { return (oldCount != countAfterDeletions) } - let pagedItemIndexes: [Int64] = PagedData.indexes( + let pagedItemIndexes: [PagedData.RowIndexInfo] = PagedData.indexes( db, rowIds: pagedRowIds, tableName: pagedTableName, @@ -1078,9 +1088,9 @@ public class AssociatedRecord: ErasedAssociatedRecord where T: Fet /// Instead of following the pattern the `PagedDatabaseObserver` does where we get the proper `validRowIds` we /// basically have to check if there is a single valid index, and if so retrieve and store all data related to the changes for this /// commit - this will mean in some cases we cache data which is actually unrelated to the filtered paged data - let hasOneValidIndex: Bool = pagedItemIndexes.contains(where: { index -> Bool in - index >= pageInfo.pageOffset && ( - index < pageInfo.currentCount || + let hasOneValidIndex: Bool = pagedItemIndexes.contains(where: { info -> Bool in + info.rowIndex >= pageInfo.pageOffset && ( + info.rowIndex < pageInfo.currentCount || pageInfo.currentCount == 0 ) }) diff --git a/SessionUtilitiesKit/Database/Utilities/GRDB+Notifications.swift b/SessionUtilitiesKit/Database/Utilities/GRDB+Notifications.swift deleted file mode 100644 index fe1d4f95e..000000000 --- a/SessionUtilitiesKit/Database/Utilities/GRDB+Notifications.swift +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -public extension Notification.Name { - static let resetStorage = Notification.Name("resetStorage") -} - -@objc public extension NSNotification { - @objc static let resetStorage = Notification.Name.resetStorage.rawValue as NSString -} diff --git a/SignalUtilitiesKit/Utilities/Notification+Loki.swift b/SignalUtilitiesKit/Utilities/Notification+Loki.swift index 46d08fadf..958e41249 100644 --- a/SignalUtilitiesKit/Utilities/Notification+Loki.swift +++ b/SignalUtilitiesKit/Utilities/Notification+Loki.swift @@ -6,8 +6,6 @@ public extension Notification.Name { static let contactOnlineStatusChanged = Notification.Name("contactOnlineStatusChanged") static let threadDeleted = Notification.Name("threadDeleted") static let threadSessionRestoreDevicesChanged = Notification.Name("threadSessionRestoreDevicesChanged") - // Interaction - static let dataNukeRequested = Notification.Name("dataNukeRequested") } @objc public extension NSNotification { @@ -16,6 +14,4 @@ public extension Notification.Name { @objc static let contactOnlineStatusChanged = Notification.Name.contactOnlineStatusChanged.rawValue as NSString @objc static let threadDeleted = Notification.Name.threadDeleted.rawValue as NSString @objc static let threadSessionRestoreDevicesChanged = Notification.Name.threadSessionRestoreDevicesChanged.rawValue as NSString - // Interaction - @objc static let dataNukeRequested = Notification.Name.dataNukeRequested.rawValue as NSString } From aa25b9c8bc9485746d7877e667071eaf6a220cae Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 29 Jun 2022 18:12:20 +1000 Subject: [PATCH 121/157] Updated the code to remove blinded contact records once they become unblinded (no point keeping them) --- .../MessageReceiver+MessageRequests.swift | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift index cb065a635..782d288a7 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift @@ -11,7 +11,7 @@ extension MessageReceiver { dependencies: SMKDependencies ) throws { let userPublicKey = getUserHexEncodedPublicKey(db, dependencies: dependencies) - var hadBlindedContact: Bool = false + var blindedContactIds: [String] = [] // Ignore messages which were sent from the current user guard message.sender != userPublicKey else { return } @@ -52,9 +52,8 @@ extension MessageReceiver { .with(sessionId: senderId) .saved(db) - // Flag that we had a blinded contact and add the `blindedThreadId` to an array so we can remove - // them at the end of processing - hadBlindedContact = true + // Add the `blindedId` to an array so we can remove them at the end of processing + blindedContactIds.append(blindedIdLookup.blindedId) // Update all interactions to be on the new thread // Note: Pending `MessageSendJobs` _shouldn't_ be an issue as even if they are sent after the @@ -74,12 +73,17 @@ extension MessageReceiver { db, senderSessionId: senderId, threadId: nil, - forceConfigSync: !hadBlindedContact // Sync here if there were no blinded contacts + forceConfigSync: blindedContactIds.isEmpty // Sync here if there were no blinded contacts ) - // If there were blinded contacts then we need to assume that the 'sender' is a newly create contact and hence - // need to update it's `isApproved` state - if hadBlindedContact { + // If there were blinded contacts which have now been resolved to this contact then we should remove + // the blinded contact and we also need to assume that the 'sender' is a newly created contact and + // hence need to update it's `isApproved` state + if !blindedContactIds.isEmpty { + _ = try? Contact + .filter(ids: blindedContactIds) + .deleteAll(db) + try updateContactApprovalStatusIfNeeded( db, senderSessionId: userPublicKey, From f2bd72b3ae217c464c0c4457ee4974d96b339cb1 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 30 Jun 2022 17:48:43 +1000 Subject: [PATCH 122/157] Fixed a couple more bugs Fixed an issue with updating the users profile image Fixed an annoying bug where the conversation screen would "bounce" when initial loading --- Session/Conversations/ConversationVC.swift | 41 +++++++++++++++++-- .../InsetLockableTableView.swift | 4 +- .../Sending & Receiving/MessageReceiver.swift | 2 +- .../Utilities/ProfileManager.swift | 12 +++++- 4 files changed, 50 insertions(+), 9 deletions(-) diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 73038c611..92ce1c2f1 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -1194,14 +1194,17 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers return } + let targetIndexPath: IndexPath = IndexPath( + row: (self.viewModel.interactionData[messagesSectionIndex].elements.count - 1), + section: messagesSectionIndex + ) self.tableView.scrollToRow( - at: IndexPath( - row: (self.viewModel.interactionData[messagesSectionIndex].elements.count - 1), - section: messagesSectionIndex - ), + at: targetIndexPath, at: .bottom, animated: isAnimated ) + + self.handleInitialOffsetBounceBug(targetIndexPath: targetIndexPath, at: .bottom) } func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { @@ -1398,6 +1401,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers at: position, animated: (self.didFinishInitialLayout && isAnimated) ) + self.handleInitialOffsetBounceBug(targetIndexPath: targetIndexPath, at: position) // If we haven't finished the initial layout then we want to delay the highlight slightly // so it doesn't look buggy with the push transition @@ -1424,6 +1428,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers } self.tableView.scrollToRow(at: targetIndexPath, at: position, animated: true) + self.handleInitialOffsetBounceBug(targetIndexPath: targetIndexPath, at: position) } func highlightCellIfNeeded(interactionId: Int64) { @@ -1439,4 +1444,32 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers .highlight() } } + + private func handleInitialOffsetBounceBug(targetIndexPath: IndexPath, at position: UITableView.ScrollPosition) { + /// Note: This code is a hack to prevent a weird 'bounce' behaviour that occurs when triggering the initial scroll due + /// to the UITableView properly calculating it's cell sizes (it seems to layout ~3 times each with slightly different sizes) + if !self.hasPerformedInitialScroll { + let initialUpdateTime: CFTimeInterval = CACurrentMediaTime() + var lastSize: CGSize = .zero + + self.tableView.afterNextLayoutSubviews( + when: { [weak self] a, b, updatedContentSize in + guard (CACurrentMediaTime() - initialUpdateTime) < 2 && lastSize != updatedContentSize else { + return true + } + + lastSize = updatedContentSize + + self?.tableView.scrollToRow( + at: targetIndexPath, + at: position, + animated: false + ) + + return false + }, + then: {} + ) + } + } } diff --git a/Session/Conversations/Views & Modals/InsetLockableTableView.swift b/Session/Conversations/Views & Modals/InsetLockableTableView.swift index cb0abdc1f..1f0fb7980 100644 --- a/Session/Conversations/Views & Modals/InsetLockableTableView.swift +++ b/Session/Conversations/Views & Modals/InsetLockableTableView.swift @@ -29,7 +29,7 @@ public class InsetLockableTableView: UITableView { // Store the callback locally to prevent infinite loops var callback: (() -> ())? - if self.testCallbackCondition() { + if self.checkCallbackCondition() { callback = self.afterLayoutSubviewsCallback self.afterLayoutSubviewsCallback = nil } @@ -61,7 +61,7 @@ public class InsetLockableTableView: UITableView { self.afterLayoutSubviewsCallback = callback } - private func testCallbackCondition() -> Bool { + private func checkCallbackCondition() -> Bool { guard self.callbackCondition != nil else { return false } let numSections: Int = self.numberOfSections diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index b1ba41403..5b4dca427 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -345,7 +345,7 @@ public enum MessageReceiver { // Download the profile picture if needed db.afterNextTransactionCommit { _ in - ProfileManager.downloadAvatar(for: profile) + ProfileManager.downloadAvatar(for: updatedProfile) } } } diff --git a/SessionMessagingKit/Utilities/ProfileManager.swift b/SessionMessagingKit/Utilities/ProfileManager.swift index 885fdf0d3..992fdaaa6 100644 --- a/SessionMessagingKit/Utilities/ProfileManager.swift +++ b/SessionMessagingKit/Utilities/ProfileManager.swift @@ -129,11 +129,12 @@ public struct ProfileManager { return } + let queue: DispatchQueue = DispatchQueue.global(qos: .default) let fileName: String = UUID().uuidString.appendingFileExtension("jpg") let filePath: String = ProfileManager.profileAvatarFilepath(filename: fileName) var backgroundTask: OWSBackgroundTask? = OWSBackgroundTask(label: funcName) - DispatchQueue.global(qos: .default).async { + queue.async { OWSLogger.verbose("downloading profile avatar: \(profile.id)") currentAvatarDownloads.mutate { $0.insert(profile.id) } @@ -141,7 +142,7 @@ public struct ProfileManager { FileServerAPI .download(fileId, useOldServer: useOldServer) - .done { data in + .done(on: queue) { data in currentAvatarDownloads.mutate { $0.remove(profile.id) } GRDBStorage.shared.write { db in @@ -189,6 +190,13 @@ public struct ProfileManager { // isn't used if backgroundTask != nil { backgroundTask = nil } } + .catch(on: queue) { _ in + currentAvatarDownloads.mutate { $0.remove(profile.id) } + + // Redundant but without reading 'backgroundTask' it will warn that the variable + // isn't used + if backgroundTask != nil { backgroundTask = nil } + } .retainUntilComplete() } } From eb0118ac10c2d226c89af829bbe06263c79f6260 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 1 Jul 2022 12:52:41 +1000 Subject: [PATCH 123/157] Fixed a few more bugs and tweaked attachment download logic Updated the code to only auto-start attachment downloads when a user opens a conversation (and only for the current page of messages) Updated the GarbageCollectionJob to default to handling all cases (instead of requiring the cases to be defined) - this means we can add future cases without having to recreate the default job Added logic to remove approved blinded contact records as part of the GarbageCollectionJob Added code to better handle "invalid" attachments when migrating Added a mechanism to retrieve the details for currently running jobs (ie. allows us to check for duplicate concurrent jobs) Resolved the remaining TODOs in the GRDB migration code Cleaned up DB update logic to update only the targeted columns Fixed a bug due to a typo in a localised string Fixed a bug where link previews without images or with custom copy weren't being processed as link previews Fixed a bug where Open Groups could display with an empty name value --- Session.xcodeproj/project.pbxproj | 24 ++--- .../ConversationVC+Interaction.swift | 2 +- .../Conversations/ConversationViewModel.swift | 44 +++++++++ .../Content Views/LinkPreviewState.swift | 2 +- .../Views & Modals/BlockedModal.swift | 23 +++-- .../MediaDetailViewController.swift | 4 +- .../Translations/de.lproj/Localizable.strings | 2 +- .../Translations/en.lproj/Localizable.strings | 3 +- .../Translations/es.lproj/Localizable.strings | 2 +- .../Translations/fa.lproj/Localizable.strings | 2 +- .../Translations/fi.lproj/Localizable.strings | 2 +- .../Translations/fr.lproj/Localizable.strings | 2 +- .../Translations/hi.lproj/Localizable.strings | 2 +- .../Translations/hr.lproj/Localizable.strings | 2 +- .../id-ID.lproj/Localizable.strings | 2 +- .../Translations/it.lproj/Localizable.strings | 2 +- .../Translations/ja.lproj/Localizable.strings | 2 +- .../Translations/nl.lproj/Localizable.strings | 2 +- .../Translations/pl.lproj/Localizable.strings | 2 +- .../pt_BR.lproj/Localizable.strings | 2 +- .../Translations/ru.lproj/Localizable.strings | 2 +- .../Translations/si.lproj/Localizable.strings | 2 +- .../Translations/sk.lproj/Localizable.strings | 2 +- .../Translations/sv.lproj/Localizable.strings | 2 +- .../Translations/th.lproj/Localizable.strings | 2 +- .../vi-VN.lproj/Localizable.strings | 2 +- .../zh-Hant.lproj/Localizable.strings | 2 +- .../zh_CN.lproj/Localizable.strings | 2 +- .../Common Networking/Header.swift | 2 - .../Contacts/BlindedIdMapping.swift | 40 --------- .../Migrations/_002_SetupStandardJobs.swift | 7 +- .../Migrations/_003_YDBToGRDBMigration.swift | 74 ++++++++++++--- .../Database/Models/Attachment.swift | 46 ++++++---- .../Database/Models/ClosedGroup.swift | 12 --- .../Database/Models/LinkPreview.swift | 5 -- .../Database/Models/OpenGroup.swift | 2 +- .../Database/Models/Profile.swift | 9 +- .../Jobs/Types/AttachmentDownloadJob.swift | 89 +++++++++++++++---- .../Jobs/Types/GarbageCollectionJob.swift | 73 +++++++++------ .../MessageReceiver+ClosedGroups.swift | 6 +- .../MessageReceiver+VisibleMessages.swift | 32 +------ .../MessageSender+ClosedGroups.swift | 5 +- .../MessageSender+Convenience.swift | 1 + .../Sending & Receiving/MessageSender.swift | 22 ++--- .../Utilities/ProfileManager.swift | 6 +- .../Contacts/BlindedIdLookupSpec.swift | 32 +++++++ .../Contacts/BlindedIdMappingSpec.swift | 48 ---------- .../General/Dictionary+Utilities.swift | 8 +- SessionUtilitiesKit/JobRunner/JobRunner.swift | 16 ++++ 49 files changed, 377 insertions(+), 302 deletions(-) delete mode 100644 SessionMessagingKit/Contacts/BlindedIdMapping.swift create mode 100644 SessionMessagingKitTests/Contacts/BlindedIdLookupSpec.swift delete mode 100644 SessionMessagingKitTests/Contacts/BlindedIdMappingSpec.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 95da2432b..e66c454b1 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -593,7 +593,6 @@ FD09C5E828264937000CE219 /* MediaDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E728264937000CE219 /* MediaDetailViewController.swift */; }; FD09C5EA282A1BB2000CE219 /* ThreadTypingIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E9282A1BB2000CE219 /* ThreadTypingIndicator.swift */; }; FD09C5EC282B8F18000CE219 /* AttachmentError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5EB282B8F17000CE219 /* AttachmentError.swift */; }; - FD0BA51B27CD88EC00CC6805 /* BlindedIdMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0BA51A27CD88EC00CC6805 /* BlindedIdMapping.swift */; }; FD17D79927F40AB800122BE0 /* _003_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79827F40AB800122BE0 /* _003_YDBToGRDBMigration.swift */; }; FD17D79C27F40B2E00122BE0 /* SMKLegacy.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79B27F40B2E00122BE0 /* SMKLegacy.swift */; }; FD17D7A027F40CC800122BE0 /* _001_InitialSetupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79F27F40CC800122BE0 /* _001_InitialSetupMigration.swift */; }; @@ -653,7 +652,7 @@ FD3C906027E410F700CD579F /* FileUploadResponseSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C905F27E410F700CD579F /* FileUploadResponseSpec.swift */; }; FD3C906227E411AF00CD579F /* HeaderSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906127E411AF00CD579F /* HeaderSpec.swift */; }; FD3C906427E4122F00CD579F /* RequestSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906327E4122F00CD579F /* RequestSpec.swift */; }; - FD3C906727E416AF00CD579F /* BlindedIdMappingSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906627E416AF00CD579F /* BlindedIdMappingSpec.swift */; }; + FD3C906727E416AF00CD579F /* BlindedIdLookupSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906627E416AF00CD579F /* BlindedIdLookupSpec.swift */; }; FD3C906A27E417CE00CD579F /* SodiumUtilitiesSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906927E417CE00CD579F /* SodiumUtilitiesSpec.swift */; }; FD3C906D27E43C4B00CD579F /* MessageSenderEncryptionSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906C27E43C4B00CD579F /* MessageSenderEncryptionSpec.swift */; }; FD3C906F27E43E8700CD579F /* MockBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906E27E43E8700CD579F /* MockBox.swift */; }; @@ -1658,7 +1657,6 @@ FD09C5E728264937000CE219 /* MediaDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaDetailViewController.swift; sourceTree = ""; }; FD09C5E9282A1BB2000CE219 /* ThreadTypingIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadTypingIndicator.swift; sourceTree = ""; }; FD09C5EB282B8F17000CE219 /* AttachmentError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentError.swift; sourceTree = ""; }; - FD0BA51A27CD88EC00CC6805 /* BlindedIdMapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlindedIdMapping.swift; sourceTree = ""; }; FD17D79527F3E04600122BE0 /* _001_InitialSetupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _001_InitialSetupMigration.swift; sourceTree = ""; }; FD17D79827F40AB800122BE0 /* _003_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _003_YDBToGRDBMigration.swift; sourceTree = ""; }; FD17D79B27F40B2E00122BE0 /* SMKLegacy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMKLegacy.swift; sourceTree = ""; }; @@ -1694,7 +1692,7 @@ FD3C905F27E410F700CD579F /* FileUploadResponseSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUploadResponseSpec.swift; sourceTree = ""; }; FD3C906127E411AF00CD579F /* HeaderSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderSpec.swift; sourceTree = ""; }; FD3C906327E4122F00CD579F /* RequestSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestSpec.swift; sourceTree = ""; }; - FD3C906627E416AF00CD579F /* BlindedIdMappingSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlindedIdMappingSpec.swift; sourceTree = ""; }; + FD3C906627E416AF00CD579F /* BlindedIdLookupSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlindedIdLookupSpec.swift; sourceTree = ""; }; FD3C906927E417CE00CD579F /* SodiumUtilitiesSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SodiumUtilitiesSpec.swift; sourceTree = ""; }; FD3C906C27E43C4B00CD579F /* MessageSenderEncryptionSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSenderEncryptionSpec.swift; sourceTree = ""; }; FD3C906E27E43E8700CD579F /* MockBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBox.swift; sourceTree = ""; }; @@ -2424,14 +2422,6 @@ path = General; sourceTree = ""; }; - B8B3201F258B1A540020074B /* Contacts */ = { - isa = PBXGroup; - children = ( - FD0BA51A27CD88EC00CC6805 /* BlindedIdMapping.swift */, - ); - path = Contacts; - sourceTree = ""; - }; B8B558ED26C4B55F00693325 /* Calls */ = { isa = PBXGroup; children = ( @@ -3153,7 +3143,6 @@ C3C2A70A25539DF900C340D1 /* Meta */, FDC4384D27B47FD600C60D73 /* Common Networking */, B8DE1FB226C22F1F0079C9CE /* Calls */, - B8B3201F258B1A540020074B /* Contacts */, C32C5BCB256DC818003C73A2 /* Database */, C300A5BB2554AFFB00555489 /* Messages */, C300A5F02554B08500555489 /* Sending & Receiving */, @@ -3615,7 +3604,7 @@ FD3C906527E416A200CD579F /* Contacts */ = { isa = PBXGroup; children = ( - FD3C906627E416AF00CD579F /* BlindedIdMappingSpec.swift */, + FD3C906627E416AF00CD579F /* BlindedIdLookupSpec.swift */, ); path = Contacts; sourceTree = ""; @@ -5113,7 +5102,6 @@ FD09797527FAB64300936362 /* ProfileManager.swift in Sources */, FD245C57285065F100B966DD /* Poller.swift in Sources */, FDA8EAFE280E8B78002B68E5 /* FailedMessageSendsJob.swift in Sources */, - FD0BA51B27CD88EC00CC6805 /* BlindedIdMapping.swift in Sources */, FD245C6A2850666F00B966DD /* FileServerAPI.swift in Sources */, FDC4386927B4E6B800C60D73 /* String+Utlities.swift in Sources */, FD716E6628502EE200C96BF4 /* CurrentCallProtocol.swift in Sources */, @@ -5487,7 +5475,7 @@ FD3C906D27E43C4B00CD579F /* MessageSenderEncryptionSpec.swift in Sources */, FDC2908D27D70905005DAE71 /* UpdateMessageRequestSpec.swift in Sources */, FD078E5427E197CA000769AF /* OpenGroupManagerSpec.swift in Sources */, - FD3C906727E416AF00CD579F /* BlindedIdMappingSpec.swift in Sources */, + FD3C906727E416AF00CD579F /* BlindedIdLookupSpec.swift in Sources */, FD83B9D227D59495005E1583 /* MockUserDefaults.swift in Sources */, FD078E5C27E29F78000769AF /* MockNonce24Generator.swift in Sources */, ); @@ -6818,7 +6806,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 350; + CURRENT_PROJECT_VERSION = 354; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -6890,7 +6878,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 350; + CURRENT_PROJECT_VERSION = 354; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 2aea6b825..2324dd93c 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -751,7 +751,7 @@ extension ConversationVC: guard let mediaView = albumView.mediaView(forLocation: locationInAlbumView) else { return } switch mediaView.attachment.state { - case .pendingDownload, .downloading, .uploading: break + case .pendingDownload, .downloading, .uploading, .invalid: break // Failed uploads should be handled via the "resend" process instead case .failedUpload: break diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 4d44fa286..5f631d953 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -36,6 +36,12 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { public var lastSearchedText: String? public let focusedInteractionId: Int64? // Note: This is used for global search + /// We maintain a local set of ids for attachments which we have automatically created attachmentDownload jobs for + /// in order to avoid creating excessive jobs while the user is actively chatting in a conversation (the attachmentDownload + /// jobs run serially and will only actually perform the download if the attachment hasn't already been downloaded so + /// we don't need to worry about duplicate jobs but it's better to avoid creating duplicate jobs when possible) + private var autoStartedDownloadJobAttachmentIds: Set = [] + public lazy var blockedBannerMessage: String = { switch self.threadData.threadVariant { case .contact: @@ -240,6 +246,44 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { .filter { $0.isTypingIndicator != true } .sorted { lhs, rhs -> Bool in lhs.timestampMs < rhs.timestampMs } + // Add download jobs for any attachments which need to be downloaded + let pendingAttachmentsToDownload: [(attachment: Attachment, interactionId: Int64)] = sortedData + .flatMap { viewModel -> [(attachment: Attachment, interactionId: Int64)] in + // Do nothing if this is an incoming message on an untrusted contact thread + guard + viewModel.variant != .standardIncoming || + viewModel.threadIsTrusted || + viewModel.threadVariant != .contact + else { return [] } + + return (viewModel.attachments ?? []) + .appending(viewModel.quoteAttachment) + .appending(viewModel.linkPreviewAttachment) + .filter { $0.state == .pendingDownload } + .filter { !self.autoStartedDownloadJobAttachmentIds.contains($0.id) } + .map { ($0, viewModel.id) } + } + + if !pendingAttachmentsToDownload.isEmpty { + GRDBStorage.shared.writeAsync { db in + pendingAttachmentsToDownload.forEach { attachment, interactionId in + JobRunner.add( + db, + job: Job( + variant: .attachmentDownload, + threadId: self.threadId, + interactionId: interactionId, + details: AttachmentDownloadJob.Details( + attachmentId: attachment.id + ) + ) + ) + + self.autoStartedDownloadJobAttachmentIds.insert(attachment.id) + } + } + } + // We load messages from newest to oldest so having a pageOffset larger than zero means // there are newer pages to load return [ diff --git a/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift b/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift index 22fc0ea74..054d19271 100644 --- a/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift +++ b/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift @@ -96,7 +96,7 @@ public extension LinkPreview { return .loaded case .pendingDownload, .downloading, .uploading: return .loading - case .failedDownload, .failedUpload: return .invalid + case .failedDownload, .failedUpload, .invalid: return .invalid } } diff --git a/Session/Conversations/Views & Modals/BlockedModal.swift b/Session/Conversations/Views & Modals/BlockedModal.swift index 854e2fc36..9bcc28c02 100644 --- a/Session/Conversations/Views & Modals/BlockedModal.swift +++ b/Session/Conversations/Views & Modals/BlockedModal.swift @@ -73,21 +73,20 @@ final class BlockedModal: Modal { contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing) } - // MARK: Interaction + // MARK: - Interaction + @objc private func unblock() { let publicKey: String = self.publicKey - GRDBStorage.shared.writeAsync( - updates: { db in - try? Contact - .fetchOne(db, id: publicKey)? - .with(isBlocked: true) - .update(db) - }, - completion: { db, _ in - try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() - } - ) + GRDBStorage.shared.writeAsync { db in + try Contact + .filter(id: publicKey) + .updateAll(db, Contact.Columns.isBlocked.set(to: true)) + + try MessageSender + .syncConfiguration(db, forceSyncNow: true) + .retainUntilComplete() + } presentingViewController?.dismiss(animated: true, completion: nil) } diff --git a/Session/Media Viewing & Editing/MediaDetailViewController.swift b/Session/Media Viewing & Editing/MediaDetailViewController.swift index b83035af2..0ae1ff5a3 100644 --- a/Session/Media Viewing & Editing/MediaDetailViewController.swift +++ b/Session/Media Viewing & Editing/MediaDetailViewController.swift @@ -155,9 +155,7 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVid self.mediaView.removeFromSuperview() self.playVideoButton.removeFromSuperview() self.videoProgressBar.removeFromSuperview() - - // TODO: COnfirm this - scrollView.zoomScale = 1 + self.scrollView.zoomScale = 1 if self.galleryItem.attachment.isAnimated { if self.galleryItem.attachment.isValid, let originalFilePath: String = self.galleryItem.attachment.originalFilePath { diff --git a/Session/Meta/Translations/de.lproj/Localizable.strings b/Session/Meta/Translations/de.lproj/Localizable.strings index 3ebda7272..12d7713ec 100644 --- a/Session/Meta/Translations/de.lproj/Localizable.strings +++ b/Session/Meta/Translations/de.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Loslösen"; "modal_call_missed_tips_title" = "Verpasster Anruf"; "modal_call_missed_tips_explanation" = "Verpasster Anruf von '%@', da du die Berechtigung 'Anrufe und Videoanrufe' in den Datenschutzeinstellungen aktivieren musst."; -"meida_saved" = "Medien gespeichert von %@."; +"media_saved" = "Medien gespeichert von %@."; "screenshot_taken" = "%@ hat ein Screenshot gemacht."; "SEARCH_SECTION_CONTACTS" = "Kontakte und Gruppen"; "SEARCH_SECTION_MESSAGES" = "Nachrichten"; diff --git a/Session/Meta/Translations/en.lproj/Localizable.strings b/Session/Meta/Translations/en.lproj/Localizable.strings index f46ff4cbf..57ef01ba2 100644 --- a/Session/Meta/Translations/en.lproj/Localizable.strings +++ b/Session/Meta/Translations/en.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Unpin"; "modal_call_missed_tips_title" = "Call missed"; "modal_call_missed_tips_explanation" = "Call missed from '%@' because you needed to enable the 'Voice and video calls' permission in the Privacy Settings."; -"meida_saved" = "Media saved by %@."; +"media_saved" = "Media saved by %@."; "screenshot_taken" = "%@ took a screenshot."; "SEARCH_SECTION_CONTACTS" = "Contacts and Groups"; "SEARCH_SECTION_MESSAGES" = "Messages"; @@ -660,3 +660,4 @@ "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; "MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; + diff --git a/Session/Meta/Translations/es.lproj/Localizable.strings b/Session/Meta/Translations/es.lproj/Localizable.strings index 6c0ce3a4f..c721a032e 100644 --- a/Session/Meta/Translations/es.lproj/Localizable.strings +++ b/Session/Meta/Translations/es.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Dejar de fijar"; "modal_call_missed_tips_title" = "Llamada perdida"; "modal_call_missed_tips_explanation" = "Llamada perdida de '%@' porque necesitas habilitar el permiso de 'Llamadas de voz y video' en la configuración de privacidad."; -"meida_saved" = "Media saved by %@."; +"media_saved" = "Media saved by %@."; "screenshot_taken" = "%@ tomó una captura de pantalla."; "SEARCH_SECTION_CONTACTS" = "Contacts and Groups"; "SEARCH_SECTION_MESSAGES" = "Messages"; diff --git a/Session/Meta/Translations/fa.lproj/Localizable.strings b/Session/Meta/Translations/fa.lproj/Localizable.strings index cf91d20ac..b15fa022f 100644 --- a/Session/Meta/Translations/fa.lproj/Localizable.strings +++ b/Session/Meta/Translations/fa.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Unpin"; "modal_call_missed_tips_title" = "Call missed"; "modal_call_missed_tips_explanation" = "Call missed from '%@' because you needed to enable the 'Voice and video calls' permission in the Privacy Settings."; -"meida_saved" = "Media saved by %@."; +"media_saved" = "Media saved by %@."; "screenshot_taken" = "%@ took a screenshot."; "SEARCH_SECTION_CONTACTS" = "Contacts and Groups"; "SEARCH_SECTION_MESSAGES" = "Messages"; diff --git a/Session/Meta/Translations/fi.lproj/Localizable.strings b/Session/Meta/Translations/fi.lproj/Localizable.strings index c30b60393..4c4ed311e 100644 --- a/Session/Meta/Translations/fi.lproj/Localizable.strings +++ b/Session/Meta/Translations/fi.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Irrota"; "modal_call_missed_tips_title" = "Vastaamaton puhelu"; "modal_call_missed_tips_explanation" = "Vastaamaton puhelu käyttäjältä '%@', koska pahelut edellyttävät 'Ääni- ja videopuhelut' -käyttöoikeuden yksityisyysasetuksista."; -"meida_saved" = "%@ tallensi median."; +"media_saved" = "%@ tallensi median."; "screenshot_taken" = "%@ otti kuvankaappauksen."; "SEARCH_SECTION_CONTACTS" = "Henkilöt ja ryhmät"; "SEARCH_SECTION_MESSAGES" = "Viestit"; diff --git a/Session/Meta/Translations/fr.lproj/Localizable.strings b/Session/Meta/Translations/fr.lproj/Localizable.strings index 820120d7c..52ca9e487 100644 --- a/Session/Meta/Translations/fr.lproj/Localizable.strings +++ b/Session/Meta/Translations/fr.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Désépingler"; "modal_call_missed_tips_title" = "Appel manqué"; "modal_call_missed_tips_explanation" = "Appel manqué de '%@' car vous devez activer la permission 'Appels vocaux et vidéo' dans les paramètres de confidentialité."; -"meida_saved" = "%@ a enregistré le média."; +"media_saved" = "%@ a enregistré le média."; "screenshot_taken" = "%@ a pris une capture d'écran."; "SEARCH_SECTION_CONTACTS" = "Contacts et Groupes"; "SEARCH_SECTION_MESSAGES" = "Messages"; diff --git a/Session/Meta/Translations/hi.lproj/Localizable.strings b/Session/Meta/Translations/hi.lproj/Localizable.strings index e862c25c0..5bdba14ac 100644 --- a/Session/Meta/Translations/hi.lproj/Localizable.strings +++ b/Session/Meta/Translations/hi.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Unpin"; "modal_call_missed_tips_title" = "Call missed"; "modal_call_missed_tips_explanation" = "Call missed from '%@' because you needed to enable the 'Voice and video calls' permission in the Privacy Settings."; -"meida_saved" = "Media saved by %@."; +"media_saved" = "Media saved by %@."; "screenshot_taken" = "%@ took a screenshot."; "SEARCH_SECTION_CONTACTS" = "Contacts and Groups"; "SEARCH_SECTION_MESSAGES" = "Messages"; diff --git a/Session/Meta/Translations/hr.lproj/Localizable.strings b/Session/Meta/Translations/hr.lproj/Localizable.strings index e40b5492e..bacb148c8 100644 --- a/Session/Meta/Translations/hr.lproj/Localizable.strings +++ b/Session/Meta/Translations/hr.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Otkvači"; "modal_call_missed_tips_title" = "Propušten poziv"; "modal_call_missed_tips_explanation" = "Propušten poziv od '%@' jer 'Audio i video pozivi' nemaju dopuštenje u Postavkama privatnosti."; -"meida_saved" = "%@ je spremio/la medij."; +"media_saved" = "%@ je spremio/la medij."; "screenshot_taken" = "%@ je napravio/la snimku zaslona."; "SEARCH_SECTION_CONTACTS" = "Contacts and Groups"; "SEARCH_SECTION_MESSAGES" = "Messages"; diff --git a/Session/Meta/Translations/id-ID.lproj/Localizable.strings b/Session/Meta/Translations/id-ID.lproj/Localizable.strings index 3ffd04b51..b3ccf6522 100644 --- a/Session/Meta/Translations/id-ID.lproj/Localizable.strings +++ b/Session/Meta/Translations/id-ID.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Unpin"; "modal_call_missed_tips_title" = "Call missed"; "modal_call_missed_tips_explanation" = "Call missed from '%@' because you needed to enable the 'Voice and video calls' permission in the Privacy Settings."; -"meida_saved" = "Media saved by %@."; +"media_saved" = "Media saved by %@."; "screenshot_taken" = "%@ took a screenshot."; "SEARCH_SECTION_CONTACTS" = "Contacts and Groups"; "SEARCH_SECTION_MESSAGES" = "Messages"; diff --git a/Session/Meta/Translations/it.lproj/Localizable.strings b/Session/Meta/Translations/it.lproj/Localizable.strings index bbd0183d8..b00a8fab3 100644 --- a/Session/Meta/Translations/it.lproj/Localizable.strings +++ b/Session/Meta/Translations/it.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Non fissare in alto"; "modal_call_missed_tips_title" = "Chiamata persa"; "modal_call_missed_tips_explanation" = "Chiamata persa da '%@' perché era necessario abilitare l'autorizzazione 'Voce e video chiamate' nelle Impostazioni Privacy."; -"meida_saved" = "Media salvato da %@."; +"media_saved" = "Media salvato da %@."; "screenshot_taken" = "%@ ha acquisito uno screenshot."; "SEARCH_SECTION_CONTACTS" = "Contatti e Gruppi"; "SEARCH_SECTION_MESSAGES" = "Messaggi"; diff --git a/Session/Meta/Translations/ja.lproj/Localizable.strings b/Session/Meta/Translations/ja.lproj/Localizable.strings index 108805053..7be122fca 100644 --- a/Session/Meta/Translations/ja.lproj/Localizable.strings +++ b/Session/Meta/Translations/ja.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "ピン留めを外す"; "modal_call_missed_tips_title" = "通話できません"; "modal_call_missed_tips_explanation" = "プライバシー設定で「音声通話とビデオ通話」を許可していないため、%@から着信できませんでした。"; -"meida_saved" = "%@ によって保存されたメディア"; +"media_saved" = "%@ によって保存されたメディア"; "screenshot_taken" = "%@はスクリーンショットを撮りました。"; "SEARCH_SECTION_CONTACTS" = "連絡先とグループ"; "SEARCH_SECTION_MESSAGES" = "メッセージ"; diff --git a/Session/Meta/Translations/nl.lproj/Localizable.strings b/Session/Meta/Translations/nl.lproj/Localizable.strings index 6aadc1caf..ac4a696b4 100644 --- a/Session/Meta/Translations/nl.lproj/Localizable.strings +++ b/Session/Meta/Translations/nl.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Losmaken"; "modal_call_missed_tips_title" = "Oproep gemist"; "modal_call_missed_tips_explanation" = "Oproep gemist van '%@' omdat je de 'Spraak- en video-oproep' permissie nodig hebt in de privacy-instellingen."; -"meida_saved" = "Media opgeslagen door %@."; +"media_saved" = "Media opgeslagen door %@."; "screenshot_taken" = "%@ heeft een schermafbeelding genomen."; "SEARCH_SECTION_CONTACTS" = "Contacts and Groups"; "SEARCH_SECTION_MESSAGES" = "Messages"; diff --git a/Session/Meta/Translations/pl.lproj/Localizable.strings b/Session/Meta/Translations/pl.lproj/Localizable.strings index 2e93d89d6..e873b2e2e 100644 --- a/Session/Meta/Translations/pl.lproj/Localizable.strings +++ b/Session/Meta/Translations/pl.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Odepnij"; "modal_call_missed_tips_title" = "Połączenie nieodebrane"; "modal_call_missed_tips_explanation" = "Połączenie nieodebrane od '%@' ponieważ musisz włączyć uprawnienie 'Połączenia głosowe i wideo' w Ustawieniach Prywatności."; -"meida_saved" = "Media zapisane przez %@."; +"media_saved" = "Media zapisane przez %@."; "screenshot_taken" = "%@ wykonał zrzut ekranu."; "SEARCH_SECTION_CONTACTS" = "Kontakty i grupy"; "SEARCH_SECTION_MESSAGES" = "Wiadomości"; diff --git a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings index 3a933083a..218d22cac 100644 --- a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings +++ b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Desfixar"; "modal_call_missed_tips_title" = "Chamada perdida"; "modal_call_missed_tips_explanation" = "Chamada perdida de '%@', você precisa habilitar a permissão de 'Voz e Video' nas configurações de Privacidade."; -"meida_saved" = "Mídia salva por %@."; +"media_saved" = "Mídia salva por %@."; "screenshot_taken" = "%@ fez uma captura de tela."; "SEARCH_SECTION_CONTACTS" = "Contacts and Groups"; "SEARCH_SECTION_MESSAGES" = "Messages"; diff --git a/Session/Meta/Translations/ru.lproj/Localizable.strings b/Session/Meta/Translations/ru.lproj/Localizable.strings index e6f0421e3..2f501ee7d 100644 --- a/Session/Meta/Translations/ru.lproj/Localizable.strings +++ b/Session/Meta/Translations/ru.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Открепить"; "modal_call_missed_tips_title" = "Пропущен вызов"; "modal_call_missed_tips_explanation" = "Вызов от '%@' пропущен, вам необходимо включить разрешение 'Голосовые и видео вызовы' в настройках Конфиденциальности."; -"meida_saved" = "%@ сохранил(а) медиафайл."; +"media_saved" = "%@ сохранил(а) медиафайл."; "screenshot_taken" = "%@ сделал(а) снимок экрана."; "SEARCH_SECTION_CONTACTS" = "Контакты и группы"; "SEARCH_SECTION_MESSAGES" = "Сообщения"; diff --git a/Session/Meta/Translations/si.lproj/Localizable.strings b/Session/Meta/Translations/si.lproj/Localizable.strings index 04d1ea3d2..14696b268 100644 --- a/Session/Meta/Translations/si.lproj/Localizable.strings +++ b/Session/Meta/Translations/si.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Unpin"; "modal_call_missed_tips_title" = "Call missed"; "modal_call_missed_tips_explanation" = "Call missed from '%@' because you needed to enable the 'Voice and video calls' permission in the Privacy Settings."; -"meida_saved" = "Media saved by %@."; +"media_saved" = "Media saved by %@."; "screenshot_taken" = "%@ took a screenshot."; "SEARCH_SECTION_CONTACTS" = "Contacts and Groups"; "SEARCH_SECTION_MESSAGES" = "Messages"; diff --git a/Session/Meta/Translations/sk.lproj/Localizable.strings b/Session/Meta/Translations/sk.lproj/Localizable.strings index d2ed3836a..cdf5f5f7f 100644 --- a/Session/Meta/Translations/sk.lproj/Localizable.strings +++ b/Session/Meta/Translations/sk.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Zrušiť pripnutie"; "modal_call_missed_tips_title" = "Zmeškaný hovor"; "modal_call_missed_tips_explanation" = "Zmeškaný hovor od %@ pretože ste potrebovali zapnúť povolenie pre 'Hlasové a video hovory' v Nastaveniach Súkromia."; -"meida_saved" = "Médiá uložené používateľom %@."; +"media_saved" = "Médiá uložené používateľom %@."; "screenshot_taken" = "%@ urobili snímku obrazovky."; "SEARCH_SECTION_CONTACTS" = "Kontakty a Skupiny"; "SEARCH_SECTION_MESSAGES" = "Správy"; diff --git a/Session/Meta/Translations/sv.lproj/Localizable.strings b/Session/Meta/Translations/sv.lproj/Localizable.strings index 617b930c5..ccc97107f 100644 --- a/Session/Meta/Translations/sv.lproj/Localizable.strings +++ b/Session/Meta/Translations/sv.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Unpin"; "modal_call_missed_tips_title" = "Call missed"; "modal_call_missed_tips_explanation" = "Call missed from '%@' because you needed to enable the 'Voice and video calls' permission in the Privacy Settings."; -"meida_saved" = "Media saved by %@."; +"media_saved" = "Media saved by %@."; "screenshot_taken" = "%@ took a screenshot."; "SEARCH_SECTION_CONTACTS" = "Contacts and Groups"; "SEARCH_SECTION_MESSAGES" = "Messages"; diff --git a/Session/Meta/Translations/th.lproj/Localizable.strings b/Session/Meta/Translations/th.lproj/Localizable.strings index 5832a403b..656d65ef6 100644 --- a/Session/Meta/Translations/th.lproj/Localizable.strings +++ b/Session/Meta/Translations/th.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Unpin"; "modal_call_missed_tips_title" = "Call missed"; "modal_call_missed_tips_explanation" = "Call missed from '%@' because you needed to enable the 'Voice and video calls' permission in the Privacy Settings."; -"meida_saved" = "Media saved by %@."; +"media_saved" = "Media saved by %@."; "screenshot_taken" = "%@ took a screenshot."; "SEARCH_SECTION_CONTACTS" = "Contacts and Groups"; "SEARCH_SECTION_MESSAGES" = "Messages"; diff --git a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings index 5835cc170..2a0d7928c 100644 --- a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings +++ b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Unpin"; "modal_call_missed_tips_title" = "Call missed"; "modal_call_missed_tips_explanation" = "Call missed from '%@' because you needed to enable the 'Voice and video calls' permission in the Privacy Settings."; -"meida_saved" = "Media saved by %@."; +"media_saved" = "Media saved by %@."; "screenshot_taken" = "%@ took a screenshot."; "SEARCH_SECTION_CONTACTS" = "Contacts and Groups"; "SEARCH_SECTION_MESSAGES" = "Messages"; diff --git a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings index 333724265..87e8a6c3e 100644 --- a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "取消置頂"; "modal_call_missed_tips_title" = "未接來電"; "modal_call_missed_tips_explanation" = "Call missed from '%@' because you needed to enable the 'Voice and video calls' permission in the Privacy Settings."; -"meida_saved" = "%@ 儲存了媒體"; +"media_saved" = "%@ 儲存了媒體"; "screenshot_taken" = "%@ 擷取了螢幕畫面"; "SEARCH_SECTION_CONTACTS" = "Contacts and Groups"; "SEARCH_SECTION_MESSAGES" = "Messages"; diff --git a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings index 637d27c67..46cad399f 100644 --- a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "取消置顶"; "modal_call_missed_tips_title" = "未接来电"; "modal_call_missed_tips_explanation" = "未接听 '%@',因为您需要在隐私设置中启用“语音和视频通话”权限。"; -"meida_saved" = "%@ 保存了媒体内容。"; +"media_saved" = "%@ 保存了媒体内容。"; "screenshot_taken" = "%@ 进行了截图。"; "SEARCH_SECTION_CONTACTS" = "联系人和群组"; "SEARCH_SECTION_MESSAGES" = "消息"; diff --git a/SessionMessagingKit/Common Networking/Header.swift b/SessionMessagingKit/Common Networking/Header.swift index 97ea01ef7..6c33e41a3 100644 --- a/SessionMessagingKit/Common Networking/Header.swift +++ b/SessionMessagingKit/Common Networking/Header.swift @@ -7,8 +7,6 @@ enum Header: String { case contentType = "Content-Type" case contentDisposition = "Content-Disposition" - case room = "Room" // TODO: Confirm this is needed - case sogsPubKey = "X-SOGS-Pubkey" case sogsNonce = "X-SOGS-Nonce" case sogsTimestamp = "X-SOGS-Timestamp" diff --git a/SessionMessagingKit/Contacts/BlindedIdMapping.swift b/SessionMessagingKit/Contacts/BlindedIdMapping.swift deleted file mode 100644 index 5b289c96b..000000000 --- a/SessionMessagingKit/Contacts/BlindedIdMapping.swift +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -@objc(SNBlindedIdMapping) -public final class BlindedIdMapping: NSObject, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility - @objc public let blindedId: String - @objc public let sessionId: String - @objc public let serverPublicKey: String - - // MARK: - Initialization - - @objc public init(blindedId: String, sessionId: String, serverPublicKey: String) { - self.blindedId = blindedId - self.sessionId = sessionId - self.serverPublicKey = serverPublicKey - - super.init() - } - - private override init() { preconditionFailure("Use init(blindedId:sessionId:) instead.") } - - // MARK: - Coding - - public required init?(coder: NSCoder) { - guard let blindedId: String = coder.decodeObject(forKey: "blindedId") as! String? else { return nil } - guard let sessionId: String = coder.decodeObject(forKey: "sessionId") as! String? else { return nil } - guard let serverPublicKey: String = coder.decodeObject(forKey: "serverPublicKey") as! String? else { return nil } - - self.blindedId = blindedId - self.sessionId = sessionId - self.serverPublicKey = serverPublicKey - } - - public func encode(with coder: NSCoder) { - coder.encode(blindedId, forKey: "blindedId") - coder.encode(sessionId, forKey: "sessionId") - coder.encode(serverPublicKey, forKey: "serverPublicKey") - } -} diff --git a/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift index ac82f040a..b488653b8 100644 --- a/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -48,11 +48,8 @@ enum _002_SetupStandardJobs: Migration { _ = try Job( variant: .garbageCollection, - behaviour: .recurringOnActive, - details: GarbageCollectionJob.Details( - typesToCollect: GarbageCollectionJob.Types.allCases - ) - )?.inserted(db) + behaviour: .recurringOnActive + ).inserted(db) } GRDBStorage.update(progress: 1, for: self, in: target) // In case this is the last migration diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 0ef03a903..ec5db6db8 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -841,7 +841,6 @@ enum _003_YDBToGRDBMigration: Migration { } default: - // TODO: What message types have no body? SNLog("[Migration Error] Unsupported interaction type") throw StorageError.migrationFailed } @@ -926,7 +925,6 @@ enum _003_YDBToGRDBMigration: Migration { receivedMessageTimestamps.remove(legacyInteraction.timestamp) guard let interactionId: Int64 = interaction.id else { - // TODO: Is it possible the old database has duplicates which could hit this case? SNLog("[Migration Error] Failed to insert interaction") throw StorageError.migrationFailed } @@ -1072,13 +1070,9 @@ enum _003_YDBToGRDBMigration: Migration { // Note: The `legacyInteraction.timestamp` value is in milliseconds let timestamp: TimeInterval = LinkPreview.timestampFor(sentTimestampMs: Double(legacyInteraction.timestamp)) - guard linkPreview.imageAttachmentId == nil || attachments[linkPreview.imageAttachmentId ?? ""] != nil else { - // TODO: Is it possible to hit this case if a quoted attachment hasn't been downloaded? - SNLog("[Migration Error] Missing link preview attachment") - throw StorageError.migrationFailed - } - - // Setup the attachment and add it to the lookup (if it exists) + // Setup the attachment and add it to the lookup (if it exists - we do actually + // support link previews with no image attachments so no need to throw migration + // errors in those cases) let attachmentId: String? = try attachmentId( db, for: linkPreview.imageAttachmentId, @@ -1100,15 +1094,28 @@ enum _003_YDBToGRDBMigration: Migration { // Handle any attachments try attachmentIds.enumerated().forEach { index, legacyAttachmentId in - guard let attachmentId: String = try attachmentId( + let maybeAttachmentId: String? = (try attachmentId( db, for: legacyAttachmentId, interactionVariant: variant, attachments: attachments, processedAttachmentIds: &processedAttachmentIds - ) else { - SNLog("[Migration Error] Missing interaction attachment") -// throw StorageError.migrationFailed + )) + .defaulting( + // It looks like somehow messages could exist in the old database which + // referenced attachments but had no attachments in the database; doing + // nothing here results in these messages appearing as empty message + // bubbles so instead we want to insert invalid attachments instead + to: try invalidAttachmentId( + db, + for: legacyAttachmentId, + attachments: attachments, + processedAttachmentIds: &processedAttachmentIds + ) + ) + + guard let attachmentId: String = maybeAttachmentId else { + SNLog("[Migration Warning] Failed to create invalid attachment for missing attachment") return } @@ -1457,7 +1464,7 @@ enum _003_YDBToGRDBMigration: Migration { } guard let legacyAttachment: SMKLegacy._Attachment = attachments[legacyAttachmentId] else { - SNLog("[Migration Warning] Missing attachment - interaction will appear as blank") + SNLog("[Migration Warning] Missing attachment - interaction will show a \"failed\" attachment") return nil } @@ -1589,6 +1596,45 @@ enum _003_YDBToGRDBMigration: Migration { return legacyAttachmentId } + private static func invalidAttachmentId( + _ db: Database, + for legacyAttachmentId: String, + interactionVariant: Interaction.Variant? = nil, + attachments: [String: SMKLegacy._Attachment], + processedAttachmentIds: inout Set + ) throws -> String { + guard !processedAttachmentIds.contains(legacyAttachmentId) else { + return legacyAttachmentId + } + + _ = try Attachment( + // Note: The legacy attachment object used a UUID string for it's id as well + // and saved files using these id's so just used the existing id so we don't + // need to bother renaming files as part of the migration + id: legacyAttachmentId, + serverId: nil, + variant: .standard, + state: .invalid, + contentType: "", + byteCount: 0, + creationTimestamp: Date().timeIntervalSince1970, + sourceFilename: nil, + downloadUrl: nil, + localRelativeFilePath: nil, + width: nil, + height: nil, + duration: nil, + isValid: false, + encryptionKey: nil, + digest: nil, + caption: nil + ).inserted(db) + + processedAttachmentIds.insert(legacyAttachmentId) + + return legacyAttachmentId + } + private static func mapLegacyTypesForNSKeyedUnarchiver() { NSKeyedUnarchiver.setClass( SMKLegacy._Thread.self, diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index c04ea6aef..d0201658a 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -56,6 +56,8 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR case failedUpload case uploading case uploaded + + case invalid = 100 } /// A unique identifier for the attachment @@ -939,6 +941,8 @@ extension Attachment { return } + let attachmentId: String = self.id + // If the attachment is a downloaded attachment, check if it came from the server // and if so just succeed immediately (no use re-uploading an attachment that is // already present on the server) - or if we want it to be encrypted and it's not @@ -956,16 +960,20 @@ extension Attachment { // Save the final upload info let uploadedAttachment: Attachment? = { guard let db: Database = db else { - return GRDBStorage.shared.write { db in - try? self - .with(state: .uploaded) - .saved(db) + GRDBStorage.shared.write { db in + try? Attachment + .filter(id: attachmentId) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploaded)) } + + return self.with(state: .uploaded) } - return try? self - .with(state: .uploaded) - .saved(db) + _ = try? Attachment + .filter(id: attachmentId) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploaded)) + + return self.with(state: .uploaded) }() guard uploadedAttachment != nil else { @@ -1008,16 +1016,20 @@ extension Attachment { // Update the attachment to the 'uploading' state let updatedAttachment: Attachment? = { guard let db: Database = db else { - return GRDBStorage.shared.write { db in - try? processedAttachment - .with(state: .uploading) - .saved(db) + GRDBStorage.shared.write { db in + try? Attachment + .filter(id: attachmentId) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploading)) } + + return processedAttachment.with(state: .uploading) } - return try? processedAttachment - .with(state: .uploading) - .saved(db) + _ = try? Attachment + .filter(id: attachmentId) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploading)) + + return processedAttachment.with(state: .uploading) }() guard updatedAttachment != nil else { @@ -1062,9 +1074,9 @@ extension Attachment { } .catch(on: queue) { error in GRDBStorage.shared.write { db in - try updatedAttachment? - .with(state: .failedUpload) - .saved(db) + try Attachment + .filter(id: attachmentId) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedUpload)) } failure?(error) diff --git a/SessionMessagingKit/Database/Models/ClosedGroup.swift b/SessionMessagingKit/Database/Models/ClosedGroup.swift index dcd4b9e81..48be3511a 100644 --- a/SessionMessagingKit/Database/Models/ClosedGroup.swift +++ b/SessionMessagingKit/Database/Models/ClosedGroup.swift @@ -78,18 +78,6 @@ public struct ClosedGroup: Codable, Identifiable, FetchableRecord, PersistableRe } } -// MARK: - Mutation - -public extension ClosedGroup { - func with(name: String) -> ClosedGroup { - return ClosedGroup( - threadId: threadId, - name: name, - formationTimestamp: formationTimestamp - ) - } -} - // MARK: - GRDB Interactions public extension ClosedGroup { diff --git a/SessionMessagingKit/Database/Models/LinkPreview.swift b/SessionMessagingKit/Database/Models/LinkPreview.swift index 03cb18f66..6aea5fa3b 100644 --- a/SessionMessagingKit/Database/Models/LinkPreview.swift +++ b/SessionMessagingKit/Database/Models/LinkPreview.swift @@ -79,13 +79,8 @@ public struct LinkPreview: Codable, Equatable, Hashable, FetchableRecord, Persis public extension LinkPreview { init?(_ db: Database, proto: SNProtoDataMessage, body: String?, sentTimestampMs: TimeInterval) throws { guard let previewProto = proto.preview.first else { throw LinkPreviewError.noPreview } - guard proto.attachments.count < 1 else { throw LinkPreviewError.invalidInput } guard URL(string: previewProto.url) != nil else { throw LinkPreviewError.invalidInput } guard LinkPreview.isValidLinkUrl(previewProto.url) else { throw LinkPreviewError.invalidInput } - guard let body: String = body else { throw LinkPreviewError.invalidInput } - guard LinkPreview.allPreviewUrls(forMessageBodyText: body).contains(previewProto.url) else { - throw LinkPreviewError.invalidInput - } // Try to get an existing link preview first let timestamp: TimeInterval = LinkPreview.timestampFor(sentTimestampMs: sentTimestampMs) diff --git a/SessionMessagingKit/Database/Models/OpenGroup.swift b/SessionMessagingKit/Database/Models/OpenGroup.swift index 0ab4a96ce..da3b236cc 100644 --- a/SessionMessagingKit/Database/Models/OpenGroup.swift +++ b/SessionMessagingKit/Database/Models/OpenGroup.swift @@ -151,7 +151,7 @@ public extension OpenGroup { roomToken: roomToken, publicKey: publicKey, isActive: false, - name: "", + name: roomToken, // Default the name to the `roomToken` until we get retrieve the actual name roomDescription: nil, imageId: nil, imageData: nil, diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index 977eb4541..328de9fe6 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -200,7 +200,6 @@ public extension Profile { public extension Profile { func with( name: String? = nil, - nickname: Updatable = .existing, profilePictureUrl: Updatable = .existing, profilePictureFileName: Updatable = .existing, profileEncryptionKey: Updatable = .existing @@ -208,7 +207,7 @@ public extension Profile { return Profile( id: id, name: (name ?? self.name), - nickname: (nickname ?? self.nickname), + nickname: self.nickname, profilePictureUrl: (profilePictureUrl ?? self.profilePictureUrl), profilePictureFileName: (profilePictureFileName ?? self.profilePictureFileName), profileEncryptionKey: (profileEncryptionKey ?? self.profileEncryptionKey) @@ -406,9 +405,9 @@ public class SMKProfile: NSObject { let profile: Profile = Profile.fetchOrCreate(id: profileId) let targetNickname: String? = ((nickname ?? "").count > 0 ? nickname : nil) - _ = try profile - .with(nickname: .update(targetNickname)) - .saved(db) + try Profile + .filter(id: profile.id) + .updateAll(db, Profile.Columns.nickname.set(to: targetNickname)) return (targetNickname ?? profile.name) } diff --git a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift index 2c9755347..9fa244677 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift @@ -7,7 +7,7 @@ import SessionSnodeKit import SignalCoreKit public enum AttachmentDownloadJob: JobExecutor { - public static var maxFailureCount: Int = 10 + public static var maxFailureCount: Int = 3 public static var requiresThreadId: Bool = true public static let requiresInteractionId: Bool = true @@ -30,13 +30,50 @@ public enum AttachmentDownloadJob: JobExecutor { } // Due to the complex nature of jobs and how attachments can be reused it's possible for - // and AttachmentDownloadJob to get created for an attachment which has already been + // an AttachmentDownloadJob to get created for an attachment which has already been // downloaded/uploaded so in those cases just succeed immediately guard attachment.state != .downloaded && attachment.state != .uploaded else { success(job, false) return } + // If we ever make attachment downloads concurrent this will prevent us from downloading + // the same attachment multiple times at the same time (it also adds a "clean up" mechanism + // if an attachment ends up stuck in a "downloading" state incorrectly + guard attachment.state != .downloading else { + let otherCurrentJobAttachmentIds: Set = JobRunner + .defailsForCurrentlyRunningJobs(of: .attachmentDownload) + .filter { key, _ in key != job.id } + .values + .compactMap { data -> String? in + guard let data: Data = data else { return nil } + + return (try? JSONDecoder().decode(Details.self, from: data))? + .attachmentId + } + .asSet() + + // If there isn't another currently running attachmentDownload job downloading this attachment + // then we should update the state of the attachment to be failed to avoid having attachments + // appear in an endlessly downloading state + if !otherCurrentJobAttachmentIds.contains(attachment.id) { + GRDBStorage.shared.write { db in + _ = try Attachment + .filter(id: attachment.id) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedDownload)) + } + } + + // Note: The only ways we should be able to get into this state are if we enable concurrent + // downloads or if the app was closed/crashed while an attachmentDownload job was in progress + // + // If there is another current job then just fail this one permanently, otherwise let it + // retry (if there are more retry attempts available) and in the next retry it's state should + // be 'failedDownload' so we won't get stuck in a loop + failure(job, nil, otherCurrentJobAttachmentIds.contains(attachment.id)) + return + } + // Update to the 'downloading' state (no need to update the 'attachment' instance) GRDBStorage.shared.write { db in try Attachment @@ -123,25 +160,43 @@ public enum AttachmentDownloadJob: JobExecutor { .catch(on: queue) { error in OWSFileSystem.deleteFile(temporaryFileUrl.path) + let targetState: Attachment.State + let permanentFailure: Bool + switch error { - case OnionRequestAPIError.httpRequestFailedAtDestination(let statusCode, _, _) where statusCode == 400: - /// Otherwise, the attachment will show a state of downloading forever, and the message - /// won't be able to be marked as read - /// - /// **Note:** We **MUST** use the `'with()` function here as it will update the - /// `isValid` and `duration` values based on the downloaded data and the state - GRDBStorage.shared.write { db in - _ = try attachment - .with(state: .failedDownload) - .saved(db) - } - - // This usually indicates a file that has expired on the server, so there's no need to retry - failure(job, error, true) + /// If we get a 404 then we got a successful response from the server but the attachment doesn't + /// exist, in this case update the attachment to an "invalid" state so the user doesn't get stuck in + /// a retry download loop + case OnionRequestAPIError.httpRequestFailedAtDestination(let statusCode, _, _) where statusCode == 404: + targetState = .invalid + permanentFailure = true + case OnionRequestAPIError.httpRequestFailedAtDestination(let statusCode, _, _) where statusCode == 400 || statusCode == 401: + /// If we got a 400 or a 401 then we want to fail the download in a way that has to be manually retried as it's + /// likely something else is going on that caused the failure + targetState = .failedDownload + permanentFailure = true + + /// For any other error it's likely either the server is down or something weird just happened with the request + /// so we want to automatically retry default: - failure(job, error, false) + targetState = .failedDownload + permanentFailure = false } + + /// To prevent the attachment from showing a state of downloading forever, we need to update the attachment + /// state here based on the type of error that occurred + /// + /// **Note:** We **MUST** use the `'with()` function here as it will update the + /// `isValid` and `duration` values based on the downloaded data and the state + GRDBStorage.shared.write { db in + _ = try Attachment + .filter(id: attachment.id) + .updateAll(db, Attachment.Columns.state.set(to: targetState)) + } + + /// Trigger the failure and provide the `permanentFailure` value defined above + failure(job, error, permanentFailure) } } } diff --git a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift index 78225cf0c..dd89ee21b 100644 --- a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift +++ b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift @@ -7,6 +7,10 @@ import SignalCoreKit import SessionUtilitiesKit import SessionSnodeKit +/// This job deletes unused and orphaned data from the database as well as orphaned files from device storage +/// +/// **Note:** When sheduling this job if no `Details` are provided (with a list of `typesToCollect`) then this job will +/// assume that it should be collecting all `Types` public enum GarbageCollectionJob: JobExecutor { public static var maxFailureCount: Int = -1 public static var requiresThreadId: Bool = false @@ -20,39 +24,33 @@ public enum GarbageCollectionJob: JobExecutor { failure: @escaping (Job, Error?, Bool) -> (), deferred: @escaping (Job) -> () ) { - guard - let detailsData: Data = job.details, - let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData) - else { - failure(job, JobRunnerError.missingRequiredDetails, false) - return - } - - // If there are no types to collect then complete the job (and never run again - it doesn't do anything) - guard !details.typesToCollect.isEmpty else { - success(job, true) - return - } - + /// Determine what types of data we want to collect (if we didn't provide any then assume we want to collect everything) + /// + /// **Note:** The reason we default to handle all cases (instead of just doing nothing in that case) is so the initial registration + /// of the garbageCollection job never needs to be updated as we continue to add more types going forward + let typesToCollect: [Types] = (job.details + .map { try? JSONDecoder().decode(Details.self, from: $0) }? + .typesToCollect) + .defaulting(to: Types.allCases) let timestampNow: TimeInterval = Date().timeIntervalSince1970 GRDBStorage.shared.writeAsync( updates: { db in /// Remove any expired controlMessageProcessRecords - if details.typesToCollect.contains(.expiredControlMessageProcessRecords) { + if typesToCollect.contains(.expiredControlMessageProcessRecords) { _ = try ControlMessageProcessRecord .filter(ControlMessageProcessRecord.Columns.serverExpirationTimestamp <= timestampNow) .deleteAll(db) } /// Remove any typing indicators - if details.typesToCollect.contains(.threadTypingIndicators) { + if typesToCollect.contains(.threadTypingIndicators) { _ = try ThreadTypingIndicator .deleteAll(db) } /// Remove any old open group messages - open group messages which are older than six months - if details.typesToCollect.contains(.oldOpenGroupMessages) && db[.trimOpenGroupMessagesOlderThanSixMonths] { + if typesToCollect.contains(.oldOpenGroupMessages) && db[.trimOpenGroupMessagesOlderThanSixMonths] { let interaction: TypedTableAlias = TypedTableAlias() let thread: TypedTableAlias = TypedTableAlias() @@ -71,7 +69,7 @@ public enum GarbageCollectionJob: JobExecutor { } /// Orphaned jobs - jobs which have had their threads or interactions removed - if details.typesToCollect.contains(.orphanedJobs) { + if typesToCollect.contains(.orphanedJobs) { let job: TypedTableAlias = TypedTableAlias() let thread: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() @@ -97,7 +95,7 @@ public enum GarbageCollectionJob: JobExecutor { } /// Orphaned link previews - link previews which have no interactions with matching url & rounded timestamps - if details.typesToCollect.contains(.orphanedLinkPreviews) { + if typesToCollect.contains(.orphanedLinkPreviews) { let linkPreview: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() @@ -117,7 +115,7 @@ public enum GarbageCollectionJob: JobExecutor { /// Orphaned open groups - open groups which are no longer associated to a thread (except for the session-run ones for which /// we want cached image data even if the user isn't in the group) - if details.typesToCollect.contains(.orphanedOpenGroups) { + if typesToCollect.contains(.orphanedOpenGroups) { let openGroup: TypedTableAlias = TypedTableAlias() let thread: TypedTableAlias = TypedTableAlias() @@ -136,7 +134,7 @@ public enum GarbageCollectionJob: JobExecutor { } /// Orphaned open group capabilities - capabilities which have no existing open groups with the same server - if details.typesToCollect.contains(.orphanedOpenGroupCapabilities) { + if typesToCollect.contains(.orphanedOpenGroupCapabilities) { let capability: TypedTableAlias = TypedTableAlias() let openGroup: TypedTableAlias = TypedTableAlias() @@ -152,7 +150,7 @@ public enum GarbageCollectionJob: JobExecutor { } /// Orphaned blinded id lookups - lookups which have no existing threads or approval/block settings for either blinded/un-blinded id - if details.typesToCollect.contains(.orphanedBlindedIdLookups) { + if typesToCollect.contains(.orphanedBlindedIdLookups) { let blindedIdLookup: TypedTableAlias = TypedTableAlias() let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() @@ -178,8 +176,28 @@ public enum GarbageCollectionJob: JobExecutor { """) } + /// Approved blinded contact records - once a blinded contact has been approved there is no need to keep the blinded + /// contact record around anymore + if typesToCollect.contains(.approvedBlindedContactRecords) { + let contact: TypedTableAlias = TypedTableAlias() + let blindedIdLookup: TypedTableAlias = TypedTableAlias() + + try db.execute(literal: """ + DELETE FROM \(Contact.self) + WHERE \(Column.rowID) IN ( + SELECT \(contact.alias[Column.rowID]) + FROM \(Contact.self) + LEFT JOIN \(BlindedIdLookup.self) ON ( + \(blindedIdLookup[.blindedId]) = \(contact[.id]) AND + \(blindedIdLookup[.sessionId]) IS NOT NULL + ) + WHERE \(blindedIdLookup[.sessionId]) IS NOT NULL + ) + """) + } + /// Orphaned attachments - attachments which have no related interactions, quotes or link previews - if details.typesToCollect.contains(.orphanedAttachments) { + if typesToCollect.contains(.orphanedAttachments) { let attachment: TypedTableAlias = TypedTableAlias() let quote: TypedTableAlias = TypedTableAlias() let linkPreview: TypedTableAlias = TypedTableAlias() @@ -216,7 +234,7 @@ public enum GarbageCollectionJob: JobExecutor { var profileAvatarFilenames: Set = [] /// Orphaned attachment files - attachment files which don't have an associated record in the database - if details.typesToCollect.contains(.orphanedAttachmentFiles) { + if typesToCollect.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) @@ -229,7 +247,7 @@ public enum GarbageCollectionJob: JobExecutor { } /// Orphaned profile avatar files - profile avatar files which don't have an associated record in the database - if details.typesToCollect.contains(.orphanedProfileAvatars) { + if typesToCollect.contains(.orphanedProfileAvatars) { profileAvatarFilenames = try Profile .select(.profilePictureFileName) .filter(Profile.Columns.profilePictureFileName != nil) @@ -252,7 +270,7 @@ public enum GarbageCollectionJob: JobExecutor { var deletionErrors: [Error] = [] // Orphaned attachment files (actual deletion) - if details.typesToCollect.contains(.orphanedAttachmentFiles) { + if typesToCollect.contains(.orphanedAttachmentFiles) { // Note: Looks like in order to recursively look through files we need to use the // enumerator method let fileEnumerator = FileManager.default.enumerator( @@ -294,7 +312,7 @@ public enum GarbageCollectionJob: JobExecutor { } // Orphaned profile avatar files (actual deletion) - if details.typesToCollect.contains(.orphanedProfileAvatars) { + if typesToCollect.contains(.orphanedProfileAvatars) { let allAvatarProfileFilenames: Set = (try? FileManager.default .contentsOfDirectory(atPath: ProfileManager.sharedDataProfileAvatarsDirPath)) .defaulting(to: []) @@ -339,6 +357,7 @@ extension GarbageCollectionJob { case orphanedOpenGroups case orphanedOpenGroupCapabilities case orphanedBlindedIdLookups + case approvedBlindedContactRecords case orphanedAttachments case orphanedAttachmentFiles case orphanedProfileAvatars diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift index d398e4b47..a9e24c8f7 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift @@ -212,9 +212,9 @@ extension MessageReceiver { guard case let .nameChange(name) = message.kind else { return } try performIfValid(db, message: message) { id, sender, thread, closedGroup in - try closedGroup - .with(name: name) - .save(db) + _ = try ClosedGroup + .filter(id: id) + .updateAll(db, ClosedGroup.Columns.name.set(to: name)) // Notify the user if needed guard name != closedGroup.name else { return } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index 97806cff0..4692851d9 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -204,20 +204,20 @@ extension MessageReceiver { message.attachmentIds = attachments.map { $0.id } // Persist quote if needed - let quote: Quote? = try? Quote( + try? Quote( db, proto: dataMessage, interactionId: interactionId, thread: thread - )?.inserted(db) + )?.insert(db) // Parse link preview if needed - let linkPreview: LinkPreview? = try? LinkPreview( + try? LinkPreview( db, proto: dataMessage, body: message.text, sentTimestampMs: (messageSentTimestamp * 1000) - )?.saved(db) + )?.save(db) // Open group invitations are stored as LinkPreview values so create one if needed if @@ -232,30 +232,6 @@ extension MessageReceiver { ).save(db) } - // Start attachment downloads if needed (ie. trusted contact or group thread) - let isContactTrusted: Bool = ((try? Contact.fetchOne(db, id: sender))?.isTrusted ?? false) - - if isContactTrusted || thread.variant != .contact { - attachments - .map { $0.id } - .appending(quote?.attachmentId) - .appending(linkPreview?.attachmentId) - .forEach { attachmentId in - JobRunner.add( - db, - job: Job( - variant: .attachmentDownload, - threadId: thread.id, - interactionId: interactionId, - details: AttachmentDownloadJob.Details( - attachmentId: attachmentId - ) - ), - canStartJob: isMainAppActive - ) - } - } - // Cancel any typing indicators if needed if isMainAppActive { TypingIndicators.didStopTyping(db, threadId: thread.id, direction: .incoming) diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift index d9332c814..8bf2d87dd 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift @@ -216,8 +216,9 @@ extension MessageSender { // Update name if needed if name != closedGroup.name { // Update the group - let updatedClosedGroup: ClosedGroup = closedGroup.with(name: name) - try updatedClosedGroup.save(db) + _ = try ClosedGroup + .filter(id: closedGroup.id) + .updateAll(db, ClosedGroup.Columns.name.set(to: name)) // Notify the user let interaction: Interaction = try Interaction( diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index 0c5ceeb6b..bde98e927 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -165,6 +165,7 @@ extension MessageSender { } if let error: Error = errors.first { return Promise(error: error) } + return GRDBStorage.shared.writeAsync { db in try MessageSender.sendImmediate( db, diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index f470754c0..dde2ee13e 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -646,21 +646,15 @@ public final class MessageSender { with error: MessageSenderError, interactionId: Int64? ) { - guard let interaction: Interaction = try? interaction(db, for: message, interactionId: interactionId) else { - return - } - // Mark any "sending" recipients as "failed" - try? interaction.recipientStates - .fetchAll(db) - .forEach { oldState in - guard oldState.state == .sending else { return } - - try? oldState.with( - state: .failed, - mostRecentFailureText: error.localizedDescription - ).save(db) - } + _ = try? RecipientState + .filter(RecipientState.Columns.interactionId == interactionId) + .filter(RecipientState.Columns.state == RecipientState.State.sending) + .updateAll( + db, + RecipientState.Columns.state.set(to: RecipientState.State.failed), + RecipientState.Columns.mostRecentFailureText.set(to: error.localizedDescription) + ) } // MARK: - Convenience diff --git a/SessionMessagingKit/Utilities/ProfileManager.swift b/SessionMessagingKit/Utilities/ProfileManager.swift index 992fdaaa6..4604cf104 100644 --- a/SessionMessagingKit/Utilities/ProfileManager.swift +++ b/SessionMessagingKit/Utilities/ProfileManager.swift @@ -180,9 +180,9 @@ public struct ProfileManager { return } - try? latestProfile - .with(profilePictureFileName: .update(fileName)) - .update(db) + _ = try? Profile + .filter(id: profile.id) + .updateAll(db, Profile.Columns.profilePictureFileName.set(to: fileName)) profileAvatarCache.mutate { $0[fileName] = image } } diff --git a/SessionMessagingKitTests/Contacts/BlindedIdLookupSpec.swift b/SessionMessagingKitTests/Contacts/BlindedIdLookupSpec.swift new file mode 100644 index 000000000..4cb4b4cba --- /dev/null +++ b/SessionMessagingKitTests/Contacts/BlindedIdLookupSpec.swift @@ -0,0 +1,32 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class BlindedIdLookupSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + describe("a BlindedIdLookup") { + context("when initializing") { + it("sets the values correctly") { + let lookup: BlindedIdLookup = BlindedIdLookup( + blindedId: "testBlindedId", + sessionId: "testSessionId", + openGroupServer: "testServer", + openGroupPublicKey: "testPublicKey" + ) + + expect(lookup.blindedId).to(equal("testBlindedId")) + expect(lookup.sessionId).to(equal("testSessionId")) + expect(lookup.openGroupServer).to(equal("testServer")) + expect(lookup.openGroupPublicKey).to(equal("testPublicKey")) + } + } + } + } +} diff --git a/SessionMessagingKitTests/Contacts/BlindedIdMappingSpec.swift b/SessionMessagingKitTests/Contacts/BlindedIdMappingSpec.swift deleted file mode 100644 index 3c2c26d40..000000000 --- a/SessionMessagingKitTests/Contacts/BlindedIdMappingSpec.swift +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -import Quick -import Nimble - -@testable import SessionMessagingKit - -class BlindedIdMappingSpec: QuickSpec { - // MARK: - Spec - - override func spec() { - describe("a BlindedIdMapping") { - context("when initializing") { - it("sets the values correctly") { - let mapping: BlindedIdMapping = BlindedIdMapping( - blindedId: "testBlindedId", - sessionId: "testSessionId", - serverPublicKey: "testPublicKey" - ) - - expect(mapping.blindedId).to(equal("testBlindedId")) - expect(mapping.sessionId).to(equal("testSessionId")) - expect(mapping.serverPublicKey).to(equal("testPublicKey")) - } - } - - context("when NSCoding") { - // Note: Unit testing NSCoder is horrible so we won't do it properly - wait until we refactor it to Codable - it("successfully encodes and decodes") { - let mappingToEncode: BlindedIdMapping = BlindedIdMapping( - blindedId: "testBlindedId", - sessionId: "testSessionId", - serverPublicKey: "testPublicKey" - ) - let encodedData: Data = try! NSKeyedArchiver.archivedData(withRootObject: mappingToEncode, requiringSecureCoding: false) - let mapping: BlindedIdMapping? = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(encodedData) as? BlindedIdMapping - - expect(mapping).toNot(beNil()) - expect(mapping?.blindedId).to(equal("testBlindedId")) - expect(mapping?.sessionId).to(equal("testSessionId")) - expect(mapping?.serverPublicKey).to(equal("testPublicKey")) - } - } - } - } -} diff --git a/SessionUtilitiesKit/General/Dictionary+Utilities.swift b/SessionUtilitiesKit/General/Dictionary+Utilities.swift index 1b3c918f9..5ac19dcd2 100644 --- a/SessionUtilitiesKit/General/Dictionary+Utilities.swift +++ b/SessionUtilitiesKit/General/Dictionary+Utilities.swift @@ -28,7 +28,9 @@ public extension Dictionary.Values { // MARK: - Functional Convenience public extension Dictionary { - func setting(_ key: Key, _ value: Value?) -> [Key: Value] { + func setting(_ key: Key?, _ value: Value?) -> [Key: Value] { + guard let key: Key = key else { return self } + var updatedDictionary: [Key: Value] = self updatedDictionary[key] = value @@ -45,7 +47,9 @@ public extension Dictionary { return updatedDictionary } - func removingValue(forKey key: Key) -> [Key: Value] { + func removingValue(forKey key: Key?) -> [Key: Value] { + guard let key: Key = key else { return self } + var updatedDictionary: [Key: Value] = self updatedDictionary.removeValue(forKey: key) diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index 2ab114973..2e6055d93 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -285,6 +285,11 @@ public final class JobRunner { return (queues.wrappedValue[job.variant]?.isCurrentlyRunning(jobId) == true) } + public static func defailsForCurrentlyRunningJobs(of variant: Job.Variant) -> [Int64: Data?] { + return (queues.wrappedValue[variant]?.detailsForAllCurrentlyRunningJobs()) + .defaulting(to: [:]) + } + public static func hasPendingOrRunningJob(with variant: Job.Variant, details: T) -> Bool { guard let targetQueue: JobQueue = queues.wrappedValue[variant] else { return false } guard let detailsData: Data = try? JSONEncoder().encode(details) else { return false } @@ -396,6 +401,7 @@ private final class JobQueue { fileprivate var isRunning: Atomic = Atomic(false) private var queue: Atomic<[Job]> = Atomic([]) private var jobsCurrentlyRunning: Atomic> = Atomic([]) + private var detailsForCurrentlyRunningJobs: Atomic<[Int64: Data?]> = Atomic([:]) fileprivate var hasPendingJobs: Bool { !queue.wrappedValue.isEmpty } @@ -505,6 +511,10 @@ private final class JobQueue { return jobsCurrentlyRunning.wrappedValue.contains(jobId) } + fileprivate func detailsForAllCurrentlyRunningJobs() -> [Int64: Data?] { + return detailsForCurrentlyRunningJobs.wrappedValue + } + fileprivate func hasPendingOrRunningJob(with detailsData: Data?) -> Bool { let pendingJobs: [Job] = queue.wrappedValue @@ -683,6 +693,7 @@ private final class JobQueue { jobsCurrentlyRunning = jobsCurrentlyRunning.inserting(nextJob.id) numJobsRunning = jobsCurrentlyRunning.count } + detailsForCurrentlyRunningJobs.mutate { $0 = $0.setting(nextJob.id, nextJob.details) } SNLog("[JobRunner] \(queueContext) started job (\(executionType == .concurrent ? "\(numJobsRunning) currently running, " : "")\(numJobsRemaining) remaining)") jobExecutor.run( @@ -817,6 +828,7 @@ private final class JobQueue { // The job is removed from the queue before it runs so all we need to to is remove it // from the 'currentlyRunning' set and start the next one jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) } + detailsForCurrentlyRunningJobs.mutate { $0 = $0.removingValue(forKey: job.id) } internalQueue.async { [weak self] in self?.runNextJob() } @@ -828,6 +840,7 @@ private final class JobQueue { guard GRDBStorage.shared.read({ db in try Job.exists(db, id: job.id ?? -1) }) == true else { SNLog("[JobRunner] \(queueContext) \(job.variant) job canceled") jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) } + detailsForCurrentlyRunningJobs.mutate { $0 = $0.removingValue(forKey: job.id) } internalQueue.async { [weak self] in self?.runNextJob() @@ -839,6 +852,7 @@ private final class JobQueue { if self.type == .blocking && job.shouldBlockFirstRunEachSession { SNLog("[JobRunner] \(queueContext) \(job.variant) job failed; retrying immediately") jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) } + detailsForCurrentlyRunningJobs.mutate { $0 = $0.removingValue(forKey: job.id) } queue.mutate { $0.insert(job, at: 0) } internalQueue.async { [weak self] in @@ -915,6 +929,7 @@ private final class JobQueue { } jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) } + detailsForCurrentlyRunningJobs.mutate { $0 = $0.removingValue(forKey: job.id) } internalQueue.async { [weak self] in self?.runNextJob() } @@ -924,6 +939,7 @@ private final class JobQueue { /// on other jobs, and it should automatically manage those dependencies) private func handleJobDeferred(_ job: Job) { jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) } + detailsForCurrentlyRunningJobs.mutate { $0 = $0.removingValue(forKey: job.id) } internalQueue.async { [weak self] in self?.runNextJob() } From 8cf2a57fcc6719200d77e975e2155152d5bab56d Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 1 Jul 2022 13:08:45 +1000 Subject: [PATCH 124/157] Renamed GRDBStorage to Storage (no use having the prefix anymore since the old DB is deprecated) --- Session.xcodeproj/project.pbxproj | 8 +-- .../Calls/Call Management/SessionCall.swift | 8 +-- .../SessionCallManager+Action.swift | 2 +- .../Call Management/SessionCallManager.swift | 2 +- Session/Closed Groups/EditClosedGroupVC.swift | 6 +- Session/Closed Groups/NewClosedGroupVC.swift | 4 +- .../Conversations/ConversationSearch.swift | 2 +- .../ConversationVC+Interaction.swift | 56 ++++++++-------- Session/Conversations/ConversationVC.swift | 10 +-- .../Conversations/ConversationViewModel.swift | 12 ++-- .../Conversations/Input View/InputView.swift | 2 +- .../Message Cells/CallMessageCell.swift | 4 +- .../Views & Modals/BlockedModal.swift | 2 +- .../Views & Modals/CallModal.swift | 2 +- .../DownloadAttachmentModal.swift | 2 +- .../Views & Modals/JoinOpenGroupModal.swift | 4 +- .../Views & Modals/LinkPreviewModal.swift | 2 +- Session/DMs/NewDMVC.swift | 2 +- .../GlobalSearchViewController.swift | 4 +- Session/Home/HomeVC.swift | 12 ++-- Session/Home/HomeViewModel.swift | 6 +- .../MessageRequestsViewController.swift | 6 +- .../MediaGalleryViewModel.swift | 67 +++++++++---------- .../MediaPageViewController.swift | 8 +-- .../MediaTileViewController.swift | 4 +- Session/Meta/AppDelegate.swift | 12 ++-- Session/Meta/SessionApp.swift | 4 +- Session/Notifications/AppNotifications.swift | 12 ++-- .../PushRegistrationManager.swift | 2 +- Session/Notifications/SyncPushTokensJob.swift | 6 +- Session/Onboarding/Onboarding.swift | 6 +- Session/Onboarding/SeedVC.swift | 2 +- Session/Open Groups/JoinOpenGroupVC.swift | 4 +- .../Open Groups/OpenGroupSuggestionGrid.swift | 2 +- .../Settings/ChatSettingsViewController.swift | 4 +- Session/Settings/NukeDataModal.swift | 2 +- Session/Settings/QRCodeVC.swift | 2 +- Session/Settings/SeedModal.swift | 2 +- Session/Settings/SettingsVC.swift | 4 +- Session/Utilities/BackgroundPoller.swift | 6 +- SessionMessagingKit/Calls/WebRTCSession.swift | 6 +- .../_001_InitialSetupMigration.swift | 2 +- .../Migrations/_002_SetupStandardJobs.swift | 2 +- .../Migrations/_003_YDBToGRDBMigration.swift | 24 +++---- .../Database/Models/Attachment.swift | 10 +-- .../Database/Models/Contact.swift | 4 +- .../DisappearingMessageConfiguration.swift | 6 +- .../Database/Models/GroupMember.swift | 4 +- .../Database/Models/LinkPreview.swift | 6 +- .../Database/Models/OpenGroup.swift | 2 +- .../Database/Models/Profile.swift | 12 ++-- .../Database/Models/SessionThread.swift | 12 ++-- .../Jobs/Types/AttachmentDownloadJob.swift | 12 ++-- .../Jobs/Types/AttachmentUploadJob.swift | 2 +- .../Jobs/Types/DisappearingMessagesJob.swift | 2 +- .../Types/FailedAttachmentDownloadsJob.swift | 2 +- .../Jobs/Types/FailedMessageSendsJob.swift | 2 +- .../Jobs/Types/GarbageCollectionJob.swift | 4 +- .../Jobs/Types/MessageReceiveJob.swift | 2 +- .../Jobs/Types/MessageSendJob.swift | 6 +- .../RetrieveDefaultOpenGroupRoomsJob.swift | 2 +- .../Jobs/Types/SendReadReceiptsJob.swift | 4 +- .../Open Groups/OpenGroupManager.swift | 2 +- .../MessageSender+ClosedGroups.swift | 4 +- .../MessageSender+Convenience.swift | 2 +- .../Sending & Receiving/MessageSender.swift | 10 +-- .../Notifications/PushNotificationAPI.swift | 4 +- .../Pollers/ClosedGroupPoller.swift | 10 +-- .../Sending & Receiving/Pollers/Poller.swift | 2 +- .../Typing Indicators/TypingIndicators.swift | 6 +- .../Utilities/Preferences.swift | 36 +++++----- .../Utilities/ProfileManager.swift | 8 +-- .../Utilities/SMKDependencies.swift | 2 +- .../Open Groups/OpenGroupAPISpec.swift | 4 +- .../Open Groups/OpenGroupManagerSpec.swift | 4 +- .../MessageReceiverDecryptionSpec.swift | 4 +- .../MessageSenderEncryptionSpec.swift | 4 +- .../_TestUtilities/DependencyExtensions.swift | 2 +- .../OGMDependencyExtensions.swift | 2 +- .../NotificationServiceExtension.swift | 10 +-- SessionShareExtension/ShareVC.swift | 4 +- SessionShareExtension/ThreadPickerVC.swift | 4 +- .../_001_InitialSetupMigration.swift | 2 +- .../Migrations/_002_SetupStandardJobs.swift | 2 +- .../Migrations/_003_YDBToGRDBMigration.swift | 20 +++--- .../Models/SnodeReceivedMessageInfo.swift | 4 +- SessionSnodeKit/OnionRequestAPI.swift | 8 +-- SessionSnodeKit/SnodeAPI.swift | 16 ++--- .../_001_InitialSetupMigration.swift | 2 +- .../Migrations/_002_SetupStandardJobs.swift | 2 +- .../Migrations/_003_YDBToGRDBMigration.swift | 2 +- .../Database/Models/Identity.swift | 14 ++-- .../Database/Models/Setting.swift | 2 +- .../{GRDBStorage.swift => Storage.swift} | 35 +++++----- .../Types/PagedDatabaseObserver.swift | 2 +- .../General/Dependencies.swift | 8 +-- SessionUtilitiesKit/JobRunner/JobRunner.swift | 22 +++--- .../Messaging/BlockListUIUtils.swift | 4 +- .../Profile Pictures/ProfilePictureView.swift | 2 +- .../Screen Lock/OWSScreenLock.swift | 8 +-- SignalUtilitiesKit/Utilities/AppSetup.swift | 2 +- 101 files changed, 358 insertions(+), 358 deletions(-) rename SessionUtilitiesKit/Database/{GRDBStorage.swift => Storage.swift} (93%) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index e66c454b1..19554d21f 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -596,7 +596,7 @@ FD17D79927F40AB800122BE0 /* _003_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79827F40AB800122BE0 /* _003_YDBToGRDBMigration.swift */; }; FD17D79C27F40B2E00122BE0 /* SMKLegacy.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79B27F40B2E00122BE0 /* SMKLegacy.swift */; }; FD17D7A027F40CC800122BE0 /* _001_InitialSetupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79F27F40CC800122BE0 /* _001_InitialSetupMigration.swift */; }; - FD17D7A127F40D2500122BE0 /* GRDBStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD28A4F527EAD44C00FF65E7 /* GRDBStorage.swift */; }; + FD17D7A127F40D2500122BE0 /* Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD28A4F527EAD44C00FF65E7 /* Storage.swift */; }; FD17D7A227F40F0500122BE0 /* _001_InitialSetupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79527F3E04600122BE0 /* _001_InitialSetupMigration.swift */; }; FD17D7A427F40F8100122BE0 /* _003_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7A327F40F8100122BE0 /* _003_YDBToGRDBMigration.swift */; }; FD17D7A727F41AF000122BE0 /* SSKLegacy.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7A627F41AF000122BE0 /* SSKLegacy.swift */; }; @@ -1686,7 +1686,7 @@ FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationBar+Utilities.swift"; sourceTree = ""; }; FD245C612850664300B966DD /* Configuration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = ""; }; FD28A4F327EA79F800FF65E7 /* BlockListUIUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockListUIUtils.swift; sourceTree = ""; }; - FD28A4F527EAD44C00FF65E7 /* GRDBStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GRDBStorage.swift; sourceTree = ""; }; + FD28A4F527EAD44C00FF65E7 /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = ""; }; FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadPickerViewModel.swift; sourceTree = ""; }; FD3C905B27E3FBEF00CD579F /* BatchRequestInfoSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchRequestInfoSpec.swift; sourceTree = ""; }; FD3C905F27E410F700CD579F /* FileUploadResponseSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUploadResponseSpec.swift; sourceTree = ""; }; @@ -2306,7 +2306,7 @@ FD17D7B427F51E6700122BE0 /* Types */, FD17D7BB27F51F5C00122BE0 /* Utilities */, FD848B9928442CE6000E298B /* StorageError.swift */, - FD28A4F527EAD44C00FF65E7 /* GRDBStorage.swift */, + FD28A4F527EAD44C00FF65E7 /* Storage.swift */, C33FDBAB255A581500E217F9 /* OWSFileSystem.h */, C33FDA8E255A57FD00E217F9 /* OWSFileSystem.m */, C33FDBBC255A581600E217F9 /* SSKKeychainStorage.swift */, @@ -5033,7 +5033,7 @@ C33FDEF8255A656D00E217F9 /* Promise+Delaying.swift in Sources */, 7BD477A827EC39F5004E2822 /* Atomic.swift in Sources */, B8BC00C0257D90E30032E807 /* General.swift in Sources */, - FD17D7A127F40D2500122BE0 /* GRDBStorage.swift in Sources */, + FD17D7A127F40D2500122BE0 /* Storage.swift in Sources */, FD5D201E27B0D87C00FEA984 /* SessionId.swift in Sources */, C32C5A24256DB7DB003C73A2 /* SNUserDefaults.swift in Sources */, C3BBE0A72554D4DE0050F1E3 /* Promise+Retrying.swift in Sources */, diff --git a/Session/Calls/Call Management/SessionCall.swift b/Session/Calls/Call Management/SessionCall.swift index 95a973304..41998656d 100644 --- a/Session/Calls/Call Management/SessionCall.swift +++ b/Session/Calls/Call Management/SessionCall.swift @@ -227,7 +227,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { in: thread ) .done { [weak self] _ in - GRDBStorage.shared.writeAsync { db in + Storage.shared.writeAsync { db in self?.webRTCSession.sendOffer(db, to: sessionId) } @@ -258,7 +258,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { webRTCSession.hangUp() - GRDBStorage.shared.writeAsync { [weak self] db in + Storage.shared.writeAsync { [weak self] db in try self?.webRTCSession.endCall(db, with: sessionId) } @@ -273,7 +273,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { let duration: TimeInterval = self.duration let hasStartedConnecting: Bool = self.hasStartedConnecting - GRDBStorage.shared.writeAsync { db in + Storage.shared.writeAsync { db in guard let interaction: Interaction = try? Interaction.fetchOne(db, id: callInteractionId) else { return } @@ -396,7 +396,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { let sessionId: String = self.sessionId let webRTCSession: WebRTCSession = self.webRTCSession - GRDBStorage.shared + Storage.shared .read { db in webRTCSession.sendOffer(db, to: sessionId, isRestartingICEConnection: true) } .retainUntilComplete() } diff --git a/Session/Calls/Call Management/SessionCallManager+Action.swift b/Session/Calls/Call Management/SessionCallManager+Action.swift index 32482d5de..6ac7c49cd 100644 --- a/Session/Calls/Call Management/SessionCallManager+Action.swift +++ b/Session/Calls/Call Management/SessionCallManager+Action.swift @@ -8,7 +8,7 @@ extension SessionCallManager { public func startCallAction() -> Bool { guard let call: CurrentCallProtocol = self.currentCall else { return false } - GRDBStorage.shared.writeAsync { db in + Storage.shared.writeAsync { db in call.startSessionCall(db) } diff --git a/Session/Calls/Call Management/SessionCallManager.swift b/Session/Calls/Call Management/SessionCallManager.swift index a1ebc6028..b21d98f13 100644 --- a/Session/Calls/Call Management/SessionCallManager.swift +++ b/Session/Calls/Call Management/SessionCallManager.swift @@ -170,7 +170,7 @@ public final class SessionCallManager: NSObject, CallManagerProtocol { } return } - guard let call: SessionCall = GRDBStorage.shared.read({ db in SessionCall(db, for: caller, uuid: uuid, mode: mode) }) else { + guard let call: SessionCall = Storage.shared.read({ db in SessionCall(db, for: caller, uuid: uuid, mode: mode) }) else { return } diff --git a/Session/Closed Groups/EditClosedGroupVC.swift b/Session/Closed Groups/EditClosedGroupVC.swift index 7ed1b4b7b..cfcca7798 100644 --- a/Session/Closed Groups/EditClosedGroupVC.swift +++ b/Session/Closed Groups/EditClosedGroupVC.swift @@ -92,7 +92,7 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat let threadId: String = self.threadId - GRDBStorage.shared.read { [weak self] db in + Storage.shared.read { [weak self] db in self?.userPublicKey = getUserHexEncodedPublicKey(db) self?.name = try ClosedGroup .select(.name) @@ -321,7 +321,7 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat .map { $0.profileId } .asSet() ) { [weak self] selectedUserIds in - GRDBStorage.shared.read { [weak self] db in + Storage.shared.read { [weak self] db in let selectedGroupMembers: [GroupMemberDisplayInfo] = try Profile .filter(selectedUserIds.contains(Profile.Columns.id)) .fetchAll(db) @@ -416,7 +416,7 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat } ModalActivityIndicatorViewController.present(fromViewController: navigationController) { _ in - GRDBStorage.shared + Storage.shared .writeAsync { db in if !updatedMemberIds.contains(userPublicKey) { return try MessageSender.leave(db, groupPublicKey: threadId) diff --git a/Session/Closed Groups/NewClosedGroupVC.swift b/Session/Closed Groups/NewClosedGroupVC.swift index 7ec2c4b57..bd51ba8c2 100644 --- a/Session/Closed Groups/NewClosedGroupVC.swift +++ b/Session/Closed Groups/NewClosedGroupVC.swift @@ -196,12 +196,12 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate let selectedContacts = self.selectedContacts let message: String? = (selectedContacts.count > 20) ? "Please wait while the group is created..." : nil ModalActivityIndicatorViewController.present(fromViewController: navigationController!, message: message) { [weak self] _ in - GRDBStorage.shared + Storage.shared .writeAsync { db in try MessageSender.createClosedGroup(db, name: name, members: selectedContacts) } .done(on: DispatchQueue.main) { thread in - GRDBStorage.shared.writeAsync { db in + Storage.shared.writeAsync { db in try? MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() } diff --git a/Session/Conversations/ConversationSearch.swift b/Session/Conversations/ConversationSearch.swift index f1e365b37..0f97b8644 100644 --- a/Session/Conversations/ConversationSearch.swift +++ b/Session/Conversations/ConversationSearch.swift @@ -57,7 +57,7 @@ extension ConversationSearchController: UISearchResultsUpdating { } let threadId: String = self.threadId - let results: [Int64] = GRDBStorage.shared.read { db -> [Int64] in + let results: [Int64] = Storage.shared.read { db -> [Int64] in try Interaction.idsForTermWithin( threadId: threadId, pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 2324dd93c..28be01462 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -54,7 +54,7 @@ extension ConversationVC: @objc func startCall(_ sender: Any?) { guard SessionCall.isEnabled else { return } - guard GRDBStorage.shared[.areCallsEnabled] else { + guard Storage.shared[.areCallsEnabled] else { let callPermissionRequestModal = CallPermissionRequestModal() self.navigationController?.present(callPermissionRequestModal, animated: true, completion: nil) return @@ -67,7 +67,7 @@ extension ConversationVC: guard AVAudioSession.sharedInstance().recordPermission == .granted else { return } guard self.viewModel.threadData.threadVariant == .contact else { return } guard AppEnvironment.shared.callManager.currentCall == nil else { return } - guard let call: SessionCall = GRDBStorage.shared.read({ db in SessionCall(db, for: threadId, uuid: UUID().uuidString.lowercased(), mode: .offer, outgoing: true) }) else { + guard let call: SessionCall = Storage.shared.read({ db in SessionCall(db, for: threadId, uuid: UUID().uuidString.lowercased(), mode: .offer, outgoing: true) }) else { return } @@ -92,7 +92,7 @@ extension ConversationVC: self.blockedBanner.alpha = 0 }, completion: { _ in - GRDBStorage.shared.write { db in + Storage.shared.write { db in try Contact .filter(id: publicKey) .updateAll(db, Contact.Columns.isBlocked.set(to: true)) @@ -354,7 +354,7 @@ extension ConversationVC: timestampMs: (sentTimestampMs - 1) // Set 1ms earlier as this is used for sorting ) .done { [weak self] _ in - GRDBStorage.shared.writeAsync( + Storage.shared.writeAsync( updates: { db in guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { return @@ -451,7 +451,7 @@ extension ConversationVC: timestampMs: (sentTimestampMs - 1) // Set 1ms earlier as this is used for sorting ) .done { [weak self] _ in - GRDBStorage.shared.writeAsync( + Storage.shared.writeAsync( updates: { db in guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { return @@ -509,14 +509,14 @@ extension ConversationVC: self?.resetMentions() } - if GRDBStorage.shared[.playNotificationSoundInForeground] { + if Storage.shared[.playNotificationSoundInForeground] { let soundID = Preferences.Sound.systemSoundId(for: .messageSent, quiet: true) AudioServicesPlaySystemSound(soundID) } let threadId: String = self.viewModel.threadData.threadId - GRDBStorage.shared.writeAsync { db in + Storage.shared.writeAsync { db in TypingIndicators.didStopTyping(db, threadId: threadId, direction: .outgoing) _ = try SessionThread @@ -542,7 +542,7 @@ extension ConversationVC: let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant let threadIsMessageRequest: Bool = (self.viewModel.threadData.threadIsMessageRequest == true) - GRDBStorage.shared.writeAsync { db in + Storage.shared.writeAsync { db in TypingIndicators.didStartTyping( db, threadId: threadId, @@ -760,7 +760,7 @@ extension ConversationVC: let threadId: String = self.viewModel.threadData.threadId // Retry downloading the failed attachment - GRDBStorage.shared.writeAsync { db in + Storage.shared.writeAsync { db in JobRunner.add( db, job: Job( @@ -845,7 +845,7 @@ extension ConversationVC: case .textOnlyMessage: if let quote: Quote = cellViewModel.quote { // Scroll to the original quoted message - let maybeOriginalInteractionId: Int64? = GRDBStorage.shared.read { db in + let maybeOriginalInteractionId: Int64? = Storage.shared.read { db in try quote.originalInteraction .select(.id) .asRequest(of: Int64.self) @@ -920,7 +920,7 @@ extension ConversationVC: func startThread(with sessionId: String, openGroupServer: String?, openGroupPublicKey: String?) { guard SessionId.Prefix(from: sessionId) == .blinded else { - GRDBStorage.shared.write { db in + Storage.shared.write { db in try SessionThread.fetchOrCreate(db, id: sessionId, variant: .contact) } @@ -936,7 +936,7 @@ extension ConversationVC: return } - let targetThreadId: String? = GRDBStorage.shared.write { db in + let targetThreadId: String? = Storage.shared.write { db in let lookup: BlindedIdLookup = try BlindedIdLookup .fetchOrCreate( db, @@ -968,14 +968,14 @@ extension ConversationVC: let sheet = UIAlertController(title: cellViewModel.mostRecentFailureText, message: nil, preferredStyle: .actionSheet) sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) sheet.addAction(UIAlertAction(title: "Delete", style: .destructive, handler: { _ in - GRDBStorage.shared.writeAsync { db in + Storage.shared.writeAsync { db in try Interaction .filter(id: cellViewModel.id) .deleteAll(db) } })) sheet.addAction(UIAlertAction(title: "Resend", style: .default, handler: { _ in - GRDBStorage.shared.writeAsync { [weak self] db in + Storage.shared.writeAsync { [weak self] db in guard let threadId: String = self?.viewModel.threadData.threadId, let interaction: Interaction = try? Interaction.fetchOne(db, id: cellViewModel.id), @@ -1094,7 +1094,7 @@ extension ConversationVC: .then { _ -> Promise in request } .done { _ in // Delete the interaction (and associated data) from the database - GRDBStorage.shared.writeAsync { db in + Storage.shared.writeAsync { db in _ = try Interaction .filter(id: cellViewModel.id) .deleteAll(db) @@ -1117,7 +1117,7 @@ extension ConversationVC: // Handle open group messages the old way case .openGroup: // If it's an incoming message the user must have moderator status - let result: (openGroupServerMessageId: Int64?, openGroup: OpenGroup?)? = GRDBStorage.shared.read { db -> (Int64?, OpenGroup?) in + let result: (openGroupServerMessageId: Int64?, openGroup: OpenGroup?)? = Storage.shared.read { db -> (Int64?, OpenGroup?) in ( try Interaction .select(.openGroupServerMessageId) @@ -1143,7 +1143,7 @@ extension ConversationVC: // Delete the message from the open group deleteRemotely( from: self, - request: GRDBStorage.shared.read { db in + request: Storage.shared.read { db in OpenGroupAPI.messageDelete( db, id: openGroupServerMessageId, @@ -1157,7 +1157,7 @@ extension ConversationVC: } case .contact, .closedGroup: - let serverHash: String? = GRDBStorage.shared.read { db -> String? in + let serverHash: String? = Storage.shared.read { db -> String? in try Interaction .select(.serverHash) .filter(id: cellViewModel.id) @@ -1174,7 +1174,7 @@ extension ConversationVC: // For incoming interactions or interactions with no serverHash just delete them locally guard cellViewModel.variant == .standardOutgoing, let serverHash: String = serverHash else { - GRDBStorage.shared.writeAsync { db in + Storage.shared.writeAsync { db in _ = try Interaction .filter(id: cellViewModel.id) .deleteAll(db) @@ -1197,7 +1197,7 @@ extension ConversationVC: let alertVC = UIAlertController.init(title: nil, message: nil, preferredStyle: .actionSheet) alertVC.addAction(UIAlertAction(title: "delete_message_for_me".localized(), style: .destructive) { [weak self] _ in - GRDBStorage.shared.writeAsync { db in + Storage.shared.writeAsync { db in _ = try Interaction .filter(id: cellViewModel.id) .deleteAll(db) @@ -1230,7 +1230,7 @@ extension ConversationVC: ) .map { _ in () } ) { [weak self] in - GRDBStorage.shared.writeAsync { db in + Storage.shared.writeAsync { db in guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { return } @@ -1302,7 +1302,7 @@ extension ConversationVC: let threadId: String = self.viewModel.threadData.threadId - GRDBStorage.shared.writeAsync { db in + Storage.shared.writeAsync { db in guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { return } try MessageSender.send( @@ -1326,7 +1326,7 @@ extension ConversationVC: preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { [weak self] _ in - GRDBStorage.shared + Storage.shared .read { db -> Promise in guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { return Promise(error: StorageError.objectNotFound) @@ -1365,7 +1365,7 @@ extension ConversationVC: preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { [weak self] _ in - GRDBStorage.shared + Storage.shared .read { db -> Promise in guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { return Promise(error: StorageError.objectNotFound) @@ -1650,7 +1650,7 @@ extension ConversationVC { // (it'll be updated with correct profile info if they accept the message request so this // shouldn't cause weird behaviours) guard - let approvalData: (contact: Contact, thread: SessionThread?) = GRDBStorage.shared.read({ db in + let approvalData: (contact: Contact, thread: SessionThread?) = Storage.shared.read({ db in return ( Contact.fetchOrCreate(db, id: threadId), try SessionThread.fetchOne(db, id: threadId) @@ -1683,7 +1683,7 @@ extension ConversationVC { return promise .then { _ -> Promise in - GRDBStorage.shared.writeAsync { db in + Storage.shared.writeAsync { db in try MessageSender.sendNonDurably( db, message: messageRequestResponse, @@ -1700,7 +1700,7 @@ extension ConversationVC { } .map { _ in // Default 'didApproveMe' to true for the person approving the message request - GRDBStorage.shared.writeAsync( + Storage.shared.writeAsync( updates: { db in try approvalData.contact .with( @@ -1783,7 +1783,7 @@ extension ConversationVC { ) alertVC.addAction(UIAlertAction(title: "TXT_DELETE_TITLE".localized(), style: .destructive) { _ in // Delete the request - GRDBStorage.shared.writeAsync( + Storage.shared.writeAsync( updates: { db in // Update the contact _ = try Contact diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 92ce1c2f1..66532526c 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -308,7 +308,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers init(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionId: Int64? = nil) { self.viewModel = ConversationViewModel(threadId: threadId, threadVariant: threadVariant, focusedInteractionId: focusedInteractionId) - GRDBStorage.shared.addObserver(viewModel.pagedDataObserver) + Storage.shared.addObserver(viewModel.pagedDataObserver) super.init(nibName: nil, bundle: nil) } @@ -472,7 +472,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers private func startObservingChanges() { // Start observing for data changes - dataChangeObservable = GRDBStorage.shared.start( + dataChangeObservable = Storage.shared.start( viewModel.observableThreadData, onError: { _ in }, onChange: { [weak self] maybeThreadData in @@ -482,7 +482,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers guard let sessionId: String = self?.viewModel.threadData.threadId, SessionId.Prefix(from: sessionId) == .blinded, - let blindedLookup: BlindedIdLookup = GRDBStorage.shared.read({ db in + let blindedLookup: BlindedIdLookup = Storage.shared.read({ db in try BlindedIdLookup .filter(id: sessionId) .fetchOne(db) @@ -496,13 +496,13 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers // Stop observing changes self?.stopObservingChanges() - GRDBStorage.shared.removeObserver(self?.viewModel.pagedDataObserver) + Storage.shared.removeObserver(self?.viewModel.pagedDataObserver) // Swap the observing to the updated thread self?.viewModel.swapToThread(updatedThreadId: unblindedId) // Start observing changes again - GRDBStorage.shared.addObserver(self?.viewModel.pagedDataObserver) + Storage.shared.addObserver(self?.viewModel.pagedDataObserver) self?.startObservingChanges() return } diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 5f631d953..00d5d45ee 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -64,7 +64,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { let targetInteractionId: Int64? = { if let focusedInteractionId: Int64 = focusedInteractionId { return focusedInteractionId } - return GRDBStorage.shared.read { db in + return Storage.shared.read { db in let interaction: TypedTableAlias = TypedTableAlias() return try Interaction @@ -109,7 +109,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { threadVariant: self.initialThreadVariant, currentUserIsClosedGroupMember: (self.initialThreadVariant != .closedGroup ? nil : - GRDBStorage.shared.read { db in + Storage.shared.read { db in try GroupMember .filter(GroupMember.Columns.groupId == self.threadId) .filter(GroupMember.Columns.profileId == getUserHexEncodedPublicKey(db)) @@ -265,7 +265,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { } if !pendingAttachmentsToDownload.isEmpty { - GRDBStorage.shared.writeAsync { db in + Storage.shared.writeAsync { db in pendingAttachmentsToDownload.forEach { attachment, interactionId in JobRunner.add( db, @@ -339,7 +339,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { public func mentions(for query: String = "") -> [MentionInfo] { let threadData: SessionThreadViewModel = self.threadData - let results: [MentionInfo] = GRDBStorage.shared + let results: [MentionInfo] = Storage.shared .read { db -> [MentionInfo] in let userPublicKey: String = getUserHexEncodedPublicKey(db) @@ -447,7 +447,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { // MARK: - Functions public func updateDraft(to draft: String) { - GRDBStorage.shared.writeAsync { db in + Storage.shared.writeAsync { db in try SessionThread .filter(id: self.threadId) .updateAll(db, SessionThread.Columns.messageDraft.set(to: draft)) @@ -460,7 +460,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { let threadId: String = self.threadData.threadId let trySendReadReceipt: Bool = (self.threadData.threadIsMessageRequest == false) - GRDBStorage.shared.writeAsync { db in + Storage.shared.writeAsync { db in try Interaction.markAsRead( db, interactionId: lastInteractionId, diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift index 2e2c13174..a61783967 100644 --- a/Session/Conversations/Input View/InputView.swift +++ b/Session/Conversations/Input View/InputView.swift @@ -250,7 +250,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M // Suggest that the user enable link previews if they haven't already and we haven't // told them about link previews yet let text = inputTextView.text! - let areLinkPreviewsEnabled: Bool = GRDBStorage.shared[.areLinkPreviewsEnabled] + let areLinkPreviewsEnabled: Bool = Storage.shared[.areLinkPreviewsEnabled] if !LinkPreview.allPreviewUrls(forMessageBodyText: text).isEmpty && diff --git a/Session/Conversations/Message Cells/CallMessageCell.swift b/Session/Conversations/Message Cells/CallMessageCell.swift index eb87954a9..ff98141ff 100644 --- a/Session/Conversations/Message Cells/CallMessageCell.swift +++ b/Session/Conversations/Message Cells/CallMessageCell.swift @@ -135,7 +135,7 @@ final class CallMessageCell: MessageCell { let shouldShowInfoIcon: Bool = ( messageInfo.state == .permissionDenied && - !GRDBStorage.shared[.areCallsEnabled] + !Storage.shared[.areCallsEnabled] ) infoImageViewWidthConstraint.constant = (shouldShowInfoIcon ? CallMessageCell.iconSize : 0) infoImageViewHeightConstraint.constant = (shouldShowInfoIcon ? CallMessageCell.iconSize : 0) @@ -159,7 +159,7 @@ final class CallMessageCell: MessageCell { else { return } // Should only be tappable if the info icon is visible - guard messageInfo.state == .permissionDenied && !GRDBStorage.shared[.areCallsEnabled] else { return } + guard messageInfo.state == .permissionDenied && !Storage.shared[.areCallsEnabled] else { return } self.delegate?.handleItemTapped(cellViewModel, gestureRecognizer: gestureRecognizer) } diff --git a/Session/Conversations/Views & Modals/BlockedModal.swift b/Session/Conversations/Views & Modals/BlockedModal.swift index 9bcc28c02..15b3f8f18 100644 --- a/Session/Conversations/Views & Modals/BlockedModal.swift +++ b/Session/Conversations/Views & Modals/BlockedModal.swift @@ -78,7 +78,7 @@ final class BlockedModal: Modal { @objc private func unblock() { let publicKey: String = self.publicKey - GRDBStorage.shared.writeAsync { db in + Storage.shared.writeAsync { db in try Contact .filter(id: publicKey) .updateAll(db, Contact.Columns.isBlocked.set(to: true)) diff --git a/Session/Conversations/Views & Modals/CallModal.swift b/Session/Conversations/Views & Modals/CallModal.swift index d5a4a4073..1822c5e9a 100644 --- a/Session/Conversations/Views & Modals/CallModal.swift +++ b/Session/Conversations/Views & Modals/CallModal.swift @@ -76,7 +76,7 @@ final class CallModal: Modal { // MARK: - Interaction @objc private func enable() { - GRDBStorage.shared.writeAsync { db in db[.areCallsEnabled] = true } + Storage.shared.writeAsync { db in db[.areCallsEnabled] = true } presentingViewController?.dismiss(animated: true, completion: nil) onCallEnabled() } diff --git a/Session/Conversations/Views & Modals/DownloadAttachmentModal.swift b/Session/Conversations/Views & Modals/DownloadAttachmentModal.swift index d3ea79fd3..981c1d42c 100644 --- a/Session/Conversations/Views & Modals/DownloadAttachmentModal.swift +++ b/Session/Conversations/Views & Modals/DownloadAttachmentModal.swift @@ -90,7 +90,7 @@ final class DownloadAttachmentModal: Modal { @objc private func trust() { guard let profileId: String = profile?.id else { return } - GRDBStorage.shared.writeAsync { db in + Storage.shared.writeAsync { db in try Contact .filter(id: profileId) .updateAll(db, Contact.Columns.isTrusted.set(to: true)) diff --git a/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift b/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift index 0335df69c..3e8dde97d 100644 --- a/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift +++ b/Session/Conversations/Views & Modals/JoinOpenGroupModal.swift @@ -92,7 +92,7 @@ final class JoinOpenGroupModal: Modal { presentingViewController.dismiss(animated: true, completion: nil) - GRDBStorage.shared + Storage.shared .writeAsync { db in OpenGroupManager.shared.add( db, @@ -103,7 +103,7 @@ final class JoinOpenGroupModal: Modal { ) } .done(on: DispatchQueue.main) { _ in - GRDBStorage.shared.writeAsync { db in + Storage.shared.writeAsync { db in try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...) } } diff --git a/Session/Conversations/Views & Modals/LinkPreviewModal.swift b/Session/Conversations/Views & Modals/LinkPreviewModal.swift index fe2ec1f51..ec6da49ea 100644 --- a/Session/Conversations/Views & Modals/LinkPreviewModal.swift +++ b/Session/Conversations/Views & Modals/LinkPreviewModal.swift @@ -77,7 +77,7 @@ final class LinkPreviewModal: Modal { // MARK: - Interaction @objc private func enable() { - GRDBStorage.shared.writeAsync { db in + Storage.shared.writeAsync { db in db[.areLinkPreviewsEnabled] = true } diff --git a/Session/DMs/NewDMVC.swift b/Session/DMs/NewDMVC.swift index a943e15c3..c340ce94f 100644 --- a/Session/DMs/NewDMVC.swift +++ b/Session/DMs/NewDMVC.swift @@ -175,7 +175,7 @@ final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControll } private func startNewDM(with sessionId: String) { - let maybeThread: SessionThread? = GRDBStorage.shared.write { db in + let maybeThread: SessionThread? = Storage.shared.write { db in try SessionThread.fetchOrCreate(db, id: sessionId, variant: .contact) } diff --git a/Session/Home/GlobalSearch/GlobalSearchViewController.swift b/Session/Home/GlobalSearch/GlobalSearchViewController.swift index bd87a81c5..c88b527be 100644 --- a/Session/Home/GlobalSearch/GlobalSearchViewController.swift +++ b/Session/Home/GlobalSearch/GlobalSearchViewController.swift @@ -22,7 +22,7 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo // MARK: - Variables private lazy var defaultSearchResults: [SectionModel] = { - let result: SessionThreadViewModel? = GRDBStorage.shared.read { db -> SessionThreadViewModel? in + let result: SessionThreadViewModel? = Storage.shared.read { db -> SessionThreadViewModel? in try SessionThreadViewModel .noteToSelfOnlyQuery(userPublicKey: getUserHexEncodedPublicKey(db)) .fetchOne(db) @@ -155,7 +155,7 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo lastSearchText = searchText - let result: Result<[SectionModel], Error>? = GRDBStorage.shared.read { db -> Result<[SectionModel], Error> in + let result: Result<[SectionModel], Error>? = Storage.shared.read { db -> Result<[SectionModel], Error> in do { let userPublicKey: String = getUserHexEncodedPublicKey(db) let contactsAndGroupsResults: [SessionThreadViewModel] = try SessionThreadViewModel diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 2c50fa346..c5e6e9058 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -20,7 +20,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve // MARK: - Intialization init() { - GRDBStorage.shared.addObserver(viewModel.pagedDataObserver) + Storage.shared.addObserver(viewModel.pagedDataObserver) super.init(nibName: nil, bundle: nil) } @@ -242,7 +242,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve private func startObservingChanges() { // Start observing for data changes - dataChangeObservable = GRDBStorage.shared.start( + dataChangeObservable = Storage.shared.start( viewModel.observableState, // If we haven't done the initial load the trigger it immediately (blocking the main // thread so we remain on the launch screen until it completes to be consistent with @@ -537,7 +537,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve switch section.model { case .messageRequests: let hide = UITableViewRowAction(style: .destructive, title: "TXT_HIDE_TITLE".localized()) { _, _ in - GRDBStorage.shared.write { db in db[.hasHiddenMessageRequests] = true } + Storage.shared.write { db in db[.hasHiddenMessageRequests] = true } } hide.backgroundColor = Colors.destructive @@ -563,7 +563,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve title: "TXT_DELETE_TITLE".localized(), style: .destructive ) { _ in - GRDBStorage.shared.writeAsync { db in + Storage.shared.writeAsync { db in switch threadViewModel.threadVariant { case .closedGroup: try MessageSender @@ -597,7 +597,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve "PIN_BUTTON_TEXT".localized() ) ) { _, _ in - GRDBStorage.shared.writeAsync { db in + Storage.shared.writeAsync { db in try SessionThread .filter(id: threadViewModel.threadId) .updateAll(db, SessionThread.Columns.isPinned.set(to: !threadViewModel.threadIsPinned)) @@ -615,7 +615,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve "BLOCK_LIST_BLOCK_BUTTON".localized() ) ) { _, _ in - GRDBStorage.shared.writeAsync { db in + Storage.shared.writeAsync { db in try Contact .filter(id: threadViewModel.threadId) .updateAll( diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 8a6022005..72505ee99 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -28,8 +28,8 @@ public class HomeViewModel { let userProfile: Profile? init( - showViewedSeedBanner: Bool = !GRDBStorage.shared[.hasViewedSeed], - hasHiddenMessageRequests: Bool = GRDBStorage.shared[.hasHiddenMessageRequests], + showViewedSeedBanner: Bool = !Storage.shared[.hasViewedSeed], + hasHiddenMessageRequests: Bool = Storage.shared[.hasHiddenMessageRequests], unreadMessageRequestThreadCount: Int = 0, userProfile: Profile? = nil ) { @@ -43,7 +43,7 @@ public class HomeViewModel { // MARK: - Initialization init() { - self.state = GRDBStorage.shared.read { db in try HomeViewModel.retrieveState(db) } + self.state = Storage.shared.read { db in try HomeViewModel.retrieveState(db) } .defaulting(to: State()) self.pagedDataObserver = nil diff --git a/Session/Home/Message Requests/MessageRequestsViewController.swift b/Session/Home/Message Requests/MessageRequestsViewController.swift index 3d41a7fe7..401240ff3 100644 --- a/Session/Home/Message Requests/MessageRequestsViewController.swift +++ b/Session/Home/Message Requests/MessageRequestsViewController.swift @@ -18,7 +18,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat // MARK: - Intialization init() { - GRDBStorage.shared.addObserver(viewModel.pagedDataObserver) + Storage.shared.addObserver(viewModel.pagedDataObserver) super.init(nibName: nil, bundle: nil) } @@ -355,7 +355,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat style: .destructive ) { _ in // Clear the requests - GRDBStorage.shared.write { db in + Storage.shared.write { db in _ = try SessionThread .filter(ids: threadIds) .deleteAll(db) @@ -388,7 +388,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat title: "TXT_DELETE_TITLE".localized(), style: .destructive ) { _ in - GRDBStorage.shared.write { db in + Storage.shared.write { db in _ = try SessionThread .filter(id: threadId) .deleteAll(db) diff --git a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift index 01032e870..cc425d2d3 100644 --- a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift +++ b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift @@ -372,41 +372,40 @@ public class MediaGalleryViewModel { // Note: It's possible we already have cached album data for this interaction // but to avoid displaying stale data we re-fetch from the database anyway - let maybeAlbumInfo: AlbumInfo? = GRDBStorage.shared - .read { db -> AlbumInfo in - let attachment: TypedTableAlias = TypedTableAlias() - let interaction: TypedTableAlias = TypedTableAlias() - let interactionAttachment: TypedTableAlias = TypedTableAlias() - - let newAlbumData: [Item] = try Item - .baseQuery( - orderSQL: SQL(interactionAttachment[.albumIndex]), - customFilters: SQL(""" - \(attachment[.isValid]) = true AND - \(interaction[.id]) = \(interactionId) - """) - ) - .fetchAll(db) - - guard let albumTimestampMs: Int64 = newAlbumData.first?.interactionTimestampMs else { - return (newAlbumData, nil, nil) - } - - let itemBefore: Item? = try Item - .baseQuery( - orderSQL: Item.galleryReverseOrderSQL, - customFilters: SQL("\(interaction[.timestampMs]) > \(albumTimestampMs)") - ) - .fetchOne(db) - let itemAfter: Item? = try Item - .baseQuery( - orderSQL: Item.galleryOrderSQL, - customFilters: SQL("\(interaction[.timestampMs]) < \(albumTimestampMs)") - ) - .fetchOne(db) - - return (newAlbumData, itemBefore?.interactionId, itemAfter?.interactionId) + let maybeAlbumInfo: AlbumInfo? = Storage.shared.read { db -> AlbumInfo in + let attachment: TypedTableAlias = TypedTableAlias() + let interaction: TypedTableAlias = TypedTableAlias() + let interactionAttachment: TypedTableAlias = TypedTableAlias() + + let newAlbumData: [Item] = try Item + .baseQuery( + orderSQL: SQL(interactionAttachment[.albumIndex]), + customFilters: SQL(""" + \(attachment[.isValid]) = true AND + \(interaction[.id]) = \(interactionId) + """) + ) + .fetchAll(db) + + guard let albumTimestampMs: Int64 = newAlbumData.first?.interactionTimestampMs else { + return (newAlbumData, nil, nil) } + + let itemBefore: Item? = try Item + .baseQuery( + orderSQL: Item.galleryReverseOrderSQL, + customFilters: SQL("\(interaction[.timestampMs]) > \(albumTimestampMs)") + ) + .fetchOne(db) + let itemAfter: Item? = try Item + .baseQuery( + orderSQL: Item.galleryOrderSQL, + customFilters: SQL("\(interaction[.timestampMs]) < \(albumTimestampMs)") + ) + .fetchOne(db) + + return (newAlbumData, itemBefore?.interactionId, itemAfter?.interactionId) + } guard let newAlbumInfo: AlbumInfo = maybeAlbumInfo else { return [] } diff --git a/Session/Media Viewing & Editing/MediaPageViewController.swift b/Session/Media Viewing & Editing/MediaPageViewController.swift index a772343dc..2acad0906 100644 --- a/Session/Media Viewing & Editing/MediaPageViewController.swift +++ b/Session/Media Viewing & Editing/MediaPageViewController.swift @@ -384,7 +384,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou private func startObservingChanges() { // Start observing for data changes - dataChangeObservable = GRDBStorage.shared.start( + dataChangeObservable = Storage.shared.start( viewModel.observableAlbumData, onError: { _ in }, onChange: { [weak self] albumData in @@ -528,7 +528,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou self.viewModel.threadVariant == .contact else { return } - GRDBStorage.shared.write { db in + Storage.shared.write { db in guard let thread: SessionThread = try SessionThread.fetchOne(db, id: self.viewModel.threadId) else { return } @@ -555,7 +555,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou title: "delete_message_for_me".localized(), style: .destructive ) { _ in - GRDBStorage.shared.writeAsync { db in + Storage.shared.writeAsync { db in _ = try Attachment .filter(id: itemToDelete.attachment.id) .deleteAll(db) @@ -864,7 +864,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou let name: String = { switch targetItem.interactionVariant { case .standardIncoming: - return GRDBStorage.shared + return Storage.shared .read { db in Profile.displayName( db, diff --git a/Session/Media Viewing & Editing/MediaTileViewController.swift b/Session/Media Viewing & Editing/MediaTileViewController.swift index 84a539b21..584678357 100644 --- a/Session/Media Viewing & Editing/MediaTileViewController.swift +++ b/Session/Media Viewing & Editing/MediaTileViewController.swift @@ -35,7 +35,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour init(viewModel: MediaGalleryViewModel) { self.viewModel = viewModel - GRDBStorage.shared.addObserver(viewModel.pagedDataObserver) + Storage.shared.addObserver(viewModel.pagedDataObserver) super.init(nibName: nil, bundle: nil) } @@ -670,7 +670,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour }() let deleteAction = UIAlertAction(title: confirmationTitle, style: .destructive) { [weak self] _ in - GRDBStorage.shared.writeAsync { db in + Storage.shared.writeAsync { db in let interactionIds: Set = items .map { $0.interactionId } .asSet() diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 045566300..eebe29179 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -202,7 +202,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD Environment.shared?.audioSession.setup() Environment.shared?.reachabilityManager.setup() - GRDBStorage.shared.writeAsync { db in + Storage.shared.writeAsync { db in // Disable the SAE until the main app has successfully completed launch process // at least once in the post-SAE world. db[.isReadyForAppExtensions] = true @@ -238,7 +238,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD // Remove the legacy database and any message hashes that have been migrated to the new DB try? SUKLegacy.deleteLegacyDatabaseFilesAndKey() - GRDBStorage.shared.write { db in + Storage.shared.write { db in try SnodeReceivedMessageInfo.deleteAll(db) } @@ -272,7 +272,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD private func verifyDBKeysAvailableBeforeBackgroundLaunch() { guard UIApplication.shared.applicationState == .background else { return } - guard !GRDBStorage.isDatabasePasswordAccessible else { return } // All good + guard !Storage.isDatabasePasswordAccessible else { return } // All good Logger.info("Exiting because we are in the background and the database password is not accessible.") @@ -320,7 +320,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD } private func ensureRootViewController(isPreAppReadyCall: Bool = false) { - guard (AppReadiness.isAppReady() || isPreAppReadyCall) && GRDBStorage.shared.isValid && !hasInitialRootViewController else { + guard (AppReadiness.isAppReady() || isPreAppReadyCall) && Storage.shared.isValid && !hasInitialRootViewController else { return } @@ -366,7 +366,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD guard CurrentAppContext().isMainApp else { return } CurrentAppContext().setMainAppBadgeNumber( - GRDBStorage.shared + Storage.shared .read { db in let userPublicKey: String = getUserHexEncodedPublicKey(db) let thread: TypedTableAlias = TypedTableAlias() @@ -596,7 +596,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD guard Date().timeIntervalSince(lastSync) > (7 * 24 * 60 * 60) else { return } // Sync every 2 days - GRDBStorage.shared + Storage.shared .writeAsync { db in try MessageSender.syncConfiguration(db, forceSyncNow: false) } .done { // Only update the 'lastConfigurationSync' timestamp if we have done the diff --git a/Session/Meta/SessionApp.swift b/Session/Meta/SessionApp.swift index dadebd048..ceae960ce 100644 --- a/Session/Meta/SessionApp.swift +++ b/Session/Meta/SessionApp.swift @@ -10,7 +10,7 @@ public struct SessionApp { // MARK: - View Convenience Methods public static func presentConversation(for threadId: String, action: ConversationViewModel.Action = .none, animated: Bool) { - let maybeThread: SessionThread? = GRDBStorage.shared.write { db in + let maybeThread: SessionThread? = Storage.shared.write { db in try SessionThread.fetchOrCreate(db, id: threadId, variant: .contact) } @@ -61,7 +61,7 @@ public struct SessionApp { Logger.error("") DDLog.flushLog() - GRDBStorage.resetAllStorage() + Storage.resetAllStorage() ProfileManager.resetProfileStorage() Attachment.resetAttachmentStorage() AppEnvironment.shared.notificationPresenter.clearAllNotifications() diff --git a/Session/Notifications/AppNotifications.swift b/Session/Notifications/AppNotifications.swift index 3670cc668..c0eb0d4a1 100644 --- a/Session/Notifications/AppNotifications.swift +++ b/Session/Notifications/AppNotifications.swift @@ -395,7 +395,7 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { AssertIsOnMainThread() guard UIApplication.shared.applicationState == .active else { return true } - guard GRDBStorage.shared[.playNotificationSoundInForeground] else { return false } + guard Storage.shared[.playNotificationSoundInForeground] else { return false } let nowMs: UInt64 = UInt64(floor(Date().timeIntervalSince1970 * 1000)) let recentThreshold = nowMs - UInt64(kAudioNotificationsThrottleInterval * Double(kSecondInMs)) @@ -428,7 +428,7 @@ class NotificationActionHandler { throw NotificationError.failDebug("threadId was unexpectedly nil") } - guard let thread: SessionThread = GRDBStorage.shared.read({ db in try SessionThread.fetchOne(db, id: threadId) }) else { + guard let thread: SessionThread = Storage.shared.read({ db in try SessionThread.fetchOne(db, id: threadId) }) else { throw NotificationError.failDebug("unable to find thread with id: \(threadId)") } @@ -440,13 +440,13 @@ class NotificationActionHandler { throw NotificationError.failDebug("threadId was unexpectedly nil") } - guard let thread: SessionThread = GRDBStorage.shared.read({ db in try SessionThread.fetchOne(db, id: threadId) }) else { + guard let thread: SessionThread = Storage.shared.read({ db in try SessionThread.fetchOne(db, id: threadId) }) else { throw NotificationError.failDebug("unable to find thread with id: \(threadId)") } let (promise, seal) = Promise.pending() - GRDBStorage.shared.writeAsync { db in + Storage.shared.writeAsync { db in let interaction: Interaction = try Interaction( threadId: thread.id, authorId: getUserHexEncodedPublicKey(db), @@ -472,7 +472,7 @@ class NotificationActionHandler { } .done { seal.fulfill(()) } .catch { error in - GRDBStorage.shared.read { [weak self] db in + Storage.shared.read { [weak self] db in self?.notificationPresenter.notifyForFailedSend(db, in: thread) } @@ -504,7 +504,7 @@ class NotificationActionHandler { private func markAsRead(thread: SessionThread) -> Promise { let (promise, seal) = Promise.pending() - GRDBStorage.shared.writeAsync( + Storage.shared.writeAsync( updates: { db in try Interaction.markAsRead( db, diff --git a/Session/Notifications/PushRegistrationManager.swift b/Session/Notifications/PushRegistrationManager.swift index 71ff8f9c2..9069858fc 100644 --- a/Session/Notifications/PushRegistrationManager.swift +++ b/Session/Notifications/PushRegistrationManager.swift @@ -243,7 +243,7 @@ public enum PushRegistrationError: Error { let payload = payload.dictionaryPayload if let uuid = payload["uuid"] as? String, let caller = payload["caller"] as? String, let timestampMs = payload["timestamp"] as? Int64 { - let call: SessionCall? = GRDBStorage.shared.write { db in + let call: SessionCall? = Storage.shared.write { db in let messageInfo: CallMessage.MessageInfo = CallMessage.MessageInfo( state: (caller == getUserHexEncodedPublicKey(db) ? .outgoing : diff --git a/Session/Notifications/SyncPushTokensJob.swift b/Session/Notifications/SyncPushTokensJob.swift index a5234e826..478f3fa55 100644 --- a/Session/Notifications/SyncPushTokensJob.swift +++ b/Session/Notifications/SyncPushTokensJob.swift @@ -51,8 +51,8 @@ public enum SyncPushTokensJob: JobExecutor { PushRegistrationManager.shared.requestPushTokens() .then(on: queue) { (pushToken: String, voipToken: String) -> Promise in - let lastPushToken: String? = GRDBStorage.shared[.lastRecordedPushToken] - let lastVoipToken: String? = GRDBStorage.shared[.lastRecordedVoipToken] + let lastPushToken: String? = Storage.shared[.lastRecordedPushToken] + let lastVoipToken: String? = Storage.shared[.lastRecordedVoipToken] let shouldUploadTokens: Bool = ( !uploadOnlyIfStale || ( lastPushToken != pushToken || @@ -77,7 +77,7 @@ public enum SyncPushTokensJob: JobExecutor { .done(on: queue) { _ in Logger.warn("Recording push tokens locally. pushToken: \(redact(pushToken)), voipToken: \(redact(voipToken))") - GRDBStorage.shared.write { db in + Storage.shared.write { db in db[.lastRecordedPushToken] = pushToken db[.lastRecordedVoipToken] = voipToken } diff --git a/Session/Onboarding/Onboarding.swift b/Session/Onboarding/Onboarding.swift index f5d1fc564..3814a223c 100644 --- a/Session/Onboarding/Onboarding.swift +++ b/Session/Onboarding/Onboarding.swift @@ -17,7 +17,7 @@ enum Onboarding { Identity.store(seed: seed, ed25519KeyPair: ed25519KeyPair, x25519KeyPair: x25519KeyPair) let x25519PublicKey = x25519KeyPair.hexEncodedPublicKey - GRDBStorage.shared.write { db in + Storage.shared.write { db in try Contact(id: x25519PublicKey) .with( isApproved: true, @@ -28,7 +28,7 @@ enum Onboarding { switch self { case .register: - GRDBStorage.shared.write { db in db[.hasViewedSeed] = false } + Storage.shared.write { db in db[.hasViewedSeed] = false } // Set hasSyncedInitialConfiguration to true so that when we hit the // home screen a configuration sync is triggered (yes, the logic is a // bit weird). This is needed so that if the user registers and @@ -37,7 +37,7 @@ enum Onboarding { case .recover, .link: // No need to show it again if the user is restoring or linking - GRDBStorage.shared.write { db in db[.hasViewedSeed] = true } + Storage.shared.write { db in db[.hasViewedSeed] = true } userDefaults[.hasSyncedInitialConfiguration] = false } diff --git a/Session/Onboarding/SeedVC.swift b/Session/Onboarding/SeedVC.swift index c519275a3..23bbc3b1f 100644 --- a/Session/Onboarding/SeedVC.swift +++ b/Session/Onboarding/SeedVC.swift @@ -171,7 +171,7 @@ final class SeedVC: BaseVC { }, completion: nil) seedReminderView.setProgress(1, animated: true) - GRDBStorage.shared.write { db in db[.hasViewedSeed] = true } + Storage.shared.writeAsync { db in db[.hasViewedSeed] = true } } @objc private func copyMnemonic() { diff --git a/Session/Open Groups/JoinOpenGroupVC.swift b/Session/Open Groups/JoinOpenGroupVC.swift index 74891013e..10de3d156 100644 --- a/Session/Open Groups/JoinOpenGroupVC.swift +++ b/Session/Open Groups/JoinOpenGroupVC.swift @@ -162,7 +162,7 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC isJoining = true ModalActivityIndicatorViewController.present(fromViewController: navigationController, canCancel: false) { [weak self] _ in - GRDBStorage.shared + Storage.shared .writeAsync { db in OpenGroupManager.shared.add( db, @@ -173,7 +173,7 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC ) } .done(on: DispatchQueue.main) { [weak self] _ in - GRDBStorage.shared.writeAsync { db in + Storage.shared.writeAsync { db in try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...) } diff --git a/Session/Open Groups/OpenGroupSuggestionGrid.swift b/Session/Open Groups/OpenGroupSuggestionGrid.swift index 3527a24d5..81cf042b0 100644 --- a/Session/Open Groups/OpenGroupSuggestionGrid.swift +++ b/Session/Open Groups/OpenGroupSuggestionGrid.swift @@ -250,7 +250,7 @@ extension OpenGroupSuggestionGrid { return } - let promise = GRDBStorage.shared.read { db in + let promise = Storage.shared.read { db in OpenGroupManager.roomImage(db, fileId: imageId, for: room.token, on: OpenGroupAPI.defaultServer) } diff --git a/Session/Settings/ChatSettingsViewController.swift b/Session/Settings/ChatSettingsViewController.swift index 1e013aea6..eba267dca 100644 --- a/Session/Settings/ChatSettingsViewController.swift +++ b/Session/Settings/ChatSettingsViewController.swift @@ -35,7 +35,7 @@ class ChatSettingsViewController: OWSTableViewController { messageTrimming.footerTitle = "MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION".localized() messageTrimming.add(OWSTableItem.switch( withText: "MESSAGE_TRIMMING_OPEN_GROUP_TITLE".localized(), - isOn: { GRDBStorage.shared[.trimOpenGroupMessagesOlderThanSixMonths] }, + isOn: { Storage.shared[.trimOpenGroupMessagesOlderThanSixMonths] }, target: self, selector: #selector(didToggleTrimOpenGroupsSwitch(_:)) )) @@ -47,7 +47,7 @@ class ChatSettingsViewController: OWSTableViewController { // MARK: - Actions @objc private func didToggleTrimOpenGroupsSwitch(_ sender: UISwitch) { - GRDBStorage.shared.writeAsync( + Storage.shared.writeAsync( updates: { db in db[.trimOpenGroupMessagesOlderThanSixMonths] = !sender.isOn }, diff --git a/Session/Settings/NukeDataModal.swift b/Session/Settings/NukeDataModal.swift index 823c7f474..3f2eb92e5 100644 --- a/Session/Settings/NukeDataModal.swift +++ b/Session/Settings/NukeDataModal.swift @@ -154,7 +154,7 @@ final class NukeDataModal: Modal { @objc private func clearDeviceOnly() { ModalActivityIndicatorViewController.present(fromViewController: self, canCancel: false) { [weak self] _ in - GRDBStorage.shared + Storage.shared .writeAsync { db in try MessageSender.syncConfiguration(db, forceSyncNow: true) } .ensure(on: DispatchQueue.main) { self?.deleteAllLocalData() diff --git a/Session/Settings/QRCodeVC.swift b/Session/Settings/QRCodeVC.swift index 64f766024..b29e8f62f 100644 --- a/Session/Settings/QRCodeVC.swift +++ b/Session/Settings/QRCodeVC.swift @@ -132,7 +132,7 @@ final class QRCodeVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControl presentAlert(alert) } else { - let maybeThread: SessionThread? = GRDBStorage.shared.write { db in + let maybeThread: SessionThread? = Storage.shared.write { db in try SessionThread.fetchOrCreate(db, id: hexEncodedPublicKey, variant: .contact) } diff --git a/Session/Settings/SeedModal.swift b/Session/Settings/SeedModal.swift index b514e1a11..0ac3c36c0 100644 --- a/Session/Settings/SeedModal.swift +++ b/Session/Settings/SeedModal.swift @@ -86,7 +86,7 @@ final class SeedModal: Modal { contentView.pin(.bottom, to: .bottom, of: stackView, withInset: spacing) // Mark seed as viewed - GRDBStorage.shared.write { db in db[.hasViewedSeed] = true } + Storage.shared.writeAsync { db in db[.hasViewedSeed] = true } } // MARK: - Interaction diff --git a/Session/Settings/SettingsVC.swift b/Session/Settings/SettingsVC.swift index 56e8b4500..4d852c241 100644 --- a/Session/Settings/SettingsVC.swift +++ b/Session/Settings/SettingsVC.swift @@ -602,8 +602,8 @@ final class SettingsVC: BaseVC, AvatarViewHelperDelegate { preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "Re-migrate", style: .destructive) { _ in - GRDBStorage.deleteDatabaseFiles() - try? GRDBStorage.deleteDbKeys() + Storage.deleteDatabaseFiles() + try? Storage.deleteDbKeys() exit(1) }) alert.addAction(UIAlertAction(title: "Cancel", style: .default)) diff --git a/Session/Utilities/BackgroundPoller.swift b/Session/Utilities/BackgroundPoller.swift index e09328840..8841286d3 100644 --- a/Session/Utilities/BackgroundPoller.swift +++ b/Session/Utilities/BackgroundPoller.swift @@ -19,7 +19,7 @@ public final class BackgroundPoller: NSObject { .appending(pollForMessages()) .appending(contentsOf: pollForClosedGroupMessages()) .appending( - contentsOf: GRDBStorage.shared + contentsOf: Storage.shared .read { db in // The default room promise creates an OpenGroup with an empty `roomToken` value, // we don't want to start a poller for this as the user hasn't actually joined a room @@ -58,7 +58,7 @@ public final class BackgroundPoller: NSObject { private static func pollForClosedGroupMessages() -> [Promise] { // Fetch all closed groups (excluding any don't contain the current user as a // GroupMemeber as the user is no longer a member of those) - return GRDBStorage.shared + return Storage.shared .read { db in try ClosedGroup .select(.threadId) @@ -92,7 +92,7 @@ public final class BackgroundPoller: NSObject { var jobsToRun: [Job] = [] - GRDBStorage.shared.write { db in + Storage.shared.write { db in var threadMessages: [String: [MessageReceiveJob.Details.MessageInfo]] = [:] messages.forEach { message in diff --git a/SessionMessagingKit/Calls/WebRTCSession.swift b/SessionMessagingKit/Calls/WebRTCSession.swift index 3f82bda0c..f6e4bf48a 100644 --- a/SessionMessagingKit/Calls/WebRTCSession.swift +++ b/SessionMessagingKit/Calls/WebRTCSession.swift @@ -170,7 +170,7 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { } } - GRDBStorage.shared + Storage.shared .writeAsync { db in try MessageSender .sendNonDurably( @@ -203,7 +203,7 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { let uuid: String = self.uuid let mediaConstraints: RTCMediaConstraints = mediaConstraints(false) - GRDBStorage.shared.writeAsync { [weak self] db in + Storage.shared.writeAsync { [weak self] db in guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: sessionId) else { seal.reject(Error.noThread) return @@ -268,7 +268,7 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { // Empty the queue self.queuedICECandidates.removeAll() - GRDBStorage.shared.writeAsync { db in + Storage.shared.writeAsync { db in guard let thread: SessionThread = try SessionThread.fetchOne(db, id: contactSessionId) else { return } SNLog("[Calls] Batch sending \(candidates.count) ICE candidates.") diff --git a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift index 4bcdc7e46..1184a4a4b 100644 --- a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -368,6 +368,6 @@ enum _001_InitialSetupMigration: Migration { t.column(.timestampMs, .integer).notNull() } - GRDBStorage.update(progress: 1, for: self, in: target) // In case this is the last migration + Storage.update(progress: 1, for: self, in: target) // In case this is the last migration } } diff --git a/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift index b488653b8..eab037d68 100644 --- a/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -52,6 +52,6 @@ enum _002_SetupStandardJobs: Migration { ).inserted(db) } - GRDBStorage.update(progress: 1, for: self, in: target) // In case this is the last migration + Storage.update(progress: 1, for: self, in: target) // In case this is the last migration } } diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index ec5db6db8..12682d06d 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -80,7 +80,7 @@ enum _003_YDBToGRDBMigration: Migration { legacyMigrations.insert(legacyMigration) } - GRDBStorage.update(progress: 0.01, for: self, in: target) + Storage.update(progress: 0.01, for: self, in: target) // MARK: --Contacts @@ -96,7 +96,7 @@ enum _003_YDBToGRDBMigration: Migration { forKey: SMKLegacy.blockedPhoneNumbersKey, inCollection: SMKLegacy.blockListCollection ) as? [String] ?? []) - GRDBStorage.update(progress: 0.02, for: self, in: target) + Storage.update(progress: 0.02, for: self, in: target) // MARK: --Threads @@ -196,7 +196,7 @@ enum _003_YDBToGRDBMigration: Migration { openGroupImage[thread.uniqueId] = transaction.object(forKey: openGroup.id, inCollection: SMKLegacy.openGroupImageCollection) as? Data } } - GRDBStorage.update(progress: 0.04, for: self, in: target) + Storage.update(progress: 0.04, for: self, in: target) // MARK: --Interactions @@ -245,7 +245,7 @@ enum _003_YDBToGRDBMigration: Migration { rowIndex += 1 - GRDBStorage.update( + Storage.update( progress: min( interactionsCompleteProgress, ((rowIndex / roughNumRows) * (interactionsCompleteProgress - startProgress)) @@ -254,7 +254,7 @@ enum _003_YDBToGRDBMigration: Migration { in: target ) } - GRDBStorage.update(progress: interactionsCompleteProgress, for: self, in: target) + Storage.update(progress: interactionsCompleteProgress, for: self, in: target) // MARK: --Attachments @@ -269,7 +269,7 @@ enum _003_YDBToGRDBMigration: Migration { attachments[key] = attachment } - GRDBStorage.update(progress: 0.21, for: self, in: target) + Storage.update(progress: 0.21, for: self, in: target) // MARK: --Read Receipts @@ -328,7 +328,7 @@ enum _003_YDBToGRDBMigration: Migration { guard let job = object as? SMKLegacy._AttachmentDownloadJob else { return } attachmentDownloadJobs.insert(job) } - GRDBStorage.update(progress: 0.22, for: self, in: target) + Storage.update(progress: 0.22, for: self, in: target) // MARK: --Preferences @@ -387,7 +387,7 @@ enum _003_YDBToGRDBMigration: Migration { .asType(NSNumber.self)? .doubleValue) .defaulting(to: (15 * 60)) - GRDBStorage.update(progress: 0.23, for: self, in: target) + Storage.update(progress: 0.23, for: self, in: target) } // We can't properly throw within the 'enumerateKeysAndObjects' block so have to throw here @@ -490,7 +490,7 @@ enum _003_YDBToGRDBMigration: Migration { } // Increment the progress for each contact - GRDBStorage.update( + Storage.update( progress: contactStartProgress + (progressPerContact * CGFloat(index + 1)), for: self, in: target @@ -1128,7 +1128,7 @@ enum _003_YDBToGRDBMigration: Migration { } // Increment the progress for each contact - GRDBStorage.update( + Storage.update( progress: ( threadInteractionsStartProgress + (progressPerInteraction * (interactionCounter + 1)) @@ -1400,7 +1400,7 @@ enum _003_YDBToGRDBMigration: Migration { )?.inserted(db) } } - GRDBStorage.update(progress: 0.99, for: self, in: target) + Storage.update(progress: 0.99, for: self, in: target) // MARK: - Preferences @@ -1440,7 +1440,7 @@ enum _003_YDBToGRDBMigration: Migration { db[.hasSentAMessage] = (legacyPreferences[SMKLegacy.preferencesKeyHasSentAMessageKey] as? Bool == true) db[.isReadyForAppExtensions] = CurrentAppContext().appUserDefaults().bool(forKey: SMKLegacy.preferencesKeyIsReadyForAppExtensions) - GRDBStorage.update(progress: 1, for: self, in: target) // In case this is the last migration + Storage.update(progress: 1, for: self, in: target) // In case this is the last migration } // MARK: - Convenience diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index d0201658a..e55f5f9ad 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -960,7 +960,7 @@ extension Attachment { // Save the final upload info let uploadedAttachment: Attachment? = { guard let db: Database = db else { - GRDBStorage.shared.write { db in + Storage.shared.write { db in try? Attachment .filter(id: attachmentId) .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploaded)) @@ -1016,7 +1016,7 @@ extension Attachment { // Update the attachment to the 'uploading' state let updatedAttachment: Attachment? = { guard let db: Database = db else { - GRDBStorage.shared.write { db in + Storage.shared.write { db in try? Attachment .filter(id: attachmentId) .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploading)) @@ -1041,7 +1041,7 @@ extension Attachment { // Perform the upload let uploadPromise: Promise = { guard let db: Database = db else { - return GRDBStorage.shared.read { db in upload(db, data) } + return Storage.shared.read { db in upload(db, data) } } return upload(db, data) @@ -1050,7 +1050,7 @@ extension Attachment { uploadPromise .done(on: queue) { fileId in // Save the final upload info - let uploadedAttachment: Attachment? = GRDBStorage.shared.write { db in + let uploadedAttachment: Attachment? = Storage.shared.write { db in try updatedAttachment? .with( serverId: "\(fileId)", @@ -1073,7 +1073,7 @@ extension Attachment { success?() } .catch(on: queue) { error in - GRDBStorage.shared.write { db in + Storage.shared.write { db in try Attachment .filter(id: attachmentId) .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedUpload)) diff --git a/SessionMessagingKit/Database/Models/Contact.swift b/SessionMessagingKit/Database/Models/Contact.swift index dca421b0a..57e93ae0c 100644 --- a/SessionMessagingKit/Database/Models/Contact.swift +++ b/SessionMessagingKit/Database/Models/Contact.swift @@ -117,7 +117,7 @@ public class SMKContact: NSObject { } @objc public static func fetchOrCreate(id: String) -> SMKContact { - let existingContact: Contact? = GRDBStorage.shared.read { db in + let existingContact: Contact? = Storage.shared.read { db in try Contact.fetchOne(db, id: id) } @@ -130,7 +130,7 @@ public class SMKContact: NSObject { @objc(isBlockedFor:) public static func isBlocked(id: String) -> Bool { - return GRDBStorage.shared + return Storage.shared .read { db in try Contact .select(.isBlocked) diff --git a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift index 042b6190f..08937d1e4 100644 --- a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift +++ b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift @@ -140,7 +140,7 @@ public class SMKDisappearingMessagesConfiguration: NSObject { @objc(isEnabledFor:) public static func isEnabled(for threadId: String) -> Bool { - return GRDBStorage.shared + return Storage.shared .read { db in try DisappearingMessagesConfiguration .select(.isEnabled) @@ -153,7 +153,7 @@ public class SMKDisappearingMessagesConfiguration: NSObject { @objc(durationIndexFor:) public static func durationIndex(for threadId: String) -> Int { - let durationSeconds: TimeInterval = GRDBStorage.shared + let durationSeconds: TimeInterval = Storage.shared .read { db in try DisappearingMessagesConfiguration .select(.durationSeconds) @@ -187,7 +187,7 @@ public class SMKDisappearingMessagesConfiguration: NSObject { DisappearingMessagesConfiguration.validDurationsSeconds[0] ) - GRDBStorage.shared.write { db in + Storage.shared.write { db in guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { return } diff --git a/SessionMessagingKit/Database/Models/GroupMember.swift b/SessionMessagingKit/Database/Models/GroupMember.swift index 0c6afa4f4..a59bfc417 100644 --- a/SessionMessagingKit/Database/Models/GroupMember.swift +++ b/SessionMessagingKit/Database/Models/GroupMember.swift @@ -65,7 +65,7 @@ public struct GroupMember: Codable, Equatable, FetchableRecord, PersistableRecor public class SMKGroupMember: NSObject { @objc(isCurrentUserMemberOf:) public static func isCurrentUserMember(of groupId: String) -> Bool { - return GRDBStorage.shared.read { db in + return Storage.shared.read { db in let userPublicKey: String = getUserHexEncodedPublicKey(db) let numEntries: Int = try GroupMember .filter(GroupMember.Columns.groupId == groupId) @@ -79,7 +79,7 @@ public class SMKGroupMember: NSObject { @objc(isCurrentUserAdminOf:) public static func isCurrentUserAdmin(of groupId: String) -> Bool { - return GRDBStorage.shared.read { db in + return Storage.shared.read { db in let userPublicKey: String = getUserHexEncodedPublicKey(db) let numEntries: Int = try GroupMember .filter(GroupMember.Columns.groupId == groupId) diff --git a/SessionMessagingKit/Database/Models/LinkPreview.swift b/SessionMessagingKit/Database/Models/LinkPreview.swift index 6aea5fa3b..21b3de9fa 100644 --- a/SessionMessagingKit/Database/Models/LinkPreview.swift +++ b/SessionMessagingKit/Database/Models/LinkPreview.swift @@ -214,7 +214,7 @@ public extension LinkPreview { private static var previewUrlCache: Atomic> = Atomic(NSCache()) static func previewUrl(for body: String?, selectedRange: NSRange? = nil) -> String? { - guard GRDBStorage.shared[.areLinkPreviewsEnabled] else { return nil } + guard Storage.shared[.areLinkPreviewsEnabled] else { return nil } guard let body: String = body else { return nil } if let cachedUrl = previewUrlCache.wrappedValue.object(forKey: body as NSString) as String? { @@ -291,7 +291,7 @@ public extension LinkPreview { // Exit early if link previews are not enabled in order to avoid // tainting the cache. - guard GRDBStorage.shared[.areLinkPreviewsEnabled] else { return } + guard Storage.shared[.areLinkPreviewsEnabled] else { return } serialQueue.sync { linkPreviewDraftCache = linkPreviewDraft @@ -299,7 +299,7 @@ public extension LinkPreview { } static func tryToBuildPreviewInfo(previewUrl: String?) -> Promise { - guard GRDBStorage.shared[.areLinkPreviewsEnabled] else { + guard Storage.shared[.areLinkPreviewsEnabled] else { return Promise(error: LinkPreviewError.featureDisabled) } guard let previewUrl: String = previewUrl else { diff --git a/SessionMessagingKit/Database/Models/OpenGroup.swift b/SessionMessagingKit/Database/Models/OpenGroup.swift index da3b236cc..069959b7c 100644 --- a/SessionMessagingKit/Database/Models/OpenGroup.swift +++ b/SessionMessagingKit/Database/Models/OpenGroup.swift @@ -205,7 +205,7 @@ extension OpenGroup: CustomStringConvertible, CustomDebugStringConvertible { public class SMKOpenGroup: NSObject { @objc(inviteUsers:toOpenGroupFor:) public static func invite(selectedUsers: Set, openGroupThreadId: String) { - GRDBStorage.shared.write { db in + Storage.shared.write { db in guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: openGroupThreadId) else { return } let urlString: String = "\(openGroup.server)/\(openGroup.roomToken)?public_key=\(openGroup.publicKey)" diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index 328de9fe6..393ea6fb0 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -219,7 +219,7 @@ public extension Profile { public extension Profile { static func fetchAllContactProfiles(excluding: Set = [], excludeCurrentUser: Bool = true) -> [Profile] { - return GRDBStorage.shared + return Storage.shared .read { db in let idsToExclude: Set = excluding .inserting(excludeCurrentUser ? getUserHexEncodedPublicKey(db) : nil) @@ -240,7 +240,7 @@ public extension Profile { static func displayName(_ db: Database? = nil, id: ID, threadVariant: SessionThread.Variant = .contact, customFallback: String? = nil) -> String { guard let db: Database = db else { - return GRDBStorage.shared + return Storage.shared .read { db in displayName(db, id: id, threadVariant: threadVariant, customFallback: customFallback) } .defaulting(to: (customFallback ?? id)) } @@ -253,7 +253,7 @@ public extension Profile { static func displayNameNoFallback(_ db: Database? = nil, id: ID, threadVariant: SessionThread.Variant = .contact) -> String? { guard let db: Database = db else { - return GRDBStorage.shared.read { db in displayNameNoFallback(db, id: id, threadVariant: threadVariant) } + return Storage.shared.read { db in displayNameNoFallback(db, id: id, threadVariant: threadVariant) } } return (try? Profile.fetchOne(db, id: id))? @@ -280,7 +280,7 @@ public extension Profile { static func fetchOrCreateCurrentUser() -> Profile { var userPublicKey: String = "" - let exisingProfile: Profile? = GRDBStorage.shared.read { db in + let exisingProfile: Profile? = Storage.shared.read { db in userPublicKey = getUserHexEncodedPublicKey(db) return try Profile.fetchOne(db, id: userPublicKey) @@ -307,7 +307,7 @@ public extension Profile { /// **Note:** This method intentionally does **not** save the newly created Profile, /// it will need to be explicitly saved after calling static func fetchOrCreate(id: String) -> Profile { - let exisingProfile: Profile? = GRDBStorage.shared.read { db in + let exisingProfile: Profile? = Storage.shared.read { db in try Profile.fetchOne(db, id: id) } @@ -401,7 +401,7 @@ public class SMKProfile: NSObject { @objc(displayNameAfterSavingNickname:forProfileId:) public static func displayNameAfterSaving(nickname: String?, for profileId: String) -> String { - return GRDBStorage.shared.write { db in + return Storage.shared.write { db in let profile: Profile = Profile.fetchOrCreate(id: profileId) let targetNickname: String? = ((nickname ?? "").count > 0 ? nickname : nil) diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index f1c94ecf9..9162392ef 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -275,14 +275,14 @@ public extension SessionThread { public class SMKThread: NSObject { @objc(deleteAll) public static func deleteAll() { - GRDBStorage.shared.writeAsync { db in + Storage.shared.writeAsync { db in _ = try SessionThread.deleteAll(db) } } @objc(isThreadMuted:) public static func isThreadMuted(_ threadId: String) -> Bool { - return GRDBStorage.shared.read { db in + return Storage.shared.read { db in let mutedUntilTimestamp: TimeInterval? = try SessionThread .select(SessionThread.Columns.mutedUntilTimestamp) .filter(id: threadId) @@ -296,7 +296,7 @@ public class SMKThread: NSObject { @objc(isOnlyNotifyingForMentions:) public static func isOnlyNotifyingForMentions(_ threadId: String) -> Bool { - return GRDBStorage.shared.read { db in + return Storage.shared.read { db in return try SessionThread .select(SessionThread.Columns.onlyNotifyForMentions == true) .filter(id: threadId) @@ -308,7 +308,7 @@ public class SMKThread: NSObject { @objc(setIsOnlyNotifyingForMentions:to:) public static func isOnlyNotifyingForMentions(_ threadId: String, isEnabled: Bool) { - GRDBStorage.shared.write { db in + Storage.shared.write { db in try SessionThread .filter(id: threadId) .updateAll(db, SessionThread.Columns.onlyNotifyForMentions.set(to: isEnabled)) @@ -317,7 +317,7 @@ public class SMKThread: NSObject { @objc(mutedUntilDateFor:) public static func mutedUntilDateFor(_ threadId: String) -> Date? { - return GRDBStorage.shared.read { db in + return Storage.shared.read { db in return try SessionThread .select(SessionThread.Columns.mutedUntilTimestamp) .filter(id: threadId) @@ -329,7 +329,7 @@ public class SMKThread: NSObject { @objc(updateWithMutedUntilDateTo:forThreadId:) public static func updateWithMutedUntilDate(to date: Date?, threadId: String) { - GRDBStorage.shared.write { db in + Storage.shared.write { db in try SessionThread .filter(id: threadId) .updateAll(db, SessionThread.Columns.mutedUntilTimestamp.set(to: date?.timeIntervalSince1970)) diff --git a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift index 9fa244677..33f5e0b93 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift @@ -22,7 +22,7 @@ public enum AttachmentDownloadJob: JobExecutor { let threadId: String = job.threadId, let detailsData: Data = job.details, let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData), - let attachment: Attachment = GRDBStorage.shared + let attachment: Attachment = Storage.shared .read({ db in try Attachment.fetchOne(db, id: details.attachmentId) }) else { failure(job, JobRunnerError.missingRequiredDetails, false) @@ -57,7 +57,7 @@ public enum AttachmentDownloadJob: JobExecutor { // then we should update the state of the attachment to be failed to avoid having attachments // appear in an endlessly downloading state if !otherCurrentJobAttachmentIds.contains(attachment.id) { - GRDBStorage.shared.write { db in + Storage.shared.write { db in _ = try Attachment .filter(id: attachment.id) .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedDownload)) @@ -75,7 +75,7 @@ public enum AttachmentDownloadJob: JobExecutor { } // Update to the 'downloading' state (no need to update the 'attachment' instance) - GRDBStorage.shared.write { db in + Storage.shared.write { db in try Attachment .filter(id: attachment.id) .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.downloading)) @@ -93,7 +93,7 @@ public enum AttachmentDownloadJob: JobExecutor { return Promise(error: AttachmentDownloadError.invalidUrl) } - let maybeOpenGroupDownloadPromise: Promise? = GRDBStorage.shared.read({ db in + let maybeOpenGroupDownloadPromise: Promise? = Storage.shared.read({ db in guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { return nil // Not an open group so just use standard FileServer upload } @@ -142,7 +142,7 @@ public enum AttachmentDownloadJob: JobExecutor { /// /// **Note:** We **MUST** use the `'with()` function here as it will update the /// `isValid` and `duration` values based on the downloaded data and the state - GRDBStorage.shared.write { db in + Storage.shared.write { db in _ = try attachment .with( state: .downloaded, @@ -189,7 +189,7 @@ public enum AttachmentDownloadJob: JobExecutor { /// /// **Note:** We **MUST** use the `'with()` function here as it will update the /// `isValid` and `duration` values based on the downloaded data and the state - GRDBStorage.shared.write { db in + Storage.shared.write { db in _ = try Attachment .filter(id: attachment.id) .updateAll(db, Attachment.Columns.state.set(to: targetState)) diff --git a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift index c2b7173ce..18a058f4f 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift @@ -22,7 +22,7 @@ public enum AttachmentUploadJob: JobExecutor { let threadId: String = job.threadId, let detailsData: Data = job.details, let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData), - let (attachment, openGroup): (Attachment, OpenGroup?) = GRDBStorage.shared.read({ db in + let (attachment, openGroup): (Attachment, OpenGroup?) = Storage.shared.read({ db in guard let attachment: Attachment = try Attachment.fetchOne(db, id: details.attachmentId) else { return nil } diff --git a/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift b/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift index ebfea6a9b..9ed31c7e7 100644 --- a/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift +++ b/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift @@ -20,7 +20,7 @@ public enum DisappearingMessagesJob: JobExecutor { let timestampNowMs: TimeInterval = (Date().timeIntervalSince1970 * 1000) var backgroundTask: OWSBackgroundTask? = OWSBackgroundTask(label: #function) - let updatedJob: Job? = GRDBStorage.shared.write { db in + let updatedJob: Job? = Storage.shared.write { db in _ = try Interaction .filter(Interaction.Columns.expiresStartedAtMs != nil) .filter((Interaction.Columns.expiresStartedAtMs + (Interaction.Columns.expiresInSeconds * 1000)) <= timestampNowMs) diff --git a/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift b/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift index 08fd8f2fe..a2d921eee 100644 --- a/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift +++ b/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift @@ -18,7 +18,7 @@ public enum FailedAttachmentDownloadsJob: JobExecutor { deferred: @escaping (Job) -> () ) { // Update all 'sending' message states to 'failed' - GRDBStorage.shared.write { db in + Storage.shared.write { db in let changeCount: Int = try Attachment .filter(Attachment.Columns.state == Attachment.State.downloading) .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedDownload)) diff --git a/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift b/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift index e66f0775b..b83e2e31e 100644 --- a/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift +++ b/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift @@ -18,7 +18,7 @@ public enum FailedMessageSendsJob: JobExecutor { deferred: @escaping (Job) -> () ) { // Update all 'sending' message states to 'failed' - GRDBStorage.shared.write { db in + Storage.shared.write { db in let changeCount: Int = try RecipientState .filter(RecipientState.Columns.state == RecipientState.State.sending) .updateAll(db, RecipientState.Columns.state.set(to: RecipientState.State.failed)) diff --git a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift index dd89ee21b..4ec8ae8b9 100644 --- a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift +++ b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift @@ -34,7 +34,7 @@ public enum GarbageCollectionJob: JobExecutor { .defaulting(to: Types.allCases) let timestampNow: TimeInterval = Date().timeIntervalSince1970 - GRDBStorage.shared.writeAsync( + Storage.shared.writeAsync( updates: { db in /// Remove any expired controlMessageProcessRecords if typesToCollect.contains(.expiredControlMessageProcessRecords) { @@ -229,7 +229,7 @@ public enum GarbageCollectionJob: JobExecutor { let profileAvatarFilenames: Set } - let maybeFileInfo: FileInfo? = GRDBStorage.shared.read { db -> FileInfo in + let maybeFileInfo: FileInfo? = Storage.shared.read { db -> FileInfo in var attachmentLocalRelativePaths: Set = [] var profileAvatarFilenames: Set = [] diff --git a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift index 3d4f158db..6822f1fe0 100644 --- a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift @@ -28,7 +28,7 @@ public enum MessageReceiveJob: JobExecutor { var updatedJob: Job = job var leastSevereError: Error? - GRDBStorage.shared.write { db in + Storage.shared.write { db in var remainingMessagesToProcess: [Details.MessageInfo] = [] for messageInfo in details.messages { diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift index e4ddef856..bad9defe0 100644 --- a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift @@ -41,7 +41,7 @@ public enum MessageSendJob: JobExecutor { // // Note: Normal attachments should be sent in a non-durable way but any // attachments for LinkPreviews and Quotes will be processed through this mechanism - let attachmentState: (shouldFail: Bool, shouldDefer: Bool)? = GRDBStorage.shared.write { db in + let attachmentState: (shouldFail: Bool, shouldDefer: Bool)? = Storage.shared.write { db in let allAttachmentStateInfo: [Attachment.StateInfo] = try Attachment .stateInfo(interactionId: interactionId) .fetchAll(db) @@ -131,7 +131,7 @@ public enum MessageSendJob: JobExecutor { details.message.threadId = (details.message.threadId ?? job.threadId) // Perform the actual message sending - GRDBStorage.shared.writeAsync { db -> Promise in + Storage.shared.writeAsync { db -> Promise in try MessageSender.sendImmediate( db, message: details.message, @@ -160,7 +160,7 @@ public enum MessageSendJob: JobExecutor { if details.message is VisibleMessage { guard let interactionId: Int64 = job.interactionId, - GRDBStorage.shared.read({ db in try Interaction.exists(db, id: interactionId) }) == true + Storage.shared.read({ db in try Interaction.exists(db, id: interactionId) }) == true else { // The message has been deleted so permanently fail the job failure(job, error, true) diff --git a/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift b/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift index 7e2c6e4a4..01c244019 100644 --- a/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift +++ b/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift @@ -27,7 +27,7 @@ public enum RetrieveDefaultOpenGroupRoomsJob: JobExecutor { // in the database so we need to create a dummy one to retrieve the default room data let defaultGroupId: String = OpenGroup.idFor(roomToken: "", server: OpenGroupAPI.defaultServer) - GRDBStorage.shared.write { db in + Storage.shared.write { db in guard try OpenGroup.exists(db, id: defaultGroupId) == false else { return } _ = try OpenGroup( diff --git a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift index b6bc5e5b9..c9e8b8af3 100644 --- a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift +++ b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift @@ -35,7 +35,7 @@ public enum SendReadReceiptsJob: JobExecutor { return } - GRDBStorage.shared + Storage.shared .writeAsync { db in try MessageSender.sendImmediate( db, @@ -53,7 +53,7 @@ public enum SendReadReceiptsJob: JobExecutor { var shouldFinishCurrentJob: Bool = false let nextRunTimestamp: TimeInterval = (Date().timeIntervalSince1970 + minRunFrequency) - let updatedJob: Job? = GRDBStorage.shared.write { db in + let updatedJob: Job? = Storage.shared.write { db in // If another 'sendReadReceipts' job was scheduled then update that one // to run at 'nextRunTimestamp' and make the current job stop if diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 57da8bfd3..c307e1a80 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -911,7 +911,7 @@ extension OpenGroupManager { cache: Atomic? = nil, onionApi: OnionRequestAPIType.Type? = nil, generalCache: Atomic? = nil, - storage: GRDBStorage? = nil, + storage: Storage? = nil, sodium: SodiumType? = nil, box: BoxType? = nil, genericHash: GenericHashType? = nil, diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift index 8bf2d87dd..a52576076 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift @@ -178,7 +178,7 @@ extension MessageSender { ) .done { /// Store it **after** having sent out the message to the group - GRDBStorage.shared.write { db in + Storage.shared.write { db in try newKeyPair.insert(db) distributingKeyPairs.mutate { @@ -514,7 +514,7 @@ extension MessageSender { // Remove the group from the database and unsubscribe from PNs ClosedGroupPoller.shared.stopPolling(for: groupPublicKey) - GRDBStorage.shared.write { db in + Storage.shared.write { db in try closedGroup .keyPairs .deleteAll(db) diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index bde98e927..1a4640753 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -166,7 +166,7 @@ extension MessageSender { if let error: Error = errors.first { return Promise(error: error) } - return GRDBStorage.shared.writeAsync { db in + return Storage.shared.writeAsync { db in try MessageSender.sendImmediate( db, message: message, diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index dde2ee13e..e84f370d7 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -221,7 +221,7 @@ public final class MessageSender { guard !isSuccess else { return } // Succeed as soon as the first promise succeeds isSuccess = true - GRDBStorage.shared.write { db in + Storage.shared.write { db in let responseJson: JSON? = try? JSONSerialization.jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? JSON message.serverHash = (responseJson?["hash"] as? String) @@ -291,7 +291,7 @@ public final class MessageSender { errorCount += 1 guard errorCount == promiseCount else { return } // Only error out if all promises failed - GRDBStorage.shared.write { db in + Storage.shared.write { db in handleFailure(db, with: .other(error)) } } @@ -300,7 +300,7 @@ public final class MessageSender { .catch(on: DispatchQueue.global(qos: .default)) { error in SNLog("Couldn't send message due to error: \(error).") - GRDBStorage.shared.write { db in + Storage.shared.write { db in handleFailure(db, with: .other(error)) } } @@ -682,7 +682,7 @@ public final class MessageSender { public class SMKMessageSender: NSObject { @objc(leaveClosedGroupWithPublicKey:) public static func objc_leave(_ groupPublicKey: String) -> AnyPromise { - let promise = GRDBStorage.shared.writeAsync { db in + let promise = Storage.shared.writeAsync { db in try MessageSender.leave(db, groupPublicKey: groupPublicKey) } @@ -691,7 +691,7 @@ public class SMKMessageSender: NSObject { @objc(forceSyncConfigurationNow) public static func objc_forceSyncConfigurationNow() { - GRDBStorage.shared.write { db in + Storage.shared.write { db in try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() } } diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift index 8aca7d16e..7aad64ac8 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift @@ -70,7 +70,7 @@ public final class PushNotificationAPI : NSObject { } // Unsubscribe from all closed groups (including ones the user is no longer a member of, just in case) - GRDBStorage.shared.read { db in + Storage.shared.read { db in let userPublicKey: String = getUserHexEncodedPublicKey(db) try ClosedGroup @@ -133,7 +133,7 @@ public final class PushNotificationAPI : NSObject { } // Subscribe to all closed groups - GRDBStorage.shared.read { db in + Storage.shared.read { db in try ClosedGroup .select(.threadId) .joining( diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift index b44928006..71c4f15a5 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift @@ -38,7 +38,7 @@ public final class ClosedGroupPoller { @objc public func start() { // Fetch all closed groups (excluding any don't contain the current user as a // GroupMemeber as the user is no longer a member of those) - GRDBStorage.shared + Storage.shared .read { db in try ClosedGroup .select(.threadId) @@ -66,7 +66,7 @@ public final class ClosedGroupPoller { } @objc public func stop() { - GRDBStorage.shared + Storage.shared .read { db in try ClosedGroup .select(.threadId) @@ -102,13 +102,13 @@ public final class ClosedGroupPoller { private func pollRecursively(_ groupPublicKey: String) { guard isPolling.wrappedValue[groupPublicKey] == true, - let thread: SessionThread = GRDBStorage.shared.read({ db in try SessionThread.fetchOne(db, id: groupPublicKey) }) + let thread: SessionThread = Storage.shared.read({ db in try SessionThread.fetchOne(db, id: groupPublicKey) }) else { return } // Get the received date of the last message in the thread. If we don't have any messages yet, pick some // reasonable fake time interval to use instead - let lastMessageDate: Date = GRDBStorage.shared + let lastMessageDate: Date = Storage.shared .read { db in try thread .interactions @@ -200,7 +200,7 @@ public final class ClosedGroupPoller { var jobToRun: Job? - GRDBStorage.shared.write { db in + Storage.shared.write { db in var jobDetailMessages: [MessageReceiveJob.Details.MessageInfo] = [] messages.forEach { message in diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift index be7f3c3cb..f63a29486 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift @@ -135,7 +135,7 @@ public final class Poller { if !messages.isEmpty { var messageCount: Int = 0 - GRDBStorage.shared.write { db in + Storage.shared.write { db in var threadMessages: [String: [MessageReceiveJob.Details.MessageInfo]] = [:] messages.forEach { message in diff --git a/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift b/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift index 71ebf2553..d2d99806e 100644 --- a/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift +++ b/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift @@ -32,7 +32,7 @@ public class TypingIndicators { // or show typing indicators for other users // // We also don't want to show/send typing indicators for message requests - guard GRDBStorage.shared[.typingIndicatorsEnabled] && !threadIsMessageRequest else { + guard Storage.shared[.typingIndicatorsEnabled] && !threadIsMessageRequest else { return nil } @@ -67,7 +67,7 @@ public class TypingIndicators { withTimeInterval: (direction == .outgoing ? 3 : 5), repeats: false ) { [weak self] _ in - GRDBStorage.shared.write { db in + Storage.shared.write { db in self?.stoping(db) } } @@ -122,7 +122,7 @@ public class TypingIndicators { withTimeInterval: 10, repeats: false ) { [weak self] _ in - GRDBStorage.shared.write { db in + Storage.shared.write { db in self?.scheduleRefreshCallback(db) } } diff --git a/SessionMessagingKit/Utilities/Preferences.swift b/SessionMessagingKit/Utilities/Preferences.swift index 414417396..868a37cc2 100644 --- a/SessionMessagingKit/Utilities/Preferences.swift +++ b/SessionMessagingKit/Utilities/Preferences.swift @@ -317,13 +317,13 @@ public class SMKPreferences: NSObject { } @objc public static func notificationPreviewType() -> Int { - return GRDBStorage.shared[.preferencesNotificationPreviewType] + return Storage.shared[.preferencesNotificationPreviewType] .defaulting(to: Preferences.NotificationPreviewType.nameAndPreview) .rawValue } @objc public static func setNotificationPreviewType(_ previewType: Int) { - GRDBStorage.shared.write { db in + Storage.shared.write { db in db[.preferencesNotificationPreviewType] = Preferences.NotificationPreviewType(rawValue: previewType) .defaulting(to: .nameAndPreview) } @@ -342,62 +342,62 @@ public class SMKPreferences: NSObject { @objc(setPlayNotificationSoundInForeground:) static func objc_setPlayNotificationSoundInForeground(_ enabled: Bool) { - GRDBStorage.shared.write { db in db[.playNotificationSoundInForeground] = enabled } + Storage.shared.write { db in db[.playNotificationSoundInForeground] = enabled } } @objc(playNotificationSoundInForeground) static func objc_playNotificationSoundInForeground() -> Bool { - return GRDBStorage.shared[.playNotificationSoundInForeground] + return Storage.shared[.playNotificationSoundInForeground] } @objc(setScreenSecurity:) static func objc_setScreenSecurity(_ enabled: Bool) { - GRDBStorage.shared.write { db in db[.appSwitcherPreviewEnabled] = enabled } + Storage.shared.write { db in db[.appSwitcherPreviewEnabled] = enabled } } @objc(isScreenSecurityEnabled) static func objc_isScreenSecurityEnabled() -> Bool { - return GRDBStorage.shared[.appSwitcherPreviewEnabled] + return Storage.shared[.appSwitcherPreviewEnabled] } @objc(setAreReadReceiptsEnabled:) static func objc_setAreReadReceiptsEnabled(_ enabled: Bool) { - GRDBStorage.shared.write { db in db[.areReadReceiptsEnabled] = enabled } + Storage.shared.write { db in db[.areReadReceiptsEnabled] = enabled } } @objc(areReadReceiptsEnabled) static func objc_areReadReceiptsEnabled() -> Bool { - return GRDBStorage.shared[.areReadReceiptsEnabled] + return Storage.shared[.areReadReceiptsEnabled] } @objc(setTypingIndicatorsEnabled:) static func objc_setTypingIndicatorsEnabled(_ enabled: Bool) { - GRDBStorage.shared.write { db in db[.typingIndicatorsEnabled] = enabled } + Storage.shared.write { db in db[.typingIndicatorsEnabled] = enabled } } @objc(areTypingIndicatorsEnabled) static func objc_areTypingIndicatorsEnabled() -> Bool { - return GRDBStorage.shared[.typingIndicatorsEnabled] + return Storage.shared[.typingIndicatorsEnabled] } @objc(setLinkPreviewsEnabled:) static func objc_setLinkPreviewsEnabled(_ enabled: Bool) { - GRDBStorage.shared.write { db in db[.areLinkPreviewsEnabled] = enabled } + Storage.shared.write { db in db[.areLinkPreviewsEnabled] = enabled } } @objc(areLinkPreviewsEnabled) static func objc_areLinkPreviewsEnabled() -> Bool { - return GRDBStorage.shared[.areLinkPreviewsEnabled] + return Storage.shared[.areLinkPreviewsEnabled] } @objc(setCallsEnabled:) static func objc_setCallsEnabled(_ enabled: Bool) { - GRDBStorage.shared.write { db in db[.areCallsEnabled] = enabled } + Storage.shared.write { db in db[.areCallsEnabled] = enabled } } @objc(areCallsEnabled) static func objc_areCallsEnabled() -> Bool { - return GRDBStorage.shared[.areCallsEnabled] + return Storage.shared[.areCallsEnabled] } } @@ -420,7 +420,7 @@ public class SMKSound: NSObject { } @objc public static var defaultNotificationSound: Int { - return GRDBStorage.shared[.defaultNotificationSound] + return Storage.shared[.defaultNotificationSound] .defaulting(to: Preferences.Sound.defaultNotificationSound) .rawValue } @@ -428,7 +428,7 @@ public class SMKSound: NSObject { @objc public static func setGlobalNotificationSound(_ sound: Int) { guard let sound: Preferences.Sound = Preferences.Sound(rawValue: sound) else { return } - GRDBStorage.shared.write { db in + Storage.shared.write { db in db[.defaultNotificationSound] = sound } } @@ -436,7 +436,7 @@ public class SMKSound: NSObject { @objc public static func notificationSound(for threadId: String?) -> Int { guard let threadId: String = threadId else { return defaultNotificationSound } - return (GRDBStorage.shared + return (Storage.shared .read { db in try Preferences.Sound .fetchOne( @@ -453,7 +453,7 @@ public class SMKSound: NSObject { @objc public static func setNotificationSound(_ sound: Int, forThreadId threadId: String) { guard let sound: Preferences.Sound = Preferences.Sound(rawValue: sound) else { return } - GRDBStorage.shared.write { db in + Storage.shared.write { db in try SessionThread .filter(id: threadId) .updateAll(db, SessionThread.Columns.notificationSound.set(to: sound)) diff --git a/SessionMessagingKit/Utilities/ProfileManager.swift b/SessionMessagingKit/Utilities/ProfileManager.swift index 4604cf104..170bee378 100644 --- a/SessionMessagingKit/Utilities/ProfileManager.swift +++ b/SessionMessagingKit/Utilities/ProfileManager.swift @@ -23,7 +23,7 @@ public struct ProfileManager { public static func profileAvatar(_ db: Database? = nil, id: String) -> UIImage? { guard let db: Database = db else { - return GRDBStorage.shared.read { db in profileAvatar(db, id: id) } + return Storage.shared.read { db in profileAvatar(db, id: id) } } guard let profile: Profile = try? Profile.fetchOne(db, id: id) else { return nil } @@ -145,7 +145,7 @@ public struct ProfileManager { .done(on: queue) { data in currentAvatarDownloads.mutate { $0.remove(profile.id) } - GRDBStorage.shared.write { db in + Storage.shared.write { db in guard let latestProfile: Profile = try Profile.fetchOne(db, id: profile.id) else { return } @@ -218,7 +218,7 @@ public struct ProfileManager { guard let avatarImage: UIImage = avatarImage else { // If we have no image then we need to make sure to remove it from the profile - GRDBStorage.shared.writeAsync { db in + Storage.shared.writeAsync { db in let existingProfile: Profile = Profile.fetchOrCreateCurrentUser(db) OWSLogger.verbose(existingProfile.profilePictureUrl != nil ? @@ -309,7 +309,7 @@ public struct ProfileManager { let downloadUrl: String = "\(FileServerAPI.server)/files/\(fileId)" UserDefaults.standard[.lastProfilePictureUpload] = Date() - GRDBStorage.shared.writeAsync { db in + Storage.shared.writeAsync { db in let profile: Profile = try Profile .fetchOrCreateCurrentUser(db) .with( diff --git a/SessionMessagingKit/Utilities/SMKDependencies.swift b/SessionMessagingKit/Utilities/SMKDependencies.swift index eea334c04..f7b8f4498 100644 --- a/SessionMessagingKit/Utilities/SMKDependencies.swift +++ b/SessionMessagingKit/Utilities/SMKDependencies.swift @@ -65,7 +65,7 @@ public class SMKDependencies: Dependencies { public init( onionApi: OnionRequestAPIType.Type? = nil, generalCache: Atomic? = nil, - storage: GRDBStorage? = nil, + storage: Storage? = nil, sodium: SodiumType? = nil, box: BoxType? = nil, genericHash: GenericHashType? = nil, diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift index a6321c9b5..50a99e225 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift @@ -15,7 +15,7 @@ class OpenGroupAPISpec: QuickSpec { // MARK: - Spec override func spec() { - var mockStorage: GRDBStorage! + var mockStorage: Storage! var mockSodium: MockSodium! var mockAeadXChaCha20Poly1305Ietf: MockAeadXChaCha20Poly1305Ietf! var mockSign: MockSign! @@ -33,7 +33,7 @@ class OpenGroupAPISpec: QuickSpec { // MARK: - Configuration beforeEach { - mockStorage = GRDBStorage( + mockStorage = Storage( customWriter: DatabaseQueue(), customMigrations: [ SNUtilitiesKit.migrations(), diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift index 2f82fbaa0..d0f84429f 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -73,7 +73,7 @@ class OpenGroupManagerSpec: QuickSpec { override func spec() { var mockOGMCache: MockOGMCache! var mockGeneralCache: MockGeneralCache! - var mockStorage: GRDBStorage! + var mockStorage: Storage! var mockSodium: MockSodium! var mockAeadXChaCha20Poly1305Ietf: MockAeadXChaCha20Poly1305Ietf! var mockGenericHash: MockGenericHash! @@ -99,7 +99,7 @@ class OpenGroupManagerSpec: QuickSpec { beforeEach { mockOGMCache = MockOGMCache() mockGeneralCache = MockGeneralCache() - mockStorage = GRDBStorage( + mockStorage = Storage( customWriter: DatabaseQueue(), customMigrations: [ SNUtilitiesKit.migrations(), diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverDecryptionSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverDecryptionSpec.swift index f083cb217..760290e27 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverDecryptionSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverDecryptionSpec.swift @@ -14,7 +14,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { // MARK: - Spec override func spec() { - var mockStorage: GRDBStorage! + var mockStorage: Storage! var mockSodium: MockSodium! var mockBox: MockBox! var mockGenericHash: MockGenericHash! @@ -25,7 +25,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { describe("a MessageReceiver") { beforeEach { - mockStorage = GRDBStorage( + mockStorage = Storage( customWriter: DatabaseQueue(), customMigrations: [ SNUtilitiesKit.migrations(), diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageSenderEncryptionSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageSenderEncryptionSpec.swift index c8966abb7..2016fa2a0 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageSenderEncryptionSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageSenderEncryptionSpec.swift @@ -14,7 +14,7 @@ class MessageSenderEncryptionSpec: QuickSpec { // MARK: - Spec override func spec() { - var mockStorage: GRDBStorage! + var mockStorage: Storage! var mockBox: MockBox! var mockSign: MockSign! var mockNonce24Generator: MockNonce24Generator! @@ -22,7 +22,7 @@ class MessageSenderEncryptionSpec: QuickSpec { describe("a MessageSender") { beforeEach { - mockStorage = GRDBStorage( + mockStorage = Storage( customWriter: DatabaseQueue(), customMigrations: [ SNUtilitiesKit.migrations(), diff --git a/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift b/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift index d7e2e0da0..5c2f8de5d 100644 --- a/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift +++ b/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift @@ -10,7 +10,7 @@ extension SMKDependencies { public func with( onionApi: OnionRequestAPIType.Type? = nil, generalCache: Atomic? = nil, - storage: GRDBStorage? = nil, + storage: Storage? = nil, sodium: SodiumType? = nil, box: BoxType? = nil, genericHash: GenericHashType? = nil, diff --git a/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift b/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift index fa5883b73..d559bdfec 100644 --- a/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift +++ b/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift @@ -11,7 +11,7 @@ extension OpenGroupManager.OGMDependencies { cache: Atomic? = nil, onionApi: OnionRequestAPIType.Type? = nil, generalCache: Atomic? = nil, - storage: GRDBStorage? = nil, + storage: Storage? = nil, sodium: SodiumType? = nil, box: BoxType? = nil, genericHash: GenericHashType? = nil, diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index 66cee426b..bb701f1be 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -60,7 +60,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension // 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 - GRDBStorage.shared.write { db in + Storage.shared.write { db in do { guard let processedMessage: ProcessedMessage = try Message.processRawReceivedMessageAsNotification(db, envelope: envelope) else { self.handleFailure(for: notificationContent) @@ -164,7 +164,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension // 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 GRDBStorage.shared[.isReadyForAppExtensions] else { return completeSilenty() } + guard Storage.shared[.isReadyForAppExtensions] else { return completeSilenty() } AppSetup.setupEnvironment( appSpecificBlock: { @@ -187,7 +187,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension // If we need a config sync then trigger it now if needsConfigSync { - GRDBStorage.shared.write { db in + Storage.shared.write { db in try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() } } @@ -203,7 +203,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension guard !AppReadiness.isAppReady() else { return } // App isn't ready until storage is ready AND all version migrations are complete. - guard GRDBStorage.shared.isValid && areVersionMigrationsComplete else { return } + guard Storage.shared.isValid && areVersionMigrationsComplete else { return } SignalUtilitiesKit.Configuration.performMainSetup() @@ -297,7 +297,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension // MARK: - Poll for open groups private func pollForOpenGroups() -> [Promise] { - let promises: [Promise] = GRDBStorage.shared + let promises: [Promise] = Storage.shared .read { db in // The default room promise creates an OpenGroup with an empty `roomToken` value, // we don't want to start a poller for this as the user hasn't actually joined a room diff --git a/SessionShareExtension/ShareVC.swift b/SessionShareExtension/ShareVC.swift index 49541e6f5..d33cf0045 100644 --- a/SessionShareExtension/ShareVC.swift +++ b/SessionShareExtension/ShareVC.swift @@ -74,7 +74,7 @@ final class ShareVC : UINavigationController, ShareViewDelegate, AppModeManagerD // If we need a config sync then trigger it now if needsConfigSync { - GRDBStorage.shared.write { db in + Storage.shared.write { db in try? MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() } } @@ -88,7 +88,7 @@ final class ShareVC : UINavigationController, ShareViewDelegate, AppModeManagerD // App isn't ready until storage is ready AND all version migrations are complete. guard areVersionMigrationsComplete else { return } - guard GRDBStorage.shared.isValid else { return } + guard Storage.shared.isValid else { return } guard !AppReadiness.isAppReady() else { // Only mark the app as ready once. return diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index 82d4f99ab..b75214326 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -143,7 +143,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView private func startObservingChanges() { // Start observing for data changes - dataChangeObservable = GRDBStorage.shared.start( + dataChangeObservable = Storage.shared.start( viewModel.observableViewData, onError: { _ in }, onChange: { [weak self] viewData in @@ -220,7 +220,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView shareVC?.dismiss(animated: true, completion: nil) ModalActivityIndicatorViewController.present(fromViewController: shareVC!, canCancel: false, message: "vc_share_sending_message".localized()) { activityIndicator in - GRDBStorage.shared + Storage.shared .writeAsync { [weak self] db -> Promise in guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { activityIndicator.dismiss { } diff --git a/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift index 4ca4a6730..dc023922c 100644 --- a/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -52,6 +52,6 @@ enum _001_InitialSetupMigration: Migration { t.uniqueKey([.key, .hash]) } - GRDBStorage.update(progress: 1, for: self, in: target) // In case this is the last migration + Storage.update(progress: 1, for: self, in: target) // In case this is the last migration } } diff --git a/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift index 9bf961039..7284c4af4 100644 --- a/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -21,6 +21,6 @@ enum _002_SetupStandardJobs: Migration { ).inserted(db) } - GRDBStorage.update(progress: 1, for: self, in: target) // In case this is the last migration + Storage.update(progress: 1, for: self, in: target) // In case this is the last migration } } diff --git a/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 0eae08fa3..54f3421d7 100644 --- a/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -67,7 +67,7 @@ enum _003_YDBToGRDBMigration: Migration { snodeSetResult["\(SnodeSet.onionRequestPathPrefix)1"] = [ path1Snode0, path1Snode1, path1Snode2 ] } } - GRDBStorage.update(progress: 0.02, for: self, in: target) + Storage.update(progress: 0.02, for: self, in: target) // MARK: --SnodePool @@ -100,7 +100,7 @@ enum _003_YDBToGRDBMigration: Migration { collectionIndex += 1 - GRDBStorage.update( + Storage.update( progress: min( swarmCompleteProgress, ((collectionIndex / roughNumCollections) * (swarmCompleteProgress - startProgress)) @@ -109,7 +109,7 @@ enum _003_YDBToGRDBMigration: Migration { in: target ) } - GRDBStorage.update(progress: swarmCompleteProgress, for: self, in: target) + Storage.update(progress: swarmCompleteProgress, for: self, in: target) for swarmCollection in swarmCollections { let collection: String = "\(SSKLegacy.swarmCollectionPrefix)\(swarmCollection)" @@ -120,7 +120,7 @@ enum _003_YDBToGRDBMigration: Migration { snodeSetResult[swarmCollection] = (snodeSetResult[swarmCollection] ?? Set()).inserting(snode) } } - GRDBStorage.update(progress: 0.92, for: self, in: target) + Storage.update(progress: 0.92, for: self, in: target) // MARK: --Received message hashes @@ -128,7 +128,7 @@ enum _003_YDBToGRDBMigration: Migration { guard let hashSet = object as? Set else { return } receivedMessageResults[key] = hashSet } - GRDBStorage.update(progress: 0.93, for: self, in: target) + Storage.update(progress: 0.93, for: self, in: target) // MARK: --Last message info @@ -141,7 +141,7 @@ enum _003_YDBToGRDBMigration: Migration { lastMessageResults[key] = (lastMessageHash, lastMessageJson) receivedMessageResults[key] = receivedMessageResults[key]?.removing(lastMessageHash) } - GRDBStorage.update(progress: 0.94, for: self, in: target) + Storage.update(progress: 0.94, for: self, in: target) } // MARK: - Insert into GRDB @@ -161,7 +161,7 @@ enum _003_YDBToGRDBMigration: Migration { x25519PublicKey: legacySnode.publicKeySet.x25519Key ).insert(db) } - GRDBStorage.update(progress: 0.96, for: self, in: target) + Storage.update(progress: 0.96, for: self, in: target) // MARK: --SnodeSets @@ -176,7 +176,7 @@ enum _003_YDBToGRDBMigration: Migration { ).insert(db) } } - GRDBStorage.update(progress: 0.98, for: self, in: target) + Storage.update(progress: 0.98, for: self, in: target) } try autoreleasepool { @@ -191,7 +191,7 @@ enum _003_YDBToGRDBMigration: Migration { ).inserted(db) } } - GRDBStorage.update(progress: 0.99, for: self, in: target) + Storage.update(progress: 0.99, for: self, in: target) // MARK: --Last Message Hash @@ -209,6 +209,6 @@ enum _003_YDBToGRDBMigration: Migration { } } - GRDBStorage.update(progress: 1, for: self, in: target) // In case this is the last migration + Storage.update(progress: 1, for: self, in: target) // In case this is the last migration } } diff --git a/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift b/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift index ee65b75c9..e9b5fbfbb 100644 --- a/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift +++ b/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift @@ -69,7 +69,7 @@ public extension SnodeReceivedMessageInfo { public extension SnodeReceivedMessageInfo { static func pruneExpiredMessageHashInfo(for snode: Snode, namespace: Int, associatedWith publicKey: String) { // Delete any expired SnodeReceivedMessageInfo values associated to a specific node - GRDBStorage.shared.write { db in + Storage.shared.write { db in // Only prune the hashes if new hashes exist for this Snode (if they don't then we don't want // to clear out the legacy hashes) let hasNonLegacyHash: Bool = try SnodeReceivedMessageInfo @@ -91,7 +91,7 @@ public extension SnodeReceivedMessageInfo { /// this method to be called after the hash value has been updated but before the various `read` threads have been updated, resulting in a /// pointless fetch for data the app has already received static func fetchLastNotExpired(for snode: Snode, namespace: Int, associatedWith publicKey: String) -> SnodeReceivedMessageInfo? { - return GRDBStorage.shared.read { db in + return Storage.shared.read { db in let nonLegacyHash: SnodeReceivedMessageInfo? = try SnodeReceivedMessageInfo .filter(SnodeReceivedMessageInfo.Columns.key == key(for: snode, publicKey: publicKey, namespace: namespace)) .filter(SnodeReceivedMessageInfo.Columns.expirationDateMs > (Date().timeIntervalSince1970 * 1000)) diff --git a/SessionSnodeKit/OnionRequestAPI.swift b/SessionSnodeKit/OnionRequestAPI.swift index 918fa00e1..739430af6 100644 --- a/SessionSnodeKit/OnionRequestAPI.swift +++ b/SessionSnodeKit/OnionRequestAPI.swift @@ -36,7 +36,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { get { if let paths: [[Snode]] = _paths { return paths } - let results: [[Snode]]? = GRDBStorage.shared.read { db in + let results: [[Snode]]? = Storage.shared.read { db in try? Snode.fetchAllOnionRequestPaths(db) } @@ -180,7 +180,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { .map2 { paths in OnionRequestAPI.paths = paths + reusablePaths - GRDBStorage.shared.write { db in + Storage.shared.write { db in SNLog("Persisting onion request paths to database.") try? paths.save(db) } @@ -281,7 +281,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { let newPaths = oldPaths + [ path ] paths = newPaths - GRDBStorage.shared.write { db in + Storage.shared.write { db in SNLog("Persisting onion request paths to database.") try? newPaths.save(db) } @@ -297,7 +297,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { paths.remove(at: pathIndex) OnionRequestAPI.paths = paths - GRDBStorage.shared.write { db in + Storage.shared.write { db in guard !paths.isEmpty else { SNLog("Clearing onion request paths.") try? Snode.clearOnionRequestPaths(db) diff --git a/SessionSnodeKit/SnodeAPI.swift b/SessionSnodeKit/SnodeAPI.swift index 1bf2e988e..bda5ad482 100644 --- a/SessionSnodeKit/SnodeAPI.swift +++ b/SessionSnodeKit/SnodeAPI.swift @@ -53,7 +53,7 @@ public final class SnodeAPI { private static func loadSnodePoolIfNeeded() { guard !hasLoadedSnodePool else { return } - GRDBStorage.shared.read { db in + Storage.shared.read { db in snodePool = ((try? Snode.fetchSet(db)) ?? Set()) } @@ -68,7 +68,7 @@ public final class SnodeAPI { newValue.forEach { try? $0.save(db) } } else { - GRDBStorage.shared.write { db in + Storage.shared.write { db in _ = try? Snode.deleteAll(db) newValue.forEach { try? $0.save(db) } } @@ -96,7 +96,7 @@ public final class SnodeAPI { private static func loadSwarmIfNeeded(for publicKey: String) { guard !loadedSwarms.contains(publicKey) else { return } - GRDBStorage.shared.read { db in + Storage.shared.read { db in swarmCache[publicKey] = ((try? Snode.fetchSet(db, publicKey: publicKey)) ?? []) } @@ -110,7 +110,7 @@ public final class SnodeAPI { swarmCache[publicKey] = newValue guard persist else { return } - GRDBStorage.shared.write { db in + Storage.shared.write { db in try? newValue.save(db, key: publicKey) } } @@ -299,7 +299,7 @@ public final class SnodeAPI { public static func getSnodePool() -> Promise> { loadSnodePoolIfNeeded() let now = Date() - let hasSnodePoolExpired = given(GRDBStorage.shared[.lastSnodePoolRefreshDate]) { + let hasSnodePoolExpired = given(Storage.shared[.lastSnodePoolRefreshDate]) { now.timeIntervalSince($0) > 2 * 60 * 60 }.defaulting(to: true) let snodePool: Set = SnodeAPI.snodePool @@ -327,7 +327,7 @@ public final class SnodeAPI { promise.then2 { snodePool -> Promise> in let (promise, seal) = Promise>.pending() - GRDBStorage.shared.writeAsync( + Storage.shared.writeAsync( updates: { db in db[.lastSnodePoolRefreshDate] = now setSnodePool(to: snodePool, db: db) @@ -537,7 +537,7 @@ public final class SnodeAPI { private static func getMessagesWithAuthentication(from snode: Snode, associatedWith publicKey: String, namespace: Int) -> Promise<[SnodeReceivedMessage]> { /// **Note:** All authentication logic is only apply to 1-1 chats, the reason being that we can't currently support it yet for /// closed groups. The Storage Server requires an ed25519 key pair, but we don't have that for our closed groups. - guard let userED25519KeyPair: Box.KeyPair = GRDBStorage.shared.read({ db in Identity.fetchUserEd25519KeyPair(db) }) else { + guard let userED25519KeyPair: Box.KeyPair = Storage.shared.read({ db in Identity.fetchUserEd25519KeyPair(db) }) else { return Promise(error: SnodeAPIError.noKeyPair) } @@ -640,7 +640,7 @@ public final class SnodeAPI { let messageJson: JSON = try? JSONSerialization.jsonObject(with: messageData, options: [ .fragmentsAllowed ]) as? JSON else { return Promise(error: HTTP.Error.invalidJSON) } - guard let userED25519KeyPair: Box.KeyPair = GRDBStorage.shared.read({ db in Identity.fetchUserEd25519KeyPair(db) }) else { + guard let userED25519KeyPair: Box.KeyPair = Storage.shared.read({ db in Identity.fetchUserEd25519KeyPair(db) }) else { return Promise(error: SnodeAPIError.noKeyPair) } diff --git a/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift index afa37ed32..1e93b41ab 100644 --- a/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -65,6 +65,6 @@ enum _001_InitialSetupMigration: Migration { t.column(.value, .blob).notNull() } - GRDBStorage.update(progress: 1, for: self, in: target) // In case this is the last migration + Storage.update(progress: 1, for: self, in: target) // In case this is the last migration } } diff --git a/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift index 00b8f4b81..f8a13021b 100644 --- a/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -21,6 +21,6 @@ enum _002_SetupStandardJobs: Migration { ).inserted(db) } - GRDBStorage.update(progress: 1, for: self, in: target) // In case this is the last migration + Storage.update(progress: 1, for: self, in: target) // In case this is the last migration } } diff --git a/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 2d087efff..8dfd91deb 100644 --- a/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -114,6 +114,6 @@ enum _003_YDBToGRDBMigration: Migration { ).insert(db) } - GRDBStorage.update(progress: 1, for: self, in: target) // In case this is the last migration + Storage.update(progress: 1, for: self, in: target) // In case this is the last migration } } diff --git a/SessionUtilitiesKit/Database/Models/Identity.swift b/SessionUtilitiesKit/Database/Models/Identity.swift index 39517fa58..cc9749ce8 100644 --- a/SessionUtilitiesKit/Database/Models/Identity.swift +++ b/SessionUtilitiesKit/Database/Models/Identity.swift @@ -70,7 +70,7 @@ public extension Identity { } static func store(seed: Data, ed25519KeyPair: Sign.KeyPair, x25519KeyPair: ECKeyPair) { - GRDBStorage.shared.write { db in + Storage.shared.write { db in try Identity(variant: .seed, data: seed).save(db) try Identity(variant: .ed25519SecretKey, data: Data(ed25519KeyPair.secretKey)).save(db) try Identity(variant: .ed25519PublicKey, data: Data(ed25519KeyPair.publicKey)).save(db) @@ -85,7 +85,7 @@ public extension Identity { static func fetchUserPublicKey(_ db: Database? = nil) -> Data? { guard let db: Database = db else { - return GRDBStorage.shared.read { db in fetchUserPublicKey(db) } + return Storage.shared.read { db in fetchUserPublicKey(db) } } return try? Identity.fetchOne(db, id: .x25519PublicKey)?.data @@ -93,7 +93,7 @@ public extension Identity { static func fetchUserPrivateKey(_ db: Database? = nil) -> Data? { guard let db: Database = db else { - return GRDBStorage.shared.read { db in fetchUserPrivateKey(db) } + return Storage.shared.read { db in fetchUserPrivateKey(db) } } return try? Identity.fetchOne(db, id: .x25519PrivateKey)?.data @@ -101,7 +101,7 @@ public extension Identity { static func fetchUserKeyPair(_ db: Database? = nil) -> Box.KeyPair? { guard let db: Database = db else { - return GRDBStorage.shared.read { db in fetchUserKeyPair(db) } + return Storage.shared.read { db in fetchUserKeyPair(db) } } guard let publicKey: Data = fetchUserPublicKey(db), @@ -116,7 +116,7 @@ public extension Identity { static func fetchUserEd25519KeyPair(_ db: Database? = nil) -> Box.KeyPair? { guard let db: Database = db else { - return GRDBStorage.shared.read { db in fetchUserEd25519KeyPair(db) } + return Storage.shared.read { db in fetchUserEd25519KeyPair(db) } } guard let publicKey: Data = try? Identity.fetchOne(db, id: .ed25519PublicKey)?.data, @@ -130,7 +130,7 @@ public extension Identity { } static func fetchHexEncodedSeed() -> String? { - return GRDBStorage.shared.read { db in + return Storage.shared.read { db in guard let data: Data = try? Identity.fetchOne(db, id: .seed)?.data else { return nil } @@ -159,7 +159,7 @@ public extension Identity { public class SUKIdentity: NSObject { @objc(userExists) public static func userExists() -> Bool { - return GRDBStorage.shared + return Storage.shared .read { db in Identity.userExists(db) } .defaulting(to: false) } diff --git a/SessionUtilitiesKit/Database/Models/Setting.swift b/SessionUtilitiesKit/Database/Models/Setting.swift index bf31436a9..c3060a746 100644 --- a/SessionUtilitiesKit/Database/Models/Setting.swift +++ b/SessionUtilitiesKit/Database/Models/Setting.swift @@ -148,7 +148,7 @@ public protocol EnumSetting: RawRepresentable where RawValue == Int {} // MARK: - GRDB Interactions -public extension GRDBStorage { +public extension Storage { subscript(key: Setting.BoolKey) -> Bool { // Default to false if it doesn't exist return (read { db in db[key] } ?? false) diff --git a/SessionUtilitiesKit/Database/GRDBStorage.swift b/SessionUtilitiesKit/Database/Storage.swift similarity index 93% rename from SessionUtilitiesKit/Database/GRDBStorage.swift rename to SessionUtilitiesKit/Database/Storage.swift index ddda89bef..563ce2ed0 100644 --- a/SessionUtilitiesKit/Database/GRDBStorage.swift +++ b/SessionUtilitiesKit/Database/Storage.swift @@ -5,16 +5,16 @@ import GRDB import PromiseKit import SignalCoreKit -public final class GRDBStorage { +public final class Storage { private static let dbFileName: String = "Session.sqlite" private static let keychainService: String = "TSKeyChainService" private static let dbCipherKeySpecKey: String = "GRDBDatabaseCipherKeySpec" private static let kSQLCipherKeySpecLength: Int32 = 48 private static var sharedDatabaseDirectoryPath: String { "\(OWSFileSystem.appSharedDataDirectoryPath())/database" } - private static var databasePath: String { "\(GRDBStorage.sharedDatabaseDirectoryPath)/\(GRDBStorage.dbFileName)" } - private static var databasePathShm: String { "\(GRDBStorage.sharedDatabaseDirectoryPath)/\(GRDBStorage.dbFileName)-shm" } - private static var databasePathWal: String { "\(GRDBStorage.sharedDatabaseDirectoryPath)/\(GRDBStorage.dbFileName)-wal" } + 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 isDatabasePasswordAccessible: Bool { guard (try? getDatabaseCipherKeySpec()) != nil else { return false } @@ -22,7 +22,7 @@ public final class GRDBStorage { return true } - public static let shared: GRDBStorage = GRDBStorage() + public static let shared: Storage = Storage() public private(set) var isValid: Bool = false public private(set) var hasCompletedMigrations: Bool = false @@ -36,11 +36,10 @@ public final class GRDBStorage { customWriter: DatabaseWriter? = nil, customMigrations: [TargetMigrations]? = 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 - OWSFileSystem.ensureDirectoryExists(GRDBStorage.sharedDatabaseDirectoryPath) - OWSFileSystem.protectFileOrFolder(atPath: GRDBStorage.sharedDatabaseDirectoryPath) + OWSFileSystem.ensureDirectoryExists(Storage.sharedDatabaseDirectoryPath) + OWSFileSystem.protectFileOrFolder(atPath: Storage.sharedDatabaseDirectoryPath) // If a custom writer was provided then use that (for unit testing) guard customWriter == nil else { @@ -55,15 +54,16 @@ public final class GRDBStorage { // // Note: We reset the bytes immediately after generation to ensure the database key doesn't hang // around in memory unintentionally - var tmpKeySpec: Data = GRDBStorage.getOrGenerateDatabaseKeySpec() + var tmpKeySpec: Data = Storage.getOrGenerateDatabaseKeySpec() tmpKeySpec.resetBytes(in: 0..(_ value: (Database) throws -> Promise) -> Promise { guard isValid, let dbWriter: DatabaseWriter = dbWriter else { diff --git a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift index 561bb409e..16d482baf 100644 --- a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift +++ b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift @@ -375,7 +375,7 @@ public class PagedDatabaseObserver: TransactionObserver where let orderSQL: SQL = self.orderSQL let dataQuery: ([Int64]) -> AdaptedFetchRequest> = self.dataQuery - let loadedPage: (data: [T]?, pageInfo: PagedData.PageInfo)? = GRDBStorage.shared.read { [weak self] db in + let loadedPage: (data: [T]?, pageInfo: PagedData.PageInfo)? = Storage.shared.read { [weak self] db in let totalCount: Int = PagedData.totalCount( db, tableName: pagedTableName, diff --git a/SessionUtilitiesKit/General/Dependencies.swift b/SessionUtilitiesKit/General/Dependencies.swift index 3e7525fcc..5ac20c999 100644 --- a/SessionUtilitiesKit/General/Dependencies.swift +++ b/SessionUtilitiesKit/General/Dependencies.swift @@ -9,9 +9,9 @@ open class Dependencies { set { _generalCache = newValue } } - public var _storage: GRDBStorage? - public var storage: GRDBStorage { - get { Dependencies.getValueSettingIfNull(&_storage) { GRDBStorage.shared } } + public var _storage: Storage? + public var storage: Storage { + get { Dependencies.getValueSettingIfNull(&_storage) { Storage.shared } } set { _storage = newValue } } @@ -31,7 +31,7 @@ open class Dependencies { public init( generalCache: Atomic? = nil, - storage: GRDBStorage? = nil, + storage: Storage? = nil, standardUserDefaults: UserDefaultsType? = nil, date: Date? = nil ) { diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index 2e6055d93..c05a732aa 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -175,7 +175,7 @@ public final class JobRunner { public static func appDidFinishLaunching() { // Note: 'appDidBecomeActive' will run on first launch anyway so we can // leave those jobs out and can wait until then to start the JobRunner - let jobsToRun: (blocking: [Job], nonBlocking: [Job]) = GRDBStorage.shared + let jobsToRun: (blocking: [Job], nonBlocking: [Job]) = Storage.shared .read { db in let blockingJobs: [Job] = try Job .filter( @@ -222,7 +222,7 @@ public final class JobRunner { // long as there are no other jobs already running let alreadyRunningOtherJobs: Bool = queues.wrappedValue .contains(where: { _, queue -> Bool in queue.isRunning.wrappedValue }) - let jobsToRun: (blocking: [Job], nonBlocking: [Job]) = GRDBStorage.shared + let jobsToRun: (blocking: [Job], nonBlocking: [Job]) = Storage.shared .read { db in guard !alreadyRunningOtherJobs else { let onActiveJobs: [Job] = try Job @@ -548,7 +548,7 @@ private final class JobQueue { // Get any pending jobs let jobIdsAlreadyRunning: Set = jobsCurrentlyRunning.wrappedValue let jobsAlreadyInQueue: Set = queue.wrappedValue.compactMap { $0.id }.asSet() - let jobsToRun: [Job] = GRDBStorage.shared.read { db in + let jobsToRun: [Job] = Storage.shared.read { db in try Job.filterPendingJobs(variants: jobVariants) .filter(!jobIdsAlreadyRunning.contains(Job.Columns.id)) // Exclude jobs already running .filter(!jobsAlreadyInQueue.contains(Job.Columns.id)) // Exclude jobs already in the queue @@ -623,7 +623,7 @@ private final class JobQueue { } // Check if the next job has any dependencies - let dependencyInfo: (expectedCount: Int, jobs: [Job]) = GRDBStorage.shared.read { db in + let dependencyInfo: (expectedCount: Int, jobs: [Job]) = Storage.shared.read { db in let numExpectedDependencies: Int = try JobDependencies .filter(JobDependencies.Columns.jobId == nextJob.id) .fetchCount(db) @@ -714,7 +714,7 @@ private final class JobQueue { } private func scheduleNextSoonestJob() { - let nextJobTimestamp: TimeInterval? = GRDBStorage.shared.read { db in + let nextJobTimestamp: TimeInterval? = Storage.shared.read { db in try Job.filterPendingJobs(variants: jobVariants, excludeFutureJobs: false) .select(.nextRunTimestamp) .asRequest(of: TimeInterval.self) @@ -768,7 +768,7 @@ private final class JobQueue { private func handleJobSucceeded(_ job: Job, shouldStop: Bool) { switch job.behaviour { case .runOnce, .runOnceNextLaunch: - GRDBStorage.shared.write { db in + Storage.shared.write { db in // First remove any JobDependencies requiring this job to be completed (if // we don't then the dependant jobs will automatically be deleted) _ = try JobDependencies @@ -779,7 +779,7 @@ private final class JobQueue { } case .recurring where shouldStop == true: - GRDBStorage.shared.write { db in + Storage.shared.write { db in // First remove any JobDependencies requiring this job to be completed (if // we don't then the dependant jobs will automatically be deleted) _ = try JobDependencies @@ -793,7 +793,7 @@ private final class JobQueue { // but we want at least 1 second to pass before doing so - the job itself should // really update it's own 'nextRunTimestamp' (this is just a safety net) case .recurring where job.nextRunTimestamp <= Date().timeIntervalSince1970: - GRDBStorage.shared.write { db in + Storage.shared.write { db in _ = try job .with(nextRunTimestamp: (Date().timeIntervalSince1970 + 1)) .saved(db) @@ -805,7 +805,7 @@ private final class JobQueue { // For concurrent queues retrieve any 'dependant' jobs and re-add them here (if they have other // dependencies they will be removed again when they try to execute) if executionType == .concurrent { - let dependantJobs: [Job] = GRDBStorage.shared + let dependantJobs: [Job] = Storage.shared .read { db in try job.dependantJobs.fetchAll(db) } .defaulting(to: []) let dependantJobIds: [Int64] = dependantJobs @@ -837,7 +837,7 @@ private final class JobQueue { /// This function is called when a job fails, if it's wasn't a permanent failure then the 'failureCount' for the job will be incremented and it'll /// be re-run after a retry interval has passed private func handleJobFailed(_ job: Job, error: Error?, permanentFailure: Bool) { - guard GRDBStorage.shared.read({ db in try Job.exists(db, id: job.id ?? -1) }) == true else { + guard Storage.shared.read({ db in try Job.exists(db, id: job.id ?? -1) }) == true else { SNLog("[JobRunner] \(queueContext) \(job.variant) job canceled") jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) } detailsForCurrentlyRunningJobs.mutate { $0 = $0.removingValue(forKey: job.id) } @@ -865,7 +865,7 @@ private final class JobQueue { let maxFailureCount: Int = (JobRunner.executorMap.wrappedValue[job.variant]?.maxFailureCount ?? 0) let nextRunTimestamp: TimeInterval = (Date().timeIntervalSince1970 + JobRunner.getRetryInterval(for: job)) - GRDBStorage.shared.write { db in + Storage.shared.write { db in guard !permanentFailure && ( maxFailureCount < 0 || diff --git a/SignalUtilitiesKit/Messaging/BlockListUIUtils.swift b/SignalUtilitiesKit/Messaging/BlockListUIUtils.swift index 5a432bb24..8f0126117 100644 --- a/SignalUtilitiesKit/Messaging/BlockListUIUtils.swift +++ b/SignalUtilitiesKit/Messaging/BlockListUIUtils.swift @@ -32,7 +32,7 @@ import SessionMessagingKit accessibilityIdentifier: "\(type(of: self).self).block", style: .destructive, handler: { _ in - GRDBStorage.shared.writeAsync( + Storage.shared.writeAsync( updates: { db in try Contact .fetchOrCreate(db, id: threadId) @@ -83,7 +83,7 @@ import SessionMessagingKit accessibilityIdentifier: "\(type(of: self).self).unblock", style: .destructive, handler: { _ in - GRDBStorage.shared.writeAsync( + Storage.shared.writeAsync( updates: { db in try Contact .fetchOrCreate(db, id: threadId) diff --git a/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift b/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift index cecd4d3c8..08123780e 100644 --- a/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift +++ b/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift @@ -59,7 +59,7 @@ public final class ProfilePictureView: UIView { public func update(forThreadId threadId: String?) { guard let threadId: String = threadId, - let viewModel: SessionThreadViewModel = GRDBStorage.shared.read({ db -> SessionThreadViewModel? in + let viewModel: SessionThreadViewModel = Storage.shared.read({ db -> SessionThreadViewModel? in let userPublicKey: String = getUserHexEncodedPublicKey(db) return try SessionThreadViewModel diff --git a/SignalUtilitiesKit/Screen Lock/OWSScreenLock.swift b/SignalUtilitiesKit/Screen Lock/OWSScreenLock.swift index e04a24e83..a8365162c 100644 --- a/SignalUtilitiesKit/Screen Lock/OWSScreenLock.swift +++ b/SignalUtilitiesKit/Screen Lock/OWSScreenLock.swift @@ -41,12 +41,12 @@ import SessionMessagingKit // MARK: - Properties @objc public func isScreenLockEnabled() -> Bool { - return GRDBStorage.shared[.isScreenLockEnabled] + return Storage.shared[.isScreenLockEnabled] } @objc public func setIsScreenLockEnabled(_ value: Bool) { - GRDBStorage.shared.writeAsync( + Storage.shared.writeAsync( updates: { db in db[.isScreenLockEnabled] = value }, completion: { _, _ in NotificationCenter.default.postNotificationNameAsync(OWSScreenLock.ScreenLockDidChange, object: nil) @@ -55,12 +55,12 @@ import SessionMessagingKit } @objc public func screenLockTimeout() -> TimeInterval { - return GRDBStorage.shared[.screenLockTimeoutSeconds] + return Storage.shared[.screenLockTimeoutSeconds] .defaulting(to: screenLockTimeoutDefault) } @objc public func setScreenLockTimeout(_ value: TimeInterval) { - GRDBStorage.shared.writeAsync( + Storage.shared.writeAsync( updates: { db in db[.screenLockTimeoutSeconds] = value }, completion: { _, _ in NotificationCenter.default.postNotificationNameAsync(OWSScreenLock.ScreenLockDidChange, object: nil) diff --git a/SignalUtilitiesKit/Utilities/AppSetup.swift b/SignalUtilitiesKit/Utilities/AppSetup.swift index 32221892c..7e40f357f 100644 --- a/SignalUtilitiesKit/Utilities/AppSetup.swift +++ b/SignalUtilitiesKit/Utilities/AppSetup.swift @@ -64,7 +64,7 @@ public enum AppSetup { ) { var backgroundTask: OWSBackgroundTask? = (backgroundTask ?? OWSBackgroundTask(labelStr: #function)) - GRDBStorage.shared.perform( + Storage.shared.perform( migrations: [ SNUtilitiesKit.migrations(), SNSnodeKit.migrations(), From cdb211b72a6294ba163bbd79314985a1574b4f4b Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 1 Jul 2022 13:33:00 +1000 Subject: [PATCH 125/157] Applied the "increase min version to iOS 13" changes --- Session.xcodeproj/project.pbxproj | 8 -- .../Context Menu/ContextMenuVC.swift | 1 + .../Context Menu/ContextMenuWindow.swift | 1 - .../ConversationVC+Interaction.swift | 1 + Session/Conversations/ConversationVC.swift | 32 +++---- .../Input View/InputViewButton.swift | 9 +- Session/DMs/NewDMVC.swift | 15 +--- .../GIFs/GifPickerLayout.swift | 2 +- .../ImagePickerController.swift | 29 +------ .../MediaPageViewController.swift | 14 --- .../MediaTileViewController.swift | 6 +- .../PhotoCapture.swift | 10 +-- .../PhotoCaptureViewController.swift | 26 +----- .../PhotoCollectionPickerController.swift | 2 +- Session/Meta/AppDelegate.swift | 1 + Session/Settings/QRCodeVC.swift | 8 +- Session/Settings/SettingsVC.swift | 52 +++++------- Session/Shared/BaseVC.swift | 5 +- Session/Shared/CaptionView.swift | 2 +- Session/Shared/MarqueeLabel.swift | 8 +- Session/Utilities/HapticFeedback.swift | 17 +--- Session/Utilities/SNAppearance.swift | 36 ++++---- Session/Utilities/UIAlerts+iOS9.m | 85 ------------------- Session/Utilities/UIApplication+OWS.swift | 2 +- .../Utilities/OWSWindowManager.m | 31 +------ SessionUIKit/Components/SearchBar.swift | 7 +- SessionUIKit/Style Guide/AppMode.swift | 6 +- .../Screen Lock/OWSScreenLock.swift | 68 +++++++-------- .../OWSNavigationController.m | 28 ++---- .../OWSTableViewController.m | 6 +- .../Shared Views/OWSNavigationBar.swift | 22 ----- SignalUtilitiesKit/Utilities/OWSQueues.h | 28 ------ .../Utilities/UIColor+Extensions.swift | 20 ++--- SignalUtilitiesKit/Utilities/UIView+OWS.swift | 26 ++---- 34 files changed, 134 insertions(+), 480 deletions(-) delete mode 100644 Session/Utilities/UIAlerts+iOS9.m delete mode 100644 SignalUtilitiesKit/Utilities/OWSQueues.h diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 19554d21f..bde3b8f17 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -96,7 +96,6 @@ 45F32C232057297A00A300D5 /* MediaPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45F32C1D205718B000A300D5 /* MediaPageViewController.swift */; }; 4C090A1B210FD9C7001FD7F9 /* HapticFeedback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C090A1A210FD9C7001FD7F9 /* HapticFeedback.swift */; }; 4C1885D2218F8E1C00B67051 /* PhotoGridViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C1885D1218F8E1C00B67051 /* PhotoGridViewCell.swift */; }; - 4C21D5D6223A9DC500EF8A77 /* UIAlerts+iOS9.m in Sources */ = {isa = PBXBuildFile; fileRef = 4C21D5D5223A9DC500EF8A77 /* UIAlerts+iOS9.m */; }; 4C21D5D8223AC60F00EF8A77 /* PhotoCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C21D5D7223AC60F00EF8A77 /* PhotoCapture.swift */; }; 4C4AE6A1224AF35700D4AF6F /* SendMediaNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4AE69F224AF21900D4AF6F /* SendMediaNavigationController.swift */; }; 4C4AEC4520EC343B0020E72B /* DismissableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4AEC4420EC343B0020E72B /* DismissableTextField.swift */; }; @@ -367,7 +366,6 @@ C33FDDC5255A582000E217F9 /* OWSError.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDC0B255A581D00E217F9 /* OWSError.m */; }; C33FDDCC255A582000E217F9 /* TSConstants.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDC12255A581E00E217F9 /* TSConstants.h */; settings = {ATTRIBUTES = (Public, ); }; }; C33FDDD0255A582000E217F9 /* FunctionalUtil.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDC16255A581E00E217F9 /* FunctionalUtil.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C33FDDD3255A582000E217F9 /* OWSQueues.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDC19255A581F00E217F9 /* OWSQueues.h */; settings = {ATTRIBUTES = (Public, ); }; }; C33FDEF8255A656D00E217F9 /* Promise+Delaying.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D32553860900C340D1 /* Promise+Delaying.swift */; }; C3402FE52559036600EA6424 /* SessionUIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C331FF1B2558F9D300070591 /* SessionUIKit.framework */; }; C3471ECB2555356A00297E91 /* MessageSender+Encryption.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3471ECA2555356A00297E91 /* MessageSender+Encryption.swift */; }; @@ -1105,7 +1103,6 @@ 4C090A1A210FD9C7001FD7F9 /* HapticFeedback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticFeedback.swift; sourceTree = ""; }; 4C1885D1218F8E1C00B67051 /* PhotoGridViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoGridViewCell.swift; sourceTree = ""; }; 4C1D2337218B6BA000A0598F /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; - 4C21D5D5223A9DC500EF8A77 /* UIAlerts+iOS9.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UIAlerts+iOS9.m"; sourceTree = ""; }; 4C21D5D7223AC60F00EF8A77 /* PhotoCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoCapture.swift; sourceTree = ""; }; 4C4AE69F224AF21900D4AF6F /* SendMediaNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMediaNavigationController.swift; sourceTree = ""; }; 4C4AEC4420EC343B0020E72B /* DismissableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DismissableTextField.swift; sourceTree = ""; }; @@ -1402,7 +1399,6 @@ C33FDC0B255A581D00E217F9 /* OWSError.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSError.m; sourceTree = ""; }; C33FDC12255A581E00E217F9 /* TSConstants.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSConstants.h; sourceTree = ""; }; C33FDC16255A581E00E217F9 /* FunctionalUtil.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FunctionalUtil.h; sourceTree = ""; }; - C33FDC19255A581F00E217F9 /* OWSQueues.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSQueues.h; sourceTree = ""; }; C33FDC1B255A581F00E217F9 /* OWSBackgroundTask.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSBackgroundTask.m; sourceTree = ""; }; C3471ECA2555356A00297E91 /* MessageSender+Encryption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageSender+Encryption.swift"; sourceTree = ""; }; C3471F4B25553AB000297E91 /* MessageReceiver+Decryption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+Decryption.swift"; sourceTree = ""; }; @@ -2102,7 +2098,6 @@ 34D5CCA81EAE3D30005515DB /* AvatarViewHelper.m */, FD848B9728422F1A000E298B /* Date+Utilities.swift */, FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */, - 4C21D5D5223A9DC500EF8A77 /* UIAlerts+iOS9.m */, 45C0DC1A1E68FE9000E04C47 /* UIApplication+OWS.swift */, 45C0DC1D1E69011F00E04C47 /* UIStoryboard+OWS.swift */, EF764C331DB67CC5000D9A87 /* UIViewController+Permissions.h */, @@ -3223,7 +3218,6 @@ C33FDC0B255A581D00E217F9 /* OWSError.m */, C33FDBA1255A581400E217F9 /* OWSOperation.h */, C33FDB78255A581000E217F9 /* OWSOperation.m */, - C33FDC19255A581F00E217F9 /* OWSQueues.h */, C33FDBD3255A581800E217F9 /* OWSSignalAddress.swift */, C33FDA6F255A57FA00E217F9 /* ReachabilityManager.swift */, C33FDBD8255A581900E217F9 /* SignalIOS.pb.swift */, @@ -3950,7 +3944,6 @@ C38EF249255B6D67007E1867 /* UIColor+OWS.h in Headers */, C38EF3F5255B6DF7007E1867 /* OWSTextField.h in Headers */, C38EF366255B6DCC007E1867 /* ScreenLockViewController.h in Headers */, - C33FDDD3255A582000E217F9 /* OWSQueues.h in Headers */, C33FDDB3255A582000E217F9 /* OWSError.h in Headers */, C38EF35E255B6DCC007E1867 /* OWSViewController.h in Headers */, C38EF367255B6DCC007E1867 /* OWSTableViewController.h in Headers */, @@ -5377,7 +5370,6 @@ 34A6C28021E503E700B5B12E /* OWSImagePickerController.swift in Sources */, C31A6C5C247F2CF3001123EF /* CGRect+Utilities.swift in Sources */, FD4B200E283492210034334B /* InsetLockableTableView.swift in Sources */, - 4C21D5D6223A9DC500EF8A77 /* UIAlerts+iOS9.m in Sources */, B8269D3325C7A8C600488AB4 /* InputViewButton.swift in Sources */, B8269D3D25C7B34D00488AB4 /* InputTextView.swift in Sources */, 340FC8B6204DAC8D007AEB0F /* OWSQRCodeScanningViewController.m in Sources */, diff --git a/Session/Conversations/Context Menu/ContextMenuVC.swift b/Session/Conversations/Context Menu/ContextMenuVC.swift index b80de3eda..d99c0a1cb 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC.swift @@ -121,6 +121,7 @@ final class ContextMenuVC: UIViewController { let menuHeight = (CGFloat(actions.count) * ContextMenuVC.actionViewHeight) let spacing = Values.smallSpacing + // FIXME: Need to update this when an appropriate replacement is added (see https://teng.pub/technical/2021/11/9/uiapplication-key-window-replacement) let margin = max(UIApplication.shared.keyWindow!.safeAreaInsets.bottom, Values.mediumSpacing) if frame.maxY + spacing + menuHeight > UIScreen.main.bounds.height - margin { diff --git a/Session/Conversations/Context Menu/ContextMenuWindow.swift b/Session/Conversations/Context Menu/ContextMenuWindow.swift index 9cd7fe4ff..7e309c199 100644 --- a/Session/Conversations/Context Menu/ContextMenuWindow.swift +++ b/Session/Conversations/Context Menu/ContextMenuWindow.swift @@ -11,7 +11,6 @@ final class ContextMenuWindow : UIWindow { initialize() } - @available(iOS 13.0, *) override init(windowScene: UIWindowScene) { super.init(windowScene: windowScene) initialize() diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 28be01462..284207fd7 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -662,6 +662,7 @@ extension ConversationVC: func handleItemLongPressed(_ cellViewModel: MessageViewModel) { // Show the context menu if applicable guard + // FIXME: Need to update this when an appropriate replacement is added (see https://teng.pub/technical/2021/11/9/uiapplication-key-window-replacement) let keyWindow: UIWindow = UIApplication.shared.keyWindow, let sectionIndex: Int = self.viewModel.interactionData .firstIndex(where: { $0.model == .messages }), diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 66532526c..59e6c81e0 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -241,17 +241,11 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers for: .highlighted ) result.layer.cornerRadius = (ConversationVC.messageRequestButtonHeight / 2) - result.layer.borderColor = { - if #available(iOS 13.0, *) { - return Colors.sessionHeading - .resolvedColor( - // Note: This is needed for '.cgColor' to support dark mode - with: UITraitCollection(userInterfaceStyle: isDarkMode ? .dark : .light) - ).cgColor - } - - return Colors.sessionHeading.cgColor - }() + result.layer.borderColor = Colors.sessionHeading + .resolvedColor( + // Note: This is needed for '.cgColor' to support dark mode + with: UITraitCollection(userInterfaceStyle: isDarkMode ? .dark : .light) + ).cgColor result.layer.borderWidth = 1 result.addTarget(self, action: #selector(acceptMessageRequest), for: .touchUpInside) @@ -272,17 +266,11 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers for: .highlighted ) result.layer.cornerRadius = (ConversationVC.messageRequestButtonHeight / 2) - result.layer.borderColor = { - if #available(iOS 13.0, *) { - return Colors.destructive - .resolvedColor( - // Note: This is needed for '.cgColor' to support dark mode - with: UITraitCollection(userInterfaceStyle: isDarkMode ? .dark : .light) - ).cgColor - } - - return Colors.destructive.cgColor - }() + result.layer.borderColor = Colors.destructive + .resolvedColor( + // Note: This is needed for '.cgColor' to support dark mode + with: UITraitCollection(userInterfaceStyle: isDarkMode ? .dark : .light) + ).cgColor result.layer.borderWidth = 1 result.addTarget(self, action: #selector(deleteMessageRequest), for: .touchUpInside) diff --git a/Session/Conversations/Input View/InputViewButton.swift b/Session/Conversations/Input View/InputViewButton.swift index 43166a5f0..26d2b495d 100644 --- a/Session/Conversations/Input View/InputViewButton.swift +++ b/Session/Conversations/Input View/InputViewButton.swift @@ -141,17 +141,16 @@ final class InputViewButton : UIView { } } -// MARK: Delegate -protocol InputViewButtonDelegate : class { - +// MARK: - Delegate + +protocol InputViewButtonDelegate: AnyObject { func handleInputViewButtonTapped(_ inputViewButton: InputViewButton) func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton) func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch) func handleInputViewButtonLongPressEnded(_ inputViewButton: InputViewButton, with touch: UITouch) } -extension InputViewButtonDelegate { - +extension InputViewButtonDelegate { func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton) { } func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch) { } func handleInputViewButtonLongPressEnded(_ inputViewButton: InputViewButton, with touch: UITouch) { } diff --git a/Session/DMs/NewDMVC.swift b/Session/DMs/NewDMVC.swift index c340ce94f..8767c8263 100644 --- a/Session/DMs/NewDMVC.swift +++ b/Session/DMs/NewDMVC.swift @@ -78,12 +78,7 @@ final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControll // Set up tab bar view.addSubview(tabBar) tabBar.pin(.leading, to: .leading, of: view) - let tabBarInset: CGFloat - if #available(iOS 13, *) { - tabBarInset = UIDevice.current.isIPad ? navigationBar.height() + 20 : navigationBar.height() - } else { - tabBarInset = 0 - } + let tabBarInset: CGFloat = (UIDevice.current.isIPad ? navigationBar.height() + 20 : navigationBar.height()) tabBar.pin(.top, to: .top, of: view, withInset: tabBarInset) view.pin(.trailing, to: .trailing, of: tabBar) // Set up page VC constraints @@ -95,13 +90,7 @@ final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControll view.pin(.bottom, to: .bottom, of: pageVCView) let screen = UIScreen.main.bounds pageVCView.set(.width, to: screen.width) - let height: CGFloat - if #available(iOS 13, *) { - height = navigationController!.view.bounds.height - navigationBar.height() - TabBar.snHeight - } else { - let statusBarHeight = UIApplication.shared.statusBarFrame.height - height = navigationController!.view.bounds.height - navigationBar.height() - TabBar.snHeight - statusBarHeight - } + let height: CGFloat = (navigationController!.view.bounds.height - navigationBar.height() - TabBar.snHeight) pageVCView.set(.height, to: height) enterPublicKeyVC.constrainHeight(to: height) scanQRCodePlaceholderVC.constrainHeight(to: height) diff --git a/Session/Media Viewing & Editing/GIFs/GifPickerLayout.swift b/Session/Media Viewing & Editing/GIFs/GifPickerLayout.swift index 7697974ce..2587eb5a2 100644 --- a/Session/Media Viewing & Editing/GIFs/GifPickerLayout.swift +++ b/Session/Media Viewing & Editing/GIFs/GifPickerLayout.swift @@ -4,7 +4,7 @@ import Foundation -protocol GifPickerLayoutDelegate: class { +protocol GifPickerLayoutDelegate: AnyObject { func imageInfosForLayout() -> [GiphyImageInfo] } diff --git a/Session/Media Viewing & Editing/ImagePickerController.swift b/Session/Media Viewing & Editing/ImagePickerController.swift index 75ff9b004..2ac147d40 100644 --- a/Session/Media Viewing & Editing/ImagePickerController.swift +++ b/Session/Media Viewing & Editing/ImagePickerController.swift @@ -77,14 +77,6 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat let titleView = TitleView() titleView.delegate = self titleView.text = photoCollection.localizedTitle() - - if #available(iOS 11, *) { - // do nothing - } else { - // must assign titleView frame manually on older iOS - titleView.frame = CGRect(origin: .zero, size: titleView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)) - } - navigationItem.titleView = titleView self.titleView = titleView @@ -269,11 +261,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat // MARK: var lastPageYOffset: CGFloat { - var yOffset = collectionView.contentSize.height - collectionView.bounds.height + collectionView.adjustedContentInset.bottom - if #available(iOS 11.0, *) { - yOffset += view.safeAreaInsets.bottom - } - return yOffset + return (collectionView.contentSize.height - collectionView.bounds.height + collectionView.adjustedContentInset.bottom + view.safeAreaInsets.bottom) } func scrollToBottom(animated: Bool) { @@ -344,10 +332,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat static let kInterItemSpacing: CGFloat = 2 private class func buildLayout() -> UICollectionViewFlowLayout { let layout = UICollectionViewFlowLayout() - - if #available(iOS 11, *) { - layout.sectionInsetReference = .fromSafeArea - } + layout.sectionInsetReference = .fromSafeArea layout.minimumInteritemSpacing = kInterItemSpacing layout.minimumLineSpacing = kInterItemSpacing layout.sectionHeadersPinToVisibleBounds = true @@ -356,13 +341,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat } func updateLayout() { - let containerWidth: CGFloat - if #available(iOS 11.0, *) { - containerWidth = self.view.safeAreaLayoutGuide.layoutFrame.size.width - } else { - containerWidth = self.view.frame.size.width - } - + let containerWidth: CGFloat = self.view.safeAreaLayoutGuide.layoutFrame.size.width let kItemsPerPortraitRow = 4 let screenWidth = min(UIScreen.main.bounds.width, UIScreen.main.bounds.height) let approxItemWidth = screenWidth / CGFloat(kItemsPerPortraitRow) @@ -586,7 +565,7 @@ extension ImagePickerGridController: UIGestureRecognizerDelegate { } } -protocol TitleViewDelegate: class { +protocol TitleViewDelegate: AnyObject { func titleViewWasTapped(_ titleView: TitleView) } diff --git a/Session/Media Viewing & Editing/MediaPageViewController.swift b/Session/Media Viewing & Editing/MediaPageViewController.swift index 2acad0906..7b9f96349 100644 --- a/Session/Media Viewing & Editing/MediaPageViewController.swift +++ b/Session/Media Viewing & Editing/MediaPageViewController.swift @@ -894,20 +894,6 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou let landscapeHeaderText = String(format: landscapeHeaderFormat, name, formattedDate) self.title = landscapeHeaderText self.navigationItem.title = landscapeHeaderText - - if #available(iOS 11, *) { - // Do nothing, on iOS11+, autolayout grows the stack view as necessary. - } else { - // Size the titleView to be large enough to fit the widest label, - // but no larger. If we go for a "full width" label, our title view - // will not be centered (since the left and right bar buttons have different widths) - portraitHeaderNameLabel.sizeToFit() - portraitHeaderDateLabel.sizeToFit() - let width = max(portraitHeaderNameLabel.frame.width, portraitHeaderDateLabel.frame.width) - - let headerFrame: CGRect = CGRect(x: 0, y: 0, width: width, height: 44) - portraitHeaderView.frame = headerFrame - } } // MARK: - InteractivelyDismissableViewController diff --git a/Session/Media Viewing & Editing/MediaTileViewController.swift b/Session/Media Viewing & Editing/MediaTileViewController.swift index 584678357..45acdf317 100644 --- a/Session/Media Viewing & Editing/MediaTileViewController.swift +++ b/Session/Media Viewing & Editing/MediaTileViewController.swift @@ -754,11 +754,7 @@ private class MediaGallerySectionHeader: UICollectionReusableView { get { // HACK: scrollbar incorrectly appears *behind* section headers // in collection view on iOS11 =( - if #available(iOS 11, *) { - return AlwaysOnTopLayer.self - } else { - return super.layerClass - } + return AlwaysOnTopLayer.self } } diff --git a/Session/Media Viewing & Editing/PhotoCapture.swift b/Session/Media Viewing & Editing/PhotoCapture.swift index aa6db0714..74afdf5ea 100644 --- a/Session/Media Viewing & Editing/PhotoCapture.swift +++ b/Session/Media Viewing & Editing/PhotoCapture.swift @@ -453,16 +453,10 @@ protocol ImageCaptureOutput: AnyObject { class CaptureOutput { - let imageOutput: ImageCaptureOutput + let imageOutput: ImageCaptureOutput = PhotoCaptureOutputAdaptee() let movieOutput: AVCaptureMovieFileOutput init() { - if #available(iOS 10.0, *) { - imageOutput = PhotoCaptureOutputAdaptee() - } else { - imageOutput = StillImageCaptureOutput() - } - movieOutput = AVCaptureMovieFileOutput() // disable movie fragment writing since it's not supported on mp4 // leaving it enabled causes all audio to be lost on videos longer @@ -531,7 +525,6 @@ class CaptureOutput { } } -@available(iOS 10.0, *) class PhotoCaptureOutputAdaptee: NSObject, ImageCaptureOutput { let photoOutput = AVCapturePhotoOutput() @@ -586,7 +579,6 @@ class PhotoCaptureOutputAdaptee: NSObject, ImageCaptureOutput { self.completion = completion } - @available(iOS 11.0, *) func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { var data = photo.fileDataRepresentation()! // Call normalized here to fix the orientation diff --git a/Session/Media Viewing & Editing/PhotoCaptureViewController.swift b/Session/Media Viewing & Editing/PhotoCaptureViewController.swift index b7b61333c..cfa944e3a 100644 --- a/Session/Media Viewing & Editing/PhotoCaptureViewController.swift +++ b/Session/Media Viewing & Editing/PhotoCaptureViewController.swift @@ -115,12 +115,7 @@ class PhotoCaptureViewController: OWSViewController { init(imageName: String, block: @escaping () -> Void) { self.button = OWSButton(imageName: imageName, tintColor: .ows_white, block: block) - if #available(iOS 10, *) { - button.autoPinToSquareAspectRatio() - } else { - button.sizeToFit() - } - + button.autoPinToSquareAspectRatio() button.layer.shadowOffset = CGSize.zero button.layer.shadowOpacity = 0.35 button.layer.shadowRadius = 4 @@ -600,20 +595,6 @@ class RecordingTimerView: UIView { return icon }() - // MARK: - Overrides // - - override func sizeThatFits(_ size: CGSize) -> CGSize { - if #available(iOS 10, *) { - return super.sizeThatFits(size) - } else { - // iOS9 manual layout sizing required for items in the navigation bar - var baseSize = label.frame.size - baseSize.width = baseSize.width + stackViewSpacing + RecordingTimerView.iconWidth + layoutMargins.left + layoutMargins.right - baseSize.height = baseSize.height + layoutMargins.top + layoutMargins.bottom - return baseSize - } - } - // MARK: - var recordingStartTime: TimeInterval? @@ -662,10 +643,5 @@ class RecordingTimerView: UIView { Logger.verbose("recordingDuration: \(recordingDuration)") let durationDate = Date(timeIntervalSinceReferenceDate: recordingDuration) label.text = timeFormatter.string(from: durationDate) - if #available(iOS 10, *) { - // do nothing - } else { - label.sizeToFit() - } } } diff --git a/Session/Media Viewing & Editing/PhotoCollectionPickerController.swift b/Session/Media Viewing & Editing/PhotoCollectionPickerController.swift index 2c6682307..714655c12 100644 --- a/Session/Media Viewing & Editing/PhotoCollectionPickerController.swift +++ b/Session/Media Viewing & Editing/PhotoCollectionPickerController.swift @@ -6,7 +6,7 @@ import Foundation import Photos import PromiseKit -protocol PhotoCollectionPickerDelegate: class { +protocol PhotoCollectionPickerDelegate: AnyObject { func photoCollectionPicker(_ photoCollectionPicker: PhotoCollectionPickerController, didPickCollection collection: PhotoCollection) } diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index eebe29179..556337aae 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -498,6 +498,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD // MARK: - App Mode private func adapt(appMode: AppMode) { + // FIXME: Need to update this when an appropriate replacement is added (see https://teng.pub/technical/2021/11/9/uiapplication-key-window-replacement) guard let window: UIWindow = UIApplication.shared.keyWindow else { return } switch (appMode) { diff --git a/Session/Settings/QRCodeVC.swift b/Session/Settings/QRCodeVC.swift index b29e8f62f..4376809ea 100644 --- a/Session/Settings/QRCodeVC.swift +++ b/Session/Settings/QRCodeVC.swift @@ -71,13 +71,7 @@ final class QRCodeVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControl view.pin(.bottom, to: .bottom, of: pageVCView) let screen = UIScreen.main.bounds pageVCView.set(.width, to: screen.width) - let height: CGFloat - if #available(iOS 13, *) { - height = navigationController!.view.bounds.height - navigationBar.height() - TabBar.snHeight - } else { - let statusBarHeight = UIApplication.shared.statusBarFrame.height - height = navigationController!.view.bounds.height - navigationBar.height() - TabBar.snHeight - statusBarHeight - } + let height: CGFloat = (navigationController!.view.bounds.height - navigationBar.height() - TabBar.snHeight) pageVCView.set(.height, to: height) viewMyQRCodeVC.constrainHeight(to: height) scanQRCodePlaceholderVC.constrainHeight(to: height) diff --git a/Session/Settings/SettingsVC.swift b/Session/Settings/SettingsVC.swift index 4d852c241..1465cc56f 100644 --- a/Session/Settings/SettingsVC.swift +++ b/Session/Settings/SettingsVC.swift @@ -391,39 +391,31 @@ final class SettingsVC: BaseVC, AvatarViewHelperDelegate { closeButton.isAccessibilityElement = true navigationItem.leftBarButtonItem = closeButton - if #available(iOS 13, *) { // Pre iOS 13 the user can't switch actively but the app still responds to system changes - let appModeIcon: UIImage - if isSystemDefault { - appModeIcon = isDarkMode ? #imageLiteral(resourceName: "ic_theme_auto").withTintColor(.white) : #imageLiteral(resourceName: "ic_theme_auto").withTintColor(.black) - } - else { - appModeIcon = isDarkMode ? #imageLiteral(resourceName: "ic_dark_theme_on").withTintColor(.white) : #imageLiteral(resourceName: "ic_dark_theme_off").withTintColor(.black) - } - - let appModeButton = UIButton() - appModeButton.setImage(appModeIcon, for: UIControl.State.normal) - appModeButton.tintColor = Colors.text - appModeButton.addTarget(self, action: #selector(switchAppMode), for: UIControl.Event.touchUpInside) - appModeButton.accessibilityLabel = "Switch app mode button" - - let qrCodeIcon = isDarkMode ? #imageLiteral(resourceName: "QRCode").withTintColor(.white) : #imageLiteral(resourceName: "QRCode").withTintColor(.black) - let qrCodeButton = UIButton() - qrCodeButton.setImage(qrCodeIcon, for: UIControl.State.normal) - qrCodeButton.tintColor = Colors.text - qrCodeButton.addTarget(self, action: #selector(showQRCode), for: UIControl.Event.touchUpInside) - qrCodeButton.accessibilityLabel = "Show QR code button" - - let stackView = UIStackView(arrangedSubviews: [ appModeButton, qrCodeButton ]) - stackView.axis = .horizontal - stackView.spacing = Values.mediumSpacing - navigationItem.rightBarButtonItem = UIBarButtonItem(customView: stackView) + let appModeIcon: UIImage + if isSystemDefault { + appModeIcon = isDarkMode ? #imageLiteral(resourceName: "ic_theme_auto").withTintColor(.white) : #imageLiteral(resourceName: "ic_theme_auto").withTintColor(.black) } else { - let qrCodeIcon = isDarkMode ? #imageLiteral(resourceName: "QRCode").asTintedImage(color: .white) : #imageLiteral(resourceName: "QRCode").asTintedImage(color: .black) - let qrCodeButton = UIBarButtonItem(image: qrCodeIcon, style: .plain, target: self, action: #selector(showQRCode)) - qrCodeButton.tintColor = Colors.text - navigationItem.rightBarButtonItem = qrCodeButton + appModeIcon = isDarkMode ? #imageLiteral(resourceName: "ic_dark_theme_on").withTintColor(.white) : #imageLiteral(resourceName: "ic_dark_theme_off").withTintColor(.black) } + + let appModeButton = UIButton() + appModeButton.setImage(appModeIcon, for: UIControl.State.normal) + appModeButton.tintColor = Colors.text + appModeButton.addTarget(self, action: #selector(switchAppMode), for: UIControl.Event.touchUpInside) + appModeButton.accessibilityLabel = "Switch app mode button" + + let qrCodeIcon = isDarkMode ? #imageLiteral(resourceName: "QRCode").withTintColor(.white) : #imageLiteral(resourceName: "QRCode").withTintColor(.black) + let qrCodeButton = UIButton() + qrCodeButton.setImage(qrCodeIcon, for: UIControl.State.normal) + qrCodeButton.tintColor = Colors.text + qrCodeButton.addTarget(self, action: #selector(showQRCode), for: UIControl.Event.touchUpInside) + qrCodeButton.accessibilityLabel = "Show QR code button" + + let stackView = UIStackView(arrangedSubviews: [ appModeButton, qrCodeButton ]) + stackView.axis = .horizontal + stackView.spacing = Values.mediumSpacing + navigationItem.rightBarButtonItem = UIBarButtonItem(customView: stackView) } } diff --git a/Session/Shared/BaseVC.swift b/Session/Shared/BaseVC.swift index 08106e3ff..4946295dd 100644 --- a/Session/Shared/BaseVC.swift +++ b/Session/Shared/BaseVC.swift @@ -108,9 +108,8 @@ class BaseVC : UIViewController { } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - if #available(iOS 13.0, *) { - SNLog("Current trait collection: \(UITraitCollection.current), previous trait collection: \(previousTraitCollection)") - } + SNLog("Current trait collection: \(UITraitCollection.current), previous trait collection: \(previousTraitCollection)") + if LKAppModeUtilities.isSystemDefault { NotificationCenter.default.post(name: .appModeChanged, object: nil) } diff --git a/Session/Shared/CaptionView.swift b/Session/Shared/CaptionView.swift index 8db43a089..97217838a 100644 --- a/Session/Shared/CaptionView.swift +++ b/Session/Shared/CaptionView.swift @@ -2,7 +2,7 @@ // Copyright (c) 2019 Open Whisper Systems. All rights reserved. // -public protocol CaptionContainerViewDelegate: class { +public protocol CaptionContainerViewDelegate: AnyObject { func captionContainerViewDidUpdateText(_ captionContainerView: CaptionContainerView) } diff --git a/Session/Shared/MarqueeLabel.swift b/Session/Shared/MarqueeLabel.swift index 2b787e477..d892299f2 100644 --- a/Session/Shared/MarqueeLabel.swift +++ b/Session/Shared/MarqueeLabel.swift @@ -609,7 +609,7 @@ open class MarqueeLabel: UILabel, CAAnimationDelegate { animationDuration = { switch self.speed { case .rate(let rate): - return CGFloat(fabs(self.awayOffset) / rate) + return CGFloat(abs(self.awayOffset) / rate) case .duration(let duration): return duration } @@ -634,7 +634,7 @@ open class MarqueeLabel: UILabel, CAAnimationDelegate { // Find when the lead label will be totally offscreen let offsetDistance = awayOffset let offscreenAmount = homeLabelFrame.size.width - let startFadeFraction = fabs(offscreenAmount / offsetDistance) + let startFadeFraction = abs(offscreenAmount / offsetDistance) // Find when the animation will hit that point let startFadeTimeFraction = timingFunctionForAnimationCurve(animationCurve).durationPercentageForPositionPercentage(startFadeFraction, duration: (animationDelay + animationDuration)) let startFadeTime = startFadeTimeFraction * animationDuration @@ -1764,14 +1764,14 @@ fileprivate extension CAMediaTimingFunction { // Calculate f(t0) f0 = YforCurveAt(t0, controlPoints: controlPoints) - y_0 // Check if this is close (enough) - if (fabs(f0) < epsilon) { + if (abs(f0) < epsilon) { // Done! return t0 } // Else continue Newton's Method df0 = derivativeCurveYValueAt(t0, controlPoints: controlPoints) // Check if derivative is small or zero ( http://en.wikipedia.org/wiki/Newton's_method#Failure_analysis ) - if (fabs(df0) < 1e-6) { + if (abs(df0) < 1e-6) { break } // Else recalculate t1 diff --git a/Session/Utilities/HapticFeedback.swift b/Session/Utilities/HapticFeedback.swift index 3eaecd86e..39461379f 100644 --- a/Session/Utilities/HapticFeedback.swift +++ b/Session/Utilities/HapticFeedback.swift @@ -9,28 +9,13 @@ protocol SelectionHapticFeedbackAdapter { } class SelectionHapticFeedback: SelectionHapticFeedbackAdapter { - let adapter: SelectionHapticFeedbackAdapter - - init() { - if #available(iOS 10, *) { - adapter = ModernSelectionHapticFeedbackAdapter() - } else { - adapter = LegacySelectionHapticFeedbackAdapter() - } - } + let adapter: SelectionHapticFeedbackAdapter = ModernSelectionHapticFeedbackAdapter() func selectionChanged() { adapter.selectionChanged() } } -class LegacySelectionHapticFeedbackAdapter: NSObject, SelectionHapticFeedbackAdapter { - func selectionChanged() { - // do nothing - } -} - -@available(iOS 10, *) class ModernSelectionHapticFeedbackAdapter: NSObject, SelectionHapticFeedbackAdapter { let selectionFeedbackGenerator: UISelectionFeedbackGenerator diff --git a/Session/Utilities/SNAppearance.swift b/Session/Utilities/SNAppearance.swift index 951492399..d3da1b3c5 100644 --- a/Session/Utilities/SNAppearance.swift +++ b/Session/Utilities/SNAppearance.swift @@ -2,32 +2,26 @@ @objc final class SNAppearance : NSObject { @objc static func switchToSessionAppearance() { - if #available(iOS 13, *) { - UINavigationBar.appearance().barTintColor = Colors.navigationBarBackground - UINavigationBar.appearance().isTranslucent = false - UINavigationBar.appearance().tintColor = Colors.text - UIToolbar.appearance().barTintColor = Colors.navigationBarBackground - UIToolbar.appearance().isTranslucent = false - UIToolbar.appearance().tintColor = Colors.text - UISwitch.appearance().onTintColor = Colors.accent - UINavigationBar.appearance().titleTextAttributes = [ NSAttributedString.Key.foregroundColor : Colors.text ] - } + UINavigationBar.appearance().barTintColor = Colors.navigationBarBackground + UINavigationBar.appearance().isTranslucent = false + UINavigationBar.appearance().tintColor = Colors.text + UIToolbar.appearance().barTintColor = Colors.navigationBarBackground + UIToolbar.appearance().isTranslucent = false + UIToolbar.appearance().tintColor = Colors.text + UISwitch.appearance().onTintColor = Colors.accent + UINavigationBar.appearance().titleTextAttributes = [ NSAttributedString.Key.foregroundColor : Colors.text ] } @objc static func switchToImagePickerAppearance() { - if #available(iOS 13, *) { - UINavigationBar.appearance().barTintColor = .white - UINavigationBar.appearance().isTranslucent = false - UINavigationBar.appearance().tintColor = .black - UINavigationBar.appearance().titleTextAttributes = [ NSAttributedString.Key.foregroundColor : UIColor.black ] - } + UINavigationBar.appearance().barTintColor = .white + UINavigationBar.appearance().isTranslucent = false + UINavigationBar.appearance().tintColor = .black + UINavigationBar.appearance().titleTextAttributes = [ NSAttributedString.Key.foregroundColor : UIColor.black ] } @objc static func switchToDocumentPickerAppearance() { - if #available(iOS 13, *) { - let textColor: UIColor = isDarkMode ? .white : .black - UINavigationBar.appearance().tintColor = textColor - UINavigationBar.appearance().titleTextAttributes = [ NSAttributedString.Key.foregroundColor : textColor ] - } + let textColor: UIColor = isDarkMode ? .white : .black + UINavigationBar.appearance().tintColor = textColor + UINavigationBar.appearance().titleTextAttributes = [ NSAttributedString.Key.foregroundColor : textColor ] } } diff --git a/Session/Utilities/UIAlerts+iOS9.m b/Session/Utilities/UIAlerts+iOS9.m deleted file mode 100644 index bd2fa7ced..000000000 --- a/Session/Utilities/UIAlerts+iOS9.m +++ /dev/null @@ -1,85 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import - -@implementation UIAlertController (iOS9) - -+ (void)load { - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - // On iOS9, avoids an exception when presenting an alert controller. - // - // *** Assertion failure in -[UIAlertController supportedInterfaceOrientations], /BuildRoot/Library/Caches/com.apple.xbs/Sources/UIKit/UIKit-3512.30.14/UIAlertController.m:542 - // Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'UIAlertController:supportedInterfaceOrientations was invoked recursively!' - // - // I'm not sure when this was introduced, or the exact root casue, but this quick workaround - // seems reasonable given the small size of our iOS9 userbase. - if (@available(iOS 10, *)) { - return; - } - - Class class = [self class]; - - // supportedInterfaceOrientation - - SEL originalOrientationSelector = @selector(supportedInterfaceOrientations); - SEL swizzledOrientationSelector = @selector(ows_iOS9Alerts_swizzle_supportedInterfaceOrientation); - - Method originalOrientationMethod = class_getInstanceMethod(class, originalOrientationSelector); - Method swizzledOrientationMethod = class_getInstanceMethod(class, swizzledOrientationSelector); - - BOOL didAddOrientationMethod = class_addMethod(class, - originalOrientationSelector, - method_getImplementation(swizzledOrientationMethod), - method_getTypeEncoding(swizzledOrientationMethod)); - - if (didAddOrientationMethod) { - class_replaceMethod(class, - swizzledOrientationSelector, - method_getImplementation(originalOrientationMethod), - method_getTypeEncoding(originalOrientationMethod)); - } else { - method_exchangeImplementations(originalOrientationMethod, swizzledOrientationMethod); - } - - // shouldAutorotate - - SEL originalAutorotateSelector = @selector(shouldAutorotate); - SEL swizzledAutorotateSelector = @selector(ows_iOS9Alerts_swizzle_shouldAutorotate); - - Method originalAutorotateMethod = class_getInstanceMethod(class, originalAutorotateSelector); - Method swizzledAutorotateMethod = class_getInstanceMethod(class, swizzledAutorotateSelector); - - BOOL didAddAutorotateMethod = class_addMethod(class, - originalAutorotateSelector, - method_getImplementation(swizzledAutorotateMethod), - method_getTypeEncoding(swizzledAutorotateMethod)); - - if (didAddAutorotateMethod) { - class_replaceMethod(class, - swizzledAutorotateSelector, - method_getImplementation(originalAutorotateMethod), - method_getTypeEncoding(originalAutorotateMethod)); - } else { - method_exchangeImplementations(originalAutorotateMethod, swizzledAutorotateMethod); - } - }); -} - -#pragma mark - Method Swizzling - -- (UIInterfaceOrientationMask)ows_iOS9Alerts_swizzle_supportedInterfaceOrientation -{ - OWSLogInfo(@"swizzled"); - return UIInterfaceOrientationMaskAllButUpsideDown; -} - -- (BOOL)ows_iOS9Alerts_swizzle_shouldAutorotate -{ - OWSLogInfo(@"swizzled"); - return NO; -} - -@end diff --git a/Session/Utilities/UIApplication+OWS.swift b/Session/Utilities/UIApplication+OWS.swift index 8cd6d7055..712f9a069 100644 --- a/Session/Utilities/UIApplication+OWS.swift +++ b/Session/Utilities/UIApplication+OWS.swift @@ -27,6 +27,6 @@ import UIKit } func openSystemSettings() { - openURL(URL(string: UIApplication.openSettingsURLString)!) + open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil) } } diff --git a/SessionMessagingKit/Utilities/OWSWindowManager.m b/SessionMessagingKit/Utilities/OWSWindowManager.m index a1ddc0f2a..f257a09d3 100644 --- a/SessionMessagingKit/Utilities/OWSWindowManager.m +++ b/SessionMessagingKit/Utilities/OWSWindowManager.m @@ -14,22 +14,7 @@ NSString *const IsScreenBlockActiveDidChangeNotification = @"IsScreenBlockActive const CGFloat OWSWindowManagerCallBannerHeight(void) { - if (@available(iOS 11.4, *)) { - return CurrentAppContext().statusBarHeight + 20; - } - - if (![UIDevice currentDevice].hasIPhoneXNotch) { - return CurrentAppContext().statusBarHeight + 20; - } - - // Hardcode CallBanner height for iPhone X's on older iOS. - // - // As of iOS11.4 and iOS12, this no longer seems to be an issue, but previously statusBarHeight returned - // something like 20pts (IIRC), meaning our call banner did not extend sufficiently past the iPhone X notch. - // - // Before noticing that this behavior changed, I actually assumed that notch height was intentionally excluded from - // the statusBarHeight, and that this was not a bug, else I'd have taken better notes. - return 64; + return CurrentAppContext().statusBarHeight + 20; } // Behind everything, especially the root window. @@ -200,19 +185,7 @@ const UIWindowLevel UIWindowLevel_MessageActions(void) - (UIWindow *)createMenuActionsWindowWithRoowWindow:(UIWindow *)rootWindow { - UIWindow *window; - if (@available(iOS 11, *)) { - // On iOS11, setting the windowLevel is insufficient, so we override - // the `windowLevel` getter. - window = [[MessageActionsWindow alloc] initWithFrame:rootWindow.bounds]; - } else { - // On iOS9, 10 overriding the `windowLevel` getter does not cause the - // window to be displayed above the keyboard, but setting the window - // level works. - window = [[UIWindow alloc] initWithFrame:rootWindow.bounds]; - window.windowLevel = UIWindowLevel_MessageActions(); - } - + UIWindow *window = [[MessageActionsWindow alloc] initWithFrame:rootWindow.bounds]; window.hidden = YES; window.backgroundColor = UIColor.clearColor; diff --git a/SessionUIKit/Components/SearchBar.swift b/SessionUIKit/Components/SearchBar.swift index f4fa0b992..ecefdca22 100644 --- a/SessionUIKit/Components/SearchBar.swift +++ b/SessionUIKit/Components/SearchBar.swift @@ -23,12 +23,7 @@ public extension UISearchBar { setImage(searchImage, for: .search, state: .normal) let clearImage = #imageLiteral(resourceName: "searchbar_clear").withTint(Colors.searchBarPlaceholder)! setImage(clearImage, for: .clear, state: .normal) - let searchTextField: UITextField - if #available(iOS 13, *) { - searchTextField = self.searchTextField - } else { - searchTextField = self.value(forKey: "_searchField") as! UITextField - } + let searchTextField: UITextField = self.searchTextField searchTextField.backgroundColor = Colors.searchBarBackground // The search bar background color searchTextField.textColor = Colors.text searchTextField.attributedPlaceholder = NSAttributedString(string: NSLocalizedString("Search", comment: ""), attributes: [ .foregroundColor : Colors.searchBarPlaceholder ]) diff --git a/SessionUIKit/Style Guide/AppMode.swift b/SessionUIKit/Style Guide/AppMode.swift index e2ad9e75a..ac46013b8 100644 --- a/SessionUIKit/Style Guide/AppMode.swift +++ b/SessionUIKit/Style Guide/AppMode.swift @@ -34,11 +34,7 @@ public final class AppModeManager : NSObject { let userDefaults = UserDefaults.standard guard userDefaults.dictionaryRepresentation().keys.contains("appMode") else { - if #available(iOS 13.0, *) { - return UITraitCollection.current.userInterfaceStyle == .dark ? .dark : .light - } - - return .light + return (UITraitCollection.current.userInterfaceStyle == .dark ? .dark : .light) } let mode = userDefaults.integer(forKey: "appMode") diff --git a/SignalUtilitiesKit/Screen Lock/OWSScreenLock.swift b/SignalUtilitiesKit/Screen Lock/OWSScreenLock.swift index a8365162c..dfa7a7279 100644 --- a/SignalUtilitiesKit/Screen Lock/OWSScreenLock.swift +++ b/SignalUtilitiesKit/Screen Lock/OWSScreenLock.swift @@ -174,8 +174,7 @@ import SessionMessagingKit return .failure(error:defaultErrorDescription) } - if #available(iOS 11.0, *) { - switch laError.code { + switch laError.code { case .biometryNotAvailable: Logger.error("local authentication error: biometryNotAvailable.") return .failure(error: NSLocalizedString("SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE", @@ -191,41 +190,41 @@ import SessionMessagingKit default: // Fall through to second switch break - } } switch laError.code { - case .authenticationFailed: - Logger.error("local authentication error: authenticationFailed.") - return .failure(error: NSLocalizedString("SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_FAILED", - comment: "Indicates that Touch ID/Face ID/Phone Passcode authentication failed.")) - case .userCancel, .userFallback, .systemCancel, .appCancel: - Logger.info("local authentication cancelled.") - return .cancel - case .passcodeNotSet: - Logger.error("local authentication error: passcodeNotSet.") - return .failure(error: NSLocalizedString("SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_PASSCODE_NOT_SET", - comment: "Indicates that Touch ID/Face ID/Phone Passcode passcode is not set.")) - case .touchIDNotAvailable: - Logger.error("local authentication error: touchIDNotAvailable.") - return .failure(error: NSLocalizedString("SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE", - comment: "Indicates that Touch ID/Face ID/Phone Passcode are not available on this device.")) - case .touchIDNotEnrolled: - Logger.error("local authentication error: touchIDNotEnrolled.") - return .failure(error: NSLocalizedString("SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED", - comment: "Indicates that Touch ID/Face ID/Phone Passcode is not configured on this device.")) - case .touchIDLockout: - Logger.error("local authentication error: touchIDLockout.") - return .failure(error: NSLocalizedString("SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT", - comment: "Indicates that Touch ID/Face ID/Phone Passcode is 'locked out' on this device due to authentication failures.")) - case .invalidContext: - owsFailDebug("context not valid.") - return .unexpectedFailure(error:defaultErrorDescription) - case .notInteractive: - owsFailDebug("context not interactive.") - return .unexpectedFailure(error:defaultErrorDescription) + case .authenticationFailed: + Logger.error("local authentication error: authenticationFailed.") + return .failure(error: NSLocalizedString("SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_FAILED", + comment: "Indicates that Touch ID/Face ID/Phone Passcode authentication failed.")) + case .userCancel, .userFallback, .systemCancel, .appCancel: + Logger.info("local authentication cancelled.") + return .cancel + case .passcodeNotSet: + Logger.error("local authentication error: passcodeNotSet.") + return .failure(error: NSLocalizedString("SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_PASSCODE_NOT_SET", + comment: "Indicates that Touch ID/Face ID/Phone Passcode passcode is not set.")) + case .touchIDNotAvailable: + Logger.error("local authentication error: touchIDNotAvailable.") + return .failure(error: NSLocalizedString("SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE", + comment: "Indicates that Touch ID/Face ID/Phone Passcode are not available on this device.")) + case .touchIDNotEnrolled: + Logger.error("local authentication error: touchIDNotEnrolled.") + return .failure(error: NSLocalizedString("SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED", + comment: "Indicates that Touch ID/Face ID/Phone Passcode is not configured on this device.")) + case .touchIDLockout: + Logger.error("local authentication error: touchIDLockout.") + return .failure(error: NSLocalizedString("SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT", + comment: "Indicates that Touch ID/Face ID/Phone Passcode is 'locked out' on this device due to authentication failures.")) + case .invalidContext: + owsFailDebug("context not valid.") + return .unexpectedFailure(error:defaultErrorDescription) + case .notInteractive: + owsFailDebug("context not interactive.") + return .unexpectedFailure(error:defaultErrorDescription) } } + return .failure(error:defaultErrorDescription) } @@ -241,10 +240,7 @@ import SessionMessagingKit // Never recycle biometric auth. context.touchIDAuthenticationAllowableReuseDuration = TimeInterval(0) - - if #available(iOS 11.0, *) { - assert(!context.interactionNotAllowed) - } + assert(!context.interactionNotAllowed) return context } diff --git a/SignalUtilitiesKit/Shared View Controllers/OWSNavigationController.m b/SignalUtilitiesKit/Shared View Controllers/OWSNavigationController.m index 6921a9001..3fa1aa29a 100644 --- a/SignalUtilitiesKit/Shared View Controllers/OWSNavigationController.m +++ b/SignalUtilitiesKit/Shared View Controllers/OWSNavigationController.m @@ -161,28 +161,16 @@ NS_ASSUME_NONNULL_BEGIN OWSLogDebug(@""); [UIView setAnimationsEnabled:NO]; - - if (@available(iOS 11.0, *)) { - if (!CurrentAppContext().isMainApp) { - self.additionalSafeAreaInsets = UIEdgeInsetsZero; - } else if (OWSWindowManager.sharedManager.hasCall) { - self.additionalSafeAreaInsets = UIEdgeInsetsMake(20, 0, 0, 0); - } else { - self.additionalSafeAreaInsets = UIEdgeInsetsZero; - } - - // in iOS11 we have to ensure the navbar frame *in* layoutSubviews. - [navbar layoutSubviews]; + + if (!CurrentAppContext().isMainApp) { + self.additionalSafeAreaInsets = UIEdgeInsetsZero; + } else if (OWSWindowManager.sharedManager.hasCall) { + self.additionalSafeAreaInsets = UIEdgeInsetsMake(20, 0, 0, 0); } else { - // in iOS9/10 we only need to size the navbar once - [navbar sizeToFit]; - [navbar layoutIfNeeded]; - - // Since the navbar's frame was updated, we need to be sure our child VC's - // container view is updated. - [self.view setNeedsLayout]; - [self.view layoutSubviews]; + self.additionalSafeAreaInsets = UIEdgeInsetsZero; } + + [navbar layoutSubviews]; [UIView setAnimationsEnabled:YES]; } diff --git a/SignalUtilitiesKit/Shared View Controllers/OWSTableViewController.m b/SignalUtilitiesKit/Shared View Controllers/OWSTableViewController.m index 0a6b1915f..b7cb56e6b 100644 --- a/SignalUtilitiesKit/Shared View Controllers/OWSTableViewController.m +++ b/SignalUtilitiesKit/Shared View Controllers/OWSTableViewController.m @@ -110,11 +110,7 @@ const CGFloat kOWSTable_DefaultCellHeight = 45.f; + (void)configureCell:(UITableViewCell *)cell { cell.backgroundColor = LKColors.cellBackground; - if (@available(iOS 13, *)) { - cell.contentView.backgroundColor = UIColor.clearColor; - } else { - cell.contentView.backgroundColor = LKColors.cellBackground; - } + cell.contentView.backgroundColor = UIColor.clearColor; cell.textLabel.font = [UIFont systemFontOfSize:LKValues.mediumFontSize]; cell.textLabel.textColor = LKColors.text; diff --git a/SignalUtilitiesKit/Shared Views/OWSNavigationBar.swift b/SignalUtilitiesKit/Shared Views/OWSNavigationBar.swift index ef81497b4..5547ab36b 100644 --- a/SignalUtilitiesKit/Shared Views/OWSNavigationBar.swift +++ b/SignalUtilitiesKit/Shared Views/OWSNavigationBar.swift @@ -114,28 +114,6 @@ public class OWSNavigationBar: UINavigationBar { self.navBarLayoutDelegate?.navBarCallLayoutDidChange(navbar: self) } - public override func sizeThatFits(_ size: CGSize) -> CGSize { - guard OWSWindowManager.shared().hasCall() else { - return super.sizeThatFits(size) - } - - if #available(iOS 11, *) { - return super.sizeThatFits(size) - } else if #available(iOS 10, *) { - // iOS10 - // sizeThatFits is repeatedly called to determine how much space to reserve for that navbar. - // That is, increasing this causes the child view controller to be pushed down. - // (as of iOS11, this is not used and instead we use additionalSafeAreaInsets) - return CGSize(width: fullWidth, height: navbarWithoutStatusHeight + statusBarHeight) - } else { - // iOS9 - // sizeThatFits is repeatedly called to determine how much space to reserve for that navbar. - // That is, increasing this causes the child view controller to be pushed down. - // (as of iOS11, this is not used and instead we use additionalSafeAreaInsets) - return CGSize(width: fullWidth, height: navbarWithoutStatusHeight + callBannerHeight + 20) - } - } - public override func layoutSubviews() { guard CurrentAppContext().isMainApp else { super.layoutSubviews() diff --git a/SignalUtilitiesKit/Utilities/OWSQueues.h b/SignalUtilitiesKit/Utilities/OWSQueues.h deleted file mode 100644 index 5ca99712a..000000000 --- a/SignalUtilitiesKit/Utilities/OWSQueues.h +++ /dev/null @@ -1,28 +0,0 @@ -// -// Copyright (c) 2017 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -#ifdef DEBUG - -#define AssertOnDispatchQueue(queue) \ - { \ - if (@available(iOS 10.0, *)) { \ - dispatch_assert_queue(queue); \ - } else { \ - _Pragma("clang diagnostic push") _Pragma("clang diagnostic ignored \"-Wdeprecated-declarations\"") \ - OWSAssertDebug(dispatch_get_current_queue() == queue); \ - _Pragma("clang diagnostic pop") \ - } \ - } - -#else - -#define AssertOnDispatchQueue(queue) - -#endif - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/UIColor+Extensions.swift b/SignalUtilitiesKit/Utilities/UIColor+Extensions.swift index 5001f50ef..ada78290b 100644 --- a/SignalUtilitiesKit/Utilities/UIColor+Extensions.swift +++ b/SignalUtilitiesKit/Utilities/UIColor+Extensions.swift @@ -29,19 +29,13 @@ public extension UIColor { let renderer: UIGraphicsImageRenderer = UIGraphicsImageRenderer(bounds: bounds) return renderer.image { rendererContext in - if #available(iOS 13.0, *) { - rendererContext.cgContext - .setFillColor( - self.resolvedColor( - // Note: This is needed for '.cgColor' to support dark mode - with: UITraitCollection(userInterfaceStyle: isDarkMode ? .dark : .light) - ).cgColor - ) - } - else { - rendererContext.cgContext.setFillColor(self.cgColor) - } - + rendererContext.cgContext + .setFillColor( + self.resolvedColor( + // Note: This is needed for '.cgColor' to support dark mode + with: UITraitCollection(userInterfaceStyle: isDarkMode ? .dark : .light) + ).cgColor + ) rendererContext.cgContext.fill(bounds) } } diff --git a/SignalUtilitiesKit/Utilities/UIView+OWS.swift b/SignalUtilitiesKit/Utilities/UIView+OWS.swift index 18b36936b..0d4542538 100644 --- a/SignalUtilitiesKit/Utilities/UIView+OWS.swift +++ b/SignalUtilitiesKit/Utilities/UIView+OWS.swift @@ -53,25 +53,13 @@ public extension UIView { } func renderAsImage(opaque: Bool, scale: CGFloat) -> UIImage? { - if #available(iOS 10, *) { - let format = UIGraphicsImageRendererFormat() - format.scale = scale - format.opaque = opaque - let renderer = UIGraphicsImageRenderer(bounds: self.bounds, - format: format) - return renderer.image { (context) in - self.layer.render(in: context.cgContext) - } - } else { - UIGraphicsBeginImageContextWithOptions(bounds.size, opaque, scale) - if let _ = UIGraphicsGetCurrentContext() { - drawHierarchy(in: bounds, afterScreenUpdates: true) - let image = UIGraphicsGetImageFromCurrentImageContext() - UIGraphicsEndImageContext() - return image - } - owsFailDebug("Could not create graphics context.") - return nil + let format = UIGraphicsImageRendererFormat() + format.scale = scale + format.opaque = opaque + let renderer = UIGraphicsImageRenderer(bounds: self.bounds, + format: format) + return renderer.image { (context) in + self.layer.render(in: context.cgContext) } } From f9f06625581c90ad121a2fc94ce4e3f191e80883 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 1 Jul 2022 13:59:50 +1000 Subject: [PATCH 126/157] Removed an import for file deleted as part of the iOS 13 min version changes Fixed a bug where opening a conversation by tapping a notification wouldn't mark the conversation messages as read --- Session/Conversations/ConversationVC.swift | 9 +++++---- SignalUtilitiesKit/Meta/SignalUtilitiesKit.h | 1 - 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 59e6c81e0..bddae7992 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -424,7 +424,6 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers } } - viewModel.markAllAsRead() recoverInputView() } @@ -580,8 +579,11 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers snInputView.text = draft } - // Now we have done all the needed diffs, update the viewModel with the latest data + // Now we have done all the needed diffs, update the viewModel with the latest data and mark + // all messages as read (we do it in here as the 'threadData' actually contains the last + // 'interactionId' for the thread) self.viewModel.updateThreadData(updatedThreadData) + self.viewModel.markAllAsRead() /// **Note:** This needs to happen **after** we have update the viewModel's thread data if initialLoad || viewModel.threadData.currentUserIsClosedGroupMember != updatedThreadData.currentUserIsClosedGroupMember { @@ -608,9 +610,8 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers return } - // Mark received messages as read + // Store the 'sentMessageBeforeUpdate' state locally let didSendMessageBeforeUpdate: Bool = self.viewModel.sentMessageBeforeUpdate - self.viewModel.markAllAsRead() self.viewModel.sentMessageBeforeUpdate = false // When sending a message we want to reload the UI instantly (with any form of animation the message diff --git a/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h b/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h index 7863687e9..f76a0cba6 100644 --- a/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h +++ b/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h @@ -17,7 +17,6 @@ FOUNDATION_EXPORT const unsigned char SignalUtilitiesKitVersionString[]; #import #import #import -#import #import #import #import From fe2e2510bb038640b1970fc0d4504629840b0c27 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 1 Jul 2022 17:46:50 +1000 Subject: [PATCH 127/157] Fixed a bug where open group messages sent on another device weren't correctly getting marked as sent --- .../MessageReceiver+VisibleMessages.swift | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index 4692851d9..1dc545ad1 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -276,24 +276,34 @@ extension MessageReceiver { ) throws { guard variant == .standardOutgoing else { return } - if let syncTarget: String = syncTarget { - try RecipientState( - interactionId: interactionId, - recipientId: syncTarget, - state: .sent - ).save(db) - } - else if thread.variant == .closedGroup { - try GroupMember - .filter(GroupMember.Columns.groupId == thread.id) - .fetchAll(db) - .forEach { member in + switch thread.variant { + case .contact: + if let syncTarget: String = syncTarget { try RecipientState( interactionId: interactionId, - recipientId: member.profileId, + recipientId: syncTarget, state: .sent ).save(db) } + + case .closedGroup: + try GroupMember + .filter(GroupMember.Columns.groupId == thread.id) + .fetchAll(db) + .forEach { member in + try RecipientState( + interactionId: interactionId, + recipientId: member.profileId, + state: .sent + ).save(db) + } + + case .openGroup: + try RecipientState( + interactionId: interactionId, + recipientId: thread.id, // For open groups this will always be the thread id + state: .sent + ).save(db) } // For outgoing messages mark all older interactions as read (the user should have seen From 34fea96db3ea1ef5cb9993912fcbcced9edd7e35 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 4 Jul 2022 17:36:48 +1000 Subject: [PATCH 128/157] Fixed a bunch more bugs around push notifications and avatars Added code to prevent the garbage collection job from auto-running more often than once every 23 hours Fixed a bug where if the first avatar you try to add is your own, it could fail due to the folder not getting created Fixed a bug where updating your profile would store and send an invalid profile picture url against your profile Fixed an issue where the closed group icon wouldn't appear as the double icon when it couldn't retrieve a second profile Fixed a bug where the device might not correctly register for push notifications in some cases Fixed a bug where interacting with a notification when the app is in the background (but not closed) wasn't doing anything Fixed a bug where the SyncPushTokensJob wouldn't re-run correctly in some cases if the user was already registered Updated the profile avatar downloading logic to only download avatars if they have been updated Updated the migration and OpenGroupManager to force Session-run open groups to always use the OpenGroupAPI.defaultServer value --- Session/Meta/AppDelegate.swift | 12 +-- Session/Notifications/SyncPushTokensJob.swift | 7 +- Session/Shared/FullConversationCell.swift | 3 +- .../Migrations/_003_YDBToGRDBMigration.swift | 8 +- .../Jobs/Types/GarbageCollectionJob.swift | 15 ++++ .../Open Groups/OpenGroupManager.swift | 78 +++++++++++++++--- .../MessageReceiver+ClosedGroups.swift | 6 +- .../Sending & Receiving/MessageReceiver.swift | 6 +- .../Notifications/PushNotificationAPI.swift | 62 +++++++------- .../SessionThreadViewModel.swift | 12 +++ .../Utilities/ProfileManager.swift | 9 ++- .../Open Groups/OpenGroupManagerSpec.swift | 59 ++++++++++++++ .../NotificationServiceExtension.swift | 18 ++--- .../SimplifiedConversationCell.swift | 3 +- SessionSnodeKit/SnodeAPI.swift | 80 +++++++++---------- .../Migrations/_002_SetupStandardJobs.swift | 8 ++ .../General/SNUserDefaults.swift | 1 + .../Profile Pictures/ProfilePictureView.swift | 69 +++++++++------- 18 files changed, 316 insertions(+), 140 deletions(-) diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 556337aae..0cf8f0e5a 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -144,11 +144,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD self?.handleActivation() } - // Clear all notifications whenever we become active. - // When opening the app from a notification, - // AppDelegate.didReceiveLocalNotification will always - // be called _before_ we become active. - clearAllNotificationsAndRestoreBadgeCount() + /// Clear all notifications whenever we become active + /// + /// **Note:** It looks like when opening the app from a notification, `userNotificationCenter(didReceive)` is + /// no longer always called before we become active so we need to dispatch this to run on the next run loop + DispatchQueue.main.async { [weak self] in + self?.clearAllNotificationsAndRestoreBadgeCount() + } // On every activation, clear old temp directories. ClearOldTemporaryDirectories(); diff --git a/Session/Notifications/SyncPushTokensJob.swift b/Session/Notifications/SyncPushTokensJob.swift index 478f3fa55..ca26e0347 100644 --- a/Session/Notifications/SyncPushTokensJob.swift +++ b/Session/Notifications/SyncPushTokensJob.swift @@ -34,7 +34,11 @@ public enum SyncPushTokensJob: JobExecutor { return } - guard !UIApplication.shared.isRegisteredForRemoteNotifications else { + // Push tokens don't normally change while the app is launched, so checking once during launch is + // usually sufficient, but e.g. on iOS11, users who have disabled "Allow Notifications" and disabled + // "Background App Refresh" will not be able to obtain an APN token. Enabling those settings does not + // restart the app, so we check every activation for users who haven't yet registered. + guard job.behaviour != .recurringOnActive || !UIApplication.shared.isRegisteredForRemoteNotifications else { deferred(job) // Don't need to do anything if push notifications are already registered return } @@ -157,6 +161,7 @@ extension SyncPushTokensJob { failure(error) } + .retainUntilComplete() } } diff --git a/Session/Shared/FullConversationCell.swift b/Session/Shared/FullConversationCell.swift index e8beb16b1..92ffebb8a 100644 --- a/Session/Shared/FullConversationCell.swift +++ b/Session/Shared/FullConversationCell.swift @@ -339,7 +339,8 @@ public final class FullConversationCell: UITableViewCell { useFallbackPicture: ( cellViewModel.threadVariant == .openGroup && cellViewModel.openGroupProfilePictureData == nil - ) + ), + showMultiAvatarForClosedGroup: true ) displayNameLabel.text = cellViewModel.displayName timestampLabel.text = cellViewModel.lastInteractionDate.formattedForDisplay diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 12682d06d..0a70ebeb9 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -178,11 +178,9 @@ enum _003_YDBToGRDBMigration: Migration { // value contains a HTTPS scheme so we get IP HTTP -> HTTPS for free as well) let processedOpenGroupServer: String = { // Check if the server is a Session-run one based on it's - guard - openGroup.server.contains(OpenGroupAPI.legacyDefaultServerIP) || - openGroup.server == OpenGroupAPI.defaultServer - .replacingOccurrences(of: "https://", with: "http://") - else { return openGroup.server } + guard OpenGroupManager.isSessionRunOpenGroup(server: openGroup.server) else { + return openGroup.server + } return OpenGroupAPI.defaultServer }() diff --git a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift index 4ec8ae8b9..43ce8b5ea 100644 --- a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift +++ b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift @@ -34,6 +34,21 @@ public enum GarbageCollectionJob: JobExecutor { .defaulting(to: Types.allCases) let timestampNow: TimeInterval = Date().timeIntervalSince1970 + /// Only do something if the job isn't the recurring one or it's been 23 hours since it last ran (23 hours so a user who opens the + /// app at about the same time every day will trigger the garbage collection) - since this runs when the app becomes active we + /// want to prevent it running to frequently (the app becomes active if a system alert, the notification center or the control panel + /// are shown) + let lastGarbageCollection: Date = UserDefaults.standard[.lastGarbageCollection] + .defaulting(to: Date.distantPast) + + guard + job.behaviour != .recurringOnActive || + Date().timeIntervalSince(lastGarbageCollection) > (23 * 60 * 60) + else { + deferred(job) + return + } + Storage.shared.writeAsync( updates: { db in /// Remove any expired controlMessageProcessRecords diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index c307e1a80..856003cc3 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -108,12 +108,57 @@ public final class OpenGroupManager: NSObject { // MARK: - Adding & Removing + private static func port(for server: String, serverUrl: URL) -> String { + if let port: Int = serverUrl.port { + return ":\(port)" + } + + let components: [String] = server.components(separatedBy: ":") + + guard + let port: String = components.last, + ( + port != components.first && + !port.starts(with: "//") + ) + else { return "" } + + return ":\(port)" + } + + public static func isSessionRunOpenGroup(server: String) -> Bool { + guard let serverUrl: URL = URL(string: server.lowercased()) else { return false } + + let serverPort: String = OpenGroupManager.port(for: server, serverUrl: serverUrl) + let serverHost: String = serverUrl.host + .defaulting( + to: server + .lowercased() + .replacingOccurrences(of: serverPort, with: "") + ) + let options: Set = Set([ + OpenGroupAPI.legacyDefaultServerIP, + OpenGroupAPI.defaultServer + .replacingOccurrences(of: "http://", with: "") + .replacingOccurrences(of: "https://", with: "") + ]) + + return options.contains(serverHost) + } + public func hasExistingOpenGroup(_ db: Database, roomToken: String, server: String, publicKey: String, dependencies: OGMDependencies = OGMDependencies()) -> Bool { guard let serverUrl: URL = URL(string: server.lowercased()) else { return false } - let serverHost: String = (serverUrl.host ?? server.lowercased()) - let serverPort: String = (serverUrl.port.map { ":\($0)" } ?? "") - let defaultServerHost: String = OpenGroupAPI.defaultServer.substring(from: "https://".count) + let serverPort: String = OpenGroupManager.port(for: server, serverUrl: serverUrl) + let serverHost: String = serverUrl.host + .defaulting( + to: server + .lowercased() + .replacingOccurrences(of: serverPort, with: "") + ) + let defaultServerHost: String = OpenGroupAPI.defaultServer + .replacingOccurrences(of: "http://", with: "") + .replacingOccurrences(of: "https://", with: "") var serverOptions: Set = Set([ server.lowercased(), "\(serverHost)\(serverPort)", @@ -121,12 +166,12 @@ public final class OpenGroupManager: NSObject { "https://\(serverHost)\(serverPort)" ]) - if serverHost == OpenGroupAPI.legacyDefaultServerIP { + // If the server is run by Session then include all configurations in case one of the alternate configurations + // was used + if OpenGroupManager.isSessionRunOpenGroup(server: server) { serverOptions.insert(defaultServerHost) serverOptions.insert("http://\(defaultServerHost)") - serverOptions.insert(OpenGroupAPI.defaultServer) - } - else if serverHost == defaultServerHost { + serverOptions.insert("https://\(defaultServerHost)") serverOptions.insert(OpenGroupAPI.legacyDefaultServerIP) serverOptions.insert("http://\(OpenGroupAPI.legacyDefaultServerIP)") serverOptions.insert("https://\(OpenGroupAPI.legacyDefaultServerIP)") @@ -158,7 +203,14 @@ public final class OpenGroupManager: NSObject { } // Store the open group information - let threadId: String = OpenGroup.idFor(roomToken: roomToken, server: server) + let targetServer: String = { + guard OpenGroupManager.isSessionRunOpenGroup(server: server) else { + return server.lowercased() + } + + return OpenGroupAPI.defaultServer + }() + let threadId: String = OpenGroup.idFor(roomToken: roomToken, server: targetServer) // Optionally try to insert a new version of the OpenGroup (it will fail if there is already an // inactive one but that won't matter as we then activate it @@ -167,14 +219,14 @@ public final class OpenGroupManager: NSObject { if (try? OpenGroup.exists(db, id: threadId)) == false { try? OpenGroup - .fetchOrCreate(db, server: server, roomToken: roomToken, publicKey: publicKey) + .fetchOrCreate(db, server: targetServer, roomToken: roomToken, publicKey: publicKey) .save(db) } // Set the group to active and reset the sequenceNumber (handle groups which have // been deactivated) _ = try? OpenGroup - .filter(id: OpenGroup.idFor(roomToken: roomToken, server: server)) + .filter(id: OpenGroup.idFor(roomToken: roomToken, server: targetServer)) .updateAll( db, OpenGroup.Columns.isActive.set(to: true), @@ -195,7 +247,7 @@ public final class OpenGroupManager: NSObject { .capabilitiesAndRoom( db, for: roomToken, - on: server, + on: targetServer, authenticated: false, using: dependencies ) @@ -206,7 +258,7 @@ public final class OpenGroupManager: NSObject { OpenGroupManager.handleCapabilities( db, capabilities: response.capabilities.data, - on: server + on: targetServer ) // Then the room @@ -215,7 +267,7 @@ public final class OpenGroupManager: NSObject { pollInfo: OpenGroupAPI.RoomPollInfo(room: response.room.data), publicKey: publicKey, for: roomToken, - on: server, + on: targetServer, dependencies: dependencies ) { seal.fulfill(()) diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift index a9e24c8f7..e2312383d 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift @@ -433,8 +433,10 @@ extension MessageReceiver { publicKey: userPublicKey ) } - else { - // Re-add the removed member as a zombie + + // Re-add the removed member as a zombie (unless the admin left which disbands the + // group) + if !didAdminLeave { try GroupMember( groupId: id, profileId: sender, diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index 5b4dca427..da8b9065b 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -344,8 +344,10 @@ public enum MessageReceiver { } // Download the profile picture if needed - db.afterNextTransactionCommit { _ in - ProfileManager.downloadAvatar(for: updatedProfile) + if updatedProfile.profilePictureUrl != profile.profilePictureUrl || updatedProfile.profileEncryptionKey != profile.profileEncryptionKey { + db.afterNextTransactionCommit { _ in + ProfileManager.downloadAvatar(for: updatedProfile) + } } } } diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift index 7aad64ac8..7770a65ae 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift @@ -113,41 +113,49 @@ public final class PushNotificationAPI : NSObject { request.allHTTPHeaderFields = [ Header.contentType.rawValue: "application/json" ] request.httpBody = body - let promise: Promise = attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) { - OnionRequestAPI.sendOnionRequest(request, to: server, using: .v2, with: serverPublicKey) - .map2 { _, response in - guard let response: UpdateRegistrationResponse = try? response?.decoded(as: UpdateRegistrationResponse.self) else { - return SNLog("Couldn't register device token.") + var promises: [Promise] = [] + + promises.append( + attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) { + OnionRequestAPI.sendOnionRequest(request, to: server, using: .v2, with: serverPublicKey) + .map2 { _, response -> Void in + guard let response: UpdateRegistrationResponse = try? response?.decoded(as: UpdateRegistrationResponse.self) else { + return SNLog("Couldn't register device token.") + } + guard response.body.code != 0 else { + return SNLog("Couldn't register device token due to error: \(response.body.message ?? "nil").") + } + + userDefaults[.deviceToken] = hexEncodedToken + userDefaults[.lastDeviceTokenUpload] = now + userDefaults[.isUsingFullAPNs] = true } - guard response.body.code != 0 else { - return SNLog("Couldn't register device token due to error: \(response.body.message ?? "nil").") - } - - userDefaults[.deviceToken] = hexEncodedToken - userDefaults[.lastDeviceTokenUpload] = now - userDefaults[.isUsingFullAPNs] = true - } - } - promise.catch2 { error in + } + ) + promises.first?.catch2 { error in SNLog("Couldn't register device token.") } // Subscribe to all closed groups - Storage.shared.read { db in - try ClosedGroup - .select(.threadId) - .joining( - required: ClosedGroup.members - .filter(GroupMember.Columns.profileId == getUserHexEncodedPublicKey(db)) - ) - .asRequest(of: String.self) - .fetchAll(db) - .forEach { closedGroupPublicKey in + promises.append( + contentsOf: Storage.shared + .read { db -> [String] in + try ClosedGroup + .select(.threadId) + .joining( + required: ClosedGroup.members + .filter(GroupMember.Columns.profileId == getUserHexEncodedPublicKey(db)) + ) + .asRequest(of: String.self) + .fetchAll(db) + } + .defaulting(to: []) + .map { closedGroupPublicKey -> Promise in performOperation(.subscribe, for: closedGroupPublicKey, publicKey: publicKey) } - } + ) - return promise + return when(fulfilled: promises) } @objc(registerWithToken:hexEncodedPublicKey:isForcedUpdate:) diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index e9132b51d..f32718c95 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -421,6 +421,7 @@ public extension SessionThreadViewModel { \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( SELECT MIN(\(groupMember[.profileId])) FROM \(GroupMember.self) + JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) WHERE ( \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND @@ -433,6 +434,7 @@ public extension SessionThreadViewModel { \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) = ( SELECT MAX(\(groupMember[.profileId])) FROM \(GroupMember.self) + JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) WHERE ( \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND @@ -662,6 +664,7 @@ public extension SessionThreadViewModel { let closedGroup: TypedTableAlias = TypedTableAlias() let groupMember: TypedTableAlias = TypedTableAlias() let openGroup: TypedTableAlias = TypedTableAlias() + let profile: TypedTableAlias = TypedTableAlias() let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) @@ -698,6 +701,7 @@ public extension SessionThreadViewModel { \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( SELECT MIN(\(groupMember[.profileId])) FROM \(GroupMember.self) + JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) WHERE ( \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND @@ -710,6 +714,7 @@ public extension SessionThreadViewModel { \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) = ( SELECT MAX(\(groupMember[.profileId])) FROM \(GroupMember.self) + JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) WHERE ( \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND @@ -851,6 +856,7 @@ public extension SessionThreadViewModel { \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( SELECT MIN(\(groupMember[.profileId])) FROM \(GroupMember.self) + JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) WHERE ( \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND @@ -863,6 +869,7 @@ public extension SessionThreadViewModel { \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) = ( SELECT MAX(\(groupMember[.profileId])) FROM \(GroupMember.self) + JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) WHERE ( \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND @@ -1034,6 +1041,7 @@ public extension SessionThreadViewModel { \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( SELECT MIN(\(groupMember[.profileId])) FROM \(GroupMember.self) + JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) WHERE ( \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND @@ -1046,6 +1054,7 @@ public extension SessionThreadViewModel { \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) = ( SELECT MAX(\(groupMember[.profileId])) FROM \(GroupMember.self) + JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) WHERE ( \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND @@ -1330,6 +1339,7 @@ public extension SessionThreadViewModel { let closedGroup: TypedTableAlias = TypedTableAlias() let groupMember: TypedTableAlias = TypedTableAlias() let openGroup: TypedTableAlias = TypedTableAlias() + let profile: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) @@ -1378,6 +1388,7 @@ public extension SessionThreadViewModel { \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( SELECT MIN(\(groupMember[.profileId])) FROM \(GroupMember.self) + JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) WHERE ( \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND @@ -1390,6 +1401,7 @@ public extension SessionThreadViewModel { \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) = ( SELECT MAX(\(groupMember[.profileId])) FROM \(GroupMember.self) + JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) WHERE ( \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND diff --git a/SessionMessagingKit/Utilities/ProfileManager.swift b/SessionMessagingKit/Utilities/ProfileManager.swift index 170bee378..46a0564e2 100644 --- a/SessionMessagingKit/Utilities/ProfileManager.swift +++ b/SessionMessagingKit/Utilities/ProfileManager.swift @@ -83,9 +83,12 @@ public struct ProfileManager { // MARK: - File Paths public static let sharedDataProfileAvatarsDirPath: String = { - URL(fileURLWithPath: OWSFileSystem.appSharedDataDirectoryPath()) + let path: String = URL(fileURLWithPath: OWSFileSystem.appSharedDataDirectoryPath()) .appendingPathComponent("ProfileAvatars") .path + OWSFileSystem.ensureDirectoryExists(path) + + return path }() private static let profileAvatarsDirPath: String = { @@ -305,8 +308,8 @@ public struct ProfileManager { // Upload the avatar to the FileServer FileServerAPI .upload(encryptedAvatarData) - .done(on: queue) { fileId in - let downloadUrl: String = "\(FileServerAPI.server)/files/\(fileId)" + .done(on: queue) { fileUploadResponse in + let downloadUrl: String = "\(FileServerAPI.server)/files/\(fileUploadResponse.id)" UserDefaults.standard[.lastProfilePictureUpload] = Date() Storage.shared.writeAsync { db in diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift index d0f84429f..bfb823411 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -421,6 +421,65 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: - Adding & Removing + // MARK: - --isSessionRunOpenGroup + + context("when checking if an open group is run by session") { + it("returns false when it does not match one of Sessions servers with no scheme") { + expect(OpenGroupManager.isSessionRunOpenGroup(server: "test.test")) + .to(beFalse()) + } + + it("returns false when it does not match one of Sessions servers in http") { + expect(OpenGroupManager.isSessionRunOpenGroup(server: "http://test.test")) + .to(beFalse()) + } + + it("returns false when it does not match one of Sessions servers in https") { + expect(OpenGroupManager.isSessionRunOpenGroup(server: "https://test.test")) + .to(beFalse()) + } + + it("returns true when it matches Sessions SOGS IP") { + expect(OpenGroupManager.isSessionRunOpenGroup(server: "116.203.70.33")) + .to(beTrue()) + } + + it("returns true when it matches Sessions SOGS IP with http") { + expect(OpenGroupManager.isSessionRunOpenGroup(server: "http://116.203.70.33")) + .to(beTrue()) + } + + it("returns true when it matches Sessions SOGS IP with https") { + expect(OpenGroupManager.isSessionRunOpenGroup(server: "https://116.203.70.33")) + .to(beTrue()) + } + + it("returns true when it matches Sessions SOGS IP with a port") { + expect(OpenGroupManager.isSessionRunOpenGroup(server: "116.203.70.33:80")) + .to(beTrue()) + } + + it("returns true when it matches Sessions SOGS domain") { + expect(OpenGroupManager.isSessionRunOpenGroup(server: "open.getsession.org")) + .to(beTrue()) + } + + it("returns true when it matches Sessions SOGS domain with http") { + expect(OpenGroupManager.isSessionRunOpenGroup(server: "http://open.getsession.org")) + .to(beTrue()) + } + + it("returns true when it matches Sessions SOGS domain with https") { + expect(OpenGroupManager.isSessionRunOpenGroup(server: "https://open.getsession.org")) + .to(beTrue()) + } + + it("returns true when it matches Sessions SOGS domain with a port") { + expect(OpenGroupManager.isSessionRunOpenGroup(server: "open.getsession.org:80")) + .to(beTrue()) + } + } + // MARK: - --hasExistingOpenGroup context("when checking it has an existing open group") { diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index bb701f1be..1cb3dd274 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -82,16 +82,16 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension let interaction: Interaction = try? Interaction.fetchOne(db, id: interactionId), interaction.variant == .standardOutgoing { - let semaphore = DispatchSemaphore(value: 0) - let center = UNUserNotificationCenter.current() - center.getDeliveredNotifications { notifications in - let matchingNotifications = notifications.filter({ $0.request.content.userInfo[NotificationServiceExtension.threadIdKey] as? String == interaction.threadId }) - center.removeDeliveredNotifications(withIdentifiers: matchingNotifications.map({ $0.request.identifier })) - // Hack: removeDeliveredNotifications seems to be async,need to wait for some time before the delivered notifications can be removed. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { semaphore.signal() } + let semaphore = DispatchSemaphore(value: 0) + let center = UNUserNotificationCenter.current() + center.getDeliveredNotifications { notifications in + let matchingNotifications = notifications.filter({ $0.request.content.userInfo[NotificationServiceExtension.threadIdKey] as? String == interaction.threadId }) + center.removeDeliveredNotifications(withIdentifiers: matchingNotifications.map({ $0.request.identifier })) + // Hack: removeDeliveredNotifications seems to be async,need to wait for some time before the delivered notifications can be removed. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { semaphore.signal() } + } + semaphore.wait() } - semaphore.wait() - } case let unsendRequest as UnsendRequest: try MessageReceiver.handleUnsendRequest(db, message: unsendRequest) diff --git a/SessionShareExtension/SimplifiedConversationCell.swift b/SessionShareExtension/SimplifiedConversationCell.swift index b3427ebb3..9bea5687a 100644 --- a/SessionShareExtension/SimplifiedConversationCell.swift +++ b/SessionShareExtension/SimplifiedConversationCell.swift @@ -95,7 +95,8 @@ final class SimplifiedConversationCell: UITableViewCell { additionalProfile: cellViewModel.additionalProfile, threadVariant: cellViewModel.threadVariant, openGroupProfilePicture: cellViewModel.openGroupProfilePictureData.map { UIImage(data: $0) }, - useFallbackPicture: (cellViewModel.threadVariant == .openGroup && cellViewModel.openGroupProfilePictureData == nil) + useFallbackPicture: (cellViewModel.threadVariant == .openGroup && cellViewModel.openGroupProfilePictureData == nil), + showMultiAvatarForClosedGroup: true ) displayNameLabel.text = cellViewModel.displayName } diff --git a/SessionSnodeKit/SnodeAPI.swift b/SessionSnodeKit/SnodeAPI.swift index bda5ad482..b71cb0ef9 100644 --- a/SessionSnodeKit/SnodeAPI.swift +++ b/SessionSnodeKit/SnodeAPI.swift @@ -304,52 +304,52 @@ public final class SnodeAPI { }.defaulting(to: true) let snodePool: Set = SnodeAPI.snodePool - if hasInsufficientSnodes || hasSnodePoolExpired { - if let getSnodePoolPromise = getSnodePoolPromise { return getSnodePoolPromise } - - let promise: Promise> - if snodePool.count < minSnodePoolCount { - promise = getSnodePoolFromSeedNode() + guard hasInsufficientSnodes || hasSnodePoolExpired else { + return Promise.value(snodePool) + } + + if let getSnodePoolPromise = getSnodePoolPromise { return getSnodePoolPromise } + + let promise: Promise> + if snodePool.count < minSnodePoolCount { + promise = getSnodePoolFromSeedNode() + } + else { + promise = getSnodePoolFromSnode().recover2 { _ in + getSnodePoolFromSeedNode() } - else { - promise = getSnodePoolFromSnode().recover2 { _ in - getSnodePoolFromSeedNode() + } + + getSnodePoolPromise = promise + promise.map2 { snodePool -> Set in + guard !snodePool.isEmpty else { throw SnodeAPIError.snodePoolUpdatingFailed } + + return snodePool + } + + promise.then2 { snodePool -> Promise> in + let (promise, seal) = Promise>.pending() + + Storage.shared.writeAsync( + updates: { db in + db[.lastSnodePoolRefreshDate] = now + setSnodePool(to: snodePool, db: db) + }, + completion: { _, _ in + seal.fulfill(snodePool) } - } - - getSnodePoolPromise = promise - promise.map2 { snodePool -> Set in - guard !snodePool.isEmpty else { throw SnodeAPIError.snodePoolUpdatingFailed } - - return snodePool - } - - promise.then2 { snodePool -> Promise> in - let (promise, seal) = Promise>.pending() - - Storage.shared.writeAsync( - updates: { db in - db[.lastSnodePoolRefreshDate] = now - setSnodePool(to: snodePool, db: db) - }, - completion: { _, _ in - seal.fulfill(snodePool) - } - ) - - return promise - } - promise.done2 { _ in - getSnodePoolPromise = nil - } - promise.catch2 { _ in - getSnodePoolPromise = nil - } + ) return promise } + promise.done2 { _ in + getSnodePoolPromise = nil + } + promise.catch2 { _ in + getSnodePoolPromise = nil + } - return Promise.value(snodePool) + return promise } public static func getSessionID(for onsName: String) -> Promise { diff --git a/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift index f8a13021b..7e7fb370d 100644 --- a/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -19,6 +19,14 @@ enum _002_SetupStandardJobs: Migration { variant: .syncPushTokens, behaviour: .recurringOnLaunch ).inserted(db) + + // Note: We actually need this job to run both onLaunch and onActive as the logic differs + // slightly and there are cases where a user might not be registered in 'onLaunch' but is + // in 'onActive' (see the `SyncPushTokensJob` for more info) + _ = try Job( + variant: .syncPushTokens, + behaviour: .recurringOnActive + ).inserted(db) } Storage.update(progress: 1, for: self, in: target) // In case this is the last migration diff --git a/SessionUtilitiesKit/General/SNUserDefaults.swift b/SessionUtilitiesKit/General/SNUserDefaults.swift index 815ea5317..8fbeee36c 100644 --- a/SessionUtilitiesKit/General/SNUserDefaults.swift +++ b/SessionUtilitiesKit/General/SNUserDefaults.swift @@ -43,6 +43,7 @@ public enum SNUserDefaults { case lastProfilePictureUpload case lastOpenGroupImageUpdate case lastOpen + case lastGarbageCollection } public enum Double: Swift.String { diff --git a/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift b/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift index 08123780e..a9d57eb16 100644 --- a/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift +++ b/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift @@ -77,7 +77,8 @@ public final class ProfilePictureView: UIView { useFallbackPicture: ( viewModel.threadVariant == .openGroup && viewModel.openGroupProfilePictureData == nil - ) + ), + showMultiAvatarForClosedGroup: true ) } @@ -87,7 +88,8 @@ public final class ProfilePictureView: UIView { additionalProfile: Profile? = nil, threadVariant: SessionThread.Variant, openGroupProfilePicture: UIImage? = nil, - useFallbackPicture: Bool = false + useFallbackPicture: Bool = false, + showMultiAvatarForClosedGroup: Bool = false ) { AssertIsOnMainThread() guard !useFallbackPicture else { @@ -125,36 +127,41 @@ public final class ProfilePictureView: UIView { ) } - // Calulate the sizes (and set the additional image content + // Calulate the sizes (and set the additional image content) let targetSize: CGFloat - if let additionalProfile: Profile = additionalProfile, openGroupProfilePicture == nil { - if self.size == 40 { - targetSize = 32 - } - else if self.size == Values.largeProfilePictureSize { - targetSize = 56 - } - else { - targetSize = Values.smallProfilePictureSize - } - - imageViewWidthConstraint.constant = targetSize - imageViewHeightConstraint.constant = targetSize - additionalImageViewWidthConstraint.constant = targetSize - additionalImageViewHeightConstraint.constant = targetSize - additionalImageView.isHidden = false - additionalImageView.image = getProfilePicture( - of: targetSize, - for: additionalProfile.id, - profile: additionalProfile - ).image - } - else { - targetSize = self.size - imageViewWidthConstraint.constant = targetSize - imageViewHeightConstraint.constant = targetSize - additionalImageView.isHidden = true - additionalImageView.image = nil + + switch (threadVariant, showMultiAvatarForClosedGroup) { + case (.closedGroup, true): + if self.size == 40 { + targetSize = 32 + } + else if self.size == Values.largeProfilePictureSize { + targetSize = 56 + } + else { + targetSize = Values.smallProfilePictureSize + } + + imageViewWidthConstraint.constant = targetSize + imageViewHeightConstraint.constant = targetSize + additionalImageViewWidthConstraint.constant = targetSize + additionalImageViewHeightConstraint.constant = targetSize + additionalImageView.isHidden = false + + if let additionalProfile: Profile = additionalProfile { + additionalImageView.image = getProfilePicture( + of: targetSize, + for: additionalProfile.id, + profile: additionalProfile + ).image + } + + default: + targetSize = self.size + imageViewWidthConstraint.constant = targetSize + imageViewHeightConstraint.constant = targetSize + additionalImageView.isHidden = true + additionalImageView.image = nil } // Set the image From 6b9a19c761855f1330223d4dedcf8a6b78fb2269 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 5 Jul 2022 16:47:12 +1000 Subject: [PATCH 129/157] Fixed a few more bugs and made a couple of optimisations to the GarbageCollectionJob Added an index on Quote.authorId and added a garbage collection job to remove orphaned Profile entries Added a few more indexes to improve the GarbageCollectionJob performance Added some debug code to force a re-migration on next launch if the DB is invalid (only affects testers so code should be removed) Fixed an issue where the GetSnodePool job wasn't properly blocking Fixed an issue where a user could send the same message multiple times if they clicked the send button quickly enough Fixed an issue where profiles might not have been getting created correctly for ClosedGroup members which have no threads/interactions --- Session.xcodeproj/project.pbxproj | 4 +- .../ConversationVC+Interaction.swift | 9 ++ Session/Meta/AppDelegate.swift | 83 +++++++++++-------- .../_001_InitialSetupMigration.swift | 7 +- .../Migrations/_002_SetupStandardJobs.swift | 6 +- .../Migrations/_003_YDBToGRDBMigration.swift | 27 ++++++ .../Jobs/Types/GarbageCollectionJob.swift | 39 +++++++++ .../Migrations/_002_SetupStandardJobs.swift | 10 ++- .../_001_InitialSetupMigration.swift | 5 +- .../Migrations/_002_SetupStandardJobs.swift | 3 +- SessionUtilitiesKit/Database/Models/Job.swift | 75 +++++++++++++---- SessionUtilitiesKit/Database/Storage.swift | 21 +++-- .../Database/StorageError.swift | 2 + SessionUtilitiesKit/JobRunner/JobRunner.swift | 52 +++--------- SignalUtilitiesKit/Utilities/AppSetup.swift | 8 +- 15 files changed, 245 insertions(+), 106 deletions(-) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 7c1a8f752..62d1a01b1 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -6798,7 +6798,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 354; + CURRENT_PROJECT_VERSION = 355; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -6870,7 +6870,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 354; + CURRENT_PROJECT_VERSION = 355; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 284207fd7..aaad028c0 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -337,6 +337,15 @@ extension ConversationVC: modal.proceed = { self.sendMessage(hasPermissionToSendSeed: true) } return present(modal, animated: true, completion: nil) } + + // Clearing this out immediately (even though it already happens in 'messageSent') to prevent + // "double sending" if the user rapidly taps the send button + DispatchQueue.main.async { [weak self] in + self?.snInputView.text = "" + self?.snInputView.quoteDraftInfo = nil + + self?.resetMentions() + } // Note: 'shouldBeVisible' is set to true the first time a thread is saved so we can // use it to determine if the user is creating a new thread and update the 'isApproved' diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 0cf8f0e5a..53f06c9f8 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -66,9 +66,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD minEstimatedTotalTime: minEstimatedTotalTime ) }, - migrationsCompletion: { [weak self] successful, needsConfigSync in - guard successful else { - self?.showFailedMigrationAlert() + migrationsCompletion: { [weak self] error, needsConfigSync in + guard error == nil else { + self?.showFailedMigrationAlert(error: error) return } @@ -225,43 +225,58 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD } } - private func showFailedMigrationAlert() { + private func showFailedMigrationAlert(error: Error?) { let alert = UIAlertController( title: "Session", - message: "DATABASE_MIGRATION_FAILED".localized(), + message: ((error as? StorageError) == StorageError.devRemigrationRequired ? + "The database has changed since the last version and you need to re-migrate (this will close the app and migrate on the next launch)" : + "DATABASE_MIGRATION_FAILED".localized() + ), preferredStyle: .alert ) - alert.addAction(UIAlertAction(title: "modal_share_logs_title".localized(), style: .default) { _ in - ShareLogsModal.shareLogs(from: alert) { [weak self] in - self?.showFailedMigrationAlert() - } - }) - alert.addAction(UIAlertAction(title: "vc_restore_title".localized(), style: .destructive) { _ in - // Remove the legacy database and any message hashes that have been migrated to the new DB - try? SUKLegacy.deleteLegacyDatabaseFilesAndKey() - - Storage.shared.write { db in - try SnodeReceivedMessageInfo.deleteAll(db) - } - - // The re-run the migration (should succeed since there is no data) - AppSetup.runPostSetupMigrations( - migrationProgressChanged: { [weak self] progress, minEstimatedTotalTime in - self?.loadingViewController?.updateProgress( - progress: progress, - minEstimatedTotalTime: minEstimatedTotalTime - ) - }, - migrationsCompletion: { [weak self] successful, needsConfigSync in - guard successful else { - self?.showFailedMigrationAlert() - return + + switch (error as? StorageError) { + case .devRemigrationRequired: + alert.addAction(UIAlertAction(title: "Re-Migrate Database", style: .default) { _ in + Storage.deleteDatabaseFiles() + try? Storage.deleteDbKeys() + exit(1) + }) + + default: + alert.addAction(UIAlertAction(title: "modal_share_logs_title".localized(), style: .default) { _ in + ShareLogsModal.shareLogs(from: alert) { [weak self] in + self?.showFailedMigrationAlert(error: error) + } + }) + alert.addAction(UIAlertAction(title: "vc_restore_title".localized(), style: .destructive) { _ in + // Remove the legacy database and any message hashes that have been migrated to the new DB + try? SUKLegacy.deleteLegacyDatabaseFilesAndKey() + + Storage.shared.write { db in + try SnodeReceivedMessageInfo.deleteAll(db) } - self?.completePostMigrationSetup(needsConfigSync: needsConfigSync) - } - ) - }) + // The re-run the migration (should succeed since there is no data) + AppSetup.runPostSetupMigrations( + migrationProgressChanged: { [weak self] progress, minEstimatedTotalTime in + self?.loadingViewController?.updateProgress( + progress: progress, + minEstimatedTotalTime: minEstimatedTotalTime + ) + }, + migrationsCompletion: { [weak self] error, needsConfigSync in + guard error == nil else { + self?.showFailedMigrationAlert(error: error) + return + } + + self?.completePostMigrationSetup(needsConfigSync: needsConfigSync) + } + ) + }) + } + alert.addAction(UIAlertAction(title: "Close", style: .default) { _ in DDLog.flushLog() exit(0) diff --git a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift index 1184a4a4b..0e18775b6 100644 --- a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -123,7 +123,9 @@ enum _001_InitialSetupMigration: Migration { t.column(.threadId, .text) .notNull() .primaryKey() - t.column(.server, .text).notNull() + t.column(.server, .text) + .indexed() // Quicker querying + .notNull() t.column(.roomToken, .text).notNull() t.column(.publicKey, .text).notNull() t.column(.isActive, .boolean) @@ -328,10 +330,12 @@ enum _001_InitialSetupMigration: Migration { .references(Interaction.self, onDelete: .cascade) // Delete if interaction deleted t.column(.authorId, .text) .notNull() + .indexed() // Quicker querying .references(Profile.self) t.column(.timestampMs, .double).notNull() t.column(.body, .text) t.column(.attachmentId, .text) + .indexed() // Quicker querying .references(Attachment.self, onDelete: .setNull) // Clear if attachment deleted } @@ -345,6 +349,7 @@ enum _001_InitialSetupMigration: Migration { t.column(.variant, .integer).notNull() t.column(.title, .text) t.column(.attachmentId, .text) + .indexed() // Quicker querying .references(Attachment.self) // Managed via garbage collection t.primaryKey([.url, .timestamp]) diff --git a/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift index eab037d68..30485c730 100644 --- a/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -21,19 +21,19 @@ enum _002_SetupStandardJobs: Migration { _ = try Job( variant: .disappearingMessages, behaviour: .recurringOnLaunch, - shouldBlockFirstRunEachSession: true + shouldBlock: true ).inserted(db) _ = try Job( variant: .failedMessageSends, behaviour: .recurringOnLaunch, - shouldBlockFirstRunEachSession: true + shouldBlock: true ).inserted(db) _ = try Job( variant: .failedAttachmentDownloads, behaviour: .recurringOnLaunch, - shouldBlockFirstRunEachSession: true + shouldBlock: true ).inserted(db) _ = try Job( diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 0a70ebeb9..9a4ebf7de 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -88,7 +88,10 @@ enum _003_YDBToGRDBMigration: Migration { transaction.enumerateRows(inCollection: SMKLegacy.contactCollection) { _, object, _, _ in guard let contact = object as? SMKLegacy._Contact else { return } + contacts.insert(contact) + + /// Store a record of the all valid profiles (so we can create dummy entries if we need to for closed group members) validProfileIds.insert(contact.sessionID) } @@ -628,12 +631,28 @@ enum _003_YDBToGRDBMigration: Migration { // Create the 'GroupMember' models for the group (even if the current user is no longer // a member as these objects are used to generate the group avatar icon) + func createDummyProfile(profileId: String) { + SNLog("[Migration Warning] Closed group member with unknown user found - Creating empty profile") + + // Note: Need to upsert here because it's possible multiple quotes + // will use the same invalid 'authorId' value resulting in a unique + // constraint violation + try? Profile( + id: profileId, + name: profileId + ).save(db) + } + try groupModel.groupMemberIds.forEach { memberId in try GroupMember( groupId: threadId, profileId: memberId, role: .standard ).insert(db) + + if !validProfileIds.contains(memberId) { + createDummyProfile(profileId: memberId) + } } try groupModel.groupAdminIds.forEach { adminId in @@ -642,6 +661,10 @@ enum _003_YDBToGRDBMigration: Migration { profileId: adminId, role: .admin ).insert(db) + + if !validProfileIds.contains(adminId) { + createDummyProfile(profileId: adminId) + } } try (closedGroupZombieMemberIds[legacyThread.uniqueId] ?? []).forEach { zombieId in @@ -650,6 +673,10 @@ enum _003_YDBToGRDBMigration: Migration { profileId: zombieId, role: .zombie ).insert(db) + + if !validProfileIds.contains(zombieId) { + createDummyProfile(profileId: zombieId) + } } } diff --git a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift index 43ce8b5ea..f0b6ba060 100644 --- a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift +++ b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift @@ -234,6 +234,41 @@ public enum GarbageCollectionJob: JobExecutor { ) """) } + + if typesToCollect.contains(.orphanedProfiles) { + let profile: TypedTableAlias = TypedTableAlias() + let thread: TypedTableAlias = TypedTableAlias() + let interaction: TypedTableAlias = TypedTableAlias() + let quote: TypedTableAlias = TypedTableAlias() + let groupMember: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() + let blindedIdLookup: TypedTableAlias = TypedTableAlias() + + try db.execute(literal: """ + DELETE FROM \(Profile.self) + WHERE \(Column.rowID) IN ( + SELECT \(profile.alias[Column.rowID]) + FROM \(Profile.self) + LEFT JOIN \(SessionThread.self) ON \(thread[.id]) = \(profile[.id]) + LEFT JOIN \(Interaction.self) ON \(interaction[.authorId]) = \(profile[.id]) + LEFT JOIN \(Quote.self) ON \(quote[.authorId]) = \(profile[.id]) + LEFT JOIN \(GroupMember.self) ON \(groupMember[.profileId]) = \(profile[.id]) + LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(profile[.id]) + LEFT JOIN \(BlindedIdLookup.self) ON ( + blindedIdLookup.blindedId = \(profile[.id]) OR + blindedIdLookup.sessionId = \(profile[.id]) + ) + WHERE ( + \(thread[.id]) IS NULL AND + \(interaction[.authorId]) IS NULL AND + \(quote[.authorId]) IS NULL AND + \(groupMember[.profileId]) IS NULL AND + \(contact[.id]) IS NULL AND + \(blindedIdLookup[.blindedId]) IS NULL + ) + ) + """) + } }, completion: { _, _ in // Dispatch async so we can swap from the write queue to a read one (we are done writing) @@ -353,6 +388,9 @@ public enum GarbageCollectionJob: JobExecutor { return } + // Update the 'lastGarbageCollection' date to prevent this job from running again + // for the next 23 hours + UserDefaults.standard[.lastGarbageCollection] = Date() success(job, false) } } @@ -373,6 +411,7 @@ extension GarbageCollectionJob { case orphanedOpenGroupCapabilities case orphanedBlindedIdLookups case approvedBlindedContactRecords + case orphanedProfiles case orphanedAttachments case orphanedAttachmentFiles case orphanedProfileAvatars diff --git a/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift index 7284c4af4..89a825f56 100644 --- a/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -14,10 +14,18 @@ enum _002_SetupStandardJobs: Migration { static func migrate(_ db: Database) throws { try autoreleasepool { + _ = try Job( + variant: .getSnodePool, + behaviour: .recurringOnLaunch, + shouldBlock: true + ).inserted(db) + + // 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 Job( variant: .getSnodePool, behaviour: .recurringOnActive, - shouldBlockFirstRunEachSession: true + shouldSkipLaunchBecomeActive: true ).inserted(db) } diff --git a/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift index 1e93b41ab..797e7c7a4 100644 --- a/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -31,10 +31,13 @@ enum _001_InitialSetupMigration: Migration { t.column(.behaviour, .integer) .notNull() .indexed() // Quicker querying - t.column(.shouldBlockFirstRunEachSession, .boolean) + t.column(.shouldBlock, .boolean) .notNull() .indexed() // Quicker querying .defaults(to: false) + t.column(.shouldSkipLaunchBecomeActive, .boolean) + .notNull() + .defaults(to: false) t.column(.nextRunTimestamp, .double) .notNull() .indexed() // Quicker querying diff --git a/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift index 7e7fb370d..ea056aa28 100644 --- a/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -25,7 +25,8 @@ enum _002_SetupStandardJobs: Migration { // in 'onActive' (see the `SyncPushTokensJob` for more info) _ = try Job( variant: .syncPushTokens, - behaviour: .recurringOnActive + behaviour: .recurringOnActive, + shouldSkipLaunchBecomeActive: true ).inserted(db) } diff --git a/SessionUtilitiesKit/Database/Models/Job.swift b/SessionUtilitiesKit/Database/Models/Job.swift index 147d87fd1..471df30c1 100644 --- a/SessionUtilitiesKit/Database/Models/Job.swift +++ b/SessionUtilitiesKit/Database/Models/Job.swift @@ -31,7 +31,8 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer case failureCount case variant case behaviour - case shouldBlockFirstRunEachSession + case shouldBlock + case shouldSkipLaunchBecomeActive case nextRunTimestamp case threadId case interactionId @@ -136,12 +137,16 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer /// How the job should behave public let behaviour: Behaviour - /// When the app starts or returns from the background this flag controls whether the job should prevent other - /// jobs from starting until after it completes + /// When the app starts this flag controls whether the job should prevent other jobs from starting until after it completes /// - /// **Note:** `OnLaunch` blocking jobs will be started on launch and all others will be triggered when becoming - /// active but the "blocking" behaviour will only occur if there are no other jobs already running - public let shouldBlockFirstRunEachSession: Bool + /// **Note:** This flag is only supported for jobs with an `OnLaunch` behaviour because there is no way to guarantee + /// jobs with any other behaviours will be added to the JobRunner before all the `OnLaunch` blocking jobs are completed + /// resulting in the JobRunner no longer blocking + public let shouldBlock: Bool + + /// When the app starts it also triggers any `OnActive` jobs, this flag controls whether the job should skip this initial `OnActive` + /// trigger (generally used for the same job registered with both `OnLaunch` and `OnActive` behaviours) + public let shouldSkipLaunchBecomeActive: Bool /// Seconds since epoch to indicate the next datetime that this job should run public let nextRunTimestamp: TimeInterval @@ -184,17 +189,25 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer failureCount: UInt, variant: Variant, behaviour: Behaviour, - shouldBlockFirstRunEachSession: Bool, + shouldBlock: Bool, + shouldSkipLaunchBecomeActive: Bool, nextRunTimestamp: TimeInterval, threadId: String?, interactionId: Int64?, details: Data? ) { + Job.ensureValidBehaviour( + behaviour: behaviour, + shouldBlock: shouldBlock, + shouldSkipLaunchBecomeActive: shouldSkipLaunchBecomeActive + ) + self.id = id self.failureCount = failureCount self.variant = variant self.behaviour = behaviour - self.shouldBlockFirstRunEachSession = shouldBlockFirstRunEachSession + self.shouldBlock = shouldBlock + self.shouldSkipLaunchBecomeActive = shouldSkipLaunchBecomeActive self.nextRunTimestamp = nextRunTimestamp self.threadId = threadId self.interactionId = interactionId @@ -205,15 +218,23 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer failureCount: UInt = 0, variant: Variant, behaviour: Behaviour = .runOnce, - shouldBlockFirstRunEachSession: Bool = false, + shouldBlock: Bool = false, + shouldSkipLaunchBecomeActive: Bool = false, nextRunTimestamp: TimeInterval = 0, threadId: String? = nil, interactionId: Int64? = nil ) { + Job.ensureValidBehaviour( + behaviour: behaviour, + shouldBlock: shouldBlock, + shouldSkipLaunchBecomeActive: shouldSkipLaunchBecomeActive + ) + self.failureCount = failureCount self.variant = variant self.behaviour = behaviour - self.shouldBlockFirstRunEachSession = shouldBlockFirstRunEachSession + self.shouldBlock = shouldBlock + self.shouldSkipLaunchBecomeActive = shouldSkipLaunchBecomeActive self.nextRunTimestamp = nextRunTimestamp self.threadId = threadId self.interactionId = interactionId @@ -224,13 +245,19 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer failureCount: UInt = 0, variant: Variant, behaviour: Behaviour = .runOnce, - shouldBlockFirstRunEachSession: Bool = false, + shouldBlock: Bool = false, + shouldSkipLaunchBecomeActive: Bool = false, nextRunTimestamp: TimeInterval = 0, threadId: String? = nil, interactionId: Int64? = nil, details: T? ) { precondition(T.self != Job.self, "[Job] Fatal error trying to create a Job with a Job as it's details") + Job.ensureValidBehaviour( + behaviour: behaviour, + shouldBlock: shouldBlock, + shouldSkipLaunchBecomeActive: shouldSkipLaunchBecomeActive + ) guard let details: T = details, @@ -240,13 +267,31 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer self.failureCount = failureCount self.variant = variant self.behaviour = behaviour - self.shouldBlockFirstRunEachSession = shouldBlockFirstRunEachSession + self.shouldBlock = shouldBlock + self.shouldSkipLaunchBecomeActive = shouldSkipLaunchBecomeActive self.nextRunTimestamp = nextRunTimestamp self.threadId = threadId self.interactionId = interactionId self.details = detailsData } + fileprivate static func ensureValidBehaviour( + behaviour: Behaviour, + shouldBlock: Bool, + shouldSkipLaunchBecomeActive: Bool + ) { + // Blocking jobs can only run on launch as we can't guarantee that any other behaviours will get added + // to the JobRunner before any prior blocking jobs have completed (resulting in them being non-blocking) + precondition( + !shouldBlock || behaviour == .recurringOnLaunch || behaviour == .runOnceNextLaunch, + "[Job] Fatal error trying to create a blocking job which doesn't run on launch" + ) + precondition( + !shouldSkipLaunchBecomeActive || behaviour == .recurringOnActive, + "[Job] Fatal error trying to create a job which skips on 'OnActive' triggered during launch with doesn't run on active" + ) + } + // MARK: - Custom Database Interaction public mutating func didInsert(with rowID: Int64, for column: String?) { @@ -306,7 +351,8 @@ public extension Job { failureCount: failureCount, variant: self.variant, behaviour: self.behaviour, - shouldBlockFirstRunEachSession: self.shouldBlockFirstRunEachSession, + shouldBlock: self.shouldBlock, + shouldSkipLaunchBecomeActive: self.shouldSkipLaunchBecomeActive, nextRunTimestamp: nextRunTimestamp, threadId: self.threadId, interactionId: self.interactionId, @@ -322,7 +368,8 @@ public extension Job { failureCount: self.failureCount, variant: self.variant, behaviour: self.behaviour, - shouldBlockFirstRunEachSession: self.shouldBlockFirstRunEachSession, + shouldBlock: self.shouldBlock, + shouldSkipLaunchBecomeActive: self.shouldSkipLaunchBecomeActive, nextRunTimestamp: self.nextRunTimestamp, threadId: self.threadId, interactionId: self.interactionId, diff --git a/SessionUtilitiesKit/Database/Storage.swift b/SessionUtilitiesKit/Database/Storage.swift index 563ce2ed0..8dc87d8cc 100644 --- a/SessionUtilitiesKit/Database/Storage.swift +++ b/SessionUtilitiesKit/Database/Storage.swift @@ -100,7 +100,7 @@ public final class Storage { migrations: [TargetMigrations], async: Bool = true, onProgressUpdate: ((CGFloat, TimeInterval) -> ())?, - onComplete: @escaping (Bool, Bool) -> () + onComplete: @escaping (Error?, Bool) -> () ) { guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return } @@ -176,7 +176,7 @@ public final class Storage { } // Store the logic to run when the migration completes - let migrationCompleted: (Error?) -> () = { [weak self] error in + let migrationCompleted: (Database, Error?) -> () = { [weak self] db, error in self?.hasCompletedMigrations = true self?.migrationProgressUpdater = nil SUKLegacy.clearLegacyDatabaseInstance() @@ -186,18 +186,27 @@ public final class Storage { SNLog("[Migration Error] Migration failed with error: \(error)") } - onComplete((error == nil), needsConfigSync) + // TODO: Remove this once everyone has updated + var finalError: Error? = error + let jobTableInfo: [Row] = (try? Row.fetchAll(db, sql: "PRAGMA table_info(\(Job.databaseTableName))")) + .defaulting(to: []) + if !jobTableInfo.contains(where: { $0["name"] == "shouldSkipLaunchBecomeActive" }) { + finalError = StorageError.devRemigrationRequired + } + // TODO: Remove this once everyone has updated + + onComplete(finalError, needsConfigSync) } // Note: The non-async migration should only be used for unit tests guard async else { do { try self.migrator?.migrate(dbWriter) } - catch { migrationCompleted(error) } + catch { try? dbWriter.read { db in migrationCompleted(db, error) } } return } - self.migrator?.asyncMigrate(dbWriter) { _, error in - migrationCompleted(error) + self.migrator?.asyncMigrate(dbWriter) { db, error in + migrationCompleted(db, error) } } diff --git a/SessionUtilitiesKit/Database/StorageError.swift b/SessionUtilitiesKit/Database/StorageError.swift index 04ad00a98..0112fddb1 100644 --- a/SessionUtilitiesKit/Database/StorageError.swift +++ b/SessionUtilitiesKit/Database/StorageError.swift @@ -14,4 +14,6 @@ public enum StorageError: Error { case objectNotSaved case invalidSearchPattern + + case devRemigrationRequired } diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index c05a732aa..96278ad7b 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -102,6 +102,7 @@ public final class JobRunner { internal static var executorMap: Atomic<[Job.Variant: JobExecutor.Type]> = Atomic([:]) fileprivate static var perSessionJobsCompleted: Atomic> = Atomic([]) + private static var hasCompletedInitialBecomeActive: Atomic = Atomic(false) // MARK: - Configuration @@ -184,7 +185,7 @@ public final class JobRunner { Job.Behaviour.runOnceNextLaunch ].contains(Job.Columns.behaviour) ) - .filter(Job.Columns.shouldBlockFirstRunEachSession == true) + .filter(Job.Columns.shouldBlock == true) .order(Job.Columns.id) .fetchAll(db) let nonblockingJobs: [Job] = try Job @@ -194,7 +195,7 @@ public final class JobRunner { Job.Behaviour.runOnceNextLaunch ].contains(Job.Columns.behaviour) ) - .filter(Job.Columns.shouldBlockFirstRunEachSession == false) + .filter(Job.Columns.shouldBlock == false) .order(Job.Columns.id) .fetchAll(db) @@ -218,65 +219,38 @@ public final class JobRunner { } public static func appDidBecomeActive() { - // Note: When becoming active we want to start all non-on-launch blocking jobs as - // long as there are no other jobs already running - let alreadyRunningOtherJobs: Bool = queues.wrappedValue - .contains(where: { _, queue -> Bool in queue.isRunning.wrappedValue }) - let jobsToRun: (blocking: [Job], nonBlocking: [Job]) = Storage.shared + let hasCompletedInitialBecomeActive: Bool = JobRunner.hasCompletedInitialBecomeActive.wrappedValue + let jobsToRun: [Job] = Storage.shared .read { db in - guard !alreadyRunningOtherJobs else { - let onActiveJobs: [Job] = try Job - .filter(Job.Columns.behaviour == Job.Behaviour.recurringOnActive) - .order(Job.Columns.id) - .fetchAll(db) - - return ([], onActiveJobs) - } - - let blockingJobs: [Job] = try Job - .filter( - Job.Behaviour.allCases - .filter { - $0 != .recurringOnLaunch && - $0 != .runOnceNextLaunch - } - .contains(Job.Columns.behaviour) - ) - .filter(Job.Columns.shouldBlockFirstRunEachSession == true) - .order(Job.Columns.id) - .fetchAll(db) - let nonBlockingJobs: [Job] = try Job + return try Job .filter(Job.Columns.behaviour == Job.Behaviour.recurringOnActive) - .filter(Job.Columns.shouldBlockFirstRunEachSession == false) .order(Job.Columns.id) .fetchAll(db) - - return (blockingJobs, nonBlockingJobs) } - .defaulting(to: ([], [])) + .defaulting(to: []) + .filter { hasCompletedInitialBecomeActive || !$0.shouldSkipLaunchBecomeActive } // Store the current queue state locally to avoid multiple atomic retrievals let jobQueues: [Job.Variant: JobQueue] = queues.wrappedValue let blockingQueueIsRunning: Bool = (blockingQueue.wrappedValue?.isRunning.wrappedValue == true) - guard !jobsToRun.blocking.isEmpty || !jobsToRun.nonBlocking.isEmpty else { + guard !jobsToRun.isEmpty else { if !blockingQueueIsRunning { jobQueues.forEach { _, queue in queue.start() } } return } - // Add and start any blocking jobs - blockingQueue.wrappedValue?.appDidFinishLaunching(with: jobsToRun.blocking, canStart: true) // Add and start any non-blocking jobs (if there are no blocking jobs) - let jobsByVariant: [Job.Variant: [Job]] = jobsToRun.nonBlocking.grouped(by: \.variant) + let jobsByVariant: [Job.Variant: [Job]] = jobsToRun.grouped(by: \.variant) jobQueues.forEach { variant, queue in queue.appDidBecomeActive( with: (jobsByVariant[variant] ?? []), - canStart: (!blockingQueueIsRunning && jobsToRun.blocking.isEmpty) + canStart: !blockingQueueIsRunning ) } + JobRunner.hasCompletedInitialBecomeActive.mutate { $0 = true } } public static func isCurrentlyRunning(_ job: Job?) -> Bool { @@ -849,7 +823,7 @@ private final class JobQueue { } // If this is the blocking queue and a "blocking" job failed then rerun it immediately - if self.type == .blocking && job.shouldBlockFirstRunEachSession { + if self.type == .blocking && job.shouldBlock { SNLog("[JobRunner] \(queueContext) \(job.variant) job failed; retrying immediately") jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) } detailsForCurrentlyRunningJobs.mutate { $0 = $0.removingValue(forKey: job.id) } diff --git a/SignalUtilitiesKit/Utilities/AppSetup.swift b/SignalUtilitiesKit/Utilities/AppSetup.swift index 7e40f357f..dfc80f644 100644 --- a/SignalUtilitiesKit/Utilities/AppSetup.swift +++ b/SignalUtilitiesKit/Utilities/AppSetup.swift @@ -11,7 +11,7 @@ public enum AppSetup { public static func setupEnvironment( appSpecificBlock: @escaping () -> (), migrationProgressChanged: ((CGFloat, TimeInterval) -> ())? = nil, - migrationsCompletion: @escaping (Bool, Bool) -> () + migrationsCompletion: @escaping (Error?, Bool) -> () ) { guard !AppSetup.hasRun else { return } @@ -60,7 +60,7 @@ public enum AppSetup { public static func runPostSetupMigrations( backgroundTask: OWSBackgroundTask? = nil, migrationProgressChanged: ((CGFloat, TimeInterval) -> ())? = nil, - migrationsCompletion: @escaping (Bool, Bool) -> () + migrationsCompletion: @escaping (Error?, Bool) -> () ) { var backgroundTask: OWSBackgroundTask? = (backgroundTask ?? OWSBackgroundTask(labelStr: #function)) @@ -71,9 +71,9 @@ public enum AppSetup { SNMessagingKit.migrations() ], onProgressUpdate: migrationProgressChanged, - onComplete: { success, needsConfigSync in + onComplete: { error, needsConfigSync in DispatchQueue.main.async { - migrationsCompletion(success, needsConfigSync) + migrationsCompletion(error, needsConfigSync) // The 'if' is only there to prevent the "variable never read" warning from showing if backgroundTask != nil { backgroundTask = nil } From 4afddd6fbb9ec685913dc9cad4ecb3dfd4d212cc Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 8 Jul 2022 17:53:48 +1000 Subject: [PATCH 130/157] Fixed a number of reported bugs, some cleanup, added animated profile support Added support for animated profile images (no ability to crop/resize) Updated the message trimming to only remove messages if the open group has 2000 messages or more Updated the message trimming setting to default to be on Updated the ContextMenu to fade out the snapshot as well (looked buggy if the device had even minor lag) Updated the ProfileManager to delete and re-download invalid avatar images (and updated the conversation screen to reload when avatars complete downloading) Updated the message request notification logic so it will show notifications when receiving a new message request as long as the user has read all the old ones (previously the user had to accept/reject all the old ones) Fixed a bug where the "trim open group messages" toggle was accessing UI off the main thread Fixed a bug where the "Chats" settings screen had a close button instead of a back button Fixed a bug where the 'viewsToMove' for the reply UI was inconsistent in some places Fixed an issue where the ProfileManager was doing all of it's validation (and writing to disk) within the database write closure which would block database writes unnecessarily Fixed a bug where a message request wouldn't be identified as such just because it wasn't visible in the conversations list Fixed a bug where opening a message request notification would result in the message request being in the wrong state (also wouldn't insert the 'MessageRequestsViewController' into the hierarchy) Fixed a bug where the avatar image wouldn't appear beside incoming closed group message in some situations cases Removed an error log that was essentially just spam Remove the logic to delete old profile images when calling save on a Profile (wouldn't get called if the row was modified directly and duplicates GarbageCollection logic) Remove the logic to send a notification when calling save on a Profile (wouldn't get called if the row was modified directly) Tweaked the message trimming description to be more accurate Cleaned up some duplicate logic used to determine if a notification should be shown Cleaned up some onion request logic (was passing the version info in some cases when not needed) Moved the push notification notify API call into the PushNotificationAPI class for consistency --- Session.xcodeproj/project.pbxproj | 4 - .../Calls/Call Management/SessionCall.swift | 1 + .../Context Menu/ContextMenuVC.swift | 1 + .../Conversations/ConversationViewModel.swift | 10 + .../Message Cells/VisibleMessageCell.swift | 24 +- .../OWSConversationSettingsViewController.m | 28 +-- Session/Home/HomeVC.swift | 15 +- Session/Meta/AppDelegate.swift | 18 +- Session/Meta/SessionApp.swift | 15 +- .../Translations/de.lproj/Localizable.strings | 2 +- .../Translations/en.lproj/Localizable.strings | 2 +- .../Translations/es.lproj/Localizable.strings | 2 +- .../Translations/fa.lproj/Localizable.strings | 2 +- .../Translations/fi.lproj/Localizable.strings | 2 +- .../Translations/fr.lproj/Localizable.strings | 2 +- .../Translations/hi.lproj/Localizable.strings | 2 +- .../Translations/hr.lproj/Localizable.strings | 2 +- .../id-ID.lproj/Localizable.strings | 2 +- .../Translations/it.lproj/Localizable.strings | 2 +- .../Translations/ja.lproj/Localizable.strings | 2 +- .../Translations/nl.lproj/Localizable.strings | 2 +- .../Translations/pl.lproj/Localizable.strings | 2 +- .../pt_BR.lproj/Localizable.strings | 2 +- .../Translations/ru.lproj/Localizable.strings | 2 +- .../Translations/si.lproj/Localizable.strings | 2 +- .../Translations/sk.lproj/Localizable.strings | 2 +- .../Translations/sv.lproj/Localizable.strings | 2 +- .../Translations/th.lproj/Localizable.strings | 2 +- .../vi-VN.lproj/Localizable.strings | 2 +- .../zh-Hant.lproj/Localizable.strings | 2 +- .../zh_CN.lproj/Localizable.strings | 2 +- Session/Notifications/AppNotifications.swift | 31 +-- Session/Onboarding/DisplayNameVC.swift | 3 +- .../Settings/ChatSettingsViewController.swift | 7 +- Session/Settings/SettingsVC.swift | 44 ++-- Session/Utilities/AvatarViewHelper.h | 2 +- Session/Utilities/AvatarViewHelper.m | 23 +- Session/Utilities/UIApplication+OWS.swift | 2 - .../Migrations/_003_YDBToGRDBMigration.swift | 3 + .../Database/Models/Attachment.swift | 5 +- .../Database/Models/Profile.swift | 25 -- .../Database/Models/SessionThread.swift | 55 ++++- .../Database/Notification+Contacts.swift | 21 -- .../Jobs/Types/GarbageCollectionJob.swift | 15 +- .../Jobs/Types/NotifyPushServerJob.swift | 51 +---- .../Jobs/Types/UpdateProfilePictureJob.swift | 6 +- .../Sending & Receiving/MessageReceiver.swift | 9 + .../Notifications/PushNotificationAPI.swift | 45 +++- .../Shared Models/MessageViewModel.swift | 3 +- .../SessionThreadViewModel.swift | 1 - .../Utilities/ProfileManager.swift | 213 +++++++++++------- .../NSENotificationPresenter.swift | 35 +-- .../NotificationServiceExtension.swift | 21 +- .../OnionRequestAPI+Encryption.swift | 35 +-- SessionSnodeKit/OnionRequestAPI.swift | 80 +++---- SessionSnodeKit/SnodeAPI.swift | 3 - SessionUtilitiesKit/Database/Storage.swift | 1 - .../Profile Pictures/ProfilePictureView.swift | 13 +- 58 files changed, 500 insertions(+), 412 deletions(-) delete mode 100644 SessionMessagingKit/Database/Notification+Contacts.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 62d1a01b1..edb5c7aae 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -261,7 +261,6 @@ B8DE1FB626C22FCB0079C9CE /* CallMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8DE1FB526C22FCB0079C9CE /* CallMessage.swift */; }; B8EB20EE2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8EB20ED2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift */; }; B8EB20F02640F7F000773E52 /* OpenGroupInvitationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8EB20EF2640F7F000773E52 /* OpenGroupInvitationView.swift */; }; - B8F5F56525EC8453003BF8D4 /* Notification+Contacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F56425EC8453003BF8D4 /* Notification+Contacts.swift */; }; B8F5F58325EC94A6003BF8D4 /* Collection+Subscripting.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F58225EC94A6003BF8D4 /* Collection+Subscripting.swift */; }; B8F5F60325EDE16F003BF8D4 /* DataExtractionNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F60225EDE16F003BF8D4 /* DataExtractionNotification.swift */; }; B8F5F71A25F1B35C003BF8D4 /* MediaPlaceholderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F71925F1B35C003BF8D4 /* MediaPlaceholderView.swift */; }; @@ -1296,7 +1295,6 @@ B8EB20E6263F7E4B00773E52 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = ""; }; B8EB20ED2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VisibleMessage+OpenGroupInvitation.swift"; sourceTree = ""; }; B8EB20EF2640F7F000773E52 /* OpenGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupInvitationView.swift; sourceTree = ""; }; - B8F5F56425EC8453003BF8D4 /* Notification+Contacts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Contacts.swift"; sourceTree = ""; }; B8F5F58225EC94A6003BF8D4 /* Collection+Subscripting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+Subscripting.swift"; sourceTree = ""; }; B8F5F60225EDE16F003BF8D4 /* DataExtractionNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataExtractionNotification.swift; sourceTree = ""; }; B8F5F71925F1B35C003BF8D4 /* MediaPlaceholderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlaceholderView.swift; sourceTree = ""; }; @@ -2630,7 +2628,6 @@ FD17D79A27F40ADA00122BE0 /* LegacyDatabase */, FD17D79427F3E03300122BE0 /* Migrations */, FD09796C27FA6C8B00936362 /* Models */, - B8F5F56425EC8453003BF8D4 /* Notification+Contacts.swift */, ); path = Database; sourceTree = ""; @@ -5176,7 +5173,6 @@ FDC438C927BB706500C60D73 /* SendDirectMessageRequest.swift in Sources */, C3A71D1F25589AC30043A11F /* WebSocketResources.pb.swift in Sources */, FDF0B74B28061F7A004C14C5 /* InteractionAttachment.swift in Sources */, - B8F5F56525EC8453003BF8D4 /* Notification+Contacts.swift in Sources */, FD09796E27FA6D0000936362 /* Contact.swift in Sources */, C38D5E8D2575011E00B6A65C /* MessageSender+ClosedGroups.swift in Sources */, FD5C72F7284F0E560029977D /* MessageReceiver+ReadReceipts.swift in Sources */, diff --git a/Session/Calls/Call Management/SessionCall.swift b/Session/Calls/Call Management/SessionCall.swift index 41998656d..030860de8 100644 --- a/Session/Calls/Call Management/SessionCall.swift +++ b/Session/Calls/Call Management/SessionCall.swift @@ -152,6 +152,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { self.contactName = Profile.displayName(db, id: sessionId, threadVariant: .contact) self.profilePicture = ProfileManager.profileAvatar(db, id: sessionId) + .map { UIImage(data: $0) } .defaulting(to: Identicon.generatePlaceholderIcon(seed: sessionId, text: self.contactName, size: 300)) WebRTCSession.current = self.webRTCSession diff --git a/Session/Conversations/Context Menu/ContextMenuVC.swift b/Session/Conversations/Context Menu/ContextMenuVC.swift index d99c0a1cb..d7d9ed68a 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC.swift @@ -174,6 +174,7 @@ final class ContextMenuVC: UIViewController { animations: { [weak self] in self?.blurView.effect = nil self?.menuView.alpha = 0 + self?.snapshot.alpha = 0 self?.timestampLabel.alpha = 0 }, completion: { [weak self] _ in diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 00d5d45ee..4ab71302e 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -185,6 +185,16 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { return SQL("LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId])") }() + ), + PagedData.ObservedChanges( + table: Profile.self, + columns: [.profilePictureFileName], + joinToPagedType: { + let interaction: TypedTableAlias = TypedTableAlias() + let profile: TypedTableAlias = TypedTableAlias() + + return SQL("LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId])") + }() ) ], filterSQL: MessageViewModel.filterSQL(threadId: threadId), diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index cc57d5282..248ee419a 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -38,6 +38,15 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel // MARK: - UI Components + private lazy var viewsToMoveForReply: [UIView] = [ + bubbleView, + bubbleBackgroundView, + profilePictureView, + replyButton, + timerView, + messageStatusImageView + ] + private lazy var profilePictureView: ProfilePictureView = { let result: ProfilePictureView = ProfilePictureView() result.set(.height, to: Values.verySmallProfilePictureSize) @@ -619,8 +628,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel super.prepareForReuse() unloadContent?() - let viewsToMove = [ bubbleView, profilePictureView, replyButton, timerView, messageStatusImageView ] - viewsToMove.forEach { $0.transform = .identity } + viewsToMoveForReply.forEach { $0.transform = .identity } replyButton.alpha = 0 timerView.prepareForReuse() } @@ -726,9 +734,6 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel @objc private func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) { guard let cellViewModel: MessageViewModel = self.viewModel else { return } - let viewsToMove: [UIView] = [ - bubbleView, bubbleBackgroundView, profilePictureView, replyButton, timerView, messageStatusImageView - ] let translationX = gestureRecognizer.translation(in: self).x.clamp(-CGFloat.greatestFiniteMagnitude, 0) switch gestureRecognizer.state { @@ -739,7 +744,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel let damping: CGFloat = 20 let sign: CGFloat = -1 let x = (damping * (sqrt(abs(translationX)) / sqrt(damping))) * sign - viewsToMove.forEach { $0.transform = CGAffineTransform(translationX: x, y: 0) } + viewsToMoveForReply.forEach { $0.transform = CGAffineTransform(translationX: x, y: 0) } if timerView.isHidden { replyButton.alpha = abs(translationX) / VisibleMessageCell.maxBubbleTranslationX } else { @@ -778,10 +783,9 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel } private func resetReply() { - let viewsToMove = [ bubbleView, profilePictureView, replyButton, timerView, messageStatusImageView ] - UIView.animate(withDuration: 0.25) { - viewsToMove.forEach { $0.transform = .identity } - self.replyButton.alpha = 0 + UIView.animate(withDuration: 0.25) { [weak self] in + self?.viewsToMoveForReply.forEach { $0.transform = .identity } + self?.replyButton.alpha = 0 } } diff --git a/Session/Conversations/Settings/OWSConversationSettingsViewController.m b/Session/Conversations/Settings/OWSConversationSettingsViewController.m index 96bc904aa..8eadd24dd 100644 --- a/Session/Conversations/Settings/OWSConversationSettingsViewController.m +++ b/Session/Conversations/Settings/OWSConversationSettingsViewController.m @@ -53,8 +53,6 @@ CGFloat kIconViewLength = 24; return self; } - [self commonInit]; - return self; } @@ -65,8 +63,6 @@ CGFloat kIconViewLength = 24; return self; } - [self commonInit]; - return self; } @@ -77,32 +73,11 @@ CGFloat kIconViewLength = 24; return self; } - [self commonInit]; - return self; } -- (void)commonInit -{ - - [self observeNotifications]; -} - -- (void)dealloc -{ - [[NSNotificationCenter defaultCenter] removeObserver:self]; -} - #pragma mark -- (void)observeNotifications -{ - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(otherUsersProfileDidChange:) - name:NSNotification.otherUsersProfileDidChange - object:nil]; -} - - (void)configureWithThreadId:(NSString *)threadId threadName:(NSString *)threadName isClosedGroup:(BOOL)isClosedGroup isOpenGroup:(BOOL)isOpenGroup isNoteToSelf:(BOOL)isNoteToSelf { self.threadId = threadId; self.threadName = threadName; @@ -964,9 +939,10 @@ CGFloat kIconViewLength = 24; #pragma mark - Notifications +// FIXME: When this screen gets refactored, make sure to observe changes for relevant profile image updates - (void)otherUsersProfileDidChange:(NSNotification *)notification { - NSString *recipientId = notification.userInfo[NSNotification.profileRecipientIdKey]; + NSString *recipientId = @"";//notification.userInfo[NSNotification.profileRecipientIdKey]; OWSAssertDebug(recipientId.length > 0); if (recipientId.length > 0 && !self.isClosedGroup && !self.isOpenGroup && self.threadId == recipientId) { diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index c5e6e9058..66be8ff71 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -518,6 +518,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve show( threadViewModel.threadId, variant: threadViewModel.threadVariant, + isMessageRequest: (threadViewModel.threadIsMessageRequest == true), with: .none, focusedInteractionId: nil, animated: true @@ -651,6 +652,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve func show( _ threadId: String, variant: SessionThread.Variant, + isMessageRequest: Bool, with action: ConversationViewModel.Action, focusedInteractionId: Int64?, animated: Bool @@ -659,8 +661,17 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve presentedVC.dismiss(animated: false, completion: nil) } - let conversationVC: ConversationVC = ConversationVC(threadId: threadId, threadVariant: variant, focusedInteractionId: focusedInteractionId) - self.navigationController?.setViewControllers([ self, conversationVC ], animated: animated) + let finalViewControllers: [UIViewController] = [ + self, + (isMessageRequest ? MessageRequestsViewController() : nil), + ConversationVC( + threadId: threadId, + threadVariant: variant, + focusedInteractionId: focusedInteractionId + ) + ].compactMap { $0 } + + self.navigationController?.setViewControllers(finalViewControllers, animated: animated) } @objc private func openSettings() { diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 53f06c9f8..d58faa92f 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -142,14 +142,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD AppReadiness.runNowOrWhenAppDidBecomeReady { [weak self] in self?.handleActivation() - } - - /// Clear all notifications whenever we become active - /// - /// **Note:** It looks like when opening the app from a notification, `userNotificationCenter(didReceive)` is - /// no longer always called before we become active so we need to dispatch this to run on the next run loop - DispatchQueue.main.async { [weak self] in - self?.clearAllNotificationsAndRestoreBadgeCount() + + /// Clear all notifications whenever we become active once the app is ready + /// + /// **Note:** It looks like when opening the app from a notification, `userNotificationCenter(didReceive)` is + /// no longer always called before `applicationDidBecomeActive` we need to trigger the "clear notifications" logic + /// within the `runNowOrWhenAppDidBecomeReady` callback and dispatch to the next run loop to ensure it runs after + /// the notification has actually been handled + DispatchQueue.main.async { [weak self] in + self?.clearAllNotificationsAndRestoreBadgeCount() + } } // On every activation, clear old temp directories. diff --git a/Session/Meta/SessionApp.swift b/Session/Meta/SessionApp.swift index ceae960ce..7b88c216c 100644 --- a/Session/Meta/SessionApp.swift +++ b/Session/Meta/SessionApp.swift @@ -10,15 +10,21 @@ public struct SessionApp { // MARK: - View Convenience Methods public static func presentConversation(for threadId: String, action: ConversationViewModel.Action = .none, animated: Bool) { - let maybeThread: SessionThread? = Storage.shared.write { db in - try SessionThread.fetchOrCreate(db, id: threadId, variant: .contact) + let maybeThreadInfo: (thread: SessionThread, isMessageRequest: Bool)? = Storage.shared.write { db in + let thread: SessionThread = try SessionThread.fetchOrCreate(db, id: threadId, variant: .contact) + + return (thread, thread.isMessageRequest(db)) } - guard let variant: SessionThread.Variant = maybeThread?.variant else { return } + guard + let variant: SessionThread.Variant = maybeThreadInfo?.thread.variant, + let isMessageRequest: Bool = maybeThreadInfo?.isMessageRequest + else { return } self.presentConversation( for: threadId, threadVariant: variant, + isMessageRequest: isMessageRequest, action: action, focusInteractionId: nil, animated: animated @@ -28,6 +34,7 @@ public struct SessionApp { public static func presentConversation( for threadId: String, threadVariant: SessionThread.Variant, + isMessageRequest: Bool, action: ConversationViewModel.Action, focusInteractionId: Int64?, animated: Bool @@ -37,6 +44,7 @@ public struct SessionApp { self.presentConversation( for: threadId, threadVariant: threadVariant, + isMessageRequest: isMessageRequest, action: action, focusInteractionId: focusInteractionId, animated: animated @@ -48,6 +56,7 @@ public struct SessionApp { homeViewController.wrappedValue?.show( threadId, variant: threadVariant, + isMessageRequest: isMessageRequest, with: action, focusedInteractionId: focusInteractionId, animated: animated diff --git a/Session/Meta/Translations/de.lproj/Localizable.strings b/Session/Meta/Translations/de.lproj/Localizable.strings index 12d7713ec..764cc21f7 100644 --- a/Session/Meta/Translations/de.lproj/Localizable.strings +++ b/Session/Meta/Translations/de.lproj/Localizable.strings @@ -659,4 +659,4 @@ "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; -"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; diff --git a/Session/Meta/Translations/en.lproj/Localizable.strings b/Session/Meta/Translations/en.lproj/Localizable.strings index 57ef01ba2..bbdfe3f4e 100644 --- a/Session/Meta/Translations/en.lproj/Localizable.strings +++ b/Session/Meta/Translations/en.lproj/Localizable.strings @@ -659,5 +659,5 @@ "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; -"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; diff --git a/Session/Meta/Translations/es.lproj/Localizable.strings b/Session/Meta/Translations/es.lproj/Localizable.strings index c721a032e..6a4ca17e0 100644 --- a/Session/Meta/Translations/es.lproj/Localizable.strings +++ b/Session/Meta/Translations/es.lproj/Localizable.strings @@ -659,4 +659,4 @@ "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; -"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; diff --git a/Session/Meta/Translations/fa.lproj/Localizable.strings b/Session/Meta/Translations/fa.lproj/Localizable.strings index b15fa022f..f742bc0bc 100644 --- a/Session/Meta/Translations/fa.lproj/Localizable.strings +++ b/Session/Meta/Translations/fa.lproj/Localizable.strings @@ -659,4 +659,4 @@ "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; -"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; diff --git a/Session/Meta/Translations/fi.lproj/Localizable.strings b/Session/Meta/Translations/fi.lproj/Localizable.strings index 4c4ed311e..e8e512296 100644 --- a/Session/Meta/Translations/fi.lproj/Localizable.strings +++ b/Session/Meta/Translations/fi.lproj/Localizable.strings @@ -659,4 +659,4 @@ "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; -"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; diff --git a/Session/Meta/Translations/fr.lproj/Localizable.strings b/Session/Meta/Translations/fr.lproj/Localizable.strings index 52ca9e487..729b1a90d 100644 --- a/Session/Meta/Translations/fr.lproj/Localizable.strings +++ b/Session/Meta/Translations/fr.lproj/Localizable.strings @@ -659,4 +659,4 @@ "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; -"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; diff --git a/Session/Meta/Translations/hi.lproj/Localizable.strings b/Session/Meta/Translations/hi.lproj/Localizable.strings index 5bdba14ac..d9589c6a4 100644 --- a/Session/Meta/Translations/hi.lproj/Localizable.strings +++ b/Session/Meta/Translations/hi.lproj/Localizable.strings @@ -659,4 +659,4 @@ "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; -"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; diff --git a/Session/Meta/Translations/hr.lproj/Localizable.strings b/Session/Meta/Translations/hr.lproj/Localizable.strings index bacb148c8..2aaa1e710 100644 --- a/Session/Meta/Translations/hr.lproj/Localizable.strings +++ b/Session/Meta/Translations/hr.lproj/Localizable.strings @@ -659,4 +659,4 @@ "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; -"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; diff --git a/Session/Meta/Translations/id-ID.lproj/Localizable.strings b/Session/Meta/Translations/id-ID.lproj/Localizable.strings index b3ccf6522..227258661 100644 --- a/Session/Meta/Translations/id-ID.lproj/Localizable.strings +++ b/Session/Meta/Translations/id-ID.lproj/Localizable.strings @@ -659,4 +659,4 @@ "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; -"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; diff --git a/Session/Meta/Translations/it.lproj/Localizable.strings b/Session/Meta/Translations/it.lproj/Localizable.strings index b00a8fab3..5c0f62aec 100644 --- a/Session/Meta/Translations/it.lproj/Localizable.strings +++ b/Session/Meta/Translations/it.lproj/Localizable.strings @@ -659,4 +659,4 @@ "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; -"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; diff --git a/Session/Meta/Translations/ja.lproj/Localizable.strings b/Session/Meta/Translations/ja.lproj/Localizable.strings index 7be122fca..34f322714 100644 --- a/Session/Meta/Translations/ja.lproj/Localizable.strings +++ b/Session/Meta/Translations/ja.lproj/Localizable.strings @@ -659,4 +659,4 @@ "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; -"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; diff --git a/Session/Meta/Translations/nl.lproj/Localizable.strings b/Session/Meta/Translations/nl.lproj/Localizable.strings index ac4a696b4..af4d90a7f 100644 --- a/Session/Meta/Translations/nl.lproj/Localizable.strings +++ b/Session/Meta/Translations/nl.lproj/Localizable.strings @@ -659,4 +659,4 @@ "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; -"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; diff --git a/Session/Meta/Translations/pl.lproj/Localizable.strings b/Session/Meta/Translations/pl.lproj/Localizable.strings index e873b2e2e..6cef8f942 100644 --- a/Session/Meta/Translations/pl.lproj/Localizable.strings +++ b/Session/Meta/Translations/pl.lproj/Localizable.strings @@ -659,4 +659,4 @@ "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; -"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; diff --git a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings index 218d22cac..1ebe65929 100644 --- a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings +++ b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings @@ -659,4 +659,4 @@ "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; -"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; diff --git a/Session/Meta/Translations/ru.lproj/Localizable.strings b/Session/Meta/Translations/ru.lproj/Localizable.strings index 2f501ee7d..cf4e35c0c 100644 --- a/Session/Meta/Translations/ru.lproj/Localizable.strings +++ b/Session/Meta/Translations/ru.lproj/Localizable.strings @@ -659,4 +659,4 @@ "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; -"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; diff --git a/Session/Meta/Translations/si.lproj/Localizable.strings b/Session/Meta/Translations/si.lproj/Localizable.strings index 14696b268..826878f56 100644 --- a/Session/Meta/Translations/si.lproj/Localizable.strings +++ b/Session/Meta/Translations/si.lproj/Localizable.strings @@ -659,4 +659,4 @@ "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; -"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; diff --git a/Session/Meta/Translations/sk.lproj/Localizable.strings b/Session/Meta/Translations/sk.lproj/Localizable.strings index cdf5f5f7f..c6330a516 100644 --- a/Session/Meta/Translations/sk.lproj/Localizable.strings +++ b/Session/Meta/Translations/sk.lproj/Localizable.strings @@ -659,4 +659,4 @@ "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; -"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; diff --git a/Session/Meta/Translations/sv.lproj/Localizable.strings b/Session/Meta/Translations/sv.lproj/Localizable.strings index ccc97107f..92138867f 100644 --- a/Session/Meta/Translations/sv.lproj/Localizable.strings +++ b/Session/Meta/Translations/sv.lproj/Localizable.strings @@ -659,4 +659,4 @@ "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; -"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; diff --git a/Session/Meta/Translations/th.lproj/Localizable.strings b/Session/Meta/Translations/th.lproj/Localizable.strings index 656d65ef6..6545dea2d 100644 --- a/Session/Meta/Translations/th.lproj/Localizable.strings +++ b/Session/Meta/Translations/th.lproj/Localizable.strings @@ -659,4 +659,4 @@ "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; -"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; diff --git a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings index 2a0d7928c..58c025221 100644 --- a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings +++ b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings @@ -659,4 +659,4 @@ "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; -"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; diff --git a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings index 87e8a6c3e..f47db9db0 100644 --- a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings @@ -659,4 +659,4 @@ "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; -"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; diff --git a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings index 46cad399f..820ba3c80 100644 --- a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings @@ -659,4 +659,4 @@ "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; -"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; +"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; diff --git a/Session/Notifications/AppNotifications.swift b/Session/Notifications/AppNotifications.swift index c0eb0d4a1..49491f158 100644 --- a/Session/Notifications/AppNotifications.swift +++ b/Session/Notifications/AppNotifications.swift @@ -142,31 +142,11 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { } public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, isBackgroundPoll: Bool) { - guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return } - - let userPublicKey: String = getUserHexEncodedPublicKey(db) let isMessageRequest: Bool = thread.isMessageRequest(db, includeNonVisible: true) - // If the thread is a message request and the user hasn't hidden message requests then we need - // to check if this is the only message request thread (group threads can't be message requests - // so just ignore those and if the user has hidden message requests then we want to show the - // notification regardless of how many message requests there are) - if thread.variant == .contact { - if isMessageRequest && !db[.hasHiddenMessageRequests] { - let numMessageRequestThreads: Int = (try? SessionThread - .messageRequestsQuery(userPublicKey: userPublicKey, includeNonVisible: true) - .fetchCount(db)) - .defaulting(to: 0) - - // Allow this to show a notification if there are no message requests (ie. this is the first one) - guard numMessageRequestThreads == 0 else { return } - } - else if isMessageRequest && db[.hasHiddenMessageRequests] { - // If there are other interactions on this thread already then don't show the notification - if ((try? thread.interactions.fetchCount(db)) ?? 0) > 1 { return } - - db[.hasHiddenMessageRequests] = false - } + // Ensure we should be showing a notification for the thread + guard thread.shouldShowNotification(db, for: interaction, isMessageRequest: isMessageRequest) else { + return } let identifier: String = interaction.notificationIdentifier(isBackgroundPoll: isBackgroundPoll) @@ -180,11 +160,6 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { // see https://developer.apple.com/documentation/uikit/uilocalnotification/1616646-alertbody // for more details. let messageText: String? = String.filterNotificationText(rawMessageText) - - // Don't fire the notification if the current user isn't mentioned - // and isOnlyNotifyingForMentions is on. - guard !thread.onlyNotifyForMentions || interaction.hasMention else { return } - let notificationTitle: String? var notificationBody: String? diff --git a/Session/Onboarding/DisplayNameVC.swift b/Session/Onboarding/DisplayNameVC.swift index d23d6b440..d5ff17982 100644 --- a/Session/Onboarding/DisplayNameVC.swift +++ b/Session/Onboarding/DisplayNameVC.swift @@ -170,7 +170,8 @@ final class DisplayNameVC: BaseVC { ProfileManager.updateLocal( queue: DispatchQueue.global(qos: .default), profileName: displayName, - avatarImage: nil, + image: nil, + imageFilePath: nil, requiredSync: false ) let pnModeVC = PNModeVC() diff --git a/Session/Settings/ChatSettingsViewController.swift b/Session/Settings/ChatSettingsViewController.swift index eba267dca..9ff0c80e3 100644 --- a/Session/Settings/ChatSettingsViewController.swift +++ b/Session/Settings/ChatSettingsViewController.swift @@ -14,9 +14,6 @@ class ChatSettingsViewController: OWSTableViewController { self.updateTableContents() ViewControllerUtilities.setUpDefaultSessionStyle(for: self, title: "CHATS_TITLE".localized(), hasCustomBackButton: false) - - let closeButton: UIBarButtonItem = UIBarButtonItem(image: UIImage(named: "X"), style: .plain, target: self, action: #selector(close(_:))) - self.navigationItem.leftBarButtonItem = closeButton } override func viewDidAppear(_ animated: Bool) { @@ -47,9 +44,11 @@ class ChatSettingsViewController: OWSTableViewController { // MARK: - Actions @objc private func didToggleTrimOpenGroupsSwitch(_ sender: UISwitch) { + let switchIsOn: Bool = sender.isOn + Storage.shared.writeAsync( updates: { db in - db[.trimOpenGroupMessagesOlderThanSixMonths] = !sender.isOn + db[.trimOpenGroupMessagesOlderThanSixMonths] = !switchIsOn }, completion: { [weak self] _, _ in self?.updateTableContents() diff --git a/Session/Settings/SettingsVC.swift b/Session/Settings/SettingsVC.swift index 1465cc56f..a58081392 100644 --- a/Session/Settings/SettingsVC.swift +++ b/Session/Settings/SettingsVC.swift @@ -7,7 +7,6 @@ import SessionMessagingKit import SignalUtilitiesKit final class SettingsVC: BaseVC, AvatarViewHelperDelegate { - private var profilePictureToBeUploaded: UIImage? private var displayNameToBeUploaded: String? private var isEditingDisplayName = false { didSet { handleIsEditingDisplayNameChanged() } } @@ -419,34 +418,47 @@ final class SettingsVC: BaseVC, AvatarViewHelperDelegate { } } - func avatarDidChange(_ image: UIImage) { - let maxSize = Int(ProfileManager.maxAvatarDiameter) - profilePictureToBeUploaded = image.resizedImage(toFillPixelSize: CGSize(width: maxSize, height: maxSize)) - updateProfile(isUpdatingDisplayName: false, isUpdatingProfilePicture: true) + func avatarDidChange(_ image: UIImage?, filePath: String?) { + updateProfile( + profilePicture: image, + profilePictureFilePath: filePath, + isUpdatingDisplayName: false, + isUpdatingProfilePicture: true + ) } func clearAvatar() { - profilePictureToBeUploaded = nil - updateProfile(isUpdatingDisplayName: false, isUpdatingProfilePicture: true) + updateProfile( + profilePicture: nil, + profilePictureFilePath: nil, + isUpdatingDisplayName: false, + isUpdatingProfilePicture: true + ) } - private func updateProfile(isUpdatingDisplayName: Bool, isUpdatingProfilePicture: Bool) { + private func updateProfile( + profilePicture: UIImage?, + profilePictureFilePath: String?, + isUpdatingDisplayName: Bool, + isUpdatingProfilePicture: Bool + ) { let userDefaults = UserDefaults.standard let name: String? = (displayNameToBeUploaded ?? Profile.fetchOrCreateCurrentUser().name) - let profilePicture: UIImage? = (profilePictureToBeUploaded ?? ProfileManager.profileAvatar(id: getUserHexEncodedPublicKey())) + let imageFilePath: String? = (profilePictureFilePath ?? ProfileManager.profileAvatarFilepath(id: getUserHexEncodedPublicKey())) - ModalActivityIndicatorViewController.present(fromViewController: navigationController!, canCancel: false) { [weak self, displayNameToBeUploaded, profilePictureToBeUploaded] modalActivityIndicator in + ModalActivityIndicatorViewController.present(fromViewController: navigationController!, canCancel: false) { [weak self, displayNameToBeUploaded] modalActivityIndicator in ProfileManager.updateLocal( queue: DispatchQueue.global(qos: .default), profileName: (name ?? ""), - avatarImage: profilePicture, + image: profilePicture, + imageFilePath: imageFilePath, requiredSync: true, success: { db, updatedProfile in if displayNameToBeUploaded != nil { userDefaults[.lastDisplayNameUpdate] = Date() } - if profilePictureToBeUploaded != nil { + if isUpdatingProfilePicture { userDefaults[.lastProfilePictureUpdate] = Date() } @@ -462,7 +474,6 @@ final class SettingsVC: BaseVC, AvatarViewHelperDelegate { threadVariant: .contact ) self?.displayNameLabel.text = name - self?.profilePictureToBeUploaded = nil self?.displayNameToBeUploaded = nil } } @@ -556,7 +567,12 @@ final class SettingsVC: BaseVC, AvatarViewHelperDelegate { } isEditingDisplayName = false displayNameToBeUploaded = displayName - updateProfile(isUpdatingDisplayName: true, isUpdatingProfilePicture: false) + updateProfile( + profilePicture: nil, + profilePictureFilePath: nil, + isUpdatingDisplayName: true, + isUpdatingProfilePicture: false + ) } @objc private func showEditProfilePictureUI() { diff --git a/Session/Utilities/AvatarViewHelper.h b/Session/Utilities/AvatarViewHelper.h index e6a0e8e06..bf9ba6832 100644 --- a/Session/Utilities/AvatarViewHelper.h +++ b/Session/Utilities/AvatarViewHelper.h @@ -13,7 +13,7 @@ NS_ASSUME_NONNULL_BEGIN - (nullable NSString *)avatarActionSheetTitle; -- (void)avatarDidChange:(UIImage *)image; +- (void)avatarDidChange:(nullable UIImage *)image filePath:(nullable NSString *)filePath; - (UIViewController *)fromViewController; diff --git a/Session/Utilities/AvatarViewHelper.m b/Session/Utilities/AvatarViewHelper.m index db7d36938..12e11c82f 100644 --- a/Session/Utilities/AvatarViewHelper.m +++ b/Session/Utilities/AvatarViewHelper.m @@ -123,19 +123,34 @@ NS_ASSUME_NONNULL_BEGIN [SNAppearance switchToSessionAppearance]; + + NSURL* imageURL = [info objectForKey:UIImagePickerControllerImageURL]; UIImage *rawAvatar = [info objectForKey:UIImagePickerControllerOriginalImage]; - + [self.delegate.fromViewController dismissViewControllerAnimated:YES completion:^{ + OWSAssertIsOnMainThread(); + + // Check if the user selected an animated image (if so then don't crop, just + // set the avatar directly + NSString *type; + if ([imageURL getResourceValue:&type forKey:NSURLTypeIdentifierKey error:nil]) { + if ([[MIMETypeUtil supportedAnimatedImageUTITypes] containsObject:type]) { + dispatch_async(dispatch_get_main_queue(), ^{ + [self.delegate avatarDidChange:nil filePath: imageURL.path]; + }); + + return; + } + } + if (rawAvatar) { - OWSAssertIsOnMainThread(); - CropScaleImageViewController *vc = [[CropScaleImageViewController alloc] initWithSrcImage:rawAvatar successCompletion:^(UIImage *_Nonnull dstImage) { dispatch_async(dispatch_get_main_queue(), ^{ - [self.delegate avatarDidChange:dstImage]; + [self.delegate avatarDidChange:dstImage filePath:nil]; }); }]; [self.delegate.fromViewController presentViewController:vc diff --git a/Session/Utilities/UIApplication+OWS.swift b/Session/Utilities/UIApplication+OWS.swift index 712f9a069..6e33cb009 100644 --- a/Session/Utilities/UIApplication+OWS.swift +++ b/Session/Utilities/UIApplication+OWS.swift @@ -17,8 +17,6 @@ import UIKit internal func findFrontmostViewController(ignoringAlerts: Bool) -> UIViewController? { guard let window: UIWindow = CurrentAppContext().mainWindow else { return nil } - Logger.error("findFrontmostViewController: \(window)") - guard let viewController: UIViewController = window.rootViewController else { owsFailDebug("Missing root view controller.") return nil diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 9a4ebf7de..9397989a1 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -1465,6 +1465,9 @@ enum _003_YDBToGRDBMigration: Migration { db[.hasSentAMessage] = (legacyPreferences[SMKLegacy.preferencesKeyHasSentAMessageKey] as? Bool == true) db[.isReadyForAppExtensions] = CurrentAppContext().appUserDefaults().bool(forKey: SMKLegacy.preferencesKeyIsReadyForAppExtensions) + // We want this setting to be on by default + db[.trimOpenGroupMessagesOlderThanSixMonths] = true + Storage.update(progress: 1, for: self, in: target) // In case this is the last migration } diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index e55f5f9ad..ffe2a3bdd 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -1049,7 +1049,10 @@ extension Attachment { uploadPromise .done(on: queue) { fileId in - // Save the final upload info + /// Save the final upload info + /// + /// **Note:** We **MUST** use the `.with` function here to ensure the `isValid` flag is + /// updated correctly let uploadedAttachment: Attachment? = Storage.shared.write { db in try updatedAttachment? .with( diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index 393ea6fb0..32385683b 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -72,31 +72,6 @@ public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableReco ) """ } - - // MARK: - PersistableRecord - - public func save(_ db: Database) throws { - let oldProfile: Profile? = try? Profile.fetchOne(db, id: id) - - try performSave(db) - - db.afterNextTransactionCommit { db in - // Delete old profile picture if needed - if let oldProfilePictureFileName: String = oldProfile?.profilePictureFileName, oldProfilePictureFileName != profilePictureFileName { - let path: String = ProfileManager.profileAvatarFilepath(filename: oldProfilePictureFileName) - - DispatchQueue.global(qos: .default).async { - OWSFileSystem.deleteFileIfExists(path) - } - } - - // FIXME: Remove this once the OWSConversationSettingsViewController has been refactored and is observing DB changes - if id != getUserHexEncodedPublicKey(db) { - let userInfo = [ Notification.Key.profileRecipientId.rawValue: id ] - NotificationCenter.default.post(name: .otherUsersProfileDidChange, object: nil, userInfo: userInfo) - } - } - } } // MARK: - Codable diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index 9162392ef..9f0967104 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -174,7 +174,12 @@ public extension SessionThread { (includeNonVisible || shouldBeVisible) && variant == .contact && id != getUserHexEncodedPublicKey(db) && // Note to self - (try? Contact.fetchOne(db, id: id))?.isApproved != true + (try? Contact + .filter(id: id) + .select(.isApproved) + .asRequest(of: Bool.self) + .fetchOne(db)) + .defaulting(to: false) == false ) } } @@ -196,7 +201,7 @@ public extension SessionThread { """ } - static func unreadMessageRequestsThreadIdQuery(userPublicKey: String) -> SQLRequest { + static func unreadMessageRequestsThreadIdQuery(userPublicKey: String, includeNonVisible: Bool = false) -> SQLRequest { let thread: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() @@ -210,7 +215,7 @@ public extension SessionThread { ) LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) WHERE ( - \(SessionThread.isMessageRequest(userPublicKey: userPublicKey)) + \(SessionThread.isMessageRequest(userPublicKey: userPublicKey, includeNonVisible: includeNonVisible)) ) GROUP BY \(thread[.id]) """ @@ -245,6 +250,50 @@ public extension SessionThread { ) } + func shouldShowNotification(_ db: Database, for interaction: Interaction, isMessageRequest: Bool) -> 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.onlyNotifyForMentions || + interaction.hasMention + ) + else { return false } + + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + // No need to notify the user for self-send messages + guard interaction.authorId != userPublicKey else { return false } + + // If the thread is a message request then we only want to notify for the first message + if self.variant == .contact && isMessageRequest { + let hasHiddenMessageRequests: Bool = db[.hasHiddenMessageRequests] + + // If the user hasn't hidden the message requests section then only show the notification if + // all the other message request threads have been read + if !hasHiddenMessageRequests { + let numUnreadMessageRequestThreads: Int = (try? SessionThread + .unreadMessageRequestsThreadIdQuery(userPublicKey: userPublicKey, includeNonVisible: true) + .fetchCount(db)) + .defaulting(to: 1) + + guard numUnreadMessageRequestThreads == 1 else { return false } + } + + // We only want to show a notification for the first interaction in the thread + guard ((try? self.interactions.fetchCount(db)) ?? 0) <= 1 else { return false } + + // Need to re-show the message requests section if it had been hidden + if hasHiddenMessageRequests { + db[.hasHiddenMessageRequests] = false + } + } + + return true + } + static func displayName( threadId: String, variant: Variant, diff --git a/SessionMessagingKit/Database/Notification+Contacts.swift b/SessionMessagingKit/Database/Notification+Contacts.swift deleted file mode 100644 index a61ca1aa8..000000000 --- a/SessionMessagingKit/Database/Notification+Contacts.swift +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import SessionUtilitiesKit -// FIXME: Remove these extensions once the OWSConversationSettingsViewModel is refactored to swift and uses proper database observation -public extension Notification.Name { - - static let otherUsersProfileDidChange = Notification.Name("otherUsersProfileDidChange") -} - -@objc public extension NSNotification { - - @objc static let otherUsersProfileDidChange = Notification.Name.otherUsersProfileDidChange.rawValue as NSString -} - -extension Notification.Key { - static let profileRecipientId = Notification.Key("profileRecipientId") -} - -@objc public extension NSNotification { - static let profileRecipientIdKey = Notification.Key.profileRecipientId.rawValue as NSString -} diff --git a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift index f0b6ba060..21abafe58 100644 --- a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift +++ b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift @@ -16,6 +16,7 @@ public enum GarbageCollectionJob: JobExecutor { public static var requiresThreadId: Bool = false public static let requiresInteractionId: Bool = false public static let approxSixMonthsInSeconds: TimeInterval = (6 * 30 * 24 * 60 * 60) + private static let minInteractionsToTrim: Int = 2000 public static func run( _ job: Job, @@ -68,6 +69,8 @@ public enum GarbageCollectionJob: JobExecutor { if typesToCollect.contains(.oldOpenGroupMessages) && db[.trimOpenGroupMessagesOlderThanSixMonths] { let interaction: TypedTableAlias = TypedTableAlias() let thread: TypedTableAlias = TypedTableAlias() + let threadIdLiteral: SQL = SQL(stringLiteral: Interaction.Columns.threadId.name) + let minInteractionsToTrimSql: SQL = SQL("\(GarbageCollectionJob.minInteractionsToTrim)") try db.execute(literal: """ DELETE FROM \(Interaction.self) @@ -78,7 +81,17 @@ public enum GarbageCollectionJob: JobExecutor { \(SQL("\(thread[.variant]) = \(SessionThread.Variant.openGroup)")) AND \(thread[.id]) = \(interaction[.threadId]) ) - WHERE \(interaction[.timestampMs]) < \(timestampNow - approxSixMonthsInSeconds) + JOIN ( + SELECT + COUNT(\(interaction.alias[Column.rowID])) AS interactionCount, + \(interaction[.threadId]) + FROM \(Interaction.self) + GROUP BY \(interaction[.threadId]) + ) AS interactionInfo ON interactionInfo.\(threadIdLiteral) = \(interaction[.threadId]) + WHERE ( + \(interaction[.timestampMs]) < \(timestampNow - approxSixMonthsInSeconds) AND + interactionInfo.interactionCount >= \(minInteractionsToTrimSql) + ) ) """) } diff --git a/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift b/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift index 313070dd5..63885541a 100644 --- a/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift +++ b/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift @@ -17,10 +17,7 @@ public enum NotifyPushServerJob: JobExecutor { failure: @escaping (Job, Error?, Bool) -> (), deferred: @escaping (Job) -> () ) { - let server: String = PushNotificationAPI.server - guard - let url: URL = URL(string: "\(server)/notify"), let detailsData: Data = job.details, let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData) else { @@ -28,34 +25,16 @@ public enum NotifyPushServerJob: JobExecutor { return } - let requestBody: RequestBody = RequestBody( - data: details.message.data.description, - sendTo: details.message.recipient - ) - - guard let body: Data = try? JSONEncoder().encode(requestBody) else { - failure(job, HTTP.Error.invalidJSON, true) - return - } - - var request: URLRequest = URLRequest(url: url) - request.httpMethod = "POST" - request.allHTTPHeaderFields = [ Header.contentType.rawValue: "application/json" ] - request.httpBody = body - - attempt(maxRetryCount: 4, recoveringOn: queue) { - OnionRequestAPI - .sendOnionRequest( - request, - to: server, - using: .v2, - with: PushNotificationAPI.serverPublicKey - ) - .map { _ in } - } - .done(on: queue) { _ in success(job, false) } - .catch(on: queue) { error in failure(job, error, false) } - .retainUntilComplete() + PushNotificationAPI + .notify( + recipient: details.message.recipient, + with: details.message.data, + maxRetryCount: 4, + queue: queue + ) + .done(on: queue) { _ in success(job, false) } + .catch(on: queue) { error in failure(job, error, false) } + .retainUntilComplete() } } @@ -65,14 +44,4 @@ extension NotifyPushServerJob { public struct Details: Codable { public let message: SnodeMessage } - - struct RequestBody: Codable { - enum CodingKeys: String, CodingKey { - case data - case sendTo = "send_to" - } - - let data: String - let sendTo: String - } } diff --git a/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift b/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift index ca26bd3ec..f20cc5cca 100644 --- a/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift +++ b/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift @@ -34,12 +34,14 @@ public enum UpdateProfilePictureJob: JobExecutor { // Note: The user defaults flag is updated in ProfileManager let profile: Profile = Profile.fetchOrCreateCurrentUser() - let profilePicture: UIImage? = ProfileManager.profileAvatar(id: profile.id) + let profileFilePath: String? = profile.profilePictureFileName + .map { ProfileManager.profileAvatarFilepath(filename: $0) } ProfileManager.updateLocal( queue: queue, profileName: profile.name, - avatarImage: profilePicture, + image: nil, + imageFilePath: profileFilePath, requiredSync: true, success: { _, _ in success(job, false) }, failure: { error in failure(job, error, false) } diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index da8b9065b..2eda1e7ab 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -224,6 +224,15 @@ public enum MessageReceiver { default: fatalError() } + // Perform any required post-handling logic + try MessageReceiver.postHandleMessage(db, message: message, openGroupId: openGroupId) + } + + public static func postHandleMessage( + _ db: Database, + message: Message, + openGroupId: String? + ) throws { // When handling any non-typing indicator message we want to make sure the thread becomes // visible (the only other spot this flag gets set is when sending messages) switch message { diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift index 7770a65ae..3ad5dbcc9 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift @@ -7,11 +7,21 @@ import SessionUtilitiesKit @objc(LKPushNotificationAPI) public final class PushNotificationAPI : NSObject { - struct RequestBody: Codable { + struct RegistrationRequestBody: Codable { let token: String let pubKey: String? } + struct NotifyRequestBody: Codable { + enum CodingKeys: String, CodingKey { + case data + case sendTo = "send_to" + } + + let data: String + let sendTo: String + } + struct ClosedGroupRequestBody: Codable { let closedGroupPublicKey: String let pubKey: String @@ -42,7 +52,7 @@ public final class PushNotificationAPI : NSObject { // MARK: - Registration public static func unregister(_ token: Data) -> Promise { - let requestBody: RequestBody = RequestBody(token: token.toHexString(), pubKey: nil) + let requestBody: RegistrationRequestBody = RegistrationRequestBody(token: token.toHexString(), pubKey: nil) guard let body: Data = try? JSONEncoder().encode(requestBody) else { return Promise(error: HTTP.Error.invalidJSON) @@ -92,7 +102,7 @@ public final class PushNotificationAPI : NSObject { public static func register(with token: Data, publicKey: String, isForcedUpdate: Bool) -> Promise { let hexEncodedToken: String = token.toHexString() - let requestBody: RequestBody = RequestBody(token: hexEncodedToken, pubKey: publicKey) + let requestBody: RegistrationRequestBody = RegistrationRequestBody(token: hexEncodedToken, pubKey: publicKey) guard let body: Data = try? JSONEncoder().encode(requestBody) else { return Promise(error: HTTP.Error.invalidJSON) @@ -203,4 +213,33 @@ public final class PushNotificationAPI : NSObject { public static func objc_performOperation(_ operation: ClosedGroupOperation, for closedGroupPublicKey: String, publicKey: String) -> AnyPromise { return AnyPromise.from(performOperation(operation, for: closedGroupPublicKey, publicKey: publicKey)) } + + // MARK: - Notify + + public static func notify( + recipient: String, + with message: String, + maxRetryCount: UInt? = nil, + queue: DispatchQueue = DispatchQueue.global() + ) -> Promise { + let requestBody: NotifyRequestBody = NotifyRequestBody(data: message, sendTo: recipient) + + guard let body: Data = try? JSONEncoder().encode(requestBody) else { + return Promise(error: HTTP.Error.invalidJSON) + } + + let url = URL(string: "\(server)/notify")! + var request: URLRequest = URLRequest(url: url) + request.httpMethod = "POST" + request.allHTTPHeaderFields = [ Header.contentType.rawValue: "application/json" ] + request.httpBody = body + + let retryCount: UInt = (maxRetryCount ?? PushNotificationAPI.maxRetryCount) + let promise: Promise = attempt(maxRetryCount: retryCount, recoveringOn: queue) { + OnionRequestAPI.sendOnionRequest(request, to: server, using: .v2, with: serverPublicKey) + .map { _ in } + } + + return promise + } } diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index 363e98069..9e7467c99 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -366,9 +366,10 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, // Only incoming messages (self.variant == .standardIncoming || self.variant == .standardIncomingDeleted) && - // Show if the next message has a different sender or has a "date break" + // Show if the next message has a different sender, isn't a standard message or has a "date break" ( self.authorId != nextModel?.authorId || + (nextModel?.variant != .standardIncoming && nextModel?.variant != .standardIncomingDeleted) || shouldShowDateOnNextModel ) && diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index f32718c95..5b51a6cb7 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -574,7 +574,6 @@ public extension SessionThreadViewModel { (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.threadIsNoteToSelfKey), ( - \(thread[.shouldBeVisible]) = true AND \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND \(SQL("\(thread[.id]) != \(userPublicKey)")) AND IFNULL(\(contact[.isApproved]), false) = false diff --git a/SessionMessagingKit/Utilities/ProfileManager.swift b/SessionMessagingKit/Utilities/ProfileManager.swift index 46a0564e2..ac25ce0f9 100644 --- a/SessionMessagingKit/Utilities/ProfileManager.swift +++ b/SessionMessagingKit/Utilities/ProfileManager.swift @@ -12,7 +12,7 @@ public struct ProfileManager { private static let nameDataLength: UInt = 26 public static let maxAvatarDiameter: CGFloat = 640 - private static var profileAvatarCache: Atomic<[String: UIImage]> = Atomic([:]) + private static var profileAvatarCache: Atomic<[String: Data]> = Atomic([:]) private static var currentAvatarDownloads: Atomic> = Atomic([]) // MARK: - Functions @@ -21,7 +21,7 @@ public struct ProfileManager { return ((profileName.data(using: .utf8)?.count ?? 0) > nameDataLength) } - public static func profileAvatar(_ db: Database? = nil, id: String) -> UIImage? { + public static func profileAvatar(_ db: Database? = nil, id: String) -> Data? { guard let db: Database = db else { return Storage.shared.read { db in profileAvatar(db, id: id) } } @@ -30,9 +30,9 @@ public struct ProfileManager { return profileAvatar(profile: profile) } - public static func profileAvatar(profile: Profile) -> UIImage? { + public static func profileAvatar(profile: Profile) -> Data? { if let profileFileName: String = profile.profilePictureFileName, !profileFileName.isEmpty { - return loadProfileAvatar(for: profileFileName) + return loadProfileAvatar(for: profileFileName, profile: profile) } if let profilePictureUrl: String = profile.profilePictureUrl, !profilePictureUrl.isEmpty { @@ -42,22 +42,36 @@ public struct ProfileManager { return nil } - private static func loadProfileAvatar(for fileName: String) -> UIImage? { - if let cachedImage: UIImage = profileAvatarCache.wrappedValue[fileName] { - return cachedImage + private static func loadProfileAvatar(for fileName: String, profile: Profile) -> Data? { + if let cachedImageData: Data = profileAvatarCache.wrappedValue[fileName] { + return cachedImageData } guard !fileName.isEmpty, let data: Data = loadProfileData(with: fileName), - data.isValidImage, - let image: UIImage = UIImage(data: data) + data.isValidImage else { + // If we can't load the avatar or it's an invalid/corrupted image then clear out + // the 'profilePictureFileName' and try to re-download + Storage.shared.writeAsync( + updates: { db in + _ = try? Profile + .filter(id: profile.id) + .updateAll(db, Profile.Columns.profilePictureFileName.set(to: nil)) + }, + completion: { _, _ in + // Try to re-download the avatar if it has a URL + if let profilePictureUrl: String = profile.profilePictureUrl, !profilePictureUrl.isEmpty { + downloadAvatar(for: profile) + } + } + ) return nil } - profileAvatarCache.mutate { $0[fileName] = image } - return image + profileAvatarCache.mutate { $0[fileName] = data } + return data } private static func loadProfileData(with fileName: String) -> Data? { @@ -98,6 +112,20 @@ public struct ProfileManager { return path }() + public static func profileAvatarFilepath(_ db: Database? = nil, id: String) -> String? { + guard let db: Database = db else { + return Storage.shared.read { db in profileAvatarFilepath(db, id: id) } + } + + let maybeFileName: String? = try? Profile + .filter(id: id) + .select(.profilePictureFileName) + .asRequest(of: String.self) + .fetchOne(db) + + return maybeFileName.map { ProfileManager.profileAvatarFilepath(filename: $0) } + } + public static func profileAvatarFilepath(filename: String) -> String { guard !filename.isEmpty else { return "" } @@ -148,45 +176,46 @@ public struct ProfileManager { .done(on: queue) { data in currentAvatarDownloads.mutate { $0.remove(profile.id) } + guard let latestProfile: Profile = Storage.shared.read({ db in try Profile.fetchOne(db, id: profile.id) }) else { + return + } + + guard + let latestProfileKey: OWSAES256Key = latestProfile.profileEncryptionKey, + !latestProfileKey.keyData.isEmpty, + latestProfileKey == profileKeyAtStart + else { + OWSLogger.warn("Ignoring avatar download for obsolete user profile.") + return + } + + guard profileUrlStringAtStart == latestProfile.profilePictureUrl else { + OWSLogger.warn("Avatar url has changed during download.") + + if latestProfile.profilePictureUrl?.isEmpty == false { + self.downloadAvatar(for: latestProfile) + } + return + } + + guard let decryptedData: Data = decryptProfileData(data: data, key: profileKeyAtStart) else { + OWSLogger.warn("Avatar data for \(profile.id) could not be decrypted.") + return + } + + try? decryptedData.write(to: URL(fileURLWithPath: filePath), options: [.atomic]) + + guard UIImage(contentsOfFile: filePath) != nil else { + OWSLogger.warn("Avatar image for \(profile.id) could not be loaded.") + return + } + + // Store the updated 'profilePictureFileName' Storage.shared.write { db in - guard let latestProfile: Profile = try Profile.fetchOne(db, id: profile.id) else { - return - } - - guard - let latestProfileKey: OWSAES256Key = latestProfile.profileEncryptionKey, - !latestProfileKey.keyData.isEmpty, - latestProfileKey == profileKeyAtStart - else { - OWSLogger.warn("Ignoring avatar download for obsolete user profile.") - return - } - - guard profileUrlStringAtStart == latestProfile.profilePictureUrl else { - OWSLogger.warn("Avatar url has changed during download.") - - if latestProfile.profilePictureUrl?.isEmpty == false { - self.downloadAvatar(for: latestProfile) - } - return - } - - guard let decryptedData: Data = decryptProfileData(data: data, key: profileKeyAtStart) else { - OWSLogger.warn("Avatar data for \(profile.id) could not be decrypted.") - return - } - - try? decryptedData.write(to: URL(fileURLWithPath: filePath), options: [.atomic]) - - guard let image: UIImage = UIImage(contentsOfFile: filePath) else { - OWSLogger.warn("Avatar image for \(profile.id) could not be loaded.") - return - } - _ = try? Profile .filter(id: profile.id) .updateAll(db, Profile.Columns.profilePictureFileName.set(to: fileName)) - profileAvatarCache.mutate { $0[fileName] = image } + profileAvatarCache.mutate { $0[fileName] = decryptedData } } // Redundant but without reading 'backgroundTask' it will warn that the variable @@ -209,7 +238,8 @@ public struct ProfileManager { public static func updateLocal( queue: DispatchQueue, profileName: String, - avatarImage: UIImage?, + image: UIImage?, + imageFilePath: String?, requiredSync: Bool, success: ((Database, Profile) throws -> ())? = nil, failure: ((ProfileManagerError) -> ())? = nil @@ -218,8 +248,60 @@ public struct ProfileManager { // If the profile avatar was updated or removed then encrypt with a new profile key // to ensure that other users know that our profile picture was updated let newProfileKey: OWSAES256Key = OWSAES256Key.generateRandom() + let maxAvatarBytes: UInt = (5 * 1000 * 1000) + let avatarImageData: Data? - guard let avatarImage: UIImage = avatarImage else { + do { + avatarImageData = try { + guard var image: UIImage = image else { + guard let imageFilePath: String = imageFilePath else { return nil } + + let data: Data = try Data(contentsOf: URL(fileURLWithPath: imageFilePath)) + + guard data.count <= maxAvatarBytes else { + // Our avatar dimensions are so small that it's incredibly unlikely we wouldn't + // be able to fit our profile photo (eg. generating pure noise at our resolution + // compresses to ~200k) + SNLog("Animated profile avatar was too large.") + SNLog("Updating service with profile failed.") + throw ProfileManagerError.avatarUploadMaxFileSizeExceeded + } + + return data + } + + if image.size.width != maxAvatarDiameter || image.size.height != maxAvatarDiameter { + // To help ensure the user is being shown the same cropping of their avatar as + // everyone else will see, we want to be sure that the image was resized before this point. + SNLog("Avatar image should have been resized before trying to upload") + image = image.resizedImage(toFillPixelSize: CGSize(width: maxAvatarDiameter, height: maxAvatarDiameter)) + } + + guard let data: Data = image.jpegData(compressionQuality: 0.95) else { + SNLog("Updating service with profile failed.") + throw ProfileManagerError.avatarWriteFailed + } + + guard data.count <= maxAvatarBytes else { + // Our avatar dimensions are so small that it's incredibly unlikely we wouldn't + // be able to fit our profile photo (eg. generating pure noise at our resolution + // compresses to ~200k) + SNLog("Suprised to find profile avatar was too large. Was it scaled properly? image: \(image)") + SNLog("Updating service with profile failed.") + throw ProfileManagerError.avatarUploadMaxFileSizeExceeded + } + + return data + }() + } + catch { + if let profileManagerError: ProfileManagerError = error as? ProfileManagerError { + failure?(profileManagerError) + } + return + } + + guard let data: Data = avatarImageData else { // If we have no image then we need to make sure to remove it from the profile Storage.shared.writeAsync { db in let existingProfile: Profile = Profile.fetchOrCreateCurrentUser(db) @@ -255,39 +337,18 @@ public struct ProfileManager { // If we have a new avatar image, we must first: // - // * Encode it to JPEG. // * Write it to disk. // * Encrypt it // * Upload it to asset service // * Send asset service info to Signal Service OWSLogger.verbose("Updating local profile on service with new avatar.") - let maxAvatarBytes: UInt = (5 * 1000 * 1000) - var image: UIImage = avatarImage - - if image.size.width != maxAvatarDiameter || image.size.height != maxAvatarDiameter { - // To help ensure the user is being shown the same cropping of their avatar as - // everyone else will see, we want to be sure that the image was resized before this point. - SNLog("Avatar image should have been resized before trying to upload") - image = image.resizedImage(toFillPixelSize: CGSize(width: maxAvatarDiameter, height: maxAvatarDiameter)) - } - - guard let data: Data = image.jpegData(compressionQuality: 0.95) else { - SNLog("Updating service with profile failed.") - failure?(.avatarWriteFailed) - return - } - guard data.count <= maxAvatarBytes else { - // Our avatar dimensions are so small that it's incredibly unlikely we wouldn't - // be able to fit our profile photo (eg. generating pure noise at our resolution - // compresses to ~200k) - SNLog("Suprised to find profile avatar was too large. Was it scaled properly? image: \(image)") - SNLog("Updating service with profile failed.") - failure?(.avatarImageTooLarge) - return - } - - let fileName: String = UUID().uuidString.appendingFileExtension("jpg") + let fileName: String = UUID().uuidString + .appendingFileExtension( + imageFilePath + .map { URL(fileURLWithPath: $0).pathExtension } + .defaulting(to: "jpg") + ) let filePath: String = ProfileManager.profileAvatarFilepath(filename: fileName) // Write the avatar to disk @@ -324,7 +385,7 @@ public struct ProfileManager { .saved(db) // Update the cached avatar image value - profileAvatarCache.mutate { $0[fileName] = avatarImage } + profileAvatarCache.mutate { $0[fileName] = data } SNLog("Successfully updated service with profile.") try success?(db, profile) diff --git a/SessionNotificationServiceExtension/NSENotificationPresenter.swift b/SessionNotificationServiceExtension/NSENotificationPresenter.swift index 2f487f70e..04503ed5f 100644 --- a/SessionNotificationServiceExtension/NSENotificationPresenter.swift +++ b/SessionNotificationServiceExtension/NSENotificationPresenter.swift @@ -10,43 +10,14 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { private var notifications: [String: UNNotificationRequest] = [:] public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, isBackgroundPoll: Bool) { - guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return } - - let userPublicKey: String = getUserHexEncodedPublicKey(db) let isMessageRequest: Bool = thread.isMessageRequest(db, includeNonVisible: true) - // If the thread is a message request and the user hasn't hidden message requests then we need - // to check if this is the only message request thread (group threads can't be message requests - // so just ignore those and if the user has hidden message requests then we want to show the - // notification regardless of how many message requests there are) - if thread.variant == .contact { - if isMessageRequest && !db[.hasHiddenMessageRequests] { - let numMessageRequestThreads: Int = (try? SessionThread - .messageRequestsQuery(userPublicKey: userPublicKey, includeNonVisible: true) - .fetchCount(db)) - .defaulting(to: 0) - - // Allow this to show a notification if there are no message requests (ie. this is the first one) - guard numMessageRequestThreads == 0 else { return } - } - else if isMessageRequest && db[.hasHiddenMessageRequests] { - // If there are other interactions on this thread already then don't show the notification - if ((try? thread.interactions.fetchCount(db)) ?? 0) > 1 { return } - - db[.hasHiddenMessageRequests] = false - } - } - - let senderPublicKey: String = interaction.authorId - - guard senderPublicKey != userPublicKey else { - // Ignore PNs for messages sent by the current user - // after handling the message. Otherwise the closed - // group self-send messages won't show. + // Ensure we should be showing a notification for the thread + guard thread.shouldShowNotification(db, for: interaction, isMessageRequest: isMessageRequest) else { return } - let senderName: String = Profile.displayName(db, id: senderPublicKey, threadVariant: thread.variant) + let senderName: String = Profile.displayName(db, id: interaction.authorId, threadVariant: thread.variant) var notificationTitle: String = senderName if thread.variant == .closedGroup || thread.variant == .openGroup { diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index 1cb3dd274..df3fed80c 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -67,16 +67,26 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension return } + let maybeVariant: SessionThread.Variant? = processedMessage.threadId + .map { threadId in + try? SessionThread + .filter(id: threadId) + .select(.variant) + .asRequest(of: SessionThread.Variant.self) + .fetchOne(db) + } + let isOpenGroup: Bool = (maybeVariant == .openGroup) + switch processedMessage.messageInfo.message { case let visibleMessage as VisibleMessage: let interactionId: Int64 = try MessageReceiver.handleVisibleMessage( db, message: visibleMessage, associatedWithProto: processedMessage.proto, - openGroupId: nil, + openGroupId: (isOpenGroup ? processedMessage.threadId : nil), isBackgroundPoll: false ) - + // Remove the notifications if there is an outgoing messages from a linked device if let interaction: Interaction = try? Interaction.fetchOne(db, id: interactionId), @@ -127,6 +137,13 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension default: break } + + // Perform any required post-handling logic + try MessageReceiver.postHandleMessage( + db, + message: processedMessage.messageInfo.message, + openGroupId: (isOpenGroup ? processedMessage.threadId : nil) + ) } catch { if let error = error as? MessageReceiverError, error.isRetryable { diff --git a/SessionSnodeKit/OnionRequestAPI+Encryption.swift b/SessionSnodeKit/OnionRequestAPI+Encryption.swift index d3f3f8c89..8652b28d8 100644 --- a/SessionSnodeKit/OnionRequestAPI+Encryption.swift +++ b/SessionSnodeKit/OnionRequestAPI+Encryption.swift @@ -16,26 +16,21 @@ internal extension OnionRequestAPI { } /// Encrypts `payload` for `destination` and returns the result. Use this to build the core of an onion request. - static func encrypt(_ payload: Data, for destination: OnionRequestAPIDestination, with version: OnionRequestAPIVersion) -> Promise { + static func encrypt(_ payload: Data, for destination: OnionRequestAPIDestination) -> Promise { let (promise, seal) = Promise.pending() DispatchQueue.global(qos: .userInitiated).async { do { - let data: Data - - switch version { - case .v2, .v3: - // Wrapping is only needed for snode requests - switch destination { - case .snode: data = try encode(ciphertext: payload, json: [ "headers" : "" ]) - case .server: data = payload - } + switch destination { + case .snode(let snode): + // Need to wrap the payload for snode requests + let data: Data = try encode(ciphertext: payload, json: [ "headers" : "" ]) + let result: AESGCM.EncryptionResult = try AESGCM.encrypt(data, for: snode.x25519PublicKey) + seal.fulfill(result) - case .v4: - data = payload + case .server(_, _, let serverX25519PublicKey, _, _): + let result: AESGCM.EncryptionResult = try AESGCM.encrypt(payload, for: serverX25519PublicKey) + seal.fulfill(result) } - - let result = try encrypt(data, for: destination) - seal.fulfill(result) } catch (let error) { seal.reject(error) @@ -44,16 +39,6 @@ internal extension OnionRequestAPI { return promise } - - private static func encrypt(_ payload: Data, for destination: OnionRequestAPIDestination) throws -> AESGCM.EncryptionResult { - switch destination { - case .snode(let snode): - return try AESGCM.encrypt(payload, for: snode.x25519PublicKey) - - case .server(_, _, let serverX25519PublicKey, _, _): - return try AESGCM.encrypt(payload, for: serverX25519PublicKey) - } - } /// Encrypts the previous encryption result (i.e. that of the hop after this one) for this hop. Use this to build the layers of an onion request. static func encryptHop(from lhs: OnionRequestAPIDestination, to rhs: OnionRequestAPIDestination, using previousEncryptionResult: AESGCM.EncryptionResult) -> Promise { diff --git a/SessionSnodeKit/OnionRequestAPI.swift b/SessionSnodeKit/OnionRequestAPI.swift index 739430af6..ce6cc7b46 100644 --- a/SessionSnodeKit/OnionRequestAPI.swift +++ b/SessionSnodeKit/OnionRequestAPI.swift @@ -7,7 +7,7 @@ import PromiseKit import SessionUtilitiesKit public protocol OnionRequestAPIType { - static func sendOnionRequest(to snode: Snode, invoking method: SnodeAPIEndpoint, with parameters: JSON, using version: OnionRequestAPIVersion, associatedWith publicKey: String?) -> Promise + static func sendOnionRequest(to snode: Snode, invoking method: SnodeAPIEndpoint, with parameters: JSON, associatedWith publicKey: String?) -> Promise static func sendOnionRequest(_ request: URLRequest, to server: String, using version: OnionRequestAPIVersion, with x25519PublicKey: String) -> Promise<(OnionRequestResponseInfoType, Data?)> } @@ -310,7 +310,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { } /// Builds an onion around `payload` and returns the result. - private static func buildOnion(around payload: Data, targetedAt destination: OnionRequestAPIDestination, version: OnionRequestAPIVersion) -> Promise { + private static func buildOnion(around payload: Data, targetedAt destination: OnionRequestAPIDestination) -> Promise { var guardSnode: Snode! var targetSnodeSymmetricKey: Data! // Needed by invoke(_:on:with:) to decrypt the response sent back by the destination var encryptionResult: AESGCM.EncryptionResult! @@ -323,7 +323,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { guardSnode = path.first! // Encrypt in reverse order, i.e. the destination first - return encrypt(payload, for: destination, with: version) + return encrypt(payload, for: destination) .then2 { r -> Promise in targetSnodeSymmetricKey = r.symmetricKey @@ -356,14 +356,15 @@ public enum OnionRequestAPI: OnionRequestAPIType { // MARK: - Public API /// Sends an onion request to `snode`. Builds new paths as needed. - public static func sendOnionRequest(to snode: Snode, invoking method: SnodeAPIEndpoint, with parameters: JSON, using version: OnionRequestAPIVersion, associatedWith publicKey: String? = nil) -> Promise { + public static func sendOnionRequest(to snode: Snode, invoking method: SnodeAPIEndpoint, with parameters: JSON, associatedWith publicKey: String? = nil) -> Promise { let payloadJson: JSON = [ "method" : method.rawValue, "params" : parameters ] guard let payload: Data = try? JSONSerialization.data(withJSONObject: payloadJson, options: []) else { return Promise(error: HTTP.Error.invalidJSON) } - return sendOnionRequest(with: payload, to: OnionRequestAPIDestination.snode(snode), version: version) + /// **Note:** Currently the service nodes only support V3 Onion Requests + return sendOnionRequest(with: payload, to: OnionRequestAPIDestination.snode(snode), version: .v3) .map { _, maybeData in guard let data: Data = maybeData else { throw HTTP.Error.invalidResponse } @@ -410,42 +411,43 @@ public enum OnionRequestAPI: OnionRequestAPIType { var guardSnode: Snode? Threading.workQueue.async { // Avoid race conditions on `guardSnodes` and `paths` - buildOnion(around: payload, targetedAt: destination, version: version).done2 { intermediate in - guardSnode = intermediate.guardSnode - let url = "\(guardSnode!.address):\(guardSnode!.port)/onion_req/v2" - let finalEncryptionResult = intermediate.finalEncryptionResult - let onion = finalEncryptionResult.ciphertext - if case OnionRequestAPIDestination.server = destination, Double(onion.count) > 0.75 * Double(maxRequestSize) { - SNLog("Approaching request size limit: ~\(onion.count) bytes.") - } - let parameters: JSON = [ - "ephemeral_key" : finalEncryptionResult.ephemeralPublicKey.toHexString() - ] - let body: Data - do { - body = try encode(ciphertext: onion, json: parameters) - } catch { - return seal.reject(error) - } - let destinationSymmetricKey = intermediate.destinationSymmetricKey - - HTTP.execute(.post, url, body: body) - .done2 { responseData in - handleResponse( - responseData: responseData, - destinationSymmetricKey: destinationSymmetricKey, - version: version, - destination: destination, - seal: seal - ) + buildOnion(around: payload, targetedAt: destination) + .done2 { intermediate in + guardSnode = intermediate.guardSnode + let url = "\(guardSnode!.address):\(guardSnode!.port)/onion_req/v2" + let finalEncryptionResult = intermediate.finalEncryptionResult + let onion = finalEncryptionResult.ciphertext + if case OnionRequestAPIDestination.server = destination, Double(onion.count) > 0.75 * Double(maxRequestSize) { + SNLog("Approaching request size limit: ~\(onion.count) bytes.") } - .catch2 { error in - seal.reject(error) + let parameters: JSON = [ + "ephemeral_key" : finalEncryptionResult.ephemeralPublicKey.toHexString() + ] + let body: Data + do { + body = try encode(ciphertext: onion, json: parameters) + } catch { + return seal.reject(error) } - } - .catch2 { error in - seal.reject(error) - } + let destinationSymmetricKey = intermediate.destinationSymmetricKey + + HTTP.execute(.post, url, body: body) + .done2 { responseData in + handleResponse( + responseData: responseData, + destinationSymmetricKey: destinationSymmetricKey, + version: version, + destination: destination, + seal: seal + ) + } + .catch2 { error in + seal.reject(error) + } + } + .catch2 { error in + seal.reject(error) + } } promise.catch2 { error in // Must be invoked on Threading.workQueue diff --git a/SessionSnodeKit/SnodeAPI.swift b/SessionSnodeKit/SnodeAPI.swift index b71cb0ef9..5a4af04dc 100644 --- a/SessionSnodeKit/SnodeAPI.swift +++ b/SessionSnodeKit/SnodeAPI.swift @@ -134,7 +134,6 @@ public final class SnodeAPI { to: snode, invoking: method, with: parameters, - using: .v3, associatedWith: publicKey ) .map2 { responseData in @@ -207,7 +206,6 @@ public final class SnodeAPI { attempt(maxRetryCount: 4, recoveringOn: Threading.workQueue) { HTTP.execute(.post, url, parameters: parameters, useSeedNodeURLSession: true) .map2 { responseData -> Set in - // TODO: Validate this works guard let snodePool: SnodePoolResponse = try? JSONDecoder().decode(SnodePoolResponse.self, from: responseData) else { throw SnodeAPIError.snodePoolUpdatingFailed } @@ -261,7 +259,6 @@ public final class SnodeAPI { return invoke(.oxenDaemonRPCCall, on: snode, parameters: parameters) .map2 { responseData in - // TODO: Validate this works guard let snodePool: SnodePoolResponse = try? JSONDecoder().decode(SnodePoolResponse.self, from: responseData) else { throw SnodeAPIError.snodePoolUpdatingFailed } diff --git a/SessionUtilitiesKit/Database/Storage.swift b/SessionUtilitiesKit/Database/Storage.swift index 8dc87d8cc..3cd783d74 100644 --- a/SessionUtilitiesKit/Database/Storage.swift +++ b/SessionUtilitiesKit/Database/Storage.swift @@ -258,7 +258,6 @@ public final class Storage { defer { keySpec.resetBytes(in: 0.. (image: UIImage, isTappable: Bool) { - if let profile: Profile = profile, let profilePicture: UIImage = ProfileManager.profileAvatar(profile: profile) { - return (profilePicture, true) + if let profile: Profile = profile, let profileData: Data = ProfileManager.profileAvatar(profile: profile), let image: YYImage = YYImage(data: profileData) { + return (image, true) } return ( @@ -179,7 +180,7 @@ public final class ProfilePictureView: UIView { hasTappableProfilePicture = isTappable } - imageView.contentMode = .scaleAspectFit + imageView.contentMode = .scaleAspectFill imageView.backgroundColor = Colors.unimportant imageView.layer.cornerRadius = (targetSize / 2) additionalImageView.layer.cornerRadius = (targetSize / 2) @@ -187,11 +188,11 @@ public final class ProfilePictureView: UIView { // MARK: - Convenience - private func getImageView() -> UIImageView { - let result = UIImageView() + private func getImageView() -> YYAnimatedImageView { + let result = YYAnimatedImageView() result.layer.masksToBounds = true result.backgroundColor = Colors.unimportant - result.contentMode = .scaleAspectFit + result.contentMode = .scaleAspectFill return result } From 9b10944567c8b11125c360dfdc22bfab9833dad7 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 8 Jul 2022 17:54:07 +1000 Subject: [PATCH 131/157] Increased build number --- Session.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index edb5c7aae..c6c3c63e1 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -6794,7 +6794,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 355; + CURRENT_PROJECT_VERSION = 356; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -6866,7 +6866,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 355; + CURRENT_PROJECT_VERSION = 356; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", From fd76438686cb6ba594b70449847f88ebf5bceb39 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 11 Jul 2022 09:57:06 +1000 Subject: [PATCH 132/157] Fixed an issue where the 'message trimming' setting wouldn't get set to on by default if there was no legacy database --- .../Database/Migrations/_003_YDBToGRDBMigration.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 9397989a1..e1c8d8b4e 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -18,6 +18,9 @@ enum _003_YDBToGRDBMigration: Migration { static func migrate(_ db: Database) throws { guard let dbConnection: YapDatabaseConnection = SUKLegacy.newDatabaseConnection() else { + // We want this setting to be on by default (even if there isn't a legacy database) + db[.trimOpenGroupMessagesOlderThanSixMonths] = true + SNLog("[Migration Warning] No legacy database, skipping \(target.key(with: self))") return } From b4ab521713f54e5c4b72c2b3b503da55335a8c31 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 11 Jul 2022 14:26:23 +1000 Subject: [PATCH 133/157] Fixed a bug with the message request UI and with recurring job retrying Fixed a bug where jobs that recur on launch or active could end up endlessly retrying if they failed once Fixed a bug where the message request UI would appear for outgoing message requests --- Session/Conversations/ConversationVC.swift | 27 ++++++++++++++++--- .../SessionThreadViewModel.swift | 6 ++--- SessionUtilitiesKit/JobRunner/JobRunner.swift | 13 +++++++++ 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index bddae7992..70546c16f 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -209,7 +209,10 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers lazy var messageRequestView: UIView = { let result: UIView = UIView() result.translatesAutoresizingMaskIntoConstraints = false - result.isHidden = (self.viewModel.threadData.threadIsMessageRequest == false) + result.isHidden = ( + self.viewModel.threadData.threadIsMessageRequest == false || + self.viewModel.threadData.threadRequiresApproval == true + ) result.setGradient(Gradients.defaultBackground) return result @@ -558,11 +561,21 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers } if initialLoad || viewModel.threadData.threadIsMessageRequest != updatedThreadData.threadIsMessageRequest { - messageRequestView.isHidden = (updatedThreadData.threadIsMessageRequest == false) scrollButtonMessageRequestsBottomConstraint?.isActive = (updatedThreadData.threadIsMessageRequest == true) scrollButtonBottomConstraint?.isActive = (updatedThreadData.threadIsMessageRequest == false) } + if + initialLoad || + viewModel.threadData.threadRequiresApproval != updatedThreadData.threadRequiresApproval || + viewModel.threadData.threadIsMessageRequest != updatedThreadData.threadIsMessageRequest + { + messageRequestView.isHidden = ( + updatedThreadData.threadIsMessageRequest == false || + updatedThreadData.threadRequiresApproval == true + ) + } + if initialLoad || viewModel.threadData.threadUnreadCount != updatedThreadData.threadUnreadCount { updateUnreadCountView(unreadCount: updatedThreadData.threadUnreadCount) } @@ -871,7 +884,13 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers navigationItem.rightBarButtonItems = [] } else { - guard let threadData: SessionThreadViewModel = threadData, threadData.threadRequiresApproval == false else { + guard + let threadData: SessionThreadViewModel = threadData, + ( + threadData.threadRequiresApproval == false && + threadData.threadIsMessageRequest == false + ) + else { // Note: Adding empty buttons because without it the title alignment is busted (Note: The size was // taken from the layout inspector for the back button in Xcode navigationItem.rightBarButtonItems = [ @@ -914,7 +933,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers settingsButtonItem.accessibilityLabel = "Settings button" settingsButtonItem.isAccessibilityElement = true - if SessionCall.isEnabled && !threadData.threadIsNoteToSelf && threadData.threadIsMessageRequest == false { + if SessionCall.isEnabled && !threadData.threadIsNoteToSelf { let callButton = UIBarButtonItem( image: UIImage(named: "Phone"), style: .plain, diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 5b51a6cb7..fc099a5cb 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -579,10 +579,8 @@ public extension SessionThreadViewModel { IFNULL(\(contact[.isApproved]), false) = false ) AS \(ViewModel.threadIsMessageRequestKey), ( - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND ( - IFNULL(\(contact[.isApproved]), false) = false OR - IFNULL(\(contact[.didApproveMe]), false) = false - ) + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND + IFNULL(\(contact[.didApproveMe]), false) = false ) AS \(ViewModel.threadRequiresApprovalKey), \(thread[.shouldBeVisible]) AS \(ViewModel.threadShouldBeVisibleKey), diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index 96278ad7b..6104b5680 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -772,6 +772,19 @@ private final class JobQueue { .with(nextRunTimestamp: (Date().timeIntervalSince1970 + 1)) .saved(db) } + + // For `recurringOnLaunch/Active` jobs which have already run, we want to clear their + // `failureCount` and `nextRunTimestamp` to prevent them from endlessly running over + // and over and reset their retry backoff in case they fail next time + case .recurringOnLaunch, .recurringOnActive: + Storage.shared.write { db in + _ = try job + .with( + failureCount: 0, + nextRunTimestamp: 0 + ) + .saved(db) + } default: break } From 5b6be3912dc205dec057f5d401a89a63a99b4aae Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 12 Jul 2022 17:43:52 +1000 Subject: [PATCH 134/157] Fixed an edge-case crash, a couple of minor bugs and made future-proofing tweaks Fixed a bit of the OnionRequest error handling to better send through server error messages for debugging Fixed a bug where the initial offset could be negative if the number of messages was less than the page size resulting in a crash Fixed a crash due to a code path which was thought to be impossible exiting but is actually possible (so just erroring) Added the 'expire' SnodeAPI endpoint Removed the 'openGroupServerTimestamp' property (was unused and just added confusion) Updated the logic to always handle the 'fileId' for uploads/downloads as a string instead of casting it to an Int64 Updated the OpenGroup room parsing to support either Int or String values for image ids --- Session/Conversations/ConversationVC.swift | 13 +- .../Open Groups/OpenGroupSuggestionGrid.swift | 2 +- .../Database/LegacyDatabase/SMKLegacy.swift | 3 +- .../File Server/FileServerAPI.swift | 4 +- .../File Server/Types/FSEndpoint.swift | 2 +- .../Jobs/Types/AttachmentDownloadJob.swift | 10 +- SessionMessagingKit/Messages/Message.swift | 3 - .../Open Groups/Models/Room.swift | 10 +- .../Open Groups/OpenGroupAPI.swift | 2 +- .../Open Groups/OpenGroupManager.swift | 12 +- .../Open Groups/Types/SOGSEndpoint.swift | 2 +- .../Sending & Receiving/MessageReceiver.swift | 1 - .../Sending & Receiving/MessageSender.swift | 8 +- .../Utilities/ProfileManager.swift | 10 +- .../Models/OnionRequestAPIError.swift | 5 +- SessionSnodeKit/Models/SnodeAPIEndpoint.swift | 1 + SessionSnodeKit/OnionRequestAPI.swift | 7 +- SessionSnodeKit/SnodeAPI.swift | 115 ++++++++++++++++-- .../Types/PagedDatabaseObserver.swift | 2 +- SessionUtilitiesKit/General/SessionId.swift | 4 +- 20 files changed, 166 insertions(+), 50 deletions(-) diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 70546c16f..3b5e8241e 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -1461,10 +1461,15 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers var lastSize: CGSize = .zero self.tableView.afterNextLayoutSubviews( - when: { [weak self] a, b, updatedContentSize in - guard (CACurrentMediaTime() - initialUpdateTime) < 2 && lastSize != updatedContentSize else { - return true - } + when: { [weak self] numSections, numRowInSections, updatedContentSize in + // If too much time has passed or the section/row count doesn't match then + // just stop the callback + guard + (CACurrentMediaTime() - initialUpdateTime) < 2 && + lastSize != updatedContentSize && + numSections > targetIndexPath.section && + numRowInSections[targetIndexPath.section] > targetIndexPath.row + else { return true } lastSize = updatedContentSize diff --git a/Session/Open Groups/OpenGroupSuggestionGrid.swift b/Session/Open Groups/OpenGroupSuggestionGrid.swift index 81cf042b0..ee9e83ed7 100644 --- a/Session/Open Groups/OpenGroupSuggestionGrid.swift +++ b/Session/Open Groups/OpenGroupSuggestionGrid.swift @@ -245,7 +245,7 @@ extension OpenGroupSuggestionGrid { label.text = room.name // Only continue if we have a room image - guard let imageId: Int64 = room.imageId else { + guard let imageId: String = room.imageId else { imageView.isHidden = true return } diff --git a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift index 41d9109d9..a0c268851 100644 --- a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift +++ b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift @@ -141,7 +141,7 @@ public enum SMKLegacy { internal var sender: String? internal var groupPublicKey: String? internal var openGroupServerMessageID: UInt64? - internal var openGroupServerTimestamp: UInt64? + internal var openGroupServerTimestamp: UInt64? // Not used for anything internal var serverHash: String? // MARK: NSCoding @@ -175,7 +175,6 @@ public enum SMKLegacy { result.sender = self.sender result.groupPublicKey = self.groupPublicKey result.openGroupServerMessageId = self.openGroupServerMessageID - result.openGroupServerTimestamp = self.openGroupServerTimestamp result.serverHash = self.serverHash return result diff --git a/SessionMessagingKit/File Server/FileServerAPI.swift b/SessionMessagingKit/File Server/FileServerAPI.swift index c1aed7116..c92c9499e 100644 --- a/SessionMessagingKit/File Server/FileServerAPI.swift +++ b/SessionMessagingKit/File Server/FileServerAPI.swift @@ -41,11 +41,11 @@ public final class FileServerAPI: NSObject { .decoded(as: FileUploadResponse.self, on: .global(qos: .userInitiated)) } - public static func download(_ file: Int64, useOldServer: Bool) -> Promise { + public static func download(_ fileId: String, useOldServer: Bool) -> Promise { let serverPublicKey: String = (useOldServer ? oldServerPublicKey : serverPublicKey) let request = Request( server: (useOldServer ? oldServer : server), - endpoint: .fileIndividual(fileId: file) + endpoint: .fileIndividual(fileId: fileId) ) return send(request, serverPublicKey: serverPublicKey) diff --git a/SessionMessagingKit/File Server/Types/FSEndpoint.swift b/SessionMessagingKit/File Server/Types/FSEndpoint.swift index d2c9aa668..5e242bea8 100644 --- a/SessionMessagingKit/File Server/Types/FSEndpoint.swift +++ b/SessionMessagingKit/File Server/Types/FSEndpoint.swift @@ -5,7 +5,7 @@ import Foundation extension FileServerAPI { public enum Endpoint: EndpointType { case file - case fileIndividual(fileId: Int64) + case fileIndividual(fileId: String) case sessionVersion var path: String { diff --git a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift index 33f5e0b93..c420db071 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift @@ -87,8 +87,10 @@ public enum AttachmentDownloadJob: JobExecutor { let downloadPromise: Promise = { guard let downloadUrl: String = attachment.downloadUrl, - let fileAsString: String = downloadUrl.split(separator: "/").last.map({ String($0) }), - let file: Int64 = Int64(fileAsString) + let fileId: String = downloadUrl + .split(separator: "/") + .last + .map({ String($0) }) else { return Promise(error: AttachmentDownloadError.invalidUrl) } @@ -98,13 +100,13 @@ public enum AttachmentDownloadJob: JobExecutor { return nil // Not an open group so just use standard FileServer upload } - return OpenGroupAPI.downloadFile(db, fileId: file, from: openGroup.roomToken, on: openGroup.server) + return OpenGroupAPI.downloadFile(db, fileId: fileId, from: openGroup.roomToken, on: openGroup.server) .map { _, data in data } }) return ( maybeOpenGroupDownloadPromise ?? - FileServerAPI.download(file, useOldServer: downloadUrl.contains(FileServerAPI.oldServer)) + FileServerAPI.download(fileId, useOldServer: downloadUrl.contains(FileServerAPI.oldServer)) ) }() diff --git a/SessionMessagingKit/Messages/Message.swift b/SessionMessagingKit/Messages/Message.swift index 0f09de1e0..e33da9647 100644 --- a/SessionMessagingKit/Messages/Message.swift +++ b/SessionMessagingKit/Messages/Message.swift @@ -14,7 +14,6 @@ public class Message: Codable { public var sender: String? public var groupPublicKey: String? public var openGroupServerMessageId: UInt64? - public var openGroupServerTimestamp: UInt64? public var serverHash: String? public var ttl: UInt64 { 14 * 24 * 60 * 60 * 1000 } @@ -41,7 +40,6 @@ public class Message: Codable { sender: String? = nil, groupPublicKey: String? = nil, openGroupServerMessageId: UInt64? = nil, - openGroupServerTimestamp: UInt64? = nil, serverHash: String? = nil ) { self.id = id @@ -52,7 +50,6 @@ public class Message: Codable { self.sender = sender self.groupPublicKey = groupPublicKey self.openGroupServerMessageId = openGroupServerMessageId - self.openGroupServerTimestamp = openGroupServerTimestamp self.serverHash = serverHash } diff --git a/SessionMessagingKit/Open Groups/Models/Room.swift b/SessionMessagingKit/Open Groups/Models/Room.swift index c791e04dd..f1a2f32d6 100644 --- a/SessionMessagingKit/Open Groups/Models/Room.swift +++ b/SessionMessagingKit/Open Groups/Models/Room.swift @@ -70,7 +70,7 @@ extension OpenGroupAPI { /// File ID of an uploaded file containing the room's image /// /// Omitted if there is no image - public let imageId: Int64? + public let imageId: String? /// Array of pinned message information (omitted entirely if there are no pinned messages) public let pinnedMessages: [PinnedMessage]? @@ -150,6 +150,12 @@ extension OpenGroupAPI.Room { public init(from decoder: Decoder) throws { let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + // This logic is to future-proof the transition from int-based to string-based image ids + let maybeImageId: String? = ( + ((try? container.decode(Int64.self, forKey: .imageId)).map { "\($0)" }) ?? + (try? container.decode(String.self, forKey: .imageId)) + ) + self = OpenGroupAPI.Room( token: try container.decode(String.self, forKey: .token), name: try container.decode(String.self, forKey: .name), @@ -160,7 +166,7 @@ extension OpenGroupAPI.Room { activeUsers: try container.decode(Int64.self, forKey: .activeUsers), activeUsersCutoff: try container.decode(Int64.self, forKey: .activeUsersCutoff), - imageId: try? container.decode(Int64.self, forKey: .imageId), + imageId: maybeImageId, pinnedMessages: try? container.decode([OpenGroupAPI.PinnedMessage].self, forKey: .pinnedMessages), admin: ((try? container.decode(Bool.self, forKey: .admin)) ?? false), diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index bfc099aaf..65190d397 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -700,7 +700,7 @@ public enum OpenGroupAPI { public static func downloadFile( _ db: Database, - fileId: Int64, + fileId: String, from roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 856003cc3..3b4f5042b 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -446,10 +446,10 @@ public final class OpenGroupManager: NSObject { /// Start downloading the room image (if we don't have one or it's been updated) if - let imageId: Int64 = pollInfo.details?.imageId, + let imageId: String = pollInfo.details?.imageId, ( openGroup.imageData == nil || - openGroup.imageId != "\(imageId)" + openGroup.imageId != imageId ) { OpenGroupManager.roomImage(db, fileId: imageId, for: roomToken, on: server, using: dependencies) @@ -786,7 +786,7 @@ public final class OpenGroupManager: NSObject { .done(on: OpenGroupAPI.workQueue) { items in dependencies.storage.writeAsync { db in items - .compactMap { room -> (Int64, String)? in + .compactMap { room -> (String, String)? in // Try to insert an inactive version of the OpenGroup (use 'insert' rather than 'save' // as we want it to fail if the room already exists) do { @@ -797,7 +797,7 @@ public final class OpenGroupManager: NSObject { isActive: false, name: room.name, roomDescription: room.roomDescription, - imageId: room.imageId.map { "\($0)" }, + imageId: room.imageId, imageData: nil, userCount: room.activeUsers, infoUpdates: room.infoUpdates, @@ -809,7 +809,7 @@ public final class OpenGroupManager: NSObject { } catch {} - guard let imageId: Int64 = room.imageId else { return nil } + guard let imageId: String = room.imageId else { return nil } return (imageId, room.token) } @@ -845,7 +845,7 @@ public final class OpenGroupManager: NSObject { public static func roomImage( _ db: Database, - fileId: Int64, + fileId: String, for roomToken: String, on server: String, using dependencies: OGMDependencies = OGMDependencies() diff --git a/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift b/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift index 1c1ee52dc..052b2fe80 100644 --- a/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift +++ b/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift @@ -35,7 +35,7 @@ extension OpenGroupAPI { // Files case roomFile(String) - case roomFileIndividual(String, Int64) + case roomFileIndividual(String, String) // Inbox/Outbox (Message Requests) diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index 2eda1e7ab..4524b75d2 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -146,7 +146,6 @@ public enum MessageReceiver { message.sentTimestamp = envelope.timestamp message.receivedTimestamp = UInt64((Date().timeIntervalSince1970) * 1000) message.groupPublicKey = groupPublicKey - message.openGroupServerTimestamp = (isOpenGroupMessage ? envelope.serverTimestamp : nil) message.openGroupServerMessageId = openGroupMessageServerId.map { UInt64($0) } // Validate diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index e84f370d7..c20391a42 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -339,11 +339,17 @@ public final class MessageSender { .joined(separator: ".") } + // Note: It's possible to send a message and then delete the open group you sent the message to + // which would go into this case, so rather than handling it as an invalid state we just want to + // error in a non-retryable way guard let openGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: threadId), let userEdKeyPair: Box.KeyPair = Identity.fetchUserEd25519KeyPair(db), case .openGroup(let roomToken, let server, let whisperTo, let whisperMods, let fileIds) = destination - else { preconditionFailure() } + else { + seal.reject(MessageSenderError.invalidMessage) + return promise + } message.sender = { let capabilities: [Capability.Variant] = (try? Capability diff --git a/SessionMessagingKit/Utilities/ProfileManager.swift b/SessionMessagingKit/Utilities/ProfileManager.swift index ac25ce0f9..e163268a4 100644 --- a/SessionMessagingKit/Utilities/ProfileManager.swift +++ b/SessionMessagingKit/Utilities/ProfileManager.swift @@ -145,15 +145,15 @@ public struct ProfileManager { // Download already in flight; ignore return } - guard - let profileUrlStringAtStart: String = profile.profilePictureUrl, - let profileUrlAtStart: URL = URL(string: profileUrlStringAtStart) - else { + guard let profileUrlStringAtStart: String = profile.profilePictureUrl else { SNLog("Skipping downloading avatar for \(profile.id) because url is not set") return } guard - let fileId: Int64 = Int64(profileUrlAtStart.lastPathComponent), + let fileId: String = profileUrlStringAtStart + .split(separator: "/") + .last + .map({ String($0) }), let profileKeyAtStart: OWSAES256Key = profile.profileEncryptionKey, profileKeyAtStart.keyData.count > 0 else { diff --git a/SessionSnodeKit/Models/OnionRequestAPIError.swift b/SessionSnodeKit/Models/OnionRequestAPIError.swift index 6f555542c..3b75fe124 100644 --- a/SessionSnodeKit/Models/OnionRequestAPIError.swift +++ b/SessionSnodeKit/Models/OnionRequestAPIError.swift @@ -14,8 +14,11 @@ public enum OnionRequestAPIError: LocalizedError { public var errorDescription: String? { switch self { - case .httpRequestFailedAtDestination(let statusCode, _, let destination): + case .httpRequestFailedAtDestination(let statusCode, let data, let destination): if statusCode == 429 { return "Rate limited." } + if let errorResponse: String = String(data: data, encoding: .utf8) { + return "HTTP request failed at destination (\(destination)) with status code: \(statusCode), error body: \(errorResponse)." + } return "HTTP request failed at destination (\(destination)) with status code: \(statusCode)." diff --git a/SessionSnodeKit/Models/SnodeAPIEndpoint.swift b/SessionSnodeKit/Models/SnodeAPIEndpoint.swift index 1e46b10c0..1028740fe 100644 --- a/SessionSnodeKit/Models/SnodeAPIEndpoint.swift +++ b/SessionSnodeKit/Models/SnodeAPIEndpoint.swift @@ -10,4 +10,5 @@ public enum SnodeAPIEndpoint: String { case oxenDaemonRPCCall = "oxend_request" case getInfo = "info" case clearAllData = "delete_all" + case expire = "expire" } diff --git a/SessionSnodeKit/OnionRequestAPI.swift b/SessionSnodeKit/OnionRequestAPI.swift index ce6cc7b46..0d69334d4 100644 --- a/SessionSnodeKit/OnionRequestAPI.swift +++ b/SessionSnodeKit/OnionRequestAPI.swift @@ -650,8 +650,11 @@ public enum OnionRequestAPI: OnionRequestAPIType { } if let bodyAsString = json["body"] as? String { - guard let bodyAsData = bodyAsString.data(using: .utf8), let body = try JSONSerialization.jsonObject(with: bodyAsData, options: [ .fragmentsAllowed ]) as? JSON else { - return seal.reject(HTTP.Error.invalidJSON) + guard let bodyAsData = bodyAsString.data(using: .utf8) else { + return seal.reject(HTTP.Error.invalidResponse) + } + guard let body = try? JSONSerialization.jsonObject(with: bodyAsData, options: [ .fragmentsAllowed ]) as? JSON else { + return seal.reject(OnionRequestAPIError.httpRequestFailedAtDestination(statusCode: UInt(statusCode), data: bodyAsData, destination: destination)) } if let timestamp = body["t"] as? Int64 { diff --git a/SessionSnodeKit/SnodeAPI.swift b/SessionSnodeKit/SnodeAPI.swift index 5a4af04dc..2e5b5fc1f 100644 --- a/SessionSnodeKit/SnodeAPI.swift +++ b/SessionSnodeKit/SnodeAPI.swift @@ -719,6 +719,99 @@ public final class SnodeAPI { return promise } + // MARK: Edit + + public static func updateExpiry( + publicKey: String, + edKeyPair: Box.KeyPair, + updatedExpiryMs: UInt64, + serverHashes: [String] + ) -> Promise<[String: (hashes: [String], expiry: UInt64)]> { + let publicKey = (Features.useTestnet ? publicKey.removingIdPrefixIfNeeded() : publicKey) + + return attempt(maxRetryCount: maxRetryCount, recoveringOn: Threading.workQueue) { + getSwarm(for: publicKey) + .then2 { swarm -> Promise<[String: (hashes: [String], expiry: UInt64)]> in + // "expire" || expiry || messages[0] || ... || messages[N] + let verificationBytes = SnodeAPIEndpoint.expire.rawValue.bytes + .appending(contentsOf: "\(updatedExpiryMs)".data(using: .ascii)?.bytes) + .appending(contentsOf: serverHashes.joined().bytes) + + guard + let snode = swarm.randomElement(), + let signature = sodium.sign.signature( + message: verificationBytes, + secretKey: edKeyPair.secretKey + ) + else { + throw SnodeAPIError.signingFailed + } + + let parameters: JSON = [ + "pubkey" : publicKey, + "pubkey_ed25519" : edKeyPair.publicKey.toHexString(), + "expiry": updatedExpiryMs, + "messages": serverHashes, + "signature": signature.toBase64() + ] + + return attempt(maxRetryCount: maxRetryCount, recoveringOn: Threading.workQueue) { + invoke(.expire, on: snode, associatedWith: publicKey, parameters: parameters) + .map2 { responseData -> [String: (hashes: [String], expiry: UInt64)] in + guard let responseJson: JSON = try? JSONSerialization.jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? JSON else { + throw HTTP.Error.invalidJSON + } + guard let swarm = responseJson["swarm"] as? JSON else { throw HTTP.Error.invalidJSON } + + var result: [String: (hashes: [String], expiry: UInt64)] = [:] + + for (snodePublicKey, rawJSON) in swarm { + guard let json = rawJSON as? JSON else { throw HTTP.Error.invalidJSON } + guard (json["failed"] as? Bool ?? false) == false else { + if let reason = json["reason"] as? String, let statusCode = json["code"] as? String { + SNLog("Couldn't delete data from: \(snodePublicKey) due to error: \(reason) (\(statusCode)).") + } + else { + SNLog("Couldn't delete data from: \(snodePublicKey).") + } + result[snodePublicKey] = ([], 0) + continue + } + + guard + let hashes: [String] = json["updated"] as? [String], + let expiryApplied: UInt64 = json["expiry"] as? UInt64, + let signature: String = json["signature"] as? String + else { + throw HTTP.Error.invalidJSON + } + + // The signature format is ( PUBKEY_HEX || EXPIRY || RMSG[0] || ... || RMSG[N] || UMSG[0] || ... || UMSG[M] ) + let verificationBytes = publicKey.bytes + .appending(contentsOf: "\(expiryApplied)".data(using: .ascii)?.bytes) + .appending(contentsOf: serverHashes.joined().bytes) + .appending(contentsOf: hashes.joined().bytes) + let isValid = sodium.sign.verify( + message: verificationBytes, + publicKey: Bytes(Data(hex: snodePublicKey)), + signature: Bytes(Data(base64Encoded: signature)!) + ) + + // Ensure the signature is valid + guard isValid else { + throw SnodeAPIError.signatureVerificationFailed + } + + result[snodePublicKey] = (hashes, expiryApplied) + } + + return result + } + } + } + } + } + // MARK: Delete public static func deleteMessage(publicKey: String, serverHashes: [String]) -> Promise<[String: Bool]> { @@ -732,10 +825,16 @@ public final class SnodeAPI { return attempt(maxRetryCount: maxRetryCount, recoveringOn: Threading.workQueue) { getSwarm(for: publicKey) .then2 { swarm -> Promise<[String: Bool]> in + // "delete" || messages... + let verificationBytes = SnodeAPIEndpoint.deleteMessage.rawValue.bytes + .appending(contentsOf: serverHashes.joined().bytes) + guard let snode = swarm.randomElement(), - let verificationData = (SnodeAPIEndpoint.deleteMessage.rawValue + serverHashes.joined()).data(using: String.Encoding.utf8), - let signature = sodium.sign.signature(message: Bytes(verificationData), secretKey: userED25519KeyPair.secretKey) + let signature = sodium.sign.signature( + message: verificationBytes, + secretKey: userED25519KeyPair.secretKey + ) else { throw SnodeAPIError.signingFailed } @@ -771,15 +870,11 @@ public final class SnodeAPI { } // The signature format is ( PUBKEY_HEX || RMSG[0] || ... || RMSG[N] || DMSG[0] || ... || DMSG[M] ) - let verificationData = [ - userX25519PublicKey, - serverHashes.joined(), - hashes.joined() - ] - .joined() - .data(using: String.Encoding.utf8)! + let verificationBytes = userX25519PublicKey.bytes + .appending(contentsOf: serverHashes.joined().bytes) + .appending(contentsOf: hashes.joined().bytes) let isValid = sodium.sign.verify( - message: Bytes(verificationData), + message: verificationBytes, publicKey: Bytes(Data(hex: snodePublicKey)), signature: Bytes(Data(base64Encoded: signature)!) ) diff --git a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift index 16d482baf..1317224f0 100644 --- a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift +++ b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift @@ -411,7 +411,7 @@ public class PagedDatabaseObserver: TransactionObserver where guard targetIndex > halfPageSize else { return 0 } guard targetIndex < (totalCount - halfPageSize) else { - return (totalCount - currentPageInfo.pageSize) + return max(0, (totalCount - currentPageInfo.pageSize)) } return (targetIndex - halfPageSize) diff --git a/SessionUtilitiesKit/General/SessionId.swift b/SessionUtilitiesKit/General/SessionId.swift index 3f81bc6b6..7e251876e 100644 --- a/SessionUtilitiesKit/General/SessionId.swift +++ b/SessionUtilitiesKit/General/SessionId.swift @@ -7,8 +7,8 @@ import Curve25519Kit public struct SessionId { public enum Prefix: String, CaseIterable { case standard = "05" // Used for identified users, open groups, etc. - case blinded = "15" // Used for participants in open groups with blinding enabled - case unblinded = "00" // Used for participants in open groups with blinding disabled + case blinded = "15" // Used for authentication and participants in open groups with blinding enabled + case unblinded = "00" // Used for authentication in open groups with blinding disabled public init?(from stringValue: String?) { guard let stringValue: String = stringValue else { return nil } From 3c07a2d044852762d77b2203904b471b8c667446 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 15 Jul 2022 18:15:28 +1000 Subject: [PATCH 135/157] Added linting for the localized strings, updated the quote & mention behaviour for the current user Added a script and build step to error if we have localised a string in code bug don't have an entry in the localisable files Added the logic and UI to replace the current users public key (or blinded key) with 'You' in mentions and quotes Cleaned up some duplicate & missing localised strings Fixed a bug where new closed groups weren't getting setup locally correctly Updated the id truncating behaviour to always truncate from the middle --- Scripts/LintLocalizableStrings.swift | 251 ++++++++++++++++++ Session.xcodeproj/project.pbxproj | 36 +++ .../Call Management/SessionCallManager.swift | 3 +- .../Conversations/ConversationViewModel.swift | 16 +- .../Conversations/Input View/InputView.swift | 2 + .../Content Views/QuoteView.swift | 24 +- .../Message Cells/VisibleMessageCell.swift | 4 + .../Views & Modals/BodyTextView.swift | 28 ++ Session/Home/HomeViewModel.swift | 12 + .../MessageRequestsViewModel.swift | 13 + .../GIFs/GifPickerViewController.swift | 2 +- .../Translations/de.lproj/Localizable.strings | 29 +- .../Translations/en.lproj/Localizable.strings | 30 ++- .../Translations/es.lproj/Localizable.strings | 29 +- .../Translations/fa.lproj/Localizable.strings | 29 +- .../Translations/fi.lproj/Localizable.strings | 29 +- .../Translations/fr.lproj/Localizable.strings | 29 +- .../Translations/hi.lproj/Localizable.strings | 29 +- .../Translations/hr.lproj/Localizable.strings | 29 +- .../id-ID.lproj/Localizable.strings | 29 +- .../Translations/it.lproj/Localizable.strings | 29 +- .../Translations/ja.lproj/Localizable.strings | 29 +- .../Translations/nl.lproj/Localizable.strings | 29 +- .../Translations/pl.lproj/Localizable.strings | 29 +- .../pt_BR.lproj/Localizable.strings | 29 +- .../Translations/ru.lproj/Localizable.strings | 29 +- .../Translations/si.lproj/Localizable.strings | 29 +- .../Translations/sk.lproj/Localizable.strings | 29 +- .../Translations/sv.lproj/Localizable.strings | 29 +- .../Translations/th.lproj/Localizable.strings | 29 +- .../vi-VN.lproj/Localizable.strings | 29 +- .../zh-Hant.lproj/Localizable.strings | 29 +- .../zh_CN.lproj/Localizable.strings | 29 +- Session/Notifications/AppNotifications.swift | 10 +- Session/Onboarding/LinkDeviceVC.swift | 6 +- Session/Settings/QRCodeVC.swift | 4 +- Session/Shared/FullConversationCell.swift | 16 +- .../HighlightMentionBackgroundView.swift | 161 +++++++++++ Session/Shared/ScanQRCodeWrapperVC.swift | 2 +- Session/Utilities/MentionUtilities.swift | 65 ++++- .../Database/Models/Profile.swift | 9 +- .../Database/Models/SessionThread.swift | 34 +++ .../MessageSender+ClosedGroups.swift | 6 +- .../Shared Models/MessageViewModel.swift | 27 +- .../SessionThreadViewModel.swift | 63 +++++ SessionSnodeKit/Models/SnodeAPIEndpoint.swift | 2 + SessionUIKit/Components/SearchBar.swift | 2 +- SessionUtilitiesKit/Crypto/Mnemonic.swift | 10 +- .../AttachmentTextToolbar.swift | 2 +- .../Screen Lock/OWSScreenLock.swift | 29 +- .../Utilities/CommonStrings.swift | 42 +-- SignalUtilitiesKit/Utilities/OWSAlerts.swift | 19 -- 52 files changed, 1369 insertions(+), 170 deletions(-) create mode 100755 Scripts/LintLocalizableStrings.swift create mode 100644 Session/Shared/HighlightMentionBackgroundView.swift diff --git a/Scripts/LintLocalizableStrings.swift b/Scripts/LintLocalizableStrings.swift new file mode 100755 index 000000000..3f0860735 --- /dev/null +++ b/Scripts/LintLocalizableStrings.swift @@ -0,0 +1,251 @@ +#!/usr/bin/xcrun --sdk macosx swift + +// +// ListLocalizableStrings.swift +// Archa +// +// Created by Morgan Pretty on 18/5/20. +// Copyright © 2020 Archa. All rights reserved. +// +// This script is based on https://github.com/ginowu7/CleanSwiftLocalizableExample the main difference +// is canges to the localized usage regex + +import Foundation + +let fileManager = FileManager.default +let currentPath = ( + ProcessInfo.processInfo.environment["PROJECT_DIR"] ?? fileManager.currentDirectoryPath +) + +/// List of files in currentPath - recursive +var pathFiles: [String] = { + guard let enumerator = fileManager.enumerator(atPath: currentPath), let files = enumerator.allObjects as? [String] else { + fatalError("Could not locate files in path directory: \(currentPath)") + } + + return files +}() + + +/// List of localizable files - not including Localizable files in the Pods +var localizableFiles: [String] = { + return pathFiles + .filter { + $0.hasSuffix("Localizable.strings") && + !$0.contains(".app/") && // Exclude Built Localizable.strings files + !$0.contains("Pods") // Exclude Pods + } +}() + + +/// List of executable files +var executableFiles: [String] = { + return pathFiles.filter { + !$0.localizedCaseInsensitiveContains("test") && // Exclude test files + !$0.contains(".app/") && // Exclude Built Localizable.strings files + !$0.contains("Pods") && // Exclude Pods + ( + NSString(string: $0).pathExtension == "swift" || + NSString(string: $0).pathExtension == "m" + ) + } +}() + +/// Reads contents in path +/// +/// - Parameter path: path of file +/// - Returns: content in file +func contents(atPath path: String) -> String { + print("Path: \(path)") + guard let data = fileManager.contents(atPath: path), let content = String(data: data, encoding: .utf8) else { + fatalError("Could not read from path: \(path)") + } + + return content +} + +/// Returns a list of strings that match regex pattern from content +/// +/// - Parameters: +/// - pattern: regex pattern +/// - content: content to match +/// - Returns: list of results +func regexFor(_ pattern: String, content: String) -> [String] { + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { + fatalError("Regex not formatted correctly: \(pattern)") + } + + let matches = regex.matches(in: content, options: [], range: NSRange(location: 0, length: content.utf16.count)) + + return matches.map { + guard let range = Range($0.range(at: 0), in: content) else { + fatalError("Incorrect range match") + } + + return String(content[range]) + } +} + +func create() -> [LocalizationStringsFile] { + return localizableFiles.map(LocalizationStringsFile.init(path:)) +} + +/// +/// +/// - Returns: A list of LocalizationCodeFile - contains path of file and all keys in it +func localizedStringsInCode() -> [LocalizationCodeFile] { + return executableFiles.compactMap { + let content = contents(atPath: $0) + // Note: Need to exclude escaped quotation marks from strings + let matchesOld = regexFor("(?<=NSLocalizedString\\()\\s*\"(?!.*?%d)(.*?)\"", content: content) + let matchesNew = regexFor("\"(?!.*?%d)([^(\\\")]*?)\"(?=\\s*)(?=\\.localized)", content: content) + let allMatches = (matchesOld + matchesNew) + + return allMatches.isEmpty ? nil : LocalizationCodeFile(path: $0, keys: Set(allMatches)) + } +} + +/// Throws error if ALL localizable files does not have matching keys +/// +/// - Parameter files: list of localizable files to validate +func validateMatchKeys(_ files: [LocalizationStringsFile]) { + print("------------ Validating keys match in all localizable files ------------") + + guard let base = files.first, files.count > 1 else { return } + + let files = Array(files.dropFirst()) + + files.forEach { + guard let extraKey = Set(base.keys).symmetricDifference($0.keys).first else { return } + let incorrectFile = $0.keys.contains(extraKey) ? $0 : base + printPretty("error: Found extra key: \(extraKey) in file: \(incorrectFile.path)") + } +} + +/// Throws error if localizable files are missing keys +/// +/// - Parameters: +/// - codeFiles: Array of LocalizationCodeFile +/// - localizationFiles: Array of LocalizableStringFiles +func validateMissingKeys(_ codeFiles: [LocalizationCodeFile], localizationFiles: [LocalizationStringsFile]) { + print("------------ Checking for missing keys -----------") + + guard let baseFile = localizationFiles.first else { + fatalError("Could not locate base localization file") + } + + let baseKeys = Set(baseFile.keys) + + codeFiles.forEach { + let extraKeys = $0.keys.subtracting(baseKeys) + if !extraKeys.isEmpty { + printPretty("error: Found keys in code missing in strings file: \(extraKeys) from \($0.path)") + } + } +} + +/// Throws warning if keys exist in localizable file but are not being used +/// +/// - Parameters: +/// - codeFiles: Array of LocalizationCodeFile +/// - localizationFiles: Array of LocalizableStringFiles +func validateDeadKeys(_ codeFiles: [LocalizationCodeFile], localizationFiles: [LocalizationStringsFile]) { + print("------------ Checking for any dead keys in localizable file -----------") + + guard let baseFile = localizationFiles.first else { + fatalError("Could not locate base localization file") + } + + let baseKeys: Set = Set(baseFile.keys) + let allCodeFileKeys: [String] = codeFiles.flatMap { $0.keys } + let deadKeys: [String] = Array(baseKeys.subtracting(allCodeFileKeys)) + .sorted() + .map { $0.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) } + + if !deadKeys.isEmpty { + printPretty("warning: \(deadKeys) - Suggest cleaning dead keys") + } +} + +protocol Pathable { + var path: String { get } +} + +struct LocalizationStringsFile: Pathable { + let path: String + let kv: [String: String] + + var keys: [String] { + return Array(kv.keys) + } + + init(path: String) { + self.path = path + self.kv = ContentParser.parse(path) + } + + /// Writes back to localizable file with sorted keys and removed whitespaces and new lines + func cleanWrite() { + print("------------ Sort and remove whitespaces: \(path) ------------") + let content = kv.keys.sorted().map { "\($0) = \(kv[$0]!);" }.joined(separator: "\n") + try! content.write(toFile: path, atomically: true, encoding: .utf8) + } + +} + +struct LocalizationCodeFile: Pathable { + let path: String + let keys: Set +} + +struct ContentParser { + + /// Parses contents of a file to localizable keys and values - Throws error if localizable file have duplicated keys + /// + /// - Parameter path: Localizable file paths + /// - Returns: localizable key and value for content at path + static func parse(_ path: String) -> [String: String] { + print("------------ Checking for duplicate keys: \(path) ------------") + + let content = contents(atPath: path) + let trimmed = content + .replacingOccurrences(of: "\n+", with: "", options: .regularExpression, range: nil) + .trimmingCharacters(in: .whitespacesAndNewlines) + let keys = regexFor("\"([^\"]*?)\"(?= =)", content: trimmed) + let values = regexFor("(?<== )\"(.*?)\"(?=;)", content: trimmed) + + if keys.count != values.count { + fatalError("Error parsing contents: Make sure all keys and values are in correct format (this could be due to extra spaces between keys and values)") + } + + return zip(keys, values).reduce(into: [String: String]()) { results, keyValue in + if results[keyValue.0] != nil { + printPretty("error: Found duplicate key: \(keyValue.0) in file: \(path)") + abort() + } + results[keyValue.0] = keyValue.1 + } + } +} + +func printPretty(_ string: String) { + print(string.replacingOccurrences(of: "\\", with: "")) +} + +let stringFiles = create() + +if !stringFiles.isEmpty { + print("------------ Found \(stringFiles.count) file(s) ------------") + + stringFiles.forEach { print($0.path) } + validateMatchKeys(stringFiles) + + // Note: Uncomment the below file to clean out all comments from the localizable file (we don't want this because comments make it readable...) + // stringFiles.forEach { $0.cleanWrite() } + + let codeFiles = localizedStringsInCode() + validateMissingKeys(codeFiles, localizationFiles: stringFiles) + validateDeadKeys(codeFiles, localizationFiles: stringFiles) +} + +print("------------ SUCCESS ------------") diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index c6c3c63e1..4ed98f633 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -781,6 +781,7 @@ FDD250702837199200198BDA /* GarbageCollectionJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD2506F2837199200198BDA /* GarbageCollectionJob.swift */; }; FDD250722837234B00198BDA /* MediaGalleryNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD250712837234B00198BDA /* MediaGalleryNavigationController.swift */; }; FDE72118286C156E0093DF33 /* ChatSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE72117286C156E0093DF33 /* ChatSettingsViewController.swift */; }; + FDE72154287FE4470093DF33 /* HighlightMentionBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE72153287FE4470093DF33 /* HighlightMentionBackgroundView.swift */; }; FDE77F6B280FEB28002CFC5D /* ControlMessageProcessRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */; }; FDED2E3C282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDED2E3B282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift */; }; FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */; }; @@ -1811,6 +1812,9 @@ FDD2506F2837199200198BDA /* GarbageCollectionJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarbageCollectionJob.swift; sourceTree = ""; }; FDD250712837234B00198BDA /* MediaGalleryNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGalleryNavigationController.swift; sourceTree = ""; }; FDE72117286C156E0093DF33 /* ChatSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatSettingsViewController.swift; sourceTree = ""; }; + FDE7214F287E50D50093DF33 /* ProtoWrappers.py */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.python; path = ProtoWrappers.py; sourceTree = ""; }; + FDE72150287E50D50093DF33 /* LintLocalizableStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LintLocalizableStrings.swift; sourceTree = ""; }; + FDE72153287FE4470093DF33 /* HighlightMentionBackgroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightMentionBackgroundView.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 = ""; }; FDED2E3B282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UICollectionView+ReusableView.swift"; sourceTree = ""; }; @@ -2436,6 +2440,7 @@ 4C4AEC4420EC343B0020E72B /* DismissableTextField.swift */, 45E5A6981F61E6DD001E4A8A /* MarqueeLabel.swift */, 34386A53207D271C009F5D9C /* NeverClearView.swift */, + FDE72153287FE4470093DF33 /* HighlightMentionBackgroundView.swift */, 34F308A01ECB469700BB7697 /* OWSBezierPathView.h */, 34F308A11ECB469700BB7697 /* OWSBezierPathView.m */, 34330AA11E79686200DF2FB9 /* OWSProgressView.h */, @@ -3306,6 +3311,7 @@ FD83B9BC27CF2215005E1583 /* SharedTest */, FDC4388F27B9FFC700C60D73 /* SessionMessagingKitTests */, FD83B9B027CF200A005E1583 /* SessionUtilitiesKitTests */, + FDE7214E287E50D50093DF33 /* Scripts */, D221A08C169C9E5E00537ABF /* Frameworks */, D221A08A169C9E5E00537ABF /* Products */, 2BADBA206E0B8D297E313FBA /* Pods */, @@ -3871,6 +3877,15 @@ path = Utilities; sourceTree = ""; }; + FDE7214E287E50D50093DF33 /* Scripts */ = { + isa = PBXGroup; + children = ( + FDE7214F287E50D50093DF33 /* ProtoWrappers.py */, + FDE72150287E50D50093DF33 /* LintLocalizableStrings.swift */, + ); + path = Scripts; + sourceTree = ""; + }; FDF0B7452804F0A8004C14C5 /* Types */ = { isa = PBXGroup; children = ( @@ -4141,6 +4156,7 @@ buildConfigurationList = D221A0BC169C9E5F00537ABF /* Build configuration list for PBXNativeTarget "Session" */; buildPhases = ( 0401967CF3320CC84B175A3B /* [CP] Check Pods Manifest.lock */, + FDE7214D287E50820093DF33 /* Lint Localizable.strings */, D221A085169C9E5E00537ABF /* Sources */, D221A086169C9E5E00537ABF /* Frameworks */, D221A087169C9E5E00537ABF /* Resources */, @@ -4768,6 +4784,25 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + FDE7214D287E50820093DF33 /* Lint Localizable.strings */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Lint Localizable.strings"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${SRCROOT}/Scripts/LintLocalizableStrings.swift\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -5261,6 +5296,7 @@ 7B1581E6271FD2A100848B49 /* VideoPreviewVC.swift in Sources */, B83F2B88240CB75A000A54AB /* UIImage+Scaling.swift in Sources */, 3430FE181F7751D4000EC51B /* GiphyAPI.swift in Sources */, + FDE72154287FE4470093DF33 /* HighlightMentionBackgroundView.swift in Sources */, 340FC8AA204DAC8D007AEB0F /* NotificationSettingsViewController.m in Sources */, 4C090A1B210FD9C7001FD7F9 /* HapticFeedback.swift in Sources */, 34F308A21ECB469700BB7697 /* OWSBezierPathView.m in Sources */, diff --git a/Session/Calls/Call Management/SessionCallManager.swift b/Session/Calls/Call Management/SessionCallManager.swift index b21d98f13..30dbcdfa0 100644 --- a/Session/Calls/Call Management/SessionCallManager.swift +++ b/Session/Calls/Call Management/SessionCallManager.swift @@ -40,8 +40,7 @@ public final class SessionCallManager: NSObject, CallManagerProtocol { } static func buildProviderConfiguration(useSystemCallLog: Bool) -> CXProviderConfiguration { - let localizedName = NSLocalizedString("APPLICATION_NAME", comment: "Name of application") - let providerConfiguration = CXProviderConfiguration(localizedName: localizedName) + let providerConfiguration = CXProviderConfiguration(localizedName: "Session") providerConfiguration.supportsVideo = true providerConfiguration.maximumCallGroups = 1 providerConfiguration.maximumCallsPerCallGroup = 1 diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 4ab71302e..4cc9d64a7 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -86,7 +86,10 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { // also want to skip the initial query and trigger it async so that the push animation // doesn't stutter (it should load basically immediately but without this there is a // distinct stutter) - self.pagedDataObserver = self.setupPagedObserver(for: threadId) + self.pagedDataObserver = self.setupPagedObserver( + for: threadId, + userPublicKey: getUserHexEncodedPublicKey() + ) // Run the initial query on a background thread so we don't block the push transition DispatchQueue.global(qos: .default).async { [weak self] in @@ -164,7 +167,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { } } - private func setupPagedObserver(for threadId: String) -> PagedDatabaseObserver { + private func setupPagedObserver(for threadId: String, userPublicKey: String) -> PagedDatabaseObserver { return PagedDatabaseObserver( pagedTable: Interaction.self, pageSize: ConversationViewModel.pageSize, @@ -201,6 +204,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { groupSQL: MessageViewModel.groupSQL, orderSQL: MessageViewModel.orderSQL, dataQuery: MessageViewModel.baseQuery( + userPublicKey: userPublicKey, orderSQL: MessageViewModel.orderSQL, groupSQL: MessageViewModel.groupSQL ), @@ -316,7 +320,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { // it's the last element in the 'sortedData' array index == (sortedData.count - 1) && pageInfo.pageOffset == 0 - ) + ), + currentUserBlindedPublicKey: threadData.currentUserBlindedPublicKey ) } .appending(typingIndicator) @@ -491,7 +496,10 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { self.threadId = updatedThreadId self.observableThreadData = self.setupObservableThreadData(for: updatedThreadId) - self.pagedDataObserver = self.setupPagedObserver(for: updatedThreadId) + self.pagedDataObserver = self.setupPagedObserver( + for: updatedThreadId, + userPublicKey: getUserHexEncodedPublicKey() + ) // Try load everything up to the initial visible message, fallback to just the initial page of messages // if we don't have one diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift index a61783967..474c4d6db 100644 --- a/Session/Conversations/Input View/InputView.swift +++ b/Session/Conversations/Input View/InputView.swift @@ -228,6 +228,8 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M authorId: quoteDraftInfo.model.authorId, quotedText: quoteDraftInfo.model.body, threadVariant: threadVariant, + currentUserPublicKey: nil, + currentUserBlindedPublicKey: nil, direction: (quoteDraftInfo.isOutgoing ? .outgoing : .incoming), attachment: quoteDraftInfo.model.attachment, hInset: hInset, diff --git a/Session/Conversations/Message Cells/Content Views/QuoteView.swift b/Session/Conversations/Message Cells/Content Views/QuoteView.swift index 16b91f6ca..2a47a91d9 100644 --- a/Session/Conversations/Message Cells/Content Views/QuoteView.swift +++ b/Session/Conversations/Message Cells/Content Views/QuoteView.swift @@ -28,6 +28,8 @@ final class QuoteView: UIView { authorId: String, quotedText: String?, threadVariant: SessionThread.Variant, + currentUserPublicKey: String?, + currentUserBlindedPublicKey: String?, direction: Direction, attachment: Attachment?, hInset: CGFloat, @@ -43,6 +45,8 @@ final class QuoteView: UIView { authorId: authorId, quotedText: quotedText, threadVariant: threadVariant, + currentUserPublicKey: currentUserPublicKey, + currentUserBlindedPublicKey: currentUserBlindedPublicKey, direction: direction, attachment: attachment, hInset: hInset, @@ -63,6 +67,8 @@ final class QuoteView: UIView { authorId: String, quotedText: String?, threadVariant: SessionThread.Variant, + currentUserPublicKey: String?, + currentUserBlindedPublicKey: String?, direction: Direction, attachment: Attachment?, hInset: CGFloat, @@ -190,6 +196,8 @@ final class QuoteView: UIView { MentionUtilities.highlightMentions( in: $0, threadVariant: threadVariant, + currentUserPublicKey: currentUserPublicKey, + currentUserBlindedPublicKey: currentUserBlindedPublicKey, isOutgoingMessage: isOutgoing, attributes: [:] ) @@ -207,11 +215,21 @@ final class QuoteView: UIView { // Label stack view var authorLabelHeight: CGFloat? if threadVariant == .openGroup || threadVariant == .closedGroup { + let isCurrentUser: Bool = [ + currentUserPublicKey, + currentUserBlindedPublicKey, + ] + .compactMap { $0 } + .asSet() + .contains(authorId) let authorLabel = UILabel() authorLabel.lineBreakMode = .byTruncatingTail - authorLabel.text = Profile.displayName( - id: authorId, - threadVariant: threadVariant + authorLabel.text = (isCurrentUser ? + "MEDIA_GALLERY_SENDER_NAME_YOU".localized() : + Profile.displayName( + id: authorId, + threadVariant: threadVariant + ) ) authorLabel.textColor = textColor authorLabel.font = .boldSystemFont(ofSize: Values.smallFontSize) diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 248ee419a..ccb099d8e 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -459,6 +459,8 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel authorId: quote.authorId, quotedText: quote.body, threadVariant: cellViewModel.threadVariant, + currentUserPublicKey: cellViewModel.currentUserPublicKey, + currentUserBlindedPublicKey: cellViewModel.currentUserBlindedPublicKey, direction: (cellViewModel.variant == .standardOutgoing ? .outgoing : .incoming @@ -956,6 +958,8 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel attributedString: MentionUtilities.highlightMentions( in: (cellViewModel.body ?? ""), threadVariant: cellViewModel.threadVariant, + currentUserPublicKey: cellViewModel.currentUserPublicKey, + currentUserBlindedPublicKey: cellViewModel.currentUserBlindedPublicKey, isOutgoingMessage: isOutgoing, attributes: [ .foregroundColor : textColor, diff --git a/Session/Conversations/Views & Modals/BodyTextView.swift b/Session/Conversations/Views & Modals/BodyTextView.swift index d329bd972..358333594 100644 --- a/Session/Conversations/Views & Modals/BodyTextView.swift +++ b/Session/Conversations/Views & Modals/BodyTextView.swift @@ -9,10 +9,29 @@ import UIKit // • The long press interaction that shows the context menu should still work final class BodyTextView: UITextView { private let snDelegate: BodyTextViewDelegate? + private let highlightedMentionBackgroundView: HighlightMentionBackgroundView = HighlightMentionBackgroundView() + + override var attributedText: NSAttributedString! { + didSet { + guard attributedText != nil else { return } + + highlightedMentionBackgroundView.maxPadding = highlightedMentionBackgroundView + .calculateMaxPadding(for: attributedText) + highlightedMentionBackgroundView.frame = self.bounds.insetBy( + dx: -highlightedMentionBackgroundView.maxPadding, + dy: -highlightedMentionBackgroundView.maxPadding + ) + } + } init(snDelegate: BodyTextViewDelegate?) { self.snDelegate = snDelegate + super.init(frame: CGRect.zero, textContainer: nil) + + self.clipsToBounds = false // Needed for the 'HighlightMentionBackgroundView' + addSubview(highlightedMentionBackgroundView) + setUpGestureRecognizers() } @@ -39,6 +58,15 @@ final class BodyTextView: UITextView { @objc private func handleDoubleTap() { // Do nothing } + + override func layoutSubviews() { + super.layoutSubviews() + + highlightedMentionBackgroundView.frame = self.bounds.insetBy( + dx: -highlightedMentionBackgroundView.maxPadding, + dy: -highlightedMentionBackgroundView.maxPadding + ) + } } protocol BodyTextViewDelegate { diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 72505ee99..a6c99443b 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -252,6 +252,11 @@ public class HomeViewModel { 0 : self.state.unreadMessageRequestThreadCount ) + let groupedOldData: [String: [SessionThreadViewModel]] = (self.threadData + .first(where: { $0.model == .threads })? + .elements) + .defaulting(to: []) + .grouped(by: \.threadId) return [ // If there are no unread message requests then hide the message request banner @@ -275,6 +280,13 @@ public class HomeViewModel { return lhs.lastInteractionDate > rhs.lastInteractionDate } + .map { viewModel -> SessionThreadViewModel in + viewModel.populatingCurrentUserBlindedKey( + currentUserBlindedPublicKeyForThisThread: groupedOldData[viewModel.threadId]? + .first? + .currentUserBlindedPublicKey + ) + } ) ], (!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ? diff --git a/Session/Home/Message Requests/MessageRequestsViewModel.swift b/Session/Home/Message Requests/MessageRequestsViewModel.swift index 96a7c69ad..c19ff8538 100644 --- a/Session/Home/Message Requests/MessageRequestsViewModel.swift +++ b/Session/Home/Message Requests/MessageRequestsViewModel.swift @@ -140,12 +140,25 @@ public class MessageRequestsViewModel { } private func process(data: [SessionThreadViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] { + let groupedOldData: [String: [SessionThreadViewModel]] = (self.threadData + .first(where: { $0.model == .threads })? + .elements) + .defaulting(to: []) + .grouped(by: \.threadId) + return [ [ SectionModel( section: .threads, elements: data .sorted { lhs, rhs -> Bool in lhs.lastInteractionDate > rhs.lastInteractionDate } + .map { viewModel -> SessionThreadViewModel in + viewModel.populatingCurrentUserBlindedKey( + currentUserBlindedPublicKeyForThisThread: groupedOldData[viewModel.threadId]? + .first? + .currentUserBlindedPublicKey + ) + } ) ], (!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ? diff --git a/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift b/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift index 980e985c6..ce215af26 100644 --- a/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift +++ b/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift @@ -105,7 +105,7 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect // Loki: Customize title let titleLabel = UILabel() - titleLabel.text = NSLocalizedString("GIF", comment: "") + titleLabel.text = "accessibility_gif_button".localized().uppercased() titleLabel.textColor = Colors.text titleLabel.font = .boldSystemFont(ofSize: Values.veryLargeFontSize) navigationItem.titleView = titleLabel diff --git a/Session/Meta/Translations/de.lproj/Localizable.strings b/Session/Meta/Translations/de.lproj/Localizable.strings index 764cc21f7..0138af276 100644 --- a/Session/Meta/Translations/de.lproj/Localizable.strings +++ b/Session/Meta/Translations/de.lproj/Localizable.strings @@ -606,7 +606,6 @@ "accessibility_camera_button" = "Kamera"; "accessibility_main_button_collapse" = "Optionen für Anhänge einklappen"; "invalid_recovery_phrase" = "Ungültige Wiederherstellungsphrase"; -"invalid_recovery_phrase" = "Ungültige Wiederherstellungsphrase"; "DISMISS_BUTTON_TEXT" = "Verwerfen"; /* Button text which opens the settings app */ "OPEN_SETTINGS_BUTTON" = "Einstellungen"; @@ -653,10 +652,36 @@ "modal_call_permission_request_explanation" = "You can enable the 'Voice and video calls' permission in the Privacy Settings."; "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; -"ALERT_ERROR_TITLE" = "Fehler"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; "MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; +"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LAST_WORD" = "You seem to be missing the last word of your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_INVALID_WORD" = "There appears to be an invalid word in your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_FAILED" = "Your recovery phrase couldn't be verified. Please check what you entered and try again."; +/* Indicates that an unknown error occurred while using Touch ID/Face ID/Phone Passcode. */ +"SCREEN_LOCK_ENABLE_UNKNOWN_ERROR" = "Authentication could not be accessed."; +/* Indicates that Touch ID/Face ID/Phone Passcode authentication failed. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_FAILED" = "Authentifizierung gescheitert."; +/* Indicates that Touch ID/Face ID/Phone Passcode is 'locked out' on this device due to authentication failures. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT" = "Zu viele gescheiterte Authentifizierungsversuche. Bitte versuche es später erneut."; +/* Indicates that Touch ID/Face ID/Phone Passcode are not available on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE" = "Du musst einen Passcode in deinen iOS-Einstellungen festlegen, um die Bildschirmsperre zu verwenden."; +/* Indicates that Touch ID/Face ID/Phone Passcode is not configured on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED" = "Du musst einen Passcode in deinen iOS-Einstellungen festlegen, um die Bildschirmsperre zu verwenden."; +/* Indicates that Touch ID/Face ID/Phone Passcode passcode is not set. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_PASSCODE_NOT_SET" = "Du musst einen Passcode in deinen iOS-Einstellungen festlegen, um die Bildschirmsperre zu verwenden."; +/* Label for the button to send a message */ +"SEND_BUTTON_TITLE" = "Send"; +/* Generic text for button that retries whatever the last action was. */ +"RETRY_BUTTON_TEXT" = "Retry"; +/* notification action */ +"SHOW_THREAD_BUTTON_TITLE" = "Show Chat"; +/* notification body */ +"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send."; +"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again."; +"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again."; diff --git a/Session/Meta/Translations/en.lproj/Localizable.strings b/Session/Meta/Translations/en.lproj/Localizable.strings index bbdfe3f4e..18d73b302 100644 --- a/Session/Meta/Translations/en.lproj/Localizable.strings +++ b/Session/Meta/Translations/en.lproj/Localizable.strings @@ -606,7 +606,6 @@ "accessibility_camera_button" = "Camera"; "accessibility_main_button_collapse" = "Collapse attachment options"; "invalid_recovery_phrase" = "Invalid Recovery Phrase"; -"invalid_recovery_phrase" = "Invalid Recovery Phrase"; "DISMISS_BUTTON_TEXT" = "Dismiss"; /* Button text which opens the settings app */ "OPEN_SETTINGS_BUTTON" = "Settings"; @@ -653,11 +652,36 @@ "modal_call_permission_request_explanation" = "You can enable the 'Voice and video calls' permission in the Privacy Settings."; "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; -"ALERT_ERROR_TITLE" = "Error"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; "MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; - +"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LAST_WORD" = "You seem to be missing the last word of your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_INVALID_WORD" = "There appears to be an invalid word in your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_FAILED" = "Your recovery phrase couldn't be verified. Please check what you entered and try again."; +/* Indicates that an unknown error occurred while using Touch ID/Face ID/Phone Passcode. */ +"SCREEN_LOCK_ENABLE_UNKNOWN_ERROR" = "Authentication could not be accessed."; +/* Indicates that Touch ID/Face ID/Phone Passcode authentication failed. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_FAILED" = "Authentication failed."; +/* Indicates that Touch ID/Face ID/Phone Passcode is 'locked out' on this device due to authentication failures. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT" = "Too many failed authentication attempts. Please try again later."; +/* Indicates that Touch ID/Face ID/Phone Passcode are not available on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE" = "You must enable a passcode in your iOS Settings in order to use Screen Lock."; +/* Indicates that Touch ID/Face ID/Phone Passcode is not configured on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED" = "You must enable a passcode in your iOS Settings in order to use Screen Lock."; +/* Indicates that Touch ID/Face ID/Phone Passcode passcode is not set. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_PASSCODE_NOT_SET" = "You must enable a passcode in your iOS Settings in order to use Screen Lock."; +/* Label for the button to send a message */ +"SEND_BUTTON_TITLE" = "Send"; +/* Generic text for button that retries whatever the last action was. */ +"RETRY_BUTTON_TEXT" = "Retry"; +/* notification action */ +"SHOW_THREAD_BUTTON_TITLE" = "Show Chat"; +/* notification body */ +"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send."; +"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again."; +"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again."; diff --git a/Session/Meta/Translations/es.lproj/Localizable.strings b/Session/Meta/Translations/es.lproj/Localizable.strings index 6a4ca17e0..76abb2b87 100644 --- a/Session/Meta/Translations/es.lproj/Localizable.strings +++ b/Session/Meta/Translations/es.lproj/Localizable.strings @@ -606,7 +606,6 @@ "accessibility_camera_button" = "Cámara"; "accessibility_main_button_collapse" = "Collapse attachment options"; "invalid_recovery_phrase" = "Frase de Recuperación Incorrecta"; -"invalid_recovery_phrase" = "Invalid Recovery Phrase"; "DISMISS_BUTTON_TEXT" = "Descartar"; /* Button text which opens the settings app */ "OPEN_SETTINGS_BUTTON" = "Ajustes"; @@ -653,10 +652,36 @@ "modal_call_permission_request_explanation" = "You can enable the 'Voice and video calls' permission in the Privacy Settings."; "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; -"ALERT_ERROR_TITLE" = "Fallo"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; "MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; +"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LAST_WORD" = "You seem to be missing the last word of your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_INVALID_WORD" = "There appears to be an invalid word in your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_FAILED" = "Your recovery phrase couldn't be verified. Please check what you entered and try again."; +/* Indicates that an unknown error occurred while using Touch ID/Face ID/Phone Passcode. */ +"SCREEN_LOCK_ENABLE_UNKNOWN_ERROR" = "Authentication could not be accessed."; +/* Indicates that Touch ID/Face ID/Phone Passcode authentication failed. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_FAILED" = "Fallo en la identificación."; +/* Indicates that Touch ID/Face ID/Phone Passcode is 'locked out' on this device due to authentication failures. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT" = "Demasiados intentos fallidos. Prueda de nuevo más tarde."; +/* Indicates that Touch ID/Face ID/Phone Passcode are not available on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE" = "Necesitas configurar un código en «Ajustes» de iOS para poder usar el bloqueo de acceso."; +/* Indicates that Touch ID/Face ID/Phone Passcode is not configured on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED" = "Necesitas configurar un código en «Ajustes» de iOS para poder usar el bloqueo de acceso."; +/* Indicates that Touch ID/Face ID/Phone Passcode passcode is not set. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_PASSCODE_NOT_SET" = "Necesitas configurar un código en «Ajustes» de iOS para poder usar el bloqueo de acceso."; +/* Label for the button to send a message */ +"SEND_BUTTON_TITLE" = "Send"; +/* Generic text for button that retries whatever the last action was. */ +"RETRY_BUTTON_TEXT" = "Retry"; +/* notification action */ +"SHOW_THREAD_BUTTON_TITLE" = "Show Chat"; +/* notification body */ +"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send."; +"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again."; +"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again."; diff --git a/Session/Meta/Translations/fa.lproj/Localizable.strings b/Session/Meta/Translations/fa.lproj/Localizable.strings index f742bc0bc..60d6d71ee 100644 --- a/Session/Meta/Translations/fa.lproj/Localizable.strings +++ b/Session/Meta/Translations/fa.lproj/Localizable.strings @@ -606,7 +606,6 @@ "accessibility_camera_button" = "Camera"; "accessibility_main_button_collapse" = "Collapse attachment options"; "invalid_recovery_phrase" = "Invalid Recovery Phrase"; -"invalid_recovery_phrase" = "Invalid Recovery Phrase"; "DISMISS_BUTTON_TEXT" = "Dismiss"; /* Button text which opens the settings app */ "OPEN_SETTINGS_BUTTON" = "Settings"; @@ -653,10 +652,36 @@ "modal_call_permission_request_explanation" = "You can enable the 'Voice and video calls' permission in the Privacy Settings."; "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; -"ALERT_ERROR_TITLE" = "خطاء"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; "MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; +"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LAST_WORD" = "You seem to be missing the last word of your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_INVALID_WORD" = "There appears to be an invalid word in your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_FAILED" = "Your recovery phrase couldn't be verified. Please check what you entered and try again."; +/* Indicates that an unknown error occurred while using Touch ID/Face ID/Phone Passcode. */ +"SCREEN_LOCK_ENABLE_UNKNOWN_ERROR" = "Authentication could not be accessed."; +/* Indicates that Touch ID/Face ID/Phone Passcode authentication failed. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_FAILED" = "احراز هویت ناموفق بود."; +/* Indicates that Touch ID/Face ID/Phone Passcode is 'locked out' on this device due to authentication failures. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT" = "چندین احراز هویت ناموفق رخ داد. لطفا بعدا تلاش کنید."; +/* Indicates that Touch ID/Face ID/Phone Passcode are not available on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE" = "برای استفاده از قفل صفحه نمایش می بایستی یک رمزعبور از تنظیمات iOS خود فعال کنید."; +/* Indicates that Touch ID/Face ID/Phone Passcode is not configured on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED" = "شما برای استفاده از قفل صفحه نمایس می بایستی یک رمزعبور از تنظیمات iOS خود فعال کنید."; +/* Indicates that Touch ID/Face ID/Phone Passcode passcode is not set. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_PASSCODE_NOT_SET" = "برای استفاده از قفل صفحه نمایش می بایستی یک رمزعبور از تنظیمات iOS فعال کنید."; +/* Label for the button to send a message */ +"SEND_BUTTON_TITLE" = "Send"; +/* Generic text for button that retries whatever the last action was. */ +"RETRY_BUTTON_TEXT" = "Retry"; +/* notification action */ +"SHOW_THREAD_BUTTON_TITLE" = "Show Chat"; +/* notification body */ +"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send."; +"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again."; +"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again."; diff --git a/Session/Meta/Translations/fi.lproj/Localizable.strings b/Session/Meta/Translations/fi.lproj/Localizable.strings index e8e512296..47b43d082 100644 --- a/Session/Meta/Translations/fi.lproj/Localizable.strings +++ b/Session/Meta/Translations/fi.lproj/Localizable.strings @@ -606,7 +606,6 @@ "accessibility_camera_button" = "Kamera"; "accessibility_main_button_collapse" = "Tiivistä liiteasetukset"; "invalid_recovery_phrase" = "Virheellinen Palautuslauseke"; -"invalid_recovery_phrase" = "Virheellinen palautuslauseke"; "DISMISS_BUTTON_TEXT" = "Hylkää"; /* Button text which opens the settings app */ "OPEN_SETTINGS_BUTTON" = "Asetukset"; @@ -653,10 +652,36 @@ "modal_call_permission_request_explanation" = "You can enable the 'Voice and video calls' permission in the Privacy Settings."; "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; -"ALERT_ERROR_TITLE" = "Error"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; "MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; +"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LAST_WORD" = "You seem to be missing the last word of your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_INVALID_WORD" = "There appears to be an invalid word in your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_FAILED" = "Your recovery phrase couldn't be verified. Please check what you entered and try again."; +/* Indicates that an unknown error occurred while using Touch ID/Face ID/Phone Passcode. */ +"SCREEN_LOCK_ENABLE_UNKNOWN_ERROR" = "Authentication could not be accessed."; +/* Indicates that Touch ID/Face ID/Phone Passcode authentication failed. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_FAILED" = "Tunnistautuminen epäonnistui"; +/* Indicates that Touch ID/Face ID/Phone Passcode is 'locked out' on this device due to authentication failures. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT" = "Liian monta epäonnistunutta tunnistautumista. Yritä myöhemmin uudelleen."; +/* Indicates that Touch ID/Face ID/Phone Passcode are not available on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE" = "Aseta pääsykoodi puhelimesi asetuksista, jotta voit käyttää näytön lukitusta."; +/* Indicates that Touch ID/Face ID/Phone Passcode is not configured on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED" = "Aseta pääsykoodi puhelimesi asetuksista, jotta voit käyttää näytön lukitusta."; +/* Indicates that Touch ID/Face ID/Phone Passcode passcode is not set. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_PASSCODE_NOT_SET" = "Aseta pääsykoodi puhelimesi asetuksista, jotta voit käyttää näytön lukitusta."; +/* Label for the button to send a message */ +"SEND_BUTTON_TITLE" = "Send"; +/* Generic text for button that retries whatever the last action was. */ +"RETRY_BUTTON_TEXT" = "Retry"; +/* notification action */ +"SHOW_THREAD_BUTTON_TITLE" = "Show Chat"; +/* notification body */ +"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send."; +"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again."; +"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again."; diff --git a/Session/Meta/Translations/fr.lproj/Localizable.strings b/Session/Meta/Translations/fr.lproj/Localizable.strings index 729b1a90d..ee7ccbf84 100644 --- a/Session/Meta/Translations/fr.lproj/Localizable.strings +++ b/Session/Meta/Translations/fr.lproj/Localizable.strings @@ -606,7 +606,6 @@ "accessibility_camera_button" = "Caméra"; "accessibility_main_button_collapse" = "Réduire les options de pièces jointes"; "invalid_recovery_phrase" = "Phrase de récupération incorrecte"; -"invalid_recovery_phrase" = "Phrase de récupération incorrecte"; "DISMISS_BUTTON_TEXT" = "Fermer"; /* Button text which opens the settings app */ "OPEN_SETTINGS_BUTTON" = "Paramètres"; @@ -653,10 +652,36 @@ "modal_call_permission_request_explanation" = "Vous pouvez activer la permission \"Appels vocaux et vidéo\" dans les paramètres de confidentialité."; "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; -"ALERT_ERROR_TITLE" = "Erreur"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; "MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; +"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LAST_WORD" = "You seem to be missing the last word of your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_INVALID_WORD" = "There appears to be an invalid word in your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_FAILED" = "Your recovery phrase couldn't be verified. Please check what you entered and try again."; +/* Indicates that an unknown error occurred while using Touch ID/Face ID/Phone Passcode. */ +"SCREEN_LOCK_ENABLE_UNKNOWN_ERROR" = "Authentication could not be accessed."; +/* Indicates that Touch ID/Face ID/Phone Passcode authentication failed. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_FAILED" = "Échec d’authentification"; +/* Indicates that Touch ID/Face ID/Phone Passcode is 'locked out' on this device due to authentication failures. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT" = "Trop d’essais infructueux d’authentification. Veuillez réessayer plus tard."; +/* Indicates that Touch ID/Face ID/Phone Passcode are not available on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE" = "Vous devez activer un code dans vos réglages iOS pour utiliser le verrou d’écran."; +/* Indicates that Touch ID/Face ID/Phone Passcode is not configured on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED" = "Vous devez activer un code dans vos réglages iOS pour utiliser le verrou d’écran."; +/* Indicates that Touch ID/Face ID/Phone Passcode passcode is not set. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_PASSCODE_NOT_SET" = "Vous devez activer un code dans vos réglages iOS pour utiliser le verrou d’écran."; +/* Label for the button to send a message */ +"SEND_BUTTON_TITLE" = "Send"; +/* Generic text for button that retries whatever the last action was. */ +"RETRY_BUTTON_TEXT" = "Retry"; +/* notification action */ +"SHOW_THREAD_BUTTON_TITLE" = "Show Chat"; +/* notification body */ +"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send."; +"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again."; +"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again."; diff --git a/Session/Meta/Translations/hi.lproj/Localizable.strings b/Session/Meta/Translations/hi.lproj/Localizable.strings index d9589c6a4..86a98157a 100644 --- a/Session/Meta/Translations/hi.lproj/Localizable.strings +++ b/Session/Meta/Translations/hi.lproj/Localizable.strings @@ -606,7 +606,6 @@ "accessibility_camera_button" = "Camera"; "accessibility_main_button_collapse" = "Collapse attachment options"; "invalid_recovery_phrase" = "Invalid Recovery Phrase"; -"invalid_recovery_phrase" = "Invalid Recovery Phrase"; "DISMISS_BUTTON_TEXT" = "Dismiss"; /* Button text which opens the settings app */ "OPEN_SETTINGS_BUTTON" = "Settings"; @@ -653,10 +652,36 @@ "modal_call_permission_request_explanation" = "You can enable the 'Voice and video calls' permission in the Privacy Settings."; "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; -"ALERT_ERROR_TITLE" = "Error"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; "MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; +"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LAST_WORD" = "You seem to be missing the last word of your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_INVALID_WORD" = "There appears to be an invalid word in your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_FAILED" = "Your recovery phrase couldn't be verified. Please check what you entered and try again."; +/* Indicates that an unknown error occurred while using Touch ID/Face ID/Phone Passcode. */ +"SCREEN_LOCK_ENABLE_UNKNOWN_ERROR" = "Authentication could not be accessed."; +/* Indicates that Touch ID/Face ID/Phone Passcode authentication failed. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_FAILED" = "प्रमाणीकरण असफल"; +/* Indicates that Touch ID/Face ID/Phone Passcode is 'locked out' on this device due to authentication failures. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT" = "बहुत सारी असफल प्रमाणीकरण की कोशिशें हुई हैं। कृपया थोङी देर बाद कोशिश करें।"; +/* Indicates that Touch ID/Face ID/Phone Passcode are not available on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE" = "सक्रीन लॉक इस्तेमाल करने के लिये अपने iOS सेटिंग्स से पासकोड की अनुमति दें।"; +/* Indicates that Touch ID/Face ID/Phone Passcode is not configured on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED" = "सक्रीन लॉक इस्तेमाल करने के लिये अपने iOS सेटिंग्स से पासकोड की अनुमति दें।"; +/* Indicates that Touch ID/Face ID/Phone Passcode passcode is not set. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_PASSCODE_NOT_SET" = "सक्रीन लॉक इस्तेमाल करने के लिये अपने iOS सेटिंग्स से पासकोड की अनुमति दें।"; +/* Label for the button to send a message */ +"SEND_BUTTON_TITLE" = "Send"; +/* Generic text for button that retries whatever the last action was. */ +"RETRY_BUTTON_TEXT" = "Retry"; +/* notification action */ +"SHOW_THREAD_BUTTON_TITLE" = "Show Chat"; +/* notification body */ +"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send."; +"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again."; +"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again."; diff --git a/Session/Meta/Translations/hr.lproj/Localizable.strings b/Session/Meta/Translations/hr.lproj/Localizable.strings index 2aaa1e710..4b4b3f6ca 100644 --- a/Session/Meta/Translations/hr.lproj/Localizable.strings +++ b/Session/Meta/Translations/hr.lproj/Localizable.strings @@ -606,7 +606,6 @@ "accessibility_camera_button" = "Kamera"; "accessibility_main_button_collapse" = "Sažmi opcije privitka"; "invalid_recovery_phrase" = "Nevažeća fraza za oporavak"; -"invalid_recovery_phrase" = "Nevažeća fraza za oporavak"; "DISMISS_BUTTON_TEXT" = "Odbaci"; /* Button text which opens the settings app */ "OPEN_SETTINGS_BUTTON" = "Postavke"; @@ -653,10 +652,36 @@ "modal_call_permission_request_explanation" = "You can enable the 'Voice and video calls' permission in the Privacy Settings."; "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; -"ALERT_ERROR_TITLE" = "Error"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; "MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; +"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LAST_WORD" = "You seem to be missing the last word of your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_INVALID_WORD" = "There appears to be an invalid word in your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_FAILED" = "Your recovery phrase couldn't be verified. Please check what you entered and try again."; +/* Indicates that an unknown error occurred while using Touch ID/Face ID/Phone Passcode. */ +"SCREEN_LOCK_ENABLE_UNKNOWN_ERROR" = "Authentication could not be accessed."; +/* Indicates that Touch ID/Face ID/Phone Passcode authentication failed. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_FAILED" = "Provjera autentičnosti nije uspjela."; +/* Indicates that Touch ID/Face ID/Phone Passcode is 'locked out' on this device due to authentication failures. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT" = "Previše neuspjelih pokušaja provjere autentičnosti. Pokušajte ponovo kasnije."; +/* Indicates that Touch ID/Face ID/Phone Passcode are not available on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE" = "Kako biste koristili Zaključavanje zaslona, morate omogućiti lozinku u postavkama iOS-a."; +/* Indicates that Touch ID/Face ID/Phone Passcode is not configured on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED" = "Kako biste koristili Zaključavanje zaslona, morate omogućiti lozinku u postavkama iOS-a."; +/* Indicates that Touch ID/Face ID/Phone Passcode passcode is not set. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_PASSCODE_NOT_SET" = "Kako biste koristili Zaključavanje zaslona, morate omogućiti lozinku u postavkama iOS-a."; +/* Label for the button to send a message */ +"SEND_BUTTON_TITLE" = "Send"; +/* Generic text for button that retries whatever the last action was. */ +"RETRY_BUTTON_TEXT" = "Retry"; +/* notification action */ +"SHOW_THREAD_BUTTON_TITLE" = "Show Chat"; +/* notification body */ +"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send."; +"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again."; +"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again."; diff --git a/Session/Meta/Translations/id-ID.lproj/Localizable.strings b/Session/Meta/Translations/id-ID.lproj/Localizable.strings index 227258661..b1376c4db 100644 --- a/Session/Meta/Translations/id-ID.lproj/Localizable.strings +++ b/Session/Meta/Translations/id-ID.lproj/Localizable.strings @@ -606,7 +606,6 @@ "accessibility_camera_button" = "Camera"; "accessibility_main_button_collapse" = "Collapse attachment options"; "invalid_recovery_phrase" = "Invalid Recovery Phrase"; -"invalid_recovery_phrase" = "Invalid Recovery Phrase"; "DISMISS_BUTTON_TEXT" = "Dismiss"; /* Button text which opens the settings app */ "OPEN_SETTINGS_BUTTON" = "Settings"; @@ -653,10 +652,36 @@ "modal_call_permission_request_explanation" = "You can enable the 'Voice and video calls' permission in the Privacy Settings."; "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; -"ALERT_ERROR_TITLE" = "Galat"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; "MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; +"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LAST_WORD" = "You seem to be missing the last word of your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_INVALID_WORD" = "There appears to be an invalid word in your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_FAILED" = "Your recovery phrase couldn't be verified. Please check what you entered and try again."; +/* Indicates that an unknown error occurred while using Touch ID/Face ID/Phone Passcode. */ +"SCREEN_LOCK_ENABLE_UNKNOWN_ERROR" = "Authentication could not be accessed."; +/* Indicates that Touch ID/Face ID/Phone Passcode authentication failed. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_FAILED" = "Authentication failed."; +/* Indicates that Touch ID/Face ID/Phone Passcode is 'locked out' on this device due to authentication failures. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT" = "Too many failed authentication attempts. Please try again later."; +/* Indicates that Touch ID/Face ID/Phone Passcode are not available on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE" = "You must enable a passcode in your iOS Settings in order to use Screen Lock."; +/* Indicates that Touch ID/Face ID/Phone Passcode is not configured on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED" = "You must enable a passcode in your iOS Settings in order to use Screen Lock."; +/* Indicates that Touch ID/Face ID/Phone Passcode passcode is not set. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_PASSCODE_NOT_SET" = "You must enable a passcode in your iOS Settings in order to use Screen Lock."; +/* Label for the button to send a message */ +"SEND_BUTTON_TITLE" = "Send"; +/* Generic text for button that retries whatever the last action was. */ +"RETRY_BUTTON_TEXT" = "Retry"; +/* notification action */ +"SHOW_THREAD_BUTTON_TITLE" = "Show Chat"; +/* notification body */ +"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send."; +"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again."; +"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again."; diff --git a/Session/Meta/Translations/it.lproj/Localizable.strings b/Session/Meta/Translations/it.lproj/Localizable.strings index 5c0f62aec..007cac231 100644 --- a/Session/Meta/Translations/it.lproj/Localizable.strings +++ b/Session/Meta/Translations/it.lproj/Localizable.strings @@ -606,7 +606,6 @@ "accessibility_camera_button" = "Fotocamera"; "accessibility_main_button_collapse" = "Comprimi opzioni allegato"; "invalid_recovery_phrase" = "Frase Di Recupero non valida"; -"invalid_recovery_phrase" = "Frase Di Ripristino Non Valida"; "DISMISS_BUTTON_TEXT" = "Chiudi"; /* Button text which opens the settings app */ "OPEN_SETTINGS_BUTTON" = "Impostazioni"; @@ -653,10 +652,36 @@ "modal_call_permission_request_explanation" = "È possibile abilitare l'autorizzazione 'Voce e video chiamate' nelle Impostazioni Privacy."; "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; -"ALERT_ERROR_TITLE" = "Errore"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; "MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; +"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LAST_WORD" = "You seem to be missing the last word of your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_INVALID_WORD" = "There appears to be an invalid word in your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_FAILED" = "Your recovery phrase couldn't be verified. Please check what you entered and try again."; +/* Indicates that an unknown error occurred while using Touch ID/Face ID/Phone Passcode. */ +"SCREEN_LOCK_ENABLE_UNKNOWN_ERROR" = "Authentication could not be accessed."; +/* Indicates that Touch ID/Face ID/Phone Passcode authentication failed. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_FAILED" = "Autenticazione non riuscita."; +/* Indicates that Touch ID/Face ID/Phone Passcode is 'locked out' on this device due to authentication failures. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT" = "Troppi tentativi di autenticazione non riusciti. Riprova più tardi."; +/* Indicates that Touch ID/Face ID/Phone Passcode are not available on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE" = "Devi abilitare una password nelle impostazioni di iOS per poter usare il blocco schermo."; +/* Indicates that Touch ID/Face ID/Phone Passcode is not configured on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED" = "Devi abilitare una password nelle impostazioni di iOS per poter usare il blocco schermo."; +/* Indicates that Touch ID/Face ID/Phone Passcode passcode is not set. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_PASSCODE_NOT_SET" = "Devi abilitare una password nelle impostazioni di iOS per poter usare il blocco schermo."; +/* Label for the button to send a message */ +"SEND_BUTTON_TITLE" = "Send"; +/* Generic text for button that retries whatever the last action was. */ +"RETRY_BUTTON_TEXT" = "Retry"; +/* notification action */ +"SHOW_THREAD_BUTTON_TITLE" = "Show Chat"; +/* notification body */ +"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send."; +"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again."; +"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again."; diff --git a/Session/Meta/Translations/ja.lproj/Localizable.strings b/Session/Meta/Translations/ja.lproj/Localizable.strings index 34f322714..f554a0bcb 100644 --- a/Session/Meta/Translations/ja.lproj/Localizable.strings +++ b/Session/Meta/Translations/ja.lproj/Localizable.strings @@ -606,7 +606,6 @@ "accessibility_camera_button" = "カメラ"; "accessibility_main_button_collapse" = "添付ファイルのオプションを閉じる"; "invalid_recovery_phrase" = "無効な復元フレーズ"; -"invalid_recovery_phrase" = "無効な復元フレーズ"; "DISMISS_BUTTON_TEXT" = "中止"; /* Button text which opens the settings app */ "OPEN_SETTINGS_BUTTON" = "設定"; @@ -653,10 +652,36 @@ "modal_call_permission_request_explanation" = "プライバシー設定から音声とビデオ通話の許可を有効にできます。"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; -"ALERT_ERROR_TITLE" = "エラー"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; "MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; +"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LAST_WORD" = "You seem to be missing the last word of your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_INVALID_WORD" = "There appears to be an invalid word in your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_FAILED" = "Your recovery phrase couldn't be verified. Please check what you entered and try again."; +/* Indicates that an unknown error occurred while using Touch ID/Face ID/Phone Passcode. */ +"SCREEN_LOCK_ENABLE_UNKNOWN_ERROR" = "Authentication could not be accessed."; +/* Indicates that Touch ID/Face ID/Phone Passcode authentication failed. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_FAILED" = "認証に失敗しました。"; +/* Indicates that Touch ID/Face ID/Phone Passcode is 'locked out' on this device due to authentication failures. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT" = "認証失敗が多すぎます。あとで再度試してください。"; +/* Indicates that Touch ID/Face ID/Phone Passcode are not available on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE" = "画面ロックを使用するには、iOSの設定でパスコードを有効にしてください。"; +/* Indicates that Touch ID/Face ID/Phone Passcode is not configured on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED" = "画面ロックを使用するには、iOSの設定でパスコードを有効にしてください。"; +/* Indicates that Touch ID/Face ID/Phone Passcode passcode is not set. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_PASSCODE_NOT_SET" = "画面ロックを使用するには、iOSの設定でパスコードを有効にしてください。"; +/* Label for the button to send a message */ +"SEND_BUTTON_TITLE" = "Send"; +/* Generic text for button that retries whatever the last action was. */ +"RETRY_BUTTON_TEXT" = "Retry"; +/* notification action */ +"SHOW_THREAD_BUTTON_TITLE" = "Show Chat"; +/* notification body */ +"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send."; +"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again."; +"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again."; diff --git a/Session/Meta/Translations/nl.lproj/Localizable.strings b/Session/Meta/Translations/nl.lproj/Localizable.strings index af4d90a7f..d2280bc06 100644 --- a/Session/Meta/Translations/nl.lproj/Localizable.strings +++ b/Session/Meta/Translations/nl.lproj/Localizable.strings @@ -606,7 +606,6 @@ "accessibility_camera_button" = "Camera"; "accessibility_main_button_collapse" = "Bijlage-opties inklappen"; "invalid_recovery_phrase" = "Ongeldig Herstelzin"; -"invalid_recovery_phrase" = "Ongeldig Herstelzin"; "DISMISS_BUTTON_TEXT" = "Negeren"; /* Button text which opens the settings app */ "OPEN_SETTINGS_BUTTON" = "Instellingen"; @@ -653,10 +652,36 @@ "modal_call_permission_request_explanation" = "You can enable the 'Voice and video calls' permission in the Privacy Settings."; "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; -"ALERT_ERROR_TITLE" = "Error"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; "MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; +"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LAST_WORD" = "You seem to be missing the last word of your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_INVALID_WORD" = "There appears to be an invalid word in your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_FAILED" = "Your recovery phrase couldn't be verified. Please check what you entered and try again."; +/* Indicates that an unknown error occurred while using Touch ID/Face ID/Phone Passcode. */ +"SCREEN_LOCK_ENABLE_UNKNOWN_ERROR" = "Authentication could not be accessed."; +/* Indicates that Touch ID/Face ID/Phone Passcode authentication failed. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_FAILED" = "Authenticatie mislukt."; +/* Indicates that Touch ID/Face ID/Phone Passcode is 'locked out' on this device due to authentication failures. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT" = "Te veel pogingen tot authenticatie. Probeer het later opnieuw."; +/* Indicates that Touch ID/Face ID/Phone Passcode are not available on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE" = "Om appvergrendeling te kunnen gebruiken, moet je een toegangscode instellen in de iOS-instellingen."; +/* Indicates that Touch ID/Face ID/Phone Passcode is not configured on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED" = "Om appvergrendeling te kunnen gebruiken, moet je een toegangscode instellen in de iOS-instellingen."; +/* Indicates that Touch ID/Face ID/Phone Passcode passcode is not set. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_PASSCODE_NOT_SET" = "Om appvergrendeling te kunnen gebruiken, moet je een toegangscode instellen in de iOS-instellingen."; +/* Label for the button to send a message */ +"SEND_BUTTON_TITLE" = "Send"; +/* Generic text for button that retries whatever the last action was. */ +"RETRY_BUTTON_TEXT" = "Retry"; +/* notification action */ +"SHOW_THREAD_BUTTON_TITLE" = "Show Chat"; +/* notification body */ +"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send."; +"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again."; +"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again."; diff --git a/Session/Meta/Translations/pl.lproj/Localizable.strings b/Session/Meta/Translations/pl.lproj/Localizable.strings index 6cef8f942..7d53e7b81 100644 --- a/Session/Meta/Translations/pl.lproj/Localizable.strings +++ b/Session/Meta/Translations/pl.lproj/Localizable.strings @@ -606,7 +606,6 @@ "accessibility_camera_button" = "Aparat"; "accessibility_main_button_collapse" = "Zwiń opcje załączników"; "invalid_recovery_phrase" = "Nieprawidłowa fraza odzyskiwania"; -"invalid_recovery_phrase" = "Nieprawidłowa fraza odzyskiwania"; "DISMISS_BUTTON_TEXT" = "Odrzuć"; /* Button text which opens the settings app */ "OPEN_SETTINGS_BUTTON" = "Ustawienia"; @@ -653,10 +652,36 @@ "modal_call_permission_request_explanation" = "Możesz włączyć uprawnienie 'Połączenia głosowe i wideo' w Ustawieniach Prywatności."; "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; -"ALERT_ERROR_TITLE" = "Błąd"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; "MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; +"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LAST_WORD" = "You seem to be missing the last word of your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_INVALID_WORD" = "There appears to be an invalid word in your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_FAILED" = "Your recovery phrase couldn't be verified. Please check what you entered and try again."; +/* Indicates that an unknown error occurred while using Touch ID/Face ID/Phone Passcode. */ +"SCREEN_LOCK_ENABLE_UNKNOWN_ERROR" = "Authentication could not be accessed."; +/* Indicates that Touch ID/Face ID/Phone Passcode authentication failed. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_FAILED" = "Uwierzytelnianie nie powiodło się."; +/* Indicates that Touch ID/Face ID/Phone Passcode is 'locked out' on this device due to authentication failures. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT" = "Zbyt wiele błędnych logowań. Spróbuj później."; +/* Indicates that Touch ID/Face ID/Phone Passcode are not available on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE" = "Musisz włączyć kod dostępu w Ustawieniach systemu iOS, aby korzystać z blokady ekranu."; +/* Indicates that Touch ID/Face ID/Phone Passcode is not configured on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED" = "Musisz włączyć kod dostępu w Ustawieniach systemu iOS, aby korzystać z blokady ekranu."; +/* Indicates that Touch ID/Face ID/Phone Passcode passcode is not set. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_PASSCODE_NOT_SET" = "Musisz włączyć kod dostępu w Ustawieniach systemu iOS, aby korzystać z blokady ekranu."; +/* Label for the button to send a message */ +"SEND_BUTTON_TITLE" = "Send"; +/* Generic text for button that retries whatever the last action was. */ +"RETRY_BUTTON_TEXT" = "Retry"; +/* notification action */ +"SHOW_THREAD_BUTTON_TITLE" = "Show Chat"; +/* notification body */ +"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send."; +"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again."; +"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again."; diff --git a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings index 1ebe65929..8e5856689 100644 --- a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings +++ b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings @@ -606,7 +606,6 @@ "accessibility_camera_button" = "Câmera"; "accessibility_main_button_collapse" = "Recolher opções de anexo"; "invalid_recovery_phrase" = "Frase de Recuperação inválida"; -"invalid_recovery_phrase" = "Frase de Recuperação inválida"; "DISMISS_BUTTON_TEXT" = "Ignorar"; /* Button text which opens the settings app */ "OPEN_SETTINGS_BUTTON" = "Configurações"; @@ -653,10 +652,36 @@ "modal_call_permission_request_explanation" = "You can enable the 'Voice and video calls' permission in the Privacy Settings."; "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; -"ALERT_ERROR_TITLE" = "Erro"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; "MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; +"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LAST_WORD" = "You seem to be missing the last word of your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_INVALID_WORD" = "There appears to be an invalid word in your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_FAILED" = "Your recovery phrase couldn't be verified. Please check what you entered and try again."; +/* Indicates that an unknown error occurred while using Touch ID/Face ID/Phone Passcode. */ +"SCREEN_LOCK_ENABLE_UNKNOWN_ERROR" = "Authentication could not be accessed."; +/* Indicates that Touch ID/Face ID/Phone Passcode authentication failed. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_FAILED" = "Falha na autenticação."; +/* Indicates that Touch ID/Face ID/Phone Passcode is 'locked out' on this device due to authentication failures. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT" = "Você excedeu o número máximo permitido de tentativas de autenticação. Por favor, tente novamente mais tarde."; +/* Indicates that Touch ID/Face ID/Phone Passcode are not available on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE" = "Você deve criar uma senha no app Ajustes do iOS para usar bloqueio de tela."; +/* Indicates that Touch ID/Face ID/Phone Passcode is not configured on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED" = "Você deve criar uma senha no app Ajustes do iOS para usar o bloqueio de tela."; +/* Indicates that Touch ID/Face ID/Phone Passcode passcode is not set. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_PASSCODE_NOT_SET" = "Você deve criar uma senha no app Ajustes do iOS para usar o bloqueio de tela."; +/* Label for the button to send a message */ +"SEND_BUTTON_TITLE" = "Send"; +/* Generic text for button that retries whatever the last action was. */ +"RETRY_BUTTON_TEXT" = "Retry"; +/* notification action */ +"SHOW_THREAD_BUTTON_TITLE" = "Show Chat"; +/* notification body */ +"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send."; +"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again."; +"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again."; diff --git a/Session/Meta/Translations/ru.lproj/Localizable.strings b/Session/Meta/Translations/ru.lproj/Localizable.strings index cf4e35c0c..8e0c3f534 100644 --- a/Session/Meta/Translations/ru.lproj/Localizable.strings +++ b/Session/Meta/Translations/ru.lproj/Localizable.strings @@ -606,7 +606,6 @@ "accessibility_camera_button" = "Камера"; "accessibility_main_button_collapse" = "Свернуть параметры вложений"; "invalid_recovery_phrase" = "Неверная секретная фраза"; -"invalid_recovery_phrase" = "Неверная секретная фраза"; "DISMISS_BUTTON_TEXT" = "Закрыть"; /* Button text which opens the settings app */ "OPEN_SETTINGS_BUTTON" = "Настройки"; @@ -653,10 +652,36 @@ "modal_call_permission_request_explanation" = "You can enable the 'Voice and video calls' permission in the Privacy Settings."; "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; -"ALERT_ERROR_TITLE" = "Ошибка"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; "MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; +"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LAST_WORD" = "You seem to be missing the last word of your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_INVALID_WORD" = "There appears to be an invalid word in your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_FAILED" = "Your recovery phrase couldn't be verified. Please check what you entered and try again."; +/* Indicates that an unknown error occurred while using Touch ID/Face ID/Phone Passcode. */ +"SCREEN_LOCK_ENABLE_UNKNOWN_ERROR" = "Authentication could not be accessed."; +/* Indicates that Touch ID/Face ID/Phone Passcode authentication failed. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_FAILED" = "Ошибка аутентификации."; +/* Indicates that Touch ID/Face ID/Phone Passcode is 'locked out' on this device due to authentication failures. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT" = "Слишком много неудачных попыток аутентификации. Пожалуйста, повторите попытку позже."; +/* Indicates that Touch ID/Face ID/Phone Passcode are not available on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE" = "Вы должны включить код доступа в приложении «Настройки», чтобы использовать блокировку экрана."; +/* Indicates that Touch ID/Face ID/Phone Passcode is not configured on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED" = "Вы должны включить код доступа в приложении «Настройки», чтобы использовать блокировку экрана."; +/* Indicates that Touch ID/Face ID/Phone Passcode passcode is not set. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_PASSCODE_NOT_SET" = "Вы должны включить код доступа в приложении «Настройки», чтобы использовать блокировку экрана."; +/* Label for the button to send a message */ +"SEND_BUTTON_TITLE" = "Send"; +/* Generic text for button that retries whatever the last action was. */ +"RETRY_BUTTON_TEXT" = "Retry"; +/* notification action */ +"SHOW_THREAD_BUTTON_TITLE" = "Show Chat"; +/* notification body */ +"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send."; +"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again."; +"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again."; diff --git a/Session/Meta/Translations/si.lproj/Localizable.strings b/Session/Meta/Translations/si.lproj/Localizable.strings index 826878f56..bd1d4fd91 100644 --- a/Session/Meta/Translations/si.lproj/Localizable.strings +++ b/Session/Meta/Translations/si.lproj/Localizable.strings @@ -606,7 +606,6 @@ "accessibility_camera_button" = "Camera"; "accessibility_main_button_collapse" = "Collapse attachment options"; "invalid_recovery_phrase" = "Invalid Recovery Phrase"; -"invalid_recovery_phrase" = "Invalid Recovery Phrase"; "DISMISS_BUTTON_TEXT" = "Dismiss"; /* Button text which opens the settings app */ "OPEN_SETTINGS_BUTTON" = "සැකසුම්"; @@ -653,10 +652,36 @@ "modal_call_permission_request_explanation" = "You can enable the 'Voice and video calls' permission in the Privacy Settings."; "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; -"ALERT_ERROR_TITLE" = "Error"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; "MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; +"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LAST_WORD" = "You seem to be missing the last word of your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_INVALID_WORD" = "There appears to be an invalid word in your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_FAILED" = "Your recovery phrase couldn't be verified. Please check what you entered and try again."; +/* Indicates that an unknown error occurred while using Touch ID/Face ID/Phone Passcode. */ +"SCREEN_LOCK_ENABLE_UNKNOWN_ERROR" = "Authentication could not be accessed."; +/* Indicates that Touch ID/Face ID/Phone Passcode authentication failed. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_FAILED" = "Authentication failed."; +/* Indicates that Touch ID/Face ID/Phone Passcode is 'locked out' on this device due to authentication failures. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT" = "Too many failed authentication attempts. Please try again later."; +/* Indicates that Touch ID/Face ID/Phone Passcode are not available on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE" = "You must enable a passcode in your iOS Settings in order to use Screen Lock."; +/* Indicates that Touch ID/Face ID/Phone Passcode is not configured on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED" = "You must enable a passcode in your iOS Settings in order to use Screen Lock."; +/* Indicates that Touch ID/Face ID/Phone Passcode passcode is not set. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_PASSCODE_NOT_SET" = "You must enable a passcode in your iOS Settings in order to use Screen Lock."; +/* Label for the button to send a message */ +"SEND_BUTTON_TITLE" = "Send"; +/* Generic text for button that retries whatever the last action was. */ +"RETRY_BUTTON_TEXT" = "Retry"; +/* notification action */ +"SHOW_THREAD_BUTTON_TITLE" = "Show Chat"; +/* notification body */ +"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send."; +"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again."; +"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again."; diff --git a/Session/Meta/Translations/sk.lproj/Localizable.strings b/Session/Meta/Translations/sk.lproj/Localizable.strings index c6330a516..6632f0943 100644 --- a/Session/Meta/Translations/sk.lproj/Localizable.strings +++ b/Session/Meta/Translations/sk.lproj/Localizable.strings @@ -606,7 +606,6 @@ "accessibility_camera_button" = "Kamera"; "accessibility_main_button_collapse" = "Uzatvor možnosti prípon"; "invalid_recovery_phrase" = "Neplatná Obnovovacia Fráza"; -"invalid_recovery_phrase" = "Neplatná Obnovovacia Fráza"; "DISMISS_BUTTON_TEXT" = "Zrušiť"; /* Button text which opens the settings app */ "OPEN_SETTINGS_BUTTON" = "Nastavenia"; @@ -653,10 +652,36 @@ "modal_call_permission_request_explanation" = "You can enable the 'Voice and video calls' permission in the Privacy Settings."; "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; -"ALERT_ERROR_TITLE" = "Error"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; "MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; +"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LAST_WORD" = "You seem to be missing the last word of your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_INVALID_WORD" = "There appears to be an invalid word in your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_FAILED" = "Your recovery phrase couldn't be verified. Please check what you entered and try again."; +/* Indicates that an unknown error occurred while using Touch ID/Face ID/Phone Passcode. */ +"SCREEN_LOCK_ENABLE_UNKNOWN_ERROR" = "Authentication could not be accessed."; +/* Indicates that Touch ID/Face ID/Phone Passcode authentication failed. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_FAILED" = "Autentifikácia zlyhala"; +/* Indicates that Touch ID/Face ID/Phone Passcode is 'locked out' on this device due to authentication failures. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT" = "Priveľa nepodarených pokusov o autentifikáciu. Prosím, skúste to znovu neskôr."; +/* Indicates that Touch ID/Face ID/Phone Passcode are not available on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE" = "Pre používanie zámku obrazovky, zapnite kódový zámok v nastaveniach iOS."; +/* Indicates that Touch ID/Face ID/Phone Passcode is not configured on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED" = "Pre používanie zámku obrazovky, zapnite kódový zámok v nastaveniach iOS."; +/* Indicates that Touch ID/Face ID/Phone Passcode passcode is not set. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_PASSCODE_NOT_SET" = "Pre používanie zámku obrazovky, zapnite kódový zámok v nastaveniach iOS."; +/* Label for the button to send a message */ +"SEND_BUTTON_TITLE" = "Send"; +/* Generic text for button that retries whatever the last action was. */ +"RETRY_BUTTON_TEXT" = "Retry"; +/* notification action */ +"SHOW_THREAD_BUTTON_TITLE" = "Show Chat"; +/* notification body */ +"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send."; +"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again."; +"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again."; diff --git a/Session/Meta/Translations/sv.lproj/Localizable.strings b/Session/Meta/Translations/sv.lproj/Localizable.strings index 92138867f..afee13e19 100644 --- a/Session/Meta/Translations/sv.lproj/Localizable.strings +++ b/Session/Meta/Translations/sv.lproj/Localizable.strings @@ -606,7 +606,6 @@ "accessibility_camera_button" = "Kamera"; "accessibility_main_button_collapse" = "Collapse attachment options"; "invalid_recovery_phrase" = "Invalid Recovery Phrase"; -"invalid_recovery_phrase" = "Invalid Recovery Phrase"; "DISMISS_BUTTON_TEXT" = "Dismiss"; /* Button text which opens the settings app */ "OPEN_SETTINGS_BUTTON" = "Inställningar"; @@ -653,10 +652,36 @@ "modal_call_permission_request_explanation" = "You can enable the 'Voice and video calls' permission in the Privacy Settings."; "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; -"ALERT_ERROR_TITLE" = "Error"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; "MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; +"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LAST_WORD" = "You seem to be missing the last word of your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_INVALID_WORD" = "There appears to be an invalid word in your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_FAILED" = "Your recovery phrase couldn't be verified. Please check what you entered and try again."; +/* Indicates that an unknown error occurred while using Touch ID/Face ID/Phone Passcode. */ +"SCREEN_LOCK_ENABLE_UNKNOWN_ERROR" = "Authentication could not be accessed."; +/* Indicates that Touch ID/Face ID/Phone Passcode authentication failed. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_FAILED" = "Autentisering misslyckades."; +/* Indicates that Touch ID/Face ID/Phone Passcode is 'locked out' on this device due to authentication failures. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT" = "För många misslyckade autentiseringsförsök. Försök igen senare."; +/* Indicates that Touch ID/Face ID/Phone Passcode are not available on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE" = "Du måste aktivera en lösenkod i dina iOS-inställningar för att använda Skärmlås."; +/* Indicates that Touch ID/Face ID/Phone Passcode is not configured on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED" = "Du måste aktivera en lösenkod i dina iOS-inställningar för att använda Skärmlås."; +/* Indicates that Touch ID/Face ID/Phone Passcode passcode is not set. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_PASSCODE_NOT_SET" = "Du måste aktivera en lösenkod i dina iOS-inställningar för att använda Skärmlås."; +/* Label for the button to send a message */ +"SEND_BUTTON_TITLE" = "Send"; +/* Generic text for button that retries whatever the last action was. */ +"RETRY_BUTTON_TEXT" = "Retry"; +/* notification action */ +"SHOW_THREAD_BUTTON_TITLE" = "Show Chat"; +/* notification body */ +"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send."; +"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again."; +"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again."; diff --git a/Session/Meta/Translations/th.lproj/Localizable.strings b/Session/Meta/Translations/th.lproj/Localizable.strings index 6545dea2d..6b9f85dec 100644 --- a/Session/Meta/Translations/th.lproj/Localizable.strings +++ b/Session/Meta/Translations/th.lproj/Localizable.strings @@ -606,7 +606,6 @@ "accessibility_camera_button" = "Camera"; "accessibility_main_button_collapse" = "Collapse attachment options"; "invalid_recovery_phrase" = "Invalid Recovery Phrase"; -"invalid_recovery_phrase" = "Invalid Recovery Phrase"; "DISMISS_BUTTON_TEXT" = "Dismiss"; /* Button text which opens the settings app */ "OPEN_SETTINGS_BUTTON" = "Settings"; @@ -653,10 +652,36 @@ "modal_call_permission_request_explanation" = "You can enable the 'Voice and video calls' permission in the Privacy Settings."; "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; -"ALERT_ERROR_TITLE" = "Error"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; "MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; +"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LAST_WORD" = "You seem to be missing the last word of your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_INVALID_WORD" = "There appears to be an invalid word in your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_FAILED" = "Your recovery phrase couldn't be verified. Please check what you entered and try again."; +/* Indicates that an unknown error occurred while using Touch ID/Face ID/Phone Passcode. */ +"SCREEN_LOCK_ENABLE_UNKNOWN_ERROR" = "Authentication could not be accessed."; +/* Indicates that Touch ID/Face ID/Phone Passcode authentication failed. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_FAILED" = "การรับรองความถูกต้องไม่สำเร็จ"; +/* Indicates that Touch ID/Face ID/Phone Passcode is 'locked out' on this device due to authentication failures. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT" = "รับรองความถูกต้องไม่สำเร็จหลายครั้งเกินไป โปรดลองใหม่ในภายหลัง"; +/* Indicates that Touch ID/Face ID/Phone Passcode are not available on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE" = "คุณต้องเปิดใช้งานรหัสผ่านในการตั้งค่า iOS ของคุณเพื่อใช้งานการล็อกหน้าจอ"; +/* Indicates that Touch ID/Face ID/Phone Passcode is not configured on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED" = "คุณต้องเปิดใช้งานรหัสผ่านในการตั้งค่า iOS ของคุณเพื่อใช้งานการล็อกหน้าจอ"; +/* Indicates that Touch ID/Face ID/Phone Passcode passcode is not set. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_PASSCODE_NOT_SET" = "คุณต้องเปิดใช้งานรหัสผ่านในการตั้งค่า iOS ของคุณเพื่อใช้งานการล็อกหน้าจอ"; +/* Label for the button to send a message */ +"SEND_BUTTON_TITLE" = "Send"; +/* Generic text for button that retries whatever the last action was. */ +"RETRY_BUTTON_TEXT" = "Retry"; +/* notification action */ +"SHOW_THREAD_BUTTON_TITLE" = "Show Chat"; +/* notification body */ +"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send."; +"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again."; +"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again."; diff --git a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings index 58c025221..6b5e5594b 100644 --- a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings +++ b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings @@ -606,7 +606,6 @@ "accessibility_camera_button" = "Camera"; "accessibility_main_button_collapse" = "Collapse attachment options"; "invalid_recovery_phrase" = "Invalid Recovery Phrase"; -"invalid_recovery_phrase" = "Invalid Recovery Phrase"; "DISMISS_BUTTON_TEXT" = "Dismiss"; /* Button text which opens the settings app */ "OPEN_SETTINGS_BUTTON" = "Settings"; @@ -653,10 +652,36 @@ "modal_call_permission_request_explanation" = "You can enable the 'Voice and video calls' permission in the Privacy Settings."; "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; -"ALERT_ERROR_TITLE" = "Error"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; "MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; +"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LAST_WORD" = "You seem to be missing the last word of your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_INVALID_WORD" = "There appears to be an invalid word in your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_FAILED" = "Your recovery phrase couldn't be verified. Please check what you entered and try again."; +/* Indicates that an unknown error occurred while using Touch ID/Face ID/Phone Passcode. */ +"SCREEN_LOCK_ENABLE_UNKNOWN_ERROR" = "Authentication could not be accessed."; +/* Indicates that Touch ID/Face ID/Phone Passcode authentication failed. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_FAILED" = "Authentication failed."; +/* Indicates that Touch ID/Face ID/Phone Passcode is 'locked out' on this device due to authentication failures. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT" = "Too many failed authentication attempts. Please try again later."; +/* Indicates that Touch ID/Face ID/Phone Passcode are not available on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE" = "You must enable a passcode in your iOS Settings in order to use Screen Lock."; +/* Indicates that Touch ID/Face ID/Phone Passcode is not configured on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED" = "You must enable a passcode in your iOS Settings in order to use Screen Lock."; +/* Indicates that Touch ID/Face ID/Phone Passcode passcode is not set. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_PASSCODE_NOT_SET" = "You must enable a passcode in your iOS Settings in order to use Screen Lock."; +/* Label for the button to send a message */ +"SEND_BUTTON_TITLE" = "Send"; +/* Generic text for button that retries whatever the last action was. */ +"RETRY_BUTTON_TEXT" = "Retry"; +/* notification action */ +"SHOW_THREAD_BUTTON_TITLE" = "Show Chat"; +/* notification body */ +"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send."; +"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again."; +"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again."; diff --git a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings index f47db9db0..c389b1514 100644 --- a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings @@ -606,7 +606,6 @@ "accessibility_camera_button" = "相機"; "accessibility_main_button_collapse" = "摺疊附件選項"; "invalid_recovery_phrase" = "備援暗語無效"; -"invalid_recovery_phrase" = "恢復短語無效"; "DISMISS_BUTTON_TEXT" = "關閉"; /* Button text which opens the settings app */ "OPEN_SETTINGS_BUTTON" = "設定"; @@ -653,10 +652,36 @@ "modal_call_permission_request_explanation" = "You can enable the 'Voice and video calls' permission in the Privacy Settings."; "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; -"ALERT_ERROR_TITLE" = "Error"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; "MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; +"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LAST_WORD" = "You seem to be missing the last word of your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_INVALID_WORD" = "There appears to be an invalid word in your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_FAILED" = "Your recovery phrase couldn't be verified. Please check what you entered and try again."; +/* Indicates that an unknown error occurred while using Touch ID/Face ID/Phone Passcode. */ +"SCREEN_LOCK_ENABLE_UNKNOWN_ERROR" = "Authentication could not be accessed."; +/* Indicates that Touch ID/Face ID/Phone Passcode authentication failed. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_FAILED" = "Authentication failed."; +/* Indicates that Touch ID/Face ID/Phone Passcode is 'locked out' on this device due to authentication failures. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT" = "Too many failed authentication attempts. Please try again later."; +/* Indicates that Touch ID/Face ID/Phone Passcode are not available on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE" = "You must enable a passcode in your iOS Settings in order to use Screen Lock."; +/* Indicates that Touch ID/Face ID/Phone Passcode is not configured on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED" = "You must enable a passcode in your iOS Settings in order to use Screen Lock."; +/* Indicates that Touch ID/Face ID/Phone Passcode passcode is not set. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_PASSCODE_NOT_SET" = "You must enable a passcode in your iOS Settings in order to use Screen Lock."; +/* Label for the button to send a message */ +"SEND_BUTTON_TITLE" = "Send"; +/* Generic text for button that retries whatever the last action was. */ +"RETRY_BUTTON_TEXT" = "Retry"; +/* notification action */ +"SHOW_THREAD_BUTTON_TITLE" = "Show Chat"; +/* notification body */ +"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send."; +"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again."; +"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again."; diff --git a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings index 820ba3c80..68bdf9b6b 100644 --- a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings @@ -606,7 +606,6 @@ "accessibility_camera_button" = "相机"; "accessibility_main_button_collapse" = "收起附件选项"; "invalid_recovery_phrase" = "恢复口令无效"; -"invalid_recovery_phrase" = "恢复口令无效"; "DISMISS_BUTTON_TEXT" = "取消"; /* Button text which opens the settings app */ "OPEN_SETTINGS_BUTTON" = "设置"; @@ -653,10 +652,36 @@ "modal_call_permission_request_explanation" = "您可以在隐私设置中启用“语音和视频通话”权限。"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; -"ALERT_ERROR_TITLE" = "错误"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; "MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app"; +"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; +"RECOVERY_PHASE_ERROR_LAST_WORD" = "You seem to be missing the last word of your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_INVALID_WORD" = "There appears to be an invalid word in your recovery phrase. Please check what you entered and try again."; +"RECOVERY_PHASE_ERROR_FAILED" = "Your recovery phrase couldn't be verified. Please check what you entered and try again."; +/* Indicates that an unknown error occurred while using Touch ID/Face ID/Phone Passcode. */ +"SCREEN_LOCK_ENABLE_UNKNOWN_ERROR" = "Authentication could not be accessed."; +/* Indicates that Touch ID/Face ID/Phone Passcode authentication failed. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_FAILED" = "认证失败。"; +/* Indicates that Touch ID/Face ID/Phone Passcode is 'locked out' on this device due to authentication failures. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT" = "认证失败次数太多,请稍后再试。"; +/* Indicates that Touch ID/Face ID/Phone Passcode are not available on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE" = "您需要先设置您的密码来开启屏幕锁功能。"; +/* Indicates that Touch ID/Face ID/Phone Passcode is not configured on this device. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED" = "您需要先设置您的密码来开启屏幕锁功能。"; +/* Indicates that Touch ID/Face ID/Phone Passcode passcode is not set. */ +"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_PASSCODE_NOT_SET" = "您需要先设置您的密码来开启屏幕锁功能。"; +/* Label for the button to send a message */ +"SEND_BUTTON_TITLE" = "Send"; +/* Generic text for button that retries whatever the last action was. */ +"RETRY_BUTTON_TEXT" = "Retry"; +/* notification action */ +"SHOW_THREAD_BUTTON_TITLE" = "Show Chat"; +/* notification body */ +"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send."; +"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again."; +"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again."; diff --git a/Session/Notifications/AppNotifications.swift b/Session/Notifications/AppNotifications.swift index 49491f158..96463a3f8 100644 --- a/Session/Notifications/AppNotifications.swift +++ b/Session/Notifications/AppNotifications.swift @@ -224,11 +224,19 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { let userInfo = [ AppNotificationUserInfoKey.threadId: thread.id ] + + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let userBlindedKey: String? = SessionThread.getUserHexEncodedBlindedKey( + threadId: thread.id, + threadVariant: thread.variant + ) DispatchQueue.main.async { notificationBody = MentionUtilities.highlightMentions( in: (notificationBody ?? ""), - threadVariant: thread.variant + threadVariant: thread.variant, + currentUserPublicKey: userPublicKey, + currentUserBlindedPublicKey: userBlindedKey ) let sound: Preferences.Sound? = self.requestSound(thread: thread) diff --git a/Session/Onboarding/LinkDeviceVC.swift b/Session/Onboarding/LinkDeviceVC.swift index 2d14ab3f9..5791ee005 100644 --- a/Session/Onboarding/LinkDeviceVC.swift +++ b/Session/Onboarding/LinkDeviceVC.swift @@ -128,7 +128,11 @@ final class LinkDeviceVC : BaseVC, UIPageViewControllerDataSource, UIPageViewCon func continueWithSeed(_ seed: Data) { if (seed.count != 16) { - let alert = UIAlertController(title: NSLocalizedString("invalid_recovery_phrase", comment: ""), message: NSLocalizedString("Please check the Recovery Phrase and try again.", comment: ""), preferredStyle: .alert) + let alert = UIAlertController( + title: "invalid_recovery_phrase".localized(), + message: "INVALID_RECOVERY_PHRASE_MESSAGE".localized(), + preferredStyle: .alert + ) alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: { _ in self.scanQRCodeWrapperVC.startCapture() })) diff --git a/Session/Settings/QRCodeVC.swift b/Session/Settings/QRCodeVC.swift index 4376809ea..be39795b0 100644 --- a/Session/Settings/QRCodeVC.swift +++ b/Session/Settings/QRCodeVC.swift @@ -121,7 +121,9 @@ final class QRCodeVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControl fileprivate func startNewPrivateChatIfPossible(with hexEncodedPublicKey: String) { if !ECKeyPair.isValidHexEncodedPublicKey(candidate: hexEncodedPublicKey) { - let alert = UIAlertController(title: NSLocalizedString("invalid_session_id", comment: ""), message: NSLocalizedString("Please check the Session ID and try again.", comment: ""), preferredStyle: .alert) + let alert = UIAlertController( + title: "invalid_session_id".localized(), + message: "INVALID_SESSION_ID_MESSAGE".localized(), preferredStyle: .alert) alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil)) presentAlert(alert) } diff --git a/Session/Shared/FullConversationCell.swift b/Session/Shared/FullConversationCell.swift index 92ffebb8a..ebf55e4c2 100644 --- a/Session/Shared/FullConversationCell.swift +++ b/Session/Shared/FullConversationCell.swift @@ -267,6 +267,8 @@ public final class FullConversationCell: UITableViewCell { cellViewModel.authorName(for: .contact) : nil ), + currentUserPublicKey: cellViewModel.currentUserPublicKey, + currentUserBlindedPublicKey: cellViewModel.currentUserBlindedPublicKey, searchText: searchText.lowercased(), fontSize: Values.smallFontSize ) @@ -288,6 +290,8 @@ public final class FullConversationCell: UITableViewCell { timestampLabel.isHidden = true displayNameLabel.attributedText = getHighlightedSnippet( content: cellViewModel.displayName, + currentUserPublicKey: cellViewModel.currentUserPublicKey, + currentUserBlindedPublicKey: cellViewModel.currentUserBlindedPublicKey, searchText: searchText.lowercased(), fontSize: Values.mediumFontSize ) @@ -299,6 +303,8 @@ public final class FullConversationCell: UITableViewCell { bottomLabelStackView.isHidden = (cellViewModel.threadMemberNames ?? "").isEmpty snippetLabel.attributedText = getHighlightedSnippet( content: (cellViewModel.threadMemberNames ?? ""), + currentUserPublicKey: cellViewModel.currentUserPublicKey, + currentUserBlindedPublicKey: cellViewModel.currentUserBlindedPublicKey, searchText: searchText.lowercased(), fontSize: Values.smallFontSize ) @@ -440,7 +446,9 @@ public final class FullConversationCell: UITableViewCell { attachmentCount: cellViewModel.interactionAttachmentCount, isOpenGroupInvitation: (cellViewModel.interactionIsOpenGroupInvitation == true) ), - threadVariant: cellViewModel.threadVariant + threadVariant: cellViewModel.threadVariant, + currentUserPublicKey: cellViewModel.currentUserPublicKey, + currentUserBlindedPublicKey: cellViewModel.currentUserBlindedPublicKey ), attributes: [ .font: font, @@ -454,6 +462,8 @@ public final class FullConversationCell: UITableViewCell { private func getHighlightedSnippet( content: String, authorName: String? = nil, + currentUserPublicKey: String, + currentUserBlindedPublicKey: String?, searchText: String, fontSize: CGFloat ) -> NSAttributedString { @@ -473,7 +483,9 @@ public final class FullConversationCell: UITableViewCell { // we don't want to include the truncated id as part of the name so we exclude it let mentionReplacedContent: String = MentionUtilities.highlightMentions( in: content, - threadVariant: .contact + threadVariant: .contact, + currentUserPublicKey: currentUserPublicKey, + currentUserBlindedPublicKey: currentUserBlindedPublicKey ) let result: NSMutableAttributedString = NSMutableAttributedString( string: mentionReplacedContent, diff --git a/Session/Shared/HighlightMentionBackgroundView.swift b/Session/Shared/HighlightMentionBackgroundView.swift new file mode 100644 index 000000000..1b9a593ba --- /dev/null +++ b/Session/Shared/HighlightMentionBackgroundView.swift @@ -0,0 +1,161 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit + +public extension NSAttributedString.Key { + static let currentUserMentionBackgroundColor: NSAttributedString.Key = NSAttributedString.Key(rawValue: "currentUserMentionBackgroundColor") + static let currentUserMentionBackgroundCornerRadius: NSAttributedString.Key = NSAttributedString.Key(rawValue: "currentUserMentionBackgroundCornerRadius") + static let currentUserMentionBackgroundPadding: NSAttributedString.Key = NSAttributedString.Key(rawValue: "currentUserMentionBackgroundPadding") +} + +class HighlightMentionBackgroundView: UIView { + var maxPadding: CGFloat = 0 + + init() { + super.init(frame: .zero) + + self.isOpaque = false + self.layer.zPosition = -1 + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Functions + + public func calculateMaxPadding(for attributedText: NSAttributedString) -> CGFloat { + var allMentionRadii: [CGFloat?] = [] + let path: CGMutablePath = CGMutablePath() + path.addRect(CGRect( + x: 0, + y: 0, + width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude + )) + + let framesetter = CTFramesetterCreateWithAttributedString(attributedText as CFAttributedString) + let frame: CTFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, attributedText.length), path, nil) + let lines: [CTLine] = frame.lines + + lines.forEach { line in + let runs: [CTRun] = line.ctruns + + runs.forEach { run in + let attributes: NSDictionary = CTRunGetAttributes(run) + allMentionRadii.append( + attributes + .value(forKey: NSAttributedString.Key.currentUserMentionBackgroundPadding.rawValue) as? CGFloat + ) + } + } + + return allMentionRadii + .compactMap { $0 } + .max() + .defaulting(to: 0) + } + + // MARK: - Drawing + + override func draw(_ rect: CGRect) { + guard + let superview: UITextView = (self.superview as? UITextView), + let context = UIGraphicsGetCurrentContext() + else { return } + + // Need to invery the Y axis because iOS likes to render from the bottom left instead of the top left + context.textMatrix = .identity + context.translateBy(x: 0, y: bounds.size.height) + context.scaleBy(x: 1.0, y: -1.0) + + // Note: Calculations MUST happen based on the 'superview' size as this class has extra padding which + // can result in calculations being off + let path = CGMutablePath() + let size = superview.sizeThatFits(CGSize(width: superview.bounds.width, height: .greatestFiniteMagnitude)) + path.addRect(CGRect(x: 0, y: 0, width: size.width, height: size.height), transform: .identity) + + let framesetter = CTFramesetterCreateWithAttributedString(superview.attributedText as CFAttributedString) + let frame: CTFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, superview.attributedText.length), path, nil) + let lines: [CTLine] = frame.lines + + var origins = [CGPoint](repeating: .zero, count: lines.count) + CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins) + + for lineIndex in 0.. lineWidth ? lineWidth : runBounds.width) + + let path = UIBezierPath(roundedRect: runBounds, cornerRadius: cornerRadius) + mentionBackgroundColor.setFill() + path.fill() + } + } + } +} + +extension CTFrame { + var lines: [CTLine] { + return ((CTFrameGetLines(self) as [AnyObject] as? [CTLine]) ?? []) + } +} + +extension CTLine { + var ctruns: [CTRun] { + return ((CTLineGetGlyphRuns(self) as [AnyObject] as? [CTRun]) ?? []) + } +} diff --git a/Session/Shared/ScanQRCodeWrapperVC.swift b/Session/Shared/ScanQRCodeWrapperVC.swift index e3a8c07e2..2d8241569 100644 --- a/Session/Shared/ScanQRCodeWrapperVC.swift +++ b/Session/Shared/ScanQRCodeWrapperVC.swift @@ -56,7 +56,7 @@ final class ScanQRCodeWrapperVC : BaseVC { explanationLabel.autoPinWidthToSuperview(withMargin: 32) explanationLabel.autoPinHeightToSuperview(withMargin: 32) // Title - title = NSLocalizedString("Scan QR Code", comment: "") + title = "Scan QR Code" } override func viewDidAppear(_ animated: Bool) { diff --git a/Session/Utilities/MentionUtilities.swift b/Session/Utilities/MentionUtilities.swift index 104deaaea..f5876d5d6 100644 --- a/Session/Utilities/MentionUtilities.swift +++ b/Session/Utilities/MentionUtilities.swift @@ -6,10 +6,17 @@ import SessionUIKit import SessionMessagingKit public enum MentionUtilities { - public static func highlightMentions(in string: String, threadVariant: SessionThread.Variant) -> String { + public static func highlightMentions( + in string: String, + threadVariant: SessionThread.Variant, + currentUserPublicKey: String, + currentUserBlindedPublicKey: String? + ) -> String { return highlightMentions( in: string, threadVariant: threadVariant, + currentUserPublicKey: currentUserPublicKey, + currentUserBlindedPublicKey: currentUserBlindedPublicKey, isOutgoingMessage: false, attributes: [:] ).string // isOutgoingMessage and attributes are irrelevant @@ -18,6 +25,8 @@ public enum MentionUtilities { public static func highlightMentions( in string: String, threadVariant: SessionThread.Variant, + currentUserPublicKey: String?, + currentUserBlindedPublicKey: String?, isOutgoingMessage: Bool, attributes: [NSAttributedString.Key: Any] ) -> NSAttributedString { @@ -29,7 +38,13 @@ public enum MentionUtilities { var string = string var lastMatchEnd: Int = 0 - var mentions: [(range: NSRange, publicKey: String)] = [] + var mentions: [(range: NSRange, isCurrentUser: Bool)] = [] + let currentUserPublicKeys: Set = [ + currentUserPublicKey, + currentUserBlindedPublicKey + ] + .compactMap { $0 } + .asSet() while let match: NSTextCheckingResult = regex.firstMatch( in: string, @@ -39,28 +54,52 @@ public enum MentionUtilities { guard let range: Range = Range(match.range, in: string) else { break } let publicKey: String = String(string[range].dropFirst()) // Drop the @ + let isCurrentUser: Bool = currentUserPublicKeys.contains(publicKey) - guard let displayName: String = Profile.displayNameNoFallback(id: publicKey, threadVariant: threadVariant) else { - lastMatchEnd = (match.range.location + match.range.length) - continue - } + guard let targetString: String = { + guard !isCurrentUser else { return "MEDIA_GALLERY_SENDER_NAME_YOU".localized() } + guard let displayName: String = Profile.displayNameNoFallback(id: publicKey, threadVariant: threadVariant) else { + lastMatchEnd = (match.range.location + match.range.length) + return nil + } + + return displayName + }() + else { continue } - string = string.replacingCharacters(in: range, with: "@\(displayName)") - lastMatchEnd = (match.range.location + displayName.utf16.count) + string = string.replacingCharacters(in: range, with: "@\(targetString)") + lastMatchEnd = (match.range.location + targetString.utf16.count) mentions.append(( // + 1 to include the @ - range: NSRange(location: match.range.location, length: displayName.utf16.count + 1), - publicKey: publicKey + range: NSRange(location: match.range.location, length: targetString.utf16.count + 1), + isCurrentUser: isCurrentUser )) } + let sizeDiff: CGFloat = (Values.smallFontSize / Values.mediumFontSize) let result: NSMutableAttributedString = NSMutableAttributedString(string: string, attributes: attributes) mentions.forEach { mention in - // FIXME: This might break when swapping between themes - let color = isOutgoingMessage ? (isLightMode ? .white : .black) : Colors.accent - result.addAttribute(.foregroundColor, value: color, range: mention.range) result.addAttribute(.font, value: UIFont.boldSystemFont(ofSize: Values.smallFontSize), range: mention.range) + + if mention.isCurrentUser { + // Note: The designs don't match with the dynamic sizing so these values need to be calculated + // to maintain a "rounded rect" effect rather than a "pill" effect + result.addAttribute(.currentUserMentionBackgroundCornerRadius, value: (8 * sizeDiff), range: mention.range) + result.addAttribute(.currentUserMentionBackgroundPadding, value: (3 * sizeDiff), range: mention.range) + result.addAttribute(.currentUserMentionBackgroundColor, value: Colors.accent, range: mention.range) + result.addAttribute(.foregroundColor, value: UIColor.black, range: mention.range) + } + else { + let color: UIColor = { + switch (isLightMode, isOutgoingMessage) { + case (_, true): return .black + case (true, false): return .black + case (false, false): return Colors.accent + } + }() + result.addAttribute(.foregroundColor, value: color, range: mention.range) + } } return result diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index 32385683b..e57a95422 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -314,14 +314,11 @@ public extension Profile { /// A standardised mechanism for truncating a user id for a given thread static func truncated(id: String, threadVariant: SessionThread.Variant = .contact) -> String { - switch threadVariant { - case .openGroup: return truncated(id: id, truncating: .start) - default: return truncated(id: id, truncating: .middle) - } + return truncated(id: id, truncating: .middle) } /// A standardised mechanism for truncating a user id - static func truncated(id: String, truncating: Truncation = .start) -> String { + static func truncated(id: String, truncating: Truncation = .middle) -> String { guard id.count > 8 else { return id } switch truncating { @@ -355,7 +352,7 @@ public extension Profile { case .openGroup: // In open groups, where it's more likely that multiple users have the same name, // we display a bit of the Session ID after a user's display name for added context - return "\(name) (\(Profile.truncated(id: id, truncating: .start)))" + return "\(name) (\(Profile.truncated(id: id, truncating: .middle)))" } } } diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index 9f0967104..1b4e55169 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -2,6 +2,7 @@ import Foundation import GRDB +import Sodium import SessionUtilitiesKit public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { @@ -314,6 +315,39 @@ public extension SessionThread { return profile.displayName() } } + + static func getUserHexEncodedBlindedKey( + threadId: String, + threadVariant: Variant + ) -> String? { + guard + threadVariant == .openGroup, + let blindingInfo: (edkeyPair: Box.KeyPair?, publicKey: String?) = Storage.shared.read({ db in + return ( + Identity.fetchUserEd25519KeyPair(db), + try OpenGroup + .filter(id: threadId) + .select(.publicKey) + .asRequest(of: String.self) + .fetchOne(db) + ) + }), + let userEdKeyPair: Box.KeyPair = blindingInfo.edkeyPair, + let publicKey: String = blindingInfo.publicKey + else { return nil } + + let sodium: Sodium = Sodium() + + let blindedKeyPair: Box.KeyPair? = sodium.blindedKeyPair( + serverPublicKey: publicKey, + edKeyPair: userEdKeyPair, + genericHash: sodium.getGenericHash() + ) + + return blindedKeyPair.map { keyPair -> String in + SessionId(.blinded, publicKey: keyPair.publicKey).hexString + } + } } // MARK: - Objective-C Support diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift index a52576076..ff813332e 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift @@ -44,11 +44,11 @@ extension MessageSender { // Send a closed group update message to all members individually var promises: [Promise] = [] - try members.forEach { adminId in + try members.forEach { memberId in try GroupMember( groupId: groupPublicKey, - profileId: adminId, - role: .admin + profileId: memberId, + role: .standard ).insert(db) } diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index 9e7467c99..bad4cb96e 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -29,6 +29,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, public static let quoteAttachmentKey: SQL = SQL(stringLiteral: CodingKeys.quoteAttachment.stringValue) public static let linkPreviewKey: SQL = SQL(stringLiteral: CodingKeys.linkPreview.stringValue) public static let linkPreviewAttachmentKey: SQL = SQL(stringLiteral: CodingKeys.linkPreviewAttachment.stringValue) + public static let currentUserPublicKeyKey: SQL = SQL(stringLiteral: CodingKeys.currentUserPublicKey.stringValue) public static let cellTypeKey: SQL = SQL(stringLiteral: CodingKeys.cellType.stringValue) public static let authorNameKey: SQL = SQL(stringLiteral: CodingKeys.authorName.stringValue) public static let shouldShowProfileKey: SQL = SQL(stringLiteral: CodingKeys.shouldShowProfile.stringValue) @@ -92,6 +93,8 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, public let linkPreview: LinkPreview? public let linkPreviewAttachment: Attachment? + public let currentUserPublicKey: String + // Post-Query Processing Data /// This value includes the associated attachments @@ -132,6 +135,9 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, /// This value indicates whether this is the last message in the thread public let isLast: Bool + + /// This is the users blinded key (will only be set for messages within open groups) + public let currentUserBlindedPublicKey: String? // MARK: - Mutation @@ -164,6 +170,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, quoteAttachment: self.quoteAttachment, linkPreview: self.linkPreview, linkPreviewAttachment: self.linkPreviewAttachment, + currentUserPublicKey: self.currentUserPublicKey, attachments: attachments, cellType: self.cellType, authorName: self.authorName, @@ -175,14 +182,16 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, previousVariant: self.previousVariant, positionInCluster: self.positionInCluster, isOnlyMessageInCluster: self.isOnlyMessageInCluster, - isLast: self.isLast + isLast: self.isLast, + currentUserBlindedPublicKey: self.currentUserBlindedPublicKey ) } public func withClusteringChanges( prevModel: MessageViewModel?, nextModel: MessageViewModel?, - isLast: Bool + isLast: Bool, + currentUserBlindedPublicKey: String? ) -> MessageViewModel { let cellType: CellType = { guard self.isTypingIndicator != true else { return .typingIndicator } @@ -338,6 +347,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, quoteAttachment: self.quoteAttachment, linkPreview: self.linkPreview, linkPreviewAttachment: self.linkPreviewAttachment, + currentUserPublicKey: self.currentUserPublicKey, attachments: self.attachments, cellType: cellType, authorName: authorDisplayName, @@ -385,7 +395,8 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, previousVariant: prevModel?.variant, positionInCluster: positionInCluster, isOnlyMessageInCluster: isOnlyMessageInCluster, - isLast: isLast + isLast: isLast, + currentUserBlindedPublicKey: currentUserBlindedPublicKey ) } } @@ -478,6 +489,7 @@ public extension MessageViewModel { self.quoteAttachment = nil self.linkPreview = nil self.linkPreviewAttachment = nil + self.currentUserPublicKey = "" // Post-Query Processing Data @@ -493,6 +505,7 @@ public extension MessageViewModel { self.positionInCluster = .middle self.isOnlyMessageInCluster = true self.isLast = true + self.currentUserBlindedPublicKey = nil } } @@ -557,7 +570,11 @@ public extension MessageViewModel { return SQL("\(interaction[.timestampMs].desc)") }() - static func baseQuery(orderSQL: SQL, groupSQL: SQL?) -> (([Int64]) -> AdaptedFetchRequest>) { + static func baseQuery( + userPublicKey: String, + orderSQL: SQL, + groupSQL: SQL? + ) -> (([Int64]) -> AdaptedFetchRequest>) { return { rowIds -> AdaptedFetchRequest> in let interaction: TypedTableAlias = TypedTableAlias() let thread: TypedTableAlias = TypedTableAlias() @@ -621,6 +638,8 @@ public extension MessageViewModel { \(ViewModel.linkPreviewKey).*, \(ViewModel.linkPreviewAttachmentKey).*, + \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey), + -- All of the below properties are set in post-query processing but to prevent the -- query from crashing when decoding we need to provide default values \(CellType.textOnlyMessage) AS \(ViewModel.cellTypeKey), diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index fc099a5cb..bb78b6940 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -2,6 +2,7 @@ import Foundation import GRDB +import Sodium import DifferenceKit import SessionUtilitiesKit @@ -124,6 +125,7 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat private let threadContactNameInternal: String? private let authorNameInternal: String? public let currentUserPublicKey: String + public let currentUserBlindedPublicKey: String? // UI specific logic @@ -275,6 +277,67 @@ public extension SessionThreadViewModel { self.threadContactNameInternal = nil self.authorNameInternal = nil self.currentUserPublicKey = getUserHexEncodedPublicKey() + self.currentUserBlindedPublicKey = nil + } +} + +// MARK: - Mutation + +public extension SessionThreadViewModel { + func populatingCurrentUserBlindedKey( + currentUserBlindedPublicKeyForThisThread: String? = nil + ) -> SessionThreadViewModel { + return SessionThreadViewModel( + rowId: self.rowId, + threadId: self.threadId, + threadVariant: self.threadVariant, + threadCreationDateTimestamp: self.threadCreationDateTimestamp, + threadMemberNames: self.threadMemberNames, + threadIsNoteToSelf: self.threadIsNoteToSelf, + threadIsMessageRequest: self.threadIsMessageRequest, + threadRequiresApproval: self.threadRequiresApproval, + threadShouldBeVisible: self.threadShouldBeVisible, + threadIsPinned: self.threadIsPinned, + threadIsBlocked: self.threadIsBlocked, + threadMutedUntilTimestamp: self.threadMutedUntilTimestamp, + threadOnlyNotifyForMentions: self.threadOnlyNotifyForMentions, + threadMessageDraft: self.threadMessageDraft, + threadContactIsTyping: self.threadContactIsTyping, + threadUnreadCount: self.threadUnreadCount, + threadUnreadMentionCount: self.threadUnreadMentionCount, + contactProfile: self.contactProfile, + closedGroupProfileFront: self.closedGroupProfileFront, + closedGroupProfileBack: self.closedGroupProfileBack, + closedGroupProfileBackFallback: self.closedGroupProfileBackFallback, + closedGroupName: self.closedGroupName, + closedGroupUserCount: self.closedGroupUserCount, + currentUserIsClosedGroupMember: self.currentUserIsClosedGroupMember, + currentUserIsClosedGroupAdmin: self.currentUserIsClosedGroupAdmin, + openGroupName: self.openGroupName, + openGroupServer: self.openGroupServer, + openGroupRoomToken: self.openGroupRoomToken, + openGroupProfilePictureData: self.openGroupProfilePictureData, + openGroupUserCount: self.openGroupUserCount, + interactionId: self.interactionId, + interactionVariant: self.interactionVariant, + interactionTimestampMs: self.interactionTimestampMs, + interactionBody: self.interactionBody, + interactionState: self.interactionState, + interactionIsOpenGroupInvitation: self.interactionIsOpenGroupInvitation, + interactionAttachmentDescriptionInfo: self.interactionAttachmentDescriptionInfo, + interactionAttachmentCount: self.interactionAttachmentCount, + authorId: self.authorId, + threadContactNameInternal: self.threadContactNameInternal, + authorNameInternal: self.authorNameInternal, + currentUserPublicKey: self.currentUserPublicKey, + currentUserBlindedPublicKey: ( + currentUserBlindedPublicKeyForThisThread ?? + SessionThread.getUserHexEncodedBlindedKey( + threadId: self.threadId, + threadVariant: self.threadVariant + ) + ) + ) } } diff --git a/SessionSnodeKit/Models/SnodeAPIEndpoint.swift b/SessionSnodeKit/Models/SnodeAPIEndpoint.swift index 1028740fe..63ffd5334 100644 --- a/SessionSnodeKit/Models/SnodeAPIEndpoint.swift +++ b/SessionSnodeKit/Models/SnodeAPIEndpoint.swift @@ -11,4 +11,6 @@ public enum SnodeAPIEndpoint: String { case getInfo = "info" case clearAllData = "delete_all" case expire = "expire" + case batch = "batch" + case sequence = "sequence" } diff --git a/SessionUIKit/Components/SearchBar.swift b/SessionUIKit/Components/SearchBar.swift index ecefdca22..830f0973f 100644 --- a/SessionUIKit/Components/SearchBar.swift +++ b/SessionUIKit/Components/SearchBar.swift @@ -26,7 +26,7 @@ public extension UISearchBar { let searchTextField: UITextField = self.searchTextField searchTextField.backgroundColor = Colors.searchBarBackground // The search bar background color searchTextField.textColor = Colors.text - searchTextField.attributedPlaceholder = NSAttributedString(string: NSLocalizedString("Search", comment: ""), attributes: [ .foregroundColor : Colors.searchBarPlaceholder ]) + searchTextField.attributedPlaceholder = NSAttributedString(string: "Search", attributes: [ .foregroundColor : Colors.searchBarPlaceholder ]) setPositionAdjustment(UIOffset(horizontal: 4, vertical: 0), for: UISearchBar.Icon.search) searchTextPositionAdjustment = UIOffset(horizontal: 2, vertical: 0) setPositionAdjustment(UIOffset(horizontal: -4, vertical: 0), for: UISearchBar.Icon.clear) diff --git a/SessionUtilitiesKit/Crypto/Mnemonic.swift b/SessionUtilitiesKit/Crypto/Mnemonic.swift index 1786f5e72..b420a89f7 100644 --- a/SessionUtilitiesKit/Crypto/Mnemonic.swift +++ b/SessionUtilitiesKit/Crypto/Mnemonic.swift @@ -48,11 +48,11 @@ public enum Mnemonic { public var errorDescription: String? { switch self { - case .generic: return NSLocalizedString("Something went wrong. Please check your recovery phrase and try again.", comment: "") - case .inputTooShort: return NSLocalizedString("Looks like you didn't enter enough words. Please check your recovery phrase and try again.", comment: "") - case .missingLastWord: return NSLocalizedString("You seem to be missing the last word of your recovery phrase. Please check what you entered and try again.", comment: "") - case .invalidWord: return NSLocalizedString("There appears to be an invalid word in your recovery phrase. Please check what you entered and try again.", comment: "") - case .verificationFailed: return NSLocalizedString("Your recovery phrase couldn't be verified. Please check what you entered and try again.", comment: "") + case .generic: return "RECOVERY_PHASE_ERROR_GENERIC".localized() + case .inputTooShort: return "RECOVERY_PHASE_ERROR_LENGTH".localized() + case .missingLastWord: return "RECOVERY_PHASE_ERROR_LAST_WORD".localized() + case .invalidWord: return "RECOVERY_PHASE_ERROR_INVALID_WORD".localized() + case .verificationFailed: return "RECOVERY_PHASE_ERROR_FAILED".localized() } } } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift index f5dcf5963..01bb11578 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift @@ -164,7 +164,7 @@ class AttachmentTextToolbar: UIView, UITextViewDelegate { private lazy var placeholderTextView: UITextView = { let placeholderTextView = buildTextView() - placeholderTextView.text = NSLocalizedString("Message", comment: "") + placeholderTextView.text = "Message" placeholderTextView.isEditable = false return placeholderTextView diff --git a/SignalUtilitiesKit/Screen Lock/OWSScreenLock.swift b/SignalUtilitiesKit/Screen Lock/OWSScreenLock.swift index dfa7a7279..306bac424 100644 --- a/SignalUtilitiesKit/Screen Lock/OWSScreenLock.swift +++ b/SignalUtilitiesKit/Screen Lock/OWSScreenLock.swift @@ -118,8 +118,7 @@ import SessionMessagingKit completion completionParam: @escaping ((OWSScreenLockOutcome) -> Void)) { AssertIsOnMainThread() - let defaultErrorDescription = NSLocalizedString("SCREEN_LOCK_ENABLE_UNKNOWN_ERROR", - comment: "Indicates that an unknown error occurred while using Touch ID/Face ID/Phone Passcode.") + let defaultErrorDescription = "SCREEN_LOCK_ENABLE_UNKNOWN_ERROR".localized() // Ensure completion is always called on the main thread. let completion = { (outcome: OWSScreenLockOutcome) in @@ -140,7 +139,7 @@ import SessionMessagingKit switch outcome { case .success: owsFailDebug("local authentication unexpected success") - completion(.failure(error:defaultErrorDescription)) + completion(.failure(error: defaultErrorDescription)) case .cancel, .failure, .unexpectedFailure: completion(outcome) } @@ -177,16 +176,13 @@ import SessionMessagingKit switch laError.code { case .biometryNotAvailable: Logger.error("local authentication error: biometryNotAvailable.") - return .failure(error: NSLocalizedString("SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE", - comment: "Indicates that Touch ID/Face ID/Phone Passcode are not available on this device.")) + return .failure(error: "SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE".localized()) case .biometryNotEnrolled: Logger.error("local authentication error: biometryNotEnrolled.") - return .failure(error: NSLocalizedString("SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED", - comment: "Indicates that Touch ID/Face ID/Phone Passcode is not configured on this device.")) + return .failure(error: "SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED".localized()) case .biometryLockout: Logger.error("local authentication error: biometryLockout.") - return .failure(error: NSLocalizedString("SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT", - comment: "Indicates that Touch ID/Face ID/Phone Passcode is 'locked out' on this device due to authentication failures.")) + return .failure(error: "SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT".localized()) default: // Fall through to second switch break @@ -195,27 +191,22 @@ import SessionMessagingKit switch laError.code { case .authenticationFailed: Logger.error("local authentication error: authenticationFailed.") - return .failure(error: NSLocalizedString("SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_FAILED", - comment: "Indicates that Touch ID/Face ID/Phone Passcode authentication failed.")) + return .failure(error: "SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_FAILED".localized()) case .userCancel, .userFallback, .systemCancel, .appCancel: Logger.info("local authentication cancelled.") return .cancel case .passcodeNotSet: Logger.error("local authentication error: passcodeNotSet.") - return .failure(error: NSLocalizedString("SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_PASSCODE_NOT_SET", - comment: "Indicates that Touch ID/Face ID/Phone Passcode passcode is not set.")) + return .failure(error: "SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_PASSCODE_NOT_SET".localized()) case .touchIDNotAvailable: Logger.error("local authentication error: touchIDNotAvailable.") - return .failure(error: NSLocalizedString("SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE", - comment: "Indicates that Touch ID/Face ID/Phone Passcode are not available on this device.")) + return .failure(error: "SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE".localized()) case .touchIDNotEnrolled: Logger.error("local authentication error: touchIDNotEnrolled.") - return .failure(error: NSLocalizedString("SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED", - comment: "Indicates that Touch ID/Face ID/Phone Passcode is not configured on this device.")) + return .failure(error: "SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED".localized()) case .touchIDLockout: Logger.error("local authentication error: touchIDLockout.") - return .failure(error: NSLocalizedString("SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT", - comment: "Indicates that Touch ID/Face ID/Phone Passcode is 'locked out' on this device due to authentication failures.")) + return .failure(error: "SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT".localized()) case .invalidContext: owsFailDebug("context not valid.") return .unexpectedFailure(error:defaultErrorDescription) diff --git a/SignalUtilitiesKit/Utilities/CommonStrings.swift b/SignalUtilitiesKit/Utilities/CommonStrings.swift index 0bcac0c4f..b3c7f6c88 100644 --- a/SignalUtilitiesKit/Utilities/CommonStrings.swift +++ b/SignalUtilitiesKit/Utilities/CommonStrings.swift @@ -16,7 +16,7 @@ import Foundation @objc static public let doneButton = NSLocalizedString("BUTTON_DONE", comment: "Label for generic done button.") @objc - static public let retryButton = NSLocalizedString("RETRY_BUTTON_TEXT", comment: "Generic text for button that retries whatever the last action was.") + static public let retryButton = "RETRY_BUTTON_TEXT".localized() @objc static public let openSettingsButton = NSLocalizedString("OPEN_SETTINGS_BUTTON", comment: "Button text which opens the settings app") @objc @@ -31,20 +31,11 @@ import Foundation static public let markAsReadNotificationAction = NSLocalizedString("PUSH_MANAGER_MARKREAD", comment: "Notification action button title") @objc - static public let sendButton = NSLocalizedString("SEND_BUTTON_TITLE", comment: "Label for the button to send a message") + static public let sendButton = "SEND_BUTTON_TITLE".localized() } @objc public class NotificationStrings: NSObject { - @objc - static public let incomingCallBody = NSLocalizedString("CALL_INCOMING_NOTIFICATION_BODY", comment: "notification body") - - @objc - static public let missedCallBody = NSLocalizedString("CALL_MISSED_NOTIFICATION_BODY", comment: "notification body") - - @objc - static public let missedCallBecauseOfIdentityChangeBody = NSLocalizedString("CALL_MISSED_BECAUSE_OF_IDENTITY_CHANGE_NOTIFICATION_BODY", comment: "notification body") - @objc static public let incomingMessageBody = NSLocalizedString("APN_Message", comment: "notification body") @@ -55,41 +46,16 @@ public class NotificationStrings: NSObject { static public let incomingGroupMessageTitleFormat = NSLocalizedString("NEW_GROUP_MESSAGE_NOTIFICATION_TITLE", comment: "notification title. Embeds {{author name}} and {{group name}}") @objc - static public let failedToSendBody = NSLocalizedString("SEND_FAILED_NOTIFICATION_BODY", comment: "notification body") + static public let failedToSendBody = "SEND_FAILED_NOTIFICATION_BODY".localized() } @objc public class CallStrings: NSObject { - @objc - static public let callStatusFormat = NSLocalizedString("CALL_STATUS_FORMAT", comment: "embeds {{Call Status}} in call screen label. For ongoing calls, {{Call Status}} is a seconds timer like 01:23, otherwise {{Call Status}} is a short text like 'Ringing', 'Busy', or 'Failed Call'") - - @objc - static public let confirmAndCallButtonTitle = NSLocalizedString("SAFETY_NUMBER_CHANGED_CONFIRM_CALL_ACTION", comment: "alert button text to confirm placing an outgoing call after the recipients Safety Number has changed.") - - @objc - static public let callBackAlertTitle = NSLocalizedString("CALL_USER_ALERT_TITLE", comment: "Title for alert offering to call a user.") - @objc - static public let callBackAlertMessageFormat = NSLocalizedString("CALL_USER_ALERT_MESSAGE_FORMAT", comment: "Message format for alert offering to call a user. Embeds {{the user's display name or phone number}}.") - @objc - static public let callBackAlertCallButton = NSLocalizedString("CALL_USER_ALERT_CALL_BUTTON", comment: "Label for call button for alert offering to call a user.") - // MARK: Notification actions @objc - static public let callBackButtonTitle = NSLocalizedString("CALLBACK_BUTTON_TITLE", comment: "notification action") - @objc - static public let showThreadButtonTitle = NSLocalizedString("SHOW_THREAD_BUTTON_TITLE", comment: "notification action") - @objc - static public let answerCallButtonTitle = NSLocalizedString("ANSWER_CALL_BUTTON_TITLE", comment: "notification action") - @objc - static public let declineCallButtonTitle = NSLocalizedString("REJECT_CALL_BUTTON_TITLE", comment: "notification action") + static public let showThreadButtonTitle = "SHOW_THREAD_BUTTON_TITLE".localized() } @objc public class MediaStrings: NSObject { @objc static public let allMedia = NSLocalizedString("MEDIA_DETAIL_VIEW_ALL_MEDIA_BUTTON", comment: "nav bar button item") } - -@objc public class SafetyNumberStrings: NSObject { - @objc - static public let confirmSendButton = NSLocalizedString("SAFETY_NUMBER_CHANGED_CONFIRM_SEND_ACTION", - comment: "button title to confirm sending to a recipient whose safety number recently changed") -} diff --git a/SignalUtilitiesKit/Utilities/OWSAlerts.swift b/SignalUtilitiesKit/Utilities/OWSAlerts.swift index caa9d7e6d..f8cc99beb 100644 --- a/SignalUtilitiesKit/Utilities/OWSAlerts.swift +++ b/SignalUtilitiesKit/Utilities/OWSAlerts.swift @@ -6,25 +6,6 @@ import Foundation import SessionUtilitiesKit @objc public class OWSAlerts: NSObject { - - /// Cleanup and present alert for no permissions - @objc - public class func showNoMicrophonePermissionAlert() { - let alertTitle = NSLocalizedString("CALL_AUDIO_PERMISSION_TITLE", comment: "Alert title when calling and permissions for microphone are missing") - let alertMessage = NSLocalizedString("CALL_AUDIO_PERMISSION_MESSAGE", comment: "Alert message when calling and permissions for microphone are missing") - let alert = UIAlertController(title: alertTitle, message: alertMessage, preferredStyle: .alert) - - let dismissAction = UIAlertAction(title: CommonStrings.dismissButton, style: .cancel) - dismissAction.accessibilityIdentifier = "OWSAlerts.\("dismiss")" - alert.addAction(dismissAction) - - if let settingsAction = CurrentAppContext().openSystemSettingsAction { - settingsAction.accessibilityIdentifier = "OWSAlerts.\("settings")" - alert.addAction(settingsAction) - } - CurrentAppContext().frontmostViewController()?.presentAlert(alert) - } - @objc public class func showAlert(_ alert: UIAlertController) { guard let frontmostViewController = CurrentAppContext().frontmostViewController() else { From 9fff4dce200bd54a74ed793a08004586f2da3c6c Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 18 Jul 2022 09:54:23 +1000 Subject: [PATCH 136/157] Fixed a number of bugs found during QA Fixed a bug where the "Block user" toggle wasn't correctly reflecting the current users state Fixed a bug where the "Blocked banner" wasn't showing the "unblock this user" alert. Fixed a bug where the "Blocked banner" wouldn't re-appear if you re-block a user after unblocking them. Fixed a bug where the conversation screen unblocking logic wasn't actually unblocking the user. Fixed a bug where some settings options were disabled in open groups because the code thought the user had left the group. Fixed a bug where the settings button wouldn't appear after accepting a message request. --- .../ConversationVC+Interaction.swift | 22 ++---------- Session/Conversations/ConversationVC.swift | 35 ++++++++++--------- .../OWSConversationSettingsViewController.m | 2 +- .../Views & Modals/BlockedModal.swift | 2 +- .../Database/Models/Contact.swift | 23 +----------- .../Database/Models/SessionThread.swift | 2 +- 6 files changed, 24 insertions(+), 62 deletions(-) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index aaad028c0..5b7d4dbc3 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -82,29 +82,11 @@ extension ConversationVC: // MARK: - Blocking @objc func unblock() { - guard self.viewModel.threadData.threadVariant == .contact else { return } - - let publicKey: String = self.viewModel.threadData.threadId - - UIView.animate( - withDuration: 0.25, - animations: { - self.blockedBanner.alpha = 0 - }, - completion: { _ in - Storage.shared.write { db in - try Contact - .filter(id: publicKey) - .updateAll(db, Contact.Columns.isBlocked.set(to: true)) - - try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() - } - } - ) + self.showBlockedModalIfNeeded() } func showBlockedModalIfNeeded() -> Bool { - guard viewModel.threadData.threadIsBlocked == true else { return false } + guard self.viewModel.threadData.threadIsBlocked == true else { return false } let blockedModal = BlockedModal(publicKey: viewModel.threadData.threadId) blockedModal.modalPresentationStyle = .overFullScreen diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 3b5e8241e..0ee0ddb1c 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -551,29 +551,21 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers if initialLoad || viewModel.threadData.threadRequiresApproval != updatedThreadData.threadRequiresApproval || + viewModel.threadData.threadIsMessageRequest != updatedThreadData.threadIsMessageRequest || viewModel.threadData.profile != updatedThreadData.profile { updateNavBarButtons(threadData: updatedThreadData, initialVariant: viewModel.initialThreadVariant) - } - - if initialLoad || viewModel.threadData.threadIsBlocked != updatedThreadData.threadIsBlocked { - addOrRemoveBlockedBanner(threadIsBlocked: (updatedThreadData.threadIsBlocked == true)) - } - - if initialLoad || viewModel.threadData.threadIsMessageRequest != updatedThreadData.threadIsMessageRequest { - scrollButtonMessageRequestsBottomConstraint?.isActive = (updatedThreadData.threadIsMessageRequest == true) - scrollButtonBottomConstraint?.isActive = (updatedThreadData.threadIsMessageRequest == false) - } - - if - initialLoad || - viewModel.threadData.threadRequiresApproval != updatedThreadData.threadRequiresApproval || - viewModel.threadData.threadIsMessageRequest != updatedThreadData.threadIsMessageRequest - { + messageRequestView.isHidden = ( updatedThreadData.threadIsMessageRequest == false || updatedThreadData.threadRequiresApproval == true ) + scrollButtonMessageRequestsBottomConstraint?.isActive = (updatedThreadData.threadIsMessageRequest == true) + scrollButtonBottomConstraint?.isActive = (updatedThreadData.threadIsMessageRequest == false) + } + + if initialLoad || viewModel.threadData.threadIsBlocked != updatedThreadData.threadIsBlocked { + addOrRemoveBlockedBanner(threadIsBlocked: (updatedThreadData.threadIsBlocked == true)) } if initialLoad || viewModel.threadData.threadUnreadCount != updatedThreadData.threadUnreadCount { @@ -1056,7 +1048,16 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers func addOrRemoveBlockedBanner(threadIsBlocked: Bool) { guard threadIsBlocked else { - self.blockedBanner.removeFromSuperview() + UIView.animate( + withDuration: 0.25, + animations: { [weak self] in + self?.blockedBanner.alpha = 0 + }, + completion: { [weak self] _ in + self?.blockedBanner.alpha = 1 + self?.blockedBanner.removeFromSuperview() + } + ) return } diff --git a/Session/Conversations/Settings/OWSConversationSettingsViewController.m b/Session/Conversations/Settings/OWSConversationSettingsViewController.m index 8eadd24dd..8af9dc453 100644 --- a/Session/Conversations/Settings/OWSConversationSettingsViewController.m +++ b/Session/Conversations/Settings/OWSConversationSettingsViewController.m @@ -718,7 +718,7 @@ CGFloat kIconViewLength = 24; - (BOOL)hasLeftGroup { - if (self.isClosedGroup || self.isOpenGroup) { + if (self.isClosedGroup) { return ![SMKGroupMember isCurrentUserMemberOf:self.threadId]; } diff --git a/Session/Conversations/Views & Modals/BlockedModal.swift b/Session/Conversations/Views & Modals/BlockedModal.swift index 15b3f8f18..ba24745bb 100644 --- a/Session/Conversations/Views & Modals/BlockedModal.swift +++ b/Session/Conversations/Views & Modals/BlockedModal.swift @@ -81,7 +81,7 @@ final class BlockedModal: Modal { Storage.shared.writeAsync { db in try Contact .filter(id: publicKey) - .updateAll(db, Contact.Columns.isBlocked.set(to: true)) + .updateAll(db, Contact.Columns.isBlocked.set(to: false)) try MessageSender .syncConfiguration(db, forceSyncNow: true) diff --git a/SessionMessagingKit/Database/Models/Contact.swift b/SessionMessagingKit/Database/Models/Contact.swift index 57e93ae0c..ab85bb808 100644 --- a/SessionMessagingKit/Database/Models/Contact.swift +++ b/SessionMessagingKit/Database/Models/Contact.swift @@ -106,33 +106,12 @@ public extension Contact { // TODO: Remove this when possible @objc(SMKContact) public class SMKContact: NSObject { - @objc let isApproved: Bool - @objc let isBlocked: Bool - @objc let didApproveMe: Bool - - init(isApproved: Bool, isBlocked: Bool, didApproveMe: Bool) { - self.isApproved = isApproved - self.isBlocked = isBlocked - self.didApproveMe = didApproveMe - } - - @objc public static func fetchOrCreate(id: String) -> SMKContact { - let existingContact: Contact? = Storage.shared.read { db in - try Contact.fetchOne(db, id: id) - } - - return SMKContact( - isApproved: existingContact?.isApproved ?? false, - isBlocked: existingContact?.isBlocked ?? false, - didApproveMe: existingContact?.didApproveMe ?? false - ) - } - @objc(isBlockedFor:) public static func isBlocked(id: String) -> Bool { return Storage.shared .read { db in try Contact + .filter(id: id) .select(.isBlocked) .asRequest(of: Bool.self) .fetchOne(db) diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index 1b4e55169..452ab8c49 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -381,7 +381,7 @@ public class SMKThread: NSObject { public static func isOnlyNotifyingForMentions(_ threadId: String) -> Bool { return Storage.shared.read { db in return try SessionThread - .select(SessionThread.Columns.onlyNotifyForMentions == true) + .select(SessionThread.Columns.onlyNotifyForMentions) .filter(id: threadId) .asRequest(of: Bool.self) .fetchOne(db) From d730ce3e6232dbc03a3d522afeab822120b74539 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 18 Jul 2022 10:08:26 +1000 Subject: [PATCH 137/157] Reverted the logic to only download attachments when opening a conversation (new flag in future) Fixed a minor bug where the UpdateProfilePictureJob could get stuck in a "defer loop" --- .../Conversations/ConversationViewModel.swift | 44 ------------------- .../Jobs/Types/UpdateProfilePictureJob.swift | 9 ++++ .../MessageReceiver+VisibleMessages.swift | 33 ++++++++++++-- 3 files changed, 38 insertions(+), 48 deletions(-) diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 4cc9d64a7..28bf921a7 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -36,12 +36,6 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { public var lastSearchedText: String? public let focusedInteractionId: Int64? // Note: This is used for global search - /// We maintain a local set of ids for attachments which we have automatically created attachmentDownload jobs for - /// in order to avoid creating excessive jobs while the user is actively chatting in a conversation (the attachmentDownload - /// jobs run serially and will only actually perform the download if the attachment hasn't already been downloaded so - /// we don't need to worry about duplicate jobs but it's better to avoid creating duplicate jobs when possible) - private var autoStartedDownloadJobAttachmentIds: Set = [] - public lazy var blockedBannerMessage: String = { switch self.threadData.threadVariant { case .contact: @@ -260,44 +254,6 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { .filter { $0.isTypingIndicator != true } .sorted { lhs, rhs -> Bool in lhs.timestampMs < rhs.timestampMs } - // Add download jobs for any attachments which need to be downloaded - let pendingAttachmentsToDownload: [(attachment: Attachment, interactionId: Int64)] = sortedData - .flatMap { viewModel -> [(attachment: Attachment, interactionId: Int64)] in - // Do nothing if this is an incoming message on an untrusted contact thread - guard - viewModel.variant != .standardIncoming || - viewModel.threadIsTrusted || - viewModel.threadVariant != .contact - else { return [] } - - return (viewModel.attachments ?? []) - .appending(viewModel.quoteAttachment) - .appending(viewModel.linkPreviewAttachment) - .filter { $0.state == .pendingDownload } - .filter { !self.autoStartedDownloadJobAttachmentIds.contains($0.id) } - .map { ($0, viewModel.id) } - } - - if !pendingAttachmentsToDownload.isEmpty { - Storage.shared.writeAsync { db in - pendingAttachmentsToDownload.forEach { attachment, interactionId in - JobRunner.add( - db, - job: Job( - variant: .attachmentDownload, - threadId: self.threadId, - interactionId: interactionId, - details: AttachmentDownloadJob.Details( - attachmentId: attachment.id - ) - ) - ) - - self.autoStartedDownloadJobAttachmentIds.insert(attachment.id) - } - } - } - // We load messages from newest to oldest so having a pageOffset larger than zero means // there are newer pages to load return [ diff --git a/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift b/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift index f20cc5cca..803ea34b9 100644 --- a/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift +++ b/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift @@ -28,6 +28,15 @@ public enum UpdateProfilePictureJob: JobExecutor { let lastProfilePictureUpload: Date = UserDefaults.standard[.lastProfilePictureUpload], Date().timeIntervalSince(lastProfilePictureUpload) > (14 * 24 * 60 * 60) else { + // Reset the `nextRunTimestamp` value just in case the last run failed so we don't get stuck + // in a loop endlessly deferring the job + if let jobId: Int64 = job.id { + Storage.shared.write { db in + try Job + .filter(id: jobId) + .updateAll(db, Job.Columns.nextRunTimestamp.set(to: 0)) + } + } deferred(job) return } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index 1dc545ad1..a18e0560b 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -204,20 +204,20 @@ extension MessageReceiver { message.attachmentIds = attachments.map { $0.id } // Persist quote if needed - try? Quote( + let quote: Quote? = try? Quote( db, proto: dataMessage, interactionId: interactionId, thread: thread - )?.insert(db) + )?.inserted(db) // Parse link preview if needed - try? LinkPreview( + let linkPreview: LinkPreview? = try? LinkPreview( db, proto: dataMessage, body: message.text, sentTimestampMs: (messageSentTimestamp * 1000) - )?.save(db) + )?.saved(db) // Open group invitations are stored as LinkPreview values so create one if needed if @@ -232,6 +232,31 @@ extension MessageReceiver { ).save(db) } + // Start attachment downloads if needed (ie. trusted contact or group thread) + // FIXME: Replace this to check the `autoDownloadAttachments` flag we are adding to threads + let isContactTrusted: Bool = ((try? Contact.fetchOne(db, id: sender))?.isTrusted ?? false) + + if isContactTrusted || thread.variant != .contact { + attachments + .map { $0.id } + .appending(quote?.attachmentId) + .appending(linkPreview?.attachmentId) + .forEach { attachmentId in + JobRunner.add( + db, + job: Job( + variant: .attachmentDownload, + threadId: thread.id, + interactionId: interactionId, + details: AttachmentDownloadJob.Details( + attachmentId: attachmentId + ) + ), + canStartJob: isMainAppActive + ) + } + } + // Cancel any typing indicators if needed if isMainAppActive { TypingIndicators.didStopTyping(db, threadId: thread.id, direction: .incoming) From 0d80678a77759875e4671d1817a72c8d50801e53 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 18 Jul 2022 12:32:46 +1000 Subject: [PATCH 138/157] Updated the message request approval process to run asynchronously Fixed a bug where the MessageRequestsViewController wouldn't page properly in certain cases --- .../ConversationVC+Interaction.swift | 366 ++++++++---------- Session/Conversations/ConversationVC.swift | 32 +- Session/Home/HomeVC.swift | 10 +- .../MessageRequestsViewController.swift | 54 ++- .../Translations/de.lproj/Localizable.strings | 1 - .../Translations/en.lproj/Localizable.strings | 1 - .../Translations/es.lproj/Localizable.strings | 1 - .../Translations/fa.lproj/Localizable.strings | 1 - .../Translations/fi.lproj/Localizable.strings | 1 - .../Translations/fr.lproj/Localizable.strings | 1 - .../Translations/hi.lproj/Localizable.strings | 1 - .../Translations/hr.lproj/Localizable.strings | 1 - .../id-ID.lproj/Localizable.strings | 1 - .../Translations/it.lproj/Localizable.strings | 1 - .../Translations/ja.lproj/Localizable.strings | 1 - .../Translations/nl.lproj/Localizable.strings | 1 - .../Translations/pl.lproj/Localizable.strings | 1 - .../pt_BR.lproj/Localizable.strings | 1 - .../Translations/ru.lproj/Localizable.strings | 1 - .../Translations/si.lproj/Localizable.strings | 1 - .../Translations/sk.lproj/Localizable.strings | 1 - .../Translations/sv.lproj/Localizable.strings | 1 - .../Translations/th.lproj/Localizable.strings | 1 - .../vi-VN.lproj/Localizable.strings | 1 - .../zh-Hant.lproj/Localizable.strings | 1 - .../zh_CN.lproj/Localizable.strings | 1 - 26 files changed, 238 insertions(+), 246 deletions(-) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 5b7d4dbc3..33dfce0fb 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -338,83 +338,77 @@ extension ConversationVC: let linkPreviewDraft: LinkPreviewDraft? = snInputView.linkPreviewInfo?.draft let quoteModel: QuotedReplyModel? = snInputView.quoteDraftInfo?.model + // If this was a message request then approve it approveMessageRequestIfNeeded( for: threadId, threadVariant: self.viewModel.threadData.threadVariant, isNewThread: !oldThreadShouldBeVisible, timestampMs: (sentTimestampMs - 1) // Set 1ms earlier as this is used for sorting ) - .done { [weak self] _ in - Storage.shared.writeAsync( - updates: { db in - guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { - return - } - - // Let the viewModel know we are about to send a message - self?.viewModel.sentMessageBeforeUpdate = true - - // Update the thread to be visible - _ = try SessionThread - .filter(id: threadId) - .updateAll(db, SessionThread.Columns.shouldBeVisible.set(to: true)) - - // Create the interaction - let interaction: Interaction = try Interaction( - threadId: threadId, - authorId: getUserHexEncodedPublicKey(db), - variant: .standardOutgoing, - body: text, - timestampMs: sentTimestampMs, - hasMention: Interaction.isUserMentioned(db, threadId: threadId, body: text), - linkPreviewUrl: linkPreviewDraft?.urlString - ).inserted(db) - - // If there is a LinkPreview and it doesn't match an existing one then add it now - if - let linkPreviewDraft: LinkPreviewDraft = linkPreviewDraft, - (try? interaction.linkPreview.isEmpty(db)) == true - { - try LinkPreview( - url: linkPreviewDraft.urlString, - title: linkPreviewDraft.title, - attachmentId: LinkPreview.saveAttachmentIfPossible( - db, - imageData: linkPreviewDraft.jpegImageData, - mimeType: OWSMimeTypeImageJpeg - ) - ).insert(db) - } - - // If there is a Quote the insert it now - if let interactionId: Int64 = interaction.id, let quoteModel: QuotedReplyModel = quoteModel { - try Quote( - interactionId: interactionId, - authorId: quoteModel.authorId, - timestampMs: quoteModel.timestampMs, - body: quoteModel.body, - attachmentId: quoteModel.generateAttachmentThumbnailIfNeeded(db) - ).insert(db) - } - - try MessageSender.send( - db, - interaction: interaction, - in: thread - ) - }, - completion: { [weak self] _, _ in - self?.handleMessageSent() + + // Send the message + Storage.shared.writeAsync( + updates: { [weak self] db in + guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { + return } - ) - } - .catch(on: DispatchQueue.main) { [weak self] _ in - // Show an error indicating that approving the thread failed - let alert = UIAlertController(title: "Session", message: "An error occurred when trying to accept this message request", preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) - self?.present(alert, animated: true, completion: nil) - } - .retainUntilComplete() + + // Let the viewModel know we are about to send a message + self?.viewModel.sentMessageBeforeUpdate = true + + // Update the thread to be visible + _ = try SessionThread + .filter(id: threadId) + .updateAll(db, SessionThread.Columns.shouldBeVisible.set(to: true)) + + // Create the interaction + let interaction: Interaction = try Interaction( + threadId: threadId, + authorId: getUserHexEncodedPublicKey(db), + variant: .standardOutgoing, + body: text, + timestampMs: sentTimestampMs, + hasMention: Interaction.isUserMentioned(db, threadId: threadId, body: text), + linkPreviewUrl: linkPreviewDraft?.urlString + ).inserted(db) + + // If there is a LinkPreview and it doesn't match an existing one then add it now + if + let linkPreviewDraft: LinkPreviewDraft = linkPreviewDraft, + (try? interaction.linkPreview.isEmpty(db)) == true + { + try LinkPreview( + url: linkPreviewDraft.urlString, + title: linkPreviewDraft.title, + attachmentId: LinkPreview.saveAttachmentIfPossible( + db, + imageData: linkPreviewDraft.jpegImageData, + mimeType: OWSMimeTypeImageJpeg + ) + ).insert(db) + } + + // If there is a Quote the insert it now + if let interactionId: Int64 = interaction.id, let quoteModel: QuotedReplyModel = quoteModel { + try Quote( + interactionId: interactionId, + authorId: quoteModel.authorId, + timestampMs: quoteModel.timestampMs, + body: quoteModel.body, + attachmentId: quoteModel.generateAttachmentThumbnailIfNeeded(db) + ).insert(db) + } + + try MessageSender.send( + db, + interaction: interaction, + in: thread + ) + }, + completion: { [weak self] _, _ in + self?.handleMessageSent() + } + ) } func sendAttachments(_ attachments: [SignalAttachment], with text: String, onComplete: (() -> ())? = nil) { @@ -435,61 +429,55 @@ extension ConversationVC: let oldThreadShouldBeVisible: Bool = (self.viewModel.threadData.threadShouldBeVisible == true) let sentTimestampMs: Int64 = Int64(floor((Date().timeIntervalSince1970 * 1000))) + // If this was a message request then approve it approveMessageRequestIfNeeded( for: threadId, threadVariant: self.viewModel.threadData.threadVariant, isNewThread: !oldThreadShouldBeVisible, timestampMs: (sentTimestampMs - 1) // Set 1ms earlier as this is used for sorting ) - .done { [weak self] _ in - Storage.shared.writeAsync( - updates: { db in - guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { - return - } - - // Let the viewModel know we are about to send a message - self?.viewModel.sentMessageBeforeUpdate = true - - // Update the thread to be visible - _ = try SessionThread - .filter(id: threadId) - .updateAll(db, SessionThread.Columns.shouldBeVisible.set(to: true)) - - // Create the interaction - let interaction: Interaction = try Interaction( - threadId: threadId, - authorId: getUserHexEncodedPublicKey(db), - variant: .standardOutgoing, - body: text, - timestampMs: sentTimestampMs, - hasMention: Interaction.isUserMentioned(db, threadId: threadId, body: text) - ).inserted(db) - - try MessageSender.send( - db, - interaction: interaction, - with: attachments, - in: thread - ) - }, - completion: { [weak self] _, _ in - self?.handleMessageSent() - - // Attachment successfully sent - dismiss the screen - DispatchQueue.main.async { - onComplete?() - } + + // Send the message + Storage.shared.writeAsync( + updates: { [weak self] db in + guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { + return } - ) - } - .catch(on: DispatchQueue.main) { [weak self] _ in - // Show an error indicating that approving the thread failed - let alert = UIAlertController(title: "Session", message: "An error occurred when trying to accept this message request", preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) - self?.present(alert, animated: true, completion: nil) - } - .retainUntilComplete() + + // Let the viewModel know we are about to send a message + self?.viewModel.sentMessageBeforeUpdate = true + + // Update the thread to be visible + _ = try SessionThread + .filter(id: threadId) + .updateAll(db, SessionThread.Columns.shouldBeVisible.set(to: true)) + + // Create the interaction + let interaction: Interaction = try Interaction( + threadId: threadId, + authorId: getUserHexEncodedPublicKey(db), + variant: .standardOutgoing, + body: text, + timestampMs: sentTimestampMs, + hasMention: Interaction.isUserMentioned(db, threadId: threadId, body: text) + ).inserted(db) + + try MessageSender.send( + db, + interaction: interaction, + with: attachments, + in: thread + ) + }, + completion: { [weak self] _, _ in + self?.handleMessageSent() + + // Attachment successfully sent - dismiss the screen + DispatchQueue.main.async { + onComplete?() + } + } + ) } func handleMessageSent() { @@ -1635,8 +1623,8 @@ extension ConversationVC { threadVariant: SessionThread.Variant, isNewThread: Bool, timestampMs: Int64 - ) -> Promise { - guard threadVariant == .contact else { return Promise.value(()) } + ) { + guard threadVariant == .contact else { return } // If the contact doesn't exist then we should create it so we can store the 'isApproved' state // (it'll be updated with correct profile info if they accept the message request so this @@ -1651,97 +1639,54 @@ extension ConversationVC { let thread: SessionThread = approvalData.thread, !approvalData.contact.isApproved else { - return Promise.value(()) + return } - - return Promise.value(()) - .then { [weak self] _ -> Promise in - guard !isNewThread else { return Promise.value(()) } - guard let strongSelf = self else { return Promise(error: MessageSenderError.noThread) } - + + Storage.shared.writeAsync( + updates: { db in // If we aren't creating a new thread (ie. sending a message request) then send a // messageRequestResponse back to the sender (this allows the sender to know that // they have been approved and can now use this contact in closed groups) - let (promise, seal) = Promise.pending() - let messageRequestResponse: MessageRequestResponse = MessageRequestResponse( - isApproved: true, - sentTimestampMs: UInt64(timestampMs) - ) - - // Show a loading indicator - ModalActivityIndicatorViewController.present(fromViewController: strongSelf, canCancel: false) { _ in - seal.fulfill(()) + if !isNewThread { + try MessageSender.send( + db, + message: MessageRequestResponse( + isApproved: true, + sentTimestampMs: UInt64(timestampMs) + ), + interactionId: nil, + in: thread + ) } - - return promise - .then { _ -> Promise in - Storage.shared.writeAsync { db in - try MessageSender.sendNonDurably( - db, - message: messageRequestResponse, - interactionId: nil, - in: thread - ) - } - } - .map { _ in - if self?.presentedViewController is ModalActivityIndicatorViewController { - self?.dismiss(animated: true, completion: nil) // Dismiss the loader - } - } - } - .map { _ in + // Default 'didApproveMe' to true for the person approving the message request - Storage.shared.writeAsync( - updates: { db in - try approvalData.contact - .with( - isApproved: true, - didApproveMe: .update(approvalData.contact.didApproveMe || !isNewThread) - ) - .save(db) - - // Send a sync message with the details of the contact - try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() - }, - completion: { db, _ in - // Hide the 'messageRequestView' since the request has been approved - DispatchQueue.main.async { [weak self] in - let messageRequestViewWasVisible: Bool = (self?.messageRequestView.isHidden == false) - - UIView.animate(withDuration: 0.3) { - self?.messageRequestView.isHidden = true - self?.scrollButtonMessageRequestsBottomConstraint?.isActive = false - self?.scrollButtonBottomConstraint?.isActive = true - - // Update the table content inset and offset to account for - // the dissapearance of the messageRequestsView - if messageRequestViewWasVisible { - let messageRequestsOffset: CGFloat = ((self?.messageRequestView.bounds.height ?? 0) + 16) - let oldContentInset: UIEdgeInsets = (self?.tableView.contentInset ?? UIEdgeInsets.zero) - self?.tableView.contentInset = UIEdgeInsets( - top: 0, - leading: 0, - bottom: max(oldContentInset.bottom - messageRequestsOffset, 0), - trailing: 0 - ) - } - } - - // Remove the 'MessageRequestsViewController' from the nav hierarchy if present - if - let viewControllers: [UIViewController] = self?.navigationController?.viewControllers, - let messageRequestsIndex = viewControllers.firstIndex(where: { $0 is MessageRequestsViewController }), - messageRequestsIndex > 0 - { - var newViewControllers = viewControllers - newViewControllers.remove(at: messageRequestsIndex) - self?.navigationController?.viewControllers = newViewControllers - } - } + try approvalData.contact + .with( + isApproved: true, + didApproveMe: .update(approvalData.contact.didApproveMe || !isNewThread) + ) + .save(db) + + // Send a sync message with the details of the contact + try MessageSender + .syncConfiguration(db, forceSyncNow: true) + .retainUntilComplete() + }, + completion: { _, _ in + // Remove the 'MessageRequestsViewController' from the nav hierarchy if present + DispatchQueue.main.async { [weak self] in + if + let viewControllers: [UIViewController] = self?.navigationController?.viewControllers, + let messageRequestsIndex = viewControllers.firstIndex(where: { $0 is MessageRequestsViewController }), + messageRequestsIndex > 0 + { + var newViewControllers = viewControllers + newViewControllers.remove(at: messageRequestsIndex) + self?.navigationController?.viewControllers = newViewControllers } - ) + } } + ) } @objc func acceptMessageRequest() { @@ -1751,17 +1696,6 @@ extension ConversationVC { isNewThread: false, timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) ) - .catch(on: DispatchQueue.main) { [weak self] _ in - // Show an error indicating that approving the thread failed - let alert = UIAlertController( - title: "Session", - message: "MESSAGE_REQUESTS_APPROVAL_ERROR_MESSAGE".localized(), - preferredStyle: .alert - ) - alert.addAction(UIAlertAction(title: "BUTTON_OK".localized(), style: .default, handler: nil)) - self?.present(alert, animated: true, completion: nil) - } - .retainUntilComplete() } @objc func deleteMessageRequest() { @@ -1795,7 +1729,9 @@ extension ConversationVC { .filter(id: threadId) .deleteAll(db) - try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() + try MessageSender + .syncConfiguration(db, forceSyncNow: true) + .retainUntilComplete() }, completion: { db, _ in DispatchQueue.main.async { [weak self] in diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 0ee0ddb1c..31a7c770a 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -556,12 +556,32 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers { updateNavBarButtons(threadData: updatedThreadData, initialVariant: viewModel.initialThreadVariant) - messageRequestView.isHidden = ( - updatedThreadData.threadIsMessageRequest == false || - updatedThreadData.threadRequiresApproval == true - ) - scrollButtonMessageRequestsBottomConstraint?.isActive = (updatedThreadData.threadIsMessageRequest == true) - scrollButtonBottomConstraint?.isActive = (updatedThreadData.threadIsMessageRequest == false) + let messageRequestsViewWasVisible: Bool = (messageRequestView.isHidden == false) + + UIView.animate(withDuration: 0.3) { [weak self] in + self?.messageRequestView.isHidden = ( + updatedThreadData.threadIsMessageRequest == false || + updatedThreadData.threadRequiresApproval == true + ) + + self?.scrollButtonMessageRequestsBottomConstraint?.isActive = ( + updatedThreadData.threadIsMessageRequest == true + ) + self?.scrollButtonBottomConstraint?.isActive = (updatedThreadData.threadIsMessageRequest == false) + + // Update the table content inset and offset to account for + // the dissapearance of the messageRequestsView + if messageRequestsViewWasVisible { + let messageRequestsOffset: CGFloat = ((self?.messageRequestView.bounds.height ?? 0) + 16) + let oldContentInset: UIEdgeInsets = (self?.tableView.contentInset ?? UIEdgeInsets.zero) + self?.tableView.contentInset = UIEdgeInsets( + top: 0, + leading: 0, + bottom: max(oldContentInset.bottom - messageRequestsOffset, 0), + trailing: 0 + ) + } + } } if initialLoad || viewModel.threadData.threadIsBlocked != updatedThreadData.threadIsBlocked { diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 66be8ff71..ec2ab0c32 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -16,6 +16,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve private var hasLoadedInitialThreadData: Bool = false private var isLoadingMore: Bool = false private var isAutoLoadingNextPage: Bool = false + private var viewHasAppeared: Bool = false // MARK: - Intialization @@ -224,6 +225,13 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve startObservingChanges() } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + self.viewHasAppeared = true + self.autoLoadNextPageIfNeeded() + } + override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) @@ -487,7 +495,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve } func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) { - guard self.hasLoadedInitialThreadData && !self.isLoadingMore else { return } + guard self.hasLoadedInitialThreadData && self.viewHasAppeared && !self.isLoadingMore else { return } let section: HomeViewModel.SectionModel = self.viewModel.threadData[section] diff --git a/Session/Home/Message Requests/MessageRequestsViewController.swift b/Session/Home/Message Requests/MessageRequestsViewController.swift index 401240ff3..afcbf8000 100644 --- a/Session/Home/Message Requests/MessageRequestsViewController.swift +++ b/Session/Home/Message Requests/MessageRequestsViewController.swift @@ -14,6 +14,8 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat private var dataChangeObservable: DatabaseCancellable? private var hasLoadedInitialThreadData: Bool = false private var isLoadingMore: Bool = false + private var isAutoLoadingNextPage: Bool = false + private var viewHasAppeared: Bool = false // MARK: - Intialization @@ -130,6 +132,13 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat startObservingChanges() } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + self.viewHasAppeared = true + self.autoLoadNextPageIfNeeded() + } + override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) @@ -196,6 +205,13 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat clearAllButton.isHidden = updatedData.isEmpty emptyStateLabel.isHidden = !updatedData.isEmpty + CATransaction.begin() + CATransaction.setCompletionBlock { [weak self] in + // Complete page loading + self?.isLoadingMore = false + self?.autoLoadNextPageIfNeeded() + } + // Reload the table content (animate changes after the first load) tableView.reload( using: StagedChangeset(source: viewModel.threadData, target: updatedData), @@ -209,6 +225,38 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat ) { [weak self] updatedData in self?.viewModel.updateThreadData(updatedData) } + + CATransaction.commit() + } + + private func autoLoadNextPageIfNeeded() { + guard !self.isAutoLoadingNextPage && !self.isLoadingMore else { return } + + self.isAutoLoadingNextPage = true + + DispatchQueue.main.asyncAfter(deadline: .now() + PagedData.autoLoadNextPageDelay) { [weak self] in + self?.isAutoLoadingNextPage = false + + // Note: We sort the headers as we want to prioritise loading newer pages over older ones + let sections: [(MessageRequestsViewModel.Section, CGRect)] = (self?.viewModel.threadData + .enumerated() + .map { index, section in (section.model, (self?.tableView.rectForHeader(inSection: index) ?? .zero)) }) + .defaulting(to: []) + let shouldLoadMore: Bool = sections + .contains { section, headerRect in + section == .loadMore && + headerRect != .zero && + (self?.tableView.bounds.contains(headerRect) == true) + } + + guard shouldLoadMore else { return } + + self?.isLoadingMore = true + + DispatchQueue.global(qos: .default).async { [weak self] in + self?.viewModel.pagedDataObserver?.load(.pageAfter) + } + } } @objc override internal func handleAppModeChangedNotification(_ notification: Notification) { @@ -277,7 +325,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat } func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) { - guard self.hasLoadedInitialThreadData && !self.isLoadingMore else { return } + guard self.hasLoadedInitialThreadData && self.viewHasAppeared && !self.isLoadingMore else { return } let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[section] @@ -338,7 +386,9 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat // MARK: - Interaction @objc private func clearAllTapped() { - guard viewModel.threadData.first { $0.model == .threads }?.elements.isEmpty == false else { return } + guard viewModel.threadData.first(where: { $0.model == .threads })?.elements.isEmpty == false else { + return + } let threadIds: [String] = (viewModel.threadData .first { $0.model == .threads }? diff --git a/Session/Meta/Translations/de.lproj/Localizable.strings b/Session/Meta/Translations/de.lproj/Localizable.strings index 0138af276..60196d5df 100644 --- a/Session/Meta/Translations/de.lproj/Localizable.strings +++ b/Session/Meta/Translations/de.lproj/Localizable.strings @@ -638,7 +638,6 @@ "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE" = "Möchten Sie wirklich alle Nachrichten löschen?"; "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON" = "Clear"; "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON" = "Are you sure you want to delete this message request?"; -"MESSAGE_REQUESTS_APPROVAL_ERROR_MESSAGE" = "An error occurred when trying to accept this message request"; "MESSAGE_REQUESTS_INFO" = "Sending a message to this user will automatically accept their message request."; "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request"; diff --git a/Session/Meta/Translations/en.lproj/Localizable.strings b/Session/Meta/Translations/en.lproj/Localizable.strings index 18d73b302..70292f244 100644 --- a/Session/Meta/Translations/en.lproj/Localizable.strings +++ b/Session/Meta/Translations/en.lproj/Localizable.strings @@ -638,7 +638,6 @@ "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE" = "Are you sure you want to clear all message requests?"; "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON" = "Clear"; "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON" = "Are you sure you want to delete this message request?"; -"MESSAGE_REQUESTS_APPROVAL_ERROR_MESSAGE" = "An error occurred when trying to accept this message request"; "MESSAGE_REQUESTS_INFO" = "Sending a message to this user will automatically accept their message request."; "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request"; diff --git a/Session/Meta/Translations/es.lproj/Localizable.strings b/Session/Meta/Translations/es.lproj/Localizable.strings index 76abb2b87..eda8d36e6 100644 --- a/Session/Meta/Translations/es.lproj/Localizable.strings +++ b/Session/Meta/Translations/es.lproj/Localizable.strings @@ -638,7 +638,6 @@ "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE" = "Are you sure you want to clear all message requests?"; "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON" = "Clear"; "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON" = "Are you sure you want to delete this message request?"; -"MESSAGE_REQUESTS_APPROVAL_ERROR_MESSAGE" = "An error occurred when trying to accept this message request"; "MESSAGE_REQUESTS_INFO" = "Sending a message to this user will automatically accept their message request."; "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request"; diff --git a/Session/Meta/Translations/fa.lproj/Localizable.strings b/Session/Meta/Translations/fa.lproj/Localizable.strings index 60d6d71ee..1177dd54e 100644 --- a/Session/Meta/Translations/fa.lproj/Localizable.strings +++ b/Session/Meta/Translations/fa.lproj/Localizable.strings @@ -638,7 +638,6 @@ "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE" = "Are you sure you want to clear all message requests?"; "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON" = "Clear"; "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON" = "Are you sure you want to delete this message request?"; -"MESSAGE_REQUESTS_APPROVAL_ERROR_MESSAGE" = "An error occurred when trying to accept this message request"; "MESSAGE_REQUESTS_INFO" = "Sending a message to this user will automatically accept their message request."; "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request"; diff --git a/Session/Meta/Translations/fi.lproj/Localizable.strings b/Session/Meta/Translations/fi.lproj/Localizable.strings index 47b43d082..0ccbf8630 100644 --- a/Session/Meta/Translations/fi.lproj/Localizable.strings +++ b/Session/Meta/Translations/fi.lproj/Localizable.strings @@ -638,7 +638,6 @@ "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE" = "Oletko varma että haluat poistaa kaikki viestipyynnöt?"; "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON" = "Poista"; "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON" = "Oletko varma että haluat poistaa tämän viestipyynnön?"; -"MESSAGE_REQUESTS_APPROVAL_ERROR_MESSAGE" = "Viestipyyntöä hyväksyttäessä ilmeni virhe"; "MESSAGE_REQUESTS_INFO" = "Viestin lähettäminen tälle henkilölle hyväksyy automaattisesti viestipyynnön."; "MESSAGE_REQUESTS_ACCEPTED" = "Viestipyyntösi hyväksyttiin."; "MESSAGE_REQUESTS_NOTIFICATION" = "Sinulla on uusi viestipyyntö"; diff --git a/Session/Meta/Translations/fr.lproj/Localizable.strings b/Session/Meta/Translations/fr.lproj/Localizable.strings index ee7ccbf84..71bba7d52 100644 --- a/Session/Meta/Translations/fr.lproj/Localizable.strings +++ b/Session/Meta/Translations/fr.lproj/Localizable.strings @@ -638,7 +638,6 @@ "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE" = "Êtes-vous sûr de vouloir supprimer toutes les demandes de messages ?"; "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON" = "Effacer"; "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON" = "Êtes-vous sûr de vouloir supprimer cette demande de message ?"; -"MESSAGE_REQUESTS_APPROVAL_ERROR_MESSAGE" = "Une erreur s'est produite en acceptant cette demande de message"; "MESSAGE_REQUESTS_INFO" = "Envoyer un message à cet utilisateur acceptera automatiquement sa demande de message."; "MESSAGE_REQUESTS_ACCEPTED" = "Votre demande de message a été réceptionnée."; "MESSAGE_REQUESTS_NOTIFICATION" = "Vous avez une nouvelle demande de message"; diff --git a/Session/Meta/Translations/hi.lproj/Localizable.strings b/Session/Meta/Translations/hi.lproj/Localizable.strings index 86a98157a..e74664705 100644 --- a/Session/Meta/Translations/hi.lproj/Localizable.strings +++ b/Session/Meta/Translations/hi.lproj/Localizable.strings @@ -638,7 +638,6 @@ "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE" = "Are you sure you want to clear all message requests?"; "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON" = "Clear"; "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON" = "Are you sure you want to delete this message request?"; -"MESSAGE_REQUESTS_APPROVAL_ERROR_MESSAGE" = "An error occurred when trying to accept this message request"; "MESSAGE_REQUESTS_INFO" = "Sending a message to this user will automatically accept their message request."; "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request"; diff --git a/Session/Meta/Translations/hr.lproj/Localizable.strings b/Session/Meta/Translations/hr.lproj/Localizable.strings index 4b4b3f6ca..f8a01cf6a 100644 --- a/Session/Meta/Translations/hr.lproj/Localizable.strings +++ b/Session/Meta/Translations/hr.lproj/Localizable.strings @@ -638,7 +638,6 @@ "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE" = "Are you sure you want to clear all message requests?"; "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON" = "Clear"; "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON" = "Are you sure you want to delete this message request?"; -"MESSAGE_REQUESTS_APPROVAL_ERROR_MESSAGE" = "An error occurred when trying to accept this message request"; "MESSAGE_REQUESTS_INFO" = "Sending a message to this user will automatically accept their message request."; "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request"; diff --git a/Session/Meta/Translations/id-ID.lproj/Localizable.strings b/Session/Meta/Translations/id-ID.lproj/Localizable.strings index b1376c4db..65e9f6828 100644 --- a/Session/Meta/Translations/id-ID.lproj/Localizable.strings +++ b/Session/Meta/Translations/id-ID.lproj/Localizable.strings @@ -638,7 +638,6 @@ "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE" = "Are you sure you want to clear all message requests?"; "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON" = "Clear"; "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON" = "Are you sure you want to delete this message request?"; -"MESSAGE_REQUESTS_APPROVAL_ERROR_MESSAGE" = "An error occurred when trying to accept this message request"; "MESSAGE_REQUESTS_INFO" = "Sending a message to this user will automatically accept their message request."; "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request"; diff --git a/Session/Meta/Translations/it.lproj/Localizable.strings b/Session/Meta/Translations/it.lproj/Localizable.strings index 007cac231..6ca09139f 100644 --- a/Session/Meta/Translations/it.lproj/Localizable.strings +++ b/Session/Meta/Translations/it.lproj/Localizable.strings @@ -638,7 +638,6 @@ "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE" = "Eliminare veramente tutte le richieste di messaggio?"; "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON" = "Cancella"; "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON" = "Sei sicuro di voler eliminare questa richiesta di messaggio?"; -"MESSAGE_REQUESTS_APPROVAL_ERROR_MESSAGE" = "Si è verificato un errore durante il tentativo di accettare questa richiesta di messaggio"; "MESSAGE_REQUESTS_INFO" = "L'invio di un messaggio a questo utente accetterà automaticamente la richiesta di messaggio."; "MESSAGE_REQUESTS_ACCEPTED" = "La tua richiesta di messaggio è stata accettata."; "MESSAGE_REQUESTS_NOTIFICATION" = "Hai una nuova richiesta di messaggio"; diff --git a/Session/Meta/Translations/ja.lproj/Localizable.strings b/Session/Meta/Translations/ja.lproj/Localizable.strings index f554a0bcb..7467457c3 100644 --- a/Session/Meta/Translations/ja.lproj/Localizable.strings +++ b/Session/Meta/Translations/ja.lproj/Localizable.strings @@ -638,7 +638,6 @@ "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE" = "本当に全てのリクエストを消去しますか?"; "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON" = "消去"; "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON" = "本当にこのリクエストを削除しますか?"; -"MESSAGE_REQUESTS_APPROVAL_ERROR_MESSAGE" = "リクエスト承認時にエラーが発生しました"; "MESSAGE_REQUESTS_INFO" = "このユーザーにメッセージを送信すると、自動的にリクエストが承認されます。"; "MESSAGE_REQUESTS_ACCEPTED" = "リクエストが承認されました"; "MESSAGE_REQUESTS_NOTIFICATION" = "新しいリクエストがあります"; diff --git a/Session/Meta/Translations/nl.lproj/Localizable.strings b/Session/Meta/Translations/nl.lproj/Localizable.strings index d2280bc06..41354fa3c 100644 --- a/Session/Meta/Translations/nl.lproj/Localizable.strings +++ b/Session/Meta/Translations/nl.lproj/Localizable.strings @@ -638,7 +638,6 @@ "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE" = "Are you sure you want to clear all message requests?"; "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON" = "Clear"; "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON" = "Are you sure you want to delete this message request?"; -"MESSAGE_REQUESTS_APPROVAL_ERROR_MESSAGE" = "An error occurred when trying to accept this message request"; "MESSAGE_REQUESTS_INFO" = "Sending a message to this user will automatically accept their message request."; "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request"; diff --git a/Session/Meta/Translations/pl.lproj/Localizable.strings b/Session/Meta/Translations/pl.lproj/Localizable.strings index 7d53e7b81..b480bbe12 100644 --- a/Session/Meta/Translations/pl.lproj/Localizable.strings +++ b/Session/Meta/Translations/pl.lproj/Localizable.strings @@ -638,7 +638,6 @@ "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE" = "Czy na pewno chcesz wyczyścić wszystkie żądania wiadomości?"; "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON" = "Wyczyść"; "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON" = "Czy na pewno chcesz usunąć to żądanie wiadomości?"; -"MESSAGE_REQUESTS_APPROVAL_ERROR_MESSAGE" = "Wystąpił błąd podczas próby zaakceptowania tego żądania wiadomości"; "MESSAGE_REQUESTS_INFO" = "Wysyłanie wiadomości do tego użytkownika automatycznie zaakceptuje ich żądanie wiadomości."; "MESSAGE_REQUESTS_ACCEPTED" = "Twoje żądanie wiadomości zostało zaakceptowane."; "MESSAGE_REQUESTS_NOTIFICATION" = "Masz nowe żądanie wiadomości"; diff --git a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings index 8e5856689..d785a31be 100644 --- a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings +++ b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings @@ -638,7 +638,6 @@ "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE" = "Are you sure you want to clear all message requests?"; "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON" = "Clear"; "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON" = "Are you sure you want to delete this message request?"; -"MESSAGE_REQUESTS_APPROVAL_ERROR_MESSAGE" = "An error occurred when trying to accept this message request"; "MESSAGE_REQUESTS_INFO" = "Sending a message to this user will automatically accept their message request."; "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request"; diff --git a/Session/Meta/Translations/ru.lproj/Localizable.strings b/Session/Meta/Translations/ru.lproj/Localizable.strings index 8e0c3f534..07a7951f2 100644 --- a/Session/Meta/Translations/ru.lproj/Localizable.strings +++ b/Session/Meta/Translations/ru.lproj/Localizable.strings @@ -638,7 +638,6 @@ "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE" = "Вы уверены, что хотите очистить все запросы сообщений?"; "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON" = "Очистить"; "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON" = "Вы уверены, что хотите удалить это сообщение?"; -"MESSAGE_REQUESTS_APPROVAL_ERROR_MESSAGE" = "An error occurred when trying to accept this message request"; "MESSAGE_REQUESTS_INFO" = "Sending a message to this user will automatically accept their message request."; "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request"; diff --git a/Session/Meta/Translations/si.lproj/Localizable.strings b/Session/Meta/Translations/si.lproj/Localizable.strings index bd1d4fd91..6c6d14467 100644 --- a/Session/Meta/Translations/si.lproj/Localizable.strings +++ b/Session/Meta/Translations/si.lproj/Localizable.strings @@ -638,7 +638,6 @@ "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE" = "Are you sure you want to clear all message requests?"; "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON" = "Clear"; "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON" = "Are you sure you want to delete this message request?"; -"MESSAGE_REQUESTS_APPROVAL_ERROR_MESSAGE" = "An error occurred when trying to accept this message request"; "MESSAGE_REQUESTS_INFO" = "Sending a message to this user will automatically accept their message request."; "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request"; diff --git a/Session/Meta/Translations/sk.lproj/Localizable.strings b/Session/Meta/Translations/sk.lproj/Localizable.strings index 6632f0943..76e709953 100644 --- a/Session/Meta/Translations/sk.lproj/Localizable.strings +++ b/Session/Meta/Translations/sk.lproj/Localizable.strings @@ -638,7 +638,6 @@ "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE" = "Naozaj chcete vymazať všetky žiadosti o správu?"; "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON" = "Vymazať"; "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON" = "Naozaj chcete vymazať túto žiadosť o správu?"; -"MESSAGE_REQUESTS_APPROVAL_ERROR_MESSAGE" = "Nastala chyba pri akceptovaní žiadosti o túto správu"; "MESSAGE_REQUESTS_INFO" = "Poslanie správy tomuto používateľovi automaticky príjme ich žiadosť o správu."; "MESSAGE_REQUESTS_ACCEPTED" = "Vaša žiadosť o správu bola prijatá."; "MESSAGE_REQUESTS_NOTIFICATION" = "Máte novú žiadosť o správu"; diff --git a/Session/Meta/Translations/sv.lproj/Localizable.strings b/Session/Meta/Translations/sv.lproj/Localizable.strings index afee13e19..eb846d574 100644 --- a/Session/Meta/Translations/sv.lproj/Localizable.strings +++ b/Session/Meta/Translations/sv.lproj/Localizable.strings @@ -638,7 +638,6 @@ "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE" = "Are you sure you want to clear all message requests?"; "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON" = "Clear"; "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON" = "Are you sure you want to delete this message request?"; -"MESSAGE_REQUESTS_APPROVAL_ERROR_MESSAGE" = "An error occurred when trying to accept this message request"; "MESSAGE_REQUESTS_INFO" = "Sending a message to this user will automatically accept their message request."; "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request"; diff --git a/Session/Meta/Translations/th.lproj/Localizable.strings b/Session/Meta/Translations/th.lproj/Localizable.strings index 6b9f85dec..bbc550448 100644 --- a/Session/Meta/Translations/th.lproj/Localizable.strings +++ b/Session/Meta/Translations/th.lproj/Localizable.strings @@ -638,7 +638,6 @@ "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE" = "Are you sure you want to clear all message requests?"; "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON" = "Clear"; "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON" = "Are you sure you want to delete this message request?"; -"MESSAGE_REQUESTS_APPROVAL_ERROR_MESSAGE" = "An error occurred when trying to accept this message request"; "MESSAGE_REQUESTS_INFO" = "Sending a message to this user will automatically accept their message request."; "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request"; diff --git a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings index 6b5e5594b..f7be858a2 100644 --- a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings +++ b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings @@ -638,7 +638,6 @@ "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE" = "Are you sure you want to clear all message requests?"; "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON" = "Clear"; "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON" = "Are you sure you want to delete this message request?"; -"MESSAGE_REQUESTS_APPROVAL_ERROR_MESSAGE" = "An error occurred when trying to accept this message request"; "MESSAGE_REQUESTS_INFO" = "Sending a message to this user will automatically accept their message request."; "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request"; diff --git a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings index c389b1514..ecf86aca8 100644 --- a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings @@ -638,7 +638,6 @@ "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE" = "Are you sure you want to clear all message requests?"; "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON" = "Clear"; "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON" = "Are you sure you want to delete this message request?"; -"MESSAGE_REQUESTS_APPROVAL_ERROR_MESSAGE" = "An error occurred when trying to accept this message request"; "MESSAGE_REQUESTS_INFO" = "Sending a message to this user will automatically accept their message request."; "MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted."; "MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request"; diff --git a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings index 68bdf9b6b..44a6ea8d4 100644 --- a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings @@ -638,7 +638,6 @@ "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE" = "您确定要清除所有消息请求吗?"; "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON" = "清除"; "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON" = "您确定要删除此消息请求吗?"; -"MESSAGE_REQUESTS_APPROVAL_ERROR_MESSAGE" = "尝试接受此消息请求时发生错误"; "MESSAGE_REQUESTS_INFO" = "发送消息给此用户将自动接受他们的消息请求。"; "MESSAGE_REQUESTS_ACCEPTED" = "您的消息请求已被接受。"; "MESSAGE_REQUESTS_NOTIFICATION" = "您有一个新的消息请求"; From 44e7a2dfa4e4bf311ff6edb0e841a1630725a1a7 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 18 Jul 2022 17:40:32 +1000 Subject: [PATCH 139/157] Added defensive coding to prevent some crashes Added some defensive coding to prevent path selection from being able to crash due to being empty Fixed a crash where the MediaDetailViewController could access UI on a non-main thread Updated the BackgroundPoller to no longer retry the users or closed group swarms and to "cancel" and return immediately if we hit 25 seconds of run time (OS will kill the process if we hit 30 seconds) --- Session.xcodeproj/project.pbxproj | 4 +- .../MediaDetailViewController.swift | 21 ++- Session/Utilities/BackgroundPoller.swift | 153 ++++++++++-------- SessionSnodeKit/OnionRequestAPI.swift | 33 ++-- 4 files changed, 123 insertions(+), 88 deletions(-) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 4ed98f633..e6a50a7d4 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -6830,7 +6830,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 356; + CURRENT_PROJECT_VERSION = 357; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -6902,7 +6902,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 356; + CURRENT_PROJECT_VERSION = 357; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", diff --git a/Session/Media Viewing & Editing/MediaDetailViewController.swift b/Session/Media Viewing & Editing/MediaDetailViewController.swift index 0ae1ff5a3..80538eb4a 100644 --- a/Session/Media Viewing & Editing/MediaDetailViewController.swift +++ b/Session/Media Viewing & Editing/MediaDetailViewController.swift @@ -54,14 +54,25 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVid galleryItem.attachment.thumbnail( size: .large, success: { [weak self] image, _ in - self?.image = image - // Only reload the content if the view has already loaded (if it // hasn't then it'll load with the image immediately) - if self?.isViewLoaded == true { - self?.updateContents() - self?.updateMinZoomScale() + let updateUICallback = { + self?.image = image + + if self?.isViewLoaded == true { + self?.updateContents() + self?.updateMinZoomScale() + } } + + guard Thread.isMainThread else { + DispatchQueue.main.async { + updateUICallback() + } + return + } + + updateUICallback() }, failure: { SNLog("Could not load media.") diff --git a/Session/Utilities/BackgroundPoller.swift b/Session/Utilities/BackgroundPoller.swift index 8841286d3..9a430ec5e 100644 --- a/Session/Utilities/BackgroundPoller.swift +++ b/Session/Utilities/BackgroundPoller.swift @@ -7,13 +7,9 @@ import SessionSnodeKit import SessionMessagingKit import SessionUtilitiesKit -@objc(LKBackgroundPoller) -public final class BackgroundPoller: NSObject { +public final class BackgroundPoller { private static var promises: [Promise] = [] - private override init() { } - - @objc(pollWithCompletionHandler:) public static func poll(completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { promises = [] .appending(pollForMessages()) @@ -40,12 +36,29 @@ public final class BackgroundPoller: NSObject { } ) + // Background tasks will automatically be terminated after 30 seconds (which results in a crash + // and a prompt to appear for the user) we want to avoid this so we start a timer which expires + // after 25 seconds allowing us to cancel all pending promises + let cancelTimer: Timer = Timer.scheduledTimerOnMainThread(withTimeInterval: 25, repeats: false) { timer in + timer.invalidate() + + guard promises.contains(where: { !$0.isResolved }) else { return } + + SNLog("Background poll failed due to manual timeout") + completionHandler(.failed) + } + when(resolved: promises) .done { _ in + cancelTimer.invalidate() completionHandler(.newData) } .catch { error in + // If we have already invalidated the timer then do nothing (we essentially timed out) + guard cancelTimer.isValid else { return } + SNLog("Background poll failed due to error: \(error)") + cancelTimer.invalidate() completionHandler(.failed) } } @@ -74,7 +87,7 @@ public final class BackgroundPoller: NSObject { ClosedGroupPoller.poll( groupPublicKey, on: DispatchQueue.main, - maxRetryCount: 4, + maxRetryCount: 0, isBackgroundPoll: true ) } @@ -85,78 +98,76 @@ public final class BackgroundPoller: NSObject { .then(on: DispatchQueue.main) { swarm -> Promise in guard let snode = swarm.randomElement() else { throw SnodeAPIError.generic } - return attempt(maxRetryCount: 4, recoveringOn: DispatchQueue.main) { - return SnodeAPI.getMessages(from: snode, associatedWith: publicKey) - .then(on: DispatchQueue.main) { messages -> Promise in - guard !messages.isEmpty else { return Promise.value(()) } + return SnodeAPI.getMessages(from: snode, associatedWith: publicKey) + .then(on: DispatchQueue.main) { messages -> Promise in + guard !messages.isEmpty else { return Promise.value(()) } + + var jobsToRun: [Job] = [] + + Storage.shared.write { db in + var threadMessages: [String: [MessageReceiveJob.Details.MessageInfo]] = [:] - var jobsToRun: [Job] = [] - - Storage.shared.write { db in - var threadMessages: [String: [MessageReceiveJob.Details.MessageInfo]] = [:] - - messages.forEach { message in - do { - let processedMessage: ProcessedMessage? = try Message.processRawReceivedMessage(db, rawMessage: message) - let key: String = (processedMessage?.threadId ?? Message.nonThreadMessageId) + messages.forEach { message in + do { + let processedMessage: ProcessedMessage? = try Message.processRawReceivedMessage(db, rawMessage: message) + let key: String = (processedMessage?.threadId ?? Message.nonThreadMessageId) + + threadMessages[key] = (threadMessages[key] ?? []) + .appending(processedMessage?.messageInfo) + } + 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, + MessageReceiverError.duplicateMessage, + MessageReceiverError.duplicateControlMessage, + MessageReceiverError.selfSend: + break - threadMessages[key] = (threadMessages[key] ?? []) - .appending(processedMessage?.messageInfo) - } - 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, - MessageReceiverError.duplicateMessage, - MessageReceiverError.duplicateControlMessage, - MessageReceiverError.selfSend: - break - - default: SNLog("Failed to deserialize envelope due to error: \(error).") - } + default: SNLog("Failed to deserialize envelope due to error: \(error).") } } - - threadMessages - .forEach { threadId, threadMessages in - let maybeJob: Job? = Job( - variant: .messageReceive, - behaviour: .runOnce, - threadId: threadId, - details: MessageReceiveJob.Details( - messages: threadMessages, - isBackgroundPoll: true - ) - ) - - guard let job: Job = maybeJob else { return } - - // Add to the JobRunner so they are persistent and will retry on - // the next app run if they fail - JobRunner.add(db, job: job, canStartJob: false) - jobsToRun.append(job) - } } - let promises: [Promise] = jobsToRun.map { job -> Promise in - let (promise, seal) = Promise.pending() - - // Note: In the background we just want jobs to fail silently - MessageReceiveJob.run( - job, - queue: DispatchQueue.main, - success: { _, _ in seal.fulfill(()) }, - failure: { _, _, _ in seal.fulfill(()) }, - deferred: { _ in seal.fulfill(()) } - ) - - return promise - } - - return when(fulfilled: promises) + threadMessages + .forEach { threadId, threadMessages in + let maybeJob: Job? = Job( + variant: .messageReceive, + behaviour: .runOnce, + threadId: threadId, + details: MessageReceiveJob.Details( + messages: threadMessages, + isBackgroundPoll: true + ) + ) + + guard let job: Job = maybeJob else { return } + + // Add to the JobRunner so they are persistent and will retry on + // the next app run if they fail + JobRunner.add(db, job: job, canStartJob: false) + jobsToRun.append(job) + } } - } + + let promises: [Promise] = jobsToRun.map { job -> Promise in + let (promise, seal) = Promise.pending() + + // Note: In the background we just want jobs to fail silently + MessageReceiveJob.run( + job, + queue: DispatchQueue.main, + success: { _, _ in seal.fulfill(()) }, + failure: { _, _, _ in seal.fulfill(()) }, + deferred: { _ in seal.fulfill(()) } + ) + + return promise + } + + return when(fulfilled: promises) + } } } } diff --git a/SessionSnodeKit/OnionRequestAPI.swift b/SessionSnodeKit/OnionRequestAPI.swift index 0d69334d4..3efd5d4ba 100644 --- a/SessionSnodeKit/OnionRequestAPI.swift +++ b/SessionSnodeKit/OnionRequestAPI.swift @@ -212,13 +212,13 @@ public enum OnionRequestAPI: OnionRequestAPIType { } // randomElement() uses the system's default random generator, which is cryptographically secure - if paths.count >= targetPathCount { - if let snode: Snode = snode { - return Promise { $0.fulfill(paths.filter { !$0.contains(snode) }.randomElement()!) } - } - else { - return Promise { $0.fulfill(paths.randomElement()!) } - } + if + paths.count >= targetPathCount, + let targetPath: [Snode] = paths + .filter({ snode == nil || !$0.contains(snode!) }) + .randomElement() + { + return Promise { $0.fulfill(targetPath) } } else if !paths.isEmpty { if let snode = snode { @@ -228,13 +228,22 @@ public enum OnionRequestAPI: OnionRequestAPIType { } else { return buildPaths(reusing: paths).map2 { paths in - return paths.filter { !$0.contains(snode) }.randomElement()! + guard let path: [Snode] = paths.filter({ !$0.contains(snode) }).randomElement() else { + throw OnionRequestAPIError.insufficientSnodes + } + + return path } } } else { buildPaths(reusing: paths) // Re-build paths in the background - return Promise { $0.fulfill(paths.randomElement()!) } + + guard let path: [Snode] = paths.randomElement() else { + return Promise(error: OnionRequestAPIError.insufficientSnodes) + } + + return Promise { $0.fulfill(path) } } } else { @@ -247,7 +256,11 @@ public enum OnionRequestAPI: OnionRequestAPIType { throw OnionRequestAPIError.insufficientSnodes } - return paths.randomElement()! + guard let path: [Snode] = paths.randomElement() else { + throw OnionRequestAPIError.insufficientSnodes + } + + return path } } } From 3df3114beef7c3fc977efc6af65b406eff4c0286 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 19 Jul 2022 09:22:15 +1000 Subject: [PATCH 140/157] Fixed the broken unit tests --- .../Open Groups/OpenGroupAPISpec.swift | 2 +- .../Open Groups/OpenGroupManagerSpec.swift | 34 +++++++++---------- .../Open Groups/Types/SOGSEndpointSpec.swift | 2 +- .../_TestUtilities/TestOnionRequestAPI.swift | 2 +- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift index 50a99e225..a67c9c3ee 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift @@ -2328,7 +2328,7 @@ class OpenGroupAPISpec: QuickSpec { OpenGroupAPI .downloadFile( db, - fileId: 1, + fileId: "1", from: "testRoom", on: "testserver", using: dependencies diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift index bfb823411..e14a1099f 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -1574,7 +1574,7 @@ class OpenGroupManagerSpec: QuickSpec { created: 0, activeUsers: 0, activeUsersCutoff: 0, - imageId: 10, + imageId: "10", pinnedMessages: nil, admin: false, globalAdmin: false, @@ -1732,7 +1732,7 @@ class OpenGroupManagerSpec: QuickSpec { created: 0, activeUsers: 0, activeUsersCutoff: 0, - imageId: 10, + imageId: "10", pinnedMessages: nil, admin: false, globalAdmin: false, @@ -1843,7 +1843,7 @@ class OpenGroupManagerSpec: QuickSpec { created: 0, activeUsers: 0, activeUsersCutoff: 0, - imageId: 10, + imageId: "10", pinnedMessages: nil, admin: false, globalAdmin: false, @@ -1912,7 +1912,7 @@ class OpenGroupManagerSpec: QuickSpec { created: 0, activeUsers: 0, activeUsersCutoff: 0, - imageId: 10, + imageId: "10", pinnedMessages: nil, admin: false, globalAdmin: false, @@ -2988,7 +2988,7 @@ class OpenGroupManagerSpec: QuickSpec { created: 0, activeUsers: 0, activeUsersCutoff: 0, - imageId: 12, + imageId: "12", pinnedMessages: nil, admin: false, globalAdmin: false, @@ -3104,7 +3104,7 @@ class OpenGroupManagerSpec: QuickSpec { created: 0, activeUsers: 0, activeUsersCutoff: 0, - imageId: 12, + imageId: "12", pinnedMessages: nil, admin: false, globalAdmin: false, @@ -3188,7 +3188,7 @@ class OpenGroupManagerSpec: QuickSpec { created: 0, activeUsers: 0, activeUsersCutoff: 0, - imageId: 12, + imageId: "12", pinnedMessages: nil, admin: false, globalAdmin: false, @@ -3279,7 +3279,7 @@ class OpenGroupManagerSpec: QuickSpec { OpenGroupManager .roomImage( db, - fileId: 1, + fileId: "1", for: "testRoom", on: "testServer", using: dependencies @@ -3293,7 +3293,7 @@ class OpenGroupManagerSpec: QuickSpec { OpenGroupManager .roomImage( db, - fileId: 1, + fileId: "1", for: "testRoom", on: "testServer", using: dependencies @@ -3321,7 +3321,7 @@ class OpenGroupManagerSpec: QuickSpec { OpenGroupManager .roomImage( db, - fileId: 1, + fileId: "1", for: "testRoom", on: "testServer", using: dependencies @@ -3348,7 +3348,7 @@ class OpenGroupManagerSpec: QuickSpec { return Promise<(OnionRequestResponseInfoType, Data?)>.pending().promise } - static func sendOnionRequest(to snode: Snode, invoking method: SnodeAPIEndpoint, with parameters: JSON, using version: OnionRequestAPIVersion, associatedWith publicKey: String?) -> Promise { + static func sendOnionRequest(to snode: Snode, invoking method: SnodeAPIEndpoint, with parameters: JSON, associatedWith publicKey: String?) -> Promise { return Promise.value(Data()) } } @@ -3357,7 +3357,7 @@ class OpenGroupManagerSpec: QuickSpec { let promise = mockStorage.read { db in OpenGroupManager.roomImage( db, - fileId: 1, + fileId: "1", for: "testRoom", on: "testServer", using: dependencies @@ -3382,7 +3382,7 @@ class OpenGroupManagerSpec: QuickSpec { OpenGroupManager .roomImage( db, - fileId: 1, + fileId: "1", for: "testRoom", on: OpenGroupAPI.defaultServer, using: dependencies @@ -3400,7 +3400,7 @@ class OpenGroupManagerSpec: QuickSpec { OpenGroupManager .roomImage( db, - fileId: 1, + fileId: "1", for: "testRoom", on: OpenGroupAPI.defaultServer, using: dependencies @@ -3428,7 +3428,7 @@ class OpenGroupManagerSpec: QuickSpec { OpenGroupManager .roomImage( db, - fileId: 1, + fileId: "1", for: "testRoom", on: OpenGroupAPI.defaultServer, using: dependencies @@ -3471,7 +3471,7 @@ class OpenGroupManagerSpec: QuickSpec { OpenGroupManager .roomImage( db, - fileId: 1, + fileId: "1", for: "testRoom", on: OpenGroupAPI.defaultServer, using: dependencies @@ -3500,7 +3500,7 @@ class OpenGroupManagerSpec: QuickSpec { OpenGroupManager .roomImage( db, - fileId: 1, + fileId: "1", for: "testRoom", on: OpenGroupAPI.defaultServer, using: dependencies diff --git a/SessionMessagingKitTests/Open Groups/Types/SOGSEndpointSpec.swift b/SessionMessagingKitTests/Open Groups/Types/SOGSEndpointSpec.swift index 7147b95fa..1dfb972d9 100644 --- a/SessionMessagingKitTests/Open Groups/Types/SOGSEndpointSpec.swift +++ b/SessionMessagingKitTests/Open Groups/Types/SOGSEndpointSpec.swift @@ -46,7 +46,7 @@ class SOGSEndpointSpec: QuickSpec { // Files expect(OpenGroupAPI.Endpoint.roomFile("test").path).to(equal("room/test/file")) - expect(OpenGroupAPI.Endpoint.roomFileIndividual("test", 123).path).to(equal("room/test/file/123")) + expect(OpenGroupAPI.Endpoint.roomFileIndividual("test", "123").path).to(equal("room/test/file/123")) // Inbox/Outbox (Message Requests) diff --git a/SessionMessagingKitTests/_TestUtilities/TestOnionRequestAPI.swift b/SessionMessagingKitTests/_TestUtilities/TestOnionRequestAPI.swift index 33039ef03..67b7dde86 100644 --- a/SessionMessagingKitTests/_TestUtilities/TestOnionRequestAPI.swift +++ b/SessionMessagingKitTests/_TestUtilities/TestOnionRequestAPI.swift @@ -54,7 +54,7 @@ class TestOnionRequestAPI: OnionRequestAPIType { return Promise.value((responseInfo, mockResponse)) } - static func sendOnionRequest(to snode: Snode, invoking method: SnodeAPIEndpoint, with parameters: JSON, using version: OnionRequestAPIVersion, associatedWith publicKey: String?) -> Promise { + static func sendOnionRequest(to snode: Snode, invoking method: SnodeAPIEndpoint, with parameters: JSON, associatedWith publicKey: String?) -> Promise { return Promise.value(mockResponse!) } } From 9859cf95a48b320d39699daf3ad33b2a4a12d435 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 25 Jul 2022 17:03:09 +1000 Subject: [PATCH 141/157] Attempted to fix the notification & call reporting issues Fixed an issue where fileIds weren't correctly getting sent along with open group messages Fixed an issue where the screens could miss updates if the device was locked with the app in the foreground and then later unlocked after receiving notifications Added an optimisation to prevent attempting to send a message after it has been deleted Added logic to report fake calls if the code goes down an invalid code path when handling a call (to prevent Apple blocking the app) Delayed the core which clears notifications to increase the time the app has to handle interactions (just in case it was a race condition) --- .../Calls/Call Management/SessionCall.swift | 5 +- .../Call Management/SessionCallManager.swift | 14 +++- Session/Conversations/ConversationVC.swift | 11 ++- Session/Home/HomeVC.swift | 11 ++- .../MessageRequestsViewController.swift | 11 ++- .../MediaTileViewController.swift | 11 ++- Session/Meta/AppDelegate.swift | 4 +- .../PushRegistrationManager.swift | 74 +++++++++++-------- .../Database/Models/Attachment.swift | 25 +++++-- .../Jobs/Types/AttachmentDownloadJob.swift | 5 +- .../Jobs/Types/AttachmentUploadJob.swift | 2 +- .../Jobs/Types/MessageSendJob.swift | 32 ++++++-- .../Messages/Message+Destination.swift | 16 ++++ .../MessageSender+Convenience.swift | 20 +++-- .../Utilities/ProfileManager.swift | 5 +- .../Types/PagedDatabaseObserver.swift | 12 +++ 16 files changed, 189 insertions(+), 69 deletions(-) diff --git a/Session/Calls/Call Management/SessionCall.swift b/Session/Calls/Call Management/SessionCall.swift index 030860de8..1aef15a69 100644 --- a/Session/Calls/Call Management/SessionCall.swift +++ b/Session/Calls/Call Management/SessionCall.swift @@ -167,7 +167,10 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { } func reportIncomingCallIfNeeded(completion: @escaping (Error?) -> Void) { - guard case .answer = mode else { return } + guard case .answer = mode else { + SessionCallManager.reportFakeCall(info: "Call not in answer mode") + return + } setupTimeoutTimer() AppEnvironment.shared.callManager.reportIncomingCall(self, callerName: contactName) { error in diff --git a/Session/Calls/Call Management/SessionCallManager.swift b/Session/Calls/Call Management/SessionCallManager.swift index 30dbcdfa0..643268bc1 100644 --- a/Session/Calls/Call Management/SessionCallManager.swift +++ b/Session/Calls/Call Management/SessionCallManager.swift @@ -72,6 +72,16 @@ public final class SessionCallManager: NSObject, CallManagerProtocol { // MARK: - Report calls + public static func reportFakeCall(info: String) { + SessionCallManager.sharedProvider(useSystemCallLog: false) + .reportNewIncomingCall( + with: UUID(), + update: CXCallUpdate() + ) { _ in + SNLog("[Calls] Reported fake incoming call to CallKit due to: \(info)") + } + } + public func reportOutgoingCall(_ call: SessionCall) { AssertIsOnMainThread() UserDefaults.sharedLokiProject?.set(true, forKey: "isCallOngoing") @@ -109,7 +119,9 @@ public final class SessionCallManager: NSObject, CallManagerProtocol { UserDefaults.sharedLokiProject?.set(true, forKey: "isCallOngoing") completion(nil) } - } else { + } + else { + SessionCallManager.reportFakeCall(info: "No CXProvider instance") UserDefaults.sharedLokiProject?.set(true, forKey: "isCallOngoing") completion(nil) } diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 31a7c770a..b06c8d0b2 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -450,7 +450,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers } @objc func applicationDidBecomeActive(_ notification: Notification) { - startObservingChanges() + startObservingChanges(didReturnFromBackground: true) recoverInputView() } @@ -460,7 +460,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers // MARK: - Updating - private func startObservingChanges() { + private func startObservingChanges(didReturnFromBackground: Bool = false) { // Start observing for data changes dataChangeObservable = Storage.shared.start( viewModel.observableThreadData, @@ -506,6 +506,13 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers self?.viewModel.onInteractionChange = { [weak self] updatedInteractionData in self?.handleInteractionUpdates(updatedInteractionData) } + + // Note: When returning from the background we could have received notifications but the + // PagedDatabaseObserver won't have them so we need to force a re-fetch of the current + // data to ensure everything is up to date + if didReturnFromBackground { + self?.viewModel.pagedDataObserver?.reload() + } } } ) diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index ec2ab0c32..f45a9ece4 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -239,7 +239,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve } @objc func applicationDidBecomeActive(_ notification: Notification) { - startObservingChanges() + startObservingChanges(didReturnFromBackground: true) } @objc func applicationDidResignActive(_ notification: Notification) { @@ -248,7 +248,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve // MARK: - Updating - private func startObservingChanges() { + private func startObservingChanges(didReturnFromBackground: Bool = false) { // Start observing for data changes dataChangeObservable = Storage.shared.start( viewModel.observableState, @@ -269,6 +269,13 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve self.viewModel.onThreadChange = { [weak self] updatedThreadData in self?.handleThreadUpdates(updatedThreadData) } + + // Note: When returning from the background we could have received notifications but the + // PagedDatabaseObserver won't have them so we need to force a re-fetch of the current + // data to ensure everything is up to date + if didReturnFromBackground { + self.viewModel.pagedDataObserver?.reload() + } } private func stopObservingChanges() { diff --git a/Session/Home/Message Requests/MessageRequestsViewController.swift b/Session/Home/Message Requests/MessageRequestsViewController.swift index afcbf8000..ba87a80c3 100644 --- a/Session/Home/Message Requests/MessageRequestsViewController.swift +++ b/Session/Home/Message Requests/MessageRequestsViewController.swift @@ -147,7 +147,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat } @objc func applicationDidBecomeActive(_ notification: Notification) { - startObservingChanges() + startObservingChanges(didReturnFromBackground: true) } @objc func applicationDidResignActive(_ notification: Notification) { @@ -186,10 +186,17 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat // MARK: - Updating - private func startObservingChanges() { + private func startObservingChanges(didReturnFromBackground: Bool = false) { self.viewModel.onThreadChange = { [weak self] updatedThreadData in self?.handleThreadUpdates(updatedThreadData) } + + // Note: When returning from the background we could have received notifications but the + // PagedDatabaseObserver won't have them so we need to force a re-fetch of the current + // data to ensure everything is up to date + if didReturnFromBackground { + self.viewModel.pagedDataObserver?.reload() + } } private func handleThreadUpdates(_ updatedData: [MessageRequestsViewModel.SectionModel], initialLoad: Bool = false) { diff --git a/Session/Media Viewing & Editing/MediaTileViewController.swift b/Session/Media Viewing & Editing/MediaTileViewController.swift index 45acdf317..1085b574a 100644 --- a/Session/Media Viewing & Editing/MediaTileViewController.swift +++ b/Session/Media Viewing & Editing/MediaTileViewController.swift @@ -171,7 +171,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour } @objc func applicationDidBecomeActive(_ notification: Notification) { - startObservingChanges() + startObservingChanges(didReturnFromBackground: true) } @objc func applicationDidResignActive(_ notification: Notification) { @@ -243,11 +243,18 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour } } - private func startObservingChanges() { + private func startObservingChanges(didReturnFromBackground: Bool = false) { // Start observing for data changes (will callback on the main thread) self.viewModel.onGalleryChange = { [weak self] updatedGalleryData in self?.handleUpdates(updatedGalleryData) } + + // Note: When returning from the background we could have received notifications but the + // PagedDatabaseObserver won't have them so we need to force a re-fetch of the current + // data to ensure everything is up to date + if didReturnFromBackground { + self.viewModel.pagedDataObserver?.reload() + } } private func stopObservingChanges() { diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index d58faa92f..84286342e 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -149,13 +149,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD /// no longer always called before `applicationDidBecomeActive` we need to trigger the "clear notifications" logic /// within the `runNowOrWhenAppDidBecomeReady` callback and dispatch to the next run loop to ensure it runs after /// the notification has actually been handled - DispatchQueue.main.async { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { [weak self] in self?.clearAllNotificationsAndRestoreBadgeCount() } } // On every activation, clear old temp directories. - ClearOldTemporaryDirectories(); + ClearOldTemporaryDirectories() } func applicationWillResignActive(_ application: UIApplication) { diff --git a/Session/Notifications/PushRegistrationManager.swift b/Session/Notifications/PushRegistrationManager.swift index 9069858fc..1cd8d71cf 100644 --- a/Session/Notifications/PushRegistrationManager.swift +++ b/Session/Notifications/PushRegistrationManager.swift @@ -242,40 +242,52 @@ public enum PushRegistrationError: Error { owsAssertDebug(type == .voIP) let payload = payload.dictionaryPayload - if let uuid = payload["uuid"] as? String, let caller = payload["caller"] as? String, let timestampMs = payload["timestamp"] as? Int64 { - let call: SessionCall? = Storage.shared.write { db in - let messageInfo: CallMessage.MessageInfo = CallMessage.MessageInfo( - state: (caller == getUserHexEncodedPublicKey(db) ? - .outgoing : - .incoming - ) + guard + let uuid: String = payload["uuid"] as? String, + let caller: String = payload["caller"] as? String, + let timestampMs: Int64 = payload["timestamp"] as? Int64 + else { + SessionCallManager.reportFakeCall(info: "Missing payload data") + return + } + + let maybeCall: SessionCall? = Storage.shared.write { db in + let messageInfo: CallMessage.MessageInfo = CallMessage.MessageInfo( + state: (caller == getUserHexEncodedPublicKey(db) ? + .outgoing : + .incoming ) - - guard let messageInfoData: Data = try? JSONEncoder().encode(messageInfo) else { return nil } - - let call: SessionCall = SessionCall(db, for: caller, uuid: uuid, mode: .answer) - let thread: SessionThread = try SessionThread.fetchOrCreate(db, id: caller, variant: .contact) - - let interaction: Interaction = try Interaction( - messageUuid: uuid, - threadId: thread.id, - authorId: caller, - variant: .infoCall, - body: String(data: messageInfoData, encoding: .utf8), - timestampMs: timestampMs - ).inserted(db) - call.callInteractionId = interaction.id - - return call - } + ) - // NOTE: Just start 1-1 poller so that it won't wait for polling group messages - (UIApplication.shared.delegate as? AppDelegate)?.startPollersIfNeeded(shouldStartGroupPollers: false) + guard let messageInfoData: Data = try? JSONEncoder().encode(messageInfo) else { return nil } - call?.reportIncomingCallIfNeeded { error in - if let error = error { - SNLog("[Calls] Failed to report incoming call to CallKit due to error: \(error)") - } + let call: SessionCall = SessionCall(db, for: caller, uuid: uuid, mode: .answer) + let thread: SessionThread = try SessionThread.fetchOrCreate(db, id: caller, variant: .contact) + + let interaction: Interaction = try Interaction( + messageUuid: uuid, + threadId: thread.id, + authorId: caller, + variant: .infoCall, + body: String(data: messageInfoData, encoding: .utf8), + timestampMs: timestampMs + ).inserted(db) + call.callInteractionId = interaction.id + + return call + } + + guard let call: SessionCall = maybeCall else { + SessionCallManager.reportFakeCall(info: "Could not retrieve call from database") + return + } + + // NOTE: Just start 1-1 poller so that it won't wait for polling group messages + (UIApplication.shared.delegate as? AppDelegate)?.startPollersIfNeeded(shouldStartGroupPollers: false) + + call.reportIncomingCallIfNeeded { error in + if let error = error { + SNLog("[Calls] Failed to report incoming call to CallKit due to error: \(error)") } } } diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index ffe2a3bdd..a0d18d392 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -468,6 +468,7 @@ extension Attachment { public let attachmentId: String public let interactionId: Int64 public let state: Attachment.State + public let downloadUrl: String? } public static func stateInfo(authorId: String, state: State? = nil) -> SQLRequest { @@ -484,7 +485,8 @@ extension Attachment { SELECT DISTINCT \(attachment[.id]) AS attachmentId, \(interaction[.id]) AS interactionId, - \(attachment[.state]) AS state + \(attachment[.state]) AS state, + \(attachment[.downloadUrl]) AS downloadUrl FROM \(Attachment.self) @@ -529,7 +531,8 @@ extension Attachment { SELECT DISTINCT \(attachment[.id]) AS attachmentId, \(interaction[.id]) AS interactionId, - \(attachment[.state]) AS state + \(attachment[.state]) AS state, + \(attachment[.downloadUrl]) AS downloadUrl FROM \(Attachment.self) @@ -913,6 +916,16 @@ extension Attachment { return true } + + public static func fileId(for downloadUrl: String?) -> String? { + return downloadUrl + .map { urlString -> String? in + urlString + .split(separator: "/") + .last + .map { String($0) } + } + } } // MARK: - Upload @@ -923,14 +936,14 @@ extension Attachment { queue: DispatchQueue, using upload: (Database, Data) -> Promise, encrypt: Bool, - success: (() -> Void)?, + success: ((String?) -> Void)?, failure: ((Error) -> Void)? ) { // This can occur if an AttachmnetUploadJob was explicitly created for a message // dependant on the attachment being uploaded (in this case the attachment has // already been uploaded so just succeed) guard state != .uploaded else { - success?() + success?(Attachment.fileId(for: self.downloadUrl)) return } @@ -982,7 +995,7 @@ extension Attachment { return } - success?() + success?(Attachment.fileId(for: self.downloadUrl)) return } @@ -1073,7 +1086,7 @@ extension Attachment { return } - success?() + success?(fileId) } .catch(on: queue) { error in Storage.shared.write { db in diff --git a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift index c420db071..6a1d4fc16 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift @@ -87,10 +87,7 @@ public enum AttachmentDownloadJob: JobExecutor { let downloadPromise: Promise = { guard let downloadUrl: String = attachment.downloadUrl, - let fileId: String = downloadUrl - .split(separator: "/") - .last - .map({ String($0) }) + let fileId: String = Attachment.fileId(for: downloadUrl) else { return Promise(error: AttachmentDownloadError.invalidUrl) } diff --git a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift index 18a058f4f..5be30e2f7 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift @@ -55,7 +55,7 @@ public enum AttachmentUploadJob: JobExecutor { .map { response -> String in response.id } }, encrypt: (openGroup == nil), - success: { success(job, false) }, + success: { _ in success(job, false) }, failure: { error in failure(job, error, false) } ) } diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift index bad9defe0..f7bbceb62 100644 --- a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift @@ -27,6 +27,10 @@ public enum MessageSendJob: JobExecutor { return } + // We need to include 'fileIds' when sending messages with attachments to Open Groups + // so extract them from any associated attachments + var messageFileIds: [String] = [] + if details.message is VisibleMessage { guard let jobId: Int64 = job.id, @@ -36,20 +40,30 @@ public enum MessageSendJob: JobExecutor { return } + // If the original interaction no longer exists then don't bother sending the message (ie. the + // message was deleted before it even got sent) + guard Storage.shared.read({ db in try Interaction.exists(db, id: interactionId) }) == true else { + failure(job, StorageError.objectNotFound, true) + return + } + // Check if there are any attachments associated to this message, and if so // upload them now // // Note: Normal attachments should be sent in a non-durable way but any // attachments for LinkPreviews and Quotes will be processed through this mechanism - let attachmentState: (shouldFail: Bool, shouldDefer: Bool)? = Storage.shared.write { db in + let attachmentState: (shouldFail: Bool, shouldDefer: Bool, fileIds: [String])? = Storage.shared.write { db in let allAttachmentStateInfo: [Attachment.StateInfo] = try Attachment .stateInfo(interactionId: interactionId) .fetchAll(db) + let maybeFileIds: [String?] = allAttachmentStateInfo + .map { Attachment.fileId(for: $0.downloadUrl) } + let fileIds: [String] = maybeFileIds.compactMap { $0 } // If there were failed attachments then this job should fail (can't send a // message which has associated attachments if the attachments fail to upload) guard !allAttachmentStateInfo.contains(where: { $0.state == .failedDownload }) else { - return (true, false) + return (true, false, fileIds) } // Create jobs for any pending (or failed) attachment jobs and insert them into the @@ -102,9 +116,13 @@ public enum MessageSendJob: JobExecutor { // If there were pending or uploading attachments then stop here (we want to // upload them first and then re-run this send job - the 'JobRunner.insert' // method will take care of this) + let isMissingFileIds: Bool = (maybeFileIds.count != fileIds.count) + let hasPendingUploads: Bool = allAttachmentStateInfo.contains(where: { $0.state != .uploaded }) + return ( - false, - allAttachmentStateInfo.contains(where: { $0.state != .uploaded }) + (isMissingFileIds && !hasPendingUploads), + hasPendingUploads, + fileIds ) } @@ -122,6 +140,9 @@ public enum MessageSendJob: JobExecutor { deferred(job) return } + + // Store the fileIds so they can be sent with the open group message content + messageFileIds = (attachmentState?.fileIds ?? []) } // Store the sentTimestamp from the message in case it fails due to a clockOutOfSync error @@ -135,7 +156,8 @@ public enum MessageSendJob: JobExecutor { try MessageSender.sendImmediate( db, message: details.message, - to: details.destination, + to: details.destination + .with(fileIds: messageFileIds), interactionId: job.interactionId ) } diff --git a/SessionMessagingKit/Messages/Message+Destination.swift b/SessionMessagingKit/Messages/Message+Destination.swift index a61a05344..e1eaad9bc 100644 --- a/SessionMessagingKit/Messages/Message+Destination.swift +++ b/SessionMessagingKit/Messages/Message+Destination.swift @@ -49,5 +49,21 @@ public extension Message { return .openGroup(roomToken: openGroup.roomToken, server: openGroup.server, fileIds: fileIds) } } + + func with(fileIds: [String]) -> Message.Destination { + // Only Open Group messages support receiving the 'fileIds' + switch self { + case .openGroup(let roomToken, let server, let whisperTo, let whisperMods, _): + return .openGroup( + roomToken: roomToken, + server: server, + whisperTo: whisperTo, + whisperMods: whisperMods, + fileIds: fileIds + ) + + default: return self + } + } } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index 1a4640753..9942c3012 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -100,7 +100,7 @@ extension MessageSender { } public static func sendNonDurably(_ db: Database, message: Message, interactionId: Int64?, to destination: Message.Destination) -> Promise { - var attachmentUploadPromises: [Promise] = [Promise.value(())] + var attachmentUploadPromises: [Promise] = [Promise.value(nil)] // If we have an interactionId then check if it has any attachments and process them first if let interactionId: Int64 = interactionId { @@ -124,8 +124,8 @@ extension MessageSender { .filter(ids: attachmentStateInfo.map { $0.attachmentId }) .fetchAll(db)) .defaulting(to: []) - .map { attachment -> Promise in - let (promise, seal) = Promise.pending() + .map { attachment -> Promise in + let (promise, seal) = Promise.pending() attachment.upload( db, @@ -146,7 +146,7 @@ extension MessageSender { .map { response -> String in response.id } }, encrypt: (openGroup == nil), - success: { seal.fulfill(()) }, + success: { fileId in seal.fulfill(fileId) }, failure: { seal.reject($0) } ) @@ -167,10 +167,18 @@ extension MessageSender { if let error: Error = errors.first { return Promise(error: error) } return Storage.shared.writeAsync { db in - try MessageSender.sendImmediate( + let fileIds: [String] = results + .compactMap { result -> String? in + if case .fulfilled(let value) = result { return value } + + return nil + } + + return try MessageSender.sendImmediate( db, message: message, - to: destination, + to: destination + .with(fileIds: fileIds), interactionId: interactionId ) } diff --git a/SessionMessagingKit/Utilities/ProfileManager.swift b/SessionMessagingKit/Utilities/ProfileManager.swift index e163268a4..42c5fd0dd 100644 --- a/SessionMessagingKit/Utilities/ProfileManager.swift +++ b/SessionMessagingKit/Utilities/ProfileManager.swift @@ -150,10 +150,7 @@ public struct ProfileManager { return } guard - let fileId: String = profileUrlStringAtStart - .split(separator: "/") - .last - .map({ String($0) }), + let fileId: String = Attachment.fileId(for: profileUrlStringAtStart), let profileKeyAtStart: OWSAES256Key = profile.profileEncryptionKey, profileKeyAtStart.keyData.count > 0 else { diff --git a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift index 1317224f0..d8c60cc5a 100644 --- a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift +++ b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift @@ -477,6 +477,13 @@ public class PagedDatabaseObserver: TransactionObserver where cacheCurrentEndIndex, currentPageInfo.pageOffset ) + + case .reloadCurrent: + return ( + currentPageInfo.currentCount, + currentPageInfo.pageOffset, + currentPageInfo.pageOffset + ) } }() @@ -570,6 +577,10 @@ public class PagedDatabaseObserver: TransactionObserver where triggerUpdates() } + + public func reload() { + self.load(.reloadCurrent) + } } // MARK: - Convenience @@ -718,6 +729,7 @@ public enum PagedData { case pageBefore case pageAfter case untilInclusive(id: SQLExpression, padding: Int) + case reloadCurrent } public enum Target { From aed1b731857232cf976962edc6548db8e2d2f015 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 26 Jul 2022 11:36:32 +1000 Subject: [PATCH 142/157] Fixed a few additional issues uncovered Added a explicit "timeout" error to make debugging a little easier Added code to prevent the AttachmentUploadJob from continuing to try to upload if it's associated interaction has been deleted Updated the getDefaultRoomsIfNeeded to make an unauthenticated sequence all to get both capabilities and rooms (so we will know if the server is blinded and retrieve the room images using blinded auth) Fixed a bug where the notification badge wouldn't get cleared when removing data from a device Fixed a bug where adding an open group could start with an invalid 'infoUpdates' value resulting in invalid data getting retrieved Fixed a bug where under certain circumstances the PagedDatabaseObserver was filtering out updates (noticeable when restoring a device, would happen if the currentCount of content was smaller than the pageSize) --- Session/Settings/NukeDataModal.swift | 14 ++-- .../Database/Models/OpenGroup.swift | 2 +- .../Jobs/Types/AttachmentUploadJob.swift | 9 +++ .../Open Groups/OpenGroupAPI.swift | 66 +++++++++++++++++++ .../Open Groups/OpenGroupManager.swift | 24 +++++-- .../Pollers/OpenGroupPoller.swift | 8 +-- SessionUtilitiesKit/Database/Storage.swift | 6 ++ .../Types/PagedDatabaseObserver.swift | 18 +++-- SessionUtilitiesKit/Networking/HTTP.swift | 9 ++- 9 files changed, 133 insertions(+), 23 deletions(-) diff --git a/Session/Settings/NukeDataModal.swift b/Session/Settings/NukeDataModal.swift index 3f2eb92e5..4877e0e90 100644 --- a/Session/Settings/NukeDataModal.swift +++ b/Session/Settings/NukeDataModal.swift @@ -15,7 +15,7 @@ final class NukeDataModal: Modal { let result = UILabel() result.textColor = Colors.text result.font = .boldSystemFont(ofSize: Values.mediumFontSize) - result.text = NSLocalizedString("modal_clear_all_data_title", comment: "") + result.text = "modal_clear_all_data_title".localized() result.numberOfLines = 0 result.lineBreakMode = .byWordWrapping result.textAlignment = .center @@ -27,7 +27,7 @@ final class NukeDataModal: Modal { let result = UILabel() result.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity) result.font = .systemFont(ofSize: Values.smallFontSize) - result.text = NSLocalizedString("modal_clear_all_data_explanation", comment: "") + result.text = "modal_clear_all_data_explanation".localized() result.numberOfLines = 0 result.textAlignment = .center result.lineBreakMode = .byWordWrapping @@ -44,7 +44,7 @@ final class NukeDataModal: Modal { } result.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize) result.setTitleColor(isLightMode ? Colors.destructive : Colors.text, for: UIControl.State.normal) - result.setTitle(NSLocalizedString("TXT_DELETE_TITLE", comment: ""), for: UIControl.State.normal) + result.setTitle("TXT_DELETE_TITLE".localized(), for: UIControl.State.normal) result.addTarget(self, action: #selector(clearAllData), for: UIControl.Event.touchUpInside) return result @@ -66,7 +66,7 @@ final class NukeDataModal: Modal { result.backgroundColor = Colors.buttonBackground result.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize) result.setTitleColor(Colors.text, for: UIControl.State.normal) - result.setTitle(NSLocalizedString("modal_clear_all_data_device_only_button_title", comment: ""), for: UIControl.State.normal) + result.setTitle("modal_clear_all_data_device_only_button_title".localized(), for: UIControl.State.normal) result.addTarget(self, action: #selector(clearDeviceOnly), for: UIControl.Event.touchUpInside) return result @@ -81,7 +81,7 @@ final class NukeDataModal: Modal { } result.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize) result.setTitleColor(isLightMode ? Colors.destructive : Colors.text, for: UIControl.State.normal) - result.setTitle(NSLocalizedString("modal_clear_all_data_entire_account_button_title", comment: ""), for: UIControl.State.normal) + result.setTitle("modal_clear_all_data_entire_account_button_title".localized(), for: UIControl.State.normal) result.addTarget(self, action: #selector(clearEntireAccount), for: UIControl.Event.touchUpInside) return result @@ -211,6 +211,10 @@ final class NukeDataModal: Modal { PushNotificationAPI.unregister(data).retainUntilComplete() } + // Clear the app badge and notifications + AppEnvironment.shared.notificationPresenter.clearAllNotifications() + CurrentAppContext().setMainAppBadgeNumber(0) + // Clear out the user defaults UserDefaults.removeAll() diff --git a/SessionMessagingKit/Database/Models/OpenGroup.swift b/SessionMessagingKit/Database/Models/OpenGroup.swift index 069959b7c..fec7569ba 100644 --- a/SessionMessagingKit/Database/Models/OpenGroup.swift +++ b/SessionMessagingKit/Database/Models/OpenGroup.swift @@ -156,7 +156,7 @@ public extension OpenGroup { imageId: nil, imageData: nil, userCount: 0, - infoUpdates: -1, + infoUpdates: 0, sequenceNumber: 0, inboxLatestMessageId: 0, outboxLatestMessageId: 0 diff --git a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift index 5be30e2f7..ae538be47 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift @@ -34,6 +34,15 @@ public enum AttachmentUploadJob: JobExecutor { return } + // If the original interaction no longer exists then don't bother uploading the attachment (ie. the + // message was deleted before it even got sent) + if let interactionId: Int64 = job.interactionId { + guard Storage.shared.read({ db in try Interaction.exists(db, id: interactionId) }) == true else { + failure(job, StorageError.objectNotFound, true) + return + } + } + // Note: In the AttachmentUploadJob we intentionally don't provide our own db instance to prevent reentrancy // issues when the success/failure closures get called before the upload as the JobRunner will attempt to // update the state of the job immediately diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index 65190d397..60011301e 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -382,6 +382,72 @@ public enum OpenGroupAPI { } } + /// This is a convenience method which constructs a `/sequence` of the `capabilities` and `rooms` requests, refer to those + /// methods for the documented behaviour of each method + public static func capabilitiesAndRooms( + _ db: Database, + on server: String, + authenticated: Bool = true, + using dependencies: SMKDependencies = SMKDependencies() + ) -> Promise<(capabilities: (info: OnionRequestResponseInfoType, data: Capabilities), rooms: (info: OnionRequestResponseInfoType, data: [Room]))> { + let requestResponseType: [BatchRequestInfoType] = [ + // Get the latest capabilities for the server (in case it's a new server or the cached ones are stale) + BatchRequestInfo( + request: Request( + server: server, + endpoint: .capabilities + ), + responseType: Capabilities.self + ), + + // And the room info + BatchRequestInfo( + request: Request( + server: server, + endpoint: .rooms + ), + responseType: [Room].self + ) + ] + + return OpenGroupAPI + .sequence( + db, + server: server, + requests: requestResponseType, + authenticated: authenticated, + using: dependencies + ) + .map { (response: [Endpoint: (OnionRequestResponseInfoType, Codable?)]) -> (capabilities: (OnionRequestResponseInfoType, Capabilities), rooms: (OnionRequestResponseInfoType, [Room])) in + let maybeCapabilities: (info: OnionRequestResponseInfoType, data: Capabilities?)? = response[.capabilities] + .map { info, data in (info, (data as? BatchSubResponse)?.body) } + let maybeRoomResponse: (OnionRequestResponseInfoType, Codable?)? = response + .first(where: { key, _ in + switch key { + case .rooms: return true + default: return false + } + }) + .map { _, value in value } + let maybeRooms: (info: OnionRequestResponseInfoType, data: [Room]?)? = maybeRoomResponse + .map { info, data in (info, (data as? BatchSubResponse<[Room]>)?.body) } + + guard + let capabilitiesInfo: OnionRequestResponseInfoType = maybeCapabilities?.info, + let capabilities: Capabilities = maybeCapabilities?.data, + let roomsInfo: OnionRequestResponseInfoType = maybeRooms?.info, + let rooms: [Room] = maybeRooms?.data + else { + throw HTTP.Error.parsingFailed + } + + return ( + (capabilitiesInfo, capabilities), + (roomsInfo, rooms) + ) + } + } + // MARK: - Messages /// Posts a new message to a room diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 3b4f5042b..5a2fb5c8a 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -775,17 +775,29 @@ public final class OpenGroupManager: NSObject { } let (promise, seal) = Promise<[OpenGroupAPI.Room]>.pending() - + // Try to retrieve the default rooms 8 times attempt(maxRetryCount: 8, recoveringOn: OpenGroupAPI.workQueue) { dependencies.storage.read { db in - OpenGroupAPI.rooms(db, server: OpenGroupAPI.defaultServer, using: dependencies) + OpenGroupAPI.capabilitiesAndRooms( + db, + on: OpenGroupAPI.defaultServer, + authenticated: false, + using: dependencies + ) } - .map { _, data in data } } - .done(on: OpenGroupAPI.workQueue) { items in + .done(on: OpenGroupAPI.workQueue) { response in dependencies.storage.writeAsync { db in - items + // Store the capabilities first + OpenGroupManager.handleCapabilities( + db, + capabilities: response.capabilities.data, + on: OpenGroupAPI.defaultServer + ) + + // Then the rooms + response.rooms.data .compactMap { room -> (String, String)? in // Try to insert an inactive version of the OpenGroup (use 'insert' rather than 'save' // as we want it to fail if the room already exists) @@ -825,7 +837,7 @@ public final class OpenGroupManager: NSObject { } } - seal.fulfill(items) + seal.fulfill(response.rooms.data) } .catch(on: OpenGroupAPI.workQueue) { error in dependencies.mutableCache.mutate { cache in diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift index 6ebedae5e..72f5126b7 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift @@ -182,7 +182,7 @@ extension OpenGroupAPI { switch endpoint { case .capabilities: guard let responseData: BatchSubResponse = endpointResponse.data as? BatchSubResponse, let responseBody: Capabilities = responseData.body else { - SNLog("Open group polling failed due to invalid data.") + SNLog("Open group polling failed due to invalid capability data.") return } @@ -194,7 +194,7 @@ extension OpenGroupAPI { case .roomPollInfo(let roomToken, _): guard let responseData: BatchSubResponse = endpointResponse.data as? BatchSubResponse, let responseBody: RoomPollInfo = responseData.body else { - SNLog("Open group polling failed due to invalid data.") + SNLog("Open group polling failed due to invalid room info data.") return } @@ -209,7 +209,7 @@ extension OpenGroupAPI { case .roomMessagesRecent(let roomToken), .roomMessagesBefore(let roomToken, _), .roomMessagesSince(let roomToken, _): guard let responseData: BatchSubResponse<[Failable]> = endpointResponse.data as? BatchSubResponse<[Failable]>, let responseBody: [Failable] = responseData.body else { - SNLog("Open group polling failed due to invalid data.") + SNLog("Open group polling failed due to invalid messages data.") return } let successfulMessages: [Message] = responseBody.compactMap { $0.value } @@ -231,7 +231,7 @@ extension OpenGroupAPI { case .inbox, .inboxSince, .outbox, .outboxSince: guard let responseData: BatchSubResponse<[DirectMessage]?> = endpointResponse.data as? BatchSubResponse<[DirectMessage]?>, !responseData.failedToParseBody else { - SNLog("Open group polling failed due to invalid data.") + SNLog("Open group polling failed due to invalid inbox/outbox data.") return } diff --git a/SessionUtilitiesKit/Database/Storage.swift b/SessionUtilitiesKit/Database/Storage.swift index 3cd783d74..3c1d3871f 100644 --- a/SessionUtilitiesKit/Database/Storage.swift +++ b/SessionUtilitiesKit/Database/Storage.swift @@ -193,6 +193,12 @@ public final class Storage { if !jobTableInfo.contains(where: { $0["name"] == "shouldSkipLaunchBecomeActive" }) { finalError = StorageError.devRemigrationRequired } + // Forcibly change any 'infoUpdates' on open groups from '-1' to '0' (-1 is invalid) + try? db.execute(literal: """ + UPDATE openGroup + SET infoUpdates = 0 + WHERE openGroup.infoUpdates = -1 + """) // TODO: Remove this once everyone has updated onComplete(finalError, needsConfigSync) diff --git a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift index d8c60cc5a..bdc17323e 100644 --- a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift +++ b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift @@ -283,8 +283,10 @@ public class PagedDatabaseObserver: TransactionObserver where let indexesAreSequential: Bool = (indexes.map { $0 - 1 }.dropFirst() == indexes.dropLast()) let hasOneValidIndex: Bool = indexInfo.contains(where: { info -> Bool in info.rowIndex >= updatedPageInfo.pageOffset && ( - info.rowIndex < updatedPageInfo.currentCount || - updatedPageInfo.currentCount == 0 + info.rowIndex < updatedPageInfo.currentCount || ( + updatedPageInfo.currentCount < updatedPageInfo.pageSize && + info.rowIndex <= (updatedPageInfo.pageOffset + updatedPageInfo.pageSize) + ) ) }) @@ -293,8 +295,10 @@ public class PagedDatabaseObserver: TransactionObserver where indexInfo .filter { info -> Bool in info.rowIndex >= updatedPageInfo.pageOffset && ( - info.rowIndex < updatedPageInfo.currentCount || - updatedPageInfo.currentCount == 0 + info.rowIndex < updatedPageInfo.currentCount || ( + updatedPageInfo.currentCount < updatedPageInfo.pageSize && + info.rowIndex <= (updatedPageInfo.pageOffset + updatedPageInfo.pageSize) + ) ) } .map { info -> Int64 in info.rowId } @@ -1102,8 +1106,10 @@ public class AssociatedRecord: ErasedAssociatedRecord where T: Fet /// commit - this will mean in some cases we cache data which is actually unrelated to the filtered paged data let hasOneValidIndex: Bool = pagedItemIndexes.contains(where: { info -> Bool in info.rowIndex >= pageInfo.pageOffset && ( - info.rowIndex < pageInfo.currentCount || - pageInfo.currentCount == 0 + info.rowIndex < pageInfo.currentCount || ( + pageInfo.currentCount < pageInfo.pageSize && + info.rowIndex <= (pageInfo.pageOffset + pageInfo.pageSize) + ) ) }) diff --git a/SessionUtilitiesKit/Networking/HTTP.swift b/SessionUtilitiesKit/Networking/HTTP.swift index 06c7b7f13..9e5946735 100644 --- a/SessionUtilitiesKit/Networking/HTTP.swift +++ b/SessionUtilitiesKit/Networking/HTTP.swift @@ -86,6 +86,7 @@ public enum HTTP { case invalidResponse case maxFileSizeExceeded case httpRequestFailed(statusCode: UInt, data: Data?) + case timeout public var errorDescription: String? { switch self { @@ -95,6 +96,7 @@ public enum HTTP { case .parsingFailed, .invalidResponse: return "Invalid response." case .maxFileSizeExceeded: return "Maximum file size exceeded." case .httpRequestFailed(let statusCode, _): return "HTTP request failed with status code: \(statusCode)." + case .timeout: return "The request timed out." } } } @@ -138,8 +140,13 @@ public enum HTTP { } else { SNLog("\(verb.rawValue) request to \(url) failed.") } + // Override the actual error so that we can correctly catch failed requests in sendOnionRequest(invoking:on:with:) - return seal.reject(Error.httpRequestFailed(statusCode: 0, data: nil)) + switch (error as? NSError)?.code { + case NSURLErrorTimedOut: return seal.reject(Error.timeout) + default: return seal.reject(Error.httpRequestFailed(statusCode: 0, data: nil)) + } + } if let error = error { SNLog("\(verb.rawValue) request to \(url) failed due to error: \(error).") From ae4999c3a78bfe7e90ac003c1fdd279a3b9f00dc Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 28 Jul 2022 15:36:56 +1000 Subject: [PATCH 143/157] Fixed a couple of crashes and a couple of other bugs Fixed a crash due to database re-entrancy Fixed an issue where interacting with a push notification wouldn't open the conversation in some cases Added code to prevent a user from being able to start a DM with a blinded id Updated some open group polling logs to be clearer --- Session.xcodeproj/project.pbxproj | 20 +------ Session/DMs/NewDMVC.swift | 54 +++++++++++-------- Session/Meta/AppDelegate.swift | 12 ++++- .../Jobs/Types/UpdateProfilePictureJob.swift | 9 +++- .../Pollers/OpenGroupPoller.swift | 10 +++- 5 files changed, 62 insertions(+), 43 deletions(-) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index e6a50a7d4..985a383ac 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -3070,8 +3070,6 @@ children = ( C3C2A5B0255385C700C340D1 /* Meta */, FD17D79D27F40CAA00122BE0 /* Database */, - FD17D7DF27F67BC400122BE0 /* Models */, - FD17D7D027F5795300122BE0 /* Types */, FDC438AF27BB158500C60D73 /* Models */, C3C2A5CD255385F300C340D1 /* Utilities */, C3C2A5B9255385ED00C340D1 /* Configuration.swift */, @@ -3558,20 +3556,6 @@ path = Models; sourceTree = ""; }; - FD17D7D027F5795300122BE0 /* Types */ = { - isa = PBXGroup; - children = ( - ); - path = Types; - sourceTree = ""; - }; - FD17D7DF27F67BC400122BE0 /* Models */ = { - isa = PBXGroup; - children = ( - ); - path = Models; - sourceTree = ""; - }; FD17D7E827F6A1B800122BE0 /* LegacyDatabase */ = { isa = PBXGroup; children = ( @@ -6830,7 +6814,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 357; + CURRENT_PROJECT_VERSION = 360; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -6902,7 +6886,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 357; + CURRENT_PROJECT_VERSION = 360; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", diff --git a/Session/DMs/NewDMVC.swift b/Session/DMs/NewDMVC.swift index 8767c8263..b55fbb3df 100644 --- a/Session/DMs/NewDMVC.swift +++ b/Session/DMs/NewDMVC.swift @@ -134,30 +134,42 @@ final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControll } fileprivate func startNewDMIfPossible(with onsNameOrPublicKey: String) { - if ECKeyPair.isValidHexEncodedPublicKey(candidate: onsNameOrPublicKey) { + let maybeSessionId: SessionId? = SessionId(from: onsNameOrPublicKey) + + if ECKeyPair.isValidHexEncodedPublicKey(candidate: onsNameOrPublicKey) && maybeSessionId?.prefix == .standard { startNewDM(with: onsNameOrPublicKey) - } else { - // This could be an ONS name - ModalActivityIndicatorViewController.present(fromViewController: navigationController!, canCancel: false) { [weak self] modalActivityIndicator in - SnodeAPI.getSessionID(for: onsNameOrPublicKey).done { sessionID in - modalActivityIndicator.dismiss { - self?.startNewDM(with: sessionID) - } - }.catch { error in - modalActivityIndicator.dismiss { - var messageOrNil: String? - if let error = error as? SnodeAPIError { - switch error { - case .decryptionFailed, .hashingFailed, .validationFailed: - messageOrNil = error.errorDescription - default: break - } + return + } + + // This could be an ONS name + ModalActivityIndicatorViewController.present(fromViewController: navigationController!, canCancel: false) { [weak self] modalActivityIndicator in + SnodeAPI.getSessionID(for: onsNameOrPublicKey).done { sessionID in + modalActivityIndicator.dismiss { + self?.startNewDM(with: sessionID) + } + }.catch { error in + modalActivityIndicator.dismiss { + var messageOrNil: String? + if let error = error as? SnodeAPIError { + switch error { + case .decryptionFailed, .hashingFailed, .validationFailed: + messageOrNil = error.errorDescription + default: break } - let message = messageOrNil ?? "Please check the Session ID or ONS name and try again" - let alert = UIAlertController(title: "Error", message: message, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil)) - self?.presentAlert(alert) } + let message: String = { + if let messageOrNil: String = messageOrNil { + return messageOrNil + } + + return (maybeSessionId?.prefix == .blinded ? + "You can only send messages to Blinded IDs from within an Open Group" : + "Please check the Session ID or ONS name and try again" + ) + }() + let alert = UIAlertController(title: "Error", message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "BUTTON_OK".localized(), style: .default, handler: nil)) + self?.presentAlert(alert) } } } diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 84286342e..7067ea3db 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -114,6 +114,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD return true } + func applicationWillEnterForeground(_ application: UIApplication) { + /// **Note:** We _shouldn't_ need to call this here but for some reason the OS doesn't seems to + /// be calling the `userNotificationCenter(_:,didReceive:withCompletionHandler:)` + /// method when the device is locked while the app is in the foreground (or if the user returns to the + /// springboard without swapping to another app) - adding this here in addition to the one in + /// `appDidFinishLaunching` seems to fix this odd behaviour (even though it doesn't match + /// Apple's documentation on the matter) + UNUserNotificationCenter.current().delegate = self + } + func applicationDidEnterBackground(_ application: UIApplication) { DDLog.flushLog() @@ -149,7 +159,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD /// no longer always called before `applicationDidBecomeActive` we need to trigger the "clear notifications" logic /// within the `runNowOrWhenAppDidBecomeReady` callback and dispatch to the next run loop to ensure it runs after /// the notification has actually been handled - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { [weak self] in + DispatchQueue.main.async { [weak self] in self?.clearAllNotificationsAndRestoreBadgeCount() } } diff --git a/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift b/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift index 803ea34b9..260f150be 100644 --- a/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift +++ b/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift @@ -52,7 +52,14 @@ public enum UpdateProfilePictureJob: JobExecutor { image: nil, imageFilePath: profileFilePath, requiredSync: true, - success: { _, _ in success(job, false) }, + success: { _, _ in + // Need to call the 'success' closure asynchronously on the queue to prevent a reentrancy + // issue as it will write to the database and this closure is already called within + // another database write + queue.async { + success(job, false) + } + }, failure: { error in failure(job, error, false) } ) } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift index 72f5126b7..c46938844 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift @@ -194,7 +194,10 @@ extension OpenGroupAPI { case .roomPollInfo(let roomToken, _): guard let responseData: BatchSubResponse = endpointResponse.data as? BatchSubResponse, let responseBody: RoomPollInfo = responseData.body else { - SNLog("Open group polling failed due to invalid room info data.") + switch (endpointResponse.data as? BatchSubResponse)?.code { + case 404: SNLog("Open group polling failed to retrieve info for unknown room '\(roomToken)'.") + default: SNLog("Open group polling failed due to invalid room info data.") + } return } @@ -209,7 +212,10 @@ extension OpenGroupAPI { case .roomMessagesRecent(let roomToken), .roomMessagesBefore(let roomToken, _), .roomMessagesSince(let roomToken, _): guard let responseData: BatchSubResponse<[Failable]> = endpointResponse.data as? BatchSubResponse<[Failable]>, let responseBody: [Failable] = responseData.body else { - SNLog("Open group polling failed due to invalid messages data.") + switch (endpointResponse.data as? BatchSubResponse<[Failable]>)?.code { + case 404: SNLog("Open group polling failed to retrieve messages for unknown room '\(roomToken)'.") + default: SNLog("Open group polling failed due to invalid messages data.") + } return } let successfulMessages: [Message] = responseBody.compactMap { $0.value } From c022f7cda23afbf8d1fe480a05f56c5de55834cd Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 28 Jul 2022 18:26:22 +1000 Subject: [PATCH 144/157] Added an exponential back-off to polling open groups when they fail to poll --- .../_001_InitialSetupMigration.swift | 3 + .../Database/Models/OpenGroup.swift | 14 +++- .../Pollers/OpenGroupPoller.swift | 66 +++++++++++++++++-- SessionUtilitiesKit/Database/Storage.swift | 8 +++ 4 files changed, 82 insertions(+), 9 deletions(-) diff --git a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift index 0e18775b6..61747d7ea 100644 --- a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -140,6 +140,9 @@ enum _001_InitialSetupMigration: Migration { t.column(.sequenceNumber, .integer).notNull() t.column(.inboxLatestMessageId, .integer).notNull() t.column(.outboxLatestMessageId, .integer).notNull() + t.column(.pollFailureCount, .integer) + .notNull() + .defaults(to: 0) } /// Create a full-text search table synchronized with the OpenGroup table diff --git a/SessionMessagingKit/Database/Models/OpenGroup.swift b/SessionMessagingKit/Database/Models/OpenGroup.swift index fec7569ba..71912d32d 100644 --- a/SessionMessagingKit/Database/Models/OpenGroup.swift +++ b/SessionMessagingKit/Database/Models/OpenGroup.swift @@ -26,6 +26,7 @@ public struct OpenGroup: Codable, Identifiable, FetchableRecord, PersistableReco case sequenceNumber case inboxLatestMessageId case outboxLatestMessageId + case pollFailureCount } public var id: String { threadId } // Identifiable @@ -86,6 +87,9 @@ public struct OpenGroup: Codable, Identifiable, FetchableRecord, PersistableReco /// updated whenever this value changes) public let outboxLatestMessageId: Int64 + /// The number of times this room has failed to poll since the last successful poll + public let pollFailureCount: Int64 + // MARK: - Relationships public var thread: QueryInterfaceRequest { @@ -117,7 +121,8 @@ public struct OpenGroup: Codable, Identifiable, FetchableRecord, PersistableReco infoUpdates: Int64, sequenceNumber: Int64 = 0, inboxLatestMessageId: Int64 = 0, - outboxLatestMessageId: Int64 = 0 + outboxLatestMessageId: Int64 = 0, + pollFailureCount: Int64 = 0 ) { self.threadId = OpenGroup.idFor(roomToken: roomToken, server: server) self.server = server.lowercased() @@ -133,6 +138,7 @@ public struct OpenGroup: Codable, Identifiable, FetchableRecord, PersistableReco self.sequenceNumber = sequenceNumber self.inboxLatestMessageId = inboxLatestMessageId self.outboxLatestMessageId = outboxLatestMessageId + self.pollFailureCount = pollFailureCount } } @@ -159,7 +165,8 @@ public extension OpenGroup { infoUpdates: 0, sequenceNumber: 0, inboxLatestMessageId: 0, - outboxLatestMessageId: 0 + outboxLatestMessageId: 0, + pollFailureCount: 0 ) } @@ -192,7 +199,8 @@ extension OpenGroup: CustomStringConvertible, CustomDebugStringConvertible { "infoUpdates: \(infoUpdates)", "sequenceNumber: \(sequenceNumber)", "inboxLatestMessageId: \(inboxLatestMessageId)", - "outboxLatestMessageId: \(outboxLatestMessageId))" + "outboxLatestMessageId: \(outboxLatestMessageId)", + "pollFailureCount: \(pollFailureCount))" ].joined(separator: ", ") } } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift index c46938844..52c2714fd 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift @@ -15,7 +15,8 @@ extension OpenGroupAPI { // MARK: - Settings - private static let pollInterval: TimeInterval = 4 + private static let minPollInterval: TimeInterval = 3 + private static let maxPollInterval: Double = (60 * 60) internal static let maxInactivityPeriod: Double = (14 * 24 * 60 * 60) // MARK: - Lifecycle @@ -28,10 +29,7 @@ extension OpenGroupAPI { guard !hasStarted else { return } hasStarted = true - timer = Timer.scheduledTimerOnMainThread(withTimeInterval: Poller.pollInterval, repeats: true) { _ in - self.poll(using: dependencies).retainUntilComplete() - } - poll(using: dependencies).retainUntilComplete() + pollRecursively(using: dependencies) } @objc public func stop() { @@ -41,6 +39,30 @@ extension OpenGroupAPI { // MARK: - Polling + private func pollRecursively(using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies()) { + guard hasStarted else { return } + + let minPollFailureCount: TimeInterval = Storage.shared + .read { db in + try OpenGroup + .filter(OpenGroup.Columns.server == server) + .select(min(OpenGroup.Columns.pollFailureCount)) + .asRequest(of: TimeInterval.self) + .fetchOne(db) + } + .defaulting(to: 0) + let nextPollInterval: TimeInterval = getInterval(for: minPollFailureCount, minInterval: Poller.minPollInterval, maxInterval: Poller.maxPollInterval) + + poll(using: dependencies).retainUntilComplete() + timer = Timer.scheduledTimerOnMainThread(withTimeInterval: nextPollInterval, repeats: false) { [weak self] timer in + timer.invalidate() + + Threading.pollerQueue.async { + self?.pollRecursively(using: dependencies) + } + } + } + @discardableResult public func poll(using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies()) -> Promise { return poll(isBackgroundPoll: false, isPostCapabilitiesRetry: false, using: dependencies) @@ -83,6 +105,14 @@ extension OpenGroupAPI { cache.timeSinceLastPoll[server] = Date().timeIntervalSince1970 UserDefaults.standard[.lastOpen] = Date() } + + // Reset the failure count + Storage.shared.writeAsync { db in + try OpenGroup + .filter(OpenGroup.Columns.server == server) + .updateAll(db, OpenGroup.Columns.pollFailureCount.set(to: 0)) + } + SNLog("Open group polling finished for \(server).") seal.fulfill(()) } @@ -97,7 +127,24 @@ extension OpenGroupAPI { ) .done(on: OpenGroupAPI.workQueue) { [weak self] didHandleError in if !didHandleError { - SNLog("Open group polling failed due to error: \(error).") + // Increase the failure count + let pollFailureCount: Int64 = Storage.shared + .read { db in + try OpenGroup + .filter(OpenGroup.Columns.server == server) + .select(max(OpenGroup.Columns.pollFailureCount)) + .asRequest(of: Int64.self) + .fetchOne(db) + } + .defaulting(to: 0) + + Storage.shared.writeAsync { db in + try OpenGroup + .filter(OpenGroup.Columns.server == server) + .updateAll(db, OpenGroup.Columns.pollFailureCount.set(to: (pollFailureCount + 1))) + } + + SNLog("Open group polling failed due to error: \(error). Setting failure count to \(pollFailureCount).") } self?.isPolling = false @@ -265,4 +312,11 @@ extension OpenGroupAPI { } } } + + // MARK: - Convenience + + fileprivate static func getInterval(for failureCount: TimeInterval, minInterval: TimeInterval, maxInterval: TimeInterval) -> TimeInterval { + // Arbitrary backoff factor... + return min(maxInterval, minInterval + pow(2, failureCount)) + } } diff --git a/SessionUtilitiesKit/Database/Storage.swift b/SessionUtilitiesKit/Database/Storage.swift index 3c1d3871f..92ba77ec0 100644 --- a/SessionUtilitiesKit/Database/Storage.swift +++ b/SessionUtilitiesKit/Database/Storage.swift @@ -200,6 +200,14 @@ public final class Storage { WHERE openGroup.infoUpdates = -1 """) // TODO: Remove this once everyone has updated + let openGroupTableInfo: [Row] = (try? Row.fetchAll(db, sql: "PRAGMA table_info(openGroup)")) + .defaulting(to: []) + if !openGroupTableInfo.contains(where: { $0["name"] == "pollFailureCount" }) { + try? db.execute(literal: """ + ALTER TABLE openGroup + ADD pollFailureCount INTEGER NOT NULL DEFAULT 0 + """) + } onComplete(finalError, needsConfigSync) } From 4d5ded7557152472cc402bfe211c9a6b70d42dba Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 29 Jul 2022 15:26:24 +1000 Subject: [PATCH 145/157] Fixed a few bugs with media attachment handling, added webp support Updated the OpenGroupManager to create a BlindedIdLookup for messages within the `inbox` (validating that the sessionId does actually match the blindedId) Added support for static and animated WebP images Added basic support for HEIC and HEIF images Fixed an issue where the file size limit was set to 10,000,000 bytes instead of 10,485,760 bytes (which is actually 10Mb) Fixed an issue where attachments uploaded by the current user on other devices would always show a loading indicator Fixed an issue where media attachments that don't contain width/height information in their protos weren't updating the values once the download was completed Fixed an issue where the media view could download an invalid file and endlessly appear to be downloading --- Podfile | 3 + Podfile.lock | 17 +- .../Content Views/MediaView.swift | 34 ++- .../MediaDetailViewController.swift | 20 +- .../Database/Models/Attachment.swift | 29 ++- .../Database/Models/BlindedIdLookup.swift | 17 ++ .../File Server/FileServerAPI.swift | 2 +- .../Jobs/Types/DisappearingMessagesJob.swift | 34 +++ .../Open Groups/OpenGroupManager.swift | 53 ++-- SessionUtilitiesKit/Media/MIMETypeUtil.h | 7 + SessionUtilitiesKit/Media/MIMETypeUtil.m | 45 +++- SessionUtilitiesKit/Media/NSData+Image.m | 230 ++++++++++++++++-- 12 files changed, 424 insertions(+), 67 deletions(-) diff --git a/Podfile b/Podfile index 705f23a53..a8b57ea29 100644 --- a/Podfile +++ b/Podfile @@ -24,6 +24,7 @@ abstract_target 'GlobalDependencies' do pod 'PureLayout', '~> 3.1.8' pod 'NVActivityIndicatorView' pod 'YYImage', git: 'https://github.com/signalapp/YYImage' + pod 'YYImage/libwebp', git: 'https://github.com/signalapp/YYImage' pod 'ZXingObjC' pod 'DifferenceKit' end @@ -52,6 +53,7 @@ abstract_target 'GlobalDependencies' do pod 'SAMKeychain' pod 'SwiftProtobuf', '~> 1.5.0' pod 'YYImage', git: 'https://github.com/signalapp/YYImage' + pod 'YYImage/libwebp', git: 'https://github.com/signalapp/YYImage' pod 'DifferenceKit' end @@ -71,6 +73,7 @@ abstract_target 'GlobalDependencies' do target 'SessionUtilitiesKit' do pod 'SAMKeychain' + pod 'YYImage/libwebp', git: 'https://github.com/signalapp/YYImage' target 'SessionUtilitiesKitTests' do inherit! :complete diff --git a/Podfile.lock b/Podfile.lock index 70045a1da..58c3a3735 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -29,6 +29,15 @@ PODS: - DifferenceKit/Core - GRDB.swift/SQLCipher (5.24.1): - SQLCipher (>= 3.4.0) + - libwebp (1.2.1): + - libwebp/demux (= 1.2.1) + - libwebp/mux (= 1.2.1) + - libwebp/webp (= 1.2.1) + - libwebp/demux (1.2.1): + - libwebp/webp + - libwebp/mux (1.2.1): + - libwebp/demux + - libwebp/webp (1.2.1) - Nimble (10.0.0) - NVActivityIndicatorView (5.1.1): - NVActivityIndicatorView/Base (= 5.1.1) @@ -124,6 +133,9 @@ PODS: - YYImage (1.0.4): - YYImage/Core (= 1.0.4) - YYImage/Core (1.0.4) + - YYImage/libwebp (1.0.4): + - libwebp + - YYImage/Core - ZXingObjC (3.6.5): - ZXingObjC/All (= 3.6.5) - ZXingObjC/All (3.6.5) @@ -149,6 +161,7 @@ DEPENDENCIES: - WebRTC-lib - YapDatabase/SQLCipher (from `https://github.com/oxen-io/session-ios-yap-database.git`, branch `signal-release`) - YYImage (from `https://github.com/signalapp/YYImage`) + - YYImage/libwebp (from `https://github.com/signalapp/YYImage`) - ZXingObjC SPEC REPOS: @@ -158,6 +171,7 @@ SPEC REPOS: - CryptoSwift - DifferenceKit - GRDB.swift + - libwebp - Nimble - NVActivityIndicatorView - OpenSSL-Universal @@ -212,6 +226,7 @@ SPEC CHECKSUMS: Curve25519Kit: e63f9859ede02438ae3defc5e1a87e09d1ec7ee6 DifferenceKit: 5659c430bb7fe45876fa32ce5cba5d6167f0c805 GRDB.swift: b3180ce2135fc06a453297889b746b1478c4d8c7 + libwebp: 98a37e597e40bfdb4c911fc98f2c53d0b12d05fc Nimble: 5316ef81a170ce87baf72dd961f22f89a602ff84 NVActivityIndicatorView: 1f6c5687f1171810aa27a3296814dc2d7dec3667 OpenSSL-Universal: e7311447fd2419f57420c79524b641537387eff2 @@ -230,6 +245,6 @@ SPEC CHECKSUMS: YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331 ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: 6ab902a81a379cc2c0a9a92c334c78d413190338 +PODFILE CHECKSUM: 456facc7043447a9c67733cf8846ec62afff8ea8 COCOAPODS: 1.11.3 diff --git a/Session/Conversations/Message Cells/Content Views/MediaView.swift b/Session/Conversations/Message Cells/Content Views/MediaView.swift index a194e6588..77f2183c1 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaView.swift @@ -129,7 +129,10 @@ public class MediaView: UIView { configure(forError: .failed) return false } - guard attachment.state != .uploaded else { return false } + + // If this message was uploaded on a different device it'll now be seen as 'downloaded' (but + // will still be outgoing - we don't want to show a loading indicator in this case) + guard attachment.state != .uploaded && attachment.state != .downloaded else { return false } let loader = MediaLoaderView() addSubview(loader) @@ -164,9 +167,13 @@ public class MediaView: UIView { } strongSelf.tryToLoadMedia( loadMediaBlock: { applyMediaBlock in - guard attachment.isValid else { return } + guard attachment.isValid else { + self?.configure(forError: .invalid) + return + } guard let filePath: String = attachment.originalFilePath else { owsFailDebug("Attachment stream missing original file path.") + self?.configure(forError: .invalid) return } @@ -177,6 +184,7 @@ public class MediaView: UIView { guard let image: YYImage = media as? YYImage else { owsFailDebug("Media has unexpected type: \(type(of: media))") + self?.configure(forError: .invalid) return } // FIXME: Animated images flicker when reloading the cells (even though they are in the cache) @@ -216,12 +224,18 @@ public class MediaView: UIView { } self?.tryToLoadMedia( loadMediaBlock: { applyMediaBlock in - guard attachment.isValid else { return } + guard attachment.isValid else { + self?.configure(forError: .invalid) + return + } attachment.thumbnail( size: .large, success: { image, _ in applyMediaBlock(image) }, - failure: { Logger.error("Could not load thumbnail") } + failure: { + Logger.error("Could not load thumbnail") + self?.configure(forError: .invalid) + } ) }, applyMediaBlock: { media in @@ -229,6 +243,7 @@ public class MediaView: UIView { guard let image: UIImage = media as? UIImage else { owsFailDebug("Media has unexpected type: \(type(of: media))") + self?.configure(forError: .invalid) return } @@ -277,12 +292,18 @@ public class MediaView: UIView { } self?.tryToLoadMedia( loadMediaBlock: { applyMediaBlock in - guard attachment.isValid else { return } + guard attachment.isValid else { + self?.configure(forError: .invalid) + return + } attachment.thumbnail( size: .medium, success: { image, _ in applyMediaBlock(image) }, - failure: { Logger.error("Could not load thumbnail") } + failure: { + Logger.error("Could not load thumbnail") + self?.configure(forError: .invalid) + } ) }, applyMediaBlock: { media in @@ -290,6 +311,7 @@ public class MediaView: UIView { guard let image: UIImage = media as? UIImage else { owsFailDebug("Media has unexpected type: \(type(of: media))") + self?.configure(forError: .invalid) return } diff --git a/Session/Media Viewing & Editing/MediaDetailViewController.swift b/Session/Media Viewing & Editing/MediaDetailViewController.swift index 80538eb4a..2d89e625d 100644 --- a/Session/Media Viewing & Editing/MediaDetailViewController.swift +++ b/Session/Media Viewing & Editing/MediaDetailViewController.swift @@ -129,7 +129,15 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVid // MARK: - Functions private func updateMinZoomScale() { - guard let image: UIImage = image else { + let maybeImageSize: CGSize? = { + switch self.mediaView { + case let imageView as UIImageView: return (imageView.image?.size ?? .zero) + case let imageView as YYAnimatedImageView: return (imageView.image?.size ?? .zero) + default: return nil + } + }() + + guard let imageSize: CGSize = maybeImageSize else { self.scrollView.minimumZoomScale = 1 self.scrollView.maximumZoomScale = 1 self.scrollView.zoomScale = 1 @@ -138,13 +146,13 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVid let viewSize: CGSize = self.scrollView.bounds.size - guard image.size.width > 0 && image.size.height > 0 else { - SNLog("Invalid image dimensions (\(image.size.width), \(image.size.height))") - return; + guard imageSize.width > 0 && imageSize.height > 0 else { + SNLog("Invalid image dimensions (\(imageSize.width), \(imageSize.height))") + return } - let scaleWidth: CGFloat = (viewSize.width / image.size.width) - let scaleHeight: CGFloat = (viewSize.height / image.size.height) + let scaleWidth: CGFloat = (viewSize.width / imageSize.width) + let scaleHeight: CGFloat = (viewSize.height / imageSize.height) let minScale: CGFloat = min(scaleWidth, scaleHeight) if minScale != self.scrollView.minimumZoomScale { diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index a0d18d392..e0ceef673 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -339,6 +339,23 @@ extension Attachment { default: return (self.isValid, self.duration) } }() + // Regenerate this just in case we added support since the attachment was inserted into + // the database (eg. manually downloaded in a later update) + let isVisualMedia: Bool = ( + MIMETypeUtil.isImage(contentType) || + MIMETypeUtil.isVideo(contentType) || + MIMETypeUtil.isAnimated(contentType) + ) + let attachmentResolution: CGSize? = { + if let width: UInt = self.width, let height: UInt = self.height, width > 0, height > 0 { + return CGSize(width: Int(width), height: Int(height)) + } + guard isVisualMedia else { return nil } + guard state == .downloaded else { return nil } + guard let originalFilePath: String = originalFilePath else { return nil } + + return Attachment.imageSize(contentType: contentType, originalFilePath: originalFilePath) + }() return Attachment( id: self.id, @@ -351,10 +368,16 @@ extension Attachment { sourceFilename: sourceFilename, downloadUrl: (downloadUrl ?? self.downloadUrl), localRelativeFilePath: (localRelativeFilePath ?? self.localRelativeFilePath), - width: width, - height: height, + width: attachmentResolution.map { UInt($0.width) }, + height: attachmentResolution.map { UInt($0.height) }, duration: duration, - isVisualMedia: isVisualMedia, + isVisualMedia: ( + // Regenerate this just in case we added support since the attachment was inserted into + // the database (eg. manually downloaded in a later update) + MIMETypeUtil.isImage(contentType) || + MIMETypeUtil.isVideo(contentType) || + MIMETypeUtil.isAnimated(contentType) + ), isValid: isValid, encryptionKey: (encryptionKey ?? self.encryptionKey), digest: (digest ?? self.digest), diff --git a/SessionMessagingKit/Database/Models/BlindedIdLookup.swift b/SessionMessagingKit/Database/Models/BlindedIdLookup.swift index 6d63624ae..d5e8704c6 100644 --- a/SessionMessagingKit/Database/Models/BlindedIdLookup.swift +++ b/SessionMessagingKit/Database/Models/BlindedIdLookup.swift @@ -72,6 +72,7 @@ public extension BlindedIdLookup { static func fetchOrCreate( _ db: Database, blindedId: String, + sessionId: String? = nil, openGroupServer: String, openGroupPublicKey: String, isCheckingForOutbox: Bool, @@ -90,6 +91,22 @@ public extension BlindedIdLookup { // If the lookup already has a resolved sessionId then just return it immediately guard lookup.sessionId == nil else { return lookup } + // If we we given a sessionId then validate it is correct and if so save it + if + let sessionId: String = sessionId, + dependencies.sodium.sessionId( + sessionId, + matchesBlindedId: blindedId, + serverPublicKey: openGroupPublicKey, + genericHash: dependencies.genericHash + ) + { + lookup = try lookup + .with(sessionId: sessionId) + .saved(db) + return lookup + } + // We now need to try to match the blinded id to an existing contact, this can only be done by looping // through all approved contacts and generating a blinded id for the provided open group for each to // see if it matches the provided blindedId diff --git a/SessionMessagingKit/File Server/FileServerAPI.swift b/SessionMessagingKit/File Server/FileServerAPI.swift index c92c9499e..694bf53be 100644 --- a/SessionMessagingKit/File Server/FileServerAPI.swift +++ b/SessionMessagingKit/File Server/FileServerAPI.swift @@ -14,7 +14,7 @@ public final class FileServerAPI: NSObject { public static let oldServerPublicKey = "7cb31905b55cd5580c686911debf672577b3fb0bff81df4ce2d5c4cb3a7aaa69" @objc public static let server = "http://filev2.getsession.org" public static let serverPublicKey = "da21e1d886c6fbaea313f75298bd64aab03a97ce985b46bb2dad9f2089c8ee59" - public static let maxFileSize = 10_000_000 // 10 MB + public static let maxFileSize = (10 * 1024 * 1024) // 10 MB /// The file server has a file size limit of `maxFileSize`, which the Service Nodes try to enforce as well. However, the limit applied by the Service Nodes /// is on the **HTTP request** and not the actual file size. Because the file server expects the file data to be base 64 encoded, the size of the HTTP /// request for a given file will be at least `ceil(n / 3) * 4` bytes, where n is the file size in bytes. This is the minimum size because there might also diff --git a/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift b/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift index 9ed31c7e7..4bad27849 100644 --- a/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift +++ b/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift @@ -41,6 +41,40 @@ public enum DisappearingMessagesJob: JobExecutor { // The 'if' is only there to prevent the "variable never read" warning from showing if backgroundTask != nil { backgroundTask = nil } + + // TODO: Remove this for the final build + Storage.shared.writeAsync { db in + // Re-process all WebP images, and images with no width/height values to update their validity state + let supportedVisualMediaMimeTypes: Set = MIMETypeUtil.supportedImageMIMETypes() + .appending(contentsOf: MIMETypeUtil.supportedAnimatedImageMIMETypes()) + .appending(contentsOf: MIMETypeUtil.supportedVideoMIMETypes()) + .asSet() + let attachments: [Attachment] = try Attachment + .filter(Attachment.Columns.state == Attachment.State.downloaded) + .filter( + Attachment.Columns.contentType == "image/webp" || ( + ( + Attachment.Columns.width == nil || + Attachment.Columns.height == nil + ) && + supportedVisualMediaMimeTypes.contains(Attachment.Columns.contentType) + ) + ) + .filter( + !Attachment.Columns.isValid || + !Attachment.Columns.isVisualMedia || + Attachment.Columns.width == nil || + Attachment.Columns.height == nil + ) + .fetchAll(db) + + if !attachments.isEmpty { + attachments.forEach { attachment in + _ = try? attachment.with(state: attachment.state).saved(db) + } + } + } + // TODO: Remove this for the final build } } diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 5a2fb5c8a..c72dea525 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -617,28 +617,37 @@ public final class OpenGroupManager: NSObject { dependencies: dependencies ) - // If the message was an outgoing message then attempt to unblind the recipient (this will help put - // messages in the correct thread in case of message request approval race conditions as well as - // during device sync'ing and restoration) + // 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) + if let result: BlindedIdLookup = lookupCache[message.recipient] { + return result + } + + return try BlindedIdLookup.fetchOrCreate( + db, + blindedId: (fromOutbox ? + message.recipient : + message.sender + ), + sessionId: (fromOutbox ? + nil : + processedMessage?.threadId + ), + openGroupServer: server.lowercased(), + openGroupPublicKey: openGroup.publicKey, + isCheckingForOutbox: fromOutbox, + dependencies: dependencies + ) + }() + lookupCache[message.recipient] = lookup + + // We also need to set the 'syncTarget' for outgoing messages to be consistent with + // standard messages if fromOutbox { - // Attempt to un-blind the 'message.recipient' - 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) - if let result: BlindedIdLookup = lookupCache[message.recipient] { - return result - } - - return try BlindedIdLookup.fetchOrCreate( - db, - blindedId: message.recipient, - openGroupServer: server.lowercased(), - openGroupPublicKey: openGroup.publicKey, - isCheckingForOutbox: true, - dependencies: dependencies - ) - }() let syncTarget: String = (lookup.sessionId ?? message.recipient) switch processedMessage?.messageInfo.variant { @@ -650,8 +659,6 @@ public final class OpenGroupManager: NSObject { default: break } - - lookupCache[message.recipient] = lookup } if let messageInfo: MessageReceiveJob.Details.MessageInfo = processedMessage?.messageInfo { diff --git a/SessionUtilitiesKit/Media/MIMETypeUtil.h b/SessionUtilitiesKit/Media/MIMETypeUtil.h index 811fea270..697e15e3f 100644 --- a/SessionUtilitiesKit/Media/MIMETypeUtil.h +++ b/SessionUtilitiesKit/Media/MIMETypeUtil.h @@ -12,6 +12,9 @@ extern NSString *const OWSMimeTypeImageTiff1; extern NSString *const OWSMimeTypeImageTiff2; extern NSString *const OWSMimeTypeImageBmp1; extern NSString *const OWSMimeTypeImageBmp2; +extern NSString *const OWSMimeTypeImageWebp; +extern NSString *const OWSMimeTypeImageHeic; +extern NSString *const OWSMimeTypeImageHeif; extern NSString *const OWSMimeTypeUnknownForTests; extern NSString *const kOversizeTextAttachmentUTI; @@ -36,6 +39,10 @@ extern NSString *const kSyncMessageFileExtension; + (nullable NSString *)getSupportedExtensionFromImageMIMEType:(NSString *)supportedMIMEType; + (nullable NSString *)getSupportedExtensionFromAnimatedMIMEType:(NSString *)supportedMIMEType; ++ (NSArray *)supportedImageMIMETypes; ++ (NSArray *)supportedAnimatedImageMIMETypes; ++ (NSArray *)supportedVideoMIMETypes; + + (BOOL)isAnimated:(NSString *)contentType; + (BOOL)isImage:(NSString *)contentType; + (BOOL)isVideo:(NSString *)contentType; diff --git a/SessionUtilitiesKit/Media/MIMETypeUtil.m b/SessionUtilitiesKit/Media/MIMETypeUtil.m index e93ed6bf6..469898125 100644 --- a/SessionUtilitiesKit/Media/MIMETypeUtil.m +++ b/SessionUtilitiesKit/Media/MIMETypeUtil.m @@ -19,6 +19,9 @@ NSString *const OWSMimeTypeImageTiff1 = @"image/tiff"; NSString *const OWSMimeTypeImageTiff2 = @"image/x-tiff"; NSString *const OWSMimeTypeImageBmp1 = @"image/bmp"; NSString *const OWSMimeTypeImageBmp2 = @"image/x-windows-bmp"; +NSString *const OWSMimeTypeImageWebp = @"image/webp"; +NSString *const OWSMimeTypeImageHeic = @"image/heic"; +NSString *const OWSMimeTypeImageHeif = @"image/heif"; NSString *const OWSMimeTypeUnknownForTests = @"unknown/mimetype"; NSString *const OWSMimeTypeApplicationZip = @"application/zip"; NSString *const OWSMimeTypeApplicationPdf = @"application/pdf"; @@ -85,7 +88,8 @@ NSString *const kSyncMessageFileExtension = @"bin"; @"image/bmp" : @"bmp", @"image/x-windows-bmp" : @"bmp", @"image/gif" : @"gif", - @"image/x-icon": @"ico" + @"image/x-icon": @"ico", + OWSMimeTypeImageWebp : @"webp" }; }); return result; @@ -97,6 +101,7 @@ NSString *const kSyncMessageFileExtension = @"bin"; dispatch_once(&onceToken, ^{ result = @{ OWSMimeTypeImageGif : @"gif", + OWSMimeTypeImageWebp : @"image/webp", }; }); return result; @@ -175,7 +180,8 @@ NSString *const kSyncMessageFileExtension = @"bin"; @"jpeg" : @"image/jpeg", @"jpg" : @"image/jpeg", @"tif" : @"image/tiff", - @"tiff" : @"image/tiff" + @"tiff" : @"image/tiff", + @"webp" : OWSMimeTypeImageWebp }; }); return result; @@ -187,6 +193,7 @@ NSString *const kSyncMessageFileExtension = @"bin"; dispatch_once(&onceToken, ^{ result = @{ @"gif" : OWSMimeTypeImageGif, + @"image/webp" : OWSMimeTypeImageWebp }; }); return result; @@ -556,6 +563,36 @@ NSString *const kSyncMessageFileExtension = @"bin"; return result; } ++ (NSArray *)supportedImageMIMETypes +{ + static NSArray *result = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + result = [self supportedImageMIMETypesToExtensionTypes].allKeys; + }); + return result; +} + ++ (NSArray *)supportedAnimatedImageMIMETypes +{ + static NSArray *result = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + result = [self supportedAnimatedMIMETypesToExtensionTypes].allKeys; + }); + return result; +} + ++ (NSArray *)supportedVideoMIMETypes +{ + static NSArray *result = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + result = [self supportedVideoMIMETypesToExtensionTypes].allKeys; + }); + return result; +} + + (NSDictionary *)genericMIMETypesToExtensionTypes { static NSDictionary *result = nil; @@ -1386,6 +1423,8 @@ NSString *const kSyncMessageFileExtension = @"bin"; @"image/fif" : @"fif", @"image/g3fax" : @"g3", @"image/gif" : @"gif", + @"image/heic" : @"heic", + @"image/heif" : @"heif", @"image/ief" : @"ief", @"image/jpeg" : @"jpg", @"image/jutvision" : @"jut", @@ -1935,6 +1974,8 @@ NSString *const kSyncMessageFileExtension = @"bin"; @"hal" : @"application/vnd.hal+xml", @"hbci" : @"application/vnd.hbci", @"hdf" : @"application/x-hdf", + @"heic" : @"image/heic", + @"heif" : @"image/heif", @"hh" : @"text/x-c", @"hlp" : @"application/winhlp", @"hpgl" : @"application/vnd.hp-hpgl", diff --git a/SessionUtilitiesKit/Media/NSData+Image.m b/SessionUtilitiesKit/Media/NSData+Image.m index 9d2747809..5af4610cd 100644 --- a/SessionUtilitiesKit/Media/NSData+Image.m +++ b/SessionUtilitiesKit/Media/NSData+Image.m @@ -2,6 +2,8 @@ #import "MIMETypeUtil.h" #import "OWSFileSystem.h" #import +#import +#import #import NS_ASSUME_NONNULL_BEGIN @@ -13,8 +15,18 @@ typedef NS_ENUM(NSInteger, ImageFormat) { ImageFormat_Tiff, ImageFormat_Jpeg, ImageFormat_Bmp, + ImageFormat_Webp, + ImageFormat_Heic, + ImageFormat_Heif, }; +#pragma mark - + +typedef struct { + CGSize pixelSize; + CGFloat depthBytes; +} ImageDimensionInfo; + // FIXME: Refactor all of these to be in Swift against 'Data' @implementation NSData (Image) @@ -47,40 +59,47 @@ typedef NS_ENUM(NSInteger, ImageFormat) { return YES; } -+ (BOOL)ows_isValidImageAtPath:(NSString *)filePath mimeType:(nullable NSString *)mimeType ++ (nullable NSData *)ows_validImageDataAtPath:(NSString *)filePath mimeType:(nullable NSString *)mimeType { if (mimeType.length < 1) { NSString *fileExtension = [filePath pathExtension].lowercaseString; mimeType = [MIMETypeUtil mimeTypeForFileExtension:fileExtension]; } if (mimeType.length < 1) { - return NO; + return nil; } NSNumber *_Nullable fileSize = [OWSFileSystem fileSizeOfPath:filePath]; if (!fileSize) { - return NO; + return nil; } BOOL isAnimated = [MIMETypeUtil isSupportedAnimatedMIMEType:mimeType]; if (isAnimated) { if (fileSize.unsignedIntegerValue > OWSMediaUtils.kMaxFileSizeAnimatedImage) { - return NO; + return nil; } } else if ([MIMETypeUtil isSupportedImageMIMEType:mimeType]) { if (fileSize.unsignedIntegerValue > OWSMediaUtils.kMaxFileSizeImage) { - return NO; + return nil; } } else { - return NO; + return nil; } NSError *error = nil; - NSData *_Nullable data = [NSData dataWithContentsOfFile:filePath options:NSDataReadingMappedIfSafe error:&error]; - if (!data || error) { + return [NSData dataWithContentsOfFile:filePath options:NSDataReadingMappedIfSafe error:&error]; +} + ++ (BOOL)ows_isValidImageAtPath:(NSString *)filePath mimeType:(nullable NSString *)mimeType +{ + NSData *_Nullable data = [NSData ows_validImageDataAtPath:filePath mimeType:mimeType]; + if (!data) { return NO; } - if (![self ows_hasValidImageDimensionsAtPath:filePath isAnimated:isAnimated]) { + BOOL isAnimated = [MIMETypeUtil isSupportedAnimatedMIMEType:mimeType]; + + if (![self ows_hasValidImageDimensionsAtPath:filePath withData:data mimeType:mimeType isAnimated:isAnimated]) { return NO; } @@ -93,45 +112,98 @@ typedef NS_ENUM(NSInteger, ImageFormat) { if (imageSource == NULL) { return NO; } - BOOL result = [NSData ows_hasValidImageDimensionWithImageSource:imageSource isAnimated:isAnimated]; + + ImageDimensionInfo dimensionInfo = [NSData ows_imageDimensionWithImageSource:imageSource isAnimated:isAnimated]; CFRelease(imageSource); - return result; + + return [NSData ows_isValidImageDimension:dimensionInfo.pixelSize depthBytes:dimensionInfo.depthBytes isAnimated:isAnimated]; } -+ (BOOL)ows_hasValidImageDimensionsAtPath:(NSString *)path isAnimated:(BOOL)isAnimated ++ (BOOL)ows_hasValidImageDimensionsAtPath:(NSString *)path withData:(NSData *)data mimeType:(nullable NSString *)mimeType isAnimated:(BOOL)isAnimated +{ + CGSize imageDimensions = [self ows_imageDimensionsAtPath:path withData:data mimeType:mimeType isAnimated:isAnimated]; + + if (imageDimensions.width < 1 || imageDimensions.height < 1) { + return NO; + } + + return YES; +} + ++ (CGSize)ows_imageDimensionsAtPath:(NSString *)path withData:(nullable NSData *)data mimeType:(nullable NSString *)mimeType isAnimated:(BOOL)isAnimated { NSURL *url = [NSURL fileURLWithPath:path]; if (!url) { - return NO; + return CGSizeZero; + } + + if ([mimeType isEqualToString:OWSMimeTypeImageWebp]) { + NSData *targetData = data; + + if (targetData == nil) { + NSError *error = nil; + NSData *_Nullable loadedData = [NSData dataWithContentsOfFile:path options:NSDataReadingMappedIfSafe error:&error]; + + if (!data || error) { + return CGSizeZero; + } + + targetData = loadedData; + } + + CGSize imageSize = [data sizeForWebpData]; + + if (imageSize.width < 1 || imageSize.height < 1) { + return CGSizeZero; + } + + const CGFloat kExpectedBytePerPixel = 4; + CGFloat kMaxValidImageDimension = OWSMediaUtils.kMaxAnimatedImageDimensions; + CGFloat kMaxBytes = kMaxValidImageDimension * kMaxValidImageDimension * kExpectedBytePerPixel; + + if (data.length > kMaxBytes) { + return CGSizeZero; + } + + return imageSize; } CGImageSourceRef imageSource = CGImageSourceCreateWithURL((__bridge CFURLRef)url, NULL); if (imageSource == NULL) { - return NO; + return CGSizeZero; } - BOOL result = [self ows_hasValidImageDimensionWithImageSource:imageSource isAnimated:isAnimated]; + + ImageDimensionInfo dimensionInfo = [self ows_imageDimensionWithImageSource:imageSource isAnimated:isAnimated]; CFRelease(imageSource); - return result; + + if (![self ows_isValidImageDimension:dimensionInfo.pixelSize depthBytes:dimensionInfo.depthBytes isAnimated:isAnimated]) { + return CGSizeZero; + } + + return dimensionInfo.pixelSize; } -+ (BOOL)ows_hasValidImageDimensionWithImageSource:(CGImageSourceRef)imageSource isAnimated:(BOOL)isAnimated ++ (ImageDimensionInfo)ows_imageDimensionWithImageSource:(CGImageSourceRef)imageSource isAnimated:(BOOL)isAnimated { NSDictionary *imageProperties = (__bridge_transfer NSDictionary *)CGImageSourceCopyPropertiesAtIndex(imageSource, 0, NULL); + ImageDimensionInfo info; + info.pixelSize = CGSizeZero; + info.depthBytes = 0; if (!imageProperties) { - return NO; + return info; } NSNumber *widthNumber = imageProperties[(__bridge NSString *)kCGImagePropertyPixelWidth]; if (!widthNumber) { - return NO; + return info; } CGFloat width = widthNumber.floatValue; NSNumber *heightNumber = imageProperties[(__bridge NSString *)kCGImagePropertyPixelHeight]; if (!heightNumber) { - return NO; + return info; } CGFloat height = heightNumber.floatValue; @@ -139,7 +211,7 @@ typedef NS_ENUM(NSInteger, ImageFormat) { * key is a CFNumberRef. */ NSNumber *depthNumber = imageProperties[(__bridge NSString *)kCGImagePropertyDepth]; if (!depthNumber) { - return NO; + return info; } NSUInteger depthBits = depthNumber.unsignedIntegerValue; // This should usually be 1. @@ -149,13 +221,27 @@ typedef NS_ENUM(NSInteger, ImageFormat) { * The value of this key is CFStringRef. */ NSString *colorModel = imageProperties[(__bridge NSString *)kCGImagePropertyColorModel]; if (!colorModel) { - return NO; + return info; } if (![colorModel isEqualToString:(__bridge NSString *)kCGImagePropertyColorModelRGB] && ![colorModel isEqualToString:(__bridge NSString *)kCGImagePropertyColorModelGray]) { + return info; + } + + // Update the struct to return + info.pixelSize = CGSizeMake(width, height); + info.depthBytes = depthBytes; + + return info; +} + ++ (BOOL)ows_isValidImageDimension:(CGSize)imageSize depthBytes:(CGFloat)depthBytes isAnimated:(BOOL)isAnimated +{ + if (imageSize.width < 1 || imageSize.height < 1 || depthBytes < 1) { + // Invalid metadata. return NO; } - + // We only support (A)RGB and (A)Grayscale, so worst case is 4. const CGFloat kWorseCastComponentsPerPixel = 4; CGFloat bytesPerPixel = kWorseCastComponentsPerPixel * depthBytes; @@ -164,7 +250,7 @@ typedef NS_ENUM(NSInteger, ImageFormat) { CGFloat kMaxValidImageDimension = (isAnimated ? OWSMediaUtils.kMaxAnimatedImageDimensions : OWSMediaUtils.kMaxStillImageDimensions); CGFloat kMaxBytes = kMaxValidImageDimension * kMaxValidImageDimension * kExpectedBytePerPixel; - CGFloat actualBytes = width * height * bytesPerPixel; + CGFloat actualBytes = imageSize.width * imageSize.height * bytesPerPixel; if (actualBytes > kMaxBytes) { return NO; } @@ -205,6 +291,12 @@ typedef NS_ENUM(NSInteger, ImageFormat) { case ImageFormat_Bmp: return (mimeType == nil || [mimeType isEqualToString:OWSMimeTypeImageBmp1] || [mimeType isEqualToString:OWSMimeTypeImageBmp2]); + case ImageFormat_Webp: + return (mimeType == nil || [mimeType isEqualToString:OWSMimeTypeImageWebp]); + case ImageFormat_Heic: + return (mimeType == nil || [mimeType isEqualToString:OWSMimeTypeImageHeic]); + case ImageFormat_Heif: + return (mimeType == nil || [mimeType isEqualToString:OWSMimeTypeImageHeif]); } } @@ -235,9 +327,52 @@ typedef NS_ENUM(NSInteger, ImageFormat) { } else if (byte0 == 0x49 && byte1 == 0x49) { // Intel byte order TIFF return ImageFormat_Tiff; + } else if (byte0 == 0x52 && byte1 == 0x49) { + // First two letters of RIFF tag. + return ImageFormat_Webp; + } + + return [self ows_guessHighEfficiencyImageFormat]; +} + +- (ImageFormat)ows_guessHighEfficiencyImageFormat +{ + // A HEIF image file has the first 16 bytes like + // 0000 0018 6674 7970 6865 6963 0000 0000 + // so in this case the 5th to 12th bytes shall make a string of "ftypheic" + const NSUInteger kHeifHeaderStartsAt = 4; + const NSUInteger kHeifBrandStartsAt = 8; + // We support "heic", "mif1" or "msf1". Other brands are invalid for us for now. + // The length is 4 + 1 because the brand must be terminated with a null. + // Include the null in the comparison to prevent a bogus brand like "heicfake" + // from being considered valid. + const NSUInteger kHeifSupportedBrandLength = 5; + const NSUInteger kTotalHeaderLength = kHeifBrandStartsAt - kHeifHeaderStartsAt + kHeifSupportedBrandLength; + if (self.length < kHeifBrandStartsAt + kHeifSupportedBrandLength) { + return ImageFormat_Unknown; } return ImageFormat_Unknown; + // These are the brands of HEIF formatted files that are renderable by CoreGraphics + const NSString *kHeifBrandHeaderHeic = @"ftypheic\0"; + const NSString *kHeifBrandHeaderHeif = @"ftypmif1\0"; + const NSString *kHeifBrandHeaderHeifStream = @"ftypmsf1\0"; + + // Pull the string from the header and compare it with the supported formats + unsigned char bytes[kTotalHeaderLength]; + [self getBytes:&bytes range:NSMakeRange(kHeifHeaderStartsAt, kTotalHeaderLength)]; + NSData *data = [[NSData alloc] initWithBytes:bytes length:kTotalHeaderLength]; + NSString *marker = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + + if ([kHeifBrandHeaderHeic isEqualToString:marker]) { + return ImageFormat_Heic; + } else if ([kHeifBrandHeaderHeif isEqualToString:marker]) { + return ImageFormat_Heif; + } else if ([kHeifBrandHeaderHeifStream isEqualToString:marker]) { + return ImageFormat_Heif; + } else { + return ImageFormat_Unknown; + } } - (NSString *_Nullable)ows_guessMimeType @@ -304,9 +439,18 @@ typedef NS_ENUM(NSInteger, ImageFormat) { + (CGSize)imageSizeForFilePath:(NSString *)filePath mimeType:(NSString *)mimeType { - if (![NSData ows_isValidImageAtPath:filePath mimeType:mimeType]) { + NSData *_Nullable data = [NSData ows_validImageDataAtPath:filePath mimeType:mimeType]; + if (!data) { return CGSizeZero; } + + BOOL isAnimated = [MIMETypeUtil isSupportedAnimatedMIMEType:mimeType]; + CGSize pixelSize = [NSData ows_imageDimensionsAtPath:filePath withData:data mimeType:mimeType isAnimated:isAnimated]; + + if (pixelSize.width > 0 && pixelSize.height > 0 && [mimeType isEqualToString:OWSMimeTypeImageWebp]) { + return pixelSize; + } + NSURL *url = [NSURL fileURLWithPath:filePath]; // With CGImageSource we avoid loading the whole image into memory. @@ -386,6 +530,42 @@ typedef NS_ENUM(NSInteger, ImageFormat) { return result; } +// MARK: - Webp + ++ (CGSize)sizeForWebpFilePath:(NSString *)filePath +{ + NSError *error = nil; + NSData *_Nullable data = [NSData dataWithContentsOfFile:filePath options:NSDataReadingMappedIfSafe error:&error]; + if (!data || error) { + return CGSizeZero; + } + return [data sizeForWebpData]; +} + +- (CGSize)sizeForWebpData +{ + WebPData webPData = { 0 }; + webPData.bytes = self.bytes; + webPData.size = self.length; + WebPDemuxer *demuxer = WebPDemux(&webPData); + + if (!demuxer) { + return CGSizeZero; + } + + CGFloat canvasWidth = WebPDemuxGetI(demuxer, WEBP_FF_CANVAS_WIDTH); + CGFloat canvasHeight = WebPDemuxGetI(demuxer, WEBP_FF_CANVAS_HEIGHT); + CGFloat frameCount = WebPDemuxGetI(demuxer, WEBP_FF_FRAME_COUNT); + + WebPDemuxDelete(demuxer); + + if (canvasWidth > 0 && canvasHeight > 0 && frameCount > 0) { + return CGSizeMake(canvasWidth, canvasHeight); + } + + return CGSizeZero; +} + @end NS_ASSUME_NONNULL_END From 775cc4f156cac2af483bf51650eb2d00ba92a6d0 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 29 Jul 2022 15:27:53 +1000 Subject: [PATCH 146/157] Increased build number --- Session.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 985a383ac..ad26299c1 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -6814,7 +6814,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 360; + CURRENT_PROJECT_VERSION = 361; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -6886,7 +6886,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 360; + CURRENT_PROJECT_VERSION = 361; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", From fde19efc1386a5baccd990b659049c366b6aee84 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 29 Jul 2022 15:29:20 +1000 Subject: [PATCH 147/157] Fixed a duplicate dependency issue --- Podfile | 2 -- Podfile.lock | 5 +---- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/Podfile b/Podfile index a8b57ea29..ae568f035 100644 --- a/Podfile +++ b/Podfile @@ -23,7 +23,6 @@ abstract_target 'GlobalDependencies' do pod 'Reachability' pod 'PureLayout', '~> 3.1.8' pod 'NVActivityIndicatorView' - pod 'YYImage', git: 'https://github.com/signalapp/YYImage' pod 'YYImage/libwebp', git: 'https://github.com/signalapp/YYImage' pod 'ZXingObjC' pod 'DifferenceKit' @@ -52,7 +51,6 @@ abstract_target 'GlobalDependencies' do pod 'Reachability' pod 'SAMKeychain' pod 'SwiftProtobuf', '~> 1.5.0' - pod 'YYImage', git: 'https://github.com/signalapp/YYImage' pod 'YYImage/libwebp', git: 'https://github.com/signalapp/YYImage' pod 'DifferenceKit' end diff --git a/Podfile.lock b/Podfile.lock index 58c3a3735..89c8650fc 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -130,8 +130,6 @@ PODS: - YapDatabase/SQLCipher/Core - YapDatabase/SQLCipher/Extensions/View (3.1.1): - YapDatabase/SQLCipher/Core - - YYImage (1.0.4): - - YYImage/Core (= 1.0.4) - YYImage/Core (1.0.4) - YYImage/libwebp (1.0.4): - libwebp @@ -160,7 +158,6 @@ DEPENDENCIES: - SwiftProtobuf (~> 1.5.0) - WebRTC-lib - YapDatabase/SQLCipher (from `https://github.com/oxen-io/session-ios-yap-database.git`, branch `signal-release`) - - YYImage (from `https://github.com/signalapp/YYImage`) - YYImage/libwebp (from `https://github.com/signalapp/YYImage`) - ZXingObjC @@ -245,6 +242,6 @@ SPEC CHECKSUMS: YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331 ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: 456facc7043447a9c67733cf8846ec62afff8ea8 +PODFILE CHECKSUM: f0857369c4831b2e5c1946345e76e493f3286805 COCOAPODS: 1.11.3 From b468efc33bd075414269ff32858f0ee720791a1b Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 1 Aug 2022 10:05:30 +1000 Subject: [PATCH 148/157] Updated the GarbageCollectionJob to log the number of files it removes --- SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift index 21abafe58..0777de7a2 100644 --- a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift +++ b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift @@ -372,6 +372,8 @@ public enum GarbageCollectionJob: JobExecutor { } catch { deletionErrors.append(error) } } + + SNLog("[GarbageCollectionJob] Removed \(orphanedAttachmentFiles.count) orphaned attachment\(orphanedAttachmentFiles.count == 1 ? "" : "s")") } // Orphaned profile avatar files (actual deletion) @@ -393,6 +395,8 @@ public enum GarbageCollectionJob: JobExecutor { } catch { deletionErrors.append(error) } } + + SNLog("[GarbageCollectionJob] Removed \(orphanedAvatarFiles.count) orphaned avatar image\(orphanedAvatarFiles.count == 1 ? "" : "s")") } // Report a single file deletion as a job failure (even if other content was successfully removed) From 8f3e7fc36ae420477289a4066ee3616ac33544c0 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 1 Aug 2022 16:53:05 +1000 Subject: [PATCH 149/157] Removed the debug code and added a migration to remove the old YDB Fixed a typo --- Session.xcodeproj/project.pbxproj | 4 + Session/Meta/AppDelegate.swift | 76 ++++++++----------- .../Translations/de.lproj/Localizable.strings | 2 +- .../Translations/en.lproj/Localizable.strings | 2 +- .../Translations/es.lproj/Localizable.strings | 2 +- .../Translations/fa.lproj/Localizable.strings | 2 +- .../Translations/fi.lproj/Localizable.strings | 2 +- .../Translations/fr.lproj/Localizable.strings | 2 +- .../Translations/hi.lproj/Localizable.strings | 2 +- .../Translations/hr.lproj/Localizable.strings | 2 +- .../id-ID.lproj/Localizable.strings | 2 +- .../Translations/it.lproj/Localizable.strings | 2 +- .../Translations/ja.lproj/Localizable.strings | 2 +- .../Translations/nl.lproj/Localizable.strings | 2 +- .../Translations/pl.lproj/Localizable.strings | 2 +- .../pt_BR.lproj/Localizable.strings | 2 +- .../Translations/ru.lproj/Localizable.strings | 2 +- .../Translations/si.lproj/Localizable.strings | 2 +- .../Translations/sk.lproj/Localizable.strings | 2 +- .../Translations/sv.lproj/Localizable.strings | 2 +- .../Translations/th.lproj/Localizable.strings | 2 +- .../vi-VN.lproj/Localizable.strings | 2 +- .../zh-Hant.lproj/Localizable.strings | 2 +- .../zh_CN.lproj/Localizable.strings | 2 +- Session/Path/PathVC.swift | 1 + Session/Settings/SettingsVC.swift | 20 ----- SessionMessagingKit/Configuration.swift | 3 + .../Migrations/_004_RemoveLegacyYDB.swift | 20 +++++ .../Jobs/Types/DisappearingMessagesJob.swift | 34 --------- .../Notifications/PushNotificationAPI.swift | 4 +- SessionUtilitiesKit/Database/Storage.swift | 30 +------- 31 files changed, 86 insertions(+), 150 deletions(-) create mode 100644 SessionMessagingKit/Database/Migrations/_004_RemoveLegacyYDB.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index ad26299c1..f6719afe2 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -800,6 +800,7 @@ FDF2220B2818F38D000A4995 /* SessionApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF2220A2818F38D000A4995 /* SessionApp.swift */; }; FDF2220F281B55E6000A4995 /* QueryInterfaceRequest+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF2220E281B55E6000A4995 /* QueryInterfaceRequest+Utilities.swift */; }; FDF22211281B5E0B000A4995 /* TableRecord+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF22210281B5E0B000A4995 /* TableRecord+Utilities.swift */; }; + FDF40CDE2897A1BC006A0CC4 /* _004_RemoveLegacyYDB.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF40CDD2897A1BC006A0CC4 /* _004_RemoveLegacyYDB.swift */; }; FDFD645927F26C6800808CA1 /* Array+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D12553860800C340D1 /* Array+Utilities.swift */; }; FDFD645B27F26D4600808CA1 /* Data+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFD645A27F26D4600808CA1 /* Data+Utilities.swift */; }; FDFD645D27F273F300808CA1 /* MockGeneralCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */; }; @@ -1837,6 +1838,7 @@ FDF2220A2818F38D000A4995 /* SessionApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionApp.swift; sourceTree = ""; }; FDF2220E281B55E6000A4995 /* QueryInterfaceRequest+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "QueryInterfaceRequest+Utilities.swift"; sourceTree = ""; }; FDF22210281B5E0B000A4995 /* TableRecord+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TableRecord+Utilities.swift"; sourceTree = ""; }; + FDF40CDD2897A1BC006A0CC4 /* _004_RemoveLegacyYDB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _004_RemoveLegacyYDB.swift; sourceTree = ""; }; FDFD645A27F26D4600808CA1 /* Data+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Data+Utilities.swift"; sourceTree = ""; }; FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockGeneralCache.swift; sourceTree = ""; }; FDFDE123282D04F20098B17F /* MediaDismissAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaDismissAnimationController.swift; sourceTree = ""; }; @@ -3451,6 +3453,7 @@ FD17D79527F3E04600122BE0 /* _001_InitialSetupMigration.swift */, FDF0B7412804EA4F004C14C5 /* _002_SetupStandardJobs.swift */, FD17D79827F40AB800122BE0 /* _003_YDBToGRDBMigration.swift */, + FDF40CDD2897A1BC006A0CC4 /* _004_RemoveLegacyYDB.swift */, ); path = Migrations; sourceTree = ""; @@ -5089,6 +5092,7 @@ FD09799927FFC1A300936362 /* Attachment.swift in Sources */, FD245C5F2850662200B966DD /* OWSWindowManager.m in Sources */, C3471ECB2555356A00297E91 /* MessageSender+Encryption.swift in Sources */, + FDF40CDE2897A1BC006A0CC4 /* _004_RemoveLegacyYDB.swift in Sources */, FDF0B74928060D13004C14C5 /* QuotedReplyModel.swift in Sources */, FD77289A284AF1BD0018502F /* Sodium+Utilities.swift in Sources */, FD5C7309285007920029977D /* BlindedIdLookup.swift in Sources */, diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 7067ea3db..41b106584 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -240,54 +240,40 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD private func showFailedMigrationAlert(error: Error?) { let alert = UIAlertController( title: "Session", - message: ((error as? StorageError) == StorageError.devRemigrationRequired ? - "The database has changed since the last version and you need to re-migrate (this will close the app and migrate on the next launch)" : - "DATABASE_MIGRATION_FAILED".localized() - ), + message: "DATABASE_MIGRATION_FAILED".localized(), preferredStyle: .alert ) - - switch (error as? StorageError) { - case .devRemigrationRequired: - alert.addAction(UIAlertAction(title: "Re-Migrate Database", style: .default) { _ in - Storage.deleteDatabaseFiles() - try? Storage.deleteDbKeys() - exit(1) - }) - - default: - alert.addAction(UIAlertAction(title: "modal_share_logs_title".localized(), style: .default) { _ in - ShareLogsModal.shareLogs(from: alert) { [weak self] in - self?.showFailedMigrationAlert(error: error) - } - }) - alert.addAction(UIAlertAction(title: "vc_restore_title".localized(), style: .destructive) { _ in - // Remove the legacy database and any message hashes that have been migrated to the new DB - try? SUKLegacy.deleteLegacyDatabaseFilesAndKey() - - Storage.shared.write { db in - try SnodeReceivedMessageInfo.deleteAll(db) - } - - // The re-run the migration (should succeed since there is no data) - AppSetup.runPostSetupMigrations( - migrationProgressChanged: { [weak self] progress, minEstimatedTotalTime in - self?.loadingViewController?.updateProgress( - progress: progress, - minEstimatedTotalTime: minEstimatedTotalTime - ) - }, - migrationsCompletion: { [weak self] error, needsConfigSync in - guard error == nil else { - self?.showFailedMigrationAlert(error: error) - return - } - - self?.completePostMigrationSetup(needsConfigSync: needsConfigSync) - } + alert.addAction(UIAlertAction(title: "modal_share_logs_title".localized(), style: .default) { _ in + ShareLogsModal.shareLogs(from: alert) { [weak self] in + self?.showFailedMigrationAlert(error: error) + } + }) + alert.addAction(UIAlertAction(title: "vc_restore_title".localized(), style: .destructive) { _ in + // Remove the legacy database and any message hashes that have been migrated to the new DB + try? SUKLegacy.deleteLegacyDatabaseFilesAndKey() + + Storage.shared.write { db in + try SnodeReceivedMessageInfo.deleteAll(db) + } + + // The re-run the migration (should succeed since there is no data) + AppSetup.runPostSetupMigrations( + migrationProgressChanged: { [weak self] progress, minEstimatedTotalTime in + self?.loadingViewController?.updateProgress( + progress: progress, + minEstimatedTotalTime: minEstimatedTotalTime ) - }) - } + }, + migrationsCompletion: { [weak self] error, needsConfigSync in + guard error == nil else { + self?.showFailedMigrationAlert(error: error) + return + } + + self?.completePostMigrationSetup(needsConfigSync: needsConfigSync) + } + ) + }) alert.addAction(UIAlertAction(title: "Close", style: .default) { _ in DDLog.flushLog() diff --git a/Session/Meta/Translations/de.lproj/Localizable.strings b/Session/Meta/Translations/de.lproj/Localizable.strings index 60196d5df..d12868e54 100644 --- a/Session/Meta/Translations/de.lproj/Localizable.strings +++ b/Session/Meta/Translations/de.lproj/Localizable.strings @@ -652,7 +652,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; diff --git a/Session/Meta/Translations/en.lproj/Localizable.strings b/Session/Meta/Translations/en.lproj/Localizable.strings index 70292f244..6dd37a8bb 100644 --- a/Session/Meta/Translations/en.lproj/Localizable.strings +++ b/Session/Meta/Translations/en.lproj/Localizable.strings @@ -652,7 +652,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; diff --git a/Session/Meta/Translations/es.lproj/Localizable.strings b/Session/Meta/Translations/es.lproj/Localizable.strings index eda8d36e6..e5d4f9d44 100644 --- a/Session/Meta/Translations/es.lproj/Localizable.strings +++ b/Session/Meta/Translations/es.lproj/Localizable.strings @@ -652,7 +652,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; diff --git a/Session/Meta/Translations/fa.lproj/Localizable.strings b/Session/Meta/Translations/fa.lproj/Localizable.strings index 1177dd54e..f333f1b70 100644 --- a/Session/Meta/Translations/fa.lproj/Localizable.strings +++ b/Session/Meta/Translations/fa.lproj/Localizable.strings @@ -652,7 +652,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; diff --git a/Session/Meta/Translations/fi.lproj/Localizable.strings b/Session/Meta/Translations/fi.lproj/Localizable.strings index 0ccbf8630..b9e9b8267 100644 --- a/Session/Meta/Translations/fi.lproj/Localizable.strings +++ b/Session/Meta/Translations/fi.lproj/Localizable.strings @@ -652,7 +652,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; diff --git a/Session/Meta/Translations/fr.lproj/Localizable.strings b/Session/Meta/Translations/fr.lproj/Localizable.strings index 71bba7d52..f99011932 100644 --- a/Session/Meta/Translations/fr.lproj/Localizable.strings +++ b/Session/Meta/Translations/fr.lproj/Localizable.strings @@ -652,7 +652,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; diff --git a/Session/Meta/Translations/hi.lproj/Localizable.strings b/Session/Meta/Translations/hi.lproj/Localizable.strings index e74664705..eca955776 100644 --- a/Session/Meta/Translations/hi.lproj/Localizable.strings +++ b/Session/Meta/Translations/hi.lproj/Localizable.strings @@ -652,7 +652,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; diff --git a/Session/Meta/Translations/hr.lproj/Localizable.strings b/Session/Meta/Translations/hr.lproj/Localizable.strings index f8a01cf6a..bb4556500 100644 --- a/Session/Meta/Translations/hr.lproj/Localizable.strings +++ b/Session/Meta/Translations/hr.lproj/Localizable.strings @@ -652,7 +652,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; diff --git a/Session/Meta/Translations/id-ID.lproj/Localizable.strings b/Session/Meta/Translations/id-ID.lproj/Localizable.strings index 65e9f6828..6322bf9f2 100644 --- a/Session/Meta/Translations/id-ID.lproj/Localizable.strings +++ b/Session/Meta/Translations/id-ID.lproj/Localizable.strings @@ -652,7 +652,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; diff --git a/Session/Meta/Translations/it.lproj/Localizable.strings b/Session/Meta/Translations/it.lproj/Localizable.strings index 6ca09139f..eedf2137b 100644 --- a/Session/Meta/Translations/it.lproj/Localizable.strings +++ b/Session/Meta/Translations/it.lproj/Localizable.strings @@ -652,7 +652,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; diff --git a/Session/Meta/Translations/ja.lproj/Localizable.strings b/Session/Meta/Translations/ja.lproj/Localizable.strings index 7467457c3..253f72de8 100644 --- a/Session/Meta/Translations/ja.lproj/Localizable.strings +++ b/Session/Meta/Translations/ja.lproj/Localizable.strings @@ -652,7 +652,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; diff --git a/Session/Meta/Translations/nl.lproj/Localizable.strings b/Session/Meta/Translations/nl.lproj/Localizable.strings index 41354fa3c..d58ebff00 100644 --- a/Session/Meta/Translations/nl.lproj/Localizable.strings +++ b/Session/Meta/Translations/nl.lproj/Localizable.strings @@ -652,7 +652,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; diff --git a/Session/Meta/Translations/pl.lproj/Localizable.strings b/Session/Meta/Translations/pl.lproj/Localizable.strings index b480bbe12..5a08f7e35 100644 --- a/Session/Meta/Translations/pl.lproj/Localizable.strings +++ b/Session/Meta/Translations/pl.lproj/Localizable.strings @@ -652,7 +652,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; diff --git a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings index d785a31be..a848b7587 100644 --- a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings +++ b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings @@ -652,7 +652,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; diff --git a/Session/Meta/Translations/ru.lproj/Localizable.strings b/Session/Meta/Translations/ru.lproj/Localizable.strings index 07a7951f2..a3984387e 100644 --- a/Session/Meta/Translations/ru.lproj/Localizable.strings +++ b/Session/Meta/Translations/ru.lproj/Localizable.strings @@ -652,7 +652,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; diff --git a/Session/Meta/Translations/si.lproj/Localizable.strings b/Session/Meta/Translations/si.lproj/Localizable.strings index 6c6d14467..cacf3f5d1 100644 --- a/Session/Meta/Translations/si.lproj/Localizable.strings +++ b/Session/Meta/Translations/si.lproj/Localizable.strings @@ -652,7 +652,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; diff --git a/Session/Meta/Translations/sk.lproj/Localizable.strings b/Session/Meta/Translations/sk.lproj/Localizable.strings index 76e709953..102514ede 100644 --- a/Session/Meta/Translations/sk.lproj/Localizable.strings +++ b/Session/Meta/Translations/sk.lproj/Localizable.strings @@ -652,7 +652,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; diff --git a/Session/Meta/Translations/sv.lproj/Localizable.strings b/Session/Meta/Translations/sv.lproj/Localizable.strings index eb846d574..da8933ad7 100644 --- a/Session/Meta/Translations/sv.lproj/Localizable.strings +++ b/Session/Meta/Translations/sv.lproj/Localizable.strings @@ -652,7 +652,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; diff --git a/Session/Meta/Translations/th.lproj/Localizable.strings b/Session/Meta/Translations/th.lproj/Localizable.strings index bbc550448..b8f6e41ac 100644 --- a/Session/Meta/Translations/th.lproj/Localizable.strings +++ b/Session/Meta/Translations/th.lproj/Localizable.strings @@ -652,7 +652,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; diff --git a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings index f7be858a2..fd93b1f63 100644 --- a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings +++ b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings @@ -652,7 +652,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; diff --git a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings index ecf86aca8..f936369d4 100644 --- a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings @@ -652,7 +652,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; diff --git a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings index 44a6ea8d4..9145c3fe8 100644 --- a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings @@ -652,7 +652,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore our device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "CHATS_TITLE" = "Chats"; "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; diff --git a/Session/Path/PathVC.swift b/Session/Path/PathVC.swift index 14d09dfa2..b3fdb9b37 100644 --- a/Session/Path/PathVC.swift +++ b/Session/Path/PathVC.swift @@ -1,5 +1,6 @@ import NVActivityIndicatorView import UIKit +import SessionMessagingKit final class PathVC : BaseVC { diff --git a/Session/Settings/SettingsVC.swift b/Session/Settings/SettingsVC.swift index a58081392..27d4ecec0 100644 --- a/Session/Settings/SettingsVC.swift +++ b/Session/Settings/SettingsVC.swift @@ -298,8 +298,6 @@ final class SettingsVC: BaseVC, AvatarViewHelperDelegate { return button } - let debugReMigrateButton = getSettingButton(withTitle: "DEBUG - Re-Migrate Database", color: Colors.destructive, action: #selector(remigrateDatabase)) - let pathButton = getSettingButton(withTitle: NSLocalizedString("vc_path_title", comment: ""), color: Colors.text, action: #selector(showPath)) let pathStatusView = PathStatusView() pathStatusView.set(.width, to: PathStatusView.size) @@ -310,8 +308,6 @@ final class SettingsVC: BaseVC, AvatarViewHelperDelegate { pathStatusView.autoVCenterInSuperview() return [ - getSeparator(), - debugReMigrateButton, getSeparator(), pathButton, getSeparator(), @@ -603,22 +599,6 @@ final class SettingsVC: BaseVC, AvatarViewHelperDelegate { navigationController!.present(shareVC, animated: true, completion: nil) } - @objc private func remigrateDatabase() { - let alert = UIAlertController( - title: "Session", - message: "Are you sure you want to re-migrate from your old database state?\n\nWarning: If you had a migration error and picked the \"Restore your account\" option this will result in a complete loss of data and the need to manually restore from the seed", - preferredStyle: .alert - ) - alert.addAction(UIAlertAction(title: "Re-migrate", style: .destructive) { _ in - Storage.deleteDatabaseFiles() - try? Storage.deleteDbKeys() - exit(1) - }) - alert.addAction(UIAlertAction(title: "Cancel", style: .default)) - - navigationController?.present(alert, animated: true) - } - @objc private func showPath() { let pathVC = PathVC() navigationController!.pushViewController(pathVC, animated: true) diff --git a/SessionMessagingKit/Configuration.swift b/SessionMessagingKit/Configuration.swift index bdc23dc0d..22a726c2e 100644 --- a/SessionMessagingKit/Configuration.swift +++ b/SessionMessagingKit/Configuration.swift @@ -12,6 +12,9 @@ public enum SNMessagingKit { // Just to make the external API nice ], [ _003_YDBToGRDBMigration.self + ], + [ + _004_RemoveLegacyYDB.self ] ] ) diff --git a/SessionMessagingKit/Database/Migrations/_004_RemoveLegacyYDB.swift b/SessionMessagingKit/Database/Migrations/_004_RemoveLegacyYDB.swift new file mode 100644 index 000000000..97aa7462e --- /dev/null +++ b/SessionMessagingKit/Database/Migrations/_004_RemoveLegacyYDB.swift @@ -0,0 +1,20 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import Curve25519Kit +import SessionUtilitiesKit +import SessionSnodeKit + +/// This migration removes the legacy YapDatabase files +enum _004_RemoveLegacyYDB: Migration { + static let target: TargetMigrations.Identifier = .messagingKit + static let identifier: String = "RemoveLegacyYDB" + static let needsConfigSync: Bool = false + static let minExpectedRunDuration: TimeInterval = 0.1 + + static func migrate(_ db: Database) throws { + try? SUKLegacy.deleteLegacyDatabaseFilesAndKey() + Storage.update(progress: 1, for: self, in: target) // In case this is the last migration + } +} diff --git a/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift b/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift index 4bad27849..9ed31c7e7 100644 --- a/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift +++ b/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift @@ -41,40 +41,6 @@ public enum DisappearingMessagesJob: JobExecutor { // The 'if' is only there to prevent the "variable never read" warning from showing if backgroundTask != nil { backgroundTask = nil } - - // TODO: Remove this for the final build - Storage.shared.writeAsync { db in - // Re-process all WebP images, and images with no width/height values to update their validity state - let supportedVisualMediaMimeTypes: Set = MIMETypeUtil.supportedImageMIMETypes() - .appending(contentsOf: MIMETypeUtil.supportedAnimatedImageMIMETypes()) - .appending(contentsOf: MIMETypeUtil.supportedVideoMIMETypes()) - .asSet() - let attachments: [Attachment] = try Attachment - .filter(Attachment.Columns.state == Attachment.State.downloaded) - .filter( - Attachment.Columns.contentType == "image/webp" || ( - ( - Attachment.Columns.width == nil || - Attachment.Columns.height == nil - ) && - supportedVisualMediaMimeTypes.contains(Attachment.Columns.contentType) - ) - ) - .filter( - !Attachment.Columns.isValid || - !Attachment.Columns.isVisualMedia || - Attachment.Columns.width == nil || - Attachment.Columns.height == nil - ) - .fetchAll(db) - - if !attachments.isEmpty { - attachments.forEach { attachment in - _ = try? attachment.with(state: attachment.state).saved(db) - } - } - } - // TODO: Remove this for the final build } } diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift index 3ad5dbcc9..a63af0b54 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift @@ -128,8 +128,8 @@ public final class PushNotificationAPI : NSObject { promises.append( attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) { OnionRequestAPI.sendOnionRequest(request, to: server, using: .v2, with: serverPublicKey) - .map2 { _, response -> Void in - guard let response: UpdateRegistrationResponse = try? response?.decoded(as: UpdateRegistrationResponse.self) else { + .map2 { _, data -> Void in + guard let response: UpdateRegistrationResponse = try? data?.decoded(as: UpdateRegistrationResponse.self) else { return SNLog("Couldn't register device token.") } guard response.body.code != 0 else { diff --git a/SessionUtilitiesKit/Database/Storage.swift b/SessionUtilitiesKit/Database/Storage.swift index 92ba77ec0..87e283918 100644 --- a/SessionUtilitiesKit/Database/Storage.swift +++ b/SessionUtilitiesKit/Database/Storage.swift @@ -186,30 +186,7 @@ public final class Storage { SNLog("[Migration Error] Migration failed with error: \(error)") } - // TODO: Remove this once everyone has updated - var finalError: Error? = error - let jobTableInfo: [Row] = (try? Row.fetchAll(db, sql: "PRAGMA table_info(\(Job.databaseTableName))")) - .defaulting(to: []) - if !jobTableInfo.contains(where: { $0["name"] == "shouldSkipLaunchBecomeActive" }) { - finalError = StorageError.devRemigrationRequired - } - // Forcibly change any 'infoUpdates' on open groups from '-1' to '0' (-1 is invalid) - try? db.execute(literal: """ - UPDATE openGroup - SET infoUpdates = 0 - WHERE openGroup.infoUpdates = -1 - """) - // TODO: Remove this once everyone has updated - let openGroupTableInfo: [Row] = (try? Row.fetchAll(db, sql: "PRAGMA table_info(openGroup)")) - .defaulting(to: []) - if !openGroupTableInfo.contains(where: { $0["name"] == "pollFailureCount" }) { - try? db.execute(literal: """ - ALTER TABLE openGroup - ADD pollFailureCount INTEGER NOT NULL DEFAULT 0 - """) - } - - onComplete(finalError, needsConfigSync) + onComplete(error, needsConfigSync) } // Note: The non-async migration should only be used for unit tests @@ -314,14 +291,13 @@ public final class Storage { try? self.deleteDbKeys() } - // TODO: Change these back to private - public/*private*/ static func deleteDatabaseFiles() { + private static func deleteDatabaseFiles() { OWSFileSystem.deleteFile(databasePath) OWSFileSystem.deleteFile(databasePathShm) OWSFileSystem.deleteFile(databasePathWal) } - public/*private*/ static func deleteDbKeys() throws { + private static func deleteDbKeys() throws { try SSKDefaultKeychainStorage.shared.remove(service: keychainService, key: dbCipherKeySpecKey) } From 5f1039b39e43c6da61dd928e87210beefb86584f Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 3 Aug 2022 09:10:20 +1000 Subject: [PATCH 150/157] Updated the PushNotificationAPI to use V4 onion requests instead of V2 Fixed a crash which could occur when handling 'loadMedia' edge cases in the MediaView (was running on non-main thread) --- Session.xcodeproj/project.pbxproj | 8 ++-- .../Content Views/MediaView.swift | 16 ++++++-- .../Models/PushServerResponse.swift | 10 +++++ .../Models/UpdateRegistrationResponse.swift | 15 ------- .../Notifications/PushNotificationAPI.swift | 39 +++++++++++-------- 5 files changed, 49 insertions(+), 39 deletions(-) create mode 100644 SessionMessagingKit/Sending & Receiving/Notifications/Models/PushServerResponse.swift delete mode 100644 SessionMessagingKit/Sending & Receiving/Notifications/Models/UpdateRegistrationResponse.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index f6719afe2..5f9e30244 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -741,7 +741,7 @@ FDC4381527B329CE00C60D73 /* NonceGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381427B329CE00C60D73 /* NonceGenerator.swift */; }; FDC4381727B32EC700C60D73 /* Personalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381627B32EC700C60D73 /* Personalization.swift */; }; FDC4382027B36ADC00C60D73 /* SOGSEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381F27B36ADC00C60D73 /* SOGSEndpoint.swift */; }; - FDC4382F27B383AF00C60D73 /* UpdateRegistrationResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382E27B383AF00C60D73 /* UpdateRegistrationResponse.swift */; }; + FDC4382F27B383AF00C60D73 /* PushServerResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382E27B383AF00C60D73 /* PushServerResponse.swift */; }; FDC4383827B3863200C60D73 /* VersionResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4383727B3863200C60D73 /* VersionResponse.swift */; }; FDC4384F27B4804F00C60D73 /* Header.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4384E27B4804F00C60D73 /* Header.swift */; }; FDC4385127B4807400C60D73 /* QueryParam.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4385027B4807400C60D73 /* QueryParam.swift */; }; @@ -1775,7 +1775,7 @@ FDC4381427B329CE00C60D73 /* NonceGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonceGenerator.swift; sourceTree = ""; }; FDC4381627B32EC700C60D73 /* Personalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Personalization.swift; sourceTree = ""; }; FDC4381F27B36ADC00C60D73 /* SOGSEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSEndpoint.swift; sourceTree = ""; }; - FDC4382E27B383AF00C60D73 /* UpdateRegistrationResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateRegistrationResponse.swift; sourceTree = ""; }; + FDC4382E27B383AF00C60D73 /* PushServerResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushServerResponse.swift; sourceTree = ""; }; FDC4383727B3863200C60D73 /* VersionResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionResponse.swift; sourceTree = ""; }; FDC4383D27B4708600C60D73 /* Atomic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = ""; }; FDC4384E27B4804F00C60D73 /* Header.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Header.swift; sourceTree = ""; }; @@ -3761,7 +3761,7 @@ FDC4382D27B383A600C60D73 /* Models */ = { isa = PBXGroup; children = ( - FDC4382E27B383AF00C60D73 /* UpdateRegistrationResponse.swift */, + FDC4382E27B383AF00C60D73 /* PushServerResponse.swift */, ); path = Models; sourceTree = ""; @@ -5165,7 +5165,7 @@ FD6A7A692818BE7300035AC1 /* RetrieveDefaultOpenGroupRoomsJob.swift in Sources */, FD09798D27FD1D8900936362 /* DisappearingMessageConfiguration.swift in Sources */, FDF0B75A2807F3A3004C14C5 /* MessageSenderError.swift in Sources */, - FDC4382F27B383AF00C60D73 /* UpdateRegistrationResponse.swift in Sources */, + FDC4382F27B383AF00C60D73 /* PushServerResponse.swift in Sources */, FDC4386327B4D94E00C60D73 /* SOGSMessage.swift in Sources */, FD245C692850666800B966DD /* ExpirationTimerUpdate.swift in Sources */, FD42F9A8285064B800A0C77D /* PushNotificationAPI.swift in Sources */, diff --git a/Session/Conversations/Message Cells/Content Views/MediaView.swift b/Session/Conversations/Message Cells/Content Views/MediaView.swift index 77f2183c1..255d65690 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaView.swift @@ -158,14 +158,12 @@ public class MediaView: UIView { loadBlock = { [weak self] in AssertIsOnMainThread() - - guard let strongSelf = self else { return } - + if animatedImageView.image != nil { owsFailDebug("Unexpectedly already loaded.") return } - strongSelf.tryToLoadMedia( + self?.tryToLoadMedia( loadMediaBlock: { applyMediaBlock in guard attachment.isValid else { self?.configure(forError: .invalid) @@ -328,6 +326,16 @@ public class MediaView: UIView { } private func configure(forError error: MediaError) { + // When there is a failure in the 'loadMediaBlock' closure this can be called + // on a background thread - rather than dispatching in every 'loadMediaBlock' + // usage we just do so here + guard Thread.isMainThread else { + DispatchQueue.main.async { + self.configure(forError: error) + } + return + } + let icon: UIImage switch error { diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/PushServerResponse.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/PushServerResponse.swift new file mode 100644 index 000000000..eee22e266 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/PushServerResponse.swift @@ -0,0 +1,10 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension PushNotificationAPI { + struct PushServerResponse: Codable { + let code: Int + let message: String? + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/UpdateRegistrationResponse.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/UpdateRegistrationResponse.swift deleted file mode 100644 index aaf4ff484..000000000 --- a/SessionMessagingKit/Sending & Receiving/Notifications/Models/UpdateRegistrationResponse.swift +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -extension PushNotificationAPI { - struct UpdateRegistrationResponse: Codable { - struct Body: Codable { - let code: Int - let message: String? - } - - let status: Int - let body: Body - } -} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift index a63af0b54..11499c28f 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift @@ -65,13 +65,13 @@ public final class PushNotificationAPI : NSObject { request.httpBody = body let promise: Promise = attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) { - OnionRequestAPI.sendOnionRequest(request, to: server, using: .v2, with: serverPublicKey) - .map2 { _, response in - guard let response: UpdateRegistrationResponse = try? response?.decoded(as: UpdateRegistrationResponse.self) else { + OnionRequestAPI.sendOnionRequest(request, to: server, with: serverPublicKey) + .map2 { _, data in + guard let response: PushServerResponse = try? data?.decoded(as: PushServerResponse.self) else { return SNLog("Couldn't unregister from push notifications.") } - guard response.body.code != 0 else { - return SNLog("Couldn't unregister from push notifications due to error: \(response.body.message ?? "nil").") + guard response.code != 0 else { + return SNLog("Couldn't unregister from push notifications due to error: \(response.message ?? "nil").") } } } @@ -127,13 +127,13 @@ public final class PushNotificationAPI : NSObject { promises.append( attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) { - OnionRequestAPI.sendOnionRequest(request, to: server, using: .v2, with: serverPublicKey) + OnionRequestAPI.sendOnionRequest(request, to: server, with: serverPublicKey) .map2 { _, data -> Void in - guard let response: UpdateRegistrationResponse = try? data?.decoded(as: UpdateRegistrationResponse.self) else { + guard let response: PushServerResponse = try? data?.decoded(as: PushServerResponse.self) else { return SNLog("Couldn't register device token.") } - guard response.body.code != 0 else { - return SNLog("Couldn't register device token due to error: \(response.body.message ?? "nil").") + guard response.code != 0 else { + return SNLog("Couldn't register device token due to error: \(response.message ?? "nil").") } userDefaults[.deviceToken] = hexEncodedToken @@ -193,13 +193,13 @@ public final class PushNotificationAPI : NSObject { request.httpBody = body let promise: Promise = attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) { - OnionRequestAPI.sendOnionRequest(request, to: server, using: .v2, with: serverPublicKey) - .map2 { _, response in - guard let response: UpdateRegistrationResponse = try? response?.decoded(as: UpdateRegistrationResponse.self) else { + OnionRequestAPI.sendOnionRequest(request, to: server, with: serverPublicKey) + .map2 { _, data in + guard let response: PushServerResponse = try? data?.decoded(as: PushServerResponse.self) else { return SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey).") } - guard response.body.code != 0 else { - return SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey) due to error: \(response.body.message ?? "nil").") + guard response.code != 0 else { + return SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey) due to error: \(response.message ?? "nil").") } } } @@ -236,8 +236,15 @@ public final class PushNotificationAPI : NSObject { let retryCount: UInt = (maxRetryCount ?? PushNotificationAPI.maxRetryCount) let promise: Promise = attempt(maxRetryCount: retryCount, recoveringOn: queue) { - OnionRequestAPI.sendOnionRequest(request, to: server, using: .v2, with: serverPublicKey) - .map { _ in } + OnionRequestAPI.sendOnionRequest(request, to: server, with: serverPublicKey) + .map2 { _, data in + guard let response: PushServerResponse = try? data?.decoded(as: PushServerResponse.self) else { + return SNLog("Couldn't send push notification.") + } + guard response.code != 0 else { + return SNLog("Couldn't send push notification due to error: \(response.message ?? "nil").") + } + } } return promise From d8103ede12568a8855772b8aacc2ea0aa508973a Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 3 Aug 2022 09:12:01 +1000 Subject: [PATCH 151/157] Updated the build number --- Session.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 5f9e30244..6c20b39d0 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -6818,7 +6818,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 361; + CURRENT_PROJECT_VERSION = 363; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -6890,7 +6890,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 361; + CURRENT_PROJECT_VERSION = 363; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", From f8b2f73f7b1e8fad8c64f16d96a5c334a10ae4cb Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 4 Aug 2022 13:25:46 +1000 Subject: [PATCH 152/157] Fixed a few issues found during QA Fixed an issue where quotes containing images wouldn't send Fixed an issue where a MessageSend job could get stuck in an infinite retry loop if it had an attachment in an invalid state Fixed an issue where quotes containing non-media files wouldn't contain the correct data Fixed an issue where the quote thumbnail was getting the wrong content mode set Fixed an issue where the local disappearing messages config wasn't getting generated correctly Fixed an issue where the format parameters for the disappearing message info message were the wrong way around in one case Updated the AttachmentUploadJob to try to support images which haven't completed downloading (untested as it's not supported via the UI) --- .../Content Views/QuoteView.swift | 39 ++++++++++--------- .../Database/Models/Attachment.swift | 22 ++++++++++- .../DisappearingMessageConfiguration.swift | 14 +++---- .../Database/Models/Quote.swift | 4 +- .../Jobs/Types/AttachmentUploadJob.swift | 12 ++++-- .../Jobs/Types/MessageSendJob.swift | 12 +++++- .../Quotes/QuotedReplyModel.swift | 2 +- 7 files changed, 71 insertions(+), 34 deletions(-) diff --git a/Session/Conversations/Message Cells/Content Views/QuoteView.swift b/Session/Conversations/Message Cells/Content Views/QuoteView.swift index 2a47a91d9..03aea60b5 100644 --- a/Session/Conversations/Message Cells/Content Views/QuoteView.swift +++ b/Session/Conversations/Message Cells/Content Views/QuoteView.swift @@ -135,25 +135,8 @@ final class QuoteView: UIView { let fallbackImageName: String = (isAudio ? "attachment_audio" : "actionsheet_document_black") let imageView: UIImageView = UIImageView( image: UIImage(named: fallbackImageName)? + .resizedImage(to: CGSize(width: iconSize, height: iconSize))? .withRenderingMode(.alwaysTemplate) - .resizedImage(to: CGSize(width: iconSize, height: iconSize)) - ) - - attachment.thumbnail( - size: .small, - success: { image, _ in - guard Thread.isMainThread else { - DispatchQueue.main.async { - imageView.image = image - imageView.contentMode = .scaleAspectFill - } - return - } - - imageView.image = image - imageView.contentMode = .scaleAspectFill - }, - failure: {} ) imageView.tintColor = .white @@ -171,6 +154,26 @@ final class QuoteView: UIView { (isAudio ? "Audio" : "Document") ) } + + // Generate the thumbnail if needed + if attachment.isVisualMedia { + attachment.thumbnail( + size: .small, + success: { image, _ in + guard Thread.isMainThread else { + DispatchQueue.main.async { + imageView.image = image + imageView.contentMode = .scaleAspectFill + } + return + } + + imageView.image = image + imageView.contentMode = .scaleAspectFill + }, + failure: {} + ) + } } else { mainStackView.addArrangedSubview(lineView) diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index e0ceef673..a62849bd7 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -717,6 +717,8 @@ extension Attachment { // MARK: - Convenience extension Attachment { + public static let nonMediaQuoteFileId: String = "NON_MEDIA_QUOTE_FILE_ID" + public enum ThumbnailSize { case small case medium @@ -869,7 +871,7 @@ extension Attachment { return existingImage } - public func cloneAsThumbnail() -> Attachment? { + public func cloneAsQuoteThumbnail() -> Attachment? { let cloneId: String = UUID().uuidString let thumbnailName: String = "quoted-thumbnail-\(sourceFilename ?? "null")" @@ -881,7 +883,22 @@ extension Attachment { mimeType: OWSMimeTypeImageJpeg, sourceFilename: thumbnailName ) - else { return nil } + else { + // Non-media files cannot have thumbnails but may be sent as quotes, in these cases we want + // to create an attachment in an 'uploaded' state with a hard-coded file id so the messageSend + // job doesn't try to upload the attachment (we include the original `serverId` as it's + // required for generating the protobuf) + return Attachment( + id: cloneId, + serverId: self.serverId, + variant: self.variant, + state: .uploaded, + contentType: self.contentType, + byteCount: 0, + downloadUrl: Attachment.nonMediaQuoteFileId, + isValid: self.isValid + ) + } // Try generate the thumbnail var thumbnailData: Data? @@ -922,6 +939,7 @@ extension Attachment { return Attachment( id: cloneId, variant: .standard, + state: .downloaded, contentType: OWSMimeTypeImageJpeg, byteCount: UInt(thumbnailData.count), sourceFilename: thumbnailName, diff --git a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift index 08937d1e4..6d4b1cfbc 100644 --- a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift +++ b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift @@ -79,8 +79,8 @@ public extension DisappearingMessagesConfiguration { return String( format: "OTHER_UPDATED_DISAPPEARING_MESSAGES_CONFIGURATION".localized(), - NSString.formatDurationSeconds(UInt32(floor(durationSeconds)), useShortFormat: false), - senderName + senderName, + NSString.formatDurationSeconds(UInt32(floor(durationSeconds)), useShortFormat: false) ) } } @@ -192,14 +192,14 @@ public class SMKDisappearingMessagesConfiguration: NSObject { return } - let config: DisappearingMessagesConfiguration = (try DisappearingMessagesConfiguration - .fetchOne(db, id: threadId)? + let config: DisappearingMessagesConfiguration = try DisappearingMessagesConfiguration + .fetchOne(db, id: threadId) + .defaulting(to: DisappearingMessagesConfiguration.defaultWith(threadId)) .with( isEnabled: isEnabled, durationSeconds: durationSeconds ) - .saved(db)) - .defaulting(to: DisappearingMessagesConfiguration.defaultWith(threadId)) + .saved(db) let interaction: Interaction = try Interaction( threadId: threadId, @@ -214,7 +214,7 @@ public class SMKDisappearingMessagesConfiguration: NSObject { db, message: ExpirationTimerUpdate( syncTarget: nil, - duration: UInt32(floor(durationSeconds)) + duration: UInt32(floor(isEnabled ? durationSeconds : 0)) ), interactionId: interaction.id, in: thread diff --git a/SessionMessagingKit/Database/Models/Quote.swift b/SessionMessagingKit/Database/Models/Quote.swift index 9ac83853c..633676aa6 100644 --- a/SessionMessagingKit/Database/Models/Quote.swift +++ b/SessionMessagingKit/Database/Models/Quote.swift @@ -113,7 +113,7 @@ public extension Quote { .map { quotedInteraction -> Attachment? in // If the quotedInteraction has an attachment then try clone it if let attachment: Attachment = try? quotedInteraction.attachments.fetchOne(db) { - return attachment.cloneAsThumbnail() + return attachment.cloneAsQuoteThumbnail() } // Otherwise if the quotedInteraction has a link preview, try clone that @@ -121,7 +121,7 @@ public extension Quote { .fetchOne(db)? .attachment .fetchOne(db)? - .cloneAsThumbnail() + .cloneAsQuoteThumbnail() } .defaulting(to: Attachment(proto: attachment)) .inserted(db) diff --git a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift index ae538be47..4692d48b1 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift @@ -43,9 +43,15 @@ public enum AttachmentUploadJob: JobExecutor { } } - // Note: In the AttachmentUploadJob we intentionally don't provide our own db instance to prevent reentrancy - // issues when the success/failure closures get called before the upload as the JobRunner will attempt to - // update the state of the job immediately + // If the attachment is still pending download the hold off on running this job + guard attachment.state != .pendingDownload && attachment.state != .downloading else { + deferred(job) + return + } + + // Note: In the AttachmentUploadJob we intentionally don't provide our own db instance to prevent + // reentrancy issues when the success/failure closures get called before the upload as the JobRunner + // will attempt to update the state of the job immediately attachment.upload( queue: queue, using: { db, data in diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift index f7bbceb62..8e9aa3732 100644 --- a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift @@ -75,7 +75,17 @@ public enum MessageSendJob: JobExecutor { // but not on the message recipients device - both LinkPreview and Quote can // have this case) try allAttachmentStateInfo - .filter { $0.state == .uploading || $0.state == .failedUpload || $0.state == .downloaded } + .filter { attachment -> Bool in + // Non-media quotes won't have thumbnails so so don't try to upload them + guard attachment.downloadUrl != Attachment.nonMediaQuoteFileId else { return false } + + switch attachment.state { + case .uploading, .pendingDownload, .downloading, .failedUpload, .downloaded: + return true + + default: return false + } + } .filter { stateInfo in // Don't add a new job if there is one already in the queue !JobRunner.hasPendingOrRunningJob( diff --git a/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift b/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift index abde118f3..9e688faaa 100644 --- a/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift +++ b/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift @@ -69,7 +69,7 @@ public extension QuotedReplyModel { guard let sourceAttachment: Attachment = self.attachment else { return nil } return try sourceAttachment - .cloneAsThumbnail()? + .cloneAsQuoteThumbnail()? .inserted(db) .id } From ecbded38199c9a7e302c42d8fda35e0ba5fc7283 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 4 Aug 2022 18:09:03 +1000 Subject: [PATCH 153/157] Cleaned up the poller logic a bit --- .../Pollers/ClosedGroupPoller.swift | 68 ++++++++---------- .../Sending & Receiving/Pollers/Poller.swift | 71 +++++++++---------- SessionUtilitiesKit/JobRunner/JobRunner.swift | 2 + 3 files changed, 65 insertions(+), 76 deletions(-) diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift index 71c4f15a5..f747a1cc2 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift @@ -184,31 +184,21 @@ public final class ClosedGroupPoller { guard isBackgroundPoll || poller?.isPolling.wrappedValue[groupPublicKey] == true else { return Promise.value(()) } var promises: [Promise] = [] - var messageCount: Int = 0 - let totalMessagesCount: Int = messageResults - .map { result -> Int in - switch result { - case .fulfilled(let messages): return messages.count - default: return 0 + let allMessages: [SnodeReceivedMessage] = messageResults + .reduce([]) { result, next in + switch next { + case .fulfilled(let messages): return result.appending(contentsOf: messages) + default: return result } } - .reduce(0, +) + var messageCount: Int = 0 + let totalMessagesCount: Int = allMessages.count - messageResults.forEach { result in - guard case .fulfilled(let messages) = result else { return } - guard !messages.isEmpty else { return } - - var jobToRun: Job? - - Storage.shared.write { db in - var jobDetailMessages: [MessageReceiveJob.Details.MessageInfo] = [] - - messages.forEach { message in + Storage.shared.write { db in + let processedMessages: [ProcessedMessage] = allMessages + .compactMap { message -> ProcessedMessage? in do { - let processedMessage: ProcessedMessage? = try Message.processRawReceivedMessage(db, rawMessage: message) - - jobDetailMessages = jobDetailMessages - .appending(processedMessage?.messageInfo) + return try Message.processRawReceivedMessage(db, rawMessage: message) } catch { switch error { @@ -219,28 +209,30 @@ public final class ClosedGroupPoller { MessageReceiverError.duplicateControlMessage, MessageReceiverError.selfSend: break - + default: SNLog("Failed to deserialize envelope due to error: \(error).") } + + return nil } } - - messageCount += jobDetailMessages.count - jobToRun = Job( - variant: .messageReceive, - behaviour: .runOnce, - threadId: groupPublicKey, - details: MessageReceiveJob.Details( - messages: jobDetailMessages, - isBackgroundPoll: isBackgroundPoll - ) - ) - - // 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 - JobRunner.add(db, job: jobToRun, canStartJob: !isBackgroundPoll) - } + messageCount = processedMessages.count + + let jobToRun: Job? = Job( + variant: .messageReceive, + behaviour: .runOnce, + threadId: groupPublicKey, + details: MessageReceiveJob.Details( + messages: processedMessages.map { $0.messageInfo }, + isBackgroundPoll: isBackgroundPoll + ) + ) + + // 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 + JobRunner.add(db, job: jobToRun, canStartJob: !isBackgroundPoll) + // We want to try to handle the receive jobs immediately in the background if isBackgroundPoll { promises = promises.appending( diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift index f63a29486..9e2c0d310 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift @@ -136,49 +136,44 @@ public final class Poller { var messageCount: Int = 0 Storage.shared.write { db in - var threadMessages: [String: [MessageReceiveJob.Details.MessageInfo]] = [:] - - messages.forEach { message in - do { - let processedMessage: ProcessedMessage? = try Message.processRawReceivedMessage(db, rawMessage: message) - let key: String = (processedMessage?.threadId ?? Message.nonThreadMessageId) - - threadMessages[key] = (threadMessages[key] ?? []) - .appending(processedMessage?.messageInfo) - } - 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, - MessageReceiverError.duplicateMessage, - MessageReceiverError.duplicateControlMessage, - MessageReceiverError.selfSend: - break + messages + .compactMap { message -> ProcessedMessage? in + do { + return try Message.processRawReceivedMessage(db, rawMessage: message) + } + 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, + MessageReceiverError.duplicateMessage, + MessageReceiverError.duplicateControlMessage, + MessageReceiverError.selfSend: + break + + default: SNLog("Failed to deserialize envelope due to error: \(error).") + } - default: SNLog("Failed to deserialize envelope due to error: \(error).") + return nil } } - } - - messageCount = threadMessages - .values - .reduce(into: 0) { prev, next in prev += next.count } - - threadMessages.forEach { threadId, threadMessages in - JobRunner.add( - db, - job: Job( - variant: .messageReceive, - behaviour: .runOnce, - threadId: threadId, - details: MessageReceiveJob.Details( - messages: threadMessages, - isBackgroundPoll: false + .grouped { threadId, _, _ in (threadId ?? Message.nonThreadMessageId) } + .forEach { threadId, threadMessages in + messageCount += threadMessages.count + + JobRunner.add( + db, + job: Job( + variant: .messageReceive, + behaviour: .runOnce, + threadId: threadId, + details: MessageReceiveJob.Details( + messages: threadMessages.map { $0.messageInfo }, + isBackgroundPoll: false + ) ) ) - ) - } + } } SNLog("Received \(messageCount) new message\(messageCount == 1 ? "" : "s") (duplicates: \(messages.count - messageCount))") diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index 6104b5680..0c1950947 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -688,9 +688,11 @@ private final class JobQueue { } private func scheduleNextSoonestJob() { + let jobIdsAlreadyRunning: Set = jobsCurrentlyRunning.wrappedValue let nextJobTimestamp: TimeInterval? = Storage.shared.read { db in try Job.filterPendingJobs(variants: jobVariants, excludeFutureJobs: false) .select(.nextRunTimestamp) + .filter(!jobIdsAlreadyRunning.contains(Job.Columns.id)) // Exclude jobs already running .asRequest(of: TimeInterval.self) .fetchOne(db) } From 1224e539ead4f2724a79218573f587bc4f6cc112 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 5 Aug 2022 17:10:01 +1000 Subject: [PATCH 154/157] Reduced unneeded DB write operations and fixed a few minor UI bugs Updated the database to better support the application getting suspended (0xdead10cc crash) Updated the SOGS message handling to delete messages based on a new 'deleted' flag instead of 'data' being null Updated the code to prevent the typing indicator from needing a DB write block as frequently Updated the code to stop any pending jobs when entering the background (in an attempt to prevent the database suspension from causing issues) Removed the duplicate 'Capabilities.Capability' type (updated 'Capability.Variant' to work in the same way) Fixed a bug where a number of icons (inc. the "download document" icon) were the wrong colour in dark mode Fixed a bug where the '@You' highlight could incorrectly have it's width reduced in some cases (had protection to prevent it being larger than the line, but that is a valid case) Fixed a bug where the JobRunner was starting the background (which could lead to trying to access the database once it had been suspended) Updated to the latest version of GRDB Added some logic to the BackgroundPoller process to try and stop processing if the timeout is triggered (will catch some cases but others will end up logging a bunch of "Database is suspended" errors) Added in some protection to prevent future deferral loops in the JobRunner --- Podfile.lock | 4 +- .../ConversationVC+Interaction.swift | 20 +- .../Conversations/ConversationViewModel.swift | 23 +- .../Input View/InputViewButton.swift | 4 +- .../Content Views/CallMessageView.swift | 2 +- .../Content Views/DeletedMessageView.swift | 4 +- .../Content Views/MediaPlaceholderView.swift | 4 +- .../OpenGroupInvitationView.swift | 2 +- Session/Meta/AppDelegate.swift | 17 +- .../HighlightMentionBackgroundView.swift | 3 - Session/Utilities/BackgroundPoller.swift | 67 +++-- .../Database/Models/Capability.swift | 34 +++ .../Open Groups/Models/Capabilities.swift | 52 +--- .../Open Groups/Models/SOGSMessage.swift | 3 + .../Open Groups/OpenGroupManager.swift | 15 +- .../MessageReceiver+TypingIndicators.swift | 7 +- .../Sending & Receiving/MessageSender.swift | 41 ++- .../Pollers/ClosedGroupPoller.swift | 82 +++--- .../Pollers/OpenGroupPoller.swift | 250 +++++++++++++++--- .../Sending & Receiving/Pollers/Poller.swift | 4 + .../Typing Indicators/TypingIndicators.swift | 123 +++++---- .../Models/SnodeReceivedMessageInfo.swift | 37 ++- SessionUtilitiesKit/Database/Storage.swift | 2 +- .../General/Dictionary+Utilities.swift | 6 + SessionUtilitiesKit/JobRunner/JobRunner.swift | 88 +++++- .../JobRunner/JobRunnerError.swift | 2 + 26 files changed, 625 insertions(+), 271 deletions(-) diff --git a/Podfile.lock b/Podfile.lock index 89c8650fc..37f4ac9c5 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -27,7 +27,7 @@ PODS: - DifferenceKit/Core (1.2.0) - DifferenceKit/UIKitExtension (1.2.0): - DifferenceKit/Core - - GRDB.swift/SQLCipher (5.24.1): + - GRDB.swift/SQLCipher (5.26.0): - SQLCipher (>= 3.4.0) - libwebp (1.2.1): - libwebp/demux (= 1.2.1) @@ -222,7 +222,7 @@ SPEC CHECKSUMS: CryptoSwift: a532e74ed010f8c95f611d00b8bbae42e9fe7c17 Curve25519Kit: e63f9859ede02438ae3defc5e1a87e09d1ec7ee6 DifferenceKit: 5659c430bb7fe45876fa32ce5cba5d6167f0c805 - GRDB.swift: b3180ce2135fc06a453297889b746b1478c4d8c7 + GRDB.swift: 1395cb3556df6b16ed69dfc74c3886abc75d2825 libwebp: 98a37e597e40bfdb4c911fc98f2c53d0b12d05fc Nimble: 5316ef81a170ce87baf72dd961f22f89a602ff84 NVActivityIndicatorView: 1f6c5687f1171810aa27a3296814dc2d7dec3667 diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 33dfce0fb..caef86415 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -520,16 +520,18 @@ extension ConversationVC: let threadId: String = self.viewModel.threadData.threadId let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant let threadIsMessageRequest: Bool = (self.viewModel.threadData.threadIsMessageRequest == true) + let needsToStartTypingIndicator: Bool = TypingIndicators.didStartTypingNeedsToStart( + threadId: threadId, + threadVariant: threadVariant, + threadIsMessageRequest: threadIsMessageRequest, + direction: .outgoing, + timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) + ) - Storage.shared.writeAsync { db in - TypingIndicators.didStartTyping( - db, - threadId: threadId, - threadVariant: threadVariant, - threadIsMessageRequest: threadIsMessageRequest, - direction: .outgoing, - timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) - ) + if needsToStartTypingIndicator { + Storage.shared.writeAsync { db in + TypingIndicators.start(db, threadId: threadId, direction: .outgoing) + } } } diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 28bf921a7..c151ea467 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -418,15 +418,34 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { // MARK: - Functions public func updateDraft(to draft: String) { + let threadId: String = self.threadId + let currentDraft: String = Storage.shared + .read { db in + try SessionThread + .select(.messageDraft) + .filter(id: threadId) + .asRequest(of: String.self) + .fetchOne(db) + } + .defaulting(to: "") + + // Only write the updated draft to the database if it's changed (avoid unnecessary writes) + guard draft != currentDraft else { return } + Storage.shared.writeAsync { db in try SessionThread - .filter(id: self.threadId) + .filter(id: threadId) .updateAll(db, SessionThread.Columns.messageDraft.set(to: draft)) } } public func markAllAsRead() { - guard let lastInteractionId: Int64 = self.threadData.interactionId else { return } + // Don't bother marking anything as read if there are no unread interactions (we can rely + // on the 'threadData.threadUnreadCount' to always be accurate) + guard + (self.threadData.threadUnreadCount ?? 0) > 0, + let lastInteractionId: Int64 = self.threadData.interactionId + else { return } let threadId: String = self.threadData.threadId let trySendReadReceipt: Bool = (self.threadData.threadIsMessageRequest == false) diff --git a/Session/Conversations/Input View/InputViewButton.swift b/Session/Conversations/Input View/InputViewButton.swift index 26d2b495d..a9d2ca015 100644 --- a/Session/Conversations/Input View/InputViewButton.swift +++ b/Session/Conversations/Input View/InputViewButton.swift @@ -59,8 +59,8 @@ final class InputViewButton : UIView { isUserInteractionEnabled = true widthConstraint.isActive = true heightConstraint.isActive = true - let tint = isSendButton ? UIColor.black : Colors.text - let iconImageView = UIImageView(image: icon.withTint(tint)) + let iconImageView = UIImageView(image: icon.withRenderingMode(.alwaysTemplate)) + iconImageView.tintColor = (isSendButton ? UIColor.black : Colors.text) iconImageView.contentMode = .scaleAspectFit let iconSize = InputViewButton.iconSize iconImageView.set(.width, to: iconSize) diff --git a/Session/Conversations/Message Cells/Content Views/CallMessageView.swift b/Session/Conversations/Message Cells/Content Views/CallMessageView.swift index ffc527311..f27302f8f 100644 --- a/Session/Conversations/Message Cells/Content Views/CallMessageView.swift +++ b/Session/Conversations/Message Cells/Content Views/CallMessageView.swift @@ -28,8 +28,8 @@ final class CallMessageView: UIView { // Image view let imageView: UIImageView = UIImageView( image: UIImage(named: "Phone")? + .resizedImage(to: CGSize(width: CallMessageView.iconSize, height: CallMessageView.iconSize))? .withRenderingMode(.alwaysTemplate) - .resizedImage(to: CGSize(width: CallMessageView.iconSize, height: CallMessageView.iconSize)) ) imageView.tintColor = textColor imageView.contentMode = .center diff --git a/Session/Conversations/Message Cells/Content Views/DeletedMessageView.swift b/Session/Conversations/Message Cells/Content Views/DeletedMessageView.swift index 437689b35..22393d1a9 100644 --- a/Session/Conversations/Message Cells/Content Views/DeletedMessageView.swift +++ b/Session/Conversations/Message Cells/Content Views/DeletedMessageView.swift @@ -27,11 +27,11 @@ final class DeletedMessageView: UIView { private func setUpViewHierarchy(textColor: UIColor) { // Image view let icon = UIImage(named: "ic_trash")? - .withRenderingMode(.alwaysTemplate) .resizedImage(to: CGSize( width: DeletedMessageView.iconSize, height: DeletedMessageView.iconSize - )) + ))? + .withRenderingMode(.alwaysTemplate) let imageView = UIImageView(image: icon) imageView.tintColor = textColor diff --git a/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift b/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift index fbd65d20a..05ad35c01 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift @@ -44,13 +44,13 @@ final class MediaPlaceholderView: UIView { // Image view let imageView = UIImageView( image: UIImage(named: iconName)? - .withRenderingMode(.alwaysTemplate) .resizedImage( to: CGSize( width: MediaPlaceholderView.iconSize, height: MediaPlaceholderView.iconSize ) - ) + )? + .withRenderingMode(.alwaysTemplate) ) imageView.tintColor = textColor imageView.contentMode = .center diff --git a/Session/Conversations/Message Cells/Content Views/OpenGroupInvitationView.swift b/Session/Conversations/Message Cells/Content Views/OpenGroupInvitationView.swift index f95841449..432b34aff 100644 --- a/Session/Conversations/Message Cells/Content Views/OpenGroupInvitationView.swift +++ b/Session/Conversations/Message Cells/Content Views/OpenGroupInvitationView.swift @@ -68,8 +68,8 @@ final class OpenGroupInvitationView: UIView { let iconImageViewSize = OpenGroupInvitationView.iconImageViewSize let iconImageView = UIImageView( image: UIImage(named: iconName)? + .resizedImage(to: CGSize(width: iconSize, height: iconSize))? .withRenderingMode(.alwaysTemplate) - .resizedImage(to: CGSize(width: iconSize, height: iconSize)) ) iconImageView.tintColor = .white iconImageView.contentMode = .center diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 41b106584..affde2523 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -122,6 +122,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD /// `appDidFinishLaunching` seems to fix this odd behaviour (even though it doesn't match /// Apple's documentation on the matter) UNUserNotificationCenter.current().delegate = self + + // Resume database + NotificationCenter.default.post(name: Database.resumeNotification, object: self) } func applicationDidEnterBackground(_ application: UIApplication) { @@ -130,6 +133,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD // NOTE: Fix an edge case where user taps on the callkit notification // but answers the call on another device stopPollers(shouldStopUserPoller: !self.hasIncomingCallWaiting()) + JobRunner.stopAndClearPendingJobs() + + // Suspend database + NotificationCenter.default.post(name: Database.suspendNotification, object: self) } func applicationDidReceiveMemoryWarning(_ application: UIApplication) { @@ -185,8 +192,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD // MARK: - Background Fetching func application(_ application: UIApplication, performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + // Resume database + NotificationCenter.default.post(name: Database.resumeNotification, object: self) + AppReadiness.runNowOrWhenAppDidBecomeReady { - BackgroundPoller.poll(completionHandler: completionHandler) + BackgroundPoller.poll { result in + // Suspend database + NotificationCenter.default.post(name: Database.suspendNotification, object: self) + + completionHandler(result) + } } } diff --git a/Session/Shared/HighlightMentionBackgroundView.swift b/Session/Shared/HighlightMentionBackgroundView.swift index 1b9a593ba..3a7db4eaa 100644 --- a/Session/Shared/HighlightMentionBackgroundView.swift +++ b/Session/Shared/HighlightMentionBackgroundView.swift @@ -137,9 +137,6 @@ class HighlightMentionBackgroundView: UIView { extraYOffset ) - // We don't want to draw too far to the right - runBounds.size.width = (runBounds.width > lineWidth ? lineWidth : runBounds.width) - let path = UIBezierPath(roundedRect: runBounds, cornerRadius: cornerRadius) mentionBackgroundColor.setFill() path.fill() diff --git a/Session/Utilities/BackgroundPoller.swift b/Session/Utilities/BackgroundPoller.swift index 9a430ec5e..03f575c48 100644 --- a/Session/Utilities/BackgroundPoller.swift +++ b/Session/Utilities/BackgroundPoller.swift @@ -9,8 +9,11 @@ import SessionUtilitiesKit public final class BackgroundPoller { private static var promises: [Promise] = [] + private static var isValid: Bool = false public static func poll(completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + BackgroundPoller.isValid = true + promises = [] .appending(pollForMessages()) .appending(contentsOf: pollForClosedGroupMessages()) @@ -32,7 +35,11 @@ public final class BackgroundPoller { let poller: OpenGroupAPI.Poller = OpenGroupAPI.Poller(for: server) poller.stop() - return poller.poll(isBackgroundPoll: true, isPostCapabilitiesRetry: false) + return poller.poll( + isBackgroundPoll: true, + isBackgroundPollerValid: { BackgroundPoller.isValid }, + isPostCapabilitiesRetry: false + ) } ) @@ -41,6 +48,7 @@ public final class BackgroundPoller { // after 25 seconds allowing us to cancel all pending promises let cancelTimer: Timer = Timer.scheduledTimerOnMainThread(withTimeInterval: 25, repeats: false) { timer in timer.invalidate() + BackgroundPoller.isValid = false guard promises.contains(where: { !$0.isResolved }) else { return } @@ -50,6 +58,9 @@ public final class BackgroundPoller { when(resolved: promises) .done { _ in + // If we have already invalidated the timer then do nothing (we essentially timed out) + guard cancelTimer.isValid else { return } + cancelTimer.invalidate() completionHandler(.newData) } @@ -88,7 +99,8 @@ public final class BackgroundPoller { groupPublicKey, on: DispatchQueue.main, maxRetryCount: 0, - isBackgroundPoll: true + isBackgroundPoll: true, + isBackgroundPollValid: { BackgroundPoller.isValid } ) } } @@ -100,44 +112,45 @@ public final class BackgroundPoller { return SnodeAPI.getMessages(from: snode, associatedWith: publicKey) .then(on: DispatchQueue.main) { messages -> Promise in - guard !messages.isEmpty else { return Promise.value(()) } + guard !messages.isEmpty, BackgroundPoller.isValid else { return Promise.value(()) } var jobsToRun: [Job] = [] Storage.shared.write { db in - var threadMessages: [String: [MessageReceiveJob.Details.MessageInfo]] = [:] - - messages.forEach { message in - do { - let processedMessage: ProcessedMessage? = try Message.processRawReceivedMessage(db, rawMessage: message) - let key: String = (processedMessage?.threadId ?? Message.nonThreadMessageId) - - threadMessages[key] = (threadMessages[key] ?? []) - .appending(processedMessage?.messageInfo) - } - 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, - MessageReceiverError.duplicateMessage, - MessageReceiverError.duplicateControlMessage, - MessageReceiverError.selfSend: - break + messages + .compactMap { message -> ProcessedMessage? in + do { + return try Message.processRawReceivedMessage(db, rawMessage: message) + } + 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, + MessageReceiverError.duplicateMessage, + MessageReceiverError.duplicateControlMessage, + MessageReceiverError.selfSend: + break + + // In the background ignore 'SQLITE_ABORT' (it generally means + // the BackgroundPoller has timed out + case DatabaseError.SQLITE_ABORT: break + + default: SNLog("Failed to deserialize envelope due to error: \(error).") + } - default: SNLog("Failed to deserialize envelope due to error: \(error).") + return nil } } - } - - threadMessages + .grouped { threadId, _, _ in (threadId ?? Message.nonThreadMessageId) } .forEach { threadId, threadMessages in let maybeJob: Job? = Job( variant: .messageReceive, behaviour: .runOnce, threadId: threadId, details: MessageReceiveJob.Details( - messages: threadMessages, + messages: threadMessages.map { $0.messageInfo }, isBackgroundPoll: true ) ) diff --git a/SessionMessagingKit/Database/Models/Capability.swift b/SessionMessagingKit/Database/Models/Capability.swift index 60437fa23..9feda3eb1 100644 --- a/SessionMessagingKit/Database/Models/Capability.swift +++ b/SessionMessagingKit/Database/Models/Capability.swift @@ -59,3 +59,37 @@ public struct Capability: Codable, FetchableRecord, PersistableRecord, TableReco self.isMissing = isMissing } } + +extension Capability.Variant { + // MARK: - Codable + + public init(from decoder: Decoder) throws { + let container: SingleValueDecodingContainer = try decoder.singleValueContainer() + let valueString: String = try container.decode(String.self) + + // FIXME: Remove this code + // There was a point where we didn't have custom Codable handling for the Capability.Variant + // which resulted in the data being encoded into the database as a JSON dict - this code catches + // that case and extracts the standard string value so it can be processed the same as the + // "proper" custom Codable logic) + if valueString.starts(with: "{") { + self = Capability.Variant( + from: valueString + .replacingOccurrences(of: "\":{}}", with: "") + .replacingOccurrences(of: "\"}}", with: "") + .replacingOccurrences(of: "{\"unsupported\":{\"_0\":\"", with: "") + .replacingOccurrences(of: "{\"", with: "") + ) + return + } + // FIXME: Remove this code ^^^ + + self = Capability.Variant(from: valueString) + } + + public func encode(to encoder: Encoder) throws { + var container: SingleValueEncodingContainer = encoder.singleValueContainer() + + try container.encode(rawValue) + } +} diff --git a/SessionMessagingKit/Open Groups/Models/Capabilities.swift b/SessionMessagingKit/Open Groups/Models/Capabilities.swift index 4c4332485..2947214b8 100644 --- a/SessionMessagingKit/Open Groups/Models/Capabilities.swift +++ b/SessionMessagingKit/Open Groups/Models/Capabilities.swift @@ -4,60 +4,14 @@ import Foundation extension OpenGroupAPI { public struct Capabilities: Codable, Equatable { - public enum Capability: Equatable, CaseIterable, Codable { - public static var allCases: [Capability] { - [.sogs, .blind] - } - - case sogs - case blind - - /// Fallback case if the capability isn't supported by this version of the app - case unsupported(String) - - // MARK: - Convenience - - public var rawValue: String { - switch self { - case .unsupported(let originalValue): return originalValue - default: return "\(self)" - } - } - - // MARK: - Initialization - - public init(from valueString: String) { - let maybeValue: Capability? = Capability.allCases.first { $0.rawValue == valueString } - - self = (maybeValue ?? .unsupported(valueString)) - } - } - - public let capabilities: [Capability] - public let missing: [Capability]? + public let capabilities: [Capability.Variant] + public let missing: [Capability.Variant]? // MARK: - Initialization - public init(capabilities: [Capability], missing: [Capability]? = nil) { + public init(capabilities: [Capability.Variant], missing: [Capability.Variant]? = nil) { self.capabilities = capabilities self.missing = missing } } } - -extension OpenGroupAPI.Capabilities.Capability { - // MARK: - Codable - - public init(from decoder: Decoder) throws { - let container: SingleValueDecodingContainer = try decoder.singleValueContainer() - let valueString: String = try container.decode(String.self) - - self = OpenGroupAPI.Capabilities.Capability(from: valueString) - } - - public func encode(to encoder: Encoder) throws { - var container: SingleValueEncodingContainer = encoder.singleValueContainer() - - try container.encode(rawValue) - } -} diff --git a/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift b/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift index f668454bb..f566565f7 100644 --- a/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift +++ b/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift @@ -10,6 +10,7 @@ extension OpenGroupAPI { case sender = "session_id" case posted case edited + case deleted case seqNo = "seqno" case whisper case whisperMods = "whisper_mods" @@ -23,6 +24,7 @@ extension OpenGroupAPI { public let sender: String? public let posted: TimeInterval public let edited: TimeInterval? + public let deleted: Bool? public let seqNo: Int64 public let whisper: Bool public let whisperMods: Bool @@ -79,6 +81,7 @@ extension OpenGroupAPI.Message { 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), 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), diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index c72dea525..ea750d7cd 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -348,7 +348,7 @@ public final class OpenGroupManager: NSObject { capabilities.capabilities.forEach { capability in _ = try? Capability( openGroupServer: server.lowercased(), - variant: Capability.Variant(from: capability.rawValue), + variant: capability, isMissing: false ) .saved(db) @@ -356,7 +356,7 @@ public final class OpenGroupManager: NSObject { capabilities.missing?.forEach { capability in _ = try? Capability( openGroupServer: server.lowercased(), - variant: Capability.Variant(from: capability.rawValue), + variant: capability, isMissing: true ) .saved(db) @@ -499,9 +499,12 @@ public final class OpenGroupManager: NSObject { } let sortedMessages: [OpenGroupAPI.Message] = messages + .filter { $0.deleted != true } .sorted { lhs, rhs in lhs.id < rhs.id } + let messageServerIdsToRemove: [Int64] = messages + .filter { $0.deleted == true } + .map { $0.id } let seqNo: Int64? = sortedMessages.map { $0.seqNo }.max() - var messageServerIdsToRemove: [UInt64] = [] // Update the 'openGroupSequenceNumber' value (Note: SOGS V4 uses the 'seqNo' instead of the 'serverId') if let seqNo: Int64 = seqNo { @@ -515,11 +518,7 @@ public final class OpenGroupManager: NSObject { guard let base64EncodedString: String = message.base64EncodedData, let data = Data(base64Encoded: base64EncodedString) - else { - // A message with no data has been deleted so add it to the list to remove - messageServerIdsToRemove.append(UInt64(message.id)) - return - } + else { return } do { let processedMessage: ProcessedMessage? = try Message.processReceivedOpenGroupMessage( diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+TypingIndicators.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+TypingIndicators.swift index d875c0f0e..46807cf60 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+TypingIndicators.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+TypingIndicators.swift @@ -13,8 +13,7 @@ extension MessageReceiver { switch message.kind { case .started: - TypingIndicators.didStartTyping( - db, + let needsToStartTypingIndicator: Bool = TypingIndicators.didStartTypingNeedsToStart( threadId: thread.id, threadVariant: thread.variant, threadIsMessageRequest: thread.isMessageRequest(db), @@ -22,6 +21,10 @@ extension MessageReceiver { timestampMs: message.sentTimestamp.map { Int64($0) } ) + if needsToStartTypingIndicator { + TypingIndicators.start(db, threadId: thread.id, direction: .incoming) + } + case .stopped: TypingIndicators.didStopTyping(db, threadId: thread.id, direction: .incoming) diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index c20391a42..438ffa55f 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -291,7 +291,7 @@ public final class MessageSender { errorCount += 1 guard errorCount == promiseCount else { return } // Only error out if all promises failed - Storage.shared.write { db in + Storage.shared.read { db in handleFailure(db, with: .other(error)) } } @@ -300,7 +300,7 @@ public final class MessageSender { .catch(on: DispatchQueue.global(qos: .default)) { error in SNLog("Couldn't send message due to error: \(error).") - Storage.shared.write { db in + Storage.shared.read { db in handleFailure(db, with: .other(error)) } } @@ -447,7 +447,7 @@ public final class MessageSender { } } .catch(on: DispatchQueue.global(qos: .default)) { error in - dependencies.storage.write { db in + dependencies.storage.read { db in handleFailure(db, with: .other(error)) } } @@ -557,7 +557,7 @@ public final class MessageSender { } } .catch(on: DispatchQueue.global(qos: .default)) { error in - dependencies.storage.write { db in + dependencies.storage.read { db in handleFailure(db, with: .other(error)) } } @@ -652,15 +652,34 @@ public final class MessageSender { with error: MessageSenderError, interactionId: Int64? ) { - // Mark any "sending" recipients as "failed" - _ = try? RecipientState + // Check if we need to mark any "sending" recipients as "failed" + // + // Note: The 'db' could be either read-only or writeable so we determine + // if a change is required, and if so dispatch to a separate queue for the + // actual write + let rowIds: [Int64] = (try? RecipientState + .select(Column.rowID) .filter(RecipientState.Columns.interactionId == interactionId) .filter(RecipientState.Columns.state == RecipientState.State.sending) - .updateAll( - db, - RecipientState.Columns.state.set(to: RecipientState.State.failed), - RecipientState.Columns.mostRecentFailureText.set(to: error.localizedDescription) - ) + .asRequest(of: Int64.self) + .fetchAll(db)) + .defaulting(to: []) + + guard !rowIds.isEmpty else { return } + + // Need to dispatch to a different thread to prevent a potential db re-entrancy + // issue from occuring in some cases + DispatchQueue.global(qos: .background).async { + Storage.shared.write { db in + try RecipientState + .filter(rowIds.contains(Column.rowID)) + .updateAll( + db, + RecipientState.Columns.state.set(to: RecipientState.State.failed), + RecipientState.Columns.mostRecentFailureText.set(to: error.localizedDescription) + ) + } + } } // MARK: - Convenience diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift index f747a1cc2..ad5c8cda5 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift @@ -152,6 +152,7 @@ public final class ClosedGroupPoller { on queue: DispatchQueue = SessionSnodeKit.Threading.workQueue, maxRetryCount: UInt = 0, isBackgroundPoll: Bool = false, + isBackgroundPollValid: @escaping (() -> Bool) = { true }, poller: ClosedGroupPoller? = nil ) -> Promise { let promise: Promise = SnodeAPI.getSwarm(for: groupPublicKey) @@ -160,9 +161,10 @@ public final class ClosedGroupPoller { guard let snode = swarm.randomElement() else { return Promise(error: Error.insufficientSnodes) } return attempt(maxRetryCount: maxRetryCount, recoveringOn: queue) { - guard isBackgroundPoll || poller?.isPolling.wrappedValue[groupPublicKey] == true else { - return Promise(error: Error.pollingCanceled) - } + guard + (isBackgroundPoll && isBackgroundPollValid()) || + poller?.isPolling.wrappedValue[groupPublicKey] == true + else { return Promise(error: Error.pollingCanceled) } let promises: [Promise<[SnodeReceivedMessage]>] = { if SnodeAPI.hardfork >= 19 && SnodeAPI.softfork >= 1 { @@ -181,9 +183,13 @@ public final class ClosedGroupPoller { return when(resolved: promises) .then(on: queue) { messageResults -> Promise in - guard isBackgroundPoll || poller?.isPolling.wrappedValue[groupPublicKey] == true else { return Promise.value(()) } + guard + (isBackgroundPoll && isBackgroundPollValid()) || + poller?.isPolling.wrappedValue[groupPublicKey] == true + else { return Promise.value(()) } var promises: [Promise] = [] + var jobToRun: Job? = nil let allMessages: [SnodeReceivedMessage] = messageResults .reduce([]) { result, next in switch next { @@ -192,8 +198,16 @@ public final class ClosedGroupPoller { } } var messageCount: Int = 0 - let totalMessagesCount: Int = allMessages.count + // No need to do anything if there are no messages + guard !allMessages.isEmpty else { + if !isBackgroundPoll { + SNLog("Received no new messages in closed group with public key: \(groupPublicKey)") + } + return Promise.value(()) + } + + // Otherwise process the messages and add them to the queue for handling Storage.shared.write { db in let processedMessages: [ProcessedMessage] = allMessages .compactMap { message -> ProcessedMessage? in @@ -209,6 +223,14 @@ public final class ClosedGroupPoller { MessageReceiverError.duplicateControlMessage, MessageReceiverError.selfSend: break + + // In the background ignore 'SQLITE_ABORT' (it generally means + // the BackgroundPoller has timed out + case DatabaseError.SQLITE_ABORT: + guard !isBackgroundPoll else { break } + + SNLog("Failed to the database being suspended (running in background with no background task).") + break default: SNLog("Failed to deserialize envelope due to error: \(error).") } @@ -219,7 +241,7 @@ public final class ClosedGroupPoller { messageCount = processedMessages.count - let jobToRun: Job? = Job( + jobToRun = Job( variant: .messageReceive, behaviour: .runOnce, threadId: groupPublicKey, @@ -232,35 +254,29 @@ public final class ClosedGroupPoller { // 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 JobRunner.add(db, job: jobToRun, canStartJob: !isBackgroundPoll) - - // We want to try to handle the receive jobs immediately in the background - if isBackgroundPoll { - promises = promises.appending( - jobToRun.map { job -> Promise in - let (promise, seal) = Promise.pending() - - // Note: In the background we just want jobs to fail silently - MessageReceiveJob.run( - job, - queue: queue, - success: { _, _ in seal.fulfill(()) }, - failure: { _, _, _ in seal.fulfill(()) }, - deferred: { _ in seal.fulfill(()) } - ) - - return promise - } - ) - } } - if !isBackgroundPoll { - if totalMessagesCount > 0 { - SNLog("Received \(messageCount) new message\(messageCount == 1 ? "" : "s") in closed group with public key: \(groupPublicKey) (duplicates: \(totalMessagesCount - messageCount))") - } - else { - SNLog("Received no new messages in closed group with public key: \(groupPublicKey)") - } + if isBackgroundPoll { + // We want to try to handle the receive jobs immediately in the background + promises = promises.appending( + jobToRun.map { job -> Promise in + let (promise, seal) = Promise.pending() + + // Note: In the background we just want jobs to fail silently + MessageReceiveJob.run( + job, + queue: queue, + success: { _, _ in seal.fulfill(()) }, + failure: { _, _, _ in seal.fulfill(()) }, + deferred: { _ in seal.fulfill(()) } + ) + + return promise + } + ) + } + else { + SNLog("Received \(messageCount) new message\(messageCount == 1 ? "" : "s") in closed group with public key: \(groupPublicKey) (duplicates: \(allMessages.count - messageCount))") } return when(fulfilled: promises) diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift index 52c2714fd..fc4b15eb1 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift @@ -8,6 +8,8 @@ import SessionUtilitiesKit extension OpenGroupAPI { public final class Poller { + typealias PollResponse = [OpenGroupAPI.Endpoint: (info: OnionRequestResponseInfoType, data: Codable?)] + private let server: String private var timer: Timer? = nil private var hasStarted = false @@ -71,6 +73,7 @@ extension OpenGroupAPI { @discardableResult public func poll( isBackgroundPoll: Bool, + isBackgroundPollerValid: @escaping (() -> Bool) = { true }, isPostCapabilitiesRetry: Bool, using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies() ) -> Promise { @@ -83,8 +86,14 @@ extension OpenGroupAPI { Threading.pollerQueue.async { dependencies.storage - .read { db in - OpenGroupAPI + .read { db -> Promise<(Int64, PollResponse)> in + let failureCount: Int64 = (try? OpenGroup + .select(max(OpenGroup.Columns.pollFailureCount)) + .asRequest(of: Int64.self) + .fetchOne(db)) + .defaulting(to: 0) + + return OpenGroupAPI .poll( db, server: server, @@ -95,10 +104,24 @@ extension OpenGroupAPI { ), using: dependencies ) + .map(on: OpenGroupAPI.workQueue) { (failureCount, $0) } } - .done(on: OpenGroupAPI.workQueue) { [weak self] response in + .done(on: OpenGroupAPI.workQueue) { [weak self] failureCount, response in + guard !isBackgroundPoll || isBackgroundPollerValid() else { + // If this was a background poll and the background poll is no longer valid + // then just stop + self?.isPolling = false + seal.fulfill(()) + return + } + self?.isPolling = false - self?.handlePollResponse(response, isBackgroundPoll: isBackgroundPoll, using: dependencies) + self?.handlePollResponse( + response, + failureCount: failureCount, + isBackgroundPoll: isBackgroundPoll, + using: dependencies + ) dependencies.mutableCache.mutate { cache in cache.hasPerformedInitialPoll[server] = true @@ -106,17 +129,18 @@ extension OpenGroupAPI { UserDefaults.standard[.lastOpen] = Date() } - // Reset the failure count - Storage.shared.writeAsync { db in - try OpenGroup - .filter(OpenGroup.Columns.server == server) - .updateAll(db, OpenGroup.Columns.pollFailureCount.set(to: 0)) - } - SNLog("Open group polling finished for \(server).") seal.fulfill(()) } .catch(on: OpenGroupAPI.workQueue) { [weak self] error in + guard !isBackgroundPoll || isBackgroundPollerValid() else { + // If this was a background poll and the background poll is no longer valid + // then just stop + self?.isPolling = false + seal.fulfill(()) + return + } + // If we are retrying then the error is being handled so no need to continue (this // method will always resolve) self?.updateCapabilitiesAndRetryIfNeeded( @@ -141,7 +165,10 @@ extension OpenGroupAPI { Storage.shared.writeAsync { db in try OpenGroup .filter(OpenGroup.Columns.server == server) - .updateAll(db, OpenGroup.Columns.pollFailureCount.set(to: (pollFailureCount + 1))) + .updateAll( + db, + OpenGroup.Columns.pollFailureCount.set(to: (pollFailureCount + 1)) + ) } SNLog("Open group polling failed due to error: \(error). Setting failure count to \(pollFailureCount).") @@ -221,18 +248,166 @@ extension OpenGroupAPI { return promise } - private func handlePollResponse(_ response: [OpenGroupAPI.Endpoint: (info: OnionRequestResponseInfoType, data: Codable?)], isBackgroundPoll: Bool, using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies()) { + private func handlePollResponse( + _ response: PollResponse, + failureCount: Int64, + isBackgroundPoll: Bool, + using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies() + ) { let server: String = self.server - - dependencies.storage.write { db in - try response.forEach { endpoint, endpointResponse in + let validResponses: PollResponse = response + .filter { endpoint, endpointResponse in switch endpoint { case .capabilities: - guard let responseData: BatchSubResponse = endpointResponse.data as? BatchSubResponse, let responseBody: Capabilities = responseData.body else { + guard (endpointResponse.data as? BatchSubResponse)?.body != nil else { SNLog("Open group polling failed due to invalid capability data.") - return + return false } + return true + + case .roomPollInfo(let roomToken, _): + guard (endpointResponse.data as? BatchSubResponse)?.body != nil else { + switch (endpointResponse.data as? BatchSubResponse)?.code { + case 404: SNLog("Open group polling failed to retrieve info for unknown room '\(roomToken)'.") + default: SNLog("Open group polling failed due to invalid room info data.") + } + return false + } + + return true + + case .roomMessagesRecent(let roomToken), .roomMessagesBefore(let roomToken, _), .roomMessagesSince(let roomToken, _): + guard + let responseData: BatchSubResponse<[Failable]> = endpointResponse.data as? BatchSubResponse<[Failable]>, + let responseBody: [Failable] = responseData.body + else { + switch (endpointResponse.data as? BatchSubResponse<[Failable]>)?.code { + case 404: SNLog("Open group polling failed to retrieve messages for unknown room '\(roomToken)'.") + default: SNLog("Open group polling failed due to invalid messages data.") + } + return false + } + + let successfulMessages: [Message] = responseBody.compactMap { $0.value } + + if successfulMessages.count != responseBody.count { + let droppedCount: Int = (responseBody.count - successfulMessages.count) + + SNLog("Dropped \(droppedCount) invalid open group message\(droppedCount == 1 ? "" : "s").") + } + + return !successfulMessages.isEmpty + + case .inbox, .inboxSince, .outbox, .outboxSince: + guard + let responseData: BatchSubResponse<[DirectMessage]?> = endpointResponse.data as? BatchSubResponse<[DirectMessage]?>, + !responseData.failedToParseBody + else { + SNLog("Open group polling failed due to invalid inbox/outbox data.") + return false + } + + // Double optional because the server can return a `304` with an empty body + let messages: [OpenGroupAPI.DirectMessage] = ((responseData.body ?? []) ?? []) + + return !messages.isEmpty + + default: return false // No custom handling needed + } + } + + // If there are no remaining 'validResponses' and there hasn't been a failure then there is + // no need to do anything else + guard !validResponses.isEmpty || failureCount != 0 else { return } + + // Retrieve the current capability & group info to check if anything changed + let rooms: [String] = validResponses + .keys + .compactMap { endpoint -> String? in + switch endpoint { + case .roomPollInfo(let roomToken, _): return roomToken + default: return nil + } + } + let currentInfo: (capabilities: Capabilities, groups: [OpenGroup])? = dependencies.storage.read { db in + let allCapabilities: [Capability] = try Capability + .filter(Capability.Columns.openGroupServer == server) + .fetchAll(db) + let capabilities: Capabilities = Capabilities( + capabilities: allCapabilities + .filter { !$0.isMissing } + .map { $0.variant }, + missing: { + let missingCapabilities: [Capability.Variant] = allCapabilities + .filter { $0.isMissing } + .map { $0.variant } + + return (missingCapabilities.isEmpty ? nil : missingCapabilities) + }() + ) + let openGroupIds: [String] = rooms + .map { OpenGroup.idFor(roomToken: $0, server: server) } + let groups: [OpenGroup] = try OpenGroup + .filter(ids: openGroupIds) + .fetchAll(db) + + return (capabilities, groups) + } + let changedResponses: PollResponse = validResponses + .filter { endpoint, endpointResponse in + switch endpoint { + case .capabilities: + guard + let responseData: BatchSubResponse = endpointResponse.data as? BatchSubResponse, + let responseBody: Capabilities = responseData.body + else { return false } + + return (responseBody != currentInfo?.capabilities) + + case .roomPollInfo(let roomToken, _): + guard + let responseData: BatchSubResponse = endpointResponse.data as? BatchSubResponse, + let responseBody: RoomPollInfo = responseData.body + else { return false } + guard let existingOpenGroup: OpenGroup = currentInfo?.groups.first(where: { $0.roomToken == roomToken }) else { + return true + } + + // Note: This might need to be updated in the future when we start tracking + // user permissions if changes to permissions don't trigger a change to + // the 'infoUpdates' + return ( + responseBody.activeUsers != existingOpenGroup.userCount || ( + responseBody.details != nil && + responseBody.details?.infoUpdates != existingOpenGroup.infoUpdates + ) + ) + + default: return true + } + } + + // If there are no 'changedResponses' and there hasn't been a failure then there is + // no need to do anything else + guard !changedResponses.isEmpty || failureCount != 0 else { return } + + dependencies.storage.write { db in + // Reset the failure count + if failureCount > 0 { + try OpenGroup + .filter(OpenGroup.Columns.server == server) + .updateAll(db, OpenGroup.Columns.pollFailureCount.set(to: 0)) + } + + try changedResponses.forEach { endpoint, endpointResponse in + switch endpoint { + case .capabilities: + guard + let responseData: BatchSubResponse = endpointResponse.data as? BatchSubResponse, + let responseBody: Capabilities = responseData.body + else { return } + OpenGroupManager.handleCapabilities( db, capabilities: responseBody, @@ -240,13 +415,10 @@ extension OpenGroupAPI { ) case .roomPollInfo(let roomToken, _): - guard let responseData: BatchSubResponse = endpointResponse.data as? BatchSubResponse, let responseBody: RoomPollInfo = responseData.body else { - switch (endpointResponse.data as? BatchSubResponse)?.code { - case 404: SNLog("Open group polling failed to retrieve info for unknown room '\(roomToken)'.") - default: SNLog("Open group polling failed due to invalid room info data.") - } - return - } + guard + let responseData: BatchSubResponse = endpointResponse.data as? BatchSubResponse, + let responseBody: RoomPollInfo = responseData.body + else { return } try OpenGroupManager.handlePollInfo( db, @@ -258,24 +430,14 @@ extension OpenGroupAPI { ) case .roomMessagesRecent(let roomToken), .roomMessagesBefore(let roomToken, _), .roomMessagesSince(let roomToken, _): - guard let responseData: BatchSubResponse<[Failable]> = endpointResponse.data as? BatchSubResponse<[Failable]>, let responseBody: [Failable] = responseData.body else { - switch (endpointResponse.data as? BatchSubResponse<[Failable]>)?.code { - case 404: SNLog("Open group polling failed to retrieve messages for unknown room '\(roomToken)'.") - default: SNLog("Open group polling failed due to invalid messages data.") - } - return - } - let successfulMessages: [Message] = responseBody.compactMap { $0.value } - - if successfulMessages.count != responseBody.count { - let droppedCount: Int = (responseBody.count - successfulMessages.count) - - SNLog("Dropped \(droppedCount) invalid open group message\(droppedCount == 1 ? "" : "s").") - } + guard + let responseData: BatchSubResponse<[Failable]> = endpointResponse.data as? BatchSubResponse<[Failable]>, + let responseBody: [Failable] = responseData.body + else { return } OpenGroupManager.handleMessages( db, - messages: successfulMessages, + messages: responseBody.compactMap { $0.value }, for: roomToken, on: server, isBackgroundPoll: isBackgroundPoll, @@ -283,10 +445,10 @@ extension OpenGroupAPI { ) case .inbox, .inboxSince, .outbox, .outboxSince: - guard let responseData: BatchSubResponse<[DirectMessage]?> = endpointResponse.data as? BatchSubResponse<[DirectMessage]?>, !responseData.failedToParseBody else { - SNLog("Open group polling failed due to invalid inbox/outbox data.") - return - } + guard + let responseData: BatchSubResponse<[DirectMessage]?> = endpointResponse.data as? BatchSubResponse<[DirectMessage]?>, + !responseData.failedToParseBody + else { return } // Double optional because the server can return a `304` with an empty body let messages: [OpenGroupAPI.DirectMessage] = ((responseData.body ?? []) ?? []) diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift index 9e2c0d310..4877077d2 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift @@ -150,6 +150,10 @@ public final class Poller { MessageReceiverError.duplicateControlMessage, MessageReceiverError.selfSend: break + + case DatabaseError.SQLITE_ABORT: + SNLog("Failed to the database being suspended (running in background with no background task).") + break default: SNLog("Failed to deserialize envelope due to error: \(error).") } diff --git a/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift b/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift index d2d99806e..cdda8b025 100644 --- a/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift +++ b/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift @@ -44,10 +44,7 @@ public class TypingIndicators { self.timestampMs = (timestampMs ?? Int64(floor(Date().timeIntervalSince1970 * 1000))) } - fileprivate func starting(_ db: Database) -> Indicator { - let direction: Direction = self.direction - let timestampMs: Int64 = self.timestampMs - + fileprivate func start(_ db: Database) { // Start the typing indicator switch direction { case .outgoing: @@ -55,27 +52,17 @@ public class TypingIndicators { case .incoming: try? ThreadTypingIndicator( - threadId: self.threadId, + threadId: threadId, timestampMs: timestampMs ) .save(db) } - // Schedule the 'stopCallback' to cancel the typing indicator - stopTimer?.invalidate() - stopTimer = Timer.scheduledTimerOnMainThread( - withTimeInterval: (direction == .outgoing ? 3 : 5), - repeats: false - ) { [weak self] _ in - Storage.shared.write { db in - self?.stoping(db) - } - } - - return self + // Refresh the timeout since we just started + refreshTimeout() } - @discardableResult fileprivate func stoping(_ db: Database) -> Indicator? { + fileprivate func stop(_ db: Database) { self.refreshTimer?.invalidate() self.refreshTimer = nil self.stopTimer?.invalidate() @@ -84,7 +71,7 @@ public class TypingIndicators { switch direction { case .outgoing: guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: self.threadId) else { - return nil + return } try? MessageSender.send( @@ -99,8 +86,22 @@ public class TypingIndicators { .filter(ThreadTypingIndicator.Columns.threadId == self.threadId) .deleteAll(db) } + } + + fileprivate func refreshTimeout() { + let threadId: String = self.threadId + let direction: Direction = self.direction - return nil + // Schedule the 'stopCallback' to cancel the typing indicator + stopTimer?.invalidate() + stopTimer = Timer.scheduledTimerOnMainThread( + withTimeInterval: (direction == .outgoing ? 3 : 5), + repeats: false + ) { _ in + Storage.shared.write { db in + TypingIndicators.didStopTyping(db, threadId: threadId, direction: direction) + } + } } private func scheduleRefreshCallback(_ db: Database, shouldSend: Bool = true) { @@ -138,56 +139,76 @@ public class TypingIndicators { // MARK: - Functions - public static func didStartTyping( - _ db: Database, + public static func didStartTypingNeedsToStart( threadId: String, threadVariant: SessionThread.Variant, threadIsMessageRequest: Bool, direction: Direction, timestampMs: Int64? - ) { + ) -> Bool { switch direction { case .outgoing: - let updatedIndicator: Indicator? = ( - outgoing.wrappedValue[threadId] ?? - Indicator( - threadId: threadId, - threadVariant: threadVariant, - threadIsMessageRequest: threadIsMessageRequest, - direction: direction, - timestampMs: timestampMs - ) - )?.starting(db) + // If we already have an existing typing indicator for this thread then just + // refresh it's timeout (no need to do anything else) + if let existingIndicator: Indicator = outgoing.wrappedValue[threadId] { + existingIndicator.refreshTimeout() + return false + } - outgoing.mutate { $0[threadId] = updatedIndicator } + let newIndicator: Indicator? = Indicator( + threadId: threadId, + threadVariant: threadVariant, + threadIsMessageRequest: threadIsMessageRequest, + direction: direction, + timestampMs: timestampMs + ) + newIndicator?.refreshTimeout() + + outgoing.mutate { $0[threadId] = newIndicator } + return true case .incoming: - let updatedIndicator: Indicator? = ( - incoming.wrappedValue[threadId] ?? - Indicator( - threadId: threadId, - threadVariant: threadVariant, - threadIsMessageRequest: threadIsMessageRequest, - direction: direction, - timestampMs: timestampMs - ) - )?.starting(db) + // If we already have an existing typing indicator for this thread then just + // refresh it's timeout (no need to do anything else) + if let existingIndicator: Indicator = incoming.wrappedValue[threadId] { + existingIndicator.refreshTimeout() + return false + } - incoming.mutate { $0[threadId] = updatedIndicator } + let newIndicator: Indicator? = Indicator( + threadId: threadId, + threadVariant: threadVariant, + threadIsMessageRequest: threadIsMessageRequest, + direction: direction, + timestampMs: timestampMs + ) + newIndicator?.refreshTimeout() + + incoming.mutate { $0[threadId] = newIndicator } + return true + } + } + + public static func start(_ db: Database, threadId: String, direction: Direction) { + switch direction { + case .outgoing: outgoing.wrappedValue[threadId]?.start(db) + case .incoming: incoming.wrappedValue[threadId]?.start(db) } } public static func didStopTyping(_ db: Database, threadId: String, direction: Direction) { switch direction { case .outgoing: - let updatedIndicator: Indicator? = outgoing.wrappedValue[threadId]?.stoping(db) - - outgoing.mutate { $0[threadId] = updatedIndicator } + if let indicator: Indicator = outgoing.wrappedValue[threadId] { + indicator.stop(db) + outgoing.mutate { $0[threadId] = nil } + } case .incoming: - let updatedIndicator: Indicator? = incoming.wrappedValue[threadId]?.stoping(db) - - incoming.mutate { $0[threadId] = updatedIndicator } + if let indicator: Indicator = incoming.wrappedValue[threadId] { + indicator.stop(db) + incoming.mutate { $0[threadId] = nil } + } } } } diff --git a/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift b/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift index e9b5fbfbb..16b66672d 100644 --- a/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift +++ b/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift @@ -68,19 +68,34 @@ public extension SnodeReceivedMessageInfo { public extension SnodeReceivedMessageInfo { static func pruneExpiredMessageHashInfo(for snode: Snode, namespace: Int, associatedWith publicKey: String) { - // Delete any expired SnodeReceivedMessageInfo values associated to a specific node + // Delete any expired SnodeReceivedMessageInfo values associated to a specific node (even though + // this runs very quickly we fetch the rowIds we want to delete from a 'read' call to avoid + // blocking the write queue since this method is called very frequently) + let rowIds: [Int64] = Storage.shared + .read { db in + // Only prune the hashes if new hashes exist for this Snode (if they don't then we don't want + // to clear out the legacy hashes) + let hasNonLegacyHash: Bool = try SnodeReceivedMessageInfo + .filter(SnodeReceivedMessageInfo.Columns.key == key(for: snode, publicKey: publicKey, namespace: namespace)) + .isNotEmpty(db) + + guard hasNonLegacyHash else { return [] } + + return try SnodeReceivedMessageInfo + .select(Column.rowID) + .filter(SnodeReceivedMessageInfo.Columns.key == key(for: snode, publicKey: publicKey, namespace: namespace)) + .filter(SnodeReceivedMessageInfo.Columns.expirationDateMs <= (Date().timeIntervalSince1970 * 1000)) + .asRequest(of: Int64.self) + .fetchAll(db) + } + .defaulting(to: []) + + // If there are no rowIds to delete then do nothing + guard !rowIds.isEmpty else { return } + Storage.shared.write { db in - // Only prune the hashes if new hashes exist for this Snode (if they don't then we don't want - // to clear out the legacy hashes) - let hasNonLegacyHash: Bool = try SnodeReceivedMessageInfo - .filter(SnodeReceivedMessageInfo.Columns.key == key(for: snode, publicKey: publicKey, namespace: namespace)) - .isNotEmpty(db) - - guard hasNonLegacyHash else { return } - try SnodeReceivedMessageInfo - .filter(SnodeReceivedMessageInfo.Columns.key == key(for: snode, publicKey: publicKey, namespace: namespace)) - .filter(SnodeReceivedMessageInfo.Columns.expirationDateMs <= (Date().timeIntervalSince1970 * 1000)) + .filter(rowIds.contains(Column.rowID)) .deleteAll(db) } } diff --git a/SessionUtilitiesKit/Database/Storage.swift b/SessionUtilitiesKit/Database/Storage.swift index 87e283918..65d515f4b 100644 --- a/SessionUtilitiesKit/Database/Storage.swift +++ b/SessionUtilitiesKit/Database/Storage.swift @@ -60,6 +60,7 @@ public final class Storage { // Configure the database and create the DatabasePool for interacting with the database var config = Configuration() config.maximumReaderCount = 10 // Increase the max read connection limit - Default is 5 + config.observesSuspensionNotifications = true // Minimise `0xDEAD10CC` exceptions config.prepareDatabase { db in var keySpec: Data = Storage.getOrGenerateDatabaseKeySpec() defer { keySpec.resetBytes(in: 0.. Value? { + guard let key: Key = key else { return nil } + + return self[key] + } + func setting(_ key: Key?, _ value: Value?) -> [Key: Value] { guard let key: Key = key else { return self } diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index 0c1950947..c84a11283 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -126,6 +126,9 @@ public final class JobRunner { queues.mutate { $0[updatedJob.variant]?.add(updatedJob, canStartJob: canStartJob) } + // Don't start the queue if the job can't be started + guard canStartJob else { return } + // Start the job runner if needed db.afterNextTransactionCommit { _ in queues.wrappedValue[updatedJob.variant]?.start() @@ -253,6 +256,15 @@ public final class JobRunner { JobRunner.hasCompletedInitialBecomeActive.mutate { $0 = true } } + /// Calling this will clear the JobRunner queues and stop it from running new jobs, any currently executing jobs will continue to run + /// though (this means if we suspend the database it's likely that any currently running jobs will fail to complete and fail to record their + /// failure - they _should_ be picked up again the next time the app is launched) + public static func stopAndClearPendingJobs() { + queues.wrappedValue.values.forEach { queue in + queue.stopAndClearPendingJobs() + } + } + public static func isCurrentlyRunning(_ job: Job?) -> Bool { guard let job: Job = job, let jobId: Int64 = job.id else { return false } @@ -347,6 +359,8 @@ private final class JobQueue { } } + private static let deferralLoopThreshold: Int = 3 + private let type: QueueType private let executionType: ExecutionType private let qosClass: DispatchQoS @@ -376,6 +390,7 @@ private final class JobQueue { private var queue: Atomic<[Job]> = Atomic([]) private var jobsCurrentlyRunning: Atomic> = Atomic([]) private var detailsForCurrentlyRunningJobs: Atomic<[Int64: Data?]> = Atomic([:]) + private var deferLoopTracker: Atomic<[Int64: (count: Int, times: [TimeInterval])]> = Atomic([:]) fileprivate var hasPendingJobs: Bool { !queue.wrappedValue.isEmpty } @@ -555,7 +570,16 @@ private final class JobQueue { runNextJob() } + fileprivate func stopAndClearPendingJobs() { + isRunning.mutate { $0 = false } + queue.mutate { $0 = [] } + deferLoopTracker.mutate { $0 = [:] } + } + private func runNextJob() { + // Ensure the queue is running (if we've stopped the queue then we shouldn't start the next job) + guard isRunning.wrappedValue else { return } + // Ensure this is running on the correct queue guard DispatchQueue.getSpecific(key: queueKey) == queueContext else { internalQueue.async { [weak self] in @@ -652,7 +676,7 @@ private final class JobQueue { return } - // Update the state to indicate it's running + // Update the state to indicate the particular job is running // // Note: We need to store 'numJobsRemaining' in it's own variable because // the 'SNLog' seems to dispatch to it's own queue which ends up getting @@ -662,7 +686,6 @@ private final class JobQueue { trigger?.invalidate() // Need to invalidate to prevent a memory leak trigger = nil } - isRunning.mutate { $0 = true } jobsCurrentlyRunning.mutate { jobsCurrentlyRunning in jobsCurrentlyRunning = jobsCurrentlyRunning.inserting(nextJob.id) numJobsRunning = jobsCurrentlyRunning.count @@ -779,13 +802,20 @@ private final class JobQueue { // `failureCount` and `nextRunTimestamp` to prevent them from endlessly running over // and over and reset their retry backoff in case they fail next time case .recurringOnLaunch, .recurringOnActive: - Storage.shared.write { db in - _ = try job - .with( - failureCount: 0, - nextRunTimestamp: 0 - ) - .saved(db) + if + let jobId: Int64 = job.id, + job.failureCount != 0 && + job.nextRunTimestamp > TimeInterval.leastNonzeroMagnitude + { + Storage.shared.write { db in + _ = try Job + .filter(id: jobId) + .updateAll( + db, + Job.Columns.failureCount.set(to: 0), + Job.Columns.nextRunTimestamp.set(to: 0) + ) + } } default: break @@ -927,8 +957,48 @@ private final class JobQueue { /// This function is called when a job neither succeeds or fails (this should only occur if the job has specific logic that makes it dependant /// on other jobs, and it should automatically manage those dependencies) private func handleJobDeferred(_ job: Job) { + var stuckInDeferLoop: Bool = false jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) } detailsForCurrentlyRunningJobs.mutate { $0 = $0.removingValue(forKey: job.id) } + deferLoopTracker.mutate { + guard let lastRecord: (count: Int, times: [TimeInterval]) = $0[job.id] else { + $0 = $0.setting( + job.id, + (1, [Date().timeIntervalSince1970]) + ) + return + } + + let timeNow: TimeInterval = Date().timeIntervalSince1970 + stuckInDeferLoop = ( + lastRecord.count >= JobQueue.deferralLoopThreshold && + (timeNow - lastRecord.times[0]) < CGFloat(lastRecord.count) + ) + + $0 = $0.setting( + job.id, + ( + lastRecord.count + 1, + // Only store the last 'deferralLoopThreshold' times to ensure we aren't running faster + // than one loop per second + lastRecord.times.suffix(JobQueue.deferralLoopThreshold - 1) + [timeNow] + ) + ) + } + + // It's possible (by introducing bugs) to create a loop where a Job tries to run and immediately + // defers itself but then attempts to run again (resulting in an infinite loop); this won't block + // the app since it's on a background thread but can result in 100% of a CPU being used (and a + // battery drain) + // + // This code will maintain an in-memory store for any jobs which are deferred too quickly (ie. + // more than 'deferralLoopThreshold' times within 'deferralLoopThreshold' seconds) + guard !stuckInDeferLoop else { + deferLoopTracker.mutate { $0 = $0.removingValue(forKey: job.id) } + handleJobFailed(job, error: JobRunnerError.possibleDeferralLoop, permanentFailure: false) + return + } + internalQueue.async { [weak self] in self?.runNextJob() } diff --git a/SessionUtilitiesKit/JobRunner/JobRunnerError.swift b/SessionUtilitiesKit/JobRunner/JobRunnerError.swift index 15e2b23a2..8d015095d 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunnerError.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunnerError.swift @@ -11,4 +11,6 @@ public enum JobRunnerError: Error { case missingRequiredDetails case missingDependencies + + case possibleDeferralLoop } From 3f63a44c319170c6330108063c39957b7ac7fab0 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 5 Aug 2022 17:11:14 +1000 Subject: [PATCH 155/157] Increased the build number --- Session.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 6c20b39d0..c7ca1ac9c 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -6818,7 +6818,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 363; + CURRENT_PROJECT_VERSION = 365; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -6890,7 +6890,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 363; + CURRENT_PROJECT_VERSION = 365; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", From 2025fd263834dfcbbab39f12f8849d038aa3e0b9 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 8 Aug 2022 13:56:11 +1000 Subject: [PATCH 156/157] Fixed a crash and the SOGS deletion logic to continue to support the deprecated approach for the time being Fixed an issue where the app could crash when entering the background during migration Added the old 'messageServerIdsToRemove' code back for the time being to support the deprecated deletion behaviour --- Session/Meta/AppDelegate.swift | 2 +- .../Open Groups/OpenGroupManager.swift | 8 ++++++-- .../Pollers/ClosedGroupPoller.swift | 18 ++++++------------ 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index affde2523..9e79ae3cf 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -521,7 +521,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD poller.stop() } - ClosedGroupPoller.shared.stop() + ClosedGroupPoller.shared.stopAllPollers() OpenGroupManager.shared.stopPolling() } diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index ea750d7cd..0c73af2cc 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -501,7 +501,7 @@ public final class OpenGroupManager: NSObject { let sortedMessages: [OpenGroupAPI.Message] = messages .filter { $0.deleted != true } .sorted { lhs, rhs in lhs.id < rhs.id } - let messageServerIdsToRemove: [Int64] = messages + var messageServerIdsToRemove: [Int64] = messages .filter { $0.deleted == true } .map { $0.id } let seqNo: Int64? = sortedMessages.map { $0.seqNo }.max() @@ -518,7 +518,11 @@ public final class OpenGroupManager: NSObject { guard let base64EncodedString: String = message.base64EncodedData, let data = Data(base64Encoded: base64EncodedString) - else { return } + else { + // FIXME: Once the SOGS Emoji Reacts update is live we should remove this line (deprecated by the `deleted` flag) + messageServerIdsToRemove.append(Int64(message.id)) + return + } do { let processedMessage: ProcessedMessage? = try Message.processReceivedOpenGroupMessage( diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift index ad5c8cda5..e87643a1b 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift @@ -65,18 +65,12 @@ public final class ClosedGroupPoller { setUpPolling(for: groupPublicKey) } - @objc public func stop() { - Storage.shared - .read { db in - try ClosedGroup - .select(.threadId) - .asRequest(of: String.self) - .fetchAll(db) - } - .defaulting(to: []) - .forEach { [weak self] groupPublicKey in - self?.stopPolling(for: groupPublicKey) - } + public func stopAllPollers() { + let pollers: [String] = Array(isPolling.wrappedValue.keys) + + pollers.forEach { groupPublicKey in + self.stopPolling(for: groupPublicKey) + } } public func stopPolling(for groupPublicKey: String) { From 09f2b4124281210060834438d27553ad013b616b Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 8 Aug 2022 15:10:33 +1000 Subject: [PATCH 157/157] Updated the build number --- Session.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index c7ca1ac9c..ed849e696 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -6818,7 +6818,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 365; + CURRENT_PROJECT_VERSION = 366; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -6890,7 +6890,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 365; + CURRENT_PROJECT_VERSION = 366; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)",